serverless-ircd 0.9.0 → 0.11.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 (254) hide show
  1. package/.github/workflows/ci.yml +28 -0
  2. package/.github/workflows/deploy-aws.yml +156 -32
  3. package/.github/workflows/deploy-cf-tcp.yml +35 -9
  4. package/.github/workflows/deploy-cf.yml +40 -14
  5. package/CHANGELOG.md +594 -0
  6. package/README.md +286 -60
  7. package/apps/aws-stack/README.md +3 -5
  8. package/apps/aws-stack/bin/aws.ts +118 -9
  9. package/apps/aws-stack/cdk.json +0 -3
  10. package/apps/aws-stack/package.json +3 -4
  11. package/apps/aws-stack/src/aws-stack.ts +398 -67
  12. package/apps/aws-stack/src/static-site.ts +323 -0
  13. package/apps/aws-stack/tests/smoke-helpers.test.ts +1 -1
  14. package/apps/aws-stack/tests/stack.test.ts +714 -105
  15. package/apps/aws-stack/tests/static-site.test.ts +491 -0
  16. package/apps/aws-stack/tests/synth-no-bundle.test.ts +0 -1
  17. package/apps/cf-tcp-container/Dockerfile +37 -5
  18. package/apps/cf-tcp-container/package.json +7 -3
  19. package/apps/cf-tcp-container/src/config-loader.ts +113 -2
  20. package/apps/cf-tcp-container/src/container-server.ts +267 -87
  21. package/apps/cf-tcp-container/src/main.ts +22 -7
  22. package/apps/cf-tcp-container/src/proxy-protocol.ts +112 -0
  23. package/apps/cf-tcp-container/terraform/spectrum.tf +40 -11
  24. package/apps/cf-tcp-container/tests/config-loader.test.ts +170 -0
  25. package/apps/cf-tcp-container/tests/container-server-tls.test.ts +382 -0
  26. package/apps/cf-tcp-container/tests/container-server.test.ts +358 -31
  27. package/apps/cf-tcp-container/tests/dockerfile.test.ts +110 -0
  28. package/apps/cf-tcp-container/tests/proxy-protocol.test.ts +187 -0
  29. package/apps/cf-tcp-container/tests/spectrum-terraform.test.ts +135 -0
  30. package/apps/cf-tcp-container/tests/tls-e2e.test.ts +5 -1
  31. package/apps/cf-tcp-container/wrangler.toml +18 -14
  32. package/apps/cf-worker/package.json +3 -4
  33. package/apps/cf-worker/src/worker.ts +77 -5
  34. package/apps/cf-worker/tests/raw-modules.d.ts +11 -0
  35. package/apps/cf-worker/tests/smoke.test.ts +4 -0
  36. package/apps/cf-worker/tests/wrangler-config.test.ts +47 -0
  37. package/apps/cf-worker/tests/ws-admission.test.ts +112 -0
  38. package/apps/cf-worker/tests/ws-rate-limit.test.ts +133 -0
  39. package/apps/cf-worker/wrangler.test.toml +15 -1
  40. package/apps/cf-worker/wrangler.toml +95 -77
  41. package/apps/local-cli/package.json +1 -1
  42. package/apps/local-cli/src/config-loader.ts +14 -2
  43. package/apps/local-cli/src/line-scanner.ts +26 -0
  44. package/apps/local-cli/src/server.ts +44 -19
  45. package/apps/local-cli/tests/line-scanner.test.ts +64 -0
  46. package/apps/local-cli/tests/tcp.test.ts +29 -0
  47. package/apps/web/landing/favicon.ico +0 -0
  48. package/apps/web/landing/index.html +1 -0
  49. package/apps/web/package.json +2 -2
  50. package/apps/web/scripts/build.mjs +66 -4
  51. package/apps/web/src/build-env.ts +125 -4
  52. package/apps/web/src/config-schema.ts +20 -6
  53. package/apps/web/static/{config.staging.json → config.prod-aws.json} +3 -2
  54. package/apps/web/tests/build-env.test.ts +210 -9
  55. package/apps/web/tests/build-smoke.test.ts +2 -2
  56. package/apps/web/tests/config-schema.test.ts +149 -25
  57. package/docs/AWS-Deployment.md +793 -118
  58. package/docs/AWS-TCP-Deployment.md +57 -47
  59. package/docs/Chat-History.md +55 -0
  60. package/docs/Cloudflare-Deployment-Guide.md +95 -114
  61. package/docs/Cloudflare-TCP-Deployment.md +160 -101
  62. package/docs/Release-Process.md +27 -23
  63. package/docs/SASL-EXTERNAL.md +175 -0
  64. package/docs/Services.md +69 -22
  65. package/docs/WebClientGuide.md +35 -26
  66. package/package.json +7 -10
  67. package/packages/aws-adapter/package.json +1 -1
  68. package/packages/aws-adapter/src/admission.ts +28 -13
  69. package/packages/aws-adapter/src/aws-runtime.ts +30 -3
  70. package/packages/aws-adapter/src/cdk-table-defs.ts +39 -16
  71. package/packages/aws-adapter/src/config-loader.ts +153 -8
  72. package/packages/aws-adapter/src/dynamo-services-store.ts +19 -0
  73. package/packages/aws-adapter/src/handlers/connect.ts +73 -1
  74. package/packages/aws-adapter/src/handlers/default.ts +279 -123
  75. package/packages/aws-adapter/src/handlers/index.ts +98 -25
  76. package/packages/aws-adapter/src/handlers/nlb-stream.ts +135 -14
  77. package/packages/aws-adapter/src/index.ts +5 -7
  78. package/packages/aws-adapter/src/ip-admission.ts +79 -0
  79. package/packages/aws-adapter/src/origin-allowlist.ts +94 -0
  80. package/packages/aws-adapter/src/serialize.ts +23 -0
  81. package/packages/aws-adapter/src/tables.ts +11 -12
  82. package/packages/aws-adapter/tests/admission.test.ts +60 -2
  83. package/packages/aws-adapter/tests/aws-harness.ts +23 -2
  84. package/packages/aws-adapter/tests/aws-runtime.test.ts +64 -0
  85. package/packages/aws-adapter/tests/config-loader.test.ts +217 -0
  86. package/packages/aws-adapter/tests/connect.test.ts +323 -3
  87. package/packages/aws-adapter/tests/default-frame-limit.test.ts +231 -0
  88. package/packages/aws-adapter/tests/default-occ.test.ts +226 -0
  89. package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +134 -1
  90. package/packages/aws-adapter/tests/handlers.test.ts +174 -12
  91. package/packages/aws-adapter/tests/migrate-accounts-to-services.test.ts +164 -0
  92. package/packages/aws-adapter/tests/nlb-secure.test.ts +362 -0
  93. package/packages/aws-adapter/tests/nlb-stream.test.ts +628 -9
  94. package/packages/aws-adapter/tests/origin-allowlist.test.ts +110 -0
  95. package/packages/aws-adapter/tests/ping-checker.test.ts +0 -1
  96. package/packages/aws-adapter/tests/stats.test.ts +0 -3
  97. package/packages/aws-adapter/tests/sweeper.test.ts +0 -1
  98. package/packages/aws-adapter/tests/tables.test.ts +1 -8
  99. package/packages/aws-adapter/tests/transactions.test.ts +0 -1
  100. package/packages/cf-adapter/package.json +1 -5
  101. package/packages/cf-adapter/src/cf-runtime.ts +100 -10
  102. package/packages/cf-adapter/src/channel-do.ts +13 -3
  103. package/packages/cf-adapter/src/config-loader.ts +133 -8
  104. package/packages/cf-adapter/src/connection-do.ts +406 -116
  105. package/packages/cf-adapter/src/counter-do.ts +142 -0
  106. package/packages/cf-adapter/src/d1-services-store.ts +105 -26
  107. package/packages/cf-adapter/src/env.ts +99 -10
  108. package/packages/cf-adapter/src/index.ts +17 -7
  109. package/packages/cf-adapter/src/rate-limit-do.ts +87 -0
  110. package/packages/cf-adapter/tests/cf-runtime.test.ts +205 -16
  111. package/packages/cf-adapter/tests/channel-do.test.ts +118 -1
  112. package/packages/cf-adapter/tests/config-loader.test.ts +159 -0
  113. package/packages/cf-adapter/tests/connection-do-counter.test.ts +165 -0
  114. package/packages/cf-adapter/tests/connection-do-coverage.test.ts +460 -0
  115. package/packages/cf-adapter/tests/connection-do-frame-limit.test.ts +177 -0
  116. package/packages/cf-adapter/tests/connection-do-pure.test.ts +164 -54
  117. package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +62 -38
  118. package/packages/cf-adapter/tests/connection-do-ws-spec-contract.test.ts +7 -4
  119. package/packages/cf-adapter/tests/counter-do.test.ts +181 -0
  120. package/packages/cf-adapter/tests/d1-services-store.test.ts +245 -3
  121. package/packages/cf-adapter/tests/rate-limit-do.test.ts +160 -0
  122. package/packages/cf-adapter/tests/serialize.test.ts +25 -0
  123. package/packages/cf-adapter/tests/worker/main.ts +4 -0
  124. package/packages/cf-adapter/wrangler.test.toml +18 -1
  125. package/packages/in-memory-runtime/package.json +1 -1
  126. package/packages/in-memory-runtime/src/in-memory-runtime.ts +25 -0
  127. package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +74 -0
  128. package/packages/irc-core/package.json +1 -1
  129. package/packages/irc-core/src/account-migration.ts +140 -0
  130. package/packages/irc-core/src/caps/capabilities.ts +20 -10
  131. package/packages/irc-core/src/certfp.ts +178 -0
  132. package/packages/irc-core/src/commands/account-auth.ts +16 -19
  133. package/packages/irc-core/src/commands/cap.ts +10 -2
  134. package/packages/irc-core/src/commands/chanserv.ts +117 -14
  135. package/packages/irc-core/src/commands/chathistory.ts +13 -5
  136. package/packages/irc-core/src/commands/hostserv.ts +84 -8
  137. package/packages/irc-core/src/commands/index.ts +2 -1
  138. package/packages/irc-core/src/commands/invite.ts +1 -7
  139. package/packages/irc-core/src/commands/join.ts +1 -16
  140. package/packages/irc-core/src/commands/kick.ts +1 -8
  141. package/packages/irc-core/src/commands/list.ts +1 -8
  142. package/packages/irc-core/src/commands/memoserv.ts +1 -1
  143. package/packages/irc-core/src/commands/mode.ts +1 -8
  144. package/packages/irc-core/src/commands/multiline.ts +4 -10
  145. package/packages/irc-core/src/commands/names.ts +53 -13
  146. package/packages/irc-core/src/commands/nickserv.ts +161 -11
  147. package/packages/irc-core/src/commands/oper.ts +361 -8
  148. package/packages/irc-core/src/commands/part.ts +4 -10
  149. package/packages/irc-core/src/commands/privmsg.ts +8 -4
  150. package/packages/irc-core/src/commands/registration.ts +148 -4
  151. package/packages/irc-core/src/commands/sasl.ts +154 -46
  152. package/packages/irc-core/src/commands/topic.ts +10 -12
  153. package/packages/irc-core/src/commands/who.ts +1 -8
  154. package/packages/irc-core/src/config.ts +424 -25
  155. package/packages/irc-core/src/credential-hashing.ts +11 -54
  156. package/packages/irc-core/src/effects.ts +24 -0
  157. package/packages/irc-core/src/flood-control.ts +10 -10
  158. package/packages/irc-core/src/frame-rate-limit.ts +82 -0
  159. package/packages/irc-core/src/index.ts +9 -0
  160. package/packages/irc-core/src/oper-hashing.ts +43 -0
  161. package/packages/irc-core/src/oper-lockout.ts +87 -0
  162. package/packages/irc-core/src/ports.ts +529 -190
  163. package/packages/irc-core/src/protocol/bytes.ts +65 -0
  164. package/packages/irc-core/src/protocol/channel-name.ts +37 -0
  165. package/packages/irc-core/src/protocol/index.ts +12 -1
  166. package/packages/irc-core/src/protocol/outbound.ts +43 -10
  167. package/packages/irc-core/src/protocol/parser.ts +79 -10
  168. package/packages/irc-core/src/state/connection.ts +13 -0
  169. package/packages/irc-core/src/types.ts +266 -23
  170. package/packages/irc-core/src/ws-framing.ts +5 -4
  171. package/packages/irc-core/tests/account-migration.test.ts +133 -0
  172. package/packages/irc-core/tests/bytes.test.ts +89 -0
  173. package/packages/irc-core/tests/certfp.test.ts +117 -0
  174. package/packages/irc-core/tests/commands/cap.test.ts +76 -2
  175. package/packages/irc-core/tests/commands/chanserv.test.ts +166 -0
  176. package/packages/irc-core/tests/commands/chathistory.test.ts +140 -0
  177. package/packages/irc-core/tests/commands/hostserv.test.ts +316 -0
  178. package/packages/irc-core/tests/commands/join.test.ts +78 -1
  179. package/packages/irc-core/tests/commands/markread.test.ts +54 -0
  180. package/packages/irc-core/tests/commands/memoserv.test.ts +19 -0
  181. package/packages/irc-core/tests/commands/names.test.ts +193 -0
  182. package/packages/irc-core/tests/commands/nickserv.test.ts +419 -3
  183. package/packages/irc-core/tests/commands/oper.test.ts +574 -1
  184. package/packages/irc-core/tests/commands/privmsg.test.ts +16 -0
  185. package/packages/irc-core/tests/commands/registration.test.ts +602 -133
  186. package/packages/irc-core/tests/commands/sasl.test.ts +742 -172
  187. package/packages/irc-core/tests/commands/topic.test.ts +137 -2
  188. package/packages/irc-core/tests/commands/unified-account.test.ts +104 -84
  189. package/packages/irc-core/tests/config.test.ts +534 -2
  190. package/packages/irc-core/tests/credential-hashing.test.ts +0 -78
  191. package/packages/irc-core/tests/effects.test.ts +14 -0
  192. package/packages/irc-core/tests/flood-control.test.ts +29 -1
  193. package/packages/irc-core/tests/frame-rate-limit.test.ts +98 -0
  194. package/packages/irc-core/tests/message-store.test.ts +5 -0
  195. package/packages/irc-core/tests/oper-hashing.test.ts +60 -0
  196. package/packages/irc-core/tests/oper-lockout.test.ts +74 -0
  197. package/packages/irc-core/tests/outbound.test.ts +148 -0
  198. package/packages/irc-core/tests/parser.test.ts +287 -5
  199. package/packages/irc-core/tests/persistent-services-store.test.ts +212 -12
  200. package/packages/irc-core/tests/ports.test.ts +170 -7
  201. package/packages/irc-core/tests/services-store.test.ts +567 -1
  202. package/packages/irc-core/tests/ws-framing.test.ts +45 -0
  203. package/packages/irc-core/vitest.config.ts +6 -1
  204. package/packages/irc-server/package.json +1 -1
  205. package/packages/irc-server/src/actor.ts +123 -22
  206. package/packages/irc-server/src/dispatch.ts +1 -0
  207. package/packages/irc-server/src/index.ts +7 -0
  208. package/packages/irc-server/src/redact.ts +159 -0
  209. package/packages/irc-server/src/runtime.ts +14 -0
  210. package/packages/irc-server/src/transport.ts +28 -1
  211. package/packages/irc-server/tests/actor.test.ts +563 -54
  212. package/packages/irc-server/tests/dispatch.test.ts +31 -0
  213. package/packages/irc-server/tests/redact.test.ts +198 -0
  214. package/packages/irc-server/tests/runtime.test.ts +2 -0
  215. package/packages/irc-server/tests/transport.test.ts +66 -0
  216. package/packages/irc-test-support/package.json +1 -1
  217. package/packages/irc-test-support/src/in-memory-harness.ts +4 -0
  218. package/pnpm-workspace.yaml +1 -0
  219. package/scripts/__tests__/deploy-web-aws.test.ts +491 -0
  220. package/scripts/deploy-web-aws.mjs +290 -0
  221. package/scripts/package.json +23 -0
  222. package/scripts/tsconfig.test.json +12 -0
  223. package/scripts/vitest.config.ts +19 -0
  224. package/tools/ci-hardening/package.json +2 -2
  225. package/tools/ci-hardening/src/cf-deploy-cli.ts +3 -0
  226. package/tools/ci-hardening/src/cf-deploy.ts +118 -0
  227. package/tools/ci-hardening/src/deploy-hostname.ts +118 -0
  228. package/tools/ci-hardening/src/env-var-drift.ts +192 -0
  229. package/tools/ci-hardening/src/hostname-guard.ts +11 -0
  230. package/tools/ci-hardening/src/index.ts +19 -0
  231. package/tools/ci-hardening/src/validate.ts +57 -0
  232. package/tools/ci-hardening/tests/__wrangler_missing__.toml +2 -0
  233. package/tools/ci-hardening/tests/__wrangler_placeholder__.toml +3 -0
  234. package/tools/ci-hardening/tests/__wrangler_real__.toml +3 -0
  235. package/tools/ci-hardening/tests/cf-deploy.test.ts +200 -0
  236. package/tools/ci-hardening/tests/deploy-aws-oidc.test.ts +96 -0
  237. package/tools/ci-hardening/tests/deploy-hostname.test.ts +348 -0
  238. package/tools/ci-hardening/tests/env-var-drift.test.ts +284 -0
  239. package/tools/ci-hardening/tests/validate.test.ts +42 -0
  240. package/tools/ci-hardening/vitest.config.ts +5 -1
  241. package/tools/hash-oper-cred.ts +85 -0
  242. package/tools/load-test/package.json +1 -1
  243. package/tools/migrate-accounts-to-services.ts +270 -0
  244. package/tools/package.json +2 -1
  245. package/tools/seed-aws-accounts.ts +35 -10
  246. package/tools/seed-cf-accounts.ts +42 -9
  247. package/tools/tcp-ws-forwarder/package.json +1 -1
  248. package/packages/aws-adapter/src/account-store.ts +0 -121
  249. package/packages/aws-adapter/src/dynamo-account-store.ts +0 -95
  250. package/packages/aws-adapter/tests/account-store-dynamo.test.ts +0 -223
  251. package/packages/aws-adapter/tests/account-store.test.ts +0 -276
  252. package/packages/cf-adapter/src/d1-account-store.ts +0 -198
  253. package/packages/cf-adapter/tests/d1-account-store.test.ts +0 -274
  254. package/packages/irc-core/tests/account-store.test.ts +0 -131
