serverless-ircd 0.1.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 +47 -0
- package/.github/workflows/deploy-cf.yml +97 -0
- package/LICENSE +29 -0
- package/README.md +323 -0
- package/apps/cf-worker/package.json +37 -0
- package/apps/cf-worker/scripts/smoke.mjs +175 -0
- package/apps/cf-worker/src/worker.ts +59 -0
- package/apps/cf-worker/tests/smoke.test.ts +150 -0
- package/apps/cf-worker/tsconfig.build.json +17 -0
- package/apps/cf-worker/tsconfig.test.json +18 -0
- package/apps/cf-worker/vitest.config.ts +31 -0
- package/apps/cf-worker/wrangler.test.toml +33 -0
- package/apps/cf-worker/wrangler.toml +98 -0
- package/apps/local-cli/package.json +35 -0
- package/apps/local-cli/src/line-scanner.ts +60 -0
- package/apps/local-cli/src/main.ts +145 -0
- package/apps/local-cli/src/motd-file.ts +45 -0
- package/apps/local-cli/src/server.ts +409 -0
- package/apps/local-cli/tests/e2e.test.ts +346 -0
- package/apps/local-cli/tests/id-factory.test.ts +25 -0
- package/apps/local-cli/tests/line-scanner.test.ts +81 -0
- package/apps/local-cli/tests/motd-file.test.ts +71 -0
- package/apps/local-cli/tests/tcp.test.ts +358 -0
- package/apps/local-cli/tsconfig.build.json +17 -0
- package/apps/local-cli/tsconfig.test.json +15 -0
- package/apps/local-cli/vitest.config.ts +24 -0
- package/biome.json +52 -0
- package/package.json +35 -0
- package/packages/cf-adapter/package.json +38 -0
- package/packages/cf-adapter/src/cf-runtime.ts +308 -0
- package/packages/cf-adapter/src/channel-do.ts +374 -0
- package/packages/cf-adapter/src/connection-do.ts +542 -0
- package/packages/cf-adapter/src/env.ts +67 -0
- package/packages/cf-adapter/src/index.ts +38 -0
- package/packages/cf-adapter/src/registry-do.ts +130 -0
- package/packages/cf-adapter/src/serialize.ts +142 -0
- package/packages/cf-adapter/src/sharding.ts +101 -0
- package/packages/cf-adapter/tests/cf-harness.ts +264 -0
- package/packages/cf-adapter/tests/cf-integration.test.ts +73 -0
- package/packages/cf-adapter/tests/cf-runtime.test.ts +527 -0
- package/packages/cf-adapter/tests/channel-do.test.ts +480 -0
- package/packages/cf-adapter/tests/connection-do.test.ts +329 -0
- package/packages/cf-adapter/tests/integration/harness.test.ts +229 -0
- package/packages/cf-adapter/tests/integration/harness.ts +102 -0
- package/packages/cf-adapter/tests/integration/in-memory-harness.ts +242 -0
- package/packages/cf-adapter/tests/integration/index.ts +17 -0
- package/packages/cf-adapter/tests/integration/scenarios.test.ts +20 -0
- package/packages/cf-adapter/tests/integration/scenarios.ts +495 -0
- package/packages/cf-adapter/tests/registry-do.test.ts +335 -0
- package/packages/cf-adapter/tests/sharding.test.ts +100 -0
- package/packages/cf-adapter/tests/worker/main.ts +33 -0
- package/packages/cf-adapter/tests/worker/stubs/channel-stub.ts +50 -0
- package/packages/cf-adapter/tests/worker/stubs/registry-stub.ts +57 -0
- package/packages/cf-adapter/tsconfig.build.json +16 -0
- package/packages/cf-adapter/tsconfig.test.json +18 -0
- package/packages/cf-adapter/vitest.config.ts +32 -0
- package/packages/cf-adapter/wrangler.test.toml +50 -0
- package/packages/in-memory-runtime/package.json +29 -0
- package/packages/in-memory-runtime/src/in-memory-runtime.ts +245 -0
- package/packages/in-memory-runtime/src/index.ts +9 -0
- package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +502 -0
- package/packages/in-memory-runtime/tsconfig.build.json +16 -0
- package/packages/in-memory-runtime/tsconfig.test.json +14 -0
- package/packages/in-memory-runtime/vitest.config.ts +21 -0
- package/packages/irc-core/package.json +29 -0
- package/packages/irc-core/src/caps/capabilities.ts +96 -0
- package/packages/irc-core/src/caps/index.ts +1 -0
- package/packages/irc-core/src/commands/away.ts +82 -0
- package/packages/irc-core/src/commands/cap.ts +257 -0
- package/packages/irc-core/src/commands/chghost.ts +30 -0
- package/packages/irc-core/src/commands/index.ts +40 -0
- package/packages/irc-core/src/commands/invite.ts +156 -0
- package/packages/irc-core/src/commands/isupport.ts +133 -0
- package/packages/irc-core/src/commands/join.ts +309 -0
- package/packages/irc-core/src/commands/kick.ts +162 -0
- package/packages/irc-core/src/commands/list.ts +126 -0
- package/packages/irc-core/src/commands/mode.ts +655 -0
- package/packages/irc-core/src/commands/motd.ts +146 -0
- package/packages/irc-core/src/commands/names.ts +164 -0
- package/packages/irc-core/src/commands/part.ts +118 -0
- package/packages/irc-core/src/commands/ping.ts +47 -0
- package/packages/irc-core/src/commands/privmsg.ts +256 -0
- package/packages/irc-core/src/commands/quit.ts +70 -0
- package/packages/irc-core/src/commands/registration.ts +251 -0
- package/packages/irc-core/src/commands/topic.ts +171 -0
- package/packages/irc-core/src/commands/who.ts +169 -0
- package/packages/irc-core/src/commands/whois.ts +165 -0
- package/packages/irc-core/src/effects.ts +184 -0
- package/packages/irc-core/src/index.ts +20 -0
- package/packages/irc-core/src/ports.ts +153 -0
- package/packages/irc-core/src/protocol/batch.ts +85 -0
- package/packages/irc-core/src/protocol/index.ts +18 -0
- package/packages/irc-core/src/protocol/messages.ts +36 -0
- package/packages/irc-core/src/protocol/numerics.ts +155 -0
- package/packages/irc-core/src/protocol/outbound.ts +145 -0
- package/packages/irc-core/src/protocol/parser.ts +175 -0
- package/packages/irc-core/src/protocol/serializer.ts +71 -0
- package/packages/irc-core/src/state/channel.ts +293 -0
- package/packages/irc-core/src/state/connection.ts +153 -0
- package/packages/irc-core/src/state/index.ts +25 -0
- package/packages/irc-core/src/state/registry.ts +22 -0
- package/packages/irc-core/src/types.ts +83 -0
- package/packages/irc-core/tests/batch.test.ts +79 -0
- package/packages/irc-core/tests/caps/capabilities.test.ts +148 -0
- package/packages/irc-core/tests/commands/away.test.ts +233 -0
- package/packages/irc-core/tests/commands/batch.test.ts +178 -0
- package/packages/irc-core/tests/commands/cap.test.ts +499 -0
- package/packages/irc-core/tests/commands/chghost.test.ts +186 -0
- package/packages/irc-core/tests/commands/echo-message.test.ts +212 -0
- package/packages/irc-core/tests/commands/extended-join.test.ts +147 -0
- package/packages/irc-core/tests/commands/invite-notify.test.ts +160 -0
- package/packages/irc-core/tests/commands/invite.test.ts +321 -0
- package/packages/irc-core/tests/commands/isupport.test.ts +209 -0
- package/packages/irc-core/tests/commands/join.test.ts +687 -0
- package/packages/irc-core/tests/commands/kick.test.ts +316 -0
- package/packages/irc-core/tests/commands/list.test.ts +452 -0
- package/packages/irc-core/tests/commands/mode.test.ts +1048 -0
- package/packages/irc-core/tests/commands/motd.test.ts +322 -0
- package/packages/irc-core/tests/commands/names.test.ts +342 -0
- package/packages/irc-core/tests/commands/part.test.ts +265 -0
- package/packages/irc-core/tests/commands/ping.test.ts +144 -0
- package/packages/irc-core/tests/commands/privmsg.test.ts +665 -0
- package/packages/irc-core/tests/commands/quit.test.ts +220 -0
- package/packages/irc-core/tests/commands/registration.test.ts +599 -0
- package/packages/irc-core/tests/commands/topic.test.ts +337 -0
- package/packages/irc-core/tests/commands/who.test.ts +441 -0
- package/packages/irc-core/tests/commands/whois.test.ts +422 -0
- package/packages/irc-core/tests/effects.test.ts +147 -0
- package/packages/irc-core/tests/message-tags.test.ts +182 -0
- package/packages/irc-core/tests/numerics.test.ts +98 -0
- package/packages/irc-core/tests/outbound.test.ts +232 -0
- package/packages/irc-core/tests/parser.test.ts +162 -0
- package/packages/irc-core/tests/ports.test.ts +101 -0
- package/packages/irc-core/tests/serializer.test.ts +164 -0
- package/packages/irc-core/tests/state/channel.test.ts +351 -0
- package/packages/irc-core/tests/state/connection.test.ts +122 -0
- package/packages/irc-core/tests/state/registry.test.ts +20 -0
- package/packages/irc-core/tests/types.test.ts +127 -0
- package/packages/irc-core/tsconfig.build.json +12 -0
- package/packages/irc-core/tsconfig.test.json +10 -0
- package/packages/irc-core/vitest.config.ts +21 -0
- package/packages/irc-server/package.json +28 -0
- package/packages/irc-server/src/actor.ts +449 -0
- package/packages/irc-server/src/dispatch.ts +120 -0
- package/packages/irc-server/src/index.ts +11 -0
- package/packages/irc-server/src/runtime.ts +61 -0
- package/packages/irc-server/tests/actor.test.ts +816 -0
- package/packages/irc-server/tests/dispatch.test.ts +512 -0
- package/packages/irc-server/tests/runtime.test.ts +52 -0
- package/packages/irc-server/tsconfig.build.json +13 -0
- package/packages/irc-server/tsconfig.test.json +11 -0
- package/packages/irc-server/vitest.config.ts +21 -0
- package/pnpm-workspace.yaml +4 -0
- package/tools/tcp-ws-forwarder/package.json +32 -0
- package/tools/tcp-ws-forwarder/src/forwarder.ts +244 -0
- package/tools/tcp-ws-forwarder/src/line-scanner.ts +55 -0
- package/tools/tcp-ws-forwarder/src/main.ts +114 -0
- package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +618 -0
- package/tools/tcp-ws-forwarder/tests/framing.test.ts +51 -0
- package/tools/tcp-ws-forwarder/tests/line-scanner.test.ts +75 -0
- package/tools/tcp-ws-forwarder/tsconfig.build.json +12 -0
- package/tools/tcp-ws-forwarder/tsconfig.test.json +10 -0
- package/tools/tcp-ws-forwarder/vitest.config.ts +27 -0
- package/tsconfig.base.json +25 -0
- package/turbo.json +29 -0
|
@@ -0,0 +1,816 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type ChanName,
|
|
3
|
+
type ChanSnapshot,
|
|
4
|
+
type ChannelDelta,
|
|
5
|
+
type ChannelState,
|
|
6
|
+
type ConnId,
|
|
7
|
+
type ConnSnapshot,
|
|
8
|
+
type ConnectionState,
|
|
9
|
+
EmptyMotdProvider,
|
|
10
|
+
FakeClock,
|
|
11
|
+
type Nick,
|
|
12
|
+
Numerics,
|
|
13
|
+
type RawLine,
|
|
14
|
+
SequentialIdFactory,
|
|
15
|
+
applyChannelDelta as applyDelta,
|
|
16
|
+
createChannel,
|
|
17
|
+
createConnection,
|
|
18
|
+
isChannelTarget,
|
|
19
|
+
toChannelSnapshot,
|
|
20
|
+
} from '@serverless-ircd/irc-core';
|
|
21
|
+
import { describe, expect, it } from 'vitest';
|
|
22
|
+
import { ConnectionActor } from '../src/actor';
|
|
23
|
+
import type { IrcRuntime } from '../src/runtime';
|
|
24
|
+
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
// Fakes
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
interface RecordedCall {
|
|
30
|
+
method: string;
|
|
31
|
+
args: unknown[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
class FakeRuntime implements IrcRuntime {
|
|
35
|
+
readonly calls: RecordedCall[] = [];
|
|
36
|
+
private readonly channels = new Map<string, ChannelState>();
|
|
37
|
+
private readonly testConnections = new Map<ConnId, ConnectionState>();
|
|
38
|
+
private readonly nicks = new Map<string, ConnId>();
|
|
39
|
+
|
|
40
|
+
constructor(private readonly clock: { now(): number }) {}
|
|
41
|
+
|
|
42
|
+
addConnection(state: ConnectionState): void {
|
|
43
|
+
this.testConnections.set(state.id, state);
|
|
44
|
+
if (state.nick !== undefined) {
|
|
45
|
+
this.nicks.set(state.nick.toLowerCase(), state.id);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Channel-state access (used by the actor's `channels` dep).
|
|
50
|
+
getOrCreateChannel(name: ChanName): ChannelState {
|
|
51
|
+
const key = name.toLowerCase();
|
|
52
|
+
const existing = this.channels.get(key);
|
|
53
|
+
if (existing) return existing;
|
|
54
|
+
const chan = createChannel({ name, nameLower: key, createdAt: this.clock.now() });
|
|
55
|
+
this.channels.set(key, chan);
|
|
56
|
+
return chan;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async send(to: ConnId, lines: RawLine[]): Promise<void> {
|
|
60
|
+
this.calls.push({ method: 'send', args: [to, lines] });
|
|
61
|
+
}
|
|
62
|
+
async broadcast(chan: ChanName, lines: RawLine[], except?: ConnId): Promise<void> {
|
|
63
|
+
this.calls.push({ method: 'broadcast', args: [chan, lines, except] });
|
|
64
|
+
}
|
|
65
|
+
async disconnect(conn: ConnId, reason?: string): Promise<void> {
|
|
66
|
+
this.calls.push({ method: 'disconnect', args: [conn, reason] });
|
|
67
|
+
}
|
|
68
|
+
async sendToNick(
|
|
69
|
+
nick: Nick,
|
|
70
|
+
sender: ConnId,
|
|
71
|
+
lines: RawLine[],
|
|
72
|
+
notFoundLines?: RawLine[],
|
|
73
|
+
): Promise<void> {
|
|
74
|
+
this.calls.push({ method: 'sendToNick', args: [nick, sender, lines, notFoundLines] });
|
|
75
|
+
}
|
|
76
|
+
async reserveNick(nick: Nick, conn: ConnId): Promise<{ ok: true } | { ok: false }> {
|
|
77
|
+
this.calls.push({ method: 'reserveNick', args: [nick, conn] });
|
|
78
|
+
return { ok: true };
|
|
79
|
+
}
|
|
80
|
+
async changeNick(conn: ConnId, oldNick: Nick, newNick: Nick): Promise<boolean> {
|
|
81
|
+
this.calls.push({ method: 'changeNick', args: [conn, oldNick, newNick] });
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
async releaseNick(nick: Nick): Promise<void> {
|
|
85
|
+
this.calls.push({ method: 'releaseNick', args: [nick] });
|
|
86
|
+
}
|
|
87
|
+
async applyChannelDelta(chan: ChanName, delta: ChannelDelta): Promise<void> {
|
|
88
|
+
this.calls.push({ method: 'applyChannelDelta', args: [chan, delta] });
|
|
89
|
+
// Actually mutate the stored channel so subsequent getOrCreateChannel
|
|
90
|
+
// calls observe the new state. The actor's setup relies on this.
|
|
91
|
+
const key = chan.toLowerCase();
|
|
92
|
+
const current = this.channels.get(key);
|
|
93
|
+
const next = current
|
|
94
|
+
? applyDelta(current, delta)
|
|
95
|
+
: applyDelta(
|
|
96
|
+
createChannel({ name: chan, nameLower: key, createdAt: this.clock.now() }),
|
|
97
|
+
delta,
|
|
98
|
+
);
|
|
99
|
+
this.channels.set(key, next);
|
|
100
|
+
}
|
|
101
|
+
async lookupNick(nick: Nick): Promise<ConnId | null> {
|
|
102
|
+
this.calls.push({ method: 'lookupNick', args: [nick] });
|
|
103
|
+
return this.nicks.get(nick.toLowerCase()) ?? null;
|
|
104
|
+
}
|
|
105
|
+
async getConnectionInfo(conn: ConnId): Promise<ConnSnapshot | null> {
|
|
106
|
+
this.calls.push({ method: 'getConnectionInfo', args: [conn] });
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
async getConnection(conn: ConnId): Promise<ConnectionState | null> {
|
|
110
|
+
this.calls.push({ method: 'getConnection', args: [conn] });
|
|
111
|
+
return this.testConnections.get(conn) ?? null;
|
|
112
|
+
}
|
|
113
|
+
async getChannelSnapshot(chan: ChanName): Promise<ChanSnapshot | null> {
|
|
114
|
+
this.calls.push({ method: 'getChannelSnapshot', args: [chan] });
|
|
115
|
+
const key = chan.toLowerCase();
|
|
116
|
+
const channel = this.channels.get(key);
|
|
117
|
+
return channel === undefined ? null : toChannelSnapshot(channel);
|
|
118
|
+
}
|
|
119
|
+
async listChannels(): Promise<ChanSnapshot[]> {
|
|
120
|
+
this.calls.push({ method: 'listChannels', args: [] });
|
|
121
|
+
return Array.from(this.channels.values(), (c) => toChannelSnapshot(c));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async getChannelConnections(chan: ChanName): Promise<ReadonlyMap<ConnId, ConnectionState>> {
|
|
125
|
+
this.calls.push({ method: 'getChannelConnections', args: [chan] });
|
|
126
|
+
const key = chan.toLowerCase();
|
|
127
|
+
const channel = this.channels.get(key);
|
|
128
|
+
const out = new Map<ConnId, ConnectionState>();
|
|
129
|
+
if (channel === undefined) return out;
|
|
130
|
+
for (const connId of channel.members.keys()) {
|
|
131
|
+
const state = this.testConnections.get(connId);
|
|
132
|
+
if (state !== undefined) out.set(connId, state);
|
|
133
|
+
}
|
|
134
|
+
return out;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function makeActor(opts: {
|
|
139
|
+
conn?: Partial<ConnectionState>;
|
|
140
|
+
clock?: FakeClock;
|
|
141
|
+
}): { actor: ConnectionActor; runtime: FakeRuntime; conn: ConnectionState } {
|
|
142
|
+
const clock = opts.clock ?? new FakeClock(1_000);
|
|
143
|
+
const conn = createConnection({ id: 'c1', connectedSince: 0 });
|
|
144
|
+
conn.nick = 'alice';
|
|
145
|
+
conn.user = 'alice';
|
|
146
|
+
conn.host = 'example.com';
|
|
147
|
+
conn.realname = 'Alice';
|
|
148
|
+
conn.registration = 'registered';
|
|
149
|
+
if (opts.conn) Object.assign(conn, opts.conn);
|
|
150
|
+
|
|
151
|
+
const runtime = new FakeRuntime(clock);
|
|
152
|
+
const actor = new ConnectionActor({
|
|
153
|
+
state: conn,
|
|
154
|
+
runtime,
|
|
155
|
+
channels: runtime,
|
|
156
|
+
serverConfig: {
|
|
157
|
+
serverName: 'irc.example.com',
|
|
158
|
+
networkName: 'ExampleNet',
|
|
159
|
+
maxChannelsPerUser: 30,
|
|
160
|
+
maxTargetsPerCommand: 10,
|
|
161
|
+
maxListEntries: 50,
|
|
162
|
+
nickLen: 30,
|
|
163
|
+
channelLen: 50,
|
|
164
|
+
topicLen: 390,
|
|
165
|
+
quitMessage: 'Client Quit',
|
|
166
|
+
},
|
|
167
|
+
clock,
|
|
168
|
+
ids: new SequentialIdFactory(),
|
|
169
|
+
motd: EmptyMotdProvider,
|
|
170
|
+
});
|
|
171
|
+
return { actor, runtime, conn };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Lazy import to keep the unused-warning suppression tidy for `isChannelTarget`.
|
|
175
|
+
void isChannelTarget;
|
|
176
|
+
|
|
177
|
+
describe('ConnectionActor — framing', () => {
|
|
178
|
+
it('processes one PRIVMSG in a single text frame into one broadcast', async () => {
|
|
179
|
+
const { actor, runtime } = makeActor({});
|
|
180
|
+
// Pre-add the connection to the channel so the broadcast excludes the sender.
|
|
181
|
+
await runtime.applyChannelDelta('#foo', {
|
|
182
|
+
memberships: [{ type: 'add', conn: 'c1', nick: 'alice' }],
|
|
183
|
+
});
|
|
184
|
+
runtime.calls.length = 0;
|
|
185
|
+
|
|
186
|
+
await actor.receiveTextFrame('PRIVMSG #foo :hello\r\n');
|
|
187
|
+
|
|
188
|
+
const broadcasts = runtime.calls.filter((c) => c.method === 'broadcast');
|
|
189
|
+
expect(broadcasts).toHaveLength(1);
|
|
190
|
+
expect(broadcasts[0]?.args[0]).toBe('#foo');
|
|
191
|
+
expect(broadcasts[0]?.args[2]).toBe('c1'); // except sender
|
|
192
|
+
const lines = broadcasts[0]?.args[1] as RawLine[];
|
|
193
|
+
expect(lines[0]?.text).toBe(':alice!alice@example.com PRIVMSG #foo :hello');
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('tolerates a frame without a trailing CRLF', async () => {
|
|
197
|
+
const { actor, runtime } = makeActor({});
|
|
198
|
+
await actor.receiveTextFrame('PING :tok');
|
|
199
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
200
|
+
expect(sends).toHaveLength(1);
|
|
201
|
+
const lines = sends[0]?.args[1] as RawLine[];
|
|
202
|
+
expect(lines[0]?.text).toBe('PONG :tok');
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('splits a joined frame with N CRLF-delimited messages into N invocations in order', async () => {
|
|
206
|
+
const { actor, runtime } = makeActor({});
|
|
207
|
+
await actor.receiveTextFrame('PING :a\r\nPING :b\r\nPING :c\r\n');
|
|
208
|
+
|
|
209
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
210
|
+
expect(sends).toHaveLength(3);
|
|
211
|
+
expect((sends[0]?.args[1] as RawLine[])[0]?.text).toBe('PONG :a');
|
|
212
|
+
expect((sends[1]?.args[1] as RawLine[])[0]?.text).toBe('PONG :b');
|
|
213
|
+
expect((sends[2]?.args[1] as RawLine[])[0]?.text).toBe('PONG :c');
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('tolerates bare LF line endings in a joined frame', async () => {
|
|
217
|
+
const { actor, runtime } = makeActor({});
|
|
218
|
+
await actor.receiveTextFrame('PING :a\nPING :b\n');
|
|
219
|
+
|
|
220
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
221
|
+
expect(sends).toHaveLength(2);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('ignores empty lines between messages', async () => {
|
|
225
|
+
const { actor, runtime } = makeActor({});
|
|
226
|
+
await actor.receiveTextFrame('PING :a\r\n\r\nPING :b\r\n');
|
|
227
|
+
|
|
228
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
229
|
+
expect(sends).toHaveLength(2);
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
describe('ConnectionActor — malformed input', () => {
|
|
234
|
+
it('emits 421 for an unparseable line and continues processing the rest', async () => {
|
|
235
|
+
const { actor, runtime } = makeActor({});
|
|
236
|
+
// First token starts with a digit but is not exactly 3 digits → parser rejects.
|
|
237
|
+
await actor.receiveTextFrame('12345\r\nPING :tok\r\n');
|
|
238
|
+
|
|
239
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
240
|
+
expect(sends).toHaveLength(2);
|
|
241
|
+
const firstLines = sends[0]?.args[1] as RawLine[];
|
|
242
|
+
expect(firstLines[0]?.text).toContain(Numerics.ERR_UNKNOWNCOMMAND.toString().padStart(3, '0'));
|
|
243
|
+
const secondLines = sends[1]?.args[1] as RawLine[];
|
|
244
|
+
expect(secondLines[0]?.text).toBe('PONG :tok');
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it('emits 421 for an unknown command and continues', async () => {
|
|
248
|
+
const { actor, runtime } = makeActor({});
|
|
249
|
+
await actor.receiveTextFrame('BOGUS arg\r\nPING :tok\r\n');
|
|
250
|
+
|
|
251
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
252
|
+
// First send carries the 421, second carries the PONG.
|
|
253
|
+
expect(sends).toHaveLength(2);
|
|
254
|
+
const firstLines = sends[0]?.args[1] as RawLine[];
|
|
255
|
+
expect(firstLines[0]?.text).toContain('421');
|
|
256
|
+
expect(firstLines[0]?.text).toContain('BOGUS');
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it('does not crash when the frame is an empty string', async () => {
|
|
260
|
+
const { actor, runtime } = makeActor({});
|
|
261
|
+
await expect(actor.receiveTextFrame('')).resolves.toBeUndefined();
|
|
262
|
+
expect(runtime.calls).toHaveLength(0);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it('does not crash when the frame has only whitespace', async () => {
|
|
266
|
+
const { actor, runtime } = makeActor({});
|
|
267
|
+
await actor.receiveTextFrame('\r\n\r\n');
|
|
268
|
+
expect(runtime.calls).toHaveLength(0);
|
|
269
|
+
});
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
describe('ConnectionActor — routing', () => {
|
|
273
|
+
it('routes PRIVMSG to a nick through sendToNick', async () => {
|
|
274
|
+
const { actor, runtime } = makeActor({});
|
|
275
|
+
await actor.receiveTextFrame('PRIVMSG bob :hi\r\n');
|
|
276
|
+
|
|
277
|
+
const sendsToNick = runtime.calls.filter((c) => c.method === 'sendToNick');
|
|
278
|
+
expect(sendsToNick).toHaveLength(1);
|
|
279
|
+
expect(sendsToNick[0]?.args[0]).toBe('bob');
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it('routes JOIN to the channel authority (mutates roster + broadcasts)', async () => {
|
|
283
|
+
const { actor, runtime } = makeActor({});
|
|
284
|
+
await actor.receiveTextFrame('JOIN #foo\r\n');
|
|
285
|
+
|
|
286
|
+
// JOIN emits a cap-aware broadcast (extended-join); dispatch routes the
|
|
287
|
+
// legacy JOIN line per-recipient via send rather than the broadcast
|
|
288
|
+
// fast path (the connection here lacks the extended-join cap).
|
|
289
|
+
const joinSends = runtime.calls.filter(
|
|
290
|
+
(c) =>
|
|
291
|
+
c.method === 'send' && (c.args[1] as RawLine[]).some((l) => l.text.includes('JOIN #foo')),
|
|
292
|
+
);
|
|
293
|
+
expect(joinSends).toHaveLength(1);
|
|
294
|
+
const lines = joinSends[0]?.args[1] as RawLine[];
|
|
295
|
+
expect(lines[0]?.text).toContain('JOIN #foo');
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it('routes QUIT through disconnect + releaseNick effects', async () => {
|
|
299
|
+
const { actor, runtime, conn } = makeActor({});
|
|
300
|
+
conn.nick = 'alice';
|
|
301
|
+
await actor.receiveTextFrame('QUIT :bye\r\n');
|
|
302
|
+
|
|
303
|
+
const methods = runtime.calls.map((c) => c.method);
|
|
304
|
+
expect(methods).toContain('releaseNick');
|
|
305
|
+
expect(methods).toContain('disconnect');
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it('routes NICK through reserveNick effect', async () => {
|
|
309
|
+
const { actor, runtime, conn } = makeActor({});
|
|
310
|
+
conn.registration = 'pre-registration';
|
|
311
|
+
// biome-ignore lint/performance/noDelete: exactOptionalPropertyTypes
|
|
312
|
+
delete conn.nick;
|
|
313
|
+
await actor.receiveTextFrame('NICK alice\r\n');
|
|
314
|
+
|
|
315
|
+
const reserves = runtime.calls.filter((c) => c.method === 'reserveNick');
|
|
316
|
+
expect(reserves).toHaveLength(1);
|
|
317
|
+
expect(reserves[0]?.args[0]).toBe('alice');
|
|
318
|
+
});
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
describe('ConnectionActor — additional routing', () => {
|
|
322
|
+
it('routes PART through routeMultiChannel (one channel per invocation)', async () => {
|
|
323
|
+
const { actor, runtime } = makeActor({});
|
|
324
|
+
await runtime.applyChannelDelta('#foo', {
|
|
325
|
+
memberships: [{ type: 'add', conn: 'c1', nick: 'alice' }],
|
|
326
|
+
});
|
|
327
|
+
await runtime.applyChannelDelta('#bar', {
|
|
328
|
+
memberships: [{ type: 'add', conn: 'c1', nick: 'alice' }],
|
|
329
|
+
});
|
|
330
|
+
runtime.calls.length = 0;
|
|
331
|
+
|
|
332
|
+
await actor.receiveTextFrame('PART #foo,#bar :bye\r\n');
|
|
333
|
+
|
|
334
|
+
const broadcasts = runtime.calls.filter((c) => c.method === 'broadcast');
|
|
335
|
+
expect(broadcasts).toHaveLength(2);
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
it('routes TOPIC through routeSingleChannel', async () => {
|
|
339
|
+
const { actor, runtime } = makeActor({});
|
|
340
|
+
await runtime.applyChannelDelta('#foo', {
|
|
341
|
+
memberships: [{ type: 'add', conn: 'c1', nick: 'alice', op: true }],
|
|
342
|
+
});
|
|
343
|
+
runtime.calls.length = 0;
|
|
344
|
+
|
|
345
|
+
await actor.receiveTextFrame('TOPIC #foo :hello topic\r\n');
|
|
346
|
+
// The TOPIC reducer emits either a Broadcast (on set) or a Send (on read).
|
|
347
|
+
const methods = runtime.calls.map((c) => c.method);
|
|
348
|
+
expect(methods).toContain('broadcast');
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
it('routes NAMES through routeSingleChannel', async () => {
|
|
352
|
+
const { actor, runtime } = makeActor({});
|
|
353
|
+
await runtime.applyChannelDelta('#foo', {
|
|
354
|
+
memberships: [{ type: 'add', conn: 'c1', nick: 'alice' }],
|
|
355
|
+
});
|
|
356
|
+
runtime.calls.length = 0;
|
|
357
|
+
|
|
358
|
+
await actor.receiveTextFrame('NAMES #foo\r\n');
|
|
359
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
360
|
+
expect(sends.length).toBeGreaterThan(0);
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
it('routes KICK through routeSingleChannel', async () => {
|
|
364
|
+
const { actor, runtime } = makeActor({});
|
|
365
|
+
await runtime.applyChannelDelta('#foo', {
|
|
366
|
+
memberships: [
|
|
367
|
+
{ type: 'add', conn: 'c1', nick: 'alice', op: true },
|
|
368
|
+
{ type: 'add', conn: 'c2', nick: 'bob' },
|
|
369
|
+
],
|
|
370
|
+
});
|
|
371
|
+
runtime.calls.length = 0;
|
|
372
|
+
|
|
373
|
+
await actor.receiveTextFrame('KICK #foo bob :bye\r\n');
|
|
374
|
+
const broadcasts = runtime.calls.filter((c) => c.method === 'broadcast');
|
|
375
|
+
expect(broadcasts).toHaveLength(1);
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
it('routes INVITE through routeSingleChannel', async () => {
|
|
379
|
+
const { actor, runtime } = makeActor({});
|
|
380
|
+
await runtime.applyChannelDelta('#foo', {
|
|
381
|
+
memberships: [{ type: 'add', conn: 'c1', nick: 'alice', op: true }],
|
|
382
|
+
});
|
|
383
|
+
runtime.calls.length = 0;
|
|
384
|
+
|
|
385
|
+
await actor.receiveTextFrame('INVITE bob #foo\r\n');
|
|
386
|
+
// INVITE emits a Send (341 to inviter) + SendToNick (the INVITE notice).
|
|
387
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
388
|
+
expect(sends.length).toBeGreaterThan(0);
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
it('routes MODE #chan through channelModeReducer', async () => {
|
|
392
|
+
const { actor, runtime } = makeActor({});
|
|
393
|
+
await runtime.applyChannelDelta('#foo', {
|
|
394
|
+
memberships: [{ type: 'add', conn: 'c1', nick: 'alice', op: true }],
|
|
395
|
+
});
|
|
396
|
+
runtime.calls.length = 0;
|
|
397
|
+
|
|
398
|
+
await actor.receiveTextFrame('MODE #foo +t\r\n');
|
|
399
|
+
const broadcasts = runtime.calls.filter((c) => c.method === 'broadcast');
|
|
400
|
+
expect(broadcasts).toHaveLength(1);
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
it('routes MODE <nick> through userModeReducer (connection authority)', async () => {
|
|
404
|
+
const { actor, runtime } = makeActor({});
|
|
405
|
+
await actor.receiveTextFrame('MODE alice +i\r\n');
|
|
406
|
+
// User-mode changes don't broadcast; they echo via Send to the user.
|
|
407
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
408
|
+
expect(sends.length).toBeGreaterThan(0);
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
it('routes JOIN 0 to handleJoinZero (parts every channel)', async () => {
|
|
412
|
+
const { actor, runtime, conn } = makeActor({});
|
|
413
|
+
conn.joinedChannels.add('#foo');
|
|
414
|
+
conn.joinedChannels.add('#bar');
|
|
415
|
+
runtime.calls.length = 0;
|
|
416
|
+
|
|
417
|
+
await actor.receiveTextFrame('JOIN 0\r\n');
|
|
418
|
+
const broadcasts = runtime.calls.filter((c) => c.method === 'broadcast');
|
|
419
|
+
expect(broadcasts).toHaveLength(2);
|
|
420
|
+
const deltas = runtime.calls.filter((c) => c.method === 'applyChannelDelta');
|
|
421
|
+
expect(deltas).toHaveLength(2);
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
it('routes JOIN with multiple channels and keys', async () => {
|
|
425
|
+
const { actor, runtime } = makeActor({});
|
|
426
|
+
await actor.receiveTextFrame('JOIN #a,#b ka,kb\r\n');
|
|
427
|
+
// Two cap-aware JOIN broadcasts (one per channel); dispatch delivers
|
|
428
|
+
// each legacy JOIN line per-recipient via send.
|
|
429
|
+
const joinSends = runtime.calls.filter(
|
|
430
|
+
(c) => c.method === 'send' && (c.args[1] as RawLine[]).some((l) => l.text.includes('JOIN')),
|
|
431
|
+
);
|
|
432
|
+
expect(joinSends).toHaveLength(2);
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
it('routes NOTICE to a channel target', async () => {
|
|
436
|
+
const { actor, runtime } = makeActor({});
|
|
437
|
+
await runtime.applyChannelDelta('#foo', {
|
|
438
|
+
memberships: [{ type: 'add', conn: 'c1', nick: 'alice' }],
|
|
439
|
+
});
|
|
440
|
+
runtime.calls.length = 0;
|
|
441
|
+
await actor.receiveTextFrame('NOTICE #foo :note\r\n');
|
|
442
|
+
expect(runtime.calls.filter((c) => c.method === 'broadcast')).toHaveLength(1);
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
it('routes PRIVMSG with mixed targets (#chan,nick) into 2 invocations', async () => {
|
|
446
|
+
const { actor, runtime } = makeActor({});
|
|
447
|
+
await runtime.applyChannelDelta('#foo', {
|
|
448
|
+
memberships: [{ type: 'add', conn: 'c1', nick: 'alice' }],
|
|
449
|
+
});
|
|
450
|
+
runtime.calls.length = 0;
|
|
451
|
+
await actor.receiveTextFrame('PRIVMSG #foo,bob :hi\r\n');
|
|
452
|
+
// One broadcast (channel) + one sendToNick (user).
|
|
453
|
+
expect(runtime.calls.filter((c) => c.method === 'broadcast')).toHaveLength(1);
|
|
454
|
+
expect(runtime.calls.filter((c) => c.method === 'sendToNick')).toHaveLength(1);
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
it('routes CAP through the connection-authority capReducer', async () => {
|
|
458
|
+
const { actor, runtime } = makeActor({});
|
|
459
|
+
await actor.receiveTextFrame('CAP LS\r\n');
|
|
460
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
461
|
+
expect(sends.length).toBeGreaterThan(0);
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
it('routes MOTD through motdReducer (empty MOTD → 422)', async () => {
|
|
465
|
+
const { actor, runtime } = makeActor({});
|
|
466
|
+
await actor.receiveTextFrame('MOTD\r\n');
|
|
467
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
468
|
+
expect(sends).toHaveLength(1);
|
|
469
|
+
const lines = sends[0]?.args[1] as RawLine[];
|
|
470
|
+
expect(lines[0]?.text).toContain('422');
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
it('routes USER post-registration to a 462 error Send', async () => {
|
|
474
|
+
const { actor, runtime } = makeActor({});
|
|
475
|
+
await actor.receiveTextFrame('USER alice 0 * :Alice\r\n');
|
|
476
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
477
|
+
expect(sends).toHaveLength(1);
|
|
478
|
+
const lines = sends[0]?.args[1] as RawLine[];
|
|
479
|
+
expect(lines[0]?.text).toContain('462');
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
it('routes PASS to passReducer (stashes the attempt)', async () => {
|
|
483
|
+
const { actor, runtime } = makeActor({});
|
|
484
|
+
await actor.receiveTextFrame('PASS sekrat\r\n');
|
|
485
|
+
// passReducer on an already-registered conn emits a 462 Send.
|
|
486
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
487
|
+
expect(sends).toHaveLength(1);
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
it('routes PONG to pongReducer (no effects, lastSeen refreshed)', async () => {
|
|
491
|
+
const { actor, runtime } = makeActor({});
|
|
492
|
+
await actor.receiveTextFrame('PONG :tok\r\n');
|
|
493
|
+
expect(runtime.calls).toHaveLength(0);
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
it('emits 421 with no command token for an entirely non-token parse failure', async () => {
|
|
497
|
+
const { actor, runtime } = makeActor({});
|
|
498
|
+
// Parser throws "missing command" when the line is just `@tags`. The
|
|
499
|
+
// first-token extractor returns the whole `@foo=bar` run; we just
|
|
500
|
+
// assert the 421 numeric was emitted.
|
|
501
|
+
await actor.receiveTextFrame('@foo=bar\r\n');
|
|
502
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
503
|
+
expect(sends).toHaveLength(1);
|
|
504
|
+
const line = (sends[0]?.args[1] as RawLine[])[0]?.text ?? '';
|
|
505
|
+
expect(line).toContain('421');
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
it('emits 421 for PART with no channel argument', async () => {
|
|
509
|
+
const { actor, runtime } = makeActor({});
|
|
510
|
+
await actor.receiveTextFrame('PART\r\n');
|
|
511
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
512
|
+
expect(sends).toHaveLength(1);
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
it('routes WHOIS of a registered user through whoisReducer (311 + 318, never 421)', async () => {
|
|
516
|
+
const { actor, runtime, conn } = makeActor({});
|
|
517
|
+
// Register a second connection `bob` so lookupNick + getConnection resolve.
|
|
518
|
+
const bobState = createConnection({ id: 'c2', connectedSince: 0 });
|
|
519
|
+
bobState.nick = 'bob';
|
|
520
|
+
bobState.user = 'bob';
|
|
521
|
+
bobState.host = 'example.com';
|
|
522
|
+
bobState.realname = 'Bob';
|
|
523
|
+
bobState.registration = 'registered';
|
|
524
|
+
runtime.addConnection(conn);
|
|
525
|
+
runtime.addConnection(bobState);
|
|
526
|
+
runtime.calls.length = 0;
|
|
527
|
+
|
|
528
|
+
await actor.receiveTextFrame('WHOIS bob\r\n');
|
|
529
|
+
|
|
530
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
531
|
+
// whoisReducer emits the 311/312/317 batch, then the 318 end marker.
|
|
532
|
+
expect(sends.length).toBeGreaterThanOrEqual(2);
|
|
533
|
+
const firstBatch = sends[0]?.args[1] as RawLine[];
|
|
534
|
+
expect(firstBatch[0]?.text).toContain(' 311 ');
|
|
535
|
+
expect(firstBatch[0]?.text).toContain(' bob ');
|
|
536
|
+
const endBatch = sends[sends.length - 1]?.args[1] as RawLine[];
|
|
537
|
+
expect(endBatch[0]?.text).toContain(' 318 ');
|
|
538
|
+
// Critical: never 421 for WHOIS.
|
|
539
|
+
for (const s of sends) {
|
|
540
|
+
for (const line of s.args[1] as RawLine[]) {
|
|
541
|
+
expect(line.text).not.toContain(' 421 ');
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
it('routes WHOIS of an unknown nick to 401 then 318 (never 421)', async () => {
|
|
547
|
+
const { actor, runtime } = makeActor({});
|
|
548
|
+
runtime.calls.length = 0;
|
|
549
|
+
|
|
550
|
+
await actor.receiveTextFrame('WHOIS ghost\r\n');
|
|
551
|
+
|
|
552
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
553
|
+
expect(sends).toHaveLength(2);
|
|
554
|
+
const notFound = sends[0]?.args[1] as RawLine[];
|
|
555
|
+
expect(notFound[0]?.text).toContain(' 401 ');
|
|
556
|
+
expect(notFound[0]?.text).toContain(' ghost ');
|
|
557
|
+
const end = sends[1]?.args[1] as RawLine[];
|
|
558
|
+
expect(end[0]?.text).toContain(' 318 ');
|
|
559
|
+
for (const s of sends) {
|
|
560
|
+
for (const line of s.args[1] as RawLine[]) {
|
|
561
|
+
expect(line.text).not.toContain(' 421 ');
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
it('emits 421 with empty command when routeSingleChannel gets no channel (TOPIC)', async () => {
|
|
567
|
+
const { actor, runtime } = makeActor({});
|
|
568
|
+
await actor.receiveTextFrame('TOPIC\r\n');
|
|
569
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
570
|
+
expect(sends).toHaveLength(1);
|
|
571
|
+
const lines = sends[0]?.args[1] as RawLine[];
|
|
572
|
+
expect(lines[0]?.text).toContain('421');
|
|
573
|
+
expect(lines[0]?.text).toContain('TOPIC');
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
it('emits 421 with no command token when the line starts with whitespace', async () => {
|
|
577
|
+
const { actor, runtime } = makeActor({});
|
|
578
|
+
// Leading space: firstToken(' foo') returns '' (the `^\S+` match fails),
|
|
579
|
+
// exercising the empty-command branch of `unknownCommandEffect`.
|
|
580
|
+
await actor.receiveTextFrame(' foo\r\n');
|
|
581
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
582
|
+
expect(sends).toHaveLength(1);
|
|
583
|
+
const lines = sends[0]?.args[1] as RawLine[];
|
|
584
|
+
expect(lines[0]?.text).toBe(':irc.example.com 421 alice :Unknown command');
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
it('covers JOIN with no params (splitCsv("") → [])', async () => {
|
|
588
|
+
const { actor, runtime } = makeActor({});
|
|
589
|
+
await actor.receiveTextFrame('JOIN\r\n');
|
|
590
|
+
// joinReducer with no channel emits a 461 ERR_NEEDMOREPARAMS via Send.
|
|
591
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
592
|
+
expect(sends.length).toBeGreaterThan(0);
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
it('covers PRIVMSG with no params (splitCsv("") → [])', async () => {
|
|
596
|
+
const { actor, runtime } = makeActor({});
|
|
597
|
+
await actor.receiveTextFrame('PRIVMSG\r\n');
|
|
598
|
+
// privmsgUserReducer with no recipient emits a 411 ERR_NORECIPIENT Send.
|
|
599
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
600
|
+
expect(sends.length).toBeGreaterThan(0);
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
it('uses "*" as the nick in 421 when the connection has no nick yet', async () => {
|
|
604
|
+
const { actor, runtime, conn } = makeActor({});
|
|
605
|
+
// biome-ignore lint/performance/noDelete: exactOptionalPropertyTypes
|
|
606
|
+
delete conn.nick;
|
|
607
|
+
await actor.receiveTextFrame('BOGUS\r\n');
|
|
608
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
609
|
+
expect(sends).toHaveLength(1);
|
|
610
|
+
const lines = sends[0]?.args[1] as RawLine[];
|
|
611
|
+
expect(lines[0]?.text).toContain('421 * BOGUS');
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
it('covers MODE with no params (routes to user-mode reducer)', async () => {
|
|
615
|
+
const { actor, runtime } = makeActor({});
|
|
616
|
+
await actor.receiveTextFrame('MODE\r\n');
|
|
617
|
+
// userModeReducer with no params emits a Send (numeric error or empty mode).
|
|
618
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
619
|
+
expect(sends.length).toBeGreaterThan(0);
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
it('uses the EmptyMotdProvider default when no motd is supplied', async () => {
|
|
623
|
+
const clock = new FakeClock(1_000);
|
|
624
|
+
const conn = createConnection({ id: 'c1', connectedSince: 0 });
|
|
625
|
+
conn.nick = 'alice';
|
|
626
|
+
conn.user = 'alice';
|
|
627
|
+
conn.host = 'example.com';
|
|
628
|
+
conn.realname = 'Alice';
|
|
629
|
+
conn.registration = 'registered';
|
|
630
|
+
const runtime = new FakeRuntime(clock);
|
|
631
|
+
// No `motd` option → defaults to EmptyMotdProvider.
|
|
632
|
+
const actor = new ConnectionActor({
|
|
633
|
+
state: conn,
|
|
634
|
+
runtime,
|
|
635
|
+
channels: runtime,
|
|
636
|
+
serverConfig: {
|
|
637
|
+
serverName: 'irc.example.com',
|
|
638
|
+
networkName: 'ExampleNet',
|
|
639
|
+
maxChannelsPerUser: 30,
|
|
640
|
+
maxTargetsPerCommand: 10,
|
|
641
|
+
maxListEntries: 50,
|
|
642
|
+
nickLen: 30,
|
|
643
|
+
channelLen: 50,
|
|
644
|
+
topicLen: 390,
|
|
645
|
+
quitMessage: 'Client Quit',
|
|
646
|
+
},
|
|
647
|
+
clock,
|
|
648
|
+
ids: new SequentialIdFactory(),
|
|
649
|
+
});
|
|
650
|
+
await actor.receiveTextFrame('MOTD\r\n');
|
|
651
|
+
// Empty MOTD → 422 ERR_NOMOTD via Send.
|
|
652
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
653
|
+
expect(sends).toHaveLength(1);
|
|
654
|
+
const lines = sends[0]?.args[1] as RawLine[];
|
|
655
|
+
expect(lines[0]?.text).toContain('422');
|
|
656
|
+
});
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
describe('ConnectionActor — LIST', () => {
|
|
660
|
+
it('LIST with no args emits 321, one 322 per visible channel, then 323', async () => {
|
|
661
|
+
const { actor, runtime } = makeActor({});
|
|
662
|
+
await runtime.applyChannelDelta('#foo', {
|
|
663
|
+
memberships: [{ type: 'add', conn: 'c2', nick: 'bob' }],
|
|
664
|
+
});
|
|
665
|
+
await runtime.applyChannelDelta('#bar', {
|
|
666
|
+
memberships: [{ type: 'add', conn: 'c3', nick: 'carol' }],
|
|
667
|
+
});
|
|
668
|
+
runtime.calls.length = 0;
|
|
669
|
+
|
|
670
|
+
await actor.receiveTextFrame('LIST\r\n');
|
|
671
|
+
|
|
672
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
673
|
+
expect(sends).toHaveLength(3);
|
|
674
|
+
const startLines = sends[0]?.args[1] as RawLine[];
|
|
675
|
+
expect(startLines[0]?.text).toBe(':irc.example.com 321 alice Channel :Users Name');
|
|
676
|
+
const listLines = sends[1]?.args[1] as RawLine[];
|
|
677
|
+
expect(listLines).toHaveLength(2);
|
|
678
|
+
expect(listLines[0]?.text).toBe(':irc.example.com 322 alice #foo 1 :');
|
|
679
|
+
expect(listLines[1]?.text).toBe(':irc.example.com 322 alice #bar 1 :');
|
|
680
|
+
const endLines = sends[2]?.args[1] as RawLine[];
|
|
681
|
+
expect(endLines[0]?.text).toBe(':irc.example.com 323 alice :End of /LIST');
|
|
682
|
+
});
|
|
683
|
+
|
|
684
|
+
it('LIST <chan> narrows to the requested channel', async () => {
|
|
685
|
+
const { actor, runtime } = makeActor({});
|
|
686
|
+
await runtime.applyChannelDelta('#foo', {
|
|
687
|
+
memberships: [{ type: 'add', conn: 'c2', nick: 'bob' }],
|
|
688
|
+
});
|
|
689
|
+
await runtime.applyChannelDelta('#bar', {
|
|
690
|
+
memberships: [{ type: 'add', conn: 'c3', nick: 'carol' }],
|
|
691
|
+
});
|
|
692
|
+
runtime.calls.length = 0;
|
|
693
|
+
|
|
694
|
+
await actor.receiveTextFrame('LIST #foo\r\n');
|
|
695
|
+
|
|
696
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
697
|
+
expect(sends).toHaveLength(3);
|
|
698
|
+
const listLines = sends[1]?.args[1] as RawLine[];
|
|
699
|
+
expect(listLines).toHaveLength(1);
|
|
700
|
+
expect(listLines[0]?.text).toBe(':irc.example.com 322 alice #foo 1 :');
|
|
701
|
+
});
|
|
702
|
+
|
|
703
|
+
it('LIST for a non-existent channel emits 321 then 323 with no 322', async () => {
|
|
704
|
+
const { actor, runtime } = makeActor({});
|
|
705
|
+
await runtime.applyChannelDelta('#foo', {
|
|
706
|
+
memberships: [{ type: 'add', conn: 'c2', nick: 'bob' }],
|
|
707
|
+
});
|
|
708
|
+
runtime.calls.length = 0;
|
|
709
|
+
|
|
710
|
+
await actor.receiveTextFrame('LIST #nope\r\n');
|
|
711
|
+
|
|
712
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
713
|
+
expect(sends).toHaveLength(2);
|
|
714
|
+
const startLines = sends[0]?.args[1] as RawLine[];
|
|
715
|
+
expect(startLines[0]?.text).toBe(':irc.example.com 321 alice Channel :Users Name');
|
|
716
|
+
const endLines = sends[1]?.args[1] as RawLine[];
|
|
717
|
+
expect(endLines[0]?.text).toBe(':irc.example.com 323 alice :End of /LIST');
|
|
718
|
+
});
|
|
719
|
+
|
|
720
|
+
it('LIST hides secret channels from non-members', async () => {
|
|
721
|
+
const { actor, runtime } = makeActor({});
|
|
722
|
+
await runtime.applyChannelDelta('#foo', {
|
|
723
|
+
memberships: [{ type: 'add', conn: 'c2', nick: 'bob' }],
|
|
724
|
+
});
|
|
725
|
+
await runtime.applyChannelDelta('#secret', {
|
|
726
|
+
memberships: [{ type: 'add', conn: 'c3', nick: 'carol' }],
|
|
727
|
+
modeChanges: [{ mode: 'secret', set: true }],
|
|
728
|
+
});
|
|
729
|
+
runtime.calls.length = 0;
|
|
730
|
+
|
|
731
|
+
await actor.receiveTextFrame('LIST\r\n');
|
|
732
|
+
|
|
733
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
734
|
+
const listLines = sends[1]?.args[1] as RawLine[];
|
|
735
|
+
expect(listLines).toHaveLength(1);
|
|
736
|
+
expect(listLines[0]?.text).toBe(':irc.example.com 322 alice #foo 1 :');
|
|
737
|
+
});
|
|
738
|
+
});
|
|
739
|
+
|
|
740
|
+
describe('ConnectionActor — WHO', () => {
|
|
741
|
+
it('WHO with no args emits only 315 End of /WHO', async () => {
|
|
742
|
+
const { actor, runtime } = makeActor({});
|
|
743
|
+
runtime.calls.length = 0;
|
|
744
|
+
|
|
745
|
+
await actor.receiveTextFrame('WHO\r\n');
|
|
746
|
+
|
|
747
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
748
|
+
expect(sends).toHaveLength(1);
|
|
749
|
+
const lines = sends[0]?.args[1] as RawLine[];
|
|
750
|
+
expect(lines).toHaveLength(1);
|
|
751
|
+
expect(lines[0]?.text).toBe(':irc.example.com 315 alice :End of /WHO list');
|
|
752
|
+
});
|
|
753
|
+
|
|
754
|
+
it('WHO #chan with no connections emits only 315', async () => {
|
|
755
|
+
const { actor, runtime } = makeActor({});
|
|
756
|
+
await runtime.applyChannelDelta('#foo', {
|
|
757
|
+
memberships: [{ type: 'add', conn: 'c1', nick: 'alice' }],
|
|
758
|
+
});
|
|
759
|
+
runtime.calls.length = 0;
|
|
760
|
+
|
|
761
|
+
await actor.receiveTextFrame('WHO #foo\r\n');
|
|
762
|
+
|
|
763
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
764
|
+
expect(sends).toHaveLength(1);
|
|
765
|
+
const lines = sends[0]?.args[1] as RawLine[];
|
|
766
|
+
expect(lines).toHaveLength(1);
|
|
767
|
+
expect(lines[0]?.text).toBe(':irc.example.com 315 alice #foo :End of /WHO list');
|
|
768
|
+
});
|
|
769
|
+
|
|
770
|
+
it('WHO #chan with members emits one 352 per member then 315', async () => {
|
|
771
|
+
const { actor, runtime, conn } = makeActor({});
|
|
772
|
+
const bobState = createConnection({ id: 'c2', connectedSince: 0 });
|
|
773
|
+
bobState.nick = 'bob';
|
|
774
|
+
bobState.user = 'bob';
|
|
775
|
+
bobState.host = 'example.com';
|
|
776
|
+
bobState.realname = 'Bob';
|
|
777
|
+
bobState.registration = 'registered';
|
|
778
|
+
|
|
779
|
+
await runtime.applyChannelDelta('#foo', {
|
|
780
|
+
memberships: [
|
|
781
|
+
{ type: 'add', conn: 'c1', nick: 'alice' },
|
|
782
|
+
{ type: 'add', conn: 'c2', nick: 'bob' },
|
|
783
|
+
],
|
|
784
|
+
});
|
|
785
|
+
runtime.addConnection(conn);
|
|
786
|
+
runtime.addConnection(bobState);
|
|
787
|
+
runtime.calls.length = 0;
|
|
788
|
+
|
|
789
|
+
await actor.receiveTextFrame('WHO #foo\r\n');
|
|
790
|
+
|
|
791
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
792
|
+
expect(sends).toHaveLength(2);
|
|
793
|
+
const replyLines = sends[0]?.args[1] as RawLine[];
|
|
794
|
+
expect(replyLines).toHaveLength(2);
|
|
795
|
+
expect(replyLines[0]?.text).toBe(
|
|
796
|
+
':irc.example.com 352 alice #foo alice example.com irc.example.com alice H :0 Alice',
|
|
797
|
+
);
|
|
798
|
+
expect(replyLines[1]?.text).toBe(
|
|
799
|
+
':irc.example.com 352 alice #foo bob example.com irc.example.com bob H :0 Bob',
|
|
800
|
+
);
|
|
801
|
+
const endLines = sends[1]?.args[1] as RawLine[];
|
|
802
|
+
expect(endLines[0]?.text).toBe(':irc.example.com 315 alice #foo :End of /WHO list');
|
|
803
|
+
});
|
|
804
|
+
|
|
805
|
+
it('WHO on an invalid channel name emits 403 ERR_NOSUCHCHANNEL', async () => {
|
|
806
|
+
const { actor, runtime } = makeActor({});
|
|
807
|
+
runtime.calls.length = 0;
|
|
808
|
+
|
|
809
|
+
await actor.receiveTextFrame('WHO badname\r\n');
|
|
810
|
+
|
|
811
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
812
|
+
expect(sends).toHaveLength(1);
|
|
813
|
+
const lines = sends[0]?.args[1] as RawLine[];
|
|
814
|
+
expect(lines[0]?.text).toBe(':irc.example.com 403 alice badname :No such channel');
|
|
815
|
+
});
|
|
816
|
+
});
|