serverless-ircd 0.1.0 → 0.3.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 +96 -2
- package/.github/workflows/deploy-aws.yml +129 -0
- package/.github/workflows/deploy-cf.yml +0 -2
- package/.gitmodules +3 -0
- package/CHANGELOG.md +436 -0
- package/README.md +127 -84
- package/apps/aws-stack/README.md +73 -0
- package/apps/aws-stack/bin/aws.ts +49 -0
- package/apps/aws-stack/cdk.json +10 -0
- package/apps/aws-stack/package.json +41 -0
- package/apps/aws-stack/scripts/smoke-helpers.d.mts +22 -0
- package/apps/aws-stack/scripts/smoke-helpers.mjs +89 -0
- package/apps/aws-stack/scripts/smoke.mjs +142 -0
- package/apps/aws-stack/src/aws-stack.ts +263 -0
- package/apps/aws-stack/src/tables.ts +10 -0
- package/apps/aws-stack/tests/localstack.test.ts +46 -0
- package/apps/aws-stack/tests/smoke-helpers.test.ts +98 -0
- package/apps/aws-stack/tests/stack.test.ts +464 -0
- package/apps/aws-stack/tsconfig.build.json +11 -0
- package/apps/aws-stack/tsconfig.test.json +8 -0
- package/apps/aws-stack/vitest.config.ts +20 -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 +5 -1
- package/apps/cf-worker/wrangler.toml +66 -17
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/config-loader.ts +57 -0
- package/apps/local-cli/src/main.ts +1 -1
- package/apps/local-cli/src/server.ts +267 -32
- package/apps/local-cli/tests/config-loader.test.ts +107 -0
- package/apps/local-cli/tests/e2e.test.ts +126 -0
- package/apps/local-cli/tests/security-e2e.test.ts +239 -0
- package/biome.json +3 -1
- package/docs/ADR-001-pure-reducers-and-effect-system.md +74 -0
- package/docs/ADR-002-location-of-authority.md +82 -0
- package/docs/ADR-003-durable-object-sharding.md +93 -0
- package/docs/ADR-004-dynamodb-schema.md +96 -0
- package/docs/ADR-005-wss-only-transport-v1.md +83 -0
- package/docs/ADR-006-sasl-mechanism-scope.md +86 -0
- package/docs/ADR-007-deterministic-ports.md +82 -0
- package/docs/ADR-008-monorepo-tooling.md +60 -0
- package/docs/AWS-Adapter-Architecture.md +496 -0
- package/docs/AWS-Deployment.md +1186 -0
- package/docs/Cloudflare-Deployment-Guide.md +660 -0
- package/docs/Home.md +11 -0
- package/docs/Observability.md +87 -0
- package/docs/PlanIRCv3Websocket.md +489 -0
- package/docs/PlanWebClient.md +451 -0
- package/docs/Release-Process.md +443 -0
- package/package.json +19 -14
- package/packages/aws-adapter/README.md +35 -0
- package/packages/aws-adapter/package.json +52 -0
- package/packages/aws-adapter/src/account-store.ts +121 -0
- package/packages/aws-adapter/src/aws-runtime.ts +871 -0
- package/packages/aws-adapter/src/cdk-table-defs.ts +73 -0
- package/packages/aws-adapter/src/config-loader.ts +126 -0
- package/packages/aws-adapter/src/dynamo-account-store.ts +156 -0
- package/packages/aws-adapter/src/dynamo.ts +61 -0
- package/packages/aws-adapter/src/handlers/connect.ts +44 -0
- package/packages/aws-adapter/src/handlers/default.ts +330 -0
- package/packages/aws-adapter/src/handlers/disconnect.ts +48 -0
- package/packages/aws-adapter/src/handlers/index.ts +293 -0
- package/packages/aws-adapter/src/handlers/ping-checker.ts +217 -0
- package/packages/aws-adapter/src/handlers/state.ts +13 -0
- package/packages/aws-adapter/src/handlers/sweeper.ts +84 -0
- package/packages/aws-adapter/src/index.ts +74 -0
- package/packages/aws-adapter/src/message-store.ts +34 -0
- package/packages/aws-adapter/src/serialize.ts +283 -0
- package/packages/aws-adapter/src/tables.ts +53 -0
- package/packages/aws-adapter/tests/account-store-dynamo.test.ts +171 -0
- package/packages/aws-adapter/tests/account-store.test.ts +280 -0
- package/packages/aws-adapter/tests/aws-harness.ts +408 -0
- package/packages/aws-adapter/tests/aws-integration.test.ts +17 -0
- package/packages/aws-adapter/tests/aws-runtime.test.ts +654 -0
- package/packages/aws-adapter/tests/config-loader.test.ts +213 -0
- package/packages/aws-adapter/tests/disconnect-fanout.test.ts +336 -0
- package/packages/aws-adapter/tests/dynamo.test.ts +57 -0
- package/packages/aws-adapter/tests/global-setup.ts +301 -0
- package/packages/aws-adapter/tests/gone-exception.test.ts +271 -0
- package/packages/aws-adapter/tests/handlers.test.ts +473 -0
- package/packages/aws-adapter/tests/message-store.test.ts +55 -0
- package/packages/aws-adapter/tests/ping-checker.test.ts +571 -0
- package/packages/aws-adapter/tests/serialize.test.ts +249 -0
- package/packages/aws-adapter/tests/smoke.test.ts +48 -0
- package/packages/aws-adapter/tests/sweeper.test.ts +420 -0
- package/packages/aws-adapter/tests/tables.test.ts +74 -0
- package/packages/aws-adapter/tests/transactions.test.ts +446 -0
- package/packages/aws-adapter/tsconfig.build.json +16 -0
- package/packages/aws-adapter/tsconfig.test.json +14 -0
- package/packages/aws-adapter/vitest.config.ts +23 -0
- package/packages/cf-adapter/package.json +2 -1
- package/packages/cf-adapter/src/cf-runtime.ts +42 -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 +135 -0
- package/packages/cf-adapter/src/connection-do.ts +119 -0
- package/packages/cf-adapter/src/env.ts +27 -1
- package/packages/cf-adapter/src/index.ts +2 -1
- package/packages/cf-adapter/tests/cf-harness.ts +2 -2
- package/packages/cf-adapter/tests/cf-integration.test.ts +2 -2
- package/packages/cf-adapter/tests/cf-runtime.test.ts +91 -0
- package/packages/cf-adapter/tests/config-loader.test.ts +149 -0
- package/packages/cf-adapter/tests/connection-do.test.ts +82 -0
- package/packages/cf-adapter/tests/worker/main.ts +2 -1
- package/packages/cf-adapter/tests/worker/stubs/channel-stub.ts +7 -1
- package/packages/cf-adapter/wrangler.test.toml +10 -1
- package/packages/in-memory-runtime/package.json +1 -1
- package/packages/in-memory-runtime/src/in-memory-runtime.ts +107 -16
- package/packages/in-memory-runtime/src/index.ts +1 -1
- package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +134 -0
- package/packages/irc-core/package.json +13 -2
- package/packages/irc-core/src/admission.ts +216 -0
- package/packages/irc-core/src/caps/capabilities.ts +1 -0
- package/packages/irc-core/src/case-fold.ts +64 -0
- package/packages/irc-core/src/cloak.ts +81 -0
- package/packages/irc-core/src/commands/cap.ts +1 -2
- package/packages/irc-core/src/commands/chathistory.ts +305 -0
- package/packages/irc-core/src/commands/index.ts +9 -0
- package/packages/irc-core/src/commands/invite.ts +6 -9
- package/packages/irc-core/src/commands/isupport.ts +1 -1
- package/packages/irc-core/src/commands/join.ts +63 -10
- 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/privmsg.ts +24 -0
- package/packages/irc-core/src/commands/registration.ts +79 -21
- package/packages/irc-core/src/commands/sasl.ts +251 -0
- package/packages/irc-core/src/commands/tagmsg.ts +205 -0
- package/packages/irc-core/src/config.ts +270 -0
- package/packages/irc-core/src/flood-control.ts +175 -0
- package/packages/irc-core/src/index.ts +7 -0
- package/packages/irc-core/src/ports.ts +719 -0
- package/packages/irc-core/src/protocol/base64.ts +16 -0
- package/packages/irc-core/src/protocol/index.ts +2 -1
- package/packages/irc-core/src/protocol/numerics.ts +11 -0
- package/packages/irc-core/src/protocol/parser.ts +27 -2
- package/packages/irc-core/src/state/connection.ts +41 -0
- package/packages/irc-core/src/types.ts +88 -2
- package/packages/irc-core/stryker.commands.conf.json +41 -0
- package/packages/irc-core/stryker.protocol.conf.json +26 -0
- package/packages/irc-core/tests/account-store.test.ts +88 -0
- package/packages/irc-core/tests/admission.test.ts +229 -0
- package/packages/irc-core/tests/caps/capabilities.test.ts +22 -0
- package/packages/irc-core/tests/case-fold.test.ts +80 -0
- package/packages/irc-core/tests/cloak.test.ts +78 -0
- package/packages/irc-core/tests/commands/chathistory.test.ts +996 -0
- package/packages/irc-core/tests/commands/join.test.ts +246 -2
- 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/privmsg.test.ts +146 -2
- package/packages/irc-core/tests/commands/registration.test.ts +380 -20
- package/packages/irc-core/tests/commands/sasl.test.ts +536 -0
- package/packages/irc-core/tests/commands/tagmsg.test.ts +688 -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/config.test.ts +721 -0
- package/packages/irc-core/tests/flood-control.test.ts +394 -0
- package/packages/irc-core/tests/message-store.test.ts +530 -0
- package/packages/irc-core/tests/parser.test.ts +44 -1
- package/packages/irc-core/tests/ports.test.ts +263 -0
- package/packages/irc-server/package.json +1 -1
- package/packages/irc-server/src/actor.ts +186 -44
- package/packages/irc-server/src/dispatch.ts +89 -5
- package/packages/irc-server/src/index.ts +2 -0
- package/packages/irc-server/src/routing.ts +160 -0
- package/packages/irc-server/tests/actor.test.ts +690 -15
- package/packages/irc-server/tests/dispatch.test.ts +84 -0
- package/packages/irc-server/tests/routing.test.ts +204 -0
- package/packages/irc-test-support/README.md +32 -0
- package/packages/irc-test-support/package.json +37 -0
- package/packages/{cf-adapter/tests/integration → irc-test-support/src}/in-memory-harness.ts +6 -5
- package/packages/irc-test-support/src/index.ts +24 -0
- package/packages/{cf-adapter/tests/integration/harness.test.ts → irc-test-support/tests/in-memory-harness.test.ts} +1 -1
- package/packages/{cf-adapter/tests/integration/scenarios.test.ts → irc-test-support/tests/in-memory-scenarios.test.ts} +1 -2
- package/packages/irc-test-support/tests/smoke.test.ts +11 -0
- package/packages/irc-test-support/tsconfig.build.json +16 -0
- package/packages/irc-test-support/tsconfig.test.json +14 -0
- package/packages/irc-test-support/vitest.config.ts +20 -0
- package/pnpm-workspace.yaml +1 -0
- package/tools/ci-hardening/package.json +30 -0
- package/tools/ci-hardening/src/index.ts +6 -0
- package/tools/ci-hardening/src/validate.ts +103 -0
- package/tools/ci-hardening/tests/__no_thresholds__.txt +11 -0
- package/tools/ci-hardening/tests/__partial_thresholds__.txt +16 -0
- package/tools/ci-hardening/tests/__stryker_bad__.json +4 -0
- package/tools/ci-hardening/tests/validate.test.ts +177 -0
- package/tools/ci-hardening/tsconfig.build.json +12 -0
- package/tools/ci-hardening/tsconfig.test.json +10 -0
- package/tools/ci-hardening/vitest.config.ts +21 -0
- package/tools/seed-aws-accounts.ts +80 -0
- package/tools/tcp-ws-forwarder/package.json +1 -1
- package/tools/tcp-ws-forwarder/src/forwarder.ts +34 -23
- package/tools/tcp-ws-forwarder/src/logger.ts +155 -0
- package/tools/tcp-ws-forwarder/src/main.ts +33 -14
- package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +86 -0
- package/tools/tcp-ws-forwarder/tests/logger.test.ts +136 -0
- package/packages/cf-adapter/tests/integration/index.ts +0 -17
- /package/packages/{cf-adapter/tests/integration → irc-test-support/src}/harness.ts +0 -0
- /package/packages/{cf-adapter/tests/integration → irc-test-support/src}/scenarios.ts +0 -0
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
|
+
type AccountStore,
|
|
3
|
+
CapturingLogger,
|
|
2
4
|
type ChanName,
|
|
3
5
|
type ChanSnapshot,
|
|
4
6
|
type ChannelDelta,
|
|
@@ -8,15 +10,26 @@ import {
|
|
|
8
10
|
type ConnectionState,
|
|
9
11
|
EmptyMotdProvider,
|
|
10
12
|
FakeClock,
|
|
13
|
+
type FloodControlConfig,
|
|
14
|
+
InMemoryMessageStore,
|
|
15
|
+
LogLevel,
|
|
16
|
+
type Logger,
|
|
17
|
+
type MessageStore,
|
|
11
18
|
type Nick,
|
|
12
19
|
Numerics,
|
|
13
20
|
type RawLine,
|
|
21
|
+
type SaslPayload,
|
|
22
|
+
type SaslResult,
|
|
14
23
|
SequentialIdFactory,
|
|
24
|
+
type ServerConfig,
|
|
25
|
+
type StoredMessage,
|
|
15
26
|
applyChannelDelta as applyDelta,
|
|
16
27
|
createChannel,
|
|
17
28
|
createConnection,
|
|
29
|
+
encodeBase64,
|
|
18
30
|
isChannelTarget,
|
|
19
31
|
toChannelSnapshot,
|
|
32
|
+
toSnapshot,
|
|
20
33
|
} from '@serverless-ircd/irc-core';
|
|
21
34
|
import { describe, expect, it } from 'vitest';
|
|
22
35
|
import { ConnectionActor } from '../src/actor';
|
|
@@ -56,6 +69,10 @@ class FakeRuntime implements IrcRuntime {
|
|
|
56
69
|
return chan;
|
|
57
70
|
}
|
|
58
71
|
|
|
72
|
+
getChannel(name: ChanName): ChannelState | undefined {
|
|
73
|
+
return this.channels.get(name.toLowerCase());
|
|
74
|
+
}
|
|
75
|
+
|
|
59
76
|
async send(to: ConnId, lines: RawLine[]): Promise<void> {
|
|
60
77
|
this.calls.push({ method: 'send', args: [to, lines] });
|
|
61
78
|
}
|
|
@@ -104,7 +121,8 @@ class FakeRuntime implements IrcRuntime {
|
|
|
104
121
|
}
|
|
105
122
|
async getConnectionInfo(conn: ConnId): Promise<ConnSnapshot | null> {
|
|
106
123
|
this.calls.push({ method: 'getConnectionInfo', args: [conn] });
|
|
107
|
-
|
|
124
|
+
const state = this.testConnections.get(conn);
|
|
125
|
+
return state === undefined ? null : toSnapshot(state);
|
|
108
126
|
}
|
|
109
127
|
async getConnection(conn: ConnId): Promise<ConnectionState | null> {
|
|
110
128
|
this.calls.push({ method: 'getConnection', args: [conn] });
|
|
@@ -138,6 +156,12 @@ class FakeRuntime implements IrcRuntime {
|
|
|
138
156
|
function makeActor(opts: {
|
|
139
157
|
conn?: Partial<ConnectionState>;
|
|
140
158
|
clock?: FakeClock;
|
|
159
|
+
floodControl?: FloodControlConfig | null;
|
|
160
|
+
logger?: Logger;
|
|
161
|
+
traceId?: string;
|
|
162
|
+
messages?: MessageStore;
|
|
163
|
+
accounts?: AccountStore;
|
|
164
|
+
operCreds?: ReadonlyArray<{ user: string; password: string }>;
|
|
141
165
|
}): { actor: ConnectionActor; runtime: FakeRuntime; conn: ConnectionState } {
|
|
142
166
|
const clock = opts.clock ?? new FakeClock(1_000);
|
|
143
167
|
const conn = createConnection({ id: 'c1', connectedSince: 0 });
|
|
@@ -149,25 +173,49 @@ function makeActor(opts: {
|
|
|
149
173
|
if (opts.conn) Object.assign(conn, opts.conn);
|
|
150
174
|
|
|
151
175
|
const runtime = new FakeRuntime(clock);
|
|
152
|
-
|
|
176
|
+
// Build in one expression: `floodControl` is readonly, and only stamp it
|
|
177
|
+
// when the caller supplied a value so `undefined` (omit → default) stays
|
|
178
|
+
// distinct from `null` (explicit disable).
|
|
179
|
+
const serverConfig: ServerConfig = {
|
|
180
|
+
serverName: 'irc.example.com',
|
|
181
|
+
networkName: 'ExampleNet',
|
|
182
|
+
maxChannelsPerUser: 30,
|
|
183
|
+
maxTargetsPerCommand: 10,
|
|
184
|
+
maxListEntries: 50,
|
|
185
|
+
nickLen: 30,
|
|
186
|
+
channelLen: 50,
|
|
187
|
+
topicLen: 390,
|
|
188
|
+
quitMessage: 'Client Quit',
|
|
189
|
+
...(opts.floodControl !== undefined ? { floodControl: opts.floodControl } : {}),
|
|
190
|
+
...(opts.operCreds !== undefined ? { operCreds: opts.operCreds } : {}),
|
|
191
|
+
};
|
|
192
|
+
const ids = new SequentialIdFactory();
|
|
193
|
+
type ActorOpts = ConstructorParameters<typeof ConnectionActor>[0];
|
|
194
|
+
// Strip readonly modifiers so we can conditionally assign `logger` /
|
|
195
|
+
// `traceId` without tripping the readonly guards on `ConnectionActorOptions`.
|
|
196
|
+
type Mutable<T> = { -readonly [K in keyof T]: T[K] };
|
|
197
|
+
const finalOpts: Mutable<ActorOpts> = {
|
|
153
198
|
state: conn,
|
|
154
199
|
runtime,
|
|
155
200
|
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
|
-
},
|
|
201
|
+
serverConfig,
|
|
167
202
|
clock,
|
|
168
|
-
ids
|
|
203
|
+
ids,
|
|
169
204
|
motd: EmptyMotdProvider,
|
|
170
|
-
}
|
|
205
|
+
};
|
|
206
|
+
if (opts.logger !== undefined) {
|
|
207
|
+
finalOpts.logger = opts.logger;
|
|
208
|
+
}
|
|
209
|
+
if (opts.traceId !== undefined) {
|
|
210
|
+
finalOpts.traceId = opts.traceId;
|
|
211
|
+
}
|
|
212
|
+
if (opts.messages !== undefined) {
|
|
213
|
+
finalOpts.messages = opts.messages;
|
|
214
|
+
}
|
|
215
|
+
if (opts.accounts !== undefined) {
|
|
216
|
+
finalOpts.accounts = opts.accounts;
|
|
217
|
+
}
|
|
218
|
+
const actor = new ConnectionActor(finalOpts);
|
|
171
219
|
return { actor, runtime, conn };
|
|
172
220
|
}
|
|
173
221
|
|
|
@@ -442,6 +490,59 @@ describe('ConnectionActor — additional routing', () => {
|
|
|
442
490
|
expect(runtime.calls.filter((c) => c.method === 'broadcast')).toHaveLength(1);
|
|
443
491
|
});
|
|
444
492
|
|
|
493
|
+
it('routes TAGMSG to a channel broadcast (never 421) when message-tags cap is present', async () => {
|
|
494
|
+
const bobConn = createConnection({ id: 'c2', connectedSince: 0 });
|
|
495
|
+
bobConn.nick = 'bob';
|
|
496
|
+
bobConn.user = 'bob';
|
|
497
|
+
bobConn.host = 'example.org';
|
|
498
|
+
bobConn.registration = 'registered';
|
|
499
|
+
bobConn.caps = new Set<string>(['message-tags']);
|
|
500
|
+
const { actor, runtime } = makeActor({
|
|
501
|
+
conn: { caps: new Set<string>(['message-tags']) },
|
|
502
|
+
});
|
|
503
|
+
runtime.addConnection(bobConn);
|
|
504
|
+
await runtime.applyChannelDelta('#foo', {
|
|
505
|
+
memberships: [
|
|
506
|
+
{ type: 'add', conn: 'c1', nick: 'alice' },
|
|
507
|
+
{ type: 'add', conn: 'c2', nick: 'bob' },
|
|
508
|
+
],
|
|
509
|
+
});
|
|
510
|
+
runtime.calls.length = 0;
|
|
511
|
+
await actor.receiveTextFrame('@+typing=active TAGMSG #foo\r\n');
|
|
512
|
+
|
|
513
|
+
// Cap-gated broadcast dispatches per-recipient via send (only to
|
|
514
|
+
// message-tags-enabled members, excluding the sender).
|
|
515
|
+
const sends = runtime.calls.filter(
|
|
516
|
+
(c) => c.method === 'send' && c.args[0] === 'c2',
|
|
517
|
+
);
|
|
518
|
+
expect(sends).toHaveLength(1);
|
|
519
|
+
const lines = sends[0]?.args[1] as RawLine[];
|
|
520
|
+
expect(lines[0]?.text).toBe('@+typing=active :alice!alice@example.com TAGMSG #foo');
|
|
521
|
+
// Critical: never 421 for a cap-enabled TAGMSG.
|
|
522
|
+
for (const call of runtime.calls) {
|
|
523
|
+
if (call.method === 'send') {
|
|
524
|
+
for (const line of call.args[1] as RawLine[]) {
|
|
525
|
+
expect(line.text).not.toContain(' 421 ');
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
it('still emits 421 for TAGMSG when message-tags cap is not negotiated', async () => {
|
|
532
|
+
const { actor, runtime } = makeActor({});
|
|
533
|
+
await runtime.applyChannelDelta('#foo', {
|
|
534
|
+
memberships: [{ type: 'add', conn: 'c1', nick: 'alice' }],
|
|
535
|
+
});
|
|
536
|
+
runtime.calls.length = 0;
|
|
537
|
+
await actor.receiveTextFrame('TAGMSG #foo\r\n');
|
|
538
|
+
|
|
539
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
540
|
+
expect(sends).toHaveLength(1);
|
|
541
|
+
const lines = sends[0]?.args[1] as RawLine[];
|
|
542
|
+
expect(lines[0]?.text).toContain(' 421 ');
|
|
543
|
+
expect(lines[0]?.text).toContain('TAGMSG');
|
|
544
|
+
});
|
|
545
|
+
|
|
445
546
|
it('routes PRIVMSG with mixed targets (#chan,nick) into 2 invocations', async () => {
|
|
446
547
|
const { actor, runtime } = makeActor({});
|
|
447
548
|
await runtime.applyChannelDelta('#foo', {
|
|
@@ -563,6 +664,176 @@ describe('ConnectionActor — additional routing', () => {
|
|
|
563
664
|
}
|
|
564
665
|
});
|
|
565
666
|
|
|
667
|
+
it('routes AUTHENTICATE PLAIN (post CAP REQ sasl) through authenticateReducer, never 421', async () => {
|
|
668
|
+
// Pre-registration: client has opened CAP negotiation and requested sasl.
|
|
669
|
+
// AUTHENTICATE must reach the SASL reducer, not the 421 fallthrough.
|
|
670
|
+
const { actor, runtime, conn } = makeActor({
|
|
671
|
+
conn: {
|
|
672
|
+
registration: 'pre-registration',
|
|
673
|
+
capNegotiating: true,
|
|
674
|
+
caps: new Set<string>(['sasl']),
|
|
675
|
+
},
|
|
676
|
+
});
|
|
677
|
+
// The connection needs a nick for the reducer's `nickOf(ctx)` fallback.
|
|
678
|
+
conn.nick = 'alice';
|
|
679
|
+
runtime.calls.length = 0;
|
|
680
|
+
|
|
681
|
+
await actor.receiveTextFrame('AUTHENTICATE PLAIN\r\n');
|
|
682
|
+
|
|
683
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
684
|
+
expect(sends).toHaveLength(1);
|
|
685
|
+
const lines = sends[0]?.args[1] as RawLine[];
|
|
686
|
+
// SASL reducer responds with `AUTHENTICATE +` to request the payload.
|
|
687
|
+
expect(lines[0]?.text).toBe('AUTHENTICATE +');
|
|
688
|
+
// Critical: never 421 for AUTHENTICATE.
|
|
689
|
+
for (const s of sends) {
|
|
690
|
+
for (const line of s.args[1] as RawLine[]) {
|
|
691
|
+
expect(line.text).not.toContain(' 421 ');
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
});
|
|
695
|
+
|
|
696
|
+
it('routes AUTHENTICATE * (abort) through authenticateReducer, never 421', async () => {
|
|
697
|
+
const { actor, runtime } = makeActor({
|
|
698
|
+
conn: {
|
|
699
|
+
registration: 'pre-registration',
|
|
700
|
+
capNegotiating: true,
|
|
701
|
+
caps: new Set<string>(['sasl']),
|
|
702
|
+
saslMech: 'PLAIN',
|
|
703
|
+
},
|
|
704
|
+
});
|
|
705
|
+
runtime.calls.length = 0;
|
|
706
|
+
|
|
707
|
+
await actor.receiveTextFrame('AUTHENTICATE *\r\n');
|
|
708
|
+
|
|
709
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
710
|
+
expect(sends).toHaveLength(1);
|
|
711
|
+
const lines = sends[0]?.args[1] as RawLine[];
|
|
712
|
+
// Abort → 906 ERR_SASLABORT.
|
|
713
|
+
expect(lines[0]?.text).toContain(' 906 ');
|
|
714
|
+
for (const s of sends) {
|
|
715
|
+
for (const line of s.args[1] as RawLine[]) {
|
|
716
|
+
expect(line.text).not.toContain(' 421 ');
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
});
|
|
720
|
+
|
|
721
|
+
it('routes AWAY :reason through awayReducer, emitting 306 RPL_NOWAWAY (never 421)', async () => {
|
|
722
|
+
const { actor, runtime } = makeActor({});
|
|
723
|
+
runtime.calls.length = 0;
|
|
724
|
+
|
|
725
|
+
await actor.receiveTextFrame('AWAY :gone\r\n');
|
|
726
|
+
|
|
727
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
728
|
+
expect(sends).toHaveLength(1);
|
|
729
|
+
const lines = sends[0]?.args[1] as RawLine[];
|
|
730
|
+
expect(lines[0]?.text).toBe(':irc.example.com 306 alice :You have been marked as being away');
|
|
731
|
+
// Critical: never 421 for AWAY.
|
|
732
|
+
for (const s of sends) {
|
|
733
|
+
for (const line of s.args[1] as RawLine[]) {
|
|
734
|
+
expect(line.text).not.toContain(' 421 ');
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
});
|
|
738
|
+
|
|
739
|
+
it('routes AWAY with no params through awayReducer, emitting 305 RPL_UNAWAY (never 421)', async () => {
|
|
740
|
+
const { actor, runtime, conn } = makeActor({});
|
|
741
|
+
// Start from an away state so the unset path is observable.
|
|
742
|
+
conn.away = 'previously away';
|
|
743
|
+
runtime.calls.length = 0;
|
|
744
|
+
|
|
745
|
+
await actor.receiveTextFrame('AWAY\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[0]?.text).toBe(
|
|
751
|
+
':irc.example.com 305 alice :You are no longer marked as being away',
|
|
752
|
+
);
|
|
753
|
+
expect(conn.away).toBeUndefined();
|
|
754
|
+
for (const s of sends) {
|
|
755
|
+
for (const line of s.args[1] as RawLine[]) {
|
|
756
|
+
expect(line.text).not.toContain(' 421 ');
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
});
|
|
760
|
+
|
|
761
|
+
it('routes AWAY with an empty reason through awayReducer, emitting 305 RPL_UNAWAY (never 421)', async () => {
|
|
762
|
+
const { actor, runtime, conn } = makeActor({});
|
|
763
|
+
conn.away = 'previously away';
|
|
764
|
+
runtime.calls.length = 0;
|
|
765
|
+
|
|
766
|
+
await actor.receiveTextFrame('AWAY :\r\n');
|
|
767
|
+
|
|
768
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
769
|
+
expect(sends).toHaveLength(1);
|
|
770
|
+
const lines = sends[0]?.args[1] as RawLine[];
|
|
771
|
+
expect(lines[0]?.text).toContain(' 305 ');
|
|
772
|
+
expect(conn.away).toBeUndefined();
|
|
773
|
+
for (const s of sends) {
|
|
774
|
+
for (const line of s.args[1] as RawLine[]) {
|
|
775
|
+
expect(line.text).not.toContain(' 421 ');
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
});
|
|
779
|
+
|
|
780
|
+
it('routes OPER with matching creds through operReducer (381, never 421)', async () => {
|
|
781
|
+
const { actor, runtime, conn } = makeActor({
|
|
782
|
+
operCreds: [{ user: 'alice', password: 'secret' }],
|
|
783
|
+
});
|
|
784
|
+
runtime.calls.length = 0;
|
|
785
|
+
|
|
786
|
+
await actor.receiveTextFrame('OPER alice secret\r\n');
|
|
787
|
+
|
|
788
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
789
|
+
expect(sends).toHaveLength(1);
|
|
790
|
+
const lines = sends[0]?.args[1] as RawLine[];
|
|
791
|
+
expect(lines[0]?.text).toBe(':irc.example.com 381 alice :You are now an IRC operator');
|
|
792
|
+
expect(conn.userModes.oper).toBe(true);
|
|
793
|
+
// Critical: never 421 for OPER.
|
|
794
|
+
for (const s of sends) {
|
|
795
|
+
for (const line of s.args[1] as RawLine[]) {
|
|
796
|
+
expect(line.text).not.toContain(' 421 ');
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
});
|
|
800
|
+
|
|
801
|
+
it('routes OPER with wrong creds through operReducer (464, never 421)', async () => {
|
|
802
|
+
const { actor, runtime } = makeActor({
|
|
803
|
+
operCreds: [{ user: 'alice', password: 'secret' }],
|
|
804
|
+
});
|
|
805
|
+
runtime.calls.length = 0;
|
|
806
|
+
|
|
807
|
+
await actor.receiveTextFrame('OPER alice wrong\r\n');
|
|
808
|
+
|
|
809
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
810
|
+
expect(sends).toHaveLength(1);
|
|
811
|
+
const lines = sends[0]?.args[1] as RawLine[];
|
|
812
|
+
expect(lines[0]?.text).toContain(' 464 ');
|
|
813
|
+
for (const s of sends) {
|
|
814
|
+
for (const line of s.args[1] as RawLine[]) {
|
|
815
|
+
expect(line.text).not.toContain(' 421 ');
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
});
|
|
819
|
+
|
|
820
|
+
it('routes OPER with no configured creds through operReducer (491, never 421)', async () => {
|
|
821
|
+
const { actor, runtime } = makeActor({});
|
|
822
|
+
runtime.calls.length = 0;
|
|
823
|
+
|
|
824
|
+
await actor.receiveTextFrame('OPER alice secret\r\n');
|
|
825
|
+
|
|
826
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
827
|
+
expect(sends).toHaveLength(1);
|
|
828
|
+
const lines = sends[0]?.args[1] as RawLine[];
|
|
829
|
+
expect(lines[0]?.text).toContain(' 491 ');
|
|
830
|
+
for (const s of sends) {
|
|
831
|
+
for (const line of s.args[1] as RawLine[]) {
|
|
832
|
+
expect(line.text).not.toContain(' 421 ');
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
});
|
|
836
|
+
|
|
566
837
|
it('emits 421 with empty command when routeSingleChannel gets no channel (TOPIC)', async () => {
|
|
567
838
|
const { actor, runtime } = makeActor({});
|
|
568
839
|
await actor.receiveTextFrame('TOPIC\r\n');
|
|
@@ -814,3 +1085,407 @@ describe('ConnectionActor — WHO', () => {
|
|
|
814
1085
|
expect(lines[0]?.text).toBe(':irc.example.com 403 alice badname :No such channel');
|
|
815
1086
|
});
|
|
816
1087
|
});
|
|
1088
|
+
|
|
1089
|
+
describe('ConnectionActor — flood control', () => {
|
|
1090
|
+
// Pre-add the connection to #foo so under-limit PRIVMSG frames broadcast
|
|
1091
|
+
// (makes the pass-through vs. disconnect contrast observable).
|
|
1092
|
+
async function joinFoo(runtime: FakeRuntime): Promise<void> {
|
|
1093
|
+
await runtime.applyChannelDelta('#foo', {
|
|
1094
|
+
memberships: [{ type: 'add', conn: 'c1', nick: 'alice' }],
|
|
1095
|
+
});
|
|
1096
|
+
runtime.calls.length = 0;
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
it('disconnects a client bursting PRIVMSG past the bucket capacity with "Excess Flood"', async () => {
|
|
1100
|
+
const { actor, runtime } = makeActor({});
|
|
1101
|
+
await joinFoo(runtime);
|
|
1102
|
+
|
|
1103
|
+
// Default bucket (capacity 10, threshold 0): the first 10 PRIVMSG pass,
|
|
1104
|
+
// the 11th onward trip the Disconnect path.
|
|
1105
|
+
for (let i = 0; i < 20; i++) {
|
|
1106
|
+
await actor.receiveTextFrame('PRIVMSG #foo :flood\r\n');
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
const disconnects = runtime.calls.filter((c) => c.method === 'disconnect');
|
|
1110
|
+
expect(disconnects.length).toBeGreaterThan(0);
|
|
1111
|
+
expect(disconnects[0]?.args[1]).toBe('Excess Flood');
|
|
1112
|
+
// The close path targets the actor's own connection.
|
|
1113
|
+
expect(disconnects[0]?.args[0]).toBe('c1');
|
|
1114
|
+
});
|
|
1115
|
+
|
|
1116
|
+
it('lets the same burst pass through unchanged when flood control is disabled (floodControl: null)', async () => {
|
|
1117
|
+
const { actor, runtime } = makeActor({ floodControl: null });
|
|
1118
|
+
await joinFoo(runtime);
|
|
1119
|
+
|
|
1120
|
+
for (let i = 0; i < 20; i++) {
|
|
1121
|
+
await actor.receiveTextFrame('PRIVMSG #foo :flood\r\n');
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
// Disabled → every frame reaches the inner reducer and broadcasts; the
|
|
1125
|
+
// close path is never invoked.
|
|
1126
|
+
expect(runtime.calls.filter((c) => c.method === 'disconnect')).toHaveLength(0);
|
|
1127
|
+
expect(runtime.calls.filter((c) => c.method === 'broadcast')).toHaveLength(20);
|
|
1128
|
+
});
|
|
1129
|
+
|
|
1130
|
+
it('does not let an interleaved PING advance the bucket (control-plane exemption plumbed through)', async () => {
|
|
1131
|
+
const { actor, runtime } = makeActor({});
|
|
1132
|
+
await joinFoo(runtime);
|
|
1133
|
+
|
|
1134
|
+
// 10 PRIVMSG drain the default bucket exactly to zero (no disconnect yet).
|
|
1135
|
+
for (let i = 0; i < 10; i++) {
|
|
1136
|
+
await actor.receiveTextFrame('PRIVMSG #foo :flood\r\n');
|
|
1137
|
+
}
|
|
1138
|
+
expect(runtime.calls.filter((c) => c.method === 'disconnect')).toHaveLength(0);
|
|
1139
|
+
|
|
1140
|
+
// A PING is control-plane traffic: it must not decrement the bucket, so
|
|
1141
|
+
// it neither disconnects here nor consumes the last token. (A flat-cost
|
|
1142
|
+
// wrapper would trip the Disconnect on this very frame.)
|
|
1143
|
+
await actor.receiveTextFrame('PING :keepalive\r\n');
|
|
1144
|
+
expect(runtime.calls.filter((c) => c.method === 'disconnect')).toHaveLength(0);
|
|
1145
|
+
|
|
1146
|
+
// The bucket is still at zero, so the next PRIVMSG is the one that trips
|
|
1147
|
+
// the disconnect — proving the PING was free.
|
|
1148
|
+
await actor.receiveTextFrame('PRIVMSG #foo :one-more\r\n');
|
|
1149
|
+
const disconnects = runtime.calls.filter((c) => c.method === 'disconnect');
|
|
1150
|
+
expect(disconnects).toHaveLength(1);
|
|
1151
|
+
expect(disconnects[0]?.args[1]).toBe('Excess Flood');
|
|
1152
|
+
});
|
|
1153
|
+
});
|
|
1154
|
+
|
|
1155
|
+
// ===========================================================================
|
|
1156
|
+
// Observability: structured logs + traceId/connectionId
|
|
1157
|
+
// ===========================================================================
|
|
1158
|
+
|
|
1159
|
+
describe('ConnectionActor — observability', () => {
|
|
1160
|
+
it('emits a structured log record with traceId and connectionId for a processed frame', async () => {
|
|
1161
|
+
const logger = new CapturingLogger();
|
|
1162
|
+
const { actor } = makeActor({ logger, traceId: 'trace-xyz' });
|
|
1163
|
+
|
|
1164
|
+
await actor.receiveTextFrame('PING :tok\r\n');
|
|
1165
|
+
|
|
1166
|
+
const received = logger.records.filter((r) => r.msg === 'frame.receive');
|
|
1167
|
+
expect(received).toHaveLength(1);
|
|
1168
|
+
const rec = received[0];
|
|
1169
|
+
expect(rec?.level).toBe(LogLevel.Debug);
|
|
1170
|
+
expect(rec?.traceId).toBe('trace-xyz');
|
|
1171
|
+
expect(rec?.connectionId).toBe('c1');
|
|
1172
|
+
expect(rec?.fields?.command).toBe('PING');
|
|
1173
|
+
expect(rec?.fields?.lines).toBe(1);
|
|
1174
|
+
});
|
|
1175
|
+
|
|
1176
|
+
it('stamps the same traceId on every record emitted while processing one frame', async () => {
|
|
1177
|
+
const logger = new CapturingLogger();
|
|
1178
|
+
const { actor } = makeActor({ logger, traceId: 'trace-abc' });
|
|
1179
|
+
|
|
1180
|
+
// A joined frame exercises the per-line path; a successful reducer
|
|
1181
|
+
// followed by a parse failure exercises both frame.receive and
|
|
1182
|
+
// frame.parse-error records on the same trace.
|
|
1183
|
+
await actor.receiveTextFrame('PING :tok\r\nBADLINE_7_DIGITS\r\n');
|
|
1184
|
+
|
|
1185
|
+
expect(logger.records.length).toBeGreaterThan(0);
|
|
1186
|
+
for (const rec of logger.records) {
|
|
1187
|
+
expect(rec.traceId).toBe('trace-abc');
|
|
1188
|
+
expect(rec.connectionId).toBe('c1');
|
|
1189
|
+
}
|
|
1190
|
+
});
|
|
1191
|
+
|
|
1192
|
+
it('emits a warn-level frame.parse-error record on a malformed line', async () => {
|
|
1193
|
+
const logger = new CapturingLogger();
|
|
1194
|
+
const { actor } = makeActor({ logger, traceId: 't1' });
|
|
1195
|
+
|
|
1196
|
+
// 5-digit numeric prefix is not a legal IRC command → parse fails.
|
|
1197
|
+
await actor.receiveTextFrame('12345\r\n');
|
|
1198
|
+
|
|
1199
|
+
const errs = logger.records.filter((r) => r.msg === 'frame.parse-error');
|
|
1200
|
+
expect(errs).toHaveLength(1);
|
|
1201
|
+
expect(errs[0]?.level).toBe(LogLevel.Warn);
|
|
1202
|
+
expect(errs[0]?.traceId).toBe('t1');
|
|
1203
|
+
expect(typeof errs[0]?.fields?.line).toBe('string');
|
|
1204
|
+
});
|
|
1205
|
+
|
|
1206
|
+
it('falls back to a NoopLogger and unknown traceId when omitted', async () => {
|
|
1207
|
+
// The actor must still function when no logger/traceId is supplied — the
|
|
1208
|
+
// codebase's default. No exceptions, no observable output.
|
|
1209
|
+
const { actor, runtime } = makeActor({});
|
|
1210
|
+
await actor.receiveTextFrame('PING :tok\r\n');
|
|
1211
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
1212
|
+
expect(sends).toHaveLength(1);
|
|
1213
|
+
});
|
|
1214
|
+
|
|
1215
|
+
it('allocates a fresh traceId per receiveTextFrame call when none was supplied', async () => {
|
|
1216
|
+
// Each inbound frame gets its own trace id; the actor generates one
|
|
1217
|
+
// itself via IdFactory.traceId() when the caller did not pin one. Two
|
|
1218
|
+
// frames → two distinct ids.
|
|
1219
|
+
const logger = new CapturingLogger();
|
|
1220
|
+
const { actor } = makeActor({ logger });
|
|
1221
|
+
|
|
1222
|
+
await actor.receiveTextFrame('PING :a\r\n');
|
|
1223
|
+
await actor.receiveTextFrame('PING :b\r\n');
|
|
1224
|
+
|
|
1225
|
+
const ids = new Set(logger.records.map((r) => r.traceId));
|
|
1226
|
+
expect(ids.size).toBe(2);
|
|
1227
|
+
});
|
|
1228
|
+
|
|
1229
|
+
it('logs dispatch effects with the bound traceId', async () => {
|
|
1230
|
+
const logger = new CapturingLogger();
|
|
1231
|
+
const { actor, runtime } = makeActor({ logger, traceId: 'trace-d1' });
|
|
1232
|
+
await runtime.applyChannelDelta('#foo', {
|
|
1233
|
+
memberships: [{ type: 'add', conn: 'c1', nick: 'alice' }],
|
|
1234
|
+
});
|
|
1235
|
+
|
|
1236
|
+
await actor.receiveTextFrame('PRIVMSG #foo :hello\r\n');
|
|
1237
|
+
|
|
1238
|
+
const dispatchLogs = logger.records.filter((r) => r.msg === 'dispatch');
|
|
1239
|
+
expect(dispatchLogs.length).toBeGreaterThan(0);
|
|
1240
|
+
for (const rec of dispatchLogs) {
|
|
1241
|
+
expect(rec.traceId).toBe('trace-d1');
|
|
1242
|
+
expect(rec.fields?.effects).toEqual(expect.any(Array));
|
|
1243
|
+
}
|
|
1244
|
+
});
|
|
1245
|
+
});
|
|
1246
|
+
|
|
1247
|
+
// ===========================================================================
|
|
1248
|
+
// CHATHISTORY — dispatcher routing + MessageStore plumbing
|
|
1249
|
+
// ===========================================================================
|
|
1250
|
+
|
|
1251
|
+
describe('ConnectionActor — CHATHISTORY', () => {
|
|
1252
|
+
/** The full cap set a chathistory-capable client negotiates. */
|
|
1253
|
+
const CHATHISTORY_CAPS = new Set<string>([
|
|
1254
|
+
'draft/chathistory',
|
|
1255
|
+
'server-time',
|
|
1256
|
+
'message-tags',
|
|
1257
|
+
'batch',
|
|
1258
|
+
]);
|
|
1259
|
+
|
|
1260
|
+
/** Records `count` chronological PRIVMSG messages into `store` for `chan`. */
|
|
1261
|
+
function seedMessages(store: MessageStore, chan: string, count: number): StoredMessage[] {
|
|
1262
|
+
const out: StoredMessage[] = [];
|
|
1263
|
+
for (let i = 1; i <= count; i++) {
|
|
1264
|
+
const m: StoredMessage = {
|
|
1265
|
+
msgid: `m${i}`,
|
|
1266
|
+
time: i * 1_000,
|
|
1267
|
+
chan: chan.toLowerCase(),
|
|
1268
|
+
command: 'PRIVMSG',
|
|
1269
|
+
nick: 'bob',
|
|
1270
|
+
user: 'bob',
|
|
1271
|
+
host: 'ex.org',
|
|
1272
|
+
text: `msg ${i}`,
|
|
1273
|
+
};
|
|
1274
|
+
store.record(m);
|
|
1275
|
+
out.push(m);
|
|
1276
|
+
}
|
|
1277
|
+
return out;
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
it('routes CHATHISTORY LATEST to the reducer, emitting a BATCH chathistory frame (never 421)', async () => {
|
|
1281
|
+
const store = new InMemoryMessageStore();
|
|
1282
|
+
const { actor, runtime } = makeActor({
|
|
1283
|
+
conn: { caps: CHATHISTORY_CAPS },
|
|
1284
|
+
messages: store,
|
|
1285
|
+
});
|
|
1286
|
+
// Create the channel so the peek resolves it (no creation side-effect
|
|
1287
|
+
// during the query itself).
|
|
1288
|
+
runtime.getOrCreateChannel('#foo');
|
|
1289
|
+
seedMessages(store, '#foo', 2);
|
|
1290
|
+
runtime.calls.length = 0;
|
|
1291
|
+
|
|
1292
|
+
await actor.receiveTextFrame('CHATHISTORY LATEST #foo * 2\r\n');
|
|
1293
|
+
|
|
1294
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
1295
|
+
expect(sends).toHaveLength(1);
|
|
1296
|
+
const lines = sends[0]?.args[1] as RawLine[];
|
|
1297
|
+
// BATCH +id chathistory #foo … BATCH -id (id from the shared id factory).
|
|
1298
|
+
expect(lines[0]?.text).toMatch(/^BATCH \+batch-\d+ chathistory #foo$/);
|
|
1299
|
+
const batchId = lines[0]?.text.match(/^BATCH \+batch-(\d+)/)?.[1];
|
|
1300
|
+
expect(lines[lines.length - 1]?.text).toBe(`BATCH -batch-${batchId}`);
|
|
1301
|
+
// Two replay lines between the batch markers.
|
|
1302
|
+
expect(lines).toHaveLength(4);
|
|
1303
|
+
expect(lines[1]?.text).toContain('PRIVMSG #foo :msg 1');
|
|
1304
|
+
expect(lines[2]?.text).toContain('PRIVMSG #foo :msg 2');
|
|
1305
|
+
// Critical: never 421 for a cap-enabled CHATHISTORY query.
|
|
1306
|
+
for (const line of lines) {
|
|
1307
|
+
expect(line.text).not.toContain(' 421 ');
|
|
1308
|
+
}
|
|
1309
|
+
});
|
|
1310
|
+
|
|
1311
|
+
it('still emits 421 for CHATHISTORY when the cap is not negotiated (reducer cap-gating)', async () => {
|
|
1312
|
+
const store = new InMemoryMessageStore();
|
|
1313
|
+
const { actor, runtime } = makeActor({
|
|
1314
|
+
conn: { caps: new Set<string>() },
|
|
1315
|
+
messages: store,
|
|
1316
|
+
});
|
|
1317
|
+
runtime.getOrCreateChannel('#foo');
|
|
1318
|
+
seedMessages(store, '#foo', 2);
|
|
1319
|
+
runtime.calls.length = 0;
|
|
1320
|
+
|
|
1321
|
+
await actor.receiveTextFrame('CHATHISTORY LATEST #foo * 2\r\n');
|
|
1322
|
+
|
|
1323
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
1324
|
+
expect(sends).toHaveLength(1);
|
|
1325
|
+
const lines = sends[0]?.args[1] as RawLine[];
|
|
1326
|
+
expect(lines[0]?.text).toContain(' 421 ');
|
|
1327
|
+
expect(lines[0]?.text).toContain('CHATHISTORY');
|
|
1328
|
+
});
|
|
1329
|
+
|
|
1330
|
+
it('emits 403 ERR_NOSUCHCHANNEL for an unknown channel target', async () => {
|
|
1331
|
+
const store = new InMemoryMessageStore();
|
|
1332
|
+
const { actor, runtime } = makeActor({
|
|
1333
|
+
conn: { caps: CHATHISTORY_CAPS },
|
|
1334
|
+
messages: store,
|
|
1335
|
+
});
|
|
1336
|
+
// Deliberately do NOT create #nope.
|
|
1337
|
+
runtime.calls.length = 0;
|
|
1338
|
+
|
|
1339
|
+
await actor.receiveTextFrame('CHATHISTORY LATEST #nope * 2\r\n');
|
|
1340
|
+
|
|
1341
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
1342
|
+
expect(sends).toHaveLength(1);
|
|
1343
|
+
const lines = sends[0]?.args[1] as RawLine[];
|
|
1344
|
+
expect(lines[0]?.text).toContain(' 403 ');
|
|
1345
|
+
expect(lines[0]?.text).toContain('#nope');
|
|
1346
|
+
});
|
|
1347
|
+
|
|
1348
|
+
it('does not crash and emits nothing when no store is bound (ctx.messages undefined)', async () => {
|
|
1349
|
+
const { actor, runtime } = makeActor({
|
|
1350
|
+
conn: { caps: CHATHISTORY_CAPS },
|
|
1351
|
+
// No `messages` option → ctx.messages is undefined.
|
|
1352
|
+
});
|
|
1353
|
+
runtime.getOrCreateChannel('#foo');
|
|
1354
|
+
runtime.calls.length = 0;
|
|
1355
|
+
|
|
1356
|
+
await actor.receiveTextFrame('CHATHISTORY LATEST #foo * 2\r\n');
|
|
1357
|
+
|
|
1358
|
+
// No store → the reducer's query returns [] → empty batch elided → no
|
|
1359
|
+
// Send effects. Proves the no-store path is a graceful no-op.
|
|
1360
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
1361
|
+
expect(sends).toHaveLength(0);
|
|
1362
|
+
});
|
|
1363
|
+
|
|
1364
|
+
it('records a channel PRIVMSG into the bound store (JOIN auto-playback data source)', async () => {
|
|
1365
|
+
const store = new InMemoryMessageStore();
|
|
1366
|
+
const { actor, runtime } = makeActor({ messages: store });
|
|
1367
|
+
await runtime.applyChannelDelta('#foo', {
|
|
1368
|
+
memberships: [{ type: 'add', conn: 'c1', nick: 'alice' }],
|
|
1369
|
+
});
|
|
1370
|
+
runtime.calls.length = 0;
|
|
1371
|
+
|
|
1372
|
+
await actor.receiveTextFrame('PRIVMSG #foo :hello\r\n');
|
|
1373
|
+
|
|
1374
|
+
const recorded = store.query({ chan: '#foo', direction: 'latest', limit: 10 });
|
|
1375
|
+
expect(recorded).toHaveLength(1);
|
|
1376
|
+
expect(recorded[0]?.text).toBe('hello');
|
|
1377
|
+
expect(recorded[0]?.command).toBe('PRIVMSG');
|
|
1378
|
+
expect(recorded[0]?.nick).toBe('alice');
|
|
1379
|
+
});
|
|
1380
|
+
|
|
1381
|
+
it('does not record a PRIVMSG when no store is bound (unchanged behaviour)', async () => {
|
|
1382
|
+
const { actor, runtime } = makeActor({});
|
|
1383
|
+
await runtime.applyChannelDelta('#foo', {
|
|
1384
|
+
memberships: [{ type: 'add', conn: 'c1', nick: 'alice' }],
|
|
1385
|
+
});
|
|
1386
|
+
runtime.calls.length = 0;
|
|
1387
|
+
|
|
1388
|
+
await actor.receiveTextFrame('PRIVMSG #foo :hello\r\n');
|
|
1389
|
+
|
|
1390
|
+
// Broadcast still happens (the message is delivered live)…
|
|
1391
|
+
expect(runtime.calls.filter((c) => c.method === 'broadcast')).toHaveLength(1);
|
|
1392
|
+
// …but there is no store to record into.
|
|
1393
|
+
});
|
|
1394
|
+
});
|
|
1395
|
+
|
|
1396
|
+
// ===========================================================================
|
|
1397
|
+
// SASL / AccountStore — actor plumbing (ctx.accounts threaded through buildCtx)
|
|
1398
|
+
// ===========================================================================
|
|
1399
|
+
|
|
1400
|
+
describe('ConnectionActor — SASL AccountStore plumbing', () => {
|
|
1401
|
+
/** Capture-and-respond fake AccountStore (mirrors the irc-core sasl tests). */
|
|
1402
|
+
class FakeAccountStore implements AccountStore {
|
|
1403
|
+
readonly calls: Array<{ mech: string; payload: SaslPayload }> = [];
|
|
1404
|
+
constructor(private readonly result: SaslResult) {}
|
|
1405
|
+
verify(mech: string, payload: SaslPayload): SaslResult {
|
|
1406
|
+
this.calls.push({ mech, payload });
|
|
1407
|
+
return this.result;
|
|
1408
|
+
}
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
/** Builds the PLAIN base64 payload `authzid\0authcid\0password`. */
|
|
1412
|
+
function plainPayload(authcid: string, password: string): string {
|
|
1413
|
+
return encodeBase64(`\0${authcid}\0${password}`);
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
/** A connection that has opened CAP negotiation and requested `sasl`. */
|
|
1417
|
+
function saslReadyConn(): Partial<ConnectionState> {
|
|
1418
|
+
return {
|
|
1419
|
+
registration: 'pre-registration',
|
|
1420
|
+
capNegotiating: true,
|
|
1421
|
+
caps: new Set<string>(['sasl']),
|
|
1422
|
+
};
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
it('completes SASL PLAIN with 900/903 when a bound AccountStore accepts creds', async () => {
|
|
1426
|
+
const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
|
|
1427
|
+
const { actor, runtime, conn } = makeActor({
|
|
1428
|
+
conn: saslReadyConn(),
|
|
1429
|
+
accounts,
|
|
1430
|
+
});
|
|
1431
|
+
conn.nick = 'alice';
|
|
1432
|
+
runtime.calls.length = 0;
|
|
1433
|
+
|
|
1434
|
+
await actor.receiveTextFrame('AUTHENTICATE PLAIN\r\n');
|
|
1435
|
+
await actor.receiveTextFrame(`AUTHENTICATE ${plainPayload('alice', 'secret')}\r\n`);
|
|
1436
|
+
|
|
1437
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
1438
|
+
// Second frame emits the success numerics; gather every sent line.
|
|
1439
|
+
const lines = sends.flatMap((s) => s.args[1] as RawLine[]);
|
|
1440
|
+
expect(lines.some((l) => l.text.includes(' 900 '))).toBe(true);
|
|
1441
|
+
expect(lines.some((l) => l.text.includes(' 903 '))).toBe(true);
|
|
1442
|
+
// Critical: never 904 for valid creds.
|
|
1443
|
+
expect(lines.some((l) => l.text.includes(' 904 '))).toBe(false);
|
|
1444
|
+
// The AccountStore was consulted with the parsed PLAIN credentials.
|
|
1445
|
+
expect(accounts.calls).toEqual([
|
|
1446
|
+
{ mech: 'PLAIN', payload: { kind: 'PLAIN', username: 'alice', password: 'secret' } },
|
|
1447
|
+
]);
|
|
1448
|
+
// Account name recorded on the connection for extended-join/account-tag.
|
|
1449
|
+
expect(conn.account).toBe('alice');
|
|
1450
|
+
});
|
|
1451
|
+
|
|
1452
|
+
it('emits 904 ERR_SASLFAIL when no AccountStore is bound (regression guard)', async () => {
|
|
1453
|
+
const { actor, runtime, conn } = makeActor({
|
|
1454
|
+
conn: saslReadyConn(),
|
|
1455
|
+
// No `accounts` option → ctx.accounts is undefined.
|
|
1456
|
+
});
|
|
1457
|
+
conn.nick = 'alice';
|
|
1458
|
+
runtime.calls.length = 0;
|
|
1459
|
+
|
|
1460
|
+
await actor.receiveTextFrame('AUTHENTICATE PLAIN\r\n');
|
|
1461
|
+
await actor.receiveTextFrame(`AUTHENTICATE ${plainPayload('alice', 'secret')}\r\n`);
|
|
1462
|
+
|
|
1463
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
1464
|
+
const lines = sends.flatMap((s) => s.args[1] as RawLine[]);
|
|
1465
|
+
expect(lines.some((l) => l.text.includes(' 904 '))).toBe(true);
|
|
1466
|
+
// No success numerics.
|
|
1467
|
+
expect(lines.some((l) => l.text.includes(' 900 '))).toBe(false);
|
|
1468
|
+
expect(lines.some((l) => l.text.includes(' 903 '))).toBe(false);
|
|
1469
|
+
// Account name never recorded.
|
|
1470
|
+
expect(conn.account).toBeUndefined();
|
|
1471
|
+
});
|
|
1472
|
+
|
|
1473
|
+
it('emits 904 when a bound AccountStore rejects the credentials', async () => {
|
|
1474
|
+
const accounts = new FakeAccountStore({ ok: false, reason: 'invalid credentials' });
|
|
1475
|
+
const { actor, runtime, conn } = makeActor({
|
|
1476
|
+
conn: saslReadyConn(),
|
|
1477
|
+
accounts,
|
|
1478
|
+
});
|
|
1479
|
+
conn.nick = 'alice';
|
|
1480
|
+
runtime.calls.length = 0;
|
|
1481
|
+
|
|
1482
|
+
await actor.receiveTextFrame('AUTHENTICATE PLAIN\r\n');
|
|
1483
|
+
await actor.receiveTextFrame(`AUTHENTICATE ${plainPayload('alice', 'wrong')}\r\n`);
|
|
1484
|
+
|
|
1485
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
1486
|
+
const lines = sends.flatMap((s) => s.args[1] as RawLine[]);
|
|
1487
|
+
expect(lines.some((l) => l.text.includes(' 904 '))).toBe(true);
|
|
1488
|
+
expect(lines.some((l) => l.text.includes(' 903 '))).toBe(false);
|
|
1489
|
+
expect(conn.account).toBeUndefined();
|
|
1490
|
+
});
|
|
1491
|
+
});
|