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
|
@@ -5,7 +5,12 @@
|
|
|
5
5
|
* new connection row. The admission policy itself is covered by
|
|
6
6
|
* `admission.test.ts`; the counter primitives by `connection-counter.test.ts`.
|
|
7
7
|
*/
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
type DynamoDBDocumentClient,
|
|
10
|
+
PutCommand,
|
|
11
|
+
QueryCommand,
|
|
12
|
+
UpdateCommand,
|
|
13
|
+
} from '@aws-sdk/lib-dynamodb';
|
|
9
14
|
import { makeTestServerConfig } from '@serverless-ircd/irc-test-support';
|
|
10
15
|
import { describe, expect, it } from 'vitest';
|
|
11
16
|
import { handleConnect } from '../src/handlers/connect.js';
|
|
@@ -17,7 +22,6 @@ const TABLES: TablesConfig = {
|
|
|
17
22
|
ChannelMeta: 'ChannelMeta',
|
|
18
23
|
ChannelMembers: 'ChannelMembers',
|
|
19
24
|
Nicks: 'Nicks',
|
|
20
|
-
Accounts: 'Accounts',
|
|
21
25
|
};
|
|
22
26
|
|
|
23
27
|
interface Stub {
|
|
@@ -31,16 +35,27 @@ interface Stub {
|
|
|
31
35
|
decrements: number;
|
|
32
36
|
/** Commands issued, in order (for asserting no Scan is used). */
|
|
33
37
|
commands: string[];
|
|
38
|
+
/** QueryCommand inputs issued (for asserting the GSI consultation). */
|
|
39
|
+
queries: Array<Record<string, unknown>>;
|
|
34
40
|
}
|
|
35
41
|
|
|
42
|
+
/**
|
|
43
|
+
* Rows the stub's GSI Query pages return, per call (an outer array entry
|
|
44
|
+
* per page). Defaults to a single empty page.
|
|
45
|
+
*/
|
|
46
|
+
type QueryPages = Array<Array<{ connectedSince: number }>>;
|
|
47
|
+
|
|
36
48
|
/**
|
|
37
49
|
* Builds a stub client with an initial admission counter value. The stub
|
|
38
50
|
* honours the floor-at-0 decrement condition the real handler relies on.
|
|
51
|
+
* `gsiPages` simulates the sourceIp-GSI Query result pages.
|
|
39
52
|
*/
|
|
40
|
-
function makeStub(initialCount: number): Stub {
|
|
53
|
+
function makeStub(initialCount: number, gsiPages: QueryPages = [[]]): Stub {
|
|
41
54
|
const state = { count: initialCount, puts: 0, increments: 0, decrements: 0 };
|
|
42
55
|
let lastPutItem: Record<string, unknown> | undefined;
|
|
43
56
|
const commands: string[] = [];
|
|
57
|
+
const queries: Array<Record<string, unknown>> = [];
|
|
58
|
+
let queryCall = 0;
|
|
44
59
|
const dynamo = {
|
|
45
60
|
send: async (cmd: unknown) => {
|
|
46
61
|
if (cmd instanceof UpdateCommand) {
|
|
@@ -65,8 +80,22 @@ function makeStub(initialCount: number): Stub {
|
|
|
65
80
|
}
|
|
66
81
|
return { Attributes: { count: state.count } };
|
|
67
82
|
}
|
|
83
|
+
// Non-counter Update expressions are not expected on this path.
|
|
68
84
|
throw new Error(`unexpected Update expression: ${expr}`);
|
|
69
85
|
}
|
|
86
|
+
if (cmd instanceof QueryCommand) {
|
|
87
|
+
commands.push('Query');
|
|
88
|
+
queries.push(cmd.input as Record<string, unknown>);
|
|
89
|
+
const page = gsiPages[queryCall] ?? [];
|
|
90
|
+
const last = queryCall;
|
|
91
|
+
queryCall += 1;
|
|
92
|
+
return {
|
|
93
|
+
Items: page,
|
|
94
|
+
// Multi-page when the scripted result says so and more calls follow.
|
|
95
|
+
LastEvaluatedKey:
|
|
96
|
+
last + 1 < gsiPages.length ? { connectionId: `page-${last}` } : undefined,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
70
99
|
if (cmd instanceof PutCommand) {
|
|
71
100
|
commands.push('Put');
|
|
72
101
|
state.puts += 1;
|
|
@@ -97,6 +126,9 @@ function makeStub(initialCount: number): Stub {
|
|
|
97
126
|
get commands() {
|
|
98
127
|
return commands;
|
|
99
128
|
},
|
|
129
|
+
get queries() {
|
|
130
|
+
return queries;
|
|
131
|
+
},
|
|
100
132
|
};
|
|
101
133
|
}
|
|
102
134
|
|
|
@@ -298,3 +330,291 @@ describe('handleConnect — IRCv3 WebSocket subprotocol negotiation', () => {
|
|
|
298
330
|
expect(stub.puts).toBe(0);
|
|
299
331
|
});
|
|
300
332
|
});
|
|
333
|
+
|
|
334
|
+
describe('handleConnect — Origin allowlist (CSWSH defence)', () => {
|
|
335
|
+
// The same lowercase / trim / comma-split semantics as the CF Worker's
|
|
336
|
+
// policy (`apps/cf-worker/src/origin-allowlist.ts`); the parsed set is
|
|
337
|
+
// built once per cold start and injected via `ConnectParams.webOrigins`.
|
|
338
|
+
const allowlist = new Set(['https://app.example.com']);
|
|
339
|
+
|
|
340
|
+
it('denies a disallowed Origin with 403 before touching the counter', async () => {
|
|
341
|
+
// A denied upgrade must NOT consume an admission slot — the Origin
|
|
342
|
+
// check runs before the counter increment so a CSWSH flood from a
|
|
343
|
+
// hostile origin cannot exhaust the `maxClients` cap.
|
|
344
|
+
const stub = makeStub(0);
|
|
345
|
+
const outcome = await handleConnect({
|
|
346
|
+
dynamo: stub.dynamo,
|
|
347
|
+
tables: TABLES,
|
|
348
|
+
connectionId: 'c-cswsh',
|
|
349
|
+
serverConfig: makeTestServerConfig({ maxClients: 5 }),
|
|
350
|
+
originHeader: 'https://attacker.example.com',
|
|
351
|
+
webOrigins: allowlist,
|
|
352
|
+
});
|
|
353
|
+
expect(outcome).toEqual({
|
|
354
|
+
admitted: false,
|
|
355
|
+
statusCode: 403,
|
|
356
|
+
reason: expect.stringMatching(/origin/iu),
|
|
357
|
+
});
|
|
358
|
+
expect(stub.puts).toBe(0);
|
|
359
|
+
expect(stub.increments).toBe(0);
|
|
360
|
+
expect(stub.decrements).toBe(0);
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
it('admits an allowed Origin (existing connect path)', async () => {
|
|
364
|
+
const stub = makeStub(0);
|
|
365
|
+
const outcome = await handleConnect({
|
|
366
|
+
dynamo: stub.dynamo,
|
|
367
|
+
tables: TABLES,
|
|
368
|
+
connectionId: 'c-ok',
|
|
369
|
+
now: 42,
|
|
370
|
+
serverConfig: makeTestServerConfig({ maxClients: 5 }),
|
|
371
|
+
originHeader: 'https://app.example.com',
|
|
372
|
+
webOrigins: allowlist,
|
|
373
|
+
});
|
|
374
|
+
expect(outcome).toEqual({ admitted: true, subprotocol: null });
|
|
375
|
+
expect(stub.puts).toBe(1);
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
it('case-insensitively matches a mixed-case allowed Origin', async () => {
|
|
379
|
+
const stub = makeStub(0);
|
|
380
|
+
const outcome = await handleConnect({
|
|
381
|
+
dynamo: stub.dynamo,
|
|
382
|
+
tables: TABLES,
|
|
383
|
+
connectionId: 'c-case',
|
|
384
|
+
serverConfig: makeTestServerConfig({ maxClients: 5 }),
|
|
385
|
+
originHeader: 'https://APP.Example.COM',
|
|
386
|
+
webOrigins: allowlist,
|
|
387
|
+
});
|
|
388
|
+
expect(outcome.admitted).toBe(true);
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
it('admits a missing Origin (non-browser client: curl, WeeChat, tcp-ws-forwarder)', async () => {
|
|
392
|
+
// The defence only fires when `Origin` is present; non-browser
|
|
393
|
+
// tooling never sends it and must pass through unchanged.
|
|
394
|
+
const stub = makeStub(0);
|
|
395
|
+
const outcome = await handleConnect({
|
|
396
|
+
dynamo: stub.dynamo,
|
|
397
|
+
tables: TABLES,
|
|
398
|
+
connectionId: 'c-cli',
|
|
399
|
+
serverConfig: makeTestServerConfig({ maxClients: 5 }),
|
|
400
|
+
originHeader: null,
|
|
401
|
+
webOrigins: allowlist,
|
|
402
|
+
});
|
|
403
|
+
expect(outcome.admitted).toBe(true);
|
|
404
|
+
expect(stub.puts).toBe(1);
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
it('admits a missing Origin when the header key is absent from the event', async () => {
|
|
408
|
+
// APIGW may omit the header entirely on some non-browser flows;
|
|
409
|
+
// `undefined` is treated the same as `null`.
|
|
410
|
+
const stub = makeStub(0);
|
|
411
|
+
const outcome = await handleConnect({
|
|
412
|
+
dynamo: stub.dynamo,
|
|
413
|
+
tables: TABLES,
|
|
414
|
+
connectionId: 'c-noheader',
|
|
415
|
+
serverConfig: makeTestServerConfig({ maxClients: 5 }),
|
|
416
|
+
originHeader: undefined,
|
|
417
|
+
webOrigins: allowlist,
|
|
418
|
+
});
|
|
419
|
+
expect(outcome.admitted).toBe(true);
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
it('skips the check when WEB_ORIGINS is unset (defence is opt-in)', async () => {
|
|
423
|
+
// An empty allowlist means the deployment has not opted in; every
|
|
424
|
+
// Origin (including an obviously hostile one) must proceed so
|
|
425
|
+
// existing AWS deployments without a web frontend are unchanged
|
|
426
|
+
// on upgrade.
|
|
427
|
+
const stub = makeStub(0);
|
|
428
|
+
const outcome = await handleConnect({
|
|
429
|
+
dynamo: stub.dynamo,
|
|
430
|
+
tables: TABLES,
|
|
431
|
+
connectionId: 'c-unset',
|
|
432
|
+
serverConfig: makeTestServerConfig({ maxClients: 5 }),
|
|
433
|
+
originHeader: 'https://attacker.example.com',
|
|
434
|
+
webOrigins: new Set(),
|
|
435
|
+
});
|
|
436
|
+
expect(outcome.admitted).toBe(true);
|
|
437
|
+
expect(stub.puts).toBe(1);
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
it('denies an Origin that matches a listed origin except for the scheme', async () => {
|
|
441
|
+
// Origins are case-sensitive on scheme+host+port; an `http://`
|
|
442
|
+
// form MUST NOT be admitted against an `https://`-only allowlist
|
|
443
|
+
// (defends against a stripped-TLS attacker on the same hostname).
|
|
444
|
+
const stub = makeStub(0);
|
|
445
|
+
const outcome = await handleConnect({
|
|
446
|
+
dynamo: stub.dynamo,
|
|
447
|
+
tables: TABLES,
|
|
448
|
+
connectionId: 'c-scheme',
|
|
449
|
+
serverConfig: makeTestServerConfig({ maxClients: 5 }),
|
|
450
|
+
originHeader: 'http://app.example.com',
|
|
451
|
+
webOrigins: allowlist,
|
|
452
|
+
});
|
|
453
|
+
expect(outcome.admitted).toBe(false);
|
|
454
|
+
expect(outcome.admitted === false && outcome.statusCode).toBe(403);
|
|
455
|
+
});
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
describe('handleConnect — per-IP admission (sourceIp GSI)', () => {
|
|
459
|
+
/** Fixed epoch base so window membership is deterministic. */
|
|
460
|
+
const NOW = 1_700_000_000_000;
|
|
461
|
+
|
|
462
|
+
it('consults the sourceIp GSI when sourceIp is present', async () => {
|
|
463
|
+
const stub = makeStub(0, [[{ connectedSince: NOW - 1000 }]]);
|
|
464
|
+
const outcome = await handleConnect({
|
|
465
|
+
dynamo: stub.dynamo,
|
|
466
|
+
tables: TABLES,
|
|
467
|
+
connectionId: 'c-gsi',
|
|
468
|
+
now: NOW,
|
|
469
|
+
sourceIp: '203.0.113.20',
|
|
470
|
+
serverConfig: makeTestServerConfig({ maxClients: 5, maxConnectionsPerIp: 3 }),
|
|
471
|
+
});
|
|
472
|
+
expect(outcome.admitted).toBe(true);
|
|
473
|
+
expect(stub.queries.length).toBe(1);
|
|
474
|
+
const q = stub.queries[0];
|
|
475
|
+
expect(q?.IndexName).toBe('sourceIp-connectedSince');
|
|
476
|
+
expect(q?.ExpressionAttributeValues).toMatchObject({ ':ip': '203.0.113.20' });
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
it('rejects with 429 once the per-IP count reaches maxConnectionsPerIp', async () => {
|
|
480
|
+
const stub = makeStub(0, [
|
|
481
|
+
[
|
|
482
|
+
{ connectedSince: NOW - 1000 },
|
|
483
|
+
{ connectedSince: NOW - 2000 },
|
|
484
|
+
{ connectedSince: NOW - 3000 },
|
|
485
|
+
],
|
|
486
|
+
]);
|
|
487
|
+
const outcome = await handleConnect({
|
|
488
|
+
dynamo: stub.dynamo,
|
|
489
|
+
tables: TABLES,
|
|
490
|
+
connectionId: 'c-perip',
|
|
491
|
+
now: NOW,
|
|
492
|
+
sourceIp: '203.0.113.21',
|
|
493
|
+
serverConfig: makeTestServerConfig({ maxClients: 50, maxConnectionsPerIp: 3 }),
|
|
494
|
+
});
|
|
495
|
+
expect(outcome).toEqual({
|
|
496
|
+
admitted: false,
|
|
497
|
+
statusCode: 429,
|
|
498
|
+
reason: 'too many connections from this IP',
|
|
499
|
+
});
|
|
500
|
+
expect(stub.puts).toBe(0);
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
it('rolls back the counter reservation when the per-IP cap rejects', async () => {
|
|
504
|
+
const stub = makeStub(0, [[{ connectedSince: NOW - 1000 }]]);
|
|
505
|
+
await handleConnect({
|
|
506
|
+
dynamo: stub.dynamo,
|
|
507
|
+
tables: TABLES,
|
|
508
|
+
connectionId: 'c-perip-rb',
|
|
509
|
+
now: NOW,
|
|
510
|
+
sourceIp: '203.0.113.22',
|
|
511
|
+
serverConfig: makeTestServerConfig({ maxClients: 50, maxConnectionsPerIp: 1 }),
|
|
512
|
+
});
|
|
513
|
+
expect(stub.increments).toBe(1);
|
|
514
|
+
expect(stub.decrements).toBe(1);
|
|
515
|
+
expect(stub.count).toBe(0);
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
it('admits below the per-IP cap and persists sourceIp on the row', async () => {
|
|
519
|
+
const stub = makeStub(0, [[{ connectedSince: NOW - 1000 }]]);
|
|
520
|
+
const outcome = await handleConnect({
|
|
521
|
+
dynamo: stub.dynamo,
|
|
522
|
+
tables: TABLES,
|
|
523
|
+
connectionId: 'c-perip-ok',
|
|
524
|
+
now: NOW,
|
|
525
|
+
sourceIp: '203.0.113.23',
|
|
526
|
+
serverConfig: makeTestServerConfig({ maxClients: 5, maxConnectionsPerIp: 3 }),
|
|
527
|
+
});
|
|
528
|
+
expect(outcome).toEqual({ admitted: true, subprotocol: null });
|
|
529
|
+
expect(stub.lastPutItem?.sourceIp).toBe('203.0.113.23');
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
it('rejects with 429 once the recent-window count reaches the rate budget', async () => {
|
|
533
|
+
// Five admissions from this IP inside the window (and one stale row
|
|
534
|
+
// outside it — the stale one must not count toward the budget).
|
|
535
|
+
const rows = [
|
|
536
|
+
{ connectedSince: NOW - 1000 },
|
|
537
|
+
{ connectedSince: NOW - 2000 },
|
|
538
|
+
{ connectedSince: NOW - 3000 },
|
|
539
|
+
{ connectedSince: NOW - 4000 },
|
|
540
|
+
{ connectedSince: NOW - 5000 },
|
|
541
|
+
{ connectedSince: NOW - 120_000 },
|
|
542
|
+
];
|
|
543
|
+
const stub = makeStub(0, [rows]);
|
|
544
|
+
const outcome = await handleConnect({
|
|
545
|
+
dynamo: stub.dynamo,
|
|
546
|
+
tables: TABLES,
|
|
547
|
+
connectionId: 'c-rate',
|
|
548
|
+
now: NOW,
|
|
549
|
+
sourceIp: '203.0.113.24',
|
|
550
|
+
serverConfig: makeTestServerConfig({
|
|
551
|
+
maxClients: 50,
|
|
552
|
+
maxConnectionsPerIp: 50,
|
|
553
|
+
perIpConnectionRate: { max: 5, windowMs: 60_000 },
|
|
554
|
+
}),
|
|
555
|
+
});
|
|
556
|
+
expect(outcome).toEqual({
|
|
557
|
+
admitted: false,
|
|
558
|
+
statusCode: 429,
|
|
559
|
+
reason: 'connection rate exceeded for this IP',
|
|
560
|
+
});
|
|
561
|
+
expect(stub.puts).toBe(0);
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
it('window-reset: admissions older than the window no longer trip the rate budget', async () => {
|
|
565
|
+
// Same five admissions, but all outside the 60 s window → the recent
|
|
566
|
+
// count is 0 and the rate branch must not fire.
|
|
567
|
+
const rows = Array.from({ length: 5 }, (_, i) => ({ connectedSince: NOW - 120_000 - i }));
|
|
568
|
+
const stub = makeStub(0, [rows]);
|
|
569
|
+
const outcome = await handleConnect({
|
|
570
|
+
dynamo: stub.dynamo,
|
|
571
|
+
tables: TABLES,
|
|
572
|
+
connectionId: 'c-reset',
|
|
573
|
+
now: NOW,
|
|
574
|
+
sourceIp: '203.0.113.25',
|
|
575
|
+
serverConfig: makeTestServerConfig({
|
|
576
|
+
maxClients: 50,
|
|
577
|
+
maxConnectionsPerIp: 50,
|
|
578
|
+
perIpConnectionRate: { max: 5, windowMs: 60_000 },
|
|
579
|
+
}),
|
|
580
|
+
});
|
|
581
|
+
expect(outcome).toEqual({ admitted: true, subprotocol: null });
|
|
582
|
+
expect(stub.puts).toBe(1);
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
it('paginates the GSI query when the per-IP partition spans pages', async () => {
|
|
586
|
+
const stub = makeStub(0, [
|
|
587
|
+
[{ connectedSince: NOW - 1000 }, { connectedSince: NOW - 2000 }],
|
|
588
|
+
[{ connectedSince: NOW - 3000 }, { connectedSince: NOW - 4000 }],
|
|
589
|
+
]);
|
|
590
|
+
const outcome = await handleConnect({
|
|
591
|
+
dynamo: stub.dynamo,
|
|
592
|
+
tables: TABLES,
|
|
593
|
+
connectionId: 'c-pages',
|
|
594
|
+
now: NOW,
|
|
595
|
+
sourceIp: '203.0.113.26',
|
|
596
|
+
serverConfig: makeTestServerConfig({ maxClients: 50, maxConnectionsPerIp: 3 }),
|
|
597
|
+
});
|
|
598
|
+
// 4 existing connections across two pages ≥ cap 3 → rejected.
|
|
599
|
+
expect(outcome).toEqual({
|
|
600
|
+
admitted: false,
|
|
601
|
+
statusCode: 429,
|
|
602
|
+
reason: 'too many connections from this IP',
|
|
603
|
+
});
|
|
604
|
+
expect(stub.queries.length).toBe(2);
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
it('skips the per-IP checks entirely when no sourceIp is present (no GSI query)', async () => {
|
|
608
|
+
const stub = makeStub(0);
|
|
609
|
+
const outcome = await handleConnect({
|
|
610
|
+
dynamo: stub.dynamo,
|
|
611
|
+
tables: TABLES,
|
|
612
|
+
connectionId: 'c-noip',
|
|
613
|
+
now: NOW,
|
|
614
|
+
serverConfig: makeTestServerConfig({ maxClients: 5, maxConnectionsPerIp: 1 }),
|
|
615
|
+
});
|
|
616
|
+
expect(outcome).toEqual({ admitted: true, subprotocol: null });
|
|
617
|
+
expect(stub.queries.length).toBe(0);
|
|
618
|
+
expect(stub.commands).not.toContain('Query');
|
|
619
|
+
});
|
|
620
|
+
});
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AWS `$default` handler — per-connection inbound frame-rate window at
|
|
3
|
+
* the adapter boundary.
|
|
4
|
+
*
|
|
5
|
+
* The gate fires BEFORE the actor run / state write: a connection that
|
|
6
|
+
* delivered more than `adapter.maxFramesPerWindow` frames inside the
|
|
7
|
+
* `adapter.frameWindowSeconds` sliding window receives
|
|
8
|
+
* `ERROR :Closing link (flooding)` via the management API, is closed via
|
|
9
|
+
* `DeleteConnection` (which fires the `$disconnect` cleanup), and the
|
|
10
|
+
* invocation skips the actor + persist entirely.
|
|
11
|
+
*
|
|
12
|
+
* The window state persists as the `frameTimes` attribute on the
|
|
13
|
+
* Connections row (a short list of epoch-ms timestamps), so the limit
|
|
14
|
+
* holds across Lambda invocations and isolates. Admitted frames persist
|
|
15
|
+
* their window in the SAME conditional write as the actor state, so the
|
|
16
|
+
* optimistic-concurrency revision guard makes each frame count exactly
|
|
17
|
+
* once.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { GetCommand, UpdateCommand } from '@aws-sdk/lib-dynamodb';
|
|
21
|
+
import type { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';
|
|
22
|
+
import {
|
|
23
|
+
FakeClock,
|
|
24
|
+
type ParsedServerConfig,
|
|
25
|
+
StaticMotdProvider,
|
|
26
|
+
createConnection,
|
|
27
|
+
} from '@serverless-ircd/irc-core';
|
|
28
|
+
import { makeTestServerConfig } from '@serverless-ircd/irc-test-support';
|
|
29
|
+
import { describe, expect, it } from 'vitest';
|
|
30
|
+
import { handleDefault } from '../src/handlers/default.js';
|
|
31
|
+
import { marshalConnection } from '../src/serialize.js';
|
|
32
|
+
import type { TablesConfig } from '../src/tables.js';
|
|
33
|
+
|
|
34
|
+
const SERVER_CONFIG: ParsedServerConfig = makeTestServerConfig({
|
|
35
|
+
serverName: 'irc.example.com',
|
|
36
|
+
networkName: 'ExampleNet',
|
|
37
|
+
motdLines: ['Welcome'],
|
|
38
|
+
adapter: { maxFramesPerWindow: 3, frameWindowSeconds: 5 },
|
|
39
|
+
});
|
|
40
|
+
const MOTD = new StaticMotdProvider(SERVER_CONFIG.motdLines);
|
|
41
|
+
const TABLES: TablesConfig = {
|
|
42
|
+
Connections: 'Connections',
|
|
43
|
+
ChannelMeta: 'ChannelMeta',
|
|
44
|
+
ChannelMembers: 'ChannelMembers',
|
|
45
|
+
Nicks: 'Nicks',
|
|
46
|
+
Services: 'Services',
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Minimal fake document client: returns the seeded row on GetCommand and
|
|
51
|
+
* applies the pieces of the persist expression the frame-limit
|
|
52
|
+
* assertions care about (revision bump, frameTimes SET, capNegotiating).
|
|
53
|
+
*/
|
|
54
|
+
class FakeDynamo {
|
|
55
|
+
readonly items = new Map<string, Record<string, unknown>>();
|
|
56
|
+
updateCalls = 0;
|
|
57
|
+
getCalls = 0;
|
|
58
|
+
|
|
59
|
+
async send(cmd: unknown): Promise<{ Item?: Record<string, unknown> } | Record<string, never>> {
|
|
60
|
+
if (cmd instanceof GetCommand) {
|
|
61
|
+
this.getCalls += 1;
|
|
62
|
+
const input = (cmd as unknown as { input: { Key: { connectionId: string } } }).input;
|
|
63
|
+
const item = this.items.get(input.Key.connectionId);
|
|
64
|
+
return item === undefined ? {} : { Item: structuredClone(item) };
|
|
65
|
+
}
|
|
66
|
+
if (cmd instanceof UpdateCommand) {
|
|
67
|
+
this.updateCalls += 1;
|
|
68
|
+
const input = (
|
|
69
|
+
cmd as unknown as {
|
|
70
|
+
input: {
|
|
71
|
+
Key: { connectionId: string };
|
|
72
|
+
ExpressionAttributeValues?: Record<string, unknown>;
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
).input;
|
|
76
|
+
const cur = this.items.get(input.Key.connectionId);
|
|
77
|
+
if (cur !== undefined) {
|
|
78
|
+
cur.revision = ((cur.revision as number | undefined) ?? 0) + 1;
|
|
79
|
+
const vals = input.ExpressionAttributeValues ?? {};
|
|
80
|
+
if (vals[':capN'] !== undefined) cur.capNegotiating = vals[':capN'];
|
|
81
|
+
if (vals[':ft'] !== undefined) cur.frameTimes = vals[':ft'];
|
|
82
|
+
}
|
|
83
|
+
return {};
|
|
84
|
+
}
|
|
85
|
+
return {};
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Management-API double that records posts AND connection deletes. */
|
|
90
|
+
class RecordingMgmt {
|
|
91
|
+
readonly posted: string[] = [];
|
|
92
|
+
readonly deleted: string[] = [];
|
|
93
|
+
|
|
94
|
+
async postToConnection(input: { ConnectionId: string; Data: string }): Promise<unknown> {
|
|
95
|
+
this.posted.push(input.Data);
|
|
96
|
+
return {};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async deleteConnection(input: { ConnectionId: string }): Promise<unknown> {
|
|
100
|
+
this.deleted.push(input.ConnectionId);
|
|
101
|
+
return {};
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** `PostToConnection`-shaped double with NO deleteConnection arm. */
|
|
106
|
+
class PostOnlyMgmt {
|
|
107
|
+
readonly posted: string[] = [];
|
|
108
|
+
|
|
109
|
+
async postToConnection(input: { ConnectionId: string; Data: string }): Promise<unknown> {
|
|
110
|
+
this.posted.push(input.Data);
|
|
111
|
+
return {};
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Management double whose postToConnection rejects (socket already gone). */
|
|
116
|
+
class FailingMgmt {
|
|
117
|
+
async postToConnection(): Promise<unknown> {
|
|
118
|
+
throw new Error('GoneException');
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
interface Fixture {
|
|
123
|
+
dynamo: FakeDynamo;
|
|
124
|
+
clock: FakeClock;
|
|
125
|
+
connId: string;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function seed(connId: string, startMs = 1_000): Fixture {
|
|
129
|
+
const dynamo = new FakeDynamo();
|
|
130
|
+
const state = createConnection({ id: connId, connectedSince: startMs });
|
|
131
|
+
state.capNegotiating = true;
|
|
132
|
+
dynamo.items.set(connId, { ...marshalConnection(state, startMs) });
|
|
133
|
+
return { dynamo, clock: new FakeClock(startMs), connId };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function run(
|
|
137
|
+
fixture: Fixture,
|
|
138
|
+
body: string,
|
|
139
|
+
mgmt: unknown = null,
|
|
140
|
+
): Promise<{ statusCode: number }> {
|
|
141
|
+
return handleDefault({
|
|
142
|
+
dynamo: fixture.dynamo as unknown as DynamoDBDocumentClient,
|
|
143
|
+
tables: TABLES,
|
|
144
|
+
connectionId: fixture.connId,
|
|
145
|
+
body,
|
|
146
|
+
serverConfig: SERVER_CONFIG,
|
|
147
|
+
motd: MOTD,
|
|
148
|
+
managementApi: mgmt as never,
|
|
149
|
+
clock: fixture.clock,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
describe('handleDefault — inbound frame-rate window (3 frames / 5 s)', () => {
|
|
154
|
+
it('admits frames under and at the ceiling, persisting the window on the row', async () => {
|
|
155
|
+
const fx = seed('c-admit');
|
|
156
|
+
for (let i = 1; i <= 3; i++) {
|
|
157
|
+
const res = await run(fx, `PING :${i}\r\n`);
|
|
158
|
+
expect(res.statusCode).toBe(200);
|
|
159
|
+
}
|
|
160
|
+
expect(fx.dynamo.updateCalls).toBe(3);
|
|
161
|
+
const row = fx.dynamo.items.get('c-admit');
|
|
162
|
+
expect((row?.frameTimes as number[]).length).toBe(3);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('closes the connection on the over-threshold frame without running the actor or persisting', async () => {
|
|
166
|
+
const fx = seed('c-flood');
|
|
167
|
+
const mgmt = new RecordingMgmt();
|
|
168
|
+
for (let i = 1; i <= 3; i++) {
|
|
169
|
+
await run(fx, `PING :${i}\r\n`, mgmt);
|
|
170
|
+
}
|
|
171
|
+
const before = fx.dynamo.items.get('c-flood');
|
|
172
|
+
|
|
173
|
+
const res = await run(fx, 'PING :4\r\n', mgmt);
|
|
174
|
+
expect(res.statusCode).toBe(200);
|
|
175
|
+
// No actor run, no state write: the row is untouched.
|
|
176
|
+
expect(fx.dynamo.updateCalls).toBe(3);
|
|
177
|
+
expect(fx.dynamo.items.get('c-flood')).toEqual(before);
|
|
178
|
+
// The ERROR line was delivered, then the socket was closed via the
|
|
179
|
+
// management API (DeleteConnection fires the $disconnect cleanup).
|
|
180
|
+
expect(mgmt.posted).toContain('ERROR :Closing link (flooding)');
|
|
181
|
+
expect(mgmt.deleted).toEqual(['c-flood']);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('admits a fresh burst after the window decays (sliding window)', async () => {
|
|
185
|
+
const fx = seed('c-decay');
|
|
186
|
+
for (let i = 1; i <= 3; i++) {
|
|
187
|
+
await run(fx, `PING :${i}\r\n`);
|
|
188
|
+
}
|
|
189
|
+
// 50 frames, wait 6 s, more frames — per the ticket's decay contract.
|
|
190
|
+
fx.clock.advance(6_000);
|
|
191
|
+
const res = await run(fx, 'PING :post-window\r\n');
|
|
192
|
+
expect(res.statusCode).toBe(200);
|
|
193
|
+
const row = fx.dynamo.items.get('c-decay');
|
|
194
|
+
// The aged burst decayed out; only the fresh frame's timestamp
|
|
195
|
+
// remains in the persisted window.
|
|
196
|
+
expect((row?.frameTimes as number[]).length).toBe(1);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('skips the actor and close when managementApi is null (no post, no delete, no write)', async () => {
|
|
200
|
+
const fx = seed('c-null-mgmt');
|
|
201
|
+
for (let i = 1; i <= 3; i++) {
|
|
202
|
+
await run(fx, `PING :${i}\r\n`);
|
|
203
|
+
}
|
|
204
|
+
const res = await run(fx, 'PING :4\r\n');
|
|
205
|
+
expect(res.statusCode).toBe(200);
|
|
206
|
+
expect(fx.dynamo.updateCalls).toBe(3);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it('delivers the ERROR line but skips the delete when the api double has no deleteConnection', async () => {
|
|
210
|
+
const fx = seed('c-post-only');
|
|
211
|
+
const mgmt = new PostOnlyMgmt();
|
|
212
|
+
for (let i = 1; i <= 3; i++) {
|
|
213
|
+
await run(fx, `PING :${i}\r\n`, mgmt);
|
|
214
|
+
}
|
|
215
|
+
const res = await run(fx, 'PING :4\r\n', mgmt);
|
|
216
|
+
expect(res.statusCode).toBe(200);
|
|
217
|
+
expect(mgmt.posted).toContain('ERROR :Closing link (flooding)');
|
|
218
|
+
expect(fx.dynamo.updateCalls).toBe(3);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it('still returns 200 when the flood-close post fails (socket already gone)', async () => {
|
|
222
|
+
const fx = seed('c-post-fail');
|
|
223
|
+
const mgmt = new FailingMgmt();
|
|
224
|
+
for (let i = 1; i <= 3; i++) {
|
|
225
|
+
await run(fx, `PING :${i}\r\n`, mgmt);
|
|
226
|
+
}
|
|
227
|
+
const res = await run(fx, 'PING :4\r\n', mgmt);
|
|
228
|
+
expect(res.statusCode).toBe(200);
|
|
229
|
+
expect(fx.dynamo.updateCalls).toBe(3);
|
|
230
|
+
});
|
|
231
|
+
});
|