mirror of
https://github.com/affaan-m/ECC.git
synced 2026-11-07 16:45:53 +00:00
185 lines
8.7 KiB
JavaScript
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/);
|
|
});
|