import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts' import * as TestApp from '../../../../test/App.js' import * as Memberships from '../../../db/tables/memberships.js' import * as Members from './members.js' import * as Orgs from './orgs.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, } } function createApp(db = TestApp.database()) { return TestApp.create({ db, 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 } } /** An org owned by `owner`, with `member` seeded at the given role. */ async function team( app: ReturnType, db: ReturnType, role: Memberships.Role, ) { const owner = await session(app) const member = 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) await Memberships.create(db, { orgId: org.id, role, userId: member.userId }) return { member, org, owner } } test('publishes generator-ready OpenAPI contracts', async () => { const spec = await (await createApp().request('/openapi.json')).json() const collection = spec.paths['/v1/orgs/{orgId}/members'] const detail = spec.paths['/v1/orgs/{orgId}/members/{userId}'] expect(spec.components.schemas.MemberList.properties.data.examples).toEqual([[]]) expect({ components: ['Member', 'MemberList', 'RemoveMemberResponse', 'UpdateMemberRequest'].filter( (name) => spec.components.schemas[name], ), 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, }, memberFormats: { address: spec.components.schemas.Member.properties.address.pattern, createdAt: spec.components.schemas.Member.properties.createdAt.format, email: spec.components.schemas.Member.properties.email.format, }, remove: { 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, 409: detail.delete.responses[409].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, 409: detail.patch.responses[409].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": [ "Member", "MemberList", "RemoveMemberResponse", "UpdateMemberRequest", ], "list": { "errors": { "400": { "$ref": "#/components/schemas/ApiKeyMalformedOrParamInvalidError", }, "403": { "$ref": "#/components/schemas/ForbiddenError", }, "404": { "$ref": "#/components/schemas/OrganizationNotFoundError", }, }, "operationId": "listMembers", "response": { "$ref": "#/components/schemas/MemberList", }, }, "memberFormats": { "address": "^0x[0-9a-fA-F]{40}$", "createdAt": "date-time", "email": "email", }, "remove": { "errors": { "400": { "$ref": "#/components/schemas/ApiKeyMalformedOrParamInvalidError", }, "403": { "$ref": "#/components/schemas/ForbiddenError", }, "404": { "$ref": "#/components/schemas/MemberNotFoundOrOrganizationNotFoundError", }, "409": { "$ref": "#/components/schemas/LastOwnerError", }, }, "operationId": "removeMember", "response": { "$ref": "#/components/schemas/RemoveMemberResponse", }, }, "update": { "errors": { "400": { "$ref": "#/components/schemas/ApiKeyMalformedOrBodyInvalidOrParamInvalidError", }, "403": { "$ref": "#/components/schemas/ForbiddenError", }, "404": { "$ref": "#/components/schemas/MemberNotFoundOrOrganizationNotFoundError", }, "409": { "$ref": "#/components/schemas/LastOwnerError", }, }, "operationId": "updateMember", "request": { "$ref": "#/components/schemas/UpdateMemberRequest", }, "response": { "$ref": "#/components/schemas/Member", }, }, } `) }) describe('members', () => { test('lists the team with roles and identity', async () => { const db = TestApp.database() const app = createApp(db) const { member, org, owner } = await team(app, db, 'member') const response = await app.request(`/v1/orgs/${org.id}/members`, { headers: { cookie: member.cookie }, }) expect(response.status).toBe(200) const body = await TestApp.json(response, Members.schema.listMembers.Response) expect(body.data.map(({ role, userId }) => ({ role, userId }))).toEqual([ { role: 'owner', userId: owner.userId }, { role: 'member', userId: member.userId }, ]) expect(body.data[0]!.address).toMatch(/^0x/) }) test('tiers write access by role across the surface', async () => { const db = TestApp.database() const app = createApp(db) const { member, org } = await team(app, db, 'member') // Members read the org but cannot manage it or its projects. const read = await app.request(`/v1/orgs/${org.id}`, { headers: { cookie: member.cookie } }) expect(read.status).toBe(200) const rename = await app.request(`/v1/orgs/${org.id}`, json('PATCH', member.cookie, { name: 'Steal' })) // prettier-ignore expect(rename.status).toBe(403) const project = await app.request(`/v1/orgs/${org.id}/projects`, json('POST', member.cookie, { name: 'Nope' })) // prettier-ignore expect(project.status).toBe(403) // Admins manage projects but not the org itself. const admin = await session(app) await Memberships.create(db, { orgId: org.id, role: 'admin', userId: admin.userId }) const adminProject = await app.request(`/v1/orgs/${org.id}/projects`, json('POST', admin.cookie, { name: 'Checkout' })) // prettier-ignore expect(adminProject.status).toBe(200) const adminRename = await app.request(`/v1/orgs/${org.id}`, json('PATCH', admin.cookie, { name: 'Steal' })) // prettier-ignore expect(adminRename.status).toBe(403) const adminDelete = await app.request(`/v1/orgs/${org.id}`, { headers: { cookie: admin.cookie }, method: 'DELETE', }) expect(adminDelete.status).toBe(403) }) test('owners change roles; the last owner cannot be demoted', async () => { const db = TestApp.database() const app = createApp(db) const { member, org, owner } = await team(app, db, 'member') const promoted = await app.request( `/v1/orgs/${org.id}/members/${member.userId}`, json('PATCH', owner.cookie, { role: 'admin' }), ) expect(promoted.status).toBe(200) expect((await TestApp.json(promoted, Members.schema.Member)).role).toBe('admin') // A non-owner cannot change roles. const foreign = await app.request( `/v1/orgs/${org.id}/members/${owner.userId}`, json('PATCH', member.cookie, { role: 'member' }), ) expect(foreign.status).toBe(403) const demoted = await app.request( `/v1/orgs/${org.id}/members/${owner.userId}`, json('PATCH', owner.cookie, { role: 'member' }), ) expect(demoted.status).toBe(409) const { requestId, ...body } = (await demoted.json()) as { requestId?: string } expect(requestId).toBeDefined() expect(body).toMatchInlineSnapshot(` { "error": { "code": "last_owner", "message": "The organization would be left without an owner", }, } `) }) test('concurrent demotions cannot strip the last owner', 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) // Two owners, each demoting the other at the same time: the guard must let // exactly one through so the org is never left ownerless. const second = await session(app) await Memberships.create(db, { orgId: org.id, role: 'owner', userId: second.userId }) const [a, b] = await Promise.all([ app.request(`/v1/orgs/${org.id}/members/${owner.userId}`, json('PATCH', second.cookie, { role: 'member' })), // prettier-ignore app.request(`/v1/orgs/${org.id}/members/${second.userId}`, json('PATCH', owner.cookie, { role: 'member' })), // prettier-ignore ]) expect([a.status, b.status].sort((left, right) => left - right)).toEqual([200, 409]) const owners = (await Memberships.listByOrg(db, org.id)).filter(({ role }) => role === 'owner') expect(owners).toHaveLength(1) }) test('owners remove members; members remove only themselves', async () => { const db = TestApp.database() const app = createApp(db) const { member, org, owner } = await team(app, db, 'member') const other = await session(app) await Memberships.create(db, { orgId: org.id, role: 'member', userId: other.userId }) // A member cannot remove someone else. const foreign = await app.request(`/v1/orgs/${org.id}/members/${other.userId}`, { headers: { cookie: member.cookie }, method: 'DELETE', }) expect(foreign.status).toBe(403) // A member removes themself (leaves). const left = await app.request(`/v1/orgs/${org.id}/members/${member.userId}`, { headers: { cookie: member.cookie }, method: 'DELETE', }) expect(left.status).toBe(200) const gone = await app.request(`/v1/orgs/${org.id}`, { headers: { cookie: member.cookie } }) expect(gone.status).toBe(404) // The owner removes the other member; the last owner cannot leave. const removed = await app.request(`/v1/orgs/${org.id}/members/${other.userId}`, { headers: { cookie: owner.cookie }, method: 'DELETE', }) expect(removed.status).toBe(200) const lastOwner = await app.request(`/v1/orgs/${org.id}/members/${owner.userId}`, { headers: { cookie: owner.cookie }, method: 'DELETE', }) expect(lastOwner.status).toBe(409) }) test('hides the team from foreign sessions', async () => { const db = TestApp.database() const app = createApp(db) const { org } = await team(app, db, 'member') const stranger = await session(app) const response = await app.request(`/v1/orgs/${org.id}/members`, { headers: { cookie: stranger.cookie }, }) expect(response.status).toBe(404) }) })