@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,267 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { createCallerFactory, router, setTrpcOrgMemberRepo } from "../../../trpc/init.js";
|
|
3
|
+
import { createTestContainer } from "../../test-container.js";
|
|
4
|
+
import { createAdminRouter } from "../admin.js";
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// Mocks
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
// Mock global fetch for OpenRouter API calls
|
|
9
|
+
const mockFetch = vi.fn();
|
|
10
|
+
vi.stubGlobal("fetch", mockFetch);
|
|
11
|
+
function makeMockOrgRepo() {
|
|
12
|
+
return {
|
|
13
|
+
listMembers: vi.fn().mockResolvedValue([]),
|
|
14
|
+
addMember: vi.fn().mockResolvedValue(undefined),
|
|
15
|
+
updateMemberRole: vi.fn().mockResolvedValue(undefined),
|
|
16
|
+
removeMember: vi.fn().mockResolvedValue(undefined),
|
|
17
|
+
findMember: vi.fn().mockResolvedValue(null),
|
|
18
|
+
countAdminsAndOwners: vi.fn().mockResolvedValue(0),
|
|
19
|
+
listInvites: vi.fn().mockResolvedValue([]),
|
|
20
|
+
createInvite: vi.fn().mockResolvedValue(undefined),
|
|
21
|
+
findInviteById: vi.fn().mockResolvedValue(null),
|
|
22
|
+
findInviteByToken: vi.fn().mockResolvedValue(null),
|
|
23
|
+
deleteInvite: vi.fn().mockResolvedValue(undefined),
|
|
24
|
+
deleteAllMembers: vi.fn().mockResolvedValue(undefined),
|
|
25
|
+
deleteAllInvites: vi.fn().mockResolvedValue(undefined),
|
|
26
|
+
listOrgsByUser: vi.fn().mockResolvedValue([]),
|
|
27
|
+
markInviteAccepted: vi.fn().mockResolvedValue(undefined),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
const adminCtx = {
|
|
31
|
+
user: { id: "admin-1", roles: ["platform_admin"] },
|
|
32
|
+
tenantId: undefined,
|
|
33
|
+
};
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
// Helpers
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
function makeCaller(container, config) {
|
|
38
|
+
const adminRouter = createAdminRouter(container, config);
|
|
39
|
+
const appRouter = router({ admin: adminRouter });
|
|
40
|
+
const createCaller = createCallerFactory(appRouter);
|
|
41
|
+
return createCaller(adminCtx);
|
|
42
|
+
}
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
// Tests
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
describe("createAdminRouter", () => {
|
|
47
|
+
beforeEach(() => {
|
|
48
|
+
setTrpcOrgMemberRepo(makeMockOrgRepo());
|
|
49
|
+
mockFetch.mockReset();
|
|
50
|
+
});
|
|
51
|
+
// -------------------------------------------------------------------------
|
|
52
|
+
// Model list endpoint
|
|
53
|
+
// -------------------------------------------------------------------------
|
|
54
|
+
describe("listAvailableModels", () => {
|
|
55
|
+
it("returns cached models from OpenRouter API", async () => {
|
|
56
|
+
const container = createTestContainer();
|
|
57
|
+
const models = [
|
|
58
|
+
{
|
|
59
|
+
id: "openai/gpt-4",
|
|
60
|
+
name: "GPT-4",
|
|
61
|
+
context_length: 8192,
|
|
62
|
+
pricing: { prompt: "0.03", completion: "0.06" },
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
id: "anthropic/claude-3",
|
|
66
|
+
name: "Claude 3",
|
|
67
|
+
context_length: 200000,
|
|
68
|
+
pricing: { prompt: "0.015", completion: "0.075" },
|
|
69
|
+
},
|
|
70
|
+
];
|
|
71
|
+
mockFetch.mockResolvedValueOnce({
|
|
72
|
+
ok: true,
|
|
73
|
+
json: async () => ({ data: models }),
|
|
74
|
+
});
|
|
75
|
+
const caller = makeCaller(container, { openRouterApiKey: "sk-test-key" });
|
|
76
|
+
const result = await caller.admin.listAvailableModels();
|
|
77
|
+
expect(result.models).toHaveLength(2);
|
|
78
|
+
expect(result.models[0].id).toBe("anthropic/claude-3");
|
|
79
|
+
expect(result.models[1].id).toBe("openai/gpt-4");
|
|
80
|
+
expect(mockFetch).toHaveBeenCalledOnce();
|
|
81
|
+
// Second call should use cache (no additional fetch)
|
|
82
|
+
const result2 = await caller.admin.listAvailableModels();
|
|
83
|
+
expect(result2.models).toHaveLength(2);
|
|
84
|
+
expect(mockFetch).toHaveBeenCalledOnce();
|
|
85
|
+
});
|
|
86
|
+
it("returns empty list when no API key configured", async () => {
|
|
87
|
+
const container = createTestContainer();
|
|
88
|
+
const caller = makeCaller(container); // no config = no API key
|
|
89
|
+
const result = await caller.admin.listAvailableModels();
|
|
90
|
+
expect(result.models).toEqual([]);
|
|
91
|
+
expect(mockFetch).not.toHaveBeenCalled();
|
|
92
|
+
});
|
|
93
|
+
it("returns stale cache on fetch failure", async () => {
|
|
94
|
+
// First call seeds the cache
|
|
95
|
+
const container = createTestContainer();
|
|
96
|
+
mockFetch.mockResolvedValueOnce({
|
|
97
|
+
ok: true,
|
|
98
|
+
json: async () => ({
|
|
99
|
+
data: [{ id: "model/a", name: "A", context_length: 100, pricing: { prompt: "1", completion: "2" } }],
|
|
100
|
+
}),
|
|
101
|
+
});
|
|
102
|
+
const caller = makeCaller(container, { openRouterApiKey: "sk-test-key" });
|
|
103
|
+
await caller.admin.listAvailableModels();
|
|
104
|
+
// Advance past 60s cache TTL so the next call triggers a fetch
|
|
105
|
+
vi.useFakeTimers();
|
|
106
|
+
vi.advanceTimersByTime(61_000);
|
|
107
|
+
mockFetch.mockRejectedValueOnce(new Error("Network error"));
|
|
108
|
+
const result = await caller.admin.listAvailableModels();
|
|
109
|
+
// On failure, falls back to stale cache
|
|
110
|
+
expect(result.models).toHaveLength(1);
|
|
111
|
+
expect(result.models[0].id).toBe("model/a");
|
|
112
|
+
vi.useRealTimers();
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
// -------------------------------------------------------------------------
|
|
116
|
+
// Fleet instance listing
|
|
117
|
+
// -------------------------------------------------------------------------
|
|
118
|
+
describe("listAllInstances", () => {
|
|
119
|
+
it("lists instances using container.fleet", async () => {
|
|
120
|
+
const mockProfiles = [
|
|
121
|
+
{ id: "inst-1", name: "Bot A", tenantId: "t1", image: "img:latest" },
|
|
122
|
+
{ id: "inst-2", name: "Bot B", tenantId: "t2", image: "img:v2" },
|
|
123
|
+
];
|
|
124
|
+
const mockStatus = {
|
|
125
|
+
state: "running",
|
|
126
|
+
health: "healthy",
|
|
127
|
+
uptime: 3600,
|
|
128
|
+
containerId: "abc123",
|
|
129
|
+
startedAt: "2026-01-01T00:00:00Z",
|
|
130
|
+
};
|
|
131
|
+
const container = createTestContainer({
|
|
132
|
+
fleet: {
|
|
133
|
+
manager: {
|
|
134
|
+
status: vi.fn().mockResolvedValue(mockStatus),
|
|
135
|
+
},
|
|
136
|
+
docker: {},
|
|
137
|
+
proxy: {},
|
|
138
|
+
profileStore: {
|
|
139
|
+
list: vi.fn().mockResolvedValue(mockProfiles),
|
|
140
|
+
init: vi.fn(),
|
|
141
|
+
save: vi.fn(),
|
|
142
|
+
get: vi.fn(),
|
|
143
|
+
delete: vi.fn(),
|
|
144
|
+
},
|
|
145
|
+
serviceKeyRepo: {},
|
|
146
|
+
},
|
|
147
|
+
});
|
|
148
|
+
const caller = makeCaller(container);
|
|
149
|
+
const result = await caller.admin.listAllInstances();
|
|
150
|
+
expect(result.instances).toHaveLength(2);
|
|
151
|
+
expect(result.instances[0]).toEqual({
|
|
152
|
+
id: "inst-1",
|
|
153
|
+
name: "Bot A",
|
|
154
|
+
tenantId: "t1",
|
|
155
|
+
image: "img:latest",
|
|
156
|
+
state: "running",
|
|
157
|
+
health: "healthy",
|
|
158
|
+
uptime: 3600,
|
|
159
|
+
containerId: "abc123",
|
|
160
|
+
startedAt: "2026-01-01T00:00:00Z",
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
it("returns error when fleet not configured", async () => {
|
|
164
|
+
const container = createTestContainer({ fleet: null });
|
|
165
|
+
const caller = makeCaller(container);
|
|
166
|
+
const result = await caller.admin.listAllInstances();
|
|
167
|
+
expect(result.instances).toEqual([]);
|
|
168
|
+
expect(result.error).toBe("Fleet not configured");
|
|
169
|
+
});
|
|
170
|
+
it("returns error state for instances that fail status check", async () => {
|
|
171
|
+
const mockProfiles = [{ id: "inst-bad", name: "Bad Bot", tenantId: "t1", image: "img:latest" }];
|
|
172
|
+
const container = createTestContainer({
|
|
173
|
+
fleet: {
|
|
174
|
+
manager: {
|
|
175
|
+
status: vi.fn().mockRejectedValue(new Error("container not found")),
|
|
176
|
+
},
|
|
177
|
+
docker: {},
|
|
178
|
+
proxy: {},
|
|
179
|
+
profileStore: {
|
|
180
|
+
list: vi.fn().mockResolvedValue(mockProfiles),
|
|
181
|
+
init: vi.fn(),
|
|
182
|
+
save: vi.fn(),
|
|
183
|
+
get: vi.fn(),
|
|
184
|
+
delete: vi.fn(),
|
|
185
|
+
},
|
|
186
|
+
serviceKeyRepo: {},
|
|
187
|
+
},
|
|
188
|
+
});
|
|
189
|
+
const caller = makeCaller(container);
|
|
190
|
+
const result = await caller.admin.listAllInstances();
|
|
191
|
+
expect(result.instances).toHaveLength(1);
|
|
192
|
+
expect(result.instances[0].state).toBe("error");
|
|
193
|
+
expect(result.instances[0].health).toBeNull();
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
// -------------------------------------------------------------------------
|
|
197
|
+
// Credit balance query
|
|
198
|
+
// -------------------------------------------------------------------------
|
|
199
|
+
describe("billingOverview", () => {
|
|
200
|
+
it("queries credit balance via container pool", async () => {
|
|
201
|
+
const mockPool = {
|
|
202
|
+
query: vi
|
|
203
|
+
.fn()
|
|
204
|
+
.mockResolvedValueOnce({ rows: [{ totalRaw: "50000000" }] }) // credit_entry sum (50M microdollars = 5000 cents)
|
|
205
|
+
.mockResolvedValueOnce({ rows: [{ count: "3" }] }) // payment methods
|
|
206
|
+
.mockResolvedValueOnce({ rows: [{ count: "5" }] }), // orgs
|
|
207
|
+
end: vi.fn(),
|
|
208
|
+
};
|
|
209
|
+
const container = createTestContainer({
|
|
210
|
+
pool: mockPool,
|
|
211
|
+
gateway: null, // no gateway = skip service key count
|
|
212
|
+
});
|
|
213
|
+
const caller = makeCaller(container);
|
|
214
|
+
const result = await caller.admin.billingOverview();
|
|
215
|
+
expect(result.totalBalanceCents).toBe(5000);
|
|
216
|
+
expect(result.activeKeyCount).toBe(0); // gateway not configured
|
|
217
|
+
expect(result.paymentMethodCount).toBe(3);
|
|
218
|
+
expect(result.orgCount).toBe(5);
|
|
219
|
+
});
|
|
220
|
+
it("counts active service keys when gateway is configured", async () => {
|
|
221
|
+
const mockPool = {
|
|
222
|
+
query: vi
|
|
223
|
+
.fn()
|
|
224
|
+
.mockResolvedValueOnce({ rows: [{ totalRaw: "0" }] }) // credit_entry
|
|
225
|
+
.mockResolvedValueOnce({ rows: [{ count: "7" }] }) // service_keys
|
|
226
|
+
.mockResolvedValueOnce({ rows: [{ count: "0" }] }) // payment methods
|
|
227
|
+
.mockResolvedValueOnce({ rows: [{ count: "0" }] }), // orgs
|
|
228
|
+
end: vi.fn(),
|
|
229
|
+
};
|
|
230
|
+
const container = createTestContainer({
|
|
231
|
+
pool: mockPool,
|
|
232
|
+
gateway: {
|
|
233
|
+
serviceKeyRepo: {},
|
|
234
|
+
},
|
|
235
|
+
});
|
|
236
|
+
const caller = makeCaller(container);
|
|
237
|
+
const result = await caller.admin.billingOverview();
|
|
238
|
+
expect(result.activeKeyCount).toBe(7);
|
|
239
|
+
});
|
|
240
|
+
it("returns zeros when tables do not exist", async () => {
|
|
241
|
+
const mockPool = {
|
|
242
|
+
query: vi.fn().mockRejectedValue(new Error('relation "credit_entry" does not exist')),
|
|
243
|
+
end: vi.fn(),
|
|
244
|
+
};
|
|
245
|
+
const container = createTestContainer({
|
|
246
|
+
pool: mockPool,
|
|
247
|
+
});
|
|
248
|
+
const caller = makeCaller(container);
|
|
249
|
+
const result = await caller.admin.billingOverview();
|
|
250
|
+
expect(result.totalBalanceCents).toBe(0);
|
|
251
|
+
expect(result.activeKeyCount).toBe(0);
|
|
252
|
+
expect(result.paymentMethodCount).toBe(0);
|
|
253
|
+
expect(result.orgCount).toBe(0);
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
// -------------------------------------------------------------------------
|
|
257
|
+
// Auth guard
|
|
258
|
+
// -------------------------------------------------------------------------
|
|
259
|
+
it("rejects non-admin users", async () => {
|
|
260
|
+
const container = createTestContainer();
|
|
261
|
+
const adminRouter = createAdminRouter(container);
|
|
262
|
+
const appRouter = router({ admin: adminRouter });
|
|
263
|
+
const createCaller = createCallerFactory(appRouter);
|
|
264
|
+
const caller = createCaller({ user: { id: "u1", roles: ["user"] }, tenantId: undefined });
|
|
265
|
+
await expect(caller.admin.listAvailableModels()).rejects.toThrow("Platform admin role required");
|
|
266
|
+
});
|
|
267
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { createTestContainer } from "../../test-container.js";
|
|
3
|
+
import { createCryptoWebhookRoutes } from "../crypto-webhook.js";
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// Mock the key-server-webhook handler so we never hit real billing logic
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
vi.mock("../../../billing/crypto/key-server-webhook.js", () => ({
|
|
8
|
+
handleKeyServerWebhook: vi.fn().mockResolvedValue({
|
|
9
|
+
handled: true,
|
|
10
|
+
creditedCents: 500,
|
|
11
|
+
}),
|
|
12
|
+
}));
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
// Helpers
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
const SECRET = "test-provision-secret";
|
|
17
|
+
const CRYPTO_KEY = "test-crypto-service-key";
|
|
18
|
+
function makeConfig(overrides) {
|
|
19
|
+
return {
|
|
20
|
+
provisionSecret: SECRET,
|
|
21
|
+
cryptoServiceKey: CRYPTO_KEY,
|
|
22
|
+
...overrides,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
function makeCrypto() {
|
|
26
|
+
return {
|
|
27
|
+
chargeRepo: {},
|
|
28
|
+
webhookSeenRepo: {},
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
const validPayload = {
|
|
32
|
+
chargeId: "ch_123",
|
|
33
|
+
chain: "ETH",
|
|
34
|
+
address: "0xabc",
|
|
35
|
+
status: "confirmed",
|
|
36
|
+
};
|
|
37
|
+
function buildApp(opts) {
|
|
38
|
+
const container = createTestContainer({
|
|
39
|
+
crypto: opts?.crypto !== undefined ? opts.crypto : makeCrypto(),
|
|
40
|
+
});
|
|
41
|
+
const config = makeConfig(opts?.config);
|
|
42
|
+
return createCryptoWebhookRoutes(container, config);
|
|
43
|
+
}
|
|
44
|
+
async function post(app, body, headers) {
|
|
45
|
+
const init = {
|
|
46
|
+
method: "POST",
|
|
47
|
+
headers: {
|
|
48
|
+
"Content-Type": "application/json",
|
|
49
|
+
...headers,
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
if (body !== undefined) {
|
|
53
|
+
init.body = typeof body === "string" ? body : JSON.stringify(body);
|
|
54
|
+
}
|
|
55
|
+
return app.request("/", init);
|
|
56
|
+
}
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
// Tests
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
describe("createCryptoWebhookRoutes", () => {
|
|
61
|
+
// 1. No auth header
|
|
62
|
+
it("returns 401 without auth header", async () => {
|
|
63
|
+
const app = buildApp();
|
|
64
|
+
const res = await post(app, validPayload);
|
|
65
|
+
expect(res.status).toBe(401);
|
|
66
|
+
const json = await res.json();
|
|
67
|
+
expect(json.error).toBe("Unauthorized");
|
|
68
|
+
});
|
|
69
|
+
// 2. Wrong Bearer token
|
|
70
|
+
it("returns 401 with wrong Bearer token", async () => {
|
|
71
|
+
const app = buildApp();
|
|
72
|
+
const res = await post(app, validPayload, {
|
|
73
|
+
Authorization: "Bearer wrong-token",
|
|
74
|
+
});
|
|
75
|
+
expect(res.status).toBe(401);
|
|
76
|
+
const json = await res.json();
|
|
77
|
+
expect(json.error).toBe("Unauthorized");
|
|
78
|
+
});
|
|
79
|
+
// 3. Invalid JSON
|
|
80
|
+
it("returns 400 on invalid JSON", async () => {
|
|
81
|
+
const app = buildApp();
|
|
82
|
+
const res = await app.request("/", {
|
|
83
|
+
method: "POST",
|
|
84
|
+
headers: {
|
|
85
|
+
"Content-Type": "application/json",
|
|
86
|
+
Authorization: `Bearer ${SECRET}`,
|
|
87
|
+
},
|
|
88
|
+
body: "not-valid-json{{{",
|
|
89
|
+
});
|
|
90
|
+
expect(res.status).toBe(400);
|
|
91
|
+
const json = await res.json();
|
|
92
|
+
expect(json.error).toBe("Invalid JSON");
|
|
93
|
+
});
|
|
94
|
+
// 4. Zod validation failure (missing required fields)
|
|
95
|
+
it("returns 400 on payload that fails Zod validation", async () => {
|
|
96
|
+
const app = buildApp();
|
|
97
|
+
const res = await post(app, { chargeId: "ch_123" }, // missing chain, address, status
|
|
98
|
+
{ Authorization: `Bearer ${SECRET}` });
|
|
99
|
+
expect(res.status).toBe(400);
|
|
100
|
+
const json = await res.json();
|
|
101
|
+
expect(json.error).toBe("Invalid payload");
|
|
102
|
+
expect(json.issues).toBeDefined();
|
|
103
|
+
expect(json.issues.length).toBeGreaterThan(0);
|
|
104
|
+
});
|
|
105
|
+
// 5. Container crypto is null -> 501
|
|
106
|
+
it("returns 501 when container.crypto is null", async () => {
|
|
107
|
+
const app = buildApp({ crypto: null });
|
|
108
|
+
const res = await post(app, validPayload, {
|
|
109
|
+
Authorization: `Bearer ${SECRET}`,
|
|
110
|
+
});
|
|
111
|
+
expect(res.status).toBe(501);
|
|
112
|
+
const json = await res.json();
|
|
113
|
+
expect(json.error).toBe("Crypto payments not configured");
|
|
114
|
+
});
|
|
115
|
+
// 6. Valid Bearer matching provision secret
|
|
116
|
+
it("accepts valid Bearer token matching provision secret", async () => {
|
|
117
|
+
const app = buildApp();
|
|
118
|
+
const res = await post(app, validPayload, {
|
|
119
|
+
Authorization: `Bearer ${SECRET}`,
|
|
120
|
+
});
|
|
121
|
+
expect(res.status).toBe(200);
|
|
122
|
+
const json = await res.json();
|
|
123
|
+
expect(json.handled).toBe(true);
|
|
124
|
+
expect(json.creditedCents).toBe(500);
|
|
125
|
+
});
|
|
126
|
+
// 7. Valid Bearer matching crypto service key
|
|
127
|
+
it("accepts valid Bearer token matching crypto service key", async () => {
|
|
128
|
+
const app = buildApp();
|
|
129
|
+
const res = await post(app, validPayload, {
|
|
130
|
+
Authorization: `Bearer ${CRYPTO_KEY}`,
|
|
131
|
+
});
|
|
132
|
+
expect(res.status).toBe(200);
|
|
133
|
+
const json = await res.json();
|
|
134
|
+
expect(json.handled).toBe(true);
|
|
135
|
+
expect(json.creditedCents).toBe(500);
|
|
136
|
+
});
|
|
137
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { createTestContainer } from "../../test-container.js";
|
|
3
|
+
import { createProvisionWebhookRoutes } from "../provision-webhook.js";
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// Helpers
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
const SECRET = "test-provision-secret-1234";
|
|
8
|
+
function makeConfig(overrides) {
|
|
9
|
+
return {
|
|
10
|
+
provisionSecret: SECRET,
|
|
11
|
+
instanceImage: "ghcr.io/test/app:latest",
|
|
12
|
+
containerPort: 3000,
|
|
13
|
+
maxInstancesPerTenant: 5,
|
|
14
|
+
gatewayUrl: "http://gateway:4000",
|
|
15
|
+
containerPrefix: "test",
|
|
16
|
+
...overrides,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
function makeFleet() {
|
|
20
|
+
return {
|
|
21
|
+
manager: {
|
|
22
|
+
create: vi.fn().mockResolvedValue({
|
|
23
|
+
id: "inst-001",
|
|
24
|
+
containerId: "docker-abc",
|
|
25
|
+
containerName: "test-myapp",
|
|
26
|
+
url: "http://test-myapp:3000",
|
|
27
|
+
profile: { id: "inst-001", name: "myapp", tenantId: "tenant-1" },
|
|
28
|
+
}),
|
|
29
|
+
remove: vi.fn().mockResolvedValue(undefined),
|
|
30
|
+
status: vi.fn().mockResolvedValue({
|
|
31
|
+
id: "inst-001",
|
|
32
|
+
name: "myapp",
|
|
33
|
+
state: "running",
|
|
34
|
+
}),
|
|
35
|
+
},
|
|
36
|
+
docker: {},
|
|
37
|
+
proxy: {
|
|
38
|
+
addRoute: vi.fn().mockResolvedValue(undefined),
|
|
39
|
+
removeRoute: vi.fn(),
|
|
40
|
+
updateHealth: vi.fn(),
|
|
41
|
+
getRoutes: vi.fn().mockReturnValue([]),
|
|
42
|
+
start: vi.fn().mockResolvedValue(undefined),
|
|
43
|
+
stop: vi.fn().mockResolvedValue(undefined),
|
|
44
|
+
reload: vi.fn().mockResolvedValue(undefined),
|
|
45
|
+
},
|
|
46
|
+
profileStore: {
|
|
47
|
+
init: vi.fn().mockResolvedValue(undefined),
|
|
48
|
+
save: vi.fn().mockResolvedValue(undefined),
|
|
49
|
+
get: vi.fn().mockResolvedValue(null),
|
|
50
|
+
list: vi.fn().mockResolvedValue([]),
|
|
51
|
+
delete: vi.fn().mockResolvedValue(true),
|
|
52
|
+
},
|
|
53
|
+
serviceKeyRepo: {
|
|
54
|
+
generate: vi.fn().mockResolvedValue("key-abc"),
|
|
55
|
+
resolve: vi.fn().mockResolvedValue(null),
|
|
56
|
+
revokeByInstance: vi.fn().mockResolvedValue(undefined),
|
|
57
|
+
revokeByTenant: vi.fn().mockResolvedValue(undefined),
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
function buildApp(opts) {
|
|
62
|
+
const container = createTestContainer({
|
|
63
|
+
fleet: opts?.fleet !== undefined ? opts.fleet : makeFleet(),
|
|
64
|
+
});
|
|
65
|
+
const config = makeConfig(opts?.config);
|
|
66
|
+
return createProvisionWebhookRoutes(container, config);
|
|
67
|
+
}
|
|
68
|
+
async function request(app, method, path, body, headers) {
|
|
69
|
+
const init = {
|
|
70
|
+
method,
|
|
71
|
+
headers: {
|
|
72
|
+
"Content-Type": "application/json",
|
|
73
|
+
...headers,
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
if (body !== undefined) {
|
|
77
|
+
init.body = JSON.stringify(body);
|
|
78
|
+
}
|
|
79
|
+
return app.request(path, init);
|
|
80
|
+
}
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
// Tests
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
describe("createProvisionWebhookRoutes", () => {
|
|
85
|
+
// ---- Auth tests (apply to all endpoints) ----
|
|
86
|
+
it("returns 401 without authorization header", async () => {
|
|
87
|
+
const app = buildApp();
|
|
88
|
+
const res = await request(app, "POST", "/create", { tenantId: "t1", subdomain: "test" });
|
|
89
|
+
expect(res.status).toBe(401);
|
|
90
|
+
const json = await res.json();
|
|
91
|
+
expect(json.error).toBe("Unauthorized");
|
|
92
|
+
});
|
|
93
|
+
it("returns 401 with wrong secret", async () => {
|
|
94
|
+
const app = buildApp();
|
|
95
|
+
const res = await request(app, "POST", "/create", { tenantId: "t1", subdomain: "test" }, {
|
|
96
|
+
Authorization: "Bearer wrong-secret",
|
|
97
|
+
});
|
|
98
|
+
expect(res.status).toBe(401);
|
|
99
|
+
const json = await res.json();
|
|
100
|
+
expect(json.error).toBe("Unauthorized");
|
|
101
|
+
});
|
|
102
|
+
// ---- Fleet not configured ----
|
|
103
|
+
it("returns 501 when fleet not configured (container.fleet is null)", async () => {
|
|
104
|
+
const app = buildApp({ fleet: null });
|
|
105
|
+
const res = await request(app, "POST", "/create", { tenantId: "t1", subdomain: "test" }, {
|
|
106
|
+
Authorization: `Bearer ${SECRET}`,
|
|
107
|
+
});
|
|
108
|
+
expect(res.status).toBe(501);
|
|
109
|
+
const json = await res.json();
|
|
110
|
+
expect(json.error).toBe("Fleet management not configured");
|
|
111
|
+
});
|
|
112
|
+
it("returns 501 on destroy when fleet not configured", async () => {
|
|
113
|
+
const app = buildApp({ fleet: null });
|
|
114
|
+
const res = await request(app, "POST", "/destroy", { instanceId: "inst-001" }, {
|
|
115
|
+
Authorization: `Bearer ${SECRET}`,
|
|
116
|
+
});
|
|
117
|
+
expect(res.status).toBe(501);
|
|
118
|
+
});
|
|
119
|
+
it("returns 501 on budget when fleet not configured", async () => {
|
|
120
|
+
const app = buildApp({ fleet: null });
|
|
121
|
+
const res = await request(app, "PUT", "/budget", { instanceId: "inst-001", tenantEntityId: "te-1", budgetCents: 1000 }, { Authorization: `Bearer ${SECRET}` });
|
|
122
|
+
expect(res.status).toBe(501);
|
|
123
|
+
});
|
|
124
|
+
// ---- Create endpoint ----
|
|
125
|
+
it("handles create webhook with valid auth and payload", async () => {
|
|
126
|
+
const fleet = makeFleet();
|
|
127
|
+
const app = buildApp({ fleet });
|
|
128
|
+
const res = await request(app, "POST", "/create", { tenantId: "tenant-1", subdomain: "myapp" }, { Authorization: `Bearer ${SECRET}` });
|
|
129
|
+
expect(res.status).toBe(201);
|
|
130
|
+
const json = await res.json();
|
|
131
|
+
expect(json.ok).toBe(true);
|
|
132
|
+
expect(json.instanceId).toBe("inst-001");
|
|
133
|
+
expect(json.subdomain).toBe("myapp");
|
|
134
|
+
expect(json.containerUrl).toBe("http://test-myapp:3000");
|
|
135
|
+
// Verify fleet.create was called with generic env var names
|
|
136
|
+
expect(fleet.manager.create).toHaveBeenCalledTimes(1);
|
|
137
|
+
const createCall = fleet.manager.create.mock.calls[0][0];
|
|
138
|
+
expect(createCall.env.HOSTED_MODE).toBe("true");
|
|
139
|
+
expect(createCall.env.DEPLOYMENT_MODE).toBe("hosted_proxy");
|
|
140
|
+
expect(createCall.env.DEPLOYMENT_EXPOSURE).toBe("private");
|
|
141
|
+
expect(createCall.env.MIGRATION_AUTO_APPLY).toBe("true");
|
|
142
|
+
// Verify NO product-specific prefixes
|
|
143
|
+
expect(createCall.env.PAPERCLIP_HOSTED_MODE).toBeUndefined();
|
|
144
|
+
expect(createCall.env.PAPERCLIP_DEPLOYMENT_MODE).toBeUndefined();
|
|
145
|
+
// Verify proxy route was registered
|
|
146
|
+
expect(fleet.proxy.addRoute).toHaveBeenCalledTimes(1);
|
|
147
|
+
});
|
|
148
|
+
it("returns 422 on create when required fields are missing", async () => {
|
|
149
|
+
const app = buildApp();
|
|
150
|
+
const res = await request(app, "POST", "/create", { tenantId: "tenant-1" }, // missing subdomain
|
|
151
|
+
{ Authorization: `Bearer ${SECRET}` });
|
|
152
|
+
expect(res.status).toBe(422);
|
|
153
|
+
const json = await res.json();
|
|
154
|
+
expect(json.error).toContain("Missing required fields");
|
|
155
|
+
});
|
|
156
|
+
// ---- Destroy endpoint ----
|
|
157
|
+
it("handles destroy webhook with valid auth and instanceId", async () => {
|
|
158
|
+
const fleet = makeFleet();
|
|
159
|
+
const app = buildApp({ fleet });
|
|
160
|
+
const res = await request(app, "POST", "/destroy", { instanceId: "inst-001" }, { Authorization: `Bearer ${SECRET}` });
|
|
161
|
+
expect(res.status).toBe(200);
|
|
162
|
+
const json = await res.json();
|
|
163
|
+
expect(json.ok).toBe(true);
|
|
164
|
+
expect(fleet.serviceKeyRepo.revokeByInstance).toHaveBeenCalledWith("inst-001");
|
|
165
|
+
expect(fleet.manager.remove).toHaveBeenCalledWith("inst-001");
|
|
166
|
+
expect(fleet.proxy.removeRoute).toHaveBeenCalledWith("inst-001");
|
|
167
|
+
});
|
|
168
|
+
it("returns 422 on destroy when instanceId is missing", async () => {
|
|
169
|
+
const app = buildApp();
|
|
170
|
+
const res = await request(app, "POST", "/destroy", {}, {
|
|
171
|
+
Authorization: `Bearer ${SECRET}`,
|
|
172
|
+
});
|
|
173
|
+
expect(res.status).toBe(422);
|
|
174
|
+
const json = await res.json();
|
|
175
|
+
expect(json.error).toContain("Missing required field");
|
|
176
|
+
});
|
|
177
|
+
// ---- Budget endpoint ----
|
|
178
|
+
it("handles budget webhook with valid auth and payload", async () => {
|
|
179
|
+
const fleet = makeFleet();
|
|
180
|
+
const app = buildApp({ fleet });
|
|
181
|
+
const res = await request(app, "PUT", "/budget", { instanceId: "inst-001", tenantEntityId: "te-1", budgetCents: 5000 }, { Authorization: `Bearer ${SECRET}` });
|
|
182
|
+
expect(res.status).toBe(200);
|
|
183
|
+
const json = await res.json();
|
|
184
|
+
expect(json.ok).toBe(true);
|
|
185
|
+
expect(json.budgetCents).toBe(5000);
|
|
186
|
+
});
|
|
187
|
+
it("returns 422 on budget when required fields are missing", async () => {
|
|
188
|
+
const app = buildApp();
|
|
189
|
+
const res = await request(app, "PUT", "/budget", { instanceId: "inst-001" }, // missing tenantEntityId, budgetCents
|
|
190
|
+
{ Authorization: `Bearer ${SECRET}` });
|
|
191
|
+
expect(res.status).toBe(422);
|
|
192
|
+
const json = await res.json();
|
|
193
|
+
expect(json.error).toContain("Missing required fields");
|
|
194
|
+
});
|
|
195
|
+
// ---- Generic env var names ----
|
|
196
|
+
it("uses generic env var names with no PAPERCLIP_ prefix anywhere", async () => {
|
|
197
|
+
const fleet = makeFleet();
|
|
198
|
+
const app = buildApp({ fleet });
|
|
199
|
+
await request(app, "POST", "/create", { tenantId: "tenant-1", subdomain: "myapp" }, { Authorization: `Bearer ${SECRET}` });
|
|
200
|
+
const createCall = fleet.manager.create.mock.calls[0][0];
|
|
201
|
+
const envKeys = Object.keys(createCall.env);
|
|
202
|
+
for (const key of envKeys) {
|
|
203
|
+
expect(key).not.toMatch(/^PAPERCLIP_/);
|
|
204
|
+
}
|
|
205
|
+
// Verify expected generic names are present
|
|
206
|
+
expect(envKeys).toContain("HOSTED_MODE");
|
|
207
|
+
expect(envKeys).toContain("DEPLOYMENT_MODE");
|
|
208
|
+
expect(envKeys).toContain("DEPLOYMENT_EXPOSURE");
|
|
209
|
+
expect(envKeys).toContain("MIGRATION_AUTO_APPLY");
|
|
210
|
+
expect(envKeys).toContain("PROVISION_SECRET");
|
|
211
|
+
});
|
|
212
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|