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(); + }); + }); +});