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,289 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CF adapter — IRCv3 WebSocket spec-compliance (end-to-end contract).
|
|
3
|
+
*
|
|
4
|
+
* A single integration suite driving the real `ConnectionDO`
|
|
5
|
+
* through `cloudflare:test` (miniflare) that asserts the FULL IRCv3
|
|
6
|
+
* WebSocket spec contract end-to-end against a negotiated `text.ircv3.net`
|
|
7
|
+
* connection (with a legacy fallback for the differential cases):
|
|
8
|
+
*
|
|
9
|
+
* - `Sec-WebSocket-Protocol` echo (`text.ircv3.net` / `binary.ircv3.net`)
|
|
10
|
+
* - one IRC message per WebSocket message, BOTH directions:
|
|
11
|
+
* outbound registration replies arrive as N separate WS messages
|
|
12
|
+
* with no trailing CR-LF; bare inbound frames (no trailing CR-LF)
|
|
13
|
+
* are accepted; an embedded CR-LF is NOT split into two commands.
|
|
14
|
+
* - the 510-byte message budget: a 511-byte message is rejected with
|
|
15
|
+
* RFC 6455 close code 1009 (Message Too Big); a 510-byte message is
|
|
16
|
+
* accepted.
|
|
17
|
+
* - legacy fallback: a connection offering no subprotocol still
|
|
18
|
+
* registers and still splits `\r\n`-joined frames.
|
|
19
|
+
*
|
|
20
|
+
* Hermetic against Miniflare; runs in the normal `pnpm test` suite (no
|
|
21
|
+
* `--e2e` flag). The per-connection negotiation cases live in
|
|
22
|
+
* `connection-do-ws-subprotocol.test.ts`; this file is the
|
|
23
|
+
* consolidated end-to-end contract.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { env } from 'cloudflare:test';
|
|
27
|
+
import { MAX_WS_MESSAGE_BYTES } from '@serverless-ircd/irc-core';
|
|
28
|
+
import { describe, expect, it } from 'vitest';
|
|
29
|
+
|
|
30
|
+
declare global {
|
|
31
|
+
namespace Cloudflare {
|
|
32
|
+
interface Env {
|
|
33
|
+
CONNECTION_DO: DurableObjectNamespace;
|
|
34
|
+
REGISTRY_DO: DurableObjectNamespace;
|
|
35
|
+
CHANNEL_DO: DurableObjectNamespace;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
// Helpers — a WebSocket client that records raw WS messages (not pre-split).
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
/** Poll interval (ms) for the waitFor* helpers. */
|
|
45
|
+
const POLL_MS = 10;
|
|
46
|
+
/** Default timeout (ms) for the waitFor* helpers. */
|
|
47
|
+
const DEFAULT_TIMEOUT_MS = 2000;
|
|
48
|
+
|
|
49
|
+
class SpecWsClient {
|
|
50
|
+
/** Raw WebSocket messages exactly as delivered (one entry per WS message). */
|
|
51
|
+
readonly messages: string[] = [];
|
|
52
|
+
/** Lines split out of `messages` (tolerant of legacy `\r\n` framing). */
|
|
53
|
+
readonly lines: string[] = [];
|
|
54
|
+
readonly ws: WebSocket;
|
|
55
|
+
closedCode: number | undefined;
|
|
56
|
+
|
|
57
|
+
constructor(ws: WebSocket) {
|
|
58
|
+
this.ws = ws;
|
|
59
|
+
ws.addEventListener('message', (ev: MessageEvent) => {
|
|
60
|
+
const data = typeof ev.data === 'string' ? ev.data : new TextDecoder().decode(ev.data);
|
|
61
|
+
this.messages.push(data);
|
|
62
|
+
for (const line of data.split('\r\n')) {
|
|
63
|
+
if (line.length > 0) this.lines.push(line);
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
ws.addEventListener('close', (ev: CloseEvent) => {
|
|
67
|
+
this.closedCode = ev.code;
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Sends one IRC line as a single WS message WITH a trailing CR-LF. */
|
|
72
|
+
send(line: string): void {
|
|
73
|
+
this.ws.send(`${line}\r\n`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Sends `frame` verbatim as a single WS message — no trailing CR-LF is
|
|
78
|
+
* appended. This is how an IRCv3 spec-compliant client sends each IRC
|
|
79
|
+
* message (one per WS message, no terminator on the wire).
|
|
80
|
+
*/
|
|
81
|
+
sendRaw(frame: string): void {
|
|
82
|
+
this.ws.send(frame);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async waitForLine(
|
|
86
|
+
predicate: (line: string) => boolean,
|
|
87
|
+
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
88
|
+
): Promise<string> {
|
|
89
|
+
const deadline = Date.now() + timeoutMs;
|
|
90
|
+
for (const l of this.lines) if (predicate(l)) return l;
|
|
91
|
+
while (Date.now() < deadline) {
|
|
92
|
+
await new Promise((r) => setTimeout(r, POLL_MS));
|
|
93
|
+
for (const l of this.lines) if (predicate(l)) return l;
|
|
94
|
+
}
|
|
95
|
+
throw new Error(`waitForLine timed out; lines: ${JSON.stringify(this.lines)}`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async waitForClose(timeoutMs = DEFAULT_TIMEOUT_MS): Promise<number | undefined> {
|
|
99
|
+
if (this.closedCode !== undefined) return this.closedCode;
|
|
100
|
+
const deadline = Date.now() + timeoutMs;
|
|
101
|
+
while (Date.now() < deadline) {
|
|
102
|
+
await new Promise((r) => setTimeout(r, POLL_MS));
|
|
103
|
+
if (this.closedCode !== undefined) return this.closedCode;
|
|
104
|
+
}
|
|
105
|
+
throw new Error(`waitForClose timed out; messages: ${JSON.stringify(this.messages)}`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Waits until at least `n` raw WS messages satisfy `predicate`. */
|
|
109
|
+
async waitForMessages(
|
|
110
|
+
predicate: (msg: string) => boolean,
|
|
111
|
+
n: number,
|
|
112
|
+
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
113
|
+
): Promise<string[]> {
|
|
114
|
+
const deadline = Date.now() + timeoutMs;
|
|
115
|
+
const matching = (): string[] => this.messages.filter(predicate);
|
|
116
|
+
let hit = matching();
|
|
117
|
+
if (hit.length >= n) return hit;
|
|
118
|
+
while (Date.now() < deadline) {
|
|
119
|
+
await new Promise((r) => setTimeout(r, POLL_MS));
|
|
120
|
+
hit = matching();
|
|
121
|
+
if (hit.length >= n) return hit;
|
|
122
|
+
}
|
|
123
|
+
throw new Error(
|
|
124
|
+
`waitForMessages(n=${n}) timed out; matching ${hit.length}: ${JSON.stringify(hit)}`,
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Counts raw WS messages whose command token is `cmd` (e.g. 'PONG'). */
|
|
130
|
+
function countCommand(messages: readonly string[], cmd: string): number {
|
|
131
|
+
const upper = cmd.toUpperCase();
|
|
132
|
+
let n = 0;
|
|
133
|
+
for (const msg of messages) {
|
|
134
|
+
const parts = msg.split(' ');
|
|
135
|
+
const start = parts[0]?.startsWith(':') ? 1 : 0;
|
|
136
|
+
if (parts[start]?.toUpperCase() === upper) n++;
|
|
137
|
+
}
|
|
138
|
+
return n;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Opens a WebSocket to a fresh ConnectionDO, optionally offering a
|
|
143
|
+
* `Sec-WebSocket-Protocol` list. Returns the upgrade Response (so the
|
|
144
|
+
* caller can assert on the echoed header) plus a ready-to-use client.
|
|
145
|
+
*/
|
|
146
|
+
async function openUpgrade(
|
|
147
|
+
connId: string,
|
|
148
|
+
protocols?: string,
|
|
149
|
+
): Promise<{ response: Response; client: SpecWsClient }> {
|
|
150
|
+
const stub = env.CONNECTION_DO.get(env.CONNECTION_DO.idFromName(connId));
|
|
151
|
+
const headers: Record<string, string> = { Upgrade: 'websocket' };
|
|
152
|
+
if (protocols !== undefined) {
|
|
153
|
+
headers['Sec-WebSocket-Protocol'] = protocols;
|
|
154
|
+
}
|
|
155
|
+
const response = await stub.fetch('https://do/upgrade', { headers });
|
|
156
|
+
const ws = response.webSocket;
|
|
157
|
+
if (ws === undefined || ws === null) {
|
|
158
|
+
throw new Error('ConnectionDO.fetch did not return a WebSocket');
|
|
159
|
+
}
|
|
160
|
+
ws.accept();
|
|
161
|
+
const client = await new Promise<SpecWsClient>((resolve) => {
|
|
162
|
+
setTimeout(() => resolve(new SpecWsClient(ws as WebSocket)), 0);
|
|
163
|
+
});
|
|
164
|
+
return { response, client };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Registers a client using bare spec-style frames (no trailing CR-LF). */
|
|
168
|
+
async function registerBare(client: SpecWsClient, nick: string): Promise<void> {
|
|
169
|
+
client.sendRaw(`NICK ${nick}`);
|
|
170
|
+
client.sendRaw(`USER ${nick} 0 * :${nick}`);
|
|
171
|
+
await client.waitForLine((l) => l.includes(' 001 '));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ---------------------------------------------------------------------------
|
|
175
|
+
// Spec contract
|
|
176
|
+
// ---------------------------------------------------------------------------
|
|
177
|
+
|
|
178
|
+
describe('ConnectionDO — IRCv3 WebSocket spec contract (end-to-end)', () => {
|
|
179
|
+
// --- Sec-WebSocket-Protocol echo ----------------------------------------
|
|
180
|
+
|
|
181
|
+
it('echoes text.ircv3.net and binary.ircv3.net back in the 101 response', async () => {
|
|
182
|
+
const text = await openUpgrade('conn-contract-echo-text', 'text.ircv3.net');
|
|
183
|
+
expect(text.response.status).toBe(101);
|
|
184
|
+
expect(text.response.headers.get('Sec-WebSocket-Protocol')).toBe('text.ircv3.net');
|
|
185
|
+
text.client.ws.close();
|
|
186
|
+
|
|
187
|
+
const bin = await openUpgrade('conn-contract-echo-bin', 'binary.ircv3.net');
|
|
188
|
+
expect(bin.response.status).toBe(101);
|
|
189
|
+
expect(bin.response.headers.get('Sec-WebSocket-Protocol')).toBe('binary.ircv3.net');
|
|
190
|
+
bin.client.ws.close();
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
// --- Outbound: one IRC line per WS message, no trailing CR-LF -----------
|
|
194
|
+
|
|
195
|
+
it('delivers each outbound IRC line as its own WS message with no trailing CR-LF (spec-text)', async () => {
|
|
196
|
+
const { client } = await openUpgrade('conn-contract-outbound', 'text.ircv3.net');
|
|
197
|
+
client.sendRaw('NICK out-bob');
|
|
198
|
+
client.sendRaw('USER out-bob 0 * :out-bob');
|
|
199
|
+
await client.waitForLine((l) => l.includes(' 001 '));
|
|
200
|
+
|
|
201
|
+
// A successful registration emits several numerics (001..005, MOTD).
|
|
202
|
+
expect(client.messages.length).toBeGreaterThan(1);
|
|
203
|
+
for (const msg of client.messages) {
|
|
204
|
+
expect(msg.endsWith('\r\n')).toBe(false);
|
|
205
|
+
}
|
|
206
|
+
client.ws.close();
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
// --- Inbound: one IRC message per WS message ----------------------------
|
|
210
|
+
|
|
211
|
+
it('accepts bare single-line inbound frames (no trailing CR-LF) and completes registration (spec-text)', async () => {
|
|
212
|
+
const { client } = await openUpgrade('conn-contract-inbound-bare', 'text.ircv3.net');
|
|
213
|
+
// Each command is its own WS message with NO trailing CR-LF — the
|
|
214
|
+
// spec-mandated client shape. Registration must still complete.
|
|
215
|
+
await registerBare(client, 'bare-alice');
|
|
216
|
+
expect(client.lines.some((l) => l.includes(' 001 '))).toBe(true);
|
|
217
|
+
client.ws.close();
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('treats one WS message as exactly one IRC command (embedded CR-LF is not split) in spec-text mode', async () => {
|
|
221
|
+
const { client } = await openUpgrade('conn-contract-nosplit', 'text.ircv3.net');
|
|
222
|
+
await registerBare(client, 'nosplit-alice');
|
|
223
|
+
client.messages.length = 0;
|
|
224
|
+
|
|
225
|
+
// One WS message that LOOKS like two PING lines. In spec mode the frame
|
|
226
|
+
// is a single IRC message, so exactly ONE PONG is emitted.
|
|
227
|
+
client.sendRaw('PING one\r\nPING two');
|
|
228
|
+
await client.waitForMessages((m) => countCommand([m], 'PONG') === 1, 1);
|
|
229
|
+
// Give a grace window for a (incorrect) second PONG to arrive.
|
|
230
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
231
|
+
expect(countCommand(client.messages, 'PONG')).toBe(1);
|
|
232
|
+
client.ws.close();
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it('splits a joined CR-LF frame into multiple commands in legacy mode', async () => {
|
|
236
|
+
const { client } = await openUpgrade('conn-contract-legacy-split');
|
|
237
|
+
await registerBare(client, 'legsplit-bob');
|
|
238
|
+
client.messages.length = 0;
|
|
239
|
+
|
|
240
|
+
// Same joined frame, but legacy framing splits on CR-LF → TWO PONGs.
|
|
241
|
+
client.sendRaw('PING one\r\nPING two');
|
|
242
|
+
await client.waitForMessages((m) => countCommand([m], 'PONG') === 1, 2);
|
|
243
|
+
expect(countCommand(client.messages, 'PONG')).toBe(2);
|
|
244
|
+
client.ws.close();
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
// --- 510-byte message budget -------------------------------------------
|
|
248
|
+
|
|
249
|
+
it(`accepts a ${MAX_WS_MESSAGE_BYTES}-byte message and rejects a ${MAX_WS_MESSAGE_BYTES + 1}-byte message with close 1009 (spec-text)`, async () => {
|
|
250
|
+
// Accepted: exactly the budget. A PING whose token pads the frame to
|
|
251
|
+
// MAX_WS_MESSAGE_BYTES bytes parses and is answered with a PONG.
|
|
252
|
+
const okFrame = `PING :${'a'.repeat(MAX_WS_MESSAGE_BYTES - 'PING :'.length)}`;
|
|
253
|
+
expect(okFrame.length).toBe(MAX_WS_MESSAGE_BYTES);
|
|
254
|
+
const ok = await openUpgrade('conn-contract-budget-ok', 'text.ircv3.net');
|
|
255
|
+
ok.client.sendRaw(okFrame);
|
|
256
|
+
await ok.client.waitForLine((l) => l.startsWith('PONG') || l.includes(' PONG '));
|
|
257
|
+
expect(ok.client.closedCode).toBeUndefined();
|
|
258
|
+
ok.client.ws.close();
|
|
259
|
+
|
|
260
|
+
// Rejected: one byte over the budget → RFC 6455 1009 (Message Too Big).
|
|
261
|
+
const bigFrame = `PING :${'a'.repeat(MAX_WS_MESSAGE_BYTES + 1 - 'PING :'.length)}`;
|
|
262
|
+
expect(bigFrame.length).toBe(MAX_WS_MESSAGE_BYTES + 1);
|
|
263
|
+
const big = await openUpgrade('conn-contract-budget-big', 'text.ircv3.net');
|
|
264
|
+
big.client.sendRaw(bigFrame);
|
|
265
|
+
const code = await big.client.waitForClose();
|
|
266
|
+
expect(code).toBe(1009);
|
|
267
|
+
|
|
268
|
+
// The same budget applies on a binary.ircv3.net connection (sent as a
|
|
269
|
+
// binary frame): oversize → close 1009.
|
|
270
|
+
const bin = await openUpgrade('conn-contract-budget-bin', 'binary.ircv3.net');
|
|
271
|
+
bin.client.ws.send(new TextEncoder().encode(bigFrame));
|
|
272
|
+
const binCode = await bin.client.waitForClose();
|
|
273
|
+
expect(binCode).toBe(1009);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
// --- Legacy fallback ----------------------------------------------------
|
|
277
|
+
|
|
278
|
+
it('still serves a legacy (no subprotocol offer) connection end-to-end', async () => {
|
|
279
|
+
const { response, client } = await openUpgrade('conn-contract-legacy');
|
|
280
|
+
expect(response.headers.get('Sec-WebSocket-Protocol')).toBeNull();
|
|
281
|
+
// Legacy clients join several commands into one CR-LF frame; the server
|
|
282
|
+
// splits and processes both → registration completes.
|
|
283
|
+
client.send('NICK legacy-carol');
|
|
284
|
+
client.send('USER legacy-carol 0 * :legacy-carol');
|
|
285
|
+
await client.waitForLine((l) => l.includes(' 001 '));
|
|
286
|
+
expect(client.lines.some((l) => l.includes(' 001 '))).toBe(true);
|
|
287
|
+
client.ws.close();
|
|
288
|
+
});
|
|
289
|
+
});
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CF adapter — IRCv3 WebSocket subprotocol negotiation + per-message framing.
|
|
3
|
+
*
|
|
4
|
+
* Drives the real `ConnectionDO` through `cloudflare:test` (miniflare) so
|
|
5
|
+
* the `Sec-WebSocket-Protocol` round-trip, hibernation tags, and frame
|
|
6
|
+
* framing behave exactly as in production. Each test gets its own
|
|
7
|
+
* isolated storage namespace.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { env } from 'cloudflare:test';
|
|
11
|
+
import { describe, expect, it } from 'vitest';
|
|
12
|
+
|
|
13
|
+
declare global {
|
|
14
|
+
namespace Cloudflare {
|
|
15
|
+
interface Env {
|
|
16
|
+
CONNECTION_DO: DurableObjectNamespace;
|
|
17
|
+
REGISTRY_DO: DurableObjectNamespace;
|
|
18
|
+
CHANNEL_DO: DurableObjectNamespace;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// Helpers — WebSocket client that records raw WS messages (not split lines).
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
class SpecWsClient {
|
|
28
|
+
/** Raw WebSocket messages exactly as delivered (one entry per WS message). */
|
|
29
|
+
readonly messages: string[] = [];
|
|
30
|
+
/** Lines split out of `messages` (tolerant of legacy `\r\n` framing). */
|
|
31
|
+
readonly lines: string[] = [];
|
|
32
|
+
readonly ws: WebSocket;
|
|
33
|
+
closedCode: number | undefined;
|
|
34
|
+
|
|
35
|
+
constructor(ws: WebSocket) {
|
|
36
|
+
this.ws = ws;
|
|
37
|
+
ws.addEventListener('message', (ev: MessageEvent) => {
|
|
38
|
+
const data = typeof ev.data === 'string' ? ev.data : new TextDecoder().decode(ev.data);
|
|
39
|
+
this.messages.push(data);
|
|
40
|
+
for (const line of data.split('\r\n')) {
|
|
41
|
+
if (line.length > 0) this.lines.push(line);
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
ws.addEventListener('close', (ev: CloseEvent) => {
|
|
45
|
+
this.closedCode = ev.code;
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
send(line: string): void {
|
|
50
|
+
this.ws.send(`${line}\r\n`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
waitForLine(predicate: (line: string) => boolean, timeoutMs = 2000): Promise<string> {
|
|
54
|
+
return new Promise<string>((resolve, reject) => {
|
|
55
|
+
const start = Date.now();
|
|
56
|
+
const tick = (): void => {
|
|
57
|
+
const hit = this.lines.find(predicate);
|
|
58
|
+
if (hit !== undefined) {
|
|
59
|
+
resolve(hit);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
if (Date.now() - start > timeoutMs) {
|
|
63
|
+
reject(new Error(`timeout waiting for line; got: ${JSON.stringify(this.lines)}`));
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
setTimeout(tick, 20);
|
|
67
|
+
};
|
|
68
|
+
tick();
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
waitForClose(timeoutMs = 2000): Promise<number | undefined> {
|
|
73
|
+
return new Promise<number | undefined>((resolve, reject) => {
|
|
74
|
+
if (this.closedCode !== undefined) {
|
|
75
|
+
resolve(this.closedCode);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
const start = Date.now();
|
|
79
|
+
const tick = (): void => {
|
|
80
|
+
if (this.closedCode !== undefined) {
|
|
81
|
+
resolve(this.closedCode);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
if (Date.now() - start > timeoutMs) {
|
|
85
|
+
reject(
|
|
86
|
+
new Error(`timeout waiting for close; messages: ${JSON.stringify(this.messages)}`),
|
|
87
|
+
);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
setTimeout(tick, 20);
|
|
91
|
+
};
|
|
92
|
+
tick();
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Opens a WebSocket to a fresh ConnectionDO, optionally offering a
|
|
99
|
+
* `Sec-WebSocket-Protocol` list. Returns the upgrade Response so the
|
|
100
|
+
* caller can assert on the echoed header, plus a ready-to-use client.
|
|
101
|
+
*/
|
|
102
|
+
async function openUpgrade(
|
|
103
|
+
connId: string,
|
|
104
|
+
protocols?: string,
|
|
105
|
+
): Promise<{ response: Response; client: SpecWsClient }> {
|
|
106
|
+
const stub = env.CONNECTION_DO.get(env.CONNECTION_DO.idFromName(connId));
|
|
107
|
+
const headers: Record<string, string> = { Upgrade: 'websocket' };
|
|
108
|
+
if (protocols !== undefined) {
|
|
109
|
+
headers['Sec-WebSocket-Protocol'] = protocols;
|
|
110
|
+
}
|
|
111
|
+
const response = await stub.fetch('https://do/upgrade', { headers });
|
|
112
|
+
const ws = response.webSocket;
|
|
113
|
+
if (ws === undefined || ws === null) {
|
|
114
|
+
throw new Error('ConnectionDO.fetch did not return a WebSocket');
|
|
115
|
+
}
|
|
116
|
+
ws.accept();
|
|
117
|
+
const client = await new Promise<SpecWsClient>((resolve) => {
|
|
118
|
+
setTimeout(() => resolve(new SpecWsClient(ws as WebSocket)), 0);
|
|
119
|
+
});
|
|
120
|
+
return { response, client };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function register(client: SpecWsClient, nick: string): Promise<void> {
|
|
124
|
+
client.send(`NICK ${nick}`);
|
|
125
|
+
client.send(`USER ${nick} 0 * :${nick}`);
|
|
126
|
+
await client.waitForLine((l) => l.includes(' 001 '));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ---------------------------------------------------------------------------
|
|
130
|
+
// Acceptance criteria
|
|
131
|
+
// ---------------------------------------------------------------------------
|
|
132
|
+
|
|
133
|
+
describe('ConnectionDO — IRCv3 WebSocket subprotocol negotiation', () => {
|
|
134
|
+
it('echoes text.ircv3.net back in the 101 response Sec-WebSocket-Protocol header', async () => {
|
|
135
|
+
const { response, client } = await openUpgrade('conn-subproto-text', 'text.ircv3.net');
|
|
136
|
+
expect(response.status).toBe(101);
|
|
137
|
+
expect(response.headers.get('Sec-WebSocket-Protocol')).toBe('text.ircv3.net');
|
|
138
|
+
client.ws.close();
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('selects the first-listed supported protocol when both are offered', async () => {
|
|
142
|
+
const a = await openUpgrade('conn-subproto-order-a', 'text.ircv3.net, binary.ircv3.net');
|
|
143
|
+
expect(a.response.headers.get('Sec-WebSocket-Protocol')).toBe('text.ircv3.net');
|
|
144
|
+
a.client.ws.close();
|
|
145
|
+
|
|
146
|
+
const b = await openUpgrade('conn-subproto-order-b', 'binary.ircv3.net, text.ircv3.net');
|
|
147
|
+
expect(b.response.headers.get('Sec-WebSocket-Protocol')).toBe('binary.ircv3.net');
|
|
148
|
+
b.client.ws.close();
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('omits Sec-WebSocket-Protocol when the client offers no supported protocol', async () => {
|
|
152
|
+
const { response, client } = await openUpgrade('conn-subproto-unsupported', 'bogus.proto');
|
|
153
|
+
expect(response.status).toBe(101);
|
|
154
|
+
expect(response.headers.get('Sec-WebSocket-Protocol')).toBeNull();
|
|
155
|
+
client.ws.close();
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('connects without a subprotocol offer (legacy mode) and registers', async () => {
|
|
159
|
+
const { client } = await openUpgrade('conn-subproto-legacy');
|
|
160
|
+
await register(client, 'legacy-alice');
|
|
161
|
+
expect(client.lines.some((l) => l.includes(' 001 '))).toBe(true);
|
|
162
|
+
client.ws.close();
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('delivers registration replies as N separate WS messages with no trailing CRLF (spec-text)', async () => {
|
|
166
|
+
const { client } = await openUpgrade('conn-subproto-permsg', 'text.ircv3.net');
|
|
167
|
+
client.send('NICK spec-bob');
|
|
168
|
+
client.send('USER spec-bob 0 * :spec-bob');
|
|
169
|
+
await client.waitForLine((l) => l.includes(' 001 '));
|
|
170
|
+
|
|
171
|
+
expect(client.messages.length).toBeGreaterThan(1);
|
|
172
|
+
for (const msg of client.messages) {
|
|
173
|
+
expect(msg.endsWith('\r\n')).toBe(false);
|
|
174
|
+
}
|
|
175
|
+
client.ws.close();
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('closes with code 1003 when a binary frame arrives on a spec-text connection', async () => {
|
|
179
|
+
const { client } = await openUpgrade('conn-subproto-bin-on-text', 'text.ircv3.net');
|
|
180
|
+
client.ws.send(new Uint8Array([0x4e, 0x49, 0x43, 0x4b]).buffer);
|
|
181
|
+
const code = await client.waitForClose();
|
|
182
|
+
expect(code).toBe(1003);
|
|
183
|
+
});
|
|
184
|
+
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* `ConnectionDO` (hibernatable WS), state migration, alarms.
|
|
3
3
|
*
|
|
4
|
-
* TDD outline
|
|
4
|
+
* TDD outline:
|
|
5
5
|
* "Red via `@cloudflare/vitest-pool-workers` on 'hibernate then wake
|
|
6
6
|
* preserves nick'; green; then alarm-driven PING."
|
|
7
7
|
*
|
|
@@ -406,6 +406,31 @@ describe('ConnectionDO — SASL PLAIN end-to-end', () => {
|
|
|
406
406
|
});
|
|
407
407
|
});
|
|
408
408
|
|
|
409
|
+
describe('ConnectionDO — OPER command (env-thread operCreds)', () => {
|
|
410
|
+
it('grants oper with 381 RPL_YOUREOPER for matching OPER_USER/OPER_PASSWORD', async () => {
|
|
411
|
+
const client = await connect('conn-oper-ok');
|
|
412
|
+
await register(client, 'alice');
|
|
413
|
+
|
|
414
|
+
client.send('OPER cf-oper oper-secret\r\n');
|
|
415
|
+
const reply = await client.waitForMessage((l) => / 381 | 491 | 464 | 461 /.test(l));
|
|
416
|
+
|
|
417
|
+
expect(reply).toContain(' 381 ');
|
|
418
|
+
expect(reply).toContain('You are now an IRC operator');
|
|
419
|
+
client.ws.close();
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
it('rejects oper with 464 ERR_PASSWDMISMATCH for a wrong password', async () => {
|
|
423
|
+
const client = await connect('conn-oper-bad');
|
|
424
|
+
await register(client, 'bob');
|
|
425
|
+
|
|
426
|
+
client.send('OPER cf-oper wrong\r\n');
|
|
427
|
+
const reply = await client.waitForMessage((l) => / 381 | 491 | 464 | 461 /.test(l));
|
|
428
|
+
|
|
429
|
+
expect(reply).toContain(' 464 ');
|
|
430
|
+
client.ws.close();
|
|
431
|
+
});
|
|
432
|
+
});
|
|
433
|
+
|
|
409
434
|
// ---------------------------------------------------------------------------
|
|
410
435
|
// End of file
|
|
411
436
|
// ---------------------------------------------------------------------------
|
|
@@ -224,3 +224,51 @@ describe('resolveAccountStore precedence', () => {
|
|
|
224
224
|
).toBe(true);
|
|
225
225
|
});
|
|
226
226
|
});
|
|
227
|
+
|
|
228
|
+
// ---------------------------------------------------------------------------
|
|
229
|
+
// resolveAccountStore seed parsing — malformed entries are skipped so a
|
|
230
|
+
// trailing newline or partial edit does not break boot.
|
|
231
|
+
// ---------------------------------------------------------------------------
|
|
232
|
+
|
|
233
|
+
describe('resolveAccountStore — SASL_ACCOUNTS seed edge cases', () => {
|
|
234
|
+
it('skips blank lines within the seed', async () => {
|
|
235
|
+
const store = await resolveAccountStore(undefined, 'alice:secret\n\nbob:pw\n \n');
|
|
236
|
+
expect(store).toBeDefined();
|
|
237
|
+
expect(
|
|
238
|
+
store?.verify('PLAIN', { kind: 'PLAIN', username: 'alice', password: 'secret' }).ok,
|
|
239
|
+
).toBe(true);
|
|
240
|
+
expect(store?.verify('PLAIN', { kind: 'PLAIN', username: 'bob', password: 'pw' }).ok).toBe(
|
|
241
|
+
true,
|
|
242
|
+
);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it('skips lines with no colon separator', async () => {
|
|
246
|
+
const store = await resolveAccountStore(undefined, 'alice:secret\nnocolon\nbob:pw');
|
|
247
|
+
// Only the two well-formed entries are loaded.
|
|
248
|
+
expect(store?.verify('PLAIN', { kind: 'PLAIN', username: 'nocolon', password: '' }).ok).toBe(
|
|
249
|
+
false,
|
|
250
|
+
);
|
|
251
|
+
expect(
|
|
252
|
+
store?.verify('PLAIN', { kind: 'PLAIN', username: 'alice', password: 'secret' }).ok,
|
|
253
|
+
).toBe(true);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it('skips lines where the colon is at position 0', async () => {
|
|
257
|
+
const store = await resolveAccountStore(undefined, ':secret\nalice:valid');
|
|
258
|
+
expect(store?.verify('PLAIN', { kind: 'PLAIN', username: 'alice', password: 'valid' }).ok).toBe(
|
|
259
|
+
true,
|
|
260
|
+
);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it('skips lines with an empty password', async () => {
|
|
264
|
+
const store = await resolveAccountStore(undefined, 'alice:\nbob:valid');
|
|
265
|
+
expect(store?.verify('PLAIN', { kind: 'PLAIN', username: 'bob', password: 'valid' }).ok).toBe(
|
|
266
|
+
true,
|
|
267
|
+
);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it('returns undefined when every line is malformed', async () => {
|
|
271
|
+
const store = await resolveAccountStore(undefined, '\n \n:no-user\nuser:');
|
|
272
|
+
expect(store).toBeUndefined();
|
|
273
|
+
});
|
|
274
|
+
});
|