@cosmicdrift/kumiko-bundled-features 0.125.2 → 0.126.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/billing-foundation/__tests__/billing-foundation.integration.test.ts +12 -1
- package/src/billing-foundation/entities.ts +2 -4
- package/src/billing-foundation/feature.ts +7 -1
- package/src/billing-foundation/tenant-destroy-hook.ts +24 -0
- package/src/subscription-mollie/__tests__/mollie-foundation.integration.test.ts +12 -1
- package/src/subscription-stripe/__tests__/stripe-foundation.integration.test.ts +9 -0
- package/src/tenant/__tests__/members-screens.boot.test.ts +7 -0
- package/src/tenant/feature.ts +1 -1
- package/src/tenant/index.ts +6 -1
- package/src/tenant/schema/tenant.ts +25 -0
- package/src/tenant-lifecycle/__tests__/stages.test.ts +22 -0
- package/src/tenant-lifecycle/__tests__/tenant-lifecycle.integration.test.ts +309 -0
- package/src/tenant-lifecycle/constants.ts +56 -0
- package/src/tenant-lifecycle/events.ts +46 -0
- package/src/tenant-lifecycle/feature.ts +104 -0
- package/src/tenant-lifecycle/handlers/cancel-destruction.write.ts +79 -0
- package/src/tenant-lifecycle/handlers/request-destruction.write.ts +81 -0
- package/src/tenant-lifecycle/index.ts +7 -0
- package/src/tenant-lifecycle/lib/revoke-tenant-sessions.ts +16 -0
- package/src/tenant-lifecycle/run-tenant-destroy.ts +357 -0
- package/src/tenant-lifecycle/stages.ts +176 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.126.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>",
|
|
@@ -62,6 +62,7 @@
|
|
|
62
62
|
"./tenant/constants": "./src/tenant/constants.ts",
|
|
63
63
|
"./tenant/web": "./src/tenant/web/index.ts",
|
|
64
64
|
"./tenant/seeding": "./src/tenant/seeding.ts",
|
|
65
|
+
"./tenant-lifecycle": "./src/tenant-lifecycle/index.ts",
|
|
65
66
|
"./tenant/testing": "./src/tenant/testing.ts",
|
|
66
67
|
"./user": "./src/user/index.ts",
|
|
67
68
|
"./user/seeding": "./src/user/seeding.ts",
|
|
@@ -106,11 +107,11 @@
|
|
|
106
107
|
"./step-dispatcher": "./src/step-dispatcher/index.ts"
|
|
107
108
|
},
|
|
108
109
|
"dependencies": {
|
|
109
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
110
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
111
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
112
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
113
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
110
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.126.0",
|
|
111
|
+
"@cosmicdrift/kumiko-framework": "0.126.0",
|
|
112
|
+
"@cosmicdrift/kumiko-headless": "0.126.0",
|
|
113
|
+
"@cosmicdrift/kumiko-renderer": "0.126.0",
|
|
114
|
+
"@cosmicdrift/kumiko-renderer-web": "0.126.0",
|
|
114
115
|
"@mollie/api-client": "^4.5.0",
|
|
115
116
|
"@node-rs/argon2": "^2.0.2",
|
|
116
117
|
"@types/nodemailer": "^8.0.0",
|
|
@@ -21,6 +21,10 @@ import {
|
|
|
21
21
|
type TestStack,
|
|
22
22
|
testTenantId,
|
|
23
23
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
24
|
+
import { createComplianceProfilesFeature } from "../../compliance-profiles";
|
|
25
|
+
import { createConfigFeature } from "../../config";
|
|
26
|
+
import { createTenantFeature } from "../../tenant/feature";
|
|
27
|
+
import { createTenantLifecycleFeature } from "../../tenant-lifecycle";
|
|
24
28
|
import { subscriptionAggregateId } from "../aggregate-id";
|
|
25
29
|
import {
|
|
26
30
|
SubscriptionEventTypes,
|
|
@@ -80,7 +84,14 @@ let db: DbConnection;
|
|
|
80
84
|
|
|
81
85
|
beforeAll(async () => {
|
|
82
86
|
stack = await setupTestStack({
|
|
83
|
-
features: [
|
|
87
|
+
features: [
|
|
88
|
+
createConfigFeature(),
|
|
89
|
+
createTenantFeature(),
|
|
90
|
+
createComplianceProfilesFeature(),
|
|
91
|
+
createTenantLifecycleFeature(),
|
|
92
|
+
billingFoundationFeature,
|
|
93
|
+
mockProviderFeature,
|
|
94
|
+
],
|
|
84
95
|
});
|
|
85
96
|
db = stack.db;
|
|
86
97
|
// subscriptionsProjectionTable wird von setupTestStack automatisch
|
|
@@ -3,8 +3,6 @@ import {
|
|
|
3
3
|
createTextField,
|
|
4
4
|
createTimestampField,
|
|
5
5
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
6
|
-
|
|
7
|
-
// =============================================================================
|
|
8
6
|
// `subscription` — current state pro Plattform-Tenant (= Read-Model)
|
|
9
7
|
// =============================================================================
|
|
10
8
|
//
|
|
@@ -41,8 +39,8 @@ export const subscriptionEntity = createEntity({
|
|
|
41
39
|
table: "read_subscriptions",
|
|
42
40
|
fields: {
|
|
43
41
|
providerName: createTextField({ required: true, maxLength: 50 }),
|
|
44
|
-
providerCustomerId: createTextField({ required: true, maxLength: 200 }),
|
|
45
|
-
providerSubscriptionId: createTextField({ required: true, maxLength: 200 }),
|
|
42
|
+
providerCustomerId: createTextField({ required: true, maxLength: 200, tenantOwned: true }),
|
|
43
|
+
providerSubscriptionId: createTextField({ required: true, maxLength: 200, tenantOwned: true }),
|
|
46
44
|
status: createTextField({ required: true, maxLength: 30 }),
|
|
47
45
|
tier: createTextField({ required: true, maxLength: 50 }),
|
|
48
46
|
currentPeriodEnd: createTimestampField({ required: true }),
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
// (z.B. für analytics: "wie viele Wechsel im Monat?"), kommt ein
|
|
42
42
|
// `subscription-provider-changed`-event-type später.
|
|
43
43
|
|
|
44
|
-
import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
44
|
+
import { defineFeature, EXT_TENANT_DATA } from "@cosmicdrift/kumiko-framework/engine";
|
|
45
45
|
import { BILLING_FOUNDATION_FEATURE, SUBSCRIPTION_PROVIDER_EXTENSION } from "./constants";
|
|
46
46
|
import {
|
|
47
47
|
INVOICE_PAID_EVENT_QN,
|
|
@@ -69,6 +69,7 @@ import {
|
|
|
69
69
|
applySubscriptionUpdated,
|
|
70
70
|
subscriptionsProjectionTable,
|
|
71
71
|
} from "./projection";
|
|
72
|
+
import { subscriptionTenantDestroyHook } from "./tenant-destroy-hook";
|
|
72
73
|
|
|
73
74
|
export const billingFoundationFeature = defineFeature(BILLING_FOUNDATION_FEATURE, (r) => {
|
|
74
75
|
r.describe(
|
|
@@ -79,6 +80,7 @@ export const billingFoundationFeature = defineFeature(BILLING_FOUNDATION_FEATURE
|
|
|
79
80
|
category: "billing",
|
|
80
81
|
recommended: false,
|
|
81
82
|
});
|
|
83
|
+
r.requires("tenant-lifecycle", "compliance-profiles");
|
|
82
84
|
// 5 fine-grained domain-events. Alle 5 nutzen denselben payload-
|
|
83
85
|
// shape (= subscription-state-snapshot); der event-type taggt was
|
|
84
86
|
// passiert ist. Future-consumer (billing-history, accounting)
|
|
@@ -124,4 +126,8 @@ export const billingFoundationFeature = defineFeature(BILLING_FOUNDATION_FEATURE
|
|
|
124
126
|
// Custom list-query auf der subscription-projection (raw drizzle-
|
|
125
127
|
// table; kein r.entity weil Schreiben via projection-apply läuft).
|
|
126
128
|
r.queryHandler(listSubscriptionsQuery);
|
|
129
|
+
|
|
130
|
+
r.useExtension(EXT_TENANT_DATA, "subscription", {
|
|
131
|
+
destroy: subscriptionTenantDestroyHook,
|
|
132
|
+
});
|
|
127
133
|
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { deleteMany, type EntityTableMeta } from "@cosmicdrift/kumiko-framework/db";
|
|
2
|
+
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
import { resolveProfileForTenant } from "../compliance-profiles";
|
|
4
|
+
import { subscriptionAggregateId } from "./aggregate-id";
|
|
5
|
+
import { subscriptionsProjectionTable } from "./projection";
|
|
6
|
+
|
|
7
|
+
/** Tenant-destroy hook for billing PII (#800). HGB keeps ciphertext row
|
|
8
|
+
* (subject-keys stage erases tenant DEK); other profiles hard-delete. */
|
|
9
|
+
export async function subscriptionTenantDestroyHook(ctx: {
|
|
10
|
+
readonly db: import("@cosmicdrift/kumiko-framework/db").DbRunner;
|
|
11
|
+
readonly tenantId: TenantId;
|
|
12
|
+
}): Promise<void> {
|
|
13
|
+
const { profile } = await resolveProfileForTenant({
|
|
14
|
+
db: ctx.db,
|
|
15
|
+
tenantId: ctx.tenantId,
|
|
16
|
+
});
|
|
17
|
+
if (profile.key === "de-hr-dsgvo-hgb") {
|
|
18
|
+
// skip: HGB retention — row stays until crypto-shredded by subject-keys stage
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
await deleteMany(ctx.db, subscriptionsProjectionTable as EntityTableMeta, {
|
|
22
|
+
id: subscriptionAggregateId(ctx.tenantId),
|
|
23
|
+
});
|
|
24
|
+
}
|
|
@@ -39,6 +39,10 @@ import type {
|
|
|
39
39
|
Subscription as MollieSubscription,
|
|
40
40
|
} from "@mollie/api-client";
|
|
41
41
|
import { Hono } from "hono";
|
|
42
|
+
import { createComplianceProfilesFeature } from "../../compliance-profiles";
|
|
43
|
+
import { createConfigFeature } from "../../config";
|
|
44
|
+
import { createTenantFeature } from "../../tenant/feature";
|
|
45
|
+
import { createTenantLifecycleFeature } from "../../tenant-lifecycle";
|
|
42
46
|
import { createSubscriptionMollieFeature } from "../feature";
|
|
43
47
|
import type { MollieClientShape } from "../verify-webhook";
|
|
44
48
|
|
|
@@ -133,7 +137,14 @@ beforeAll(async () => {
|
|
|
133
137
|
});
|
|
134
138
|
|
|
135
139
|
stack = await setupTestStack({
|
|
136
|
-
features: [
|
|
140
|
+
features: [
|
|
141
|
+
createConfigFeature(),
|
|
142
|
+
createTenantFeature(),
|
|
143
|
+
createComplianceProfilesFeature(),
|
|
144
|
+
createTenantLifecycleFeature(),
|
|
145
|
+
billingFoundationFeature,
|
|
146
|
+
mollieFeature,
|
|
147
|
+
],
|
|
137
148
|
});
|
|
138
149
|
db = stack.db;
|
|
139
150
|
// subscriptionsProjectionTable wird von setupTestStack automatisch
|
|
@@ -34,6 +34,7 @@ import {
|
|
|
34
34
|
import { createTestEnvelopeCipher } from "@cosmicdrift/kumiko-framework/testing";
|
|
35
35
|
import { Hono } from "hono";
|
|
36
36
|
import Stripe from "stripe";
|
|
37
|
+
import { createComplianceProfilesFeature } from "../../compliance-profiles";
|
|
37
38
|
import { configValuesTable, createConfigFeature } from "../../config";
|
|
38
39
|
import { createConfigAccessorFactory } from "../../config/feature";
|
|
39
40
|
import { createConfigResolver } from "../../config/resolver";
|
|
@@ -43,6 +44,8 @@ import {
|
|
|
43
44
|
type SecretsContext,
|
|
44
45
|
tenantSecretsTable,
|
|
45
46
|
} from "../../secrets";
|
|
47
|
+
import { createTenantFeature } from "../../tenant/feature";
|
|
48
|
+
import { createTenantLifecycleFeature } from "../../tenant-lifecycle";
|
|
46
49
|
import { createSubscriptionStripeFeature } from "../feature";
|
|
47
50
|
|
|
48
51
|
// Qualified-names der runtime-keys (drift-pin: müssen 1:1 dem entsprechen,
|
|
@@ -93,6 +96,9 @@ beforeAll(async () => {
|
|
|
93
96
|
features: [
|
|
94
97
|
createConfigFeature(),
|
|
95
98
|
createSecretsFeature(),
|
|
99
|
+
createTenantFeature(),
|
|
100
|
+
createComplianceProfilesFeature(),
|
|
101
|
+
createTenantLifecycleFeature(),
|
|
96
102
|
billingFoundationFeature,
|
|
97
103
|
stripeFeature,
|
|
98
104
|
],
|
|
@@ -483,6 +489,9 @@ describe("scenario 6: billing-live gate end-to-end (#104)", () => {
|
|
|
483
489
|
features: [
|
|
484
490
|
createConfigFeature(),
|
|
485
491
|
createSecretsFeature(),
|
|
492
|
+
createTenantFeature(),
|
|
493
|
+
createComplianceProfilesFeature(),
|
|
494
|
+
createTenantLifecycleFeature(),
|
|
486
495
|
billingFoundationFeature,
|
|
487
496
|
stripeFeature,
|
|
488
497
|
],
|
|
@@ -5,6 +5,7 @@ import { AuthHandlers } from "../../auth-email-password/constants";
|
|
|
5
5
|
import { createConfigFeature } from "../../config/feature";
|
|
6
6
|
import { MEMBERS_SCREEN_ID, TenantHandlers, TenantQueries } from "../constants";
|
|
7
7
|
import { createTenantFeature } from "../feature";
|
|
8
|
+
import { defaultTranslations } from "../web/i18n";
|
|
8
9
|
|
|
9
10
|
describe("tenant members screen + handler access alignment", () => {
|
|
10
11
|
const features = [createConfigFeature(), createTenantFeature()];
|
|
@@ -34,6 +35,12 @@ describe("tenant members screen + handler access alignment", () => {
|
|
|
34
35
|
void TenantQueries;
|
|
35
36
|
});
|
|
36
37
|
|
|
38
|
+
test("members nav label uses tenantClient i18n key (not feature:nav.* alias)", () => {
|
|
39
|
+
const nav = createTenantFeature().navs["members"];
|
|
40
|
+
expect(nav?.label).toBe("tenant.nav.members");
|
|
41
|
+
expect(defaultTranslations["de"]?.["tenant.nav.members"]).toBe("Team");
|
|
42
|
+
});
|
|
43
|
+
|
|
37
44
|
test("updateMemberRoles stays SystemAdmin/system-only (not on members screen)", () => {
|
|
38
45
|
const tenant = createTenantFeature();
|
|
39
46
|
expect(rolesOf(tenant.writeHandlers["updateMemberRoles"]?.access)).toEqual([
|
package/src/tenant/feature.ts
CHANGED
package/src/tenant/index.ts
CHANGED
|
@@ -9,4 +9,9 @@ export {
|
|
|
9
9
|
tenantInvitationsTable,
|
|
10
10
|
} from "./invitation-table";
|
|
11
11
|
export { tenantMembershipsTable } from "./membership-table";
|
|
12
|
-
export {
|
|
12
|
+
export {
|
|
13
|
+
TENANT_LIFECYCLE_STATUSES,
|
|
14
|
+
type TenantLifecycleStatus,
|
|
15
|
+
tenantEntity,
|
|
16
|
+
tenantTable,
|
|
17
|
+
} from "./schema/tenant";
|
|
@@ -2,9 +2,21 @@ import { buildEntityTable } from "@cosmicdrift/kumiko-framework/db";
|
|
|
2
2
|
import {
|
|
3
3
|
createBooleanField,
|
|
4
4
|
createEntity,
|
|
5
|
+
createSelectField,
|
|
5
6
|
createTextField,
|
|
7
|
+
createTimestampField,
|
|
6
8
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
7
9
|
|
|
10
|
+
export const TENANT_LIFECYCLE_STATUSES = [
|
|
11
|
+
"active",
|
|
12
|
+
"destroyRequested",
|
|
13
|
+
"destroying",
|
|
14
|
+
"destroyFailed",
|
|
15
|
+
"destroyed",
|
|
16
|
+
] as const;
|
|
17
|
+
|
|
18
|
+
export type TenantLifecycleStatus = (typeof TENANT_LIFECYCLE_STATUSES)[number];
|
|
19
|
+
|
|
8
20
|
export const tenantEntity = createEntity({
|
|
9
21
|
table: "read_tenants",
|
|
10
22
|
// tenant.id IS the tenantId-value that every other table references as FK.
|
|
@@ -14,6 +26,19 @@ export const tenantEntity = createEntity({
|
|
|
14
26
|
key: createTextField({ required: true, maxLength: 50 }),
|
|
15
27
|
name: createTextField({ required: true, maxLength: 200, searchable: true }),
|
|
16
28
|
isEnabled: createBooleanField({ default: true }),
|
|
29
|
+
// Tenant-destroy lifecycle (tenant-lifecycle feature). Defaults keep
|
|
30
|
+
// existing tenants valid when the feature is not mounted.
|
|
31
|
+
status: createSelectField({
|
|
32
|
+
options: TENANT_LIFECYCLE_STATUSES,
|
|
33
|
+
default: "active",
|
|
34
|
+
required: true,
|
|
35
|
+
filterable: true,
|
|
36
|
+
}),
|
|
37
|
+
destroyRequestedAt: createTimestampField({}),
|
|
38
|
+
destroyRequestedBy: createTextField({ maxLength: 36 }),
|
|
39
|
+
gracePeriodEnd: createTimestampField({}),
|
|
40
|
+
destroyStartedAt: createTimestampField({}),
|
|
41
|
+
destroyedAt: createTimestampField({}),
|
|
17
42
|
},
|
|
18
43
|
// tenant.key wird in Admin-URLs verwendet (`admin.<host>/<key>/...`) und
|
|
19
44
|
// muss eindeutig sein. Ohne unique-constraint hätte ein konkurrenter
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { DESTRUCTION_STAGES, isDestructionPipelineComplete, pickNextStage } from "../stages";
|
|
3
|
+
|
|
4
|
+
describe("tenant-lifecycle stages", () => {
|
|
5
|
+
test("pickNextStage halts when any stage was abandoned", () => {
|
|
6
|
+
const completed = new Set(["external-resources", "search-indices"]);
|
|
7
|
+
const abandoned = new Set(["app-data"]);
|
|
8
|
+
expect(pickNextStage(completed, abandoned)).toBeNull();
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test("pickNextStage returns first incomplete stage when healthy", () => {
|
|
12
|
+
const completed = new Set(["external-resources"]);
|
|
13
|
+
expect(pickNextStage(completed, new Set())?.name).toBe("search-indices");
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("isDestructionPipelineComplete requires every stage", () => {
|
|
17
|
+
const partial = new Set(DESTRUCTION_STAGES.slice(0, 3).map((s) => s.name));
|
|
18
|
+
expect(isDestructionPipelineComplete(partial)).toBe(false);
|
|
19
|
+
const all = new Set(DESTRUCTION_STAGES.map((s) => s.name));
|
|
20
|
+
expect(isDestructionPipelineComplete(all)).toBe(true);
|
|
21
|
+
});
|
|
22
|
+
});
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { randomBytes } from "node:crypto";
|
|
3
|
+
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
4
|
+
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
5
|
+
import {
|
|
6
|
+
defineFeature,
|
|
7
|
+
EXT_TENANT_DATA,
|
|
8
|
+
type TenantId,
|
|
9
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
10
|
+
import {
|
|
11
|
+
append,
|
|
12
|
+
createEventsTable,
|
|
13
|
+
eventsTable,
|
|
14
|
+
loadAggregate,
|
|
15
|
+
} from "@cosmicdrift/kumiko-framework/event-store";
|
|
16
|
+
import {
|
|
17
|
+
createTestUser,
|
|
18
|
+
setupTestStack,
|
|
19
|
+
type TestStack,
|
|
20
|
+
TestUsers,
|
|
21
|
+
unsafeCreateEntityTable,
|
|
22
|
+
unsafePushTables,
|
|
23
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
24
|
+
import {
|
|
25
|
+
createTestEnvelopeCipher,
|
|
26
|
+
resetTestTables,
|
|
27
|
+
updateRows,
|
|
28
|
+
} from "@cosmicdrift/kumiko-framework/testing";
|
|
29
|
+
import {
|
|
30
|
+
createComplianceProfilesFeature,
|
|
31
|
+
tenantComplianceProfileEntity,
|
|
32
|
+
tenantComplianceProfileTable,
|
|
33
|
+
} from "../../compliance-profiles";
|
|
34
|
+
import { createConfigFeature } from "../../config";
|
|
35
|
+
import { createConfigResolver } from "../../config/resolver";
|
|
36
|
+
import { configValuesTable } from "../../config/table";
|
|
37
|
+
import { createSessionsFeature } from "../../sessions";
|
|
38
|
+
import { userSessionEntity, userSessionTable } from "../../sessions/schema/user-session";
|
|
39
|
+
import { TenantHandlers, TenantQueries } from "../../tenant/constants";
|
|
40
|
+
import { createTenantFeature } from "../../tenant/feature";
|
|
41
|
+
import { tenantEntity, tenantTable } from "../../tenant/schema/tenant";
|
|
42
|
+
import { createUserFeature } from "../../user/feature";
|
|
43
|
+
import {
|
|
44
|
+
TENANT_AGGREGATE_TYPE,
|
|
45
|
+
TENANT_DESTRUCTION_FAILED_EVENT_QN,
|
|
46
|
+
TENANT_DESTRUCTION_STARTED_EVENT_QN,
|
|
47
|
+
} from "../constants";
|
|
48
|
+
import {
|
|
49
|
+
createTenantLifecycleFeature,
|
|
50
|
+
resolveTenantLifecycleGate,
|
|
51
|
+
TenantLifecycleHandlers,
|
|
52
|
+
} from "../index";
|
|
53
|
+
import { runNextDestructionStage } from "../run-tenant-destroy";
|
|
54
|
+
|
|
55
|
+
const REQUEST = TenantLifecycleHandlers.requestDestruction;
|
|
56
|
+
const CANCEL = TenantLifecycleHandlers.cancelDestruction;
|
|
57
|
+
const SET_PROFILE = "compliance-profiles:write:set-profile";
|
|
58
|
+
|
|
59
|
+
let stack: TestStack;
|
|
60
|
+
let db: DbConnection;
|
|
61
|
+
const tenantAdmin = TestUsers.admin;
|
|
62
|
+
|
|
63
|
+
beforeAll(async () => {
|
|
64
|
+
const encryption = createTestEnvelopeCipher(randomBytes(32).toString("base64"));
|
|
65
|
+
const resolver = createConfigResolver({ cipher: encryption });
|
|
66
|
+
|
|
67
|
+
stack = await setupTestStack({
|
|
68
|
+
features: [
|
|
69
|
+
createConfigFeature(),
|
|
70
|
+
createUserFeature(),
|
|
71
|
+
createTenantFeature(),
|
|
72
|
+
createComplianceProfilesFeature(),
|
|
73
|
+
createSessionsFeature(),
|
|
74
|
+
createTenantLifecycleFeature(),
|
|
75
|
+
],
|
|
76
|
+
extraContext: { configResolver: resolver, configEncryption: encryption },
|
|
77
|
+
authConfig: {
|
|
78
|
+
resolveTenantLifecycleStatus: async (tenantId: TenantId) => {
|
|
79
|
+
const gate = await resolveTenantLifecycleGate(stack.db, tenantId);
|
|
80
|
+
return gate ? { status: gate.status } : null;
|
|
81
|
+
},
|
|
82
|
+
} as import("@cosmicdrift/kumiko-framework/api").AuthRoutesConfig,
|
|
83
|
+
});
|
|
84
|
+
db = stack.db;
|
|
85
|
+
|
|
86
|
+
await unsafeCreateEntityTable(db, tenantEntity);
|
|
87
|
+
await unsafeCreateEntityTable(db, userSessionEntity);
|
|
88
|
+
await unsafeCreateEntityTable(db, tenantComplianceProfileEntity);
|
|
89
|
+
await createEventsTable(db);
|
|
90
|
+
await unsafePushTables(db, { configValuesTable });
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
afterAll(async () => {
|
|
94
|
+
await stack.cleanup();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
beforeEach(async () => {
|
|
98
|
+
stack.events.reset();
|
|
99
|
+
await resetTestTables(db, [
|
|
100
|
+
tenantTable,
|
|
101
|
+
tenantComplianceProfileTable,
|
|
102
|
+
userSessionTable,
|
|
103
|
+
eventsTable,
|
|
104
|
+
]);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
async function seedTenant(): Promise<void> {
|
|
108
|
+
await stack.http.writeOk(
|
|
109
|
+
TenantHandlers.create,
|
|
110
|
+
{ id: tenantAdmin.tenantId, key: "acme", name: "ACME Corp" },
|
|
111
|
+
TestUsers.systemAdmin,
|
|
112
|
+
);
|
|
113
|
+
await stack.http.writeOk(SET_PROFILE, { profileKey: "eu-dsgvo" }, tenantAdmin);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
describe("tenant-lifecycle :: request / cancel / 410 gate", () => {
|
|
117
|
+
test("request destruction sets destroyRequested + grace", async () => {
|
|
118
|
+
await seedTenant();
|
|
119
|
+
const result = await stack.http.writeOk<{
|
|
120
|
+
status: string;
|
|
121
|
+
gracePeriodEnd: string;
|
|
122
|
+
}>(REQUEST, {}, tenantAdmin);
|
|
123
|
+
expect(result.status).toBe("destroyRequested");
|
|
124
|
+
expect(result.gracePeriodEnd).toBeTruthy();
|
|
125
|
+
|
|
126
|
+
const rows = await selectMany(db, tenantTable, { id: tenantAdmin.tenantId });
|
|
127
|
+
expect(rows[0]?.["status"]).toBe("destroyRequested");
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("API returns 410 after destruction requested", async () => {
|
|
131
|
+
await seedTenant();
|
|
132
|
+
await stack.http.writeOk(REQUEST, {}, tenantAdmin);
|
|
133
|
+
|
|
134
|
+
const res = await stack.http.query(TenantQueries.me, {}, tenantAdmin);
|
|
135
|
+
expect(res.status).toBe(410);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("cancel within grace restores active tenant", async () => {
|
|
139
|
+
await seedTenant();
|
|
140
|
+
await stack.http.writeOk(REQUEST, {}, tenantAdmin);
|
|
141
|
+
const cancelled = await stack.http.writeOk<{ status: string }>(CANCEL, {}, tenantAdmin);
|
|
142
|
+
expect(cancelled.status).toBe("active");
|
|
143
|
+
|
|
144
|
+
const me = await stack.http.queryOk(TenantQueries.me, {}, tenantAdmin);
|
|
145
|
+
expect(me).not.toBeNull();
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("cancel after grace expired returns grace_period_expired", async () => {
|
|
149
|
+
await seedTenant();
|
|
150
|
+
await stack.http.writeOk(REQUEST, {}, tenantAdmin);
|
|
151
|
+
const past = (await import("@cosmicdrift/kumiko-framework/time"))
|
|
152
|
+
.getTemporal()
|
|
153
|
+
.Now.instant()
|
|
154
|
+
.subtract({
|
|
155
|
+
hours: 1,
|
|
156
|
+
});
|
|
157
|
+
await updateRows(db, tenantTable, { gracePeriodEnd: past }, { id: tenantAdmin.tenantId });
|
|
158
|
+
|
|
159
|
+
const err = await stack.http.writeErr(CANCEL, {}, tenantAdmin);
|
|
160
|
+
expect(err.httpStatus).toBe(422);
|
|
161
|
+
expect((err.details as { reason?: string } | undefined)?.reason).toBe("grace_period_expired");
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("non-admin cannot request destruction", async () => {
|
|
165
|
+
await seedTenant();
|
|
166
|
+
const member = createTestUser({ id: 99, roles: ["Member"] });
|
|
167
|
+
const err = await stack.http.writeErr(REQUEST, {}, member);
|
|
168
|
+
expect(err.httpStatus).toBe(403);
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
function createPoisonTenantDataFeature() {
|
|
173
|
+
return defineFeature("test-poison-tenant-data", (r) => {
|
|
174
|
+
r.requires("tenant-lifecycle");
|
|
175
|
+
r.useExtension(EXT_TENANT_DATA, "poison-pill", {
|
|
176
|
+
destroy: async () => {
|
|
177
|
+
throw new Error("poison-pill");
|
|
178
|
+
},
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
describe("tenant-lifecycle :: pipeline abandon / destroyFailed", () => {
|
|
184
|
+
let poisonStack: TestStack;
|
|
185
|
+
|
|
186
|
+
beforeAll(async () => {
|
|
187
|
+
const encryption = createTestEnvelopeCipher(randomBytes(32).toString("base64"));
|
|
188
|
+
const resolver = createConfigResolver({ cipher: encryption });
|
|
189
|
+
poisonStack = await setupTestStack({
|
|
190
|
+
features: [
|
|
191
|
+
createConfigFeature(),
|
|
192
|
+
createTenantFeature(),
|
|
193
|
+
createComplianceProfilesFeature(),
|
|
194
|
+
createTenantLifecycleFeature(),
|
|
195
|
+
createPoisonTenantDataFeature(),
|
|
196
|
+
],
|
|
197
|
+
extraContext: { configResolver: resolver, configEncryption: encryption },
|
|
198
|
+
authConfig: {
|
|
199
|
+
resolveTenantLifecycleStatus: async (tenantId: TenantId) => {
|
|
200
|
+
const gate = await resolveTenantLifecycleGate(poisonStack.db, tenantId);
|
|
201
|
+
return gate ? { status: gate.status } : null;
|
|
202
|
+
},
|
|
203
|
+
} as import("@cosmicdrift/kumiko-framework/api").AuthRoutesConfig,
|
|
204
|
+
});
|
|
205
|
+
await unsafeCreateEntityTable(poisonStack.db, tenantEntity);
|
|
206
|
+
await unsafeCreateEntityTable(poisonStack.db, tenantComplianceProfileEntity);
|
|
207
|
+
await createEventsTable(poisonStack.db);
|
|
208
|
+
await unsafePushTables(poisonStack.db, { configValuesTable });
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
afterAll(async () => {
|
|
212
|
+
await poisonStack.cleanup();
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
beforeEach(async () => {
|
|
216
|
+
poisonStack.events.reset();
|
|
217
|
+
await resetTestTables(poisonStack.db, [tenantTable, tenantComplianceProfileTable, eventsTable]);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
async function seedDestroyingTenant(): Promise<void> {
|
|
221
|
+
await poisonStack.http.writeOk(
|
|
222
|
+
TenantHandlers.create,
|
|
223
|
+
{ id: tenantAdmin.tenantId, key: "acme", name: "ACME Corp" },
|
|
224
|
+
TestUsers.systemAdmin,
|
|
225
|
+
);
|
|
226
|
+
await poisonStack.http.writeOk(SET_PROFILE, { profileKey: "eu-dsgvo" }, tenantAdmin);
|
|
227
|
+
const T = (await import("@cosmicdrift/kumiko-framework/time")).getTemporal();
|
|
228
|
+
const now = T.Now.instant();
|
|
229
|
+
await updateRows(
|
|
230
|
+
poisonStack.db,
|
|
231
|
+
tenantTable,
|
|
232
|
+
{ status: "destroying", destroyStartedAt: now },
|
|
233
|
+
{ id: tenantAdmin.tenantId },
|
|
234
|
+
);
|
|
235
|
+
await append(poisonStack.db, {
|
|
236
|
+
aggregateId: tenantAdmin.tenantId,
|
|
237
|
+
aggregateType: TENANT_AGGREGATE_TYPE,
|
|
238
|
+
tenantId: tenantAdmin.tenantId,
|
|
239
|
+
expectedVersion:
|
|
240
|
+
(await loadAggregate(poisonStack.db, tenantAdmin.tenantId, tenantAdmin.tenantId)).at(-1)
|
|
241
|
+
?.version ?? 0,
|
|
242
|
+
type: TENANT_DESTRUCTION_STARTED_EVENT_QN,
|
|
243
|
+
payload: { startedAt: now.toString() },
|
|
244
|
+
metadata: { userId: "system", requestId: "test:destruction-started" },
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
test("abandoned app-data stage halts pipeline as destroyFailed without tombstone", async () => {
|
|
249
|
+
await seedDestroyingTenant();
|
|
250
|
+
let halted = false;
|
|
251
|
+
for (let i = 0; i < 12; i++) {
|
|
252
|
+
const result = await runNextDestructionStage({
|
|
253
|
+
db: poisonStack.db,
|
|
254
|
+
registry: poisonStack.registry,
|
|
255
|
+
tenantId: tenantAdmin.tenantId,
|
|
256
|
+
});
|
|
257
|
+
if (result.halted) {
|
|
258
|
+
halted = true;
|
|
259
|
+
break;
|
|
260
|
+
}
|
|
261
|
+
if (result.done) break;
|
|
262
|
+
}
|
|
263
|
+
expect(halted).toBe(true);
|
|
264
|
+
|
|
265
|
+
const rows = await selectMany(poisonStack.db, tenantTable, { id: tenantAdmin.tenantId });
|
|
266
|
+
expect(rows[0]?.["status"]).toBe("destroyFailed");
|
|
267
|
+
expect(rows[0]?.["destroyedAt"]).toBeNull();
|
|
268
|
+
|
|
269
|
+
const events = await selectMany(poisonStack.db, eventsTable, {
|
|
270
|
+
aggregateId: tenantAdmin.tenantId,
|
|
271
|
+
});
|
|
272
|
+
expect(events.some((e) => e["type"] === TENANT_DESTRUCTION_FAILED_EVENT_QN)).toBe(true);
|
|
273
|
+
expect(events.some((e) => String(e["type"]).includes("tenant-destruction-completed"))).toBe(
|
|
274
|
+
false,
|
|
275
|
+
);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
test("destroyFailed tenant returns 410 on API", async () => {
|
|
279
|
+
await seedDestroyingTenant();
|
|
280
|
+
for (let i = 0; i < 12; i++) {
|
|
281
|
+
const result = await runNextDestructionStage({
|
|
282
|
+
db: poisonStack.db,
|
|
283
|
+
registry: poisonStack.registry,
|
|
284
|
+
tenantId: tenantAdmin.tenantId,
|
|
285
|
+
});
|
|
286
|
+
if (result.halted || result.done) break;
|
|
287
|
+
}
|
|
288
|
+
const res = await poisonStack.http.query(TenantQueries.me, {}, tenantAdmin);
|
|
289
|
+
expect(res.status).toBe(410);
|
|
290
|
+
});
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
describe("tenant-lifecycle :: batch cancel exemption", () => {
|
|
294
|
+
test("cancel via /api/batch during grace succeeds", async () => {
|
|
295
|
+
await seedTenant();
|
|
296
|
+
await stack.http.writeOk(REQUEST, {}, tenantAdmin);
|
|
297
|
+
const res = await stack.http.batch([{ type: CANCEL, payload: {} }], tenantAdmin);
|
|
298
|
+
expect(res.status).toBe(200);
|
|
299
|
+
const body = (await res.json()) as {
|
|
300
|
+
isSuccess: boolean;
|
|
301
|
+
results: Array<{ isSuccess: boolean }>;
|
|
302
|
+
};
|
|
303
|
+
expect(body.isSuccess).toBe(true);
|
|
304
|
+
expect(body.results[0]?.isSuccess).toBe(true);
|
|
305
|
+
|
|
306
|
+
const rows = await selectMany(db, tenantTable, { id: tenantAdmin.tenantId });
|
|
307
|
+
expect(rows[0]?.["status"]).toBe("active");
|
|
308
|
+
});
|
|
309
|
+
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
export const TENANT_LIFECYCLE_FEATURE = "tenant-lifecycle" as const;
|
|
2
|
+
|
|
3
|
+
export const TENANT_AGGREGATE_TYPE = "tenant" as const;
|
|
4
|
+
|
|
5
|
+
const EVENT_PREFIX = `${TENANT_LIFECYCLE_FEATURE}:event:` as const;
|
|
6
|
+
|
|
7
|
+
export const DESTRUCTION_REQUESTED_EVENT_SHORT = "destruction-requested" as const;
|
|
8
|
+
export const DESTRUCTION_CANCELLED_EVENT_SHORT = "destruction-cancelled" as const;
|
|
9
|
+
export const TENANT_DESTRUCTION_STARTED_EVENT_SHORT = "tenant-destruction-started" as const;
|
|
10
|
+
export const TENANT_DESTRUCTION_STAGE_STARTED_EVENT_SHORT =
|
|
11
|
+
"tenant-destruction-stage-started" as const;
|
|
12
|
+
export const TENANT_DESTRUCTION_STAGE_SUCCEEDED_EVENT_SHORT =
|
|
13
|
+
"tenant-destruction-stage-succeeded" as const;
|
|
14
|
+
export const TENANT_DESTRUCTION_STAGE_FAILED_EVENT_SHORT =
|
|
15
|
+
"tenant-destruction-stage-failed" as const;
|
|
16
|
+
export const TENANT_DESTRUCTION_STAGE_ABANDONED_EVENT_SHORT =
|
|
17
|
+
"tenant-destruction-stage-abandoned" as const;
|
|
18
|
+
export const TENANT_DESTRUCTION_COMPLETED_EVENT_SHORT = "tenant-destruction-completed" as const;
|
|
19
|
+
export const TENANT_DESTRUCTION_FAILED_EVENT_SHORT = "tenant-destruction-failed" as const;
|
|
20
|
+
|
|
21
|
+
export const DESTRUCTION_REQUESTED_EVENT_QN =
|
|
22
|
+
`${EVENT_PREFIX}${DESTRUCTION_REQUESTED_EVENT_SHORT}` as const;
|
|
23
|
+
export const DESTRUCTION_CANCELLED_EVENT_QN =
|
|
24
|
+
`${EVENT_PREFIX}${DESTRUCTION_CANCELLED_EVENT_SHORT}` as const;
|
|
25
|
+
export const TENANT_DESTRUCTION_STARTED_EVENT_QN =
|
|
26
|
+
`${EVENT_PREFIX}${TENANT_DESTRUCTION_STARTED_EVENT_SHORT}` as const;
|
|
27
|
+
export const TENANT_DESTRUCTION_STAGE_SUCCEEDED_EVENT_QN =
|
|
28
|
+
`${EVENT_PREFIX}${TENANT_DESTRUCTION_STAGE_SUCCEEDED_EVENT_SHORT}` as const;
|
|
29
|
+
export const TENANT_DESTRUCTION_STAGE_FAILED_EVENT_QN =
|
|
30
|
+
`${EVENT_PREFIX}${TENANT_DESTRUCTION_STAGE_FAILED_EVENT_SHORT}` as const;
|
|
31
|
+
export const TENANT_DESTRUCTION_STAGE_ABANDONED_EVENT_QN =
|
|
32
|
+
`${EVENT_PREFIX}${TENANT_DESTRUCTION_STAGE_ABANDONED_EVENT_SHORT}` as const;
|
|
33
|
+
export const TENANT_DESTRUCTION_COMPLETED_EVENT_QN =
|
|
34
|
+
`${EVENT_PREFIX}${TENANT_DESTRUCTION_COMPLETED_EVENT_SHORT}` as const;
|
|
35
|
+
export const TENANT_DESTRUCTION_FAILED_EVENT_QN =
|
|
36
|
+
`${EVENT_PREFIX}${TENANT_DESTRUCTION_FAILED_EVENT_SHORT}` as const;
|
|
37
|
+
export const TENANT_DESTRUCTION_STAGE_STARTED_EVENT_QN =
|
|
38
|
+
`${EVENT_PREFIX}${TENANT_DESTRUCTION_STAGE_STARTED_EVENT_SHORT}` as const;
|
|
39
|
+
|
|
40
|
+
export const TenantLifecycleHandlers = {
|
|
41
|
+
requestDestruction: `${TENANT_LIFECYCLE_FEATURE}:write:request-destruction`,
|
|
42
|
+
cancelDestruction: `${TENANT_LIFECYCLE_FEATURE}:write:cancel-destruction`,
|
|
43
|
+
} as const;
|
|
44
|
+
|
|
45
|
+
export const TENANT_DESTRUCTION_STAGES = [
|
|
46
|
+
"external-resources",
|
|
47
|
+
"search-indices",
|
|
48
|
+
"cache",
|
|
49
|
+
"app-data",
|
|
50
|
+
"subject-keys",
|
|
51
|
+
"files",
|
|
52
|
+
"infra-resources",
|
|
53
|
+
"tenant-row",
|
|
54
|
+
] as const;
|
|
55
|
+
|
|
56
|
+
export type TenantDestructionStageName = (typeof TENANT_DESTRUCTION_STAGES)[number];
|