fabro/apps/fabro-web/app/install-api.test.ts
Bryan Helmkamp 2460ffc37a
feat(install): add sandbox provider step to web install wizard
Operators choose Docker (default, zero-config) or Daytona (validated
via Daytona SDK) during browser install. Selection is captured in
settings.toml under [run.sandbox] -- explicitly even for Docker, so the
choice is locked in. Daytona keys land in the vault as DAYTONA_API_KEY
(Environment secret). Step always runs after object_store and before
the LLM step.

Server adds POST /install/sandbox/test (validates Daytona key via
client.list) and PUT /install/sandbox; both reuse the install-token
auth and InstallSecret redaction patterns established by object-store.
A resolve_install_sandbox_state helper preserves a saved Daytona key
when the operator revisits the step without re-entering it. The
in-memory api_key is dropped from PendingInstall after finish, matching
the manual_credentials cleanup for S3 access keys.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 14:59:17 -07:00

140 lines
4.3 KiB
TypeScript

import { describe, expect, test } from "bun:test";
import {
putInstallObjectStore,
putInstallSandbox,
readInstallError,
testInstallObjectStore,
testInstallSandbox,
} from "./install-api";
describe("readInstallError", () => {
test("prefers the structured install error payload", async () => {
const response = new Response(
JSON.stringify({
errors: [{ status: "422", title: "Unprocessable Entity", detail: "invalid token" }],
}),
{
status: 422,
headers: { "Content-Type": "application/json" },
},
);
await expect(
readInstallError(response, "install request failed"),
).resolves.toBe("invalid token");
});
test("falls back to the provided message when the body is not structured JSON", async () => {
const response = new Response("boom", {
status: 500,
headers: { "Content-Type": "text/plain" },
});
await expect(
readInstallError(response, "install request failed"),
).resolves.toBe("install request failed (500)");
});
});
describe("install object-store requests", () => {
test("testInstallObjectStore posts the install payload to the validation endpoint", async () => {
const calls: Array<{ input: RequestInfo | URL; init?: RequestInit }> = [];
globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => {
calls.push({ input, init });
return Promise.resolve(new Response(JSON.stringify({ ok: true }), { status: 200 }));
}) as typeof fetch;
await testInstallObjectStore("test-install-token", {
provider: "s3",
bucket: "fabro-data",
region: "us-east-1",
credential_mode: "runtime",
});
expect(calls).toHaveLength(1);
expect(String(calls[0]!.input)).toBe("/install/object-store/test");
expect(calls[0]!.init?.method).toBe("POST");
expect(calls[0]!.init?.headers).toEqual({
Authorization: "Bearer test-install-token",
"Content-Type": "application/json",
});
expect(calls[0]!.init?.body).toBe(
JSON.stringify({
provider: "s3",
bucket: "fabro-data",
region: "us-east-1",
credential_mode: "runtime",
}),
);
});
test("putInstallObjectStore surfaces structured API errors", async () => {
globalThis.fetch = (() =>
Promise.resolve(
new Response(
JSON.stringify({
errors: [
{
status: "422",
title: "Unprocessable Entity",
detail: "Bucket is required.",
},
],
}),
{
status: 422,
headers: { "Content-Type": "application/json" },
},
),
)) as typeof fetch;
await expect(
putInstallObjectStore("test-install-token", { provider: "s3" }),
).rejects.toThrow("Bucket is required.");
});
});
describe("install sandbox requests", () => {
test("testInstallSandbox posts the install payload to the validation endpoint", async () => {
const calls: Array<{ input: RequestInfo | URL; init?: RequestInit }> = [];
globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => {
calls.push({ input, init });
return Promise.resolve(new Response(JSON.stringify({ ok: true }), { status: 200 }));
}) as typeof fetch;
await testInstallSandbox("test-install-token", {
provider: "daytona",
api_key: "dtn_test",
});
expect(calls).toHaveLength(1);
expect(String(calls[0]!.input)).toBe("/install/sandbox/test");
expect(calls[0]!.init?.method).toBe("POST");
expect(calls[0]!.init?.body).toBe(
JSON.stringify({ provider: "daytona", api_key: "dtn_test" }),
);
});
test("putInstallSandbox surfaces structured API errors", async () => {
globalThis.fetch = (() =>
Promise.resolve(
new Response(
JSON.stringify({
errors: [
{
status: "422",
title: "Unprocessable Entity",
detail: "api_key is required for daytona",
},
],
}),
{ status: 422, headers: { "Content-Type": "application/json" } },
),
)) as typeof fetch;
await expect(
putInstallSandbox("test-install-token", { provider: "daytona" }),
).rejects.toThrow("api_key is required for daytona");
});
});