Skip to content

Commit

Permalink
feat: add invite namespace to MapeoManager (#281)
Browse files Browse the repository at this point in the history
  • Loading branch information
achou11 committed Sep 28, 2023
1 parent c9387bd commit 021ef15
Show file tree
Hide file tree
Showing 3 changed files with 225 additions and 0 deletions.
36 changes: 36 additions & 0 deletions src/mapeo-manager.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 */

Expand All @@ -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
Expand All @@ -45,6 +48,7 @@ export class MapeoManager {
#dbFolder
#deviceId
#rpc
#invite

/**
* @param {Object} opts
Expand Down Expand Up @@ -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
Expand All @@ -84,6 +106,13 @@ export class MapeoManager {
}
}

/**
* MapeoRPC instance, used for tests
*/
get [kRPC]() {
return this.#rpc
}

/**
* @param {Buffer} keysCipher
* @param {string} projectId
Expand Down Expand Up @@ -367,4 +396,11 @@ export class MapeoManager {
.get()
return row ? row.deviceInfo : {}
}

/**
* @returns {InviteApi}
*/
get invite() {
return this.#invite
}
}
179 changes: 179 additions & 0 deletions test-e2e/manager-invite.js
Original file line number Diff line number Diff line change
@@ -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)
})
10 changes: 10 additions & 0 deletions tests/helpers/rpc.js
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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() {
Expand Down

0 comments on commit 021ef15

Please sign in to comment.