fix(mosaic): de-flake mutator-class lease gate TTL-expiry test
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
The "observer revocation and monotonic TTL expiry deny the next
mutator" acceptance test reused a 1-second-TTL lease both to prove
authorization works immediately after promotion and, later, to
demonstrate real monotonic-clock expiry. Under a contended push-CI
host (concurrent build-gateway/appservice/web matrix), scheduling
delay alone between promote() and the immediate "still valid"
authorize() check could consume the entire 1-second margin, flipping
that setup assertion to a spurious deny (Woodpecker push CI#1945:
`expected { ok: false } to match { ok: true, decision: 'allow' }`).
Reproduced deterministically by inserting a 995ms delay between
promote() and the check, which reliably reproduced the exact CI#1945
failure signature.
Fix is test-determinism only: the initial "lease authorizes" check
now uses a normal-TTL lease with no real-time race, and a dedicated,
isolated short-TTL lease drives only the deliberate wait-then-expire
assertion. No production lease-broker/mutator-gate code changed, and
the deny-the-next-mutator security assertions (LEASE_EXPIRED after
TTL wait, MUTATOR_UNVERIFIED after revoke) are unchanged and
unweakened.
Closes #860
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BMpAGx3YQZT64dov5xCNJL
This commit is contained in:
@@ -661,13 +661,27 @@ describe('whole mutator-class lease gate', () => {
|
||||
test('observer revocation and monotonic TTL expiry deny the next mutator', async () => {
|
||||
const { socket } = await startBroker();
|
||||
const sessionId = await register(socket);
|
||||
const pending = await beginVerification(socket, sessionId, 'claude', 1, 1);
|
||||
await promote(socket, sessionId, pending.receipt_challenge!);
|
||||
|
||||
// Establish the lease with a normal (non-racing) TTL first and prove it
|
||||
// authorizes. This "still valid" check is setup, not a TTL-expiry
|
||||
// assertion, so it must not share a lease with a 1-second TTL: on a
|
||||
// contended push-CI host, scheduling delay alone between promote() and
|
||||
// this authorize() call can consume that entire 1-second margin and
|
||||
// spuriously deny it (CI#1945). Using a generous TTL here removes that
|
||||
// real-time race without touching lease-gate security semantics.
|
||||
const pending = await beginVerification(socket, sessionId, 'claude');
|
||||
await promote(socket, sessionId, pending.receipt_challenge!);
|
||||
expect(await authorize(socket, sessionId, 'claude', 'Bash')).toMatchObject({
|
||||
ok: true,
|
||||
decision: 'allow',
|
||||
});
|
||||
|
||||
// A dedicated, isolated short-TTL lease drives the deliberate monotonic
|
||||
// expiry demonstration below. It is never used for anything but the
|
||||
// wait-then-expire assertion, so there is no setup work racing its
|
||||
// 1-second window.
|
||||
const shortLived = await beginVerification(socket, sessionId, 'claude', 1, 1, 2);
|
||||
await promote(socket, sessionId, shortLived.receipt_challenge!);
|
||||
await new Promise((resolve) => setTimeout(resolve, 1_100));
|
||||
expect(await authorize(socket, sessionId, 'claude', 'Bash')).toMatchObject({
|
||||
ok: false,
|
||||
@@ -675,7 +689,7 @@ describe('whole mutator-class lease gate', () => {
|
||||
decision: 'deny',
|
||||
});
|
||||
|
||||
const refreshed = await beginVerification(socket, sessionId, 'claude', 1, 300, 2);
|
||||
const refreshed = await beginVerification(socket, sessionId, 'claude', 1, 300, 3);
|
||||
await promote(socket, sessionId, refreshed.receipt_challenge!);
|
||||
expect(
|
||||
await request(socket, {
|
||||
|
||||
Reference in New Issue
Block a user