serverless-ircd 0.10.0 → 0.11.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 +28 -0
- package/.github/workflows/deploy-cf-tcp.yml +26 -2
- package/.github/workflows/deploy-cf.yml +26 -0
- package/CHANGELOG.md +289 -0
- package/README.md +153 -20
- package/apps/aws-stack/bin/aws.ts +36 -0
- package/apps/aws-stack/package.json +1 -1
- package/apps/aws-stack/src/aws-stack.ts +221 -15
- package/apps/aws-stack/tests/stack.test.ts +450 -16
- package/apps/cf-tcp-container/Dockerfile +37 -5
- package/apps/cf-tcp-container/package.json +7 -2
- package/apps/cf-tcp-container/src/config-loader.ts +113 -2
- package/apps/cf-tcp-container/src/container-server.ts +256 -79
- package/apps/cf-tcp-container/src/main.ts +22 -7
- package/apps/cf-tcp-container/src/proxy-protocol.ts +112 -0
- package/apps/cf-tcp-container/terraform/spectrum.tf +40 -11
- package/apps/cf-tcp-container/tests/config-loader.test.ts +170 -0
- package/apps/cf-tcp-container/tests/container-server-tls.test.ts +382 -0
- package/apps/cf-tcp-container/tests/container-server.test.ts +358 -31
- package/apps/cf-tcp-container/tests/dockerfile.test.ts +110 -0
- package/apps/cf-tcp-container/tests/proxy-protocol.test.ts +187 -0
- package/apps/cf-tcp-container/tests/spectrum-terraform.test.ts +135 -0
- package/apps/cf-tcp-container/tests/tls-e2e.test.ts +5 -1
- package/apps/cf-tcp-container/wrangler.toml +17 -4
- package/apps/cf-worker/package.json +2 -2
- package/apps/cf-worker/src/worker.ts +77 -5
- package/apps/cf-worker/tests/raw-modules.d.ts +11 -0
- package/apps/cf-worker/tests/smoke.test.ts +4 -0
- package/apps/cf-worker/tests/wrangler-config.test.ts +47 -0
- package/apps/cf-worker/tests/ws-admission.test.ts +112 -0
- package/apps/cf-worker/tests/ws-rate-limit.test.ts +133 -0
- package/apps/cf-worker/wrangler.test.toml +15 -1
- package/apps/cf-worker/wrangler.toml +86 -9
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/config-loader.ts +14 -2
- package/apps/local-cli/src/line-scanner.ts +26 -0
- package/apps/local-cli/src/server.ts +23 -2
- package/apps/local-cli/tests/line-scanner.test.ts +64 -0
- package/apps/local-cli/tests/tcp.test.ts +29 -0
- package/apps/web/package.json +1 -1
- package/docs/AWS-Deployment.md +123 -22
- package/docs/AWS-TCP-Deployment.md +37 -2
- package/docs/Chat-History.md +55 -0
- package/docs/Cloudflare-Deployment-Guide.md +9 -2
- package/docs/Cloudflare-TCP-Deployment.md +135 -52
- package/docs/SASL-EXTERNAL.md +175 -0
- package/package.json +3 -3
- package/packages/aws-adapter/package.json +1 -1
- package/packages/aws-adapter/src/admission.ts +28 -13
- package/packages/aws-adapter/src/aws-runtime.ts +30 -3
- package/packages/aws-adapter/src/cdk-table-defs.ts +34 -6
- package/packages/aws-adapter/src/config-loader.ts +134 -6
- package/packages/aws-adapter/src/dynamo-services-store.ts +12 -0
- package/packages/aws-adapter/src/handlers/connect.ts +47 -1
- package/packages/aws-adapter/src/handlers/default.ts +95 -6
- package/packages/aws-adapter/src/handlers/index.ts +31 -2
- package/packages/aws-adapter/src/handlers/nlb-stream.ts +132 -8
- package/packages/aws-adapter/src/ip-admission.ts +79 -0
- package/packages/aws-adapter/src/serialize.ts +8 -0
- package/packages/aws-adapter/src/tables.ts +9 -0
- package/packages/aws-adapter/tests/admission.test.ts +60 -2
- package/packages/aws-adapter/tests/aws-harness.ts +23 -1
- package/packages/aws-adapter/tests/aws-runtime.test.ts +64 -0
- package/packages/aws-adapter/tests/config-loader.test.ts +151 -0
- package/packages/aws-adapter/tests/connect.test.ts +199 -2
- package/packages/aws-adapter/tests/default-frame-limit.test.ts +231 -0
- package/packages/aws-adapter/tests/default-occ.test.ts +10 -3
- package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +123 -1
- package/packages/aws-adapter/tests/handlers.test.ts +57 -1
- package/packages/aws-adapter/tests/nlb-secure.test.ts +362 -0
- package/packages/aws-adapter/tests/nlb-stream.test.ts +628 -9
- package/packages/cf-adapter/package.json +1 -1
- package/packages/cf-adapter/src/cf-runtime.ts +48 -9
- package/packages/cf-adapter/src/config-loader.ts +133 -8
- package/packages/cf-adapter/src/connection-do.ts +154 -21
- package/packages/cf-adapter/src/counter-do.ts +142 -0
- package/packages/cf-adapter/src/d1-services-store.ts +47 -5
- package/packages/cf-adapter/src/env.ts +88 -0
- package/packages/cf-adapter/src/index.ts +17 -1
- package/packages/cf-adapter/src/rate-limit-do.ts +87 -0
- package/packages/cf-adapter/tests/cf-runtime.test.ts +104 -15
- package/packages/cf-adapter/tests/config-loader.test.ts +159 -0
- package/packages/cf-adapter/tests/connection-do-counter.test.ts +165 -0
- package/packages/cf-adapter/tests/connection-do-frame-limit.test.ts +177 -0
- package/packages/cf-adapter/tests/connection-do-pure.test.ts +74 -5
- package/packages/cf-adapter/tests/connection-do-ws-spec-contract.test.ts +7 -4
- package/packages/cf-adapter/tests/counter-do.test.ts +181 -0
- package/packages/cf-adapter/tests/d1-services-store.test.ts +192 -1
- package/packages/cf-adapter/tests/rate-limit-do.test.ts +160 -0
- package/packages/cf-adapter/tests/worker/main.ts +4 -0
- 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 +25 -0
- package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +74 -0
- package/packages/irc-core/package.json +1 -1
- package/packages/irc-core/src/caps/capabilities.ts +20 -10
- package/packages/irc-core/src/certfp.ts +178 -0
- package/packages/irc-core/src/commands/cap.ts +10 -2
- package/packages/irc-core/src/commands/chanserv.ts +117 -14
- package/packages/irc-core/src/commands/chathistory.ts +13 -5
- package/packages/irc-core/src/commands/hostserv.ts +84 -8
- package/packages/irc-core/src/commands/index.ts +2 -1
- package/packages/irc-core/src/commands/invite.ts +1 -7
- package/packages/irc-core/src/commands/join.ts +1 -16
- package/packages/irc-core/src/commands/kick.ts +1 -8
- package/packages/irc-core/src/commands/list.ts +1 -8
- package/packages/irc-core/src/commands/mode.ts +1 -8
- package/packages/irc-core/src/commands/multiline.ts +4 -10
- package/packages/irc-core/src/commands/names.ts +53 -13
- package/packages/irc-core/src/commands/nickserv.ts +40 -1
- package/packages/irc-core/src/commands/oper.ts +361 -8
- package/packages/irc-core/src/commands/part.ts +4 -10
- package/packages/irc-core/src/commands/privmsg.ts +8 -4
- package/packages/irc-core/src/commands/registration.ts +146 -2
- package/packages/irc-core/src/commands/sasl.ts +136 -19
- package/packages/irc-core/src/commands/topic.ts +10 -12
- package/packages/irc-core/src/commands/who.ts +1 -8
- package/packages/irc-core/src/config.ts +393 -20
- package/packages/irc-core/src/effects.ts +24 -0
- package/packages/irc-core/src/flood-control.ts +10 -10
- package/packages/irc-core/src/frame-rate-limit.ts +82 -0
- package/packages/irc-core/src/index.ts +8 -0
- package/packages/irc-core/src/oper-hashing.ts +43 -0
- package/packages/irc-core/src/oper-lockout.ts +87 -0
- package/packages/irc-core/src/ports.ts +395 -36
- package/packages/irc-core/src/protocol/bytes.ts +65 -0
- package/packages/irc-core/src/protocol/channel-name.ts +37 -0
- package/packages/irc-core/src/protocol/index.ts +12 -1
- package/packages/irc-core/src/protocol/outbound.ts +43 -10
- package/packages/irc-core/src/protocol/parser.ts +79 -10
- package/packages/irc-core/src/state/connection.ts +13 -0
- package/packages/irc-core/src/types.ts +228 -13
- package/packages/irc-core/src/ws-framing.ts +5 -4
- package/packages/irc-core/tests/bytes.test.ts +89 -0
- package/packages/irc-core/tests/certfp.test.ts +117 -0
- package/packages/irc-core/tests/commands/cap.test.ts +76 -2
- package/packages/irc-core/tests/commands/chanserv.test.ts +166 -0
- package/packages/irc-core/tests/commands/chathistory.test.ts +140 -0
- package/packages/irc-core/tests/commands/hostserv.test.ts +316 -0
- package/packages/irc-core/tests/commands/join.test.ts +78 -1
- package/packages/irc-core/tests/commands/names.test.ts +193 -0
- package/packages/irc-core/tests/commands/nickserv.test.ts +182 -2
- package/packages/irc-core/tests/commands/oper.test.ts +560 -2
- package/packages/irc-core/tests/commands/privmsg.test.ts +16 -0
- package/packages/irc-core/tests/commands/registration.test.ts +463 -1
- package/packages/irc-core/tests/commands/sasl.test.ts +596 -7
- package/packages/irc-core/tests/commands/topic.test.ts +137 -2
- package/packages/irc-core/tests/commands/unified-account.test.ts +2 -0
- package/packages/irc-core/tests/config.test.ts +534 -2
- package/packages/irc-core/tests/effects.test.ts +14 -0
- package/packages/irc-core/tests/flood-control.test.ts +29 -1
- package/packages/irc-core/tests/frame-rate-limit.test.ts +98 -0
- package/packages/irc-core/tests/oper-hashing.test.ts +60 -0
- package/packages/irc-core/tests/oper-lockout.test.ts +74 -0
- package/packages/irc-core/tests/outbound.test.ts +148 -0
- package/packages/irc-core/tests/parser.test.ts +287 -5
- package/packages/irc-core/tests/persistent-services-store.test.ts +141 -0
- package/packages/irc-core/tests/ports.test.ts +99 -7
- package/packages/irc-core/tests/services-store.test.ts +376 -14
- package/packages/irc-core/tests/ws-framing.test.ts +45 -0
- package/packages/irc-server/package.json +1 -1
- package/packages/irc-server/src/actor.ts +123 -8
- package/packages/irc-server/src/dispatch.ts +1 -0
- package/packages/irc-server/src/index.ts +7 -0
- package/packages/irc-server/src/redact.ts +159 -0
- package/packages/irc-server/src/runtime.ts +14 -0
- package/packages/irc-server/src/transport.ts +28 -1
- package/packages/irc-server/tests/actor.test.ts +544 -7
- package/packages/irc-server/tests/dispatch.test.ts +31 -0
- package/packages/irc-server/tests/redact.test.ts +198 -0
- package/packages/irc-server/tests/runtime.test.ts +2 -0
- package/packages/irc-server/tests/transport.test.ts +66 -0
- package/packages/irc-test-support/package.json +1 -1
- package/packages/irc-test-support/src/in-memory-harness.ts +4 -0
- package/scripts/package.json +1 -1
- package/tools/ci-hardening/package.json +2 -2
- package/tools/ci-hardening/src/cf-deploy-cli.ts +3 -0
- package/tools/ci-hardening/src/cf-deploy.ts +118 -0
- package/tools/ci-hardening/src/deploy-hostname.ts +118 -0
- package/tools/ci-hardening/src/env-var-drift.ts +192 -0
- package/tools/ci-hardening/src/hostname-guard.ts +11 -0
- package/tools/ci-hardening/src/index.ts +17 -0
- package/tools/ci-hardening/tests/__wrangler_missing__.toml +2 -0
- package/tools/ci-hardening/tests/__wrangler_placeholder__.toml +3 -0
- package/tools/ci-hardening/tests/__wrangler_real__.toml +3 -0
- package/tools/ci-hardening/tests/cf-deploy.test.ts +200 -0
- package/tools/ci-hardening/tests/deploy-hostname.test.ts +348 -0
- package/tools/ci-hardening/tests/env-var-drift.test.ts +284 -0
- package/tools/ci-hardening/vitest.config.ts +5 -1
- package/tools/hash-oper-cred.ts +85 -0
- package/tools/load-test/package.json +1 -1
- package/tools/tcp-ws-forwarder/package.json +1 -1
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
type AwayStore,
|
|
3
3
|
CapturingLogger,
|
|
4
|
+
type CertIdentity,
|
|
4
5
|
type ChanName,
|
|
5
6
|
type ChanSnapshot,
|
|
6
7
|
type ChannelDelta,
|
|
@@ -18,12 +19,14 @@ import {
|
|
|
18
19
|
InMemoryServicesStore,
|
|
19
20
|
LogLevel,
|
|
20
21
|
type Logger,
|
|
22
|
+
MAX_TAGS,
|
|
21
23
|
type MessageStore,
|
|
22
24
|
type MonitorStore,
|
|
23
25
|
type MtlsIdentityProvider,
|
|
24
26
|
type Nick,
|
|
25
27
|
type NickHistoryStore,
|
|
26
28
|
Numerics,
|
|
29
|
+
OperFailureStats,
|
|
27
30
|
type RawLine,
|
|
28
31
|
SequentialIdFactory,
|
|
29
32
|
type ServerConfig,
|
|
@@ -32,6 +35,7 @@ import {
|
|
|
32
35
|
type ServicesStore,
|
|
33
36
|
type StoredMessage,
|
|
34
37
|
applyChannelDelta as applyDelta,
|
|
38
|
+
certIdentityFromSubject,
|
|
35
39
|
createChannel,
|
|
36
40
|
createConnection,
|
|
37
41
|
encodeBase64,
|
|
@@ -113,6 +117,9 @@ class FakeRuntime implements IrcRuntime {
|
|
|
113
117
|
async broadcastWallops(lines: RawLine[], except?: ConnId): Promise<void> {
|
|
114
118
|
this.calls.push({ method: 'broadcastWallops', args: [lines, except] });
|
|
115
119
|
}
|
|
120
|
+
async broadcastOperNotice(lines: RawLine[], except?: ConnId): Promise<void> {
|
|
121
|
+
this.calls.push({ method: 'broadcastOperNotice', args: [lines, except] });
|
|
122
|
+
}
|
|
116
123
|
async disconnect(conn: ConnId, reason?: string): Promise<void> {
|
|
117
124
|
this.calls.push({ method: 'disconnect', args: [conn, reason] });
|
|
118
125
|
}
|
|
@@ -226,6 +233,8 @@ function makeActor(opts: {
|
|
|
226
233
|
monitor?: MonitorStore;
|
|
227
234
|
away?: AwayStore;
|
|
228
235
|
services?: ServicesStore;
|
|
236
|
+
operFailures?: OperFailureStats;
|
|
237
|
+
sasl?: ServerConfig['sasl'];
|
|
229
238
|
}): { actor: ConnectionActor; runtime: FakeRuntime; conn: ConnectionState } {
|
|
230
239
|
const clock = opts.clock ?? new FakeClock(1_000);
|
|
231
240
|
const conn = createConnection({ id: 'c1', connectedSince: 0 });
|
|
@@ -252,6 +261,7 @@ function makeActor(opts: {
|
|
|
252
261
|
quitMessage: 'Client Quit',
|
|
253
262
|
...(opts.floodControl !== undefined ? { floodControl: opts.floodControl } : {}),
|
|
254
263
|
...(opts.operCreds !== undefined ? { operCreds: opts.operCreds } : {}),
|
|
264
|
+
...(opts.sasl !== undefined ? { sasl: opts.sasl } : {}),
|
|
255
265
|
};
|
|
256
266
|
const ids = new SequentialIdFactory();
|
|
257
267
|
type ActorOpts = ConstructorParameters<typeof ConnectionActor>[0];
|
|
@@ -303,6 +313,9 @@ function makeActor(opts: {
|
|
|
303
313
|
if (opts.services !== undefined) {
|
|
304
314
|
finalOpts.services = opts.services;
|
|
305
315
|
}
|
|
316
|
+
if (opts.operFailures !== undefined) {
|
|
317
|
+
finalOpts.operFailures = opts.operFailures;
|
|
318
|
+
}
|
|
306
319
|
const actor = new ConnectionActor(finalOpts);
|
|
307
320
|
return { actor, runtime, conn };
|
|
308
321
|
}
|
|
@@ -581,6 +594,27 @@ describe('ConnectionActor — malformed input', () => {
|
|
|
581
594
|
expect(firstLines[0]?.text).toContain('BOGUS');
|
|
582
595
|
});
|
|
583
596
|
|
|
597
|
+
it('emits 421 with the too-many-tags reason for a 16-tag frame and continues', async () => {
|
|
598
|
+
const logger = new CapturingLogger();
|
|
599
|
+
const { actor, runtime } = makeActor({ logger });
|
|
600
|
+
const tags = Array.from({ length: MAX_TAGS + 1 }, (_, i) => `t${i}`);
|
|
601
|
+
await actor.receiveTextFrame(`@${tags.join(';')} PRIVMSG #foo :hi\r\nPING :tok\r\n`);
|
|
602
|
+
|
|
603
|
+
// Parse-error log carries the stable reason code, never the raw line
|
|
604
|
+
// (the tag section is attacker-controlled payload).
|
|
605
|
+
const errs = logger.records.filter((r) => r.msg === 'frame.parse-error');
|
|
606
|
+
expect(errs).toHaveLength(1);
|
|
607
|
+
expect(errs[0]?.fields?.reason).toBe('too-many-tags');
|
|
608
|
+
expect(JSON.stringify(errs[0])).not.toContain('PRIVMSG');
|
|
609
|
+
// Connection continues: 421 for the offending frame, PONG for the next.
|
|
610
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
611
|
+
expect(sends).toHaveLength(2);
|
|
612
|
+
const firstLines = sends[0]?.args[1] as RawLine[];
|
|
613
|
+
expect(firstLines[0]?.text).toContain('421');
|
|
614
|
+
const secondLines = sends[1]?.args[1] as RawLine[];
|
|
615
|
+
expect(secondLines[0]?.text).toBe('PONG :tok');
|
|
616
|
+
});
|
|
617
|
+
|
|
584
618
|
it('does not crash when the frame is an empty string', async () => {
|
|
585
619
|
const { actor, runtime } = makeActor({});
|
|
586
620
|
await expect(actor.receiveTextFrame('')).resolves.toBeUndefined();
|
|
@@ -643,6 +677,147 @@ describe('ConnectionActor — routing', () => {
|
|
|
643
677
|
});
|
|
644
678
|
});
|
|
645
679
|
|
|
680
|
+
describe('ConnectionActor — maxTargetsPerCommand cap on multi-target commands', () => {
|
|
681
|
+
// makeActor() configures serverConfig.maxTargetsPerCommand = 10.
|
|
682
|
+
const CAP = 10;
|
|
683
|
+
const OVER = 27;
|
|
684
|
+
|
|
685
|
+
/** Builds a comma-separated target list of `count` `<prefix><NN>` names. */
|
|
686
|
+
function csv(count: number, prefix: string): string {
|
|
687
|
+
return Array.from(
|
|
688
|
+
{ length: count },
|
|
689
|
+
(_, i) => `${prefix}${String(i + 1).padStart(2, '0')}`,
|
|
690
|
+
).join(',');
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
/** Same list, space-separated (USERHOST/ISON take separate params). */
|
|
694
|
+
function spaced(count: number, prefix: string): string {
|
|
695
|
+
return csv(count, prefix).split(',').join(' ');
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
/** Collects the `send` payloads whose lines carry the 407 numeric. */
|
|
699
|
+
function tooManyTargetSends(runtime: FakeRuntime): RawLine[][] {
|
|
700
|
+
return runtime.calls
|
|
701
|
+
.filter(
|
|
702
|
+
(c) =>
|
|
703
|
+
c.method === 'send' && (c.args[1] as RawLine[]).some((l) => l.text.includes(' 407 ')),
|
|
704
|
+
)
|
|
705
|
+
.map((c) => c.args[1] as RawLine[]);
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
it('caps multi-target PRIVMSG at maxTargetsPerCommand and 407s the dropped targets', async () => {
|
|
709
|
+
// Flood control disabled: the default token bucket would otherwise
|
|
710
|
+
// silently drop per-target reducer calls past its capacity, masking
|
|
711
|
+
// the target cap under test.
|
|
712
|
+
const { actor, runtime } = makeActor({ floodControl: null });
|
|
713
|
+
runtime.calls.length = 0;
|
|
714
|
+
|
|
715
|
+
await actor.receiveTextFrame(`PRIVMSG ${csv(OVER, 'n')} :hi\r\n`);
|
|
716
|
+
|
|
717
|
+
// Only the first CAP targets fan out.
|
|
718
|
+
const sendsToNick = runtime.calls.filter((c) => c.method === 'sendToNick');
|
|
719
|
+
expect(sendsToNick).toHaveLength(CAP);
|
|
720
|
+
expect(sendsToNick[0]?.args[0]).toBe('n01');
|
|
721
|
+
expect(sendsToNick[CAP - 1]?.args[0]).toBe('n10');
|
|
722
|
+
|
|
723
|
+
// Each dropped target gets a dedicated 407 ERR_TOOMANYTARGETS line.
|
|
724
|
+
const drops = tooManyTargetSends(runtime);
|
|
725
|
+
expect(drops).toHaveLength(OVER - CAP);
|
|
726
|
+
expect(drops[0]?.[0]?.text).toContain(' 407 alice n11 ');
|
|
727
|
+
expect(drops[0]?.[0]?.text).toContain(':Too many targets');
|
|
728
|
+
expect(drops[OVER - CAP - 1]?.[0]?.text).toContain('n27');
|
|
729
|
+
});
|
|
730
|
+
|
|
731
|
+
it('multi-target PRIVMSG below the cap is unaffected (no 407)', async () => {
|
|
732
|
+
const { actor, runtime } = makeActor({ floodControl: null });
|
|
733
|
+
runtime.calls.length = 0;
|
|
734
|
+
|
|
735
|
+
await actor.receiveTextFrame('PRIVMSG a,b,c :hi\r\n');
|
|
736
|
+
|
|
737
|
+
expect(runtime.calls.filter((c) => c.method === 'sendToNick')).toHaveLength(3);
|
|
738
|
+
expect(tooManyTargetSends(runtime)).toHaveLength(0);
|
|
739
|
+
});
|
|
740
|
+
|
|
741
|
+
it('caps multi-target NOTICE at maxTargetsPerCommand and 407s the dropped targets', async () => {
|
|
742
|
+
const { actor, runtime } = makeActor({ floodControl: null });
|
|
743
|
+
runtime.calls.length = 0;
|
|
744
|
+
|
|
745
|
+
await actor.receiveTextFrame(`NOTICE ${csv(OVER, 'n')} :hi\r\n`);
|
|
746
|
+
|
|
747
|
+
expect(runtime.calls.filter((c) => c.method === 'sendToNick')).toHaveLength(CAP);
|
|
748
|
+
const drops = tooManyTargetSends(runtime);
|
|
749
|
+
expect(drops).toHaveLength(OVER - CAP);
|
|
750
|
+
expect(drops[0]?.[0]?.text).toContain(' 407 alice n11 ');
|
|
751
|
+
});
|
|
752
|
+
|
|
753
|
+
it('caps multi-target TAGMSG at maxTargetsPerCommand and 407s the dropped targets', async () => {
|
|
754
|
+
const { actor, runtime } = makeActor({
|
|
755
|
+
floodControl: null,
|
|
756
|
+
conn: { caps: new Set<string>(['message-tags']) },
|
|
757
|
+
});
|
|
758
|
+
runtime.calls.length = 0;
|
|
759
|
+
|
|
760
|
+
await actor.receiveTextFrame(`@+typing=active TAGMSG ${csv(OVER, 'n')}\r\n`);
|
|
761
|
+
|
|
762
|
+
expect(runtime.calls.filter((c) => c.method === 'sendToNick')).toHaveLength(CAP);
|
|
763
|
+
expect(tooManyTargetSends(runtime)).toHaveLength(OVER - CAP);
|
|
764
|
+
});
|
|
765
|
+
|
|
766
|
+
it('caps multi-channel PART at maxTargetsPerCommand and 407s the dropped channels', async () => {
|
|
767
|
+
const { actor, runtime } = makeActor({ floodControl: null });
|
|
768
|
+
for (let i = 1; i <= OVER; i++) {
|
|
769
|
+
const name = `#c${String(i).padStart(2, '0')}` as ChanName;
|
|
770
|
+
await runtime.applyChannelDelta(name, {
|
|
771
|
+
memberships: [{ type: 'add', conn: 'c1', nick: 'alice' }],
|
|
772
|
+
});
|
|
773
|
+
}
|
|
774
|
+
runtime.calls.length = 0;
|
|
775
|
+
|
|
776
|
+
await actor.receiveTextFrame(`PART ${csv(OVER, '#c')} :bye\r\n`);
|
|
777
|
+
|
|
778
|
+
// Only the first CAP parts apply.
|
|
779
|
+
const parts = runtime.calls.filter((c) => c.method === 'applyChannelDelta');
|
|
780
|
+
expect(parts).toHaveLength(CAP);
|
|
781
|
+
const drops = tooManyTargetSends(runtime);
|
|
782
|
+
expect(drops).toHaveLength(OVER - CAP);
|
|
783
|
+
expect(drops[0]?.[0]?.text).toContain(' 407 alice #c11 ');
|
|
784
|
+
});
|
|
785
|
+
|
|
786
|
+
it('407s dropped targets with the * placeholder when the connection has no nick yet', async () => {
|
|
787
|
+
const { actor, runtime, conn } = makeActor({ floodControl: null });
|
|
788
|
+
conn.registration = 'pre-registration';
|
|
789
|
+
// biome-ignore lint/performance/noDelete: exactOptionalPropertyTypes
|
|
790
|
+
delete conn.nick;
|
|
791
|
+
runtime.calls.length = 0;
|
|
792
|
+
|
|
793
|
+
await actor.receiveTextFrame(`NOTICE ${csv(OVER, 'n')} :hi\r\n`);
|
|
794
|
+
|
|
795
|
+
const drops = tooManyTargetSends(runtime);
|
|
796
|
+
expect(drops).toHaveLength(OVER - CAP);
|
|
797
|
+
expect(drops[0]?.[0]?.text).toContain(' 407 * n11 ');
|
|
798
|
+
});
|
|
799
|
+
|
|
800
|
+
it('USERHOST still resolves at most maxTargetsPerCommand nicks (regression guard)', async () => {
|
|
801
|
+
const { actor, runtime } = makeActor({ floodControl: null });
|
|
802
|
+
runtime.calls.length = 0;
|
|
803
|
+
|
|
804
|
+
await actor.receiveTextFrame(`USERHOST ${spaced(OVER, 'n')}\r\n`);
|
|
805
|
+
|
|
806
|
+
expect(runtime.calls.filter((c) => c.method === 'lookupNick')).toHaveLength(CAP);
|
|
807
|
+
expect(tooManyTargetSends(runtime)).toHaveLength(0);
|
|
808
|
+
});
|
|
809
|
+
|
|
810
|
+
it('ISON still resolves at most maxTargetsPerCommand nicks (regression guard)', async () => {
|
|
811
|
+
const { actor, runtime } = makeActor({ floodControl: null });
|
|
812
|
+
runtime.calls.length = 0;
|
|
813
|
+
|
|
814
|
+
await actor.receiveTextFrame(`ISON ${spaced(OVER, 'n')}\r\n`);
|
|
815
|
+
|
|
816
|
+
expect(runtime.calls.filter((c) => c.method === 'lookupNick')).toHaveLength(CAP);
|
|
817
|
+
expect(tooManyTargetSends(runtime)).toHaveLength(0);
|
|
818
|
+
});
|
|
819
|
+
});
|
|
820
|
+
|
|
646
821
|
describe('ConnectionActor — secure transport (user mode `S`)', () => {
|
|
647
822
|
it('marks the connection state secure when constructed with secure: true', () => {
|
|
648
823
|
const { conn } = makeActor({ secure: true });
|
|
@@ -1276,6 +1451,53 @@ describe('ConnectionActor — additional routing', () => {
|
|
|
1276
1451
|
}
|
|
1277
1452
|
});
|
|
1278
1453
|
|
|
1454
|
+
it('rejects pre-registration OPER with 451 ERR_NOTREGISTERED without granting oper', async () => {
|
|
1455
|
+
const { actor, runtime, conn } = makeActor({
|
|
1456
|
+
conn: { registration: 'pre-registration' },
|
|
1457
|
+
operCreds: [{ user: 'alice', password: 'secret' }],
|
|
1458
|
+
});
|
|
1459
|
+
// Strip the registration handshake fields: a genuine pre-registration
|
|
1460
|
+
// client has no nick/user yet. Object.assign is the exactOptionalPropertyTypes
|
|
1461
|
+
// escape hatch (a `nick: undefined` literal or `delete` is rejected).
|
|
1462
|
+
Object.assign(conn, { nick: undefined, user: undefined });
|
|
1463
|
+
runtime.calls.length = 0;
|
|
1464
|
+
|
|
1465
|
+
// Valid credentials: without the gate the reducer would grant oper here.
|
|
1466
|
+
await actor.receiveTextFrame('OPER alice secret\r\n');
|
|
1467
|
+
|
|
1468
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
1469
|
+
expect(sends).toHaveLength(1);
|
|
1470
|
+
const lines = sends[0]?.args[1] as RawLine[];
|
|
1471
|
+
expect(lines[0]?.text).toBe(':irc.example.com 451 * :You have not registered');
|
|
1472
|
+
expect(conn.userModes.oper).toBe(false);
|
|
1473
|
+
});
|
|
1474
|
+
|
|
1475
|
+
it('grants oper with 381 once the same connection completes registration after a gated attempt', async () => {
|
|
1476
|
+
const { actor, runtime, conn } = makeActor({
|
|
1477
|
+
conn: { registration: 'pre-registration' },
|
|
1478
|
+
operCreds: [{ user: 'alice', password: 'secret' }],
|
|
1479
|
+
});
|
|
1480
|
+
Object.assign(conn, { nick: undefined, user: undefined });
|
|
1481
|
+
runtime.calls.length = 0;
|
|
1482
|
+
|
|
1483
|
+
await actor.receiveTextFrame('OPER alice secret\r\n');
|
|
1484
|
+
expect(conn.userModes.oper).toBe(false);
|
|
1485
|
+
|
|
1486
|
+
// Complete the handshake; the gate must not stick.
|
|
1487
|
+
await actor.receiveTextFrame('NICK alice\r\n');
|
|
1488
|
+
await actor.receiveTextFrame('USER alice 0 * :Alice\r\n');
|
|
1489
|
+
expect(conn.registration).toBe('registered');
|
|
1490
|
+
runtime.calls.length = 0;
|
|
1491
|
+
|
|
1492
|
+
await actor.receiveTextFrame('OPER alice secret\r\n');
|
|
1493
|
+
|
|
1494
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
1495
|
+
expect(sends).toHaveLength(2);
|
|
1496
|
+
const firstLines = sends[0]?.args[1] as RawLine[];
|
|
1497
|
+
expect(firstLines[0]?.text).toBe(':irc.example.com 381 alice :You are now an IRC operator');
|
|
1498
|
+
expect(conn.userModes.oper).toBe(true);
|
|
1499
|
+
});
|
|
1500
|
+
|
|
1279
1501
|
it('emits 421 with empty command when routeSingleChannel gets no channel (TOPIC)', async () => {
|
|
1280
1502
|
const { actor, runtime } = makeActor({});
|
|
1281
1503
|
await actor.receiveTextFrame('TOPIC\r\n');
|
|
@@ -1379,6 +1601,56 @@ describe('ConnectionActor — additional routing', () => {
|
|
|
1379
1601
|
}
|
|
1380
1602
|
});
|
|
1381
1603
|
|
|
1604
|
+
it('threads the shared per-IP OPER failure tracker: 3 failures then 491 + broadcastOperNotice', async () => {
|
|
1605
|
+
const clock = new FakeClock(1_000);
|
|
1606
|
+
const tracker = new OperFailureStats(clock);
|
|
1607
|
+
const { actor, runtime } = makeActor({
|
|
1608
|
+
clock,
|
|
1609
|
+
operCreds: [{ user: 'alice', password: 'secret' }],
|
|
1610
|
+
operFailures: tracker,
|
|
1611
|
+
});
|
|
1612
|
+
|
|
1613
|
+
for (let i = 0; i < 3; i++) {
|
|
1614
|
+
runtime.calls.length = 0;
|
|
1615
|
+
await actor.receiveTextFrame(`OPER alice :wrong-${i}\r\n`);
|
|
1616
|
+
const sends = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
|
|
1617
|
+
expect(sends).toHaveLength(1);
|
|
1618
|
+
expect((sends[0]?.args[1] as RawLine[])[0]?.text).toContain(' 464 ');
|
|
1619
|
+
// No oper notice until the lockout actually trips.
|
|
1620
|
+
expect(runtime.calls.some((c) => c.method === 'broadcastOperNotice')).toBe(false);
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1623
|
+
runtime.calls.length = 0;
|
|
1624
|
+
await actor.receiveTextFrame('OPER alice :secret\r\n');
|
|
1625
|
+
|
|
1626
|
+
// Locked out: 491 to the connection (never 464 — the credential store
|
|
1627
|
+
// was not consulted) and one oper-notice fanout to every +o connection.
|
|
1628
|
+
const sends = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
|
|
1629
|
+
expect(sends).toHaveLength(1);
|
|
1630
|
+
expect((sends[0]?.args[1] as RawLine[])[0]?.text).toBe(
|
|
1631
|
+
':irc.example.com 491 alice :No O-lines for your host',
|
|
1632
|
+
);
|
|
1633
|
+
const notices = runtime.calls.filter((c) => c.method === 'broadcastOperNotice');
|
|
1634
|
+
expect(notices).toHaveLength(1);
|
|
1635
|
+
const lines = notices[0]?.args[0] as RawLine[];
|
|
1636
|
+
expect(lines[0]?.text).toBe(
|
|
1637
|
+
':irc.example.com NOTICE * :OPER lockout triggered for example.com',
|
|
1638
|
+
);
|
|
1639
|
+
});
|
|
1640
|
+
|
|
1641
|
+
it('keeps OPER unlocked when no tracker is bound to the actor', async () => {
|
|
1642
|
+
const { actor, runtime } = makeActor({
|
|
1643
|
+
operCreds: [{ user: 'alice', password: 'secret' }],
|
|
1644
|
+
});
|
|
1645
|
+
for (let i = 0; i < 5; i++) {
|
|
1646
|
+
await actor.receiveTextFrame('OPER alice :wrong\r\n');
|
|
1647
|
+
}
|
|
1648
|
+
runtime.calls.length = 0;
|
|
1649
|
+
await actor.receiveTextFrame('OPER alice :secret\r\n');
|
|
1650
|
+
const sends = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
|
|
1651
|
+
expect((sends[0]?.args[1] as RawLine[])[0]?.text).toContain(' 381 ');
|
|
1652
|
+
});
|
|
1653
|
+
|
|
1382
1654
|
it('routes REHASH from an oper through rehashReducer (382, never 421) and re-invokes the loader', async () => {
|
|
1383
1655
|
const { actor, runtime, conn } = makeActor({
|
|
1384
1656
|
conn: {
|
|
@@ -1892,7 +2164,7 @@ describe('ConnectionActor — observability', () => {
|
|
|
1892
2164
|
}
|
|
1893
2165
|
});
|
|
1894
2166
|
|
|
1895
|
-
it('emits a warn-level frame.parse-error record
|
|
2167
|
+
it('emits a warn-level frame.parse-error record with token/length/reason', async () => {
|
|
1896
2168
|
const logger = new CapturingLogger();
|
|
1897
2169
|
const { actor } = makeActor({ logger, traceId: 't1' });
|
|
1898
2170
|
|
|
@@ -1903,7 +2175,53 @@ describe('ConnectionActor — observability', () => {
|
|
|
1903
2175
|
expect(errs).toHaveLength(1);
|
|
1904
2176
|
expect(errs[0]?.level).toBe(LogLevel.Warn);
|
|
1905
2177
|
expect(errs[0]?.traceId).toBe('t1');
|
|
1906
|
-
expect(
|
|
2178
|
+
expect(errs[0]?.fields?.token).toBe('12345');
|
|
2179
|
+
expect(errs[0]?.fields?.length).toBe('12345'.length);
|
|
2180
|
+
expect(errs[0]?.fields?.reason).toBe('invalid-command-token');
|
|
2181
|
+
});
|
|
2182
|
+
|
|
2183
|
+
it('never includes the raw line in a frame.parse-error record', async () => {
|
|
2184
|
+
const logger = new CapturingLogger();
|
|
2185
|
+
const { actor } = makeActor({ logger, traceId: 't2' });
|
|
2186
|
+
|
|
2187
|
+
// The 5-digit command trips the parser while the trailing PASS
|
|
2188
|
+
// payload carries a credential: a hostile client can smuggle secret
|
|
2189
|
+
// material into the warn record unless the payload is scrubbed. The
|
|
2190
|
+
// record must carry no `line` field and no trace of the secret.
|
|
2191
|
+
await actor.receiveTextFrame('12345 PASS hunter2\r\n');
|
|
2192
|
+
|
|
2193
|
+
const errs = logger.records.filter((r) => r.msg === 'frame.parse-error');
|
|
2194
|
+
expect(errs).toHaveLength(1);
|
|
2195
|
+
expect(errs[0]?.fields?.line).toBeUndefined();
|
|
2196
|
+
expect(JSON.stringify(errs[0])).not.toContain('hunter2');
|
|
2197
|
+
});
|
|
2198
|
+
|
|
2199
|
+
it('truncates an over-long leading token in a parse-error record', async () => {
|
|
2200
|
+
const logger = new CapturingLogger();
|
|
2201
|
+
const { actor } = makeActor({ logger, traceId: 't3' });
|
|
2202
|
+
|
|
2203
|
+
// 32 letters + a digit is an illegal command token (mixed) AND the
|
|
2204
|
+
// 33-char token exceeds the 32-char log cap.
|
|
2205
|
+
await actor.receiveTextFrame(`${'X'.repeat(32)}9 PASS hunter2\r\n`);
|
|
2206
|
+
|
|
2207
|
+
const errs = logger.records.filter((r) => r.msg === 'frame.parse-error');
|
|
2208
|
+
expect(errs).toHaveLength(1);
|
|
2209
|
+
expect(errs[0]?.fields?.token).toBe(`${'X'.repeat(32)}...`);
|
|
2210
|
+
expect(JSON.stringify(errs[0])).not.toContain('hunter2');
|
|
2211
|
+
});
|
|
2212
|
+
|
|
2213
|
+
it('reports the input-cap reason for an over-long line', async () => {
|
|
2214
|
+
const logger = new CapturingLogger();
|
|
2215
|
+
const { actor } = makeActor({ logger, traceId: 't4' });
|
|
2216
|
+
|
|
2217
|
+
// 600 bytes exceeds the 512-byte input cap → the byte-cap branch of
|
|
2218
|
+
// the parser fires before the command-token check.
|
|
2219
|
+
await actor.receiveTextFrame(`${'A'.repeat(600)}\r\n`);
|
|
2220
|
+
|
|
2221
|
+
const errs = logger.records.filter((r) => r.msg === 'frame.parse-error');
|
|
2222
|
+
expect(errs).toHaveLength(1);
|
|
2223
|
+
expect(errs[0]?.fields?.reason).toBe('input-cap-exceeded');
|
|
2224
|
+
expect(errs[0]?.fields?.length).toBe(600);
|
|
1907
2225
|
});
|
|
1908
2226
|
|
|
1909
2227
|
it('falls back to a NoopLogger and unknown traceId when omitted', async () => {
|
|
@@ -2204,10 +2522,10 @@ describe('ConnectionActor — SASL ServicesStore plumbing', () => {
|
|
|
2204
2522
|
});
|
|
2205
2523
|
|
|
2206
2524
|
describe('ConnectionActor — SASL EXTERNAL + mTLS plumbing', () => {
|
|
2207
|
-
/** Fake MtlsIdentityProvider returning a canned
|
|
2525
|
+
/** Fake MtlsIdentityProvider returning a canned cert identity for 'c1'. */
|
|
2208
2526
|
class FakeMtlsProvider implements MtlsIdentityProvider {
|
|
2209
|
-
constructor(private readonly identity:
|
|
2210
|
-
getIdentity(connId: string):
|
|
2527
|
+
constructor(private readonly identity: CertIdentity | undefined) {}
|
|
2528
|
+
getIdentity(connId: string): CertIdentity | undefined {
|
|
2211
2529
|
return connId === 'c1' ? this.identity : undefined;
|
|
2212
2530
|
}
|
|
2213
2531
|
}
|
|
@@ -2220,15 +2538,19 @@ describe('ConnectionActor — SASL EXTERNAL + mTLS plumbing', () => {
|
|
|
2220
2538
|
};
|
|
2221
2539
|
}
|
|
2222
2540
|
|
|
2223
|
-
it('completes SASL EXTERNAL with 900/903 when mTLS provider and store accept', async () => {
|
|
2541
|
+
it('completes SASL EXTERNAL with 900/903 when mTLS provider, operator opt-in, secure transport, and store accept', async () => {
|
|
2224
2542
|
const services = new InMemoryServicesStore({ clock: new FakeClock(0) });
|
|
2225
2543
|
services.registerNick('alice', 'secret', 'alice@example.com');
|
|
2226
2544
|
services.addCertFP('alice', 'CN=alice');
|
|
2227
|
-
const mtlsIdentity = new FakeMtlsProvider('CN=alice');
|
|
2545
|
+
const mtlsIdentity = new FakeMtlsProvider(certIdentityFromSubject('CN=alice'));
|
|
2228
2546
|
const { actor, runtime, conn } = makeActor({
|
|
2229
2547
|
conn: saslReadyConn(),
|
|
2230
2548
|
services,
|
|
2231
2549
|
mtlsIdentity,
|
|
2550
|
+
// The EXTERNAL triple gate: mTLS provider bound (above) +
|
|
2551
|
+
// operator opt-in + a secure (TLS) connection.
|
|
2552
|
+
sasl: { externalEnabled: true },
|
|
2553
|
+
secure: true,
|
|
2232
2554
|
});
|
|
2233
2555
|
conn.nick = 'alice';
|
|
2234
2556
|
runtime.calls.length = 0;
|
|
@@ -2264,6 +2586,31 @@ describe('ConnectionActor — SASL EXTERNAL + mTLS plumbing', () => {
|
|
|
2264
2586
|
expect(lines.some((l) => l.text.includes(' 904 '))).toBe(false);
|
|
2265
2587
|
expect(conn.account).toBeUndefined();
|
|
2266
2588
|
});
|
|
2589
|
+
|
|
2590
|
+
it('answers 908 when the operator opt-in is missing even with mTLS and a secure connection', async () => {
|
|
2591
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(0) });
|
|
2592
|
+
services.registerNick('alice', 'secret', 'alice@example.com');
|
|
2593
|
+
services.addCertFP('alice', 'CN=alice');
|
|
2594
|
+
const mtlsIdentity = new FakeMtlsProvider(certIdentityFromSubject('CN=alice'));
|
|
2595
|
+
// mTLS provider bound + secure connection, but sasl.externalEnabled
|
|
2596
|
+
// left at the default (off) → EXTERNAL is refused with 908.
|
|
2597
|
+
const { actor, runtime, conn } = makeActor({
|
|
2598
|
+
conn: saslReadyConn(),
|
|
2599
|
+
services,
|
|
2600
|
+
mtlsIdentity,
|
|
2601
|
+
secure: true,
|
|
2602
|
+
});
|
|
2603
|
+
conn.nick = 'alice';
|
|
2604
|
+
runtime.calls.length = 0;
|
|
2605
|
+
|
|
2606
|
+
await actor.receiveTextFrame('AUTHENTICATE EXTERNAL\r\n');
|
|
2607
|
+
|
|
2608
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
2609
|
+
const lines = sends.flatMap((s) => s.args[1] as RawLine[]);
|
|
2610
|
+
expect(lines.some((l) => l.text.includes(' 908 '))).toBe(true);
|
|
2611
|
+
expect(lines.some((l) => l.text.includes(' 904 '))).toBe(false);
|
|
2612
|
+
expect(conn.account).toBeUndefined();
|
|
2613
|
+
});
|
|
2267
2614
|
});
|
|
2268
2615
|
|
|
2269
2616
|
describe('ConnectionActor — server-info commands (VERSION/TIME/ADMIN/INFO/USERHOST/ISON)', () => {
|
|
@@ -3971,6 +4318,196 @@ describe('ConnectionActor — draft/multiline BATCH', () => {
|
|
|
3971
4318
|
).accumulateMultilineLine({ command: 'PRIVMSG', params: ['#foo', 'hi'] });
|
|
3972
4319
|
expect(result).toEqual([]);
|
|
3973
4320
|
});
|
|
4321
|
+
|
|
4322
|
+
it('aborts the batch mid-stream when an inner line pushes it over the byte budget (no close needed)', async () => {
|
|
4323
|
+
const { actor, runtime, conn } = makeActor({});
|
|
4324
|
+
conn.caps = new Set<string>(['batch', 'draft/multiline', 'standard-replies']);
|
|
4325
|
+
// Tiny budget: 6 bytes + separator + 6 bytes = 13 > 10 on the second push.
|
|
4326
|
+
(actor as unknown as { serverConfig: ServerConfig }).serverConfig = {
|
|
4327
|
+
...(actor as unknown as { serverConfig: ServerConfig }).serverConfig,
|
|
4328
|
+
multilineMaxBytes: 10,
|
|
4329
|
+
};
|
|
4330
|
+
await setupMultilineChannel(runtime, [
|
|
4331
|
+
{ conn: 'c1', nick: 'alice', caps: ['batch', 'draft/multiline'] },
|
|
4332
|
+
{ conn: 'c2', nick: 'bob' },
|
|
4333
|
+
]);
|
|
4334
|
+
runtime.calls.length = 0;
|
|
4335
|
+
|
|
4336
|
+
await actor.receiveTextFrame('BATCH +m1 draft/multiline :#foo\r\n');
|
|
4337
|
+
await actor.receiveTextFrame('PRIVMSG #foo :aaaaaa\r\n');
|
|
4338
|
+
// No `BATCH -m1` close — the over-budget push itself must abort the batch.
|
|
4339
|
+
await actor.receiveTextFrame('PRIVMSG #foo :bbbbbb\r\n');
|
|
4340
|
+
|
|
4341
|
+
const aliceLines = sentTo(runtime, 'c1');
|
|
4342
|
+
// A synthesized `BATCH -m1` closes the aborted batch client-side...
|
|
4343
|
+
expect(aliceLines.some((l) => l.text === 'BATCH -m1')).toBe(true);
|
|
4344
|
+
// ...followed by the FAIL rejection.
|
|
4345
|
+
expect(
|
|
4346
|
+
aliceLines.some(
|
|
4347
|
+
(l) =>
|
|
4348
|
+
l.text ===
|
|
4349
|
+
':irc.example.com FAIL BATCH MULTILINE_MAX_BYTES #foo :Multi-line batch exceeds the 10 byte limit',
|
|
4350
|
+
),
|
|
4351
|
+
).toBe(true);
|
|
4352
|
+
// Nothing was relayed to the channel.
|
|
4353
|
+
expect(runtime.calls.some((c) => c.method === 'broadcast')).toBe(false);
|
|
4354
|
+
// The accumulator is gone: a late `BATCH -m1` close hits the unknown-id 421 path.
|
|
4355
|
+
await actor.receiveTextFrame('BATCH -m1\r\n');
|
|
4356
|
+
expect(sentTo(runtime, 'c1').some((l) => l.text.includes(' 421 '))).toBe(true);
|
|
4357
|
+
});
|
|
4358
|
+
|
|
4359
|
+
it('accepts an inner line that lands exactly at the byte budget (boundary is inclusive)', async () => {
|
|
4360
|
+
const { actor, runtime, conn } = makeActor({});
|
|
4361
|
+
conn.caps = new Set<string>(['batch', 'draft/multiline']);
|
|
4362
|
+
// Budget 13: 6 bytes + separator + 6 bytes = 13 → accepted.
|
|
4363
|
+
(actor as unknown as { serverConfig: ServerConfig }).serverConfig = {
|
|
4364
|
+
...(actor as unknown as { serverConfig: ServerConfig }).serverConfig,
|
|
4365
|
+
multilineMaxBytes: 13,
|
|
4366
|
+
};
|
|
4367
|
+
await setupMultilineChannel(runtime, [
|
|
4368
|
+
{ conn: 'c1', nick: 'alice', caps: ['batch', 'draft/multiline'] },
|
|
4369
|
+
{ conn: 'c2', nick: 'bob' },
|
|
4370
|
+
]);
|
|
4371
|
+
runtime.calls.length = 0;
|
|
4372
|
+
|
|
4373
|
+
await actor.receiveTextFrame('BATCH +m1 draft/multiline :#foo\r\n');
|
|
4374
|
+
await actor.receiveTextFrame('PRIVMSG #foo :aaaaaa\r\n');
|
|
4375
|
+
await actor.receiveTextFrame('PRIVMSG #foo :cccccc\r\n');
|
|
4376
|
+
expect(sentTo(runtime, 'c1').some((l) => l.text.includes('FAIL BATCH'))).toBe(false);
|
|
4377
|
+
await actor.receiveTextFrame('BATCH -m1\r\n');
|
|
4378
|
+
|
|
4379
|
+
// The at-budget batch finalizes normally: bob gets the joined PRIVMSG.
|
|
4380
|
+
const bobLines = sentTo(runtime, 'c2');
|
|
4381
|
+
expect(
|
|
4382
|
+
bobLines.some((l) => l.text === ':alice!alice@example.com PRIVMSG #foo :aaaaaa\ncccccc'),
|
|
4383
|
+
).toBe(true);
|
|
4384
|
+
});
|
|
4385
|
+
|
|
4386
|
+
it('rejects the 101st inner line by entry count even when under the byte budget', async () => {
|
|
4387
|
+
const { actor, runtime, conn } = makeActor({});
|
|
4388
|
+
conn.caps = new Set<string>(['batch', 'draft/multiline', 'standard-replies']);
|
|
4389
|
+
await setupMultilineChannel(runtime, [
|
|
4390
|
+
{ conn: 'c1', nick: 'alice', caps: ['batch', 'draft/multiline'] },
|
|
4391
|
+
{ conn: 'c2', nick: 'bob' },
|
|
4392
|
+
]);
|
|
4393
|
+
runtime.calls.length = 0;
|
|
4394
|
+
|
|
4395
|
+
await actor.receiveTextFrame('BATCH +m1 draft/multiline :#foo\r\n');
|
|
4396
|
+
// 100 single-byte entries: joined bytes 199 < 4096, all accepted.
|
|
4397
|
+
await actor.receiveTextFrame('PRIVMSG #foo :x\r\n'.repeat(100));
|
|
4398
|
+
expect(sentTo(runtime, 'c1').some((l) => l.text.includes('FAIL BATCH'))).toBe(false);
|
|
4399
|
+
|
|
4400
|
+
// The 101st push trips the defense-in-depth entry cap.
|
|
4401
|
+
await actor.receiveTextFrame('PRIVMSG #foo :x\r\n');
|
|
4402
|
+
const aliceLines = sentTo(runtime, 'c1');
|
|
4403
|
+
expect(aliceLines.some((l) => l.text === 'BATCH -m1')).toBe(true);
|
|
4404
|
+
expect(
|
|
4405
|
+
aliceLines.some(
|
|
4406
|
+
(l) =>
|
|
4407
|
+
l.text ===
|
|
4408
|
+
':irc.example.com FAIL BATCH MULTILINE_MAX_LINES #foo :Multi-line batch exceeds the 100 line limit',
|
|
4409
|
+
),
|
|
4410
|
+
).toBe(true);
|
|
4411
|
+
|
|
4412
|
+
// The accumulator is gone: a normal PRIVMSG now delivers instead of
|
|
4413
|
+
// accumulating into the aborted batch.
|
|
4414
|
+
runtime.calls.length = 0;
|
|
4415
|
+
await actor.receiveTextFrame('PRIVMSG #foo :plain\r\n');
|
|
4416
|
+
expect(
|
|
4417
|
+
sentTo(runtime, 'c2').some((l) => l.text === ':alice!alice@example.com PRIVMSG #foo :plain'),
|
|
4418
|
+
).toBe(true);
|
|
4419
|
+
});
|
|
4420
|
+
|
|
4421
|
+
it('honours a configured multilineMaxEntries override', async () => {
|
|
4422
|
+
const { actor, runtime, conn } = makeActor({});
|
|
4423
|
+
conn.caps = new Set<string>(['batch', 'draft/multiline', 'standard-replies']);
|
|
4424
|
+
(actor as unknown as { serverConfig: ServerConfig }).serverConfig = {
|
|
4425
|
+
...(actor as unknown as { serverConfig: ServerConfig }).serverConfig,
|
|
4426
|
+
multilineMaxEntries: 2,
|
|
4427
|
+
};
|
|
4428
|
+
await setupMultilineChannel(runtime, [
|
|
4429
|
+
{ conn: 'c1', nick: 'alice', caps: ['batch', 'draft/multiline'] },
|
|
4430
|
+
]);
|
|
4431
|
+
runtime.calls.length = 0;
|
|
4432
|
+
|
|
4433
|
+
await actor.receiveTextFrame('BATCH +m1 draft/multiline :#foo\r\n');
|
|
4434
|
+
await actor.receiveTextFrame('PRIVMSG #foo :one\r\n');
|
|
4435
|
+
await actor.receiveTextFrame('PRIVMSG #foo :two\r\n');
|
|
4436
|
+
await actor.receiveTextFrame('PRIVMSG #foo :three\r\n');
|
|
4437
|
+
|
|
4438
|
+
const aliceLines = sentTo(runtime, 'c1');
|
|
4439
|
+
expect(aliceLines.some((l) => l.text === 'BATCH -m1')).toBe(true);
|
|
4440
|
+
expect(
|
|
4441
|
+
aliceLines.some(
|
|
4442
|
+
(l) =>
|
|
4443
|
+
l.text ===
|
|
4444
|
+
':irc.example.com FAIL BATCH MULTILINE_MAX_LINES #foo :Multi-line batch exceeds the 2 line limit',
|
|
4445
|
+
),
|
|
4446
|
+
).toBe(true);
|
|
4447
|
+
});
|
|
4448
|
+
|
|
4449
|
+
it('clears an abandoned open batch on connection teardown (dispose)', async () => {
|
|
4450
|
+
const { actor, runtime, conn } = makeActor({});
|
|
4451
|
+
conn.caps = new Set<string>(['batch', 'draft/multiline']);
|
|
4452
|
+
await setupMultilineChannel(runtime, [
|
|
4453
|
+
{ conn: 'c1', nick: 'alice', caps: ['batch', 'draft/multiline'] },
|
|
4454
|
+
{ conn: 'c2', nick: 'bob' },
|
|
4455
|
+
]);
|
|
4456
|
+
runtime.calls.length = 0;
|
|
4457
|
+
|
|
4458
|
+
await actor.receiveTextFrame('BATCH +m1 draft/multiline :#foo\r\n');
|
|
4459
|
+
await actor.receiveTextFrame('PRIVMSG #foo :partial\r\n');
|
|
4460
|
+
expect((actor as unknown as { openMultilineBatch?: unknown }).openMultilineBatch).toBeDefined();
|
|
4461
|
+
|
|
4462
|
+
actor.dispose();
|
|
4463
|
+
|
|
4464
|
+
expect(
|
|
4465
|
+
(actor as unknown as { openMultilineBatch?: unknown }).openMultilineBatch,
|
|
4466
|
+
).toBeUndefined();
|
|
4467
|
+
// Post-teardown traffic delivers normally — nothing leaks into a stale batch.
|
|
4468
|
+
await actor.receiveTextFrame('PRIVMSG #foo :after\r\n');
|
|
4469
|
+
expect(
|
|
4470
|
+
sentTo(runtime, 'c2').some((l) => l.text === ':alice!alice@example.com PRIVMSG #foo :after'),
|
|
4471
|
+
).toBe(true);
|
|
4472
|
+
});
|
|
4473
|
+
|
|
4474
|
+
it('dispose() is a safe no-op when no batch is open', () => {
|
|
4475
|
+
const { actor } = makeActor({});
|
|
4476
|
+
expect(() => actor.dispose()).not.toThrow();
|
|
4477
|
+
expect(
|
|
4478
|
+
(actor as unknown as { openMultilineBatch?: unknown }).openMultilineBatch,
|
|
4479
|
+
).toBeUndefined();
|
|
4480
|
+
});
|
|
4481
|
+
|
|
4482
|
+
it('nick-target close still rejects an over-budget batch (close-time guard survives the incremental check)', async () => {
|
|
4483
|
+
const { actor, runtime, conn } = makeActor({});
|
|
4484
|
+
conn.caps = new Set<string>(['batch', 'draft/multiline']);
|
|
4485
|
+
(actor as unknown as { serverConfig: ServerConfig }).serverConfig = {
|
|
4486
|
+
...(actor as unknown as { serverConfig: ServerConfig }).serverConfig,
|
|
4487
|
+
multilineMaxBytes: 5,
|
|
4488
|
+
};
|
|
4489
|
+
// The incremental accumulator aborts over-budget pushes before close, so
|
|
4490
|
+
// the close-time guard only fires when the budget shrinks under an open
|
|
4491
|
+
// batch (e.g. a REHASH between BATCH +id and BATCH -id). Inject the
|
|
4492
|
+
// over-budget batch directly and close it through the real route.
|
|
4493
|
+
(actor as unknown as { openMultilineBatch: unknown }).openMultilineBatch = {
|
|
4494
|
+
id: 'm1',
|
|
4495
|
+
target: 'bob',
|
|
4496
|
+
command: 'PRIVMSG',
|
|
4497
|
+
entries: [{ text: 'aaaaaaaaaa' }],
|
|
4498
|
+
};
|
|
4499
|
+
runtime.calls.length = 0;
|
|
4500
|
+
|
|
4501
|
+
await actor.receiveTextFrame('BATCH -m1\r\n');
|
|
4502
|
+
|
|
4503
|
+
expect(
|
|
4504
|
+
sentTo(runtime, 'c1').some(
|
|
4505
|
+
(l) =>
|
|
4506
|
+
l.text ===
|
|
4507
|
+
':irc.example.com FAIL BATCH MULTILINE_MAX_BYTES bob :Multi-line batch exceeds the 5 byte limit',
|
|
4508
|
+
),
|
|
4509
|
+
).toBe(true);
|
|
4510
|
+
});
|
|
3974
4511
|
});
|
|
3975
4512
|
|
|
3976
4513
|
// ---------------------------------------------------------------------------
|