@bugmole/cli 0.4.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 (308) hide show
  1. package/.bugmole.env.example +20 -0
  2. package/LICENSE +7 -0
  3. package/README.md +293 -0
  4. package/TESTING.md +117 -0
  5. package/bugmole.config.yaml +39 -0
  6. package/package.json +85 -0
  7. package/scripts/billing/paypal-setup.mjs +121 -0
  8. package/scripts/bugmole-continue.ts +318 -0
  9. package/scripts/bugmole-init.ts +188 -0
  10. package/scripts/bugmole.cjs +17 -0
  11. package/scripts/bugmole.test.ts +344 -0
  12. package/scripts/bugmole.ts +657 -0
  13. package/scripts/ensure-maestro.cjs +79 -0
  14. package/scripts/ios-tunnel-keeper.sh +45 -0
  15. package/scripts/ios-wda-keeper.sh +66 -0
  16. package/scripts/sync-plan-catalog.d.mts +3 -0
  17. package/scripts/sync-plan-catalog.mjs +16 -0
  18. package/scripts/ui-parity-diff.py +65 -0
  19. package/scripts/ui-parity-requirements.txt +1 -0
  20. package/scripts/verify-manage-to-plans.mts +194 -0
  21. package/spec/app-ui-audit.schema.json +176 -0
  22. package/spec/blockers.yaml +79 -0
  23. package/spec/bugs.index.json +42 -0
  24. package/spec/design-dna.schema.json +38 -0
  25. package/spec/domain_rules.yaml +24 -0
  26. package/spec/flows/flow_manage-to-plans-64c3c61b.yaml +8 -0
  27. package/spec/flows/flow_screen-to-evidence-6c3ab309.yaml +7 -0
  28. package/spec/journey_graph.yaml +126 -0
  29. package/spec/journeys.graph.json +2618 -0
  30. package/spec/plans/dashboard-smoke.flow.yaml +20 -0
  31. package/spec/plans/example.flow.yaml +99 -0
  32. package/spec/plans/flow_api-keys-to-logout-7396cd5d.flow.yaml +33 -0
  33. package/spec/plans/flow_api-keys-to-screen-157e10a6.flow.yaml +33 -0
  34. package/spec/plans/flow_manage-to-audits-176c1eb8.flow.yaml +33 -0
  35. package/spec/plans/flow_manage-to-blockers-633282c8.flow.yaml +112 -0
  36. package/spec/plans/flow_manage-to-devices-ad57e202.flow.yaml +33 -0
  37. package/spec/plans/flow_manage-to-logout-86c54656.flow.yaml +33 -0
  38. package/spec/plans/flow_manage-to-manage-resource-action-6325f99a.flow.yaml +185 -0
  39. package/spec/plans/flow_manage-to-parity-issues-01fa2579.flow.yaml +34 -0
  40. package/spec/plans/flow_manage-to-plans-64c3c61b.flow.yaml +123 -0
  41. package/spec/plans/flow_manage-to-screen-6857f915.flow.yaml +106 -0
  42. package/spec/plans/flow_manage-to-tasks-c5791c95.flow.yaml +33 -0
  43. package/spec/plans/flow_profile-to-screen-6045aa3d.flow.yaml +33 -0
  44. package/spec/plans/flow_register-to-api-auth-login-460a9cc8.flow.yaml +34 -0
  45. package/spec/plans/flow_screen-to-audits-07666356.flow.yaml +33 -0
  46. package/spec/plans/flow_screen-to-blockers-091d258b.flow.yaml +33 -0
  47. package/spec/plans/flow_screen-to-devices-cad72f75.flow.yaml +33 -0
  48. package/spec/plans/flow_screen-to-evidence-6c3ab309.flow.yaml +126 -0
  49. package/spec/plans/flow_screen-to-parity-issues-2218e3ad.flow.yaml +34 -0
  50. package/spec/plans/flow_screen-to-plans-8232a94f.flow.yaml +33 -0
  51. package/spec/plans/flow_screen-to-tasks-540671d1.flow.yaml +33 -0
  52. package/spec/plans/login.flow.yaml +22 -0
  53. package/spec/plans/owner-operations.flow.yaml +20 -0
  54. package/spec/project_config.yaml +55 -0
  55. package/spec/roles.yaml +30 -0
  56. package/spec/schema.md +394 -0
  57. package/spec/test-case-results.schema.json +62 -0
  58. package/spec/test-cases.schema.json +85 -0
  59. package/spec/ui-parity-audit.schema.json +194 -0
  60. package/spec/ui-reverse-engineering.schema.json +94 -0
  61. package/src/billing/plan-catalog.test.ts +46 -0
  62. package/src/billing/plan-catalog.ts +199 -0
  63. package/src/integrations/aws-sigv4.test.ts +42 -0
  64. package/src/integrations/aws-sigv4.ts +72 -0
  65. package/src/integrations/device-farm.ts +155 -0
  66. package/src/integrations/github-app.test.ts +57 -0
  67. package/src/integrations/github-app.ts +143 -0
  68. package/src/integrations/gitlab.ts +81 -0
  69. package/src/integrations/temp-email.test.ts +123 -0
  70. package/src/integrations/temp-email.ts +175 -0
  71. package/src/integrations/testflight-feedback.test.ts +51 -0
  72. package/src/integrations/testflight-feedback.ts +173 -0
  73. package/src/integrations/webdriver-client.ts +131 -0
  74. package/src/mcp/server.test.ts +1220 -0
  75. package/src/mcp/server.ts +3064 -0
  76. package/src/mcp/write-test-cases.test.ts +287 -0
  77. package/src/registry/api-key-client.ts +39 -0
  78. package/src/registry/control-plane-client.ts +212 -0
  79. package/src/registry/migrations/0001_registry.sql +47 -0
  80. package/src/registry/migrations/0002_device_authorizations.sql +23 -0
  81. package/src/registry/migrations/0003_project_environments.sql +25 -0
  82. package/src/registry/migrations/0004_device_authorization_email.sql +1 -0
  83. package/src/registry/migrations/0005_testing_control_plane.sql +55 -0
  84. package/src/registry/migrations/0006_workspaces.sql +36 -0
  85. package/src/registry/migrations/0007_project_apps.sql +26 -0
  86. package/src/registry/migrations/0008_agent_tasks.sql +30 -0
  87. package/src/registry/migrations/0009_journey_revisions.sql +17 -0
  88. package/src/registry/migrations/0010_agent_task_journey.sql +2 -0
  89. package/src/registry/migrations/0011_run_journey_revision.sql +1 -0
  90. package/src/registry/migrations/0012_canonical_flow_execution.sql +10 -0
  91. package/src/registry/migrations/0013_device_sessions.sql +22 -0
  92. package/src/registry/migrations/0014_agent_task_step.sql +1 -0
  93. package/src/registry/migrations/0015_agent_task_attempts.sql +6 -0
  94. package/src/registry/migrations/0016_github_issue_tracker.sql +54 -0
  95. package/src/registry/migrations/0017_run_targets.sql +7 -0
  96. package/src/registry/migrations/0018_run_fix_from.sql +4 -0
  97. package/src/registry/migrations/0019_workspace_flags.sql +9 -0
  98. package/src/registry/migrations/0020_orgs.sql +40 -0
  99. package/src/registry/migrations/0021_billing_core.sql +58 -0
  100. package/src/registry/migrations/0022_cloud_runners.sql +19 -0
  101. package/src/registry/migrations/0023_signup.sql +4 -0
  102. package/src/registry/migrations/0024_billing.sql +67 -0
  103. package/src/registry/migrations/0025_notifications.sql +47 -0
  104. package/src/registry/migrations/0026_repo_bindings.sql +28 -0
  105. package/src/registry/migrations/0027_feedback.sql +29 -0
  106. package/src/registry/migrations/0028_devices.sql +48 -0
  107. package/src/registry/migrations/0029_sso.sql +31 -0
  108. package/src/registry/migrations/0030_workspace_domains.sql +18 -0
  109. package/src/registry/migrations/0031_gitlab_and_teams.sql +45 -0
  110. package/src/registry/migrations/0032_personas.sql +15 -0
  111. package/src/registry/migrations/0033_bugmole_rename.sql +11 -0
  112. package/src/registry/task-scheduling.test.ts +100 -0
  113. package/src/registry/task-scheduling.ts +80 -0
  114. package/src/registry-worker/ai/platform-model.ts +77 -0
  115. package/src/registry-worker/ai/routes.test.ts +88 -0
  116. package/src/registry-worker/ai/routes.ts +90 -0
  117. package/src/registry-worker/artifacts.test.ts +98 -0
  118. package/src/registry-worker/artifacts.ts +85 -0
  119. package/src/registry-worker/billing/billing-core.test.ts +175 -0
  120. package/src/registry-worker/billing/checkout-routes.ts +209 -0
  121. package/src/registry-worker/billing/enforcement.ts +69 -0
  122. package/src/registry-worker/billing/entitlements.ts +108 -0
  123. package/src/registry-worker/billing/ledger.ts +186 -0
  124. package/src/registry-worker/billing/paypal/api.ts +259 -0
  125. package/src/registry-worker/billing/paypal/client.ts +91 -0
  126. package/src/registry-worker/billing/paypal/provider.ts +143 -0
  127. package/src/registry-worker/billing/paypal.test.ts +466 -0
  128. package/src/registry-worker/billing/provider.ts +114 -0
  129. package/src/registry-worker/billing/routes.ts +66 -0
  130. package/src/registry-worker/billing/subscriptions.ts +780 -0
  131. package/src/registry-worker/billing/thresholds.ts +107 -0
  132. package/src/registry-worker/core.ts +308 -0
  133. package/src/registry-worker/devices/devices.test.ts +185 -0
  134. package/src/registry-worker/devices/policy.ts +71 -0
  135. package/src/registry-worker/devices/routes.ts +453 -0
  136. package/src/registry-worker/domains/domains.test.ts +210 -0
  137. package/src/registry-worker/domains/routes.ts +139 -0
  138. package/src/registry-worker/domains.ts +88 -0
  139. package/src/registry-worker/email/sender.ts +75 -0
  140. package/src/registry-worker/env.d.ts +14716 -0
  141. package/src/registry-worker/features.ts +20 -0
  142. package/src/registry-worker/feedback/feedback.test.ts +230 -0
  143. package/src/registry-worker/feedback/format.ts +148 -0
  144. package/src/registry-worker/feedback/routes.ts +386 -0
  145. package/src/registry-worker/flags.ts +39 -0
  146. package/src/registry-worker/github/checks.test.ts +177 -0
  147. package/src/registry-worker/github/checks.ts +374 -0
  148. package/src/registry-worker/gitlab/checks.test.ts +141 -0
  149. package/src/registry-worker/gitlab/checks.ts +349 -0
  150. package/src/registry-worker/hooks.ts +54 -0
  151. package/src/registry-worker/index.ts +2077 -0
  152. package/src/registry-worker/jobs/index.ts +29 -0
  153. package/src/registry-worker/jobs/retention.ts +68 -0
  154. package/src/registry-worker/mcp/mcp.test.ts +355 -0
  155. package/src/registry-worker/mcp/routes.ts +215 -0
  156. package/src/registry-worker/mcp/token.ts +126 -0
  157. package/src/registry-worker/mcp/tools.ts +563 -0
  158. package/src/registry-worker/notifications/alerts.ts +212 -0
  159. package/src/registry-worker/notifications/notifications.test.ts +298 -0
  160. package/src/registry-worker/notifications/outbox.ts +83 -0
  161. package/src/registry-worker/notifications/routes.ts +280 -0
  162. package/src/registry-worker/notifications/secrets.ts +49 -0
  163. package/src/registry-worker/notifications/slack.ts +96 -0
  164. package/src/registry-worker/notifications/teams.ts +46 -0
  165. package/src/registry-worker/org/audit.ts +116 -0
  166. package/src/registry-worker/org/routes.test.ts +163 -0
  167. package/src/registry-worker/org/routes.ts +302 -0
  168. package/src/registry-worker/personas/personas.test.ts +78 -0
  169. package/src/registry-worker/personas/routes.ts +100 -0
  170. package/src/registry-worker/repo-triggers.ts +20 -0
  171. package/src/registry-worker/routes/index.ts +74 -0
  172. package/src/registry-worker/run-events.ts +24 -0
  173. package/src/registry-worker/runner/dispatch.ts +219 -0
  174. package/src/registry-worker/runner/jobs.ts +43 -0
  175. package/src/registry-worker/runner/metering.ts +82 -0
  176. package/src/registry-worker/runner/policy.ts +59 -0
  177. package/src/registry-worker/runner/routes.ts +171 -0
  178. package/src/registry-worker/runner/runner.test.ts +358 -0
  179. package/src/registry-worker/runner/tokens.ts +93 -0
  180. package/src/registry-worker/runs.test.ts +60 -0
  181. package/src/registry-worker/signup/policy.ts +57 -0
  182. package/src/registry-worker/signup/routes.ts +106 -0
  183. package/src/registry-worker/signup/signup.test.ts +81 -0
  184. package/src/registry-worker/sso/aegis.ts +141 -0
  185. package/src/registry-worker/sso/membership.ts +157 -0
  186. package/src/registry-worker/sso/routes.ts +458 -0
  187. package/src/registry-worker/sso/sso.test.ts +344 -0
  188. package/src/registry-worker/testing/d1-shim.ts +180 -0
  189. package/src/registry-worker/testing/harness.ts +137 -0
  190. package/src/runner-worker/index.ts +108 -0
  191. package/src/runtime/ai-analysis.ts +97 -0
  192. package/src/runtime/ai-exploration.test.ts +32 -0
  193. package/src/runtime/ai-exploration.ts +69 -0
  194. package/src/runtime/ai-repair.ts +74 -0
  195. package/src/runtime/ai-work.test.ts +99 -0
  196. package/src/runtime/android-screen-record.test.ts +75 -0
  197. package/src/runtime/android-screen-record.ts +192 -0
  198. package/src/runtime/app-understanding.test.ts +123 -0
  199. package/src/runtime/app-understanding.ts +201 -0
  200. package/src/runtime/appium-driver.test.ts +179 -0
  201. package/src/runtime/appium-driver.ts +295 -0
  202. package/src/runtime/blocker-resolution.test.ts +113 -0
  203. package/src/runtime/blocker-resolution.ts +111 -0
  204. package/src/runtime/browser-matrix.integration.test.ts +212 -0
  205. package/src/runtime/browser-matrix.test.ts +143 -0
  206. package/src/runtime/browser-matrix.ts +200 -0
  207. package/src/runtime/canonical-flow.test.ts +52 -0
  208. package/src/runtime/config-validate.ts +185 -0
  209. package/src/runtime/continuous-execution.ts +291 -0
  210. package/src/runtime/cursor-applescript.ts +573 -0
  211. package/src/runtime/cursor-cli-driver.test.ts +78 -0
  212. package/src/runtime/cursor-cli-driver.ts +156 -0
  213. package/src/runtime/cursor-driver-example.ts +117 -0
  214. package/src/runtime/cursor-driver-index.ts +65 -0
  215. package/src/runtime/cursor-driver-init.ts +277 -0
  216. package/src/runtime/cursor-driver-run.test.ts +15 -0
  217. package/src/runtime/cursor-driver-run.ts +323 -0
  218. package/src/runtime/cursor-driver.ts +332 -0
  219. package/src/runtime/cursor-llm-example.ts +90 -0
  220. package/src/runtime/cursor-llm.ts +206 -0
  221. package/src/runtime/cursor-mcp-monitor.ts +386 -0
  222. package/src/runtime/device-clouds/browserstack.ts +73 -0
  223. package/src/runtime/device-clouds/device-farm.ts +52 -0
  224. package/src/runtime/device-clouds/index.ts +92 -0
  225. package/src/runtime/device-clouds/kobiton.ts +70 -0
  226. package/src/runtime/device-clouds/targets.ts +44 -0
  227. package/src/runtime/device-clouds/types.ts +62 -0
  228. package/src/runtime/diff-proposal.ts +84 -0
  229. package/src/runtime/discovery-task.test.ts +29 -0
  230. package/src/runtime/discovery-task.ts +284 -0
  231. package/src/runtime/driver-recovery.ts +69 -0
  232. package/src/runtime/driver.ts +79 -0
  233. package/src/runtime/environment.test.ts +104 -0
  234. package/src/runtime/environment.ts +137 -0
  235. package/src/runtime/executor.test.ts +509 -0
  236. package/src/runtime/executor.ts +921 -0
  237. package/src/runtime/explorer.test.ts +101 -0
  238. package/src/runtime/explorer.ts +1013 -0
  239. package/src/runtime/failure-analysis.test.ts +111 -0
  240. package/src/runtime/failure-analysis.ts +272 -0
  241. package/src/runtime/fixtures/fake-maestro.sh +36 -0
  242. package/src/runtime/flow-language.test.ts +268 -0
  243. package/src/runtime/flow-language.ts +414 -0
  244. package/src/runtime/init-wizard.ts +354 -0
  245. package/src/runtime/ios-screen-record.test.ts +68 -0
  246. package/src/runtime/ios-screen-record.ts +155 -0
  247. package/src/runtime/journey-editor.ts +452 -0
  248. package/src/runtime/journey-evidence.test.ts +161 -0
  249. package/src/runtime/journey-evidence.ts +180 -0
  250. package/src/runtime/journey-graph.test.ts +257 -0
  251. package/src/runtime/journey-graph.ts +170 -0
  252. package/src/runtime/legacy-names.ts +32 -0
  253. package/src/runtime/llm-example.ts +105 -0
  254. package/src/runtime/llm.ts +527 -0
  255. package/src/runtime/local-browser.test.ts +45 -0
  256. package/src/runtime/local-browser.ts +48 -0
  257. package/src/runtime/local-registry-stub.test.ts +325 -0
  258. package/src/runtime/local-registry-stub.ts +803 -0
  259. package/src/runtime/maestro-driver.test.ts +84 -0
  260. package/src/runtime/maestro-driver.ts +209 -0
  261. package/src/runtime/mole-voice.ts +21 -0
  262. package/src/runtime/nav-crawl.test.ts +100 -0
  263. package/src/runtime/nav-crawl.ts +153 -0
  264. package/src/runtime/pipeline.test.ts +405 -0
  265. package/src/runtime/pipeline.ts +833 -0
  266. package/src/runtime/planner.test.ts +37 -0
  267. package/src/runtime/planner.ts +274 -0
  268. package/src/runtime/platform-ai.ts +76 -0
  269. package/src/runtime/playwright-driver.test.ts +93 -0
  270. package/src/runtime/playwright-driver.ts +620 -0
  271. package/src/runtime/project-spec.ts +140 -0
  272. package/src/runtime/record-run-verdicts.ts +68 -0
  273. package/src/runtime/reporter.test.ts +56 -0
  274. package/src/runtime/reporter.ts +158 -0
  275. package/src/runtime/reset.test.ts +44 -0
  276. package/src/runtime/reset.ts +61 -0
  277. package/src/runtime/reviewer.test.ts +73 -0
  278. package/src/runtime/reviewer.ts +158 -0
  279. package/src/runtime/run-job.ts +136 -0
  280. package/src/runtime/run-once.test.ts +207 -0
  281. package/src/runtime/run-once.ts +168 -0
  282. package/src/runtime/run.ts +132 -0
  283. package/src/runtime/screen-recording.ts +34 -0
  284. package/src/runtime/serve-gateway.test.ts +74 -0
  285. package/src/runtime/serve-gateway.ts +164 -0
  286. package/src/runtime/serve-worker.test.ts +23 -0
  287. package/src/runtime/serve-worker.ts +278 -0
  288. package/src/runtime/site-discovery.test.ts +168 -0
  289. package/src/runtime/site-discovery.ts +308 -0
  290. package/src/runtime/target-runner.ts +144 -0
  291. package/src/runtime/test-case-verdicts.test.ts +94 -0
  292. package/src/runtime/test-case-verdicts.ts +120 -0
  293. package/src/runtime/ui-reverse-engineering/coordinator.test.ts +59 -0
  294. package/src/runtime/ui-reverse-engineering/coordinator.ts +391 -0
  295. package/src/runtime/ui-reverse-engineering/types.ts +197 -0
  296. package/src/runtime/web-suite.test.ts +97 -0
  297. package/src/runtime/web-suite.ts +176 -0
  298. package/src/storage/create-object-store.ts +144 -0
  299. package/src/storage/keys.ts +34 -0
  300. package/src/storage/local-artifact-server.test.ts +314 -0
  301. package/src/storage/local-artifact-server.ts +357 -0
  302. package/src/storage/object-store.test.ts +28 -0
  303. package/src/storage/object-store.ts +101 -0
  304. package/src/storage/registry-object-store.ts +88 -0
  305. package/src/storage/remote-object-store.ts +104 -0
  306. package/src/storage/storage-directory.test.ts +43 -0
  307. package/src/storage/storage-directory.ts +24 -0
  308. package/tsconfig.json +24 -0