@@ -0,0 +1,491 @@
1
+ /**
2
+ * Unit tests for `scripts/deploy-web-aws.mjs` — the AWS web-client deploy
3
+ * pipeline helper.
4
+ *
5
+ * The script performs the post-CDK two-phase deploy:
6
+ * 1. `aws cloudformation describe-stacks` reads the deployed stack's
7
+ * outputs (ConnectUrl, WebsiteBucketName, WebsiteDistributionId).
8
+ * 2. `pnpm --filter @serverless-ircd/web run build:prod-aws -- --api-url <url>`
9
+ * bakes the SPA with the real wss URL from the stack output.
10
+ * 3. `aws s3 sync apps/web/dist s3://<bucket> --delete` uploads the build.
11
+ * 4. `aws cloudfront create-invalidation --distribution-id <id> --paths /*`
12
+ * busts the edge cache so the new build is served immediately.
13
+ *
14
+ * The deploy function takes an injectable `runner` ({ aws, pnpm }) so these
15
+ * tests stub the AWS CLI and pnpm child-process spawns — no real AWS
16
+ * credentials, no live stack, no SPA build. The tests assert the call
17
+ * SEQUENCE, the OUTPUT SUBSTITUTION (the stack-output wss URL is the only
18
+ * value that reaches `--api-url`), the missing-output guard, and the
19
+ * `s3 sync --delete` invocation shape (whose etag-based no-op-on-redeploy
20
+ * is the AWS CLI's behaviour, not ours).
21
+ */
22
+
23
+ import { spawnSync } from 'node:child_process';
24
+ import { describe, expect, it, vi } from 'vitest';
25
+ import { deployWebAws, makeDefaultRunner, parseStackOutputs, runCli } from '../deploy-web-aws.mjs';
26
+
27
+ /**
28
+ * Builds an in-memory stub runner that records every call in order and
29
+ * returns canned responses keyed by the leading AWS / pnpm subcommand.
30
+ *
31
+ * `describeStacksOutputs` is returned as the JSON string the real CLI
32
+ * emits with `--output json` (an array of `{ OutputKey, OutputValue }`).
33
+ */
34
+ function makeStubRunner({
35
+ outputs = DEFAULT_OUTPUTS,
36
+ awsResponses = {},
37
+ }: {
38
+ outputs?: Array<{ OutputKey: string; OutputValue: string }>;
39
+ awsResponses?: Record<string, string>;
40
+ } = {}) {
41
+ const calls: Array<{ kind: 'aws' | 'pnpm'; args: string[] }> = [];
42
+ const describeJson = JSON.stringify(outputs);
43
+ const runner = {
44
+ aws(args: string[]): { stdout: string; stderr: string; status: number } {
45
+ calls.push({ kind: 'aws', args: [...args] });
46
+ // The first AWS call is always `cloudformation describe-stacks`. Subsequent
47
+ // ones (`s3 sync`, `cloudfront create-invalidation`) are dispatched by
48
+ // their leading service token so tests can override the canned reply.
49
+ const subcommand = args[0] === 'cloudformation' && args[1] === 'describe-stacks';
50
+ if (subcommand) {
51
+ return { stdout: describeJson, stderr: '', status: 0 };
52
+ }
53
+ const service = args[0] ?? '';
54
+ const canned = awsResponses[service] ?? '';
55
+ return { stdout: canned, stderr: '', status: 0 };
56
+ },
57
+ pnpm(args: string[]): { stdout: string; stderr: string; status: number } {
58
+ calls.push({ kind: 'pnpm', args: [...args] });
59
+ return { stdout: '', stderr: '', status: 0 };
60
+ },
61
+ };
62
+ return { runner, calls };
63
+ }
64
+
65
+ const DEFAULT_OUTPUTS = [
66
+ { OutputKey: 'ConnectUrl', OutputValue: 'wss://abc123.execute-api.us-east-1.amazonaws.com/prod' },
67
+ { OutputKey: 'WebsiteBucketName', OutputValue: 'irc-aws-stack-bucket-1a2b3c' },
68
+ { OutputKey: 'WebsiteDistributionId', OutputValue: 'E1A2B3C4D5E6F7' },
69
+ ];
70
+
71
+ // ============================================================================
72
+ // parseStackOutputs — JSON → lookup map
73
+ // ============================================================================
74
+
75
+ describe('parseStackOutputs', () => {
76
+ it('returns a key→value map for a CLI-shaped Outputs array', () => {
77
+ const json = JSON.stringify([
78
+ { OutputKey: 'ConnectUrl', OutputValue: 'wss://example' },
79
+ { OutputKey: 'WebsiteBucketName', OutputValue: 'bucket-1' },
80
+ ]);
81
+ expect(parseStackOutputs(json)).toEqual({
82
+ ConnectUrl: 'wss://example',
83
+ WebsiteBucketName: 'bucket-1',
84
+ });
85
+ });
86
+
87
+ it('returns an empty object when describe-stacks yields an empty Outputs array', () => {
88
+ expect(parseStackOutputs('[]')).toEqual({});
89
+ });
90
+
91
+ it('returns an empty object when the parsed JSON is not an array (e.g. an object)', () => {
92
+ expect(parseStackOutputs('{"OutputKey":"ConnectUrl"}')).toEqual({});
93
+ expect(parseStackOutputs('null')).toEqual({});
94
+ expect(parseStackOutputs('"string"')).toEqual({});
95
+ });
96
+
97
+ it('skips entries missing OutputKey or OutputValue', () => {
98
+ const json = JSON.stringify([
99
+ { OutputKey: 'ConnectUrl', OutputValue: 'wss://x' },
100
+ { OutputKey: 'NoValue' },
101
+ { OutputValue: 'orphan' },
102
+ { neither: 'here' },
103
+ null,
104
+ ]);
105
+ expect(parseStackOutputs(json)).toEqual({ ConnectUrl: 'wss://x' });
106
+ });
107
+
108
+ it('throws on a non-JSON body (surfaces the underlying parse error)', () => {
109
+ expect(() => parseStackOutputs('not-json')).toThrowError();
110
+ });
111
+ });
112
+
113
+ // ============================================================================
114
+ // deployWebAws — happy path sequence
115
+ // ============================================================================
116
+
117
+ describe('deployWebAws — sequence (describe → build → s3 sync → invalidate)', () => {
118
+ it('invokes the AWS CLI and pnpm in the exact required order', async () => {
119
+ const { runner, calls } = makeStubRunner();
120
+ await deployWebAws({ runner });
121
+
122
+ // Exactly four calls: one describe-stacks, one build, one s3 sync,
123
+ // one invalidation. No extra round-trips.
124
+ expect(calls).toHaveLength(4);
125
+ expect(calls.map((c) => c.kind)).toEqual(['aws', 'pnpm', 'aws', 'aws']);
126
+ });
127
+
128
+ it('first call describes the IrcAwsStack (no env suffix)', async () => {
129
+ const { runner, calls } = makeStubRunner();
130
+ await deployWebAws({ runner });
131
+ const describeCall = calls[0];
132
+ expect(describeCall?.kind).toBe('aws');
133
+ expect(describeCall?.args).toContain('cloudformation');
134
+ expect(describeCall?.args).toContain('describe-stacks');
135
+ expect(describeCall?.args).toContain('--stack-name');
136
+ expect(describeCall?.args).toContain('IrcAwsStack');
137
+ });
138
+
139
+ it('second call builds the SPA with --env prod-aws AND --api-url from the stack output', async () => {
140
+ const { runner, calls } = makeStubRunner();
141
+ await deployWebAws({ runner });
142
+ const buildCall = calls[1];
143
+ expect(buildCall?.kind).toBe('pnpm');
144
+ expect(buildCall?.args).toEqual(
145
+ expect.arrayContaining([
146
+ '--filter',
147
+ '@serverless-ircd/web',
148
+ 'run',
149
+ 'build:prod-aws',
150
+ '--',
151
+ '--env',
152
+ 'prod-aws',
153
+ ]),
154
+ );
155
+ // The --api-url value MUST be the ConnectUrl stack output verbatim —
156
+ // no hardcoded URLs anywhere in the pipeline. The template
157
+ // {{API_ID}}.{{REGION}}.{{STAGE}} placeholders are substituted by
158
+ // the build script from this single value.
159
+ const apiUrlIdx = buildCall?.args.indexOf('--api-url') ?? -1;
160
+ expect(apiUrlIdx, 'expected --api-url to appear in the build argv').toBeGreaterThan(-1);
161
+ const apiUrlValue = buildCall?.args[apiUrlIdx + 1];
162
+ expect(apiUrlValue).toBe('wss://abc123.execute-api.us-east-1.amazonaws.com/prod');
163
+ });
164
+
165
+ it('third call runs `s3 sync apps/web/dist s3://<bucket> --delete` against the stack-output bucket', async () => {
166
+ const { runner, calls } = makeStubRunner();
167
+ await deployWebAws({ runner });
168
+ const syncCall = calls[2];
169
+ expect(syncCall?.kind).toBe('aws');
170
+ expect(syncCall?.args).toEqual(
171
+ expect.arrayContaining([
172
+ 's3',
173
+ 'sync',
174
+ 'apps/web/dist',
175
+ 's3://irc-aws-stack-bucket-1a2b3c',
176
+ '--delete',
177
+ ]),
178
+ );
179
+ });
180
+
181
+ it('fourth call invalidates the CloudFront distribution with --paths /*', async () => {
182
+ const { runner, calls } = makeStubRunner();
183
+ await deployWebAws({ runner });
184
+ const invCall = calls[3];
185
+ expect(invCall?.kind).toBe('aws');
186
+ expect(invCall?.args).toEqual(
187
+ expect.arrayContaining([
188
+ 'cloudfront',
189
+ 'create-invalidation',
190
+ '--distribution-id',
191
+ 'E1A2B3C4D5E6F7',
192
+ '--paths',
193
+ '/*',
194
+ ]),
195
+ );
196
+ });
197
+ });
198
+
199
+ // ============================================================================
200
+ // deployWebAws — output substitution (no hardcoded URLs)
201
+ // ============================================================================
202
+
203
+ describe('deployWebAws — output substitution', () => {
204
+ it("threads the deployed stack's ConnectUrl into --api-url (no hardcoded URLs)", async () => {
205
+ const { runner, calls } = makeStubRunner({
206
+ outputs: [
207
+ { OutputKey: 'ConnectUrl', OutputValue: 'wss://real-deployed-url.example/prod' },
208
+ { OutputKey: 'WebsiteBucketName', OutputValue: 'real-bucket' },
209
+ { OutputKey: 'WebsiteDistributionId', OutputValue: 'REALDISTID' },
210
+ ],
211
+ });
212
+ await deployWebAws({ runner });
213
+ const buildCall = calls[1];
214
+ const apiUrlIdx = buildCall?.args.indexOf('--api-url') ?? -1;
215
+ expect(buildCall?.args[apiUrlIdx + 1]).toBe('wss://real-deployed-url.example/prod');
216
+ });
217
+
218
+ it('rejects a templated URL value in the stack output (defence in depth)', async () => {
219
+ // If the stack output itself carries an unsubstituted placeholder the
220
+ // pipeline must fail loudly rather than shipping a broken SPA that
221
+ // dials `{{API_ID}}...` verbatim.
222
+ const { runner } = makeStubRunner({
223
+ outputs: [
224
+ {
225
+ OutputKey: 'ConnectUrl',
226
+ OutputValue: 'wss://{{API_ID}}.execute-api.{{REGION}}.amazonaws.com/{{STAGE}}',
227
+ },
228
+ { OutputKey: 'WebsiteBucketName', OutputValue: 'b' },
229
+ { OutputKey: 'WebsiteDistributionId', OutputValue: 'd' },
230
+ ],
231
+ });
232
+ await expect(deployWebAws({ runner })).rejects.toThrowError(/ConnectUrl/u);
233
+ });
234
+ });
235
+
236
+ // ============================================================================
237
+ // deployWebAws — missing-output guard
238
+ // ============================================================================
239
+
240
+ describe('deployWebAws — missing-output guard', () => {
241
+ it('throws (and the entrypoint exits non-zero) when ConnectUrl is absent', async () => {
242
+ const { runner } = makeStubRunner({
243
+ outputs: [
244
+ { OutputKey: 'WebsiteBucketName', OutputValue: 'b' },
245
+ { OutputKey: 'WebsiteDistributionId', OutputValue: 'd' },
246
+ ],
247
+ });
248
+ await expect(deployWebAws({ runner })).rejects.toThrowError(/ConnectUrl/u);
249
+ });
250
+
251
+ it('throws when WebsiteBucketName is absent', async () => {
252
+ const { runner } = makeStubRunner({
253
+ outputs: [
254
+ { OutputKey: 'ConnectUrl', OutputValue: 'wss://x' },
255
+ { OutputKey: 'WebsiteDistributionId', OutputValue: 'd' },
256
+ ],
257
+ });
258
+ await expect(deployWebAws({ runner })).rejects.toThrowError(/WebsiteBucketName/u);
259
+ });
260
+
261
+ it('throws when WebsiteDistributionId is absent', async () => {
262
+ const { runner } = makeStubRunner({
263
+ outputs: [
264
+ { OutputKey: 'ConnectUrl', OutputValue: 'wss://x' },
265
+ { OutputKey: 'WebsiteBucketName', OutputValue: 'b' },
266
+ ],
267
+ });
268
+ await expect(deployWebAws({ runner })).rejects.toThrowError(/WebsiteDistributionId/u);
269
+ });
270
+
271
+ it('mentions the stack name in the error so the operator knows what to deploy', async () => {
272
+ const { runner } = makeStubRunner({ outputs: [] });
273
+ await expect(deployWebAws({ runner })).rejects.toThrowError(/IrcAwsStack/u);
274
+ });
275
+
276
+ it('does NOT invoke pnpm build or any s3/cloudfront call when outputs are missing', async () => {
277
+ const { runner, calls } = makeStubRunner({
278
+ outputs: [{ OutputKey: 'ConnectUrl', OutputValue: 'wss://x' }],
279
+ });
280
+ await expect(deployWebAws({ runner })).rejects.toThrow();
281
+ // Only the describe-stacks call should have landed; no build / sync /
282
+ // invalidation may fire on a missing-output failure.
283
+ expect(calls).toHaveLength(1);
284
+ expect(calls[0]?.kind).toBe('aws');
285
+ });
286
+ });
287
+
288
+ // ============================================================================
289
+ // deployWebAws — re-deploy no-op behaviour
290
+ // ============================================================================
291
+
292
+ describe('deployWebAws — re-deploy (s3 sync unchanged-skip)', () => {
293
+ // The AWS CLI's `s3 sync` compares etags and skips uploads of unchanged
294
+ // objects; that behaviour is the CLI's, not ours. What we CAN assert is
295
+ // that the script always invokes `s3 sync --delete` with the same
296
+ // arguments on every run — the CLI then decides what (if anything) to
297
+ // upload. The invalidation fires unconditionally because CloudFront has
298
+ // no equivalent unchanged-skip.
299
+ it('always invokes s3 sync with --delete (the CLI handles unchanged-skip via etags)', async () => {
300
+ const { runner, calls } = makeStubRunner();
301
+ await deployWebAws({ runner });
302
+ const syncCall = calls[2];
303
+ expect(syncCall?.args).toContain('--delete');
304
+ expect(syncCall?.args).toContain('s3://irc-aws-stack-bucket-1a2b3c');
305
+ });
306
+
307
+ it('always invokes the invalidation after sync (no unchanged-skip for CloudFront)', async () => {
308
+ const { runner, calls } = makeStubRunner();
309
+ await deployWebAws({ runner });
310
+ expect(calls[3]?.args).toContain('create-invalidation');
311
+ expect(calls[3]?.args).toContain('/*');
312
+ });
313
+ });
314
+
315
+ // ============================================================================
316
+ // deployWebAws — CLI failure propagation
317
+ // ============================================================================
318
+
319
+ describe('deployWebAws — CLI failure propagation', () => {
320
+ it('surfaces a non-zero aws describe-stacks exit as an Error', async () => {
321
+ const runner = {
322
+ aws() {
323
+ return { stdout: '', stderr: 'Stack with id IrcAwsStack does not exist', status: 255 };
324
+ },
325
+ pnpm() {
326
+ return { stdout: '', stderr: '', status: 0 };
327
+ },
328
+ };
329
+ await expect(deployWebAws({ runner })).rejects.toThrowError(/IrcAwsStack/u);
330
+ });
331
+
332
+ it('surfaces a non-zero pnpm build exit as an Error (does not proceed to s3 sync)', async () => {
333
+ const calls: Array<{ kind: 'aws' | 'pnpm'; args: string[] }> = [];
334
+ const runner = {
335
+ aws(args: string[]) {
336
+ calls.push({ kind: 'aws', args });
337
+ if (args[0] === 'cloudformation' && args[1] === 'describe-stacks') {
338
+ return { stdout: JSON.stringify(DEFAULT_OUTPUTS), stderr: '', status: 0 };
339
+ }
340
+ return { stdout: '', stderr: '', status: 0 };
341
+ },
342
+ pnpm(args: string[]) {
343
+ calls.push({ kind: 'pnpm', args });
344
+ return { stdout: '', stderr: 'Kiwi build failed', status: 1 };
345
+ },
346
+ };
347
+ await expect(deployWebAws({ runner })).rejects.toThrowError(/build/iu);
348
+ // describe-stacks + build were called, but s3 sync / invalidation were not.
349
+ expect(calls).toHaveLength(2);
350
+ expect(calls[1]?.kind).toBe('pnpm');
351
+ });
352
+
353
+ it('surfaces a non-zero s3 sync exit as an Error (does not proceed to invalidation)', async () => {
354
+ const calls: Array<{ kind: 'aws' | 'pnpm'; args: string[] }> = [];
355
+ const runner = {
356
+ aws(args: string[]) {
357
+ calls.push({ kind: 'aws', args });
358
+ if (args[0] === 'cloudformation' && args[1] === 'describe-stacks') {
359
+ return { stdout: JSON.stringify(DEFAULT_OUTPUTS), stderr: '', status: 0 };
360
+ }
361
+ if (args[0] === 's3') {
362
+ return { stdout: '', stderr: 'AccessDenied', status: 255 };
363
+ }
364
+ return { stdout: '', stderr: '', status: 0 };
365
+ },
366
+ pnpm(args: string[]) {
367
+ calls.push({ kind: 'pnpm', args });
368
+ return { stdout: '', stderr: '', status: 0 };
369
+ },
370
+ };
371
+ await expect(deployWebAws({ runner })).rejects.toThrowError(/s3 sync/u);
372
+ expect(calls.filter((c) => c.kind === 'aws')).toHaveLength(2);
373
+ // describe-stacks + s3 sync; the invalidation did NOT fire.
374
+ const invalidation = calls.find(
375
+ (c) => c.kind === 'aws' && c.args.includes('create-invalidation'),
376
+ );
377
+ expect(invalidation).toBeUndefined();
378
+ });
379
+
380
+ it('surfaces a non-zero cloudfront create-invalidation exit as an Error', async () => {
381
+ const runner = {
382
+ aws(args: string[]) {
383
+ if (args[0] === 'cloudformation' && args[1] === 'describe-stacks') {
384
+ return { stdout: JSON.stringify(DEFAULT_OUTPUTS), stderr: '', status: 0 };
385
+ }
386
+ if (args[0] === 'cloudfront') {
387
+ return { stdout: '', stderr: 'AccessDenied', status: 255 };
388
+ }
389
+ return { stdout: '', stderr: '', status: 0 };
390
+ },
391
+ pnpm() {
392
+ return { stdout: '', stderr: '', status: 0 };
393
+ },
394
+ };
395
+ await expect(deployWebAws({ runner })).rejects.toThrowError(/create-invalidation/u);
396
+ });
397
+ });
398
+
399
+ // ============================================================================
400
+ // makeDefaultRunner — real subprocess plumbing
401
+ // ============================================================================
402
+ //
403
+ // The default runner is what the CLI entrypoint uses; the deploy function
404
+ // itself is exercised through stubbed runners above. These tests run real
405
+ // subprocesses (`node --version` / a no-op script) so the spawnSync shape
406
+ // is covered end-to-end without depending on `aws` or `pnpm` being on the
407
+ // PATH inside the test environment.
408
+
409
+ describe('makeDefaultRunner', () => {
410
+ it('returns an object exposing aws() and pnpm() spawnSync wrappers', () => {
411
+ const runner = makeDefaultRunner();
412
+ expect(typeof runner.aws).toBe('function');
413
+ expect(typeof runner.pnpm).toBe('function');
414
+ });
415
+
416
+ it('uses the cwd passed in (defaults to process.cwd())', () => {
417
+ // Smoke-check the option wiring without invoking the real `aws`/`pnpm`
418
+ // binaries — those aren't on PATH in the test environment. The
419
+ // spawnSync shape is exercised by the runCli tests below.
420
+ const r1 = makeDefaultRunner();
421
+ const r2 = makeDefaultRunner({ cwd: '/tmp' });
422
+ expect(r1).not.toBe(r2);
423
+ });
424
+ });
425
+
426
+ describe('runCli — spawnSync shape', () => {
427
+ // The default runner's aws()/pnpm() close over runCli with the literal
428
+ // 'aws'/'pnpm' binaries. Those aren't guaranteed to be on PATH inside
429
+ // the test environment, so exercise runCli directly with `node` as the
430
+ // binary — the normalisation logic (status / stdout / stderr types) is
431
+ // what matters, not the binary name.
432
+ it('returns the captured stdout and a zero status when the child succeeds', () => {
433
+ const result = runCli(process.execPath, ['-e', "process.stdout.write('ok')"], {
434
+ cwd: process.cwd(),
435
+ stdio: ['ignore', 'pipe', 'pipe'],
436
+ });
437
+ expect(result.status).toBe(0);
438
+ expect(result.stdout).toBe('ok');
439
+ expect(result.stderr).toBe('');
440
+ });
441
+
442
+ it('returns a non-zero status and the stderr body when the child fails', () => {
443
+ const result = runCli(
444
+ process.execPath,
445
+ ['-e', "process.stderr.write('boom'); process.exit(1);"],
446
+ { cwd: process.cwd(), stdio: ['ignore', 'pipe', 'pipe'] },
447
+ );
448
+ expect(result.status).toBe(1);
449
+ expect(result.stderr).toBe('boom');
450
+ });
451
+
452
+ it('returns status -1 when the binary cannot be spawned', () => {
453
+ const result = runCli('/nonexistent/binary/that/does/not/exist', [], {
454
+ cwd: process.cwd(),
455
+ stdio: ['ignore', 'pipe', 'pipe'],
456
+ });
457
+ expect(result.status).toBe(-1);
458
+ expect(typeof result.stderr).toBe('string');
459
+ });
460
+
461
+ it('returns empty-string stdout/stderr (never null/undefined) on every path', () => {
462
+ const ok = runCli(process.execPath, ['-e', "process.stdout.write('')"], {
463
+ cwd: process.cwd(),
464
+ stdio: ['ignore', 'pipe', 'pipe'],
465
+ });
466
+ expect(ok.stdout).toBe('');
467
+ expect(typeof ok.stdout).toBe('string');
468
+ expect(typeof ok.stderr).toBe('string');
469
+ });
470
+ });
471
+
472
+ // Sanity-check that the test harness can still spawnSync node directly
473
+ // (used by runCli via process.execPath).
474
+ describe('node child_process harness sanity', () => {
475
+ it('spawnSync(node --version) exits 0', () => {
476
+ const r = spawnSync(process.execPath, ['--version'], { encoding: 'utf8' });
477
+ expect(r.status).toBe(0);
478
+ });
479
+ });
480
+
481
+ // ============================================================================
482
+ // Silencing the unused-vi import warning for future spies
483
+ // ============================================================================
484
+
485
+ describe('deployWebAws — runner contract', () => {
486
+ it('uses vi.fn spies (smoke check that the import path is wired)', () => {
487
+ const spy = vi.fn();
488
+ spy();
489
+ expect(spy).toHaveBeenCalled();
490
+ });
491
+ });