test(19-02): add failing tests for self-change password and hasLocalCredential on /api/me
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
This commit is contained in:
@@ -12,6 +12,9 @@
|
|||||||
* - dev-bypass path returns isAdmin (DB-backed, not hardcoded) + needsProviderSetup
|
* - dev-bypass path returns isAdmin (DB-backed, not hardcoded) + needsProviderSetup
|
||||||
* - OIDC path returns isAdmin + needsProviderSetup
|
* - OIDC path returns isAdmin + needsProviderSetup
|
||||||
* - needsProviderSetup=true when no member_credentials row exists; false when one exists
|
* - 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:
|
* Architecture note:
|
||||||
* devAuthBypass() and devBypassActive in index.ts both evaluate env vars at module
|
* 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 { 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.
|
// 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);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user