@cosmicdrift/kumiko-bundled-features 0.41.1 → 0.43.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 +1 -1
- package/src/__tests__/env-schemas.test.ts +6 -1
- package/src/billing-foundation/types.ts +16 -7
- package/src/billing-foundation/webhook-handler.ts +10 -1
- package/src/subscription-stripe/__tests__/feature.test.ts +27 -24
- package/src/subscription-stripe/__tests__/plugin-methods.test.ts +47 -6
- package/src/subscription-stripe/__tests__/runtime.test.ts +180 -0
- package/src/subscription-stripe/__tests__/stripe-foundation.integration.test.ts +156 -31
- package/src/subscription-stripe/__tests__/verify-webhook.test.ts +10 -6
- package/src/subscription-stripe/constants.ts +11 -0
- package/src/subscription-stripe/feature.ts +128 -104
- package/src/subscription-stripe/plugin-methods.ts +20 -8
- package/src/subscription-stripe/runtime.ts +156 -0
- package/src/subscription-stripe/verify-webhook.ts +33 -21
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.43.0",
|
|
4
4
|
"description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -245,8 +245,13 @@ describe("compose across all Phase-2 features", () => {
|
|
|
245
245
|
const out = asBootError(err).format();
|
|
246
246
|
expect(out).toContain("✗ JWT_SECRET (auth-email-password, required, missing)");
|
|
247
247
|
expect(out).toContain("✗ KUMIKO_SECRETS_MASTER_KEY_V1 (secrets, required, missing)");
|
|
248
|
-
expect(out).toContain("✗ STRIPE_API_KEY (subscription-stripe, required, missing)");
|
|
249
248
|
expect(out).toContain("✗ MOLLIE_API_KEY (subscription-mollie, required, missing)");
|
|
249
|
+
// subscription-stripe-v2: STRIPE_API_KEY/STRIPE_WEBHOOK_SECRET sind
|
|
250
|
+
// jetzt optionale env→secrets-Bridge-Fallbacks, also KEIN missing-
|
|
251
|
+
// required-Boot-Fehler mehr — credentials kommen zur Laufzeit aus
|
|
252
|
+
// secrets.
|
|
253
|
+
expect(out).not.toContain("STRIPE_API_KEY");
|
|
254
|
+
expect(out).not.toContain("STRIPE_WEBHOOK_SECRET");
|
|
250
255
|
}
|
|
251
256
|
});
|
|
252
257
|
});
|
|
@@ -6,10 +6,14 @@
|
|
|
6
6
|
// **Two-phase plugin contract:**
|
|
7
7
|
// 1. **Pre-tenant-resolution:** `verifyAndParseWebhook` läuft BEVOR
|
|
8
8
|
// ein Tenant aus dem Event aufgelöst ist — kein HandlerContext
|
|
9
|
-
// verfügbar.
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
9
|
+
// verfügbar. Das **Webhook-secret ist app-wide** (App-Owner's
|
|
10
|
+
// Stripe-/PayPal-Account, nicht Tenant-Sache). Damit ein Plugin den
|
|
11
|
+
// secret ZUR LAUFFZEIT aus dem secrets-Feature lesen kann (statt aus
|
|
12
|
+
// einem mount-time-Closure), reicht der webhook-handler einen
|
|
13
|
+
// optionalen system-scoped `SecretsContext` als 3. Arg durch — der
|
|
14
|
+
// Plugin liest seine app-wide-Secrets daraus un-audited unter
|
|
15
|
+
// SYSTEM_TENANT_ID. Plugins, die ihre Keys weiter aus einem Closure
|
|
16
|
+
// halten (z.B. mollie), ignorieren den Param → backward-compatible.
|
|
13
17
|
// 2. **Post-tenant-resolution:** `createPortalSession` +
|
|
14
18
|
// `cancelSubscription` werden aus regulären write-handlern
|
|
15
19
|
// gerufen mit voll-aufgelöstem HandlerContext. Plugin kann
|
|
@@ -21,6 +25,7 @@
|
|
|
21
25
|
// und sind über den Customer-Portal-Link erreichbar.
|
|
22
26
|
|
|
23
27
|
import type { HandlerContext } from "@cosmicdrift/kumiko-framework/engine";
|
|
28
|
+
import type { SecretsContext } from "@cosmicdrift/kumiko-framework/secrets";
|
|
24
29
|
import type { SubscriptionEventType, SubscriptionStatus } from "./constants";
|
|
25
30
|
|
|
26
31
|
// =============================================================================
|
|
@@ -75,9 +80,12 @@ export type SubscriptionProviderPlugin = {
|
|
|
75
80
|
* (Plugin macht die Tenant-Resolution selbst aus dem provider-
|
|
76
81
|
* payload metadata).
|
|
77
82
|
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
83
|
+
* App-wide-secret = App-Owner's eigener Provider-Account. Der Plugin
|
|
84
|
+
* liest ihn entweder aus einem mount-time-Closure ODER — zur Laufzeit
|
|
85
|
+
* rotierbar — aus dem optionalen `systemSecrets`-Arg (system-scoped
|
|
86
|
+
* SecretsContext, vom webhook-handler durchgereicht). `systemSecrets`
|
|
87
|
+
* ist undefined, wenn der App-Owner keinen wired; Plugins müssen dann
|
|
88
|
+
* auf ihren Closure-Fallback zurückfallen.
|
|
81
89
|
*
|
|
82
90
|
* Returns null für events die der Plugin nicht versteht oder die
|
|
83
91
|
* foundation nicht braucht (= filter out, foundation returnt 200
|
|
@@ -90,6 +98,7 @@ export type SubscriptionProviderPlugin = {
|
|
|
90
98
|
readonly verifyAndParseWebhook: (
|
|
91
99
|
rawBody: string,
|
|
92
100
|
headers: Record<string, string>,
|
|
101
|
+
systemSecrets?: SecretsContext,
|
|
93
102
|
) => Promise<SubscriptionEvent | null>;
|
|
94
103
|
|
|
95
104
|
/**
|
|
@@ -72,6 +72,15 @@ export type SubscriptionWebhookDeps = {
|
|
|
72
72
|
* Pfad und returnt den passenden Plugin (= entityName-match in
|
|
73
73
|
* registry.getExtensionUsages("subscriptionProvider")). */
|
|
74
74
|
readonly resolveProvider: (providerName: string) => SubscriptionProviderPlugin | undefined;
|
|
75
|
+
|
|
76
|
+
/** Optionaler system-scoped SecretsContext, durchgereicht an
|
|
77
|
+
* `verifyAndParseWebhook` (3. Arg). Erlaubt Plugins, ihre app-wide-
|
|
78
|
+
* Credentials (Stripe api-key/webhook-secret) zur Laufzeit aus
|
|
79
|
+
* secrets unter SYSTEM_TENANT_ID zu lesen statt aus einem mount-time-
|
|
80
|
+
* Closure. Der App-Owner baut ihn via `createSecretsContext({ db,
|
|
81
|
+
* masterKeyProvider })` aus den extraRoutes-deps. Fehlt er, fallen
|
|
82
|
+
* Plugins auf ihren Closure-Fallback zurück. */
|
|
83
|
+
readonly systemSecrets?: import("@cosmicdrift/kumiko-framework/secrets").SecretsContext;
|
|
75
84
|
};
|
|
76
85
|
|
|
77
86
|
/**
|
|
@@ -124,7 +133,7 @@ export function createSubscriptionWebhookHandler(deps: SubscriptionWebhookDeps)
|
|
|
124
133
|
// retry won't help, Provider stopp").
|
|
125
134
|
let parsed: Awaited<ReturnType<SubscriptionProviderPlugin["verifyAndParseWebhook"]>>;
|
|
126
135
|
try {
|
|
127
|
-
parsed = await plugin.verifyAndParseWebhook(rawBody, headers);
|
|
136
|
+
parsed = await plugin.verifyAndParseWebhook(rawBody, headers, deps.systemSecrets);
|
|
128
137
|
} catch (e) {
|
|
129
138
|
const msg = e instanceof Error ? e.message : String(e);
|
|
130
139
|
return c.json(
|
|
@@ -4,52 +4,55 @@ import { describe, expect, test } from "bun:test";
|
|
|
4
4
|
import { STRIPE_PROVIDER_NAME, StripeEventTypes, SUBSCRIPTION_STRIPE_FEATURE } from "../constants";
|
|
5
5
|
import { createSubscriptionStripeFeature } from "../feature";
|
|
6
6
|
|
|
7
|
-
const
|
|
8
|
-
webhookSecret: "whsec_test_dummy",
|
|
9
|
-
apiKey: "sk_test_dummy",
|
|
7
|
+
const OPTIONS = {
|
|
10
8
|
priceToTier: { price_test: "pro" },
|
|
11
9
|
};
|
|
12
10
|
|
|
13
11
|
describe("createSubscriptionStripeFeature — shape", () => {
|
|
14
12
|
test("has the expected name", () => {
|
|
15
|
-
const feature = createSubscriptionStripeFeature(
|
|
13
|
+
const feature = createSubscriptionStripeFeature(OPTIONS);
|
|
16
14
|
expect(feature.name).toBe(SUBSCRIPTION_STRIPE_FEATURE);
|
|
17
15
|
expect(feature.name).toBe("subscription-stripe");
|
|
18
16
|
});
|
|
19
17
|
|
|
20
|
-
test("requires
|
|
21
|
-
const feature = createSubscriptionStripeFeature(
|
|
18
|
+
test("requires billing-foundation + config + secrets (runtime-keys via config/secrets)", () => {
|
|
19
|
+
const feature = createSubscriptionStripeFeature(OPTIONS);
|
|
22
20
|
expect(feature.requires).toContain("billing-foundation");
|
|
23
|
-
// Drift-Pin: webhook-secret
|
|
24
|
-
//
|
|
25
|
-
expect(feature.requires).
|
|
26
|
-
expect(feature.requires).
|
|
21
|
+
// Drift-Pin (v2): api-key/webhook-secret kommen ZUR LAUFZEIT aus
|
|
22
|
+
// secrets, billing-live aus config — daher harte deps auf beide.
|
|
23
|
+
expect(feature.requires).toContain("config");
|
|
24
|
+
expect(feature.requires).toContain("secrets");
|
|
27
25
|
});
|
|
28
26
|
});
|
|
29
27
|
|
|
30
|
-
describe("createSubscriptionStripeFeature —
|
|
31
|
-
test("
|
|
28
|
+
describe("createSubscriptionStripeFeature — mounts without mount-time credentials", () => {
|
|
29
|
+
test("mounts with no options at all (keys resolved at runtime from secrets)", () => {
|
|
30
|
+
// v1 warf hier bei leerem webhookSecret/apiKey. v2 mountet immer —
|
|
31
|
+
// die Credentials kommen erst zur Laufzeit aus config/secrets, der
|
|
32
|
+
// billing-live-Gate hält den Checkout solange inert.
|
|
33
|
+
expect(() => createSubscriptionStripeFeature()).not.toThrow();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("mounts with only priceToTier (no api/webhook keys passed)", () => {
|
|
32
37
|
expect(() =>
|
|
33
|
-
createSubscriptionStripeFeature({
|
|
34
|
-
|
|
35
|
-
webhookSecret: "",
|
|
36
|
-
}),
|
|
37
|
-
).toThrow(/webhookSecret is empty/);
|
|
38
|
+
createSubscriptionStripeFeature({ priceToTier: { price_x: "pro" } }),
|
|
39
|
+
).not.toThrow();
|
|
38
40
|
});
|
|
39
41
|
|
|
40
|
-
test("
|
|
42
|
+
test("mounts with bridge-fallback keys passed (env→secrets transition)", () => {
|
|
41
43
|
expect(() =>
|
|
42
44
|
createSubscriptionStripeFeature({
|
|
43
|
-
|
|
44
|
-
|
|
45
|
+
apiKey: "sk_test_dummy",
|
|
46
|
+
webhookSecret: "whsec_dummy",
|
|
47
|
+
priceToTier: { price_x: "pro" },
|
|
45
48
|
}),
|
|
46
|
-
).toThrow(
|
|
49
|
+
).not.toThrow();
|
|
47
50
|
});
|
|
48
51
|
});
|
|
49
52
|
|
|
50
53
|
describe("subscription-stripe — plugin-registration", () => {
|
|
51
|
-
test("registers itself under entityName 'stripe' for
|
|
52
|
-
const feature = createSubscriptionStripeFeature(
|
|
54
|
+
test("registers itself under entityName 'stripe' for billing-foundation's extension", () => {
|
|
55
|
+
const feature = createSubscriptionStripeFeature(OPTIONS);
|
|
53
56
|
const usages = feature.extensionUsages;
|
|
54
57
|
expect(
|
|
55
58
|
usages.some(
|
|
@@ -65,7 +68,7 @@ describe("subscription-stripe — plugin-registration", () => {
|
|
|
65
68
|
// "method not supported"-error brechen — type-check würde es nicht
|
|
66
69
|
// fangen weil die useExtension-options als `unknown` durchgereicht
|
|
67
70
|
// werden.
|
|
68
|
-
const feature = createSubscriptionStripeFeature(
|
|
71
|
+
const feature = createSubscriptionStripeFeature(OPTIONS);
|
|
69
72
|
const usage = feature.extensionUsages.find((u) => u.entityName === STRIPE_PROVIDER_NAME);
|
|
70
73
|
expect(usage).toBeDefined();
|
|
71
74
|
const options = usage?.options as {
|
|
@@ -2,15 +2,24 @@
|
|
|
2
2
|
// createPortalSession, cancelSubscription). Stripe-SDK-calls werden via
|
|
3
3
|
// spyOn gemockt — wir testen unsere Mapping-Logik (Argumente die wir
|
|
4
4
|
// an Stripe schicken + Antwort-Parsing), NICHT Stripe selbst.
|
|
5
|
+
//
|
|
6
|
+
// **Runtime-Wrapper:** die methods nehmen jetzt einen StripeCtxRuntime
|
|
7
|
+
// (löst Client + billing-live aus ctx auf), nicht mehr einen rohen
|
|
8
|
+
// Stripe-Client. `ctxRuntime(stripe, billingLive)` baut einen Test-runtime
|
|
9
|
+
// der den gespyten Client zurückgibt — die echte Resolution-Logik testet
|
|
10
|
+
// runtime.test.ts.
|
|
5
11
|
|
|
6
12
|
import { describe, expect, spyOn, test } from "bun:test";
|
|
7
13
|
import type { HandlerContext } from "@cosmicdrift/kumiko-framework/engine";
|
|
14
|
+
import { FeatureDisabledError } from "@cosmicdrift/kumiko-framework/errors";
|
|
8
15
|
import Stripe from "stripe";
|
|
16
|
+
import { SUBSCRIPTION_STRIPE_FEATURE } from "../constants";
|
|
9
17
|
import {
|
|
10
18
|
createStripeCancelSubscription,
|
|
11
19
|
createStripeCheckoutSession,
|
|
12
20
|
createStripePortalSession,
|
|
13
21
|
} from "../plugin-methods";
|
|
22
|
+
import type { StripeCtxRuntime } from "../runtime";
|
|
14
23
|
|
|
15
24
|
const TEST_API_KEY = "sk_test_dummy";
|
|
16
25
|
|
|
@@ -18,6 +27,19 @@ function buildStripe(): Stripe {
|
|
|
18
27
|
return new Stripe(TEST_API_KEY, { apiVersion: "2026-04-22.dahlia" });
|
|
19
28
|
}
|
|
20
29
|
|
|
30
|
+
/** Test-runtime: gibt den gespyten Client zurück + ein billing-live-Gate
|
|
31
|
+
* das (default) durchlässt. */
|
|
32
|
+
function ctxRuntime(stripe: Stripe, billingLive = true): StripeCtxRuntime {
|
|
33
|
+
return {
|
|
34
|
+
clientForCtx: async () => stripe,
|
|
35
|
+
assertBillingLive: async () => {
|
|
36
|
+
if (!billingLive) {
|
|
37
|
+
throw new FeatureDisabledError(SUBSCRIPTION_STRIPE_FEATURE, "create-checkout-session");
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
21
43
|
const stubCtx = {} as HandlerContext;
|
|
22
44
|
|
|
23
45
|
// =============================================================================
|
|
@@ -31,7 +53,7 @@ describe("createStripeCheckoutSession", () => {
|
|
|
31
53
|
// biome-ignore lint/suspicious/noExplicitAny: Stripe-SDK-typed mock-return
|
|
32
54
|
.mockResolvedValue({ url: "https://checkout.stripe.com/c/pay/test" } as any);
|
|
33
55
|
|
|
34
|
-
const checkout = createStripeCheckoutSession(stripe);
|
|
56
|
+
const checkout = createStripeCheckoutSession(ctxRuntime(stripe));
|
|
35
57
|
const result = await checkout(stubCtx, {
|
|
36
58
|
priceId: "price_pro_monthly",
|
|
37
59
|
tenantId: "tenant-001",
|
|
@@ -55,13 +77,32 @@ describe("createStripeCheckoutSession", () => {
|
|
|
55
77
|
});
|
|
56
78
|
});
|
|
57
79
|
|
|
80
|
+
test("#104-Gate: throws FeatureDisabledError + ruft Stripe NICHT wenn billing-live aus", async () => {
|
|
81
|
+
const stripe = buildStripe();
|
|
82
|
+
const createMock = spyOn(stripe.checkout.sessions, "create")
|
|
83
|
+
// biome-ignore lint/suspicious/noExplicitAny: Stripe-SDK-typed mock-return
|
|
84
|
+
.mockResolvedValue({ url: "https://x" } as any);
|
|
85
|
+
|
|
86
|
+
const checkout = createStripeCheckoutSession(ctxRuntime(stripe, false));
|
|
87
|
+
await expect(
|
|
88
|
+
checkout(stubCtx, {
|
|
89
|
+
priceId: "price_x",
|
|
90
|
+
tenantId: "t",
|
|
91
|
+
successUrl: "https://x/s",
|
|
92
|
+
cancelUrl: "https://x/c",
|
|
93
|
+
}),
|
|
94
|
+
).rejects.toBeInstanceOf(FeatureDisabledError);
|
|
95
|
+
// Kein Stripe-Call — die Schranke greift VOR jeder Session-Erstellung.
|
|
96
|
+
expect(createMock).not.toHaveBeenCalled();
|
|
97
|
+
});
|
|
98
|
+
|
|
58
99
|
test("passes existing customer-id wenn gesetzt (Plan-Wechsel-Flow)", async () => {
|
|
59
100
|
const stripe = buildStripe();
|
|
60
101
|
const createMock = spyOn(stripe.checkout.sessions, "create")
|
|
61
102
|
// biome-ignore lint/suspicious/noExplicitAny: Stripe-SDK-typed mock-return
|
|
62
103
|
.mockResolvedValue({ url: "https://x" } as any);
|
|
63
104
|
|
|
64
|
-
const checkout = createStripeCheckoutSession(stripe);
|
|
105
|
+
const checkout = createStripeCheckoutSession(ctxRuntime(stripe));
|
|
65
106
|
await checkout(stubCtx, {
|
|
66
107
|
priceId: "price_x",
|
|
67
108
|
tenantId: "tenant-002",
|
|
@@ -81,7 +122,7 @@ describe("createStripeCheckoutSession", () => {
|
|
|
81
122
|
// biome-ignore lint/suspicious/noExplicitAny: SDK-Drift-Test
|
|
82
123
|
.mockResolvedValue({ url: null } as any);
|
|
83
124
|
|
|
84
|
-
const checkout = createStripeCheckoutSession(stripe);
|
|
125
|
+
const checkout = createStripeCheckoutSession(ctxRuntime(stripe));
|
|
85
126
|
await expect(
|
|
86
127
|
checkout(stubCtx, {
|
|
87
128
|
priceId: "p",
|
|
@@ -102,7 +143,7 @@ describe("createStripeCheckoutSession", () => {
|
|
|
102
143
|
new Error("Stripe API: Internal server error"),
|
|
103
144
|
);
|
|
104
145
|
|
|
105
|
-
const checkout = createStripeCheckoutSession(stripe);
|
|
146
|
+
const checkout = createStripeCheckoutSession(ctxRuntime(stripe));
|
|
106
147
|
await expect(
|
|
107
148
|
checkout(stubCtx, {
|
|
108
149
|
priceId: "p",
|
|
@@ -125,7 +166,7 @@ describe("createStripePortalSession", () => {
|
|
|
125
166
|
// biome-ignore lint/suspicious/noExplicitAny: Stripe-SDK-typed mock-return
|
|
126
167
|
.mockResolvedValue({ url: "https://billing.stripe.com/p/session/test" } as any);
|
|
127
168
|
|
|
128
|
-
const portal = createStripePortalSession(stripe);
|
|
169
|
+
const portal = createStripePortalSession(ctxRuntime(stripe));
|
|
129
170
|
const result = await portal(stubCtx, {
|
|
130
171
|
providerCustomerId: "cus_001",
|
|
131
172
|
returnUrl: "https://example.com/return",
|
|
@@ -151,7 +192,7 @@ describe("createStripeCancelSubscription", () => {
|
|
|
151
192
|
// biome-ignore lint/suspicious/noExplicitAny: Stripe-SDK-typed mock-return
|
|
152
193
|
.mockResolvedValue({ id: "sub_001", status: "canceled" } as any);
|
|
153
194
|
|
|
154
|
-
const cancel = createStripeCancelSubscription(stripe);
|
|
195
|
+
const cancel = createStripeCancelSubscription(ctxRuntime(stripe));
|
|
155
196
|
await cancel(stubCtx, "sub_001");
|
|
156
197
|
|
|
157
198
|
expect(cancelMock).toHaveBeenCalledTimes(1);
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// Unit-Tests für die Runtime-Key/Flag-Resolution (runtime.ts) — das Herz
|
|
2
|
+
// des v2-Pivots weg von mount-time-Closures. Stub-ctx/SecretsContext
|
|
3
|
+
// statt echter DB: wir testen die Resolution-Logik (secrets→fallback→throw,
|
|
4
|
+
// billing-live-Gate, Client-Memoization), nicht die secrets-Persistenz
|
|
5
|
+
// (das deckt der Integration-Test ab).
|
|
6
|
+
|
|
7
|
+
import { describe, expect, test } from "bun:test";
|
|
8
|
+
import type {
|
|
9
|
+
ConfigKeyHandle,
|
|
10
|
+
HandlerContext,
|
|
11
|
+
SecretKeyHandle,
|
|
12
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
13
|
+
import { FeatureDisabledError, UnconfiguredError } from "@cosmicdrift/kumiko-framework/errors";
|
|
14
|
+
import { createSecret, type SecretsContext } from "@cosmicdrift/kumiko-framework/secrets";
|
|
15
|
+
import Stripe from "stripe";
|
|
16
|
+
import { createStripeClientCache, createStripeRuntimes } from "../runtime";
|
|
17
|
+
|
|
18
|
+
const API_KEY_HANDLE: SecretKeyHandle = { name: "subscription-stripe:secret:api-key" };
|
|
19
|
+
const WEBHOOK_SECRET_HANDLE: SecretKeyHandle = {
|
|
20
|
+
name: "subscription-stripe:secret:webhook-secret",
|
|
21
|
+
};
|
|
22
|
+
const BILLING_LIVE_HANDLE: ConfigKeyHandle<"boolean"> = {
|
|
23
|
+
name: "subscription-stripe:config:billingLive",
|
|
24
|
+
type: "boolean",
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/** Stub-SecretsContext: liest aus einer in-memory-map, matcht per
|
|
28
|
+
* qualified-name. Ignoriert auditCtx (irrelevant für die Resolution). */
|
|
29
|
+
function stubSecrets(values: Record<string, string>): SecretsContext {
|
|
30
|
+
const nameOf = (k: string | { readonly name: string }): string =>
|
|
31
|
+
typeof k === "string" ? k : k.name;
|
|
32
|
+
return {
|
|
33
|
+
get: async (_tenantId, key) => {
|
|
34
|
+
const value = values[nameOf(key)];
|
|
35
|
+
return value === undefined ? undefined : createSecret(value);
|
|
36
|
+
},
|
|
37
|
+
has: async (_tenantId, key) => values[nameOf(key)] !== undefined,
|
|
38
|
+
set: async () => undefined,
|
|
39
|
+
delete: async () => false,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Minimaler HandlerContext-Stub mit nur den Feldern, die die ctx-
|
|
44
|
+
* Resolution liest (secrets, _userId für audit, config). */
|
|
45
|
+
function stubCtx(opts: { secrets?: SecretsContext; billingLive?: boolean }): HandlerContext {
|
|
46
|
+
return {
|
|
47
|
+
secrets: opts.secrets,
|
|
48
|
+
_userId: "tester",
|
|
49
|
+
config: async () => opts.billingLive,
|
|
50
|
+
} as unknown as HandlerContext; // @cast-boundary test-stub — partial ctx
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function makeRuntimes(fallback: { apiKey?: string; webhookSecret?: string } = {}) {
|
|
54
|
+
return createStripeRuntimes({
|
|
55
|
+
apiKeyHandle: API_KEY_HANDLE,
|
|
56
|
+
webhookSecretHandle: WEBHOOK_SECRET_HANDLE,
|
|
57
|
+
billingLiveHandle: BILLING_LIVE_HANDLE,
|
|
58
|
+
fallback,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// =============================================================================
|
|
63
|
+
// createStripeClientCache
|
|
64
|
+
// =============================================================================
|
|
65
|
+
|
|
66
|
+
describe("createStripeClientCache", () => {
|
|
67
|
+
test("memoizes by api-key — same key → same instance, rotated key → new instance", () => {
|
|
68
|
+
const cache = createStripeClientCache();
|
|
69
|
+
const a1 = cache("sk_test_aaa");
|
|
70
|
+
const a2 = cache("sk_test_aaa");
|
|
71
|
+
const b1 = cache("sk_test_bbb");
|
|
72
|
+
|
|
73
|
+
expect(a1).toBeInstanceOf(Stripe);
|
|
74
|
+
expect(a2).toBe(a1); // steady-state reuses one client per key
|
|
75
|
+
expect(b1).not.toBe(a1); // rotation builds a fresh client
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// =============================================================================
|
|
80
|
+
// ctx-runtime: api-key resolution (post-tenant, audited)
|
|
81
|
+
// =============================================================================
|
|
82
|
+
|
|
83
|
+
describe("StripeCtxRuntime.clientForCtx", () => {
|
|
84
|
+
test("reads api-key from system-secrets → builds a client", async () => {
|
|
85
|
+
const rt = makeRuntimes();
|
|
86
|
+
const ctx = stubCtx({ secrets: stubSecrets({ [API_KEY_HANDLE.name]: "sk_test_from_secret" }) });
|
|
87
|
+
const client = await rt.ctx.clientForCtx(ctx);
|
|
88
|
+
expect(client).toBeInstanceOf(Stripe);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("secret takes precedence over factory-fallback; rotating the secret → new client", async () => {
|
|
92
|
+
const rt = makeRuntimes({ apiKey: "sk_test_fallback" });
|
|
93
|
+
const values: Record<string, string> = { [API_KEY_HANDLE.name]: "sk_test_v1" };
|
|
94
|
+
const ctx = stubCtx({ secrets: stubSecrets(values) });
|
|
95
|
+
|
|
96
|
+
const first = await rt.ctx.clientForCtx(ctx);
|
|
97
|
+
const again = await rt.ctx.clientForCtx(ctx);
|
|
98
|
+
expect(again).toBe(first); // same secret → memoized
|
|
99
|
+
|
|
100
|
+
values[API_KEY_HANDLE.name] = "sk_test_v2"; // operator rotates the secret
|
|
101
|
+
const afterRotation = await rt.ctx.clientForCtx(ctx);
|
|
102
|
+
expect(afterRotation).not.toBe(first); // runtime re-read picked up the new key
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("falls back to factory api-key when no secret is set", async () => {
|
|
106
|
+
const rt = makeRuntimes({ apiKey: "sk_test_fallback" });
|
|
107
|
+
const ctx = stubCtx({ secrets: stubSecrets({}) });
|
|
108
|
+
const client = await rt.ctx.clientForCtx(ctx);
|
|
109
|
+
expect(client).toBeInstanceOf(Stripe);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("throws UnconfiguredError when neither secret nor fallback is set", async () => {
|
|
113
|
+
const rt = makeRuntimes();
|
|
114
|
+
const ctx = stubCtx({ secrets: stubSecrets({}) });
|
|
115
|
+
await expect(rt.ctx.clientForCtx(ctx)).rejects.toBeInstanceOf(UnconfiguredError);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// =============================================================================
|
|
120
|
+
// ctx-runtime: billing-live gate (#104 invariant)
|
|
121
|
+
// =============================================================================
|
|
122
|
+
|
|
123
|
+
describe("StripeCtxRuntime.assertBillingLive", () => {
|
|
124
|
+
test("passes when billing-live config is true", async () => {
|
|
125
|
+
const rt = makeRuntimes();
|
|
126
|
+
await expect(rt.ctx.assertBillingLive(stubCtx({ billingLive: true }))).resolves.toBeUndefined();
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("throws FeatureDisabledError when billing-live is false", async () => {
|
|
130
|
+
const rt = makeRuntimes();
|
|
131
|
+
await expect(rt.ctx.assertBillingLive(stubCtx({ billingLive: false }))).rejects.toBeInstanceOf(
|
|
132
|
+
FeatureDisabledError,
|
|
133
|
+
);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("throws FeatureDisabledError when billing-live is undefined (default-off)", async () => {
|
|
137
|
+
const rt = makeRuntimes();
|
|
138
|
+
await expect(
|
|
139
|
+
rt.ctx.assertBillingLive(stubCtx({ billingLive: undefined })),
|
|
140
|
+
).rejects.toBeInstanceOf(FeatureDisabledError);
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// =============================================================================
|
|
145
|
+
// webhook-runtime: pre-tenant resolution (raw, un-audited)
|
|
146
|
+
// =============================================================================
|
|
147
|
+
|
|
148
|
+
describe("StripeWebhookRuntime.resolve", () => {
|
|
149
|
+
test("resolves client + webhook-secret from system-secrets", async () => {
|
|
150
|
+
const rt = makeRuntimes();
|
|
151
|
+
const secrets = stubSecrets({
|
|
152
|
+
[API_KEY_HANDLE.name]: "sk_test_wh",
|
|
153
|
+
[WEBHOOK_SECRET_HANDLE.name]: "whsec_runtime",
|
|
154
|
+
});
|
|
155
|
+
const { stripe, webhookSecret } = await rt.webhook.resolve(secrets);
|
|
156
|
+
expect(stripe).toBeInstanceOf(Stripe);
|
|
157
|
+
expect(webhookSecret).toBe("whsec_runtime");
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("falls back to factory keys when no system-secrets passed", async () => {
|
|
161
|
+
const rt = makeRuntimes({ apiKey: "sk_test_fb", webhookSecret: "whsec_fb" });
|
|
162
|
+
const { webhookSecret } = await rt.webhook.resolve(undefined);
|
|
163
|
+
expect(webhookSecret).toBe("whsec_fb");
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("system-secret wins over fallback (rotation without redeploy)", async () => {
|
|
167
|
+
const rt = makeRuntimes({ webhookSecret: "whsec_stale_env" });
|
|
168
|
+
const secrets = stubSecrets({
|
|
169
|
+
[API_KEY_HANDLE.name]: "sk_test_x",
|
|
170
|
+
[WEBHOOK_SECRET_HANDLE.name]: "whsec_rotated",
|
|
171
|
+
});
|
|
172
|
+
const { webhookSecret } = await rt.webhook.resolve(secrets);
|
|
173
|
+
expect(webhookSecret).toBe("whsec_rotated");
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test("throws when a key is unresolved (no secret, no fallback)", async () => {
|
|
177
|
+
const rt = makeRuntimes({ apiKey: "sk_test_only" }); // webhook-secret missing
|
|
178
|
+
await expect(rt.webhook.resolve(stubSecrets({}))).rejects.toThrow(/webhook-secret.*unresolved/);
|
|
179
|
+
});
|
|
180
|
+
});
|