637 lines
26 KiB
TypeScript
637 lines
26 KiB
TypeScript
/**
|
|
* 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<void>) => 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<void>) => {
|
|
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<void>) => {
|
|
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);
|
|
});
|
|
});
|