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.
- package/.github/workflows/ci.yml +28 -0
- package/.github/workflows/deploy-aws.yml +156 -32
- package/.github/workflows/deploy-cf-tcp.yml +35 -9
- package/.github/workflows/deploy-cf.yml +40 -14
- package/CHANGELOG.md +594 -0
- package/README.md +286 -60
- package/apps/aws-stack/README.md +3 -5
- package/apps/aws-stack/bin/aws.ts +118 -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 +398 -67
- 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 +714 -105
- 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/Dockerfile +37 -5
- package/apps/cf-tcp-container/package.json +7 -3
- package/apps/cf-tcp-container/src/config-loader.ts +113 -2
- package/apps/cf-tcp-container/src/container-server.ts +267 -87
- package/apps/cf-tcp-container/src/main.ts +22 -7
- package/apps/cf-tcp-container/src/proxy-protocol.ts +112 -0
- package/apps/cf-tcp-container/terraform/spectrum.tf +40 -11
- package/apps/cf-tcp-container/tests/config-loader.test.ts +170 -0
- package/apps/cf-tcp-container/tests/container-server-tls.test.ts +382 -0
- package/apps/cf-tcp-container/tests/container-server.test.ts +358 -31
- package/apps/cf-tcp-container/tests/dockerfile.test.ts +110 -0
- package/apps/cf-tcp-container/tests/proxy-protocol.test.ts +187 -0
- package/apps/cf-tcp-container/tests/spectrum-terraform.test.ts +135 -0
- package/apps/cf-tcp-container/tests/tls-e2e.test.ts +5 -1
- package/apps/cf-tcp-container/wrangler.toml +18 -14
- package/apps/cf-worker/package.json +3 -4
- package/apps/cf-worker/src/worker.ts +77 -5
- package/apps/cf-worker/tests/raw-modules.d.ts +11 -0
- package/apps/cf-worker/tests/smoke.test.ts +4 -0
- package/apps/cf-worker/tests/wrangler-config.test.ts +47 -0
- package/apps/cf-worker/tests/ws-admission.test.ts +112 -0
- package/apps/cf-worker/tests/ws-rate-limit.test.ts +133 -0
- package/apps/cf-worker/wrangler.test.toml +15 -1
- package/apps/cf-worker/wrangler.toml +95 -77
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/config-loader.ts +14 -2
- package/apps/local-cli/src/line-scanner.ts +26 -0
- package/apps/local-cli/src/server.ts +44 -19
- package/apps/local-cli/tests/line-scanner.test.ts +64 -0
- package/apps/local-cli/tests/tcp.test.ts +29 -0
- package/apps/web/landing/favicon.ico +0 -0
- package/apps/web/landing/index.html +1 -0
- package/apps/web/package.json +2 -2
- package/apps/web/scripts/build.mjs +66 -4
- package/apps/web/src/build-env.ts +125 -4
- package/apps/web/src/config-schema.ts +20 -6
- 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 +2 -2
- package/apps/web/tests/config-schema.test.ts +149 -25
- package/docs/AWS-Deployment.md +793 -118
- package/docs/AWS-TCP-Deployment.md +57 -47
- package/docs/Chat-History.md +55 -0
- package/docs/Cloudflare-Deployment-Guide.md +95 -114
- package/docs/Cloudflare-TCP-Deployment.md +160 -101
- package/docs/Release-Process.md +27 -23
- package/docs/SASL-EXTERNAL.md +175 -0
- package/docs/Services.md +69 -22
- package/docs/WebClientGuide.md +35 -26
- package/package.json +7 -10
- package/packages/aws-adapter/package.json +1 -1
- package/packages/aws-adapter/src/admission.ts +28 -13
- package/packages/aws-adapter/src/aws-runtime.ts +30 -3
- package/packages/aws-adapter/src/cdk-table-defs.ts +39 -16
- package/packages/aws-adapter/src/config-loader.ts +153 -8
- package/packages/aws-adapter/src/dynamo-services-store.ts +19 -0
- package/packages/aws-adapter/src/handlers/connect.ts +73 -1
- package/packages/aws-adapter/src/handlers/default.ts +279 -123
- package/packages/aws-adapter/src/handlers/index.ts +98 -25
- package/packages/aws-adapter/src/handlers/nlb-stream.ts +135 -14
- package/packages/aws-adapter/src/index.ts +5 -7
- package/packages/aws-adapter/src/ip-admission.ts +79 -0
- package/packages/aws-adapter/src/origin-allowlist.ts +94 -0
- package/packages/aws-adapter/src/serialize.ts +23 -0
- package/packages/aws-adapter/src/tables.ts +11 -12
- package/packages/aws-adapter/tests/admission.test.ts +60 -2
- package/packages/aws-adapter/tests/aws-harness.ts +23 -2
- package/packages/aws-adapter/tests/aws-runtime.test.ts +64 -0
- package/packages/aws-adapter/tests/config-loader.test.ts +217 -0
- package/packages/aws-adapter/tests/connect.test.ts +323 -3
- package/packages/aws-adapter/tests/default-frame-limit.test.ts +231 -0
- package/packages/aws-adapter/tests/default-occ.test.ts +226 -0
- package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +134 -1
- package/packages/aws-adapter/tests/handlers.test.ts +174 -12
- package/packages/aws-adapter/tests/migrate-accounts-to-services.test.ts +164 -0
- package/packages/aws-adapter/tests/nlb-secure.test.ts +362 -0
- package/packages/aws-adapter/tests/nlb-stream.test.ts +628 -9
- 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 +0 -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 +100 -10
- package/packages/cf-adapter/src/channel-do.ts +13 -3
- package/packages/cf-adapter/src/config-loader.ts +133 -8
- package/packages/cf-adapter/src/connection-do.ts +406 -116
- package/packages/cf-adapter/src/counter-do.ts +142 -0
- package/packages/cf-adapter/src/d1-services-store.ts +105 -26
- package/packages/cf-adapter/src/env.ts +99 -10
- package/packages/cf-adapter/src/index.ts +17 -7
- package/packages/cf-adapter/src/rate-limit-do.ts +87 -0
- package/packages/cf-adapter/tests/cf-runtime.test.ts +205 -16
- package/packages/cf-adapter/tests/channel-do.test.ts +118 -1
- package/packages/cf-adapter/tests/config-loader.test.ts +159 -0
- package/packages/cf-adapter/tests/connection-do-counter.test.ts +165 -0
- package/packages/cf-adapter/tests/connection-do-coverage.test.ts +460 -0
- package/packages/cf-adapter/tests/connection-do-frame-limit.test.ts +177 -0
- package/packages/cf-adapter/tests/connection-do-pure.test.ts +164 -54
- package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +62 -38
- package/packages/cf-adapter/tests/connection-do-ws-spec-contract.test.ts +7 -4
- package/packages/cf-adapter/tests/counter-do.test.ts +181 -0
- package/packages/cf-adapter/tests/d1-services-store.test.ts +245 -3
- package/packages/cf-adapter/tests/rate-limit-do.test.ts +160 -0
- package/packages/cf-adapter/tests/serialize.test.ts +25 -0
- package/packages/cf-adapter/tests/worker/main.ts +4 -0
- package/packages/cf-adapter/wrangler.test.toml +18 -1
- package/packages/in-memory-runtime/package.json +1 -1
- package/packages/in-memory-runtime/src/in-memory-runtime.ts +25 -0
- package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +74 -0
- package/packages/irc-core/package.json +1 -1
- package/packages/irc-core/src/account-migration.ts +140 -0
- package/packages/irc-core/src/caps/capabilities.ts +20 -10
- package/packages/irc-core/src/certfp.ts +178 -0
- package/packages/irc-core/src/commands/account-auth.ts +16 -19
- package/packages/irc-core/src/commands/cap.ts +10 -2
- package/packages/irc-core/src/commands/chanserv.ts +117 -14
- package/packages/irc-core/src/commands/chathistory.ts +13 -5
- package/packages/irc-core/src/commands/hostserv.ts +84 -8
- package/packages/irc-core/src/commands/index.ts +2 -1
- package/packages/irc-core/src/commands/invite.ts +1 -7
- package/packages/irc-core/src/commands/join.ts +1 -16
- package/packages/irc-core/src/commands/kick.ts +1 -8
- package/packages/irc-core/src/commands/list.ts +1 -8
- package/packages/irc-core/src/commands/memoserv.ts +1 -1
- package/packages/irc-core/src/commands/mode.ts +1 -8
- package/packages/irc-core/src/commands/multiline.ts +4 -10
- package/packages/irc-core/src/commands/names.ts +53 -13
- package/packages/irc-core/src/commands/nickserv.ts +161 -11
- package/packages/irc-core/src/commands/oper.ts +361 -8
- package/packages/irc-core/src/commands/part.ts +4 -10
- package/packages/irc-core/src/commands/privmsg.ts +8 -4
- package/packages/irc-core/src/commands/registration.ts +148 -4
- package/packages/irc-core/src/commands/sasl.ts +154 -46
- package/packages/irc-core/src/commands/topic.ts +10 -12
- package/packages/irc-core/src/commands/who.ts +1 -8
- package/packages/irc-core/src/config.ts +424 -25
- package/packages/irc-core/src/credential-hashing.ts +11 -54
- package/packages/irc-core/src/effects.ts +24 -0
- package/packages/irc-core/src/flood-control.ts +10 -10
- package/packages/irc-core/src/frame-rate-limit.ts +82 -0
- package/packages/irc-core/src/index.ts +9 -0
- package/packages/irc-core/src/oper-hashing.ts +43 -0
- package/packages/irc-core/src/oper-lockout.ts +87 -0
- package/packages/irc-core/src/ports.ts +529 -190
- package/packages/irc-core/src/protocol/bytes.ts +65 -0
- package/packages/irc-core/src/protocol/channel-name.ts +37 -0
- package/packages/irc-core/src/protocol/index.ts +12 -1
- package/packages/irc-core/src/protocol/outbound.ts +43 -10
- package/packages/irc-core/src/protocol/parser.ts +79 -10
- package/packages/irc-core/src/state/connection.ts +13 -0
- package/packages/irc-core/src/types.ts +266 -23
- package/packages/irc-core/src/ws-framing.ts +5 -4
- package/packages/irc-core/tests/account-migration.test.ts +133 -0
- package/packages/irc-core/tests/bytes.test.ts +89 -0
- package/packages/irc-core/tests/certfp.test.ts +117 -0
- package/packages/irc-core/tests/commands/cap.test.ts +76 -2
- package/packages/irc-core/tests/commands/chanserv.test.ts +166 -0
- package/packages/irc-core/tests/commands/chathistory.test.ts +140 -0
- package/packages/irc-core/tests/commands/hostserv.test.ts +316 -0
- package/packages/irc-core/tests/commands/join.test.ts +78 -1
- 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/names.test.ts +193 -0
- package/packages/irc-core/tests/commands/nickserv.test.ts +419 -3
- package/packages/irc-core/tests/commands/oper.test.ts +574 -1
- package/packages/irc-core/tests/commands/privmsg.test.ts +16 -0
- package/packages/irc-core/tests/commands/registration.test.ts +602 -133
- package/packages/irc-core/tests/commands/sasl.test.ts +742 -172
- package/packages/irc-core/tests/commands/topic.test.ts +137 -2
- package/packages/irc-core/tests/commands/unified-account.test.ts +104 -84
- package/packages/irc-core/tests/config.test.ts +534 -2
- package/packages/irc-core/tests/credential-hashing.test.ts +0 -78
- package/packages/irc-core/tests/effects.test.ts +14 -0
- package/packages/irc-core/tests/flood-control.test.ts +29 -1
- package/packages/irc-core/tests/frame-rate-limit.test.ts +98 -0
- package/packages/irc-core/tests/message-store.test.ts +5 -0
- package/packages/irc-core/tests/oper-hashing.test.ts +60 -0
- package/packages/irc-core/tests/oper-lockout.test.ts +74 -0
- package/packages/irc-core/tests/outbound.test.ts +148 -0
- package/packages/irc-core/tests/parser.test.ts +287 -5
- package/packages/irc-core/tests/persistent-services-store.test.ts +212 -12
- package/packages/irc-core/tests/ports.test.ts +170 -7
- package/packages/irc-core/tests/services-store.test.ts +567 -1
- package/packages/irc-core/tests/ws-framing.test.ts +45 -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 +123 -22
- package/packages/irc-server/src/dispatch.ts +1 -0
- package/packages/irc-server/src/index.ts +7 -0
- package/packages/irc-server/src/redact.ts +159 -0
- package/packages/irc-server/src/runtime.ts +14 -0
- package/packages/irc-server/src/transport.ts +28 -1
- package/packages/irc-server/tests/actor.test.ts +563 -54
- package/packages/irc-server/tests/dispatch.test.ts +31 -0
- package/packages/irc-server/tests/redact.test.ts +198 -0
- package/packages/irc-server/tests/runtime.test.ts +2 -0
- package/packages/irc-server/tests/transport.test.ts +66 -0
- package/packages/irc-test-support/package.json +1 -1
- package/packages/irc-test-support/src/in-memory-harness.ts +4 -0
- 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 +2 -2
- package/tools/ci-hardening/src/cf-deploy-cli.ts +3 -0
- package/tools/ci-hardening/src/cf-deploy.ts +118 -0
- package/tools/ci-hardening/src/deploy-hostname.ts +118 -0
- package/tools/ci-hardening/src/env-var-drift.ts +192 -0
- package/tools/ci-hardening/src/hostname-guard.ts +11 -0
- package/tools/ci-hardening/src/index.ts +19 -0
- package/tools/ci-hardening/src/validate.ts +57 -0
- package/tools/ci-hardening/tests/__wrangler_missing__.toml +2 -0
- package/tools/ci-hardening/tests/__wrangler_placeholder__.toml +3 -0
- package/tools/ci-hardening/tests/__wrangler_real__.toml +3 -0
- package/tools/ci-hardening/tests/cf-deploy.test.ts +200 -0
- package/tools/ci-hardening/tests/deploy-aws-oidc.test.ts +96 -0
- package/tools/ci-hardening/tests/deploy-hostname.test.ts +348 -0
- package/tools/ci-hardening/tests/env-var-drift.test.ts +284 -0
- package/tools/ci-hardening/tests/validate.test.ts +42 -0
- package/tools/ci-hardening/vitest.config.ts +5 -1
- package/tools/hash-oper-cred.ts +85 -0
- package/tools/load-test/package.json +1 -1
- 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/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
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* These are the primary red→green tests for the infrastructure stack:
|
|
5
5
|
* they assert the exact CloudFormation shape that `cdk synth` must
|
|
6
|
-
* produce —
|
|
6
|
+
* produce — five DynamoDB tables with the PLAN §4 schema, a single
|
|
7
7
|
* Node 24 Lambda wired to all three WebSocket routes, the API Gateway
|
|
8
8
|
* v2 WebSocket API, the two stack outputs, and a least-privilege guard
|
|
9
9
|
* that no IAM policy grants `Resource: '*'` or `Action: '*'`.
|
|
@@ -18,28 +18,31 @@ import { App, type Stack } from 'aws-cdk-lib';
|
|
|
18
18
|
import { Template } from 'aws-cdk-lib/assertions';
|
|
19
19
|
import { describe, expect, it } from 'vitest';
|
|
20
20
|
import { IrcAwsStack } from '../src/aws-stack.js';
|
|
21
|
+
import type { StaticSiteProps } from '../src/static-site.js';
|
|
21
22
|
|
|
22
23
|
/** All five table names that must exist in the stack (PLAN §4). */
|
|
23
|
-
const TABLE_NAMES = ['
|
|
24
|
-
|
|
25
|
-
/** Default environment name assumed when the prop is omitted. */
|
|
26
|
-
const DEFAULT_ENV = 'staging';
|
|
24
|
+
const TABLE_NAMES = ['ChannelMembers', 'ChannelMeta', 'Connections', 'Nicks', 'Services'] as const;
|
|
27
25
|
|
|
28
26
|
/**
|
|
29
|
-
*
|
|
30
|
-
*
|
|
27
|
+
* Raw synthesised CloudFormation node for a DynamoDB table. `DeletionPolicy`
|
|
28
|
+
* and `UpdateReplacePolicy` live at the resource root (siblings of
|
|
29
|
+
* `Properties`), so they are NOT visible via `Template.hasResourceProperties`
|
|
30
|
+
* — tests that assert the removal policy inspect the full node.
|
|
31
31
|
*/
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
32
|
+
type DynamoTableResource = {
|
|
33
|
+
Type: string;
|
|
34
|
+
DeletionPolicy?: string;
|
|
35
|
+
UpdateReplacePolicy?: string;
|
|
36
|
+
Properties?: {
|
|
37
|
+
TableName?: string;
|
|
38
|
+
PointInTimeRecoverySpecification?: { PointInTimeRecoveryEnabled?: boolean };
|
|
39
|
+
DeletionProtectionEnabled?: boolean;
|
|
40
|
+
};
|
|
41
|
+
};
|
|
35
42
|
|
|
36
|
-
function makeTemplate(
|
|
43
|
+
function makeTemplate(): { app: App; stack: Stack; template: Template } {
|
|
37
44
|
const app = new App();
|
|
38
|
-
const stack = new IrcAwsStack(
|
|
39
|
-
app,
|
|
40
|
-
'TestStack',
|
|
41
|
-
environmentName !== undefined ? { environmentName } : undefined,
|
|
42
|
-
);
|
|
45
|
+
const stack = new IrcAwsStack(app, 'TestStack');
|
|
43
46
|
const template = Template.fromStack(stack);
|
|
44
47
|
return { app, stack, template };
|
|
45
48
|
}
|
|
@@ -79,15 +82,15 @@ function findRuleTargetingLambda(
|
|
|
79
82
|
}
|
|
80
83
|
|
|
81
84
|
describe('IrcAwsStack — DynamoDB tables', () => {
|
|
82
|
-
it('creates exactly
|
|
85
|
+
it('creates exactly five DynamoDB tables', () => {
|
|
83
86
|
const { template } = makeTemplate();
|
|
84
|
-
template.resourceCountIs('AWS::DynamoDB::Table',
|
|
87
|
+
template.resourceCountIs('AWS::DynamoDB::Table', 5);
|
|
85
88
|
});
|
|
86
89
|
|
|
87
90
|
it.each(TABLE_NAMES)('creates the %s table', (tableName) => {
|
|
88
91
|
const { template } = makeTemplate();
|
|
89
92
|
template.hasResourceProperties('AWS::DynamoDB::Table', {
|
|
90
|
-
TableName:
|
|
93
|
+
TableName: tableName,
|
|
91
94
|
});
|
|
92
95
|
});
|
|
93
96
|
|
|
@@ -101,7 +104,7 @@ describe('IrcAwsStack — DynamoDB tables', () => {
|
|
|
101
104
|
it('enables TTL on the Connections table via the idleSince attribute', () => {
|
|
102
105
|
const { template } = makeTemplate();
|
|
103
106
|
template.hasResourceProperties('AWS::DynamoDB::Table', {
|
|
104
|
-
TableName:
|
|
107
|
+
TableName: 'Connections',
|
|
105
108
|
TimeToLiveSpecification: {
|
|
106
109
|
AttributeName: 'idleSince',
|
|
107
110
|
Enabled: true,
|
|
@@ -112,8 +115,13 @@ describe('IrcAwsStack — DynamoDB tables', () => {
|
|
|
112
115
|
it('keys the Connections table by connectionId (String)', () => {
|
|
113
116
|
const { template } = makeTemplate();
|
|
114
117
|
template.hasResourceProperties('AWS::DynamoDB::Table', {
|
|
115
|
-
TableName:
|
|
116
|
-
|
|
118
|
+
TableName: 'Connections',
|
|
119
|
+
// connectionId (base key) plus the per-IP admission GSI keys.
|
|
120
|
+
AttributeDefinitions: [
|
|
121
|
+
{ AttributeName: 'connectionId', AttributeType: 'S' },
|
|
122
|
+
{ AttributeName: 'sourceIp', AttributeType: 'S' },
|
|
123
|
+
{ AttributeName: 'connectedSince', AttributeType: 'N' },
|
|
124
|
+
],
|
|
117
125
|
KeySchema: [{ AttributeName: 'connectionId', KeyType: 'HASH' }],
|
|
118
126
|
});
|
|
119
127
|
});
|
|
@@ -121,7 +129,7 @@ describe('IrcAwsStack — DynamoDB tables', () => {
|
|
|
121
129
|
it('keys the ChannelMembers table by channelName (PK) + connectionId (SK)', () => {
|
|
122
130
|
const { template } = makeTemplate();
|
|
123
131
|
template.hasResourceProperties('AWS::DynamoDB::Table', {
|
|
124
|
-
TableName:
|
|
132
|
+
TableName: 'ChannelMembers',
|
|
125
133
|
AttributeDefinitions: [
|
|
126
134
|
{ AttributeName: 'channelName', AttributeType: 'S' },
|
|
127
135
|
{ AttributeName: 'connectionId', AttributeType: 'S' },
|
|
@@ -136,7 +144,7 @@ describe('IrcAwsStack — DynamoDB tables', () => {
|
|
|
136
144
|
it('keys the ChannelMeta table by channelName (String)', () => {
|
|
137
145
|
const { template } = makeTemplate();
|
|
138
146
|
template.hasResourceProperties('AWS::DynamoDB::Table', {
|
|
139
|
-
TableName:
|
|
147
|
+
TableName: 'ChannelMeta',
|
|
140
148
|
AttributeDefinitions: [{ AttributeName: 'channelName', AttributeType: 'S' }],
|
|
141
149
|
KeySchema: [{ AttributeName: 'channelName', KeyType: 'HASH' }],
|
|
142
150
|
});
|
|
@@ -145,25 +153,16 @@ describe('IrcAwsStack — DynamoDB tables', () => {
|
|
|
145
153
|
it('keys the Nicks table by nickLower (String)', () => {
|
|
146
154
|
const { template } = makeTemplate();
|
|
147
155
|
template.hasResourceProperties('AWS::DynamoDB::Table', {
|
|
148
|
-
TableName:
|
|
156
|
+
TableName: 'Nicks',
|
|
149
157
|
AttributeDefinitions: [{ AttributeName: 'nickLower', AttributeType: 'S' }],
|
|
150
158
|
KeySchema: [{ AttributeName: 'nickLower', KeyType: 'HASH' }],
|
|
151
159
|
});
|
|
152
160
|
});
|
|
153
161
|
|
|
154
|
-
it('keys the Accounts table by account (String)', () => {
|
|
155
|
-
const { template } = makeTemplate();
|
|
156
|
-
template.hasResourceProperties('AWS::DynamoDB::Table', {
|
|
157
|
-
TableName: prefixedTableName(DEFAULT_ENV, 'Accounts'),
|
|
158
|
-
AttributeDefinitions: [{ AttributeName: 'account', AttributeType: 'S' }],
|
|
159
|
-
KeySchema: [{ AttributeName: 'account', KeyType: 'HASH' }],
|
|
160
|
-
});
|
|
161
|
-
});
|
|
162
|
-
|
|
163
162
|
it('keys the Services table by composite pk/sk (String/String)', () => {
|
|
164
163
|
const { template } = makeTemplate();
|
|
165
164
|
template.hasResourceProperties('AWS::DynamoDB::Table', {
|
|
166
|
-
TableName:
|
|
165
|
+
TableName: 'Services',
|
|
167
166
|
AttributeDefinitions: [
|
|
168
167
|
{ AttributeName: 'pk', AttributeType: 'S' },
|
|
169
168
|
{ AttributeName: 'sk', AttributeType: 'S' },
|
|
@@ -176,6 +175,173 @@ describe('IrcAwsStack — DynamoDB tables', () => {
|
|
|
176
175
|
});
|
|
177
176
|
});
|
|
178
177
|
|
|
178
|
+
describe('IrcAwsStack — DynamoDB table protection (RETAIN + PITR + deletion protection)', () => {
|
|
179
|
+
/**
|
|
180
|
+
* State-bearing tables hold user data (nicks, services credentials,
|
|
181
|
+
* channel state, membership) and MUST survive a mistaken `cdk destroy`,
|
|
182
|
+
* a stack-name collision, or a stray `aws dynamodb delete-table` from a
|
|
183
|
+
* compromised credential. `Connections` is the one exception: its rows
|
|
184
|
+
* are ephemeral per-connection records (TTL-reaped, sweeper-cleaned), so
|
|
185
|
+
* it stays DESTROY with no PITR and no deletion protection.
|
|
186
|
+
*/
|
|
187
|
+
const STATE_TABLES = ['ChannelMembers', 'ChannelMeta', 'Nicks', 'Services'] as const;
|
|
188
|
+
const EPHEMERAL_TABLE = 'Connections';
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Returns the synthesised `AWS::DynamoDB::Table` resources keyed by their
|
|
192
|
+
* physical `TableName`. Each value is the raw CloudFormation resource
|
|
193
|
+
* node, so `DeletionPolicy`, `UpdateReplacePolicy`, and the protection
|
|
194
|
+
* properties under `Properties` are all inspectable. `DeletionPolicy` lives
|
|
195
|
+
* at the resource root (sibling of `Properties`), so it is NOT visible to
|
|
196
|
+
* `hasResourceProperties` — the raw node is required.
|
|
197
|
+
*/
|
|
198
|
+
function dynamoTablesByName(template: Template): Record<
|
|
199
|
+
string,
|
|
200
|
+
{
|
|
201
|
+
Type: string;
|
|
202
|
+
DeletionPolicy?: string;
|
|
203
|
+
UpdateReplacePolicy?: string;
|
|
204
|
+
Properties?: {
|
|
205
|
+
TableName?: string;
|
|
206
|
+
PointInTimeRecoverySpecification?: { PointInTimeRecoveryEnabled?: boolean };
|
|
207
|
+
DeletionProtectionEnabled?: boolean;
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
> {
|
|
211
|
+
const tmpl = template.toJSON() as { Resources?: Record<string, unknown> };
|
|
212
|
+
const out: Record<string, DynamoTableResource> = {};
|
|
213
|
+
for (const raw of Object.values(tmpl.Resources ?? {})) {
|
|
214
|
+
const res = raw as { Type?: string; Properties?: { TableName?: string } };
|
|
215
|
+
if (res.Type !== 'AWS::DynamoDB::Table') {
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
const name = res.Properties?.TableName;
|
|
219
|
+
if (typeof name === 'string') {
|
|
220
|
+
out[name] = raw as DynamoTableResource;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return out;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
describe('default synth (no flags)', () => {
|
|
227
|
+
it.each(STATE_TABLES)('RETAINs the %s table (DeletionPolicy=Retain)', (name) => {
|
|
228
|
+
const { template } = makeTemplate();
|
|
229
|
+
expect(dynamoTablesByName(template)[name]?.DeletionPolicy).toBe('Retain');
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it.each(STATE_TABLES)('sets UpdateReplacePolicy=Retain on the %s table', (name) => {
|
|
233
|
+
const { template } = makeTemplate();
|
|
234
|
+
expect(dynamoTablesByName(template)[name]?.UpdateReplacePolicy).toBe('Retain');
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it('keeps the Connections table DESTROY (DeletionPolicy=Delete)', () => {
|
|
238
|
+
const { template } = makeTemplate();
|
|
239
|
+
expect(dynamoTablesByName(template)[EPHEMERAL_TABLE]?.DeletionPolicy).toBe('Delete');
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it.each(STATE_TABLES)('enables point-in-time recovery on the %s table', (name) => {
|
|
243
|
+
const { template } = makeTemplate();
|
|
244
|
+
expect(
|
|
245
|
+
dynamoTablesByName(template)[name]?.Properties?.PointInTimeRecoverySpecification
|
|
246
|
+
?.PointInTimeRecoveryEnabled,
|
|
247
|
+
).toBe(true);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it('does NOT enable point-in-time recovery on the Connections table', () => {
|
|
251
|
+
const { template } = makeTemplate();
|
|
252
|
+
expect(
|
|
253
|
+
dynamoTablesByName(template)[EPHEMERAL_TABLE]?.Properties?.PointInTimeRecoverySpecification,
|
|
254
|
+
).toBeUndefined();
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it.each(STATE_TABLES)('enables deletion protection on the %s table', (name) => {
|
|
258
|
+
const { template } = makeTemplate();
|
|
259
|
+
expect(dynamoTablesByName(template)[name]?.Properties?.DeletionProtectionEnabled).toBe(true);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it('does NOT enable deletion protection on the Connections table', () => {
|
|
263
|
+
const { template } = makeTemplate();
|
|
264
|
+
expect(
|
|
265
|
+
dynamoTablesByName(template)[EPHEMERAL_TABLE]?.Properties?.DeletionProtectionEnabled,
|
|
266
|
+
).toBeUndefined();
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
describe('allowTableDeletion=true synth (teardown mode)', () => {
|
|
271
|
+
function makeTeardownTemplate(): Template {
|
|
272
|
+
const app = new App();
|
|
273
|
+
const stack = new IrcAwsStack(app, 'TestStack', { allowTableDeletion: true });
|
|
274
|
+
return Template.fromStack(stack);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
it.each(STATE_TABLES)('flips the %s table to DESTROY (DeletionPolicy=Delete)', (name) => {
|
|
278
|
+
const template = makeTeardownTemplate();
|
|
279
|
+
expect(dynamoTablesByName(template)[name]?.DeletionPolicy).toBe('Delete');
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it.each(STATE_TABLES)('disables deletion protection on the %s table', (name) => {
|
|
283
|
+
const template = makeTeardownTemplate();
|
|
284
|
+
expect(dynamoTablesByName(template)[name]?.Properties?.DeletionProtectionEnabled).toBe(false);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it('keeps the Connections table DESTROY', () => {
|
|
288
|
+
const template = makeTeardownTemplate();
|
|
289
|
+
expect(dynamoTablesByName(template)[EPHEMERAL_TABLE]?.DeletionPolicy).toBe('Delete');
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it('still keeps point-in-time recovery enabled on state tables in teardown mode', () => {
|
|
293
|
+
// allowTableDeletion and enablePitr are independent flags: a legitimate
|
|
294
|
+
// teardown still keeps PITR on (it is harmless and the data is still
|
|
295
|
+
// recoverable up until the table is actually deleted). Disabling PITR
|
|
296
|
+
// is a separate, deliberate opt-in via enablePitr=false.
|
|
297
|
+
const template = makeTeardownTemplate();
|
|
298
|
+
for (const name of STATE_TABLES) {
|
|
299
|
+
expect(
|
|
300
|
+
dynamoTablesByName(template)[name]?.Properties?.PointInTimeRecoverySpecification
|
|
301
|
+
?.PointInTimeRecoveryEnabled,
|
|
302
|
+
).toBe(true);
|
|
303
|
+
}
|
|
304
|
+
});
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
describe('enablePitr=false synth (PITR disabled)', () => {
|
|
308
|
+
function makeNoPitrTemplate(): Template {
|
|
309
|
+
const app = new App();
|
|
310
|
+
const stack = new IrcAwsStack(app, 'TestStack', { enablePitr: false });
|
|
311
|
+
return Template.fromStack(stack);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
it.each(STATE_TABLES)('disables point-in-time recovery on the %s table', (name) => {
|
|
315
|
+
const template = makeNoPitrTemplate();
|
|
316
|
+
expect(
|
|
317
|
+
dynamoTablesByName(template)[name]?.Properties?.PointInTimeRecoverySpecification
|
|
318
|
+
?.PointInTimeRecoveryEnabled,
|
|
319
|
+
).toBe(false);
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
it('still does NOT enable point-in-time recovery on the Connections table', () => {
|
|
323
|
+
const template = makeNoPitrTemplate();
|
|
324
|
+
expect(
|
|
325
|
+
dynamoTablesByName(template)[EPHEMERAL_TABLE]?.Properties?.PointInTimeRecoverySpecification,
|
|
326
|
+
).toBeUndefined();
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it('keeps state tables RETAIN with deletion protection when PITR is disabled', () => {
|
|
330
|
+
// enablePitr=false narrows ONLY the PITR knob; the RETAIN +
|
|
331
|
+
// deletion-protection guarantees are independent of PITR and must
|
|
332
|
+
// remain on so a disabled-PITR deploy is still protected from
|
|
333
|
+
// accidental stack deletion.
|
|
334
|
+
const template = makeNoPitrTemplate();
|
|
335
|
+
for (const name of STATE_TABLES) {
|
|
336
|
+
expect(dynamoTablesByName(template)[name]?.DeletionPolicy).toBe('Retain');
|
|
337
|
+
expect(dynamoTablesByName(template)[name]?.Properties?.DeletionProtectionEnabled).toBe(
|
|
338
|
+
true,
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
});
|
|
342
|
+
});
|
|
343
|
+
});
|
|
344
|
+
|
|
179
345
|
describe('IrcAwsStack — API Gateway v2 WebSocket API', () => {
|
|
180
346
|
it('creates a WebSocket protocol API Gateway v2 API', () => {
|
|
181
347
|
const { template } = makeTemplate();
|
|
@@ -287,12 +453,20 @@ describe('IrcAwsStack — API Gateway logging', () => {
|
|
|
287
453
|
]);
|
|
288
454
|
});
|
|
289
455
|
|
|
290
|
-
it('wires the stage execution logging level to the parameter (Ref) with
|
|
456
|
+
it('wires the stage execution logging level to the parameter (Ref) with full-frame body tracing OFF by default', () => {
|
|
457
|
+
// Regression guard: `dataTraceEnabled` MUST default to `false`. For a
|
|
458
|
+
// WebSocket IRC endpoint, APIGW execution-log body tracing writes the
|
|
459
|
+
// full payload of every frame — including `PASS <password>`,
|
|
460
|
+
// `AUTHENTICATE <base64-SASL-PLAIN>`, `JOIN #chan <channel-key>`, and
|
|
461
|
+
// every `PRIVMSG`/`NOTICE` — to the APIGW CloudWatch log group. Anyone
|
|
462
|
+
// with `logs:GetLogEvents` recovers IRC credentials verbatim. The
|
|
463
|
+
// default is therefore safe-by-default; re-enabling requires an
|
|
464
|
+
// explicit, double-opt-in escape hatch (see the guard tests below).
|
|
291
465
|
const { template } = makeTemplate();
|
|
292
466
|
template.hasResourceProperties('AWS::ApiGatewayV2::Stage', {
|
|
293
467
|
DefaultRouteSettings: {
|
|
294
468
|
LoggingLevel: { Ref: 'ApiGatewayLoggingLevel' },
|
|
295
|
-
DataTraceEnabled:
|
|
469
|
+
DataTraceEnabled: false,
|
|
296
470
|
},
|
|
297
471
|
});
|
|
298
472
|
});
|
|
@@ -361,15 +535,12 @@ describe('IrcAwsStack — API Gateway logging', () => {
|
|
|
361
535
|
).toBe(true);
|
|
362
536
|
});
|
|
363
537
|
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
// in every partition); CDK renders the statement as a separate
|
|
371
|
-
// AWS::IAM::Policy attached to the role.
|
|
372
|
-
const { template } = makeTemplate();
|
|
538
|
+
/**
|
|
539
|
+
* Flattens the IAM actions granted by the inline policy CDK attaches to
|
|
540
|
+
* the `ApiGatewayLoggingRole` (`addToPolicy` renders as a separate
|
|
541
|
+
* `AWS::IAM::Policy` resource referencing the role).
|
|
542
|
+
*/
|
|
543
|
+
function loggingRoleActions(template: Template): string[] {
|
|
373
544
|
const roles = template.findResources('AWS::IAM::Role') as Record<string, unknown>;
|
|
374
545
|
const loggingRoleEntry = Object.entries(roles).find(([id]) =>
|
|
375
546
|
id.startsWith('ApiGatewayLoggingRole'),
|
|
@@ -394,12 +565,102 @@ describe('IrcAwsStack — API Gateway logging', () => {
|
|
|
394
565
|
const stmts = attached?.Properties?.PolicyDocument?.Statement ?? [];
|
|
395
566
|
const flat = (a: string | string[] | undefined): string[] =>
|
|
396
567
|
Array.isArray(a) ? a : a !== undefined ? [a] : [];
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
//
|
|
402
|
-
|
|
568
|
+
return stmts.flatMap((s) => flat(s.Action));
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
it('grants the logging role exactly the three CloudWatch Logs WRITE actions across ALL log groups', () => {
|
|
572
|
+
// APIGW writes execution logs to its OWN AWS/ApiGateway... log groups
|
|
573
|
+
// (separate from this stack's access-log group), so the role must be
|
|
574
|
+
// able to write broadly — a single-log-group ARN scope is rejected by
|
|
575
|
+
// APIGW account-role validation. The grant set is EXACTLY the three
|
|
576
|
+
// write actions APIGW needs; the legacy inline policy additionally
|
|
577
|
+
// carried Describe* actions, which let a compromised role enumerate
|
|
578
|
+
// (and, via the managed policy AWS offers for this purpose, read)
|
|
579
|
+
// arbitrary log groups in the account. Exact-set assertion so any
|
|
580
|
+
// future widening fails this test.
|
|
581
|
+
const { template } = makeTemplate();
|
|
582
|
+
expect([...loggingRoleActions(template)].sort()).toEqual([
|
|
583
|
+
'logs:CreateLogGroup',
|
|
584
|
+
'logs:CreateLogStream',
|
|
585
|
+
'logs:PutLogEvents',
|
|
586
|
+
]);
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
it('does not grant the logging role any log-enumeration or log-read actions', () => {
|
|
590
|
+
const { template } = makeTemplate();
|
|
591
|
+
const actions = loggingRoleActions(template);
|
|
592
|
+
expect(
|
|
593
|
+
actions.filter((a) => /^logs:(Describe|Get|Filter)/.test(a)),
|
|
594
|
+
'logging role must not enumerate or read log groups',
|
|
595
|
+
).toEqual([]);
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
it('attaches no AWS-managed policies to the logging role (documented inline decision)', () => {
|
|
599
|
+
// Decision record: the AmazonAPIGatewayPushToCloudWatchLogs managed
|
|
600
|
+
// policy was considered and REJECTED. Its fixed grant set is broader
|
|
601
|
+
// than the tightened inline policy — it still grants DescribeLogGroups
|
|
602
|
+
// and DescribeLogStreams (the actions this stack deliberately drops)
|
|
603
|
+
// plus GetLogEvents and FilterLogEvents, all on Resource '*' — and AWS
|
|
604
|
+
// can widen its contents without this stack being re-reviewed. The
|
|
605
|
+
// inline policy keeps the grant set exact and auditable at synth time.
|
|
606
|
+
const { template } = makeTemplate();
|
|
607
|
+
const roles = template.findResources('AWS::IAM::Role') as Record<
|
|
608
|
+
string,
|
|
609
|
+
{ Properties?: { ManagedPolicyArns?: unknown } }
|
|
610
|
+
>;
|
|
611
|
+
const loggingRole = Object.entries(roles).find(([id]) =>
|
|
612
|
+
id.startsWith('ApiGatewayLoggingRole'),
|
|
613
|
+
);
|
|
614
|
+
expect(loggingRole, 'expected an ApiGatewayLoggingRole').toBeDefined();
|
|
615
|
+
expect(loggingRole?.[1]?.Properties?.ManagedPolicyArns).toBeUndefined();
|
|
616
|
+
});
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
describe('IrcAwsStack — `dataTraceEnabled` credential-leak guard', () => {
|
|
620
|
+
/**
|
|
621
|
+
* Since the staging/prod split was collapsed (every synth is effectively a
|
|
622
|
+
* prod synth), `dataTraceEnabled` defaults to `false` and the ONLY way to
|
|
623
|
+
* re-enable it is the explicit `allowDataTrace` + `iUnderstandThisLeaksCredentials`
|
|
624
|
+
* double opt-in. Enabling without the confirmation REFUSES synthesis with a
|
|
625
|
+
* loud error naming the IRC credentials it would leak.
|
|
626
|
+
*/
|
|
627
|
+
it('refuses synth when allowDataTrace=true is set WITHOUT the leak acknowledgement', () => {
|
|
628
|
+
const app = new App();
|
|
629
|
+
expect(() => new IrcAwsStack(app, 'TestStack', { allowDataTrace: true })).toThrow(
|
|
630
|
+
/DataTraceEnabled|dataTraceEnabled|leak|credential/i,
|
|
631
|
+
);
|
|
632
|
+
});
|
|
633
|
+
|
|
634
|
+
it('still refuses synth when allowDataTrace=true is set but the acknowledgement is explicitly false', () => {
|
|
635
|
+
// Belt-and-braces: an explicit `false` confirmation is identical to
|
|
636
|
+
// omitting it — the guard must not be bypassable by passing the prop.
|
|
637
|
+
const app = new App();
|
|
638
|
+
expect(
|
|
639
|
+
() =>
|
|
640
|
+
new IrcAwsStack(app, 'TestStack', {
|
|
641
|
+
allowDataTrace: true,
|
|
642
|
+
iUnderstandThisLeaksCredentials: false,
|
|
643
|
+
}),
|
|
644
|
+
).toThrow(/DataTraceEnabled|dataTraceEnabled|leak|credential/i);
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
it('permits dataTraceEnabled ONLY when allowDataTrace AND the leak acknowledgement are BOTH true', () => {
|
|
648
|
+
// The documented dev escape hatch: a sandbox operator who has read the
|
|
649
|
+
// warning can force-enable full-frame tracing by supplying the
|
|
650
|
+
// two-step opt-in. This is the ONLY path that produces
|
|
651
|
+
// DataTraceEnabled=true, and it is deliberately awkward to set.
|
|
652
|
+
const app = new App();
|
|
653
|
+
const stack = new IrcAwsStack(app, 'TestStack', {
|
|
654
|
+
allowDataTrace: true,
|
|
655
|
+
iUnderstandThisLeaksCredentials: true,
|
|
656
|
+
});
|
|
657
|
+
const template = Template.fromStack(stack);
|
|
658
|
+
template.hasResourceProperties('AWS::ApiGatewayV2::Stage', {
|
|
659
|
+
DefaultRouteSettings: {
|
|
660
|
+
LoggingLevel: { Ref: 'ApiGatewayLoggingLevel' },
|
|
661
|
+
DataTraceEnabled: true,
|
|
662
|
+
},
|
|
663
|
+
});
|
|
403
664
|
});
|
|
404
665
|
});
|
|
405
666
|
|
|
@@ -438,7 +699,7 @@ describe('IrcAwsStack — gone-connection sweeper', () => {
|
|
|
438
699
|
// Both Lambda roles must scope their DynamoDB actions to the concrete
|
|
439
700
|
// table ARNs — the least-privilege suite below also asserts no '*'.
|
|
440
701
|
const tables = template.findResources('AWS::DynamoDB::Table');
|
|
441
|
-
expect(Object.keys(tables)).toHaveLength(
|
|
702
|
+
expect(Object.keys(tables)).toHaveLength(5);
|
|
442
703
|
});
|
|
443
704
|
});
|
|
444
705
|
|
|
@@ -637,73 +898,44 @@ describe('IrcAwsStack — least-privilege IAM', () => {
|
|
|
637
898
|
});
|
|
638
899
|
});
|
|
639
900
|
|
|
640
|
-
describe('IrcAwsStack —
|
|
641
|
-
it('
|
|
642
|
-
const { template } = makeTemplate(
|
|
643
|
-
template.hasResourceProperties('AWS::DynamoDB::Table', {
|
|
644
|
-
TableName: 'ProductionConnections',
|
|
645
|
-
});
|
|
646
|
-
});
|
|
647
|
-
|
|
648
|
-
it('prefixes the Connections table name with the staging environment', () => {
|
|
649
|
-
const { template } = makeTemplate('staging');
|
|
901
|
+
describe('IrcAwsStack — single deploy target (no env prefixing)', () => {
|
|
902
|
+
it('uses the bare logical id as the Connections table name (no env prefix)', () => {
|
|
903
|
+
const { template } = makeTemplate();
|
|
650
904
|
template.hasResourceProperties('AWS::DynamoDB::Table', {
|
|
651
|
-
TableName: '
|
|
905
|
+
TableName: 'Connections',
|
|
652
906
|
});
|
|
653
907
|
});
|
|
654
908
|
|
|
655
|
-
it('
|
|
909
|
+
it('uses the bare logical id as the Services table name (no env prefix)', () => {
|
|
656
910
|
const { template } = makeTemplate();
|
|
657
911
|
template.hasResourceProperties('AWS::DynamoDB::Table', {
|
|
658
|
-
TableName:
|
|
912
|
+
TableName: 'Services',
|
|
659
913
|
});
|
|
660
914
|
});
|
|
661
915
|
|
|
662
|
-
it('injects the
|
|
663
|
-
const { template } = makeTemplate(
|
|
916
|
+
it('injects the bare logical table name into every Lambda env var', () => {
|
|
917
|
+
const { template } = makeTemplate();
|
|
664
918
|
const fns = template.findResources('AWS::Lambda::Function') as Record<
|
|
665
919
|
string,
|
|
666
920
|
{ Properties?: { Environment?: { Variables?: Record<string, string> } } }
|
|
667
921
|
>;
|
|
668
|
-
// Every Lambda must carry CONNECTIONS_TABLE pointing at the
|
|
669
|
-
const
|
|
670
|
-
(fn) => fn.Properties?.Environment?.Variables?.CONNECTIONS_TABLE === '
|
|
671
|
-
);
|
|
672
|
-
expect(withPrefixed).toHaveLength(3);
|
|
673
|
-
});
|
|
674
|
-
|
|
675
|
-
it('produces non-colliding table names for staging vs production', () => {
|
|
676
|
-
const stagingTables = Object.keys(
|
|
677
|
-
makeTemplate('staging').template.findResources('AWS::DynamoDB::Table'),
|
|
922
|
+
// Every Lambda must carry CONNECTIONS_TABLE pointing at the bare name.
|
|
923
|
+
const withBare = Object.values(fns).filter(
|
|
924
|
+
(fn) => fn.Properties?.Environment?.Variables?.CONNECTIONS_TABLE === 'Connections',
|
|
678
925
|
);
|
|
679
|
-
|
|
680
|
-
makeTemplate('production').template.findResources('AWS::DynamoDB::Table'),
|
|
681
|
-
);
|
|
682
|
-
const stagingNames = new Set(
|
|
683
|
-
Object.values(
|
|
684
|
-
makeTemplate('staging').template.findResources('AWS::DynamoDB::Table') as Record<
|
|
685
|
-
string,
|
|
686
|
-
{ Properties: { TableName: string } }
|
|
687
|
-
>,
|
|
688
|
-
).map((t) => t.Properties.TableName),
|
|
689
|
-
);
|
|
690
|
-
const prodNames = Object.values(
|
|
691
|
-
makeTemplate('production').template.findResources('AWS::DynamoDB::Table') as Record<
|
|
692
|
-
string,
|
|
693
|
-
{ Properties: { TableName: string } }
|
|
694
|
-
>,
|
|
695
|
-
).map((t) => t.Properties.TableName);
|
|
696
|
-
expect(stagingTables).toHaveLength(6);
|
|
697
|
-
expect(prodTables).toHaveLength(6);
|
|
698
|
-
expect(prodNames.every((n) => !stagingNames.has(n))).toBe(true);
|
|
926
|
+
expect(withBare).toHaveLength(3);
|
|
699
927
|
});
|
|
700
928
|
|
|
701
|
-
it('
|
|
702
|
-
const
|
|
703
|
-
const
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
929
|
+
it('produces a fixed set of bare table names (no Staging* / Production* prefixes)', () => {
|
|
930
|
+
const { template } = makeTemplate();
|
|
931
|
+
const tables = template.findResources('AWS::DynamoDB::Table') as Record<
|
|
932
|
+
string,
|
|
933
|
+
{ Properties: { TableName: string } }
|
|
934
|
+
>;
|
|
935
|
+
const names = Object.values(tables).map((t) => t.Properties.TableName);
|
|
936
|
+
expect(names).toHaveLength(5);
|
|
937
|
+
// No name may carry a capitalized environment prefix.
|
|
938
|
+
expect(names.every((n) => !/^(Staging|Production)/.test(n))).toBe(true);
|
|
707
939
|
});
|
|
708
940
|
});
|
|
709
941
|
|
|
@@ -853,6 +1085,82 @@ describe('IrcAwsStack — server identity CloudFormation parameters', () => {
|
|
|
853
1085
|
});
|
|
854
1086
|
});
|
|
855
1087
|
|
|
1088
|
+
describe('IrcAwsStack — `$connect` Origin allowlist (CSWSH defence)', () => {
|
|
1089
|
+
/** Finds a Lambda function resource by its logical-id prefix (e.g. `IrcHandler`). */
|
|
1090
|
+
function findFunction(
|
|
1091
|
+
template: Template,
|
|
1092
|
+
idPrefix: string,
|
|
1093
|
+
): {
|
|
1094
|
+
Properties?: { Environment?: { Variables?: Record<string, unknown> } };
|
|
1095
|
+
} {
|
|
1096
|
+
const fns = template.findResources('AWS::Lambda::Function') as Record<
|
|
1097
|
+
string,
|
|
1098
|
+
{ Properties?: { Environment?: { Variables?: Record<string, unknown> } } }
|
|
1099
|
+
>;
|
|
1100
|
+
const found = Object.entries(fns).find(([id]) => id.startsWith(idPrefix));
|
|
1101
|
+
if (!found) {
|
|
1102
|
+
throw new Error(`No Lambda with logical id starting with "${idPrefix}"`);
|
|
1103
|
+
}
|
|
1104
|
+
return found[1];
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
it('does NOT inject WEB_ORIGINS on the handler when the prop is omitted (defence opt-in)', () => {
|
|
1108
|
+
// The CSWSH defence is opt-in: existing AWS deployments without a
|
|
1109
|
+
// web frontend must be unchanged on upgrade, so the env var is
|
|
1110
|
+
// omitted entirely (not set to '') when the prop is absent. The
|
|
1111
|
+
// handler's parser then returns an empty set and the `$connect`
|
|
1112
|
+
// Origin check is skipped.
|
|
1113
|
+
const app = new App();
|
|
1114
|
+
const stack = new IrcAwsStack(app, 'TestStack');
|
|
1115
|
+
const template = Template.fromStack(stack);
|
|
1116
|
+
expect(
|
|
1117
|
+
findFunction(template, 'IrcHandler').Properties?.Environment?.Variables?.WEB_ORIGINS,
|
|
1118
|
+
).toBeUndefined();
|
|
1119
|
+
});
|
|
1120
|
+
|
|
1121
|
+
it('does NOT inject WEB_ORIGINS when the prop is an empty string', () => {
|
|
1122
|
+
const app = new App();
|
|
1123
|
+
const stack = new IrcAwsStack(app, 'TestStack', { webOrigins: '' });
|
|
1124
|
+
const template = Template.fromStack(stack);
|
|
1125
|
+
expect(
|
|
1126
|
+
findFunction(template, 'IrcHandler').Properties?.Environment?.Variables?.WEB_ORIGINS,
|
|
1127
|
+
).toBeUndefined();
|
|
1128
|
+
});
|
|
1129
|
+
|
|
1130
|
+
it('injects WEB_ORIGINS on the wss handler when the prop is set', () => {
|
|
1131
|
+
const app = new App();
|
|
1132
|
+
const stack = new IrcAwsStack(app, 'TestStack', {
|
|
1133
|
+
webOrigins: 'https://app.example.com, https://staging.app.example.com',
|
|
1134
|
+
});
|
|
1135
|
+
const template = Template.fromStack(stack);
|
|
1136
|
+
expect(
|
|
1137
|
+
findFunction(template, 'IrcHandler').Properties?.Environment?.Variables?.WEB_ORIGINS,
|
|
1138
|
+
).toBe('https://app.example.com, https://staging.app.example.com');
|
|
1139
|
+
});
|
|
1140
|
+
|
|
1141
|
+
it('does NOT inject WEB_ORIGINS on the sweeper (never runs `$connect`)', () => {
|
|
1142
|
+
const app = new App();
|
|
1143
|
+
const stack = new IrcAwsStack(app, 'TestStack', {
|
|
1144
|
+
webOrigins: 'https://app.example.com',
|
|
1145
|
+
});
|
|
1146
|
+
const template = Template.fromStack(stack);
|
|
1147
|
+
expect(
|
|
1148
|
+
findFunction(template, 'IrcSweeper').Properties?.Environment?.Variables?.WEB_ORIGINS,
|
|
1149
|
+
).toBeUndefined();
|
|
1150
|
+
});
|
|
1151
|
+
|
|
1152
|
+
it('does NOT inject WEB_ORIGINS on the ping checker (never runs `$connect`)', () => {
|
|
1153
|
+
const app = new App();
|
|
1154
|
+
const stack = new IrcAwsStack(app, 'TestStack', {
|
|
1155
|
+
webOrigins: 'https://app.example.com',
|
|
1156
|
+
});
|
|
1157
|
+
const template = Template.fromStack(stack);
|
|
1158
|
+
expect(
|
|
1159
|
+
findFunction(template, 'IrcPingChecker').Properties?.Environment?.Variables?.WEB_ORIGINS,
|
|
1160
|
+
).toBeUndefined();
|
|
1161
|
+
});
|
|
1162
|
+
});
|
|
1163
|
+
|
|
856
1164
|
describe('IrcAwsStack — TCP+TLS NLB adapter', () => {
|
|
857
1165
|
/** Makes a template WITH the tcpTlsDomainName prop (enables the NLB path). */
|
|
858
1166
|
function makeTcpTemplate(domainName = 'irc.example.com'): {
|
|
@@ -862,7 +1170,6 @@ describe('IrcAwsStack — TCP+TLS NLB adapter', () => {
|
|
|
862
1170
|
} {
|
|
863
1171
|
const app = new App();
|
|
864
1172
|
const stack = new IrcAwsStack(app, 'TestStack', {
|
|
865
|
-
environmentName: 'staging',
|
|
866
1173
|
tcpTlsDomainName: domainName,
|
|
867
1174
|
});
|
|
868
1175
|
const template = Template.fromStack(stack);
|
|
@@ -933,6 +1240,101 @@ describe('IrcAwsStack — TCP+TLS NLB adapter', () => {
|
|
|
933
1240
|
});
|
|
934
1241
|
});
|
|
935
1242
|
|
|
1243
|
+
/**
|
|
1244
|
+
* Collects every IAM policy statement attached to the NLB handler
|
|
1245
|
+
* Lambda's execution role — the role's inline `Policies` plus every
|
|
1246
|
+
* separate `AWS::IAM::Policy` resource referencing it (CDK renders
|
|
1247
|
+
* `grant*` calls as the role's `DefaultPolicy` resource).
|
|
1248
|
+
*/
|
|
1249
|
+
function nlbRoleStatements(template: Template): Array<Record<string, unknown>> {
|
|
1250
|
+
const fns = template.findResources('AWS::Lambda::Function') as Record<
|
|
1251
|
+
string,
|
|
1252
|
+
{ Properties?: { Role?: { 'Fn::GetAtt'?: string[] } | { Ref?: string } } }
|
|
1253
|
+
>;
|
|
1254
|
+
const nlbFn = Object.entries(fns).find(([id]) => id.startsWith('IrcNlbHandler'));
|
|
1255
|
+
if (nlbFn === undefined) {
|
|
1256
|
+
throw new Error('No Lambda with logical id starting with "IrcNlbHandler"');
|
|
1257
|
+
}
|
|
1258
|
+
const role = nlbFn[1]?.Properties?.Role as
|
|
1259
|
+
| { 'Fn::GetAtt'?: string[]; Ref?: string }
|
|
1260
|
+
| undefined;
|
|
1261
|
+
const getAtt = role?.['Fn::GetAtt'];
|
|
1262
|
+
const roleLogicalId = Array.isArray(getAtt) ? getAtt[0] : role?.Ref;
|
|
1263
|
+
if (typeof roleLogicalId !== 'string') {
|
|
1264
|
+
throw new Error('IrcNlbHandler Lambda has no resolvable execution role');
|
|
1265
|
+
}
|
|
1266
|
+
const statements: Array<Record<string, unknown>> = [];
|
|
1267
|
+
const roles = template.findResources('AWS::IAM::Role') as Record<
|
|
1268
|
+
string,
|
|
1269
|
+
{ Properties?: { Policies?: Array<{ PolicyDocument?: { Statement?: unknown[] } }> } }
|
|
1270
|
+
>;
|
|
1271
|
+
for (const inline of roles[roleLogicalId]?.Properties?.Policies ?? []) {
|
|
1272
|
+
statements.push(
|
|
1273
|
+
...((inline.PolicyDocument?.Statement ?? []) as Array<Record<string, unknown>>),
|
|
1274
|
+
);
|
|
1275
|
+
}
|
|
1276
|
+
const policies = template.findResources('AWS::IAM::Policy') as Record<
|
|
1277
|
+
string,
|
|
1278
|
+
{
|
|
1279
|
+
Properties?: {
|
|
1280
|
+
Roles?: Array<{ Ref?: string }>;
|
|
1281
|
+
PolicyDocument?: { Statement?: unknown[] };
|
|
1282
|
+
};
|
|
1283
|
+
}
|
|
1284
|
+
>;
|
|
1285
|
+
for (const policy of Object.values(policies)) {
|
|
1286
|
+
const attached = (policy.Properties?.Roles ?? []).some((r) => r.Ref === roleLogicalId);
|
|
1287
|
+
if (attached) {
|
|
1288
|
+
statements.push(
|
|
1289
|
+
...((policy.Properties?.PolicyDocument?.Statement ?? []) as Array<
|
|
1290
|
+
Record<string, unknown>
|
|
1291
|
+
>),
|
|
1292
|
+
);
|
|
1293
|
+
}
|
|
1294
|
+
}
|
|
1295
|
+
return statements;
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
/** True when a statement's Action includes `execute-api:ManageConnections`. */
|
|
1299
|
+
function managesConnections(stmt: Record<string, unknown>): boolean {
|
|
1300
|
+
const action = stmt.Action;
|
|
1301
|
+
return (
|
|
1302
|
+
action === 'execute-api:ManageConnections' ||
|
|
1303
|
+
(Array.isArray(action) && action.includes('execute-api:ManageConnections'))
|
|
1304
|
+
);
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
it('grants the NLB handler Lambda execute-api:ManageConnections on the stage', () => {
|
|
1308
|
+
// The NLB stream handler passes MANAGEMENT_URL into AwsRuntime and
|
|
1309
|
+
// fans channel messages out to *other* connections via
|
|
1310
|
+
// ApiGatewayManagementApi.postToConnection — including wss clients
|
|
1311
|
+
// on the API Gateway transport. Without the stage grant on the NLB
|
|
1312
|
+
// handler's own execution role, every such cross-transport delivery
|
|
1313
|
+
// fails with 403.
|
|
1314
|
+
const { template } = makeTcpTemplate();
|
|
1315
|
+
const manages = nlbRoleStatements(template).filter(managesConnections);
|
|
1316
|
+
expect(
|
|
1317
|
+
manages.length,
|
|
1318
|
+
'NLB handler role must be granted execute-api:ManageConnections',
|
|
1319
|
+
).toBeGreaterThan(0);
|
|
1320
|
+
});
|
|
1321
|
+
|
|
1322
|
+
it('scopes the NLB handler ManageConnections grant to this stack API stage connections', () => {
|
|
1323
|
+
// Least privilege: the grant must target THIS stack's WebSocket API
|
|
1324
|
+
// stage connections resource (…/apis/<api>/prod/*/@connections/*),
|
|
1325
|
+
// never a wildcard API or account-wide resource.
|
|
1326
|
+
const { template } = makeTcpTemplate();
|
|
1327
|
+
const apiIds = Object.keys(template.findResources('AWS::ApiGatewayV2::Api')).filter((id) =>
|
|
1328
|
+
id.startsWith('IrcWsApi'),
|
|
1329
|
+
);
|
|
1330
|
+
expect(apiIds).toHaveLength(1);
|
|
1331
|
+
const manages = nlbRoleStatements(template).filter(managesConnections);
|
|
1332
|
+
const resourceJson = JSON.stringify(manages.map((s) => s.Resource));
|
|
1333
|
+
expect(resourceJson).toContain('@connections');
|
|
1334
|
+
expect(resourceJson).toContain('/prod/');
|
|
1335
|
+
expect(resourceJson).toContain(apiIds[0] ?? '');
|
|
1336
|
+
});
|
|
1337
|
+
|
|
936
1338
|
it('still creates the wss WebSocket API (both transports coexist)', () => {
|
|
937
1339
|
const { template } = makeTcpTemplate();
|
|
938
1340
|
template.hasResourceProperties('AWS::ApiGatewayV2::Api', {
|
|
@@ -947,3 +1349,210 @@ describe('IrcAwsStack — TCP+TLS NLB adapter', () => {
|
|
|
947
1349
|
});
|
|
948
1350
|
});
|
|
949
1351
|
});
|
|
1352
|
+
|
|
1353
|
+
describe('IrcAwsStack — web client hosting (StaticSite)', () => {
|
|
1354
|
+
/** Makes a template WITH the webSite prop (enables the StaticSite path). */
|
|
1355
|
+
function makeWebTemplate(webSite: StaticSiteProps = {}): {
|
|
1356
|
+
app: App;
|
|
1357
|
+
stack: Stack;
|
|
1358
|
+
template: Template;
|
|
1359
|
+
} {
|
|
1360
|
+
const app = new App();
|
|
1361
|
+
const stack = new IrcAwsStack(app, 'TestStack', { webSite });
|
|
1362
|
+
const template = Template.fromStack(stack);
|
|
1363
|
+
return { app, stack, template };
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
describe('when webSite is omitted (default)', () => {
|
|
1367
|
+
it('creates no S3 origin bucket for the web client', () => {
|
|
1368
|
+
const { template } = makeTemplate();
|
|
1369
|
+
template.resourceCountIs('AWS::S3::Bucket', 0);
|
|
1370
|
+
});
|
|
1371
|
+
|
|
1372
|
+
it('creates no CloudFront distribution', () => {
|
|
1373
|
+
const { template } = makeTemplate();
|
|
1374
|
+
template.resourceCountIs('AWS::CloudFront::Distribution', 0);
|
|
1375
|
+
});
|
|
1376
|
+
|
|
1377
|
+
it('creates no Origin Access Control', () => {
|
|
1378
|
+
const { template } = makeTemplate();
|
|
1379
|
+
template.resourceCountIs('AWS::CloudFront::OriginAccessControl', 0);
|
|
1380
|
+
});
|
|
1381
|
+
});
|
|
1382
|
+
|
|
1383
|
+
describe('when webSite is provided', () => {
|
|
1384
|
+
it('creates an S3 origin bucket', () => {
|
|
1385
|
+
const { template } = makeWebTemplate();
|
|
1386
|
+
template.resourceCountIs('AWS::S3::Bucket', 1);
|
|
1387
|
+
});
|
|
1388
|
+
|
|
1389
|
+
it('creates a CloudFront distribution with an S3 origin + OAC', () => {
|
|
1390
|
+
const { template } = makeWebTemplate();
|
|
1391
|
+
template.resourceCountIs('AWS::CloudFront::Distribution', 1);
|
|
1392
|
+
template.resourceCountIs('AWS::CloudFront::OriginAccessControl', 1);
|
|
1393
|
+
});
|
|
1394
|
+
|
|
1395
|
+
it('still creates the wss WebSocket API (both paths coexist)', () => {
|
|
1396
|
+
const { template } = makeWebTemplate();
|
|
1397
|
+
template.hasResourceProperties('AWS::ApiGatewayV2::Api', {
|
|
1398
|
+
ProtocolType: 'WEBSOCKET',
|
|
1399
|
+
});
|
|
1400
|
+
});
|
|
1401
|
+
|
|
1402
|
+
it('emits a WebsiteURL output with a stable logical id naming the distribution domain', () => {
|
|
1403
|
+
const { template } = makeWebTemplate();
|
|
1404
|
+
const outputs = (template.toJSON().Outputs ?? {}) as Record<string, { Value?: unknown }>;
|
|
1405
|
+
expect(outputs.WebsiteURL, 'expected a WebsiteURL output with a stable key').toBeDefined();
|
|
1406
|
+
const distIds = Object.keys(template.findResources('AWS::CloudFront::Distribution'));
|
|
1407
|
+
expect(distIds).toHaveLength(1);
|
|
1408
|
+
const getAtt = (outputs.WebsiteURL?.Value as { 'Fn::GetAtt'?: unknown[] })?.['Fn::GetAtt'];
|
|
1409
|
+
expect(getAtt, 'expected WebsiteURL.Value to Fn::GetAtt the distribution DomainName').toEqual(
|
|
1410
|
+
[distIds[0], 'DomainName'],
|
|
1411
|
+
);
|
|
1412
|
+
});
|
|
1413
|
+
|
|
1414
|
+
// The AWS web deploy pipeline (`scripts/deploy-web-aws.mjs`) reads these
|
|
1415
|
+
// outputs to drive `s3 sync <bucket>` and `cloudfront create-invalidation
|
|
1416
|
+
// --distribution-id <id>`. They MUST be exposed at the parent-stack level
|
|
1417
|
+
// with stable logical IDs (no CDK hashing, since the CfnOutput constructs
|
|
1418
|
+
// live at the stack root alongside `ConnectUrl` / `ManagementUrl`) — the
|
|
1419
|
+
// script queries them by exact key via `describe-stacks --query`.
|
|
1420
|
+
it('emits a WebsiteBucketName output Ref-ing the S3 origin bucket', () => {
|
|
1421
|
+
const { template } = makeWebTemplate();
|
|
1422
|
+
const outputs = (template.toJSON().Outputs ?? {}) as Record<string, { Value?: unknown }>;
|
|
1423
|
+
const bucketIds = Object.keys(template.findResources('AWS::S3::Bucket'));
|
|
1424
|
+
expect(bucketIds).toHaveLength(1);
|
|
1425
|
+
expect(outputs.WebsiteBucketName, 'expected a WebsiteBucketName output').toBeDefined();
|
|
1426
|
+
const ref = (outputs.WebsiteBucketName?.Value as { Ref?: unknown })?.Ref;
|
|
1427
|
+
expect(ref, 'expected WebsiteBucketName.Value to be a { Ref: <bucket-logical-id> }').toBe(
|
|
1428
|
+
bucketIds[0],
|
|
1429
|
+
);
|
|
1430
|
+
});
|
|
1431
|
+
|
|
1432
|
+
it('emits a WebsiteDistributionId output Ref-ing the CloudFront distribution', () => {
|
|
1433
|
+
const { template } = makeWebTemplate();
|
|
1434
|
+
const outputs = (template.toJSON().Outputs ?? {}) as Record<string, { Value?: unknown }>;
|
|
1435
|
+
const distIds = Object.keys(template.findResources('AWS::CloudFront::Distribution'));
|
|
1436
|
+
expect(distIds).toHaveLength(1);
|
|
1437
|
+
expect(
|
|
1438
|
+
outputs.WebsiteDistributionId,
|
|
1439
|
+
'expected a WebsiteDistributionId output',
|
|
1440
|
+
).toBeDefined();
|
|
1441
|
+
const ref = (outputs.WebsiteDistributionId?.Value as { Ref?: unknown })?.Ref;
|
|
1442
|
+
expect(ref, 'expected WebsiteDistributionId.Value to be a { Ref: <dist-logical-id> }').toBe(
|
|
1443
|
+
distIds[0],
|
|
1444
|
+
);
|
|
1445
|
+
});
|
|
1446
|
+
});
|
|
1447
|
+
});
|
|
1448
|
+
|
|
1449
|
+
describe('IrcAwsStack — API Gateway stage throttling', () => {
|
|
1450
|
+
it('exposes ApiThrottlingRateLimit as a configurable Number parameter (default 100)', () => {
|
|
1451
|
+
const { template } = makeTemplate();
|
|
1452
|
+
const p = parameters(template).ApiThrottlingRateLimit;
|
|
1453
|
+
expect(p).toBeDefined();
|
|
1454
|
+
expect(p?.Type).toBe('Number');
|
|
1455
|
+
// CfnParameter renders a numeric default as a JSON number.
|
|
1456
|
+
expect(Number(p?.Default)).toBe(100);
|
|
1457
|
+
expect(typeof p?.Description).toBe('string');
|
|
1458
|
+
expect(p?.Description?.length).toBeGreaterThan(0);
|
|
1459
|
+
});
|
|
1460
|
+
|
|
1461
|
+
it('exposes ApiThrottlingBurstLimit as a configurable Number parameter (default 200)', () => {
|
|
1462
|
+
const { template } = makeTemplate();
|
|
1463
|
+
const p = parameters(template).ApiThrottlingBurstLimit;
|
|
1464
|
+
expect(p).toBeDefined();
|
|
1465
|
+
expect(p?.Type).toBe('Number');
|
|
1466
|
+
expect(Number(p?.Default)).toBe(200);
|
|
1467
|
+
expect(typeof p?.Description).toBe('string');
|
|
1468
|
+
});
|
|
1469
|
+
|
|
1470
|
+
it('wires the stage default-route throttling to the parameters (Ref)', () => {
|
|
1471
|
+
// Stage-level throttling is the global backstop behind the per-IP
|
|
1472
|
+
// admission gates: it bounds aggregate request rate into the API
|
|
1473
|
+
// (connect floods included) before a Lambda invocation is billed.
|
|
1474
|
+
const { template } = makeTemplate();
|
|
1475
|
+
template.hasResourceProperties('AWS::ApiGatewayV2::Stage', {
|
|
1476
|
+
DefaultRouteSettings: {
|
|
1477
|
+
ThrottlingRateLimit: { Ref: 'ApiThrottlingRateLimit' },
|
|
1478
|
+
ThrottlingBurstLimit: { Ref: 'ApiThrottlingBurstLimit' },
|
|
1479
|
+
},
|
|
1480
|
+
});
|
|
1481
|
+
});
|
|
1482
|
+
});
|
|
1483
|
+
|
|
1484
|
+
describe('IrcAwsStack — Connections sourceIp GSI', () => {
|
|
1485
|
+
it('adds a sourceIp+connectedSince GSI to the Connections table', () => {
|
|
1486
|
+
// The per-IP admission gates ($connect simultaneous cap + rate
|
|
1487
|
+
// window, NLB new-flow rate) consult this index; without it the
|
|
1488
|
+
// handler would need a full-table Scan per connect.
|
|
1489
|
+
const { template } = makeTemplate();
|
|
1490
|
+
template.hasResourceProperties('AWS::DynamoDB::Table', {
|
|
1491
|
+
TableName: 'Connections',
|
|
1492
|
+
GlobalSecondaryIndexes: [
|
|
1493
|
+
{
|
|
1494
|
+
IndexName: 'sourceIp-connectedSince',
|
|
1495
|
+
KeySchema: [
|
|
1496
|
+
{ AttributeName: 'sourceIp', KeyType: 'HASH' },
|
|
1497
|
+
{ AttributeName: 'connectedSince', KeyType: 'RANGE' },
|
|
1498
|
+
],
|
|
1499
|
+
},
|
|
1500
|
+
],
|
|
1501
|
+
});
|
|
1502
|
+
});
|
|
1503
|
+
});
|
|
1504
|
+
|
|
1505
|
+
describe('IrcAwsStack — WAF rate-based rule on $connect', () => {
|
|
1506
|
+
/** Synths a stack with the optional edge rate limit configured. */
|
|
1507
|
+
function makeWafTemplate(limit: number): Template {
|
|
1508
|
+
const app = new App();
|
|
1509
|
+
const stack = new IrcAwsStack(app, 'TestStack', { wafConnectRateLimit: limit });
|
|
1510
|
+
return Template.fromStack(stack);
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
it('emits no WebACL by default (the knob is opt-in)', () => {
|
|
1514
|
+
const { template } = makeTemplate();
|
|
1515
|
+
template.resourceCountIs('AWS::WAFv2::WebACL', 0);
|
|
1516
|
+
template.resourceCountIs('AWS::WAFv2::WebACLAssociation', 0);
|
|
1517
|
+
});
|
|
1518
|
+
|
|
1519
|
+
it('emits a REGIONAL WebACL with a per-IP rate-based rule when the knob is set', () => {
|
|
1520
|
+
// WAF on a WebSocket API only inspects the initial HTTP upgrade —
|
|
1521
|
+
// the `$connect` route — so a plain rate-based statement is
|
|
1522
|
+
// effectively scoped to connection establishment.
|
|
1523
|
+
const template = makeWafTemplate(2000);
|
|
1524
|
+
template.resourceCountIs('AWS::WAFv2::WebACL', 1);
|
|
1525
|
+
template.hasResourceProperties('AWS::WAFv2::WebACL', {
|
|
1526
|
+
Scope: 'REGIONAL',
|
|
1527
|
+
DefaultAction: { Allow: {} },
|
|
1528
|
+
Rules: [
|
|
1529
|
+
{
|
|
1530
|
+
Name: 'ConnectRateLimit',
|
|
1531
|
+
Priority: 0,
|
|
1532
|
+
Action: { Block: {} },
|
|
1533
|
+
Statement: {
|
|
1534
|
+
RateBasedStatement: { Limit: 2000, AggregateKeyType: 'IP' },
|
|
1535
|
+
},
|
|
1536
|
+
},
|
|
1537
|
+
],
|
|
1538
|
+
});
|
|
1539
|
+
});
|
|
1540
|
+
|
|
1541
|
+
it('associates the WebACL with the WebSocket stage', () => {
|
|
1542
|
+
const template = makeWafTemplate(2000);
|
|
1543
|
+
template.resourceCountIs('AWS::WAFv2::WebACLAssociation', 1);
|
|
1544
|
+
const assoc = template.findResources('AWS::WAFv2::WebACLAssociation') as Record<
|
|
1545
|
+
string,
|
|
1546
|
+
{ Properties?: { ResourceArn?: unknown; WebACLArn?: unknown } }
|
|
1547
|
+
>;
|
|
1548
|
+
const entry = Object.values(assoc)[0];
|
|
1549
|
+
expect(entry?.Properties?.WebACLArn).toBeDefined();
|
|
1550
|
+
// ResourceArn must be the API Gateway stage ARN (Fn::Join over the
|
|
1551
|
+
// partition/region refs + the WebSocket Api Ref + the stage name),
|
|
1552
|
+
// not a dangling literal.
|
|
1553
|
+
const arn = JSON.stringify(entry?.Properties?.ResourceArn);
|
|
1554
|
+
expect(arn).toContain('apigateway');
|
|
1555
|
+
expect(arn).toContain('/stages/prod');
|
|
1556
|
+
expect(arn).toContain('IrcWsApi');
|
|
1557
|
+
});
|
|
1558
|
+
});
|