Files
ThothII/frontend/src/api/client.test.ts
T
Codex 076c9742c5 feat: consolidate database management work
Add catalog-owned logical relationships and runtime snapshots, extend the database-management UI and validation coverage, and document the updated operational workflow.

Keep active sensitive-generation status in a tooltip and indicator, and update the layout E2E to follow the history action in its new database-scoped location.
2026-09-01 14:46:55 +02:00

332 lines
14 KiB
TypeScript

import { http, HttpResponse } from "msw";
import { server } from "../test/msw";
import { apiErrorMessage, apiFetch, ApiError, assertSameOriginRequestUrl } from "./client";
import { clearAuthState, setAuthState } from "../auth/authState";
const authenticated = {
issuer: "local",
subject: "user-1",
roles: ["user"] as const,
permissions: ["session.use"],
isAdmin: false,
csrfToken: "c".repeat(43),
session: null,
};
beforeEach(() => setAuthState(authenticated));
afterEach(() => clearAuthState());
test("body-less POST omits content-type (avoids Fastify empty-body 400)", async () => {
let contentType: string | null = "unset";
server.use(
http.post("/api/sessions/s1/resume", ({ request }) => {
contentType = request.headers.get("content-type");
return HttpResponse.json({ id: "s1" });
}),
);
await apiFetch("/sessions/s1/resume", { method: "POST" });
expect(contentType).toBeNull();
});
test("POST with a body sends application/json content-type", async () => {
let contentType: string | null = null;
server.use(
http.post("/api/sessions/s1/steer", ({ request }) => {
contentType = request.headers.get("content-type");
return new HttpResponse(null, { status: 204 });
}),
);
await apiFetch("/sessions/s1/steer", { method: "POST", body: JSON.stringify({ text: "hi" }) });
expect(contentType).toContain("application/json");
});
test("same-origin requests include credentials and overwrite the CSRF header from memory", async () => {
let observed: { credentials: string | null; csrf: string | null } | undefined;
const fetchSpy = vi.spyOn(globalThis, "fetch");
server.use(
http.post("/api/sessions/s1/steer", ({ request }) => {
observed = {
credentials: request.headers.get("credentials"),
csrf: request.headers.get("x-thothii-csrf"),
};
return new HttpResponse(null, { status: 204 });
}),
);
await apiFetch("/sessions/s1/steer", {
method: "POST",
headers: { "X-ThothII-CSRF": "attacker-supplied" },
body: JSON.stringify({ text: "hi" }),
});
expect(observed?.csrf).toBe("c".repeat(43));
expect(observed?.credentials).toBeNull();
expect(fetchSpy.mock.calls.at(-1)?.[1]).toMatchObject({ credentials: "same-origin" });
fetchSpy.mockRestore();
});
test("a 401 clears the in-memory auth state and advances its generation", async () => {
const before = (await import("../auth/authState")).getAuthGeneration();
server.use(http.get("/api/private", () => new HttpResponse(null, { status: 401 })));
await expect(apiFetch("/private")).rejects.toMatchObject({ status: 401 });
const state = await import("../auth/authState");
expect(state.getAuthState()).toBeNull();
expect(state.getAuthGeneration()).toBeGreaterThan(before);
});
test("a delayed 401 from user A cannot clear user B after a new login", async () => {
let release!: () => void;
const delayed = new Promise<void>((resolve) => { release = resolve; });
server.use(http.get("/api/stale-request", async () => {
await delayed;
return new HttpResponse(null, { status: 401 });
}));
const request = apiFetch("/stale-request");
const userB = { ...authenticated, subject: "user-b", csrfToken: "b".repeat(43) };
setAuthState(userB);
release();
await expect(request).rejects.toMatchObject({ status: 401 });
expect((await import("../auth/authState")).getAuthState()).toMatchObject({ subject: "user-b" });
});
test("bounds streamed error bodies and never exposes raw HTML or secrets", async () => {
const secret = "TOP-SECRET-token-123";
const hugeBody = `<html>${secret}${"x".repeat(20_000)}</html>`;
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode(hugeBody.slice(0, 4_000)));
controller.enqueue(new TextEncoder().encode(hugeBody.slice(4_000)));
controller.close();
},
}), { status: 500, headers: { "content-type": "text/html", "content-length": "1" } }),
);
try {
const result = await apiFetch<unknown>("/oversized").catch((error: unknown) => error);
expect(result).toBeInstanceOf(ApiError);
const failure = result as ApiError;
expect(failure.status).toBe(500);
expect(failure.message).not.toContain(secret);
expect(failure.message).not.toContain("<html>");
expect(failure.bodyText).not.toContain(secret);
expect(failure.payload).toBeUndefined();
} finally {
fetchSpy.mockRestore();
}
});
test("keeps only a known safe bounded JSON error payload", async () => {
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(JSON.stringify({
code: "dwh_unreachable",
error: "The data warehouse is unreachable.",
}), { status: 503, headers: { "content-type": "application/json" } }),
);
try {
const result = await apiFetch<unknown>("/known-error").catch((error: unknown) => error);
const failure = result as ApiError;
expect(failure).toMatchObject({
status: 503,
code: "dwh_unreachable",
payload: { code: "dwh_unreachable" },
});
expect(failure.bodyText).toBe("");
expect(failure.message).toBe("The database is unreachable. Please retry.");
} finally {
fetchSpy.mockRestore();
}
});
test.each([
["description_generation_target_ids_duplicate", "Description generation target IDs must be unique."],
["description_generation_no_eligible_targets", "No eligible catalog tables or columns need description generation."],
["catalog_table_not_found", "One or more selected catalog tables were not found."],
["sensitive_data_suggestion_invalid_response", "The model returned an incomplete or invalid classification. No suggestions were applied."],
["sensitive_data_suggestion_provider_unavailable", "The selected model could not complete the request. No suggestions were applied."],
["sensitive_data_suggestion_history_request_invalid", "Sensitive suggestion history parameters are invalid."],
["sensitive_data_suggestion_history_failed", "Sensitive suggestion history could not be loaded."],
["sensitive_data_suggestion_run_not_found", "The sensitive suggestion run was not found."],
["relationship_not_found", "The relationship no longer exists. Refresh and try again."],
["relationship_duplicate", "This relationship already exists."],
["relationship_target_not_unique", "The target column must be the only primary-key column of its table."],
["relationship_type_incompatible", "Source and target column types are not compatible."],
["relationship_read_only", "Physical relationships are read-only."],
["relationship_request_invalid", "The relationship request is invalid."],
["relationship_operation_failed", "The relationship operation failed."],
["relationship_schema_stale", "Synchronize the current database schema before managing logical relationships."],
["column_not_found", "The selected catalog column was not found. Refresh and try again."],
])("maps the catalog error code %s to safe local copy", async (code, message) => {
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(JSON.stringify({ code, message: "provider detail must not be trusted" }), {
status: 400,
headers: { "content-type": "application/json" },
}),
);
try {
const failure = await apiFetch<unknown>("/description-generation-error")
.catch((error: unknown) => error) as ApiError;
expect(failure).toMatchObject({ code, message, payload: { code } });
} finally {
fetchSpy.mockRestore();
}
});
test("derives local messages without retaining a malicious known-code message", async () => {
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(JSON.stringify({
code: "dwh_unreachable",
message: "Bearer eyJhbGciOiJIUzI1NiJ9.password=do-not-show",
error: "<html>password=do-not-show</html>",
}), { status: 503, headers: { "content-type": "application/json" } }),
);
try {
const failure = await apiFetch<unknown>("/known-malicious").catch((error: unknown) => error) as ApiError;
expect(failure.code).toBe("dwh_unreachable");
expect(failure.message).toBe("The database is unreachable. Please retry.");
expect(failure.bodyText).toBe("");
expect(failure.payload).toEqual({ code: "dwh_unreachable" });
expect(failure.message).not.toMatch(/Bearer|password|html|do-not-show/i);
} finally {
fetchSpy.mockRestore();
}
});
test("uses a generic local message for unknown malicious codes", async () => {
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(JSON.stringify({
code: "unknown-secret-code",
message: "Bearer eyJhbGciOiJIUzI1NiJ9",
error: "password=do-not-show",
}), { status: 500, headers: { "content-type": "application/json" } }),
);
try {
const failure = await apiFetch<unknown>("/unknown-malicious").catch((error: unknown) => error) as ApiError;
expect(failure.code).toBeUndefined();
expect(failure.message).toBe("Request failed. Please try again.");
expect(failure.bodyText).toBe("");
expect(failure.payload).toBeUndefined();
expect(apiErrorMessage(failure)).toBe("Request failed. Please try again.");
} finally {
fetchSpy.mockRestore();
}
});
test("releases the response reader after a successful bounded read", async () => {
const reader = {
read: vi.fn()
.mockResolvedValueOnce({ done: false, value: new TextEncoder().encode("{not-json") })
.mockResolvedValueOnce({ done: true, value: undefined }),
cancel: vi.fn().mockResolvedValue(undefined),
releaseLock: vi.fn(),
};
const response = new Response(new ReadableStream(), { status: 500 });
vi.spyOn(response.body!, "getReader").mockReturnValue(reader as unknown as ReadableStreamReader<Uint8Array<ArrayBuffer>>);
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(response);
try {
await expect(apiFetch("/reader-success")).rejects.toBeInstanceOf(ApiError);
expect(reader.cancel).not.toHaveBeenCalled();
expect(reader.releaseLock).toHaveBeenCalledOnce();
} finally {
fetchSpy.mockRestore();
}
});
test("cancels and releases the response reader on overflow", async () => {
const reader = {
read: vi.fn()
.mockResolvedValueOnce({ done: false, value: new Uint8Array(9 * 1024) }),
cancel: vi.fn().mockResolvedValue(undefined),
releaseLock: vi.fn(),
};
const response = new Response(new ReadableStream(), { status: 500 });
vi.spyOn(response.body!, "getReader").mockReturnValue(reader as unknown as ReadableStreamReader<Uint8Array<ArrayBuffer>>);
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(response);
try {
await expect(apiFetch("/reader-overflow")).rejects.toBeInstanceOf(ApiError);
expect(reader.cancel).toHaveBeenCalledOnce();
expect(reader.releaseLock).toHaveBeenCalledOnce();
} finally {
fetchSpy.mockRestore();
}
});
test("cancels and releases the response reader when a read throws", async () => {
const reader = {
read: vi.fn().mockRejectedValue(new Error("stream broke")),
cancel: vi.fn().mockResolvedValue(undefined),
releaseLock: vi.fn(),
};
const response = new Response(new ReadableStream(), { status: 500 });
vi.spyOn(response.body!, "getReader").mockReturnValue(reader as unknown as ReadableStreamReader<Uint8Array<ArrayBuffer>>);
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(response);
try {
await expect(apiFetch("/reader-throws")).rejects.toThrow("stream broke");
expect(reader.cancel).toHaveBeenCalledOnce();
expect(reader.releaseLock).toHaveBeenCalledOnce();
} finally {
fetchSpy.mockRestore();
}
});
test("rejects malformed and sensitive JSON error bodies without surfacing their content", async () => {
const bodies = [
"{not-json",
JSON.stringify({ code: "unknown_secret_code", error: "password=super-secret" }),
];
for (const body of bodies) {
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(body, { status: 500, headers: { "content-type": "application/json" } }),
);
try {
const result = await apiFetch<unknown>("/unsafe-error").catch((error: unknown) => error);
const failure = result as ApiError;
expect(failure.payload).toBeUndefined();
expect(failure.message).not.toContain("super-secret");
expect(failure.message).not.toContain("not-json");
} finally {
fetchSpy.mockRestore();
}
}
const knownCode = vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(JSON.stringify({ code: "auth_forbidden", error: "token=super-secret" }), {
status: 403, headers: { "content-type": "application/json" },
}),
);
try {
const result = await apiFetch<unknown>("/known-sensitive-error").catch((error: unknown) => error);
const failure = result as ApiError;
expect(failure.payload).toEqual({ code: "auth_forbidden" });
expect(failure.message).toBe("Access is not permitted.");
expect(failure.message).not.toContain("super-secret");
} finally {
knownCode.mockRestore();
}
});
test("refuses a cross-origin request before sending credentials", () => {
expect(() => assertSameOriginRequestUrl("https://attacker.example/api/me")).toThrow(/same-origin/i);
});
test("preserves explicit 403 and 503 statuses for presentation", async () => {
server.use(
http.get("/api/forbidden", () => HttpResponse.json({ code: "auth_forbidden" }, { status: 403 })),
http.get("/api/unavailable", () => HttpResponse.json({ code: "auth_unavailable" }, { status: 503 })),
);
await expect(apiFetch("/forbidden")).rejects.toBeInstanceOf(ApiError);
await expect(apiFetch("/unavailable")).rejects.toMatchObject({ status: 503 });
});