From e6e4ca0a1ef8f912a29094ce928deb4885cb86a1 Mon Sep 17 00:00:00 2001 From: Bas van Rossem Date: Wed, 22 Jul 2026 11:50:32 +0200 Subject: [PATCH] =?UTF-8?q?test(api):=20Auth=20&=20/me=20=E2=80=94=20fill?= =?UTF-8?q?=20gaps=20+=20lifecycle=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/api/test/auth.test.ts | 11 +++++ .../test/integration/auth-lifecycle.test.ts | 32 ++++++++++++++ apps/api/test/me.test.ts | 25 ++++++++++- apps/api/test/require-user.test.ts | 43 +++++++++++++++++++ docs/reference/domain-test-checklist.md | 23 ++++++++++ 5 files changed, 133 insertions(+), 1 deletion(-) create mode 100644 apps/api/test/integration/auth-lifecycle.test.ts create mode 100644 apps/api/test/require-user.test.ts diff --git a/apps/api/test/auth.test.ts b/apps/api/test/auth.test.ts index f97d657..46f2045 100644 --- a/apps/api/test/auth.test.ts +++ b/apps/api/test/auth.test.ts @@ -9,6 +9,17 @@ describe('auth', () => { expect(token).toBeTruthy(); }); + it('rejects sign-in with a wrong password', async () => { + const app = createApp(); + await authToken(app, 'wrong-pw@example.com'); // create the user first + const res = await app.request('/api/auth/sign-in/email', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ email: 'wrong-pw@example.com', password: 'not-the-password' }), + }); + expect(res.status).toBeGreaterThanOrEqual(400); + }); + it('rejects public sign-up (admin creates users)', async () => { const app = createApp(); const res = await app.request('/api/auth/sign-up/email', { diff --git a/apps/api/test/integration/auth-lifecycle.test.ts b/apps/api/test/integration/auth-lifecycle.test.ts new file mode 100644 index 0000000..f340a77 --- /dev/null +++ b/apps/api/test/integration/auth-lifecycle.test.ts @@ -0,0 +1,32 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { createApp } from '../../src/app'; +import { authToken, bearer, resetDb } from '../helpers'; + +describe('auth lifecycle (create worker → sign in → /api/me → admin route 403)', () => { + beforeEach(resetDb); + + it('issues a bearer that authorizes /api/me as a worker but not admin routes', async () => { + const app = createApp(); + const token = await authToken(app, 'auth-lifecycle@example.com', 'worker'); + + // /api/me returns the full worker identity for the freshly-issued token. + const meRes = await app.request('/api/me', { headers: bearer(token) }); + expect(meRes.status).toBe(200); + const { user } = await meRes.json(); + expect(user).toEqual({ + id: expect.any(String), + email: 'auth-lifecycle@example.com', + name: 'auth-lifecycle', + role: 'worker', + }); + + // The same token is rejected by an admin-only route (worker is not an admin). + const adminRes = await app.request('/api/admin/users', { headers: bearer(token) }); + expect(adminRes.status).toBe(403); + + // The bearer keeps working across repeated requests (stateless token, not one-shot). + for (let i = 0; i < 3; i++) { + expect((await app.request('/api/me', { headers: bearer(token) })).status).toBe(200); + } + }); +}); diff --git a/apps/api/test/me.test.ts b/apps/api/test/me.test.ts index fcd6264..ac8e86f 100644 --- a/apps/api/test/me.test.ts +++ b/apps/api/test/me.test.ts @@ -1,8 +1,11 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, afterEach, vi } from 'vitest'; import { createApp } from '../src/app'; +import { auth } from '../src/auth'; import { authToken, bearer } from './helpers'; describe('GET /api/me', () => { + afterEach(() => vi.restoreAllMocks()); + it('rejects an unauthenticated request', async () => { const app = createApp(); const res = await app.request('/api/me'); @@ -39,4 +42,24 @@ describe('GET /api/me', () => { const body = await res.json(); expect(body.user.role).toBe('admin'); }); + + it("defaults role to 'worker' when the session user has no role", async () => { + const app = createApp(); + // A role-less session can't arise from a real sign-in (the admin plugin always + // sets a role), so stub getSession to hit the `?? 'worker'` fallback branch. + vi.spyOn(auth.api, 'getSession').mockResolvedValue({ + user: { id: 'u1', email: 'norole@example.com', name: 'No Role' }, + session: {}, + } as never); + + const res = await app.request('/api/me', { headers: bearer('irrelevant') }); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.user).toEqual({ + id: 'u1', + email: 'norole@example.com', + name: 'No Role', + role: 'worker', + }); + }); }); diff --git a/apps/api/test/require-user.test.ts b/apps/api/test/require-user.test.ts new file mode 100644 index 0000000..389e4da --- /dev/null +++ b/apps/api/test/require-user.test.ts @@ -0,0 +1,43 @@ +import { describe, it, expect, afterEach, vi } from 'vitest'; +import type { Context } from 'hono'; +import { auth } from '../src/auth'; +import { getSessionUser, isAdmin } from '../src/lib/require-user'; + +// getSessionUser/isAdmin are the auth gate's building blocks. The role/banned +// fallbacks can't be reached through a real sign-in (the admin plugin always sets +// a role), so exercise them by stubbing the one thing getSessionUser reads: +// auth.api.getSession. vi is already the suite's mocking tool (see env.test.ts). +const fakeCtx = { req: { raw: { headers: new Headers() } } } as unknown as Context; + +function mockSession(sessionUser: Record | null) { + vi.spyOn(auth.api, 'getSession').mockResolvedValue( + (sessionUser ? { user: sessionUser, session: {} } : null) as never, + ); +} + +describe('getSessionUser', () => { + afterEach(() => vi.restoreAllMocks()); + + it('returns null when there is no session', async () => { + mockSession(null); + expect(await getSessionUser(fakeCtx)).toBeNull(); + }); + + it('maps id, role and banned from the session user', async () => { + mockSession({ id: 'u1', role: 'admin', banned: true }); + expect(await getSessionUser(fakeCtx)).toEqual({ id: 'u1', role: 'admin', banned: true }); + }); + + it("defaults role to 'worker' and banned to false when they are unset", async () => { + mockSession({ id: 'u2' }); + expect(await getSessionUser(fakeCtx)).toEqual({ id: 'u2', role: 'worker', banned: false }); + }); +}); + +describe('isAdmin', () => { + it('is true only for the admin role', () => { + expect(isAdmin({ id: 'a', role: 'admin', banned: false })).toBe(true); + expect(isAdmin({ id: 'w', role: 'worker', banned: false })).toBe(false); + expect(isAdmin(null)).toBe(false); + }); +}); diff --git a/docs/reference/domain-test-checklist.md b/docs/reference/domain-test-checklist.md index 29ee3dc..91b4f77 100644 --- a/docs/reference/domain-test-checklist.md +++ b/docs/reference/domain-test-checklist.md @@ -40,3 +40,26 @@ Guaranteed behaviors: seeded). - The `migrate.ts` / `seed.ts` CLI direct-run blocks are `c8 ignore`d — unreachable under the test runner (exercised only via `tsx src/db/{migrate,seed}.ts`). + +## Auth & /me + +Src covered: `src/auth.ts`, `src/routes/me.ts`, `src/lib/require-user.ts`, and the CORS +middleware in `src/app.ts` — all at 100% stmts/branch/funcs/lines. + +Guaranteed behaviors: + +- Sign-in with valid credentials returns a bearer token (`set-auth-token` header); sign-in with + a wrong password fails (status ≥ 400). +- Public sign-up is rejected (`disableSignUp` — admin creates users). +- `GET /api/me` returns 401 without a token, and `{ user: { id, email, name, role } }` with a + valid bearer; role reflects the user (`worker` / `admin`). +- `GET /api/me` falls back to role `'worker'` when the session user has no role set. +- `getSessionUser` maps `id`, `role`, and `banned` from the session; defaults `role` to + `'worker'` and `banned` to `false` when unset; returns `null` when there is no session. +- `isAdmin` is true only for the `admin` role (false for `worker` and for `null`). +- `adminGuard` returns 401 without a session, 403 for a non-admin, and 403 for a banned admin + that still holds a valid token; it passes through for an active admin. (Exercised through the + `/api/admin/*` surface in `admin-users.test.ts`.) +- CORS: the allowed SPA origins (worker 5173, admin 5174) are echoed on the + `access-control-allow-origin` header, OPTIONS preflight is answered with the allowed methods, + and `set-auth-token` is exposed so the SPA can read the bearer on sign-in.