serverless-ircd 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/ci.yml +2 -2
- package/.github/workflows/deploy-aws.yml +1 -3
- package/.github/workflows/deploy-cf-tcp.yml +87 -0
- package/.github/workflows/deploy-cf.yml +1 -1
- package/.node-version +1 -0
- package/.nvmrc +1 -0
- package/CHANGELOG.md +258 -18
- package/README.md +72 -30
- package/apps/aws-stack/README.md +2 -2
- package/apps/aws-stack/bin/aws.ts +7 -0
- package/apps/aws-stack/package.json +4 -4
- package/apps/aws-stack/src/aws-stack.ts +118 -6
- package/apps/aws-stack/tests/stack.test.ts +98 -3
- package/apps/cf-tcp-container/Dockerfile +69 -0
- package/apps/cf-tcp-container/package.json +34 -0
- package/apps/cf-tcp-container/src/config-loader.ts +145 -0
- package/apps/cf-tcp-container/src/container-do.ts +38 -0
- package/apps/cf-tcp-container/src/container-server.ts +363 -0
- package/apps/cf-tcp-container/src/main.ts +77 -0
- package/apps/cf-tcp-container/src/persistence.ts +144 -0
- package/apps/cf-tcp-container/src/worker.ts +41 -0
- package/apps/cf-tcp-container/terraform/provider.tf +24 -0
- package/apps/cf-tcp-container/terraform/spectrum.tf +81 -0
- package/apps/cf-tcp-container/tests/config-loader.test.ts +217 -0
- package/apps/cf-tcp-container/tests/container-server.test.ts +465 -0
- package/apps/cf-tcp-container/tests/persistence.test.ts +227 -0
- package/apps/cf-tcp-container/tests/tls-e2e.test.ts +275 -0
- package/apps/cf-tcp-container/tsconfig.build.json +17 -0
- package/apps/cf-tcp-container/tsconfig.test.json +15 -0
- package/apps/cf-tcp-container/vitest.config.ts +26 -0
- package/apps/cf-tcp-container/wrangler.toml +63 -0
- package/apps/cf-worker/package.json +1 -1
- package/apps/cf-worker/scripts/smoke.mjs +0 -0
- package/apps/cf-worker/src/worker.ts +8 -2
- package/apps/cf-worker/tests/smoke.test.ts +1 -0
- package/apps/cf-worker/wrangler.test.toml +11 -1
- package/apps/cf-worker/wrangler.toml +69 -19
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/config-loader.ts +11 -0
- package/apps/local-cli/src/main.ts +1 -1
- package/apps/local-cli/src/server.ts +46 -3
- package/apps/local-cli/tests/e2e.test.ts +52 -0
- package/package.json +19 -16
- package/packages/aws-adapter/package.json +3 -3
- package/packages/aws-adapter/src/account-store.ts +121 -0
- package/packages/aws-adapter/src/admission.ts +74 -0
- package/packages/aws-adapter/src/aws-runtime.ts +124 -34
- package/packages/aws-adapter/src/cdk-table-defs.ts +5 -1
- package/packages/aws-adapter/src/config-loader.ts +62 -0
- package/packages/aws-adapter/src/dynamo-account-store.ts +95 -0
- package/packages/aws-adapter/src/handlers/connect.ts +64 -8
- package/packages/aws-adapter/src/handlers/default.ts +43 -2
- package/packages/aws-adapter/src/handlers/disconnect.ts +27 -8
- package/packages/aws-adapter/src/handlers/index.ts +120 -9
- package/packages/aws-adapter/src/handlers/nlb-stream.ts +481 -0
- package/packages/aws-adapter/src/handlers/ping-checker.ts +1 -1
- package/packages/aws-adapter/src/index.ts +13 -0
- package/packages/aws-adapter/tests/account-store-dynamo.test.ts +182 -0
- package/packages/aws-adapter/tests/account-store.test.ts +279 -0
- package/packages/aws-adapter/tests/admission.test.ts +70 -0
- package/packages/aws-adapter/tests/aws-harness.ts +13 -1
- package/packages/aws-adapter/tests/config-loader.test.ts +75 -0
- package/packages/aws-adapter/tests/connect.test.ts +78 -0
- package/packages/aws-adapter/tests/disconnect-fanout.test.ts +343 -0
- package/packages/aws-adapter/tests/gone-exception.test.ts +31 -26
- package/packages/aws-adapter/tests/handlers.test.ts +194 -47
- package/packages/aws-adapter/tests/nlb-stream.test.ts +478 -0
- package/packages/aws-adapter/tests/ping-checker.test.ts +34 -29
- package/packages/aws-adapter/tests/sweeper.test.ts +25 -18
- package/packages/aws-adapter/tests/transactions.test.ts +25 -20
- package/packages/cf-adapter/package.json +1 -1
- package/packages/cf-adapter/src/cf-runtime.ts +40 -22
- package/packages/cf-adapter/src/channel-do.ts +40 -1
- package/packages/cf-adapter/src/channel-registry-do.ts +58 -0
- package/packages/cf-adapter/src/config-loader.ts +62 -0
- package/packages/cf-adapter/src/connection-do.ts +181 -31
- package/packages/cf-adapter/src/d1-account-store.ts +198 -0
- package/packages/cf-adapter/src/env.ts +58 -8
- package/packages/cf-adapter/src/index.ts +11 -9
- package/packages/cf-adapter/tests/cf-harness.ts +11 -1
- package/packages/cf-adapter/tests/cf-integration.test.ts +2 -1
- package/packages/cf-adapter/tests/cf-runtime.test.ts +91 -0
- package/packages/cf-adapter/tests/config-loader.test.ts +22 -0
- package/packages/cf-adapter/tests/connection-do-channel-registration.test.ts +37 -0
- package/packages/cf-adapter/tests/connection-do-no-batching-reservation.test.ts +52 -0
- package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +166 -0
- package/packages/cf-adapter/tests/connection-do.test.ts +40 -0
- package/packages/cf-adapter/tests/d1-account-store.test.ts +226 -0
- package/packages/cf-adapter/tests/raw-modules.d.ts +11 -0
- package/packages/cf-adapter/tests/worker/main.ts +9 -1
- package/packages/cf-adapter/tests/worker/stubs/channel-stub.ts +7 -1
- package/packages/cf-adapter/wrangler.test.toml +18 -1
- package/packages/in-memory-runtime/package.json +1 -1
- package/packages/in-memory-runtime/src/in-memory-runtime.ts +14 -28
- package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +19 -0
- package/packages/irc-core/package.json +8 -2
- package/packages/irc-core/scripts/generate-build-info.mjs +31 -0
- package/packages/irc-core/src/caps/capabilities.ts +23 -2
- package/packages/irc-core/src/case-fold.ts +64 -0
- package/packages/irc-core/src/cloak.ts +1 -1
- package/packages/irc-core/src/commands/cap.ts +8 -1
- package/packages/irc-core/src/commands/index.ts +11 -0
- package/packages/irc-core/src/commands/invite.ts +6 -9
- package/packages/irc-core/src/commands/ison.ts +61 -0
- package/packages/irc-core/src/commands/isupport.ts +1 -1
- package/packages/irc-core/src/commands/join.ts +4 -3
- package/packages/irc-core/src/commands/kick.ts +4 -11
- package/packages/irc-core/src/commands/list.ts +5 -4
- package/packages/irc-core/src/commands/mode.ts +5 -9
- package/packages/irc-core/src/commands/motd-lines.ts +127 -0
- package/packages/irc-core/src/commands/motd.ts +6 -110
- package/packages/irc-core/src/commands/oper.ts +151 -0
- package/packages/irc-core/src/commands/quit.ts +12 -0
- package/packages/irc-core/src/commands/registration.ts +26 -19
- package/packages/irc-core/src/commands/sasl.ts +72 -9
- package/packages/irc-core/src/commands/server-info.ts +129 -0
- package/packages/irc-core/src/commands/tagmsg.ts +205 -0
- package/packages/irc-core/src/commands/userhost.ts +84 -0
- package/packages/irc-core/src/commands/whowas.ts +113 -0
- package/packages/irc-core/src/config.ts +84 -3
- package/packages/irc-core/src/credential-hashing.ts +124 -0
- package/packages/irc-core/src/effects.ts +6 -29
- package/packages/irc-core/src/index.ts +3 -0
- package/packages/irc-core/src/ports.ts +240 -7
- package/packages/irc-core/src/protocol/numerics.ts +14 -8
- package/packages/irc-core/src/types.ts +60 -1
- package/packages/irc-core/tests/account-store.test.ts +131 -0
- package/packages/irc-core/tests/caps/capabilities.test.ts +4 -3
- package/packages/irc-core/tests/case-fold.test.ts +80 -0
- package/packages/irc-core/tests/commands/cap.test.ts +33 -1
- package/packages/irc-core/tests/commands/ison.test.ts +166 -0
- package/packages/irc-core/tests/commands/kick.test.ts +15 -0
- package/packages/irc-core/tests/commands/oper.test.ts +257 -0
- package/packages/irc-core/tests/commands/quit.test.ts +69 -2
- package/packages/irc-core/tests/commands/registration.test.ts +256 -19
- package/packages/irc-core/tests/commands/sasl.test.ts +118 -10
- package/packages/irc-core/tests/commands/server-info.test.ts +274 -0
- package/packages/irc-core/tests/commands/tagmsg.test.ts +662 -0
- package/packages/irc-core/tests/commands/userhost.test.ts +264 -0
- package/packages/irc-core/tests/commands/who.test.ts +22 -4
- package/packages/irc-core/tests/commands/whois.test.ts +8 -1
- package/packages/irc-core/tests/commands/whowas.test.ts +312 -0
- package/packages/irc-core/tests/config.test.ts +170 -1
- package/packages/irc-core/tests/credential-hashing.test.ts +170 -0
- package/packages/irc-core/tests/effects.test.ts +0 -27
- package/packages/irc-core/tests/nick-history-store.test.ts +162 -0
- package/packages/irc-core/tests/numerics.test.ts +12 -0
- package/packages/irc-core/tests/types.test.ts +35 -1
- package/packages/irc-core/tsconfig.build.json +1 -1
- package/packages/irc-core/tsconfig.test.json +1 -1
- package/packages/irc-server/package.json +1 -1
- package/packages/irc-server/src/actor.ts +182 -14
- package/packages/irc-server/src/dispatch.ts +0 -3
- package/packages/irc-server/src/index.ts +10 -2
- package/packages/irc-server/src/routing.ts +21 -0
- package/packages/irc-server/src/transport.ts +101 -0
- package/packages/irc-server/tests/actor.test.ts +617 -1
- package/packages/irc-server/tests/dispatch.test.ts +0 -17
- package/packages/irc-server/tests/routing.test.ts +7 -0
- package/packages/irc-server/tests/transport.test.ts +230 -0
- package/packages/irc-test-support/package.json +1 -1
- package/packages/irc-test-support/src/harness.ts +44 -9
- package/packages/irc-test-support/src/in-memory-harness.ts +78 -13
- package/packages/irc-test-support/src/index.ts +3 -0
- package/packages/irc-test-support/src/scenarios.ts +132 -2
- package/packages/irc-test-support/tests/in-memory-harness.test.ts +2 -1
- package/packages/irc-test-support/tests/in-memory-scenarios.test.ts +23 -9
- package/pnpm-workspace.yaml +9 -0
- package/tools/ci-hardening/package.json +1 -1
- package/tools/package.json +4 -0
- package/tools/seed-aws-accounts.ts +79 -0
- package/tools/seed-cf-accounts.ts +104 -0
- package/tools/tcp-ws-forwarder/package.json +1 -1
- package/AGENTS.md +0 -5
- package/MOTD.txt +0 -3
- package/PLAN-FIXES.md +0 -358
- package/PLAN.md +0 -420
- package/dashboards/cloudwatch-irc.json +0 -139
- package/docs/AWS-Adapter-Architecture.md +0 -494
- package/docs/AWS-Deployment.md +0 -1107
- package/docs/Cloudflare-Deployment-Guide.md +0 -660
- package/docs/Home.md +0 -1
- package/docs/Observability.md +0 -87
- package/docs/PlanIRCv3Websocket.md +0 -489
- package/docs/PlanWebClient.md +0 -451
- package/docs/Release-Process.md +0 -440
- package/progress.md +0 -107
- package/tickets.md +0 -2485
- package/webircgateway/LICENSE +0 -201
- package/webircgateway/Makefile +0 -44
- package/webircgateway/README.md +0 -134
- package/webircgateway/config.conf.example +0 -135
- package/webircgateway/go.mod +0 -16
- package/webircgateway/go.sum +0 -89
- package/webircgateway/main.go +0 -118
- package/webircgateway/pkg/dnsbl/dnsbl.go +0 -121
- package/webircgateway/pkg/identd/identd.go +0 -86
- package/webircgateway/pkg/identd/rpcclient.go +0 -59
- package/webircgateway/pkg/irc/isupport.go +0 -56
- package/webircgateway/pkg/irc/message.go +0 -217
- package/webircgateway/pkg/irc/state.go +0 -79
- package/webircgateway/pkg/proxy/proxy.go +0 -129
- package/webircgateway/pkg/proxy/server.go +0 -237
- package/webircgateway/pkg/recaptcha/recaptcha.go +0 -59
- package/webircgateway/pkg/webircgateway/client.go +0 -741
- package/webircgateway/pkg/webircgateway/client_command_handlers.go +0 -495
- package/webircgateway/pkg/webircgateway/config.go +0 -385
- package/webircgateway/pkg/webircgateway/gateway.go +0 -278
- package/webircgateway/pkg/webircgateway/gateway_utils.go +0 -133
- package/webircgateway/pkg/webircgateway/hooks.go +0 -152
- package/webircgateway/pkg/webircgateway/letsencrypt.go +0 -41
- package/webircgateway/pkg/webircgateway/messagetags.go +0 -103
- package/webircgateway/pkg/webircgateway/transport_kiwiirc.go +0 -206
- package/webircgateway/pkg/webircgateway/transport_sockjs.go +0 -107
- package/webircgateway/pkg/webircgateway/transport_tcp.go +0 -113
- package/webircgateway/pkg/webircgateway/transport_websocket.go +0 -126
- package/webircgateway/pkg/webircgateway/utils.go +0 -147
- package/webircgateway/plugins/example/plugin.go +0 -11
- package/webircgateway/plugins/stats/plugin.go +0 -52
- package/webircgateway/staticcheck.conf +0 -1
- package/webircgateway/webircgateway.svg +0 -3
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import {
|
|
2
|
+
type AccountStore,
|
|
2
3
|
CapturingLogger,
|
|
3
4
|
type ChanName,
|
|
4
5
|
type ChanSnapshot,
|
|
@@ -11,24 +12,33 @@ import {
|
|
|
11
12
|
FakeClock,
|
|
12
13
|
type FloodControlConfig,
|
|
13
14
|
InMemoryMessageStore,
|
|
15
|
+
InMemoryNickHistoryStore,
|
|
14
16
|
LogLevel,
|
|
15
17
|
type Logger,
|
|
16
18
|
type MessageStore,
|
|
19
|
+
type MtlsIdentityProvider,
|
|
17
20
|
type Nick,
|
|
21
|
+
type NickHistoryStore,
|
|
18
22
|
Numerics,
|
|
19
23
|
type RawLine,
|
|
24
|
+
type SaslPayload,
|
|
25
|
+
type SaslResult,
|
|
20
26
|
SequentialIdFactory,
|
|
21
27
|
type ServerConfig,
|
|
22
28
|
type StoredMessage,
|
|
23
29
|
applyChannelDelta as applyDelta,
|
|
24
30
|
createChannel,
|
|
25
31
|
createConnection,
|
|
32
|
+
encodeBase64,
|
|
26
33
|
isChannelTarget,
|
|
27
34
|
toChannelSnapshot,
|
|
35
|
+
toSnapshot,
|
|
28
36
|
} from '@serverless-ircd/irc-core';
|
|
29
37
|
import { describe, expect, it } from 'vitest';
|
|
30
38
|
import { ConnectionActor } from '../src/actor';
|
|
31
39
|
import type { IrcRuntime } from '../src/runtime';
|
|
40
|
+
import { TcpByteStreamTransport, WsTextFrameTransport } from '../src/transport';
|
|
41
|
+
import type { Transport } from '../src/transport';
|
|
32
42
|
|
|
33
43
|
// ---------------------------------------------------------------------------
|
|
34
44
|
// Fakes
|
|
@@ -116,7 +126,8 @@ class FakeRuntime implements IrcRuntime {
|
|
|
116
126
|
}
|
|
117
127
|
async getConnectionInfo(conn: ConnId): Promise<ConnSnapshot | null> {
|
|
118
128
|
this.calls.push({ method: 'getConnectionInfo', args: [conn] });
|
|
119
|
-
|
|
129
|
+
const state = this.testConnections.get(conn);
|
|
130
|
+
return state === undefined ? null : toSnapshot(state);
|
|
120
131
|
}
|
|
121
132
|
async getConnection(conn: ConnId): Promise<ConnectionState | null> {
|
|
122
133
|
this.calls.push({ method: 'getConnection', args: [conn] });
|
|
@@ -154,6 +165,11 @@ function makeActor(opts: {
|
|
|
154
165
|
logger?: Logger;
|
|
155
166
|
traceId?: string;
|
|
156
167
|
messages?: MessageStore;
|
|
168
|
+
accounts?: AccountStore;
|
|
169
|
+
mtlsIdentity?: MtlsIdentityProvider;
|
|
170
|
+
history?: NickHistoryStore;
|
|
171
|
+
operCreds?: ReadonlyArray<{ user: string; password: string }>;
|
|
172
|
+
transport?: Transport;
|
|
157
173
|
}): { actor: ConnectionActor; runtime: FakeRuntime; conn: ConnectionState } {
|
|
158
174
|
const clock = opts.clock ?? new FakeClock(1_000);
|
|
159
175
|
const conn = createConnection({ id: 'c1', connectedSince: 0 });
|
|
@@ -179,6 +195,7 @@ function makeActor(opts: {
|
|
|
179
195
|
topicLen: 390,
|
|
180
196
|
quitMessage: 'Client Quit',
|
|
181
197
|
...(opts.floodControl !== undefined ? { floodControl: opts.floodControl } : {}),
|
|
198
|
+
...(opts.operCreds !== undefined ? { operCreds: opts.operCreds } : {}),
|
|
182
199
|
};
|
|
183
200
|
const ids = new SequentialIdFactory();
|
|
184
201
|
type ActorOpts = ConstructorParameters<typeof ConnectionActor>[0];
|
|
@@ -203,6 +220,18 @@ function makeActor(opts: {
|
|
|
203
220
|
if (opts.messages !== undefined) {
|
|
204
221
|
finalOpts.messages = opts.messages;
|
|
205
222
|
}
|
|
223
|
+
if (opts.accounts !== undefined) {
|
|
224
|
+
finalOpts.accounts = opts.accounts;
|
|
225
|
+
}
|
|
226
|
+
if (opts.mtlsIdentity !== undefined) {
|
|
227
|
+
finalOpts.mtlsIdentity = opts.mtlsIdentity;
|
|
228
|
+
}
|
|
229
|
+
if (opts.history !== undefined) {
|
|
230
|
+
finalOpts.history = opts.history;
|
|
231
|
+
}
|
|
232
|
+
if (opts.transport !== undefined) {
|
|
233
|
+
finalOpts.transport = opts.transport;
|
|
234
|
+
}
|
|
206
235
|
const actor = new ConnectionActor(finalOpts);
|
|
207
236
|
return { actor, runtime, conn };
|
|
208
237
|
}
|
|
@@ -266,6 +295,112 @@ describe('ConnectionActor — framing', () => {
|
|
|
266
295
|
});
|
|
267
296
|
});
|
|
268
297
|
|
|
298
|
+
describe('ConnectionActor — TCP byte-stream transport seam', () => {
|
|
299
|
+
it('two TCP chunks split mid-line produce exactly one PRIVMSG broadcast', async () => {
|
|
300
|
+
const { actor, runtime } = makeActor({ transport: new TcpByteStreamTransport() });
|
|
301
|
+
await runtime.applyChannelDelta('#foo', {
|
|
302
|
+
memberships: [{ type: 'add', conn: 'c1', nick: 'alice' }],
|
|
303
|
+
});
|
|
304
|
+
runtime.calls.length = 0;
|
|
305
|
+
|
|
306
|
+
// Split the single message mid-payload across two chunks. The partial
|
|
307
|
+
// line is buffered across the first chunk; only the second completes it.
|
|
308
|
+
await actor.receive('PRIVMSG #foo :hel');
|
|
309
|
+
expect(runtime.calls).toHaveLength(0);
|
|
310
|
+
await actor.receive('lo\r\n');
|
|
311
|
+
|
|
312
|
+
const broadcasts = runtime.calls.filter((c) => c.method === 'broadcast');
|
|
313
|
+
expect(broadcasts).toHaveLength(1);
|
|
314
|
+
expect(broadcasts[0]?.args[0]).toBe('#foo');
|
|
315
|
+
const lines = broadcasts[0]?.args[1] as RawLine[];
|
|
316
|
+
expect(lines[0]?.text).toBe(':alice!alice@example.com PRIVMSG #foo :hello');
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it('one message per WS text frame and one per CRLF over TCP produce identical effects', async () => {
|
|
320
|
+
// Same bytes, same connection setup, two transports. The WS actor gets
|
|
321
|
+
// the whole frame in one receiveTextFrame; the TCP actor gets the same
|
|
322
|
+
// bytes split across arbitrary chunks via receive().
|
|
323
|
+
const wsSetup = makeActor({});
|
|
324
|
+
const tcpSetup = makeActor({ transport: new TcpByteStreamTransport() });
|
|
325
|
+
for (const rt of [wsSetup.runtime, tcpSetup.runtime]) {
|
|
326
|
+
await rt.applyChannelDelta('#foo', {
|
|
327
|
+
memberships: [{ type: 'add', conn: 'c1', nick: 'alice' }],
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
wsSetup.runtime.calls.length = 0;
|
|
331
|
+
tcpSetup.runtime.calls.length = 0;
|
|
332
|
+
|
|
333
|
+
const stream = 'PRIVMSG #foo :hello\r\n';
|
|
334
|
+
await wsSetup.actor.receiveTextFrame(stream);
|
|
335
|
+
// Feed the TCP actor the same bytes in 4-byte chunks.
|
|
336
|
+
for (let i = 0; i < stream.length; i += 4) {
|
|
337
|
+
await tcpSetup.actor.receive(stream.slice(i, i + 4));
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const wsBroadcast = wsSetup.runtime.calls.filter((c) => c.method === 'broadcast');
|
|
341
|
+
const tcpBroadcast = tcpSetup.runtime.calls.filter((c) => c.method === 'broadcast');
|
|
342
|
+
expect(tcpBroadcast).toHaveLength(wsBroadcast.length);
|
|
343
|
+
expect((tcpBroadcast[0]?.args[1] as RawLine[])[0]?.text).toBe(
|
|
344
|
+
(wsBroadcast[0]?.args[1] as RawLine[])[0]?.text,
|
|
345
|
+
);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it('buffers a partial line across multiple chunks with no loss or duplication', async () => {
|
|
349
|
+
const { actor, runtime } = makeActor({ transport: new TcpByteStreamTransport() });
|
|
350
|
+
await runtime.applyChannelDelta('#foo', {
|
|
351
|
+
memberships: [{ type: 'add', conn: 'c1', nick: 'alice' }],
|
|
352
|
+
});
|
|
353
|
+
runtime.calls.length = 0;
|
|
354
|
+
|
|
355
|
+
// Three messages, each fed byte-by-byte. Every complete line is emitted
|
|
356
|
+
// exactly once; nothing is lost or duplicated at the chunk boundaries.
|
|
357
|
+
const stream = 'PING :a\r\nPRIVMSG #foo :b\r\nPING :c\r\n';
|
|
358
|
+
for (const ch of stream) {
|
|
359
|
+
await actor.receive(ch);
|
|
360
|
+
}
|
|
361
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
362
|
+
const broadcasts = runtime.calls.filter((c) => c.method === 'broadcast');
|
|
363
|
+
expect(sends).toHaveLength(2); // PONG :a, PONG :c
|
|
364
|
+
expect((sends[0]?.args[1] as RawLine[])[0]?.text).toBe('PONG :a');
|
|
365
|
+
expect((sends[1]?.args[1] as RawLine[])[0]?.text).toBe('PONG :c');
|
|
366
|
+
expect(broadcasts).toHaveLength(1); // PRIVMSG #foo :b
|
|
367
|
+
expect((broadcasts[0]?.args[1] as RawLine[])[0]?.text).toBe(
|
|
368
|
+
':alice!alice@example.com PRIVMSG #foo :b',
|
|
369
|
+
);
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
it('uses the default WS-frame transport when none is injected (backward compat)', () => {
|
|
373
|
+
// No `transport` option → the actor still frames WS text frames exactly
|
|
374
|
+
// as before. Confirms the default is the stateless WS transport.
|
|
375
|
+
const { actor, runtime } = makeActor({});
|
|
376
|
+
// A trailing unterminated line is still emitted by the WS transport
|
|
377
|
+
// (frame = complete unit), so this produces one PONG with no terminator.
|
|
378
|
+
return actor.receiveTextFrame('PING :tok').then(() => {
|
|
379
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
380
|
+
expect(sends).toHaveLength(1);
|
|
381
|
+
expect((sends[0]?.args[1] as RawLine[])[0]?.text).toBe('PONG :tok');
|
|
382
|
+
});
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
it('a TCP actor does NOT emit a trailing unterminated line until the terminator arrives', async () => {
|
|
386
|
+
const tcpActor = makeActor({ transport: new TcpByteStreamTransport() });
|
|
387
|
+
const wsActor = makeActor({ transport: new WsTextFrameTransport() });
|
|
388
|
+
tcpActor.runtime.calls.length = 0;
|
|
389
|
+
wsActor.runtime.calls.length = 0;
|
|
390
|
+
|
|
391
|
+
// WS treats the frame as complete → emits a PONG. TCP buffers the
|
|
392
|
+
// unterminated line → emits nothing yet. This is the defining
|
|
393
|
+
// behavioural difference of stateful byte-stream framing.
|
|
394
|
+
await wsActor.actor.receiveTextFrame('PING :unterminated');
|
|
395
|
+
await tcpActor.actor.receive('PING :unterminated');
|
|
396
|
+
expect(wsActor.runtime.calls.filter((c) => c.method === 'send')).toHaveLength(1);
|
|
397
|
+
expect(tcpActor.runtime.calls).toHaveLength(0);
|
|
398
|
+
// …until the terminator completes the line.
|
|
399
|
+
await tcpActor.actor.receive('\r\n');
|
|
400
|
+
expect(tcpActor.runtime.calls.filter((c) => c.method === 'send')).toHaveLength(1);
|
|
401
|
+
});
|
|
402
|
+
});
|
|
403
|
+
|
|
269
404
|
describe('ConnectionActor — malformed input', () => {
|
|
270
405
|
it('emits 421 for an unparseable line and continues processing the rest', async () => {
|
|
271
406
|
const { actor, runtime } = makeActor({});
|
|
@@ -478,6 +613,57 @@ describe('ConnectionActor — additional routing', () => {
|
|
|
478
613
|
expect(runtime.calls.filter((c) => c.method === 'broadcast')).toHaveLength(1);
|
|
479
614
|
});
|
|
480
615
|
|
|
616
|
+
it('routes TAGMSG to a channel broadcast (never 421) when message-tags cap is present', async () => {
|
|
617
|
+
const bobConn = createConnection({ id: 'c2', connectedSince: 0 });
|
|
618
|
+
bobConn.nick = 'bob';
|
|
619
|
+
bobConn.user = 'bob';
|
|
620
|
+
bobConn.host = 'example.org';
|
|
621
|
+
bobConn.registration = 'registered';
|
|
622
|
+
bobConn.caps = new Set<string>(['message-tags']);
|
|
623
|
+
const { actor, runtime } = makeActor({
|
|
624
|
+
conn: { caps: new Set<string>(['message-tags']) },
|
|
625
|
+
});
|
|
626
|
+
runtime.addConnection(bobConn);
|
|
627
|
+
await runtime.applyChannelDelta('#foo', {
|
|
628
|
+
memberships: [
|
|
629
|
+
{ type: 'add', conn: 'c1', nick: 'alice' },
|
|
630
|
+
{ type: 'add', conn: 'c2', nick: 'bob' },
|
|
631
|
+
],
|
|
632
|
+
});
|
|
633
|
+
runtime.calls.length = 0;
|
|
634
|
+
await actor.receiveTextFrame('@+typing=active TAGMSG #foo\r\n');
|
|
635
|
+
|
|
636
|
+
// Cap-gated broadcast dispatches per-recipient via send (only to
|
|
637
|
+
// message-tags-enabled members, excluding the sender).
|
|
638
|
+
const sends = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c2');
|
|
639
|
+
expect(sends).toHaveLength(1);
|
|
640
|
+
const lines = sends[0]?.args[1] as RawLine[];
|
|
641
|
+
expect(lines[0]?.text).toBe('@+typing=active :alice!alice@example.com TAGMSG #foo');
|
|
642
|
+
// Critical: never 421 for a cap-enabled TAGMSG.
|
|
643
|
+
for (const call of runtime.calls) {
|
|
644
|
+
if (call.method === 'send') {
|
|
645
|
+
for (const line of call.args[1] as RawLine[]) {
|
|
646
|
+
expect(line.text).not.toContain(' 421 ');
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
});
|
|
651
|
+
|
|
652
|
+
it('still emits 421 for TAGMSG when message-tags cap is not negotiated', async () => {
|
|
653
|
+
const { actor, runtime } = makeActor({});
|
|
654
|
+
await runtime.applyChannelDelta('#foo', {
|
|
655
|
+
memberships: [{ type: 'add', conn: 'c1', nick: 'alice' }],
|
|
656
|
+
});
|
|
657
|
+
runtime.calls.length = 0;
|
|
658
|
+
await actor.receiveTextFrame('TAGMSG #foo\r\n');
|
|
659
|
+
|
|
660
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
661
|
+
expect(sends).toHaveLength(1);
|
|
662
|
+
const lines = sends[0]?.args[1] as RawLine[];
|
|
663
|
+
expect(lines[0]?.text).toContain(' 421 ');
|
|
664
|
+
expect(lines[0]?.text).toContain('TAGMSG');
|
|
665
|
+
});
|
|
666
|
+
|
|
481
667
|
it('routes PRIVMSG with mixed targets (#chan,nick) into 2 invocations', async () => {
|
|
482
668
|
const { actor, runtime } = makeActor({});
|
|
483
669
|
await runtime.applyChannelDelta('#foo', {
|
|
@@ -599,6 +785,76 @@ describe('ConnectionActor — additional routing', () => {
|
|
|
599
785
|
}
|
|
600
786
|
});
|
|
601
787
|
|
|
788
|
+
it('routes WHOWAS of a quit nick through whowasReducer (314 + 369, never 421)', async () => {
|
|
789
|
+
const history = new InMemoryNickHistoryStore(new FakeClock(1_000));
|
|
790
|
+
history.record({
|
|
791
|
+
nick: 'bob',
|
|
792
|
+
connId: 'c2',
|
|
793
|
+
username: 'bob',
|
|
794
|
+
hostname: 'example.com',
|
|
795
|
+
realname: 'Bob',
|
|
796
|
+
signoffTime: 500,
|
|
797
|
+
});
|
|
798
|
+
const { actor, runtime } = makeActor({ history });
|
|
799
|
+
runtime.calls.length = 0;
|
|
800
|
+
|
|
801
|
+
await actor.receiveTextFrame('WHOWAS bob\r\n');
|
|
802
|
+
|
|
803
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
804
|
+
expect(sends.length).toBeGreaterThanOrEqual(2);
|
|
805
|
+
const firstBatch = sends[0]?.args[1] as RawLine[];
|
|
806
|
+
expect(firstBatch[0]?.text).toContain(' 314 ');
|
|
807
|
+
expect(firstBatch[0]?.text).toContain(' bob ');
|
|
808
|
+
const endBatch = sends[sends.length - 1]?.args[1] as RawLine[];
|
|
809
|
+
expect(endBatch[0]?.text).toContain(' 369 ');
|
|
810
|
+
for (const s of sends) {
|
|
811
|
+
for (const line of s.args[1] as RawLine[]) {
|
|
812
|
+
expect(line.text).not.toContain(' 421 ');
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
});
|
|
816
|
+
|
|
817
|
+
it('routes WHOWAS of an unknown nick to 406 then 369 (never 421)', async () => {
|
|
818
|
+
const history = new InMemoryNickHistoryStore(new FakeClock(1_000));
|
|
819
|
+
const { actor, runtime } = makeActor({ history });
|
|
820
|
+
runtime.calls.length = 0;
|
|
821
|
+
|
|
822
|
+
await actor.receiveTextFrame('WHOWAS ghost\r\n');
|
|
823
|
+
|
|
824
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
825
|
+
expect(sends).toHaveLength(2);
|
|
826
|
+
const notFound = sends[0]?.args[1] as RawLine[];
|
|
827
|
+
expect(notFound[0]?.text).toContain(' 406 ');
|
|
828
|
+
expect(notFound[0]?.text).toContain(' ghost ');
|
|
829
|
+
const end = sends[1]?.args[1] as RawLine[];
|
|
830
|
+
expect(end[0]?.text).toContain(' 369 ');
|
|
831
|
+
});
|
|
832
|
+
|
|
833
|
+
it('still emits 406 + 369 for WHOWAS when no history store is bound (no crash)', async () => {
|
|
834
|
+
const { actor, runtime } = makeActor({});
|
|
835
|
+
runtime.calls.length = 0;
|
|
836
|
+
|
|
837
|
+
await actor.receiveTextFrame('WHOWAS ghost\r\n');
|
|
838
|
+
|
|
839
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
840
|
+
expect(sends).toHaveLength(2);
|
|
841
|
+
const notFound = sends[0]?.args[1] as RawLine[];
|
|
842
|
+
expect(notFound[0]?.text).toContain(' 406 ');
|
|
843
|
+
const end = sends[1]?.args[1] as RawLine[];
|
|
844
|
+
expect(end[0]?.text).toContain(' 369 ');
|
|
845
|
+
});
|
|
846
|
+
|
|
847
|
+
it('records a nick into history on QUIT when a store is bound', async () => {
|
|
848
|
+
const history = new InMemoryNickHistoryStore(new FakeClock(1_000));
|
|
849
|
+
const { actor } = makeActor({ history });
|
|
850
|
+
|
|
851
|
+
await actor.receiveTextFrame('QUIT\r\n');
|
|
852
|
+
|
|
853
|
+
const entries = history.query('alice', 10);
|
|
854
|
+
expect(entries).toHaveLength(1);
|
|
855
|
+
expect(entries[0]?.nick).toBe('alice');
|
|
856
|
+
});
|
|
857
|
+
|
|
602
858
|
it('routes AUTHENTICATE PLAIN (post CAP REQ sasl) through authenticateReducer, never 421', async () => {
|
|
603
859
|
// Pre-registration: client has opened CAP negotiation and requested sasl.
|
|
604
860
|
// AUTHENTICATE must reach the SASL reducer, not the 421 fallthrough.
|
|
@@ -712,6 +968,63 @@ describe('ConnectionActor — additional routing', () => {
|
|
|
712
968
|
}
|
|
713
969
|
});
|
|
714
970
|
|
|
971
|
+
it('routes OPER with matching creds through operReducer (381, never 421)', async () => {
|
|
972
|
+
const { actor, runtime, conn } = makeActor({
|
|
973
|
+
operCreds: [{ user: 'alice', password: 'secret' }],
|
|
974
|
+
});
|
|
975
|
+
runtime.calls.length = 0;
|
|
976
|
+
|
|
977
|
+
await actor.receiveTextFrame('OPER alice secret\r\n');
|
|
978
|
+
|
|
979
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
980
|
+
expect(sends).toHaveLength(1);
|
|
981
|
+
const lines = sends[0]?.args[1] as RawLine[];
|
|
982
|
+
expect(lines[0]?.text).toBe(':irc.example.com 381 alice :You are now an IRC operator');
|
|
983
|
+
expect(conn.userModes.oper).toBe(true);
|
|
984
|
+
// Critical: never 421 for OPER.
|
|
985
|
+
for (const s of sends) {
|
|
986
|
+
for (const line of s.args[1] as RawLine[]) {
|
|
987
|
+
expect(line.text).not.toContain(' 421 ');
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
});
|
|
991
|
+
|
|
992
|
+
it('routes OPER with wrong creds through operReducer (464, never 421)', async () => {
|
|
993
|
+
const { actor, runtime } = makeActor({
|
|
994
|
+
operCreds: [{ user: 'alice', password: 'secret' }],
|
|
995
|
+
});
|
|
996
|
+
runtime.calls.length = 0;
|
|
997
|
+
|
|
998
|
+
await actor.receiveTextFrame('OPER alice wrong\r\n');
|
|
999
|
+
|
|
1000
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
1001
|
+
expect(sends).toHaveLength(1);
|
|
1002
|
+
const lines = sends[0]?.args[1] as RawLine[];
|
|
1003
|
+
expect(lines[0]?.text).toContain(' 464 ');
|
|
1004
|
+
for (const s of sends) {
|
|
1005
|
+
for (const line of s.args[1] as RawLine[]) {
|
|
1006
|
+
expect(line.text).not.toContain(' 421 ');
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
});
|
|
1010
|
+
|
|
1011
|
+
it('routes OPER with no configured creds through operReducer (491, never 421)', async () => {
|
|
1012
|
+
const { actor, runtime } = makeActor({});
|
|
1013
|
+
runtime.calls.length = 0;
|
|
1014
|
+
|
|
1015
|
+
await actor.receiveTextFrame('OPER alice secret\r\n');
|
|
1016
|
+
|
|
1017
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
1018
|
+
expect(sends).toHaveLength(1);
|
|
1019
|
+
const lines = sends[0]?.args[1] as RawLine[];
|
|
1020
|
+
expect(lines[0]?.text).toContain(' 491 ');
|
|
1021
|
+
for (const s of sends) {
|
|
1022
|
+
for (const line of s.args[1] as RawLine[]) {
|
|
1023
|
+
expect(line.text).not.toContain(' 421 ');
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
});
|
|
1027
|
+
|
|
715
1028
|
it('emits 421 with empty command when routeSingleChannel gets no channel (TOPIC)', async () => {
|
|
716
1029
|
const { actor, runtime } = makeActor({});
|
|
717
1030
|
await actor.receiveTextFrame('TOPIC\r\n');
|
|
@@ -1270,3 +1583,306 @@ describe('ConnectionActor — CHATHISTORY', () => {
|
|
|
1270
1583
|
// …but there is no store to record into.
|
|
1271
1584
|
});
|
|
1272
1585
|
});
|
|
1586
|
+
|
|
1587
|
+
// ===========================================================================
|
|
1588
|
+
// SASL / AccountStore — actor plumbing (ctx.accounts threaded through buildCtx)
|
|
1589
|
+
// ===========================================================================
|
|
1590
|
+
|
|
1591
|
+
describe('ConnectionActor — SASL AccountStore plumbing', () => {
|
|
1592
|
+
/** Capture-and-respond fake AccountStore (mirrors the irc-core sasl tests). */
|
|
1593
|
+
class FakeAccountStore implements AccountStore {
|
|
1594
|
+
readonly calls: Array<{ mech: string; payload: SaslPayload }> = [];
|
|
1595
|
+
constructor(private readonly result: SaslResult) {}
|
|
1596
|
+
verify(mech: string, payload: SaslPayload): SaslResult {
|
|
1597
|
+
this.calls.push({ mech, payload });
|
|
1598
|
+
return this.result;
|
|
1599
|
+
}
|
|
1600
|
+
}
|
|
1601
|
+
|
|
1602
|
+
/** Builds the PLAIN base64 payload `authzid\0authcid\0password`. */
|
|
1603
|
+
function plainPayload(authcid: string, password: string): string {
|
|
1604
|
+
return encodeBase64(`\0${authcid}\0${password}`);
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
/** A connection that has opened CAP negotiation and requested `sasl`. */
|
|
1608
|
+
function saslReadyConn(): Partial<ConnectionState> {
|
|
1609
|
+
return {
|
|
1610
|
+
registration: 'pre-registration',
|
|
1611
|
+
capNegotiating: true,
|
|
1612
|
+
caps: new Set<string>(['sasl']),
|
|
1613
|
+
};
|
|
1614
|
+
}
|
|
1615
|
+
|
|
1616
|
+
it('completes SASL PLAIN with 900/903 when a bound AccountStore accepts creds', async () => {
|
|
1617
|
+
const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
|
|
1618
|
+
const { actor, runtime, conn } = makeActor({
|
|
1619
|
+
conn: saslReadyConn(),
|
|
1620
|
+
accounts,
|
|
1621
|
+
});
|
|
1622
|
+
conn.nick = 'alice';
|
|
1623
|
+
runtime.calls.length = 0;
|
|
1624
|
+
|
|
1625
|
+
await actor.receiveTextFrame('AUTHENTICATE PLAIN\r\n');
|
|
1626
|
+
await actor.receiveTextFrame(`AUTHENTICATE ${plainPayload('alice', 'secret')}\r\n`);
|
|
1627
|
+
|
|
1628
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
1629
|
+
// Second frame emits the success numerics; gather every sent line.
|
|
1630
|
+
const lines = sends.flatMap((s) => s.args[1] as RawLine[]);
|
|
1631
|
+
expect(lines.some((l) => l.text.includes(' 900 '))).toBe(true);
|
|
1632
|
+
expect(lines.some((l) => l.text.includes(' 903 '))).toBe(true);
|
|
1633
|
+
// Critical: never 904 for valid creds.
|
|
1634
|
+
expect(lines.some((l) => l.text.includes(' 904 '))).toBe(false);
|
|
1635
|
+
// The AccountStore was consulted with the parsed PLAIN credentials.
|
|
1636
|
+
expect(accounts.calls).toEqual([
|
|
1637
|
+
{ mech: 'PLAIN', payload: { kind: 'PLAIN', username: 'alice', password: 'secret' } },
|
|
1638
|
+
]);
|
|
1639
|
+
// Account name recorded on the connection for extended-join/account-tag.
|
|
1640
|
+
expect(conn.account).toBe('alice');
|
|
1641
|
+
});
|
|
1642
|
+
|
|
1643
|
+
it('emits 904 ERR_SASLFAIL when no AccountStore is bound (regression guard)', async () => {
|
|
1644
|
+
const { actor, runtime, conn } = makeActor({
|
|
1645
|
+
conn: saslReadyConn(),
|
|
1646
|
+
// No `accounts` option → ctx.accounts is undefined.
|
|
1647
|
+
});
|
|
1648
|
+
conn.nick = 'alice';
|
|
1649
|
+
runtime.calls.length = 0;
|
|
1650
|
+
|
|
1651
|
+
await actor.receiveTextFrame('AUTHENTICATE PLAIN\r\n');
|
|
1652
|
+
await actor.receiveTextFrame(`AUTHENTICATE ${plainPayload('alice', 'secret')}\r\n`);
|
|
1653
|
+
|
|
1654
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
1655
|
+
const lines = sends.flatMap((s) => s.args[1] as RawLine[]);
|
|
1656
|
+
expect(lines.some((l) => l.text.includes(' 904 '))).toBe(true);
|
|
1657
|
+
// No success numerics.
|
|
1658
|
+
expect(lines.some((l) => l.text.includes(' 900 '))).toBe(false);
|
|
1659
|
+
expect(lines.some((l) => l.text.includes(' 903 '))).toBe(false);
|
|
1660
|
+
// Account name never recorded.
|
|
1661
|
+
expect(conn.account).toBeUndefined();
|
|
1662
|
+
});
|
|
1663
|
+
|
|
1664
|
+
it('emits 904 when a bound AccountStore rejects the credentials', async () => {
|
|
1665
|
+
const accounts = new FakeAccountStore({ ok: false, reason: 'invalid credentials' });
|
|
1666
|
+
const { actor, runtime, conn } = makeActor({
|
|
1667
|
+
conn: saslReadyConn(),
|
|
1668
|
+
accounts,
|
|
1669
|
+
});
|
|
1670
|
+
conn.nick = 'alice';
|
|
1671
|
+
runtime.calls.length = 0;
|
|
1672
|
+
|
|
1673
|
+
await actor.receiveTextFrame('AUTHENTICATE PLAIN\r\n');
|
|
1674
|
+
await actor.receiveTextFrame(`AUTHENTICATE ${plainPayload('alice', 'wrong')}\r\n`);
|
|
1675
|
+
|
|
1676
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
1677
|
+
const lines = sends.flatMap((s) => s.args[1] as RawLine[]);
|
|
1678
|
+
expect(lines.some((l) => l.text.includes(' 904 '))).toBe(true);
|
|
1679
|
+
expect(lines.some((l) => l.text.includes(' 903 '))).toBe(false);
|
|
1680
|
+
expect(conn.account).toBeUndefined();
|
|
1681
|
+
});
|
|
1682
|
+
});
|
|
1683
|
+
|
|
1684
|
+
describe('ConnectionActor — SASL EXTERNAL + mTLS plumbing', () => {
|
|
1685
|
+
/** Fake AccountStore that accepts EXTERNAL with a known cert subject. */
|
|
1686
|
+
class FakeAccountStore implements AccountStore {
|
|
1687
|
+
readonly calls: Array<{ mech: string; payload: SaslPayload }> = [];
|
|
1688
|
+
constructor(private readonly result: SaslResult) {}
|
|
1689
|
+
verify(mech: string, payload: SaslPayload): SaslResult {
|
|
1690
|
+
this.calls.push({ mech, payload });
|
|
1691
|
+
return this.result;
|
|
1692
|
+
}
|
|
1693
|
+
}
|
|
1694
|
+
|
|
1695
|
+
/** Fake MtlsIdentityProvider returning a canned subject for 'c1'. */
|
|
1696
|
+
class FakeMtlsProvider implements MtlsIdentityProvider {
|
|
1697
|
+
constructor(private readonly identity: string | undefined) {}
|
|
1698
|
+
getIdentity(connId: string): string | undefined {
|
|
1699
|
+
return connId === 'c1' ? this.identity : undefined;
|
|
1700
|
+
}
|
|
1701
|
+
}
|
|
1702
|
+
|
|
1703
|
+
function saslReadyConn(): Partial<ConnectionState> {
|
|
1704
|
+
return {
|
|
1705
|
+
registration: 'pre-registration',
|
|
1706
|
+
capNegotiating: true,
|
|
1707
|
+
caps: new Set<string>(['sasl']),
|
|
1708
|
+
};
|
|
1709
|
+
}
|
|
1710
|
+
|
|
1711
|
+
it('completes SASL EXTERNAL with 900/903 when mTLS provider and store accept', async () => {
|
|
1712
|
+
const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
|
|
1713
|
+
const mtlsIdentity = new FakeMtlsProvider('CN=alice');
|
|
1714
|
+
const { actor, runtime, conn } = makeActor({
|
|
1715
|
+
conn: saslReadyConn(),
|
|
1716
|
+
accounts,
|
|
1717
|
+
mtlsIdentity,
|
|
1718
|
+
});
|
|
1719
|
+
conn.nick = 'alice';
|
|
1720
|
+
runtime.calls.length = 0;
|
|
1721
|
+
|
|
1722
|
+
await actor.receiveTextFrame('AUTHENTICATE EXTERNAL\r\n');
|
|
1723
|
+
await actor.receiveTextFrame('AUTHENTICATE +\r\n');
|
|
1724
|
+
|
|
1725
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
1726
|
+
const lines = sends.flatMap((s) => s.args[1] as RawLine[]);
|
|
1727
|
+
expect(lines.some((l) => l.text.includes(' 900 '))).toBe(true);
|
|
1728
|
+
expect(lines.some((l) => l.text.includes(' 903 '))).toBe(true);
|
|
1729
|
+
expect(lines.some((l) => l.text.includes(' 904 '))).toBe(false);
|
|
1730
|
+
expect(accounts.calls).toEqual([
|
|
1731
|
+
{ mech: 'EXTERNAL', payload: { kind: 'EXTERNAL', identity: 'CN=alice' } },
|
|
1732
|
+
]);
|
|
1733
|
+
expect(conn.account).toBe('alice');
|
|
1734
|
+
});
|
|
1735
|
+
|
|
1736
|
+
it('returns 908 for EXTERNAL when no mTLS provider is bound', async () => {
|
|
1737
|
+
const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
|
|
1738
|
+
const { actor, runtime, conn } = makeActor({
|
|
1739
|
+
conn: saslReadyConn(),
|
|
1740
|
+
accounts,
|
|
1741
|
+
// No mtlsIdentity → EXTERNAL should 908 at mechanism phase.
|
|
1742
|
+
});
|
|
1743
|
+
conn.nick = 'alice';
|
|
1744
|
+
runtime.calls.length = 0;
|
|
1745
|
+
|
|
1746
|
+
await actor.receiveTextFrame('AUTHENTICATE EXTERNAL\r\n');
|
|
1747
|
+
|
|
1748
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
1749
|
+
const lines = sends.flatMap((s) => s.args[1] as RawLine[]);
|
|
1750
|
+
expect(lines.some((l) => l.text.includes(' 908 '))).toBe(true);
|
|
1751
|
+
expect(lines.some((l) => l.text.includes(' 904 '))).toBe(false);
|
|
1752
|
+
expect(conn.account).toBeUndefined();
|
|
1753
|
+
});
|
|
1754
|
+
});
|
|
1755
|
+
|
|
1756
|
+
describe('ConnectionActor — server-info commands (VERSION/TIME/ADMIN/INFO/USERHOST/ISON)', () => {
|
|
1757
|
+
it('routes VERSION through versionReducer (351, never 421)', async () => {
|
|
1758
|
+
const { actor, runtime } = makeActor({});
|
|
1759
|
+
runtime.calls.length = 0;
|
|
1760
|
+
|
|
1761
|
+
await actor.receiveTextFrame('VERSION\r\n');
|
|
1762
|
+
|
|
1763
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
1764
|
+
expect(sends).toHaveLength(1);
|
|
1765
|
+
const lines = sends[0]?.args[1] as RawLine[];
|
|
1766
|
+
expect(lines[0]?.text).toContain(' 351 ');
|
|
1767
|
+
expect(lines[0]?.text).not.toContain(' 421 ');
|
|
1768
|
+
});
|
|
1769
|
+
|
|
1770
|
+
it('routes TIME through timeReducer (391, never 421)', async () => {
|
|
1771
|
+
const { actor, runtime } = makeActor({});
|
|
1772
|
+
runtime.calls.length = 0;
|
|
1773
|
+
|
|
1774
|
+
await actor.receiveTextFrame('TIME\r\n');
|
|
1775
|
+
|
|
1776
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
1777
|
+
expect(sends).toHaveLength(1);
|
|
1778
|
+
const lines = sends[0]?.args[1] as RawLine[];
|
|
1779
|
+
expect(lines[0]?.text).toContain(' 391 ');
|
|
1780
|
+
expect(lines[0]?.text).not.toContain(' 421 ');
|
|
1781
|
+
});
|
|
1782
|
+
|
|
1783
|
+
it('routes ADMIN through adminReducer (256, never 421)', async () => {
|
|
1784
|
+
const { actor, runtime } = makeActor({});
|
|
1785
|
+
runtime.calls.length = 0;
|
|
1786
|
+
|
|
1787
|
+
await actor.receiveTextFrame('ADMIN\r\n');
|
|
1788
|
+
|
|
1789
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
1790
|
+
expect(sends).toHaveLength(1);
|
|
1791
|
+
const lines = sends[0]?.args[1] as RawLine[];
|
|
1792
|
+
expect(lines[0]?.text).toContain(' 256 ');
|
|
1793
|
+
expect(lines[0]?.text).not.toContain(' 421 ');
|
|
1794
|
+
});
|
|
1795
|
+
|
|
1796
|
+
it('routes INFO through infoReducer (371 + 374, never 421)', async () => {
|
|
1797
|
+
const { actor, runtime } = makeActor({});
|
|
1798
|
+
runtime.calls.length = 0;
|
|
1799
|
+
|
|
1800
|
+
await actor.receiveTextFrame('INFO\r\n');
|
|
1801
|
+
|
|
1802
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
1803
|
+
expect(sends).toHaveLength(1);
|
|
1804
|
+
const lines = sends[0]?.args[1] as RawLine[];
|
|
1805
|
+
expect(lines.some((l) => l.text.includes(' 371 '))).toBe(true);
|
|
1806
|
+
expect(lines.some((l) => l.text.includes(' 374 '))).toBe(true);
|
|
1807
|
+
expect(lines.every((l) => !l.text.includes(' 421 '))).toBe(true);
|
|
1808
|
+
});
|
|
1809
|
+
|
|
1810
|
+
it('routes USERHOST resolving online nicks (302, never 421)', async () => {
|
|
1811
|
+
const { actor, runtime, conn } = makeActor({});
|
|
1812
|
+
const bobState = createConnection({ id: 'c2', connectedSince: 0 });
|
|
1813
|
+
bobState.nick = 'bob';
|
|
1814
|
+
bobState.user = 'bobuser';
|
|
1815
|
+
bobState.host = 'bobhost';
|
|
1816
|
+
bobState.registration = 'registered';
|
|
1817
|
+
runtime.addConnection(conn);
|
|
1818
|
+
runtime.addConnection(bobState);
|
|
1819
|
+
runtime.calls.length = 0;
|
|
1820
|
+
|
|
1821
|
+
await actor.receiveTextFrame('USERHOST bob ghost\r\n');
|
|
1822
|
+
|
|
1823
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
1824
|
+
expect(sends).toHaveLength(1);
|
|
1825
|
+
const lines = sends[0]?.args[1] as RawLine[];
|
|
1826
|
+
expect(lines[0]?.text).toContain(' 302 ');
|
|
1827
|
+
expect(lines[0]?.text).toContain('bob=+bobuser@bobhost');
|
|
1828
|
+
// Offline nick `ghost` is omitted from the reply.
|
|
1829
|
+
expect(lines[0]?.text).not.toContain('ghost');
|
|
1830
|
+
expect(lines[0]?.text).not.toContain(' 421 ');
|
|
1831
|
+
});
|
|
1832
|
+
|
|
1833
|
+
it('USERHOST marks away users with - and opers with *', async () => {
|
|
1834
|
+
const { actor, runtime, conn } = makeActor({});
|
|
1835
|
+
const bobState = createConnection({ id: 'c2', connectedSince: 0 });
|
|
1836
|
+
bobState.nick = 'bob';
|
|
1837
|
+
bobState.user = 'bobuser';
|
|
1838
|
+
bobState.host = 'bobhost';
|
|
1839
|
+
bobState.away = 'brb';
|
|
1840
|
+
bobState.userModes.oper = true;
|
|
1841
|
+
bobState.registration = 'registered';
|
|
1842
|
+
runtime.addConnection(conn);
|
|
1843
|
+
runtime.addConnection(bobState);
|
|
1844
|
+
runtime.calls.length = 0;
|
|
1845
|
+
|
|
1846
|
+
await actor.receiveTextFrame('USERHOST bob\r\n');
|
|
1847
|
+
|
|
1848
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
1849
|
+
const lines = sends[0]?.args[1] as RawLine[];
|
|
1850
|
+
expect(lines[0]?.text).toContain('bob*=-bobuser@bobhost');
|
|
1851
|
+
});
|
|
1852
|
+
|
|
1853
|
+
it('routes ISON resolving online nicks (303, never 421)', async () => {
|
|
1854
|
+
const { actor, runtime, conn } = makeActor({});
|
|
1855
|
+
const bobState = createConnection({ id: 'c2', connectedSince: 0 });
|
|
1856
|
+
bobState.nick = 'bob';
|
|
1857
|
+
bobState.registration = 'registered';
|
|
1858
|
+
runtime.addConnection(conn);
|
|
1859
|
+
runtime.addConnection(bobState);
|
|
1860
|
+
runtime.calls.length = 0;
|
|
1861
|
+
|
|
1862
|
+
await actor.receiveTextFrame('ISON bob ghost\r\n');
|
|
1863
|
+
|
|
1864
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
1865
|
+
expect(sends).toHaveLength(1);
|
|
1866
|
+
const lines = sends[0]?.args[1] as RawLine[];
|
|
1867
|
+
expect(lines[0]?.text).toContain(' 303 ');
|
|
1868
|
+
expect(lines[0]?.text).toContain('bob');
|
|
1869
|
+
expect(lines[0]?.text).not.toContain('ghost');
|
|
1870
|
+
expect(lines[0]?.text).not.toContain(' 421 ');
|
|
1871
|
+
});
|
|
1872
|
+
|
|
1873
|
+
it('ISON matches case-insensitively and reports the registered spelling', async () => {
|
|
1874
|
+
const { actor, runtime, conn } = makeActor({});
|
|
1875
|
+
const bobState = createConnection({ id: 'c2', connectedSince: 0 });
|
|
1876
|
+
bobState.nick = 'Bob';
|
|
1877
|
+
bobState.registration = 'registered';
|
|
1878
|
+
runtime.addConnection(conn);
|
|
1879
|
+
runtime.addConnection(bobState);
|
|
1880
|
+
runtime.calls.length = 0;
|
|
1881
|
+
|
|
1882
|
+
await actor.receiveTextFrame('ISON BOB\r\n');
|
|
1883
|
+
|
|
1884
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
1885
|
+
const lines = sends[0]?.args[1] as RawLine[];
|
|
1886
|
+
expect(lines[0]?.text).toContain(':Bob');
|
|
1887
|
+
});
|
|
1888
|
+
});
|