← back to George Gmail

test/mock-george.js

60 lines

#!/usr/bin/env node
/*
 * mock-george.js — a throwaway stand-in for the George bridge, used ONLY by
 * test-protected-survival.sh. It never touches a real mailbox.
 *
 * Serves the four routes delete-old-drafts-info.js uses:
 *   GET    /api/messages?q=...      -> the server-side >30d id set
 *   GET    /api/drafts              -> the draft listing
 *   GET    /api/messages/:id        -> archive-before-delete metadata
 *   DELETE /api/drafts/:id          -> permanent delete
 *
 * FAULT INJECTION (this is the point of the file): SABOTAGE_AFTER_LIST=N makes the
 * protected draft SABOTAGE_ID vanish after the Nth /api/drafts listing, without the
 * drain ever asking for it. That models the class the survival assertion exists to
 * catch — a protected draft lost to something other than the drain's own intent,
 * which an intent-side check (`!isExempt(d)`) is structurally blind to.
 */
'use strict';
const http = require('http');

const PORT = parseInt(process.env.MOCK_PORT || '9871', 10);
const SABOTAGE_AFTER_LIST = parseInt(process.env.SABOTAGE_AFTER_LIST || '0', 10);
const SABOTAGE_ID = process.env.SABOTAGE_ID || '';
const NDRAFTS = parseInt(process.env.MOCK_NDRAFTS || '0', 10); // bulk filler, for the truncation case

// draftId -> messageId. "old" = in the >30d set.
let drafts = new Map([
  ['draft-protected', 'msg-protected'],  // keep-listed, old  -> must survive
  ['draft-garbage',   'msg-garbage'],    // not kept, old     -> must be deleted
  ['draft-fresh',     'msg-fresh'],      // not old           -> must survive
]);
for (let i = 0; i < NDRAFTS; i++) drafts.set(`draft-bulk-${i}`, `msg-bulk-${i}`);
const OLD = new Set(['msg-protected', 'msg-garbage', ...[...Array(NDRAFTS).keys()].map((i) => `msg-bulk-${i}`)]);

let listCount = 0;
const send = (res, code, obj) => { res.writeHead(code, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(obj)); };

http.createServer((req, res) => {
  const u = new URL(req.url, `http://127.0.0.1:${PORT}`);
  const p = u.pathname;

  if (req.method === 'GET' && p === '/api/messages') {
    return send(res, 200, { messages: [...drafts.values()].filter((m) => OLD.has(m)).map((id) => ({ id })), nextPageToken: '' });
  }
  if (req.method === 'GET' && p === '/api/drafts') {
    listCount++;
    if (SABOTAGE_AFTER_LIST && listCount > SABOTAGE_AFTER_LIST && SABOTAGE_ID) drafts.delete(SABOTAGE_ID);
    const max = parseInt(u.searchParams.get('maxResults') || '500', 10);
    return send(res, 200, [...drafts.entries()].slice(0, max).map(([id, mid]) => ({ id, message: { id: mid } })));
  }
  if (req.method === 'GET' && p.startsWith('/api/messages/')) {
    return send(res, 200, { subject: 'mock', from: 'a@b.c', to: '', date: 'Thu, 14 Aug 2026 16:38:47 -0700', snippet: 'mock snippet' });
  }
  if (req.method === 'DELETE' && p.startsWith('/api/drafts/')) {
    drafts.delete(decodeURIComponent(p.split('/').pop()));
    return send(res, 200, { ok: true });
  }
  return send(res, 404, { error: 'not found' });
}).listen(PORT, '127.0.0.1', () => console.error(`mock-george listening on ${PORT}`));