serverless-ircd 0.4.0 → 0.6.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 +34 -2
- package/.github/workflows/deploy-aws.yml +1 -2
- package/.github/workflows/deploy-cf-tcp.yml +1 -5
- package/.github/workflows/deploy-cf.yml +1 -2
- package/CHANGELOG.md +435 -0
- package/README.md +206 -27
- package/apps/aws-stack/README.md +37 -3
- package/apps/aws-stack/package.json +1 -1
- package/apps/aws-stack/src/aws-stack.ts +106 -13
- package/apps/aws-stack/tests/coverage-command.test.ts +155 -0
- package/apps/aws-stack/tests/stack.test.ts +49 -3
- package/apps/aws-stack/tests/synth-no-bundle.test.ts +129 -0
- package/apps/aws-stack/tests/vitest.setup.ts +18 -0
- package/apps/aws-stack/vitest.config.ts +3 -0
- package/apps/cf-tcp-container/package.json +1 -1
- package/apps/cf-tcp-container/src/config-loader.ts +6 -7
- package/apps/cf-tcp-container/src/container-server.ts +3 -0
- package/apps/cf-tcp-container/tests/config-loader.test.ts +10 -3
- package/apps/cf-tcp-container/wrangler.toml +1 -1
- package/apps/cf-worker/package.json +2 -1
- package/apps/cf-worker/vitest.config.ts +1 -1
- package/apps/cf-worker/wrangler.toml +5 -3
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/config-loader.ts +6 -6
- package/apps/local-cli/src/main.ts +20 -0
- package/apps/local-cli/src/server.ts +138 -28
- package/apps/local-cli/tests/e2e.test.ts +113 -29
- package/apps/local-cli/tests/tcp.test.ts +71 -19
- package/apps/local-cli/tests/ws-subprotocol.test.ts +262 -0
- package/biome.json +1 -0
- package/docs/ADR-001-pure-reducers-and-effect-system.md +74 -0
- package/docs/ADR-002-location-of-authority.md +82 -0
- package/docs/ADR-003-durable-object-sharding.md +110 -0
- package/docs/ADR-004-dynamodb-schema.md +96 -0
- package/docs/ADR-005-wss-only-transport-v1.md +83 -0
- package/docs/ADR-006-sasl-mechanism-scope.md +96 -0
- package/docs/ADR-007-deterministic-ports.md +82 -0
- package/docs/ADR-008-monorepo-tooling.md +60 -0
- package/docs/ADR-009-dual-transport-wss-and-irc-tls.md +138 -0
- package/docs/ADR-Index.md +15 -0
- package/docs/AWS-Adapter-Architecture.md +496 -0
- package/docs/AWS-Deployment.md +1275 -0
- package/docs/AWS-TCP-Deployment.md +410 -0
- package/docs/Cloudflare-Deployment-Guide.md +719 -0
- package/docs/Cloudflare-TCP-Deployment.md +510 -0
- package/docs/Home.md +15 -0
- package/docs/Observability.md +87 -0
- package/docs/PlanExtensions.md +812 -0
- package/docs/PlanIRCv3Websocket.md +503 -0
- package/docs/PlanWebClient.md +451 -0
- package/docs/Release-Process.md +443 -0
- package/package.json +2 -2
- package/packages/aws-adapter/package.json +1 -1
- package/packages/aws-adapter/src/aws-runtime.ts +69 -0
- package/packages/aws-adapter/src/handlers/connect.ts +36 -5
- package/packages/aws-adapter/src/handlers/default.ts +66 -5
- package/packages/aws-adapter/src/handlers/index.ts +41 -2
- package/packages/aws-adapter/src/handlers/nlb-stream.ts +18 -0
- package/packages/aws-adapter/src/index.ts +2 -0
- package/packages/aws-adapter/src/serialize.ts +40 -2
- package/packages/aws-adapter/src/stats.ts +80 -0
- package/packages/aws-adapter/tests/account-store-dynamo.test.ts +49 -8
- package/packages/aws-adapter/tests/account-store.test.ts +4 -7
- package/packages/aws-adapter/tests/aws-harness.ts +5 -2
- package/packages/aws-adapter/tests/aws-integration.test.ts +8 -4
- package/packages/aws-adapter/tests/aws-runtime.test.ts +140 -0
- package/packages/aws-adapter/tests/config-loader.test.ts +11 -2
- package/packages/aws-adapter/tests/connect.test.ts +100 -4
- package/packages/aws-adapter/tests/disconnect-fanout.test.ts +3 -4
- package/packages/aws-adapter/tests/global-setup.ts +55 -30
- package/packages/aws-adapter/tests/gone-exception.test.ts +4 -9
- package/packages/aws-adapter/tests/handlers.test.ts +238 -4
- package/packages/aws-adapter/tests/message-store.test.ts +6 -5
- package/packages/aws-adapter/tests/nlb-stream.test.ts +62 -8
- package/packages/aws-adapter/tests/stats.test.ts +317 -0
- package/packages/cf-adapter/package.json +6 -1
- package/packages/cf-adapter/src/cf-runtime.ts +66 -1
- package/packages/cf-adapter/src/channel-do.ts +2 -2
- package/packages/cf-adapter/src/connection-do.ts +185 -54
- package/packages/cf-adapter/src/env.ts +25 -6
- package/packages/cf-adapter/src/index.ts +2 -0
- package/packages/cf-adapter/src/registry-do.ts +22 -3
- package/packages/cf-adapter/src/serialize.ts +25 -4
- package/packages/cf-adapter/src/sharding.ts +1 -2
- package/packages/cf-adapter/src/stats.ts +65 -0
- package/packages/cf-adapter/tests/cf-harness.ts +1 -1
- package/packages/cf-adapter/tests/cf-integration.test.ts +4 -4
- package/packages/cf-adapter/tests/cf-runtime.test.ts +307 -2
- package/packages/cf-adapter/tests/channel-do.test.ts +119 -2
- package/packages/cf-adapter/tests/config-loader.test.ts +49 -0
- package/packages/cf-adapter/tests/connection-do-channel-registration.test.ts +2 -2
- package/packages/cf-adapter/tests/connection-do-no-batching-reservation.test.ts +2 -2
- package/packages/cf-adapter/tests/connection-do-pure.test.ts +91 -0
- package/packages/cf-adapter/tests/connection-do-ws-spec-contract.test.ts +289 -0
- package/packages/cf-adapter/tests/connection-do-ws-subprotocol.test.ts +184 -0
- package/packages/cf-adapter/tests/connection-do.test.ts +27 -2
- package/packages/cf-adapter/tests/d1-account-store.test.ts +48 -0
- package/packages/cf-adapter/tests/registry-do.test.ts +108 -4
- package/packages/cf-adapter/tests/serialize.test.ts +308 -0
- package/packages/cf-adapter/tests/sharding.test.ts +1 -1
- package/packages/cf-adapter/tests/stats.test.ts +120 -0
- package/packages/cf-adapter/tests/worker/main.ts +7 -7
- package/packages/cf-adapter/tests/worker/stubs/channel-stub.ts +2 -2
- package/packages/cf-adapter/tests/worker/stubs/registry-stub.ts +8 -2
- package/packages/cf-adapter/vitest.config.ts +1 -1
- package/packages/cf-adapter/wrangler.test.toml +7 -0
- package/packages/in-memory-runtime/package.json +1 -1
- package/packages/in-memory-runtime/src/in-memory-runtime.ts +39 -0
- package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +259 -0
- package/packages/irc-core/package.json +1 -1
- package/packages/irc-core/src/admission.ts +16 -15
- package/packages/irc-core/src/caps/capabilities.ts +38 -4
- package/packages/irc-core/src/caps/index.ts +1 -0
- package/packages/irc-core/src/caps/sts.ts +84 -0
- package/packages/irc-core/src/commands/account-notify.ts +53 -0
- package/packages/irc-core/src/commands/away.ts +9 -3
- package/packages/irc-core/src/commands/cap.ts +23 -5
- package/packages/irc-core/src/commands/chathistory.ts +40 -11
- package/packages/irc-core/src/commands/index.ts +42 -0
- package/packages/irc-core/src/commands/invite.ts +2 -4
- package/packages/irc-core/src/commands/isupport.ts +59 -2
- package/packages/irc-core/src/commands/kick.ts +2 -4
- package/packages/irc-core/src/commands/kill.ts +127 -0
- package/packages/irc-core/src/commands/list.ts +1 -1
- package/packages/irc-core/src/commands/lusers.ts +204 -0
- package/packages/irc-core/src/commands/mode.ts +12 -9
- package/packages/irc-core/src/commands/monitor.ts +327 -0
- package/packages/irc-core/src/commands/multiline.ts +256 -0
- package/packages/irc-core/src/commands/names.ts +3 -5
- package/packages/irc-core/src/commands/part.ts +2 -4
- package/packages/irc-core/src/commands/pre-away.ts +112 -0
- package/packages/irc-core/src/commands/privmsg.ts +19 -12
- package/packages/irc-core/src/commands/read-marker.ts +108 -0
- package/packages/irc-core/src/commands/registration.ts +8 -0
- package/packages/irc-core/src/commands/rehash.ts +119 -0
- package/packages/irc-core/src/commands/sasl.ts +24 -1
- package/packages/irc-core/src/commands/setname.ts +109 -0
- package/packages/irc-core/src/commands/stats.ts +152 -0
- package/packages/irc-core/src/commands/tagmsg.ts +71 -1
- package/packages/irc-core/src/commands/topic.ts +2 -4
- package/packages/irc-core/src/commands/trace.ts +137 -0
- package/packages/irc-core/src/commands/wallops.ts +118 -0
- package/packages/irc-core/src/commands/whois.ts +5 -0
- package/packages/irc-core/src/config.ts +72 -10
- package/packages/irc-core/src/effects.ts +41 -1
- package/packages/irc-core/src/index.ts +2 -0
- package/packages/irc-core/src/ports.ts +568 -0
- package/packages/irc-core/src/protocol/index.ts +14 -0
- package/packages/irc-core/src/protocol/labeled-response.ts +133 -0
- package/packages/irc-core/src/protocol/numerics.ts +57 -11
- package/packages/irc-core/src/protocol/outbound.ts +36 -4
- package/packages/irc-core/src/protocol/standard-replies.ts +277 -0
- package/packages/irc-core/src/state/connection.ts +32 -1
- package/packages/irc-core/src/types.ts +120 -1
- package/packages/irc-core/src/ws-framing.ts +132 -0
- package/packages/irc-core/src/ws-subprotocol.ts +66 -0
- package/packages/irc-core/stryker.commands.conf.json +1 -2
- package/packages/irc-core/tests/admission.test.ts +18 -0
- package/packages/irc-core/tests/away-store.test.ts +73 -0
- package/packages/irc-core/tests/caps/capabilities.test.ts +119 -2
- package/packages/irc-core/tests/caps/sts.test.ts +123 -0
- package/packages/irc-core/tests/commands/account-notify.test.ts +171 -0
- package/packages/irc-core/tests/commands/away.test.ts +85 -2
- package/packages/irc-core/tests/commands/cap.test.ts +165 -0
- package/packages/irc-core/tests/commands/chathistory.test.ts +148 -1
- package/packages/irc-core/tests/commands/echo-message.test.ts +9 -3
- package/packages/irc-core/tests/commands/isupport.test.ts +80 -0
- package/packages/irc-core/tests/commands/kill.test.ts +243 -0
- package/packages/irc-core/tests/commands/lusers.test.ts +368 -0
- package/packages/irc-core/tests/commands/mode.test.ts +129 -0
- package/packages/irc-core/tests/commands/monitor.test.ts +411 -0
- package/packages/irc-core/tests/commands/multiline.test.ts +428 -0
- package/packages/irc-core/tests/commands/pre-away.test.ts +241 -0
- package/packages/irc-core/tests/commands/privmsg.test.ts +154 -3
- package/packages/irc-core/tests/commands/read-marker.test.ts +211 -0
- package/packages/irc-core/tests/commands/registration.test.ts +64 -0
- package/packages/irc-core/tests/commands/rehash.test.ts +171 -0
- package/packages/irc-core/tests/commands/sasl.test.ts +116 -2
- package/packages/irc-core/tests/commands/setname.test.ts +225 -0
- package/packages/irc-core/tests/commands/stats.test.ts +294 -0
- package/packages/irc-core/tests/commands/tagmsg.test.ts +357 -0
- package/packages/irc-core/tests/commands/trace.test.ts +282 -0
- package/packages/irc-core/tests/commands/wallops.test.ts +231 -0
- package/packages/irc-core/tests/commands/whois.test.ts +34 -0
- package/packages/irc-core/tests/config.test.ts +103 -13
- package/packages/irc-core/tests/dropped-s2s-and-obsolete-verbs.test.ts +90 -0
- package/packages/irc-core/tests/effects.test.ts +14 -0
- package/packages/irc-core/tests/labeled-response.test.ts +181 -0
- package/packages/irc-core/tests/message-tags.test.ts +114 -0
- package/packages/irc-core/tests/monitor-store.test.ts +200 -0
- package/packages/irc-core/tests/numerics.test.ts +90 -0
- package/packages/irc-core/tests/outbound.test.ts +51 -0
- package/packages/irc-core/tests/ports.test.ts +22 -0
- package/packages/irc-core/tests/raw-modules.d.ts +11 -0
- package/packages/irc-core/tests/read-marker-store.test.ts +108 -0
- package/packages/irc-core/tests/standard-replies.test.ts +412 -0
- package/packages/irc-core/tests/stats-store.test.ts +222 -0
- package/packages/irc-core/tests/types.test.ts +27 -0
- package/packages/irc-core/tests/ws-framing.test.ts +213 -0
- package/packages/irc-core/tests/ws-subprotocol.test.ts +111 -0
- package/packages/irc-server/package.json +1 -1
- package/packages/irc-server/src/actor.ts +699 -19
- package/packages/irc-server/src/dispatch.ts +109 -16
- package/packages/irc-server/src/routing.ts +3 -0
- package/packages/irc-server/src/runtime.ts +31 -0
- package/packages/irc-server/src/transport.ts +10 -7
- package/packages/irc-server/tests/actor.test.ts +2523 -42
- package/packages/irc-server/tests/dispatch.test.ts +300 -2
- package/packages/irc-server/tests/raw-modules.d.ts +11 -0
- package/packages/irc-server/tests/routing.test.ts +1 -0
- package/packages/irc-server/tests/runtime.test.ts +7 -0
- package/packages/irc-test-support/package.json +1 -1
- package/packages/irc-test-support/src/index.ts +6 -0
- package/packages/irc-test-support/src/scenarios.ts +9 -1
- package/packages/irc-test-support/src/test-config.ts +54 -0
- package/packages/irc-test-support/tests/in-memory-scenarios.test.ts +1 -1
- package/packages/irc-test-support/tests/test-config.test.ts +51 -0
- package/pnpm-workspace.yaml +1 -0
- package/tools/ci-hardening/package.json +1 -1
- package/tools/ci-hardening/tests/ddb-local.test.ts +82 -0
- package/tools/load-test/package.json +33 -0
- package/tools/load-test/src/client.ts +351 -0
- package/tools/load-test/src/config.ts +313 -0
- package/tools/load-test/src/harness.ts +116 -0
- package/tools/load-test/src/main.ts +120 -0
- package/tools/load-test/src/metrics.ts +168 -0
- package/tools/load-test/src/report.ts +106 -0
- package/tools/load-test/tests/client.test.ts +212 -0
- package/tools/load-test/tests/config.test.ts +152 -0
- package/tools/load-test/tests/framing.test.ts +37 -0
- package/tools/load-test/tests/harness.test.ts +165 -0
- package/tools/load-test/tests/metrics.test.ts +174 -0
- package/tools/load-test/tests/report.test.ts +161 -0
- package/tools/load-test/tests/smoke.test.ts +67 -0
- package/tools/load-test/tsconfig.build.json +12 -0
- package/tools/load-test/tsconfig.test.json +10 -0
- package/tools/load-test/vitest.config.ts +29 -0
- package/tools/package.json +6 -1
- package/tools/seed-cf-accounts.ts +4 -1
- package/tools/tcp-ws-forwarder/package.json +1 -1
- package/tools/tcp-ws-forwarder/src/forwarder.ts +57 -9
- package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +34 -1
- package/tools/tcp-ws-forwarder/tests/framing.test.ts +65 -1
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
DEFAULT_STS_DURATION_SECONDS,
|
|
4
|
+
DEFAULT_STS_PORT,
|
|
5
|
+
STS_CAP_NAME,
|
|
6
|
+
stsCapValue,
|
|
7
|
+
} from '../../src/caps/sts';
|
|
8
|
+
import type { StsPolicy } from '../../src/caps/sts';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* IRCv3 `sts` (Strict Transport Security) policy builder.
|
|
12
|
+
*
|
|
13
|
+
* Spec: https://ircv3.net/specs/extensions/sts
|
|
14
|
+
*
|
|
15
|
+
* On a plaintext connection the server advertises the full policy
|
|
16
|
+
* (`duration=…,port=…` plus optional `preload`) so compliant clients
|
|
17
|
+
* reconnect over TLS. On a TLS connection only `duration` is sent —
|
|
18
|
+
* the port is meaningless once the client is already on the secure
|
|
19
|
+
* listener, and `preload` is only meaningful as an upgrade directive.
|
|
20
|
+
*/
|
|
21
|
+
describe('stsCapValue', () => {
|
|
22
|
+
describe('STS_CAP_NAME', () => {
|
|
23
|
+
it('exposes the IRCv3 wire name', () => {
|
|
24
|
+
expect(STS_CAP_NAME).toBe('sts');
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe('plaintext connection (secure=false)', () => {
|
|
29
|
+
it('builds duration+port from the supplied config', () => {
|
|
30
|
+
const cfg: StsPolicy = { duration: 600, port: 6697 };
|
|
31
|
+
expect(stsCapValue(cfg, false)).toBe('duration=600,port=6697');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('appends `preload` when preload=true', () => {
|
|
35
|
+
const cfg: StsPolicy = { duration: 600, port: 6697, preload: true };
|
|
36
|
+
expect(stsCapValue(cfg, false)).toBe('duration=600,port=6697,preload');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('omits `preload` when preload=false', () => {
|
|
40
|
+
const cfg: StsPolicy = { duration: 600, port: 6697, preload: false };
|
|
41
|
+
expect(stsCapValue(cfg, false)).toBe('duration=600,port=6697');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('omits `preload` when preload is undefined', () => {
|
|
45
|
+
const cfg: StsPolicy = { duration: 600, port: 6697 };
|
|
46
|
+
expect(stsCapValue(cfg, false)).toBe('duration=600,port=6697');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('honours an overridden port', () => {
|
|
50
|
+
const cfg: StsPolicy = { duration: 600, port: 7000 };
|
|
51
|
+
expect(stsCapValue(cfg, false)).toBe('duration=600,port=7000');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('honours an overridden duration', () => {
|
|
55
|
+
const cfg: StsPolicy = { duration: 86_400, port: 6697 };
|
|
56
|
+
expect(stsCapValue(cfg, false)).toBe('duration=86400,port=6697');
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe('TLS connection (secure=true)', () => {
|
|
61
|
+
it('advertises only the duration (port is meaningless on TLS)', () => {
|
|
62
|
+
const cfg: StsPolicy = { duration: 600, port: 6697 };
|
|
63
|
+
expect(stsCapValue(cfg, true)).toBe('duration=600');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('omits preload on TLS (upgrade-only directive)', () => {
|
|
67
|
+
const cfg: StsPolicy = { duration: 600, port: 6697, preload: true };
|
|
68
|
+
expect(stsCapValue(cfg, true)).toBe('duration=600');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('honours an overridden duration on TLS', () => {
|
|
72
|
+
const cfg: StsPolicy = { duration: 86_400, port: 6697 };
|
|
73
|
+
expect(stsCapValue(cfg, true)).toBe('duration=86400');
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe('disabled (no policy configured)', () => {
|
|
78
|
+
it('returns undefined when config is undefined', () => {
|
|
79
|
+
expect(stsCapValue(undefined, false)).toBeUndefined();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('returns undefined when config is undefined on a TLS connection', () => {
|
|
83
|
+
expect(stsCapValue(undefined, true)).toBeUndefined();
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe('field defaults', () => {
|
|
88
|
+
it('falls back to DEFAULT_STS_DURATION_SECONDS when duration is omitted (plaintext)', () => {
|
|
89
|
+
const value = stsCapValue({ port: 6697 }, false);
|
|
90
|
+
expect(value).toBe(`duration=${DEFAULT_STS_DURATION_SECONDS},port=6697`);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('falls back to DEFAULT_STS_DURATION_SECONDS when duration is omitted (TLS)', () => {
|
|
94
|
+
const value = stsCapValue({ port: 6697 }, true);
|
|
95
|
+
expect(value).toBe(`duration=${DEFAULT_STS_DURATION_SECONDS}`);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('falls back to DEFAULT_STS_PORT when port is omitted (plaintext)', () => {
|
|
99
|
+
const value = stsCapValue({ duration: 600 }, false);
|
|
100
|
+
expect(value).toBe(`duration=600,port=${DEFAULT_STS_PORT}`);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('uses both defaults when only an empty object is supplied (plaintext)', () => {
|
|
104
|
+
const value = stsCapValue({}, false);
|
|
105
|
+
expect(value).toBe(`duration=${DEFAULT_STS_DURATION_SECONDS},port=${DEFAULT_STS_PORT}`);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('uses both defaults when only an empty object is supplied (TLS)', () => {
|
|
109
|
+
const value = stsCapValue({}, true);
|
|
110
|
+
expect(value).toBe(`duration=${DEFAULT_STS_DURATION_SECONDS}`);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
describe('defaults', () => {
|
|
115
|
+
it('exposes 6697 as the canonical IRC-over-TLS port', () => {
|
|
116
|
+
expect(DEFAULT_STS_PORT).toBe(6697);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('exposes a positive duration default (seconds)', () => {
|
|
120
|
+
expect(DEFAULT_STS_DURATION_SECONDS).toBeGreaterThan(0);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
});
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { emitAccountNotify } from '../../src/commands/account-notify';
|
|
3
|
+
import { Effect } from '../../src/effects';
|
|
4
|
+
import type { Effect as EffectType, RawLine } from '../../src/effects';
|
|
5
|
+
import { type ConnectionState, createConnection } from '../../src/state/connection';
|
|
6
|
+
|
|
7
|
+
const L = (text: string): RawLine => ({ text });
|
|
8
|
+
|
|
9
|
+
function makeConn(id = 'c1', nick = 'alice'): ConnectionState {
|
|
10
|
+
const s = createConnection({ id, connectedSince: 0 });
|
|
11
|
+
s.nick = nick;
|
|
12
|
+
s.user = 'alice';
|
|
13
|
+
s.host = 'example.com';
|
|
14
|
+
s.realname = 'Alice';
|
|
15
|
+
s.registration = 'registered';
|
|
16
|
+
return s;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe('emitAccountNotify — login', () => {
|
|
20
|
+
it('emits a cap-split Broadcast per joined channel with the account name', () => {
|
|
21
|
+
const conn = makeConn();
|
|
22
|
+
conn.joinedChannels.add('#foo');
|
|
23
|
+
conn.joinedChannels.add('#bar');
|
|
24
|
+
|
|
25
|
+
const effects = emitAccountNotify({ conn, account: 'alice' });
|
|
26
|
+
|
|
27
|
+
expect(effects).toEqual<EffectType[]>([
|
|
28
|
+
Effect.broadcast('#foo', [], 'c1', 'account-notify', [
|
|
29
|
+
L(':alice!alice@example.com ACCOUNT alice'),
|
|
30
|
+
]),
|
|
31
|
+
Effect.broadcast('#bar', [], 'c1', 'account-notify', [
|
|
32
|
+
L(':alice!alice@example.com ACCOUNT alice'),
|
|
33
|
+
]),
|
|
34
|
+
]);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('excludes the sender (except) so non-echo peers do not see their own ACCOUNT', () => {
|
|
38
|
+
const conn = makeConn();
|
|
39
|
+
conn.joinedChannels.add('#foo');
|
|
40
|
+
|
|
41
|
+
const effects = emitAccountNotify({ conn, account: 'alice' });
|
|
42
|
+
|
|
43
|
+
const bcast = effects[0];
|
|
44
|
+
if (bcast?.tag === 'Broadcast') {
|
|
45
|
+
expect(bcast.except).toBe('c1');
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('uses empty legacy lines so non-cap peers receive nothing', () => {
|
|
50
|
+
const conn = makeConn();
|
|
51
|
+
conn.joinedChannels.add('#foo');
|
|
52
|
+
|
|
53
|
+
const effects = emitAccountNotify({ conn, account: 'alice' });
|
|
54
|
+
|
|
55
|
+
const bcast = effects[0];
|
|
56
|
+
if (bcast?.tag === 'Broadcast') {
|
|
57
|
+
expect(bcast.lines).toEqual([]);
|
|
58
|
+
expect(bcast.cap).toBe('account-notify');
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe('emitAccountNotify — logout', () => {
|
|
64
|
+
it('emits ACCOUNT * for a logout', () => {
|
|
65
|
+
const conn = makeConn();
|
|
66
|
+
conn.joinedChannels.add('#foo');
|
|
67
|
+
|
|
68
|
+
const effects = emitAccountNotify({ conn, account: undefined });
|
|
69
|
+
|
|
70
|
+
expect(effects).toEqual<EffectType[]>([
|
|
71
|
+
Effect.broadcast('#foo', [], 'c1', 'account-notify', [
|
|
72
|
+
L(':alice!alice@example.com ACCOUNT *'),
|
|
73
|
+
]),
|
|
74
|
+
]);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe('emitAccountNotify — gating', () => {
|
|
79
|
+
it('emits nothing before registration completes', () => {
|
|
80
|
+
const conn = makeConn();
|
|
81
|
+
conn.registration = 'pre-registration';
|
|
82
|
+
conn.joinedChannels.add('#foo');
|
|
83
|
+
|
|
84
|
+
const effects = emitAccountNotify({ conn, account: 'alice' });
|
|
85
|
+
|
|
86
|
+
expect(effects).toEqual([]);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('emits nothing during registration', () => {
|
|
90
|
+
const conn = makeConn();
|
|
91
|
+
conn.registration = 'registering';
|
|
92
|
+
conn.joinedChannels.add('#foo');
|
|
93
|
+
|
|
94
|
+
const effects = emitAccountNotify({ conn, account: 'alice' });
|
|
95
|
+
|
|
96
|
+
expect(effects).toEqual([]);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('emits nothing when the connection has no joined channels', () => {
|
|
100
|
+
const conn = makeConn();
|
|
101
|
+
|
|
102
|
+
const effects = emitAccountNotify({ conn, account: 'alice' });
|
|
103
|
+
|
|
104
|
+
expect(effects).toEqual([]);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('falls back to ? for the source when nick/user/host are absent', () => {
|
|
108
|
+
const conn = createConnection({ id: 'c1', connectedSince: 0 });
|
|
109
|
+
conn.registration = 'registered';
|
|
110
|
+
conn.joinedChannels.add('#foo');
|
|
111
|
+
|
|
112
|
+
const effects = emitAccountNotify({ conn, account: 'alice' });
|
|
113
|
+
|
|
114
|
+
const bcast = effects[0];
|
|
115
|
+
if (bcast?.tag === 'Broadcast') {
|
|
116
|
+
expect(bcast.capLines?.[0]?.text).toBe(':? ACCOUNT alice');
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe('emitAccountNotify — echo-message', () => {
|
|
122
|
+
it('echoes ACCOUNT back to a sender with echo-message negotiated', () => {
|
|
123
|
+
const conn = makeConn();
|
|
124
|
+
conn.caps.add('echo-message');
|
|
125
|
+
conn.joinedChannels.add('#foo');
|
|
126
|
+
|
|
127
|
+
const effects = emitAccountNotify({ conn, account: 'alice' });
|
|
128
|
+
|
|
129
|
+
// First: the broadcast (sender excluded). Then the echo Send to sender.
|
|
130
|
+
expect(effects).toEqual<EffectType[]>([
|
|
131
|
+
Effect.broadcast('#foo', [], 'c1', 'account-notify', [
|
|
132
|
+
L(':alice!alice@example.com ACCOUNT alice'),
|
|
133
|
+
]),
|
|
134
|
+
Effect.send('c1', [L(':alice!alice@example.com ACCOUNT alice')]),
|
|
135
|
+
]);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('does not echo when the sender lacks echo-message', () => {
|
|
139
|
+
const conn = makeConn();
|
|
140
|
+
conn.joinedChannels.add('#foo');
|
|
141
|
+
|
|
142
|
+
const effects = emitAccountNotify({ conn, account: 'alice' });
|
|
143
|
+
|
|
144
|
+
expect(effects).toHaveLength(1);
|
|
145
|
+
expect(effects[0]?.tag).toBe('Broadcast');
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('does not echo when the sender has echo-message but no joined channels', () => {
|
|
149
|
+
const conn = makeConn();
|
|
150
|
+
conn.caps.add('echo-message');
|
|
151
|
+
|
|
152
|
+
const effects = emitAccountNotify({ conn, account: 'alice' });
|
|
153
|
+
|
|
154
|
+
expect(effects).toEqual([]);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('echoes a single ACCOUNT even across multiple shared channels', () => {
|
|
158
|
+
const conn = makeConn();
|
|
159
|
+
conn.caps.add('echo-message');
|
|
160
|
+
conn.joinedChannels.add('#foo');
|
|
161
|
+
conn.joinedChannels.add('#bar');
|
|
162
|
+
|
|
163
|
+
const effects = emitAccountNotify({ conn, account: 'alice' });
|
|
164
|
+
|
|
165
|
+
// Two broadcasts (one per channel) + exactly one echo Send.
|
|
166
|
+
const broadcasts = effects.filter((e) => e.tag === 'Broadcast');
|
|
167
|
+
const sends = effects.filter((e) => e.tag === 'Send');
|
|
168
|
+
expect(broadcasts).toHaveLength(2);
|
|
169
|
+
expect(sends).toHaveLength(1);
|
|
170
|
+
});
|
|
171
|
+
});
|
|
@@ -2,7 +2,13 @@ import { describe, expect, it } from 'vitest';
|
|
|
2
2
|
import { awayReducer } from '../../src/commands/away';
|
|
3
3
|
import { Effect } from '../../src/effects';
|
|
4
4
|
import type { Effect as EffectType, RawLine } from '../../src/effects';
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
type AwayStore,
|
|
7
|
+
EmptyMotdProvider,
|
|
8
|
+
FakeClock,
|
|
9
|
+
InMemoryAwayStore,
|
|
10
|
+
SequentialIdFactory,
|
|
11
|
+
} from '../../src/ports';
|
|
6
12
|
import { type ConnectionState, createConnection } from '../../src/state/connection';
|
|
7
13
|
import { type Ctx, type ServerConfig, buildCtx } from '../../src/types';
|
|
8
14
|
|
|
@@ -18,13 +24,14 @@ const serverConfig: ServerConfig = {
|
|
|
18
24
|
quitMessage: 'Client Quit',
|
|
19
25
|
};
|
|
20
26
|
|
|
21
|
-
function makeCtx(conn: ConnectionState, clock = new FakeClock(1_000)): Ctx {
|
|
27
|
+
function makeCtx(conn: ConnectionState, clock = new FakeClock(1_000), away?: AwayStore): Ctx {
|
|
22
28
|
return buildCtx({
|
|
23
29
|
serverConfig,
|
|
24
30
|
clock,
|
|
25
31
|
ids: new SequentialIdFactory(),
|
|
26
32
|
motd: EmptyMotdProvider,
|
|
27
33
|
connection: conn,
|
|
34
|
+
...(away !== undefined ? { away } : {}),
|
|
28
35
|
});
|
|
29
36
|
}
|
|
30
37
|
|
|
@@ -231,3 +238,79 @@ describe('awayReducer — defensive', () => {
|
|
|
231
238
|
expect(broadcast?.lines[0]?.text).toBe(':? AWAY :gone');
|
|
232
239
|
});
|
|
233
240
|
});
|
|
241
|
+
|
|
242
|
+
// ============================================================================
|
|
243
|
+
// awayReducer — draft/pre-away persistence
|
|
244
|
+
// ============================================================================
|
|
245
|
+
|
|
246
|
+
describe('awayReducer — draft/pre-away persistence', () => {
|
|
247
|
+
it('persists the reason to the AwayStore keyed by account when setting away', () => {
|
|
248
|
+
const store = new InMemoryAwayStore();
|
|
249
|
+
const conn = makeConn();
|
|
250
|
+
conn.account = 'alice';
|
|
251
|
+
const ctx = makeCtx(conn, new FakeClock(1_000), store);
|
|
252
|
+
|
|
253
|
+
awayReducer(conn, { command: 'AWAY', params: ['brb'], tags: {} }, ctx);
|
|
254
|
+
|
|
255
|
+
expect(store.get('alice')).toBe('brb');
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it('overwrites the persisted reason when the away reason changes', () => {
|
|
259
|
+
const store = new InMemoryAwayStore();
|
|
260
|
+
const conn = makeConn();
|
|
261
|
+
conn.account = 'alice';
|
|
262
|
+
const ctx = makeCtx(conn, new FakeClock(1_000), store);
|
|
263
|
+
|
|
264
|
+
awayReducer(conn, { command: 'AWAY', params: ['brb'], tags: {} }, ctx);
|
|
265
|
+
awayReducer(conn, { command: 'AWAY', params: ['lunch'], tags: {} }, ctx);
|
|
266
|
+
|
|
267
|
+
expect(store.get('alice')).toBe('lunch');
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it('clears the persisted reason when AWAY is unset (no param)', () => {
|
|
271
|
+
const store = new InMemoryAwayStore();
|
|
272
|
+
store.set('alice', 'brb');
|
|
273
|
+
const conn = makeConn();
|
|
274
|
+
conn.account = 'alice';
|
|
275
|
+
conn.away = 'brb';
|
|
276
|
+
const ctx = makeCtx(conn, new FakeClock(1_000), store);
|
|
277
|
+
|
|
278
|
+
awayReducer(conn, { command: 'AWAY', params: [], tags: {} }, ctx);
|
|
279
|
+
|
|
280
|
+
expect(store.get('alice')).toBeUndefined();
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it('clears the persisted reason when AWAY is unset (empty param)', () => {
|
|
284
|
+
const store = new InMemoryAwayStore();
|
|
285
|
+
store.set('alice', 'brb');
|
|
286
|
+
const conn = makeConn();
|
|
287
|
+
conn.account = 'alice';
|
|
288
|
+
conn.away = 'brb';
|
|
289
|
+
const ctx = makeCtx(conn, new FakeClock(1_000), store);
|
|
290
|
+
|
|
291
|
+
awayReducer(conn, { command: 'AWAY', params: [''], tags: {} }, ctx);
|
|
292
|
+
|
|
293
|
+
expect(store.get('alice')).toBeUndefined();
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it('does not persist when no AwayStore is bound (in-memory only)', () => {
|
|
297
|
+
const conn = makeConn();
|
|
298
|
+
conn.account = 'alice';
|
|
299
|
+
const ctx = makeCtx(conn); // no store
|
|
300
|
+
|
|
301
|
+
awayReducer(conn, { command: 'AWAY', params: ['brb'], tags: {} }, ctx);
|
|
302
|
+
|
|
303
|
+
// In-memory reason still set so the session's own view is consistent.
|
|
304
|
+
expect(conn.away).toBe('brb');
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it('does not persist when the connection is unidentified (no account)', () => {
|
|
308
|
+
const store = new InMemoryAwayStore();
|
|
309
|
+
const conn = makeConn(); // no account
|
|
310
|
+
const ctx = makeCtx(conn, new FakeClock(1_000), store);
|
|
311
|
+
|
|
312
|
+
awayReducer(conn, { command: 'AWAY', params: ['brb'], tags: {} }, ctx);
|
|
313
|
+
|
|
314
|
+
expect(store.get('alice')).toBeUndefined();
|
|
315
|
+
});
|
|
316
|
+
});
|
|
@@ -142,6 +142,32 @@ describe('capReducer — LS', () => {
|
|
|
142
142
|
}
|
|
143
143
|
});
|
|
144
144
|
|
|
145
|
+
it('advertises draft/multiline=4096 with the default byte budget', () => {
|
|
146
|
+
const state = makeState();
|
|
147
|
+
const ctx = makeCtx(state);
|
|
148
|
+
const out = capReducer(state, { command: 'CAP', params: ['LS'], tags: {} }, ctx);
|
|
149
|
+
const send = out.effects[0];
|
|
150
|
+
expect(send).toBeDefined();
|
|
151
|
+
if (send?.tag === 'Send') {
|
|
152
|
+
const body = send.lines[0]?.text ?? '';
|
|
153
|
+
expect(body).toContain('draft/multiline=4096');
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('advertises the configured multiline byte budget when overridden', () => {
|
|
158
|
+
const state = makeState();
|
|
159
|
+
const cfg: ServerConfig = { ...serverConfig, multilineMaxBytes: 8192 };
|
|
160
|
+
const ctx = makeCtx(state, cfg);
|
|
161
|
+
const out = capReducer(state, { command: 'CAP', params: ['LS'], tags: {} }, ctx);
|
|
162
|
+
const send = out.effects[0];
|
|
163
|
+
expect(send).toBeDefined();
|
|
164
|
+
if (send?.tag === 'Send') {
|
|
165
|
+
const body = send.lines[0]?.text ?? '';
|
|
166
|
+
expect(body).toContain('draft/multiline=8192');
|
|
167
|
+
expect(body).not.toContain('draft/multiline=4096');
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
|
|
145
171
|
it('marks the connection as in CAP negotiation so the welcome is deferred', () => {
|
|
146
172
|
const state = makeState();
|
|
147
173
|
const ctx = makeCtx(state);
|
|
@@ -529,3 +555,142 @@ describe('capReducer — unknown subcommands', () => {
|
|
|
529
555
|
]);
|
|
530
556
|
});
|
|
531
557
|
});
|
|
558
|
+
|
|
559
|
+
// ============================================================================
|
|
560
|
+
// capReducer — IRCv3 `sts` (Strict Transport Security) advertisement
|
|
561
|
+
// ============================================================================
|
|
562
|
+
|
|
563
|
+
describe('capReducer — STS advertisement', () => {
|
|
564
|
+
it('omits the sts cap when no STS policy is configured', () => {
|
|
565
|
+
const state = makeState();
|
|
566
|
+
const ctx = makeCtx(state);
|
|
567
|
+
const out = capReducer(state, { command: 'CAP', params: ['LS'], tags: {} }, ctx);
|
|
568
|
+
const send = out.effects[0];
|
|
569
|
+
expect(send).toBeDefined();
|
|
570
|
+
if (send?.tag === 'Send') {
|
|
571
|
+
const body = send.lines[0]?.text ?? '';
|
|
572
|
+
expect(body).not.toContain('sts=');
|
|
573
|
+
expect(body).not.toContain(' sts ');
|
|
574
|
+
}
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
it('advertises sts=duration=…,port=… on a plaintext connection', () => {
|
|
578
|
+
const state = makeState();
|
|
579
|
+
state.secure = false;
|
|
580
|
+
const cfg: ServerConfig = {
|
|
581
|
+
...serverConfig,
|
|
582
|
+
sts: { duration: 600, port: 6697 },
|
|
583
|
+
};
|
|
584
|
+
const ctx = makeCtx(state, cfg);
|
|
585
|
+
const out = capReducer(state, { command: 'CAP', params: ['LS'], tags: {} }, ctx);
|
|
586
|
+
const send = out.effects[0];
|
|
587
|
+
expect(send).toBeDefined();
|
|
588
|
+
if (send?.tag === 'Send') {
|
|
589
|
+
const body = send.lines[0]?.text ?? '';
|
|
590
|
+
expect(body).toContain('sts=duration=600,port=6697');
|
|
591
|
+
}
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
it('reads duration and port from ServerConfig (not literals)', () => {
|
|
595
|
+
const state = makeState();
|
|
596
|
+
state.secure = false;
|
|
597
|
+
const cfg: ServerConfig = {
|
|
598
|
+
...serverConfig,
|
|
599
|
+
sts: { duration: 12345, port: 7000 },
|
|
600
|
+
};
|
|
601
|
+
const ctx = makeCtx(state, cfg);
|
|
602
|
+
const out = capReducer(state, { command: 'CAP', params: ['LS'], tags: {} }, ctx);
|
|
603
|
+
const send = out.effects[0];
|
|
604
|
+
expect(send).toBeDefined();
|
|
605
|
+
if (send?.tag === 'Send') {
|
|
606
|
+
const body = send.lines[0]?.text ?? '';
|
|
607
|
+
expect(body).toContain('sts=duration=12345,port=7000');
|
|
608
|
+
expect(body).not.toContain('sts=duration=600');
|
|
609
|
+
expect(body).not.toContain('port=6697');
|
|
610
|
+
}
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
it('appends preload when configured on a plaintext connection', () => {
|
|
614
|
+
const state = makeState();
|
|
615
|
+
state.secure = false;
|
|
616
|
+
const cfg: ServerConfig = {
|
|
617
|
+
...serverConfig,
|
|
618
|
+
sts: { duration: 600, port: 6697, preload: true },
|
|
619
|
+
};
|
|
620
|
+
const ctx = makeCtx(state, cfg);
|
|
621
|
+
const out = capReducer(state, { command: 'CAP', params: ['LS'], tags: {} }, ctx);
|
|
622
|
+
const send = out.effects[0];
|
|
623
|
+
expect(send).toBeDefined();
|
|
624
|
+
if (send?.tag === 'Send') {
|
|
625
|
+
const body = send.lines[0]?.text ?? '';
|
|
626
|
+
expect(body).toContain('sts=duration=600,port=6697,preload');
|
|
627
|
+
}
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
it('advertises only duration on a TLS connection (wss / irc+tls)', () => {
|
|
631
|
+
const state = makeState();
|
|
632
|
+
state.secure = true;
|
|
633
|
+
const cfg: ServerConfig = {
|
|
634
|
+
...serverConfig,
|
|
635
|
+
sts: { duration: 600, port: 6697 },
|
|
636
|
+
};
|
|
637
|
+
const ctx = makeCtx(state, cfg);
|
|
638
|
+
const out = capReducer(state, { command: 'CAP', params: ['LS'], tags: {} }, ctx);
|
|
639
|
+
const send = out.effects[0];
|
|
640
|
+
expect(send).toBeDefined();
|
|
641
|
+
if (send?.tag === 'Send') {
|
|
642
|
+
const body = send.lines[0]?.text ?? '';
|
|
643
|
+
expect(body).toContain('sts=duration=600');
|
|
644
|
+
expect(body).not.toContain('port=');
|
|
645
|
+
expect(body).not.toContain('preload');
|
|
646
|
+
}
|
|
647
|
+
});
|
|
648
|
+
|
|
649
|
+
it('omits preload on a TLS connection even when configured', () => {
|
|
650
|
+
const state = makeState();
|
|
651
|
+
state.secure = true;
|
|
652
|
+
const cfg: ServerConfig = {
|
|
653
|
+
...serverConfig,
|
|
654
|
+
sts: { duration: 600, port: 6697, preload: true },
|
|
655
|
+
};
|
|
656
|
+
const ctx = makeCtx(state, cfg);
|
|
657
|
+
const out = capReducer(state, { command: 'CAP', params: ['LS'], tags: {} }, ctx);
|
|
658
|
+
const send = out.effects[0];
|
|
659
|
+
expect(send).toBeDefined();
|
|
660
|
+
if (send?.tag === 'Send') {
|
|
661
|
+
const body = send.lines[0]?.text ?? '';
|
|
662
|
+
expect(body).toContain('sts=duration=600');
|
|
663
|
+
expect(body).not.toContain('preload');
|
|
664
|
+
}
|
|
665
|
+
});
|
|
666
|
+
|
|
667
|
+
it('ACKs CAP REQ sts when configured', () => {
|
|
668
|
+
const state = readyState();
|
|
669
|
+
const cfg: ServerConfig = {
|
|
670
|
+
...serverConfig,
|
|
671
|
+
sts: { duration: 600, port: 6697 },
|
|
672
|
+
};
|
|
673
|
+
const ctx = makeCtx(state, cfg);
|
|
674
|
+
const out = capReducer(state, { command: 'CAP', params: ['REQ', 'sts'], tags: {} }, ctx);
|
|
675
|
+
const send = out.effects[0];
|
|
676
|
+
expect(send).toBeDefined();
|
|
677
|
+
if (send?.tag === 'Send') {
|
|
678
|
+
const body = send.lines[0]?.text ?? '';
|
|
679
|
+
expect(body).toContain('ACK :sts');
|
|
680
|
+
}
|
|
681
|
+
expect(out.state.caps.has('sts')).toBe(true);
|
|
682
|
+
});
|
|
683
|
+
|
|
684
|
+
it('NAKs CAP REQ sts when no STS policy is configured', () => {
|
|
685
|
+
const state = readyState();
|
|
686
|
+
const ctx = makeCtx(state);
|
|
687
|
+
const out = capReducer(state, { command: 'CAP', params: ['REQ', 'sts'], tags: {} }, ctx);
|
|
688
|
+
const send = out.effects[0];
|
|
689
|
+
expect(send).toBeDefined();
|
|
690
|
+
if (send?.tag === 'Send') {
|
|
691
|
+
const body = send.lines[0]?.text ?? '';
|
|
692
|
+
expect(body).toContain('NAK :sts');
|
|
693
|
+
}
|
|
694
|
+
expect(out.state.caps.has('sts')).toBe(false);
|
|
695
|
+
});
|
|
696
|
+
});
|