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
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import type { BatchWriteCommandInput } from '@aws-sdk/lib-dynamodb';
|
|
14
|
-
import { FakeClock } from '@serverless-ircd/irc-core';
|
|
14
|
+
import { FakeClock, certIdentityFromSubject } from '@serverless-ircd/irc-core';
|
|
15
15
|
import { describe, expect, it } from 'vitest';
|
|
16
16
|
import { DynamoServicesStore, loadDynamoServicesStore } from '../src/dynamo-services-store';
|
|
17
17
|
|
|
@@ -174,6 +174,32 @@ describe('DynamoServicesStore — round-trip via fake DDB', () => {
|
|
|
174
174
|
expect(reloaded.verifyNick('alice', 'hunter2').ok).toBe(false);
|
|
175
175
|
});
|
|
176
176
|
|
|
177
|
+
it('persists the identify-failure throttle and round-trips the freeze', async () => {
|
|
178
|
+
const clock = new FakeClock(NOW);
|
|
179
|
+
const { client } = makeFakeClient();
|
|
180
|
+
const first = await loadDynamoServicesStore(asClient(client), 'Services', clock);
|
|
181
|
+
if (first === undefined) throw new Error('store not loaded');
|
|
182
|
+
first.registerNick('alice', 'hunter2', 'a@x');
|
|
183
|
+
clock.advance(10);
|
|
184
|
+
first.recordIdentifyFailure('alice');
|
|
185
|
+
clock.advance(10);
|
|
186
|
+
first.recordIdentifyFailure('alice');
|
|
187
|
+
await first.flush();
|
|
188
|
+
|
|
189
|
+
// Fresh connection / deploy: the reload still sees the freeze.
|
|
190
|
+
const reloaded = await reload(client);
|
|
191
|
+
expect(reloaded.isNickIdentifyFrozen('alice', { maxFailures: 2, windowMs: 300_000 })).toBe(
|
|
192
|
+
true,
|
|
193
|
+
);
|
|
194
|
+
expect(reloaded.clearIdentifyFailures('alice')).toBe(2);
|
|
195
|
+
|
|
196
|
+
// The cleared state round-trips too.
|
|
197
|
+
await reloaded.flush();
|
|
198
|
+
const after = await reload(client);
|
|
199
|
+
expect(after.isNickIdentifyFrozen('alice', { maxFailures: 1, windowMs: 300_000 })).toBe(false);
|
|
200
|
+
expect(after.clearIdentifyFailures('alice')).toBe(0);
|
|
201
|
+
});
|
|
202
|
+
|
|
177
203
|
it('drops a NickServ registration after flush + reload', async () => {
|
|
178
204
|
const { store, client } = await fresh();
|
|
179
205
|
store.registerNick('alice', 'pw', 'a@x');
|
|
@@ -184,6 +210,54 @@ describe('DynamoServicesStore — round-trip via fake DDB', () => {
|
|
|
184
210
|
expect((await reload(client)).isRegisteredNick('alice')).toBe(false);
|
|
185
211
|
});
|
|
186
212
|
|
|
213
|
+
it('round-trips a bound cert fingerprint and verifies EXTERNAL after reload', async () => {
|
|
214
|
+
// A cert binding attached before a deploy must still authenticate a
|
|
215
|
+
// SASL EXTERNAL client after the store rehydrates from DynamoDB —
|
|
216
|
+
// the production path for certificate-backed login.
|
|
217
|
+
const { store, client } = await fresh();
|
|
218
|
+
store.registerNick('alice', 'hunter2', 'a@x');
|
|
219
|
+
store.addCertFingerprint('alice', 'AA112233445566778899AABBCCDDEEFF');
|
|
220
|
+
await store.flush();
|
|
221
|
+
|
|
222
|
+
const reloaded = await reload(client);
|
|
223
|
+
// The fingerprint is normalised (lowercase, colon-free) on the round-trip.
|
|
224
|
+
expect(reloaded.getNick('alice')?.certSubjects).toEqual([
|
|
225
|
+
'fp:aa112233445566778899aabbccddeeff',
|
|
226
|
+
]);
|
|
227
|
+
expect(
|
|
228
|
+
reloaded.verifyCertFP(
|
|
229
|
+
certIdentityFromSubject('CN=alice', 'aa112233445566778899aabbccddeeff'),
|
|
230
|
+
),
|
|
231
|
+
).toEqual({ ok: true, account: 'alice' });
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('round-trips a bound subject DN and verifies EXTERNAL via the canonical DN arm', async () => {
|
|
235
|
+
const { store, client } = await fresh();
|
|
236
|
+
store.registerNick('alice', 'hunter2', 'a@x');
|
|
237
|
+
store.addCertFP('alice', 'CN=alice,O=Example Inc');
|
|
238
|
+
await store.flush();
|
|
239
|
+
|
|
240
|
+
const reloaded = await reload(client);
|
|
241
|
+
// Platform-surfaced DN in a different RDN order still matches (canonical compare).
|
|
242
|
+
expect(reloaded.verifyCertFP(certIdentityFromSubject('O=Example Inc,CN=alice'))).toEqual({
|
|
243
|
+
ok: true,
|
|
244
|
+
account: 'alice',
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it('keeps EXTERNAL verify failing for an unbound cert after reload', async () => {
|
|
249
|
+
const { store, client } = await fresh();
|
|
250
|
+
store.registerNick('alice', 'hunter2', 'a@x');
|
|
251
|
+
store.addCertFingerprint('alice', 'aa112233445566778899aabbccddeeff');
|
|
252
|
+
await store.flush();
|
|
253
|
+
|
|
254
|
+
const reloaded = await reload(client);
|
|
255
|
+
expect(
|
|
256
|
+
reloaded.verifyCertFP(certIdentityFromSubject('CN=alice', 'ffffffffffffffffffffffffffffffff'))
|
|
257
|
+
.ok,
|
|
258
|
+
).toBe(false);
|
|
259
|
+
});
|
|
260
|
+
|
|
187
261
|
it('persists channel + access + levels + topic, then drops the whole subtree', async () => {
|
|
188
262
|
const { store, client } = await fresh();
|
|
189
263
|
store.registerChannel('#chan', 'alice');
|
|
@@ -324,6 +398,54 @@ describe('DynamoServicesStore — round-trip via fake DDB', () => {
|
|
|
324
398
|
});
|
|
325
399
|
|
|
326
400
|
describe('DynamoServicesStore — snapshot coercion tolerance', () => {
|
|
401
|
+
it('defaults the identify-throttle fields on a legacy nick item (attrs absent)', async () => {
|
|
402
|
+
// A nick item written before the identify throttle existed carries no
|
|
403
|
+
// identifyFailures / pendingIdentifyFailureNotice attributes.
|
|
404
|
+
const { client, items } = makeFakeClient();
|
|
405
|
+
items.set('NICK:alice\x00#', {
|
|
406
|
+
pk: 'NICK:alice',
|
|
407
|
+
sk: '#',
|
|
408
|
+
type: 'nick',
|
|
409
|
+
nick: 'alice',
|
|
410
|
+
account: 'alice',
|
|
411
|
+
email: 'a@x',
|
|
412
|
+
createdAt: NOW,
|
|
413
|
+
enforce: 'none',
|
|
414
|
+
// Mixed-types array: exercises the string-filter callback on load.
|
|
415
|
+
certSubjects: ['CN=alice', 7],
|
|
416
|
+
algorithm: 'scrypt',
|
|
417
|
+
salt: 'c2FsdA==',
|
|
418
|
+
hash: 'aGFzaA==',
|
|
419
|
+
});
|
|
420
|
+
const store = await loadDynamoServicesStore(asClient(client), 'Services', new FakeClock(NOW));
|
|
421
|
+
if (store === undefined) throw new Error('store not loaded');
|
|
422
|
+
expect(store.isRegisteredNick('alice')).toBe(true);
|
|
423
|
+
expect(store.getNick('alice')?.certSubjects).toEqual(['CN=alice']);
|
|
424
|
+
expect(store.isNickIdentifyFrozen('alice', { maxFailures: 1, windowMs: 300_000 })).toBe(false);
|
|
425
|
+
expect(store.clearIdentifyFailures('alice')).toBe(0);
|
|
426
|
+
// Ill-typed throttle attributes collapse to the same neutral defaults.
|
|
427
|
+
items.set('NICK:bob\x00#', {
|
|
428
|
+
pk: 'NICK:bob',
|
|
429
|
+
sk: '#',
|
|
430
|
+
type: 'nick',
|
|
431
|
+
nick: 'bob',
|
|
432
|
+
account: 'bob',
|
|
433
|
+
email: 'b@x',
|
|
434
|
+
createdAt: NOW,
|
|
435
|
+
enforce: 'none',
|
|
436
|
+
certSubjects: [],
|
|
437
|
+
identifyFailures: 'soon',
|
|
438
|
+
pendingIdentifyFailureNotice: 'many',
|
|
439
|
+
algorithm: 'scrypt',
|
|
440
|
+
salt: 'c2FsdA==',
|
|
441
|
+
hash: 'aGFzaA==',
|
|
442
|
+
});
|
|
443
|
+
const second = await loadDynamoServicesStore(asClient(client), 'Services', new FakeClock(NOW));
|
|
444
|
+
if (second === undefined) throw new Error('store not loaded');
|
|
445
|
+
expect(second.isNickIdentifyFrozen('bob', { maxFailures: 1, windowMs: 300_000 })).toBe(false);
|
|
446
|
+
expect(second.clearIdentifyFailures('bob')).toBe(0);
|
|
447
|
+
});
|
|
448
|
+
|
|
327
449
|
it('skips items with unknown or ill-typed fields', async () => {
|
|
328
450
|
// Hand-craft malformed items directly in the fake table.
|
|
329
451
|
const { client, items } = makeFakeClient();
|
|
@@ -62,7 +62,7 @@ async function makeFixture(): Promise<Fixture> {
|
|
|
62
62
|
const pkName = props.partitionKey?.name;
|
|
63
63
|
const skName = props.sortKey?.name;
|
|
64
64
|
if (pkName === undefined) throw new Error(`table ${logical} missing partitionKey`);
|
|
65
|
-
const attributeDefinitions: Array<{ AttributeName: string; AttributeType: 'S' }> = [
|
|
65
|
+
const attributeDefinitions: Array<{ AttributeName: string; AttributeType: 'S' | 'N' }> = [
|
|
66
66
|
{ AttributeName: pkName, AttributeType: 'S' },
|
|
67
67
|
];
|
|
68
68
|
const keySchema: Array<{ AttributeName: string; KeyType: 'HASH' | 'RANGE' }> = [
|
|
@@ -72,11 +72,33 @@ async function makeFixture(): Promise<Fixture> {
|
|
|
72
72
|
attributeDefinitions.push({ AttributeName: skName, AttributeType: 'S' });
|
|
73
73
|
keySchema.push({ AttributeName: skName, KeyType: 'RANGE' });
|
|
74
74
|
}
|
|
75
|
+
// Mirror the CDK-declared secondary indexes (the Connections per-IP
|
|
76
|
+
// admission GSI) so the fixture matches the deployed schema.
|
|
77
|
+
const globalSecondaryIndexes = (props.globalSecondaryIndexes ?? []).map((g) => {
|
|
78
|
+
const gpk = g.partitionKey;
|
|
79
|
+
if (gpk === undefined) throw new Error(`index ${g.indexName} missing partitionKey`);
|
|
80
|
+
const gsiKeySchema: Array<{ AttributeName: string; KeyType: 'HASH' | 'RANGE' }> = [
|
|
81
|
+
{ AttributeName: gpk.name, KeyType: 'HASH' },
|
|
82
|
+
];
|
|
83
|
+
attributeDefinitions.push({ AttributeName: gpk.name, AttributeType: 'S' });
|
|
84
|
+
if (g.sortKey !== undefined) {
|
|
85
|
+
gsiKeySchema.push({ AttributeName: g.sortKey.name, KeyType: 'RANGE' });
|
|
86
|
+
attributeDefinitions.push({ AttributeName: g.sortKey.name, AttributeType: 'N' });
|
|
87
|
+
}
|
|
88
|
+
return {
|
|
89
|
+
IndexName: g.indexName,
|
|
90
|
+
KeySchema: gsiKeySchema,
|
|
91
|
+
Projection: { ProjectionType: 'ALL' as const },
|
|
92
|
+
};
|
|
93
|
+
});
|
|
75
94
|
await client.send(
|
|
76
95
|
new CreateTableCommand({
|
|
77
96
|
TableName: `${prefix}${logical}`,
|
|
78
97
|
AttributeDefinitions: attributeDefinitions,
|
|
79
98
|
KeySchema: keySchema,
|
|
99
|
+
...(globalSecondaryIndexes.length > 0
|
|
100
|
+
? { GlobalSecondaryIndexes: globalSecondaryIndexes }
|
|
101
|
+
: {}),
|
|
80
102
|
BillingMode: 'PAY_PER_REQUEST',
|
|
81
103
|
}),
|
|
82
104
|
);
|
|
@@ -257,6 +279,40 @@ describe.skipIf(!available)('handlers', () => {
|
|
|
257
279
|
});
|
|
258
280
|
expect((await probe.getConnectionInfo('b1'))?.id).toBe('b1');
|
|
259
281
|
});
|
|
282
|
+
|
|
283
|
+
it('rejects the per-IP cap via identity.sourceIp and the sourceIp GSI', async () => {
|
|
284
|
+
// End-to-end per-IP admission: dispatch feeds
|
|
285
|
+
// `event.requestContext.identity.sourceIp` into handleConnect,
|
|
286
|
+
// which counts the IP's live rows on the `sourceIp` GSI and fires
|
|
287
|
+
// the (previously dead) maxConnectionsPerIp branch.
|
|
288
|
+
const capped: HandlerDeps = {
|
|
289
|
+
...requireFx().deps,
|
|
290
|
+
serverConfig: { ...SERVER_CONFIG, maxClients: 50, maxConnectionsPerIp: 2 },
|
|
291
|
+
};
|
|
292
|
+
const fromIp = (id: string, ip: string) =>
|
|
293
|
+
dispatch(
|
|
294
|
+
{
|
|
295
|
+
requestContext: { routeKey: '$connect', connectionId: id, identity: { sourceIp: ip } },
|
|
296
|
+
},
|
|
297
|
+
capped,
|
|
298
|
+
);
|
|
299
|
+
expect((await fromIp('ip-a1', '203.0.113.30')).statusCode).toBe(200);
|
|
300
|
+
expect((await fromIp('ip-a2', '203.0.113.30')).statusCode).toBe(200);
|
|
301
|
+
const third = await fromIp('ip-a3', '203.0.113.30');
|
|
302
|
+
expect(third.statusCode).toBe(429);
|
|
303
|
+
expect(third.body).toBe('too many connections from this IP');
|
|
304
|
+
// A different IP is unaffected — the budget is keyed per IP.
|
|
305
|
+
expect((await fromIp('ip-b1', '198.51.100.30')).statusCode).toBe(200);
|
|
306
|
+
// The rejected connection must NOT be recorded.
|
|
307
|
+
const probe = new AwsRuntime({
|
|
308
|
+
dynamo: requireFx().client,
|
|
309
|
+
tables: requireFx().tables,
|
|
310
|
+
connId: '__probe__',
|
|
311
|
+
handlers: { send: () => {}, disconnect: () => {}, snapshot: () => undefined },
|
|
312
|
+
managementApi: null,
|
|
313
|
+
});
|
|
314
|
+
expect(await probe.getConnectionInfo('ip-a3')).toBeNull();
|
|
315
|
+
});
|
|
260
316
|
});
|
|
261
317
|
|
|
262
318
|
// -------------------------------------------------------------------------
|
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the NLB TLS secure-flag propagation (user mode `S`).
|
|
3
|
+
*
|
|
4
|
+
* The stack wires the NLB streaming Lambda exclusively behind a
|
|
5
|
+
* `Protocol.TLS` listener (ACM-cert terminated at the edge), so every
|
|
6
|
+
* connection on that entry is TLS-protected. These tests pin the
|
|
7
|
+
* propagation of that fact end-to-end:
|
|
8
|
+
* - the `nlbStreamHandler` dispatcher (`dispatchNlbStream`) asserts
|
|
9
|
+
* `secure: true`, so registration stamps read-only user mode `S`
|
|
10
|
+
* (visible via `MODE` → `221 … +S`) and the Connections row
|
|
11
|
+
* persists both `secure` and `userModes.tls`;
|
|
12
|
+
* - STS reflects the secure transport (`CAP LS` advertises the
|
|
13
|
+
* duration-only TLS form, never the plaintext `port=` upgrade form);
|
|
14
|
+
* - SASL EXTERNAL negotiation is permitted on the secure path (the
|
|
15
|
+
* mech is accepted when an mTLS provider is bound; the verify-level
|
|
16
|
+
* `904` for a missing account is owned by the EXTERNAL-verify
|
|
17
|
+
* ticket, not a secure-connection refusal);
|
|
18
|
+
* - a plain transport (secure not asserted) stays unflagged.
|
|
19
|
+
*
|
|
20
|
+
* Uses real DynamoDB Local, mirroring `nlb-stream.test.ts`.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { CreateTableCommand, DeleteTableCommand } from '@aws-sdk/client-dynamodb';
|
|
24
|
+
import { GetCommand } from '@aws-sdk/lib-dynamodb';
|
|
25
|
+
import {
|
|
26
|
+
InMemoryMtlsIdentityProvider,
|
|
27
|
+
type ParsedServerConfig,
|
|
28
|
+
StaticMotdProvider,
|
|
29
|
+
certIdentityFromSubject,
|
|
30
|
+
} from '@serverless-ircd/irc-core';
|
|
31
|
+
import { makeTestServerConfig } from '@serverless-ircd/irc-test-support';
|
|
32
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
33
|
+
import type { PostToConnection } from '../src/aws-runtime.js';
|
|
34
|
+
import { TABLE_DEFS } from '../src/cdk-table-defs.js';
|
|
35
|
+
import { createDynamoDocumentClient } from '../src/dynamo.js';
|
|
36
|
+
import { type HandlerDeps, dispatchNlbStream } from '../src/handlers/index.js';
|
|
37
|
+
import {
|
|
38
|
+
type NlbStreamEvent,
|
|
39
|
+
deriveNlbConnectionId,
|
|
40
|
+
handleNlbStream,
|
|
41
|
+
} from '../src/handlers/nlb-stream.js';
|
|
42
|
+
import { bindMessageStore } from '../src/message-store.js';
|
|
43
|
+
import type { TablesConfig } from '../src/tables.js';
|
|
44
|
+
|
|
45
|
+
const available = process.env.DDB_AVAILABLE === '1';
|
|
46
|
+
const endpoint = process.env.DYNAMO_ENDPOINT;
|
|
47
|
+
|
|
48
|
+
const SERVER_CONFIG: ParsedServerConfig = makeTestServerConfig({
|
|
49
|
+
serverName: 'irc.example.com',
|
|
50
|
+
networkName: 'ExampleNet',
|
|
51
|
+
motdLines: ['Welcome to the test IRC server.'],
|
|
52
|
+
// SASL EXTERNAL is operator opt-in; these tests exercise the enabled
|
|
53
|
+
// deployment (mTLS + TLS listener configured at the edge).
|
|
54
|
+
sasl: { externalEnabled: true },
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const STS_CONFIG: ParsedServerConfig = makeTestServerConfig({
|
|
58
|
+
serverName: 'irc.example.com',
|
|
59
|
+
networkName: 'ExampleNet',
|
|
60
|
+
motdLines: ['Welcome to the test IRC server.'],
|
|
61
|
+
sts: { duration: 3600, port: 6697 },
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const MOTD = new StaticMotdProvider(SERVER_CONFIG.motdLines);
|
|
65
|
+
|
|
66
|
+
interface Fixture {
|
|
67
|
+
tables: TablesConfig;
|
|
68
|
+
client: ReturnType<typeof createDynamoDocumentClient>;
|
|
69
|
+
mgmt: LocalPostToConnection;
|
|
70
|
+
deps: HandlerDeps;
|
|
71
|
+
stsDeps: HandlerDeps;
|
|
72
|
+
cleanup: () => Promise<void>;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
let fx: Fixture | null;
|
|
76
|
+
|
|
77
|
+
function requireFx(): Fixture {
|
|
78
|
+
if (!fx) throw new Error('test fixture not initialized');
|
|
79
|
+
return fx;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function makeFixture(): Promise<Fixture> {
|
|
83
|
+
if (endpoint === undefined) throw new Error('DYNAMO_ENDPOINT missing');
|
|
84
|
+
const prefix = `s${Date.now()}-${Math.floor(Math.random() * 1e6)}-`;
|
|
85
|
+
const tables = Object.fromEntries(
|
|
86
|
+
Object.keys(TABLE_DEFS).map((name) => [name, `${prefix}${name}`]),
|
|
87
|
+
) as TablesConfig;
|
|
88
|
+
const client = createDynamoDocumentClient({ endpoint });
|
|
89
|
+
for (const [logical, props] of Object.entries(TABLE_DEFS)) {
|
|
90
|
+
const pkName = props.partitionKey?.name;
|
|
91
|
+
const skName = props.sortKey?.name;
|
|
92
|
+
if (pkName === undefined) throw new Error(`table ${logical} missing partitionKey`);
|
|
93
|
+
const attributeDefinitions: Array<{ AttributeName: string; AttributeType: 'S' | 'N' }> = [
|
|
94
|
+
{ AttributeName: pkName, AttributeType: 'S' },
|
|
95
|
+
];
|
|
96
|
+
const keySchema: Array<{ AttributeName: string; KeyType: 'HASH' | 'RANGE' }> = [
|
|
97
|
+
{ AttributeName: pkName, KeyType: 'HASH' },
|
|
98
|
+
];
|
|
99
|
+
if (skName !== undefined) {
|
|
100
|
+
attributeDefinitions.push({ AttributeName: skName, AttributeType: 'S' });
|
|
101
|
+
keySchema.push({ AttributeName: skName, KeyType: 'RANGE' });
|
|
102
|
+
}
|
|
103
|
+
// Secondary indexes declared by the CDK definition (the Connections
|
|
104
|
+
// per-IP admission GSI) are created here too — the NLB new-flow rate
|
|
105
|
+
// gate Queries the index, so a fixture without it diverges from the
|
|
106
|
+
// deployed schema.
|
|
107
|
+
const globalSecondaryIndexes = (props.globalSecondaryIndexes ?? []).map((g) => {
|
|
108
|
+
const gpk = g.partitionKey;
|
|
109
|
+
if (gpk === undefined) throw new Error(`index ${g.indexName} missing partitionKey`);
|
|
110
|
+
const gsiKeySchema: Array<{ AttributeName: string; KeyType: 'HASH' | 'RANGE' }> = [
|
|
111
|
+
{ AttributeName: gpk.name, KeyType: 'HASH' },
|
|
112
|
+
];
|
|
113
|
+
attributeDefinitions.push({ AttributeName: gpk.name, AttributeType: 'S' });
|
|
114
|
+
if (g.sortKey !== undefined) {
|
|
115
|
+
gsiKeySchema.push({ AttributeName: g.sortKey.name, KeyType: 'RANGE' });
|
|
116
|
+
attributeDefinitions.push({ AttributeName: g.sortKey.name, AttributeType: 'N' });
|
|
117
|
+
}
|
|
118
|
+
return {
|
|
119
|
+
IndexName: g.indexName,
|
|
120
|
+
KeySchema: gsiKeySchema,
|
|
121
|
+
Projection: { ProjectionType: 'ALL' as const },
|
|
122
|
+
};
|
|
123
|
+
});
|
|
124
|
+
await client.send(
|
|
125
|
+
new CreateTableCommand({
|
|
126
|
+
TableName: `${prefix}${logical}`,
|
|
127
|
+
AttributeDefinitions: attributeDefinitions,
|
|
128
|
+
KeySchema: keySchema,
|
|
129
|
+
...(globalSecondaryIndexes.length > 0
|
|
130
|
+
? { GlobalSecondaryIndexes: globalSecondaryIndexes }
|
|
131
|
+
: {}),
|
|
132
|
+
BillingMode: 'PAY_PER_REQUEST',
|
|
133
|
+
}),
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
const mgmt = new LocalPostToConnection();
|
|
137
|
+
const deps: HandlerDeps = {
|
|
138
|
+
dynamo: client,
|
|
139
|
+
tables,
|
|
140
|
+
serverConfig: SERVER_CONFIG,
|
|
141
|
+
motd: MOTD,
|
|
142
|
+
messages: bindMessageStore(SERVER_CONFIG),
|
|
143
|
+
managementApi: mgmt as never,
|
|
144
|
+
};
|
|
145
|
+
const stsDeps: HandlerDeps = {
|
|
146
|
+
...deps,
|
|
147
|
+
serverConfig: STS_CONFIG,
|
|
148
|
+
messages: bindMessageStore(STS_CONFIG),
|
|
149
|
+
};
|
|
150
|
+
return {
|
|
151
|
+
tables,
|
|
152
|
+
client,
|
|
153
|
+
mgmt,
|
|
154
|
+
deps,
|
|
155
|
+
stsDeps,
|
|
156
|
+
cleanup: async () => {
|
|
157
|
+
for (const logical of Object.keys(TABLE_DEFS)) {
|
|
158
|
+
try {
|
|
159
|
+
await client.send(new DeleteTableCommand({ TableName: `${prefix}${logical}` }));
|
|
160
|
+
} catch {
|
|
161
|
+
// Already gone.
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Builds an NLB event from raw client bytes (base64-encodes the body). */
|
|
169
|
+
function nlbEvent(sourceIp: string, sourcePort: string, data: string): NlbStreamEvent {
|
|
170
|
+
return {
|
|
171
|
+
requestContext: { elb: { targetGroupArn: 'arn:aws:elasticloadbalancing:::targetgroup/test' } },
|
|
172
|
+
version: '2.0',
|
|
173
|
+
body: Buffer.from(data, 'utf8').toString('base64'),
|
|
174
|
+
isBase64Encoded: true,
|
|
175
|
+
headers: {
|
|
176
|
+
'x-forwarded-for': sourceIp,
|
|
177
|
+
'x-forwarded-port': sourcePort,
|
|
178
|
+
},
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Decodes an NLB response body back to raw IRC bytes. */
|
|
183
|
+
function decodeBody(res: { body: string }): string {
|
|
184
|
+
return Buffer.from(res.body, 'base64').toString('utf8');
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Reads a raw Connections row. */
|
|
188
|
+
async function readRow(
|
|
189
|
+
tableName: string,
|
|
190
|
+
client: Fixture['client'],
|
|
191
|
+
connId: string,
|
|
192
|
+
): Promise<Record<string, unknown> | undefined> {
|
|
193
|
+
const row = await client.send(
|
|
194
|
+
new GetCommand({ TableName: tableName, Key: { connectionId: connId } }),
|
|
195
|
+
);
|
|
196
|
+
return row.Item;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
describe.skipIf(!available)('NLB TLS secure-flag propagation', () => {
|
|
200
|
+
beforeEach(async () => {
|
|
201
|
+
fx = await makeFixture();
|
|
202
|
+
});
|
|
203
|
+
afterEach(async () => {
|
|
204
|
+
if (fx) await fx.cleanup();
|
|
205
|
+
fx = null;
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('flags the NLB+TLS connection secure: MODE read-back shows +S and the row persists secure + userModes.tls', async () => {
|
|
209
|
+
const f = requireFx();
|
|
210
|
+
const res = await dispatchNlbStream(
|
|
211
|
+
nlbEvent(
|
|
212
|
+
'10.0.0.70',
|
|
213
|
+
'50070',
|
|
214
|
+
'NICK tlsclient\r\nUSER tls 0 * :TLS User\r\nMODE tlsclient\r\n',
|
|
215
|
+
),
|
|
216
|
+
f.deps,
|
|
217
|
+
);
|
|
218
|
+
expect(res.statusCode).toBe(200);
|
|
219
|
+
const body = decodeBody(res);
|
|
220
|
+
// Read-only user mode `S` is visible in the MODE read form (221).
|
|
221
|
+
expect(body).toContain('221 tlsclient +S');
|
|
222
|
+
expect(body).not.toContain('221 tlsclient +\r\n');
|
|
223
|
+
|
|
224
|
+
// The persisted row carries the TLS fact for cross-invocation readers.
|
|
225
|
+
const row = await readRow(
|
|
226
|
+
f.tables.Connections,
|
|
227
|
+
f.client,
|
|
228
|
+
deriveNlbConnectionId('10.0.0.70', '50070'),
|
|
229
|
+
);
|
|
230
|
+
expect(row?.secure).toBe(true);
|
|
231
|
+
expect(row?.userModes).toMatchObject({ tls: true });
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('keeps the secure flag across invocations of the same flow (persist + re-stamp)', async () => {
|
|
235
|
+
const f = requireFx();
|
|
236
|
+
await dispatchNlbStream(
|
|
237
|
+
nlbEvent('10.0.0.71', '50071', 'NICK reuser\r\nUSER u 0 * :Re User\r\n'),
|
|
238
|
+
f.deps,
|
|
239
|
+
);
|
|
240
|
+
const res = await dispatchNlbStream(nlbEvent('10.0.0.71', '50071', 'MODE reuser\r\n'), f.deps);
|
|
241
|
+
expect(res.statusCode).toBe(200);
|
|
242
|
+
expect(decodeBody(res)).toContain('221 reuser +S');
|
|
243
|
+
|
|
244
|
+
const row = await readRow(
|
|
245
|
+
f.tables.Connections,
|
|
246
|
+
f.client,
|
|
247
|
+
deriveNlbConnectionId('10.0.0.71', '50071'),
|
|
248
|
+
);
|
|
249
|
+
expect(row?.secure).toBe(true);
|
|
250
|
+
expect(row?.userModes).toMatchObject({ tls: true });
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it('does not flag a plain transport when secure is not asserted (contrast)', async () => {
|
|
254
|
+
const f = requireFx();
|
|
255
|
+
// handleNlbStream called WITHOUT `secure` models a plain-TCP listener
|
|
256
|
+
// target: no S mode, and the row records the plain transport.
|
|
257
|
+
const res = await handleNlbStream(
|
|
258
|
+
nlbEvent(
|
|
259
|
+
'10.0.0.72',
|
|
260
|
+
'50072',
|
|
261
|
+
'NICK plainclient\r\nUSER p 0 * :Plain User\r\nMODE plainclient\r\n',
|
|
262
|
+
),
|
|
263
|
+
{
|
|
264
|
+
dynamo: f.client,
|
|
265
|
+
tables: f.tables,
|
|
266
|
+
serverConfig: SERVER_CONFIG,
|
|
267
|
+
motd: MOTD,
|
|
268
|
+
messages: bindMessageStore(SERVER_CONFIG),
|
|
269
|
+
managementApi: f.mgmt,
|
|
270
|
+
},
|
|
271
|
+
);
|
|
272
|
+
expect(res.statusCode).toBe(200);
|
|
273
|
+
const body = decodeBody(res);
|
|
274
|
+
expect(body).toContain('221 plainclient +\r\n');
|
|
275
|
+
expect(body).not.toContain('+S');
|
|
276
|
+
|
|
277
|
+
const row = await readRow(
|
|
278
|
+
f.tables.Connections,
|
|
279
|
+
f.client,
|
|
280
|
+
deriveNlbConnectionId('10.0.0.72', '50072'),
|
|
281
|
+
);
|
|
282
|
+
expect(row?.secure).toBe(false);
|
|
283
|
+
expect(row?.userModes).toMatchObject({ tls: false });
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it('advertises the TLS form of sts on the secure NLB path (duration only, no port)', async () => {
|
|
287
|
+
const f = requireFx();
|
|
288
|
+
const res = await dispatchNlbStream(
|
|
289
|
+
nlbEvent('10.0.0.73', '50073', 'CAP LS 302\r\n'),
|
|
290
|
+
f.stsDeps,
|
|
291
|
+
);
|
|
292
|
+
expect(res.statusCode).toBe(200);
|
|
293
|
+
const body = decodeBody(res);
|
|
294
|
+
// Already on the secure listener: duration-only advertisement.
|
|
295
|
+
expect(body).toContain('sts=duration=3600');
|
|
296
|
+
expect(body).not.toContain('port=');
|
|
297
|
+
|
|
298
|
+
// Contrast — the same config over a transport that did not assert
|
|
299
|
+
// secure advertises the plaintext upgrade form with the port.
|
|
300
|
+
const plain = await handleNlbStream(nlbEvent('10.0.0.74', '50074', 'CAP LS 302\r\n'), {
|
|
301
|
+
dynamo: f.client,
|
|
302
|
+
tables: f.tables,
|
|
303
|
+
serverConfig: STS_CONFIG,
|
|
304
|
+
motd: MOTD,
|
|
305
|
+
messages: bindMessageStore(STS_CONFIG),
|
|
306
|
+
managementApi: f.mgmt,
|
|
307
|
+
});
|
|
308
|
+
expect(decodeBody(plain)).toContain('sts=duration=3600,port=6697');
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
it('permits SASL EXTERNAL negotiation on the secure NLB path', async () => {
|
|
312
|
+
const f = requireFx();
|
|
313
|
+
const connId = deriveNlbConnectionId('10.0.0.75', '50075');
|
|
314
|
+
const opts = {
|
|
315
|
+
dynamo: f.client,
|
|
316
|
+
tables: f.tables,
|
|
317
|
+
serverConfig: SERVER_CONFIG,
|
|
318
|
+
motd: MOTD,
|
|
319
|
+
messages: bindMessageStore(SERVER_CONFIG),
|
|
320
|
+
managementApi: f.mgmt,
|
|
321
|
+
secure: true,
|
|
322
|
+
mtlsIdentity: new InMemoryMtlsIdentityProvider([
|
|
323
|
+
{ connId, identity: certIdentityFromSubject('CN=extuser') },
|
|
324
|
+
]),
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
// Mechanism selection: EXTERNAL is advertised and accepted (the
|
|
328
|
+
// server asks for the empty payload with `AUTHENTICATE +`).
|
|
329
|
+
const ls = await handleNlbStream(
|
|
330
|
+
nlbEvent('10.0.0.75', '50075', 'CAP LS 302\r\nAUTHENTICATE EXTERNAL\r\n'),
|
|
331
|
+
opts,
|
|
332
|
+
);
|
|
333
|
+
const lsBody = decodeBody(ls);
|
|
334
|
+
expect(lsBody).toContain('sasl=PLAIN,EXTERNAL');
|
|
335
|
+
expect(lsBody).toContain('AUTHENTICATE +');
|
|
336
|
+
|
|
337
|
+
// Payload delivery: the exchange proceeds to the verify step. With no
|
|
338
|
+
// services store bound the verify fails with 904 (account resolution
|
|
339
|
+
// is the EXTERNAL-verify ticket's concern) — never a 908 mech-level
|
|
340
|
+
// refusal on secure-connection grounds.
|
|
341
|
+
const fin = await handleNlbStream(nlbEvent('10.0.0.75', '50075', 'AUTHENTICATE +\r\n'), opts);
|
|
342
|
+
const finBody = decodeBody(fin);
|
|
343
|
+
expect(finBody).toContain('904');
|
|
344
|
+
expect(finBody).not.toContain('908');
|
|
345
|
+
});
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Test double for `ApiGatewayManagementApi`. Routes `postToConnection`
|
|
350
|
+
* calls to a registered sink for `ConnectionId`.
|
|
351
|
+
*/
|
|
352
|
+
class LocalPostToConnection implements PostToConnection {
|
|
353
|
+
private readonly sinks = new Map<string, (data: string) => void>();
|
|
354
|
+
register(connId: string, sink: (data: string) => void): void {
|
|
355
|
+
this.sinks.set(connId, sink);
|
|
356
|
+
}
|
|
357
|
+
async postToConnection(input: { ConnectionId: string; Data: string }): Promise<unknown> {
|
|
358
|
+
const sink = this.sinks.get(input.ConnectionId);
|
|
359
|
+
if (sink !== undefined) sink(input.Data);
|
|
360
|
+
return {};
|
|
361
|
+
}
|
|
362
|
+
}
|