← back to Directory Core

test/auth.test.ts

265 lines

// test/auth.test.ts
// Tests for src/auth.ts — password hashing, requireAdminToken, cookie helpers.
//
// auth.ts → db.ts throws at evaluation if DATABASE_URL is unset. Because
// node --test runs each file in a worker, top-level `process.env` assignments
// execute AFTER the module graph begins loading. We therefore set DATABASE_URL
// before any import, using the node --env-file mechanism isn't available here,
// so instead we set it at the very top of this file (synchronously, before
// the ESM static imports are evaluated by tsx's transform) and use dynamic
// import() inside before() for the functions under test.
//
// tsx transforms static imports into requires in the CJS shim it injects,
// but with --import tsx the loader runs synchronously before user code.
// The safe approach: set DATABASE_URL in the node options via the env,
// OR use a .env file. Here we use a thin env-file trick: we write the var
// into process.env at the very top, which works because tsx (as a loader)
// defers module execution until after the environment is established.

// ── Set DATABASE_URL before any module evaluation ─────────────────────────────
// This line MUST be the first executable line. tsx's --import loader evaluates
// this file top-to-bottom before resolving static imports when run via the
// tsx ESM loader. In Node 22 + tsx the env assignment happens before the
// imported modules execute their top-level code.
process.env['DATABASE_URL'] = 'postgresql://fake:fake@localhost:5432/fake_test';

import { describe, it, before, after, beforeEach } from 'node:test';
import assert from 'node:assert/strict';

// ── Mock req/res builders ─────────────────────────────────────────────────────

function makeRes() {
  const headers: Record<string, string | string[]> = {};
  let statusCode = 200;
  let body = '';
  return {
    setHeader(name: string, value: string | string[]) { headers[name] = value; },
    status(code: number) { statusCode = code; return this; },
    send(b: string) { body = b; return this; },
    redirect(_code: number, _url: string) { statusCode = _code; body = _url; return this; },
    get headers() { return headers; },
    get statusCode() { return statusCode; },
    get body() { return body; },
  };
}

function makeReq(overrides: { headers?: Record<string, string>; query?: Record<string, string> } = {}) {
  return {
    header: (name: string) => overrides.headers?.[name] ?? undefined,
    query: overrides.query ?? {},
    headers: {},
    originalUrl: '/test',
  };
}

// ── Lazily loaded module exports ───────────────────────────────────────────────

type AuthModule = typeof import('../src/auth.js');
let authMod: AuthModule;

before(async () => {
  // Dynamic import runs after the process.env assignment above, so db.ts
  // sees DATABASE_URL correctly.
  authMod = await import('../src/auth.js') as AuthModule;
});

// ── Tests ─────────────────────────────────────────────────────────────────────

describe('hashPassword / verifyPassword', () => {
  it('round-trip: hash then verify returns true', async () => {
    const hash = await authMod.hashPassword('secret123');
    const ok = await authMod.verifyPassword('secret123', hash);
    assert.equal(ok, true);
  });

  it('wrong password returns false', async () => {
    const hash = await authMod.hashPassword('correct-password');
    const ok = await authMod.verifyPassword('wrong-password', hash);
    assert.equal(ok, false);
  });

  it('produces a bcrypt hash string', async () => {
    const hash = await authMod.hashPassword('anypass');
    assert.match(hash, /^\$2[ab]\$\d+\$/);
  });

  it('two hashes of the same password differ (salted)', async () => {
    const h1 = await authMod.hashPassword('same');
    const h2 = await authMod.hashPassword('same');
    assert.notEqual(h1, h2);
  });
});

