serverless-ircd 0.9.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-aws.yml +156 -32
- package/.github/workflows/deploy-cf-tcp.yml +35 -9
- package/.github/workflows/deploy-cf.yml +40 -14
- package/CHANGELOG.md +594 -0
- package/README.md +286 -60
- package/apps/aws-stack/README.md +3 -5
- package/apps/aws-stack/bin/aws.ts +118 -9
- package/apps/aws-stack/cdk.json +0 -3
- package/apps/aws-stack/package.json +3 -4
- package/apps/aws-stack/src/aws-stack.ts +398 -67
- package/apps/aws-stack/src/static-site.ts +323 -0
- package/apps/aws-stack/tests/smoke-helpers.test.ts +1 -1
- package/apps/aws-stack/tests/stack.test.ts +714 -105
- package/apps/aws-stack/tests/static-site.test.ts +491 -0
- package/apps/aws-stack/tests/synth-no-bundle.test.ts +0 -1
- package/apps/cf-tcp-container/Dockerfile +37 -5
- package/apps/cf-tcp-container/package.json +7 -3
- package/apps/cf-tcp-container/src/config-loader.ts +113 -2
- package/apps/cf-tcp-container/src/container-server.ts +267 -87
- 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 +18 -14
- package/apps/cf-worker/package.json +3 -4
- 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 +95 -77
- 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 +44 -19
- package/apps/local-cli/tests/line-scanner.test.ts +64 -0
- package/apps/local-cli/tests/tcp.test.ts +29 -0
- package/apps/web/landing/favicon.ico +0 -0
- package/apps/web/landing/index.html +1 -0
- package/apps/web/package.json +2 -2
- package/apps/web/scripts/build.mjs +66 -4
- package/apps/web/src/build-env.ts +125 -4
- package/apps/web/src/config-schema.ts +20 -6
- package/apps/web/static/{config.staging.json → config.prod-aws.json} +3 -2
- package/apps/web/tests/build-env.test.ts +210 -9
- package/apps/web/tests/build-smoke.test.ts +2 -2
- package/apps/web/tests/config-schema.test.ts +149 -25
- package/docs/AWS-Deployment.md +793 -118
- package/docs/AWS-TCP-Deployment.md +57 -47
- package/docs/Chat-History.md +55 -0
- package/docs/Cloudflare-Deployment-Guide.md +95 -114
- package/docs/Cloudflare-TCP-Deployment.md +160 -101
- package/docs/Release-Process.md +27 -23
- package/docs/SASL-EXTERNAL.md +175 -0
- package/docs/Services.md +69 -22
- package/docs/WebClientGuide.md +35 -26
- package/package.json +7 -10
- 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 +39 -16
- package/packages/aws-adapter/src/config-loader.ts +153 -8
- package/packages/aws-adapter/src/dynamo-services-store.ts +19 -0
- package/packages/aws-adapter/src/handlers/connect.ts +73 -1
- package/packages/aws-adapter/src/handlers/default.ts +279 -123
- package/packages/aws-adapter/src/handlers/index.ts +98 -25
- package/packages/aws-adapter/src/handlers/nlb-stream.ts +135 -14
- package/packages/aws-adapter/src/index.ts +5 -7
- package/packages/aws-adapter/src/ip-admission.ts +79 -0
- package/packages/aws-adapter/src/origin-allowlist.ts +94 -0
- package/packages/aws-adapter/src/serialize.ts +23 -0
- package/packages/aws-adapter/src/tables.ts +11 -12
- package/packages/aws-adapter/tests/admission.test.ts +60 -2
- package/packages/aws-adapter/tests/aws-harness.ts +23 -2
- package/packages/aws-adapter/tests/aws-runtime.test.ts +64 -0
- package/packages/aws-adapter/tests/config-loader.test.ts +217 -0
- package/packages/aws-adapter/tests/connect.test.ts +323 -3
- package/packages/aws-adapter/tests/default-frame-limit.test.ts +231 -0
- package/packages/aws-adapter/tests/default-occ.test.ts +226 -0
- package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +134 -1
- package/packages/aws-adapter/tests/handlers.test.ts +174 -12
- package/packages/aws-adapter/tests/migrate-accounts-to-services.test.ts +164 -0
- package/packages/aws-adapter/tests/nlb-secure.test.ts +362 -0
- package/packages/aws-adapter/tests/nlb-stream.test.ts +628 -9
- package/packages/aws-adapter/tests/origin-allowlist.test.ts +110 -0
- package/packages/aws-adapter/tests/ping-checker.test.ts +0 -1
- package/packages/aws-adapter/tests/stats.test.ts +0 -3
- package/packages/aws-adapter/tests/sweeper.test.ts +0 -1
- package/packages/aws-adapter/tests/tables.test.ts +1 -8
- package/packages/aws-adapter/tests/transactions.test.ts +0 -1
- package/packages/cf-adapter/package.json +1 -5
- package/packages/cf-adapter/src/cf-runtime.ts +100 -10
- package/packages/cf-adapter/src/channel-do.ts +13 -3
- package/packages/cf-adapter/src/config-loader.ts +133 -8
- package/packages/cf-adapter/src/connection-do.ts +406 -116
- package/packages/cf-adapter/src/counter-do.ts +142 -0
- package/packages/cf-adapter/src/d1-services-store.ts +105 -26
- package/packages/cf-adapter/src/env.ts +99 -10
- package/packages/cf-adapter/src/index.ts +17 -7
- package/packages/cf-adapter/src/rate-limit-do.ts +87 -0
- package/packages/cf-adapter/tests/cf-runtime.test.ts +205 -16
- package/packages/cf-adapter/tests/channel-do.test.ts +118 -1
- 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-coverage.test.ts +460 -0
- package/packages/cf-adapter/tests/connection-do-frame-limit.test.ts +177 -0
- package/packages/cf-adapter/tests/connection-do-pure.test.ts +164 -54
- package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +62 -38
- 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 +245 -3
- package/packages/cf-adapter/tests/rate-limit-do.test.ts +160 -0
- package/packages/cf-adapter/tests/serialize.test.ts +25 -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/account-migration.ts +140 -0
- 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/account-auth.ts +16 -19
- 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/memoserv.ts +1 -1
- 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 +161 -11
- 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 +148 -4
- package/packages/irc-core/src/commands/sasl.ts +154 -46
- 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 +424 -25
- package/packages/irc-core/src/credential-hashing.ts +11 -54
- 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 +9 -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 +529 -190
- 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 +266 -23
- package/packages/irc-core/src/ws-framing.ts +5 -4
- package/packages/irc-core/tests/account-migration.test.ts +133 -0
- 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/markread.test.ts +54 -0
- package/packages/irc-core/tests/commands/memoserv.test.ts +19 -0
- package/packages/irc-core/tests/commands/names.test.ts +193 -0
- package/packages/irc-core/tests/commands/nickserv.test.ts +419 -3
- package/packages/irc-core/tests/commands/oper.test.ts +574 -1
- package/packages/irc-core/tests/commands/privmsg.test.ts +16 -0
- package/packages/irc-core/tests/commands/registration.test.ts +602 -133
- package/packages/irc-core/tests/commands/sasl.test.ts +742 -172
- package/packages/irc-core/tests/commands/topic.test.ts +137 -2
- package/packages/irc-core/tests/commands/unified-account.test.ts +104 -84
- package/packages/irc-core/tests/config.test.ts +534 -2
- package/packages/irc-core/tests/credential-hashing.test.ts +0 -78
- 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/message-store.test.ts +5 -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 +212 -12
- package/packages/irc-core/tests/ports.test.ts +170 -7
- package/packages/irc-core/tests/services-store.test.ts +567 -1
- package/packages/irc-core/tests/ws-framing.test.ts +45 -0
- package/packages/irc-core/vitest.config.ts +6 -1
- package/packages/irc-server/package.json +1 -1
- package/packages/irc-server/src/actor.ts +123 -22
- 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 +563 -54
- 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/pnpm-workspace.yaml +1 -0
- package/scripts/__tests__/deploy-web-aws.test.ts +491 -0
- package/scripts/deploy-web-aws.mjs +290 -0
- package/scripts/package.json +23 -0
- package/scripts/tsconfig.test.json +12 -0
- package/scripts/vitest.config.ts +19 -0
- 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 +19 -0
- package/tools/ci-hardening/src/validate.ts +57 -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-aws-oidc.test.ts +96 -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/tests/validate.test.ts +42 -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/migrate-accounts-to-services.ts +270 -0
- package/tools/package.json +2 -1
- package/tools/seed-aws-accounts.ts +35 -10
- package/tools/seed-cf-accounts.ts +42 -9
- package/tools/tcp-ws-forwarder/package.json +1 -1
- package/packages/aws-adapter/src/account-store.ts +0 -121
- package/packages/aws-adapter/src/dynamo-account-store.ts +0 -95
- package/packages/aws-adapter/tests/account-store-dynamo.test.ts +0 -223
- package/packages/aws-adapter/tests/account-store.test.ts +0 -276
- package/packages/cf-adapter/src/d1-account-store.ts +0 -198
- package/packages/cf-adapter/tests/d1-account-store.test.ts +0 -274
- package/packages/irc-core/tests/account-store.test.ts +0 -131
|
@@ -95,6 +95,18 @@ describe('Effect constructors', () => {
|
|
|
95
95
|
});
|
|
96
96
|
});
|
|
97
97
|
|
|
98
|
+
it('BroadcastOperNotice carries lines and optional except', () => {
|
|
99
|
+
expect(Effect.broadcastOperNotice([line(':s NOTICE * :lockout')])).toEqual<EffectType>({
|
|
100
|
+
tag: 'BroadcastOperNotice',
|
|
101
|
+
lines: [{ text: ':s NOTICE * :lockout' }],
|
|
102
|
+
});
|
|
103
|
+
expect(Effect.broadcastOperNotice([line(':s NOTICE * :lockout')], 'c1')).toEqual<EffectType>({
|
|
104
|
+
tag: 'BroadcastOperNotice',
|
|
105
|
+
lines: [{ text: ':s NOTICE * :lockout' }],
|
|
106
|
+
except: 'c1',
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
98
110
|
it('EnforceNick carries conn, nick, policy, and graceMs', () => {
|
|
99
111
|
expect(Effect.enforceNick('c1', 'alice', 'kill', 0)).toEqual<EffectType>({
|
|
100
112
|
tag: 'EnforceNick',
|
|
@@ -125,6 +137,7 @@ describe('Effect tag set', () => {
|
|
|
125
137
|
'ApplyChannelDelta',
|
|
126
138
|
'SendToNick',
|
|
127
139
|
'BroadcastWallops',
|
|
140
|
+
'BroadcastOperNotice',
|
|
128
141
|
'EnforceNick',
|
|
129
142
|
];
|
|
130
143
|
|
|
@@ -139,6 +152,7 @@ describe('Effect tag set', () => {
|
|
|
139
152
|
ApplyChannelDelta: Effect.applyChannelDelta('#c', { memberships: [] }),
|
|
140
153
|
SendToNick: Effect.sendToNick('n', 'c', []),
|
|
141
154
|
BroadcastWallops: Effect.broadcastWallops([]),
|
|
155
|
+
BroadcastOperNotice: Effect.broadcastOperNotice([]),
|
|
142
156
|
EnforceNick: Effect.enforceNick('c', 'n', 'none', 0),
|
|
143
157
|
};
|
|
144
158
|
expect(constructors[tag].tag).toBe(tag);
|
|
@@ -286,13 +286,41 @@ describe('wrapWithFloodControl — generic over inner state', () => {
|
|
|
286
286
|
|
|
287
287
|
describe('wrapWithFloodControl — per-command cost (commandCost)', () => {
|
|
288
288
|
it('defaultCommandCost returns 0 for the control-plane exempt set and 1 otherwise', () => {
|
|
289
|
-
for (const command of ['PING', 'PONG', 'CAP', '
|
|
289
|
+
for (const command of ['PING', 'PONG', 'CAP', 'QUIT']) {
|
|
290
290
|
expect(defaultCommandCost(command)).toBe(0);
|
|
291
291
|
}
|
|
292
292
|
expect(defaultCommandCost('PRIVMSG')).toBe(1);
|
|
293
293
|
expect(defaultCommandCost('JOIN')).toBe(1);
|
|
294
294
|
});
|
|
295
295
|
|
|
296
|
+
it('AUTHENTICATE is NOT exempt — it consumes the default flood bucket', () => {
|
|
297
|
+
// SASL brute-force hardening: an attacker spamming AUTHENTICATE must
|
|
298
|
+
// not get a free pass. The per-connection SASL failure tracker in the
|
|
299
|
+
// SASL reducer is the second, orthogonal defense.
|
|
300
|
+
expect(defaultCommandCost('AUTHENTICATE')).toBe(1);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it('an AUTHENTICATE burst drains the bucket like PRIVMSG would', () => {
|
|
304
|
+
const state = makeState();
|
|
305
|
+
const clock = new FakeClock(1_000);
|
|
306
|
+
const ctx = makeCtx(state, clock);
|
|
307
|
+
const inner = countingReducer();
|
|
308
|
+
const wrapped = wrapWithFloodControl(inner.reducer, {
|
|
309
|
+
capacity: 2,
|
|
310
|
+
refillRatePerSecond: 1,
|
|
311
|
+
disconnectThreshold: 0,
|
|
312
|
+
commandCost: defaultCommandCost,
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
wrapped(state, ircMessage('AUTHENTICATE', ['PLAIN']), ctx);
|
|
316
|
+
wrapped(state, ircMessage('AUTHENTICATE', ['dGVzdA==']), ctx);
|
|
317
|
+
expect(inner.getCalls()).toBe(2);
|
|
318
|
+
|
|
319
|
+
const out = wrapped(state, ircMessage('AUTHENTICATE', ['*']), ctx);
|
|
320
|
+
expect(out.effects).toEqual<EffectType[]>([Effect.disconnect('c1', EXCESS_FLOOD_REASON)]);
|
|
321
|
+
expect(inner.getCalls()).toBe(2);
|
|
322
|
+
});
|
|
323
|
+
|
|
296
324
|
it('delegates a cost-0 PING to the inner reducer even when the bucket is exhausted', () => {
|
|
297
325
|
const state = makeState();
|
|
298
326
|
const clock = new FakeClock(1_000);
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-connection inbound frame-rate window (`admitFrameWindow`).
|
|
3
|
+
*
|
|
4
|
+
* Pure sliding-window admission policy shared by the adapters' inbound
|
|
5
|
+
* frame gates: a connection may deliver at most `maxFrames`
|
|
6
|
+
* frames inside any `windowMs` window; the frame that would exceed the
|
|
7
|
+
* budget is rejected so the adapter can close the connection before the
|
|
8
|
+
* actor / storage write runs.
|
|
9
|
+
*
|
|
10
|
+
* Coverage matrix: under-threshold, at-threshold,
|
|
11
|
+
* over-threshold, and the sliding-window decay.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { describe, expect, it } from 'vitest';
|
|
15
|
+
import {
|
|
16
|
+
FRAME_FLOOD_CLOSE_CODE,
|
|
17
|
+
FRAME_FLOOD_ERROR_LINE,
|
|
18
|
+
admitFrameWindow,
|
|
19
|
+
} from '../src/frame-rate-limit';
|
|
20
|
+
|
|
21
|
+
describe('admitFrameWindow', () => {
|
|
22
|
+
it('admits a frame while the window holds fewer than maxFrames entries (under-threshold)', () => {
|
|
23
|
+
const decision = admitFrameWindow([1_000, 2_000], 3_000, 50, 5_000);
|
|
24
|
+
expect(decision.admitted).toBe(true);
|
|
25
|
+
expect(decision.times).toEqual([1_000, 2_000, 3_000]);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('admits an empty window (first frame of a connection)', () => {
|
|
29
|
+
const decision = admitFrameWindow([], 42, 50, 5_000);
|
|
30
|
+
expect(decision.admitted).toBe(true);
|
|
31
|
+
expect(decision.times).toEqual([42]);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('admits exactly the maxFrames-th frame in the window (at-threshold)', () => {
|
|
35
|
+
const times = Array.from({ length: 49 }, (_, i) => i + 1);
|
|
36
|
+
const decision = admitFrameWindow(times, 50, 50, 5_000);
|
|
37
|
+
expect(decision.admitted).toBe(true);
|
|
38
|
+
expect(decision.times.length).toBe(50);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('rejects the maxFrames+1-th frame within the window (over-threshold)', () => {
|
|
42
|
+
const times = Array.from({ length: 50 }, (_, i) => i + 1);
|
|
43
|
+
const decision = admitFrameWindow(times, 51, 50, 5_000);
|
|
44
|
+
expect(decision.admitted).toBe(false);
|
|
45
|
+
// The rejected frame is NOT recorded; the caller keeps the pruned view.
|
|
46
|
+
expect(decision.times.length).toBe(50);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('ages entries out of the window so old frames stop counting (sliding-window decay)', () => {
|
|
50
|
+
// 50 frames at t=0..49; 6 s after the first frame every entry has
|
|
51
|
+
// aged out of the 5 s window, so the connection starts fresh.
|
|
52
|
+
const times = Array.from({ length: 50 }, (_, i) => i);
|
|
53
|
+
const decision = admitFrameWindow(times, 6_049, 50, 5_000);
|
|
54
|
+
expect(decision.admitted).toBe(true);
|
|
55
|
+
expect(decision.times).toEqual([6_049]);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('partially decays a window (entries inside the window still count)', () => {
|
|
59
|
+
// Frames at t=0 and t=4_000; at t=5_500 only the t=0 entry has aged
|
|
60
|
+
// out of the 5 s window — the recent one still counts, so this frame
|
|
61
|
+
// fits a max=2 budget.
|
|
62
|
+
const decision = admitFrameWindow([0, 4_000], 5_500, 2, 5_000);
|
|
63
|
+
expect(decision.admitted).toBe(true);
|
|
64
|
+
expect(decision.times).toEqual([4_000, 5_500]);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('keeps an entry at exactly now - windowMs inside the window (inclusive boundary)', () => {
|
|
68
|
+
const decision = admitFrameWindow([1_000], 6_000, 1, 5_000);
|
|
69
|
+
// 1_000 >= 6_000 - 5_000 → still counted, so this frame exceeds max=1.
|
|
70
|
+
expect(decision.admitted).toBe(false);
|
|
71
|
+
expect(decision.times).toEqual([1_000]);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('drops entries older than the window even when the frame is rejected', () => {
|
|
75
|
+
// t=0 has aged out at now=6_100 (5 s window); the two recent entries
|
|
76
|
+
// fill the max=2 budget → rejected, and the decayed entry is gone
|
|
77
|
+
// from the retained window.
|
|
78
|
+
const decision = admitFrameWindow([0, 6_000, 6_050], 6_100, 2, 5_000);
|
|
79
|
+
expect(decision.admitted).toBe(false);
|
|
80
|
+
expect(decision.times).toEqual([6_000, 6_050]);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('does not mutate the caller-supplied times array (pure)', () => {
|
|
84
|
+
const times = [1, 2, 3];
|
|
85
|
+
admitFrameWindow(times, 4, 50, 5_000);
|
|
86
|
+
expect(times).toEqual([1, 2, 3]);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe('frame-flood close constants', () => {
|
|
91
|
+
it('uses RFC 6455 close code 1008 (Policy Violation)', () => {
|
|
92
|
+
expect(FRAME_FLOOD_CLOSE_CODE).toBe(1008);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('delivers the canonical flooding ERROR line before the close', () => {
|
|
96
|
+
expect(FRAME_FLOOD_ERROR_LINE).toBe('ERROR :Closing link (flooding)');
|
|
97
|
+
});
|
|
98
|
+
});
|
|
@@ -590,4 +590,9 @@ describe('InMemoryMessageStore — getMsg (msgid -> message)', () => {
|
|
|
590
590
|
const store = seededStore();
|
|
591
591
|
expect(store.getMsg('#FoO', 'm2')?.msgid).toBe('m2');
|
|
592
592
|
});
|
|
593
|
+
|
|
594
|
+
it('returns undefined for a channel with no recorded messages', () => {
|
|
595
|
+
const store = seededStore();
|
|
596
|
+
expect(store.getMsg('#empty', 'm1')).toBeUndefined();
|
|
597
|
+
});
|
|
593
598
|
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
2
|
+
import { matchOperCred, resetOperLegacyPlaintextWarning } from '../src/commands/oper';
|
|
3
|
+
import { hashAccountCredential } from '../src/credential-hashing';
|
|
4
|
+
import { type OperCredHashed, hashOperCredential } from '../src/oper-hashing';
|
|
5
|
+
|
|
6
|
+
describe('hashOperCredential', () => {
|
|
7
|
+
afterEach(() => {
|
|
8
|
+
resetOperLegacyPlaintextWarning();
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it('emits a {user, salt, hash} triple with non-empty base64 fields', () => {
|
|
12
|
+
const out = hashOperCredential('admin', 's3cret');
|
|
13
|
+
expect(out.user).toBe('admin');
|
|
14
|
+
expect(out.salt.length).toBeGreaterThan(0);
|
|
15
|
+
expect(out.hash.length).toBeGreaterThan(0);
|
|
16
|
+
// base64 alphabet only (no whitespace, no plaintext leakage).
|
|
17
|
+
expect(out.salt).toMatch(/^[A-Za-z0-9+/]+=*$/u);
|
|
18
|
+
expect(out.hash).toMatch(/^[A-Za-z0-9+/]+=*$/u);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('never stores the plaintext password in any field', () => {
|
|
22
|
+
const out = hashOperCredential('admin', 'hunter2-secret');
|
|
23
|
+
expect(out.user).not.toContain('hunter2');
|
|
24
|
+
expect(out.salt).not.toContain('hunter2');
|
|
25
|
+
expect(out.hash).not.toContain('hunter2');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('produces a fresh random salt per call (no two salts collide)', () => {
|
|
29
|
+
const a = hashOperCredential('admin', 's3cret');
|
|
30
|
+
const b = hashOperCredential('admin', 's3cret');
|
|
31
|
+
expect(a.salt).not.toBe(b.salt);
|
|
32
|
+
expect(a.hash).not.toBe(b.hash);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('honours a fixed salt for deterministic test scenarios', () => {
|
|
36
|
+
const salt = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]);
|
|
37
|
+
const a = hashOperCredential('admin', 's3cret', { salt });
|
|
38
|
+
const b = hashOperCredential('admin', 's3cret', { salt });
|
|
39
|
+
expect(a.hash).toBe(b.hash);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('round-trips via matchOperCred: the hashed output verifies the original password', () => {
|
|
43
|
+
const cred = hashOperCredential('admin', 's3cret-password');
|
|
44
|
+
expect(matchOperCred([cred], 'admin', 's3cret-password')).toBe(true);
|
|
45
|
+
expect(matchOperCred([cred], 'admin', 'wrong')).toBe(false);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('round-trips via verifyHashedPassword: the underlying scrypt entry is well-formed', () => {
|
|
49
|
+
const cred = hashOperCredential('admin', 's3cret');
|
|
50
|
+
const entry = hashAccountCredential('admin', 's3cret', {
|
|
51
|
+
salt: Uint8Array.from(Buffer.from(cred.salt, 'base64')),
|
|
52
|
+
});
|
|
53
|
+
expect(entry.hash).toBe(cred.hash);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('returns an object assignable to the OperCredHashed type', () => {
|
|
57
|
+
const cred: OperCredHashed = hashOperCredential('admin', 's3cret');
|
|
58
|
+
expect(cred.user).toBe('admin');
|
|
59
|
+
});
|
|
60
|
+
});
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for {@link OperFailureStats} — the runtime-owned per-IP
|
|
3
|
+
* failed-OPER counter (sliding window, AdmissionStats shape).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, expect, it } from 'vitest';
|
|
7
|
+
import { OperFailureStats } from '../src/oper-lockout';
|
|
8
|
+
import { FakeClock } from '../src/ports';
|
|
9
|
+
|
|
10
|
+
describe('OperFailureStats', () => {
|
|
11
|
+
it('reports zero recent failures for an unknown IP', () => {
|
|
12
|
+
const clock = new FakeClock(5_000);
|
|
13
|
+
const stats = new OperFailureStats(clock);
|
|
14
|
+
expect(stats.recentFailures('10.0.0.1', 300_000)).toBe(0);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('counts recorded failures within the window', () => {
|
|
18
|
+
const clock = new FakeClock(5_000);
|
|
19
|
+
const stats = new OperFailureStats(clock);
|
|
20
|
+
stats.recordFailure('10.0.0.1');
|
|
21
|
+
clock.advance(1_000);
|
|
22
|
+
stats.recordFailure('10.0.0.1');
|
|
23
|
+
expect(stats.recentFailures('10.0.0.1', 300_000)).toBe(2);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('prunes failures older than the window (sliding)', () => {
|
|
27
|
+
const clock = new FakeClock(5_000);
|
|
28
|
+
const stats = new OperFailureStats(clock);
|
|
29
|
+
stats.recordFailure('10.0.0.1'); // t = 5_000
|
|
30
|
+
clock.advance(10_000);
|
|
31
|
+
stats.recordFailure('10.0.0.1'); // t = 15_000
|
|
32
|
+
|
|
33
|
+
// Window that excludes the first failure keeps only the second.
|
|
34
|
+
expect(stats.recentFailures('10.0.0.1', 9_000)).toBe(1);
|
|
35
|
+
// The prune is sticky: the stale entry is gone on the next read.
|
|
36
|
+
expect(stats.recentFailures('10.0.0.1', 300_000)).toBe(1);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('deletes the per-IP entry when every recorded failure is stale', () => {
|
|
40
|
+
const clock = new FakeClock(5_000);
|
|
41
|
+
const stats = new OperFailureStats(clock);
|
|
42
|
+
stats.recordFailure('10.0.0.1');
|
|
43
|
+
clock.advance(301_000);
|
|
44
|
+
expect(stats.recentFailures('10.0.0.1', 300_000)).toBe(0);
|
|
45
|
+
// A later failure for the same IP starts from a fresh entry.
|
|
46
|
+
stats.recordFailure('10.0.0.1');
|
|
47
|
+
expect(stats.recentFailures('10.0.0.1', 300_000)).toBe(1);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('keeps per-IP accounting independent between IPs', () => {
|
|
51
|
+
const clock = new FakeClock(5_000);
|
|
52
|
+
const stats = new OperFailureStats(clock);
|
|
53
|
+
stats.recordFailure('10.0.0.1');
|
|
54
|
+
stats.recordFailure('10.0.0.1');
|
|
55
|
+
expect(stats.recentFailures('10.0.0.2', 300_000)).toBe(0);
|
|
56
|
+
expect(stats.recentFailures('10.0.0.1', 300_000)).toBe(2);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('reset clears every recorded failure for the IP', () => {
|
|
60
|
+
const clock = new FakeClock(5_000);
|
|
61
|
+
const stats = new OperFailureStats(clock);
|
|
62
|
+
stats.recordFailure('10.0.0.1');
|
|
63
|
+
stats.recordFailure('10.0.0.1');
|
|
64
|
+
stats.reset('10.0.0.1');
|
|
65
|
+
expect(stats.recentFailures('10.0.0.1', 300_000)).toBe(0);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('reset is a no-op for an IP with no recorded failures', () => {
|
|
69
|
+
const clock = new FakeClock(5_000);
|
|
70
|
+
const stats = new OperFailureStats(clock);
|
|
71
|
+
expect(() => stats.reset('10.0.0.1')).not.toThrow();
|
|
72
|
+
expect(stats.recentFailures('10.0.0.1', 300_000)).toBe(0);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import fc from 'fast-check';
|
|
2
2
|
import { describe, expect, it } from 'vitest';
|
|
3
3
|
import type { RawLine } from '../src/effects';
|
|
4
|
+
import { byteLength } from '../src/protocol/bytes';
|
|
4
5
|
import {
|
|
5
6
|
MAX_LINE_BYTES,
|
|
6
7
|
applyServerTime,
|
|
7
8
|
enforceLineLimit,
|
|
8
9
|
formatServerTime,
|
|
10
|
+
sanitizeLineText,
|
|
9
11
|
} from '../src/protocol/outbound';
|
|
10
12
|
import { parse } from '../src/protocol/parser';
|
|
11
13
|
import { serialize } from '../src/protocol/serializer';
|
|
@@ -243,6 +245,152 @@ describe('enforceLineLimit — WsFrameMode budget', () => {
|
|
|
243
245
|
});
|
|
244
246
|
});
|
|
245
247
|
|
|
248
|
+
// ============================================================================
|
|
249
|
+
// enforceLineLimit — byte (not UTF-16 code-unit) accounting
|
|
250
|
+
// ============================================================================
|
|
251
|
+
|
|
252
|
+
describe('enforceLineLimit — multi-byte (UTF-8 byte) accounting', () => {
|
|
253
|
+
const TEXT_BUDGET = MAX_LINE_BYTES - 2;
|
|
254
|
+
|
|
255
|
+
it('truncates a 353 NAMES-style line that fits 510 UTF-16 units but exceeds 510 bytes', () => {
|
|
256
|
+
// Prefix is 23 ASCII bytes; 260 'é' nicks/text = 260 units but 520 bytes.
|
|
257
|
+
// Total: 283 code units (inside the old unit budget), 543 UTF-8 bytes.
|
|
258
|
+
const prefix = ':srv 353 alice = #chan :';
|
|
259
|
+
const line: RawLine = L(`${prefix}${'é'.repeat(260)}`);
|
|
260
|
+
expect(line.text.length).toBeLessThanOrEqual(TEXT_BUDGET);
|
|
261
|
+
expect(byteLength(line.text)).toBeGreaterThan(TEXT_BUDGET);
|
|
262
|
+
|
|
263
|
+
const out = enforceLineLimit(line, new Set<string>());
|
|
264
|
+
expect(out).not.toBe(line);
|
|
265
|
+
expect(out.text.startsWith(prefix)).toBe(true);
|
|
266
|
+
expect(byteLength(out.text)).toBeLessThanOrEqual(TEXT_BUDGET);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it('passes a multi-byte line whose UTF-8 length is exactly 510 bytes through unchanged', () => {
|
|
270
|
+
// "PRIVMSG #c :" (12 ASCII bytes) + 249 'é' (498 bytes) = 510 bytes in
|
|
271
|
+
// 261 code units.
|
|
272
|
+
const line: RawLine = L(`PRIVMSG #c :${'é'.repeat(249)}`);
|
|
273
|
+
expect(line.text.length).toBeLessThan(TEXT_BUDGET);
|
|
274
|
+
expect(byteLength(line.text)).toBe(TEXT_BUDGET);
|
|
275
|
+
expect(enforceLineLimit(line, new Set<string>())).toBe(line);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it('keeps the truncated output within the byte budget for a long multi-byte body', () => {
|
|
279
|
+
const line: RawLine = L(`:alice PRIVMSG #foo :${'é'.repeat(600)}`);
|
|
280
|
+
const out = enforceLineLimit(line, new Set<string>());
|
|
281
|
+
expect(byteLength(out.text)).toBeLessThanOrEqual(TEXT_BUDGET);
|
|
282
|
+
expect(out.text.startsWith(':alice PRIVMSG #foo :')).toBe(true);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it('never splits a surrogate pair when truncating multi-byte content', () => {
|
|
286
|
+
const prefix = ':alice PRIVMSG #foo :';
|
|
287
|
+
const line: RawLine = L(`${prefix}${'😀'.repeat(400)}`);
|
|
288
|
+
const out = enforceLineLimit(line, new Set<string>());
|
|
289
|
+
expect(byteLength(out.text)).toBeLessThanOrEqual(TEXT_BUDGET);
|
|
290
|
+
// A split pair would leave a half emoji at the end; the last code point
|
|
291
|
+
// must be a complete emoji.
|
|
292
|
+
expect(out.text.endsWith('😀')).toBe(true);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it('preserves the tag section verbatim and truncates the multi-byte body by bytes', () => {
|
|
296
|
+
const tagPrefix = '@time=2024-01-15T12:34:56.789Z '; // 31 bytes
|
|
297
|
+
const prefix = ':srv 353 alice = #chan :';
|
|
298
|
+
const line: RawLine = L(`${tagPrefix}${prefix}${'é'.repeat(300)}`);
|
|
299
|
+
const out = enforceLineLimit(line, new Set<string>(['message-tags']));
|
|
300
|
+
expect(out.text.startsWith(`${tagPrefix}${prefix}`)).toBe(true);
|
|
301
|
+
expect(byteLength(out.text)).toBeLessThanOrEqual(TEXT_BUDGET);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it('truncates the multi-byte body by bytes in spec-text mode too', () => {
|
|
305
|
+
const line: RawLine = L(`:alice PRIVMSG #foo :${'é'.repeat(400)}`);
|
|
306
|
+
const out = enforceLineLimit(line, new Set<string>(), 'spec-text');
|
|
307
|
+
expect(byteLength(out.text)).toBeLessThanOrEqual(MAX_WS_MESSAGE_BYTES);
|
|
308
|
+
});
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
// ============================================================================
|
|
312
|
+
// sanitizeLineText — defense-in-depth CR stripping for RawLine.text
|
|
313
|
+
// ============================================================================
|
|
314
|
+
|
|
315
|
+
describe('sanitizeLineText', () => {
|
|
316
|
+
it('removes a bare CR embedded in the line text', () => {
|
|
317
|
+
// The 311 RPL_WHOISREALNAME shape: a realname carrying a CR would echo
|
|
318
|
+
// the control character verbatim into a client's frame stream.
|
|
319
|
+
const out = sanitizeLineText(L(':srv 311 me alice u h * :real\rname'));
|
|
320
|
+
expect(out.text).toBe(':srv 311 me alice u h * :realname');
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it('removes every CR in the line text, not just the first', () => {
|
|
324
|
+
const out = sanitizeLineText(L('TOPIC #foo :a\rb\rc'));
|
|
325
|
+
expect(out.text).toBe('TOPIC #foo :abc');
|
|
326
|
+
expect(out.text.includes('\r')).toBe(false);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it('returns the same object when the text contains no CR', () => {
|
|
330
|
+
const line: RawLine = L(':alice PRIVMSG #foo :hi');
|
|
331
|
+
expect(sanitizeLineText(line)).toBe(line);
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
it('leaves LF untouched (CR-only hardening)', () => {
|
|
335
|
+
const out = sanitizeLineText(L('PRIVMSG #foo :a\nb'));
|
|
336
|
+
expect(out.text).toBe('PRIVMSG #foo :a\nb');
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
it('does not mutate the input RawLine', () => {
|
|
340
|
+
const original: RawLine = L('PRIVMSG #foo :a\rb');
|
|
341
|
+
sanitizeLineText(original);
|
|
342
|
+
expect(original.text).toBe('PRIVMSG #foo :a\rb');
|
|
343
|
+
});
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
// ============================================================================
|
|
347
|
+
// enforceLineLimit — CR sanitization composes with the byte budget
|
|
348
|
+
// ============================================================================
|
|
349
|
+
|
|
350
|
+
describe('enforceLineLimit — CR sanitization before budget enforcement', () => {
|
|
351
|
+
const TEXT_BUDGET = MAX_LINE_BYTES - 2;
|
|
352
|
+
|
|
353
|
+
it('strips a bare CR from an otherwise in-budget line', () => {
|
|
354
|
+
const line: RawLine = L(':srv 311 me alice u h * :real\rname');
|
|
355
|
+
const out = enforceLineLimit(line, new Set<string>());
|
|
356
|
+
expect(out.text).toBe(':srv 311 me alice u h * :realname');
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
it('strips CR before measuring the budget so a line that fits after stripping is not truncated', () => {
|
|
360
|
+
// 21-byte prefix + 488 'x' + CR + 1 'x' = 511 units/bytes; stripping the
|
|
361
|
+
// single CR byte lands exactly on the 510 budget — no truncation.
|
|
362
|
+
const prefix = ':alice PRIVMSG #foo :';
|
|
363
|
+
const xs = 'x'.repeat(TEXT_BUDGET - prefix.length - 1);
|
|
364
|
+
const line: RawLine = L(`${prefix}${xs}\rx`);
|
|
365
|
+
expect(byteLength(line.text)).toBe(TEXT_BUDGET + 1);
|
|
366
|
+
const out = enforceLineLimit(line, new Set<string>());
|
|
367
|
+
expect(out.text).toBe(`${prefix}${xs}x`);
|
|
368
|
+
expect(byteLength(out.text)).toBe(TEXT_BUDGET);
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
it('keeps the output within budget when a CR-bearing line also needs truncation', () => {
|
|
372
|
+
const line: RawLine = L(`:alice PRIVMSG #foo :a\rb${'x'.repeat(600)}`);
|
|
373
|
+
const out = enforceLineLimit(line, new Set<string>());
|
|
374
|
+
expect(out.text.includes('\r')).toBe(false);
|
|
375
|
+
expect(byteLength(out.text)).toBe(TEXT_BUDGET);
|
|
376
|
+
expect(out.text.startsWith(':alice PRIVMSG #foo :ab')).toBe(true);
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
it('preserves the tag section while sanitizing CR from a tagged, over-budget line', () => {
|
|
380
|
+
const tagPrefix = '@time=2024-01-15T12:34:56.789Z ';
|
|
381
|
+
const line: RawLine = L(`${tagPrefix}:alice PRIVMSG #foo :a\rb${'x'.repeat(600)}`);
|
|
382
|
+
const out = enforceLineLimit(line, new Set<string>(['message-tags']));
|
|
383
|
+
expect(out.text.startsWith(tagPrefix)).toBe(true);
|
|
384
|
+
expect(out.text.includes('\r')).toBe(false);
|
|
385
|
+
expect(byteLength(out.text)).toBe(TEXT_BUDGET);
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
it('returns a CR-free line object unchanged (identity preserved for the common case)', () => {
|
|
389
|
+
const line: RawLine = L(':alice PRIVMSG #foo :hi');
|
|
390
|
+
expect(enforceLineLimit(line, new Set<string>())).toBe(line);
|
|
391
|
+
});
|
|
392
|
+
});
|
|
393
|
+
|
|
246
394
|
// ============================================================================
|
|
247
395
|
// Round-trip through serialize -> applyServerTime -> parse
|
|
248
396
|
// ============================================================================
|