test(api): Auth & /me — fill gaps + lifecycle test
This commit is contained in:
@@ -9,6 +9,17 @@ describe('auth', () => {
|
|||||||
expect(token).toBeTruthy();
|
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 () => {
|
it('rejects public sign-up (admin creates users)', async () => {
|
||||||
const app = createApp();
|
const app = createApp();
|
||||||
const res = await app.request('/api/auth/sign-up/email', {
|
const res = await app.request('/api/auth/sign-up/email', {
|
||||||
|
|||||||
32
apps/api/test/integration/auth-lifecycle.test.ts
Normal file
32
apps/api/test/integration/auth-lifecycle.test.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect, afterEach, vi } from 'vitest';
|
||||||
import { createApp } from '../src/app';
|
import { createApp } from '../src/app';
|
||||||
|
import { auth } from '../src/auth';
|
||||||
import { authToken, bearer } from './helpers';
|
import { authToken, bearer } from './helpers';
|
||||||
|
|
||||||
describe('GET /api/me', () => {
|
describe('GET /api/me', () => {
|
||||||
|
afterEach(() => vi.restoreAllMocks());
|
||||||
|
|
||||||
it('rejects an unauthenticated request', async () => {
|
it('rejects an unauthenticated request', async () => {
|
||||||
const app = createApp();
|
const app = createApp();
|
||||||
const res = await app.request('/api/me');
|
const res = await app.request('/api/me');
|
||||||
@@ -39,4 +42,24 @@ describe('GET /api/me', () => {
|
|||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
expect(body.user.role).toBe('admin');
|
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',
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
43
apps/api/test/require-user.test.ts
Normal file
43
apps/api/test/require-user.test.ts
Normal file
@@ -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<string, unknown> | 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -40,3 +40,26 @@ Guaranteed behaviors:
|
|||||||
seeded).
|
seeded).
|
||||||
- The `migrate.ts` / `seed.ts` CLI direct-run blocks are `c8 ignore`d — unreachable under the
|
- 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`).
|
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.
|
||||||
|
|||||||
Reference in New Issue
Block a user