/** * 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 = await 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(await verifyPassword(updatedHash!, newPassword)).toBe(true); expect(await verifyPassword(updatedHash!, oldPassword)).toBe(false); }); it('Test 2: wrong currentPassword → 403 and update is NOT called', async () => { const { db } = await import('../../src/db/client.js'); const realPassword = 'real-password-correct-789'; const storedHash = await 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' }), }); // CR-03: wrong current password returns 403 (in-app authz failure), NOT 401. // A 401 would be interpreted by the PWA as session expiry and log the user out. expect(res.status).toBe(403); 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); }); it('Test 4 (WR-05 no-echo): malformed body never echoes the submitted password or Zod received field', async () => { // newPassword too short (< 8) → Zod rejects via meNoEchoHook. The response must be // ONLY { error: 'Invalid request' } and must NOT leak the submitted password or the // Zod issues[].received field (T-19-06). const { app } = await import('../../src/index.js'); const submitted = 'my-secret-current-pw'; const res = await app.request('/api/me/password', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ currentPassword: submitted, newPassword: 'short' }), }); expect(res.status).toBe(400); const bodyText = await res.text(); expect(bodyText).not.toContain(submitted); expect(bodyText).not.toContain('received'); expect(bodyText).not.toContain('issues'); const parsed = JSON.parse(bodyText) as { error: string }; expect(parsed.error).toBe('Invalid request'); }); }); // --------------------------------------------------------------------------- // 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); }); }); // --------------------------------------------------------------------------- // Plan 19-02: linkOidcToUser helper + POST /api/me/link-oidc (AUTH-LOCAL-10) // --------------------------------------------------------------------------- describe('linkOidcToUser helper (AUTH-LOCAL-10, T-19-08)', () => { 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: linkOidcToUser updates users.oidc_iss/sub and deletes local_credentials for userId', async () => { const { db } = await import('../../src/db/client.js'); const { linkOidcToUser } = await import('../../src/auth/linkOidc.js'); const iss = 'https://auth.example.com'; const sub = 'user-sub-abc-123'; let updatedUsers = false; let deletedLocalCreds = false; // Mock: SELECT users WHERE oidc_iss = iss AND oidc_sub = sub → no conflict (empty) let txSelectCount = 0; const mockTx = { select: vi.fn().mockImplementation(() => { txSelectCount++; return { from: vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([]) }), // no conflict innerJoin: vi.fn().mockReturnValue({ innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }) }), }), }; }), update: vi.fn().mockImplementation(() => ({ set: vi.fn().mockImplementation(() => { updatedUsers = true; return { where: vi.fn().mockResolvedValue({ rowsAffected: 1 }) }; }), })), delete: vi.fn().mockImplementation(() => ({ where: vi.fn().mockImplementation(() => { deletedLocalCreds = true; return Promise.resolve({ rowsAffected: 1 }); }), })), }; vi.mocked(db.select).mockImplementation(() => ({ from: vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([]) }), // preflight: no conflict innerJoin: vi.fn().mockReturnValue({ innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }) }), }), // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any)); // eslint-disable-next-line @typescript-eslint/no-explicit-any vi.mocked(db).transaction = vi.fn().mockImplementation(async (fn: (tx: any) => Promise) => { await fn(mockTx); }); await linkOidcToUser(42, iss, sub); expect(updatedUsers).toBe(true); expect(deletedLocalCreds).toBe(true); }); it('Test 2: linkOidcToUser throws OidcLinkConflictError and does NOT delete local_credentials when iss+sub belongs to different user', async () => { const { db } = await import('../../src/db/client.js'); const { linkOidcToUser, OidcLinkConflictError } = await import('../../src/auth/linkOidc.js'); const iss = 'https://auth.example.com'; const sub = 'already-taken-sub'; const conflictingUserId = 99; // different from target userId 42 let deletedLocalCreds = false; // Preflight SELECT finds a conflicting user (id=99, different from target=42) vi.mocked(db.select).mockImplementation(() => ({ from: vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([{ id: conflictingUserId }]), // conflict! }), innerJoin: vi.fn().mockReturnValue({ innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }) }), }), // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any)); // Transaction should NEVER be called on conflict const mockTx = { delete: vi.fn().mockImplementation(() => ({ where: vi.fn().mockImplementation(() => { deletedLocalCreds = true; return Promise.resolve({ rowsAffected: 1 }); }), })), }; // eslint-disable-next-line @typescript-eslint/no-explicit-any vi.mocked(db).transaction = vi.fn().mockImplementation(async (fn: (tx: any) => Promise) => { await fn(mockTx); }); // Should throw OidcLinkConflictError, not proceed to transaction await expect(linkOidcToUser(42, iss, sub)).rejects.toThrow(OidcLinkConflictError); // local_credentials row must NOT have been deleted (binding aborted before any write) expect(deletedLocalCreds).toBe(false); // Transaction must not have been called expect(vi.mocked(db).transaction).not.toHaveBeenCalled(); }); it('Test 3: POST /api/me/link-oidc returns response shape with authorization URL / initiation payload', async () => { const { db } = await import('../../src/db/client.js'); // Mock db — not needed for route shape test but avoids errors vi.mocked(db.select).mockImplementation(() => ({ from: vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([{ isAdmin: false }]) }), 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/link-oidc', { method: 'POST', headers: { 'Content-Type': 'application/json' }, }); // Must be 200 with an initiation payload (not the actual OIDC redirect — that's 19-03) expect(res.status).toBe(200); const body = (await res.json()) as { authorizationUrl?: string; state?: string }; // The response must have at minimum a signedState field (or authorizationUrl) // — the exact shape depends on implementation; assert it's an object with a useful field const hasInitiationPayload = 'authorizationUrl' in body || 'state' in body || 'signedState' in body; expect(hasInitiationPayload).toBe(true); }); });