describe('requireAdminToken', () => {
  // Helper: set token, run fn, restore env. Keeps each test hermetic.
  function withToken(token: string | undefined, fn: () => void) {
    const saved = process.env.ADMIN_TOKEN;
    if (token === undefined) delete process.env.ADMIN_TOKEN;
    else process.env.ADMIN_TOKEN = token;
    try { fn(); }
    finally {
      if (saved === undefined) delete process.env.ADMIN_TOKEN;
      else process.env.ADMIN_TOKEN = saved;
    }
  }

  it('returns 500 when ADMIN_TOKEN env var is unset', () => {
    withToken(undefined, () => {
      const req = makeReq();
      const res = makeRes();
      let nextCalled = false;
      authMod.requireAdminToken(req as any, res as any, () => { nextCalled = true; });
      assert.equal(res.statusCode, 500);
      assert.equal(nextCalled, false);
    });
  });

  it('returns 401 when X-Admin-Token header is missing', () => {
    withToken('my-secret-token', () => {
      const req = makeReq({ headers: {} });
      const res = makeRes();
      let nextCalled = false;
      authMod.requireAdminToken(req as any, res as any, () => { nextCalled = true; });
      assert.equal(res.statusCode, 401);
      assert.equal(nextCalled, false);
    });
  });

  it('returns 403 when token is present but wrong', () => {
    withToken('correct-token', () => {
      const req = makeReq({ headers: { 'X-Admin-Token': 'wrong-token' } });
      const res = makeRes();
      let nextCalled = false;
      authMod.requireAdminToken(req as any, res as any, () => { nextCalled = true; });
      assert.equal(res.statusCode, 403);
      assert.equal(nextCalled, false);
    });
  });

  it('returns 403 (not a crash) when submitted token is longer than expected', () => {
    withToken('short', () => {
      const req = makeReq({ headers: { 'X-Admin-Token': 'a-much-longer-token-than-expected' } });
      const res = makeRes();
      let nextCalled = false;
      assert.doesNotThrow(() => {
        authMod.requireAdminToken(req as any, res as any, () => { nextCalled = true; });
      });
      assert.equal(res.statusCode, 403);
      assert.equal(nextCalled, false);
    });
  });

  it('returns 403 (not a crash) when submitted token is shorter than expected', () => {
    withToken('a-much-longer-token-than-submitted', () => {
      const req = makeReq({ headers: { 'X-Admin-Token': 'short' } });
      const res = makeRes();
      let nextCalled = false;
      assert.doesNotThrow(() => {
        authMod.requireAdminToken(req as any, res as any, () => { nextCalled = true; });
      });
      assert.equal(res.statusCode, 403);
      assert.equal(nextCalled, false);
    });
  });

  it('calls next() when token matches exactly', () => {
    withToken('valid-token-abc', () => {
      const req = makeReq({ headers: { 'X-Admin-Token': 'valid-token-abc' } });
      const res = makeRes();
      let nextCalled = false;
      authMod.requireAdminToken(req as any, res as any, () => { nextCalled = true; });
      assert.equal(nextCalled, true);
      assert.equal(res.statusCode, 200); // untouched
    });
  });

  it('rejects query-param token (security: tokens must not appear in logs)', () => {
    // requireAdminToken deliberately ignores ?admin_token= query params to
    // prevent token leakage via nginx access logs, proxy history, analytics.
    withToken('query-token', () => {
      const req = makeReq({ headers: {}, query: { admin_token: 'query-token' } });
      const res = makeRes();
      let nextCalled = false;
      authMod.requireAdminToken(req as any, res as any, () => { nextCalled = true; });
      // Should return 401 (header missing), NOT call next()
      assert.equal(res.statusCode, 401);
      assert.equal(nextCalled, false);
    });
  });
});

describe('setSessionCookie', () => {
  let savedEnv: string | undefined;

  before(() => { savedEnv = process.env.NODE_ENV; });
  after(() => {
    if (savedEnv === undefined) delete process.env.NODE_ENV;
    else process.env.NODE_ENV = savedEnv;
  });

  it('sets HttpOnly and SameSite=Lax', () => {
    process.env.NODE_ENV = 'development';
    const res = makeRes();
    authMod.setSessionCookie(res as any, 'test-session-id');
    const header = res.headers['Set-Cookie'] as string;
    assert.ok(header.includes('HttpOnly'), `expected HttpOnly in: ${header}`);
    assert.ok(header.toLowerCase().includes('samesite=lax'), `expected SameSite=Lax in: ${header}`);
  });

  it('does NOT set Secure flag in development', () => {
    process.env.NODE_ENV = 'development';
    const res = makeRes();
    authMod.setSessionCookie(res as any, 'test-session-id');
    const header = res.headers['Set-Cookie'] as string;
    // The word "Secure" must not appear as a standalone attribute
    assert.ok(
      !/(^|;\s*)Secure(\s*;|$)/i.test(header),
      `should NOT include Secure in dev. Got: ${header}`,
    );
  });

  it('sets Secure flag in production', () => {
    process.env.NODE_ENV = 'production';
    const res = makeRes();
    authMod.setSessionCookie(res as any, 'test-session-id');
    const header = res.headers['Set-Cookie'] as string;
    assert.ok(/(^|;\s*)Secure(\s*;|$)/i.test(header), `should include Secure in production. Got: ${header}`);
  });

  it('encodes the session id into the cookie value', () => {
    process.env.NODE_ENV = 'development';
    const res = makeRes();
    authMod.setSessionCookie(res as any, 'my-unique-sid');
    const header = res.headers['Set-Cookie'] as string;
    assert.ok(header.includes('my-unique-sid'), `cookie should contain the session id. Got: ${header}`);
  });
});

describe('clearSessionCookie', () => {
  let savedEnv: string | undefined;

  before(() => { savedEnv = process.env.NODE_ENV; });
  after(() => {
    if (savedEnv === undefined) delete process.env.NODE_ENV;
    else process.env.NODE_ENV = savedEnv;
  });

  it('sets Max-Age=0 to expire the cookie immediately', () => {
    process.env.NODE_ENV = 'development';
    const res = makeRes();
    authMod.clearSessionCookie(res as any);
    const header = res.headers['Set-Cookie'] as string;
    assert.ok(header.includes('Max-Age=0'), `should include Max-Age=0. Got: ${header}`);
  });

  it('sets empty string as cookie value', () => {
    process.env.NODE_ENV = 'development';
    const res = makeRes();
    authMod.clearSessionCookie(res as any);
    const header = res.headers['Set-Cookie'] as string;
    // cookie.serialize('sid', '') → "sid=; ..."
    assert.ok(/^sid=;/.test(header) || /^sid=$/.test(header.split(';')[0].trim()),
      `should have empty sid value. Got: ${header}`);
  });
});