hanzi-browse 2.2.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/README.md +182 -0
- package/dist/agent/loop.d.ts +63 -0
- package/dist/agent/loop.js +186 -0
- package/dist/agent/system-prompt.d.ts +7 -0
- package/dist/agent/system-prompt.js +41 -0
- package/dist/agent/tools.d.ts +9 -0
- package/dist/agent/tools.js +154 -0
- package/dist/cli/detect-credentials.d.ts +31 -0
- package/dist/cli/detect-credentials.js +44 -0
- package/dist/cli/import-credentials-handler.d.ts +14 -0
- package/dist/cli/import-credentials-handler.js +22 -0
- package/dist/cli/session-files.d.ts +28 -0
- package/dist/cli/session-files.js +118 -0
- package/dist/cli/setup.d.ts +10 -0
- package/dist/cli/setup.js +915 -0
- package/dist/cli.d.ts +16 -0
- package/dist/cli.js +506 -0
- package/dist/dashboard/assets/index-CEFyesbT.js +46 -0
- package/dist/dashboard/assets/index-Dnht2kLU.css +1 -0
- package/dist/dashboard/index.html +13 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1116 -0
- package/dist/ipc/index.d.ts +8 -0
- package/dist/ipc/index.js +8 -0
- package/dist/ipc/native-host.d.ts +96 -0
- package/dist/ipc/native-host.js +223 -0
- package/dist/ipc/websocket-client.d.ts +73 -0
- package/dist/ipc/websocket-client.js +199 -0
- package/dist/license/manager.d.ts +20 -0
- package/dist/license/manager.js +15 -0
- package/dist/llm/client.d.ts +72 -0
- package/dist/llm/client.js +227 -0
- package/dist/llm/credentials.d.ts +61 -0
- package/dist/llm/credentials.js +200 -0
- package/dist/llm/vertex.d.ts +22 -0
- package/dist/llm/vertex.js +335 -0
- package/dist/managed/api-http.test.d.ts +7 -0
- package/dist/managed/api-http.test.js +623 -0
- package/dist/managed/api.d.ts +51 -0
- package/dist/managed/api.js +1448 -0
- package/dist/managed/api.test.d.ts +10 -0
- package/dist/managed/api.test.js +146 -0
- package/dist/managed/auth.d.ts +38 -0
- package/dist/managed/auth.js +192 -0
- package/dist/managed/billing.d.ts +70 -0
- package/dist/managed/billing.js +227 -0
- package/dist/managed/deploy.d.ts +17 -0
- package/dist/managed/deploy.js +385 -0
- package/dist/managed/e2e.test.d.ts +15 -0
- package/dist/managed/e2e.test.js +151 -0
- package/dist/managed/hardening.test.d.ts +14 -0
- package/dist/managed/hardening.test.js +346 -0
- package/dist/managed/integration.test.d.ts +8 -0
- package/dist/managed/integration.test.js +274 -0
- package/dist/managed/log.d.ts +18 -0
- package/dist/managed/log.js +31 -0
- package/dist/managed/server.d.ts +12 -0
- package/dist/managed/server.js +69 -0
- package/dist/managed/store-pg.d.ts +191 -0
- package/dist/managed/store-pg.js +479 -0
- package/dist/managed/store.d.ts +188 -0
- package/dist/managed/store.js +379 -0
- package/dist/relay/auto-start.d.ts +19 -0
- package/dist/relay/auto-start.js +71 -0
- package/dist/relay/server.d.ts +17 -0
- package/dist/relay/server.js +403 -0
- package/dist/types/index.d.ts +5 -0
- package/dist/types/index.js +4 -0
- package/dist/types/session.d.ts +134 -0
- package/dist/types/session.js +16 -0
- package/package.json +61 -0
- package/skills/README.md +48 -0
- package/skills/a11y-auditor/SKILL.md +42 -0
- package/skills/e2e-tester/SKILL.md +154 -0
- package/skills/hanzi-browse/SKILL.md +182 -0
- package/skills/linkedin-prospector/SKILL.md +149 -0
- package/skills/social-poster/SKILL.md +146 -0
- package/skills/x-marketer/SKILL.md +479 -0
|
@@ -0,0 +1,623 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP-level API tests
|
|
3
|
+
*
|
|
4
|
+
* Starts the actual server and hits endpoints.
|
|
5
|
+
* Tests auth enforcement, workspace isolation, session validation.
|
|
6
|
+
*/
|
|
7
|
+
import { initVertex } from "../llm/vertex.js";
|
|
8
|
+
import { startManagedAPI, initManagedAPI } from "./api.js";
|
|
9
|
+
import { createWorkspace, createApiKey } from "./store.js";
|
|
10
|
+
const PORT = 4567; // Use different port from production
|
|
11
|
+
const BASE = `http://localhost:${PORT}`;
|
|
12
|
+
// Mock relay — we don't need real WebSocket for API tests
|
|
13
|
+
const mockRelay = {
|
|
14
|
+
send: () => { },
|
|
15
|
+
onMessage: () => { },
|
|
16
|
+
connect: async () => { },
|
|
17
|
+
isConnected: () => true,
|
|
18
|
+
};
|
|
19
|
+
let defaultKey;
|
|
20
|
+
let otherKey;
|
|
21
|
+
async function setup() {
|
|
22
|
+
// Init Vertex (needed for imports, won't actually call it)
|
|
23
|
+
try {
|
|
24
|
+
initVertex("/tmp/hanzi-vertex-sa.json");
|
|
25
|
+
}
|
|
26
|
+
catch { }
|
|
27
|
+
// Always create fresh workspaces with known plaintext keys
|
|
28
|
+
const ws1 = createWorkspace("Test Workspace");
|
|
29
|
+
const key1 = createApiKey(ws1.id, "test-key");
|
|
30
|
+
defaultKey = key1.key; // plaintext from createApiKey
|
|
31
|
+
const ws2 = createWorkspace("Other Workspace");
|
|
32
|
+
const key2 = createApiKey(ws2.id, "other-key");
|
|
33
|
+
otherKey = key2.key;
|
|
34
|
+
initManagedAPI(mockRelay, () => false); // All sessions "not connected" by default
|
|
35
|
+
startManagedAPI(PORT);
|
|
36
|
+
await new Promise((r) => setTimeout(r, 500)); // Let server start
|
|
37
|
+
}
|
|
38
|
+
async function req(method, path, body, apiKey) {
|
|
39
|
+
const headers = { "Content-Type": "application/json" };
|
|
40
|
+
if (apiKey)
|
|
41
|
+
headers["Authorization"] = `Bearer ${apiKey}`;
|
|
42
|
+
const res = await fetch(`${BASE}${path}`, {
|
|
43
|
+
method,
|
|
44
|
+
headers,
|
|
45
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
46
|
+
});
|
|
47
|
+
const data = await res.json().catch(() => null);
|
|
48
|
+
return { status: res.status, data };
|
|
49
|
+
}
|
|
50
|
+
function assert(condition, msg) {
|
|
51
|
+
if (!condition)
|
|
52
|
+
throw new Error(`FAIL: ${msg}`);
|
|
53
|
+
console.log(` ✓ ${msg}`);
|
|
54
|
+
}
|
|
55
|
+
// --- Tests ---
|
|
56
|
+
async function testHealthNoAuth() {
|
|
57
|
+
console.log("\n--- Health (no auth required) ---");
|
|
58
|
+
const { status, data } = await req("GET", "/v1/health");
|
|
59
|
+
assert(status === 200, "Health returns 200");
|
|
60
|
+
assert(data.status === "ok", "Health status is ok");
|
|
61
|
+
}
|
|
62
|
+
async function testAuthRequired() {
|
|
63
|
+
console.log("\n--- Auth required ---");
|
|
64
|
+
const { status: s1 } = await req("POST", "/v1/tasks", { task: "test" });
|
|
65
|
+
assert(s1 === 401, "POST /tasks without auth returns 401");
|
|
66
|
+
const { status: s2 } = await req("GET", "/v1/tasks");
|
|
67
|
+
assert(s2 === 401, "GET /tasks without auth returns 401");
|
|
68
|
+
const { status: s3 } = await req("GET", "/v1/usage");
|
|
69
|
+
assert(s3 === 401, "GET /usage without auth returns 401");
|
|
70
|
+
const { status: s4 } = await req("POST", "/v1/browser-sessions/pair");
|
|
71
|
+
assert(s4 === 401, "POST /browser-sessions/pair without auth returns 401");
|
|
72
|
+
const { status: s5 } = await req("GET", "/v1/tasks", undefined, "hic_live_bogus");
|
|
73
|
+
assert(s5 === 401, "Invalid API key returns 401");
|
|
74
|
+
}
|
|
75
|
+
async function testBrowserSessionIdRequired() {
|
|
76
|
+
console.log("\n--- browser_session_id required ---");
|
|
77
|
+
const { status, data } = await req("POST", "/v1/tasks", { task: "test" }, defaultKey);
|
|
78
|
+
assert(status === 400, "Task without browser_session_id returns 400");
|
|
79
|
+
assert(data.error.includes("browser_session_id"), "Error mentions browser_session_id");
|
|
80
|
+
}
|
|
81
|
+
async function testPairingFlow() {
|
|
82
|
+
console.log("\n--- Pairing flow ---");
|
|
83
|
+
// Create pairing token
|
|
84
|
+
const { status: s1, data: d1 } = await req("POST", "/v1/browser-sessions/pair", {}, defaultKey);
|
|
85
|
+
assert(s1 === 201, "Pairing token created");
|
|
86
|
+
assert(d1.pairing_token.startsWith("hic_pair_"), "Token has correct prefix");
|
|
87
|
+
assert(d1.expires_in_seconds > 0, "Token has expiry");
|
|
88
|
+
// Register with pairing token (no auth required — the token IS the auth)
|
|
89
|
+
const { status: s2, data: d2 } = await req("POST", "/v1/browser-sessions/register", {
|
|
90
|
+
pairing_token: d1.pairing_token,
|
|
91
|
+
});
|
|
92
|
+
assert(s2 === 201, "Session registered");
|
|
93
|
+
assert(d2.browser_session_id, "Got browser_session_id");
|
|
94
|
+
assert(d2.session_token.startsWith("hic_sess_"), "Got session_token");
|
|
95
|
+
// Cannot reuse pairing token
|
|
96
|
+
const { status: s3 } = await req("POST", "/v1/browser-sessions/register", {
|
|
97
|
+
pairing_token: d1.pairing_token,
|
|
98
|
+
});
|
|
99
|
+
assert(s3 === 401, "Reused pairing token rejected");
|
|
100
|
+
// Invalid pairing token
|
|
101
|
+
const { status: s4 } = await req("POST", "/v1/browser-sessions/register", {
|
|
102
|
+
pairing_token: "hic_pair_bogus",
|
|
103
|
+
});
|
|
104
|
+
assert(s4 === 401, "Bogus pairing token rejected");
|
|
105
|
+
}
|
|
106
|
+
async function testSessionNotConnectedRejectsTask() {
|
|
107
|
+
console.log("\n--- Session not connected rejects task ---");
|
|
108
|
+
// Create a session
|
|
109
|
+
const { data: d1 } = await req("POST", "/v1/browser-sessions/pair", {}, defaultKey);
|
|
110
|
+
const { data: d2 } = await req("POST", "/v1/browser-sessions/register", {
|
|
111
|
+
pairing_token: d1.pairing_token,
|
|
112
|
+
});
|
|
113
|
+
// Try to create task — session exists but not connected (mock returns false)
|
|
114
|
+
const { status, data } = await req("POST", "/v1/tasks", {
|
|
115
|
+
task: "test",
|
|
116
|
+
browser_session_id: d2.browser_session_id,
|
|
117
|
+
}, defaultKey);
|
|
118
|
+
assert(status === 409, "Disconnected session returns 409");
|
|
119
|
+
assert(data.error.includes("not connected"), "Error mentions not connected");
|
|
120
|
+
}
|
|
121
|
+
async function testWrongWorkspaceSessionRejectsTask() {
|
|
122
|
+
console.log("\n--- Wrong workspace session rejects task ---");
|
|
123
|
+
// Create session in workspace A (defaultKey)
|
|
124
|
+
const { data: d1 } = await req("POST", "/v1/browser-sessions/pair", {}, defaultKey);
|
|
125
|
+
const { data: d2 } = await req("POST", "/v1/browser-sessions/register", {
|
|
126
|
+
pairing_token: d1.pairing_token,
|
|
127
|
+
});
|
|
128
|
+
// Try to use it with workspace B's key
|
|
129
|
+
const { status, data } = await req("POST", "/v1/tasks", {
|
|
130
|
+
task: "test",
|
|
131
|
+
browser_session_id: d2.browser_session_id,
|
|
132
|
+
}, otherKey);
|
|
133
|
+
assert(status === 403, "Wrong workspace session returns 403");
|
|
134
|
+
assert(data.error.includes("does not belong"), "Error mentions workspace mismatch");
|
|
135
|
+
}
|
|
136
|
+
async function testTaskOwnershipIsolation() {
|
|
137
|
+
console.log("\n--- Task ownership isolation ---");
|
|
138
|
+
// Create a task in default workspace directly in the store
|
|
139
|
+
const { createTaskRun, validateApiKey } = await import("./store.js");
|
|
140
|
+
const resolved = validateApiKey(defaultKey);
|
|
141
|
+
const task = createTaskRun({
|
|
142
|
+
workspaceId: resolved.workspaceId,
|
|
143
|
+
apiKeyId: "test",
|
|
144
|
+
task: "ownership test",
|
|
145
|
+
browserSessionId: "test-session",
|
|
146
|
+
});
|
|
147
|
+
// Default key can see it
|
|
148
|
+
const { status: s1, data: d1 } = await req("GET", `/v1/tasks/${task.id}`, undefined, defaultKey);
|
|
149
|
+
assert(s1 === 200, "Owner can read task");
|
|
150
|
+
assert(d1.id === task.id, "Correct task returned");
|
|
151
|
+
// Other workspace gets 404 (not 403 — don't leak existence)
|
|
152
|
+
const { status: s2 } = await req("GET", `/v1/tasks/${task.id}`, undefined, otherKey);
|
|
153
|
+
assert(s2 === 404, "Non-owner gets 404 (not 403)");
|
|
154
|
+
// Other workspace can't cancel it
|
|
155
|
+
const { status: s3 } = await req("POST", `/v1/tasks/${task.id}/cancel`, {}, otherKey);
|
|
156
|
+
assert(s3 === 404, "Non-owner can't cancel (gets 404)");
|
|
157
|
+
// Nonexistent task
|
|
158
|
+
const { status: s4 } = await req("GET", "/v1/tasks/nonexistent-id", undefined, defaultKey);
|
|
159
|
+
assert(s4 === 404, "Nonexistent task returns 404");
|
|
160
|
+
}
|
|
161
|
+
async function testListTasksIsolation() {
|
|
162
|
+
console.log("\n--- List tasks isolation ---");
|
|
163
|
+
const { status: s1, data: d1 } = await req("GET", "/v1/tasks", undefined, defaultKey);
|
|
164
|
+
assert(s1 === 200, "Default workspace can list tasks");
|
|
165
|
+
const { status: s2, data: d2 } = await req("GET", "/v1/tasks", undefined, otherKey);
|
|
166
|
+
assert(s2 === 200, "Other workspace can list tasks");
|
|
167
|
+
// Other workspace should have zero tasks (all tasks belong to default)
|
|
168
|
+
assert(d2.tasks.length === 0, "Other workspace sees no tasks");
|
|
169
|
+
}
|
|
170
|
+
async function testUsageIsolation() {
|
|
171
|
+
console.log("\n--- Usage isolation ---");
|
|
172
|
+
const { status: s1, data: d1 } = await req("GET", "/v1/usage", undefined, defaultKey);
|
|
173
|
+
assert(s1 === 200, "Default workspace can get usage");
|
|
174
|
+
const { status: s2, data: d2 } = await req("GET", "/v1/usage", undefined, otherKey);
|
|
175
|
+
assert(s2 === 200, "Other workspace can get usage");
|
|
176
|
+
assert(d2.totalApiCalls === 0, "Other workspace has zero usage");
|
|
177
|
+
}
|
|
178
|
+
// --- Input Validation Tests (Slice 1 hardening) ---
|
|
179
|
+
async function testInputValidation() {
|
|
180
|
+
console.log("\n--- Input validation (hardening) ---");
|
|
181
|
+
// Task too long
|
|
182
|
+
const longTask = "a".repeat(10_001);
|
|
183
|
+
const { status: s1, data: d1 } = await req("POST", "/v1/tasks", {
|
|
184
|
+
task: longTask,
|
|
185
|
+
browser_session_id: "test",
|
|
186
|
+
}, defaultKey);
|
|
187
|
+
assert(s1 === 400, "Task exceeding 10000 chars returns 400");
|
|
188
|
+
assert(d1.error.includes("10000"), "Error mentions char limit");
|
|
189
|
+
// Context too long
|
|
190
|
+
const longCtx = "b".repeat(50_001);
|
|
191
|
+
const { status: s2, data: d2 } = await req("POST", "/v1/tasks", {
|
|
192
|
+
task: "test",
|
|
193
|
+
context: longCtx,
|
|
194
|
+
browser_session_id: "test",
|
|
195
|
+
}, defaultKey);
|
|
196
|
+
assert(s2 === 400, "Context exceeding 50000 chars returns 400");
|
|
197
|
+
assert(d2.error.includes("context"), "Error mentions context");
|
|
198
|
+
// Invalid URL
|
|
199
|
+
const { status: s3, data: d3 } = await req("POST", "/v1/tasks", {
|
|
200
|
+
task: "test",
|
|
201
|
+
url: "not-a-url",
|
|
202
|
+
browser_session_id: "test",
|
|
203
|
+
}, defaultKey);
|
|
204
|
+
assert(s3 === 400, "Invalid URL returns 400");
|
|
205
|
+
assert(d3.error.includes("url"), "Error mentions url");
|
|
206
|
+
// URL too long
|
|
207
|
+
const longUrl = "https://example.com/" + "x".repeat(2048);
|
|
208
|
+
const { status: s4, data: d4 } = await req("POST", "/v1/tasks", {
|
|
209
|
+
task: "test",
|
|
210
|
+
url: longUrl,
|
|
211
|
+
browser_session_id: "test",
|
|
212
|
+
}, defaultKey);
|
|
213
|
+
assert(s4 === 400, "URL exceeding 2048 chars returns 400");
|
|
214
|
+
// Empty task
|
|
215
|
+
const { status: s5 } = await req("POST", "/v1/tasks", {
|
|
216
|
+
task: " ",
|
|
217
|
+
browser_session_id: "test",
|
|
218
|
+
}, defaultKey);
|
|
219
|
+
assert(s5 === 400, "Whitespace-only task returns 400");
|
|
220
|
+
// Valid task passes validation (hits browser_session_id check next)
|
|
221
|
+
const { status: s6, data: d6 } = await req("POST", "/v1/tasks", {
|
|
222
|
+
task: "valid task",
|
|
223
|
+
url: "https://example.com",
|
|
224
|
+
context: "some context",
|
|
225
|
+
browser_session_id: "nonexistent-session",
|
|
226
|
+
}, defaultKey);
|
|
227
|
+
assert(s6 === 404, "Valid input with unknown session returns 404 (passes validation)");
|
|
228
|
+
}
|
|
229
|
+
async function testRequestBodyLimit() {
|
|
230
|
+
console.log("\n--- Request body size limit ---");
|
|
231
|
+
// Send a request with body > 128KB
|
|
232
|
+
const hugeBody = JSON.stringify({ task: "x".repeat(200_000) });
|
|
233
|
+
try {
|
|
234
|
+
const res = await fetch(`${BASE}/v1/tasks`, {
|
|
235
|
+
method: "POST",
|
|
236
|
+
headers: {
|
|
237
|
+
"Content-Type": "application/json",
|
|
238
|
+
"Authorization": `Bearer ${defaultKey}`,
|
|
239
|
+
},
|
|
240
|
+
body: hugeBody,
|
|
241
|
+
});
|
|
242
|
+
// Should get either 400 (body too large) or connection reset
|
|
243
|
+
assert(res.status >= 400, "Oversized body rejected");
|
|
244
|
+
}
|
|
245
|
+
catch {
|
|
246
|
+
// Connection reset is also acceptable — server destroyed the socket
|
|
247
|
+
assert(true, "Oversized body caused connection reset (acceptable)");
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
// --- Rate Limiting Tests (Slice 2) ---
|
|
251
|
+
async function testRateLimiting() {
|
|
252
|
+
console.log("\n--- Rate limiting (hardening) ---");
|
|
253
|
+
// Rate limit is checked AFTER input validation — bad requests (400) don't burn quota.
|
|
254
|
+
// To trigger rate limits, we need valid requests that pass validation but fail later
|
|
255
|
+
// (e.g., session exists but is not connected → 409).
|
|
256
|
+
const { createWorkspace, createApiKey, createPairingToken, consumePairingToken } = await import("./store.js");
|
|
257
|
+
const freshWs = createWorkspace("Rate Limit Test");
|
|
258
|
+
const freshKey = createApiKey(freshWs.id, "rate-key");
|
|
259
|
+
// Create a real session (but it won't be "connected" since mock returns false)
|
|
260
|
+
const pt = createPairingToken(freshWs.id, freshKey.id);
|
|
261
|
+
const session = consumePairingToken(pt._plainToken);
|
|
262
|
+
// Verify bad requests don't burn quota: send 5 invalid requests first
|
|
263
|
+
for (let i = 0; i < 5; i++) {
|
|
264
|
+
const { status } = await req("POST", "/v1/tasks", { task: `bad ${i}` }, freshKey.key);
|
|
265
|
+
assert(status === 400, "Bad request returns 400 without burning quota");
|
|
266
|
+
}
|
|
267
|
+
// Now hammer with valid-looking requests (reach rate limit check, get 409 = not connected)
|
|
268
|
+
let hitRateLimit = false;
|
|
269
|
+
for (let i = 0; i < 12; i++) {
|
|
270
|
+
const { status } = await req("POST", "/v1/tasks", {
|
|
271
|
+
task: `rate test ${i}`,
|
|
272
|
+
browser_session_id: session.id,
|
|
273
|
+
}, freshKey.key);
|
|
274
|
+
if (status === 429) {
|
|
275
|
+
hitRateLimit = true;
|
|
276
|
+
break;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
assert(hitRateLimit, "Rate limit kicks in after max valid requests");
|
|
280
|
+
// Verify the error message is informative
|
|
281
|
+
const { status: s2, data: d2 } = await req("POST", "/v1/tasks", {
|
|
282
|
+
task: "one more",
|
|
283
|
+
browser_session_id: session.id,
|
|
284
|
+
}, freshKey.key);
|
|
285
|
+
assert(s2 === 429, "Rate limited request returns 429");
|
|
286
|
+
assert(d2.error.includes("Rate limit"), "Error mentions rate limit");
|
|
287
|
+
}
|
|
288
|
+
async function testCorsHeaders() {
|
|
289
|
+
console.log("\n--- CORS headers (hardening) ---");
|
|
290
|
+
// Known origin should get CORS headers
|
|
291
|
+
const res1 = await fetch(`${BASE}/v1/health`, {
|
|
292
|
+
headers: { Origin: "https://browse.hanzilla.co" },
|
|
293
|
+
});
|
|
294
|
+
const acao1 = res1.headers.get("access-control-allow-origin");
|
|
295
|
+
assert(acao1 === "https://browse.hanzilla.co", "Known origin gets reflected CORS");
|
|
296
|
+
// Unknown origin should NOT get CORS headers
|
|
297
|
+
const res2 = await fetch(`${BASE}/v1/health`, {
|
|
298
|
+
headers: { Origin: "https://evil.com" },
|
|
299
|
+
});
|
|
300
|
+
const acao2 = res2.headers.get("access-control-allow-origin");
|
|
301
|
+
assert(acao2 === null, "Unknown origin gets no CORS header");
|
|
302
|
+
// No origin header — no CORS headers
|
|
303
|
+
const res3 = await fetch(`${BASE}/v1/health`);
|
|
304
|
+
const acao3 = res3.headers.get("access-control-allow-origin");
|
|
305
|
+
assert(acao3 === null, "No origin header means no CORS header");
|
|
306
|
+
}
|
|
307
|
+
// --- Self-serve API Key Tests ---
|
|
308
|
+
async function testApiKeyCRUD() {
|
|
309
|
+
console.log("\n--- Self-serve API key CRUD ---");
|
|
310
|
+
// Create key
|
|
311
|
+
const { status: s1, data: d1 } = await req("POST", "/v1/api-keys", { name: "test-key" }, defaultKey);
|
|
312
|
+
assert(s1 === 201, "API key created (201)");
|
|
313
|
+
assert(d1.key.startsWith("hic_live_"), "Key has correct prefix");
|
|
314
|
+
assert(d1.name === "test-key", "Key has correct name");
|
|
315
|
+
assert(d1.id, "Key has an ID");
|
|
316
|
+
assert(d1._warning, "Response includes save warning");
|
|
317
|
+
const createdKeyId = d1.id;
|
|
318
|
+
const createdKeyValue = d1.key;
|
|
319
|
+
// List keys
|
|
320
|
+
const { status: s2, data: d2 } = await req("GET", "/v1/api-keys", undefined, defaultKey);
|
|
321
|
+
assert(s2 === 200, "List API keys returns 200");
|
|
322
|
+
assert(Array.isArray(d2.api_keys), "Response has api_keys array");
|
|
323
|
+
const found = d2.api_keys.find((k) => k.id === createdKeyId);
|
|
324
|
+
assert(!!found, "Created key appears in list");
|
|
325
|
+
assert(found.key_prefix.startsWith("hic_live_"), "Listed key shows readable prefix");
|
|
326
|
+
assert(!found.key_prefix.includes(createdKeyValue), "Listed key does not expose full key");
|
|
327
|
+
// The new key should work for auth
|
|
328
|
+
const { status: s3 } = await req("GET", "/v1/api-keys", undefined, createdKeyValue);
|
|
329
|
+
assert(s3 === 200, "Newly created key works for auth");
|
|
330
|
+
// Delete key
|
|
331
|
+
const { status: s4, data: d4 } = await req("DELETE", `/v1/api-keys/${createdKeyId}`, undefined, defaultKey);
|
|
332
|
+
assert(s4 === 200, "Delete returns 200");
|
|
333
|
+
assert(d4.deleted === true, "Response confirms deletion");
|
|
334
|
+
// Deleted key no longer works for auth
|
|
335
|
+
const { status: s5 } = await req("GET", "/v1/api-keys", undefined, createdKeyValue);
|
|
336
|
+
assert(s5 === 401, "Deleted key returns 401");
|
|
337
|
+
// Deleted key no longer in list
|
|
338
|
+
const { status: s6, data: d6 } = await req("GET", "/v1/api-keys", undefined, defaultKey);
|
|
339
|
+
const notFound = d6.api_keys.find((k) => k.id === createdKeyId);
|
|
340
|
+
assert(!notFound, "Deleted key removed from list");
|
|
341
|
+
// Delete nonexistent key
|
|
342
|
+
const { status: s7 } = await req("DELETE", "/v1/api-keys/nonexistent-id", undefined, defaultKey);
|
|
343
|
+
assert(s7 === 404, "Delete nonexistent key returns 404");
|
|
344
|
+
// Create with missing name
|
|
345
|
+
const { status: s8 } = await req("POST", "/v1/api-keys", {}, defaultKey);
|
|
346
|
+
assert(s8 === 400, "Create without name returns 400");
|
|
347
|
+
// Create with oversized name
|
|
348
|
+
const { status: s9 } = await req("POST", "/v1/api-keys", { name: "x".repeat(101) }, defaultKey);
|
|
349
|
+
assert(s9 === 400, "Create with name > 100 chars returns 400");
|
|
350
|
+
}
|
|
351
|
+
async function testApiKeyWorkspaceIsolation() {
|
|
352
|
+
console.log("\n--- API key workspace isolation ---");
|
|
353
|
+
// Create a key in default workspace
|
|
354
|
+
const { data: d1 } = await req("POST", "/v1/api-keys", { name: "ws-test" }, defaultKey);
|
|
355
|
+
// Other workspace cannot see it
|
|
356
|
+
const { data: d2 } = await req("GET", "/v1/api-keys", undefined, otherKey);
|
|
357
|
+
const found = d2.api_keys.find((k) => k.id === d1.id);
|
|
358
|
+
assert(!found, "Other workspace cannot see key from different workspace");
|
|
359
|
+
// Other workspace cannot delete it
|
|
360
|
+
const { status: s3 } = await req("DELETE", `/v1/api-keys/${d1.id}`, undefined, otherKey);
|
|
361
|
+
assert(s3 === 404, "Other workspace cannot delete key from different workspace");
|
|
362
|
+
// Clean up
|
|
363
|
+
await req("DELETE", `/v1/api-keys/${d1.id}`, undefined, defaultKey);
|
|
364
|
+
}
|
|
365
|
+
// --- Session Metadata Tests ---
|
|
366
|
+
async function testSessionMetadata() {
|
|
367
|
+
console.log("\n--- Session metadata propagation ---");
|
|
368
|
+
// Create pairing token WITH metadata
|
|
369
|
+
const { status: s1, data: d1 } = await req("POST", "/v1/browser-sessions/pair", {
|
|
370
|
+
label: "Dr. Smith's browser",
|
|
371
|
+
external_user_id: "user_abc123",
|
|
372
|
+
}, defaultKey);
|
|
373
|
+
assert(s1 === 201, "Pairing token with metadata created");
|
|
374
|
+
// Register session
|
|
375
|
+
const { status: s2, data: d2 } = await req("POST", "/v1/browser-sessions/register", {
|
|
376
|
+
pairing_token: d1.pairing_token,
|
|
377
|
+
});
|
|
378
|
+
assert(s2 === 201, "Session registered from token with metadata");
|
|
379
|
+
// List sessions — metadata should be present
|
|
380
|
+
const { data: d3 } = await req("GET", "/v1/browser-sessions", undefined, defaultKey);
|
|
381
|
+
const session = d3.sessions.find((s) => s.id === d2.browser_session_id);
|
|
382
|
+
assert(!!session, "Session appears in list");
|
|
383
|
+
assert(session.label === "Dr. Smith's browser", "Label propagated to session");
|
|
384
|
+
assert(session.external_user_id === "user_abc123", "external_user_id propagated to session");
|
|
385
|
+
}
|
|
386
|
+
async function testSessionMetadataOptional() {
|
|
387
|
+
console.log("\n--- Session metadata optional ---");
|
|
388
|
+
// Create pairing token WITHOUT metadata (backward compatibility)
|
|
389
|
+
const { status: s1, data: d1 } = await req("POST", "/v1/browser-sessions/pair", {}, defaultKey);
|
|
390
|
+
assert(s1 === 201, "Pairing token without metadata created");
|
|
391
|
+
// Register
|
|
392
|
+
const { data: d2 } = await req("POST", "/v1/browser-sessions/register", {
|
|
393
|
+
pairing_token: d1.pairing_token,
|
|
394
|
+
});
|
|
395
|
+
// List — null metadata is fine
|
|
396
|
+
const { data: d3 } = await req("GET", "/v1/browser-sessions", undefined, defaultKey);
|
|
397
|
+
const session = d3.sessions.find((s) => s.id === d2.browser_session_id);
|
|
398
|
+
assert(!!session, "Session without metadata appears in list");
|
|
399
|
+
assert(session.label === null || session.label === undefined, "Label is null when not provided");
|
|
400
|
+
assert(session.external_user_id === null || session.external_user_id === undefined, "external_user_id is null when not provided");
|
|
401
|
+
}
|
|
402
|
+
// --- Legacy Key Prefix Normalization ---
|
|
403
|
+
async function testLegacyKeyPrefixNormalization() {
|
|
404
|
+
console.log("\n--- Legacy key prefix normalization ---");
|
|
405
|
+
// Simulate a legacy key by injecting one without keyPrefix directly into the store
|
|
406
|
+
const { validateApiKey } = await import("./store.js");
|
|
407
|
+
const resolved = validateApiKey(defaultKey);
|
|
408
|
+
const store = await import("./store.js");
|
|
409
|
+
// The store's internal data is not directly accessible, but we can verify
|
|
410
|
+
// that all keys returned by the API have readable prefixes (not raw hashes).
|
|
411
|
+
const { status, data } = await req("GET", "/v1/api-keys", undefined, defaultKey);
|
|
412
|
+
assert(status === 200, "List API keys returns 200");
|
|
413
|
+
for (const k of data.api_keys) {
|
|
414
|
+
assert(k.key_prefix.startsWith("hic_live_") || k.key_prefix.startsWith("hic_live_***"), `Key prefix is readable: ${k.key_prefix.slice(0, 20)}`);
|
|
415
|
+
// Must never be a raw 64-char hex hash
|
|
416
|
+
assert(k.key_prefix.length < 40, `Key prefix is not a raw hash (length ${k.key_prefix.length})`);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
// --- Managed Task Execution (mock relay) ---
|
|
420
|
+
async function testManagedTaskExecution() {
|
|
421
|
+
console.log("\n--- Managed task execution (mock relay) ---");
|
|
422
|
+
// This test proves the full managed path:
|
|
423
|
+
// 1. Create pairing token → 2. Register session → 3. Create task → 4. Task completes
|
|
424
|
+
//
|
|
425
|
+
// Limitation: we cannot test real LLM execution or real browser tool execution
|
|
426
|
+
// locally without a running extension + relay + LLM. What we CAN test:
|
|
427
|
+
// - The happy path up to task creation with a connected session
|
|
428
|
+
// - The mock relay session connectivity check
|
|
429
|
+
//
|
|
430
|
+
// For this test, we need a session that reports as "connected" via the
|
|
431
|
+
// isSessionConnectedFn. The test setup uses () => false, so all sessions
|
|
432
|
+
// appear disconnected. We test the 409 → proves the guard works.
|
|
433
|
+
// A real end-to-end test requires: live relay + extension + LLM.
|
|
434
|
+
// Create and register a session
|
|
435
|
+
const { data: d1 } = await req("POST", "/v1/browser-sessions/pair", {
|
|
436
|
+
label: "e2e test browser",
|
|
437
|
+
external_user_id: "e2e-user-1",
|
|
438
|
+
}, defaultKey);
|
|
439
|
+
const { data: d2 } = await req("POST", "/v1/browser-sessions/register", {
|
|
440
|
+
pairing_token: d1.pairing_token,
|
|
441
|
+
});
|
|
442
|
+
assert(d2.browser_session_id, "Session registered for e2e test");
|
|
443
|
+
// Verify session appears as disconnected (mock returns false)
|
|
444
|
+
const { data: d3 } = await req("GET", "/v1/browser-sessions", undefined, defaultKey);
|
|
445
|
+
const session = d3.sessions.find((s) => s.id === d2.browser_session_id);
|
|
446
|
+
assert(!!session, "Session in list");
|
|
447
|
+
assert(session.status === "disconnected", "Session reports disconnected (mock relay)");
|
|
448
|
+
assert(session.label === "e2e test browser", "Session has label");
|
|
449
|
+
assert(session.external_user_id === "e2e-user-1", "Session has external_user_id");
|
|
450
|
+
// Task creation is correctly rejected because session is not connected
|
|
451
|
+
const { status: s4, data: d4 } = await req("POST", "/v1/tasks", {
|
|
452
|
+
task: "Read the current page title",
|
|
453
|
+
browser_session_id: d2.browser_session_id,
|
|
454
|
+
}, defaultKey);
|
|
455
|
+
assert(s4 === 409, "Task rejected — session not connected (mock)");
|
|
456
|
+
assert(d4.error.includes("not connected"), "Error explains why");
|
|
457
|
+
// This proves: auth → pairing → session ownership → connectivity guard all work.
|
|
458
|
+
// What remains unproven: actual LLM call + tool execution + result retrieval.
|
|
459
|
+
// That requires: live relay, live extension, live LLM — tested by integration.test.ts
|
|
460
|
+
// against production.
|
|
461
|
+
}
|
|
462
|
+
// --- Better Auth Session Cookie ---
|
|
463
|
+
// Better Auth session-cookie auth requires:
|
|
464
|
+
// 1. A running Postgres instance (Better Auth stores sessions in DB)
|
|
465
|
+
// 2. Google OAuth or email/password signup to create a user + session
|
|
466
|
+
// 3. The session cookie to be passed in requests
|
|
467
|
+
//
|
|
468
|
+
// This cannot be tested in the local file-store test harness because:
|
|
469
|
+
// - createAuth() returns null when DATABASE_URL is not set
|
|
470
|
+
// - resolveSessionToWorkspace() returns null when auth is not initialized
|
|
471
|
+
//
|
|
472
|
+
// What we CAN prove: that the authenticate() function falls through correctly
|
|
473
|
+
// when no session cookie is present, and that API key auth still works.
|
|
474
|
+
// The full session-cookie path is proven by integration.test.ts against production.
|
|
475
|
+
async function testSessionCookieAuthFallthrough() {
|
|
476
|
+
console.log("\n--- Session cookie auth fallthrough ---");
|
|
477
|
+
// Request with no auth at all — should get 401
|
|
478
|
+
const { status: s1 } = await req("GET", "/v1/api-keys");
|
|
479
|
+
assert(s1 === 401, "No auth → 401 (cookie auth not available without DB)");
|
|
480
|
+
// Request with a fake cookie — should still get 401 (no DB = no session resolution)
|
|
481
|
+
const res = await fetch(`http://localhost:${PORT}/v1/api-keys`, {
|
|
482
|
+
headers: {
|
|
483
|
+
"Content-Type": "application/json",
|
|
484
|
+
"Cookie": "better-auth.session_token=fake_session_token_123",
|
|
485
|
+
},
|
|
486
|
+
});
|
|
487
|
+
assert(res.status === 401, "Fake session cookie → 401 (no DB backing)");
|
|
488
|
+
// API key auth still works (not broken by cookie auth attempt)
|
|
489
|
+
const { status: s3 } = await req("GET", "/v1/api-keys", undefined, defaultKey);
|
|
490
|
+
assert(s3 === 200, "API key auth still works after cookie fallthrough");
|
|
491
|
+
}
|
|
492
|
+
// --- Stuck-Task Recovery ---
|
|
493
|
+
async function testStuckTaskRecovery() {
|
|
494
|
+
console.log("\n--- Stuck-task recovery ---");
|
|
495
|
+
// Create a task directly in the store with "running" status and an old createdAt
|
|
496
|
+
const { createTaskRun, getTaskRun, validateApiKey, listStuckTasks } = await import("./store.js");
|
|
497
|
+
const resolved = validateApiKey(defaultKey);
|
|
498
|
+
const task = createTaskRun({
|
|
499
|
+
workspaceId: resolved.workspaceId,
|
|
500
|
+
apiKeyId: "test",
|
|
501
|
+
task: "stuck task test",
|
|
502
|
+
browserSessionId: "test-session",
|
|
503
|
+
});
|
|
504
|
+
assert(task.status === "running", "Task starts as running");
|
|
505
|
+
// Manually backdate the task's createdAt to simulate a stuck task
|
|
506
|
+
const stored = getTaskRun(task.id);
|
|
507
|
+
stored.createdAt = Date.now() - 40 * 60 * 1000; // 40 minutes ago
|
|
508
|
+
// listStuckTasks should find it
|
|
509
|
+
const stuck = listStuckTasks(35 * 60 * 1000); // 35-minute threshold
|
|
510
|
+
assert(stuck.some(t => t.id === task.id), "listStuckTasks finds the old running task");
|
|
511
|
+
// Call recoverStuckTasks
|
|
512
|
+
const { recoverStuckTasks } = await import("./api.js");
|
|
513
|
+
await recoverStuckTasks();
|
|
514
|
+
// Task should now be marked as error
|
|
515
|
+
const recovered = getTaskRun(task.id);
|
|
516
|
+
assert(recovered.status === "error", "Stuck task marked as error after recovery");
|
|
517
|
+
assert(recovered.answer.includes("server restart"), "Answer mentions server restart");
|
|
518
|
+
assert(recovered.completedAt > 0, "completedAt is set");
|
|
519
|
+
}
|
|
520
|
+
// --- Request ID Header ---
|
|
521
|
+
async function testRequestIdHeader() {
|
|
522
|
+
console.log("\n--- Request ID in response headers ---");
|
|
523
|
+
// All responses should have X-Request-Id header
|
|
524
|
+
const res1 = await fetch(`http://localhost:${PORT}/v1/health`);
|
|
525
|
+
const rid1 = res1.headers.get("x-request-id");
|
|
526
|
+
assert(!!rid1, "Health response has X-Request-Id header");
|
|
527
|
+
assert(rid1.length === 8, "Request ID is 8 chars");
|
|
528
|
+
// Error responses should also have it
|
|
529
|
+
const res2 = await fetch(`http://localhost:${PORT}/v1/tasks`, {
|
|
530
|
+
method: "POST",
|
|
531
|
+
headers: { "Content-Type": "application/json" },
|
|
532
|
+
body: JSON.stringify({ task: "test" }),
|
|
533
|
+
});
|
|
534
|
+
const rid2 = res2.headers.get("x-request-id");
|
|
535
|
+
assert(!!rid2, "401 error response has X-Request-Id header");
|
|
536
|
+
assert(rid2 !== rid1, "Different requests get different IDs");
|
|
537
|
+
}
|
|
538
|
+
// --- Billing Store Functions ---
|
|
539
|
+
async function testBillingWorkspaceFields() {
|
|
540
|
+
console.log("\n--- Billing workspace fields ---");
|
|
541
|
+
const { createWorkspace, getWorkspace, updateWorkspaceBilling } = await import("./store.js");
|
|
542
|
+
// New workspace defaults to free plan
|
|
543
|
+
const ws = createWorkspace("Billing Test");
|
|
544
|
+
assert(ws.plan === "free", "New workspace defaults to free plan");
|
|
545
|
+
assert(ws.stripeCustomerId === undefined, "No Stripe customer by default");
|
|
546
|
+
assert(ws.subscriptionId === undefined, "No subscription by default");
|
|
547
|
+
assert(ws.subscriptionStatus === undefined, "No subscription status by default");
|
|
548
|
+
// Update billing fields
|
|
549
|
+
const updated = updateWorkspaceBilling(ws.id, {
|
|
550
|
+
stripeCustomerId: "cus_test123",
|
|
551
|
+
plan: "pro",
|
|
552
|
+
subscriptionId: "sub_test456",
|
|
553
|
+
subscriptionStatus: "active",
|
|
554
|
+
});
|
|
555
|
+
assert(updated !== null, "Update returns workspace");
|
|
556
|
+
assert(updated.stripeCustomerId === "cus_test123", "Stripe customer ID persisted");
|
|
557
|
+
assert(updated.plan === "pro", "Plan updated to pro");
|
|
558
|
+
assert(updated.subscriptionId === "sub_test456", "Subscription ID persisted");
|
|
559
|
+
assert(updated.subscriptionStatus === "active", "Subscription status persisted");
|
|
560
|
+
// Verify getWorkspace returns the updated fields
|
|
561
|
+
const fetched = getWorkspace(ws.id);
|
|
562
|
+
assert(fetched.plan === "pro", "getWorkspace reflects updated plan");
|
|
563
|
+
assert(fetched.stripeCustomerId === "cus_test123", "getWorkspace reflects customer ID");
|
|
564
|
+
// Simulate subscription cancellation
|
|
565
|
+
const cancelled = updateWorkspaceBilling(ws.id, {
|
|
566
|
+
plan: "free",
|
|
567
|
+
subscriptionStatus: "cancelled",
|
|
568
|
+
});
|
|
569
|
+
assert(cancelled.plan === "free", "Plan reverted to free");
|
|
570
|
+
assert(cancelled.subscriptionStatus === "cancelled", "Status set to cancelled");
|
|
571
|
+
assert(cancelled.stripeCustomerId === "cus_test123", "Customer ID preserved on cancel");
|
|
572
|
+
// Update nonexistent workspace returns null
|
|
573
|
+
const missing = updateWorkspaceBilling("nonexistent-id", { plan: "pro" });
|
|
574
|
+
assert(missing === null, "Update nonexistent workspace returns null");
|
|
575
|
+
}
|
|
576
|
+
async function testBillingCheckoutEndpoint() {
|
|
577
|
+
console.log("\n--- Billing checkout endpoint ---");
|
|
578
|
+
// Billing is not configured in test environment (no STRIPE_SECRET_KEY)
|
|
579
|
+
const { status, data } = await req("POST", "/v1/billing/checkout", {
|
|
580
|
+
email: "test@example.com",
|
|
581
|
+
}, defaultKey);
|
|
582
|
+
assert(status === 503, "Checkout returns 503 when billing not configured");
|
|
583
|
+
assert(data.error.includes("not configured"), "Error mentions billing not configured");
|
|
584
|
+
}
|
|
585
|
+
// --- Run ---
|
|
586
|
+
async function runAll() {
|
|
587
|
+
await setup();
|
|
588
|
+
console.log("=== HTTP API Tests ===");
|
|
589
|
+
await testHealthNoAuth();
|
|
590
|
+
await testAuthRequired();
|
|
591
|
+
await testBrowserSessionIdRequired();
|
|
592
|
+
await testPairingFlow();
|
|
593
|
+
await testSessionNotConnectedRejectsTask();
|
|
594
|
+
await testWrongWorkspaceSessionRejectsTask();
|
|
595
|
+
await testTaskOwnershipIsolation();
|
|
596
|
+
await testListTasksIsolation();
|
|
597
|
+
await testUsageIsolation();
|
|
598
|
+
await testInputValidation();
|
|
599
|
+
await testRequestBodyLimit();
|
|
600
|
+
await testRateLimiting();
|
|
601
|
+
await testCorsHeaders();
|
|
602
|
+
await testApiKeyCRUD();
|
|
603
|
+
await testApiKeyWorkspaceIsolation();
|
|
604
|
+
await testSessionMetadata();
|
|
605
|
+
await testSessionMetadataOptional();
|
|
606
|
+
await testLegacyKeyPrefixNormalization();
|
|
607
|
+
await testManagedTaskExecution();
|
|
608
|
+
await testSessionCookieAuthFallthrough();
|
|
609
|
+
await testStuckTaskRecovery();
|
|
610
|
+
await testRequestIdHeader();
|
|
611
|
+
await testBillingWorkspaceFields();
|
|
612
|
+
await testBillingCheckoutEndpoint();
|
|
613
|
+
console.log("\n=== All HTTP API tests passed ===\n");
|
|
614
|
+
process.exit(0);
|
|
615
|
+
}
|
|
616
|
+
// Only run when executed directly (not when imported by vitest)
|
|
617
|
+
const isDirectRun = !process.env.VITEST;
|
|
618
|
+
if (isDirectRun) {
|
|
619
|
+
runAll().catch((err) => {
|
|
620
|
+
console.error("\n❌ TEST FAILED:", err.message);
|
|
621
|
+
process.exit(1);
|
|
622
|
+
});
|
|
623
|
+
}
|