diff --git a/sdk/typescript/README.md b/sdk/typescript/README.md index 836590b98..0ee624d3f 100644 --- a/sdk/typescript/README.md +++ b/sdk/typescript/README.md @@ -579,13 +579,40 @@ npx @openai/codex-security publish scan \ ``` Destination flags take precedence over `CODEX_SECURITY_LINEAR_TEAM` and the -optional `CODEX_SECURITY_LINEAR_PROJECT`. Use `--dry-run` to preview the issue -titles without creating them, or `--json` to return structured publication -results. +optional `CODEX_SECURITY_LINEAR_PROJECT`. Use `--dry-run --json` to preview the +issue content and destination without creating issues, or `--json` to return +structured publication results. Interactive publication shows a full-screen activity view with live Codex output and issue-creation progress. Other terminals receive plain progress on stderr, so `--json` output remains machine-readable. +Repeat `--finding FINDING_ID` to publish only selected findings. Omit it to +publish every finding. To publish exactly what you reviewed, copy the +`payloadDigest` from a dry run and pass it as `--expect-digest` with the same +selection and destination: + +```bash +npx @openai/codex-security publish scan /path/to/completed-scan \ + --to linear --linear-team TEAM_ID --finding csf_example \ + --dry-run --json + +npx @openai/codex-security publish scan /path/to/completed-scan \ + --to linear --linear-team TEAM_ID --finding csf_example \ + --expect-digest DIGEST_FROM_PREVIEW +``` + +The digest covers the selected issue content, destination, scan identity, and +requested assignee. A mismatch stops publication before any local publication +state or Linear issues are created. When an assignee is selected, the digest +uses HMAC-SHA-256 keyed by the selected Linear API credential. Keep the same +assignee and credential when publishing; changing either requires a new preview. +Unassigned previews remain credential-independent. Previews do not echo +assignee identities or credentials. The digest is not a permissions check or a +remote readback. +Keep saved previews private: they contain the full finding descriptions and +source snippets. Descriptions omit a wall-clock upload timestamp so an unchanged +scan and selection produce the same preview; Linear records issue creation time. + By default, publishing starts Codex with your existing Codex configuration and connected Linear app. Sign in to Codex and connect Linear before publishing in this mode. No separate Linear API token is required, and publication does not @@ -653,6 +680,10 @@ console.log(publication.created.length); Add `projectId: "PROJECT_ID"` to the options to publish into a specific Linear project instead of directly to the team. +Use `findingIds: ["csf_example"]` to select findings. A call with `dryRun: true` +returns the selected `issues` and `payloadDigest`; pass that digest as +`expectedDigest` on the publishing call to reject changes since review. + Pass `linearApiKey` to publish directly through the Linear API. Omit `assigneeId` to leave issues unassigned, or supply a Linear user ID or email address to select an assignee: diff --git a/sdk/typescript/scripts/smoke-package.mjs b/sdk/typescript/scripts/smoke-package.mjs index 9c7307b6b..940d6ac02 100644 --- a/sdk/typescript/scripts/smoke-package.mjs +++ b/sdk/typescript/scripts/smoke-package.mjs @@ -445,43 +445,92 @@ try { assert.equal(publication.counts.findings, 1); assert.equal(publication.counts.created, 0); assert.match(publication.issues[0].title, /^\[Codex Security\]\[HIGH\] /u); + assert.match(publication.payloadDigest, /^[a-f0-9]{64}$/u); + assert.doesNotMatch(publication.issues[0].description, /\*\*Uploaded:\*\*/u); const networkGuard = join(consumer, "reject-publication-network.cjs"); await writeFile( networkGuard, 'globalThis.fetch = async () => { throw new Error("Publication dry runs must not make network requests."); };\n', ); + const selectedPublicationArgs = [ + "--require", + networkGuard, + launcher, + "publish", + "scan", + publicationScan, + "--to", + "linear", + "--linear-team", + "team-example", + "--finding", + publication.issues[0].findingId, + "--expect-digest", + publication.payloadDigest, + ]; + const selectedPublicationOptions = { + cwd: consumer, + capture: true, + env: { + ...process.env, + CODEX_SECURITY_LINEAR_PROJECT: "", + CODEX_SECURITY_LINEAR_API_KEY: "", + CODEX_SECURITY_STATE_DIR: join(consumer, "publication-state"), + }, + }; + assert.deepEqual( + JSON.parse( + run( + process.execPath, + [...selectedPublicationArgs, "--dry-run", "--json"], + selectedPublicationOptions, + ), + ), + publication, + ); + assert.throws( + () => + run( + process.execPath, + [...selectedPublicationArgs.slice(0, -1), "0".repeat(64), "--json"], + selectedPublicationOptions, + ), + /does not match the expected digest/u, + ); + const directPublicationArgs = [ + "--require", + networkGuard, + launcher, + "publish", + "scan", + publicationScan, + "--to", + "linear", + "--linear-team", + "team-example", + "--project", + "project-example", + "--linear-api-key", + "lin_api_SYNTHETIC_INSTALLED_OVERRIDE", + "--linear-assignee", + "security@example.test", + "--dry-run", + "--json", + ]; + const directPublicationOptions = { + cwd: consumer, + capture: true, + env: { + ...process.env, + CODEX_SECURITY_STATE_DIR: join(consumer, "publication-state"), + CODEX_SECURITY_LINEAR_API_KEY: "lin_api_SYNTHETIC_INSTALLED_ENV", + }, + }; const directPublicationText = run( process.execPath, - [ - "--require", - networkGuard, - launcher, - "publish", - "scan", - publicationScan, - "--to", - "linear", - "--linear-team", - "team-example", - "--project", - "project-example", - "--linear-api-key", - "lin_api_SYNTHETIC_INSTALLED_OVERRIDE", - "--linear-assignee", - "security@example.test", - "--dry-run", - "--json", - ], - { - cwd: consumer, - capture: true, - env: { - ...process.env, - CODEX_SECURITY_STATE_DIR: join(consumer, "publication-state"), - CODEX_SECURITY_LINEAR_API_KEY: "lin_api_SYNTHETIC_INSTALLED_ENV", - }, - }, + directPublicationArgs, + directPublicationOptions, ); const directPublication = JSON.parse(directPublicationText); assert.equal(directPublication.scanId, publication.scanId); @@ -492,6 +541,44 @@ try { directPublicationText, /lin_api_|security@example\.test/u, ); + assert.deepEqual( + JSON.parse( + run( + process.execPath, + [ + ...directPublicationArgs, + "--expect-digest", + directPublication.payloadDigest, + ], + directPublicationOptions, + ), + ), + directPublication, + ); + const rotatedPublicationArgs = [...directPublicationArgs]; + rotatedPublicationArgs[ + rotatedPublicationArgs.indexOf("--linear-api-key") + 1 + ] = "lin_api_SYNTHETIC_INSTALLED_ROTATED"; + const rotatedPublication = JSON.parse( + run(process.execPath, rotatedPublicationArgs, directPublicationOptions), + ); + assert.notEqual( + rotatedPublication.payloadDigest, + directPublication.payloadDigest, + ); + assert.throws( + () => + run( + process.execPath, + [ + ...rotatedPublicationArgs, + "--expect-digest", + directPublication.payloadDigest, + ], + directPublicationOptions, + ), + /does not match the expected digest/u, + ); await smokeNestedDeepScanWorker(installedRoot, consumer); diff --git a/sdk/typescript/src/cli.ts b/sdk/typescript/src/cli.ts index 5b9211919..b7c7ac0e5 100644 --- a/sdk/typescript/src/cli.ts +++ b/sdk/typescript/src/cli.ts @@ -235,6 +235,8 @@ const VALUE_OPTIONS = new Set([ "--linear-api-key", "--project", "--linear-assignee", + "--finding", + "--expect-digest", ]); const PROVIDER_OPTION = z .enum(["openai", "openrouter", "fireworks", "amazon-bedrock"]) @@ -1547,7 +1549,8 @@ export async function main( const publication = Cli.create("publish", { description: "Publish completed Codex Security scan findings.", }).command("scan", { - description: "Publish every finding from a completed scan to Linear.", + description: + "Publish selected or all findings from a completed scan to Linear.", destructive: true, mcp: false, args: z.object({ @@ -1576,6 +1579,15 @@ export async function main( .describe( "Linear assignee email or user ID; omit to leave issues unassigned.", ), + finding: z + .array(optionValue("--finding")) + .optional() + .describe( + "Finding ID to publish; repeat to select several. Defaults to all findings.", + ), + expectDigest: optionValue("--expect-digest") + .optional() + .describe("Require the payload digest from a reviewed dry run."), dryRun: z .boolean() .default(false) @@ -1818,6 +1830,12 @@ export async function main( dryRun: options.dryRun, ...(linearApiKey === undefined ? {} : { linearApiKey }), ...(assigneeId === undefined ? {} : { assigneeId }), + ...(options.finding === undefined + ? {} + : { findingIds: options.finding }), + ...(options.expectDigest === undefined + ? {} + : { expectedDigest: options.expectDigest }), ...(options.dryRun ? {} : { diff --git a/sdk/typescript/src/publication.ts b/sdk/typescript/src/publication.ts index f4255a0c2..3a97409ab 100644 --- a/sdk/typescript/src/publication.ts +++ b/sdk/typescript/src/publication.ts @@ -53,7 +53,6 @@ export async function prepareScanPublication( const contract = await loadContract(scanDirectory, { pluginRoot: await bundledPluginRoot(), }); - const uploadedAt = options.uploadedAt ?? new Date().toISOString(); const scanId = contract.manifest.scan.id; return { @@ -73,7 +72,11 @@ export async function prepareScanPublication( findingId: finding.findingId, occurrenceId: finding.occurrenceId, title: `[Codex Security][${finding.severity.level.toUpperCase()}] ${finding.title}`, - description: renderFindingDescription(contract, finding, uploadedAt), + description: renderFindingDescription( + contract, + finding, + options.uploadedAt, + ), ...(priority === undefined ? {} : { priority }), }; }), @@ -83,7 +86,7 @@ export async function prepareScanPublication( function renderFindingDescription( contract: LoadedContract, finding: Finding, - uploadedAt: string, + uploadedAt: string | undefined, ): string { const { coverage } = contract; const { scan } = contract.manifest; @@ -116,7 +119,7 @@ function renderFindingDescription( `**Scan mode:** ${scanMode(coverage.mode)}`, `**Started:** ${scan.startedAt}`, `**Completed:** ${scan.completedAt}`, - `**Uploaded:** ${uploadedAt}`, + ...(uploadedAt === undefined ? [] : [`**Uploaded:** ${uploadedAt}`]), "", "### Affected locations", "", diff --git a/sdk/typescript/src/publish.ts b/sdk/typescript/src/publish.ts index 93639959b..1a2bed533 100644 --- a/sdk/typescript/src/publish.ts +++ b/sdk/typescript/src/publish.ts @@ -1,5 +1,5 @@ import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; -import { createHash, randomUUID } from "node:crypto"; +import { createHash, createHmac, randomUUID } from "node:crypto"; import { appendFile, mkdir, @@ -46,6 +46,8 @@ export interface PublishScanOptions { projectId?: string; linearApiKey?: string; assigneeId?: string; + findingIds?: readonly string[]; + expectedDigest?: string; dryRun?: boolean; signal?: AbortSignal; onProgress?: (event: PublishScanProgress) => void; @@ -90,6 +92,7 @@ export interface PublishScanResult { dryRun?: boolean; issues?: PreparedPublicationIssue[]; warnings?: string[]; + payloadDigest?: string; } export interface PublicationCodexResult { @@ -146,21 +149,38 @@ export async function publishScanInternal( const environment = dependencies.environment ?? process.env; const linearApiKey = resolveLinearApiKey(environment, options.linearApiKey); - if (options.assigneeId !== undefined && linearApiKey === undefined) { - throw new ConfigurationError( - "A Linear API key is required to select a publication assignee.", - ); + let approvedAssignee: { id: string; key: string } | undefined; + if (options.assigneeId !== undefined) { + if (linearApiKey === undefined) { + throw new ConfigurationError( + "A Linear API key is required to select a publication assignee.", + ); + } + approvedAssignee = { id: options.assigneeId, key: linearApiKey }; } - const prepared = await (dependencies.prepare ?? prepareScanPublication)( - scanDirectory, - options, - ); + const fullPublication = await ( + dependencies.prepare ?? prepareScanPublication + )(scanDirectory, options); options.signal?.throwIfAborted(); + const prepared = selectPublicationFindings( + fullPublication, + options.findingIds, + ); + const payloadDigest = publicationPayloadDigest(prepared, approvedAssignee); + if ( + options.expectedDigest !== undefined && + options.expectedDigest !== payloadDigest + ) { + throw new ConfigurationError( + "The prepared Linear publication does not match the expected digest. Review a new dry run before publishing.", + ); + } const result: PublishScanResult = { scanId: prepared.scanId, uploadId: prepared.scanId, destination: prepared.destination, + payloadDigest, created: [], failed: [], counts: { @@ -175,7 +195,7 @@ export async function publishScanInternal( if (prepared.issues.length === 0) return result; await (dependencies.preparePublicationStore ?? preparePublicationStore)( - prepared, + fullPublication, environment, ); options.signal?.throwIfAborted(); @@ -307,7 +327,7 @@ export async function publishScanInternal( try { result.created = await ( dependencies.recordPublishedIssues ?? recordPublishedIssues - )(prepared, handoffResults.created, environment); + )(fullPublication, handoffResults.created, environment); } catch (error) { const detail = error instanceof Error ? error.message : String(error); throw new CodexSecurityError( @@ -377,6 +397,66 @@ export async function publishScanInternal( return result; } +function selectPublicationFindings( + publication: PreparedScanPublication, + findingIds: readonly string[] | undefined, +): PreparedScanPublication { + if (findingIds === undefined) return publication; + if ( + !Array.isArray(findingIds) || + findingIds.some((id) => typeof id !== "string" || !id.trim()) + ) { + throw new ConfigurationError( + "Publication finding IDs must be nonempty strings.", + ); + } + const selected = new Set(findingIds); + const known = new Set(publication.issues.map((issue) => issue.findingId)); + for (const findingId of selected) { + if (!known.has(findingId)) { + throw new ConfigurationError( + `Unknown publication finding ID: ${JSON.stringify(findingId)}.`, + ); + } + } + return { + ...publication, + issues: publication.issues.filter((issue) => selected.has(issue.findingId)), + }; +} + +function publicationPayloadDigest( + publication: PreparedScanPublication, + assignee: { id: string; key: string } | undefined, +): string { + const { destination } = publication; + const digest = + assignee === undefined + ? createHash("sha256") + : createHmac("sha256", assignee.key); + return digest + .update( + JSON.stringify({ + version: assignee === undefined ? 1 : 2, + scanId: publication.scanId, + destination: { + type: destination.type, + teamId: destination.teamId, + projectId: destination.projectId ?? null, + }, + assigneeId: assignee?.id ?? null, + issues: publication.issues.map((issue) => ({ + findingId: issue.findingId, + occurrenceId: issue.occurrenceId, + title: issue.title, + description: issue.description, + priority: issue.priority ?? null, + })), + }), + ) + .digest("hex"); +} + async function publishLinearApiIssues( publication: PreparedScanPublication, handoffFile: string, diff --git a/sdk/typescript/tests-ts/cli-publish.test.ts b/sdk/typescript/tests-ts/cli-publish.test.ts index 8d5ec5134..c7c9026ad 100644 --- a/sdk/typescript/tests-ts/cli-publish.test.ts +++ b/sdk/typescript/tests-ts/cli-publish.test.ts @@ -74,6 +74,47 @@ function publicationResult( } describe("publish scan", () => { + test("forwards repeated finding selections and the reviewed payload digest", async () => { + const stdout = capture(); + const stderr = capture(); + const deps = dependencies(); + let selected: Record | undefined; + const digest = "a".repeat(64); + deps.publishScan = async (_directory, options) => { + selected = { ...options }; + return { ...publicationResult(), payloadDigest: digest }; + }; + + expect( + await main( + [ + "publish", + "scan", + "completed-scan", + ...DESTINATION_OPTIONS, + "--finding", + "finding-3", + "--finding", + "finding-1", + "--expect-digest", + digest, + "--dry-run", + "--json", + ], + stdout.stream, + stderr.stream, + deps, + ), + ).toBe(0); + expect(selected).toMatchObject({ + findingIds: ["finding-3", "finding-1"], + expectedDigest: digest, + dryRun: true, + }); + expect(JSON.parse(stdout.text()).payloadDigest).toBe(digest); + expect(stderr.text()).toBe(""); + }); + test("accepts the Linear project flag and its published alias", async () => { for (const flag of ["--linear-project", "--project"]) { let projectId: string | undefined; @@ -1834,6 +1875,26 @@ describe("publish scan", () => { test("requires an explicit supported destination and team with valid optional flags", async () => { const cases: ReadonlyArray<[readonly string[], string]> = [ [["publish", "scan", "completed-scan"], "to"], + [ + [ + "publish", + "scan", + "completed-scan", + ...DESTINATION_OPTIONS, + "--finding", + ], + "Missing value for flag: --finding", + ], + [ + [ + "publish", + "scan", + "completed-scan", + ...DESTINATION_OPTIONS, + "--expect-digest", + ], + "Missing value for flag: --expect-digest", + ], [["publish", "scan", "completed-scan", "--to", "azure"], "linear"], [ ["publish", "scan", "completed-scan", "--to", "linear"], diff --git a/sdk/typescript/tests-ts/publication-integration.test.ts b/sdk/typescript/tests-ts/publication-integration.test.ts index b3f959a70..dd72c7f61 100644 --- a/sdk/typescript/tests-ts/publication-integration.test.ts +++ b/sdk/typescript/tests-ts/publication-integration.test.ts @@ -280,6 +280,93 @@ function receiptPath(fixture: PublicationFixture): string { } describe("database-backed Linear publication integration", () => { + test("publishes a reviewed subset without weakening full-scan history checks", async () => { + const completed = await fixture(3); + const sealed = await artifactDigests(completed.scanDirectory); + const selected = completed.findings[1]!; + const environment = { + ...completed.environment, + CODEX_SECURITY_LINEAR_API_KEY: "synthetic-key", + }; + const preview = await publishScanInternal( + completed.scanDirectory, + { + ...OPTIONS, + findingIds: [selected.findingId], + dryRun: true, + }, + { environment }, + ); + expect(preview.issues?.map((issue) => issue.findingId)).toEqual([ + selected.findingId, + ]); + expect(storedPublications(completed)).toEqual([]); + const stdout = capture(); + const stderr = capture(); + const cli = dependencies({ environment }); + type LinearClient = ReturnType< + NonNullable + >; + type IssueInput = Parameters[0]; + let mutations = 0; + cli.publishScan = async (directory, options) => + publishScanInternal(directory, options, { + environment, + linearClient: () => + ({ + createIssue: async (input: IssueInput) => { + mutations += 1; + expect(input.description).toBe(preview.issues![0]!.description); + return { + success: true, + issue: Promise.resolve({ + identifier: "SEC-SELECTED", + url: "https://linear.app/example/issue/SEC-SELECTED", + }), + }; + }, + }) as unknown as LinearClient, + }); + + expect( + await main( + [ + "publish", + "scan", + completed.scanDirectory, + "--to", + "linear", + "--linear-team", + OPTIONS.teamId, + "--project", + OPTIONS.projectId, + "--finding", + selected.findingId, + "--expect-digest", + preview.payloadDigest!, + "--json", + ], + stdout.stream, + stderr.stream, + cli, + ), + ).toBe(0); + const result = JSON.parse(stdout.text()) as PublishScanResult; + expect(result.payloadDigest).toBe(preview.payloadDigest); + expect(result.counts).toEqual({ findings: 1, created: 1, failed: 0 }); + expect(mutations).toBe(1); + expect( + storedPublications(completed).map((record) => [ + record.finding_id, + record.external_id, + ]), + ).toEqual([[selected.findingId, "SEC-SELECTED"]]); + expect(JSON.parse(await readFile(receiptPath(completed), "utf8"))).toEqual( + result, + ); + expect(await artifactDigests(completed.scanDirectory)).toEqual(sealed); + }); + test("persists unassigned direct team-only publication", async () => { const completed = await fixture(23); const sealed = await artifactDigests(completed.scanDirectory); diff --git a/sdk/typescript/tests-ts/publication.test.ts b/sdk/typescript/tests-ts/publication.test.ts index 15d61526c..37f11f0ca 100644 --- a/sdk/typescript/tests-ts/publication.test.ts +++ b/sdk/typescript/tests-ts/publication.test.ts @@ -58,6 +58,17 @@ async function reseal(scanDirectory: string): Promise { } describe("scan publication preparation", () => { + test("prepares stable descriptions without a wall-clock upload timestamp", async () => { + const scanDirectory = await copyExample(); + const options = { destination: "linear", teamId: "team_example" } as const; + const first = await prepareScanPublication(scanDirectory, options); + const second = await prepareScanPublication(scanDirectory, options); + + expect(second).toEqual(first); + expect(first.issues[0]!.description).toContain("**Completed:**"); + expect(first.issues[0]!.description).not.toContain("**Uploaded:**"); + }); + test("prepares sealed findings with scan-based upload IDs and full traceability", async () => { const scanDirectory = await copyExample(); const publication = await prepareScanPublication( diff --git a/sdk/typescript/tests-ts/publish.test.ts b/sdk/typescript/tests-ts/publish.test.ts index abd6b9adf..f7a847308 100644 --- a/sdk/typescript/tests-ts/publish.test.ts +++ b/sdk/typescript/tests-ts/publish.test.ts @@ -441,6 +441,258 @@ describe("direct Linear API publication", () => { }); describe("connected Linear publication", () => { + test("publishes only selected findings while verifying the full scan history", async () => { + const publication = preparedPublication(3); + const selected = [publication.issues[0]!, publication.issues[2]!]; + const preview = await publishScanInternal( + publication.scanDirectory, + { + ...OPTIONS, + findingIds: ["finding-3", "finding-1", "finding-1"], + dryRun: true, + }, + dependencies(publication), + ); + expect(preview.issues).toEqual(selected); + expect(preview.payloadDigest).toMatch(/^[a-f0-9]{64}$/u); + let verified = false; + let persisted = false; + + const result = await publishScanInternal( + publication.scanDirectory, + { + ...OPTIONS, + findingIds: ["finding-1", "finding-3"], + expectedDigest: preview.payloadDigest!, + }, + dependencies( + publication, + {}, + { + preparePublicationStore: async (full) => { + expect(full).toBe(publication); + verified = true; + }, + runCodex: async (_command, _args, input) => { + expect(verified).toBe(true); + const payload = JSON.parse( + await readFile(publicationData(input).publicationFile, "utf8"), + ); + expect( + payload.batches + .flat() + .map((issue: PreparedPublicationIssue) => issue.findingId), + ).toEqual(["finding-1", "finding-3"]); + expect(JSON.stringify(payload)).not.toContain("finding-2"); + return { + exitCode: 0, + stdout: selected.map((issue) => issueEvent(issue)).join("\n"), + stderr: "", + }; + }, + recordPublishedIssues: async (full, issues) => { + expect(full).toBe(publication); + expect(issues.map((issue) => issue.findingId)).toEqual([ + "finding-1", + "finding-3", + ]); + persisted = true; + return [...issues]; + }, + }, + ), + ); + expect(persisted).toBe(true); + expect(result.payloadDigest).toBe(preview.payloadDigest); + expect(result.counts).toEqual({ findings: 2, created: 2, failed: 0 }); + }); + + test("rejects changed approved payloads before local or remote publication work", async () => { + const original = preparedPublication(2); + const preview = await publishScanInternal( + original.scanDirectory, + { ...OPTIONS, dryRun: true }, + dependencies(original), + ); + const changes: Array< + ( + publication: PreparedScanPublication, + options: PublishScanOptions, + ) => void + > = [ + (publication) => { + publication.scanId = "different-scan"; + }, + (publication) => { + publication.destination.teamId = "different-team"; + }, + (publication) => { + delete publication.destination.projectId; + }, + (publication) => { + publication.issues[0]!.occurrenceId = "different-occurrence"; + }, + (publication) => { + publication.issues[0]!.title = "Changed title"; + }, + (publication) => { + publication.issues[0]!.description += "\nChanged content"; + }, + (publication) => { + publication.issues[0]!.priority = 4; + }, + (_publication, options) => { + options.findingIds = ["finding-1"]; + }, + (_publication, options) => { + options.linearApiKey = "synthetic-key"; + options.assigneeId = "another-user"; + }, + ]; + for (const change of changes) { + const publication = structuredClone(original); + const options: PublishScanOptions = { + ...OPTIONS, + expectedDigest: preview.payloadDigest!, + }; + change(publication, options); + let started = false; + await expect( + publishScanInternal( + publication.scanDirectory, + options, + dependencies( + publication, + {}, + { + preparePublicationStore: async () => { + started = true; + }, + resolveCodex: () => { + started = true; + return { command: "must-not-run" }; + }, + linearClient: linearApiClient(publication, { + configured: () => { + started = true; + }, + }), + }, + ), + ), + ).rejects.toThrow("does not match the expected digest"); + expect(started).toBe(false); + } + }); + + test("rejects unknown selected findings and keeps an empty selection inert", async () => { + const publication = preparedPublication(); + let started = false; + const injected = dependencies( + publication, + {}, + { + preparePublicationStore: async () => { + started = true; + }, + }, + ); + for (const findingIds of [["missing-finding"], [""]]) { + await expect( + publishScanInternal( + publication.scanDirectory, + { ...OPTIONS, findingIds }, + injected, + ), + ).rejects.toThrow(/finding ID/u); + } + const empty = await publishScanInternal( + publication.scanDirectory, + { ...OPTIONS, findingIds: [] }, + injected, + ); + expect(empty.counts).toEqual({ findings: 0, created: 0, failed: 0 }); + expect(started).toBe(false); + }); + + test("keys assigned approvals without exposing the assignee or credential", async () => { + const publication = preparedPublication(); + const preview = ( + linearApiKey: string, + assigneeId = "reviewer@example.test", + ) => + publishScanInternal( + publication.scanDirectory, + { + ...OPTIONS, + linearApiKey, + assigneeId, + dryRun: true, + }, + dependencies(publication), + ); + const first = await preview("synthetic-first-key"); + const repeated = await preview("synthetic-first-key"); + const rotated = await preview("synthetic-rotated-key"); + const reassigned = await preview( + "synthetic-first-key", + "another@example.test", + ); + expect(repeated.payloadDigest).toBe(first.payloadDigest); + expect(rotated.payloadDigest).not.toBe(first.payloadDigest); + expect(reassigned.payloadDigest).not.toBe(first.payloadDigest); + expect(JSON.stringify(first)).not.toContain("synthetic-first-key"); + expect(JSON.stringify(first)).not.toContain("reviewer@example.test"); + + const unassigned = (linearApiKey: string) => + publishScanInternal( + publication.scanDirectory, + { ...OPTIONS, linearApiKey, dryRun: true }, + dependencies(publication), + ); + expect((await unassigned("synthetic-first-key")).payloadDigest).toBe( + (await unassigned("synthetic-rotated-key")).payloadDigest, + ); + + const approved = { + ...OPTIONS, + linearApiKey: "synthetic-first-key", + assigneeId: "reviewer@example.test", + expectedDigest: first.payloadDigest!, + }; + expect( + ( + await publishScanInternal( + publication.scanDirectory, + { ...approved, dryRun: true }, + dependencies(publication), + ) + ).payloadDigest, + ).toBe(first.payloadDigest); + let started = false; + await expect( + publishScanInternal( + publication.scanDirectory, + { ...approved, linearApiKey: "synthetic-rotated-key" }, + dependencies( + publication, + {}, + { + preparePublicationStore: async () => { + started = true; + }, + linearClient: linearApiClient(publication, { + configured: () => { + started = true; + }, + }), + }, + ), + ), + ).rejects.toThrow("does not match the expected digest"); + expect(started).toBe(false); + }); + test("rejects pre-aborted publication before preparing scans or touching local state", async () => { const publication = preparedPublication(); const controller = new AbortController(); @@ -742,6 +994,7 @@ describe("connected Linear publication", () => { scanId: "scan-example", uploadId: "scan-example", destination: publication.destination, + payloadDigest: expect.stringMatching(/^[a-f0-9]{64}$/u), created: [ { findingId: "finding-1", @@ -1827,6 +2080,7 @@ describe("connected Linear publication", () => { scanId: "scan-example", uploadId: "scan-example", destination: publication.destination, + payloadDigest: expect.stringMatching(/^[a-f0-9]{64}$/u), created: [], failed: [], counts: { findings: 2, created: 0, failed: 0 },