Files
ECC/tests/ci/pr-burst-guard.test.js
T

185 lines
8.7 KiB
JavaScript

#!/usr/bin/env node
const assert = require('node:assert/strict');
const { test } = require('node:test');
const { evaluate, moderate, githubAPI, listPages, strongestWindow } = require('../../scripts/ci/pr-burst-guard');
const policy = { windowMinutes: 20, closeAbove: 5, blockAbove: 10, deniedUserIDs: [999], notify: ['maintainer'] };
const start = Date.parse('2026-10-14T19:00:00Z');
const pull = (number, extra = {}) => ({ number, user: { id: 42, login: 'contributor' },
created_at: new Date(start + number * 1000).toISOString(), state: 'open', ...extra });
const pulls = n => Array.from({ length: n }, (_, i) => pull(i + 1));
function fixture(n, options = {}) {
const rows = options.rows || pulls(n);
const calls = [];
let issue;
const api = async (method, route, body) => {
calls.push({ method, route, body });
if (route === '/repos/owner/repo') return { owner: { login: 'owner', type: 'User' } };
if (route.endsWith(`/pulls/${n}`)) return rows.find(p => p.number === n);
if (route.includes('/collaborators/')) return { permission: options.permission || 'read' };
if (route.includes('/pulls?')) return rows.slice().reverse();
if (route.includes('/issues?')) return issue ? [issue] : [];
if (route.endsWith('/issues') && method === 'POST') {
issue = { number: 101, user: { login: 'github-actions[bot]' }, ...body };
return issue;
}
if (route.endsWith('/issues/101') && method === 'PATCH') { issue = { ...issue, ...body }; return issue; }
if (route.endsWith('/issues/101/comments')) return {};
if (method === 'PATCH') return {};
if (method === 'PUT') return {};
throw new Error(`Unexpected ${method} ${route}`);
};
const args = { api, repository: 'owner/repo', number: n, action: 'opened', policy, ...options };
return { args, calls, api };
}
test('five allowed, sixth closed, ten not blocked, eleventh escalates', () => {
for (const n of [5, 6, 10, 11]) {
const result = evaluate(pulls(n), pull(n), policy, start + n * 1000);
assert.equal(result.count, n);
assert.equal(result.block, n > 10);
assert.deepEqual(result.excess.map(p => p.number), Array.from({ length: Math.max(0, n - 5) }, (_, i) => i + 6));
}
});
test('counts closed and draft PRs by stable user ID, excludes other authors and out-of-window rows', () => {
const rows = [...pulls(5), pull(6, { state: 'closed', draft: true }), pull(7, { user: { id: 99 } }),
pull(8, { created_at: new Date(start - 21 * 60000).toISOString() })];
assert.equal(evaluate(rows, pull(6), policy, start + 6000).count, 6);
});
test('delayed events retain their creation window and deterministic number tie-break', () => {
const rows = pulls(11).map(p => ({ ...p, created_at: new Date(start).toISOString() }));
assert.equal(evaluate(rows.reverse(), rows[0], policy, start).block, true);
assert.deepEqual(evaluate(rows, rows[0], policy, start).excess.map(p => p.number), [6, 7, 8, 9, 10, 11]);
});
test('moderation closes and locks excess, then deduplicates maintainer alert', async () => {
const f = fixture(6);
await moderate(f.args);
await moderate(f.args);
assert.equal(f.calls.filter(c => c.method === 'POST' && c.route.endsWith('/issues')).length, 1);
assert(f.calls.some(c => c.method === 'PATCH' && c.route.endsWith('/pulls/6') && c.body.state === 'closed'));
assert(f.calls.some(c => c.method === 'PUT' && c.route.endsWith('/issues/6/lock')));
});
test('no owner credential reports blocking unavailable and still notifies', async () => {
const f = fixture(11);
const result = await moderate(f.args);
assert.match(result.blockStatus, /not configured/);
assert(f.calls.some(c => c.method === 'POST' && c.body.body.includes('not configured')));
});
test('owner token identity checked before personal block, escalation notifies exactly once', async () => {
const ownerCalls = [];
const blockAPI = async (method, route) => {
ownerCalls.push({ method, route });
if (route === '/user') return { login: 'owner' };
return {};
};
const f = fixture(6, { blockAPI });
await moderate(f.args);
f.args.number = 11;
// Use a second snapshot while retaining the alert ledger.
const original = f.args.api;
f.args.api = (method, route, body) => route.endsWith('/pulls/11') ? pull(11)
: route.includes('/pulls?') ? pulls(11).reverse() : original(method, route, body);
await moderate(f.args);
await moderate(f.args);
assert(ownerCalls.some(c => c.method === 'PUT' && c.route === '/user/blocks/contributor'));
assert.equal(f.calls.filter(c => c.route.endsWith('/comments')).length, 1);
});
test('wrong personal identity never blocks and records the failure', async () => {
const f = fixture(11, { blockAPI: async (method, route) => {
assert.equal(route, '/user'); return { login: 'someone-else' };
} });
assert.match((await moderate(f.args)).blockStatus, /owner identity/);
});
test('write/admin collaborators bypass; actor identity cannot exempt an outsider', async () => {
for (const permission of ['write', 'maintain', 'admin']) {
const f = fixture(11, { permission });
assert.equal((await moderate(f.args)).exempt, true);
assert.equal(f.calls.filter(c => ['POST', 'PATCH', 'PUT'].includes(c.method)).length, 0);
}
const f = fixture(6, { actor: 'owner' });
assert.equal((await moderate(f.args)).count, 6);
});
test('denylisted user is closed on reopen even outside the creation window', async () => {
const f = fixture(1, { action: 'reopened', rows: [pull(1, { user: { id: 999, login: 'denied' } })] });
assert.equal((await moderate(f.args)).denied, true);
assert(f.calls.some(c => c.method === 'PATCH' && c.route.endsWith('/pulls/1')));
});
test('dry run performs no mutations or owner-token calls', async () => {
const f = fixture(11, { dryRun: true, blockAPI: async () => { throw new Error('secret used'); } });
assert.equal((await moderate(f.args)).block, true);
assert(f.calls.every(c => c.method === 'GET'));
});
test('REST transport rejects off-host paths and never retries mutations', async () => {
const api = githubAPI('test-token', async () => { throw new Error('must not fetch'); });
await assert.rejects(api('GET', '//attacker.test'), /path/);
let attempts = 0;
const limited = githubAPI('token', async () => {
attempts++; return { ok: false, status: 403 };
});
await assert.rejects(limited('PUT', '/user/blocks/example'), /403/);
assert.equal(attempts, 1);
});
test('out-of-order older event sees newer burst and retains historical peak', async () => {
const rows = [...pulls(11), pull(12, { created_at: new Date(start + 30 * 60000).toISOString() })];
assert.equal(strongestWindow(rows, pull(6), policy, start + 6000).count, 11);
const f = fixture(6, { rows });
assert.equal((await moderate(f.args)).block, true);
});
test('separate bursts close excess from each window even if pending events disappear', () => {
const rows = [...pulls(6), ...pulls(6).map(p => ({ ...p, number: p.number + 6,
created_at: new Date(start + 60 * 60000 + p.number * 1000).toISOString() }))];
const result = strongestWindow(rows, pull(6), policy, start + 6000);
assert.equal(result.count, 6);
assert.deepEqual(result.excess.map(p => p.number), [6, 12]);
});
test('pagination includes page two and refuses silent truncation', async () => {
let calls = 0;
const rows = await listPages(async () => { calls++; return calls === 1 ? pulls(100) : pulls(1); }, '/repos/o/r/pulls?state=all');
assert.equal(rows.length, 101);
assert.equal(calls, 2);
await assert.rejects(listPages(async () => pulls(100), '/repos/o/r/pulls?state=all'), /Pagination limit/);
});
test('permission errors fail safely; outsider404 permits moderation', async () => {
for (const status of [403, 404]) {
const f = fixture(6);
const original = f.args.api;
f.args.api = (method, route, body) => {
if (route.includes('/collaborators/')) throw Object.assign(new Error('permission lookup failed'), { status });
return original(method, route, body);
};
if (status === 403) await assert.rejects(moderate(f.args), /permission lookup/);
else assert.equal((await moderate(f.args)).count, 6);
}
});
test('closure failure still creates an alert with enforcement errors', async () => {
const f = fixture(6);
const original = f.args.api;
f.args.api = (method, route, body) => {
if (method === 'PATCH' && route.endsWith('/pulls/6')) throw new Error('close denied');
return original(method, route, body);
};
assert.match((await moderate(f.args)).errors[0], /close denied/);
assert(f.calls.some(c => c.method === 'POST' && c.body.body.includes('close denied')));
});
test('reports only moderation metadata, never PR bodies or titles', async () => {
const f = fixture(6, { rows: pulls(6).map(p => ({ ...p, title: 'untrusted title', body: 'untrusted body' })) });
assert.doesNotMatch(JSON.stringify(await moderate(f.args)), /untrusted/);
});