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,4 +1,6 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest';
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { CERT_FP_PREFIX, certIdentityFromSubject } from '../src/certfp';
|
|
3
|
+
import type { HashedAccountCredential } from '../src/credential-hashing';
|
|
2
4
|
import {
|
|
3
5
|
type ChannelLevelOp,
|
|
4
6
|
type ChannelRegisterResult,
|
|
@@ -13,8 +15,13 @@ import {
|
|
|
13
15
|
type ServicesAuthResult,
|
|
14
16
|
type ServicesSnapshot,
|
|
15
17
|
type ServicesStore,
|
|
18
|
+
VERIFY_NICK_PRECOMPUTED_DUMMY,
|
|
19
|
+
resetVerifyNickTimingSpy,
|
|
20
|
+
setVerifyNickHashedPassword,
|
|
16
21
|
} from '../src/ports';
|
|
17
22
|
|
|
23
|
+
type VerifyHashedPasswordFn = (password: string, entry: HashedAccountCredential) => boolean;
|
|
24
|
+
|
|
18
25
|
const NOW = 1_700_000_000_000;
|
|
19
26
|
|
|
20
27
|
function store(): InMemoryServicesStore {
|
|
@@ -117,6 +124,101 @@ describe('InMemoryServicesStore — NickServ IDENTIFY (verifyNick)', () => {
|
|
|
117
124
|
});
|
|
118
125
|
});
|
|
119
126
|
|
|
127
|
+
// ============================================================================
|
|
128
|
+
// verifyNick timing equalisation — the unknown-nick miss must perform the
|
|
129
|
+
// same scrypt work as a wrong-password verify so callers (SASL PLAIN, PASS
|
|
130
|
+
// auth, NickServ IDENTIFY/SETPASS) cannot enumerate registered nicks by
|
|
131
|
+
// measuring latency.
|
|
132
|
+
// ============================================================================
|
|
133
|
+
|
|
134
|
+
describe('InMemoryServicesStore — verifyNick timing equalisation', () => {
|
|
135
|
+
afterEach(() => {
|
|
136
|
+
resetVerifyNickTimingSpy();
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('runs exactly one dummy scrypt verify on an unknown nick — no O(1) short-circuit', () => {
|
|
140
|
+
const s = store();
|
|
141
|
+
const spy = vi.fn<VerifyHashedPasswordFn>(() => false);
|
|
142
|
+
setVerifyNickHashedPassword(spy);
|
|
143
|
+
try {
|
|
144
|
+
expect(s.verifyNick('ghost', 'hunter2').ok).toBe(false);
|
|
145
|
+
// Critical: a single dummy verify runs even though the nick is unknown,
|
|
146
|
+
// equalising wall-clock cost with the wrong-password path.
|
|
147
|
+
expect(spy).toHaveBeenCalledTimes(1);
|
|
148
|
+
// The dummy verify is fed the candidate password and runs against the
|
|
149
|
+
// fixed precomputed entry (never a real credential), exactly as a real
|
|
150
|
+
// verify would be — the cost is the point, the result is discarded.
|
|
151
|
+
expect(spy.mock.calls[0]?.[0]).toBe('hunter2');
|
|
152
|
+
expect(spy.mock.calls[0]?.[1]).toBe(VERIFY_NICK_PRECOMPUTED_DUMMY);
|
|
153
|
+
} finally {
|
|
154
|
+
setVerifyNickHashedPassword(null);
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('runs exactly one scrypt verify against the stored credential on a wrong password', () => {
|
|
159
|
+
const s = store();
|
|
160
|
+
s.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
161
|
+
const spy = vi.fn<VerifyHashedPasswordFn>(() => false);
|
|
162
|
+
setVerifyNickHashedPassword(spy);
|
|
163
|
+
try {
|
|
164
|
+
expect(s.verifyNick('alice', 'wrong').ok).toBe(false);
|
|
165
|
+
expect(spy).toHaveBeenCalledTimes(1);
|
|
166
|
+
expect(spy.mock.calls[0]?.[0]).toBe('wrong');
|
|
167
|
+
expect(spy.mock.calls[0]?.[1]?.account).toBe('alice');
|
|
168
|
+
} finally {
|
|
169
|
+
setVerifyNickHashedPassword(null);
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('costs the same single verify on the unknown-nick and wrong-password paths', () => {
|
|
174
|
+
const s = store();
|
|
175
|
+
s.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
176
|
+
const unknownNick = vi.fn<VerifyHashedPasswordFn>(() => false);
|
|
177
|
+
setVerifyNickHashedPassword(unknownNick);
|
|
178
|
+
try {
|
|
179
|
+
s.verifyNick('ghost', 'hunter2');
|
|
180
|
+
} finally {
|
|
181
|
+
setVerifyNickHashedPassword(null);
|
|
182
|
+
}
|
|
183
|
+
const wrongPassword = vi.fn<VerifyHashedPasswordFn>(() => false);
|
|
184
|
+
setVerifyNickHashedPassword(wrongPassword);
|
|
185
|
+
try {
|
|
186
|
+
s.verifyNick('alice', 'wrong');
|
|
187
|
+
} finally {
|
|
188
|
+
setVerifyNickHashedPassword(null);
|
|
189
|
+
}
|
|
190
|
+
expect(unknownNick).toHaveBeenCalledTimes(1);
|
|
191
|
+
expect(wrongPassword).toHaveBeenCalledTimes(1);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('still returns the identical failure result on the unknown-nick path', () => {
|
|
195
|
+
const s = store();
|
|
196
|
+
const spy = vi.fn<VerifyHashedPasswordFn>(() => false);
|
|
197
|
+
setVerifyNickHashedPassword(spy);
|
|
198
|
+
try {
|
|
199
|
+
expect(s.verifyNick('ghost', 'hunter2')).toEqual<ServicesAuthResult>({
|
|
200
|
+
ok: false,
|
|
201
|
+
reason: 'invalid credentials',
|
|
202
|
+
});
|
|
203
|
+
} finally {
|
|
204
|
+
setVerifyNickHashedPassword(null);
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('verifies a correct password through the same seam with no dummy call', () => {
|
|
209
|
+
const s = store();
|
|
210
|
+
s.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
211
|
+
const spy = vi.fn<VerifyHashedPasswordFn>((_pw, entry) => entry.account === 'alice');
|
|
212
|
+
setVerifyNickHashedPassword(spy);
|
|
213
|
+
try {
|
|
214
|
+
expect(s.verifyNick('alice', 'hunter2').ok).toBe(true);
|
|
215
|
+
expect(spy).toHaveBeenCalledTimes(1);
|
|
216
|
+
} finally {
|
|
217
|
+
setVerifyNickHashedPassword(null);
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
|
|
120
222
|
// ============================================================================
|
|
121
223
|
// NickServ CertFP — hydrate round-trip (legacy snapshot without certSubjects)
|
|
122
224
|
// ============================================================================
|
|
@@ -134,6 +236,8 @@ describe('InMemoryServicesStore — CertFP snapshot hydration', () => {
|
|
|
134
236
|
createdAt: NOW,
|
|
135
237
|
enforce: 'none',
|
|
136
238
|
certSubjects: undefined as unknown as string[],
|
|
239
|
+
identifyFailures: [],
|
|
240
|
+
pendingIdentifyFailureNotice: 0,
|
|
137
241
|
credential: { account: 'alice', algorithm: 'scrypt', salt: 's', hash: 'aGVsbG8=' },
|
|
138
242
|
},
|
|
139
243
|
],
|
|
@@ -157,7 +261,120 @@ describe('InMemoryServicesStore — CertFP snapshot hydration', () => {
|
|
|
157
261
|
});
|
|
158
262
|
|
|
159
263
|
// ============================================================================
|
|
160
|
-
// NickServ
|
|
264
|
+
// NickServ — failed-IDENTIFY throttle (record / frozen / clear)
|
|
265
|
+
// ============================================================================
|
|
266
|
+
|
|
267
|
+
describe('InMemoryServicesStore — identify-failure throttle', () => {
|
|
268
|
+
const WINDOW = { maxFailures: 3, windowMs: 300_000 };
|
|
269
|
+
|
|
270
|
+
it('records failures and freezes once the threshold is met inside the window', () => {
|
|
271
|
+
const clock = new FakeClock(NOW);
|
|
272
|
+
const s = new InMemoryServicesStore({ clock });
|
|
273
|
+
s.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
274
|
+
|
|
275
|
+
s.recordIdentifyFailure('alice');
|
|
276
|
+
s.recordIdentifyFailure('alice');
|
|
277
|
+
expect(s.isNickIdentifyFrozen('alice', WINDOW)).toBe(false);
|
|
278
|
+
s.recordIdentifyFailure('alice');
|
|
279
|
+
expect(s.isNickIdentifyFrozen('alice', WINDOW)).toBe(true);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it('prunes failures that aged out of the window (freeze lifts)', () => {
|
|
283
|
+
const clock = new FakeClock(NOW);
|
|
284
|
+
const s = new InMemoryServicesStore({ clock });
|
|
285
|
+
s.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
286
|
+
|
|
287
|
+
s.recordIdentifyFailure('alice'); // at NOW
|
|
288
|
+
clock.advance(400_000);
|
|
289
|
+
s.recordIdentifyFailure('alice'); // at NOW + 400_000 (first failure is 400s old)
|
|
290
|
+
s.recordIdentifyFailure('alice');
|
|
291
|
+
// Only the two recent failures survive the prune → below threshold.
|
|
292
|
+
expect(s.isNickIdentifyFrozen('alice', WINDOW)).toBe(false);
|
|
293
|
+
|
|
294
|
+
clock.advance(1);
|
|
295
|
+
s.recordIdentifyFailure('alice'); // third failure inside the window
|
|
296
|
+
expect(s.isNickIdentifyFrozen('alice', WINDOW)).toBe(true);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it('clearIdentifyFailures returns the queued notice count and resets the throttle', () => {
|
|
300
|
+
const clock = new FakeClock(NOW);
|
|
301
|
+
const s = new InMemoryServicesStore({ clock });
|
|
302
|
+
s.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
303
|
+
|
|
304
|
+
s.recordIdentifyFailure('alice');
|
|
305
|
+
s.recordIdentifyFailure('alice');
|
|
306
|
+
expect(s.clearIdentifyFailures('alice')).toBe(2);
|
|
307
|
+
|
|
308
|
+
// Both the window and the pending notice are reset.
|
|
309
|
+
expect(s.isNickIdentifyFrozen('alice', { maxFailures: 1, windowMs: 300_000 })).toBe(false);
|
|
310
|
+
expect(s.clearIdentifyFailures('alice')).toBe(0);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it('is a no-op for an unknown nick (record / frozen / clear)', () => {
|
|
314
|
+
const clock = new FakeClock(NOW);
|
|
315
|
+
const s = new InMemoryServicesStore({ clock });
|
|
316
|
+
s.recordIdentifyFailure('ghost');
|
|
317
|
+
expect(s.isNickIdentifyFrozen('ghost', WINDOW)).toBe(false);
|
|
318
|
+
expect(s.clearIdentifyFailures('ghost')).toBe(0);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it('round-trips the throttle state through a snapshot reload', () => {
|
|
322
|
+
const clock = new FakeClock(NOW);
|
|
323
|
+
const original = new InMemoryServicesStore({ clock });
|
|
324
|
+
original.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
325
|
+
original.recordIdentifyFailure('alice');
|
|
326
|
+
clock.advance(1);
|
|
327
|
+
original.recordIdentifyFailure('alice');
|
|
328
|
+
|
|
329
|
+
const reloaded = new InMemoryServicesStore({
|
|
330
|
+
clock: new FakeClock(NOW + 1),
|
|
331
|
+
snapshot: original.snapshot(),
|
|
332
|
+
});
|
|
333
|
+
// A re-identify from a fresh connection (fresh store) still sees the
|
|
334
|
+
// recorded failures and the queued owner notice.
|
|
335
|
+
expect(reloaded.isNickIdentifyFrozen('alice', { maxFailures: 2, windowMs: 300_000 })).toBe(
|
|
336
|
+
true,
|
|
337
|
+
);
|
|
338
|
+
expect(reloaded.clearIdentifyFailures('alice')).toBe(2);
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
it('defaults the throttle fields when a legacy snapshot row omits them', () => {
|
|
342
|
+
const legacySnap: ServicesSnapshot = {
|
|
343
|
+
nicks: [
|
|
344
|
+
{
|
|
345
|
+
nick: 'alice',
|
|
346
|
+
account: 'alice',
|
|
347
|
+
email: 'a@x',
|
|
348
|
+
createdAt: NOW,
|
|
349
|
+
enforce: 'none',
|
|
350
|
+
certSubjects: [],
|
|
351
|
+
identifyFailures: undefined as unknown as number[],
|
|
352
|
+
pendingIdentifyFailureNotice: undefined as unknown as number,
|
|
353
|
+
credential: { account: 'alice', algorithm: 'scrypt', salt: 's', hash: 'aGVsbG8=' },
|
|
354
|
+
},
|
|
355
|
+
],
|
|
356
|
+
channels: [],
|
|
357
|
+
access: [],
|
|
358
|
+
levels: [],
|
|
359
|
+
vhosts: [],
|
|
360
|
+
pendingVhosts: [],
|
|
361
|
+
memos: [],
|
|
362
|
+
readMarkers: [],
|
|
363
|
+
akills: [],
|
|
364
|
+
jupes: [],
|
|
365
|
+
};
|
|
366
|
+
const s = new InMemoryServicesStore({ clock: new FakeClock(NOW), snapshot: legacySnap });
|
|
367
|
+
expect(s.isNickIdentifyFrozen('alice', { maxFailures: 1, windowMs: 300_000 })).toBe(false);
|
|
368
|
+
expect(s.clearIdentifyFailures('alice')).toBe(0);
|
|
369
|
+
// Mutations after a legacy hydrate behave like a fresh registration.
|
|
370
|
+
s.recordIdentifyFailure('alice');
|
|
371
|
+
expect(s.isNickIdentifyFrozen('alice', { maxFailures: 1, windowMs: 300_000 })).toBe(true);
|
|
372
|
+
});
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
// ============================================================================
|
|
376
|
+
// NickServ CertFP — SASL EXTERNAL surface (verifyCertFP / addCertFP /
|
|
377
|
+
// addCertFingerprint / removeCertFP)
|
|
161
378
|
// ============================================================================
|
|
162
379
|
|
|
163
380
|
describe('InMemoryServicesStore — NickServ CertFP (verifyCertFP)', () => {
|
|
@@ -165,7 +382,7 @@ describe('InMemoryServicesStore — NickServ CertFP (verifyCertFP)', () => {
|
|
|
165
382
|
const s = store();
|
|
166
383
|
s.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
167
384
|
s.addCertFP('alice', 'CN=alice');
|
|
168
|
-
expect(s.verifyCertFP('CN=alice')).toEqual<ServicesAuthResult>({
|
|
385
|
+
expect(s.verifyCertFP(certIdentityFromSubject('CN=alice'))).toEqual<ServicesAuthResult>({
|
|
169
386
|
ok: true,
|
|
170
387
|
account: 'alice',
|
|
171
388
|
});
|
|
@@ -175,7 +392,7 @@ describe('InMemoryServicesStore — NickServ CertFP (verifyCertFP)', () => {
|
|
|
175
392
|
const s = store();
|
|
176
393
|
s.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
177
394
|
s.addCertFP('alice', 'CN=alice');
|
|
178
|
-
const out = s.verifyCertFP('CN=evil');
|
|
395
|
+
const out = s.verifyCertFP(certIdentityFromSubject('CN=evil'));
|
|
179
396
|
expect(out.ok).toBe(false);
|
|
180
397
|
if (out.ok) return;
|
|
181
398
|
expect(out.reason.length).toBeGreaterThan(0);
|
|
@@ -184,15 +401,15 @@ describe('InMemoryServicesStore — NickServ CertFP (verifyCertFP)', () => {
|
|
|
184
401
|
it('returns ok:false when no nick has any cert subject registered', () => {
|
|
185
402
|
const s = store();
|
|
186
403
|
s.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
187
|
-
expect(s.verifyCertFP('CN=alice').ok).toBe(false);
|
|
404
|
+
expect(s.verifyCertFP(certIdentityFromSubject('CN=alice')).ok).toBe(false);
|
|
188
405
|
});
|
|
189
406
|
|
|
190
|
-
it('matches the subject case-sensitively (
|
|
407
|
+
it('matches the subject value case-sensitively (values are NOT folded)', () => {
|
|
191
408
|
const s = store();
|
|
192
409
|
s.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
193
410
|
s.addCertFP('alice', 'CN=Alice');
|
|
194
|
-
expect(s.verifyCertFP('CN=alice').ok).toBe(false);
|
|
195
|
-
expect(s.verifyCertFP('CN=Alice').ok).toBe(true);
|
|
411
|
+
expect(s.verifyCertFP(certIdentityFromSubject('CN=alice')).ok).toBe(false);
|
|
412
|
+
expect(s.verifyCertFP(certIdentityFromSubject('CN=Alice')).ok).toBe(true);
|
|
196
413
|
});
|
|
197
414
|
|
|
198
415
|
it('resolves the account that owns the subject (multiple accounts)', () => {
|
|
@@ -200,7 +417,7 @@ describe('InMemoryServicesStore — NickServ CertFP (verifyCertFP)', () => {
|
|
|
200
417
|
s.registerNick('alice', 'pw', 'a@e');
|
|
201
418
|
s.registerNick('bob', 'pw', 'b@e');
|
|
202
419
|
s.addCertFP('bob', 'CN=bob');
|
|
203
|
-
expect(s.verifyCertFP('CN=bob')).toEqual<ServicesAuthResult>({
|
|
420
|
+
expect(s.verifyCertFP(certIdentityFromSubject('CN=bob'))).toEqual<ServicesAuthResult>({
|
|
204
421
|
ok: true,
|
|
205
422
|
account: 'bob',
|
|
206
423
|
});
|
|
@@ -227,7 +444,124 @@ describe('InMemoryServicesStore — NickServ CertFP (verifyCertFP)', () => {
|
|
|
227
444
|
});
|
|
228
445
|
});
|
|
229
446
|
|
|
230
|
-
describe('InMemoryServicesStore — NickServ CertFP
|
|
447
|
+
describe('InMemoryServicesStore — NickServ CertFP canonical-DN verify', () => {
|
|
448
|
+
it('verifies a semantically-equal DN spelling (whitespace + ordering differ)', () => {
|
|
449
|
+
const s = store();
|
|
450
|
+
s.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
451
|
+
s.addCertFP('alice', 'CN=alice , O = Example Inc');
|
|
452
|
+
const presented = certIdentityFromSubject('O=Example Inc,CN=alice');
|
|
453
|
+
expect(s.verifyCertFP(presented)).toEqual<ServicesAuthResult>({
|
|
454
|
+
ok: true,
|
|
455
|
+
account: 'alice',
|
|
456
|
+
});
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
it('migrates a legacy verbatim binding to the canonical form on first use', () => {
|
|
460
|
+
const s = store();
|
|
461
|
+
s.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
462
|
+
s.addCertFP('alice', 'CN=alice , O=Example Inc');
|
|
463
|
+
s.verifyCertFP(certIdentityFromSubject('O=Example Inc,CN=alice'));
|
|
464
|
+
expect(s.getNick('alice')?.certSubjects).toEqual(['cn=alice,o=Example Inc']);
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
it('leaves an already-canonical binding untouched on verify', () => {
|
|
468
|
+
const s = store();
|
|
469
|
+
s.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
470
|
+
s.addCertFP('alice', 'cn=alice');
|
|
471
|
+
s.verifyCertFP(certIdentityFromSubject('CN=alice'));
|
|
472
|
+
expect(s.getNick('alice')?.certSubjects).toEqual(['cn=alice']);
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
it('rejects when the presented DN differs beyond spacing/ordering/type case', () => {
|
|
476
|
+
const s = store();
|
|
477
|
+
s.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
478
|
+
s.addCertFP('alice', 'CN=alice,O=Example Inc');
|
|
479
|
+
expect(s.verifyCertFP(certIdentityFromSubject('CN=alice,O=Evil Inc')).ok).toBe(false);
|
|
480
|
+
});
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
describe('InMemoryServicesStore — NickServ CertFP fingerprint verify', () => {
|
|
484
|
+
it('verifies via a fingerprint binding alone (subject spelling irrelevant)', () => {
|
|
485
|
+
const s = store();
|
|
486
|
+
s.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
487
|
+
s.addCertFingerprint('alice', 'aa112233445566778899aabbccddeeff');
|
|
488
|
+
expect(
|
|
489
|
+
s.verifyCertFP(certIdentityFromSubject('CN=whatever', 'aa112233445566778899aabbccddeeff')),
|
|
490
|
+
).toEqual<ServicesAuthResult>({ ok: true, account: 'alice' });
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
it('prefers the fingerprint binding over a competing DN binding', () => {
|
|
494
|
+
const s = store();
|
|
495
|
+
s.registerNick('alice', 'pw', 'a@e');
|
|
496
|
+
s.registerNick('bob', 'pw', 'b@e');
|
|
497
|
+
// bob is bound by the cert subject DN; alice is bound by the cert DER
|
|
498
|
+
// fingerprint. Both would match the presented cert — the fingerprint
|
|
499
|
+
// binding must win.
|
|
500
|
+
s.addCertFP('bob', 'CN=shared,O=TwoCerts');
|
|
501
|
+
s.addCertFingerprint('alice', 'aa112233445566778899aabbccddeeff');
|
|
502
|
+
expect(
|
|
503
|
+
s.verifyCertFP(
|
|
504
|
+
certIdentityFromSubject('CN=shared,O=TwoCerts', 'aa112233445566778899aabbccddeeff'),
|
|
505
|
+
),
|
|
506
|
+
).toEqual<ServicesAuthResult>({ ok: true, account: 'alice' });
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
it('normalises fingerprint spelling (colons, case) before comparing', () => {
|
|
510
|
+
const s = store();
|
|
511
|
+
s.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
512
|
+
s.addCertFingerprint('alice', 'AA:11:22:33');
|
|
513
|
+
const out = s.verifyCertFP(certIdentityFromSubject('CN=alice', 'aa112233'));
|
|
514
|
+
expect(out.ok).toBe(true);
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
it('pins the platform fingerprint onto a DN-bound account on first verify', () => {
|
|
518
|
+
const s = store();
|
|
519
|
+
s.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
520
|
+
s.addCertFP('alice', 'CN=alice');
|
|
521
|
+
s.verifyCertFP(certIdentityFromSubject('CN=alice', 'aa112233'));
|
|
522
|
+
expect(s.getNick('alice')?.certSubjects).toContain(`${CERT_FP_PREFIX}aa112233`);
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
it('migrates a legacy binding and pins the fingerprint in one first use', () => {
|
|
526
|
+
const s = store();
|
|
527
|
+
s.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
528
|
+
s.addCertFP('alice', 'CN=alice , O=Example Inc');
|
|
529
|
+
s.verifyCertFP(certIdentityFromSubject('O=Example Inc,CN=alice', 'aa112233'));
|
|
530
|
+
expect(s.getNick('alice')?.certSubjects).toEqual([
|
|
531
|
+
'cn=alice,o=Example Inc',
|
|
532
|
+
`${CERT_FP_PREFIX}aa112233`,
|
|
533
|
+
]);
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
it('does not pin a fingerprint twice on repeated verifies', () => {
|
|
537
|
+
const s = store();
|
|
538
|
+
s.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
539
|
+
s.addCertFP('alice', 'CN=alice');
|
|
540
|
+
s.verifyCertFP(certIdentityFromSubject('CN=alice', 'aa112233'));
|
|
541
|
+
s.verifyCertFP(certIdentityFromSubject('CN=alice', 'aa112233'));
|
|
542
|
+
const fps = s.getNick('alice')?.certSubjects.filter((e) => e.startsWith(CERT_FP_PREFIX));
|
|
543
|
+
expect(fps).toEqual([`${CERT_FP_PREFIX}aa112233`]);
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
it('rejects an unknown fingerprint and unknown DN (unknown cert)', () => {
|
|
547
|
+
const s = store();
|
|
548
|
+
s.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
549
|
+
s.addCertFP('alice', 'CN=alice');
|
|
550
|
+
s.addCertFingerprint('alice', 'aa112233');
|
|
551
|
+
expect(s.verifyCertFP(certIdentityFromSubject('CN=evil', 'ffffffffffffffff')).ok).toBe(false);
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
it('falls back to the DN arm when the fingerprint matches nothing', () => {
|
|
555
|
+
const s = store();
|
|
556
|
+
s.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
557
|
+
s.addCertFP('alice', 'CN=alice');
|
|
558
|
+
// Platform-supplied fingerprint is unknown to the store; the DN binding
|
|
559
|
+
// still authorises the cert.
|
|
560
|
+
expect(s.verifyCertFP(certIdentityFromSubject('CN=alice', 'ffffffffffffffff')).ok).toBe(true);
|
|
561
|
+
});
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
describe('InMemoryServicesStore — NickServ CertFP mutators (addCertFP / addCertFingerprint / removeCertFP)', () => {
|
|
231
565
|
it('addCertFP attaches a subject to a registered nick', () => {
|
|
232
566
|
const s = store();
|
|
233
567
|
s.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
@@ -238,7 +572,7 @@ describe('InMemoryServicesStore — NickServ CertFP mutators (addCertFP / remove
|
|
|
238
572
|
it('addCertFP is a no-op on an unregistered nick', () => {
|
|
239
573
|
const s = store();
|
|
240
574
|
s.addCertFP('ghost', 'CN=ghost');
|
|
241
|
-
expect(s.verifyCertFP('CN=ghost').ok).toBe(false);
|
|
575
|
+
expect(s.verifyCertFP(certIdentityFromSubject('CN=ghost')).ok).toBe(false);
|
|
242
576
|
});
|
|
243
577
|
|
|
244
578
|
it('addCertFP does not duplicate an already-attached subject', () => {
|
|
@@ -253,19 +587,47 @@ describe('InMemoryServicesStore — NickServ CertFP mutators (addCertFP / remove
|
|
|
253
587
|
const s = store();
|
|
254
588
|
s.registerNick('Alice', 'hunter2', 'alice@example.com');
|
|
255
589
|
s.addCertFP('alice', 'CN=alice');
|
|
256
|
-
expect(s.verifyCertFP('CN=alice')).toEqual<ServicesAuthResult>({
|
|
590
|
+
expect(s.verifyCertFP(certIdentityFromSubject('CN=alice'))).toEqual<ServicesAuthResult>({
|
|
257
591
|
ok: true,
|
|
258
592
|
account: 'Alice',
|
|
259
593
|
});
|
|
260
594
|
});
|
|
261
595
|
|
|
596
|
+
it('addCertFingerprint attaches a fingerprint binding to a registered nick', () => {
|
|
597
|
+
const s = store();
|
|
598
|
+
s.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
599
|
+
expect(s.addCertFingerprint('alice', 'AA:11')).toBe(true);
|
|
600
|
+
expect(s.getNick('alice')?.certSubjects).toEqual([`${CERT_FP_PREFIX}aa11`]);
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
it('addCertFingerprint does not duplicate an equivalent fingerprint', () => {
|
|
604
|
+
const s = store();
|
|
605
|
+
s.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
606
|
+
s.addCertFingerprint('alice', 'aa11');
|
|
607
|
+
s.addCertFingerprint('alice', 'AA:11');
|
|
608
|
+
expect(s.getNick('alice')?.certSubjects).toEqual([`${CERT_FP_PREFIX}aa11`]);
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
it('addCertFingerprint is a no-op on an unregistered nick', () => {
|
|
612
|
+
const s = store();
|
|
613
|
+
expect(s.addCertFingerprint('ghost', 'aa11')).toBe(false);
|
|
614
|
+
});
|
|
615
|
+
|
|
262
616
|
it('removeCertFP detaches a subject from a registered nick', () => {
|
|
263
617
|
const s = store();
|
|
264
618
|
s.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
265
619
|
s.addCertFP('alice', 'CN=alice');
|
|
266
620
|
expect(s.removeCertFP('alice', 'CN=alice')).toBe(true);
|
|
267
621
|
expect(s.getNick('alice')?.certSubjects).toEqual([]);
|
|
268
|
-
expect(s.verifyCertFP('CN=alice').ok).toBe(false);
|
|
622
|
+
expect(s.verifyCertFP(certIdentityFromSubject('CN=alice')).ok).toBe(false);
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
it('removeCertFP detaches a stored fingerprint binding entry', () => {
|
|
626
|
+
const s = store();
|
|
627
|
+
s.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
628
|
+
s.addCertFingerprint('alice', 'aa11');
|
|
629
|
+
expect(s.removeCertFP('alice', `${CERT_FP_PREFIX}aa11`)).toBe(true);
|
|
630
|
+
expect(s.getNick('alice')?.certSubjects).toEqual([]);
|
|
269
631
|
});
|
|
270
632
|
|
|
271
633
|
it('removeCertFP returns false when the subject is not attached', () => {
|
|
@@ -292,7 +654,7 @@ describe('InMemoryServicesStore — NickServ CertFP mutators (addCertFP / remove
|
|
|
292
654
|
s.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
293
655
|
s.addCertFP('alice', 'CN=alice');
|
|
294
656
|
s.dropNick('alice');
|
|
295
|
-
expect(s.verifyCertFP('CN=alice').ok).toBe(false);
|
|
657
|
+
expect(s.verifyCertFP(certIdentityFromSubject('CN=alice')).ok).toBe(false);
|
|
296
658
|
});
|
|
297
659
|
});
|
|
298
660
|
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
frameToLines,
|
|
6
6
|
isWithinWsByteBudget,
|
|
7
7
|
sanitizeForTextMode,
|
|
8
|
+
splitFrameLines,
|
|
8
9
|
stripTrailingCrLf,
|
|
9
10
|
wsFrameModeFor,
|
|
10
11
|
} from '../src/ws-framing';
|
|
@@ -147,6 +148,33 @@ describe('frameToLines — mode coverage', () => {
|
|
|
147
148
|
}
|
|
148
149
|
});
|
|
149
150
|
|
|
151
|
+
// ============================================================================
|
|
152
|
+
// frameToLines — bare CR pass-through (rejection is the parser's job)
|
|
153
|
+
// ============================================================================
|
|
154
|
+
|
|
155
|
+
describe('framing passes a bare CR through to the parser', () => {
|
|
156
|
+
// Framing only recognizes complete CR-LF / LF terminators. A bare CR has
|
|
157
|
+
// no LF partner, so no framing mode treats it as a boundary: the line is
|
|
158
|
+
// emitted intact and the shared parser is the single place that rejects
|
|
159
|
+
// it (loudly, with a parse error) rather than silently dropping frames.
|
|
160
|
+
|
|
161
|
+
it('spec-text keeps a bare mid-line CR inside the single line', () => {
|
|
162
|
+
expect(frameToLines('PRIVMSG #foo :a\rb', 'spec-text')).toEqual(['PRIVMSG #foo :a\rb']);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('spec-binary keeps a bare mid-line CR inside the single line', () => {
|
|
166
|
+
expect(frameToLines('PRIVMSG #foo :a\rb', 'spec-binary')).toEqual(['PRIVMSG #foo :a\rb']);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('legacy keeps a bare CR (no CR-LF boundary to split on)', () => {
|
|
170
|
+
expect(splitFrameLines('PRIVMSG #foo :a\rb')).toEqual(['PRIVMSG #foo :a\rb']);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('stripTrailingCrLf leaves a bare mid-line CR untouched', () => {
|
|
174
|
+
expect(stripTrailingCrLf('PRIVMSG #foo :a\rb')).toBe('PRIVMSG #foo :a\rb');
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
150
178
|
// ============================================================================
|
|
151
179
|
// isWithinWsByteBudget — the IRCv3 WS message byte cap
|
|
152
180
|
// ============================================================================
|
|
@@ -167,6 +195,23 @@ describe('isWithinWsByteBudget', () => {
|
|
|
167
195
|
it('accepts a short frame', () => {
|
|
168
196
|
expect(isWithinWsByteBudget('NICK bob')).toBe(true);
|
|
169
197
|
});
|
|
198
|
+
|
|
199
|
+
it('rejects a multi-byte frame that fits 510 UTF-16 units but exceeds 510 bytes', () => {
|
|
200
|
+
// 300 'é' = 300 code units (inside the unit budget) but 600 UTF-8 bytes.
|
|
201
|
+
const frame = 'é'.repeat(300);
|
|
202
|
+
expect(frame.length).toBeLessThanOrEqual(MAX_WS_MESSAGE_BYTES);
|
|
203
|
+
expect(isWithinWsByteBudget(frame)).toBe(false);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('accepts a multi-byte frame whose UTF-8 length is exactly 510 bytes', () => {
|
|
207
|
+
// 255 'é' = 255 code units, exactly 510 UTF-8 bytes. Byte accounting
|
|
208
|
+
// must not over-reject.
|
|
209
|
+
expect(isWithinWsByteBudget('é'.repeat(255))).toBe(true);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('rejects a 511-é frame (1022 bytes) that once passed the unit cap', () => {
|
|
213
|
+
expect(isWithinWsByteBudget('é'.repeat(511))).toBe(false);
|
|
214
|
+
});
|
|
170
215
|
});
|
|
171
216
|
|
|
172
217
|
// ============================================================================
|