← back to Butlr

test/password-reset.test.js

263 lines

#!/usr/bin/env node
// Unit + integration tests for the Butlr password-reset flow.
//
// Covers:
//   - lib/password-reset token store: create / find / single-use / expiry /
//     supersede / hashed-at-rest.
//   - The HTTP flow end-to-end against a tiny express app mounting the real
//     owner-auth router: forgot-password (anti-enumeration), reset-password
//     (token verify + password write + session rotation).
//
// Runs against an isolated tmp data dir so it never touches real
// data/users.json or data/password-resets.json.

process.env.HFM_NO_WORKER = '1';
process.env.HFM_NO_WATCHER = '1';
process.env.RESET_DELIVERY_CHANNEL = 'email';
process.env.RESET_EMAIL_TRANSPORT = 'log';
process.env.PUBLIC_URL = 'http://localhost:9932';

const fs = require('fs');
const os = require('os');
const path = require('path');
const http = require('http');
const assert = require('assert');

// ── Isolate the JSON stores into a tmp dir ────────────────────────────
// lib/users.js and lib/password-reset.js both build their file path as
// path.join(__dirname, '..', 'data', '<name>.json'). Redirect just those
// two files via fs interception.
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'butlr-pwreset-'));
const tmpUsers = path.join(tmpDir, 'users.json');
const tmpResets = path.join(tmpDir, 'password-resets.json');
fs.writeFileSync(tmpUsers, '[]');
fs.writeFileSync(tmpResets, '[]');

const realWrite = fs.writeFileSync, realRead = fs.readFileSync,
      realRename = fs.renameSync, realChmod = fs.chmodSync, realMkdir = fs.mkdirSync;

function redirect(p) {
  const s = String(p);
  if (s.includes('users.json')) return tmpUsers + s.slice(s.indexOf('users.json') + 'users.json'.length);
  if (s.includes('password-resets.json')) return tmpResets + s.slice(s.indexOf('password-resets.json') + 'password-resets.json'.length);
  return p;
}
fs.writeFileSync = (p, ...rest) => realWrite(redirect(p), ...rest);
fs.readFileSync  = (p, ...rest) => realRead(redirect(p), ...rest);
fs.renameSync    = (a, b) => realRename(redirect(a), redirect(b));
fs.chmodSync     = (p, ...rest) => { try { return realChmod(redirect(p), ...rest); } catch { return; } };
fs.mkdirSync     = (p, ...rest) => { try { return realMkdir(p, ...rest); } catch { return; } };

const passwordReset = require('../lib/password-reset');
const users = require('../lib/users');
const { mountLogin } = require('../lib/owner-auth');

let pass = 0, total = 0;
function ok(name, fn) {
  total++;
  return Promise.resolve()
    .then(fn)
    .then(() => { pass++; console.log(`  ✓ ${name}`); })
    .catch((e) => { console.error(`  ✗ ${name}`); console.error('     ', e.message); });
}

// ── HTTP helper ───────────────────────────────────────────────────────
const express = require('express');
const app = express();
app.use(express.urlencoded({ extended: true }));
app.use((req, res, next) => { res.locals.PUBLIC_URL = process.env.PUBLIC_URL; next(); });
const router = express.Router();
mountLogin(router);
app.use('/', router);
const server = app.listen(0);
const PORT = server.address().port;

function request(method, urlPath, { body, cookie } = {}) {
  return new Promise((resolve) => {
    const bodyStr = body
      ? Object.entries(body).map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join('&')
      : '';
    const headers = {};
    if (body) {
      headers['Content-Type'] = 'application/x-www-form-urlencoded';
      headers['Content-Length'] = Buffer.byteLength(bodyStr);
    }
    if (cookie) headers['Cookie'] = cookie;
    const req = http.request({ hostname: '127.0.0.1', port: PORT, path: urlPath, method, headers }, (res) => {
      let chunks = '';
      res.on('data', c => { chunks += c; });
      res.on('end', () => resolve({ status: res.statusCode, body: chunks, headers: res.headers }));
    });
    if (bodyStr) req.write(bodyStr);
    req.end();
  });
}

