serverless-ircd 0.8.0 → 0.10.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.
- package/.github/workflows/ci.yml +4 -0
- package/.github/workflows/deploy-aws.yml +156 -32
- package/.github/workflows/deploy-cf-tcp.yml +11 -9
- package/.github/workflows/deploy-cf.yml +14 -14
- package/CHANGELOG.md +550 -0
- package/README.md +275 -222
- package/apps/aws-stack/README.md +3 -5
- package/apps/aws-stack/bin/aws.ts +82 -9
- package/apps/aws-stack/cdk.json +0 -3
- package/apps/aws-stack/package.json +3 -4
- package/apps/aws-stack/src/aws-stack.ts +177 -52
- package/apps/aws-stack/src/static-site.ts +323 -0
- package/apps/aws-stack/tests/smoke-helpers.test.ts +1 -1
- package/apps/aws-stack/tests/stack.test.ts +267 -92
- package/apps/aws-stack/tests/static-site.test.ts +491 -0
- package/apps/aws-stack/tests/synth-no-bundle.test.ts +0 -1
- package/apps/cf-tcp-container/package.json +2 -3
- package/apps/cf-tcp-container/src/container-server.ts +33 -10
- package/apps/cf-tcp-container/tests/config-loader.test.ts +43 -0
- package/apps/cf-tcp-container/tests/container-server.test.ts +249 -1
- package/apps/cf-tcp-container/tests/persistence.test.ts +9 -0
- package/apps/cf-tcp-container/tests/tls-e2e.test.ts +24 -5
- package/apps/cf-tcp-container/wrangler.toml +1 -10
- package/apps/cf-worker/package.json +3 -4
- package/apps/cf-worker/wrangler.toml +12 -71
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/server.ts +115 -48
- package/apps/local-cli/tests/config-resolution.test.ts +65 -0
- package/apps/local-cli/tests/motd-file-non-error.test.ts +29 -0
- package/apps/local-cli/tests/rehash.test.ts +147 -0
- package/apps/local-cli/tests/server-helpers.test.ts +63 -0
- package/apps/local-cli/tests/tcp.test.ts +89 -0
- package/apps/local-cli/tests/ws-subprotocol.test.ts +92 -0
- package/apps/web/landing/favicon.ico +0 -0
- package/apps/web/landing/index.html +227 -3
- package/apps/web/package.json +3 -2
- package/apps/web/scripts/build.mjs +91 -6
- package/apps/web/src/build-env.ts +125 -4
- package/apps/web/src/config-schema.ts +20 -6
- package/apps/web/src/render-docs.ts +292 -0
- package/apps/web/static/{config.staging.json → config.prod-aws.json} +3 -2
- package/apps/web/tests/build-env.test.ts +210 -9
- package/apps/web/tests/build-smoke.test.ts +33 -4
- package/apps/web/tests/config-schema.test.ts +149 -25
- package/apps/web/tests/landing-content.test.ts +103 -0
- package/apps/web/tests/render-docs.test.ts +198 -0
- package/docs/AWS-Adapter-Architecture.md +3 -2
- package/docs/AWS-Deployment.md +670 -96
- package/docs/AWS-TCP-Deployment.md +20 -45
- package/docs/Cloudflare-Deployment-Guide.md +87 -113
- package/docs/Cloudflare-TCP-Deployment.md +25 -49
- package/docs/Release-Process.md +27 -23
- package/docs/Services.md +102 -23
- package/docs/WebClientGuide.md +35 -26
- package/package.json +7 -10
- package/packages/aws-adapter/package.json +1 -1
- package/packages/aws-adapter/src/aws-runtime.ts +15 -1
- package/packages/aws-adapter/src/cdk-table-defs.ts +6 -11
- package/packages/aws-adapter/src/config-loader.ts +19 -2
- package/packages/aws-adapter/src/dynamo-services-store.ts +7 -0
- package/packages/aws-adapter/src/handlers/connect.ts +26 -0
- package/packages/aws-adapter/src/handlers/default.ts +190 -123
- package/packages/aws-adapter/src/handlers/index.ts +67 -23
- package/packages/aws-adapter/src/handlers/nlb-stream.ts +13 -8
- package/packages/aws-adapter/src/index.ts +5 -7
- package/packages/aws-adapter/src/origin-allowlist.ts +94 -0
- package/packages/aws-adapter/src/serialize.ts +15 -0
- package/packages/aws-adapter/src/tables.ts +2 -12
- package/packages/aws-adapter/tests/aws-harness.ts +0 -1
- package/packages/aws-adapter/tests/aws-runtime.test.ts +23 -1
- package/packages/aws-adapter/tests/config-loader.test.ts +66 -0
- package/packages/aws-adapter/tests/connect.test.ts +124 -1
- package/packages/aws-adapter/tests/connection-counter.test.ts +17 -0
- package/packages/aws-adapter/tests/default-occ.test.ts +219 -0
- package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +11 -0
- package/packages/aws-adapter/tests/global-setup.ts +28 -1
- package/packages/aws-adapter/tests/gone-exception.test.ts +21 -2
- package/packages/aws-adapter/tests/handlers.test.ts +117 -11
- package/packages/aws-adapter/tests/migrate-accounts-to-services.test.ts +164 -0
- package/packages/aws-adapter/tests/nlb-stream.test.ts +29 -1
- package/packages/aws-adapter/tests/origin-allowlist.test.ts +110 -0
- package/packages/aws-adapter/tests/ping-checker.test.ts +0 -1
- package/packages/aws-adapter/tests/stats.test.ts +0 -3
- package/packages/aws-adapter/tests/sweeper.test.ts +20 -1
- package/packages/aws-adapter/tests/tables.test.ts +1 -8
- package/packages/aws-adapter/tests/transactions.test.ts +0 -1
- package/packages/cf-adapter/package.json +1 -5
- package/packages/cf-adapter/src/cf-runtime.ts +59 -8
- package/packages/cf-adapter/src/channel-do.ts +13 -3
- package/packages/cf-adapter/src/connection-do.ts +284 -115
- package/packages/cf-adapter/src/d1-services-store.ts +63 -26
- package/packages/cf-adapter/src/env.ts +11 -10
- package/packages/cf-adapter/src/index.ts +0 -6
- package/packages/cf-adapter/tests/cf-runtime.test.ts +101 -1
- package/packages/cf-adapter/tests/channel-do.test.ts +118 -1
- package/packages/cf-adapter/tests/connection-do-coverage.test.ts +460 -0
- package/packages/cf-adapter/tests/connection-do-pure.test.ts +222 -51
- package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +62 -38
- package/packages/cf-adapter/tests/d1-services-store.test.ts +53 -2
- package/packages/cf-adapter/tests/serialize.test.ts +25 -0
- package/packages/in-memory-runtime/package.json +1 -1
- package/packages/irc-core/package.json +1 -1
- package/packages/irc-core/src/account-migration.ts +140 -0
- package/packages/irc-core/src/commands/account-auth.ts +60 -35
- package/packages/irc-core/src/commands/chanserv.ts +288 -4
- package/packages/irc-core/src/commands/hostserv.ts +38 -3
- package/packages/irc-core/src/commands/index.ts +1 -0
- package/packages/irc-core/src/commands/join.ts +41 -35
- package/packages/irc-core/src/commands/memoserv.ts +1 -1
- package/packages/irc-core/src/commands/nickserv.ts +138 -15
- package/packages/irc-core/src/commands/registration.ts +28 -17
- package/packages/irc-core/src/commands/sasl.ts +22 -31
- package/packages/irc-core/src/commands/service-aliases.ts +52 -0
- package/packages/irc-core/src/commands/topic.ts +23 -10
- package/packages/irc-core/src/config.ts +35 -9
- package/packages/irc-core/src/credential-hashing.ts +11 -54
- package/packages/irc-core/src/index.ts +1 -0
- package/packages/irc-core/src/ports.ts +159 -179
- package/packages/irc-core/src/state/channel.ts +17 -0
- package/packages/irc-core/src/types.ts +38 -10
- package/packages/irc-core/tests/account-migration.test.ts +133 -0
- package/packages/irc-core/tests/commands/chanserv.test.ts +668 -1
- package/packages/irc-core/tests/commands/hostserv.test.ts +71 -0
- package/packages/irc-core/tests/commands/join.test.ts +179 -0
- package/packages/irc-core/tests/commands/markread.test.ts +54 -0
- package/packages/irc-core/tests/commands/memoserv.test.ts +19 -0
- package/packages/irc-core/tests/commands/nickserv.test.ts +422 -3
- package/packages/irc-core/tests/commands/oper.test.ts +15 -0
- package/packages/irc-core/tests/commands/registration.test.ts +336 -108
- package/packages/irc-core/tests/commands/sasl.test.ts +194 -169
- package/packages/irc-core/tests/commands/service-aliases.test.ts +52 -0
- package/packages/irc-core/tests/commands/unified-account.test.ts +102 -84
- package/packages/irc-core/tests/credential-hashing.test.ts +0 -78
- package/packages/irc-core/tests/message-store.test.ts +5 -0
- package/packages/irc-core/tests/persistent-services-store.test.ts +71 -12
- package/packages/irc-core/tests/ports.test.ts +71 -0
- package/packages/irc-core/tests/services-store.test.ts +204 -0
- package/packages/irc-core/vitest.config.ts +6 -1
- package/packages/irc-server/package.json +1 -1
- package/packages/irc-server/src/actor.ts +80 -44
- package/packages/irc-server/tests/actor.test.ts +384 -50
- package/packages/irc-test-support/package.json +1 -1
- package/packages/irc-test-support/src/in-memory-harness.ts +8 -5
- package/packages/irc-test-support/src/scenarios.ts +21 -6
- package/packages/irc-test-support/tests/in-memory-harness.test.ts +19 -0
- package/packages/irc-test-support/vitest.config.ts +6 -1
- package/pnpm-workspace.yaml +1 -0
- package/scripts/__tests__/deploy-web-aws.test.ts +491 -0
- package/scripts/deploy-web-aws.mjs +290 -0
- package/scripts/package.json +23 -0
- package/scripts/tsconfig.test.json +12 -0
- package/scripts/vitest.config.ts +19 -0
- package/tools/ci-hardening/package.json +1 -1
- package/tools/ci-hardening/src/index.ts +2 -0
- package/tools/ci-hardening/src/validate.ts +57 -0
- package/tools/ci-hardening/tests/deploy-aws-oidc.test.ts +96 -0
- package/tools/ci-hardening/tests/validate.test.ts +42 -0
- package/tools/load-test/package.json +1 -1
- package/tools/load-test/src/client.ts +13 -13
- package/tools/load-test/tests/client.test.ts +258 -2
- package/tools/load-test/tests/config.test.ts +39 -0
- package/tools/load-test/tests/harness.test.ts +21 -0
- package/tools/load-test/tests/metrics.test.ts +7 -0
- package/tools/migrate-accounts-to-services.ts +270 -0
- package/tools/package.json +2 -1
- package/tools/seed-aws-accounts.ts +35 -10
- package/tools/seed-cf-accounts.ts +42 -9
- package/tools/tcp-ws-forwarder/package.json +1 -1
- package/tools/tcp-ws-forwarder/tests/close-error.test.ts +40 -0
- package/tools/tcp-ws-forwarder/tests/defensive-branches.test.ts +78 -0
- package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +51 -0
- package/tools/tcp-ws-forwarder/tests/logger.test.ts +31 -1
- package/packages/aws-adapter/src/account-store.ts +0 -121
- package/packages/aws-adapter/src/dynamo-account-store.ts +0 -95
- package/packages/aws-adapter/tests/account-store-dynamo.test.ts +0 -223
- package/packages/aws-adapter/tests/account-store.test.ts +0 -276
- package/packages/cf-adapter/src/d1-account-store.ts +0 -198
- package/packages/cf-adapter/tests/d1-account-store.test.ts +0 -274
- package/packages/irc-core/tests/account-store.test.ts +0 -131
|
@@ -191,6 +191,25 @@ describe('InMemoryClientHarness.waitForNumeric', () => {
|
|
|
191
191
|
expect(result).toContain('004');
|
|
192
192
|
await h.close();
|
|
193
193
|
});
|
|
194
|
+
|
|
195
|
+
it('ignores received lines that carry no :server prefix when scanning', async () => {
|
|
196
|
+
// A PONG reply is emitted without a `:server` prefix (`PONG :tok`, not
|
|
197
|
+
// `:srv PONG :tok`). waitForNumeric must scan such a line without
|
|
198
|
+
// false-matching: numericEquals takes its no-prefix branch (start=0),
|
|
199
|
+
// rejects the line, and keeps scanning until the real numeric lands.
|
|
200
|
+
const h = new InMemoryHarness();
|
|
201
|
+
const c = await h.spawnClient({ nick: 'alice' });
|
|
202
|
+
await c.waitForNumeric('001');
|
|
203
|
+
// Isolate a bare (no-prefix) line first, then drive a prefixed numeric
|
|
204
|
+
// after it, so the scan MUST walk past the PONG to reach 375.
|
|
205
|
+
c.received.length = 0;
|
|
206
|
+
await c.send('PING :bare');
|
|
207
|
+
await c.waitForLine((l) => l === 'PONG :bare');
|
|
208
|
+
await c.send('MOTD');
|
|
209
|
+
const result = await c.waitForNumeric('375');
|
|
210
|
+
expect(result).toContain('375');
|
|
211
|
+
await h.close();
|
|
212
|
+
});
|
|
194
213
|
});
|
|
195
214
|
|
|
196
215
|
describe('InMemoryClientHarness.close', () => {
|
|
@@ -7,7 +7,12 @@ export default defineConfig({
|
|
|
7
7
|
provider: 'v8',
|
|
8
8
|
all: false,
|
|
9
9
|
include: ['src/**/*.ts'],
|
|
10
|
-
|
|
10
|
+
// harness.ts exports types/interfaces only — it compiles to a bare
|
|
11
|
+
// `export {}` with zero runtime statements, so v8 reports it as a
|
|
12
|
+
// 0/0 (0%-statements) file that drags the file list down. Excluding
|
|
13
|
+
// it keeps the report honest: only modules with runtime code are
|
|
14
|
+
// scored. index.ts is excluded as the pure re-export barrel.
|
|
15
|
+
exclude: ['src/**/index.ts', 'src/harness.ts'],
|
|
11
16
|
reporter: ['text', 'html', 'json-summary'],
|
|
12
17
|
thresholds: {
|
|
13
18
|
lines: 90,
|
package/pnpm-workspace.yaml
CHANGED
|
@@ -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
|
+
});
|