serverless-ircd 0.8.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/ci.yml +4 -0
- package/.github/workflows/deploy-aws.yml +156 -32
- package/.github/workflows/deploy-cf-tcp.yml +11 -9
- package/.github/workflows/deploy-cf.yml +14 -14
- package/CHANGELOG.md +550 -0
- package/README.md +275 -222
- package/apps/aws-stack/README.md +3 -5
- package/apps/aws-stack/bin/aws.ts +82 -9
- package/apps/aws-stack/cdk.json +0 -3
- package/apps/aws-stack/package.json +3 -4
- package/apps/aws-stack/src/aws-stack.ts +177 -52
- package/apps/aws-stack/src/static-site.ts +323 -0
- package/apps/aws-stack/tests/smoke-helpers.test.ts +1 -1
- package/apps/aws-stack/tests/stack.test.ts +267 -92
- package/apps/aws-stack/tests/static-site.test.ts +491 -0
- package/apps/aws-stack/tests/synth-no-bundle.test.ts +0 -1
- package/apps/cf-tcp-container/package.json +2 -3
- package/apps/cf-tcp-container/src/container-server.ts +33 -10
- package/apps/cf-tcp-container/tests/config-loader.test.ts +43 -0
- package/apps/cf-tcp-container/tests/container-server.test.ts +249 -1
- package/apps/cf-tcp-container/tests/persistence.test.ts +9 -0
- package/apps/cf-tcp-container/tests/tls-e2e.test.ts +24 -5
- package/apps/cf-tcp-container/wrangler.toml +1 -10
- package/apps/cf-worker/package.json +3 -4
- package/apps/cf-worker/wrangler.toml +12 -71
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/server.ts +115 -48
- package/apps/local-cli/tests/config-resolution.test.ts +65 -0
- package/apps/local-cli/tests/motd-file-non-error.test.ts +29 -0
- package/apps/local-cli/tests/rehash.test.ts +147 -0
- package/apps/local-cli/tests/server-helpers.test.ts +63 -0
- package/apps/local-cli/tests/tcp.test.ts +89 -0
- package/apps/local-cli/tests/ws-subprotocol.test.ts +92 -0
- package/apps/web/landing/favicon.ico +0 -0
- package/apps/web/landing/index.html +227 -3
- package/apps/web/package.json +3 -2
- package/apps/web/scripts/build.mjs +91 -6
- package/apps/web/src/build-env.ts +125 -4
- package/apps/web/src/config-schema.ts +20 -6
- package/apps/web/src/render-docs.ts +292 -0
- package/apps/web/static/{config.staging.json → config.prod-aws.json} +3 -2
- package/apps/web/tests/build-env.test.ts +210 -9
- package/apps/web/tests/build-smoke.test.ts +33 -4
- package/apps/web/tests/config-schema.test.ts +149 -25
- package/apps/web/tests/landing-content.test.ts +103 -0
- package/apps/web/tests/render-docs.test.ts +198 -0
- package/docs/AWS-Adapter-Architecture.md +3 -2
- package/docs/AWS-Deployment.md +670 -96
- package/docs/AWS-TCP-Deployment.md +20 -45
- package/docs/Cloudflare-Deployment-Guide.md +87 -113
- package/docs/Cloudflare-TCP-Deployment.md +25 -49
- package/docs/Release-Process.md +27 -23
- package/docs/Services.md +102 -23
- package/docs/WebClientGuide.md +35 -26
- package/package.json +7 -10
- package/packages/aws-adapter/package.json +1 -1
- package/packages/aws-adapter/src/aws-runtime.ts +15 -1
- package/packages/aws-adapter/src/cdk-table-defs.ts +6 -11
- package/packages/aws-adapter/src/config-loader.ts +19 -2
- package/packages/aws-adapter/src/dynamo-services-store.ts +7 -0
- package/packages/aws-adapter/src/handlers/connect.ts +26 -0
- package/packages/aws-adapter/src/handlers/default.ts +190 -123
- package/packages/aws-adapter/src/handlers/index.ts +67 -23
- package/packages/aws-adapter/src/handlers/nlb-stream.ts +13 -8
- package/packages/aws-adapter/src/index.ts +5 -7
- package/packages/aws-adapter/src/origin-allowlist.ts +94 -0
- package/packages/aws-adapter/src/serialize.ts +15 -0
- package/packages/aws-adapter/src/tables.ts +2 -12
- package/packages/aws-adapter/tests/aws-harness.ts +0 -1
- package/packages/aws-adapter/tests/aws-runtime.test.ts +23 -1
- package/packages/aws-adapter/tests/config-loader.test.ts +66 -0
- package/packages/aws-adapter/tests/connect.test.ts +124 -1
- package/packages/aws-adapter/tests/connection-counter.test.ts +17 -0
- package/packages/aws-adapter/tests/default-occ.test.ts +219 -0
- package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +11 -0
- package/packages/aws-adapter/tests/global-setup.ts +28 -1
- package/packages/aws-adapter/tests/gone-exception.test.ts +21 -2
- package/packages/aws-adapter/tests/handlers.test.ts +117 -11
- package/packages/aws-adapter/tests/migrate-accounts-to-services.test.ts +164 -0
- package/packages/aws-adapter/tests/nlb-stream.test.ts +29 -1
- package/packages/aws-adapter/tests/origin-allowlist.test.ts +110 -0
- package/packages/aws-adapter/tests/ping-checker.test.ts +0 -1
- package/packages/aws-adapter/tests/stats.test.ts +0 -3
- package/packages/aws-adapter/tests/sweeper.test.ts +20 -1
- package/packages/aws-adapter/tests/tables.test.ts +1 -8
- package/packages/aws-adapter/tests/transactions.test.ts +0 -1
- package/packages/cf-adapter/package.json +1 -5
- package/packages/cf-adapter/src/cf-runtime.ts +59 -8
- package/packages/cf-adapter/src/channel-do.ts +13 -3
- package/packages/cf-adapter/src/connection-do.ts +284 -115
- package/packages/cf-adapter/src/d1-services-store.ts +63 -26
- package/packages/cf-adapter/src/env.ts +11 -10
- package/packages/cf-adapter/src/index.ts +0 -6
- package/packages/cf-adapter/tests/cf-runtime.test.ts +101 -1
- package/packages/cf-adapter/tests/channel-do.test.ts +118 -1
- package/packages/cf-adapter/tests/connection-do-coverage.test.ts +460 -0
- package/packages/cf-adapter/tests/connection-do-pure.test.ts +222 -51
- package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +62 -38
- package/packages/cf-adapter/tests/d1-services-store.test.ts +53 -2
- package/packages/cf-adapter/tests/serialize.test.ts +25 -0
- package/packages/in-memory-runtime/package.json +1 -1
- package/packages/irc-core/package.json +1 -1
- package/packages/irc-core/src/account-migration.ts +140 -0
- package/packages/irc-core/src/commands/account-auth.ts +60 -35
- package/packages/irc-core/src/commands/chanserv.ts +288 -4
- package/packages/irc-core/src/commands/hostserv.ts +38 -3
- package/packages/irc-core/src/commands/index.ts +1 -0
- package/packages/irc-core/src/commands/join.ts +41 -35
- package/packages/irc-core/src/commands/memoserv.ts +1 -1
- package/packages/irc-core/src/commands/nickserv.ts +138 -15
- package/packages/irc-core/src/commands/registration.ts +28 -17
- package/packages/irc-core/src/commands/sasl.ts +22 -31
- package/packages/irc-core/src/commands/service-aliases.ts +52 -0
- package/packages/irc-core/src/commands/topic.ts +23 -10
- package/packages/irc-core/src/config.ts +35 -9
- package/packages/irc-core/src/credential-hashing.ts +11 -54
- package/packages/irc-core/src/index.ts +1 -0
- package/packages/irc-core/src/ports.ts +159 -179
- package/packages/irc-core/src/state/channel.ts +17 -0
- package/packages/irc-core/src/types.ts +38 -10
- package/packages/irc-core/tests/account-migration.test.ts +133 -0
- package/packages/irc-core/tests/commands/chanserv.test.ts +668 -1
- package/packages/irc-core/tests/commands/hostserv.test.ts +71 -0
- package/packages/irc-core/tests/commands/join.test.ts +179 -0
- package/packages/irc-core/tests/commands/markread.test.ts +54 -0
- package/packages/irc-core/tests/commands/memoserv.test.ts +19 -0
- package/packages/irc-core/tests/commands/nickserv.test.ts +422 -3
- package/packages/irc-core/tests/commands/oper.test.ts +15 -0
- package/packages/irc-core/tests/commands/registration.test.ts +336 -108
- package/packages/irc-core/tests/commands/sasl.test.ts +194 -169
- package/packages/irc-core/tests/commands/service-aliases.test.ts +52 -0
- package/packages/irc-core/tests/commands/unified-account.test.ts +102 -84
- package/packages/irc-core/tests/credential-hashing.test.ts +0 -78
- package/packages/irc-core/tests/message-store.test.ts +5 -0
- package/packages/irc-core/tests/persistent-services-store.test.ts +71 -12
- package/packages/irc-core/tests/ports.test.ts +71 -0
- package/packages/irc-core/tests/services-store.test.ts +204 -0
- package/packages/irc-core/vitest.config.ts +6 -1
- package/packages/irc-server/package.json +1 -1
- package/packages/irc-server/src/actor.ts +80 -44
- package/packages/irc-server/tests/actor.test.ts +384 -50
- package/packages/irc-test-support/package.json +1 -1
- package/packages/irc-test-support/src/in-memory-harness.ts +8 -5
- package/packages/irc-test-support/src/scenarios.ts +21 -6
- package/packages/irc-test-support/tests/in-memory-harness.test.ts +19 -0
- package/packages/irc-test-support/vitest.config.ts +6 -1
- package/pnpm-workspace.yaml +1 -0
- package/scripts/__tests__/deploy-web-aws.test.ts +491 -0
- package/scripts/deploy-web-aws.mjs +290 -0
- package/scripts/package.json +23 -0
- package/scripts/tsconfig.test.json +12 -0
- package/scripts/vitest.config.ts +19 -0
- package/tools/ci-hardening/package.json +1 -1
- package/tools/ci-hardening/src/index.ts +2 -0
- package/tools/ci-hardening/src/validate.ts +57 -0
- package/tools/ci-hardening/tests/deploy-aws-oidc.test.ts +96 -0
- package/tools/ci-hardening/tests/validate.test.ts +42 -0
- package/tools/load-test/package.json +1 -1
- package/tools/load-test/src/client.ts +13 -13
- package/tools/load-test/tests/client.test.ts +258 -2
- package/tools/load-test/tests/config.test.ts +39 -0
- package/tools/load-test/tests/harness.test.ts +21 -0
- package/tools/load-test/tests/metrics.test.ts +7 -0
- package/tools/migrate-accounts-to-services.ts +270 -0
- package/tools/package.json +2 -1
- package/tools/seed-aws-accounts.ts +35 -10
- package/tools/seed-cf-accounts.ts +42 -9
- package/tools/tcp-ws-forwarder/package.json +1 -1
- package/tools/tcp-ws-forwarder/tests/close-error.test.ts +40 -0
- package/tools/tcp-ws-forwarder/tests/defensive-branches.test.ts +78 -0
- package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +51 -0
- package/tools/tcp-ws-forwarder/tests/logger.test.ts +31 -1
- package/packages/aws-adapter/src/account-store.ts +0 -121
- package/packages/aws-adapter/src/dynamo-account-store.ts +0 -95
- package/packages/aws-adapter/tests/account-store-dynamo.test.ts +0 -223
- package/packages/aws-adapter/tests/account-store.test.ts +0 -276
- package/packages/cf-adapter/src/d1-account-store.ts +0 -198
- package/packages/cf-adapter/tests/d1-account-store.test.ts +0 -274
- package/packages/irc-core/tests/account-store.test.ts +0 -131
|
@@ -17,7 +17,6 @@ const TABLES: TablesConfig = {
|
|
|
17
17
|
ChannelMeta: 'ChannelMeta',
|
|
18
18
|
ChannelMembers: 'ChannelMembers',
|
|
19
19
|
Nicks: 'Nicks',
|
|
20
|
-
Accounts: 'Accounts',
|
|
21
20
|
};
|
|
22
21
|
|
|
23
22
|
interface Stub {
|
|
@@ -298,3 +297,127 @@ describe('handleConnect — IRCv3 WebSocket subprotocol negotiation', () => {
|
|
|
298
297
|
expect(stub.puts).toBe(0);
|
|
299
298
|
});
|
|
300
299
|
});
|
|
300
|
+
|
|
301
|
+
describe('handleConnect — Origin allowlist (CSWSH defence)', () => {
|
|
302
|
+
// The same lowercase / trim / comma-split semantics as the CF Worker's
|
|
303
|
+
// policy (`apps/cf-worker/src/origin-allowlist.ts`); the parsed set is
|
|
304
|
+
// built once per cold start and injected via `ConnectParams.webOrigins`.
|
|
305
|
+
const allowlist = new Set(['https://app.example.com']);
|
|
306
|
+
|
|
307
|
+
it('denies a disallowed Origin with 403 before touching the counter', async () => {
|
|
308
|
+
// A denied upgrade must NOT consume an admission slot — the Origin
|
|
309
|
+
// check runs before the counter increment so a CSWSH flood from a
|
|
310
|
+
// hostile origin cannot exhaust the `maxClients` cap.
|
|
311
|
+
const stub = makeStub(0);
|
|
312
|
+
const outcome = await handleConnect({
|
|
313
|
+
dynamo: stub.dynamo,
|
|
314
|
+
tables: TABLES,
|
|
315
|
+
connectionId: 'c-cswsh',
|
|
316
|
+
serverConfig: makeTestServerConfig({ maxClients: 5 }),
|
|
317
|
+
originHeader: 'https://attacker.example.com',
|
|
318
|
+
webOrigins: allowlist,
|
|
319
|
+
});
|
|
320
|
+
expect(outcome).toEqual({
|
|
321
|
+
admitted: false,
|
|
322
|
+
statusCode: 403,
|
|
323
|
+
reason: expect.stringMatching(/origin/iu),
|
|
324
|
+
});
|
|
325
|
+
expect(stub.puts).toBe(0);
|
|
326
|
+
expect(stub.increments).toBe(0);
|
|
327
|
+
expect(stub.decrements).toBe(0);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it('admits an allowed Origin (existing connect path)', async () => {
|
|
331
|
+
const stub = makeStub(0);
|
|
332
|
+
const outcome = await handleConnect({
|
|
333
|
+
dynamo: stub.dynamo,
|
|
334
|
+
tables: TABLES,
|
|
335
|
+
connectionId: 'c-ok',
|
|
336
|
+
now: 42,
|
|
337
|
+
serverConfig: makeTestServerConfig({ maxClients: 5 }),
|
|
338
|
+
originHeader: 'https://app.example.com',
|
|
339
|
+
webOrigins: allowlist,
|
|
340
|
+
});
|
|
341
|
+
expect(outcome).toEqual({ admitted: true, subprotocol: null });
|
|
342
|
+
expect(stub.puts).toBe(1);
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
it('case-insensitively matches a mixed-case allowed Origin', async () => {
|
|
346
|
+
const stub = makeStub(0);
|
|
347
|
+
const outcome = await handleConnect({
|
|
348
|
+
dynamo: stub.dynamo,
|
|
349
|
+
tables: TABLES,
|
|
350
|
+
connectionId: 'c-case',
|
|
351
|
+
serverConfig: makeTestServerConfig({ maxClients: 5 }),
|
|
352
|
+
originHeader: 'https://APP.Example.COM',
|
|
353
|
+
webOrigins: allowlist,
|
|
354
|
+
});
|
|
355
|
+
expect(outcome.admitted).toBe(true);
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
it('admits a missing Origin (non-browser client: curl, WeeChat, tcp-ws-forwarder)', async () => {
|
|
359
|
+
// The defence only fires when `Origin` is present; non-browser
|
|
360
|
+
// tooling never sends it and must pass through unchanged.
|
|
361
|
+
const stub = makeStub(0);
|
|
362
|
+
const outcome = await handleConnect({
|
|
363
|
+
dynamo: stub.dynamo,
|
|
364
|
+
tables: TABLES,
|
|
365
|
+
connectionId: 'c-cli',
|
|
366
|
+
serverConfig: makeTestServerConfig({ maxClients: 5 }),
|
|
367
|
+
originHeader: null,
|
|
368
|
+
webOrigins: allowlist,
|
|
369
|
+
});
|
|
370
|
+
expect(outcome.admitted).toBe(true);
|
|
371
|
+
expect(stub.puts).toBe(1);
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
it('admits a missing Origin when the header key is absent from the event', async () => {
|
|
375
|
+
// APIGW may omit the header entirely on some non-browser flows;
|
|
376
|
+
// `undefined` is treated the same as `null`.
|
|
377
|
+
const stub = makeStub(0);
|
|
378
|
+
const outcome = await handleConnect({
|
|
379
|
+
dynamo: stub.dynamo,
|
|
380
|
+
tables: TABLES,
|
|
381
|
+
connectionId: 'c-noheader',
|
|
382
|
+
serverConfig: makeTestServerConfig({ maxClients: 5 }),
|
|
383
|
+
originHeader: undefined,
|
|
384
|
+
webOrigins: allowlist,
|
|
385
|
+
});
|
|
386
|
+
expect(outcome.admitted).toBe(true);
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
it('skips the check when WEB_ORIGINS is unset (defence is opt-in)', async () => {
|
|
390
|
+
// An empty allowlist means the deployment has not opted in; every
|
|
391
|
+
// Origin (including an obviously hostile one) must proceed so
|
|
392
|
+
// existing AWS deployments without a web frontend are unchanged
|
|
393
|
+
// on upgrade.
|
|
394
|
+
const stub = makeStub(0);
|
|
395
|
+
const outcome = await handleConnect({
|
|
396
|
+
dynamo: stub.dynamo,
|
|
397
|
+
tables: TABLES,
|
|
398
|
+
connectionId: 'c-unset',
|
|
399
|
+
serverConfig: makeTestServerConfig({ maxClients: 5 }),
|
|
400
|
+
originHeader: 'https://attacker.example.com',
|
|
401
|
+
webOrigins: new Set(),
|
|
402
|
+
});
|
|
403
|
+
expect(outcome.admitted).toBe(true);
|
|
404
|
+
expect(stub.puts).toBe(1);
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
it('denies an Origin that matches a listed origin except for the scheme', async () => {
|
|
408
|
+
// Origins are case-sensitive on scheme+host+port; an `http://`
|
|
409
|
+
// form MUST NOT be admitted against an `https://`-only allowlist
|
|
410
|
+
// (defends against a stripped-TLS attacker on the same hostname).
|
|
411
|
+
const stub = makeStub(0);
|
|
412
|
+
const outcome = await handleConnect({
|
|
413
|
+
dynamo: stub.dynamo,
|
|
414
|
+
tables: TABLES,
|
|
415
|
+
connectionId: 'c-scheme',
|
|
416
|
+
serverConfig: makeTestServerConfig({ maxClients: 5 }),
|
|
417
|
+
originHeader: 'http://app.example.com',
|
|
418
|
+
webOrigins: allowlist,
|
|
419
|
+
});
|
|
420
|
+
expect(outcome.admitted).toBe(false);
|
|
421
|
+
expect(outcome.admitted === false && outcome.statusCode).toBe(403);
|
|
422
|
+
});
|
|
423
|
+
});
|
|
@@ -95,6 +95,23 @@ describe('incrementConnectionCount', () => {
|
|
|
95
95
|
expect(stub.count).toBe(1);
|
|
96
96
|
expect(CONNECTION_COUNT_META_ID).toContain('__meta:');
|
|
97
97
|
});
|
|
98
|
+
|
|
99
|
+
it('returns 0 when the response carries no numeric count (defensive)', async () => {
|
|
100
|
+
// A malformed/empty response must not crash the connect path; the
|
|
101
|
+
// handler treats a missing count as a 0 reservation so admission still
|
|
102
|
+
// proceeds conservatively rather than throwing.
|
|
103
|
+
const dynamo = {
|
|
104
|
+
send: async () => ({ Attributes: { count: 'not-a-number' } }),
|
|
105
|
+
} as unknown as DynamoDBDocumentClient;
|
|
106
|
+
await expect(incrementConnectionCount(dynamo, 'Connections')).resolves.toBe(0);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('returns 0 when the response carries no Attributes at all', async () => {
|
|
110
|
+
const dynamo = {
|
|
111
|
+
send: async () => ({}),
|
|
112
|
+
} as unknown as DynamoDBDocumentClient;
|
|
113
|
+
await expect(incrementConnectionCount(dynamo, 'Connections')).resolves.toBe(0);
|
|
114
|
+
});
|
|
98
115
|
});
|
|
99
116
|
|
|
100
117
|
describe('decrementConnectionCount', () => {
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the optimistic-concurrency state persist in `$default`
|
|
3
|
+
* (`handleDefault` + `buildPersistUpdate` + `isConditionalCheckFailed`).
|
|
4
|
+
*
|
|
5
|
+
* Background: API Gateway dispatches a client's rapid frames (CAP END,
|
|
6
|
+
* NICK, USER) as CONCURRENT Lambda invocations that each
|
|
7
|
+
* read-modify-write the same Connections row. Without a revision guard,
|
|
8
|
+
* the last writer wins and clobbers the others — the registration
|
|
9
|
+
* deadlock where `CAP END`'s `capNegotiating=false` was overwritten by
|
|
10
|
+
* `NICK`/`USER`'s stale read, so `001 RPL_WELCOME` never fired.
|
|
11
|
+
*
|
|
12
|
+
* The fix: every persist is a conditional `UpdateItem` requiring
|
|
13
|
+
* `revision = :expected` (bumped atomically); a
|
|
14
|
+
* `ConditionalCheckFailedException` triggers a re-read + re-run retry
|
|
15
|
+
* loop in `handleDefault`.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { GetCommand, UpdateCommand } from '@aws-sdk/lib-dynamodb';
|
|
19
|
+
import type { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';
|
|
20
|
+
import {
|
|
21
|
+
type ParsedServerConfig,
|
|
22
|
+
StaticMotdProvider,
|
|
23
|
+
createConnection,
|
|
24
|
+
} from '@serverless-ircd/irc-core';
|
|
25
|
+
import { makeTestServerConfig } from '@serverless-ircd/irc-test-support';
|
|
26
|
+
import { describe, expect, it } from 'vitest';
|
|
27
|
+
import {
|
|
28
|
+
buildPersistUpdate,
|
|
29
|
+
handleDefault,
|
|
30
|
+
isConditionalCheckFailed,
|
|
31
|
+
} from '../src/handlers/default.js';
|
|
32
|
+
import { marshalConnection } from '../src/serialize.js';
|
|
33
|
+
import type { TablesConfig } from '../src/tables.js';
|
|
34
|
+
|
|
35
|
+
const SERVER_CONFIG: ParsedServerConfig = makeTestServerConfig({
|
|
36
|
+
serverName: 'irc.example.com',
|
|
37
|
+
networkName: 'ExampleNet',
|
|
38
|
+
motdLines: ['Welcome'],
|
|
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
|
+
describe('buildPersistUpdate — optimistic-concurrency revision guard', () => {
|
|
50
|
+
it('adds an ADD #rev :revInc clause to the update expression', () => {
|
|
51
|
+
const state = createConnection({ id: 'c1', connectedSince: 1 });
|
|
52
|
+
const expr = buildPersistUpdate(state, 100, 7);
|
|
53
|
+
expect(expr.updateExpression).toContain('ADD #rev :revInc');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('gates the write on attribute_not_exists(#rev) OR #rev = :expectedRev', () => {
|
|
57
|
+
const state = createConnection({ id: 'c1', connectedSince: 1 });
|
|
58
|
+
const expr = buildPersistUpdate(state, 100, 7);
|
|
59
|
+
expect(expr.conditionExpression).toBe('attribute_not_exists(#rev) OR #rev = :expectedRev');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('binds #rev to the revision column and :expectedRev to the read revision', () => {
|
|
63
|
+
const state = createConnection({ id: 'c1', connectedSince: 1 });
|
|
64
|
+
const expr = buildPersistUpdate(state, 100, 7);
|
|
65
|
+
expect(expr.expressionAttributeNames['#rev']).toBe('revision');
|
|
66
|
+
expect(expr.expressionAttributeValues[':expectedRev']).toBe(7);
|
|
67
|
+
expect(expr.expressionAttributeValues[':revInc']).toBe(1);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe('isConditionalCheckFailed', () => {
|
|
72
|
+
it('returns true for a DynamoDB ConditionalCheckFailedException', () => {
|
|
73
|
+
const err = new Error('The conditional request failed');
|
|
74
|
+
err.name = 'ConditionalCheckFailedException';
|
|
75
|
+
expect(isConditionalCheckFailed(err)).toBe(true);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('returns false for an unrelated error name', () => {
|
|
79
|
+
const err = new Error('boom');
|
|
80
|
+
err.name = 'ProvisionedThroughputExceededException';
|
|
81
|
+
expect(isConditionalCheckFailed(err)).toBe(false);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('returns false for non-object throws', () => {
|
|
85
|
+
expect(isConditionalCheckFailed(null)).toBe(false);
|
|
86
|
+
expect(isConditionalCheckFailed(undefined)).toBe(false);
|
|
87
|
+
expect(isConditionalCheckFailed('string throw')).toBe(false);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Minimal fake document client. It does NOT implement DynamoDB's
|
|
93
|
+
* expression semantics — it only:
|
|
94
|
+
* - returns the seeded row on GetCommand;
|
|
95
|
+
* - simulates a concurrent writer on UpdateCommand by failing the first
|
|
96
|
+
* `failNextUpdates` writes with ConditionalCheckFailedException (and
|
|
97
|
+
* bumping the row's revision so a re-read sees it), then succeeding.
|
|
98
|
+
* This is enough to exercise `handleDefault`'s retry loop.
|
|
99
|
+
*/
|
|
100
|
+
class FakeDynamo {
|
|
101
|
+
readonly items = new Map<string, Record<string, unknown>>();
|
|
102
|
+
failNextUpdates = 0;
|
|
103
|
+
updateCalls = 0;
|
|
104
|
+
getCalls = 0;
|
|
105
|
+
|
|
106
|
+
async send(cmd: unknown): Promise<{ Item?: Record<string, unknown> } | Record<string, never>> {
|
|
107
|
+
if (cmd instanceof GetCommand) {
|
|
108
|
+
this.getCalls += 1;
|
|
109
|
+
const input = (cmd as unknown as { input: { Key: { connectionId: string } } }).input;
|
|
110
|
+
const item = this.items.get(input.Key.connectionId);
|
|
111
|
+
return item === undefined ? {} : { Item: { ...item } };
|
|
112
|
+
}
|
|
113
|
+
if (cmd instanceof UpdateCommand) {
|
|
114
|
+
this.updateCalls += 1;
|
|
115
|
+
const input = (
|
|
116
|
+
cmd as unknown as {
|
|
117
|
+
input: {
|
|
118
|
+
Key: { connectionId: string };
|
|
119
|
+
ExpressionAttributeValues?: Record<string, unknown>;
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
).input;
|
|
123
|
+
const key = input.Key.connectionId;
|
|
124
|
+
if (this.failNextUpdates > 0) {
|
|
125
|
+
this.failNextUpdates -= 1;
|
|
126
|
+
// Simulate a concurrent writer landing first: bump the row's
|
|
127
|
+
// revision so the next read sees the conflict.
|
|
128
|
+
const cur = this.items.get(key);
|
|
129
|
+
if (cur !== undefined) {
|
|
130
|
+
cur.revision = ((cur.revision as number | undefined) ?? 0) + 1;
|
|
131
|
+
}
|
|
132
|
+
const err = new Error('The conditional request failed');
|
|
133
|
+
err.name = 'ConditionalCheckFailedException';
|
|
134
|
+
throw err;
|
|
135
|
+
}
|
|
136
|
+
// Success: bump revision (ADD #rev :revInc) and apply the simple
|
|
137
|
+
// SETs the assertions care about.
|
|
138
|
+
const cur = this.items.get(key);
|
|
139
|
+
if (cur !== undefined) {
|
|
140
|
+
cur.revision = ((cur.revision as number | undefined) ?? 0) + 1;
|
|
141
|
+
const vals = input.ExpressionAttributeValues ?? {};
|
|
142
|
+
if (vals[':capN'] !== undefined) cur.capNegotiating = vals[':capN'];
|
|
143
|
+
}
|
|
144
|
+
return {};
|
|
145
|
+
}
|
|
146
|
+
return {};
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
describe('handleDefault — optimistic-concurrency retry loop', () => {
|
|
151
|
+
it('retries read→run→persist when concurrent writes race, then returns 200', async () => {
|
|
152
|
+
const connId = 'c1';
|
|
153
|
+
const state = createConnection({ id: connId, connectedSince: 1000 });
|
|
154
|
+
// Mid-CAP-negotiation so CAP END is a meaningful, peer-free frame.
|
|
155
|
+
state.capNegotiating = true;
|
|
156
|
+
const dynamo = new FakeDynamo();
|
|
157
|
+
dynamo.items.set(connId, { ...marshalConnection(state, 1000) });
|
|
158
|
+
// Two concurrent invocations write first (conditional-check failures).
|
|
159
|
+
dynamo.failNextUpdates = 2;
|
|
160
|
+
|
|
161
|
+
const result = await handleDefault({
|
|
162
|
+
dynamo: dynamo as unknown as DynamoDBDocumentClient,
|
|
163
|
+
tables: TABLES,
|
|
164
|
+
connectionId: connId,
|
|
165
|
+
body: 'CAP END\r\n',
|
|
166
|
+
serverConfig: SERVER_CONFIG,
|
|
167
|
+
motd: MOTD,
|
|
168
|
+
managementApi: null,
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
expect(result.statusCode).toBe(200);
|
|
172
|
+
// 2 failed writes + 1 committed write, and a re-read per attempt.
|
|
173
|
+
expect(dynamo.updateCalls).toBe(3);
|
|
174
|
+
expect(dynamo.getCalls).toBe(3);
|
|
175
|
+
// Final committed state reflects this invocation's mutation and the
|
|
176
|
+
// revision bumps (2 concurrent + 1 own = 3).
|
|
177
|
+
const finalRow = dynamo.items.get(connId);
|
|
178
|
+
expect(finalRow?.capNegotiating).toBe(false);
|
|
179
|
+
expect(finalRow?.revision).toBe(3);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('commits on the first try when there is no contention', async () => {
|
|
183
|
+
const connId = 'c2';
|
|
184
|
+
const state = createConnection({ id: connId, connectedSince: 1000 });
|
|
185
|
+
state.capNegotiating = true;
|
|
186
|
+
const dynamo = new FakeDynamo();
|
|
187
|
+
dynamo.items.set(connId, { ...marshalConnection(state, 1000) });
|
|
188
|
+
|
|
189
|
+
const result = await handleDefault({
|
|
190
|
+
dynamo: dynamo as unknown as DynamoDBDocumentClient,
|
|
191
|
+
tables: TABLES,
|
|
192
|
+
connectionId: connId,
|
|
193
|
+
body: 'CAP END\r\n',
|
|
194
|
+
serverConfig: SERVER_CONFIG,
|
|
195
|
+
motd: MOTD,
|
|
196
|
+
managementApi: null,
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
expect(result.statusCode).toBe(200);
|
|
200
|
+
expect(dynamo.updateCalls).toBe(1);
|
|
201
|
+
expect(dynamo.getCalls).toBe(1);
|
|
202
|
+
expect(dynamo.items.get(connId)?.capNegotiating).toBe(false);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('returns 410 when the connection row is missing (not retried)', async () => {
|
|
206
|
+
const dynamo = new FakeDynamo();
|
|
207
|
+
const result = await handleDefault({
|
|
208
|
+
dynamo: dynamo as unknown as DynamoDBDocumentClient,
|
|
209
|
+
tables: TABLES,
|
|
210
|
+
connectionId: 'ghost',
|
|
211
|
+
body: 'CAP END\r\n',
|
|
212
|
+
serverConfig: SERVER_CONFIG,
|
|
213
|
+
motd: MOTD,
|
|
214
|
+
managementApi: null,
|
|
215
|
+
});
|
|
216
|
+
expect(result.statusCode).toBe(410);
|
|
217
|
+
expect(dynamo.updateCalls).toBe(0);
|
|
218
|
+
});
|
|
219
|
+
});
|
|
@@ -163,6 +163,17 @@ describe('DynamoServicesStore — round-trip via fake DDB', () => {
|
|
|
163
163
|
expect(reloaded.getNickEnforce('alice')).toBe('kill');
|
|
164
164
|
});
|
|
165
165
|
|
|
166
|
+
it('persists SET PASSWORD and round-trips the new credential', async () => {
|
|
167
|
+
const { store, client } = await fresh();
|
|
168
|
+
store.registerNick('alice', 'hunter2', 'a@x');
|
|
169
|
+
store.setNickPassword('alice', 'newpassword');
|
|
170
|
+
await store.flush();
|
|
171
|
+
|
|
172
|
+
const reloaded = await reload(client);
|
|
173
|
+
expect(reloaded.verifyNick('alice', 'newpassword')).toEqual({ ok: true, account: 'alice' });
|
|
174
|
+
expect(reloaded.verifyNick('alice', 'hunter2').ok).toBe(false);
|
|
175
|
+
});
|
|
176
|
+
|
|
166
177
|
it('drops a NickServ registration after flush + reload', async () => {
|
|
167
178
|
const { store, client } = await fresh();
|
|
168
179
|
store.registerNick('alice', 'pw', 'a@x');
|
|
@@ -71,7 +71,11 @@ export default async function globalSetup(): Promise<(() => Promise<void>) | und
|
|
|
71
71
|
const wantZip = strategy === 'auto' || strategy === 'zip';
|
|
72
72
|
|
|
73
73
|
// Strategy 1: testcontainers (Docker).
|
|
74
|
-
|
|
74
|
+
// Pre-probe the Docker daemon: `docker info` hangs indefinitely on macOS
|
|
75
|
+
// when Docker Desktop is installed but not running (the CLI waits for
|
|
76
|
+
// the daemon to spin up). Bound that wait so we fall through to the
|
|
77
|
+
// ZIP strategy instead of stalling the whole test run.
|
|
78
|
+
if (wantDocker && (await dockerDaemonReachable())) {
|
|
75
79
|
try {
|
|
76
80
|
const handle = await tryTestcontainers();
|
|
77
81
|
if (handle !== null) {
|
|
@@ -85,6 +89,10 @@ export default async function globalSetup(): Promise<(() => Promise<void>) | und
|
|
|
85
89
|
} catch (err: unknown) {
|
|
86
90
|
console.warn('[aws-adapter global-setup] testcontainers unavailable:', renderErr(err));
|
|
87
91
|
}
|
|
92
|
+
} else if (wantDocker) {
|
|
93
|
+
console.warn(
|
|
94
|
+
'[aws-adapter global-setup] Docker daemon not reachable within probe window; skipping testcontainers.',
|
|
95
|
+
);
|
|
88
96
|
}
|
|
89
97
|
|
|
90
98
|
// Strategy 2: local ZIP + Java.
|
|
@@ -119,6 +127,25 @@ export default async function globalSetup(): Promise<(() => Promise<void>) | und
|
|
|
119
127
|
// Strategy 1: testcontainers
|
|
120
128
|
// ---------------------------------------------------------------------------
|
|
121
129
|
|
|
130
|
+
/**
|
|
131
|
+
* Bounds the `docker info` probe. The Docker CLI on macOS waits silently for
|
|
132
|
+
* the daemon to start when Docker Desktop is installed but stopped, which
|
|
133
|
+
* would otherwise hang the entire global setup. Treat anything that doesn't
|
|
134
|
+
* return within `DOCKER_PROBE_MS` as "unavailable" and fall through to the
|
|
135
|
+
* ZIP strategy.
|
|
136
|
+
*/
|
|
137
|
+
const DOCKER_PROBE_MS = 5_000;
|
|
138
|
+
|
|
139
|
+
async function dockerDaemonReachable(): Promise<boolean> {
|
|
140
|
+
if (which('docker') === null) return false;
|
|
141
|
+
try {
|
|
142
|
+
await execAsync('docker info', { timeout: DOCKER_PROBE_MS });
|
|
143
|
+
return true;
|
|
144
|
+
} catch {
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
122
149
|
async function tryTestcontainers(): Promise<DdbHandle | null> {
|
|
123
150
|
let mod: typeof import('testcontainers') | undefined;
|
|
124
151
|
try {
|
|
@@ -10,10 +10,10 @@
|
|
|
10
10
|
|
|
11
11
|
import { GoneException } from '@aws-sdk/client-apigatewaymanagementapi';
|
|
12
12
|
import { CreateTableCommand, DeleteTableCommand } from '@aws-sdk/client-dynamodb';
|
|
13
|
-
import { PutCommand } from '@aws-sdk/lib-dynamodb';
|
|
13
|
+
import { GetCommand, PutCommand } from '@aws-sdk/lib-dynamodb';
|
|
14
14
|
import { type Nick, type ParsedServerConfig, createConnection } from '@serverless-ircd/irc-core';
|
|
15
15
|
import { makeTestServerConfig } from '@serverless-ircd/irc-test-support';
|
|
16
|
-
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
16
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
17
17
|
import { AwsRuntime, type PostToConnection, cleanupConnection } from '../src/aws-runtime.js';
|
|
18
18
|
import { TABLE_DEFS } from '../src/cdk-table-defs.js';
|
|
19
19
|
import { createDynamoDocumentClient } from '../src/dynamo.js';
|
|
@@ -219,6 +219,25 @@ describe.skipIf(!available)('GoneException cleanup', () => {
|
|
|
219
219
|
).resolves.toBeUndefined();
|
|
220
220
|
});
|
|
221
221
|
|
|
222
|
+
it('cleanupConnection reads the Connections row strongly consistently', async () => {
|
|
223
|
+
// $disconnect runs cleanupConnection immediately after the connection's
|
|
224
|
+
// own row was last written/updated. An eventually-consistent Get can miss
|
|
225
|
+
// that just-written row under replication lag and skip nick release +
|
|
226
|
+
// roster fanout. The Get MUST be strongly consistent.
|
|
227
|
+
await seedConnection(requireFx().client, requireFx().tables, 'cr', 'crnick', '#room');
|
|
228
|
+
const sendSpy = vi.spyOn(requireFx().client, 'send');
|
|
229
|
+
await cleanupConnection(requireFx().client, requireFx().tables, 'cr', null);
|
|
230
|
+
const conGets = sendSpy.mock.calls
|
|
231
|
+
.map((call) => call[0] as unknown)
|
|
232
|
+
.filter(
|
|
233
|
+
(cmd): cmd is GetCommand =>
|
|
234
|
+
cmd instanceof GetCommand && (cmd.input.TableName?.endsWith('Connections') ?? false),
|
|
235
|
+
);
|
|
236
|
+
expect(conGets.length).toBeGreaterThan(0);
|
|
237
|
+
expect((conGets[0]?.input as { ConsistentRead?: boolean }).ConsistentRead).toBe(true);
|
|
238
|
+
sendSpy.mockRestore();
|
|
239
|
+
});
|
|
240
|
+
|
|
222
241
|
it('broadcast() skips and cleans up gone members', async () => {
|
|
223
242
|
await seedConnection(requireFx().client, requireFx().tables, 'gone3', 'eve', '#room');
|
|
224
243
|
const mgmt: PostToConnection = {
|
|
@@ -8,16 +8,13 @@
|
|
|
8
8
|
|
|
9
9
|
import { CreateTableCommand, DeleteTableCommand } from '@aws-sdk/client-dynamodb';
|
|
10
10
|
import { GetCommand } from '@aws-sdk/lib-dynamodb';
|
|
11
|
-
import {
|
|
12
|
-
InMemoryAccountStore,
|
|
13
|
-
type ParsedServerConfig,
|
|
14
|
-
StaticMotdProvider,
|
|
15
|
-
} from '@serverless-ircd/irc-core';
|
|
11
|
+
import { type ParsedServerConfig, StaticMotdProvider } from '@serverless-ircd/irc-core';
|
|
16
12
|
import { makeTestServerConfig } from '@serverless-ircd/irc-test-support';
|
|
17
13
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
18
14
|
import { AwsRuntime } from '../src/aws-runtime.js';
|
|
19
15
|
import type { PostToConnection } from '../src/aws-runtime.js';
|
|
20
16
|
import { TABLE_DEFS } from '../src/cdk-table-defs.js';
|
|
17
|
+
import { DynamoServicesStore } from '../src/dynamo-services-store.js';
|
|
21
18
|
import { createDynamoDocumentClient } from '../src/dynamo.js';
|
|
22
19
|
import {
|
|
23
20
|
type HandlerDeps,
|
|
@@ -262,6 +259,107 @@ describe.skipIf(!available)('handlers', () => {
|
|
|
262
259
|
});
|
|
263
260
|
});
|
|
264
261
|
|
|
262
|
+
// -------------------------------------------------------------------------
|
|
263
|
+
// $connect Origin allowlist (CSWSH defence)
|
|
264
|
+
// -------------------------------------------------------------------------
|
|
265
|
+
|
|
266
|
+
describe('$connect Origin allowlist (CSWSH defence)', () => {
|
|
267
|
+
it('denies a disallowed Origin with 403 when WEB_ORIGINS is set', async () => {
|
|
268
|
+
const depsWithOrigins: HandlerDeps = {
|
|
269
|
+
...requireFx().deps,
|
|
270
|
+
webOrigins: new Set(['https://app.example.com']),
|
|
271
|
+
};
|
|
272
|
+
const res = await dispatch(
|
|
273
|
+
{
|
|
274
|
+
requestContext: { routeKey: '$connect', connectionId: 'c-cswsh' },
|
|
275
|
+
headers: { Origin: 'https://attacker.example.com' },
|
|
276
|
+
},
|
|
277
|
+
depsWithOrigins,
|
|
278
|
+
);
|
|
279
|
+
expect(res.statusCode).toBe(403);
|
|
280
|
+
// The denied upgrade must NOT persist a Connections row — a
|
|
281
|
+
// subsequent $default for the same id hits the missing-row path.
|
|
282
|
+
const probe = new AwsRuntime({
|
|
283
|
+
dynamo: requireFx().client,
|
|
284
|
+
tables: requireFx().tables,
|
|
285
|
+
connId: '__probe__',
|
|
286
|
+
handlers: { send: () => {}, disconnect: () => {}, snapshot: () => undefined },
|
|
287
|
+
managementApi: null,
|
|
288
|
+
});
|
|
289
|
+
expect(await probe.getConnectionInfo('c-cswsh')).toBeNull();
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it('admits an allowed Origin (200 + persisted row)', async () => {
|
|
293
|
+
const depsWithOrigins: HandlerDeps = {
|
|
294
|
+
...requireFx().deps,
|
|
295
|
+
webOrigins: new Set(['https://app.example.com']),
|
|
296
|
+
};
|
|
297
|
+
const res = await dispatch(
|
|
298
|
+
{
|
|
299
|
+
requestContext: { routeKey: '$connect', connectionId: 'c-ok-origin' },
|
|
300
|
+
headers: { Origin: 'https://app.example.com' },
|
|
301
|
+
},
|
|
302
|
+
depsWithOrigins,
|
|
303
|
+
);
|
|
304
|
+
expect(res.statusCode).toBe(200);
|
|
305
|
+
const probe = new AwsRuntime({
|
|
306
|
+
dynamo: requireFx().client,
|
|
307
|
+
tables: requireFx().tables,
|
|
308
|
+
connId: '__probe__',
|
|
309
|
+
handlers: { send: () => {}, disconnect: () => {}, snapshot: () => undefined },
|
|
310
|
+
managementApi: null,
|
|
311
|
+
});
|
|
312
|
+
expect((await probe.getConnectionInfo('c-ok-origin'))?.id).toBe('c-ok-origin');
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it('matches an `Origin` header served lowercased by APIGW', async () => {
|
|
316
|
+
// API Gateway normalises header keys to lowercase; the handler
|
|
317
|
+
// MUST find `origin` (not just `Origin`) so a CSWSH defence
|
|
318
|
+
// bypass cannot hide behind header-key casing.
|
|
319
|
+
const depsWithOrigins: HandlerDeps = {
|
|
320
|
+
...requireFx().deps,
|
|
321
|
+
webOrigins: new Set(['https://app.example.com']),
|
|
322
|
+
};
|
|
323
|
+
const res = await dispatch(
|
|
324
|
+
{
|
|
325
|
+
requestContext: { routeKey: '$connect', connectionId: 'c-lower' },
|
|
326
|
+
headers: { origin: 'https://attacker.example.com' },
|
|
327
|
+
},
|
|
328
|
+
depsWithOrigins,
|
|
329
|
+
);
|
|
330
|
+
expect(res.statusCode).toBe(403);
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it('admits a missing Origin header (non-browser client)', async () => {
|
|
334
|
+
// curl / WeeChat / the tcp-ws-forwarder never send `Origin`;
|
|
335
|
+
// their upgrades must pass through unchanged even when the
|
|
336
|
+
// allowlist is configured.
|
|
337
|
+
const depsWithOrigins: HandlerDeps = {
|
|
338
|
+
...requireFx().deps,
|
|
339
|
+
webOrigins: new Set(['https://app.example.com']),
|
|
340
|
+
};
|
|
341
|
+
const res = await dispatch(
|
|
342
|
+
{ requestContext: { routeKey: '$connect', connectionId: 'c-cli' } },
|
|
343
|
+
depsWithOrigins,
|
|
344
|
+
);
|
|
345
|
+
expect(res.statusCode).toBe(200);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it('skips the check entirely when WEB_ORIGINS is unset', async () => {
|
|
349
|
+
// The default — defence is opt-in. Existing AWS deployments
|
|
350
|
+
// without a web frontend must be unchanged on upgrade: any
|
|
351
|
+
// Origin (including an obviously hostile one) proceeds.
|
|
352
|
+
const res = await dispatch(
|
|
353
|
+
{
|
|
354
|
+
requestContext: { routeKey: '$connect', connectionId: 'c-unset' },
|
|
355
|
+
headers: { Origin: 'https://attacker.example.com' },
|
|
356
|
+
},
|
|
357
|
+
requireFx().deps, // no webOrigins
|
|
358
|
+
);
|
|
359
|
+
expect(res.statusCode).toBe(200);
|
|
360
|
+
});
|
|
361
|
+
});
|
|
362
|
+
|
|
265
363
|
// -------------------------------------------------------------------------
|
|
266
364
|
// $disconnect
|
|
267
365
|
// -------------------------------------------------------------------------
|
|
@@ -693,7 +791,7 @@ describe.skipIf(!available)('handlers', () => {
|
|
|
693
791
|
expect(replay).toBeDefined();
|
|
694
792
|
});
|
|
695
793
|
|
|
696
|
-
it('completes SASL PLAIN with 900/903 when
|
|
794
|
+
it('completes SASL PLAIN with 900/903 when a ServicesStore is bound', async () => {
|
|
697
795
|
await dispatch(
|
|
698
796
|
{ requestContext: { routeKey: '$connect', connectionId: 'c1' } },
|
|
699
797
|
requireFx().deps,
|
|
@@ -704,10 +802,18 @@ describe.skipIf(!available)('handlers', () => {
|
|
|
704
802
|
if (line.length > 0) sink.push(line);
|
|
705
803
|
}
|
|
706
804
|
});
|
|
707
|
-
// Swap in a deps with a seeded
|
|
708
|
-
|
|
805
|
+
// Swap in a deps with a seeded ServicesStore for this dispatch.
|
|
806
|
+
// The store is constructed against the test's DynamoDB client so the
|
|
807
|
+
// post-dispatch flush succeeds; the registration lives in the
|
|
808
|
+
// in-memory cache so SASL PLAIN resolves it synchronously.
|
|
809
|
+
const servicesStore = new DynamoServicesStore({
|
|
810
|
+
docClient: requireFx().client,
|
|
811
|
+
tableName: requireFx().tables.Services ?? 'Services',
|
|
812
|
+
});
|
|
813
|
+
servicesStore.registerNick('aws-sasl', 's3cret', '');
|
|
814
|
+
const depsWithServices: HandlerDeps = {
|
|
709
815
|
...requireFx().deps,
|
|
710
|
-
|
|
816
|
+
services: servicesStore,
|
|
711
817
|
};
|
|
712
818
|
const payload = Buffer.from('\0aws-sasl\0s3cret', 'utf8').toString('base64');
|
|
713
819
|
await dispatch(
|
|
@@ -715,14 +821,14 @@ describe.skipIf(!available)('handlers', () => {
|
|
|
715
821
|
requestContext: { routeKey: '$default', connectionId: 'c1' },
|
|
716
822
|
body: `CAP REQ :sasl\r\nAUTHENTICATE PLAIN\r\nAUTHENTICATE ${payload}\r\n`,
|
|
717
823
|
},
|
|
718
|
-
|
|
824
|
+
depsWithServices,
|
|
719
825
|
);
|
|
720
826
|
expect(sink.some((l) => l.includes(' 900 '))).toBe(true);
|
|
721
827
|
expect(sink.some((l) => l.includes(' 903 '))).toBe(true);
|
|
722
828
|
expect(sink.some((l) => l.includes(' 904 '))).toBe(false);
|
|
723
829
|
});
|
|
724
830
|
|
|
725
|
-
it('emits 904 when no
|
|
831
|
+
it('emits 904 when no ServicesStore is bound (regression guard)', async () => {
|
|
726
832
|
await dispatch(
|
|
727
833
|
{ requestContext: { routeKey: '$connect', connectionId: 'c2' } },
|
|
728
834
|
requireFx().deps,
|