serverless-ircd 0.7.0 → 0.8.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 +3 -3
- package/.gitmodules +1 -1
- package/CHANGELOG.md +273 -29
- package/README.md +155 -55
- 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-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 +1 -0
- package/apps/local-cli/tests/config-loader.test.ts +14 -0
- package/apps/web/landing/index.html +14 -16
- package/apps/web/package.json +2 -2
- package/apps/web/scripts/build.mjs +23 -16
- package/apps/web/src/build-env.ts +1 -1
- package/apps/web/src/config-schema.ts +6 -6
- package/apps/web/tests/build-smoke.test.ts +14 -14
- package/apps/web/tests/config-schema.test.ts +1 -1
- 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 +546 -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 +5 -0
- 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 +15 -0
- 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/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 +127 -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/handlers.test.ts +105 -3
- 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 +112 -2
- 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.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 +172 -0
- package/packages/irc-core/src/commands/chanserv.ts +882 -0
- package/packages/irc-core/src/commands/hostserv.ts +487 -0
- package/packages/irc-core/src/commands/index.ts +12 -0
- package/packages/irc-core/src/commands/join.ts +164 -8
- 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 +390 -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 +61 -6
- package/packages/irc-core/src/commands/sasl.ts +18 -49
- package/packages/irc-core/src/commands/tagmsg.ts +41 -6
- package/packages/irc-core/src/commands/topic.ts +37 -0
- 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 +21 -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 +1668 -0
- package/packages/irc-core/tests/commands/chathistory.test.ts +6 -0
- package/packages/irc-core/tests/commands/hostserv.test.ts +935 -0
- package/packages/irc-core/tests/commands/join.test.ts +393 -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 +807 -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 +788 -14
- package/packages/irc-core/tests/commands/sasl.test.ts +185 -12
- package/packages/irc-core/tests/commands/server-info.test.ts +9 -5
- 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 +71 -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 +623 -12
- 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 +29 -3
- package/packages/irc-test-support/src/index.ts +1 -0
- package/packages/irc-test-support/tests/in-memory-harness.test.ts +32 -0
- package/tools/ci-hardening/package.json +1 -1
- package/tools/load-test/package.json +1 -1
- package/tools/tcp-ws-forwarder/package.json +1 -1
- package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +2 -2
- 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);
|
|
@@ -648,14 +687,18 @@ describe('buildWelcomeLines', () => {
|
|
|
648
687
|
// ============================================================================
|
|
649
688
|
|
|
650
689
|
describe('buildWelcomeLines — config-driven version & created text', () => {
|
|
651
|
-
/** Builds a ctx whose serverConfig carries the supplied version/created.
|
|
690
|
+
/** Builds a ctx whose serverConfig carries the supplied version/created.
|
|
691
|
+
* When `serverVersion` is undefined the inherited fixture version is dropped
|
|
692
|
+
* so the schema/reducer default applies — this lets callers exercise the
|
|
693
|
+
* fallback path explicitly. */
|
|
652
694
|
function makeCtxWithMeta(
|
|
653
695
|
state: ConnectionState,
|
|
654
696
|
serverVersion: string | undefined,
|
|
655
697
|
createdAt: string | number | undefined,
|
|
656
698
|
): Ctx {
|
|
699
|
+
const { serverVersion: _drop, ...base } = serverConfig;
|
|
657
700
|
const cfg: ServerConfig = {
|
|
658
|
-
...
|
|
701
|
+
...base,
|
|
659
702
|
...(serverVersion !== undefined ? { serverVersion } : {}),
|
|
660
703
|
...(createdAt !== undefined ? { createdAt } : {}),
|
|
661
704
|
};
|
|
@@ -702,14 +745,13 @@ describe('buildWelcomeLines — config-driven version & created text', () => {
|
|
|
702
745
|
expect(line003?.text).toContain('2023');
|
|
703
746
|
});
|
|
704
747
|
|
|
705
|
-
it('falls back to the
|
|
748
|
+
it('falls back to the irc-core package version when serverVersion is omitted', () => {
|
|
706
749
|
const ctx = makeCtxWithMeta(registeredConn(), undefined, undefined);
|
|
707
750
|
const lines = buildWelcomeLines(ctx.connection, ctx);
|
|
708
751
|
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);
|
|
752
|
+
// The default must track the irc-core package version (PACKAGE_VERSION),
|
|
753
|
+
// not a stale hardcoded placeholder.
|
|
754
|
+
expect(line002?.text).toContain(PACKAGE_VERSION);
|
|
713
755
|
});
|
|
714
756
|
});
|
|
715
757
|
|
|
@@ -1095,9 +1137,9 @@ function expectedWelcomeWithHost(nick: string, hostmask: string): RawLine[] {
|
|
|
1095
1137
|
);
|
|
1096
1138
|
return [
|
|
1097
1139
|
L(`${prefix} 001 ${nick} :Welcome to the ExampleNet IRC Network, ${hostmask}`),
|
|
1098
|
-
L(`${prefix} 002 ${nick} :Your host is ${sv}, running version ${
|
|
1140
|
+
L(`${prefix} 002 ${nick} :Your host is ${sv}, running version ${FIXTURE_VERSION}`),
|
|
1099
1141
|
L(`${prefix} 003 ${nick} :This server was created in ${DEFAULT_CREATED_TEXT}`),
|
|
1100
|
-
L(`${prefix} 004 ${nick} ${sv} ${
|
|
1142
|
+
L(`${prefix} 004 ${nick} ${sv} ${FIXTURE_VERSION} iosw biklmnstp`),
|
|
1101
1143
|
...isupport,
|
|
1102
1144
|
L(`${prefix} 422 ${nick} :MOTD File is missing`),
|
|
1103
1145
|
];
|
|
@@ -1166,3 +1208,735 @@ describe('emitWelcomeIfReady — user mode `S` (TLS connected)', () => {
|
|
|
1166
1208
|
expect(out.state.userModes.tls).toBe(true);
|
|
1167
1209
|
});
|
|
1168
1210
|
});
|
|
1211
|
+
|
|
1212
|
+
// ============================================================================
|
|
1213
|
+
// NickServ nick-enforcement wiring (fires on registration + nick change)
|
|
1214
|
+
// ============================================================================
|
|
1215
|
+
|
|
1216
|
+
/** Like {@link makeCtx} but threads a bound services store. */
|
|
1217
|
+
function makeCtxWithServices(
|
|
1218
|
+
state: ConnectionState,
|
|
1219
|
+
services: ServicesStore,
|
|
1220
|
+
clock = new FakeClock(1_000),
|
|
1221
|
+
): Ctx {
|
|
1222
|
+
return buildCtx({
|
|
1223
|
+
serverConfig,
|
|
1224
|
+
clock,
|
|
1225
|
+
ids: new SequentialIdFactory(),
|
|
1226
|
+
motd: EmptyMotdProvider,
|
|
1227
|
+
connection: state,
|
|
1228
|
+
services,
|
|
1229
|
+
});
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
describe('emitWelcomeIfReady — NickServ nick enforcement', () => {
|
|
1233
|
+
it('fires EnforceNick(kill) when an unidentified client registers a kill-enforced nick', () => {
|
|
1234
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
1235
|
+
services.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
1236
|
+
services.setNickEnforce('alice', 'kill');
|
|
1237
|
+
const state = makeState();
|
|
1238
|
+
state.nick = 'alice';
|
|
1239
|
+
state.user = 'alice';
|
|
1240
|
+
const ctx = makeCtxWithServices(state, services);
|
|
1241
|
+
|
|
1242
|
+
const effects = emitWelcomeIfReady(state, ctx);
|
|
1243
|
+
|
|
1244
|
+
// First effect is still the welcome Send.
|
|
1245
|
+
expect(effects[0]?.tag).toBe('Send');
|
|
1246
|
+
const enforce = effects.filter((e) => e.tag === 'EnforceNick');
|
|
1247
|
+
expect(enforce).toEqual<EffectType[]>([Effect.enforceNick('c1', 'alice', 'kill', 0)]);
|
|
1248
|
+
});
|
|
1249
|
+
|
|
1250
|
+
it('fires EnforceNick(ghost) with the configured grace for a ghost-enforced nick', () => {
|
|
1251
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
1252
|
+
services.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
1253
|
+
services.setNickEnforce('alice', 'ghost');
|
|
1254
|
+
const state = makeState();
|
|
1255
|
+
state.nick = 'alice';
|
|
1256
|
+
state.user = 'alice';
|
|
1257
|
+
const ctx: Ctx = buildCtx({
|
|
1258
|
+
serverConfig: { ...serverConfig, nickEnforceGraceMs: 12_000 },
|
|
1259
|
+
clock: new FakeClock(1_000),
|
|
1260
|
+
ids: new SequentialIdFactory(),
|
|
1261
|
+
motd: EmptyMotdProvider,
|
|
1262
|
+
connection: state,
|
|
1263
|
+
services,
|
|
1264
|
+
});
|
|
1265
|
+
|
|
1266
|
+
const effects = emitWelcomeIfReady(state, ctx);
|
|
1267
|
+
|
|
1268
|
+
expect(effects.filter((e) => e.tag === 'EnforceNick')).toEqual<EffectType[]>([
|
|
1269
|
+
Effect.enforceNick('c1', 'alice', 'ghost', 12_000),
|
|
1270
|
+
]);
|
|
1271
|
+
});
|
|
1272
|
+
|
|
1273
|
+
it('does NOT fire enforcement when the connection is identified as the owner', () => {
|
|
1274
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
1275
|
+
services.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
1276
|
+
services.setNickEnforce('alice', 'kill');
|
|
1277
|
+
const state = makeState();
|
|
1278
|
+
state.nick = 'alice';
|
|
1279
|
+
state.user = 'alice';
|
|
1280
|
+
state.account = 'alice'; // SASL-identified before CAP END
|
|
1281
|
+
const ctx = makeCtxWithServices(state, services);
|
|
1282
|
+
|
|
1283
|
+
const effects = emitWelcomeIfReady(state, ctx);
|
|
1284
|
+
|
|
1285
|
+
expect(effects.filter((e) => e.tag === 'EnforceNick')).toEqual<EffectType[]>([]);
|
|
1286
|
+
// Welcome is the only effect.
|
|
1287
|
+
expect(effects).toHaveLength(1);
|
|
1288
|
+
expect(effects[0]?.tag).toBe('Send');
|
|
1289
|
+
});
|
|
1290
|
+
|
|
1291
|
+
it('emits only a warning NOTICE (no EnforceNick) for a none-enforced nick', () => {
|
|
1292
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
1293
|
+
services.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
1294
|
+
services.setNickEnforce('alice', 'none');
|
|
1295
|
+
const state = makeState();
|
|
1296
|
+
state.nick = 'alice';
|
|
1297
|
+
state.user = 'alice';
|
|
1298
|
+
const ctx = makeCtxWithServices(state, services);
|
|
1299
|
+
|
|
1300
|
+
const effects = emitWelcomeIfReady(state, ctx);
|
|
1301
|
+
|
|
1302
|
+
expect(effects.filter((e) => e.tag === 'EnforceNick')).toEqual<EffectType[]>([]);
|
|
1303
|
+
// Welcome Send + warning NOTICE Send.
|
|
1304
|
+
expect(effects.filter((e) => e.tag === 'Send')).toHaveLength(2);
|
|
1305
|
+
});
|
|
1306
|
+
|
|
1307
|
+
it('does not fire enforcement for an unregistered nick', () => {
|
|
1308
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
1309
|
+
const state = makeState();
|
|
1310
|
+
state.nick = 'ghostie';
|
|
1311
|
+
state.user = 'ghostie';
|
|
1312
|
+
const ctx = makeCtxWithServices(state, services);
|
|
1313
|
+
|
|
1314
|
+
const effects = emitWelcomeIfReady(state, ctx);
|
|
1315
|
+
|
|
1316
|
+
expect(effects.filter((e) => e.tag === 'EnforceNick')).toEqual<EffectType[]>([]);
|
|
1317
|
+
expect(effects).toHaveLength(1);
|
|
1318
|
+
});
|
|
1319
|
+
|
|
1320
|
+
it('does not fire enforcement when no services store is bound', () => {
|
|
1321
|
+
const state = makeState();
|
|
1322
|
+
state.nick = 'alice';
|
|
1323
|
+
state.user = 'alice';
|
|
1324
|
+
const ctx = makeCtx(state);
|
|
1325
|
+
|
|
1326
|
+
const effects = emitWelcomeIfReady(state, ctx);
|
|
1327
|
+
|
|
1328
|
+
expect(effects.filter((e) => e.tag === 'EnforceNick')).toEqual<EffectType[]>([]);
|
|
1329
|
+
expect(effects).toEqual([Effect.send('c1', expectedWelcome('alice', 'alice', undefined))]);
|
|
1330
|
+
});
|
|
1331
|
+
});
|
|
1332
|
+
|
|
1333
|
+
describe('nickReducer — NickServ nick enforcement on post-registration nick change', () => {
|
|
1334
|
+
it('fires EnforceNick(ghost) when changing onto a ghost-enforced registered nick', () => {
|
|
1335
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
1336
|
+
services.registerNick('carol', 'hunter2', 'carol@example.com');
|
|
1337
|
+
services.setNickEnforce('carol', 'ghost');
|
|
1338
|
+
const state = registeredState(); // already registered as alice
|
|
1339
|
+
const ctx = makeCtxWithServices(state, services);
|
|
1340
|
+
|
|
1341
|
+
const out = nickReducer(state, { command: 'NICK', params: ['carol'], tags: {} }, ctx);
|
|
1342
|
+
|
|
1343
|
+
expect(out.state.nick).toBe('carol');
|
|
1344
|
+
// changeNick + NICK echo + warning NOTICE + EnforceNick(ghost).
|
|
1345
|
+
const enforce = out.effects.filter((e) => e.tag === 'EnforceNick');
|
|
1346
|
+
expect(enforce).toEqual<EffectType[]>([Effect.enforceNick('c1', 'carol', 'ghost', 30_000)]);
|
|
1347
|
+
});
|
|
1348
|
+
|
|
1349
|
+
it('fires EnforceNick(kill) when changing onto a kill-enforced registered nick', () => {
|
|
1350
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
1351
|
+
services.registerNick('carol', 'hunter2', 'carol@example.com');
|
|
1352
|
+
services.setNickEnforce('carol', 'kill');
|
|
1353
|
+
const state = registeredState();
|
|
1354
|
+
const ctx = makeCtxWithServices(state, services);
|
|
1355
|
+
|
|
1356
|
+
const out = nickReducer(state, { command: 'NICK', params: ['carol'], tags: {} }, ctx);
|
|
1357
|
+
|
|
1358
|
+
expect(out.effects.filter((e) => e.tag === 'EnforceNick')).toEqual<EffectType[]>([
|
|
1359
|
+
Effect.enforceNick('c1', 'carol', 'kill', 0),
|
|
1360
|
+
]);
|
|
1361
|
+
});
|
|
1362
|
+
|
|
1363
|
+
it('does not fire enforcement when changing onto an unregistered nick', () => {
|
|
1364
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
1365
|
+
const state = registeredState();
|
|
1366
|
+
const ctx = makeCtxWithServices(state, services);
|
|
1367
|
+
|
|
1368
|
+
const out = nickReducer(state, { command: 'NICK', params: ['carol'], tags: {} }, ctx);
|
|
1369
|
+
|
|
1370
|
+
expect(out.effects.filter((e) => e.tag === 'EnforceNick')).toEqual<EffectType[]>([]);
|
|
1371
|
+
// changeNick + NICK echo only.
|
|
1372
|
+
expect(out.effects).toHaveLength(2);
|
|
1373
|
+
});
|
|
1374
|
+
|
|
1375
|
+
it('does not fire enforcement when already identified as the nick owner', () => {
|
|
1376
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
1377
|
+
services.registerNick('carol', 'hunter2', 'carol@example.com');
|
|
1378
|
+
services.setNickEnforce('carol', 'kill');
|
|
1379
|
+
const state = registeredState();
|
|
1380
|
+
state.account = 'carol'; // identified as carol
|
|
1381
|
+
const ctx = makeCtxWithServices(state, services);
|
|
1382
|
+
|
|
1383
|
+
const out = nickReducer(state, { command: 'NICK', params: ['carol'], tags: {} }, ctx);
|
|
1384
|
+
|
|
1385
|
+
expect(out.effects.filter((e) => e.tag === 'EnforceNick')).toEqual<EffectType[]>([]);
|
|
1386
|
+
expect(out.effects).toHaveLength(2);
|
|
1387
|
+
});
|
|
1388
|
+
});
|
|
1389
|
+
|
|
1390
|
+
// ============================================================================
|
|
1391
|
+
// OperServ JUPE enforcement (NICK reducer returns 432 for a juped nick)
|
|
1392
|
+
// ============================================================================
|
|
1393
|
+
|
|
1394
|
+
describe('nickReducer — OperServ JUPE enforcement', () => {
|
|
1395
|
+
it('returns 432 ERR_ERRONEUSNICKNAME on a pre-registration attempt to use a juped nick', () => {
|
|
1396
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
1397
|
+
services.addJupe('moo', 'blocked');
|
|
1398
|
+
const state = createConnection({ id: 'c1', connectedSince: 0 });
|
|
1399
|
+
const ctx = makeCtxWithServices(state, services);
|
|
1400
|
+
|
|
1401
|
+
const out = nickReducer(state, { command: 'NICK', params: ['moo'], tags: {} }, ctx);
|
|
1402
|
+
|
|
1403
|
+
// The jupe hook fires after the grammar check; the nick IS reserved
|
|
1404
|
+
// (the actor's ReserveNick will succeed and the connection will be
|
|
1405
|
+
// welcomed under a different nick later), but the 432 is emitted so
|
|
1406
|
+
// the client knows the choice was rejected.
|
|
1407
|
+
const texts = out.effects.flatMap((e) => (e.tag === 'Send' ? e.lines.map((l) => l.text) : []));
|
|
1408
|
+
expect(texts.some((t) => / 432 .*moo/.test(t))).toBe(true);
|
|
1409
|
+
});
|
|
1410
|
+
|
|
1411
|
+
it('returns 432 on a post-registration NICK change onto a juped nick', () => {
|
|
1412
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
1413
|
+
services.addJupe('moo', 'blocked');
|
|
1414
|
+
const state = registeredState();
|
|
1415
|
+
const ctx = makeCtxWithServices(state, services);
|
|
1416
|
+
|
|
1417
|
+
const out = nickReducer(state, { command: 'NICK', params: ['moo'], tags: {} }, ctx);
|
|
1418
|
+
|
|
1419
|
+
const texts = out.effects.flatMap((e) => (e.tag === 'Send' ? e.lines.map((l) => l.text) : []));
|
|
1420
|
+
expect(texts.some((t) => / 432 .*moo/.test(t))).toBe(true);
|
|
1421
|
+
// The nick should NOT change for the post-registration case (rejected).
|
|
1422
|
+
expect(out.state.nick).toBe('alice');
|
|
1423
|
+
});
|
|
1424
|
+
|
|
1425
|
+
it('does not emit 432 when no services store is bound', () => {
|
|
1426
|
+
const state = registeredState();
|
|
1427
|
+
const ctx = makeCtx(state);
|
|
1428
|
+
|
|
1429
|
+
const out = nickReducer(state, { command: 'NICK', params: ['moo'], tags: {} }, ctx);
|
|
1430
|
+
|
|
1431
|
+
const texts = out.effects.flatMap((e) => (e.tag === 'Send' ? e.lines.map((l) => l.text) : []));
|
|
1432
|
+
expect(texts.some((t) => / 432 /.test(t))).toBe(false);
|
|
1433
|
+
});
|
|
1434
|
+
|
|
1435
|
+
it('does not emit 432 for a non-jupe nick', () => {
|
|
1436
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
1437
|
+
const state = registeredState();
|
|
1438
|
+
const ctx = makeCtxWithServices(state, services);
|
|
1439
|
+
|
|
1440
|
+
const out = nickReducer(state, { command: 'NICK', params: ['carol'], tags: {} }, ctx);
|
|
1441
|
+
|
|
1442
|
+
const texts = out.effects.flatMap((e) => (e.tag === 'Send' ? e.lines.map((l) => l.text) : []));
|
|
1443
|
+
expect(texts.some((t) => / 432 /.test(t))).toBe(false);
|
|
1444
|
+
});
|
|
1445
|
+
|
|
1446
|
+
it('matches the jupe case-insensitively', () => {
|
|
1447
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
1448
|
+
services.addJupe('Moo', 'blocked');
|
|
1449
|
+
const state = registeredState();
|
|
1450
|
+
const ctx = makeCtxWithServices(state, services);
|
|
1451
|
+
|
|
1452
|
+
const out = nickReducer(state, { command: 'NICK', params: ['moo'], tags: {} }, ctx);
|
|
1453
|
+
|
|
1454
|
+
const texts = out.effects.flatMap((e) => (e.tag === 'Send' ? e.lines.map((l) => l.text) : []));
|
|
1455
|
+
expect(texts.some((t) => / 432 /.test(t))).toBe(true);
|
|
1456
|
+
});
|
|
1457
|
+
});
|
|
1458
|
+
|
|
1459
|
+
// ============================================================================
|
|
1460
|
+
// OperServ AKILL admission (emitWelcomeIfReady disconnects on connect-time
|
|
1461
|
+
// match)
|
|
1462
|
+
// ============================================================================
|
|
1463
|
+
|
|
1464
|
+
describe('emitWelcomeIfReady — OperServ AKILL admission', () => {
|
|
1465
|
+
it('disconnects a connection whose hostmask matches a recorded AKILL', () => {
|
|
1466
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
1467
|
+
services.addAkill('*!*@bad.example.net', 'flooding');
|
|
1468
|
+
const state = makeState();
|
|
1469
|
+
state.nick = 'evil';
|
|
1470
|
+
state.user = 'spammer';
|
|
1471
|
+
state.host = 'bad.example.net';
|
|
1472
|
+
const ctx = makeCtxWithServices(state, services);
|
|
1473
|
+
|
|
1474
|
+
const effects = emitWelcomeIfReady(state, ctx);
|
|
1475
|
+
|
|
1476
|
+
const disconnects = effects.filter((e) => e.tag === 'Disconnect');
|
|
1477
|
+
expect(disconnects).toHaveLength(1);
|
|
1478
|
+
});
|
|
1479
|
+
|
|
1480
|
+
it('NOTIFIES the connection with the AKILL reason before disconnecting', () => {
|
|
1481
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
1482
|
+
services.addAkill('*!*@bad.example.net', 'flooding');
|
|
1483
|
+
const state = makeState();
|
|
1484
|
+
state.nick = 'evil';
|
|
1485
|
+
state.user = 'spammer';
|
|
1486
|
+
state.host = 'bad.example.net';
|
|
1487
|
+
const ctx = makeCtxWithServices(state, services);
|
|
1488
|
+
|
|
1489
|
+
const effects = emitWelcomeIfReady(state, ctx);
|
|
1490
|
+
|
|
1491
|
+
const texts = effects.flatMap((e) => (e.tag === 'Send' ? e.lines.map((l) => l.text) : []));
|
|
1492
|
+
expect(texts.some((t) => t.includes('AKILL: flooding'))).toBe(true);
|
|
1493
|
+
});
|
|
1494
|
+
|
|
1495
|
+
it('does NOT disconnect when no AKILL matches the hostmask', () => {
|
|
1496
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
1497
|
+
services.addAkill('*!*@bad.example.net', 'flooding');
|
|
1498
|
+
const state = makeState();
|
|
1499
|
+
state.nick = 'alice';
|
|
1500
|
+
state.user = 'alice';
|
|
1501
|
+
state.host = 'good.example.net';
|
|
1502
|
+
const ctx = makeCtxWithServices(state, services);
|
|
1503
|
+
|
|
1504
|
+
const effects = emitWelcomeIfReady(state, ctx);
|
|
1505
|
+
|
|
1506
|
+
expect(effects.filter((e) => e.tag === 'Disconnect')).toHaveLength(0);
|
|
1507
|
+
});
|
|
1508
|
+
|
|
1509
|
+
it('does NOT run the AKILL check when no services store is bound', () => {
|
|
1510
|
+
const state = makeState();
|
|
1511
|
+
state.nick = 'alice';
|
|
1512
|
+
state.user = 'alice';
|
|
1513
|
+
const ctx = makeCtx(state);
|
|
1514
|
+
|
|
1515
|
+
const effects = emitWelcomeIfReady(state, ctx);
|
|
1516
|
+
|
|
1517
|
+
expect(effects.filter((e) => e.tag === 'Disconnect')).toHaveLength(0);
|
|
1518
|
+
});
|
|
1519
|
+
});
|
|
1520
|
+
|
|
1521
|
+
// ============================================================================
|
|
1522
|
+
// PASS-based account auth (NICK + PASS <nick>:<password> + USER → identified)
|
|
1523
|
+
// ============================================================================
|
|
1524
|
+
|
|
1525
|
+
/** A capture-and-respond fake AccountStore (mirrors the sasl.test.ts fake). */
|
|
1526
|
+
class FakeAccountStore implements AccountStore {
|
|
1527
|
+
readonly calls: Array<{ mech: string; payload: SaslPayload }> = [];
|
|
1528
|
+
constructor(private readonly result: SaslResult) {}
|
|
1529
|
+
verify(mech: string, payload: SaslPayload): SaslResult {
|
|
1530
|
+
this.calls.push({ mech, payload });
|
|
1531
|
+
return this.result;
|
|
1532
|
+
}
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1535
|
+
/** Like {@link makeCtx} but also threads a bound AccountStore. */
|
|
1536
|
+
function makeCtxWithAccounts(
|
|
1537
|
+
state: ConnectionState,
|
|
1538
|
+
accounts: AccountStore,
|
|
1539
|
+
clock = new FakeClock(1_000),
|
|
1540
|
+
): Ctx {
|
|
1541
|
+
return buildCtx({
|
|
1542
|
+
serverConfig,
|
|
1543
|
+
clock,
|
|
1544
|
+
ids: new SequentialIdFactory(),
|
|
1545
|
+
motd: EmptyMotdProvider,
|
|
1546
|
+
connection: state,
|
|
1547
|
+
accounts,
|
|
1548
|
+
});
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1551
|
+
const SV = 'irc.example.com';
|
|
1552
|
+
const L2 = (text: string): RawLine => ({ text });
|
|
1553
|
+
|
|
1554
|
+
describe('emitWelcomeIfReady — PASS-based account auth (success)', () => {
|
|
1555
|
+
it('identifies the connection when PASS <nick>:<password> matches an account', () => {
|
|
1556
|
+
const state = makeState();
|
|
1557
|
+
state.nick = 'alice';
|
|
1558
|
+
state.user = 'alice';
|
|
1559
|
+
state.host = 'example.com';
|
|
1560
|
+
state.passAttempt = 'alice:hunter2';
|
|
1561
|
+
const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
|
|
1562
|
+
const ctx = makeCtxWithAccounts(state, accounts);
|
|
1563
|
+
|
|
1564
|
+
const effects = emitWelcomeIfReady(state, ctx);
|
|
1565
|
+
|
|
1566
|
+
expect(state.account).toBe('alice');
|
|
1567
|
+
expect(state.registration).toBe('registered');
|
|
1568
|
+
const texts = effects.flatMap((e) => (e.tag === 'Send' ? e.lines : [])).map((l) => l.text);
|
|
1569
|
+
expect(texts).toContain(
|
|
1570
|
+
`:${SV} 900 alice alice!alice@example.com alice :You are now logged in as alice`,
|
|
1571
|
+
);
|
|
1572
|
+
expect(texts).toContain(`:${SV} 903 alice :SASL authentication successful`);
|
|
1573
|
+
expect(texts.some((t) => t.startsWith(`:${SV} 001 `))).toBe(true);
|
|
1574
|
+
});
|
|
1575
|
+
});
|
|
1576
|
+
|
|
1577
|
+
// ---- helpers for the broader PASS-auth coverage ----
|
|
1578
|
+
|
|
1579
|
+
/** Flexible ctx that binds any combination of account/services/away stores. */
|
|
1580
|
+
function makeCtxPassAuth(
|
|
1581
|
+
state: ConnectionState,
|
|
1582
|
+
opts: {
|
|
1583
|
+
accounts?: AccountStore;
|
|
1584
|
+
services?: ServicesStore;
|
|
1585
|
+
away?: AwayStore;
|
|
1586
|
+
serverPassword?: string;
|
|
1587
|
+
},
|
|
1588
|
+
): Ctx {
|
|
1589
|
+
return buildCtx({
|
|
1590
|
+
serverConfig:
|
|
1591
|
+
opts.serverPassword !== undefined
|
|
1592
|
+
? { ...serverConfig, serverPassword: opts.serverPassword }
|
|
1593
|
+
: serverConfig,
|
|
1594
|
+
clock: new FakeClock(1_000),
|
|
1595
|
+
ids: new SequentialIdFactory(),
|
|
1596
|
+
motd: EmptyMotdProvider,
|
|
1597
|
+
connection: state,
|
|
1598
|
+
...(opts.accounts !== undefined ? { accounts: opts.accounts } : {}),
|
|
1599
|
+
...(opts.services !== undefined ? { services: opts.services } : {}),
|
|
1600
|
+
...(opts.away !== undefined ? { away: opts.away } : {}),
|
|
1601
|
+
});
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1604
|
+
/** All Send-effect line texts emitted by `effects`. */
|
|
1605
|
+
function sentTexts(effects: EffectType[]): string[] {
|
|
1606
|
+
return effects.flatMap((e) => (e.tag === 'Send' ? e.lines : [])).map((l) => l.text);
|
|
1607
|
+
}
|
|
1608
|
+
|
|
1609
|
+
describe('emitWelcomeIfReady — PASS-based account auth (account-effects parity with SASL)', () => {
|
|
1610
|
+
it('forwards the parsed credentials to the AccountStore as a PLAIN verify', () => {
|
|
1611
|
+
const state = makeState();
|
|
1612
|
+
state.nick = 'alice';
|
|
1613
|
+
state.user = 'alice';
|
|
1614
|
+
state.passAttempt = 'alice:hunter2';
|
|
1615
|
+
const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
|
|
1616
|
+
emitWelcomeIfReady(state, makeCtxPassAuth(state, { accounts }));
|
|
1617
|
+
expect(accounts.calls).toEqual([
|
|
1618
|
+
{ mech: 'PLAIN', payload: { kind: 'PLAIN', username: 'alice', password: 'hunter2' } },
|
|
1619
|
+
]);
|
|
1620
|
+
});
|
|
1621
|
+
|
|
1622
|
+
it('stamps read-only user mode `r` on a successful PASS-auth', () => {
|
|
1623
|
+
const state = makeState();
|
|
1624
|
+
state.nick = 'alice';
|
|
1625
|
+
state.user = 'alice';
|
|
1626
|
+
state.passAttempt = 'alice:hunter2';
|
|
1627
|
+
const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
|
|
1628
|
+
emitWelcomeIfReady(state, makeCtxPassAuth(state, { accounts }));
|
|
1629
|
+
expect(state.userModes.registered).toBe(true);
|
|
1630
|
+
});
|
|
1631
|
+
|
|
1632
|
+
it('seeds lastReadMarkers from the ServicesStore on PASS-auth success', () => {
|
|
1633
|
+
const state = makeState();
|
|
1634
|
+
state.nick = 'alice';
|
|
1635
|
+
state.user = 'alice';
|
|
1636
|
+
state.passAttempt = 'alice:hunter2';
|
|
1637
|
+
const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
|
|
1638
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
1639
|
+
services.setLastReadMarker('alice', '#foo', 'm1');
|
|
1640
|
+
emitWelcomeIfReady(state, makeCtxPassAuth(state, { accounts, services }));
|
|
1641
|
+
expect(state.lastReadMarkers?.get('#foo')).toBe('m1');
|
|
1642
|
+
});
|
|
1643
|
+
|
|
1644
|
+
it('replays the persisted away reason and emits 306 on PASS-auth success', () => {
|
|
1645
|
+
const state = makeState();
|
|
1646
|
+
state.nick = 'alice';
|
|
1647
|
+
state.user = 'alice';
|
|
1648
|
+
state.passAttempt = 'alice:hunter2';
|
|
1649
|
+
const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
|
|
1650
|
+
const away = new InMemoryAwayStore();
|
|
1651
|
+
away.set('alice', 'brb');
|
|
1652
|
+
const effects = emitWelcomeIfReady(state, makeCtxPassAuth(state, { accounts, away }));
|
|
1653
|
+
expect(state.away).toBe('brb');
|
|
1654
|
+
expect(sentTexts(effects)).toContain(`:${SV} 306 alice :You have been marked as being away`);
|
|
1655
|
+
});
|
|
1656
|
+
|
|
1657
|
+
it('delivers queued unread memos on PASS-auth success (MemoServ parity)', () => {
|
|
1658
|
+
const state = makeState();
|
|
1659
|
+
state.nick = 'alice';
|
|
1660
|
+
state.user = 'alice';
|
|
1661
|
+
state.passAttempt = 'alice:hunter2';
|
|
1662
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
1663
|
+
services.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
1664
|
+
services.registerNick('bob', 'pw', 'bob@example.com');
|
|
1665
|
+
services.recordMemo({ from: 'bob', to: 'alice', body: 'hi', sentAt: 0 });
|
|
1666
|
+
const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
|
|
1667
|
+
const effects = emitWelcomeIfReady(state, makeCtxPassAuth(state, { accounts, services }));
|
|
1668
|
+
expect(sentTexts(effects).some((t) => t.includes('[Memo #') && t.includes('hi'))).toBe(true);
|
|
1669
|
+
});
|
|
1670
|
+
|
|
1671
|
+
it('records the payload nick as the canonical account name', () => {
|
|
1672
|
+
const state = makeState();
|
|
1673
|
+
state.nick = 'alice';
|
|
1674
|
+
state.user = 'alice';
|
|
1675
|
+
state.passAttempt = 'alice:hunter2';
|
|
1676
|
+
const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
|
|
1677
|
+
emitWelcomeIfReady(state, makeCtxPassAuth(state, { accounts }));
|
|
1678
|
+
expect(state.account).toBe('alice');
|
|
1679
|
+
});
|
|
1680
|
+
});
|
|
1681
|
+
|
|
1682
|
+
describe('emitWelcomeIfReady — PASS-based account auth (failures)', () => {
|
|
1683
|
+
it('does not identify and still completes registration when the password is wrong (no server password)', () => {
|
|
1684
|
+
const state = makeState();
|
|
1685
|
+
state.nick = 'alice';
|
|
1686
|
+
state.user = 'alice';
|
|
1687
|
+
state.passAttempt = 'alice:wrong';
|
|
1688
|
+
const accounts = new FakeAccountStore({ ok: false, reason: 'invalid credentials' });
|
|
1689
|
+
const effects = emitWelcomeIfReady(state, makeCtxPassAuth(state, { accounts }));
|
|
1690
|
+
|
|
1691
|
+
expect(state.account).toBeUndefined();
|
|
1692
|
+
expect(state.userModes.registered).toBe(false);
|
|
1693
|
+
expect(state.registration).toBe('registered');
|
|
1694
|
+
const texts = sentTexts(effects);
|
|
1695
|
+
expect(texts.some((t) => t.includes(' 900 '))).toBe(false);
|
|
1696
|
+
expect(texts.some((t) => t.includes(' 903 '))).toBe(false);
|
|
1697
|
+
expect(texts.some((t) => t.includes(' 464 '))).toBe(false);
|
|
1698
|
+
});
|
|
1699
|
+
|
|
1700
|
+
it('is indistinguishable from an unknown nick (no auth numerics, un-identified)', () => {
|
|
1701
|
+
const state = makeState();
|
|
1702
|
+
state.nick = 'nobody';
|
|
1703
|
+
state.user = 'nobody';
|
|
1704
|
+
state.passAttempt = 'nobody:wrong';
|
|
1705
|
+
const accounts = new FakeAccountStore({ ok: false, reason: 'invalid credentials' });
|
|
1706
|
+
const effects = emitWelcomeIfReady(state, makeCtxPassAuth(state, { accounts }));
|
|
1707
|
+
|
|
1708
|
+
expect(state.account).toBeUndefined();
|
|
1709
|
+
expect(state.registration).toBe('registered');
|
|
1710
|
+
const texts = sentTexts(effects);
|
|
1711
|
+
expect(texts.some((t) => t.includes(' 900 '))).toBe(false);
|
|
1712
|
+
expect(texts.some((t) => t.includes(' 903 '))).toBe(false);
|
|
1713
|
+
expect(texts.some((t) => t.includes(' 904 '))).toBe(false);
|
|
1714
|
+
expect(texts.some((t) => t.includes(' 464 '))).toBe(false);
|
|
1715
|
+
});
|
|
1716
|
+
|
|
1717
|
+
it('runs an extra verify against a fixed dummy entry on failure to equalise timing', () => {
|
|
1718
|
+
const state = makeState();
|
|
1719
|
+
state.nick = 'alice';
|
|
1720
|
+
state.user = 'alice';
|
|
1721
|
+
state.passAttempt = 'alice:wrong';
|
|
1722
|
+
const accounts = new FakeAccountStore({ ok: false, reason: 'invalid credentials' });
|
|
1723
|
+
emitWelcomeIfReady(state, makeCtxPassAuth(state, { accounts }));
|
|
1724
|
+
|
|
1725
|
+
expect(accounts.calls).toHaveLength(2);
|
|
1726
|
+
expect(accounts.calls[0]?.payload).toEqual({
|
|
1727
|
+
kind: 'PLAIN',
|
|
1728
|
+
username: 'alice',
|
|
1729
|
+
password: 'wrong',
|
|
1730
|
+
});
|
|
1731
|
+
expect(accounts.calls[1]?.mech).toBe('PLAIN');
|
|
1732
|
+
expect(accounts.calls[1]?.payload.kind).toBe('PLAIN');
|
|
1733
|
+
});
|
|
1734
|
+
|
|
1735
|
+
it('treats a payload nick that does not match state.nick as a failed auth (no silent login)', () => {
|
|
1736
|
+
const state = makeState();
|
|
1737
|
+
state.nick = 'alice';
|
|
1738
|
+
state.user = 'alice';
|
|
1739
|
+
state.passAttempt = 'bob:hunter2';
|
|
1740
|
+
const accounts = new FakeAccountStore({ ok: true, account: 'bob' });
|
|
1741
|
+
const effects = emitWelcomeIfReady(state, makeCtxPassAuth(state, { accounts }));
|
|
1742
|
+
|
|
1743
|
+
expect(state.account).toBeUndefined();
|
|
1744
|
+
expect(state.registration).toBe('registered');
|
|
1745
|
+
// The store is never consulted on a nick mismatch.
|
|
1746
|
+
expect(accounts.calls).toHaveLength(0);
|
|
1747
|
+
expect(sentTexts(effects).some((t) => t.includes(' 900 '))).toBe(false);
|
|
1748
|
+
});
|
|
1749
|
+
});
|
|
1750
|
+
|
|
1751
|
+
describe('emitWelcomeIfReady — PASS-auth interaction with the server-password gate', () => {
|
|
1752
|
+
it('ignores the <nick>:<password> form when no AccountStore is bound (open gate)', () => {
|
|
1753
|
+
const state = makeState();
|
|
1754
|
+
state.nick = 'alice';
|
|
1755
|
+
state.user = 'alice';
|
|
1756
|
+
state.passAttempt = 'alice:hunter2';
|
|
1757
|
+
const effects = emitWelcomeIfReady(state, makeCtxPassAuth(state, {}));
|
|
1758
|
+
|
|
1759
|
+
expect(state.account).toBeUndefined();
|
|
1760
|
+
expect(state.registration).toBe('registered');
|
|
1761
|
+
expect(sentTexts(effects).some((t) => t.includes(' 464 '))).toBe(false);
|
|
1762
|
+
});
|
|
1763
|
+
|
|
1764
|
+
it('collapses to the server-password gate (464) when no AccountStore is bound and the colon form does not match', () => {
|
|
1765
|
+
const state = makeState();
|
|
1766
|
+
state.nick = 'alice';
|
|
1767
|
+
state.user = 'alice';
|
|
1768
|
+
state.passAttempt = 'alice:hunter2';
|
|
1769
|
+
const effects = emitWelcomeIfReady(state, makeCtxPassAuth(state, { serverPassword: 's3cret' }));
|
|
1770
|
+
|
|
1771
|
+
expect(state.registration).not.toBe('registered');
|
|
1772
|
+
expect(effects).toEqual<EffectType[]>([
|
|
1773
|
+
Effect.send('c1', [L2(`:${SV} 464 alice :Password mismatch`)]),
|
|
1774
|
+
Effect.disconnect('c1', 'Bad Password'),
|
|
1775
|
+
]);
|
|
1776
|
+
});
|
|
1777
|
+
|
|
1778
|
+
it('precedence: a server password configured AND a <nick>:<password> that fails verify → 464 + disconnect', () => {
|
|
1779
|
+
const state = makeState();
|
|
1780
|
+
state.nick = 'alice';
|
|
1781
|
+
state.user = 'alice';
|
|
1782
|
+
state.passAttempt = 'alice:wrong';
|
|
1783
|
+
const accounts = new FakeAccountStore({ ok: false, reason: 'invalid credentials' });
|
|
1784
|
+
const effects = emitWelcomeIfReady(
|
|
1785
|
+
state,
|
|
1786
|
+
makeCtxPassAuth(state, { accounts, serverPassword: 's3cret' }),
|
|
1787
|
+
);
|
|
1788
|
+
|
|
1789
|
+
expect(state.registration).not.toBe('registered');
|
|
1790
|
+
expect(state.account).toBeUndefined();
|
|
1791
|
+
expect(effects).toEqual<EffectType[]>([
|
|
1792
|
+
Effect.send('c1', [L2(`:${SV} 464 alice :Password mismatch`)]),
|
|
1793
|
+
Effect.disconnect('c1', 'Bad Password'),
|
|
1794
|
+
]);
|
|
1795
|
+
});
|
|
1796
|
+
|
|
1797
|
+
it('does not re-run PASS-auth when the connection already identified via SASL', () => {
|
|
1798
|
+
const state = makeState();
|
|
1799
|
+
state.nick = 'alice';
|
|
1800
|
+
state.user = 'alice';
|
|
1801
|
+
state.account = 'alice'; // SASL-identified before registration completion
|
|
1802
|
+
state.passAttempt = 'alice:hunter2';
|
|
1803
|
+
const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
|
|
1804
|
+
const effects = emitWelcomeIfReady(state, makeCtxPassAuth(state, { accounts }));
|
|
1805
|
+
|
|
1806
|
+
expect(accounts.calls).toHaveLength(0);
|
|
1807
|
+
expect(state.registration).toBe('registered');
|
|
1808
|
+
// No 900/903 are re-emitted by PASS-auth; SASL already emitted them.
|
|
1809
|
+
expect(sentTexts(effects).some((t) => t.includes(' 900 '))).toBe(false);
|
|
1810
|
+
});
|
|
1811
|
+
|
|
1812
|
+
it('treats a bare PASS value (no colon) as a server-password candidate, never an account attempt', () => {
|
|
1813
|
+
const state = makeState();
|
|
1814
|
+
state.nick = 'alice';
|
|
1815
|
+
state.user = 'alice';
|
|
1816
|
+
state.passAttempt = 's3cret';
|
|
1817
|
+
const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
|
|
1818
|
+
const effects = emitWelcomeIfReady(
|
|
1819
|
+
state,
|
|
1820
|
+
makeCtxPassAuth(state, { accounts, serverPassword: 's3cret' }),
|
|
1821
|
+
);
|
|
1822
|
+
|
|
1823
|
+
expect(accounts.calls).toHaveLength(0);
|
|
1824
|
+
expect(state.account).toBeUndefined();
|
|
1825
|
+
expect(state.registration).toBe('registered');
|
|
1826
|
+
expect(sentTexts(effects).some((t) => t.includes(' 464 '))).toBe(false);
|
|
1827
|
+
});
|
|
1828
|
+
});
|
|
1829
|
+
|
|
1830
|
+
describe('passBasedAccountAuth — unit (PASS before NICK, parsing, edge cases)', () => {
|
|
1831
|
+
it('uses the payload nick (not state.nick) when PASS arrives before NICK', () => {
|
|
1832
|
+
const state = makeState();
|
|
1833
|
+
state.user = 'alice';
|
|
1834
|
+
// state.nick intentionally undefined — PASS arrived before NICK.
|
|
1835
|
+
state.passAttempt = 'alice:hunter2';
|
|
1836
|
+
const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
|
|
1837
|
+
const effects = passBasedAccountAuth(state, makeCtxPassAuth(state, { accounts }));
|
|
1838
|
+
|
|
1839
|
+
expect(state.account).toBe('alice');
|
|
1840
|
+
expect(effects.some((e) => e.tag === 'Send')).toBe(true);
|
|
1841
|
+
});
|
|
1842
|
+
|
|
1843
|
+
it('returns no effects when no PASS was sent', () => {
|
|
1844
|
+
const state = makeState();
|
|
1845
|
+
state.nick = 'alice';
|
|
1846
|
+
const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
|
|
1847
|
+
expect(passBasedAccountAuth(state, makeCtxPassAuth(state, { accounts }))).toEqual([]);
|
|
1848
|
+
});
|
|
1849
|
+
|
|
1850
|
+
it('returns no effects for a bare PASS value (no colon)', () => {
|
|
1851
|
+
const state = makeState();
|
|
1852
|
+
state.nick = 'alice';
|
|
1853
|
+
state.passAttempt = 's3cret';
|
|
1854
|
+
const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
|
|
1855
|
+
expect(passBasedAccountAuth(state, makeCtxPassAuth(state, { accounts }))).toEqual([]);
|
|
1856
|
+
expect(accounts.calls).toHaveLength(0);
|
|
1857
|
+
});
|
|
1858
|
+
|
|
1859
|
+
it('returns no effects when the nick portion is empty (`:password`)', () => {
|
|
1860
|
+
const state = makeState();
|
|
1861
|
+
state.nick = 'alice';
|
|
1862
|
+
state.passAttempt = ':hunter2';
|
|
1863
|
+
const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
|
|
1864
|
+
expect(passBasedAccountAuth(state, makeCtxPassAuth(state, { accounts }))).toEqual([]);
|
|
1865
|
+
expect(accounts.calls).toHaveLength(0);
|
|
1866
|
+
});
|
|
1867
|
+
|
|
1868
|
+
it('splits only on the first colon so a password may contain colons', () => {
|
|
1869
|
+
const state = makeState();
|
|
1870
|
+
state.nick = 'alice';
|
|
1871
|
+
state.user = 'alice';
|
|
1872
|
+
state.passAttempt = 'alice:p:ass:word';
|
|
1873
|
+
const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
|
|
1874
|
+
passBasedAccountAuth(state, makeCtxPassAuth(state, { accounts }));
|
|
1875
|
+
expect(accounts.calls[0]?.payload).toEqual({
|
|
1876
|
+
kind: 'PLAIN',
|
|
1877
|
+
username: 'alice',
|
|
1878
|
+
password: 'p:ass:word',
|
|
1879
|
+
});
|
|
1880
|
+
});
|
|
1881
|
+
|
|
1882
|
+
it('returns no effects when no AccountStore is bound', () => {
|
|
1883
|
+
const state = makeState();
|
|
1884
|
+
state.nick = 'alice';
|
|
1885
|
+
state.passAttempt = 'alice:hunter2';
|
|
1886
|
+
expect(passBasedAccountAuth(state, makeCtxPassAuth(state, {}))).toEqual([]);
|
|
1887
|
+
});
|
|
1888
|
+
});
|
|
1889
|
+
|
|
1890
|
+
describe('parsePassAccountAttempt', () => {
|
|
1891
|
+
it('returns null when the attempt is undefined', () => {
|
|
1892
|
+
expect(parsePassAccountAttempt(undefined)).toBeNull();
|
|
1893
|
+
});
|
|
1894
|
+
|
|
1895
|
+
it('returns null when there is no colon', () => {
|
|
1896
|
+
expect(parsePassAccountAttempt('s3cret')).toBeNull();
|
|
1897
|
+
});
|
|
1898
|
+
|
|
1899
|
+
it('returns null when the nick portion is empty', () => {
|
|
1900
|
+
expect(parsePassAccountAttempt(':hunter2')).toBeNull();
|
|
1901
|
+
});
|
|
1902
|
+
|
|
1903
|
+
it('parses <nick>:<password> into its parts', () => {
|
|
1904
|
+
expect(parsePassAccountAttempt('alice:hunter2')).toEqual({
|
|
1905
|
+
nick: 'alice',
|
|
1906
|
+
password: 'hunter2',
|
|
1907
|
+
});
|
|
1908
|
+
});
|
|
1909
|
+
|
|
1910
|
+
it('keeps colons in the password (splits on the first colon only)', () => {
|
|
1911
|
+
expect(parsePassAccountAttempt('alice:p:w')).toEqual({
|
|
1912
|
+
nick: 'alice',
|
|
1913
|
+
password: 'p:w',
|
|
1914
|
+
});
|
|
1915
|
+
});
|
|
1916
|
+
});
|
|
1917
|
+
|
|
1918
|
+
describe('applyAccountSuccess — shared account-login helper', () => {
|
|
1919
|
+
it('broadcasts ACCOUNT to account-notify-capable peers in shared channels', () => {
|
|
1920
|
+
const state = registeredState();
|
|
1921
|
+
state.caps.add('account-notify');
|
|
1922
|
+
state.joinedChannels.add('#foo');
|
|
1923
|
+
const ctx = makeCtx(state);
|
|
1924
|
+
const effects = applyAccountSuccess(state, ctx, 'alice');
|
|
1925
|
+
|
|
1926
|
+
const broadcasts = effects.filter((e) => e.tag === 'Broadcast');
|
|
1927
|
+
expect(broadcasts).toHaveLength(1);
|
|
1928
|
+
expect(state.account).toBe('alice');
|
|
1929
|
+
expect(state.userModes.registered).toBe(true);
|
|
1930
|
+
});
|
|
1931
|
+
|
|
1932
|
+
it('emits the 900 RPL_LOGGEDIN and 903 RPL_SASLSUCCESS numerics', () => {
|
|
1933
|
+
const state = registeredState();
|
|
1934
|
+
const ctx = makeCtx(state);
|
|
1935
|
+
const effects = applyAccountSuccess(state, ctx, 'alice');
|
|
1936
|
+
const texts = sentTexts(effects);
|
|
1937
|
+
expect(texts).toContain(
|
|
1938
|
+
`:${SV} 900 alice alice!alice@example.com alice :You are now logged in as alice`,
|
|
1939
|
+
);
|
|
1940
|
+
expect(texts).toContain(`:${SV} 903 alice :SASL authentication successful`);
|
|
1941
|
+
});
|
|
1942
|
+
});
|