241 lines
8.7 KiB
TypeScript
241 lines
8.7 KiB
TypeScript
import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { createElement, type ReactNode } from "react";
|
|
import type { SecretListItem } from "@/lib/types/http-secrets";
|
|
import { ApiError } from "@/lib/api/client";
|
|
|
|
const mocks = vi.hoisted(() => ({
|
|
deleteSecret: vi.fn(),
|
|
listSecretReferences: vi.fn(),
|
|
addSecret: vi.fn(),
|
|
updateSecret: vi.fn(),
|
|
removeSecret: vi.fn(),
|
|
toast: vi.fn(),
|
|
items: [] as SecretListItem[],
|
|
responsive: { isFinePointer: true },
|
|
}));
|
|
|
|
vi.mock("@/lib/api/domains/secrets-api", () => ({
|
|
createSecret: vi.fn(),
|
|
updateSecret: mocks.updateSecret,
|
|
deleteSecret: mocks.deleteSecret,
|
|
listSecretReferences: mocks.listSecretReferences,
|
|
}));
|
|
vi.mock("@/hooks/domains/settings/use-secrets", () => ({
|
|
useSecrets: () => ({
|
|
loaded: true,
|
|
items: mocks.items,
|
|
addSecret: mocks.addSecret,
|
|
updateSecret: mocks.updateSecret,
|
|
removeSecret: mocks.removeSecret,
|
|
}),
|
|
}));
|
|
vi.mock("@/components/state-provider", () => ({
|
|
useAppStore: (selector: (state: unknown) => unknown) =>
|
|
selector({ addSecret: mocks.addSecret, workspaces: { items: [] } }),
|
|
}));
|
|
vi.mock("@/components/toast-provider", () => ({ useToast: () => ({ toast: mocks.toast }) }));
|
|
vi.mock("@/hooks/use-responsive-breakpoint", () => ({
|
|
useResponsiveBreakpoint: () => mocks.responsive,
|
|
}));
|
|
vi.mock("@/components/settings/settings-page-template", () => ({
|
|
SettingsPageTemplate: ({ children }: { children: ReactNode }) =>
|
|
createElement("div", null, children),
|
|
}));
|
|
vi.mock("@/components/settings/workspaces/workspace-section-header", () => ({
|
|
WorkspaceSectionHeader: () => null,
|
|
}));
|
|
vi.mock("./copy-move-secret-dialog", () => ({ CopyMoveSecretDialog: () => null }));
|
|
|
|
import { getSecretDraftMeta, SecretsSettings } from "./secrets-settings";
|
|
|
|
const secret: SecretListItem = {
|
|
id: "secret-1",
|
|
name: "Saved secret",
|
|
has_value: true,
|
|
created_at: "",
|
|
updated_at: "",
|
|
};
|
|
|
|
const DELETE_BUTTON = "Delete secret Saved secret";
|
|
const CONFIRM_POPOVER_TEST_ID = "secret-delete-confirm-popover";
|
|
const CONFIRM_TEST_ID = "secret-delete-confirm";
|
|
|
|
function renderSettings() {
|
|
render(createElement(SecretsSettings, { initialItems: mocks.items }));
|
|
}
|
|
|
|
async function openDeleteConfirmation() {
|
|
fireEvent.click(screen.getByRole("button", { name: DELETE_BUTTON }));
|
|
const popover = await screen.findByTestId(CONFIRM_POPOVER_TEST_ID);
|
|
const confirm = within(popover).getByTestId(CONFIRM_TEST_ID) as HTMLButtonElement;
|
|
await waitFor(() => expect(confirm.disabled).toBe(false));
|
|
return popover;
|
|
}
|
|
|
|
beforeEach(() => {
|
|
mocks.items = [secret];
|
|
mocks.responsive.isFinePointer = true;
|
|
mocks.deleteSecret.mockReset();
|
|
mocks.deleteSecret.mockResolvedValue(undefined);
|
|
mocks.listSecretReferences.mockReset();
|
|
mocks.listSecretReferences.mockResolvedValue([]);
|
|
mocks.removeSecret.mockReset();
|
|
mocks.toast.mockReset();
|
|
});
|
|
|
|
afterEach(() => cleanup());
|
|
|
|
describe("getSecretDraftMeta", () => {
|
|
it("treats a newly opened secret form as dirty before persistence", () => {
|
|
const state = getSecretDraftMeta([], null, true, { name: "", value: "" });
|
|
|
|
expect(state.isDirty).toBe(true);
|
|
expect(state.revision.startsWith("new:")).toBe(true);
|
|
});
|
|
|
|
it("tracks existing secret edits against the saved name", () => {
|
|
expect(
|
|
getSecretDraftMeta([secret], secret.id, false, { name: secret.name, value: "" }).isDirty,
|
|
).toBe(false);
|
|
expect(
|
|
getSecretDraftMeta([secret], secret.id, false, { name: "Renamed", value: "" }).isDirty,
|
|
).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("SecretsSettings deletion preflight", () => {
|
|
it("keeps deletion disabled while checking and ignores a cancelled lookup", async () => {
|
|
let resolveReferences!: (references: unknown[]) => void;
|
|
mocks.listSecretReferences.mockReturnValue(
|
|
new Promise((resolve) => {
|
|
resolveReferences = resolve;
|
|
}),
|
|
);
|
|
renderSettings();
|
|
|
|
fireEvent.click(screen.getByRole("button", { name: DELETE_BUTTON }));
|
|
const popover = await screen.findByTestId(CONFIRM_POPOVER_TEST_ID);
|
|
expect(within(popover).getByText("Checking where this secret is used…")).toBeTruthy();
|
|
expect((within(popover).getByTestId(CONFIRM_TEST_ID) as HTMLButtonElement).disabled).toBe(true);
|
|
|
|
fireEvent.click(within(popover).getByRole("button", { name: "Cancel" }));
|
|
resolveReferences([{ kind: "repository", id: "repo-1", name: "App", key: "DEPLOY_TOKEN" }]);
|
|
|
|
await waitFor(() => expect(screen.queryByTestId(CONFIRM_POPOVER_TEST_ID)).toBeNull());
|
|
expect(screen.queryByTestId("secret-delete-conflict-dialog")).toBeNull();
|
|
expect(mocks.deleteSecret).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("shows references before deletion and does not offer an unsafe delete", async () => {
|
|
mocks.listSecretReferences.mockResolvedValue([
|
|
{ kind: "agent_profile", id: "profile-1", name: "Claude review", key: "MY_TOKEN" },
|
|
]);
|
|
renderSettings();
|
|
|
|
fireEvent.click(screen.getByRole("button", { name: DELETE_BUTTON }));
|
|
|
|
const dialog = await screen.findByTestId("secret-delete-conflict-dialog");
|
|
expect(within(dialog).getByText(/Claude review/)).toBeTruthy();
|
|
expect(within(dialog).getByText(/MY_TOKEN/)).toBeTruthy();
|
|
expect(within(dialog).queryByTestId(CONFIRM_TEST_ID)).toBeNull();
|
|
expect(mocks.deleteSecret).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("shows fresh references when deletion loses a race", async () => {
|
|
mocks.deleteSecret.mockRejectedValue(
|
|
new ApiError("private server details", 409, {
|
|
code: "secret_in_use",
|
|
references: [
|
|
{ kind: "agent_profile", id: "profile-1", name: "Claude review", key: "MY_TOKEN" },
|
|
],
|
|
}),
|
|
);
|
|
renderSettings();
|
|
await openDeleteConfirmation();
|
|
fireEvent.click(
|
|
within(screen.getByTestId(CONFIRM_POPOVER_TEST_ID)).getByTestId(CONFIRM_TEST_ID),
|
|
);
|
|
const dialog = await screen.findByTestId("secret-delete-conflict-dialog");
|
|
expect(within(dialog).getByText(/Claude review/)).toBeTruthy();
|
|
expect(mocks.removeSecret).not.toHaveBeenCalled();
|
|
expect(document.body.textContent).not.toContain("private server details");
|
|
});
|
|
});
|
|
|
|
describe("SecretsSettings confirmed deletion", () => {
|
|
it("cancels locally without dispatching deletion", async () => {
|
|
renderSettings();
|
|
|
|
const popover = await openDeleteConfirmation();
|
|
fireEvent.click(within(popover).getByRole("button", { name: "Cancel" }));
|
|
|
|
expect(mocks.deleteSecret).not.toHaveBeenCalled();
|
|
expect(screen.queryByTestId(CONFIRM_POPOVER_TEST_ID)).toBeNull();
|
|
});
|
|
|
|
it("closes before dispatching exactly one delete and removes on success", async () => {
|
|
let confirmationClosed = false;
|
|
mocks.deleteSecret.mockImplementation(async () => {
|
|
confirmationClosed = screen.queryByTestId(CONFIRM_POPOVER_TEST_ID) === null;
|
|
});
|
|
renderSettings();
|
|
|
|
await openDeleteConfirmation();
|
|
fireEvent.click(
|
|
within(screen.getByTestId(CONFIRM_POPOVER_TEST_ID)).getByTestId(CONFIRM_TEST_ID),
|
|
);
|
|
|
|
await waitFor(() => expect(mocks.deleteSecret).toHaveBeenCalledTimes(1));
|
|
expect(confirmationClosed).toBe(true);
|
|
await waitFor(() => expect(mocks.removeSecret).toHaveBeenCalledWith(secret.id));
|
|
});
|
|
|
|
it("keeps row actions busy until deletion settles", async () => {
|
|
let resolveDelete!: () => void;
|
|
mocks.deleteSecret.mockReturnValue(
|
|
new Promise<void>((resolve) => {
|
|
resolveDelete = resolve;
|
|
}),
|
|
);
|
|
renderSettings();
|
|
|
|
await openDeleteConfirmation();
|
|
fireEvent.click(
|
|
within(screen.getByTestId(CONFIRM_POPOVER_TEST_ID)).getByTestId(CONFIRM_TEST_ID),
|
|
);
|
|
|
|
await waitFor(() => expect(mocks.deleteSecret).toHaveBeenCalledTimes(1));
|
|
expect(
|
|
(screen.getByRole("button", { name: DELETE_BUTTON }) as HTMLButtonElement).disabled,
|
|
).toBe(true);
|
|
resolveDelete();
|
|
await waitFor(() => expect(mocks.removeSecret).toHaveBeenCalledWith(secret.id));
|
|
});
|
|
|
|
it("keeps the secret and reports a localized failure when deletion fails", async () => {
|
|
mocks.deleteSecret.mockRejectedValue(new Error("server details must stay out of copy"));
|
|
renderSettings();
|
|
|
|
await openDeleteConfirmation();
|
|
fireEvent.click(
|
|
within(screen.getByTestId(CONFIRM_POPOVER_TEST_ID)).getByTestId(CONFIRM_TEST_ID),
|
|
);
|
|
|
|
await waitFor(() =>
|
|
expect(mocks.toast).toHaveBeenCalledWith({
|
|
description: "Couldn't delete secret",
|
|
variant: "error",
|
|
}),
|
|
);
|
|
expect(screen.getByText(secret.name)).not.toBeNull();
|
|
expect(
|
|
(screen.getByRole("button", { name: DELETE_BUTTON }) as HTMLButtonElement).disabled,
|
|
).toBe(false);
|
|
expect(mocks.removeSecret).not.toHaveBeenCalled();
|
|
expect(JSON.stringify(mocks.toast.mock.calls)).not.toContain(
|
|
"server details must stay out of copy",
|
|
);
|
|
});
|
|
});
|