@wopr-network/platform-core 1.67.1 → 1.69.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/dist/auth/better-auth.js +7 -0
- package/dist/backup/types.d.ts +1 -1
- package/dist/email/client.js +16 -0
- package/dist/server/__tests__/build-container.test.d.ts +1 -0
- package/dist/server/__tests__/build-container.test.js +339 -0
- package/dist/server/__tests__/container.test.d.ts +1 -0
- package/dist/server/__tests__/container.test.js +170 -0
- package/dist/server/__tests__/lifecycle.test.d.ts +1 -0
- package/dist/server/__tests__/lifecycle.test.js +90 -0
- package/dist/server/__tests__/mount-routes.test.d.ts +1 -0
- package/dist/server/__tests__/mount-routes.test.js +151 -0
- package/dist/server/boot-config.d.ts +51 -0
- package/dist/server/boot-config.js +7 -0
- package/dist/server/container.d.ts +81 -0
- package/dist/server/container.js +134 -0
- package/dist/server/index.d.ts +33 -0
- package/dist/server/index.js +66 -0
- package/dist/server/lifecycle.d.ts +25 -0
- package/dist/server/lifecycle.js +46 -0
- package/dist/server/middleware/__tests__/admin-auth.test.d.ts +1 -0
- package/dist/server/middleware/__tests__/admin-auth.test.js +59 -0
- package/dist/server/middleware/__tests__/tenant-proxy.test.d.ts +1 -0
- package/dist/server/middleware/__tests__/tenant-proxy.test.js +268 -0
- package/dist/server/middleware/admin-auth.d.ts +18 -0
- package/dist/server/middleware/admin-auth.js +38 -0
- package/dist/server/middleware/tenant-proxy.d.ts +56 -0
- package/dist/server/middleware/tenant-proxy.js +162 -0
- package/dist/server/mount-routes.d.ts +30 -0
- package/dist/server/mount-routes.js +74 -0
- package/dist/server/routes/__tests__/admin.test.d.ts +1 -0
- package/dist/server/routes/__tests__/admin.test.js +267 -0
- package/dist/server/routes/__tests__/crypto-webhook.test.d.ts +1 -0
- package/dist/server/routes/__tests__/crypto-webhook.test.js +137 -0
- package/dist/server/routes/__tests__/provision-webhook.test.d.ts +1 -0
- package/dist/server/routes/__tests__/provision-webhook.test.js +212 -0
- package/dist/server/routes/__tests__/stripe-webhook.test.d.ts +1 -0
- package/dist/server/routes/__tests__/stripe-webhook.test.js +65 -0
- package/dist/server/routes/admin.d.ts +111 -0
- package/dist/server/routes/admin.js +273 -0
- package/dist/server/routes/crypto-webhook.d.ts +23 -0
- package/dist/server/routes/crypto-webhook.js +82 -0
- package/dist/server/routes/provision-webhook.d.ts +38 -0
- package/dist/server/routes/provision-webhook.js +160 -0
- package/dist/server/routes/stripe-webhook.d.ts +10 -0
- package/dist/server/routes/stripe-webhook.js +29 -0
- package/dist/server/test-container.d.ts +15 -0
- package/dist/server/test-container.js +103 -0
- package/dist/trpc/auth-helpers.d.ts +17 -0
- package/dist/trpc/auth-helpers.js +26 -0
- package/dist/trpc/container-factories.d.ts +300 -0
- package/dist/trpc/container-factories.js +80 -0
- package/dist/trpc/index.d.ts +2 -0
- package/dist/trpc/index.js +2 -0
- package/package.json +8 -3
- package/src/auth/better-auth.ts +8 -0
- package/src/email/client.ts +18 -0
- package/src/server/__tests__/build-container.test.ts +402 -0
- package/src/server/__tests__/container.test.ts +204 -0
- package/src/server/__tests__/lifecycle.test.ts +106 -0
- package/src/server/__tests__/mount-routes.test.ts +169 -0
- package/src/server/boot-config.ts +84 -0
- package/src/server/container.ts +237 -0
- package/src/server/index.ts +92 -0
- package/src/server/lifecycle.ts +62 -0
- package/src/server/middleware/__tests__/admin-auth.test.ts +67 -0
- package/src/server/middleware/__tests__/tenant-proxy.test.ts +308 -0
- package/src/server/middleware/admin-auth.ts +51 -0
- package/src/server/middleware/tenant-proxy.ts +192 -0
- package/src/server/mount-routes.ts +113 -0
- package/src/server/routes/__tests__/admin.test.ts +320 -0
- package/src/server/routes/__tests__/crypto-webhook.test.ts +167 -0
- package/src/server/routes/__tests__/provision-webhook.test.ts +323 -0
- package/src/server/routes/__tests__/stripe-webhook.test.ts +73 -0
- package/src/server/routes/admin.ts +334 -0
- package/src/server/routes/crypto-webhook.ts +110 -0
- package/src/server/routes/provision-webhook.ts +212 -0
- package/src/server/routes/stripe-webhook.ts +36 -0
- package/src/server/test-container.ts +120 -0
- package/src/trpc/auth-helpers.ts +28 -0
- package/src/trpc/container-factories.ts +114 -0
- package/src/trpc/index.ts +9 -0
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import type { IOrgMemberRepository } from "../../../tenancy/org-member-repository.js";
|
|
3
|
+
import { createCallerFactory, router, setTrpcOrgMemberRepo } from "../../../trpc/init.js";
|
|
4
|
+
import type { PlatformContainer } from "../../container.js";
|
|
5
|
+
import { createTestContainer } from "../../test-container.js";
|
|
6
|
+
import type { AdminRouterConfig } from "../admin.js";
|
|
7
|
+
import { createAdminRouter } from "../admin.js";
|
|
8
|
+
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
// Mocks
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
|
|
13
|
+
// Mock global fetch for OpenRouter API calls
|
|
14
|
+
const mockFetch = vi.fn();
|
|
15
|
+
vi.stubGlobal("fetch", mockFetch);
|
|
16
|
+
|
|
17
|
+
function makeMockOrgRepo(): IOrgMemberRepository {
|
|
18
|
+
return {
|
|
19
|
+
listMembers: vi.fn().mockResolvedValue([]),
|
|
20
|
+
addMember: vi.fn().mockResolvedValue(undefined),
|
|
21
|
+
updateMemberRole: vi.fn().mockResolvedValue(undefined),
|
|
22
|
+
removeMember: vi.fn().mockResolvedValue(undefined),
|
|
23
|
+
findMember: vi.fn().mockResolvedValue(null),
|
|
24
|
+
countAdminsAndOwners: vi.fn().mockResolvedValue(0),
|
|
25
|
+
listInvites: vi.fn().mockResolvedValue([]),
|
|
26
|
+
createInvite: vi.fn().mockResolvedValue(undefined),
|
|
27
|
+
findInviteById: vi.fn().mockResolvedValue(null),
|
|
28
|
+
findInviteByToken: vi.fn().mockResolvedValue(null),
|
|
29
|
+
deleteInvite: vi.fn().mockResolvedValue(undefined),
|
|
30
|
+
deleteAllMembers: vi.fn().mockResolvedValue(undefined),
|
|
31
|
+
deleteAllInvites: vi.fn().mockResolvedValue(undefined),
|
|
32
|
+
listOrgsByUser: vi.fn().mockResolvedValue([]),
|
|
33
|
+
markInviteAccepted: vi.fn().mockResolvedValue(undefined),
|
|
34
|
+
} as unknown as IOrgMemberRepository;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const adminCtx = {
|
|
38
|
+
user: { id: "admin-1", roles: ["platform_admin"] },
|
|
39
|
+
tenantId: undefined,
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
// Helpers
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
function makeCaller(container: PlatformContainer, config?: AdminRouterConfig) {
|
|
47
|
+
const adminRouter = createAdminRouter(container, config);
|
|
48
|
+
const appRouter = router({ admin: adminRouter });
|
|
49
|
+
const createCaller = createCallerFactory(appRouter);
|
|
50
|
+
return createCaller(adminCtx);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
// Tests
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
describe("createAdminRouter", () => {
|
|
58
|
+
beforeEach(() => {
|
|
59
|
+
setTrpcOrgMemberRepo(makeMockOrgRepo());
|
|
60
|
+
mockFetch.mockReset();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// -------------------------------------------------------------------------
|
|
64
|
+
// Model list endpoint
|
|
65
|
+
// -------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
describe("listAvailableModels", () => {
|
|
68
|
+
it("returns cached models from OpenRouter API", async () => {
|
|
69
|
+
const container = createTestContainer();
|
|
70
|
+
const models = [
|
|
71
|
+
{
|
|
72
|
+
id: "openai/gpt-4",
|
|
73
|
+
name: "GPT-4",
|
|
74
|
+
context_length: 8192,
|
|
75
|
+
pricing: { prompt: "0.03", completion: "0.06" },
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
id: "anthropic/claude-3",
|
|
79
|
+
name: "Claude 3",
|
|
80
|
+
context_length: 200000,
|
|
81
|
+
pricing: { prompt: "0.015", completion: "0.075" },
|
|
82
|
+
},
|
|
83
|
+
];
|
|
84
|
+
|
|
85
|
+
mockFetch.mockResolvedValueOnce({
|
|
86
|
+
ok: true,
|
|
87
|
+
json: async () => ({ data: models }),
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
const caller = makeCaller(container, { openRouterApiKey: "sk-test-key" });
|
|
91
|
+
const result = await caller.admin.listAvailableModels();
|
|
92
|
+
|
|
93
|
+
expect(result.models).toHaveLength(2);
|
|
94
|
+
expect(result.models[0].id).toBe("anthropic/claude-3");
|
|
95
|
+
expect(result.models[1].id).toBe("openai/gpt-4");
|
|
96
|
+
expect(mockFetch).toHaveBeenCalledOnce();
|
|
97
|
+
|
|
98
|
+
// Second call should use cache (no additional fetch)
|
|
99
|
+
const result2 = await caller.admin.listAvailableModels();
|
|
100
|
+
expect(result2.models).toHaveLength(2);
|
|
101
|
+
expect(mockFetch).toHaveBeenCalledOnce();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("returns empty list when no API key configured", async () => {
|
|
105
|
+
const container = createTestContainer();
|
|
106
|
+
const caller = makeCaller(container); // no config = no API key
|
|
107
|
+
|
|
108
|
+
const result = await caller.admin.listAvailableModels();
|
|
109
|
+
|
|
110
|
+
expect(result.models).toEqual([]);
|
|
111
|
+
expect(mockFetch).not.toHaveBeenCalled();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("returns stale cache on fetch failure", async () => {
|
|
115
|
+
// First call seeds the cache
|
|
116
|
+
const container = createTestContainer();
|
|
117
|
+
mockFetch.mockResolvedValueOnce({
|
|
118
|
+
ok: true,
|
|
119
|
+
json: async () => ({
|
|
120
|
+
data: [{ id: "model/a", name: "A", context_length: 100, pricing: { prompt: "1", completion: "2" } }],
|
|
121
|
+
}),
|
|
122
|
+
});
|
|
123
|
+
const caller = makeCaller(container, { openRouterApiKey: "sk-test-key" });
|
|
124
|
+
await caller.admin.listAvailableModels();
|
|
125
|
+
|
|
126
|
+
// Advance past 60s cache TTL so the next call triggers a fetch
|
|
127
|
+
vi.useFakeTimers();
|
|
128
|
+
vi.advanceTimersByTime(61_000);
|
|
129
|
+
|
|
130
|
+
mockFetch.mockRejectedValueOnce(new Error("Network error"));
|
|
131
|
+
const result = await caller.admin.listAvailableModels();
|
|
132
|
+
|
|
133
|
+
// On failure, falls back to stale cache
|
|
134
|
+
expect(result.models).toHaveLength(1);
|
|
135
|
+
expect(result.models[0].id).toBe("model/a");
|
|
136
|
+
|
|
137
|
+
vi.useRealTimers();
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// -------------------------------------------------------------------------
|
|
142
|
+
// Fleet instance listing
|
|
143
|
+
// -------------------------------------------------------------------------
|
|
144
|
+
|
|
145
|
+
describe("listAllInstances", () => {
|
|
146
|
+
it("lists instances using container.fleet", async () => {
|
|
147
|
+
const mockProfiles = [
|
|
148
|
+
{ id: "inst-1", name: "Bot A", tenantId: "t1", image: "img:latest" },
|
|
149
|
+
{ id: "inst-2", name: "Bot B", tenantId: "t2", image: "img:v2" },
|
|
150
|
+
];
|
|
151
|
+
|
|
152
|
+
const mockStatus = {
|
|
153
|
+
state: "running",
|
|
154
|
+
health: "healthy",
|
|
155
|
+
uptime: 3600,
|
|
156
|
+
containerId: "abc123",
|
|
157
|
+
startedAt: "2026-01-01T00:00:00Z",
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
const container = createTestContainer({
|
|
161
|
+
fleet: {
|
|
162
|
+
manager: {
|
|
163
|
+
status: vi.fn().mockResolvedValue(mockStatus),
|
|
164
|
+
} as never,
|
|
165
|
+
docker: {} as never,
|
|
166
|
+
proxy: {} as never,
|
|
167
|
+
profileStore: {
|
|
168
|
+
list: vi.fn().mockResolvedValue(mockProfiles),
|
|
169
|
+
init: vi.fn(),
|
|
170
|
+
save: vi.fn(),
|
|
171
|
+
get: vi.fn(),
|
|
172
|
+
delete: vi.fn(),
|
|
173
|
+
},
|
|
174
|
+
serviceKeyRepo: {} as never,
|
|
175
|
+
},
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
const caller = makeCaller(container);
|
|
179
|
+
const result = await caller.admin.listAllInstances();
|
|
180
|
+
|
|
181
|
+
expect(result.instances).toHaveLength(2);
|
|
182
|
+
expect(result.instances[0]).toEqual({
|
|
183
|
+
id: "inst-1",
|
|
184
|
+
name: "Bot A",
|
|
185
|
+
tenantId: "t1",
|
|
186
|
+
image: "img:latest",
|
|
187
|
+
state: "running",
|
|
188
|
+
health: "healthy",
|
|
189
|
+
uptime: 3600,
|
|
190
|
+
containerId: "abc123",
|
|
191
|
+
startedAt: "2026-01-01T00:00:00Z",
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("returns error when fleet not configured", async () => {
|
|
196
|
+
const container = createTestContainer({ fleet: null });
|
|
197
|
+
const caller = makeCaller(container);
|
|
198
|
+
const result = await caller.admin.listAllInstances();
|
|
199
|
+
|
|
200
|
+
expect(result.instances).toEqual([]);
|
|
201
|
+
expect(result.error).toBe("Fleet not configured");
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("returns error state for instances that fail status check", async () => {
|
|
205
|
+
const mockProfiles = [{ id: "inst-bad", name: "Bad Bot", tenantId: "t1", image: "img:latest" }];
|
|
206
|
+
|
|
207
|
+
const container = createTestContainer({
|
|
208
|
+
fleet: {
|
|
209
|
+
manager: {
|
|
210
|
+
status: vi.fn().mockRejectedValue(new Error("container not found")),
|
|
211
|
+
} as never,
|
|
212
|
+
docker: {} as never,
|
|
213
|
+
proxy: {} as never,
|
|
214
|
+
profileStore: {
|
|
215
|
+
list: vi.fn().mockResolvedValue(mockProfiles),
|
|
216
|
+
init: vi.fn(),
|
|
217
|
+
save: vi.fn(),
|
|
218
|
+
get: vi.fn(),
|
|
219
|
+
delete: vi.fn(),
|
|
220
|
+
},
|
|
221
|
+
serviceKeyRepo: {} as never,
|
|
222
|
+
},
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
const caller = makeCaller(container);
|
|
226
|
+
const result = await caller.admin.listAllInstances();
|
|
227
|
+
|
|
228
|
+
expect(result.instances).toHaveLength(1);
|
|
229
|
+
expect(result.instances[0].state).toBe("error");
|
|
230
|
+
expect(result.instances[0].health).toBeNull();
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
// -------------------------------------------------------------------------
|
|
235
|
+
// Credit balance query
|
|
236
|
+
// -------------------------------------------------------------------------
|
|
237
|
+
|
|
238
|
+
describe("billingOverview", () => {
|
|
239
|
+
it("queries credit balance via container pool", async () => {
|
|
240
|
+
const mockPool = {
|
|
241
|
+
query: vi
|
|
242
|
+
.fn()
|
|
243
|
+
.mockResolvedValueOnce({ rows: [{ totalRaw: "50000000" }] }) // credit_entry sum (50M microdollars = 5000 cents)
|
|
244
|
+
.mockResolvedValueOnce({ rows: [{ count: "3" }] }) // payment methods
|
|
245
|
+
.mockResolvedValueOnce({ rows: [{ count: "5" }] }), // orgs
|
|
246
|
+
end: vi.fn(),
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
const container = createTestContainer({
|
|
250
|
+
pool: mockPool as never,
|
|
251
|
+
gateway: null, // no gateway = skip service key count
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
const caller = makeCaller(container);
|
|
255
|
+
const result = await caller.admin.billingOverview();
|
|
256
|
+
|
|
257
|
+
expect(result.totalBalanceCents).toBe(5000);
|
|
258
|
+
expect(result.activeKeyCount).toBe(0); // gateway not configured
|
|
259
|
+
expect(result.paymentMethodCount).toBe(3);
|
|
260
|
+
expect(result.orgCount).toBe(5);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it("counts active service keys when gateway is configured", async () => {
|
|
264
|
+
const mockPool = {
|
|
265
|
+
query: vi
|
|
266
|
+
.fn()
|
|
267
|
+
.mockResolvedValueOnce({ rows: [{ totalRaw: "0" }] }) // credit_entry
|
|
268
|
+
.mockResolvedValueOnce({ rows: [{ count: "7" }] }) // service_keys
|
|
269
|
+
.mockResolvedValueOnce({ rows: [{ count: "0" }] }) // payment methods
|
|
270
|
+
.mockResolvedValueOnce({ rows: [{ count: "0" }] }), // orgs
|
|
271
|
+
end: vi.fn(),
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
const container = createTestContainer({
|
|
275
|
+
pool: mockPool as never,
|
|
276
|
+
gateway: {
|
|
277
|
+
serviceKeyRepo: {} as never,
|
|
278
|
+
},
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
const caller = makeCaller(container);
|
|
282
|
+
const result = await caller.admin.billingOverview();
|
|
283
|
+
|
|
284
|
+
expect(result.activeKeyCount).toBe(7);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it("returns zeros when tables do not exist", async () => {
|
|
288
|
+
const mockPool = {
|
|
289
|
+
query: vi.fn().mockRejectedValue(new Error('relation "credit_entry" does not exist')),
|
|
290
|
+
end: vi.fn(),
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
const container = createTestContainer({
|
|
294
|
+
pool: mockPool as never,
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
const caller = makeCaller(container);
|
|
298
|
+
const result = await caller.admin.billingOverview();
|
|
299
|
+
|
|
300
|
+
expect(result.totalBalanceCents).toBe(0);
|
|
301
|
+
expect(result.activeKeyCount).toBe(0);
|
|
302
|
+
expect(result.paymentMethodCount).toBe(0);
|
|
303
|
+
expect(result.orgCount).toBe(0);
|
|
304
|
+
});
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
// -------------------------------------------------------------------------
|
|
308
|
+
// Auth guard
|
|
309
|
+
// -------------------------------------------------------------------------
|
|
310
|
+
|
|
311
|
+
it("rejects non-admin users", async () => {
|
|
312
|
+
const container = createTestContainer();
|
|
313
|
+
const adminRouter = createAdminRouter(container);
|
|
314
|
+
const appRouter = router({ admin: adminRouter });
|
|
315
|
+
const createCaller = createCallerFactory(appRouter);
|
|
316
|
+
const caller = createCaller({ user: { id: "u1", roles: ["user"] }, tenantId: undefined });
|
|
317
|
+
|
|
318
|
+
await expect(caller.admin.listAvailableModels()).rejects.toThrow("Platform admin role required");
|
|
319
|
+
});
|
|
320
|
+
});
|
|
@@ -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
|
+
});
|