fix(gate): harden baseSha to GateTest's strict receiver contract #5624
2 changed files+141−5
Modifiedsrc/__tests__/gatetest-base-sha.test.ts+50−0View fileUnifiedSplit
@@ -83,3 +83,53 @@ describe("runGateTestScan — baseSha in the blocking payload", () => {
8383 expect("baseSha" in (lastBody as object)).toBe(false);
8484 });
8585});
86
87describe("gateTestBaseShaField — the 400-avoidance contract (gatetest@528e2ad7)", () => {
88 // GateTest's receiver 400s the ENTIRE event on a malformed baseSha —
89 // nothing gets queued. Omitting degrades to whole-repo enforcement;
90 // malformed loses the push. These pin that we can only ever emit a
91 // value the receiver accepts.
92
93 it("omits a malformed base rather than dropping the whole event", async () => {
94 await notifyGateTestOfPush("o", "r", "refs/heads/main", HEAD, "not-a-sha");
95 expect("baseSha" in (lastBody as object)).toBe(false);
96 await notifyGateTestOfPush("o", "r", "refs/heads/main", HEAD, "b".repeat(39));
97 expect("baseSha" in (lastBody as object)).toBe(false);
98 });
99
100 it("case-normalises to lowercase", async () => {
101 await notifyGateTestOfPush("o", "r", "refs/heads/main", HEAD, "B".repeat(40));
102 expect(lastBody?.baseSha).toBe("b".repeat(40));
103 });
104
105 it("treats baseSha === sha as no base and omits it", async () => {
106 await notifyGateTestOfPush("o", "r", "refs/heads/main", HEAD, HEAD);
107 expect("baseSha" in (lastBody as object)).toBe(false);
108 });
109});
110
111describe("gateTestEventId — the idempotency key GateTest dedupes on", () => {
112 // GateTest's ON CONFLICT (event_id) DO NOTHING means: same id on retry →
113 // harmless 200-duplicate; fresh id on retry → a SECOND scan of the same
114 // push. So the id must be a pure function of the event's identity.
115
116 it("is deterministic: same event → same id, and sent in the payload", async () => {
117 await notifyGateTestOfPush("o", "r", "refs/heads/main", HEAD, BASE);
118 const first = lastBody?.eventId;
119 await notifyGateTestOfPush("o", "r", "refs/heads/main", HEAD, BASE);
120 expect(typeof first).toBe("string");
121 expect(lastBody?.eventId).toBe(first);
122 expect(first).toMatch(
123 /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
124 );
125 });
126
127 it("differs across sha, base, and mode — a blocking scan is its own event", async () => {
128 await notifyGateTestOfPush("o", "r", "refs/heads/main", HEAD, BASE);
129 const pushId = lastBody?.eventId;
130 await notifyGateTestOfPush("o", "r", "refs/heads/main", "c".repeat(40), BASE);
131 expect(lastBody?.eventId).not.toBe(pushId);
132 await runGateTestScan("o", "r", "refs/heads/main", HEAD, BASE);
133 expect(lastBody?.eventId).not.toBe(pushId);
134 });
135});
Modifiedsrc/lib/gate.ts+91−5View fileUnifiedSplit
@@ -166,6 +166,68 @@ export function _resetGateTestAuthWarning(): void {
166166 _gatetestAuthWarned = false;
167167}
168168
169/**
170 * The `baseSha` field for a GateTest payload, or nothing.
171 *
172 * GateTest's receiver contract (gatetest@528e2ad7, /api/events/push) is
173 * strict on purpose: a malformed `baseSha` — wrong length, non-hex, or
174 * all-zero — is a 400 and NOTHING IS QUEUED. Omitting is safe (whole-repo
175 * enforcement, "base unknown"); malformed drops the entire push event, a
176 * strictly worse failure than never having sent a base. So this helper is
177 * the only place allowed to build the field, and it only ever emits a
178 * value the receiver will accept:
179 * - 40-hex, case-normalised to lowercase;
180 * - never all-zeros (branch creation — git's null oldSha);
181 * - never equal to `sha` (the receiver treats that as no base anyway).
182 * Anything else → field omitted entirely (not null, not zeros).
183 */
184export function gateTestBaseShaField(
185 baseSha: string | undefined,
186 headSha: string
187): { baseSha?: string } {
188 if (!baseSha) return {};
189 const b = baseSha.toLowerCase();
190 if (!/^[0-9a-f]{40}$/.test(b)) return {};
191 if (/^0{40}$/.test(b)) return {};
192 if (b === headSha.toLowerCase()) return {};
193 return { baseSha: b };
194}
195
196/**
197 * Deterministic event id for a GateTest payload.
198 *
199 * GateTest deduplicates on `event_id` (ON CONFLICT DO NOTHING) and echoes
200 * OUR id back — the id is the idempotency key, so it must be a pure
201 * function of the event's identity, never random: a retry with a fresh id
202 * would queue a SECOND scan of the same push, while a stable id turns the
203 * retry into a harmless 200-duplicate. Identity = repo + ref + sha + base
204 * + mode (a blocking merge-gate scan is deliberately a different event
205 * from the async push scan of the same sha). Formatted as a UUID-shaped
206 * string from a SHA-256 of the identity, so any conventional id validator
207 * on the receiving side accepts it.
208 */
209export function gateTestEventId(parts: {
210 repository: string;
211 ref: string;
212 sha: string;
213 baseSha?: string;
214 mode: "async" | "blocking";
215}): string {
216 const hasher = new Bun.CryptoHasher("sha256");
217 hasher.update(
218 [
219 "gluecron-gatetest-v1",
220 parts.repository,
221 parts.ref,
222 parts.sha.toLowerCase(),
223 parts.baseSha?.toLowerCase() ?? "",
224 parts.mode,
225 ].join("\n")
226 );
227 const h = hasher.digest("hex");
228 return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20, 32)}`;
229}
230
169231export async function notifyGateTestOfPush(
170232 owner: string,
171233 repo: string,
@@ -195,17 +257,23 @@ export async function notifyGateTestOfPush(
195257 "[gatetest] GATETEST_URL is set but GATETEST_API_KEY is empty — push notifications will be rejected by GateTest with 401. Set GATETEST_API_KEY in the deploy environment to enable scans."
196258 );
197259 }
260 const baseField = gateTestBaseShaField(baseSha, headSha);
261 const eventId = gateTestEventId({
262 repository: `${owner}/${repo}`,
263 ref,
264 sha: headSha,
265 baseSha: baseField.baseSha,
266 mode: "async",
267 });
198268 const res = await fetch(config.gatetestUrl, {
199269 method: "POST",
200270 headers,
201271 body: JSON.stringify({
272 eventId,
202273 repository: `${owner}/${repo}`,
203274 ref,
204275 sha: headSha,
205 // Only send a real ancestor commit — an all-zero oldSha (branch
206 // creation) is not a base, and sending it would make GateTest diff
207 // against garbage instead of falling back to "base unknown".
208 ...(baseSha && !/^0+$/.test(baseSha) ? { baseSha } : {}),
276 ...baseField,
209277 source: "gluecron",
210278 mode: "async",
211279 }),
@@ -216,6 +284,16 @@ export async function notifyGateTestOfPush(
216284 console.warn(
217285 `[gatetest] push notify returned ${res.status} for ${owner}/${repo}@${headSha.slice(0, 7)}: ${body.slice(0, 200)}`
218286 );
287 } else {
288 // Trace line: eventId is OUR deterministic id echoed back — it names
289 // the scan_queue row on the GateTest side. `duplicate:true` (200) means
290 // the same event was already queued; `queued:true` (202) is a new scan.
291 const body = (await res.json().catch(() => null)) as
292 | { eventId?: string; queued?: boolean; duplicate?: boolean }
293 | null;
294 console.log(
295 `[gatetest] push notify accepted for ${owner}/${repo}@${headSha.slice(0, 7)}: eventId=${eventId}${body?.duplicate ? " (duplicate — already queued)" : ""}`
296 );
219297 }
220298 } catch (err) {
221299 console.warn(
@@ -250,14 +328,22 @@ export async function runGateTestScan(
250328 headers["Authorization"] = `Bearer ${config.gatetestApiKey}`;
251329 }
252330
331 const baseField = gateTestBaseShaField(baseSha, headSha);
253332 const response = await fetch(config.gatetestUrl, {
254333 method: "POST",
255334 headers,
256335 body: JSON.stringify({
336 eventId: gateTestEventId({
337 repository: `${owner}/${repo}`,
338 ref,
339 sha: headSha,
340 baseSha: baseField.baseSha,
341 mode: "blocking",
342 }),
257343 repository: `${owner}/${repo}`,
258344 ref,
259345 sha: headSha,
260 ...(baseSha && !/^0+$/.test(baseSha) ? { baseSha } : {}),
346 ...baseField,
261347 source: "gluecron",
262348 mode: "blocking",
263349 }),
264350
c comment · e edit title · m merge · a approve · r request changes · ? shortcuts