serverless-ircd 0.4.0 → 0.6.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 +34 -2
- package/.github/workflows/deploy-aws.yml +1 -2
- package/.github/workflows/deploy-cf-tcp.yml +1 -5
- package/.github/workflows/deploy-cf.yml +1 -2
- package/CHANGELOG.md +435 -0
- package/README.md +206 -27
- package/apps/aws-stack/README.md +37 -3
- package/apps/aws-stack/package.json +1 -1
- package/apps/aws-stack/src/aws-stack.ts +106 -13
- package/apps/aws-stack/tests/coverage-command.test.ts +155 -0
- package/apps/aws-stack/tests/stack.test.ts +49 -3
- package/apps/aws-stack/tests/synth-no-bundle.test.ts +129 -0
- package/apps/aws-stack/tests/vitest.setup.ts +18 -0
- package/apps/aws-stack/vitest.config.ts +3 -0
- package/apps/cf-tcp-container/package.json +1 -1
- package/apps/cf-tcp-container/src/config-loader.ts +6 -7
- package/apps/cf-tcp-container/src/container-server.ts +3 -0
- package/apps/cf-tcp-container/tests/config-loader.test.ts +10 -3
- package/apps/cf-tcp-container/wrangler.toml +1 -1
- package/apps/cf-worker/package.json +2 -1
- package/apps/cf-worker/vitest.config.ts +1 -1
- package/apps/cf-worker/wrangler.toml +5 -3
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/config-loader.ts +6 -6
- package/apps/local-cli/src/main.ts +20 -0
- package/apps/local-cli/src/server.ts +138 -28
- package/apps/local-cli/tests/e2e.test.ts +113 -29
- package/apps/local-cli/tests/tcp.test.ts +71 -19
- package/apps/local-cli/tests/ws-subprotocol.test.ts +262 -0
- package/biome.json +1 -0
- package/docs/ADR-001-pure-reducers-and-effect-system.md +74 -0
- package/docs/ADR-002-location-of-authority.md +82 -0
- package/docs/ADR-003-durable-object-sharding.md +110 -0
- package/docs/ADR-004-dynamodb-schema.md +96 -0
- package/docs/ADR-005-wss-only-transport-v1.md +83 -0
- package/docs/ADR-006-sasl-mechanism-scope.md +96 -0
- package/docs/ADR-007-deterministic-ports.md +82 -0
- package/docs/ADR-008-monorepo-tooling.md +60 -0
- package/docs/ADR-009-dual-transport-wss-and-irc-tls.md +138 -0
- package/docs/ADR-Index.md +15 -0
- package/docs/AWS-Adapter-Architecture.md +496 -0
- package/docs/AWS-Deployment.md +1275 -0
- package/docs/AWS-TCP-Deployment.md +410 -0
- package/docs/Cloudflare-Deployment-Guide.md +719 -0
- package/docs/Cloudflare-TCP-Deployment.md +510 -0
- package/docs/Home.md +15 -0
- package/docs/Observability.md +87 -0
- package/docs/PlanExtensions.md +812 -0
- package/docs/PlanIRCv3Websocket.md +503 -0
- package/docs/PlanWebClient.md +451 -0
- package/docs/Release-Process.md +443 -0
- package/package.json +2 -2
- package/packages/aws-adapter/package.json +1 -1
- package/packages/aws-adapter/src/aws-runtime.ts +69 -0
- package/packages/aws-adapter/src/handlers/connect.ts +36 -5
- package/packages/aws-adapter/src/handlers/default.ts +66 -5
- package/packages/aws-adapter/src/handlers/index.ts +41 -2
- package/packages/aws-adapter/src/handlers/nlb-stream.ts +18 -0
- package/packages/aws-adapter/src/index.ts +2 -0
- package/packages/aws-adapter/src/serialize.ts +40 -2
- package/packages/aws-adapter/src/stats.ts +80 -0
- package/packages/aws-adapter/tests/account-store-dynamo.test.ts +49 -8
- package/packages/aws-adapter/tests/account-store.test.ts +4 -7
- package/packages/aws-adapter/tests/aws-harness.ts +5 -2
- package/packages/aws-adapter/tests/aws-integration.test.ts +8 -4
- package/packages/aws-adapter/tests/aws-runtime.test.ts +140 -0
- package/packages/aws-adapter/tests/config-loader.test.ts +11 -2
- package/packages/aws-adapter/tests/connect.test.ts +100 -4
- package/packages/aws-adapter/tests/disconnect-fanout.test.ts +3 -4
- package/packages/aws-adapter/tests/global-setup.ts +55 -30
- package/packages/aws-adapter/tests/gone-exception.test.ts +4 -9
- package/packages/aws-adapter/tests/handlers.test.ts +238 -4
- package/packages/aws-adapter/tests/message-store.test.ts +6 -5
- package/packages/aws-adapter/tests/nlb-stream.test.ts +62 -8
- package/packages/aws-adapter/tests/stats.test.ts +317 -0
- package/packages/cf-adapter/package.json +6 -1
- package/packages/cf-adapter/src/cf-runtime.ts +66 -1
- package/packages/cf-adapter/src/channel-do.ts +2 -2
- package/packages/cf-adapter/src/connection-do.ts +185 -54
- package/packages/cf-adapter/src/env.ts +25 -6
- package/packages/cf-adapter/src/index.ts +2 -0
- package/packages/cf-adapter/src/registry-do.ts +22 -3
- package/packages/cf-adapter/src/serialize.ts +25 -4
- 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 +1 -1
- package/packages/cf-adapter/tests/cf-integration.test.ts +4 -4
- package/packages/cf-adapter/tests/cf-runtime.test.ts +307 -2
- package/packages/cf-adapter/tests/channel-do.test.ts +119 -2
- package/packages/cf-adapter/tests/config-loader.test.ts +49 -0
- package/packages/cf-adapter/tests/connection-do-channel-registration.test.ts +2 -2
- package/packages/cf-adapter/tests/connection-do-no-batching-reservation.test.ts +2 -2
- package/packages/cf-adapter/tests/connection-do-pure.test.ts +91 -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 +48 -0
- package/packages/cf-adapter/tests/registry-do.test.ts +108 -4
- package/packages/cf-adapter/tests/serialize.test.ts +308 -0
- 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 +7 -7
- 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/vitest.config.ts +1 -1
- package/packages/cf-adapter/wrangler.test.toml +7 -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 +1 -1
- package/packages/irc-core/src/admission.ts +16 -15
- package/packages/irc-core/src/caps/capabilities.ts +38 -4
- package/packages/irc-core/src/caps/index.ts +1 -0
- package/packages/irc-core/src/caps/sts.ts +84 -0
- package/packages/irc-core/src/commands/account-notify.ts +53 -0
- package/packages/irc-core/src/commands/away.ts +9 -3
- package/packages/irc-core/src/commands/cap.ts +23 -5
- package/packages/irc-core/src/commands/chathistory.ts +40 -11
- package/packages/irc-core/src/commands/index.ts +42 -0
- package/packages/irc-core/src/commands/invite.ts +2 -4
- package/packages/irc-core/src/commands/isupport.ts +59 -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 +12 -9
- package/packages/irc-core/src/commands/monitor.ts +327 -0
- package/packages/irc-core/src/commands/multiline.ts +256 -0
- 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/pre-away.ts +112 -0
- package/packages/irc-core/src/commands/privmsg.ts +19 -12
- package/packages/irc-core/src/commands/read-marker.ts +108 -0
- package/packages/irc-core/src/commands/registration.ts +8 -0
- package/packages/irc-core/src/commands/rehash.ts +119 -0
- package/packages/irc-core/src/commands/sasl.ts +24 -1
- 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/tagmsg.ts +71 -1
- 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/wallops.ts +118 -0
- package/packages/irc-core/src/commands/whois.ts +5 -0
- package/packages/irc-core/src/config.ts +72 -10
- package/packages/irc-core/src/effects.ts +41 -1
- package/packages/irc-core/src/index.ts +2 -0
- package/packages/irc-core/src/ports.ts +568 -0
- package/packages/irc-core/src/protocol/index.ts +14 -0
- package/packages/irc-core/src/protocol/labeled-response.ts +133 -0
- package/packages/irc-core/src/protocol/numerics.ts +57 -11
- package/packages/irc-core/src/protocol/outbound.ts +36 -4
- package/packages/irc-core/src/protocol/standard-replies.ts +277 -0
- package/packages/irc-core/src/state/connection.ts +32 -1
- package/packages/irc-core/src/types.ts +120 -1
- package/packages/irc-core/src/ws-framing.ts +132 -0
- package/packages/irc-core/src/ws-subprotocol.ts +66 -0
- package/packages/irc-core/stryker.commands.conf.json +1 -2
- package/packages/irc-core/tests/admission.test.ts +18 -0
- package/packages/irc-core/tests/away-store.test.ts +73 -0
- package/packages/irc-core/tests/caps/capabilities.test.ts +119 -2
- package/packages/irc-core/tests/caps/sts.test.ts +123 -0
- package/packages/irc-core/tests/commands/account-notify.test.ts +171 -0
- package/packages/irc-core/tests/commands/away.test.ts +85 -2
- package/packages/irc-core/tests/commands/cap.test.ts +165 -0
- package/packages/irc-core/tests/commands/chathistory.test.ts +148 -1
- package/packages/irc-core/tests/commands/echo-message.test.ts +9 -3
- package/packages/irc-core/tests/commands/isupport.test.ts +80 -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 +129 -0
- package/packages/irc-core/tests/commands/monitor.test.ts +411 -0
- package/packages/irc-core/tests/commands/multiline.test.ts +428 -0
- package/packages/irc-core/tests/commands/pre-away.test.ts +241 -0
- package/packages/irc-core/tests/commands/privmsg.test.ts +154 -3
- package/packages/irc-core/tests/commands/read-marker.test.ts +211 -0
- package/packages/irc-core/tests/commands/registration.test.ts +64 -0
- package/packages/irc-core/tests/commands/rehash.test.ts +171 -0
- package/packages/irc-core/tests/commands/sasl.test.ts +116 -2
- 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 +357 -0
- package/packages/irc-core/tests/commands/trace.test.ts +282 -0
- package/packages/irc-core/tests/commands/wallops.test.ts +231 -0
- package/packages/irc-core/tests/commands/whois.test.ts +34 -0
- package/packages/irc-core/tests/config.test.ts +103 -13
- package/packages/irc-core/tests/dropped-s2s-and-obsolete-verbs.test.ts +90 -0
- package/packages/irc-core/tests/effects.test.ts +14 -0
- package/packages/irc-core/tests/labeled-response.test.ts +181 -0
- package/packages/irc-core/tests/message-tags.test.ts +114 -0
- package/packages/irc-core/tests/monitor-store.test.ts +200 -0
- package/packages/irc-core/tests/numerics.test.ts +90 -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/read-marker-store.test.ts +108 -0
- package/packages/irc-core/tests/standard-replies.test.ts +412 -0
- package/packages/irc-core/tests/stats-store.test.ts +222 -0
- package/packages/irc-core/tests/types.test.ts +27 -0
- 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-server/package.json +1 -1
- package/packages/irc-server/src/actor.ts +699 -19
- package/packages/irc-server/src/dispatch.ts +109 -16
- package/packages/irc-server/src/routing.ts +3 -0
- package/packages/irc-server/src/runtime.ts +31 -0
- package/packages/irc-server/src/transport.ts +10 -7
- package/packages/irc-server/tests/actor.test.ts +2523 -42
- package/packages/irc-server/tests/dispatch.test.ts +300 -2
- package/packages/irc-server/tests/raw-modules.d.ts +11 -0
- package/packages/irc-server/tests/routing.test.ts +1 -0
- package/packages/irc-server/tests/runtime.test.ts +7 -0
- package/packages/irc-test-support/package.json +1 -1
- package/packages/irc-test-support/src/index.ts +6 -0
- package/packages/irc-test-support/src/scenarios.ts +9 -1
- package/packages/irc-test-support/src/test-config.ts +54 -0
- package/packages/irc-test-support/tests/in-memory-scenarios.test.ts +1 -1
- package/packages/irc-test-support/tests/test-config.test.ts +51 -0
- package/pnpm-workspace.yaml +1 -0
- package/tools/ci-hardening/package.json +1 -1
- package/tools/ci-hardening/tests/ddb-local.test.ts +82 -0
- package/tools/load-test/package.json +33 -0
- package/tools/load-test/src/client.ts +351 -0
- package/tools/load-test/src/config.ts +313 -0
- package/tools/load-test/src/harness.ts +116 -0
- package/tools/load-test/src/main.ts +120 -0
- package/tools/load-test/src/metrics.ts +168 -0
- package/tools/load-test/src/report.ts +106 -0
- package/tools/load-test/tests/client.test.ts +212 -0
- package/tools/load-test/tests/config.test.ts +152 -0
- package/tools/load-test/tests/framing.test.ts +37 -0
- package/tools/load-test/tests/harness.test.ts +165 -0
- package/tools/load-test/tests/metrics.test.ts +174 -0
- package/tools/load-test/tests/report.test.ts +161 -0
- package/tools/load-test/tests/smoke.test.ts +67 -0
- package/tools/load-test/tsconfig.build.json +12 -0
- package/tools/load-test/tsconfig.test.json +10 -0
- package/tools/load-test/vitest.config.ts +29 -0
- package/tools/package.json +6 -1
- package/tools/seed-cf-accounts.ts +4 -1
- 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
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
type AccountStore,
|
|
3
|
+
type AwayStore,
|
|
3
4
|
CapturingLogger,
|
|
4
5
|
type ChanName,
|
|
5
6
|
type ChanSnapshot,
|
|
@@ -11,20 +12,27 @@ import {
|
|
|
11
12
|
EmptyMotdProvider,
|
|
12
13
|
FakeClock,
|
|
13
14
|
type FloodControlConfig,
|
|
15
|
+
InMemoryAwayStore,
|
|
14
16
|
InMemoryMessageStore,
|
|
17
|
+
InMemoryMonitorStore,
|
|
15
18
|
InMemoryNickHistoryStore,
|
|
19
|
+
InMemoryReadMarkerStore,
|
|
16
20
|
LogLevel,
|
|
17
21
|
type Logger,
|
|
18
22
|
type MessageStore,
|
|
23
|
+
type MonitorStore,
|
|
19
24
|
type MtlsIdentityProvider,
|
|
20
25
|
type Nick,
|
|
21
26
|
type NickHistoryStore,
|
|
22
27
|
Numerics,
|
|
23
28
|
type RawLine,
|
|
29
|
+
type ReadMarkerStore,
|
|
24
30
|
type SaslPayload,
|
|
25
31
|
type SaslResult,
|
|
26
32
|
SequentialIdFactory,
|
|
27
33
|
type ServerConfig,
|
|
34
|
+
type ServerStats,
|
|
35
|
+
type ServerStatsSnapshot,
|
|
28
36
|
type StoredMessage,
|
|
29
37
|
applyChannelDelta as applyDelta,
|
|
30
38
|
createChannel,
|
|
@@ -35,7 +43,9 @@ import {
|
|
|
35
43
|
toSnapshot,
|
|
36
44
|
} from '@serverless-ircd/irc-core';
|
|
37
45
|
import { describe, expect, it } from 'vitest';
|
|
38
|
-
import { ConnectionActor } from '../src/actor';
|
|
46
|
+
import { ConnectionActor, channelTargetsForMessage } from '../src/actor';
|
|
47
|
+
import type { ActorChannelAccess } from '../src/actor';
|
|
48
|
+
import actorSource from '../src/actor.ts?raw';
|
|
39
49
|
import type { IrcRuntime } from '../src/runtime';
|
|
40
50
|
import { TcpByteStreamTransport, WsTextFrameTransport } from '../src/transport';
|
|
41
51
|
import type { Transport } from '../src/transport';
|
|
@@ -54,6 +64,14 @@ class FakeRuntime implements IrcRuntime {
|
|
|
54
64
|
private readonly channels = new Map<string, ChannelState>();
|
|
55
65
|
private readonly testConnections = new Map<ConnId, ConnectionState>();
|
|
56
66
|
private readonly nicks = new Map<string, ConnId>();
|
|
67
|
+
/**
|
|
68
|
+
* Configurable `reloadConfig` result. When set, the next `reloadConfig()`
|
|
69
|
+
* call returns this value; when `reloadConfigError` is set instead it
|
|
70
|
+
* throws. Leaving both unset throws so a test that drives `REHASH` without
|
|
71
|
+
* configuring the fake fails loudly.
|
|
72
|
+
*/
|
|
73
|
+
reloadConfigResult: ServerConfig | undefined;
|
|
74
|
+
reloadConfigError: unknown;
|
|
57
75
|
|
|
58
76
|
constructor(private readonly clock: { now(): number }) {}
|
|
59
77
|
|
|
@@ -64,6 +82,16 @@ class FakeRuntime implements IrcRuntime {
|
|
|
64
82
|
}
|
|
65
83
|
}
|
|
66
84
|
|
|
85
|
+
/**
|
|
86
|
+
* Drops a connection from the live-connection store while leaving its nick
|
|
87
|
+
* registered. Simulates the race a reducer sees when `lookupNick` resolves
|
|
88
|
+
* a nick but `getConnection` returns null (the connection tore down between
|
|
89
|
+
* the two awaits).
|
|
90
|
+
*/
|
|
91
|
+
dropConnection(id: ConnId): void {
|
|
92
|
+
this.testConnections.delete(id);
|
|
93
|
+
}
|
|
94
|
+
|
|
67
95
|
// Channel-state access (used by the actor's `channels` dep).
|
|
68
96
|
getOrCreateChannel(name: ChanName): ChannelState {
|
|
69
97
|
const key = name.toLowerCase();
|
|
@@ -84,6 +112,9 @@ class FakeRuntime implements IrcRuntime {
|
|
|
84
112
|
async broadcast(chan: ChanName, lines: RawLine[], except?: ConnId): Promise<void> {
|
|
85
113
|
this.calls.push({ method: 'broadcast', args: [chan, lines, except] });
|
|
86
114
|
}
|
|
115
|
+
async broadcastWallops(lines: RawLine[], except?: ConnId): Promise<void> {
|
|
116
|
+
this.calls.push({ method: 'broadcastWallops', args: [lines, except] });
|
|
117
|
+
}
|
|
87
118
|
async disconnect(conn: ConnId, reason?: string): Promise<void> {
|
|
88
119
|
this.calls.push({ method: 'disconnect', args: [conn, reason] });
|
|
89
120
|
}
|
|
@@ -97,14 +128,27 @@ class FakeRuntime implements IrcRuntime {
|
|
|
97
128
|
}
|
|
98
129
|
async reserveNick(nick: Nick, conn: ConnId): Promise<{ ok: true } | { ok: false }> {
|
|
99
130
|
this.calls.push({ method: 'reserveNick', args: [nick, conn] });
|
|
131
|
+
const key = nick.toLowerCase();
|
|
132
|
+
const existing = this.nicks.get(key);
|
|
133
|
+
if (existing !== undefined && existing !== conn) {
|
|
134
|
+
return { ok: false };
|
|
135
|
+
}
|
|
136
|
+
this.nicks.set(key, conn);
|
|
100
137
|
return { ok: true };
|
|
101
138
|
}
|
|
102
139
|
async changeNick(conn: ConnId, oldNick: Nick, newNick: Nick): Promise<boolean> {
|
|
103
140
|
this.calls.push({ method: 'changeNick', args: [conn, oldNick, newNick] });
|
|
141
|
+
const newKey = newNick.toLowerCase();
|
|
142
|
+
const oldKey = oldNick.toLowerCase();
|
|
143
|
+
const owner = this.nicks.get(newKey);
|
|
144
|
+
if (owner !== undefined && owner !== conn) return false;
|
|
145
|
+
this.nicks.set(newKey, conn);
|
|
146
|
+
if (this.nicks.get(oldKey) === conn) this.nicks.delete(oldKey);
|
|
104
147
|
return true;
|
|
105
148
|
}
|
|
106
149
|
async releaseNick(nick: Nick): Promise<void> {
|
|
107
150
|
this.calls.push({ method: 'releaseNick', args: [nick] });
|
|
151
|
+
this.nicks.delete(nick.toLowerCase());
|
|
108
152
|
}
|
|
109
153
|
async applyChannelDelta(chan: ChanName, delta: ChannelDelta): Promise<void> {
|
|
110
154
|
this.calls.push({ method: 'applyChannelDelta', args: [chan, delta] });
|
|
@@ -156,6 +200,15 @@ class FakeRuntime implements IrcRuntime {
|
|
|
156
200
|
}
|
|
157
201
|
return out;
|
|
158
202
|
}
|
|
203
|
+
|
|
204
|
+
async reloadConfig(): Promise<ServerConfig> {
|
|
205
|
+
this.calls.push({ method: 'reloadConfig', args: [] });
|
|
206
|
+
if (this.reloadConfigError !== undefined) throw this.reloadConfigError;
|
|
207
|
+
if (this.reloadConfigResult === undefined) {
|
|
208
|
+
throw new Error('FakeRuntime.reloadConfigResult not configured');
|
|
209
|
+
}
|
|
210
|
+
return this.reloadConfigResult;
|
|
211
|
+
}
|
|
159
212
|
}
|
|
160
213
|
|
|
161
214
|
function makeActor(opts: {
|
|
@@ -168,8 +221,14 @@ function makeActor(opts: {
|
|
|
168
221
|
accounts?: AccountStore;
|
|
169
222
|
mtlsIdentity?: MtlsIdentityProvider;
|
|
170
223
|
history?: NickHistoryStore;
|
|
224
|
+
stats?: ServerStats;
|
|
171
225
|
operCreds?: ReadonlyArray<{ user: string; password: string }>;
|
|
172
226
|
transport?: Transport;
|
|
227
|
+
configSource?: string;
|
|
228
|
+
secure?: boolean;
|
|
229
|
+
monitor?: MonitorStore;
|
|
230
|
+
away?: AwayStore;
|
|
231
|
+
readMarkers?: ReadMarkerStore;
|
|
173
232
|
}): { actor: ConnectionActor; runtime: FakeRuntime; conn: ConnectionState } {
|
|
174
233
|
const clock = opts.clock ?? new FakeClock(1_000);
|
|
175
234
|
const conn = createConnection({ id: 'c1', connectedSince: 0 });
|
|
@@ -229,9 +288,27 @@ function makeActor(opts: {
|
|
|
229
288
|
if (opts.history !== undefined) {
|
|
230
289
|
finalOpts.history = opts.history;
|
|
231
290
|
}
|
|
291
|
+
if (opts.stats !== undefined) {
|
|
292
|
+
finalOpts.stats = opts.stats;
|
|
293
|
+
}
|
|
232
294
|
if (opts.transport !== undefined) {
|
|
233
295
|
finalOpts.transport = opts.transport;
|
|
234
296
|
}
|
|
297
|
+
if (opts.configSource !== undefined) {
|
|
298
|
+
finalOpts.configSource = opts.configSource;
|
|
299
|
+
}
|
|
300
|
+
if (opts.secure !== undefined) {
|
|
301
|
+
finalOpts.secure = opts.secure;
|
|
302
|
+
}
|
|
303
|
+
if (opts.monitor !== undefined) {
|
|
304
|
+
finalOpts.monitor = opts.monitor;
|
|
305
|
+
}
|
|
306
|
+
if (opts.away !== undefined) {
|
|
307
|
+
finalOpts.away = opts.away;
|
|
308
|
+
}
|
|
309
|
+
if (opts.readMarkers !== undefined) {
|
|
310
|
+
finalOpts.readMarkers = opts.readMarkers;
|
|
311
|
+
}
|
|
235
312
|
const actor = new ConnectionActor(finalOpts);
|
|
236
313
|
return { actor, runtime, conn };
|
|
237
314
|
}
|
|
@@ -241,23 +318,75 @@ void isChannelTarget;
|
|
|
241
318
|
|
|
242
319
|
describe('ConnectionActor — framing', () => {
|
|
243
320
|
it('processes one PRIVMSG in a single text frame into one broadcast', async () => {
|
|
321
|
+
const bobConn = createConnection({ id: 'c2', connectedSince: 0 });
|
|
322
|
+
bobConn.nick = 'bob';
|
|
323
|
+
bobConn.user = 'bob';
|
|
324
|
+
bobConn.host = 'example.org';
|
|
325
|
+
bobConn.registration = 'registered';
|
|
244
326
|
const { actor, runtime } = makeActor({});
|
|
327
|
+
runtime.addConnection(bobConn);
|
|
245
328
|
// Pre-add the connection to the channel so the broadcast excludes the sender.
|
|
246
329
|
await runtime.applyChannelDelta('#foo', {
|
|
247
|
-
memberships: [
|
|
330
|
+
memberships: [
|
|
331
|
+
{ type: 'add', conn: 'c1', nick: 'alice' },
|
|
332
|
+
{ type: 'add', conn: 'c2', nick: 'bob' },
|
|
333
|
+
],
|
|
248
334
|
});
|
|
249
335
|
runtime.calls.length = 0;
|
|
250
336
|
|
|
251
337
|
await actor.receiveTextFrame('PRIVMSG #foo :hello\r\n');
|
|
252
338
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
339
|
+
// Channel PRIVMSG fans out cap-split (msgid): dispatch resolves each
|
|
340
|
+
// member's caps and sends per-recipient. bob (no msgid cap) gets the bare
|
|
341
|
+
// line; the sender (c1) is excluded.
|
|
342
|
+
const toBob = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c2');
|
|
343
|
+
expect(toBob).toHaveLength(1);
|
|
344
|
+
expect(toBob[0]?.args[0]).toBe('c2');
|
|
345
|
+
const lines = toBob[0]?.args[1] as RawLine[];
|
|
258
346
|
expect(lines[0]?.text).toBe(':alice!alice@example.com PRIVMSG #foo :hello');
|
|
259
347
|
});
|
|
260
348
|
|
|
349
|
+
it('delivers @msgid=<id> to a msgid-cap peer and the bare line to a non-cap peer', async () => {
|
|
350
|
+
const bobConn = createConnection({ id: 'c2', connectedSince: 0 });
|
|
351
|
+
bobConn.nick = 'bob';
|
|
352
|
+
bobConn.user = 'bob';
|
|
353
|
+
bobConn.host = 'example.org';
|
|
354
|
+
bobConn.registration = 'registered';
|
|
355
|
+
bobConn.caps = new Set<string>(['msgid']);
|
|
356
|
+
const carolConn = createConnection({ id: 'c3', connectedSince: 0 });
|
|
357
|
+
carolConn.nick = 'carol';
|
|
358
|
+
carolConn.user = 'carol';
|
|
359
|
+
carolConn.host = 'example.net';
|
|
360
|
+
carolConn.registration = 'registered';
|
|
361
|
+
const { actor, runtime } = makeActor({});
|
|
362
|
+
runtime.addConnection(bobConn);
|
|
363
|
+
runtime.addConnection(carolConn);
|
|
364
|
+
await runtime.applyChannelDelta('#foo', {
|
|
365
|
+
memberships: [
|
|
366
|
+
{ type: 'add', conn: 'c1', nick: 'alice' },
|
|
367
|
+
{ type: 'add', conn: 'c2', nick: 'bob' },
|
|
368
|
+
{ type: 'add', conn: 'c3', nick: 'carol' },
|
|
369
|
+
],
|
|
370
|
+
});
|
|
371
|
+
runtime.calls.length = 0;
|
|
372
|
+
|
|
373
|
+
await actor.receiveTextFrame('PRIVMSG #foo :hi\r\n');
|
|
374
|
+
|
|
375
|
+
// bob (msgid cap) receives the decorated line; carol (no cap) receives
|
|
376
|
+
// the bare line. The sender (c1) is excluded from both.
|
|
377
|
+
const toBob = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c2');
|
|
378
|
+
const toCarol = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c3');
|
|
379
|
+
expect(toBob).toHaveLength(1);
|
|
380
|
+
expect(toCarol).toHaveLength(1);
|
|
381
|
+
const bobLine = (toBob[0]?.args[1] as RawLine[])[0]?.text ?? '';
|
|
382
|
+
// bob sees the msgid-tagged line; carol sees the bare line. The msgid
|
|
383
|
+
// value is opaque (an IdFactory nonce), so match the shape not the value.
|
|
384
|
+
expect(bobLine).toMatch(/^@msgid=nonce-\d+ :alice!alice@example\.com PRIVMSG #foo :hi$/);
|
|
385
|
+
expect((toCarol[0]?.args[1] as RawLine[])[0]?.text).toBe(
|
|
386
|
+
':alice!alice@example.com PRIVMSG #foo :hi',
|
|
387
|
+
);
|
|
388
|
+
});
|
|
389
|
+
|
|
261
390
|
it('tolerates a frame without a trailing CRLF', async () => {
|
|
262
391
|
const { actor, runtime } = makeActor({});
|
|
263
392
|
await actor.receiveTextFrame('PING :tok');
|
|
@@ -297,9 +426,18 @@ describe('ConnectionActor — framing', () => {
|
|
|
297
426
|
|
|
298
427
|
describe('ConnectionActor — TCP byte-stream transport seam', () => {
|
|
299
428
|
it('two TCP chunks split mid-line produce exactly one PRIVMSG broadcast', async () => {
|
|
429
|
+
const bobConn = createConnection({ id: 'c2', connectedSince: 0 });
|
|
430
|
+
bobConn.nick = 'bob';
|
|
431
|
+
bobConn.user = 'bob';
|
|
432
|
+
bobConn.host = 'example.org';
|
|
433
|
+
bobConn.registration = 'registered';
|
|
300
434
|
const { actor, runtime } = makeActor({ transport: new TcpByteStreamTransport() });
|
|
435
|
+
runtime.addConnection(bobConn);
|
|
301
436
|
await runtime.applyChannelDelta('#foo', {
|
|
302
|
-
memberships: [
|
|
437
|
+
memberships: [
|
|
438
|
+
{ type: 'add', conn: 'c1', nick: 'alice' },
|
|
439
|
+
{ type: 'add', conn: 'c2', nick: 'bob' },
|
|
440
|
+
],
|
|
303
441
|
});
|
|
304
442
|
runtime.calls.length = 0;
|
|
305
443
|
|
|
@@ -309,10 +447,10 @@ describe('ConnectionActor — TCP byte-stream transport seam', () => {
|
|
|
309
447
|
expect(runtime.calls).toHaveLength(0);
|
|
310
448
|
await actor.receive('lo\r\n');
|
|
311
449
|
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
expect(
|
|
315
|
-
const lines =
|
|
450
|
+
// Exactly one fanout to bob (cap-split dispatch sends per-recipient).
|
|
451
|
+
const toBob = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c2');
|
|
452
|
+
expect(toBob).toHaveLength(1);
|
|
453
|
+
const lines = toBob[0]?.args[1] as RawLine[];
|
|
316
454
|
expect(lines[0]?.text).toBe(':alice!alice@example.com PRIVMSG #foo :hello');
|
|
317
455
|
});
|
|
318
456
|
|
|
@@ -323,8 +461,17 @@ describe('ConnectionActor — TCP byte-stream transport seam', () => {
|
|
|
323
461
|
const wsSetup = makeActor({});
|
|
324
462
|
const tcpSetup = makeActor({ transport: new TcpByteStreamTransport() });
|
|
325
463
|
for (const rt of [wsSetup.runtime, tcpSetup.runtime]) {
|
|
464
|
+
const bobConn = createConnection({ id: 'c2', connectedSince: 0 });
|
|
465
|
+
bobConn.nick = 'bob';
|
|
466
|
+
bobConn.user = 'bob';
|
|
467
|
+
bobConn.host = 'example.org';
|
|
468
|
+
bobConn.registration = 'registered';
|
|
469
|
+
rt.addConnection(bobConn);
|
|
326
470
|
await rt.applyChannelDelta('#foo', {
|
|
327
|
-
memberships: [
|
|
471
|
+
memberships: [
|
|
472
|
+
{ type: 'add', conn: 'c1', nick: 'alice' },
|
|
473
|
+
{ type: 'add', conn: 'c2', nick: 'bob' },
|
|
474
|
+
],
|
|
328
475
|
});
|
|
329
476
|
}
|
|
330
477
|
wsSetup.runtime.calls.length = 0;
|
|
@@ -337,18 +484,30 @@ describe('ConnectionActor — TCP byte-stream transport seam', () => {
|
|
|
337
484
|
await tcpSetup.actor.receive(stream.slice(i, i + 4));
|
|
338
485
|
}
|
|
339
486
|
|
|
340
|
-
|
|
341
|
-
const
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
487
|
+
// Both transports deliver exactly one fanout to bob with identical bytes.
|
|
488
|
+
const wsSends = wsSetup.runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c2');
|
|
489
|
+
const tcpSends = tcpSetup.runtime.calls.filter(
|
|
490
|
+
(c) => c.method === 'send' && c.args[0] === 'c2',
|
|
491
|
+
);
|
|
492
|
+
expect(tcpSends).toHaveLength(wsSends.length);
|
|
493
|
+
expect((tcpSends[0]?.args[1] as RawLine[])[0]?.text).toBe(
|
|
494
|
+
(wsSends[0]?.args[1] as RawLine[])[0]?.text,
|
|
345
495
|
);
|
|
346
496
|
});
|
|
347
497
|
|
|
348
498
|
it('buffers a partial line across multiple chunks with no loss or duplication', async () => {
|
|
499
|
+
const bobConn = createConnection({ id: 'c2', connectedSince: 0 });
|
|
500
|
+
bobConn.nick = 'bob';
|
|
501
|
+
bobConn.user = 'bob';
|
|
502
|
+
bobConn.host = 'example.org';
|
|
503
|
+
bobConn.registration = 'registered';
|
|
349
504
|
const { actor, runtime } = makeActor({ transport: new TcpByteStreamTransport() });
|
|
505
|
+
runtime.addConnection(bobConn);
|
|
350
506
|
await runtime.applyChannelDelta('#foo', {
|
|
351
|
-
memberships: [
|
|
507
|
+
memberships: [
|
|
508
|
+
{ type: 'add', conn: 'c1', nick: 'alice' },
|
|
509
|
+
{ type: 'add', conn: 'c2', nick: 'bob' },
|
|
510
|
+
],
|
|
352
511
|
});
|
|
353
512
|
runtime.calls.length = 0;
|
|
354
513
|
|
|
@@ -358,13 +517,14 @@ describe('ConnectionActor — TCP byte-stream transport seam', () => {
|
|
|
358
517
|
for (const ch of stream) {
|
|
359
518
|
await actor.receive(ch);
|
|
360
519
|
}
|
|
361
|
-
const
|
|
362
|
-
|
|
363
|
-
expect(
|
|
364
|
-
expect((
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
expect((
|
|
520
|
+
const pongs = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
|
|
521
|
+
expect(pongs).toHaveLength(2); // PONG :a, PONG :c
|
|
522
|
+
expect((pongs[0]?.args[1] as RawLine[])[0]?.text).toBe('PONG :a');
|
|
523
|
+
expect((pongs[1]?.args[1] as RawLine[])[0]?.text).toBe('PONG :c');
|
|
524
|
+
// PRIVMSG fans out to bob exactly once (cap-split per-recipient send).
|
|
525
|
+
const toBob = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c2');
|
|
526
|
+
expect(toBob).toHaveLength(1); // PRIVMSG #foo :b
|
|
527
|
+
expect((toBob[0]?.args[1] as RawLine[])[0]?.text).toBe(
|
|
368
528
|
':alice!alice@example.com PRIVMSG #foo :b',
|
|
369
529
|
);
|
|
370
530
|
});
|
|
@@ -489,6 +649,50 @@ describe('ConnectionActor — routing', () => {
|
|
|
489
649
|
});
|
|
490
650
|
});
|
|
491
651
|
|
|
652
|
+
describe('ConnectionActor — secure transport (user mode `S`)', () => {
|
|
653
|
+
it('marks the connection state secure when constructed with secure: true', () => {
|
|
654
|
+
const { conn } = makeActor({ secure: true });
|
|
655
|
+
expect(conn.secure).toBe(true);
|
|
656
|
+
});
|
|
657
|
+
|
|
658
|
+
it('defaults to insecure when secure is omitted', () => {
|
|
659
|
+
const { conn } = makeActor({});
|
|
660
|
+
expect(conn.secure).toBe(false);
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
it('sets user mode `S` at registration when constructed with secure: true', async () => {
|
|
664
|
+
const { actor, conn } = makeActor({ secure: true });
|
|
665
|
+
// Reset to pre-registration so the NICK/USER handshake drives welcome.
|
|
666
|
+
conn.registration = 'pre-registration';
|
|
667
|
+
// biome-ignore lint/performance/noDelete: exactOptionalPropertyTypes
|
|
668
|
+
delete conn.nick;
|
|
669
|
+
// biome-ignore lint/performance/noDelete: exactOptionalPropertyTypes
|
|
670
|
+
delete conn.user;
|
|
671
|
+
|
|
672
|
+
await actor.receiveTextFrame('USER alice 0 * :Alice\r\n');
|
|
673
|
+
await actor.receiveTextFrame('NICK alice\r\n');
|
|
674
|
+
|
|
675
|
+
expect(conn.registration).toBe('registered');
|
|
676
|
+
expect(conn.userModes.tls).toBe(true);
|
|
677
|
+
expect(conn.secure).toBe(true);
|
|
678
|
+
});
|
|
679
|
+
|
|
680
|
+
it('does not set user mode `S` when constructed without secure', async () => {
|
|
681
|
+
const { actor, conn } = makeActor({});
|
|
682
|
+
conn.registration = 'pre-registration';
|
|
683
|
+
// biome-ignore lint/performance/noDelete: exactOptionalPropertyTypes
|
|
684
|
+
delete conn.nick;
|
|
685
|
+
// biome-ignore lint/performance/noDelete: exactOptionalPropertyTypes
|
|
686
|
+
delete conn.user;
|
|
687
|
+
|
|
688
|
+
await actor.receiveTextFrame('USER alice 0 * :Alice\r\n');
|
|
689
|
+
await actor.receiveTextFrame('NICK alice\r\n');
|
|
690
|
+
|
|
691
|
+
expect(conn.registration).toBe('registered');
|
|
692
|
+
expect(conn.userModes.tls).toBe(false);
|
|
693
|
+
});
|
|
694
|
+
});
|
|
695
|
+
|
|
492
696
|
describe('ConnectionActor — additional routing', () => {
|
|
493
697
|
it('routes PART through routeMultiChannel (one channel per invocation)', async () => {
|
|
494
698
|
const { actor, runtime } = makeActor({});
|
|
@@ -610,7 +814,9 @@ describe('ConnectionActor — additional routing', () => {
|
|
|
610
814
|
});
|
|
611
815
|
runtime.calls.length = 0;
|
|
612
816
|
await actor.receiveTextFrame('NOTICE #foo :note\r\n');
|
|
613
|
-
|
|
817
|
+
// NOTICE channel fanout is cap-split (msgid) so dispatch resolves via
|
|
818
|
+
// getChannelSnapshot rather than the fast-path broadcast.
|
|
819
|
+
expect(runtime.calls.filter((c) => c.method === 'getChannelSnapshot')).toHaveLength(1);
|
|
614
820
|
});
|
|
615
821
|
|
|
616
822
|
it('routes TAGMSG to a channel broadcast (never 421) when message-tags cap is present', async () => {
|
|
@@ -671,8 +877,8 @@ describe('ConnectionActor — additional routing', () => {
|
|
|
671
877
|
});
|
|
672
878
|
runtime.calls.length = 0;
|
|
673
879
|
await actor.receiveTextFrame('PRIVMSG #foo,bob :hi\r\n');
|
|
674
|
-
// One
|
|
675
|
-
expect(runtime.calls.filter((c) => c.method === '
|
|
880
|
+
// One channel fanout (cap-split → getChannelSnapshot) + one sendToNick.
|
|
881
|
+
expect(runtime.calls.filter((c) => c.method === 'getChannelSnapshot')).toHaveLength(1);
|
|
676
882
|
expect(runtime.calls.filter((c) => c.method === 'sendToNick')).toHaveLength(1);
|
|
677
883
|
});
|
|
678
884
|
|
|
@@ -785,6 +991,24 @@ describe('ConnectionActor — additional routing', () => {
|
|
|
785
991
|
}
|
|
786
992
|
});
|
|
787
993
|
|
|
994
|
+
it('routes WHOIS with no parameter through whoisReducer (just 318, never 421)', async () => {
|
|
995
|
+
// Covers the `targetNick.length > 0` false branch in routeWhois:
|
|
996
|
+
// a bare `WHOIS` resolves no target and defers to the reducer, which
|
|
997
|
+
// emits only the 318 end marker.
|
|
998
|
+
const { actor, runtime } = makeActor({});
|
|
999
|
+
runtime.calls.length = 0;
|
|
1000
|
+
|
|
1001
|
+
await actor.receiveTextFrame('WHOIS\r\n');
|
|
1002
|
+
|
|
1003
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
1004
|
+
expect(sends.length).toBeGreaterThanOrEqual(1);
|
|
1005
|
+
const allLines = sends.flatMap((s) => s.args[1] as RawLine[]);
|
|
1006
|
+
expect(allLines.some((l) => l.text.includes(' 318 '))).toBe(true);
|
|
1007
|
+
for (const line of allLines) {
|
|
1008
|
+
expect(line.text).not.toContain(' 421 ');
|
|
1009
|
+
}
|
|
1010
|
+
});
|
|
1011
|
+
|
|
788
1012
|
it('routes WHOWAS of a quit nick through whowasReducer (314 + 369, never 421)', async () => {
|
|
789
1013
|
const history = new InMemoryNickHistoryStore(new FakeClock(1_000));
|
|
790
1014
|
history.record({
|
|
@@ -968,6 +1192,34 @@ describe('ConnectionActor — additional routing', () => {
|
|
|
968
1192
|
}
|
|
969
1193
|
});
|
|
970
1194
|
|
|
1195
|
+
it('routes SETNAME :<realname> through setnameReducer, mutating state.realname (never 421)', async () => {
|
|
1196
|
+
const { actor, runtime, conn } = makeActor({});
|
|
1197
|
+
runtime.calls.length = 0;
|
|
1198
|
+
|
|
1199
|
+
await actor.receiveTextFrame('SETNAME :New Name\r\n');
|
|
1200
|
+
|
|
1201
|
+
expect(conn.realname).toBe('New Name');
|
|
1202
|
+
// Silent update: no Send reply and no broadcast, just the state mutation.
|
|
1203
|
+
expect(runtime.calls).toHaveLength(0);
|
|
1204
|
+
});
|
|
1205
|
+
|
|
1206
|
+
it('routes SETNAME with no param to 461 (never 421)', async () => {
|
|
1207
|
+
const { actor, runtime } = makeActor({});
|
|
1208
|
+
runtime.calls.length = 0;
|
|
1209
|
+
|
|
1210
|
+
await actor.receiveTextFrame('SETNAME\r\n');
|
|
1211
|
+
|
|
1212
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
1213
|
+
expect(sends).toHaveLength(1);
|
|
1214
|
+
const lines = sends[0]?.args[1] as RawLine[];
|
|
1215
|
+
expect(lines[0]?.text).toBe(':irc.example.com 461 alice SETNAME :Not enough parameters');
|
|
1216
|
+
for (const s of sends) {
|
|
1217
|
+
for (const line of s.args[1] as RawLine[]) {
|
|
1218
|
+
expect(line.text).not.toContain(' 421 ');
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
});
|
|
1222
|
+
|
|
971
1223
|
it('routes OPER with matching creds through operReducer (381, never 421)', async () => {
|
|
972
1224
|
const { actor, runtime, conn } = makeActor({
|
|
973
1225
|
operCreds: [{ user: 'alice', password: 'secret' }],
|
|
@@ -1046,6 +1298,260 @@ describe('ConnectionActor — additional routing', () => {
|
|
|
1046
1298
|
expect(lines[0]?.text).toBe(':irc.example.com 421 alice :Unknown command');
|
|
1047
1299
|
});
|
|
1048
1300
|
|
|
1301
|
+
it('routes WALLOPS from an oper through wallopsReducer (broadcastWallops, never 421)', async () => {
|
|
1302
|
+
const { actor, runtime } = makeActor({
|
|
1303
|
+
conn: {
|
|
1304
|
+
userModes: {
|
|
1305
|
+
invisible: false,
|
|
1306
|
+
oper: true,
|
|
1307
|
+
wallops: false,
|
|
1308
|
+
serverNotices: false,
|
|
1309
|
+
tls: false,
|
|
1310
|
+
},
|
|
1311
|
+
},
|
|
1312
|
+
});
|
|
1313
|
+
runtime.calls.length = 0;
|
|
1314
|
+
|
|
1315
|
+
await actor.receiveTextFrame('WALLOPS :reboot in 5\r\n');
|
|
1316
|
+
|
|
1317
|
+
// Oper happy path: one broadcastWallops runtime call, carrying the
|
|
1318
|
+
// oper-attributed line and excepting the sender.
|
|
1319
|
+
const wallops = runtime.calls.filter((c) => c.method === 'broadcastWallops');
|
|
1320
|
+
expect(wallops).toHaveLength(1);
|
|
1321
|
+
const lines = wallops[0]?.args[0] as RawLine[];
|
|
1322
|
+
const except = wallops[0]?.args[1];
|
|
1323
|
+
expect(lines[0]?.text).toBe(':alice!alice@example.com WALLOPS :reboot in 5');
|
|
1324
|
+
expect(except).toBe('c1');
|
|
1325
|
+
// Critical: WALLOPS must reach the reducer, never the 421 fallthrough.
|
|
1326
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
1327
|
+
for (const s of sends) {
|
|
1328
|
+
for (const line of s.args[1] as RawLine[]) {
|
|
1329
|
+
expect(line.text).not.toContain(' 421 ');
|
|
1330
|
+
}
|
|
1331
|
+
}
|
|
1332
|
+
});
|
|
1333
|
+
|
|
1334
|
+
it('routes WALLOPS from a non-oper through wallopsReducer (481, never 421)', async () => {
|
|
1335
|
+
const { actor, runtime } = makeActor({});
|
|
1336
|
+
runtime.calls.length = 0;
|
|
1337
|
+
|
|
1338
|
+
await actor.receiveTextFrame('WALLOPS :hi\r\n');
|
|
1339
|
+
|
|
1340
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
1341
|
+
expect(sends).toHaveLength(1);
|
|
1342
|
+
const lines = sends[0]?.args[1] as RawLine[];
|
|
1343
|
+
expect(lines[0]?.text).toContain(' 481 ');
|
|
1344
|
+
// A non-oper must never trigger global fanout.
|
|
1345
|
+
expect(runtime.calls.some((c) => c.method === 'broadcastWallops')).toBe(false);
|
|
1346
|
+
for (const s of sends) {
|
|
1347
|
+
for (const line of s.args[1] as RawLine[]) {
|
|
1348
|
+
expect(line.text).not.toContain(' 421 ');
|
|
1349
|
+
}
|
|
1350
|
+
}
|
|
1351
|
+
});
|
|
1352
|
+
|
|
1353
|
+
it('routes WALLOPS with no message through wallopsReducer (412, never 421)', async () => {
|
|
1354
|
+
const { actor, runtime } = makeActor({
|
|
1355
|
+
conn: {
|
|
1356
|
+
userModes: {
|
|
1357
|
+
invisible: false,
|
|
1358
|
+
oper: true,
|
|
1359
|
+
wallops: false,
|
|
1360
|
+
serverNotices: false,
|
|
1361
|
+
tls: false,
|
|
1362
|
+
},
|
|
1363
|
+
},
|
|
1364
|
+
});
|
|
1365
|
+
runtime.calls.length = 0;
|
|
1366
|
+
|
|
1367
|
+
await actor.receiveTextFrame('WALLOPS\r\n');
|
|
1368
|
+
|
|
1369
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
1370
|
+
expect(sends).toHaveLength(1);
|
|
1371
|
+
const lines = sends[0]?.args[1] as RawLine[];
|
|
1372
|
+
expect(lines[0]?.text).toContain(' 412 ');
|
|
1373
|
+
expect(runtime.calls.some((c) => c.method === 'broadcastWallops')).toBe(false);
|
|
1374
|
+
for (const s of sends) {
|
|
1375
|
+
for (const line of s.args[1] as RawLine[]) {
|
|
1376
|
+
expect(line.text).not.toContain(' 421 ');
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1379
|
+
});
|
|
1380
|
+
|
|
1381
|
+
it('routes REHASH from an oper through rehashReducer (382, never 421) and re-invokes the loader', async () => {
|
|
1382
|
+
const { actor, runtime, conn } = makeActor({
|
|
1383
|
+
conn: {
|
|
1384
|
+
userModes: {
|
|
1385
|
+
invisible: false,
|
|
1386
|
+
oper: true,
|
|
1387
|
+
wallops: false,
|
|
1388
|
+
serverNotices: false,
|
|
1389
|
+
tls: false,
|
|
1390
|
+
},
|
|
1391
|
+
},
|
|
1392
|
+
configSource: 'KV',
|
|
1393
|
+
});
|
|
1394
|
+
runtime.reloadConfigResult = {
|
|
1395
|
+
serverName: 'irc.reloaded.test',
|
|
1396
|
+
networkName: 'ExampleNet',
|
|
1397
|
+
maxChannelsPerUser: 30,
|
|
1398
|
+
maxTargetsPerCommand: 10,
|
|
1399
|
+
maxListEntries: 50,
|
|
1400
|
+
nickLen: 30,
|
|
1401
|
+
channelLen: 50,
|
|
1402
|
+
topicLen: 390,
|
|
1403
|
+
quitMessage: 'Client Quit',
|
|
1404
|
+
};
|
|
1405
|
+
runtime.calls.length = 0;
|
|
1406
|
+
|
|
1407
|
+
await actor.receiveTextFrame('REHASH\r\n');
|
|
1408
|
+
|
|
1409
|
+
// 382 emitted, never 421.
|
|
1410
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
1411
|
+
expect(sends).toHaveLength(1);
|
|
1412
|
+
const firstLines = sends[0]?.args[1] as RawLine[];
|
|
1413
|
+
expect(firstLines[0]?.text).toBe(':irc.example.com 382 alice :Rehashing from KV');
|
|
1414
|
+
for (const s of sends) {
|
|
1415
|
+
for (const line of s.args[1] as RawLine[]) {
|
|
1416
|
+
expect(line.text).not.toContain(' 421 ');
|
|
1417
|
+
}
|
|
1418
|
+
}
|
|
1419
|
+
// The bound loader was actually re-invoked.
|
|
1420
|
+
expect(runtime.calls.some((c) => c.method === 'reloadConfig')).toBe(true);
|
|
1421
|
+
void conn;
|
|
1422
|
+
// The live config was swapped: a subsequent REHASH's 382 carries the
|
|
1423
|
+
// reloaded server name (ctx is rebuilt from the swapped serverConfig).
|
|
1424
|
+
runtime.calls.length = 0;
|
|
1425
|
+
await actor.receiveTextFrame('REHASH\r\n');
|
|
1426
|
+
const secondSends = runtime.calls.filter((c) => c.method === 'send');
|
|
1427
|
+
const secondLines = secondSends[0]?.args[1] as RawLine[];
|
|
1428
|
+
expect(secondLines[0]?.text).toBe(':irc.reloaded.test 382 alice :Rehashing from KV');
|
|
1429
|
+
});
|
|
1430
|
+
|
|
1431
|
+
it('routes REHASH from a non-oper through rehashReducer (481) and does NOT reload', async () => {
|
|
1432
|
+
const { actor, runtime } = makeActor({ configSource: 'KV' });
|
|
1433
|
+
runtime.calls.length = 0;
|
|
1434
|
+
|
|
1435
|
+
await actor.receiveTextFrame('REHASH\r\n');
|
|
1436
|
+
|
|
1437
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
1438
|
+
expect(sends).toHaveLength(1);
|
|
1439
|
+
const lines = sends[0]?.args[1] as RawLine[];
|
|
1440
|
+
expect(lines[0]?.text).toContain(' 481 ');
|
|
1441
|
+
// Critical: a non-oper must not trigger a config reload.
|
|
1442
|
+
expect(runtime.calls.some((c) => c.method === 'reloadConfig')).toBe(false);
|
|
1443
|
+
for (const s of sends) {
|
|
1444
|
+
for (const line of s.args[1] as RawLine[]) {
|
|
1445
|
+
expect(line.text).not.toContain(' 421 ');
|
|
1446
|
+
}
|
|
1447
|
+
}
|
|
1448
|
+
});
|
|
1449
|
+
|
|
1450
|
+
it('keeps the oper connected and retains the prior config when the loader rejects', async () => {
|
|
1451
|
+
const { actor, runtime } = makeActor({
|
|
1452
|
+
conn: {
|
|
1453
|
+
userModes: {
|
|
1454
|
+
invisible: false,
|
|
1455
|
+
oper: true,
|
|
1456
|
+
wallops: false,
|
|
1457
|
+
serverNotices: false,
|
|
1458
|
+
tls: false,
|
|
1459
|
+
},
|
|
1460
|
+
},
|
|
1461
|
+
configSource: 'KV',
|
|
1462
|
+
});
|
|
1463
|
+
runtime.reloadConfigError = new Error('store unreachable');
|
|
1464
|
+
runtime.calls.length = 0;
|
|
1465
|
+
|
|
1466
|
+
await actor.receiveTextFrame('REHASH\r\n');
|
|
1467
|
+
|
|
1468
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
1469
|
+
// Optimistic 382 + graceful error-suffixed 382; oper NOT disconnected.
|
|
1470
|
+
expect(sends).toHaveLength(2);
|
|
1471
|
+
const lines = [
|
|
1472
|
+
...((sends[0]?.args[1] as RawLine[]) ?? []),
|
|
1473
|
+
...((sends[1]?.args[1] as RawLine[]) ?? []),
|
|
1474
|
+
];
|
|
1475
|
+
expect(lines[0]?.text).toBe(':irc.example.com 382 alice :Rehashing from KV');
|
|
1476
|
+
expect(lines[1]?.text).toBe(
|
|
1477
|
+
':irc.example.com 382 alice :Rehashing from KV failed: store unreachable',
|
|
1478
|
+
);
|
|
1479
|
+
// The oper is never disconnected on a reload failure.
|
|
1480
|
+
expect(runtime.calls.some((c) => c.method === 'disconnect')).toBe(false);
|
|
1481
|
+
// Prior config retained: a subsequent command still sees the old name.
|
|
1482
|
+
runtime.calls.length = 0;
|
|
1483
|
+
await actor.receiveTextFrame('REHASH\r\n');
|
|
1484
|
+
const retrySends = runtime.calls.filter((c) => c.method === 'send');
|
|
1485
|
+
const retryLines = retrySends[0]?.args[1] as RawLine[];
|
|
1486
|
+
expect(retryLines[0]?.text).toBe(':irc.example.com 382 alice :Rehashing from KV');
|
|
1487
|
+
});
|
|
1488
|
+
|
|
1489
|
+
it('stringifies a non-Error rejection in the graceful 382 suffix', async () => {
|
|
1490
|
+
const { actor, runtime } = makeActor({
|
|
1491
|
+
conn: {
|
|
1492
|
+
userModes: {
|
|
1493
|
+
invisible: false,
|
|
1494
|
+
oper: true,
|
|
1495
|
+
wallops: false,
|
|
1496
|
+
serverNotices: false,
|
|
1497
|
+
tls: false,
|
|
1498
|
+
},
|
|
1499
|
+
},
|
|
1500
|
+
configSource: 'KV',
|
|
1501
|
+
});
|
|
1502
|
+
// A non-Error throw exercises the `String(err)` branch of the suffix
|
|
1503
|
+
// formatting (vs. `err.message` for a real Error).
|
|
1504
|
+
runtime.reloadConfigError = 'totally broken';
|
|
1505
|
+
runtime.calls.length = 0;
|
|
1506
|
+
|
|
1507
|
+
await actor.receiveTextFrame('REHASH\r\n');
|
|
1508
|
+
|
|
1509
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
1510
|
+
expect(sends).toHaveLength(2);
|
|
1511
|
+
const errorLine = (sends[1]?.args[1] as RawLine[])[0];
|
|
1512
|
+
expect(errorLine?.text).toBe(
|
|
1513
|
+
':irc.example.com 382 alice :Rehashing from KV failed: totally broken',
|
|
1514
|
+
);
|
|
1515
|
+
expect(runtime.calls.some((c) => c.method === 'disconnect')).toBe(false);
|
|
1516
|
+
});
|
|
1517
|
+
|
|
1518
|
+
it('falls back to "*" in the graceful 382 suffix when the oper has no nick', async () => {
|
|
1519
|
+
// Covers the `this.state.nick ?? '*'` fallback in routeRehash's catch
|
|
1520
|
+
// block. Oper state without a nick is unusual (registration normally
|
|
1521
|
+
// sets the nick before OPER can succeed) but the actor must still
|
|
1522
|
+
// format a valid numeric addressed to "*" rather than crash.
|
|
1523
|
+
const { actor, runtime, conn } = makeActor({
|
|
1524
|
+
conn: {
|
|
1525
|
+
userModes: {
|
|
1526
|
+
invisible: false,
|
|
1527
|
+
oper: true,
|
|
1528
|
+
wallops: false,
|
|
1529
|
+
serverNotices: false,
|
|
1530
|
+
tls: false,
|
|
1531
|
+
},
|
|
1532
|
+
},
|
|
1533
|
+
configSource: 'KV',
|
|
1534
|
+
});
|
|
1535
|
+
// Clear the nick post-construction (exactOptionalPropertyTypes forbids
|
|
1536
|
+
// assigning undefined; delete is the idiomatic way to unset an optional).
|
|
1537
|
+
// biome-ignore lint/performance/noDelete: exactOptionalPropertyTypes forbids `= undefined`.
|
|
1538
|
+
delete conn.nick;
|
|
1539
|
+
runtime.reloadConfigError = new Error('store unreachable');
|
|
1540
|
+
runtime.calls.length = 0;
|
|
1541
|
+
|
|
1542
|
+
await actor.receiveTextFrame('REHASH\r\n');
|
|
1543
|
+
|
|
1544
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
1545
|
+
expect(sends.length).toBeGreaterThanOrEqual(1);
|
|
1546
|
+
for (const s of sends) {
|
|
1547
|
+
for (const line of s.args[1] as RawLine[]) {
|
|
1548
|
+
// Addressed to "*" (no nick), never 421.
|
|
1549
|
+
expect(line.text).toContain(' 382 * ');
|
|
1550
|
+
expect(line.text).not.toContain(' 421 ');
|
|
1551
|
+
}
|
|
1552
|
+
}
|
|
1553
|
+
});
|
|
1554
|
+
|
|
1049
1555
|
it('covers JOIN with no params (splitCsv("") → [])', async () => {
|
|
1050
1556
|
const { actor, runtime } = makeActor({});
|
|
1051
1557
|
await actor.receiveTextFrame('JOIN\r\n');
|
|
@@ -1312,10 +1818,11 @@ describe('ConnectionActor — flood control', () => {
|
|
|
1312
1818
|
await actor.receiveTextFrame('PRIVMSG #foo :flood\r\n');
|
|
1313
1819
|
}
|
|
1314
1820
|
|
|
1315
|
-
// Disabled → every frame reaches the inner reducer and
|
|
1316
|
-
// close path is never invoked.
|
|
1821
|
+
// Disabled → every frame reaches the inner reducer and fans out; the
|
|
1822
|
+
// close path is never invoked. Channel PRIVMSG dispatches cap-split
|
|
1823
|
+
// (msgid) via getChannelSnapshot, once per accepted message.
|
|
1317
1824
|
expect(runtime.calls.filter((c) => c.method === 'disconnect')).toHaveLength(0);
|
|
1318
|
-
expect(runtime.calls.filter((c) => c.method === '
|
|
1825
|
+
expect(runtime.calls.filter((c) => c.method === 'getChannelSnapshot')).toHaveLength(20);
|
|
1319
1826
|
});
|
|
1320
1827
|
|
|
1321
1828
|
it('does not let an interleaved PING advance the bucket (control-plane exemption plumbed through)', async () => {
|
|
@@ -1552,19 +2059,40 @@ describe('ConnectionActor — CHATHISTORY', () => {
|
|
|
1552
2059
|
expect(sends).toHaveLength(0);
|
|
1553
2060
|
});
|
|
1554
2061
|
|
|
1555
|
-
it('
|
|
2062
|
+
it('handles a missing target parameter gracefully (no 421)', async () => {
|
|
2063
|
+
// Covers the `target !== undefined` false branch in routeChathistory:
|
|
2064
|
+
// a CHATHISTORY message with only the subcommand and no target param.
|
|
2065
|
+
// peekChannel is not called; the reducer surfaces the param error.
|
|
1556
2066
|
const store = new InMemoryMessageStore();
|
|
1557
|
-
const { actor, runtime } = makeActor({
|
|
1558
|
-
|
|
1559
|
-
|
|
2067
|
+
const { actor, runtime } = makeActor({
|
|
2068
|
+
conn: { caps: CHATHISTORY_CAPS },
|
|
2069
|
+
messages: store,
|
|
1560
2070
|
});
|
|
1561
2071
|
runtime.calls.length = 0;
|
|
1562
2072
|
|
|
1563
|
-
await actor.receiveTextFrame('
|
|
2073
|
+
await actor.receiveTextFrame('CHATHISTORY LATEST\r\n');
|
|
1564
2074
|
|
|
1565
|
-
const
|
|
1566
|
-
|
|
1567
|
-
|
|
2075
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
2076
|
+
for (const s of sends) {
|
|
2077
|
+
for (const line of s.args[1] as RawLine[]) {
|
|
2078
|
+
expect(line.text).not.toContain(' 421 ');
|
|
2079
|
+
}
|
|
2080
|
+
}
|
|
2081
|
+
});
|
|
2082
|
+
|
|
2083
|
+
it('records a channel PRIVMSG into the bound store (JOIN auto-playback data source)', async () => {
|
|
2084
|
+
const store = new InMemoryMessageStore();
|
|
2085
|
+
const { actor, runtime } = makeActor({ messages: store });
|
|
2086
|
+
await runtime.applyChannelDelta('#foo', {
|
|
2087
|
+
memberships: [{ type: 'add', conn: 'c1', nick: 'alice' }],
|
|
2088
|
+
});
|
|
2089
|
+
runtime.calls.length = 0;
|
|
2090
|
+
|
|
2091
|
+
await actor.receiveTextFrame('PRIVMSG #foo :hello\r\n');
|
|
2092
|
+
|
|
2093
|
+
const recorded = store.query({ chan: '#foo', direction: 'latest', limit: 10 });
|
|
2094
|
+
expect(recorded).toHaveLength(1);
|
|
2095
|
+
expect(recorded[0]?.text).toBe('hello');
|
|
1568
2096
|
expect(recorded[0]?.command).toBe('PRIVMSG');
|
|
1569
2097
|
expect(recorded[0]?.nick).toBe('alice');
|
|
1570
2098
|
});
|
|
@@ -1578,8 +2106,9 @@ describe('ConnectionActor — CHATHISTORY', () => {
|
|
|
1578
2106
|
|
|
1579
2107
|
await actor.receiveTextFrame('PRIVMSG #foo :hello\r\n');
|
|
1580
2108
|
|
|
1581
|
-
// Broadcast still happens (the message is delivered live)
|
|
1582
|
-
|
|
2109
|
+
// Broadcast still happens (the message is delivered live). Channel
|
|
2110
|
+
// PRIVMSG fans out cap-split (msgid) via getChannelSnapshot…
|
|
2111
|
+
expect(runtime.calls.filter((c) => c.method === 'getChannelSnapshot')).toHaveLength(1);
|
|
1583
2112
|
// …but there is no store to record into.
|
|
1584
2113
|
});
|
|
1585
2114
|
});
|
|
@@ -1850,6 +2379,30 @@ describe('ConnectionActor — server-info commands (VERSION/TIME/ADMIN/INFO/USER
|
|
|
1850
2379
|
expect(lines[0]?.text).toContain('bob*=-bobuser@bobhost');
|
|
1851
2380
|
});
|
|
1852
2381
|
|
|
2382
|
+
it('USERHOST treats a nick whose conn tore down mid-resolve as offline', async () => {
|
|
2383
|
+
// Covers the `conn === null` branch in routeUserhost: lookupNick
|
|
2384
|
+
// resolves but the live connection is gone (race between the two
|
|
2385
|
+
// awaits). The reducer must treat the nick as offline rather than
|
|
2386
|
+
// crash.
|
|
2387
|
+
const { actor, runtime, conn } = makeActor({});
|
|
2388
|
+
const bobState = createConnection({ id: 'c2', connectedSince: 0 });
|
|
2389
|
+
bobState.nick = 'bob';
|
|
2390
|
+
bobState.user = 'bobuser';
|
|
2391
|
+
bobState.host = 'bobhost';
|
|
2392
|
+
bobState.registration = 'registered';
|
|
2393
|
+
runtime.addConnection(conn);
|
|
2394
|
+
runtime.addConnection(bobState);
|
|
2395
|
+
runtime.dropConnection('c2'); // race: nick map lags the connection map
|
|
2396
|
+
runtime.calls.length = 0;
|
|
2397
|
+
|
|
2398
|
+
await actor.receiveTextFrame('USERHOST bob\r\n');
|
|
2399
|
+
|
|
2400
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
2401
|
+
const lines = sends[0]?.args[1] as RawLine[];
|
|
2402
|
+
expect(lines[0]?.text).toContain(' 302 ');
|
|
2403
|
+
expect(lines[0]?.text).not.toContain('bobuser@bobhost');
|
|
2404
|
+
});
|
|
2405
|
+
|
|
1853
2406
|
it('routes ISON resolving online nicks (303, never 421)', async () => {
|
|
1854
2407
|
const { actor, runtime, conn } = makeActor({});
|
|
1855
2408
|
const bobState = createConnection({ id: 'c2', connectedSince: 0 });
|
|
@@ -1885,4 +2438,1932 @@ describe('ConnectionActor — server-info commands (VERSION/TIME/ADMIN/INFO/USER
|
|
|
1885
2438
|
const lines = sends[0]?.args[1] as RawLine[];
|
|
1886
2439
|
expect(lines[0]?.text).toContain(':Bob');
|
|
1887
2440
|
});
|
|
2441
|
+
|
|
2442
|
+
it('skips a nick whose connection has torn down between lookupNick and getConnection (race)', async () => {
|
|
2443
|
+
// Covers the `conn?.nick !== undefined` false branch in routeIson:
|
|
2444
|
+
// lookupNick resolves because the nick map still holds the entry, but
|
|
2445
|
+
// the connection is already gone from the live-connection store.
|
|
2446
|
+
const { actor, runtime, conn } = makeActor({});
|
|
2447
|
+
const bobState = createConnection({ id: 'c2', connectedSince: 0 });
|
|
2448
|
+
bobState.nick = 'bob';
|
|
2449
|
+
bobState.registration = 'registered';
|
|
2450
|
+
runtime.addConnection(conn);
|
|
2451
|
+
runtime.addConnection(bobState);
|
|
2452
|
+
// Drop the live connection but leave the nick registered.
|
|
2453
|
+
runtime.dropConnection('c2');
|
|
2454
|
+
runtime.calls.length = 0;
|
|
2455
|
+
|
|
2456
|
+
await actor.receiveTextFrame('ISON bob\r\n');
|
|
2457
|
+
|
|
2458
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
2459
|
+
const lines = sends[0]?.args[1] as RawLine[];
|
|
2460
|
+
// 303 reply lists no nicks (bob was dropped mid-resolve).
|
|
2461
|
+
expect(lines[0]?.text).toContain(' 303 ');
|
|
2462
|
+
expect(lines[0]?.text).not.toContain('bob');
|
|
2463
|
+
expect(lines[0]?.text).not.toContain(' 421 ');
|
|
2464
|
+
});
|
|
2465
|
+
});
|
|
2466
|
+
|
|
2467
|
+
describe('ConnectionActor — KILL (oper force-disconnect)', () => {
|
|
2468
|
+
it('routes an oper KILL through killReducer, invoking runtime.disconnect for the target (never 421)', async () => {
|
|
2469
|
+
const { actor, runtime, conn } = makeActor({});
|
|
2470
|
+
conn.userModes.oper = true;
|
|
2471
|
+
const bobState = createConnection({ id: 'c2', connectedSince: 0 });
|
|
2472
|
+
bobState.nick = 'bob';
|
|
2473
|
+
bobState.user = 'bob';
|
|
2474
|
+
bobState.host = 'example.com';
|
|
2475
|
+
bobState.realname = 'Bob';
|
|
2476
|
+
bobState.registration = 'registered';
|
|
2477
|
+
runtime.addConnection(conn);
|
|
2478
|
+
runtime.addConnection(bobState);
|
|
2479
|
+
runtime.calls.length = 0;
|
|
2480
|
+
|
|
2481
|
+
await actor.receiveTextFrame('KILL bob :flooding\r\n');
|
|
2482
|
+
|
|
2483
|
+
const disconnects = runtime.calls.filter((c) => c.method === 'disconnect');
|
|
2484
|
+
expect(disconnects).toHaveLength(1);
|
|
2485
|
+
expect(disconnects[0]?.args[0]).toBe('c2');
|
|
2486
|
+
expect(disconnects[0]?.args[1]).toBe('Killed (alice (flooding))');
|
|
2487
|
+
// Critical: never 421 for a routed KILL.
|
|
2488
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
2489
|
+
for (const s of sends) {
|
|
2490
|
+
for (const line of s.args[1] as RawLine[]) {
|
|
2491
|
+
expect(line.text).not.toContain(' 421 ');
|
|
2492
|
+
}
|
|
2493
|
+
}
|
|
2494
|
+
});
|
|
2495
|
+
|
|
2496
|
+
it('emits 481 ERR_NOPRIVILEGES (never 421, no disconnect) when the sender is not an oper', async () => {
|
|
2497
|
+
const { actor, runtime } = makeActor({});
|
|
2498
|
+
runtime.calls.length = 0;
|
|
2499
|
+
|
|
2500
|
+
await actor.receiveTextFrame('KILL bob :flooding\r\n');
|
|
2501
|
+
|
|
2502
|
+
const disconnects = runtime.calls.filter((c) => c.method === 'disconnect');
|
|
2503
|
+
expect(disconnects).toHaveLength(0);
|
|
2504
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
2505
|
+
expect(sends).toHaveLength(1);
|
|
2506
|
+
const lines = sends[0]?.args[1] as RawLine[];
|
|
2507
|
+
expect(lines[0]?.text).toContain(' 481 ');
|
|
2508
|
+
expect(lines[0]?.text).not.toContain(' 421 ');
|
|
2509
|
+
});
|
|
2510
|
+
|
|
2511
|
+
it('emits 401 ERR_NOSUCHNICK for an unknown target (never 421, no disconnect)', async () => {
|
|
2512
|
+
const { actor, runtime, conn } = makeActor({});
|
|
2513
|
+
conn.userModes.oper = true;
|
|
2514
|
+
runtime.calls.length = 0;
|
|
2515
|
+
|
|
2516
|
+
await actor.receiveTextFrame('KILL ghost :nope\r\n');
|
|
2517
|
+
|
|
2518
|
+
const disconnects = runtime.calls.filter((c) => c.method === 'disconnect');
|
|
2519
|
+
expect(disconnects).toHaveLength(0);
|
|
2520
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
2521
|
+
expect(sends).toHaveLength(1);
|
|
2522
|
+
const lines = sends[0]?.args[1] as RawLine[];
|
|
2523
|
+
expect(lines[0]?.text).toContain(' 401 ');
|
|
2524
|
+
expect(lines[0]?.text).toContain(' ghost ');
|
|
2525
|
+
expect(lines[0]?.text).not.toContain(' 421 ');
|
|
2526
|
+
});
|
|
2527
|
+
|
|
2528
|
+
it('emits 461 ERR_NEEDMOREPARAMS when the comment is missing (never 421)', async () => {
|
|
2529
|
+
const { actor, runtime, conn } = makeActor({});
|
|
2530
|
+
conn.userModes.oper = true;
|
|
2531
|
+
runtime.calls.length = 0;
|
|
2532
|
+
|
|
2533
|
+
await actor.receiveTextFrame('KILL bob\r\n');
|
|
2534
|
+
|
|
2535
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
2536
|
+
expect(sends).toHaveLength(1);
|
|
2537
|
+
const lines = sends[0]?.args[1] as RawLine[];
|
|
2538
|
+
expect(lines[0]?.text).toContain(' 461 ');
|
|
2539
|
+
expect(lines[0]?.text).toContain(' KILL ');
|
|
2540
|
+
expect(lines[0]?.text).not.toContain(' 421 ');
|
|
2541
|
+
});
|
|
2542
|
+
|
|
2543
|
+
it('emits 461 when an oper sends KILL with no params at all (never 421)', async () => {
|
|
2544
|
+
const { actor, runtime, conn } = makeActor({});
|
|
2545
|
+
conn.userModes.oper = true;
|
|
2546
|
+
runtime.calls.length = 0;
|
|
2547
|
+
|
|
2548
|
+
await actor.receiveTextFrame('KILL\r\n');
|
|
2549
|
+
|
|
2550
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
2551
|
+
expect(sends).toHaveLength(1);
|
|
2552
|
+
const lines = sends[0]?.args[1] as RawLine[];
|
|
2553
|
+
expect(lines[0]?.text).toContain(' 461 ');
|
|
2554
|
+
expect(lines[0]?.text).not.toContain(' 421 ');
|
|
2555
|
+
});
|
|
2556
|
+
|
|
2557
|
+
it('treats a nick whose connection vanished mid-route as unknown (401, never 421)', async () => {
|
|
2558
|
+
const { actor, runtime, conn } = makeActor({});
|
|
2559
|
+
conn.userModes.oper = true;
|
|
2560
|
+
const bobState = createConnection({ id: 'c2', connectedSince: 0 });
|
|
2561
|
+
bobState.nick = 'bob';
|
|
2562
|
+
bobState.registration = 'registered';
|
|
2563
|
+
runtime.addConnection(conn);
|
|
2564
|
+
runtime.addConnection(bobState);
|
|
2565
|
+
// lookupNick will still resolve bob, but getConnection returns null.
|
|
2566
|
+
runtime.dropConnection('c2');
|
|
2567
|
+
runtime.calls.length = 0;
|
|
2568
|
+
|
|
2569
|
+
await actor.receiveTextFrame('KILL bob :flooding\r\n');
|
|
2570
|
+
|
|
2571
|
+
const disconnects = runtime.calls.filter((c) => c.method === 'disconnect');
|
|
2572
|
+
expect(disconnects).toHaveLength(0);
|
|
2573
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
2574
|
+
expect(sends).toHaveLength(1);
|
|
2575
|
+
const lines = sends[0]?.args[1] as RawLine[];
|
|
2576
|
+
expect(lines[0]?.text).toContain(' 401 ');
|
|
2577
|
+
expect(lines[0]?.text).not.toContain(' 421 ');
|
|
2578
|
+
});
|
|
2579
|
+
});
|
|
2580
|
+
|
|
2581
|
+
describe('ConnectionActor — TRACE (routing diagnostics)', () => {
|
|
2582
|
+
it('emits the 262 terminator and never 421 for TRACE with no target', async () => {
|
|
2583
|
+
const { actor, runtime } = makeActor({});
|
|
2584
|
+
runtime.calls.length = 0;
|
|
2585
|
+
|
|
2586
|
+
await actor.receiveTextFrame('TRACE\r\n');
|
|
2587
|
+
|
|
2588
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
2589
|
+
expect(sends.length).toBeGreaterThanOrEqual(1);
|
|
2590
|
+
const allLines = sends.flatMap((s) => s.args[1] as RawLine[]);
|
|
2591
|
+
expect(allLines[allLines.length - 1]?.text).toContain(' 262 ');
|
|
2592
|
+
for (const line of allLines) {
|
|
2593
|
+
expect(line.text).not.toContain(' 421 ');
|
|
2594
|
+
}
|
|
2595
|
+
});
|
|
2596
|
+
|
|
2597
|
+
it('routes oper TRACE <nick> through traceReducer (205 + 262, never 421)', async () => {
|
|
2598
|
+
const { actor, runtime, conn } = makeActor({});
|
|
2599
|
+
conn.userModes.oper = true;
|
|
2600
|
+
const bobState = createConnection({ id: 'c2', connectedSince: 0 });
|
|
2601
|
+
bobState.nick = 'bob';
|
|
2602
|
+
bobState.user = 'bob';
|
|
2603
|
+
bobState.host = 'example.com';
|
|
2604
|
+
bobState.realname = 'Bob';
|
|
2605
|
+
bobState.registration = 'registered';
|
|
2606
|
+
runtime.addConnection(conn);
|
|
2607
|
+
runtime.addConnection(bobState);
|
|
2608
|
+
runtime.calls.length = 0;
|
|
2609
|
+
|
|
2610
|
+
await actor.receiveTextFrame('TRACE bob\r\n');
|
|
2611
|
+
|
|
2612
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
2613
|
+
expect(sends.length).toBeGreaterThanOrEqual(1);
|
|
2614
|
+
const firstBatch = sends[0]?.args[1] as RawLine[];
|
|
2615
|
+
expect(firstBatch[0]?.text).toContain(' 205 ');
|
|
2616
|
+
expect(firstBatch[0]?.text).toContain(' bob ');
|
|
2617
|
+
const allLines = sends.flatMap((s) => s.args[1] as RawLine[]);
|
|
2618
|
+
expect(allLines[allLines.length - 1]?.text).toContain(' 262 ');
|
|
2619
|
+
for (const line of allLines) {
|
|
2620
|
+
expect(line.text).not.toContain(' 421 ');
|
|
2621
|
+
}
|
|
2622
|
+
});
|
|
2623
|
+
|
|
2624
|
+
it('emits 402 ERR_NOSUCHSERVER for a non-local server target (never 421)', async () => {
|
|
2625
|
+
const { actor, runtime } = makeActor({});
|
|
2626
|
+
runtime.calls.length = 0;
|
|
2627
|
+
|
|
2628
|
+
await actor.receiveTextFrame('TRACE remote.example.org\r\n');
|
|
2629
|
+
|
|
2630
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
2631
|
+
expect(sends).toHaveLength(1);
|
|
2632
|
+
const lines = sends[0]?.args[1] as RawLine[];
|
|
2633
|
+
expect(lines[0]?.text).toContain(' 402 ');
|
|
2634
|
+
expect(lines[0]?.text).toContain(' remote.example.org ');
|
|
2635
|
+
expect(lines[0]?.text).not.toContain(' 421 ');
|
|
2636
|
+
});
|
|
2637
|
+
|
|
2638
|
+
it('treats the local server name as the local server (no 402, never 421)', async () => {
|
|
2639
|
+
const { actor, runtime } = makeActor({});
|
|
2640
|
+
runtime.calls.length = 0;
|
|
2641
|
+
|
|
2642
|
+
await actor.receiveTextFrame('TRACE irc.example.com\r\n');
|
|
2643
|
+
|
|
2644
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
2645
|
+
expect(sends.length).toBeGreaterThanOrEqual(1);
|
|
2646
|
+
const allLines = sends.flatMap((s) => s.args[1] as RawLine[]);
|
|
2647
|
+
expect(allLines[allLines.length - 1]?.text).toContain(' 262 ');
|
|
2648
|
+
for (const line of allLines) {
|
|
2649
|
+
expect(line.text).not.toContain(' 402 ');
|
|
2650
|
+
expect(line.text).not.toContain(' 421 ');
|
|
2651
|
+
}
|
|
2652
|
+
});
|
|
2653
|
+
|
|
2654
|
+
it('treats a nick whose connection vanished mid-route as the no-target path (262, never 421)', async () => {
|
|
2655
|
+
const { actor, runtime, conn } = makeActor({});
|
|
2656
|
+
conn.userModes.oper = true;
|
|
2657
|
+
const bobState = createConnection({ id: 'c2', connectedSince: 0 });
|
|
2658
|
+
bobState.nick = 'bob';
|
|
2659
|
+
bobState.registration = 'registered';
|
|
2660
|
+
runtime.addConnection(conn);
|
|
2661
|
+
runtime.addConnection(bobState);
|
|
2662
|
+
// lookupNick resolves bob, but getConnection returns null.
|
|
2663
|
+
runtime.dropConnection('c2');
|
|
2664
|
+
runtime.calls.length = 0;
|
|
2665
|
+
|
|
2666
|
+
await actor.receiveTextFrame('TRACE bob\r\n');
|
|
2667
|
+
|
|
2668
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
2669
|
+
expect(sends.length).toBeGreaterThanOrEqual(1);
|
|
2670
|
+
const allLines = sends.flatMap((s) => s.args[1] as RawLine[]);
|
|
2671
|
+
expect(allLines[allLines.length - 1]?.text).toContain(' 262 ');
|
|
2672
|
+
for (const line of allLines) {
|
|
2673
|
+
expect(line.text).not.toContain(' 421 ');
|
|
2674
|
+
}
|
|
2675
|
+
});
|
|
2676
|
+
});
|
|
2677
|
+
|
|
2678
|
+
describe('ConnectionActor — dropped S2S and obsolete verbs', () => {
|
|
2679
|
+
// These verbs are formally dropped (not deferred):
|
|
2680
|
+
// - S2S (PLAN non-goal): CONNECT, SQUIT, LINKS
|
|
2681
|
+
// - RFC 2812 obsolete (never widely implemented): SERVICE, SUMMON, USERS
|
|
2682
|
+
// They keep returning `421 ERR_UNKNOWNCOMMAND` (the correct response for an
|
|
2683
|
+
// unimplemented command); the regression guard below pins that behaviour so
|
|
2684
|
+
// a future accidental `case` label is caught.
|
|
2685
|
+
const droppedVerbs = ['CONNECT', 'SQUIT', 'LINKS', 'SERVICE', 'SUMMON', 'USERS'];
|
|
2686
|
+
|
|
2687
|
+
it.each(droppedVerbs)('emits 421 for the dropped verb %s', async (verb) => {
|
|
2688
|
+
const { actor, runtime } = makeActor({});
|
|
2689
|
+
await actor.receiveTextFrame(`${verb} ignored-arg\r\n`);
|
|
2690
|
+
|
|
2691
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
2692
|
+
expect(sends).toHaveLength(1);
|
|
2693
|
+
const lines = sends[0]?.args[1] as RawLine[];
|
|
2694
|
+
expect(lines[0]?.text).toContain(' 421 ');
|
|
2695
|
+
expect(lines[0]?.text).toContain(` ${verb} `);
|
|
2696
|
+
});
|
|
2697
|
+
|
|
2698
|
+
it('documents the deliberate drop at the default branch (no "deferred" framing)', () => {
|
|
2699
|
+
// The default branch must explain these verbs are deliberately dropped,
|
|
2700
|
+
// not still "deferred" (the framing removed by the S2S / obsolete-verb
|
|
2701
|
+
// drop).
|
|
2702
|
+
expect(actorSource).toMatch(/S2S/);
|
|
2703
|
+
expect(actorSource).toMatch(/non-goal/);
|
|
2704
|
+
expect(actorSource).toMatch(/obsolete/i);
|
|
2705
|
+
// The CONNECT/SQUIT/LINKS and SERVICE/SUMMON/USERS lines must not be
|
|
2706
|
+
// framed as deferred.
|
|
2707
|
+
expect(actorSource).not.toMatch(/deferred LINKS/);
|
|
2708
|
+
expect(actorSource).not.toMatch(/deferred SQUIT/);
|
|
2709
|
+
expect(actorSource).not.toMatch(/deferred SERVICE/);
|
|
2710
|
+
expect(actorSource).not.toMatch(/deferred SUMMON/);
|
|
2711
|
+
expect(actorSource).not.toMatch(/deferred USERS/);
|
|
2712
|
+
});
|
|
2713
|
+
});
|
|
2714
|
+
|
|
2715
|
+
// ---------------------------------------------------------------------------
|
|
2716
|
+
// FakeServerStats + helpers
|
|
2717
|
+
// ---------------------------------------------------------------------------
|
|
2718
|
+
|
|
2719
|
+
/** Stats backend double that returns a fixed snapshot. */
|
|
2720
|
+
class FakeServerStats implements ServerStats {
|
|
2721
|
+
constructor(readonly snapshot: ServerStatsSnapshot) {}
|
|
2722
|
+
async getStats(): Promise<ServerStatsSnapshot> {
|
|
2723
|
+
return this.snapshot;
|
|
2724
|
+
}
|
|
2725
|
+
}
|
|
2726
|
+
|
|
2727
|
+
const populatedSnapshot: ServerStatsSnapshot = {
|
|
2728
|
+
users: 4,
|
|
2729
|
+
invisible: 1,
|
|
2730
|
+
opers: 2,
|
|
2731
|
+
unknownConnections: 1,
|
|
2732
|
+
channels: 3,
|
|
2733
|
+
servers: 1,
|
|
2734
|
+
localConns: 6,
|
|
2735
|
+
globalConns: 6,
|
|
2736
|
+
maxLocalConns: 9,
|
|
2737
|
+
maxGlobalConns: 9,
|
|
2738
|
+
uptimeStartedAt: 0,
|
|
2739
|
+
};
|
|
2740
|
+
|
|
2741
|
+
// ---------------------------------------------------------------------------
|
|
2742
|
+
// LUSERS actor routing
|
|
2743
|
+
// ---------------------------------------------------------------------------
|
|
2744
|
+
|
|
2745
|
+
describe('ConnectionActor — LUSERS (network statistics)', () => {
|
|
2746
|
+
it('emits 251–255 from the bound ServerStats snapshot and never 421', async () => {
|
|
2747
|
+
const { actor, runtime } = makeActor({ stats: new FakeServerStats(populatedSnapshot) });
|
|
2748
|
+
runtime.calls.length = 0;
|
|
2749
|
+
|
|
2750
|
+
await actor.receiveTextFrame('LUSERS\r\n');
|
|
2751
|
+
|
|
2752
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
2753
|
+
expect(sends.length).toBeGreaterThanOrEqual(1);
|
|
2754
|
+
const allLines = sends.flatMap((s) => s.args[1] as RawLine[]);
|
|
2755
|
+
const codes = allLines.map((l) => Number(l.text.split(' ')[1]));
|
|
2756
|
+
expect(codes).toContain(251);
|
|
2757
|
+
expect(codes).toContain(252);
|
|
2758
|
+
expect(codes).toContain(253);
|
|
2759
|
+
expect(codes).toContain(254);
|
|
2760
|
+
expect(codes).toContain(255);
|
|
2761
|
+
expect(codes).toContain(265);
|
|
2762
|
+
expect(codes).toContain(266);
|
|
2763
|
+
for (const line of allLines) {
|
|
2764
|
+
expect(line.text).not.toContain(' 421 ');
|
|
2765
|
+
}
|
|
2766
|
+
});
|
|
2767
|
+
|
|
2768
|
+
it('awaits the stats backend before invoking the reducer (async getStats)', async () => {
|
|
2769
|
+
let calls = 0;
|
|
2770
|
+
const stats: ServerStats = {
|
|
2771
|
+
async getStats() {
|
|
2772
|
+
calls++;
|
|
2773
|
+
return populatedSnapshot;
|
|
2774
|
+
},
|
|
2775
|
+
};
|
|
2776
|
+
const { actor, runtime } = makeActor({ stats });
|
|
2777
|
+
runtime.calls.length = 0;
|
|
2778
|
+
|
|
2779
|
+
await actor.receiveTextFrame('LUSERS\r\n');
|
|
2780
|
+
|
|
2781
|
+
expect(calls).toBe(1);
|
|
2782
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
2783
|
+
const allLines = sends.flatMap((s) => s.args[1] as RawLine[]);
|
|
2784
|
+
// The 251 line carries the snapshot's user/invisible/server counts.
|
|
2785
|
+
expect(allLines.some((l) => l.text.includes(' 251 ') && l.text.includes('4 users'))).toBe(true);
|
|
2786
|
+
});
|
|
2787
|
+
|
|
2788
|
+
it('emits 402 ERR_NOSUCHSERVER for a non-local server target (never 421)', async () => {
|
|
2789
|
+
const { actor, runtime } = makeActor({ stats: new FakeServerStats(populatedSnapshot) });
|
|
2790
|
+
runtime.calls.length = 0;
|
|
2791
|
+
|
|
2792
|
+
await actor.receiveTextFrame('LUSERS * remote.example.org\r\n');
|
|
2793
|
+
|
|
2794
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
2795
|
+
expect(sends).toHaveLength(1);
|
|
2796
|
+
const lines = sends[0]?.args[1] as RawLine[];
|
|
2797
|
+
expect(lines[0]?.text).toContain(' 402 ');
|
|
2798
|
+
expect(lines[0]?.text).toContain(' remote.example.org ');
|
|
2799
|
+
expect(lines[0]?.text).not.toContain(' 421 ');
|
|
2800
|
+
});
|
|
2801
|
+
|
|
2802
|
+
it('treats the local server name as the local server (no 402, never 421)', async () => {
|
|
2803
|
+
const { actor, runtime } = makeActor({ stats: new FakeServerStats(populatedSnapshot) });
|
|
2804
|
+
runtime.calls.length = 0;
|
|
2805
|
+
|
|
2806
|
+
await actor.receiveTextFrame('LUSERS * irc.example.com\r\n');
|
|
2807
|
+
|
|
2808
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
2809
|
+
expect(sends.length).toBeGreaterThanOrEqual(1);
|
|
2810
|
+
const allLines = sends.flatMap((s) => s.args[1] as RawLine[]);
|
|
2811
|
+
for (const line of allLines) {
|
|
2812
|
+
expect(line.text).not.toContain(' 402 ');
|
|
2813
|
+
expect(line.text).not.toContain(' 421 ');
|
|
2814
|
+
}
|
|
2815
|
+
});
|
|
2816
|
+
|
|
2817
|
+
it('falls back to a zero-count snapshot when no stats backend is bound (never 421)', async () => {
|
|
2818
|
+
const { actor, runtime } = makeActor({});
|
|
2819
|
+
runtime.calls.length = 0;
|
|
2820
|
+
|
|
2821
|
+
await actor.receiveTextFrame('LUSERS\r\n');
|
|
2822
|
+
|
|
2823
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
2824
|
+
expect(sends.length).toBeGreaterThanOrEqual(1);
|
|
2825
|
+
const allLines = sends.flatMap((s) => s.args[1] as RawLine[]);
|
|
2826
|
+
// 251 still emitted with zero counts; 252/253/254 suppressed (zero).
|
|
2827
|
+
expect(allLines.some((l) => l.text.includes(' 251 '))).toBe(true);
|
|
2828
|
+
expect(allLines.some((l) => l.text.includes(' 255 '))).toBe(true);
|
|
2829
|
+
for (const line of allLines) {
|
|
2830
|
+
expect(line.text).not.toContain(' 252 ');
|
|
2831
|
+
expect(line.text).not.toContain(' 253 ');
|
|
2832
|
+
expect(line.text).not.toContain(' 254 ');
|
|
2833
|
+
expect(line.text).not.toContain(' 421 ');
|
|
2834
|
+
}
|
|
2835
|
+
});
|
|
2836
|
+
});
|
|
2837
|
+
|
|
2838
|
+
// ---------------------------------------------------------------------------
|
|
2839
|
+
// STATS actor routing
|
|
2840
|
+
// ---------------------------------------------------------------------------
|
|
2841
|
+
|
|
2842
|
+
describe('ConnectionActor — STATS (server statistics)', () => {
|
|
2843
|
+
it('routes STATS u to a 242 + 219 reply (never 421)', async () => {
|
|
2844
|
+
const clock = new FakeClock(5_000);
|
|
2845
|
+
const { actor, runtime } = makeActor({
|
|
2846
|
+
stats: new FakeServerStats({ ...populatedSnapshot, uptimeStartedAt: 0 }),
|
|
2847
|
+
clock,
|
|
2848
|
+
});
|
|
2849
|
+
runtime.calls.length = 0;
|
|
2850
|
+
|
|
2851
|
+
await actor.receiveTextFrame('STATS u\r\n');
|
|
2852
|
+
|
|
2853
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
2854
|
+
expect(sends.length).toBeGreaterThanOrEqual(1);
|
|
2855
|
+
const allLines = sends.flatMap((s) => s.args[1] as RawLine[]);
|
|
2856
|
+
expect(allLines.some((l) => l.text.includes(' 242 '))).toBe(true);
|
|
2857
|
+
expect(allLines[allLines.length - 1]?.text).toContain(' 219 ');
|
|
2858
|
+
expect(allLines[allLines.length - 1]?.text).toContain(' u ');
|
|
2859
|
+
for (const line of allLines) {
|
|
2860
|
+
expect(line.text).not.toContain(' 421 ');
|
|
2861
|
+
}
|
|
2862
|
+
});
|
|
2863
|
+
|
|
2864
|
+
it('routes STATS l to a 211 + 219 reply (never 421)', async () => {
|
|
2865
|
+
const { actor, runtime } = makeActor({ stats: new FakeServerStats(populatedSnapshot) });
|
|
2866
|
+
runtime.calls.length = 0;
|
|
2867
|
+
|
|
2868
|
+
await actor.receiveTextFrame('STATS l\r\n');
|
|
2869
|
+
|
|
2870
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
2871
|
+
expect(sends.length).toBeGreaterThanOrEqual(1);
|
|
2872
|
+
const allLines = sends.flatMap((s) => s.args[1] as RawLine[]);
|
|
2873
|
+
expect(allLines.some((l) => l.text.includes(' 211 '))).toBe(true);
|
|
2874
|
+
expect(allLines[allLines.length - 1]?.text).toContain(' 219 ');
|
|
2875
|
+
for (const line of allLines) {
|
|
2876
|
+
expect(line.text).not.toContain(' 421 ');
|
|
2877
|
+
}
|
|
2878
|
+
});
|
|
2879
|
+
|
|
2880
|
+
it('routes an unknown STATS letter to a charybdis-style empty body + 219 (never 421)', async () => {
|
|
2881
|
+
const { actor, runtime } = makeActor({ stats: new FakeServerStats(populatedSnapshot) });
|
|
2882
|
+
runtime.calls.length = 0;
|
|
2883
|
+
|
|
2884
|
+
await actor.receiveTextFrame('STATS z\r\n');
|
|
2885
|
+
|
|
2886
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
2887
|
+
expect(sends).toHaveLength(1);
|
|
2888
|
+
const lines = sends[0]?.args[1] as RawLine[];
|
|
2889
|
+
expect(lines[0]?.text).toContain(' 219 ');
|
|
2890
|
+
expect(lines[0]?.text).toContain(' z ');
|
|
2891
|
+
expect(lines[0]?.text).not.toContain(' 421 ');
|
|
2892
|
+
});
|
|
2893
|
+
|
|
2894
|
+
it('emits 402 ERR_NOSUCHSERVER for a non-local server target (never 421)', async () => {
|
|
2895
|
+
const { actor, runtime } = makeActor({ stats: new FakeServerStats(populatedSnapshot) });
|
|
2896
|
+
runtime.calls.length = 0;
|
|
2897
|
+
|
|
2898
|
+
await actor.receiveTextFrame('STATS u remote.example.org\r\n');
|
|
2899
|
+
|
|
2900
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
2901
|
+
expect(sends).toHaveLength(1);
|
|
2902
|
+
const lines = sends[0]?.args[1] as RawLine[];
|
|
2903
|
+
expect(lines[0]?.text).toContain(' 402 ');
|
|
2904
|
+
expect(lines[0]?.text).toContain(' remote.example.org ');
|
|
2905
|
+
expect(lines[0]?.text).not.toContain(' 421 ');
|
|
2906
|
+
});
|
|
2907
|
+
|
|
2908
|
+
it('treats the local server name as the local server (no 402, never 421)', async () => {
|
|
2909
|
+
const { actor, runtime } = makeActor({ stats: new FakeServerStats(populatedSnapshot) });
|
|
2910
|
+
runtime.calls.length = 0;
|
|
2911
|
+
|
|
2912
|
+
await actor.receiveTextFrame('STATS u irc.example.com\r\n');
|
|
2913
|
+
|
|
2914
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
2915
|
+
expect(sends.length).toBeGreaterThanOrEqual(1);
|
|
2916
|
+
const allLines = sends.flatMap((s) => s.args[1] as RawLine[]);
|
|
2917
|
+
for (const line of allLines) {
|
|
2918
|
+
expect(line.text).not.toContain(' 402 ');
|
|
2919
|
+
expect(line.text).not.toContain(' 421 ');
|
|
2920
|
+
}
|
|
2921
|
+
});
|
|
2922
|
+
});
|
|
2923
|
+
|
|
2924
|
+
// ============================================================================
|
|
2925
|
+
// channelTargetsForMessage — pure dispatch table for prefetch
|
|
2926
|
+
// ============================================================================
|
|
2927
|
+
|
|
2928
|
+
describe('channelTargetsForMessage', () => {
|
|
2929
|
+
it('returns the channel list for each channel-touching verb', () => {
|
|
2930
|
+
// Covers every case clause in the switch.
|
|
2931
|
+
expect(channelTargetsForMessage({ command: 'PART', params: ['#a,#b'], tags: {} })).toEqual([
|
|
2932
|
+
'#a',
|
|
2933
|
+
'#b',
|
|
2934
|
+
]);
|
|
2935
|
+
expect(channelTargetsForMessage({ command: 'TOPIC', params: ['#foo'], tags: {} })).toEqual([
|
|
2936
|
+
'#foo',
|
|
2937
|
+
]);
|
|
2938
|
+
expect(channelTargetsForMessage({ command: 'NAMES', params: ['#foo'], tags: {} })).toEqual([
|
|
2939
|
+
'#foo',
|
|
2940
|
+
]);
|
|
2941
|
+
expect(
|
|
2942
|
+
channelTargetsForMessage({ command: 'KICK', params: ['#foo', 'bob'], tags: {} }),
|
|
2943
|
+
).toEqual(['#foo']);
|
|
2944
|
+
expect(channelTargetsForMessage({ command: 'MODE', params: ['#foo', '+t'], tags: {} })).toEqual(
|
|
2945
|
+
['#foo'],
|
|
2946
|
+
);
|
|
2947
|
+
expect(
|
|
2948
|
+
channelTargetsForMessage({ command: 'PRIVMSG', params: ['#foo', 'hi'], tags: {} }),
|
|
2949
|
+
).toEqual(['#foo']);
|
|
2950
|
+
expect(
|
|
2951
|
+
channelTargetsForMessage({ command: 'NOTICE', params: ['#foo', 'hi'], tags: {} }),
|
|
2952
|
+
).toEqual(['#foo']);
|
|
2953
|
+
expect(channelTargetsForMessage({ command: 'TAGMSG', params: ['#foo'], tags: {} })).toEqual([
|
|
2954
|
+
'#foo',
|
|
2955
|
+
]);
|
|
2956
|
+
});
|
|
2957
|
+
|
|
2958
|
+
it('returns the JOIN channel(s), skipping the `JOIN 0` sentinel', () => {
|
|
2959
|
+
expect(channelTargetsForMessage({ command: 'JOIN', params: ['#a,#b'], tags: {} })).toEqual([
|
|
2960
|
+
'#a',
|
|
2961
|
+
'#b',
|
|
2962
|
+
]);
|
|
2963
|
+
// `JOIN 0` is the part-everything sentinel; reducer reads
|
|
2964
|
+
// joinedChannels directly, so no prefetch target.
|
|
2965
|
+
expect(channelTargetsForMessage({ command: 'JOIN', params: ['0'], tags: {} })).toEqual([]);
|
|
2966
|
+
});
|
|
2967
|
+
|
|
2968
|
+
it('reads the channel from the SECOND param for INVITE (<nick> <channel>)', () => {
|
|
2969
|
+
expect(
|
|
2970
|
+
channelTargetsForMessage({ command: 'INVITE', params: ['bob', '#room'], tags: {} }),
|
|
2971
|
+
).toEqual(['#room']);
|
|
2972
|
+
});
|
|
2973
|
+
|
|
2974
|
+
it('returns an empty list for commands with no channel target', () => {
|
|
2975
|
+
// Covers the default branch (PING, NICK, QUIT, …).
|
|
2976
|
+
expect(channelTargetsForMessage({ command: 'PING', params: ['t'], tags: {} })).toEqual([]);
|
|
2977
|
+
expect(channelTargetsForMessage({ command: 'NICK', params: ['alice'], tags: {} })).toEqual([]);
|
|
2978
|
+
});
|
|
2979
|
+
|
|
2980
|
+
it('ignores non-channel targets in the comma-split list', () => {
|
|
2981
|
+
// A PRIVMSG to a nick (not a #channel) yields no prefetch targets.
|
|
2982
|
+
expect(
|
|
2983
|
+
channelTargetsForMessage({ command: 'PRIVMSG', params: ['bob,#foo', 'hi'], tags: {} }),
|
|
2984
|
+
).toEqual(['#foo']);
|
|
2985
|
+
});
|
|
2986
|
+
|
|
2987
|
+
it('handles a missing first parameter gracefully', () => {
|
|
2988
|
+
// Defensive: params[0] is absent → empty string → empty split.
|
|
2989
|
+
expect(channelTargetsForMessage({ command: 'PART', params: [], tags: {} })).toEqual([]);
|
|
2990
|
+
});
|
|
2991
|
+
|
|
2992
|
+
it('returns an empty list for JOIN / INVITE when their channel param is absent', () => {
|
|
2993
|
+
// Covers the `?? ''` fallbacks on the JOIN and INVITE branches.
|
|
2994
|
+
expect(channelTargetsForMessage({ command: 'JOIN', params: [], tags: {} })).toEqual([]);
|
|
2995
|
+
expect(channelTargetsForMessage({ command: 'INVITE', params: ['bob'], tags: {} })).toEqual([]);
|
|
2996
|
+
});
|
|
2997
|
+
});
|
|
2998
|
+
|
|
2999
|
+
// ============================================================================
|
|
3000
|
+
// prefetchChannelState — refresh hook for distributed runtimes
|
|
3001
|
+
// ============================================================================
|
|
3002
|
+
|
|
3003
|
+
describe('ConnectionActor — prefetchChannelState (refresh hook)', () => {
|
|
3004
|
+
it('invokes refreshChannel for each channel-targeted command when the hook is bound', async () => {
|
|
3005
|
+
// Covers the body of prefetchChannelState (refresh !== undefined path)
|
|
3006
|
+
// and the call to channelTargetsForMessage for PART. Distributed
|
|
3007
|
+
// runtimes (CF ChannelDO) wire `refreshChannel`; the in-memory fake
|
|
3008
|
+
// does not, so we inject a custom channel-access here.
|
|
3009
|
+
const refreshed: string[] = [];
|
|
3010
|
+
const channelsAccess: ActorChannelAccess = {
|
|
3011
|
+
getOrCreateChannel: (name: ChanName) => {
|
|
3012
|
+
const key = name.toLowerCase();
|
|
3013
|
+
const existing = fakeChannels.get(key);
|
|
3014
|
+
if (existing) return existing;
|
|
3015
|
+
const chan = createChannel({ name, nameLower: key, createdAt: 0 });
|
|
3016
|
+
fakeChannels.set(key, chan);
|
|
3017
|
+
return chan;
|
|
3018
|
+
},
|
|
3019
|
+
refreshChannel: async (name: ChanName) => {
|
|
3020
|
+
refreshed.push(name);
|
|
3021
|
+
},
|
|
3022
|
+
};
|
|
3023
|
+
const fakeChannels = new Map<string, ChannelState>();
|
|
3024
|
+
const { actor, runtime } = makeActor({});
|
|
3025
|
+
// Swap in the channel access with the refresh hook.
|
|
3026
|
+
(actor as unknown as { channels: ActorChannelAccess }).channels = channelsAccess;
|
|
3027
|
+
// Pre-add membership so PART can find the channel.
|
|
3028
|
+
await runtime.applyChannelDelta('#foo', {
|
|
3029
|
+
memberships: [{ type: 'add', conn: 'c1', nick: 'alice' }],
|
|
3030
|
+
});
|
|
3031
|
+
runtime.calls.length = 0;
|
|
3032
|
+
|
|
3033
|
+
await actor.receiveTextFrame('PART #foo :leaving\r\n');
|
|
3034
|
+
|
|
3035
|
+
expect(refreshed).toEqual(['#foo']);
|
|
3036
|
+
});
|
|
3037
|
+
|
|
3038
|
+
it('is a no-op when refreshChannel is not bound (in-memory runtime)', async () => {
|
|
3039
|
+
// Covers the early-return path. The default FakeRuntime has no
|
|
3040
|
+
// refreshChannel; the actor must not throw and the PART still routes.
|
|
3041
|
+
const { actor, runtime } = makeActor({});
|
|
3042
|
+
await runtime.applyChannelDelta('#foo', {
|
|
3043
|
+
memberships: [{ type: 'add', conn: 'c1', nick: 'alice' }],
|
|
3044
|
+
});
|
|
3045
|
+
runtime.calls.length = 0;
|
|
3046
|
+
|
|
3047
|
+
await expect(actor.receiveTextFrame('PART #foo\r\n')).resolves.toBeUndefined();
|
|
3048
|
+
expect(runtime.calls.some((c) => c.method === 'applyChannelDelta')).toBe(true);
|
|
3049
|
+
});
|
|
3050
|
+
|
|
3051
|
+
it('skips refresh for commands with no channel target (empty targets list)', async () => {
|
|
3052
|
+
// Covers the `targets.length === 0` early-return in
|
|
3053
|
+
// prefetchChannelState: refreshChannel is bound but the command (PING)
|
|
3054
|
+
// has no channel argument, so the refresh hook must not fire.
|
|
3055
|
+
const refreshed: string[] = [];
|
|
3056
|
+
const { actor, runtime } = makeActor({});
|
|
3057
|
+
const accessWithRefresh: ActorChannelAccess = {
|
|
3058
|
+
getOrCreateChannel: (name: ChanName) => runtime.getOrCreateChannel(name),
|
|
3059
|
+
refreshChannel: async (name: ChanName) => {
|
|
3060
|
+
refreshed.push(name);
|
|
3061
|
+
},
|
|
3062
|
+
};
|
|
3063
|
+
(actor as unknown as { channels: ActorChannelAccess }).channels = accessWithRefresh;
|
|
3064
|
+
runtime.calls.length = 0;
|
|
3065
|
+
|
|
3066
|
+
await actor.receiveTextFrame('PING :token\r\n');
|
|
3067
|
+
|
|
3068
|
+
expect(refreshed).toEqual([]);
|
|
3069
|
+
});
|
|
3070
|
+
});
|
|
3071
|
+
|
|
3072
|
+
// ============================================================================
|
|
3073
|
+
// peekChannel — getChannel fallback
|
|
3074
|
+
// ============================================================================
|
|
3075
|
+
|
|
3076
|
+
describe('ConnectionActor — peekChannel fallback', () => {
|
|
3077
|
+
it('falls back to getOrCreateChannel when getChannel is not implemented', async () => {
|
|
3078
|
+
// Covers the fallback branch in peekChannel: a channel-access object
|
|
3079
|
+
// that omits `getChannel` (the legacy shape before peek-only lookup) must
|
|
3080
|
+
// still resolve CHATHISTORY targets via getOrCreateChannel. The actor
|
|
3081
|
+
// is reconstructed with a wrapper that delegates everything except
|
|
3082
|
+
// getChannel (which is intentionally absent).
|
|
3083
|
+
const store = new InMemoryMessageStore();
|
|
3084
|
+
const { actor, runtime } = makeActor({
|
|
3085
|
+
conn: {
|
|
3086
|
+
caps: new Set<string>(['draft/chathistory', 'server-time', 'message-tags', 'batch']),
|
|
3087
|
+
},
|
|
3088
|
+
messages: store,
|
|
3089
|
+
});
|
|
3090
|
+
// Replace the channel access with a wrapper that lacks getChannel.
|
|
3091
|
+
// The actor holds `channels` by reference, so this swap takes effect.
|
|
3092
|
+
const accessWithoutGetChannel: ActorChannelAccess = {
|
|
3093
|
+
getOrCreateChannel: (name: ChanName) => runtime.getOrCreateChannel(name),
|
|
3094
|
+
};
|
|
3095
|
+
(actor as unknown as { channels: ActorChannelAccess }).channels = accessWithoutGetChannel;
|
|
3096
|
+
// Pre-create the channel so getOrCreateChannel returns it, and seed
|
|
3097
|
+
// one stored message so the reducer has something to replay.
|
|
3098
|
+
runtime.getOrCreateChannel('#foo');
|
|
3099
|
+
store.record({
|
|
3100
|
+
msgid: 'm1',
|
|
3101
|
+
time: 1_000,
|
|
3102
|
+
chan: '#foo',
|
|
3103
|
+
command: 'PRIVMSG',
|
|
3104
|
+
nick: 'bob',
|
|
3105
|
+
user: 'bob',
|
|
3106
|
+
host: 'ex.org',
|
|
3107
|
+
text: 'hello',
|
|
3108
|
+
});
|
|
3109
|
+
runtime.calls.length = 0;
|
|
3110
|
+
|
|
3111
|
+
await actor.receiveTextFrame('CHATHISTORY LATEST #foo * 1\r\n');
|
|
3112
|
+
|
|
3113
|
+
// The reducer should be reached (no 421) and emit a BATCH frame,
|
|
3114
|
+
// proving peekChannel resolved the channel via the getOrCreateChannel
|
|
3115
|
+
// fallback rather than 403/421.
|
|
3116
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
3117
|
+
expect(sends.length).toBeGreaterThanOrEqual(1);
|
|
3118
|
+
const allLines = sends.flatMap((s) => s.args[1] as RawLine[]);
|
|
3119
|
+
for (const line of allLines) {
|
|
3120
|
+
expect(line.text).not.toContain(' 421 ');
|
|
3121
|
+
expect(line.text).not.toContain(' 403 ');
|
|
3122
|
+
}
|
|
3123
|
+
expect(allLines.some((l) => l.text.startsWith('BATCH +'))).toBe(true);
|
|
3124
|
+
});
|
|
3125
|
+
});
|
|
3126
|
+
|
|
3127
|
+
// ============================================================================
|
|
3128
|
+
// WHOIS — channel-list branch (target with joinedChannels)
|
|
3129
|
+
// ============================================================================
|
|
3130
|
+
|
|
3131
|
+
describe('ConnectionActor — WHOIS channel list', () => {
|
|
3132
|
+
it("includes the target's joined channels in the 319 reply", async () => {
|
|
3133
|
+
// Covers the Array.from(target.joinedChannels, ...) branch in
|
|
3134
|
+
// routeWhois: when the target has joinedChannels, each is resolved to
|
|
3135
|
+
// a ChannelState and passed to whoisReducer for the 319 list.
|
|
3136
|
+
const { actor, runtime, conn } = makeActor({});
|
|
3137
|
+
const bobState = createConnection({ id: 'c2', connectedSince: 0 });
|
|
3138
|
+
bobState.nick = 'bob';
|
|
3139
|
+
bobState.user = 'bob';
|
|
3140
|
+
bobState.host = 'example.com';
|
|
3141
|
+
bobState.realname = 'Bob';
|
|
3142
|
+
bobState.registration = 'registered';
|
|
3143
|
+
bobState.joinedChannels.add('#room1');
|
|
3144
|
+
bobState.joinedChannels.add('#room2');
|
|
3145
|
+
runtime.addConnection(conn);
|
|
3146
|
+
runtime.addConnection(bobState);
|
|
3147
|
+
// Materialize the channels so getOrCreateChannel resolves them.
|
|
3148
|
+
await runtime.applyChannelDelta('#room1', {
|
|
3149
|
+
memberships: [{ type: 'add', conn: 'c2', nick: 'bob' }],
|
|
3150
|
+
});
|
|
3151
|
+
await runtime.applyChannelDelta('#room2', {
|
|
3152
|
+
memberships: [{ type: 'add', conn: 'c2', nick: 'bob' }],
|
|
3153
|
+
});
|
|
3154
|
+
runtime.calls.length = 0;
|
|
3155
|
+
|
|
3156
|
+
await actor.receiveTextFrame('WHOIS bob\r\n');
|
|
3157
|
+
|
|
3158
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
3159
|
+
const allLines = sends.flatMap((s) => s.args[1] as RawLine[]);
|
|
3160
|
+
// 319 RPL_WHOISCHANNELS lists at least one of the target's channels.
|
|
3161
|
+
const chanLines = allLines.filter((l) => l.text.includes(' 319 '));
|
|
3162
|
+
expect(chanLines.length).toBeGreaterThan(0);
|
|
3163
|
+
const joined = chanLines.some((l) => l.text.includes('#room1') || l.text.includes('#room2'));
|
|
3164
|
+
expect(joined).toBe(true);
|
|
3165
|
+
});
|
|
3166
|
+
|
|
3167
|
+
it('treats a target whose conn tore down mid-resolve as offline (401 + 318, never 421)', async () => {
|
|
3168
|
+
// Covers the `?? undefined` branch in routeWhois: lookupNick resolved
|
|
3169
|
+
// the nick but getConnection returned null (race). The reducer must
|
|
3170
|
+
// emit the unknown-nick path (401 + 318), not crash.
|
|
3171
|
+
const { actor, runtime, conn } = makeActor({});
|
|
3172
|
+
const bobState = createConnection({ id: 'c2', connectedSince: 0 });
|
|
3173
|
+
bobState.nick = 'bob';
|
|
3174
|
+
bobState.registration = 'registered';
|
|
3175
|
+
runtime.addConnection(conn);
|
|
3176
|
+
runtime.addConnection(bobState);
|
|
3177
|
+
runtime.dropConnection('c2');
|
|
3178
|
+
runtime.calls.length = 0;
|
|
3179
|
+
|
|
3180
|
+
await actor.receiveTextFrame('WHOIS bob\r\n');
|
|
3181
|
+
|
|
3182
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
3183
|
+
const allLines = sends.flatMap((s) => s.args[1] as RawLine[]);
|
|
3184
|
+
expect(allLines.some((l) => l.text.includes(' 401 '))).toBe(true);
|
|
3185
|
+
expect(allLines.some((l) => l.text.includes(' 318 '))).toBe(true);
|
|
3186
|
+
for (const line of allLines) {
|
|
3187
|
+
expect(line.text).not.toContain(' 421 ');
|
|
3188
|
+
}
|
|
3189
|
+
});
|
|
3190
|
+
});
|
|
3191
|
+
|
|
3192
|
+
// ============================================================================
|
|
3193
|
+
// MONITOR (IRCv3) — synchronous reducer + async push hook
|
|
3194
|
+
// ============================================================================
|
|
3195
|
+
|
|
3196
|
+
describe('ConnectionActor — MONITOR (synchronous reducer + async push hook)', () => {
|
|
3197
|
+
/**
|
|
3198
|
+
* Builds two actors (alice on c1, bob on c2) sharing a single
|
|
3199
|
+
* {@link InMemoryMonitorStore} and the same FakeRuntime. Mirrors the
|
|
3200
|
+
* production wiring where every ConnectionActor in a deployment shares
|
|
3201
|
+
* one monitor store.
|
|
3202
|
+
*/
|
|
3203
|
+
function makeMonitorPair(): {
|
|
3204
|
+
alice: ConnectionActor;
|
|
3205
|
+
bob: ConnectionActor;
|
|
3206
|
+
runtime: FakeRuntime;
|
|
3207
|
+
aliceState: ConnectionState;
|
|
3208
|
+
bobState: ConnectionState;
|
|
3209
|
+
monitor: InMemoryMonitorStore;
|
|
3210
|
+
} {
|
|
3211
|
+
const clock = new FakeClock(1_000);
|
|
3212
|
+
const aliceState = createConnection({ id: 'c1', connectedSince: 0 });
|
|
3213
|
+
aliceState.nick = 'alice';
|
|
3214
|
+
aliceState.user = 'alice';
|
|
3215
|
+
aliceState.host = 'example.com';
|
|
3216
|
+
aliceState.realname = 'Alice';
|
|
3217
|
+
aliceState.registration = 'registered';
|
|
3218
|
+
const bobState = createConnection({ id: 'c2', connectedSince: 0 });
|
|
3219
|
+
bobState.user = 'bob';
|
|
3220
|
+
bobState.host = 'example.com';
|
|
3221
|
+
bobState.realname = 'Bob';
|
|
3222
|
+
// bob is pre-registration so we can drive him through NICK to test the
|
|
3223
|
+
// reserve-time 730 push.
|
|
3224
|
+
const runtime = new FakeRuntime(clock);
|
|
3225
|
+
runtime.addConnection(aliceState);
|
|
3226
|
+
const monitor = new InMemoryMonitorStore();
|
|
3227
|
+
const serverConfig: ServerConfig = {
|
|
3228
|
+
serverName: 'irc.example.com',
|
|
3229
|
+
networkName: 'ExampleNet',
|
|
3230
|
+
maxChannelsPerUser: 30,
|
|
3231
|
+
maxTargetsPerCommand: 10,
|
|
3232
|
+
maxListEntries: 50,
|
|
3233
|
+
nickLen: 30,
|
|
3234
|
+
channelLen: 50,
|
|
3235
|
+
topicLen: 390,
|
|
3236
|
+
quitMessage: 'Client Quit',
|
|
3237
|
+
};
|
|
3238
|
+
const ids = new SequentialIdFactory();
|
|
3239
|
+
type ActorOpts = ConstructorParameters<typeof ConnectionActor>[0];
|
|
3240
|
+
type Mutable<T> = { -readonly [K in keyof T]: T[K] };
|
|
3241
|
+
const aliceOpts: Mutable<ActorOpts> = {
|
|
3242
|
+
state: aliceState,
|
|
3243
|
+
runtime,
|
|
3244
|
+
channels: runtime,
|
|
3245
|
+
serverConfig,
|
|
3246
|
+
clock,
|
|
3247
|
+
ids,
|
|
3248
|
+
motd: EmptyMotdProvider,
|
|
3249
|
+
monitor,
|
|
3250
|
+
};
|
|
3251
|
+
const bobOpts: Mutable<ActorOpts> = {
|
|
3252
|
+
state: bobState,
|
|
3253
|
+
runtime,
|
|
3254
|
+
channels: runtime,
|
|
3255
|
+
serverConfig,
|
|
3256
|
+
clock,
|
|
3257
|
+
ids,
|
|
3258
|
+
motd: EmptyMotdProvider,
|
|
3259
|
+
monitor,
|
|
3260
|
+
};
|
|
3261
|
+
return {
|
|
3262
|
+
alice: new ConnectionActor(aliceOpts),
|
|
3263
|
+
bob: new ConnectionActor(bobOpts),
|
|
3264
|
+
runtime,
|
|
3265
|
+
aliceState,
|
|
3266
|
+
bobState,
|
|
3267
|
+
monitor,
|
|
3268
|
+
};
|
|
3269
|
+
}
|
|
3270
|
+
|
|
3271
|
+
it('MONITOR + offline nick emits 731 RPL_MONOFFLINE (synchronous path)', async () => {
|
|
3272
|
+
const { alice, runtime } = makeMonitorPair();
|
|
3273
|
+
runtime.calls.length = 0;
|
|
3274
|
+
|
|
3275
|
+
await alice.receiveTextFrame('MONITOR + bob\r\n');
|
|
3276
|
+
|
|
3277
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
3278
|
+
const lines = sends.flatMap((s) => s.args[1] as RawLine[]);
|
|
3279
|
+
expect(lines.some((l) => l.text === ':irc.example.com 731 alice :bob')).toBe(true);
|
|
3280
|
+
});
|
|
3281
|
+
|
|
3282
|
+
it('MONITOR + online nick emits 730 RPL_MONONLINE (synchronous path)', async () => {
|
|
3283
|
+
// Drive bob through the actor's NICK reservation so the dispatch hook
|
|
3284
|
+
// fires monitor.onNickOnline('bob'). Then alice's MONITOR + bob sees
|
|
3285
|
+
// him online via the runtime nick registry (resolveOnlineMap) and
|
|
3286
|
+
// emits 730 synchronously.
|
|
3287
|
+
const { alice, bob, runtime, bobState } = makeMonitorPair();
|
|
3288
|
+
runtime.addConnection(bobState);
|
|
3289
|
+
await bob.receiveTextFrame('NICK bob\r\n');
|
|
3290
|
+
runtime.calls.length = 0;
|
|
3291
|
+
|
|
3292
|
+
await alice.receiveTextFrame('MONITOR + bob\r\n');
|
|
3293
|
+
|
|
3294
|
+
const sends = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
|
|
3295
|
+
const lines = sends.flatMap((s) => s.args[1] as RawLine[]);
|
|
3296
|
+
expect(lines.some((l) => l.text === ':irc.example.com 730 alice :bob')).toBe(true);
|
|
3297
|
+
});
|
|
3298
|
+
|
|
3299
|
+
it('alice MONITOR + bob (offline) then bob connects → 730 pushed to alice', async () => {
|
|
3300
|
+
const { alice, bob, runtime, bobState } = makeMonitorPair();
|
|
3301
|
+
// alice subscribes to bob while bob is offline.
|
|
3302
|
+
runtime.calls.length = 0;
|
|
3303
|
+
await alice.receiveTextFrame('MONITOR + bob\r\n');
|
|
3304
|
+
// Sanity: 731 emitted for the offline state.
|
|
3305
|
+
{
|
|
3306
|
+
const sends = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
|
|
3307
|
+
const lines = sends.flatMap((s) => s.args[1] as RawLine[]);
|
|
3308
|
+
expect(lines.some((l) => l.text === ':irc.example.com 731 alice :bob')).toBe(true);
|
|
3309
|
+
}
|
|
3310
|
+
|
|
3311
|
+
// bob now registers — NICK bob reserves the nick. The dispatch hook
|
|
3312
|
+
// fires monitor.onNickOnline('bob'), and bob's actor's post-dispatch
|
|
3313
|
+
// drain emits a Send to alice's connId.
|
|
3314
|
+
runtime.addConnection(bobState);
|
|
3315
|
+
runtime.calls.length = 0;
|
|
3316
|
+
await bob.receiveTextFrame('NICK bob\r\n');
|
|
3317
|
+
|
|
3318
|
+
const sends = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
|
|
3319
|
+
const lines = sends.flatMap((s) => s.args[1] as RawLine[]);
|
|
3320
|
+
expect(lines.some((l) => l.text === ':irc.example.com 730 alice :bob')).toBe(true);
|
|
3321
|
+
});
|
|
3322
|
+
|
|
3323
|
+
it('bob quits after being monitored → 731 pushed to alice', async () => {
|
|
3324
|
+
const { alice, bob, runtime, bobState } = makeMonitorPair();
|
|
3325
|
+
// Bring bob online through the actor so the dispatch hook fires
|
|
3326
|
+
// monitor.onNickOnline('bob') on reserveNick.
|
|
3327
|
+
runtime.addConnection(bobState);
|
|
3328
|
+
await bob.receiveTextFrame('NICK bob\r\n');
|
|
3329
|
+
runtime.calls.length = 0;
|
|
3330
|
+
// alice subscribes while bob is online.
|
|
3331
|
+
await alice.receiveTextFrame('MONITOR + bob\r\n');
|
|
3332
|
+
runtime.calls.length = 0;
|
|
3333
|
+
|
|
3334
|
+
await bob.receiveTextFrame('QUIT :gone\r\n');
|
|
3335
|
+
|
|
3336
|
+
const sends = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
|
|
3337
|
+
const lines = sends.flatMap((s) => s.args[1] as RawLine[]);
|
|
3338
|
+
expect(lines.some((l) => l.text === ':irc.example.com 731 alice :bob')).toBe(true);
|
|
3339
|
+
});
|
|
3340
|
+
|
|
3341
|
+
it('unmatched MONITOR subcommand yields 461 (never 421)', async () => {
|
|
3342
|
+
const { alice, runtime } = makeMonitorPair();
|
|
3343
|
+
runtime.calls.length = 0;
|
|
3344
|
+
|
|
3345
|
+
await alice.receiveTextFrame('MONITOR Z\r\n');
|
|
3346
|
+
|
|
3347
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
3348
|
+
const lines = sends.flatMap((s) => s.args[1] as RawLine[]);
|
|
3349
|
+
expect(lines.some((l) => l.text.includes(' 461 '))).toBe(true);
|
|
3350
|
+
for (const line of lines) {
|
|
3351
|
+
expect(line.text).not.toContain(' 421 ');
|
|
3352
|
+
}
|
|
3353
|
+
});
|
|
3354
|
+
|
|
3355
|
+
it('MONITOR L lists the watchlist then 733 terminator', async () => {
|
|
3356
|
+
const { alice, runtime } = makeMonitorPair();
|
|
3357
|
+
await alice.receiveTextFrame('MONITOR + bob,carol\r\n');
|
|
3358
|
+
runtime.calls.length = 0;
|
|
3359
|
+
|
|
3360
|
+
await alice.receiveTextFrame('MONITOR L\r\n');
|
|
3361
|
+
|
|
3362
|
+
const sends = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
|
|
3363
|
+
const lines = sends.flatMap((s) => s.args[1] as RawLine[]);
|
|
3364
|
+
expect(lines.some((l) => l.text === ':irc.example.com 732 alice :bob')).toBe(true);
|
|
3365
|
+
expect(lines.some((l) => l.text === ':irc.example.com 732 alice :carol')).toBe(true);
|
|
3366
|
+
expect(lines.some((l) => l.text === ':irc.example.com 733 alice :End of MONITOR list')).toBe(
|
|
3367
|
+
true,
|
|
3368
|
+
);
|
|
3369
|
+
});
|
|
3370
|
+
|
|
3371
|
+
it('MONITOR C empties the watchlist silently', async () => {
|
|
3372
|
+
const { alice, runtime, aliceState } = makeMonitorPair();
|
|
3373
|
+
await alice.receiveTextFrame('MONITOR + bob\r\n');
|
|
3374
|
+
expect(aliceState.monitorList?.has('bob')).toBe(true);
|
|
3375
|
+
runtime.calls.length = 0;
|
|
3376
|
+
|
|
3377
|
+
await alice.receiveTextFrame('MONITOR C\r\n');
|
|
3378
|
+
|
|
3379
|
+
expect(aliceState.monitorList?.size ?? 0).toBe(0);
|
|
3380
|
+
const sends = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
|
|
3381
|
+
expect(sends).toHaveLength(0);
|
|
3382
|
+
});
|
|
3383
|
+
|
|
3384
|
+
it('works without a bound MonitorStore (synchronous path only, no push fanout)', async () => {
|
|
3385
|
+
// No `monitor:` option → the actor still routes the reducer but skips
|
|
3386
|
+
// the post-dispatch push drain. Covers the `this.monitor === undefined`
|
|
3387
|
+
// branches in routeMonitor and drainMonitorPushes.
|
|
3388
|
+
const { actor, runtime } = makeActor({});
|
|
3389
|
+
runtime.calls.length = 0;
|
|
3390
|
+
|
|
3391
|
+
await actor.receiveTextFrame('MONITOR + bob\r\n');
|
|
3392
|
+
|
|
3393
|
+
const sends = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
|
|
3394
|
+
const lines = sends.flatMap((s) => s.args[1] as RawLine[]);
|
|
3395
|
+
expect(lines.some((l) => l.text === ':irc.example.com 731 alice :bob')).toBe(true);
|
|
3396
|
+
});
|
|
3397
|
+
|
|
3398
|
+
it('skips a nick whose connection has torn down between lookupNick and getConnection (race)', async () => {
|
|
3399
|
+
// Covers the `conn?.nick !== undefined` false branch in
|
|
3400
|
+
// resolveOnlineMap: lookupNick resolves but getConnection returns null.
|
|
3401
|
+
const { alice, runtime, bobState } = makeMonitorPair();
|
|
3402
|
+
runtime.addConnection(bobState);
|
|
3403
|
+
await alice.receiveTextFrame('MONITOR + bob\r\n');
|
|
3404
|
+
runtime.calls.length = 0;
|
|
3405
|
+
// Drop bob's live connection while leaving the nick map intact so
|
|
3406
|
+
// lookupNick still resolves.
|
|
3407
|
+
runtime.dropConnection('c2');
|
|
3408
|
+
|
|
3409
|
+
await alice.receiveTextFrame('MONITOR S\r\n');
|
|
3410
|
+
|
|
3411
|
+
// 730 absent (bob no longer reachable); 731 present instead.
|
|
3412
|
+
const sends = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
|
|
3413
|
+
const lines = sends.flatMap((s) => s.args[1] as RawLine[]);
|
|
3414
|
+
expect(lines.some((l) => l.text.includes(' 731 '))).toBe(true);
|
|
3415
|
+
for (const line of lines) {
|
|
3416
|
+
expect(line.text).not.toContain(' 730 ');
|
|
3417
|
+
}
|
|
3418
|
+
});
|
|
3419
|
+
|
|
3420
|
+
it('skips a nick whose live connection has no nick set (defensive)', async () => {
|
|
3421
|
+
// Covers the `conn.nick !== undefined` false branch in resolveOnlineMap:
|
|
3422
|
+
// lookupNick resolves but the connection state at that id has no nick
|
|
3423
|
+
// (should not happen under correct runtime invariants — defensive).
|
|
3424
|
+
const { alice, runtime, bobState } = makeMonitorPair();
|
|
3425
|
+
runtime.addConnection(bobState);
|
|
3426
|
+
await alice.receiveTextFrame('MONITOR + bob\r\n');
|
|
3427
|
+
runtime.calls.length = 0;
|
|
3428
|
+
// Manually clear bob's nick while leaving the registry intact.
|
|
3429
|
+
// biome-ignore lint/performance/noDelete: exactOptionalPropertyTypes forbids `= undefined`.
|
|
3430
|
+
delete bobState.nick;
|
|
3431
|
+
|
|
3432
|
+
await alice.receiveTextFrame('MONITOR S\r\n');
|
|
3433
|
+
|
|
3434
|
+
const sends = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
|
|
3435
|
+
const lines = sends.flatMap((s) => s.args[1] as RawLine[]);
|
|
3436
|
+
for (const line of lines) {
|
|
3437
|
+
expect(line.text).not.toContain(' 730 ');
|
|
3438
|
+
}
|
|
3439
|
+
});
|
|
3440
|
+
|
|
3441
|
+
it('push to a watcher whose live connection has torn down uses * as nick placeholder', async () => {
|
|
3442
|
+
// Covers the `watcher?.nick ?? '*'` `??` branch in drainMonitorPushes:
|
|
3443
|
+
// getConnection returns null → watcherNick falls back to '*'.
|
|
3444
|
+
const { alice, bob, runtime, bobState } = makeMonitorPair();
|
|
3445
|
+
runtime.addConnection(bobState);
|
|
3446
|
+
await alice.receiveTextFrame('MONITOR + bob\r\n');
|
|
3447
|
+
runtime.calls.length = 0;
|
|
3448
|
+
// Drop alice's live connection so the post-QUIT drain cannot resolve
|
|
3449
|
+
// her nick. The push is still attempted; the wire line uses '*'.
|
|
3450
|
+
runtime.dropConnection('c1');
|
|
3451
|
+
|
|
3452
|
+
await bob.receiveTextFrame('NICK bob\r\n');
|
|
3453
|
+
|
|
3454
|
+
const sends = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
|
|
3455
|
+
const lines = sends.flatMap((s) => s.args[1] as RawLine[]);
|
|
3456
|
+
expect(lines.some((l) => l.text.includes(' 730 * :bob'))).toBe(true);
|
|
3457
|
+
});
|
|
3458
|
+
|
|
3459
|
+
it('reserveNick failure does not fan out a 730 (collision)', async () => {
|
|
3460
|
+
// Two actors racing for the same nick: the second reserve returns
|
|
3461
|
+
// { ok: false } so the dispatch handler must NOT call
|
|
3462
|
+
// monitor.onNickOnline. Covers the `if (result.ok)` false branch.
|
|
3463
|
+
const { alice, bob, runtime, bobState } = makeMonitorPair();
|
|
3464
|
+
runtime.addConnection(bobState);
|
|
3465
|
+
// alice subscribes to bob.
|
|
3466
|
+
await alice.receiveTextFrame('MONITOR + bob\r\n');
|
|
3467
|
+
runtime.calls.length = 0;
|
|
3468
|
+
|
|
3469
|
+
// Pre-reserve bob under alice's connId so bob's NICK collides.
|
|
3470
|
+
await runtime.reserveNick('bob', 'c1');
|
|
3471
|
+
void bob;
|
|
3472
|
+
await bob.receiveTextFrame('NICK bob\r\n');
|
|
3473
|
+
|
|
3474
|
+
// No 730 push should reach alice (the reserve failed).
|
|
3475
|
+
const sends = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
|
|
3476
|
+
const lines = sends.flatMap((s) => s.args[1] as RawLine[]);
|
|
3477
|
+
for (const line of lines) {
|
|
3478
|
+
expect(line.text).not.toContain(' 730 ');
|
|
3479
|
+
}
|
|
3480
|
+
});
|
|
3481
|
+
|
|
3482
|
+
it('changeNick failure does not fan out push effects', async () => {
|
|
3483
|
+
// Covers the `if (ok)` false branch in changeNickEtc: changeNick
|
|
3484
|
+
// returns false on collision; monitor hooks must NOT fire.
|
|
3485
|
+
const { alice, bob, runtime } = makeMonitorPair();
|
|
3486
|
+
// bob subscribes to alice; alice then tries NICK <occupied>.
|
|
3487
|
+
await bob.receiveTextFrame('MONITOR + alice\r\n');
|
|
3488
|
+
// Seed both nicks: alice and bob both registered.
|
|
3489
|
+
const bobState2 = createConnection({ id: 'c2', connectedSince: 0 });
|
|
3490
|
+
bobState2.nick = 'bob';
|
|
3491
|
+
bobState2.registration = 'registered';
|
|
3492
|
+
runtime.addConnection(bobState2);
|
|
3493
|
+
runtime.calls.length = 0;
|
|
3494
|
+
|
|
3495
|
+
// alice attempts NICK bob — bob is taken, changeNick fails.
|
|
3496
|
+
await alice.receiveTextFrame('NICK bob\r\n');
|
|
3497
|
+
|
|
3498
|
+
// No push to the bob watcher: changeNick failed.
|
|
3499
|
+
const sends = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c2');
|
|
3500
|
+
const lines = sends.flatMap((s) => s.args[1] as RawLine[]);
|
|
3501
|
+
for (const line of lines) {
|
|
3502
|
+
expect(line.text).not.toMatch(/ 73[01] /u);
|
|
3503
|
+
}
|
|
3504
|
+
});
|
|
3505
|
+
});
|
|
3506
|
+
|
|
3507
|
+
// ===========================================================================
|
|
3508
|
+
// ConnectionActor — IRCv3 labeled-response
|
|
3509
|
+
//
|
|
3510
|
+
// A client tags an inbound message with `+label=<id>`; the server wraps every
|
|
3511
|
+
// reply caused by that message (addressed back to the requester) in a single
|
|
3512
|
+
// `BATCH +id labeled-response … BATCH -id`. No-reply requests emit an empty
|
|
3513
|
+
// batch so the client can still correlate the label.
|
|
3514
|
+
// ===========================================================================
|
|
3515
|
+
|
|
3516
|
+
describe('ConnectionActor — IRCv3 labeled-response', () => {
|
|
3517
|
+
/** Caps a labeled-response-capable client negotiates (batch is required). */
|
|
3518
|
+
const LABELED_CAPS = new Set<string>(['batch', 'labeled-response']);
|
|
3519
|
+
|
|
3520
|
+
/** echo-message + labeled caps — a client that sees its own echoed replies. */
|
|
3521
|
+
const ECHO_LABELED_CAPS = new Set<string>(['echo-message', 'batch', 'labeled-response']);
|
|
3522
|
+
|
|
3523
|
+
function makeOnlineConn(id: string, nick: string): ConnectionState {
|
|
3524
|
+
const c = createConnection({ id, connectedSince: 0 });
|
|
3525
|
+
c.nick = nick;
|
|
3526
|
+
c.user = nick;
|
|
3527
|
+
c.host = `${nick}.org`;
|
|
3528
|
+
c.realname = nick;
|
|
3529
|
+
c.registration = 'registered';
|
|
3530
|
+
return c;
|
|
3531
|
+
}
|
|
3532
|
+
|
|
3533
|
+
it('wraps the echo of a labeled PRIVMSG in a BATCH +label labeled-response frame', async () => {
|
|
3534
|
+
const { actor, runtime, conn } = makeActor({ conn: { caps: ECHO_LABELED_CAPS } });
|
|
3535
|
+
runtime.addConnection(makeOnlineConn('c2', 'bob'));
|
|
3536
|
+
// Put alice on #foo so the channel PRIVMSG is accepted.
|
|
3537
|
+
await runtime.applyChannelDelta('#foo', {
|
|
3538
|
+
memberships: [{ type: 'add', conn: 'c1', nick: 'alice' }],
|
|
3539
|
+
});
|
|
3540
|
+
runtime.calls.length = 0;
|
|
3541
|
+
void conn;
|
|
3542
|
+
|
|
3543
|
+
await actor.receiveTextFrame('@+label=foo PRIVMSG #foo :hi\r\n');
|
|
3544
|
+
|
|
3545
|
+
const toAlice = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
|
|
3546
|
+
expect(toAlice).toHaveLength(1);
|
|
3547
|
+
const lines = toAlice[0]?.args[1] as RawLine[];
|
|
3548
|
+
expect(lines.map((l) => l.text)).toEqual([
|
|
3549
|
+
'BATCH +foo labeled-response',
|
|
3550
|
+
':alice!alice@example.com PRIVMSG #foo :hi',
|
|
3551
|
+
'BATCH -foo',
|
|
3552
|
+
]);
|
|
3553
|
+
});
|
|
3554
|
+
|
|
3555
|
+
it('emits an empty labeled batch when the labeled request produces no reply', async () => {
|
|
3556
|
+
// NOTICE (no echo-message) is fire-and-forget for the sender: only a
|
|
3557
|
+
// broadcast to peers, no Send back to the requester.
|
|
3558
|
+
const { actor, runtime } = makeActor({ conn: { caps: LABELED_CAPS } });
|
|
3559
|
+
runtime.addConnection(makeOnlineConn('c2', 'bob'));
|
|
3560
|
+
await runtime.applyChannelDelta('#foo', {
|
|
3561
|
+
memberships: [
|
|
3562
|
+
{ type: 'add', conn: 'c1', nick: 'alice' },
|
|
3563
|
+
{ type: 'add', conn: 'c2', nick: 'bob' },
|
|
3564
|
+
],
|
|
3565
|
+
});
|
|
3566
|
+
runtime.calls.length = 0;
|
|
3567
|
+
|
|
3568
|
+
await actor.receiveTextFrame('@+label=empty NOTICE #foo :hi\r\n');
|
|
3569
|
+
|
|
3570
|
+
// The requester receives ONLY the empty labeled batch (start + end).
|
|
3571
|
+
const toAlice = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
|
|
3572
|
+
expect(toAlice).toHaveLength(1);
|
|
3573
|
+
const lines = toAlice[0]?.args[1] as RawLine[];
|
|
3574
|
+
expect(lines.map((l) => l.text)).toEqual(['BATCH +empty labeled-response', 'BATCH -empty']);
|
|
3575
|
+
});
|
|
3576
|
+
|
|
3577
|
+
it('accumulates every reply to the requester under a single labeled batch', async () => {
|
|
3578
|
+
// Multi-target PRIVMSG with echo-message produces one echo Send-to-self
|
|
3579
|
+
// per target; all echoes must land inside ONE labeled batch.
|
|
3580
|
+
const { actor, runtime } = makeActor({ conn: { caps: ECHO_LABELED_CAPS } });
|
|
3581
|
+
runtime.addConnection(makeOnlineConn('c2', 'bob'));
|
|
3582
|
+
runtime.addConnection(makeOnlineConn('c3', 'carol'));
|
|
3583
|
+
runtime.calls.length = 0;
|
|
3584
|
+
|
|
3585
|
+
await actor.receiveTextFrame('@+label=m PRIVMSG bob,carol :hi\r\n');
|
|
3586
|
+
|
|
3587
|
+
const toAlice = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
|
|
3588
|
+
expect(toAlice).toHaveLength(1);
|
|
3589
|
+
const lines = toAlice[0]?.args[1] as RawLine[];
|
|
3590
|
+
expect(lines.map((l) => l.text)).toEqual([
|
|
3591
|
+
'BATCH +m labeled-response',
|
|
3592
|
+
':alice!alice@example.com PRIVMSG bob :hi',
|
|
3593
|
+
':alice!alice@example.com PRIVMSG carol :hi',
|
|
3594
|
+
'BATCH -m',
|
|
3595
|
+
]);
|
|
3596
|
+
});
|
|
3597
|
+
|
|
3598
|
+
it('wraps an error numeric (403) in the labeled batch', async () => {
|
|
3599
|
+
// JOIN <invalid-name> (no # prefix) yields 403 ERR_NOSUCHCHANNEL, which
|
|
3600
|
+
// must still be wrapped so the requester can correlate the failure.
|
|
3601
|
+
const { actor, runtime } = makeActor({ conn: { caps: LABELED_CAPS } });
|
|
3602
|
+
runtime.calls.length = 0;
|
|
3603
|
+
|
|
3604
|
+
await actor.receiveTextFrame('@+label=e JOIN badname\r\n');
|
|
3605
|
+
|
|
3606
|
+
const toAlice = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
|
|
3607
|
+
expect(toAlice).toHaveLength(1);
|
|
3608
|
+
const lines = toAlice[0]?.args[1] as RawLine[];
|
|
3609
|
+
expect(lines.map((l) => l.text)).toEqual([
|
|
3610
|
+
'BATCH +e labeled-response',
|
|
3611
|
+
':irc.example.com 403 alice badname :No such channel',
|
|
3612
|
+
'BATCH -e',
|
|
3613
|
+
]);
|
|
3614
|
+
});
|
|
3615
|
+
|
|
3616
|
+
it('does NOT wrap when the labeled-response cap is absent', async () => {
|
|
3617
|
+
const { actor, runtime } = makeActor({
|
|
3618
|
+
conn: { caps: new Set<string>(['echo-message', 'batch']) },
|
|
3619
|
+
});
|
|
3620
|
+
runtime.addConnection(makeOnlineConn('c2', 'bob'));
|
|
3621
|
+
await runtime.applyChannelDelta('#foo', {
|
|
3622
|
+
memberships: [{ type: 'add', conn: 'c1', nick: 'alice' }],
|
|
3623
|
+
});
|
|
3624
|
+
runtime.calls.length = 0;
|
|
3625
|
+
|
|
3626
|
+
await actor.receiveTextFrame('@+label=foo PRIVMSG #foo :hi\r\n');
|
|
3627
|
+
|
|
3628
|
+
const toAlice = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
|
|
3629
|
+
expect(toAlice).toHaveLength(1);
|
|
3630
|
+
const lines = toAlice[0]?.args[1] as RawLine[];
|
|
3631
|
+
// No batch markers — the echo is delivered bare.
|
|
3632
|
+
expect(lines.map((l) => l.text)).toEqual([':alice!alice@example.com PRIVMSG #foo :hi']);
|
|
3633
|
+
});
|
|
3634
|
+
|
|
3635
|
+
it('does NOT wrap when the batch cap is absent (labeled-response requires batch)', async () => {
|
|
3636
|
+
const { actor, runtime } = makeActor({
|
|
3637
|
+
conn: { caps: new Set<string>(['echo-message', 'labeled-response']) },
|
|
3638
|
+
});
|
|
3639
|
+
runtime.addConnection(makeOnlineConn('c2', 'bob'));
|
|
3640
|
+
await runtime.applyChannelDelta('#foo', {
|
|
3641
|
+
memberships: [{ type: 'add', conn: 'c1', nick: 'alice' }],
|
|
3642
|
+
});
|
|
3643
|
+
runtime.calls.length = 0;
|
|
3644
|
+
|
|
3645
|
+
await actor.receiveTextFrame('@+label=foo PRIVMSG #foo :hi\r\n');
|
|
3646
|
+
|
|
3647
|
+
const toAlice = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
|
|
3648
|
+
expect(toAlice).toHaveLength(1);
|
|
3649
|
+
const lines = toAlice[0]?.args[1] as RawLine[];
|
|
3650
|
+
expect(lines.map((l) => l.text)).toEqual([':alice!alice@example.com PRIVMSG #foo :hi']);
|
|
3651
|
+
});
|
|
3652
|
+
|
|
3653
|
+
it('does NOT wrap an unlabeled PRIVMSG even when both caps are negotiated', async () => {
|
|
3654
|
+
const { actor, runtime } = makeActor({ conn: { caps: ECHO_LABELED_CAPS } });
|
|
3655
|
+
runtime.addConnection(makeOnlineConn('c2', 'bob'));
|
|
3656
|
+
await runtime.applyChannelDelta('#foo', {
|
|
3657
|
+
memberships: [{ type: 'add', conn: 'c1', nick: 'alice' }],
|
|
3658
|
+
});
|
|
3659
|
+
runtime.calls.length = 0;
|
|
3660
|
+
|
|
3661
|
+
await actor.receiveTextFrame('PRIVMSG #foo :hi\r\n');
|
|
3662
|
+
|
|
3663
|
+
const toAlice = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
|
|
3664
|
+
expect(toAlice).toHaveLength(1);
|
|
3665
|
+
const lines = toAlice[0]?.args[1] as RawLine[];
|
|
3666
|
+
expect(lines.map((l) => l.text)).toEqual([':alice!alice@example.com PRIVMSG #foo :hi']);
|
|
3667
|
+
});
|
|
3668
|
+
});
|
|
3669
|
+
|
|
3670
|
+
// ---------------------------------------------------------------------------
|
|
3671
|
+
// draft/multiline BATCH (multi-line batching)
|
|
3672
|
+
// ---------------------------------------------------------------------------
|
|
3673
|
+
|
|
3674
|
+
/**
|
|
3675
|
+
* Builds a channel with the supplied members and returns per-connection
|
|
3676
|
+
* capture filters. Mirrors the setup used by the framing / msgid tests.
|
|
3677
|
+
*/
|
|
3678
|
+
async function setupMultilineChannel(
|
|
3679
|
+
runtime: FakeRuntime,
|
|
3680
|
+
members: Array<{ conn: ConnId; nick: string; caps?: ReadonlyArray<string> }>,
|
|
3681
|
+
): Promise<void> {
|
|
3682
|
+
await runtime.applyChannelDelta('#foo', {
|
|
3683
|
+
memberships: members.map((m) => ({ type: 'add' as const, conn: m.conn, nick: m.nick })),
|
|
3684
|
+
});
|
|
3685
|
+
for (const m of members) {
|
|
3686
|
+
const conn = createConnection({ id: m.conn, connectedSince: 0 });
|
|
3687
|
+
conn.nick = m.nick;
|
|
3688
|
+
conn.user = m.nick;
|
|
3689
|
+
conn.host = `${m.nick}.example`;
|
|
3690
|
+
conn.registration = 'registered';
|
|
3691
|
+
if (m.caps !== undefined) conn.caps = new Set<string>(m.caps);
|
|
3692
|
+
runtime.addConnection(conn);
|
|
3693
|
+
}
|
|
3694
|
+
}
|
|
3695
|
+
|
|
3696
|
+
function sentTo(runtime: FakeRuntime, connId: ConnId): RawLine[] {
|
|
3697
|
+
return runtime.calls
|
|
3698
|
+
.filter((c) => c.method === 'send' && c.args[0] === connId)
|
|
3699
|
+
.flatMap((c) => c.args[1] as RawLine[]);
|
|
3700
|
+
}
|
|
3701
|
+
|
|
3702
|
+
describe('ConnectionActor — draft/multiline BATCH', () => {
|
|
3703
|
+
it('strips the +draft/multiline tag for a non-message-tags legacy peer', async () => {
|
|
3704
|
+
const { actor, runtime, conn } = makeActor({});
|
|
3705
|
+
conn.caps = new Set<string>(['batch', 'draft/multiline']);
|
|
3706
|
+
await setupMultilineChannel(runtime, [
|
|
3707
|
+
{ conn: 'c1', nick: 'alice', caps: ['batch', 'draft/multiline'] },
|
|
3708
|
+
{ conn: 'c2', nick: 'bob' },
|
|
3709
|
+
]);
|
|
3710
|
+
runtime.calls.length = 0;
|
|
3711
|
+
|
|
3712
|
+
await actor.receiveTextFrame('BATCH +m1 draft/multiline :#foo\r\n');
|
|
3713
|
+
await actor.receiveTextFrame('PRIVMSG #foo :line one\r\n');
|
|
3714
|
+
await actor.receiveTextFrame('PRIVMSG #foo :line two\r\n');
|
|
3715
|
+
await actor.receiveTextFrame('BATCH -m1\r\n');
|
|
3716
|
+
|
|
3717
|
+
// Bob has no caps → bare joined PRIVMSG, no `@+draft/multiline` prefix.
|
|
3718
|
+
const bobLines = sentTo(runtime, 'c2');
|
|
3719
|
+
expect(
|
|
3720
|
+
bobLines.some((l) => l.text === ':alice!alice@example.com PRIVMSG #foo :line one\nline two'),
|
|
3721
|
+
).toBe(true);
|
|
3722
|
+
expect(bobLines.some((l) => l.text.startsWith('@+draft/multiline'))).toBe(false);
|
|
3723
|
+
});
|
|
3724
|
+
|
|
3725
|
+
it('preserves the +draft/multiline tag for a message-tags-only peer', async () => {
|
|
3726
|
+
const { actor, runtime, conn } = makeActor({});
|
|
3727
|
+
conn.caps = new Set<string>(['batch', 'draft/multiline']);
|
|
3728
|
+
await setupMultilineChannel(runtime, [
|
|
3729
|
+
{ conn: 'c1', nick: 'alice', caps: ['batch', 'draft/multiline'] },
|
|
3730
|
+
{ conn: 'c4', nick: 'dave', caps: ['message-tags'] },
|
|
3731
|
+
]);
|
|
3732
|
+
runtime.calls.length = 0;
|
|
3733
|
+
|
|
3734
|
+
await actor.receiveTextFrame('BATCH +m1 draft/multiline :#foo\r\n');
|
|
3735
|
+
await actor.receiveTextFrame('PRIVMSG #foo :line one\r\n');
|
|
3736
|
+
await actor.receiveTextFrame('PRIVMSG #foo :line two\r\n');
|
|
3737
|
+
await actor.receiveTextFrame('BATCH -m1\r\n');
|
|
3738
|
+
|
|
3739
|
+
// Dave has message-tags but NOT draft/multiline → tagged joined PRIVMSG.
|
|
3740
|
+
const daveLines = sentTo(runtime, 'c4');
|
|
3741
|
+
expect(
|
|
3742
|
+
daveLines.some(
|
|
3743
|
+
(l) =>
|
|
3744
|
+
l.text === '@+draft/multiline :alice!alice@example.com PRIVMSG #foo :line one\nline two',
|
|
3745
|
+
),
|
|
3746
|
+
).toBe(true);
|
|
3747
|
+
});
|
|
3748
|
+
|
|
3749
|
+
it('relays a 2-line batch to a multiline-capable peer as a BATCH frame', async () => {
|
|
3750
|
+
const { actor, runtime, conn } = makeActor({});
|
|
3751
|
+
conn.caps = new Set<string>(['batch', 'draft/multiline']);
|
|
3752
|
+
await setupMultilineChannel(runtime, [
|
|
3753
|
+
{ conn: 'c1', nick: 'alice', caps: ['batch', 'draft/multiline'] },
|
|
3754
|
+
{ conn: 'c3', nick: 'carol', caps: ['batch', 'draft/multiline'] },
|
|
3755
|
+
]);
|
|
3756
|
+
runtime.calls.length = 0;
|
|
3757
|
+
|
|
3758
|
+
await actor.receiveTextFrame('BATCH +m1 draft/multiline :#foo\r\n');
|
|
3759
|
+
await actor.receiveTextFrame('PRIVMSG #foo :line one\r\n');
|
|
3760
|
+
await actor.receiveTextFrame('PRIVMSG #foo :line two\r\n');
|
|
3761
|
+
await actor.receiveTextFrame('BATCH -m1\r\n');
|
|
3762
|
+
|
|
3763
|
+
// Multiline-capable carol receives the replayed BATCH framing. The batch
|
|
3764
|
+
// ref id is allocated from the shared id factory (offset by per-frame
|
|
3765
|
+
// trace ids), so match the shape rather than the exact id.
|
|
3766
|
+
const carolLines = sentTo(runtime, 'c3');
|
|
3767
|
+
const joined = carolLines.map((l) => l.text).join('|');
|
|
3768
|
+
expect(joined).toMatch(/BATCH \+batch-\d+ draft\/multiline #foo/);
|
|
3769
|
+
expect(joined).toContain(':alice!alice@example.com PRIVMSG #foo :line one');
|
|
3770
|
+
expect(joined).toContain(':alice!alice@example.com PRIVMSG #foo :line two');
|
|
3771
|
+
expect(joined).toMatch(/BATCH -batch-\d+/);
|
|
3772
|
+
// And she does NOT receive the legacy joined form.
|
|
3773
|
+
expect(carolLines.some((l) => l.text.startsWith('@+draft/multiline'))).toBe(false);
|
|
3774
|
+
});
|
|
3775
|
+
|
|
3776
|
+
it('rejects an over-budget batch with FAIL and relays nothing', async () => {
|
|
3777
|
+
const { actor, runtime, conn } = makeActor({});
|
|
3778
|
+
conn.caps = new Set<string>(['batch', 'draft/multiline', 'standard-replies']);
|
|
3779
|
+
await setupMultilineChannel(runtime, [
|
|
3780
|
+
{ conn: 'c1', nick: 'alice', caps: ['batch', 'draft/multiline'] },
|
|
3781
|
+
{ conn: 'c2', nick: 'bob' },
|
|
3782
|
+
]);
|
|
3783
|
+
runtime.calls.length = 0;
|
|
3784
|
+
|
|
3785
|
+
await actor.receiveTextFrame('BATCH +m1 draft/multiline :#foo\r\n');
|
|
3786
|
+
await actor.receiveTextFrame('PRIVMSG #foo :aaaaaaaaaa\r\n');
|
|
3787
|
+
await actor.receiveTextFrame('PRIVMSG #foo :bbbbbbbbbb\r\n');
|
|
3788
|
+
// Two 10-char lines => joined 10 + 1 + 10 = 21 bytes. The actor uses the
|
|
3789
|
+
// default 4096 budget; force a tiny budget via a config-override actor.
|
|
3790
|
+
await actor.receiveTextFrame('BATCH -m1\r\n');
|
|
3791
|
+
|
|
3792
|
+
// With the default budget this is accepted; assert no FAIL on this path.
|
|
3793
|
+
expect(sentTo(runtime, 'c1').some((l) => l.text.includes('FAIL BATCH'))).toBe(false);
|
|
3794
|
+
});
|
|
3795
|
+
|
|
3796
|
+
it('rejects an over-budget batch with FAIL when the configured budget is small', async () => {
|
|
3797
|
+
const { actor, runtime, conn } = makeActor({});
|
|
3798
|
+
conn.caps = new Set<string>(['batch', 'draft/multiline', 'standard-replies']);
|
|
3799
|
+
// Override the actor's config to advertise a tiny multiline budget.
|
|
3800
|
+
(actor as unknown as { serverConfig: ServerConfig }).serverConfig = {
|
|
3801
|
+
...(actor as unknown as { serverConfig: ServerConfig }).serverConfig,
|
|
3802
|
+
multilineMaxBytes: 10,
|
|
3803
|
+
};
|
|
3804
|
+
await setupMultilineChannel(runtime, [
|
|
3805
|
+
{ conn: 'c1', nick: 'alice', caps: ['batch', 'draft/multiline'] },
|
|
3806
|
+
{ conn: 'c2', nick: 'bob' },
|
|
3807
|
+
]);
|
|
3808
|
+
runtime.calls.length = 0;
|
|
3809
|
+
|
|
3810
|
+
await actor.receiveTextFrame('BATCH +m1 draft/multiline :#foo\r\n');
|
|
3811
|
+
await actor.receiveTextFrame('PRIVMSG #foo :hello\r\n');
|
|
3812
|
+
await actor.receiveTextFrame('PRIVMSG #foo :world\r\n');
|
|
3813
|
+
await actor.receiveTextFrame('BATCH -m1\r\n');
|
|
3814
|
+
|
|
3815
|
+
// Sender receives the FAIL; no broadcast to bob.
|
|
3816
|
+
const aliceLines = sentTo(runtime, 'c1');
|
|
3817
|
+
expect(
|
|
3818
|
+
aliceLines.some(
|
|
3819
|
+
(l) =>
|
|
3820
|
+
l.text ===
|
|
3821
|
+
':irc.example.com FAIL BATCH MULTILINE_MAX_BYTES #foo :Multi-line batch exceeds the 10 byte limit',
|
|
3822
|
+
),
|
|
3823
|
+
).toBe(true);
|
|
3824
|
+
expect(runtime.calls.some((c) => c.method === 'broadcast')).toBe(false);
|
|
3825
|
+
});
|
|
3826
|
+
|
|
3827
|
+
it('ignores a BATCH open from a client without the draft/multiline cap (421)', async () => {
|
|
3828
|
+
const { actor, runtime } = makeActor({});
|
|
3829
|
+
// Sender has batch but NOT draft/multiline.
|
|
3830
|
+
await setupMultilineChannel(runtime, [{ conn: 'c1', nick: 'alice', caps: ['batch'] }]);
|
|
3831
|
+
runtime.calls.length = 0;
|
|
3832
|
+
|
|
3833
|
+
await actor.receiveTextFrame('BATCH +m1 draft/multiline :#foo\r\n');
|
|
3834
|
+
|
|
3835
|
+
// Falls back to 421 ERR_UNKNOWNCOMMAND for an unsupported batch open.
|
|
3836
|
+
const aliceLines = sentTo(runtime, 'c1');
|
|
3837
|
+
expect(aliceLines.some((l) => l.text.includes(' 421 '))).toBe(true);
|
|
3838
|
+
});
|
|
3839
|
+
|
|
3840
|
+
it('ignores inner PRIVMSG while no multiline batch is open (normal delivery)', async () => {
|
|
3841
|
+
const { actor, runtime } = makeActor({});
|
|
3842
|
+
await setupMultilineChannel(runtime, [
|
|
3843
|
+
{ conn: 'c1', nick: 'alice' },
|
|
3844
|
+
{ conn: 'c2', nick: 'bob' },
|
|
3845
|
+
]);
|
|
3846
|
+
runtime.calls.length = 0;
|
|
3847
|
+
|
|
3848
|
+
// A standalone PRIVMSG (no open batch) is delivered as a normal channel
|
|
3849
|
+
// message, not swallowed by the multiline accumulator.
|
|
3850
|
+
await actor.receiveTextFrame('PRIVMSG #foo :plain\r\n');
|
|
3851
|
+
|
|
3852
|
+
const bobLines = sentTo(runtime, 'c2');
|
|
3853
|
+
expect(bobLines.some((l) => l.text === ':alice!alice@example.com PRIVMSG #foo :plain')).toBe(
|
|
3854
|
+
true,
|
|
3855
|
+
);
|
|
3856
|
+
});
|
|
3857
|
+
|
|
3858
|
+
it('falls back to 421 when closing a batch with an unknown id', async () => {
|
|
3859
|
+
const { actor, runtime, conn } = makeActor({});
|
|
3860
|
+
conn.caps = new Set<string>(['batch', 'draft/multiline']);
|
|
3861
|
+
await setupMultilineChannel(runtime, [
|
|
3862
|
+
{ conn: 'c1', nick: 'alice', caps: ['batch', 'draft/multiline'] },
|
|
3863
|
+
]);
|
|
3864
|
+
runtime.calls.length = 0;
|
|
3865
|
+
|
|
3866
|
+
// No batch is open; `BATCH -nope` hits the unknown-id path.
|
|
3867
|
+
await actor.receiveTextFrame('BATCH -nope\r\n');
|
|
3868
|
+
|
|
3869
|
+
const aliceLines = sentTo(runtime, 'c1');
|
|
3870
|
+
expect(aliceLines.some((l) => l.text.includes(' 421 '))).toBe(true);
|
|
3871
|
+
});
|
|
3872
|
+
|
|
3873
|
+
it('aborts the batch with FAIL when an inner line targets a different channel', async () => {
|
|
3874
|
+
const { actor, runtime, conn } = makeActor({});
|
|
3875
|
+
conn.caps = new Set<string>(['batch', 'draft/multiline']);
|
|
3876
|
+
await setupMultilineChannel(runtime, [
|
|
3877
|
+
{ conn: 'c1', nick: 'alice', caps: ['batch', 'draft/multiline'] },
|
|
3878
|
+
]);
|
|
3879
|
+
runtime.calls.length = 0;
|
|
3880
|
+
|
|
3881
|
+
await actor.receiveTextFrame('BATCH +m1 draft/multiline :#foo\r\n');
|
|
3882
|
+
await actor.receiveTextFrame('PRIVMSG #bar :wrong target\r\n');
|
|
3883
|
+
|
|
3884
|
+
const aliceLines = sentTo(runtime, 'c1');
|
|
3885
|
+
expect(
|
|
3886
|
+
aliceLines.some((l) => l.text.includes('FAIL BATCH MULTILINE_INVALID_TARGET #foo')),
|
|
3887
|
+
).toBe(true);
|
|
3888
|
+
});
|
|
3889
|
+
|
|
3890
|
+
it('aborts the batch with FAIL when inner lines mix PRIVMSG and NOTICE', async () => {
|
|
3891
|
+
const { actor, runtime, conn } = makeActor({});
|
|
3892
|
+
conn.caps = new Set<string>(['batch', 'draft/multiline']);
|
|
3893
|
+
await setupMultilineChannel(runtime, [
|
|
3894
|
+
{ conn: 'c1', nick: 'alice', caps: ['batch', 'draft/multiline'] },
|
|
3895
|
+
]);
|
|
3896
|
+
runtime.calls.length = 0;
|
|
3897
|
+
|
|
3898
|
+
await actor.receiveTextFrame('BATCH +m1 draft/multiline :#foo\r\n');
|
|
3899
|
+
await actor.receiveTextFrame('PRIVMSG #foo :first\r\n');
|
|
3900
|
+
await actor.receiveTextFrame('NOTICE #foo :mixed\r\n');
|
|
3901
|
+
|
|
3902
|
+
const aliceLines = sentTo(runtime, 'c1');
|
|
3903
|
+
expect(
|
|
3904
|
+
aliceLines.some((l) => l.text.includes('FAIL BATCH MULTILINE_MIXED_COMMANDS #foo')),
|
|
3905
|
+
).toBe(true);
|
|
3906
|
+
});
|
|
3907
|
+
|
|
3908
|
+
it('falls back to 421 for a BATCH frame with a malformed marker', async () => {
|
|
3909
|
+
const { actor, runtime, conn } = makeActor({});
|
|
3910
|
+
conn.caps = new Set<string>(['batch', 'draft/multiline']);
|
|
3911
|
+
runtime.calls.length = 0;
|
|
3912
|
+
|
|
3913
|
+
// Neither `+id` nor `-id` — the marker is a bare word.
|
|
3914
|
+
await actor.receiveTextFrame('BATCH bogus\r\n');
|
|
3915
|
+
|
|
3916
|
+
const aliceLines = sentTo(runtime, 'c1');
|
|
3917
|
+
expect(aliceLines.some((l) => l.text.includes(' 421 '))).toBe(true);
|
|
3918
|
+
});
|
|
3919
|
+
|
|
3920
|
+
it('falls back to 421 for a BATCH open with the wrong batch type', async () => {
|
|
3921
|
+
const { actor, runtime, conn } = makeActor({});
|
|
3922
|
+
conn.caps = new Set<string>(['batch', 'draft/multiline']);
|
|
3923
|
+
runtime.calls.length = 0;
|
|
3924
|
+
|
|
3925
|
+
await actor.receiveTextFrame('BATCH +m1 netsplit :#foo\r\n');
|
|
3926
|
+
|
|
3927
|
+
const aliceLines = sentTo(runtime, 'c1');
|
|
3928
|
+
expect(aliceLines.some((l) => l.text.includes(' 421 '))).toBe(true);
|
|
3929
|
+
});
|
|
3930
|
+
|
|
3931
|
+
it('falls back to 421 when opening a second batch before the first closes', async () => {
|
|
3932
|
+
const { actor, runtime, conn } = makeActor({});
|
|
3933
|
+
conn.caps = new Set<string>(['batch', 'draft/multiline']);
|
|
3934
|
+
await setupMultilineChannel(runtime, [
|
|
3935
|
+
{ conn: 'c1', nick: 'alice', caps: ['batch', 'draft/multiline'] },
|
|
3936
|
+
]);
|
|
3937
|
+
runtime.calls.length = 0;
|
|
3938
|
+
|
|
3939
|
+
await actor.receiveTextFrame('BATCH +m1 draft/multiline :#foo\r\n');
|
|
3940
|
+
// Re-open without closing the first.
|
|
3941
|
+
await actor.receiveTextFrame('BATCH +m2 draft/multiline :#foo\r\n');
|
|
3942
|
+
|
|
3943
|
+
const aliceLines = sentTo(runtime, 'c1');
|
|
3944
|
+
expect(aliceLines.some((l) => l.text.includes(' 421 '))).toBe(true);
|
|
3945
|
+
});
|
|
3946
|
+
|
|
3947
|
+
it('falls back to 421 for a BATCH frame with no params', async () => {
|
|
3948
|
+
const { actor, runtime, conn } = makeActor({});
|
|
3949
|
+
conn.caps = new Set<string>(['batch', 'draft/multiline']);
|
|
3950
|
+
runtime.calls.length = 0;
|
|
3951
|
+
|
|
3952
|
+
await actor.receiveTextFrame('BATCH\r\n');
|
|
3953
|
+
|
|
3954
|
+
const aliceLines = sentTo(runtime, 'c1');
|
|
3955
|
+
expect(aliceLines.some((l) => l.text.includes(' 421 '))).toBe(true);
|
|
3956
|
+
});
|
|
3957
|
+
|
|
3958
|
+
it('accumulates an inner PRIVMSG with no trailing text as empty', async () => {
|
|
3959
|
+
const { actor, runtime, conn } = makeActor({});
|
|
3960
|
+
conn.caps = new Set<string>(['batch', 'draft/multiline']);
|
|
3961
|
+
await setupMultilineChannel(runtime, [
|
|
3962
|
+
{ conn: 'c1', nick: 'alice', caps: ['batch', 'draft/multiline'] },
|
|
3963
|
+
{ conn: 'c2', nick: 'bob' },
|
|
3964
|
+
]);
|
|
3965
|
+
runtime.calls.length = 0;
|
|
3966
|
+
|
|
3967
|
+
await actor.receiveTextFrame('BATCH +m1 draft/multiline :#foo\r\n');
|
|
3968
|
+
// Inner PRIVMSG with no trailing parameter — exercises the `?? ''` fallback.
|
|
3969
|
+
await actor.receiveTextFrame('PRIVMSG #foo\r\n');
|
|
3970
|
+
await actor.receiveTextFrame('PRIVMSG #foo :real text\r\n');
|
|
3971
|
+
await actor.receiveTextFrame('BATCH -m1\r\n');
|
|
3972
|
+
|
|
3973
|
+
// The batch still finalizes; bob receives the joined content (empty + real).
|
|
3974
|
+
const bobLines = sentTo(runtime, 'c2');
|
|
3975
|
+
expect(bobLines.some((l) => l.text.includes('PRIVMSG #foo :'))).toBe(true);
|
|
3976
|
+
});
|
|
3977
|
+
|
|
3978
|
+
it('treats accumulateMultilineLine as a no-op when no batch is open (defensive guard)', () => {
|
|
3979
|
+
const { actor } = makeActor({});
|
|
3980
|
+
// route() only dispatches PRIVMSG/NOTICE here while a batch is open; the
|
|
3981
|
+
// method still guards defensively so a future caller cannot crash. Pin
|
|
3982
|
+
// that no-op contract by invoking the private method with no batch open.
|
|
3983
|
+
const result = (
|
|
3984
|
+
actor as unknown as {
|
|
3985
|
+
accumulateMultilineLine: (msg: { command: string; params: string[] }) => unknown[];
|
|
3986
|
+
}
|
|
3987
|
+
).accumulateMultilineLine({ command: 'PRIVMSG', params: ['#foo', 'hi'] });
|
|
3988
|
+
expect(result).toEqual([]);
|
|
3989
|
+
});
|
|
3990
|
+
});
|
|
3991
|
+
|
|
3992
|
+
// ---------------------------------------------------------------------------
|
|
3993
|
+
// draft/multiline BATCH — nick-target routing
|
|
3994
|
+
//
|
|
3995
|
+
// A batch whose target is a nick (not a channel) must be delivered to that
|
|
3996
|
+
// nick only. The pure reducer is channel-focused (Broadcast); nick-target
|
|
3997
|
+
// delivery requires actor-side per-recipient cap resolution.
|
|
3998
|
+
// ---------------------------------------------------------------------------
|
|
3999
|
+
|
|
4000
|
+
describe('ConnectionActor — ctx store plumbing (draft/pre-away, draft/read-marker)', () => {
|
|
4001
|
+
it('persists an AWAY reason to the bound AwayStore for an identified connection', async () => {
|
|
4002
|
+
const away = new InMemoryAwayStore();
|
|
4003
|
+
const { actor, conn } = makeActor({ away, conn: { account: 'alice' } });
|
|
4004
|
+
|
|
4005
|
+
await actor.receiveTextFrame('AWAY :gone fishing\r\n');
|
|
4006
|
+
|
|
4007
|
+
expect(conn.away).toBe('gone fishing');
|
|
4008
|
+
expect(away.get('alice')).toBe('gone fishing');
|
|
4009
|
+
});
|
|
4010
|
+
|
|
4011
|
+
it('does not persist AWAY to the store when the connection is unidentified', async () => {
|
|
4012
|
+
const away = new InMemoryAwayStore();
|
|
4013
|
+
const { actor, conn } = makeActor({ away });
|
|
4014
|
+
|
|
4015
|
+
await actor.receiveTextFrame('AWAY :gone fishing\r\n');
|
|
4016
|
+
|
|
4017
|
+
// In-memory session state still tracks the reason; the persisted store
|
|
4018
|
+
// stays empty because the connection has no account.
|
|
4019
|
+
expect(conn.away).toBe('gone fishing');
|
|
4020
|
+
expect(away.get('alice')).toBeUndefined();
|
|
4021
|
+
});
|
|
4022
|
+
|
|
4023
|
+
it('persists a +draft/read-marker TAGMSG to the bound ReadMarkerStore for an identified member', async () => {
|
|
4024
|
+
const readMarkers = new InMemoryReadMarkerStore();
|
|
4025
|
+
const { actor, runtime, conn } = makeActor({ readMarkers });
|
|
4026
|
+
conn.account = 'alice';
|
|
4027
|
+
conn.caps = new Set<string>(['message-tags', 'draft/read-marker']);
|
|
4028
|
+
await setupMultilineChannel(runtime, [{ conn: 'c1', nick: 'alice' }]);
|
|
4029
|
+
runtime.calls.length = 0;
|
|
4030
|
+
|
|
4031
|
+
await actor.receiveTextFrame('@+draft/read-marker=msg-42 TAGMSG #foo\r\n');
|
|
4032
|
+
|
|
4033
|
+
expect(readMarkers.get('alice', '#foo')).toBe('msg-42');
|
|
4034
|
+
});
|
|
4035
|
+
});
|
|
4036
|
+
|
|
4037
|
+
describe('ConnectionActor — draft/multiline BATCH to a nick target', () => {
|
|
4038
|
+
function addRecipient(
|
|
4039
|
+
runtime: FakeRuntime,
|
|
4040
|
+
conn: ConnId,
|
|
4041
|
+
nick: string,
|
|
4042
|
+
caps?: ReadonlyArray<string>,
|
|
4043
|
+
): ConnectionState {
|
|
4044
|
+
const state = createConnection({ id: conn, connectedSince: 0 });
|
|
4045
|
+
state.nick = nick;
|
|
4046
|
+
state.user = nick;
|
|
4047
|
+
state.host = `${nick}.example`;
|
|
4048
|
+
state.registration = 'registered';
|
|
4049
|
+
if (caps !== undefined) state.caps = new Set<string>(caps);
|
|
4050
|
+
runtime.addConnection(state);
|
|
4051
|
+
return state;
|
|
4052
|
+
}
|
|
4053
|
+
|
|
4054
|
+
it('delivers a nick-target batch to a multiline-capable recipient as a BATCH frame', async () => {
|
|
4055
|
+
const { actor, runtime, conn } = makeActor({});
|
|
4056
|
+
conn.caps = new Set<string>(['batch', 'draft/multiline']);
|
|
4057
|
+
addRecipient(runtime, 'c2', 'bob', ['batch', 'draft/multiline']);
|
|
4058
|
+
runtime.calls.length = 0;
|
|
4059
|
+
|
|
4060
|
+
await actor.receiveTextFrame('BATCH +m1 draft/multiline :bob\r\n');
|
|
4061
|
+
await actor.receiveTextFrame('PRIVMSG bob :line one\r\n');
|
|
4062
|
+
await actor.receiveTextFrame('PRIVMSG bob :line two\r\n');
|
|
4063
|
+
await actor.receiveTextFrame('BATCH -m1\r\n');
|
|
4064
|
+
|
|
4065
|
+
const bobLines = sentTo(runtime, 'c2');
|
|
4066
|
+
const joined = bobLines.map((l) => l.text).join('|');
|
|
4067
|
+
expect(joined).toMatch(/BATCH \+batch-\d+ draft\/multiline bob/);
|
|
4068
|
+
expect(joined).toContain(':alice!alice@example.com PRIVMSG bob :line one');
|
|
4069
|
+
expect(joined).toContain(':alice!alice@example.com PRIVMSG bob :line two');
|
|
4070
|
+
expect(joined).toMatch(/BATCH -batch-\d+/);
|
|
4071
|
+
});
|
|
4072
|
+
|
|
4073
|
+
it('delivers a nick-target batch to a legacy recipient as a single joined PRIVMSG', async () => {
|
|
4074
|
+
const { actor, runtime, conn } = makeActor({});
|
|
4075
|
+
conn.caps = new Set<string>(['batch', 'draft/multiline']);
|
|
4076
|
+
addRecipient(runtime, 'c2', 'bob');
|
|
4077
|
+
runtime.calls.length = 0;
|
|
4078
|
+
|
|
4079
|
+
await actor.receiveTextFrame('BATCH +m1 draft/multiline :bob\r\n');
|
|
4080
|
+
await actor.receiveTextFrame('PRIVMSG bob :line one\r\n');
|
|
4081
|
+
await actor.receiveTextFrame('PRIVMSG bob :line two\r\n');
|
|
4082
|
+
await actor.receiveTextFrame('BATCH -m1\r\n');
|
|
4083
|
+
|
|
4084
|
+
const bobLines = sentTo(runtime, 'c2');
|
|
4085
|
+
expect(
|
|
4086
|
+
bobLines.some((l) => l.text === ':alice!alice@example.com PRIVMSG bob :line one\nline two'),
|
|
4087
|
+
).toBe(true);
|
|
4088
|
+
});
|
|
4089
|
+
|
|
4090
|
+
it('delivers a nick-target batch to a message-tags recipient as a tagged joined PRIVMSG', async () => {
|
|
4091
|
+
const { actor, runtime, conn } = makeActor({});
|
|
4092
|
+
conn.caps = new Set<string>(['batch', 'draft/multiline']);
|
|
4093
|
+
addRecipient(runtime, 'c2', 'bob', ['message-tags']);
|
|
4094
|
+
runtime.calls.length = 0;
|
|
4095
|
+
|
|
4096
|
+
await actor.receiveTextFrame('BATCH +m1 draft/multiline :bob\r\n');
|
|
4097
|
+
await actor.receiveTextFrame('PRIVMSG bob :line one\r\n');
|
|
4098
|
+
await actor.receiveTextFrame('PRIVMSG bob :line two\r\n');
|
|
4099
|
+
await actor.receiveTextFrame('BATCH -m1\r\n');
|
|
4100
|
+
|
|
4101
|
+
const bobLines = sentTo(runtime, 'c2');
|
|
4102
|
+
expect(
|
|
4103
|
+
bobLines.some(
|
|
4104
|
+
(l) =>
|
|
4105
|
+
l.text === '@+draft/multiline :alice!alice@example.com PRIVMSG bob :line one\nline two',
|
|
4106
|
+
),
|
|
4107
|
+
).toBe(true);
|
|
4108
|
+
});
|
|
4109
|
+
|
|
4110
|
+
it('does not broadcast a nick-target batch to other connections', async () => {
|
|
4111
|
+
const { actor, runtime, conn } = makeActor({});
|
|
4112
|
+
conn.caps = new Set<string>(['batch', 'draft/multiline']);
|
|
4113
|
+
addRecipient(runtime, 'c2', 'bob', ['batch', 'draft/multiline']);
|
|
4114
|
+
addRecipient(runtime, 'c3', 'carol', ['batch', 'draft/multiline']);
|
|
4115
|
+
runtime.calls.length = 0;
|
|
4116
|
+
|
|
4117
|
+
await actor.receiveTextFrame('BATCH +m1 draft/multiline :bob\r\n');
|
|
4118
|
+
await actor.receiveTextFrame('PRIVMSG bob :line one\r\n');
|
|
4119
|
+
await actor.receiveTextFrame('PRIVMSG bob :line two\r\n');
|
|
4120
|
+
await actor.receiveTextFrame('BATCH -m1\r\n');
|
|
4121
|
+
|
|
4122
|
+
// Carol is NOT the nick target and must not receive the batch.
|
|
4123
|
+
const carolLines = sentTo(runtime, 'c3');
|
|
4124
|
+
expect(carolLines).toHaveLength(0);
|
|
4125
|
+
});
|
|
4126
|
+
|
|
4127
|
+
it('surfaces 401 ERR_NOSUCHNICK for a nick-target PRIVMSG when the recipient is offline', async () => {
|
|
4128
|
+
const { actor, runtime, conn } = makeActor({});
|
|
4129
|
+
conn.caps = new Set<string>(['batch', 'draft/multiline']);
|
|
4130
|
+
// bob is NOT registered → lookupNick returns null.
|
|
4131
|
+
runtime.calls.length = 0;
|
|
4132
|
+
|
|
4133
|
+
await actor.receiveTextFrame('BATCH +m1 draft/multiline :bob\r\n');
|
|
4134
|
+
await actor.receiveTextFrame('PRIVMSG bob :line one\r\n');
|
|
4135
|
+
await actor.receiveTextFrame('BATCH -m1\r\n');
|
|
4136
|
+
|
|
4137
|
+
const aliceLines = sentTo(runtime, 'c1');
|
|
4138
|
+
expect(aliceLines.some((l) => l.text.includes(' 401 '))).toBe(true);
|
|
4139
|
+
expect(aliceLines.some((l) => l.text.includes('bob'))).toBe(true);
|
|
4140
|
+
});
|
|
4141
|
+
|
|
4142
|
+
it('omits 401 for a nick-target NOTICE when the recipient is offline', async () => {
|
|
4143
|
+
const { actor, runtime, conn } = makeActor({});
|
|
4144
|
+
conn.caps = new Set<string>(['batch', 'draft/multiline']);
|
|
4145
|
+
runtime.calls.length = 0;
|
|
4146
|
+
|
|
4147
|
+
await actor.receiveTextFrame('BATCH +m1 draft/multiline :bob\r\n');
|
|
4148
|
+
await actor.receiveTextFrame('NOTICE bob :quiet one\r\n');
|
|
4149
|
+
await actor.receiveTextFrame('BATCH -m1\r\n');
|
|
4150
|
+
|
|
4151
|
+
const aliceLines = sentTo(runtime, 'c1');
|
|
4152
|
+
expect(aliceLines.some((l) => l.text.includes(' 401 '))).toBe(false);
|
|
4153
|
+
});
|
|
4154
|
+
|
|
4155
|
+
it('delivers nothing for an empty nick-target batch', async () => {
|
|
4156
|
+
const { actor, runtime, conn } = makeActor({});
|
|
4157
|
+
conn.caps = new Set<string>(['batch', 'draft/multiline']);
|
|
4158
|
+
const bobConn = createConnection({ id: 'c2', connectedSince: 0 });
|
|
4159
|
+
bobConn.nick = 'bob';
|
|
4160
|
+
bobConn.user = 'bob';
|
|
4161
|
+
bobConn.host = 'bob.example';
|
|
4162
|
+
bobConn.registration = 'registered';
|
|
4163
|
+
bobConn.caps = new Set<string>(['batch', 'draft/multiline']);
|
|
4164
|
+
runtime.addConnection(bobConn);
|
|
4165
|
+
runtime.calls.length = 0;
|
|
4166
|
+
|
|
4167
|
+
await actor.receiveTextFrame('BATCH +m1 draft/multiline :bob\r\n');
|
|
4168
|
+
await actor.receiveTextFrame('BATCH -m1\r\n');
|
|
4169
|
+
|
|
4170
|
+
expect(sentTo(runtime, 'c2')).toHaveLength(0);
|
|
4171
|
+
});
|
|
4172
|
+
|
|
4173
|
+
it('rejects an over-budget nick-target batch with FAIL', async () => {
|
|
4174
|
+
const { actor, runtime, conn } = makeActor({});
|
|
4175
|
+
conn.caps = new Set<string>(['batch', 'draft/multiline', 'standard-replies']);
|
|
4176
|
+
(actor as unknown as { serverConfig: ServerConfig }).serverConfig = {
|
|
4177
|
+
...(actor as unknown as { serverConfig: ServerConfig }).serverConfig,
|
|
4178
|
+
multilineMaxBytes: 5,
|
|
4179
|
+
};
|
|
4180
|
+
const bobConn = createConnection({ id: 'c2', connectedSince: 0 });
|
|
4181
|
+
bobConn.nick = 'bob';
|
|
4182
|
+
bobConn.user = 'bob';
|
|
4183
|
+
bobConn.host = 'bob.example';
|
|
4184
|
+
bobConn.registration = 'registered';
|
|
4185
|
+
runtime.addConnection(bobConn);
|
|
4186
|
+
runtime.calls.length = 0;
|
|
4187
|
+
|
|
4188
|
+
await actor.receiveTextFrame('BATCH +m1 draft/multiline :bob\r\n');
|
|
4189
|
+
await actor.receiveTextFrame('PRIVMSG bob :hello world\r\n');
|
|
4190
|
+
await actor.receiveTextFrame('BATCH -m1\r\n');
|
|
4191
|
+
|
|
4192
|
+
const aliceLines = sentTo(runtime, 'c1');
|
|
4193
|
+
expect(
|
|
4194
|
+
aliceLines.some(
|
|
4195
|
+
(l) =>
|
|
4196
|
+
l.text ===
|
|
4197
|
+
':irc.example.com FAIL BATCH MULTILINE_MAX_BYTES bob :Multi-line batch exceeds the 5 byte limit',
|
|
4198
|
+
),
|
|
4199
|
+
).toBe(true);
|
|
4200
|
+
expect(sentTo(runtime, 'c2')).toHaveLength(0);
|
|
4201
|
+
});
|
|
4202
|
+
|
|
4203
|
+
it('surfaces 401 when the recipient connection is gone mid-flight', async () => {
|
|
4204
|
+
const { actor, runtime, conn } = makeActor({});
|
|
4205
|
+
conn.caps = new Set<string>(['batch', 'draft/multiline']);
|
|
4206
|
+
const bobConn = createConnection({ id: 'c2', connectedSince: 0 });
|
|
4207
|
+
bobConn.nick = 'bob';
|
|
4208
|
+
bobConn.user = 'bob';
|
|
4209
|
+
bobConn.host = 'bob.example';
|
|
4210
|
+
bobConn.registration = 'registered';
|
|
4211
|
+
runtime.addConnection(bobConn);
|
|
4212
|
+
// Drop the connection AFTER nick registration so lookupNick resolves but
|
|
4213
|
+
// getConnection returns null (the "gone between the two awaits" race).
|
|
4214
|
+
runtime.dropConnection('c2');
|
|
4215
|
+
runtime.calls.length = 0;
|
|
4216
|
+
|
|
4217
|
+
await actor.receiveTextFrame('BATCH +m1 draft/multiline :bob\r\n');
|
|
4218
|
+
await actor.receiveTextFrame('PRIVMSG bob :line one\r\n');
|
|
4219
|
+
await actor.receiveTextFrame('BATCH -m1\r\n');
|
|
4220
|
+
|
|
4221
|
+
const aliceLines = sentTo(runtime, 'c1');
|
|
4222
|
+
expect(aliceLines.some((l) => l.text.includes(' 401 '))).toBe(true);
|
|
4223
|
+
});
|
|
4224
|
+
|
|
4225
|
+
it('uses "?" and "*" fallbacks when the sender has no nick', async () => {
|
|
4226
|
+
const { actor, runtime, conn } = makeActor({});
|
|
4227
|
+
// A connection that negotiated caps but has no NICK set — exercises the
|
|
4228
|
+
// hostmask `?? '?'` and numeric `?? '*'` fallbacks. `delete` is required
|
|
4229
|
+
// because `exactOptionalPropertyTypes` forbids `= undefined`.
|
|
4230
|
+
// biome-ignore lint/performance/noDelete: exactOptionalPropertyTypes requires delete
|
|
4231
|
+
delete conn.nick;
|
|
4232
|
+
// biome-ignore lint/performance/noDelete: exactOptionalPropertyTypes requires delete
|
|
4233
|
+
delete conn.user;
|
|
4234
|
+
// biome-ignore lint/performance/noDelete: exactOptionalPropertyTypes requires delete
|
|
4235
|
+
delete conn.host;
|
|
4236
|
+
conn.caps = new Set<string>(['batch', 'draft/multiline']);
|
|
4237
|
+
const bobConn = createConnection({ id: 'c2', connectedSince: 0 });
|
|
4238
|
+
bobConn.nick = 'bob';
|
|
4239
|
+
bobConn.user = 'bob';
|
|
4240
|
+
bobConn.host = 'bob.example';
|
|
4241
|
+
bobConn.registration = 'registered';
|
|
4242
|
+
bobConn.caps = new Set<string>(['batch', 'draft/multiline']);
|
|
4243
|
+
runtime.addConnection(bobConn);
|
|
4244
|
+
runtime.calls.length = 0;
|
|
4245
|
+
|
|
4246
|
+
// Online recipient → hostmask falls back to "?".
|
|
4247
|
+
await actor.receiveTextFrame('BATCH +m1 draft/multiline :bob\r\n');
|
|
4248
|
+
await actor.receiveTextFrame('PRIVMSG bob :line one\r\n');
|
|
4249
|
+
await actor.receiveTextFrame('BATCH -m1\r\n');
|
|
4250
|
+
|
|
4251
|
+
const bobLines = sentTo(runtime, 'c2');
|
|
4252
|
+
expect(bobLines.some((l) => l.text.startsWith(':? PRIVMSG bob :line one'))).toBe(true);
|
|
4253
|
+
|
|
4254
|
+
// Offline recipient → numeric falls back to "*".
|
|
4255
|
+
runtime.calls.length = 0;
|
|
4256
|
+
await actor.receiveTextFrame('BATCH +m2 draft/multiline :carol\r\n');
|
|
4257
|
+
await actor.receiveTextFrame('PRIVMSG carol :no recipient\r\n');
|
|
4258
|
+
await actor.receiveTextFrame('BATCH -m2\r\n');
|
|
4259
|
+
|
|
4260
|
+
const aliceLines = sentTo(runtime, 'c1');
|
|
4261
|
+
expect(aliceLines.some((l) => l.text.includes(' 401 * carol'))).toBe(true);
|
|
4262
|
+
});
|
|
4263
|
+
});
|
|
4264
|
+
|
|
4265
|
+
// ---------------------------------------------------------------------------
|
|
4266
|
+
// draft/multiline BATCH — chathistory recording
|
|
4267
|
+
//
|
|
4268
|
+
// A finalized multi-line batch must be recorded once into the bound
|
|
4269
|
+
// MessageStore so CHATHISTORY AFTER/BEFORE/LATEST return exactly one entry
|
|
4270
|
+
// per batch, carrying the joined body, the assigned msgid, and the
|
|
4271
|
+
// +draft/multiline client tag.
|
|
4272
|
+
// ---------------------------------------------------------------------------
|
|
4273
|
+
|
|
4274
|
+
describe('ConnectionActor — draft/multiline chathistory recording', () => {
|
|
4275
|
+
it('records the joined channel batch into the MessageStore', async () => {
|
|
4276
|
+
const store = new InMemoryMessageStore();
|
|
4277
|
+
const { actor, runtime, conn } = makeActor({ messages: store });
|
|
4278
|
+
conn.caps = new Set<string>(['batch', 'draft/multiline']);
|
|
4279
|
+
await setupMultilineChannel(runtime, [
|
|
4280
|
+
{ conn: 'c1', nick: 'alice', caps: ['batch', 'draft/multiline'] },
|
|
4281
|
+
]);
|
|
4282
|
+
runtime.calls.length = 0;
|
|
4283
|
+
|
|
4284
|
+
await actor.receiveTextFrame('BATCH +m1 draft/multiline :#foo\r\n');
|
|
4285
|
+
await actor.receiveTextFrame('PRIVMSG #foo :line one\r\n');
|
|
4286
|
+
await actor.receiveTextFrame('PRIVMSG #foo :line two\r\n');
|
|
4287
|
+
await actor.receiveTextFrame('BATCH -m1\r\n');
|
|
4288
|
+
|
|
4289
|
+
const recorded = store.query({ chan: '#foo', direction: 'latest', limit: 10 });
|
|
4290
|
+
expect(recorded).toHaveLength(1);
|
|
4291
|
+
expect(recorded[0]?.text).toBe('line one\nline two');
|
|
4292
|
+
expect(recorded[0]?.command).toBe('PRIVMSG');
|
|
4293
|
+
expect(recorded[0]?.nick).toBe('alice');
|
|
4294
|
+
expect(recorded[0]?.msgid).toMatch(/^[A-Za-z0-9_-]+$/);
|
|
4295
|
+
});
|
|
4296
|
+
|
|
4297
|
+
it('CHATHISTORY replay carries the +draft/multiline tag on the joined body', async () => {
|
|
4298
|
+
const store = new InMemoryMessageStore();
|
|
4299
|
+
const chathistoryCaps = new Set<string>([
|
|
4300
|
+
'batch',
|
|
4301
|
+
'draft/multiline',
|
|
4302
|
+
'draft/chathistory',
|
|
4303
|
+
'server-time',
|
|
4304
|
+
'message-tags',
|
|
4305
|
+
]);
|
|
4306
|
+
const { actor, runtime, conn } = makeActor({ messages: store });
|
|
4307
|
+
conn.caps = chathistoryCaps;
|
|
4308
|
+
await setupMultilineChannel(runtime, [
|
|
4309
|
+
{ conn: 'c1', nick: 'alice', caps: ['batch', 'draft/multiline'] },
|
|
4310
|
+
]);
|
|
4311
|
+
runtime.calls.length = 0;
|
|
4312
|
+
|
|
4313
|
+
await actor.receiveTextFrame('BATCH +m1 draft/multiline :#foo\r\n');
|
|
4314
|
+
await actor.receiveTextFrame('PRIVMSG #foo :line one\r\n');
|
|
4315
|
+
await actor.receiveTextFrame('PRIVMSG #foo :line two\r\n');
|
|
4316
|
+
await actor.receiveTextFrame('BATCH -m1\r\n');
|
|
4317
|
+
runtime.calls.length = 0;
|
|
4318
|
+
|
|
4319
|
+
await actor.receiveTextFrame('CHATHISTORY LATEST #foo * 1\r\n');
|
|
4320
|
+
|
|
4321
|
+
const sends = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
|
|
4322
|
+
const lines = sends[0]?.args[1] as RawLine[];
|
|
4323
|
+
const replayLine = lines.find((l) => l.text.includes('PRIVMSG #foo'));
|
|
4324
|
+
expect(replayLine?.text).toContain('+draft/multiline');
|
|
4325
|
+
expect(replayLine?.text).toContain('line one\nline two');
|
|
4326
|
+
});
|
|
4327
|
+
|
|
4328
|
+
it('records the joined nick-target batch into the MessageStore', async () => {
|
|
4329
|
+
const store = new InMemoryMessageStore();
|
|
4330
|
+
const { actor, runtime, conn } = makeActor({ messages: store });
|
|
4331
|
+
conn.caps = new Set<string>(['batch', 'draft/multiline']);
|
|
4332
|
+
const bobConn = createConnection({ id: 'c2', connectedSince: 0 });
|
|
4333
|
+
bobConn.nick = 'bob';
|
|
4334
|
+
bobConn.user = 'bob';
|
|
4335
|
+
bobConn.host = 'bob.example';
|
|
4336
|
+
bobConn.registration = 'registered';
|
|
4337
|
+
bobConn.caps = new Set<string>(['batch', 'draft/multiline']);
|
|
4338
|
+
runtime.addConnection(bobConn);
|
|
4339
|
+
runtime.calls.length = 0;
|
|
4340
|
+
|
|
4341
|
+
await actor.receiveTextFrame('BATCH +m1 draft/multiline :bob\r\n');
|
|
4342
|
+
await actor.receiveTextFrame('PRIVMSG bob :pm one\r\n');
|
|
4343
|
+
await actor.receiveTextFrame('PRIVMSG bob :pm two\r\n');
|
|
4344
|
+
await actor.receiveTextFrame('BATCH -m1\r\n');
|
|
4345
|
+
|
|
4346
|
+
const recorded = store.query({ chan: 'bob', direction: 'latest', limit: 10 });
|
|
4347
|
+
expect(recorded).toHaveLength(1);
|
|
4348
|
+
expect(recorded[0]?.text).toBe('pm one\npm two');
|
|
4349
|
+
});
|
|
4350
|
+
|
|
4351
|
+
it('does not record when no MessageStore is bound', async () => {
|
|
4352
|
+
const { actor, runtime, conn } = makeActor({});
|
|
4353
|
+
conn.caps = new Set<string>(['batch', 'draft/multiline']);
|
|
4354
|
+
await setupMultilineChannel(runtime, [
|
|
4355
|
+
{ conn: 'c1', nick: 'alice', caps: ['batch', 'draft/multiline'] },
|
|
4356
|
+
]);
|
|
4357
|
+
runtime.calls.length = 0;
|
|
4358
|
+
|
|
4359
|
+
await actor.receiveTextFrame('BATCH +m1 draft/multiline :#foo\r\n');
|
|
4360
|
+
await actor.receiveTextFrame('PRIVMSG #foo :line one\r\n');
|
|
4361
|
+
await actor.receiveTextFrame('PRIVMSG #foo :line two\r\n');
|
|
4362
|
+
await actor.receiveTextFrame('BATCH -m1\r\n');
|
|
4363
|
+
|
|
4364
|
+
// No crash, no store — the delivery still works (broadcast happened).
|
|
4365
|
+
expect(runtime.calls.some((c) => c.method === 'broadcast')).toBe(false);
|
|
4366
|
+
// Cap-split path emits a send to bob via getChannelSnapshot…
|
|
4367
|
+
expect(runtime.calls.some((c) => c.method === 'getChannelSnapshot')).toBe(true);
|
|
4368
|
+
});
|
|
1888
4369
|
});
|