/** * 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 * 4. Plan 19-02 additions: * - POST /api/me/password: self-change with correct/wrong current-password * - GET /api/me: hasLocalCredential field * * 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'; import { hashPassword } from '../../src/auth/localCredentials.js'; // --------------------------------------------------------------------------- // 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); }); }); // --------------------------------------------------------------------------- // Plan 19-02: POST /api/me/password — self-change password (AUTH-LOCAL-09, T-19-07) // --------------------------------------------------------------------------- describe('POST /api/me/password — self-change password (AUTH-LOCAL-09)', () => { beforeEach(() => { process.env.NODE_ENV = 'test'; process.env.DEV_AUTH_BYPASS = 'true'; process.env.LOCAL_SESSION_SECRET = 'test-secret-that-is-long-enough-32chars!!'; }); it('Test 1: correct currentPassword → 200 and stored hash verifies newPassword', async () => { const { db } = await import('../../src/db/client.js'); const oldPassword = 'old-password-correct-123'; const newPassword = 'new-password-secure-456'; const storedHash = hashPassword(oldPassword); let updatedHash: string | null = null; // Mock sequence: resolveUserId (devBypass sets user), then: // 1. SELECT local_credentials WHERE user_id (returns row with stored hash) // 2. UPDATE local_credentials SET password_hash (capture the new hash) let callCount = 0; vi.mocked(db.select).mockImplementation(() => { callCount++; if (callCount === 1) { // local_credentials lookup return { from: vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([{ passwordHash: storedHash, userId: 1 }]), }), innerJoin: vi.fn().mockReturnValue({ innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }) }), }), // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any; } // fallback for other selects return { from: vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([]) }), innerJoin: vi.fn().mockReturnValue({ innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }) }), }), // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any; }); // Mock the UPDATE call — capture what hash it sets vi.mocked(db).update = vi.fn().mockImplementation(() => ({ set: vi.fn().mockImplementation((values: { passwordHash?: string }) => { if (values.passwordHash) updatedHash = values.passwordHash; return { where: vi.fn().mockResolvedValue({ rowsAffected: 1 }), }; }), })); const { app } = await import('../../src/index.js'); const res = await app.request('/api/me/password', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ currentPassword: oldPassword, newPassword }), }); expect(res.status).toBe(200); // The updatedHash must verify the new password expect(updatedHash).not.toBeNull(); const { verifyPassword } = await import('../../src/auth/localCredentials.js'); expect(verifyPassword(updatedHash!, newPassword)).toBe(true); expect(verifyPassword(updatedHash!, oldPassword)).toBe(false); }); it('Test 2: wrong currentPassword → 401 and update is NOT called', async () => { const { db } = await import('../../src/db/client.js'); const realPassword = 'real-password-correct-789'; const storedHash = hashPassword(realPassword); let updateWasCalled = false; let callCount = 0; vi.mocked(db.select).mockImplementation(() => { callCount++; if (callCount === 1) { return { from: vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([{ passwordHash: storedHash, userId: 1 }]), }), innerJoin: vi.fn().mockReturnValue({ innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }) }), }), // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any; } return { from: vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([]) }), innerJoin: vi.fn().mockReturnValue({ innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }) }), }), // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any; }); vi.mocked(db).update = vi.fn().mockImplementation(() => { updateWasCalled = true; return { set: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue({ rowsAffected: 1 }) }), }; }); const { app } = await import('../../src/index.js'); const res = await app.request('/api/me/password', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ currentPassword: 'WRONG-password', newPassword: 'new-pass-12345678' }), }); expect(res.status).toBe(401); const body = (await res.json()) as { error: string }; expect(body.error).toBe('Current password incorrect'); // Update must NOT have been called expect(updateWasCalled).toBe(false); }); it('Test 3: user with no local_credentials row → 404', async () => { const { db } = await import('../../src/db/client.js'); vi.mocked(db.select).mockImplementation(() => ({ from: vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([]) }), 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/password', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ currentPassword: 'any', newPassword: 'new-pass-12345678' }), }); expect(res.status).toBe(404); }); }); // --------------------------------------------------------------------------- // Plan 19-02: GET /api/me — hasLocalCredential field (AUTH-LOCAL-17) // --------------------------------------------------------------------------- describe('GET /api/me — hasLocalCredential (AUTH-LOCAL-17)', () => { beforeEach(() => { process.env.NODE_ENV = 'test'; process.env.DEV_AUTH_BYPASS = 'true'; }); it('Test 4: includes hasLocalCredential:true when local_credentials row exists', async () => { const { db } = await import('../../src/db/client.js'); let callCount = 0; vi.mocked(db.select).mockImplementation(() => { callCount++; // Call order in resolveAdminAndSetupStatus: // 1 → users.isAdmin lookup // 2 → memberCredentials lookup // 3 → localCredentials lookup (new, AUTH-LOCAL-17) let limitResult: object[]; if (callCount === 1) { limitResult = [{ isAdmin: false }]; // users row } else if (callCount === 2) { limitResult = []; // no member_credentials (needsProviderSetup=true) } else { limitResult = [{ id: 42 }]; // has local_credentials row } return { from: vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue(limitResult) }), 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: { hasLocalCredential: boolean } }; expect(body.user).toHaveProperty('hasLocalCredential'); expect(body.user.hasLocalCredential).toBe(true); }); it('hasLocalCredential:false when no local_credentials row', async () => { const { db } = await import('../../src/db/client.js'); let callCount = 0; vi.mocked(db.select).mockImplementation(() => { callCount++; // All 3 selects return empty/minimal const limitResult = callCount === 1 ? [{ isAdmin: false }] : []; return { from: vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue(limitResult) }), 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: { hasLocalCredential: boolean } }; expect(body.user).toHaveProperty('hasLocalCredential'); expect(body.user.hasLocalCredential).toBe(false); }); });