@cosmicdrift/kumiko-bundled-features 0.57.2 → 0.60.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.
Files changed (53) hide show
  1. package/package.json +10 -7
  2. package/src/auth-email-password/i18n.ts +2 -0
  3. package/src/config/__tests__/app-override-visibility.integration.test.ts +6 -0
  4. package/src/config/__tests__/backing-secrets.integration.test.ts +38 -0
  5. package/src/config/__tests__/inherited-redaction.integration.test.ts +29 -0
  6. package/src/config/handlers/cascade.query.ts +1 -3
  7. package/src/config/handlers/readiness.query.ts +6 -0
  8. package/src/config/handlers/values.query.ts +1 -3
  9. package/src/config/read-redaction.ts +13 -2
  10. package/src/custom-fields/__tests__/feature.test.ts +57 -4
  11. package/src/custom-fields/feature.ts +19 -4
  12. package/src/files-provider-s3/__tests__/s3-provider.test.ts +61 -1
  13. package/src/files-provider-s3/s3-provider.ts +9 -3
  14. package/src/managed-pages/__tests__/managed-pages.integration.test.ts +92 -1
  15. package/src/managed-pages/handlers/set.write.ts +14 -4
  16. package/src/subscription-stripe/__tests__/runtime.test.ts +59 -5
  17. package/src/subscription-stripe/__tests__/stripe-foundation.integration.test.ts +105 -0
  18. package/src/subscription-stripe/feature.ts +2 -1
  19. package/src/tags/__tests__/drift.test.ts +46 -0
  20. package/src/tags/__tests__/feature.test.ts +155 -0
  21. package/src/tags/__tests__/tags.integration.test.ts +251 -0
  22. package/src/tags/aggregate-id.ts +23 -0
  23. package/src/tags/constants.ts +37 -0
  24. package/src/tags/entity.ts +35 -0
  25. package/src/tags/executor.ts +11 -0
  26. package/src/tags/feature.ts +75 -0
  27. package/src/tags/handlers/assign-tag.write.ts +48 -0
  28. package/src/tags/handlers/create-tag.write.ts +23 -0
  29. package/src/tags/handlers/remove-tag.write.ts +34 -0
  30. package/src/tags/index.ts +30 -0
  31. package/src/tags/schemas.ts +20 -0
  32. package/src/template-resolver/README.md +22 -0
  33. package/src/template-resolver/__tests__/conformance.integration.test.ts +79 -0
  34. package/src/template-resolver/testing.ts +192 -0
  35. package/src/tier-engine/__tests__/drift.test.ts +4 -0
  36. package/src/tier-engine/__tests__/resolver.integration.test.ts +30 -0
  37. package/src/tier-engine/__tests__/tier-engine.integration.test.ts +118 -0
  38. package/src/tier-engine/constants.ts +13 -0
  39. package/src/tier-engine/entity.ts +5 -0
  40. package/src/tier-engine/feature.ts +51 -3
  41. package/src/tier-engine/handlers/get-tenant-tier.query.ts +36 -0
  42. package/src/tier-engine/handlers/set-tenant-tier.write.ts +99 -0
  43. package/src/tier-engine/i18n.ts +39 -0
  44. package/src/tier-engine/web/client-plugin.tsx +27 -0
  45. package/src/tier-engine/web/index.ts +8 -0
  46. package/src/tier-engine/web/tier-admin-screen.tsx +161 -0
  47. package/src/user-data-rights/__tests__/anonymous-deletion.integration.test.ts +11 -0
  48. package/src/user-data-rights/deletion-token.ts +9 -3
  49. package/src/user-data-rights/handlers/confirm-deletion-by-token.write.ts +22 -3
  50. package/src/user-data-rights/web/__tests__/deletion-screens.test.tsx +37 -43
  51. package/src/user-profile/__tests__/profile-screen.test.tsx +61 -3
  52. package/src/user-profile/i18n.ts +2 -3
  53. package/src/user-profile/web/profile-screen.tsx +29 -5
@@ -5,22 +5,40 @@
5
5
  // (das deckt der Integration-Test ab).
6
6
 
7
7
  import { describe, expect, test } from "bun:test";
8
- import type { ConfigKeyHandle, HandlerContext } from "@cosmicdrift/kumiko-framework/engine";
8
+ import {
9
+ type ConfigKeyHandle,
10
+ type HandlerContext,
11
+ qn,
12
+ toKebab,
13
+ } from "@cosmicdrift/kumiko-framework/engine";
9
14
  import { FeatureDisabledError, UnconfiguredError } from "@cosmicdrift/kumiko-framework/errors";
10
15
  import { createSecret, type SecretsContext } from "@cosmicdrift/kumiko-framework/secrets";
11
16
  import Stripe from "stripe";
