import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts' import * as App from '../App.js' import * as TestApp from '../../../../test/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 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 } } = {}, ) { return TestApp.create({ auth: { superAdmin: { secret } }, db: options.db ?? TestApp.database(), ...(options.email ? { email: options.email } : {}), ...(options.kv ? { kv: options.kv } : {}), 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, }, 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", }, }, "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('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('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) // Admins cannot grant their own role either — only strictly below it. 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(403) 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('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}`, ) }) })