- Standardize SecretsView action icon sizing and color so header, row, loading, and modal toggle icons stay visible in dark and light themes - Expand SecretsView tests to load app CSS and assert icon visibility across both themes - Stabilize the process supervisor fallback test by removing an unnecessary maxLifetime override Fusion-Task-Id: FN-5257
246 lines
9.1 KiB
TypeScript
246 lines
9.1 KiB
TypeScript
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(<SecretsView addToast={vi.fn()} />);
|
|
|
|
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(<SecretsView addToast={vi.fn()} />);
|
|
|
|
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(<SecretsView addToast={vi.fn()} />);
|
|
|
|
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(<SecretsView addToast={vi.fn()} />);
|
|
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(<SecretsView addToast={vi.fn()} />);
|
|
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(<SecretsView addToast={vi.fn()} />);
|
|
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(<SecretsView addToast={vi.fn()} />);
|
|
|
|
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(<SecretsView addToast={vi.fn()} />);
|
|
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(<SecretsView addToast={vi.fn()} />);
|
|
await screen.findByText("Not configured");
|
|
|
|
await userEvent.click(screen.getByRole("button", { name: "Add Secret" }));
|
|
expectVisibleActionIcon(screen.getByRole("button", { name: "Show value" }));
|
|
});
|
|
});
|