← 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}`));