Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/core-manager/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -258,11 +258,17 @@ export class CoreManager extends TypedEmitter {
*/
async close() {
this.#state = 'closing'

// Closes all cores in the index
const promises = []
for (const { core } of this.#coreIndex) {
promises.push(core.close())
}
await Promise.all(promises)

// Closes sessions in the corestore
await this.#corestore.close()

this.#state = 'closed'
}

Expand Down
15 changes: 10 additions & 5 deletions src/invite/invite-api.js
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ export class InviteApi extends TypedEmitter {
* @param {Object} options
* @param {import('../local-peers.js').LocalPeers} options.rpc
* @param {object} options.queries
* @param {(projectInviteId: Readonly<Buffer>) => undefined | { projectPublicId: string }} options.queries.getProjectByInviteId
* @param {(projectInviteId: Readonly<Buffer>) => undefined | { projectPublicId: string, hasLeftProject: boolean }} options.queries.getProjectByInviteId
* @param {AddProjectQuery} options.queries.addProject
* @param {Logger} [options.logger]
*/
Expand Down Expand Up @@ -138,7 +138,11 @@ export class InviteApi extends TypedEmitter {
return
}

const isAlreadyMember = Boolean(this.#getProjectByInviteId(projectInviteId))
const existingProject = this.#getProjectByInviteId(projectInviteId)
const isAlreadyMember = existingProject
? !existingProject.hasLeftProject
: false

if (isAlreadyMember) {
this.#l.log('Invite %h: already in project', inviteId)
this.rpc
Expand Down Expand Up @@ -169,9 +173,10 @@ export class InviteApi extends TypedEmitter {
guards: {
isNotAlreadyJoiningOrInProject: () => {
const isJoining = this.#isJoiningProject(projectInviteId)
const isAlreadyMember = Boolean(
this.#getProjectByInviteId(projectInviteId)
)
const existingProject = this.#getProjectByInviteId(projectInviteId)
const isAlreadyMember = existingProject
? !existingProject.hasLeftProject
: false
return !isJoining && !isAlreadyMember
},
},
Expand Down
14 changes: 11 additions & 3 deletions src/mapeo-manager.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { randomBytes } from 'crypto'
import path from 'path'
import { KeyManager } from '@mapeo/crypto'
import Database from 'better-sqlite3'
import { eq } from 'drizzle-orm'
import { eq, and } from 'drizzle-orm'
import { drizzle } from 'drizzle-orm/better-sqlite3'
import Hypercore from 'hypercore'
import { TypedEmitter } from 'tiny-typed-emitter'
Expand Down Expand Up @@ -694,11 +694,15 @@ export class MapeoManager extends TypedEmitter {
const projectExists = this.#db
.select()
.from(projectKeysTable)
.where(eq(projectKeysTable.projectId, projectId))
.where(
and(
eq(projectKeysTable.projectId, projectId),
eq(projectKeysTable.hasLeftProject, false)
)
)
.get()

if (projectExists) {
// TODO: Define behavior for adding a project that the user has left
throw new Error(`Project with ID ${projectPublicId} already exists`)
}

Expand All @@ -719,6 +723,7 @@ export class MapeoManager extends TypedEmitter {
projectDescription,
sendStats,
},
hasLeftProject: false,
})

// Any errors from here we need to remove project from db because it has not
Expand Down Expand Up @@ -1064,6 +1069,9 @@ export class MapeoManager extends TypedEmitter {
const project = await this.getProject(projectPublicId)

await project[kProjectLeave]()

// Sync any role changes from project leave
await this.#waitForInitialSync(project)
}

async getMapStyleJsonUrl() {
Expand Down
2 changes: 2 additions & 0 deletions src/mapeo-project.js
Original file line number Diff line number Diff line change
Expand Up @@ -428,6 +428,8 @@ export class MapeoProject extends TypedEmitter {
})

this.#blobStore.on('error', (err) => {
// Ignore hypercore inflight request cancellation
if (ensureError(err).message.includes('REQUEST_CANCELLED')) return
// TODO: Handle this error in some way - this error will come from an
// unexpected error with background blob downloads
console.error('BlobStore error', err)
Expand Down
91 changes: 91 additions & 0 deletions test-e2e/members.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
} from './utils.js'
import { kDataTypes } from '../src/mapeo-project.js'
import { pEvent } from 'p-event'
import { InviteResponse_Decision } from '../src/generated/rpc.js'
/** @import { MapeoProject } from '../src/mapeo-project.js' */
/** @import { RoleId } from '../src/roles.js' */

Expand Down Expand Up @@ -792,3 +793,93 @@ test('remove member from project with reason', async (t) => {

await invitee.leaveProject(projectId)
})

test('remove member from project, add them back', async (t) => {
const managers = await createManagers(2, t)
const [invitor, invitee] = managers
const disconnectPeers = connectPeers(managers)
t.after(disconnectPeers)

const projectId = await invitor.createProject({ name: 'Mapeo' })

await invite({
invitor,
projectId,
invitees: [invitee],
})

const inviteeProject = await invitee.getProject(projectId)
const invitorProject = await invitor.getProject(projectId)

const onRoleChange = pEvent(inviteeProject, 'own-role-change', {
timeout: 1_000,
})

await invitorProject.$member.remove(invitee.deviceId)

await waitForSync([inviteeProject, invitorProject], 'initial')

const updatedRole = await onRoleChange

assert.equal(
updatedRole.role.roleId,
BLOCKED_ROLE_ID,
'invitee sees they were removed'
)

assert.equal(updatedRole.role.reason, undefined, 'No reason for removal')

await invitee.leaveProject(projectId)
await inviteeProject.close()

await invite({
invitor,
projectId,
invitees: [invitee],
})

const reinviteeProject = await invitee.getProject(projectId)

await waitForSync([reinviteeProject, invitorProject], 'initial')

const reRole = await reinviteeProject.$getOwnRole()
assert.equal(reRole.roleId, MEMBER_ROLE_ID, 'Sees self as a member again')
})

test('Auto deny invites if invited before remove is processed', async (t) => {
const managers = await createManagers(2, t)
const [invitor, invitee] = managers
const disconnectPeers = connectPeers(managers)
t.after(disconnectPeers)

const projectId = await invitor.createProject({ name: 'Mapeo' })

await invite({
invitor,
projectId,
invitees: [invitee],
})

const invitorProject = await invitor.getProject(projectId)
const inviteeProject = await invitee.getProject(projectId)

const onRoleChange = pEvent(inviteeProject, 'own-role-change', {
timeout: 1_000,
})

await invitorProject.$member.remove(invitee.deviceId)

await waitForSync([inviteeProject, invitorProject], 'initial')

await onRoleChange

const response = await invitorProject.$member.invite(invitee.deviceId, {
roleId: MEMBER_ROLE_ID,
})

assert.equal(
response,
InviteResponse_Decision.ALREADY,
'Auto rejects before leave project is called'
)
})
69 changes: 64 additions & 5 deletions test-e2e/project-leave.js
Original file line number Diff line number Diff line change
Expand Up @@ -167,8 +167,6 @@ test('Member can leave project if creator exists', async (t) => {
'member now has LEFT role'
)

await waitForSync(projects, 'initial')

assert.equal(
(await creatorProject.$member.getById(member.deviceId)).role.roleId,
LEFT_ROLE_ID,
Expand Down Expand Up @@ -224,8 +222,6 @@ test('Data access after leaving project', async (t) => {
member.leaveProject(projectId),
])

await waitForSync(projects, 'initial')

await assert.rejects(async () => {
await memberProject.observation.create(valueOf(generate('observation')[0]))
}, 'member cannot create new data after leaving')
Expand Down Expand Up @@ -420,4 +416,67 @@ test('leaving a project before PR#1125 persists after PR#1125', async (t) => {
assert.equal(projectsList.length, 0, 'no projects listed')
})

// TODO: Add test for leaving and rejoining a project
test('Member can join project again after leaving', async (t) => {
const managers = await createManagers(2, t)

const disconnectPeers = connectPeers(managers)
t.after(disconnectPeers)

const [creator, member] = managers
const projectId = await creator.createProject({ name: 'mapeo' })

await invite({
invitor: creator,
invitees: [member],
projectId,
roleId: MEMBER_ROLE_ID,
})

const projects = await Promise.all(
managers.map((m) => m.getProject(projectId))
)

const [creatorProject, memberProject] = projects

assert(
await creatorProject.$member.getById(member.deviceId),
'member successfully added from creator perspective'
)

assert(
await memberProject.$member.getById(member.deviceId),
'creator successfully added from creator perspective'
)

await waitForSync(projects, 'initial')

await member.leaveProject(projectId)

// Close the project after you leave and sync
// This clears up resources so we can be reinvited
await memberProject.close()

await invite({
invitor: creator,
invitees: [member],
projectId,
roleId: MEMBER_ROLE_ID,
})

// We need to re-get the project since it was closed
const reMemberProject = await member.getProject(projectId)

assert.notEqual(reMemberProject, memberProject, 'new project on rejoin')

await waitForSync([creatorProject, reMemberProject], 'initial')

assert(
await creatorProject.$member.getById(member.deviceId),
'member successfully added from creator perspective'
)

assert(
await reMemberProject.$member.getById(member.deviceId),
'creator successfully added from creator perspective'
)
})
6 changes: 4 additions & 2 deletions test/invite-api.js
Original file line number Diff line number Diff line change
Expand Up @@ -299,7 +299,9 @@ test('Receiving invite for project that peer already belongs to', async (t) => {
rpc,
queries: {
getProjectByInviteId: (p) =>
p === projectInviteId ? { projectPublicId } : undefined,
p === projectInviteId
? { projectPublicId, hasLeftProject: false }
: undefined,
addProject: async () => {
assert.fail('should not add project')
},
Expand Down Expand Up @@ -355,7 +357,7 @@ test('Receiving invite for project that peer already belongs to', async (t) => {
rpc,
queries: {
getProjectByInviteId: () =>
isMember ? { projectPublicId } : undefined,
isMember ? { projectPublicId, hasLeftProject: false } : undefined,
addProject: async () => {
assert.fail('should not add project')
},
Expand Down