serverless-ircd 0.10.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-cf-tcp.yml +26 -2
- package/.github/workflows/deploy-cf.yml +26 -0
- package/CHANGELOG.md +289 -0
- package/README.md +153 -20
- package/apps/aws-stack/bin/aws.ts +36 -0
- package/apps/aws-stack/package.json +1 -1
- package/apps/aws-stack/src/aws-stack.ts +221 -15
- package/apps/aws-stack/tests/stack.test.ts +450 -16
- package/apps/cf-tcp-container/Dockerfile +37 -5
- package/apps/cf-tcp-container/package.json +7 -2
- package/apps/cf-tcp-container/src/config-loader.ts +113 -2
- package/apps/cf-tcp-container/src/container-server.ts +256 -79
- 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 +17 -4
- package/apps/cf-worker/package.json +2 -2
- 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 +86 -9
- 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 +23 -2
- package/apps/local-cli/tests/line-scanner.test.ts +64 -0
- package/apps/local-cli/tests/tcp.test.ts +29 -0
- package/apps/web/package.json +1 -1
- package/docs/AWS-Deployment.md +123 -22
- package/docs/AWS-TCP-Deployment.md +37 -2
- package/docs/Chat-History.md +55 -0
- package/docs/Cloudflare-Deployment-Guide.md +9 -2
- package/docs/Cloudflare-TCP-Deployment.md +135 -52
- package/docs/SASL-EXTERNAL.md +175 -0
- package/package.json +3 -3
- 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 +34 -6
- package/packages/aws-adapter/src/config-loader.ts +134 -6
- package/packages/aws-adapter/src/dynamo-services-store.ts +12 -0
- package/packages/aws-adapter/src/handlers/connect.ts +47 -1
- package/packages/aws-adapter/src/handlers/default.ts +95 -6
- package/packages/aws-adapter/src/handlers/index.ts +31 -2
- package/packages/aws-adapter/src/handlers/nlb-stream.ts +132 -8
- package/packages/aws-adapter/src/ip-admission.ts +79 -0
- package/packages/aws-adapter/src/serialize.ts +8 -0
- package/packages/aws-adapter/src/tables.ts +9 -0
- package/packages/aws-adapter/tests/admission.test.ts +60 -2
- package/packages/aws-adapter/tests/aws-harness.ts +23 -1
- package/packages/aws-adapter/tests/aws-runtime.test.ts +64 -0
- package/packages/aws-adapter/tests/config-loader.test.ts +151 -0
- package/packages/aws-adapter/tests/connect.test.ts +199 -2
- package/packages/aws-adapter/tests/default-frame-limit.test.ts +231 -0
- package/packages/aws-adapter/tests/default-occ.test.ts +10 -3
- package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +123 -1
- package/packages/aws-adapter/tests/handlers.test.ts +57 -1
- package/packages/aws-adapter/tests/nlb-secure.test.ts +362 -0
- package/packages/aws-adapter/tests/nlb-stream.test.ts +628 -9
- package/packages/cf-adapter/package.json +1 -1
- package/packages/cf-adapter/src/cf-runtime.ts +48 -9
- package/packages/cf-adapter/src/config-loader.ts +133 -8
- package/packages/cf-adapter/src/connection-do.ts +154 -21
- package/packages/cf-adapter/src/counter-do.ts +142 -0
- package/packages/cf-adapter/src/d1-services-store.ts +47 -5
- package/packages/cf-adapter/src/env.ts +88 -0
- package/packages/cf-adapter/src/index.ts +17 -1
- package/packages/cf-adapter/src/rate-limit-do.ts +87 -0
- package/packages/cf-adapter/tests/cf-runtime.test.ts +104 -15
- 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-frame-limit.test.ts +177 -0
- package/packages/cf-adapter/tests/connection-do-pure.test.ts +74 -5
- 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 +192 -1
- package/packages/cf-adapter/tests/rate-limit-do.test.ts +160 -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/caps/capabilities.ts +20 -10
- package/packages/irc-core/src/certfp.ts +178 -0
- 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/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 +40 -1
- 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 +146 -2
- package/packages/irc-core/src/commands/sasl.ts +136 -19
- 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 +393 -20
- 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 +8 -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 +395 -36
- 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 +228 -13
- package/packages/irc-core/src/ws-framing.ts +5 -4
- 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/names.test.ts +193 -0
- package/packages/irc-core/tests/commands/nickserv.test.ts +182 -2
- package/packages/irc-core/tests/commands/oper.test.ts +560 -2
- package/packages/irc-core/tests/commands/privmsg.test.ts +16 -0
- package/packages/irc-core/tests/commands/registration.test.ts +463 -1
- package/packages/irc-core/tests/commands/sasl.test.ts +596 -7
- package/packages/irc-core/tests/commands/topic.test.ts +137 -2
- package/packages/irc-core/tests/commands/unified-account.test.ts +2 -0
- package/packages/irc-core/tests/config.test.ts +534 -2
- 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/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 +141 -0
- package/packages/irc-core/tests/ports.test.ts +99 -7
- package/packages/irc-core/tests/services-store.test.ts +376 -14
- package/packages/irc-core/tests/ws-framing.test.ts +45 -0
- package/packages/irc-server/package.json +1 -1
- package/packages/irc-server/src/actor.ts +123 -8
- 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 +544 -7
- 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/scripts/package.json +1 -1
- 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 +17 -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-hostname.test.ts +348 -0
- package/tools/ci-hardening/tests/env-var-drift.test.ts +284 -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/tcp-ws-forwarder/package.json +1 -1
|
@@ -51,6 +51,35 @@ describe('buildLambdaConfigInput', () => {
|
|
|
51
51
|
expect(out.operCreds).toEqual([{ user: '', password: 'pw' }]);
|
|
52
52
|
});
|
|
53
53
|
|
|
54
|
+
it('builds a hashed operCred when OPER_USER + OPER_SALT + OPER_HASH are set', () => {
|
|
55
|
+
const out = buildLambdaConfigInput({
|
|
56
|
+
OPER_USER: 'admin',
|
|
57
|
+
OPER_SALT: 'c2FsdA==',
|
|
58
|
+
OPER_HASH: 'aGFzaA==',
|
|
59
|
+
});
|
|
60
|
+
expect(out.operCreds).toEqual([{ user: 'admin', salt: 'c2FsdA==', hash: 'aGFzaA==' }]);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('prefers the hashed form over plaintext when both are set', () => {
|
|
64
|
+
const out = buildLambdaConfigInput({
|
|
65
|
+
OPER_USER: 'admin',
|
|
66
|
+
OPER_PASSWORD: 'legacy',
|
|
67
|
+
OPER_SALT: 'c2FsdA==',
|
|
68
|
+
OPER_HASH: 'aGFzaA==',
|
|
69
|
+
});
|
|
70
|
+
expect(out.operCreds).toEqual([{ user: 'admin', salt: 'c2FsdA==', hash: 'aGFzaA==' }]);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('does NOT build operCreds when only OPER_SALT + OPER_HASH are set (no OPER_USER)', () => {
|
|
74
|
+
// Without OPER_USER the loader has no `user` field to thread; the
|
|
75
|
+
// hashed form requires a user name. The schema's superRefine would
|
|
76
|
+
// catch a missing user too, but the loader opts to omit operCreds
|
|
77
|
+
// entirely so the no-op (vs. `491 ERR_NOOPERHOST`) behaviour stays
|
|
78
|
+
// consistent with the legacy "no OPER_USER" path.
|
|
79
|
+
const out = buildLambdaConfigInput({ OPER_SALT: 'c2FsdA==', OPER_HASH: 'aGFzaA==' });
|
|
80
|
+
expect('operCreds' in out).toBe(false);
|
|
81
|
+
});
|
|
82
|
+
|
|
54
83
|
it('threads SERVER_PASSWORD through to serverPassword', () => {
|
|
55
84
|
expect(buildLambdaConfigInput({ SERVER_PASSWORD: 'hunter2' }).serverPassword).toBe('hunter2');
|
|
56
85
|
});
|
|
@@ -314,3 +343,125 @@ describe('loadServerConfigFromLambdaEnv', () => {
|
|
|
314
343
|
]);
|
|
315
344
|
});
|
|
316
345
|
});
|
|
346
|
+
|
|
347
|
+
describe('loadServerConfigFromLambdaEnv — EXTERNAL_ENABLED (SASL EXTERNAL opt-in)', () => {
|
|
348
|
+
it('leaves SASL EXTERNAL off when EXTERNAL_ENABLED is unset', () => {
|
|
349
|
+
const cfg = loadServerConfigFromLambdaEnv({ SERVER_NAME: 's', NETWORK_NAME: 'n' });
|
|
350
|
+
expect(cfg.sasl?.externalEnabled ?? false).toBe(false);
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
it('maps EXTERNAL_ENABLED=true to sasl.externalEnabled', () => {
|
|
354
|
+
const cfg = loadServerConfigFromLambdaEnv({
|
|
355
|
+
SERVER_NAME: 's',
|
|
356
|
+
NETWORK_NAME: 'n',
|
|
357
|
+
EXTERNAL_ENABLED: 'true',
|
|
358
|
+
});
|
|
359
|
+
expect(cfg.sasl?.externalEnabled).toBe(true);
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
it('maps EXTERNAL_ENABLED=1 to sasl.externalEnabled', () => {
|
|
363
|
+
const cfg = loadServerConfigFromLambdaEnv({
|
|
364
|
+
SERVER_NAME: 's',
|
|
365
|
+
NETWORK_NAME: 'n',
|
|
366
|
+
EXTERNAL_ENABLED: '1',
|
|
367
|
+
});
|
|
368
|
+
expect(cfg.sasl?.externalEnabled).toBe(true);
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
it('maps EXTERNAL_ENABLED=false (and any other value) to false', () => {
|
|
372
|
+
const cfg = loadServerConfigFromLambdaEnv({
|
|
373
|
+
SERVER_NAME: 's',
|
|
374
|
+
NETWORK_NAME: 'n',
|
|
375
|
+
EXTERNAL_ENABLED: 'false',
|
|
376
|
+
});
|
|
377
|
+
expect(cfg.sasl?.externalEnabled).toBe(false);
|
|
378
|
+
const junk = loadServerConfigFromLambdaEnv({
|
|
379
|
+
SERVER_NAME: 's',
|
|
380
|
+
NETWORK_NAME: 'n',
|
|
381
|
+
EXTERNAL_ENABLED: 'yes please',
|
|
382
|
+
});
|
|
383
|
+
expect(junk.sasl?.externalEnabled ?? false).toBe(false);
|
|
384
|
+
});
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
describe('loadServerConfigFromLambdaEnv — adapter frame-rate limit', () => {
|
|
388
|
+
it('defaults the adapter frame window when the vars are unset', () => {
|
|
389
|
+
const cfg = loadServerConfigFromLambdaEnv({ SERVER_NAME: 's', NETWORK_NAME: 'n' });
|
|
390
|
+
expect(cfg.adapter).toEqual({ maxFramesPerWindow: 50, frameWindowSeconds: 5 });
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
it('maps MAX_FRAMES_PER_WINDOW + FRAME_WINDOW_SECONDS to the adapter section', () => {
|
|
394
|
+
const cfg = loadServerConfigFromLambdaEnv({
|
|
395
|
+
SERVER_NAME: 's',
|
|
396
|
+
NETWORK_NAME: 'n',
|
|
397
|
+
MAX_FRAMES_PER_WINDOW: '20',
|
|
398
|
+
FRAME_WINDOW_SECONDS: '10',
|
|
399
|
+
});
|
|
400
|
+
expect(cfg.adapter).toEqual({ maxFramesPerWindow: 20, frameWindowSeconds: 10 });
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
it('fills the unset knob with its schema default when only one var is set', () => {
|
|
404
|
+
const cfg = loadServerConfigFromLambdaEnv({
|
|
405
|
+
SERVER_NAME: 's',
|
|
406
|
+
NETWORK_NAME: 'n',
|
|
407
|
+
FRAME_WINDOW_SECONDS: '30',
|
|
408
|
+
});
|
|
409
|
+
expect(cfg.adapter).toEqual({ maxFramesPerWindow: 50, frameWindowSeconds: 30 });
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
it('throws when MAX_FRAMES_PER_WINDOW is non-numeric', () => {
|
|
413
|
+
expect(() =>
|
|
414
|
+
loadServerConfigFromLambdaEnv({
|
|
415
|
+
SERVER_NAME: 's',
|
|
416
|
+
NETWORK_NAME: 'n',
|
|
417
|
+
MAX_FRAMES_PER_WINDOW: 'many',
|
|
418
|
+
}),
|
|
419
|
+
).toThrowError(/maxFramesPerWindow/u);
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
it('throws when FRAME_WINDOW_SECONDS is non-positive', () => {
|
|
423
|
+
expect(() =>
|
|
424
|
+
loadServerConfigFromLambdaEnv({
|
|
425
|
+
SERVER_NAME: 's',
|
|
426
|
+
NETWORK_NAME: 'n',
|
|
427
|
+
FRAME_WINDOW_SECONDS: '-1',
|
|
428
|
+
}),
|
|
429
|
+
).toThrowError(/frameWindowSeconds/u);
|
|
430
|
+
});
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
describe('loadServerConfigFromLambdaEnv — per-IP admission knobs', () => {
|
|
434
|
+
it('defaults maxConnectionsPerIp / perIpConnectionRate from the schema', () => {
|
|
435
|
+
const cfg = loadServerConfigFromLambdaEnv({ SERVER_NAME: 's', NETWORK_NAME: 'n' });
|
|
436
|
+
expect(cfg.maxConnectionsPerIp).toBe(10);
|
|
437
|
+
expect(cfg.perIpConnectionRate).toEqual({ max: 5, windowMs: 60_000 });
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
it('parses MAX_CONNECTIONS_PER_IP as an integer', () => {
|
|
441
|
+
const cfg = loadServerConfigFromLambdaEnv({
|
|
442
|
+
SERVER_NAME: 's',
|
|
443
|
+
NETWORK_NAME: 'n',
|
|
444
|
+
MAX_CONNECTIONS_PER_IP: '4',
|
|
445
|
+
});
|
|
446
|
+
expect(cfg.maxConnectionsPerIp).toBe(4);
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
it('parses PER_IP_CONNECTION_RATE_MAX / _WINDOW_MS into the rate knob', () => {
|
|
450
|
+
const cfg = loadServerConfigFromLambdaEnv({
|
|
451
|
+
SERVER_NAME: 's',
|
|
452
|
+
NETWORK_NAME: 'n',
|
|
453
|
+
PER_IP_CONNECTION_RATE_MAX: '10',
|
|
454
|
+
PER_IP_CONNECTION_RATE_WINDOW_MS: '30000',
|
|
455
|
+
});
|
|
456
|
+
expect(cfg.perIpConnectionRate).toEqual({ max: 10, windowMs: 30_000 });
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
it('fills the unset half of the rate knob with the schema default', () => {
|
|
460
|
+
const cfg = loadServerConfigFromLambdaEnv({
|
|
461
|
+
SERVER_NAME: 's',
|
|
462
|
+
NETWORK_NAME: 'n',
|
|
463
|
+
PER_IP_CONNECTION_RATE_WINDOW_MS: '10000',
|
|
464
|
+
});
|
|
465
|
+
expect(cfg.perIpConnectionRate).toEqual({ max: 5, windowMs: 10_000 });
|
|
466
|
+
});
|
|
467
|
+
});
|
|
@@ -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';
|
|
@@ -30,16 +35,27 @@ interface Stub {
|
|
|
30
35
|
decrements: number;
|
|
31
36
|
/** Commands issued, in order (for asserting no Scan is used). */
|
|
32
37
|
commands: string[];
|
|
38
|
+
/** QueryCommand inputs issued (for asserting the GSI consultation). */
|
|
39
|
+
queries: Array<Record<string, unknown>>;
|
|
33
40
|
}
|
|
34
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
|
+
|
|
35
48
|
/**
|
|
36
49
|
* Builds a stub client with an initial admission counter value. The stub
|
|
37
50
|
* honours the floor-at-0 decrement condition the real handler relies on.
|
|
51
|
+
* `gsiPages` simulates the sourceIp-GSI Query result pages.
|
|
38
52
|
*/
|
|
39
|
-
function makeStub(initialCount: number): Stub {
|
|
53
|
+
function makeStub(initialCount: number, gsiPages: QueryPages = [[]]): Stub {
|
|
40
54
|
const state = { count: initialCount, puts: 0, increments: 0, decrements: 0 };
|
|
41
55
|
let lastPutItem: Record<string, unknown> | undefined;
|
|
42
56
|
const commands: string[] = [];
|
|
57
|
+
const queries: Array<Record<string, unknown>> = [];
|
|
58
|
+
let queryCall = 0;
|
|
43
59
|
const dynamo = {
|
|
44
60
|
send: async (cmd: unknown) => {
|
|
45
61
|
if (cmd instanceof UpdateCommand) {
|
|
@@ -64,8 +80,22 @@ function makeStub(initialCount: number): Stub {
|
|
|
64
80
|
}
|
|
65
81
|
return { Attributes: { count: state.count } };
|
|
66
82
|
}
|
|
83
|
+
// Non-counter Update expressions are not expected on this path.
|
|
67
84
|
throw new Error(`unexpected Update expression: ${expr}`);
|
|
68
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
|
+
}
|
|
69
99
|
if (cmd instanceof PutCommand) {
|
|
70
100
|
commands.push('Put');
|
|
71
101
|
state.puts += 1;
|
|
@@ -96,6 +126,9 @@ function makeStub(initialCount: number): Stub {
|
|
|
96
126
|
get commands() {
|
|
97
127
|
return commands;
|
|
98
128
|
},
|
|
129
|
+
get queries() {
|
|
130
|
+
return queries;
|
|
131
|
+
},
|
|
99
132
|
};
|
|
100
133
|
}
|
|
101
134
|
|
|
@@ -421,3 +454,167 @@ describe('handleConnect — Origin allowlist (CSWSH defence)', () => {
|
|
|
421
454
|
expect(outcome.admitted === false && outcome.statusCode).toBe(403);
|
|
422
455
|
});
|
|
423
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
|
+
});
|
|
@@ -49,23 +49,30 @@ const TABLES: TablesConfig = {
|
|
|
49
49
|
describe('buildPersistUpdate — optimistic-concurrency revision guard', () => {
|
|
50
50
|
it('adds an ADD #rev :revInc clause to the update expression', () => {
|
|
51
51
|
const state = createConnection({ id: 'c1', connectedSince: 1 });
|
|
52
|
-
const expr = buildPersistUpdate(state, 100, 7);
|
|
52
|
+
const expr = buildPersistUpdate(state, 100, 7, []);
|
|
53
53
|
expect(expr.updateExpression).toContain('ADD #rev :revInc');
|
|
54
54
|
});
|
|
55
55
|
|
|
56
56
|
it('gates the write on attribute_not_exists(#rev) OR #rev = :expectedRev', () => {
|
|
57
57
|
const state = createConnection({ id: 'c1', connectedSince: 1 });
|
|
58
|
-
const expr = buildPersistUpdate(state, 100, 7);
|
|
58
|
+
const expr = buildPersistUpdate(state, 100, 7, []);
|
|
59
59
|
expect(expr.conditionExpression).toBe('attribute_not_exists(#rev) OR #rev = :expectedRev');
|
|
60
60
|
});
|
|
61
61
|
|
|
62
62
|
it('binds #rev to the revision column and :expectedRev to the read revision', () => {
|
|
63
63
|
const state = createConnection({ id: 'c1', connectedSince: 1 });
|
|
64
|
-
const expr = buildPersistUpdate(state, 100, 7);
|
|
64
|
+
const expr = buildPersistUpdate(state, 100, 7, []);
|
|
65
65
|
expect(expr.expressionAttributeNames['#rev']).toBe('revision');
|
|
66
66
|
expect(expr.expressionAttributeValues[':expectedRev']).toBe(7);
|
|
67
67
|
expect(expr.expressionAttributeValues[':revInc']).toBe(1);
|
|
68
68
|
});
|
|
69
|
+
|
|
70
|
+
it('persists the inbound frame-window timestamps in the same atomic write', () => {
|
|
71
|
+
const state = createConnection({ id: 'c1', connectedSince: 1 });
|
|
72
|
+
const expr = buildPersistUpdate(state, 100, 7, [90, 95]);
|
|
73
|
+
expect(expr.updateExpression).toContain('frameTimes = :ft');
|
|
74
|
+
expect(expr.expressionAttributeValues[':ft']).toEqual([90, 95]);
|
|
75
|
+
});
|
|
69
76
|
});
|
|
70
77
|
|
|
71
78
|
describe('isConditionalCheckFailed', () => {
|