[object Object]

← back to Ticket System

TK-10971: hermetic test suite for ticket-system core (lib.js)

6f55ede9a887f695df3c3d64f6eb943807b19935 · 2026-08-30 10:00:05 -0700 · Steve Abrams

- Co-locate injection-safe ref gate (IDRE/REFRE/resolveList) in lib.js next to
  resolveId, export them; server.js imports instead of defining locally
  (pure relocation, behavior-identical)
- Add hermetic lib.test.js (node --test) against a mkdtemp TICKET_DATA_DIR:
  append/read round-trip, status transitions + reopen + unknown-status-ignored,
  STATUSES contract, comment/action/assign fold + body-seeds-comment, resolveId,
  resolveList security (rejects TK-1;rm -rf / class, resolves+dedups clean refs),
  nextMid monotonic, messages dm+read fold, isBroadcast, withLock cleanup
- Add package.json with test script (node --test)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 6f55ede9a887f695df3c3d64f6eb943807b19935
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sun Aug 30 10:00:05 2026 -0700

    TK-10971: hermetic test suite for ticket-system core (lib.js)
    
    - Co-locate injection-safe ref gate (IDRE/REFRE/resolveList) in lib.js next to
      resolveId, export them; server.js imports instead of defining locally
      (pure relocation, behavior-identical)
    - Add hermetic lib.test.js (node --test) against a mkdtemp TICKET_DATA_DIR:
      append/read round-trip, status transitions + reopen + unknown-status-ignored,
      STATUSES contract, comment/action/assign fold + body-seeds-comment, resolveId,
      resolveList security (rejects TK-1;rm -rf / class, resolves+dedups clean refs),
      nextMid monotonic, messages dm+read fold, isBroadcast, withLock cleanup
    - Add package.json with test script (node --test)
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 lib.js       |  13 +++++
 lib.test.js  | 165 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 package.json |   9 ++++
 server.js    |  12 +----
 4 files changed, 189 insertions(+), 10 deletions(-)

diff --git a/lib.js b/lib.js
index 460c50ca..3fd110ef 100644
--- a/lib.js
+++ b/lib.js
@@ -207,6 +207,19 @@ function resolveId(ref, map) {
   return null;
 }
 
+// Injection-safe reference gate (co-located here so the whole security boundary
+// lives with resolveId + is unit-testable). IDRE is the hard shape a canonical id
+// must have before any shell use. REFRE pre-gates the RAW client ref FIRST —
+// otherwise "TK-1; rm -rf /" would numeric-resolve to the real TK-1 (resolveId
+// stops parseInt at the first non-digit) and reach a shell. resolveList resolves
+// a list of client refs to canonical, shell-safe ids, deduped.
+const IDRE = /^TK-[0-9]+(-[a-z0-9-]+)?$/;
+const REFRE = /^(TK-?)?\d+(-[a-z0-9-]+)?$/i;
+const resolveList = (refs, map) => { const out = []; if (!Array.isArray(refs)) return out;
+  for (const r of refs) { if (!REFRE.test(String(r).trim())) continue; const id = resolveId(r, map); if (id && IDRE.test(id)) out.push(id); }
+  return [...new Set(out)]; };
+
 module.exports = { withLock, append, tickets, nextId, resolveId, idNum, slugify, STATUSES, EVENTS,
+  IDRE, REFRE, resolveList,
   nextMid, messages, inbox, thread, resolveMid, threadParticipants, parseMentions, isBroadcast, knownAgents,
   correlationId, createTicket, ticketComment, ticketAction, ticketAssign, ticketStatus, sendDm, markMessageRead };
