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.
332 lines
14 KiB
TypeScript
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 });
|
|
});
|