@wopr-network/platform-core 1.68.0 → 1.70.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 (93) hide show
  1. package/dist/backup/types.d.ts +1 -1
  2. package/dist/db/schema/pool-config.d.ts +41 -0
  3. package/dist/db/schema/pool-config.js +5 -0
  4. package/dist/db/schema/pool-instances.d.ts +126 -0
  5. package/dist/db/schema/pool-instances.js +10 -0
  6. package/dist/server/__tests__/build-container.test.d.ts +1 -0
  7. package/dist/server/__tests__/build-container.test.js +339 -0
  8. package/dist/server/__tests__/container.test.d.ts +1 -0
  9. package/dist/server/__tests__/container.test.js +173 -0
  10. package/dist/server/__tests__/lifecycle.test.d.ts +1 -0
  11. package/dist/server/__tests__/lifecycle.test.js +90 -0
  12. package/dist/server/__tests__/mount-routes.test.d.ts +1 -0
  13. package/dist/server/__tests__/mount-routes.test.js +151 -0
  14. package/dist/server/boot-config.d.ts +51 -0
  15. package/dist/server/boot-config.js +7 -0
  16. package/dist/server/container.d.ts +97 -0
  17. package/dist/server/container.js +148 -0
  18. package/dist/server/index.d.ts +33 -0
  19. package/dist/server/index.js +66 -0
  20. package/dist/server/lifecycle.d.ts +25 -0
  21. package/dist/server/lifecycle.js +56 -0
  22. package/dist/server/middleware/__tests__/admin-auth.test.d.ts +1 -0
  23. package/dist/server/middleware/__tests__/admin-auth.test.js +59 -0
  24. package/dist/server/middleware/__tests__/tenant-proxy.test.d.ts +1 -0
  25. package/dist/server/middleware/__tests__/tenant-proxy.test.js +268 -0
  26. package/dist/server/middleware/admin-auth.d.ts +18 -0
  27. package/dist/server/middleware/admin-auth.js +38 -0
  28. package/dist/server/middleware/tenant-proxy.d.ts +56 -0
  29. package/dist/server/middleware/tenant-proxy.js +162 -0
  30. package/dist/server/mount-routes.d.ts +30 -0
  31. package/dist/server/mount-routes.js +74 -0
  32. package/dist/server/routes/__tests__/admin.test.d.ts +1 -0
  33. package/dist/server/routes/__tests__/admin.test.js +267 -0
  34. package/dist/server/routes/__tests__/crypto-webhook.test.d.ts +1 -0
  35. package/dist/server/routes/__tests__/crypto-webhook.test.js +137 -0
  36. package/dist/server/routes/__tests__/provision-webhook.test.d.ts +1 -0
  37. package/dist/server/routes/__tests__/provision-webhook.test.js +212 -0
  38. package/dist/server/routes/__tests__/stripe-webhook.test.d.ts +1 -0
  39. package/dist/server/routes/__tests__/stripe-webhook.test.js +65 -0
  40. package/dist/server/routes/admin.d.ts +129 -0
  41. package/dist/server/routes/admin.js +294 -0
  42. package/dist/server/routes/crypto-webhook.d.ts +23 -0
  43. package/dist/server/routes/crypto-webhook.js +82 -0
  44. package/dist/server/routes/provision-webhook.d.ts +38 -0
  45. package/dist/server/routes/provision-webhook.js +160 -0
  46. package/dist/server/routes/stripe-webhook.d.ts +10 -0
  47. package/dist/server/routes/stripe-webhook.js +29 -0
  48. package/dist/server/services/hot-pool-claim.d.ts +30 -0
  49. package/dist/server/services/hot-pool-claim.js +92 -0
  50. package/dist/server/services/hot-pool.d.ts +25 -0
  51. package/dist/server/services/hot-pool.js +129 -0
  52. package/dist/server/services/pool-repository.d.ts +44 -0
  53. package/dist/server/services/pool-repository.js +72 -0
  54. package/dist/server/test-container.d.ts +15 -0
  55. package/dist/server/test-container.js +103 -0
  56. package/dist/trpc/auth-helpers.d.ts +17 -0
  57. package/dist/trpc/auth-helpers.js +26 -0
  58. package/dist/trpc/container-factories.d.ts +300 -0
  59. package/dist/trpc/container-factories.js +80 -0
  60. package/dist/trpc/index.d.ts +2 -0
  61. package/dist/trpc/index.js +2 -0
  62. package/drizzle/migrations/0025_hot_pool_tables.sql +29 -0
  63. package/package.json +5 -1
  64. package/src/db/schema/pool-config.ts +6 -0
  65. package/src/db/schema/pool-instances.ts +11 -0
  66. package/src/server/__tests__/build-container.test.ts +402 -0
  67. package/src/server/__tests__/container.test.ts +207 -0
  68. package/src/server/__tests__/lifecycle.test.ts +106 -0
  69. package/src/server/__tests__/mount-routes.test.ts +169 -0
  70. package/src/server/boot-config.ts +84 -0
  71. package/src/server/container.ts +264 -0
  72. package/src/server/index.ts +92 -0
  73. package/src/server/lifecycle.ts +72 -0
  74. package/src/server/middleware/__tests__/admin-auth.test.ts +67 -0
  75. package/src/server/middleware/__tests__/tenant-proxy.test.ts +308 -0
  76. package/src/server/middleware/admin-auth.ts +51 -0
  77. package/src/server/middleware/tenant-proxy.ts +192 -0
  78. package/src/server/mount-routes.ts +113 -0
  79. package/src/server/routes/__tests__/admin.test.ts +320 -0
  80. package/src/server/routes/__tests__/crypto-webhook.test.ts +167 -0
  81. package/src/server/routes/__tests__/provision-webhook.test.ts +323 -0
  82. package/src/server/routes/__tests__/stripe-webhook.test.ts +73 -0
  83. package/src/server/routes/admin.ts +360 -0
  84. package/src/server/routes/crypto-webhook.ts +110 -0
  85. package/src/server/routes/provision-webhook.ts +212 -0
  86. package/src/server/routes/stripe-webhook.ts +36 -0
  87. package/src/server/services/hot-pool-claim.ts +130 -0
  88. package/src/server/services/hot-pool.ts +174 -0
  89. package/src/server/services/pool-repository.ts +107 -0
  90. package/src/server/test-container.ts +120 -0
  91. package/src/trpc/auth-helpers.ts +28 -0
  92. package/src/trpc/container-factories.ts +114 -0
  93. package/src/trpc/index.ts +9 -0
