serverless-ircd 0.9.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/ci.yml +28 -0
- package/.github/workflows/deploy-aws.yml +156 -32
- package/.github/workflows/deploy-cf-tcp.yml +35 -9
- package/.github/workflows/deploy-cf.yml +40 -14
- package/CHANGELOG.md +594 -0
- package/README.md +286 -60
- package/apps/aws-stack/README.md +3 -5
- package/apps/aws-stack/bin/aws.ts +118 -9
- package/apps/aws-stack/cdk.json +0 -3
- package/apps/aws-stack/package.json +3 -4
- package/apps/aws-stack/src/aws-stack.ts +398 -67
- package/apps/aws-stack/src/static-site.ts +323 -0
- package/apps/aws-stack/tests/smoke-helpers.test.ts +1 -1
- package/apps/aws-stack/tests/stack.test.ts +714 -105
- package/apps/aws-stack/tests/static-site.test.ts +491 -0
- package/apps/aws-stack/tests/synth-no-bundle.test.ts +0 -1
- package/apps/cf-tcp-container/Dockerfile +37 -5
- package/apps/cf-tcp-container/package.json +7 -3
- package/apps/cf-tcp-container/src/config-loader.ts +113 -2
- package/apps/cf-tcp-container/src/container-server.ts +267 -87
- package/apps/cf-tcp-container/src/main.ts +22 -7
- package/apps/cf-tcp-container/src/proxy-protocol.ts +112 -0
- package/apps/cf-tcp-container/terraform/spectrum.tf +40 -11
- package/apps/cf-tcp-container/tests/config-loader.test.ts +170 -0
- package/apps/cf-tcp-container/tests/container-server-tls.test.ts +382 -0
- package/apps/cf-tcp-container/tests/container-server.test.ts +358 -31
- package/apps/cf-tcp-container/tests/dockerfile.test.ts +110 -0
- package/apps/cf-tcp-container/tests/proxy-protocol.test.ts +187 -0
- package/apps/cf-tcp-container/tests/spectrum-terraform.test.ts +135 -0
- package/apps/cf-tcp-container/tests/tls-e2e.test.ts +5 -1
- package/apps/cf-tcp-container/wrangler.toml +18 -14
- package/apps/cf-worker/package.json +3 -4
- package/apps/cf-worker/src/worker.ts +77 -5
- package/apps/cf-worker/tests/raw-modules.d.ts +11 -0
- package/apps/cf-worker/tests/smoke.test.ts +4 -0
- package/apps/cf-worker/tests/wrangler-config.test.ts +47 -0
- package/apps/cf-worker/tests/ws-admission.test.ts +112 -0
- package/apps/cf-worker/tests/ws-rate-limit.test.ts +133 -0
- package/apps/cf-worker/wrangler.test.toml +15 -1
- package/apps/cf-worker/wrangler.toml +95 -77
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/config-loader.ts +14 -2
- package/apps/local-cli/src/line-scanner.ts +26 -0
- package/apps/local-cli/src/server.ts +44 -19
- package/apps/local-cli/tests/line-scanner.test.ts +64 -0
- package/apps/local-cli/tests/tcp.test.ts +29 -0
- package/apps/web/landing/favicon.ico +0 -0
- package/apps/web/landing/index.html +1 -0
- package/apps/web/package.json +2 -2
- package/apps/web/scripts/build.mjs +66 -4
- package/apps/web/src/build-env.ts +125 -4
- package/apps/web/src/config-schema.ts +20 -6
- package/apps/web/static/{config.staging.json → config.prod-aws.json} +3 -2
- package/apps/web/tests/build-env.test.ts +210 -9
- package/apps/web/tests/build-smoke.test.ts +2 -2
- package/apps/web/tests/config-schema.test.ts +149 -25
- package/docs/AWS-Deployment.md +793 -118
- package/docs/AWS-TCP-Deployment.md +57 -47
- package/docs/Chat-History.md +55 -0
- package/docs/Cloudflare-Deployment-Guide.md +95 -114
- package/docs/Cloudflare-TCP-Deployment.md +160 -101
- package/docs/Release-Process.md +27 -23
- package/docs/SASL-EXTERNAL.md +175 -0
- package/docs/Services.md +69 -22
- package/docs/WebClientGuide.md +35 -26
- package/package.json +7 -10
- package/packages/aws-adapter/package.json +1 -1
- package/packages/aws-adapter/src/admission.ts +28 -13
- package/packages/aws-adapter/src/aws-runtime.ts +30 -3
- package/packages/aws-adapter/src/cdk-table-defs.ts +39 -16
- package/packages/aws-adapter/src/config-loader.ts +153 -8
- package/packages/aws-adapter/src/dynamo-services-store.ts +19 -0
- package/packages/aws-adapter/src/handlers/connect.ts +73 -1
- package/packages/aws-adapter/src/handlers/default.ts +279 -123
- package/packages/aws-adapter/src/handlers/index.ts +98 -25
- package/packages/aws-adapter/src/handlers/nlb-stream.ts +135 -14
- package/packages/aws-adapter/src/index.ts +5 -7
- package/packages/aws-adapter/src/ip-admission.ts +79 -0
- package/packages/aws-adapter/src/origin-allowlist.ts +94 -0
- package/packages/aws-adapter/src/serialize.ts +23 -0
- package/packages/aws-adapter/src/tables.ts +11 -12
- package/packages/aws-adapter/tests/admission.test.ts +60 -2
- package/packages/aws-adapter/tests/aws-harness.ts +23 -2
- package/packages/aws-adapter/tests/aws-runtime.test.ts +64 -0
- package/packages/aws-adapter/tests/config-loader.test.ts +217 -0
- package/packages/aws-adapter/tests/connect.test.ts +323 -3
- package/packages/aws-adapter/tests/default-frame-limit.test.ts +231 -0
- package/packages/aws-adapter/tests/default-occ.test.ts +226 -0
- package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +134 -1
- package/packages/aws-adapter/tests/handlers.test.ts +174 -12
- package/packages/aws-adapter/tests/migrate-accounts-to-services.test.ts +164 -0
- package/packages/aws-adapter/tests/nlb-secure.test.ts +362 -0
- package/packages/aws-adapter/tests/nlb-stream.test.ts +628 -9
- package/packages/aws-adapter/tests/origin-allowlist.test.ts +110 -0
- package/packages/aws-adapter/tests/ping-checker.test.ts +0 -1
- package/packages/aws-adapter/tests/stats.test.ts +0 -3
- package/packages/aws-adapter/tests/sweeper.test.ts +0 -1
- package/packages/aws-adapter/tests/tables.test.ts +1 -8
- package/packages/aws-adapter/tests/transactions.test.ts +0 -1
- package/packages/cf-adapter/package.json +1 -5
- package/packages/cf-adapter/src/cf-runtime.ts +100 -10
- package/packages/cf-adapter/src/channel-do.ts +13 -3
- package/packages/cf-adapter/src/config-loader.ts +133 -8
- package/packages/cf-adapter/src/connection-do.ts +406 -116
- package/packages/cf-adapter/src/counter-do.ts +142 -0
- package/packages/cf-adapter/src/d1-services-store.ts +105 -26
- package/packages/cf-adapter/src/env.ts +99 -10
- package/packages/cf-adapter/src/index.ts +17 -7
- package/packages/cf-adapter/src/rate-limit-do.ts +87 -0
- package/packages/cf-adapter/tests/cf-runtime.test.ts +205 -16
- package/packages/cf-adapter/tests/channel-do.test.ts +118 -1
- package/packages/cf-adapter/tests/config-loader.test.ts +159 -0
- package/packages/cf-adapter/tests/connection-do-counter.test.ts +165 -0
- package/packages/cf-adapter/tests/connection-do-coverage.test.ts +460 -0
- package/packages/cf-adapter/tests/connection-do-frame-limit.test.ts +177 -0
- package/packages/cf-adapter/tests/connection-do-pure.test.ts +164 -54
- package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +62 -38
- package/packages/cf-adapter/tests/connection-do-ws-spec-contract.test.ts +7 -4
- package/packages/cf-adapter/tests/counter-do.test.ts +181 -0
- package/packages/cf-adapter/tests/d1-services-store.test.ts +245 -3
- package/packages/cf-adapter/tests/rate-limit-do.test.ts +160 -0
- package/packages/cf-adapter/tests/serialize.test.ts +25 -0
- package/packages/cf-adapter/tests/worker/main.ts +4 -0
- package/packages/cf-adapter/wrangler.test.toml +18 -1
- package/packages/in-memory-runtime/package.json +1 -1
- package/packages/in-memory-runtime/src/in-memory-runtime.ts +25 -0
- package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +74 -0
- package/packages/irc-core/package.json +1 -1
- package/packages/irc-core/src/account-migration.ts +140 -0
- package/packages/irc-core/src/caps/capabilities.ts +20 -10
- package/packages/irc-core/src/certfp.ts +178 -0
- package/packages/irc-core/src/commands/account-auth.ts +16 -19
- package/packages/irc-core/src/commands/cap.ts +10 -2
- package/packages/irc-core/src/commands/chanserv.ts +117 -14
- package/packages/irc-core/src/commands/chathistory.ts +13 -5
- package/packages/irc-core/src/commands/hostserv.ts +84 -8
- package/packages/irc-core/src/commands/index.ts +2 -1
- package/packages/irc-core/src/commands/invite.ts +1 -7
- package/packages/irc-core/src/commands/join.ts +1 -16
- package/packages/irc-core/src/commands/kick.ts +1 -8
- package/packages/irc-core/src/commands/list.ts +1 -8
- package/packages/irc-core/src/commands/memoserv.ts +1 -1
- package/packages/irc-core/src/commands/mode.ts +1 -8
- package/packages/irc-core/src/commands/multiline.ts +4 -10
- package/packages/irc-core/src/commands/names.ts +53 -13
- package/packages/irc-core/src/commands/nickserv.ts +161 -11
- package/packages/irc-core/src/commands/oper.ts +361 -8
- package/packages/irc-core/src/commands/part.ts +4 -10
- package/packages/irc-core/src/commands/privmsg.ts +8 -4
- package/packages/irc-core/src/commands/registration.ts +148 -4
- package/packages/irc-core/src/commands/sasl.ts +154 -46
- package/packages/irc-core/src/commands/topic.ts +10 -12
- package/packages/irc-core/src/commands/who.ts +1 -8
- package/packages/irc-core/src/config.ts +424 -25
- package/packages/irc-core/src/credential-hashing.ts +11 -54
- package/packages/irc-core/src/effects.ts +24 -0
- package/packages/irc-core/src/flood-control.ts +10 -10
- package/packages/irc-core/src/frame-rate-limit.ts +82 -0
- package/packages/irc-core/src/index.ts +9 -0
- package/packages/irc-core/src/oper-hashing.ts +43 -0
- package/packages/irc-core/src/oper-lockout.ts +87 -0
- package/packages/irc-core/src/ports.ts +529 -190
- package/packages/irc-core/src/protocol/bytes.ts +65 -0
- package/packages/irc-core/src/protocol/channel-name.ts +37 -0
- package/packages/irc-core/src/protocol/index.ts +12 -1
- package/packages/irc-core/src/protocol/outbound.ts +43 -10
- package/packages/irc-core/src/protocol/parser.ts +79 -10
- package/packages/irc-core/src/state/connection.ts +13 -0
- package/packages/irc-core/src/types.ts +266 -23
- package/packages/irc-core/src/ws-framing.ts +5 -4
- package/packages/irc-core/tests/account-migration.test.ts +133 -0
- package/packages/irc-core/tests/bytes.test.ts +89 -0
- package/packages/irc-core/tests/certfp.test.ts +117 -0
- package/packages/irc-core/tests/commands/cap.test.ts +76 -2
- package/packages/irc-core/tests/commands/chanserv.test.ts +166 -0
- package/packages/irc-core/tests/commands/chathistory.test.ts +140 -0
- package/packages/irc-core/tests/commands/hostserv.test.ts +316 -0
- package/packages/irc-core/tests/commands/join.test.ts +78 -1
- package/packages/irc-core/tests/commands/markread.test.ts +54 -0
- package/packages/irc-core/tests/commands/memoserv.test.ts +19 -0
- package/packages/irc-core/tests/commands/names.test.ts +193 -0
- package/packages/irc-core/tests/commands/nickserv.test.ts +419 -3
- package/packages/irc-core/tests/commands/oper.test.ts +574 -1
- package/packages/irc-core/tests/commands/privmsg.test.ts +16 -0
- package/packages/irc-core/tests/commands/registration.test.ts +602 -133
- package/packages/irc-core/tests/commands/sasl.test.ts +742 -172
- package/packages/irc-core/tests/commands/topic.test.ts +137 -2
- package/packages/irc-core/tests/commands/unified-account.test.ts +104 -84
- package/packages/irc-core/tests/config.test.ts +534 -2
- package/packages/irc-core/tests/credential-hashing.test.ts +0 -78
- package/packages/irc-core/tests/effects.test.ts +14 -0
- package/packages/irc-core/tests/flood-control.test.ts +29 -1
- package/packages/irc-core/tests/frame-rate-limit.test.ts +98 -0
- package/packages/irc-core/tests/message-store.test.ts +5 -0
- package/packages/irc-core/tests/oper-hashing.test.ts +60 -0
- package/packages/irc-core/tests/oper-lockout.test.ts +74 -0
- package/packages/irc-core/tests/outbound.test.ts +148 -0
- package/packages/irc-core/tests/parser.test.ts +287 -5
- package/packages/irc-core/tests/persistent-services-store.test.ts +212 -12
- package/packages/irc-core/tests/ports.test.ts +170 -7
- package/packages/irc-core/tests/services-store.test.ts +567 -1
- package/packages/irc-core/tests/ws-framing.test.ts +45 -0
- package/packages/irc-core/vitest.config.ts +6 -1
- package/packages/irc-server/package.json +1 -1
- package/packages/irc-server/src/actor.ts +123 -22
- package/packages/irc-server/src/dispatch.ts +1 -0
- package/packages/irc-server/src/index.ts +7 -0
- package/packages/irc-server/src/redact.ts +159 -0
- package/packages/irc-server/src/runtime.ts +14 -0
- package/packages/irc-server/src/transport.ts +28 -1
- package/packages/irc-server/tests/actor.test.ts +563 -54
- package/packages/irc-server/tests/dispatch.test.ts +31 -0
- package/packages/irc-server/tests/redact.test.ts +198 -0
- package/packages/irc-server/tests/runtime.test.ts +2 -0
- package/packages/irc-server/tests/transport.test.ts +66 -0
- package/packages/irc-test-support/package.json +1 -1
- package/packages/irc-test-support/src/in-memory-harness.ts +4 -0
- package/pnpm-workspace.yaml +1 -0
- package/scripts/__tests__/deploy-web-aws.test.ts +491 -0
- package/scripts/deploy-web-aws.mjs +290 -0
- package/scripts/package.json +23 -0
- package/scripts/tsconfig.test.json +12 -0
- package/scripts/vitest.config.ts +19 -0
- package/tools/ci-hardening/package.json +2 -2
- package/tools/ci-hardening/src/cf-deploy-cli.ts +3 -0
- package/tools/ci-hardening/src/cf-deploy.ts +118 -0
- package/tools/ci-hardening/src/deploy-hostname.ts +118 -0
- package/tools/ci-hardening/src/env-var-drift.ts +192 -0
- package/tools/ci-hardening/src/hostname-guard.ts +11 -0
- package/tools/ci-hardening/src/index.ts +19 -0
- package/tools/ci-hardening/src/validate.ts +57 -0
- package/tools/ci-hardening/tests/__wrangler_missing__.toml +2 -0
- package/tools/ci-hardening/tests/__wrangler_placeholder__.toml +3 -0
- package/tools/ci-hardening/tests/__wrangler_real__.toml +3 -0
- package/tools/ci-hardening/tests/cf-deploy.test.ts +200 -0
- package/tools/ci-hardening/tests/deploy-aws-oidc.test.ts +96 -0
- package/tools/ci-hardening/tests/deploy-hostname.test.ts +348 -0
- package/tools/ci-hardening/tests/env-var-drift.test.ts +284 -0
- package/tools/ci-hardening/tests/validate.test.ts +42 -0
- package/tools/ci-hardening/vitest.config.ts +5 -1
- package/tools/hash-oper-cred.ts +85 -0
- package/tools/load-test/package.json +1 -1
- package/tools/migrate-accounts-to-services.ts +270 -0
- package/tools/package.json +2 -1
- package/tools/seed-aws-accounts.ts +35 -10
- package/tools/seed-cf-accounts.ts +42 -9
- package/tools/tcp-ws-forwarder/package.json +1 -1
- package/packages/aws-adapter/src/account-store.ts +0 -121
- package/packages/aws-adapter/src/dynamo-account-store.ts +0 -95
- package/packages/aws-adapter/tests/account-store-dynamo.test.ts +0 -223
- package/packages/aws-adapter/tests/account-store.test.ts +0 -276
- package/packages/cf-adapter/src/d1-account-store.ts +0 -198
- package/packages/cf-adapter/tests/d1-account-store.test.ts +0 -274
- package/packages/irc-core/tests/account-store.test.ts +0 -131
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest';
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
2
2
|
import pkg from '../../package.json' with { type: 'json' };
|
|
3
3
|
import { cloakHost } from '../../src/cloak';
|
|
4
4
|
import {
|
|
@@ -13,6 +13,8 @@ import {
|
|
|
13
13
|
isValidNick,
|
|
14
14
|
nickReducer,
|
|
15
15
|
passReducer,
|
|
16
|
+
resetServerPasswordCompareSpy,
|
|
17
|
+
setServerPasswordCompare,
|
|
16
18
|
userReducer,
|
|
17
19
|
} from '../../src/commands/registration';
|
|
18
20
|
import { DEFAULT_CREATED_TEXT } from '../../src/config';
|
|
@@ -27,14 +29,7 @@ import {
|
|
|
27
29
|
SequentialIdFactory,
|
|
28
30
|
StaticMotdProvider,
|
|
29
31
|
} from '../../src/ports';
|
|
30
|
-
import type {
|
|
31
|
-
AccountStore,
|
|
32
|
-
AwayStore,
|
|
33
|
-
MotdProvider,
|
|
34
|
-
SaslPayload,
|
|
35
|
-
SaslResult,
|
|
36
|
-
ServicesStore,
|
|
37
|
-
} from '../../src/ports';
|
|
32
|
+
import type { AwayStore, MotdProvider, ServicesStore } from '../../src/ports';
|
|
38
33
|
import { type ConnectionState, createConnection } from '../../src/state/connection';
|
|
39
34
|
import { type Ctx, type ServerConfig, buildCtx } from '../../src/types';
|
|
40
35
|
|
|
@@ -555,6 +550,340 @@ describe('userReducer', () => {
|
|
|
555
550
|
});
|
|
556
551
|
});
|
|
557
552
|
|
|
553
|
+
// ============================================================================
|
|
554
|
+
// userReducer — USER validation (charset + length + control chars)
|
|
555
|
+
// ============================================================================
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* Builds the expected NOTICE line for a USER rejection addressed to the
|
|
559
|
+
* connection's current nick (or `*` when unregistered).
|
|
560
|
+
*/
|
|
561
|
+
function userRejectNotice(nick: string, text: string): RawLine {
|
|
562
|
+
return L(`:irc.example.com NOTICE ${nick} :${text}`);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/** Username-rejection NOTICE text (matches the reducer's tailored notice). */
|
|
566
|
+
const USERNAME_INVALID_TEXT =
|
|
567
|
+
'Invalid USER: username must be 1-16 bytes with no spaces, !, @, or control characters';
|
|
568
|
+
|
|
569
|
+
/** Realname-rejection NOTICE text (matches the reducer's tailored notice). */
|
|
570
|
+
const REALNAME_INVALID_TEXT =
|
|
571
|
+
'Invalid USER: realname must be at most 64 bytes with no control characters';
|
|
572
|
+
|
|
573
|
+
describe('userReducer — USER validation (charset + length)', () => {
|
|
574
|
+
// ------------------------------------------------------------------------
|
|
575
|
+
// Username charset rejections (params[0])
|
|
576
|
+
// ------------------------------------------------------------------------
|
|
577
|
+
|
|
578
|
+
it('rejects a username containing "!" with NOTICE + Disconnect (hostmask safety)', () => {
|
|
579
|
+
const state = makeState();
|
|
580
|
+
const ctx = makeCtx(state);
|
|
581
|
+
const out = userReducer(
|
|
582
|
+
state,
|
|
583
|
+
{ command: 'USER', params: ['al!ce', '0', '*', 'Alice'], tags: {} },
|
|
584
|
+
ctx,
|
|
585
|
+
);
|
|
586
|
+
expect(out.state.user).toBeUndefined();
|
|
587
|
+
expect(out.state.realname).toBeUndefined();
|
|
588
|
+
expect(out.state.registration).toBe('pre-registration');
|
|
589
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
590
|
+
Effect.send('c1', [userRejectNotice('*', USERNAME_INVALID_TEXT)]),
|
|
591
|
+
Effect.disconnect('c1', 'invalid USER'),
|
|
592
|
+
]);
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
it('rejects a username containing "@" with NOTICE + Disconnect (hostmask safety)', () => {
|
|
596
|
+
const state = makeState();
|
|
597
|
+
const ctx = makeCtx(state);
|
|
598
|
+
const out = userReducer(
|
|
599
|
+
state,
|
|
600
|
+
{ command: 'USER', params: ['al@ce', '0', '*', 'Alice'], tags: {} },
|
|
601
|
+
ctx,
|
|
602
|
+
);
|
|
603
|
+
expect(out.state.user).toBeUndefined();
|
|
604
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
605
|
+
Effect.send('c1', [userRejectNotice('*', USERNAME_INVALID_TEXT)]),
|
|
606
|
+
Effect.disconnect('c1', 'invalid USER'),
|
|
607
|
+
]);
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
it('rejects a username containing a space with NOTICE + Disconnect', () => {
|
|
611
|
+
const state = makeState();
|
|
612
|
+
const ctx = makeCtx(state);
|
|
613
|
+
const out = userReducer(
|
|
614
|
+
state,
|
|
615
|
+
{ command: 'USER', params: ['al ce', '0', '*', 'Alice'], tags: {} },
|
|
616
|
+
ctx,
|
|
617
|
+
);
|
|
618
|
+
expect(out.state.user).toBeUndefined();
|
|
619
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
620
|
+
Effect.send('c1', [userRejectNotice('*', USERNAME_INVALID_TEXT)]),
|
|
621
|
+
Effect.disconnect('c1', 'invalid USER'),
|
|
622
|
+
]);
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
it('rejects a username containing NUL (\\0) with NOTICE + Disconnect', () => {
|
|
626
|
+
const state = makeState();
|
|
627
|
+
const ctx = makeCtx(state);
|
|
628
|
+
const out = userReducer(
|
|
629
|
+
state,
|
|
630
|
+
{ command: 'USER', params: ['al\0ce', '0', '*', 'Alice'], tags: {} },
|
|
631
|
+
ctx,
|
|
632
|
+
);
|
|
633
|
+
expect(out.state.user).toBeUndefined();
|
|
634
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
635
|
+
Effect.send('c1', [userRejectNotice('*', USERNAME_INVALID_TEXT)]),
|
|
636
|
+
Effect.disconnect('c1', 'invalid USER'),
|
|
637
|
+
]);
|
|
638
|
+
});
|
|
639
|
+
|
|
640
|
+
it('rejects a username containing a bare CR (\\r) with NOTICE + Disconnect', () => {
|
|
641
|
+
const state = makeState();
|
|
642
|
+
const ctx = makeCtx(state);
|
|
643
|
+
const out = userReducer(
|
|
644
|
+
state,
|
|
645
|
+
{ command: 'USER', params: ['al\rce', '0', '*', 'Alice'], tags: {} },
|
|
646
|
+
ctx,
|
|
647
|
+
);
|
|
648
|
+
expect(out.state.user).toBeUndefined();
|
|
649
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
650
|
+
Effect.send('c1', [userRejectNotice('*', USERNAME_INVALID_TEXT)]),
|
|
651
|
+
Effect.disconnect('c1', 'invalid USER'),
|
|
652
|
+
]);
|
|
653
|
+
});
|
|
654
|
+
|
|
655
|
+
it('rejects a username containing a bare LF (\\n) with NOTICE + Disconnect', () => {
|
|
656
|
+
const state = makeState();
|
|
657
|
+
const ctx = makeCtx(state);
|
|
658
|
+
const out = userReducer(
|
|
659
|
+
state,
|
|
660
|
+
{ command: 'USER', params: ['al\nce', '0', '*', 'Alice'], tags: {} },
|
|
661
|
+
ctx,
|
|
662
|
+
);
|
|
663
|
+
expect(out.state.user).toBeUndefined();
|
|
664
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
665
|
+
Effect.send('c1', [userRejectNotice('*', USERNAME_INVALID_TEXT)]),
|
|
666
|
+
Effect.disconnect('c1', 'invalid USER'),
|
|
667
|
+
]);
|
|
668
|
+
});
|
|
669
|
+
|
|
670
|
+
it('rejects a username containing a control char (\\x01) with NOTICE + Disconnect', () => {
|
|
671
|
+
const state = makeState();
|
|
672
|
+
const ctx = makeCtx(state);
|
|
673
|
+
const out = userReducer(
|
|
674
|
+
state,
|
|
675
|
+
{ command: 'USER', params: ['al\x01ce', '0', '*', 'Alice'], tags: {} },
|
|
676
|
+
ctx,
|
|
677
|
+
);
|
|
678
|
+
expect(out.state.user).toBeUndefined();
|
|
679
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
680
|
+
Effect.send('c1', [userRejectNotice('*', USERNAME_INVALID_TEXT)]),
|
|
681
|
+
Effect.disconnect('c1', 'invalid USER'),
|
|
682
|
+
]);
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
// ------------------------------------------------------------------------
|
|
686
|
+
// Username length rejection (USERLEN = 16 byte cap)
|
|
687
|
+
// ------------------------------------------------------------------------
|
|
688
|
+
|
|
689
|
+
it('rejects a 17-byte username with NOTICE + Disconnect (USERLEN = 16)', () => {
|
|
690
|
+
const state = makeState();
|
|
691
|
+
const ctx = makeCtx(state);
|
|
692
|
+
const longUser = 'a'.repeat(17);
|
|
693
|
+
const out = userReducer(
|
|
694
|
+
state,
|
|
695
|
+
{ command: 'USER', params: [longUser, '0', '*', 'Alice'], tags: {} },
|
|
696
|
+
ctx,
|
|
697
|
+
);
|
|
698
|
+
expect(out.state.user).toBeUndefined();
|
|
699
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
700
|
+
Effect.send('c1', [userRejectNotice('*', USERNAME_INVALID_TEXT)]),
|
|
701
|
+
Effect.disconnect('c1', 'invalid USER'),
|
|
702
|
+
]);
|
|
703
|
+
});
|
|
704
|
+
|
|
705
|
+
it('accepts a username at exactly the 16-byte USERLEN cap (boundary)', () => {
|
|
706
|
+
const state = makeState();
|
|
707
|
+
const ctx = makeCtx(state);
|
|
708
|
+
const exactUser = 'a'.repeat(16);
|
|
709
|
+
const out = userReducer(
|
|
710
|
+
state,
|
|
711
|
+
{ command: 'USER', params: [exactUser, '0', '*', 'Alice'], tags: {} },
|
|
712
|
+
ctx,
|
|
713
|
+
);
|
|
714
|
+
expect(out.state.user).toBe(exactUser);
|
|
715
|
+
expect(out.state.realname).toBe('Alice');
|
|
716
|
+
});
|
|
717
|
+
|
|
718
|
+
// ------------------------------------------------------------------------
|
|
719
|
+
// Realname charset rejections (params[3])
|
|
720
|
+
// ------------------------------------------------------------------------
|
|
721
|
+
|
|
722
|
+
it('rejects a realname containing NUL (\\0) with NOTICE + Disconnect', () => {
|
|
723
|
+
const state = makeState();
|
|
724
|
+
const ctx = makeCtx(state);
|
|
725
|
+
const out = userReducer(
|
|
726
|
+
state,
|
|
727
|
+
{ command: 'USER', params: ['alice', '0', '*', 'Al\0ice'], tags: {} },
|
|
728
|
+
ctx,
|
|
729
|
+
);
|
|
730
|
+
expect(out.state.user).toBeUndefined();
|
|
731
|
+
expect(out.state.realname).toBeUndefined();
|
|
732
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
733
|
+
Effect.send('c1', [userRejectNotice('*', REALNAME_INVALID_TEXT)]),
|
|
734
|
+
Effect.disconnect('c1', 'invalid USER'),
|
|
735
|
+
]);
|
|
736
|
+
});
|
|
737
|
+
|
|
738
|
+
it('rejects a realname containing a bare CR (\\r) with NOTICE + Disconnect (CRLF injection)', () => {
|
|
739
|
+
const state = makeState();
|
|
740
|
+
const ctx = makeCtx(state);
|
|
741
|
+
const out = userReducer(
|
|
742
|
+
state,
|
|
743
|
+
{ command: 'USER', params: ['alice', '0', '*', 'Al\rice'], tags: {} },
|
|
744
|
+
ctx,
|
|
745
|
+
);
|
|
746
|
+
expect(out.state.realname).toBeUndefined();
|
|
747
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
748
|
+
Effect.send('c1', [userRejectNotice('*', REALNAME_INVALID_TEXT)]),
|
|
749
|
+
Effect.disconnect('c1', 'invalid USER'),
|
|
750
|
+
]);
|
|
751
|
+
});
|
|
752
|
+
|
|
753
|
+
it('rejects a realname containing a bare LF (\\n) with NOTICE + Disconnect', () => {
|
|
754
|
+
const state = makeState();
|
|
755
|
+
const ctx = makeCtx(state);
|
|
756
|
+
const out = userReducer(
|
|
757
|
+
state,
|
|
758
|
+
{ command: 'USER', params: ['alice', '0', '*', 'Al\nice'], tags: {} },
|
|
759
|
+
ctx,
|
|
760
|
+
);
|
|
761
|
+
expect(out.state.realname).toBeUndefined();
|
|
762
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
763
|
+
Effect.send('c1', [userRejectNotice('*', REALNAME_INVALID_TEXT)]),
|
|
764
|
+
Effect.disconnect('c1', 'invalid USER'),
|
|
765
|
+
]);
|
|
766
|
+
});
|
|
767
|
+
|
|
768
|
+
it('rejects a realname containing a control char (\\x01) with NOTICE + Disconnect', () => {
|
|
769
|
+
const state = makeState();
|
|
770
|
+
const ctx = makeCtx(state);
|
|
771
|
+
const out = userReducer(
|
|
772
|
+
state,
|
|
773
|
+
{ command: 'USER', params: ['alice', '0', '*', 'Al\x01ice'], tags: {} },
|
|
774
|
+
ctx,
|
|
775
|
+
);
|
|
776
|
+
expect(out.state.realname).toBeUndefined();
|
|
777
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
778
|
+
Effect.send('c1', [userRejectNotice('*', REALNAME_INVALID_TEXT)]),
|
|
779
|
+
Effect.disconnect('c1', 'invalid USER'),
|
|
780
|
+
]);
|
|
781
|
+
});
|
|
782
|
+
|
|
783
|
+
// ------------------------------------------------------------------------
|
|
784
|
+
// Realname length rejection (REALLEN = 64 byte cap, multi-byte aware)
|
|
785
|
+
// ------------------------------------------------------------------------
|
|
786
|
+
|
|
787
|
+
it('rejects a 65-byte realname with NOTICE + Disconnect (REALLEN = 64)', () => {
|
|
788
|
+
const state = makeState();
|
|
789
|
+
const ctx = makeCtx(state);
|
|
790
|
+
const longReal = 'A'.repeat(65);
|
|
791
|
+
const out = userReducer(
|
|
792
|
+
state,
|
|
793
|
+
{ command: 'USER', params: ['alice', '0', '*', longReal], tags: {} },
|
|
794
|
+
ctx,
|
|
795
|
+
);
|
|
796
|
+
expect(out.state.realname).toBeUndefined();
|
|
797
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
798
|
+
Effect.send('c1', [userRejectNotice('*', REALNAME_INVALID_TEXT)]),
|
|
799
|
+
Effect.disconnect('c1', 'invalid USER'),
|
|
800
|
+
]);
|
|
801
|
+
});
|
|
802
|
+
|
|
803
|
+
it('rejects a multi-byte UTF-8 realname exceeding the 64-byte budget', () => {
|
|
804
|
+
const state = makeState();
|
|
805
|
+
const ctx = makeCtx(state);
|
|
806
|
+
// Each '€' is 3 bytes in UTF-8; 22 × 3 = 66 bytes > 64.
|
|
807
|
+
const overBudget = '€'.repeat(22);
|
|
808
|
+
const out = userReducer(
|
|
809
|
+
state,
|
|
810
|
+
{ command: 'USER', params: ['alice', '0', '*', overBudget], tags: {} },
|
|
811
|
+
ctx,
|
|
812
|
+
);
|
|
813
|
+
expect(out.state.realname).toBeUndefined();
|
|
814
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
815
|
+
Effect.send('c1', [userRejectNotice('*', REALNAME_INVALID_TEXT)]),
|
|
816
|
+
Effect.disconnect('c1', 'invalid USER'),
|
|
817
|
+
]);
|
|
818
|
+
});
|
|
819
|
+
|
|
820
|
+
it('accepts a multi-byte UTF-8 realname within the 64-byte budget (regression guard)', () => {
|
|
821
|
+
const state = makeState();
|
|
822
|
+
const ctx = makeCtx(state);
|
|
823
|
+
// 21 × 3 = 63 bytes ≤ 64.
|
|
824
|
+
const withinBudget = '€'.repeat(21);
|
|
825
|
+
const out = userReducer(
|
|
826
|
+
state,
|
|
827
|
+
{ command: 'USER', params: ['alice', '0', '*', withinBudget], tags: {} },
|
|
828
|
+
ctx,
|
|
829
|
+
);
|
|
830
|
+
expect(out.state.user).toBe('alice');
|
|
831
|
+
expect(out.state.realname).toBe(withinBudget);
|
|
832
|
+
});
|
|
833
|
+
|
|
834
|
+
it('accepts a realname at exactly the 64-byte REALLEN cap (boundary)', () => {
|
|
835
|
+
const state = makeState();
|
|
836
|
+
const ctx = makeCtx(state);
|
|
837
|
+
const exactReal = 'A'.repeat(64);
|
|
838
|
+
const out = userReducer(
|
|
839
|
+
state,
|
|
840
|
+
{ command: 'USER', params: ['alice', '0', '*', exactReal], tags: {} },
|
|
841
|
+
ctx,
|
|
842
|
+
);
|
|
843
|
+
expect(out.state.realname).toBe(exactReal);
|
|
844
|
+
});
|
|
845
|
+
|
|
846
|
+
it('does not half-store state when the username is valid but the realname is invalid', () => {
|
|
847
|
+
const state = makeState();
|
|
848
|
+
const ctx = makeCtx(state);
|
|
849
|
+
const out = userReducer(
|
|
850
|
+
state,
|
|
851
|
+
{ command: 'USER', params: ['alice', '0', '*', 'Al\rice'], tags: {} },
|
|
852
|
+
ctx,
|
|
853
|
+
);
|
|
854
|
+
expect(out.state.user).toBeUndefined();
|
|
855
|
+
expect(out.state.realname).toBeUndefined();
|
|
856
|
+
});
|
|
857
|
+
|
|
858
|
+
it('uses the current nick (not *) in the rejection NOTICE when partially registered', () => {
|
|
859
|
+
const state = makeState();
|
|
860
|
+
state.nick = 'bob';
|
|
861
|
+
state.registration = 'registering';
|
|
862
|
+
const ctx = makeCtx(state);
|
|
863
|
+
const out = userReducer(
|
|
864
|
+
state,
|
|
865
|
+
{ command: 'USER', params: ['b!ob', '0', '*', 'Bob'], tags: {} },
|
|
866
|
+
ctx,
|
|
867
|
+
);
|
|
868
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
869
|
+
Effect.send('c1', [userRejectNotice('bob', USERNAME_INVALID_TEXT)]),
|
|
870
|
+
Effect.disconnect('c1', 'invalid USER'),
|
|
871
|
+
]);
|
|
872
|
+
});
|
|
873
|
+
|
|
874
|
+
it('still allows a realname containing spaces (valid realname)', () => {
|
|
875
|
+
const state = makeState();
|
|
876
|
+
const ctx = makeCtx(state);
|
|
877
|
+
const out = userReducer(
|
|
878
|
+
state,
|
|
879
|
+
{ command: 'USER', params: ['alice', '0', '*', 'Alice Wonderland'], tags: {} },
|
|
880
|
+
ctx,
|
|
881
|
+
);
|
|
882
|
+
expect(out.state.user).toBe('alice');
|
|
883
|
+
expect(out.state.realname).toBe('Alice Wonderland');
|
|
884
|
+
});
|
|
885
|
+
});
|
|
886
|
+
|
|
558
887
|
// ============================================================================
|
|
559
888
|
// passReducer
|
|
560
889
|
// ============================================================================
|
|
@@ -615,8 +944,9 @@ describe('passReducer', () => {
|
|
|
615
944
|
describe('passReducer — post-registration PASS <nick>:<password>', () => {
|
|
616
945
|
it('logs in when PASS <nick>:<password> matches an account after registration', () => {
|
|
617
946
|
const state = registeredState();
|
|
618
|
-
const
|
|
619
|
-
|
|
947
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
948
|
+
services.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
949
|
+
const ctx = makeCtxWithServices(state, services);
|
|
620
950
|
const out = passReducer(state, { command: 'PASS', params: ['alice:hunter2'], tags: {} }, ctx);
|
|
621
951
|
|
|
622
952
|
expect(out.state.account).toBe('alice');
|
|
@@ -632,8 +962,9 @@ describe('passReducer — post-registration PASS <nick>:<password>', () => {
|
|
|
632
962
|
|
|
633
963
|
it('does not log in or disconnect when the password is wrong', () => {
|
|
634
964
|
const state = registeredState();
|
|
635
|
-
const
|
|
636
|
-
|
|
965
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
966
|
+
services.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
967
|
+
const ctx = makeCtxWithServices(state, services);
|
|
637
968
|
const out = passReducer(state, { command: 'PASS', params: ['alice:wrong'], tags: {} }, ctx);
|
|
638
969
|
|
|
639
970
|
expect(out.state.account).toBeUndefined();
|
|
@@ -645,8 +976,8 @@ describe('passReducer — post-registration PASS <nick>:<password>', () => {
|
|
|
645
976
|
it('is indistinguishable from an unknown nick (no auth numerics, no disconnect)', () => {
|
|
646
977
|
const state = registeredState();
|
|
647
978
|
state.nick = 'nobody';
|
|
648
|
-
const
|
|
649
|
-
const ctx =
|
|
979
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
980
|
+
const ctx = makeCtxWithServices(state, services);
|
|
650
981
|
const out = passReducer(state, { command: 'PASS', params: ['nobody:wrong'], tags: {} }, ctx);
|
|
651
982
|
|
|
652
983
|
expect(out.state.account).toBeUndefined();
|
|
@@ -655,55 +986,63 @@ describe('passReducer — post-registration PASS <nick>:<password>', () => {
|
|
|
655
986
|
|
|
656
987
|
it('equalises timing with an extra dummy verify on failure', () => {
|
|
657
988
|
const state = registeredState();
|
|
658
|
-
const
|
|
659
|
-
|
|
989
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
990
|
+
services.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
991
|
+
const verifyNick = vi.spyOn(services, 'verifyNick');
|
|
992
|
+
const ctx = makeCtxWithServices(state, services);
|
|
660
993
|
passReducer(state, { command: 'PASS', params: ['alice:wrong'], tags: {} }, ctx);
|
|
661
994
|
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
expect(
|
|
995
|
+
// Two calls: the real (failed) verify against `alice`, then the
|
|
996
|
+
// timing-equalising dummy verify.
|
|
997
|
+
expect(verifyNick).toHaveBeenCalledTimes(2);
|
|
998
|
+
expect(verifyNick.mock.calls[0]?.[0]).toBe('alice');
|
|
999
|
+
expect(verifyNick.mock.calls[0]?.[1]).toBe('wrong');
|
|
1000
|
+
// The second call is the dummy; its nick is the sentinel, not 'alice'.
|
|
1001
|
+
expect(verifyNick.mock.calls[1]?.[0]).not.toBe('alice');
|
|
669
1002
|
});
|
|
670
1003
|
|
|
671
1004
|
it('does not log in when the payload nick does not match state.nick', () => {
|
|
672
1005
|
const state = registeredState();
|
|
673
|
-
const
|
|
674
|
-
|
|
1006
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
1007
|
+
services.registerNick('bob', 'hunter2', 'bob@example.com');
|
|
1008
|
+
const verifyNick = vi.spyOn(services, 'verifyNick');
|
|
1009
|
+
const ctx = makeCtxWithServices(state, services);
|
|
675
1010
|
const out = passReducer(state, { command: 'PASS', params: ['bob:hunter2'], tags: {} }, ctx);
|
|
676
1011
|
|
|
677
1012
|
expect(out.state.account).toBeUndefined();
|
|
678
|
-
expect(
|
|
1013
|
+
expect(verifyNick).not.toHaveBeenCalled();
|
|
679
1014
|
expect(out.effects).toEqual([]);
|
|
680
1015
|
});
|
|
681
1016
|
|
|
682
1017
|
it('ignores a bare PASS value (no colon) after registration — no 462, no disconnect', () => {
|
|
683
1018
|
const state = registeredState();
|
|
684
|
-
const
|
|
685
|
-
|
|
1019
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
1020
|
+
services.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
1021
|
+
const verifyNick = vi.spyOn(services, 'verifyNick');
|
|
1022
|
+
const ctx = makeCtxWithServices(state, services);
|
|
686
1023
|
const out = passReducer(state, { command: 'PASS', params: ['serverpw'], tags: {} }, ctx);
|
|
687
1024
|
|
|
688
1025
|
expect(out.state.account).toBeUndefined();
|
|
689
1026
|
expect(out.effects).toEqual([]);
|
|
690
|
-
expect(
|
|
1027
|
+
expect(verifyNick).not.toHaveBeenCalled();
|
|
691
1028
|
expect(out.effects.some((e) => e.tag === 'Disconnect')).toBe(false);
|
|
692
1029
|
});
|
|
693
1030
|
|
|
694
1031
|
it('ignores a late PASS when the connection is already identified', () => {
|
|
695
1032
|
const state = registeredState();
|
|
696
1033
|
state.account = 'alice';
|
|
697
|
-
const
|
|
698
|
-
|
|
1034
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
1035
|
+
services.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
1036
|
+
const verifyNick = vi.spyOn(services, 'verifyNick');
|
|
1037
|
+
const ctx = makeCtxWithServices(state, services);
|
|
699
1038
|
const out = passReducer(state, { command: 'PASS', params: ['alice:hunter2'], tags: {} }, ctx);
|
|
700
1039
|
|
|
701
|
-
expect(
|
|
1040
|
+
expect(verifyNick).not.toHaveBeenCalled();
|
|
702
1041
|
expect(out.effects).toEqual([]);
|
|
703
1042
|
expect(out.effects.some((e) => e.tag === 'Disconnect')).toBe(false);
|
|
704
1043
|
});
|
|
705
1044
|
|
|
706
|
-
it('ignores a late PASS <nick>:<password> when no
|
|
1045
|
+
it('ignores a late PASS <nick>:<password> when no ServicesStore is bound', () => {
|
|
707
1046
|
const state = registeredState();
|
|
708
1047
|
const ctx = makeCtx(state);
|
|
709
1048
|
const out = passReducer(state, { command: 'PASS', params: ['alice:hunter2'], tags: {} }, ctx);
|
|
@@ -1094,6 +1433,132 @@ describe('emitWelcomeIfReady — server-password gate', () => {
|
|
|
1094
1433
|
});
|
|
1095
1434
|
});
|
|
1096
1435
|
|
|
1436
|
+
// ============================================================================
|
|
1437
|
+
// server-password gate — constant-time comparison
|
|
1438
|
+
// ----------------------------------------------------------------------------
|
|
1439
|
+
|
|
1440
|
+
/**
|
|
1441
|
+
* Runs one gate evaluation against the configured password `s3cret` and
|
|
1442
|
+
* returns the effects, so the tests below can focus on comparator calls.
|
|
1443
|
+
*/
|
|
1444
|
+
function runGate(passAttempt: string | undefined): EffectType[] {
|
|
1445
|
+
const state = makeState();
|
|
1446
|
+
state.nick = 'alice';
|
|
1447
|
+
state.user = 'alice';
|
|
1448
|
+
if (passAttempt !== undefined) state.passAttempt = passAttempt;
|
|
1449
|
+
return emitWelcomeIfReady(state, makeCtxWithPassword(state, 's3cret'));
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1452
|
+
/**
|
|
1453
|
+
* Installs a call-counting comparator that behaves like string equality so
|
|
1454
|
+
* gate outcomes stay behavioral while the delegation is observable.
|
|
1455
|
+
*/
|
|
1456
|
+
function installCountingCompare(): ReturnType<typeof vi.fn> {
|
|
1457
|
+
const compare = vi.fn((a: string, b: string) => a === b);
|
|
1458
|
+
setServerPasswordCompare(compare);
|
|
1459
|
+
return compare;
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
describe('passwordGateFailure — constant-time comparison', () => {
|
|
1463
|
+
afterEach(() => {
|
|
1464
|
+
resetServerPasswordCompareSpy();
|
|
1465
|
+
});
|
|
1466
|
+
|
|
1467
|
+
it('compares the attempt against the configured password via constantTimeEquals', () => {
|
|
1468
|
+
const compare = installCountingCompare();
|
|
1469
|
+
try {
|
|
1470
|
+
runGate('wrong');
|
|
1471
|
+
expect(compare).toHaveBeenCalledTimes(1);
|
|
1472
|
+
expect(compare).toHaveBeenCalledWith('wrong', 's3cret');
|
|
1473
|
+
} finally {
|
|
1474
|
+
setServerPasswordCompare(null);
|
|
1475
|
+
}
|
|
1476
|
+
});
|
|
1477
|
+
|
|
1478
|
+
it('compares exactly once on the equal-password bucket (gate satisfied)', () => {
|
|
1479
|
+
const compare = installCountingCompare();
|
|
1480
|
+
try {
|
|
1481
|
+
const effects = runGate('s3cret');
|
|
1482
|
+
expect(compare).toHaveBeenCalledTimes(1);
|
|
1483
|
+
expect(effects).toEqual([Effect.send('c1', expectedWelcome('alice', 'alice', undefined))]);
|
|
1484
|
+
} finally {
|
|
1485
|
+
setServerPasswordCompare(null);
|
|
1486
|
+
}
|
|
1487
|
+
});
|
|
1488
|
+
|
|
1489
|
+
it('compares exactly once on the same-length unequal bucket (464 + Disconnect)', () => {
|
|
1490
|
+
const compare = installCountingCompare();
|
|
1491
|
+
try {
|
|
1492
|
+
const effects = runGate('X3cret');
|
|
1493
|
+
expect(compare).toHaveBeenCalledTimes(1);
|
|
1494
|
+
expect(effects).toEqual<EffectType[]>([
|
|
1495
|
+
Effect.send('c1', [L(':irc.example.com 464 alice :Password mismatch')]),
|
|
1496
|
+
Effect.disconnect('c1', 'Bad Password'),
|
|
1497
|
+
]);
|
|
1498
|
+
} finally {
|
|
1499
|
+
setServerPasswordCompare(null);
|
|
1500
|
+
}
|
|
1501
|
+
});
|
|
1502
|
+
|
|
1503
|
+
it('compares exactly once on the different-length bucket — no length pre-check bypass', () => {
|
|
1504
|
+
const compare = installCountingCompare();
|
|
1505
|
+
try {
|
|
1506
|
+
const effects = runGate('s3cr');
|
|
1507
|
+
expect(compare).toHaveBeenCalledTimes(1);
|
|
1508
|
+
expect(compare).toHaveBeenCalledWith('s3cr', 's3cret');
|
|
1509
|
+
expect(effects).toEqual<EffectType[]>([
|
|
1510
|
+
Effect.send('c1', [L(':irc.example.com 464 alice :Password mismatch')]),
|
|
1511
|
+
Effect.disconnect('c1', 'Bad Password'),
|
|
1512
|
+
]);
|
|
1513
|
+
} finally {
|
|
1514
|
+
setServerPasswordCompare(null);
|
|
1515
|
+
}
|
|
1516
|
+
});
|
|
1517
|
+
|
|
1518
|
+
it('compares the empty placeholder once when no PASS was sent', () => {
|
|
1519
|
+
const compare = installCountingCompare();
|
|
1520
|
+
try {
|
|
1521
|
+
runGate(undefined);
|
|
1522
|
+
expect(compare).toHaveBeenCalledTimes(1);
|
|
1523
|
+
expect(compare).toHaveBeenCalledWith('', 's3cret');
|
|
1524
|
+
} finally {
|
|
1525
|
+
setServerPasswordCompare(null);
|
|
1526
|
+
}
|
|
1527
|
+
});
|
|
1528
|
+
|
|
1529
|
+
it('does not compare when the gate is satisfied by SASL (account set)', () => {
|
|
1530
|
+
const state = makeState();
|
|
1531
|
+
state.nick = 'alice';
|
|
1532
|
+
state.user = 'alice';
|
|
1533
|
+
state.account = 'alice';
|
|
1534
|
+
const compare = installCountingCompare();
|
|
1535
|
+
try {
|
|
1536
|
+
const effects = emitWelcomeIfReady(state, makeCtxWithPassword(state, 's3cret'));
|
|
1537
|
+
expect(compare).not.toHaveBeenCalled();
|
|
1538
|
+
expect(state.registration).toBe('registered');
|
|
1539
|
+
expect(effects).toEqual([Effect.send('c1', expectedWelcome('alice', 'alice', undefined))]);
|
|
1540
|
+
} finally {
|
|
1541
|
+
setServerPasswordCompare(null);
|
|
1542
|
+
}
|
|
1543
|
+
});
|
|
1544
|
+
|
|
1545
|
+
it('does not compare when no server password is configured (gate disabled)', () => {
|
|
1546
|
+
const state = makeState();
|
|
1547
|
+
state.nick = 'alice';
|
|
1548
|
+
state.user = 'alice';
|
|
1549
|
+
const compare = installCountingCompare();
|
|
1550
|
+
try {
|
|
1551
|
+
const ctx = makeCtxWithPassword(state, '');
|
|
1552
|
+
const effects = emitWelcomeIfReady(state, ctx);
|
|
1553
|
+
expect(compare).not.toHaveBeenCalled();
|
|
1554
|
+
expect(state.registration).toBe('registered');
|
|
1555
|
+
expect(effects).toEqual([Effect.send('c1', expectedWelcome('alice', 'alice', undefined))]);
|
|
1556
|
+
} finally {
|
|
1557
|
+
setServerPasswordCompare(null);
|
|
1558
|
+
}
|
|
1559
|
+
});
|
|
1560
|
+
});
|
|
1561
|
+
|
|
1097
1562
|
// ============================================================================
|
|
1098
1563
|
// userReducer / nickReducer — server-password gate integration
|
|
1099
1564
|
// ============================================================================
|
|
@@ -1630,32 +2095,6 @@ describe('emitWelcomeIfReady — OperServ AKILL admission', () => {
|
|
|
1630
2095
|
// PASS-based account auth (NICK + PASS <nick>:<password> + USER → identified)
|
|
1631
2096
|
// ============================================================================
|
|
1632
2097
|
|
|
1633
|
-
/** A capture-and-respond fake AccountStore (mirrors the sasl.test.ts fake). */
|
|
1634
|
-
class FakeAccountStore implements AccountStore {
|
|
1635
|
-
readonly calls: Array<{ mech: string; payload: SaslPayload }> = [];
|
|
1636
|
-
constructor(private readonly result: SaslResult) {}
|
|
1637
|
-
verify(mech: string, payload: SaslPayload): SaslResult {
|
|
1638
|
-
this.calls.push({ mech, payload });
|
|
1639
|
-
return this.result;
|
|
1640
|
-
}
|
|
1641
|
-
}
|
|
1642
|
-
|
|
1643
|
-
/** Like {@link makeCtx} but also threads a bound AccountStore. */
|
|
1644
|
-
function makeCtxWithAccounts(
|
|
1645
|
-
state: ConnectionState,
|
|
1646
|
-
accounts: AccountStore,
|
|
1647
|
-
clock = new FakeClock(1_000),
|
|
1648
|
-
): Ctx {
|
|
1649
|
-
return buildCtx({
|
|
1650
|
-
serverConfig,
|
|
1651
|
-
clock,
|
|
1652
|
-
ids: new SequentialIdFactory(),
|
|
1653
|
-
motd: EmptyMotdProvider,
|
|
1654
|
-
connection: state,
|
|
1655
|
-
accounts,
|
|
1656
|
-
});
|
|
1657
|
-
}
|
|
1658
|
-
|
|
1659
2098
|
const SV = 'irc.example.com';
|
|
1660
2099
|
const L2 = (text: string): RawLine => ({ text });
|
|
1661
2100
|
|
|
@@ -1666,8 +2105,9 @@ describe('emitWelcomeIfReady — PASS-based account auth (success)', () => {
|
|
|
1666
2105
|
state.user = 'alice';
|
|
1667
2106
|
state.host = 'example.com';
|
|
1668
2107
|
state.passAttempt = 'alice:hunter2';
|
|
1669
|
-
const
|
|
1670
|
-
|
|
2108
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
2109
|
+
services.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
2110
|
+
const ctx = makeCtxWithServices(state, services);
|
|
1671
2111
|
|
|
1672
2112
|
const effects = emitWelcomeIfReady(state, ctx);
|
|
1673
2113
|
|
|
@@ -1684,11 +2124,10 @@ describe('emitWelcomeIfReady — PASS-based account auth (success)', () => {
|
|
|
1684
2124
|
|
|
1685
2125
|
// ---- helpers for the broader PASS-auth coverage ----
|
|
1686
2126
|
|
|
1687
|
-
/** Flexible ctx that binds any combination of
|
|
2127
|
+
/** Flexible ctx that binds any combination of services/away stores. */
|
|
1688
2128
|
function makeCtxPassAuth(
|
|
1689
2129
|
state: ConnectionState,
|
|
1690
2130
|
opts: {
|
|
1691
|
-
accounts?: AccountStore;
|
|
1692
2131
|
services?: ServicesStore;
|
|
1693
2132
|
away?: AwayStore;
|
|
1694
2133
|
serverPassword?: string;
|
|
@@ -1703,7 +2142,6 @@ function makeCtxPassAuth(
|
|
|
1703
2142
|
ids: new SequentialIdFactory(),
|
|
1704
2143
|
motd: EmptyMotdProvider,
|
|
1705
2144
|
connection: state,
|
|
1706
|
-
...(opts.accounts !== undefined ? { accounts: opts.accounts } : {}),
|
|
1707
2145
|
...(opts.services !== undefined ? { services: opts.services } : {}),
|
|
1708
2146
|
...(opts.away !== undefined ? { away: opts.away } : {}),
|
|
1709
2147
|
});
|
|
@@ -1715,16 +2153,16 @@ function sentTexts(effects: EffectType[]): string[] {
|
|
|
1715
2153
|
}
|
|
1716
2154
|
|
|
1717
2155
|
describe('emitWelcomeIfReady — PASS-based account auth (account-effects parity with SASL)', () => {
|
|
1718
|
-
it('forwards the parsed credentials to
|
|
2156
|
+
it('forwards the parsed credentials to services.verifyNick as a PLAIN verify', () => {
|
|
1719
2157
|
const state = makeState();
|
|
1720
2158
|
state.nick = 'alice';
|
|
1721
2159
|
state.user = 'alice';
|
|
1722
2160
|
state.passAttempt = 'alice:hunter2';
|
|
1723
|
-
const
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
2161
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
2162
|
+
services.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
2163
|
+
const verifyNick = vi.spyOn(services, 'verifyNick');
|
|
2164
|
+
emitWelcomeIfReady(state, makeCtxPassAuth(state, { services }));
|
|
2165
|
+
expect(verifyNick).toHaveBeenCalledWith('alice', 'hunter2');
|
|
1728
2166
|
});
|
|
1729
2167
|
|
|
1730
2168
|
it('stamps read-only user mode `r` on a successful PASS-auth', () => {
|
|
@@ -1732,8 +2170,9 @@ describe('emitWelcomeIfReady — PASS-based account auth (account-effects parity
|
|
|
1732
2170
|
state.nick = 'alice';
|
|
1733
2171
|
state.user = 'alice';
|
|
1734
2172
|
state.passAttempt = 'alice:hunter2';
|
|
1735
|
-
const
|
|
1736
|
-
|
|
2173
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
2174
|
+
services.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
2175
|
+
emitWelcomeIfReady(state, makeCtxPassAuth(state, { services }));
|
|
1737
2176
|
expect(state.userModes.registered).toBe(true);
|
|
1738
2177
|
});
|
|
1739
2178
|
|
|
@@ -1742,10 +2181,10 @@ describe('emitWelcomeIfReady — PASS-based account auth (account-effects parity
|
|
|
1742
2181
|
state.nick = 'alice';
|
|
1743
2182
|
state.user = 'alice';
|
|
1744
2183
|
state.passAttempt = 'alice:hunter2';
|
|
1745
|
-
const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
|
|
1746
2184
|
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
2185
|
+
services.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
1747
2186
|
services.setLastReadMarker('alice', '#foo', 'm1');
|
|
1748
|
-
emitWelcomeIfReady(state, makeCtxPassAuth(state, {
|
|
2187
|
+
emitWelcomeIfReady(state, makeCtxPassAuth(state, { services }));
|
|
1749
2188
|
expect(state.lastReadMarkers?.get('#foo')).toBe('m1');
|
|
1750
2189
|
});
|
|
1751
2190
|
|
|
@@ -1754,10 +2193,11 @@ describe('emitWelcomeIfReady — PASS-based account auth (account-effects parity
|
|
|
1754
2193
|
state.nick = 'alice';
|
|
1755
2194
|
state.user = 'alice';
|
|
1756
2195
|
state.passAttempt = 'alice:hunter2';
|
|
1757
|
-
const
|
|
2196
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
2197
|
+
services.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
1758
2198
|
const away = new InMemoryAwayStore();
|
|
1759
2199
|
away.set('alice', 'brb');
|
|
1760
|
-
const effects = emitWelcomeIfReady(state, makeCtxPassAuth(state, {
|
|
2200
|
+
const effects = emitWelcomeIfReady(state, makeCtxPassAuth(state, { services, away }));
|
|
1761
2201
|
expect(state.away).toBe('brb');
|
|
1762
2202
|
expect(sentTexts(effects)).toContain(`:${SV} 306 alice :You have been marked as being away`);
|
|
1763
2203
|
});
|
|
@@ -1771,8 +2211,7 @@ describe('emitWelcomeIfReady — PASS-based account auth (account-effects parity
|
|
|
1771
2211
|
services.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
1772
2212
|
services.registerNick('bob', 'pw', 'bob@example.com');
|
|
1773
2213
|
services.recordMemo({ from: 'bob', to: 'alice', body: 'hi', sentAt: 0 });
|
|
1774
|
-
const
|
|
1775
|
-
const effects = emitWelcomeIfReady(state, makeCtxPassAuth(state, { accounts, services }));
|
|
2214
|
+
const effects = emitWelcomeIfReady(state, makeCtxPassAuth(state, { services }));
|
|
1776
2215
|
expect(sentTexts(effects).some((t) => t.includes('[Memo #') && t.includes('hi'))).toBe(true);
|
|
1777
2216
|
});
|
|
1778
2217
|
|
|
@@ -1782,10 +2221,10 @@ describe('emitWelcomeIfReady — PASS-based account auth (account-effects parity
|
|
|
1782
2221
|
state.user = 'alice';
|
|
1783
2222
|
state.host = 'real.example.net';
|
|
1784
2223
|
state.passAttempt = 'alice:hunter2';
|
|
1785
|
-
const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
|
|
1786
2224
|
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
2225
|
+
services.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
1787
2226
|
services.setVhost('alice', 'cool.example.net');
|
|
1788
|
-
const ctx = makeCtxPassAuth(state, {
|
|
2227
|
+
const ctx = makeCtxPassAuth(state, { services });
|
|
1789
2228
|
|
|
1790
2229
|
const effects = emitWelcomeIfReady(state, ctx);
|
|
1791
2230
|
|
|
@@ -1804,9 +2243,9 @@ describe('emitWelcomeIfReady — PASS-based account auth (account-effects parity
|
|
|
1804
2243
|
state.user = 'alice';
|
|
1805
2244
|
state.host = 'real.example.net';
|
|
1806
2245
|
state.passAttempt = 'alice:hunter2';
|
|
1807
|
-
const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
|
|
1808
2246
|
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
1809
|
-
|
|
2247
|
+
services.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
2248
|
+
const ctx = makeCtxPassAuth(state, { services });
|
|
1810
2249
|
|
|
1811
2250
|
emitWelcomeIfReady(state, ctx);
|
|
1812
2251
|
|
|
@@ -1820,8 +2259,8 @@ describe('emitWelcomeIfReady — PASS-based account auth (account-effects parity
|
|
|
1820
2259
|
state.user = 'alice';
|
|
1821
2260
|
state.host = '203.0.113.5';
|
|
1822
2261
|
state.passAttempt = 'alice:hunter2';
|
|
1823
|
-
const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
|
|
1824
2262
|
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
2263
|
+
services.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
1825
2264
|
services.setVhost('alice', 'cool.example.net');
|
|
1826
2265
|
const ctx: Ctx = buildCtx({
|
|
1827
2266
|
serverConfig: { ...serverConfig, cloaking: { enabled: true, secret: 's3cret' } },
|
|
@@ -1829,7 +2268,6 @@ describe('emitWelcomeIfReady — PASS-based account auth (account-effects parity
|
|
|
1829
2268
|
ids: new SequentialIdFactory(),
|
|
1830
2269
|
motd: EmptyMotdProvider,
|
|
1831
2270
|
connection: state,
|
|
1832
|
-
accounts,
|
|
1833
2271
|
services,
|
|
1834
2272
|
});
|
|
1835
2273
|
|
|
@@ -1851,8 +2289,9 @@ describe('emitWelcomeIfReady — PASS-based account auth (account-effects parity
|
|
|
1851
2289
|
state.nick = 'alice';
|
|
1852
2290
|
state.user = 'alice';
|
|
1853
2291
|
state.passAttempt = 'alice:hunter2';
|
|
1854
|
-
const
|
|
1855
|
-
|
|
2292
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
2293
|
+
services.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
2294
|
+
emitWelcomeIfReady(state, makeCtxPassAuth(state, { services }));
|
|
1856
2295
|
expect(state.account).toBe('alice');
|
|
1857
2296
|
});
|
|
1858
2297
|
});
|
|
@@ -1863,8 +2302,9 @@ describe('emitWelcomeIfReady — PASS-based account auth (failures)', () => {
|
|
|
1863
2302
|
state.nick = 'alice';
|
|
1864
2303
|
state.user = 'alice';
|
|
1865
2304
|
state.passAttempt = 'alice:wrong';
|
|
1866
|
-
const
|
|
1867
|
-
|
|
2305
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
2306
|
+
services.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
2307
|
+
const effects = emitWelcomeIfReady(state, makeCtxPassAuth(state, { services }));
|
|
1868
2308
|
|
|
1869
2309
|
expect(state.account).toBeUndefined();
|
|
1870
2310
|
expect(state.userModes.registered).toBe(false);
|
|
@@ -1880,8 +2320,8 @@ describe('emitWelcomeIfReady — PASS-based account auth (failures)', () => {
|
|
|
1880
2320
|
state.nick = 'nobody';
|
|
1881
2321
|
state.user = 'nobody';
|
|
1882
2322
|
state.passAttempt = 'nobody:wrong';
|
|
1883
|
-
const
|
|
1884
|
-
const effects = emitWelcomeIfReady(state, makeCtxPassAuth(state, {
|
|
2323
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
2324
|
+
const effects = emitWelcomeIfReady(state, makeCtxPassAuth(state, { services }));
|
|
1885
2325
|
|
|
1886
2326
|
expect(state.account).toBeUndefined();
|
|
1887
2327
|
expect(state.registration).toBe('registered');
|
|
@@ -1897,17 +2337,15 @@ describe('emitWelcomeIfReady — PASS-based account auth (failures)', () => {
|
|
|
1897
2337
|
state.nick = 'alice';
|
|
1898
2338
|
state.user = 'alice';
|
|
1899
2339
|
state.passAttempt = 'alice:wrong';
|
|
1900
|
-
const
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
expect(accounts.calls[1]?.mech).toBe('PLAIN');
|
|
1910
|
-
expect(accounts.calls[1]?.payload.kind).toBe('PLAIN');
|
|
2340
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
2341
|
+
services.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
2342
|
+
const verifyNick = vi.spyOn(services, 'verifyNick');
|
|
2343
|
+
emitWelcomeIfReady(state, makeCtxPassAuth(state, { services }));
|
|
2344
|
+
|
|
2345
|
+
expect(verifyNick).toHaveBeenCalledTimes(2);
|
|
2346
|
+
expect(verifyNick.mock.calls[0]?.[0]).toBe('alice');
|
|
2347
|
+
expect(verifyNick.mock.calls[0]?.[1]).toBe('wrong');
|
|
2348
|
+
expect(verifyNick.mock.calls[1]?.[0]).not.toBe('alice');
|
|
1911
2349
|
});
|
|
1912
2350
|
|
|
1913
2351
|
it('treats a payload nick that does not match state.nick as a failed auth (no silent login)', () => {
|
|
@@ -1915,19 +2353,21 @@ describe('emitWelcomeIfReady — PASS-based account auth (failures)', () => {
|
|
|
1915
2353
|
state.nick = 'alice';
|
|
1916
2354
|
state.user = 'alice';
|
|
1917
2355
|
state.passAttempt = 'bob:hunter2';
|
|
1918
|
-
const
|
|
1919
|
-
|
|
2356
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
2357
|
+
services.registerNick('bob', 'hunter2', 'bob@example.com');
|
|
2358
|
+
const verifyNick = vi.spyOn(services, 'verifyNick');
|
|
2359
|
+
const effects = emitWelcomeIfReady(state, makeCtxPassAuth(state, { services }));
|
|
1920
2360
|
|
|
1921
2361
|
expect(state.account).toBeUndefined();
|
|
1922
2362
|
expect(state.registration).toBe('registered');
|
|
1923
2363
|
// The store is never consulted on a nick mismatch.
|
|
1924
|
-
expect(
|
|
2364
|
+
expect(verifyNick).not.toHaveBeenCalled();
|
|
1925
2365
|
expect(sentTexts(effects).some((t) => t.includes(' 900 '))).toBe(false);
|
|
1926
2366
|
});
|
|
1927
2367
|
});
|
|
1928
2368
|
|
|
1929
2369
|
describe('emitWelcomeIfReady — PASS-auth interaction with the server-password gate', () => {
|
|
1930
|
-
it('ignores the <nick>:<password> form when no
|
|
2370
|
+
it('ignores the <nick>:<password> form when no ServicesStore is bound (open gate)', () => {
|
|
1931
2371
|
const state = makeState();
|
|
1932
2372
|
state.nick = 'alice';
|
|
1933
2373
|
state.user = 'alice';
|
|
@@ -1939,7 +2379,28 @@ describe('emitWelcomeIfReady — PASS-auth interaction with the server-password
|
|
|
1939
2379
|
expect(sentTexts(effects).some((t) => t.includes(' 464 '))).toBe(false);
|
|
1940
2380
|
});
|
|
1941
2381
|
|
|
1942
|
-
it('
|
|
2382
|
+
it('a SASL-identified connection (state.account set) satisfies the gate with no credential-store read', () => {
|
|
2383
|
+
const state = makeState();
|
|
2384
|
+
state.nick = 'alice';
|
|
2385
|
+
state.user = 'alice';
|
|
2386
|
+
state.account = 'alice'; // SASL-identified before registration completion.
|
|
2387
|
+
state.passAttempt = 'alice:hunter2';
|
|
2388
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
2389
|
+
services.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
2390
|
+
const verifyNick = vi.spyOn(services, 'verifyNick');
|
|
2391
|
+
const effects = emitWelcomeIfReady(
|
|
2392
|
+
state,
|
|
2393
|
+
makeCtxPassAuth(state, { services, serverPassword: 's3cret' }),
|
|
2394
|
+
);
|
|
2395
|
+
|
|
2396
|
+
// The gate is satisfied by state.account; no PASS-auth verify fires.
|
|
2397
|
+
expect(verifyNick).not.toHaveBeenCalled();
|
|
2398
|
+
expect(state.registration).toBe('registered');
|
|
2399
|
+
expect(sentTexts(effects).some((t) => t.includes(' 464 '))).toBe(false);
|
|
2400
|
+
expect(sentTexts(effects).some((t) => t.includes(' 900 '))).toBe(false);
|
|
2401
|
+
});
|
|
2402
|
+
|
|
2403
|
+
it('collapses to the server-password gate (464) when no ServicesStore is bound and the colon form does not match', () => {
|
|
1943
2404
|
const state = makeState();
|
|
1944
2405
|
state.nick = 'alice';
|
|
1945
2406
|
state.user = 'alice';
|
|
@@ -1958,10 +2419,11 @@ describe('emitWelcomeIfReady — PASS-auth interaction with the server-password
|
|
|
1958
2419
|
state.nick = 'alice';
|
|
1959
2420
|
state.user = 'alice';
|
|
1960
2421
|
state.passAttempt = 'alice:wrong';
|
|
1961
|
-
const
|
|
2422
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
2423
|
+
services.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
1962
2424
|
const effects = emitWelcomeIfReady(
|
|
1963
2425
|
state,
|
|
1964
|
-
makeCtxPassAuth(state, {
|
|
2426
|
+
makeCtxPassAuth(state, { services, serverPassword: 's3cret' }),
|
|
1965
2427
|
);
|
|
1966
2428
|
|
|
1967
2429
|
expect(state.registration).not.toBe('registered');
|
|
@@ -1978,10 +2440,12 @@ describe('emitWelcomeIfReady — PASS-auth interaction with the server-password
|
|
|
1978
2440
|
state.user = 'alice';
|
|
1979
2441
|
state.account = 'alice'; // SASL-identified before registration completion
|
|
1980
2442
|
state.passAttempt = 'alice:hunter2';
|
|
1981
|
-
const
|
|
1982
|
-
|
|
2443
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
2444
|
+
services.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
2445
|
+
const verifyNick = vi.spyOn(services, 'verifyNick');
|
|
2446
|
+
const effects = emitWelcomeIfReady(state, makeCtxPassAuth(state, { services }));
|
|
1983
2447
|
|
|
1984
|
-
expect(
|
|
2448
|
+
expect(verifyNick).not.toHaveBeenCalled();
|
|
1985
2449
|
expect(state.registration).toBe('registered');
|
|
1986
2450
|
// No 900/903 are re-emitted by PASS-auth; SASL already emitted them.
|
|
1987
2451
|
expect(sentTexts(effects).some((t) => t.includes(' 900 '))).toBe(false);
|
|
@@ -1992,13 +2456,15 @@ describe('emitWelcomeIfReady — PASS-auth interaction with the server-password
|
|
|
1992
2456
|
state.nick = 'alice';
|
|
1993
2457
|
state.user = 'alice';
|
|
1994
2458
|
state.passAttempt = 's3cret';
|
|
1995
|
-
const
|
|
2459
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
2460
|
+
services.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
2461
|
+
const verifyNick = vi.spyOn(services, 'verifyNick');
|
|
1996
2462
|
const effects = emitWelcomeIfReady(
|
|
1997
2463
|
state,
|
|
1998
|
-
makeCtxPassAuth(state, {
|
|
2464
|
+
makeCtxPassAuth(state, { services, serverPassword: 's3cret' }),
|
|
1999
2465
|
);
|
|
2000
2466
|
|
|
2001
|
-
expect(
|
|
2467
|
+
expect(verifyNick).not.toHaveBeenCalled();
|
|
2002
2468
|
expect(state.account).toBeUndefined();
|
|
2003
2469
|
expect(state.registration).toBe('registered');
|
|
2004
2470
|
expect(sentTexts(effects).some((t) => t.includes(' 464 '))).toBe(false);
|
|
@@ -2011,8 +2477,9 @@ describe('passBasedAccountAuth — unit (PASS before NICK, parsing, edge cases)'
|
|
|
2011
2477
|
state.user = 'alice';
|
|
2012
2478
|
// state.nick intentionally undefined — PASS arrived before NICK.
|
|
2013
2479
|
state.passAttempt = 'alice:hunter2';
|
|
2014
|
-
const
|
|
2015
|
-
|
|
2480
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
2481
|
+
services.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
2482
|
+
const effects = passBasedAccountAuth(state, makeCtxPassAuth(state, { services }));
|
|
2016
2483
|
|
|
2017
2484
|
expect(state.account).toBe('alice');
|
|
2018
2485
|
expect(effects.some((e) => e.tag === 'Send')).toBe(true);
|
|
@@ -2021,26 +2488,31 @@ describe('passBasedAccountAuth — unit (PASS before NICK, parsing, edge cases)'
|
|
|
2021
2488
|
it('returns no effects when no PASS was sent', () => {
|
|
2022
2489
|
const state = makeState();
|
|
2023
2490
|
state.nick = 'alice';
|
|
2024
|
-
const
|
|
2025
|
-
|
|
2491
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
2492
|
+
services.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
2493
|
+
expect(passBasedAccountAuth(state, makeCtxPassAuth(state, { services }))).toEqual([]);
|
|
2026
2494
|
});
|
|
2027
2495
|
|
|
2028
2496
|
it('returns no effects for a bare PASS value (no colon)', () => {
|
|
2029
2497
|
const state = makeState();
|
|
2030
2498
|
state.nick = 'alice';
|
|
2031
2499
|
state.passAttempt = 's3cret';
|
|
2032
|
-
const
|
|
2033
|
-
|
|
2034
|
-
|
|
2500
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
2501
|
+
services.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
2502
|
+
const verifyNick = vi.spyOn(services, 'verifyNick');
|
|
2503
|
+
expect(passBasedAccountAuth(state, makeCtxPassAuth(state, { services }))).toEqual([]);
|
|
2504
|
+
expect(verifyNick).not.toHaveBeenCalled();
|
|
2035
2505
|
});
|
|
2036
2506
|
|
|
2037
2507
|
it('returns no effects when the nick portion is empty (`:password`)', () => {
|
|
2038
2508
|
const state = makeState();
|
|
2039
2509
|
state.nick = 'alice';
|
|
2040
2510
|
state.passAttempt = ':hunter2';
|
|
2041
|
-
const
|
|
2042
|
-
|
|
2043
|
-
|
|
2511
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
2512
|
+
services.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
2513
|
+
const verifyNick = vi.spyOn(services, 'verifyNick');
|
|
2514
|
+
expect(passBasedAccountAuth(state, makeCtxPassAuth(state, { services }))).toEqual([]);
|
|
2515
|
+
expect(verifyNick).not.toHaveBeenCalled();
|
|
2044
2516
|
});
|
|
2045
2517
|
|
|
2046
2518
|
it('splits only on the first colon so a password may contain colons', () => {
|
|
@@ -2048,16 +2520,13 @@ describe('passBasedAccountAuth — unit (PASS before NICK, parsing, edge cases)'
|
|
|
2048
2520
|
state.nick = 'alice';
|
|
2049
2521
|
state.user = 'alice';
|
|
2050
2522
|
state.passAttempt = 'alice:p:ass:word';
|
|
2051
|
-
const
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
username: 'alice',
|
|
2056
|
-
password: 'p:ass:word',
|
|
2057
|
-
});
|
|
2523
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
2524
|
+
services.registerNick('alice', 'p:ass:word', 'alice@example.com');
|
|
2525
|
+
passBasedAccountAuth(state, makeCtxPassAuth(state, { services }));
|
|
2526
|
+
expect(state.account).toBe('alice');
|
|
2058
2527
|
});
|
|
2059
2528
|
|
|
2060
|
-
it('returns no effects when no
|
|
2529
|
+
it('returns no effects when no ServicesStore is bound', () => {
|
|
2061
2530
|
const state = makeState();
|
|
2062
2531
|
state.nick = 'alice';
|
|
2063
2532
|
state.passAttempt = 'alice:hunter2';
|