676 lines
20 KiB
TypeScript
676 lines
20 KiB
TypeScript
import { describe, it, expect, vi } from "vitest";
|
|
import { upgradeWebSocket } from "@hono/node-server";
|
|
import { createApp, type RunOperation } from "./app.js";
|
|
import { createBrowserOperationPolicy } from "./browser-operation-policy.js";
|
|
import { createPresentationRoomService } from "./presentation-sync/rooms.js";
|
|
import type { OperationExchange } from "@lda/workflow-rpc";
|
|
import * as fs from "node:fs";
|
|
import * as os from "node:os";
|
|
import * as path from "node:path";
|
|
|
|
const makeExchange = (
|
|
overrides: Partial<OperationExchange> = {},
|
|
): OperationExchange => ({
|
|
operation: "workflow.health",
|
|
target: "http://127.0.0.1:8765/rpc",
|
|
label: "Health check",
|
|
interpreted: { status: "ok", storeRoot: "/tmp/store" },
|
|
exchange: { request: {}, response: { status: "ok" } },
|
|
equivalentCli: "uv run wf status",
|
|
durationMs: 12,
|
|
...overrides,
|
|
});
|
|
|
|
const okRunner: RunOperation = vi.fn(async (operation) =>
|
|
makeExchange({ operation, target: "http://127.0.0.1:8765/rpc" }),
|
|
);
|
|
|
|
const failRunner =
|
|
(code: string, message: string): RunOperation =>
|
|
async () => {
|
|
throw Object.assign(new Error(message), {
|
|
_tag: code,
|
|
exchange: { request: { method: "x" }, response: { error: message } },
|
|
});
|
|
};
|
|
|
|
type AppDependencies = Parameters<typeof createApp>[0];
|
|
|
|
const disabledBrowserOperationPolicy = createBrowserOperationPolicy({
|
|
enableCapabilityCalls: false,
|
|
});
|
|
|
|
const makeApp = (
|
|
dependencies: Omit<AppDependencies, "presentationSync" | "browserOperationPolicy"> &
|
|
Partial<Pick<AppDependencies, "browserOperationPolicy">>,
|
|
) =>
|
|
createApp({
|
|
...dependencies,
|
|
browserOperationPolicy:
|
|
dependencies.browserOperationPolicy ?? disabledBrowserOperationPolicy,
|
|
presentationSync: {
|
|
rooms: createPresentationRoomService(),
|
|
upgradeWebSocket,
|
|
},
|
|
});
|
|
|
|
const app = makeApp({ runOperation: okRunner });
|
|
|
|
const validConsoleHeaders = {
|
|
"content-type": "application/json",
|
|
"x-workflow-console": "1",
|
|
} as const;
|
|
|
|
describe("GET /api/health", () => {
|
|
it("returns 200 with ok status", async () => {
|
|
const res = await app.request("/api/health");
|
|
expect(res.status).toBe(200);
|
|
const body = await res.json();
|
|
expect(body).toEqual({ ok: true, status: "ok" });
|
|
});
|
|
});
|
|
|
|
describe("POST /api/connect", () => {
|
|
it("calls workflow.health and returns connected DTO", async () => {
|
|
const res = await app.request("/api/connect", {
|
|
method: "POST",
|
|
headers: validConsoleHeaders,
|
|
body: JSON.stringify({ target: "http://127.0.0.1:8000/rpc" }),
|
|
});
|
|
expect(res.status).toBe(200);
|
|
const body = await res.json();
|
|
expect(body.ok).toBe(true);
|
|
expect(body.connection.status).toBe("connected");
|
|
expect(body.connection.target).toBe("http://127.0.0.1:8765/rpc");
|
|
expect(body.connection.serverStatus).toBe("ok");
|
|
expect(body.connection.storeRoot).toBe("/tmp/store");
|
|
expect(okRunner).toHaveBeenCalledWith(
|
|
"workflow.health",
|
|
"http://127.0.0.1:8000/rpc",
|
|
{},
|
|
);
|
|
});
|
|
|
|
it("returns 400 when target is missing", async () => {
|
|
const res = await app.request("/api/connect", {
|
|
method: "POST",
|
|
headers: validConsoleHeaders,
|
|
body: JSON.stringify({}),
|
|
});
|
|
expect(res.status).toBe(400);
|
|
const body = await res.json();
|
|
expect(body.ok).toBe(false);
|
|
expect(body.error.code).toBe("invalid_target");
|
|
});
|
|
|
|
it("returns a decode error when health interpretation is malformed", async () => {
|
|
const malformedApp = makeApp({
|
|
runOperation: async () => makeExchange({ interpreted: { status: "ok" } }),
|
|
});
|
|
const res = await malformedApp.request("/api/connect", {
|
|
method: "POST",
|
|
headers: validConsoleHeaders,
|
|
body: JSON.stringify({ target: "http://127.0.0.1:8000/rpc" }),
|
|
});
|
|
expect(res.status).toBe(502);
|
|
const body = await res.json();
|
|
expect(body.ok).toBe(false);
|
|
expect(body.error.code).toBe("rpc_decode_error");
|
|
});
|
|
});
|
|
|
|
describe("console POST request boundary", () => {
|
|
const routes = [
|
|
{
|
|
path: "/api/connect",
|
|
body: { target: "http://127.0.0.1:8000/rpc" },
|
|
},
|
|
{
|
|
path: "/api/rpc",
|
|
body: {
|
|
operation: "workflow.health",
|
|
target: "http://127.0.0.1:8000/rpc",
|
|
params: {},
|
|
},
|
|
},
|
|
] as const;
|
|
|
|
it.each(routes)("rejects text/plain at $path before invoking the runner", async ({
|
|
path: requestPath,
|
|
body,
|
|
}) => {
|
|
const runOperation = vi.fn<RunOperation>(async (operation) =>
|
|
makeExchange({ operation }),
|
|
);
|
|
const guardedApp = makeApp({ runOperation });
|
|
|
|
const res = await guardedApp.request(requestPath, {
|
|
method: "POST",
|
|
headers: {
|
|
"content-type": "text/plain",
|
|
"x-workflow-console": "1",
|
|
},
|
|
body: JSON.stringify(body),
|
|
});
|
|
|
|
expect(res.status).toBe(415);
|
|
expect(runOperation).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it.each(routes)("rejects a missing console header at $path before invoking the runner", async ({
|
|
path: requestPath,
|
|
body,
|
|
}) => {
|
|
const runOperation = vi.fn<RunOperation>(async (operation) =>
|
|
makeExchange({ operation }),
|
|
);
|
|
const guardedApp = makeApp({ runOperation });
|
|
|
|
const res = await guardedApp.request(requestPath, {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify(body),
|
|
});
|
|
|
|
expect(res.status).toBe(403);
|
|
expect(runOperation).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
label: "foreign Origin",
|
|
headers: { ...validConsoleHeaders, origin: "https://foreign.example" },
|
|
},
|
|
{
|
|
label: "foreign Sec-Fetch-Site",
|
|
headers: { ...validConsoleHeaders, "sec-fetch-site": "cross-site" },
|
|
},
|
|
])("rejects $label before either runner is invoked", async ({ headers }) => {
|
|
const runOperation = vi.fn<RunOperation>(async (operation) =>
|
|
makeExchange({ operation }),
|
|
);
|
|
const guardedApp = makeApp({ runOperation });
|
|
|
|
for (const route of routes) {
|
|
const res = await guardedApp.request(route.path, {
|
|
method: "POST",
|
|
headers,
|
|
body: JSON.stringify(route.body),
|
|
});
|
|
expect(res.status).toBe(403);
|
|
}
|
|
|
|
expect(runOperation).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it.each(routes)("allows a same-origin console request through $path", async ({
|
|
path: requestPath,
|
|
body,
|
|
}) => {
|
|
const runOperation = vi.fn<RunOperation>(async (operation) =>
|
|
makeExchange({ operation }),
|
|
);
|
|
const guardedApp = makeApp({ runOperation });
|
|
|
|
const res = await guardedApp.request(`http://localhost:5173${requestPath}`, {
|
|
method: "POST",
|
|
headers: {
|
|
...validConsoleHeaders,
|
|
origin: "http://localhost:5173",
|
|
"sec-fetch-site": "same-origin",
|
|
},
|
|
body: JSON.stringify(body),
|
|
});
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(runOperation).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("allows an exact configured origin behind a TLS-terminating proxy", async () => {
|
|
const runOperation = vi.fn<RunOperation>(async (operation) => makeExchange({ operation }));
|
|
const guardedApp = makeApp({
|
|
runOperation,
|
|
trustedOrigins: new Set(["https://console.example"]),
|
|
});
|
|
|
|
const res = await guardedApp.request("http://internal:8787/api/connect", {
|
|
method: "POST",
|
|
headers: {
|
|
...validConsoleHeaders,
|
|
origin: "https://console.example",
|
|
"sec-fetch-site": "same-origin",
|
|
},
|
|
body: JSON.stringify({ target: "http://127.0.0.1:8765/rpc" }),
|
|
});
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(runOperation).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
describe("POST /api/rpc", () => {
|
|
it.each([
|
|
{ operation: "workflow.capabilities.list", params: {} },
|
|
{
|
|
operation: "workflow.capabilities.inspect",
|
|
params: { qualified_name: "workflow.health" },
|
|
},
|
|
{ operation: "workflow.draft_workspaces.list", params: {} },
|
|
{
|
|
operation: "workflow.draft_workspaces.get",
|
|
params: { workspace_id: "draft-1" },
|
|
},
|
|
{
|
|
operation: "workflow.draft_workspaces.inspect_authoring_contract",
|
|
params: { workspace_id: "draft-1", revision: 1 },
|
|
},
|
|
{
|
|
operation: "workflow.draft_workspaces.create_empty",
|
|
params: { workspace_id: "draft-1", name: "draft.workflow" },
|
|
},
|
|
{
|
|
operation: "workflow.draft_workspaces.create_from_capability",
|
|
params: {
|
|
workspace_id: "draft-1",
|
|
capability_name: "local.example.echo",
|
|
},
|
|
},
|
|
{
|
|
operation: "workflow.draft_workspaces.add_step_from_capability",
|
|
params: {
|
|
workspace_id: "draft-1",
|
|
revision: 1,
|
|
step_id: "echo",
|
|
capability_name: "local.example.echo",
|
|
},
|
|
},
|
|
{
|
|
operation: "workflow.draft_workspaces.update_capability_step",
|
|
params: {
|
|
workspace_id: "draft-1",
|
|
revision: 1,
|
|
step_id: "echo",
|
|
update: { retry: 2 },
|
|
},
|
|
},
|
|
{
|
|
operation: "workflow.draft_workspaces.set_route",
|
|
params: {
|
|
workspace_id: "draft-1",
|
|
revision: 1,
|
|
step_id: "echo",
|
|
outcome: "ok",
|
|
target: "__end__",
|
|
},
|
|
},
|
|
{
|
|
operation: "workflow.draft_workspaces.set_contract",
|
|
params: { workspace_id: "draft-1", revision: 1, outcomes: ["ok"] },
|
|
},
|
|
{
|
|
operation: "workflow.draft_workspaces.set_start",
|
|
params: { workspace_id: "draft-1", revision: 1, step_id: "echo" },
|
|
},
|
|
{
|
|
operation: "workflow.draft_workspaces.set_step_input_bindings",
|
|
params: {
|
|
workspace_id: "draft-1",
|
|
revision: 1,
|
|
step_id: "echo",
|
|
bindings: [{ target: "format", value: "markdown" }],
|
|
},
|
|
},
|
|
{
|
|
operation: "workflow.draft_workspaces.set_step_output_bindings",
|
|
params: {
|
|
workspace_id: "draft-1",
|
|
revision: 1,
|
|
step_id: "echo",
|
|
bindings: [{ source: "result", target: "state.result" }],
|
|
},
|
|
},
|
|
{
|
|
operation: "workflow.draft_workspaces.validate",
|
|
params: { workspace_id: "draft-1" },
|
|
},
|
|
{
|
|
operation: "workflow.draft_workspaces.set_workflow_output_bindings",
|
|
params: {
|
|
workspace_id: "draft-1",
|
|
revision: 1,
|
|
bindings: [{ source: "result", target: "state.result" }],
|
|
},
|
|
},
|
|
{ operation: "workflow.runs.start", params: {} },
|
|
{ operation: "workflow.runs.resume", params: {} },
|
|
] as const)("authorizes the allowlisted operation $operation", async ({
|
|
operation,
|
|
params,
|
|
}) => {
|
|
const res = await app.request("/api/rpc", {
|
|
method: "POST",
|
|
headers: validConsoleHeaders,
|
|
body: JSON.stringify({
|
|
operation,
|
|
target: "http://127.0.0.1:8000/rpc",
|
|
params,
|
|
}),
|
|
});
|
|
|
|
expect(res.status).toBe(200);
|
|
const body = await res.json();
|
|
expect(body.ok).toBe(true);
|
|
expect(body.operation).toBe(operation);
|
|
expect(okRunner).toHaveBeenCalledWith(
|
|
operation,
|
|
"http://127.0.0.1:8000/rpc",
|
|
params,
|
|
);
|
|
});
|
|
|
|
it("invokes the requested operation", async () => {
|
|
const res = await app.request("/api/rpc", {
|
|
method: "POST",
|
|
headers: validConsoleHeaders,
|
|
body: JSON.stringify({
|
|
operation: "workflow.sources.list",
|
|
target: "http://127.0.0.1:8000/rpc",
|
|
params: { limit: 10 },
|
|
}),
|
|
});
|
|
expect(res.status).toBe(200);
|
|
const body = await res.json();
|
|
expect(body.ok).toBe(true);
|
|
expect(body.operation).toBe("workflow.sources.list");
|
|
});
|
|
|
|
it("returns a distinct 403 when capability calls are disabled", async () => {
|
|
const runOperation = vi.fn<RunOperation>();
|
|
const disabledApp = makeApp({ runOperation });
|
|
const res = await disabledApp.request("/api/rpc", {
|
|
method: "POST",
|
|
headers: validConsoleHeaders,
|
|
body: JSON.stringify({
|
|
operation: "workflow.capabilities.call",
|
|
target: "http://127.0.0.1:8000/rpc",
|
|
params: { capability_name: "local.example.echo", input: {} },
|
|
}),
|
|
});
|
|
|
|
expect(res.status).toBe(403);
|
|
expect(await res.json()).toEqual({
|
|
ok: false,
|
|
error: {
|
|
code: "operation_disabled",
|
|
message:
|
|
"workflow.capabilities.call is disabled for this console server",
|
|
},
|
|
exchange: { request: null, response: null },
|
|
});
|
|
expect(runOperation).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("invokes capability calls when the injected policy enables them", async () => {
|
|
const runOperation: RunOperation = vi.fn(async (operation) =>
|
|
makeExchange({ operation }),
|
|
);
|
|
const enabledApp = makeApp({
|
|
runOperation,
|
|
browserOperationPolicy: createBrowserOperationPolicy({
|
|
enableCapabilityCalls: true,
|
|
}),
|
|
});
|
|
const params = { capability_name: "local.example.echo", input: {} };
|
|
const res = await enabledApp.request("/api/rpc", {
|
|
method: "POST",
|
|
headers: validConsoleHeaders,
|
|
body: JSON.stringify({
|
|
operation: "workflow.capabilities.call",
|
|
target: "http://127.0.0.1:8000/rpc",
|
|
params,
|
|
}),
|
|
});
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(runOperation).toHaveBeenCalledWith(
|
|
"workflow.capabilities.call",
|
|
"http://127.0.0.1:8000/rpc",
|
|
params,
|
|
);
|
|
});
|
|
|
|
it("returns 400 for unknown operation", async () => {
|
|
const runOperation = vi.fn<RunOperation>();
|
|
const rejectedApp = makeApp({ runOperation });
|
|
const res = await rejectedApp.request("/api/rpc", {
|
|
method: "POST",
|
|
headers: validConsoleHeaders,
|
|
body: JSON.stringify({
|
|
operation: "foo.bar",
|
|
target: "http://127.0.0.1:8000/rpc",
|
|
}),
|
|
});
|
|
expect(res.status).toBe(400);
|
|
const body = await res.json();
|
|
expect(body.ok).toBe(false);
|
|
expect(body.error.code).toBe("unknown_operation");
|
|
expect(runOperation).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not authorize generated operations outside the console boundary", async () => {
|
|
const res = await app.request("/api/rpc", {
|
|
method: "POST",
|
|
headers: validConsoleHeaders,
|
|
body: JSON.stringify({
|
|
operation: "workflow.admin.auth.list",
|
|
target: "http://127.0.0.1:8000/rpc",
|
|
}),
|
|
});
|
|
|
|
expect(res.status).toBe(400);
|
|
const body = await res.json();
|
|
expect(body.error.code).toBe("unknown_operation");
|
|
});
|
|
|
|
it("does not authorize generic draft workspace mutations", async () => {
|
|
const res = await app.request("/api/rpc", {
|
|
method: "POST",
|
|
headers: validConsoleHeaders,
|
|
body: JSON.stringify({
|
|
operation: "workflow.draft_workspaces.replace_document",
|
|
target: "http://127.0.0.1:8000/rpc",
|
|
}),
|
|
});
|
|
|
|
expect(res.status).toBe(400);
|
|
const body = await res.json();
|
|
expect(body.error.code).toBe("unknown_operation");
|
|
});
|
|
|
|
it.each(["workflow.draft_workspaces.patch", "workflow.draft_workspaces.replace_document"])(
|
|
"does not authorize adjacent generic operation %s",
|
|
async (operation) => {
|
|
const res = await app.request("/api/rpc", {
|
|
method: "POST",
|
|
headers: validConsoleHeaders,
|
|
body: JSON.stringify({
|
|
operation,
|
|
target: "http://127.0.0.1:8000/rpc",
|
|
}),
|
|
});
|
|
|
|
expect(res.status).toBe(400);
|
|
const body = await res.json();
|
|
expect(body.error.code).toBe("unknown_operation");
|
|
},
|
|
);
|
|
|
|
it("returns 400 for invalid JSON body", async () => {
|
|
const res = await app.request("/api/rpc", {
|
|
method: "POST",
|
|
headers: validConsoleHeaders,
|
|
body: "not json",
|
|
});
|
|
expect(res.status).toBe(400);
|
|
});
|
|
});
|
|
|
|
describe("POST body size limit", () => {
|
|
it("returns 413 when body exceeds 256 KiB", async () => {
|
|
const bigBody = JSON.stringify({ data: "x".repeat(257 * 1024) });
|
|
const res = await app.request("/api/rpc", {
|
|
method: "POST",
|
|
headers: validConsoleHeaders,
|
|
body: bigBody,
|
|
});
|
|
expect(res.status).toBe(413);
|
|
});
|
|
});
|
|
|
|
describe("error mapping", () => {
|
|
it("maps upstream timeout to 504", async () => {
|
|
const timeoutApp = makeApp({
|
|
runOperation: failRunner("UpstreamTimeoutError", "timed out"),
|
|
});
|
|
const res = await timeoutApp.request("/api/rpc", {
|
|
method: "POST",
|
|
headers: validConsoleHeaders,
|
|
body: JSON.stringify({
|
|
operation: "workflow.health",
|
|
target: "http://127.0.0.1:8000/rpc",
|
|
}),
|
|
});
|
|
expect(res.status).toBe(504);
|
|
const body = await res.json();
|
|
expect(body.ok).toBe(false);
|
|
expect(body.error.code).toBe("upstream_timeout");
|
|
expect(body.exchange).toEqual({
|
|
request: { method: "x" },
|
|
response: { error: "timed out" },
|
|
});
|
|
expect(body.error.stack).toBeUndefined();
|
|
});
|
|
|
|
it("maps upstream connection error to 502", async () => {
|
|
const connApp = makeApp({
|
|
runOperation: failRunner("UpstreamConnectionError", "connection refused"),
|
|
});
|
|
const res = await connApp.request("/api/rpc", {
|
|
method: "POST",
|
|
headers: validConsoleHeaders,
|
|
body: JSON.stringify({
|
|
operation: "workflow.health",
|
|
target: "http://127.0.0.1:8000/rpc",
|
|
}),
|
|
});
|
|
expect(res.status).toBe(502);
|
|
const body = await res.json();
|
|
expect(body.ok).toBe(false);
|
|
expect(body.error.code).toBe("upstream_unreachable");
|
|
expect(body.error.stack).toBeUndefined();
|
|
});
|
|
|
|
it("maps RpcRemoteError to 502 with rpc_remote_error", async () => {
|
|
const remoteApp = makeApp({
|
|
runOperation: failRunner("RpcRemoteError", "method not found"),
|
|
});
|
|
const res = await remoteApp.request("/api/rpc", {
|
|
method: "POST",
|
|
headers: validConsoleHeaders,
|
|
body: JSON.stringify({
|
|
operation: "workflow.health",
|
|
target: "http://127.0.0.1:8000/rpc",
|
|
}),
|
|
});
|
|
expect(res.status).toBe(502);
|
|
const body = await res.json();
|
|
expect(body.ok).toBe(false);
|
|
expect(body.error.code).toBe("rpc_remote_error");
|
|
expect(body.error.stack).toBeUndefined();
|
|
});
|
|
|
|
it("maps InvalidTargetError to 400", async () => {
|
|
const invalidApp = makeApp({
|
|
runOperation: failRunner("InvalidTargetError", "bad target"),
|
|
});
|
|
const res = await invalidApp.request("/api/rpc", {
|
|
method: "POST",
|
|
headers: validConsoleHeaders,
|
|
body: JSON.stringify({
|
|
operation: "workflow.health",
|
|
target: "not-a-url",
|
|
}),
|
|
});
|
|
expect(res.status).toBe(400);
|
|
const body = await res.json();
|
|
expect(body.ok).toBe(false);
|
|
expect(body.error.code).toBe("invalid_target");
|
|
expect(body.error.stack).toBeUndefined();
|
|
});
|
|
|
|
it("maps UnknownOperationError to 400", async () => {
|
|
const unknownApp = makeApp({
|
|
runOperation: failRunner("UnknownOperationError", "no such op"),
|
|
});
|
|
const res = await unknownApp.request("/api/rpc", {
|
|
method: "POST",
|
|
headers: validConsoleHeaders,
|
|
body: JSON.stringify({
|
|
operation: "workflow.health",
|
|
target: "http://127.0.0.1:8000/rpc",
|
|
}),
|
|
});
|
|
expect(res.status).toBe(400);
|
|
const body = await res.json();
|
|
expect(body.ok).toBe(false);
|
|
expect(body.error.code).toBe("unknown_operation");
|
|
});
|
|
|
|
it("never includes stack in error DTOs", async () => {
|
|
const apps = [
|
|
makeApp({ runOperation: failRunner("UpstreamTimeoutError", "t") }),
|
|
makeApp({ runOperation: failRunner("UpstreamConnectionError", "c") }),
|
|
makeApp({ runOperation: failRunner("RpcRemoteError", "r") }),
|
|
makeApp({ runOperation: failRunner("InvalidTargetError", "i") }),
|
|
];
|
|
for (const a of apps) {
|
|
const res = await a.request("/api/rpc", {
|
|
method: "POST",
|
|
headers: validConsoleHeaders,
|
|
body: JSON.stringify({
|
|
operation: "workflow.health",
|
|
target: "http://127.0.0.1:8000/rpc",
|
|
}),
|
|
});
|
|
const body = await res.json();
|
|
expect(body.error?.stack).toBeUndefined();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("static console routes", () => {
|
|
it("serves the SPA and keeps unknown API paths as JSON 404", async () => {
|
|
const consoleRoot = fs.mkdtempSync(path.join(os.tmpdir(), "wf-console-"));
|
|
fs.mkdirSync(path.join(consoleRoot, "assets"));
|
|
fs.writeFileSync(path.join(consoleRoot, "index.html"), "<main>console</main>");
|
|
fs.writeFileSync(path.join(consoleRoot, "assets", "app.js"), "console.log('ok')");
|
|
try {
|
|
const staticApp = makeApp({ runOperation: okRunner, consoleRoot });
|
|
|
|
const index = await staticApp.request("/workflows");
|
|
expect(index.status).toBe(200);
|
|
expect(await index.text()).toContain("console");
|
|
|
|
const asset = await staticApp.request("/assets/app.js");
|
|
expect(asset.status).toBe(200);
|
|
expect(await asset.text()).toContain("ok");
|
|
|
|
const unknownApi = await staticApp.request("/api/nope");
|
|
expect(unknownApi.status).toBe(404);
|
|
expect(await unknownApi.json()).toEqual({ error: "not found" });
|
|
} finally {
|
|
fs.rmSync(consoleRoot, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|