import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts' import * as TestApp from '../../../../test/App.js' import type * as App from '../../../App.js' import * as Invitations from '../../../db/tables/invitations.js' import * as InvitationsApp from './invitations.js' import * as Memberships from '../../../db/tables/memberships.js' import * as Metrics from '../../../Metrics.js' import * as RateLimit from '../../../internal/RateLimit.js' import * as Store from '../../../internal/Store.js' import * as Orgs from './orgs.js' import * as Organizations from '../../../db/tables/organizations.js' import * as Users from '../../../db/tables/users.js' /** Origin pinned for SIWE domain binding; Hono test requests use this host. */ const origin = 'http://localhost' /** RequestInit for a JSON mutation carrying the session cookie. */ function json(method: 'PATCH' | 'POST', cookie: string, body: unknown) { return { body: JSON.stringify(body), headers: { 'content-type': 'application/json', cookie }, method, } } /** In-memory email sender capturing dispatched messages. */ function sender() { const sent: App.Email.Message[] = [] const email = { from: 'noreply@tempo.test', send: (message: App.Email.Message) => { sent.push(message) return Promise.resolve() }, } satisfies App.Email return { email, sent } } /** Super admin secret configured on the test app. */ const secret = 'tempo:sk:d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3' /** Organization management credential used by API-key route tests. */ const management = { id: 'key_management', orgId: 'org_management', scopes: ['management:read', 'management:write'], token: 'secret_management_key', } satisfies TestApp.kvStore.Key function createApp( options: { db?: ReturnType email?: App.Email kv?: { store: ReturnType } metrics?: TestApp.create.Options['metrics'] rateLimit?: TestApp.create.Options['rateLimit'] } = {}, ) { return TestApp.create({ auth: { superAdmin: { secret } }, db: options.db ?? TestApp.database(), ...(options.email ? { email: options.email } : {}), ...(options.kv ? { kv: options.kv } : {}), ...(options.metrics ? { metrics: options.metrics } : {}), ...(options.rateLimit ? { rateLimit: options.rateLimit } : {}), session: { wallet: { origin } }, }) } /** Signs in a fresh scripted account; returns its cookie and user id. */ async function session(app: ReturnType) { const account = privateKeyToAccount(generatePrivateKey()) const { cookie } = await TestApp.signIn(app, account) const me = await app.request('/v1/me', { headers: { cookie: cookie! } }) const { id } = (await me.json()) as { id: string } return { cookie: cookie!, userId: id } } test('publishes generator-ready OpenAPI contracts', async () => { const spec = await (await createApp().request('/openapi.json')).json() const collection = spec.paths['/v1/orgs/{orgId}/invitations'] const detail = spec.paths['/v1/orgs/{orgId}/invitations/{invitationId}'] const mine = spec.paths['/v1/invitations'] const accept = spec.paths['/v1/invitations/{invitationId}/accept'].post const decline = spec.paths['/v1/invitations/{invitationId}/decline'].post expect({ invitationList: spec.components.schemas.InvitationList.properties.data.examples, myInvitation: spec.components.schemas.MyInvitation.description, myInvitationList: spec.components.schemas.MyInvitationList.properties.data.examples, }).toEqual({ invitationList: [[]], myInvitation: "One pending invitation addressed to the caller, with the inviting organization's name.", myInvitationList: [[]], }) expect({ components: [ 'AcceptInvitationResponse', 'CreateInvitationRequest', 'DeclineInvitationResponse', 'Invitation', 'InvitationList', 'MyInvitation', 'MyInvitationList', 'RevokeInvitationResponse', ].filter((name) => spec.components.schemas[name]), create: { errors: { 400: collection.post.responses[400].content['application/json'].schema, 403: collection.post.responses[403].content['application/json'].schema, 404: collection.post.responses[404].content['application/json'].schema, 429: collection.post.responses[429].content['application/json'].schema, }, operationId: collection.post.operationId, request: collection.post.requestBody.content['application/json'].schema, response: collection.post.responses[200].content['application/json'].schema, }, formats: { createdAt: spec.components.schemas.Invitation.properties.createdAt.format, email: spec.components.schemas.Invitation.properties.email.format, expiresAt: spec.components.schemas.Invitation.properties.expiresAt.format, }, list: { errors: { 400: collection.get.responses[400].content['application/json'].schema, 403: collection.get.responses[403].content['application/json'].schema, 404: collection.get.responses[404].content['application/json'].schema, }, operationId: collection.get.operationId, response: collection.get.responses[200].content['application/json'].schema, }, recipientActions: { accept: { errors: { 400: accept.responses[400].content['application/json'].schema, 403: accept.responses[403].content['application/json'].schema, 404: accept.responses[404].content['application/json'].schema, }, operationId: accept.operationId, response: accept.responses[200].content['application/json'].schema, }, decline: { errors: { 400: decline.responses[400].content['application/json'].schema, 403: decline.responses[403].content['application/json'].schema, 404: decline.responses[404].content['application/json'].schema, }, operationId: decline.operationId, response: decline.responses[200].content['application/json'].schema, }, list: { errors: { 400: mine.get.responses[400].content['application/json'].schema, 403: mine.get.responses[403].content['application/json'].schema, }, operationId: mine.get.operationId, response: mine.get.responses[200].content['application/json'].schema, }, }, revoke: { errors: { 400: detail.delete.responses[400].content['application/json'].schema, 403: detail.delete.responses[403].content['application/json'].schema, 404: detail.delete.responses[404].content['application/json'].schema, }, operationId: detail.delete.operationId, response: detail.delete.responses[200].content['application/json'].schema, }, }).toMatchInlineSnapshot(` { "components": [ "AcceptInvitationResponse", "CreateInvitationRequest", "DeclineInvitationResponse", "Invitation", "InvitationList", "MyInvitation", "MyInvitationList", "RevokeInvitationResponse", ], "create": { "errors": { "400": { "$ref": "#/components/schemas/ApiKeyMalformedOrBodyInvalidOrParamInvalidError", }, "403": { "$ref": "#/components/schemas/ForbiddenError", }, "404": { "$ref": "#/components/schemas/OrganizationNotFoundError", }, "429": { "$ref": "#/components/schemas/RateLimitExceededError", }, }, "operationId": "createInvitation", "request": { "$ref": "#/components/schemas/CreateInvitationRequest", }, "response": { "$ref": "#/components/schemas/Invitation", }, }, "formats": { "createdAt": "date-time", "email": "email", "expiresAt": "date-time", }, "list": { "errors": { "400": { "$ref": "#/components/schemas/ApiKeyMalformedOrParamInvalidError", }, "403": { "$ref": "#/components/schemas/ForbiddenError", }, "404": { "$ref": "#/components/schemas/OrganizationNotFoundError", }, }, "operationId": "listInvitations", "response": { "$ref": "#/components/schemas/InvitationList", }, }, "recipientActions": { "accept": { "errors": { "400": { "$ref": "#/components/schemas/ApiKeyMalformedOrParamInvalidError", }, "403": { "$ref": "#/components/schemas/ForbiddenError", }, "404": { "$ref": "#/components/schemas/InvitationNotFoundError", }, }, "operationId": "acceptInvitation", "response": { "$ref": "#/components/schemas/AcceptInvitationResponse", }, }, "decline": { "errors": { "400": { "$ref": "#/components/schemas/ApiKeyMalformedOrParamInvalidError", }, "403": { "$ref": "#/components/schemas/ForbiddenError", }, "404": { "$ref": "#/components/schemas/InvitationNotFoundError", }, }, "operationId": "declineInvitation", "response": { "$ref": "#/components/schemas/DeclineInvitationResponse", }, }, "list": { "errors": { "400": { "$ref": "#/components/schemas/ApiKeyMalformedError", }, "403": { "$ref": "#/components/schemas/ForbiddenError", }, }, "operationId": "listMyInvitations", "response": { "$ref": "#/components/schemas/MyInvitationList", }, }, }, "revoke": { "errors": { "400": { "$ref": "#/components/schemas/ApiKeyMalformedOrParamInvalidError", }, "403": { "$ref": "#/components/schemas/ForbiddenError", }, "404": { "$ref": "#/components/schemas/InvitationNotFoundOrOrganizationNotFoundError", }, }, "operationId": "revokeInvitation", "response": { "$ref": "#/components/schemas/RevokeInvitationResponse", }, }, } `) }) describe('recordEmailRateLimitDegradation', () => { test('records only the bounded quota scope', () => { const degraded: string[] = [] const metrics = Metrics.from({ count(name, _value, tags) { if (name === 'invitation_email_rate_limit_degraded_count') degraded.push(String(tags?.['scope'])) }, flush() {}, gauge() {}, histogram() {}, }) InvitationsApp.recordEmailRateLimitDegradation({ metrics, scope: 'invitation-email-actor', }) InvitationsApp.recordEmailRateLimitDegradation({ metrics, scope: 'invitation-email-recipient', }) InvitationsApp.recordEmailRateLimitDegradation({ metrics, scope: 'invitation-email-global', }) expect(degraded).toEqual([ 'invitation-email-actor', 'invitation-email-recipient', 'invitation-email-global', ]) }) }) describe('invitations', () => { test('lets management keys manage organization invitations', async () => { const db = TestApp.database() await Organizations.create(db, { id: management.orgId, name: 'Acme' }) const app = createApp({ db, kv: { store: TestApp.kvStore({ keys: [management] }) }, }) const path = `/v1/orgs/${management.orgId}/invitations` const headers = { 'content-type': 'application/json', 'tempo-api-key': management.token, } const created = await app.request(path, { body: JSON.stringify({ email: 'dev@example.com', role: 'owner' }), headers, method: 'POST', }) expect(created.status).toBe(200) const invitation = await TestApp.json(created, InvitationsApp.schema.Invitation) expect(invitation.invitedBy).toBe(management.id) const listed = await app.request(path, { headers }) expect(listed.status).toBe(200) expect( (await TestApp.json(listed, InvitationsApp.schema.listInvitations.Response)).data.map( ({ id }) => id, ), ).toEqual([invitation.id]) expect((await app.request('/v1/invitations', { headers })).status).toBe(403) expect( ( await app.request(`${path}/${invitation.id}`, { headers, method: 'DELETE', }) ).status, ).toBe(200) }) test('invites, surfaces, and accepts into membership', async () => { const db = TestApp.database() const { email, sent } = sender() const app = createApp({ db, email }) const owner = await session(app) const created = await app.request('/v1/orgs', json('POST', owner.cookie, { name: 'Acme' })) const org = await TestApp.json(created, Orgs.schema.Organization) const invited = await app.request( `/v1/orgs/${org.id}/invitations`, json('POST', owner.cookie, { email: 'Dev@Example.com', role: 'member' }), ) expect(invited.status).toBe(200) const invitation = await TestApp.json(invited, InvitationsApp.schema.Invitation) expect(invitation.id).toMatch(/^inv_/) expect(invitation.email).toBe('dev@example.com') // The invitation email dispatched through the app's sender. expect(sent).toHaveLength(1) expect(sent[0]!.to).toBe('dev@example.com') expect(sent[0]!.subject).toContain('Acme') expect(sent[0]!.text).toBe( `You've been invited to join Acme on the Tempo Developer Platform\n\nSign in at https://console.tempo.xyz/?email=dev%40example.com&invitation=${invitation.id} to accept. This invitation expires in 7 days.`, ) expect(sent[0]!.html).toContain('Acme') expect(sent[0]!.html).toContain( `https://console.tempo.xyz/?email=dev%40example.com&invitation=${invitation.id}`, ) // Invisible without a verified email; visible once it matches. const invitee = await session(app) const before = await app.request('/v1/invitations', { headers: { cookie: invitee.cookie } }) expect( (await TestApp.json(before, InvitationsApp.schema.listMyInvitations.Response)).data, ).toEqual([]) await Users.setEmail(db, invitee.userId, 'dev@example.com') const after = await app.request('/v1/invitations', { headers: { cookie: invitee.cookie } }) const mine = await TestApp.json(after, InvitationsApp.schema.listMyInvitations.Response) expect(mine.data.map(({ id, orgName }) => ({ id, orgName }))).toEqual([ { id: invitation.id, orgName: 'Acme' }, ]) const accepted = await app.request(`/v1/invitations/${invitation.id}/accept`, { headers: { cookie: invitee.cookie }, method: 'POST', }) expect(accepted.status).toBe(200) const membership = await TestApp.json(accepted, InvitationsApp.schema.acceptInvitation.Response) // prettier-ignore expect(membership).toEqual({ orgId: org.id, role: 'member', userId: invitee.userId }) // Membership grants access; the pending lists drain on both sides. const read = await app.request(`/v1/orgs/${org.id}`, { headers: { cookie: invitee.cookie } }) expect(read.status).toBe(200) const orgPending = await app.request(`/v1/orgs/${org.id}/invitations`, { headers: { cookie: owner.cookie }, }) expect( (await TestApp.json(orgPending, InvitationsApp.schema.listInvitations.Response)).data, ).toEqual([]) // Accepting again fails: the invitation is settled. const again = await app.request(`/v1/invitations/${invitation.id}/accept`, { headers: { cookie: invitee.cookie }, method: 'POST', }) expect(again.status).toBe(404) }) test('accepting an invitation preserves membership in another organization', async () => { const db = TestApp.database() const app = createApp({ db }) const invitee = await session(app) const createdA = await app.request('/v1/orgs', json('POST', invitee.cookie, { name: 'Org A' })) const orgA = await TestApp.json(createdA, Orgs.schema.Organization) await Users.setEmail(db, invitee.userId, 'dev@example.com') const owner = await session(app) const createdB = await app.request('/v1/orgs', json('POST', owner.cookie, { name: 'Org B' })) const orgB = await TestApp.json(createdB, Orgs.schema.Organization) const invited = await app.request( `/v1/orgs/${orgB.id}/invitations`, json('POST', owner.cookie, { email: 'dev@example.com', role: 'member' }), ) const invitation = await TestApp.json(invited, InvitationsApp.schema.Invitation) const accepted = await app.request(`/v1/invitations/${invitation.id}/accept`, { headers: { cookie: invitee.cookie }, method: 'POST', }) expect(accepted.status).toBe(200) const listed = await app.request('/v1/orgs', { headers: { cookie: invitee.cookie } }) const organizations = await TestApp.json(listed, Orgs.schema.listOrganizations.Response) expect( organizations.data .map(({ id, role }) => ({ id, role })) .sort((a, b) => a.id.localeCompare(b.id)), ).toEqual( [ { id: orgA.id, role: 'owner' }, { id: orgB.id, role: 'member' }, ].sort((a, b) => a.id.localeCompare(b.id)), ) const [readA, readB] = await Promise.all([ app.request(`/v1/orgs/${orgA.id}`, { headers: { cookie: invitee.cookie } }), app.request(`/v1/orgs/${orgB.id}`, { headers: { cookie: invitee.cookie } }), ]) expect(readA.status).toBe(200) expect(readB.status).toBe(200) }) test('accepting elevates an existing member but never demotes', async () => { const db = TestApp.database() const app = createApp({ db }) const owner = await session(app) const created = await app.request('/v1/orgs', json('POST', owner.cookie, { name: 'Acme' })) const org = await TestApp.json(created, Orgs.schema.Organization) // A standing member accepts an admin invitation: the role is elevated. const invitee = await session(app) await Users.setEmail(db, invitee.userId, 'dev@example.com') await Memberships.create(db, { orgId: org.id, role: 'member', userId: invitee.userId }) const promote = await app.request( `/v1/orgs/${org.id}/invitations`, json('POST', owner.cookie, { email: 'dev@example.com', role: 'admin' }), ) const promoteInvite = await TestApp.json(promote, InvitationsApp.schema.Invitation) const promoted = await app.request(`/v1/invitations/${promoteInvite.id}/accept`, { headers: { cookie: invitee.cookie }, method: 'POST', }) expect((await TestApp.json(promoted, InvitationsApp.schema.acceptInvitation.Response)).role).toBe('admin') // prettier-ignore expect((await Memberships.get(db, org.id, invitee.userId))?.role).toBe('admin') // A lower-role invitation to the same member is a no-op, never a demotion. const demote = await app.request( `/v1/orgs/${org.id}/invitations`, json('POST', owner.cookie, { email: 'dev@example.com', role: 'member' }), ) const demoteInvite = await TestApp.json(demote, InvitationsApp.schema.Invitation) const demoted = await app.request(`/v1/invitations/${demoteInvite.id}/accept`, { headers: { cookie: invitee.cookie }, method: 'POST', }) expect((await TestApp.json(demoted, InvitationsApp.schema.acceptInvitation.Response)).role).toBe('admin') // prettier-ignore expect((await Memberships.get(db, org.id, invitee.userId))?.role).toBe('admin') }) test('re-inviting a pending email refreshes the invitation, not duplicates it', async () => { const db = TestApp.database() const app = createApp({ db }) const owner = await session(app) const created = await app.request('/v1/orgs', json('POST', owner.cookie, { name: 'Acme' })) const org = await TestApp.json(created, Orgs.schema.Organization) const first = await app.request( `/v1/orgs/${org.id}/invitations`, json('POST', owner.cookie, { email: 'dev@example.com', role: 'member' }), ) const firstInvite = await TestApp.json(first, InvitationsApp.schema.Invitation) const second = await app.request( `/v1/orgs/${org.id}/invitations`, json('POST', owner.cookie, { email: 'dev@example.com', role: 'admin' }), ) const secondInvite = await TestApp.json(second, InvitationsApp.schema.Invitation) // Same row, refreshed role — a single pending invitation remains. expect(secondInvite.id).toBe(firstInvite.id) expect(secondInvite.role).toBe('admin') const listed = await app.request(`/v1/orgs/${org.id}/invitations`, { headers: { cookie: owner.cookie }, }) const pending = await TestApp.json(listed, InvitationsApp.schema.listInvitations.Response) expect(pending.data).toHaveLength(1) expect(pending.data[0]!.role).toBe('admin') }) test('rate limits invitation emails by actor', async () => { const { email, sent } = sender() const app = createApp({ email, rateLimit: { now: () => new Date('2026-01-01T00:00:00.000Z') }, }) const owner = await session(app) const created = await app.request('/v1/orgs', json('POST', owner.cookie, { name: 'Acme' })) const org = await TestApp.json(created, Orgs.schema.Organization) const path = `/v1/orgs/${org.id}/invitations` for (let index = 0; index < 10; index++) { const response = await app.request( path, json('POST', owner.cookie, { email: `dev${index}@example.com`, role: 'member' }), ) expect(response.status).toBe(200) } const limited = await app.request( path, json('POST', owner.cookie, { email: 'limited@example.com', role: 'member' }), ) expect(await limited.json()).toMatchObject({ error: { code: 'rate_limit_exceeded', message: 'Rate limit exceeded' }, requestId: expect.any(String), }) expect({ limit: limited.headers.get('rateLimit-limit'), remaining: limited.headers.get('rateLimit-remaining'), retryAfterPresent: limited.headers.has('retry-after'), scope: limited.headers.get('rateLimit-scope'), sent: sent.length, status: limited.status, }).toMatchInlineSnapshot(` { "limit": "10", "remaining": "0", "retryAfterPresent": true, "scope": "invitation-email-actor", "sent": 10, "status": 429, } `) }) test('rate limits invitation emails to one recipient across actors', async () => { const { email, sent } = sender() const app = createApp({ email, rateLimit: { now: () => new Date('2026-01-01T00:00:00.000Z') }, }) const owners = await Promise.all([session(app), session(app), session(app)]) const organizations = await Promise.all( owners.map(async (owner, index) => { const created = await app.request( '/v1/orgs', json('POST', owner.cookie, { name: `Organization ${index}` }), ) return TestApp.json(created, Orgs.schema.Organization) }), ) const responses = [] for (const [index, owner] of owners.entries()) responses.push( await app.request( `/v1/orgs/${organizations[index]!.id}/invitations`, json('POST', owner.cookie, { email: 'target@example.com', role: 'member' }), ), ) expect({ scopes: responses.map((response) => response.headers.get('rateLimit-scope')), sent: sent.length, statuses: responses.map((response) => response.status), }).toEqual({ scopes: [null, null, 'invitation-email-recipient'], sent: 2, statuses: [200, 200, 429], }) }) test('caps invitation email dispatch globally', async () => { const now = () => new Date('2026-01-01T00:00:00.000Z') const store = Store.memory() const limiter = RateLimit.memory({ now, store }) for (let index = 0; index < 100; index++) await limiter.consume({ key: 'invitation-email:global', limit: { limit: 100, period: 'minute' }, }) const { email, sent } = sender() const app = createApp({ email, rateLimit: { now, store } }) const owner = await session(app) const created = await app.request('/v1/orgs', json('POST', owner.cookie, { name: 'Acme' })) const org = await TestApp.json(created, Orgs.schema.Organization) const limited = await app.request( `/v1/orgs/${org.id}/invitations`, json('POST', owner.cookie, { email: 'dev@example.com', role: 'member' }), ) expect({ scope: limited.headers.get('rateLimit-scope'), sent: sent.length, status: limited.status, }).toEqual({ scope: 'invitation-email-global', sent: 0, status: 429 }) }) test('enforces the inviter role ladder', async () => { const db = TestApp.database() const app = createApp({ db }) const owner = await session(app) const created = await app.request('/v1/orgs', json('POST', owner.cookie, { name: 'Acme' })) const org = await TestApp.json(created, Orgs.schema.Organization) const admin = await session(app) await Memberships.create(db, { orgId: org.id, role: 'admin', userId: admin.userId }) const member = await session(app) await Memberships.create(db, { orgId: org.id, role: 'member', userId: member.userId }) // Members cannot invite at all; admins cannot grant above their role. const byMember = await app.request(`/v1/orgs/${org.id}/invitations`, json('POST', member.cookie, { email: 'a@example.com', role: 'member' })) // prettier-ignore expect(byMember.status).toBe(403) const escalated = await app.request(`/v1/orgs/${org.id}/invitations`, json('POST', admin.cookie, { email: 'a@example.com', role: 'owner' })) // prettier-ignore expect(escalated.status).toBe(403) const peer = await app.request(`/v1/orgs/${org.id}/invitations`, json('POST', admin.cookie, { email: 'a@example.com', role: 'admin' })) // prettier-ignore expect(peer.status).toBe(200) const byAdmin = await app.request(`/v1/orgs/${org.id}/invitations`, json('POST', admin.cookie, { email: 'a@example.com', role: 'member' })) // prettier-ignore expect(byAdmin.status).toBe(200) const byOwner = await app.request(`/v1/orgs/${org.id}/invitations`, json('POST', owner.cookie, { email: 'b@example.com', role: 'owner' })) // prettier-ignore expect(byOwner.status).toBe(200) }) test('prevents admins from replacing or revoking owner invitations', async () => { const db = TestApp.database() const app = createApp({ db }) const owner = await session(app) const created = await app.request('/v1/orgs', json('POST', owner.cookie, { name: 'Acme' })) const org = await TestApp.json(created, Orgs.schema.Organization) const admin = await session(app) await Memberships.create(db, { orgId: org.id, role: 'admin', userId: admin.userId }) const path = `/v1/orgs/${org.id}/invitations` const ownerInvitation = await TestApp.json( await app.request( path, json('POST', owner.cookie, { email: 'owner@example.com', role: 'owner' }), ), InvitationsApp.schema.Invitation, ) const replaced = await app.request( path, json('POST', admin.cookie, { email: ownerInvitation.email, role: 'member' }), ) const revoked = await app.request(`${path}/${ownerInvitation.id}`, { headers: { cookie: admin.cookie }, method: 'DELETE', }) const pending = await TestApp.json( await app.request(path, { headers: { cookie: owner.cookie } }), InvitationsApp.schema.listInvitations.Response, ) expect([replaced.status, revoked.status]).toEqual([403, 403]) expect(pending.data).toEqual([ownerInvitation]) // Once settled, the same row is no longer disclosed as a protected owner grant. expect( ( await app.request(`${path}/${ownerInvitation.id}`, { headers: { cookie: owner.cookie }, method: 'DELETE', }) ).status, ).toBe(200) expect( ( await app.request(`${path}/${ownerInvitation.id}`, { headers: { cookie: admin.cookie }, method: 'DELETE', }) ).status, ).toBe(404) // An expired owner grant must not reserve its email against an admin replacement. const expired = await Invitations.create(db, { email: 'expired-owner@example.com', expiresAt: '2020-01-01T00:00:00.000Z', invitedBy: owner.userId, orgId: org.id, role: 'owner', }) const refreshed = await app.request( path, json('POST', admin.cookie, { email: expired.email, role: 'member' }), ) expect(refreshed.status).toBe(200) expect((await TestApp.json(refreshed, InvitationsApp.schema.Invitation)).id).toBe(expired.id) }) test('revalidates session authority inside invitation mutations', async () => { const db = TestApp.database() const app = createApp({ db }) const owner = await session(app) const created = await app.request('/v1/orgs', json('POST', owner.cookie, { name: 'Acme' })) const org = await TestApp.json(created, Orgs.schema.Organization) const admin = await session(app) await Memberships.create(db, { orgId: org.id, role: 'admin', userId: admin.userId }) await Promise.all( ['first', 'second'].map((email) => Invitations.create(db, { email: `${email}@example.com`, expiresAt: '2099-01-01T00:00:00.000Z', invitedBy: owner.userId, orgId: org.id, role: 'member', }), ), ) const invitation = await Invitations.create(db, { email: 'pending@example.com', expiresAt: '2099-01-01T00:00:00.000Z', invitedBy: owner.userId, orgId: org.id, role: 'member', }) await Memberships.update(db, org.id, admin.userId, { role: 'member' }) await expect( Invitations.create(db, { callerUserId: admin.userId, email: 'new@example.com', expiresAt: '2099-01-01T00:00:00.000Z', invitedBy: admin.userId, orgId: org.id, role: 'member', }), ).rejects.toBeInstanceOf(Invitations.InvitationRoleForbiddenError) await expect( Invitations.revoke(db, invitation.id, { callerUserId: admin.userId, orgId: org.id, }), ).rejects.toBeInstanceOf(Invitations.InvitationRoleForbiddenError) }) test('revoked and expired invitations cannot be accepted', async () => { const db = TestApp.database() const app = createApp({ db }) const owner = await session(app) const created = await app.request('/v1/orgs', json('POST', owner.cookie, { name: 'Acme' })) const org = await TestApp.json(created, Orgs.schema.Organization) const invitee = await session(app) await Users.setEmail(db, invitee.userId, 'dev@example.com') const invited = await app.request( `/v1/orgs/${org.id}/invitations`, json('POST', owner.cookie, { email: 'dev@example.com', role: 'member' }), ) const invitation = await TestApp.json(invited, InvitationsApp.schema.Invitation) const revoked = await app.request(`/v1/orgs/${org.id}/invitations/${invitation.id}`, { headers: { cookie: owner.cookie }, method: 'DELETE', }) expect(revoked.status).toBe(200) const acceptRevoked = await app.request(`/v1/invitations/${invitation.id}/accept`, { headers: { cookie: invitee.cookie }, method: 'POST', }) expect(acceptRevoked.status).toBe(404) // Expired: seeded directly with a past expiry through the table module. const expired = await Invitations.create(db, { email: 'dev@example.com', expiresAt: '2020-01-01T00:00:00.000Z', invitedBy: owner.userId, orgId: org.id, role: 'member', }) const listed = await app.request('/v1/invitations', { headers: { cookie: invitee.cookie } }) expect( (await TestApp.json(listed, InvitationsApp.schema.listMyInvitations.Response)).data, ).toEqual([]) const acceptExpired = await app.request(`/v1/invitations/${expired.id}/accept`, { headers: { cookie: invitee.cookie }, method: 'POST', }) expect(acceptExpired.status).toBe(404) }) test('declining settles the invitation', async () => { const db = TestApp.database() const app = createApp({ db }) const owner = await session(app) const created = await app.request('/v1/orgs', json('POST', owner.cookie, { name: 'Acme' })) const org = await TestApp.json(created, Orgs.schema.Organization) const invited = await app.request( `/v1/orgs/${org.id}/invitations`, json('POST', owner.cookie, { email: 'dev@example.com', role: 'member' }), ) const invitation = await TestApp.json(invited, InvitationsApp.schema.Invitation) const invitee = await session(app) await Users.setEmail(db, invitee.userId, 'dev@example.com') const declined = await app.request(`/v1/invitations/${invitation.id}/decline`, { headers: { cookie: invitee.cookie }, method: 'POST', }) expect(declined.status).toBe(200) expect(await TestApp.json(declined, InvitationsApp.schema.declineInvitation.Response)).toEqual({ id: invitation.id, }) // The pending list drains and the invitation can no longer be accepted or re-declined. const mine = await app.request('/v1/invitations', { headers: { cookie: invitee.cookie } }) expect( (await TestApp.json(mine, InvitationsApp.schema.listMyInvitations.Response)).data, ).toEqual([]) const accepted = await app.request(`/v1/invitations/${invitation.id}/accept`, { headers: { cookie: invitee.cookie }, method: 'POST', }) expect(accepted.status).toBe(404) const again = await app.request(`/v1/invitations/${invitation.id}/decline`, { headers: { cookie: invitee.cookie }, method: 'POST', }) expect(again.status).toBe(404) }) test("hides invitations addressed to someone else's email", async () => { const db = TestApp.database() const app = createApp({ db }) const owner = await session(app) const created = await app.request('/v1/orgs', json('POST', owner.cookie, { name: 'Acme' })) const org = await TestApp.json(created, Orgs.schema.Organization) const invited = await app.request( `/v1/orgs/${org.id}/invitations`, json('POST', owner.cookie, { email: 'dev@example.com', role: 'member' }), ) const invitation = await TestApp.json(invited, InvitationsApp.schema.Invitation) const other = await session(app) await Users.setEmail(db, other.userId, 'other@example.com') const accepted = await app.request(`/v1/invitations/${invitation.id}/accept`, { headers: { cookie: other.cookie }, method: 'POST', }) expect(accepted.status).toBe(404) const declined = await app.request(`/v1/invitations/${invitation.id}/decline`, { headers: { cookie: other.cookie }, method: 'POST', }) expect(declined.status).toBe(404) }) test('a failing email sender never fails the invite', async () => { const app = createApp({ email: { from: 'noreply@tempo.test', send: () => Promise.reject(new Error('smtp down')), }, }) const owner = await session(app) const created = await app.request('/v1/orgs', json('POST', owner.cookie, { name: 'Acme' })) const org = await TestApp.json(created, Orgs.schema.Organization) const invited = await app.request( `/v1/orgs/${org.id}/invitations`, json('POST', owner.cookie, { email: 'dev@example.com', role: 'member' }), ) expect(invited.status).toBe(200) }) test('super admin provisions an org and seats its first owner', async () => { const db = TestApp.database() const app = createApp({ db }) // The super admin creates a memberless org and invites the first owner. const created = await app.request('/v1/orgs', { body: JSON.stringify({ name: 'Provisioned' }), headers: { 'content-type': 'application/json', 'tempo-api-key': secret }, method: 'POST', }) expect(created.status).toBe(200) const org = await TestApp.json(created, Orgs.schema.Organization) const invited = await app.request(`/v1/orgs/${org.id}/invitations`, { body: JSON.stringify({ email: 'founder@example.com', role: 'owner' }), headers: { 'content-type': 'application/json', 'tempo-api-key': secret }, method: 'POST', }) expect(invited.status).toBe(200) const invitation = await TestApp.json(invited, InvitationsApp.schema.Invitation) expect(invitation.invitedBy).toBe('super_admin') // The invitee accepts and holds owner powers. const founder = await session(app) await Users.setEmail(db, founder.userId, 'founder@example.com') const accepted = await app.request(`/v1/invitations/${invitation.id}/accept`, { headers: { cookie: founder.cookie }, method: 'POST', }) expect(accepted.status).toBe(200) const renamed = await app.request( `/v1/orgs/${org.id}`, json('PATCH', founder.cookie, { name: 'Founded' }), ) expect(renamed.status).toBe(200) }) test('embeds the configured console origin in the email link', async () => { const sent: App.Email.Message[] = [] const app = createApp({ email: { consoleUrl: 'https://portal.selfhost.example', from: 'noreply@selfhost.example', send: (message) => { sent.push(message) return Promise.resolve() }, }, }) const owner = await session(app) const created = await app.request('/v1/orgs', json('POST', owner.cookie, { name: 'Acme' })) const org = await TestApp.json(created, Orgs.schema.Organization) const invited = await app.request( `/v1/orgs/${org.id}/invitations`, json('POST', owner.cookie, { email: 'dev@example.com', role: 'member' }), ) expect(invited.status).toBe(200) const invitation = await TestApp.json(invited, InvitationsApp.schema.Invitation) expect(sent[0]!.text).toContain( `https://portal.selfhost.example/?email=dev%40example.com&invitation=${invitation.id}`, ) }) })