← back to Butlr
Add password-reset test suite and wire into npm test
2606c4d067448335100e319b7469eff449a1a89e · 2026-05-19 12:25:53 -0700 · SteveStudio2
Files touched
M package.jsonA test/password-reset.test.js
Diff
commit 2606c4d067448335100e319b7469eff449a1a89e
Author: SteveStudio2 <steve@designerwallcoverings.com>
Date: Tue May 19 12:25:53 2026 -0700
Add password-reset test suite and wire into npm test
---
package.json | 2 +-
test/password-reset.test.js | 262 ++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 263 insertions(+), 1 deletion(-)
diff --git a/package.json b/package.json
index 6948325..f4792d8 100644
--- a/package.json
+++ b/package.json
@@ -7,7 +7,7 @@
"scripts": {
"start": "node server.js",
"dev": "node server.js",
- "test": "node test/orphan-recordings.test.js && node test/admin-gate.test.js && node test/vapi-webhook.test.js && node test/dnc-check.test.js && node test/upload-watcher.test.js && node test/sms-stop.test.js",
+ "test": "node test/orphan-recordings.test.js && node test/admin-gate.test.js && node test/vapi-webhook.test.js && node test/dnc-check.test.js && node test/upload-watcher.test.js && node test/sms-stop.test.js && node test/password-reset.test.js",
"report": "node scripts/call-quality-report.js --remote"
},
"dependencies": {
diff --git a/test/password-reset.test.js b/test/password-reset.test.js
new file mode 100644
index 0000000..a369f65
--- /dev/null
+++ b/test/password-reset.test.js
@@ -0,0 +1,262 @@
+#!/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);
+})();
← 051c322 Add password-reset token store, pluggable delivery layer, an
·
back to Butlr
·
wire nodemailer Purelymail SMTP transport for password-reset c3e3751 →