import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { loadAllAppCss } from "../../test/cssFixture";
import { SecretsView } from "../SecretsView";
type JsonResponse = {
ok: boolean;
status?: number;
body: unknown;
};
function mockJsonResponse({ ok, status = ok ? 200 : 400, body }: JsonResponse): Response {
return {
ok,
status,
json: vi.fn().mockResolvedValue(body),
} as unknown as Response;
}
function installAllCss() {
const style = document.createElement("style");
style.setAttribute("data-test-all-app-css", "true");
style.textContent = loadAllAppCss();
document.head.appendChild(style);
}
function removeAllCss() {
document.head.querySelector('[data-test-all-app-css="true"]')?.remove();
}
function expectVisibleActionIcon(button: HTMLElement) {
const svg = button.querySelector("svg");
expect(svg).not.toBeNull();
const svgStyle = getComputedStyle(svg as SVGElement);
const buttonStyle = getComputedStyle(button);
expect(svg).toHaveClass("secrets-action-icon");
expect(Number.parseFloat(svgStyle.width)).toBeGreaterThan(0);
expect(Number.parseFloat(svgStyle.height)).toBeGreaterThan(0);
expect(svgStyle.display).toBe("block");
expect(svgStyle.stroke.toLowerCase()).not.toBe("none");
expect(svgStyle.stroke).not.toBe(buttonStyle.backgroundColor);
}
describe("SecretsView", () => {
beforeEach(() => {
vi.clearAllMocks();
document.documentElement.dataset.theme = "dark";
installAllCss();
});
afterEach(() => {
removeAllCss();
delete document.documentElement.dataset.theme;
});
it("renders Not configured status", async () => {
vi.stubGlobal(
"fetch",
vi
.fn()
.mockResolvedValueOnce(mockJsonResponse({ ok: true, body: { secrets: [] } }))
.mockResolvedValueOnce(mockJsonResponse({ ok: true, body: { configured: false } })),
);
render();
expect(await screen.findByText("Not configured")).toBeInTheDocument();
});
it("renders Configured status and clear button", async () => {
vi.stubGlobal(
"fetch",
vi
.fn()
.mockResolvedValueOnce(mockJsonResponse({ ok: true, body: { secrets: [] } }))
.mockResolvedValueOnce(mockJsonResponse({ ok: true, body: { configured: true } })),
);
render();
expect(await screen.findByText("Configured")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Clear" })).toBeInTheDocument();
});
it("FN-5222: does not render a docs link in the cross-node sync card", async () => {
vi.stubGlobal(
"fetch",
vi
.fn()
.mockResolvedValueOnce(mockJsonResponse({ ok: true, body: { secrets: [] } }))
.mockResolvedValueOnce(mockJsonResponse({ ok: true, body: { configured: false } })),
);
render();
await screen.findByText("Not configured");
expect(screen.queryByRole("link", { name: "Learn more" })).not.toBeInTheDocument();
expect(document.querySelector('a[href^="/docs/secrets.md"]')).toBeNull();
});
it("submitting matching passphrases issues PUT and re-fetches status", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(mockJsonResponse({ ok: true, body: { secrets: [] } }))
.mockResolvedValueOnce(mockJsonResponse({ ok: true, body: { configured: false } }))
.mockResolvedValueOnce(mockJsonResponse({ ok: true, body: { success: true } }))
.mockResolvedValueOnce(mockJsonResponse({ ok: true, body: { configured: true } }));
vi.stubGlobal("fetch", fetchMock);
render();
await screen.findByText("Not configured");
await userEvent.click(screen.getByRole("button", { name: "Set passphrase" }));
const dialog = screen.getByRole("dialog", { name: "Set sync passphrase" });
await userEvent.type(within(dialog).getByLabelText("Passphrase"), "shared-pass");
await userEvent.type(within(dialog).getByLabelText("Confirm passphrase"), "shared-pass");
await userEvent.click(within(dialog).getByRole("button", { name: "Set passphrase" }));
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledWith(
"/api/secrets/sync-passphrase",
expect.objectContaining({ method: "PUT" }),
);
});
expect(await screen.findByText("Configured")).toBeInTheDocument();
});
it("mismatched confirmation disables submit and does not call PUT", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(mockJsonResponse({ ok: true, body: { secrets: [] } }))
.mockResolvedValueOnce(mockJsonResponse({ ok: true, body: { configured: false } }));
vi.stubGlobal("fetch", fetchMock);
render();
await screen.findByText("Not configured");
await userEvent.click(screen.getByRole("button", { name: "Set passphrase" }));
const dialog = screen.getByRole("dialog", { name: "Set sync passphrase" });
await userEvent.type(within(dialog).getByLabelText("Passphrase"), "a");
await userEvent.type(within(dialog).getByLabelText("Confirm passphrase"), "b");
const submitButton = within(dialog).getByRole("button", { name: "Set passphrase" });
expect(submitButton).toBeDisabled();
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("clear button issues DELETE after confirmation", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(mockJsonResponse({ ok: true, body: { secrets: [] } }))
.mockResolvedValueOnce(mockJsonResponse({ ok: true, body: { configured: true } }))
.mockResolvedValueOnce(mockJsonResponse({ ok: true, body: { success: true } }))
.mockResolvedValueOnce(mockJsonResponse({ ok: true, body: { configured: false } }));
vi.stubGlobal("fetch", fetchMock);
vi.spyOn(window, "confirm").mockReturnValue(true);
render();
await screen.findByText("Configured");
await userEvent.click(screen.getByRole("button", { name: "Clear" }));
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledWith(
"/api/secrets/sync-passphrase",
expect.objectContaining({ method: "DELETE" }),
);
});
});
it("filters reserved key from main list", async () => {
vi.stubGlobal(
"fetch",
vi
.fn()
.mockResolvedValueOnce(
mockJsonResponse({
ok: true,
body: {
secrets: [
{ id: "1", key: "__sync_passphrase__", scope: "global", description: null, accessPolicy: "deny", envExportable: false, envExportKey: null, lastReadAt: null },
{ id: "2", key: "VISIBLE", scope: "project", description: null, accessPolicy: "prompt", envExportable: false, envExportKey: null, lastReadAt: null },
],
},
}),
)
.mockResolvedValueOnce(mockJsonResponse({ ok: true, body: { configured: false } })),
);
render();
expect(await screen.findByText("VISIBLE")).toBeInTheDocument();
expect(screen.queryByText("__sync_passphrase__")).not.toBeInTheDocument();
});
it.each(["dark", "light"] as const)("keeps header and row action icons visible in %s theme", async (theme) => {
document.documentElement.dataset.theme = theme;
vi.stubGlobal(
"fetch",
vi
.fn()
.mockResolvedValueOnce(
mockJsonResponse({
ok: true,
body: {
secrets: [
{ id: "secret-1", key: "VISIBLE", scope: "project", description: null, accessPolicy: "prompt", envExportable: false, envExportKey: null, lastReadAt: null },
],
},
}),
)
.mockResolvedValueOnce(mockJsonResponse({ ok: true, body: { configured: false } })),
);
render();
await screen.findByText("VISIBLE");
expectVisibleActionIcon(screen.getByRole("button", { name: "Refresh" }));
expectVisibleActionIcon(screen.getByRole("button", { name: "Add Secret" }));
expectVisibleActionIcon(screen.getByRole("button", { name: "Reveal" }));
expectVisibleActionIcon(screen.getByRole("button", { name: "Copy" }));
expectVisibleActionIcon(screen.getByRole("button", { name: "Edit" }));
expectVisibleActionIcon(screen.getByRole("button", { name: "Delete" }));
});
it.each(["dark", "light"] as const)("keeps the modal value toggle icon visible in %s theme", async (theme) => {
document.documentElement.dataset.theme = theme;
vi.stubGlobal(
"fetch",
vi
.fn()
.mockResolvedValueOnce(mockJsonResponse({ ok: true, body: { secrets: [] } }))
.mockResolvedValueOnce(mockJsonResponse({ ok: true, body: { configured: false } })),
);
render();
await screen.findByText("Not configured");
await userEvent.click(screen.getByRole("button", { name: "Add Secret" }));
expectVisibleActionIcon(screen.getByRole("button", { name: "Show value" }));
});
});