← back to Dw Nextjs Admin Login

test/auth.test.ts

285 lines

/**
 * Tests for @dw/nextjs-admin-login
 *
 * Run with: node --import tsx --test test/*.test.ts
 *
 * The 'next/server' peer dep is stubbed via Module.register mock below.
 * We use Node's built-in test runner (node:test) — no extra deps.
 */

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

// ---------------------------------------------------------------------------
// Stub next/server so tests run without a Next.js install
// ---------------------------------------------------------------------------

// Simple in-process stub: we intercept 'next/server' via a mock loader.
// Since tsx runs under --import, we can use a manual Module.prototype trick.
// Easiest portable approach for Node 22: register a custom resolver that
// returns a synthetic module for 'next/server'.

// We'll inject it into the module cache via a loader hook file.
// The stub is written to a temp file and registered before importing the source.

import { writeFileSync, unlinkSync, mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';

// Write a CJS-compatible stub loader that replaces next/server
const stubDir = mkdtempSync(join(tmpdir(), 'dw-login-test-'));
const stubFile = join(stubDir, 'next-server-stub.mjs');

writeFileSync(
  stubFile,
  `
// Minimal NextResponse stub for unit tests
class NextResponse extends Response {
  static json(body, init) {
    const res = new NextResponse(JSON.stringify(body), {
      ...init,
      headers: { 'Content-Type': 'application/json', ...(init?.headers ?? {}) },
    });
    return res;
  }
}
class NextRequest extends Request {}
export { NextResponse, NextRequest };
`,
);

// Use a loader hook to intercept 'next/server'
const loaderFile = join(stubDir, 'loader.mjs');
writeFileSync(
  loaderFile,
  `
const STUB = ${JSON.stringify(stubFile)};
export async function resolve(specifier, context, nextResolve) {
  if (specifier === 'next/server') {
    return { shortCircuit: true, url: 'file://' + STUB };
  }
  return nextResolve(specifier, context);
}
export async function load(url, context, nextLoad) {
  return nextLoad(url, context);
}
`,
);

// Register the loader — must happen before importing the module under test
register('file://' + loaderFile);

// Now import the module under test
const { createAuth, createLoginHandler } = await import('../src/index.ts');

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

function makeRequest(opts: {
  cookie?: string;
  authHeader?: string;
  body?: unknown;
  method?: string;
}): Request {
  const headers = new Headers();
  if (opts.cookie) headers.set('cookie', opts.cookie);
  if (opts.authHeader) headers.set('authorization', opts.authHeader);
  const init: RequestInit = {
    method: opts.method ?? 'GET',
    headers,
  };
  if (opts.body) init.body = JSON.stringify(opts.body);
  return new Request('http://localhost/api/auth/login', init);
}

function savedEnv(keys: string[]): Record<string, string | undefined> {
  return Object.fromEntries(keys.map((k) => [k, process.env[k]]));
}
function restoreEnv(saved: Record<string, string | undefined>): void {
  for (const [k, v] of Object.entries(saved)) {
    if (v === undefined) delete process.env[k];
    else process.env[k] = v;
  }
}

const ENV_KEYS = ['SESSION_SECRET', 'AUTH_USERNAME', 'AUTH_PASSWORD', 'NODE_ENV'];

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

describe('@dw/nextjs-admin-login', () => {
  let saved: Record<string, string | undefined>;

  before(() => {
    saved = savedEnv(ENV_KEYS);
  });

  after(() => {
    restoreEnv(saved);
    // Cleanup temp stubs
    try { unlinkSync(stubFile); } catch { /* ok */ }
    try { unlinkSync(loaderFile); } catch { /* ok */ }
  });

  beforeEach(() => {
    // Reset to a known state for each test
    process.env['SESSION_SECRET'] = 'test-secret-for-unit-tests-32bytes!!';
    process.env['AUTH_USERNAME'] = 'admin';
    process.env['AUTH_PASSWORD'] = 'correct-password';
    process.env['NODE_ENV'] = 'test';
  });

  // Test 1: createAuth with default config
  it('createAuth: produces helpers; COOKIE_NAME matches cookieName input', () => {
    const helpers = createAuth({ cookieName: 'freddy-auth' });
    assert.equal(helpers.COOKIE_NAME, 'freddy-auth');
    assert.equal(typeof helpers.createSession, 'function');
    assert.equal(typeof helpers.verifyAuth, 'function');
    assert.equal(typeof helpers.buildAuthCookie, 'function');
    assert.equal(typeof helpers.buildLogoutCookie, 'function');
    assert.equal(typeof helpers.hashPassword, 'function');
  });

  // Test 2: createSession round-trip — verifyAuth returns same username
  it('createSession round-trip: verifyAuth returns original username', () => {
    const helpers = createAuth({ cookieName: 'test-auth' });
    const token = helpers.createSession('admin');
    const cookieHeader = `test-auth=${token}`;
    const req = makeRequest({ cookie: cookieHeader });
    const result = helpers.verifyAuth(req);
    assert.equal(result, 'admin');
  });

  // Test 3: Mismatched HMAC rejected
  it('verifyAuth: rejects token with tampered signature', () => {
    const helpers = createAuth({ cookieName: 'test-auth' });
    const token = helpers.createSession('admin');
    // Decode, corrupt last byte of signature, re-encode
    const decoded = Buffer.from(token, 'base64').toString('utf-8');
    const parts = decoded.split(':');
    // parts[2] is the 64-char hex signature — flip last char
    const sig = parts[2]!;
    const corrupted = sig.slice(0, -1) + (sig.endsWith('a') ? 'b' : 'a');
    parts[2] = corrupted;
    const badToken = Buffer.from(parts.join(':')).toString('base64');

    const req = makeRequest({ cookie: `test-auth=${badToken}` });
    const result = helpers.verifyAuth(req);
    assert.equal(result, null);
  });

  // Test 4: Expired token rejected
  it('verifyAuth: rejects token older than maxAgeSeconds', () => {
    const helpers = createAuth({ cookieName: 'test-auth', maxAgeSeconds: 1 });

    // Manually mint a token with a timestamp 2 seconds in the past
    const secret = process.env['SESSION_SECRET']!;
    const username = 'admin';
    const timestamp = (Date.now() - 2000).toString(); // 2s ago, maxAge=1s
    const sig = createHash('sha256').update(`${username}:${timestamp}:${secret}`).digest('hex');
    const payload = `${username}:${timestamp}:${sig}`;
    const expiredToken = Buffer.from(payload).toString('base64');

    const req = makeRequest({ cookie: `test-auth=${expiredToken}` });
    const result = helpers.verifyAuth(req);
    assert.equal(result, null);
  });

  // Test 5: Login handler — env unset → 500
  it('createLoginHandler: AUTH_PASSWORD unset returns 500', async () => {
    delete process.env['AUTH_PASSWORD'];
    const helpers = createAuth({ cookieName: 'test-auth' });
    const handler = createLoginHandler(helpers, { cookieName: 'test-auth' });
    const req = makeRequest({ method: 'POST', body: { username: 'admin', password: 'x' } });
    const res = await handler(req);
    assert.equal(res.status, 500);
    const body = await res.json() as { error: string };
    assert.match(body.error, /misconfigured/i);
  });

  // Test 6: Login handler — bad password → 401
  it('createLoginHandler: wrong password returns 401', async () => {
    const helpers = createAuth({ cookieName: 'test-auth' });
    const handler = createLoginHandler(helpers, { cookieName: 'test-auth' });
    const req = makeRequest({ method: 'POST', body: { username: 'admin', password: 'wrong' } });
    const res = await handler(req);
    assert.equal(res.status, 401);
  });

  // Test 7: Login handler — good password → 200 + Set-Cookie
  it('createLoginHandler: correct credentials returns 200 + Set-Cookie', async () => {
    const helpers = createAuth({ cookieName: 'test-auth' });
    const handler = createLoginHandler(helpers, { cookieName: 'test-auth' });
    const req = makeRequest({
      method: 'POST',
      body: { username: 'admin', password: 'correct-password' },
    });
    const res = await handler(req);
    assert.equal(res.status, 200);
    const setCookie = res.headers.get('set-cookie');
    assert.ok(setCookie, 'Set-Cookie header should be present');
    assert.ok(setCookie.startsWith('test-auth='), 'Cookie should start with the cookie name');
    assert.ok(setCookie.includes('HttpOnly'), 'Cookie should be HttpOnly');
  });

  // Test 8: Login handler with enableBasicAuthFallback — Basic header parsed
  it('createLoginHandler + verifyAuth: Basic auth fallback accepted when enabled', () => {
    const helpers = createAuth({
      cookieName: 'poppy-auth',
      enableBasicAuthFallback: true,
    });

    const b64 = Buffer.from('admin:correct-password').toString('base64');
    const req = makeRequest({ authHeader: `Basic ${b64}` });
    const result = helpers.verifyAuth(req);
    assert.equal(result, 'admin');
  });

  // Bonus: Basic auth ignored when enableBasicAuthFallback is false
  it('verifyAuth: Basic header ignored when enableBasicAuthFallback is false (default)', () => {
    const helpers = createAuth({ cookieName: 'freddy-auth' }); // default = false
    const b64 = Buffer.from('admin:correct-password').toString('base64');
    const req = makeRequest({ authHeader: `Basic ${b64}` });
    const result = helpers.verifyAuth(req);
    assert.equal(result, null);
  });

  // Bonus: buildAuthCookie contains Secure in production
  it('buildAuthCookie: includes Secure flag when NODE_ENV=production and enableSecureCookie=true', () => {
    process.env['NODE_ENV'] = 'production';
    const helpers = createAuth({ cookieName: 'freddy-auth', enableSecureCookie: true });
    const cookie = helpers.buildAuthCookie('sometoken');
    assert.ok(cookie.includes('Secure'), 'Should include Secure flag in production');
  });

  // Bonus: buildAuthCookie omits Secure when enableSecureCookie=false
  it('buildAuthCookie: omits Secure flag when enableSecureCookie=false', () => {
    process.env['NODE_ENV'] = 'production';
    const helpers = createAuth({ cookieName: 'freddy-auth', enableSecureCookie: false });
    const cookie = helpers.buildAuthCookie('sometoken');
    assert.ok(!cookie.includes('Secure'), 'Should not include Secure flag when disabled');
  });

  // Bonus: buildLogoutCookie sets Max-Age=0
  it('buildLogoutCookie: sets Max-Age=0', () => {
    const helpers = createAuth({ cookieName: 'freddy-auth' });
    const cookie = helpers.buildLogoutCookie();
    assert.ok(cookie.includes('Max-Age=0'), 'Logout cookie should have Max-Age=0');
    assert.ok(cookie.startsWith('freddy-auth=;'), 'Should clear cookie by name');
  });

  // Bonus: hashPassword is deterministic SHA-256
  it('hashPassword: returns deterministic SHA-256 hex digest', () => {
    const helpers = createAuth({ cookieName: 'test-auth' });
    const h1 = helpers.hashPassword('secret');
    const h2 = helpers.hashPassword('secret');
    assert.equal(h1, h2);
    assert.equal(h1.length, 64); // 256-bit = 64 hex chars
    assert.notEqual(helpers.hashPassword('other'), h1);
  });
});