serverless-ircd 0.9.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/ci.yml +28 -0
- package/.github/workflows/deploy-aws.yml +156 -32
- package/.github/workflows/deploy-cf-tcp.yml +35 -9
- package/.github/workflows/deploy-cf.yml +40 -14
- package/CHANGELOG.md +594 -0
- package/README.md +286 -60
- package/apps/aws-stack/README.md +3 -5
- package/apps/aws-stack/bin/aws.ts +118 -9
- package/apps/aws-stack/cdk.json +0 -3
- package/apps/aws-stack/package.json +3 -4
- package/apps/aws-stack/src/aws-stack.ts +398 -67
- package/apps/aws-stack/src/static-site.ts +323 -0
- package/apps/aws-stack/tests/smoke-helpers.test.ts +1 -1
- package/apps/aws-stack/tests/stack.test.ts +714 -105
- package/apps/aws-stack/tests/static-site.test.ts +491 -0
- package/apps/aws-stack/tests/synth-no-bundle.test.ts +0 -1
- package/apps/cf-tcp-container/Dockerfile +37 -5
- package/apps/cf-tcp-container/package.json +7 -3
- package/apps/cf-tcp-container/src/config-loader.ts +113 -2
- package/apps/cf-tcp-container/src/container-server.ts +267 -87
- package/apps/cf-tcp-container/src/main.ts +22 -7
- package/apps/cf-tcp-container/src/proxy-protocol.ts +112 -0
- package/apps/cf-tcp-container/terraform/spectrum.tf +40 -11
- package/apps/cf-tcp-container/tests/config-loader.test.ts +170 -0
- package/apps/cf-tcp-container/tests/container-server-tls.test.ts +382 -0
- package/apps/cf-tcp-container/tests/container-server.test.ts +358 -31
- package/apps/cf-tcp-container/tests/dockerfile.test.ts +110 -0
- package/apps/cf-tcp-container/tests/proxy-protocol.test.ts +187 -0
- package/apps/cf-tcp-container/tests/spectrum-terraform.test.ts +135 -0
- package/apps/cf-tcp-container/tests/tls-e2e.test.ts +5 -1
- package/apps/cf-tcp-container/wrangler.toml +18 -14
- package/apps/cf-worker/package.json +3 -4
- package/apps/cf-worker/src/worker.ts +77 -5
- package/apps/cf-worker/tests/raw-modules.d.ts +11 -0
- package/apps/cf-worker/tests/smoke.test.ts +4 -0
- package/apps/cf-worker/tests/wrangler-config.test.ts +47 -0
- package/apps/cf-worker/tests/ws-admission.test.ts +112 -0
- package/apps/cf-worker/tests/ws-rate-limit.test.ts +133 -0
- package/apps/cf-worker/wrangler.test.toml +15 -1
- package/apps/cf-worker/wrangler.toml +95 -77
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/config-loader.ts +14 -2
- package/apps/local-cli/src/line-scanner.ts +26 -0
- package/apps/local-cli/src/server.ts +44 -19
- package/apps/local-cli/tests/line-scanner.test.ts +64 -0
- package/apps/local-cli/tests/tcp.test.ts +29 -0
- package/apps/web/landing/favicon.ico +0 -0
- package/apps/web/landing/index.html +1 -0
- package/apps/web/package.json +2 -2
- package/apps/web/scripts/build.mjs +66 -4
- package/apps/web/src/build-env.ts +125 -4
- package/apps/web/src/config-schema.ts +20 -6
- package/apps/web/static/{config.staging.json → config.prod-aws.json} +3 -2
- package/apps/web/tests/build-env.test.ts +210 -9
- package/apps/web/tests/build-smoke.test.ts +2 -2
- package/apps/web/tests/config-schema.test.ts +149 -25
- package/docs/AWS-Deployment.md +793 -118
- package/docs/AWS-TCP-Deployment.md +57 -47
- package/docs/Chat-History.md +55 -0
- package/docs/Cloudflare-Deployment-Guide.md +95 -114
- package/docs/Cloudflare-TCP-Deployment.md +160 -101
- package/docs/Release-Process.md +27 -23
- package/docs/SASL-EXTERNAL.md +175 -0
- package/docs/Services.md +69 -22
- package/docs/WebClientGuide.md +35 -26
- package/package.json +7 -10
- package/packages/aws-adapter/package.json +1 -1
- package/packages/aws-adapter/src/admission.ts +28 -13
- package/packages/aws-adapter/src/aws-runtime.ts +30 -3
- package/packages/aws-adapter/src/cdk-table-defs.ts +39 -16
- package/packages/aws-adapter/src/config-loader.ts +153 -8
- package/packages/aws-adapter/src/dynamo-services-store.ts +19 -0
- package/packages/aws-adapter/src/handlers/connect.ts +73 -1
- package/packages/aws-adapter/src/handlers/default.ts +279 -123
- package/packages/aws-adapter/src/handlers/index.ts +98 -25
- package/packages/aws-adapter/src/handlers/nlb-stream.ts +135 -14
- package/packages/aws-adapter/src/index.ts +5 -7
- package/packages/aws-adapter/src/ip-admission.ts +79 -0
- package/packages/aws-adapter/src/origin-allowlist.ts +94 -0
- package/packages/aws-adapter/src/serialize.ts +23 -0
- package/packages/aws-adapter/src/tables.ts +11 -12
- package/packages/aws-adapter/tests/admission.test.ts +60 -2
- package/packages/aws-adapter/tests/aws-harness.ts +23 -2
- package/packages/aws-adapter/tests/aws-runtime.test.ts +64 -0
- package/packages/aws-adapter/tests/config-loader.test.ts +217 -0
- package/packages/aws-adapter/tests/connect.test.ts +323 -3
- package/packages/aws-adapter/tests/default-frame-limit.test.ts +231 -0
- package/packages/aws-adapter/tests/default-occ.test.ts +226 -0
- package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +134 -1
- package/packages/aws-adapter/tests/handlers.test.ts +174 -12
- package/packages/aws-adapter/tests/migrate-accounts-to-services.test.ts +164 -0
- package/packages/aws-adapter/tests/nlb-secure.test.ts +362 -0
- package/packages/aws-adapter/tests/nlb-stream.test.ts +628 -9
- package/packages/aws-adapter/tests/origin-allowlist.test.ts +110 -0
- package/packages/aws-adapter/tests/ping-checker.test.ts +0 -1
- package/packages/aws-adapter/tests/stats.test.ts +0 -3
- package/packages/aws-adapter/tests/sweeper.test.ts +0 -1
- package/packages/aws-adapter/tests/tables.test.ts +1 -8
- package/packages/aws-adapter/tests/transactions.test.ts +0 -1
- package/packages/cf-adapter/package.json +1 -5
- package/packages/cf-adapter/src/cf-runtime.ts +100 -10
- package/packages/cf-adapter/src/channel-do.ts +13 -3
- package/packages/cf-adapter/src/config-loader.ts +133 -8
- package/packages/cf-adapter/src/connection-do.ts +406 -116
- package/packages/cf-adapter/src/counter-do.ts +142 -0
- package/packages/cf-adapter/src/d1-services-store.ts +105 -26
- package/packages/cf-adapter/src/env.ts +99 -10
- package/packages/cf-adapter/src/index.ts +17 -7
- package/packages/cf-adapter/src/rate-limit-do.ts +87 -0
- package/packages/cf-adapter/tests/cf-runtime.test.ts +205 -16
- package/packages/cf-adapter/tests/channel-do.test.ts +118 -1
- package/packages/cf-adapter/tests/config-loader.test.ts +159 -0
- package/packages/cf-adapter/tests/connection-do-counter.test.ts +165 -0
- package/packages/cf-adapter/tests/connection-do-coverage.test.ts +460 -0
- package/packages/cf-adapter/tests/connection-do-frame-limit.test.ts +177 -0
- package/packages/cf-adapter/tests/connection-do-pure.test.ts +164 -54
- package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +62 -38
- package/packages/cf-adapter/tests/connection-do-ws-spec-contract.test.ts +7 -4
- package/packages/cf-adapter/tests/counter-do.test.ts +181 -0
- package/packages/cf-adapter/tests/d1-services-store.test.ts +245 -3
- package/packages/cf-adapter/tests/rate-limit-do.test.ts +160 -0
- package/packages/cf-adapter/tests/serialize.test.ts +25 -0
- package/packages/cf-adapter/tests/worker/main.ts +4 -0
- package/packages/cf-adapter/wrangler.test.toml +18 -1
- package/packages/in-memory-runtime/package.json +1 -1
- package/packages/in-memory-runtime/src/in-memory-runtime.ts +25 -0
- package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +74 -0
- package/packages/irc-core/package.json +1 -1
- package/packages/irc-core/src/account-migration.ts +140 -0
- package/packages/irc-core/src/caps/capabilities.ts +20 -10
- package/packages/irc-core/src/certfp.ts +178 -0
- package/packages/irc-core/src/commands/account-auth.ts +16 -19
- package/packages/irc-core/src/commands/cap.ts +10 -2
- package/packages/irc-core/src/commands/chanserv.ts +117 -14
- package/packages/irc-core/src/commands/chathistory.ts +13 -5
- package/packages/irc-core/src/commands/hostserv.ts +84 -8
- package/packages/irc-core/src/commands/index.ts +2 -1
- package/packages/irc-core/src/commands/invite.ts +1 -7
- package/packages/irc-core/src/commands/join.ts +1 -16
- package/packages/irc-core/src/commands/kick.ts +1 -8
- package/packages/irc-core/src/commands/list.ts +1 -8
- package/packages/irc-core/src/commands/memoserv.ts +1 -1
- package/packages/irc-core/src/commands/mode.ts +1 -8
- package/packages/irc-core/src/commands/multiline.ts +4 -10
- package/packages/irc-core/src/commands/names.ts +53 -13
- package/packages/irc-core/src/commands/nickserv.ts +161 -11
- package/packages/irc-core/src/commands/oper.ts +361 -8
- package/packages/irc-core/src/commands/part.ts +4 -10
- package/packages/irc-core/src/commands/privmsg.ts +8 -4
- package/packages/irc-core/src/commands/registration.ts +148 -4
- package/packages/irc-core/src/commands/sasl.ts +154 -46
- package/packages/irc-core/src/commands/topic.ts +10 -12
- package/packages/irc-core/src/commands/who.ts +1 -8
- package/packages/irc-core/src/config.ts +424 -25
- package/packages/irc-core/src/credential-hashing.ts +11 -54
- package/packages/irc-core/src/effects.ts +24 -0
- package/packages/irc-core/src/flood-control.ts +10 -10
- package/packages/irc-core/src/frame-rate-limit.ts +82 -0
- package/packages/irc-core/src/index.ts +9 -0
- package/packages/irc-core/src/oper-hashing.ts +43 -0
- package/packages/irc-core/src/oper-lockout.ts +87 -0
- package/packages/irc-core/src/ports.ts +529 -190
- package/packages/irc-core/src/protocol/bytes.ts +65 -0
- package/packages/irc-core/src/protocol/channel-name.ts +37 -0
- package/packages/irc-core/src/protocol/index.ts +12 -1
- package/packages/irc-core/src/protocol/outbound.ts +43 -10
- package/packages/irc-core/src/protocol/parser.ts +79 -10
- package/packages/irc-core/src/state/connection.ts +13 -0
- package/packages/irc-core/src/types.ts +266 -23
- package/packages/irc-core/src/ws-framing.ts +5 -4
- package/packages/irc-core/tests/account-migration.test.ts +133 -0
- package/packages/irc-core/tests/bytes.test.ts +89 -0
- package/packages/irc-core/tests/certfp.test.ts +117 -0
- package/packages/irc-core/tests/commands/cap.test.ts +76 -2
- package/packages/irc-core/tests/commands/chanserv.test.ts +166 -0
- package/packages/irc-core/tests/commands/chathistory.test.ts +140 -0
- package/packages/irc-core/tests/commands/hostserv.test.ts +316 -0
- package/packages/irc-core/tests/commands/join.test.ts +78 -1
- package/packages/irc-core/tests/commands/markread.test.ts +54 -0
- package/packages/irc-core/tests/commands/memoserv.test.ts +19 -0
- package/packages/irc-core/tests/commands/names.test.ts +193 -0
- package/packages/irc-core/tests/commands/nickserv.test.ts +419 -3
- package/packages/irc-core/tests/commands/oper.test.ts +574 -1
- package/packages/irc-core/tests/commands/privmsg.test.ts +16 -0
- package/packages/irc-core/tests/commands/registration.test.ts +602 -133
- package/packages/irc-core/tests/commands/sasl.test.ts +742 -172
- package/packages/irc-core/tests/commands/topic.test.ts +137 -2
- package/packages/irc-core/tests/commands/unified-account.test.ts +104 -84
- package/packages/irc-core/tests/config.test.ts +534 -2
- package/packages/irc-core/tests/credential-hashing.test.ts +0 -78
- package/packages/irc-core/tests/effects.test.ts +14 -0
- package/packages/irc-core/tests/flood-control.test.ts +29 -1
- package/packages/irc-core/tests/frame-rate-limit.test.ts +98 -0
- package/packages/irc-core/tests/message-store.test.ts +5 -0
- package/packages/irc-core/tests/oper-hashing.test.ts +60 -0
- package/packages/irc-core/tests/oper-lockout.test.ts +74 -0
- package/packages/irc-core/tests/outbound.test.ts +148 -0
- package/packages/irc-core/tests/parser.test.ts +287 -5
- package/packages/irc-core/tests/persistent-services-store.test.ts +212 -12
- package/packages/irc-core/tests/ports.test.ts +170 -7
- package/packages/irc-core/tests/services-store.test.ts +567 -1
- package/packages/irc-core/tests/ws-framing.test.ts +45 -0
- package/packages/irc-core/vitest.config.ts +6 -1
- package/packages/irc-server/package.json +1 -1
- package/packages/irc-server/src/actor.ts +123 -22
- package/packages/irc-server/src/dispatch.ts +1 -0
- package/packages/irc-server/src/index.ts +7 -0
- package/packages/irc-server/src/redact.ts +159 -0
- package/packages/irc-server/src/runtime.ts +14 -0
- package/packages/irc-server/src/transport.ts +28 -1
- package/packages/irc-server/tests/actor.test.ts +563 -54
- package/packages/irc-server/tests/dispatch.test.ts +31 -0
- package/packages/irc-server/tests/redact.test.ts +198 -0
- package/packages/irc-server/tests/runtime.test.ts +2 -0
- package/packages/irc-server/tests/transport.test.ts +66 -0
- package/packages/irc-test-support/package.json +1 -1
- package/packages/irc-test-support/src/in-memory-harness.ts +4 -0
- package/pnpm-workspace.yaml +1 -0
- package/scripts/__tests__/deploy-web-aws.test.ts +491 -0
- package/scripts/deploy-web-aws.mjs +290 -0
- package/scripts/package.json +23 -0
- package/scripts/tsconfig.test.json +12 -0
- package/scripts/vitest.config.ts +19 -0
- package/tools/ci-hardening/package.json +2 -2
- package/tools/ci-hardening/src/cf-deploy-cli.ts +3 -0
- package/tools/ci-hardening/src/cf-deploy.ts +118 -0
- package/tools/ci-hardening/src/deploy-hostname.ts +118 -0
- package/tools/ci-hardening/src/env-var-drift.ts +192 -0
- package/tools/ci-hardening/src/hostname-guard.ts +11 -0
- package/tools/ci-hardening/src/index.ts +19 -0
- package/tools/ci-hardening/src/validate.ts +57 -0
- package/tools/ci-hardening/tests/__wrangler_missing__.toml +2 -0
- package/tools/ci-hardening/tests/__wrangler_placeholder__.toml +3 -0
- package/tools/ci-hardening/tests/__wrangler_real__.toml +3 -0
- package/tools/ci-hardening/tests/cf-deploy.test.ts +200 -0
- package/tools/ci-hardening/tests/deploy-aws-oidc.test.ts +96 -0
- package/tools/ci-hardening/tests/deploy-hostname.test.ts +348 -0
- package/tools/ci-hardening/tests/env-var-drift.test.ts +284 -0
- package/tools/ci-hardening/tests/validate.test.ts +42 -0
- package/tools/ci-hardening/vitest.config.ts +5 -1
- package/tools/hash-oper-cred.ts +85 -0
- package/tools/load-test/package.json +1 -1
- package/tools/migrate-accounts-to-services.ts +270 -0
- package/tools/package.json +2 -1
- package/tools/seed-aws-accounts.ts +35 -10
- package/tools/seed-cf-accounts.ts +42 -9
- package/tools/tcp-ws-forwarder/package.json +1 -1
- package/packages/aws-adapter/src/account-store.ts +0 -121
- package/packages/aws-adapter/src/dynamo-account-store.ts +0 -95
- package/packages/aws-adapter/tests/account-store-dynamo.test.ts +0 -223
- package/packages/aws-adapter/tests/account-store.test.ts +0 -276
- package/packages/cf-adapter/src/d1-account-store.ts +0 -198
- package/packages/cf-adapter/tests/d1-account-store.test.ts +0 -274
- package/packages/irc-core/tests/account-store.test.ts +0 -131
|
@@ -29,9 +29,10 @@ import {
|
|
|
29
29
|
type RawLine,
|
|
30
30
|
createConnection,
|
|
31
31
|
} from '@serverless-ircd/irc-core';
|
|
32
|
-
import { afterAll, afterEach, describe, expect, it } from 'vitest';
|
|
32
|
+
import { afterAll, afterEach, describe, expect, it, vi } from 'vitest';
|
|
33
33
|
import { type CfConnectionHandlers, CfRuntime } from '../src/cf-runtime';
|
|
34
34
|
import type { Env, RegistryRpc } from '../src/env';
|
|
35
|
+
import { serialize } from '../src/serialize';
|
|
35
36
|
import { registryKeyForNick } from '../src/sharding';
|
|
36
37
|
|
|
37
38
|
declare global {
|
|
@@ -45,6 +46,8 @@ declare global {
|
|
|
45
46
|
CHANNEL_DO_REAL: DurableObjectNamespace;
|
|
46
47
|
CHANNEL_DO_STUB: DurableObjectNamespace;
|
|
47
48
|
CHANNEL_REGISTRY_DO: DurableObjectNamespace;
|
|
49
|
+
COUNTER_DO: DurableObjectNamespace;
|
|
50
|
+
RATE_LIMIT_DO: DurableObjectNamespace;
|
|
48
51
|
}
|
|
49
52
|
}
|
|
50
53
|
}
|
|
@@ -64,6 +67,8 @@ function envWithRealDOs(): Env {
|
|
|
64
67
|
REGISTRY_DO: env.REGISTRY_DO_REAL,
|
|
65
68
|
CHANNEL_DO: env.CHANNEL_DO_REAL,
|
|
66
69
|
CHANNEL_REGISTRY_DO: env.CHANNEL_REGISTRY_DO,
|
|
70
|
+
COUNTER_DO: env.COUNTER_DO,
|
|
71
|
+
RATE_LIMIT_DO: env.RATE_LIMIT_DO,
|
|
67
72
|
SERVER_NAME: 'irc.example.com',
|
|
68
73
|
NETWORK_NAME: 'ExampleNet',
|
|
69
74
|
MOTD_LINES: '',
|
|
@@ -81,6 +86,8 @@ function envWithChannelStub(): Env {
|
|
|
81
86
|
REGISTRY_DO: env.REGISTRY_DO_REAL,
|
|
82
87
|
CHANNEL_DO: env.CHANNEL_DO_STUB,
|
|
83
88
|
CHANNEL_REGISTRY_DO: env.CHANNEL_REGISTRY_DO,
|
|
89
|
+
COUNTER_DO: env.COUNTER_DO,
|
|
90
|
+
RATE_LIMIT_DO: env.RATE_LIMIT_DO,
|
|
84
91
|
SERVER_NAME: 'irc.example.com',
|
|
85
92
|
NETWORK_NAME: 'ExampleNet',
|
|
86
93
|
MOTD_LINES: '',
|
|
@@ -659,41 +666,54 @@ describe('CfRuntime — reloadConfig (REHASH)', () => {
|
|
|
659
666
|
|
|
660
667
|
/**
|
|
661
668
|
* Opens a ConnectionDO, registers it with the given nick, and optionally
|
|
662
|
-
* flips user mode `w` on via a direct state mutation (avoids driving
|
|
663
|
-
* full MODE handshake through the actor). Returns the live
|
|
664
|
-
* the connection's canonical hex id.
|
|
669
|
+
* flips user mode `w`/`o` on via a direct state mutation (avoids driving
|
|
670
|
+
* the full MODE/OPER handshake through the actor). Returns the live
|
|
671
|
+
* WebSocket and the connection's canonical hex id.
|
|
665
672
|
*/
|
|
666
673
|
async function openWallopsCapableConn(
|
|
667
674
|
name: string,
|
|
668
|
-
opts: { wallops
|
|
675
|
+
opts: { wallops?: boolean; oper?: boolean },
|
|
669
676
|
): Promise<{ ws: WebSocket; hexId: string; received: string[] }> {
|
|
670
677
|
const ws = await openConnection(name);
|
|
671
678
|
await new Promise<void>((r) => setTimeout(r, 20));
|
|
672
679
|
ws.send(`NICK ${name}\r\n`);
|
|
673
|
-
|
|
680
|
+
// Fixed short username: the server caps USER at 16 bytes, and several
|
|
681
|
+
// connection names in these suites are longer — interpolating the name
|
|
682
|
+
// here made the server reject USER and close the link before the
|
|
683
|
+
// wallops/oper fan-out ever ran.
|
|
684
|
+
ws.send('USER chat 0 * :chat\r\n');
|
|
674
685
|
// Drain the registration numerics so they don't interfere with the
|
|
675
|
-
// assertions on wallops delivery below.
|
|
686
|
+
// assertions on wallops/oper-notice delivery below.
|
|
676
687
|
await new Promise<void>((r) => setTimeout(r, 100));
|
|
677
688
|
|
|
678
689
|
const hexId = await getConnectionHexId(name);
|
|
679
690
|
const received = drainSocket(ws);
|
|
680
691
|
|
|
681
|
-
if (opts.wallops) {
|
|
682
|
-
// Flip the
|
|
683
|
-
// authority for this state; broadcastWallops
|
|
684
|
-
// getConnState() so what we mutate here is exactly what
|
|
685
|
-
// will see.
|
|
692
|
+
if (opts.wallops === true || opts.oper === true) {
|
|
693
|
+
// Flip the mode(s) directly on the live cached state. The DO is the
|
|
694
|
+
// authority for this state; broadcastWallops/broadcastOperNotice read
|
|
695
|
+
// it back via getConnState() so what we mutate here is exactly what
|
|
696
|
+
// the fan-out will see.
|
|
686
697
|
const stub = env.CONNECTION_DO.get(env.CONNECTION_DO.idFromString(hexId));
|
|
687
698
|
await runInDurableObject(stub, async (instance: unknown) => {
|
|
688
699
|
const doi = instance as {
|
|
689
700
|
__peekState?: () => Promise<
|
|
690
701
|
import('@serverless-ircd/irc-core').ConnectionState | undefined
|
|
691
702
|
>;
|
|
692
|
-
__pokeState?: (s: import('@serverless-ircd/irc-core').ConnectionState) => void;
|
|
693
703
|
};
|
|
694
704
|
const state = await doi.__peekState?.();
|
|
695
|
-
if (state !== undefined)
|
|
705
|
+
if (state !== undefined) {
|
|
706
|
+
if (opts.wallops === true) state.userModes.wallops = true;
|
|
707
|
+
if (opts.oper === true) state.userModes.oper = true;
|
|
708
|
+
}
|
|
696
709
|
});
|
|
710
|
+
// Persist the mutated mode: the DO persists state only after a frame
|
|
711
|
+
// dispatches, and a DO eviction between here and the fan-out would
|
|
712
|
+
// otherwise reload the unmutated persisted copy (the cause of a
|
|
713
|
+
// pre-existing intermittent failure in the excepted-originator
|
|
714
|
+
// tests). One client PING forces the persist.
|
|
715
|
+
ws.send('PING :pin\r\n');
|
|
716
|
+
await new Promise<void>((r) => setTimeout(r, 150));
|
|
697
717
|
}
|
|
698
718
|
|
|
699
719
|
// Reserve the nick in the REAL registry under its hex id so
|
|
@@ -751,7 +771,7 @@ describe('CfRuntime — broadcastWallops', () => {
|
|
|
751
771
|
await rt.broadcastWallops([{ text: ':oper!u@h WALLOPS :except me' }], sender.hexId);
|
|
752
772
|
|
|
753
773
|
await new Promise<void>((resolve) => {
|
|
754
|
-
const deadline = Date.now() +
|
|
774
|
+
const deadline = Date.now() + 4000;
|
|
755
775
|
const tick = (): void => {
|
|
756
776
|
if (other.received.some((l) => l.includes('except me'))) {
|
|
757
777
|
resolve();
|
|
@@ -801,7 +821,7 @@ describe('CfRuntime — broadcastWallops', () => {
|
|
|
801
821
|
).resolves.toBeUndefined();
|
|
802
822
|
|
|
803
823
|
await new Promise<void>((resolve) => {
|
|
804
|
-
const deadline = Date.now() +
|
|
824
|
+
const deadline = Date.now() + 4000;
|
|
805
825
|
const tick = (): void => {
|
|
806
826
|
if (other.received.some((l) => l.includes('ghost friendly'))) {
|
|
807
827
|
resolve();
|
|
@@ -821,6 +841,76 @@ describe('CfRuntime — broadcastWallops', () => {
|
|
|
821
841
|
});
|
|
822
842
|
});
|
|
823
843
|
|
|
844
|
+
// ---------------------------------------------------------------------------
|
|
845
|
+
// Tests — broadcastOperNotice (global +o fan-out for oper-only notices)
|
|
846
|
+
// ---------------------------------------------------------------------------
|
|
847
|
+
|
|
848
|
+
describe('CfRuntime — broadcastOperNotice', () => {
|
|
849
|
+
it('delivers to every +o connection and skips non-opers', async () => {
|
|
850
|
+
const realEnv = envWithRealDOs();
|
|
851
|
+
|
|
852
|
+
const oper = await openWallopsCapableConn('opernote-oper', { oper: true });
|
|
853
|
+
// +w without +o must NOT receive an oper notice.
|
|
854
|
+
const wallopsOnly = await openWallopsCapableConn('opernote-wallops', { wallops: true });
|
|
855
|
+
|
|
856
|
+
const rt = new CfRuntime(realEnv, 'conn-opernote-sender', recordingHandlers());
|
|
857
|
+
await rt.broadcastOperNotice([{ text: ':server NOTICE * :*** oper lockout tripped' }]);
|
|
858
|
+
|
|
859
|
+
await new Promise<void>((resolve) => {
|
|
860
|
+
const deadline = Date.now() + 1500;
|
|
861
|
+
const tick = (): void => {
|
|
862
|
+
if (oper.received.some((l) => l.includes('oper lockout tripped'))) {
|
|
863
|
+
resolve();
|
|
864
|
+
return;
|
|
865
|
+
}
|
|
866
|
+
if (Date.now() > deadline) {
|
|
867
|
+
resolve();
|
|
868
|
+
return;
|
|
869
|
+
}
|
|
870
|
+
setTimeout(tick, 20);
|
|
871
|
+
};
|
|
872
|
+
tick();
|
|
873
|
+
});
|
|
874
|
+
|
|
875
|
+
expect(oper.received.some((l) => l.includes('oper lockout tripped'))).toBe(true);
|
|
876
|
+
expect(wallopsOnly.received.some((l) => l.includes('oper lockout tripped'))).toBe(false);
|
|
877
|
+
|
|
878
|
+
oper.ws.close();
|
|
879
|
+
wallopsOnly.ws.close();
|
|
880
|
+
});
|
|
881
|
+
|
|
882
|
+
it('skips the excepted originator', async () => {
|
|
883
|
+
const realEnv = envWithRealDOs();
|
|
884
|
+
const sender = await openWallopsCapableConn('opernote-except-sender', { oper: true });
|
|
885
|
+
const other = await openWallopsCapableConn('opernote-except-other', { oper: true });
|
|
886
|
+
|
|
887
|
+
const rt = new CfRuntime(realEnv, sender.hexId, recordingHandlers());
|
|
888
|
+
await rt.broadcastOperNotice([{ text: ':server NOTICE * :*** except me' }], sender.hexId);
|
|
889
|
+
|
|
890
|
+
await new Promise<void>((resolve) => {
|
|
891
|
+
const deadline = Date.now() + 4000;
|
|
892
|
+
const tick = (): void => {
|
|
893
|
+
if (other.received.some((l) => l.includes('except me'))) {
|
|
894
|
+
resolve();
|
|
895
|
+
return;
|
|
896
|
+
}
|
|
897
|
+
if (Date.now() > deadline) {
|
|
898
|
+
resolve();
|
|
899
|
+
return;
|
|
900
|
+
}
|
|
901
|
+
setTimeout(tick, 20);
|
|
902
|
+
};
|
|
903
|
+
tick();
|
|
904
|
+
});
|
|
905
|
+
|
|
906
|
+
expect(other.received.some((l) => l.includes('except me'))).toBe(true);
|
|
907
|
+
expect(sender.received.some((l) => l.includes('except me'))).toBe(false);
|
|
908
|
+
|
|
909
|
+
sender.ws.close();
|
|
910
|
+
other.ws.close();
|
|
911
|
+
});
|
|
912
|
+
});
|
|
913
|
+
|
|
824
914
|
// ---------------------------------------------------------------------------
|
|
825
915
|
// Tests — changeNick shard routing + failure paths
|
|
826
916
|
// ---------------------------------------------------------------------------
|
|
@@ -921,3 +1011,102 @@ describe('CfRuntime — getConnection and getChannelConnections branches', () =>
|
|
|
921
1011
|
member.ws.close();
|
|
922
1012
|
});
|
|
923
1013
|
});
|
|
1014
|
+
|
|
1015
|
+
// ---------------------------------------------------------------------------
|
|
1016
|
+
// Tests — pure helper seams for defensive null-DTO branches
|
|
1017
|
+
// ---------------------------------------------------------------------------
|
|
1018
|
+
|
|
1019
|
+
describe('CfRuntime — defensive DTO helper seams', () => {
|
|
1020
|
+
it('deserializeConnState returns null when the DTO is null', () => {
|
|
1021
|
+
const stubEnv = envWithChannelStub();
|
|
1022
|
+
const rt = new CfRuntime(stubEnv, 'conn-dsz-null', recordingHandlers());
|
|
1023
|
+
expect(rt.deserializeConnState(null)).toBeNull();
|
|
1024
|
+
});
|
|
1025
|
+
|
|
1026
|
+
it('deserializeConnState rehydrates a populated DTO', () => {
|
|
1027
|
+
const stubEnv = envWithChannelStub();
|
|
1028
|
+
const rt = new CfRuntime(stubEnv, 'conn-dsz-real', recordingHandlers());
|
|
1029
|
+
const live = createConnection({ id: 'conn-dsz-real', connectedSince: 0 });
|
|
1030
|
+
live.nick = 'dz';
|
|
1031
|
+
// Round-trip through serialize so the DTO matches what a real
|
|
1032
|
+
// ConnectionDO.getConnState would return.
|
|
1033
|
+
const dto = serialize(live);
|
|
1034
|
+
const out = rt.deserializeConnState(dto);
|
|
1035
|
+
expect(out?.nick).toBe('dz');
|
|
1036
|
+
expect(out?.id).toBe('conn-dsz-real');
|
|
1037
|
+
});
|
|
1038
|
+
|
|
1039
|
+
it('collectChannelMember skips a null DTO without mutating the map', () => {
|
|
1040
|
+
const stubEnv = envWithChannelStub();
|
|
1041
|
+
const rt = new CfRuntime(stubEnv, 'conn-ccm-null', recordingHandlers());
|
|
1042
|
+
const out = new Map();
|
|
1043
|
+
rt.collectChannelMember(out, 'ghost', null);
|
|
1044
|
+
expect(out.size).toBe(0);
|
|
1045
|
+
});
|
|
1046
|
+
|
|
1047
|
+
it('collectChannelMember rehydrates and inserts a populated DTO', () => {
|
|
1048
|
+
const stubEnv = envWithChannelStub();
|
|
1049
|
+
const rt = new CfRuntime(stubEnv, 'conn-ccm-real', recordingHandlers());
|
|
1050
|
+
const live = createConnection({ id: 'conn-ccm-real', connectedSince: 0 });
|
|
1051
|
+
live.nick = 'ccm';
|
|
1052
|
+
const out = new Map();
|
|
1053
|
+
rt.collectChannelMember(out, 'conn-ccm-real', serialize(live));
|
|
1054
|
+
expect(out.size).toBe(1);
|
|
1055
|
+
expect(out.get('conn-ccm-real')?.nick).toBe('ccm');
|
|
1056
|
+
});
|
|
1057
|
+
|
|
1058
|
+
it('deliverWallopsToCandidate is a no-op when the DTO is null', async () => {
|
|
1059
|
+
const stubEnv = envWithChannelStub();
|
|
1060
|
+
const rt = new CfRuntime(stubEnv, 'conn-dwc-null', recordingHandlers());
|
|
1061
|
+
const deliver = vi.fn();
|
|
1062
|
+
await rt.deliverWallopsToCandidate({ deliver }, null, [{ text: 'x' }]);
|
|
1063
|
+
expect(deliver).not.toHaveBeenCalled();
|
|
1064
|
+
});
|
|
1065
|
+
|
|
1066
|
+
it('deliverWallopsToCandidate skips a candidate whose +w mode is off', async () => {
|
|
1067
|
+
const stubEnv = envWithChannelStub();
|
|
1068
|
+
const rt = new CfRuntime(stubEnv, 'conn-dwc-now', recordingHandlers());
|
|
1069
|
+
const live = createConnection({ id: 'conn-dwc-now', connectedSince: 0 });
|
|
1070
|
+
live.userModes.wallops = false;
|
|
1071
|
+
const deliver = vi.fn();
|
|
1072
|
+
await rt.deliverWallopsToCandidate({ deliver }, serialize(live), [{ text: 'x' }]);
|
|
1073
|
+
expect(deliver).not.toHaveBeenCalled();
|
|
1074
|
+
});
|
|
1075
|
+
|
|
1076
|
+
it('deliverWallopsToCandidate delivers to a +w candidate', async () => {
|
|
1077
|
+
const stubEnv = envWithChannelStub();
|
|
1078
|
+
const rt = new CfRuntime(stubEnv, 'conn-dwc-yes', recordingHandlers());
|
|
1079
|
+
const live = createConnection({ id: 'conn-dwc-yes', connectedSince: 0 });
|
|
1080
|
+
live.userModes.wallops = true;
|
|
1081
|
+
const calls: RawLine[][] = [];
|
|
1082
|
+
const deliver = (lines: RawLine[]): Promise<void> => {
|
|
1083
|
+
calls.push(lines);
|
|
1084
|
+
return Promise.resolve();
|
|
1085
|
+
};
|
|
1086
|
+
await rt.deliverWallopsToCandidate({ deliver }, serialize(live), [
|
|
1087
|
+
{ text: ':oper!u@h WALLOPS :hi' },
|
|
1088
|
+
]);
|
|
1089
|
+
expect(calls).toEqual([[{ text: ':oper!u@h WALLOPS :hi' }]]);
|
|
1090
|
+
});
|
|
1091
|
+
});
|
|
1092
|
+
|
|
1093
|
+
// ---------------------------------------------------------------------------
|
|
1094
|
+
// Tests — getChannelSnapshot topic branch (the { topic } arm of the spread)
|
|
1095
|
+
// ---------------------------------------------------------------------------
|
|
1096
|
+
|
|
1097
|
+
describe('CfRuntime — getChannelSnapshot topic branch', () => {
|
|
1098
|
+
it('rehydrates the topic onto ChanSnapshot when the ChannelDO reports one', async () => {
|
|
1099
|
+
// Existing coverage exercises the `: {}` arm of the topic spread; this
|
|
1100
|
+
// case drives the `{ topic: dto.topic }` arm by applying a topic
|
|
1101
|
+
// through the real ChannelDO and reading it back via CfRuntime.
|
|
1102
|
+
const realEnv = envWithRealDOs();
|
|
1103
|
+
const rt = new CfRuntime(realEnv, 'conn-topic-caller', recordingHandlers());
|
|
1104
|
+
const topic = { text: 'news', setter: 'alice', setAt: 1 };
|
|
1105
|
+
await rt.applyChannelDelta('#topic-branch', {
|
|
1106
|
+
memberships: [{ type: 'add' as const, conn: 'conn-topic-caller', nick: 'alice' }],
|
|
1107
|
+
topic,
|
|
1108
|
+
});
|
|
1109
|
+
const snap = await rt.getChannelSnapshot('#topic-branch');
|
|
1110
|
+
expect(snap?.topic).toEqual(topic);
|
|
1111
|
+
});
|
|
1112
|
+
});
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
import { env, runInDurableObject } from 'cloudflare:test';
|
|
19
19
|
import type { ChannelDelta, ChannelTopic, ConnId, RosterEntry } from '@serverless-ircd/irc-core';
|
|
20
20
|
import { describe, expect, it } from 'vitest';
|
|
21
|
-
import type
|
|
21
|
+
import { type ChannelSnapshotDto, deserializeChannel, toDto } from '../src/channel-do';
|
|
22
22
|
|
|
23
23
|
declare global {
|
|
24
24
|
namespace Cloudflare {
|
|
@@ -595,3 +595,120 @@ describe('ChannelDO — broadcast + cache edge cases', () => {
|
|
|
595
595
|
await alice.client.close();
|
|
596
596
|
});
|
|
597
597
|
});
|
|
598
|
+
|
|
599
|
+
// ---------------------------------------------------------------------------
|
|
600
|
+
// Tests — ChannelDO instantiated without a name (idFromString)
|
|
601
|
+
// ---------------------------------------------------------------------------
|
|
602
|
+
|
|
603
|
+
describe('ChannelDO — id.name fallback', () => {
|
|
604
|
+
it('falls back to ctx.id.toString() when the DO was created via idFromString', async () => {
|
|
605
|
+
// ChannelDOs in production are always keyed by lowercased name
|
|
606
|
+
// (idFromName), but the DO storage-creation path still needs to
|
|
607
|
+
// handle the no-name case. `newUniqueId()` returns a valid hex DO id
|
|
608
|
+
// whose `id.name` is null, forcing the `??` fallback to `toString()`.
|
|
609
|
+
const hexId = env.CHANNEL_DO_REAL.newUniqueId().toString();
|
|
610
|
+
const stub = env.CHANNEL_DO_REAL.get(env.CHANNEL_DO_REAL.idFromString(hexId));
|
|
611
|
+
const rpc = stub as unknown as ChannelRpc;
|
|
612
|
+
await rpc.applyChannelDelta({
|
|
613
|
+
memberships: [{ type: 'add', conn: 'c-id-fallback', nick: 'alice' }],
|
|
614
|
+
});
|
|
615
|
+
const snap = await rpc.getChannelSnapshot();
|
|
616
|
+
// The DO used the hex id as the storage key, so nameLower resolves
|
|
617
|
+
// to the hex string (not a lowercased channel name).
|
|
618
|
+
expect(snap?.nameLower).toBe(hexId);
|
|
619
|
+
expect(snap?.name).toBe(hexId);
|
|
620
|
+
});
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
// ---------------------------------------------------------------------------
|
|
624
|
+
// Tests — pure deserializeChannel / toDto helpers (legacy-shape arms)
|
|
625
|
+
// ---------------------------------------------------------------------------
|
|
626
|
+
|
|
627
|
+
describe('deserializeChannel — legacy record defensive arms', () => {
|
|
628
|
+
it('coalesces missing op/voice to false for legacy persisted members', () => {
|
|
629
|
+
// Pre-feature records may have been written without `op`/`voice`
|
|
630
|
+
// fields (or with them explicitly undefined). The loader's `?? false`
|
|
631
|
+
// arms must catch both so the rehydrated RosterEntry always carries
|
|
632
|
+
// a boolean. The PersistedChannelState interface types op/voice as
|
|
633
|
+
// optional, but legacy on-disk rows predate the field — cast through
|
|
634
|
+
// unknown to simulate the storage layer's untyped payload.
|
|
635
|
+
const record = {
|
|
636
|
+
name: '#legacy',
|
|
637
|
+
nameLower: '#legacy',
|
|
638
|
+
modes: {
|
|
639
|
+
inviteOnly: false,
|
|
640
|
+
topicLock: false,
|
|
641
|
+
noExternal: false,
|
|
642
|
+
moderated: false,
|
|
643
|
+
secret: false,
|
|
644
|
+
private: false,
|
|
645
|
+
registered: false,
|
|
646
|
+
blockUnidentified: false,
|
|
647
|
+
moderatedIdent: false,
|
|
648
|
+
},
|
|
649
|
+
banMasks: [],
|
|
650
|
+
members: [{ conn: 'c-1', nick: 'alice' }],
|
|
651
|
+
pendingInvites: [],
|
|
652
|
+
createdAt: 0,
|
|
653
|
+
} as unknown as Parameters<typeof deserializeChannel>[0];
|
|
654
|
+
const chan = deserializeChannel(record);
|
|
655
|
+
const entry = chan.members.get('c-1');
|
|
656
|
+
expect(entry?.op).toBe(false);
|
|
657
|
+
expect(entry?.voice).toBe(false);
|
|
658
|
+
});
|
|
659
|
+
|
|
660
|
+
it('leaves topic undefined when the record carries none', () => {
|
|
661
|
+
// The `if (record.topic !== undefined)` else arm: a record written
|
|
662
|
+
// before any TOPIC was set has no topic field, and deserializeChannel
|
|
663
|
+
// must leave chan.topic undefined.
|
|
664
|
+
const record = {
|
|
665
|
+
name: '#notopic',
|
|
666
|
+
nameLower: '#notopic',
|
|
667
|
+
modes: {
|
|
668
|
+
inviteOnly: false,
|
|
669
|
+
topicLock: false,
|
|
670
|
+
noExternal: false,
|
|
671
|
+
moderated: false,
|
|
672
|
+
secret: false,
|
|
673
|
+
private: false,
|
|
674
|
+
registered: false,
|
|
675
|
+
blockUnidentified: false,
|
|
676
|
+
moderatedIdent: false,
|
|
677
|
+
},
|
|
678
|
+
banMasks: [],
|
|
679
|
+
members: [],
|
|
680
|
+
pendingInvites: [],
|
|
681
|
+
createdAt: 0,
|
|
682
|
+
} as unknown as Parameters<typeof deserializeChannel>[0];
|
|
683
|
+
expect(deserializeChannel(record).topic).toBeUndefined();
|
|
684
|
+
});
|
|
685
|
+
});
|
|
686
|
+
|
|
687
|
+
describe('toDto — defensive Set/Map arms', () => {
|
|
688
|
+
it('returns empty arrays when ChannelState fields are missing', () => {
|
|
689
|
+
// Channels constructed without going through createChannel (e.g. a
|
|
690
|
+
// hand-built test fixture or a future storage migration) may lack
|
|
691
|
+
// the `Set`/`Map` fields. The `?:` arms in toDto must degrade to
|
|
692
|
+
// empty arrays so the DTO never carries undefined to callers.
|
|
693
|
+
const partial = {
|
|
694
|
+
name: '#partial',
|
|
695
|
+
nameLower: '#partial',
|
|
696
|
+
modes: {
|
|
697
|
+
inviteOnly: false,
|
|
698
|
+
topicLock: false,
|
|
699
|
+
noExternal: false,
|
|
700
|
+
moderated: false,
|
|
701
|
+
secret: false,
|
|
702
|
+
private: false,
|
|
703
|
+
registered: false,
|
|
704
|
+
blockUnidentified: false,
|
|
705
|
+
moderatedIdent: false,
|
|
706
|
+
},
|
|
707
|
+
createdAt: 0,
|
|
708
|
+
} as unknown as Parameters<typeof toDto>[0];
|
|
709
|
+
const dto = toDto(partial);
|
|
710
|
+
expect(dto.banMasks).toEqual([]);
|
|
711
|
+
expect(dto.members).toEqual([]);
|
|
712
|
+
expect(dto.pendingInvites).toEqual([]);
|
|
713
|
+
});
|
|
714
|
+
});
|
|
@@ -17,6 +17,18 @@ interface CfEnvLike {
|
|
|
17
17
|
CHANNEL_PREFIXES?: string;
|
|
18
18
|
OPER_USER?: string;
|
|
19
19
|
OPER_PASSWORD?: string;
|
|
20
|
+
/**
|
|
21
|
+
* Scrypt salt (base64) for the hashed oper credential form. When set
|
|
22
|
+
* alongside `OPER_USER` + `OPER_HASH`, the loader builds a hashed
|
|
23
|
+
* `{user, salt, hash}` cred instead of the legacy plaintext
|
|
24
|
+
* `{user, password}` shape.
|
|
25
|
+
*/
|
|
26
|
+
OPER_SALT?: string;
|
|
27
|
+
/**
|
|
28
|
+
* Scrypt hash (base64) for the hashed oper credential form. See
|
|
29
|
+
* `OPER_SALT` for the full set of vars.
|
|
30
|
+
*/
|
|
31
|
+
OPER_HASH?: string;
|
|
20
32
|
/**
|
|
21
33
|
* Server-password gate. Treat as a wrangler secret in production
|
|
22
34
|
* (never a plaintext `[vars]` entry); the loader threads it through
|
|
@@ -30,6 +42,8 @@ interface CfEnvLike {
|
|
|
30
42
|
TOPIC_LEN?: string;
|
|
31
43
|
MAX_LIST_ENTRIES?: string;
|
|
32
44
|
QUIT_MESSAGE?: string;
|
|
45
|
+
/** SASL EXTERNAL operator opt-in ('true'/'1' enable; default off). */
|
|
46
|
+
EXTERNAL_ENABLED?: string;
|
|
33
47
|
}
|
|
34
48
|
|
|
35
49
|
function baseEnv(): CfEnvLike {
|
|
@@ -82,6 +96,49 @@ describe('loadServerConfigFromCfEnv — valid env', () => {
|
|
|
82
96
|
expect(cfg.operCreds).toEqual([{ user: 'admin', password: 'hunter2' }]);
|
|
83
97
|
});
|
|
84
98
|
|
|
99
|
+
it('builds a hashed operCred from OPER_USER + OPER_SALT + OPER_HASH', () => {
|
|
100
|
+
const cfg = loadServerConfigFromCfEnv({
|
|
101
|
+
...baseEnv(),
|
|
102
|
+
OPER_USER: 'admin',
|
|
103
|
+
OPER_SALT: 'c2FsdA==',
|
|
104
|
+
OPER_HASH: 'aGFzaA==',
|
|
105
|
+
});
|
|
106
|
+
expect(cfg.operCreds).toEqual([{ user: 'admin', salt: 'c2FsdA==', hash: 'aGFzaA==' }]);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('prefers the hashed form when both OPER_PASSWORD and OPER_HASH/OPER_SALT are set', () => {
|
|
110
|
+
// Hashed form takes precedence so operators can rotate to it without
|
|
111
|
+
// removing the legacy OPER_PASSWORD var in the same deploy.
|
|
112
|
+
const cfg = loadServerConfigFromCfEnv({
|
|
113
|
+
...baseEnv(),
|
|
114
|
+
OPER_USER: 'admin',
|
|
115
|
+
OPER_PASSWORD: 'legacy',
|
|
116
|
+
OPER_SALT: 'c2FsdA==',
|
|
117
|
+
OPER_HASH: 'aGFzaA==',
|
|
118
|
+
});
|
|
119
|
+
expect(cfg.operCreds).toEqual([{ user: 'admin', salt: 'c2FsdA==', hash: 'aGFzaA==' }]);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('throws when OPER_USER + OPER_SALT are set but OPER_HASH is missing', () => {
|
|
123
|
+
expect(() =>
|
|
124
|
+
loadServerConfigFromCfEnv({
|
|
125
|
+
...baseEnv(),
|
|
126
|
+
OPER_USER: 'admin',
|
|
127
|
+
OPER_SALT: 'c2FsdA==',
|
|
128
|
+
}),
|
|
129
|
+
).toThrowError(/hash/u);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('throws when OPER_USER + OPER_HASH are set but OPER_SALT is missing', () => {
|
|
133
|
+
expect(() =>
|
|
134
|
+
loadServerConfigFromCfEnv({
|
|
135
|
+
...baseEnv(),
|
|
136
|
+
OPER_USER: 'admin',
|
|
137
|
+
OPER_HASH: 'aGFzaA==',
|
|
138
|
+
}),
|
|
139
|
+
).toThrowError(/salt/u);
|
|
140
|
+
});
|
|
141
|
+
|
|
85
142
|
it('threads SERVER_PASSWORD through to serverPassword', () => {
|
|
86
143
|
const cfg = loadServerConfigFromCfEnv({
|
|
87
144
|
...baseEnv(),
|
|
@@ -237,3 +294,105 @@ describe('loadServerConfigFromCfEnv — SASL_ACCOUNTS parsing', () => {
|
|
|
237
294
|
expect(cfg.createdAt).toBe(huge);
|
|
238
295
|
});
|
|
239
296
|
});
|
|
297
|
+
|
|
298
|
+
// ---------------------------------------------------------------------------
|
|
299
|
+
// Adapter frame-rate limit (per-connection inbound frame window).
|
|
300
|
+
// ---------------------------------------------------------------------------
|
|
301
|
+
|
|
302
|
+
describe('loadServerConfigFromCfEnv — adapter frame-rate limit', () => {
|
|
303
|
+
it('defaults the adapter frame window when the vars are unset', () => {
|
|
304
|
+
const cfg = loadServerConfigFromCfEnv(baseEnv());
|
|
305
|
+
expect(cfg.adapter).toEqual({ maxFramesPerWindow: 50, frameWindowSeconds: 5 });
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it('maps MAX_FRAMES_PER_WINDOW + FRAME_WINDOW_SECONDS to the adapter section', () => {
|
|
309
|
+
const cfg = loadServerConfigFromCfEnv({
|
|
310
|
+
...baseEnv(),
|
|
311
|
+
MAX_FRAMES_PER_WINDOW: '10',
|
|
312
|
+
FRAME_WINDOW_SECONDS: '2',
|
|
313
|
+
});
|
|
314
|
+
expect(cfg.adapter).toEqual({ maxFramesPerWindow: 10, frameWindowSeconds: 2 });
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
it('fills the unset knob with its schema default when only one var is set', () => {
|
|
318
|
+
const cfg = loadServerConfigFromCfEnv({ ...baseEnv(), MAX_FRAMES_PER_WINDOW: '25' });
|
|
319
|
+
expect(cfg.adapter).toEqual({ maxFramesPerWindow: 25, frameWindowSeconds: 5 });
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
it('throws when MAX_FRAMES_PER_WINDOW is non-numeric', () => {
|
|
323
|
+
expect(() =>
|
|
324
|
+
loadServerConfigFromCfEnv({ ...baseEnv(), MAX_FRAMES_PER_WINDOW: 'lots' }),
|
|
325
|
+
).toThrowError(/maxFramesPerWindow/u);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it('throws when FRAME_WINDOW_SECONDS is non-positive', () => {
|
|
329
|
+
expect(() =>
|
|
330
|
+
loadServerConfigFromCfEnv({ ...baseEnv(), FRAME_WINDOW_SECONDS: '0' }),
|
|
331
|
+
).toThrowError(/frameWindowSeconds/u);
|
|
332
|
+
});
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
// ---------------------------------------------------------------------------
|
|
336
|
+
// EXTERNAL_ENABLED — SASL EXTERNAL operator opt-in (default off).
|
|
337
|
+
// ---------------------------------------------------------------------------
|
|
338
|
+
|
|
339
|
+
describe('loadServerConfigFromCfEnv — EXTERNAL_ENABLED (SASL EXTERNAL opt-in)', () => {
|
|
340
|
+
it('leaves SASL EXTERNAL off when EXTERNAL_ENABLED is unset', () => {
|
|
341
|
+
const cfg = loadServerConfigFromCfEnv(baseEnv());
|
|
342
|
+
expect(cfg.sasl?.externalEnabled ?? false).toBe(false);
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
it('maps EXTERNAL_ENABLED=true to sasl.externalEnabled', () => {
|
|
346
|
+
const cfg = loadServerConfigFromCfEnv({ ...baseEnv(), EXTERNAL_ENABLED: 'true' });
|
|
347
|
+
expect(cfg.sasl?.externalEnabled).toBe(true);
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
it('maps EXTERNAL_ENABLED=1 to sasl.externalEnabled', () => {
|
|
351
|
+
const cfg = loadServerConfigFromCfEnv({ ...baseEnv(), EXTERNAL_ENABLED: '1' });
|
|
352
|
+
expect(cfg.sasl?.externalEnabled).toBe(true);
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
it('maps EXTERNAL_ENABLED=false (and any other value) to false', () => {
|
|
356
|
+
const cfg = loadServerConfigFromCfEnv({ ...baseEnv(), EXTERNAL_ENABLED: 'false' });
|
|
357
|
+
expect(cfg.sasl?.externalEnabled).toBe(false);
|
|
358
|
+
const junk = loadServerConfigFromCfEnv({ ...baseEnv(), EXTERNAL_ENABLED: 'yes please' });
|
|
359
|
+
expect(junk.sasl?.externalEnabled ?? false).toBe(false);
|
|
360
|
+
});
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
// ---------------------------------------------------------------------------
|
|
364
|
+
// Per-IP admission knobs — edge throttling is operator-tunable via env vars.
|
|
365
|
+
// ---------------------------------------------------------------------------
|
|
366
|
+
|
|
367
|
+
describe('loadServerConfigFromCfEnv — per-IP admission knobs', () => {
|
|
368
|
+
it('defaults maxConnectionsPerIp / perIpConnectionRate from the schema', () => {
|
|
369
|
+
const cfg = loadServerConfigFromCfEnv(baseEnv());
|
|
370
|
+
expect(cfg.maxConnectionsPerIp).toBe(10);
|
|
371
|
+
expect(cfg.perIpConnectionRate).toEqual({ max: 5, windowMs: 60_000 });
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
it('parses MAX_CONNECTIONS_PER_IP as an integer', () => {
|
|
375
|
+
const cfg = loadServerConfigFromCfEnv({ ...baseEnv(), MAX_CONNECTIONS_PER_IP: '3' });
|
|
376
|
+
expect(cfg.maxConnectionsPerIp).toBe(3);
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
it('parses PER_IP_CONNECTION_RATE_MAX / _WINDOW_MS into the rate knob', () => {
|
|
380
|
+
const cfg = loadServerConfigFromCfEnv({
|
|
381
|
+
...baseEnv(),
|
|
382
|
+
PER_IP_CONNECTION_RATE_MAX: '10',
|
|
383
|
+
PER_IP_CONNECTION_RATE_WINDOW_MS: '30000',
|
|
384
|
+
});
|
|
385
|
+
expect(cfg.perIpConnectionRate).toEqual({ max: 10, windowMs: 30000 });
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
it('fills the unset half of the rate knob with the schema default', () => {
|
|
389
|
+
const cfg = loadServerConfigFromCfEnv({ ...baseEnv(), PER_IP_CONNECTION_RATE_MAX: '9' });
|
|
390
|
+
expect(cfg.perIpConnectionRate).toEqual({ max: 9, windowMs: 60_000 });
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
it('rejects a non-positive rate budget', () => {
|
|
394
|
+
expect(() =>
|
|
395
|
+
loadServerConfigFromCfEnv({ ...baseEnv(), PER_IP_CONNECTION_RATE_MAX: '0' }),
|
|
396
|
+
).toThrowError(/perIpConnectionRate/u);
|
|
397
|
+
});
|
|
398
|
+
});
|