serverless-ircd 0.7.0 → 0.9.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 +7 -3
- package/.gitmodules +1 -1
- package/CHANGELOG.md +518 -29
- package/README.md +221 -161
- package/apps/aws-stack/package.json +1 -1
- package/apps/aws-stack/src/aws-stack.ts +186 -18
- package/apps/aws-stack/tests/stack.test.ts +400 -56
- package/apps/cf-tcp-container/package.json +1 -1
- package/apps/cf-tcp-container/src/container-server.ts +21 -1
- package/apps/cf-tcp-container/tests/config-loader.test.ts +43 -0
- package/apps/cf-tcp-container/tests/container-server.test.ts +249 -1
- package/apps/cf-tcp-container/tests/persistence.test.ts +9 -0
- package/apps/cf-tcp-container/tests/tls-e2e.test.ts +24 -5
- package/apps/cf-worker/package.json +1 -1
- package/apps/cf-worker/src/worker.ts +4 -4
- package/apps/cf-worker/tests/fixtures/web-dist/webclient/index.html +18 -0
- package/apps/cf-worker/tests/smoke.test.ts +5 -5
- package/apps/cf-worker/wrangler.test.toml +6 -6
- package/apps/cf-worker/wrangler.toml +7 -5
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/config-loader.ts +8 -0
- package/apps/local-cli/src/main.ts +16 -0
- package/apps/local-cli/src/server.ts +95 -31
- package/apps/local-cli/tests/config-loader.test.ts +14 -0
- package/apps/local-cli/tests/config-resolution.test.ts +65 -0
- package/apps/local-cli/tests/motd-file-non-error.test.ts +29 -0
- package/apps/local-cli/tests/rehash.test.ts +147 -0
- package/apps/local-cli/tests/server-helpers.test.ts +63 -0
- package/apps/local-cli/tests/tcp.test.ts +89 -0
- package/apps/local-cli/tests/ws-subprotocol.test.ts +92 -0
- package/apps/web/landing/index.html +237 -16
- package/apps/web/package.json +3 -2
- package/apps/web/scripts/build.mjs +46 -16
- package/apps/web/src/build-env.ts +1 -1
- package/apps/web/src/config-schema.ts +6 -6
- package/apps/web/src/render-docs.ts +292 -0
- package/apps/web/tests/build-smoke.test.ts +43 -14
- package/apps/web/tests/config-schema.test.ts +1 -1
- package/apps/web/tests/landing-content.test.ts +103 -0
- package/apps/web/tests/render-docs.test.ts +198 -0
- package/docs/AWS-Adapter-Architecture.md +3 -2
- package/docs/AWS-Deployment.md +21 -8
- package/docs/Cloudflare-Deployment-Guide.md +22 -6
- package/docs/PlanExtensions.md +113 -3
- package/docs/Release-Process.md +23 -13
- package/docs/Services.md +578 -0
- package/docs/WebClientGuide.md +32 -31
- package/package.json +2 -2
- package/packages/aws-adapter/package.json +1 -1
- package/packages/aws-adapter/src/aws-runtime.ts +20 -1
- package/packages/aws-adapter/src/cdk-table-defs.ts +6 -0
- package/packages/aws-adapter/src/config-loader.ts +11 -0
- package/packages/aws-adapter/src/connection-counter.ts +89 -0
- package/packages/aws-adapter/src/dynamo-services-store.ts +649 -0
- package/packages/aws-adapter/src/handlers/connect.ts +55 -51
- package/packages/aws-adapter/src/handlers/default.ts +36 -4
- package/packages/aws-adapter/src/handlers/index.ts +15 -0
- package/packages/aws-adapter/src/handlers/nlb-stream.ts +25 -2
- package/packages/aws-adapter/src/handlers/sweeper.ts +5 -1
- package/packages/aws-adapter/src/index.ts +4 -0
- package/packages/aws-adapter/src/stats.ts +6 -1
- package/packages/aws-adapter/src/tables.ts +34 -4
- package/packages/aws-adapter/tests/aws-harness.ts +3 -0
- package/packages/aws-adapter/tests/aws-runtime.test.ts +23 -1
- package/packages/aws-adapter/tests/config-loader.test.ts +8 -0
- package/packages/aws-adapter/tests/connect.test.ts +158 -32
- package/packages/aws-adapter/tests/connection-counter.test.ts +144 -0
- package/packages/aws-adapter/tests/dynamo-services-store-dynamo.test.ts +183 -0
- package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +568 -0
- package/packages/aws-adapter/tests/global-setup.ts +28 -1
- package/packages/aws-adapter/tests/gone-exception.test.ts +21 -2
- package/packages/aws-adapter/tests/handlers.test.ts +105 -3
- package/packages/aws-adapter/tests/nlb-stream.test.ts +29 -1
- package/packages/aws-adapter/tests/sweeper.test.ts +20 -0
- package/packages/aws-adapter/tests/tables.test.ts +6 -1
- package/packages/cf-adapter/package.json +1 -1
- package/packages/cf-adapter/src/config-loader.ts +11 -0
- package/packages/cf-adapter/src/connection-do.ts +130 -8
- package/packages/cf-adapter/src/d1-services-store.ts +703 -0
- package/packages/cf-adapter/src/env.ts +8 -0
- package/packages/cf-adapter/src/index.ts +5 -0
- package/packages/cf-adapter/tests/config-loader.test.ts +19 -0
- package/packages/cf-adapter/tests/connection-do-nickserv-d1.test.ts +128 -0
- package/packages/cf-adapter/tests/connection-do-pure.test.ts +130 -0
- package/packages/cf-adapter/tests/connection-do.test.ts +150 -2
- package/packages/cf-adapter/tests/d1-services-store.test.ts +582 -0
- package/packages/cf-adapter/tests/serialize.test.ts +1 -0
- package/packages/in-memory-runtime/package.json +1 -1
- package/packages/irc-core/package.json +1 -1
- package/packages/irc-core/scripts/generate-build-info.mjs +26 -5
- package/packages/irc-core/src/commands/account-auth.ts +200 -0
- package/packages/irc-core/src/commands/chanserv.ts +1166 -0
- package/packages/irc-core/src/commands/hostserv.ts +522 -0
- package/packages/irc-core/src/commands/index.ts +13 -0
- package/packages/irc-core/src/commands/join.ts +171 -9
- package/packages/irc-core/src/commands/markread.ts +202 -0
- package/packages/irc-core/src/commands/memoserv.ts +319 -0
- package/packages/irc-core/src/commands/mode.ts +96 -4
- package/packages/irc-core/src/commands/nickserv.ts +402 -0
- package/packages/irc-core/src/commands/oper.ts +18 -1
- package/packages/irc-core/src/commands/operserv.ts +346 -0
- package/packages/irc-core/src/commands/pre-away.ts +3 -1
- package/packages/irc-core/src/commands/privmsg.ts +42 -0
- package/packages/irc-core/src/commands/read-marker.ts +8 -8
- package/packages/irc-core/src/commands/registration.ts +78 -12
- package/packages/irc-core/src/commands/sasl.ts +18 -49
- package/packages/irc-core/src/commands/service-aliases.ts +52 -0
- package/packages/irc-core/src/commands/tagmsg.ts +41 -6
- package/packages/irc-core/src/commands/topic.ts +60 -10
- package/packages/irc-core/src/config.ts +36 -5
- package/packages/irc-core/src/effects.ts +56 -1
- package/packages/irc-core/src/ports.ts +1653 -84
- package/packages/irc-core/src/protocol/numerics.ts +8 -0
- package/packages/irc-core/src/state/channel.ts +38 -1
- package/packages/irc-core/src/state/connection.ts +25 -1
- package/packages/irc-core/src/types.ts +48 -12
- package/packages/irc-core/tests/commands/chanserv.test.ts +2335 -0
- package/packages/irc-core/tests/commands/chathistory.test.ts +6 -0
- package/packages/irc-core/tests/commands/hostserv.test.ts +1006 -0
- package/packages/irc-core/tests/commands/join.test.ts +572 -1
- package/packages/irc-core/tests/commands/markread.test.ts +361 -0
- package/packages/irc-core/tests/commands/memoserv.test.ts +654 -0
- package/packages/irc-core/tests/commands/mode.test.ts +381 -2
- package/packages/irc-core/tests/commands/nickserv.test.ts +990 -0
- package/packages/irc-core/tests/commands/oper.test.ts +13 -0
- package/packages/irc-core/tests/commands/operserv.test.ts +656 -0
- package/packages/irc-core/tests/commands/privmsg.test.ts +147 -0
- package/packages/irc-core/tests/commands/read-marker.test.ts +28 -28
- package/packages/irc-core/tests/commands/registration.test.ts +1015 -20
- package/packages/irc-core/tests/commands/sasl.test.ts +229 -12
- package/packages/irc-core/tests/commands/server-info.test.ts +9 -5
- package/packages/irc-core/tests/commands/service-aliases.test.ts +52 -0
- package/packages/irc-core/tests/commands/tagmsg.test.ts +73 -33
- package/packages/irc-core/tests/commands/topic.test.ts +94 -2
- package/packages/irc-core/tests/commands/unified-account.test.ts +416 -0
- package/packages/irc-core/tests/config.test.ts +49 -5
- package/packages/irc-core/tests/effects.test.ts +19 -0
- package/packages/irc-core/tests/message-store.test.ts +63 -0
- package/packages/irc-core/tests/persistent-services-store.test.ts +582 -0
- package/packages/irc-core/tests/services-store.test.ts +1289 -0
- package/packages/irc-core/tests/state/channel.test.ts +3 -0
- package/packages/irc-server/package.json +1 -1
- package/packages/irc-server/src/actor.ts +121 -16
- package/packages/irc-server/src/dispatch.ts +94 -7
- package/packages/irc-server/src/routing.ts +19 -0
- package/packages/irc-server/tests/actor.test.ts +988 -15
- package/packages/irc-server/tests/dispatch.test.ts +270 -2
- package/packages/irc-server/tests/routing.test.ts +6 -0
- package/packages/irc-test-support/package.json +1 -1
- package/packages/irc-test-support/src/in-memory-harness.ts +37 -8
- package/packages/irc-test-support/src/index.ts +1 -0
- package/packages/irc-test-support/src/scenarios.ts +21 -6
- package/packages/irc-test-support/tests/in-memory-harness.test.ts +51 -0
- package/packages/irc-test-support/vitest.config.ts +6 -1
- package/tools/ci-hardening/package.json +1 -1
- package/tools/load-test/package.json +1 -1
- package/tools/load-test/src/client.ts +13 -13
- package/tools/load-test/tests/client.test.ts +258 -2
- package/tools/load-test/tests/config.test.ts +39 -0
- package/tools/load-test/tests/harness.test.ts +21 -0
- package/tools/load-test/tests/metrics.test.ts +7 -0
- package/tools/tcp-ws-forwarder/package.json +1 -1
- package/tools/tcp-ws-forwarder/tests/close-error.test.ts +40 -0
- package/tools/tcp-ws-forwarder/tests/defensive-branches.test.ts +78 -0
- package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +53 -2
- package/tools/tcp-ws-forwarder/tests/logger.test.ts +31 -1
- package/apps/cf-worker/tests/fixtures/web-dist/app/index.html +0 -18
- package/packages/irc-core/tests/read-marker-store.test.ts +0 -108
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import pkg from '../../package.json' with { type: 'json' };
|
|
2
3
|
import { cloakHost } from '../../src/cloak';
|
|
4
|
+
import {
|
|
5
|
+
applyAccountSuccess,
|
|
6
|
+
parsePassAccountAttempt,
|
|
7
|
+
passBasedAccountAuth,
|
|
8
|
+
} from '../../src/commands/account-auth';
|
|
3
9
|
import { generateIsupport } from '../../src/commands/isupport';
|
|
4
10
|
import {
|
|
5
11
|
buildWelcomeLines,
|
|
@@ -9,23 +15,41 @@ import {
|
|
|
9
15
|
passReducer,
|
|
10
16
|
userReducer,
|
|
11
17
|
} from '../../src/commands/registration';
|
|
12
|
-
import { DEFAULT_CREATED_TEXT
|
|
18
|
+
import { DEFAULT_CREATED_TEXT } from '../../src/config';
|
|
13
19
|
import { Effect } from '../../src/effects';
|
|
14
20
|
import type { Effect as EffectType, RawLine } from '../../src/effects';
|
|
15
21
|
import {
|
|
16
22
|
EmptyMotdProvider,
|
|
17
23
|
FakeClock,
|
|
24
|
+
InMemoryAwayStore,
|
|
18
25
|
InMemoryNickHistoryStore,
|
|
26
|
+
InMemoryServicesStore,
|
|
19
27
|
SequentialIdFactory,
|
|
20
28
|
StaticMotdProvider,
|
|
21
29
|
} from '../../src/ports';
|
|
22
|
-
import type {
|
|
30
|
+
import type {
|
|
31
|
+
AccountStore,
|
|
32
|
+
AwayStore,
|
|
33
|
+
MotdProvider,
|
|
34
|
+
SaslPayload,
|
|
35
|
+
SaslResult,
|
|
36
|
+
ServicesStore,
|
|
37
|
+
} from '../../src/ports';
|
|
23
38
|
import { type ConnectionState, createConnection } from '../../src/state/connection';
|
|
24
39
|
import { type Ctx, type ServerConfig, buildCtx } from '../../src/types';
|
|
25
40
|
|
|
41
|
+
// Explicit version pinned in the fixture so the assertions below verify that
|
|
42
|
+
// the configured version is what reaches the wire — not a specific default.
|
|
43
|
+
const FIXTURE_VERSION = '9.9.9';
|
|
44
|
+
// The schema default must track the irc-core package version so a deployment
|
|
45
|
+
// that omits `serverVersion` advertises the version it was shipped with, not
|
|
46
|
+
// a stale hardcoded placeholder.
|
|
47
|
+
const PACKAGE_VERSION = pkg.version as string;
|
|
48
|
+
|
|
26
49
|
const serverConfig: ServerConfig = {
|
|
27
50
|
serverName: 'irc.example.com',
|
|
28
51
|
networkName: 'ExampleNet',
|
|
52
|
+
serverVersion: FIXTURE_VERSION,
|
|
29
53
|
maxChannelsPerUser: 30,
|
|
30
54
|
maxTargetsPerCommand: 10,
|
|
31
55
|
maxListEntries: 50,
|
|
@@ -104,9 +128,9 @@ function expectedWelcome(nick: string, user: string, host: string | undefined):
|
|
|
104
128
|
);
|
|
105
129
|
return [
|
|
106
130
|
L(`${prefix} 001 ${nick} :Welcome to the ExampleNet IRC Network, ${hostmask}`),
|
|
107
|
-
L(`${prefix} 002 ${nick} :Your host is ${sv}, running version ${
|
|
131
|
+
L(`${prefix} 002 ${nick} :Your host is ${sv}, running version ${FIXTURE_VERSION}`),
|
|
108
132
|
L(`${prefix} 003 ${nick} :This server was created in ${DEFAULT_CREATED_TEXT}`),
|
|
109
|
-
L(`${prefix} 004 ${nick} ${sv} ${
|
|
133
|
+
L(`${prefix} 004 ${nick} ${sv} ${FIXTURE_VERSION} iosw biklmnstp`),
|
|
110
134
|
...isupport,
|
|
111
135
|
L(`${prefix} 422 ${nick} :MOTD File is missing`),
|
|
112
136
|
];
|
|
@@ -307,6 +331,21 @@ describe('nickReducer', () => {
|
|
|
307
331
|
expect(out.state.registration).toBe('registered');
|
|
308
332
|
});
|
|
309
333
|
|
|
334
|
+
it('broadcasts the NICK change to every joined channel (except self)', () => {
|
|
335
|
+
const state = registeredState();
|
|
336
|
+
state.joinedChannels.add('#foo');
|
|
337
|
+
state.joinedChannels.add('#bar');
|
|
338
|
+
const ctx = makeCtx(state);
|
|
339
|
+
const out = nickReducer(state, { command: 'NICK', params: ['bob'], tags: {} }, ctx);
|
|
340
|
+
const line = L(':alice!alice@example.com NICK bob');
|
|
341
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
342
|
+
Effect.changeNick('c1', 'alice', 'bob'),
|
|
343
|
+
Effect.send('c1', [line]),
|
|
344
|
+
Effect.broadcast('#foo', [line], 'c1'),
|
|
345
|
+
Effect.broadcast('#bar', [line], 'c1'),
|
|
346
|
+
]);
|
|
347
|
+
});
|
|
348
|
+
|
|
310
349
|
it('echoes back with partial hostmask when host is absent on nick change', () => {
|
|
311
350
|
const state = makeState();
|
|
312
351
|
state.nick = 'alice';
|
|
@@ -530,7 +569,7 @@ describe('passReducer', () => {
|
|
|
530
569
|
expect(out.state.registration).toBe('pre-registration');
|
|
531
570
|
});
|
|
532
571
|
|
|
533
|
-
it('
|
|
572
|
+
it('stashes the password attempt; the gate is enforced by passwordGateFailure at emitWelcomeIfReady', () => {
|
|
534
573
|
const state = makeState();
|
|
535
574
|
const ctx = makeCtx(state);
|
|
536
575
|
const out = passReducer(state, { command: 'PASS', params: ['wrong-password'], tags: {} }, ctx);
|
|
@@ -538,12 +577,15 @@ describe('passReducer', () => {
|
|
|
538
577
|
expect(out.effects).toEqual([]);
|
|
539
578
|
});
|
|
540
579
|
|
|
541
|
-
it('
|
|
580
|
+
it('emits 462 when PASS is sent twice before registration completes', () => {
|
|
542
581
|
const state = makeState();
|
|
543
582
|
state.passAttempt = 'first';
|
|
544
583
|
const ctx = makeCtx(state);
|
|
545
584
|
const out = passReducer(state, { command: 'PASS', params: ['second'], tags: {} }, ctx);
|
|
546
|
-
expect(out.
|
|
585
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
586
|
+
Effect.send('c1', [L(':irc.example.com 462 * :You may not reregister')]),
|
|
587
|
+
]);
|
|
588
|
+
expect(out.state.passAttempt).toBe('first');
|
|
547
589
|
});
|
|
548
590
|
|
|
549
591
|
it('emits 461 when no password is supplied', () => {
|
|
@@ -556,14 +598,119 @@ describe('passReducer', () => {
|
|
|
556
598
|
expect(out.state.passAttempt).toBeUndefined();
|
|
557
599
|
});
|
|
558
600
|
|
|
559
|
-
it('emits
|
|
601
|
+
it('emits 461 when no password is supplied even after registration', () => {
|
|
560
602
|
const state = registeredState();
|
|
561
603
|
const ctx = makeCtx(state);
|
|
562
|
-
const out = passReducer(state, { command: 'PASS', params: [
|
|
604
|
+
const out = passReducer(state, { command: 'PASS', params: [], tags: {} }, ctx);
|
|
563
605
|
expect(out.effects).toEqual<EffectType[]>([
|
|
564
|
-
Effect.send('c1', [L(':irc.example.com
|
|
606
|
+
Effect.send('c1', [L(':irc.example.com 461 alice PASS :Not enough parameters')]),
|
|
565
607
|
]);
|
|
566
|
-
|
|
608
|
+
});
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
// ============================================================================
|
|
612
|
+
// passReducer — post-registration PASS (late account login)
|
|
613
|
+
// ============================================================================
|
|
614
|
+
|
|
615
|
+
describe('passReducer — post-registration PASS <nick>:<password>', () => {
|
|
616
|
+
it('logs in when PASS <nick>:<password> matches an account after registration', () => {
|
|
617
|
+
const state = registeredState();
|
|
618
|
+
const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
|
|
619
|
+
const ctx = makeCtxWithAccounts(state, accounts);
|
|
620
|
+
const out = passReducer(state, { command: 'PASS', params: ['alice:hunter2'], tags: {} }, ctx);
|
|
621
|
+
|
|
622
|
+
expect(out.state.account).toBe('alice');
|
|
623
|
+
expect(out.state.userModes.registered).toBe(true);
|
|
624
|
+
const texts = sentTexts(out.effects);
|
|
625
|
+
expect(texts).toContain(
|
|
626
|
+
`:${SV} 900 alice alice!alice@example.com alice :You are now logged in as alice`,
|
|
627
|
+
);
|
|
628
|
+
expect(texts).toContain(`:${SV} 903 alice :SASL authentication successful`);
|
|
629
|
+
expect(texts.some((t) => t.includes(' 462 '))).toBe(false);
|
|
630
|
+
expect(out.effects.some((e) => e.tag === 'Disconnect')).toBe(false);
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
it('does not log in or disconnect when the password is wrong', () => {
|
|
634
|
+
const state = registeredState();
|
|
635
|
+
const accounts = new FakeAccountStore({ ok: false, reason: 'invalid credentials' });
|
|
636
|
+
const ctx = makeCtxWithAccounts(state, accounts);
|
|
637
|
+
const out = passReducer(state, { command: 'PASS', params: ['alice:wrong'], tags: {} }, ctx);
|
|
638
|
+
|
|
639
|
+
expect(out.state.account).toBeUndefined();
|
|
640
|
+
expect(out.state.userModes.registered).toBe(false);
|
|
641
|
+
expect(out.effects).toEqual([]);
|
|
642
|
+
expect(out.effects.some((e) => e.tag === 'Disconnect')).toBe(false);
|
|
643
|
+
});
|
|
644
|
+
|
|
645
|
+
it('is indistinguishable from an unknown nick (no auth numerics, no disconnect)', () => {
|
|
646
|
+
const state = registeredState();
|
|
647
|
+
state.nick = 'nobody';
|
|
648
|
+
const accounts = new FakeAccountStore({ ok: false, reason: 'invalid credentials' });
|
|
649
|
+
const ctx = makeCtxWithAccounts(state, accounts);
|
|
650
|
+
const out = passReducer(state, { command: 'PASS', params: ['nobody:wrong'], tags: {} }, ctx);
|
|
651
|
+
|
|
652
|
+
expect(out.state.account).toBeUndefined();
|
|
653
|
+
expect(out.effects).toEqual([]);
|
|
654
|
+
});
|
|
655
|
+
|
|
656
|
+
it('equalises timing with an extra dummy verify on failure', () => {
|
|
657
|
+
const state = registeredState();
|
|
658
|
+
const accounts = new FakeAccountStore({ ok: false, reason: 'invalid credentials' });
|
|
659
|
+
const ctx = makeCtxWithAccounts(state, accounts);
|
|
660
|
+
passReducer(state, { command: 'PASS', params: ['alice:wrong'], tags: {} }, ctx);
|
|
661
|
+
|
|
662
|
+
expect(accounts.calls).toHaveLength(2);
|
|
663
|
+
expect(accounts.calls[0]?.payload).toEqual({
|
|
664
|
+
kind: 'PLAIN',
|
|
665
|
+
username: 'alice',
|
|
666
|
+
password: 'wrong',
|
|
667
|
+
});
|
|
668
|
+
expect(accounts.calls[1]?.payload.kind).toBe('PLAIN');
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
it('does not log in when the payload nick does not match state.nick', () => {
|
|
672
|
+
const state = registeredState();
|
|
673
|
+
const accounts = new FakeAccountStore({ ok: true, account: 'bob' });
|
|
674
|
+
const ctx = makeCtxWithAccounts(state, accounts);
|
|
675
|
+
const out = passReducer(state, { command: 'PASS', params: ['bob:hunter2'], tags: {} }, ctx);
|
|
676
|
+
|
|
677
|
+
expect(out.state.account).toBeUndefined();
|
|
678
|
+
expect(accounts.calls).toHaveLength(0);
|
|
679
|
+
expect(out.effects).toEqual([]);
|
|
680
|
+
});
|
|
681
|
+
|
|
682
|
+
it('ignores a bare PASS value (no colon) after registration — no 462, no disconnect', () => {
|
|
683
|
+
const state = registeredState();
|
|
684
|
+
const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
|
|
685
|
+
const ctx = makeCtxWithAccounts(state, accounts);
|
|
686
|
+
const out = passReducer(state, { command: 'PASS', params: ['serverpw'], tags: {} }, ctx);
|
|
687
|
+
|
|
688
|
+
expect(out.state.account).toBeUndefined();
|
|
689
|
+
expect(out.effects).toEqual([]);
|
|
690
|
+
expect(accounts.calls).toHaveLength(0);
|
|
691
|
+
expect(out.effects.some((e) => e.tag === 'Disconnect')).toBe(false);
|
|
692
|
+
});
|
|
693
|
+
|
|
694
|
+
it('ignores a late PASS when the connection is already identified', () => {
|
|
695
|
+
const state = registeredState();
|
|
696
|
+
state.account = 'alice';
|
|
697
|
+
const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
|
|
698
|
+
const ctx = makeCtxWithAccounts(state, accounts);
|
|
699
|
+
const out = passReducer(state, { command: 'PASS', params: ['alice:hunter2'], tags: {} }, ctx);
|
|
700
|
+
|
|
701
|
+
expect(accounts.calls).toHaveLength(0);
|
|
702
|
+
expect(out.effects).toEqual([]);
|
|
703
|
+
expect(out.effects.some((e) => e.tag === 'Disconnect')).toBe(false);
|
|
704
|
+
});
|
|
705
|
+
|
|
706
|
+
it('ignores a late PASS <nick>:<password> when no AccountStore is bound', () => {
|
|
707
|
+
const state = registeredState();
|
|
708
|
+
const ctx = makeCtx(state);
|
|
709
|
+
const out = passReducer(state, { command: 'PASS', params: ['alice:hunter2'], tags: {} }, ctx);
|
|
710
|
+
|
|
711
|
+
expect(out.state.account).toBeUndefined();
|
|
712
|
+
expect(out.effects).toEqual([]);
|
|
713
|
+
expect(out.effects.some((e) => e.tag === 'Disconnect')).toBe(false);
|
|
567
714
|
});
|
|
568
715
|
});
|
|
569
716
|
|
|
@@ -648,14 +795,18 @@ describe('buildWelcomeLines', () => {
|
|
|
648
795
|
// ============================================================================
|
|
649
796
|
|
|
650
797
|
describe('buildWelcomeLines — config-driven version & created text', () => {
|
|
651
|
-
/** Builds a ctx whose serverConfig carries the supplied version/created.
|
|
798
|
+
/** Builds a ctx whose serverConfig carries the supplied version/created.
|
|
799
|
+
* When `serverVersion` is undefined the inherited fixture version is dropped
|
|
800
|
+
* so the schema/reducer default applies — this lets callers exercise the
|
|
801
|
+
* fallback path explicitly. */
|
|
652
802
|
function makeCtxWithMeta(
|
|
653
803
|
state: ConnectionState,
|
|
654
804
|
serverVersion: string | undefined,
|
|
655
805
|
createdAt: string | number | undefined,
|
|
656
806
|
): Ctx {
|
|
807
|
+
const { serverVersion: _drop, ...base } = serverConfig;
|
|
657
808
|
const cfg: ServerConfig = {
|
|
658
|
-
...
|
|
809
|
+
...base,
|
|
659
810
|
...(serverVersion !== undefined ? { serverVersion } : {}),
|
|
660
811
|
...(createdAt !== undefined ? { createdAt } : {}),
|
|
661
812
|
};
|
|
@@ -702,14 +853,13 @@ describe('buildWelcomeLines — config-driven version & created text', () => {
|
|
|
702
853
|
expect(line003?.text).toContain('2023');
|
|
703
854
|
});
|
|
704
855
|
|
|
705
|
-
it('falls back to the
|
|
856
|
+
it('falls back to the irc-core package version when serverVersion is omitted', () => {
|
|
706
857
|
const ctx = makeCtxWithMeta(registeredConn(), undefined, undefined);
|
|
707
858
|
const lines = buildWelcomeLines(ctx.connection, ctx);
|
|
708
859
|
const line002 = lines.find((l) => l.text.split(' ')[1] === '002');
|
|
709
|
-
// The
|
|
710
|
-
//
|
|
711
|
-
expect(line002?.text).
|
|
712
|
-
expect(line002?.text).toContain(DEFAULT_SERVER_VERSION);
|
|
860
|
+
// The default must track the irc-core package version (PACKAGE_VERSION),
|
|
861
|
+
// not a stale hardcoded placeholder.
|
|
862
|
+
expect(line002?.text).toContain(PACKAGE_VERSION);
|
|
713
863
|
});
|
|
714
864
|
});
|
|
715
865
|
|
|
@@ -1095,9 +1245,9 @@ function expectedWelcomeWithHost(nick: string, hostmask: string): RawLine[] {
|
|
|
1095
1245
|
);
|
|
1096
1246
|
return [
|
|
1097
1247
|
L(`${prefix} 001 ${nick} :Welcome to the ExampleNet IRC Network, ${hostmask}`),
|
|
1098
|
-
L(`${prefix} 002 ${nick} :Your host is ${sv}, running version ${
|
|
1248
|
+
L(`${prefix} 002 ${nick} :Your host is ${sv}, running version ${FIXTURE_VERSION}`),
|
|
1099
1249
|
L(`${prefix} 003 ${nick} :This server was created in ${DEFAULT_CREATED_TEXT}`),
|
|
1100
|
-
L(`${prefix} 004 ${nick} ${sv} ${
|
|
1250
|
+
L(`${prefix} 004 ${nick} ${sv} ${FIXTURE_VERSION} iosw biklmnstp`),
|
|
1101
1251
|
...isupport,
|
|
1102
1252
|
L(`${prefix} 422 ${nick} :MOTD File is missing`),
|
|
1103
1253
|
];
|
|
@@ -1166,3 +1316,848 @@ describe('emitWelcomeIfReady — user mode `S` (TLS connected)', () => {
|
|
|
1166
1316
|
expect(out.state.userModes.tls).toBe(true);
|
|
1167
1317
|
});
|
|
1168
1318
|
});
|
|
1319
|
+
|
|
1320
|
+
// ============================================================================
|
|
1321
|
+
// NickServ nick-enforcement wiring (fires on registration + nick change)
|
|
1322
|
+
// ============================================================================
|
|
1323
|
+
|
|
1324
|
+
/** Like {@link makeCtx} but threads a bound services store. */
|
|
1325
|
+
function makeCtxWithServices(
|
|
1326
|
+
state: ConnectionState,
|
|
1327
|
+
services: ServicesStore,
|
|
1328
|
+
clock = new FakeClock(1_000),
|
|
1329
|
+
): Ctx {
|
|
1330
|
+
return buildCtx({
|
|
1331
|
+
serverConfig,
|
|
1332
|
+
clock,
|
|
1333
|
+
ids: new SequentialIdFactory(),
|
|
1334
|
+
motd: EmptyMotdProvider,
|
|
1335
|
+
connection: state,
|
|
1336
|
+
services,
|
|
1337
|
+
});
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
describe('emitWelcomeIfReady — NickServ nick enforcement', () => {
|
|
1341
|
+
it('fires EnforceNick(kill) when an unidentified client registers a kill-enforced nick', () => {
|
|
1342
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
1343
|
+
services.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
1344
|
+
services.setNickEnforce('alice', 'kill');
|
|
1345
|
+
const state = makeState();
|
|
1346
|
+
state.nick = 'alice';
|
|
1347
|
+
state.user = 'alice';
|
|
1348
|
+
const ctx = makeCtxWithServices(state, services);
|
|
1349
|
+
|
|
1350
|
+
const effects = emitWelcomeIfReady(state, ctx);
|
|
1351
|
+
|
|
1352
|
+
// First effect is still the welcome Send.
|
|
1353
|
+
expect(effects[0]?.tag).toBe('Send');
|
|
1354
|
+
const enforce = effects.filter((e) => e.tag === 'EnforceNick');
|
|
1355
|
+
expect(enforce).toEqual<EffectType[]>([Effect.enforceNick('c1', 'alice', 'kill', 0)]);
|
|
1356
|
+
});
|
|
1357
|
+
|
|
1358
|
+
it('fires EnforceNick(ghost) with the configured grace for a ghost-enforced nick', () => {
|
|
1359
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
1360
|
+
services.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
1361
|
+
services.setNickEnforce('alice', 'ghost');
|
|
1362
|
+
const state = makeState();
|
|
1363
|
+
state.nick = 'alice';
|
|
1364
|
+
state.user = 'alice';
|
|
1365
|
+
const ctx: Ctx = buildCtx({
|
|
1366
|
+
serverConfig: { ...serverConfig, nickEnforceGraceMs: 12_000 },
|
|
1367
|
+
clock: new FakeClock(1_000),
|
|
1368
|
+
ids: new SequentialIdFactory(),
|
|
1369
|
+
motd: EmptyMotdProvider,
|
|
1370
|
+
connection: state,
|
|
1371
|
+
services,
|
|
1372
|
+
});
|
|
1373
|
+
|
|
1374
|
+
const effects = emitWelcomeIfReady(state, ctx);
|
|
1375
|
+
|
|
1376
|
+
expect(effects.filter((e) => e.tag === 'EnforceNick')).toEqual<EffectType[]>([
|
|
1377
|
+
Effect.enforceNick('c1', 'alice', 'ghost', 12_000),
|
|
1378
|
+
]);
|
|
1379
|
+
});
|
|
1380
|
+
|
|
1381
|
+
it('does NOT fire enforcement when the connection is identified as the owner', () => {
|
|
1382
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
1383
|
+
services.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
1384
|
+
services.setNickEnforce('alice', 'kill');
|
|
1385
|
+
const state = makeState();
|
|
1386
|
+
state.nick = 'alice';
|
|
1387
|
+
state.user = 'alice';
|
|
1388
|
+
state.account = 'alice'; // SASL-identified before CAP END
|
|
1389
|
+
const ctx = makeCtxWithServices(state, services);
|
|
1390
|
+
|
|
1391
|
+
const effects = emitWelcomeIfReady(state, ctx);
|
|
1392
|
+
|
|
1393
|
+
expect(effects.filter((e) => e.tag === 'EnforceNick')).toEqual<EffectType[]>([]);
|
|
1394
|
+
// Welcome is the only effect.
|
|
1395
|
+
expect(effects).toHaveLength(1);
|
|
1396
|
+
expect(effects[0]?.tag).toBe('Send');
|
|
1397
|
+
});
|
|
1398
|
+
|
|
1399
|
+
it('emits only a warning NOTICE (no EnforceNick) for a none-enforced nick', () => {
|
|
1400
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
1401
|
+
services.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
1402
|
+
services.setNickEnforce('alice', 'none');
|
|
1403
|
+
const state = makeState();
|
|
1404
|
+
state.nick = 'alice';
|
|
1405
|
+
state.user = 'alice';
|
|
1406
|
+
const ctx = makeCtxWithServices(state, services);
|
|
1407
|
+
|
|
1408
|
+
const effects = emitWelcomeIfReady(state, ctx);
|
|
1409
|
+
|
|
1410
|
+
expect(effects.filter((e) => e.tag === 'EnforceNick')).toEqual<EffectType[]>([]);
|
|
1411
|
+
// Welcome Send + warning NOTICE Send.
|
|
1412
|
+
expect(effects.filter((e) => e.tag === 'Send')).toHaveLength(2);
|
|
1413
|
+
});
|
|
1414
|
+
|
|
1415
|
+
it('does not fire enforcement for an unregistered nick', () => {
|
|
1416
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
1417
|
+
const state = makeState();
|
|
1418
|
+
state.nick = 'ghostie';
|
|
1419
|
+
state.user = 'ghostie';
|
|
1420
|
+
const ctx = makeCtxWithServices(state, services);
|
|
1421
|
+
|
|
1422
|
+
const effects = emitWelcomeIfReady(state, ctx);
|
|
1423
|
+
|
|
1424
|
+
expect(effects.filter((e) => e.tag === 'EnforceNick')).toEqual<EffectType[]>([]);
|
|
1425
|
+
expect(effects).toHaveLength(1);
|
|
1426
|
+
});
|
|
1427
|
+
|
|
1428
|
+
it('does not fire enforcement when no services store is bound', () => {
|
|
1429
|
+
const state = makeState();
|
|
1430
|
+
state.nick = 'alice';
|
|
1431
|
+
state.user = 'alice';
|
|
1432
|
+
const ctx = makeCtx(state);
|
|
1433
|
+
|
|
1434
|
+
const effects = emitWelcomeIfReady(state, ctx);
|
|
1435
|
+
|
|
1436
|
+
expect(effects.filter((e) => e.tag === 'EnforceNick')).toEqual<EffectType[]>([]);
|
|
1437
|
+
expect(effects).toEqual([Effect.send('c1', expectedWelcome('alice', 'alice', undefined))]);
|
|
1438
|
+
});
|
|
1439
|
+
});
|
|
1440
|
+
|
|
1441
|
+
describe('nickReducer — NickServ nick enforcement on post-registration nick change', () => {
|
|
1442
|
+
it('fires EnforceNick(ghost) when changing onto a ghost-enforced registered nick', () => {
|
|
1443
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
1444
|
+
services.registerNick('carol', 'hunter2', 'carol@example.com');
|
|
1445
|
+
services.setNickEnforce('carol', 'ghost');
|
|
1446
|
+
const state = registeredState(); // already registered as alice
|
|
1447
|
+
const ctx = makeCtxWithServices(state, services);
|
|
1448
|
+
|
|
1449
|
+
const out = nickReducer(state, { command: 'NICK', params: ['carol'], tags: {} }, ctx);
|
|
1450
|
+
|
|
1451
|
+
expect(out.state.nick).toBe('carol');
|
|
1452
|
+
// changeNick + NICK echo + warning NOTICE + EnforceNick(ghost).
|
|
1453
|
+
const enforce = out.effects.filter((e) => e.tag === 'EnforceNick');
|
|
1454
|
+
expect(enforce).toEqual<EffectType[]>([Effect.enforceNick('c1', 'carol', 'ghost', 30_000)]);
|
|
1455
|
+
});
|
|
1456
|
+
|
|
1457
|
+
it('fires EnforceNick(kill) when changing onto a kill-enforced registered nick', () => {
|
|
1458
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
1459
|
+
services.registerNick('carol', 'hunter2', 'carol@example.com');
|
|
1460
|
+
services.setNickEnforce('carol', 'kill');
|
|
1461
|
+
const state = registeredState();
|
|
1462
|
+
const ctx = makeCtxWithServices(state, services);
|
|
1463
|
+
|
|
1464
|
+
const out = nickReducer(state, { command: 'NICK', params: ['carol'], tags: {} }, ctx);
|
|
1465
|
+
|
|
1466
|
+
expect(out.effects.filter((e) => e.tag === 'EnforceNick')).toEqual<EffectType[]>([
|
|
1467
|
+
Effect.enforceNick('c1', 'carol', 'kill', 0),
|
|
1468
|
+
]);
|
|
1469
|
+
});
|
|
1470
|
+
|
|
1471
|
+
it('does not fire enforcement when changing onto an unregistered nick', () => {
|
|
1472
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
1473
|
+
const state = registeredState();
|
|
1474
|
+
const ctx = makeCtxWithServices(state, services);
|
|
1475
|
+
|
|
1476
|
+
const out = nickReducer(state, { command: 'NICK', params: ['carol'], tags: {} }, ctx);
|
|
1477
|
+
|
|
1478
|
+
expect(out.effects.filter((e) => e.tag === 'EnforceNick')).toEqual<EffectType[]>([]);
|
|
1479
|
+
// changeNick + NICK echo only.
|
|
1480
|
+
expect(out.effects).toHaveLength(2);
|
|
1481
|
+
});
|
|
1482
|
+
|
|
1483
|
+
it('does not fire enforcement when already identified as the nick owner', () => {
|
|
1484
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
1485
|
+
services.registerNick('carol', 'hunter2', 'carol@example.com');
|
|
1486
|
+
services.setNickEnforce('carol', 'kill');
|
|
1487
|
+
const state = registeredState();
|
|
1488
|
+
state.account = 'carol'; // identified as carol
|
|
1489
|
+
const ctx = makeCtxWithServices(state, services);
|
|
1490
|
+
|
|
1491
|
+
const out = nickReducer(state, { command: 'NICK', params: ['carol'], tags: {} }, ctx);
|
|
1492
|
+
|
|
1493
|
+
expect(out.effects.filter((e) => e.tag === 'EnforceNick')).toEqual<EffectType[]>([]);
|
|
1494
|
+
expect(out.effects).toHaveLength(2);
|
|
1495
|
+
});
|
|
1496
|
+
});
|
|
1497
|
+
|
|
1498
|
+
// ============================================================================
|
|
1499
|
+
// OperServ JUPE enforcement (NICK reducer returns 432 for a juped nick)
|
|
1500
|
+
// ============================================================================
|
|
1501
|
+
|
|
1502
|
+
describe('nickReducer — OperServ JUPE enforcement', () => {
|
|
1503
|
+
it('returns 432 ERR_ERRONEUSNICKNAME on a pre-registration attempt to use a juped nick', () => {
|
|
1504
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
1505
|
+
services.addJupe('moo', 'blocked');
|
|
1506
|
+
const state = createConnection({ id: 'c1', connectedSince: 0 });
|
|
1507
|
+
const ctx = makeCtxWithServices(state, services);
|
|
1508
|
+
|
|
1509
|
+
const out = nickReducer(state, { command: 'NICK', params: ['moo'], tags: {} }, ctx);
|
|
1510
|
+
|
|
1511
|
+
// The jupe hook fires after the grammar check; the nick IS reserved
|
|
1512
|
+
// (the actor's ReserveNick will succeed and the connection will be
|
|
1513
|
+
// welcomed under a different nick later), but the 432 is emitted so
|
|
1514
|
+
// the client knows the choice was rejected.
|
|
1515
|
+
const texts = out.effects.flatMap((e) => (e.tag === 'Send' ? e.lines.map((l) => l.text) : []));
|
|
1516
|
+
expect(texts.some((t) => / 432 .*moo/.test(t))).toBe(true);
|
|
1517
|
+
});
|
|
1518
|
+
|
|
1519
|
+
it('returns 432 on a post-registration NICK change onto a juped nick', () => {
|
|
1520
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
1521
|
+
services.addJupe('moo', 'blocked');
|
|
1522
|
+
const state = registeredState();
|
|
1523
|
+
const ctx = makeCtxWithServices(state, services);
|
|
1524
|
+
|
|
1525
|
+
const out = nickReducer(state, { command: 'NICK', params: ['moo'], tags: {} }, ctx);
|
|
1526
|
+
|
|
1527
|
+
const texts = out.effects.flatMap((e) => (e.tag === 'Send' ? e.lines.map((l) => l.text) : []));
|
|
1528
|
+
expect(texts.some((t) => / 432 .*moo/.test(t))).toBe(true);
|
|
1529
|
+
// The nick should NOT change for the post-registration case (rejected).
|
|
1530
|
+
expect(out.state.nick).toBe('alice');
|
|
1531
|
+
});
|
|
1532
|
+
|
|
1533
|
+
it('does not emit 432 when no services store is bound', () => {
|
|
1534
|
+
const state = registeredState();
|
|
1535
|
+
const ctx = makeCtx(state);
|
|
1536
|
+
|
|
1537
|
+
const out = nickReducer(state, { command: 'NICK', params: ['moo'], tags: {} }, ctx);
|
|
1538
|
+
|
|
1539
|
+
const texts = out.effects.flatMap((e) => (e.tag === 'Send' ? e.lines.map((l) => l.text) : []));
|
|
1540
|
+
expect(texts.some((t) => / 432 /.test(t))).toBe(false);
|
|
1541
|
+
});
|
|
1542
|
+
|
|
1543
|
+
it('does not emit 432 for a non-jupe nick', () => {
|
|
1544
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
1545
|
+
const state = registeredState();
|
|
1546
|
+
const ctx = makeCtxWithServices(state, services);
|
|
1547
|
+
|
|
1548
|
+
const out = nickReducer(state, { command: 'NICK', params: ['carol'], tags: {} }, ctx);
|
|
1549
|
+
|
|
1550
|
+
const texts = out.effects.flatMap((e) => (e.tag === 'Send' ? e.lines.map((l) => l.text) : []));
|
|
1551
|
+
expect(texts.some((t) => / 432 /.test(t))).toBe(false);
|
|
1552
|
+
});
|
|
1553
|
+
|
|
1554
|
+
it('matches the jupe case-insensitively', () => {
|
|
1555
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
1556
|
+
services.addJupe('Moo', 'blocked');
|
|
1557
|
+
const state = registeredState();
|
|
1558
|
+
const ctx = makeCtxWithServices(state, services);
|
|
1559
|
+
|
|
1560
|
+
const out = nickReducer(state, { command: 'NICK', params: ['moo'], tags: {} }, ctx);
|
|
1561
|
+
|
|
1562
|
+
const texts = out.effects.flatMap((e) => (e.tag === 'Send' ? e.lines.map((l) => l.text) : []));
|
|
1563
|
+
expect(texts.some((t) => / 432 /.test(t))).toBe(true);
|
|
1564
|
+
});
|
|
1565
|
+
});
|
|
1566
|
+
|
|
1567
|
+
// ============================================================================
|
|
1568
|
+
// OperServ AKILL admission (emitWelcomeIfReady disconnects on connect-time
|
|
1569
|
+
// match)
|
|
1570
|
+
// ============================================================================
|
|
1571
|
+
|
|
1572
|
+
describe('emitWelcomeIfReady — OperServ AKILL admission', () => {
|
|
1573
|
+
it('disconnects a connection whose hostmask matches a recorded AKILL', () => {
|
|
1574
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
1575
|
+
services.addAkill('*!*@bad.example.net', 'flooding');
|
|
1576
|
+
const state = makeState();
|
|
1577
|
+
state.nick = 'evil';
|
|
1578
|
+
state.user = 'spammer';
|
|
1579
|
+
state.host = 'bad.example.net';
|
|
1580
|
+
const ctx = makeCtxWithServices(state, services);
|
|
1581
|
+
|
|
1582
|
+
const effects = emitWelcomeIfReady(state, ctx);
|
|
1583
|
+
|
|
1584
|
+
const disconnects = effects.filter((e) => e.tag === 'Disconnect');
|
|
1585
|
+
expect(disconnects).toHaveLength(1);
|
|
1586
|
+
});
|
|
1587
|
+
|
|
1588
|
+
it('NOTIFIES the connection with the AKILL reason before disconnecting', () => {
|
|
1589
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
1590
|
+
services.addAkill('*!*@bad.example.net', 'flooding');
|
|
1591
|
+
const state = makeState();
|
|
1592
|
+
state.nick = 'evil';
|
|
1593
|
+
state.user = 'spammer';
|
|
1594
|
+
state.host = 'bad.example.net';
|
|
1595
|
+
const ctx = makeCtxWithServices(state, services);
|
|
1596
|
+
|
|
1597
|
+
const effects = emitWelcomeIfReady(state, ctx);
|
|
1598
|
+
|
|
1599
|
+
const texts = effects.flatMap((e) => (e.tag === 'Send' ? e.lines.map((l) => l.text) : []));
|
|
1600
|
+
expect(texts.some((t) => t.includes('AKILL: flooding'))).toBe(true);
|
|
1601
|
+
});
|
|
1602
|
+
|
|
1603
|
+
it('does NOT disconnect when no AKILL matches the hostmask', () => {
|
|
1604
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
1605
|
+
services.addAkill('*!*@bad.example.net', 'flooding');
|
|
1606
|
+
const state = makeState();
|
|
1607
|
+
state.nick = 'alice';
|
|
1608
|
+
state.user = 'alice';
|
|
1609
|
+
state.host = 'good.example.net';
|
|
1610
|
+
const ctx = makeCtxWithServices(state, services);
|
|
1611
|
+
|
|
1612
|
+
const effects = emitWelcomeIfReady(state, ctx);
|
|
1613
|
+
|
|
1614
|
+
expect(effects.filter((e) => e.tag === 'Disconnect')).toHaveLength(0);
|
|
1615
|
+
});
|
|
1616
|
+
|
|
1617
|
+
it('does NOT run the AKILL check when no services store is bound', () => {
|
|
1618
|
+
const state = makeState();
|
|
1619
|
+
state.nick = 'alice';
|
|
1620
|
+
state.user = 'alice';
|
|
1621
|
+
const ctx = makeCtx(state);
|
|
1622
|
+
|
|
1623
|
+
const effects = emitWelcomeIfReady(state, ctx);
|
|
1624
|
+
|
|
1625
|
+
expect(effects.filter((e) => e.tag === 'Disconnect')).toHaveLength(0);
|
|
1626
|
+
});
|
|
1627
|
+
});
|
|
1628
|
+
|
|
1629
|
+
// ============================================================================
|
|
1630
|
+
// PASS-based account auth (NICK + PASS <nick>:<password> + USER → identified)
|
|
1631
|
+
// ============================================================================
|
|
1632
|
+
|
|
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
|
+
const SV = 'irc.example.com';
|
|
1660
|
+
const L2 = (text: string): RawLine => ({ text });
|
|
1661
|
+
|
|
1662
|
+
describe('emitWelcomeIfReady — PASS-based account auth (success)', () => {
|
|
1663
|
+
it('identifies the connection when PASS <nick>:<password> matches an account', () => {
|
|
1664
|
+
const state = makeState();
|
|
1665
|
+
state.nick = 'alice';
|
|
1666
|
+
state.user = 'alice';
|
|
1667
|
+
state.host = 'example.com';
|
|
1668
|
+
state.passAttempt = 'alice:hunter2';
|
|
1669
|
+
const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
|
|
1670
|
+
const ctx = makeCtxWithAccounts(state, accounts);
|
|
1671
|
+
|
|
1672
|
+
const effects = emitWelcomeIfReady(state, ctx);
|
|
1673
|
+
|
|
1674
|
+
expect(state.account).toBe('alice');
|
|
1675
|
+
expect(state.registration).toBe('registered');
|
|
1676
|
+
const texts = effects.flatMap((e) => (e.tag === 'Send' ? e.lines : [])).map((l) => l.text);
|
|
1677
|
+
expect(texts).toContain(
|
|
1678
|
+
`:${SV} 900 alice alice!alice@example.com alice :You are now logged in as alice`,
|
|
1679
|
+
);
|
|
1680
|
+
expect(texts).toContain(`:${SV} 903 alice :SASL authentication successful`);
|
|
1681
|
+
expect(texts.some((t) => t.startsWith(`:${SV} 001 `))).toBe(true);
|
|
1682
|
+
});
|
|
1683
|
+
});
|
|
1684
|
+
|
|
1685
|
+
// ---- helpers for the broader PASS-auth coverage ----
|
|
1686
|
+
|
|
1687
|
+
/** Flexible ctx that binds any combination of account/services/away stores. */
|
|
1688
|
+
function makeCtxPassAuth(
|
|
1689
|
+
state: ConnectionState,
|
|
1690
|
+
opts: {
|
|
1691
|
+
accounts?: AccountStore;
|
|
1692
|
+
services?: ServicesStore;
|
|
1693
|
+
away?: AwayStore;
|
|
1694
|
+
serverPassword?: string;
|
|
1695
|
+
},
|
|
1696
|
+
): Ctx {
|
|
1697
|
+
return buildCtx({
|
|
1698
|
+
serverConfig:
|
|
1699
|
+
opts.serverPassword !== undefined
|
|
1700
|
+
? { ...serverConfig, serverPassword: opts.serverPassword }
|
|
1701
|
+
: serverConfig,
|
|
1702
|
+
clock: new FakeClock(1_000),
|
|
1703
|
+
ids: new SequentialIdFactory(),
|
|
1704
|
+
motd: EmptyMotdProvider,
|
|
1705
|
+
connection: state,
|
|
1706
|
+
...(opts.accounts !== undefined ? { accounts: opts.accounts } : {}),
|
|
1707
|
+
...(opts.services !== undefined ? { services: opts.services } : {}),
|
|
1708
|
+
...(opts.away !== undefined ? { away: opts.away } : {}),
|
|
1709
|
+
});
|
|
1710
|
+
}
|
|
1711
|
+
|
|
1712
|
+
/** All Send-effect line texts emitted by `effects`. */
|
|
1713
|
+
function sentTexts(effects: EffectType[]): string[] {
|
|
1714
|
+
return effects.flatMap((e) => (e.tag === 'Send' ? e.lines : [])).map((l) => l.text);
|
|
1715
|
+
}
|
|
1716
|
+
|
|
1717
|
+
describe('emitWelcomeIfReady — PASS-based account auth (account-effects parity with SASL)', () => {
|
|
1718
|
+
it('forwards the parsed credentials to the AccountStore as a PLAIN verify', () => {
|
|
1719
|
+
const state = makeState();
|
|
1720
|
+
state.nick = 'alice';
|
|
1721
|
+
state.user = 'alice';
|
|
1722
|
+
state.passAttempt = 'alice:hunter2';
|
|
1723
|
+
const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
|
|
1724
|
+
emitWelcomeIfReady(state, makeCtxPassAuth(state, { accounts }));
|
|
1725
|
+
expect(accounts.calls).toEqual([
|
|
1726
|
+
{ mech: 'PLAIN', payload: { kind: 'PLAIN', username: 'alice', password: 'hunter2' } },
|
|
1727
|
+
]);
|
|
1728
|
+
});
|
|
1729
|
+
|
|
1730
|
+
it('stamps read-only user mode `r` on a successful PASS-auth', () => {
|
|
1731
|
+
const state = makeState();
|
|
1732
|
+
state.nick = 'alice';
|
|
1733
|
+
state.user = 'alice';
|
|
1734
|
+
state.passAttempt = 'alice:hunter2';
|
|
1735
|
+
const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
|
|
1736
|
+
emitWelcomeIfReady(state, makeCtxPassAuth(state, { accounts }));
|
|
1737
|
+
expect(state.userModes.registered).toBe(true);
|
|
1738
|
+
});
|
|
1739
|
+
|
|
1740
|
+
it('seeds lastReadMarkers from the ServicesStore on PASS-auth success', () => {
|
|
1741
|
+
const state = makeState();
|
|
1742
|
+
state.nick = 'alice';
|
|
1743
|
+
state.user = 'alice';
|
|
1744
|
+
state.passAttempt = 'alice:hunter2';
|
|
1745
|
+
const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
|
|
1746
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
1747
|
+
services.setLastReadMarker('alice', '#foo', 'm1');
|
|
1748
|
+
emitWelcomeIfReady(state, makeCtxPassAuth(state, { accounts, services }));
|
|
1749
|
+
expect(state.lastReadMarkers?.get('#foo')).toBe('m1');
|
|
1750
|
+
});
|
|
1751
|
+
|
|
1752
|
+
it('replays the persisted away reason and emits 306 on PASS-auth success', () => {
|
|
1753
|
+
const state = makeState();
|
|
1754
|
+
state.nick = 'alice';
|
|
1755
|
+
state.user = 'alice';
|
|
1756
|
+
state.passAttempt = 'alice:hunter2';
|
|
1757
|
+
const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
|
|
1758
|
+
const away = new InMemoryAwayStore();
|
|
1759
|
+
away.set('alice', 'brb');
|
|
1760
|
+
const effects = emitWelcomeIfReady(state, makeCtxPassAuth(state, { accounts, away }));
|
|
1761
|
+
expect(state.away).toBe('brb');
|
|
1762
|
+
expect(sentTexts(effects)).toContain(`:${SV} 306 alice :You have been marked as being away`);
|
|
1763
|
+
});
|
|
1764
|
+
|
|
1765
|
+
it('delivers queued unread memos on PASS-auth success (MemoServ parity)', () => {
|
|
1766
|
+
const state = makeState();
|
|
1767
|
+
state.nick = 'alice';
|
|
1768
|
+
state.user = 'alice';
|
|
1769
|
+
state.passAttempt = 'alice:hunter2';
|
|
1770
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
1771
|
+
services.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
1772
|
+
services.registerNick('bob', 'pw', 'bob@example.com');
|
|
1773
|
+
services.recordMemo({ from: 'bob', to: 'alice', body: 'hi', sentAt: 0 });
|
|
1774
|
+
const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
|
|
1775
|
+
const effects = emitWelcomeIfReady(state, makeCtxPassAuth(state, { accounts, services }));
|
|
1776
|
+
expect(sentTexts(effects).some((t) => t.includes('[Memo #') && t.includes('hi'))).toBe(true);
|
|
1777
|
+
});
|
|
1778
|
+
|
|
1779
|
+
it('applies the assigned HostServ vhost on PASS-auth success so 001 carries the vhost', () => {
|
|
1780
|
+
const state = makeState();
|
|
1781
|
+
state.nick = 'alice';
|
|
1782
|
+
state.user = 'alice';
|
|
1783
|
+
state.host = 'real.example.net';
|
|
1784
|
+
state.passAttempt = 'alice:hunter2';
|
|
1785
|
+
const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
|
|
1786
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
1787
|
+
services.setVhost('alice', 'cool.example.net');
|
|
1788
|
+
const ctx = makeCtxPassAuth(state, { accounts, services });
|
|
1789
|
+
|
|
1790
|
+
const effects = emitWelcomeIfReady(state, ctx);
|
|
1791
|
+
|
|
1792
|
+
expect(state.account).toBe('alice');
|
|
1793
|
+
expect(state.vhostActive).toBe(true);
|
|
1794
|
+
expect(state.host).toBe('cool.example.net');
|
|
1795
|
+
expect(state.preVhostHost).toBe('real.example.net');
|
|
1796
|
+
const welcome = sentTexts(effects).find((t) => t.startsWith(`:${SV} 001 `));
|
|
1797
|
+
expect(welcome).toBeDefined();
|
|
1798
|
+
expect(welcome).toContain('cool.example.net');
|
|
1799
|
+
});
|
|
1800
|
+
|
|
1801
|
+
it('does not apply a vhost on PASS-auth success when none is assigned', () => {
|
|
1802
|
+
const state = makeState();
|
|
1803
|
+
state.nick = 'alice';
|
|
1804
|
+
state.user = 'alice';
|
|
1805
|
+
state.host = 'real.example.net';
|
|
1806
|
+
state.passAttempt = 'alice:hunter2';
|
|
1807
|
+
const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
|
|
1808
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
1809
|
+
const ctx = makeCtxPassAuth(state, { accounts, services });
|
|
1810
|
+
|
|
1811
|
+
emitWelcomeIfReady(state, ctx);
|
|
1812
|
+
|
|
1813
|
+
expect(state.vhostActive).toBeUndefined();
|
|
1814
|
+
expect(state.host).toBe('real.example.net');
|
|
1815
|
+
});
|
|
1816
|
+
|
|
1817
|
+
it('does not cloak over an active vhost applied during PASS-auth (vhost wins)', () => {
|
|
1818
|
+
const state = makeState();
|
|
1819
|
+
state.nick = 'alice';
|
|
1820
|
+
state.user = 'alice';
|
|
1821
|
+
state.host = '203.0.113.5';
|
|
1822
|
+
state.passAttempt = 'alice:hunter2';
|
|
1823
|
+
const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
|
|
1824
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
1825
|
+
services.setVhost('alice', 'cool.example.net');
|
|
1826
|
+
const ctx: Ctx = buildCtx({
|
|
1827
|
+
serverConfig: { ...serverConfig, cloaking: { enabled: true, secret: 's3cret' } },
|
|
1828
|
+
clock: new FakeClock(1_000),
|
|
1829
|
+
ids: new SequentialIdFactory(),
|
|
1830
|
+
motd: EmptyMotdProvider,
|
|
1831
|
+
connection: state,
|
|
1832
|
+
accounts,
|
|
1833
|
+
services,
|
|
1834
|
+
});
|
|
1835
|
+
|
|
1836
|
+
const effects = emitWelcomeIfReady(state, ctx);
|
|
1837
|
+
|
|
1838
|
+
// The vhost was applied by the PASS-auth success chain; cloaking must not
|
|
1839
|
+
// clobber it, and the 001 welcome must carry the vhost, not the cloak.
|
|
1840
|
+
expect(state.vhostActive).toBe(true);
|
|
1841
|
+
expect(state.host).toBe('cool.example.net');
|
|
1842
|
+
const welcome = sentTexts(effects).find((t) => t.startsWith(`:${SV} 001 `));
|
|
1843
|
+
expect(welcome).toContain('cool.example.net');
|
|
1844
|
+
expect(welcome).not.toContain(
|
|
1845
|
+
cloakHost('203.0.113.5', { enabled: true, secret: 's3cret' }, 'ExampleNet'),
|
|
1846
|
+
);
|
|
1847
|
+
});
|
|
1848
|
+
|
|
1849
|
+
it('records the payload nick as the canonical account name', () => {
|
|
1850
|
+
const state = makeState();
|
|
1851
|
+
state.nick = 'alice';
|
|
1852
|
+
state.user = 'alice';
|
|
1853
|
+
state.passAttempt = 'alice:hunter2';
|
|
1854
|
+
const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
|
|
1855
|
+
emitWelcomeIfReady(state, makeCtxPassAuth(state, { accounts }));
|
|
1856
|
+
expect(state.account).toBe('alice');
|
|
1857
|
+
});
|
|
1858
|
+
});
|
|
1859
|
+
|
|
1860
|
+
describe('emitWelcomeIfReady — PASS-based account auth (failures)', () => {
|
|
1861
|
+
it('does not identify and still completes registration when the password is wrong (no server password)', () => {
|
|
1862
|
+
const state = makeState();
|
|
1863
|
+
state.nick = 'alice';
|
|
1864
|
+
state.user = 'alice';
|
|
1865
|
+
state.passAttempt = 'alice:wrong';
|
|
1866
|
+
const accounts = new FakeAccountStore({ ok: false, reason: 'invalid credentials' });
|
|
1867
|
+
const effects = emitWelcomeIfReady(state, makeCtxPassAuth(state, { accounts }));
|
|
1868
|
+
|
|
1869
|
+
expect(state.account).toBeUndefined();
|
|
1870
|
+
expect(state.userModes.registered).toBe(false);
|
|
1871
|
+
expect(state.registration).toBe('registered');
|
|
1872
|
+
const texts = sentTexts(effects);
|
|
1873
|
+
expect(texts.some((t) => t.includes(' 900 '))).toBe(false);
|
|
1874
|
+
expect(texts.some((t) => t.includes(' 903 '))).toBe(false);
|
|
1875
|
+
expect(texts.some((t) => t.includes(' 464 '))).toBe(false);
|
|
1876
|
+
});
|
|
1877
|
+
|
|
1878
|
+
it('is indistinguishable from an unknown nick (no auth numerics, un-identified)', () => {
|
|
1879
|
+
const state = makeState();
|
|
1880
|
+
state.nick = 'nobody';
|
|
1881
|
+
state.user = 'nobody';
|
|
1882
|
+
state.passAttempt = 'nobody:wrong';
|
|
1883
|
+
const accounts = new FakeAccountStore({ ok: false, reason: 'invalid credentials' });
|
|
1884
|
+
const effects = emitWelcomeIfReady(state, makeCtxPassAuth(state, { accounts }));
|
|
1885
|
+
|
|
1886
|
+
expect(state.account).toBeUndefined();
|
|
1887
|
+
expect(state.registration).toBe('registered');
|
|
1888
|
+
const texts = sentTexts(effects);
|
|
1889
|
+
expect(texts.some((t) => t.includes(' 900 '))).toBe(false);
|
|
1890
|
+
expect(texts.some((t) => t.includes(' 903 '))).toBe(false);
|
|
1891
|
+
expect(texts.some((t) => t.includes(' 904 '))).toBe(false);
|
|
1892
|
+
expect(texts.some((t) => t.includes(' 464 '))).toBe(false);
|
|
1893
|
+
});
|
|
1894
|
+
|
|
1895
|
+
it('runs an extra verify against a fixed dummy entry on failure to equalise timing', () => {
|
|
1896
|
+
const state = makeState();
|
|
1897
|
+
state.nick = 'alice';
|
|
1898
|
+
state.user = 'alice';
|
|
1899
|
+
state.passAttempt = 'alice:wrong';
|
|
1900
|
+
const accounts = new FakeAccountStore({ ok: false, reason: 'invalid credentials' });
|
|
1901
|
+
emitWelcomeIfReady(state, makeCtxPassAuth(state, { accounts }));
|
|
1902
|
+
|
|
1903
|
+
expect(accounts.calls).toHaveLength(2);
|
|
1904
|
+
expect(accounts.calls[0]?.payload).toEqual({
|
|
1905
|
+
kind: 'PLAIN',
|
|
1906
|
+
username: 'alice',
|
|
1907
|
+
password: 'wrong',
|
|
1908
|
+
});
|
|
1909
|
+
expect(accounts.calls[1]?.mech).toBe('PLAIN');
|
|
1910
|
+
expect(accounts.calls[1]?.payload.kind).toBe('PLAIN');
|
|
1911
|
+
});
|
|
1912
|
+
|
|
1913
|
+
it('treats a payload nick that does not match state.nick as a failed auth (no silent login)', () => {
|
|
1914
|
+
const state = makeState();
|
|
1915
|
+
state.nick = 'alice';
|
|
1916
|
+
state.user = 'alice';
|
|
1917
|
+
state.passAttempt = 'bob:hunter2';
|
|
1918
|
+
const accounts = new FakeAccountStore({ ok: true, account: 'bob' });
|
|
1919
|
+
const effects = emitWelcomeIfReady(state, makeCtxPassAuth(state, { accounts }));
|
|
1920
|
+
|
|
1921
|
+
expect(state.account).toBeUndefined();
|
|
1922
|
+
expect(state.registration).toBe('registered');
|
|
1923
|
+
// The store is never consulted on a nick mismatch.
|
|
1924
|
+
expect(accounts.calls).toHaveLength(0);
|
|
1925
|
+
expect(sentTexts(effects).some((t) => t.includes(' 900 '))).toBe(false);
|
|
1926
|
+
});
|
|
1927
|
+
});
|
|
1928
|
+
|
|
1929
|
+
describe('emitWelcomeIfReady — PASS-auth interaction with the server-password gate', () => {
|
|
1930
|
+
it('ignores the <nick>:<password> form when no AccountStore is bound (open gate)', () => {
|
|
1931
|
+
const state = makeState();
|
|
1932
|
+
state.nick = 'alice';
|
|
1933
|
+
state.user = 'alice';
|
|
1934
|
+
state.passAttempt = 'alice:hunter2';
|
|
1935
|
+
const effects = emitWelcomeIfReady(state, makeCtxPassAuth(state, {}));
|
|
1936
|
+
|
|
1937
|
+
expect(state.account).toBeUndefined();
|
|
1938
|
+
expect(state.registration).toBe('registered');
|
|
1939
|
+
expect(sentTexts(effects).some((t) => t.includes(' 464 '))).toBe(false);
|
|
1940
|
+
});
|
|
1941
|
+
|
|
1942
|
+
it('collapses to the server-password gate (464) when no AccountStore is bound and the colon form does not match', () => {
|
|
1943
|
+
const state = makeState();
|
|
1944
|
+
state.nick = 'alice';
|
|
1945
|
+
state.user = 'alice';
|
|
1946
|
+
state.passAttempt = 'alice:hunter2';
|
|
1947
|
+
const effects = emitWelcomeIfReady(state, makeCtxPassAuth(state, { serverPassword: 's3cret' }));
|
|
1948
|
+
|
|
1949
|
+
expect(state.registration).not.toBe('registered');
|
|
1950
|
+
expect(effects).toEqual<EffectType[]>([
|
|
1951
|
+
Effect.send('c1', [L2(`:${SV} 464 alice :Password mismatch`)]),
|
|
1952
|
+
Effect.disconnect('c1', 'Bad Password'),
|
|
1953
|
+
]);
|
|
1954
|
+
});
|
|
1955
|
+
|
|
1956
|
+
it('precedence: a server password configured AND a <nick>:<password> that fails verify → 464 + disconnect', () => {
|
|
1957
|
+
const state = makeState();
|
|
1958
|
+
state.nick = 'alice';
|
|
1959
|
+
state.user = 'alice';
|
|
1960
|
+
state.passAttempt = 'alice:wrong';
|
|
1961
|
+
const accounts = new FakeAccountStore({ ok: false, reason: 'invalid credentials' });
|
|
1962
|
+
const effects = emitWelcomeIfReady(
|
|
1963
|
+
state,
|
|
1964
|
+
makeCtxPassAuth(state, { accounts, serverPassword: 's3cret' }),
|
|
1965
|
+
);
|
|
1966
|
+
|
|
1967
|
+
expect(state.registration).not.toBe('registered');
|
|
1968
|
+
expect(state.account).toBeUndefined();
|
|
1969
|
+
expect(effects).toEqual<EffectType[]>([
|
|
1970
|
+
Effect.send('c1', [L2(`:${SV} 464 alice :Password mismatch`)]),
|
|
1971
|
+
Effect.disconnect('c1', 'Bad Password'),
|
|
1972
|
+
]);
|
|
1973
|
+
});
|
|
1974
|
+
|
|
1975
|
+
it('does not re-run PASS-auth when the connection already identified via SASL', () => {
|
|
1976
|
+
const state = makeState();
|
|
1977
|
+
state.nick = 'alice';
|
|
1978
|
+
state.user = 'alice';
|
|
1979
|
+
state.account = 'alice'; // SASL-identified before registration completion
|
|
1980
|
+
state.passAttempt = 'alice:hunter2';
|
|
1981
|
+
const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
|
|
1982
|
+
const effects = emitWelcomeIfReady(state, makeCtxPassAuth(state, { accounts }));
|
|
1983
|
+
|
|
1984
|
+
expect(accounts.calls).toHaveLength(0);
|
|
1985
|
+
expect(state.registration).toBe('registered');
|
|
1986
|
+
// No 900/903 are re-emitted by PASS-auth; SASL already emitted them.
|
|
1987
|
+
expect(sentTexts(effects).some((t) => t.includes(' 900 '))).toBe(false);
|
|
1988
|
+
});
|
|
1989
|
+
|
|
1990
|
+
it('treats a bare PASS value (no colon) as a server-password candidate, never an account attempt', () => {
|
|
1991
|
+
const state = makeState();
|
|
1992
|
+
state.nick = 'alice';
|
|
1993
|
+
state.user = 'alice';
|
|
1994
|
+
state.passAttempt = 's3cret';
|
|
1995
|
+
const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
|
|
1996
|
+
const effects = emitWelcomeIfReady(
|
|
1997
|
+
state,
|
|
1998
|
+
makeCtxPassAuth(state, { accounts, serverPassword: 's3cret' }),
|
|
1999
|
+
);
|
|
2000
|
+
|
|
2001
|
+
expect(accounts.calls).toHaveLength(0);
|
|
2002
|
+
expect(state.account).toBeUndefined();
|
|
2003
|
+
expect(state.registration).toBe('registered');
|
|
2004
|
+
expect(sentTexts(effects).some((t) => t.includes(' 464 '))).toBe(false);
|
|
2005
|
+
});
|
|
2006
|
+
});
|
|
2007
|
+
|
|
2008
|
+
describe('passBasedAccountAuth — unit (PASS before NICK, parsing, edge cases)', () => {
|
|
2009
|
+
it('uses the payload nick (not state.nick) when PASS arrives before NICK', () => {
|
|
2010
|
+
const state = makeState();
|
|
2011
|
+
state.user = 'alice';
|
|
2012
|
+
// state.nick intentionally undefined — PASS arrived before NICK.
|
|
2013
|
+
state.passAttempt = 'alice:hunter2';
|
|
2014
|
+
const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
|
|
2015
|
+
const effects = passBasedAccountAuth(state, makeCtxPassAuth(state, { accounts }));
|
|
2016
|
+
|
|
2017
|
+
expect(state.account).toBe('alice');
|
|
2018
|
+
expect(effects.some((e) => e.tag === 'Send')).toBe(true);
|
|
2019
|
+
});
|
|
2020
|
+
|
|
2021
|
+
it('returns no effects when no PASS was sent', () => {
|
|
2022
|
+
const state = makeState();
|
|
2023
|
+
state.nick = 'alice';
|
|
2024
|
+
const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
|
|
2025
|
+
expect(passBasedAccountAuth(state, makeCtxPassAuth(state, { accounts }))).toEqual([]);
|
|
2026
|
+
});
|
|
2027
|
+
|
|
2028
|
+
it('returns no effects for a bare PASS value (no colon)', () => {
|
|
2029
|
+
const state = makeState();
|
|
2030
|
+
state.nick = 'alice';
|
|
2031
|
+
state.passAttempt = 's3cret';
|
|
2032
|
+
const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
|
|
2033
|
+
expect(passBasedAccountAuth(state, makeCtxPassAuth(state, { accounts }))).toEqual([]);
|
|
2034
|
+
expect(accounts.calls).toHaveLength(0);
|
|
2035
|
+
});
|
|
2036
|
+
|
|
2037
|
+
it('returns no effects when the nick portion is empty (`:password`)', () => {
|
|
2038
|
+
const state = makeState();
|
|
2039
|
+
state.nick = 'alice';
|
|
2040
|
+
state.passAttempt = ':hunter2';
|
|
2041
|
+
const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
|
|
2042
|
+
expect(passBasedAccountAuth(state, makeCtxPassAuth(state, { accounts }))).toEqual([]);
|
|
2043
|
+
expect(accounts.calls).toHaveLength(0);
|
|
2044
|
+
});
|
|
2045
|
+
|
|
2046
|
+
it('splits only on the first colon so a password may contain colons', () => {
|
|
2047
|
+
const state = makeState();
|
|
2048
|
+
state.nick = 'alice';
|
|
2049
|
+
state.user = 'alice';
|
|
2050
|
+
state.passAttempt = 'alice:p:ass:word';
|
|
2051
|
+
const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
|
|
2052
|
+
passBasedAccountAuth(state, makeCtxPassAuth(state, { accounts }));
|
|
2053
|
+
expect(accounts.calls[0]?.payload).toEqual({
|
|
2054
|
+
kind: 'PLAIN',
|
|
2055
|
+
username: 'alice',
|
|
2056
|
+
password: 'p:ass:word',
|
|
2057
|
+
});
|
|
2058
|
+
});
|
|
2059
|
+
|
|
2060
|
+
it('returns no effects when no AccountStore is bound', () => {
|
|
2061
|
+
const state = makeState();
|
|
2062
|
+
state.nick = 'alice';
|
|
2063
|
+
state.passAttempt = 'alice:hunter2';
|
|
2064
|
+
expect(passBasedAccountAuth(state, makeCtxPassAuth(state, {}))).toEqual([]);
|
|
2065
|
+
});
|
|
2066
|
+
});
|
|
2067
|
+
|
|
2068
|
+
describe('parsePassAccountAttempt', () => {
|
|
2069
|
+
it('returns null when the attempt is undefined', () => {
|
|
2070
|
+
expect(parsePassAccountAttempt(undefined)).toBeNull();
|
|
2071
|
+
});
|
|
2072
|
+
|
|
2073
|
+
it('returns null when there is no colon', () => {
|
|
2074
|
+
expect(parsePassAccountAttempt('s3cret')).toBeNull();
|
|
2075
|
+
});
|
|
2076
|
+
|
|
2077
|
+
it('returns null when the nick portion is empty', () => {
|
|
2078
|
+
expect(parsePassAccountAttempt(':hunter2')).toBeNull();
|
|
2079
|
+
});
|
|
2080
|
+
|
|
2081
|
+
it('parses <nick>:<password> into its parts', () => {
|
|
2082
|
+
expect(parsePassAccountAttempt('alice:hunter2')).toEqual({
|
|
2083
|
+
nick: 'alice',
|
|
2084
|
+
password: 'hunter2',
|
|
2085
|
+
});
|
|
2086
|
+
});
|
|
2087
|
+
|
|
2088
|
+
it('keeps colons in the password (splits on the first colon only)', () => {
|
|
2089
|
+
expect(parsePassAccountAttempt('alice:p:w')).toEqual({
|
|
2090
|
+
nick: 'alice',
|
|
2091
|
+
password: 'p:w',
|
|
2092
|
+
});
|
|
2093
|
+
});
|
|
2094
|
+
});
|
|
2095
|
+
|
|
2096
|
+
describe('applyAccountSuccess — shared account-login helper', () => {
|
|
2097
|
+
it('broadcasts ACCOUNT to account-notify-capable peers in shared channels', () => {
|
|
2098
|
+
const state = registeredState();
|
|
2099
|
+
state.caps.add('account-notify');
|
|
2100
|
+
state.joinedChannels.add('#foo');
|
|
2101
|
+
const ctx = makeCtx(state);
|
|
2102
|
+
const effects = applyAccountSuccess(state, ctx, 'alice');
|
|
2103
|
+
|
|
2104
|
+
const broadcasts = effects.filter((e) => e.tag === 'Broadcast');
|
|
2105
|
+
expect(broadcasts).toHaveLength(1);
|
|
2106
|
+
expect(state.account).toBe('alice');
|
|
2107
|
+
expect(state.userModes.registered).toBe(true);
|
|
2108
|
+
});
|
|
2109
|
+
|
|
2110
|
+
it('emits the 900 RPL_LOGGEDIN and 903 RPL_SASLSUCCESS numerics', () => {
|
|
2111
|
+
const state = registeredState();
|
|
2112
|
+
const ctx = makeCtx(state);
|
|
2113
|
+
const effects = applyAccountSuccess(state, ctx, 'alice');
|
|
2114
|
+
const texts = sentTexts(effects);
|
|
2115
|
+
expect(texts).toContain(
|
|
2116
|
+
`:${SV} 900 alice alice!alice@example.com alice :You are now logged in as alice`,
|
|
2117
|
+
);
|
|
2118
|
+
expect(texts).toContain(`:${SV} 903 alice :SASL authentication successful`);
|
|
2119
|
+
});
|
|
2120
|
+
});
|
|
2121
|
+
|
|
2122
|
+
// ============================================================================
|
|
2123
|
+
// applyAccountSuccess — HostServ vhost auto-apply (SASL + PASS-auth share it)
|
|
2124
|
+
// ============================================================================
|
|
2125
|
+
|
|
2126
|
+
describe('applyAccountSuccess — HostServ vhost auto-apply', () => {
|
|
2127
|
+
it('applies an assigned vhost to state.host and sets vhostActive when services hold a vhost for the account', () => {
|
|
2128
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
2129
|
+
services.setVhost('alice', 'cool.example.net');
|
|
2130
|
+
const state = registeredState();
|
|
2131
|
+
state.host = 'real.example.net';
|
|
2132
|
+
const ctx = makeCtxWithServices(state, services);
|
|
2133
|
+
|
|
2134
|
+
applyAccountSuccess(state, ctx, 'alice');
|
|
2135
|
+
|
|
2136
|
+
expect(state.vhostActive).toBe(true);
|
|
2137
|
+
expect(state.host).toBe('cool.example.net');
|
|
2138
|
+
expect(state.preVhostHost).toBe('real.example.net');
|
|
2139
|
+
});
|
|
2140
|
+
|
|
2141
|
+
it('leaves state.host unchanged when no vhost is assigned to the account', () => {
|
|
2142
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
2143
|
+
const state = registeredState();
|
|
2144
|
+
state.host = 'real.example.net';
|
|
2145
|
+
const ctx = makeCtxWithServices(state, services);
|
|
2146
|
+
|
|
2147
|
+
applyAccountSuccess(state, ctx, 'alice');
|
|
2148
|
+
|
|
2149
|
+
expect(state.vhostActive).toBeUndefined();
|
|
2150
|
+
expect(state.host).toBe('real.example.net');
|
|
2151
|
+
});
|
|
2152
|
+
|
|
2153
|
+
it('does not emit a CHGHOST broadcast when no channels are joined (pre-JOIN identify)', () => {
|
|
2154
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
2155
|
+
services.setVhost('alice', 'cool.example.net');
|
|
2156
|
+
const state = registeredState();
|
|
2157
|
+
const ctx = makeCtxWithServices(state, services);
|
|
2158
|
+
|
|
2159
|
+
const effects = applyAccountSuccess(state, ctx, 'alice');
|
|
2160
|
+
|
|
2161
|
+
expect(effects.filter((e) => e.tag === 'Broadcast')).toEqual<EffectType[]>([]);
|
|
2162
|
+
});
|
|
2163
|
+
});
|