@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.
- package/package.json +9 -9
- package/src/billing-foundation/__tests__/billing-foundation.integration.test.ts +11 -37
- package/src/billing-foundation/__tests__/subscription-tier-sync.integration.test.ts +159 -119
- package/src/billing-foundation/__tests__/webhook-handler.integration.test.ts +174 -0
- package/src/billing-foundation/changes.json +6 -0
- package/src/billing-foundation/feature.ts +3 -3
- package/src/billing-foundation/handlers/process-event.write.ts +1 -1
- package/src/billing-foundation/index.ts +2 -4
- package/src/billing-foundation/subscription-tier-sync.ts +110 -83
- package/src/billing-foundation/webhook-handler.ts +117 -187
- package/src/inbound-mail-foundation/__tests__/connect-routes.integration.test.ts +52 -81
- package/src/inbound-mail-foundation/changes.json +6 -0
- package/src/inbound-mail-foundation/connect-routes.ts +218 -197
- package/src/inbound-mail-foundation/index.ts +1 -2
- package/src/subscription-mollie/__tests__/mollie-foundation.integration.test.ts +7 -40
- package/src/subscription-stripe/__tests__/stripe-foundation.integration.test.ts +18 -60
- package/src/billing-foundation/__tests__/webhook-handler.test.ts +0 -200
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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("
|
|
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
|
|
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
|
|
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
|
-
});
|