← 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');
  });
});