17
+ import {
18
+ STRIPE_API_KEY_CONFIG,
19
+ STRIPE_BILLING_LIVE_CONFIG,
20
+ STRIPE_WEBHOOK_SECRET_CONFIG,
21
+ SUBSCRIPTION_STRIPE_FEATURE,
22
+ } from "../constants";
12
23
  import { createStripeClientCache, createStripeRuntimes } from "../runtime";
13
24
 
25
+ // Handle-Namen aus den kanonischen Konstanten + demselben Qualifier ableiten,
26
+ // den r.config zur Build-Zeit anwendet (define-feature.ts: qn(toKebab(feature),
27
+ // "config", toKebab(shortKey))). Eine hand-redeklarierte Fixture konnte still
28
+ // von der Produktion driften (#421/2) — diese Ableitung macht das unmöglich.
29
+ const configHandleName = (shortKey: string): string =>
30
+ qn(toKebab(SUBSCRIPTION_STRIPE_FEATURE), "config", toKebab(shortKey));
31
+
14
32
  const API_KEY_HANDLE: ConfigKeyHandle<"text"> = {
15
- name: "subscription-stripe:config:api-key",
33
+ name: configHandleName(STRIPE_API_KEY_CONFIG),
16
34
  type: "text",
17
35
  };
18
36
  const WEBHOOK_SECRET_HANDLE: ConfigKeyHandle<"text"> = {
19
- name: "subscription-stripe:config:webhook-secret",
37
+ name: configHandleName(STRIPE_WEBHOOK_SECRET_CONFIG),
20
38
  type: "text",
21
39
  };
22
40
  const BILLING_LIVE_HANDLE: ConfigKeyHandle<"boolean"> = {
23
- name: "subscription-stripe:config:billing-live",
41
+ name: configHandleName(STRIPE_BILLING_LIVE_CONFIG),
24
42
  type: "boolean",
25
43
  };
26
44
 
@@ -42,13 +60,36 @@ function stubSecrets(values: Record<string, string>): SecretsContext {
42
60
  };
43
61
  }
44
62
 
63
+ /** Wie stubSecrets, aber speichert den Wert ROH (kein JSON.stringify) — um
64
+ * parseStoredSecret's Fehlerpfad zu treffen: ein Credential, das der Store
65
+ * un-JSON-kodiert zurückgibt (Korruption oder ein außerhalb des
66
+ * backing:"secrets"-Roundtrips geschriebener Wert) muss laut failen, nicht
67
+ * still Müll liefern. */
68
+ function rawSecretsStub(values: Record<string, string>): SecretsContext {
69
+ const nameOf = (k: string | { readonly name: string }): string =>
70
+ typeof k === "string" ? k : k.name;
71
+ return {
72
+ get: async (_tenantId, key) => {
73
+ const value = values[nameOf(key)];
74
+ return value === undefined ? undefined : createSecret(value); // RAW, not JSON
75
+ },
76
+ has: async (_tenantId, key) => values[nameOf(key)] !== undefined,
77
+ set: async () => undefined,
78
+ delete: async () => false,
79
+ };
80
+ }
81
+
45
82
  /** Minimaler HandlerContext-Stub mit nur den Feldern, die die ctx-
46
83
  * Resolution liest (secrets, _userId für audit, config). */
47
84
  function stubCtx(opts: { secrets?: SecretsContext; billingLive?: boolean }): HandlerContext {
48
85
  return {
49
86
  secrets: opts.secrets,
50
87
  _userId: "tester",
51
- config: async () => opts.billingLive,
88
+ // Key-aware: antwortet NUR auf das billing-live-Handle. Liest
89
+ // assertBillingLive versehentlich einen anderen Config-Key, kommt undefined
90
+ // zurück → Gate schließt → der "passes when true"-Test schlägt fehl (#421/3).
91
+ config: async (handle: ConfigKeyHandle<"boolean">) =>
92
+ handle.name === BILLING_LIVE_HANDLE.name ? opts.billingLive : undefined,
52
93
  } as unknown as HandlerContext; // @cast-boundary test-stub — partial ctx
53
94
  }
54
95
 
@@ -116,6 +157,19 @@ describe("StripeCtxRuntime.clientForCtx", () => {
116
157
  const ctx = stubCtx({ secrets: stubSecrets({}) });
117
158
  await expect(rt.ctx.clientForCtx(ctx)).rejects.toBeInstanceOf(UnconfiguredError);
118
159
  });
160
+
161
+ test("throws loudly on a malformed (non-JSON) stored credential (#393/2)", async () => {
162
+ // The store round-trips backing:"secrets" values JSON-encoded; a raw,
163
+ // un-quoted value reaching parseStoredSecret means corruption — it must
164
+ // throw, not silently fall through to undefined/fallback.
165
+ const rt = makeRuntimes({ apiKey: "sk_test_fallback" });
166
+ const ctx = stubCtx({
167
+ secrets: rawSecretsStub({ [API_KEY_HANDLE.name]: "sk_test_raw_unquoted" }),
168
+ });
169
+ await expect(rt.ctx.clientForCtx(ctx)).rejects.toThrow(
170
+ /Invalid JSON in subscription-stripe credential/,
171
+ );
172
+ });
119
173
  });
