@cosmicdrift/kumiko-bundled-features 0.250.0 → 0.252.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.
@@ -0,0 +1,291 @@
1
+ // crypto-shredding forget-subject over a "record" subject (kumiko-framework#2786):
2
+ //
3
+ // - a row-scoped field (personal: { of: "id" } → recordOwned) shreds via
4
+ // forget-subject with { subject: { kind: "record", entity, id } }
5
+ // - the raw kumiko_events ciphertext stays permanently under the
6
+ // record:<entity>:<id> prefix — decrypting it separately proves the
7
+ // erase, not just the projection read path
8
+ // - the record namespace never collides with a "user" subject that
9
+ // happens to share the same uuid
10
+
11
+ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test";
12
+ import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
13
+ import {
14
+ configurePiiSubjectKms,
15
+ decryptPiiFieldValues,
16
+ InMemoryKmsAdapter,
17
+ isPiiCiphertext,
18
+ PII_CIPHERTEXT_PREFIX,
19
+ PII_ERASED_SENTINEL,
20
+ } from "@cosmicdrift/kumiko-framework/crypto";
21
+ import {
22
+ buildEntityTable,
23
+ createEventStoreExecutor,
24
+ createTenantDb,
25
+ } from "@cosmicdrift/kumiko-framework/db";
26
+ import {
27
+ createEntity,
28
+ createTextField,
29
+ defineFeature,
30
+ type TenantId,
31
+ } from "@cosmicdrift/kumiko-framework/engine";
32
+ import { createEventsTable, eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
33
+ import {
34
+ setupTestStack,
35
+ type TestStack,
36
+ testTenantId,
37
+ unsafeCreateEntityTable,
38
+ unsafePushTables,
39
+ } from "@cosmicdrift/kumiko-framework/stack";
40
+ import { resetPiiSubjectKmsForTests, resetTestTables } from "@cosmicdrift/kumiko-framework/testing";
41
+ import { createConfigFeature } from "../../config";
42
+ import { createTenantFeature } from "../../tenant";
43
+ import { tenantMembershipsTable } from "../../tenant/membership-table";
44
+ import { RECORD_ENTITY_NOT_REGISTERED, TARGET_RECORD_NOT_ADMIN_TENANT } from "../constants";
45
+ import { createCryptoShreddingFeature } from "../feature";
46
+
47
+ const FORGET = "crypto-shredding:write:forget-subject";
48
+ const RECORD_PROBE_ENTITY_NAME = "recordProbe";
49
+ const REASON = "authority request #2786 (Art. 17 row-subject)";
50
+
51
+ const recordProbeEntity = createEntity({
52
+ table: "read_forget_subject_record_probe",
53
+ fields: {
54
+ body: createTextField({
55
+ required: true,
56
+ maxLength: 200,
57
+ personal: { of: "id" },
58
+ find: "none",
59
+ }),
60
+ },
61
+ });
62
+ const recordProbeTable = buildEntityTable("forgetSubjectRecordProbe", recordProbeEntity);
63
+ const recordProbeFeature = defineFeature("forget-subject-record-probe", (r) => {
64
+ r.entity(RECORD_PROBE_ENTITY_NAME, recordProbeEntity);
65
+ });
66
+
67
+ function recordProbeExecutor() {
68
+ return createEventStoreExecutor(recordProbeTable, recordProbeEntity, {
69
+ entityName: RECORD_PROBE_ENTITY_NAME,
70
+ });
71
+ }
72
+
73
+ const TENANT: TenantId = testTenantId(21);
74
+
75
+ const dpoUser = {
76
+ id: "cccccccc-cccc-4ccc-8ccc-000000000001",
77
+ tenantId: TENANT,
78
+ roles: ["DataProtectionOfficer"],
79
+ };
80
+
81
+ let stack: TestStack;
82
+ let kms: InMemoryKmsAdapter;
83
+
84
+ beforeAll(async () => {
85
+ stack = await setupTestStack({
86
+ features: [createCryptoShreddingFeature(), recordProbeFeature],
87
+ });
88
+ await unsafeCreateEntityTable(stack.db, recordProbeEntity, RECORD_PROBE_ENTITY_NAME);
89
+ await createEventsTable(stack.db);
90
+ });
91
+
92
+ afterAll(async () => {
93
+ await stack.cleanup();
94
+ });
95
+
96
+ beforeEach(async () => {
97
+ await resetTestTables(stack.db, [recordProbeTable, eventsTable]);
98
+ kms = new InMemoryKmsAdapter();
99
+ configurePiiSubjectKms(kms);
100
+ });
101
+
102
+ afterEach(() => {
103
+ resetPiiSubjectKmsForTests();
104
+ });
105
+
106
+ describe("crypto-shredding :: forget-subject (record subject, #2786)", () => {
107
+ test("erases the row's ciphertext under the record namespace, proven via decrypt; a same-uuid user-forget never touches it", async () => {
108
+ const tenantDb = createTenantDb(stack.db, TENANT, "system");
109
+ const plaintext = "confidential support note";
110
+ const created = await recordProbeExecutor().create({ body: plaintext }, dpoUser, tenantDb);
111
+ if (!created.isSuccess) throw new Error("create failed");
112
+ const rowId = String(created.data.id);
113
+
114
+ const beforeShred = await recordProbeExecutor().detail({ id: rowId }, dpoUser, tenantDb);
115
+ expect(beforeShred?.["body"]).toBe(plaintext);
116
+
117
+ // Namespace separation, checked BEFORE the record subject is touched: a
118
+ // user-forget for the row's own uuid must leave the record row readable
119
+ // — record:<entity>:<id> and user:<id> are different DEKs even when the
120
+ // uuid is identical.
121
+ await stack.http.writeOk(
122
+ FORGET,
123
+ { subject: { kind: "user", userId: rowId }, reason: REASON },
124
+ dpoUser,
125
+ );
126
+ const afterBystanderForget = await recordProbeExecutor().detail(
127
+ { id: rowId },
128
+ dpoUser,
129
+ tenantDb,
130
+ );
131
+ expect(afterBystanderForget?.["body"]).toBe(plaintext);
132
+
133
+ const result = await stack.http.writeOk<{ subjectKey: string }>(
134
+ FORGET,
135
+ {
136
+ subject: { kind: "record", entity: RECORD_PROBE_ENTITY_NAME, id: rowId },
137
+ reason: REASON,
138
+ },
139
+ dpoUser,
140
+ );
141
+ expect(result.subjectKey).toBe(`record:${RECORD_PROBE_ENTITY_NAME}:${rowId}`);
142
+
143
+ const afterShred = await recordProbeExecutor().detail({ id: rowId }, dpoUser, tenantDb);
144
+ expect(afterShred?.["body"]).toBe(PII_ERASED_SENTINEL);
145
+
146
+ const createdEvents = (await selectMany(stack.db, eventsTable, {
147
+ aggregateId: rowId,
148
+ type: `${RECORD_PROBE_ENTITY_NAME}.created`,
149
+ })) as Array<{ payload: Record<string, unknown> }>;
150
+ expect(createdEvents).toHaveLength(1);
151
+ const rawBody = createdEvents[0]?.payload["body"];
152
+ expect(typeof rawBody).toBe("string");
153
+ expect(isPiiCiphertext(rawBody)).toBe(true);
154
+ expect(String(rawBody)).toStartWith(
155
+ `${PII_CIPHERTEXT_PREFIX}record:${RECORD_PROBE_ENTITY_NAME}:${rowId}:`,
156
+ );
157
+ expect(String(rawBody)).not.toContain(plaintext);
158
+
159
+ const decrypted = await decryptPiiFieldValues({ body: rawBody }, ["body"], kms, {
160
+ requestId: "test",
161
+ });
162
+ expect(decrypted["body"]).toBe(PII_ERASED_SENTINEL);
163
+ });
164
+
165
+ // Empirical: update()'s previous-row re-encrypt (encryptForStorage(previous))
166
+ // sees the erased sentinel and passes it through unchanged (pii-field-
167
+ // encryption.ts's isPiiCiphertext/PII_ERASED_SENTINEL short-circuit) — but
168
+ // the NEW value in `changes` is real plaintext, so it still needs a DEK.
169
+ // getOrCreateDek's getKey call on a tombstoned subject throws KeyErasedError,
170
+ // which propagates out of update() uncaught (only version-conflict errors
171
+ // are caught there). Not record-specific: the same getOrCreateDek path runs
172
+ // for a user/tenant PII field after that subject is forgotten.
173
+ test("update on an already-shredded row rejects instead of writing a fresh plaintext value", async () => {
174
+ const tenantDb = createTenantDb(stack.db, TENANT, "system");
175
+ const created = await recordProbeExecutor().create(
176
+ { body: "pre-shred content" },
177
+ dpoUser,
178
+ tenantDb,
179
+ );
180
+ if (!created.isSuccess) throw new Error("create failed");
181
+ const rowId = String(created.data.id);
182
+
183
+ await stack.http.writeOk(
184
+ FORGET,
185
+ { subject: { kind: "record", entity: RECORD_PROBE_ENTITY_NAME, id: rowId }, reason: REASON },
186
+ dpoUser,
187
+ );
188
+
189
+ await expect(
190
+ recordProbeExecutor().update(
191
+ { id: rowId, changes: { body: "post-shred content" } },
192
+ dpoUser,
193
+ tenantDb,
194
+ { skipOptimisticLock: true },
195
+ ),
196
+ ).rejects.toThrow(`Subject key erased: record:${RECORD_PROBE_ENTITY_NAME}:${rowId}`);
197
+ });
198
+ });
199
+
200
+ // mh#349 / #2786: resolveTenantScopeDenial's record branch has its own
201
+ // `!features.has("tenant")` fail-open — a denial test against the top-of-file
202
+ // stack (crypto-shredding only) would pass without proving anything, since
203
+ // the tenant gate never even runs there. This describe mounts a separate
204
+ // stack with the tenant feature actually present so the two denial branches
205
+ // (RECORD_ENTITY_NOT_REGISTERED, TARGET_RECORD_NOT_ADMIN_TENANT) get real
206
+ // coverage.
207
+ describe("crypto-shredding :: forget-subject (record subject) tenant gate, #2786", () => {
208
+ let gateStack: TestStack;
209
+ let gateKms: InMemoryKmsAdapter;
210
+
211
+ const GATE_TENANT_A: TenantId = testTenantId(22);
212
+ const GATE_TENANT_B: TenantId = testTenantId(23);
213
+
214
+ const dpoTenantA = {
215
+ id: "cccccccc-cccc-4ccc-8ccc-000000000002",
216
+ tenantId: GATE_TENANT_A,
217
+ roles: ["DataProtectionOfficer"],
218
+ };
219
+ const dpoTenantB = {
220
+ id: "cccccccc-cccc-4ccc-8ccc-000000000003",
221
+ tenantId: GATE_TENANT_B,
222
+ roles: ["DataProtectionOfficer"],
223
+ };
224
+
225
+ beforeAll(async () => {
226
+ gateStack = await setupTestStack({
227
+ features: [
228
+ createCryptoShreddingFeature(),
229
+ createConfigFeature(),
230
+ createTenantFeature(),
231
+ recordProbeFeature,
232
+ ],
233
+ });
234
+ await unsafeCreateEntityTable(gateStack.db, recordProbeEntity, RECORD_PROBE_ENTITY_NAME);
235
+ await unsafePushTables(gateStack.db, { tenantMembershipsTable });
236
+ await createEventsTable(gateStack.db);
237
+ });
238
+
239
+ afterAll(async () => {
240
+ await gateStack.cleanup();
241
+ });
242
+
243
+ beforeEach(async () => {
244
+ await resetTestTables(gateStack.db, [recordProbeTable, eventsTable]);
245
+ gateKms = new InMemoryKmsAdapter();
246
+ configurePiiSubjectKms(gateKms);
247
+ });
248
+
249
+ afterEach(() => {
250
+ resetPiiSubjectKmsForTests();
251
+ });
252
+
253
+ test("DPO from another tenant cannot forget a record subject owned by a foreign tenant → denied, key survives", async () => {
254
+ const tenantADb = createTenantDb(gateStack.db, GATE_TENANT_A, "system");
255
+ const plaintext = "tenant A confidential note";
256
+ const created = await recordProbeExecutor().create({ body: plaintext }, dpoTenantA, tenantADb);
257
+ if (!created.isSuccess) throw new Error("create failed");
258
+ const rowId = String(created.data.id);
259
+
260
+ const err = await gateStack.http.writeErr(
261
+ FORGET,
262
+ {
263
+ subject: { kind: "record", entity: RECORD_PROBE_ENTITY_NAME, id: rowId },
264
+ reason: REASON,
265
+ },
266
+ dpoTenantB,
267
+ );
268
+ expect(err.httpStatus).toBe(403);
269
+ expect((err.details as { reason?: string } | undefined)?.reason).toBe(
270
+ TARGET_RECORD_NOT_ADMIN_TENANT,
271
+ );
272
+
273
+ const stillReadable = await recordProbeExecutor().detail({ id: rowId }, dpoTenantA, tenantADb);
274
+ expect(stillReadable?.["body"]).toBe(plaintext);
275
+ });
276
+
277
+ test("record subject naming an unregistered entity is denied, not resolved into raw SQL", async () => {
278
+ const err = await gateStack.http.writeErr(
279
+ FORGET,
280
+ {
281
+ subject: { kind: "record", entity: "notARegisteredEntity", id: crypto.randomUUID() },
282
+ reason: REASON,
283
+ },
284
+ dpoTenantA,
285
+ );
286
+ expect(err.httpStatus).toBe(403);
287
+ expect((err.details as { reason?: string } | undefined)?.reason).toBe(
288
+ RECORD_ENTITY_NOT_REGISTERED,
289
+ );
290
+ });
291
+ });
@@ -6,3 +6,5 @@ export const SUBJECT_FORGET_DENIED_EVENT_NAME = "crypto-shredding:event:forget-d
6
6
  export const CRYPTO_SHREDDING_AGGREGATE_TYPE = "crypto-shredding-subject";
7
7
 
8
8
  export const TARGET_TENANT_NOT_ADMIN_TENANT = "target_tenant_not_admin_tenant" as const;
9
+ export const RECORD_ENTITY_NOT_REGISTERED = "record_entity_not_registered" as const;
10
+ export const TARGET_RECORD_NOT_ADMIN_TENANT = "target_record_not_admin_tenant" as const;
@@ -9,10 +9,12 @@ import {
9
9
  import {
10
10
  type DbRunner,
11
11
  nullBlindIndexesForSubject,
12
+ recordRowExistsInTenant,
12
13
  subjectRowExistsInTenant,
13
14
  } from "@cosmicdrift/kumiko-framework/db";
14
15
  import {
15
16
  defineWriteHandler,
17
+ type EntityDefinition,
16
18
  type FeatureDefinition,
17
19
  type HandlerContext,
18
20
  type SessionUser,
@@ -38,14 +40,24 @@ import {
38
40
  } from "../../user-data-rights";
39
41
  import {
40
42
  CRYPTO_SHREDDING_AGGREGATE_TYPE,
43
+ RECORD_ENTITY_NOT_REGISTERED,
41
44
  SUBJECT_FORGET_DENIED_EVENT_NAME,
42
45
  SUBJECT_FORGOTTEN_EVENT_NAME,
46
+ TARGET_RECORD_NOT_ADMIN_TENANT,
43
47
  TARGET_TENANT_NOT_ADMIN_TENANT,
44
48
  } from "../constants";
45
49
 
50
+ // Registry entity names are identifier-shaped; ":" would break the subject-key round-trip.
51
+ const RECORD_ENTITY_PATTERN = /^[A-Za-z][A-Za-z0-9_-]*$/;
52
+
46
53
  export const subjectIdSchema = z.discriminatedUnion("kind", [
47
54
  z.object({ kind: z.literal("user"), userId: z.uuid() }),
48
55
  z.object({ kind: z.literal("tenant"), tenantId: z.uuid() }),
56
+ z.object({
57
+ kind: z.literal("record"),
58
+ entity: z.string().regex(RECORD_ENTITY_PATTERN),
59
+ id: z.uuid(),
60
+ }),
49
61
  ]);
50
62
 
51
63
  export const forgetSubjectSchema = z.object({
@@ -61,7 +73,7 @@ export const subjectForgottenSchema = z.object({
61
73
 
62
74
  export const subjectForgetDeniedSchema = z.object({
63
75
  subjectKeyDigest: z.string().min(1),
64
- subjectKind: z.enum(["user", "tenant"]),
76
+ subjectKind: z.enum(["user", "tenant", "record"]),
65
77
  reason: z.string().min(10),
66
78
  forgottenBy: z.string().min(1),
67
79
  actorTenantId: z.string().min(1),
@@ -70,6 +82,36 @@ export const subjectForgetDeniedSchema = z.object({
70
82
 
71
83
  type SubjectIdInput = z.infer<typeof subjectIdSchema>;
72
84
 
85
+ // Exhaustive switch so a 4th subject kind fails to compile instead of
86
+ // silently aggregating under the wrong id.
87
+ function subjectAggregateId(raw: SubjectIdInput): string {
88
+ switch (raw.kind) {
89
+ case "user":
90
+ return raw.userId;
91
+ case "tenant":
92
+ return raw.tenantId;
93
+ case "record":
94
+ return raw.id;
95
+ default: {
96
+ const exhaustiveCheck: never = raw;
97
+ throw new Error(`Unhandled subject kind: ${JSON.stringify(exhaustiveCheck)}`);
98
+ }
99
+ }
100
+ }
101
+
102
+ // The record subject names its own entity — resolve it against the registry
103
+ // instead of trusting the payload straight into resolveTableName/SQL (#2786).
104
+ function findRegisteredEntity(
105
+ features: ReadonlyMap<string, FeatureDefinition>,
106
+ entityName: string,
107
+ ): EntityDefinition | undefined {
108
+ for (const feature of features.values()) {
109
+ const entity = feature.entities?.[entityName];
110
+ if (entity) return entity;
111
+ }
112
+ return undefined;
113
+ }
114
+
73
115
  // Tenant-scope guard (mh#349): DataProtectionOfficer is a tenant-scoped role,
74
116
  // but the handler otherwise erases ANY subject cross-tenant on a raw
75
117
  // (un-scoped) client — a Tenant-A DPO who learns a Tenant-B subject id
@@ -90,6 +132,30 @@ async function resolveTenantScopeDenial(
90
132
  );
91
133
  }
92
134
 
135
+ if (raw.kind === "record") {
136
+ // Same fail-open rule as the user branch below — without the tenant
137
+ // feature there is no tenant concept to enforce.
138
+ if (!features.has("tenant")) return undefined;
139
+ const entity = findRegisteredEntity(features, raw.entity);
140
+ if (!entity) {
141
+ return writeFailure(
142
+ new AccessDeniedError({ details: { reason: RECORD_ENTITY_NOT_REGISTERED } }),
143
+ );
144
+ }
145
+ const ownedInTenant = await recordRowExistsInTenant(
146
+ db,
147
+ features,
148
+ raw.entity,
149
+ raw.id,
150
+ user.tenantId,
151
+ );
152
+ return ownedInTenant
153
+ ? undefined
154
+ : writeFailure(
155
+ new AccessDeniedError({ details: { reason: TARGET_RECORD_NOT_ADMIN_TENANT } }),
156
+ );
157
+ }
158
+
93
159
  // Without the tenant feature there's no membership table to check against —
94
160
  // no scoping concept exists to enforce (single/no-tenant apps only;
95
161
  // ponytail: fail-open here, not a gap in multi-tenant apps).
@@ -188,7 +254,7 @@ export const forgetSubjectWrite = defineWriteHandler({
188
254
  schema: forgetSubjectSchema,
189
255
  access: { roles: [ROLES.DataProtectionOfficer, ROLES.SystemAdmin] },
190
256
  description:
191
- "Irreversibly crypto-shreds one user or tenant subject by erasing its encryption key, nulling its blind indexes, purging its search documents and closing the user's login, for supervisory-authority requests and operator recovery outside the automated Art. 17 cleanup pipeline.",
257
+ "Irreversibly crypto-shreds one user, tenant or record subject by erasing its encryption key, nulling its blind indexes, purging its search documents and closing the user's login, for supervisory-authority requests and operator recovery outside the automated Art. 17 cleanup pipeline.",
192
258
  // Erasing the subject key is irreversible: there is no undo, so an agent must
193
259
  // not be able to reach it at all.
194
260
  agent: { expose: false },
@@ -208,7 +274,9 @@ export const forgetSubjectWrite = defineWriteHandler({
208
274
  const subject: SubjectId =
209
275
  raw.kind === "user"
210
276
  ? { kind: "user", userId: raw.userId }
211
- : { kind: "tenant", tenantId: raw.tenantId as TenantId }; // @cast-boundary uuid-validated command payload → branded id
277
+ : raw.kind === "tenant"
278
+ ? { kind: "tenant", tenantId: raw.tenantId as TenantId } // @cast-boundary uuid-validated command payload → branded id
279
+ : { kind: "record", entity: raw.entity, id: raw.id };
212
280
  const subjectKey = subjectIdToKey(subject);
213
281
 
214
282
  const tenantScopeDenial = await resolveTenantScopeDenial(
@@ -288,7 +356,7 @@ export const forgetSubjectWrite = defineWriteHandler({
288
356
  }
289
357
 
290
358
  await ctx.unsafeAppendEvent({
291
- aggregateId: raw.kind === "user" ? raw.userId : raw.tenantId,
359
+ aggregateId: subjectAggregateId(raw),
292
360
  aggregateType: CRYPTO_SHREDDING_AGGREGATE_TYPE,
293
361
  type: SUBJECT_FORGOTTEN_EVENT_NAME,
294
362
  payload: {
@@ -109,6 +109,8 @@ describe("constants — Stripe-event-types die wir mappen", () => {
109
109
  "customer.subscription.deleted",
110
110
  "invoice.paid",
111
111
  "invoice.payment_failed",
112
+ "checkout.session.completed",
113
+ "checkout.session.async_payment_succeeded",
112
114
  ]);
113
115
  });
114
116
  });
@@ -0,0 +1,167 @@
1
+ // Unit tests for the one-off-payment branch of verifyAndParseStripeWebhook
2
+ // (checkout.session.completed / .async_payment_succeeded). Split out of
3
+ // verify-webhook.test.ts — that file's fixtures stay subscription/invoice-
4
+ // only, this one owns every checkout.session.* fixture.
5
+
6
+ import { describe, expect, test } from "bun:test";
7
+ import { BillingEventKinds } from "@cosmicdrift/kumiko-bundled-features/billing-foundation";
8
+ import Stripe from "stripe";
9
+ import type { StripeWebhookRuntime } from "../runtime";
10
+ import { verifyAndParseStripeWebhook } from "../verify-webhook";
11
+
12
+ const TEST_SECRET = "whsec_test_secret_12345";
13
+ const TEST_API_KEY = "sk_test_dummy_apikey";
14
+
15
+ const stripeForFixtures = new Stripe(TEST_API_KEY);
16
+
17
+ async function signEvent(payload: string, secret = TEST_SECRET): Promise<string> {
18
+ return stripeForFixtures.webhooks.generateTestHeaderStringAsync({ payload, secret });
19
+ }
20
+
21
+ function buildCheckoutSessionEvent(overrides: {
22
+ eventType?: string;
23
+ eventId?: string;
24
+ sessionId?: string;
25
+ mode?: string;
26
+ paymentStatus?: string;
27
+ }) {
28
+ return {
29
+ id: overrides.eventId ?? "evt_checkout_001",
30
+ object: "event",
31
+ api_version: "2026-04-22.dahlia",
32
+ created: 1_770_000_000,
33
+ type: overrides.eventType ?? "checkout.session.completed",
34
+ livemode: false,
35
+ pending_webhooks: 1,
36
+ request: { id: null, idempotency_key: null },
37
+ data: {
38
+ object: {
39
+ id: overrides.sessionId ?? "cs_test_001",
40
+ object: "checkout.session",
41
+ mode: overrides.mode ?? "payment",
42
+ payment_status: overrides.paymentStatus ?? "paid",
43
+ },
44
+ },
45
+ };
46
+ }
47
+
48
+ /** Stub-client: real Stripe.webhooks (sig-verify), fake checkout.sessions.retrieve
49
+ * (the lazy-fetch). No real network precedent for this call in the suite, so a
50
+ * stub result is the intended pattern (advisor-endorsed), not a mock-out of
51
+ * application logic under test. */
52
+ function webhookRuntimeWithRetrieve(
53
+ retrieve: () => Promise<unknown>,
54
+ webhookSecret = TEST_SECRET,
55
+ ): StripeWebhookRuntime {
56
+ const stripe = {
57
+ webhooks: stripeForFixtures.webhooks,
58
+ checkout: { sessions: { retrieve } },
59
+ } as unknown as Stripe; // @cast-boundary engine-bridge
60
+ return { resolve: async () => ({ stripe, webhookSecret }) };
61
+ }
62
+
63
+ function throwingRetrieve(): Promise<never> {
64
+ throw new Error("checkout.sessions.retrieve must not be called for a non-payment/unpaid session");
65
+ }
66
+
67
+ function buildExpandedSession(overrides: {
68
+ sessionId?: string;
69
+ tenantId?: string;
70
+ priceId?: string;
71
+ customerId?: string;
72
+ }) {
73
+ return {
74
+ id: overrides.sessionId ?? "cs_test_001",
75
+ customer: overrides.customerId ?? "cus_test_checkout",
76
+ payment_intent: {
77
+ id: "pi_test_001",
78
+ metadata: { tenantId: overrides.tenantId ?? "tenant-test-1" },
79
+ customer: overrides.customerId ?? "cus_test_checkout",
80
+ },
81
+ line_items: {
82
+ object: "list",
83
+ data: [{ id: "li_test_001", price: { id: overrides.priceId ?? "price_topup_test" } }],
84
+ },
85
+ };
86
+ }
87
+
88
+ describe("verifyAndParseStripeWebhook — one-off payment (checkout.session.*)", () => {
89
+ test("mode: 'subscription' → no PaymentEvent, lazy-fetch never called", async () => {
90
+ const verify = verifyAndParseStripeWebhook(webhookRuntimeWithRetrieve(throwingRetrieve), {
91
+ priceToTier: {},
92
+ });
93
+ const payload = JSON.stringify(
94
+ buildCheckoutSessionEvent({ mode: "subscription", paymentStatus: "paid" }),
95
+ );
96
+ const sig = await signEvent(payload);
97
+
98
+ const event = await verify(payload, { "stripe-signature": sig });
99
+ expect(event).toBeNull();
100
+ });
101
+
102
+ test("checkout.session.completed with payment_status: 'unpaid' → no PaymentEvent", async () => {
103
+ const verify = verifyAndParseStripeWebhook(webhookRuntimeWithRetrieve(throwingRetrieve), {
104
+ priceToTier: {},
105
+ });
106
+ const payload = JSON.stringify(
107
+ buildCheckoutSessionEvent({
108
+ eventType: "checkout.session.completed",
109
+ mode: "payment",
110
+ paymentStatus: "unpaid",
111
+ }),
112
+ );
113
+ const sig = await signEvent(payload);
114
+
115
+ const event = await verify(payload, { "stripe-signature": sig });
116
+ expect(event).toBeNull();
117
+ });
118
+
119
+ test("checkout.session.async_payment_succeeded, mode: payment, paid → PaymentEvent", async () => {
120
+ const verify = verifyAndParseStripeWebhook(
121
+ webhookRuntimeWithRetrieve(async () => buildExpandedSession({})),
122
+ { priceToTier: {} },
123
+ );
124
+ const payload = JSON.stringify(
125
+ buildCheckoutSessionEvent({
126
+ eventType: "checkout.session.async_payment_succeeded",
127
+ eventId: "evt_checkout_async_001",
128
+ mode: "payment",
129
+ paymentStatus: "paid",
130
+ }),
131
+ );
132
+ const sig = await signEvent(payload);
133
+
134
+ const event = await verify(payload, { "stripe-signature": sig });
135
+ expect(event).not.toBeNull();
136
+ if (!event || event.kind !== BillingEventKinds.payment) {
137
+ throw new Error("expected a PaymentEvent");
138
+ }
139
+ expect(event.providerEventId).toBe("evt_checkout_async_001");
140
+ expect(event.providerName).toBe("stripe");
141
+ expect(event.tenantId).toBe("tenant-test-1");
142
+ expect(event.priceId).toBe("price_topup_test");
143
+ expect(event.providerCustomerId).toBe("cus_test_checkout");
144
+ });
145
+
146
+ test("checkout.session.completed, mode: payment, paid, missing tenantId metadata → null", async () => {
147
+ const verify = verifyAndParseStripeWebhook(
148
+ webhookRuntimeWithRetrieve(async () => ({
149
+ ...buildExpandedSession({}),
150
+ payment_intent: { id: "pi_test_002", metadata: {}, customer: "cus_test_checkout" },
151
+ })),
152
+ { priceToTier: {} },
153
+ );
154
+ const payload = JSON.stringify(
155
+ buildCheckoutSessionEvent({
156
+ eventType: "checkout.session.completed",
157
+ eventId: "evt_checkout_no_tenant",
158
+ mode: "payment",
159
+ paymentStatus: "paid",
160
+ }),
161
+ );
162
+ const sig = await signEvent(payload);
163
+
164
+ const event = await verify(payload, { "stripe-signature": sig });
165
+ expect(event).toBeNull();
166
+ });
167
+ });