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