@cosmicdrift/kumiko-framework 0.165.0 → 2.0.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/package.json +5 -3
- package/src/__tests__/consumer-cli.integration.test.ts +32 -0
- package/src/__tests__/schema-cli.integration.test.ts +1 -1
- package/src/api/__tests__/api.test.ts +267 -19
- package/src/api/__tests__/auth-middleware-anonymous-access-boot.test.ts +40 -0
- package/src/api/__tests__/auth-routes-invalid-body-invite.test.ts +16 -0
- package/src/api/__tests__/auth-routes-mfa-preauth-confirm.test.ts +2 -1
- package/src/api/__tests__/auth-routes-mfa-preauth-enable-start.test.ts +64 -1
- package/src/api/__tests__/auth-routes-trusted-proxy.test.ts +135 -0
- package/src/api/__tests__/batch.integration.test.ts +21 -2
- package/src/api/__tests__/jwt.test.ts +52 -2
- package/src/api/__tests__/redis-login-rate-limiter.integration.test.ts +72 -0
- package/src/api/__tests__/sse-broker.test.ts +57 -0
- package/src/api/__tests__/sse-route.test.ts +4 -0
- package/src/api/auth-routes.ts +165 -33
- package/src/api/index.ts +1 -0
- package/src/api/jwt.ts +22 -1
- package/src/api/routes.ts +103 -35
- package/src/api/server.ts +17 -1
- package/src/api/sse-broker.ts +39 -0
- package/src/bun-db/index.ts +1 -0
- package/src/bun-db/query.ts +12 -3
- package/src/consumer-cli.ts +60 -13
- package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +14 -1
- package/src/db/__tests__/located-timestamp.test.ts +19 -0
- package/src/db/__tests__/migrate-runner.test.ts +61 -0
- package/src/db/__tests__/replay-migration-sql.test.ts +131 -2
- package/src/db/__tests__/tenant-db-where-merge.test.ts +6 -2
- package/src/db/api.ts +2 -2
- package/src/db/bun-provider.ts +2 -2
- package/src/db/connection.ts +6 -3
- package/src/db/dialect.ts +1 -6
- package/src/db/entity-table-meta-types.ts +1 -1
- package/src/db/event-store-executor-context.ts +2 -3
- package/src/db/event-store-executor-read.ts +2 -3
- package/src/db/event-store-executor-write.ts +8 -0
- package/src/db/index.ts +8 -1
- package/src/db/located-timestamp.ts +4 -0
- package/src/db/migrate-runner.ts +107 -11
- package/src/db/pg-error.ts +8 -0
- package/src/db/postgres-provider.ts +2 -2
- package/src/db/queries/__tests__/event-store-idempotency-index.integration.test.ts +80 -0
- package/src/db/queries/ddl.ts +45 -0
- package/src/db/queries/event-store.ts +97 -5
- package/src/db/queries/test-stack.ts +4 -30
- package/src/db/reference-data.ts +2 -3
- package/src/db/replay-migration-sql.ts +114 -12
- package/src/db/tenant-db.ts +2 -4
- package/src/engine/__tests__/engine.test.ts +30 -0
- package/src/engine/__tests__/schema-builder.test.ts +18 -0
- package/src/engine/__tests__/store-table.test.ts +2 -2
- package/src/engine/boot-validator/nav.ts +5 -0
- package/src/engine/constants.ts +32 -6
- package/src/engine/create-app.ts +11 -0
- package/src/engine/effective-features.ts +12 -2
- package/src/engine/extensions/user-data.ts +12 -4
- package/src/engine/feature-ui-extensions.ts +2 -2
- package/src/engine/hook-helpers.ts +3 -1
- package/src/engine/index.ts +1 -1
- package/src/engine/ownership.ts +4 -3
- package/src/engine/registry-ingest.ts +14 -14
- package/src/engine/registry-state.ts +4 -1
- package/src/engine/schema-builder.ts +1 -0
- package/src/engine/steps/__tests__/duration-utils.test.ts +20 -0
- package/src/engine/steps/_duration-utils.ts +2 -0
- package/src/engine/steps/unsafe-projection-upsert.ts +1 -4
- package/src/engine/types/config.ts +1 -1
- package/src/engine/types/define-handler.ts +1 -1
- package/src/engine/types/entity-handlers.ts +1 -1
- package/src/engine/types/event-type-map.ts +1 -1
- package/src/engine/types/feature.ts +1 -1
- package/src/engine/types/fields.ts +1 -1
- package/src/engine/types/handlers.ts +1 -1
- package/src/engine/types/hooks.ts +1 -1
- package/src/engine/types/http-route.ts +1 -1
- package/src/engine/types/nav.ts +1 -1
- package/src/engine/types/ownership.ts +1 -1
- package/src/engine/types/projection.ts +1 -1
- package/src/engine/types/relations.ts +1 -1
- package/src/engine/types/screen.ts +1 -1
- package/src/engine/types/step.ts +1 -1
- package/src/engine/types/target-ref.ts +1 -1
- package/src/engine/types/tree-node.ts +1 -1
- package/src/engine/types/workspace.ts +1 -1
- package/src/engine/validate-projection-allowlist.ts +5 -5
- package/src/errors/classes.ts +21 -0
- package/src/errors/index.ts +1 -0
- package/src/errors/write-error-info.ts +6 -2
- package/src/event-store/__tests__/admin-api.integration.test.ts +27 -1
- package/src/event-store/__tests__/event-store.integration.test.ts +32 -0
- package/src/event-store/__tests__/unscoped-stream-primitives.guard.test.ts +22 -4
- package/src/event-store/admin-api.ts +11 -4
- package/src/event-store/event-store.ts +19 -4
- package/src/event-store/types.ts +1 -1
- package/src/files/__tests__/build-storage-key.test.ts +28 -0
- package/src/files/__tests__/local-provider.test.ts +31 -0
- package/src/files/__tests__/write-stream.test.ts +3 -3
- package/src/files/index.ts +1 -1
- package/src/files/local-provider.ts +6 -1
- package/src/files/types.ts +8 -1
- package/src/jobs/__tests__/jobs.integration.test.ts +167 -7
- package/src/jobs/job-runner.ts +41 -11
- package/src/logging/types.ts +1 -1
- package/src/observability/index.ts +1 -0
- package/src/observability/standard-metrics.ts +35 -2
- package/src/observability/types/index.ts +1 -1
- package/src/observability/types/metric.ts +1 -1
- package/src/observability/types/provider.ts +1 -1
- package/src/observability/types/span.ts +1 -1
- package/src/pipeline/__tests__/dispatcher.test.ts +151 -0
- package/src/pipeline/__tests__/event-consumer-state.integration.test.ts +31 -0
- package/src/pipeline/__tests__/event-dispatcher-rearm.integration.test.ts +83 -0
- package/src/pipeline/__tests__/lifecycle-pipeline.test.ts +107 -97
- package/src/pipeline/dispatch-shared.ts +59 -6
- package/src/pipeline/dispatch-stream.ts +32 -11
- package/src/pipeline/dispatcher.ts +7 -1
- package/src/pipeline/event-consumer-state.ts +16 -13
- package/src/pipeline/event-dispatcher-delivery.ts +20 -6
- package/src/pipeline/event-dispatcher.ts +22 -0
- package/src/pipeline/index.ts +2 -0
- package/src/pipeline/system-hooks.ts +87 -0
- package/src/rate-limit/__tests__/resolver.integration.test.ts +18 -0
- package/src/rate-limit/resolver.ts +6 -2
- package/src/schema-cli.ts +24 -12
- package/src/search/__tests__/reindex-entity.integration.test.ts +24 -1
- package/src/search/reindex-entity.ts +31 -2
- package/src/search/types.ts +1 -1
- package/src/stack/__tests__/setup-test-stack-jobs.integration.test.ts +6 -2
- package/src/stack/db.ts +2 -1
- package/src/stack/push-entity-projection-tables.ts +2 -1
- package/src/stack/request-helper.ts +20 -1
- package/src/stack/table-helpers.ts +6 -4
- package/src/stack/test-stack.ts +18 -15
- package/src/testing/__tests__/late-bound.test.ts +7 -0
- package/src/testing/__tests__/wait-for.test.ts +6 -0
- package/src/testing/file-provider-contract.ts +26 -6
- package/src/testing/index.ts +1 -0
- package/src/testing/late-bound.ts +5 -3
- package/src/testing/wait-for.ts +3 -0
- package/src/testing/without-ambient-temporal.ts +14 -0
- package/src/time/geo-tz.ts +1 -1
- package/src/time/polyfill.ts +21 -38
- package/src/time/tz-context.ts +37 -31
- package/src/utils/__tests__/safe-json-temporal.test.ts +18 -0
- package/src/utils/safe-json.ts +13 -1
- package/src/engine/__tests__/registry-facade-sweep.test.ts +0 -80
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// kumiko-framework#1539: `trustedProxyHops` on AuthRoutesConfig. Verifies
|
|
2
|
+
// the client-IP derivation behind /auth/mfa/verify's rate limiter — chosen
|
|
3
|
+
// because it keys on bare IP (no email composite), so a wrong default here
|
|
4
|
+
// is a shared-bucket DoS, not just a spoofing hole. Real Hono app + real
|
|
5
|
+
// in-memory rate limiter, HTTP requests with attacker-shaped headers — no
|
|
6
|
+
// fake dispatcher.
|
|
7
|
+
|
|
8
|
+
import { describe, expect, test } from "bun:test";
|
|
9
|
+
import type { Hono } from "hono";
|
|
10
|
+
import { Hono as HonoCtor } from "hono";
|
|
11
|
+
import { UnprocessableError } from "../../errors";
|
|
12
|
+
import type { BatchResult, Dispatcher, WriteResult } from "../../pipeline/dispatcher";
|
|
13
|
+
import { PUBLIC_API_PATHS } from "../api-constants";
|
|
14
|
+
import { authMiddleware } from "../auth-middleware";
|
|
15
|
+
import {
|
|
16
|
+
type AuthRoutesConfig,
|
|
17
|
+
createAuthRoutes,
|
|
18
|
+
createInMemoryLoginRateLimiter,
|
|
19
|
+
} from "../auth-routes";
|
|
20
|
+
import { createJwtHelper } from "../jwt";
|
|
21
|
+
|
|
22
|
+
const JWT_SECRET = "test-secret-at-least-32-bytes-long-for-hs256";
|
|
23
|
+
const MFA_VERIFY_QN = "auth-mfa:write:verify";
|
|
24
|
+
|
|
25
|
+
function failingDispatcher(): Dispatcher {
|
|
26
|
+
return {
|
|
27
|
+
async write(): Promise<WriteResult> {
|
|
28
|
+
return { isSuccess: false, error: new UnprocessableError("invalid_totp_code") };
|
|
29
|
+
},
|
|
30
|
+
async query(): Promise<unknown> {
|
|
31
|
+
return [];
|
|
32
|
+
},
|
|
33
|
+
async *stream(): AsyncGenerator<unknown> {},
|
|
34
|
+
async command(): Promise<void> {},
|
|
35
|
+
async batch(): Promise<BatchResult> {
|
|
36
|
+
return { isSuccess: true, results: [] };
|
|
37
|
+
},
|
|
38
|
+
async resolveAuthClaims(): Promise<Record<string, unknown>> {
|
|
39
|
+
return {};
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function buildApp(overrides: Partial<AuthRoutesConfig> = {}): Promise<Hono> {
|
|
45
|
+
const jwt = createJwtHelper(JWT_SECRET);
|
|
46
|
+
const config: AuthRoutesConfig = {
|
|
47
|
+
membershipQuery: "tenant:query:memberships",
|
|
48
|
+
mfaVerifyHandler: MFA_VERIFY_QN,
|
|
49
|
+
mfaVerifyRateLimit: createInMemoryLoginRateLimiter(2, 60_000),
|
|
50
|
+
...overrides,
|
|
51
|
+
};
|
|
52
|
+
const app = new HonoCtor();
|
|
53
|
+
const jwtGuard = authMiddleware(jwt);
|
|
54
|
+
app.use("/api/*", async (c, next) => {
|
|
55
|
+
if (PUBLIC_API_PATHS.has(c.req.path)) return next();
|
|
56
|
+
return jwtGuard(c, next);
|
|
57
|
+
});
|
|
58
|
+
app.route("/api", createAuthRoutes(failingDispatcher(), jwt, config));
|
|
59
|
+
return app;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function verifyRequest(xForwardedFor?: string): Request {
|
|
63
|
+
return new Request("http://localhost/api/auth/mfa/verify", {
|
|
64
|
+
method: "POST",
|
|
65
|
+
headers: {
|
|
66
|
+
"Content-Type": "application/json",
|
|
67
|
+
...(xForwardedFor !== undefined ? { "x-forwarded-for": xForwardedFor } : {}),
|
|
68
|
+
},
|
|
69
|
+
body: JSON.stringify({ challengeToken: "t", code: "000000" }),
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
describe("AuthRoutesConfig.trustedProxyHops", () => {
|
|
74
|
+
test("rejects a negative trustedProxyHops at construction — fails loud, not into a shared bucket", async () => {
|
|
75
|
+
await expect(buildApp({ trustedProxyHops: -1 })).rejects.toThrow(/non-negative integer/);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("rejects a non-integer trustedProxyHops at construction", async () => {
|
|
79
|
+
await expect(buildApp({ trustedProxyHops: 1.5 })).rejects.toThrow(/non-negative integer/);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("hops=1: an attacker rotating the untrusted XFF prefix still hits the real client's bucket", async () => {
|
|
83
|
+
const app = await buildApp({ trustedProxyHops: 1 });
|
|
84
|
+
// nginx-style append: "<attacker-claim>, <real-client-as-seen-by-nginx>"
|
|
85
|
+
// — attacker rotates the left entry per request, last entry stays fixed.
|
|
86
|
+
const attempt = (n: number) => app.request(verifyRequest(`1.2.3.${n}, 9.9.9.9`));
|
|
87
|
+
expect((await attempt(1)).status).toBe(422);
|
|
88
|
+
expect((await attempt(2)).status).toBe(422);
|
|
89
|
+
const third = await attempt(3);
|
|
90
|
+
expect(third.status).toBe(429);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("hops=1: two genuinely different clients (differing last entry) get independent buckets", async () => {
|
|
94
|
+
const app = await buildApp({ trustedProxyHops: 1 });
|
|
95
|
+
const clientA = () => app.request(verifyRequest("fake, 9.9.9.9"));
|
|
96
|
+
const clientB = () => app.request(verifyRequest("fake, 8.8.8.8"));
|
|
97
|
+
expect((await clientA()).status).toBe(422);
|
|
98
|
+
expect((await clientA()).status).toBe(422);
|
|
99
|
+
expect((await clientA()).status).toBe(429);
|
|
100
|
+
// client B is unaffected by client A's cap
|
|
101
|
+
expect((await clientB()).status).toBe(422);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("hops=0 (default): legacy behavior — trusts the first XFF entry unconditionally", async () => {
|
|
105
|
+
const app = await buildApp();
|
|
106
|
+
// No override: two "different" first-entries mean two different buckets,
|
|
107
|
+
// even though both requests carry the same real backend hop.
|
|
108
|
+
const clientA = () => app.request(verifyRequest("1.1.1.1, 9.9.9.9"));
|
|
109
|
+
const clientB = () => app.request(verifyRequest("2.2.2.2, 9.9.9.9"));
|
|
110
|
+
expect((await clientA()).status).toBe(422);
|
|
111
|
+
expect((await clientA()).status).toBe(422);
|
|
112
|
+
expect((await clientA()).status).toBe(429);
|
|
113
|
+
expect((await clientB()).status).toBe(422);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("hops=2 with a shorter XFF chain than configured collapses distinct clients into the unknown bucket", async () => {
|
|
117
|
+
const app = await buildApp({ trustedProxyHops: 2 });
|
|
118
|
+
// Only 1 entry present but 2 hops configured — chain shorter than
|
|
119
|
+
// expected. Two DIFFERENT single-entry values must still share the cap:
|
|
120
|
+
// proves the short chain was rejected into "unknown" rather than
|
|
121
|
+
// extracted per-request (a same-value-only assertion would also pass
|
|
122
|
+
// for a constant return).
|
|
123
|
+
const attempt = (xff: string) => app.request(verifyRequest(xff));
|
|
124
|
+
expect((await attempt("9.9.9.9")).status).toBe(422);
|
|
125
|
+
expect((await attempt("7.7.7.7")).status).toBe(422);
|
|
126
|
+
const third = await attempt("6.6.6.6");
|
|
127
|
+
expect(third.status).toBe(429);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("hops=1 with no XFF header at all falls back to unknown, not a throw", async () => {
|
|
131
|
+
const app = await buildApp({ trustedProxyHops: 1 });
|
|
132
|
+
const res = await app.request(verifyRequest(undefined));
|
|
133
|
+
expect(res.status).toBe(422);
|
|
134
|
+
});
|
|
135
|
+
});
|
|
@@ -97,7 +97,7 @@ const itemFeature = defineFeature("batch", (r) => {
|
|
|
97
97
|
"postSave",
|
|
98
98
|
{ allOf: item },
|
|
99
99
|
async (result, ctx) => {
|
|
100
|
-
if (!ctx.db) return;
|
|
100
|
+
if (!ctx.db || !("insertOne" in ctx.db)) return;
|
|
101
101
|
await ctx.db.insertOne(auditTable, { action: "item_saved", itemId: result.id });
|
|
102
102
|
},
|
|
103
103
|
{ phase: HookPhases.inTransaction },
|
|
@@ -407,7 +407,26 @@ describe("POST /api/batch", () => {
|
|
|
407
407
|
|
|
408
408
|
test("idempotency: corrupted cache entry is treated as miss and re-runs", async () => {
|
|
409
409
|
const requestId = "batch-rid-corrupt";
|
|
410
|
-
|
|
410
|
+
const cacheKey = `${RedisKeys.idempotency}${requestId}`;
|
|
411
|
+
|
|
412
|
+
// Prove key-coupling first: seed a well-formed cached entry under the
|
|
413
|
+
// exact manually-built key and confirm the batch short-circuits on it
|
|
414
|
+
// (no handler run, no DB write) — if the manual key ever drifts from
|
|
415
|
+
// what the guard actually reads, this half fails loud instead of the
|
|
416
|
+
// corrupt-JSON half below silently becoming a no-op cache-miss test.
|
|
417
|
+
const seeded = { isSuccess: true, results: [{ seeded: true }] };
|
|
418
|
+
await stack.redis.redis.set(cacheKey, JSON.stringify(seeded), "EX", 60);
|
|
419
|
+
const cachedRes = await stack.http.batch(
|
|
420
|
+
[{ type: "batch:write:item:create", payload: { name: "should-not-run" } }],
|
|
421
|
+
admin,
|
|
422
|
+
requestId,
|
|
423
|
+
);
|
|
424
|
+
expect(await cachedRes.json()).toEqual(seeded);
|
|
425
|
+
expect(await selectMany(stack.db, itemTable)).toHaveLength(0);
|
|
426
|
+
expect(inTxHookLog).toHaveLength(0);
|
|
427
|
+
|
|
428
|
+
// Now corrupt the same key and confirm it's treated as a miss.
|
|
429
|
+
await stack.redis.redis.set(cacheKey, "{not-json", "EX", 60);
|
|
411
430
|
|
|
412
431
|
const res = await stack.http.batch(
|
|
413
432
|
[{ type: "batch:write:item:create", payload: { name: "after-corrupt" } }],
|
|
@@ -185,10 +185,36 @@ describe("loadJwtSecretOrKeyring", () => {
|
|
|
185
185
|
).toThrow(/not present in the keyring/);
|
|
186
186
|
});
|
|
187
187
|
|
|
188
|
-
it("plain JWT_SECRET
|
|
188
|
+
it("kumiko-framework#1308: plain JWT_SECRET carries over as a verify-only legacy key once a keyring is adopted", () => {
|
|
189
|
+
// Adopting rotation for the first time doesn't unset JWT_SECRET (it still
|
|
190
|
+
// signs the separate password-reset/email-verification HMAC family) —
|
|
191
|
+
// and sessions signed before adoption have no `kid`, so they must keep
|
|
192
|
+
// verifying against this exact value, not get orphaned the moment
|
|
193
|
+
// JWT_SECRET_V1 shows up.
|
|
194
|
+
const PLAIN_SECRET = "pre-rotation-plain-secret-32-chars-long!!";
|
|
195
|
+
expect(
|
|
196
|
+
loadJwtSecretOrKeyring({
|
|
197
|
+
JWT_SECRET: PLAIN_SECRET,
|
|
198
|
+
JWT_SECRET_V1: SECRET,
|
|
199
|
+
JWT_SECRET_CURRENT_VERSION: "1",
|
|
200
|
+
}),
|
|
201
|
+
).toEqual({ keys: { v1: SECRET, legacy: PLAIN_SECRET }, signKid: "v1" });
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("kumiko-framework#1308: legacy JWT_SECRET never becomes the sign key, only ever verify-only", () => {
|
|
205
|
+
const PLAIN_SECRET = "pre-rotation-plain-secret-32-chars-long!!";
|
|
206
|
+
const keyring = loadJwtSecretOrKeyring({
|
|
207
|
+
JWT_SECRET: PLAIN_SECRET,
|
|
208
|
+
JWT_SECRET_V1: SECRET,
|
|
209
|
+
JWT_SECRET_CURRENT_VERSION: "1",
|
|
210
|
+
});
|
|
211
|
+
expect(typeof keyring).not.toBe("string");
|
|
212
|
+
expect((keyring as { signKid: string }).signKid).toBe("v1");
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it("kumiko-framework#1308: no plain JWT_SECRET set alongside a keyring → no legacy key added", () => {
|
|
189
216
|
expect(
|
|
190
217
|
loadJwtSecretOrKeyring({
|
|
191
|
-
JWT_SECRET: "should-be-ignored-once-keyring-present-aa",
|
|
192
218
|
JWT_SECRET_V1: SECRET,
|
|
193
219
|
JWT_SECRET_CURRENT_VERSION: "1",
|
|
194
220
|
}),
|
|
@@ -209,6 +235,30 @@ describe("loadJwtSecretOrKeyring", () => {
|
|
|
209
235
|
}),
|
|
210
236
|
).toThrow(/JWT_SECRET_V1 must be ≥32 chars/);
|
|
211
237
|
});
|
|
238
|
+
|
|
239
|
+
it("kumiko-framework#1526: short legacy JWT_SECRET is rejected, not silently carried into the keyring", () => {
|
|
240
|
+
// Auth-bypass: a short JWT_SECRET carried into keys["legacy"] without a
|
|
241
|
+
// length check would be offline-bruteforceable — an attacker forges
|
|
242
|
+
// {alg:"HS256", kid:"legacy"} with arbitrary sub/tenantId/roles and
|
|
243
|
+
// verifyWithKeyring looks the key up by kid straight from the header.
|
|
244
|
+
expect(() =>
|
|
245
|
+
loadJwtSecretOrKeyring({
|
|
246
|
+
JWT_SECRET: "short",
|
|
247
|
+
JWT_SECRET_V1: SECRET,
|
|
248
|
+
JWT_SECRET_CURRENT_VERSION: "1",
|
|
249
|
+
}),
|
|
250
|
+
).toThrow(/JWT_SECRET must be ≥32 chars/);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it("kumiko-framework#1308: empty JWT_SECRET_V<n> throws instead of silently falling back to single-secret mode", () => {
|
|
254
|
+
expect(() =>
|
|
255
|
+
loadJwtSecretOrKeyring({
|
|
256
|
+
JWT_SECRET: SECRET,
|
|
257
|
+
JWT_SECRET_V1: "",
|
|
258
|
+
JWT_SECRET_CURRENT_VERSION: "1",
|
|
259
|
+
}),
|
|
260
|
+
).toThrow(/JWT_SECRET_V1 is set but empty/);
|
|
261
|
+
});
|
|
212
262
|
});
|
|
213
263
|
|
|
214
264
|
describe("createJwtHelper — ttlSeconds", () => {
|
|
@@ -63,4 +63,76 @@ describe("createRedisLoginRateLimiter", () => {
|
|
|
63
63
|
|
|
64
64
|
expect(await limiter.check("k")).toBe(true);
|
|
65
65
|
});
|
|
66
|
+
|
|
67
|
+
// Regression for the INCR-then-PEXPIRE race: two separate round-trips left
|
|
68
|
+
// a window for a crash/network blip right after the key is created
|
|
69
|
+
// (count===1) to skip the PEXPIRE call entirely, leaving the key
|
|
70
|
+
// permanently without a TTL. The single-eval fix closes that window by
|
|
71
|
+
// construction (one round-trip, can't be interrupted mid-way). "TTL
|
|
72
|
+
// exists after check()" alone doesn't distinguish old from new — the old
|
|
73
|
+
// two-call code also leaves a TTL set on the non-crashing path — so this
|
|
74
|
+
// pins the actual mechanism instead: exactly one `eval` round-trip, and
|
|
75
|
+
// `incr`/`pexpire` are never called directly (the only path is atomic).
|
|
76
|
+
test("check() goes through a single atomic eval — never incr/pexpire directly", async () => {
|
|
77
|
+
const limiter = createRedisLoginRateLimiter(testRedis.redis, 3, 60_000);
|
|
78
|
+
const key = "1.2.3.4|ttl-race@test.local";
|
|
79
|
+
|
|
80
|
+
// Manual call-counting instrumentation, not a test-library mock: each
|
|
81
|
+
// wrapper delegates to the real bound method on the real Redis client
|
|
82
|
+
// (setupTestStack), so behavior is unchanged — this only counts which
|
|
83
|
+
// wire commands actually went out, without the bun:test spy helper the
|
|
84
|
+
// integration-mock-guard forbids in *.integration.test.ts files.
|
|
85
|
+
const origEval = testRedis.redis.eval.bind(testRedis.redis);
|
|
86
|
+
const origIncr = testRedis.redis.incr.bind(testRedis.redis);
|
|
87
|
+
const origPexpire = testRedis.redis.pexpire.bind(testRedis.redis);
|
|
88
|
+
let evalCalls = 0;
|
|
89
|
+
let incrCalls = 0;
|
|
90
|
+
let pexpireCalls = 0;
|
|
91
|
+
testRedis.redis.eval = ((...args: Parameters<typeof origEval>) => {
|
|
92
|
+
evalCalls++;
|
|
93
|
+
return origEval(...args);
|
|
94
|
+
}) as typeof origEval;
|
|
95
|
+
testRedis.redis.incr = ((...args: Parameters<typeof origIncr>) => {
|
|
96
|
+
incrCalls++;
|
|
97
|
+
return origIncr(...args);
|
|
98
|
+
}) as typeof origIncr;
|
|
99
|
+
testRedis.redis.pexpire = ((...args: Parameters<typeof origPexpire>) => {
|
|
100
|
+
pexpireCalls++;
|
|
101
|
+
return origPexpire(...args);
|
|
102
|
+
}) as typeof origPexpire;
|
|
103
|
+
try {
|
|
104
|
+
expect(await limiter.check(key)).toBe(true);
|
|
105
|
+
|
|
106
|
+
expect(evalCalls).toBe(1);
|
|
107
|
+
expect(incrCalls).toBe(0);
|
|
108
|
+
expect(pexpireCalls).toBe(0);
|
|
109
|
+
|
|
110
|
+
const ttlMs = await testRedis.redis.pttl(`kumiko:auth:ratelimit:login:${key}`);
|
|
111
|
+
expect(ttlMs).toBeGreaterThan(0);
|
|
112
|
+
} finally {
|
|
113
|
+
testRedis.redis.eval = origEval;
|
|
114
|
+
testRedis.redis.incr = origIncr;
|
|
115
|
+
testRedis.redis.pexpire = origPexpire;
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// kumiko-framework#1522 idx=5: a key that already exists WITHOUT a TTL
|
|
120
|
+
// (exactly what the pre-fix INCR-then-PEXPIRE race could leave behind)
|
|
121
|
+
// must get one applied on the very next check(), not stay permanently
|
|
122
|
+
// uncapped. Simulates the orphan directly via INCR (no TTL) rather than
|
|
123
|
+
// trying to hit the original race window, which isn't reproducible from
|
|
124
|
+
// outside the eval script.
|
|
125
|
+
test("heals a pre-existing key that has no TTL (orphaned by the old two-call race)", async () => {
|
|
126
|
+
const limiter = createRedisLoginRateLimiter(testRedis.redis, 5, 60_000, "heal-test");
|
|
127
|
+
const key = "1.2.3.4|orphan@test.local";
|
|
128
|
+
const redisKey = `kumiko:auth:ratelimit:heal-test:${key}`;
|
|
129
|
+
|
|
130
|
+
await testRedis.redis.incr(redisKey);
|
|
131
|
+
expect(await testRedis.redis.pttl(redisKey)).toBe(-1);
|
|
132
|
+
|
|
133
|
+
await limiter.check(key);
|
|
134
|
+
|
|
135
|
+
const ttlMs = await testRedis.redis.pttl(redisKey);
|
|
136
|
+
expect(ttlMs).toBeGreaterThan(0);
|
|
137
|
+
});
|
|
66
138
|
});
|
|
@@ -55,4 +55,61 @@ describe("SSE broker", () => {
|
|
|
55
55
|
expect(() => broker.removeClient("unknown", "fake-id")).not.toThrow();
|
|
56
56
|
expect(broker.getClientCount("unknown")).toBe(0);
|
|
57
57
|
});
|
|
58
|
+
|
|
59
|
+
test("subscribeAccessInvalidation fires only listeners on the same user's channel", () => {
|
|
60
|
+
const broker = createSseBroker();
|
|
61
|
+
const onInvalidateA = mock();
|
|
62
|
+
const onInvalidateB = mock();
|
|
63
|
+
|
|
64
|
+
broker.subscribeAccessInvalidation("user-a", onInvalidateA);
|
|
65
|
+
broker.subscribeAccessInvalidation("user-b", onInvalidateB);
|
|
66
|
+
|
|
67
|
+
broker.publishAccessInvalidation("user-a");
|
|
68
|
+
|
|
69
|
+
expect(onInvalidateA).toHaveBeenCalledTimes(1);
|
|
70
|
+
expect(onInvalidateB).not.toHaveBeenCalled();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("subscribeAccessInvalidation supports multiple listeners on the same user", () => {
|
|
74
|
+
const broker = createSseBroker();
|
|
75
|
+
const first = mock();
|
|
76
|
+
const second = mock();
|
|
77
|
+
|
|
78
|
+
broker.subscribeAccessInvalidation("user-a", first);
|
|
79
|
+
broker.subscribeAccessInvalidation("user-a", second);
|
|
80
|
+
broker.publishAccessInvalidation("user-a");
|
|
81
|
+
|
|
82
|
+
expect(first).toHaveBeenCalledTimes(1);
|
|
83
|
+
expect(second).toHaveBeenCalledTimes(1);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("unsubscribe (returned closure) stops further delivery", () => {
|
|
87
|
+
const broker = createSseBroker();
|
|
88
|
+
const onInvalidate = mock();
|
|
89
|
+
|
|
90
|
+
const unsubscribe = broker.subscribeAccessInvalidation("user-a", onInvalidate);
|
|
91
|
+
unsubscribe();
|
|
92
|
+
broker.publishAccessInvalidation("user-a");
|
|
93
|
+
|
|
94
|
+
expect(onInvalidate).not.toHaveBeenCalled();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("a fired listener can unsubscribe itself without skipping other listeners", () => {
|
|
98
|
+
const broker = createSseBroker();
|
|
99
|
+
let unsubscribeSelf: () => void = () => {};
|
|
100
|
+
const self = mock(() => unsubscribeSelf());
|
|
101
|
+
const other = mock();
|
|
102
|
+
|
|
103
|
+
unsubscribeSelf = broker.subscribeAccessInvalidation("user-a", self);
|
|
104
|
+
broker.subscribeAccessInvalidation("user-a", other);
|
|
105
|
+
broker.publishAccessInvalidation("user-a");
|
|
106
|
+
|
|
107
|
+
expect(self).toHaveBeenCalledTimes(1);
|
|
108
|
+
expect(other).toHaveBeenCalledTimes(1);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("publishAccessInvalidation to a user with no listeners does nothing", () => {
|
|
112
|
+
const broker = createSseBroker();
|
|
113
|
+
expect(() => broker.publishAccessInvalidation("nobody-listening")).not.toThrow();
|
|
114
|
+
});
|
|
58
115
|
});
|
|
@@ -27,6 +27,10 @@ function createTrackingBroker(): { broker: SseBroker; subscribedChannel: Promise
|
|
|
27
27
|
getTotalClientCount() {
|
|
28
28
|
return 0;
|
|
29
29
|
},
|
|
30
|
+
subscribeAccessInvalidation() {
|
|
31
|
+
return () => {};
|
|
32
|
+
},
|
|
33
|
+
publishAccessInvalidation() {},
|
|
30
34
|
};
|
|
31
35
|
|
|
32
36
|
return { broker, subscribedChannel };
|