@intentius/chant 0.122.0 → 0.123.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (156) hide show
  1. package/dist/cli/commands/lint.d.ts.map +1 -1
  2. package/dist/cli/handlers/gate-resume.d.ts +1 -1
  3. package/dist/cli/handlers/gate-resume.d.ts.map +1 -1
  4. package/dist/cli/handlers/operator.d.ts +1 -1
  5. package/dist/cli/handlers/operator.d.ts.map +1 -1
  6. package/dist/cli/handlers/override-approve.d.ts +17 -0
  7. package/dist/cli/handlers/override-approve.d.ts.map +1 -0
  8. package/dist/cli/handlers/run-wave.d.ts.map +1 -1
  9. package/dist/cli/main.d.ts.map +1 -1
  10. package/dist/cli/registry.d.ts +4 -0
  11. package/dist/cli/registry.d.ts.map +1 -1
  12. package/dist/gate-decision.d.ts +157 -0
  13. package/dist/gate-decision.d.ts.map +1 -0
  14. package/dist/lifecycle/gate-applied.d.ts +75 -0
  15. package/dist/lifecycle/gate-applied.d.ts.map +1 -0
  16. package/dist/lifecycle/gate-audit.d.ts +145 -0
  17. package/dist/lifecycle/gate-audit.d.ts.map +1 -0
  18. package/dist/lifecycle/gate-ledger-select.d.ts +54 -0
  19. package/dist/lifecycle/gate-ledger-select.d.ts.map +1 -0
  20. package/dist/lifecycle/gate-ledger.d.ts +1 -45
  21. package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
  22. package/dist/lifecycle/override-ledger.d.ts +34 -0
  23. package/dist/lifecycle/override-ledger.d.ts.map +1 -0
  24. package/dist/lifecycle/rollback.d.ts.map +1 -1
  25. package/dist/lifecycle/work-item-id.d.ts +22 -0
  26. package/dist/lifecycle/work-item-id.d.ts.map +1 -0
  27. package/dist/lifecycle/work-lease.d.ts +2 -2
  28. package/dist/lifecycle/work-lease.d.ts.map +1 -1
  29. package/dist/lint/post-synth.d.ts +20 -1
  30. package/dist/lint/post-synth.d.ts.map +1 -1
  31. package/dist/notify-verify.d.ts +62 -0
  32. package/dist/notify-verify.d.ts.map +1 -0
  33. package/dist/notify.d.ts +202 -0
  34. package/dist/notify.d.ts.map +1 -0
  35. package/dist/op/activities/activity-contracts.d.ts +4 -4
  36. package/dist/op/activities/gh-host.d.ts +37 -0
  37. package/dist/op/activities/gh-host.d.ts.map +1 -0
  38. package/dist/op/activities/lexicon-upgrade.d.ts +11 -0
  39. package/dist/op/activities/lexicon-upgrade.d.ts.map +1 -1
  40. package/dist/op/activities/propose-upgrade.d.ts.map +1 -1
  41. package/dist/op/activities/reconcile.d.ts +74 -76
  42. package/dist/op/activities/reconcile.d.ts.map +1 -1
  43. package/dist/op/gate-approval.d.ts +17 -1
  44. package/dist/op/gate-approval.d.ts.map +1 -1
  45. package/dist/op/gate-name.d.ts +8 -1
  46. package/dist/op/gate-name.d.ts.map +1 -1
  47. package/dist/op/gate-resume.d.ts +4 -2
  48. package/dist/op/gate-resume.d.ts.map +1 -1
  49. package/dist/op/gate-review.d.ts +2 -1
  50. package/dist/op/gate-review.d.ts.map +1 -1
  51. package/dist/op/gate.d.ts +43 -6
  52. package/dist/op/gate.d.ts.map +1 -1
  53. package/dist/op/index.d.ts +8 -6
  54. package/dist/op/index.d.ts.map +1 -1
  55. package/dist/op/op-waves-notify.d.ts +31 -0
  56. package/dist/op/op-waves-notify.d.ts.map +1 -0
  57. package/dist/op/op-waves-run.d.ts +91 -16
  58. package/dist/op/op-waves-run.d.ts.map +1 -1
  59. package/dist/op/op-waves.d.ts +99 -2
  60. package/dist/op/op-waves.d.ts.map +1 -1
  61. package/dist/op/operator.d.ts +15 -0
  62. package/dist/op/operator.d.ts.map +1 -1
  63. package/dist/op/work-lease-decl.d.ts.map +1 -1
  64. package/dist/op/work-lease-run.d.ts.map +1 -1
  65. package/dist/override-gate.d.ts +224 -0
  66. package/dist/override-gate.d.ts.map +1 -0
  67. package/dist/pr-forge.d.ts +198 -19
  68. package/dist/pr-forge.d.ts.map +1 -1
  69. package/dist/workspace/status-gates.d.ts +1 -0
  70. package/dist/workspace/status-gates.d.ts.map +1 -1
  71. package/dist/workspace/trust/gate-seal.d.ts +96 -0
  72. package/dist/workspace/trust/gate-seal.d.ts.map +1 -0
  73. package/dist/workspace/trust/policy.d.ts +2 -41
  74. package/dist/workspace/trust/policy.d.ts.map +1 -1
  75. package/dist/workspace/trust/seal.d.ts +2 -27
  76. package/dist/workspace/trust/seal.d.ts.map +1 -1
  77. package/dist/workspace/trust/trust-files.d.ts +57 -0
  78. package/dist/workspace/trust/trust-files.d.ts.map +1 -0
  79. package/dist/workspace/work-cli.d.ts.map +1 -1
  80. package/package.json +2 -1
  81. package/src/cli/commands/build.ts +1 -1
  82. package/src/cli/commands/lint.ts +5 -2
  83. package/src/cli/handlers/__snapshots__/build-member.test.ts.snap +2 -2
  84. package/src/cli/handlers/gate-resume.ts +3 -2
  85. package/src/cli/handlers/operator.test.ts +12 -1
  86. package/src/cli/handlers/operator.ts +25 -13
  87. package/src/cli/handlers/override-approve.test.ts +87 -0
  88. package/src/cli/handlers/override-approve.ts +103 -0
  89. package/src/cli/handlers/run-wave.ts +10 -2
  90. package/src/cli/main.test.ts +8 -0
  91. package/src/cli/main.ts +15 -0
  92. package/src/cli/registry.ts +4 -0
  93. package/src/gate-decision-bundle.test.ts +56 -0
  94. package/src/gate-decision.test.ts +237 -0
  95. package/src/gate-decision.ts +321 -0
  96. package/src/lifecycle/gate-applied.ts +115 -0
  97. package/src/lifecycle/gate-audit-bundle.test.ts +47 -0
  98. package/src/lifecycle/gate-audit.test.ts +134 -0
  99. package/src/lifecycle/gate-audit.ts +438 -0
  100. package/src/lifecycle/gate-ledger-select.ts +113 -0
  101. package/src/lifecycle/gate-ledger.test.ts +22 -0
  102. package/src/lifecycle/gate-ledger.ts +9 -104
  103. package/src/lifecycle/override-ledger.test.ts +63 -0
  104. package/src/lifecycle/override-ledger.ts +69 -0
  105. package/src/lifecycle/rollback.ts +5 -1
  106. package/src/lifecycle/work-item-id.test.ts +71 -0
  107. package/src/lifecycle/work-item-id.ts +35 -0
  108. package/src/lifecycle/work-lease.ts +4 -5
  109. package/src/lint/post-synth.test.ts +29 -0
  110. package/src/lint/post-synth.ts +21 -2
  111. package/src/notify-bundle.test.ts +50 -0
  112. package/src/notify-verify.test.ts +59 -0
  113. package/src/notify-verify.ts +93 -0
  114. package/src/notify.schema.json +72 -0
  115. package/src/notify.test.ts +279 -0
  116. package/src/notify.ts +398 -0
  117. package/src/op/activities/gh-host.test.ts +130 -0
  118. package/src/op/activities/gh-host.ts +79 -0
  119. package/src/op/activities/lexicon-upgrade.ts +22 -1
  120. package/src/op/activities/propose-upgrade.ts +5 -1
  121. package/src/op/activities/reconcile.test.ts +284 -280
  122. package/src/op/activities/reconcile.ts +128 -131
  123. package/src/op/gate-approval.test.ts +175 -7
  124. package/src/op/gate-approval.ts +27 -1
  125. package/src/op/gate-name.ts +10 -2
  126. package/src/op/gate-resume.ts +6 -3
  127. package/src/op/gate-review.ts +4 -7
  128. package/src/op/gate.ts +117 -16
  129. package/src/op/index.ts +20 -5
  130. package/src/op/op-waves-notify.test.ts +127 -0
  131. package/src/op/op-waves-notify.ts +101 -0
  132. package/src/op/op-waves-run.ts +527 -104
  133. package/src/op/op-waves.test.ts +383 -3
  134. package/src/op/op-waves.ts +187 -6
  135. package/src/op/operator.test.ts +29 -0
  136. package/src/op/operator.ts +27 -2
  137. package/src/op/run-context.test.ts +2 -2
  138. package/src/op/work-lease-decl.ts +4 -4
  139. package/src/op/work-lease-run.test.ts +5 -0
  140. package/src/op/work-lease-run.ts +4 -3
  141. package/src/override-gate-bundle.test.ts +47 -0
  142. package/src/override-gate.test.ts +239 -0
  143. package/src/override-gate.ts +395 -0
  144. package/src/pr-forge-bundle.test.ts +40 -0
  145. package/src/pr-forge.test.ts +303 -0
  146. package/src/pr-forge.ts +502 -47
  147. package/src/workspace/records-write-contract.test.ts +3 -2
  148. package/src/workspace/status-gates.ts +7 -4
  149. package/src/workspace/trust/gate-seal.test.ts +156 -0
  150. package/src/workspace/trust/gate-seal.ts +254 -0
  151. package/src/workspace/trust/policy.ts +10 -165
  152. package/src/workspace/trust/seal.ts +19 -34
  153. package/src/workspace/trust/trust-files.test.ts +50 -0
  154. package/src/workspace/trust/trust-files.ts +245 -0
  155. package/src/workspace/work-cli.ts +5 -3
  156. package/src/workspace/work-history.test.ts +7 -0
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Signing and checking a notify event (#3761). This module needs only
3
+ * `node:crypto`, so a receiver can import it alone and bundle nothing else:
4
+ *
5
+ * ```ts
6
+ * import { verifyWebhook } from "@intentius/chant/notify-verify";
7
+ *
8
+ * const checked = verifyWebhook({ body, headers: req.headers, key: process.env.CHANT_WEBHOOK_KEY });
9
+ * if (!checked.ok) return reply(401, checked.reason);
10
+ * handle(checked.event);
11
+ * ```
12
+ *
13
+ * The signature is HMAC-SHA256, keyed with the shared secret, over
14
+ * `<timestamp>.<body>`: the timestamp header (seconds since the epoch), a
15
+ * dot, and the request body exactly as sent. It travels as
16
+ * `x-chant-signature: sha256=<hex>`. Signing the timestamp with the body lets
17
+ * a receiver refuse a delivery replayed later; {@link verifyWebhook} refuses
18
+ * one more than five minutes from now by default. This is SQL Yodeler's
19
+ * scheme (#3756), with the header prefix a product can keep as its own.
20
+ */
21
+
22
+ import { createHmac, timingSafeEqual } from "node:crypto";
23
+
24
+ /** The header names' default prefix. A product passes its own (`x-yodel`, `x-terragucci`) to keep its receivers working. */
25
+ export const DEFAULT_HEADER_PREFIX = "x-chant";
26
+
27
+ /** The four headers a delivery carries, under `prefix`. */
28
+ export function webhookHeaderNames(prefix: string = DEFAULT_HEADER_PREFIX): { signature: string; timestamp: string; event: string; delivery: string } {
29
+ const p = prefix.toLowerCase();
30
+ return { signature: `${p}-signature`, timestamp: `${p}-timestamp`, event: `${p}-event`, delivery: `${p}-delivery` };
31
+ }
32
+
33
+ /** How far from now a delivery's timestamp {@link verifyWebhook} still accepts, in seconds. */
34
+ export const DEFAULT_TOLERANCE_SECONDS = 300;
35
+
36
+ /** `sha256=<hex>` of `<timestamp>.<body>`, keyed with `key`. */
37
+ export function signWebhook(key: string, timestamp: number | string, body: string): string {
38
+ return `sha256=${createHmac("sha256", key).update(`${String(timestamp)}.${body}`, "utf8").digest("hex")}`;
39
+ }
40
+
41
+ export type WebhookHeaders = Record<string, string | readonly string[] | undefined> | Headers;
42
+
43
+ export interface VerifyWebhookInput {
44
+ /** The request body exactly as received, before any JSON parsing. */
45
+ body: string | Uint8Array;
46
+ /** The request's headers; names match without regard to case. */
47
+ headers: WebhookHeaders;
48
+ /** The shared secret. An empty or missing key never verifies. */
49
+ key: string | undefined;
50
+ /** Now, in seconds since the epoch. Default: the clock. */
51
+ now?: number;
52
+ /** How far from now the timestamp may be, in seconds. Default {@link DEFAULT_TOLERANCE_SECONDS}. */
53
+ toleranceSeconds?: number;
54
+ /** The header names' prefix the sender used. Default {@link DEFAULT_HEADER_PREFIX}. */
55
+ prefix?: string;
56
+ }
57
+
58
+ export type VerifyWebhookResult = { ok: true; event: unknown } | { ok: false; reason: string };
59
+
60
+ function header(headers: WebhookHeaders, name: string): string | undefined {
61
+ if (typeof (headers as Headers).get === "function") return (headers as Headers).get(name) ?? undefined;
62
+ for (const [k, v] of Object.entries(headers as Record<string, string | readonly string[] | undefined>)) {
63
+ if (k.toLowerCase() !== name) continue;
64
+ return Array.isArray(v) ? (v[0] as string | undefined) : (v as string | undefined);
65
+ }
66
+ return undefined;
67
+ }
68
+
69
+ /**
70
+ * Whether a delivery was signed with `key` and is recent: the signature
71
+ * header matches (compared in constant time) and the timestamp is within the
72
+ * tolerance either side of now. On success, the parsed event.
73
+ */
74
+ export function verifyWebhook(input: VerifyWebhookInput): VerifyWebhookResult {
75
+ if (!input.key) return { ok: false, reason: "no key to verify with" };
76
+ const names = webhookHeaderNames(input.prefix);
77
+ const body = typeof input.body === "string" ? input.body : Buffer.from(input.body).toString("utf8");
78
+ const signature = header(input.headers, names.signature);
79
+ const timestamp = header(input.headers, names.timestamp);
80
+ if (!signature) return { ok: false, reason: `no ${names.signature} header` };
81
+ if (!timestamp || !/^\d+$/.test(timestamp)) return { ok: false, reason: `no ${names.timestamp} header, or not a number of seconds` };
82
+ const expected = Buffer.from(signWebhook(input.key, timestamp, body));
83
+ const given = Buffer.from(signature);
84
+ if (given.length !== expected.length || !timingSafeEqual(given, expected)) return { ok: false, reason: "the signature does not match" };
85
+ const now = input.now ?? Math.floor(Date.now() / 1000);
86
+ const tolerance = input.toleranceSeconds ?? DEFAULT_TOLERANCE_SECONDS;
87
+ if (Math.abs(now - Number(timestamp)) > tolerance) return { ok: false, reason: `the timestamp is more than ${String(tolerance)}s from now` };
88
+ try {
89
+ return { ok: true, event: JSON.parse(body) as unknown };
90
+ } catch {
91
+ return { ok: false, reason: "the body is not JSON" };
92
+ }
93
+ }
@@ -0,0 +1,72 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://intentius.io/chant/schemas/notify/v1/notify.schema.json",
4
+ "title": "chant.notify/v1",
5
+ "description": "The event a notify webhook receives (#3761): a wave or environment waits for an approval, a run was refused or failed, or drift was found. Sent by chant's Op waves and by products built on chant (terragucci, SQL Yodeler) through @intentius/chant/notify, signed with HMAC-SHA256 over <timestamp>.<body>. Readers ignore fields they do not know; a field is only ever added within a version.",
6
+ "type": "object",
7
+ "required": ["schema", "id", "event", "at", "source", "project", "summary", "targets"],
8
+ "properties": {
9
+ "schema": { "const": "chant.notify/v1" },
10
+ "id": { "type": "string", "minLength": 1, "description": "The same for every send of one event about one digest (else drift fingerprint, else commit), so a receiver can drop a repeat; random when there is none. The delivery header carries it too." },
11
+ "event": { "enum": ["waiting", "refused", "failed", "drift"] },
12
+ "at": { "type": "string", "description": "When it was sent, ISO 8601 UTC." },
13
+ "source": { "type": "string", "minLength": 1, "description": "The product that sent it: chant, terragucci, sql-yodeler." },
14
+ "project": { "type": "string", "description": "What the run belongs to: the repository, or a name the product gives." },
15
+ "summary": { "type": "string", "description": "One line a person can read." },
16
+ "wave": {
17
+ "type": "object",
18
+ "required": ["number"],
19
+ "properties": {
20
+ "number": { "type": "integer", "minimum": 1 },
21
+ "name": { "type": "string" },
22
+ "gate": { "type": "string", "description": "The gate the wave waits on." }
23
+ }
24
+ },
25
+ "environment": { "type": "string" },
26
+ "targets": { "type": "array", "items": { "type": "string" }, "description": "What the event concerns: a wave's roots or runs, an environment's pending migrations. For refused and failed, the ones refused or failed when the product knows them." },
27
+ "digest": { "type": "string", "description": "The plan digest an approval binds." },
28
+ "approve": {
29
+ "type": "object",
30
+ "description": "On waiting: how a person approves it.",
31
+ "required": ["command"],
32
+ "properties": {
33
+ "command": { "type": "string", "description": "The command to run, as the product prints it." },
34
+ "chant": { "type": "string", "description": "chant's own command for the same approval, when the product's differs." },
35
+ "review": {
36
+ "type": "object",
37
+ "description": "Under pr-review: the pull request whose approving review would approve it.",
38
+ "required": ["pr", "url"],
39
+ "properties": { "pr": { "type": "integer" }, "url": { "type": "string" } }
40
+ }
41
+ }
42
+ },
43
+ "status": { "type": "string", "description": "The product's own word for the outcome." },
44
+ "reason": { "type": "string", "description": "On refused and failed: why." },
45
+ "exitCode": { "type": "integer" },
46
+ "drift": {
47
+ "type": "object",
48
+ "required": ["changed"],
49
+ "properties": {
50
+ "changed": { "type": "array", "items": { "type": "string" } },
51
+ "missing": { "type": "array", "items": { "type": "string" } },
52
+ "failed": { "type": "array", "items": { "type": "string" }, "description": "What could not be checked." },
53
+ "fingerprint": { "type": "string", "description": "The same drift has the same fingerprint, run after run." }
54
+ }
55
+ },
56
+ "run": {
57
+ "type": "object",
58
+ "properties": {
59
+ "url": { "type": "string", "description": "The CI run's page (GitHub, Forgejo) or job's page (GitLab)." },
60
+ "forge": { "enum": ["github", "gitlab", "forgejo"] },
61
+ "repository": { "type": "string" },
62
+ "commit": { "type": "string" },
63
+ "pr": { "type": "integer" }
64
+ }
65
+ },
66
+ "reportUrl": { "type": "string" },
67
+ "detail": { "type": "object", "description": "The product's own record of the outcome, carried whole (terragucci.outcome/v1)." }
68
+ },
69
+ "allOf": [
70
+ { "if": { "required": ["event"], "properties": { "event": { "const": "drift" } } }, "then": { "required": ["drift"], "properties": { "drift": { "type": "object" } } } }
71
+ ]
72
+ }
@@ -0,0 +1,279 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import {
3
+ NOTIFY_SCHEMA,
4
+ NOTIFY_SCHEMA_ID,
5
+ ciRun,
6
+ makeNotifyEvent,
7
+ notifyConfigProblem,
8
+ notifyHeadline,
9
+ notifyVariables,
10
+ sendNotify,
11
+ slackMessage,
12
+ teamsMessage,
13
+ verifyWebhook,
14
+ webhookRequest,
15
+ type NotifyEvent,
16
+ type NotifyFetch,
17
+ } from "./notify";
18
+ import { contract, validSchema } from "./workspace/__fixtures__/contract-repo";
19
+ import schema from "./notify.schema.json";
20
+
21
+ const { expectValid, validate } = contract(schema);
22
+ const NOW = new Date("2026-10-11T12:00:00.000Z");
23
+ const GITHUB = { GITHUB_ACTIONS: "true", GITHUB_SERVER_URL: "https://github.com", GITHUB_REPOSITORY: "acme/db", GITHUB_RUN_ID: "42", GITHUB_SHA: "abc123" };
24
+ const DIGEST = "jcs1-sha256:" + "a".repeat(64);
25
+
26
+ const waiting = (env: NodeJS.ProcessEnv = GITHUB): NotifyEvent =>
27
+ makeNotifyEvent(
28
+ {
29
+ source: "chant",
30
+ event: "waiting",
31
+ summary: "wave 3 (prod) of migrations waits for an approval",
32
+ wave: { number: 3, name: "prod", gate: "migrations-wave-3" },
33
+ targets: ["prod-eu", "prod-us"],
34
+ digest: DIGEST,
35
+ approve: { command: `chant approve migrations migrations-wave-3 --plan ${DIGEST}` },
36
+ },
37
+ { env, now: NOW },
38
+ );
39
+
40
+ const drift = (): NotifyEvent =>
41
+ makeNotifyEvent(
42
+ { source: "sql-yodeler", event: "drift", summary: "prod drifted: orders changed", environment: "prod", drift: { changed: ["orders"], missing: ["audit_log"], fingerprint: "fp1" } },
43
+ { env: {}, now: NOW },
44
+ );
45
+
46
+ /** A fetch that records what it was sent and answers from `answers` in turn (the last one repeats). */
47
+ function recorder(...answers: Array<number | Error>) {
48
+ const calls: Array<{ url: string; headers: Record<string, string>; body: string }> = [];
49
+ const fetch: NotifyFetch = async (url, init) => {
50
+ calls.push({ url, headers: init.headers, body: init.body });
51
+ const a = answers[Math.min(calls.length - 1, answers.length - 1)] ?? 200;
52
+ if (a instanceof Error) throw a;
53
+ return { ok: a >= 200 && a < 300, status: a };
54
+ };
55
+ return { calls, fetch };
56
+ }
57
+
58
+ const quiet = { sleep: async () => {}, log: () => {} };
59
+
60
+ describe("the notify schema", () => {
61
+ test("is a valid draft 2020-12 document whose $id the module names", () => {
62
+ expect(validSchema(schema)).toBe(true);
63
+ expect(schema.$id).toBe(NOTIFY_SCHEMA_ID);
64
+ expect(schema.properties.schema.const).toBe(NOTIFY_SCHEMA);
65
+ });
66
+
67
+ test("every event the module makes validates, and a drift event needs its drift", () => {
68
+ expectValid(waiting());
69
+ expectValid(drift());
70
+ expectValid(makeNotifyEvent({ source: "terragucci", event: "refused", summary: "", reason: "plans moved", targets: ["infra/vpc"], exitCode: 4, detail: { schema: "terragucci.outcome/v1" } }, { env: {}, now: NOW }));
71
+ const d = drift();
72
+ delete (d as Partial<NotifyEvent>).drift;
73
+ expect(validate(d)).toBe(false);
74
+ expect(validate({ ...waiting(), event: "applied" })).toBe(false);
75
+ expect(validate({ ...waiting(), schema: "chant.notify/v2" })).toBe(false);
76
+ });
77
+ });
78
+
79
+ describe("makeNotifyEvent", () => {
80
+ test("adds the schema, the time, the CI run and the project", () => {
81
+ const e = waiting();
82
+ expect(e).toMatchObject({ schema: NOTIFY_SCHEMA, at: NOW.toISOString(), project: "acme/db" });
83
+ expect(e.run).toEqual({ url: "https://github.com/acme/db/actions/runs/42", forge: "github", repository: "acme/db", commit: "abc123" });
84
+ });
85
+
86
+ test("gives one event about one digest the same id on a re-run, and another event another id", () => {
87
+ expect(waiting().id).toBe(waiting().id);
88
+ expect(waiting().id).toMatch(/^[0-9a-f]{64}$/);
89
+ const refused = makeNotifyEvent({ ...waiting(), event: "refused", id: undefined }, { env: GITHUB });
90
+ expect(refused.id).not.toBe(waiting().id);
91
+ // No digest, fingerprint or commit: random.
92
+ const a = makeNotifyEvent({ source: "chant", event: "failed", summary: "x" }, { env: {} });
93
+ const b = makeNotifyEvent({ source: "chant", event: "failed", summary: "x" }, { env: {} });
94
+ expect(a.id).not.toBe(b.id);
95
+ expect(a.project).toBe("this project");
96
+ expect(a.run).toBeUndefined();
97
+ });
98
+
99
+ test("keeps a product's own run fields over the CI's", () => {
100
+ const e = makeNotifyEvent({ source: "terragucci", event: "failed", summary: "", run: { pr: 7, url: "https://ci.example/run/1" } }, { env: GITHUB });
101
+ expect(e.run).toMatchObject({ pr: 7, url: "https://ci.example/run/1", repository: "acme/db" });
102
+ });
103
+ });
104
+
105
+ describe("ciRun", () => {
106
+ test("reads GitLab's job and Forgejo's run", () => {
107
+ expect(ciRun({ GITLAB_CI: "true", CI_JOB_URL: "https://gitlab.example/acme/db/-/jobs/9", CI_PROJECT_PATH: "acme/db", CI_COMMIT_SHA: "def" })).toEqual({
108
+ url: "https://gitlab.example/acme/db/-/jobs/9",
109
+ forge: "gitlab",
110
+ repository: "acme/db",
111
+ commit: "def",
112
+ });
113
+ expect(ciRun({ ...GITHUB, GITHUB_ACTIONS: "true", FORGEJO_ACTIONS: "true" }).forge).toBe("forgejo");
114
+ expect(ciRun({})).toEqual({});
115
+ });
116
+ });
117
+
118
+ describe("the chat messages", () => {
119
+ test("Slack gets the headline, the targets, the digest and the approve command as code, and the run as a link", () => {
120
+ expect(slackMessage(waiting()).text).toBe(
121
+ [
122
+ "*chant: wave 3 (prod) of migrations waits for an approval*",
123
+ "Targets: prod-eu, prod-us",
124
+ `Digest: \`${DIGEST}\``,
125
+ `Approve: \`chant approve migrations migrations-wave-3 --plan ${DIGEST}\``,
126
+ "Run: <https://github.com/acme/db/actions/runs/42>",
127
+ ].join("\n"),
128
+ );
129
+ });
130
+
131
+ test("a pr-review wave links the review first, and the command becomes the other way", () => {
132
+ const e = { ...waiting(), approve: { command: "chant approve x", review: { pr: 12, url: "https://github.com/acme/db/pull/12" } } };
133
+ const text = slackMessage(e).text;
134
+ expect(text).toContain("Review and approve: <https://github.com/acme/db/pull/12|pull request 12>, then run it again");
135
+ expect(text).toContain("Or approve: `chant approve x`");
136
+ const card = JSON.stringify(teamsMessage(e));
137
+ expect(card).toContain('"title":"Review and approve","url":"https://github.com/acme/db/pull/12"');
138
+ });
139
+
140
+ test("drift shows what changed, what is missing and nothing to approve", () => {
141
+ const text = slackMessage(drift()).text;
142
+ expect(text).toBe("*sql-yodeler: prod drifted: orders changed*\nDrifted: orders\nMissing: audit_log");
143
+ });
144
+
145
+ test("Teams gets one Adaptive Card with the facts and a button to the run", () => {
146
+ const m = teamsMessage(waiting()) as { type: string; attachments: Array<{ contentType: string; content: { type: string; body: Array<{ type: string; text?: string; facts?: Array<{ title: string; value: string }> }>; actions?: unknown[] } }> };
147
+ expect(m.type).toBe("message");
148
+ const card = m.attachments[0]!;
149
+ expect(card.contentType).toBe("application/vnd.microsoft.card.adaptive");
150
+ expect(card.content.body[0]!.text).toBe("chant: wave 3 (prod) of migrations waits for an approval");
151
+ expect(card.content.body[1]!.facts!.map((f) => f.title)).toEqual(["Targets", "Digest", "Approve", "Run"]);
152
+ expect(card.content.actions).toEqual([{ type: "Action.OpenUrl", title: "Open the run", url: "https://github.com/acme/db/actions/runs/42" }]);
153
+ });
154
+
155
+ test("a headline with no summary says what happened to what", () => {
156
+ expect(notifyHeadline({ ...waiting(), summary: "" })).toBe("chant: wave 3 (prod) of acme/db waits for an approval");
157
+ expect(notifyHeadline({ ...drift(), summary: "" })).toBe("sql-yodeler: prod in this project drifted");
158
+ });
159
+ });
160
+
161
+ describe("notifyConfigProblem", () => {
162
+ test("accepts variable names and refuses URLs, keys, a webhook without its key and unknown settings", () => {
163
+ expect(notifyConfigProblem({ webhook: "HOOK", key: "HOOK_KEY", slack: "SLACK", teams: "TEAMS", events: ["waiting", "drift"] })).toBeUndefined();
164
+ expect(notifyConfigProblem({ slack: "https://hooks.slack.com/services/T/B/x" })).toMatch(/^notify\.slack: the name of the environment variable/);
165
+ expect(notifyConfigProblem({ webhook: "HOOK" })).toMatch(/^notify\.key: a webhook needs a signing key/);
166
+ expect(notifyConfigProblem({ key: "K" })).toMatch(/^notify\.webhook:/);
167
+ expect(notifyConfigProblem({})).toMatch(/name a webhook/);
168
+ expect(notifyConfigProblem({ slack: "S", channel: "#ops" })).toMatch(/^notify\.channel: unknown setting/);
169
+ expect(notifyConfigProblem({ slack: "S", events: ["applied"] })).toMatch(/^notify\.events:/);
170
+ expect(notifyConfigProblem([], "waves.notify")).toMatch(/^waves\.notify: expected/);
171
+ });
172
+
173
+ test("notifyVariables lists the variables a pipeline maps its secrets into", () => {
174
+ expect(notifyVariables({ webhook: "HOOK", key: "HOOK_KEY", teams: "TEAMS" })).toEqual(["HOOK", "HOOK_KEY", "TEAMS"]);
175
+ expect(notifyVariables(undefined)).toEqual([]);
176
+ });
177
+ });
178
+
179
+ describe("sendNotify", () => {
180
+ const SECRET_URL = "https://hooks.example/T000/B000/secret-token";
181
+ const env = { HOOK: SECRET_URL, HOOK_KEY: "k3y", SLACK: "https://hooks.slack.com/services/T/B/slack-secret", TEAMS: "https://teams.example/workflows/teams-secret" };
182
+
183
+ test("posts the signed event to the webhook and the rendered messages to Slack and Teams", async () => {
184
+ const { calls, fetch } = recorder(200);
185
+ const e = waiting();
186
+ const out = await sendNotify({ webhook: "HOOK", key: "HOOK_KEY", slack: "SLACK", teams: "TEAMS" }, e, { env, fetch, now: 1_760_184_000, ...quiet });
187
+ expect(out).toEqual([
188
+ { target: "webhook", ok: true, detail: "delivered" },
189
+ { target: "slack", ok: true, detail: "delivered" },
190
+ { target: "teams", ok: true, detail: "delivered" },
191
+ ]);
192
+ expect(calls.map((c) => c.url)).toEqual([env.HOOK, env.SLACK, env.TEAMS]);
193
+ const hook = calls[0]!;
194
+ expect(JSON.parse(hook.body)).toEqual(e);
195
+ expect(hook.headers).toMatchObject({ "x-chant-event": "waiting", "x-chant-delivery": e.id, "x-chant-timestamp": "1760184000" });
196
+ expect(verifyWebhook({ body: hook.body, headers: hook.headers, key: "k3y", now: 1_760_184_000 })).toEqual({ ok: true, event: e });
197
+ expect(JSON.parse(calls[1]!.body)).toEqual(slackMessage(e));
198
+ expect(JSON.parse(calls[2]!.body)).toEqual(teamsMessage(e));
199
+ });
200
+
201
+ test("never sends an event unsigned", async () => {
202
+ const { calls, fetch } = recorder(200);
203
+ const lines: string[] = [];
204
+ const out = await sendNotify({ webhook: "HOOK", key: "HOOK_KEY" }, waiting(), { env: { HOOK: SECRET_URL }, fetch, ...quiet, log: (l) => lines.push(l) });
205
+ expect(calls).toEqual([]);
206
+ expect(out).toEqual([{ target: "webhook", ok: false, detail: "HOOK_KEY is not set, and an event is never sent unsigned" }]);
207
+ expect(lines).toEqual(["notify: the waiting event was not delivered to the webhook: HOOK_KEY is not set, and an event is never sent unsigned"]);
208
+ });
209
+
210
+ test("retries a 5xx and a network error, not a 4xx, and never prints a URL or a key", async () => {
211
+ const lines: string[] = [];
212
+ const log = (l: string) => lines.push(l);
213
+ const flaky = recorder(503, 200);
214
+ expect(await sendNotify({ slack: "SLACK" }, waiting(), { env, fetch: flaky.fetch, ...quiet, log })).toEqual([{ target: "slack", ok: true, detail: "delivered" }]);
215
+ expect(flaky.calls).toHaveLength(2);
216
+
217
+ const refused = recorder(403);
218
+ expect(await sendNotify({ slack: "SLACK" }, waiting(), { env, fetch: refused.fetch, ...quiet, log })).toEqual([{ target: "slack", ok: false, detail: "HTTP 403" }]);
219
+ expect(refused.calls).toHaveLength(1);
220
+
221
+ const down = recorder(new Error(`connect ECONNREFUSED for ${SECRET_URL}`));
222
+ const out = await sendNotify({ webhook: "HOOK", key: "HOOK_KEY" }, waiting(), { env, fetch: down.fetch, attempts: 2, ...quiet, log });
223
+ expect(down.calls).toHaveLength(2);
224
+ expect(out).toEqual([{ target: "webhook", ok: false, detail: "connect ECONNREFUSED for <url>" }]);
225
+ const printed = lines.join("\n");
226
+ for (const secret of [...Object.values(env), "secret-token"]) expect(printed).not.toContain(secret);
227
+ });
228
+
229
+ test("a fetch that throws synchronously, or a renderer that throws, is a failed delivery and not a throw", async () => {
230
+ const fetch: NotifyFetch = () => {
231
+ throw new TypeError(`Invalid URL ${SECRET_URL}`);
232
+ };
233
+ const out = await sendNotify({ webhook: "HOOK", key: "HOOK_KEY", teams: "TEAMS" }, waiting(), {
234
+ env,
235
+ fetch,
236
+ attempts: 1,
237
+ ...quiet,
238
+ render: {
239
+ teams: () => {
240
+ throw new Error("no card");
241
+ },
242
+ },
243
+ });
244
+ expect(out).toEqual([
245
+ { target: "webhook", ok: false, detail: "Invalid URL <url>" },
246
+ { target: "teams", ok: false, detail: "no card" },
247
+ ]);
248
+ });
249
+
250
+ test("says which variable is unset, sends only the configured events, and does nothing with no config", async () => {
251
+ const { calls, fetch } = recorder(200);
252
+ expect(await sendNotify({ teams: "TEAMS" }, waiting(), { env: {}, fetch, ...quiet })).toEqual([{ target: "teams", ok: false, detail: "TEAMS is not set" }]);
253
+ expect(await sendNotify({ slack: "SLACK", events: ["drift"] }, waiting(), { env, fetch, ...quiet })).toEqual([]);
254
+ expect(await sendNotify(undefined, waiting(), { env, fetch, ...quiet })).toEqual([]);
255
+ expect(calls).toEqual([]);
256
+ });
257
+
258
+ test("takes a product's own Slack body and header prefix", async () => {
259
+ const { calls, fetch } = recorder(200);
260
+ await sendNotify({ webhook: "HOOK", key: "HOOK_KEY", slack: "SLACK" }, waiting(), {
261
+ env,
262
+ fetch,
263
+ prefix: "x-yodel",
264
+ userAgent: "sql-yodeler",
265
+ render: { slack: (e) => ({ text: `custom ${e.event}`, blocks: [] }) },
266
+ ...quiet,
267
+ });
268
+ expect(calls[0]!.headers).toHaveProperty("x-yodel-signature");
269
+ expect(calls[0]!.headers["user-agent"]).toBe("sql-yodeler");
270
+ expect(verifyWebhook({ body: calls[0]!.body, headers: calls[0]!.headers, key: "k3y", prefix: "x-yodel" }).ok).toBe(true);
271
+ expect(JSON.parse(calls[1]!.body)).toEqual({ text: "custom waiting", blocks: [] });
272
+ });
273
+
274
+ test("webhookRequest signs what it sends", () => {
275
+ const r = webhookRequest(drift(), "k", { now: 100 });
276
+ expect(verifyWebhook({ body: r.body, headers: r.headers, key: "k", now: 100 }).ok).toBe(true);
277
+ expect(r.headers["user-agent"]).toBe("sql-yodeler");
278
+ });
279
+ });