serverless-ircd 0.3.0 → 0.5.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 +2 -2
- package/.github/workflows/deploy-aws.yml +1 -3
- package/.github/workflows/deploy-cf-tcp.yml +87 -0
- package/.github/workflows/deploy-cf.yml +1 -1
- package/.node-version +1 -0
- package/.nvmrc +1 -0
- package/CHANGELOG.md +349 -18
- package/README.md +132 -30
- package/apps/aws-stack/README.md +6 -5
- package/apps/aws-stack/bin/aws.ts +7 -0
- package/apps/aws-stack/package.json +4 -4
- package/apps/aws-stack/src/aws-stack.ts +150 -10
- package/apps/aws-stack/tests/stack.test.ts +145 -4
- package/apps/cf-tcp-container/Dockerfile +69 -0
- package/apps/cf-tcp-container/package.json +34 -0
- package/apps/cf-tcp-container/src/config-loader.ts +145 -0
- package/apps/cf-tcp-container/src/container-do.ts +38 -0
- package/apps/cf-tcp-container/src/container-server.ts +363 -0
- package/apps/cf-tcp-container/src/main.ts +77 -0
- package/apps/cf-tcp-container/src/persistence.ts +144 -0
- package/apps/cf-tcp-container/src/worker.ts +41 -0
- package/apps/cf-tcp-container/terraform/provider.tf +24 -0
- package/apps/cf-tcp-container/terraform/spectrum.tf +81 -0
- package/apps/cf-tcp-container/tests/config-loader.test.ts +217 -0
- package/apps/cf-tcp-container/tests/container-server.test.ts +465 -0
- package/apps/cf-tcp-container/tests/persistence.test.ts +227 -0
- package/apps/cf-tcp-container/tests/tls-e2e.test.ts +275 -0
- package/apps/cf-tcp-container/tsconfig.build.json +17 -0
- package/apps/cf-tcp-container/tsconfig.test.json +15 -0
- package/apps/cf-tcp-container/vitest.config.ts +26 -0
- package/apps/cf-tcp-container/wrangler.toml +63 -0
- package/apps/cf-worker/package.json +1 -1
- package/apps/cf-worker/wrangler.test.toml +6 -0
- package/apps/cf-worker/wrangler.toml +28 -2
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/config-loader.ts +10 -0
- package/apps/local-cli/src/server.ts +149 -24
- package/apps/local-cli/tests/e2e.test.ts +1 -1
- package/apps/local-cli/tests/ws-subprotocol.test.ts +257 -0
- package/package.json +14 -10
- package/packages/aws-adapter/package.json +3 -3
- package/packages/aws-adapter/src/account-store.ts +1 -1
- package/packages/aws-adapter/src/admission.ts +74 -0
- package/packages/aws-adapter/src/aws-runtime.ts +75 -4
- package/packages/aws-adapter/src/config-loader.ts +32 -0
- package/packages/aws-adapter/src/dynamo-account-store.ts +35 -96
- package/packages/aws-adapter/src/handlers/connect.ts +96 -9
- package/packages/aws-adapter/src/handlers/default.ts +98 -7
- package/packages/aws-adapter/src/handlers/index.ts +109 -4
- package/packages/aws-adapter/src/handlers/nlb-stream.ts +490 -0
- package/packages/aws-adapter/src/handlers/ping-checker.ts +1 -1
- package/packages/aws-adapter/src/index.ts +8 -0
- package/packages/aws-adapter/src/serialize.ts +11 -1
- package/packages/aws-adapter/src/stats.ts +80 -0
- package/packages/aws-adapter/tests/account-store-dynamo.test.ts +45 -34
- package/packages/aws-adapter/tests/account-store.test.ts +19 -20
- package/packages/aws-adapter/tests/admission.test.ts +70 -0
- package/packages/aws-adapter/tests/aws-harness.ts +13 -1
- package/packages/aws-adapter/tests/aws-integration.test.ts +1 -1
- package/packages/aws-adapter/tests/aws-runtime.test.ts +61 -0
- package/packages/aws-adapter/tests/config-loader.test.ts +20 -0
- package/packages/aws-adapter/tests/connect.test.ts +174 -0
- package/packages/aws-adapter/tests/disconnect-fanout.test.ts +47 -40
- package/packages/aws-adapter/tests/gone-exception.test.ts +31 -26
- package/packages/aws-adapter/tests/handlers.test.ts +302 -53
- package/packages/aws-adapter/tests/nlb-stream.test.ts +480 -0
- package/packages/aws-adapter/tests/ping-checker.test.ts +34 -29
- package/packages/aws-adapter/tests/stats.test.ts +317 -0
- package/packages/aws-adapter/tests/sweeper.test.ts +25 -18
- package/packages/aws-adapter/tests/transactions.test.ts +25 -20
- package/packages/cf-adapter/package.json +5 -1
- package/packages/cf-adapter/src/cf-runtime.ts +68 -5
- package/packages/cf-adapter/src/channel-do.ts +2 -2
- package/packages/cf-adapter/src/config-loader.ts +33 -0
- package/packages/cf-adapter/src/connection-do.ts +278 -85
- package/packages/cf-adapter/src/d1-account-store.ts +198 -0
- package/packages/cf-adapter/src/env.ts +54 -11
- package/packages/cf-adapter/src/index.ts +11 -8
- package/packages/cf-adapter/src/registry-do.ts +22 -3
- package/packages/cf-adapter/src/sharding.ts +1 -2
- package/packages/cf-adapter/src/stats.ts +65 -0
- package/packages/cf-adapter/tests/cf-harness.ts +12 -2
- package/packages/cf-adapter/tests/cf-integration.test.ts +6 -5
- package/packages/cf-adapter/tests/cf-runtime.test.ts +38 -2
- package/packages/cf-adapter/tests/channel-do.test.ts +2 -2
- package/packages/cf-adapter/tests/config-loader.test.ts +22 -0
- package/packages/cf-adapter/tests/connection-do-channel-registration.test.ts +37 -0
- package/packages/cf-adapter/tests/connection-do-no-batching-reservation.test.ts +52 -0
- package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +166 -0
- package/packages/cf-adapter/tests/connection-do-ws-spec-contract.test.ts +289 -0
- package/packages/cf-adapter/tests/connection-do-ws-subprotocol.test.ts +184 -0
- package/packages/cf-adapter/tests/connection-do.test.ts +27 -2
- package/packages/cf-adapter/tests/d1-account-store.test.ts +226 -0
- package/packages/cf-adapter/tests/raw-modules.d.ts +11 -0
- package/packages/cf-adapter/tests/registry-do.test.ts +4 -4
- package/packages/cf-adapter/tests/sharding.test.ts +1 -1
- package/packages/cf-adapter/tests/stats.test.ts +120 -0
- package/packages/cf-adapter/tests/worker/main.ts +15 -8
- package/packages/cf-adapter/tests/worker/stubs/channel-stub.ts +2 -2
- package/packages/cf-adapter/tests/worker/stubs/registry-stub.ts +8 -2
- package/packages/cf-adapter/wrangler.test.toml +15 -0
- package/packages/in-memory-runtime/package.json +1 -1
- package/packages/in-memory-runtime/src/in-memory-runtime.ts +39 -0
- package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +259 -0
- package/packages/irc-core/package.json +6 -1
- package/packages/irc-core/scripts/generate-build-info.mjs +31 -0
- package/packages/irc-core/src/admission.ts +16 -15
- package/packages/irc-core/src/caps/capabilities.ts +24 -3
- package/packages/irc-core/src/cloak.ts +1 -1
- package/packages/irc-core/src/commands/cap.ts +8 -1
- package/packages/irc-core/src/commands/index.ts +17 -0
- package/packages/irc-core/src/commands/invite.ts +2 -4
- package/packages/irc-core/src/commands/ison.ts +61 -0
- package/packages/irc-core/src/commands/isupport.ts +6 -2
- package/packages/irc-core/src/commands/kick.ts +2 -4
- package/packages/irc-core/src/commands/kill.ts +127 -0
- package/packages/irc-core/src/commands/list.ts +1 -1
- package/packages/irc-core/src/commands/lusers.ts +204 -0
- package/packages/irc-core/src/commands/mode.ts +4 -8
- package/packages/irc-core/src/commands/names.ts +3 -5
- package/packages/irc-core/src/commands/part.ts +2 -4
- package/packages/irc-core/src/commands/quit.ts +12 -0
- package/packages/irc-core/src/commands/registration.ts +18 -12
- package/packages/irc-core/src/commands/rehash.ts +119 -0
- package/packages/irc-core/src/commands/sasl.ts +72 -9
- package/packages/irc-core/src/commands/server-info.ts +129 -0
- package/packages/irc-core/src/commands/setname.ts +109 -0
- package/packages/irc-core/src/commands/stats.ts +152 -0
- package/packages/irc-core/src/commands/topic.ts +2 -4
- package/packages/irc-core/src/commands/trace.ts +137 -0
- package/packages/irc-core/src/commands/userhost.ts +84 -0
- package/packages/irc-core/src/commands/wallops.ts +118 -0
- package/packages/irc-core/src/commands/whowas.ts +113 -0
- package/packages/irc-core/src/config.ts +65 -0
- package/packages/irc-core/src/credential-hashing.ts +124 -0
- package/packages/irc-core/src/effects.ts +33 -30
- package/packages/irc-core/src/index.ts +3 -0
- package/packages/irc-core/src/ports.ts +360 -12
- package/packages/irc-core/src/protocol/numerics.ts +48 -11
- package/packages/irc-core/src/protocol/outbound.ts +20 -3
- package/packages/irc-core/src/types.ts +46 -2
- package/packages/irc-core/src/ws-framing.ts +132 -0
- package/packages/irc-core/src/ws-subprotocol.ts +66 -0
- package/packages/irc-core/tests/account-store.test.ts +45 -2
- package/packages/irc-core/tests/admission.test.ts +18 -0
- package/packages/irc-core/tests/caps/capabilities.test.ts +4 -3
- package/packages/irc-core/tests/commands/cap.test.ts +33 -1
- package/packages/irc-core/tests/commands/ison.test.ts +166 -0
- package/packages/irc-core/tests/commands/kill.test.ts +243 -0
- package/packages/irc-core/tests/commands/lusers.test.ts +368 -0
- package/packages/irc-core/tests/commands/mode.test.ts +57 -0
- package/packages/irc-core/tests/commands/quit.test.ts +69 -2
- package/packages/irc-core/tests/commands/registration.test.ts +151 -6
- package/packages/irc-core/tests/commands/rehash.test.ts +171 -0
- package/packages/irc-core/tests/commands/sasl.test.ts +118 -10
- package/packages/irc-core/tests/commands/server-info.test.ts +274 -0
- package/packages/irc-core/tests/commands/setname.test.ts +225 -0
- package/packages/irc-core/tests/commands/stats.test.ts +294 -0
- package/packages/irc-core/tests/commands/tagmsg.test.ts +9 -35
- package/packages/irc-core/tests/commands/trace.test.ts +282 -0
- package/packages/irc-core/tests/commands/userhost.test.ts +264 -0
- package/packages/irc-core/tests/commands/wallops.test.ts +231 -0
- package/packages/irc-core/tests/commands/whowas.test.ts +312 -0
- package/packages/irc-core/tests/config.test.ts +95 -1
- package/packages/irc-core/tests/credential-hashing.test.ts +170 -0
- package/packages/irc-core/tests/dropped-s2s-and-obsolete-verbs.test.ts +90 -0
- package/packages/irc-core/tests/effects.test.ts +14 -27
- package/packages/irc-core/tests/nick-history-store.test.ts +162 -0
- package/packages/irc-core/tests/numerics.test.ts +102 -0
- package/packages/irc-core/tests/outbound.test.ts +51 -0
- package/packages/irc-core/tests/ports.test.ts +22 -0
- package/packages/irc-core/tests/raw-modules.d.ts +11 -0
- package/packages/irc-core/tests/stats-store.test.ts +222 -0
- package/packages/irc-core/tests/types.test.ts +35 -1
- package/packages/irc-core/tests/ws-framing.test.ts +213 -0
- package/packages/irc-core/tests/ws-subprotocol.test.ts +111 -0
- package/packages/irc-core/tsconfig.build.json +1 -1
- package/packages/irc-core/tsconfig.test.json +1 -1
- package/packages/irc-server/package.json +1 -1
- package/packages/irc-server/src/actor.ts +393 -16
- package/packages/irc-server/src/dispatch.ts +1 -3
- package/packages/irc-server/src/index.ts +10 -2
- package/packages/irc-server/src/routing.ts +15 -0
- package/packages/irc-server/src/runtime.ts +31 -0
- package/packages/irc-server/src/transport.ts +104 -0
- package/packages/irc-server/tests/actor.test.ts +1489 -4
- package/packages/irc-server/tests/dispatch.test.ts +37 -17
- package/packages/irc-server/tests/raw-modules.d.ts +11 -0
- package/packages/irc-server/tests/routing.test.ts +5 -0
- package/packages/irc-server/tests/runtime.test.ts +7 -0
- package/packages/irc-server/tests/transport.test.ts +230 -0
- package/packages/irc-test-support/package.json +1 -1
- package/packages/irc-test-support/src/harness.ts +44 -9
- package/packages/irc-test-support/src/in-memory-harness.ts +73 -9
- package/packages/irc-test-support/src/index.ts +3 -0
- package/packages/irc-test-support/src/scenarios.ts +141 -3
- package/packages/irc-test-support/tests/in-memory-harness.test.ts +2 -1
- package/packages/irc-test-support/tests/in-memory-scenarios.test.ts +23 -9
- package/pnpm-workspace.yaml +10 -1
- package/tools/ci-hardening/package.json +1 -1
- package/tools/package.json +9 -0
- package/tools/seed-aws-accounts.ts +5 -6
- package/tools/seed-cf-accounts.ts +107 -0
- package/tools/tcp-ws-forwarder/package.json +1 -1
- package/tools/tcp-ws-forwarder/src/forwarder.ts +57 -9
- package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +34 -1
- package/tools/tcp-ws-forwarder/tests/framing.test.ts +65 -1
- package/docs/ADR-001-pure-reducers-and-effect-system.md +0 -74
- package/docs/ADR-002-location-of-authority.md +0 -82
- package/docs/ADR-003-durable-object-sharding.md +0 -93
- package/docs/ADR-004-dynamodb-schema.md +0 -96
- package/docs/ADR-005-wss-only-transport-v1.md +0 -83
- package/docs/ADR-006-sasl-mechanism-scope.md +0 -86
- package/docs/ADR-007-deterministic-ports.md +0 -82
- package/docs/ADR-008-monorepo-tooling.md +0 -60
- package/docs/AWS-Adapter-Architecture.md +0 -496
- package/docs/AWS-Deployment.md +0 -1186
- package/docs/Cloudflare-Deployment-Guide.md +0 -660
- package/docs/Home.md +0 -11
- package/docs/Observability.md +0 -87
- package/docs/PlanIRCv3Websocket.md +0 -489
- package/docs/PlanWebClient.md +0 -451
- package/docs/Release-Process.md +0 -443
|
@@ -12,16 +12,21 @@ import {
|
|
|
12
12
|
FakeClock,
|
|
13
13
|
type FloodControlConfig,
|
|
14
14
|
InMemoryMessageStore,
|
|
15
|
+
InMemoryNickHistoryStore,
|
|
15
16
|
LogLevel,
|
|
16
17
|
type Logger,
|
|
17
18
|
type MessageStore,
|
|
19
|
+
type MtlsIdentityProvider,
|
|
18
20
|
type Nick,
|
|
21
|
+
type NickHistoryStore,
|
|
19
22
|
Numerics,
|
|
20
23
|
type RawLine,
|
|
21
24
|
type SaslPayload,
|
|
22
25
|
type SaslResult,
|
|
23
26
|
SequentialIdFactory,
|
|
24
27
|
type ServerConfig,
|
|
28
|
+
type ServerStats,
|
|
29
|
+
type ServerStatsSnapshot,
|
|
25
30
|
type StoredMessage,
|
|
26
31
|
applyChannelDelta as applyDelta,
|
|
27
32
|
createChannel,
|
|
@@ -32,8 +37,12 @@ import {
|
|
|
32
37
|
toSnapshot,
|
|
33
38
|
} from '@serverless-ircd/irc-core';
|
|
34
39
|
import { describe, expect, it } from 'vitest';
|
|
35
|
-
import { ConnectionActor } from '../src/actor';
|
|
40
|
+
import { ConnectionActor, channelTargetsForMessage } from '../src/actor';
|
|
41
|
+
import type { ActorChannelAccess } from '../src/actor';
|
|
42
|
+
import actorSource from '../src/actor.ts?raw';
|
|
36
43
|
import type { IrcRuntime } from '../src/runtime';
|
|
44
|
+
import { TcpByteStreamTransport, WsTextFrameTransport } from '../src/transport';
|
|
45
|
+
import type { Transport } from '../src/transport';
|
|
37
46
|
|
|
38
47
|
// ---------------------------------------------------------------------------
|
|
39
48
|
// Fakes
|
|
@@ -49,6 +58,14 @@ class FakeRuntime implements IrcRuntime {
|
|
|
49
58
|
private readonly channels = new Map<string, ChannelState>();
|
|
50
59
|
private readonly testConnections = new Map<ConnId, ConnectionState>();
|
|
51
60
|
private readonly nicks = new Map<string, ConnId>();
|
|
61
|
+
/**
|
|
62
|
+
* Configurable `reloadConfig` result. When set, the next `reloadConfig()`
|
|
63
|
+
* call returns this value; when `reloadConfigError` is set instead it
|
|
64
|
+
* throws. Leaving both unset throws so a test that drives `REHASH` without
|
|
65
|
+
* configuring the fake fails loudly.
|
|
66
|
+
*/
|
|
67
|
+
reloadConfigResult: ServerConfig | undefined;
|
|
68
|
+
reloadConfigError: unknown;
|
|
52
69
|
|
|
53
70
|
constructor(private readonly clock: { now(): number }) {}
|
|
54
71
|
|
|
@@ -59,6 +76,16 @@ class FakeRuntime implements IrcRuntime {
|
|
|
59
76
|
}
|
|
60
77
|
}
|
|
61
78
|
|
|
79
|
+
/**
|
|
80
|
+
* Drops a connection from the live-connection store while leaving its nick
|
|
81
|
+
* registered. Simulates the race a reducer sees when `lookupNick` resolves
|
|
82
|
+
* a nick but `getConnection` returns null (the connection tore down between
|
|
83
|
+
* the two awaits).
|
|
84
|
+
*/
|
|
85
|
+
dropConnection(id: ConnId): void {
|
|
86
|
+
this.testConnections.delete(id);
|
|
87
|
+
}
|
|
88
|
+
|
|
62
89
|
// Channel-state access (used by the actor's `channels` dep).
|
|
63
90
|
getOrCreateChannel(name: ChanName): ChannelState {
|
|
64
91
|
const key = name.toLowerCase();
|
|
@@ -79,6 +106,9 @@ class FakeRuntime implements IrcRuntime {
|
|
|
79
106
|
async broadcast(chan: ChanName, lines: RawLine[], except?: ConnId): Promise<void> {
|
|
80
107
|
this.calls.push({ method: 'broadcast', args: [chan, lines, except] });
|
|
81
108
|
}
|
|
109
|
+
async broadcastWallops(lines: RawLine[], except?: ConnId): Promise<void> {
|
|
110
|
+
this.calls.push({ method: 'broadcastWallops', args: [lines, except] });
|
|
111
|
+
}
|
|
82
112
|
async disconnect(conn: ConnId, reason?: string): Promise<void> {
|
|
83
113
|
this.calls.push({ method: 'disconnect', args: [conn, reason] });
|
|
84
114
|
}
|
|
@@ -151,6 +181,15 @@ class FakeRuntime implements IrcRuntime {
|
|
|
151
181
|
}
|
|
152
182
|
return out;
|
|
153
183
|
}
|
|
184
|
+
|
|
185
|
+
async reloadConfig(): Promise<ServerConfig> {
|
|
186
|
+
this.calls.push({ method: 'reloadConfig', args: [] });
|
|
187
|
+
if (this.reloadConfigError !== undefined) throw this.reloadConfigError;
|
|
188
|
+
if (this.reloadConfigResult === undefined) {
|
|
189
|
+
throw new Error('FakeRuntime.reloadConfigResult not configured');
|
|
190
|
+
}
|
|
191
|
+
return this.reloadConfigResult;
|
|
192
|
+
}
|
|
154
193
|
}
|
|
155
194
|
|
|
156
195
|
function makeActor(opts: {
|
|
@@ -161,7 +200,12 @@ function makeActor(opts: {
|
|
|
161
200
|
traceId?: string;
|
|
162
201
|
messages?: MessageStore;
|
|
163
202
|
accounts?: AccountStore;
|
|
203
|
+
mtlsIdentity?: MtlsIdentityProvider;
|
|
204
|
+
history?: NickHistoryStore;
|
|
205
|
+
stats?: ServerStats;
|
|
164
206
|
operCreds?: ReadonlyArray<{ user: string; password: string }>;
|
|
207
|
+
transport?: Transport;
|
|
208
|
+
configSource?: string;
|
|
165
209
|
}): { actor: ConnectionActor; runtime: FakeRuntime; conn: ConnectionState } {
|
|
166
210
|
const clock = opts.clock ?? new FakeClock(1_000);
|
|
167
211
|
const conn = createConnection({ id: 'c1', connectedSince: 0 });
|
|
@@ -215,6 +259,21 @@ function makeActor(opts: {
|
|
|
215
259
|
if (opts.accounts !== undefined) {
|
|
216
260
|
finalOpts.accounts = opts.accounts;
|
|
217
261
|
}
|
|
262
|
+
if (opts.mtlsIdentity !== undefined) {
|
|
263
|
+
finalOpts.mtlsIdentity = opts.mtlsIdentity;
|
|
264
|
+
}
|
|
265
|
+
if (opts.history !== undefined) {
|
|
266
|
+
finalOpts.history = opts.history;
|
|
267
|
+
}
|
|
268
|
+
if (opts.stats !== undefined) {
|
|
269
|
+
finalOpts.stats = opts.stats;
|
|
270
|
+
}
|
|
271
|
+
if (opts.transport !== undefined) {
|
|
272
|
+
finalOpts.transport = opts.transport;
|
|
273
|
+
}
|
|
274
|
+
if (opts.configSource !== undefined) {
|
|
275
|
+
finalOpts.configSource = opts.configSource;
|
|
276
|
+
}
|
|
218
277
|
const actor = new ConnectionActor(finalOpts);
|
|
219
278
|
return { actor, runtime, conn };
|
|
220
279
|
}
|
|
@@ -278,6 +337,112 @@ describe('ConnectionActor — framing', () => {
|
|
|
278
337
|
});
|
|
279
338
|
});
|
|
280
339
|
|
|
340
|
+
describe('ConnectionActor — TCP byte-stream transport seam', () => {
|
|
341
|
+
it('two TCP chunks split mid-line produce exactly one PRIVMSG broadcast', async () => {
|
|
342
|
+
const { actor, runtime } = makeActor({ transport: new TcpByteStreamTransport() });
|
|
343
|
+
await runtime.applyChannelDelta('#foo', {
|
|
344
|
+
memberships: [{ type: 'add', conn: 'c1', nick: 'alice' }],
|
|
345
|
+
});
|
|
346
|
+
runtime.calls.length = 0;
|
|
347
|
+
|
|
348
|
+
// Split the single message mid-payload across two chunks. The partial
|
|
349
|
+
// line is buffered across the first chunk; only the second completes it.
|
|
350
|
+
await actor.receive('PRIVMSG #foo :hel');
|
|
351
|
+
expect(runtime.calls).toHaveLength(0);
|
|
352
|
+
await actor.receive('lo\r\n');
|
|
353
|
+
|
|
354
|
+
const broadcasts = runtime.calls.filter((c) => c.method === 'broadcast');
|
|
355
|
+
expect(broadcasts).toHaveLength(1);
|
|
356
|
+
expect(broadcasts[0]?.args[0]).toBe('#foo');
|
|
357
|
+
const lines = broadcasts[0]?.args[1] as RawLine[];
|
|
358
|
+
expect(lines[0]?.text).toBe(':alice!alice@example.com PRIVMSG #foo :hello');
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
it('one message per WS text frame and one per CRLF over TCP produce identical effects', async () => {
|
|
362
|
+
// Same bytes, same connection setup, two transports. The WS actor gets
|
|
363
|
+
// the whole frame in one receiveTextFrame; the TCP actor gets the same
|
|
364
|
+
// bytes split across arbitrary chunks via receive().
|
|
365
|
+
const wsSetup = makeActor({});
|
|
366
|
+
const tcpSetup = makeActor({ transport: new TcpByteStreamTransport() });
|
|
367
|
+
for (const rt of [wsSetup.runtime, tcpSetup.runtime]) {
|
|
368
|
+
await rt.applyChannelDelta('#foo', {
|
|
369
|
+
memberships: [{ type: 'add', conn: 'c1', nick: 'alice' }],
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
wsSetup.runtime.calls.length = 0;
|
|
373
|
+
tcpSetup.runtime.calls.length = 0;
|
|
374
|
+
|
|
375
|
+
const stream = 'PRIVMSG #foo :hello\r\n';
|
|
376
|
+
await wsSetup.actor.receiveTextFrame(stream);
|
|
377
|
+
// Feed the TCP actor the same bytes in 4-byte chunks.
|
|
378
|
+
for (let i = 0; i < stream.length; i += 4) {
|
|
379
|
+
await tcpSetup.actor.receive(stream.slice(i, i + 4));
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
const wsBroadcast = wsSetup.runtime.calls.filter((c) => c.method === 'broadcast');
|
|
383
|
+
const tcpBroadcast = tcpSetup.runtime.calls.filter((c) => c.method === 'broadcast');
|
|
384
|
+
expect(tcpBroadcast).toHaveLength(wsBroadcast.length);
|
|
385
|
+
expect((tcpBroadcast[0]?.args[1] as RawLine[])[0]?.text).toBe(
|
|
386
|
+
(wsBroadcast[0]?.args[1] as RawLine[])[0]?.text,
|
|
387
|
+
);
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
it('buffers a partial line across multiple chunks with no loss or duplication', async () => {
|
|
391
|
+
const { actor, runtime } = makeActor({ transport: new TcpByteStreamTransport() });
|
|
392
|
+
await runtime.applyChannelDelta('#foo', {
|
|
393
|
+
memberships: [{ type: 'add', conn: 'c1', nick: 'alice' }],
|
|
394
|
+
});
|
|
395
|
+
runtime.calls.length = 0;
|
|
396
|
+
|
|
397
|
+
// Three messages, each fed byte-by-byte. Every complete line is emitted
|
|
398
|
+
// exactly once; nothing is lost or duplicated at the chunk boundaries.
|
|
399
|
+
const stream = 'PING :a\r\nPRIVMSG #foo :b\r\nPING :c\r\n';
|
|
400
|
+
for (const ch of stream) {
|
|
401
|
+
await actor.receive(ch);
|
|
402
|
+
}
|
|
403
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
404
|
+
const broadcasts = runtime.calls.filter((c) => c.method === 'broadcast');
|
|
405
|
+
expect(sends).toHaveLength(2); // PONG :a, PONG :c
|
|
406
|
+
expect((sends[0]?.args[1] as RawLine[])[0]?.text).toBe('PONG :a');
|
|
407
|
+
expect((sends[1]?.args[1] as RawLine[])[0]?.text).toBe('PONG :c');
|
|
408
|
+
expect(broadcasts).toHaveLength(1); // PRIVMSG #foo :b
|
|
409
|
+
expect((broadcasts[0]?.args[1] as RawLine[])[0]?.text).toBe(
|
|
410
|
+
':alice!alice@example.com PRIVMSG #foo :b',
|
|
411
|
+
);
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
it('uses the default WS-frame transport when none is injected (backward compat)', () => {
|
|
415
|
+
// No `transport` option → the actor still frames WS text frames exactly
|
|
416
|
+
// as before. Confirms the default is the stateless WS transport.
|
|
417
|
+
const { actor, runtime } = makeActor({});
|
|
418
|
+
// A trailing unterminated line is still emitted by the WS transport
|
|
419
|
+
// (frame = complete unit), so this produces one PONG with no terminator.
|
|
420
|
+
return actor.receiveTextFrame('PING :tok').then(() => {
|
|
421
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
422
|
+
expect(sends).toHaveLength(1);
|
|
423
|
+
expect((sends[0]?.args[1] as RawLine[])[0]?.text).toBe('PONG :tok');
|
|
424
|
+
});
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
it('a TCP actor does NOT emit a trailing unterminated line until the terminator arrives', async () => {
|
|
428
|
+
const tcpActor = makeActor({ transport: new TcpByteStreamTransport() });
|
|
429
|
+
const wsActor = makeActor({ transport: new WsTextFrameTransport() });
|
|
430
|
+
tcpActor.runtime.calls.length = 0;
|
|
431
|
+
wsActor.runtime.calls.length = 0;
|
|
432
|
+
|
|
433
|
+
// WS treats the frame as complete → emits a PONG. TCP buffers the
|
|
434
|
+
// unterminated line → emits nothing yet. This is the defining
|
|
435
|
+
// behavioural difference of stateful byte-stream framing.
|
|
436
|
+
await wsActor.actor.receiveTextFrame('PING :unterminated');
|
|
437
|
+
await tcpActor.actor.receive('PING :unterminated');
|
|
438
|
+
expect(wsActor.runtime.calls.filter((c) => c.method === 'send')).toHaveLength(1);
|
|
439
|
+
expect(tcpActor.runtime.calls).toHaveLength(0);
|
|
440
|
+
// …until the terminator completes the line.
|
|
441
|
+
await tcpActor.actor.receive('\r\n');
|
|
442
|
+
expect(tcpActor.runtime.calls.filter((c) => c.method === 'send')).toHaveLength(1);
|
|
443
|
+
});
|
|
444
|
+
});
|
|
445
|
+
|
|
281
446
|
describe('ConnectionActor — malformed input', () => {
|
|
282
447
|
it('emits 421 for an unparseable line and continues processing the rest', async () => {
|
|
283
448
|
const { actor, runtime } = makeActor({});
|
|
@@ -512,9 +677,7 @@ describe('ConnectionActor — additional routing', () => {
|
|
|
512
677
|
|
|
513
678
|
// Cap-gated broadcast dispatches per-recipient via send (only to
|
|
514
679
|
// message-tags-enabled members, excluding the sender).
|
|
515
|
-
const sends = runtime.calls.filter(
|
|
516
|
-
(c) => c.method === 'send' && c.args[0] === 'c2',
|
|
517
|
-
);
|
|
680
|
+
const sends = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c2');
|
|
518
681
|
expect(sends).toHaveLength(1);
|
|
519
682
|
const lines = sends[0]?.args[1] as RawLine[];
|
|
520
683
|
expect(lines[0]?.text).toBe('@+typing=active :alice!alice@example.com TAGMSG #foo');
|
|
@@ -664,6 +827,94 @@ describe('ConnectionActor — additional routing', () => {
|
|
|
664
827
|
}
|
|
665
828
|
});
|
|
666
829
|
|
|
830
|
+
it('routes WHOIS with no parameter through whoisReducer (just 318, never 421)', async () => {
|
|
831
|
+
// Covers the `targetNick.length > 0` false branch in routeWhois:
|
|
832
|
+
// a bare `WHOIS` resolves no target and defers to the reducer, which
|
|
833
|
+
// emits only the 318 end marker.
|
|
834
|
+
const { actor, runtime } = makeActor({});
|
|
835
|
+
runtime.calls.length = 0;
|
|
836
|
+
|
|
837
|
+
await actor.receiveTextFrame('WHOIS\r\n');
|
|
838
|
+
|
|
839
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
840
|
+
expect(sends.length).toBeGreaterThanOrEqual(1);
|
|
841
|
+
const allLines = sends.flatMap((s) => s.args[1] as RawLine[]);
|
|
842
|
+
expect(allLines.some((l) => l.text.includes(' 318 '))).toBe(true);
|
|
843
|
+
for (const line of allLines) {
|
|
844
|
+
expect(line.text).not.toContain(' 421 ');
|
|
845
|
+
}
|
|
846
|
+
});
|
|
847
|
+
|
|
848
|
+
it('routes WHOWAS of a quit nick through whowasReducer (314 + 369, never 421)', async () => {
|
|
849
|
+
const history = new InMemoryNickHistoryStore(new FakeClock(1_000));
|
|
850
|
+
history.record({
|
|
851
|
+
nick: 'bob',
|
|
852
|
+
connId: 'c2',
|
|
853
|
+
username: 'bob',
|
|
854
|
+
hostname: 'example.com',
|
|
855
|
+
realname: 'Bob',
|
|
856
|
+
signoffTime: 500,
|
|
857
|
+
});
|
|
858
|
+
const { actor, runtime } = makeActor({ history });
|
|
859
|
+
runtime.calls.length = 0;
|
|
860
|
+
|
|
861
|
+
await actor.receiveTextFrame('WHOWAS bob\r\n');
|
|
862
|
+
|
|
863
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
864
|
+
expect(sends.length).toBeGreaterThanOrEqual(2);
|
|
865
|
+
const firstBatch = sends[0]?.args[1] as RawLine[];
|
|
866
|
+
expect(firstBatch[0]?.text).toContain(' 314 ');
|
|
867
|
+
expect(firstBatch[0]?.text).toContain(' bob ');
|
|
868
|
+
const endBatch = sends[sends.length - 1]?.args[1] as RawLine[];
|
|
869
|
+
expect(endBatch[0]?.text).toContain(' 369 ');
|
|
870
|
+
for (const s of sends) {
|
|
871
|
+
for (const line of s.args[1] as RawLine[]) {
|
|
872
|
+
expect(line.text).not.toContain(' 421 ');
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
});
|
|
876
|
+
|
|
877
|
+
it('routes WHOWAS of an unknown nick to 406 then 369 (never 421)', async () => {
|
|
878
|
+
const history = new InMemoryNickHistoryStore(new FakeClock(1_000));
|
|
879
|
+
const { actor, runtime } = makeActor({ history });
|
|
880
|
+
runtime.calls.length = 0;
|
|
881
|
+
|
|
882
|
+
await actor.receiveTextFrame('WHOWAS ghost\r\n');
|
|
883
|
+
|
|
884
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
885
|
+
expect(sends).toHaveLength(2);
|
|
886
|
+
const notFound = sends[0]?.args[1] as RawLine[];
|
|
887
|
+
expect(notFound[0]?.text).toContain(' 406 ');
|
|
888
|
+
expect(notFound[0]?.text).toContain(' ghost ');
|
|
889
|
+
const end = sends[1]?.args[1] as RawLine[];
|
|
890
|
+
expect(end[0]?.text).toContain(' 369 ');
|
|
891
|
+
});
|
|
892
|
+
|
|
893
|
+
it('still emits 406 + 369 for WHOWAS when no history store is bound (no crash)', async () => {
|
|
894
|
+
const { actor, runtime } = makeActor({});
|
|
895
|
+
runtime.calls.length = 0;
|
|
896
|
+
|
|
897
|
+
await actor.receiveTextFrame('WHOWAS ghost\r\n');
|
|
898
|
+
|
|
899
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
900
|
+
expect(sends).toHaveLength(2);
|
|
901
|
+
const notFound = sends[0]?.args[1] as RawLine[];
|
|
902
|
+
expect(notFound[0]?.text).toContain(' 406 ');
|
|
903
|
+
const end = sends[1]?.args[1] as RawLine[];
|
|
904
|
+
expect(end[0]?.text).toContain(' 369 ');
|
|
905
|
+
});
|
|
906
|
+
|
|
907
|
+
it('records a nick into history on QUIT when a store is bound', async () => {
|
|
908
|
+
const history = new InMemoryNickHistoryStore(new FakeClock(1_000));
|
|
909
|
+
const { actor } = makeActor({ history });
|
|
910
|
+
|
|
911
|
+
await actor.receiveTextFrame('QUIT\r\n');
|
|
912
|
+
|
|
913
|
+
const entries = history.query('alice', 10);
|
|
914
|
+
expect(entries).toHaveLength(1);
|
|
915
|
+
expect(entries[0]?.nick).toBe('alice');
|
|
916
|
+
});
|
|
917
|
+
|
|
667
918
|
it('routes AUTHENTICATE PLAIN (post CAP REQ sasl) through authenticateReducer, never 421', async () => {
|
|
668
919
|
// Pre-registration: client has opened CAP negotiation and requested sasl.
|
|
669
920
|
// AUTHENTICATE must reach the SASL reducer, not the 421 fallthrough.
|
|
@@ -777,6 +1028,34 @@ describe('ConnectionActor — additional routing', () => {
|
|
|
777
1028
|
}
|
|
778
1029
|
});
|
|
779
1030
|
|
|
1031
|
+
it('routes SETNAME :<realname> through setnameReducer, mutating state.realname (never 421)', async () => {
|
|
1032
|
+
const { actor, runtime, conn } = makeActor({});
|
|
1033
|
+
runtime.calls.length = 0;
|
|
1034
|
+
|
|
1035
|
+
await actor.receiveTextFrame('SETNAME :New Name\r\n');
|
|
1036
|
+
|
|
1037
|
+
expect(conn.realname).toBe('New Name');
|
|
1038
|
+
// Silent update: no Send reply and no broadcast, just the state mutation.
|
|
1039
|
+
expect(runtime.calls).toHaveLength(0);
|
|
1040
|
+
});
|
|
1041
|
+
|
|
1042
|
+
it('routes SETNAME with no param to 461 (never 421)', async () => {
|
|
1043
|
+
const { actor, runtime } = makeActor({});
|
|
1044
|
+
runtime.calls.length = 0;
|
|
1045
|
+
|
|
1046
|
+
await actor.receiveTextFrame('SETNAME\r\n');
|
|
1047
|
+
|
|
1048
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
1049
|
+
expect(sends).toHaveLength(1);
|
|
1050
|
+
const lines = sends[0]?.args[1] as RawLine[];
|
|
1051
|
+
expect(lines[0]?.text).toBe(':irc.example.com 461 alice SETNAME :Not enough parameters');
|
|
1052
|
+
for (const s of sends) {
|
|
1053
|
+
for (const line of s.args[1] as RawLine[]) {
|
|
1054
|
+
expect(line.text).not.toContain(' 421 ');
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
});
|
|
1058
|
+
|
|
780
1059
|
it('routes OPER with matching creds through operReducer (381, never 421)', async () => {
|
|
781
1060
|
const { actor, runtime, conn } = makeActor({
|
|
782
1061
|
operCreds: [{ user: 'alice', password: 'secret' }],
|
|
@@ -855,6 +1134,212 @@ describe('ConnectionActor — additional routing', () => {
|
|
|
855
1134
|
expect(lines[0]?.text).toBe(':irc.example.com 421 alice :Unknown command');
|
|
856
1135
|
});
|
|
857
1136
|
|
|
1137
|
+
it('routes WALLOPS from an oper through wallopsReducer (broadcastWallops, never 421)', async () => {
|
|
1138
|
+
const { actor, runtime } = makeActor({
|
|
1139
|
+
conn: { userModes: { invisible: false, oper: true, wallops: false, serverNotices: false } },
|
|
1140
|
+
});
|
|
1141
|
+
runtime.calls.length = 0;
|
|
1142
|
+
|
|
1143
|
+
await actor.receiveTextFrame('WALLOPS :reboot in 5\r\n');
|
|
1144
|
+
|
|
1145
|
+
// Oper happy path: one broadcastWallops runtime call, carrying the
|
|
1146
|
+
// oper-attributed line and excepting the sender.
|
|
1147
|
+
const wallops = runtime.calls.filter((c) => c.method === 'broadcastWallops');
|
|
1148
|
+
expect(wallops).toHaveLength(1);
|
|
1149
|
+
const lines = wallops[0]?.args[0] as RawLine[];
|
|
1150
|
+
const except = wallops[0]?.args[1];
|
|
1151
|
+
expect(lines[0]?.text).toBe(':alice!alice@example.com WALLOPS :reboot in 5');
|
|
1152
|
+
expect(except).toBe('c1');
|
|
1153
|
+
// Critical: WALLOPS must reach the reducer, never the 421 fallthrough.
|
|
1154
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
1155
|
+
for (const s of sends) {
|
|
1156
|
+
for (const line of s.args[1] as RawLine[]) {
|
|
1157
|
+
expect(line.text).not.toContain(' 421 ');
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
});
|
|
1161
|
+
|
|
1162
|
+
it('routes WALLOPS from a non-oper through wallopsReducer (481, never 421)', async () => {
|
|
1163
|
+
const { actor, runtime } = makeActor({});
|
|
1164
|
+
runtime.calls.length = 0;
|
|
1165
|
+
|
|
1166
|
+
await actor.receiveTextFrame('WALLOPS :hi\r\n');
|
|
1167
|
+
|
|
1168
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
1169
|
+
expect(sends).toHaveLength(1);
|
|
1170
|
+
const lines = sends[0]?.args[1] as RawLine[];
|
|
1171
|
+
expect(lines[0]?.text).toContain(' 481 ');
|
|
1172
|
+
// A non-oper must never trigger global fanout.
|
|
1173
|
+
expect(runtime.calls.some((c) => c.method === 'broadcastWallops')).toBe(false);
|
|
1174
|
+
for (const s of sends) {
|
|
1175
|
+
for (const line of s.args[1] as RawLine[]) {
|
|
1176
|
+
expect(line.text).not.toContain(' 421 ');
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
});
|
|
1180
|
+
|
|
1181
|
+
it('routes WALLOPS with no message through wallopsReducer (412, never 421)', async () => {
|
|
1182
|
+
const { actor, runtime } = makeActor({
|
|
1183
|
+
conn: { userModes: { invisible: false, oper: true, wallops: false, serverNotices: false } },
|
|
1184
|
+
});
|
|
1185
|
+
runtime.calls.length = 0;
|
|
1186
|
+
|
|
1187
|
+
await actor.receiveTextFrame('WALLOPS\r\n');
|
|
1188
|
+
|
|
1189
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
1190
|
+
expect(sends).toHaveLength(1);
|
|
1191
|
+
const lines = sends[0]?.args[1] as RawLine[];
|
|
1192
|
+
expect(lines[0]?.text).toContain(' 412 ');
|
|
1193
|
+
expect(runtime.calls.some((c) => c.method === 'broadcastWallops')).toBe(false);
|
|
1194
|
+
for (const s of sends) {
|
|
1195
|
+
for (const line of s.args[1] as RawLine[]) {
|
|
1196
|
+
expect(line.text).not.toContain(' 421 ');
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
});
|
|
1200
|
+
|
|
1201
|
+
it('routes REHASH from an oper through rehashReducer (382, never 421) and re-invokes the loader', async () => {
|
|
1202
|
+
const { actor, runtime, conn } = makeActor({
|
|
1203
|
+
conn: { userModes: { invisible: false, oper: true, wallops: false, serverNotices: false } },
|
|
1204
|
+
configSource: 'KV',
|
|
1205
|
+
});
|
|
1206
|
+
runtime.reloadConfigResult = {
|
|
1207
|
+
serverName: 'irc.reloaded.test',
|
|
1208
|
+
networkName: 'ExampleNet',
|
|
1209
|
+
maxChannelsPerUser: 30,
|
|
1210
|
+
maxTargetsPerCommand: 10,
|
|
1211
|
+
maxListEntries: 50,
|
|
1212
|
+
nickLen: 30,
|
|
1213
|
+
channelLen: 50,
|
|
1214
|
+
topicLen: 390,
|
|
1215
|
+
quitMessage: 'Client Quit',
|
|
1216
|
+
};
|
|
1217
|
+
runtime.calls.length = 0;
|
|
1218
|
+
|
|
1219
|
+
await actor.receiveTextFrame('REHASH\r\n');
|
|
1220
|
+
|
|
1221
|
+
// 382 emitted, never 421.
|
|
1222
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
1223
|
+
expect(sends).toHaveLength(1);
|
|
1224
|
+
const firstLines = sends[0]?.args[1] as RawLine[];
|
|
1225
|
+
expect(firstLines[0]?.text).toBe(':irc.example.com 382 alice :Rehashing from KV');
|
|
1226
|
+
for (const s of sends) {
|
|
1227
|
+
for (const line of s.args[1] as RawLine[]) {
|
|
1228
|
+
expect(line.text).not.toContain(' 421 ');
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
// The bound loader was actually re-invoked.
|
|
1232
|
+
expect(runtime.calls.some((c) => c.method === 'reloadConfig')).toBe(true);
|
|
1233
|
+
void conn;
|
|
1234
|
+
// The live config was swapped: a subsequent REHASH's 382 carries the
|
|
1235
|
+
// reloaded server name (ctx is rebuilt from the swapped serverConfig).
|
|
1236
|
+
runtime.calls.length = 0;
|
|
1237
|
+
await actor.receiveTextFrame('REHASH\r\n');
|
|
1238
|
+
const secondSends = runtime.calls.filter((c) => c.method === 'send');
|
|
1239
|
+
const secondLines = secondSends[0]?.args[1] as RawLine[];
|
|
1240
|
+
expect(secondLines[0]?.text).toBe(':irc.reloaded.test 382 alice :Rehashing from KV');
|
|
1241
|
+
});
|
|
1242
|
+
|
|
1243
|
+
it('routes REHASH from a non-oper through rehashReducer (481) and does NOT reload', async () => {
|
|
1244
|
+
const { actor, runtime } = makeActor({ configSource: 'KV' });
|
|
1245
|
+
runtime.calls.length = 0;
|
|
1246
|
+
|
|
1247
|
+
await actor.receiveTextFrame('REHASH\r\n');
|
|
1248
|
+
|
|
1249
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
1250
|
+
expect(sends).toHaveLength(1);
|
|
1251
|
+
const lines = sends[0]?.args[1] as RawLine[];
|
|
1252
|
+
expect(lines[0]?.text).toContain(' 481 ');
|
|
1253
|
+
// Critical: a non-oper must not trigger a config reload.
|
|
1254
|
+
expect(runtime.calls.some((c) => c.method === 'reloadConfig')).toBe(false);
|
|
1255
|
+
for (const s of sends) {
|
|
1256
|
+
for (const line of s.args[1] as RawLine[]) {
|
|
1257
|
+
expect(line.text).not.toContain(' 421 ');
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
});
|
|
1261
|
+
|
|
1262
|
+
it('keeps the oper connected and retains the prior config when the loader rejects', async () => {
|
|
1263
|
+
const { actor, runtime } = makeActor({
|
|
1264
|
+
conn: { userModes: { invisible: false, oper: true, wallops: false, serverNotices: false } },
|
|
1265
|
+
configSource: 'KV',
|
|
1266
|
+
});
|
|
1267
|
+
runtime.reloadConfigError = new Error('store unreachable');
|
|
1268
|
+
runtime.calls.length = 0;
|
|
1269
|
+
|
|
1270
|
+
await actor.receiveTextFrame('REHASH\r\n');
|
|
1271
|
+
|
|
1272
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
1273
|
+
// Optimistic 382 + graceful error-suffixed 382; oper NOT disconnected.
|
|
1274
|
+
expect(sends).toHaveLength(2);
|
|
1275
|
+
const lines = [
|
|
1276
|
+
...((sends[0]?.args[1] as RawLine[]) ?? []),
|
|
1277
|
+
...((sends[1]?.args[1] as RawLine[]) ?? []),
|
|
1278
|
+
];
|
|
1279
|
+
expect(lines[0]?.text).toBe(':irc.example.com 382 alice :Rehashing from KV');
|
|
1280
|
+
expect(lines[1]?.text).toBe(
|
|
1281
|
+
':irc.example.com 382 alice :Rehashing from KV failed: store unreachable',
|
|
1282
|
+
);
|
|
1283
|
+
// The oper is never disconnected on a reload failure.
|
|
1284
|
+
expect(runtime.calls.some((c) => c.method === 'disconnect')).toBe(false);
|
|
1285
|
+
// Prior config retained: a subsequent command still sees the old name.
|
|
1286
|
+
runtime.calls.length = 0;
|
|
1287
|
+
await actor.receiveTextFrame('REHASH\r\n');
|
|
1288
|
+
const retrySends = runtime.calls.filter((c) => c.method === 'send');
|
|
1289
|
+
const retryLines = retrySends[0]?.args[1] as RawLine[];
|
|
1290
|
+
expect(retryLines[0]?.text).toBe(':irc.example.com 382 alice :Rehashing from KV');
|
|
1291
|
+
});
|
|
1292
|
+
|
|
1293
|
+
it('stringifies a non-Error rejection in the graceful 382 suffix', async () => {
|
|
1294
|
+
const { actor, runtime } = makeActor({
|
|
1295
|
+
conn: { userModes: { invisible: false, oper: true, wallops: false, serverNotices: false } },
|
|
1296
|
+
configSource: 'KV',
|
|
1297
|
+
});
|
|
1298
|
+
// A non-Error throw exercises the `String(err)` branch of the suffix
|
|
1299
|
+
// formatting (vs. `err.message` for a real Error).
|
|
1300
|
+
runtime.reloadConfigError = 'totally broken';
|
|
1301
|
+
runtime.calls.length = 0;
|
|
1302
|
+
|
|
1303
|
+
await actor.receiveTextFrame('REHASH\r\n');
|
|
1304
|
+
|
|
1305
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
1306
|
+
expect(sends).toHaveLength(2);
|
|
1307
|
+
const errorLine = (sends[1]?.args[1] as RawLine[])[0];
|
|
1308
|
+
expect(errorLine?.text).toBe(
|
|
1309
|
+
':irc.example.com 382 alice :Rehashing from KV failed: totally broken',
|
|
1310
|
+
);
|
|
1311
|
+
expect(runtime.calls.some((c) => c.method === 'disconnect')).toBe(false);
|
|
1312
|
+
});
|
|
1313
|
+
|
|
1314
|
+
it('falls back to "*" in the graceful 382 suffix when the oper has no nick', async () => {
|
|
1315
|
+
// Covers the `this.state.nick ?? '*'` fallback in routeRehash's catch
|
|
1316
|
+
// block. Oper state without a nick is unusual (registration normally
|
|
1317
|
+
// sets the nick before OPER can succeed) but the actor must still
|
|
1318
|
+
// format a valid numeric addressed to "*" rather than crash.
|
|
1319
|
+
const { actor, runtime, conn } = makeActor({
|
|
1320
|
+
conn: { userModes: { invisible: false, oper: true, wallops: false, serverNotices: false } },
|
|
1321
|
+
configSource: 'KV',
|
|
1322
|
+
});
|
|
1323
|
+
// Clear the nick post-construction (exactOptionalPropertyTypes forbids
|
|
1324
|
+
// assigning undefined; delete is the idiomatic way to unset an optional).
|
|
1325
|
+
// biome-ignore lint/performance/noDelete: exactOptionalPropertyTypes forbids `= undefined`.
|
|
1326
|
+
delete conn.nick;
|
|
1327
|
+
runtime.reloadConfigError = new Error('store unreachable');
|
|
1328
|
+
runtime.calls.length = 0;
|
|
1329
|
+
|
|
1330
|
+
await actor.receiveTextFrame('REHASH\r\n');
|
|
1331
|
+
|
|
1332
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
1333
|
+
expect(sends.length).toBeGreaterThanOrEqual(1);
|
|
1334
|
+
for (const s of sends) {
|
|
1335
|
+
for (const line of s.args[1] as RawLine[]) {
|
|
1336
|
+
// Addressed to "*" (no nick), never 421.
|
|
1337
|
+
expect(line.text).toContain(' 382 * ');
|
|
1338
|
+
expect(line.text).not.toContain(' 421 ');
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
});
|
|
1342
|
+
|
|
858
1343
|
it('covers JOIN with no params (splitCsv("") → [])', async () => {
|
|
859
1344
|
const { actor, runtime } = makeActor({});
|
|
860
1345
|
await actor.receiveTextFrame('JOIN\r\n');
|
|
@@ -1361,6 +1846,27 @@ describe('ConnectionActor — CHATHISTORY', () => {
|
|
|
1361
1846
|
expect(sends).toHaveLength(0);
|
|
1362
1847
|
});
|
|
1363
1848
|
|
|
1849
|
+
it('handles a missing target parameter gracefully (no 421)', async () => {
|
|
1850
|
+
// Covers the `target !== undefined` false branch in routeChathistory:
|
|
1851
|
+
// a CHATHISTORY message with only the subcommand and no target param.
|
|
1852
|
+
// peekChannel is not called; the reducer surfaces the param error.
|
|
1853
|
+
const store = new InMemoryMessageStore();
|
|
1854
|
+
const { actor, runtime } = makeActor({
|
|
1855
|
+
conn: { caps: CHATHISTORY_CAPS },
|
|
1856
|
+
messages: store,
|
|
1857
|
+
});
|
|
1858
|
+
runtime.calls.length = 0;
|
|
1859
|
+
|
|
1860
|
+
await actor.receiveTextFrame('CHATHISTORY LATEST\r\n');
|
|
1861
|
+
|
|
1862
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
1863
|
+
for (const s of sends) {
|
|
1864
|
+
for (const line of s.args[1] as RawLine[]) {
|
|
1865
|
+
expect(line.text).not.toContain(' 421 ');
|
|
1866
|
+
}
|
|
1867
|
+
}
|
|
1868
|
+
});
|
|
1869
|
+
|
|
1364
1870
|
it('records a channel PRIVMSG into the bound store (JOIN auto-playback data source)', async () => {
|
|
1365
1871
|
const store = new InMemoryMessageStore();
|
|
1366
1872
|
const { actor, runtime } = makeActor({ messages: store });
|
|
@@ -1489,3 +1995,982 @@ describe('ConnectionActor — SASL AccountStore plumbing', () => {
|
|
|
1489
1995
|
expect(conn.account).toBeUndefined();
|
|
1490
1996
|
});
|
|
1491
1997
|
});
|
|
1998
|
+
|
|
1999
|
+
describe('ConnectionActor — SASL EXTERNAL + mTLS plumbing', () => {
|
|
2000
|
+
/** Fake AccountStore that accepts EXTERNAL with a known cert subject. */
|
|
2001
|
+
class FakeAccountStore implements AccountStore {
|
|
2002
|
+
readonly calls: Array<{ mech: string; payload: SaslPayload }> = [];
|
|
2003
|
+
constructor(private readonly result: SaslResult) {}
|
|
2004
|
+
verify(mech: string, payload: SaslPayload): SaslResult {
|
|
2005
|
+
this.calls.push({ mech, payload });
|
|
2006
|
+
return this.result;
|
|
2007
|
+
}
|
|
2008
|
+
}
|
|
2009
|
+
|
|
2010
|
+
/** Fake MtlsIdentityProvider returning a canned subject for 'c1'. */
|
|
2011
|
+
class FakeMtlsProvider implements MtlsIdentityProvider {
|
|
2012
|
+
constructor(private readonly identity: string | undefined) {}
|
|
2013
|
+
getIdentity(connId: string): string | undefined {
|
|
2014
|
+
return connId === 'c1' ? this.identity : undefined;
|
|
2015
|
+
}
|
|
2016
|
+
}
|
|
2017
|
+
|
|
2018
|
+
function saslReadyConn(): Partial<ConnectionState> {
|
|
2019
|
+
return {
|
|
2020
|
+
registration: 'pre-registration',
|
|
2021
|
+
capNegotiating: true,
|
|
2022
|
+
caps: new Set<string>(['sasl']),
|
|
2023
|
+
};
|
|
2024
|
+
}
|
|
2025
|
+
|
|
2026
|
+
it('completes SASL EXTERNAL with 900/903 when mTLS provider and store accept', async () => {
|
|
2027
|
+
const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
|
|
2028
|
+
const mtlsIdentity = new FakeMtlsProvider('CN=alice');
|
|
2029
|
+
const { actor, runtime, conn } = makeActor({
|
|
2030
|
+
conn: saslReadyConn(),
|
|
2031
|
+
accounts,
|
|
2032
|
+
mtlsIdentity,
|
|
2033
|
+
});
|
|
2034
|
+
conn.nick = 'alice';
|
|
2035
|
+
runtime.calls.length = 0;
|
|
2036
|
+
|
|
2037
|
+
await actor.receiveTextFrame('AUTHENTICATE EXTERNAL\r\n');
|
|
2038
|
+
await actor.receiveTextFrame('AUTHENTICATE +\r\n');
|
|
2039
|
+
|
|
2040
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
2041
|
+
const lines = sends.flatMap((s) => s.args[1] as RawLine[]);
|
|
2042
|
+
expect(lines.some((l) => l.text.includes(' 900 '))).toBe(true);
|
|
2043
|
+
expect(lines.some((l) => l.text.includes(' 903 '))).toBe(true);
|
|
2044
|
+
expect(lines.some((l) => l.text.includes(' 904 '))).toBe(false);
|
|
2045
|
+
expect(accounts.calls).toEqual([
|
|
2046
|
+
{ mech: 'EXTERNAL', payload: { kind: 'EXTERNAL', identity: 'CN=alice' } },
|
|
2047
|
+
]);
|
|
2048
|
+
expect(conn.account).toBe('alice');
|
|
2049
|
+
});
|
|
2050
|
+
|
|
2051
|
+
it('returns 908 for EXTERNAL when no mTLS provider is bound', async () => {
|
|
2052
|
+
const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
|
|
2053
|
+
const { actor, runtime, conn } = makeActor({
|
|
2054
|
+
conn: saslReadyConn(),
|
|
2055
|
+
accounts,
|
|
2056
|
+
// No mtlsIdentity → EXTERNAL should 908 at mechanism phase.
|
|
2057
|
+
});
|
|
2058
|
+
conn.nick = 'alice';
|
|
2059
|
+
runtime.calls.length = 0;
|
|
2060
|
+
|
|
2061
|
+
await actor.receiveTextFrame('AUTHENTICATE EXTERNAL\r\n');
|
|
2062
|
+
|
|
2063
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
2064
|
+
const lines = sends.flatMap((s) => s.args[1] as RawLine[]);
|
|
2065
|
+
expect(lines.some((l) => l.text.includes(' 908 '))).toBe(true);
|
|
2066
|
+
expect(lines.some((l) => l.text.includes(' 904 '))).toBe(false);
|
|
2067
|
+
expect(conn.account).toBeUndefined();
|
|
2068
|
+
});
|
|
2069
|
+
});
|
|
2070
|
+
|
|
2071
|
+
describe('ConnectionActor — server-info commands (VERSION/TIME/ADMIN/INFO/USERHOST/ISON)', () => {
|
|
2072
|
+
it('routes VERSION through versionReducer (351, never 421)', async () => {
|
|
2073
|
+
const { actor, runtime } = makeActor({});
|
|
2074
|
+
runtime.calls.length = 0;
|
|
2075
|
+
|
|
2076
|
+
await actor.receiveTextFrame('VERSION\r\n');
|
|
2077
|
+
|
|
2078
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
2079
|
+
expect(sends).toHaveLength(1);
|
|
2080
|
+
const lines = sends[0]?.args[1] as RawLine[];
|
|
2081
|
+
expect(lines[0]?.text).toContain(' 351 ');
|
|
2082
|
+
expect(lines[0]?.text).not.toContain(' 421 ');
|
|
2083
|
+
});
|
|
2084
|
+
|
|
2085
|
+
it('routes TIME through timeReducer (391, never 421)', async () => {
|
|
2086
|
+
const { actor, runtime } = makeActor({});
|
|
2087
|
+
runtime.calls.length = 0;
|
|
2088
|
+
|
|
2089
|
+
await actor.receiveTextFrame('TIME\r\n');
|
|
2090
|
+
|
|
2091
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
2092
|
+
expect(sends).toHaveLength(1);
|
|
2093
|
+
const lines = sends[0]?.args[1] as RawLine[];
|
|
2094
|
+
expect(lines[0]?.text).toContain(' 391 ');
|
|
2095
|
+
expect(lines[0]?.text).not.toContain(' 421 ');
|
|
2096
|
+
});
|
|
2097
|
+
|
|
2098
|
+
it('routes ADMIN through adminReducer (256, never 421)', async () => {
|
|
2099
|
+
const { actor, runtime } = makeActor({});
|
|
2100
|
+
runtime.calls.length = 0;
|
|
2101
|
+
|
|
2102
|
+
await actor.receiveTextFrame('ADMIN\r\n');
|
|
2103
|
+
|
|
2104
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
2105
|
+
expect(sends).toHaveLength(1);
|
|
2106
|
+
const lines = sends[0]?.args[1] as RawLine[];
|
|
2107
|
+
expect(lines[0]?.text).toContain(' 256 ');
|
|
2108
|
+
expect(lines[0]?.text).not.toContain(' 421 ');
|
|
2109
|
+
});
|
|
2110
|
+
|
|
2111
|
+
it('routes INFO through infoReducer (371 + 374, never 421)', async () => {
|
|
2112
|
+
const { actor, runtime } = makeActor({});
|
|
2113
|
+
runtime.calls.length = 0;
|
|
2114
|
+
|
|
2115
|
+
await actor.receiveTextFrame('INFO\r\n');
|
|
2116
|
+
|
|
2117
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
2118
|
+
expect(sends).toHaveLength(1);
|
|
2119
|
+
const lines = sends[0]?.args[1] as RawLine[];
|
|
2120
|
+
expect(lines.some((l) => l.text.includes(' 371 '))).toBe(true);
|
|
2121
|
+
expect(lines.some((l) => l.text.includes(' 374 '))).toBe(true);
|
|
2122
|
+
expect(lines.every((l) => !l.text.includes(' 421 '))).toBe(true);
|
|
2123
|
+
});
|
|
2124
|
+
|
|
2125
|
+
it('routes USERHOST resolving online nicks (302, never 421)', async () => {
|
|
2126
|
+
const { actor, runtime, conn } = makeActor({});
|
|
2127
|
+
const bobState = createConnection({ id: 'c2', connectedSince: 0 });
|
|
2128
|
+
bobState.nick = 'bob';
|
|
2129
|
+
bobState.user = 'bobuser';
|
|
2130
|
+
bobState.host = 'bobhost';
|
|
2131
|
+
bobState.registration = 'registered';
|
|
2132
|
+
runtime.addConnection(conn);
|
|
2133
|
+
runtime.addConnection(bobState);
|
|
2134
|
+
runtime.calls.length = 0;
|
|
2135
|
+
|
|
2136
|
+
await actor.receiveTextFrame('USERHOST bob ghost\r\n');
|
|
2137
|
+
|
|
2138
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
2139
|
+
expect(sends).toHaveLength(1);
|
|
2140
|
+
const lines = sends[0]?.args[1] as RawLine[];
|
|
2141
|
+
expect(lines[0]?.text).toContain(' 302 ');
|
|
2142
|
+
expect(lines[0]?.text).toContain('bob=+bobuser@bobhost');
|
|
2143
|
+
// Offline nick `ghost` is omitted from the reply.
|
|
2144
|
+
expect(lines[0]?.text).not.toContain('ghost');
|
|
2145
|
+
expect(lines[0]?.text).not.toContain(' 421 ');
|
|
2146
|
+
});
|
|
2147
|
+
|
|
2148
|
+
it('USERHOST marks away users with - and opers with *', async () => {
|
|
2149
|
+
const { actor, runtime, conn } = makeActor({});
|
|
2150
|
+
const bobState = createConnection({ id: 'c2', connectedSince: 0 });
|
|
2151
|
+
bobState.nick = 'bob';
|
|
2152
|
+
bobState.user = 'bobuser';
|
|
2153
|
+
bobState.host = 'bobhost';
|
|
2154
|
+
bobState.away = 'brb';
|
|
2155
|
+
bobState.userModes.oper = true;
|
|
2156
|
+
bobState.registration = 'registered';
|
|
2157
|
+
runtime.addConnection(conn);
|
|
2158
|
+
runtime.addConnection(bobState);
|
|
2159
|
+
runtime.calls.length = 0;
|
|
2160
|
+
|
|
2161
|
+
await actor.receiveTextFrame('USERHOST bob\r\n');
|
|
2162
|
+
|
|
2163
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
2164
|
+
const lines = sends[0]?.args[1] as RawLine[];
|
|
2165
|
+
expect(lines[0]?.text).toContain('bob*=-bobuser@bobhost');
|
|
2166
|
+
});
|
|
2167
|
+
|
|
2168
|
+
it('USERHOST treats a nick whose conn tore down mid-resolve as offline', async () => {
|
|
2169
|
+
// Covers the `conn === null` branch in routeUserhost: lookupNick
|
|
2170
|
+
// resolves but the live connection is gone (race between the two
|
|
2171
|
+
// awaits). The reducer must treat the nick as offline rather than
|
|
2172
|
+
// crash.
|
|
2173
|
+
const { actor, runtime, conn } = makeActor({});
|
|
2174
|
+
const bobState = createConnection({ id: 'c2', connectedSince: 0 });
|
|
2175
|
+
bobState.nick = 'bob';
|
|
2176
|
+
bobState.user = 'bobuser';
|
|
2177
|
+
bobState.host = 'bobhost';
|
|
2178
|
+
bobState.registration = 'registered';
|
|
2179
|
+
runtime.addConnection(conn);
|
|
2180
|
+
runtime.addConnection(bobState);
|
|
2181
|
+
runtime.dropConnection('c2'); // race: nick map lags the connection map
|
|
2182
|
+
runtime.calls.length = 0;
|
|
2183
|
+
|
|
2184
|
+
await actor.receiveTextFrame('USERHOST bob\r\n');
|
|
2185
|
+
|
|
2186
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
2187
|
+
const lines = sends[0]?.args[1] as RawLine[];
|
|
2188
|
+
expect(lines[0]?.text).toContain(' 302 ');
|
|
2189
|
+
expect(lines[0]?.text).not.toContain('bobuser@bobhost');
|
|
2190
|
+
});
|
|
2191
|
+
|
|
2192
|
+
it('routes ISON resolving online nicks (303, never 421)', async () => {
|
|
2193
|
+
const { actor, runtime, conn } = makeActor({});
|
|
2194
|
+
const bobState = createConnection({ id: 'c2', connectedSince: 0 });
|
|
2195
|
+
bobState.nick = 'bob';
|
|
2196
|
+
bobState.registration = 'registered';
|
|
2197
|
+
runtime.addConnection(conn);
|
|
2198
|
+
runtime.addConnection(bobState);
|
|
2199
|
+
runtime.calls.length = 0;
|
|
2200
|
+
|
|
2201
|
+
await actor.receiveTextFrame('ISON bob ghost\r\n');
|
|
2202
|
+
|
|
2203
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
2204
|
+
expect(sends).toHaveLength(1);
|
|
2205
|
+
const lines = sends[0]?.args[1] as RawLine[];
|
|
2206
|
+
expect(lines[0]?.text).toContain(' 303 ');
|
|
2207
|
+
expect(lines[0]?.text).toContain('bob');
|
|
2208
|
+
expect(lines[0]?.text).not.toContain('ghost');
|
|
2209
|
+
expect(lines[0]?.text).not.toContain(' 421 ');
|
|
2210
|
+
});
|
|
2211
|
+
|
|
2212
|
+
it('ISON matches case-insensitively and reports the registered spelling', async () => {
|
|
2213
|
+
const { actor, runtime, conn } = makeActor({});
|
|
2214
|
+
const bobState = createConnection({ id: 'c2', connectedSince: 0 });
|
|
2215
|
+
bobState.nick = 'Bob';
|
|
2216
|
+
bobState.registration = 'registered';
|
|
2217
|
+
runtime.addConnection(conn);
|
|
2218
|
+
runtime.addConnection(bobState);
|
|
2219
|
+
runtime.calls.length = 0;
|
|
2220
|
+
|
|
2221
|
+
await actor.receiveTextFrame('ISON BOB\r\n');
|
|
2222
|
+
|
|
2223
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
2224
|
+
const lines = sends[0]?.args[1] as RawLine[];
|
|
2225
|
+
expect(lines[0]?.text).toContain(':Bob');
|
|
2226
|
+
});
|
|
2227
|
+
|
|
2228
|
+
it('skips a nick whose connection has torn down between lookupNick and getConnection (race)', async () => {
|
|
2229
|
+
// Covers the `conn?.nick !== undefined` false branch in routeIson:
|
|
2230
|
+
// lookupNick resolves because the nick map still holds the entry, but
|
|
2231
|
+
// the connection is already gone from the live-connection store.
|
|
2232
|
+
const { actor, runtime, conn } = makeActor({});
|
|
2233
|
+
const bobState = createConnection({ id: 'c2', connectedSince: 0 });
|
|
2234
|
+
bobState.nick = 'bob';
|
|
2235
|
+
bobState.registration = 'registered';
|
|
2236
|
+
runtime.addConnection(conn);
|
|
2237
|
+
runtime.addConnection(bobState);
|
|
2238
|
+
// Drop the live connection but leave the nick registered.
|
|
2239
|
+
runtime.dropConnection('c2');
|
|
2240
|
+
runtime.calls.length = 0;
|
|
2241
|
+
|
|
2242
|
+
await actor.receiveTextFrame('ISON bob\r\n');
|
|
2243
|
+
|
|
2244
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
2245
|
+
const lines = sends[0]?.args[1] as RawLine[];
|
|
2246
|
+
// 303 reply lists no nicks (bob was dropped mid-resolve).
|
|
2247
|
+
expect(lines[0]?.text).toContain(' 303 ');
|
|
2248
|
+
expect(lines[0]?.text).not.toContain('bob');
|
|
2249
|
+
expect(lines[0]?.text).not.toContain(' 421 ');
|
|
2250
|
+
});
|
|
2251
|
+
});
|
|
2252
|
+
|
|
2253
|
+
describe('ConnectionActor — KILL (oper force-disconnect)', () => {
|
|
2254
|
+
it('routes an oper KILL through killReducer, invoking runtime.disconnect for the target (never 421)', async () => {
|
|
2255
|
+
const { actor, runtime, conn } = makeActor({});
|
|
2256
|
+
conn.userModes.oper = true;
|
|
2257
|
+
const bobState = createConnection({ id: 'c2', connectedSince: 0 });
|
|
2258
|
+
bobState.nick = 'bob';
|
|
2259
|
+
bobState.user = 'bob';
|
|
2260
|
+
bobState.host = 'example.com';
|
|
2261
|
+
bobState.realname = 'Bob';
|
|
2262
|
+
bobState.registration = 'registered';
|
|
2263
|
+
runtime.addConnection(conn);
|
|
2264
|
+
runtime.addConnection(bobState);
|
|
2265
|
+
runtime.calls.length = 0;
|
|
2266
|
+
|
|
2267
|
+
await actor.receiveTextFrame('KILL bob :flooding\r\n');
|
|
2268
|
+
|
|
2269
|
+
const disconnects = runtime.calls.filter((c) => c.method === 'disconnect');
|
|
2270
|
+
expect(disconnects).toHaveLength(1);
|
|
2271
|
+
expect(disconnects[0]?.args[0]).toBe('c2');
|
|
2272
|
+
expect(disconnects[0]?.args[1]).toBe('Killed (alice (flooding))');
|
|
2273
|
+
// Critical: never 421 for a routed KILL.
|
|
2274
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
2275
|
+
for (const s of sends) {
|
|
2276
|
+
for (const line of s.args[1] as RawLine[]) {
|
|
2277
|
+
expect(line.text).not.toContain(' 421 ');
|
|
2278
|
+
}
|
|
2279
|
+
}
|
|
2280
|
+
});
|
|
2281
|
+
|
|
2282
|
+
it('emits 481 ERR_NOPRIVILEGES (never 421, no disconnect) when the sender is not an oper', async () => {
|
|
2283
|
+
const { actor, runtime } = makeActor({});
|
|
2284
|
+
runtime.calls.length = 0;
|
|
2285
|
+
|
|
2286
|
+
await actor.receiveTextFrame('KILL bob :flooding\r\n');
|
|
2287
|
+
|
|
2288
|
+
const disconnects = runtime.calls.filter((c) => c.method === 'disconnect');
|
|
2289
|
+
expect(disconnects).toHaveLength(0);
|
|
2290
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
2291
|
+
expect(sends).toHaveLength(1);
|
|
2292
|
+
const lines = sends[0]?.args[1] as RawLine[];
|
|
2293
|
+
expect(lines[0]?.text).toContain(' 481 ');
|
|
2294
|
+
expect(lines[0]?.text).not.toContain(' 421 ');
|
|
2295
|
+
});
|
|
2296
|
+
|
|
2297
|
+
it('emits 401 ERR_NOSUCHNICK for an unknown target (never 421, no disconnect)', async () => {
|
|
2298
|
+
const { actor, runtime, conn } = makeActor({});
|
|
2299
|
+
conn.userModes.oper = true;
|
|
2300
|
+
runtime.calls.length = 0;
|
|
2301
|
+
|
|
2302
|
+
await actor.receiveTextFrame('KILL ghost :nope\r\n');
|
|
2303
|
+
|
|
2304
|
+
const disconnects = runtime.calls.filter((c) => c.method === 'disconnect');
|
|
2305
|
+
expect(disconnects).toHaveLength(0);
|
|
2306
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
2307
|
+
expect(sends).toHaveLength(1);
|
|
2308
|
+
const lines = sends[0]?.args[1] as RawLine[];
|
|
2309
|
+
expect(lines[0]?.text).toContain(' 401 ');
|
|
2310
|
+
expect(lines[0]?.text).toContain(' ghost ');
|
|
2311
|
+
expect(lines[0]?.text).not.toContain(' 421 ');
|
|
2312
|
+
});
|
|
2313
|
+
|
|
2314
|
+
it('emits 461 ERR_NEEDMOREPARAMS when the comment is missing (never 421)', async () => {
|
|
2315
|
+
const { actor, runtime, conn } = makeActor({});
|
|
2316
|
+
conn.userModes.oper = true;
|
|
2317
|
+
runtime.calls.length = 0;
|
|
2318
|
+
|
|
2319
|
+
await actor.receiveTextFrame('KILL bob\r\n');
|
|
2320
|
+
|
|
2321
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
2322
|
+
expect(sends).toHaveLength(1);
|
|
2323
|
+
const lines = sends[0]?.args[1] as RawLine[];
|
|
2324
|
+
expect(lines[0]?.text).toContain(' 461 ');
|
|
2325
|
+
expect(lines[0]?.text).toContain(' KILL ');
|
|
2326
|
+
expect(lines[0]?.text).not.toContain(' 421 ');
|
|
2327
|
+
});
|
|
2328
|
+
|
|
2329
|
+
it('emits 461 when an oper sends KILL with no params at all (never 421)', async () => {
|
|
2330
|
+
const { actor, runtime, conn } = makeActor({});
|
|
2331
|
+
conn.userModes.oper = true;
|
|
2332
|
+
runtime.calls.length = 0;
|
|
2333
|
+
|
|
2334
|
+
await actor.receiveTextFrame('KILL\r\n');
|
|
2335
|
+
|
|
2336
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
2337
|
+
expect(sends).toHaveLength(1);
|
|
2338
|
+
const lines = sends[0]?.args[1] as RawLine[];
|
|
2339
|
+
expect(lines[0]?.text).toContain(' 461 ');
|
|
2340
|
+
expect(lines[0]?.text).not.toContain(' 421 ');
|
|
2341
|
+
});
|
|
2342
|
+
|
|
2343
|
+
it('treats a nick whose connection vanished mid-route as unknown (401, never 421)', async () => {
|
|
2344
|
+
const { actor, runtime, conn } = makeActor({});
|
|
2345
|
+
conn.userModes.oper = true;
|
|
2346
|
+
const bobState = createConnection({ id: 'c2', connectedSince: 0 });
|
|
2347
|
+
bobState.nick = 'bob';
|
|
2348
|
+
bobState.registration = 'registered';
|
|
2349
|
+
runtime.addConnection(conn);
|
|
2350
|
+
runtime.addConnection(bobState);
|
|
2351
|
+
// lookupNick will still resolve bob, but getConnection returns null.
|
|
2352
|
+
runtime.dropConnection('c2');
|
|
2353
|
+
runtime.calls.length = 0;
|
|
2354
|
+
|
|
2355
|
+
await actor.receiveTextFrame('KILL bob :flooding\r\n');
|
|
2356
|
+
|
|
2357
|
+
const disconnects = runtime.calls.filter((c) => c.method === 'disconnect');
|
|
2358
|
+
expect(disconnects).toHaveLength(0);
|
|
2359
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
2360
|
+
expect(sends).toHaveLength(1);
|
|
2361
|
+
const lines = sends[0]?.args[1] as RawLine[];
|
|
2362
|
+
expect(lines[0]?.text).toContain(' 401 ');
|
|
2363
|
+
expect(lines[0]?.text).not.toContain(' 421 ');
|
|
2364
|
+
});
|
|
2365
|
+
});
|
|
2366
|
+
|
|
2367
|
+
describe('ConnectionActor — TRACE (routing diagnostics)', () => {
|
|
2368
|
+
it('emits the 262 terminator and never 421 for TRACE with no target', async () => {
|
|
2369
|
+
const { actor, runtime } = makeActor({});
|
|
2370
|
+
runtime.calls.length = 0;
|
|
2371
|
+
|
|
2372
|
+
await actor.receiveTextFrame('TRACE\r\n');
|
|
2373
|
+
|
|
2374
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
2375
|
+
expect(sends.length).toBeGreaterThanOrEqual(1);
|
|
2376
|
+
const allLines = sends.flatMap((s) => s.args[1] as RawLine[]);
|
|
2377
|
+
expect(allLines[allLines.length - 1]?.text).toContain(' 262 ');
|
|
2378
|
+
for (const line of allLines) {
|
|
2379
|
+
expect(line.text).not.toContain(' 421 ');
|
|
2380
|
+
}
|
|
2381
|
+
});
|
|
2382
|
+
|
|
2383
|
+
it('routes oper TRACE <nick> through traceReducer (205 + 262, never 421)', async () => {
|
|
2384
|
+
const { actor, runtime, conn } = makeActor({});
|
|
2385
|
+
conn.userModes.oper = true;
|
|
2386
|
+
const bobState = createConnection({ id: 'c2', connectedSince: 0 });
|
|
2387
|
+
bobState.nick = 'bob';
|
|
2388
|
+
bobState.user = 'bob';
|
|
2389
|
+
bobState.host = 'example.com';
|
|
2390
|
+
bobState.realname = 'Bob';
|
|
2391
|
+
bobState.registration = 'registered';
|
|
2392
|
+
runtime.addConnection(conn);
|
|
2393
|
+
runtime.addConnection(bobState);
|
|
2394
|
+
runtime.calls.length = 0;
|
|
2395
|
+
|
|
2396
|
+
await actor.receiveTextFrame('TRACE bob\r\n');
|
|
2397
|
+
|
|
2398
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
2399
|
+
expect(sends.length).toBeGreaterThanOrEqual(1);
|
|
2400
|
+
const firstBatch = sends[0]?.args[1] as RawLine[];
|
|
2401
|
+
expect(firstBatch[0]?.text).toContain(' 205 ');
|
|
2402
|
+
expect(firstBatch[0]?.text).toContain(' bob ');
|
|
2403
|
+
const allLines = sends.flatMap((s) => s.args[1] as RawLine[]);
|
|
2404
|
+
expect(allLines[allLines.length - 1]?.text).toContain(' 262 ');
|
|
2405
|
+
for (const line of allLines) {
|
|
2406
|
+
expect(line.text).not.toContain(' 421 ');
|
|
2407
|
+
}
|
|
2408
|
+
});
|
|
2409
|
+
|
|
2410
|
+
it('emits 402 ERR_NOSUCHSERVER for a non-local server target (never 421)', async () => {
|
|
2411
|
+
const { actor, runtime } = makeActor({});
|
|
2412
|
+
runtime.calls.length = 0;
|
|
2413
|
+
|
|
2414
|
+
await actor.receiveTextFrame('TRACE remote.example.org\r\n');
|
|
2415
|
+
|
|
2416
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
2417
|
+
expect(sends).toHaveLength(1);
|
|
2418
|
+
const lines = sends[0]?.args[1] as RawLine[];
|
|
2419
|
+
expect(lines[0]?.text).toContain(' 402 ');
|
|
2420
|
+
expect(lines[0]?.text).toContain(' remote.example.org ');
|
|
2421
|
+
expect(lines[0]?.text).not.toContain(' 421 ');
|
|
2422
|
+
});
|
|
2423
|
+
|
|
2424
|
+
it('treats the local server name as the local server (no 402, never 421)', async () => {
|
|
2425
|
+
const { actor, runtime } = makeActor({});
|
|
2426
|
+
runtime.calls.length = 0;
|
|
2427
|
+
|
|
2428
|
+
await actor.receiveTextFrame('TRACE irc.example.com\r\n');
|
|
2429
|
+
|
|
2430
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
2431
|
+
expect(sends.length).toBeGreaterThanOrEqual(1);
|
|
2432
|
+
const allLines = sends.flatMap((s) => s.args[1] as RawLine[]);
|
|
2433
|
+
expect(allLines[allLines.length - 1]?.text).toContain(' 262 ');
|
|
2434
|
+
for (const line of allLines) {
|
|
2435
|
+
expect(line.text).not.toContain(' 402 ');
|
|
2436
|
+
expect(line.text).not.toContain(' 421 ');
|
|
2437
|
+
}
|
|
2438
|
+
});
|
|
2439
|
+
|
|
2440
|
+
it('treats a nick whose connection vanished mid-route as the no-target path (262, never 421)', async () => {
|
|
2441
|
+
const { actor, runtime, conn } = makeActor({});
|
|
2442
|
+
conn.userModes.oper = true;
|
|
2443
|
+
const bobState = createConnection({ id: 'c2', connectedSince: 0 });
|
|
2444
|
+
bobState.nick = 'bob';
|
|
2445
|
+
bobState.registration = 'registered';
|
|
2446
|
+
runtime.addConnection(conn);
|
|
2447
|
+
runtime.addConnection(bobState);
|
|
2448
|
+
// lookupNick resolves bob, but getConnection returns null.
|
|
2449
|
+
runtime.dropConnection('c2');
|
|
2450
|
+
runtime.calls.length = 0;
|
|
2451
|
+
|
|
2452
|
+
await actor.receiveTextFrame('TRACE bob\r\n');
|
|
2453
|
+
|
|
2454
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
2455
|
+
expect(sends.length).toBeGreaterThanOrEqual(1);
|
|
2456
|
+
const allLines = sends.flatMap((s) => s.args[1] as RawLine[]);
|
|
2457
|
+
expect(allLines[allLines.length - 1]?.text).toContain(' 262 ');
|
|
2458
|
+
for (const line of allLines) {
|
|
2459
|
+
expect(line.text).not.toContain(' 421 ');
|
|
2460
|
+
}
|
|
2461
|
+
});
|
|
2462
|
+
});
|
|
2463
|
+
|
|
2464
|
+
describe('ConnectionActor — dropped S2S and obsolete verbs', () => {
|
|
2465
|
+
// These verbs are formally dropped (not deferred):
|
|
2466
|
+
// - S2S (PLAN non-goal): CONNECT, SQUIT, LINKS
|
|
2467
|
+
// - RFC 2812 obsolete (never widely implemented): SERVICE, SUMMON, USERS
|
|
2468
|
+
// They keep returning `421 ERR_UNKNOWNCOMMAND` (the correct response for an
|
|
2469
|
+
// unimplemented command); the regression guard below pins that behaviour so
|
|
2470
|
+
// a future accidental `case` label is caught.
|
|
2471
|
+
const droppedVerbs = ['CONNECT', 'SQUIT', 'LINKS', 'SERVICE', 'SUMMON', 'USERS'];
|
|
2472
|
+
|
|
2473
|
+
it.each(droppedVerbs)('emits 421 for the dropped verb %s', async (verb) => {
|
|
2474
|
+
const { actor, runtime } = makeActor({});
|
|
2475
|
+
await actor.receiveTextFrame(`${verb} ignored-arg\r\n`);
|
|
2476
|
+
|
|
2477
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
2478
|
+
expect(sends).toHaveLength(1);
|
|
2479
|
+
const lines = sends[0]?.args[1] as RawLine[];
|
|
2480
|
+
expect(lines[0]?.text).toContain(' 421 ');
|
|
2481
|
+
expect(lines[0]?.text).toContain(` ${verb} `);
|
|
2482
|
+
});
|
|
2483
|
+
|
|
2484
|
+
it('documents the deliberate drop at the default branch (no "deferred" framing)', () => {
|
|
2485
|
+
// The default branch must explain these verbs are deliberately dropped,
|
|
2486
|
+
// not still "deferred" (the framing removed by the S2S / obsolete-verb
|
|
2487
|
+
// drop).
|
|
2488
|
+
expect(actorSource).toMatch(/S2S/);
|
|
2489
|
+
expect(actorSource).toMatch(/non-goal/);
|
|
2490
|
+
expect(actorSource).toMatch(/obsolete/i);
|
|
2491
|
+
// The CONNECT/SQUIT/LINKS and SERVICE/SUMMON/USERS lines must not be
|
|
2492
|
+
// framed as deferred.
|
|
2493
|
+
expect(actorSource).not.toMatch(/deferred LINKS/);
|
|
2494
|
+
expect(actorSource).not.toMatch(/deferred SQUIT/);
|
|
2495
|
+
expect(actorSource).not.toMatch(/deferred SERVICE/);
|
|
2496
|
+
expect(actorSource).not.toMatch(/deferred SUMMON/);
|
|
2497
|
+
expect(actorSource).not.toMatch(/deferred USERS/);
|
|
2498
|
+
});
|
|
2499
|
+
});
|
|
2500
|
+
|
|
2501
|
+
// ---------------------------------------------------------------------------
|
|
2502
|
+
// FakeServerStats + helpers
|
|
2503
|
+
// ---------------------------------------------------------------------------
|
|
2504
|
+
|
|
2505
|
+
/** Stats backend double that returns a fixed snapshot. */
|
|
2506
|
+
class FakeServerStats implements ServerStats {
|
|
2507
|
+
constructor(readonly snapshot: ServerStatsSnapshot) {}
|
|
2508
|
+
async getStats(): Promise<ServerStatsSnapshot> {
|
|
2509
|
+
return this.snapshot;
|
|
2510
|
+
}
|
|
2511
|
+
}
|
|
2512
|
+
|
|
2513
|
+
const populatedSnapshot: ServerStatsSnapshot = {
|
|
2514
|
+
users: 4,
|
|
2515
|
+
invisible: 1,
|
|
2516
|
+
opers: 2,
|
|
2517
|
+
unknownConnections: 1,
|
|
2518
|
+
channels: 3,
|
|
2519
|
+
servers: 1,
|
|
2520
|
+
localConns: 6,
|
|
2521
|
+
globalConns: 6,
|
|
2522
|
+
maxLocalConns: 9,
|
|
2523
|
+
maxGlobalConns: 9,
|
|
2524
|
+
uptimeStartedAt: 0,
|
|
2525
|
+
};
|
|
2526
|
+
|
|
2527
|
+
// ---------------------------------------------------------------------------
|
|
2528
|
+
// LUSERS actor routing
|
|
2529
|
+
// ---------------------------------------------------------------------------
|
|
2530
|
+
|
|
2531
|
+
describe('ConnectionActor — LUSERS (network statistics)', () => {
|
|
2532
|
+
it('emits 251–255 from the bound ServerStats snapshot and never 421', async () => {
|
|
2533
|
+
const { actor, runtime } = makeActor({ stats: new FakeServerStats(populatedSnapshot) });
|
|
2534
|
+
runtime.calls.length = 0;
|
|
2535
|
+
|
|
2536
|
+
await actor.receiveTextFrame('LUSERS\r\n');
|
|
2537
|
+
|
|
2538
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
2539
|
+
expect(sends.length).toBeGreaterThanOrEqual(1);
|
|
2540
|
+
const allLines = sends.flatMap((s) => s.args[1] as RawLine[]);
|
|
2541
|
+
const codes = allLines.map((l) => Number(l.text.split(' ')[1]));
|
|
2542
|
+
expect(codes).toContain(251);
|
|
2543
|
+
expect(codes).toContain(252);
|
|
2544
|
+
expect(codes).toContain(253);
|
|
2545
|
+
expect(codes).toContain(254);
|
|
2546
|
+
expect(codes).toContain(255);
|
|
2547
|
+
expect(codes).toContain(265);
|
|
2548
|
+
expect(codes).toContain(266);
|
|
2549
|
+
for (const line of allLines) {
|
|
2550
|
+
expect(line.text).not.toContain(' 421 ');
|
|
2551
|
+
}
|
|
2552
|
+
});
|
|
2553
|
+
|
|
2554
|
+
it('awaits the stats backend before invoking the reducer (async getStats)', async () => {
|
|
2555
|
+
let calls = 0;
|
|
2556
|
+
const stats: ServerStats = {
|
|
2557
|
+
async getStats() {
|
|
2558
|
+
calls++;
|
|
2559
|
+
return populatedSnapshot;
|
|
2560
|
+
},
|
|
2561
|
+
};
|
|
2562
|
+
const { actor, runtime } = makeActor({ stats });
|
|
2563
|
+
runtime.calls.length = 0;
|
|
2564
|
+
|
|
2565
|
+
await actor.receiveTextFrame('LUSERS\r\n');
|
|
2566
|
+
|
|
2567
|
+
expect(calls).toBe(1);
|
|
2568
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
2569
|
+
const allLines = sends.flatMap((s) => s.args[1] as RawLine[]);
|
|
2570
|
+
// The 251 line carries the snapshot's user/invisible/server counts.
|
|
2571
|
+
expect(allLines.some((l) => l.text.includes(' 251 ') && l.text.includes('4 users'))).toBe(true);
|
|
2572
|
+
});
|
|
2573
|
+
|
|
2574
|
+
it('emits 402 ERR_NOSUCHSERVER for a non-local server target (never 421)', async () => {
|
|
2575
|
+
const { actor, runtime } = makeActor({ stats: new FakeServerStats(populatedSnapshot) });
|
|
2576
|
+
runtime.calls.length = 0;
|
|
2577
|
+
|
|
2578
|
+
await actor.receiveTextFrame('LUSERS * remote.example.org\r\n');
|
|
2579
|
+
|
|
2580
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
2581
|
+
expect(sends).toHaveLength(1);
|
|
2582
|
+
const lines = sends[0]?.args[1] as RawLine[];
|
|
2583
|
+
expect(lines[0]?.text).toContain(' 402 ');
|
|
2584
|
+
expect(lines[0]?.text).toContain(' remote.example.org ');
|
|
2585
|
+
expect(lines[0]?.text).not.toContain(' 421 ');
|
|
2586
|
+
});
|
|
2587
|
+
|
|
2588
|
+
it('treats the local server name as the local server (no 402, never 421)', async () => {
|
|
2589
|
+
const { actor, runtime } = makeActor({ stats: new FakeServerStats(populatedSnapshot) });
|
|
2590
|
+
runtime.calls.length = 0;
|
|
2591
|
+
|
|
2592
|
+
await actor.receiveTextFrame('LUSERS * irc.example.com\r\n');
|
|
2593
|
+
|
|
2594
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
2595
|
+
expect(sends.length).toBeGreaterThanOrEqual(1);
|
|
2596
|
+
const allLines = sends.flatMap((s) => s.args[1] as RawLine[]);
|
|
2597
|
+
for (const line of allLines) {
|
|
2598
|
+
expect(line.text).not.toContain(' 402 ');
|
|
2599
|
+
expect(line.text).not.toContain(' 421 ');
|
|
2600
|
+
}
|
|
2601
|
+
});
|
|
2602
|
+
|
|
2603
|
+
it('falls back to a zero-count snapshot when no stats backend is bound (never 421)', async () => {
|
|
2604
|
+
const { actor, runtime } = makeActor({});
|
|
2605
|
+
runtime.calls.length = 0;
|
|
2606
|
+
|
|
2607
|
+
await actor.receiveTextFrame('LUSERS\r\n');
|
|
2608
|
+
|
|
2609
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
2610
|
+
expect(sends.length).toBeGreaterThanOrEqual(1);
|
|
2611
|
+
const allLines = sends.flatMap((s) => s.args[1] as RawLine[]);
|
|
2612
|
+
// 251 still emitted with zero counts; 252/253/254 suppressed (zero).
|
|
2613
|
+
expect(allLines.some((l) => l.text.includes(' 251 '))).toBe(true);
|
|
2614
|
+
expect(allLines.some((l) => l.text.includes(' 255 '))).toBe(true);
|
|
2615
|
+
for (const line of allLines) {
|
|
2616
|
+
expect(line.text).not.toContain(' 252 ');
|
|
2617
|
+
expect(line.text).not.toContain(' 253 ');
|
|
2618
|
+
expect(line.text).not.toContain(' 254 ');
|
|
2619
|
+
expect(line.text).not.toContain(' 421 ');
|
|
2620
|
+
}
|
|
2621
|
+
});
|
|
2622
|
+
});
|
|
2623
|
+
|
|
2624
|
+
// ---------------------------------------------------------------------------
|
|
2625
|
+
// STATS actor routing
|
|
2626
|
+
// ---------------------------------------------------------------------------
|
|
2627
|
+
|
|
2628
|
+
describe('ConnectionActor — STATS (server statistics)', () => {
|
|
2629
|
+
it('routes STATS u to a 242 + 219 reply (never 421)', async () => {
|
|
2630
|
+
const clock = new FakeClock(5_000);
|
|
2631
|
+
const { actor, runtime } = makeActor({
|
|
2632
|
+
stats: new FakeServerStats({ ...populatedSnapshot, uptimeStartedAt: 0 }),
|
|
2633
|
+
clock,
|
|
2634
|
+
});
|
|
2635
|
+
runtime.calls.length = 0;
|
|
2636
|
+
|
|
2637
|
+
await actor.receiveTextFrame('STATS u\r\n');
|
|
2638
|
+
|
|
2639
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
2640
|
+
expect(sends.length).toBeGreaterThanOrEqual(1);
|
|
2641
|
+
const allLines = sends.flatMap((s) => s.args[1] as RawLine[]);
|
|
2642
|
+
expect(allLines.some((l) => l.text.includes(' 242 '))).toBe(true);
|
|
2643
|
+
expect(allLines[allLines.length - 1]?.text).toContain(' 219 ');
|
|
2644
|
+
expect(allLines[allLines.length - 1]?.text).toContain(' u ');
|
|
2645
|
+
for (const line of allLines) {
|
|
2646
|
+
expect(line.text).not.toContain(' 421 ');
|
|
2647
|
+
}
|
|
2648
|
+
});
|
|
2649
|
+
|
|
2650
|
+
it('routes STATS l to a 211 + 219 reply (never 421)', async () => {
|
|
2651
|
+
const { actor, runtime } = makeActor({ stats: new FakeServerStats(populatedSnapshot) });
|
|
2652
|
+
runtime.calls.length = 0;
|
|
2653
|
+
|
|
2654
|
+
await actor.receiveTextFrame('STATS l\r\n');
|
|
2655
|
+
|
|
2656
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
2657
|
+
expect(sends.length).toBeGreaterThanOrEqual(1);
|
|
2658
|
+
const allLines = sends.flatMap((s) => s.args[1] as RawLine[]);
|
|
2659
|
+
expect(allLines.some((l) => l.text.includes(' 211 '))).toBe(true);
|
|
2660
|
+
expect(allLines[allLines.length - 1]?.text).toContain(' 219 ');
|
|
2661
|
+
for (const line of allLines) {
|
|
2662
|
+
expect(line.text).not.toContain(' 421 ');
|
|
2663
|
+
}
|
|
2664
|
+
});
|
|
2665
|
+
|
|
2666
|
+
it('routes an unknown STATS letter to a charybdis-style empty body + 219 (never 421)', async () => {
|
|
2667
|
+
const { actor, runtime } = makeActor({ stats: new FakeServerStats(populatedSnapshot) });
|
|
2668
|
+
runtime.calls.length = 0;
|
|
2669
|
+
|
|
2670
|
+
await actor.receiveTextFrame('STATS z\r\n');
|
|
2671
|
+
|
|
2672
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
2673
|
+
expect(sends).toHaveLength(1);
|
|
2674
|
+
const lines = sends[0]?.args[1] as RawLine[];
|
|
2675
|
+
expect(lines[0]?.text).toContain(' 219 ');
|
|
2676
|
+
expect(lines[0]?.text).toContain(' z ');
|
|
2677
|
+
expect(lines[0]?.text).not.toContain(' 421 ');
|
|
2678
|
+
});
|
|
2679
|
+
|
|
2680
|
+
it('emits 402 ERR_NOSUCHSERVER for a non-local server target (never 421)', async () => {
|
|
2681
|
+
const { actor, runtime } = makeActor({ stats: new FakeServerStats(populatedSnapshot) });
|
|
2682
|
+
runtime.calls.length = 0;
|
|
2683
|
+
|
|
2684
|
+
await actor.receiveTextFrame('STATS u remote.example.org\r\n');
|
|
2685
|
+
|
|
2686
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
2687
|
+
expect(sends).toHaveLength(1);
|
|
2688
|
+
const lines = sends[0]?.args[1] as RawLine[];
|
|
2689
|
+
expect(lines[0]?.text).toContain(' 402 ');
|
|
2690
|
+
expect(lines[0]?.text).toContain(' remote.example.org ');
|
|
2691
|
+
expect(lines[0]?.text).not.toContain(' 421 ');
|
|
2692
|
+
});
|
|
2693
|
+
|
|
2694
|
+
it('treats the local server name as the local server (no 402, never 421)', async () => {
|
|
2695
|
+
const { actor, runtime } = makeActor({ stats: new FakeServerStats(populatedSnapshot) });
|
|
2696
|
+
runtime.calls.length = 0;
|
|
2697
|
+
|
|
2698
|
+
await actor.receiveTextFrame('STATS u irc.example.com\r\n');
|
|
2699
|
+
|
|
2700
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
2701
|
+
expect(sends.length).toBeGreaterThanOrEqual(1);
|
|
2702
|
+
const allLines = sends.flatMap((s) => s.args[1] as RawLine[]);
|
|
2703
|
+
for (const line of allLines) {
|
|
2704
|
+
expect(line.text).not.toContain(' 402 ');
|
|
2705
|
+
expect(line.text).not.toContain(' 421 ');
|
|
2706
|
+
}
|
|
2707
|
+
});
|
|
2708
|
+
});
|
|
2709
|
+
|
|
2710
|
+
// ============================================================================
|
|
2711
|
+
// channelTargetsForMessage — pure dispatch table for prefetch
|
|
2712
|
+
// ============================================================================
|
|
2713
|
+
|
|
2714
|
+
describe('channelTargetsForMessage', () => {
|
|
2715
|
+
it('returns the channel list for each channel-touching verb', () => {
|
|
2716
|
+
// Covers every case clause in the switch.
|
|
2717
|
+
expect(channelTargetsForMessage({ command: 'PART', params: ['#a,#b'], tags: {} })).toEqual([
|
|
2718
|
+
'#a',
|
|
2719
|
+
'#b',
|
|
2720
|
+
]);
|
|
2721
|
+
expect(channelTargetsForMessage({ command: 'TOPIC', params: ['#foo'], tags: {} })).toEqual([
|
|
2722
|
+
'#foo',
|
|
2723
|
+
]);
|
|
2724
|
+
expect(channelTargetsForMessage({ command: 'NAMES', params: ['#foo'], tags: {} })).toEqual([
|
|
2725
|
+
'#foo',
|
|
2726
|
+
]);
|
|
2727
|
+
expect(
|
|
2728
|
+
channelTargetsForMessage({ command: 'KICK', params: ['#foo', 'bob'], tags: {} }),
|
|
2729
|
+
).toEqual(['#foo']);
|
|
2730
|
+
expect(channelTargetsForMessage({ command: 'MODE', params: ['#foo', '+t'], tags: {} })).toEqual(
|
|
2731
|
+
['#foo'],
|
|
2732
|
+
);
|
|
2733
|
+
expect(
|
|
2734
|
+
channelTargetsForMessage({ command: 'PRIVMSG', params: ['#foo', 'hi'], tags: {} }),
|
|
2735
|
+
).toEqual(['#foo']);
|
|
2736
|
+
expect(
|
|
2737
|
+
channelTargetsForMessage({ command: 'NOTICE', params: ['#foo', 'hi'], tags: {} }),
|
|
2738
|
+
).toEqual(['#foo']);
|
|
2739
|
+
expect(channelTargetsForMessage({ command: 'TAGMSG', params: ['#foo'], tags: {} })).toEqual([
|
|
2740
|
+
'#foo',
|
|
2741
|
+
]);
|
|
2742
|
+
});
|
|
2743
|
+
|
|
2744
|
+
it('returns the JOIN channel(s), skipping the `JOIN 0` sentinel', () => {
|
|
2745
|
+
expect(channelTargetsForMessage({ command: 'JOIN', params: ['#a,#b'], tags: {} })).toEqual([
|
|
2746
|
+
'#a',
|
|
2747
|
+
'#b',
|
|
2748
|
+
]);
|
|
2749
|
+
// `JOIN 0` is the part-everything sentinel; reducer reads
|
|
2750
|
+
// joinedChannels directly, so no prefetch target.
|
|
2751
|
+
expect(channelTargetsForMessage({ command: 'JOIN', params: ['0'], tags: {} })).toEqual([]);
|
|
2752
|
+
});
|
|
2753
|
+
|
|
2754
|
+
it('reads the channel from the SECOND param for INVITE (<nick> <channel>)', () => {
|
|
2755
|
+
expect(
|
|
2756
|
+
channelTargetsForMessage({ command: 'INVITE', params: ['bob', '#room'], tags: {} }),
|
|
2757
|
+
).toEqual(['#room']);
|
|
2758
|
+
});
|
|
2759
|
+
|
|
2760
|
+
it('returns an empty list for commands with no channel target', () => {
|
|
2761
|
+
// Covers the default branch (PING, NICK, QUIT, …).
|
|
2762
|
+
expect(channelTargetsForMessage({ command: 'PING', params: ['t'], tags: {} })).toEqual([]);
|
|
2763
|
+
expect(channelTargetsForMessage({ command: 'NICK', params: ['alice'], tags: {} })).toEqual([]);
|
|
2764
|
+
});
|
|
2765
|
+
|
|
2766
|
+
it('ignores non-channel targets in the comma-split list', () => {
|
|
2767
|
+
// A PRIVMSG to a nick (not a #channel) yields no prefetch targets.
|
|
2768
|
+
expect(
|
|
2769
|
+
channelTargetsForMessage({ command: 'PRIVMSG', params: ['bob,#foo', 'hi'], tags: {} }),
|
|
2770
|
+
).toEqual(['#foo']);
|
|
2771
|
+
});
|
|
2772
|
+
|
|
2773
|
+
it('handles a missing first parameter gracefully', () => {
|
|
2774
|
+
// Defensive: params[0] is absent → empty string → empty split.
|
|
2775
|
+
expect(channelTargetsForMessage({ command: 'PART', params: [], tags: {} })).toEqual([]);
|
|
2776
|
+
});
|
|
2777
|
+
|
|
2778
|
+
it('returns an empty list for JOIN / INVITE when their channel param is absent', () => {
|
|
2779
|
+
// Covers the `?? ''` fallbacks on the JOIN and INVITE branches.
|
|
2780
|
+
expect(channelTargetsForMessage({ command: 'JOIN', params: [], tags: {} })).toEqual([]);
|
|
2781
|
+
expect(channelTargetsForMessage({ command: 'INVITE', params: ['bob'], tags: {} })).toEqual([]);
|
|
2782
|
+
});
|
|
2783
|
+
});
|
|
2784
|
+
|
|
2785
|
+
// ============================================================================
|
|
2786
|
+
// prefetchChannelState — refresh hook for distributed runtimes
|
|
2787
|
+
// ============================================================================
|
|
2788
|
+
|
|
2789
|
+
describe('ConnectionActor — prefetchChannelState (refresh hook)', () => {
|
|
2790
|
+
it('invokes refreshChannel for each channel-targeted command when the hook is bound', async () => {
|
|
2791
|
+
// Covers the body of prefetchChannelState (refresh !== undefined path)
|
|
2792
|
+
// and the call to channelTargetsForMessage for PART. Distributed
|
|
2793
|
+
// runtimes (CF ChannelDO) wire `refreshChannel`; the in-memory fake
|
|
2794
|
+
// does not, so we inject a custom channel-access here.
|
|
2795
|
+
const refreshed: string[] = [];
|
|
2796
|
+
const channelsAccess: ActorChannelAccess = {
|
|
2797
|
+
getOrCreateChannel: (name: ChanName) => {
|
|
2798
|
+
const key = name.toLowerCase();
|
|
2799
|
+
const existing = fakeChannels.get(key);
|
|
2800
|
+
if (existing) return existing;
|
|
2801
|
+
const chan = createChannel({ name, nameLower: key, createdAt: 0 });
|
|
2802
|
+
fakeChannels.set(key, chan);
|
|
2803
|
+
return chan;
|
|
2804
|
+
},
|
|
2805
|
+
refreshChannel: async (name: ChanName) => {
|
|
2806
|
+
refreshed.push(name);
|
|
2807
|
+
},
|
|
2808
|
+
};
|
|
2809
|
+
const fakeChannels = new Map<string, ChannelState>();
|
|
2810
|
+
const { actor, runtime } = makeActor({});
|
|
2811
|
+
// Swap in the channel access with the refresh hook.
|
|
2812
|
+
(actor as unknown as { channels: ActorChannelAccess }).channels = channelsAccess;
|
|
2813
|
+
// Pre-add membership so PART can find the channel.
|
|
2814
|
+
await runtime.applyChannelDelta('#foo', {
|
|
2815
|
+
memberships: [{ type: 'add', conn: 'c1', nick: 'alice' }],
|
|
2816
|
+
});
|
|
2817
|
+
runtime.calls.length = 0;
|
|
2818
|
+
|
|
2819
|
+
await actor.receiveTextFrame('PART #foo :leaving\r\n');
|
|
2820
|
+
|
|
2821
|
+
expect(refreshed).toEqual(['#foo']);
|
|
2822
|
+
});
|
|
2823
|
+
|
|
2824
|
+
it('is a no-op when refreshChannel is not bound (in-memory runtime)', async () => {
|
|
2825
|
+
// Covers the early-return path. The default FakeRuntime has no
|
|
2826
|
+
// refreshChannel; the actor must not throw and the PART still routes.
|
|
2827
|
+
const { actor, runtime } = makeActor({});
|
|
2828
|
+
await runtime.applyChannelDelta('#foo', {
|
|
2829
|
+
memberships: [{ type: 'add', conn: 'c1', nick: 'alice' }],
|
|
2830
|
+
});
|
|
2831
|
+
runtime.calls.length = 0;
|
|
2832
|
+
|
|
2833
|
+
await expect(actor.receiveTextFrame('PART #foo\r\n')).resolves.toBeUndefined();
|
|
2834
|
+
expect(runtime.calls.some((c) => c.method === 'applyChannelDelta')).toBe(true);
|
|
2835
|
+
});
|
|
2836
|
+
|
|
2837
|
+
it('skips refresh for commands with no channel target (empty targets list)', async () => {
|
|
2838
|
+
// Covers the `targets.length === 0` early-return in
|
|
2839
|
+
// prefetchChannelState: refreshChannel is bound but the command (PING)
|
|
2840
|
+
// has no channel argument, so the refresh hook must not fire.
|
|
2841
|
+
const refreshed: string[] = [];
|
|
2842
|
+
const { actor, runtime } = makeActor({});
|
|
2843
|
+
const accessWithRefresh: ActorChannelAccess = {
|
|
2844
|
+
getOrCreateChannel: (name: ChanName) => runtime.getOrCreateChannel(name),
|
|
2845
|
+
refreshChannel: async (name: ChanName) => {
|
|
2846
|
+
refreshed.push(name);
|
|
2847
|
+
},
|
|
2848
|
+
};
|
|
2849
|
+
(actor as unknown as { channels: ActorChannelAccess }).channels = accessWithRefresh;
|
|
2850
|
+
runtime.calls.length = 0;
|
|
2851
|
+
|
|
2852
|
+
await actor.receiveTextFrame('PING :token\r\n');
|
|
2853
|
+
|
|
2854
|
+
expect(refreshed).toEqual([]);
|
|
2855
|
+
});
|
|
2856
|
+
});
|
|
2857
|
+
|
|
2858
|
+
// ============================================================================
|
|
2859
|
+
// peekChannel — getChannel fallback
|
|
2860
|
+
// ============================================================================
|
|
2861
|
+
|
|
2862
|
+
describe('ConnectionActor — peekChannel fallback', () => {
|
|
2863
|
+
it('falls back to getOrCreateChannel when getChannel is not implemented', async () => {
|
|
2864
|
+
// Covers the fallback branch in peekChannel: a channel-access object
|
|
2865
|
+
// that omits `getChannel` (the legacy shape before peek-only lookup) must
|
|
2866
|
+
// still resolve CHATHISTORY targets via getOrCreateChannel. The actor
|
|
2867
|
+
// is reconstructed with a wrapper that delegates everything except
|
|
2868
|
+
// getChannel (which is intentionally absent).
|
|
2869
|
+
const store = new InMemoryMessageStore();
|
|
2870
|
+
const { actor, runtime } = makeActor({
|
|
2871
|
+
conn: {
|
|
2872
|
+
caps: new Set<string>(['draft/chathistory', 'server-time', 'message-tags', 'batch']),
|
|
2873
|
+
},
|
|
2874
|
+
messages: store,
|
|
2875
|
+
});
|
|
2876
|
+
// Replace the channel access with a wrapper that lacks getChannel.
|
|
2877
|
+
// The actor holds `channels` by reference, so this swap takes effect.
|
|
2878
|
+
const accessWithoutGetChannel: ActorChannelAccess = {
|
|
2879
|
+
getOrCreateChannel: (name: ChanName) => runtime.getOrCreateChannel(name),
|
|
2880
|
+
};
|
|
2881
|
+
(actor as unknown as { channels: ActorChannelAccess }).channels = accessWithoutGetChannel;
|
|
2882
|
+
// Pre-create the channel so getOrCreateChannel returns it, and seed
|
|
2883
|
+
// one stored message so the reducer has something to replay.
|
|
2884
|
+
runtime.getOrCreateChannel('#foo');
|
|
2885
|
+
store.record({
|
|
2886
|
+
msgid: 'm1',
|
|
2887
|
+
time: 1_000,
|
|
2888
|
+
chan: '#foo',
|
|
2889
|
+
command: 'PRIVMSG',
|
|
2890
|
+
nick: 'bob',
|
|
2891
|
+
user: 'bob',
|
|
2892
|
+
host: 'ex.org',
|
|
2893
|
+
text: 'hello',
|
|
2894
|
+
});
|
|
2895
|
+
runtime.calls.length = 0;
|
|
2896
|
+
|
|
2897
|
+
await actor.receiveTextFrame('CHATHISTORY LATEST #foo * 1\r\n');
|
|
2898
|
+
|
|
2899
|
+
// The reducer should be reached (no 421) and emit a BATCH frame,
|
|
2900
|
+
// proving peekChannel resolved the channel via the getOrCreateChannel
|
|
2901
|
+
// fallback rather than 403/421.
|
|
2902
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
2903
|
+
expect(sends.length).toBeGreaterThanOrEqual(1);
|
|
2904
|
+
const allLines = sends.flatMap((s) => s.args[1] as RawLine[]);
|
|
2905
|
+
for (const line of allLines) {
|
|
2906
|
+
expect(line.text).not.toContain(' 421 ');
|
|
2907
|
+
expect(line.text).not.toContain(' 403 ');
|
|
2908
|
+
}
|
|
2909
|
+
expect(allLines.some((l) => l.text.startsWith('BATCH +'))).toBe(true);
|
|
2910
|
+
});
|
|
2911
|
+
});
|
|
2912
|
+
|
|
2913
|
+
// ============================================================================
|
|
2914
|
+
// WHOIS — channel-list branch (target with joinedChannels)
|
|
2915
|
+
// ============================================================================
|
|
2916
|
+
|
|
2917
|
+
describe('ConnectionActor — WHOIS channel list', () => {
|
|
2918
|
+
it("includes the target's joined channels in the 319 reply", async () => {
|
|
2919
|
+
// Covers the Array.from(target.joinedChannels, ...) branch in
|
|
2920
|
+
// routeWhois: when the target has joinedChannels, each is resolved to
|
|
2921
|
+
// a ChannelState and passed to whoisReducer for the 319 list.
|
|
2922
|
+
const { actor, runtime, conn } = makeActor({});
|
|
2923
|
+
const bobState = createConnection({ id: 'c2', connectedSince: 0 });
|
|
2924
|
+
bobState.nick = 'bob';
|
|
2925
|
+
bobState.user = 'bob';
|
|
2926
|
+
bobState.host = 'example.com';
|
|
2927
|
+
bobState.realname = 'Bob';
|
|
2928
|
+
bobState.registration = 'registered';
|
|
2929
|
+
bobState.joinedChannels.add('#room1');
|
|
2930
|
+
bobState.joinedChannels.add('#room2');
|
|
2931
|
+
runtime.addConnection(conn);
|
|
2932
|
+
runtime.addConnection(bobState);
|
|
2933
|
+
// Materialize the channels so getOrCreateChannel resolves them.
|
|
2934
|
+
await runtime.applyChannelDelta('#room1', {
|
|
2935
|
+
memberships: [{ type: 'add', conn: 'c2', nick: 'bob' }],
|
|
2936
|
+
});
|
|
2937
|
+
await runtime.applyChannelDelta('#room2', {
|
|
2938
|
+
memberships: [{ type: 'add', conn: 'c2', nick: 'bob' }],
|
|
2939
|
+
});
|
|
2940
|
+
runtime.calls.length = 0;
|
|
2941
|
+
|
|
2942
|
+
await actor.receiveTextFrame('WHOIS bob\r\n');
|
|
2943
|
+
|
|
2944
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
2945
|
+
const allLines = sends.flatMap((s) => s.args[1] as RawLine[]);
|
|
2946
|
+
// 319 RPL_WHOISCHANNELS lists at least one of the target's channels.
|
|
2947
|
+
const chanLines = allLines.filter((l) => l.text.includes(' 319 '));
|
|
2948
|
+
expect(chanLines.length).toBeGreaterThan(0);
|
|
2949
|
+
const joined = chanLines.some((l) => l.text.includes('#room1') || l.text.includes('#room2'));
|
|
2950
|
+
expect(joined).toBe(true);
|
|
2951
|
+
});
|
|
2952
|
+
|
|
2953
|
+
it('treats a target whose conn tore down mid-resolve as offline (401 + 318, never 421)', async () => {
|
|
2954
|
+
// Covers the `?? undefined` branch in routeWhois: lookupNick resolved
|
|
2955
|
+
// the nick but getConnection returned null (race). The reducer must
|
|
2956
|
+
// emit the unknown-nick path (401 + 318), not crash.
|
|
2957
|
+
const { actor, runtime, conn } = makeActor({});
|
|
2958
|
+
const bobState = createConnection({ id: 'c2', connectedSince: 0 });
|
|
2959
|
+
bobState.nick = 'bob';
|
|
2960
|
+
bobState.registration = 'registered';
|
|
2961
|
+
runtime.addConnection(conn);
|
|
2962
|
+
runtime.addConnection(bobState);
|
|
2963
|
+
runtime.dropConnection('c2');
|
|
2964
|
+
runtime.calls.length = 0;
|
|
2965
|
+
|
|
2966
|
+
await actor.receiveTextFrame('WHOIS bob\r\n');
|
|
2967
|
+
|
|
2968
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
2969
|
+
const allLines = sends.flatMap((s) => s.args[1] as RawLine[]);
|
|
2970
|
+
expect(allLines.some((l) => l.text.includes(' 401 '))).toBe(true);
|
|
2971
|
+
expect(allLines.some((l) => l.text.includes(' 318 '))).toBe(true);
|
|
2972
|
+
for (const line of allLines) {
|
|
2973
|
+
expect(line.text).not.toContain(' 421 ');
|
|
2974
|
+
}
|
|
2975
|
+
});
|
|
2976
|
+
});
|