@@ -0,0 +1,344 @@
1
+ import assert from "node:assert/strict";
2
+ import { createHmac, randomUUID } from "node:crypto";
3
+ import test from "node:test";
4
+ import type { Env } from "../core.js";
5
+ import { call, createTestEnv, seedProject } from "../testing/harness.js";
6
+ import { drainOutbox } from "../notifications/outbox.js";
7
+ import { resetAegisTokenCache, verifyAegisEvent } from "./aegis.js";
8
+ import { pickRole } from "./membership.js";
9
+ import { SSO_GRACE_DAYS, syncSsoEntitlements } from "./routes.js";
10
+
11
+ const EVENTS_SECRET = "e".repeat(40);
12
+ const ORG_ID = "0b6c8a4e-1f0e-4a3e-9d0a-3c1f2e5d6a7b";
13
+
14
+ type AegisCall = { method: string; path: string; body: any };
15
+
16
+ function fakeAegis() {
17
+ const calls: AegisCall[] = [];
18
+ const org = {
19
+ id: ORG_ID,
20
+ name: "Acme",
21
+ externalRef: "acme",
22
+ entitlements: { sso: false, scim: false },
23
+ ssoRequired: false,
24
+ };
25
+ const doFetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
26
+ const url = new URL(String(input));
27
+ const method = init?.method ?? "GET";
28
+ if (url.pathname === "/token") {
29
+ assert.equal(new Headers(init?.headers).get("authorization"), `Basic ${btoa("bugmole-orgs:org-secret")}`);
30
+ assert.match(String(init?.body), /scope=organizations%3Amanage/);
31
+ return Response.json({ access_token: "aegis-token", expires_in: 300 });
32
+ }
33
+ assert.equal(new Headers(init?.headers).get("authorization"), "Bearer aegis-token");
34
+ const body = init?.body ? JSON.parse(String(init.body)) : undefined;
35
+ calls.push({ method, path: url.pathname, body });
36
+ if (method === "POST" && url.pathname === "/organizations") return Response.json({ ...org, name: body.name }, { status: 201 });
37
+ if (method === "PUT" && url.pathname === `/organizations/${ORG_ID}/entitlements`) {
38
+ org.entitlements = body;
39
+ return Response.json(org);
40
+ }
41
+ if (method === "GET" && url.pathname === `/organizations/${ORG_ID}`) {
42
+ return Response.json({ ...org, domains: [], connections: [], scimTokens: [], scimBaseUrl: "https://id.test/scim/v2/x", memberCount: 0, groupCount: 0 });
43
+ }
44
+ if (method === "PATCH" && url.pathname === `/organizations/${ORG_ID}`) return Response.json({ ...org, ...body });
45
+ if (method === "POST" && url.pathname === `/organizations/${ORG_ID}/connections`) {
46
+ return Response.json({ id: randomUUID(), ...body, oidcClientSecret: undefined, hasOidcClientSecret: Boolean(body.oidcClientSecret) }, { status: 201 });
47
+ }
48
+ if (method === "POST" && url.pathname === `/organizations/${ORG_ID}/scim-tokens`) {
49
+ if (!org.entitlements.scim) return Response.json({ error: "not_entitled", message: "no scim" }, { status: 403 });
50
+ return Response.json({ id: randomUUID(), token: "scim_secret", prefix: "scim_sec" }, { status: 201 });
51
+ }
52
+ if (method === "POST" && url.pathname.endsWith("/verify")) {
53
+ return Response.json({ error: "dns_verification_failed", message: "Add a TXT record" }, { status: 400 });
54
+ }
55
+ return Response.json({ error: "not_found" }, { status: 404 });
56
+ }) as unknown as typeof fetch;
57
+ return { calls, org, doFetch };
58
+ }
59
+
60
+ async function workspace(plan: string | null, aegis = fakeAegis()) {
61
+ resetAegisTokenCache();
62
+ const setup = createTestEnv({
63
+ FEATURE_SSO: "on",
64
+ AEGIS_ISSUER: "https://id.test",
65
+ AEGIS_ORG_CLIENT_ID: "bugmole-orgs",
66
+ AEGIS_ORG_CLIENT_SECRET: "org-secret",
67
+ AEGIS_EVENTS_SECRET: EVENTS_SECRET,
68
+ AEGIS_FETCH: aegis.doFetch,
69
+ REGISTRY_PUBLIC_URL: "https://registry.test",
70
+ } as Partial<Env>);
71
+ const project = await seedProject(setup.env);
72
+ if (plan) setup.db.db.prepare("INSERT INTO workspace_plans (workspace_id, plan) VALUES (?, ?)").run(project.workspaceId, plan);
73
+ return { ...setup, ...project, aegis };
74
+ }
75
+
76
+ function setPlan(db: any, workspaceId: string, plan: string) {
77
+ db.db.prepare("DELETE FROM workspace_plans WHERE workspace_id = ?").run(workspaceId);
78
+ db.db.prepare("INSERT INTO workspace_plans (workspace_id, plan) VALUES (?, ?)").run(workspaceId, plan);
79
+ }
80
+
81
+ function orgHeaders(groups: string[] | null) {
82
+ return {
83
+ "x-aegis-org": ORG_ID,
84
+ ...(groups ? { "x-aegis-groups": encodeURIComponent(JSON.stringify(groups)) } : {}),
85
+ "x-aegis-email": "grace@acme.test",
86
+ "x-aegis-email-verified": "true",
87
+ };
88
+ }
89
+
90
+ function signedEvent(event: Record<string, unknown>, secret = EVENTS_SECRET, at = Math.floor(Date.now() / 1000)) {
91
+ const body = JSON.stringify({ id: randomUUID(), organizationId: ORG_ID, externalRef: "acme", occurredAt: new Date().toISOString(), ...event });
92
+ const signature = `t=${at},v1=${createHmac("sha256", secret).update(`${at}.${body}`).digest("hex")}`;
93
+ return { body, headers: { "x-aegis-signature": signature, "content-type": "application/json" } };
94
+ }
95
+
96
+ test("SSO needs Business, and only owners and admins manage it", async () => {
97
+ const { env, owner, workspaceId, db } = await workspace("team");
98
+ const overview = await call(env, "GET", `/api/workspaces/${workspaceId}/sso`, { as: owner });
99
+ assert.equal(overview.status, 200);
100
+ assert.equal(overview.json.available, false);
101
+ assert.match(overview.json.reason, /Business and Enterprise/);
102
+ assert.equal((await call(env, "POST", `/api/workspaces/${workspaceId}/sso/enable`, { as: owner })).status, 402);
103
+
104
+ db.db.prepare("INSERT INTO aegis_users (id) VALUES ('user_member')").run();
105
+ db.db.prepare("INSERT INTO workspace_memberships (workspace_id, aegis_id, role, status) VALUES (?, 'user_member', 'member', 'active')").run(workspaceId);
106
+ assert.equal((await call(env, "GET", `/api/workspaces/${workspaceId}/sso`, { as: "user_member" })).status, 403);
107
+ });
108
+
109
+ test("turning SSO on links an Aegis organization with the plan's entitlements", async () => {
110
+ const { env, owner, workspaceId, aegis, db } = await workspace("business");
111
+ const enabled = await call(env, "POST", `/api/workspaces/${workspaceId}/sso/enable`, { as: owner });
112
+ assert.equal(enabled.status, 201);
113
+ assert.deepEqual(aegis.calls[0], {
114
+ method: "POST",
115
+ path: "/organizations",
116
+ body: { name: "Acme", externalRef: workspaceId, eventsUrl: "https://registry.test/api/aegis/events" },
117
+ });
118
+ assert.deepEqual(aegis.calls[1].body, { sso: true, scim: false });
119
+ const row = db.db.prepare("SELECT aegis_org_id AS org, sso_entitlement AS ent FROM workspaces WHERE id = ?").get(workspaceId) as any;
120
+ assert.deepEqual({ ...row }, { org: ORG_ID, ent: "sso" });
121
+
122
+ const overview = await call(env, "GET", `/api/workspaces/${workspaceId}/sso`, { as: owner });
123
+ assert.equal(overview.json.linked, true);
124
+ assert.equal(overview.json.organization.id, ORG_ID);
125
+
126
+ // Only known fields go to Aegis, and the audit log never holds the secret.
127
+ const created = await call(env, "POST", `/api/workspaces/${workspaceId}/sso/connections`, {
128
+ as: owner,
129
+ body: { name: "Okta", protocol: "OIDC", oidcIssuer: "https://acme.okta.com", oidcClientId: "c", oidcClientSecret: "top-secret", tenantId: "someone-else" },
130
+ });
131
+ assert.equal(created.status, 201);
132
+ const sent = aegis.calls.find((c) => c.path.endsWith("/connections"))!;
133
+ assert.equal("tenantId" in sent.body, false);
134
+ assert.equal(sent.body.oidcClientSecret, "top-secret");
135
+ const audit = db.db.prepare("SELECT data_json FROM audit_log WHERE action = 'sso.connection.created'").get() as any;
136
+ assert.ok(audit);
137
+ assert.doesNotMatch(audit.data_json, /top-secret/);
138
+
139
+ // Aegis errors reach the admin as they are.
140
+ const verify = await call(env, "POST", `/api/workspaces/${workspaceId}/sso/domains/${randomUUID()}/verify`, { as: owner });
141
+ assert.equal(verify.status, 400);
142
+ assert.equal(verify.json.error, "Add a TXT record");
143
+
144
+ // Business has no SCIM.
145
+ assert.equal((await call(env, "POST", `/api/workspaces/${workspaceId}/sso/scim-tokens`, { as: owner, body: { name: "Okta" } })).status, 402);
146
+ // Path parameters must be ids.
147
+ assert.equal((await call(env, "DELETE", `/api/workspaces/${workspaceId}/sso/connections/..%2F..%2Fentitlements`, { as: owner })).status, 404);
148
+ });
149
+
150
+ test("Enterprise workspaces can create SCIM tokens", async () => {
151
+ const { env, owner, workspaceId } = await workspace("enterprise");
152
+ assert.equal((await call(env, "POST", `/api/workspaces/${workspaceId}/sso/enable`, { as: owner })).status, 201);
153
+ const token = await call(env, "POST", `/api/workspaces/${workspaceId}/sso/scim-tokens`, { as: owner, body: { name: "Okta" } });
154
+ assert.equal(token.status, 201);
155
+ assert.equal(token.json.token, "scim_secret");
156
+ });
157
+
158
+ test("IdP groups decide the role of SSO members, and invited members keep theirs", async () => {
159
+ const { env, owner, workspaceId, db } = await workspace("business");
160
+ await call(env, "POST", `/api/workspaces/${workspaceId}/sso/enable`, { as: owner });
161
+ const mapped = await call(env, "PUT", `/api/workspaces/${workspaceId}/sso/group-roles`, {
162
+ as: owner,
163
+ body: { mappings: [{ group: "QA Leads", role: "admin" }, { group: "Finance", role: "billing" }] },
164
+ });
165
+ assert.equal(mapped.status, 200);
166
+ assert.equal(mapped.json.mappings.length, 2);
167
+ assert.equal((await call(env, "PUT", `/api/workspaces/${workspaceId}/sso/group-roles`, {
168
+ as: owner, body: { mappings: [{ group: "Root", role: "owner" }] },
169
+ })).status, 422, "groups never grant ownership");
170
+
171
+ const role = (id: string) => (db.db.prepare("SELECT role, source FROM workspace_memberships WHERE workspace_id = ? AND aegis_id = ?").get(workspaceId, id) as any);
172
+
173
+ // First request from a new SSO member creates their membership.
174
+ const first = await call(env, "GET", `/api/workspaces/${workspaceId}/members`, { as: "user_grace", headers: orgHeaders(["Finance", "QA Leads"]) });
175
+ assert.equal(first.status, 200);
176
+ assert.deepEqual({ ...role("user_grace") }, { role: "admin", source: "sso" });
177
+
178
+ // Group changes follow on the next request.
179
+ await call(env, "GET", "/api/workspaces", { as: "user_grace", headers: orgHeaders(["Finance"]) });
180
+ assert.equal(role("user_grace").role, "billing");
181
+ await call(env, "GET", "/api/workspaces", { as: "user_grace", headers: orgHeaders([]) });
182
+ assert.equal(role("user_grace").role, "member", "no matching group falls back to the default role");
183
+
184
+ // With no default role, people outside the mapped groups get no access.
185
+ assert.equal((await call(env, "PATCH", `/api/workspaces/${workspaceId}/sso`, { as: owner, body: { defaultRole: "none" } })).status, 200);
186
+ await call(env, "GET", "/api/workspaces", { as: "user_grace", headers: orgHeaders(["Sales"]) });
187
+ assert.equal(role("user_grace"), undefined);
188
+ assert.equal((await call(env, "GET", `/api/workspaces/${workspaceId}/members`, { as: "user_grace", headers: orgHeaders(["Sales"]) })).status, 403);
189
+
190
+ // The owner signed up normally; their groups don't change anything.
191
+ await call(env, "GET", "/api/workspaces", { as: owner, headers: orgHeaders(["Sales"]) });
192
+ assert.equal(role(owner).role, "owner");
193
+
194
+ // Requests after sign-in carry no groups and leave the role alone.
195
+ await call(env, "GET", "/api/workspaces", { as: "user_ada", headers: orgHeaders(["QA Leads"]) });
196
+ assert.equal(role("user_ada").role, "admin");
197
+ await call(env, "GET", "/api/workspaces", { as: "user_ada", headers: orgHeaders(null) });
198
+ assert.equal(role("user_ada").role, "admin");
199
+
200
+ // Someone who isn't in a linked organization is unaffected.
201
+ const other = { ...orgHeaders(["QA Leads"]), "x-aegis-org": randomUUID() };
202
+ await call(env, "GET", "/api/workspaces", { as: "user_stranger", headers: other });
203
+ assert.equal(role("user_stranger"), undefined);
204
+ });
205
+
206
+ test("Aegis events are verified, applied once, and deprovisioning removes access", async () => {
207
+ const { env, owner, workspaceId, db } = await workspace("business");
208
+ await call(env, "POST", `/api/workspaces/${workspaceId}/sso/enable`, { as: owner });
209
+ await call(env, "GET", "/api/workspaces", { as: "user_grace", headers: orgHeaders([]) });
210
+ db.db.prepare("INSERT INTO api_keys (id, aegis_id, name, token_hash, scopes_json) VALUES ('key_1', 'user_grace', 'ci', 'hash', '[]')").run();
211
+
212
+ const forged = signedEvent({ type: "user.deprovisioned", data: { userId: "user_grace" } }, "wrong-secret");
213
+ assert.equal((await call(env, "POST", "/api/aegis/events", { body: forged.body, headers: forged.headers })).status, 401);
214
+ const stale = signedEvent({ type: "user.deprovisioned", data: { userId: "user_grace" } }, EVENTS_SECRET, Math.floor(Date.now() / 1000) - 600);
215
+ assert.equal((await call(env, "POST", "/api/aegis/events", { body: stale.body, headers: stale.headers })).status, 401);
216
+ const otherWorkspace = signedEvent({ type: "user.deprovisioned", externalRef: "someone-else", data: { userId: "user_grace" } });
217
+ assert.equal((await call(env, "POST", "/api/aegis/events", { body: otherWorkspace.body, headers: otherWorkspace.headers })).json.ignored, true);
218
+
219
+ const event = signedEvent({ type: "user.deprovisioned", data: { userId: "user_grace", email: "grace@acme.test" } });
220
+ const handled = await call(env, "POST", "/api/aegis/events", { body: event.body, headers: event.headers });
221
+ assert.equal(handled.status, 202);
222
+ assert.equal((db.db.prepare("SELECT COUNT(*) AS n FROM workspace_memberships WHERE aegis_id = 'user_grace'").get() as any).n, 0);
223
+ assert.ok((db.db.prepare("SELECT revoked_at FROM api_keys WHERE id = 'key_1'").get() as any).revoked_at);
224
+ const again = await call(env, "POST", "/api/aegis/events", { body: event.body, headers: event.headers });
225
+ assert.equal(again.json.duplicate, true);
226
+
227
+ // A leftover dashboard session can't bring them back.
228
+ const blocked = await call(env, "GET", "/api/workspaces", { as: "user_grace", headers: orgHeaders(null) });
229
+ assert.equal(blocked.status, 401);
230
+ assert.equal(blocked.json.code, "account_deprovisioned");
231
+ const audit = db.db.prepare("SELECT actor_type FROM audit_log WHERE action = 'sso.member.deprovisioned'").get() as any;
232
+ assert.equal(audit.actor_type, "scim");
233
+
234
+ // Reactivation lets them in again at their next sign-in.
235
+ const back = signedEvent({ type: "user.reactivated", data: { userId: "user_grace" } });
236
+ await call(env, "POST", "/api/aegis/events", { body: back.body, headers: back.headers });
237
+ assert.equal((await call(env, "GET", `/api/workspaces/${workspaceId}/members`, { as: "user_grace", headers: orgHeaders([]) })).status, 200);
238
+
239
+ // Group changes from SCIM apply without a sign-in.
240
+ await call(env, "PUT", `/api/workspaces/${workspaceId}/sso/group-roles`, { as: owner, body: { mappings: [{ group: "Admins", role: "admin" }] } });
241
+ const groups = signedEvent({ type: "user.groups_changed", data: { userId: "user_grace", groups: ["Admins"] } });
242
+ await call(env, "POST", "/api/aegis/events", { body: groups.body, headers: groups.headers });
243
+ assert.equal((db.db.prepare("SELECT role FROM workspace_memberships WHERE aegis_id = 'user_grace'").get() as any).role, "admin");
244
+ });
245
+
246
+ test("losing SSO starts a 14-day notice before Aegis turns it off", async () => {
247
+ const { env, owner, workspaceId, db, aegis, email } = await workspace("business");
248
+ await call(env, "POST", `/api/workspaces/${workspaceId}/sso/enable`, { as: owner });
249
+ db.db.prepare("UPDATE aegis_users SET email = 'owner@acme.test' WHERE id = ?").run(owner);
250
+ const now = new Date("2026-10-01T00:00:00Z");
251
+
252
+ setPlan(db, workspaceId, "team");
253
+ await syncSsoEntitlements(env, workspaceId, now);
254
+ const row = () => db.db.prepare("SELECT sso_entitlement AS ent, sso_grace_until AS grace FROM workspaces WHERE id = ?").get(workspaceId) as any;
255
+ assert.equal(row().ent, "sso");
256
+ assert.equal(row().grace, new Date(now.getTime() + SSO_GRACE_DAYS * 86_400_000).toISOString());
257
+ assert.deepEqual(aegis.org.entitlements, { sso: true, scim: false }, "still on during the notice");
258
+ await drainOutbox(env, now);
259
+ assert.equal(email.sent.length, 1);
260
+ assert.match(email.sent[0].subject, /Single sign-on for Acme ends on 2026-10-15/);
261
+
262
+ // A daily re-sync during the notice changes nothing and sends no second email.
263
+ await syncSsoEntitlements(env, workspaceId, new Date("2026-10-05T00:00:00Z"));
264
+ assert.equal(aegis.org.entitlements.sso, true);
265
+ await drainOutbox(env, new Date("2026-10-05T00:00:00Z"));
266
+ assert.equal(email.sent.length, 1);
267
+
268
+ await syncSsoEntitlements(env, workspaceId, new Date("2026-10-16T00:00:00Z"));
269
+ assert.deepEqual(aegis.org.entitlements, { sso: false, scim: false });
270
+ assert.deepEqual({ ...row() }, { ent: "off", grace: null });
271
+
272
+ // Upgrading again turns it straight back on.
273
+ setPlan(db, workspaceId, "business");
274
+ await syncSsoEntitlements(env, workspaceId, new Date("2026-10-20T00:00:00Z"));
275
+ assert.deepEqual(aegis.org.entitlements, { sso: true, scim: false });
276
+ assert.deepEqual({ ...row() }, { ent: "sso", grace: null });
277
+ });
278
+
279
+ test("moving from Enterprise to Business keeps SCIM through the notice", async () => {
280
+ const { env, owner, workspaceId, db, aegis } = await workspace("enterprise");
281
+ await call(env, "POST", `/api/workspaces/${workspaceId}/sso/enable`, { as: owner });
282
+ assert.deepEqual(aegis.org.entitlements, { sso: true, scim: true });
283
+ setPlan(db, workspaceId, "business");
284
+ const now = new Date("2026-10-01T00:00:00Z");
285
+ await syncSsoEntitlements(env, workspaceId, now);
286
+ assert.deepEqual(aegis.org.entitlements, { sso: true, scim: true });
287
+ await syncSsoEntitlements(env, workspaceId, new Date("2026-10-16T00:00:00Z"));
288
+ assert.deepEqual(aegis.org.entitlements, { sso: true, scim: false });
289
+ });
290
+
291
+ test("a plan change queues an entitlement sync for linked workspaces", async () => {
292
+ const { env, owner, workspaceId, aegis } = await workspace("business");
293
+ const { planChangedHooks } = await import("../hooks.js");
294
+ for (const hook of planChangedHooks) await hook(env, { workspaceId, from: "business", to: "team" });
295
+ assert.equal(aegis.calls.length, 0, "not linked yet: nothing to sync");
296
+ await call(env, "POST", `/api/workspaces/${workspaceId}/sso/enable`, { as: owner });
297
+ const before = aegis.calls.length;
298
+ for (const hook of planChangedHooks) await hook(env, { workspaceId, from: "business", to: "team" });
299
+ await drainOutbox(env, new Date());
300
+ assert.ok(aegis.calls.slice(before).some((c) => c.path.endsWith("/entitlements")));
301
+ });
302
+
303
+ test("without a separate org client, the Bugmole client is used", async () => {
304
+ const aegis = fakeAegis();
305
+ resetAegisTokenCache();
306
+ const tokenAuth: string[] = [];
307
+ const setup = createTestEnv({
308
+ FEATURE_SSO: "on",
309
+ AEGIS_ISSUER: "https://id.test",
310
+ AEGIS_EVENTS_SECRET: EVENTS_SECRET,
311
+ REGISTRY_PUBLIC_URL: "https://registry.test",
312
+ AEGIS_FETCH: (async (input: RequestInfo | URL, init?: RequestInit) => {
313
+ if (new URL(String(input)).pathname === "/token") {
314
+ tokenAuth.push(new Headers(init?.headers).get("authorization") ?? "");
315
+ return Response.json({ access_token: "aegis-token", expires_in: 300 });
316
+ }
317
+ return aegis.doFetch(input, init);
318
+ }) as typeof fetch,
319
+ } as Partial<Env>);
320
+ const project = await seedProject(setup.env);
321
+ setup.db.db.prepare("INSERT INTO workspace_plans (workspace_id, plan) VALUES (?, ?)").run(project.workspaceId, "business");
322
+ const enabled = await call(setup.env, "POST", `/api/workspaces/${project.workspaceId}/sso/enable`, { as: project.owner });
323
+ assert.equal(enabled.status, 201);
324
+ assert.deepEqual(tokenAuth, [`Basic ${btoa(`valkyrie-qa-platform:${setup.env.AEGIS_CLIENT_SECRET}`)}`]);
325
+ });
326
+
327
+ test("role picking prefers the most privileged match", () => {
328
+ const mappings = [{ group: "a", role: "member" }, { group: "b", role: "admin" }, { group: "c", role: "billing" }];
329
+ assert.equal(pickRole(mappings, ["c", "a", "b"], "none"), "admin");
330
+ assert.equal(pickRole(mappings, ["c"], "member"), "billing");
331
+ assert.equal(pickRole(mappings, ["z"], "member"), "member");
332
+ assert.equal(pickRole(mappings, ["z"], "none"), null);
333
+ assert.equal(pickRole([{ group: "a", role: "owner" }], ["a"], "none"), null);
334
+ });
335
+
336
+ test("event signatures must match and be recent", async () => {
337
+ const body = '{"a":1}';
338
+ const at = 1_800_000_000;
339
+ const good = `t=${at},v1=${createHmac("sha256", EVENTS_SECRET).update(`${at}.${body}`).digest("hex")}`;
340
+ assert.equal(await verifyAegisEvent(EVENTS_SECRET, good, body, at * 1000), true);
341
+ assert.equal(await verifyAegisEvent(EVENTS_SECRET, good, '{"a":2}', at * 1000), false);
342
+ assert.equal(await verifyAegisEvent(EVENTS_SECRET, good, body, (at + 301) * 1000), false);
343
+ assert.equal(await verifyAegisEvent(EVENTS_SECRET, null, body, at * 1000), false);
344
+ });
@@ -0,0 +1,180 @@
1
+ // In-memory stand-ins for the registry's Cloudflare bindings, so route tests
2
+ // run under `tsx --test` against the real migrations instead of mocks of SQL.
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+ import { DatabaseSync, type SQLInputValue } from "node:sqlite";
6
+
7
+ const migrationsDir = path.resolve(import.meta.dirname, "../../registry/migrations");
8
+
9
+ type Row = Record<string, unknown>;
10
+
11
+ function bindable(value: unknown): SQLInputValue {
12
+ // D1 refuses undefined; mirror that so tests catch the same bug production would.
13
+ if (value === undefined) throw new Error("D1_TYPE_ERROR: Type 'undefined' not supported for value 'undefined'");
14
+ if (typeof value === "boolean") return value ? 1 : 0;
15
+ if (value instanceof ArrayBuffer) return new Uint8Array(value);
16
+ return value as SQLInputValue;
17
+ }
18
+
19
+ class ShimStatement {
20
+ constructor(
21
+ private readonly db: DatabaseSync,
22
+ readonly sql: string,
23
+ private readonly params: SQLInputValue[] = [],
24
+ ) {}
25
+
26
+ bind(...values: unknown[]): ShimStatement {
27
+ return new ShimStatement(this.db, this.sql, values.map(bindable));
28
+ }
29
+
30
+ private statement() {
31
+ return this.db.prepare(this.sql);
32
+ }
33
+
34
+ async first<T = Row>(column?: string): Promise<T | null> {
35
+ const row = this.statement().get(...this.params) as Row | undefined;
36
+ if (!row) return null;
37
+ return (column ? (row[column] as T) : ({ ...row } as T)) ?? null;
38
+ }
39
+
40
+ async all<T = Row>(): Promise<{ results: T[]; success: true; meta: { changes: number } }> {
41
+ const rows = this.statement().all(...this.params) as Row[];
42
+ return { results: rows.map((row) => ({ ...row }) as T), success: true, meta: { changes: 0 } };
43
+ }
44
+
45
+ async run<T = Row>(): Promise<{ results: T[]; success: true; meta: { changes: number; last_row_id: number } }> {
46
+ return this.runSync<T>();
47
+ }
48
+
49
+ runSync<T = Row>() {
50
+ const statement = this.statement();
51
+ if (statement.columns().length > 0) {
52
+ const rows = statement.all(...this.params) as Row[];
53
+ const changes = Number((this.db.prepare("SELECT changes() AS c").get() as { c: number }).c);
54
+ return { results: rows as T[], success: true as const, meta: { changes, last_row_id: 0 } };
55
+ }
56
+ const info = statement.run(...this.params);
57
+ return {
58
+ results: [] as T[],
59
+ success: true as const,
60
+ meta: { changes: Number(info.changes), last_row_id: Number(info.lastInsertRowid) },
61
+ };
62
+ }
63
+
64
+ async raw<T = unknown[]>(): Promise<T[]> {
65
+ const statement = this.statement();
66
+ statement.setReturnArrays(true);
67
+ return statement.all(...this.params) as T[];
68
+ }
69
+ }
70
+
71
+ export class D1Shim {
72
+ readonly db = new DatabaseSync(":memory:");
73
+
74
+ constructor({ migrate = true }: { migrate?: boolean } = {}) {
75
+ this.db.exec("PRAGMA foreign_keys = ON");
76
+ if (migrate) this.migrate();
77
+ }
78
+
79
+ migrate(): void {
80
+ const files = fs.readdirSync(migrationsDir).filter((name) => name.endsWith(".sql")).sort();
81
+ for (const file of files) this.db.exec(fs.readFileSync(path.join(migrationsDir, file), "utf8"));
82
+ }
83
+
84
+ prepare(sql: string): ShimStatement {
85
+ return new ShimStatement(this.db, sql);
86
+ }
87
+
88
+ async batch(statements: ShimStatement[]) {
89
+ // D1 runs a batch as one transaction: all statements apply or none do.
90
+ this.db.exec("BEGIN");
91
+ try {
92
+ const results = statements.map((statement) => statement.runSync());
93
+ this.db.exec("COMMIT");
94
+ return results;
95
+ } catch (error) {
96
+ this.db.exec("ROLLBACK");
97
+ throw error;
98
+ }
99
+ }
100
+
101
+ async exec(sql: string) {
102
+ this.db.exec(sql);
103
+ return { count: 1, duration: 0 };
104
+ }
105
+ }
106
+
107
+ type StoredObject = { body: Uint8Array; contentType?: string; uploaded: Date };
108
+
109
+ export class R2Shim {
110
+ readonly objects = new Map<string, StoredObject>();
111
+
112
+ async put(key: string, value: string | ArrayBuffer | Uint8Array | ReadableStream | null, options?: { httpMetadata?: { contentType?: string } }) {
113
+ const body = typeof value === "string"
114
+ ? new TextEncoder().encode(value)
115
+ : value instanceof ReadableStream || value === null
116
+ ? new Uint8Array(await new Response(value).arrayBuffer())
117
+ : new Uint8Array(value as ArrayBuffer);
118
+ this.objects.set(key, { body, contentType: options?.httpMetadata?.contentType, uploaded: new Date() });
119
+ return { key, size: body.byteLength };
120
+ }
121
+
122
+ async get(key: string) {
123
+ const object = this.objects.get(key);
124
+ if (!object) return null;
125
+ return {
126
+ key,
127
+ size: object.body.byteLength,
128
+ uploaded: object.uploaded,
129
+ httpMetadata: { contentType: object.contentType },
130
+ body: new Blob([object.body]).stream(),
131
+ arrayBuffer: async () => object.body.slice().buffer,
132
+ text: async () => new TextDecoder().decode(object.body),
133
+ writeHttpMetadata(headers: Headers) {
134
+ if (object.contentType) headers.set("content-type", object.contentType);
135
+ },
136
+ };
137
+ }
138
+
139
+ async head(key: string) {
140
+ const object = this.objects.get(key);
141
+ return object ? { key, size: object.body.byteLength, uploaded: object.uploaded } : null;
142
+ }
143
+
144
+ async delete(keys: string | string[]) {
145
+ for (const key of Array.isArray(keys) ? keys : [keys]) this.objects.delete(key);
146
+ }
147
+
148
+ async list(options: { prefix?: string; cursor?: string; limit?: number } = {}) {
149
+ const limit = options.limit ?? 1000;
150
+ const all = [...this.objects.keys()].filter((key) => key.startsWith(options.prefix ?? "")).sort();
151
+ const start = options.cursor ? Number(options.cursor) : 0;
152
+ const page = all.slice(start, start + limit);
153
+ const truncated = start + limit < all.length;
154
+ return {
155
+ objects: page.map((key) => ({ key, size: this.objects.get(key)!.body.byteLength, uploaded: this.objects.get(key)!.uploaded })),
156
+ truncated,
157
+ cursor: truncated ? String(start + limit) : undefined,
158
+ delimitedPrefixes: [],
159
+ };
160
+ }
161
+ }
162
+
163
+ /** Records what the registry broadcasts over the live-updates Durable Object. */
164
+ export class CoordinatorShim {
165
+ readonly broadcasts: Array<{ name: string; event: unknown }> = [];
166
+
167
+ idFromName(name: string) {
168
+ return { name, toString: () => name };
169
+ }
170
+
171
+ get(id: { name: string }) {
172
+ return {
173
+ fetch: async (input: RequestInfo | URL, init?: RequestInit) => {
174
+ const request = input instanceof Request ? input : new Request(input, init);
175
+ if (request.method === "POST") this.broadcasts.push({ name: id.name, event: JSON.parse(await request.text()) });
176
+ return new Response("ok");
177
+ },
178
+ };
179
+ }
180
+ }
@@ -0,0 +1,137 @@
1
+ // Drives the real registry Worker in-process: requests go through its router,
2
+ // storage is the in-memory D1/R2 shims with every migration applied.
3
+ import worker from "../index.js";
4
+ import type { Env } from "../core.js";
5
+ import { CoordinatorShim, D1Shim, R2Shim } from "./d1-shim.js";
6
+ import type { EmailMessage, EmailSender } from "../email/sender.js";
7
+
8
+ export const TEST_SERVICE_SECRET = "test-service-secret";
9
+
10
+ export class RecordingEmailSender implements EmailSender {
11
+ readonly sent: EmailMessage[] = [];
12
+ async send(message: EmailMessage): Promise<void> {
13
+ this.sent.push(message);
14
+ }
15
+ }
16
+
17
+ export type TestEnv = Env & {
18
+ DB: D1Database;
19
+ ARTIFACTS: R2Bucket;
20
+ RUNS_COORDINATOR: DurableObjectNamespace;
21
+ };
22
+
23
+ export function createTestEnv(overrides: Partial<Env> = {}) {
24
+ const db = new D1Shim();
25
+ const artifacts = new R2Shim();
26
+ const coordinator = new CoordinatorShim();
27
+ const email = new RecordingEmailSender();
28
+ const env = {
29
+ DB: db as unknown as D1Database,
30
+ ARTIFACTS: artifacts as unknown as R2Bucket,
31
+ RUNS_COORDINATOR: coordinator as unknown as DurableObjectNamespace,
32
+ AEGIS_CLIENT_ID: "valkyrie-qa-platform",
33
+ AEGIS_CLIENT_SECRET: TEST_SERVICE_SECRET,
34
+ EMAIL_SENDER: email,
35
+ ...overrides,
36
+ } as TestEnv;
37
+ return { env, db, artifacts, coordinator, email };
38
+ }
39
+
40
+ export function createContext() {
41
+ const pending: Promise<unknown>[] = [];
42
+ const ctx = {
43
+ waitUntil(promise: Promise<unknown>) {
44
+ pending.push(promise);
45
+ },
46
+ passThroughOnException() {},
47
+ props: {},
48
+ } as unknown as ExecutionContext;
49
+ return {
50
+ ctx,
51
+ async flush() {
52
+ while (pending.length) await Promise.all(pending.splice(0));
53
+ },
54
+ };
55
+ }
56
+
57
+ export type CallOptions = {
58
+ /** Acts as this user through the dashboard's service credential. */
59
+ as?: string;
60
+ /** Uses a bearer token (API key or runner token) instead. */
61
+ token?: string;
62
+ body?: unknown;
63
+ headers?: Record<string, string>;
64
+ };
65
+
66
+ /** Calls the registry and returns status plus parsed JSON. */
67
+ export async function call(env: Env, method: string, path: string, options: CallOptions = {}) {
68
+ const headers = new Headers(options.headers);
69
+ if (options.token) headers.set("authorization", `Bearer ${options.token}`);
70
+ else if (options.as) {
71
+ headers.set("x-aegis-id", options.as);
72
+ headers.set("x-aegis-secret", TEST_SERVICE_SECRET);
73
+ }
74
+ let body: string | undefined;
75
+ if (options.body !== undefined) {
76
+ body = typeof options.body === "string" ? options.body : JSON.stringify(options.body);
77
+ if (!headers.has("content-type")) headers.set("content-type", "application/json");
78
+ }
79
+ const { ctx, flush } = createContext();
80
+ const response = await worker.fetch(new Request(`https://registry.test${path}`, { method, headers, body }), env, ctx);
81
+ await flush();
82
+ const text = await response.text();
83
+ let json: any = null;
84
+ try {
85
+ json = text ? JSON.parse(text) : null;
86
+ } catch {
87
+ json = text;
88
+ }
89
+ return { status: response.status, json, headers: response.headers };
90
+ }
91
+
92
+ /** Runs one cron tick the way Cloudflare would. */
93
+ export async function runScheduled(env: Env, cron: string, scheduledTime = Date.now()) {
94
+ const { ctx, flush } = createContext();
95
+ await worker.scheduled({ cron, scheduledTime, noRetry() {} } as ScheduledController, env, ctx);
96
+ await flush();
97
+ }
98
+
99
+ /** A workspace with one project owned by `owner`, created through the real routes. */
100
+ export async function seedProject(env: Env, {
101
+ owner = "user_owner",
102
+ workspaceId = "acme",
103
+ projectId = "shop",
104
+ storageProvider = "r2",
105
+ }: { owner?: string; workspaceId?: string; projectId?: string; storageProvider?: string } = {}) {
106
+ const workspace = await call(env, "POST", "/api/workspaces", { as: owner, body: { id: workspaceId, name: "Acme" } });
107
+ if (workspace.status !== 201) throw new Error(`workspace seed failed: ${workspace.status} ${JSON.stringify(workspace.json)}`);
108
+ const project = await call(env, "POST", "/api/projects/register", {
109
+ as: owner,
110
+ body: { id: projectId, name: "Shop", workspaceId, environment: "staging", storageProvider },
111
+ });
112
+ if (project.status !== 201) throw new Error(`project seed failed: ${project.status} ${JSON.stringify(project.json)}`);
113
+ return { owner, workspaceId, projectId };
114
+ }
115
+
116
+ /** Publishes a plan draft so runs can be created against it. */
117
+ export async function seedPlan(env: Env, projectId: string, owner: string, planId = "checkout") {
118
+ const result = await call(env, "POST", `/api/projects/${projectId}/plans`, {
119
+ as: owner,
120
+ body: { planId, journeyId: "checkout", objectKey: `projects/${projectId}/plans/${planId}.flow.yaml` },
121
+ });
122
+ if (result.status >= 300) throw new Error(`plan seed failed: ${result.status} ${JSON.stringify(result.json)}`);
123
+ return planId;
124
+ }
125
+
126
+ /**
127
+ * Marks a domain verified for a workspace directly, skipping the DNS lookup —
128
+ * for tests whose cloud run or discovery task targets that domain and isn't
129
+ * itself testing domain verification (see domains/domains.test.ts for that).
130
+ */
131
+ export function seedVerifiedDomain(db: D1Shim, workspaceId: string, domain: string): void {
132
+ db.db.prepare(`
133
+ INSERT INTO workspace_domains (id, workspace_id, domain, verification_token, status, verified_at)
134
+ VALUES (?, ?, ?, 'seeded-for-tests', 'verified', CURRENT_TIMESTAMP)
135
+ ON CONFLICT(workspace_id, domain) DO UPDATE SET status = 'verified', verified_at = CURRENT_TIMESTAMP
136
+ `).run(`dom_${workspaceId}_${domain}`.replace(/[^a-z0-9_]/gi, "_"), workspaceId, domain);
137
+ }