serverless-ircd 0.8.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/ci.yml +4 -0
- package/.github/workflows/deploy-aws.yml +156 -32
- package/.github/workflows/deploy-cf-tcp.yml +11 -9
- package/.github/workflows/deploy-cf.yml +14 -14
- package/CHANGELOG.md +550 -0
- package/README.md +275 -222
- package/apps/aws-stack/README.md +3 -5
- package/apps/aws-stack/bin/aws.ts +82 -9
- package/apps/aws-stack/cdk.json +0 -3
- package/apps/aws-stack/package.json +3 -4
- package/apps/aws-stack/src/aws-stack.ts +177 -52
- package/apps/aws-stack/src/static-site.ts +323 -0
- package/apps/aws-stack/tests/smoke-helpers.test.ts +1 -1
- package/apps/aws-stack/tests/stack.test.ts +267 -92
- package/apps/aws-stack/tests/static-site.test.ts +491 -0
- package/apps/aws-stack/tests/synth-no-bundle.test.ts +0 -1
- package/apps/cf-tcp-container/package.json +2 -3
- package/apps/cf-tcp-container/src/container-server.ts +33 -10
- package/apps/cf-tcp-container/tests/config-loader.test.ts +43 -0
- package/apps/cf-tcp-container/tests/container-server.test.ts +249 -1
- package/apps/cf-tcp-container/tests/persistence.test.ts +9 -0
- package/apps/cf-tcp-container/tests/tls-e2e.test.ts +24 -5
- package/apps/cf-tcp-container/wrangler.toml +1 -10
- package/apps/cf-worker/package.json +3 -4
- package/apps/cf-worker/wrangler.toml +12 -71
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/server.ts +115 -48
- package/apps/local-cli/tests/config-resolution.test.ts +65 -0
- package/apps/local-cli/tests/motd-file-non-error.test.ts +29 -0
- package/apps/local-cli/tests/rehash.test.ts +147 -0
- package/apps/local-cli/tests/server-helpers.test.ts +63 -0
- package/apps/local-cli/tests/tcp.test.ts +89 -0
- package/apps/local-cli/tests/ws-subprotocol.test.ts +92 -0
- package/apps/web/landing/favicon.ico +0 -0
- package/apps/web/landing/index.html +227 -3
- package/apps/web/package.json +3 -2
- package/apps/web/scripts/build.mjs +91 -6
- package/apps/web/src/build-env.ts +125 -4
- package/apps/web/src/config-schema.ts +20 -6
- package/apps/web/src/render-docs.ts +292 -0
- package/apps/web/static/{config.staging.json → config.prod-aws.json} +3 -2
- package/apps/web/tests/build-env.test.ts +210 -9
- package/apps/web/tests/build-smoke.test.ts +33 -4
- package/apps/web/tests/config-schema.test.ts +149 -25
- package/apps/web/tests/landing-content.test.ts +103 -0
- package/apps/web/tests/render-docs.test.ts +198 -0
- package/docs/AWS-Adapter-Architecture.md +3 -2
- package/docs/AWS-Deployment.md +670 -96
- package/docs/AWS-TCP-Deployment.md +20 -45
- package/docs/Cloudflare-Deployment-Guide.md +87 -113
- package/docs/Cloudflare-TCP-Deployment.md +25 -49
- package/docs/Release-Process.md +27 -23
- package/docs/Services.md +102 -23
- package/docs/WebClientGuide.md +35 -26
- package/package.json +7 -10
- package/packages/aws-adapter/package.json +1 -1
- package/packages/aws-adapter/src/aws-runtime.ts +15 -1
- package/packages/aws-adapter/src/cdk-table-defs.ts +6 -11
- package/packages/aws-adapter/src/config-loader.ts +19 -2
- package/packages/aws-adapter/src/dynamo-services-store.ts +7 -0
- package/packages/aws-adapter/src/handlers/connect.ts +26 -0
- package/packages/aws-adapter/src/handlers/default.ts +190 -123
- package/packages/aws-adapter/src/handlers/index.ts +67 -23
- package/packages/aws-adapter/src/handlers/nlb-stream.ts +13 -8
- package/packages/aws-adapter/src/index.ts +5 -7
- package/packages/aws-adapter/src/origin-allowlist.ts +94 -0
- package/packages/aws-adapter/src/serialize.ts +15 -0
- package/packages/aws-adapter/src/tables.ts +2 -12
- package/packages/aws-adapter/tests/aws-harness.ts +0 -1
- package/packages/aws-adapter/tests/aws-runtime.test.ts +23 -1
- package/packages/aws-adapter/tests/config-loader.test.ts +66 -0
- package/packages/aws-adapter/tests/connect.test.ts +124 -1
- package/packages/aws-adapter/tests/connection-counter.test.ts +17 -0
- package/packages/aws-adapter/tests/default-occ.test.ts +219 -0
- package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +11 -0
- package/packages/aws-adapter/tests/global-setup.ts +28 -1
- package/packages/aws-adapter/tests/gone-exception.test.ts +21 -2
- package/packages/aws-adapter/tests/handlers.test.ts +117 -11
- package/packages/aws-adapter/tests/migrate-accounts-to-services.test.ts +164 -0
- package/packages/aws-adapter/tests/nlb-stream.test.ts +29 -1
- package/packages/aws-adapter/tests/origin-allowlist.test.ts +110 -0
- package/packages/aws-adapter/tests/ping-checker.test.ts +0 -1
- package/packages/aws-adapter/tests/stats.test.ts +0 -3
- package/packages/aws-adapter/tests/sweeper.test.ts +20 -1
- package/packages/aws-adapter/tests/tables.test.ts +1 -8
- package/packages/aws-adapter/tests/transactions.test.ts +0 -1
- package/packages/cf-adapter/package.json +1 -5
- package/packages/cf-adapter/src/cf-runtime.ts +59 -8
- package/packages/cf-adapter/src/channel-do.ts +13 -3
- package/packages/cf-adapter/src/connection-do.ts +284 -115
- package/packages/cf-adapter/src/d1-services-store.ts +63 -26
- package/packages/cf-adapter/src/env.ts +11 -10
- package/packages/cf-adapter/src/index.ts +0 -6
- package/packages/cf-adapter/tests/cf-runtime.test.ts +101 -1
- package/packages/cf-adapter/tests/channel-do.test.ts +118 -1
- package/packages/cf-adapter/tests/connection-do-coverage.test.ts +460 -0
- package/packages/cf-adapter/tests/connection-do-pure.test.ts +222 -51
- package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +62 -38
- package/packages/cf-adapter/tests/d1-services-store.test.ts +53 -2
- package/packages/cf-adapter/tests/serialize.test.ts +25 -0
- package/packages/in-memory-runtime/package.json +1 -1
- package/packages/irc-core/package.json +1 -1
- package/packages/irc-core/src/account-migration.ts +140 -0
- package/packages/irc-core/src/commands/account-auth.ts +60 -35
- package/packages/irc-core/src/commands/chanserv.ts +288 -4
- package/packages/irc-core/src/commands/hostserv.ts +38 -3
- package/packages/irc-core/src/commands/index.ts +1 -0
- package/packages/irc-core/src/commands/join.ts +41 -35
- package/packages/irc-core/src/commands/memoserv.ts +1 -1
- package/packages/irc-core/src/commands/nickserv.ts +138 -15
- package/packages/irc-core/src/commands/registration.ts +28 -17
- package/packages/irc-core/src/commands/sasl.ts +22 -31
- package/packages/irc-core/src/commands/service-aliases.ts +52 -0
- package/packages/irc-core/src/commands/topic.ts +23 -10
- package/packages/irc-core/src/config.ts +35 -9
- package/packages/irc-core/src/credential-hashing.ts +11 -54
- package/packages/irc-core/src/index.ts +1 -0
- package/packages/irc-core/src/ports.ts +159 -179
- package/packages/irc-core/src/state/channel.ts +17 -0
- package/packages/irc-core/src/types.ts +38 -10
- package/packages/irc-core/tests/account-migration.test.ts +133 -0
- package/packages/irc-core/tests/commands/chanserv.test.ts +668 -1
- package/packages/irc-core/tests/commands/hostserv.test.ts +71 -0
- package/packages/irc-core/tests/commands/join.test.ts +179 -0
- package/packages/irc-core/tests/commands/markread.test.ts +54 -0
- package/packages/irc-core/tests/commands/memoserv.test.ts +19 -0
- package/packages/irc-core/tests/commands/nickserv.test.ts +422 -3
- package/packages/irc-core/tests/commands/oper.test.ts +15 -0
- package/packages/irc-core/tests/commands/registration.test.ts +336 -108
- package/packages/irc-core/tests/commands/sasl.test.ts +194 -169
- package/packages/irc-core/tests/commands/service-aliases.test.ts +52 -0
- package/packages/irc-core/tests/commands/unified-account.test.ts +102 -84
- package/packages/irc-core/tests/credential-hashing.test.ts +0 -78
- package/packages/irc-core/tests/message-store.test.ts +5 -0
- package/packages/irc-core/tests/persistent-services-store.test.ts +71 -12
- package/packages/irc-core/tests/ports.test.ts +71 -0
- package/packages/irc-core/tests/services-store.test.ts +204 -0
- package/packages/irc-core/vitest.config.ts +6 -1
- package/packages/irc-server/package.json +1 -1
- package/packages/irc-server/src/actor.ts +80 -44
- package/packages/irc-server/tests/actor.test.ts +384 -50
- package/packages/irc-test-support/package.json +1 -1
- package/packages/irc-test-support/src/in-memory-harness.ts +8 -5
- package/packages/irc-test-support/src/scenarios.ts +21 -6
- package/packages/irc-test-support/tests/in-memory-harness.test.ts +19 -0
- package/packages/irc-test-support/vitest.config.ts +6 -1
- package/pnpm-workspace.yaml +1 -0
- package/scripts/__tests__/deploy-web-aws.test.ts +491 -0
- package/scripts/deploy-web-aws.mjs +290 -0
- package/scripts/package.json +23 -0
- package/scripts/tsconfig.test.json +12 -0
- package/scripts/vitest.config.ts +19 -0
- package/tools/ci-hardening/package.json +1 -1
- package/tools/ci-hardening/src/index.ts +2 -0
- package/tools/ci-hardening/src/validate.ts +57 -0
- package/tools/ci-hardening/tests/deploy-aws-oidc.test.ts +96 -0
- package/tools/ci-hardening/tests/validate.test.ts +42 -0
- package/tools/load-test/package.json +1 -1
- package/tools/load-test/src/client.ts +13 -13
- package/tools/load-test/tests/client.test.ts +258 -2
- package/tools/load-test/tests/config.test.ts +39 -0
- package/tools/load-test/tests/harness.test.ts +21 -0
- package/tools/load-test/tests/metrics.test.ts +7 -0
- package/tools/migrate-accounts-to-services.ts +270 -0
- package/tools/package.json +2 -1
- package/tools/seed-aws-accounts.ts +35 -10
- package/tools/seed-cf-accounts.ts +42 -9
- package/tools/tcp-ws-forwarder/package.json +1 -1
- package/tools/tcp-ws-forwarder/tests/close-error.test.ts +40 -0
- package/tools/tcp-ws-forwarder/tests/defensive-branches.test.ts +78 -0
- package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +51 -0
- package/tools/tcp-ws-forwarder/tests/logger.test.ts +31 -1
- package/packages/aws-adapter/src/account-store.ts +0 -121
- package/packages/aws-adapter/src/dynamo-account-store.ts +0 -95
- package/packages/aws-adapter/tests/account-store-dynamo.test.ts +0 -223
- package/packages/aws-adapter/tests/account-store.test.ts +0 -276
- package/packages/cf-adapter/src/d1-account-store.ts +0 -198
- package/packages/cf-adapter/tests/d1-account-store.test.ts +0 -274
- package/packages/irc-core/tests/account-store.test.ts +0 -131
|
@@ -6,86 +6,257 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { describe, expect, it } from 'vitest';
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
NO_OP_DISCONNECT,
|
|
11
|
+
NO_OP_SEND,
|
|
12
|
+
buildOptionalActorFields,
|
|
13
|
+
chooseMotdProvider,
|
|
14
|
+
extractMtlsSubject,
|
|
15
|
+
wsFrameModeFromTags,
|
|
16
|
+
} from '../src/connection-do';
|
|
10
17
|
|
|
11
|
-
describe('
|
|
12
|
-
it(
|
|
13
|
-
expect(
|
|
18
|
+
describe('wsFrameModeFromTags', () => {
|
|
19
|
+
it("returns 'legacy' when no tags are present", () => {
|
|
20
|
+
expect(wsFrameModeFromTags([])).toBe('legacy');
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("returns 'legacy' when no recognized tag matches", () => {
|
|
24
|
+
// Unknown tags (e.g. from an older deployment's tag scheme) fall
|
|
25
|
+
// back to the legacy-tolerant mode.
|
|
26
|
+
expect(wsFrameModeFromTags(['unknown:tag', 'another'])).toBe('legacy');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("returns 'spec-text' when the spec-text tag is present", () => {
|
|
30
|
+
expect(wsFrameModeFromTags(['ws:spec-text'])).toBe('spec-text');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("returns 'spec-binary' when the spec-binary tag is present", () => {
|
|
34
|
+
expect(wsFrameModeFromTags(['ws:spec-binary'])).toBe('spec-binary');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("returns 'legacy' when the legacy tag is present", () => {
|
|
38
|
+
expect(wsFrameModeFromTags(['ws:legacy'])).toBe('legacy');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('picks the first recognized tag when multiple are present', () => {
|
|
42
|
+
// The scan is order-sensitive; the first matching tag wins.
|
|
43
|
+
expect(wsFrameModeFromTags(['ws:spec-text', 'ws:legacy'])).toBe('spec-text');
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// ============================================================================
|
|
48
|
+
// PassthroughChannelAccess.refreshChannel
|
|
49
|
+
// ============================================================================
|
|
50
|
+
//
|
|
51
|
+
// refreshChannel pulls the authoritative snapshot from the ChannelDO and
|
|
52
|
+
// rebuilds (or updates) the local cache entry. The zero-members branch is
|
|
53
|
+
// the post-deploy state: the ChannelDO persisted a topic across the
|
|
54
|
+
// deploy but its roster is empty, so the local cache must still absorb
|
|
55
|
+
// the topic/modes/banMasks or the JOIN reducer runs against a topic-less
|
|
56
|
+
// state and never emits 332/333.
|
|
57
|
+
// ============================================================================
|
|
58
|
+
|
|
59
|
+
import { PassthroughChannelAccess } from '../src/connection-do';
|
|
60
|
+
import type { Env } from '../src/env';
|
|
61
|
+
|
|
62
|
+
const EMPTY_MODES = {
|
|
63
|
+
inviteOnly: false,
|
|
64
|
+
topicLock: false,
|
|
65
|
+
noExternal: false,
|
|
66
|
+
moderated: false,
|
|
67
|
+
secret: false,
|
|
68
|
+
private: false,
|
|
69
|
+
registered: false,
|
|
70
|
+
blockUnidentified: false,
|
|
71
|
+
moderatedIdent: false,
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/** Builds a fake Env whose CHANNEL_DO returns `snapshot` from getChannelSnapshot. */
|
|
75
|
+
function makeEnvWithSnapshot(snapshot: unknown): Env {
|
|
76
|
+
const fakeChannelDo = {
|
|
77
|
+
idFromName: (key: string) => `id-${key}`,
|
|
78
|
+
get: () => ({ getChannelSnapshot: async () => snapshot }),
|
|
79
|
+
};
|
|
80
|
+
return { CHANNEL_DO: fakeChannelDo } as unknown as Env;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
describe('PassthroughChannelAccess.refreshChannel', () => {
|
|
84
|
+
it('propagates topic/modes/banMasks onto the cached entry when the snapshot reports zero members', async () => {
|
|
85
|
+
const snap = {
|
|
86
|
+
modes: { ...EMPTY_MODES, topicLock: true },
|
|
87
|
+
banMasks: ['bad!*@*'],
|
|
88
|
+
topic: { text: 'survived deploy', setter: 'alice!alice@example.com', setAt: 1234 },
|
|
89
|
+
members: [],
|
|
90
|
+
pendingInvites: [],
|
|
91
|
+
createdAt: 0,
|
|
92
|
+
};
|
|
93
|
+
const env = makeEnvWithSnapshot(snap);
|
|
94
|
+
const access = new PassthroughChannelAccess(0, env);
|
|
95
|
+
const chan = access.getOrCreateChannel('#foo');
|
|
96
|
+
expect(chan.topic).toBeUndefined();
|
|
97
|
+
|
|
98
|
+
await access.refreshChannel('#foo');
|
|
99
|
+
|
|
100
|
+
expect(chan.topic?.text).toBe('survived deploy');
|
|
101
|
+
expect(chan.topic?.setter).toBe('alice!alice@example.com');
|
|
102
|
+
expect(chan.topic?.setAt).toBe(1234);
|
|
103
|
+
expect(chan.modes.topicLock).toBe(true);
|
|
104
|
+
expect(chan.banMasks.has('bad!*@*')).toBe(true);
|
|
14
105
|
});
|
|
15
106
|
|
|
16
|
-
it('
|
|
17
|
-
|
|
107
|
+
it('leaves the cached topic unset when a zero-members snapshot carries no topic', async () => {
|
|
108
|
+
const snap = {
|
|
109
|
+
modes: { ...EMPTY_MODES },
|
|
110
|
+
banMasks: [],
|
|
111
|
+
members: [],
|
|
112
|
+
pendingInvites: [],
|
|
113
|
+
createdAt: 0,
|
|
114
|
+
};
|
|
115
|
+
const env = makeEnvWithSnapshot(snap);
|
|
116
|
+
const access = new PassthroughChannelAccess(0, env);
|
|
117
|
+
const chan = access.getOrCreateChannel('#foo');
|
|
118
|
+
|
|
119
|
+
await access.refreshChannel('#foo');
|
|
120
|
+
|
|
121
|
+
expect(chan.topic).toBeUndefined();
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('does not seed a cache entry when the snapshot reports zero members and none exists', async () => {
|
|
125
|
+
const snap = {
|
|
126
|
+
modes: { ...EMPTY_MODES },
|
|
127
|
+
banMasks: [],
|
|
128
|
+
topic: { text: 'x', setter: 's', setAt: 0 },
|
|
129
|
+
members: [],
|
|
130
|
+
pendingInvites: [],
|
|
131
|
+
createdAt: 0,
|
|
132
|
+
};
|
|
133
|
+
const env = makeEnvWithSnapshot(snap);
|
|
134
|
+
const access = new PassthroughChannelAccess(0, env);
|
|
135
|
+
|
|
136
|
+
await access.refreshChannel('#never');
|
|
137
|
+
|
|
138
|
+
// No cache entry was created: getOrCreateChannel now seeds a fresh one
|
|
139
|
+
// with no topic (the zero-members refresh must not seed a new entry).
|
|
140
|
+
const chan = access.getOrCreateChannel('#never');
|
|
141
|
+
expect(chan.topic).toBeUndefined();
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('rebuilds the cached entry from a snapshot with members', async () => {
|
|
145
|
+
const snap = {
|
|
146
|
+
modes: { ...EMPTY_MODES, secret: true },
|
|
147
|
+
banMasks: [],
|
|
148
|
+
topic: { text: 'hi', setter: 's', setAt: 9 },
|
|
149
|
+
members: [{ conn: 'c0', nick: 'alice', op: true, voice: false }],
|
|
150
|
+
pendingInvites: [],
|
|
151
|
+
createdAt: 7,
|
|
152
|
+
};
|
|
153
|
+
const env = makeEnvWithSnapshot(snap);
|
|
154
|
+
const access = new PassthroughChannelAccess(0, env);
|
|
155
|
+
|
|
156
|
+
await access.refreshChannel('#foo');
|
|
157
|
+
|
|
158
|
+
const chan = access.getOrCreateChannel('#foo');
|
|
159
|
+
expect(chan.topic?.text).toBe('hi');
|
|
160
|
+
expect(chan.modes.secret).toBe(true);
|
|
161
|
+
expect(chan.members.size).toBe(1);
|
|
162
|
+
expect(chan.members.get('c0')?.nick).toBe('alice');
|
|
18
163
|
});
|
|
19
164
|
|
|
20
|
-
it('
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
165
|
+
it('is a no-op when the ChannelDO snapshot is null', async () => {
|
|
166
|
+
const env = makeEnvWithSnapshot(null);
|
|
167
|
+
const access = new PassthroughChannelAccess(0, env);
|
|
168
|
+
const chan = access.getOrCreateChannel('#foo');
|
|
169
|
+
chan.topic = { text: 'keep', setter: 's', setAt: 1 };
|
|
170
|
+
|
|
171
|
+
await access.refreshChannel('#foo');
|
|
172
|
+
|
|
173
|
+
expect(chan.topic?.text).toBe('keep');
|
|
24
174
|
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// ============================================================================
|
|
178
|
+
// chooseMotdProvider — default vs. populated MOTD_LINES branch
|
|
179
|
+
// ============================================================================
|
|
25
180
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
]);
|
|
181
|
+
describe('chooseMotdProvider', () => {
|
|
182
|
+
it('returns the default MOTD when the env value is undefined', () => {
|
|
183
|
+
const provider = chooseMotdProvider(undefined);
|
|
184
|
+
expect(provider.lines()).toEqual(['Welcome to the ServerlessIRCd Cloudflare adapter.']);
|
|
31
185
|
});
|
|
32
186
|
|
|
33
|
-
it('
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
{ username: 'bob', password: 'pw' },
|
|
37
|
-
]);
|
|
187
|
+
it('returns the default MOTD when the env value is an empty string', () => {
|
|
188
|
+
const provider = chooseMotdProvider('');
|
|
189
|
+
expect(provider.lines()).toEqual(['Welcome to the ServerlessIRCd Cloudflare adapter.']);
|
|
38
190
|
});
|
|
39
191
|
|
|
40
|
-
it('
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
{ username: 'bob', password: 'pw' },
|
|
44
|
-
]);
|
|
192
|
+
it('splits a populated MOTD_LINES on newlines', () => {
|
|
193
|
+
const provider = chooseMotdProvider('Line one\nLine two\nLine three');
|
|
194
|
+
expect(provider.lines()).toEqual(['Line one', 'Line two', 'Line three']);
|
|
45
195
|
});
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
// ============================================================================
|
|
199
|
+
// buildOptionalActorFields — conditional spread branches
|
|
200
|
+
// ============================================================================
|
|
46
201
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
]);
|
|
202
|
+
describe('buildOptionalActorFields', () => {
|
|
203
|
+
it('returns an empty object when every optional is undefined', () => {
|
|
204
|
+
expect(buildOptionalActorFields(undefined, undefined)).toEqual({});
|
|
51
205
|
});
|
|
52
206
|
|
|
53
|
-
it('
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
]);
|
|
207
|
+
it('includes `services` when defined', () => {
|
|
208
|
+
const fake = {} as unknown as Parameters<typeof buildOptionalActorFields>[0];
|
|
209
|
+
expect(buildOptionalActorFields(fake, undefined).services).toBe(fake);
|
|
57
210
|
});
|
|
58
211
|
|
|
59
|
-
it('
|
|
60
|
-
|
|
212
|
+
it('includes `mtlsIdentity` when defined', () => {
|
|
213
|
+
const fake = {} as unknown as Parameters<typeof buildOptionalActorFields>[1];
|
|
214
|
+
expect(buildOptionalActorFields(undefined, fake).mtlsIdentity).toBe(fake);
|
|
61
215
|
});
|
|
62
216
|
});
|
|
63
217
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
218
|
+
// ============================================================================
|
|
219
|
+
// NO_OP_SEND / NO_OP_DISCONNECT — invoked once to cover the function bodies
|
|
220
|
+
// (the no-socket teardown path constructs these handlers but the actor never
|
|
221
|
+
// invokes send/disconnect during QUIT processing — there is no peer to
|
|
222
|
+
// deliver to and the socket is already gone).
|
|
223
|
+
// ============================================================================
|
|
224
|
+
|
|
225
|
+
describe('NO_OP_SEND / NO_OP_DISCONNECT', () => {
|
|
226
|
+
it('NO_OP_SEND runs without throwing and returns undefined', () => {
|
|
227
|
+
expect(NO_OP_SEND([{ text: 'ignored' }])).toBeUndefined();
|
|
67
228
|
});
|
|
68
229
|
|
|
69
|
-
it(
|
|
70
|
-
|
|
71
|
-
// back to the legacy-tolerant mode.
|
|
72
|
-
expect(wsFrameModeFromTags(['unknown:tag', 'another'])).toBe('legacy');
|
|
230
|
+
it('NO_OP_DISCONNECT runs without throwing and returns undefined', () => {
|
|
231
|
+
expect(NO_OP_DISCONNECT('any reason')).toBeUndefined();
|
|
73
232
|
});
|
|
233
|
+
});
|
|
74
234
|
|
|
75
|
-
|
|
76
|
-
|
|
235
|
+
// ============================================================================
|
|
236
|
+
// extractMtlsSubject — request.cf.tlsClientAuthCertSubject capture
|
|
237
|
+
// ============================================================================
|
|
238
|
+
|
|
239
|
+
describe('extractMtlsSubject', () => {
|
|
240
|
+
it('returns the subject when cf.tlsClientAuthCertSubject is a string', () => {
|
|
241
|
+
// The CF edge injects request.cf on every proxied request. We can't
|
|
242
|
+
// mutate Request.cf in workerd tests (it's read-only), so the
|
|
243
|
+
// upgrade-time capture is exercised via this pure seam.
|
|
244
|
+
const fake = { cf: { tlsClientAuthCertSubject: 'CN=alice' } } as unknown as Request;
|
|
245
|
+
expect(extractMtlsSubject(fake)).toBe('CN=alice');
|
|
77
246
|
});
|
|
78
247
|
|
|
79
|
-
it(
|
|
80
|
-
|
|
248
|
+
it('returns undefined when cf is absent', () => {
|
|
249
|
+
const fake = { cf: undefined } as unknown as Request;
|
|
250
|
+
expect(extractMtlsSubject(fake)).toBeUndefined();
|
|
81
251
|
});
|
|
82
252
|
|
|
83
|
-
it(
|
|
84
|
-
|
|
253
|
+
it('returns undefined when cf is present but lacks tlsClientAuthCertSubject', () => {
|
|
254
|
+
const fake = { cf: { country: 'US' } } as unknown as Request;
|
|
255
|
+
expect(extractMtlsSubject(fake)).toBeUndefined();
|
|
85
256
|
});
|
|
86
257
|
|
|
87
|
-
it('
|
|
88
|
-
|
|
89
|
-
expect(
|
|
258
|
+
it('returns undefined when tlsClientAuthCertSubject is not a string', () => {
|
|
259
|
+
const fake = { cf: { tlsClientAuthCertSubject: 42 } } as unknown as Request;
|
|
260
|
+
expect(extractMtlsSubject(fake)).toBeUndefined();
|
|
90
261
|
});
|
|
91
262
|
});
|
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* End-to-end SASL PLAIN through ConnectionDO with D1-backed
|
|
2
|
+
* End-to-end SASL PLAIN through ConnectionDO with D1-backed services.
|
|
3
3
|
*
|
|
4
|
-
* Verifies the
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* - D1 empty → the `SASL_ACCOUNTS` env-var seed still works (fallback).
|
|
4
|
+
* Verifies the services-store credential path: an account seeded into the
|
|
5
|
+
* `nickserv_accounts` table (via `ServicesStore.registerNick`) verifies
|
|
6
|
+
* end-to-end through `AUTHENTICATE PLAIN`.
|
|
8
7
|
*
|
|
9
8
|
* Each test file gets its own isolated D1 database from miniflare, so the
|
|
10
9
|
* table state set up here does not leak into `connection-do.test.ts`.
|
|
@@ -12,7 +11,7 @@
|
|
|
12
11
|
|
|
13
12
|
import { env } from 'cloudflare:test';
|
|
14
13
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
15
|
-
import {
|
|
14
|
+
import { CREATE_SERVICES_TABLES_SQL } from '../src/d1-services-store';
|
|
16
15
|
|
|
17
16
|
declare global {
|
|
18
17
|
namespace Cloudflare {
|
|
@@ -107,16 +106,56 @@ async function authenticatePlain(
|
|
|
107
106
|
client.send(`AUTHENTICATE ${plainB64(authcid, password)}\r\n`);
|
|
108
107
|
}
|
|
109
108
|
|
|
110
|
-
|
|
109
|
+
/**
|
|
110
|
+
* Seeds a NickServ registration directly into the D1 services table by
|
|
111
|
+
* driving a first connection through `PRIVMSG NickServ :REGISTER`. Returns
|
|
112
|
+
* once the registration NOTICE is observed.
|
|
113
|
+
*/
|
|
114
|
+
async function seedViaNickservRegister(
|
|
115
|
+
connId: string,
|
|
116
|
+
nick: string,
|
|
117
|
+
password: string,
|
|
118
|
+
email: string,
|
|
119
|
+
): Promise<IrcWsClient> {
|
|
120
|
+
const client = await connect(connId);
|
|
121
|
+
client.send(`NICK ${nick}\r\n`);
|
|
122
|
+
client.send(`USER ${nick} 0 * :${nick}\r\n`);
|
|
123
|
+
await client.waitForMessage((l) => l.includes(' 001 '));
|
|
124
|
+
client.send(`PRIVMSG NickServ :REGISTER ${password} ${email}\r\n`);
|
|
125
|
+
await client.waitForMessage((l) => l.includes('NickServ') && l.includes('registered'));
|
|
126
|
+
return client;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
describe('ConnectionDO — SASL PLAIN with D1-backed services', () => {
|
|
111
130
|
beforeEach(async () => {
|
|
112
|
-
|
|
131
|
+
for (const sql of CREATE_SERVICES_TABLES_SQL) {
|
|
132
|
+
await env.ACCOUNTS_DB.exec(sql);
|
|
133
|
+
}
|
|
113
134
|
});
|
|
114
135
|
afterEach(async () => {
|
|
115
|
-
await env.ACCOUNTS_DB.exec('DROP TABLE IF EXISTS
|
|
136
|
+
await env.ACCOUNTS_DB.exec('DROP TABLE IF EXISTS nickserv_accounts');
|
|
137
|
+
await env.ACCOUNTS_DB.exec('DROP TABLE IF EXISTS chanserv_channels');
|
|
138
|
+
await env.ACCOUNTS_DB.exec('DROP TABLE IF EXISTS chanserv_access');
|
|
139
|
+
await env.ACCOUNTS_DB.exec('DROP TABLE IF EXISTS chanserv_levels');
|
|
140
|
+
await env.ACCOUNTS_DB.exec('DROP TABLE IF EXISTS hostserv_vhosts');
|
|
141
|
+
await env.ACCOUNTS_DB.exec('DROP TABLE IF EXISTS hostserv_pending_vhosts');
|
|
142
|
+
await env.ACCOUNTS_DB.exec('DROP TABLE IF EXISTS memoserv_memos');
|
|
143
|
+
await env.ACCOUNTS_DB.exec('DROP TABLE IF EXISTS read_markers');
|
|
144
|
+
await env.ACCOUNTS_DB.exec('DROP TABLE IF EXISTS operserv_akills');
|
|
145
|
+
await env.ACCOUNTS_DB.exec('DROP TABLE IF EXISTS operserv_jupes');
|
|
116
146
|
});
|
|
117
147
|
|
|
118
|
-
it('completes SASL PLAIN with 900/903 for a
|
|
119
|
-
|
|
148
|
+
it('completes SASL PLAIN with 900/903 for a NickServ-registered account', async () => {
|
|
149
|
+
// Seed alice via NickServ REGISTER on a first connection.
|
|
150
|
+
const seeder = await seedViaNickservRegister(
|
|
151
|
+
'conn-d1-sasl-seed',
|
|
152
|
+
'd1-alice',
|
|
153
|
+
'd1-s3cret',
|
|
154
|
+
'd1@example.com',
|
|
155
|
+
);
|
|
156
|
+
await seeder.close();
|
|
157
|
+
|
|
158
|
+
// A second connection speaking SASL PLAIN against the registered nick.
|
|
120
159
|
const client = await connect('conn-d1-sasl-1');
|
|
121
160
|
await authenticatePlain(client, 'd1-alice', 'd1-s3cret');
|
|
122
161
|
await client.waitForMessage((l) => l.includes(' 900 '));
|
|
@@ -125,42 +164,27 @@ describe('ConnectionDO — SASL PLAIN with D1-backed accounts', () => {
|
|
|
125
164
|
await client.close();
|
|
126
165
|
});
|
|
127
166
|
|
|
128
|
-
it('rejects SASL PLAIN with 904 for a wrong password
|
|
129
|
-
await
|
|
167
|
+
it('rejects SASL PLAIN with 904 for a wrong password', async () => {
|
|
168
|
+
const seeder = await seedViaNickservRegister(
|
|
169
|
+
'conn-d1-sasl-seed2',
|
|
170
|
+
'd1-bob',
|
|
171
|
+
'd1-s3cret',
|
|
172
|
+
'd1@example.com',
|
|
173
|
+
);
|
|
174
|
+
await seeder.close();
|
|
175
|
+
|
|
130
176
|
const client = await connect('conn-d1-sasl-2');
|
|
131
|
-
await authenticatePlain(client, 'd1-
|
|
177
|
+
await authenticatePlain(client, 'd1-bob', 'wrong');
|
|
132
178
|
const fail = await client.waitForMessage((l) => l.includes(' 904 '));
|
|
133
179
|
expect(fail).toContain(' 904 ');
|
|
134
180
|
await client.close();
|
|
135
181
|
});
|
|
136
182
|
|
|
137
|
-
it('
|
|
138
|
-
// SASL_ACCOUNTS env seeds "cf-sasl:s3cret" but D1 has no such row.
|
|
139
|
-
await putAccountCredential(env.ACCOUNTS_DB, 'd1-alice', 'd1-s3cret');
|
|
183
|
+
it('rejects SASL PLAIN with 904 for an unknown nick', async () => {
|
|
140
184
|
const client = await connect('conn-d1-sasl-3');
|
|
141
|
-
|
|
142
|
-
await authenticatePlain(client, 'cf-sasl', 's3cret');
|
|
185
|
+
await authenticatePlain(client, 'nobody', 'whatever');
|
|
143
186
|
const fail = await client.waitForMessage((l) => l.includes(' 904 '));
|
|
144
187
|
expect(fail).toContain(' 904 ');
|
|
145
188
|
await client.close();
|
|
146
189
|
});
|
|
147
190
|
});
|
|
148
|
-
|
|
149
|
-
describe('ConnectionDO — SASL PLAIN in-memory fallback (D1 empty)', () => {
|
|
150
|
-
// The table exists but has zero rows → resolver falls back to SASL_ACCOUNTS.
|
|
151
|
-
beforeEach(async () => {
|
|
152
|
-
await env.ACCOUNTS_DB.exec(CREATE_ACCOUNTS_TABLE_SQL);
|
|
153
|
-
});
|
|
154
|
-
afterEach(async () => {
|
|
155
|
-
await env.ACCOUNTS_DB.exec('DROP TABLE IF EXISTS accounts');
|
|
156
|
-
});
|
|
157
|
-
|
|
158
|
-
it('falls back to SASL_ACCOUNTS seed when D1 is empty', async () => {
|
|
159
|
-
const client = await connect('conn-d1-sasl-fallback');
|
|
160
|
-
await authenticatePlain(client, 'cf-sasl', 's3cret');
|
|
161
|
-
await client.waitForMessage((l) => l.includes(' 900 '));
|
|
162
|
-
const success = await client.waitForMessage((l) => l.includes(' 903 '));
|
|
163
|
-
expect(success).toContain(' 903 ');
|
|
164
|
-
await client.close();
|
|
165
|
-
});
|
|
166
|
-
});
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
CREATE_SERVICES_TABLES_SQL,
|
|
16
16
|
D1ServicesStore,
|
|
17
17
|
loadD1ServicesStore,
|
|
18
|
+
safeSelect,
|
|
18
19
|
} from '../src/d1-services-store';
|
|
19
20
|
|
|
20
21
|
declare global {
|
|
@@ -194,6 +195,18 @@ describe('D1ServicesStore — NickServ round-trip across reload', () => {
|
|
|
194
195
|
expect(reloaded?.getNickEnforce('alice')).toBe('kill');
|
|
195
196
|
});
|
|
196
197
|
|
|
198
|
+
it('persists SET PASSWORD and round-trips the new credential', async () => {
|
|
199
|
+
const store = await loadD1ServicesStore(env.ACCOUNTS_DB, new FakeClock(NOW));
|
|
200
|
+
store?.registerNick('alice', 'hunter2', 'a@x');
|
|
201
|
+
store?.setNickPassword('alice', 'newpassword');
|
|
202
|
+
await store?.flush();
|
|
203
|
+
|
|
204
|
+
const reloaded = await loadD1ServicesStore(env.ACCOUNTS_DB, new FakeClock(NOW));
|
|
205
|
+
// The new password verifies against the reloaded store; the old one no longer does.
|
|
206
|
+
expect(reloaded?.verifyNick('alice', 'newpassword')).toEqual({ ok: true, account: 'alice' });
|
|
207
|
+
expect(reloaded?.verifyNick('alice', 'hunter2').ok).toBe(false);
|
|
208
|
+
});
|
|
209
|
+
|
|
197
210
|
it('drops a NickServ registration after flush + reload', async () => {
|
|
198
211
|
const store = await loadD1ServicesStore(env.ACCOUNTS_DB, new FakeClock(NOW));
|
|
199
212
|
store?.registerNick('alice', 'pw', 'a@x');
|
|
@@ -524,14 +537,18 @@ describe('D1ServicesStore — tolerance', () => {
|
|
|
524
537
|
expect(snap.jupes).toEqual([]);
|
|
525
538
|
});
|
|
526
539
|
|
|
527
|
-
it('skips rows with NULL fields in the all-TEXT tables (vhosts / read_markers)', async () => {
|
|
528
|
-
// Recreate the
|
|
540
|
+
it('skips rows with NULL fields in the all-TEXT tables (vhosts / pending_vhosts / read_markers)', async () => {
|
|
541
|
+
// Recreate the all-TEXT tables without NOT NULL so a NULL field can
|
|
529
542
|
// survive — exercises the typeof-string defensive skip for the loaders
|
|
530
543
|
// whose INTEGER-corruption path is unreachable under SQLite affinity.
|
|
531
544
|
await env.ACCOUNTS_DB.exec('DROP TABLE hostserv_vhosts');
|
|
532
545
|
await env.ACCOUNTS_DB.exec(
|
|
533
546
|
'CREATE TABLE hostserv_vhosts (account_key TEXT PRIMARY KEY, account TEXT, vhost TEXT)',
|
|
534
547
|
);
|
|
548
|
+
await env.ACCOUNTS_DB.exec('DROP TABLE hostserv_pending_vhosts');
|
|
549
|
+
await env.ACCOUNTS_DB.exec(
|
|
550
|
+
'CREATE TABLE hostserv_pending_vhosts (account_key TEXT PRIMARY KEY, account TEXT, vhost TEXT)',
|
|
551
|
+
);
|
|
535
552
|
await env.ACCOUNTS_DB.exec('DROP TABLE read_markers');
|
|
536
553
|
await env.ACCOUNTS_DB.exec(
|
|
537
554
|
'CREATE TABLE read_markers (account_key TEXT NOT NULL, channel_key TEXT NOT NULL, account TEXT, channel TEXT, msgid TEXT)',
|
|
@@ -541,6 +558,11 @@ describe('D1ServicesStore — tolerance', () => {
|
|
|
541
558
|
)
|
|
542
559
|
.bind('bad#v', null, 'cloak')
|
|
543
560
|
.run();
|
|
561
|
+
await env.ACCOUNTS_DB.prepare(
|
|
562
|
+
'INSERT INTO hostserv_pending_vhosts (account_key, account, vhost) VALUES (?, ?, ?)',
|
|
563
|
+
)
|
|
564
|
+
.bind('bad#pv', 'someone', null)
|
|
565
|
+
.run();
|
|
544
566
|
await env.ACCOUNTS_DB.prepare(
|
|
545
567
|
'INSERT INTO read_markers (account_key, channel_key, account, channel, msgid) VALUES (?, ?, ?, ?, ?)',
|
|
546
568
|
)
|
|
@@ -552,6 +574,7 @@ describe('D1ServicesStore — tolerance', () => {
|
|
|
552
574
|
if (!store) throw new Error('services store failed to load');
|
|
553
575
|
const snap = store.snapshot();
|
|
554
576
|
expect(snap.vhosts).toEqual([]);
|
|
577
|
+
expect(snap.pendingVhosts).toEqual([]);
|
|
555
578
|
expect(snap.readMarkers).toEqual([]);
|
|
556
579
|
});
|
|
557
580
|
|
|
@@ -566,6 +589,34 @@ describe('D1ServicesStore — tolerance', () => {
|
|
|
566
589
|
});
|
|
567
590
|
});
|
|
568
591
|
|
|
592
|
+
// ---------------------------------------------------------------------------
|
|
593
|
+
// safeSelect — defensive catch block (real D1 bindings don't throw on
|
|
594
|
+
// existing-table queries; the catch arm is unreachable through integration).
|
|
595
|
+
// ---------------------------------------------------------------------------
|
|
596
|
+
|
|
597
|
+
describe('safeSelect — defensive catch block', () => {
|
|
598
|
+
it('returns [] and logs when the underlying D1 query rejects', async () => {
|
|
599
|
+
// A misbehaving binding (transient unavailability, schema drift) would
|
|
600
|
+
// otherwise brick NickServ for the whole connection (the snapshot is
|
|
601
|
+
// cached for life). The catch arm logs to wrangler tail and degrades
|
|
602
|
+
// to an empty result.
|
|
603
|
+
const stub = {
|
|
604
|
+
prepare(): { all(): Promise<never> } {
|
|
605
|
+
return {
|
|
606
|
+
all: async () => {
|
|
607
|
+
throw new Error('simulated D1 outage');
|
|
608
|
+
},
|
|
609
|
+
};
|
|
610
|
+
},
|
|
611
|
+
};
|
|
612
|
+
const result = await safeSelect<{ x: unknown }>(
|
|
613
|
+
stub as unknown as Parameters<typeof safeSelect>[0],
|
|
614
|
+
'SELECT * FROM nicks',
|
|
615
|
+
);
|
|
616
|
+
expect(result).toEqual([]);
|
|
617
|
+
});
|
|
618
|
+
});
|
|
619
|
+
|
|
569
620
|
function emptySnapshot(): ServicesSnapshot {
|
|
570
621
|
return {
|
|
571
622
|
nicks: [],
|
|
@@ -306,4 +306,29 @@ describe('deserialize — migrate integration', () => {
|
|
|
306
306
|
expect(state.userModes.wallops).toBe(true);
|
|
307
307
|
expect(state.caps).toEqual(new Set(['server-time']));
|
|
308
308
|
});
|
|
309
|
+
|
|
310
|
+
it('coalesces an explicit `secure: null` to false via the ?? fallback', () => {
|
|
311
|
+
// `migrate` only backfills `secure` when the key is absent (=== undefined);
|
|
312
|
+
// an explicit null survives and the `?? false` arm in `deserialize` must
|
|
313
|
+
// catch it. Mirrors a hand-edited storage row where someone wrote null.
|
|
314
|
+
const rec = {
|
|
315
|
+
version: 1,
|
|
316
|
+
id: 'c',
|
|
317
|
+
registration: 'registered' as const,
|
|
318
|
+
capNegotiating: false,
|
|
319
|
+
secure: null,
|
|
320
|
+
caps: [],
|
|
321
|
+
joinedChannels: [],
|
|
322
|
+
userModes: {
|
|
323
|
+
invisible: false,
|
|
324
|
+
oper: false,
|
|
325
|
+
wallops: false,
|
|
326
|
+
serverNotices: false,
|
|
327
|
+
tls: false,
|
|
328
|
+
},
|
|
329
|
+
lastSeen: 0,
|
|
330
|
+
connectedSince: 0,
|
|
331
|
+
};
|
|
332
|
+
expect(deserialize(rec).secure).toBe(false);
|
|
333
|
+
});
|
|
309
334
|
});
|