diff --git a/src/bounties/ingest.ts b/src/bounties/ingest.ts index 46e34b5a04..fa582dd348 100644 --- a/src/bounties/ingest.ts +++ b/src/bounties/ingest.ts @@ -21,8 +21,27 @@ export function normalizeGittBountySnapshot(payload: unknown): BountyRecord[] { // empty list — matching how every other non-object value already yields `[]` — instead of throwing. const data = payload as GittIssueListPayload | null | undefined; return (data?.issues ?? []).flatMap((issue) => { - if (issue.id === undefined || !issue.repository_full_name || !issue.issue_number || !issue.status) return []; - const amountText = issue.bounty_alpha ?? (issue.bounty_amount === undefined ? undefined : String(issue.bounty_amount)); + // #9313: `issue` is cast from an UNTRUSTED Gitt payload, so its declared field types are not guaranteed at + // runtime. Type-guard every field the BountyRecord contract requires (id: string|number, repoFullName: + // string, issueNumber: number, status: string) -- mirroring registry/normalize.ts's typed value guards -- + // so a wrong-typed field drops the whole record instead of being trusted into a mistyped BountyRecord. + if ( + (typeof issue.id !== "string" && typeof issue.id !== "number") || + typeof issue.repository_full_name !== "string" || + !issue.repository_full_name || + typeof issue.issue_number !== "number" || + !Number.isFinite(issue.issue_number) || + typeof issue.status !== "string" || + !issue.status + ) { + return []; + } + const amountText = + typeof issue.bounty_alpha === "string" + ? issue.bounty_alpha + : typeof issue.bounty_amount === "number" && Number.isFinite(issue.bounty_amount) + ? String(issue.bounty_amount) + : undefined; return [ { id: String(issue.id), diff --git a/test/unit/bounties-ingest.test.ts b/test/unit/bounties-ingest.test.ts new file mode 100644 index 0000000000..3401639b01 --- /dev/null +++ b/test/unit/bounties-ingest.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; + +import { normalizeGittBountySnapshot } from "../../src/bounties/ingest"; + +// #9313: normalizeGittBountySnapshot parses an UNTRUSTED Gitt payload (`unknown`), so its declared field types +// don't hold at runtime. These pin the typed value guards: a wrong-typed required field drops the whole record +// rather than being trusted into a mistyped BountyRecord, and a wrong-typed/non-finite amount degrades to +// undefined without dropping the record. +const validIssue: Record = { + id: 42, + repository_full_name: "owner/repo", + issue_number: 7, + status: "open", + bounty_amount: 5, +}; +const snap = (issue: Record) => normalizeGittBountySnapshot({ issues: [issue] }); + +describe("normalizeGittBountySnapshot type guards (#9313)", () => { + it.each([ + ["id neither string nor number", { id: {} }], + ["repoFullName not a string", { repository_full_name: 5 }], + ["repoFullName empty", { repository_full_name: "" }], + ["issueNumber not a number", { issue_number: "7" }], + ["issueNumber non-finite (NaN)", { issue_number: Number.NaN }], + ["status not a string", { status: 5 }], + ["status empty", { status: "" }], + ])("drops a record whose required field is wrong-typed: %s", (_label, override) => { + expect(snap({ ...validIssue, ...override })).toEqual([]); + }); + + it("keeps a well-typed record (string id) and takes amountText from bounty_alpha", () => { + const [record] = snap({ ...validIssue, id: "abc-1", bounty_alpha: "1 alpha", bounty_amount: undefined }); + expect(record).toMatchObject({ + id: "abc-1", + repoFullName: "owner/repo", + issueNumber: 7, + status: "open", + amountText: "1 alpha", + }); + }); + + it("keeps a well-typed record (numeric id) and stringifies a finite bounty_amount when no bounty_alpha", () => { + const [record] = snap({ ...validIssue, id: 42, bounty_alpha: undefined, bounty_amount: 5 }); + expect(record).toMatchObject({ id: "42", issueNumber: 7, amountText: "5" }); + }); + + it("degrades a non-finite or absent amount to undefined without dropping the record", () => { + const infinite = snap({ ...validIssue, bounty_alpha: undefined, bounty_amount: Number.POSITIVE_INFINITY }); + expect(infinite).toHaveLength(1); + expect(infinite[0]?.amountText).toBeUndefined(); + const absent = snap({ ...validIssue, bounty_alpha: undefined, bounty_amount: undefined }); + expect(absent).toHaveLength(1); + expect(absent[0]?.amountText).toBeUndefined(); + }); + + it("returns [] for a null/malformed payload (unchanged fail-safe)", () => { + expect(normalizeGittBountySnapshot(null)).toEqual([]); + expect(normalizeGittBountySnapshot({})).toEqual([]); + }); +});