import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts' import * as TestApp from '../../../../test/App.js' import * as Orgs from './orgs.js' import * as Projects from './projects.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() { return TestApp.create({ session: { wallet: { origin } } }) } 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}/projects'] const detail = spec.paths['/v1/orgs/{orgId}/projects/{projectId}'] expect({ components: [ 'CreateProjectRequest', 'DeleteProjectResponse', 'Project', 'ProjectList', 'UpdateProjectRequest', ].filter((name) => spec.components.schemas[name]), create: { errors: { 400: collection.post.responses[400].content['application/json'].schema, 401: collection.post.responses[401].content['application/json'].schema, 403: collection.post.responses[403].content['application/json'].schema, 404: collection.post.responses[404].content['application/json'].schema, 502: collection.post.responses[502].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, }, delete: { operationId: detail.delete.operationId, response: detail.delete.responses[200].content['application/json'].schema, }, errorComponents: { detail: { reference: spec.components.schemas.ApiKeyMalformedOrParamInvalidError.properties.error.properties .details.items, required: spec.components.schemas.ErrorDetail.required, }, invalidBodyOrParameters: spec.components.schemas.ApiKeyMalformedOrBodyInvalidOrParamInvalidError.properties.error .properties.code.enum, invalidParameters: spec.components.schemas.ApiKeyMalformedOrParamInvalidError.properties.error.properties.code .enum, forbidden: spec.components.schemas.ForbiddenError.properties.error.properties.code.enum, organizationNotFound: spec.components.schemas.OrganizationNotFoundError.properties.error.properties.code.enum, organizationOrProjectNotFound: spec.components.schemas.OrganizationNotFoundOrProjectNotFoundError.properties.error .properties.code.enum, }, list: { operationId: collection.get.operationId, response: collection.get.responses[200].content['application/json'].schema, }, sharedResponses: { internal: spec.components.responses.InternalError.content['application/json'].schema, rateLimit: spec.components.responses.RateLimited.content['application/json'].schema, }, timestamps: { createdAt: spec.components.schemas.Project.properties.createdAt.format, updatedAt: spec.components.schemas.Project.properties.updatedAt.format, }, update: { operationId: detail.patch.operationId, request: detail.patch.requestBody.content['application/json'].schema, response: detail.patch.responses[200].content['application/json'].schema, }, }).toMatchInlineSnapshot(` { "components": [ "CreateProjectRequest", "DeleteProjectResponse", "Project", "ProjectList", "UpdateProjectRequest", ], "create": { "errors": { "400": { "$ref": "#/components/schemas/ApiKeyMalformedOrBodyInvalidOrParamInvalidError", }, "401": { "$ref": "#/components/schemas/AuthenticationError", }, "403": { "$ref": "#/components/schemas/ForbiddenError", }, "404": { "$ref": "#/components/schemas/OrganizationNotFoundError", }, "502": { "$ref": "#/components/schemas/UpstreamError", }, }, "operationId": "createProject", "request": { "$ref": "#/components/schemas/CreateProjectRequest", }, "response": { "$ref": "#/components/schemas/Project", }, }, "delete": { "operationId": "deleteProject", "response": { "$ref": "#/components/schemas/DeleteProjectResponse", }, }, "errorComponents": { "detail": { "reference": { "$ref": "#/components/schemas/ErrorDetail", }, "required": [ "message", ], }, "forbidden": [ "api_key_forbidden", "api_key_ip_forbidden", "forbidden", ], "invalidBodyOrParameters": [ "api_key_malformed", "body_invalid", "param_invalid", ], "invalidParameters": [ "api_key_malformed", "param_invalid", ], "organizationNotFound": [ "organization_not_found", ], "organizationOrProjectNotFound": [ "organization_not_found", "project_not_found", ], }, "list": { "operationId": "listProjects", "response": { "$ref": "#/components/schemas/ProjectList", }, }, "sharedResponses": { "internal": { "$ref": "#/components/schemas/InternalError", }, "rateLimit": { "$ref": "#/components/schemas/RateLimitError", }, }, "timestamps": { "createdAt": "date-time", "updatedAt": "date-time", }, "update": { "operationId": "updateProject", "request": { "$ref": "#/components/schemas/UpdateProjectRequest", }, "response": { "$ref": "#/components/schemas/Project", }, }, } `) expect(detail.patch.responses[400].content['application/json'].schema).toEqual( collection.post.responses[400].content['application/json'].schema, ) expect(collection.get.responses[404].content['application/json'].schema).toEqual( collection.post.responses[404].content['application/json'].schema, ) expect(detail.patch.responses[404].content['application/json'].schema).toEqual( detail.get.responses[404].content['application/json'].schema, ) expect(detail.delete.responses[404].content['application/json'].schema).toEqual( detail.get.responses[404].content['application/json'].schema, ) }) /** Signs in a fresh scripted account; returns its session cookie. */ async function session(app: TestApp.signIn.App) { const account = privateKeyToAccount(generatePrivateKey()) const { cookie } = await TestApp.signIn(app, account) return cookie! } describe('projects', () => { test('creates, lists, reads, and renames projects', async () => { const app = createApp() const cookie = await session(app) const created = await app.request('/v1/orgs', json('POST', cookie, { name: 'Acme' })) const org = await TestApp.json(created, Orgs.schema.Organization) const createdProject = await app.request( `/v1/orgs/${org.id}/projects`, json('POST', cookie, { name: 'Checkout' }), ) expect(createdProject.status).toBe(200) const project = await TestApp.json(createdProject, Projects.schema.Project) expect(project.id.startsWith('prj_')).toBe(true) expect(project.orgId).toBe(org.id) expect(project.environments).toMatchInlineSnapshot(` [ "production", "sandbox", ] `) const listed = await app.request(`/v1/orgs/${org.id}/projects`, { headers: { cookie } }) const list = await TestApp.json(listed, Projects.schema.listProjects.Response) expect(list.data.map(({ name }) => name)).toEqual(['Checkout']) const detail = await app.request(`/v1/orgs/${org.id}/projects/${project.id}`, { headers: { cookie }, }) expect(detail.status).toBe(200) const renamed = await app.request( `/v1/orgs/${org.id}/projects/${project.id}`, json('PATCH', cookie, { name: 'Checkout v2' }), ) expect(renamed.status).toBe(200) expect((await TestApp.json(renamed, Projects.schema.Project)).name).toBe('Checkout v2') }) test('deletes a project, leaving the organization and siblings intact', async () => { const app = createApp() const cookie = await session(app) const created = await app.request('/v1/orgs', json('POST', cookie, { name: 'Acme' })) const org = await TestApp.json(created, Orgs.schema.Organization) const createdProject = await app.request( `/v1/orgs/${org.id}/projects`, json('POST', cookie, { name: 'Checkout' }), ) const project = await TestApp.json(createdProject, Projects.schema.Project) const keeper = await app.request( `/v1/orgs/${org.id}/projects`, json('POST', cookie, { name: 'Payments' }), ) expect(keeper.status).toBe(200) const stranger = await session(app) const foreign = await app.request(`/v1/orgs/${org.id}/projects/${project.id}`, { headers: { cookie: stranger }, method: 'DELETE', }) expect(foreign.status).toBe(404) const deleted = await app.request(`/v1/orgs/${org.id}/projects/${project.id}`, { headers: { cookie }, method: 'DELETE', }) expect(deleted.status).toBe(200) expect(await TestApp.json(deleted, Projects.schema.deleteProject.Response)).toEqual({ id: project.id, }) const gone = await app.request(`/v1/orgs/${org.id}/projects/${project.id}`, { headers: { cookie }, }) expect(gone.status).toBe(404) const listed = await app.request(`/v1/orgs/${org.id}/projects`, { headers: { cookie } }) const list = await TestApp.json(listed, Projects.schema.listProjects.Response) expect(list.data.map(({ name }) => name)).toEqual(['Payments']) const orgStill = await app.request(`/v1/orgs/${org.id}`, { headers: { cookie } }) expect(orgStill.status).toBe(200) }) test('hides projects behind foreign organizations', async () => { const app = createApp() const owner = await session(app) const created = await app.request('/v1/orgs', json('POST', owner, { name: 'Acme' })) const org = await TestApp.json(created, Orgs.schema.Organization) const createdProject = await app.request( `/v1/orgs/${org.id}/projects`, json('POST', owner, { name: 'Checkout' }), ) const project = await TestApp.json(createdProject, Projects.schema.Project) const stranger = await session(app) const foreignList = await app.request(`/v1/orgs/${org.id}/projects`, { headers: { cookie: stranger }, }) expect(foreignList.status).toBe(404) const foreignDetail = await app.request(`/v1/orgs/${org.id}/projects/${project.id}`, { headers: { cookie: stranger }, }) expect(foreignDetail.status).toBe(404) }) test('rejects cross-org project ids on every verb', async () => { const app = createApp() const owner = await session(app) const created = await app.request('/v1/orgs', json('POST', owner, { name: 'Acme' })) const org = await TestApp.json(created, Orgs.schema.Organization) const createdProject = await app.request( `/v1/orgs/${org.id}/projects`, json('POST', owner, { name: 'Checkout' }), ) const project = await TestApp.json(createdProject, Projects.schema.Project) // The stranger owns a perfectly valid org of their own; the victim's // project id still must not resolve through it (project ids are bound to // their owning org, not just to any org the caller owns). const stranger = await session(app) const otherOrgResponse = await app.request('/v1/orgs', json('POST', stranger, { name: 'Other' })) // prettier-ignore const otherOrg = await TestApp.json(otherOrgResponse, Orgs.schema.Organization) const smuggled = `/v1/orgs/${otherOrg.id}/projects/${project.id}` const smuggledRead = await app.request(smuggled, { headers: { cookie: stranger } }) expect(smuggledRead.status).toBe(404) const smuggledRename = await app.request(smuggled, json('PATCH', stranger, { name: 'Hijack' })) // prettier-ignore expect(smuggledRename.status).toBe(404) const smuggledDelete = await app.request(smuggled, { headers: { cookie: stranger }, method: 'DELETE', }) expect(smuggledDelete.status).toBe(404) // Mutations through the owning org's path fail at the org-ownership link. const direct = `/v1/orgs/${org.id}/projects/${project.id}` const foreignRename = await app.request(direct, json('PATCH', stranger, { name: 'Hijack' })) expect(foreignRename.status).toBe(404) const foreignDelete = await app.request(direct, { headers: { cookie: stranger }, method: 'DELETE', }) expect(foreignDelete.status).toBe(404) // The project survives every attempt, unrenamed. const detail = await app.request(direct, { headers: { cookie: owner } }) expect(detail.status).toBe(200) expect((await TestApp.json(detail, Projects.schema.Project)).name).toBe('Checkout') }) })