120
174
 
121
175
  // =============================================================================
@@ -447,3 +447,108 @@ describe("scenario 5: runtime-secret resolution", () => {
447
447
  expect(res.status).toBe(401);
448
448
  });
449
449
  });
450
+
451
+ // =============================================================================
452
+ // Scenario 6: billing-live-Gate (#104) durch den vollen Stack.
453
+ //
454
+ // Die #104-Invariante (kein Live-Checkout solange billing-live nicht true) war
455
+ // bislang nur mit gestubbter ctx.config getestet (runtime.test.ts) — kein Test
456
+ // fuhr die Kette factory → r.config → ctx.config(handle) real durch. Eigener
457
+ // Stack OHNE api-key/webhook-secret-Fallback, damit das Gate-Öffnen hermetisch
458
+ // als UnconfiguredError sichtbar wird (api-key fehlt) statt in einem echten
459
+ // Stripe-Netzwerk-Call. Ein reiner default-off-Beweis genügt nicht: er kann
460
+ // "korrektes Handle, Wert fehlt" nicht von "falsches Handle, immer undefined"
461
+ // trennen (beide → feature_disabled). Erst der positive Fall (billing-live via
462
+ // config:write:set auf dem kanonischen QN setzen → Gate öffnet) beweist die
463
+ // Handle-Resolution real.
464
+ // =============================================================================
465
+
466
+ const BILLING_LIVE_CONFIG_QN = "subscription-stripe:config:billing-live";
467
+
468
+ describe("scenario 6: billing-live gate end-to-end (#104)", () => {
469
+ let gateStack: TestStack;
470
+
471
+ beforeAll(async () => {
472
+ const stripeFeature = createSubscriptionStripeFeature({ priceToTier: PRICE_TO_TIER });
473
+ const encryption = createEncryptionProvider(randomBytes(32).toString("base64"));
474
+ const resolver = createConfigResolver({ encryption });
475
+ const masterKeyProvider = createEnvMasterKeyProvider({
476
+ env: {
477
+ KUMIKO_SECRETS_MASTER_KEY_V1: randomBytes(32).toString("base64"),
478
+ KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION: "1",
479
+ },
480
+ });
481
+ gateStack = await setupTestStack({
482
+ features: [
483
+ createConfigFeature(),
484
+ createSecretsFeature(),
485
+ billingFoundationFeature,
486
+ stripeFeature,
487
+ ],
488
+ masterKeyProvider,
489
+ extraContext: ({ db: ctxDb, registry }) => ({
490
+ configResolver: resolver,
491
+ configEncryption: encryption,
492
+ _configAccessorFactory: createConfigAccessorFactory(registry, resolver),
493
+ secrets: createSecretsContext({ db: ctxDb, masterKeyProvider }),
494
+ }),
495
+ });
496
+ await createEventsTable(gateStack.db);
497
+ await unsafePushTables(gateStack.db, {
498
+ configValuesTable,
499
+ tenant_secrets: tenantSecretsTable,
500
+ });
501
+ });
502
+
503
+ afterAll(async () => {
504
+ await gateStack.cleanup();
505
+ });
506
+
507
+ const checkoutPayload = {
508
+ providerName: "stripe",
509
+ priceId: "price_pro_monthly",
510
+ successUrl: "https://app.example.com/ok",
511
+ cancelUrl: "https://app.example.com/cancel",
512
+ };
513
+
514
+ test("default-off → feature_disabled; config-flip → Gate öffnet (Handle-Resolution real)", async () => {
515
+ const tenantAdmin = createTestUser({
516
+ id: 6001,
517
+ tenantId: testTenantId(6001),
518
+ roles: ["TenantAdmin"],
519
+ });
520
+
521
+ // billing-live ungesetzt → Gate zu, throw VOR jedem api-key/Stripe-Schritt.
522
+ const closed = await gateStack.http.writeErr(
523
+ "billing-foundation:write:create-checkout-session",
524
+ checkoutPayload,
525
+ tenantAdmin,
526
+ );
527
+ expect(closed.code).toBe("feature_disabled");
528
+
529
+ // billing-live=true auf dem kanonischen QN setzen (was der abgeleitete
530
+ // Sysadmin-configEdit-Screen in prod dispatcht).
531
+ const sysAdmin = createTestUser({
532
+ id: 6002,
533
+ tenantId: SYSTEM_TENANT_ID,
534
+ roles: ["SystemAdmin"],
535
+ });
536
+ await gateStack.http.writeOk(
537
+ "config:write:set",
538
+ { key: BILLING_LIVE_CONFIG_QN, value: true, scope: "system" },
539
+ sysAdmin,
540
+ );
541
+
542
+ // Gate jetzt offen: nicht mehr feature_disabled. Der nächste Schritt
543
+ // (api-key-Resolution) schlägt fehl, weil weder secret noch fallback
544
+ // gesetzt sind → unconfigured. Wäre der billing-live-Handle falsch
545
+ // qualifiziert, bliebe ctx.config undefined → Fehler weiter feature_disabled.
546
+ const opened = await gateStack.http.writeErr(
547
+ "billing-foundation:write:create-checkout-session",
548
+ checkoutPayload,
549
+ tenantAdmin,
550
+ );
551
+ expect(opened.code).not.toBe("feature_disabled");
552
+ expect(opened.code).toBe("unconfigured");
553
+ });
554
+ });
@@ -13,7 +13,8 @@
13
13
  // wird er als config-Key. `mask` leitet den Sysadmin-configEdit-Screen