@@ -0,0 +1,167 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import type { CryptoServices } from "../../container.js";
3
+ import { createTestContainer } from "../../test-container.js";
4
+ import { type CryptoWebhookConfig, createCryptoWebhookRoutes } from "../crypto-webhook.js";
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // Mock the key-server-webhook handler so we never hit real billing logic
8
+ // ---------------------------------------------------------------------------
9
+
10
+ vi.mock("../../../billing/crypto/key-server-webhook.js", () => ({
11
+ handleKeyServerWebhook: vi.fn().mockResolvedValue({
12
+ handled: true,
13
+ creditedCents: 500,
14
+ }),
15
+ }));
16
+
17
+ // ---------------------------------------------------------------------------
18
+ // Helpers
19
+ // ---------------------------------------------------------------------------
20
+
21
+ const SECRET = "test-provision-secret";
22
+ const CRYPTO_KEY = "test-crypto-service-key";
23
+
24
+ function makeConfig(overrides?: Partial<CryptoWebhookConfig>): CryptoWebhookConfig {
25
+ return {
26
+ provisionSecret: SECRET,
27
+ cryptoServiceKey: CRYPTO_KEY,
28
+ ...overrides,
29
+ };
30
+ }
31
+
32
+ function makeCrypto(): CryptoServices {
33
+ return {
34
+ chargeRepo: {} as never,
35
+ webhookSeenRepo: {} as never,
36
+ };
37
+ }
38
+
39
+ const validPayload = {
40
+ chargeId: "ch_123",
41
+ chain: "ETH",
42
+ address: "0xabc",
43
+ status: "confirmed",
44
+ };
45
+
46
+ function buildApp(opts?: { crypto?: CryptoServices | null; config?: Partial<CryptoWebhookConfig> }) {
47
+ const container = createTestContainer({
48
+ crypto: opts?.crypto !== undefined ? opts.crypto : makeCrypto(),
49
+ });
50
+ const config = makeConfig(opts?.config);
51
+ return createCryptoWebhookRoutes(container, config);
52
+ }
53
+
54
+ async function post(app: ReturnType<typeof buildApp>, body: unknown, headers?: Record<string, string>) {
55
+ const init: RequestInit = {
56
+ method: "POST",
57
+ headers: {
58
+ "Content-Type": "application/json",
59
+ ...headers,
60
+ },
61
+ };
62
+
63
+ if (body !== undefined) {
64
+ init.body = typeof body === "string" ? body : JSON.stringify(body);
65
+ }
66
+
67
+ return app.request("/", init);
68
+ }
69
+
70
+ // ---------------------------------------------------------------------------
71
+ // Tests
72
+ // ---------------------------------------------------------------------------
73
+
74
+ describe("createCryptoWebhookRoutes", () => {
75
+ // 1. No auth header
76
+ it("returns 401 without auth header", async () => {
77
+ const app = buildApp();
78
+ const res = await post(app, validPayload);
79
+
80
+ expect(res.status).toBe(401);
81
+ const json = await res.json();
82
+ expect(json.error).toBe("Unauthorized");
83
+ });
84
+
85
+ // 2. Wrong Bearer token
86
+ it("returns 401 with wrong Bearer token", async () => {
87
+ const app = buildApp();
88
+ const res = await post(app, validPayload, {
89
+ Authorization: "Bearer wrong-token",
90
+ });
91
+
92
+ expect(res.status).toBe(401);
93
+ const json = await res.json();
94
+ expect(json.error).toBe("Unauthorized");
95
+ });
96
+
97
+ // 3. Invalid JSON
98
+ it("returns 400 on invalid JSON", async () => {
99
+ const app = buildApp();
100
+ const res = await app.request("/", {
101
+ method: "POST",
102
+ headers: {
103
+ "Content-Type": "application/json",
104
+ Authorization: `Bearer ${SECRET}`,
105
+ },
106
+ body: "not-valid-json{{{",
107
+ });
108
+
109
+ expect(res.status).toBe(400);
110
+ const json = await res.json();
111
+ expect(json.error).toBe("Invalid JSON");
112
+ });
113
+
114
+ // 4. Zod validation failure (missing required fields)
115
+ it("returns 400 on payload that fails Zod validation", async () => {
116
+ const app = buildApp();
117
+ const res = await post(
118
+ app,
119
+ { chargeId: "ch_123" }, // missing chain, address, status
120
+ { Authorization: `Bearer ${SECRET}` },
121
+ );
122
+
123
+ expect(res.status).toBe(400);
124
+ const json = await res.json();
125
+ expect(json.error).toBe("Invalid payload");
126
+ expect(json.issues).toBeDefined();
127
+ expect(json.issues.length).toBeGreaterThan(0);
128
+ });
129
+
130
+ // 5. Container crypto is null -> 501
131
+ it("returns 501 when container.crypto is null", async () => {
132
+ const app = buildApp({ crypto: null });
133
+ const res = await post(app, validPayload, {
134
+ Authorization: `Bearer ${SECRET}`,
135
+ });
136
+
137
+ expect(res.status).toBe(501);
138
+ const json = await res.json();
139
+ expect(json.error).toBe("Crypto payments not configured");
140
+ });
141
+
142
+ // 6. Valid Bearer matching provision secret
143
+ it("accepts valid Bearer token matching provision secret", async () => {
144
+ const app = buildApp();
145
+ const res = await post(app, validPayload, {
146
+ Authorization: `Bearer ${SECRET}`,
147
+ });
148
+
149
+ expect(res.status).toBe(200);
150
+ const json = await res.json();
151
+ expect(json.handled).toBe(true);
152
+ expect(json.creditedCents).toBe(500);
153
+ });
154
+
155
+ // 7. Valid Bearer matching crypto service key
156
+ it("accepts valid Bearer token matching crypto service key", async () => {
157
+ const app = buildApp();
158
+ const res = await post(app, validPayload, {
159
+ Authorization: `Bearer ${CRYPTO_KEY}`,
160
+ });
161
+
162
+ expect(res.status).toBe(200);
163
+ const json = await res.json();
164
+ expect(json.handled).toBe(true);
165
+ expect(json.creditedCents).toBe(500);
166
+ });
167
+ });
@@ -0,0 +1,323 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import type { FleetServices } from "../../container.js";
3
+ import { createTestContainer } from "../../test-container.js";
4
+ import { createProvisionWebhookRoutes, type ProvisionWebhookConfig } from "../provision-webhook.js";
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // Helpers
8
+ // ---------------------------------------------------------------------------
9
+
10
+ const SECRET = "test-provision-secret-1234";
11
+
12
+ function makeConfig(overrides?: Partial<ProvisionWebhookConfig>): ProvisionWebhookConfig {
13
+ return {
14
+ provisionSecret: SECRET,
15
+ instanceImage: "ghcr.io/test/app:latest",
16
+ containerPort: 3000,
17
+ maxInstancesPerTenant: 5,
18
+ gatewayUrl: "http://gateway:4000",
19
+ containerPrefix: "test",
20
+ ...overrides,
21
+ };
22
+ }
23
+
24
+ function makeFleet(): FleetServices {
25
+ return {
26
+ manager: {
27
+ create: vi.fn().mockResolvedValue({
28
+ id: "inst-001",
29
+ containerId: "docker-abc",
30
+ containerName: "test-myapp",
31
+ url: "http://test-myapp:3000",
32
+ profile: { id: "inst-001", name: "myapp", tenantId: "tenant-1" },
33
+ }),
34
+ remove: vi.fn().mockResolvedValue(undefined),
35
+ status: vi.fn().mockResolvedValue({
36
+ id: "inst-001",
37
+ name: "myapp",
38
+ state: "running",
39
+ }),
40
+ } as never,
41
+ docker: {} as never,
42
+ proxy: {
43
+ addRoute: vi.fn().mockResolvedValue(undefined),
44
+ removeRoute: vi.fn(),
45
+ updateHealth: vi.fn(),
46
+ getRoutes: vi.fn().mockReturnValue([]),
47
+ start: vi.fn().mockResolvedValue(undefined),
48
+ stop: vi.fn().mockResolvedValue(undefined),
49
+ reload: vi.fn().mockResolvedValue(undefined),
50
+ },
51
+ profileStore: {
52
+ init: vi.fn().mockResolvedValue(undefined),
53
+ save: vi.fn().mockResolvedValue(undefined),
54
+ get: vi.fn().mockResolvedValue(null),
55
+ list: vi.fn().mockResolvedValue([]),
56
+ delete: vi.fn().mockResolvedValue(true),
57
+ },
58
+ serviceKeyRepo: {
59
+ generate: vi.fn().mockResolvedValue("key-abc"),
60
+ resolve: vi.fn().mockResolvedValue(null),
61
+ revokeByInstance: vi.fn().mockResolvedValue(undefined),
62
+ revokeByTenant: vi.fn().mockResolvedValue(undefined),
63
+ } as never,
64
+ };
65
+ }
66
+
67
+ function buildApp(opts?: { fleet?: FleetServices | null; config?: Partial<ProvisionWebhookConfig> }) {
68
+ const container = createTestContainer({
69
+ fleet: opts?.fleet !== undefined ? opts.fleet : makeFleet(),
70
+ });
71
+ const config = makeConfig(opts?.config);
72
+ return createProvisionWebhookRoutes(container, config);
73
+ }
74
+
75
+ async function request(
76
+ app: ReturnType<typeof buildApp>,
77
+ method: string,
78
+ path: string,
79
+ body?: unknown,
80
+ headers?: Record<string, string>,
81
+ ) {
82
+ const init: RequestInit = {
83
+ method,
84
+ headers: {
85
+ "Content-Type": "application/json",
86
+ ...headers,
87
+ },
88
+ };
89
+ if (body !== undefined) {
90
+ init.body = JSON.stringify(body);
91
+ }
92
+ return app.request(path, init);
93
+ }
94
+
95
+ // ---------------------------------------------------------------------------
96
+ // Tests
97
+ // ---------------------------------------------------------------------------
98
+
99
+ describe("createProvisionWebhookRoutes", () => {
100
+ // ---- Auth tests (apply to all endpoints) ----
101
+
102
+ it("returns 401 without authorization header", async () => {
103
+ const app = buildApp();
104
+ const res = await request(app, "POST", "/create", { tenantId: "t1", subdomain: "test" });
105
+
106
+ expect(res.status).toBe(401);
107
+ const json = await res.json();
108
+ expect(json.error).toBe("Unauthorized");
109
+ });
110
+
111
+ it("returns 401 with wrong secret", async () => {
112
+ const app = buildApp();
113
+ const res = await request(
114
+ app,
115
+ "POST",
116
+ "/create",
117
+ { tenantId: "t1", subdomain: "test" },
118
+ {
119
+ Authorization: "Bearer wrong-secret",
120
+ },
121
+ );
122
+
123
+ expect(res.status).toBe(401);
124
+ const json = await res.json();
125
+ expect(json.error).toBe("Unauthorized");
126
+ });
127
+
128
+ // ---- Fleet not configured ----
129
+
130
+ it("returns 501 when fleet not configured (container.fleet is null)", async () => {
131
+ const app = buildApp({ fleet: null });
132
+ const res = await request(
133
+ app,
134
+ "POST",
135
+ "/create",
136
+ { tenantId: "t1", subdomain: "test" },
137
+ {
138
+ Authorization: `Bearer ${SECRET}`,
139
+ },
140
+ );
141
+
142
+ expect(res.status).toBe(501);
143
+ const json = await res.json();
144
+ expect(json.error).toBe("Fleet management not configured");
145
+ });
146
+
147
+ it("returns 501 on destroy when fleet not configured", async () => {
148
+ const app = buildApp({ fleet: null });
149
+ const res = await request(
150
+ app,
151
+ "POST",
152
+ "/destroy",
153
+ { instanceId: "inst-001" },
154
+ {
155
+ Authorization: `Bearer ${SECRET}`,
156
+ },
157
+ );
158
+
159
+ expect(res.status).toBe(501);
160
+ });
161
+
162
+ it("returns 501 on budget when fleet not configured", async () => {
163
+ const app = buildApp({ fleet: null });
164
+ const res = await request(
165
+ app,
166
+ "PUT",
167
+ "/budget",
168
+ { instanceId: "inst-001", tenantEntityId: "te-1", budgetCents: 1000 },
169
+ { Authorization: `Bearer ${SECRET}` },
170
+ );
171
+
172
+ expect(res.status).toBe(501);
173
+ });
174
+
175
+ // ---- Create endpoint ----
176
+
177
+ it("handles create webhook with valid auth and payload", async () => {
178
+ const fleet = makeFleet();
179
+ const app = buildApp({ fleet });
180
+ const res = await request(
181
+ app,
182
+ "POST",
183
+ "/create",
184
+ { tenantId: "tenant-1", subdomain: "myapp" },
185
+ { Authorization: `Bearer ${SECRET}` },
186
+ );
187
+
188
+ expect(res.status).toBe(201);
189
+ const json = await res.json();
190
+ expect(json.ok).toBe(true);
191
+ expect(json.instanceId).toBe("inst-001");
192
+ expect(json.subdomain).toBe("myapp");
193
+ expect(json.containerUrl).toBe("http://test-myapp:3000");
194
+
195
+ // Verify fleet.create was called with generic env var names
196
+ expect(fleet.manager.create).toHaveBeenCalledTimes(1);
197
+ const createCall = (fleet.manager.create as ReturnType<typeof vi.fn>).mock.calls[0][0];
198
+ expect(createCall.env.HOSTED_MODE).toBe("true");
199
+ expect(createCall.env.DEPLOYMENT_MODE).toBe("hosted_proxy");
200
+ expect(createCall.env.DEPLOYMENT_EXPOSURE).toBe("private");
201
+ expect(createCall.env.MIGRATION_AUTO_APPLY).toBe("true");
202
+ // Verify NO product-specific prefixes
203
+ expect(createCall.env.PAPERCLIP_HOSTED_MODE).toBeUndefined();
204
+ expect(createCall.env.PAPERCLIP_DEPLOYMENT_MODE).toBeUndefined();
205
+
206
+ // Verify proxy route was registered
207
+ expect(fleet.proxy.addRoute).toHaveBeenCalledTimes(1);
208
+ });
209
+
210
+ it("returns 422 on create when required fields are missing", async () => {
211
+ const app = buildApp();
212
+ const res = await request(
213
+ app,
214
+ "POST",
215
+ "/create",
216
+ { tenantId: "tenant-1" }, // missing subdomain
217
+ { Authorization: `Bearer ${SECRET}` },
218
+ );
219
+
220
+ expect(res.status).toBe(422);
221
+ const json = await res.json();
222
+ expect(json.error).toContain("Missing required fields");
223
+ });
224
+
225
+ // ---- Destroy endpoint ----
226
+
227
+ it("handles destroy webhook with valid auth and instanceId", async () => {
228
+ const fleet = makeFleet();
229
+ const app = buildApp({ fleet });
230
+ const res = await request(
231
+ app,
232
+ "POST",
233
+ "/destroy",
234
+ { instanceId: "inst-001" },
235
+ { Authorization: `Bearer ${SECRET}` },
236
+ );
237
+
238
+ expect(res.status).toBe(200);
239
+ const json = await res.json();
240
+ expect(json.ok).toBe(true);
241
+
242
+ expect(fleet.serviceKeyRepo.revokeByInstance).toHaveBeenCalledWith("inst-001");
243
+ expect(fleet.manager.remove).toHaveBeenCalledWith("inst-001");
244
+ expect(fleet.proxy.removeRoute).toHaveBeenCalledWith("inst-001");
245
+ });
246
+
247
+ it("returns 422 on destroy when instanceId is missing", async () => {
248
+ const app = buildApp();
249
+ const res = await request(
250
+ app,
251
+ "POST",
252
+ "/destroy",
253
+ {},
254
+ {
255
+ Authorization: `Bearer ${SECRET}`,
256
+ },
257
+ );
258
+
259
+ expect(res.status).toBe(422);
260
+ const json = await res.json();
261
+ expect(json.error).toContain("Missing required field");
262
+ });
263
+
264
+ // ---- Budget endpoint ----
265
+
266
+ it("handles budget webhook with valid auth and payload", async () => {
267
+ const fleet = makeFleet();
268
+ const app = buildApp({ fleet });
269
+ const res = await request(
270
+ app,
271
+ "PUT",
272
+ "/budget",
273
+ { instanceId: "inst-001", tenantEntityId: "te-1", budgetCents: 5000 },
274
+ { Authorization: `Bearer ${SECRET}` },
275
+ );
276
+
277
+ expect(res.status).toBe(200);
278
+ const json = await res.json();
279
+ expect(json.ok).toBe(true);
280
+ expect(json.budgetCents).toBe(5000);
281
+ });
282
+
283
+ it("returns 422 on budget when required fields are missing", async () => {
284
+ const app = buildApp();
285
+ const res = await request(
286
+ app,
287
+ "PUT",
288
+ "/budget",
289
+ { instanceId: "inst-001" }, // missing tenantEntityId, budgetCents
290
+ { Authorization: `Bearer ${SECRET}` },
291
+ );
292
+
293
+ expect(res.status).toBe(422);
294
+ const json = await res.json();
295
+ expect(json.error).toContain("Missing required fields");
296
+ });
297
+
298
+ // ---- Generic env var names ----
299
+
300
+ it("uses generic env var names with no PAPERCLIP_ prefix anywhere", async () => {
301
+ const fleet = makeFleet();
302
+ const app = buildApp({ fleet });
303
+ await request(
304
+ app,
305
+ "POST",
306
+ "/create",
307
+ { tenantId: "tenant-1", subdomain: "myapp" },
308
+ { Authorization: `Bearer ${SECRET}` },
309
+ );
310
+
311
+ const createCall = (fleet.manager.create as ReturnType<typeof vi.fn>).mock.calls[0][0];
312
+ const envKeys = Object.keys(createCall.env);
313
+ for (const key of envKeys) {
314
+ expect(key).not.toMatch(/^PAPERCLIP_/);
315
+ }
316
+ // Verify expected generic names are present
317
+ expect(envKeys).toContain("HOSTED_MODE");
318
+ expect(envKeys).toContain("DEPLOYMENT_MODE");
319
+ expect(envKeys).toContain("DEPLOYMENT_EXPOSURE");
320
+ expect(envKeys).toContain("MIGRATION_AUTO_APPLY");
321
+ expect(envKeys).toContain("PROVISION_SECRET");
322
+ });
323
+ });
@@ -0,0 +1,73 @@
1
+ import { Hono } from "hono";
2
+ import { describe, expect, it, vi } from "vitest";
3
+
4
+ import { createTestContainer } from "../../test-container.js";
5
+ import { createStripeWebhookRoutes } from "../stripe-webhook.js";
6
+
7
+ function makeApp(stripeOverride?: unknown) {
8
+ const container = createTestContainer(
9
+ stripeOverride !== undefined ? { stripe: stripeOverride as ReturnType<typeof createTestContainer>["stripe"] } : {},
10
+ );
11
+ const app = new Hono();
12
+ app.route("/api/webhooks/stripe", createStripeWebhookRoutes(container));
13
+ return app;
14
+ }
15
+
16
+ describe("stripe webhook route", () => {
17
+ it("returns 501 when stripe not configured", async () => {
18
+ const app = makeApp(null);
19
+ const res = await app.request("/api/webhooks/stripe", { method: "POST" });
20
+ expect(res.status).toBe(501);
21
+ });
22
+
23
+ it("returns 400 when stripe-signature header missing", async () => {
24
+ const app = makeApp({
25
+ stripe: {},
26
+ webhookSecret: "whsec_test",
27
+ customerRepo: {},
28
+ processor: { handleWebhook: vi.fn() },
29
+ });
30
+ const res = await app.request("/api/webhooks/stripe", {
31
+ method: "POST",
32
+ body: "{}",
33
+ });
34
+ expect(res.status).toBe(400);
35
+ const body = await res.json();
36
+ expect(body.error).toBe("Missing stripe-signature header");
37
+ });
38
+
39
+ it("returns 200 on valid webhook", async () => {
40
+ const handleWebhook = vi.fn().mockResolvedValue({ handled: true, event_type: "checkout.session.completed" });
41
+ const app = makeApp({
42
+ stripe: {},
43
+ webhookSecret: "whsec_test",
44
+ customerRepo: {},
45
+ processor: { handleWebhook },
46
+ });
47
+ const res = await app.request("/api/webhooks/stripe", {
48
+ method: "POST",
49
+ headers: { "stripe-signature": "t=123,v1=abc" },
50
+ body: '{"type":"checkout.session.completed"}',
51
+ });
52
+ expect(res.status).toBe(200);
53
+ expect(handleWebhook).toHaveBeenCalledOnce();
54
+ });
55
+
56
+ it("returns 400 when processor throws (bad signature)", async () => {
57
+ const handleWebhook = vi.fn().mockRejectedValue(new Error("Signature verification failed"));
58
+ const app = makeApp({
59
+ stripe: {},
60
+ webhookSecret: "whsec_test",
61
+ customerRepo: {},
62
+ processor: { handleWebhook },
63
+ });
64
+ const res = await app.request("/api/webhooks/stripe", {
65
+ method: "POST",
66
+ headers: { "stripe-signature": "t=123,v1=bad" },
67
+ body: "invalid",
68
+ });
69
+ expect(res.status).toBe(400);
70
+ const body = await res.json();
71
+ expect(body.error).toBe("Webhook processing failed");
72
+ });
73
+ });