(async () => {
  // ── Token store unit tests ──────────────────────────────────────────
  await ok('createToken returns a 64-hex raw token + expiry', () => {
    const r = passwordReset.createToken('user-A');
    assert.match(r.token, /^[0-9a-f]{64}$/, 'token is 64 hex chars');
    assert.ok(Date.parse(r.expires_at) > Date.now(), 'expiry is in the future');
  });

  await ok('raw token is NOT stored on disk — only its sha256', () => {
    const r = passwordReset.createToken('user-B');
    const raw = realRead(tmpResets, 'utf8');
    assert.ok(!raw.includes(r.token), 'plaintext token must not appear in the file');
    const rows = JSON.parse(raw);
    assert.ok(rows.some(x => x.token_sha256 && /^[0-9a-f]{64}$/.test(x.token_sha256)),
      'a sha256 hash is stored');
  });

  await ok('findLiveByToken finds a fresh token', () => {
    const r = passwordReset.createToken('user-C');
    const found = passwordReset.findLiveByToken(r.token);
    assert.strictEqual(found.ok, true);
    assert.strictEqual(found.row.user_id, 'user-C');
  });

  await ok('findLiveByToken rejects a bogus token', () => {
    const found = passwordReset.findLiveByToken('deadbeef'.repeat(8));
    assert.strictEqual(found.ok, false);
    assert.strictEqual(found.reason, 'not_found');
  });

  await ok('consumeToken makes a token single-use', () => {
    const r = passwordReset.createToken('user-D');
    const first = passwordReset.consumeToken(r.token);
    assert.strictEqual(first.ok, true);
    const second = passwordReset.consumeToken(r.token);
    assert.strictEqual(second.ok, false);
    assert.strictEqual(second.reason, 'used');
    // And it no longer verifies as live.
    assert.strictEqual(passwordReset.findLiveByToken(r.token).ok, false);
  });

  await ok('issuing a new token supersedes the prior unused one', () => {
    const first = passwordReset.createToken('user-E');
    const second = passwordReset.createToken('user-E');
    assert.strictEqual(passwordReset.findLiveByToken(first.token).ok, false,
      'old token should be invalidated');
    assert.strictEqual(passwordReset.findLiveByToken(second.token).ok, true,
      'new token is live');
  });

  await ok('expired token does not verify', () => {
    const r = passwordReset.createToken('user-F');
    // Hand-edit the row's expiry into the past.
    const rows = JSON.parse(realRead(tmpResets, 'utf8'));
    const row = rows.find(x => x.user_id === 'user-F' && !x.used_at);
    row.expires_at = new Date(Date.now() - 1000).toISOString();
    realWrite(tmpResets, JSON.stringify(rows));
    const found = passwordReset.findLiveByToken(r.token);
    assert.strictEqual(found.ok, false);
    assert.strictEqual(found.reason, 'expired');
  });

  // ── HTTP flow tests ─────────────────────────────────────────────────
  // Seed a real user we can reset.
  const created = await users.createUser({ email: 'reset-me@example.com', password: 'origPass123' });
  assert.strictEqual(created.ok, true, 'test user created');
  const targetUserId = created.user.id;

  await ok('GET /forgot-password renders the form', async () => {
    const r = await request('GET', '/forgot-password');
    assert.strictEqual(r.status, 200);
    assert.ok(r.body.includes('action="/forgot-password"'), 'form present');
  });

  await ok('POST /forgot-password — existing account → generic notice (no leak)', async () => {
    const r = await request('POST', '/forgot-password', { body: { email: 'reset-me@example.com' } });
    assert.strictEqual(r.status, 200);
    assert.ok(r.body.includes('If an account exists'), 'shows anti-enumeration notice');
    // A token should now exist for this user.
    const rows = JSON.parse(realRead(tmpResets, 'utf8'));
    assert.ok(rows.some(x => x.user_id === targetUserId && !x.used_at), 'live token issued');
  });

  await ok('POST /forgot-password — UNKNOWN account → IDENTICAL notice', async () => {
    const r = await request('POST', '/forgot-password', { body: { email: 'nobody@example.com' } });
    assert.strictEqual(r.status, 200);
    assert.ok(r.body.includes('If an account exists'),
      'unknown email gets the exact same notice as a real one');
  });

  // Grab the live token for the target user directly from the store, then
  // use it to drive the reset-password endpoint (the route delivers it via
  // the 'log' transport; the test reads the store instead of the log).
  let liveRawToken;
  await ok('a usable reset token exists for the target user', () => {
    // Mint a clean one so prior supersession in earlier tests is irrelevant.
    const r = passwordReset.createToken(targetUserId);
    liveRawToken = r.token;
    assert.ok(liveRawToken);
  });

  await ok('GET /reset-password?token=… renders the new-password form', async () => {
    const r = await request('GET', `/reset-password?token=${liveRawToken}`);
    assert.strictEqual(r.status, 200);
    assert.ok(r.body.includes('Choose a new password'), 'reset form present');
  });

  await ok('GET /reset-password with a bogus token → "Link expired"', async () => {
    const r = await request('GET', '/reset-password?token=' + 'ff'.repeat(32));
    assert.strictEqual(r.status, 400);
    assert.ok(r.body.includes('Link expired'));
  });

  await ok('POST /reset-password — password mismatch is rejected', async () => {
    const r = await request('POST', '/reset-password', {
      body: { token: liveRawToken, password: 'newPass4567', password2: 'different999' },
    });
    assert.strictEqual(r.status, 400);
    // The message ("Passwords don't match.") is HTML-escaped in the page,
    // so the apostrophe renders as &#39; — match on the stable prefix.
    assert.ok(r.body.includes('Passwords don'));
    // Token must still be live (a failed attempt does not burn it).
    assert.strictEqual(passwordReset.findLiveByToken(liveRawToken).ok, true);
  });

  await ok('POST /reset-password — weak password is rejected', async () => {
    const r = await request('POST', '/reset-password', {
      body: { token: liveRawToken, password: 'password', password2: 'password' },
    });
    assert.strictEqual(r.status, 400);
    assert.ok(r.body.includes('easy to guess'));
  });

  await ok('POST /reset-password — valid reset sets password + signs in + rotates session', async () => {
    const r = await request('POST', '/reset-password', {
      body: { token: liveRawToken, password: 'BrandNew0rbit', password2: 'BrandNew0rbit' },
    });
    assert.strictEqual(r.status, 302, 'redirects after success');
    assert.strictEqual(r.headers.location, '/');
    const setCookie = (r.headers['set-cookie'] || []).join(';');
    assert.ok(/butlr_owner=/.test(setCookie), 'a session cookie is set');
    assert.ok(/HttpOnly/i.test(setCookie), 'cookie is HttpOnly');
    // New password works on the real verifyLogin.
    const login = await users.verifyLogin({ email: 'reset-me@example.com', password: 'BrandNew0rbit' });
    assert.strictEqual(login.ok, true, 'new password authenticates');
    // Old password no longer works.
    const oldLogin = await users.verifyLogin({ email: 'reset-me@example.com', password: 'origPass123' });
    assert.strictEqual(oldLogin.ok, false, 'old password is dead');
  });

  await ok('POST /reset-password — the SAME token cannot be reused', async () => {
    const r = await request('POST', '/reset-password', {
      body: { token: liveRawToken, password: 'AnotherOne88', password2: 'AnotherOne88' },
    });
    assert.strictEqual(r.status, 400);
    assert.ok(r.body.includes('Link expired'), 'consumed token is rejected');
  });

  // ── done ────────────────────────────────────────────────────────────
  server.close();
  fs.writeFileSync = realWrite; fs.readFileSync = realRead;
  fs.renameSync = realRename; fs.chmodSync = realChmod; fs.mkdirSync = realMkdir;
  fs.rmSync(tmpDir, { recursive: true, force: true });

  console.log(`\n${pass}/${total} passed`);
  process.exit(pass === total ? 0 : 1);
})();