@cosmicdrift/kumiko-bundled-features 0.296.0 → 0.299.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.
@@ -16,12 +16,11 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
16
16
  import { randomBytes } from "node:crypto";
17
17
  import {
18
18
  billingFoundationFeature,
19
- createSubscriptionWebhookHandler,
20
- type SubscriptionProviderPlugin,
19
+ createSubscriptionWebhookRoute,
21
20
  subscriptionAggregateId,
22
21
  } from "@cosmicdrift/kumiko-bundled-features/billing-foundation";
23
22
  import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
24
- import { SYSTEM_TENANT_ID, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
23
+ import { SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
25
24
  import { loadAggregate } from "@cosmicdrift/kumiko-framework/event-store";
26
25
  import { createEnvMasterKeyProvider } from "@cosmicdrift/kumiko-framework/secrets";
27
26
  import {
@@ -33,18 +32,12 @@ import {
33
32
  unsafePushTables,
34
33
  } from "@cosmicdrift/kumiko-framework/stack";
35
34
  import { createTestEnvelopeCipher } from "@cosmicdrift/kumiko-framework/testing";
36
- import { Hono } from "hono";
37
35
  import Stripe from "stripe";
38
36
  import { createComplianceProfilesFeature } from "../../compliance-profiles";
39
37
  import { configValuesTable, createConfigFeature } from "../../config";
40
38
  import { createConfigAccessorFactory } from "../../config/feature";
41
39
  import { createConfigResolver } from "../../config/resolver";
42
- import {
43
- createSecretsContext,
44
- createSecretsFeature,
45
- type SecretsContext,
46
- tenantSecretsTable,
47
- } from "../../secrets";
40
+ import { createSecretsContext, createSecretsFeature, tenantSecretsTable } from "../../secrets";
48
41
  import { createTenantFeature } from "../../tenant/feature";
49
42
  import { tenantEntity } from "../../tenant/schema/tenant";
50
43
  import { createTenantLifecycleFeature } from "../../tenant-lifecycle";
@@ -67,11 +60,6 @@ const PRICE_TO_TIER = { price_pro_monthly: "pro", price_business_yearly: "busine
67
60
 
68
61
  let stack: TestStack;
69
62
  let db: DbConnection;
70
- let webhookApp: Hono;
71
- /** Zweite webhook-app MIT system-secrets gewired — für Scenario 5
72
- * (runtime-secret-Pfad). */
73
- let webhookAppWithSecrets: Hono;
74
- let secretsCtx: SecretsContext;
75
63
 
76
64
  const stripeForFixtures = new Stripe(TEST_API_KEY);
77
65
 
@@ -109,8 +97,15 @@ beforeAll(async () => {
109
97
  configResolver: resolver,
110
98
  configEncryption: encryption,
111
99
  _configAccessorFactory: createConfigAccessorFactory(registry, resolver),
100
+ // Forwarded to every signature extraRoute's verify()/handler() as
101
+ // `deps.secrets` — one webhook route now serves both the factory-
102
+ // fallback scenarios (1–4, no row seeded yet) and the runtime-secret
103
+ // scenario (5, seeded via config:write:set) since runtime.ts's
104
+ // resolver already falls back to the factory options when the store
105
+ // is empty.
112
106
  secrets: createSecretsContext({ db: ctxDb, masterKeyProvider }),
113
107
  }),
108
+ extraRoutes: [createSubscriptionWebhookRoute()],
114
109
  });
115
110
  db = stack.db;
116
111
  // subscriptionsProjectionTable wird von setupTestStack automatisch
@@ -118,46 +113,6 @@ beforeAll(async () => {
118
113
  // secrets brauchen ihre Tabellen explizit.
119
114
  await unsafeCreateEntityTable(db, tenantEntity);
120
115
  await unsafePushTables(db, { configValuesTable, tenant_secrets: tenantSecretsTable });
121
- // Standalone-secrets-context (gleiche KEK) zum direkten Seeden +
122
- // als systemSecrets für die zweite webhook-app.
123
- secretsCtx = createSecretsContext({ db, masterKeyProvider });
124
-
125
- // Webhook-app: Hono mit der webhook-handler-Route.
126
- // dispatchWrite ruft `stack.http.write` mit dem System-User des
127
- // resolved-Tenants — das ist exakt was der App-Builder im echten
128
- // bin/server.ts via extraRoutes wireup macht. `systemSecrets` optional:
129
- // ohne → factory-fallback-Pfad (Scenarios 1–4); mit → runtime-secret-
130
- // Pfad (Scenario 5), exakt wie der App-Owner createSecretsContext wired.
131
- const mountWebhook = (systemSecrets?: SecretsContext): Hono => {
132
- const app = new Hono();
133
- app.post(
134
- "/api/subscription/webhook/:providerName",
135
- createSubscriptionWebhookHandler({
136
- dispatchWrite: async ({ handlerQn, payload, tenantId }) => {
137
- const systemUser = createTestUser({
138
- id: 1,
139
- tenantId: tenantId as TenantId,
140
- roles: ["SystemAdmin"],
141
- });
142
- const res = await stack.http.write(handlerQn, payload, systemUser);
143
- const body = await res.json();
144
- return body.isSuccess
145
- ? { isSuccess: true, data: body.data }
146
- : { isSuccess: false, error: body.error };
147
- },
148
- resolveProvider: (providerName) => {
149
- const usage = stack.registry
150
- .getExtensionUsages("subscriptionProvider")
151
- .find((u) => u.entityName === providerName);
152
- return usage?.options as SubscriptionProviderPlugin | undefined;
153
- },
154
- ...(systemSecrets && { systemSecrets }),
155
- }),
156
- );
157
- return app;
158
- };
159
- webhookApp = mountWebhook();
160
- webhookAppWithSecrets = mountWebhook(secretsCtx);
161
116
  });
162
117
 
163
118
  afterAll(async () => {
@@ -218,8 +173,8 @@ async function signEvent(payload: string, secret = TEST_SECRET): Promise<string>
218
173
  });
219
174
  }
220
175
 
221
- async function postStripeWebhook(payload: string, sig: string, app: Hono = webhookApp) {
222
- return app.request("/api/subscription/webhook/stripe", {
176
+ async function postStripeWebhook(payload: string, sig: string) {
177
+ return stack.app.request("/api/subscription/webhook/stripe", {
223
178
  method: "POST",
224
179
  body: payload,
225
180
  headers: { "stripe-signature": sig, "content-type": "application/json" },
@@ -298,8 +253,11 @@ describe("scenario 2: invalid sig → 401, kein DB-write", () => {
298
253
 
299
254
  const res = await postStripeWebhook(payload, wrongSig);
300
255
  expect(res.status).toBe(401);
256
+ // extraRoutes' generic signature-rejection code (kumiko-framework#3050)
257
+ // — verify() throws a plain Error on sig mismatch, buildServer maps any
258
+ // non-ExtraRouteRejection throw from verify() to this code.
301
259
  const body = (await res.json()) as { error: { code: string } };
302
- expect(body.error.code).toBe("subscription_webhook_signature_invalid");
260
+ expect(body.error.code).toBe("extra_route_signature_invalid");
303
261
 
304
262
  // Drift-pin: foundation-DB ist unberührt — kein subscription-row
305
263
  // für diesen Tenant entstanden.
@@ -427,7 +385,7 @@ describe("scenario 5: runtime-secret resolution", () => {
427
385
  // fallback (TEST_SECRET) nutzen, schlüge die Verifikation fehl.
428
386
  const sig = await signEvent(payload, SEEDED_WEBHOOK_SECRET);
429
387
 
430
- const res = await postStripeWebhook(payload, sig, webhookAppWithSecrets);
388
+ const res = await postStripeWebhook(payload, sig);
431
389
  expect(res.status).toBe(200);
432
390
 
433
391
  const admin = createTestUser({
@@ -452,7 +410,7 @@ describe("scenario 5: runtime-secret resolution", () => {
452
410
  buildStripeSubscriptionEvent({ eventId: "evt_4005_stale", tenantId: testTenantId(4006) }),
453
411
  );
454
412
  const sigWithStaleFallback = await signEvent(payload, TEST_SECRET);
455
- const res = await postStripeWebhook(payload, sigWithStaleFallback, webhookAppWithSecrets);
413
+ const res = await postStripeWebhook(payload, sigWithStaleFallback);
456
414
  expect(res.status).toBe(401);
457
415
  });
458
416
  });
@@ -1,200 +0,0 @@
1
- // Unit-Tests für createSubscriptionWebhookHandler. Treibt alle 5
2
- // HTTP-Pfade durch (400/401/404/500 + 200) via Hono `app.request()` —
3
- // ohne setupTestStack, weil der webhook-handler nur über die deps-
4
- // injection geht und keinen DB-roundtrip braucht.
5
-
6
- import { describe, expect, mock, test } from "bun:test";
7
- import { Hono } from "hono";
8
- import { SubscriptionEventTypes, SubscriptionStatuses } from "../constants";
9
- import type { SubscriptionEvent, SubscriptionProviderPlugin } from "../types";
10
- import { createSubscriptionWebhookHandler, type SubscriptionWebhookDeps } from "../webhook-handler";
11
-
12
- // =============================================================================
13
- // Test-helpers
14
- // =============================================================================
15
-
16
- function buildEvent(): SubscriptionEvent {
17
- return {
18
- providerEventId: "evt_test_001",
19
- providerName: "stripe",
20
- type: SubscriptionEventTypes.created,
21
- tenantId: "tenant-test",
22
- providerCustomerId: "cus_test",
23
- providerSubscriptionId: "sub_test",
24
- status: SubscriptionStatuses.active,
25
- tier: "pro",
26
- currentPeriodEnd: "2026-06-01T00:00:00Z",
27
- rawPayload: '{"raw":"payload"}',
28
- };
29
- }
30
-
31
- function buildPlugin(
32
- overrides: Partial<SubscriptionProviderPlugin> = {},
33
- ): SubscriptionProviderPlugin {
34
- return {
35
- verifyAndParseWebhook: async () => buildEvent(),
36
- ...overrides,
37
- };
38
- }
39
-
40
- function buildDeps(overrides: Partial<SubscriptionWebhookDeps> = {}): SubscriptionWebhookDeps {
41
- return {
42
- dispatchWrite: async () => ({ isSuccess: true, data: { duplicate: false } }),
43
- resolveProvider: () => buildPlugin(),
44
- ...overrides,
45
- };
46
- }
47
-
48
- function buildApp(deps: SubscriptionWebhookDeps) {
49
- const app = new Hono();
50
- app.post("/api/subscription/webhook/:providerName", createSubscriptionWebhookHandler(deps));
51
- return app;
52
- }
53
-
54
- async function postWebhook(app: Hono, providerName: string, body = '{"id":"evt_test"}') {
55
- return app.request(`/api/subscription/webhook/${providerName}`, {
56
- method: "POST",
57
- body,
58
- headers: { "stripe-signature": "test_sig" },
59
- });
60
- }
61
-
62
- // =============================================================================
63
- // Happy path — 200 OK + processed
64
- // =============================================================================
65
-
66
- describe("webhook-handler — happy path", () => {
67
- test("verifyAndParseWebhook → SubscriptionEvent → dispatchWrite → 200 processed", async () => {
68
- const dispatchWrite = mock(async () => ({
69
- isSuccess: true,
70
- data: { duplicate: false, eventAggregateId: "evt-id" },
71
- }));
72
- const app = buildApp(buildDeps({ dispatchWrite }));
73
-
74
- const res = await postWebhook(app, "stripe");
75
- expect(res.status).toBe(200);
76
- const body = (await res.json()) as { processed: boolean; duplicate: boolean };
77
- expect(body.processed).toBe(true);
78
- expect(body.duplicate).toBe(false);
79
-
80
- expect(dispatchWrite).toHaveBeenCalledTimes(1);
81
- expect(dispatchWrite).toHaveBeenCalledWith(
82
- expect.objectContaining({
83
- handlerQn: "billing-foundation:write:process-event",
84
- tenantId: "tenant-test",
85
- payload: expect.objectContaining({
86
- providerEventId: "evt_test_001",
87
- providerName: "stripe",
88
- type: "subscription.created",
89
- tier: "pro",
90
- }),
91
- }),
92
- );
93
- });
94
-
95
- test("plugin returns null (= unbekannter event-type) → 200 ignored, kein dispatch", async () => {
96
- const dispatchWrite = mock();
97
- const plugin = buildPlugin({ verifyAndParseWebhook: async () => null });
98
- const app = buildApp(buildDeps({ dispatchWrite, resolveProvider: () => plugin }));
99
-
100
- const res = await postWebhook(app, "stripe");
101
- expect(res.status).toBe(200);
102
- const body = (await res.json()) as { ignored: boolean };
103
- expect(body.ignored).toBe(true);
104
- expect(dispatchWrite).not.toHaveBeenCalled();
105
- });
106
- });
107
-
108
- // =============================================================================
109
- // Error paths — 400/401/404/500
110
- // =============================================================================
111
-
112
- describe("webhook-handler — error paths", () => {
113
- test("provider not registered → 404 mit subscription_provider_not_registered", async () => {
114
- const app = buildApp(buildDeps({ resolveProvider: () => undefined }));
115
-
116
- const res = await postWebhook(app, "ghost");
117
- expect(res.status).toBe(404);
118
- // Drift-Pin: error-code stable damit Stripe-/Mollie-retry-Logik
119
- // sich auf den Code verlassen kann.
120
- const body = (await res.json()) as { error: { code: string; message: string } };
121
- expect(body.error.code).toBe("subscription_provider_not_registered");
122
- expect(body.error.message).toMatch(/ghost/);
123
- });
124
-
125
- test("plugin throws beim sig-verify → 401 mit subscription_webhook_signature_invalid", async () => {
126
- const plugin = buildPlugin({
127
- verifyAndParseWebhook: async () => {
128
- throw new Error("HMAC mismatch — wrong webhook secret?");
129
- },
130
- });
131
- const app = buildApp(buildDeps({ resolveProvider: () => plugin }));
132
-
133
- const res = await postWebhook(app, "stripe");
134
- expect(res.status).toBe(401);
135
- const body = (await res.json()) as { error: { code: string; message: string } };
136
- expect(body.error.code).toBe("subscription_webhook_signature_invalid");
137
- expect(body.error.message).toMatch(/HMAC mismatch/);
138
- });
139
-
140
- test("dispatchWrite returns isSuccess: false → 500 mit subscription_webhook_processing_failed", async () => {
141
- const dispatchWrite = mock(async () => ({
142
- isSuccess: false,
143
- error: { code: "internal_error", message: "DB unavailable" },
144
- }));
145
- const app = buildApp(buildDeps({ dispatchWrite }));
146
-
147
- const res = await postWebhook(app, "stripe");
148
- expect(res.status).toBe(500);
149
- const body = (await res.json()) as {
150
- error: { code: string; message: string; details: unknown };
151
- };
152
- expect(body.error.code).toBe("subscription_webhook_processing_failed");
153
- // Provider sees 500 + retries — that's the design (transient failure).
154
- expect(body.error.details).toMatchObject({ code: "internal_error" });
155
- });
156
- });
157
-
158
- // =============================================================================
159
- // Mounting-Pattern
160
- // =============================================================================
161
-
162
- describe("webhook-handler — Mounting-Pattern", () => {
163
- test("Multi-Provider: zwei verschiedene URL-Pfade → unterschiedliche plugins", async () => {
164
- // Drift-Pin für die Multi-Provider-Architektur: stripe-Plugin hat
165
- // einen anderen verifyAndParseWebhook als paypal-Plugin. Wenn ein
166
- // Refactor den path-segment-Lookup auf etwas anderes umstellt
167
- // (z.B. globaler config-key wieder), würde dieser Test failen.
168
- const stripeCalls: string[] = [];
169
- const paypalCalls: string[] = [];
170
- const stripePlugin = buildPlugin({
171
- verifyAndParseWebhook: async () => {
172
- stripeCalls.push("called");
173
- return buildEvent();
174
- },
175
- });
176
- const paypalPlugin = buildPlugin({
177
- verifyAndParseWebhook: async () => {
178
- paypalCalls.push("called");
179
- return { ...buildEvent(), providerName: "paypal", providerEventId: "I-PAYPAL-001" };
180
- },
181
- });
182
- const app = buildApp(
183
- buildDeps({
184
- resolveProvider: (name) => {
185
- if (name === "stripe") return stripePlugin;
186
- if (name === "paypal") return paypalPlugin;
187
- return undefined;
188
- },
189
- }),
190
- );
191
-
192
- const stripeRes = await postWebhook(app, "stripe");
193
- const paypalRes = await postWebhook(app, "paypal");
194
-
195
- expect(stripeRes.status).toBe(200);
196
- expect(paypalRes.status).toBe(200);
197
- expect(stripeCalls).toHaveLength(1);
198
- expect(paypalCalls).toHaveLength(1);
199
- });
200
- });