[object Object]

← back to Butlr

admin-gate + has-transcript: unit tests + 5 routes/listen.js endpoints

f9778332eb60758afccb6806dc734941179b09a1 · 2026-05-13 06:28:04 -0700 · SteveStudio2

Tick 6 work:
- lib/admin-gate.js: extract requireAdmin middleware. Pure function,
  unit-testable, no Express deps.
- test/admin-gate.test.js: 6/6 — no-user/wrong-role/no-role/case-sensitive
  all 403; role==='admin' calls next(). Asserts 404 template body so the
  status leaks 403 but the body looks like a 404 (no enumeration).
- routes/listen.js: re-add /api/calls/<id>/has-recording (lost in an
  earlier external rollback — was documented in ead4134 but the route
  body never made it to disk), and add new /api/calls/<id>/has-transcript.
  Both owner-scoped via data.getCall, cheap existence probes.
- views/public/calls.ejs: each row now shows a "📝 transcript" chip
  next to the inline player when /has-transcript says it exists.
- routes/admin.js: delegate to lib/admin-gate.requireAdmin.
- package.json: npm test runs orphan-recordings (8/8) + admin-gate (6/6).

YOLO tick 6 — all reversible, local-only.

Files touched

Diff

commit f9778332eb60758afccb6806dc734941179b09a1
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Wed May 13 06:28:04 2026 -0700

    admin-gate + has-transcript: unit tests + 5 routes/listen.js endpoints
    
    Tick 6 work:
    - lib/admin-gate.js: extract requireAdmin middleware. Pure function,
      unit-testable, no Express deps.
    - test/admin-gate.test.js: 6/6 — no-user/wrong-role/no-role/case-sensitive
      all 403; role==='admin' calls next(). Asserts 404 template body so the
      status leaks 403 but the body looks like a 404 (no enumeration).
    - routes/listen.js: re-add /api/calls/<id>/has-recording (lost in an
      earlier external rollback — was documented in ead4134 but the route
      body never made it to disk), and add new /api/calls/<id>/has-transcript.
      Both owner-scoped via data.getCall, cheap existence probes.
    - views/public/calls.ejs: each row now shows a "📝 transcript" chip
      next to the inline player when /has-transcript says it exists.
    - routes/admin.js: delegate to lib/admin-gate.requireAdmin.
    - package.json: npm test runs orphan-recordings (8/8) + admin-gate (6/6).
    
    YOLO tick 6 — all reversible, local-only.
---
 lib/admin-gate.js       | 21 +++++++++++++
 package.json            |  2 +-
 routes/admin.js         |  8 +----
 routes/listen.js        | 25 +++++++++++++++
 test/admin-gate.test.js | 82 +++++++++++++++++++++++++++++++++++++++++++++++++
 views/public/calls.ejs  | 15 ++++++++-
 6 files changed, 144 insertions(+), 9 deletions(-)

