@cosmicdrift/kumiko-bundled-features 0.165.0 → 0.165.2
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 +10 -6
- package/src/__tests__/access-invalidation-consumer.integration.test.ts +279 -0
- package/src/__tests__/access-invalidation-stream.integration.test.ts +205 -0
- package/src/audit/__tests__/audit.integration.test.ts +52 -21
- package/src/audit/handlers/list.query.ts +1 -0
- package/src/auth-email-password/__tests__/auth.integration.test.ts +6 -0
- package/src/auth-email-password/__tests__/invite-flow.integration.test.ts +34 -0
- package/src/auth-email-password/__tests__/login-gates.test.ts +39 -0
- package/src/auth-email-password/__tests__/self-registration-toggle.integration.test.ts +9 -14
- package/src/auth-email-password/__tests__/session-callbacks.integration.test.ts +16 -5
- package/src/auth-email-password/__tests__/signup-flow.integration.test.ts +54 -0
- package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +2 -1
- package/src/auth-email-password/handlers/invite-signup-complete.write.ts +2 -1
- package/src/auth-email-password/handlers/login.write.ts +196 -155
- package/src/auth-email-password/handlers/self-registration-status.query.ts +6 -3
- package/src/auth-email-password/handlers/signup-request.write.ts +12 -1
- package/src/auth-email-password/i18n.ts +2 -0
- package/src/auth-email-password/seeding.ts +41 -23
- package/src/auth-email-password/web/__tests__/auth-client.test.ts +13 -0
- package/src/auth-email-password/web/__tests__/auth-gate.test.tsx +55 -8
- package/src/auth-email-password/web/__tests__/client-plugin.test.tsx +34 -0
- package/src/auth-email-password/web/__tests__/confirm-account-unlock-screen.test.tsx +8 -1
- package/src/auth-email-password/web/__tests__/forgot-password-screen.test.tsx +5 -1
- package/src/auth-email-password/web/__tests__/invite-accept-screen.test.tsx +3 -0
- package/src/auth-email-password/web/__tests__/request-account-unlock-screen.test.tsx +8 -2
- package/src/auth-email-password/web/__tests__/reset-password-screen.test.tsx +5 -1
- package/src/auth-email-password/web/__tests__/verify-email-screen.test.tsx +5 -1
- package/src/auth-email-password/web/auth-client.ts +32 -10
- package/src/auth-email-password/web/auth-gate.tsx +17 -21
- package/src/auth-email-password/web/client-plugin.ts +15 -2
- package/src/auth-email-password/web/index.ts +5 -1
- package/src/auth-email-password/web/login-screen.tsx +7 -3
- package/src/auth-email-password/web/request-account-unlock-screen.tsx +1 -1
- package/src/auth-foundation/__tests__/session-store.test.ts +2 -4
- package/src/auth-foundation/__tests__/tenant-providers.test.ts +24 -0
- package/src/auth-foundation/__tests__/token-verifier.test.ts +36 -3
- package/src/auth-foundation/boot-checks.ts +74 -69
- package/src/auth-foundation/resolve-anonymous-access.ts +13 -5
- package/src/auth-foundation/types.ts +3 -3
- package/src/auth-mfa/__tests__/enable-confirm-preauth.integration.test.ts +61 -1
- package/src/auth-mfa/__tests__/no-redis.integration.test.ts +209 -0
- package/src/auth-mfa/__tests__/reencrypt-job.integration.test.ts +157 -4
- package/src/auth-mfa/handlers/disable.write.ts +16 -1
- package/src/auth-mfa/handlers/enable-confirm-preauth.write.ts +72 -42
- package/src/auth-mfa/handlers/enable-confirm.write.ts +23 -12
- package/src/auth-mfa/handlers/reencrypt.job.ts +26 -17
- package/src/auth-mfa/handlers/regenerate-recovery.write.ts +15 -1
- package/src/auth-mfa/handlers/verify.write.ts +28 -24
- package/src/auth-mfa/web/__tests__/mfa-client.test.ts +5 -1
- package/src/auth-mfa/web/__tests__/mfa-enable-screen.test.tsx +10 -1
- package/src/auth-mfa/web/__tests__/mfa-setup-preauth-screen.test.tsx +104 -1
- package/src/auth-mfa/web/__tests__/mfa-verify-screen.test.tsx +5 -1
- package/src/auth-mfa/web/i18n.ts +4 -0
- package/src/auth-mfa/web/mfa-client.ts +13 -2
- package/src/auth-mfa/web/mfa-enable-screen.tsx +1 -1
- package/src/auth-mfa/web/mfa-setup-preauth-screen.tsx +25 -2
- package/src/billing-foundation/__tests__/subscription-provider-contract.ts +36 -12
- package/src/config/__tests__/backing-secrets.integration.test.ts +4 -0
- package/src/config/__tests__/cascade.integration.test.ts +13 -2
- package/src/config/__tests__/pii-encrypted.integration.test.ts +6 -6
- package/src/config/__tests__/reencrypt-job-kek-rotation.integration.test.ts +78 -5
- package/src/config/db/queries/resolver.ts +3 -3
- package/src/config/handlers/reencrypt.job.ts +36 -24
- package/src/custom-fields/__tests__/custom-fields.integration.test.ts +14 -0
- package/src/document-ingest-foundation/__tests__/feature.test.ts +38 -0
- package/src/document-ingest-foundation/__tests__/pages.test.ts +37 -0
- package/src/document-ingest-foundation/entity.ts +10 -3
- package/src/document-ingest-foundation/feature.ts +26 -8
- package/src/document-ingest-foundation/index.ts +1 -0
- package/src/document-ingest-foundation/pages.ts +34 -0
- package/src/feature-toggles/__tests__/compose-tier-resolver.test.ts +114 -11
- package/src/feature-toggles/compose-tier-resolver.ts +77 -8
- package/src/feature-toggles/global-feature-state-table.ts +15 -12
- package/src/files-provider-s3/s3-provider.ts +12 -1
- package/src/inbound-mail-foundation/__tests__/inbound-mail-foundation.integration.test.ts +78 -1
- package/src/inbound-mail-foundation/__tests__/inbound-mail-provider-contract.ts +58 -40
- package/src/inbound-mail-foundation/__tests__/watch-supervisor.integration.test.ts +9 -1
- package/src/inbound-mail-foundation/entities.ts +3 -3
- package/src/inbound-mail-foundation/handlers/ingest-message.write.ts +22 -12
- package/src/inbound-provider-imap/__tests__/imap-live.integration.test.ts +1 -1
- package/src/inbound-provider-imap/__tests__/plugin-mocked.test.ts +35 -8
- package/src/inbound-provider-inmemory/__tests__/feature.test.ts +37 -33
- package/src/jobs/__tests__/job-instant.test.ts +15 -0
- package/src/jobs/__tests__/reindex-entity-job.integration.test.ts +19 -9
- package/src/jobs/feature.ts +6 -5
- package/src/jobs/job-instant.ts +9 -0
- package/src/mail-foundation/__tests__/mail-transport-contract.ts +27 -11
- package/src/mail-transport-inmemory/__tests__/feature.test.ts +10 -6
- package/src/mail-transport-smtp/__tests__/feature.test.ts +10 -5
- package/src/page-render/__tests__/markdown.test.ts +29 -0
- package/src/page-render/markdown.ts +1 -9
- package/src/personal-access-tokens/feature.ts +3 -3
- package/src/personal-access-tokens/schema/api-token.ts +3 -3
- package/src/sessions/__tests__/rebuild-survival.integration.test.ts +3 -1
- package/src/sessions/__tests__/sessions-screens.boot.test.ts +5 -10
- package/src/sessions/__tests__/sessions.integration.test.ts +88 -1
- package/src/sessions/feature.ts +21 -6
- package/src/sessions/handlers/revoke-all-for-user.write.ts +51 -12
- package/src/sessions/handlers/revoke-all-others.write.ts +19 -1
- package/src/sessions/handlers/revoke.write.ts +13 -0
- package/src/sessions/index.ts +7 -0
- package/src/sessions/schema/user-session.ts +6 -7
- package/src/sessions/session-callbacks.ts +58 -5
- package/src/sessions/session-revoked-event.ts +29 -0
- package/src/shared/__tests__/chunked-entity-migration.test.ts +4 -4
- package/src/shared/chunked-entity-migration.ts +2 -2
- package/src/shared/grace-period.test.ts +26 -0
- package/src/shared/grace-period.ts +20 -0
- package/src/shared/index.ts +2 -0
- package/src/shared/map-with-concurrency.test.ts +104 -0
- package/src/shared/map-with-concurrency.ts +38 -0
- package/src/subscription-mollie/__tests__/feature.test.ts +16 -6
- package/src/subscription-mollie/__tests__/verify-webhook.test.ts +21 -0
- package/src/subscription-mollie/verify-webhook.ts +1 -0
- package/src/subscription-stripe/__tests__/feature.test.ts +14 -7
- package/src/subscription-stripe/__tests__/verify-webhook.test.ts +20 -0
- package/src/subscription-stripe/verify-webhook.ts +3 -2
- package/src/tenant/__tests__/members-query-kms.integration.test.ts +2 -2
- package/src/tenant/__tests__/multi-tenant.integration.test.ts +1 -1
- package/src/tenant/handlers/active-tenant-ids.query.ts +8 -2
- package/src/tenant/handlers/invitations.query.ts +13 -15
- package/src/tenant/handlers/members.query.ts +12 -12
- package/src/tenant/seeding.ts +110 -16
- package/src/tenant-lifecycle/boot-checks.ts +11 -4
- package/src/tenant-lifecycle/handlers/cancel-destruction.write.ts +2 -6
- package/src/tenant-settings/__tests__/tenant-settings.integration.test.ts +94 -0
- package/src/tenant-settings/config.ts +44 -0
- package/src/tenant-settings/constants.ts +16 -0
- package/src/tenant-settings/feature.ts +49 -0
- package/src/tenant-settings/index.ts +8 -0
- package/src/tenant-settings/tenant-defaults.ts +72 -0
- package/src/text-content/__tests__/seed-legal-content.integration.test.ts +33 -6
- package/src/text-content/handlers/set.write.ts +2 -2
- package/src/text-content/seeding.ts +31 -29
- package/src/user/handlers/find-for-auth.query.ts +1 -1
- package/src/user/seeding.ts +8 -0
- package/src/user-data-rights/COMPLIANCE.md +1 -1
- package/src/user-data-rights/__tests__/audit-log.integration.test.ts +65 -0
- package/src/user-data-rights/__tests__/boot-checks.test.ts +15 -17
- package/src/user-data-rights/__tests__/download-by-token-resolver-trust.integration.test.ts +9 -5
- package/src/user-data-rights/__tests__/download.integration.test.ts +25 -41
- package/src/user-data-rights/__tests__/inspector-screens.boot.test.ts +7 -10
- package/src/user-data-rights/__tests__/mail-default-bridge.integration.test.ts +15 -1
- package/src/user-data-rights/__tests__/restriction-flow.integration.test.ts +140 -0
- package/src/user-data-rights/__tests__/run-export-jobs.integration.test.ts +9 -4
- package/src/user-data-rights/__tests__/run-forget-cleanup.integration.test.ts +74 -8
- package/src/user-data-rights/db/queries/__tests__/forget-cleanup.integration.test.ts +69 -0
- package/src/user-data-rights/db/queries/forget-cleanup.ts +9 -2
- package/src/user-data-rights/feature.ts +45 -45
- package/src/user-data-rights/handlers/cancel-deletion.write.ts +6 -11
- package/src/user-data-rights/handlers/download-by-job.query.ts +6 -6
- package/src/user-data-rights/handlers/download-by-token.query.ts +6 -6
- package/src/user-data-rights/handlers/lift-restriction.write.ts +33 -2
- package/src/user-data-rights/handlers/list-download-attempts.query.ts +1 -0
- package/src/user-data-rights/handlers/my-audit-log.query.ts +1 -0
- package/src/user-data-rights/handlers/restrict-account.write.ts +33 -4
- package/src/user-data-rights/handlers/run-forget-cleanup.write.ts +2 -0
- package/src/user-data-rights/lib/is-admin-actor.ts +19 -0
- package/src/user-data-rights/run-forget-cleanup.ts +27 -7
|
@@ -10,13 +10,22 @@ import {
|
|
|
10
10
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
11
11
|
import { createFilesFeature } from "@cosmicdrift/kumiko-framework/files";
|
|
12
12
|
import { z } from "zod";
|
|
13
|
-
import {
|
|
13
|
+
import { authFoundationFeature } from "../../auth-foundation";
|
|
14
14
|
import { createComplianceProfilesFeature } from "../../compliance-profiles/feature";
|
|
15
15
|
import { createConfigFeature } from "../../config/feature";
|
|
16
16
|
import { createDataRetentionFeature } from "../../data-retention/feature";
|
|
17
|
+
import { createPersonalAccessTokensFeature } from "../../personal-access-tokens";
|
|
17
18
|
import { createSessionsFeature } from "../../sessions/feature";
|
|
18
19
|
import { createTenantFeature } from "../../tenant/feature";
|
|
19
20
|
import { createTenantLifecycleFeature } from "../../tenant-lifecycle/feature";
|
|
21
|
+
|
|
22
|
+
function projectionProbeTable(name: string): SchemaTable {
|
|
23
|
+
return table(name, {
|
|
24
|
+
id: uuid("id").primaryKey(),
|
|
25
|
+
count: integer("count").notNull().default(0),
|
|
26
|
+
}) as unknown as SchemaTable; // @cast-boundary test-fixture — minimal probe shape, real schema-table irrelevant here
|
|
27
|
+
}
|
|
28
|
+
|
|
20
29
|
import { createUserFeature } from "../../user/feature";
|
|
21
30
|
import { createUserDataRightsDefaultsFeature } from "../../user-data-rights-defaults/feature";
|
|
22
31
|
import { createUserDataRightsFeature } from "../feature";
|
|
@@ -30,18 +39,13 @@ import { createUserDataRightsFeature } from "../feature";
|
|
|
30
39
|
// Unlike the old direct-function tests, these only run inside a real
|
|
31
40
|
// validateBoot() pass — exercising the actual `r.bootCheck()` plumbing, not
|
|
32
41
|
// just the guard logic.
|
|
33
|
-
// Stub instead of the real auth-foundation — these boot-checks are about
|
|
34
|
-
// GDPR-storage guards, not auth; only sessions' sessionStore registration
|
|
35
|
-
// needs an owner present.
|
|
36
|
-
const sessionStoreOwnerStub = defineFeature("auth-foundation", (r) => {
|
|
37
|
-
r.extendsRegistrar(EXT_SESSION_STORE, {});
|
|
38
|
-
});
|
|
39
|
-
|
|
40
42
|
function baseFeatures() {
|
|
41
43
|
return [
|
|
42
44
|
createConfigFeature(),
|
|
43
45
|
createUserFeature(),
|
|
44
|
-
|
|
46
|
+
createTenantFeature(),
|
|
47
|
+
authFoundationFeature,
|
|
48
|
+
createPersonalAccessTokensFeature({ scopes: {} }),
|
|
45
49
|
createSessionsFeature(),
|
|
46
50
|
createDataRetentionFeature(),
|
|
47
51
|
createComplianceProfilesFeature(),
|
|
@@ -84,10 +88,7 @@ describe("GDPR-storage boot guards V2-V4 (via r.bootCheck)", () => {
|
|
|
84
88
|
});
|
|
85
89
|
|
|
86
90
|
test("V3: pii field on a projection-only entity (no r.entity) is still caught", () => {
|
|
87
|
-
const projectionTable =
|
|
88
|
-
id: uuid("id").primaryKey(),
|
|
89
|
-
count: integer("count").notNull().default(0),
|
|
90
|
-
}) as unknown as SchemaTable;
|
|
91
|
+
const projectionTable = projectionProbeTable("crm_contact_summary");
|
|
91
92
|
const bad = defineFeature("crm", (r) => {
|
|
92
93
|
const contactCreated = r.defineEvent("contact-created", z.unknown());
|
|
93
94
|
r.projection({
|
|
@@ -102,10 +103,7 @@ describe("GDPR-storage boot guards V2-V4 (via r.bootCheck)", () => {
|
|
|
102
103
|
});
|
|
103
104
|
|
|
104
105
|
test("V3: projection-only pii entity WITH EXT_USER_DATA hook registered for its entity name → no throw", () => {
|
|
105
|
-
const projectionTable =
|
|
106
|
-
id: uuid("id").primaryKey(),
|
|
107
|
-
count: integer("count").notNull().default(0),
|
|
108
|
-
}) as unknown as SchemaTable;
|
|
106
|
+
const projectionTable = projectionProbeTable("crm_contact_summary_hooked");
|
|
109
107
|
const hooked = defineFeature("crm-hooked", (r) => {
|
|
110
108
|
r.requires("user-data-rights");
|
|
111
109
|
const contactCreated = r.defineEvent("contact-hooked-created", z.unknown());
|
|
@@ -155,11 +155,15 @@ describe("download-by-token under resolverTrust: authoritative, no defaultTenant
|
|
|
155
155
|
}
|
|
156
156
|
|
|
157
157
|
const res = await stack.app.fetch(
|
|
158
|
-
new Request(
|
|
158
|
+
new Request("http://test/user-export/by-token", {
|
|
159
|
+
method: "POST",
|
|
160
|
+
headers: { "content-type": "application/json" },
|
|
161
|
+
body: JSON.stringify({ token: plainToken }),
|
|
162
|
+
}),
|
|
159
163
|
);
|
|
160
|
-
expect(res.status).toBe(
|
|
161
|
-
const
|
|
162
|
-
expect(
|
|
163
|
-
expect(
|
|
164
|
+
expect(res.status).toBe(200);
|
|
165
|
+
const body = (await res.json()) as { data?: { url?: string } };
|
|
166
|
+
expect(body.data?.url).toMatch(/^memory:\/\//);
|
|
167
|
+
expect(body.data?.url).toContain(`${jobTenant}/exports/${jobId}.zip`);
|
|
164
168
|
});
|
|
165
169
|
});
|
|
@@ -437,8 +437,8 @@ describe("download-by-job :: happy path", () => {
|
|
|
437
437
|
});
|
|
438
438
|
|
|
439
439
|
describe("r.httpRoute :: /user-export/by-token (Magic-Link e2e)", () => {
|
|
440
|
-
test("
|
|
441
|
-
const { plainToken } = await seedDoneJobWithToken();
|
|
440
|
+
test("`?token=` in query is ignored — serves interstitial HTML instead of resolving it (no access-log leak)", async () => {
|
|
441
|
+
const { jobId, plainToken } = await seedDoneJobWithToken();
|
|
442
442
|
|
|
443
443
|
const res = await stack.app.fetch(
|
|
444
444
|
new Request(`http://test/user-export/by-token?token=${plainToken}`, {
|
|
@@ -449,21 +449,15 @@ describe("r.httpRoute :: /user-export/by-token (Magic-Link e2e)", () => {
|
|
|
449
449
|
},
|
|
450
450
|
}),
|
|
451
451
|
);
|
|
452
|
-
expect(res.status).toBe(
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
expect(
|
|
456
|
-
});
|
|
452
|
+
expect(res.status).toBe(200);
|
|
453
|
+
expect(res.headers.get("content-type")).toContain("text/html");
|
|
454
|
+
const body = await res.text();
|
|
455
|
+
expect(body).toContain("/user-export/by-token.js");
|
|
457
456
|
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
}),
|
|
463
|
-
);
|
|
464
|
-
expect(res.status).toBe(404);
|
|
465
|
-
const body = (await res.json()) as { error?: { i18nKey?: string } };
|
|
466
|
-
expect(body.error?.i18nKey).toBe("userDataRights.errors.download.notFound");
|
|
457
|
+
const [row] = (await selectMany(stack.db, exportDownloadTokensTable, { jobId })) as Array<{
|
|
458
|
+
useCount: number;
|
|
459
|
+
}>;
|
|
460
|
+
expect(row?.useCount).toBe(0);
|
|
467
461
|
});
|
|
468
462
|
|
|
469
463
|
test("no query token (fragment-Pfad) → serviert Interstitial-HTML statt 400", async () => {
|
|
@@ -487,29 +481,6 @@ describe("r.httpRoute :: /user-export/by-token (Magic-Link e2e)", () => {
|
|
|
487
481
|
expect(body).toContain("/user-export/by-token");
|
|
488
482
|
});
|
|
489
483
|
|
|
490
|
-
test("Audit-Update: useCount + IP/UA aus httpRoute-Headers (nicht aus payload)", async () => {
|
|
491
|
-
const { jobId, plainToken } = await seedDoneJobWithToken();
|
|
492
|
-
await stack.app.fetch(
|
|
493
|
-
new Request(`http://test/user-export/by-token?token=${plainToken}`, {
|
|
494
|
-
method: "GET",
|
|
495
|
-
headers: {
|
|
496
|
-
"user-agent": "e2e-test/2.0",
|
|
497
|
-
"x-forwarded-for": "198.51.100.7, 10.0.0.1",
|
|
498
|
-
},
|
|
499
|
-
}),
|
|
500
|
-
);
|
|
501
|
-
|
|
502
|
-
const [row] = (await selectMany(stack.db, exportDownloadTokensTable, { jobId })) as Array<{
|
|
503
|
-
useCount: number;
|
|
504
|
-
lastUsedFromIp: string | null;
|
|
505
|
-
lastUsedUserAgent: string | null;
|
|
506
|
-
}>;
|
|
507
|
-
expect(row?.useCount).toBe(1);
|
|
508
|
-
// X-Forwarded-For: erster Wert, comma-trimmed
|
|
509
|
-
expect(row?.lastUsedFromIp).toBe("198.51.100.7");
|
|
510
|
-
expect(row?.lastUsedUserAgent).toBe("e2e-test/2.0");
|
|
511
|
-
});
|
|
512
|
-
|
|
513
484
|
test("POST-Exchange (Fragment-Pfad): happy-path + IP/UA-Audit aus httpRoute-Headers", async () => {
|
|
514
485
|
const { jobId, plainToken } = await seedDoneJobWithToken();
|
|
515
486
|
|
|
@@ -550,6 +521,19 @@ describe("r.httpRoute :: /user-export/by-token (Magic-Link e2e)", () => {
|
|
|
550
521
|
const body = (await res.json()) as { error?: string };
|
|
551
522
|
expect(body.error).toBe("missing_token");
|
|
552
523
|
});
|
|
524
|
+
|
|
525
|
+
test("POST-Exchange: invalid token → 404 + download.notFound i18nKey", async () => {
|
|
526
|
+
const res = await stack.app.fetch(
|
|
527
|
+
new Request("http://test/user-export/by-token", {
|
|
528
|
+
method: "POST",
|
|
529
|
+
headers: { "content-type": "application/json" },
|
|
530
|
+
body: JSON.stringify({ token: "fake-xxxxx" }),
|
|
531
|
+
}),
|
|
532
|
+
);
|
|
533
|
+
expect(res.status).toBe(404);
|
|
534
|
+
const body = (await res.json()) as { error: { i18nKey: string } };
|
|
535
|
+
expect(body.error.i18nKey).toBe("userDataRights.errors.download.notFound");
|
|
536
|
+
});
|
|
553
537
|
});
|
|
554
538
|
|
|
555
539
|
describe("download-by-job :: cross-user + cross-tenant", () => {
|
|
@@ -569,8 +553,8 @@ describe("download-by-job :: cross-user + cross-tenant", () => {
|
|
|
569
553
|
});
|
|
570
554
|
|
|
571
555
|
test("provider ohne getSignedUrl → 422 unprocessable signedUrlNotSupported (job-Pfad)", async () => {
|
|
572
|
-
//
|
|
573
|
-
//
|
|
556
|
+
// Same as the token path: the operator misconfig must also surface as
|
|
557
|
+
// 422 on the UI click path, not 404.
|
|
574
558
|
const { jobId } = await seedDoneJobWithToken();
|
|
575
559
|
await stack.http.writeOk(
|
|
576
560
|
ConfigHandlers.set,
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { access,
|
|
2
|
+
import { access, validateBoot } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
3
|
import { createFilesFeature } from "@cosmicdrift/kumiko-framework/files";
|
|
4
|
-
import {
|
|
4
|
+
import { authFoundationFeature } from "../../auth-foundation";
|
|
5
5
|
import { createComplianceProfilesFeature } from "../../compliance-profiles/feature";
|
|
6
6
|
import { createConfigFeature } from "../../config/feature";
|
|
7
7
|
import { createDataRetentionFeature } from "../../data-retention/feature";
|
|
8
|
+
import { createPersonalAccessTokensFeature } from "../../personal-access-tokens";
|
|
8
9
|
import { createSessionsFeature } from "../../sessions/feature";
|
|
10
|
+
import { createTenantFeature } from "../../tenant/feature";
|
|
9
11
|
import { createUserFeature } from "../../user/feature";
|
|
10
12
|
import { createUserDataRightsDefaultsFeature } from "../../user-data-rights-defaults/feature";
|
|
11
13
|
import { createUserDataRightsFeature } from "../feature";
|
|
@@ -18,13 +20,6 @@ import { createUserDataRightsFeature } from "../feature";
|
|
|
18
20
|
// entities (export-job, download-attempt) are r.entity, not direct-write stores,
|
|
19
21
|
// so an entityList binding is rebuild-safe (unlike jobs/sessions read-models).
|
|
20
22
|
|
|
21
|
-
// Stub instead of the real auth-foundation — this test is about screen
|
|
22
|
-
// wiring, not auth; only sessions' sessionStore registration needs an owner
|
|
23
|
-
// present.
|
|
24
|
-
const sessionStoreOwnerStub = defineFeature("auth-foundation", (r) => {
|
|
25
|
-
r.extendsRegistrar(EXT_SESSION_STORE, {});
|
|
26
|
-
});
|
|
27
|
-
|
|
28
23
|
describe("user-data-rights read-only inspector screens", () => {
|
|
29
24
|
// A realistic assembly: mounting user-data-rights without its default hooks
|
|
30
25
|
// now fails the V3 boot gate (the framework `user` entity holds PII with no
|
|
@@ -33,7 +28,9 @@ describe("user-data-rights read-only inspector screens", () => {
|
|
|
33
28
|
const features = [
|
|
34
29
|
createConfigFeature(),
|
|
35
30
|
createUserFeature(),
|
|
36
|
-
|
|
31
|
+
createTenantFeature(),
|
|
32
|
+
authFoundationFeature,
|
|
33
|
+
createPersonalAccessTokensFeature({ scopes: {} }),
|
|
37
34
|
createSessionsFeature(),
|
|
38
35
|
createDataRetentionFeature(),
|
|
39
36
|
createComplianceProfilesFeature(),
|
|
@@ -123,7 +123,21 @@ describe("C6 default mail bridge :: forget cron sends deletion-executed without
|
|
|
123
123
|
expect(job).toBeDefined();
|
|
124
124
|
|
|
125
125
|
// EXAKT der prod-Job-Kontext: configResolver gesetzt, config undefined.
|
|
126
|
-
|
|
126
|
+
// log required since #1572 persists incomplete[] via ctx.log.warn.
|
|
127
|
+
const jobCtx = {
|
|
128
|
+
db: stack.db,
|
|
129
|
+
registry: stack.registry,
|
|
130
|
+
configResolver,
|
|
131
|
+
log: {
|
|
132
|
+
info() {},
|
|
133
|
+
warn() {},
|
|
134
|
+
error() {},
|
|
135
|
+
debug() {},
|
|
136
|
+
child() {
|
|
137
|
+
return this;
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
};
|
|
127
141
|
await job?.handler({}, jobCtx as never);
|
|
128
142
|
|
|
129
143
|
// Loeschung lief autonom durch.
|
|
@@ -45,6 +45,10 @@ import { createSessionsFeature } from "../../sessions";
|
|
|
45
45
|
import { SessionHandlers } from "../../sessions/constants";
|
|
46
46
|
import { userSessionEntity, userSessionTable } from "../../sessions/schema/user-session";
|
|
47
47
|
import { createSessionCallbacks, type SessionCallbacks } from "../../sessions/session-callbacks";
|
|
48
|
+
import {
|
|
49
|
+
SESSION_REVOKED_EVENT_QN,
|
|
50
|
+
sessionRevokedSchema,
|
|
51
|
+
} from "../../sessions/session-revoked-event";
|
|
48
52
|
import { sessionCallbacksFromLateBound, withMintedSession } from "../../sessions/testing";
|
|
49
53
|
import { hashPassword } from "../../shared";
|
|
50
54
|
import { createTenantFeature, tenantMembershipsTable } from "../../tenant";
|
|
@@ -211,6 +215,36 @@ describe("S2.U6 :: restrict-account state-transitions", () => {
|
|
|
211
215
|
expect(userRow[0]?.status).toBe(USER_STATUS.Active);
|
|
212
216
|
});
|
|
213
217
|
|
|
218
|
+
// kumiko-framework#1545: mirrors the lift-restriction cross-tenant guard
|
|
219
|
+
// below — an isAdminActor check alone lets an Admin/TenantAdmin from a
|
|
220
|
+
// DIFFERENT tenant restrict (and force-revoke the sessions of) a user
|
|
221
|
+
// who was never a member of the admin's tenant.
|
|
222
|
+
test("Admin aus fremdem Tenant: 403 target_user_not_in_admin_tenant, Status bleibt Active", async () => {
|
|
223
|
+
const { userId } = await seedAliceWithMembership();
|
|
224
|
+
const otherTenantAdmin = await mintActor(TestUsers.otherTenant);
|
|
225
|
+
|
|
226
|
+
const err = await stack.http.writeErr(RESTRICT, { userId }, otherTenantAdmin);
|
|
227
|
+
expect(err.httpStatus).toBe(403);
|
|
228
|
+
expect(reasonOf(err)).toBe("target_user_not_in_admin_tenant");
|
|
229
|
+
|
|
230
|
+
const userRow = (await selectMany(stack.db, userTable, { id: userId })) as Array<{
|
|
231
|
+
status: string;
|
|
232
|
+
}>;
|
|
233
|
+
expect(userRow[0]?.status).toBe(USER_STATUS.Active);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test("SystemAdmin darf tenant-übergreifend restricten (platform-weiter Operator)", async () => {
|
|
237
|
+
const { userId } = await seedAliceWithMembership();
|
|
238
|
+
const systemAdmin = await mintActor({
|
|
239
|
+
id: crypto.randomUUID(),
|
|
240
|
+
tenantId: testTenantId(2),
|
|
241
|
+
roles: ["SystemAdmin"],
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
const result = await stack.http.writeOk<{ status: string }>(RESTRICT, { userId }, systemAdmin);
|
|
245
|
+
expect(result.status).toBe(USER_STATUS.Restricted);
|
|
246
|
+
});
|
|
247
|
+
|
|
214
248
|
test("Restricted → Restricted (Idempotenz-Guard): 422 already_restricted", async () => {
|
|
215
249
|
const { userId } = await seedAliceWithMembership(USER_STATUS.Restricted);
|
|
216
250
|
// Alice's own session is blocked while Restricted — an admin targets
|
|
@@ -227,6 +261,51 @@ describe("S2.U6 :: restrict-account state-transitions", () => {
|
|
|
227
261
|
const err = await stack.http.writeErr(RESTRICT, {}, aliceUser);
|
|
228
262
|
expect(reasonOf(err)).toBe("user_not_in_active_state");
|
|
229
263
|
});
|
|
264
|
+
|
|
265
|
+
// kumiko-framework#1540: access.admin includes TenantAdmin/Admin, which
|
|
266
|
+
// are tenant-scoped roles, but the User-entity lookup uses ctx.db.raw
|
|
267
|
+
// (no auto-tenant-filter — User status is intentionally global). Without
|
|
268
|
+
// a membership check, an Admin from a DIFFERENT tenant than the target
|
|
269
|
+
// user could still pass the isAdminActor gate and flip the target's
|
|
270
|
+
// status. Mirrors the sibling lift-restriction fix (kumiko-framework#1342).
|
|
271
|
+
test("Admin aus fremdem Tenant: 403 target_user_not_in_admin_tenant, Status bleibt Active", async () => {
|
|
272
|
+
const { userId: targetId } = await seedAliceWithMembership();
|
|
273
|
+
const otherTenantAdmin = await mintActor(TestUsers.otherTenant);
|
|
274
|
+
|
|
275
|
+
const err = await stack.http.writeErr(RESTRICT, { userId: targetId }, otherTenantAdmin);
|
|
276
|
+
expect(err.httpStatus).toBe(403);
|
|
277
|
+
expect(reasonOf(err)).toBe("target_user_not_in_admin_tenant");
|
|
278
|
+
|
|
279
|
+
const userRow = (await selectMany(stack.db, userTable, { id: targetId })) as Array<{
|
|
280
|
+
status: string;
|
|
281
|
+
}>;
|
|
282
|
+
expect(userRow[0]?.status).toBe(USER_STATUS.Active);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
test("SystemAdmin darf tenant-übergreifend restricten (platform-weiter Operator) + revoked Sessions", async () => {
|
|
286
|
+
const { userId: targetId } = await seedAliceWithMembership();
|
|
287
|
+
const loginRes = await login(ALICE_EMAIL, ALICE_PW);
|
|
288
|
+
expect(loginRes.status).toBe(200);
|
|
289
|
+
|
|
290
|
+
const systemAdmin = await mintActor({
|
|
291
|
+
id: crypto.randomUUID(),
|
|
292
|
+
tenantId: testTenantId(2),
|
|
293
|
+
roles: ["SystemAdmin"],
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
const result = await stack.http.writeOk<{ userId: string; status: string }>(
|
|
297
|
+
RESTRICT,
|
|
298
|
+
{ userId: targetId },
|
|
299
|
+
systemAdmin,
|
|
300
|
+
);
|
|
301
|
+
expect(result.status).toBe(USER_STATUS.Restricted);
|
|
302
|
+
|
|
303
|
+
const sessionsAfter = (await selectMany(stack.db, userSessionTable, {
|
|
304
|
+
userId: targetId,
|
|
305
|
+
})) as Array<{ revokedAt: unknown }>;
|
|
306
|
+
expect(sessionsAfter.length).toBeGreaterThanOrEqual(1);
|
|
307
|
+
expect(sessionsAfter.every((s) => s.revokedAt !== null)).toBe(true);
|
|
308
|
+
});
|
|
230
309
|
});
|
|
231
310
|
|
|
232
311
|
describe("S2.U6 :: lift-restriction state-transitions", () => {
|
|
@@ -258,6 +337,37 @@ describe("S2.U6 :: lift-restriction state-transitions", () => {
|
|
|
258
337
|
const err = await stack.http.writeErr(LIFT, { userId }, admin);
|
|
259
338
|
expect(reasonOf(err)).toBe("not_restricted");
|
|
260
339
|
});
|
|
340
|
+
|
|
341
|
+
// kumiko-framework#1342: access.admin includes TenantAdmin/Admin, which
|
|
342
|
+
// are tenant-scoped roles, but the User-entity lookup uses ctx.db.raw
|
|
343
|
+
// (no auto-tenant-filter — User status is intentionally global). Without
|
|
344
|
+
// a membership check, an Admin from a DIFFERENT tenant than the target
|
|
345
|
+
// user could still pass the access-gate and flip the target's status.
|
|
346
|
+
test("Admin aus fremdem Tenant: 403 target_user_not_in_admin_tenant, Status bleibt Restricted", async () => {
|
|
347
|
+
const { userId } = await seedAliceWithMembership(USER_STATUS.Restricted);
|
|
348
|
+
const otherTenantAdmin = await mintActor(TestUsers.otherTenant);
|
|
349
|
+
|
|
350
|
+
const err = await stack.http.writeErr(LIFT, { userId }, otherTenantAdmin);
|
|
351
|
+
expect(err.httpStatus).toBe(403);
|
|
352
|
+
expect(reasonOf(err)).toBe("target_user_not_in_admin_tenant");
|
|
353
|
+
|
|
354
|
+
const userRow = (await selectMany(stack.db, userTable, { id: userId })) as Array<{
|
|
355
|
+
status: string;
|
|
356
|
+
}>;
|
|
357
|
+
expect(userRow[0]?.status).toBe(USER_STATUS.Restricted);
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
test("SystemAdmin darf tenant-übergreifend liften (platform-weiter Operator)", async () => {
|
|
361
|
+
const { userId } = await seedAliceWithMembership(USER_STATUS.Restricted);
|
|
362
|
+
const systemAdmin = await mintActor({
|
|
363
|
+
id: crypto.randomUUID(),
|
|
364
|
+
tenantId: testTenantId(2),
|
|
365
|
+
roles: ["SystemAdmin"],
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
const result = await stack.http.writeOk<{ status: string }>(LIFT, { userId }, systemAdmin);
|
|
369
|
+
expect(result.status).toBe(USER_STATUS.Active);
|
|
370
|
+
});
|
|
261
371
|
});
|
|
262
372
|
|
|
263
373
|
describe("S2.U6 :: Login-Block fuer Restricted/DeletionRequested/Deleted", () => {
|
|
@@ -305,6 +415,26 @@ describe("S2.U6 :: Login-Block fuer Restricted/DeletionRequested/Deleted", () =>
|
|
|
305
415
|
});
|
|
306
416
|
|
|
307
417
|
describe("S2.U6 :: Cross-Feature sessions.revokeAllForUser direct", () => {
|
|
418
|
+
test("Privileged revoke-all-for-user with no live sessions emits no event", async () => {
|
|
419
|
+
const { userId } = await seedAliceWithMembership();
|
|
420
|
+
const systemUser = await mintActor({
|
|
421
|
+
id: "00000000-0000-4000-8000-000000000000",
|
|
422
|
+
tenantId: TENANT,
|
|
423
|
+
roles: ["SystemAdmin"],
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
const result = await stack.http.writeOk<{ count: number; userId: string }>(
|
|
427
|
+
SessionHandlers.revokeAllForUser,
|
|
428
|
+
{ userId },
|
|
429
|
+
systemUser,
|
|
430
|
+
);
|
|
431
|
+
expect(result.count).toBe(0);
|
|
432
|
+
expect(result.userId).toBe(userId);
|
|
433
|
+
|
|
434
|
+
const events = await selectMany(stack.db, eventsTable, { type: SESSION_REVOKED_EVENT_QN });
|
|
435
|
+
expect(events).toHaveLength(0);
|
|
436
|
+
});
|
|
437
|
+
|
|
308
438
|
test("Privileged-Caller revoked alle live sessions eines Users", async () => {
|
|
309
439
|
const { userId } = await seedAliceWithMembership();
|
|
310
440
|
// 2 Sessions erzeugen via Login + zweiter Login.
|
|
@@ -337,5 +467,15 @@ describe("S2.U6 :: Cross-Feature sessions.revokeAllForUser direct", () => {
|
|
|
337
467
|
revokedAt: unknown;
|
|
338
468
|
}>;
|
|
339
469
|
expect(revoked.every((s) => s.revokedAt !== null)).toBe(true);
|
|
470
|
+
|
|
471
|
+
// #1559 — cross-tenant privileged revoke uses the low-level append(),
|
|
472
|
+
// not ctx.unsafeAppendEvent (see revoke-all-for-user.write.ts for why:
|
|
473
|
+
// the SystemAdmin caller's own tenantId has no relationship to the
|
|
474
|
+
// target user's session tenant). One event, both sids listed.
|
|
475
|
+
const events = await selectMany(stack.db, eventsTable, { type: SESSION_REVOKED_EVENT_QN });
|
|
476
|
+
expect(events).toHaveLength(1);
|
|
477
|
+
const payload = sessionRevokedSchema.parse(events[0]?.["payload"]);
|
|
478
|
+
expect(payload.userId).toBe(userId);
|
|
479
|
+
expect(new Set(payload.sessionIds)).toEqual(new Set(liveBefore.map((s) => s.id)));
|
|
340
480
|
});
|
|
341
481
|
});
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
// downloadStorageKey wird genullt + storage-key geloescht
|
|
13
13
|
// 6. Idempotency: 2× run → kein Re-Processing von done/failed-Jobs
|
|
14
14
|
|
|
15
|
-
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
15
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, spyOn, test } from "bun:test";
|
|
16
16
|
import { spawn } from "node:child_process";
|
|
17
17
|
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
18
18
|
import { tmpdir } from "node:os";
|
|
@@ -818,7 +818,10 @@ describe("runExportJobs :: Atom 4a download-tokens", () => {
|
|
|
818
818
|
const calls: string[] = [];
|
|
819
819
|
// Should not throw despite job A's callback throwing — the try/catch
|
|
820
820
|
// around fireExportFailedCallback swallows it (operator-visibility
|
|
821
|
-
// via console.warn only) and the loop continues to job B.
|
|
821
|
+
// via console.warn only) and the loop continues to job B. A regress to
|
|
822
|
+
// a fully silent swallow (no log call at all) would still pass every
|
|
823
|
+
// assertion below unless the warn itself is pinned.
|
|
824
|
+
const warnSpy = spyOn(console, "warn").mockImplementation(() => {});
|
|
822
825
|
const result = await runExportJobs({
|
|
823
826
|
db: stack.db,
|
|
824
827
|
registry: stack.registry,
|
|
@@ -833,6 +836,8 @@ describe("runExportJobs :: Atom 4a download-tokens", () => {
|
|
|
833
836
|
});
|
|
834
837
|
expect(result.failedJobIds).toEqual(expect.arrayContaining([jobAId, jobBId]));
|
|
835
838
|
expect(calls.sort()).toEqual([jobAId, jobBId].sort());
|
|
839
|
+
expect(warnSpy).toHaveBeenCalled();
|
|
840
|
+
warnSpy.mockRestore();
|
|
836
841
|
});
|
|
837
842
|
|
|
838
843
|
test("failed-Job, user ohne email → sendExportFailedEmail skipped (kein Throw)", async () => {
|
|
@@ -840,7 +845,7 @@ describe("runExportJobs :: Atom 4a download-tokens", () => {
|
|
|
840
845
|
await seedRow(stack.db, userTable, {
|
|
841
846
|
id: String(aliceUser.id),
|
|
842
847
|
tenantId: tenantA,
|
|
843
|
-
email: ""
|
|
848
|
+
email: "", // empty string — lookupUserEmail returns null
|
|
844
849
|
passwordHash: "hashed",
|
|
845
850
|
displayName: "Alice",
|
|
846
851
|
locale: "de",
|
|
@@ -1270,7 +1275,7 @@ describe("runExportJobs :: Atom 5 notification-callbacks", () => {
|
|
|
1270
1275
|
await seedRow(stack.db, userTable, {
|
|
1271
1276
|
id: String(aliceUser.id),
|
|
1272
1277
|
tenantId: tenantA,
|
|
1273
|
-
email: "" as string, // empty string — lookupUserEmail
|
|
1278
|
+
email: "" as string, // empty string — lookupUserEmail returns null
|
|
1274
1279
|
passwordHash: "h",
|
|
1275
1280
|
displayName: "Alice",
|
|
1276
1281
|
locale: "de",
|
|
@@ -282,6 +282,22 @@ describe("run-forget-cleanup :: registered cron (autonomous Art.17)", () => {
|
|
|
282
282
|
// getJob undefined und die Loeschung laeuft nach Grace-Ablauf NIE. Wir
|
|
283
283
|
// treiben den ECHTEN registrierten Job durch den echten JobContext (db +
|
|
284
284
|
// registry, kein config) — nicht den inneren runForgetCleanup-Helper.
|
|
285
|
+
function capturingJobLog() {
|
|
286
|
+
const warns: string[] = [];
|
|
287
|
+
const log: JobContext["log"] = {
|
|
288
|
+
info() {},
|
|
289
|
+
warn(message: string) {
|
|
290
|
+
warns.push(message);
|
|
291
|
+
},
|
|
292
|
+
error() {},
|
|
293
|
+
debug() {},
|
|
294
|
+
child(): JobContext["log"] {
|
|
295
|
+
return this;
|
|
296
|
+
},
|
|
297
|
+
};
|
|
298
|
+
return { log, warns };
|
|
299
|
+
}
|
|
300
|
+
|
|
285
301
|
test("registered job exists + erases through the real JobContext", async () => {
|
|
286
302
|
const job = stack.registry.getJob("user-data-rights:job:run-forget-cleanup");
|
|
287
303
|
expect(job).toBeTruthy();
|
|
@@ -295,10 +311,8 @@ describe("run-forget-cleanup :: registered cron (autonomous Art.17)", () => {
|
|
|
295
311
|
});
|
|
296
312
|
await seedMembership(CRON_USER, TENANT_A);
|
|
297
313
|
|
|
298
|
-
const
|
|
299
|
-
|
|
300
|
-
// systemUser/log/triggeredBy) — double-cast at this test boundary since
|
|
301
|
-
// the handler under test never touches those fields.
|
|
314
|
+
const { log } = capturingJobLog();
|
|
315
|
+
const jobCtx = { db: stack.db, registry: stack.registry, log };
|
|
302
316
|
await job?.handler({}, jobCtx as unknown as JobContext);
|
|
303
317
|
|
|
304
318
|
const row = await fetchUser(CRON_USER);
|
|
@@ -306,6 +320,52 @@ describe("run-forget-cleanup :: registered cron (autonomous Art.17)", () => {
|
|
|
306
320
|
expect(row?.email).not.toContain("cron-delete@example.com");
|
|
307
321
|
expect(row?.email).toContain("anonymized.invalid");
|
|
308
322
|
});
|
|
323
|
+
|
|
324
|
+
test("incomplete hooks are written to ctx.log (job-run audit trail, #1572)", async () => {
|
|
325
|
+
const job = stack.registry.getJob("user-data-rights:job:run-forget-cleanup");
|
|
326
|
+
expect(job).toBeTruthy();
|
|
327
|
+
|
|
328
|
+
await seedUser(ALICE_ID, {
|
|
329
|
+
status: USER_STATUS.DeletionRequested,
|
|
330
|
+
gracePeriodEnd: instantFromOffsetMs(-60 * 1000),
|
|
331
|
+
});
|
|
332
|
+
await seedMembership(ALICE_ID, TENANT_A);
|
|
333
|
+
|
|
334
|
+
const usages = stack.registry.getExtensionUsages("userData");
|
|
335
|
+
const userUsage = usages.find((u) => u.entityName === "user");
|
|
336
|
+
if (!userUsage?.options) throw new Error("user usage not found");
|
|
337
|
+
type DeleteHook = (
|
|
338
|
+
ctx: { userId: string; tenantId: string; db: unknown },
|
|
339
|
+
strategy: string,
|
|
340
|
+
) => Promise<undefined | { status: "ok" } | { status: "incomplete"; reason: string }>;
|
|
341
|
+
const originalUserDelete = (userUsage.options as { delete: DeleteHook }).delete;
|
|
342
|
+
(userUsage.options as { delete: DeleteHook }).delete = async (ctx, strategy) => {
|
|
343
|
+
if (ctx.userId === ALICE_ID) {
|
|
344
|
+
return { status: "incomplete", reason: "synthetic partial cleanup for alice" };
|
|
345
|
+
}
|
|
346
|
+
return originalUserDelete(ctx, strategy);
|
|
347
|
+
};
|
|
348
|
+
|
|
349
|
+
const { log, warns } = capturingJobLog();
|
|
350
|
+
try {
|
|
351
|
+
await job?.handler({}, {
|
|
352
|
+
db: stack.db,
|
|
353
|
+
registry: stack.registry,
|
|
354
|
+
log,
|
|
355
|
+
} as unknown as JobContext);
|
|
356
|
+
} finally {
|
|
357
|
+
(userUsage.options as { delete: DeleteHook }).delete = originalUserDelete;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const hit = warns.find((w) => w.includes("incomplete hook") && w.includes(ALICE_ID));
|
|
361
|
+
expect(hit).toBeDefined();
|
|
362
|
+
expect(hit).toContain("entityName=user");
|
|
363
|
+
expect(hit).toContain("rolledBack=false");
|
|
364
|
+
expect(hit).toContain("reason=synthetic partial cleanup for alice");
|
|
365
|
+
|
|
366
|
+
const aliceRow = await fetchUser(ALICE_ID);
|
|
367
|
+
expect(aliceRow?.status).toBe(USER_STATUS.Deleted);
|
|
368
|
+
});
|
|
309
369
|
});
|
|
310
370
|
|
|
311
371
|
describe("runForgetCleanup :: time-window guards", () => {
|
|
@@ -798,6 +858,7 @@ describe("runForgetCleanup :: per-User-Sub-Tx-Isolation (advisor-pinned Architek
|
|
|
798
858
|
expect(aliceIncomplete?.reason).toBe("synthetic partial cleanup for alice");
|
|
799
859
|
expect(aliceIncomplete?.tenantId).toBe(TENANT_A);
|
|
800
860
|
expect(aliceIncomplete?.entityName).toBe("user");
|
|
861
|
+
expect(aliceIncomplete?.rolledBack).toBe(false);
|
|
801
862
|
|
|
802
863
|
// Status flip proceeded despite the incomplete report.
|
|
803
864
|
const aliceRow = await fetchUser(ALICE_ID);
|
|
@@ -814,7 +875,7 @@ describe("runForgetCleanup :: per-User-Sub-Tx-Isolation (advisor-pinned Architek
|
|
|
814
875
|
}
|
|
815
876
|
});
|
|
816
877
|
|
|
817
|
-
test("hook incomplete on one tenant, then throws on a later tenant → the incomplete report
|
|
878
|
+
test("hook incomplete on one tenant, then throws on a later tenant → the incomplete report survives, flagged rolledBack (#1489)", async () => {
|
|
818
879
|
await seedUser(ALICE_ID, {
|
|
819
880
|
status: USER_STATUS.DeletionRequested,
|
|
820
881
|
gracePeriodEnd: instantFromOffsetMs(-60 * 1000),
|
|
@@ -857,9 +918,14 @@ describe("runForgetCleanup :: per-User-Sub-Tx-Isolation (advisor-pinned Architek
|
|
|
857
918
|
now: NOW(),
|
|
858
919
|
});
|
|
859
920
|
|
|
860
|
-
// The whole per-user sub-tx rolled back —
|
|
861
|
-
//
|
|
862
|
-
|
|
921
|
+
// The whole per-user sub-tx rolled back — the DB rows (including the
|
|
922
|
+
// status flip) are undone, and the failure surfaces as a hard error
|
|
923
|
+
// too. But tenant A's "incomplete" report describes an external,
|
|
924
|
+
// non-transactional side effect (already ran, not rolled back by a
|
|
925
|
+
// DB rollback) — it must still reach the operator, just marked
|
|
926
|
+
// rolledBack so they know the user itself is still DeletionRequested.
|
|
927
|
+
const aliceIncomplete = result.incomplete.find((i) => i.userId === ALICE_ID);
|
|
928
|
+
expect(aliceIncomplete?.rolledBack).toBe(true);
|
|
863
929
|
expect(result.errors.some((e) => e.userId === ALICE_ID)).toBe(true);
|
|
864
930
|
expect(result.processedUserIds).not.toContain(ALICE_ID);
|
|
865
931
|
|