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