serverless-ircd 0.1.0 → 0.2.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/AGENTS.md +5 -0
- package/CHANGELOG.md +352 -0
- package/MOTD.txt +3 -0
- package/PLAN-FIXES.md +358 -0
- package/PLAN.md +420 -0
- package/README.md +115 -81
- 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/wrangler.toml +25 -0
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/config-loader.ts +56 -0
- package/apps/local-cli/src/server.ts +241 -32
- package/apps/local-cli/tests/config-loader.test.ts +107 -0
- package/apps/local-cli/tests/e2e.test.ts +74 -0
- package/apps/local-cli/tests/security-e2e.test.ts +239 -0
- package/biome.json +3 -1
- package/dashboards/cloudwatch-irc.json +139 -0
- package/docs/AWS-Adapter-Architecture.md +494 -0
- package/docs/AWS-Deployment.md +1107 -0
- package/docs/Cloudflare-Deployment-Guide.md +660 -0
- package/docs/Home.md +1 -0
- package/docs/Observability.md +87 -0
- package/docs/PlanIRCv3Websocket.md +489 -0
- package/docs/PlanWebClient.md +451 -0
- package/docs/Release-Process.md +440 -0
- package/package.json +8 -2
- package/packages/aws-adapter/README.md +35 -0
- package/packages/aws-adapter/package.json +52 -0
- package/packages/aws-adapter/src/aws-runtime.ts +783 -0
- package/packages/aws-adapter/src/cdk-table-defs.ts +69 -0
- package/packages/aws-adapter/src/config-loader.ts +96 -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 +322 -0
- package/packages/aws-adapter/src/handlers/disconnect.ts +29 -0
- package/packages/aws-adapter/src/handlers/index.ts +248 -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 +67 -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/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 +158 -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 +427 -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/config-loader.ts +106 -0
- package/packages/cf-adapter/src/connection-do.ts +34 -0
- 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/config-loader.test.ts +149 -0
- package/packages/cf-adapter/tests/connection-do.test.ts +42 -0
- package/packages/in-memory-runtime/package.json +1 -1
- package/packages/in-memory-runtime/src/in-memory-runtime.ts +108 -3
- package/packages/in-memory-runtime/src/index.ts +1 -1
- package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +115 -0
- package/packages/irc-core/package.json +12 -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/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 +7 -0
- package/packages/irc-core/src/commands/join.ts +59 -7
- package/packages/irc-core/src/commands/privmsg.ts +24 -0
- package/packages/irc-core/src/commands/registration.ts +71 -14
- package/packages/irc-core/src/commands/sasl.ts +251 -0
- package/packages/irc-core/src/config.ts +247 -0
- package/packages/irc-core/src/flood-control.ts +175 -0
- package/packages/irc-core/src/index.ts +5 -0
- package/packages/irc-core/src/ports.ts +655 -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 +66 -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/admission.test.ts +229 -0
- package/packages/irc-core/tests/caps/capabilities.test.ts +22 -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/privmsg.test.ts +146 -2
- package/packages/irc-core/tests/commands/registration.test.ts +277 -9
- package/packages/irc-core/tests/commands/sasl.test.ts +536 -0
- package/packages/irc-core/tests/config.test.ts +646 -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 +162 -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 +151 -0
- package/packages/irc-server/tests/actor.test.ts +470 -14
- package/packages/irc-server/tests/dispatch.test.ts +84 -0
- package/packages/irc-server/tests/routing.test.ts +201 -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 +1 -1
- 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/progress.md +107 -0
- package/tickets.md +2485 -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/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/webircgateway/LICENSE +201 -0
- package/webircgateway/Makefile +44 -0
- package/webircgateway/README.md +134 -0
- package/webircgateway/config.conf.example +135 -0
- package/webircgateway/go.mod +16 -0
- package/webircgateway/go.sum +89 -0
- package/webircgateway/main.go +118 -0
- package/webircgateway/pkg/dnsbl/dnsbl.go +121 -0
- package/webircgateway/pkg/identd/identd.go +86 -0
- package/webircgateway/pkg/identd/rpcclient.go +59 -0
- package/webircgateway/pkg/irc/isupport.go +56 -0
- package/webircgateway/pkg/irc/message.go +217 -0
- package/webircgateway/pkg/irc/state.go +79 -0
- package/webircgateway/pkg/proxy/proxy.go +129 -0
- package/webircgateway/pkg/proxy/server.go +237 -0
- package/webircgateway/pkg/recaptcha/recaptcha.go +59 -0
- package/webircgateway/pkg/webircgateway/client.go +741 -0
- package/webircgateway/pkg/webircgateway/client_command_handlers.go +495 -0
- package/webircgateway/pkg/webircgateway/config.go +385 -0
- package/webircgateway/pkg/webircgateway/gateway.go +278 -0
- package/webircgateway/pkg/webircgateway/gateway_utils.go +133 -0
- package/webircgateway/pkg/webircgateway/hooks.go +152 -0
- package/webircgateway/pkg/webircgateway/letsencrypt.go +41 -0
- package/webircgateway/pkg/webircgateway/messagetags.go +103 -0
- package/webircgateway/pkg/webircgateway/transport_kiwiirc.go +206 -0
- package/webircgateway/pkg/webircgateway/transport_sockjs.go +107 -0
- package/webircgateway/pkg/webircgateway/transport_tcp.go +113 -0
- package/webircgateway/pkg/webircgateway/transport_websocket.go +126 -0
- package/webircgateway/pkg/webircgateway/utils.go +147 -0
- package/webircgateway/plugins/example/plugin.go +11 -0
- package/webircgateway/plugins/stats/plugin.go +52 -0
- package/webircgateway/staticcheck.conf +1 -0
- package/webircgateway/webircgateway.svg +3 -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,5 @@
|
|
|
1
1
|
import {
|
|
2
|
+
CapturingLogger,
|
|
2
3
|
type ChanName,
|
|
3
4
|
type ChanSnapshot,
|
|
4
5
|
type ChannelDelta,
|
|
@@ -8,10 +9,17 @@ import {
|
|
|
8
9
|
type ConnectionState,
|
|
9
10
|
EmptyMotdProvider,
|
|
10
11
|
FakeClock,
|
|
12
|
+
type FloodControlConfig,
|
|
13
|
+
InMemoryMessageStore,
|
|
14
|
+
LogLevel,
|
|
15
|
+
type Logger,
|
|
16
|
+
type MessageStore,
|
|
11
17
|
type Nick,
|
|
12
18
|
Numerics,
|
|
13
19
|
type RawLine,
|
|
14
20
|
SequentialIdFactory,
|
|
21
|
+
type ServerConfig,
|
|
22
|
+
type StoredMessage,
|
|
15
23
|
applyChannelDelta as applyDelta,
|
|
16
24
|
createChannel,
|
|
17
25
|
createConnection,
|
|
@@ -56,6 +64,10 @@ class FakeRuntime implements IrcRuntime {
|
|
|
56
64
|
return chan;
|
|
57
65
|
}
|
|
58
66
|
|
|
67
|
+
getChannel(name: ChanName): ChannelState | undefined {
|
|
68
|
+
return this.channels.get(name.toLowerCase());
|
|
69
|
+
}
|
|
70
|
+
|
|
59
71
|
async send(to: ConnId, lines: RawLine[]): Promise<void> {
|
|
60
72
|
this.calls.push({ method: 'send', args: [to, lines] });
|
|
61
73
|
}
|
|
@@ -138,6 +150,10 @@ class FakeRuntime implements IrcRuntime {
|
|
|
138
150
|
function makeActor(opts: {
|
|
139
151
|
conn?: Partial<ConnectionState>;
|
|
140
152
|
clock?: FakeClock;
|
|
153
|
+
floodControl?: FloodControlConfig | null;
|
|
154
|
+
logger?: Logger;
|
|
155
|
+
traceId?: string;
|
|
156
|
+
messages?: MessageStore;
|
|
141
157
|
}): { actor: ConnectionActor; runtime: FakeRuntime; conn: ConnectionState } {
|
|
142
158
|
const clock = opts.clock ?? new FakeClock(1_000);
|
|
143
159
|
const conn = createConnection({ id: 'c1', connectedSince: 0 });
|
|
@@ -149,25 +165,45 @@ function makeActor(opts: {
|
|
|
149
165
|
if (opts.conn) Object.assign(conn, opts.conn);
|
|
150
166
|
|
|
151
167
|
const runtime = new FakeRuntime(clock);
|
|
152
|
-
|
|
168
|
+
// Build in one expression: `floodControl` is readonly, and only stamp it
|
|
169
|
+
// when the caller supplied a value so `undefined` (omit → default) stays
|
|
170
|
+
// distinct from `null` (explicit disable).
|
|
171
|
+
const serverConfig: ServerConfig = {
|
|
172
|
+
serverName: 'irc.example.com',
|
|
173
|
+
networkName: 'ExampleNet',
|
|
174
|
+
maxChannelsPerUser: 30,
|
|
175
|
+
maxTargetsPerCommand: 10,
|
|
176
|
+
maxListEntries: 50,
|
|
177
|
+
nickLen: 30,
|
|
178
|
+
channelLen: 50,
|
|
179
|
+
topicLen: 390,
|
|
180
|
+
quitMessage: 'Client Quit',
|
|
181
|
+
...(opts.floodControl !== undefined ? { floodControl: opts.floodControl } : {}),
|
|
182
|
+
};
|
|
183
|
+
const ids = new SequentialIdFactory();
|
|
184
|
+
type ActorOpts = ConstructorParameters<typeof ConnectionActor>[0];
|
|
185
|
+
// Strip readonly modifiers so we can conditionally assign `logger` /
|
|
186
|
+
// `traceId` without tripping the readonly guards on `ConnectionActorOptions`.
|
|
187
|
+
type Mutable<T> = { -readonly [K in keyof T]: T[K] };
|
|
188
|
+
const finalOpts: Mutable<ActorOpts> = {
|
|
153
189
|
state: conn,
|
|
154
190
|
runtime,
|
|
155
191
|
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
|
-
},
|
|
192
|
+
serverConfig,
|
|
167
193
|
clock,
|
|
168
|
-
ids
|
|
194
|
+
ids,
|
|
169
195
|
motd: EmptyMotdProvider,
|
|
170
|
-
}
|
|
196
|
+
};
|
|
197
|
+
if (opts.logger !== undefined) {
|
|
198
|
+
finalOpts.logger = opts.logger;
|
|
199
|
+
}
|
|
200
|
+
if (opts.traceId !== undefined) {
|
|
201
|
+
finalOpts.traceId = opts.traceId;
|
|
202
|
+
}
|
|
203
|
+
if (opts.messages !== undefined) {
|
|
204
|
+
finalOpts.messages = opts.messages;
|
|
205
|
+
}
|
|
206
|
+
const actor = new ConnectionActor(finalOpts);
|
|
171
207
|
return { actor, runtime, conn };
|
|
172
208
|
}
|
|
173
209
|
|
|
@@ -563,6 +599,119 @@ describe('ConnectionActor — additional routing', () => {
|
|
|
563
599
|
}
|
|
564
600
|
});
|
|
565
601
|
|
|
602
|
+
it('routes AUTHENTICATE PLAIN (post CAP REQ sasl) through authenticateReducer, never 421', async () => {
|
|
603
|
+
// Pre-registration: client has opened CAP negotiation and requested sasl.
|
|
604
|
+
// AUTHENTICATE must reach the SASL reducer, not the 421 fallthrough.
|
|
605
|
+
const { actor, runtime, conn } = makeActor({
|
|
606
|
+
conn: {
|
|
607
|
+
registration: 'pre-registration',
|
|
608
|
+
capNegotiating: true,
|
|
609
|
+
caps: new Set<string>(['sasl']),
|
|
610
|
+
},
|
|
611
|
+
});
|
|
612
|
+
// The connection needs a nick for the reducer's `nickOf(ctx)` fallback.
|
|
613
|
+
conn.nick = 'alice';
|
|
614
|
+
runtime.calls.length = 0;
|
|
615
|
+
|
|
616
|
+
await actor.receiveTextFrame('AUTHENTICATE PLAIN\r\n');
|
|
617
|
+
|
|
618
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
619
|
+
expect(sends).toHaveLength(1);
|
|
620
|
+
const lines = sends[0]?.args[1] as RawLine[];
|
|
621
|
+
// SASL reducer responds with `AUTHENTICATE +` to request the payload.
|
|
622
|
+
expect(lines[0]?.text).toBe('AUTHENTICATE +');
|
|
623
|
+
// Critical: never 421 for AUTHENTICATE.
|
|
624
|
+
for (const s of sends) {
|
|
625
|
+
for (const line of s.args[1] as RawLine[]) {
|
|
626
|
+
expect(line.text).not.toContain(' 421 ');
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
it('routes AUTHENTICATE * (abort) through authenticateReducer, never 421', async () => {
|
|
632
|
+
const { actor, runtime } = makeActor({
|
|
633
|
+
conn: {
|
|
634
|
+
registration: 'pre-registration',
|
|
635
|
+
capNegotiating: true,
|
|
636
|
+
caps: new Set<string>(['sasl']),
|
|
637
|
+
saslMech: 'PLAIN',
|
|
638
|
+
},
|
|
639
|
+
});
|
|
640
|
+
runtime.calls.length = 0;
|
|
641
|
+
|
|
642
|
+
await actor.receiveTextFrame('AUTHENTICATE *\r\n');
|
|
643
|
+
|
|
644
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
645
|
+
expect(sends).toHaveLength(1);
|
|
646
|
+
const lines = sends[0]?.args[1] as RawLine[];
|
|
647
|
+
// Abort → 906 ERR_SASLABORT.
|
|
648
|
+
expect(lines[0]?.text).toContain(' 906 ');
|
|
649
|
+
for (const s of sends) {
|
|
650
|
+
for (const line of s.args[1] as RawLine[]) {
|
|
651
|
+
expect(line.text).not.toContain(' 421 ');
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
});
|
|
655
|
+
|
|
656
|
+
it('routes AWAY :reason through awayReducer, emitting 306 RPL_NOWAWAY (never 421)', async () => {
|
|
657
|
+
const { actor, runtime } = makeActor({});
|
|
658
|
+
runtime.calls.length = 0;
|
|
659
|
+
|
|
660
|
+
await actor.receiveTextFrame('AWAY :gone\r\n');
|
|
661
|
+
|
|
662
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
663
|
+
expect(sends).toHaveLength(1);
|
|
664
|
+
const lines = sends[0]?.args[1] as RawLine[];
|
|
665
|
+
expect(lines[0]?.text).toBe(':irc.example.com 306 alice :You have been marked as being away');
|
|
666
|
+
// Critical: never 421 for AWAY.
|
|
667
|
+
for (const s of sends) {
|
|
668
|
+
for (const line of s.args[1] as RawLine[]) {
|
|
669
|
+
expect(line.text).not.toContain(' 421 ');
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
});
|
|
673
|
+
|
|
674
|
+
it('routes AWAY with no params through awayReducer, emitting 305 RPL_UNAWAY (never 421)', async () => {
|
|
675
|
+
const { actor, runtime, conn } = makeActor({});
|
|
676
|
+
// Start from an away state so the unset path is observable.
|
|
677
|
+
conn.away = 'previously away';
|
|
678
|
+
runtime.calls.length = 0;
|
|
679
|
+
|
|
680
|
+
await actor.receiveTextFrame('AWAY\r\n');
|
|
681
|
+
|
|
682
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
683
|
+
expect(sends).toHaveLength(1);
|
|
684
|
+
const lines = sends[0]?.args[1] as RawLine[];
|
|
685
|
+
expect(lines[0]?.text).toBe(
|
|
686
|
+
':irc.example.com 305 alice :You are no longer marked as being away',
|
|
687
|
+
);
|
|
688
|
+
expect(conn.away).toBeUndefined();
|
|
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 AWAY with an empty reason through awayReducer, emitting 305 RPL_UNAWAY (never 421)', async () => {
|
|
697
|
+
const { actor, runtime, conn } = makeActor({});
|
|
698
|
+
conn.away = 'previously away';
|
|
699
|
+
runtime.calls.length = 0;
|
|
700
|
+
|
|
701
|
+
await actor.receiveTextFrame('AWAY :\r\n');
|
|
702
|
+
|
|
703
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
704
|
+
expect(sends).toHaveLength(1);
|
|
705
|
+
const lines = sends[0]?.args[1] as RawLine[];
|
|
706
|
+
expect(lines[0]?.text).toContain(' 305 ');
|
|
707
|
+
expect(conn.away).toBeUndefined();
|
|
708
|
+
for (const s of sends) {
|
|
709
|
+
for (const line of s.args[1] as RawLine[]) {
|
|
710
|
+
expect(line.text).not.toContain(' 421 ');
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
});
|
|
714
|
+
|
|
566
715
|
it('emits 421 with empty command when routeSingleChannel gets no channel (TOPIC)', async () => {
|
|
567
716
|
const { actor, runtime } = makeActor({});
|
|
568
717
|
await actor.receiveTextFrame('TOPIC\r\n');
|
|
@@ -814,3 +963,310 @@ describe('ConnectionActor — WHO', () => {
|
|
|
814
963
|
expect(lines[0]?.text).toBe(':irc.example.com 403 alice badname :No such channel');
|
|
815
964
|
});
|
|
816
965
|
});
|
|
966
|
+
|
|
967
|
+
describe('ConnectionActor — flood control', () => {
|
|
968
|
+
// Pre-add the connection to #foo so under-limit PRIVMSG frames broadcast
|
|
969
|
+
// (makes the pass-through vs. disconnect contrast observable).
|
|
970
|
+
async function joinFoo(runtime: FakeRuntime): Promise<void> {
|
|
971
|
+
await runtime.applyChannelDelta('#foo', {
|
|
972
|
+
memberships: [{ type: 'add', conn: 'c1', nick: 'alice' }],
|
|
973
|
+
});
|
|
974
|
+
runtime.calls.length = 0;
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
it('disconnects a client bursting PRIVMSG past the bucket capacity with "Excess Flood"', async () => {
|
|
978
|
+
const { actor, runtime } = makeActor({});
|
|
979
|
+
await joinFoo(runtime);
|
|
980
|
+
|
|
981
|
+
// Default bucket (capacity 10, threshold 0): the first 10 PRIVMSG pass,
|
|
982
|
+
// the 11th onward trip the Disconnect path.
|
|
983
|
+
for (let i = 0; i < 20; i++) {
|
|
984
|
+
await actor.receiveTextFrame('PRIVMSG #foo :flood\r\n');
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
const disconnects = runtime.calls.filter((c) => c.method === 'disconnect');
|
|
988
|
+
expect(disconnects.length).toBeGreaterThan(0);
|
|
989
|
+
expect(disconnects[0]?.args[1]).toBe('Excess Flood');
|
|
990
|
+
// The close path targets the actor's own connection.
|
|
991
|
+
expect(disconnects[0]?.args[0]).toBe('c1');
|
|
992
|
+
});
|
|
993
|
+
|
|
994
|
+
it('lets the same burst pass through unchanged when flood control is disabled (floodControl: null)', async () => {
|
|
995
|
+
const { actor, runtime } = makeActor({ floodControl: null });
|
|
996
|
+
await joinFoo(runtime);
|
|
997
|
+
|
|
998
|
+
for (let i = 0; i < 20; i++) {
|
|
999
|
+
await actor.receiveTextFrame('PRIVMSG #foo :flood\r\n');
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
// Disabled → every frame reaches the inner reducer and broadcasts; the
|
|
1003
|
+
// close path is never invoked.
|
|
1004
|
+
expect(runtime.calls.filter((c) => c.method === 'disconnect')).toHaveLength(0);
|
|
1005
|
+
expect(runtime.calls.filter((c) => c.method === 'broadcast')).toHaveLength(20);
|
|
1006
|
+
});
|
|
1007
|
+
|
|
1008
|
+
it('does not let an interleaved PING advance the bucket (control-plane exemption plumbed through)', async () => {
|
|
1009
|
+
const { actor, runtime } = makeActor({});
|
|
1010
|
+
await joinFoo(runtime);
|
|
1011
|
+
|
|
1012
|
+
// 10 PRIVMSG drain the default bucket exactly to zero (no disconnect yet).
|
|
1013
|
+
for (let i = 0; i < 10; i++) {
|
|
1014
|
+
await actor.receiveTextFrame('PRIVMSG #foo :flood\r\n');
|
|
1015
|
+
}
|
|
1016
|
+
expect(runtime.calls.filter((c) => c.method === 'disconnect')).toHaveLength(0);
|
|
1017
|
+
|
|
1018
|
+
// A PING is control-plane traffic: it must not decrement the bucket, so
|
|
1019
|
+
// it neither disconnects here nor consumes the last token. (A flat-cost
|
|
1020
|
+
// wrapper would trip the Disconnect on this very frame.)
|
|
1021
|
+
await actor.receiveTextFrame('PING :keepalive\r\n');
|
|
1022
|
+
expect(runtime.calls.filter((c) => c.method === 'disconnect')).toHaveLength(0);
|
|
1023
|
+
|
|
1024
|
+
// The bucket is still at zero, so the next PRIVMSG is the one that trips
|
|
1025
|
+
// the disconnect — proving the PING was free.
|
|
1026
|
+
await actor.receiveTextFrame('PRIVMSG #foo :one-more\r\n');
|
|
1027
|
+
const disconnects = runtime.calls.filter((c) => c.method === 'disconnect');
|
|
1028
|
+
expect(disconnects).toHaveLength(1);
|
|
1029
|
+
expect(disconnects[0]?.args[1]).toBe('Excess Flood');
|
|
1030
|
+
});
|
|
1031
|
+
});
|
|
1032
|
+
|
|
1033
|
+
// ===========================================================================
|
|
1034
|
+
// Observability: structured logs + traceId/connectionId
|
|
1035
|
+
// ===========================================================================
|
|
1036
|
+
|
|
1037
|
+
describe('ConnectionActor — observability', () => {
|
|
1038
|
+
it('emits a structured log record with traceId and connectionId for a processed frame', async () => {
|
|
1039
|
+
const logger = new CapturingLogger();
|
|
1040
|
+
const { actor } = makeActor({ logger, traceId: 'trace-xyz' });
|
|
1041
|
+
|
|
1042
|
+
await actor.receiveTextFrame('PING :tok\r\n');
|
|
1043
|
+
|
|
1044
|
+
const received = logger.records.filter((r) => r.msg === 'frame.receive');
|
|
1045
|
+
expect(received).toHaveLength(1);
|
|
1046
|
+
const rec = received[0];
|
|
1047
|
+
expect(rec?.level).toBe(LogLevel.Debug);
|
|
1048
|
+
expect(rec?.traceId).toBe('trace-xyz');
|
|
1049
|
+
expect(rec?.connectionId).toBe('c1');
|
|
1050
|
+
expect(rec?.fields?.command).toBe('PING');
|
|
1051
|
+
expect(rec?.fields?.lines).toBe(1);
|
|
1052
|
+
});
|
|
1053
|
+
|
|
1054
|
+
it('stamps the same traceId on every record emitted while processing one frame', async () => {
|
|
1055
|
+
const logger = new CapturingLogger();
|
|
1056
|
+
const { actor } = makeActor({ logger, traceId: 'trace-abc' });
|
|
1057
|
+
|
|
1058
|
+
// A joined frame exercises the per-line path; a successful reducer
|
|
1059
|
+
// followed by a parse failure exercises both frame.receive and
|
|
1060
|
+
// frame.parse-error records on the same trace.
|
|
1061
|
+
await actor.receiveTextFrame('PING :tok\r\nBADLINE_7_DIGITS\r\n');
|
|
1062
|
+
|
|
1063
|
+
expect(logger.records.length).toBeGreaterThan(0);
|
|
1064
|
+
for (const rec of logger.records) {
|
|
1065
|
+
expect(rec.traceId).toBe('trace-abc');
|
|
1066
|
+
expect(rec.connectionId).toBe('c1');
|
|
1067
|
+
}
|
|
1068
|
+
});
|
|
1069
|
+
|
|
1070
|
+
it('emits a warn-level frame.parse-error record on a malformed line', async () => {
|
|
1071
|
+
const logger = new CapturingLogger();
|
|
1072
|
+
const { actor } = makeActor({ logger, traceId: 't1' });
|
|
1073
|
+
|
|
1074
|
+
// 5-digit numeric prefix is not a legal IRC command → parse fails.
|
|
1075
|
+
await actor.receiveTextFrame('12345\r\n');
|
|
1076
|
+
|
|
1077
|
+
const errs = logger.records.filter((r) => r.msg === 'frame.parse-error');
|
|
1078
|
+
expect(errs).toHaveLength(1);
|
|
1079
|
+
expect(errs[0]?.level).toBe(LogLevel.Warn);
|
|
1080
|
+
expect(errs[0]?.traceId).toBe('t1');
|
|
1081
|
+
expect(typeof errs[0]?.fields?.line).toBe('string');
|
|
1082
|
+
});
|
|
1083
|
+
|
|
1084
|
+
it('falls back to a NoopLogger and unknown traceId when omitted', async () => {
|
|
1085
|
+
// The actor must still function when no logger/traceId is supplied — the
|
|
1086
|
+
// codebase's default. No exceptions, no observable output.
|
|
1087
|
+
const { actor, runtime } = makeActor({});
|
|
1088
|
+
await actor.receiveTextFrame('PING :tok\r\n');
|
|
1089
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
1090
|
+
expect(sends).toHaveLength(1);
|
|
1091
|
+
});
|
|
1092
|
+
|
|
1093
|
+
it('allocates a fresh traceId per receiveTextFrame call when none was supplied', async () => {
|
|
1094
|
+
// Each inbound frame gets its own trace id; the actor generates one
|
|
1095
|
+
// itself via IdFactory.traceId() when the caller did not pin one. Two
|
|
1096
|
+
// frames → two distinct ids.
|
|
1097
|
+
const logger = new CapturingLogger();
|
|
1098
|
+
const { actor } = makeActor({ logger });
|
|
1099
|
+
|
|
1100
|
+
await actor.receiveTextFrame('PING :a\r\n');
|
|
1101
|
+
await actor.receiveTextFrame('PING :b\r\n');
|
|
1102
|
+
|
|
1103
|
+
const ids = new Set(logger.records.map((r) => r.traceId));
|
|
1104
|
+
expect(ids.size).toBe(2);
|
|
1105
|
+
});
|
|
1106
|
+
|
|
1107
|
+
it('logs dispatch effects with the bound traceId', async () => {
|
|
1108
|
+
const logger = new CapturingLogger();
|
|
1109
|
+
const { actor, runtime } = makeActor({ logger, traceId: 'trace-d1' });
|
|
1110
|
+
await runtime.applyChannelDelta('#foo', {
|
|
1111
|
+
memberships: [{ type: 'add', conn: 'c1', nick: 'alice' }],
|
|
1112
|
+
});
|
|
1113
|
+
|
|
1114
|
+
await actor.receiveTextFrame('PRIVMSG #foo :hello\r\n');
|
|
1115
|
+
|
|
1116
|
+
const dispatchLogs = logger.records.filter((r) => r.msg === 'dispatch');
|
|
1117
|
+
expect(dispatchLogs.length).toBeGreaterThan(0);
|
|
1118
|
+
for (const rec of dispatchLogs) {
|
|
1119
|
+
expect(rec.traceId).toBe('trace-d1');
|
|
1120
|
+
expect(rec.fields?.effects).toEqual(expect.any(Array));
|
|
1121
|
+
}
|
|
1122
|
+
});
|
|
1123
|
+
});
|
|
1124
|
+
|
|
1125
|
+
// ===========================================================================
|
|
1126
|
+
// CHATHISTORY — dispatcher routing + MessageStore plumbing
|
|
1127
|
+
// ===========================================================================
|
|
1128
|
+
|
|
1129
|
+
describe('ConnectionActor — CHATHISTORY', () => {
|
|
1130
|
+
/** The full cap set a chathistory-capable client negotiates. */
|
|
1131
|
+
const CHATHISTORY_CAPS = new Set<string>([
|
|
1132
|
+
'draft/chathistory',
|
|
1133
|
+
'server-time',
|
|
1134
|
+
'message-tags',
|
|
1135
|
+
'batch',
|
|
1136
|
+
]);
|
|
1137
|
+
|
|
1138
|
+
/** Records `count` chronological PRIVMSG messages into `store` for `chan`. */
|
|
1139
|
+
function seedMessages(store: MessageStore, chan: string, count: number): StoredMessage[] {
|
|
1140
|
+
const out: StoredMessage[] = [];
|
|
1141
|
+
for (let i = 1; i <= count; i++) {
|
|
1142
|
+
const m: StoredMessage = {
|
|
1143
|
+
msgid: `m${i}`,
|
|
1144
|
+
time: i * 1_000,
|
|
1145
|
+
chan: chan.toLowerCase(),
|
|
1146
|
+
command: 'PRIVMSG',
|
|
1147
|
+
nick: 'bob',
|
|
1148
|
+
user: 'bob',
|
|
1149
|
+
host: 'ex.org',
|
|
1150
|
+
text: `msg ${i}`,
|
|
1151
|
+
};
|
|
1152
|
+
store.record(m);
|
|
1153
|
+
out.push(m);
|
|
1154
|
+
}
|
|
1155
|
+
return out;
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
it('routes CHATHISTORY LATEST to the reducer, emitting a BATCH chathistory frame (never 421)', async () => {
|
|
1159
|
+
const store = new InMemoryMessageStore();
|
|
1160
|
+
const { actor, runtime } = makeActor({
|
|
1161
|
+
conn: { caps: CHATHISTORY_CAPS },
|
|
1162
|
+
messages: store,
|
|
1163
|
+
});
|
|
1164
|
+
// Create the channel so the peek resolves it (no creation side-effect
|
|
1165
|
+
// during the query itself).
|
|
1166
|
+
runtime.getOrCreateChannel('#foo');
|
|
1167
|
+
seedMessages(store, '#foo', 2);
|
|
1168
|
+
runtime.calls.length = 0;
|
|
1169
|
+
|
|
1170
|
+
await actor.receiveTextFrame('CHATHISTORY LATEST #foo * 2\r\n');
|
|
1171
|
+
|
|
1172
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
1173
|
+
expect(sends).toHaveLength(1);
|
|
1174
|
+
const lines = sends[0]?.args[1] as RawLine[];
|
|
1175
|
+
// BATCH +id chathistory #foo … BATCH -id (id from the shared id factory).
|
|
1176
|
+
expect(lines[0]?.text).toMatch(/^BATCH \+batch-\d+ chathistory #foo$/);
|
|
1177
|
+
const batchId = lines[0]?.text.match(/^BATCH \+batch-(\d+)/)?.[1];
|
|
1178
|
+
expect(lines[lines.length - 1]?.text).toBe(`BATCH -batch-${batchId}`);
|
|
1179
|
+
// Two replay lines between the batch markers.
|
|
1180
|
+
expect(lines).toHaveLength(4);
|
|
1181
|
+
expect(lines[1]?.text).toContain('PRIVMSG #foo :msg 1');
|
|
1182
|
+
expect(lines[2]?.text).toContain('PRIVMSG #foo :msg 2');
|
|
1183
|
+
// Critical: never 421 for a cap-enabled CHATHISTORY query.
|
|
1184
|
+
for (const line of lines) {
|
|
1185
|
+
expect(line.text).not.toContain(' 421 ');
|
|
1186
|
+
}
|
|
1187
|
+
});
|
|
1188
|
+
|
|
1189
|
+
it('still emits 421 for CHATHISTORY when the cap is not negotiated (reducer cap-gating)', async () => {
|
|
1190
|
+
const store = new InMemoryMessageStore();
|
|
1191
|
+
const { actor, runtime } = makeActor({
|
|
1192
|
+
conn: { caps: new Set<string>() },
|
|
1193
|
+
messages: store,
|
|
1194
|
+
});
|
|
1195
|
+
runtime.getOrCreateChannel('#foo');
|
|
1196
|
+
seedMessages(store, '#foo', 2);
|
|
1197
|
+
runtime.calls.length = 0;
|
|
1198
|
+
|
|
1199
|
+
await actor.receiveTextFrame('CHATHISTORY LATEST #foo * 2\r\n');
|
|
1200
|
+
|
|
1201
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
1202
|
+
expect(sends).toHaveLength(1);
|
|
1203
|
+
const lines = sends[0]?.args[1] as RawLine[];
|
|
1204
|
+
expect(lines[0]?.text).toContain(' 421 ');
|
|
1205
|
+
expect(lines[0]?.text).toContain('CHATHISTORY');
|
|
1206
|
+
});
|
|
1207
|
+
|
|
1208
|
+
it('emits 403 ERR_NOSUCHCHANNEL for an unknown channel target', async () => {
|
|
1209
|
+
const store = new InMemoryMessageStore();
|
|
1210
|
+
const { actor, runtime } = makeActor({
|
|
1211
|
+
conn: { caps: CHATHISTORY_CAPS },
|
|
1212
|
+
messages: store,
|
|
1213
|
+
});
|
|
1214
|
+
// Deliberately do NOT create #nope.
|
|
1215
|
+
runtime.calls.length = 0;
|
|
1216
|
+
|
|
1217
|
+
await actor.receiveTextFrame('CHATHISTORY LATEST #nope * 2\r\n');
|
|
1218
|
+
|
|
1219
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
1220
|
+
expect(sends).toHaveLength(1);
|
|
1221
|
+
const lines = sends[0]?.args[1] as RawLine[];
|
|
1222
|
+
expect(lines[0]?.text).toContain(' 403 ');
|
|
1223
|
+
expect(lines[0]?.text).toContain('#nope');
|
|
1224
|
+
});
|
|
1225
|
+
|
|
1226
|
+
it('does not crash and emits nothing when no store is bound (ctx.messages undefined)', async () => {
|
|
1227
|
+
const { actor, runtime } = makeActor({
|
|
1228
|
+
conn: { caps: CHATHISTORY_CAPS },
|
|
1229
|
+
// No `messages` option → ctx.messages is undefined.
|
|
1230
|
+
});
|
|
1231
|
+
runtime.getOrCreateChannel('#foo');
|
|
1232
|
+
runtime.calls.length = 0;
|
|
1233
|
+
|
|
1234
|
+
await actor.receiveTextFrame('CHATHISTORY LATEST #foo * 2\r\n');
|
|
1235
|
+
|
|
1236
|
+
// No store → the reducer's query returns [] → empty batch elided → no
|
|
1237
|
+
// Send effects. Proves the no-store path is a graceful no-op.
|
|
1238
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
1239
|
+
expect(sends).toHaveLength(0);
|
|
1240
|
+
});
|
|
1241
|
+
|
|
1242
|
+
it('records a channel PRIVMSG into the bound store (JOIN auto-playback data source)', async () => {
|
|
1243
|
+
const store = new InMemoryMessageStore();
|
|
1244
|
+
const { actor, runtime } = makeActor({ messages: store });
|
|
1245
|
+
await runtime.applyChannelDelta('#foo', {
|
|
1246
|
+
memberships: [{ type: 'add', conn: 'c1', nick: 'alice' }],
|
|
1247
|
+
});
|
|
1248
|
+
runtime.calls.length = 0;
|
|
1249
|
+
|
|
1250
|
+
await actor.receiveTextFrame('PRIVMSG #foo :hello\r\n');
|
|
1251
|
+
|
|
1252
|
+
const recorded = store.query({ chan: '#foo', direction: 'latest', limit: 10 });
|
|
1253
|
+
expect(recorded).toHaveLength(1);
|
|
1254
|
+
expect(recorded[0]?.text).toBe('hello');
|
|
1255
|
+
expect(recorded[0]?.command).toBe('PRIVMSG');
|
|
1256
|
+
expect(recorded[0]?.nick).toBe('alice');
|
|
1257
|
+
});
|
|
1258
|
+
|
|
1259
|
+
it('does not record a PRIVMSG when no store is bound (unchanged behaviour)', async () => {
|
|
1260
|
+
const { actor, runtime } = makeActor({});
|
|
1261
|
+
await runtime.applyChannelDelta('#foo', {
|
|
1262
|
+
memberships: [{ type: 'add', conn: 'c1', nick: 'alice' }],
|
|
1263
|
+
});
|
|
1264
|
+
runtime.calls.length = 0;
|
|
1265
|
+
|
|
1266
|
+
await actor.receiveTextFrame('PRIVMSG #foo :hello\r\n');
|
|
1267
|
+
|
|
1268
|
+
// Broadcast still happens (the message is delivered live)…
|
|
1269
|
+
expect(runtime.calls.filter((c) => c.method === 'broadcast')).toHaveLength(1);
|
|
1270
|
+
// …but there is no store to record into.
|
|
1271
|
+
});
|
|
1272
|
+
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Effect } from '@serverless-ircd/irc-core';
|
|
2
|
+
import { CapturingLogger, LogLevel } from '@serverless-ircd/irc-core';
|
|
2
3
|
import type { ChannelDelta, ConnId, RawLine } from '@serverless-ircd/irc-core';
|
|
3
4
|
import { describe, expect, it } from 'vitest';
|
|
4
5
|
import { dispatch } from '../src/dispatch';
|
|
@@ -510,3 +511,86 @@ describe('dispatch — Broadcast cap / capLines', () => {
|
|
|
510
511
|
expect(sends.map((s) => s.to)).toEqual(['c1']);
|
|
511
512
|
});
|
|
512
513
|
});
|
|
514
|
+
|
|
515
|
+
// ===========================================================================
|
|
516
|
+
// dispatch observability
|
|
517
|
+
// ===========================================================================
|
|
518
|
+
describe('dispatch — observability', () => {
|
|
519
|
+
it('emits a `dispatch` debug record with traceId/connectionId and effect tags', async () => {
|
|
520
|
+
const calls: RecordedCall[] = [];
|
|
521
|
+
const runtime = fakeRuntime(calls);
|
|
522
|
+
const logger = new CapturingLogger();
|
|
523
|
+
|
|
524
|
+
await dispatch(
|
|
525
|
+
[Effect.send('c1', [L('PING :1')]), Effect.send('c1', [L('PONG :1')])],
|
|
526
|
+
runtime,
|
|
527
|
+
{ logger, traceId: 't-1', connectionId: 'c1' },
|
|
528
|
+
);
|
|
529
|
+
|
|
530
|
+
const records = logger.records.filter((r) => r.msg === 'dispatch');
|
|
531
|
+
expect(records).toHaveLength(1);
|
|
532
|
+
const rec = records[0];
|
|
533
|
+
expect(rec?.level).toBe(LogLevel.Debug);
|
|
534
|
+
expect(rec?.traceId).toBe('t-1');
|
|
535
|
+
expect(rec?.connectionId).toBe('c1');
|
|
536
|
+
expect(rec?.fields?.effects).toEqual(['Send', 'Send']);
|
|
537
|
+
expect(rec?.fields?.histogram).toEqual({ Send: 2 });
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
it('is silent when no logger is supplied (backwards-compatible default)', async () => {
|
|
541
|
+
const calls: RecordedCall[] = [];
|
|
542
|
+
const runtime = fakeRuntime(calls);
|
|
543
|
+
// Default silent call signature still works.
|
|
544
|
+
await expect(dispatch([Effect.send('c1', [L('x')])], runtime)).resolves.toBeUndefined();
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
it('emits a dispatch.error warn record and rethrows when a handler rejects', async () => {
|
|
548
|
+
const runtime: IrcRuntime = {
|
|
549
|
+
...fakeRuntime([]),
|
|
550
|
+
async send() {
|
|
551
|
+
throw new Error('boom');
|
|
552
|
+
},
|
|
553
|
+
};
|
|
554
|
+
const logger = new CapturingLogger();
|
|
555
|
+
|
|
556
|
+
await expect(
|
|
557
|
+
dispatch([Effect.send('c1', [L('x')])], runtime, {
|
|
558
|
+
logger,
|
|
559
|
+
traceId: 't-2',
|
|
560
|
+
connectionId: 'c1',
|
|
561
|
+
}),
|
|
562
|
+
).rejects.toThrow('boom');
|
|
563
|
+
|
|
564
|
+
const errs = logger.records.filter((r) => r.msg === 'dispatch.error');
|
|
565
|
+
expect(errs).toHaveLength(1);
|
|
566
|
+
expect(errs[0]?.level).toBe(LogLevel.Warn);
|
|
567
|
+
expect(errs[0]?.traceId).toBe('t-2');
|
|
568
|
+
expect(errs[0]?.connectionId).toBe('c1');
|
|
569
|
+
expect(errs[0]?.fields?.effect).toBe('Send');
|
|
570
|
+
const err = errs[0]?.fields?.err as { name: string; message: string };
|
|
571
|
+
expect(err.name).toBe('Error');
|
|
572
|
+
expect(err.message).toBe('boom');
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
it('stringifies non-Error rejections in the dispatch.error record', async () => {
|
|
576
|
+
const runtime: IrcRuntime = {
|
|
577
|
+
...fakeRuntime([]),
|
|
578
|
+
async send() {
|
|
579
|
+
throw 'string-error';
|
|
580
|
+
},
|
|
581
|
+
};
|
|
582
|
+
const logger = new CapturingLogger();
|
|
583
|
+
|
|
584
|
+
await expect(
|
|
585
|
+
dispatch([Effect.send('c1', [L('x')])], runtime, {
|
|
586
|
+
logger,
|
|
587
|
+
traceId: 't-3',
|
|
588
|
+
connectionId: 'c1',
|
|
589
|
+
}),
|
|
590
|
+
).rejects.toBe('string-error');
|
|
591
|
+
|
|
592
|
+
const errs = logger.records.filter((r) => r.msg === 'dispatch.error');
|
|
593
|
+
expect(errs).toHaveLength(1);
|
|
594
|
+
expect(errs[0]?.fields?.err).toBe('string-error');
|
|
595
|
+
});
|
|
596
|
+
});
|