import { serve } from '@hono/node-server' import * as Accounts from 'accounts/server' import { exportJWK, generateKeyPair } from 'jose' import { P256 } from 'ox' import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts' import { Account } from 'viem/tempo' import { tempoTestnet } from 'viem/tempo/chains' import * as TestApp from '../../../../test/App.js' import * as Me from './me.js' /** Origin pinned for SIWE domain binding; Hono test requests use this host. */ const origin = 'http://localhost' const superAdminSecret = 'tempo:sk:c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2' function createApp() { return TestApp.create({ auth: { keys: [TestApp.key], superAdmin: { secret: superAdminSecret } }, session: { wallet: { origin } }, }) } test('publishes a generator-ready OpenAPI contract', async () => { const spec = await (await createApp().request('/openapi.json')).json() const operation = spec.paths['/v1/me'].get expect(spec.components.schemas.User.properties.email.examples).toEqual(['dev@example.com']) expect({ component: spec.components.schemas.User ? 'User' : undefined, errors: { 400: operation.responses[400].content['application/json'].schema, 403: operation.responses[403].content['application/json'].schema, 404: operation.responses[404].content['application/json'].schema, }, formats: { address: spec.components.schemas.User.properties.address.pattern, createdAt: spec.components.schemas.User.properties.createdAt.format, email: spec.components.schemas.User.properties.email.format, updatedAt: spec.components.schemas.User.properties.updatedAt.format, }, operationId: operation.operationId, response: operation.responses[200].content['application/json'].schema, }).toMatchInlineSnapshot(` { "component": "User", "errors": { "400": { "$ref": "#/components/schemas/ApiKeyMalformedError", }, "403": { "$ref": "#/components/schemas/ForbiddenError", }, "404": { "$ref": "#/components/schemas/UserNotFoundError", }, }, "formats": { "address": "^0x[0-9a-fA-F]{40}$", "createdAt": "date-time", "email": "email", "updatedAt": "date-time", }, "operationId": "getMe", "response": { "$ref": "#/components/schemas/User", }, } `) }) describe('SIWE verification', () => { test('surfaces configured RPC failures as dependency errors', async () => { const account = Account.fromHeadlessWebAuthn(P256.randomPrivateKey(), { origin, rpId: 'localhost', }) let chainId_seen: number | undefined const app = TestApp.create({ rpc: ({ chainId }) => { chainId_seen = chainId return { url: 'http://127.0.0.1:1' } }, }) const { verifyResponse } = await TestApp.signIn(app, account, { chainId: tempoTestnet.id, }) expect(verifyResponse.status).toBe(502) expect(chainId_seen).toMatchInlineSnapshot(`42431`) expect(await verifyResponse.json()).toMatchInlineSnapshot(` { "error": "signature verification unavailable", } `) }) }) describe('GET /me', () => { test('returns the signed-in user', async () => { const app = createApp() const account = privateKeyToAccount(generatePrivateKey()) const { cookie, verifyResponse } = await TestApp.signIn(app, account) expect(verifyResponse.status).toBe(200) expect(cookie).toBeDefined() const response = await app.request('/v1/me', { headers: { cookie: cookie! } }) expect(response.status).toBe(200) const body = await TestApp.json(response, Me.schema.User) expect(body.address).toBe(account.address.toLowerCase()) expect(body.id.startsWith('usr_')).toBe(true) expect(body.email).toBeUndefined() }) test('sign-in is idempotent per address', async () => { const app = createApp() const account = privateKeyToAccount(generatePrivateKey()) const first = await TestApp.signIn(app, account) const firstMe = await app.request('/v1/me', { headers: { cookie: first.cookie! } }) const firstBody = await TestApp.json(firstMe, Me.schema.User) const second = await TestApp.signIn(app, account) const secondMe = await app.request('/v1/me', { headers: { cookie: second.cookie! } }) const secondBody = await TestApp.json(secondMe, Me.schema.User) expect(secondBody.id).toBe(firstBody.id) }) test('supports bearer-token sessions', async () => { const app = createApp() const account = privateKeyToAccount(generatePrivateKey()) const { verifyResponse } = await TestApp.signIn(app, account, { returnToken: true }) const { token } = (await verifyResponse.json()) as { token: string } expect(typeof token).toBe('string') // The bearer session token takes the API-key header path first; the failed // key lookup must fall through to the session lane. const response = await app.request('/v1/me', { headers: { authorization: `Bearer ${token}` }, }) expect(response.status).toBe(200) }) test('logout revokes the session', async () => { const app = createApp() const account = privateKeyToAccount(generatePrivateKey()) const { cookie } = await TestApp.signIn(app, account) const logout = await app.request('/v1/auth/siwe/logout', { headers: { cookie: cookie! }, method: 'POST', }) expect(logout.status).toBe(204) const response = await app.request('/v1/me', { headers: { cookie: cookie! } }) expect(response.status).toBe(401) }) test('logout revokes bearer-token sessions', async () => { const app = createApp() const account = privateKeyToAccount(generatePrivateKey()) const { verifyResponse } = await TestApp.signIn(app, account, { returnToken: true }) const { token } = (await verifyResponse.json()) as { token: string } const logout = await app.request('/v1/auth/siwe/logout', { headers: { authorization: `Bearer ${token}` }, method: 'POST', }) expect(logout.status).toBe(204) const response = await app.request('/v1/me', { headers: { authorization: `Bearer ${token}` }, }) expect(response.status).toBe(401) }) test('rejects anonymous requests', async () => { const app = createApp() const response = await app.request('/v1/me') expect(response.status).toBe(401) }) test('rejects API keys', async () => { const app = createApp() const response = await app.request('/v1/me', { headers: { 'tempo-api-key': TestApp.key.token }, }) expect(response.status).toBe(403) const { requestId, ...body } = (await response.json()) as { requestId?: string } expect(requestId).toBeDefined() expect(body).toMatchInlineSnapshot(` { "error": { "code": "forbidden", "message": "API key not permitted for this route", }, } `) }) test('rejects the super admin (no user row)', async () => { const app = createApp() const response = await app.request('/v1/me', { headers: { 'tempo-api-key': superAdminSecret }, }) expect(response.status).toBe(403) const { requestId, ...body } = (await response.json()) as { requestId?: string } expect(requestId).toBeDefined() expect(body).toMatchInlineSnapshot(` { "error": { "code": "forbidden", "message": "Session required", }, } `) }) }) describe('sign-in email requirement', () => { test('rejects sign-ins without an identity token', async () => { const app = TestApp.create({ auth: { keys: [TestApp.key] }, session: { wallet: { origin, requireEmail: true } }, }) const account = privateKeyToAccount(generatePrivateKey()) const { cookie, verifyResponse } = await TestApp.signIn(app, account) expect(verifyResponse.status).toBe(400) expect(cookie).toBeUndefined() expect(await verifyResponse.json()).toMatchInlineSnapshot(` { "error": "identity token required", } `) }) test('folds the token-verified email onto the session', async () => { const oidc = await createIssuer({ email: 'dev@example.com' }) try { const app = TestApp.create({ auth: { keys: [TestApp.key] }, session: { wallet: { issuer: oidc.url, origin, requireEmail: true } }, }) const account = privateKeyToAccount(generatePrivateKey()) const { cookie, verifyResponse } = await TestApp.signIn(app, account, { idToken: ({ nonce }) => oidc.mint({ address: account.address, nonce }), }) expect(verifyResponse.status).toBe(200) const response = await app.request('/v1/me', { headers: { cookie: cookie! } }) expect(response.status).toBe(200) const body = await TestApp.json(response, Me.schema.User) expect(body.email).toBe('dev@example.com') } finally { await oidc.close() } }) }) describe('open sign-up', () => { test('allows new users on any domain', async () => { const oidc = await createIssuer({ email: 'dev@example.com' }) try { const app = TestApp.create({ auth: { keys: [TestApp.key] }, session: { wallet: { issuer: oidc.url, origin } }, }) const account = privateKeyToAccount(generatePrivateKey()) const { cookie, verifyResponse } = await TestApp.signIn(app, account, { idToken: ({ nonce }) => oidc.mint({ address: account.address, nonce }), }) expect(verifyResponse.status).toBe(200) const response = await app.request('/v1/me', { headers: { cookie: cookie! } }) expect(response.status).toBe(200) const body = await TestApp.json(response, Me.schema.User) expect(body.email).toBe('dev@example.com') } finally { await oidc.close() } }) test('allows new users without an identity token', async () => { const app = createApp() const account = privateKeyToAccount(generatePrivateKey()) const { cookie, verifyResponse } = await TestApp.signIn(app, account) expect(verifyResponse.status).toBe(200) expect(cookie).toBeDefined() }) }) /** Local OIDC issuer standing in for the wallet: the SDK's own provider (discovery, JWKS, token mint). */ async function createIssuer(options: { email: string }) { // Extractable: the provider takes key material as JWK strings. const { privateKey, publicKey } = await generateKeyPair('EdDSA', { extractable: true }) // The provider pins its `issuer` URL at construction, but the ephemeral // port is only known after bind — listen through a closure, assign after. let provider!: ReturnType type Listener = { close: () => Promise; url: string } const { close, url } = await new Promise((resolve) => { const server = serve({ fetch: (request) => provider.fetch(request), port: 0 }, (info) => resolve({ close: () => new Promise((done) => server.close(() => done())), url: `http://127.0.0.1:${info.port}`, }), ) }) provider = Accounts.Handler.oidcProvider({ // Test-only: no `authenticate`, so the request body's `subject` is trusted. getClaims: () => ({ email: options.email, email_verified: true }), issuer: url, publicKey: JSON.stringify(await exportJWK(publicKey)), signingKey: JSON.stringify(await exportJWK(privateKey)), }) return { close, /** Mints a wallet-style identity token bound to the SIWE nonce and signer. */ async mint(mintOptions: { address: string; nonce: string }) { const response = await provider.fetch( new Request(`${url}/token`, { body: JSON.stringify({ audience: origin, nonce: mintOptions.nonce, subject: mintOptions.address, }), headers: { 'content-type': 'application/json' }, method: 'POST', }), ) const { idToken } = (await response.json()) as { idToken: string } return idToken }, url, } }