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,11 +1,26 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest';
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
OPER_HASHED_USER,
|
|
4
|
+
OPER_HASHED_VALID_PASSWORD,
|
|
5
|
+
OPER_HASHED_WRONG_PASSWORD,
|
|
6
|
+
OPER_PRECOMPUTED_DUMMY_HASHED,
|
|
7
|
+
OPER_PRECOMPUTED_HASHED_CRED,
|
|
8
|
+
resetOperLegacyPlaintextWarning,
|
|
9
|
+
resetOperTimingSpy,
|
|
10
|
+
setOperLegacyPlaintextWarner,
|
|
11
|
+
setOperVerifyHashedPassword,
|
|
12
|
+
} from '../../src/commands/oper';
|
|
2
13
|
import { matchOperCred, operReducer } from '../../src/commands/oper';
|
|
14
|
+
import { type HashedAccountCredential, hashAccountCredential } from '../../src/credential-hashing';
|
|
3
15
|
import { Effect } from '../../src/effects';
|
|
4
16
|
import type { Effect as EffectType, RawLine } from '../../src/effects';
|
|
17
|
+
import { OperFailureStats } from '../../src/oper-lockout';
|
|
5
18
|
import { EmptyMotdProvider, FakeClock, SequentialIdFactory } from '../../src/ports';
|
|
6
19
|
import { type ConnectionState, createConnection } from '../../src/state/connection';
|
|
7
20
|
import { type Ctx, type ServerConfig, buildCtx } from '../../src/types';
|
|
8
21
|
|
|
22
|
+
type VerifyHashedPasswordFn = (password: string, entry: HashedAccountCredential) => boolean;
|
|
23
|
+
|
|
9
24
|
const baseServerConfig: ServerConfig = {
|
|
10
25
|
serverName: 'irc.example.com',
|
|
11
26
|
networkName: 'ExampleNet',
|
|
@@ -36,6 +51,27 @@ function makeCtx(state: ConnectionState, config: ServerConfig = serverConfigWith
|
|
|
36
51
|
});
|
|
37
52
|
}
|
|
38
53
|
|
|
54
|
+
/**
|
|
55
|
+
* Builds a ctx with a per-IP failed-OPER tracker bound, sharing `clock`
|
|
56
|
+
* between the tracker and the ctx so window arithmetic is deterministic
|
|
57
|
+
* under FakeClock advances.
|
|
58
|
+
*/
|
|
59
|
+
function makeTrackedCtx(
|
|
60
|
+
state: ConnectionState,
|
|
61
|
+
tracker: OperFailureStats,
|
|
62
|
+
clock: FakeClock,
|
|
63
|
+
config: ServerConfig = serverConfigWithCreds,
|
|
64
|
+
): Ctx {
|
|
65
|
+
return buildCtx({
|
|
66
|
+
serverConfig: config,
|
|
67
|
+
clock,
|
|
68
|
+
ids: new SequentialIdFactory(),
|
|
69
|
+
motd: EmptyMotdProvider,
|
|
70
|
+
connection: state,
|
|
71
|
+
operFailures: tracker,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
39
75
|
function makeState(): ConnectionState {
|
|
40
76
|
const s = createConnection({ id: 'c1', connectedSince: 0 });
|
|
41
77
|
s.nick = 'alice';
|
|
@@ -82,9 +118,12 @@ describe('operReducer — successful authentication', () => {
|
|
|
82
118
|
});
|
|
83
119
|
|
|
84
120
|
it('falls back to ? / * in the MODE echo when the connection has no nick or hostmask', () => {
|
|
85
|
-
// A
|
|
121
|
+
// A connection with no nick/user/host: hostmaskOf returns undefined
|
|
86
122
|
// and state.nick is undefined, so modeEchoLine falls back to '?' / '*'.
|
|
123
|
+
// Registered so the registration gate passes (the formatter fallback,
|
|
124
|
+
// not the gate, is under test here).
|
|
87
125
|
const state = createConnection({ id: 'c1', connectedSince: 0 });
|
|
126
|
+
state.registration = 'registered';
|
|
88
127
|
const ctx = makeCtx(state);
|
|
89
128
|
|
|
90
129
|
const out = operReducer(state, oper('alice', 'secret'), ctx);
|
|
@@ -243,7 +282,10 @@ describe('operReducer — missing parameters', () => {
|
|
|
243
282
|
});
|
|
244
283
|
|
|
245
284
|
it('uses "*" as the nick in 461 when the connection has no nick yet', () => {
|
|
285
|
+
// Registered (a synthetic nick-less connection) so the test exercises
|
|
286
|
+
// the 461 nick fallback rather than the registration gate.
|
|
246
287
|
const state = createConnection({ id: 'c1', connectedSince: 0 });
|
|
288
|
+
state.registration = 'registered';
|
|
247
289
|
const ctx = makeCtx(state);
|
|
248
290
|
|
|
249
291
|
const out = operReducer(state, oper(), ctx);
|
|
@@ -254,6 +296,69 @@ describe('operReducer — missing parameters', () => {
|
|
|
254
296
|
});
|
|
255
297
|
});
|
|
256
298
|
|
|
299
|
+
describe('operReducer — registration gate', () => {
|
|
300
|
+
it('rejects a pre-registration connection with 451 ERR_NOTREGISTERED without granting oper', () => {
|
|
301
|
+
const state = makeState();
|
|
302
|
+
state.registration = 'pre-registration';
|
|
303
|
+
const ctx = makeCtx(state);
|
|
304
|
+
|
|
305
|
+
const out = operReducer(state, oper('alice', 'secret'), ctx);
|
|
306
|
+
|
|
307
|
+
expect(out.state.userModes.oper).toBe(false);
|
|
308
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
309
|
+
Effect.send('c1', [L(':irc.example.com 451 alice :You have not registered')]),
|
|
310
|
+
]);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it('stamps lastSeen even when the registration gate rejects', () => {
|
|
314
|
+
const state = makeState();
|
|
315
|
+
state.registration = 'pre-registration';
|
|
316
|
+
expect(state.lastSeen).toBe(0);
|
|
317
|
+
const ctx = makeCtx(state);
|
|
318
|
+
|
|
319
|
+
operReducer(state, oper('alice', 'secret'), ctx);
|
|
320
|
+
|
|
321
|
+
expect(state.lastSeen).toBe(5_000);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
it('checks the registration gate before parameter validation (451 wins over 461)', () => {
|
|
325
|
+
const state = makeState();
|
|
326
|
+
state.registration = 'pre-registration';
|
|
327
|
+
const ctx = makeCtx(state);
|
|
328
|
+
|
|
329
|
+
const out = operReducer(state, oper(), ctx);
|
|
330
|
+
|
|
331
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
332
|
+
Effect.send('c1', [L(':irc.example.com 451 alice :You have not registered')]),
|
|
333
|
+
]);
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
it('records no lockout failure for a pre-registration rejection', () => {
|
|
337
|
+
const clock = new FakeClock(1_000);
|
|
338
|
+
const tracker = new OperFailureStats(clock);
|
|
339
|
+
const state = makeState();
|
|
340
|
+
state.registration = 'pre-registration';
|
|
341
|
+
const ctx = makeTrackedCtx(state, tracker, clock);
|
|
342
|
+
|
|
343
|
+
operReducer(state, oper('alice', 'secret'), ctx);
|
|
344
|
+
|
|
345
|
+
expect(tracker.recentFailures('example.com', 60_000)).toBe(0);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it('grants oper post-registration (gate passes through to the credential path)', () => {
|
|
349
|
+
const state = makeState();
|
|
350
|
+
const ctx = makeCtx(state);
|
|
351
|
+
|
|
352
|
+
const out = operReducer(state, oper('alice', 'secret'), ctx);
|
|
353
|
+
|
|
354
|
+
expect(out.state.userModes.oper).toBe(true);
|
|
355
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
356
|
+
Effect.send('c1', [L(':irc.example.com 381 alice :You are now an IRC operator')]),
|
|
357
|
+
Effect.send('c1', [L(':alice!alice@example.com MODE alice +o')]),
|
|
358
|
+
]);
|
|
359
|
+
});
|
|
360
|
+
});
|
|
361
|
+
|
|
257
362
|
describe('matchOperCred', () => {
|
|
258
363
|
it('returns true when a credential pair matches exactly', () => {
|
|
259
364
|
const creds = [
|
|
@@ -283,3 +388,456 @@ describe('matchOperCred', () => {
|
|
|
283
388
|
expect(matchOperCred(creds, 'alice', 'Secret')).toBe(false);
|
|
284
389
|
});
|
|
285
390
|
});
|
|
391
|
+
|
|
392
|
+
// ============================================================================
|
|
393
|
+
// Hashed credentials (scrypt) — the new at-rest shape.
|
|
394
|
+
// ============================================================================
|
|
395
|
+
|
|
396
|
+
describe('matchOperCred — hashed credentials', () => {
|
|
397
|
+
afterEach(() => {
|
|
398
|
+
resetOperLegacyPlaintextWarning();
|
|
399
|
+
resetOperTimingSpy();
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
it('verifies a correct password against the precomputed scrypt-hashed cred', () => {
|
|
403
|
+
expect(
|
|
404
|
+
matchOperCred([OPER_PRECOMPUTED_HASHED_CRED], OPER_HASHED_USER, OPER_HASHED_VALID_PASSWORD),
|
|
405
|
+
).toBe(true);
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
it('rejects a wrong password against a scrypt-hashed cred', () => {
|
|
409
|
+
expect(
|
|
410
|
+
matchOperCred([OPER_PRECOMPUTED_HASHED_CRED], OPER_HASHED_USER, OPER_HASHED_WRONG_PASSWORD),
|
|
411
|
+
).toBe(false);
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
it('rejects an unknown user against a scrypt-hashed cred', () => {
|
|
415
|
+
expect(
|
|
416
|
+
matchOperCred(
|
|
417
|
+
[OPER_PRECOMPUTED_HASHED_CRED],
|
|
418
|
+
'definitely-not-a-user',
|
|
419
|
+
OPER_HASHED_VALID_PASSWORD,
|
|
420
|
+
),
|
|
421
|
+
).toBe(false);
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
it('round-trips a freshly-hashed credential via hashAccountCredential', () => {
|
|
425
|
+
const entry = hashAccountCredential('admin', 's3cret-password');
|
|
426
|
+
const cred = { user: entry.account, salt: entry.salt, hash: entry.hash };
|
|
427
|
+
expect(matchOperCred([cred], 'admin', 's3cret-password')).toBe(true);
|
|
428
|
+
expect(matchOperCred([cred], 'admin', 'wrong')).toBe(false);
|
|
429
|
+
});
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
// ============================================================================
|
|
433
|
+
// Timing equalisation — matchOperCred runs a dummy scrypt verify on the
|
|
434
|
+
// user-unknown path so a timing attacker cannot distinguish "user exists"
|
|
435
|
+
// from "user unknown".
|
|
436
|
+
// ============================================================================
|
|
437
|
+
|
|
438
|
+
describe('matchOperCred — timing-equalising dummy verify on unknown user', () => {
|
|
439
|
+
afterEach(() => {
|
|
440
|
+
resetOperLegacyPlaintextWarning();
|
|
441
|
+
resetOperTimingSpy();
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
it('invokes exactly one verify on a known user with the wrong password', () => {
|
|
445
|
+
const spy = vi.fn<VerifyHashedPasswordFn>(() => false);
|
|
446
|
+
setOperVerifyHashedPassword(spy);
|
|
447
|
+
try {
|
|
448
|
+
matchOperCred([OPER_PRECOMPUTED_HASHED_CRED], OPER_HASHED_USER, OPER_HASHED_WRONG_PASSWORD);
|
|
449
|
+
expect(spy).toHaveBeenCalledTimes(1);
|
|
450
|
+
} finally {
|
|
451
|
+
setOperVerifyHashedPassword(null);
|
|
452
|
+
}
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
it('invokes exactly one (dummy) verify on an unknown user — no O(1) short-circuit', () => {
|
|
456
|
+
const spy = vi.fn<VerifyHashedPasswordFn>(() => false);
|
|
457
|
+
setOperVerifyHashedPassword(spy);
|
|
458
|
+
try {
|
|
459
|
+
matchOperCred(
|
|
460
|
+
[OPER_PRECOMPUTED_HASHED_CRED],
|
|
461
|
+
'definitely-not-a-user',
|
|
462
|
+
OPER_HASHED_VALID_PASSWORD,
|
|
463
|
+
);
|
|
464
|
+
// Critical: a single dummy verify runs even though no user matched,
|
|
465
|
+
// equalising wall-clock cost with the wrong-password path.
|
|
466
|
+
expect(spy).toHaveBeenCalledTimes(1);
|
|
467
|
+
// The dummy verify is fed a fixed precomputed entry (never the
|
|
468
|
+
// attacker's payload as the "user") so the timing is independent
|
|
469
|
+
// of attacker input.
|
|
470
|
+
expect(spy.mock.calls[0]?.[1]).toBe(OPER_PRECOMPUTED_DUMMY_HASHED);
|
|
471
|
+
} finally {
|
|
472
|
+
setOperVerifyHashedPassword(null);
|
|
473
|
+
}
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
it('does not invoke a dummy verify when the user matches and password verifies', () => {
|
|
477
|
+
const spy = vi.fn<VerifyHashedPasswordFn>(() => true);
|
|
478
|
+
setOperVerifyHashedPassword(spy);
|
|
479
|
+
try {
|
|
480
|
+
matchOperCred([OPER_PRECOMPUTED_HASHED_CRED], OPER_HASHED_USER, OPER_HASHED_VALID_PASSWORD);
|
|
481
|
+
expect(spy).toHaveBeenCalledTimes(1);
|
|
482
|
+
} finally {
|
|
483
|
+
setOperVerifyHashedPassword(null);
|
|
484
|
+
}
|
|
485
|
+
});
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
// ============================================================================
|
|
489
|
+
// Legacy plaintext fallback (deprecation cycle) — keeps verifying but warns
|
|
490
|
+
// once per boot.
|
|
491
|
+
// ============================================================================
|
|
492
|
+
|
|
493
|
+
describe('matchOperCred — legacy plaintext fallback (deprecation cycle)', () => {
|
|
494
|
+
afterEach(() => {
|
|
495
|
+
resetOperLegacyPlaintextWarning();
|
|
496
|
+
resetOperTimingSpy();
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
it('still verifies a legacy {user,password} cred', () => {
|
|
500
|
+
const warns: string[] = [];
|
|
501
|
+
setOperLegacyPlaintextWarner((msg) => {
|
|
502
|
+
warns.push(msg);
|
|
503
|
+
});
|
|
504
|
+
try {
|
|
505
|
+
expect(matchOperCred([{ user: 'alice', password: 'secret' }], 'alice', 'secret')).toBe(true);
|
|
506
|
+
expect(matchOperCred([{ user: 'alice', password: 'secret' }], 'alice', 'wrong')).toBe(false);
|
|
507
|
+
} finally {
|
|
508
|
+
setOperLegacyPlaintextWarner(null);
|
|
509
|
+
}
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
it('warns exactly once per boot when a legacy plaintext cred is encountered', () => {
|
|
513
|
+
const warns: string[] = [];
|
|
514
|
+
setOperLegacyPlaintextWarner((msg) => {
|
|
515
|
+
warns.push(msg);
|
|
516
|
+
});
|
|
517
|
+
try {
|
|
518
|
+
matchOperCred([{ user: 'alice', password: 'secret' }], 'alice', 'secret');
|
|
519
|
+
matchOperCred([{ user: 'bob', password: 'pw' }], 'bob', 'pw');
|
|
520
|
+
matchOperCred([{ user: 'carol', password: 'pw' }], 'carol', 'pw');
|
|
521
|
+
expect(warns).toHaveLength(1);
|
|
522
|
+
expect(warns[0]).toMatch(/deprecat/iu);
|
|
523
|
+
} finally {
|
|
524
|
+
setOperLegacyPlaintextWarner(null);
|
|
525
|
+
}
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
it('does NOT warn when only hashed creds are configured', () => {
|
|
529
|
+
const warns: string[] = [];
|
|
530
|
+
setOperLegacyPlaintextWarner((msg) => {
|
|
531
|
+
warns.push(msg);
|
|
532
|
+
});
|
|
533
|
+
try {
|
|
534
|
+
matchOperCred([OPER_PRECOMPUTED_HASHED_CRED], OPER_HASHED_USER, OPER_HASHED_VALID_PASSWORD);
|
|
535
|
+
expect(warns).toHaveLength(0);
|
|
536
|
+
} finally {
|
|
537
|
+
setOperLegacyPlaintextWarner(null);
|
|
538
|
+
}
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
it('restores the default console.warn sink when setOperLegacyPlaintextWarner(null) is called', () => {
|
|
542
|
+
// Spy on console.warn to confirm the default sink routes there after
|
|
543
|
+
// a `null` restore. The previous test installed a custom sink; this
|
|
544
|
+
// call MUST revert to console.warn so production emissions land in
|
|
545
|
+
// the platform's observability pipeline.
|
|
546
|
+
const calls: string[] = [];
|
|
547
|
+
const original = console.warn;
|
|
548
|
+
const warnSpy = (msg: string) => {
|
|
549
|
+
calls.push(msg);
|
|
550
|
+
};
|
|
551
|
+
console.warn = warnSpy;
|
|
552
|
+
try {
|
|
553
|
+
setOperLegacyPlaintextWarner(null);
|
|
554
|
+
// Force the warn flag to fire on the next call.
|
|
555
|
+
resetOperLegacyPlaintextWarning();
|
|
556
|
+
matchOperCred([{ user: 'alice', password: 'secret' }], 'alice', 'secret');
|
|
557
|
+
expect(calls).toHaveLength(1);
|
|
558
|
+
expect(calls[0]).toMatch(/deprecat/iu);
|
|
559
|
+
} finally {
|
|
560
|
+
console.warn = original;
|
|
561
|
+
resetOperLegacyPlaintextWarning();
|
|
562
|
+
setOperLegacyPlaintextWarner(null);
|
|
563
|
+
}
|
|
564
|
+
});
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
// ============================================================================
|
|
568
|
+
// Per-IP failed-OPER lockout — threshold crossing, success-resets-counter,
|
|
569
|
+
// window expiry, and the oper-notice emission.
|
|
570
|
+
// ============================================================================
|
|
571
|
+
|
|
572
|
+
describe('operReducer — per-IP failed-OPER lockout', () => {
|
|
573
|
+
afterEach(() => {
|
|
574
|
+
resetOperTimingSpy();
|
|
575
|
+
resetOperLegacyPlaintextWarning();
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
it('rejects the next OPER with 491 after 3 consecutive credential failures from one IP', () => {
|
|
579
|
+
const clock = new FakeClock(5_000);
|
|
580
|
+
const tracker = new OperFailureStats(clock);
|
|
581
|
+
const state = makeState();
|
|
582
|
+
|
|
583
|
+
for (let i = 0; i < 3; i++) {
|
|
584
|
+
const out = operReducer(
|
|
585
|
+
state,
|
|
586
|
+
oper('alice', `wrong-${i}`),
|
|
587
|
+
makeTrackedCtx(state, tracker, clock),
|
|
588
|
+
);
|
|
589
|
+
expect(out.state.userModes.oper).toBe(false);
|
|
590
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
591
|
+
Effect.send('c1', [L(':irc.example.com 464 alice :Password Incorrect')]),
|
|
592
|
+
]);
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
// The 4th attempt is rejected without consulting the credential store —
|
|
596
|
+
// even with the CORRECT password the lockout wins.
|
|
597
|
+
const locked = operReducer(
|
|
598
|
+
state,
|
|
599
|
+
oper('alice', 'secret'),
|
|
600
|
+
makeTrackedCtx(state, tracker, clock),
|
|
601
|
+
);
|
|
602
|
+
expect(locked.state.userModes.oper).toBe(false);
|
|
603
|
+
const texts = locked.effects.flatMap((e) =>
|
|
604
|
+
e.tag === 'Send' ? e.lines.map((l) => l.text) : [],
|
|
605
|
+
);
|
|
606
|
+
expect(texts).toContain(':irc.example.com 491 alice :No O-lines for your host');
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
it('does not invoke the credential verifier once the IP is locked out', () => {
|
|
610
|
+
const clock = new FakeClock(5_000);
|
|
611
|
+
const tracker = new OperFailureStats(clock);
|
|
612
|
+
const config: ServerConfig = {
|
|
613
|
+
...baseServerConfig,
|
|
614
|
+
operCreds: [OPER_PRECOMPUTED_HASHED_CRED],
|
|
615
|
+
};
|
|
616
|
+
const state = makeState();
|
|
617
|
+
|
|
618
|
+
for (let i = 0; i < 3; i++) {
|
|
619
|
+
operReducer(
|
|
620
|
+
state,
|
|
621
|
+
oper(OPER_HASHED_USER, OPER_HASHED_WRONG_PASSWORD),
|
|
622
|
+
makeTrackedCtx(state, tracker, clock, config),
|
|
623
|
+
);
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
const spy = vi.fn<VerifyHashedPasswordFn>(() => true);
|
|
627
|
+
setOperVerifyHashedPassword(spy);
|
|
628
|
+
try {
|
|
629
|
+
const locked = operReducer(
|
|
630
|
+
state,
|
|
631
|
+
oper(OPER_HASHED_USER, OPER_HASHED_VALID_PASSWORD),
|
|
632
|
+
makeTrackedCtx(state, tracker, clock, config),
|
|
633
|
+
);
|
|
634
|
+
// Critical: the lockout short-circuits BEFORE matchOperCred, so the
|
|
635
|
+
// (stubbed) scrypt verifier is never invoked for the locked attempt.
|
|
636
|
+
expect(spy).toHaveBeenCalledTimes(0);
|
|
637
|
+
expect(locked.state.userModes.oper).toBe(false);
|
|
638
|
+
} finally {
|
|
639
|
+
setOperVerifyHashedPassword(null);
|
|
640
|
+
}
|
|
641
|
+
});
|
|
642
|
+
|
|
643
|
+
it('emits a BroadcastOperNotice effect to all opers when the lockout trips', () => {
|
|
644
|
+
const clock = new FakeClock(5_000);
|
|
645
|
+
const tracker = new OperFailureStats(clock);
|
|
646
|
+
const state = makeState();
|
|
647
|
+
|
|
648
|
+
for (let i = 0; i < 3; i++) {
|
|
649
|
+
operReducer(state, oper('alice', 'wrong'), makeTrackedCtx(state, tracker, clock));
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
const locked = operReducer(
|
|
653
|
+
state,
|
|
654
|
+
oper('alice', 'secret'),
|
|
655
|
+
makeTrackedCtx(state, tracker, clock),
|
|
656
|
+
);
|
|
657
|
+
expect(locked.effects).toEqual<EffectType[]>([
|
|
658
|
+
Effect.send('c1', [L(':irc.example.com 491 alice :No O-lines for your host')]),
|
|
659
|
+
Effect.broadcastOperNotice([
|
|
660
|
+
L(':irc.example.com NOTICE * :OPER lockout triggered for example.com'),
|
|
661
|
+
]),
|
|
662
|
+
]);
|
|
663
|
+
});
|
|
664
|
+
|
|
665
|
+
it('resets the per-IP failure counter on successful OPER', () => {
|
|
666
|
+
const clock = new FakeClock(5_000);
|
|
667
|
+
const tracker = new OperFailureStats(clock);
|
|
668
|
+
const state = makeState();
|
|
669
|
+
|
|
670
|
+
// Two failures, then a success that resets the counter.
|
|
671
|
+
operReducer(state, oper('alice', 'wrong'), makeTrackedCtx(state, tracker, clock));
|
|
672
|
+
operReducer(state, oper('alice', 'wrong'), makeTrackedCtx(state, tracker, clock));
|
|
673
|
+
const ok = operReducer(state, oper('alice', 'secret'), makeTrackedCtx(state, tracker, clock));
|
|
674
|
+
expect(ok.state.userModes.oper).toBe(true);
|
|
675
|
+
|
|
676
|
+
// Two more failures after the reset: only 2 recent failures remain, so
|
|
677
|
+
// the next correct attempt is NOT locked out.
|
|
678
|
+
state.userModes.oper = false;
|
|
679
|
+
operReducer(state, oper('alice', 'wrong'), makeTrackedCtx(state, tracker, clock));
|
|
680
|
+
operReducer(state, oper('alice', 'wrong'), makeTrackedCtx(state, tracker, clock));
|
|
681
|
+
const after = operReducer(
|
|
682
|
+
state,
|
|
683
|
+
oper('alice', 'secret'),
|
|
684
|
+
makeTrackedCtx(state, tracker, clock),
|
|
685
|
+
);
|
|
686
|
+
expect(after.state.userModes.oper).toBe(true);
|
|
687
|
+
});
|
|
688
|
+
|
|
689
|
+
it('forgets failures once the configured window has elapsed', () => {
|
|
690
|
+
const clock = new FakeClock(5_000);
|
|
691
|
+
const tracker = new OperFailureStats(clock);
|
|
692
|
+
const state = makeState();
|
|
693
|
+
|
|
694
|
+
for (let i = 0; i < 3; i++) {
|
|
695
|
+
operReducer(state, oper('alice', 'wrong'), makeTrackedCtx(state, tracker, clock));
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
// Default window is 300s; advancing past it prunes every recorded
|
|
699
|
+
// failure, so a subsequent correct attempt succeeds.
|
|
700
|
+
clock.advance(300_001);
|
|
701
|
+
const after = operReducer(
|
|
702
|
+
state,
|
|
703
|
+
oper('alice', 'secret'),
|
|
704
|
+
makeTrackedCtx(state, tracker, clock),
|
|
705
|
+
);
|
|
706
|
+
expect(after.state.userModes.oper).toBe(true);
|
|
707
|
+
expect(tracker.recentFailures('example.com', 300_000)).toBe(0);
|
|
708
|
+
});
|
|
709
|
+
|
|
710
|
+
it('honours oper.maxFailures and oper.failureWindowSeconds from ServerConfig', () => {
|
|
711
|
+
const clock = new FakeClock(5_000);
|
|
712
|
+
const tracker = new OperFailureStats(clock);
|
|
713
|
+
const config: ServerConfig = {
|
|
714
|
+
...serverConfigWithCreds,
|
|
715
|
+
oper: { maxFailures: 1, failureWindowSeconds: 60 },
|
|
716
|
+
};
|
|
717
|
+
const state = makeState();
|
|
718
|
+
|
|
719
|
+
// maxFailures: 1 — the SECOND attempt from this IP is already locked.
|
|
720
|
+
operReducer(state, oper('alice', 'wrong'), makeTrackedCtx(state, tracker, clock, config));
|
|
721
|
+
const locked = operReducer(
|
|
722
|
+
state,
|
|
723
|
+
oper('alice', 'secret'),
|
|
724
|
+
makeTrackedCtx(state, tracker, clock, config),
|
|
725
|
+
);
|
|
726
|
+
expect(locked.state.userModes.oper).toBe(false);
|
|
727
|
+
expect(locked.effects).toEqual<EffectType[]>([
|
|
728
|
+
Effect.send('c1', [L(':irc.example.com 491 alice :No O-lines for your host')]),
|
|
729
|
+
Effect.broadcastOperNotice([
|
|
730
|
+
L(':irc.example.com NOTICE * :OPER lockout triggered for example.com'),
|
|
731
|
+
]),
|
|
732
|
+
]);
|
|
733
|
+
|
|
734
|
+
// failureWindowSeconds: 60 — after 61s the single failure expires.
|
|
735
|
+
clock.advance(61_000);
|
|
736
|
+
const after = operReducer(
|
|
737
|
+
state,
|
|
738
|
+
oper('alice', 'wrong'),
|
|
739
|
+
makeTrackedCtx(state, tracker, clock, config),
|
|
740
|
+
);
|
|
741
|
+
expect(after.effects).toEqual<EffectType[]>([
|
|
742
|
+
Effect.send('c1', [L(':irc.example.com 464 alice :Password Incorrect')]),
|
|
743
|
+
]);
|
|
744
|
+
});
|
|
745
|
+
|
|
746
|
+
it('falls back to the default thresholds when the oper config omits the fields', () => {
|
|
747
|
+
const clock = new FakeClock(5_000);
|
|
748
|
+
const tracker = new OperFailureStats(clock);
|
|
749
|
+
const config: ServerConfig = { ...serverConfigWithCreds, oper: {} };
|
|
750
|
+
const state = makeState();
|
|
751
|
+
|
|
752
|
+
// Defaults: 3 failures. Two failures are not yet enough to lock.
|
|
753
|
+
operReducer(state, oper('alice', 'wrong'), makeTrackedCtx(state, tracker, clock, config));
|
|
754
|
+
operReducer(state, oper('alice', 'wrong'), makeTrackedCtx(state, tracker, clock, config));
|
|
755
|
+
const notLocked = operReducer(
|
|
756
|
+
state,
|
|
757
|
+
oper('alice', 'secret'),
|
|
758
|
+
makeTrackedCtx(state, tracker, clock, config),
|
|
759
|
+
);
|
|
760
|
+
expect(notLocked.state.userModes.oper).toBe(true);
|
|
761
|
+
|
|
762
|
+
// Three failures (post-success reset makes it a fresh run) then lock.
|
|
763
|
+
state.userModes.oper = false;
|
|
764
|
+
for (let i = 0; i < 3; i++) {
|
|
765
|
+
operReducer(state, oper('alice', 'wrong'), makeTrackedCtx(state, tracker, clock, config));
|
|
766
|
+
}
|
|
767
|
+
const locked = operReducer(
|
|
768
|
+
state,
|
|
769
|
+
oper('alice', 'secret'),
|
|
770
|
+
makeTrackedCtx(state, tracker, clock, config),
|
|
771
|
+
);
|
|
772
|
+
expect(locked.state.userModes.oper).toBe(false);
|
|
773
|
+
});
|
|
774
|
+
|
|
775
|
+
it('does not lock out when no failure tracker is bound (ctx.operFailures undefined)', () => {
|
|
776
|
+
const state = makeState();
|
|
777
|
+
for (let i = 0; i < 5; i++) {
|
|
778
|
+
const out = operReducer(state, oper('alice', 'wrong'), makeCtx(state));
|
|
779
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
780
|
+
Effect.send('c1', [L(':irc.example.com 464 alice :Password Incorrect')]),
|
|
781
|
+
]);
|
|
782
|
+
}
|
|
783
|
+
});
|
|
784
|
+
|
|
785
|
+
it('skips lockout accounting when the connection has no host set', () => {
|
|
786
|
+
const clock = new FakeClock(5_000);
|
|
787
|
+
const tracker = new OperFailureStats(clock);
|
|
788
|
+
const state = createConnection({ id: 'c1', connectedSince: 0 });
|
|
789
|
+
state.nick = 'alice';
|
|
790
|
+
state.registration = 'registered';
|
|
791
|
+
|
|
792
|
+
// No host → no lockout key → failures are never recorded, so the
|
|
793
|
+
// credential store keeps being consulted.
|
|
794
|
+
for (let i = 0; i < 5; i++) {
|
|
795
|
+
const out = operReducer(state, oper('alice', 'wrong'), makeTrackedCtx(state, tracker, clock));
|
|
796
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
797
|
+
Effect.send('c1', [L(':irc.example.com 464 alice :Password Incorrect')]),
|
|
798
|
+
]);
|
|
799
|
+
}
|
|
800
|
+
});
|
|
801
|
+
|
|
802
|
+
it('still re-confirms 381 for an already-oper connection from a locked-out IP', () => {
|
|
803
|
+
const clock = new FakeClock(5_000);
|
|
804
|
+
const tracker = new OperFailureStats(clock);
|
|
805
|
+
const state = makeState();
|
|
806
|
+
|
|
807
|
+
for (let i = 0; i < 3; i++) {
|
|
808
|
+
operReducer(state, oper('alice', 'wrong'), makeTrackedCtx(state, tracker, clock));
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
// A different connection from the same host that is ALREADY oper: the
|
|
812
|
+
// idempotent re-confirm path runs before the lockout gate.
|
|
813
|
+
const operState = makeState();
|
|
814
|
+
operState.userModes.oper = true;
|
|
815
|
+
const out = operReducer(
|
|
816
|
+
operState,
|
|
817
|
+
oper('alice', 'secret'),
|
|
818
|
+
makeTrackedCtx(operState, tracker, clock),
|
|
819
|
+
);
|
|
820
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
821
|
+
Effect.send('c1', [L(':irc.example.com 381 alice :You are now an IRC operator')]),
|
|
822
|
+
]);
|
|
823
|
+
});
|
|
824
|
+
|
|
825
|
+
it('keys failures per host so distinct hosts do not lock each other out', () => {
|
|
826
|
+
const clock = new FakeClock(5_000);
|
|
827
|
+
const tracker = new OperFailureStats(clock);
|
|
828
|
+
const attacker = makeState();
|
|
829
|
+
const victim = makeState();
|
|
830
|
+
victim.host = '10.0.0.2';
|
|
831
|
+
|
|
832
|
+
for (let i = 0; i < 3; i++) {
|
|
833
|
+
operReducer(attacker, oper('alice', 'wrong'), makeTrackedCtx(attacker, tracker, clock));
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
const out = operReducer(
|
|
837
|
+
victim,
|
|
838
|
+
oper('alice', 'secret'),
|
|
839
|
+
makeTrackedCtx(victim, tracker, clock),
|
|
840
|
+
);
|
|
841
|
+
expect(out.state.userModes.oper).toBe(true);
|
|
842
|
+
});
|
|
843
|
+
});
|
|
@@ -97,6 +97,22 @@ describe('isChannelTarget', () => {
|
|
|
97
97
|
it('returns false for a name with a space', () => {
|
|
98
98
|
expect(isChannelTarget('foo bar')).toBe(false);
|
|
99
99
|
});
|
|
100
|
+
|
|
101
|
+
it('returns false for a channel name containing NUL', () => {
|
|
102
|
+
expect(isChannelTarget('#foo\0bar')).toBe(false);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('returns false for a channel name containing a control character', () => {
|
|
106
|
+
expect(isChannelTarget('#foo\x01bar')).toBe(false);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('returns true for a channel name containing !', () => {
|
|
110
|
+
expect(isChannelTarget('#foo!bar')).toBe(true);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('returns true for a channel name containing @', () => {
|
|
114
|
+
expect(isChannelTarget('#foo@bar')).toBe(true);
|
|
115
|
+
});
|
|
100
116
|
});
|
|
101
117
|
|
|
102
118
|
// ============================================================================
|