← back to Directory Core
test/auth-factory.test.ts
247 lines
// test/auth-factory.test.ts
// Tests for the createAuth({...}) factory added in tick 24 (architect-driven
// refactor). Validates that:
// - Different verticals can use different table/column/cookie names
// - DUMMY_HASH defense works (constant time regardless of user existence)
// - __Host- cookie prefix activates in production
// - Custom isAdmin predicates work
process.env['DATABASE_URL'] = 'postgresql://fake:fake@localhost:5432/fake_test';
import { describe, it, before, beforeEach } from 'node:test';
import assert from 'node:assert/strict';
import { createAuth } from '../src/auth.js';
// ── Mock query function ──────────────────────────────────────────────────
function makeMockQuery(impl: (text: string, params: unknown[]) => unknown) {
return async (text: string, params: unknown[]) => {
const rows = impl(text, params);
return { rows: Array.isArray(rows) ? rows : (rows ? [rows] : []), rowCount: Array.isArray(rows) ? rows.length : (rows ? 1 : 0) } as any;
};
}
function makeMockRes() {
const headers: Record<string, string> = {};
return {
headers,
setHeader(name: string, val: string) { headers[name] = val; },
status() { return this; },
send() { return this; },
redirect() { return this; },
json() { return this; },
} as any;
}
describe('createAuth — config defaults', () => {
it('returns config introspection with default values', () => {
const auth = createAuth({ query: makeMockQuery(() => null) });
assert.equal(auth.config.userTable, 'app_users');
assert.equal(auth.config.sessionTable, 'app_sessions');
assert.equal(auth.config.sessionPkColumn, 'id');
assert.equal(auth.config.sessionUserFkColumn, 'user_id');
assert.equal(auth.config.cookieMaxAgeSeconds, 30 * 86400);
});
it('honors custom table + column overrides (animals shape)', () => {
const auth = createAuth({
query: makeMockQuery(() => null),
sessionTable: 'app_sessions',
sessionPkColumn: 'token',
sessionUserFkColumn: 'app_user_id',
cookieMaxAgeSeconds: 90 * 86400,
});
assert.equal(auth.config.sessionPkColumn, 'token');
assert.equal(auth.config.sessionUserFkColumn, 'app_user_id');
assert.equal(auth.config.cookieMaxAgeSeconds, 90 * 86400);
});
});
describe('createAuth — SQL templating', () => {
it('lawyer-default uses app_sessions.id + user_id columns', async () => {
const calls: Array<{ text: string; params: unknown[] }> = [];
const auth = createAuth({
query: makeMockQuery((text, params) => { calls.push({ text, params }); return null; }),
});
await auth.destroySession('test-sid');
assert.equal(calls.length, 1);
assert.match(calls[0].text, /DELETE FROM app_sessions WHERE id = \$1/);
});
it('animals-shape uses app_sessions.token + app_user_id columns', async () => {
const calls: Array<{ text: string; params: unknown[] }> = [];
const auth = createAuth({
query: makeMockQuery((text, params) => { calls.push({ text, params }); return null; }),
sessionPkColumn: 'token',
sessionUserFkColumn: 'app_user_id',
});
await auth.destroySession('test-token');
assert.equal(calls.length, 1);
assert.match(calls[0].text, /DELETE FROM app_sessions WHERE token = \$1/);
});
it('createSession uses configured PK + FK column names', async () => {
const calls: Array<{ text: string; params: unknown[] }> = [];
const auth = createAuth({
query: makeMockQuery((text, params) => { calls.push({ text, params }); return null; }),
sessionPkColumn: 'token',
sessionUserFkColumn: 'app_user_id',
});
await auth.createSession(42, '127.0.0.1', 'test-ua');
// First call: INSERT INTO app_sessions
const insert = calls.find(c => c.text.includes('INSERT INTO app_sessions'));
assert.ok(insert, 'should issue INSERT');
assert.match(insert!.text, /INSERT INTO app_sessions \(token, app_user_id, expires_at, ip, user_agent\)/);
});
it('findUserById SELECT honors custom userColumns', async () => {
let captured = '';
const auth = createAuth({
query: makeMockQuery((text) => { captured = text; return { id: 1, status: 'active', handle: 'foo' }; }),
userColumns: ['id', 'email', 'handle', 'home_zip', 'home_lat', 'home_lng', 'status'],
});
await auth.findUserById(1);
assert.match(captured, /SELECT id, email, handle, home_zip, home_lat, home_lng, status FROM app_users/);
assert.doesNotMatch(captured, /professional_id/, 'should NOT include lawyer-only column when userColumns overridden');
});
});
describe('createAuth — DUMMY_HASH defense', () => {
it('findUserAndVerifyPassword returns null on bad password (user exists)', async () => {
// bcrypt hash of 'correct-password' (precomputed)
const knownHash = '$2b$10$KIXz7bDNGVWUDrvnBzm9ueShy6mxWbz/uKqhGjzPzEKx8jMnMYjN6';
const auth = createAuth({
query: makeMockQuery(() => ({ id: 1, password_hash: knownHash, status: 'active' })),
});
const r = await auth.findUserAndVerifyPassword('user@example.com', 'wrong-password');
assert.equal(r, null);
});
it('findUserAndVerifyPassword returns null on no-such-user (and ran bcrypt anyway)', async () => {
const start = Date.now();
const auth = createAuth({
query: makeMockQuery(() => null), // no user
});
const r = await auth.findUserAndVerifyPassword('ghost@example.com', 'any-password');
const elapsed = Date.now() - start;
assert.equal(r, null);
// bcrypt.compare against DUMMY_HASH takes meaningful time (>20ms typically
// for cost=10). If we short-circuited on no-user, this would be <5ms.
assert.ok(elapsed > 20, `expected DUMMY_HASH defense to take time, got ${elapsed}ms`);
});
});
describe('createAuth — __Host- cookie prefix', () => {
let originalEnv: string | undefined;
before(() => { originalEnv = process.env.NODE_ENV; });
beforeEach(() => { process.env.NODE_ENV = ''; });
it('uses base cookie name when not in production', () => {
process.env.NODE_ENV = 'development';
const auth = createAuth({
query: makeMockQuery(() => null),
cookieName: 'animals_session',
cookieHostPrefix: true,
});
assert.equal(auth.cookieName(), 'animals_session');
});
it('uses __Host- prefix in production when cookieHostPrefix=true', () => {
process.env.NODE_ENV = 'production';
const auth = createAuth({
query: makeMockQuery(() => null),
cookieName: 'animals_session',
cookieHostPrefix: true,
});
assert.equal(auth.cookieName(), '__Host-animals_session');
});
it('does NOT use __Host- prefix when cookieHostPrefix=false', () => {
process.env.NODE_ENV = 'production';
const auth = createAuth({
query: makeMockQuery(() => null),
cookieName: 'sid',
cookieHostPrefix: false,
});
assert.equal(auth.cookieName(), 'sid');
});
// Restore env
before(() => { if (originalEnv !== undefined) process.env.NODE_ENV = originalEnv; });
});
describe('createAuth — custom isAdmin predicate', () => {
it('default predicate matches role=admin OR tier=admin', () => {
const auth = createAuth({ query: makeMockQuery(() => null) });
const res = makeMockRes();
let calledNext = false;
const next = () => { calledNext = true; };
// role=admin → calls next
const req: any = { user: { id: 1, role: 'admin' } };
auth.requireAdmin(req, res, next);
assert.equal(calledNext, true);
// tier=admin → calls next
calledNext = false;
auth.requireAdmin({ user: { id: 1, tier: 'admin' } } as any, res, next);
assert.equal(calledNext, true);
// role=user → 403
calledNext = false;
auth.requireAdmin({ user: { id: 1, role: 'user' } } as any, res, next);
assert.equal(calledNext, false);
});
it('custom isAdmin predicate replaces default', () => {
const auth = createAuth({
query: makeMockQuery(() => null),
isAdmin: (u: any) => u.role === 'business_owner', // animals' admin enum
});
const res = makeMockRes();
let calledNext = false;
const next = () => { calledNext = true; };
auth.requireAdmin({ user: { id: 1, role: 'business_owner' } } as any, res, next);
assert.equal(calledNext, true, 'business_owner should pass animals predicate');
calledNext = false;
auth.requireAdmin({ user: { id: 1, role: 'admin' } } as any, res, next);
assert.equal(calledNext, false, 'plain "admin" should NOT pass animals predicate');
});
});
describe('createAuth — activeStatusValue', () => {
it('null skips status filter (animals uses suspended_at TIMESTAMPTZ)', async () => {
let foundUserCalls = 0;
const auth = createAuth({
query: makeMockQuery((text) => {
if (text.includes('FROM app_sessions')) {
return { user_id: 5 };
}
if (text.includes('FROM app_users')) {
foundUserCalls++;
return { id: 5 }; // no status field
}
return null;
}),
activeStatusValue: null, // animals doesn't have a status string column
});
const u = await auth.getSessionUser('some-sid');
assert.ok(u, 'should return user when activeStatusValue=null');
assert.equal(foundUserCalls, 1);
});
it('default "active" filter rejects suspended user', async () => {
const auth = createAuth({
query: makeMockQuery((text) => {
if (text.includes('FROM app_sessions')) return { user_id: 5 };
if (text.includes('FROM app_users')) return { id: 5, status: 'suspended' };
return null;
}),
});
const u = await auth.getSessionUser('some-sid');
assert.equal(u, null, 'suspended user should be filtered out');
});
});