import { testClient } from 'hono/testing' import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts' import type * as z from 'zod/mini' import * as TestApp from '../../../../test/App.js' import type * as Db from '../../../db/Db.js' import * as Memberships from '../../../db/tables/memberships.js' import * as Users from '../../../db/tables/users.js' import * as InviteLinks from './invite-links.js' /** Origin pinned for SIWE domain binding; Hono test requests use this host. */ const origin = 'http://localhost' /** RPC request options carrying a session cookie. */ function as(cookie: string) { return { headers: { cookie } } as const } function createApp(db: Db.Source = TestApp.database()) { const app = TestApp.create({ db, session: { wallet: { origin } } }) return { app, client: testClient(app) } } /** Signs in a fresh scripted account and resolves its user id. */ async function session({ app, client }: ReturnType) { const { cookie } = await TestApp.signIn(app, privateKeyToAccount(generatePrivateKey())) if (!cookie) throw new Error('Sign-in did not establish a session') const me = await client.v1.me.$get(undefined, as(cookie)) expect(me.status).toBe(200) if (me.status !== 200) throw new Error(`Failed to resolve session user: ${me.status}`) const { id: userId } = await me.json() return { cookie, userId } } /** Creates an owner and organization through their public write boundaries. */ async function setup(context: ReturnType) { const owner = await session(context) const response = await context.client.v1.orgs.$post({ json: { name: 'Acme' } }, as(owner.cookie)) expect(response.status).toBe(200) if (response.status !== 200) throw new Error(`Failed to create organization: ${response.status}`) const org = await response.json() return { org, owner } } /** Creates an invite link through the management route. */ async function createLink( { client }: ReturnType, orgId: string, cookie: string, body: z.input = { name: 'Community' }, ) { const response = await client.v1.orgs[':orgId{org_[A-Za-z0-9_-]+}']['invite-links'].$post( { json: body, param: { orgId } }, as(cookie), ) expect(response.status).toBe(200) if (response.status !== 200) throw new Error(`Failed to create invite link: ${response.status}`) return response.json() } const unavailable = { error: { code: 'invite_link_not_found', message: 'Invite link not found' }, } test('publishes generator-ready OpenAPI contracts', async () => { const { app } = createApp() const spec = await (await app.request('/openapi.json')).json() const collection = spec.paths['/v1/orgs/{orgId}/invite-links'] const detail = spec.paths['/v1/orgs/{orgId}/invite-links/{inviteLinkId}'] const redemptions = spec.paths['/v1/orgs/{orgId}/invite-links/{inviteLinkId}/redemptions'].get const resolve = spec.paths['/v1/invite-links/resolve'].post const accept = spec.paths['/v1/invite-links/accept'].post expect({ components: [ 'AcceptInviteLinkResponse', 'CreateInviteLinkRequest', 'DeleteInviteLinkResponse', 'InviteLink', 'InviteLinkList', 'InviteLinkRedemption', 'InviteLinkRedemptionList', 'InviteLinkTokenRequest', 'ResolveInviteLinkResponse', 'UpdateInviteLinkRequest', ].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.InviteLink.properties.createdAt.format, expiresAt: spec.components.schemas.InviteLink.properties.expiresAt.anyOf[0].format, lastUsedAt: spec.components.schemas.InviteLink.properties.lastUsedAt.anyOf[0].format, redemptionCreatedAt: spec.components.schemas.InviteLinkRedemption.properties.createdAt.format, redemptionEmail: spec.components.schemas.InviteLinkRedemption.properties.email.format, updatedAt: spec.components.schemas.InviteLink.properties.updatedAt.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, request: accept.requestBody.content['application/json'].schema, response: accept.responses[200].content['application/json'].schema, }, resolve: { errors: { 400: resolve.responses[400].content['application/json'].schema, 403: resolve.responses[403].content['application/json'].schema, 404: resolve.responses[404].content['application/json'].schema, }, operationId: resolve.operationId, request: resolve.requestBody.content['application/json'].schema, response: resolve.responses[200].content['application/json'].schema, }, }, redemptions: { errors: { 400: redemptions.responses[400].content['application/json'].schema, 403: redemptions.responses[403].content['application/json'].schema, 404: redemptions.responses[404].content['application/json'].schema, }, operationId: redemptions.operationId, response: redemptions.responses[200].content['application/json'].schema, }, updateAndDelete: { delete: { 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, }, update: { errors: { 400: detail.patch.responses[400].content['application/json'].schema, 403: detail.patch.responses[403].content['application/json'].schema, 404: detail.patch.responses[404].content['application/json'].schema, }, operationId: detail.patch.operationId, request: detail.patch.requestBody.content['application/json'].schema, response: detail.patch.responses[200].content['application/json'].schema, }, }, }).toMatchInlineSnapshot(` { "components": [ "AcceptInviteLinkResponse", "CreateInviteLinkRequest", "DeleteInviteLinkResponse", "InviteLink", "InviteLinkList", "InviteLinkRedemption", "InviteLinkRedemptionList", "InviteLinkTokenRequest", "ResolveInviteLinkResponse", "UpdateInviteLinkRequest", ], "create": { "errors": { "400": { "$ref": "#/components/schemas/ApiKeyMalformedOrBodyInvalidOrParamInvalidError", }, "403": { "$ref": "#/components/schemas/ForbiddenError", }, "404": { "$ref": "#/components/schemas/OrganizationNotFoundError", }, }, "operationId": "createInviteLink", "request": { "$ref": "#/components/schemas/CreateInviteLinkRequest", }, "response": { "$ref": "#/components/schemas/InviteLink", }, }, "formats": { "createdAt": "date-time", "expiresAt": "date-time", "lastUsedAt": "date-time", "redemptionCreatedAt": "date-time", "redemptionEmail": "email", "updatedAt": "date-time", }, "list": { "errors": { "400": { "$ref": "#/components/schemas/ApiKeyMalformedOrParamInvalidError", }, "403": { "$ref": "#/components/schemas/ForbiddenError", }, "404": { "$ref": "#/components/schemas/OrganizationNotFoundError", }, }, "operationId": "listInviteLinks", "response": { "$ref": "#/components/schemas/InviteLinkList", }, }, "recipientActions": { "accept": { "errors": { "400": { "$ref": "#/components/schemas/ApiKeyMalformedOrBodyInvalidError", }, "403": { "$ref": "#/components/schemas/ApiKeyForbiddenOrApiKeyIpForbiddenOrForbiddenOrInviteLinkEmailForbiddenError", }, "404": { "$ref": "#/components/schemas/InviteLinkNotFoundError", }, }, "operationId": "acceptInviteLink", "request": { "$ref": "#/components/schemas/InviteLinkTokenRequest", }, "response": { "$ref": "#/components/schemas/AcceptInviteLinkResponse", }, }, "resolve": { "errors": { "400": { "$ref": "#/components/schemas/ApiKeyMalformedOrBodyInvalidError", }, "403": { "$ref": "#/components/schemas/ForbiddenError", }, "404": { "$ref": "#/components/schemas/InviteLinkNotFoundError", }, }, "operationId": "resolveInviteLink", "request": { "$ref": "#/components/schemas/InviteLinkTokenRequest", }, "response": { "$ref": "#/components/schemas/ResolveInviteLinkResponse", }, }, }, "redemptions": { "errors": { "400": { "$ref": "#/components/schemas/ApiKeyMalformedOrParamInvalidError", }, "403": { "$ref": "#/components/schemas/ForbiddenError", }, "404": { "$ref": "#/components/schemas/InviteLinkNotFoundOrOrganizationNotFoundError", }, }, "operationId": "listInviteLinkRedemptions", "response": { "$ref": "#/components/schemas/InviteLinkRedemptionList", }, }, "updateAndDelete": { "delete": { "errors": { "400": { "$ref": "#/components/schemas/ApiKeyMalformedOrParamInvalidError", }, "403": { "$ref": "#/components/schemas/ForbiddenError", }, "404": { "$ref": "#/components/schemas/InviteLinkNotFoundOrOrganizationNotFoundError", }, }, "operationId": "deleteInviteLink", "response": { "$ref": "#/components/schemas/DeleteInviteLinkResponse", }, }, "update": { "errors": { "400": { "$ref": "#/components/schemas/ApiKeyMalformedOrBodyInvalidOrParamInvalidError", }, "403": { "$ref": "#/components/schemas/ForbiddenError", }, "404": { "$ref": "#/components/schemas/InviteLinkNotFoundOrOrganizationNotFoundError", }, }, "operationId": "updateInviteLink", "request": { "$ref": "#/components/schemas/UpdateInviteLinkRequest", }, "response": { "$ref": "#/components/schemas/InviteLink", }, }, }, } `) }) describe('inviteLinks', () => { test('admins manage org-scoped links that outlive their creator', async () => { const db = TestApp.database() const context = createApp(db) const { client } = context const orgLinks = client.v1.orgs[':orgId{org_[A-Za-z0-9_-]+}']['invite-links'] const { org, owner } = await setup(context) const admin = await session(context) const member = await session(context) await Memberships.create(db, { orgId: org.id, role: 'admin', userId: admin.userId }) await Memberships.create(db, { orgId: org.id, role: 'member', userId: member.userId }) const forbidden = await orgLinks.$post( { json: { name: 'No access' }, param: { orgId: org.id } }, as(member.cookie), ) expect(forbidden.status).toBe(403) const link = await createLink(context, org.id, admin.cookie, { role: 'member' }) expect(link).toMatchObject({ allowedEmailDomains: null, enabled: true, lastUsedAt: null, maxUses: null, name: 'Untitled link', role: 'member', status: 'active', useCount: 0, }) expect(link.id).toMatch(/^iln_/) expect(link.token).toMatch(/^lnk_/) expect(Date.parse(link.expiresAt!) - Date.now()).toBeGreaterThan(6 * 86_400_000) const updated = await orgLinks[':inviteLinkId{iln_[A-Za-z0-9_-]+}'].$patch( { json: { enabled: false }, param: { inviteLinkId: link.id, orgId: org.id }, }, as(owner.cookie), ) expect(updated.status).toBe(200) if (updated.status !== 200) throw new Error(`Failed to update invite link: ${updated.status}`) expect(await updated.json()).toMatchObject({ enabled: false, expiresAt: link.expiresAt, maxUses: null, name: 'Untitled link', status: 'disabled', }) const enabled = await orgLinks[':inviteLinkId{iln_[A-Za-z0-9_-]+}'].$patch( { json: { enabled: true }, param: { inviteLinkId: link.id, orgId: org.id } }, as(owner.cookie), ) expect(enabled.status).toBe(200) if (enabled.status !== 200) throw new Error(`Failed to enable invite link: ${enabled.status}`) expect((await enabled.json()).status).toBe('active') expect(await Memberships.remove(db, org.id, admin.userId)).toBe('removed') const resolved = await client.v1['invite-links'].resolve.$post({ json: { token: link.token } }) expect(resolved.status).toBe(200) const foreign = await setup(context) const crossOrg = await orgLinks[':inviteLinkId{iln_[A-Za-z0-9_-]+}'].$patch( { json: { enabled: false }, param: { inviteLinkId: link.id, orgId: foreign.org.id }, }, as(foreign.owner.cookie), ) expect(crossOrg.status).toBe(404) const deleted = await orgLinks[':inviteLinkId{iln_[A-Za-z0-9_-]+}'].$delete( { param: { inviteLinkId: link.id, orgId: org.id } }, as(owner.cookie), ) expect(deleted.status).toBe(200) const listed = await orgLinks.$get({ param: { orgId: org.id } }, as(owner.cookie)) expect(listed.status).toBe(200) if (listed.status !== 200) throw new Error(`Failed to list invite links: ${listed.status}`) expect((await listed.json()).data).toEqual([]) }) test('normalizes and validates allowed email domains', async () => { const context = createApp() const { client } = context const { org, owner } = await setup(context) const orgLinks = client.v1.orgs[':orgId{org_[A-Za-z0-9_-]+}']['invite-links'] const link = await createLink(context, org.id, owner.cookie, { allowedEmailDomains: [' Example.COM ', 'example.com', 'TEMPO.XYZ'], name: 'Restricted', }) expect(link.allowedEmailDomains).toEqual(['example.com', 'tempo.xyz']) const listed = await orgLinks.$get({ param: { orgId: org.id } }, as(owner.cookie)) expect(listed.status).toBe(200) if (listed.status !== 200) throw new Error(`Failed to list invite links: ${listed.status}`) expect((await listed.json()).data[0]?.allowedEmailDomains).toEqual(['example.com', 'tempo.xyz']) for (const allowedEmailDomains of [ [], ['user@example.com'], ['https://example.com'], ['*.example.com'], ]) { const response = await orgLinks.$post( { json: { allowedEmailDomains }, param: { orgId: org.id }, }, as(owner.cookie), ) expect(response.status).toBe(400) if (response.status !== 400) throw new Error(`Expected invalid invite link body, received ${response.status}`) expect((await response.json()).error.code).toBe('body_invalid') } }) test('restricts new memberships without consuming rejected attempts', async () => { const db = TestApp.database() const context = createApp(db) const { client } = context const { org, owner } = await setup(context) const orgLinks = client.v1.orgs[':orgId{org_[A-Za-z0-9_-]+}']['invite-links'] const publicLinks = client.v1['invite-links'] const link = await createLink(context, org.id, owner.cookie, { allowedEmailDomains: [' Example.COM '], expiresAt: null, maxUses: 1, name: 'Restricted', }) const member = await session(context) await Users.setEmail(db, member.userId, 'member@sub.example.com') await Memberships.create(db, { orgId: org.id, role: 'member', userId: member.userId }) const memberRetry = await publicLinks.accept.$post( { json: { token: link.token } }, as(member.cookie), ) expect(memberRetry.status).toBe(200) const subdomain = await session(context) await Users.setEmail(db, subdomain.userId, 'dev@sub.example.com') const rejected = await publicLinks.accept.$post( { json: { token: link.token } }, as(subdomain.cookie), ) expect(rejected.status).toBe(403) if (rejected.status !== 403) throw new Error(`Expected forbidden invite link acceptance, received ${rejected.status}`) expect((await rejected.json()).error.code).toBe('invite_link_email_forbidden') expect(await Memberships.get(db, org.id, subdomain.userId)).toBeUndefined() const audits = await orgLinks[':inviteLinkId{iln_[A-Za-z0-9_-]+}'].redemptions.$get( { param: { inviteLinkId: link.id, orgId: org.id } }, as(owner.cookie), ) expect(audits.status).toBe(200) if (audits.status !== 200) throw new Error(`Failed to list redemptions: ${audits.status}`) expect((await audits.json()).data).toEqual([]) const beforeJoin = await orgLinks.$get({ param: { orgId: org.id } }, as(owner.cookie)) expect(beforeJoin.status).toBe(200) if (beforeJoin.status !== 200) throw new Error(`Failed to list invite links: ${beforeJoin.status}`) expect((await beforeJoin.json()).data[0]).toMatchObject({ status: 'active', useCount: 0 }) const exact = await session(context) await Users.setEmail(db, exact.userId, 'Dev@Example.com') const accepted = await publicLinks.accept.$post( { json: { token: link.token } }, as(exact.cookie), ) expect(accepted.status).toBe(200) const exhaustedRetry = await publicLinks.accept.$post( { json: { token: link.token } }, as(member.cookie), ) expect(exhaustedRetry.status).toBe(200) const afterJoin = await orgLinks.$get({ param: { orgId: org.id } }, as(owner.cookie)) expect(afterJoin.status).toBe(200) if (afterJoin.status !== 200) throw new Error(`Failed to list invite links: ${afterJoin.status}`) expect((await afterJoin.json()).data[0]).toMatchObject({ status: 'exhausted', useCount: 1 }) const auditsAfterJoin = await orgLinks[':inviteLinkId{iln_[A-Za-z0-9_-]+}'].redemptions.$get( { param: { inviteLinkId: link.id, orgId: org.id } }, as(owner.cookie), ) expect(auditsAfterJoin.status).toBe(200) if (auditsAfterJoin.status !== 200) throw new Error(`Failed to list redemptions: ${auditsAfterJoin.status}`) expect((await auditsAfterJoin.json()).data).toHaveLength(1) }) test('resolve reveals only a summary and hides every unavailable reason', async () => { const db = TestApp.database() const context = createApp(db) const { client } = context const orgLinks = client.v1.orgs[':orgId{org_[A-Za-z0-9_-]+}']['invite-links'] const publicLinks = client.v1['invite-links'] const { org, owner } = await setup(context) const active = await createLink(context, org.id, owner.cookie, { expiresAt: '2099-01-01T05:00:00+05:00', name: 'Active', }) expect(active.expiresAt).toBe('2099-01-01T00:00:00.000Z') const resolved = await publicLinks.resolve.$post({ json: { token: active.token } }) expect(resolved.status).toBe(200) if (resolved.status !== 200) throw new Error(`Failed to resolve invite link: ${resolved.status}`) expect(await resolved.json()).toEqual({ orgName: 'Acme', role: 'member', }) expect(resolved.headers.get('set-cookie')).toBeNull() const disabled = await createLink(context, org.id, owner.cookie, { name: 'Disabled' }) await orgLinks[':inviteLinkId{iln_[A-Za-z0-9_-]+}'].$patch( { json: { enabled: false }, param: { inviteLinkId: disabled.id, orgId: org.id } }, as(owner.cookie), ) const expired = await createLink(context, org.id, owner.cookie, { expiresAt: new Date(Date.now() - 1_000).toISOString(), name: 'Expired', }) const exhausted = await createLink(context, org.id, owner.cookie, { expiresAt: null, maxUses: 1, name: 'Exhausted', }) const invitee = await session(context) await Users.setEmail(db, invitee.userId, 'exhausted@example.com') const accepted = await publicLinks.accept.$post( { json: { token: exhausted.token } }, as(invitee.cookie), ) expect(accepted.status).toBe(200) const deleted = await createLink(context, org.id, owner.cookie, { name: 'Deleted' }) await orgLinks[':inviteLinkId{iln_[A-Za-z0-9_-]+}'].$delete( { param: { inviteLinkId: deleted.id, orgId: org.id } }, as(owner.cookie), ) for (const token of [ 'lnk_missing', deleted.token, disabled.token, exhausted.token, expired.token, ]) { const response = await publicLinks.resolve.$post({ json: { token } }) expect(response.status).toBe(404) if (response.status !== 404) throw new Error(`Expected unavailable invite link, received ${response.status}`) const { requestId: _, ...body } = await response.json() expect(body).toEqual(unavailable) } }) test('accept consumes only new memberships and preserves audit after deletion', async () => { const db = TestApp.database() const context = createApp(db) const { client } = context const orgRoutes = client.v1.orgs[':orgId{org_[A-Za-z0-9_-]+}'] const orgLinks = orgRoutes['invite-links'] const publicLinks = client.v1['invite-links'] const { org, owner } = await setup(context) const link = await createLink(context, org.id, owner.cookie, { expiresAt: null, name: 'Join', }) const invitee = await session(context) const unverified = await publicLinks.accept.$post( { json: { token: link.token } }, as(invitee.cookie), ) expect(unverified.status).toBe(403) await Users.setEmail(db, invitee.userId, 'join@example.com') const firstJoin = await publicLinks.accept.$post( { json: { token: link.token } }, as(invitee.cookie), ) const repeat = await publicLinks.accept.$post( { json: { token: link.token } }, as(invitee.cookie), ) expect(firstJoin.status).toBe(200) expect(repeat.status).toBe(200) const audits = await orgLinks[':inviteLinkId{iln_[A-Za-z0-9_-]+}'].redemptions.$get( { param: { inviteLinkId: link.id, orgId: org.id } }, as(owner.cookie), ) expect(audits.status).toBe(200) if (audits.status !== 200) throw new Error(`Failed to list redemptions: ${audits.status}`) const firstAudit = await audits.json() expect(firstAudit.data).toHaveLength(1) expect(firstAudit.data[0]).not.toHaveProperty('token') expect(firstAudit.data[0]).toMatchObject({ email: 'join@example.com', inviteLinkId: link.id, inviteLinkName: 'Join', userId: invitee.userId, }) expect(await Memberships.remove(db, org.id, invitee.userId)).toBe('removed') const rejoined = await publicLinks.accept.$post( { json: { token: link.token } }, as(invitee.cookie), ) expect(rejoined.status).toBe(200) const auditsAfterRejoin = await orgLinks[':inviteLinkId{iln_[A-Za-z0-9_-]+}'].redemptions.$get( { param: { inviteLinkId: link.id, orgId: org.id } }, as(owner.cookie), ) expect(auditsAfterRejoin.status).toBe(200) if (auditsAfterRejoin.status !== 200) throw new Error(`Failed to list redemptions: ${auditsAfterRejoin.status}`) expect((await auditsAfterRejoin.json()).data).toHaveLength(2) const listed = await orgLinks.$get({ param: { orgId: org.id } }, as(owner.cookie)) expect(listed.status).toBe(200) if (listed.status !== 200) throw new Error(`Failed to list invite links: ${listed.status}`) const managed = (await listed.json()).data[0]! expect(managed.lastUsedAt).not.toBeNull() expect(managed.useCount).toBe(2) await orgLinks[':inviteLinkId{iln_[A-Za-z0-9_-]+}'].$delete( { param: { inviteLinkId: link.id, orgId: org.id } }, as(owner.cookie), ) const auditsAfterDeletion = await orgLinks[ ':inviteLinkId{iln_[A-Za-z0-9_-]+}' ].redemptions.$get({ param: { inviteLinkId: link.id, orgId: org.id } }, as(owner.cookie)) expect(auditsAfterDeletion.status).toBe(200) if (auditsAfterDeletion.status !== 200) throw new Error(`Failed to list preserved redemptions: ${auditsAfterDeletion.status}`) expect((await auditsAfterDeletion.json()).data).toHaveLength(2) }) test('serializes concurrent attempts for the finite final use', async () => { const factory = TestApp.databaseFactory() const db = factory() const context = createApp(factory) const { client } = context const orgRoutes = client.v1.orgs[':orgId{org_[A-Za-z0-9_-]+}'] const publicLinks = client.v1['invite-links'] const { org, owner } = await setup(context) const link = await createLink(context, org.id, owner.cookie, { expiresAt: null, maxUses: 1, name: 'One use', }) const invitees = await Promise.all([session(context), session(context)]) await Promise.all( invitees.map((invitee, index) => Users.setEmail(db, invitee.userId, `user${index}@example.com`), ), ) const responses = await Promise.all( invitees.map((invitee) => publicLinks.accept.$post({ json: { token: link.token } }, as(invitee.cookie)), ), ) expect(responses.map(({ status }) => status).sort((a, b) => a - b)).toEqual([200, 404]) const winner = invitees[responses.findIndex(({ status }) => status === 200)]! const retry = await publicLinks.accept.$post({ json: { token: link.token } }, as(winner.cookie)) expect(retry.status).toBe(200) const audits = await orgRoutes['invite-links'][ ':inviteLinkId{iln_[A-Za-z0-9_-]+}' ].redemptions.$get({ param: { inviteLinkId: link.id, orgId: org.id } }, as(owner.cookie)) expect(audits.status).toBe(200) if (audits.status !== 200) throw new Error(`Failed to list redemptions: ${audits.status}`) expect((await audits.json()).data).toHaveLength(1) const listed = await orgRoutes['invite-links'].$get( { param: { orgId: org.id } }, as(owner.cookie), ) expect(listed.status).toBe(200) if (listed.status !== 200) throw new Error(`Failed to list invite links: ${listed.status}`) expect((await listed.json()).data[0]).toMatchObject({ status: 'exhausted', useCount: 1, }) }) test('keeps privileged email invitations independent from member links', async () => { const db = TestApp.database() const context = createApp(db) const { client } = context const orgRoutes = client.v1.orgs[':orgId{org_[A-Za-z0-9_-]+}'] const { org, owner } = await setup(context) const link = await createLink(context, org.id, owner.cookie, { expiresAt: null, name: 'Join', }) const invitee = await session(context) await Users.setEmail(db, invitee.userId, 'admin@example.com') const invitationResponse = await orgRoutes.invitations.$post( { json: { email: 'admin@example.com', role: 'admin' }, param: { orgId: org.id }, }, as(owner.cookie), ) expect(invitationResponse.status).toBe(200) if (invitationResponse.status !== 200) throw new Error(`Failed to create email invitation: ${invitationResponse.status}`) const invitation = await invitationResponse.json() const joined = await client.v1['invite-links'].accept.$post( { json: { token: link.token } }, as(invitee.cookie), ) expect(joined.status).toBe(200) expect((await Memberships.get(db, org.id, invitee.userId))?.role).toBe('member') const pending = await client.v1.invitations.$get(undefined, as(invitee.cookie)) expect(pending.status).toBe(200) if (pending.status !== 200) throw new Error(`Failed to list email invitations: ${pending.status}`) expect((await pending.json()).data).toHaveLength(1) const elevated = await client.v1.invitations[':invitationId{inv_[A-Za-z0-9_-]+}'].accept.$post( { param: { invitationId: invitation.id } }, as(invitee.cookie), ) expect(elevated.status).toBe(200) expect((await Memberships.get(db, org.id, invitee.userId))?.role).toBe('admin') }) })