From 80b5906bb8d792a75a2ecb1a3ddd12ac9321258d Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Wed, 17 Jun 2026 16:32:48 -0400 Subject: [PATCH] test(19-02): add failing tests for self-change password and hasLocalCredential on /api/me MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RED phase for Task 2: - Test 1: POST /api/me/password correct current → 200, new hash verifies newPassword - Test 2: wrong currentPassword → 401, UPDATE not called (hash unchanged) - Test 3: no local_credentials row → 404 - Test 4 (GET /api/me): hasLocalCredential:true/false based on local_credentials existence --- apps/api/tests/routes/me.test.ts | 222 +++++++++++++++++++++++++++++++ 1 file changed, 222 insertions(+) diff --git a/apps/api/tests/routes/me.test.ts b/apps/api/tests/routes/me.test.ts index 9747976..4891e63 100644 --- a/apps/api/tests/routes/me.test.ts +++ b/apps/api/tests/routes/me.test.ts @@ -12,6 +12,9 @@ * - 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 @@ -20,6 +23,7 @@ */ 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. @@ -253,3 +257,221 @@ describe('GET /api/me — isAdmin + needsProviderSetup (Plan 10-02, D-03)', () = 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); + }); +});