diff --git a/lib.test.js b/lib.test.js
new file mode 100644
index 00000000..57d6d07b
--- /dev/null
+++ b/lib.test.js
@@ -0,0 +1,165 @@
+'use strict';
+// Hermetic unit tests for the ticket-system core store (lib.js). Every test runs
+// against a throwaway TICKET_DATA_DIR set BEFORE lib.js is required, so the real
+// ~/.claude/tickets store is never touched.
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+
+const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'tk-'));
+process.env.TICKET_DATA_DIR = TMP;
+
+const lib = require('./lib.js');
+const {
+  append, tickets, resolveId, resolveList, IDRE, STATUSES,
+  nextMid, messages, isBroadcast, withLock, EVENTS,
+} = lib;
+
+const iso = (offsetMs = 0) => new Date(Date.now() + offsetMs).toISOString();
+const readEventLines = () => fs.readFileSync(EVENTS, 'utf8').split('\n').filter(Boolean);
+
+// ── 1. append + read round-trip; a create folds into an open ticket ──────────
+test('append + read round-trip and create folds to {open,title,project,agent}', () => {
+  const ev = append({ ts: iso(), type: 'create', id: 'TK-10001-round-trip', title: 'Round Trip', project: 'ticket-system', agent: 'alice' });
+  assert.equal(ev.id, 'TK-10001-round-trip');
+  const lines = readEventLines();
+  assert.ok(lines.length >= 1);
+  assert.deepEqual(JSON.parse(lines.at(-1)), ev);
+
+  const t = tickets().get('TK-10001-round-trip');
+  assert.equal(t.status, 'open');
+  assert.equal(t.title, 'Round Trip');
+  assert.equal(t.project, 'ticket-system');
+  assert.equal(t.agent, 'alice');
+});
+
+// ── 2. status transitions, reopen, unknown-status ignored, STATUSES contract ──
+test('status transitions open→doing→blocked→done→stopped, then reopen', () => {
+  const id = 'TK-10002-transitions';
+  append({ ts: iso(), type: 'create', id, title: 'Transitions', agent: 'a' });
+  for (const s of ['doing', 'blocked', 'done', 'stopped']) {
+    append({ ts: iso(), type: 'status', id, status: s });
+    assert.equal(tickets().get(id).status, s);
+  }
+  append({ ts: iso(), type: 'status', id, status: 'open' }); // reopen from stopped
+  assert.equal(tickets().get(id).status, 'open');
+});
+
+test('an unknown status event is ignored (status stays valid)', () => {
+  const id = 'TK-10003-unknown-status';
+  append({ ts: iso(), type: 'create', id, title: 'Unknown', agent: 'a' });
+  append({ ts: iso(), type: 'status', id, status: 'doing' });
+  append({ ts: iso(), type: 'status', id, status: 'archived' }); // not in STATUSES
+  assert.equal(tickets().get(id).status, 'doing');
+});
+
+test('STATUSES contents and order are exactly as legacy consumers expect', () => {
+  assert.deepEqual(STATUSES, ['open', 'doing', 'blocked', 'done', 'stopped']);
+  assert.equal(STATUSES.at(-1), 'stopped'); // appended last for positional consumers
+});
+
+// ── 3. comment vs action vs assign fold correctly; create body seeds a comment ─
+test('comment, action, and assign events fold into the right places', () => {
+  const id = 'TK-10004-fold';
+  append({ ts: iso(), type: 'create', id, title: 'Fold', agent: 'a' });
+  append({ ts: iso(), type: 'comment', id, agent: 'a', text: 'a comment' });
+  append({ ts: iso(), type: 'action', id, agent: 'a', text: 'an action' });
+  append({ ts: iso(), type: 'assign', id, agent: 'bob' });
+
+  const t = tickets().get(id);
+  assert.equal(t.comments.length, 1);
+  assert.equal(t.comments[0].text, 'a comment');
+  assert.equal(t.actions.length, 1);
+  assert.equal(t.actions[0].text, 'an action');
+  assert.equal(t.assignee, 'bob');
+});
+
+test('a body on create seeds a comment', () => {
+  const id = 'TK-10005-body-seeds-comment';
+  append({ ts: iso(), type: 'create', id, title: 'Body', agent: 'a', body: 'seeded from body' });
+  const t = tickets().get(id);
+  assert.equal(t.comments.length, 1);
+  assert.equal(t.comments[0].text, 'seeded from body');
+});
+
+// ── 4. resolveId: numeric, TK-prefixed, full slug, and nonexistent ───────────
+test('resolveId resolves numeric, TK-prefixed, and full-slug refs; null on miss', () => {
+  const id = 'TK-10006-resolve-me';
+  append({ ts: iso(), type: 'create', id, title: 'Resolve Me', agent: 'a' });
+  const map = tickets();
+  assert.equal(resolveId('10006', map), id);
+  assert.equal(resolveId('TK-10006', map), id);
+  assert.equal(resolveId('TK-10006-resolve-me', map), id);
+  assert.equal(resolveId('TK-99999999', map), null);
+  assert.equal(resolveId('', map), null);
+});
+
+// ── 5. SECURITY: resolveList rejects the injection class, resolves clean refs ──
+test('resolveList REJECTS the shell-injection class', () => {
+  const id = 'TK-10007-security';
+  append({ ts: iso(), type: 'create', id, title: 'Security', agent: 'a' });
+  const map = tickets();
+  const evil = ['TK-1; rm -rf /', 'TK-1 && curl evil', 'TK-1|cat', '../TK-1', 'TK-1\n rm', '1; ls', ''];
+  const out = resolveList(evil, map);
+  // None of the malicious refs survive, and whatever DOES survive is IDRE-clean.
+  for (const r of out) assert.ok(IDRE.test(r), `survivor not IDRE-clean: ${r}`);
+  for (const r of out) assert.ok(!/[;&|/\s\\]/.test(r) || r.startsWith('TK-'), `unsafe survivor: ${r}`);
+  // Specifically: the "1; ls" numeric-injection must NOT resolve to a real ticket.
+  assert.equal(out.length, 0);
+});
+
+test('resolveList rejects a non-array input and returns []', () => {
+  assert.deepEqual(resolveList('TK-10007', tickets()), []);
+  assert.deepEqual(resolveList(null, tickets()), []);
+  assert.deepEqual(resolveList(undefined, tickets()), []);
+});
+
+test('resolveList RESOLVES clean refs to canonical ids, deduped', () => {
+  const id = 'TK-10008-clean-refs';
+  append({ ts: iso(), type: 'create', id, title: 'Clean Refs', agent: 'a' });
+  const map = tickets();
+  assert.deepEqual(resolveList(['TK-10008'], map), [id]);
+  assert.deepEqual(resolveList(['10008'], map), [id]);
+  assert.deepEqual(resolveList(['TK-10008-clean-refs'], map), [id]);
+  // dedup: three forms of the same ticket collapse to one canonical id.
+  assert.deepEqual(resolveList(['TK-10008', '10008', 'TK-10008-clean-refs'], map), [id]);
+});
+
+// ── 6. nextMid monotonic; messages() folds dm + read; isBroadcast ────────────
+test('nextMid is monotonic across appended dm events', () => {
+  const m1 = nextMid();
+  append({ ts: iso(), type: 'dm', mid: m1, from: 'a', to: 'b', text: 'one' });
+  const m2 = nextMid();
+  append({ ts: iso(), type: 'dm', mid: m2, from: 'a', to: 'b', text: 'two' });
+  const m3 = nextMid();
+  const n = s => parseInt(s.replace(/^M-/, ''), 10);
+  assert.ok(n(m2) > n(m1));
+  assert.ok(n(m3) > n(m2));
+});
+
+test('messages() folds a dm and a read (reads[] includes the reader)', () => {
+  const mid = nextMid();
+  append({ ts: iso(), type: 'dm', mid, from: 'sender', to: 'reader', text: 'hi' });
+  append({ ts: iso(), type: 'read', mid, agent: 'reader' });
+  const m = messages().get(mid);
+  assert.equal(m.from, 'sender');
+  assert.equal(m.to, 'reader');
+  assert.deepEqual(m.reads, ['reader']);
+});
+
+test('isBroadcast is true for all/*/everyone only', () => {
+  for (const b of ['all', '*', 'everyone']) assert.equal(isBroadcast(b), true);
+  for (const nb of ['someone', 'allison', '', 'ALL']) assert.equal(isBroadcast(nb), false);
+});
+
+// ── 7. withLock: sequential calls succeed; no leftover .lock ─────────────────
+test('two sequential withLock calls succeed and the lock is cleaned up', () => {
+  const lockPath = path.join(TMP, '.lock');
+  const a = withLock(() => 'first');
+  const b = withLock(() => 'second');
+  assert.equal(a, 'first');
+  assert.equal(b, 'second');
+  assert.equal(fs.existsSync(lockPath), false);
+});
diff --git a/package.json b/package.json
new file mode 100644
index 00000000..dcae2e82
--- /dev/null
+++ b/package.json
@@ -0,0 +1,9 @@
+{
+  "name": "ticket-system",
+  "version": "1.0.0",
+  "private": true,
+  "description": "Shared fleet ticket store + board (append-only JSONL event log).",
+  "scripts": {
+    "test": "node --test"
+  }
+}
diff --git a/server.js b/server.js
index c4544e46..ed2ae5d1 100644
--- a/server.js
+++ b/server.js
@@ -4,7 +4,7 @@ const fs = require('fs');
 const path = require('path');
 const os = require('os');
 const { exec, execFile, spawn } = require('child_process');
