/** * GET /api/me — regression tests for dev-auth bypass path + isAdmin/needsProviderSetup * * Covers: * 1. DEV_AUTH_BYPASS=true (non-production): GET /api/me returns 200 with the injected * DEV_USER identity (id=1, displayName='Dev User', color='#4A90D9'). * The OIDC guard must NOT be enforced — no Authelia env vars needed. * 2. Without DEV_AUTH_BYPASS: the OIDC middleware is still wired on /api/*. * Verified structurally by asserting oidcAuthMiddleware is called during app init * (the mock intercepts it and acts as a passthrough, confirming the mount path). * 3. Plan 10-02 additions: * - dev-bypass path returns isAdmin (DB-backed, not hardcoded) + needsProviderSetup * - OIDC path returns isAdmin + needsProviderSetup * - needsProviderSetup=true when no member_credentials row exists; false when one exists * * Architecture note: * devAuthBypass() and devBypassActive in index.ts both evaluate env vars at module * load time. Tests must set process.env BEFORE importing the app module. * vitest.resetModules() ensures each test gets a fresh module registry. */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; // --------------------------------------------------------------------------- // Shared mock: DB — avoids real DB connections across all tests in this file. // This mock is hoisted by Vitest and applies to every dynamic import below. // // Default: select chain returns empty arrays (no rows). // Per-test overrides: use vi.mocked(db.select).mockImplementation(...) to // supply per-call sequences for isAdmin and memberCredentials lookups. // --------------------------------------------------------------------------- vi.mock('../../src/db/client.js', () => ({ db: { execute: vi.fn().mockResolvedValue([[{ '1': 1 }]]), select: vi.fn().mockReturnValue({ from: vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([]), }), innerJoin: vi.fn().mockReturnValue({ innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]), }), }), }), }), }, })); // --------------------------------------------------------------------------- // Track whether oidcAuthMiddleware was registered on the app. // The spy is set up fresh per test via beforeEach/afterEach. // --------------------------------------------------------------------------- const oidcMiddlewareSpy = vi.fn(() => async (_c: unknown, next: () => Promise) => next()); vi.mock('@hono/oidc-auth', () => ({ oidcAuthMiddleware: () => oidcMiddlewareSpy(), processOAuthCallback: () => async (c: { json: (v: unknown) => unknown }) => c.json({ ok: true }), getAuth: vi.fn().mockResolvedValue(null), })); // --------------------------------------------------------------------------- // Env snapshot — restored after each test to avoid cross-test pollution. // --------------------------------------------------------------------------- const originalNodeEnv = process.env.NODE_ENV; const originalBypassFlag = process.env.DEV_AUTH_BYPASS; afterEach(() => { process.env.NODE_ENV = originalNodeEnv; if (originalBypassFlag === undefined) { delete process.env.DEV_AUTH_BYPASS; } else { process.env.DEV_AUTH_BYPASS = originalBypassFlag; } vi.resetModules(); oidcMiddlewareSpy.mockClear(); }); // --------------------------------------------------------------------------- describe('GET /api/me — dev-auth bypass (DEV_AUTH_BYPASS=true)', () => { beforeEach(() => { process.env.NODE_ENV = 'test'; process.env.DEV_AUTH_BYPASS = 'true'; }); it('returns 200 with the injected dev user identity', async () => { // Import AFTER setting env — index.ts reads env at module load time. const { app } = await import('../../src/index.js'); const { DEV_USER } = await import('../../src/auth/devBypass.js'); const res = await app.request('/api/me'); expect(res.status).toBe(200); const body = (await res.json()) as { user: { id: number; displayName: string; color: string } }; expect(body).toHaveProperty('user'); expect(body.user.id).toBe(DEV_USER.id); expect(body.user.displayName).toBe(DEV_USER.displayName); expect(body.user.color).toBe(DEV_USER.color); }); it('returns id=1 and color=#4A90D9 (first palette slot)', async () => { const { app } = await import('../../src/index.js'); const res = await app.request('/api/me'); expect(res.status).toBe(200); const body = (await res.json()) as { user: { id: number; color: string } }; expect(body.user.id).toBe(1); expect(body.user.color).toBe('#4A90D9'); }); it('does not invoke oidcAuthMiddleware on /api/* when bypass is active', async () => { const { app } = await import('../../src/index.js'); // Hit any /api/* route to trigger the middleware stack. await app.request('/api/me'); // oidcAuthMiddleware() factory must NOT have been called — index.ts skips it. expect(oidcMiddlewareSpy).not.toHaveBeenCalled(); }); }); describe('GET /api/me — OIDC path (no DEV_AUTH_BYPASS)', () => { beforeEach(() => { process.env.NODE_ENV = 'test'; delete process.env.DEV_AUTH_BYPASS; }); it('wires oidcAuthMiddleware on /api/* when bypass is not active', async () => { // Import app — devBypassActive will be false, so oidcAuthMiddleware() is called // during app construction (index.ts registers it via app.use('/api/*', ...)). await import('../../src/index.js'); // The spy wraps the oidcAuthMiddleware() factory call in index.ts. // It must have been called exactly once (one app.use registration). expect(oidcMiddlewareSpy).toHaveBeenCalledTimes(1); }); it('returns 401 when no OIDC session is present (getAuth returns null)', async () => { const { app } = await import('../../src/index.js'); // oidcAuthMiddleware is mocked as a passthrough; getAuth is mocked to return null. // me.ts falls through to the getAuth path and returns 401. const res = await app.request('/api/me'); expect(res.status).toBe(401); const body = (await res.json()) as { error: string }; expect(body.error).toBe('Unauthorized'); }); }); // --------------------------------------------------------------------------- // Plan 10-02: isAdmin + needsProviderSetup on /api/me (D-03) // --------------------------------------------------------------------------- describe('GET /api/me — isAdmin + needsProviderSetup (Plan 10-02, D-03)', () => { beforeEach(() => { process.env.NODE_ENV = 'test'; process.env.DEV_AUTH_BYPASS = 'true'; }); it('dev-bypass: response includes isAdmin (DB-backed from users.is_admin, not hardcoded)', async () => { // Set up db.select to return isAdmin=true for the users lookup, // and [] for the memberCredentials lookup (needsProviderSetup=true). const { db } = await import('../../src/db/client.js'); let callCount = 0; vi.mocked(db.select).mockImplementation(() => { callCount++; const limitFn = callCount === 1 ? vi.fn().mockResolvedValue([{ isAdmin: true }]) // users.isAdmin lookup : vi.fn().mockResolvedValue([]); // memberCredentials lookup (none) return { from: vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue({ limit: limitFn }), innerJoin: vi.fn().mockReturnValue({ innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }), }), }), // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any; }); const { app } = await import('../../src/index.js'); const res = await app.request('/api/me'); expect(res.status).toBe(200); const body = (await res.json()) as { user: { id: number; isAdmin: boolean; needsProviderSetup: boolean }; }; expect(body.user).toHaveProperty('isAdmin'); expect(body.user.isAdmin).toBe(true); // DB returns true, not hardcoded }); it('dev-bypass: needsProviderSetup=true when no member_credentials row exists', async () => { const { db } = await import('../../src/db/client.js'); let callCount = 0; vi.mocked(db.select).mockImplementation(() => { callCount++; const limitFn = callCount === 1 ? vi.fn().mockResolvedValue([{ isAdmin: false }]) // users.isAdmin lookup : vi.fn().mockResolvedValue([]); // no member_credentials row return { from: vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue({ limit: limitFn }), innerJoin: vi.fn().mockReturnValue({ innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }), }), }), // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any; }); const { app } = await import('../../src/index.js'); const res = await app.request('/api/me'); expect(res.status).toBe(200); const body = (await res.json()) as { user: { needsProviderSetup: boolean } }; expect(body.user).toHaveProperty('needsProviderSetup'); expect(body.user.needsProviderSetup).toBe(true); }); it('dev-bypass: needsProviderSetup=false when a member_credentials row exists', async () => { const { db } = await import('../../src/db/client.js'); let callCount = 0; vi.mocked(db.select).mockImplementation(() => { callCount++; const limitFn = callCount === 1 ? vi.fn().mockResolvedValue([{ isAdmin: false }]) // users.isAdmin lookup : vi.fn().mockResolvedValue([{ id: 7 }]); // has member_credentials row return { from: vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue({ limit: limitFn }), innerJoin: vi.fn().mockReturnValue({ innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }), }), }), // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any; }); const { app } = await import('../../src/index.js'); const res = await app.request('/api/me'); expect(res.status).toBe(200); const body = (await res.json()) as { user: { needsProviderSetup: boolean } }; expect(body.user).toHaveProperty('needsProviderSetup'); expect(body.user.needsProviderSetup).toBe(false); }); });