14
14
  // + Settings-Hub-Nav ab — kein handgeschriebenes r.screen/r.nav in der
15
15
  // App mehr (v2 hatte die Keys als `r.secret` + App-eigene Maske).
16
- // - `subscription-stripe:config:billingLive` **system config**
16
+ // - `subscription-stripe:config:billing-live` (shortKey `billingLive`,
17
+ // kebab-qualifiziert von r.config) → **system config**
17
18
  // (boolean, default false). Der Master-Switch: ohne ihn darf kein
18
19
  // checkout eine Stripe-Session erzeugen (#104-Invariante, write-side
19
20
  // im createCheckoutSession-Gate durchgesetzt).
@@ -0,0 +1,46 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { tagAssignmentAggregateId } from "../aggregate-id";
3
+
4
+ // Drift-Pin-Tests — these values are cross-boot contracts. If they go red:
5
+ // stop, think, revert. aggregate-id.ts names this file.
6
+
7
+ const TENANT = "00000000-0000-0000-0000-000000000001";
8
+
9
+ describe("tags drift pins", () => {
10
+ test("tag-assignment aggregate-id namespace is stable across boots", () => {
11
+ // TAG_ASSIGNMENT_NAMESPACE is in stone — changing it re-keys every existing
12
+ // assignment stream and breaks event-replay. If this fails: revert the
13
+ // namespace, do not adjust the expected values.
14
+ const base = tagAssignmentAggregateId(TENANT, "tag-1", "credit", "c-1");
15
+
16
+ expect(base).toBe(tagAssignmentAggregateId(TENANT, "tag-1", "credit", "c-1")); // deterministic
17
+ // Every tuple component is part of the key (no collisions across the axes).
18
+ expect(base).not.toBe(
19
+ tagAssignmentAggregateId("11111111-1111-1111-1111-111111111111", "tag-1", "credit", "c-1"),
20
+ );
21
+ expect(base).not.toBe(tagAssignmentAggregateId(TENANT, "tag-2", "credit", "c-1"));
22
+ expect(base).not.toBe(tagAssignmentAggregateId(TENANT, "tag-1", "invoice", "c-1"));
23
+ expect(base).not.toBe(tagAssignmentAggregateId(TENANT, "tag-1", "credit", "c-2"));
24
+
25
+ // Pinned actual outputs — the drift-detector for the namespace constant.
26
+ expect(base).toBe("4f6e3d2e-033b-57f8-b044-6a3358647f65");
27
+ expect(
28
+ tagAssignmentAggregateId("11111111-1111-1111-1111-111111111111", "tag-1", "credit", "c-1"),
29
+ ).toBe("1bc17669-25ad-565b-9caf-72dbb18756da");
30
+ expect(tagAssignmentAggregateId(TENANT, "tag-2", "credit", "c-1")).toBe(
31
+ "6de1c5c6-25a1-508e-b1f8-de914745406d",
32
+ );
33
+ expect(tagAssignmentAggregateId(TENANT, "tag-1", "invoice", "c-1")).toBe(
34
+ "4e0d68b6-a69b-5dc1-9a2a-cd3f7e8b179d",
35
+ );
36
+ expect(tagAssignmentAggregateId(TENANT, "tag-1", "credit", "c-2")).toBe(
37
+ "659a1f64-31c0-5365-82e6-512fd822f002",
38
+ );
39
+ });
40
+
41
+ test("aggregate-id format is a valid uuid", () => {
42
+ expect(tagAssignmentAggregateId(TENANT, "tag-1", "credit", "c-1")).toMatch(
43
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/,
44
+ );
45
+ });
46
+ });
@@ -0,0 +1,155 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { DEFAULT_TAG_ROLES } from "../constants";
3
+ import { createTagsFeature } from "../feature";
4
+ import { assignTagPayloadSchema, createTagPayloadSchema, removeTagPayloadSchema } from "../schemas";
5
+
6
+ // Unit tests: feature-shape, role-options, schema-validation. The ES-loop
7
+ // behaviour (idempotent assign/remove, projection, tenant-isolation, read
8
+ // composition) needs a real stack → tags.integration.test.ts.
9
+
10
+ function writeAccess(
11
+ feature: ReturnType<typeof createTagsFeature>,
12
+ nameMatch: string,
13
+ ): readonly string[] {
14
+ const entry = Object.entries(feature.writeHandlers).find(([qn]) => qn.includes(nameMatch));
15
+ if (!entry) throw new Error(`handler ${nameMatch} not registered`);
16
+ const access = entry[1].access;
17
+ if (!access || !("roles" in access)) throw new Error(`handler ${nameMatch} has no roles`);
18
+ return access.roles;
19
+ }
20
+
21
+ function queryAccess(
22
+ feature: ReturnType<typeof createTagsFeature>,
23
+ nameMatch: string,
24
+ ): readonly string[] {
25
+ const entry = Object.entries(feature.queryHandlers).find(([qn]) => qn.includes(nameMatch));
26
+ if (!entry) throw new Error(`query ${nameMatch} not registered`);
27
+ const access = entry[1].access;
28
+ if (!access || !("roles" in access)) throw new Error(`query ${nameMatch} has no roles`);
29
+ return access.roles;
30
+ }
31
+
32
+ function rawWriteAccess(feature: ReturnType<typeof createTagsFeature>, nameMatch: string): unknown {
33
+ const entry = Object.entries(feature.writeHandlers).find(([qn]) => qn.includes(nameMatch));
34
+ if (!entry) throw new Error(`handler ${nameMatch} not registered`);
35
+ return entry[1].access;
36
+ }
37
+
38
+ function rawQueryAccess(feature: ReturnType<typeof createTagsFeature>, nameMatch: string): unknown {
39
+ const entry = Object.entries(feature.queryHandlers).find(([qn]) => qn.includes(nameMatch));
40
+ if (!entry) throw new Error(`query ${nameMatch} not registered`);
41
+ return entry[1].access;
42
+ }
43
+
44
+ describe("createTagsFeature shape", () => {
45
+ test("registers tag + tag-assignment entities, 3 write-handlers, 2 query-handlers", () => {
46
+ const feature = createTagsFeature();
47
+
48
+ expect(Object.keys(feature.entities ?? {})).toEqual(
49
+ expect.arrayContaining(["tag", "tag-assignment"]),
50
+ );
51
+
52
+ expect(Object.keys(feature.writeHandlers)).toEqual(
53
+ expect.arrayContaining([
54
+ expect.stringMatching(/create-tag/),
55
+ expect.stringMatching(/assign-tag/),
56
+ expect.stringMatching(/remove-tag/),
57
+ ]),
58
+ );
59
+ expect(Object.keys(feature.writeHandlers)).toHaveLength(3);
60
+
61
+ expect(Object.keys(feature.queryHandlers)).toEqual(
62
+ expect.arrayContaining([
63
+ expect.stringMatching(/tag:list/),
64
+ expect.stringMatching(/tag-assignment:list/),
65
+ ]),
66
+ );
67
+ expect(Object.keys(feature.queryHandlers)).toHaveLength(2);
68
+ });
69
+ });
70
+
71
+ describe("createTagsFeature access-options", () => {
72
+ test("without options: singleton with default roles on every path", () => {
73
+ const feature = createTagsFeature();
74
+ expect(feature).toBe(createTagsFeature());
75
+ expect(writeAccess(feature, "create-tag")).toEqual([...DEFAULT_TAG_ROLES]);
76
+ expect(writeAccess(feature, "assign-tag")).toEqual([...DEFAULT_TAG_ROLES]);
77
+ expect(writeAccess(feature, "remove-tag")).toEqual([...DEFAULT_TAG_ROLES]);
78
+ expect(queryAccess(feature, "tag:list")).toEqual([...DEFAULT_TAG_ROLES]);
79
+ expect(queryAccess(feature, "tag-assignment:list")).toEqual([...DEFAULT_TAG_ROLES]);
80
+ });
81
+
82
+ test("roles option overrides every write- and query-path", () => {
83
+ const feature = createTagsFeature({ roles: ["Admin", "Editor"] });
84
+ expect(writeAccess(feature, "create-tag")).toEqual(["Admin", "Editor"]);
85
+ expect(writeAccess(feature, "assign-tag")).toEqual(["Admin", "Editor"]);
86
+ expect(writeAccess(feature, "remove-tag")).toEqual(["Admin", "Editor"]);
87
+ expect(queryAccess(feature, "tag:list")).toEqual(["Admin", "Editor"]);
88
+ expect(queryAccess(feature, "tag-assignment:list")).toEqual(["Admin", "Editor"]);
89
+ });
90
+
91
+ test("access:{openToAll} applies to every write- and query-path", () => {
92
+ const feature = createTagsFeature({ access: { openToAll: true } });
93
+ for (const path of ["create-tag", "assign-tag", "remove-tag"]) {
94
+ expect(rawWriteAccess(feature, path)).toEqual({ openToAll: true });
95
+ }
96
+ for (const query of ["tag:list", "tag-assignment:list"]) {
97
+ expect(rawQueryAccess(feature, query)).toEqual({ openToAll: true });
98
+ }
99
+ });
100
+
101
+ test("access takes precedence over the roles shorthand", () => {
102
+ const feature = createTagsFeature({ access: { openToAll: true }, roles: ["Admin"] });
103
+ expect(rawWriteAccess(feature, "create-tag")).toEqual({ openToAll: true });
104
+ expect(rawQueryAccess(feature, "tag:list")).toEqual({ openToAll: true });
105
+ });
106
+
107
+ test("access:{roles} threads through like the roles shorthand", () => {
108
+ const feature = createTagsFeature({ access: { roles: ["Owner"] } });
109
+ expect(writeAccess(feature, "remove-tag")).toEqual(["Owner"]);
110
+ expect(queryAccess(feature, "tag-assignment:list")).toEqual(["Owner"]);
111
+ });
112
+ });
113
+
114
+ describe("createTagPayloadSchema", () => {
115
+ test("accepts name only", () => {
116
+ expect(createTagPayloadSchema.safeParse({ name: "Kunde Müller" }).success).toBe(true);
117
+ });
118
+
119
+ test("accepts name + color", () => {
120
+ expect(createTagPayloadSchema.safeParse({ name: "VIP", color: "#d4af37" }).success).toBe(true);
121
+ });
122
+
123
+ test("rejects empty name", () => {
124
+ expect(createTagPayloadSchema.safeParse({ name: "" }).success).toBe(false);
125
+ });
126
+
127
+ test("rejects name over 64 chars", () => {
128
+ expect(createTagPayloadSchema.safeParse({ name: "x".repeat(65) }).success).toBe(false);
129
+ });
130
+ });
131
+
132
+ describe("assign/remove payload schemas", () => {
133
+ const valid = { tagId: "tag-1", entityType: "credit", entityId: "c-1" };
134
+
135
+ test("accept a full (tag, entity) reference", () => {
136
+ expect(assignTagPayloadSchema.safeParse(valid).success).toBe(true);
137
+ expect(removeTagPayloadSchema.safeParse(valid).success).toBe(true);
138
+ });
139
+
140
+ test("reject missing entityId", () => {
141
+ expect(assignTagPayloadSchema.safeParse({ tagId: "tag-1", entityType: "credit" }).success).toBe(
142
+ false,
143
+ );
144
+ });
145
+
146
+ test("reject empty tagId", () => {
147
+ expect(assignTagPayloadSchema.safeParse({ ...valid, tagId: "" }).success).toBe(false);
148
+ });
149
+
150
+ test("reject entityId over 128 chars", () => {
151
+ expect(assignTagPayloadSchema.safeParse({ ...valid, entityId: "x".repeat(129) }).success).toBe(
152
+ false,
153
+ );
154
+ });
155
+ });
@@ -0,0 +1,251 @@
1
+ // Full-stack integration for the tags bundle. Drives create → assign → list →
2
+ // remove through the real dispatcher + entity-projection + DB. Proves the
3
+ // architecture end-to-end WITHOUT any host wiring (tags are host-agnostic — the
4
+ // host is just the entityType/entityId strings on the assignment):
5
+ // - create-tag projects into read_tags
6
+ // - assign-tag projects a join row keyed by (entityType, entityId)
7
+ // - read-layer composition both directions (tags of an entity / entities of a tag)
8
+ // - assign + remove are idempotent (re-assign = one row, remove-missing = ok)
9
+ // - multi-tenant isolation
10
+
11
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
12
+ import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
13
+ import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
14
+ import {
15
+ createTestUser,
16
+ setupTestStack,
17
+ type TestStack,
18
+ unsafeCreateEntityTable,
19
+ } from "@cosmicdrift/kumiko-framework/stack";
20
+ import { TagsHandlers, TagsQueries } from "../constants";
21
+ import { tagAssignmentEntity, tagEntity } from "../entity";
22
+ import { createTagsFeature } from "../feature";
23
+
24
+ const tagsFeature = createTagsFeature();
25
+
26
+ let stack: TestStack;
27
+
28
+ beforeAll(async () => {
29
+ stack = await setupTestStack({ features: [tagsFeature] });
30
+ await unsafeCreateEntityTable(stack.db, tagEntity);
31
+ await unsafeCreateEntityTable(stack.db, tagAssignmentEntity);
32
+ await createEventsTable(stack.db);
33
+ });
34
+
35
+ afterAll(async () => {
36
+ await stack.cleanup();
37
+ });
38
+
39
+ beforeEach(async () => {
40
+ await asRawClient(stack.db).unsafe("DELETE FROM kumiko_events");
41
+ await asRawClient(stack.db).unsafe("DELETE FROM read_tags");
42
+ await asRawClient(stack.db).unsafe("DELETE FROM read_tag_assignments");
43
+ });
44
+
45
+ const admin = createTestUser({ roles: ["TenantAdmin"] });
46
+ const otherTenant = createTestUser({
47
+ roles: ["TenantAdmin"],
48
+ tenantId: "00000000-0000-4000-8000-0000000000aa",
49
+ });
50
+
51
+ async function createTag(name: string, user = admin): Promise<string> {
52
+ const tag = await stack.http.writeOk<{ id: string }>(TagsHandlers.createTag, { name }, user);
53
+ return tag.id;
54
+ }
55
+
56
+ async function assign(tagId: string, entityType: string, entityId: string, user = admin) {
57
+ return stack.http.writeOk(TagsHandlers.assignTag, { tagId, entityType, entityId }, user);
58
+ }
59
+
60
+ async function remove(tagId: string, entityType: string, entityId: string, user = admin) {
61
+ return stack.http.writeOk(TagsHandlers.removeTag, { tagId, entityType, entityId }, user);
62
+ }
63
+
64
+ async function listTags(user = admin): Promise<Array<Record<string, unknown>>> {
65
+ const res = await stack.http.queryOk<{ rows: Array<Record<string, unknown>> }>(
66
+ TagsQueries.tagList,
67
+ {},
68
+ user,
69
+ );
70
+ return res.rows;
71
+ }
72
+
73
+ async function listAssignments(
74
+ filter: { field: string; op: "eq"; value: unknown } | undefined,
75
+ user = admin,
76
+ ): Promise<Array<Record<string, unknown>>> {
77
+ const res = await stack.http.queryOk<{ rows: Array<Record<string, unknown>> }>(
78
+ TagsQueries.assignmentList,
79
+ filter ? { filter } : {},
80
+ user,
81
+ );
82
+ return res.rows;
83
+ }
84
+
85
+ async function countAssignments(tenantId: string): Promise<number> {
86
+ const rows = await asRawClient(stack.db).unsafe(
87
+ "SELECT count(*)::int AS n FROM read_tag_assignments WHERE tenant_id = $1",
88
+ [tenantId],
89
+ );
90
+ return (rows as ReadonlyArray<{ n: number }>)[0]?.n ?? 0;
91
+ }
92
+
93
+ describe("tags integration — catalog + assignment roundtrip", () => {
94
+ test("create-tag lands in read_tags", async () => {
95
+ const id = await createTag("Kunde Müller");
96
+ const tags = await listTags();
97
+ expect(tags).toHaveLength(1);
98
+ expect(tags[0]?.["id"]).toBe(id);
99
+ expect(tags[0]?.["name"]).toBe("Kunde Müller");
100
+ });
101
+
102
+ test("assign-tag → assignment queryable both composition directions", async () => {
103
+ const tagId = await createTag("VIP");
104
+ await assign(tagId, "credit", "credit-1");
105
+
106
+ // tags of an entity
107
+ const byEntity = await listAssignments({ field: "entityId", op: "eq", value: "credit-1" });
108
+ expect(byEntity).toHaveLength(1);
109
+ expect(byEntity[0]?.["tagId"]).toBe(tagId);
110
+ expect(byEntity[0]?.["entityType"]).toBe("credit");
111
+
112
+ // entities carrying a tag
113
+ const byTag = await listAssignments({ field: "tagId", op: "eq", value: tagId });
114
+ expect(byTag).toHaveLength(1);
115
+ expect(byTag[0]?.["entityId"]).toBe("credit-1");
116
+ });
117
+
118
+ test("remove-tag deletes the assignment", async () => {
119
+ const tagId = await createTag("temp");
120
+ await assign(tagId, "credit", "credit-2");
121
+ expect(await countAssignments(admin.tenantId)).toBe(1);
122
+
123
+ await remove(tagId, "credit", "credit-2");
124
+ expect(await countAssignments(admin.tenantId)).toBe(0);
125
+ const left = await listAssignments({ field: "entityId", op: "eq", value: "credit-2" });
126
+ expect(left).toHaveLength(0);
127
+ });
128
+ });
129
+
130
+ describe("tags integration — many-to-many composition", () => {
131
+ test("one entity carries multiple tags", async () => {
132
+ const a = await createTag("rot");
133
+ const b = await createTag("wasser");
134
+ await assign(a, "credit", "credit-3");
135
+ await assign(b, "credit", "credit-3");
136
+
137
+ const tags = await listAssignments({ field: "entityId", op: "eq", value: "credit-3" });
138
+ expect(tags.map((r) => r["tagId"]).sort()).toEqual([a, b].sort());
139
+ });
140
+
141
+ test("one tag spans multiple entities", async () => {
142
+ const tagId = await createTag("Mappe-2026");
143
+ await assign(tagId, "credit", "credit-4");
144
+ await assign(tagId, "credit", "credit-5");
145
+
146
+ const entities = await listAssignments({ field: "tagId", op: "eq", value: tagId });
147
+ expect(entities.map((r) => r["entityId"]).sort()).toEqual(["credit-4", "credit-5"]);
148
+ });
149
+ });
150
+
151
+ describe("tags integration — idempotency", () => {
152
+ test("re-assigning the same (tag, entity) keeps exactly one row", async () => {
153
+ const tagId = await createTag("dup");
154
+ await assign(tagId, "credit", "credit-6");
155
+ await assign(tagId, "credit", "credit-6"); // re-assign: must be a no-op success
156
+
157
+ expect(await countAssignments(admin.tenantId)).toBe(1);
158
+ const rows = await listAssignments({ field: "entityId", op: "eq", value: "credit-6" });
159
+ expect(rows).toHaveLength(1);
160
+ });
161
+
162
+ test("removing a never-assigned (tag, entity) succeeds (no error, no row)", async () => {
163
+ const tagId = await createTag("ghost");
164
+ // never assigned — remove must still succeed (idempotent end-state)
165
+ await remove(tagId, "credit", "credit-7");
166
+ expect(await countAssignments(admin.tenantId)).toBe(0);
167
+ });
168
+ });
169
+
170
+ describe("tags integration — multi-tenant isolation", () => {
171
+ test("tenant B sees neither tenant A's tags nor assignments", async () => {
172
+ const tagId = await createTag("A-only", admin);
173
+ await assign(tagId, "credit", "credit-8", admin);
174
+
175
+ expect(await listTags(otherTenant)).toHaveLength(0);
176
+ expect(
177
+ await listAssignments({ field: "entityId", op: "eq", value: "credit-8" }, otherTenant),
178
+ ).toHaveLength(0);
179
+
180
+ // tenant A still sees its own
181
+ expect(await listTags(admin)).toHaveLength(1);
182
+ expect(await countAssignments(admin.tenantId)).toBe(1);
183
+ expect(await countAssignments(otherTenant.tenantId)).toBe(0);
184
+ });
185
+ });
186
+
187
+ // The access option must reach the runtime, not just the handler shape: a host
188
+ // that mounts tags with openToAll lets a user WITHOUT any default tag role tag
189
+ // freely (the exact failure that bit money-horse, whose signup users carry
190
+ // "Admin", not "TenantAdmin"). Default-mounted tags deny that same user.
191
+ describe("tags integration — openToAll access model", () => {
192
+ let openStack: TestStack;
193
+ // role deliberately not in DEFAULT_TAG_ROLES nor "Admin" — proves openToAll,
194
+ // not an accidental role match.
195
+ const unprivileged = createTestUser({ roles: ["Viewer"] });
196
+
197
+ beforeAll(async () => {
198
+ openStack = await setupTestStack({
199
+ features: [createTagsFeature({ access: { openToAll: true } })],
200
+ });
201
+ await unsafeCreateEntityTable(openStack.db, tagEntity);
202
+ await unsafeCreateEntityTable(openStack.db, tagAssignmentEntity);
203
+ await createEventsTable(openStack.db);
204
+ });
205
+
206
+ afterAll(async () => {
207
+ await openStack.cleanup();
208
+ });
209
+
210
+ test("a non-tag-role user can create, assign, list and remove", async () => {
211
+ const tag = await openStack.http.writeOk<{ id: string }>(
212
+ TagsHandlers.createTag,
213
+ { name: "Paket A" },
214
+ unprivileged,
215
+ );
216
+ await openStack.http.writeOk(
217
+ TagsHandlers.assignTag,
218
+ { tagId: tag.id, entityType: "credit", entityId: "c-1" },
219
+ unprivileged,
220
+ );
221
+
222
+ const tags = await openStack.http.queryOk<{ rows: unknown[] }>(
223
+ TagsQueries.tagList,
224
+ {},
225
+ unprivileged,
226
+ );
227
+ expect(tags.rows).toHaveLength(1);
228
+
229
+ const assigned = await openStack.http.queryOk<{ rows: unknown[] }>(
230
+ TagsQueries.assignmentList,
231
+ { filter: { field: "entityId", op: "eq", value: "c-1" } },
232
+ unprivileged,
233
+ );
234
+ expect(assigned.rows).toHaveLength(1);
235
+
236
+ await openStack.http.writeOk(
237
+ TagsHandlers.removeTag,
238
+ { tagId: tag.id, entityType: "credit", entityId: "c-1" },
239
+ unprivileged,
240
+ );
241
+ });
242
+
243
+ test("the SAME user is denied on a default-role-mounted feature", async () => {
244
+ const denied = await stack.http.writeErr(
245
+ TagsHandlers.createTag,
246
+ { name: "nope" },
247
+ unprivileged,
248
+ );
249
+ expect(denied.httpStatus).toBe(403);
250
+ });
251
+ });