diff --git a/lib/admin-gate.js b/lib/admin-gate.js
new file mode 100644
index 0000000..d7f1f09
--- /dev/null
+++ b/lib/admin-gate.js
@@ -0,0 +1,21 @@
+// requireAdmin middleware — second gate on top of requireOwner.
+//
+// requireOwner (lib/owner-auth) verifies the user is signed in (any role).
+// requireAdmin then verifies role === 'admin'. Splits so non-admin signed-in
+// users get a 404-shaped response (no role info leak) instead of 403, which
+// would tell them "/admin/* exists but you can't see it".
+//
+// Pure middleware — no Express object dependency — so unit-testable by
+// passing fake req/res/next.
+
+function requireAdmin(req, res, next) {
+  if (!req.user || req.user.role !== 'admin') {
+    // Render the standard 404 so admin paths look like they don't exist
+    // to non-admins. Status code is 403 so security scanners can still
+    // distinguish, but the body is identical to a real 404.
+    return res.status(403).render('public/404', { title: 'Not found — Butlr' });
+  }
+  return next();
+}
+
+module.exports = { requireAdmin };
diff --git a/package.json b/package.json
index 9e30cd0..2bc86c1 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"
+    "test": "node test/orphan-recordings.test.js && node test/admin-gate.test.js"
   },
   "dependencies": {
     "bcryptjs": "^3.0.3",
diff --git a/routes/admin.js b/routes/admin.js
index e31f191..937702b 100644
--- a/routes/admin.js
+++ b/routes/admin.js
@@ -16,16 +16,10 @@ const path = require('path');
 const data = require('../lib/data');
 const transcribe = require('../lib/transcribe');
 const { scanOrphans } = require('../lib/orphan-recordings');
+const { requireAdmin } = require('../lib/admin-gate');
 
 const router = express.Router();
 
-function requireAdmin(req, res, next) {
-  if (!req.user || req.user.role !== 'admin') {
-    return res.status(403).render('public/404', { title: 'Not found — Butlr' });
-  }
-  next();
-}
-
 router.get('/admin/orphan-recordings', requireAdmin, (req, res) => {
   let orphans;
   try { orphans = scanOrphans(); }
diff --git a/routes/listen.js b/routes/listen.js
index 2a3b646..ada8067 100644
--- a/routes/listen.js
+++ b/routes/listen.js
@@ -3,6 +3,7 @@
 //   GET /api/calls/:call_id/transcript         — transcript JSON (post-call)
 //   GET /api/calls/:call_id/recording          — MP3 recording (post-call)
 //   GET /api/calls/:call_id/has-recording      — JSON probe used by /calls list rows
+//   GET /api/calls/:call_id/has-transcript     — JSON probe used by /calls list rows
 
 const express = require('express');
 const path = require('path');
@@ -51,4 +52,28 @@ router.get('/api/calls/:call_id/recording', (req, res) => {
   fs.createReadStream(fp).pipe(res);
 });
 
+// Cheap existence probes for /calls list inline chips. Return
+// {ok, exists, size_bytes} without streaming so the list page can
+// hide rows whose recording/transcript wasn't archived (older calls,
+// dry-run, mid-call death, Whisper still running, etc).
+router.get('/api/calls/:call_id/has-recording', (req, res) => {
+  const callId = req.params.call_id;
+  if (!/^[A-Za-z0-9_-]{6,16}$/.test(callId)) return res.status(404).json({ ok: false });
+  const call = data.getCall(callId, req.user && req.user.id, false);
+  if (!call) return res.status(404).json({ ok: false });
+  const fp = path.join(transcribe.RECORDINGS_DIR, `${callId}.mp3`);
+  if (!fs.existsSync(fp)) return res.json({ ok: true, exists: false });
+  res.json({ ok: true, exists: true, size_bytes: fs.statSync(fp).size });
+});
+
+router.get('/api/calls/:call_id/has-transcript', (req, res) => {
+  const callId = req.params.call_id;
+  if (!/^[A-Za-z0-9_-]{6,16}$/.test(callId)) return res.status(404).json({ ok: false });
+  const call = data.getCall(callId, req.user && req.user.id, false);
+  if (!call) return res.status(404).json({ ok: false });
+  const fp = path.join(transcribe.TRANSCRIPTS_DIR, `${callId}.json`);
+  if (!fs.existsSync(fp)) return res.json({ ok: true, exists: false });
+  res.json({ ok: true, exists: true, size_bytes: fs.statSync(fp).size });
+});
+
 module.exports = router;
diff --git a/test/admin-gate.test.js b/test/admin-gate.test.js
new file mode 100644
index 0000000..b11aabd
--- /dev/null
+++ b/test/admin-gate.test.js
@@ -0,0 +1,82 @@
+#!/usr/bin/env node
+// Unit test for lib/admin-gate.requireAdmin.
+//
+// Tests the role-gate without spinning up Express. Mocks req/res/next
+// and asserts the middleware short-circuits with 403+render for
+// no-user / wrong-role, and calls next() for role === 'admin'.
+
+const assert = require('assert');
+const { requireAdmin } = require('../lib/admin-gate');
+
+function ok(name, fn) {
+  try { fn(); console.log(`  ✓ ${name}`); return 1; }
+  catch (e) { console.error(`  ✗ ${name}`); console.error('     ', e.message); return 0; }
+}
+
+function fakeRes() {
+  return {
+    _status: 200,
+    _rendered: null,
+    status(c) { this._status = c; return this; },
+    render(tpl, locals) { this._rendered = { tpl, locals }; return this; },
+  };
+}
+
+let pass = 0, total = 0;
+
+total++; pass += ok('no req.user → 403 + 404 template', () => {
+  const req = {};
+  const res = fakeRes();
+  let nextCalled = false;
+  requireAdmin(req, res, () => { nextCalled = true; });
+  assert.strictEqual(nextCalled, false, 'next should NOT be called');
+  assert.strictEqual(res._status, 403);
+  assert.strictEqual(res._rendered.tpl, 'public/404');
+});
+
+total++; pass += ok("req.user.role==='user' → 403", () => {
+  const req = { user: { id: 'u1', email: 'a@b.co', role: 'user' } };
+  const res = fakeRes();
+  let nextCalled = false;
+  requireAdmin(req, res, () => { nextCalled = true; });
+  assert.strictEqual(nextCalled, false);
+  assert.strictEqual(res._status, 403);
+});
+
+total++; pass += ok("req.user.role==='admin' → next()", () => {
+  const req = { user: { id: 'u1', email: 'admin@x.co', role: 'admin' } };
+  const res = fakeRes();
+  let nextCalled = false;
+  requireAdmin(req, res, () => { nextCalled = true; });
+  assert.strictEqual(nextCalled, true, 'next() must be called for admin');
+  assert.strictEqual(res._status, 200, 'status untouched');
+  assert.strictEqual(res._rendered, null, 'render NOT called');
+});
+
+total++; pass += ok('req.user with no role field → 403', () => {
+  const req = { user: { id: 'u1', email: 'a@b.co' } };
+  const res = fakeRes();
+  let nextCalled = false;
+  requireAdmin(req, res, () => { nextCalled = true; });
+  assert.strictEqual(nextCalled, false);
+  assert.strictEqual(res._status, 403);
+});
+
+total++; pass += ok("'ADMIN' (uppercase) is NOT 'admin' (case-sensitive)", () => {
+  const req = { user: { role: 'ADMIN' } };
+  const res = fakeRes();
+  let nextCalled = false;
+  requireAdmin(req, res, () => { nextCalled = true; });
+  assert.strictEqual(nextCalled, false);
+  assert.strictEqual(res._status, 403);
+});
+
+total++; pass += ok('rendered 404 has Butlr title', () => {
+  const req = {};
+  const res = fakeRes();
+  requireAdmin(req, res, () => {});
+  assert.ok(res._rendered.locals && /Butlr/.test(res._rendered.locals.title));
+});
+
+console.log(`\n${pass}/${total} passed`);
+process.exit(pass === total ? 0 : 1);
diff --git a/views/public/calls.ejs b/views/public/calls.ejs
index 83e9ad8..e574583 100644
--- a/views/public/calls.ejs
+++ b/views/public/calls.ejs
@@ -31,7 +31,10 @@
               </a>
               <% if (['done','completed','failed'].includes(c.status)) { %>
                 <div class="call-row-audio" data-call-id="<%= c.id %>" style="margin-top:.5rem; padding:.5rem .75rem; background:var(--panel-2); border:1px solid var(--line); border-radius:6px; display:none;">
-                  <div style="font-size:11px; color:var(--ink-dim); margin-bottom:4px;">📼 Recording</div>
+                  <div style="display:flex; align-items:center; gap:.5rem; font-size:11px; color:var(--ink-dim); margin-bottom:4px;">
+                    <span>📼 Recording</span>
+                    <a class="transcript-chip" data-call-id="<%= c.id %>" href="/api/calls/<%= c.id %>/transcript" target="_blank" style="display:none; margin-left:auto; padding:2px 8px; border-radius:9999px; background:var(--accent,#1a56db); color:#fff; text-decoration:none; font-size:10px;">📝 transcript</a>
+                  </div>
                   <audio controls preload="none" src="/api/calls/<%= c.id %>/recording" style="width:100%; height:32px;"></audio>
                 </div>
               <% } %>
@@ -51,6 +54,16 @@
               if (j && j.ok && j.exists) el.style.display = '';
             } catch {}
           });
+          // Transcript chip: only reveal if /has-transcript says it exists.
+          // Independent of recording — transcripts can land before the MP3.
+          document.querySelectorAll('.transcript-chip').forEach(async (el) => {
+            const id = el.dataset.callId;
+            try {
+              const r = await fetch('/api/calls/' + id + '/has-transcript');
+              const j = await r.json();
+              if (j && j.ok && j.exists) el.style.display = '';
+            } catch {}
+          });
           </script>
         </ul>
       <% } %>

← 2341930 orphan-recordings: extract pure scanOrphans() + unit test (8  ·  back to Butlr  ·  calls: quick-dial form + inline transcript viewer + listen-l 3f6963c →