@caelo-cms/provisioning 0.10.46 → 0.10.48

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 (342) hide show
  1. package/dist/adc-impersonation.d.ts.map +1 -1
  2. package/dist/adc-impersonation.js +23 -1
  3. package/dist/adc-impersonation.js.map +1 -1
  4. package/dist/agent/apply.d.ts +45 -0
  5. package/dist/agent/apply.d.ts.map +1 -0
  6. package/dist/agent/apply.js +83 -0
  7. package/dist/agent/apply.js.map +1 -0
  8. package/dist/agent/cli-provenance.d.ts +42 -0
  9. package/dist/agent/cli-provenance.d.ts.map +1 -0
  10. package/dist/agent/cli-provenance.js +138 -0
  11. package/dist/agent/cli-provenance.js.map +1 -0
  12. package/dist/agent/command.d.ts +79 -0
  13. package/dist/agent/command.d.ts.map +1 -0
  14. package/dist/agent/command.js +454 -0
  15. package/dist/agent/command.js.map +1 -0
  16. package/dist/agent/config.d.ts +30 -0
  17. package/dist/agent/config.d.ts.map +1 -0
  18. package/dist/agent/config.js +60 -0
  19. package/dist/agent/config.js.map +1 -0
  20. package/dist/agent/lock.d.ts +13 -0
  21. package/dist/agent/lock.d.ts.map +1 -0
  22. package/dist/agent/lock.js +100 -0
  23. package/dist/agent/lock.js.map +1 -0
  24. package/dist/agent/queue.d.ts +35 -0
  25. package/dist/agent/queue.d.ts.map +1 -0
  26. package/dist/agent/queue.js +50 -0
  27. package/dist/agent/queue.js.map +1 -0
  28. package/dist/agent/run.d.ts +97 -0
  29. package/dist/agent/run.d.ts.map +1 -0
  30. package/dist/agent/run.js +198 -0
  31. package/dist/agent/run.js.map +1 -0
  32. package/dist/agent/systemd.d.ts +55 -0
  33. package/dist/agent/systemd.d.ts.map +1 -0
  34. package/dist/agent/systemd.js +171 -0
  35. package/dist/agent/systemd.js.map +1 -0
  36. package/dist/agent/units.d.ts +44 -0
  37. package/dist/agent/units.d.ts.map +1 -0
  38. package/dist/agent/units.js +127 -0
  39. package/dist/agent/units.js.map +1 -0
  40. package/dist/agent-db.js +18362 -0
  41. package/dist/cli.js +122 -16
  42. package/dist/cli.js.map +1 -1
  43. package/dist/cloudsql-iam-user.d.ts +39 -0
  44. package/dist/cloudsql-iam-user.d.ts.map +1 -0
  45. package/dist/cloudsql-iam-user.js +119 -0
  46. package/dist/cloudsql-iam-user.js.map +1 -0
  47. package/dist/compose.d.ts +16 -0
  48. package/dist/compose.d.ts.map +1 -1
  49. package/dist/compose.js +26 -1
  50. package/dist/compose.js.map +1 -1
  51. package/dist/dns/index.d.ts +2 -0
  52. package/dist/dns/index.d.ts.map +1 -1
  53. package/dist/dns/index.js +3 -2
  54. package/dist/dns/index.js.map +1 -1
  55. package/dist/dns/manual.d.ts +5 -0
  56. package/dist/dns/manual.d.ts.map +1 -1
  57. package/dist/dns/manual.js +28 -12
  58. package/dist/dns/manual.js.map +1 -1
  59. package/dist/gcloud.d.ts +2 -0
  60. package/dist/gcloud.d.ts.map +1 -1
  61. package/dist/gcloud.js +1 -1
  62. package/dist/gcloud.js.map +1 -1
  63. package/dist/gcp-names.d.ts +15 -0
  64. package/dist/gcp-names.d.ts.map +1 -1
  65. package/dist/gcp-names.js +23 -0
  66. package/dist/gcp-names.js.map +1 -1
  67. package/dist/index.d.ts +6 -0
  68. package/dist/index.d.ts.map +1 -1
  69. package/dist/index.js +4 -0
  70. package/dist/index.js.map +1 -1
  71. package/dist/infra-contract.d.ts +1 -1
  72. package/dist/infra-contract.js +7 -1
  73. package/dist/infra-contract.js.map +1 -1
  74. package/dist/install-job-runner.d.ts +13 -0
  75. package/dist/install-job-runner.d.ts.map +1 -0
  76. package/dist/install-job-runner.js +155 -0
  77. package/dist/install-job-runner.js.map +1 -0
  78. package/dist/install-job.d.ts +210 -0
  79. package/dist/install-job.d.ts.map +1 -0
  80. package/dist/install-job.js +449 -0
  81. package/dist/install-job.js.map +1 -0
  82. package/dist/install-state.d.ts +19 -1
  83. package/dist/install-state.d.ts.map +1 -1
  84. package/dist/install-state.js +31 -3
  85. package/dist/install-state.js.map +1 -1
  86. package/dist/lifecycle.d.ts.map +1 -1
  87. package/dist/lifecycle.js +24 -5
  88. package/dist/lifecycle.js.map +1 -1
  89. package/dist/migration-runner.d.ts +5 -0
  90. package/dist/migration-runner.d.ts.map +1 -1
  91. package/dist/migration-runner.js +1 -1
  92. package/dist/migration-runner.js.map +1 -1
  93. package/dist/operator-access.d.ts +14 -8
  94. package/dist/operator-access.d.ts.map +1 -1
  95. package/dist/operator-access.js +16 -102
  96. package/dist/operator-access.js.map +1 -1
  97. package/dist/run-mode.d.ts +27 -0
  98. package/dist/run-mode.d.ts.map +1 -0
  99. package/dist/run-mode.js +22 -0
  100. package/dist/run-mode.js.map +1 -0
  101. package/dist/runtime-secrets.d.ts +19 -0
  102. package/dist/runtime-secrets.d.ts.map +1 -1
  103. package/dist/runtime-secrets.js +44 -0
  104. package/dist/runtime-secrets.js.map +1 -1
  105. package/dist/self-hosted-roles.d.ts +22 -1
  106. package/dist/self-hosted-roles.d.ts.map +1 -1
  107. package/dist/self-hosted-roles.js +81 -12
  108. package/dist/self-hosted-roles.js.map +1 -1
  109. package/dist/self-hosted-upgrade.d.ts +17 -0
  110. package/dist/self-hosted-upgrade.d.ts.map +1 -1
  111. package/dist/self-hosted-upgrade.js +16 -1
  112. package/dist/self-hosted-upgrade.js.map +1 -1
  113. package/dist/stack-contract.d.ts +6 -0
  114. package/dist/stack-contract.d.ts.map +1 -1
  115. package/dist/stack-contract.js +10 -1
  116. package/dist/stack-contract.js.map +1 -1
  117. package/dist/state-attach.d.ts +23 -0
  118. package/dist/state-attach.d.ts.map +1 -0
  119. package/dist/state-attach.js +112 -0
  120. package/dist/state-attach.js.map +1 -0
  121. package/dist/state-backend.d.ts +11 -0
  122. package/dist/state-backend.d.ts.map +1 -1
  123. package/dist/state-backend.js +20 -0
  124. package/dist/state-backend.js.map +1 -1
  125. package/dist/state-command.d.ts +20 -0
  126. package/dist/state-command.d.ts.map +1 -0
  127. package/dist/state-command.js +237 -0
  128. package/dist/state-command.js.map +1 -0
  129. package/dist/state-migrate.d.ts +121 -0
  130. package/dist/state-migrate.d.ts.map +1 -0
  131. package/dist/state-migrate.js +290 -0
  132. package/dist/state-migrate.js.map +1 -0
  133. package/dist/state-pulumi.d.ts +17 -0
  134. package/dist/state-pulumi.d.ts.map +1 -0
  135. package/dist/state-pulumi.js +218 -0
  136. package/dist/state-pulumi.js.map +1 -0
  137. package/dist/state-store.d.ts +27 -0
  138. package/dist/state-store.d.ts.map +1 -1
  139. package/dist/state-store.js +65 -0
  140. package/dist/state-store.js.map +1 -1
  141. package/dist/upgrade/converge.d.ts +6 -0
  142. package/dist/upgrade/converge.d.ts.map +1 -1
  143. package/dist/upgrade/converge.js +59 -2
  144. package/dist/upgrade/converge.js.map +1 -1
  145. package/dist/upgrade/gcp-lookup.d.ts +6 -0
  146. package/dist/upgrade/gcp-lookup.d.ts.map +1 -1
  147. package/dist/upgrade/gcp-lookup.js +25 -0
  148. package/dist/upgrade/gcp-lookup.js.map +1 -1
  149. package/dist/upgrade/release-images.d.ts +12 -0
  150. package/dist/upgrade/release-images.d.ts.map +1 -1
  151. package/dist/upgrade/release-images.js +31 -0
  152. package/dist/upgrade/release-images.js.map +1 -1
  153. package/dist/upgrade/upgrade.d.ts +5 -0
  154. package/dist/upgrade/upgrade.d.ts.map +1 -1
  155. package/dist/upgrade/upgrade.js +28 -1
  156. package/dist/upgrade/upgrade.js.map +1 -1
  157. package/dist/upgrader-assess.d.ts +82 -0
  158. package/dist/upgrader-assess.d.ts.map +1 -0
  159. package/dist/upgrader-assess.js +116 -0
  160. package/dist/upgrader-assess.js.map +1 -0
  161. package/dist/upgrader-gcp.d.ts +17 -0
  162. package/dist/upgrader-gcp.d.ts.map +1 -0
  163. package/dist/upgrader-gcp.js +87 -0
  164. package/dist/upgrader-gcp.js.map +1 -0
  165. package/dist/upgrader-job.d.ts +131 -0
  166. package/dist/upgrader-job.d.ts.map +1 -0
  167. package/dist/upgrader-job.js +259 -0
  168. package/dist/upgrader-job.js.map +1 -0
  169. package/dist/upgrader-reporter.d.ts +35 -0
  170. package/dist/upgrader-reporter.d.ts.map +1 -0
  171. package/dist/upgrader-reporter.js +79 -0
  172. package/dist/upgrader-reporter.js.map +1 -0
  173. package/dist/upgrader-setup.d.ts +59 -0
  174. package/dist/upgrader-setup.d.ts.map +1 -0
  175. package/dist/upgrader-setup.js +308 -0
  176. package/dist/upgrader-setup.js.map +1 -0
  177. package/dist/wizard.d.ts +6 -0
  178. package/dist/wizard.d.ts.map +1 -1
  179. package/dist/wizard.js +32 -4
  180. package/dist/wizard.js.map +1 -1
  181. package/dist/wizards/gcp-firebase.d.ts +3 -0
  182. package/dist/wizards/gcp-firebase.d.ts.map +1 -1
  183. package/dist/wizards/gcp-firebase.js +1 -0
  184. package/dist/wizards/gcp-firebase.js.map +1 -1
  185. package/dist/wizards/gcp-install-job.d.ts +74 -0
  186. package/dist/wizards/gcp-install-job.d.ts.map +1 -0
  187. package/dist/wizards/gcp-install-job.js +398 -0
  188. package/dist/wizards/gcp-install-job.js.map +1 -0
  189. package/dist/wizards/gcp-prompts.d.ts +3 -0
  190. package/dist/wizards/gcp-prompts.d.ts.map +1 -0
  191. package/dist/wizards/gcp-prompts.js +25 -0
  192. package/dist/wizards/gcp-prompts.js.map +1 -0
  193. package/dist/wizards/gcp-pulumi.d.ts +9 -2
  194. package/dist/wizards/gcp-pulumi.d.ts.map +1 -1
  195. package/dist/wizards/gcp-pulumi.js +8 -6
  196. package/dist/wizards/gcp-pulumi.js.map +1 -1
  197. package/dist/wizards/gcp.d.ts +40 -0
  198. package/dist/wizards/gcp.d.ts.map +1 -1
  199. package/dist/wizards/gcp.js +291 -83
  200. package/dist/wizards/gcp.js.map +1 -1
  201. package/package.json +6 -4
  202. package/src/adapter.ts +97 -0
  203. package/src/adc-impersonation.ts +176 -0
  204. package/src/admin-domain.test.ts +254 -0
  205. package/src/admin-domain.ts +245 -0
  206. package/src/admin-memory.test.ts +151 -0
  207. package/src/agent/apply.test.ts +185 -0
  208. package/src/agent/apply.ts +124 -0
  209. package/src/agent/cli-provenance.test.ts +159 -0
  210. package/src/agent/cli-provenance.ts +191 -0
  211. package/src/agent/command.test.ts +77 -0
  212. package/src/agent/command.ts +564 -0
  213. package/src/agent/config.ts +86 -0
  214. package/src/agent/lock-config.test.ts +97 -0
  215. package/src/agent/lock.ts +110 -0
  216. package/src/agent/queue.test.ts +10 -0
  217. package/src/agent/queue.ts +91 -0
  218. package/src/agent/run.test.ts +382 -0
  219. package/src/agent/run.ts +295 -0
  220. package/src/agent/systemd.ts +209 -0
  221. package/src/agent/units.test.ts +223 -0
  222. package/src/agent/units.ts +163 -0
  223. package/src/azure-front-door.test.ts +119 -0
  224. package/src/azure-front-door.ts +56 -0
  225. package/src/bootstrap-token.ts +20 -0
  226. package/src/caddy.ts +112 -0
  227. package/src/cdn-copy.ts +91 -0
  228. package/src/cert-wait.test.ts +194 -0
  229. package/src/cert-wait.ts +163 -0
  230. package/src/checkpoint-secrets.test.ts +380 -0
  231. package/src/checkpoint-secrets.ts +283 -0
  232. package/src/cli-routing.test.ts +45 -0
  233. package/src/cli-routing.ts +43 -0
  234. package/src/cli.ts +1491 -0
  235. package/src/cloudsql-iam-user.ts +147 -0
  236. package/src/compose.ts +334 -0
  237. package/src/default-firewall.test.ts +411 -0
  238. package/src/default-firewall.ts +342 -0
  239. package/src/deployed-release.test.ts +231 -0
  240. package/src/deployed-release.ts +220 -0
  241. package/src/dns/cloudflare.ts +182 -0
  242. package/src/dns/groups.test.ts +76 -0
  243. package/src/dns/groups.ts +86 -0
  244. package/src/dns/index.ts +56 -0
  245. package/src/dns/manual.ts +130 -0
  246. package/src/dns/types.ts +27 -0
  247. package/src/gateway-credentials.test.ts +180 -0
  248. package/src/gateway-credentials.ts +144 -0
  249. package/src/gcloud-components.test.ts +76 -0
  250. package/src/gcloud-components.ts +85 -0
  251. package/src/gcloud-retry.ts +41 -0
  252. package/src/gcloud-runner.test.ts +61 -0
  253. package/src/gcloud.test.ts +99 -0
  254. package/src/gcloud.ts +322 -0
  255. package/src/gcp-names.ts +135 -0
  256. package/src/index.test.ts +332 -0
  257. package/src/index.ts +114 -0
  258. package/src/infra-contract.ts +32 -0
  259. package/src/install-job-runner.ts +166 -0
  260. package/src/install-job.test.ts +566 -0
  261. package/src/install-job.ts +542 -0
  262. package/src/install-state.test.ts +239 -0
  263. package/src/install-state.ts +583 -0
  264. package/src/internal-token.ts +35 -0
  265. package/src/lifecycle.ts +900 -0
  266. package/src/mcp-iap-stacks.test.ts +58 -0
  267. package/src/mcp-iap.test.ts +149 -0
  268. package/src/mcp-iap.ts +131 -0
  269. package/src/media-volume.test.ts +296 -0
  270. package/src/migration-runner.ts +365 -0
  271. package/src/operator-access-stacks.test.ts +55 -0
  272. package/src/operator-access.test.ts +332 -0
  273. package/src/operator-access.ts +554 -0
  274. package/src/ops-backend.test.ts +320 -0
  275. package/src/ops-backend.ts +414 -0
  276. package/src/provisioner-identity.test.ts +482 -0
  277. package/src/provisioner-identity.ts +407 -0
  278. package/src/redirects-emit.ts +175 -0
  279. package/src/region-data.ts +428 -0
  280. package/src/regions.test.ts +277 -0
  281. package/src/regions.ts +370 -0
  282. package/src/release-contract.ts +57 -0
  283. package/src/release-manifest.test.ts +293 -0
  284. package/src/release-manifest.ts +430 -0
  285. package/src/run-mode.ts +40 -0
  286. package/src/runtime-secrets.test.ts +627 -0
  287. package/src/runtime-secrets.ts +754 -0
  288. package/src/self-command.test.ts +14 -0
  289. package/src/self-command.ts +21 -0
  290. package/src/self-hosted-compose.test.ts +177 -0
  291. package/src/self-hosted-release.ts +240 -0
  292. package/src/self-hosted-roles.integration.test.ts +161 -0
  293. package/src/self-hosted-roles.test.ts +124 -0
  294. package/src/self-hosted-roles.ts +203 -0
  295. package/src/self-hosted-upgrade.test.ts +394 -0
  296. package/src/self-hosted-upgrade.ts +548 -0
  297. package/src/stack-contract-parity.test.ts +199 -0
  298. package/src/stack-contract.test.ts +391 -0
  299. package/src/stack-contract.ts +888 -0
  300. package/src/stack-converge-secrets.test.ts +421 -0
  301. package/src/stack-converge.test.ts +833 -0
  302. package/src/stack-converge.ts +909 -0
  303. package/src/staging-admin.test.ts +246 -0
  304. package/src/staging-admin.ts +324 -0
  305. package/src/state-attach.test.ts +211 -0
  306. package/src/state-attach.ts +132 -0
  307. package/src/state-backend.test.ts +145 -0
  308. package/src/state-backend.ts +262 -0
  309. package/src/state-command.ts +293 -0
  310. package/src/state-migrate.test.ts +479 -0
  311. package/src/state-migrate.ts +434 -0
  312. package/src/state-pulumi.ts +289 -0
  313. package/src/state-store.test.ts +212 -0
  314. package/src/state-store.ts +243 -0
  315. package/src/upgrade/app-roll.ts +486 -0
  316. package/src/upgrade/backup.ts +109 -0
  317. package/src/upgrade/converge.ts +437 -0
  318. package/src/upgrade/gcp-lookup.ts +167 -0
  319. package/src/upgrade/health.test.ts +279 -0
  320. package/src/upgrade/health.ts +305 -0
  321. package/src/upgrade/release-images.test.ts +119 -0
  322. package/src/upgrade/release-images.ts +391 -0
  323. package/src/upgrade/reporter.ts +46 -0
  324. package/src/upgrade/upgrade.test.ts +626 -0
  325. package/src/upgrade/upgrade.ts +249 -0
  326. package/src/upgrader-assess.test.ts +265 -0
  327. package/src/upgrader-assess.ts +233 -0
  328. package/src/upgrader-gcp.ts +123 -0
  329. package/src/upgrader-job.test.ts +408 -0
  330. package/src/upgrader-job.ts +499 -0
  331. package/src/upgrader-reporter.test.ts +46 -0
  332. package/src/upgrader-reporter.ts +105 -0
  333. package/src/upgrader-setup.test.ts +169 -0
  334. package/src/upgrader-setup.ts +355 -0
  335. package/src/wizard.ts +425 -0
  336. package/src/wizards/gcp-cost.ts +188 -0
  337. package/src/wizards/gcp-firebase.ts +72 -0
  338. package/src/wizards/gcp-install-job.ts +506 -0
  339. package/src/wizards/gcp-prompts.ts +26 -0
  340. package/src/wizards/gcp-pulumi.test.ts +246 -0
  341. package/src/wizards/gcp-pulumi.ts +516 -0
  342. package/src/wizards/gcp.ts +1935 -0
