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
|
@@ -1,7 +1,8 @@
|
|
|
1
|
+
import { EventEmitter } from 'node:events';
|
|
1
2
|
import { type LocalServer, startLocalServer } from '@serverless-ircd/local-cli/dist/server.js';
|
|
2
|
-
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
|
|
3
|
+
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
|
3
4
|
import { WebSocketServer } from 'ws';
|
|
4
|
-
import { IrcLoadClient } from '../src/client';
|
|
5
|
+
import { IrcLoadClient, type WebSocketCtor } from '../src/client';
|
|
5
6
|
import { resolveConfig } from '../src/config';
|
|
6
7
|
|
|
7
8
|
/**
|
|
@@ -210,3 +211,258 @@ function startDroppingWsServer(): Promise<{ port: number; close: () => Promise<v
|
|
|
210
211
|
wss.once('error', onError);
|
|
211
212
|
});
|
|
212
213
|
}
|
|
214
|
+
|
|
215
|
+
// ---------------------------------------------------------------------------
|
|
216
|
+
// Failure-path coverage via an injectable fake WebSocket.
|
|
217
|
+
//
|
|
218
|
+
// The client accepts a `WebSocketCtor` dependency (client.ts) so the lifecycle
|
|
219
|
+
// can be driven against a scriptable fake. The four tests below exercise the
|
|
220
|
+
// branches that are impractical to reach through a real socket: connect
|
|
221
|
+
// timeout (socket never opens), join timeout, privmsg timeout, and a socket
|
|
222
|
+
// `error` event fanning out to pending waiters mid-wait.
|
|
223
|
+
// ---------------------------------------------------------------------------
|
|
224
|
+
|
|
225
|
+
/** A reply the fake fires when the client sends a matching line. */
|
|
226
|
+
type FakeHandler = {
|
|
227
|
+
match: RegExp;
|
|
228
|
+
action: { kind: 'messages'; lines: string[] } | { kind: 'error'; error: unknown };
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
/** Module-level fake controller state (reset per test in beforeEach). */
|
|
232
|
+
let fakeAutoOpen = true;
|
|
233
|
+
let fakeHandlers: FakeHandler[] = [];
|
|
234
|
+
let fakeReadyState: number | undefined;
|
|
235
|
+
let fakeErrorOnClose = false;
|
|
236
|
+
let lastFake: FakeSocket | undefined;
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Minimal WebSocket stand-in. Emits `open` on the next microtask (so the
|
|
240
|
+
* client's `once('open')` listener is attached first), and on `send()` fires
|
|
241
|
+
* any scripted reply for the sent line. `emit('error', ...)` is used to drive
|
|
242
|
+
* the socket-error path.
|
|
243
|
+
*/
|
|
244
|
+
class FakeSocket extends EventEmitter {
|
|
245
|
+
readonly OPEN = 1;
|
|
246
|
+
readyState: number;
|
|
247
|
+
readonly sent: string[] = [];
|
|
248
|
+
|
|
249
|
+
constructor(_url: string, _protocols: string[]) {
|
|
250
|
+
super();
|
|
251
|
+
lastFake = this;
|
|
252
|
+
this.readyState = fakeReadyState ?? 1;
|
|
253
|
+
if (fakeAutoOpen) {
|
|
254
|
+
queueMicrotask(() => this.emit('open'));
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
send(line: string): void {
|
|
259
|
+
this.sent.push(line);
|
|
260
|
+
for (const h of fakeHandlers) {
|
|
261
|
+
if (h.match.test(line)) {
|
|
262
|
+
const action = h.action;
|
|
263
|
+
queueMicrotask(() => {
|
|
264
|
+
if (action.kind === 'messages') {
|
|
265
|
+
for (const text of action.lines) {
|
|
266
|
+
this.emit('message', Buffer.from(text, 'utf8'));
|
|
267
|
+
}
|
|
268
|
+
} else {
|
|
269
|
+
this.emit('error', action.error);
|
|
270
|
+
}
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
close(): void {
|
|
277
|
+
this.readyState = 3;
|
|
278
|
+
if (fakeErrorOnClose) {
|
|
279
|
+
this.emit('error', new Error('emitted on close'));
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* A WebSocketCtor whose constructor throws synchronously — drives the
|
|
286
|
+
* `this.ws === undefined` path in teardown.
|
|
287
|
+
*/
|
|
288
|
+
class ThrowingSocketCtor extends EventEmitter {
|
|
289
|
+
constructor(_url: string, _protocols: string[]) {
|
|
290
|
+
super();
|
|
291
|
+
throw new Error('constructor blew up');
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
describe('IrcLoadClient — failure paths (fake WebSocket)', () => {
|
|
296
|
+
beforeEach(() => {
|
|
297
|
+
fakeAutoOpen = true;
|
|
298
|
+
fakeHandlers = [];
|
|
299
|
+
fakeReadyState = undefined;
|
|
300
|
+
fakeErrorOnClose = false;
|
|
301
|
+
lastFake = undefined;
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
/** Resolves a config aimed at the fake, with short timeouts. */
|
|
305
|
+
function fakeCfg(
|
|
306
|
+
overrides: Parameters<typeof resolveConfig>[0] = {},
|
|
307
|
+
): ReturnType<typeof resolveConfig> {
|
|
308
|
+
return resolveConfig({
|
|
309
|
+
target: 'ws://fake.local/',
|
|
310
|
+
channel: '#test',
|
|
311
|
+
nickPrefix: 'bot',
|
|
312
|
+
messagesPerClient: 0,
|
|
313
|
+
connectTimeoutMs: 1000,
|
|
314
|
+
registerTimeoutMs: 100,
|
|
315
|
+
...overrides,
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
it('fails at connect when the socket never opens (connect timeout)', async () => {
|
|
320
|
+
fakeAutoOpen = false;
|
|
321
|
+
const client = new IrcLoadClient({
|
|
322
|
+
id: 0,
|
|
323
|
+
config: fakeCfg({ connectTimeoutMs: 50 }),
|
|
324
|
+
WebSocketCtor: FakeSocket as unknown as WebSocketCtor,
|
|
325
|
+
});
|
|
326
|
+
const result = await client.run();
|
|
327
|
+
expect(result.ok).toBe(false);
|
|
328
|
+
expect(result.failedPhase).toBe('connect');
|
|
329
|
+
expect(result.error).toMatch(/connect timeout/i);
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it('fails at join when the server never replies with 366', async () => {
|
|
333
|
+
// Registration completes (001 sent in reply to NICK); JOIN gets no 366
|
|
334
|
+
// so the join waiter times out. The reply bundles a blank line and a
|
|
335
|
+
// non-prefixed PING line alongside the 001 to also cover the empty-line
|
|
336
|
+
// skip (onMessage) and the non-':' prefix arm (numericOf).
|
|
337
|
+
fakeHandlers = [
|
|
338
|
+
{
|
|
339
|
+
match: /^NICK /u,
|
|
340
|
+
action: {
|
|
341
|
+
kind: 'messages',
|
|
342
|
+
lines: ['\r\nPING :keepalive\r\n:srv 001 bot0 :Welcome'],
|
|
343
|
+
},
|
|
344
|
+
},
|
|
345
|
+
];
|
|
346
|
+
const client = new IrcLoadClient({
|
|
347
|
+
id: 0,
|
|
348
|
+
config: fakeCfg({ echoMessage: false }),
|
|
349
|
+
WebSocketCtor: FakeSocket as unknown as WebSocketCtor,
|
|
350
|
+
});
|
|
351
|
+
const result = await client.run();
|
|
352
|
+
expect(result.ok).toBe(false);
|
|
353
|
+
expect(result.failedPhase).toBe('join');
|
|
354
|
+
expect(result.samples.register.length).toBe(1);
|
|
355
|
+
expect(lastFake?.sent.some((l) => l.startsWith('JOIN'))).toBe(true);
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
it('fails at privmsg when the echo never arrives', async () => {
|
|
359
|
+
// Registration + join succeed; the PRIVMSG echo never arrives so the
|
|
360
|
+
// privmsg waiter times out.
|
|
361
|
+
fakeHandlers = [
|
|
362
|
+
{
|
|
363
|
+
match: /^NICK /u,
|
|
364
|
+
action: { kind: 'messages', lines: [':srv 001 bot0 :Welcome'] },
|
|
365
|
+
},
|
|
366
|
+
{
|
|
367
|
+
match: /^JOIN /u,
|
|
368
|
+
action: { kind: 'messages', lines: [':srv 366 bot0 #test :End of NAMES'] },
|
|
369
|
+
},
|
|
370
|
+
];
|
|
371
|
+
const client = new IrcLoadClient({
|
|
372
|
+
id: 0,
|
|
373
|
+
config: fakeCfg({ messagesPerClient: 1, echoMessage: false }),
|
|
374
|
+
WebSocketCtor: FakeSocket as unknown as WebSocketCtor,
|
|
375
|
+
});
|
|
376
|
+
const result = await client.run();
|
|
377
|
+
expect(result.ok).toBe(false);
|
|
378
|
+
expect(result.failedPhase).toBe('privmsg');
|
|
379
|
+
expect(result.samples.register.length).toBe(1);
|
|
380
|
+
expect(result.samples.join.length).toBe(1);
|
|
381
|
+
expect(lastFake?.sent.some((l) => l.startsWith('PRIVMSG'))).toBe(true);
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
it('fails via onError when the socket errors during a pending wait', async () => {
|
|
385
|
+
// The socket errors mid-registration: the `on('error')` handler fans the
|
|
386
|
+
// error out to the pending register waiter via failWaiters.
|
|
387
|
+
fakeHandlers = [
|
|
388
|
+
{
|
|
389
|
+
match: /^NICK /u,
|
|
390
|
+
action: { kind: 'error', error: new Error('socket boom') },
|
|
391
|
+
},
|
|
392
|
+
];
|
|
393
|
+
const client = new IrcLoadClient({
|
|
394
|
+
id: 0,
|
|
395
|
+
config: fakeCfg({ echoMessage: false }),
|
|
396
|
+
WebSocketCtor: FakeSocket as unknown as WebSocketCtor,
|
|
397
|
+
});
|
|
398
|
+
const result = await client.run();
|
|
399
|
+
expect(result.ok).toBe(false);
|
|
400
|
+
expect(result.error).toBe('socket boom');
|
|
401
|
+
// connect succeeded before the error fired.
|
|
402
|
+
expect(result.samples.connect.length).toBe(1);
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
it('coerces a non-Error rejection to a string error message', async () => {
|
|
406
|
+
// Some transports emit non-Error values on 'error'. The client's failedAt
|
|
407
|
+
// helper must fall back to String(err) rather than crash.
|
|
408
|
+
fakeHandlers = [
|
|
409
|
+
{
|
|
410
|
+
match: /^NICK /u,
|
|
411
|
+
action: { kind: 'error', error: 'stringy failure' },
|
|
412
|
+
},
|
|
413
|
+
];
|
|
414
|
+
const client = new IrcLoadClient({
|
|
415
|
+
id: 0,
|
|
416
|
+
config: fakeCfg({ echoMessage: false }),
|
|
417
|
+
WebSocketCtor: FakeSocket as unknown as WebSocketCtor,
|
|
418
|
+
});
|
|
419
|
+
const result = await client.run();
|
|
420
|
+
expect(result.ok).toBe(false);
|
|
421
|
+
expect(result.error).toBe('stringy failure');
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
it('skips send() when the socket is not in the OPEN state', async () => {
|
|
425
|
+
// The fake reports CLOSED even though it emits 'open', so the send()
|
|
426
|
+
// readyState guard is exercised and no lines reach the wire.
|
|
427
|
+
fakeReadyState = 3;
|
|
428
|
+
const client = new IrcLoadClient({
|
|
429
|
+
id: 0,
|
|
430
|
+
config: fakeCfg({ echoMessage: false }),
|
|
431
|
+
WebSocketCtor: FakeSocket as unknown as WebSocketCtor,
|
|
432
|
+
});
|
|
433
|
+
const result = await client.run();
|
|
434
|
+
expect(result.ok).toBe(false);
|
|
435
|
+
expect(result.failedPhase).toBe('register');
|
|
436
|
+
expect(lastFake?.sent.length).toBe(0);
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
it('ignores a socket error that arrives after teardown', async () => {
|
|
440
|
+
// The fake emits 'error' from close(); by the time teardown calls close()
|
|
441
|
+
// the client is already settled, so onError's guard short-circuits.
|
|
442
|
+
fakeErrorOnClose = true;
|
|
443
|
+
const client = new IrcLoadClient({
|
|
444
|
+
id: 0,
|
|
445
|
+
config: fakeCfg({ echoMessage: false }),
|
|
446
|
+
WebSocketCtor: FakeSocket as unknown as WebSocketCtor,
|
|
447
|
+
});
|
|
448
|
+
// register will time out (no 001), then teardown → close() → error is
|
|
449
|
+
// swallowed by the settled guard.
|
|
450
|
+
const result = await client.run();
|
|
451
|
+
expect(result.ok).toBe(false);
|
|
452
|
+
expect(result.failedPhase).toBe('register');
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
it('handles a WebSocket constructor that throws synchronously', async () => {
|
|
456
|
+
// The constructor blowing up means this.ws is never assigned; teardown
|
|
457
|
+
// must skip the close path without crashing.
|
|
458
|
+
const client = new IrcLoadClient({
|
|
459
|
+
id: 0,
|
|
460
|
+
config: fakeCfg({ echoMessage: false }),
|
|
461
|
+
WebSocketCtor: ThrowingSocketCtor as unknown as WebSocketCtor,
|
|
462
|
+
});
|
|
463
|
+
const result = await client.run();
|
|
464
|
+
expect(result.ok).toBe(false);
|
|
465
|
+
expect(result.failedPhase).toBe('connect');
|
|
466
|
+
expect(result.error).toBe('constructor blew up');
|
|
467
|
+
});
|
|
468
|
+
});
|
|
@@ -69,6 +69,35 @@ describe('resolveConfig — validation (fail-fast)', () => {
|
|
|
69
69
|
it('throws when the channel does not start with #', () => {
|
|
70
70
|
expect(() => resolveConfig({ target: 'ws://x/', channel: 'loadtest' })).toThrow(/channel/i);
|
|
71
71
|
});
|
|
72
|
+
|
|
73
|
+
it('throws when connectTimeoutMs is non-positive', () => {
|
|
74
|
+
expect(() => resolveConfig({ target: 'ws://x/', connectTimeoutMs: 0 })).toThrow(
|
|
75
|
+
/connect timeout/i,
|
|
76
|
+
);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('throws when registerTimeoutMs is non-positive', () => {
|
|
80
|
+
expect(() => resolveConfig({ target: 'ws://x/', registerTimeoutMs: -1 })).toThrow(
|
|
81
|
+
/register timeout/i,
|
|
82
|
+
);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('throws when rampMs is negative', () => {
|
|
86
|
+
expect(() => resolveConfig({ target: 'ws://x/', rampMs: -5 })).toThrow(/ramp-ms/i);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('throws when messageIntervalMs is negative', () => {
|
|
90
|
+
expect(() => resolveConfig({ target: 'ws://x/', messageIntervalMs: -1 })).toThrow(
|
|
91
|
+
/message interval/i,
|
|
92
|
+
);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe('resolveConfig — reportPath propagation', () => {
|
|
97
|
+
it('sets reportPath when provided', () => {
|
|
98
|
+
const cfg = resolveConfig({ target: 'ws://x/', reportPath: './out/report.md' });
|
|
99
|
+
expect(cfg.reportPath).toBe('./out/report.md');
|
|
100
|
+
});
|
|
72
101
|
});
|
|
73
102
|
|
|
74
103
|
describe('parseArgs — CLI flag parsing', () => {
|
|
@@ -107,6 +136,16 @@ describe('parseArgs — CLI flag parsing', () => {
|
|
|
107
136
|
expect(() => parseArgs(['--target', 'ws://x/', '--bogus', '1'])).toThrow(/unknown argument/i);
|
|
108
137
|
});
|
|
109
138
|
|
|
139
|
+
it('skips sparse (hole) entries in the argv array', () => {
|
|
140
|
+
// A hole at index 0 exercises the `a === undefined` guard in the parse
|
|
141
|
+
// loop; the remaining flags still resolve to a valid config.
|
|
142
|
+
const sparse: string[] = [];
|
|
143
|
+
sparse[1] = '--target';
|
|
144
|
+
sparse[2] = 'ws://x/';
|
|
145
|
+
const cfg = parseArgs(sparse);
|
|
146
|
+
expect(cfg.target).toBe('ws://x/');
|
|
147
|
+
});
|
|
148
|
+
|
|
110
149
|
it('throws when --target is absent and no positional is given', () => {
|
|
111
150
|
expect(() => parseArgs([])).toThrow(/target/i);
|
|
112
151
|
});
|
|
@@ -81,6 +81,27 @@ describe('runLoadTest — aggregation', () => {
|
|
|
81
81
|
const md = generateReport(run);
|
|
82
82
|
expect(md).toContain('Latency');
|
|
83
83
|
});
|
|
84
|
+
|
|
85
|
+
it('invokes onProgress as each client completes', async () => {
|
|
86
|
+
const calls: Array<{ done: number; total: number }> = [];
|
|
87
|
+
const factory = () => ({
|
|
88
|
+
run: async () =>
|
|
89
|
+
({
|
|
90
|
+
id: 0,
|
|
91
|
+
ok: true,
|
|
92
|
+
samples: { connect: [1], register: [], join: [], privmsg: [] },
|
|
93
|
+
}) as ClientResult,
|
|
94
|
+
});
|
|
95
|
+
const run = await runLoadTest(cfg({ connections: 4, connectConcurrency: 2 }), {
|
|
96
|
+
clientFactory: factory,
|
|
97
|
+
onProgress: (done, total) => calls.push({ done, total }),
|
|
98
|
+
});
|
|
99
|
+
expect(run.summary.totalClients).toBe(4);
|
|
100
|
+
// Monotonic done counter, one call per client, total stays constant.
|
|
101
|
+
expect(calls.length).toBe(4);
|
|
102
|
+
expect(calls.every((c) => c.total === 4)).toBe(true);
|
|
103
|
+
expect(calls.map((c) => c.done)).toEqual([1, 2, 3, 4]);
|
|
104
|
+
});
|
|
84
105
|
});
|
|
85
106
|
|
|
86
107
|
describe('runLoadTest — concurrency cap', () => {
|
|
@@ -59,6 +59,13 @@ describe('percentile (nearest-rank)', () => {
|
|
|
59
59
|
it('throws for a percentile above 100 (programming error)', () => {
|
|
60
60
|
expect(() => percentile([1, 2, 3], 101)).toThrow(/percentile/i);
|
|
61
61
|
});
|
|
62
|
+
|
|
63
|
+
it('returns 0 for a NaN percentile (slips the range guard, hits the defensive fallback)', () => {
|
|
64
|
+
// NaN < 0 and NaN > 100 are both false, so NaN passes the range check.
|
|
65
|
+
// It then makes the index NaN, so sorted[NaN] is undefined and the
|
|
66
|
+
// `?? 0` fallback returns 0 rather than NaN.
|
|
67
|
+
expect(percentile([1, 2, 3], Number.NaN)).toBe(0);
|
|
68
|
+
});
|
|
62
69
|
});
|
|
63
70
|
|
|
64
71
|
describe('summarizePhase', () => {
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Migration script: backfills rows from the legacy `accounts` / `Accounts`
|
|
4
|
+
* table into the unified `nickserv_accounts` / `Services` table BEFORE the
|
|
5
|
+
* build that drops the legacy table is deployed.
|
|
6
|
+
*
|
|
7
|
+
* The migration is:
|
|
8
|
+
* - **One-shot**: run once per environment (staging then prod), then the
|
|
9
|
+
* new build lands.
|
|
10
|
+
* - **Idempotent**: re-running is a no-op — `INSERT OR IGNORE` (D1) /
|
|
11
|
+
* conditional PutItem (DynamoDB) never clobbers an existing services
|
|
12
|
+
* row.
|
|
13
|
+
* - **Never-overwrite**: a nick already registered in the services table
|
|
14
|
+
* (via NickServ REGISTER or a prior seed) wins; the legacy row's
|
|
15
|
+
* credential is NOT copied over it.
|
|
16
|
+
* - **Cert-subject carry-through**: the legacy `accounts` / `Accounts`
|
|
17
|
+
* table has no `cert_subjects` column. The script reads the still-
|
|
18
|
+
* deployed `SASL_ACCOUNTS` env var at run time; any account declared
|
|
19
|
+
* there is registered (if not already present) so the env-seed
|
|
20
|
+
* survives the table drop. The env-var format (`username:password`)
|
|
21
|
+
* does NOT carry cert subjects, so `cert_subjects` defaults to `'[]'`
|
|
22
|
+
* for every migrated row — matching today's effective behavior (SASL
|
|
23
|
+
* EXTERNAL only worked against the static `InMemoryAccountStore`, never
|
|
24
|
+
* against the hashed `accounts`/`Accounts` table).
|
|
25
|
+
*
|
|
26
|
+
* Usage (Cloudflare D1):
|
|
27
|
+
* node --import tsx tools/migrate-accounts-to-services.ts \
|
|
28
|
+
* --platform cf \
|
|
29
|
+
* --database serverless-ircd-accounts \
|
|
30
|
+
* --env staging \
|
|
31
|
+
* --remote
|
|
32
|
+
*
|
|
33
|
+
* Usage (AWS DynamoDB):
|
|
34
|
+
* node --import tsx tools/migrate-accounts-to-services.ts \
|
|
35
|
+
* --platform aws \
|
|
36
|
+
* --accounts-table StagingAccounts \
|
|
37
|
+
* --services-table StagingServices \
|
|
38
|
+
* --region us-east-1
|
|
39
|
+
*
|
|
40
|
+
* The `SASL_ACCOUNTS` env var is read at run time in both modes so any
|
|
41
|
+
* operator-declared seed accounts are carried into the services table.
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
// ============================================================================
|
|
45
|
+
// Pure helpers (re-exported from irc-core for testability)
|
|
46
|
+
// ============================================================================
|
|
47
|
+
|
|
48
|
+
export {
|
|
49
|
+
type LegacyAccountRow,
|
|
50
|
+
type ParsedSaslAccount,
|
|
51
|
+
buildMigrationSql,
|
|
52
|
+
buildServicesPutItem,
|
|
53
|
+
parseSaslAccountsForMigration,
|
|
54
|
+
} from '@serverless-ircd/irc-core';
|
|
55
|
+
import { caseFold } from '@serverless-ircd/irc-core';
|
|
56
|
+
|
|
57
|
+
// ============================================================================
|
|
58
|
+
// CLI entry point (only runs when executed directly via `node --import tsx`)
|
|
59
|
+
// ============================================================================
|
|
60
|
+
|
|
61
|
+
async function main(): Promise<void> {
|
|
62
|
+
const { execFileSync } = await import('node:child_process');
|
|
63
|
+
const { parseArgs } = await import('node:util');
|
|
64
|
+
const { values } = parseArgs({
|
|
65
|
+
options: {
|
|
66
|
+
platform: { type: 'string' },
|
|
67
|
+
database: { type: 'string', default: undefined },
|
|
68
|
+
env: { type: 'string', default: undefined },
|
|
69
|
+
remote: { type: 'boolean', default: false },
|
|
70
|
+
'accounts-table': { type: 'string', default: undefined },
|
|
71
|
+
'services-table': { type: 'string', default: undefined },
|
|
72
|
+
region: { type: 'string', default: undefined },
|
|
73
|
+
endpoint: { type: 'string', default: undefined },
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const SASL_ACCOUNTS_RAW = process.env.SASL_ACCOUNTS;
|
|
78
|
+
|
|
79
|
+
if (values.platform === 'cf') {
|
|
80
|
+
await runCfMigration(values, SASL_ACCOUNTS_RAW, execFileSync);
|
|
81
|
+
} else if (values.platform === 'aws') {
|
|
82
|
+
await runAwsMigration(values, SASL_ACCOUNTS_RAW);
|
|
83
|
+
} else {
|
|
84
|
+
console.error(
|
|
85
|
+
'Usage: migrate-accounts-to-services --platform cf|aws [platform-specific flags]',
|
|
86
|
+
);
|
|
87
|
+
console.error(' cf: --database <d1-name> [--env staging] [--remote]');
|
|
88
|
+
console.error(
|
|
89
|
+
' aws: --accounts-table <name> --services-table <name> [--region <r>] [--endpoint <url>]',
|
|
90
|
+
);
|
|
91
|
+
process.exit(1);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
type CfValues = {
|
|
96
|
+
database?: string;
|
|
97
|
+
env?: string;
|
|
98
|
+
remote?: boolean;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
async function runCfMigration(
|
|
102
|
+
values: CfValues,
|
|
103
|
+
saslAccountsRaw: string | undefined,
|
|
104
|
+
execFileSync: typeof import('node:child_process').execFileSync,
|
|
105
|
+
): Promise<void> {
|
|
106
|
+
if (values.database === undefined) {
|
|
107
|
+
console.error('CF migration requires --database <d1-name>');
|
|
108
|
+
process.exit(1);
|
|
109
|
+
}
|
|
110
|
+
const selectArgs = [
|
|
111
|
+
'd1',
|
|
112
|
+
'execute',
|
|
113
|
+
values.database,
|
|
114
|
+
'--command',
|
|
115
|
+
'SELECT account, algorithm, salt, hash FROM accounts',
|
|
116
|
+
'--json',
|
|
117
|
+
];
|
|
118
|
+
if (values.env !== undefined) selectArgs.push('--env', values.env);
|
|
119
|
+
if (values.remote) selectArgs.push('--remote');
|
|
120
|
+
|
|
121
|
+
let legacyRows: LegacyAccountRow[] = [];
|
|
122
|
+
try {
|
|
123
|
+
const output = execFileSync('npx', ['wrangler', ...selectArgs], {
|
|
124
|
+
encoding: 'utf-8',
|
|
125
|
+
stdio: ['pipe', 'pipe', 'inherit'],
|
|
126
|
+
});
|
|
127
|
+
const parsed = JSON.parse(output) as Array<{ results?: Array<Record<string, unknown>> }>;
|
|
128
|
+
legacyRows = parsed
|
|
129
|
+
.flatMap((r) => r.results ?? [])
|
|
130
|
+
.filter(
|
|
131
|
+
(r): r is Record<string, string> =>
|
|
132
|
+
typeof r.account === 'string' &&
|
|
133
|
+
typeof r.algorithm === 'string' &&
|
|
134
|
+
typeof r.salt === 'string' &&
|
|
135
|
+
typeof r.hash === 'string',
|
|
136
|
+
)
|
|
137
|
+
.map((r) => ({
|
|
138
|
+
account: r.account,
|
|
139
|
+
algorithm: r.algorithm,
|
|
140
|
+
salt: r.salt,
|
|
141
|
+
hash: r.hash,
|
|
142
|
+
}));
|
|
143
|
+
} catch (err) {
|
|
144
|
+
console.error(`Could not read legacy accounts table: ${String(err)}`);
|
|
145
|
+
console.error('Continuing with SASL_ACCOUNTS env-seed carry-through only.');
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const statements = buildMigrationSql(legacyRows, saslAccountsRaw);
|
|
149
|
+
for (const sql of statements) {
|
|
150
|
+
const execArgs = ['d1', 'execute', values.database, '--command', sql];
|
|
151
|
+
if (values.env !== undefined) execArgs.push('--env', values.env);
|
|
152
|
+
if (values.remote) execArgs.push('--remote');
|
|
153
|
+
execFileSync('npx', ['wrangler', ...execArgs], { stdio: 'inherit' });
|
|
154
|
+
}
|
|
155
|
+
console.log(
|
|
156
|
+
`CF migration complete: ${statements.length - 1} statement(s) executed ` +
|
|
157
|
+
`(${legacyRows.length} legacy rows + env-seed carry-through).`,
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
type AwsValues = {
|
|
162
|
+
'accounts-table'?: string;
|
|
163
|
+
'services-table'?: string;
|
|
164
|
+
region?: string;
|
|
165
|
+
endpoint?: string;
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
async function runAwsMigration(
|
|
169
|
+
values: AwsValues,
|
|
170
|
+
saslAccountsRaw: string | undefined,
|
|
171
|
+
): Promise<void> {
|
|
172
|
+
if (values['accounts-table'] === undefined || values['services-table'] === undefined) {
|
|
173
|
+
console.error('AWS migration requires --accounts-table and --services-table');
|
|
174
|
+
process.exit(1);
|
|
175
|
+
}
|
|
176
|
+
const { PutCommand, ScanCommand } = await import('@aws-sdk/lib-dynamodb');
|
|
177
|
+
const { createDynamoDocumentClient, loadDynamoServicesStore } = await import(
|
|
178
|
+
'@serverless-ircd/aws-adapter'
|
|
179
|
+
);
|
|
180
|
+
const { SystemClock } = await import('@serverless-ircd/irc-core');
|
|
181
|
+
|
|
182
|
+
const clientOpts: Record<string, string> = {};
|
|
183
|
+
if (values.region !== undefined) clientOpts.region = values.region;
|
|
184
|
+
if (values.endpoint !== undefined) clientOpts.endpoint = values.endpoint;
|
|
185
|
+
const docClient = createDynamoDocumentClient(clientOpts);
|
|
186
|
+
|
|
187
|
+
const legacyRows: LegacyAccountRow[] = [];
|
|
188
|
+
try {
|
|
189
|
+
let exclusiveStartKey: Record<string, unknown> | undefined;
|
|
190
|
+
do {
|
|
191
|
+
const result = await docClient.send(
|
|
192
|
+
new ScanCommand({
|
|
193
|
+
TableName: values['accounts-table'],
|
|
194
|
+
ExclusiveStartKey: exclusiveStartKey,
|
|
195
|
+
}),
|
|
196
|
+
);
|
|
197
|
+
for (const item of result.Items ?? []) {
|
|
198
|
+
if (
|
|
199
|
+
typeof item.account === 'string' &&
|
|
200
|
+
typeof item.algorithm === 'string' &&
|
|
201
|
+
typeof item.salt === 'string' &&
|
|
202
|
+
typeof item.hash === 'string'
|
|
203
|
+
) {
|
|
204
|
+
legacyRows.push({
|
|
205
|
+
account: item.account,
|
|
206
|
+
algorithm: item.algorithm,
|
|
207
|
+
salt: item.salt,
|
|
208
|
+
hash: item.hash,
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
exclusiveStartKey = result.LastEvaluatedKey;
|
|
213
|
+
} while (exclusiveStartKey !== undefined);
|
|
214
|
+
} catch (err) {
|
|
215
|
+
console.error(`Could not scan legacy Accounts table: ${String(err)}`);
|
|
216
|
+
console.error('Continuing with SASL_ACCOUNTS env-seed carry-through only.');
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const seen = new Set<string>();
|
|
220
|
+
let legacyPutCount = 0;
|
|
221
|
+
for (const row of legacyRows) {
|
|
222
|
+
const nickKey = caseFold('rfc1459', row.account);
|
|
223
|
+
seen.add(nickKey);
|
|
224
|
+
const input = buildServicesPutItem(row, values['services-table']);
|
|
225
|
+
try {
|
|
226
|
+
await docClient.send(
|
|
227
|
+
new PutCommand({
|
|
228
|
+
...input,
|
|
229
|
+
ConditionExpression: 'attribute_not_exists(pk)',
|
|
230
|
+
}),
|
|
231
|
+
);
|
|
232
|
+
legacyPutCount++;
|
|
233
|
+
} catch (err) {
|
|
234
|
+
const msg = String(err);
|
|
235
|
+
if (msg.includes('ConditionalCheckFailed')) {
|
|
236
|
+
console.log(`Skipped ${row.account} (already registered in services).`);
|
|
237
|
+
} else {
|
|
238
|
+
throw err;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const store = await loadDynamoServicesStore(docClient, values['services-table'], SystemClock);
|
|
244
|
+
if (store === undefined) {
|
|
245
|
+
console.error(`Could not load services store from ${values['services-table']}`);
|
|
246
|
+
process.exit(1);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
let envSeedCount = 0;
|
|
250
|
+
for (const acct of parseSaslAccountsForMigration(saslAccountsRaw)) {
|
|
251
|
+
const nickKey = caseFold('rfc1459', acct.username);
|
|
252
|
+
if (seen.has(nickKey)) continue;
|
|
253
|
+
seen.add(nickKey);
|
|
254
|
+
const result = store.registerNick(acct.username, acct.password, '');
|
|
255
|
+
if (result.ok) {
|
|
256
|
+
envSeedCount++;
|
|
257
|
+
console.log(`Registered ${acct.username} from SASL_ACCOUNTS env seed.`);
|
|
258
|
+
} else {
|
|
259
|
+
console.log(`Skipped ${acct.username} (already registered in services).`);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
await store.flush();
|
|
263
|
+
|
|
264
|
+
console.log(
|
|
265
|
+
`AWS migration complete: ${legacyRows.length} legacy rows scanned ` +
|
|
266
|
+
`(${legacyPutCount} PutItem'd), ${envSeedCount} env-seed accounts registered.`,
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
void main();
|
package/tools/package.json
CHANGED