[object Object]

← back to Butlr

Add password-reset test suite and wire into npm test

2606c4d067448335100e319b7469eff449a1a89e · 2026-05-19 12:25:53 -0700 · SteveStudio2

Files touched

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 &#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);
+})();

← 051c322 Add password-reset token store, pluggable delivery layer, an  ·  back to Butlr  ·  wire nodemailer Purelymail SMTP transport for password-reset c3e3751 →