serverless-ircd 0.8.0 → 0.10.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 +4 -0
- package/.github/workflows/deploy-aws.yml +156 -32
- package/.github/workflows/deploy-cf-tcp.yml +11 -9
- package/.github/workflows/deploy-cf.yml +14 -14
- package/CHANGELOG.md +550 -0
- package/README.md +275 -222
- package/apps/aws-stack/README.md +3 -5
- package/apps/aws-stack/bin/aws.ts +82 -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 +177 -52
- 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 +267 -92
- 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/package.json +2 -3
- package/apps/cf-tcp-container/src/container-server.ts +33 -10
- package/apps/cf-tcp-container/tests/config-loader.test.ts +43 -0
- package/apps/cf-tcp-container/tests/container-server.test.ts +249 -1
- package/apps/cf-tcp-container/tests/persistence.test.ts +9 -0
- package/apps/cf-tcp-container/tests/tls-e2e.test.ts +24 -5
- package/apps/cf-tcp-container/wrangler.toml +1 -10
- package/apps/cf-worker/package.json +3 -4
- package/apps/cf-worker/wrangler.toml +12 -71
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/server.ts +115 -48
- package/apps/local-cli/tests/config-resolution.test.ts +65 -0
- package/apps/local-cli/tests/motd-file-non-error.test.ts +29 -0
- package/apps/local-cli/tests/rehash.test.ts +147 -0
- package/apps/local-cli/tests/server-helpers.test.ts +63 -0
- package/apps/local-cli/tests/tcp.test.ts +89 -0
- package/apps/local-cli/tests/ws-subprotocol.test.ts +92 -0
- package/apps/web/landing/favicon.ico +0 -0
- package/apps/web/landing/index.html +227 -3
- package/apps/web/package.json +3 -2
- package/apps/web/scripts/build.mjs +91 -6
- package/apps/web/src/build-env.ts +125 -4
- package/apps/web/src/config-schema.ts +20 -6
- package/apps/web/src/render-docs.ts +292 -0
- 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 +33 -4
- package/apps/web/tests/config-schema.test.ts +149 -25
- package/apps/web/tests/landing-content.test.ts +103 -0
- package/apps/web/tests/render-docs.test.ts +198 -0
- package/docs/AWS-Adapter-Architecture.md +3 -2
- package/docs/AWS-Deployment.md +670 -96
- package/docs/AWS-TCP-Deployment.md +20 -45
- package/docs/Cloudflare-Deployment-Guide.md +87 -113
- package/docs/Cloudflare-TCP-Deployment.md +25 -49
- package/docs/Release-Process.md +27 -23
- package/docs/Services.md +102 -23
- package/docs/WebClientGuide.md +35 -26
- package/package.json +7 -10
- package/packages/aws-adapter/package.json +1 -1
- package/packages/aws-adapter/src/aws-runtime.ts +15 -1
- package/packages/aws-adapter/src/cdk-table-defs.ts +6 -11
- package/packages/aws-adapter/src/config-loader.ts +19 -2
- package/packages/aws-adapter/src/dynamo-services-store.ts +7 -0
- package/packages/aws-adapter/src/handlers/connect.ts +26 -0
- package/packages/aws-adapter/src/handlers/default.ts +190 -123
- package/packages/aws-adapter/src/handlers/index.ts +67 -23
- package/packages/aws-adapter/src/handlers/nlb-stream.ts +13 -8
- package/packages/aws-adapter/src/index.ts +5 -7
- package/packages/aws-adapter/src/origin-allowlist.ts +94 -0
- package/packages/aws-adapter/src/serialize.ts +15 -0
- package/packages/aws-adapter/src/tables.ts +2 -12
- package/packages/aws-adapter/tests/aws-harness.ts +0 -1
- package/packages/aws-adapter/tests/aws-runtime.test.ts +23 -1
- package/packages/aws-adapter/tests/config-loader.test.ts +66 -0
- package/packages/aws-adapter/tests/connect.test.ts +124 -1
- package/packages/aws-adapter/tests/connection-counter.test.ts +17 -0
- package/packages/aws-adapter/tests/default-occ.test.ts +219 -0
- package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +11 -0
- package/packages/aws-adapter/tests/global-setup.ts +28 -1
- package/packages/aws-adapter/tests/gone-exception.test.ts +21 -2
- package/packages/aws-adapter/tests/handlers.test.ts +117 -11
- package/packages/aws-adapter/tests/migrate-accounts-to-services.test.ts +164 -0
- package/packages/aws-adapter/tests/nlb-stream.test.ts +29 -1
- 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 +20 -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 +59 -8
- package/packages/cf-adapter/src/channel-do.ts +13 -3
- package/packages/cf-adapter/src/connection-do.ts +284 -115
- package/packages/cf-adapter/src/d1-services-store.ts +63 -26
- package/packages/cf-adapter/src/env.ts +11 -10
- package/packages/cf-adapter/src/index.ts +0 -6
- package/packages/cf-adapter/tests/cf-runtime.test.ts +101 -1
- package/packages/cf-adapter/tests/channel-do.test.ts +118 -1
- package/packages/cf-adapter/tests/connection-do-coverage.test.ts +460 -0
- package/packages/cf-adapter/tests/connection-do-pure.test.ts +222 -51
- package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +62 -38
- package/packages/cf-adapter/tests/d1-services-store.test.ts +53 -2
- package/packages/cf-adapter/tests/serialize.test.ts +25 -0
- package/packages/in-memory-runtime/package.json +1 -1
- package/packages/irc-core/package.json +1 -1
- package/packages/irc-core/src/account-migration.ts +140 -0
- package/packages/irc-core/src/commands/account-auth.ts +60 -35
- package/packages/irc-core/src/commands/chanserv.ts +288 -4
- package/packages/irc-core/src/commands/hostserv.ts +38 -3
- package/packages/irc-core/src/commands/index.ts +1 -0
- package/packages/irc-core/src/commands/join.ts +41 -35
- package/packages/irc-core/src/commands/memoserv.ts +1 -1
- package/packages/irc-core/src/commands/nickserv.ts +138 -15
- package/packages/irc-core/src/commands/registration.ts +28 -17
- package/packages/irc-core/src/commands/sasl.ts +22 -31
- package/packages/irc-core/src/commands/service-aliases.ts +52 -0
- package/packages/irc-core/src/commands/topic.ts +23 -10
- package/packages/irc-core/src/config.ts +35 -9
- package/packages/irc-core/src/credential-hashing.ts +11 -54
- package/packages/irc-core/src/index.ts +1 -0
- package/packages/irc-core/src/ports.ts +159 -179
- package/packages/irc-core/src/state/channel.ts +17 -0
- package/packages/irc-core/src/types.ts +38 -10
- package/packages/irc-core/tests/account-migration.test.ts +133 -0
- package/packages/irc-core/tests/commands/chanserv.test.ts +668 -1
- package/packages/irc-core/tests/commands/hostserv.test.ts +71 -0
- package/packages/irc-core/tests/commands/join.test.ts +179 -0
- 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/nickserv.test.ts +422 -3
- package/packages/irc-core/tests/commands/oper.test.ts +15 -0
- package/packages/irc-core/tests/commands/registration.test.ts +336 -108
- package/packages/irc-core/tests/commands/sasl.test.ts +194 -169
- package/packages/irc-core/tests/commands/service-aliases.test.ts +52 -0
- package/packages/irc-core/tests/commands/unified-account.test.ts +102 -84
- package/packages/irc-core/tests/credential-hashing.test.ts +0 -78
- package/packages/irc-core/tests/message-store.test.ts +5 -0
- package/packages/irc-core/tests/persistent-services-store.test.ts +71 -12
- package/packages/irc-core/tests/ports.test.ts +71 -0
- package/packages/irc-core/tests/services-store.test.ts +204 -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 +80 -44
- package/packages/irc-server/tests/actor.test.ts +384 -50
- package/packages/irc-test-support/package.json +1 -1
- package/packages/irc-test-support/src/in-memory-harness.ts +8 -5
- package/packages/irc-test-support/src/scenarios.ts +21 -6
- package/packages/irc-test-support/tests/in-memory-harness.test.ts +19 -0
- package/packages/irc-test-support/vitest.config.ts +6 -1
- 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 +1 -1
- package/tools/ci-hardening/src/index.ts +2 -0
- package/tools/ci-hardening/src/validate.ts +57 -0
- package/tools/ci-hardening/tests/deploy-aws-oidc.test.ts +96 -0
- package/tools/ci-hardening/tests/validate.test.ts +42 -0
- package/tools/load-test/package.json +1 -1
- package/tools/load-test/src/client.ts +13 -13
- package/tools/load-test/tests/client.test.ts +258 -2
- package/tools/load-test/tests/config.test.ts +39 -0
- package/tools/load-test/tests/harness.test.ts +21 -0
- package/tools/load-test/tests/metrics.test.ts +7 -0
- 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/tools/tcp-ws-forwarder/tests/close-error.test.ts +40 -0
- package/tools/tcp-ws-forwarder/tests/defensive-branches.test.ts +78 -0
- package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +51 -0
- package/tools/tcp-ws-forwarder/tests/logger.test.ts +31 -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
|
@@ -33,20 +33,21 @@ import { randomUUID } from 'node:crypto';
|
|
|
33
33
|
import { type Server, type Socket, createServer } from 'node:net';
|
|
34
34
|
import { InMemoryRuntime } from '@serverless-ircd/in-memory-runtime';
|
|
35
35
|
import {
|
|
36
|
-
type AccountStore,
|
|
37
36
|
type AdmissionConfig,
|
|
38
37
|
type ChanName,
|
|
39
38
|
type ConnectionState,
|
|
40
39
|
ConsoleLogger,
|
|
41
|
-
InMemoryAccountStore,
|
|
42
40
|
InMemoryMessageStore,
|
|
43
41
|
InMemoryNickHistoryStore,
|
|
42
|
+
InMemoryServicesStore,
|
|
44
43
|
LogLevel,
|
|
45
44
|
type Logger,
|
|
46
45
|
type MessageStore,
|
|
47
46
|
type NickHistoryStore,
|
|
48
47
|
type RawLine,
|
|
49
48
|
type ServerConfig,
|
|
49
|
+
type ServicesStore,
|
|
50
|
+
SystemClock,
|
|
50
51
|
WS_SUBPROTO_BINARY,
|
|
51
52
|
WS_SUBPROTO_TEXT,
|
|
52
53
|
type WsFrameMode,
|
|
@@ -77,6 +78,7 @@ type ResolvedServerConfig = Omit<
|
|
|
77
78
|
| 'perIpConnectionRate'
|
|
78
79
|
| 'historyMaxPerChannel'
|
|
79
80
|
| 'saslAccounts'
|
|
81
|
+
| 'operCreds'
|
|
80
82
|
> &
|
|
81
83
|
Pick<
|
|
82
84
|
LocalServerConfig,
|
|
@@ -86,6 +88,7 @@ type ResolvedServerConfig = Omit<
|
|
|
86
88
|
| 'maxConnectionsPerUser'
|
|
87
89
|
| 'perIpConnectionRate'
|
|
88
90
|
| 'saslAccounts'
|
|
91
|
+
| 'operCreds'
|
|
89
92
|
>;
|
|
90
93
|
|
|
91
94
|
/**
|
|
@@ -107,6 +110,7 @@ function serverConfigFromResolved(cfg: ResolvedServerConfig): ServerConfig {
|
|
|
107
110
|
quitMessage: cfg.quitMessage,
|
|
108
111
|
...(cfg.serverPassword !== undefined ? { serverPassword: cfg.serverPassword } : {}),
|
|
109
112
|
...(cfg.cloaking !== undefined ? { cloaking: cfg.cloaking } : {}),
|
|
113
|
+
...(cfg.operCreds !== undefined ? { operCreds: cfg.operCreds } : {}),
|
|
110
114
|
};
|
|
111
115
|
}
|
|
112
116
|
|
|
@@ -165,12 +169,21 @@ export interface LocalServerConfig {
|
|
|
165
169
|
*/
|
|
166
170
|
historyMaxPerChannel?: number;
|
|
167
171
|
/**
|
|
168
|
-
* SASL PLAIN account credentials.
|
|
169
|
-
*
|
|
170
|
-
*
|
|
171
|
-
*
|
|
172
|
+
* SASL PLAIN account credentials. Seeded into the in-memory
|
|
173
|
+
* {@link ServicesStore} at boot so `AUTHENTICATE PLAIN`, `PASS
|
|
174
|
+
* <nick>:<password>`, and NickServ `IDENTIFY` all verify against the
|
|
175
|
+
* listed accounts. Empty array (the default) still binds a services
|
|
176
|
+
* store — NickServ REGISTER creates accounts at runtime.
|
|
172
177
|
*/
|
|
173
178
|
saslAccounts?: Array<{ username: string; password: string }>;
|
|
179
|
+
/**
|
|
180
|
+
* IRC operator credentials consulted by the `OPER` reducer. When omitted
|
|
181
|
+
* (or empty) `OPER` receives `491 ERR_NOOPERHOST` and oper-only commands
|
|
182
|
+
* like `REHASH` are unreachable; supply a non-empty list to let a client
|
|
183
|
+
* oper up. Threaded straight through to the reducer-facing
|
|
184
|
+
* {@link ServerConfig} via the shared schema.
|
|
185
|
+
*/
|
|
186
|
+
readonly operCreds?: Array<{ user: string; password: string }>;
|
|
174
187
|
}
|
|
175
188
|
|
|
176
189
|
export interface StartServerOptions extends LocalServerConfig {
|
|
@@ -235,6 +248,7 @@ const DEFAULT_CONFIG: Omit<
|
|
|
235
248
|
| 'perIpConnectionRate'
|
|
236
249
|
| 'historyMaxPerChannel'
|
|
237
250
|
| 'saslAccounts'
|
|
251
|
+
| 'operCreds'
|
|
238
252
|
| 'serverName'
|
|
239
253
|
| 'networkName'
|
|
240
254
|
> = {
|
|
@@ -303,6 +317,7 @@ export function startLocalServer(opts: StartServerOptions): Promise<LocalServer>
|
|
|
303
317
|
...(opts.quitMessage !== undefined ? { quitMessage: opts.quitMessage } : {}),
|
|
304
318
|
...(opts.saslAccounts !== undefined ? { saslAccounts: opts.saslAccounts } : {}),
|
|
305
319
|
...(opts.serverPassword !== undefined ? { serverPassword: opts.serverPassword } : {}),
|
|
320
|
+
...(opts.operCreds !== undefined ? { operCreds: opts.operCreds } : {}),
|
|
306
321
|
});
|
|
307
322
|
} catch (err) {
|
|
308
323
|
return Promise.reject(err);
|
|
@@ -332,6 +347,9 @@ export function startLocalServer(opts: StartServerOptions): Promise<LocalServer>
|
|
|
332
347
|
...(opts.saslAccounts !== undefined && opts.saslAccounts.length > 0
|
|
333
348
|
? { saslAccounts: opts.saslAccounts }
|
|
334
349
|
: {}),
|
|
350
|
+
...(opts.operCreds !== undefined && opts.operCreds.length > 0
|
|
351
|
+
? { operCreds: opts.operCreds }
|
|
352
|
+
: {}),
|
|
335
353
|
};
|
|
336
354
|
const hostname = opts.hostname ?? '127.0.0.1';
|
|
337
355
|
|
|
@@ -369,13 +387,15 @@ export function startLocalServer(opts: StartServerOptions): Promise<LocalServer>
|
|
|
369
387
|
// matches wall time.
|
|
370
388
|
const history = new InMemoryNickHistoryStore({ now: () => Date.now() });
|
|
371
389
|
|
|
372
|
-
//
|
|
373
|
-
//
|
|
374
|
-
//
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
390
|
+
// Services store: the single credential home for NickServ, SASL PLAIN,
|
|
391
|
+
// SASL EXTERNAL, and PASS-auth. Seeded from `saslAccounts` config so the
|
|
392
|
+
// listed accounts verify end-to-end via `services.verifyNick`. Always
|
|
393
|
+
// bound (never `undefined`) so NickServ REGISTER / IDENTIFY work
|
|
394
|
+
// out-of-the-box for local development.
|
|
395
|
+
const services = new InMemoryServicesStore({ clock: SystemClock });
|
|
396
|
+
for (const acct of cfg.saslAccounts ?? []) {
|
|
397
|
+
services.registerNick(acct.username, acct.password, '');
|
|
398
|
+
}
|
|
379
399
|
|
|
380
400
|
const wss = new WebSocketServer({
|
|
381
401
|
port: opts.port,
|
|
@@ -399,7 +419,7 @@ export function startLocalServer(opts: StartServerOptions): Promise<LocalServer>
|
|
|
399
419
|
// Admission gate: refuse the connection BEFORE we attach an actor / emit
|
|
400
420
|
// any IRC state. Keeps per-IP / per-user counters authoritative and
|
|
401
421
|
// avoids burning a nick or runtime slot on a denied client.
|
|
402
|
-
const ip = req.socket.remoteAddress
|
|
422
|
+
const ip = peerHost(req.socket.remoteAddress);
|
|
403
423
|
const decision = runtime.admitConnection(ip, undefined);
|
|
404
424
|
if (!decision.ok) {
|
|
405
425
|
ws.send(`ERROR :Closing link: (connection limit reached: ${decision.reason})\r\n`);
|
|
@@ -418,7 +438,7 @@ export function startLocalServer(opts: StartServerOptions): Promise<LocalServer>
|
|
|
418
438
|
wsModes.set(ws, subproto);
|
|
419
439
|
const frameMode = wsFrameModeFor(subproto ?? undefined);
|
|
420
440
|
|
|
421
|
-
const { state, actor } = attachConnection(runtime, cfg, messages,
|
|
441
|
+
const { state, actor } = attachConnection(runtime, cfg, messages, services, history, {
|
|
422
442
|
sendText: (text) => {
|
|
423
443
|
if (ws.readyState !== WebSocket.OPEN) return;
|
|
424
444
|
// binary.ircv3.net connections exchange binary WebSocket frames;
|
|
@@ -485,12 +505,15 @@ export function startLocalServer(opts: StartServerOptions): Promise<LocalServer>
|
|
|
485
505
|
const onWsListening = (): void => {
|
|
486
506
|
wss.removeListener('error', onWsError);
|
|
487
507
|
const addr = wss.address();
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
508
|
+
const wsPort = listenerPort(addr, opts.port);
|
|
509
|
+
|
|
510
|
+
// Capture the requested TCP port as a `const` so TS narrows it to
|
|
511
|
+
// `number` past the `undefined` early-return below (a property access
|
|
512
|
+
// like `opts.tcpPort` is not narrowed across the closure).
|
|
513
|
+
const requestedTcpPort = opts.tcpPort;
|
|
491
514
|
|
|
492
515
|
// If no TCP listener was requested, resolve immediately with just WS.
|
|
493
|
-
if (
|
|
516
|
+
if (requestedTcpPort === undefined) {
|
|
494
517
|
finishResolve({ wsPort, tcpPort: undefined, tcpServer: undefined });
|
|
495
518
|
return;
|
|
496
519
|
}
|
|
@@ -506,12 +529,13 @@ export function startLocalServer(opts: StartServerOptions): Promise<LocalServer>
|
|
|
506
529
|
|
|
507
530
|
// Admission gate — same policy as the WS path: refuse before the
|
|
508
531
|
// actor is attached so per-IP / per-user counters stay authoritative.
|
|
509
|
-
const ip = socket.remoteAddress
|
|
532
|
+
const ip = peerHost(socket.remoteAddress);
|
|
510
533
|
const decision = runtime.admitConnection(ip, undefined);
|
|
511
534
|
if (!decision.ok) {
|
|
512
|
-
|
|
513
|
-
socket
|
|
514
|
-
|
|
535
|
+
safeSocketWrite(
|
|
536
|
+
socket,
|
|
537
|
+
`ERROR :Closing link: (connection limit reached: ${decision.reason})\r\n`,
|
|
538
|
+
);
|
|
515
539
|
socket.destroy();
|
|
516
540
|
tcpSockets.delete(socket);
|
|
517
541
|
return;
|
|
@@ -519,9 +543,9 @@ export function startLocalServer(opts: StartServerOptions): Promise<LocalServer>
|
|
|
519
543
|
const admissionRecordId = decision.recordId;
|
|
520
544
|
runtime.commitAdmission(ip, undefined, admissionRecordId);
|
|
521
545
|
|
|
522
|
-
const { state, actor } = attachConnection(runtime, cfg, messages,
|
|
546
|
+
const { state, actor } = attachConnection(runtime, cfg, messages, services, history, {
|
|
523
547
|
sendText: (text) => {
|
|
524
|
-
|
|
548
|
+
safeSocketWrite(socket, text);
|
|
525
549
|
},
|
|
526
550
|
closeTransport: () => {
|
|
527
551
|
// `destroy()` over `end()` so a half-closed peer can't hold the
|
|
@@ -562,12 +586,10 @@ export function startLocalServer(opts: StartServerOptions): Promise<LocalServer>
|
|
|
562
586
|
});
|
|
563
587
|
tcpServer.once('listening', () => {
|
|
564
588
|
const tcpAddr = tcpServer.address();
|
|
565
|
-
|
|
566
|
-
const tcpPort =
|
|
567
|
-
typeof tcpAddr === 'object' && tcpAddr !== null ? tcpAddr.port : opts.tcpPort;
|
|
589
|
+
const tcpPort = listenerPort(tcpAddr, requestedTcpPort);
|
|
568
590
|
finishResolve({ wsPort, tcpPort, tcpServer, tcpConnections, tcpSockets });
|
|
569
591
|
});
|
|
570
|
-
tcpServer.listen(
|
|
592
|
+
tcpServer.listen(requestedTcpPort, hostname);
|
|
571
593
|
};
|
|
572
594
|
|
|
573
595
|
const finishResolve = (args: {
|
|
@@ -612,13 +634,18 @@ function attachConnection(
|
|
|
612
634
|
runtime: InMemoryRuntime,
|
|
613
635
|
cfg: ResolvedServerConfig,
|
|
614
636
|
messages: MessageStore,
|
|
615
|
-
|
|
637
|
+
services: ServicesStore,
|
|
616
638
|
history: NickHistoryStore,
|
|
617
639
|
transport: {
|
|
618
640
|
sendText: (text: string) => void;
|
|
619
641
|
closeTransport: () => void;
|
|
620
642
|
admissionRecordId?: string;
|
|
621
|
-
|
|
643
|
+
/**
|
|
644
|
+
* Transport-visible source host recorded on the connection state (cloak
|
|
645
|
+
* input / visible host). Both transports always supply the peer IP, so
|
|
646
|
+
* this is required rather than optional.
|
|
647
|
+
*/
|
|
648
|
+
sourceHost: string;
|
|
622
649
|
/**
|
|
623
650
|
* Negotiated IRCv3 WebSocket frame mode for this connection. Spec modes
|
|
624
651
|
* (`spec-text` / `spec-binary`) switch outbound delivery to one WS
|
|
@@ -639,9 +666,7 @@ function attachConnection(
|
|
|
639
666
|
const state = createConnection({ id, connectedSince: Date.now() });
|
|
640
667
|
// Record the transport-visible source host up front. Used as the cloak
|
|
641
668
|
// input when cloaking is enabled, and as the visible host otherwise.
|
|
642
|
-
|
|
643
|
-
state.host = transport.sourceHost;
|
|
644
|
-
}
|
|
669
|
+
state.host = transport.sourceHost;
|
|
645
670
|
|
|
646
671
|
// Per-connection logger: every record the actor emits
|
|
647
672
|
// carries this connection's id so the local-cli log stream can be
|
|
@@ -659,7 +684,7 @@ function attachConnection(
|
|
|
659
684
|
ids: DEFAULT_ID_FACTORY,
|
|
660
685
|
motd: { lines: () => cfg.motdLines },
|
|
661
686
|
messages,
|
|
662
|
-
...(
|
|
687
|
+
...(services !== undefined ? { services } : {}),
|
|
663
688
|
history,
|
|
664
689
|
logger,
|
|
665
690
|
...(transport.actorTransport !== undefined ? { transport: transport.actorTransport } : {}),
|
|
@@ -731,29 +756,71 @@ function closeEverything(args: {
|
|
|
731
756
|
// Tear down WS connections + server first.
|
|
732
757
|
const wsClosed = teardownWss(wss, wsConnections);
|
|
733
758
|
|
|
734
|
-
// Tear down TCP connections + server in parallel (if enabled).
|
|
759
|
+
// Tear down TCP connections + server in parallel (if enabled). Both are
|
|
760
|
+
// captured together (a TCP-disabled server has neither), so the guard is a
|
|
761
|
+
// single expression covering both the disabled and enabled cases.
|
|
735
762
|
const tcpClosed =
|
|
736
|
-
tcpServer === undefined
|
|
763
|
+
tcpServer === undefined || tcpConnections === undefined
|
|
737
764
|
? Promise.resolve()
|
|
738
765
|
: new Promise<void>((res) => {
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
// but hasn't yet been removed from `tcpConnections`. The cleanup
|
|
744
|
-
// handler always removes on 'close'/'error', so this branch is a
|
|
745
|
-
// no-op in steady state.
|
|
746
|
-
// c8 ignore next 1
|
|
747
|
-
if (!socket.destroyed) socket.destroy();
|
|
748
|
-
}
|
|
749
|
-
tcpConnections.clear();
|
|
766
|
+
for (const socket of tcpConnections.keys()) {
|
|
767
|
+
// Idempotent: a socket already torn down by a reducer-driven
|
|
768
|
+
// disconnect racing the close sweep is skipped.
|
|
769
|
+
safeDestroy(socket);
|
|
750
770
|
}
|
|
771
|
+
tcpConnections.clear();
|
|
751
772
|
tcpServer.close(() => res());
|
|
752
773
|
});
|
|
753
774
|
|
|
754
775
|
return Promise.all([wsClosed, tcpClosed]).then(() => undefined);
|
|
755
776
|
}
|
|
756
777
|
|
|
778
|
+
/**
|
|
779
|
+
* Resolves the visible peer IP for a freshly-accepted socket, falling back to
|
|
780
|
+
* `'unknown'` when the transport exposes no remote address (e.g. a socket
|
|
781
|
+
* already torn down at upgrade time). Extracted as a pure helper so the
|
|
782
|
+
* fallback arm is unit-testable in isolation — the live WS/TCP paths always
|
|
783
|
+
* observe a concrete `remoteAddress`, so the `'unknown'` arm is unreachable
|
|
784
|
+
* through the integration suite.
|
|
785
|
+
*/
|
|
786
|
+
export function peerHost(remoteAddress: string | undefined): string {
|
|
787
|
+
return remoteAddress ?? 'unknown';
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
/**
|
|
791
|
+
* Reads the bound port from a listener's `address()` result, falling back to
|
|
792
|
+
* the requested port for non-IP transports (named-pipe / unix-socket) which
|
|
793
|
+
* local-cli never binds but the `node:net` typing permits. Pure helper so the
|
|
794
|
+
* fallback arm is unit-testable; the live listeners always bind IP.
|
|
795
|
+
*/
|
|
796
|
+
export function listenerPort(addr: { port: number } | string | null, fallback: number): number {
|
|
797
|
+
return typeof addr === 'object' && addr !== null ? addr.port : fallback;
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
/**
|
|
801
|
+
* Writes `data` to `socket` only while it is still writable, silently
|
|
802
|
+
* skipping a destroyed / non-writable socket. Extracted so the non-writable
|
|
803
|
+
* arm is unit-testable with a fake — the live WS/TCP send paths always
|
|
804
|
+
* observe a writable transport, and the TCP admission-deny write races a
|
|
805
|
+
* socket that a peer RST may already have torn down.
|
|
806
|
+
*/
|
|
807
|
+
export function safeSocketWrite(
|
|
808
|
+
socket: { writable: boolean; write: (data: string) => void },
|
|
809
|
+
data: string,
|
|
810
|
+
): void {
|
|
811
|
+
if (socket.writable) socket.write(data);
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
/**
|
|
815
|
+
* Destroys `socket` unless it is already destroyed (idempotent teardown).
|
|
816
|
+
* Extracted so the already-destroyed arm is unit-testable with a fake — the
|
|
817
|
+
* race that produces an already-destroyed socket still present in the
|
|
818
|
+
* connection map is not deterministically reproducible in the suite.
|
|
819
|
+
*/
|
|
820
|
+
export function safeDestroy(socket: { destroyed: boolean; destroy(): void }): void {
|
|
821
|
+
if (!socket.destroyed) socket.destroy();
|
|
822
|
+
}
|
|
823
|
+
|
|
757
824
|
function logError(msg: string, err: unknown): void {
|
|
758
825
|
// Structured JSON per acceptance criteria.
|
|
759
826
|
const payload = { ts: new Date().toISOString(), level: 'error', msg, err: stringifyErr(err) };
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Coverage for `startLocalServer`'s config-resolution branches:
|
|
3
|
+
* - the conditional spreads that pass optional CLI knobs through to the
|
|
4
|
+
* shared schema (each knob's "defined" arm — exercised only when the
|
|
5
|
+
* caller explicitly supplies the value);
|
|
6
|
+
* - the admission-config `?? DEFAULT_*` fallbacks (exercised only when
|
|
7
|
+
* admission is enabled without the corresponding cap/rate).
|
|
8
|
+
*
|
|
9
|
+
* Starting a server (and closing it) is enough to drive the resolution — no
|
|
10
|
+
* client connection is required.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { describe, expect, it } from 'vitest';
|
|
14
|
+
import { startLocalServer } from '../src/server';
|
|
15
|
+
|
|
16
|
+
describe('startLocalServer — threads explicit config knobs to the schema', () => {
|
|
17
|
+
it('accepts every optional limit/knob override and starts cleanly', async () => {
|
|
18
|
+
const srv = await startLocalServer({
|
|
19
|
+
port: 0,
|
|
20
|
+
hostname: '127.0.0.1',
|
|
21
|
+
serverName: 'cfg.example.com',
|
|
22
|
+
networkName: 'CfgNet',
|
|
23
|
+
motdLines: ['explicit motd line'],
|
|
24
|
+
maxChannelsPerUser: 7,
|
|
25
|
+
maxTargetsPerCommand: 3,
|
|
26
|
+
maxListEntries: 11,
|
|
27
|
+
nickLen: 20,
|
|
28
|
+
channelLen: 40,
|
|
29
|
+
topicLen: 200,
|
|
30
|
+
quitMessage: 'explicit quit',
|
|
31
|
+
});
|
|
32
|
+
expect(srv.port).toBeGreaterThan(0);
|
|
33
|
+
await srv.close();
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe('startLocalServer — admission defaults', () => {
|
|
38
|
+
it('falls back to the per-IP / per-user defaults when only the rate limit is set', async () => {
|
|
39
|
+
// Admission enabled, but maxConnectionsPerIp / maxConnectionsPerUser are
|
|
40
|
+
// left undefined → the `?? DEFAULT_*` arms apply.
|
|
41
|
+
const srv = await startLocalServer({
|
|
42
|
+
port: 0,
|
|
43
|
+
hostname: '127.0.0.1',
|
|
44
|
+
serverName: 'rate.example.com',
|
|
45
|
+
networkName: 'RateNet',
|
|
46
|
+
perIpConnectionRate: { max: 5, windowMs: 60_000 },
|
|
47
|
+
});
|
|
48
|
+
expect(srv.port).toBeGreaterThan(0);
|
|
49
|
+
await srv.close();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('falls back to the per-IP cap and rate defaults when only per-user cap is set', async () => {
|
|
53
|
+
// Admission enabled, but maxConnectionsPerIp / perIpConnectionRate are
|
|
54
|
+
// left undefined → both `?? DEFAULT_*` arms apply.
|
|
55
|
+
const srv = await startLocalServer({
|
|
56
|
+
port: 0,
|
|
57
|
+
hostname: '127.0.0.1',
|
|
58
|
+
serverName: 'user.example.com',
|
|
59
|
+
networkName: 'UserNet',
|
|
60
|
+
maxConnectionsPerUser: 3,
|
|
61
|
+
});
|
|
62
|
+
expect(srv.port).toBeGreaterThan(0);
|
|
63
|
+
await srv.close();
|
|
64
|
+
});
|
|
65
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Isolated coverage for {@link loadMotdFile}'s non-Error throw wrap
|
|
3
|
+
* (motd-file.ts:41 — the `String(err)` branch). The rest of the motd-file
|
|
4
|
+
* suite exercises real `node:fs` I/O against a temp dir; this file
|
|
5
|
+
* `vi.mock`s `node:fs` so `readFileSync` rejects with a value that is NOT
|
|
6
|
+
* an `Error`, driving the `err instanceof Error ? ... : String(err)` ternary
|
|
7
|
+
* down its non-Error path. Lives in its own file so the mock never leaks
|
|
8
|
+
* into the real-I/O tests.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
12
|
+
|
|
13
|
+
vi.mock('node:fs', () => ({
|
|
14
|
+
// Throw a non-Error value so the catch wraps it via `String(err)`.
|
|
15
|
+
readFileSync: () => {
|
|
16
|
+
throw 'boom-non-error';
|
|
17
|
+
},
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
// Imported AFTER `vi.mock` is established (vitest hoists the mock).
|
|
21
|
+
import { loadMotdFile } from '../src/motd-file';
|
|
22
|
+
|
|
23
|
+
describe('loadMotdFile — wraps a non-Error throw via String()', () => {
|
|
24
|
+
it('rethrows a descriptive Error whose detail is String(<thrown value>)', () => {
|
|
25
|
+
expect(() => loadMotdFile('/irrelevant/motd.txt')).toThrowError(
|
|
26
|
+
/motd: cannot read --motd-file at .*: boom-non-error/u,
|
|
27
|
+
);
|
|
28
|
+
});
|
|
29
|
+
});
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* oper `REHASH` end-to-end through the local CLI.
|
|
3
|
+
*
|
|
4
|
+
* Drives the `configLoader` closure bound at server.ts:358 — the REHASH
|
|
5
|
+
* reload source. An oper's `REHASH` re-invokes that closure (via the runtime's
|
|
6
|
+
* `reloadConfig`) and swaps the live ServerConfig; the actor emits
|
|
7
|
+
* `382 RPL_REHASHING` on success. A non-oper receives `481 ERR_NOPRIVILEGES`
|
|
8
|
+
* and no reload is attempted.
|
|
9
|
+
*
|
|
10
|
+
* Oper credentials are threaded through the local CLI's `operCreds` option
|
|
11
|
+
* (the shared schema already accepted them; `startLocalServer` now wires them
|
|
12
|
+
* to the reducer-facing ServerConfig).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
16
|
+
import { WebSocket } from 'ws';
|
|
17
|
+
import { type LocalServer, startLocalServer } from '../src/server';
|
|
18
|
+
|
|
19
|
+
class IrcClient {
|
|
20
|
+
readonly ws: WebSocket;
|
|
21
|
+
readonly received: string[] = [];
|
|
22
|
+
|
|
23
|
+
constructor(url: string) {
|
|
24
|
+
this.ws = new WebSocket(url);
|
|
25
|
+
this.ws.on('message', (data) => {
|
|
26
|
+
const text = data.toString('utf8');
|
|
27
|
+
for (const line of text.split(/\r?\n/u)) {
|
|
28
|
+
if (line.length > 0) this.received.push(line);
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
opened(): Promise<void> {
|
|
34
|
+
return new Promise((resolve, reject) => {
|
|
35
|
+
if (this.ws.readyState === WebSocket.OPEN) resolve();
|
|
36
|
+
else {
|
|
37
|
+
this.ws.once('open', () => resolve());
|
|
38
|
+
this.ws.once('error', reject);
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
send(line: string): Promise<void> {
|
|
44
|
+
return new Promise((resolve, reject) => {
|
|
45
|
+
this.ws.send(`${line}\r\n`, (err) => (err ? reject(err) : resolve()));
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
waitFor(predicate: (line: string) => boolean, timeoutMs = 1_000): Promise<string> {
|
|
50
|
+
const check = (line: string): boolean => predicate(line);
|
|
51
|
+
return new Promise((resolve, reject) => {
|
|
52
|
+
const timeout = setTimeout(
|
|
53
|
+
() => reject(new Error(`timeout; received: ${JSON.stringify(this.received)}`)),
|
|
54
|
+
timeoutMs,
|
|
55
|
+
);
|
|
56
|
+
const onLine = (line: string): void => {
|
|
57
|
+
if (check(line)) {
|
|
58
|
+
clearTimeout(timeout);
|
|
59
|
+
this.ws.off('message', onMessage);
|
|
60
|
+
resolve(line);
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
const onMessage = (data: { toString: (enc: string) => string }): void => {
|
|
64
|
+
for (const line of data.toString('utf8').split(/\r?\n/u)) {
|
|
65
|
+
if (line.length > 0) onLine(line);
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
for (const line of this.received) {
|
|
69
|
+
if (check(line)) {
|
|
70
|
+
clearTimeout(timeout);
|
|
71
|
+
resolve(line);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
this.ws.on('message', onMessage);
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
close(): Promise<void> {
|
|
80
|
+
return new Promise((resolve) => {
|
|
81
|
+
this.ws.once('close', () => resolve());
|
|
82
|
+
this.ws.close();
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
describe('local-cli e2e — oper REHASH re-invokes the bound configLoader', () => {
|
|
88
|
+
let server: LocalServer;
|
|
89
|
+
|
|
90
|
+
beforeAll(async () => {
|
|
91
|
+
server = await startLocalServer({
|
|
92
|
+
port: 0,
|
|
93
|
+
hostname: '127.0.0.1',
|
|
94
|
+
serverName: 'rehash.example.com',
|
|
95
|
+
networkName: 'RehashNet',
|
|
96
|
+
operCreds: [{ user: 'admin', password: 's3cret' }],
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
afterAll(async () => {
|
|
101
|
+
await server.close();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('grants oper then reloads config on REHASH (382 RPL_REHASHING, no error)', async () => {
|
|
105
|
+
const client = new IrcClient(`ws://127.0.0.1:${server.port}/`);
|
|
106
|
+
await client.opened();
|
|
107
|
+
await client.send('NICK oper1');
|
|
108
|
+
await client.send('USER oper1 0 * :Oper');
|
|
109
|
+
await client.waitFor((l) => l.startsWith(':rehash.example.com 001 '));
|
|
110
|
+
|
|
111
|
+
// Oper up so the REHASH oper gate passes.
|
|
112
|
+
await client.send('OPER admin s3cret');
|
|
113
|
+
const operUp = await client.waitFor((l) => l.includes(' 381 '));
|
|
114
|
+
expect(operUp).toContain('You are now an IRC operator');
|
|
115
|
+
|
|
116
|
+
// REHASH — the actor re-invokes the runtime's reloadConfig, which calls
|
|
117
|
+
// the configLoader closure (server.ts:358). A clean `382` (no `failed`)
|
|
118
|
+
// proves the reload succeeded.
|
|
119
|
+
await client.send('REHASH');
|
|
120
|
+
const rehashLine = await client.waitFor((l) => l.includes(' 382 '), 2_000);
|
|
121
|
+
expect(rehashLine).toContain('Rehashing from');
|
|
122
|
+
expect(rehashLine).not.toContain('failed');
|
|
123
|
+
|
|
124
|
+
await client.close();
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('rejects REHASH from a non-oper with 481 ERR_NOPRIVILEGES (no reload)', async () => {
|
|
128
|
+
const client = new IrcClient(`ws://127.0.0.1:${server.port}/`);
|
|
129
|
+
await client.opened();
|
|
130
|
+
await client.send('NICK peon');
|
|
131
|
+
await client.send('USER peon 0 * :Peon');
|
|
132
|
+
await client.waitFor((l) => l.startsWith(':rehash.example.com 001 '));
|
|
133
|
+
|
|
134
|
+
await client.send('REHASH');
|
|
135
|
+
const denied = await client.waitFor((l) => l.includes(' 481 '));
|
|
136
|
+
expect(denied).toContain('Permission Denied');
|
|
137
|
+
await client.close();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('directly exercises the configLoader closure via runtime.reloadConfig()', async () => {
|
|
141
|
+
// Independent of the actor path: the closure bound at server.ts:358 must
|
|
142
|
+
// return the live ServerConfig the actor was bootstrapped with.
|
|
143
|
+
const reloaded = await server.runtime.reloadConfig();
|
|
144
|
+
expect(reloaded.serverName).toBe('rehash.example.com');
|
|
145
|
+
expect(reloaded.networkName).toBe('RehashNet');
|
|
146
|
+
});
|
|
147
|
+
});
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the small transport/adapter helpers extracted from
|
|
3
|
+
* `server.ts` as test seams. These wrap the genuinely-unreachable defensive
|
|
4
|
+
* branches (peer-IP fallback, listener-port fallback, write-to-unwritable,
|
|
5
|
+
* destroy-already-destroyed) so each branch is deterministically exercisable
|
|
6
|
+
* with a fake — the live WS/TCP integration paths only ever observe the
|
|
7
|
+
* non-defensive arm.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
11
|
+
import { listenerPort, peerHost, safeDestroy, safeSocketWrite } from '../src/server';
|
|
12
|
+
|
|
13
|
+
describe('peerHost', () => {
|
|
14
|
+
it('returns the remote address when present', () => {
|
|
15
|
+
expect(peerHost('203.0.113.7')).toBe('203.0.113.7');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("falls back to 'unknown' when no remote address is exposed", () => {
|
|
19
|
+
expect(peerHost(undefined)).toBe('unknown');
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe('listenerPort', () => {
|
|
24
|
+
it('reads the port from an AddressInfo-shaped object', () => {
|
|
25
|
+
expect(listenerPort({ port: 6667 }, 0)).toBe(6667);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('falls back when the address is a non-IP transport (string)', () => {
|
|
29
|
+
expect(listenerPort('/run/irc.sock', 7000)).toBe(7000);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('falls back when the address is null', () => {
|
|
33
|
+
expect(listenerPort(null, 7000)).toBe(7000);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe('safeSocketWrite', () => {
|
|
38
|
+
it('writes when the socket is writable', () => {
|
|
39
|
+
const write = vi.fn();
|
|
40
|
+
safeSocketWrite({ writable: true, write }, 'PONG :tok');
|
|
41
|
+
expect(write).toHaveBeenCalledWith('PONG :tok');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('is a silent no-op when the socket is no longer writable', () => {
|
|
45
|
+
const write = vi.fn();
|
|
46
|
+
safeSocketWrite({ writable: false, write }, 'PONG :tok');
|
|
47
|
+
expect(write).not.toHaveBeenCalled();
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe('safeDestroy', () => {
|
|
52
|
+
it('destroys a live socket', () => {
|
|
53
|
+
const destroy = vi.fn();
|
|
54
|
+
safeDestroy({ destroyed: false, destroy });
|
|
55
|
+
expect(destroy).toHaveBeenCalledTimes(1);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('is idempotent — skips a socket that is already destroyed', () => {
|
|
59
|
+
const destroy = vi.fn();
|
|
60
|
+
safeDestroy({ destroyed: true, destroy });
|
|
61
|
+
expect(destroy).not.toHaveBeenCalled();
|
|
62
|
+
});
|
|
63
|
+
});
|