From 021ef153678527063911006b6b6840187001c787 Mon Sep 17 00:00:00 2001 From: Andrew Chou Date: Thu, 28 Sep 2023 13:10:13 -0400 Subject: [PATCH] feat: add invite namespace to MapeoManager (#281) --- src/mapeo-manager.js | 36 ++++++++ test-e2e/manager-invite.js | 179 +++++++++++++++++++++++++++++++++++++ tests/helpers/rpc.js | 10 +++ 3 files changed, 225 insertions(+) create mode 100644 test-e2e/manager-invite.js diff --git a/src/mapeo-manager.js b/src/mapeo-manager.js index 525479ff..a31cb680 100644 --- a/src/mapeo-manager.js +++ b/src/mapeo-manager.js @@ -22,6 +22,7 @@ import { } from './utils.js' import { RandomAccessFilePool } from './core-manager/random-access-file-pool.js' import { MapeoRPC } from './rpc/index.js' +import { InviteApi } from './invite-api.js' /** @typedef {import("@mapeo/schema").ProjectSettingsValue} ProjectValue */ @@ -33,6 +34,8 @@ const CLIENT_SQLITE_FILE_NAME = 'client.db' // other things e.g. SQLite and other parts of the app. const MAX_FILE_DESCRIPTORS = 768 +export const kRPC = Symbol('rpc') + export class MapeoManager { #keyManager #projectSettingsIndexWriter @@ -45,6 +48,7 @@ export class MapeoManager { #dbFolder #deviceId #rpc + #invite /** * @param {Object} opts @@ -75,6 +79,24 @@ export class MapeoManager { }) this.#activeProjects = new Map() + this.#invite = new InviteApi({ + rpc: this.#rpc, + queries: { + isMember: async (projectId) => { + const projectExists = this.#db + .select() + .from(projectKeysTable) + .where(eq(projectKeysTable.projectId, projectId)) + .get() + + return !!projectExists + }, + addProject: async (invite) => { + await this.addProject(invite) + }, + }, + }) + if (typeof coreStorage === 'string') { const pool = new RandomAccessFilePool(MAX_FILE_DESCRIPTORS) // @ts-ignore @@ -84,6 +106,13 @@ export class MapeoManager { } } + /** + * MapeoRPC instance, used for tests + */ + get [kRPC]() { + return this.#rpc + } + /** * @param {Buffer} keysCipher * @param {string} projectId @@ -367,4 +396,11 @@ export class MapeoManager { .get() return row ? row.deviceInfo : {} } + + /** + * @returns {InviteApi} + */ + get invite() { + return this.#invite + } } diff --git a/test-e2e/manager-invite.js b/test-e2e/manager-invite.js new file mode 100644 index 00000000..437ba429 --- /dev/null +++ b/test-e2e/manager-invite.js @@ -0,0 +1,179 @@ +import { test } from 'brittle' +import { KeyManager } from '@mapeo/crypto' +import pDefer from 'p-defer' +import RAM from 'random-access-memory' +import { MEMBER_ROLE_ID } from '../src/capabilities.js' +import { InviteResponse_Decision } from '../src/generated/rpc.js' +import { MapeoManager, kRPC } from '../src/mapeo-manager.js' +import { replicate } from '../tests/helpers/rpc.js' + +test('member invite accepted', async (t) => { + t.plan(10) + + const deferred = pDefer() + + const creator = new MapeoManager({ + rootKey: KeyManager.generateRootKey(), + dbFolder: ':memory:', + coreStorage: () => new RAM(), + }) + + await creator.setDeviceInfo({ name: 'Creator' }) + + const createdProjectId = await creator.createProject({ name: 'Mapeo' }) + const creatorProject = await creator.getProject(createdProjectId) + creator[kRPC].on('peers', async (peers) => { + t.is(peers.length, 1) + + const response = await creatorProject.$member.invite(peers[0].id, { + roleId: MEMBER_ROLE_ID, + }) + + t.is(response, InviteResponse_Decision.ACCEPT) + + deferred.resolve() + }) + + /** @type {string | undefined} */ + let expectedInvitorPeerId + + const joiner = new MapeoManager({ + rootKey: KeyManager.generateRootKey(), + dbFolder: ':memory:', + coreStorage: () => new RAM(), + }) + + await joiner.setDeviceInfo({ name: 'Joiner' }) + + t.exception( + async () => joiner.getProject(createdProjectId), + 'joiner cannot get project instance before being invited and added to project' + ) + + joiner[kRPC].on('peers', (peers) => { + t.is(peers.length, 1) + expectedInvitorPeerId = peers[0].id + }) + + joiner.invite.on('invite-received', async (invite) => { + t.is(invite.projectId, createdProjectId) + t.is(invite.peerId, expectedInvitorPeerId) + t.is(invite.projectName, 'Mapeo') + // TODO: Check role being invited for (needs https://github.com/digidem/mapeo-core-next/issues/275) + + await joiner.invite.accept(invite.projectId) + }) + + replicate(creator[kRPC], joiner[kRPC]) + + await deferred.promise + + /// After invite flow has completed... + + const joinerListedProjects = await joiner.listProjects() + + t.is(joinerListedProjects.length, 1, 'project added to joiner') + t.alike( + joinerListedProjects[0], + { + name: 'Mapeo', + projectId: createdProjectId, + createdAt: undefined, + updatedAt: undefined, + }, + 'project info recorded in joiner successfully' + ) + + const joinerProject = await joiner.getProject( + joinerListedProjects[0].projectId + ) + + t.ok(joinerProject, 'can create joiner project instance') + + // TODO: Get project settings of joiner and ensure they're similar to creator's project's settings + // const joinerProjectSettings = await joinerProject.$getProjectSettings() + // t.alike(joinerProjectSettings, { defaultPresets: undefined, name: 'Mapeo' }) + + // TODO: Get members of creator project and assert info matches joiner + // const creatorProjectMembers = await creatorProject.$member.getMany() + // t.is(creatorProjectMembers.length, 1) + // t.alike(creatorProjectMembers[0], await joiner.getDeviceInfo()) +}) + +test('member invite rejected', async (t) => { + t.plan(9) + + const deferred = pDefer() + + const creator = new MapeoManager({ + rootKey: KeyManager.generateRootKey(), + dbFolder: ':memory:', + coreStorage: () => new RAM(), + }) + + await creator.setDeviceInfo({ name: 'Creator' }) + + const createdProjectId = await creator.createProject({ name: 'Mapeo' }) + const creatorProject = await creator.getProject(createdProjectId) + + creator[kRPC].on('peers', async (peers) => { + t.is(peers.length, 1) + + const response = await creatorProject.$member.invite(peers[0].id, { + roleId: MEMBER_ROLE_ID, + }) + + t.is(response, InviteResponse_Decision.REJECT) + + deferred.resolve() + }) + + /** @type {string | undefined} */ + let expectedInvitorPeerId + + const joiner = new MapeoManager({ + rootKey: KeyManager.generateRootKey(), + dbFolder: ':memory:', + coreStorage: () => new RAM(), + }) + + await joiner.setDeviceInfo({ name: 'Joiner' }) + + t.exception( + async () => joiner.getProject(createdProjectId), + 'joiner cannot get project instance before being invited and added to project' + ) + + joiner[kRPC].on('peers', (peers) => { + t.is(peers.length, 1) + expectedInvitorPeerId = peers[0].id + }) + + joiner.invite.on('invite-received', async (invite) => { + t.is(invite.projectId, createdProjectId) + t.is(invite.peerId, expectedInvitorPeerId) + t.is(invite.projectName, 'Mapeo') + // TODO: Check role being invited for (needs https://github.com/digidem/mapeo-core-next/issues/275) + + await joiner.invite.reject(invite.projectId) + }) + + replicate(creator[kRPC], joiner[kRPC]) + + await deferred.promise + + /// After invite flow has completed... + + const joinerListedProjects = await joiner.listProjects() + + t.is(joinerListedProjects.length, 0, 'project not added to joiner') + + await t.exception( + async () => joiner.getProject(createdProjectId), + 'joiner cannot get project instance' + ) + + // TODO: Get members of creator project and assert joiner not added + // const creatorProjectMembers = await creatorProject.$member.getMany() + // t.is(creatorProjectMembers.length, 0) +}) diff --git a/tests/helpers/rpc.js b/tests/helpers/rpc.js index 544f6365..f7a7a97b 100644 --- a/tests/helpers/rpc.js +++ b/tests/helpers/rpc.js @@ -1,5 +1,11 @@ import NoiseSecretStream from '@hyperswarm/secret-stream' +/** + * @param {import('../../src/rpc/index.js').MapeoRPC} rpc1 + * @param {import('../../src/rpc/index.js').MapeoRPC} rpc2 + * @param { {kp1?: import('@hyperswarm/secret-stream'), kp2?: import('@hyperswarm/secret-stream')} } [keyPairs] + * @returns {() => Promise<[void, void]>} + */ export function replicate( rpc1, rpc2, @@ -15,9 +21,13 @@ export function replicate( const n2 = new NoiseSecretStream(false, undefined, { keyPair: kp2, }) + + // @ts-expect-error n1.rawStream.pipe(n2.rawStream).pipe(n1.rawStream) + // @ts-expect-error rpc1.connect(n1) + // @ts-expect-error rpc2.connect(n2) return async function destroy() {