serverless-ircd 0.9.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/ci.yml +28 -0
- package/.github/workflows/deploy-aws.yml +156 -32
- package/.github/workflows/deploy-cf-tcp.yml +35 -9
- package/.github/workflows/deploy-cf.yml +40 -14
- package/CHANGELOG.md +594 -0
- package/README.md +286 -60
- package/apps/aws-stack/README.md +3 -5
- package/apps/aws-stack/bin/aws.ts +118 -9
- package/apps/aws-stack/cdk.json +0 -3
- package/apps/aws-stack/package.json +3 -4
- package/apps/aws-stack/src/aws-stack.ts +398 -67
- package/apps/aws-stack/src/static-site.ts +323 -0
- package/apps/aws-stack/tests/smoke-helpers.test.ts +1 -1
- package/apps/aws-stack/tests/stack.test.ts +714 -105
- package/apps/aws-stack/tests/static-site.test.ts +491 -0
- package/apps/aws-stack/tests/synth-no-bundle.test.ts +0 -1
- package/apps/cf-tcp-container/Dockerfile +37 -5
- package/apps/cf-tcp-container/package.json +7 -3
- package/apps/cf-tcp-container/src/config-loader.ts +113 -2
- package/apps/cf-tcp-container/src/container-server.ts +267 -87
- package/apps/cf-tcp-container/src/main.ts +22 -7
- package/apps/cf-tcp-container/src/proxy-protocol.ts +112 -0
- package/apps/cf-tcp-container/terraform/spectrum.tf +40 -11
- package/apps/cf-tcp-container/tests/config-loader.test.ts +170 -0
- package/apps/cf-tcp-container/tests/container-server-tls.test.ts +382 -0
- package/apps/cf-tcp-container/tests/container-server.test.ts +358 -31
- package/apps/cf-tcp-container/tests/dockerfile.test.ts +110 -0
- package/apps/cf-tcp-container/tests/proxy-protocol.test.ts +187 -0
- package/apps/cf-tcp-container/tests/spectrum-terraform.test.ts +135 -0
- package/apps/cf-tcp-container/tests/tls-e2e.test.ts +5 -1
- package/apps/cf-tcp-container/wrangler.toml +18 -14
- package/apps/cf-worker/package.json +3 -4
- package/apps/cf-worker/src/worker.ts +77 -5
- package/apps/cf-worker/tests/raw-modules.d.ts +11 -0
- package/apps/cf-worker/tests/smoke.test.ts +4 -0
- package/apps/cf-worker/tests/wrangler-config.test.ts +47 -0
- package/apps/cf-worker/tests/ws-admission.test.ts +112 -0
- package/apps/cf-worker/tests/ws-rate-limit.test.ts +133 -0
- package/apps/cf-worker/wrangler.test.toml +15 -1
- package/apps/cf-worker/wrangler.toml +95 -77
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/config-loader.ts +14 -2
- package/apps/local-cli/src/line-scanner.ts +26 -0
- package/apps/local-cli/src/server.ts +44 -19
- package/apps/local-cli/tests/line-scanner.test.ts +64 -0
- package/apps/local-cli/tests/tcp.test.ts +29 -0
- package/apps/web/landing/favicon.ico +0 -0
- package/apps/web/landing/index.html +1 -0
- package/apps/web/package.json +2 -2
- package/apps/web/scripts/build.mjs +66 -4
- package/apps/web/src/build-env.ts +125 -4
- package/apps/web/src/config-schema.ts +20 -6
- package/apps/web/static/{config.staging.json → config.prod-aws.json} +3 -2
- package/apps/web/tests/build-env.test.ts +210 -9
- package/apps/web/tests/build-smoke.test.ts +2 -2
- package/apps/web/tests/config-schema.test.ts +149 -25
- package/docs/AWS-Deployment.md +793 -118
- package/docs/AWS-TCP-Deployment.md +57 -47
- package/docs/Chat-History.md +55 -0
- package/docs/Cloudflare-Deployment-Guide.md +95 -114
- package/docs/Cloudflare-TCP-Deployment.md +160 -101
- package/docs/Release-Process.md +27 -23
- package/docs/SASL-EXTERNAL.md +175 -0
- package/docs/Services.md +69 -22
- package/docs/WebClientGuide.md +35 -26
- package/package.json +7 -10
- package/packages/aws-adapter/package.json +1 -1
- package/packages/aws-adapter/src/admission.ts +28 -13
- package/packages/aws-adapter/src/aws-runtime.ts +30 -3
- package/packages/aws-adapter/src/cdk-table-defs.ts +39 -16
- package/packages/aws-adapter/src/config-loader.ts +153 -8
- package/packages/aws-adapter/src/dynamo-services-store.ts +19 -0
- package/packages/aws-adapter/src/handlers/connect.ts +73 -1
- package/packages/aws-adapter/src/handlers/default.ts +279 -123
- package/packages/aws-adapter/src/handlers/index.ts +98 -25
- package/packages/aws-adapter/src/handlers/nlb-stream.ts +135 -14
- package/packages/aws-adapter/src/index.ts +5 -7
- package/packages/aws-adapter/src/ip-admission.ts +79 -0
- package/packages/aws-adapter/src/origin-allowlist.ts +94 -0
- package/packages/aws-adapter/src/serialize.ts +23 -0
- package/packages/aws-adapter/src/tables.ts +11 -12
- package/packages/aws-adapter/tests/admission.test.ts +60 -2
- package/packages/aws-adapter/tests/aws-harness.ts +23 -2
- package/packages/aws-adapter/tests/aws-runtime.test.ts +64 -0
- package/packages/aws-adapter/tests/config-loader.test.ts +217 -0
- package/packages/aws-adapter/tests/connect.test.ts +323 -3
- package/packages/aws-adapter/tests/default-frame-limit.test.ts +231 -0
- package/packages/aws-adapter/tests/default-occ.test.ts +226 -0
- package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +134 -1
- package/packages/aws-adapter/tests/handlers.test.ts +174 -12
- package/packages/aws-adapter/tests/migrate-accounts-to-services.test.ts +164 -0
- package/packages/aws-adapter/tests/nlb-secure.test.ts +362 -0
- package/packages/aws-adapter/tests/nlb-stream.test.ts +628 -9
- package/packages/aws-adapter/tests/origin-allowlist.test.ts +110 -0
- package/packages/aws-adapter/tests/ping-checker.test.ts +0 -1
- package/packages/aws-adapter/tests/stats.test.ts +0 -3
- package/packages/aws-adapter/tests/sweeper.test.ts +0 -1
- package/packages/aws-adapter/tests/tables.test.ts +1 -8
- package/packages/aws-adapter/tests/transactions.test.ts +0 -1
- package/packages/cf-adapter/package.json +1 -5
- package/packages/cf-adapter/src/cf-runtime.ts +100 -10
- package/packages/cf-adapter/src/channel-do.ts +13 -3
- package/packages/cf-adapter/src/config-loader.ts +133 -8
- package/packages/cf-adapter/src/connection-do.ts +406 -116
- package/packages/cf-adapter/src/counter-do.ts +142 -0
- package/packages/cf-adapter/src/d1-services-store.ts +105 -26
- package/packages/cf-adapter/src/env.ts +99 -10
- package/packages/cf-adapter/src/index.ts +17 -7
- package/packages/cf-adapter/src/rate-limit-do.ts +87 -0
- package/packages/cf-adapter/tests/cf-runtime.test.ts +205 -16
- package/packages/cf-adapter/tests/channel-do.test.ts +118 -1
- package/packages/cf-adapter/tests/config-loader.test.ts +159 -0
- package/packages/cf-adapter/tests/connection-do-counter.test.ts +165 -0
- package/packages/cf-adapter/tests/connection-do-coverage.test.ts +460 -0
- package/packages/cf-adapter/tests/connection-do-frame-limit.test.ts +177 -0
- package/packages/cf-adapter/tests/connection-do-pure.test.ts +164 -54
- package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +62 -38
- package/packages/cf-adapter/tests/connection-do-ws-spec-contract.test.ts +7 -4
- package/packages/cf-adapter/tests/counter-do.test.ts +181 -0
- package/packages/cf-adapter/tests/d1-services-store.test.ts +245 -3
- package/packages/cf-adapter/tests/rate-limit-do.test.ts +160 -0
- package/packages/cf-adapter/tests/serialize.test.ts +25 -0
- package/packages/cf-adapter/tests/worker/main.ts +4 -0
- package/packages/cf-adapter/wrangler.test.toml +18 -1
- package/packages/in-memory-runtime/package.json +1 -1
- package/packages/in-memory-runtime/src/in-memory-runtime.ts +25 -0
- package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +74 -0
- package/packages/irc-core/package.json +1 -1
- package/packages/irc-core/src/account-migration.ts +140 -0
- package/packages/irc-core/src/caps/capabilities.ts +20 -10
- package/packages/irc-core/src/certfp.ts +178 -0
- package/packages/irc-core/src/commands/account-auth.ts +16 -19
- package/packages/irc-core/src/commands/cap.ts +10 -2
- package/packages/irc-core/src/commands/chanserv.ts +117 -14
- package/packages/irc-core/src/commands/chathistory.ts +13 -5
- package/packages/irc-core/src/commands/hostserv.ts +84 -8
- package/packages/irc-core/src/commands/index.ts +2 -1
- package/packages/irc-core/src/commands/invite.ts +1 -7
- package/packages/irc-core/src/commands/join.ts +1 -16
- package/packages/irc-core/src/commands/kick.ts +1 -8
- package/packages/irc-core/src/commands/list.ts +1 -8
- package/packages/irc-core/src/commands/memoserv.ts +1 -1
- package/packages/irc-core/src/commands/mode.ts +1 -8
- package/packages/irc-core/src/commands/multiline.ts +4 -10
- package/packages/irc-core/src/commands/names.ts +53 -13
- package/packages/irc-core/src/commands/nickserv.ts +161 -11
- package/packages/irc-core/src/commands/oper.ts +361 -8
- package/packages/irc-core/src/commands/part.ts +4 -10
- package/packages/irc-core/src/commands/privmsg.ts +8 -4
- package/packages/irc-core/src/commands/registration.ts +148 -4
- package/packages/irc-core/src/commands/sasl.ts +154 -46
- package/packages/irc-core/src/commands/topic.ts +10 -12
- package/packages/irc-core/src/commands/who.ts +1 -8
- package/packages/irc-core/src/config.ts +424 -25
- package/packages/irc-core/src/credential-hashing.ts +11 -54
- package/packages/irc-core/src/effects.ts +24 -0
- package/packages/irc-core/src/flood-control.ts +10 -10
- package/packages/irc-core/src/frame-rate-limit.ts +82 -0
- package/packages/irc-core/src/index.ts +9 -0
- package/packages/irc-core/src/oper-hashing.ts +43 -0
- package/packages/irc-core/src/oper-lockout.ts +87 -0
- package/packages/irc-core/src/ports.ts +529 -190
- package/packages/irc-core/src/protocol/bytes.ts +65 -0
- package/packages/irc-core/src/protocol/channel-name.ts +37 -0
- package/packages/irc-core/src/protocol/index.ts +12 -1
- package/packages/irc-core/src/protocol/outbound.ts +43 -10
- package/packages/irc-core/src/protocol/parser.ts +79 -10
- package/packages/irc-core/src/state/connection.ts +13 -0
- package/packages/irc-core/src/types.ts +266 -23
- package/packages/irc-core/src/ws-framing.ts +5 -4
- package/packages/irc-core/tests/account-migration.test.ts +133 -0
- package/packages/irc-core/tests/bytes.test.ts +89 -0
- package/packages/irc-core/tests/certfp.test.ts +117 -0
- package/packages/irc-core/tests/commands/cap.test.ts +76 -2
- package/packages/irc-core/tests/commands/chanserv.test.ts +166 -0
- package/packages/irc-core/tests/commands/chathistory.test.ts +140 -0
- package/packages/irc-core/tests/commands/hostserv.test.ts +316 -0
- package/packages/irc-core/tests/commands/join.test.ts +78 -1
- package/packages/irc-core/tests/commands/markread.test.ts +54 -0
- package/packages/irc-core/tests/commands/memoserv.test.ts +19 -0
- package/packages/irc-core/tests/commands/names.test.ts +193 -0
- package/packages/irc-core/tests/commands/nickserv.test.ts +419 -3
- package/packages/irc-core/tests/commands/oper.test.ts +574 -1
- package/packages/irc-core/tests/commands/privmsg.test.ts +16 -0
- package/packages/irc-core/tests/commands/registration.test.ts +602 -133
- package/packages/irc-core/tests/commands/sasl.test.ts +742 -172
- package/packages/irc-core/tests/commands/topic.test.ts +137 -2
- package/packages/irc-core/tests/commands/unified-account.test.ts +104 -84
- package/packages/irc-core/tests/config.test.ts +534 -2
- package/packages/irc-core/tests/credential-hashing.test.ts +0 -78
- package/packages/irc-core/tests/effects.test.ts +14 -0
- package/packages/irc-core/tests/flood-control.test.ts +29 -1
- package/packages/irc-core/tests/frame-rate-limit.test.ts +98 -0
- package/packages/irc-core/tests/message-store.test.ts +5 -0
- package/packages/irc-core/tests/oper-hashing.test.ts +60 -0
- package/packages/irc-core/tests/oper-lockout.test.ts +74 -0
- package/packages/irc-core/tests/outbound.test.ts +148 -0
- package/packages/irc-core/tests/parser.test.ts +287 -5
- package/packages/irc-core/tests/persistent-services-store.test.ts +212 -12
- package/packages/irc-core/tests/ports.test.ts +170 -7
- package/packages/irc-core/tests/services-store.test.ts +567 -1
- package/packages/irc-core/tests/ws-framing.test.ts +45 -0
- package/packages/irc-core/vitest.config.ts +6 -1
- package/packages/irc-server/package.json +1 -1
- package/packages/irc-server/src/actor.ts +123 -22
- package/packages/irc-server/src/dispatch.ts +1 -0
- package/packages/irc-server/src/index.ts +7 -0
- package/packages/irc-server/src/redact.ts +159 -0
- package/packages/irc-server/src/runtime.ts +14 -0
- package/packages/irc-server/src/transport.ts +28 -1
- package/packages/irc-server/tests/actor.test.ts +563 -54
- package/packages/irc-server/tests/dispatch.test.ts +31 -0
- package/packages/irc-server/tests/redact.test.ts +198 -0
- package/packages/irc-server/tests/runtime.test.ts +2 -0
- package/packages/irc-server/tests/transport.test.ts +66 -0
- package/packages/irc-test-support/package.json +1 -1
- package/packages/irc-test-support/src/in-memory-harness.ts +4 -0
- package/pnpm-workspace.yaml +1 -0
- package/scripts/__tests__/deploy-web-aws.test.ts +491 -0
- package/scripts/deploy-web-aws.mjs +290 -0
- package/scripts/package.json +23 -0
- package/scripts/tsconfig.test.json +12 -0
- package/scripts/vitest.config.ts +19 -0
- package/tools/ci-hardening/package.json +2 -2
- package/tools/ci-hardening/src/cf-deploy-cli.ts +3 -0
- package/tools/ci-hardening/src/cf-deploy.ts +118 -0
- package/tools/ci-hardening/src/deploy-hostname.ts +118 -0
- package/tools/ci-hardening/src/env-var-drift.ts +192 -0
- package/tools/ci-hardening/src/hostname-guard.ts +11 -0
- package/tools/ci-hardening/src/index.ts +19 -0
- package/tools/ci-hardening/src/validate.ts +57 -0
- package/tools/ci-hardening/tests/__wrangler_missing__.toml +2 -0
- package/tools/ci-hardening/tests/__wrangler_placeholder__.toml +3 -0
- package/tools/ci-hardening/tests/__wrangler_real__.toml +3 -0
- package/tools/ci-hardening/tests/cf-deploy.test.ts +200 -0
- package/tools/ci-hardening/tests/deploy-aws-oidc.test.ts +96 -0
- package/tools/ci-hardening/tests/deploy-hostname.test.ts +348 -0
- package/tools/ci-hardening/tests/env-var-drift.test.ts +284 -0
- package/tools/ci-hardening/tests/validate.test.ts +42 -0
- package/tools/ci-hardening/vitest.config.ts +5 -1
- package/tools/hash-oper-cred.ts +85 -0
- package/tools/load-test/package.json +1 -1
- package/tools/migrate-accounts-to-services.ts +270 -0
- package/tools/package.json +2 -1
- package/tools/seed-aws-accounts.ts +35 -10
- package/tools/seed-cf-accounts.ts +42 -9
- package/tools/tcp-ws-forwarder/package.json +1 -1
- package/packages/aws-adapter/src/account-store.ts +0 -121
- package/packages/aws-adapter/src/dynamo-account-store.ts +0 -95
- package/packages/aws-adapter/tests/account-store-dynamo.test.ts +0 -223
- package/packages/aws-adapter/tests/account-store.test.ts +0 -276
- package/packages/cf-adapter/src/d1-account-store.ts +0 -198
- package/packages/cf-adapter/tests/d1-account-store.test.ts +0 -274
- package/packages/irc-core/tests/account-store.test.ts +0 -131
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `CounterDO` — global live-connection admission counter.
|
|
3
|
+
*
|
|
4
|
+
* TDD outline:
|
|
5
|
+
* "Red on admit-under-cap / reject-at-cap; green; then TTL reap,
|
|
6
|
+
* heartbeat refresh, and the lazy reconcile on admit."
|
|
7
|
+
*
|
|
8
|
+
* Tests address the single global counter instance directly over RPC,
|
|
9
|
+
* driving every branch of admit / release / heartbeat / sweep with
|
|
10
|
+
* explicit timestamps so the TTL boundary is deterministic (no wall
|
|
11
|
+
* clock dependence). Each test gets an isolated storage namespace, so
|
|
12
|
+
* the shared `global` instance never bleeds state across tests.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { env, reset } from 'cloudflare:test';
|
|
16
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
17
|
+
import { COUNTER_INSTANCE_NAME, DEFAULT_CONNECTION_TTL_MS } from '../src/counter-do';
|
|
18
|
+
import type { CounterRpc } from '../src/env';
|
|
19
|
+
|
|
20
|
+
declare global {
|
|
21
|
+
namespace Cloudflare {
|
|
22
|
+
interface Env {
|
|
23
|
+
COUNTER_DO: DurableObjectNamespace;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// DO storage persists across tests in the same file; reset between each so
|
|
29
|
+
// admission entries from one test never leak into the next. Every test
|
|
30
|
+
// addresses the same `global` counter instance, so this is essential.
|
|
31
|
+
afterEach(async () => {
|
|
32
|
+
await reset();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
/** Fixed epoch base for deterministic TTL arithmetic. */
|
|
36
|
+
const T0 = 1_700_000_000_000;
|
|
37
|
+
/** Alias for readability in the TTL-boundary tests. */
|
|
38
|
+
const TTL = DEFAULT_CONNECTION_TTL_MS;
|
|
39
|
+
|
|
40
|
+
/** RPC handle on the single global counter instance. */
|
|
41
|
+
function counter(): CounterRpc {
|
|
42
|
+
const stub = env.COUNTER_DO.get(env.COUNTER_DO.idFromName(COUNTER_INSTANCE_NAME));
|
|
43
|
+
return stub as unknown as CounterRpc;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
// admit
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
describe('CounterDO — admit', () => {
|
|
51
|
+
it('admits when under the cap and reports the live count', async () => {
|
|
52
|
+
const c = counter();
|
|
53
|
+
expect(await c.admit('conn-a', 2, T0)).toEqual({ admitted: true, live: 1 });
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('admits a second connection while still under the cap', async () => {
|
|
57
|
+
const c = counter();
|
|
58
|
+
await c.admit('conn-b1', 2, T0);
|
|
59
|
+
expect(await c.admit('conn-b2', 2, T0)).toEqual({ admitted: true, live: 2 });
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('rejects once the cap is reached', async () => {
|
|
63
|
+
const c = counter();
|
|
64
|
+
await c.admit('conn-c1', 1, T0);
|
|
65
|
+
expect(await c.admit('conn-c2', 1, T0)).toEqual({ admitted: false, live: 1 });
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('a rejected admission registers no entry', async () => {
|
|
69
|
+
const c = counter();
|
|
70
|
+
await c.admit('conn-d1', 1, T0);
|
|
71
|
+
await c.admit('conn-d2', 1, T0);
|
|
72
|
+
expect(await c.liveCount()).toBe(1);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('re-admits after the capped connection is released', async () => {
|
|
76
|
+
const c = counter();
|
|
77
|
+
await c.admit('conn-e1', 1, T0);
|
|
78
|
+
await c.release('conn-e1');
|
|
79
|
+
expect(await c.admit('conn-e2', 1, T0)).toEqual({ admitted: true, live: 1 });
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('exactly one of two concurrent admissions wins at cap 1', async () => {
|
|
83
|
+
// Two worker-edge fetches race on the single counter instance. The
|
|
84
|
+
// DO's serialized event loop linearises the admits; exactly one
|
|
85
|
+
// reservation survives. Mirrors the RegistryDO concurrency suite.
|
|
86
|
+
const c = counter();
|
|
87
|
+
const [r1, r2] = await Promise.all([c.admit('race-1', 1, T0), c.admit('race-2', 1, T0)]);
|
|
88
|
+
const results = [r1, r2];
|
|
89
|
+
expect(results.filter((r) => r.admitted).length).toBe(1);
|
|
90
|
+
expect(results.filter((r) => !r.admitted).length).toBe(1);
|
|
91
|
+
expect(await c.liveCount()).toBe(1);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
// release
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
describe('CounterDO — release', () => {
|
|
100
|
+
it('removes the entry so the slot frees', async () => {
|
|
101
|
+
const c = counter();
|
|
102
|
+
await c.admit('rel-a', 5, T0);
|
|
103
|
+
await c.release('rel-a');
|
|
104
|
+
expect(await c.liveCount()).toBe(0);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('is idempotent for an unknown connection id', async () => {
|
|
108
|
+
const c = counter();
|
|
109
|
+
await c.release('never-registered');
|
|
110
|
+
expect(await c.liveCount()).toBe(0);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
// TTL reap — housekeeping for DO instances that crashed without a close
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
describe('CounterDO — TTL reap (housekeeping)', () => {
|
|
119
|
+
it('sweep reaps entries older than the TTL and keeps fresh ones', async () => {
|
|
120
|
+
const c = counter();
|
|
121
|
+
await c.admit('stale', 5, T0);
|
|
122
|
+
await c.admit('fresh', 5, T0 + 60_000);
|
|
123
|
+
const result = await c.sweep(T0 + TTL + 60_000);
|
|
124
|
+
expect(result.reaped).toBe(1);
|
|
125
|
+
expect(result.live).toBe(1);
|
|
126
|
+
expect(await c.liveCount()).toBe(1);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('an entry aged exactly the TTL is still live (only strictly-older reaps)', async () => {
|
|
130
|
+
const c = counter();
|
|
131
|
+
await c.admit('boundary', 5, T0);
|
|
132
|
+
expect(await c.sweep(T0 + TTL)).toEqual({ reaped: 0, live: 1 });
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('admit lazily reaps stale entries first (a crashed DO frees its slot)', async () => {
|
|
136
|
+
// The production failure mode: a ConnectionDO isolate is dropped
|
|
137
|
+
// without ever delivering a close event, so `release` never fires and
|
|
138
|
+
// the entry goes stale. The next admission must sweep it out instead
|
|
139
|
+
// of rejecting the newcomer.
|
|
140
|
+
const c = counter();
|
|
141
|
+
await c.admit('crashed', 1, T0);
|
|
142
|
+
expect(await c.admit('newcomer', 1, T0 + TTL + 1)).toEqual({ admitted: true, live: 1 });
|
|
143
|
+
expect(await c.liveCount()).toBe(1);
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// ---------------------------------------------------------------------------
|
|
148
|
+
// heartbeat — keeps live connections safe from the TTL reap
|
|
149
|
+
// ---------------------------------------------------------------------------
|
|
150
|
+
|
|
151
|
+
describe('CounterDO — heartbeat', () => {
|
|
152
|
+
it('refreshes the entry so a live connection survives the TTL reap', async () => {
|
|
153
|
+
const c = counter();
|
|
154
|
+
await c.admit('beat', 5, T0);
|
|
155
|
+
// The ConnectionDO's PING alarm refreshes the entry well inside the
|
|
156
|
+
// TTL window; by T0+TTL+60s the lastSeen has moved past the original
|
|
157
|
+
// admission stamp, so the sweep at T0+TTL+60s+1ms must keep it.
|
|
158
|
+
await c.heartbeat('beat', T0 + TTL + 60_000);
|
|
159
|
+
expect(await c.sweep(T0 + TTL + 60_001)).toEqual({ reaped: 0, live: 1 });
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('does not resurrect a released entry', async () => {
|
|
163
|
+
// A late heartbeat (e.g. racing the close handler) must not re-create
|
|
164
|
+
// a slot that was already released — that would overcount.
|
|
165
|
+
const c = counter();
|
|
166
|
+
await c.admit('gone', 5, T0);
|
|
167
|
+
await c.release('gone');
|
|
168
|
+
await c.heartbeat('gone', T0 + 1000);
|
|
169
|
+
expect(await c.liveCount()).toBe(0);
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
// ---------------------------------------------------------------------------
|
|
174
|
+
// liveCount
|
|
175
|
+
// ---------------------------------------------------------------------------
|
|
176
|
+
|
|
177
|
+
describe('CounterDO — liveCount', () => {
|
|
178
|
+
it('reports zero for a fresh counter', async () => {
|
|
179
|
+
expect(await counter().liveCount()).toBe(0);
|
|
180
|
+
});
|
|
181
|
+
});
|
|
@@ -9,12 +9,17 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import { env } from 'cloudflare:test';
|
|
12
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
FakeClock,
|
|
14
|
+
type ServicesSnapshot,
|
|
15
|
+
certIdentityFromSubject,
|
|
16
|
+
} from '@serverless-ircd/irc-core';
|
|
13
17
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
14
18
|
import {
|
|
15
19
|
CREATE_SERVICES_TABLES_SQL,
|
|
16
20
|
D1ServicesStore,
|
|
17
21
|
loadD1ServicesStore,
|
|
22
|
+
safeSelect,
|
|
18
23
|
} from '../src/d1-services-store';
|
|
19
24
|
|
|
20
25
|
declare global {
|
|
@@ -162,6 +167,119 @@ describe('D1ServicesStore — schema migration (password → algorithm/salt/hash
|
|
|
162
167
|
});
|
|
163
168
|
});
|
|
164
169
|
|
|
170
|
+
describe('D1ServicesStore — schema migration (cert_subjects era → identify-throttle columns)', () => {
|
|
171
|
+
/** Pre-throttle schema: every current column except the two new ones. */
|
|
172
|
+
const CERT_ERA_NICKSERV_DDL =
|
|
173
|
+
"CREATE TABLE nickserv_accounts (nick_key TEXT PRIMARY KEY, nick TEXT NOT NULL, account TEXT NOT NULL, email TEXT NOT NULL, created_at INTEGER NOT NULL, enforce TEXT NOT NULL, cert_subjects TEXT NOT NULL DEFAULT '[]', algorithm TEXT NOT NULL, salt TEXT NOT NULL, hash TEXT NOT NULL)";
|
|
174
|
+
|
|
175
|
+
afterEach(async () => {
|
|
176
|
+
await env.ACCOUNTS_DB.exec('DROP TABLE IF EXISTS nickserv_accounts');
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('adds the identify-throttle columns on load and keeps existing rows (defaults)', async () => {
|
|
180
|
+
await env.ACCOUNTS_DB.exec('DROP TABLE IF EXISTS nickserv_accounts');
|
|
181
|
+
await env.ACCOUNTS_DB.exec(CERT_ERA_NICKSERV_DDL);
|
|
182
|
+
await env.ACCOUNTS_DB.prepare(
|
|
183
|
+
'INSERT INTO nickserv_accounts (nick_key, nick, account, email, created_at, enforce, cert_subjects, algorithm, salt, hash) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
|
184
|
+
)
|
|
185
|
+
.bind('alice', 'alice', 'alice', 'a@x', 1, 'none', '[]', 'scrypt', 'c2FsdA==', 'aGFzaA==')
|
|
186
|
+
.run();
|
|
187
|
+
|
|
188
|
+
const store = await loadD1ServicesStore(env.ACCOUNTS_DB, new FakeClock(NOW));
|
|
189
|
+
expect(store).toBeDefined();
|
|
190
|
+
|
|
191
|
+
// The additive columns now exist.
|
|
192
|
+
const info = await env.ACCOUNTS_DB.prepare('PRAGMA table_info(nickserv_accounts)').all<{
|
|
193
|
+
name: string;
|
|
194
|
+
}>();
|
|
195
|
+
const cols = info.results.map((r) => r.name);
|
|
196
|
+
expect(cols).toContain('identify_failures');
|
|
197
|
+
expect(cols).toContain('pending_identify_notice');
|
|
198
|
+
|
|
199
|
+
// The pre-existing row survived and loads with empty throttle defaults.
|
|
200
|
+
expect(store?.isRegisteredNick('alice')).toBe(true);
|
|
201
|
+
expect(store?.isNickIdentifyFrozen('alice', { maxFailures: 1, windowMs: 300_000 })).toBe(false);
|
|
202
|
+
expect(store?.clearIdentifyFailures('alice')).toBe(0);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('is idempotent — a second load does not re-add the columns or drop rows', async () => {
|
|
206
|
+
await env.ACCOUNTS_DB.exec('DROP TABLE IF EXISTS nickserv_accounts');
|
|
207
|
+
await env.ACCOUNTS_DB.exec(CERT_ERA_NICKSERV_DDL);
|
|
208
|
+
const first = await loadD1ServicesStore(env.ACCOUNTS_DB, new FakeClock(NOW));
|
|
209
|
+
first?.registerNick('carol', 'pw', 'c@x');
|
|
210
|
+
await first?.flush();
|
|
211
|
+
|
|
212
|
+
const second = await loadD1ServicesStore(env.ACCOUNTS_DB, new FakeClock(NOW + 1));
|
|
213
|
+
expect(second?.isRegisteredNick('carol')).toBe(true);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('adds the cert_subjects column too when migrating from the algorithm era', async () => {
|
|
217
|
+
// Schema after the credential unification but before cert_subjects:
|
|
218
|
+
// exercises the cert_subjects ALTER arm on its own.
|
|
219
|
+
await env.ACCOUNTS_DB.exec('DROP TABLE IF EXISTS nickserv_accounts');
|
|
220
|
+
await env.ACCOUNTS_DB.exec(
|
|
221
|
+
'CREATE TABLE nickserv_accounts (nick_key TEXT PRIMARY KEY, nick TEXT NOT NULL, account TEXT NOT NULL, email TEXT NOT NULL, created_at INTEGER NOT NULL, enforce TEXT NOT NULL, algorithm TEXT NOT NULL, salt TEXT NOT NULL, hash TEXT NOT NULL)',
|
|
222
|
+
);
|
|
223
|
+
|
|
224
|
+
const store = await loadD1ServicesStore(env.ACCOUNTS_DB, new FakeClock(NOW));
|
|
225
|
+
const info = await env.ACCOUNTS_DB.prepare('PRAGMA table_info(nickserv_accounts)').all<{
|
|
226
|
+
name: string;
|
|
227
|
+
}>();
|
|
228
|
+
const cols = info.results.map((r) => r.name);
|
|
229
|
+
expect(cols).toContain('cert_subjects');
|
|
230
|
+
expect(cols).toContain('identify_failures');
|
|
231
|
+
expect(cols).toContain('pending_identify_notice');
|
|
232
|
+
expect(store).toBeDefined();
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it('loads rows with malformed JSON columns as empty arrays (defensive)', async () => {
|
|
236
|
+
await resetDb();
|
|
237
|
+
const insert = async (
|
|
238
|
+
nick: string,
|
|
239
|
+
certSubjects: string,
|
|
240
|
+
identifyFailures: string,
|
|
241
|
+
): Promise<void> => {
|
|
242
|
+
await env.ACCOUNTS_DB.prepare(
|
|
243
|
+
'INSERT INTO nickserv_accounts (nick_key, nick, account, email, created_at, enforce, cert_subjects, identify_failures, pending_identify_notice, algorithm, salt, hash) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
|
244
|
+
)
|
|
245
|
+
.bind(
|
|
246
|
+
nick,
|
|
247
|
+
nick,
|
|
248
|
+
nick,
|
|
249
|
+
'x@x',
|
|
250
|
+
1,
|
|
251
|
+
'none',
|
|
252
|
+
certSubjects,
|
|
253
|
+
identifyFailures,
|
|
254
|
+
0,
|
|
255
|
+
'scrypt',
|
|
256
|
+
'c2FsdA==',
|
|
257
|
+
'aGFzaA==',
|
|
258
|
+
)
|
|
259
|
+
.run();
|
|
260
|
+
};
|
|
261
|
+
// Every degenerate column shape: malformed JSON, empty string,
|
|
262
|
+
// non-array JSON, and arrays with ill-typed entries.
|
|
263
|
+
await insert('a-malformed', 'not json', 'also not json');
|
|
264
|
+
await insert('b-empty', '', '');
|
|
265
|
+
await insert('c-nonarray', '{"a":1}', '{"a":1}');
|
|
266
|
+
await insert('d-illtyped', '["ok",7]', '[1,"x",2]');
|
|
267
|
+
|
|
268
|
+
const store = await loadD1ServicesStore(env.ACCOUNTS_DB, new FakeClock(NOW));
|
|
269
|
+
for (const nick of ['a-malformed', 'b-empty', 'c-nonarray']) {
|
|
270
|
+
expect(store?.isRegisteredNick(nick)).toBe(true);
|
|
271
|
+
expect(store?.getNick(nick)?.certSubjects).toEqual([]);
|
|
272
|
+
expect(store?.isNickIdentifyFrozen(nick, { maxFailures: 1, windowMs: 300_000 })).toBe(false);
|
|
273
|
+
}
|
|
274
|
+
// Ill-typed entries are filtered; well-typed ones survive. The window
|
|
275
|
+
// reaches back to the epoch so the literal timestamps count.
|
|
276
|
+
expect(store?.getNick('d-illtyped')?.certSubjects).toEqual(['ok']);
|
|
277
|
+
expect(store?.isNickIdentifyFrozen('d-illtyped', { maxFailures: 2, windowMs: NOW + 1 })).toBe(
|
|
278
|
+
true,
|
|
279
|
+
);
|
|
280
|
+
});
|
|
281
|
+
});
|
|
282
|
+
|
|
165
283
|
describe('D1ServicesStore — NickServ round-trip across reload', () => {
|
|
166
284
|
beforeEach(async () => {
|
|
167
285
|
await resetDb();
|
|
@@ -194,6 +312,43 @@ describe('D1ServicesStore — NickServ round-trip across reload', () => {
|
|
|
194
312
|
expect(reloaded?.getNickEnforce('alice')).toBe('kill');
|
|
195
313
|
});
|
|
196
314
|
|
|
315
|
+
it('persists SET PASSWORD and round-trips the new credential', async () => {
|
|
316
|
+
const store = await loadD1ServicesStore(env.ACCOUNTS_DB, new FakeClock(NOW));
|
|
317
|
+
store?.registerNick('alice', 'hunter2', 'a@x');
|
|
318
|
+
store?.setNickPassword('alice', 'newpassword');
|
|
319
|
+
await store?.flush();
|
|
320
|
+
|
|
321
|
+
const reloaded = await loadD1ServicesStore(env.ACCOUNTS_DB, new FakeClock(NOW));
|
|
322
|
+
// The new password verifies against the reloaded store; the old one no longer does.
|
|
323
|
+
expect(reloaded?.verifyNick('alice', 'newpassword')).toEqual({ ok: true, account: 'alice' });
|
|
324
|
+
expect(reloaded?.verifyNick('alice', 'hunter2').ok).toBe(false);
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it('persists the identify-failure throttle and round-trips the freeze', async () => {
|
|
328
|
+
const clock = new FakeClock(NOW);
|
|
329
|
+
const store = await loadD1ServicesStore(env.ACCOUNTS_DB, clock);
|
|
330
|
+
store?.registerNick('alice', 'hunter2', 'a@x');
|
|
331
|
+
clock.advance(10);
|
|
332
|
+
store?.recordIdentifyFailure('alice');
|
|
333
|
+
clock.advance(10);
|
|
334
|
+
store?.recordIdentifyFailure('alice');
|
|
335
|
+
await store?.flush();
|
|
336
|
+
|
|
337
|
+
// Simulate a fresh connection (fresh store) after a deploy: the reload
|
|
338
|
+
// still sees the recorded failures and the queued owner-notice count.
|
|
339
|
+
const reloaded = await loadD1ServicesStore(env.ACCOUNTS_DB, new FakeClock(NOW + 20));
|
|
340
|
+
expect(reloaded?.isNickIdentifyFrozen('alice', { maxFailures: 2, windowMs: 300_000 })).toBe(
|
|
341
|
+
true,
|
|
342
|
+
);
|
|
343
|
+
expect(reloaded?.clearIdentifyFailures('alice')).toBe(2);
|
|
344
|
+
|
|
345
|
+
// The cleared state round-trips too.
|
|
346
|
+
await reloaded?.flush();
|
|
347
|
+
const after = await loadD1ServicesStore(env.ACCOUNTS_DB, new FakeClock(NOW + 20));
|
|
348
|
+
expect(after?.isNickIdentifyFrozen('alice', { maxFailures: 1, windowMs: 300_000 })).toBe(false);
|
|
349
|
+
expect(after?.clearIdentifyFailures('alice')).toBe(0);
|
|
350
|
+
});
|
|
351
|
+
|
|
197
352
|
it('drops a NickServ registration after flush + reload', async () => {
|
|
198
353
|
const store = await loadD1ServicesStore(env.ACCOUNTS_DB, new FakeClock(NOW));
|
|
199
354
|
store?.registerNick('alice', 'pw', 'a@x');
|
|
@@ -206,6 +361,55 @@ describe('D1ServicesStore — NickServ round-trip across reload', () => {
|
|
|
206
361
|
const reloaded = await loadD1ServicesStore(env.ACCOUNTS_DB, new FakeClock(NOW));
|
|
207
362
|
expect(reloaded?.isRegisteredNick('alice')).toBe(false);
|
|
208
363
|
});
|
|
364
|
+
|
|
365
|
+
it('round-trips a bound cert fingerprint and verifies EXTERNAL after reload', async () => {
|
|
366
|
+
// A cert binding attached before a deploy must still authenticate a
|
|
367
|
+
// SASL EXTERNAL client after the store rehydrates from D1 — the
|
|
368
|
+
// production path for certificate-backed login.
|
|
369
|
+
const store = await loadD1ServicesStore(env.ACCOUNTS_DB, new FakeClock(NOW));
|
|
370
|
+
store?.registerNick('alice', 'hunter2', 'a@x');
|
|
371
|
+
store?.addCertFingerprint('alice', 'AA112233445566778899AABBCCDDEEFF');
|
|
372
|
+
await store?.flush();
|
|
373
|
+
|
|
374
|
+
const reloaded = await loadD1ServicesStore(env.ACCOUNTS_DB, new FakeClock(NOW));
|
|
375
|
+
// The fingerprint is normalised (lowercase, colon-free) on the round-trip.
|
|
376
|
+
expect(reloaded?.getNick('alice')?.certSubjects).toEqual([
|
|
377
|
+
'fp:aa112233445566778899aabbccddeeff',
|
|
378
|
+
]);
|
|
379
|
+
expect(
|
|
380
|
+
reloaded?.verifyCertFP(
|
|
381
|
+
certIdentityFromSubject('CN=alice', 'aa112233445566778899aabbccddeeff'),
|
|
382
|
+
),
|
|
383
|
+
).toEqual({ ok: true, account: 'alice' });
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
it('round-trips a bound subject DN and verifies EXTERNAL via the canonical DN arm', async () => {
|
|
387
|
+
const store = await loadD1ServicesStore(env.ACCOUNTS_DB, new FakeClock(NOW));
|
|
388
|
+
store?.registerNick('alice', 'hunter2', 'a@x');
|
|
389
|
+
store?.addCertFP('alice', 'CN=alice,O=Example Inc');
|
|
390
|
+
await store?.flush();
|
|
391
|
+
|
|
392
|
+
const reloaded = await loadD1ServicesStore(env.ACCOUNTS_DB, new FakeClock(NOW));
|
|
393
|
+
// Platform-surfaced DN in a different RDN order still matches (canonical compare).
|
|
394
|
+
expect(reloaded?.verifyCertFP(certIdentityFromSubject('O=Example Inc,CN=alice'))).toEqual({
|
|
395
|
+
ok: true,
|
|
396
|
+
account: 'alice',
|
|
397
|
+
});
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
it('keeps EXTERNAL verify failing for an unbound cert after reload', async () => {
|
|
401
|
+
const store = await loadD1ServicesStore(env.ACCOUNTS_DB, new FakeClock(NOW));
|
|
402
|
+
store?.registerNick('alice', 'hunter2', 'a@x');
|
|
403
|
+
store?.addCertFingerprint('alice', 'aa112233445566778899aabbccddeeff');
|
|
404
|
+
await store?.flush();
|
|
405
|
+
|
|
406
|
+
const reloaded = await loadD1ServicesStore(env.ACCOUNTS_DB, new FakeClock(NOW));
|
|
407
|
+
expect(
|
|
408
|
+
reloaded?.verifyCertFP(
|
|
409
|
+
certIdentityFromSubject('CN=alice', 'ffffffffffffffffffffffffffffffff'),
|
|
410
|
+
)?.ok,
|
|
411
|
+
).toBe(false);
|
|
412
|
+
});
|
|
209
413
|
});
|
|
210
414
|
|
|
211
415
|
describe('D1ServicesStore — ChanServ round-trip across reload', () => {
|
|
@@ -524,14 +728,18 @@ describe('D1ServicesStore — tolerance', () => {
|
|
|
524
728
|
expect(snap.jupes).toEqual([]);
|
|
525
729
|
});
|
|
526
730
|
|
|
527
|
-
it('skips rows with NULL fields in the all-TEXT tables (vhosts / read_markers)', async () => {
|
|
528
|
-
// Recreate the
|
|
731
|
+
it('skips rows with NULL fields in the all-TEXT tables (vhosts / pending_vhosts / read_markers)', async () => {
|
|
732
|
+
// Recreate the all-TEXT tables without NOT NULL so a NULL field can
|
|
529
733
|
// survive — exercises the typeof-string defensive skip for the loaders
|
|
530
734
|
// whose INTEGER-corruption path is unreachable under SQLite affinity.
|
|
531
735
|
await env.ACCOUNTS_DB.exec('DROP TABLE hostserv_vhosts');
|
|
532
736
|
await env.ACCOUNTS_DB.exec(
|
|
533
737
|
'CREATE TABLE hostserv_vhosts (account_key TEXT PRIMARY KEY, account TEXT, vhost TEXT)',
|
|
534
738
|
);
|
|
739
|
+
await env.ACCOUNTS_DB.exec('DROP TABLE hostserv_pending_vhosts');
|
|
740
|
+
await env.ACCOUNTS_DB.exec(
|
|
741
|
+
'CREATE TABLE hostserv_pending_vhosts (account_key TEXT PRIMARY KEY, account TEXT, vhost TEXT)',
|
|
742
|
+
);
|
|
535
743
|
await env.ACCOUNTS_DB.exec('DROP TABLE read_markers');
|
|
536
744
|
await env.ACCOUNTS_DB.exec(
|
|
537
745
|
'CREATE TABLE read_markers (account_key TEXT NOT NULL, channel_key TEXT NOT NULL, account TEXT, channel TEXT, msgid TEXT)',
|
|
@@ -541,6 +749,11 @@ describe('D1ServicesStore — tolerance', () => {
|
|
|
541
749
|
)
|
|
542
750
|
.bind('bad#v', null, 'cloak')
|
|
543
751
|
.run();
|
|
752
|
+
await env.ACCOUNTS_DB.prepare(
|
|
753
|
+
'INSERT INTO hostserv_pending_vhosts (account_key, account, vhost) VALUES (?, ?, ?)',
|
|
754
|
+
)
|
|
755
|
+
.bind('bad#pv', 'someone', null)
|
|
756
|
+
.run();
|
|
544
757
|
await env.ACCOUNTS_DB.prepare(
|
|
545
758
|
'INSERT INTO read_markers (account_key, channel_key, account, channel, msgid) VALUES (?, ?, ?, ?, ?)',
|
|
546
759
|
)
|
|
@@ -552,6 +765,7 @@ describe('D1ServicesStore — tolerance', () => {
|
|
|
552
765
|
if (!store) throw new Error('services store failed to load');
|
|
553
766
|
const snap = store.snapshot();
|
|
554
767
|
expect(snap.vhosts).toEqual([]);
|
|
768
|
+
expect(snap.pendingVhosts).toEqual([]);
|
|
555
769
|
expect(snap.readMarkers).toEqual([]);
|
|
556
770
|
});
|
|
557
771
|
|
|
@@ -566,6 +780,34 @@ describe('D1ServicesStore — tolerance', () => {
|
|
|
566
780
|
});
|
|
567
781
|
});
|
|
568
782
|
|
|
783
|
+
// ---------------------------------------------------------------------------
|
|
784
|
+
// safeSelect — defensive catch block (real D1 bindings don't throw on
|
|
785
|
+
// existing-table queries; the catch arm is unreachable through integration).
|
|
786
|
+
// ---------------------------------------------------------------------------
|
|
787
|
+
|
|
788
|
+
describe('safeSelect — defensive catch block', () => {
|
|
789
|
+
it('returns [] and logs when the underlying D1 query rejects', async () => {
|
|
790
|
+
// A misbehaving binding (transient unavailability, schema drift) would
|
|
791
|
+
// otherwise brick NickServ for the whole connection (the snapshot is
|
|
792
|
+
// cached for life). The catch arm logs to wrangler tail and degrades
|
|
793
|
+
// to an empty result.
|
|
794
|
+
const stub = {
|
|
795
|
+
prepare(): { all(): Promise<never> } {
|
|
796
|
+
return {
|
|
797
|
+
all: async () => {
|
|
798
|
+
throw new Error('simulated D1 outage');
|
|
799
|
+
},
|
|
800
|
+
};
|
|
801
|
+
},
|
|
802
|
+
};
|
|
803
|
+
const result = await safeSelect<{ x: unknown }>(
|
|
804
|
+
stub as unknown as Parameters<typeof safeSelect>[0],
|
|
805
|
+
'SELECT * FROM nicks',
|
|
806
|
+
);
|
|
807
|
+
expect(result).toEqual([]);
|
|
808
|
+
});
|
|
809
|
+
});
|
|
810
|
+
|
|
569
811
|
function emptySnapshot(): ServicesSnapshot {
|
|
570
812
|
return {
|
|
571
813
|
nicks: [],
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `RateLimitDO` — per-IP upgrade rate limiter.
|
|
3
|
+
*
|
|
4
|
+
* TDD outline:
|
|
5
|
+
* "Red on allow-under-budget / reject-over-budget / window-decay;
|
|
6
|
+
* green; then per-IP isolation and the concurrency linearisation."
|
|
7
|
+
*
|
|
8
|
+
* Tests address the single global limiter instance directly over RPC,
|
|
9
|
+
* driving every branch of `check` with explicit timestamps so the
|
|
10
|
+
* sliding-window boundary is deterministic (no wall clock dependence).
|
|
11
|
+
* Mirrors the CounterDO suite; each test gets isolated storage.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { env, reset } from 'cloudflare:test';
|
|
15
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
16
|
+
import type { RateLimitRpc } from '../src/env';
|
|
17
|
+
import { RATE_LIMIT_INSTANCE_NAME } from '../src/rate-limit-do';
|
|
18
|
+
|
|
19
|
+
declare global {
|
|
20
|
+
namespace Cloudflare {
|
|
21
|
+
interface Env {
|
|
22
|
+
RATE_LIMIT_DO: DurableObjectNamespace;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// DO storage persists across tests in the same file; reset between each so
|
|
28
|
+
// rate entries from one test never leak into the next. Every test addresses
|
|
29
|
+
// the same `global` limiter instance, so this is essential.
|
|
30
|
+
afterEach(async () => {
|
|
31
|
+
await reset();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
/** Fixed epoch base for deterministic window arithmetic. */
|
|
35
|
+
const T0 = 1_700_000_000_000;
|
|
36
|
+
/** Window used by every test (1 minute, the schema default order). */
|
|
37
|
+
const WINDOW = 60_000;
|
|
38
|
+
|
|
39
|
+
/** RPC handle on the single global limiter instance. */
|
|
40
|
+
function limiter(): RateLimitRpc {
|
|
41
|
+
const stub = env.RATE_LIMIT_DO.get(env.RATE_LIMIT_DO.idFromName(RATE_LIMIT_INSTANCE_NAME));
|
|
42
|
+
return stub as unknown as RateLimitRpc;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
// check — allow / reject
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
describe('RateLimitDO — check', () => {
|
|
50
|
+
it('allows the first N upgrades from one IP and reports the count', async () => {
|
|
51
|
+
const rl = limiter();
|
|
52
|
+
expect(await rl.check('203.0.113.1', 2, WINDOW, T0)).toEqual({ allowed: true, count: 1 });
|
|
53
|
+
expect(await rl.check('203.0.113.1', 2, WINDOW, T0 + 1000)).toEqual({
|
|
54
|
+
allowed: true,
|
|
55
|
+
count: 2,
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('rejects the over-budget upgrade from the same IP', async () => {
|
|
60
|
+
const rl = limiter();
|
|
61
|
+
await rl.check('203.0.113.2', 1, WINDOW, T0);
|
|
62
|
+
expect(await rl.check('203.0.113.2', 1, WINDOW, T0 + 1000)).toEqual({
|
|
63
|
+
allowed: false,
|
|
64
|
+
count: 1,
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('a rejected check does not append a timestamp (count stays at the cap)', async () => {
|
|
69
|
+
const rl = limiter();
|
|
70
|
+
await rl.check('203.0.113.3', 1, WINDOW, T0);
|
|
71
|
+
await rl.check('203.0.113.3', 1, WINDOW, T0 + 1000);
|
|
72
|
+
// Still rejected at the original count: the rejection itself must not
|
|
73
|
+
// consume budget (otherwise a blocked IP could never recover).
|
|
74
|
+
expect(await rl.check('203.0.113.3', 1, WINDOW, T0 + 2000)).toEqual({
|
|
75
|
+
allowed: false,
|
|
76
|
+
count: 1,
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('keys budgets per IP (one saturated IP does not block another)', async () => {
|
|
81
|
+
const rl = limiter();
|
|
82
|
+
await rl.check('198.51.100.1', 1, WINDOW, T0);
|
|
83
|
+
expect(await rl.check('198.51.100.1', 1, WINDOW, T0 + 1000)).toEqual({
|
|
84
|
+
allowed: false,
|
|
85
|
+
count: 1,
|
|
86
|
+
});
|
|
87
|
+
expect(await rl.check('198.51.100.2', 1, WINDOW, T0 + 1000)).toEqual({
|
|
88
|
+
allowed: true,
|
|
89
|
+
count: 1,
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
// sliding window — decay after the window
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
describe('RateLimitDO — window reset', () => {
|
|
99
|
+
it('an entry aged past the window stops counting (budget recovers)', async () => {
|
|
100
|
+
const rl = limiter();
|
|
101
|
+
await rl.check('203.0.113.4', 1, WINDOW, T0);
|
|
102
|
+
// At T0 + WINDOW + 1 the T0 admission is strictly older than the
|
|
103
|
+
// cutoff (T0 + 1) → decayed out of the window.
|
|
104
|
+
expect(await rl.check('203.0.113.4', 1, WINDOW, T0 + WINDOW + 1)).toEqual({
|
|
105
|
+
allowed: true,
|
|
106
|
+
count: 1,
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('an entry exactly windowMs old still counts (boundary is inclusive)', async () => {
|
|
111
|
+
// Mirrors irc-core `recentAdmissions`: entries with t >= cutoff count,
|
|
112
|
+
// so an admission exactly one window old is still inside the window.
|
|
113
|
+
const rl = limiter();
|
|
114
|
+
await rl.check('203.0.113.5', 1, WINDOW, T0);
|
|
115
|
+
expect(await rl.check('203.0.113.5', 1, WINDOW, T0 + WINDOW)).toEqual({
|
|
116
|
+
allowed: false,
|
|
117
|
+
count: 1,
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('prunes stale entries so a saturated IP recovers exactly one slot per decayed entry', async () => {
|
|
122
|
+
const rl = limiter();
|
|
123
|
+
await rl.check('203.0.113.6', 2, WINDOW, T0);
|
|
124
|
+
await rl.check('203.0.113.6', 2, WINDOW, T0 + 30_000);
|
|
125
|
+
// Cutoff at T0+45_000 is T0-15_000 → both entries still count.
|
|
126
|
+
expect(await rl.check('203.0.113.6', 2, WINDOW, T0 + 45_000)).toEqual({
|
|
127
|
+
allowed: false,
|
|
128
|
+
count: 2,
|
|
129
|
+
});
|
|
130
|
+
// At T0+WINDOW+1 the cutoff is T0+1: the T0 entry decayed, the
|
|
131
|
+
// T0+30_000 entry remains → one slot free → allowed (and this check
|
|
132
|
+
// itself consumes it).
|
|
133
|
+
expect(await rl.check('203.0.113.6', 2, WINDOW, T0 + WINDOW + 1)).toEqual({
|
|
134
|
+
allowed: true,
|
|
135
|
+
count: 2,
|
|
136
|
+
});
|
|
137
|
+
// Window members are now {T0+30_000, T0+WINDOW+1} → exhausted again.
|
|
138
|
+
expect(await rl.check('203.0.113.6', 2, WINDOW, T0 + WINDOW + 2)).toEqual({
|
|
139
|
+
allowed: false,
|
|
140
|
+
count: 2,
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// ---------------------------------------------------------------------------
|
|
146
|
+
// concurrency — the DO's serialized event loop linearises checks
|
|
147
|
+
// ---------------------------------------------------------------------------
|
|
148
|
+
|
|
149
|
+
describe('RateLimitDO — concurrency', () => {
|
|
150
|
+
it('exactly one of two concurrent checks wins at budget 1', async () => {
|
|
151
|
+
const rl = limiter();
|
|
152
|
+
const [r1, r2] = await Promise.all([
|
|
153
|
+
rl.check('203.0.113.7', 1, WINDOW, T0),
|
|
154
|
+
rl.check('203.0.113.7', 1, WINDOW, T0),
|
|
155
|
+
]);
|
|
156
|
+
const results = [r1, r2];
|
|
157
|
+
expect(results.filter((r) => r.allowed).length).toBe(1);
|
|
158
|
+
expect(results.filter((r) => !r.allowed).length).toBe(1);
|
|
159
|
+
});
|
|
160
|
+
});
|
|
@@ -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
|
});
|