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
|
@@ -37,6 +37,7 @@ const queueServerConfig: ServerConfig = {
|
|
|
37
37
|
const autoApproveServerConfig: ServerConfig = {
|
|
38
38
|
...serverConfig,
|
|
39
39
|
hostservAutoApproveVhosts: true,
|
|
40
|
+
hostservAutoApproveAllowlist: ['cool.example.net', 'one.example.net', 'two.example.net'],
|
|
40
41
|
};
|
|
41
42
|
|
|
42
43
|
function makeConn(id = 'c1', nick = 'alice'): ConnectionState {
|
|
@@ -548,6 +549,321 @@ describe('hostservReducer — REQUEST (auto-approve knob explicitly on)', () =>
|
|
|
548
549
|
});
|
|
549
550
|
});
|
|
550
551
|
|
|
552
|
+
// ============================================================================
|
|
553
|
+
// REQUEST — auto-approve hardening (denylist + allowlist)
|
|
554
|
+
// ============================================================================
|
|
555
|
+
|
|
556
|
+
describe('hostservReducer — REQUEST (auto-approve denylist)', () => {
|
|
557
|
+
/**
|
|
558
|
+
* Auto-approve config whose allowlist deliberately contains every probed
|
|
559
|
+
* vhost: each test proves the built-in denylist is enforced regardless
|
|
560
|
+
* of (and ahead of) the allowlist.
|
|
561
|
+
*/
|
|
562
|
+
const denyConfig: ServerConfig = {
|
|
563
|
+
...serverConfig,
|
|
564
|
+
hostservAutoApproveVhosts: true,
|
|
565
|
+
hostservAutoApproveAllowlist: [
|
|
566
|
+
'admin.example.net',
|
|
567
|
+
'oper.example.net',
|
|
568
|
+
'staff.example.net',
|
|
569
|
+
'irc.example.net',
|
|
570
|
+
'www.example.net',
|
|
571
|
+
'root.example.net',
|
|
572
|
+
'security.example.net',
|
|
573
|
+
'user.admin.example.net',
|
|
574
|
+
'example.net.staff',
|
|
575
|
+
'ADMIN.example.net',
|
|
576
|
+
'hub.example.net',
|
|
577
|
+
'user.hub.example.net',
|
|
578
|
+
'clean.example.net',
|
|
579
|
+
],
|
|
580
|
+
};
|
|
581
|
+
|
|
582
|
+
/** Same hardening config on a serverName free of reserved labels, so the
|
|
583
|
+
* own-hostname suffix check is probed in isolation. */
|
|
584
|
+
const suffixConfig: ServerConfig = { ...denyConfig, serverName: 'hub.example.net' };
|
|
585
|
+
|
|
586
|
+
it.each(['admin', 'oper', 'staff', 'irc', 'www', 'root', 'security'])(
|
|
587
|
+
'denies a vhost whose first label is the reserved label %s (even when allowlisted)',
|
|
588
|
+
(label) => {
|
|
589
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
|
|
590
|
+
const conn = identifiedConn('alice');
|
|
591
|
+
const ctx = makeCtx(conn, services, new FakeClock(NOW), denyConfig);
|
|
592
|
+
|
|
593
|
+
const out = hostservReducer(conn, privmsg(`REQUEST ${label}.example.net`), ctx);
|
|
594
|
+
|
|
595
|
+
expect(services.getVhost('alice')).toBeUndefined();
|
|
596
|
+
expect(sentTexts(out.effects)).toContain(
|
|
597
|
+
`:HostServ!HostServ@services NOTICE alice :Vhost ${label}.example.net is not permitted (reserved label or this server's hostname).`,
|
|
598
|
+
);
|
|
599
|
+
},
|
|
600
|
+
);
|
|
601
|
+
|
|
602
|
+
it('denies a vhost with a reserved label in a middle position', () => {
|
|
603
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
|
|
604
|
+
const conn = identifiedConn('alice');
|
|
605
|
+
const ctx = makeCtx(conn, services, new FakeClock(NOW), denyConfig);
|
|
606
|
+
|
|
607
|
+
const out = hostservReducer(conn, privmsg('REQUEST user.admin.example.net'), ctx);
|
|
608
|
+
|
|
609
|
+
expect(services.getVhost('alice')).toBeUndefined();
|
|
610
|
+
expect(sentTexts(out.effects)).toContain(
|
|
611
|
+
":HostServ!HostServ@services NOTICE alice :Vhost user.admin.example.net is not permitted (reserved label or this server's hostname).",
|
|
612
|
+
);
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
it('denies a vhost with a reserved label in the last position', () => {
|
|
616
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
|
|
617
|
+
const conn = identifiedConn('alice');
|
|
618
|
+
const ctx = makeCtx(conn, services, new FakeClock(NOW), denyConfig);
|
|
619
|
+
|
|
620
|
+
const out = hostservReducer(conn, privmsg('REQUEST example.net.staff'), ctx);
|
|
621
|
+
|
|
622
|
+
expect(services.getVhost('alice')).toBeUndefined();
|
|
623
|
+
expect(sentTexts(out.effects)).toContain(
|
|
624
|
+
":HostServ!HostServ@services NOTICE alice :Vhost example.net.staff is not permitted (reserved label or this server's hostname).",
|
|
625
|
+
);
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
it('matches reserved labels case-insensitively', () => {
|
|
629
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
|
|
630
|
+
const conn = identifiedConn('alice');
|
|
631
|
+
const ctx = makeCtx(conn, services, new FakeClock(NOW), denyConfig);
|
|
632
|
+
|
|
633
|
+
const out = hostservReducer(conn, privmsg('REQUEST ADMIN.example.net'), ctx);
|
|
634
|
+
|
|
635
|
+
expect(services.getVhost('alice')).toBeUndefined();
|
|
636
|
+
expect(sentTexts(out.effects)).toContain(
|
|
637
|
+
":HostServ!HostServ@services NOTICE alice :Vhost ADMIN.example.net is not permitted (reserved label or this server's hostname).",
|
|
638
|
+
);
|
|
639
|
+
});
|
|
640
|
+
|
|
641
|
+
it("denies the server's own hostname exactly (even when allowlisted)", () => {
|
|
642
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
|
|
643
|
+
const conn = identifiedConn('alice');
|
|
644
|
+
const ctx = makeCtx(conn, services, new FakeClock(NOW), suffixConfig);
|
|
645
|
+
|
|
646
|
+
const out = hostservReducer(conn, privmsg('REQUEST hub.example.net'), ctx);
|
|
647
|
+
|
|
648
|
+
expect(services.getVhost('alice')).toBeUndefined();
|
|
649
|
+
expect(sentTexts(out.effects)).toContain(
|
|
650
|
+
":HostServ!HostServ@services NOTICE alice :Vhost hub.example.net is not permitted (reserved label or this server's hostname).",
|
|
651
|
+
);
|
|
652
|
+
});
|
|
653
|
+
|
|
654
|
+
it("denies a vhost ending with the server's own hostname", () => {
|
|
655
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
|
|
656
|
+
const conn = identifiedConn('alice');
|
|
657
|
+
const ctx = makeCtx(conn, services, new FakeClock(NOW), suffixConfig);
|
|
658
|
+
|
|
659
|
+
const out = hostservReducer(conn, privmsg('REQUEST user.hub.example.net'), ctx);
|
|
660
|
+
|
|
661
|
+
expect(services.getVhost('alice')).toBeUndefined();
|
|
662
|
+
expect(sentTexts(out.effects)).toContain(
|
|
663
|
+
":HostServ!HostServ@services NOTICE alice :Vhost user.hub.example.net is not permitted (reserved label or this server's hostname).",
|
|
664
|
+
);
|
|
665
|
+
});
|
|
666
|
+
|
|
667
|
+
it("denies the server's own hostname case-insensitively", () => {
|
|
668
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
|
|
669
|
+
const conn = identifiedConn('alice');
|
|
670
|
+
const ctx = makeCtx(conn, services, new FakeClock(NOW), {
|
|
671
|
+
...suffixConfig,
|
|
672
|
+
serverName: 'HUB.example.net',
|
|
673
|
+
});
|
|
674
|
+
|
|
675
|
+
hostservReducer(conn, privmsg('REQUEST hub.example.net'), ctx);
|
|
676
|
+
|
|
677
|
+
expect(services.getVhost('alice')).toBeUndefined();
|
|
678
|
+
});
|
|
679
|
+
});
|
|
680
|
+
|
|
681
|
+
describe('hostservReducer — REQUEST (auto-approve allowlist)', () => {
|
|
682
|
+
const allowConfig: ServerConfig = {
|
|
683
|
+
...serverConfig,
|
|
684
|
+
hostservAutoApproveVhosts: true,
|
|
685
|
+
hostservAutoApproveAllowlist: ['users.example.net', '*.vhosts.example.net'],
|
|
686
|
+
};
|
|
687
|
+
|
|
688
|
+
it('approves a vhost that exactly matches an allowlist entry', () => {
|
|
689
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
|
|
690
|
+
const conn = identifiedConn('alice');
|
|
691
|
+
const ctx = makeCtx(conn, services, new FakeClock(NOW), allowConfig);
|
|
692
|
+
|
|
693
|
+
const out = hostservReducer(conn, privmsg('REQUEST users.example.net'), ctx);
|
|
694
|
+
|
|
695
|
+
expect(services.getVhost('alice')).toBe('users.example.net');
|
|
696
|
+
expect(sentTexts(out.effects)).toContain(
|
|
697
|
+
':HostServ!HostServ@services NOTICE alice :Vhost users.example.net has been requested and approved.',
|
|
698
|
+
);
|
|
699
|
+
});
|
|
700
|
+
|
|
701
|
+
it('matches an exact allowlist entry case-insensitively', () => {
|
|
702
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
|
|
703
|
+
const conn = identifiedConn('alice');
|
|
704
|
+
const ctx = makeCtx(conn, services, new FakeClock(NOW), allowConfig);
|
|
705
|
+
|
|
706
|
+
hostservReducer(conn, privmsg('REQUEST USERS.Example.NET'), ctx);
|
|
707
|
+
|
|
708
|
+
expect(services.getVhost('alice')).toBe('USERS.Example.NET');
|
|
709
|
+
});
|
|
710
|
+
|
|
711
|
+
it('denies a vhost with no allowlist entry (nothing recorded)', () => {
|
|
712
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
|
|
713
|
+
const conn = identifiedConn('alice');
|
|
714
|
+
const ctx = makeCtx(conn, services, new FakeClock(NOW), allowConfig);
|
|
715
|
+
|
|
716
|
+
const out = hostservReducer(conn, privmsg('REQUEST other.example.net'), ctx);
|
|
717
|
+
|
|
718
|
+
expect(services.getVhost('alice')).toBeUndefined();
|
|
719
|
+
expect(services.getPendingVhost('alice')).toBeUndefined();
|
|
720
|
+
expect(sentTexts(out.effects)).toContain(
|
|
721
|
+
':HostServ!HostServ@services NOTICE alice :Vhost other.example.net is not in the auto-approve allowlist.',
|
|
722
|
+
);
|
|
723
|
+
});
|
|
724
|
+
|
|
725
|
+
it('approves a vhost covered by a wildcard entry (*.suffix)', () => {
|
|
726
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
|
|
727
|
+
const conn = identifiedConn('alice');
|
|
728
|
+
const ctx = makeCtx(conn, services, new FakeClock(NOW), allowConfig);
|
|
729
|
+
|
|
730
|
+
const out = hostservReducer(conn, privmsg('REQUEST a.vhosts.example.net'), ctx);
|
|
731
|
+
|
|
732
|
+
expect(services.getVhost('alice')).toBe('a.vhosts.example.net');
|
|
733
|
+
expect(sentTexts(out.effects)).toContain(
|
|
734
|
+
':HostServ!HostServ@services NOTICE alice :Vhost a.vhosts.example.net has been requested and approved.',
|
|
735
|
+
);
|
|
736
|
+
});
|
|
737
|
+
|
|
738
|
+
it('approves a vhost at any depth beneath a wildcard entry', () => {
|
|
739
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
|
|
740
|
+
const conn = identifiedConn('alice');
|
|
741
|
+
const ctx = makeCtx(conn, services, new FakeClock(NOW), allowConfig);
|
|
742
|
+
|
|
743
|
+
hostservReducer(conn, privmsg('REQUEST x.y.vhosts.example.net'), ctx);
|
|
744
|
+
|
|
745
|
+
expect(services.getVhost('alice')).toBe('x.y.vhosts.example.net');
|
|
746
|
+
});
|
|
747
|
+
|
|
748
|
+
it('matches a wildcard entry case-insensitively', () => {
|
|
749
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
|
|
750
|
+
const conn = identifiedConn('alice');
|
|
751
|
+
const ctx = makeCtx(conn, services, new FakeClock(NOW), allowConfig);
|
|
752
|
+
|
|
753
|
+
hostservReducer(conn, privmsg('REQUEST A.VHOSTS.Example.NET'), ctx);
|
|
754
|
+
|
|
755
|
+
expect(services.getVhost('alice')).toBe('A.VHOSTS.Example.NET');
|
|
756
|
+
});
|
|
757
|
+
|
|
758
|
+
it('does NOT match the bare suffix of a wildcard entry', () => {
|
|
759
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
|
|
760
|
+
const conn = identifiedConn('alice');
|
|
761
|
+
const ctx = makeCtx(conn, services, new FakeClock(NOW), allowConfig);
|
|
762
|
+
|
|
763
|
+
const out = hostservReducer(conn, privmsg('REQUEST vhosts.example.net'), ctx);
|
|
764
|
+
|
|
765
|
+
expect(services.getVhost('alice')).toBeUndefined();
|
|
766
|
+
expect(sentTexts(out.effects)).toContain(
|
|
767
|
+
':HostServ!HostServ@services NOTICE alice :Vhost vhosts.example.net is not in the auto-approve allowlist.',
|
|
768
|
+
);
|
|
769
|
+
});
|
|
770
|
+
|
|
771
|
+
it('checks every allowlist entry until one matches', () => {
|
|
772
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
|
|
773
|
+
const conn = identifiedConn('alice');
|
|
774
|
+
const cfg: ServerConfig = {
|
|
775
|
+
...serverConfig,
|
|
776
|
+
hostservAutoApproveVhosts: true,
|
|
777
|
+
hostservAutoApproveAllowlist: ['first.example.net', 'users.example.net'],
|
|
778
|
+
};
|
|
779
|
+
const ctx = makeCtx(conn, services, new FakeClock(NOW), cfg);
|
|
780
|
+
|
|
781
|
+
hostservReducer(conn, privmsg('REQUEST users.example.net'), ctx);
|
|
782
|
+
|
|
783
|
+
expect(services.getVhost('alice')).toBe('users.example.net');
|
|
784
|
+
});
|
|
785
|
+
|
|
786
|
+
it('fails closed when the allowlist is missing entirely (inline literal)', () => {
|
|
787
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
|
|
788
|
+
const conn = identifiedConn('alice');
|
|
789
|
+
// A booted server can never reach this shape (config parsing refuses
|
|
790
|
+
// auto-approve without an allowlist); the reducer must still deny
|
|
791
|
+
// everything rather than approve everything.
|
|
792
|
+
const ctx = makeCtx(conn, services, new FakeClock(NOW), {
|
|
793
|
+
...serverConfig,
|
|
794
|
+
hostservAutoApproveVhosts: true,
|
|
795
|
+
});
|
|
796
|
+
|
|
797
|
+
const out = hostservReducer(conn, privmsg('REQUEST clean.example.net'), ctx);
|
|
798
|
+
|
|
799
|
+
expect(services.getVhost('alice')).toBeUndefined();
|
|
800
|
+
expect(sentTexts(out.effects)).toContain(
|
|
801
|
+
':HostServ!HostServ@services NOTICE alice :Vhost clean.example.net is not in the auto-approve allowlist.',
|
|
802
|
+
);
|
|
803
|
+
});
|
|
804
|
+
});
|
|
805
|
+
|
|
806
|
+
describe('hostservReducer — hardening does not apply to oper review', () => {
|
|
807
|
+
it('queues a reserved-label vhost in queue mode (opers vet it)', () => {
|
|
808
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
|
|
809
|
+
const conn = identifiedConn('alice');
|
|
810
|
+
const ctx = makeCtx(conn, services, new FakeClock(NOW), queueServerConfig);
|
|
811
|
+
|
|
812
|
+
const out = hostservReducer(conn, privmsg('REQUEST admin.example.net'), ctx);
|
|
813
|
+
|
|
814
|
+
expect(services.getPendingVhost('alice')).toBe('admin.example.net');
|
|
815
|
+
expect(services.getVhost('alice')).toBeUndefined();
|
|
816
|
+
expect(sentTexts(out.effects)).toContain(
|
|
817
|
+
':HostServ!HostServ@services NOTICE alice :Vhost admin.example.net is pending oper approval.',
|
|
818
|
+
);
|
|
819
|
+
});
|
|
820
|
+
|
|
821
|
+
it('queues a vhost outside the allowlist in queue mode', () => {
|
|
822
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
|
|
823
|
+
const conn = identifiedConn('alice');
|
|
824
|
+
const cfg: ServerConfig = {
|
|
825
|
+
...queueServerConfig,
|
|
826
|
+
hostservAutoApproveAllowlist: ['users.example.net'],
|
|
827
|
+
};
|
|
828
|
+
const ctx = makeCtx(conn, services, new FakeClock(NOW), cfg);
|
|
829
|
+
|
|
830
|
+
const out = hostservReducer(conn, privmsg('REQUEST other.example.net'), ctx);
|
|
831
|
+
|
|
832
|
+
expect(services.getPendingVhost('alice')).toBe('other.example.net');
|
|
833
|
+
expect(sentTexts(out.effects)).toContain(
|
|
834
|
+
':HostServ!HostServ@services NOTICE alice :Vhost other.example.net is pending oper approval.',
|
|
835
|
+
);
|
|
836
|
+
});
|
|
837
|
+
|
|
838
|
+
it('lets an oper APPROVE a reserved-label vhost from the queue', () => {
|
|
839
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
|
|
840
|
+
services.requestVhost('alice', 'admin.example.net');
|
|
841
|
+
const conn = operConn('admin');
|
|
842
|
+
const ctx = makeCtx(conn, services);
|
|
843
|
+
|
|
844
|
+
const out = hostservReducer(conn, privmsg('APPROVE alice'), ctx);
|
|
845
|
+
|
|
846
|
+
expect(services.getVhost('alice')).toBe('admin.example.net');
|
|
847
|
+
expect(sentTexts(out.effects)).toContain(
|
|
848
|
+
':HostServ!HostServ@services NOTICE admin :Vhost admin.example.net for alice has been approved and activated.',
|
|
849
|
+
);
|
|
850
|
+
});
|
|
851
|
+
|
|
852
|
+
it('lets an oper SET a reserved-label vhost directly', () => {
|
|
853
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
|
|
854
|
+
services.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
855
|
+
const conn = operConn('admin');
|
|
856
|
+
const ctx = makeCtx(conn, services);
|
|
857
|
+
|
|
858
|
+
const out = hostservReducer(conn, privmsg('SET alice staff.example.net'), ctx);
|
|
859
|
+
|
|
860
|
+
expect(services.getVhost('alice')).toBe('staff.example.net');
|
|
861
|
+
expect(sentTexts(out.effects)).toContain(
|
|
862
|
+
':HostServ!HostServ@services NOTICE admin :Vhost for alice is now set to staff.example.net.',
|
|
863
|
+
);
|
|
864
|
+
});
|
|
865
|
+
});
|
|
866
|
+
|
|
551
867
|
// ============================================================================
|
|
552
868
|
// APPROVE / ACTIVATE (oper-only)
|
|
553
869
|
// ============================================================================
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import { handleJoinZero,
|
|
2
|
+
import { handleJoinZero, joinReducer } from '../../src/commands/join';
|
|
3
3
|
import { Effect } from '../../src/effects';
|
|
4
4
|
import type { Effect as EffectType, RawLine } from '../../src/effects';
|
|
5
5
|
import {
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
type ServicesStore,
|
|
13
13
|
type StoredMessage,
|
|
14
14
|
} from '../../src/ports';
|
|
15
|
+
import { isValidChannelName } from '../../src/protocol/channel-name';
|
|
15
16
|
import { type ChannelState, createChannel } from '../../src/state/channel';
|
|
16
17
|
import { type ConnectionState, createConnection } from '../../src/state/connection';
|
|
17
18
|
import { type Ctx, type ServerConfig, buildCtx } from '../../src/types';
|
|
@@ -107,6 +108,30 @@ describe('isValidChannelName', () => {
|
|
|
107
108
|
expect(isValidChannelName('#foo:bar', 50)).toBe(false);
|
|
108
109
|
});
|
|
109
110
|
|
|
111
|
+
it('rejects a name containing NUL', () => {
|
|
112
|
+
expect(isValidChannelName('#foo\0bar', 50)).toBe(false);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('rejects a name containing a control character', () => {
|
|
116
|
+
expect(isValidChannelName('#foo\x01bar', 50)).toBe(false);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('rejects a multi-byte name whose UTF-8 byte size exceeds the cap', () => {
|
|
120
|
+
expect(isValidChannelName(`#${'é'.repeat(25)}`, 50)).toBe(false); // 51 bytes, 26 UTF-16 units
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('accepts a multi-byte name at exactly the UTF-8 byte cap', () => {
|
|
124
|
+
expect(isValidChannelName(`#${'é'.repeat(24)}x`, 50)).toBe(true); // 1 + 48 + 1 = 50 bytes
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('continues to accept ! in a channel name', () => {
|
|
128
|
+
expect(isValidChannelName('#foo!bar', 50)).toBe(true);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('continues to accept @ in a channel name', () => {
|
|
132
|
+
expect(isValidChannelName('#foo@bar', 50)).toBe(true);
|
|
133
|
+
});
|
|
134
|
+
|
|
110
135
|
it('rejects a name exceeding the length cap', () => {
|
|
111
136
|
expect(isValidChannelName(`#${'a'.repeat(50)}`, 50)).toBe(false);
|
|
112
137
|
});
|
|
@@ -395,6 +420,58 @@ describe('joinReducer — rejections', () => {
|
|
|
395
420
|
]);
|
|
396
421
|
});
|
|
397
422
|
|
|
423
|
+
it('emits 403 ERR_NOSUCHCHANNEL for a channel name containing NUL', () => {
|
|
424
|
+
const chan = makeChan('#foo');
|
|
425
|
+
const conn = makeConn();
|
|
426
|
+
const ctx = makeCtx(conn);
|
|
427
|
+
|
|
428
|
+
const out = joinReducer(chan, { command: 'JOIN', params: ['#foo\0bar'], tags: {} }, ctx);
|
|
429
|
+
|
|
430
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
431
|
+
Effect.send('c1', [L(':irc.example.com 403 alice #foo\0bar :No such channel')]),
|
|
432
|
+
]);
|
|
433
|
+
expect(out.state.members.has('c1')).toBe(false);
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
it('emits 403 ERR_NOSUCHCHANNEL for a channel name containing a control character', () => {
|
|
437
|
+
const chan = makeChan('#foo');
|
|
438
|
+
const conn = makeConn();
|
|
439
|
+
const ctx = makeCtx(conn);
|
|
440
|
+
|
|
441
|
+
const out = joinReducer(chan, { command: 'JOIN', params: ['#foo\x01bar'], tags: {} }, ctx);
|
|
442
|
+
|
|
443
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
444
|
+
Effect.send('c1', [L(':irc.example.com 403 alice #foo\x01bar :No such channel')]),
|
|
445
|
+
]);
|
|
446
|
+
expect(out.state.members.has('c1')).toBe(false);
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
it('emits 403 ERR_NOSUCHCHANNEL for a channel name whose UTF-8 byte size exceeds the cap', () => {
|
|
450
|
+
const long = `#${'é'.repeat(25)}`; // 51 UTF-8 bytes, only 26 UTF-16 units
|
|
451
|
+
const chan = makeChan(long);
|
|
452
|
+
const conn = makeConn();
|
|
453
|
+
const ctx = makeCtx(conn);
|
|
454
|
+
|
|
455
|
+
const out = joinReducer(chan, { command: 'JOIN', params: [long], tags: {} }, ctx);
|
|
456
|
+
|
|
457
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
458
|
+
Effect.send('c1', [L(`:irc.example.com 403 alice ${long} :No such channel`)]),
|
|
459
|
+
]);
|
|
460
|
+
expect(out.state.members.has('c1')).toBe(false);
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
it('joins a multi-byte channel name at exactly the 50-byte cap', () => {
|
|
464
|
+
const name = `#${'é'.repeat(24)}x`; // 1 + 48 + 1 = 50 UTF-8 bytes
|
|
465
|
+
const chan = makeChan(name);
|
|
466
|
+
const conn = makeConn();
|
|
467
|
+
const ctx = makeCtx(conn);
|
|
468
|
+
|
|
469
|
+
const out = joinReducer(chan, { command: 'JOIN', params: [name], tags: {} }, ctx);
|
|
470
|
+
|
|
471
|
+
expect(out.state.members.has('c1')).toBe(true);
|
|
472
|
+
expect(conn.joinedChannels.has(name.toLowerCase())).toBe(true);
|
|
473
|
+
});
|
|
474
|
+
|
|
398
475
|
it('emits 405 ERR_TOOMANYCHANNELS when the connection is at the max-channels limit', () => {
|
|
399
476
|
const chan = makeChan('#newchan');
|
|
400
477
|
const conn = makeConn();
|
|
@@ -115,6 +115,24 @@ describe('markreadChannelReducer — cap gating', () => {
|
|
|
115
115
|
Effect.send('c1', [L(':irc.example.com 421 alice MARKREAD :Unknown command')]),
|
|
116
116
|
]);
|
|
117
117
|
});
|
|
118
|
+
|
|
119
|
+
it('substitutes * for the nick in the 421 numeric when the connection has no nick', () => {
|
|
120
|
+
const chan = makeChan();
|
|
121
|
+
// A bare connection: no nick set and no draft/read-marker cap negotiated.
|
|
122
|
+
// The 421 numeric's `ctx.connection.nick ?? '*'` falls back to `*`.
|
|
123
|
+
const conn = createConnection({ id: 'c1', connectedSince: 0 });
|
|
124
|
+
const ctx = makeCtx(conn);
|
|
125
|
+
|
|
126
|
+
const out = markreadChannelReducer(
|
|
127
|
+
chan,
|
|
128
|
+
{ command: 'MARKREAD', params: ['#foo', ts(2_000)], tags: {} },
|
|
129
|
+
ctx,
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
133
|
+
Effect.send('c1', [L(':irc.example.com 421 * MARKREAD :Unknown command')]),
|
|
134
|
+
]);
|
|
135
|
+
});
|
|
118
136
|
});
|
|
119
137
|
|
|
120
138
|
// ============================================================================
|
|
@@ -168,6 +186,42 @@ describe('markreadChannelReducer — parameter errors', () => {
|
|
|
168
186
|
Effect.send('c1', [READ_MARKER_FAIL('INVALID_PARAMS', 'Invalid parameters')]),
|
|
169
187
|
]);
|
|
170
188
|
});
|
|
189
|
+
|
|
190
|
+
it('FAILs with INVALID_PARAMS when the timestamp prefix is present but the value is empty', () => {
|
|
191
|
+
const chan = makeChan();
|
|
192
|
+
addMember(chan, 'c1', 'alice');
|
|
193
|
+
const store = new InMemoryMessageStore();
|
|
194
|
+
seed(store, '#foo', 3);
|
|
195
|
+
const ctx = makeCtx(makeConn(), new FakeClock(10_000), store);
|
|
196
|
+
|
|
197
|
+
const out = markreadChannelReducer(
|
|
198
|
+
chan,
|
|
199
|
+
{ command: 'MARKREAD', params: ['#foo', 'timestamp='], tags: {} },
|
|
200
|
+
ctx,
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
204
|
+
Effect.send('c1', [READ_MARKER_FAIL('INVALID_PARAMS', 'Invalid parameters')]),
|
|
205
|
+
]);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('FAILs with INVALID_PARAMS when the timestamp value is not a parseable date', () => {
|
|
209
|
+
const chan = makeChan();
|
|
210
|
+
addMember(chan, 'c1', 'alice');
|
|
211
|
+
const store = new InMemoryMessageStore();
|
|
212
|
+
seed(store, '#foo', 3);
|
|
213
|
+
const ctx = makeCtx(makeConn(), new FakeClock(10_000), store);
|
|
214
|
+
|
|
215
|
+
const out = markreadChannelReducer(
|
|
216
|
+
chan,
|
|
217
|
+
{ command: 'MARKREAD', params: ['#foo', 'timestamp=not-a-date'], tags: {} },
|
|
218
|
+
ctx,
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
222
|
+
Effect.send('c1', [READ_MARKER_FAIL('INVALID_PARAMS', 'Invalid parameters')]),
|
|
223
|
+
]);
|
|
224
|
+
});
|
|
171
225
|
});
|
|
172
226
|
|
|
173
227
|
// ============================================================================
|
|
@@ -71,6 +71,25 @@ describe('memoservReducer — services availability', () => {
|
|
|
71
71
|
});
|
|
72
72
|
});
|
|
73
73
|
|
|
74
|
+
describe('memoservReducer — empty body', () => {
|
|
75
|
+
it('replies with the help notice when the PRIVMSG carries no body parameter', () => {
|
|
76
|
+
const conn = makeConn();
|
|
77
|
+
const services = new InMemoryServicesStore();
|
|
78
|
+
const ctx = makeCtx(conn, services);
|
|
79
|
+
// params: [MEMOSERV_NICK] only — params[1] is undefined, exercising the
|
|
80
|
+
// `msg.params[1] ?? ''` fallback. The empty body resolves to an empty
|
|
81
|
+
// subcommand, which emits the help notice.
|
|
82
|
+
const out = memoservReducer(
|
|
83
|
+
conn,
|
|
84
|
+
{ command: 'PRIVMSG', params: [MEMOSERV_NICK], tags: {} },
|
|
85
|
+
ctx,
|
|
86
|
+
);
|
|
87
|
+
expect(sentTexts(out.effects)).toContain(
|
|
88
|
+
':MemoServ!MemoServ@services NOTICE alice :Available commands: SEND, LIST, READ, DEL',
|
|
89
|
+
);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
74
93
|
describe('memoservReducer — SEND', () => {
|
|
75
94
|
it('requires the sender to be identified', () => {
|
|
76
95
|
const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
|