-const { tickets, STATUSES, messages, resolveId, append, withLock } = require('./lib.js');
+const { tickets, STATUSES, messages, resolveId, append, withLock, IDRE, REFRE, resolveList } = require('./lib.js');
 
 // ── ticket-run + DTD wiring (TK-10527) ──
 const DATA_DIR = path.join(os.homedir(), '.claude', 'tickets');
@@ -12,15 +12,7 @@ const VERDICTS = path.join(DATA_DIR, 'dtd-verdicts.json');       // last batched
 const DTD_RUNNING = path.join(DATA_DIR, 'dtd-verdicts.running'); // present while a sweep is in flight
 const RUN_SH = path.join(__dirname, 'run-ticket.sh');           // opens an iTerm2 Claude session
 const DTD_RUN = path.join(__dirname, 'dtd-run.js');             // batched panel.sh sweep
-const IDRE  = /^TK-[0-9]+(-[a-z0-9-]+)?$/;                        // hard id gate before any shell use
-// Resolve a list of client refs to canonical, shell-safe ticket ids. A RAW ref
-// must itself look like a clean ticket ref FIRST — otherwise "TK-1; rm -rf /"
-// would numeric-resolve to the real TK-1 (resolveId stops parseInt at the first
-// non-digit). Pre-gating the raw ref blocks that whole class before resolveId.
-const REFRE = /^(TK-?)?\d+(-[a-z0-9-]+)?$/i;
-const resolveList = (refs, map) => { const out = []; if (!Array.isArray(refs)) return out;
-  for (const r of refs) { if (!REFRE.test(String(r).trim())) continue; const id = resolveId(r, map); if (id && IDRE.test(id)) out.push(id); }
-  return [...new Set(out)]; };
+// IDRE / REFRE / resolveList now live in ./lib.js (co-located with resolveId).
 const json = (res, code, obj) => { res.writeHead(code, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(obj)); };
 function readJson(req, cb) { let b = ''; req.on('data', d => { b += d; if (b.length > 1e6) req.destroy(); }); req.on('end', () => { try { cb(JSON.parse(b || '{}')); } catch { cb(null); } }); }
 

← 3443b04a integrate Codex with shared ticket system  ·  back to Ticket System  ·  TK-10971: prove the REFRE pre-gate is load-bearing (canary T 13270deb →