openpalm 0.9.7 → 0.9.9

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.
@@ -0,0 +1,429 @@
1
+ import { describe, expect, it, beforeEach, afterEach, mock } from "bun:test";
2
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { createSetupServer } from "./server.ts";
6
+ import type { CoreAssetProvider } from "@openpalm/lib";
7
+
8
+ // ── Helpers ──────────────────────────────────────────────────────────────
9
+
10
+ let tempBase: string;
11
+ let configDir: string;
12
+ let dataDir: string;
13
+ let stateDir: string;
14
+
15
+ const savedEnv: Record<string, string | undefined> = {};
16
+
17
+ function createStubAssetProvider(): CoreAssetProvider {
18
+ return {
19
+ coreCompose: () => "services:\n caddy:\n image: caddy:latest\n",
20
+ caddyfile: () =>
21
+ ":80 {\n @denied not remote_ip 127.0.0.0/8 ::1\n respond @denied 403\n}\n",
22
+ ollamaCompose: () => "services:\n ollama:\n image: ollama/ollama\n",
23
+ adminCompose: () => "services:\n admin:\n image: openpalm/admin\n",
24
+ agentsMd: () => "# Agents\n",
25
+ opencodeConfig: () => '{"$schema":"https://opencode.ai/config.json"}\n',
26
+ adminOpencodeConfig: () => '{"$schema":"https://opencode.ai/config.json","plugin":["@openpalm/admin-tools"]}\n',
27
+ secretsSchema: () => "ADMIN_TOKEN=string\n",
28
+ stackSchema: () => "OPENPALM_IMAGE_TAG=string\n",
29
+ cleanupLogs: () => "name: cleanup-logs\nschedule: daily\n",
30
+ cleanupData: () => "name: cleanup-data\nschedule: weekly\n",
31
+ validateConfig: () => "name: validate-config\nschedule: hourly\n",
32
+ };
33
+ }
34
+
35
+ function makeSetupDirs(): void {
36
+ tempBase = mkdtempSync(join(tmpdir(), "openpalm-server-err-test-"));
37
+ configDir = join(tempBase, "config");
38
+ dataDir = join(tempBase, "data");
39
+ stateDir = join(tempBase, "state");
40
+
41
+ for (const dir of [
42
+ configDir,
43
+ join(configDir, "channels"),
44
+ join(configDir, "connections"),
45
+ join(configDir, "assistant"),
46
+ join(configDir, "automations"),
47
+ join(configDir, "stash"),
48
+ dataDir,
49
+ join(dataDir, "admin"),
50
+ join(dataDir, "memory"),
51
+ join(dataDir, "assistant"),
52
+ join(dataDir, "guardian"),
53
+ join(dataDir, "caddy"),
54
+ join(dataDir, "caddy", "data"),
55
+ join(dataDir, "caddy", "config"),
56
+ join(dataDir, "automations"),
57
+ join(dataDir, "opencode"),
58
+ stateDir,
59
+ join(stateDir, "artifacts"),
60
+ join(stateDir, "audit"),
61
+ join(stateDir, "artifacts", "channels"),
62
+ join(stateDir, "automations"),
63
+ join(stateDir, "opencode"),
64
+ ]) {
65
+ mkdirSync(dir, { recursive: true });
66
+ }
67
+
68
+ writeFileSync(join(stateDir, "artifacts", "stack.env"), "OPENPALM_SETUP_COMPLETE=false\n");
69
+ writeFileSync(
70
+ join(configDir, "secrets.env"),
71
+ [
72
+ "# OpenPalm Secrets",
73
+ "export OPENPALM_ADMIN_TOKEN=",
74
+ "export ADMIN_TOKEN=",
75
+ "export OPENAI_API_KEY=",
76
+ "export OPENAI_BASE_URL=",
77
+ "export ANTHROPIC_API_KEY=",
78
+ "export GROQ_API_KEY=",
79
+ "export MISTRAL_API_KEY=",
80
+ "export GOOGLE_API_KEY=",
81
+ "export MEMORY_USER_ID=default_user",
82
+ "export MEMORY_AUTH_TOKEN=abc123",
83
+ "export OWNER_NAME=",
84
+ "export OWNER_EMAIL=",
85
+ "",
86
+ ].join("\n")
87
+ );
88
+ }
89
+
90
+ // Incrementing port counter to avoid conflicts
91
+ let nextPort = 19200;
92
+
93
+ describe("setup wizard server error scenarios", () => {
94
+ let serverPort: number;
95
+
96
+ beforeEach(() => {
97
+ makeSetupDirs();
98
+
99
+ savedEnv.OPENPALM_CONFIG_HOME = process.env.OPENPALM_CONFIG_HOME;
100
+ savedEnv.OPENPALM_DATA_HOME = process.env.OPENPALM_DATA_HOME;
101
+ savedEnv.OPENPALM_STATE_HOME = process.env.OPENPALM_STATE_HOME;
102
+ process.env.OPENPALM_CONFIG_HOME = configDir;
103
+ process.env.OPENPALM_DATA_HOME = dataDir;
104
+ process.env.OPENPALM_STATE_HOME = stateDir;
105
+
106
+ serverPort = nextPort++;
107
+ });
108
+
109
+ afterEach(() => {
110
+ process.env.OPENPALM_CONFIG_HOME = savedEnv.OPENPALM_CONFIG_HOME;
111
+ process.env.OPENPALM_DATA_HOME = savedEnv.OPENPALM_DATA_HOME;
112
+ process.env.OPENPALM_STATE_HOME = savedEnv.OPENPALM_STATE_HOME;
113
+ if (tempBase) rmSync(tempBase, { recursive: true, force: true });
114
+ });
115
+
116
+ // ── POST /api/setup/complete validation errors ────────────────────────
117
+
118
+ it("returns 400 when adminToken is missing", async () => {
119
+ const { stop } = createSetupServer(serverPort, {
120
+ assetProvider: createStubAssetProvider(),
121
+ configDir,
122
+ });
123
+
124
+ try {
125
+ const res = await fetch(`http://localhost:${serverPort}/api/setup/complete`, {
126
+ method: "POST",
127
+ headers: { "Content-Type": "application/json" },
128
+ body: JSON.stringify({
129
+ version: 1,
130
+ // no security.adminToken
131
+ memory: { userId: "user1" },
132
+ connections: [{ id: "c1", name: "C1", provider: "openai", baseUrl: "", apiKey: "sk-test" }],
133
+ assignments: {
134
+ llm: { connectionId: "c1", model: "gpt-4o" },
135
+ embeddings: { connectionId: "c1", model: "text-embedding-3-small" },
136
+ },
137
+ }),
138
+ });
139
+ expect(res.status).toBe(400);
140
+ const data = (await res.json()) as { ok: boolean; error: string };
141
+ expect(data.ok).toBe(false);
142
+ expect(data.error).toContain("security");
143
+ } finally {
144
+ stop();
145
+ }
146
+ });
147
+
148
+ it("returns 400 when connections array is empty", async () => {
149
+ const { stop } = createSetupServer(serverPort, {
150
+ assetProvider: createStubAssetProvider(),
151
+ configDir,
152
+ });
153
+
154
+ try {
155
+ const res = await fetch(`http://localhost:${serverPort}/api/setup/complete`, {
156
+ method: "POST",
157
+ headers: { "Content-Type": "application/json" },
158
+ body: JSON.stringify({
159
+ version: 1,
160
+ security: { adminToken: "valid-token-12345" },
161
+ memory: { userId: "user1" },
162
+ connections: [],
163
+ assignments: {
164
+ llm: { connectionId: "c1", model: "gpt-4o" },
165
+ embeddings: { connectionId: "c1", model: "text-embedding-3-small" },
166
+ },
167
+ }),
168
+ });
169
+ expect(res.status).toBe(400);
170
+ const data = (await res.json()) as { ok: boolean; error: string };
171
+ expect(data.ok).toBe(false);
172
+ expect(data.error).toContain("connections");
173
+ } finally {
174
+ stop();
175
+ }
176
+ });
177
+
178
+ it("returns 400 when assignments are missing", async () => {
179
+ const { stop } = createSetupServer(serverPort, {
180
+ assetProvider: createStubAssetProvider(),
181
+ configDir,
182
+ });
183
+
184
+ try {
185
+ const res = await fetch(`http://localhost:${serverPort}/api/setup/complete`, {
186
+ method: "POST",
187
+ headers: { "Content-Type": "application/json" },
188
+ body: JSON.stringify({
189
+ version: 1,
190
+ security: { adminToken: "valid-token-12345" },
191
+ memory: { userId: "user1" },
192
+ connections: [{ id: "c1", name: "C1", provider: "openai", baseUrl: "", apiKey: "sk-test" }],
193
+ // no assignments
194
+ }),
195
+ });
196
+ expect(res.status).toBe(400);
197
+ const data = (await res.json()) as { ok: boolean; error: string };
198
+ expect(data.ok).toBe(false);
199
+ expect(data.error).toContain("assignments");
200
+ } finally {
201
+ stop();
202
+ }
203
+ });
204
+
205
+ it("returns 400 when connection has invalid provider", async () => {
206
+ const { stop } = createSetupServer(serverPort, {
207
+ assetProvider: createStubAssetProvider(),
208
+ configDir,
209
+ });
210
+
211
+ try {
212
+ const res = await fetch(`http://localhost:${serverPort}/api/setup/complete`, {
213
+ method: "POST",
214
+ headers: { "Content-Type": "application/json" },
215
+ body: JSON.stringify({
216
+ version: 1,
217
+ security: { adminToken: "valid-token-12345" },
218
+ memory: { userId: "user1" },
219
+ connections: [{ id: "c1", name: "C1", provider: "fakeprovider", baseUrl: "", apiKey: "sk-test" }],
220
+ assignments: {
221
+ llm: { connectionId: "c1", model: "gpt-4o" },
222
+ embeddings: { connectionId: "c1", model: "text-embedding-3-small" },
223
+ },
224
+ }),
225
+ });
226
+ expect(res.status).toBe(400);
227
+ const data = (await res.json()) as { ok: boolean; error: string };
228
+ expect(data.ok).toBe(false);
229
+ expect(data.error).toContain("outside wizard scope");
230
+ } finally {
231
+ stop();
232
+ }
233
+ });
234
+
235
+ it("returns 400 when assignment references nonexistent connection", async () => {
236
+ const { stop } = createSetupServer(serverPort, {
237
+ assetProvider: createStubAssetProvider(),
238
+ configDir,
239
+ });
240
+
241
+ try {
242
+ const res = await fetch(`http://localhost:${serverPort}/api/setup/complete`, {
243
+ method: "POST",
244
+ headers: { "Content-Type": "application/json" },
245
+ body: JSON.stringify({
246
+ version: 1,
247
+ security: { adminToken: "valid-token-12345" },
248
+ memory: { userId: "user1" },
249
+ connections: [{ id: "c1", name: "C1", provider: "openai", baseUrl: "", apiKey: "sk-test" }],
250
+ assignments: {
251
+ llm: { connectionId: "nonexistent", model: "gpt-4o" },
252
+ embeddings: { connectionId: "c1", model: "text-embedding-3-small" },
253
+ },
254
+ }),
255
+ });
256
+ expect(res.status).toBe(400);
257
+ const data = (await res.json()) as { ok: boolean; error: string };
258
+ expect(data.ok).toBe(false);
259
+ expect(data.error).toContain("does not match any connection");
260
+ } finally {
261
+ stop();
262
+ }
263
+ });
264
+
265
+ // ── POST /api/setup/models/:provider errors ───────────────────────────
266
+
267
+ it("returns 400 for invalid JSON on model fetch", async () => {
268
+ const { stop } = createSetupServer(serverPort, {
269
+ assetProvider: createStubAssetProvider(),
270
+ configDir,
271
+ });
272
+
273
+ try {
274
+ const res = await fetch(`http://localhost:${serverPort}/api/setup/models/openai`, {
275
+ method: "POST",
276
+ headers: { "Content-Type": "application/json" },
277
+ body: "not-json",
278
+ });
279
+ expect(res.status).toBe(400);
280
+ const data = (await res.json()) as { ok: boolean; error: string };
281
+ expect(data.ok).toBe(false);
282
+ expect(data.error).toBe("invalid_json");
283
+ } finally {
284
+ stop();
285
+ }
286
+ });
287
+
288
+ it("returns empty model list when provider has no base URL", async () => {
289
+ const { stop } = createSetupServer(serverPort, {
290
+ assetProvider: createStubAssetProvider(),
291
+ configDir,
292
+ });
293
+
294
+ try {
295
+ // lmstudio without a base URL should return 502 with recoverable_error
296
+ const res = await fetch(`http://localhost:${serverPort}/api/setup/models/lmstudio`, {
297
+ method: "POST",
298
+ headers: { "Content-Type": "application/json" },
299
+ body: JSON.stringify({ apiKey: "", baseUrl: "" }),
300
+ });
301
+ expect(res.status).toBe(502);
302
+ const data = (await res.json()) as { ok: boolean; models: string[]; status: string; reason: string };
303
+ expect(data.ok).toBe(false);
304
+ expect(Array.isArray(data.models)).toBe(true);
305
+ expect(data.status).toBe("recoverable_error");
306
+ } finally {
307
+ stop();
308
+ }
309
+ });
310
+
311
+ it("returns recoverable error when model fetch hits unreachable server", async () => {
312
+ const { stop } = createSetupServer(serverPort, {
313
+ assetProvider: createStubAssetProvider(),
314
+ configDir,
315
+ });
316
+
317
+ try {
318
+ // Use a baseUrl that definitely will not connect — should return 502
319
+ const res = await fetch(`http://localhost:${serverPort}/api/setup/models/openai`, {
320
+ method: "POST",
321
+ headers: { "Content-Type": "application/json" },
322
+ body: JSON.stringify({ apiKey: "sk-fake", baseUrl: "http://127.0.0.1:1" }),
323
+ });
324
+ expect(res.status).toBe(502);
325
+ const data = (await res.json()) as {
326
+ ok: boolean;
327
+ models: string[];
328
+ status: string;
329
+ reason: string;
330
+ error?: string;
331
+ };
332
+ expect(data.ok).toBe(false);
333
+ expect(data.models).toEqual([]);
334
+ expect(data.status).toBe("recoverable_error");
335
+ expect(data.reason).toBe("network");
336
+ expect(data.error).toBeDefined();
337
+ } finally {
338
+ stop();
339
+ }
340
+ }, 10000);
341
+
342
+ it("returns static model list for anthropic (no network call needed)", async () => {
343
+ const { stop } = createSetupServer(serverPort, {
344
+ assetProvider: createStubAssetProvider(),
345
+ configDir,
346
+ });
347
+
348
+ try {
349
+ const res = await fetch(`http://localhost:${serverPort}/api/setup/models/anthropic`, {
350
+ method: "POST",
351
+ headers: { "Content-Type": "application/json" },
352
+ body: JSON.stringify({ apiKey: "sk-ant-test", baseUrl: "" }),
353
+ });
354
+ expect(res.status).toBe(200);
355
+ const data = (await res.json()) as { ok: boolean; models: string[]; status: string; reason: string };
356
+ expect(data.ok).toBe(true);
357
+ expect(data.models.length).toBeGreaterThan(0);
358
+ expect(data.status).toBe("ok");
359
+ expect(data.reason).toBe("provider_static");
360
+ } finally {
361
+ stop();
362
+ }
363
+ });
364
+
365
+ // ── Deploy status with error state ────────────────────────────────────
366
+
367
+ it("reports deploy error via deploy-status endpoint", async () => {
368
+ const { stop, updateDeployStatus, setDeployError } = createSetupServer(serverPort, {
369
+ assetProvider: createStubAssetProvider(),
370
+ configDir,
371
+ });
372
+
373
+ try {
374
+ updateDeployStatus([
375
+ { service: "caddy", status: "running", label: "Caddy" },
376
+ { service: "memory", status: "error", label: "Failed to pull" },
377
+ ]);
378
+ setDeployError("memory container failed to start");
379
+
380
+ const res = await fetch(`http://localhost:${serverPort}/api/setup/deploy-status`);
381
+ expect(res.status).toBe(200);
382
+ const data = (await res.json()) as {
383
+ ok: boolean;
384
+ deployStatus: Array<{ service: string; status: string; label: string }>;
385
+ deployError: string | null;
386
+ };
387
+ expect(data.ok).toBe(true);
388
+ expect(data.deployError).toBe("memory container failed to start");
389
+ const memEntry = data.deployStatus.find((e) => e.service === "memory");
390
+ expect(memEntry?.status).toBe("error");
391
+ } finally {
392
+ stop();
393
+ }
394
+ });
395
+
396
+ // ── HTTP method mismatches ────────────────────────────────────────────
397
+
398
+ it("returns 404 for GET on model endpoint (requires POST)", async () => {
399
+ const { stop } = createSetupServer(serverPort, {
400
+ assetProvider: createStubAssetProvider(),
401
+ configDir,
402
+ });
403
+
404
+ try {
405
+ const res = await fetch(`http://localhost:${serverPort}/api/setup/models/openai`);
406
+ expect(res.status).toBe(404);
407
+ const data = (await res.json()) as { ok: boolean; error: string };
408
+ expect(data.ok).toBe(false);
409
+ } finally {
410
+ stop();
411
+ }
412
+ });
413
+
414
+ it("returns 404 for GET on /api/setup/complete (requires POST)", async () => {
415
+ const { stop } = createSetupServer(serverPort, {
416
+ assetProvider: createStubAssetProvider(),
417
+ configDir,
418
+ });
419
+
420
+ try {
421
+ const res = await fetch(`http://localhost:${serverPort}/api/setup/complete`);
422
+ expect(res.status).toBe(404);
423
+ const data = (await res.json()) as { ok: boolean; error: string };
424
+ expect(data.ok).toBe(false);
425
+ } finally {
426
+ stop();
427
+ }
428
+ });
429
+ });