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