diff --git a/frontend/src/components/settings/AuditLogPanel.test.tsx b/frontend/src/components/settings/AuditLogPanel.test.tsx
new file mode 100644
index 0000000..fc93d8d
--- /dev/null
+++ b/frontend/src/components/settings/AuditLogPanel.test.tsx
@@ -0,0 +1,272 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { render, screen, waitFor } from "@/test/test-utils";
+import userEvent from "@testing-library/user-event";
+import { AuditLogPanel } from "./AuditLogPanel";
+
+// Mock API client
+const mockGetAuditLogs = vi.fn();
+
+vi.mock("@/api/client", () => ({
+ getAuditLogs: (...args: unknown[]) => mockGetAuditLogs(...args),
+}));
+
+const mockLogs = [
+ {
+ id: 1,
+ timestamp: "2024-06-15T10:30:00Z",
+ user_id: 1,
+ username: "admin",
+ action: "settings.update",
+ resource_type: "oidc",
+ resource_id: "config-1",
+ details: { field: "issuer", old: "old-val", new: "new-val" },
+ ip_address: "127.0.0.1",
+ },
+ {
+ id: 2,
+ timestamp: "2024-06-15T11:00:00Z",
+ user_id: 2,
+ username: "user1",
+ action: "auth.login",
+ resource_type: null,
+ resource_id: null,
+ details: null,
+ ip_address: "10.0.0.1",
+ },
+];
+
+describe("AuditLogPanel", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockGetAuditLogs.mockResolvedValue({
+ data: mockLogs,
+ total: 2,
+ page: 1,
+ page_size: 25,
+ has_more: false,
+ });
+ });
+
+ it("shows loading spinner on mount", () => {
+ mockGetAuditLogs.mockImplementation(() => new Promise(() => {}));
+
+ render();
+
+ const spinner = document.querySelector(".animate-spin");
+ expect(spinner).toBeInTheDocument();
+ });
+
+ it("renders audit log entries after fetch", async () => {
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByText("admin")).toBeInTheDocument();
+ });
+
+ expect(screen.getByText("user1")).toBeInTheDocument();
+ expect(screen.getByText("settings.update")).toBeInTheDocument();
+ expect(screen.getByText("auth.login")).toBeInTheDocument();
+ });
+
+ it("shows empty state when no logs", async () => {
+ mockGetAuditLogs.mockResolvedValue({
+ data: [],
+ total: 0,
+ page: 1,
+ page_size: 25,
+ has_more: false,
+ });
+
+ render();
+
+ await waitFor(() => {
+ expect(
+ screen.getByText("No audit log entries found."),
+ ).toBeInTheDocument();
+ });
+ });
+
+ it("shows error message on fetch failure", async () => {
+ mockGetAuditLogs.mockRejectedValue(new Error("Network error"));
+
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByText("Failed to load audit logs")).toBeInTheDocument();
+ });
+ });
+
+ it("filters by action", async () => {
+ const user = userEvent.setup();
+
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByText("admin")).toBeInTheDocument();
+ });
+
+ mockGetAuditLogs.mockClear();
+
+ const actionInput = screen.getByPlaceholderText("Filter by action...");
+ await user.type(actionInput, "login");
+
+ await waitFor(() => {
+ expect(mockGetAuditLogs).toHaveBeenCalledWith(
+ expect.objectContaining({ action: "login" }),
+ );
+ });
+ });
+
+ it("filters by username", async () => {
+ const user = userEvent.setup();
+
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByText("admin")).toBeInTheDocument();
+ });
+
+ mockGetAuditLogs.mockClear();
+
+ const usernameInput = screen.getByPlaceholderText("Filter by username...");
+ await user.type(usernameInput, "admin");
+
+ await waitFor(() => {
+ expect(mockGetAuditLogs).toHaveBeenCalledWith(
+ expect.objectContaining({ username: "admin" }),
+ );
+ });
+ });
+
+ it("shows pagination controls when more than one page", async () => {
+ mockGetAuditLogs.mockResolvedValue({
+ data: mockLogs,
+ total: 50,
+ page: 1,
+ page_size: 25,
+ has_more: true,
+ });
+
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByText("1 / 2")).toBeInTheDocument();
+ });
+
+ expect(screen.getByText(/Showing 1-25 of 50/)).toBeInTheDocument();
+ });
+
+ it("navigates to next page", async () => {
+ const user = userEvent.setup();
+
+ mockGetAuditLogs.mockResolvedValue({
+ data: mockLogs,
+ total: 50,
+ page: 1,
+ page_size: 25,
+ has_more: true,
+ });
+
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByText("1 / 2")).toBeInTheDocument();
+ });
+
+ mockGetAuditLogs.mockClear();
+
+ // Click the next page button (second chevron button)
+ const nextButton = screen.getByText("1 / 2").nextElementSibling;
+ expect(nextButton).toBeInTheDocument();
+ await user.click(nextButton!);
+
+ await waitFor(() => {
+ expect(mockGetAuditLogs).toHaveBeenCalledWith(
+ expect.objectContaining({ page: 2 }),
+ );
+ });
+ });
+
+ it("refresh button reloads logs", async () => {
+ const user = userEvent.setup();
+
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByText("admin")).toBeInTheDocument();
+ });
+
+ mockGetAuditLogs.mockClear();
+
+ await user.click(screen.getByRole("button", { name: /Refresh/i }));
+
+ expect(mockGetAuditLogs).toHaveBeenCalled();
+ });
+
+ it("displays resource type:id when present", async () => {
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByText("oidc: config-1")).toBeInTheDocument();
+ });
+ });
+
+ it('displays "-" when resource type is missing', async () => {
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByText("admin")).toBeInTheDocument();
+ });
+
+ // The second log entry has null resource_type, should show "-"
+ const cells = screen.getAllByText("-");
+ expect(cells.length).toBeGreaterThanOrEqual(1);
+ });
+
+ it("truncates long details JSON to 100 chars", async () => {
+ const longDetails: Record = {};
+ for (let i = 0; i < 20; i++) {
+ longDetails[`key${i}`] = `value-that-is-long-${i}`;
+ }
+
+ mockGetAuditLogs.mockResolvedValue({
+ data: [
+ {
+ id: 3,
+ timestamp: "2024-06-15T12:00:00Z",
+ user_id: 1,
+ username: "admin",
+ action: "test.action",
+ resource_type: null,
+ resource_id: null,
+ details: longDetails,
+ ip_address: null,
+ },
+ ],
+ total: 1,
+ page: 1,
+ page_size: 25,
+ has_more: false,
+ });
+
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByText("test.action")).toBeInTheDocument();
+ });
+
+ // The details cell should be truncated via slice(0, 100)
+ const detailsCells = document.querySelectorAll("td.max-w-xs");
+ expect(detailsCells.length).toBeGreaterThanOrEqual(1);
+ const detailsText = detailsCells[0]?.textContent || "";
+ expect(detailsText.length).toBeLessThanOrEqual(100);
+ });
+
+ it("shows event count in header", async () => {
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByText("2 events recorded")).toBeInTheDocument();
+ });
+ });
+});
diff --git a/frontend/src/components/settings/TerraformBucketsSettings.test.tsx b/frontend/src/components/settings/TerraformBucketsSettings.test.tsx
new file mode 100644
index 0000000..7a57d87
--- /dev/null
+++ b/frontend/src/components/settings/TerraformBucketsSettings.test.tsx
@@ -0,0 +1,561 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { render, screen, waitFor } from "@/test/test-utils";
+import userEvent from "@testing-library/user-event";
+import { TerraformBucketsSettings } from "./TerraformBucketsSettings";
+import type { TerraformBucket } from "@/types";
+
+// Mock API client
+const mockGetTerraformBuckets = vi.fn();
+const mockCreateTerraformBucket = vi.fn();
+const mockUpdateTerraformBucket = vi.fn();
+const mockDeleteTerraformBucket = vi.fn();
+const mockCreateTerraformPath = vi.fn();
+const mockDeleteTerraformPath = vi.fn();
+const mockUpdateTerraformPath = vi.fn();
+const mockTestS3Bucket = vi.fn();
+const mockListS3BucketObjects = vi.fn();
+
+vi.mock("@/api/client", () => ({
+ getTerraformBuckets: (...args: unknown[]) => mockGetTerraformBuckets(...args),
+ createTerraformBucket: (...args: unknown[]) =>
+ mockCreateTerraformBucket(...args),
+ updateTerraformBucket: (...args: unknown[]) =>
+ mockUpdateTerraformBucket(...args),
+ deleteTerraformBucket: (...args: unknown[]) =>
+ mockDeleteTerraformBucket(...args),
+ createTerraformPath: (...args: unknown[]) => mockCreateTerraformPath(...args),
+ deleteTerraformPath: (...args: unknown[]) => mockDeleteTerraformPath(...args),
+ updateTerraformPath: (...args: unknown[]) => mockUpdateTerraformPath(...args),
+ testS3Bucket: (...args: unknown[]) => mockTestS3Bucket(...args),
+ listS3BucketObjects: (...args: unknown[]) => mockListS3BucketObjects(...args),
+}));
+
+const makeBucket = (overrides?: Partial): TerraformBucket => ({
+ id: 1,
+ bucket_name: "my-tf-state-bucket",
+ region: "us-east-1",
+ description: "Production state files",
+ prefix: "lab/",
+ excluded_paths: null,
+ enabled: true,
+ source: "admin",
+ paths: [],
+ created_at: "2024-01-01T00:00:00Z",
+ updated_at: "2024-06-15T00:00:00Z",
+ ...overrides,
+});
+
+const bucketWithPaths = makeBucket({
+ paths: [
+ {
+ id: 10,
+ bucket_id: 1,
+ path: "env/prod/terraform.tfstate",
+ description: "Production",
+ enabled: true,
+ created_at: "2024-01-01T00:00:00Z",
+ updated_at: "2024-06-15T00:00:00Z",
+ },
+ {
+ id: 11,
+ bucket_id: 1,
+ path: "env/dev/terraform.tfstate",
+ description: null,
+ enabled: false,
+ created_at: "2024-02-01T00:00:00Z",
+ updated_at: "2024-06-15T00:00:00Z",
+ },
+ ],
+});
+
+describe("TerraformBucketsSettings", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockGetTerraformBuckets.mockResolvedValue({
+ buckets: [makeBucket()],
+ total: 1,
+ });
+ });
+
+ it("shows loading spinner on mount", () => {
+ mockGetTerraformBuckets.mockImplementation(() => new Promise(() => {}));
+
+ render();
+
+ const spinner = document.querySelector(".animate-spin");
+ expect(spinner).toBeInTheDocument();
+ });
+
+ it("renders bucket cards after fetch", async () => {
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByText("my-tf-state-bucket")).toBeInTheDocument();
+ });
+
+ expect(screen.getByText("Active")).toBeInTheDocument();
+ expect(screen.getByText(/Region: us-east-1/)).toBeInTheDocument();
+ });
+
+ it("shows empty state when no buckets", async () => {
+ mockGetTerraformBuckets.mockResolvedValue({ buckets: [], total: 0 });
+
+ render();
+
+ await waitFor(() => {
+ expect(
+ screen.getByText("No Terraform state buckets configured"),
+ ).toBeInTheDocument();
+ });
+ });
+
+ it("shows error on fetch failure", async () => {
+ mockGetTerraformBuckets.mockRejectedValue(new Error("Network error"));
+
+ render();
+
+ await waitFor(() => {
+ expect(
+ screen.getByText("Failed to load Terraform state buckets"),
+ ).toBeInTheDocument();
+ });
+ });
+
+ it('"Add S3 Bucket" button shows add form', async () => {
+ const user = userEvent.setup();
+
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByText("my-tf-state-bucket")).toBeInTheDocument();
+ });
+
+ await user.click(screen.getByRole("button", { name: /Add S3 Bucket/i }));
+
+ expect(
+ screen.getByPlaceholderText("my-terraform-state-bucket"),
+ ).toBeInTheDocument();
+ });
+
+ it("add form: fill fields and submit calls createTerraformBucket", async () => {
+ const user = userEvent.setup();
+ const newBucket = makeBucket({
+ id: 2,
+ bucket_name: "new-bucket",
+ region: "us-west-2",
+ description: "",
+ prefix: null,
+ });
+ mockCreateTerraformBucket.mockResolvedValue(newBucket);
+
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByText("my-tf-state-bucket")).toBeInTheDocument();
+ });
+
+ await user.click(screen.getByRole("button", { name: /Add S3 Bucket/i }));
+
+ await user.type(
+ screen.getByPlaceholderText("my-terraform-state-bucket"),
+ "new-bucket",
+ );
+ await user.type(
+ screen.getByPlaceholderText("us-east-1 (optional)"),
+ "us-west-2",
+ );
+
+ await user.click(screen.getByRole("button", { name: "Add Bucket" }));
+
+ await waitFor(() => {
+ expect(mockCreateTerraformBucket).toHaveBeenCalledWith(
+ expect.objectContaining({
+ bucket_name: "new-bucket",
+ region: "us-west-2",
+ }),
+ );
+ });
+ });
+
+ it("add form: cancel hides form", async () => {
+ const user = userEvent.setup();
+
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByText("my-tf-state-bucket")).toBeInTheDocument();
+ });
+
+ await user.click(screen.getByRole("button", { name: /Add S3 Bucket/i }));
+ expect(
+ screen.getByPlaceholderText("my-terraform-state-bucket"),
+ ).toBeInTheDocument();
+
+ await user.click(screen.getByRole("button", { name: "Cancel" }));
+
+ expect(
+ screen.queryByPlaceholderText("my-terraform-state-bucket"),
+ ).not.toBeInTheDocument();
+ });
+
+ it("add form: shows error on save failure", async () => {
+ const user = userEvent.setup();
+ mockCreateTerraformBucket.mockRejectedValue({
+ response: { data: { detail: "Bucket already exists" } },
+ });
+
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByText("my-tf-state-bucket")).toBeInTheDocument();
+ });
+
+ await user.click(screen.getByRole("button", { name: /Add S3 Bucket/i }));
+
+ await user.type(
+ screen.getByPlaceholderText("my-terraform-state-bucket"),
+ "duplicate-bucket",
+ );
+
+ await user.click(screen.getByRole("button", { name: "Add Bucket" }));
+
+ await waitFor(() => {
+ expect(screen.getByText("Bucket already exists")).toBeInTheDocument();
+ });
+ });
+
+ it("delete bucket: click delete then confirm calls deleteTerraformBucket", async () => {
+ const user = userEvent.setup();
+ mockDeleteTerraformBucket.mockResolvedValue(undefined);
+
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByText("my-tf-state-bucket")).toBeInTheDocument();
+ });
+
+ // Click the delete button (Trash2 icon)
+ await user.click(screen.getByTitle("Delete"));
+
+ // Confirm deletion
+ await user.click(screen.getByTitle("Confirm delete"));
+
+ await waitFor(() => {
+ expect(mockDeleteTerraformBucket).toHaveBeenCalledWith(1);
+ });
+ });
+
+ it("delete bucket: cancel hides confirm buttons", async () => {
+ const user = userEvent.setup();
+
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByText("my-tf-state-bucket")).toBeInTheDocument();
+ });
+
+ await user.click(screen.getByTitle("Delete"));
+
+ // Confirm and Cancel buttons should appear
+ expect(screen.getByTitle("Confirm delete")).toBeInTheDocument();
+ expect(screen.getByTitle("Cancel")).toBeInTheDocument();
+
+ await user.click(screen.getByTitle("Cancel"));
+
+ // Should go back to showing the Delete button
+ expect(screen.queryByTitle("Confirm delete")).not.toBeInTheDocument();
+ expect(screen.getByTitle("Delete")).toBeInTheDocument();
+ });
+
+ it("toggle enabled calls updateTerraformBucket", async () => {
+ const user = userEvent.setup();
+ const updated = makeBucket({ enabled: false });
+ mockUpdateTerraformBucket.mockResolvedValue(updated);
+
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByText("my-tf-state-bucket")).toBeInTheDocument();
+ });
+
+ await user.click(screen.getByTitle("Disable"));
+
+ await waitFor(() => {
+ expect(mockUpdateTerraformBucket).toHaveBeenCalledWith(1, {
+ enabled: false,
+ });
+ });
+ });
+
+ it("edit button shows edit form with pre-filled values", async () => {
+ const user = userEvent.setup();
+
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByText("my-tf-state-bucket")).toBeInTheDocument();
+ });
+
+ await user.click(screen.getByTitle("Edit"));
+
+ // Should show edit form with existing values
+ expect(screen.getByText("Edit Bucket")).toBeInTheDocument();
+ expect(screen.getByDisplayValue("my-tf-state-bucket")).toBeInTheDocument();
+ expect(screen.getByDisplayValue("us-east-1")).toBeInTheDocument();
+ expect(
+ screen.getByDisplayValue("Production state files"),
+ ).toBeInTheDocument();
+ });
+
+ it("edit form: save calls updateTerraformBucket and exits edit mode", async () => {
+ const user = userEvent.setup();
+ const updated = makeBucket({ description: "Updated description" });
+ mockUpdateTerraformBucket.mockResolvedValue(updated);
+
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByText("my-tf-state-bucket")).toBeInTheDocument();
+ });
+
+ await user.click(screen.getByTitle("Edit"));
+
+ const descInput = screen.getByDisplayValue("Production state files");
+ await user.clear(descInput);
+ await user.type(descInput, "Updated description");
+
+ await user.click(screen.getByRole("button", { name: "Save" }));
+
+ await waitFor(() => {
+ expect(mockUpdateTerraformBucket).toHaveBeenCalledWith(
+ 1,
+ expect.objectContaining({
+ description: "Updated description",
+ }),
+ );
+ });
+
+ // Edit form should be gone
+ await waitFor(() => {
+ expect(screen.queryByText("Edit Bucket")).not.toBeInTheDocument();
+ });
+ });
+
+ it("edit form: cancel exits edit mode", async () => {
+ const user = userEvent.setup();
+
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByText("my-tf-state-bucket")).toBeInTheDocument();
+ });
+
+ await user.click(screen.getByTitle("Edit"));
+ expect(screen.getByText("Edit Bucket")).toBeInTheDocument();
+
+ await user.click(screen.getByRole("button", { name: "Cancel" }));
+
+ expect(screen.queryByText("Edit Bucket")).not.toBeInTheDocument();
+ });
+
+ it("test connectivity: shows success banner", async () => {
+ const user = userEvent.setup();
+ mockTestS3Bucket.mockResolvedValue({
+ success: true,
+ message: "Bucket accessible",
+ details: { region: "us-east-1" },
+ });
+
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByText("my-tf-state-bucket")).toBeInTheDocument();
+ });
+
+ await user.click(screen.getByTitle("Test connectivity"));
+
+ await waitFor(() => {
+ expect(screen.getByText("Bucket accessible")).toBeInTheDocument();
+ });
+ });
+
+ it("test connectivity: shows failure banner", async () => {
+ const user = userEvent.setup();
+ mockTestS3Bucket.mockRejectedValue(new Error("No access"));
+
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByText("my-tf-state-bucket")).toBeInTheDocument();
+ });
+
+ await user.click(screen.getByTitle("Test connectivity"));
+
+ await waitFor(() => {
+ expect(
+ screen.getByText("Failed to test bucket connectivity"),
+ ).toBeInTheDocument();
+ });
+ });
+
+ it("expand bucket shows paths section", async () => {
+ const user = userEvent.setup();
+ mockGetTerraformBuckets.mockResolvedValue({
+ buckets: [bucketWithPaths],
+ total: 1,
+ });
+
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByText("my-tf-state-bucket")).toBeInTheDocument();
+ });
+
+ // Click the expand chevron
+ const expandButtons = document.querySelectorAll("button");
+ const chevronButton = Array.from(expandButtons).find(
+ (btn) =>
+ btn.querySelector("svg") &&
+ btn.className.includes("text-gray-400") &&
+ btn.className.includes("hover:text-gray-600"),
+ );
+ expect(chevronButton).toBeTruthy();
+ await user.click(chevronButton!);
+
+ await waitFor(() => {
+ expect(screen.getByText("State File Paths")).toBeInTheDocument();
+ });
+
+ expect(screen.getByText("env/prod/terraform.tfstate")).toBeInTheDocument();
+ expect(screen.getByText("env/dev/terraform.tfstate")).toBeInTheDocument();
+ });
+
+ it("expanded with no paths shows auto-discovery message", async () => {
+ const user = userEvent.setup();
+
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByText("my-tf-state-bucket")).toBeInTheDocument();
+ });
+
+ // Bucket has no paths
+ expect(screen.getByText("Auto-discovery")).toBeInTheDocument();
+
+ // Expand the bucket
+ const expandButtons = document.querySelectorAll("button");
+ const chevronButton = Array.from(expandButtons).find(
+ (btn) =>
+ btn.querySelector("svg") &&
+ btn.className.includes("text-gray-400") &&
+ btn.className.includes("hover:text-gray-600"),
+ );
+ await user.click(chevronButton!);
+
+ await waitFor(() => {
+ expect(screen.getByText("State File Paths")).toBeInTheDocument();
+ });
+
+ expect(
+ screen.getByText(/No explicit paths configured/),
+ ).toBeInTheDocument();
+ });
+
+ it("add path: click shows form, submit calls createTerraformPath", async () => {
+ const user = userEvent.setup();
+
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByText("my-tf-state-bucket")).toBeInTheDocument();
+ });
+
+ // Expand the bucket
+ const expandButtons = document.querySelectorAll("button");
+ const chevronButton = Array.from(expandButtons).find(
+ (btn) =>
+ btn.querySelector("svg") &&
+ btn.className.includes("text-gray-400") &&
+ btn.className.includes("hover:text-gray-600"),
+ );
+ await user.click(chevronButton!);
+
+ await waitFor(() => {
+ expect(screen.getByText("Add Path")).toBeInTheDocument();
+ });
+
+ await user.click(screen.getByText("Add Path"));
+
+ const pathInput = screen.getByPlaceholderText(
+ "env/production/terraform.tfstate",
+ );
+ expect(pathInput).toBeInTheDocument();
+
+ const createdPath = {
+ id: 20,
+ bucket_id: 1,
+ path: "env/staging/terraform.tfstate",
+ description: null,
+ enabled: true,
+ created_at: "2024-06-15T00:00:00Z",
+ updated_at: "2024-06-15T00:00:00Z",
+ };
+ mockCreateTerraformPath.mockResolvedValue(createdPath);
+
+ await user.type(pathInput, "env/staging/terraform.tfstate");
+ await user.click(screen.getByRole("button", { name: "Add" }));
+
+ await waitFor(() => {
+ expect(mockCreateTerraformPath).toHaveBeenCalledWith(
+ 1,
+ expect.objectContaining({
+ path: "env/staging/terraform.tfstate",
+ enabled: true,
+ }),
+ );
+ });
+ });
+
+ it("ENV bucket: no delete button, bucket name disabled in edit form", async () => {
+ const user = userEvent.setup();
+ const envBucket = makeBucket({ source: "env" });
+ mockGetTerraformBuckets.mockResolvedValue({
+ buckets: [envBucket],
+ total: 1,
+ });
+
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByText("my-tf-state-bucket")).toBeInTheDocument();
+ });
+
+ // Should show ENV badge
+ expect(screen.getByText("ENV")).toBeInTheDocument();
+
+ // Should not have Delete button
+ expect(screen.queryByTitle("Delete")).not.toBeInTheDocument();
+
+ // Edit form should have disabled bucket name
+ await user.click(screen.getByTitle("Edit"));
+
+ const bucketNameInput = screen.getByDisplayValue("my-tf-state-bucket");
+ expect(bucketNameInput).toBeDisabled();
+ expect(
+ screen.getByText(
+ /Bucket name is set by the TF_STATE_BUCKET environment variable/,
+ ),
+ ).toBeInTheDocument();
+ });
+
+ it("shows description and prefix metadata", async () => {
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByText("my-tf-state-bucket")).toBeInTheDocument();
+ });
+
+ expect(screen.getByText("Production state files")).toBeInTheDocument();
+ expect(screen.getByText("lab/")).toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/components/settings/UserManagementPanel.test.tsx b/frontend/src/components/settings/UserManagementPanel.test.tsx
new file mode 100644
index 0000000..6f7b996
--- /dev/null
+++ b/frontend/src/components/settings/UserManagementPanel.test.tsx
@@ -0,0 +1,296 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { render, screen, waitFor } from "@/test/test-utils";
+import userEvent from "@testing-library/user-event";
+import { UserManagementPanel } from "./UserManagementPanel";
+import type { User } from "@/types";
+
+// Mock auth context
+const mockUseAuth = vi.fn();
+
+vi.mock("@/contexts/AuthContext", () => ({
+ useAuth: () => mockUseAuth(),
+}));
+
+// Mock API client
+const mockGetUsers = vi.fn();
+const mockUpdateUserStatus = vi.fn();
+const mockUpdateUserRole = vi.fn();
+
+vi.mock("@/api/client", () => ({
+ getUsers: (...args: unknown[]) => mockGetUsers(...args),
+ updateUserStatus: (...args: unknown[]) => mockUpdateUserStatus(...args),
+ updateUserRole: (...args: unknown[]) => mockUpdateUserRole(...args),
+}));
+
+const currentUser: User = {
+ id: 1,
+ username: "admin",
+ email: "admin@example.com",
+ display_name: "Admin User",
+ auth_provider: "local",
+ is_active: true,
+ is_admin: true,
+ role: "admin",
+ last_login_at: "2024-06-15T10:30:00Z",
+ created_at: "2024-01-01T00:00:00Z",
+};
+
+const otherUser: User = {
+ id: 2,
+ username: "jdoe",
+ email: "jdoe@example.com",
+ display_name: "Jane Doe",
+ auth_provider: "oidc",
+ is_active: true,
+ is_admin: false,
+ role: "user",
+ last_login_at: null,
+ created_at: "2024-03-01T00:00:00Z",
+};
+
+const inactiveUser: User = {
+ id: 3,
+ username: "inactive",
+ email: null,
+ display_name: null,
+ auth_provider: "local",
+ is_active: false,
+ is_admin: false,
+ role: "viewer",
+ last_login_at: "2024-02-01T00:00:00Z",
+ created_at: "2024-01-15T00:00:00Z",
+};
+
+const mockUsers = [currentUser, otherUser, inactiveUser];
+
+describe("UserManagementPanel", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockUseAuth.mockReturnValue({ user: currentUser });
+ mockGetUsers.mockResolvedValue({ users: mockUsers, total: 3 });
+ });
+
+ it("shows loading spinner on mount", () => {
+ mockGetUsers.mockImplementation(() => new Promise(() => {}));
+
+ render();
+
+ const spinner = document.querySelector(".animate-spin");
+ expect(spinner).toBeInTheDocument();
+ });
+
+ it("renders user table after fetch", async () => {
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByText("Admin User")).toBeInTheDocument();
+ });
+
+ expect(screen.getByText("Jane Doe")).toBeInTheDocument();
+ expect(screen.getByText("3 users total")).toBeInTheDocument();
+ });
+
+ it("shows error state on fetch failure", async () => {
+ mockGetUsers.mockRejectedValue(new Error("Network error"));
+
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByText("Failed to load users")).toBeInTheDocument();
+ });
+ });
+
+ it("displays user info including display_name, username, email, and provider badge", async () => {
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByText("Admin User")).toBeInTheDocument();
+ });
+
+ // Username and email shown below display name
+ expect(screen.getByText(/admin@example.com/)).toBeInTheDocument();
+ expect(screen.getByText("Jane Doe")).toBeInTheDocument();
+ expect(screen.getByText(/jdoe@example.com/)).toBeInTheDocument();
+
+ // Provider badges
+ expect(screen.getByText("OIDC")).toBeInTheDocument();
+ const localBadges = screen.getAllByText("Local");
+ expect(localBadges.length).toBeGreaterThanOrEqual(1);
+ });
+
+ it('shows "you" badge for current user', async () => {
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByText("you")).toBeInTheDocument();
+ });
+ });
+
+ it("current user cannot change own role", async () => {
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByText("Admin User")).toBeInTheDocument();
+ });
+
+ // Find select elements - the first one belongs to the current user
+ const selects = screen.getAllByRole("combobox");
+ const currentUserSelect = selects[0];
+ expect(currentUserSelect).toBeDisabled();
+ expect(currentUserSelect).toHaveAttribute(
+ "title",
+ "You cannot change your own role",
+ );
+ });
+
+ it("current user cannot change own status", async () => {
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByText("Admin User")).toBeInTheDocument();
+ });
+
+ // The first Deactivate button belongs to the current user and should be disabled
+ const deactivateButtons = screen.getAllByTitle(
+ "You cannot change your own status",
+ );
+ expect(deactivateButtons.length).toBe(1);
+ expect(deactivateButtons[0]).toBeDisabled();
+ });
+
+ it("role change calls updateUserRole and updates row", async () => {
+ const user = userEvent.setup();
+ const updatedUser = { ...otherUser, role: "admin" as const };
+ mockUpdateUserRole.mockResolvedValue(updatedUser);
+
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByText("Jane Doe")).toBeInTheDocument();
+ });
+
+ // Find the select for the other user (second select element)
+ const selects = screen.getAllByRole("combobox");
+ const otherUserSelect = selects[1];
+ expect(otherUserSelect).not.toBeDisabled();
+
+ await user.selectOptions(otherUserSelect, "admin");
+
+ await waitFor(() => {
+ expect(mockUpdateUserRole).toHaveBeenCalledWith(2, { role: "admin" });
+ });
+ });
+
+ it("status toggle calls updateUserStatus and updates status badge", async () => {
+ const user = userEvent.setup();
+ const updatedUser = { ...otherUser, is_active: false };
+ mockUpdateUserStatus.mockResolvedValue(updatedUser);
+
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByText("Jane Doe")).toBeInTheDocument();
+ });
+
+ // Click the Deactivate button for the other user
+ const deactivateButton = screen.getByTitle("Deactivate user");
+ await user.click(deactivateButton);
+
+ await waitFor(() => {
+ expect(mockUpdateUserStatus).toHaveBeenCalledWith(2, {
+ is_active: false,
+ });
+ });
+ });
+
+ it("shows action error banner with dismiss button", async () => {
+ const user = userEvent.setup();
+ mockUpdateUserStatus.mockRejectedValue({
+ response: { data: { detail: "Cannot deactivate last admin" } },
+ });
+
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByText("Jane Doe")).toBeInTheDocument();
+ });
+
+ const deactivateButton = screen.getByTitle("Deactivate user");
+ await user.click(deactivateButton);
+
+ await waitFor(() => {
+ expect(
+ screen.getByText("Cannot deactivate last admin"),
+ ).toBeInTheDocument();
+ });
+
+ // Dismiss the error
+ await user.click(screen.getByText("Dismiss"));
+
+ await waitFor(() => {
+ expect(
+ screen.queryByText("Cannot deactivate last admin"),
+ ).not.toBeInTheDocument();
+ });
+ });
+
+ it("refresh button reloads users", async () => {
+ const user = userEvent.setup();
+
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByText("Admin User")).toBeInTheDocument();
+ });
+
+ mockGetUsers.mockClear();
+
+ await user.click(screen.getByRole("button", { name: /Refresh/i }));
+
+ expect(mockGetUsers).toHaveBeenCalled();
+ });
+
+ it("shows Active/Inactive status badges correctly", async () => {
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByText("Admin User")).toBeInTheDocument();
+ });
+
+ const activeBadges = screen.getAllByText("Active");
+ expect(activeBadges.length).toBe(2); // currentUser and otherUser
+
+ const inactiveBadges = screen.getAllByText("Inactive");
+ expect(inactiveBadges.length).toBe(1); // inactiveUser
+ });
+
+ it('shows "Never" for null last_login_at', async () => {
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByText("Jane Doe")).toBeInTheDocument();
+ });
+
+ expect(screen.getByText("Never")).toBeInTheDocument();
+ });
+
+ it("shows generic error when action fails without detail", async () => {
+ const user = userEvent.setup();
+ mockUpdateUserStatus.mockRejectedValue(new Error("Network error"));
+
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByText("Jane Doe")).toBeInTheDocument();
+ });
+
+ const deactivateButton = screen.getByTitle("Deactivate user");
+ await user.click(deactivateButton);
+
+ await waitFor(() => {
+ expect(
+ screen.getByText("Failed to update user status"),
+ ).toBeInTheDocument();
+ });
+ });
+});