@cosmicdrift/kumiko-bundled-features 0.24.1 → 0.26.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 +1 -1
- package/src/__tests__/env-schemas.test.ts +53 -11
- package/src/auth-email-password/__tests__/email-verification.integration.test.ts +75 -11
- package/src/auth-email-password/__tests__/password-reset.integration.test.ts +86 -16
- package/src/auth-email-password/handlers/confirm-token-flow.ts +12 -8
- package/src/custom-fields/__tests__/audit-integration.integration.test.ts +28 -1
- package/src/custom-fields/__tests__/custom-fields.integration.test.ts +139 -0
- package/src/custom-fields/__tests__/drift.test.ts +43 -0
- package/src/custom-fields/__tests__/field-definition-row.test.ts +62 -0
- package/src/custom-fields/__tests__/retention.integration.test.ts +76 -0
- package/src/custom-fields/__tests__/user-data-rights.integration.test.ts +196 -75
- package/src/custom-fields/__tests__/wire-for-entity.test.ts +29 -0
- package/src/custom-fields/constants.ts +8 -7
- package/src/custom-fields/db/queries/field-access.ts +1 -1
- package/src/custom-fields/db/queries/projection.ts +13 -5
- package/src/custom-fields/db/queries/quota.ts +1 -1
- package/src/custom-fields/db/queries/retention.ts +20 -6
- package/src/custom-fields/executor.ts +10 -0
- package/src/custom-fields/feature.ts +32 -39
- package/src/custom-fields/handlers/clear-custom-field.write.ts +5 -1
- package/src/custom-fields/handlers/define-system-field.write.ts +5 -22
- package/src/custom-fields/handlers/define-tenant-field.write.ts +13 -23
- package/src/custom-fields/handlers/delete-system-field.write.ts +3 -9
- package/src/custom-fields/handlers/delete-tenant-field.write.ts +3 -9
- package/src/custom-fields/lib/field-access.ts +4 -0
- package/src/custom-fields/lib/field-definition-row.ts +33 -0
- package/src/custom-fields/run-retention.ts +6 -5
- package/src/custom-fields/web/__tests__/custom-fields-form-section.test.tsx +45 -10
- package/src/custom-fields/web/client-plugin.tsx +2 -0
- package/src/custom-fields/web/custom-fields-form-section.tsx +19 -9
- package/src/custom-fields/web/i18n.ts +30 -0
- package/src/custom-fields/wire-for-entity.ts +1 -1
- package/src/custom-fields/wire-user-data-rights.ts +9 -0
- package/src/feature-toggles/handlers/set.write.ts +13 -8
- package/src/file-provider-inmemory/__tests__/feature.test.ts +55 -0
- package/src/file-provider-s3/__tests__/feature.test.ts +27 -0
- package/src/files-provider-s3/__tests__/s3-provider.integration.test.ts +54 -12
- package/src/foundation-shared/__tests__/config-helpers.test.ts +72 -0
- package/src/foundation-shared/config-helpers.ts +7 -3
- package/src/secrets/feature.ts +4 -11
- package/src/subscription-stripe/feature.ts +2 -2
- package/src/template-resolver/handlers/list.query.ts +12 -10
- package/src/tenant/__tests__/seed-testing.integration.test.ts +26 -0
- package/src/tenant/seeding.ts +3 -3
- package/src/tier-engine/__tests__/tier-engine.integration.test.ts +55 -0
- package/src/tier-engine/feature.ts +8 -2
- package/src/user-data-rights/__tests__/file-binary-forget-cleanup.integration.test.ts +26 -74
- package/src/user-data-rights/__tests__/file-binary-forget-failure.integration.test.ts +211 -0
- package/src/user-data-rights/__tests__/forget-cleanup-hook-ordering.integration.test.ts +272 -0
- package/src/user-data-rights/__tests__/forget-test-helpers.ts +86 -0
- package/src/user-data-rights/db/queries/export-jobs.ts +1 -0
- package/src/user-data-rights/run-forget-cleanup.ts +77 -36
- package/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +21 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.26.0",
|
|
4
4
|
"description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -1,10 +1,6 @@
|
|
|
1
1
|
import { describe, expect, it } from "bun:test";
|
|
2
2
|
import { randomBytes } from "node:crypto";
|
|
3
|
-
import {
|
|
4
|
-
composeEnvSchema,
|
|
5
|
-
type KumikoBootError,
|
|
6
|
-
parseEnv,
|
|
7
|
-
} from "@cosmicdrift/kumiko-framework/env";
|
|
3
|
+
import { composeEnvSchema, KumikoBootError, parseEnv } from "@cosmicdrift/kumiko-framework/env";
|
|
8
4
|
import { authEmailPasswordEnvSchema, createAuthEmailPasswordFeature } from "../auth-email-password";
|
|
9
5
|
import { createSecretsFeature, secretsEnvSchema } from "../secrets";
|
|
10
6
|
import {
|
|
@@ -18,6 +14,13 @@ import {
|
|
|
18
14
|
|
|
19
15
|
const validKek = randomBytes(32).toString("base64");
|
|
20
16
|
|
|
17
|
+
function asBootError(err: unknown): KumikoBootError {
|
|
18
|
+
if (!(err instanceof KumikoBootError)) {
|
|
19
|
+
throw err instanceof Error ? err : new Error(String(err));
|
|
20
|
+
}
|
|
21
|
+
return err;
|
|
22
|
+
}
|
|
23
|
+
|
|
21
24
|
describe("secretsEnvSchema", () => {
|
|
22
25
|
it("accepts a base64-32 KEK and defaults CURRENT_VERSION to '1'", () => {
|
|
23
26
|
const env = parseEnv(secretsEnvSchema, {
|
|
@@ -32,7 +35,7 @@ describe("secretsEnvSchema", () => {
|
|
|
32
35
|
parseEnv(secretsEnvSchema, { KUMIKO_SECRETS_MASTER_KEY_V1: "dGVzdA==" });
|
|
33
36
|
throw new Error("should have thrown");
|
|
34
37
|
} catch (err) {
|
|
35
|
-
const boot = err
|
|
38
|
+
const boot = asBootError(err);
|
|
36
39
|
const v1 = boot.errors.find((e) => e.name === "KUMIKO_SECRETS_MASTER_KEY_V1");
|
|
37
40
|
expect(v1?.kind).toBe("invalid");
|
|
38
41
|
expect(v1?.message).toContain("32 bytes");
|
|
@@ -47,13 +50,36 @@ describe("secretsEnvSchema", () => {
|
|
|
47
50
|
});
|
|
48
51
|
throw new Error("should have thrown");
|
|
49
52
|
} catch (err) {
|
|
50
|
-
const cur = (err
|
|
53
|
+
const cur = asBootError(err).errors.find(
|
|
54
|
+
(e) => e.name === "KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION",
|
|
55
|
+
);
|
|
56
|
+
expect(cur?.kind).toBe("invalid");
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("rejects CURRENT_VERSION '0' (V0 never exists, selector starts at V1)", () => {
|
|
61
|
+
try {
|
|
62
|
+
parseEnv(secretsEnvSchema, {
|
|
63
|
+
KUMIKO_SECRETS_MASTER_KEY_V1: validKek,
|
|
64
|
+
KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION: "0",
|
|
65
|
+
});
|
|
66
|
+
throw new Error("should have thrown");
|
|
67
|
+
} catch (err) {
|
|
68
|
+
const cur = asBootError(err).errors.find(
|
|
51
69
|
(e) => e.name === "KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION",
|
|
52
70
|
);
|
|
53
71
|
expect(cur?.kind).toBe("invalid");
|
|
54
72
|
}
|
|
55
73
|
});
|
|
56
74
|
|
|
75
|
+
it("accepts CURRENT_VERSION '2' (positive version selector)", () => {
|
|
76
|
+
const env = parseEnv(secretsEnvSchema, {
|
|
77
|
+
KUMIKO_SECRETS_MASTER_KEY_V1: validKek,
|
|
78
|
+
KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION: "2",
|
|
79
|
+
});
|
|
80
|
+
expect(env.KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION).toBe("2");
|
|
81
|
+
});
|
|
82
|
+
|
|
57
83
|
it("attaches the schema via r.envSchema() on createSecretsFeature()", () => {
|
|
58
84
|
const f = createSecretsFeature();
|
|
59
85
|
expect(f.envSchema).toBe(secretsEnvSchema);
|
|
@@ -74,7 +100,7 @@ describe("authEmailPasswordEnvSchema", () => {
|
|
|
74
100
|
parseEnv(authEmailPasswordEnvSchema, { JWT_SECRET: "short" });
|
|
75
101
|
throw new Error("should have thrown");
|
|
76
102
|
} catch (err) {
|
|
77
|
-
const jwt = (err
|
|
103
|
+
const jwt = asBootError(err).errors.find((e) => e.name === "JWT_SECRET");
|
|
78
104
|
expect(jwt?.kind).toBe("invalid");
|
|
79
105
|
}
|
|
80
106
|
});
|
|
@@ -103,11 +129,27 @@ describe("subscriptionStripeEnvSchema", () => {
|
|
|
103
129
|
});
|
|
104
130
|
throw new Error("should have thrown");
|
|
105
131
|
} catch (err) {
|
|
106
|
-
const boot = err
|
|
132
|
+
const boot = asBootError(err);
|
|
107
133
|
expect(boot.errors.length).toBe(2);
|
|
108
134
|
}
|
|
109
135
|
});
|
|
110
136
|
|
|
137
|
+
it("rejects a publishable key and a non-whsec webhook secret", () => {
|
|
138
|
+
try {
|
|
139
|
+
parseEnv(subscriptionStripeEnvSchema, {
|
|
140
|
+
STRIPE_WEBHOOK_SECRET: "wrong_abc",
|
|
141
|
+
STRIPE_API_KEY: "pk_live_xyz",
|
|
142
|
+
});
|
|
143
|
+
throw new Error("should have thrown");
|
|
144
|
+
} catch (err) {
|
|
145
|
+
const boot = asBootError(err);
|
|
146
|
+
const api = boot.errors.find((e) => e.name === "STRIPE_API_KEY");
|
|
147
|
+
const hook = boot.errors.find((e) => e.name === "STRIPE_WEBHOOK_SECRET");
|
|
148
|
+
expect(api?.kind).toBe("invalid");
|
|
149
|
+
expect(hook?.kind).toBe("invalid");
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
|
|
111
153
|
it("attaches the schema via r.envSchema() on the factory", () => {
|
|
112
154
|
const f = createSubscriptionStripeFeature({
|
|
113
155
|
webhookSecret: "whsec_x",
|
|
@@ -129,7 +171,7 @@ describe("subscriptionMollieEnvSchema", () => {
|
|
|
129
171
|
parseEnv(subscriptionMollieEnvSchema, { MOLLIE_API_KEY: "no-prefix" });
|
|
130
172
|
throw new Error("should have thrown");
|
|
131
173
|
} catch (err) {
|
|
132
|
-
const k = (err
|
|
174
|
+
const k = asBootError(err).errors.find((e) => e.name === "MOLLIE_API_KEY");
|
|
133
175
|
expect(k?.kind).toBe("invalid");
|
|
134
176
|
}
|
|
135
177
|
});
|
|
@@ -200,7 +242,7 @@ describe("compose across all Phase-2 features", () => {
|
|
|
200
242
|
parseEnv(composed.schema, {}, { sources: composed.sources });
|
|
201
243
|
throw new Error("should have thrown");
|
|
202
244
|
} catch (err) {
|
|
203
|
-
const out = (err
|
|
245
|
+
const out = asBootError(err).format();
|
|
204
246
|
expect(out).toContain("✗ JWT_SECRET (auth-email-password, required, missing)");
|
|
205
247
|
expect(out).toContain("✗ KUMIKO_SECRETS_MASTER_KEY_V1 (secrets, required, missing)");
|
|
206
248
|
expect(out).toContain("✗ STRIPE_API_KEY (subscription-stripe, required, missing)");
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
2
2
|
import { randomBytes } from "node:crypto";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
asRawClient,
|
|
5
|
+
insertOne,
|
|
6
|
+
selectMany,
|
|
7
|
+
updateMany,
|
|
8
|
+
} from "@cosmicdrift/kumiko-framework/bun-db";
|
|
4
9
|
import { createEncryptionProvider } from "@cosmicdrift/kumiko-framework/db";
|
|
5
10
|
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
6
11
|
import {
|
|
@@ -204,27 +209,86 @@ describe("POST /auth/verify-email", () => {
|
|
|
204
209
|
});
|
|
205
210
|
|
|
206
211
|
test("verify that fails before the write is retryable (burn released on failure)", async () => {
|
|
207
|
-
// Symmetric to the reset-password retry test: if the confirm-flow
|
|
208
|
-
//
|
|
209
|
-
// the
|
|
210
|
-
//
|
|
212
|
+
// Symmetric to the reset-password retry test: if the confirm-flow fails
|
|
213
|
+
// AFTER burning, the finally-block in runConfirmTokenFlow releases the
|
|
214
|
+
// burn so the user can click the same link again once ops restores state.
|
|
215
|
+
//
|
|
216
|
+
// Trigger: delete the user READ-MODEL row (kumiko_events untouched) →
|
|
217
|
+
// loadValidatedUser returns null after the burn → invalidToken + unburn.
|
|
218
|
+
// Re-insert the same row verbatim → retry with the SAME token succeeds.
|
|
219
|
+
//
|
|
220
|
+
// (Membership-deletion no longer fails: the stream lives in
|
|
221
|
+
// systemAdmin.tenantId and is recovered with zero memberships — see the
|
|
222
|
+
// zero-membership-sysadmin test below.)
|
|
211
223
|
const seed = await seedUser({ email: "retry@example.com", password: "pw-retry-1234" });
|
|
212
224
|
const { token } = signVerificationToken(seed.id, 60, verifySecret);
|
|
213
225
|
|
|
214
|
-
await
|
|
226
|
+
const userRow = (await selectMany(stack.db, userTable)).find((r) => r["id"] === seed.id);
|
|
227
|
+
if (!userRow) throw new Error("seeded user row missing");
|
|
228
|
+
await asRawClient(stack.db).unsafe(`DELETE FROM "${userTable.tableName}" WHERE id = $1`, [
|
|
229
|
+
seed.id,
|
|
230
|
+
]);
|
|
231
|
+
|
|
215
232
|
const firstAttempt = await post("/api/auth/verify-email", { token });
|
|
216
233
|
expect(firstAttempt.status).toBe(422);
|
|
217
234
|
|
|
218
|
-
await
|
|
219
|
-
userId: seed.id,
|
|
220
|
-
tenantId: seed.tenantId,
|
|
221
|
-
roles: ["User"],
|
|
222
|
-
});
|
|
235
|
+
await insertOne(stack.db, userTable, userRow);
|
|
223
236
|
|
|
224
237
|
const secondAttempt = await post("/api/auth/verify-email", { token });
|
|
225
238
|
expect(secondAttempt.status).toBe(200);
|
|
226
239
|
});
|
|
227
240
|
|
|
241
|
+
test("zero-membership sysadmin can still verify (stream recovered without any membership)", async () => {
|
|
242
|
+
// 205#1: a systemScope user whose stream lives in systemAdmin.tenantId
|
|
243
|
+
// (…0001) but who holds NO membership must still resolve. The stream-
|
|
244
|
+
// tenant recovery runs BEFORE the empty-membership check, so verify
|
|
245
|
+
// targets …0001 and lands instead of collapsing to invalid_token.
|
|
246
|
+
const seed = await seedUser({ email: "lonely-admin@example.com", password: "pw-lonely-1234" });
|
|
247
|
+
await asRawClient(stack.db).unsafe(`DELETE FROM "${tenantMembershipsTable.tableName}"`);
|
|
248
|
+
|
|
249
|
+
const streamRows = (await asRawClient(stack.db).unsafe(
|
|
250
|
+
`SELECT "tenant_id" FROM "kumiko_events" WHERE "aggregate_id" = $1 AND "aggregate_type" = 'user' ORDER BY "version" LIMIT 1`,
|
|
251
|
+
[seed.id],
|
|
252
|
+
)) as ReadonlyArray<{ tenant_id: string }>;
|
|
253
|
+
expect(streamRows[0]?.tenant_id).toBe(systemAdmin.tenantId);
|
|
254
|
+
|
|
255
|
+
const { token } = signVerificationToken(seed.id, 60, verifySecret);
|
|
256
|
+
const res = await post("/api/auth/verify-email", { token });
|
|
257
|
+
expect(res.status).toBe(200);
|
|
258
|
+
|
|
259
|
+
const row = (await selectMany(stack.db, userTable)).find((r) => r["id"] === seed.id);
|
|
260
|
+
expect(row?.["emailVerified"]).toBe(true);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
test("direct-inserted user with no stream + no membership → 422 (recovery stays bounded)", async () => {
|
|
264
|
+
// 205#1 "strikt sicher" boundary: a user inserted straight into the read
|
|
265
|
+
// model (no create-event → no event stream) with no membership has
|
|
266
|
+
// nothing to recover → invalidToken. Proves the fix only gains "empty
|
|
267
|
+
// memberships + recoverable stream", never blanket-opens zero-membership.
|
|
268
|
+
// Build a fully-populated user row with NO event stream: seed a normal
|
|
269
|
+
// user (gets all NOT-NULL columns), capture its row, then re-key it to a
|
|
270
|
+
// fresh id + email. getAggregateStreamTenant(orphanId) finds no events
|
|
271
|
+
// (the stream lives under the original id), and no membership is seeded
|
|
272
|
+
// → tenantOrder is empty.
|
|
273
|
+
const donor = await seedUser({ email: "donor@example.com", password: "donor-pw-1234" });
|
|
274
|
+
const donorRow = (await selectMany(stack.db, userTable)).find((r) => r["id"] === donor.id);
|
|
275
|
+
if (!donorRow) throw new Error("donor row missing");
|
|
276
|
+
await asRawClient(stack.db).unsafe(`DELETE FROM "${tenantMembershipsTable.tableName}"`);
|
|
277
|
+
|
|
278
|
+
const orphanId = "00000000-0000-4000-8000-0000000000ff";
|
|
279
|
+
await insertOne(stack.db, userTable, {
|
|
280
|
+
...donorRow,
|
|
281
|
+
id: orphanId,
|
|
282
|
+
email: "orphan@example.com",
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
const { token } = signVerificationToken(orphanId, 60, verifySecret);
|
|
286
|
+
const res = await post("/api/auth/verify-email", { token });
|
|
287
|
+
expect(res.status).toBe(422);
|
|
288
|
+
const body = await res.json();
|
|
289
|
+
expect(body.error?.details?.reason).toBe(AuthErrors.invalidVerificationToken);
|
|
290
|
+
});
|
|
291
|
+
|
|
228
292
|
test("cross-purpose burn isolation: consuming a reset-token doesn't block a verify-token for the same user+expiry", async () => {
|
|
229
293
|
// The burn-store key includes purpose ("reset" vs "verify"). Tokens
|
|
230
294
|
// signed with the SAME userId + expiresAtMs but different purpose
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
2
2
|
import { randomBytes } from "node:crypto";
|
|
3
|
-
import { asRawClient, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
3
|
+
import { asRawClient, insertOne, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
4
4
|
import { createEncryptionProvider } from "@cosmicdrift/kumiko-framework/db";
|
|
5
5
|
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
6
6
|
import {
|
|
@@ -251,31 +251,39 @@ describe("POST /auth/reset-password", () => {
|
|
|
251
251
|
|
|
252
252
|
test("reset that fails before the write is retryable (burn is released on failure)", async () => {
|
|
253
253
|
// The burn marker goes down BEFORE the state change so a racing replay
|
|
254
|
-
// can't slip through. But if the state change itself fails —
|
|
255
|
-
//
|
|
256
|
-
//
|
|
257
|
-
//
|
|
258
|
-
// without hitting a stuck "already-used".
|
|
254
|
+
// can't slip through. But if the state change itself fails — DB error,
|
|
255
|
+
// user-row vanished, every tenant stream rejected — the token was never
|
|
256
|
+
// actually consumed. The handler releases the burn in those branches so
|
|
257
|
+
// the user can click the link again once ops restores state.
|
|
259
258
|
//
|
|
260
|
-
// Repro:
|
|
261
|
-
//
|
|
262
|
-
//
|
|
259
|
+
// Repro: delete the user READ-MODEL row (kumiko_events untouched) →
|
|
260
|
+
// loadValidatedUser returns null AFTER the burn → invalidToken + unburn.
|
|
261
|
+
// Re-insert the same row verbatim (restores version → optimistic write
|
|
262
|
+
// still matches the untouched event stream) → second attempt with the
|
|
263
|
+
// SAME token succeeds, proving the burn was released.
|
|
264
|
+
//
|
|
265
|
+
// (Deleting the membership no longer works as a failure trigger: the
|
|
266
|
+
// user aggregate stream lives in systemAdmin.tenantId and is recovered
|
|
267
|
+
// by resolveStreamTenants even with zero memberships — see the
|
|
268
|
+
// zero-membership-sysadmin test below.)
|
|
263
269
|
const seed = await seedUser({ email: "retry@example.com", password: "pw-retry-1234" });
|
|
264
270
|
const { token } = signResetToken(seed.id, 15, resetSecret);
|
|
265
271
|
|
|
266
|
-
await
|
|
272
|
+
const userRow = (await selectMany(stack.db, userTable)).find((r) => r["id"] === seed.id);
|
|
273
|
+
if (!userRow) throw new Error("seeded user row missing");
|
|
274
|
+
await asRawClient(stack.db).unsafe(`DELETE FROM "${userTable.tableName}" WHERE id = $1`, [
|
|
275
|
+
seed.id,
|
|
276
|
+
]);
|
|
277
|
+
|
|
267
278
|
const firstAttempt = await post("/api/auth/reset-password", {
|
|
268
279
|
token,
|
|
269
280
|
newPassword: "never-lands-1234",
|
|
270
281
|
});
|
|
271
282
|
expect(firstAttempt.status).toBe(422);
|
|
272
283
|
|
|
273
|
-
// Re-insert the
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
tenantId: seed.tenantId,
|
|
277
|
-
roles: ["User"],
|
|
278
|
-
});
|
|
284
|
+
// Re-insert the captured row verbatim. Same userId, same version, same
|
|
285
|
+
// token still valid.
|
|
286
|
+
await insertOne(stack.db, userTable, userRow);
|
|
279
287
|
|
|
280
288
|
const secondAttempt = await post("/api/auth/reset-password", {
|
|
281
289
|
token,
|
|
@@ -284,6 +292,68 @@ describe("POST /auth/reset-password", () => {
|
|
|
284
292
|
expect(secondAttempt.status).toBe(200);
|
|
285
293
|
});
|
|
286
294
|
|
|
295
|
+
test("zero-membership sysadmin can still reset (stream recovered without any membership)", async () => {
|
|
296
|
+
// 205#1: a systemScope user whose stream lives in systemAdmin.tenantId
|
|
297
|
+
// (…0001) but who holds NO membership must still resolve. The stream-
|
|
298
|
+
// tenant recovery in resolveStreamTenants runs BEFORE the empty-
|
|
299
|
+
// membership check, so the reset targets …0001 and lands — instead of
|
|
300
|
+
// collapsing to invalid_token. Mirrors change-password's unconditional
|
|
301
|
+
// recovery.
|
|
302
|
+
const seed = await seedUser({ email: "lonely-admin@example.com", password: "pw-old-lonely!" });
|
|
303
|
+
await asRawClient(stack.db).unsafe(`DELETE FROM "${tenantMembershipsTable.tableName}"`);
|
|
304
|
+
|
|
305
|
+
// Confirm the stream tenant the recovery must find: the user aggregate
|
|
306
|
+
// was created via systemAdmin, so its stream lives in …0001.
|
|
307
|
+
const streamRows = (await asRawClient(stack.db).unsafe(
|
|
308
|
+
`SELECT "tenant_id" FROM "kumiko_events" WHERE "aggregate_id" = $1 AND "aggregate_type" = 'user' ORDER BY "version" LIMIT 1`,
|
|
309
|
+
[seed.id],
|
|
310
|
+
)) as ReadonlyArray<{ tenant_id: string }>;
|
|
311
|
+
expect(streamRows[0]?.tenant_id).toBe(systemAdmin.tenantId);
|
|
312
|
+
|
|
313
|
+
const { token } = signResetToken(seed.id, 15, resetSecret);
|
|
314
|
+
const res = await post("/api/auth/reset-password", {
|
|
315
|
+
token,
|
|
316
|
+
newPassword: "pw-new-lonely-1234",
|
|
317
|
+
});
|
|
318
|
+
expect(res.status).toBe(200);
|
|
319
|
+
|
|
320
|
+
const row = (await selectMany(stack.db, userTable)).find((r) => r["id"] === seed.id);
|
|
321
|
+
expect(await verifyPassword(row?.["passwordHash"] as string, "pw-new-lonely-1234")).toBe(true);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
test("direct-inserted user with no stream + no membership → 422 (recovery stays bounded)", async () => {
|
|
325
|
+
// 205#1 "strikt sicher" boundary: a user inserted straight into the read
|
|
326
|
+
// model (no create-event → no event stream) with no membership has
|
|
327
|
+
// nothing to recover. resolveStreamTenants returns [] → invalidToken.
|
|
328
|
+
// Proves the fix only gains "empty memberships + recoverable stream",
|
|
329
|
+
// never blanket-opens zero-membership.
|
|
330
|
+
// Build a fully-populated user row with NO event stream: seed a normal
|
|
331
|
+
// user (gets all NOT-NULL columns), capture its row, then re-key it to a
|
|
332
|
+
// fresh id + email. getAggregateStreamTenant(orphanId) finds no events
|
|
333
|
+
// (the stream lives under the original id), and no membership is seeded
|
|
334
|
+
// → tenantOrder is empty.
|
|
335
|
+
const donor = await seedUser({ email: "donor@example.com", password: "donor-pw-1234" });
|
|
336
|
+
const donorRow = (await selectMany(stack.db, userTable)).find((r) => r["id"] === donor.id);
|
|
337
|
+
if (!donorRow) throw new Error("donor row missing");
|
|
338
|
+
await asRawClient(stack.db).unsafe(`DELETE FROM "${tenantMembershipsTable.tableName}"`);
|
|
339
|
+
|
|
340
|
+
const orphanId = "00000000-0000-4000-8000-0000000000ff";
|
|
341
|
+
await insertOne(stack.db, userTable, {
|
|
342
|
+
...donorRow,
|
|
343
|
+
id: orphanId,
|
|
344
|
+
email: "orphan@example.com",
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
const { token } = signResetToken(orphanId, 15, resetSecret);
|
|
348
|
+
const res = await post("/api/auth/reset-password", {
|
|
349
|
+
token,
|
|
350
|
+
newPassword: "should-not-land-1234",
|
|
351
|
+
});
|
|
352
|
+
expect(res.status).toBe(422);
|
|
353
|
+
const body = await res.json();
|
|
354
|
+
expect(body.error?.details?.reason).toBe(AuthErrors.invalidResetToken);
|
|
355
|
+
});
|
|
356
|
+
|
|
287
357
|
test("replayed reset-token → 422 invalid_reset_token (single-use burn)", async () => {
|
|
288
358
|
// Reset tokens are single-use: the handler burns them in Redis via
|
|
289
359
|
// SETNX before the state change. First click wins; replay within TTL
|
|
@@ -141,11 +141,14 @@ async function loadValidatedUser(
|
|
|
141
141
|
return { ...me, version: me.version };
|
|
142
142
|
}
|
|
143
143
|
|
|
144
|
-
// Loads the user's memberships and returns a prioritised tenant list
|
|
145
|
-
//
|
|
146
|
-
//
|
|
147
|
-
//
|
|
148
|
-
//
|
|
144
|
+
// Loads the user's memberships and returns a prioritised tenant list, with the
|
|
145
|
+
// aggregate's real stream tenant recovered from the event log prepended.
|
|
146
|
+
//
|
|
147
|
+
// Empty only when the user has NO memberships AND no recoverable stream tenant
|
|
148
|
+
// — the caller treats that as invalid_token. A zero-membership systemScope user
|
|
149
|
+
// whose stream lives outside any membership (a platform operator seeded under a
|
|
150
|
+
// fixture/SYSTEM tenant) still resolves, because the stream-tenant lookup runs
|
|
151
|
+
// before the empty check rather than being short-circuited by it.
|
|
149
152
|
async function resolveStreamTenants(
|
|
150
153
|
ctx: HandlerContext,
|
|
151
154
|
systemUser: SessionUser,
|
|
@@ -155,15 +158,16 @@ async function resolveStreamTenants(
|
|
|
155
158
|
userId: me.id,
|
|
156
159
|
})) as Array<{ tenantId: TenantId }>; // @cast-boundary db-runner
|
|
157
160
|
const ordered = orderTenantsByPreference(memberships, me.lastActiveTenantId);
|
|
158
|
-
if (ordered.length === 0) return [];
|
|
159
161
|
|
|
160
162
|
// The user aggregate is r.systemScope(): its event stream lives in whichever
|
|
161
163
|
// tenant the creating executor used, which need NOT be a membership tenant.
|
|
162
164
|
// A platform operator seeded under a fixture/platform tenant is the live case
|
|
163
165
|
// — its stream tenant is absent from `ordered`, so a membership-only search
|
|
164
166
|
// rejects every write and collapses to invalid_token. Recover the real stream
|
|
165
|
-
// tenant from the event log and try it first; memberships stay as fallback
|
|
166
|
-
//
|
|
167
|
+
// tenant from the event log and try it first; memberships stay as fallback.
|
|
168
|
+
// Pulled BEFORE the empty-membership check so a zero-membership operator whose
|
|
169
|
+
// stream lives in SYSTEM_TENANT is recoverable instead of collapsing to
|
|
170
|
+
// invalid_token — mirrors change-password.write.ts's unconditional recovery.
|
|
167
171
|
const streamTenant = await getAggregateStreamTenant(ctx.db.raw, me.id, USER_FEATURE);
|
|
168
172
|
if (streamTenant && !ordered.includes(streamTenant)) {
|
|
169
173
|
return [streamTenant, ...ordered];
|
|
@@ -265,13 +265,40 @@ describe("T1.5a: custom-fields events are visible in the audit log", () => {
|
|
|
265
265
|
adminWithAudit,
|
|
266
266
|
);
|
|
267
267
|
|
|
268
|
+
// Tenant-2 defines its OWN field. Without this, an audit query that
|
|
269
|
+
// returned zero rows for ANY reason (e.g. a broken filter) would still
|
|
270
|
+
// pass the "doesn't see leakyField" assertion — a false-positive that
|
|
271
|
+
// reads "isolated" but actually means "blind". Asserting tenant-2 sees its
|
|
272
|
+
// own event proves the query genuinely returns tenant-2's data.
|
|
273
|
+
const otherTenantDefiner = createTestUser({
|
|
274
|
+
id: 11,
|
|
275
|
+
roles: ["TenantAdmin"],
|
|
276
|
+
tenantId: otherTenantAdmin.tenantId,
|
|
277
|
+
});
|
|
278
|
+
await stack.http.writeOk(
|
|
279
|
+
"custom-fields:write:define-tenant-field",
|
|
280
|
+
{
|
|
281
|
+
entityName: "property",
|
|
282
|
+
fieldKey: "ownField",
|
|
283
|
+
serializedField: { type: "text" },
|
|
284
|
+
required: false,
|
|
285
|
+
searchable: false,
|
|
286
|
+
displayOrder: 0,
|
|
287
|
+
},
|
|
288
|
+
otherTenantDefiner,
|
|
289
|
+
);
|
|
290
|
+
|
|
268
291
|
const res = await stack.http.queryOk<AuditResponse>(
|
|
269
292
|
AuditQueries.list,
|
|
270
293
|
{ aggregateType: "field-definition" },
|
|
271
294
|
otherTenantAdmin,
|
|
272
295
|
);
|
|
273
296
|
|
|
274
|
-
|
|
297
|
+
// Tenant-2 sees its own event ...
|
|
298
|
+
const own = res.rows.find((r) => r.payload["fieldKey"] === "ownField");
|
|
299
|
+
expect(own).toBeDefined();
|
|
300
|
+
// ... but never tenant-1's.
|
|
301
|
+
const leak = res.rows.find((r) => r.payload["fieldKey"] === "leakyField");
|
|
275
302
|
expect(leak).toBeUndefined();
|
|
276
303
|
});
|
|
277
304
|
});
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
createTextField,
|
|
20
20
|
defineEntityListHandler,
|
|
21
21
|
defineFeature,
|
|
22
|
+
SYSTEM_TENANT_ID,
|
|
22
23
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
23
24
|
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
24
25
|
import {
|
|
@@ -102,6 +103,15 @@ beforeEach(async () => {
|
|
|
102
103
|
// (Memory: feedback_role_naming_drift — bundled-features-Convention vs.
|
|
103
104
|
// platform-Convention). Wir bauen einen tenant-admin für die Tests.
|
|
104
105
|
const admin = createTestUser({ roles: ["TenantAdmin"] });
|
|
106
|
+
const systemAdmin = createTestUser({ roles: ["SystemAdmin"] });
|
|
107
|
+
|
|
108
|
+
async function countDefinitions(tenantId: string, fieldKey: string): Promise<number> {
|
|
109
|
+
const rows = await asRawClient(stack.db).unsafe(
|
|
110
|
+
"SELECT count(*)::int AS n FROM read_custom_field_definitions WHERE tenant_id = $1 AND field_key = $2",
|
|
111
|
+
[tenantId, fieldKey],
|
|
112
|
+
);
|
|
113
|
+
return (rows as ReadonlyArray<{ n: number }>)[0]?.n ?? 0;
|
|
114
|
+
}
|
|
105
115
|
|
|
106
116
|
async function defineField(entityName: string, fieldKey: string, type = "text") {
|
|
107
117
|
return stack.http.writeOk(
|
|
@@ -260,6 +270,84 @@ describe("custom-fields integration — Last-Wins on concurrent set", () => {
|
|
|
260
270
|
});
|
|
261
271
|
});
|
|
262
272
|
|
|
273
|
+
describe("custom-fields integration — define/delete handler coverage (B1)", () => {
|
|
274
|
+
// feature.test.ts only covers schema/aggregate-id/registration shape. These
|
|
275
|
+
// drive the handler bodies through the real dispatcher: the deterministic
|
|
276
|
+
// aggregate-id → version_conflict on a duplicate define, the system-tenant
|
|
277
|
+
// guard on define-tenant-field, and the system-scope define→delete roundtrip.
|
|
278
|
+
|
|
279
|
+
test("re-defining the same tenant-field → 409 (deterministic aggregate-id conflict)", async () => {
|
|
280
|
+
await defineField("property", "color", "text");
|
|
281
|
+
const err = await stack.http.writeErr(
|
|
282
|
+
"custom-fields:write:define-tenant-field",
|
|
283
|
+
{
|
|
284
|
+
entityName: "property",
|
|
285
|
+
fieldKey: "color",
|
|
286
|
+
serializedField: { type: "text" },
|
|
287
|
+
required: false,
|
|
288
|
+
searchable: false,
|
|
289
|
+
displayOrder: 0,
|
|
290
|
+
},
|
|
291
|
+
admin,
|
|
292
|
+
);
|
|
293
|
+
expect(err.httpStatus).toBe(409);
|
|
294
|
+
// Only the first define produced a row.
|
|
295
|
+
expect(await countDefinitions(admin.tenantId, "color")).toBe(1);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
test("define-tenant-field rejects a caller whose tenant IS the system tenant", async () => {
|
|
299
|
+
// The strict guard (isSystemTenant) blocks system-scope writes through the
|
|
300
|
+
// tenant handler — system definitions must go via define-system-field.
|
|
301
|
+
const systemScopedAdmin = createTestUser({
|
|
302
|
+
roles: ["TenantAdmin"],
|
|
303
|
+
tenantId: SYSTEM_TENANT_ID,
|
|
304
|
+
});
|
|
305
|
+
const err = await stack.http.writeErr(
|
|
306
|
+
"custom-fields:write:define-tenant-field",
|
|
307
|
+
{
|
|
308
|
+
entityName: "property",
|
|
309
|
+
fieldKey: "leaky",
|
|
310
|
+
serializedField: { type: "text" },
|
|
311
|
+
required: false,
|
|
312
|
+
searchable: false,
|
|
313
|
+
displayOrder: 0,
|
|
314
|
+
},
|
|
315
|
+
systemScopedAdmin,
|
|
316
|
+
);
|
|
317
|
+
// The guard throws a plain Error → 500 internal_error. Pin the guard's own
|
|
318
|
+
// message (surfaced as the InternalError cause in test/dev) so this can't
|
|
319
|
+
// be satisfied by some unrelated 5xx that also happens to write no row.
|
|
320
|
+
expect(err.httpStatus).toBe(500);
|
|
321
|
+
expect(err.code).toBe("internal_error");
|
|
322
|
+
const causeMessage = (err.details as { causeMessage?: string } | undefined)?.causeMessage ?? "";
|
|
323
|
+
expect(causeMessage).toContain("define-system-field");
|
|
324
|
+
expect(await countDefinitions(SYSTEM_TENANT_ID, "leaky")).toBe(0);
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
test("define-system-field → delete-system-field roundtrip (SystemAdmin, system scope)", async () => {
|
|
328
|
+
const defineRes = await stack.http.writeOk(
|
|
329
|
+
"custom-fields:write:define-system-field",
|
|
330
|
+
{
|
|
331
|
+
entityName: "property",
|
|
332
|
+
fieldKey: "vendorTag",
|
|
333
|
+
serializedField: { type: "text" },
|
|
334
|
+
required: false,
|
|
335
|
+
searchable: false,
|
|
336
|
+
displayOrder: 0,
|
|
337
|
+
},
|
|
338
|
+
systemAdmin,
|
|
339
|
+
);
|
|
340
|
+
expect(defineRes).toBeDefined();
|
|
341
|
+
expect(await countDefinitions(SYSTEM_TENANT_ID, "vendorTag")).toBe(1);
|
|
342
|
+
await stack.http.writeOk(
|
|
343
|
+
"custom-fields:write:delete-system-field",
|
|
344
|
+
{ entityName: "property", fieldKey: "vendorTag" },
|
|
345
|
+
systemAdmin,
|
|
346
|
+
);
|
|
347
|
+
expect(await countDefinitions(SYSTEM_TENANT_ID, "vendorTag")).toBe(0);
|
|
348
|
+
});
|
|
349
|
+
});
|
|
350
|
+
|
|
263
351
|
describe("custom-fields integration — value validation (Builder-Reuse)", () => {
|
|
264
352
|
async function setErr(entityId: string, fieldKey: string, value: unknown) {
|
|
265
353
|
return stack.http.writeErr(
|
|
@@ -393,6 +481,57 @@ describe("custom-fields integration — value validation (Builder-Reuse)", () =>
|
|
|
393
481
|
expect(await rawCustomFields(id)).toMatchObject({ score: 7 });
|
|
394
482
|
});
|
|
395
483
|
|
|
484
|
+
async function setMissingValueErr(entityId: string, fieldKey: string) {
|
|
485
|
+
// value omitted entirely — JSON drops undefined, so the payload arrives
|
|
486
|
+
// without `value` and the schema-level refine rejects it.
|
|
487
|
+
return stack.http.writeErr(
|
|
488
|
+
"custom-fields:write:set-custom-field",
|
|
489
|
+
{ entityName: "property", entityId, fieldKey },
|
|
490
|
+
admin,
|
|
491
|
+
);
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
test("missing value → 400 validation_error, no event (set requires a value)", async () => {
|
|
495
|
+
// The payload refine (set-custom-field.write.ts) rejects a missing value
|
|
496
|
+
// before the handler runs — otherwise `undefined` would bind as a jsonb
|
|
497
|
+
// NULL against the NOT-NULL custom_fields column. clear-custom-field is the
|
|
498
|
+
// documented way to remove a value.
|
|
499
|
+
const id = "11111111-2222-4000-8000-00000000000e";
|
|
500
|
+
await defineField("property", "label", "text");
|
|
501
|
+
await createProperty(id, "MissingValue");
|
|
502
|
+
|
|
503
|
+
const err = await setMissingValueErr(id, "label");
|
|
504
|
+
expect(err.httpStatus).toBe(400);
|
|
505
|
+
expect(err.code).toBe("validation_error");
|
|
506
|
+
expect(err.details).toMatchObject({ fields: [{ path: "value" }] });
|
|
507
|
+
expect(await countSetEvents(id)).toBe(0);
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
test("default-having field: a missing value is still rejected (default not silently applied)", async () => {
|
|
511
|
+
// Pre-fix bug: `z.number().default(0).safeParse(undefined)` succeeded with
|
|
512
|
+
// data=0, and the handler emitted `payload.value` (= undefined). The refine
|
|
513
|
+
// now rejects the missing value outright — no event, no defaulted-undefined.
|
|
514
|
+
const id = "22222222-3333-4000-8000-00000000000f";
|
|
515
|
+
await stack.http.writeOk(
|
|
516
|
+
"custom-fields:write:define-tenant-field",
|
|
517
|
+
{
|
|
518
|
+
entityName: "property",
|
|
519
|
+
fieldKey: "rank",
|
|
520
|
+
serializedField: { type: "number", default: 0 },
|
|
521
|
+
required: false,
|
|
522
|
+
searchable: false,
|
|
523
|
+
displayOrder: 0,
|
|
524
|
+
},
|
|
525
|
+
admin,
|
|
526
|
+
);
|
|
527
|
+
await createProperty(id, "DefaultMissing");
|
|
528
|
+
|
|
529
|
+
const err = await setMissingValueErr(id, "rank");
|
|
530
|
+
expect(err.httpStatus).toBe(400);
|
|
531
|
+
expect(err.code).toBe("validation_error");
|
|
532
|
+
expect(await countSetEvents(id)).toBe(0);
|
|
533
|
+
});
|
|
534
|
+
|
|
396
535
|
test("embedded field rejects a non-object value → 422, no event", async () => {
|
|
397
536
|
const id = "aaaaaaaa-aaaa-4000-8000-00000000000a";
|
|
398
537
|
// embedded carries a sub-field schema in serializedField — exercises
|