@@ -0,0 +1,627 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ import { describe, expect, it } from "bun:test";
4
+ import type { GcloudResult } from "./gcloud.js";
5
+ import { migrationJobEnvArgs, parseAdminConfig } from "./migration-runner.js";
6
+ import {
7
+ ensureGatewayServiceAccount,
8
+ ensureGeneratedSecrets,
9
+ type HttpFetch,
10
+ plainGeneratedSecretSeed,
11
+ readSecretReplication,
12
+ rotateRuntimeSecret,
13
+ rotationRefusal,
14
+ stackSecretReplication,
15
+ } from "./runtime-secrets.js";
16
+
17
+ const ok = (stdout = ""): GcloudResult => ({ ok: true, stdout, stderr: "", exitCode: 0 });
18
+ const fail = (stderr: string): GcloudResult => ({ ok: false, stdout: "", stderr, exitCode: 1 });
19
+
20
+ interface Call {
21
+ readonly line: string;
22
+ readonly stdin?: string;
23
+ }
24
+
25
+ /** Fake gcloud answering by longest command prefix; records argv + stdin. */
26
+ function fakeGcloud(answers: Record<string, GcloudResult[]>, fallback: GcloudResult = ok()) {
27
+ const calls: Call[] = [];
28
+ const run = async (args: string[], opts?: { stdin?: string }) => {
29
+ const line = args.join(" ");
30
+ calls.push(opts?.stdin === undefined ? { line } : { line, stdin: opts.stdin });
31
+ const key = Object.keys(answers)
32
+ .filter((k) => line.startsWith(k))
33
+ .sort((a, b) => b.length - a.length)[0];
34
+ const queue = key ? answers[key] : undefined;
35
+ if (!queue || queue.length === 0) return fallback;
36
+ return queue.length > 1 ? (queue.shift() as GcloudResult) : (queue[0] as GcloudResult);
37
+ };
38
+ return { run, calls, lines: () => calls.map((c) => c.line) };
39
+ }
40
+
41
+ const install = { projectId: "acme", env: "production" };
42
+ /** ensureGeneratedSecrets also needs the install region (policy retry). */
43
+ const secretsInstall = { ...install, region: "europe-west1" };
44
+ const NOT_FOUND = fail("ERROR: NOT_FOUND: Secret [x] not found or has no versions.");
45
+
46
+ describe("ensureGatewayServiceAccount", () => {
47
+ it("leaves an existing SA alone", async () => {
48
+ const { run, lines } = fakeGcloud({ "iam service-accounts describe": [ok("gw@")] });
49
+ expect((await ensureGatewayServiceAccount(install, { run })).status).toBe("present");
50
+ expect(lines().some((l) => l.includes(" create "))).toBe(false);
51
+ });
52
+
53
+ it("creates a missing SA with the stack's account id", async () => {
54
+ const { run, lines } = fakeGcloud({
55
+ "iam service-accounts describe": [fail("ERROR: NOT_FOUND: Unknown service account")],
56
+ });
57
+ expect((await ensureGatewayServiceAccount(install, { run })).status).toBe("applied");
58
+ expect(lines()).toContain(
59
+ "iam service-accounts create caelo-production-gateway-sa --project=acme --display-name=Caelo production gateway --quiet",
60
+ );
61
+ });
62
+
63
+ it("fails (does not create) when the SA can't be read for another reason", async () => {
64
+ const { run, lines } = fakeGcloud({
65
+ "iam service-accounts describe": [fail("PERMISSION_DENIED: iam.serviceAccounts.get")],
66
+ });
67
+ const outcome = await ensureGatewayServiceAccount(install, { run });
68
+ expect(outcome.status).toBe("failed");
69
+ expect(lines().some((l) => l.includes(" create "))).toBe(false);
70
+ });
71
+ });
72
+
73
+ describe("readSecretReplication / stackSecretReplication", () => {
74
+ it("mirrors the postgres-password secret's replication", async () => {
75
+ const regional = fakeGcloud({
76
+ "secrets describe caelo-production-postgres-password": [
77
+ ok(
78
+ JSON.stringify({
79
+ replication: { userManaged: { replicas: [{ location: "europe-west1" }] } },
80
+ }),
81
+ ),
82
+ ],
83
+ });
84
+ expect(await readSecretReplication(install, { run: regional.run })).toEqual({
85
+ ok: true,
86
+ replication: { kind: "user-managed", locations: ["europe-west1"] },
87
+ });
88
+ const auto = fakeGcloud({
89
+ "secrets describe": [ok(JSON.stringify({ replication: { automatic: {} } }))],
90
+ });
91
+ expect(await readSecretReplication(install, { run: auto.run })).toEqual({
92
+ ok: true,
93
+ replication: { kind: "automatic" },
94
+ });
95
+ });
96
+
97
+ it("maps the stack's secretReplication config", () => {
98
+ expect(stackSecretReplication("auto", "r")).toEqual({ kind: "automatic" });
99
+ expect(stackSecretReplication("regional", "r")).toEqual({
100
+ kind: "user-managed",
101
+ locations: ["r"],
102
+ });
103
+ });
104
+ });
105
+
106
+ describe("ensureGeneratedSecrets", () => {
107
+ const regional = { kind: "user-managed" as const, locations: ["europe-west1"] };
108
+
109
+ it("is a read-only no-op when every secret has an enabled version", async () => {
110
+ const { run, lines } = fakeGcloud({
111
+ "secrets describe": [ok("projects/1/secrets/x")],
112
+ "secrets versions list": [ok("projects/1/secrets/x/versions/1")],
113
+ });
114
+ const outcomes = await ensureGeneratedSecrets(
115
+ { ...secretsInstall, replication: regional },
116
+ { run },
117
+ );
118
+ expect(outcomes.map((o) => o.status)).toEqual([
119
+ "present",
120
+ "present",
121
+ "present",
122
+ "present",
123
+ "present",
124
+ ]);
125
+ expect(lines().some((l) => l.includes(" create ") || l.includes(" add "))).toBe(false);
126
+ });
127
+
128
+ it("creates missing secrets with the install's replication, value on stdin only", async () => {
129
+ const { run, calls } = fakeGcloud({ "secrets describe": [NOT_FOUND] });
130
+ let n = 0;
131
+ const outcomes = await ensureGeneratedSecrets(
132
+ { ...secretsInstall, replication: regional },
133
+ { run, generate: () => `generated-value-${++n}` },
134
+ );
135
+ expect(outcomes.map((o) => o.status)).toEqual([
136
+ "applied",
137
+ "applied",
138
+ "applied",
139
+ "applied",
140
+ "applied",
141
+ ]);
142
+ const creates = calls.filter((c) => c.line.startsWith("secrets create"));
143
+ expect(creates).toEqual([
144
+ {
145
+ line: "secrets create caelo-production-internal-secret --project=acme --replication-policy=user-managed --locations=europe-west1 --data-file=- --quiet",
146
+ stdin: "generated-value-1",
147
+ },
148
+ {
149
+ line: "secrets create caelo-production-tool-approval-secret --project=acme --replication-policy=user-managed --locations=europe-west1 --data-file=- --quiet",
150
+ stdin: "generated-value-2",
151
+ },
152
+ // #613 — the gateway's role passwords: fresh values, never admin_role's.
153
+ {
154
+ line: "secrets create caelo-production-public-role-password --project=acme --replication-policy=user-managed --locations=europe-west1 --data-file=- --quiet",
155
+ stdin: "generated-value-3",
156
+ },
157
+ {
158
+ line: "secrets create caelo-production-gateway-role-password --project=acme --replication-policy=user-managed --locations=europe-west1 --data-file=- --quiet",
159
+ stdin: "generated-value-4",
160
+ },
161
+ // #650 — the staging access gate's key.
162
+ {
163
+ line: "secrets create caelo-production-staging-gate-secret --project=acme --replication-policy=user-managed --locations=europe-west1 --data-file=- --quiet",
164
+ stdin: "generated-value-5",
165
+ },
166
+ ]);
167
+ for (const c of calls) expect(c.line).not.toContain("generated-value");
168
+ });
169
+
170
+ it("migrates an operator-set plain value instead of rotating it", async () => {
171
+ const { run, calls } = fakeGcloud({ "secrets describe": [NOT_FOUND] });
172
+ const seed = plainGeneratedSecretSeed(
173
+ new Map([
174
+ ["CAELO_INTERNAL_SECRET", { kind: "value", value: "operator-set" }],
175
+ ["CAELO_PROVIDER", { kind: "value", value: "gcp" }],
176
+ ]),
177
+ );
178
+ expect(seed).toEqual({ "internal-secret": "operator-set" });
179
+ await ensureGeneratedSecrets(
180
+ { ...secretsInstall, replication: regional, seed },
181
+ { run, generate: () => "generated" },
182
+ );
183
+ const creates = calls.filter((c) => c.line.startsWith("secrets create"));
184
+ expect(creates.map((c) => c.stdin)).toEqual([
185
+ "operator-set",
186
+ "generated",
187
+ "generated",
188
+ "generated",
189
+ "generated",
190
+ ]);
191
+ for (const c of calls) expect(c.line).not.toContain("operator-set");
192
+ });
193
+
194
+ it("adds a first version to a secret that exists without one", async () => {
195
+ const { run, calls } = fakeGcloud({
196
+ "secrets describe": [ok("x")],
197
+ "secrets versions list caelo-production-internal-secret": [ok("")],
198
+ "secrets versions list caelo-production-tool-approval-secret": [ok("v/1")],
199
+ "secrets versions list caelo-production-public-role-password": [ok("v/1")],
200
+ "secrets versions list caelo-production-gateway-role-password": [ok("v/1")],
201
+ "secrets versions list caelo-production-staging-gate-secret": [ok("v/1")],
202
+ });
203
+ const outcomes = await ensureGeneratedSecrets(
204
+ { ...secretsInstall, replication: { kind: "automatic" } },
205
+ { run, generate: () => "v" },
206
+ );
207
+ expect(outcomes.map((o) => o.status)).toEqual([
208
+ "applied",
209
+ "present",
210
+ "present",
211
+ "present",
212
+ "present",
213
+ ]);
214
+ expect(calls.filter((c) => c.line.startsWith("secrets versions add"))).toEqual([
215
+ {
216
+ line: "secrets versions add caelo-production-internal-secret --project=acme --data-file=-",
217
+ stdin: "v",
218
+ },
219
+ ]);
220
+ });
221
+
222
+ it("reports a failure without creating anything when the secret can't be read", async () => {
223
+ const { run, lines } = fakeGcloud({
224
+ "secrets describe": [fail("PERMISSION_DENIED: secretmanager.secrets.get")],
225
+ });
226
+ const outcomes = await ensureGeneratedSecrets(
227
+ { ...secretsInstall, replication: regional },
228
+ { run },
229
+ );
230
+ expect(outcomes.every((o) => o.status === "failed")).toBe(true);
231
+ expect(lines().some((l) => l.includes(" create "))).toBe(false);
232
+ });
233
+ const POLICY_VIOLATION = fail(
234
+ "ERROR: (gcloud.secrets.create) FAILED_PRECONDITION: Constraint constraints/gcp.resourceLocations violated for [projects/acme] attempting to create a secret in [global].",
235
+ );
236
+
237
+ it("creates secrets user-managed in the install region when an org policy forbids automatic", async () => {
238
+ const { run, lines } = fakeGcloud({
239
+ "secrets describe": [NOT_FOUND],
240
+ "secrets create caelo-production-internal-secret --project=acme --replication-policy=automatic":
241
+ [POLICY_VIOLATION],
242
+ });
243
+ const outcomes = await ensureGeneratedSecrets(
244
+ { ...secretsInstall, replication: { kind: "automatic" } },
245
+ { run, generate: () => "v" },
246
+ );
247
+ expect(outcomes.map((o) => o.status)).toEqual([
248
+ "applied",
249
+ "applied",
250
+ "applied",
251
+ "applied",
252
+ "applied",
253
+ ]);
254
+ expect(outcomes.every((o) => o.detail?.includes("gcp.resourceLocations"))).toBe(true);
255
+ const creates = lines().filter((l) => l.startsWith("secrets create"));
256
+ // One refused automatic attempt; then every secret — the refused one
257
+ // and all later ones — user-managed in the install region.
258
+ expect(creates).toEqual([
259
+ "secrets create caelo-production-internal-secret --project=acme --replication-policy=automatic --data-file=- --quiet",
260
+ "secrets create caelo-production-internal-secret --project=acme --replication-policy=user-managed --locations=europe-west1 --data-file=- --quiet",
261
+ "secrets create caelo-production-tool-approval-secret --project=acme --replication-policy=user-managed --locations=europe-west1 --data-file=- --quiet",
262
+ "secrets create caelo-production-public-role-password --project=acme --replication-policy=user-managed --locations=europe-west1 --data-file=- --quiet",
263
+ "secrets create caelo-production-gateway-role-password --project=acme --replication-policy=user-managed --locations=europe-west1 --data-file=- --quiet",
264
+ // #652 — the staging gate's key follows the same path.
265
+ "secrets create caelo-production-staging-gate-secret --project=acme --replication-policy=user-managed --locations=europe-west1 --data-file=- --quiet",
266
+ ]);
267
+ });
268
+
269
+ it("keeps automatic replication where no policy refuses it", async () => {
270
+ const { run, lines } = fakeGcloud({ "secrets describe": [NOT_FOUND] });
271
+ const outcomes = await ensureGeneratedSecrets(
272
+ { ...secretsInstall, replication: { kind: "automatic" } },
273
+ { run, generate: () => "v" },
274
+ );
275
+ expect(outcomes.every((o) => o.status === "applied" && o.detail === undefined)).toBe(true);
276
+ expect(
277
+ lines()
278
+ .filter((l) => l.startsWith("secrets create"))
279
+ .every((l) => l.includes("--replication-policy=automatic")),
280
+ ).toBe(true);
281
+ });
282
+
283
+ it("does not retry other create failures", async () => {
284
+ const { run, lines } = fakeGcloud({
285
+ "secrets describe": [NOT_FOUND],
286
+ "secrets create": [fail("PERMISSION_DENIED: secretmanager.secrets.create")],
287
+ });
288
+ const outcomes = await ensureGeneratedSecrets(
289
+ { ...secretsInstall, replication: { kind: "automatic" } },
290
+ { run, generate: () => "v" },
291
+ );
292
+ expect(outcomes.every((o) => o.status === "failed")).toBe(true);
293
+ expect(lines().filter((l) => l.startsWith("secrets create"))).toHaveLength(5);
294
+ });
295
+ });
296
+
297
+ describe("rotationRefusal", () => {
298
+ it("rotates every database role's password and the generated secrets", () => {
299
+ for (const s of [
300
+ "postgres-password",
301
+ "public-role-password",
302
+ "gateway-role-password",
303
+ "internal-secret",
304
+ "tool-approval-secret",
305
+ "staging-gate-secret",
306
+ ]) {
307
+ expect(rotationRefusal(s)).toBeNull();
308
+ }
309
+ });
310
+
311
+ it("refuses the KEK (re-encryption needed) and secrets nothing reads", () => {
312
+ expect(rotationRefusal("secret-kek")).toContain("re-encryption");
313
+ expect(rotationRefusal("csrf-secret")).toContain("Rotatable");
314
+ });
315
+ });
316
+
317
+ describe("rotateRuntimeSecret", () => {
318
+ const target = {
319
+ projectId: "acme",
320
+ region: "europe-west1",
321
+ env: "production",
322
+ services: { admin: "adm-svc", gateway: "gw-svc" },
323
+ sqlInstance: "caelo-production-pg-1",
324
+ };
325
+ const ref = (name: string, secret: string, version = "latest") => ({
326
+ name,
327
+ valueFrom: { secretKeyRef: { name: `caelo-production-${secret}`, key: version } },
328
+ });
329
+ const service = (env: unknown[]) =>
330
+ ok(JSON.stringify({ spec: { template: { spec: { containers: [{ env }] } } } }));
331
+ /** Both services as upgrade leaves them: every secret var a reference at latest. */
332
+ const converged = () => ({
333
+ "run services describe adm-svc": [
334
+ service([
335
+ ref("ADMIN_DATABASE_PASSWORD", "postgres-password"),
336
+ ref("PUBLIC_ADMIN_DATABASE_PASSWORD", "postgres-password"),
337
+ ref("CAELO_SECRET_KEK", "secret-kek"),
338
+ ref("CAELO_INTERNAL_SECRET", "internal-secret"),
339
+ ref("CAELO_TOOL_APPROVAL_SECRET", "tool-approval-secret"),
340
+ ]),
341
+ ],
342
+ "run services describe gw-svc": [
343
+ service([
344
+ ref("GATEWAY_DATABASE_PASSWORD", "gateway-role-password"),
345
+ ref("PUBLIC_DATABASE_PASSWORD", "public-role-password"),
346
+ ]),
347
+ ],
348
+ "auth print-access-token": [ok("tok\n")],
349
+ });
350
+
351
+ /** Fake Cloud SQL Admin API: every update finishes at once; records requests. */
352
+ function fakeSqlApi(failRole?: string) {
353
+ const requests: { url: string; method: string; body?: string; auth?: string }[] = [];
354
+ const http: HttpFetch = async (url, init) => {
355
+ requests.push({
356
+ url,
357
+ method: init.method,
358
+ ...(init.body ? { body: init.body } : {}),
359
+ ...(init.headers.Authorization ? { auth: init.headers.Authorization } : {}),
360
+ });
361
+ if (failRole && url.endsWith(`name=${failRole}`)) {
362
+ return { ok: false, status: 403, text: async () => "PERMISSION_DENIED" };
363
+ }
364
+ return {
365
+ ok: true,
366
+ status: 200,
367
+ text: async () => JSON.stringify({ name: "op", status: "DONE" }),
368
+ };
369
+ };
370
+ const passwords = () =>
371
+ requests
372
+ .filter((r) => r.method === "PUT")
373
+ .map((r) => {
374
+ const body = JSON.parse(r.body ?? "{}") as { name: string; password: string };
375
+ return `${body.name} ${body.password}`;
376
+ });
377
+ return { http, requests, passwords };
378
+ }
379
+
380
+ it("postgres-password: admin_role first (via the API, not argv), then the new version, then rolls the admin only", async () => {
381
+ const { run, calls, lines } = fakeGcloud({
382
+ ...converged(),
383
+ "secrets versions access": [ok("old-pw")],
384
+ });
385
+ const sql = fakeSqlApi();
386
+ const report = await rotateRuntimeSecret(target, "postgres-password", {
387
+ run,
388
+ http: sql.http,
389
+ sleep: async () => {},
390
+ generate: () => "new-pw",
391
+ });
392
+ expect(report.ok).toBe(true);
393
+ // #613 — admin_role's password is admin_role's alone.
394
+ expect(sql.passwords()).toEqual(["admin_role new-pw"]);
395
+ expect(sql.requests[0]?.url).toBe(
396
+ "https://sqladmin.googleapis.com/v1/projects/acme/instances/caelo-production-pg-1/users?name=admin_role",
397
+ );
398
+ expect(sql.requests[0]?.auth).toBe("Bearer tok");
399
+ expect(lines().some((l) => l.includes("new-pw") || l.includes("old-pw"))).toBe(false);
400
+ const order = lines()
401
+ .filter((l) => !l.startsWith("run services describe") && !l.startsWith("auth "))
402
+ .map((l) => l.split(" --")[0]);
403
+ expect(order).toEqual([
404
+ "secrets versions access latest",
405
+ "secrets versions add caelo-production-postgres-password",
406
+ "run services update adm-svc",
407
+ "run services update-traffic adm-svc",
408
+ ]);
409
+ expect(calls.find((c) => c.line.startsWith("secrets versions add"))?.stdin).toBe("new-pw");
410
+ expect(
411
+ lines()
412
+ .filter((l) => l.startsWith("run services update "))
413
+ .every((l) => /--update-labels=caelo-secret-rotated=\d+/.test(l)),
414
+ ).toBe(true);
415
+ });
416
+
417
+ it("gateway-role-password: changes gateway_role only and rolls the gateway only (#613)", async () => {
418
+ const { run, lines } = fakeGcloud({
419
+ ...converged(),
420
+ "secrets versions access": [ok("old-pw")],
421
+ });
422
+ const sql = fakeSqlApi();
423
+ const report = await rotateRuntimeSecret(target, "gateway-role-password", {
424
+ run,
425
+ http: sql.http,
426
+ sleep: async () => {},
427
+ generate: () => "new-pw",
428
+ });
429
+ expect(report.ok).toBe(true);
430
+ expect(sql.passwords()).toEqual(["gateway_role new-pw"]);
431
+ expect(lines().filter((l) => l.startsWith("run services update "))).toEqual([
432
+ expect.stringContaining("run services update gw-svc"),
433
+ ]);
434
+ expect(lines()).toContain(
435
+ "secrets versions access latest --secret=caelo-production-gateway-role-password --project=acme",
436
+ );
437
+ });
438
+
439
+ it("public-role-password: stores nothing and rolls nothing when public_role can't be changed", async () => {
440
+ const { run, lines } = fakeGcloud({
441
+ ...converged(),
442
+ "secrets versions access": [ok("old-pw")],
443
+ });
444
+ const sql = fakeSqlApi("public_role");
445
+ const report = await rotateRuntimeSecret(target, "public-role-password", {
446
+ run,
447
+ http: sql.http,
448
+ sleep: async () => {},
449
+ generate: () => "new-pw",
450
+ });
451
+ expect(report.ok).toBe(false);
452
+ expect(report.error).toContain("set the public_role password");
453
+ expect(sql.passwords()).toEqual(["public_role new-pw"]);
454
+ expect(lines().some((l) => l.startsWith("secrets versions add"))).toBe(false);
455
+ expect(lines().some((l) => l.startsWith("run services update"))).toBe(false);
456
+ });
457
+
458
+ it("refuses postgres-password while the gateway still reads it (an install from before #613)", async () => {
459
+ const answers = converged();
460
+ answers["run services describe gw-svc"] = [
461
+ service([
462
+ ref("ADMIN_DATABASE_PASSWORD", "postgres-password"),
463
+ ref("PUBLIC_DATABASE_PASSWORD", "postgres-password"),
464
+ ]),
465
+ ];
466
+ const { run, lines } = fakeGcloud(answers);
467
+ const sql = fakeSqlApi();
468
+ const report = await rotateRuntimeSecret(target, "postgres-password", {
469
+ run,
470
+ http: sql.http,
471
+ generate: () => "new-pw",
472
+ });
473
+ expect(report.ok).toBe(false);
474
+ expect(report.error).toContain("ADMIN_DATABASE_PASSWORD");
475
+ expect(report.error).toContain("Run `cms-provision upgrade` first");
476
+ expect(sql.requests).toHaveLength(0);
477
+ expect(lines().every((l) => l.startsWith("run services describe"))).toBe(true);
478
+ });
479
+
480
+ it("refuses before changing anything on an install upgrade hasn't moved over", async () => {
481
+ const { run, lines } = fakeGcloud({
482
+ "run services describe adm-svc": [
483
+ service([
484
+ { name: "ADMIN_DATABASE_URL", value: "postgres://admin_role:pw@10.0.0.3:5432/cms_admin" },
485
+ ]),
486
+ ],
487
+ });
488
+ const sql = fakeSqlApi();
489
+ const report = await rotateRuntimeSecret(target, "postgres-password", {
490
+ run,
491
+ http: sql.http,
492
+ generate: () => "new-pw",
493
+ });
494
+ expect(report.ok).toBe(false);
495
+ expect(report.error).toContain("Run `cms-provision upgrade` first");
496
+ expect(sql.requests).toHaveLength(0);
497
+ expect(lines().every((l) => l.startsWith("run services describe"))).toBe(true);
498
+ });
499
+
500
+ it("refuses a reader pinned to an older secret version", async () => {
501
+ const answers = converged();
502
+ answers["run services describe adm-svc"] = [
503
+ service([ref("CAELO_INTERNAL_SECRET", "internal-secret", "3")]),
504
+ ];
505
+ const { run, lines } = fakeGcloud(answers);
506
+ const report = await rotateRuntimeSecret(target, "internal-secret", {
507
+ run,
508
+ generate: () => "v",
509
+ });
510
+ expect(report.ok).toBe(false);
511
+ expect(report.error).toContain("CAELO_INTERNAL_SECRET");
512
+ expect(lines().some((l) => l.startsWith("secrets versions add"))).toBe(false);
513
+ });
514
+
515
+ it("says how to resume when the roll fails after the database took the new value", async () => {
516
+ const { run } = fakeGcloud({
517
+ ...converged(),
518
+ "secrets versions access": [ok("old-pw")],
519
+ "run services update gw-svc": [fail("revision failed")],
520
+ });
521
+ const report = await rotateRuntimeSecret(target, "public-role-password", {
522
+ run,
523
+ http: fakeSqlApi().http,
524
+ sleep: async () => {},
525
+ generate: () => "new-pw",
526
+ });
527
+ expect(report.ok).toBe(false);
528
+ expect(report.error).toContain("the database uses it");
529
+ expect(report.error).toContain("Do not rotate again");
530
+ expect(report.error).toContain("gcloud run services update gw-svc");
531
+ expect(report.steps).toContain("set a new password on database role public_role");
532
+ });
533
+
534
+ it("an admin-only secret rolls only the admin", async () => {
535
+ const { run, lines } = fakeGcloud(converged());
536
+ const report = await rotateRuntimeSecret(target, "internal-secret", {
537
+ run,
538
+ generate: () => "v",
539
+ });
540
+ expect(report.ok).toBe(true);
541
+ expect(lines().filter((l) => l.startsWith("run services update "))).toHaveLength(1);
542
+ expect(lines().some((l) => l.startsWith("run services update gw-svc"))).toBe(false);
543
+ expect(lines().some((l) => l.startsWith("sql "))).toBe(false);
544
+ });
545
+ });
546
+
547
+ describe("parseAdminConfig", () => {
548
+ const admin = (env: unknown[]) =>
549
+ JSON.stringify({
550
+ spec: {
551
+ template: {
552
+ metadata: {
553
+ annotations: {
554
+ "run.googleapis.com/network-interfaces": JSON.stringify([
555
+ { network: "net", subnetwork: "sub" },
556
+ ]),
557
+ },
558
+ },
559
+ spec: { containers: [{ image: "img@sha256:x", env }] },
560
+ },
561
+ },
562
+ });
563
+
564
+ it("marks an admin still on an inline-password URL (jobs on its image need upgrade first)", () => {
565
+ const cfg = parseAdminConfig(
566
+ admin([
567
+ { name: "ADMIN_DATABASE_URL", value: "postgres://admin_role:pw@10.0.0.3:5432/cms_admin" },
568
+ ]),
569
+ );
570
+ expect(cfg?.databaseHost).toBe("10.0.0.3");
571
+ expect(cfg?.secretEnv).toBe(false);
572
+ });
573
+
574
+ it("marks an admin that reads the password from Secret Manager", () => {
575
+ const cfg = parseAdminConfig(
576
+ admin([
577
+ { name: "ADMIN_DATABASE_URL", value: "postgresql://admin_role@10.0.0.3:5432/cms_admin" },
578
+ {
579
+ name: "ADMIN_DATABASE_PASSWORD",
580
+ valueFrom: {
581
+ secretKeyRef: { name: "caelo-production-postgres-password", key: "latest" },
582
+ },
583
+ },
584
+ ]),
585
+ );
586
+ expect(cfg?.secretEnv).toBe(true);
587
+ });
588
+ });
589
+
590
+ describe("migrationJobEnvArgs", () => {
591
+ it("gives the job password-less URLs and the password as a secret reference", () => {
592
+ const args = migrationJobEnvArgs("10.20.0.3");
593
+ expect(args).toEqual([
594
+ "--set-env-vars=ADMIN_DATABASE_URL=postgresql://admin_role@10.20.0.3:5432/cms_admin?sslmode=require,PUBLIC_ADMIN_DATABASE_URL=postgresql://admin_role@10.20.0.3:5432/cms_public?sslmode=require",
595
+ "--set-secrets=ADMIN_DATABASE_PASSWORD=caelo-production-postgres-password:latest,PUBLIC_ADMIN_DATABASE_PASSWORD=caelo-production-postgres-password:latest",
596
+ ]);
597
+ });
598
+ });
599
+
600
+ describe("every Secret Manager secret honours the install's replication", () => {
601
+ const { readdirSync, readFileSync, statSync } = require("node:fs") as typeof import("node:fs");
602
+ const { join, relative } = require("node:path") as typeof import("node:path");
603
+ const root = join(import.meta.dir, "..");
604
+ function sources(dir: string): string[] {
605
+ return readdirSync(dir).flatMap((name) => {
606
+ const path = join(dir, name);
607
+ if (name === "node_modules" || name === "dist") return [];
608
+ if (statSync(path).isDirectory()) return sources(path);
609
+ return /\.ts$/.test(name) && !/\.test\.ts$/.test(name) ? [path] : [];
610
+ });
611
+ }
612
+ const files = [...sources(join(root, "src")), ...sources(join(root, "stacks"))];
613
+
614
+ it("creates secrets through gcloud only in ensureGeneratedSecrets", () => {
615
+ const creators = files.filter((f) => /"secrets",\s*"create"/.test(readFileSync(f, "utf8")));
616
+ expect(creators.map((f) => relative(root, f))).toEqual(["src/runtime-secrets.ts"]);
617
+ });
618
+
619
+ it("creates Pulumi secrets only with the stack's secretReplication", () => {
620
+ for (const f of files) {
621
+ const src = readFileSync(f, "utf8");
622
+ const made = src.match(/new gcp\.secretmanager\.Secret\(/g)?.length ?? 0;
623
+ const honoured = src.match(/replication: secretReplication \}/g)?.length ?? 0;
624
+ expect(`${relative(root, f)}: ${made}`).toBe(`${relative(root, f)}: ${honoured}`);
625
+ }
626
+ });
627
+ });