@cosmicdrift/kumiko-bundled-features 0.164.0 → 0.165.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 +7 -6
- package/src/admin-shell/__tests__/admin-shell-security.integration.test.ts +3 -2
- package/src/auth-email-password/__tests__/self-registration-toggle.integration.test.ts +103 -2
- package/src/auth-email-password/web/__tests__/login-screen.test.tsx +1 -1
- package/src/auth-mfa/__tests__/enable-confirm-preauth.integration.test.ts +12 -4
- package/src/auth-mfa/__tests__/enable-start-preauth.integration.test.ts +24 -1
- package/src/auth-mfa/__tests__/mfa-preauth-setup-token.test.ts +26 -0
- package/src/auth-mfa/web/__tests__/mfa-setup-preauth-screen.test.tsx +74 -0
- package/src/config/__tests__/cascade.integration.test.ts +3 -18
- package/src/config/resolver.ts +6 -49
- package/src/document-ingest-foundation/__tests__/feature.integration.test.ts +147 -0
- package/src/document-ingest-foundation/__tests__/feature.test.ts +67 -0
- package/src/document-ingest-foundation/entity.ts +36 -0
- package/src/document-ingest-foundation/events.ts +25 -0
- package/src/document-ingest-foundation/feature.ts +108 -0
- package/src/document-ingest-foundation/index.ts +11 -0
- package/src/feature-toggles/__tests__/compose-tier-resolver.test.ts +48 -0
- package/src/feature-toggles/__tests__/feature-toggles.integration.test.ts +6 -5
- package/src/feature-toggles/__tests__/toggle-admin.security.integration.test.ts +3 -3
- package/src/shared/entities-of.ts +17 -0
- package/src/shared/index.ts +1 -0
- package/src/tenant-lifecycle/boot-checks.ts +1 -16
- package/src/user-data-rights/__tests__/run-export-jobs.integration.test.ts +50 -73
- package/src/user-data-rights/__tests__/run-forget-cleanup.integration.test.ts +120 -0
- package/src/user-data-rights/boot-checks.ts +1 -17
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.165.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>",
|
|
@@ -57,6 +57,7 @@
|
|
|
57
57
|
"./mail-transport-inmemory": "./src/mail-transport-inmemory/index.ts",
|
|
58
58
|
"./file-foundation": "./src/file-foundation/index.ts",
|
|
59
59
|
"./file-provider-s3": "./src/file-provider-s3/index.ts",
|
|
60
|
+
"./document-ingest-foundation": "./src/document-ingest-foundation/index.ts",
|
|
60
61
|
"./file-provider-s3-env": "./src/file-provider-s3-env/index.ts",
|
|
61
62
|
"./file-provider-inmemory": "./src/file-provider-inmemory/index.ts",
|
|
62
63
|
"./files": "./src/files/index.ts",
|
|
@@ -117,11 +118,11 @@
|
|
|
117
118
|
"./step-dispatcher": "./src/step-dispatcher/index.ts"
|
|
118
119
|
},
|
|
119
120
|
"dependencies": {
|
|
120
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
121
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
122
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
123
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
124
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
121
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.165.0",
|
|
122
|
+
"@cosmicdrift/kumiko-framework": "0.165.0",
|
|
123
|
+
"@cosmicdrift/kumiko-headless": "0.165.0",
|
|
124
|
+
"@cosmicdrift/kumiko-renderer": "0.165.0",
|
|
125
|
+
"@cosmicdrift/kumiko-renderer-web": "0.165.0",
|
|
125
126
|
"@mollie/api-client": "^4.5.0",
|
|
126
127
|
"imapflow": "^1.3.3",
|
|
127
128
|
"mailparser": "^3.9.8",
|
|
@@ -21,7 +21,6 @@ import { createConfigFeature } from "../../config/feature";
|
|
|
21
21
|
import { createConfigResolver } from "../../config/resolver";
|
|
22
22
|
import { configValuesTable } from "../../config/table";
|
|
23
23
|
import { createFeatureTogglesFeature } from "../../feature-toggles/feature";
|
|
24
|
-
import { globalFeatureStateTable } from "../../feature-toggles/global-feature-state-table";
|
|
25
24
|
import { JobQueries } from "../../jobs/constants";
|
|
26
25
|
import { createJobsFeature } from "../../jobs/feature";
|
|
27
26
|
import { jobRunLogsTable, jobRunsTable } from "../../jobs/job-run-table";
|
|
@@ -79,10 +78,12 @@ beforeAll(async () => {
|
|
|
79
78
|
await unsafeCreateEntityTable(stack.db, userEntity);
|
|
80
79
|
await unsafeCreateEntityTable(stack.db, tenantEntity);
|
|
81
80
|
await unsafeCreateEntityTable(stack.db, tenantInvitationEntity);
|
|
81
|
+
// globalFeatureStateTable is auto-provisioned by setupTestStack — no
|
|
82
|
+
// manual push needed (that used to mask a missing r.storeTable()
|
|
83
|
+
// registration entirely).
|
|
82
84
|
await unsafePushTables(stack.db, {
|
|
83
85
|
configValuesTable,
|
|
84
86
|
tenantMembershipsTable,
|
|
85
|
-
globalFeatureStateTable,
|
|
86
87
|
jobRunsTable,
|
|
87
88
|
jobRunLogsTable,
|
|
88
89
|
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Runtime on/off switch for self-signup (#self-registration-toggle). Pins:
|
|
2
2
|
// 1. Default (no override row) → signup-request works.
|
|
3
|
-
// 2. runtime.apply(off) → signup-request
|
|
3
|
+
// 2. runtime.apply(off) → signup-request still 200 (silent no-op, anti-enumeration), no mail sent.
|
|
4
4
|
// 3. runtime.apply(on) → works again.
|
|
5
5
|
// Mirrors samples/recipes/feature-toggles' runtime.apply() pattern — flips the
|
|
6
6
|
// in-memory snapshot directly instead of going through the set-handler/HTTP,
|
|
@@ -91,11 +91,13 @@ beforeAll(async () => {
|
|
|
91
91
|
|
|
92
92
|
await unsafeCreateEntityTable(stack.db, userEntity);
|
|
93
93
|
await unsafeCreateEntityTable(stack.db, tenantEntity);
|
|
94
|
+
// globalFeatureStateTable is auto-provisioned by setupTestStack — no
|
|
95
|
+
// manual push needed (that used to mask a missing r.storeTable()
|
|
96
|
+
// registration entirely).
|
|
94
97
|
await unsafePushTables(stack.db, {
|
|
95
98
|
configValuesTable,
|
|
96
99
|
tenantMembershipsTable,
|
|
97
100
|
notificationPreferencesTable,
|
|
98
|
-
globalFeatureStateTable,
|
|
99
101
|
});
|
|
100
102
|
|
|
101
103
|
runtime = new GlobalFeatureToggleRuntime(stack.db, stack.registry);
|
|
@@ -166,3 +168,102 @@ describe("auth-self-registration toggle", () => {
|
|
|
166
168
|
});
|
|
167
169
|
});
|
|
168
170
|
});
|
|
171
|
+
|
|
172
|
+
// Regression for Finding 1 (#1468): the suite above always composes the
|
|
173
|
+
// companion toggle feature alongside feature-toggles. An app that mounts
|
|
174
|
+
// createFeatureTogglesFeature + a real effectiveFeatures resolver but
|
|
175
|
+
// forgets (or never knew to) compose createAuthSelfRegistrationToggleFeature
|
|
176
|
+
// must not go silently signup-dead — ctx.hasFeature() is checked against a
|
|
177
|
+
// feature name the registry never registered.
|
|
178
|
+
describe("auth-self-registration toggle — companion feature NOT composed", () => {
|
|
179
|
+
const bareEmailTransport = createInMemoryTransport();
|
|
180
|
+
let bareStack: TestStack;
|
|
181
|
+
let bareRuntime: GlobalFeatureToggleRuntime;
|
|
182
|
+
|
|
183
|
+
beforeAll(async () => {
|
|
184
|
+
const runtimeHolder = createLateBoundHolder<GlobalFeatureToggleRuntime>("bare-runtime");
|
|
185
|
+
let effective: () => ReadonlySet<string> = () => new Set();
|
|
186
|
+
|
|
187
|
+
bareStack = await setupTestStack({
|
|
188
|
+
features: [
|
|
189
|
+
createConfigFeature(),
|
|
190
|
+
createUserFeature(),
|
|
191
|
+
createTenantFeature(),
|
|
192
|
+
createTemplateResolverFeature(),
|
|
193
|
+
createRendererFoundationFeature(),
|
|
194
|
+
createDeliveryFeature(),
|
|
195
|
+
createRendererSimpleFeature(),
|
|
196
|
+
createChannelEmailFeature({
|
|
197
|
+
transport: bareEmailTransport,
|
|
198
|
+
renderer: simpleRenderer,
|
|
199
|
+
resolveEmail: async () => "unused@test.local",
|
|
200
|
+
}),
|
|
201
|
+
createAuthEmailPasswordFeature({
|
|
202
|
+
signup: { tokenTtlMinutes: 60, appUrl: APP_ACTIVATION_URL },
|
|
203
|
+
}),
|
|
204
|
+
// Deliberately NO createAuthSelfRegistrationToggleFeature().
|
|
205
|
+
createFeatureTogglesFeature({ getRuntime: () => runtimeHolder.get() }),
|
|
206
|
+
],
|
|
207
|
+
effectiveFeatures: () => effective(),
|
|
208
|
+
extraContext: (deps) => ({
|
|
209
|
+
...createDeliveryTestContext(deps),
|
|
210
|
+
configResolver: createConfigResolver(),
|
|
211
|
+
}),
|
|
212
|
+
systemHooks: [],
|
|
213
|
+
anonymousAccess: { defaultTenantId: SYSTEM_TENANT_ID },
|
|
214
|
+
authConfig: {
|
|
215
|
+
membershipQuery: "tenant:query:memberships",
|
|
216
|
+
loginHandler: AuthHandlers.login,
|
|
217
|
+
signup: {
|
|
218
|
+
requestHandler: AuthHandlers.signupRequest,
|
|
219
|
+
confirmHandler: AuthHandlers.signupConfirm,
|
|
220
|
+
},
|
|
221
|
+
},
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
await unsafeCreateEntityTable(bareStack.db, userEntity);
|
|
225
|
+
await unsafeCreateEntityTable(bareStack.db, tenantEntity);
|
|
226
|
+
await unsafePushTables(bareStack.db, {
|
|
227
|
+
configValuesTable,
|
|
228
|
+
tenantMembershipsTable,
|
|
229
|
+
notificationPreferencesTable,
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
bareRuntime = new GlobalFeatureToggleRuntime(bareStack.db, bareStack.registry);
|
|
233
|
+
await bareRuntime.initialize();
|
|
234
|
+
effective = bareRuntime.effectiveFeatures;
|
|
235
|
+
runtimeHolder.set(bareRuntime);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
afterAll(async () => {
|
|
239
|
+
await bareStack.cleanup();
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
// KNOWN GAP (found while writing this regression pin, not yet decided):
|
|
243
|
+
// ctx.hasFeature() checks the effective-feature SET, which only ever
|
|
244
|
+
// contains registered feature names (computeEffectiveFeatures iterates
|
|
245
|
+
// registry.features). A name nothing ever registered is never a member,
|
|
246
|
+
// so hasFeature("auth-self-registration") is false here — signup-request
|
|
247
|
+
// silently no-ops (200, anti-enumeration success shape, but no mail) the
|
|
248
|
+
// moment an app wires a real effectiveFeatures resolver without also
|
|
249
|
+
// composing createAuthSelfRegistrationToggleFeature. This pins the
|
|
250
|
+
// CURRENT (broken) behavior rather than asserting the desired one —
|
|
251
|
+
// flagged for a follow-up decision, not silently fixed here.
|
|
252
|
+
test("[KNOWN GAP] signup-request silently drops mail when the companion toggle feature was never composed", async () => {
|
|
253
|
+
const res = await bareStack.http.raw("POST", "/api/auth/signup-request", {
|
|
254
|
+
email: "not-toggle-aware@example.com",
|
|
255
|
+
});
|
|
256
|
+
expect(res.status).toBe(200);
|
|
257
|
+
expect(bareEmailTransport.sent).toHaveLength(0);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test("[KNOWN GAP] signup-registration-status reports enabled:false with no toggle feature mounted", async () => {
|
|
261
|
+
const res = await bareStack.http.raw("POST", "/api/query", {
|
|
262
|
+
type: "auth-email-password:query:signup-registration-status",
|
|
263
|
+
payload: {},
|
|
264
|
+
});
|
|
265
|
+
expect((await res.json()) as { data?: { enabled: boolean } }).toMatchObject({
|
|
266
|
+
data: { enabled: false },
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
});
|
|
@@ -331,7 +331,7 @@ describe("LoginScreen", () => {
|
|
|
331
331
|
fireEvent.change(screen.getByLabelText(/^Passwort/), { target: { value: "x" } });
|
|
332
332
|
fireEvent.click(screen.getByRole("button", { name: "Einloggen" }));
|
|
333
333
|
await waitFor(() => {
|
|
334
|
-
expect(screen.getByRole("alert").textContent).
|
|
334
|
+
expect(screen.getByRole("alert").textContent).toContain("unterstützt keine Zwei-Faktor");
|
|
335
335
|
});
|
|
336
336
|
});
|
|
337
337
|
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
8
8
|
import { randomBytes } from "node:crypto";
|
|
9
|
-
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
9
|
+
import { asRawClient, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
10
10
|
import { configureEntityFieldEncryption } from "@cosmicdrift/kumiko-framework/db";
|
|
11
11
|
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
12
12
|
import {
|
|
@@ -38,7 +38,7 @@ import { base32Decode } from "../base32";
|
|
|
38
38
|
import { mfaRequiredConfigHandle } from "../config";
|
|
39
39
|
import { AuthMfaHandlers } from "../constants";
|
|
40
40
|
import { createAuthMfaFeature, mfaStatusCheckerFromFeature } from "../feature";
|
|
41
|
-
import { userMfaEntity } from "../schema/user-mfa";
|
|
41
|
+
import { userMfaEntity, userMfaTable } from "../schema/user-mfa";
|
|
42
42
|
import { currentTotpCode } from "../totp";
|
|
43
43
|
|
|
44
44
|
let stack: TestStack;
|
|
@@ -163,7 +163,7 @@ describe("POST /auth/mfa/preauth-confirm", () => {
|
|
|
163
163
|
|
|
164
164
|
test("wrong TOTP code → invalid_totp_code, no enrollment persisted", async () => {
|
|
165
165
|
const email = "wrongcode@example.com";
|
|
166
|
-
const { preauthSetupToken } = await loginBlockedByEnforcement(email);
|
|
166
|
+
const { preauthSetupToken, userId } = await loginBlockedByEnforcement(email);
|
|
167
167
|
const { setupToken } = await startEnrollment(preauthSetupToken, email);
|
|
168
168
|
|
|
169
169
|
const res = await stack.http.raw("POST", "/api/auth/mfa/preauth-confirm", {
|
|
@@ -176,11 +176,16 @@ describe("POST /auth/mfa/preauth-confirm", () => {
|
|
|
176
176
|
expect(body.isSuccess).toBe(false);
|
|
177
177
|
expect(body.error.details.reason).toBe("invalid_totp_code");
|
|
178
178
|
expect(body.token).toBeUndefined();
|
|
179
|
+
// "no enrollment persisted" is the actual claim in the test name — the
|
|
180
|
+
// reason string alone doesn't prove it. Scoped to this test's own user:
|
|
181
|
+
// the table isn't truncated between tests in this file.
|
|
182
|
+
const rows = await selectMany(stack.db, userMfaTable, { userId });
|
|
183
|
+
expect(rows).toHaveLength(0);
|
|
179
184
|
});
|
|
180
185
|
|
|
181
186
|
test("replaying the same setupToken after a successful confirm → invalid_setup_token (burned), not a second enrollment", async () => {
|
|
182
187
|
const email = "race@example.com";
|
|
183
|
-
const { preauthSetupToken } = await loginBlockedByEnforcement(email);
|
|
188
|
+
const { preauthSetupToken, userId } = await loginBlockedByEnforcement(email);
|
|
184
189
|
const { setupToken, secret } = await startEnrollment(preauthSetupToken, email);
|
|
185
190
|
const code = currentTotpCode(secret);
|
|
186
191
|
|
|
@@ -200,6 +205,9 @@ describe("POST /auth/mfa/preauth-confirm", () => {
|
|
|
200
205
|
expect(second.status).not.toBe(200);
|
|
201
206
|
const secondBody = await second.json();
|
|
202
207
|
expect(secondBody.error.details.reason).toBe("invalid_setup_token");
|
|
208
|
+
// "not a second enrollment" — pin the row count, not just the error reason.
|
|
209
|
+
const rows = await selectMany(stack.db, userMfaTable, { userId });
|
|
210
|
+
expect(rows).toHaveLength(1);
|
|
203
211
|
});
|
|
204
212
|
|
|
205
213
|
test("a session-issued setupToken (no tenantId) is rejected, not silently accepted", async () => {
|
|
@@ -163,9 +163,32 @@ describe("POST /auth/mfa/preauth-enable-start", () => {
|
|
|
163
163
|
accountLabel: "unenrolled@example.com",
|
|
164
164
|
});
|
|
165
165
|
|
|
166
|
-
expect(res.status).
|
|
166
|
+
expect(res.status).toBe(422);
|
|
167
|
+
const body = await res.json();
|
|
168
|
+
expect(body.isSuccess).toBe(false);
|
|
169
|
+
expect(body.error.details.reason).toBe("invalid_challenge_token");
|
|
170
|
+
expect(body.setupToken).toBeUndefined();
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test("structurally-valid preauthSetupToken signed with the WRONG secret (setup instead of challenge) is rejected", async () => {
|
|
174
|
+
// EnableStartPreauthOptions warns explicitly about this secret mix-up —
|
|
175
|
+
// signing with SETUP_TOKEN_SECRET instead of the CHALLENGE_TOKEN_SECRET
|
|
176
|
+
// that preauth-enable-start actually verifies against must fail exactly
|
|
177
|
+
// like a garbage token, not silently pass signature verification.
|
|
178
|
+
const { token: wrongSecretToken } = signMfaPreauthSetupToken(
|
|
179
|
+
{ userId: "11111111-1111-4111-8111-111111111111", tenantId: TENANT_ID },
|
|
180
|
+
10,
|
|
181
|
+
SETUP_TOKEN_SECRET,
|
|
182
|
+
);
|
|
183
|
+
const res = await stack.http.raw("POST", "/api/auth/mfa/preauth-enable-start", {
|
|
184
|
+
preauthSetupToken: wrongSecretToken,
|
|
185
|
+
accountLabel: "unenrolled@example.com",
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
expect(res.status).toBe(422);
|
|
167
189
|
const body = await res.json();
|
|
168
190
|
expect(body.isSuccess).toBe(false);
|
|
191
|
+
expect(body.error.details.reason).toBe("invalid_challenge_token");
|
|
169
192
|
expect(body.setupToken).toBeUndefined();
|
|
170
193
|
});
|
|
171
194
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import { Temporal } from "temporal-polyfill";
|
|
3
|
+
import { signMfaChallengeToken, verifyMfaChallengeToken } from "../mfa-challenge-token";
|
|
3
4
|
import { signMfaPreauthSetupToken, verifyMfaPreauthSetupToken } from "../mfa-preauth-setup-token";
|
|
4
5
|
|
|
5
6
|
const SECRET = "test-mfa-preauth-setup-secret-at-least-32-bytes!!";
|
|
@@ -34,6 +35,31 @@ describe("mfa-preauth-setup-token", () => {
|
|
|
34
35
|
});
|
|
35
36
|
});
|
|
36
37
|
|
|
38
|
+
test("a genuine mfa-challenge token cannot be replayed as a preauth-setup token, and vice versa", () => {
|
|
39
|
+
// Both bodies share the identical JSON shape ({userId, tenantId,
|
|
40
|
+
// expiresAtMs}), so this only fails if the domain-separated signing
|
|
41
|
+
// string (`mfa-challenge:` vs `mfa-preauth-setup:`) actually differs.
|
|
42
|
+
const { token: challengeToken } = signMfaChallengeToken(
|
|
43
|
+
{ userId: USER_ID, tenantId: TENANT_ID },
|
|
44
|
+
10,
|
|
45
|
+
SECRET,
|
|
46
|
+
);
|
|
47
|
+
expect(verifyMfaPreauthSetupToken(challengeToken, SECRET)).toEqual({
|
|
48
|
+
ok: false,
|
|
49
|
+
reason: "bad_signature",
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const { token: setupToken } = signMfaPreauthSetupToken(
|
|
53
|
+
{ userId: USER_ID, tenantId: TENANT_ID },
|
|
54
|
+
10,
|
|
55
|
+
SECRET,
|
|
56
|
+
);
|
|
57
|
+
expect(verifyMfaChallengeToken(setupToken, SECRET)).toEqual({
|
|
58
|
+
ok: false,
|
|
59
|
+
reason: "bad_signature",
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
37
63
|
test("tampered signature → bad_signature", () => {
|
|
38
64
|
const { token } = signMfaPreauthSetupToken(
|
|
39
65
|
{ userId: USER_ID, tenantId: TENANT_ID },
|
|
@@ -199,4 +199,78 @@ describe("MfaSetupPreauthScreen", () => {
|
|
|
199
199
|
);
|
|
200
200
|
expect(screen.queryByRole("button", { name: "Zurück zum Login" })).toBeNull();
|
|
201
201
|
});
|
|
202
|
+
|
|
203
|
+
test("confirm schlägt mit invalid_totp_code fehl → übersetztes Banner, onSuccess NICHT gefeuert, Formular weiter bedienbar", async () => {
|
|
204
|
+
const onSuccess = mock(() => {});
|
|
205
|
+
globalThis.fetch = mock(async (input: RequestInfo | URL) => {
|
|
206
|
+
const url = String(input);
|
|
207
|
+
if (url.includes("preauth-enable-start")) return jsonResponse(startBody);
|
|
208
|
+
if (url.includes("preauth-confirm")) {
|
|
209
|
+
return jsonResponse({ isSuccess: false, error: "invalid_totp_code" });
|
|
210
|
+
}
|
|
211
|
+
return jsonResponse({ isSuccess: false, error: "unexpected_url" }, 500);
|
|
212
|
+
}) as unknown as typeof fetch;
|
|
213
|
+
|
|
214
|
+
render(
|
|
215
|
+
<Wrapper>
|
|
216
|
+
<MfaSetupPreauthScreen
|
|
217
|
+
preauthSetupToken="preauth-1"
|
|
218
|
+
accountLabel="user@example.com"
|
|
219
|
+
onSuccess={onSuccess}
|
|
220
|
+
/>
|
|
221
|
+
</Wrapper>,
|
|
222
|
+
);
|
|
223
|
+
fireEvent.click(screen.getByRole("button", { name: "Einrichtung starten" }));
|
|
224
|
+
await waitFor(() => {
|
|
225
|
+
expect(screen.getByText("ABCD1234")).toBeTruthy();
|
|
226
|
+
});
|
|
227
|
+
fireEvent.click(screen.getByLabelText("Ich habe die Recovery-Codes gespeichert."));
|
|
228
|
+
fireEvent.change(screen.getByLabelText(/^Code aus der Authenticator-App/), {
|
|
229
|
+
target: { value: "000000" },
|
|
230
|
+
});
|
|
231
|
+
fireEvent.click(screen.getByRole("button", { name: "Einrichtung abschließen" }));
|
|
232
|
+
|
|
233
|
+
await waitFor(() => {
|
|
234
|
+
expect(screen.getByText("Ungültiger Code. Bitte erneut versuchen.")).toBeTruthy();
|
|
235
|
+
});
|
|
236
|
+
expect(onSuccess).not.toHaveBeenCalled();
|
|
237
|
+
// Form stays usable — QR/recovery section is still there, a retry is possible.
|
|
238
|
+
expect(screen.getByText("ABCD1234")).toBeTruthy();
|
|
239
|
+
const confirm = screen.getByRole("button", {
|
|
240
|
+
name: "Einrichtung abschließen",
|
|
241
|
+
}) as HTMLButtonElement;
|
|
242
|
+
expect(confirm.disabled).toBe(false);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test("confirm schlägt mit invalid_setup_token fehl → übersetztes Banner (Neustart-Pfad)", async () => {
|
|
246
|
+
globalThis.fetch = mock(async (input: RequestInfo | URL) => {
|
|
247
|
+
const url = String(input);
|
|
248
|
+
if (url.includes("preauth-enable-start")) return jsonResponse(startBody);
|
|
249
|
+
if (url.includes("preauth-confirm")) {
|
|
250
|
+
return jsonResponse({ isSuccess: false, error: "invalid_setup_token" });
|
|
251
|
+
}
|
|
252
|
+
return jsonResponse({ isSuccess: false, error: "unexpected_url" }, 500);
|
|
253
|
+
}) as unknown as typeof fetch;
|
|
254
|
+
|
|
255
|
+
render(
|
|
256
|
+
<Wrapper>
|
|
257
|
+
<MfaSetupPreauthScreen preauthSetupToken="preauth-1" accountLabel="user@example.com" />
|
|
258
|
+
</Wrapper>,
|
|
259
|
+
);
|
|
260
|
+
fireEvent.click(screen.getByRole("button", { name: "Einrichtung starten" }));
|
|
261
|
+
await waitFor(() => {
|
|
262
|
+
expect(screen.getByText("ABCD1234")).toBeTruthy();
|
|
263
|
+
});
|
|
264
|
+
fireEvent.click(screen.getByLabelText("Ich habe die Recovery-Codes gespeichert."));
|
|
265
|
+
fireEvent.change(screen.getByLabelText(/^Code aus der Authenticator-App/), {
|
|
266
|
+
target: { value: "123456" },
|
|
267
|
+
});
|
|
268
|
+
fireEvent.click(screen.getByRole("button", { name: "Einrichtung abschließen" }));
|
|
269
|
+
|
|
270
|
+
await waitFor(() => {
|
|
271
|
+
expect(
|
|
272
|
+
screen.getByText("Die Einrichtung ist abgelaufen. Bitte erneut starten."),
|
|
273
|
+
).toBeTruthy();
|
|
274
|
+
});
|
|
275
|
+
});
|
|
202
276
|
});
|
|
@@ -528,10 +528,9 @@ describe("config:query:cascade handler", () => {
|
|
|
528
528
|
});
|
|
529
529
|
});
|
|
530
530
|
|
|
531
|
-
// getAll
|
|
532
|
-
//
|
|
533
|
-
|
|
534
|
-
describe("getAll / getAllWithSource", () => {
|
|
531
|
+
// getAll is a bulk reader — never reached via the cascade query handlers
|
|
532
|
+
// above, so it stays uncovered unless called directly on the resolver.
|
|
533
|
+
describe("getAll", () => {
|
|
535
534
|
test("getAll picks the most specific row (user > tenant > system)", async () => {
|
|
536
535
|
await stack.http.writeOk(
|
|
537
536
|
ConfigHandlers.set,
|
|
@@ -551,18 +550,4 @@ describe("getAll / getAllWithSource", () => {
|
|
|
551
550
|
// System seed still present for keys without a more-specific row.
|
|
552
551
|
expect(JSON.parse(all.get(SYSTEM_KEY)?.value ?? "null")).toBe("SEED_SYSTEM");
|
|
553
552
|
});
|
|
554
|
-
|
|
555
|
-
test("getAllWithSource tags the winning row's source", async () => {
|
|
556
|
-
const withSource = await resolver.getAllWithSource(tenantAdmin.tenantId, tenantAdmin.id, db);
|
|
557
|
-
|
|
558
|
-
const userRow = withSource.get(USER_KEY);
|
|
559
|
-
expect(userRow?.source).toBe("user-row");
|
|
560
|
-
expect(JSON.parse(userRow?.value ?? "null")).toBe("USER_BULK");
|
|
561
|
-
|
|
562
|
-
const tenantRow = withSource.get(TENANT_KEY);
|
|
563
|
-
expect(tenantRow?.source).toBe("tenant-row");
|
|
564
|
-
|
|
565
|
-
const systemRow = withSource.get(SYSTEM_KEY);
|
|
566
|
-
expect(systemRow?.source).toBe("system-row");
|
|
567
|
-
});
|
|
568
553
|
});
|
package/src/config/resolver.ts
CHANGED
|
@@ -12,7 +12,6 @@ import type {
|
|
|
12
12
|
ConfigKeyDefinition,
|
|
13
13
|
ConfigResolver,
|
|
14
14
|
ConfigSecretsReader,
|
|
15
|
-
ConfigStoredRowWithSource,
|
|
16
15
|
ConfigValueSource,
|
|
17
16
|
ConfigValueWithSource,
|
|
18
17
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
@@ -441,12 +440,12 @@ export function createConfigResolver(options: ConfigResolverOptions = {}): Confi
|
|
|
441
440
|
return { value: undefined, source: "missing" };
|
|
442
441
|
},
|
|
443
442
|
|
|
444
|
-
// NOTE: getAll
|
|
445
|
-
//
|
|
446
|
-
//
|
|
447
|
-
//
|
|
448
|
-
//
|
|
449
|
-
//
|
|
443
|
+
// NOTE: getAll reads only config_values rows and does NOT dispatch to the
|
|
444
|
+
// secrets store (unlike get/getCascade/getWithSource). A backing="secrets"
|
|
445
|
+
// key has no config_values row, so it is simply ABSENT from the result map
|
|
446
|
+
// — never a stale/undefined value. Callers needing a secrets-backed value
|
|
447
|
+
// must use get/getCascade; this bulk reader is for config_values-backed
|
|
448
|
+
// keys only.
|
|
450
449
|
async getAll(tenantId, userId, db) {
|
|
451
450
|
// Only load rows relevant to this user/tenant (system + tenant + user scope)
|
|
452
451
|
const rows = await selectConfigRowsForScope(db, SYSTEM_TENANT_ID, tenantId, userId);
|
|
@@ -467,48 +466,6 @@ export function createConfigResolver(options: ConfigResolverOptions = {}): Confi
|
|
|
467
466
|
return result;
|
|
468
467
|
},
|
|
469
468
|
|
|
470
|
-
async getAllWithSource(tenantId, userId, db) {
|
|
471
|
-
// Load ALL potentially relevant rows (user + tenant + system)
|
|
472
|
-
const rows = await selectConfigRowsForScope(db, SYSTEM_TENANT_ID, tenantId, userId);
|
|
473
|
-
|
|
474
|
-
const result = new Map<string, ConfigStoredRowWithSource>();
|
|
475
|
-
|
|
476
|
-
// Group rows by key so we can determine the winner and its source
|
|
477
|
-
const groups = new Map<string, ConfigRow[]>();
|
|
478
|
-
for (const r of rows) {
|
|
479
|
-
const g = groups.get(r.key) ?? [];
|
|
480
|
-
g.push(r);
|
|
481
|
-
groups.set(r.key, g);
|
|
482
|
-
}
|
|
483
|
-
|
|
484
|
-
for (const [key, keyRows] of groups) {
|
|
485
|
-
const specificityOf = (candidate: ConfigRow) =>
|
|
486
|
-
(candidate.userId !== null ? 2 : 0) + (candidate.tenantId !== SYSTEM_TENANT_ID ? 1 : 0);
|
|
487
|
-
|
|
488
|
-
const first = keyRows[0];
|
|
489
|
-
if (!first) continue;
|
|
490
|
-
let winner: ConfigRow = first;
|
|
491
|
-
for (const r of keyRows) {
|
|
492
|
-
if (specificityOf(r) > specificityOf(winner)) {
|
|
493
|
-
winner = r;
|
|
494
|
-
}
|
|
495
|
-
}
|
|
496
|
-
|
|
497
|
-
let source: ConfigValueSource;
|
|
498
|
-
if (winner.userId !== null) {
|
|
499
|
-
source = "user-row";
|
|
500
|
-
} else if (winner.tenantId !== SYSTEM_TENANT_ID) {
|
|
501
|
-
source = "tenant-row";
|
|
502
|
-
} else {
|
|
503
|
-
source = "system-row";
|
|
504
|
-
}
|
|
505
|
-
|
|
506
|
-
result.set(key, { ...winner, source });
|
|
507
|
-
}
|
|
508
|
-
|
|
509
|
-
return result;
|
|
510
|
-
},
|
|
511
|
-
|
|
512
469
|
async getCascade(
|
|
513
470
|
qualifiedKey,
|
|
514
471
|
keyDef,
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// document-ingest-foundation — fileRef.created MSP integration test.
|
|
2
|
+
//
|
|
3
|
+
// Proves the end-to-end trigger flow: upload → fileRef.created →
|
|
4
|
+
// documentIngest.requested, gated on mime/size (files-post-processing
|
|
5
|
+
// pattern, kumiko-framework#1497).
|
|
6
|
+
|
|
7
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
8
|
+
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
9
|
+
import {
|
|
10
|
+
createInMemoryFileProvider,
|
|
11
|
+
type InMemoryFileProvider,
|
|
12
|
+
} from "@cosmicdrift/kumiko-framework/files";
|
|
13
|
+
import { setupTestStack, type TestStack, TestUsers } from "@cosmicdrift/kumiko-framework/stack";
|
|
14
|
+
import { createConfigFeature } from "../../config";
|
|
15
|
+
import { documentIngestFoundationFeature } from "../feature";
|
|
16
|
+
|
|
17
|
+
let stack: TestStack;
|
|
18
|
+
let provider: InMemoryFileProvider;
|
|
19
|
+
|
|
20
|
+
const admin = TestUsers.admin;
|
|
21
|
+
|
|
22
|
+
const pdfBytes = new TextEncoder().encode("%PDF-1.4 minimal");
|
|
23
|
+
const pngBytes = new Uint8Array([
|
|
24
|
+
0x89,
|
|
25
|
+
0x50,
|
|
26
|
+
0x4e,
|
|
27
|
+
0x47,
|
|
28
|
+
0x0d,
|
|
29
|
+
0x0a,
|
|
30
|
+
0x1a,
|
|
31
|
+
0x0a,
|
|
32
|
+
...Array(64).fill(0),
|
|
33
|
+
]);
|
|
34
|
+
const textBytes = new TextEncoder().encode("plain text, not a supported mime type");
|
|
35
|
+
|
|
36
|
+
beforeAll(async () => {
|
|
37
|
+
provider = createInMemoryFileProvider();
|
|
38
|
+
stack = await setupTestStack({
|
|
39
|
+
features: [createConfigFeature(), documentIngestFoundationFeature],
|
|
40
|
+
files: { storageProvider: provider },
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
afterAll(async () => {
|
|
45
|
+
await stack.cleanup();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
beforeEach(async () => {
|
|
49
|
+
provider.clear();
|
|
50
|
+
stack.events.reset();
|
|
51
|
+
await asRawClient(stack.db).unsafe(
|
|
52
|
+
`TRUNCATE kumiko_events, kumiko_event_consumers, file_refs RESTART IDENTITY CASCADE`,
|
|
53
|
+
);
|
|
54
|
+
await stack.eventDispatcher?.ensureRegistered();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
async function uploadFile(
|
|
58
|
+
fileName: string,
|
|
59
|
+
content: Uint8Array,
|
|
60
|
+
mimeType: string,
|
|
61
|
+
): Promise<{ id: string; storageKey: string }> {
|
|
62
|
+
const token = await stack.jwt.sign(admin);
|
|
63
|
+
const formData = new FormData();
|
|
64
|
+
formData.append("file", new File([Buffer.from(content)], fileName, { type: mimeType }));
|
|
65
|
+
const res = await stack.app.request("/api/files", {
|
|
66
|
+
method: "POST",
|
|
67
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
68
|
+
body: formData,
|
|
69
|
+
});
|
|
70
|
+
expect(res.status).toBe(201);
|
|
71
|
+
const body = (await res.json()) as { id: string; storageKey: string };
|
|
72
|
+
return body;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function loadIngestRequestedEvents(): Promise<{ payload: Record<string, unknown> }[]> {
|
|
76
|
+
const rows = await asRawClient(stack.db).unsafe(
|
|
77
|
+
`SELECT payload FROM kumiko_events WHERE type = $1`,
|
|
78
|
+
["document-ingest-foundation:event:document-ingest-requested"],
|
|
79
|
+
);
|
|
80
|
+
return rows as { payload: Record<string, unknown> }[];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
describe("fileRef.created → documentIngest.requested", () => {
|
|
84
|
+
test("PDF upload requests ingest with the fileRef pointer, no binary", async () => {
|
|
85
|
+
const { id, storageKey } = await uploadFile("invoice.pdf", pdfBytes, "application/pdf");
|
|
86
|
+
|
|
87
|
+
await stack.eventDispatcher?.runOnce();
|
|
88
|
+
|
|
89
|
+
const rows = await loadIngestRequestedEvents();
|
|
90
|
+
expect(rows).toHaveLength(1);
|
|
91
|
+
const payload = rows[0]?.payload;
|
|
92
|
+
expect(payload?.["fileRefId"]).toBe(id);
|
|
93
|
+
expect(payload?.["storageKey"]).toBe(storageKey);
|
|
94
|
+
expect(payload?.["mimeType"]).toBe("application/pdf");
|
|
95
|
+
expect(payload?.["data"]).toBeUndefined();
|
|
96
|
+
expect(payload?.["binary"]).toBeUndefined();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("image/png upload also requests ingest (Phase-1 mime allowlist)", async () => {
|
|
100
|
+
await uploadFile("scan.png", pngBytes, "image/png");
|
|
101
|
+
|
|
102
|
+
await stack.eventDispatcher?.runOnce();
|
|
103
|
+
|
|
104
|
+
expect(await loadIngestRequestedEvents()).toHaveLength(1);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("unsupported mime type is skipped — no ingest requested", async () => {
|
|
108
|
+
await uploadFile("notes.txt", textBytes, "text/plain");
|
|
109
|
+
|
|
110
|
+
await stack.eventDispatcher?.runOnce();
|
|
111
|
+
|
|
112
|
+
expect(await loadIngestRequestedEvents()).toHaveLength(0);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("oversized file is skipped before the mime check — no ingest requested", async () => {
|
|
116
|
+
// Real uploads can't exceed file-routes.ts' 10mb unconstrained-upload
|
|
117
|
+
// default, well below this feature's 25mb domain cap — so an oversized
|
|
118
|
+
// fileRef.created is inserted directly (bypassing the upload route) to
|
|
119
|
+
// prove the MSP's own size-check fires independently of it.
|
|
120
|
+
await asRawClient(stack.db).unsafe(
|
|
121
|
+
`
|
|
122
|
+
INSERT INTO kumiko_events
|
|
123
|
+
(tenant_id, aggregate_type, aggregate_id, version, type, payload, metadata, created_at, created_by)
|
|
124
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7, now(), $8)
|
|
125
|
+
`,
|
|
126
|
+
[
|
|
127
|
+
admin.tenantId,
|
|
128
|
+
"fileRef",
|
|
129
|
+
"00000000-0000-4000-8000-0000000000ff",
|
|
130
|
+
1,
|
|
131
|
+
"fileRef.created",
|
|
132
|
+
JSON.stringify({
|
|
133
|
+
storageKey: "huge.pdf",
|
|
134
|
+
fileName: "huge.pdf",
|
|
135
|
+
mimeType: "application/pdf",
|
|
136
|
+
size: 26 * 1024 * 1024,
|
|
137
|
+
}),
|
|
138
|
+
"{}",
|
|
139
|
+
admin.id,
|
|
140
|
+
],
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
await stack.eventDispatcher?.runOnce();
|
|
144
|
+
|
|
145
|
+
expect(await loadIngestRequestedEvents()).toHaveLength(0);
|
|
146
|
+
});
|
|
147
|
+
});
|