diff --git a/docs/bootstrap/policy-exceptions.md b/docs/bootstrap/policy-exceptions.md index 8aca500..9968ae8 100644 --- a/docs/bootstrap/policy-exceptions.md +++ b/docs/bootstrap/policy-exceptions.md @@ -24,16 +24,27 @@ exceptions: ## Material-action notifications and hard stops Configure the second required notification destination by naming the -environment variable that will contain its webhook URL. Store only the variable +environment variable that contains its webhook URL. Store only the variable name in the manifest; the URL and any secret-bearing routing token remain in -the execution environment. Webhook transmission remains disabled and fails -closed until the secure transport follow-up is merged. +the execution environment. The executor must also set the fixed +`BOOTSTRAP_NOTIFICATION_WEBHOOK_ALLOWED_HOSTS` variable to a comma-separated +allowlist of exact webhook hostnames. The manifest cannot select or replace +this allowlist. ```yaml notifications: webhookUrlEnv: BOOTSTRAP_NOTIFICATION_WEBHOOK_URL ``` +```sh +export BOOTSTRAP_NOTIFICATION_WEBHOOK_ALLOWED_HOSTS=hooks.example.com +``` + +Delivery accepts only HTTPS on port 443, resolves every allowlisted hostname +before connecting, and rejects loopback, link-local, private, multicast, and +reserved addresses. The built-in transport pins a validated public address for +the TLS connection so a second DNS lookup cannot rebind the destination. + Describe one material action in a JSON file. The governing target accepts a local issue or pull request such as `#55`, an `owner/repo#55` shorthand, or a full GitHub issue or pull-request URL. @@ -64,9 +75,8 @@ bootstrap notifications exceptions --manifest project.bootstrap.yaml --deliver All notification commands support `--json` and return stable `PRS-NOTIFY-001` and `PRS-HARDSTOP-001` results. Delivery writes the redacted notification to the -governing GitHub issue or pull request. The configured webhook destination -remains blocking until the secure transport follow-up is merged, so ordinary -material actions cannot continue on partial delivery. +governing GitHub issue or pull request and to the configured HTTPS webhook. A +failed destination blocks continuation. For a defined hard stop, add its category plus explicit human approval evidence. Planning and delivery remain blocking until both fields are present: diff --git a/src/notifications.ts b/src/notifications.ts index 1904f75..1a72ba2 100644 --- a/src/notifications.ts +++ b/src/notifications.ts @@ -1,4 +1,7 @@ import { createHash } from "node:crypto"; +import { lookup } from "node:dns/promises"; +import { request as httpsRequest } from "node:https"; +import { BlockList, isIP, type LookupFunction } from "node:net"; import { z } from "zod"; @@ -153,8 +156,23 @@ interface GitHubCollaboratorPermission { const maintainerRoles = new Set(["admin", "maintain"]); +export interface WebhookResult { + ok: boolean; + status: number; +} + +export type WebhookSender = (url: string, payload: MaterialActionPlan["webhookPayload"]) => Promise; +export interface ResolvedWebhookAddress { + address: string; + family: 4 | 6; +} +export type WebhookResolver = (hostname: string) => Promise; + export interface DeliveryOptions { githubClient?: GitHubClient; + environment?: NodeJS.ProcessEnv; + webhookSender?: WebhookSender; + webhookResolver?: WebhookResolver; } export interface ExceptionDeliveryOptions extends DeliveryOptions { @@ -455,6 +473,197 @@ export function planMaterialAction(manifest: BootstrapManifest, input: unknown): }); } +const WEBHOOK_ALLOWED_HOSTS_ENV = "BOOTSTRAP_NOTIFICATION_WEBHOOK_ALLOWED_HOSTS"; +const reservedWebhookIpv4Addresses = new BlockList(); +const reservedWebhookIpv6Addresses = new BlockList(); +const publicWebhookIpv6Addresses = new BlockList(); +publicWebhookIpv6Addresses.addSubnet("2000::", 3, "ipv6"); + +for (const [network, prefix] of [ + ["0.0.0.0", 8], + ["10.0.0.0", 8], + ["100.64.0.0", 10], + ["127.0.0.0", 8], + ["169.254.0.0", 16], + ["172.16.0.0", 12], + ["192.0.0.0", 24], + ["192.0.2.0", 24], + ["192.88.99.0", 24], + ["192.168.0.0", 16], + ["198.18.0.0", 15], + ["198.51.100.0", 24], + ["203.0.113.0", 24], + ["224.0.0.0", 4], + ["240.0.0.0", 4] +] as const) { + reservedWebhookIpv4Addresses.addSubnet(network, prefix, "ipv4"); +} + +for (const [network, prefix] of [ + ["::", 128], + ["::1", 128], + ["::ffff:0:0", 96], + ["64:ff9b::", 96], + ["64:ff9b:1::", 48], + ["100::", 64], + ["2001::", 23], + ["2001:db8::", 32], + ["2002::", 16], + ["3fff::", 20], + ["fc00::", 7], + ["fe80::", 10], + ["ff00::", 8] +] as const) { + reservedWebhookIpv6Addresses.addSubnet(network, prefix, "ipv6"); +} + +function normalizeWebhookHostname(hostname: string): string { + const withoutBrackets = hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname; + return withoutBrackets.replace(/\.$/, "").toLowerCase(); +} + +function approvedWebhookHosts(environment: NodeJS.ProcessEnv): Set { + return new Set( + (environment[WEBHOOK_ALLOWED_HOSTS_ENV] ?? "") + .split(",") + .map((hostname) => normalizeWebhookHostname(hostname.trim())) + .filter(Boolean) + ); +} + +function isPublicWebhookAddress(address: string): address is string { + const family = isIP(address); + if (family === 4) return !reservedWebhookIpv4Addresses.check(address, "ipv4"); + if (family === 6) { + return publicWebhookIpv6Addresses.check(address, "ipv6") && !reservedWebhookIpv6Addresses.check(address, "ipv6"); + } + return false; +} + +const defaultWebhookResolver: WebhookResolver = async (hostname) => { + const family = isIP(hostname); + if (family === 4 || family === 6) return [{ address: hostname, family }]; + const addresses = await lookup(hostname, { all: true, verbatim: true }); + return addresses.map(({ address, family: resolvedFamily }) => ({ + address, + family: resolvedFamily === 6 ? 6 : 4 + })); +}; + +async function validateWebhookDestination( + webhookUrl: string, + environment: NodeJS.ProcessEnv, + resolver: WebhookResolver, + deadline: number +): Promise<{ url: URL; addresses: ResolvedWebhookAddress[] }> { + const url = new URL(webhookUrl); + if (url.protocol !== "https:") throw new Error("Webhook must use HTTPS."); + if (url.username || url.password) throw new Error("Webhook URLs must not contain credentials."); + if (url.port && url.port !== "443") throw new Error("Webhook delivery is restricted to HTTPS port 443."); + + const hostname = normalizeWebhookHostname(url.hostname); + if (!approvedWebhookHosts(environment).has(hostname)) { + throw new Error(`Webhook hostname is not approved by ${WEBHOOK_ALLOWED_HOSTS_ENV}.`); + } + + const addresses = await withDeadline(resolver(hostname), deadline, "Webhook hostname resolution timed out."); + if (addresses.length === 0) throw new Error("Webhook hostname did not resolve."); + const validated: ResolvedWebhookAddress[] = addresses.map(({ address }) => { + const family = isIP(address); + if ((family !== 4 && family !== 6) || !isPublicWebhookAddress(address)) { + throw new Error("Webhook hostname resolved to a non-public address."); + } + return { address, family: family as 4 | 6 }; + }); + return { url, addresses: validated }; +} + +async function withDeadline(operation: Promise, deadline: number, message: string): Promise { + const remainingMilliseconds = deadline - Date.now(); + if (remainingMilliseconds <= 0) throw new Error(message); + + let timer: NodeJS.Timeout | undefined; + try { + return await Promise.race([ + operation, + new Promise((_resolve, reject) => { + timer = setTimeout(() => reject(new Error(message)), remainingMilliseconds); + }) + ]); + } finally { + if (timer) clearTimeout(timer); + } +} + +function pinnedLookup(address: ResolvedWebhookAddress): LookupFunction { + return ((_hostname, options, callback) => { + if (typeof options === "object" && options.all) { + callback(null, [address]); + return; + } + callback(null, address.address, address.family); + }) as LookupFunction; +} + +function sendWebhookToAddress( + url: URL, + body: string, + address: ResolvedWebhookAddress, + timeoutMilliseconds: number +): Promise { + return new Promise((resolve, reject) => { + const request = httpsRequest( + url, + { + method: "POST", + headers: { + "content-type": "application/json", + "content-length": Buffer.byteLength(body) + }, + agent: false, + lookup: pinnedLookup(address), + signal: AbortSignal.timeout(timeoutMilliseconds) + }, + (response) => { + response.once("error", reject); + response.resume(); + response.once("end", () => + resolve({ + ok: response.statusCode !== undefined && response.statusCode >= 200 && response.statusCode < 300, + status: response.statusCode ?? 0 + }) + ); + } + ); + request.once("error", reject); + request.end(body); + }); +} + +async function defaultWebhookSender( + url: URL, + payload: MaterialActionPlan["webhookPayload"], + addresses: ResolvedWebhookAddress[], + deadline: number +): Promise { + const body = JSON.stringify(payload); + let lastError: unknown; + + for (const [index, address] of addresses.entries()) { + const remainingMilliseconds = deadline - Date.now(); + if (remainingMilliseconds <= 0) break; + const remainingAddresses = addresses.length - index; + const attemptMilliseconds = Math.max(1, Math.floor(remainingMilliseconds / remainingAddresses)); + try { + return await sendWebhookToAddress(url, body, address, attemptMilliseconds); + } catch (error) { + lastError = error; + } + } + + throw lastError instanceof Error ? lastError : new Error("Webhook delivery exhausted all validated addresses."); +} + export async function deliverMaterialAction( manifest: BootstrapManifest, input: unknown, @@ -464,6 +673,8 @@ export async function deliverMaterialAction( const plan = planMaterialAction(manifest, input); const githubTarget = parseGitHubTarget(plan.governingTarget, manifest); const githubClient = options.githubClient ?? new GitHubClient(); + const environment = options.environment ?? process.env; + const webhookResolver = options.webhookResolver ?? defaultWebhookResolver; const destinations: MaterialActionPlan["notification"]["destinations"] = []; const hardStop = await verifiedHardStopResult(action, manifest, githubTarget, githubClient); const commentBody = materialActionCommentBody(action, buildPublicPayload(action), hardStop, plan.approvalDigest); @@ -495,13 +706,46 @@ export async function deliverMaterialAction( } } - destinations.push({ - destination: "configured-webhook", - status: "failed", - detail: githubTarget - ? "Webhook delivery is disabled until the secure transport follow-up is merged." - : "Webhook delivery skipped because the governing target is invalid." - }); + const webhookEnvironmentName = manifest.notifications?.webhookUrlEnv; + const webhookUrl = webhookEnvironmentName ? environment[webhookEnvironmentName] : undefined; + if (!githubTarget) { + destinations.push({ + destination: "configured-webhook", + status: "failed", + detail: "Webhook delivery skipped because the governing target is invalid." + }); + } else if (!webhookEnvironmentName || !webhookUrl) { + destinations.push({ + destination: "configured-webhook", + status: "failed", + detail: webhookEnvironmentName + ? `Environment variable ${webhookEnvironmentName} is not set.` + : "No webhook environment-variable reference is configured." + }); + } else { + try { + const deadline = Date.now() + 10_000; + const destination = await validateWebhookDestination(webhookUrl, environment, webhookResolver, deadline); + const response = options.webhookSender + ? await withDeadline( + options.webhookSender(webhookUrl, plan.webhookPayload), + deadline, + "Webhook delivery timed out." + ) + : await defaultWebhookSender(destination.url, plan.webhookPayload, destination.addresses, deadline); + destinations.push({ + destination: "configured-webhook", + status: response.ok ? "delivered" : "failed", + detail: response.ok ? `Webhook returned HTTP ${response.status}.` : `Webhook rejected delivery with HTTP ${response.status}.` + }); + } catch { + destinations.push({ + destination: "configured-webhook", + status: "failed", + detail: "Webhook delivery failed." + }); + } + } const delivered = destinations.every((destination) => destination.status === "delivered"); const notification = notificationResultSchema.parse({ diff --git a/tests/notifications.test.ts b/tests/notifications.test.ts index 2cf6666..5da9a6d 100644 --- a/tests/notifications.test.ts +++ b/tests/notifications.test.ts @@ -8,11 +8,22 @@ import { exceptionNotificationReportSchema, materialActionPlanSchema, planExceptionNotifications, - planMaterialAction + planMaterialAction, + type WebhookResolver, + type WebhookSender } from "../src/notifications.js"; import type { CommandRunner } from "../src/lib/process.js"; const githubPat = ["github", "pat"].join("_") + "_abcdefghijklmnopqrstuvwxyz123456"; +const publicWebhookResolver: WebhookResolver = async () => [{ address: "8.8.8.8", family: 4 }]; + +function webhookEnvironment(url = "https://notifications.example.test/hooks/material"): NodeJS.ProcessEnv { + const hostname = new URL(url).hostname.replace(/^\[|\]$/g, "").replace(/\.$/, "").toLowerCase(); + return { + BOOTSTRAP_NOTIFICATION_WEBHOOK_URL: url, + BOOTSTRAP_NOTIFICATION_WEBHOOK_ALLOWED_HOSTS: hostname + }; +} function manifest(withNotifications = true) { return normalizeManifest({ @@ -65,7 +76,7 @@ describe("material-action notifications", () => { expect(report.exitCode).toBe(1); }); - it("delivers to GitHub but fails closed while secure webhook transport is deferred", async () => { + it("delivers to GitHub and the configured webhook before allowing work to continue", async () => { const approvalEvidence = "https://github.com/ACME/Notifications/issues/55#issuecomment-1"; const actionInput = action({ action: "license-change", @@ -87,18 +98,26 @@ describe("material-action notifications", () => { stderr: "", exitCode: 0 })); - const report = await deliverMaterialAction(manifest(), actionInput, { - githubClient: new GitHubClient({ runner }) - }); + const webhookSender = vi.fn(async () => ({ ok: true, status: 204 })); + const report = await deliverMaterialAction( + manifest(), + actionInput, + { + githubClient: new GitHubClient({ runner }), + environment: webhookEnvironment(), + webhookSender, + webhookResolver: publicWebhookResolver + } + ); expect(planned.hardStop.status).toBe("verification-required"); expect(planned.approvalDigest).toMatch(/^[0-9a-f]{64}$/); expect(planned.continueAfterNotification).toBe(false); expect(planned.exitCode).toBe(1); - expect(report.notification.status).toBe("blocking"); + expect(report.notification.status).toBe("delivered"); expect(report.hardStop.status).toBe("approved"); - expect(report.continueAfterNotification).toBe(false); - expect(report.exitCode).toBe(1); + expect(report.continueAfterNotification).toBe(true); + expect(report.exitCode).toBe(0); expect(report.commentBody).toContain("- Approval evidence: "); expect(report.commentBody).toContain("(verified)"); expect(report.commentBody).toContain("- Decision: continue after required notifications are delivered"); @@ -107,12 +126,9 @@ describe("material-action notifications", () => { expect.arrayContaining(["/repos/acme/notifications/issues/55/comments"]), expect.objectContaining({ input: expect.stringContaining("PRS-NOTIFY-001") }) ); - expect(report.notification.destinations).toContainEqual( - expect.objectContaining({ - destination: "configured-webhook", - status: "failed", - detail: expect.stringContaining("secure transport follow-up") - }) + expect(webhookSender).toHaveBeenCalledWith( + "https://notifications.example.test/hooks/material", + expect.objectContaining({ ruleId: "PRS-NOTIFY-001", actionId: "action-55", hardStopCategory: "license-change" }) ); }); @@ -134,11 +150,18 @@ describe("material-action notifications", () => { stderr: "", exitCode: 0 }); - const report = await deliverMaterialAction(manifest(), actionInput, { - githubClient: new GitHubClient({ runner }) - }); + const report = await deliverMaterialAction( + manifest(), + actionInput, + { + githubClient: new GitHubClient({ runner }), + environment: webhookEnvironment(), + webhookSender: async () => ({ ok: true, status: 204 }), + webhookResolver: publicWebhookResolver + } + ); - expect(report.notification.status).toBe("blocking"); + expect(report.notification.status).toBe("delivered"); expect(report.hardStop).toMatchObject({ status: "blocking", category: "license-change" }); expect(report.continueAfterNotification).toBe(false); expect(report.exitCode).toBe(1); @@ -172,7 +195,10 @@ describe("material-action notifications", () => { }); const report = await deliverMaterialAction(manifest(), changedInput, { - githubClient: new GitHubClient({ runner }) + githubClient: new GitHubClient({ runner }), + environment: webhookEnvironment(), + webhookSender: async () => ({ ok: true, status: 204 }), + webhookResolver: publicWebhookResolver }); expect(planMaterialAction(manifest(), changedInput).approvalDigest).not.toBe(approvedPlan.approvalDigest); @@ -214,9 +240,12 @@ describe("material-action notifications", () => { expect(report.commentBody).toContain("- Decision: continue after required notifications are delivered"); }); - it("does not attempt webhook delivery for invalid governing targets", async () => { + it("does not send invalid governing targets to the configured webhook", async () => { + const webhookSender = vi.fn(async () => ({ ok: true, status: 204 })); const report = await deliverMaterialAction(manifest(), action({ governingTarget: "not-a-github-target" }), { - githubClient: new GitHubClient({ runner: async () => ({ stdout: "{}", stderr: "", exitCode: 0 }) }) + githubClient: new GitHubClient({ runner: async () => ({ stdout: "{}", stderr: "", exitCode: 0 }) }), + environment: webhookEnvironment(), + webhookSender }); expect(report.governingTarget).toBe("[INVALID:GOVERNING-TARGET]"); @@ -224,6 +253,7 @@ describe("material-action notifications", () => { expect(report.notification.destinations).toContainEqual( expect.objectContaining({ destination: "configured-webhook", status: "failed", detail: expect.stringContaining("skipped") }) ); + expect(webhookSender).not.toHaveBeenCalled(); expect(JSON.stringify(report)).not.toContain("not-a-github-target"); for (const governingTarget of ["acme/repo?x#55", "#9007199254740993"]) { const planned = planMaterialAction(manifest(), action({ governingTarget })); @@ -234,20 +264,128 @@ describe("material-action notifications", () => { } }); - it("fails closed when the configured mechanism is absent or transport is deferred", async () => { + it("rejects allowlisted literal loopback, link-local, and private webhook addresses", async () => { + const runner: CommandRunner = async () => ({ stdout: "{}", stderr: "", exitCode: 0 }); + for (const url of [ + "https://127.0.0.1/hooks/material", + "https://169.254.169.254/hooks/material", + "https://10.0.0.7/hooks/material", + "https://[::1]/hooks/material", + "https://[::ffff:127.0.0.1]/hooks/material", + "https://[fe80::1]/hooks/material" + ]) { + const webhookSender = vi.fn(async () => ({ ok: true, status: 204 })); + const report = await deliverMaterialAction(manifest(), action(), { + githubClient: new GitHubClient({ runner }), + environment: webhookEnvironment(url), + webhookSender + }); + + expect(report.notification).toMatchObject({ status: "blocking" }); + expect(report.notification.destinations).toContainEqual( + expect.objectContaining({ destination: "configured-webhook", status: "failed" }) + ); + expect(webhookSender).not.toHaveBeenCalled(); + expect(JSON.stringify(report)).not.toContain(new URL(url).hostname); + } + }); + + it("rejects an allowlisted hostname when any DNS answer is private", async () => { + const runner: CommandRunner = async () => ({ stdout: "{}", stderr: "", exitCode: 0 }); + const webhookSender = vi.fn(async () => ({ ok: true, status: 204 })); + const webhookResolver = vi.fn(async () => [ + { address: "8.8.8.8", family: 4 }, + { address: "10.0.0.7", family: 4 } + ]); + const report = await deliverMaterialAction(manifest(), action(), { + githubClient: new GitHubClient({ runner }), + environment: webhookEnvironment("https://internal.example.test/hooks/material"), + webhookSender, + webhookResolver + }); + + expect(webhookResolver).toHaveBeenCalledWith("internal.example.test"); + expect(report.notification).toMatchObject({ status: "blocking" }); + expect(webhookSender).not.toHaveBeenCalled(); + expect(JSON.stringify(report)).not.toContain("internal.example.test"); + expect(JSON.stringify(report)).not.toContain("10.0.0.7"); + }); + + it("rejects allowlisted hostnames that resolve to reserved IPv6 documentation ranges", async () => { + const runner: CommandRunner = async () => ({ stdout: "{}", stderr: "", exitCode: 0 }); + for (const address of ["2001:db8::1", "3fff::1"]) { + const webhookSender = vi.fn(async () => ({ ok: true, status: 204 })); + const report = await deliverMaterialAction(manifest(), action(), { + githubClient: new GitHubClient({ runner }), + environment: webhookEnvironment("https://reserved.example.test/hooks/material"), + webhookSender, + webhookResolver: async () => [{ address, family: 6 }] + }); + + expect(report.notification).toMatchObject({ status: "blocking" }); + expect(webhookSender).not.toHaveBeenCalled(); + expect(JSON.stringify(report)).not.toContain(address); + } + }); + + it("rejects a public webhook hostname that is absent from the executor allowlist", async () => { + const runner: CommandRunner = async () => ({ stdout: "{}", stderr: "", exitCode: 0 }); + const webhookSender = vi.fn(async () => ({ ok: true, status: 204 })); + const webhookResolver = vi.fn(publicWebhookResolver); + const report = await deliverMaterialAction(manifest(), action(), { + githubClient: new GitHubClient({ runner }), + environment: { + BOOTSTRAP_NOTIFICATION_WEBHOOK_URL: "https://notifications.example.test/hooks/material", + BOOTSTRAP_NOTIFICATION_WEBHOOK_ALLOWED_HOSTS: "approved.example.test" + }, + webhookSender, + webhookResolver + }); + + expect(report.notification).toMatchObject({ status: "blocking" }); + expect(webhookResolver).not.toHaveBeenCalled(); + expect(webhookSender).not.toHaveBeenCalled(); + }); + + it("fails closed when allowlisted-host DNS resolution exceeds the delivery deadline", async () => { + vi.useFakeTimers(); + const runner: CommandRunner = async () => ({ stdout: "{}", stderr: "", exitCode: 0 }); + const webhookSender = vi.fn(async () => ({ ok: true, status: 204 })); + const delivery = deliverMaterialAction(manifest(), action(), { + githubClient: new GitHubClient({ runner }), + environment: webhookEnvironment(), + webhookSender, + webhookResolver: async () => new Promise(() => undefined) + }); + + try { + await vi.advanceTimersByTimeAsync(10_000); + const report = await delivery; + expect(report.notification).toMatchObject({ status: "blocking" }); + expect(webhookSender).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + + it("fails closed when the configured mechanism is absent or rejects delivery", async () => { const runner: CommandRunner = async () => ({ stdout: "{}", stderr: "", exitCode: 0 }); + const webhookSender: WebhookSender = async () => ({ ok: false, status: 503 }); const missingConfiguration = planMaterialAction(manifest(false), action()); - const deferred = await deliverMaterialAction(manifest(), action(), { - githubClient: new GitHubClient({ runner }) + const rejected = await deliverMaterialAction(manifest(), action(), { + githubClient: new GitHubClient({ runner }), + environment: webhookEnvironment(), + webhookSender, + webhookResolver: publicWebhookResolver }); expect(missingConfiguration.notification.status).toBe("blocking"); expect(missingConfiguration.continueAfterNotification).toBe(false); expect(missingConfiguration.exitCode).toBe(1); - expect(deferred.notification).toMatchObject({ status: "blocking", ruleId: "PRS-NOTIFY-001" }); - expect(deferred.continueAfterNotification).toBe(false); - expect(deferred.exitCode).toBe(1); - expect(JSON.stringify(deferred)).not.toContain("notifications.example.test"); + expect(rejected.notification).toMatchObject({ status: "blocking", ruleId: "PRS-NOTIFY-001" }); + expect(rejected.continueAfterNotification).toBe(false); + expect(rejected.exitCode).toBe(1); + expect(JSON.stringify(rejected)).not.toContain("notifications.example.test"); }); it("serializes only the webhook environment-variable reference", () => { @@ -276,10 +414,14 @@ describe("material-action notifications", () => { }); const now = new Date("2026-07-16T12:00:00.000Z"); const runner = vi.fn(async () => ({ stdout: "{}", stderr: "", exitCode: 0 })); + const webhookSender = vi.fn(async () => ({ ok: true, status: 204 })); const planned = planExceptionNotifications(expiringManifest, now); const delivered = await deliverExceptionNotifications(expiringManifest, { now, - githubClient: new GitHubClient({ runner }) + githubClient: new GitHubClient({ runner }), + environment: webhookEnvironment(), + webhookSender, + webhookResolver: publicWebhookResolver }); expect(exceptionNotificationReportSchema.parse(planned)).toEqual(planned); @@ -290,8 +432,8 @@ describe("material-action notifications", () => { governingTarget: "acme/notifications#55", notification: { status: "ready" } }); - expect(delivered).toMatchObject({ mode: "deliver", blockingExceptions: false, exitCode: 1 }); - expect(delivered.notifications[0]?.notification.status).toBe("blocking"); + expect(delivered).toMatchObject({ mode: "deliver", blockingExceptions: false, exitCode: 0 }); + expect(delivered.notifications[0]?.notification.status).toBe("delivered"); }); it("keeps blocking exception validation fail-closed when no expiry notification is due", () => {