From 7af16b75b948fe32d9a92bb3ffffdc788b65cd36 Mon Sep 17 00:00:00 2001 From: Julian Sommer <115516918+juliansommer@users.noreply.github.com> Date: Wed, 11 Mar 2026 10:56:42 +1000 Subject: [PATCH] feat: 80% real frontend coverage --- .../src/components/detail-card.test.tsx | 34 +++ .../src/components/detail-row.test.tsx | 27 ++ .../src/components/form-actions.test.tsx | 81 ++++++ .../src/components/theme-provider.test.tsx | 126 ++++++++ .../src/components/theme-switch.test.tsx | 54 ++++ .../frontend/src/mutations/iam.test.tsx | 269 ++++++++++++++++++ .../hiveui/frontend/src/queries/iam.test.ts | 171 +++++++++++ .../hiveui/frontend/src/queries/s3.test.ts | 69 +++++ .../policy-document-viewer.test.tsx | 24 ++ .../hiveui/frontend/src/types/iam.test.ts | 89 ++++++ .../hiveui/frontend/src/types/s3.test.ts | 64 +++++ hive/services/hiveui/frontend/vite.config.ts | 14 +- 12 files changed, 1021 insertions(+), 1 deletion(-) create mode 100644 hive/services/hiveui/frontend/src/components/detail-card.test.tsx create mode 100644 hive/services/hiveui/frontend/src/components/detail-row.test.tsx create mode 100644 hive/services/hiveui/frontend/src/components/form-actions.test.tsx create mode 100644 hive/services/hiveui/frontend/src/components/theme-provider.test.tsx create mode 100644 hive/services/hiveui/frontend/src/components/theme-switch.test.tsx create mode 100644 hive/services/hiveui/frontend/src/mutations/iam.test.tsx create mode 100644 hive/services/hiveui/frontend/src/queries/iam.test.ts create mode 100644 hive/services/hiveui/frontend/src/queries/s3.test.ts create mode 100644 hive/services/hiveui/frontend/src/routes/_auth/iam/-components/policy-document-viewer.test.tsx create mode 100644 hive/services/hiveui/frontend/src/types/iam.test.ts create mode 100644 hive/services/hiveui/frontend/src/types/s3.test.ts diff --git a/hive/services/hiveui/frontend/src/components/detail-card.test.tsx b/hive/services/hiveui/frontend/src/components/detail-card.test.tsx new file mode 100644 index 000000000..0d1521de5 --- /dev/null +++ b/hive/services/hiveui/frontend/src/components/detail-card.test.tsx @@ -0,0 +1,34 @@ +import { render, screen } from "@testing-library/react" +import { describe, expect, it } from "vitest" + +import { DetailCard } from "./detail-card" + +describe("DetailCard", () => { + it("renders children", () => { + render(Card content) + expect(screen.getByText("Card content")).toBeInTheDocument() + }) + + it("renders header", () => { + render(Section Title) + expect( + screen.getByRole("heading", { name: "Section Title" }), + ).toBeInTheDocument() + }) + + it("renders content", () => { + render(Inner content) + expect(screen.getByText("Inner content")).toBeInTheDocument() + }) + + it("composes root, header, and content together", () => { + render( + + Title + Body + , + ) + expect(screen.getByRole("heading", { name: "Title" })).toBeInTheDocument() + expect(screen.getByText("Body")).toBeInTheDocument() + }) +}) diff --git a/hive/services/hiveui/frontend/src/components/detail-row.test.tsx b/hive/services/hiveui/frontend/src/components/detail-row.test.tsx new file mode 100644 index 000000000..7667c8c46 --- /dev/null +++ b/hive/services/hiveui/frontend/src/components/detail-row.test.tsx @@ -0,0 +1,27 @@ +import { render, screen } from "@testing-library/react" +import { describe, expect, it } from "vitest" + +import { DetailRow } from "./detail-row" + +describe("DetailRow", () => { + it("renders label and value", () => { + render() + expect(screen.getByText("Instance ID")).toBeInTheDocument() + expect(screen.getByText("i-abc123")).toBeInTheDocument() + }) + + it("renders dash when value is undefined", () => { + render() + expect(screen.getByText("—")).toBeInTheDocument() + }) + + it("renders dash when value is empty string", () => { + render() + expect(screen.getByText("—")).toBeInTheDocument() + }) + + it("renders ReactNode as value", () => { + render(running} />) + expect(screen.getByText("running")).toBeInTheDocument() + }) +}) diff --git a/hive/services/hiveui/frontend/src/components/form-actions.test.tsx b/hive/services/hiveui/frontend/src/components/form-actions.test.tsx new file mode 100644 index 000000000..ffd0108a1 --- /dev/null +++ b/hive/services/hiveui/frontend/src/components/form-actions.test.tsx @@ -0,0 +1,81 @@ +import { render, screen } from "@testing-library/react" +import userEvent from "@testing-library/user-event" +import { describe, expect, it, vi } from "vitest" + +import { FormActions } from "./form-actions" + +describe("FormActions", () => { + it("renders submit and cancel buttons", () => { + render( + , + ) + expect(screen.getByRole("button", { name: "Create" })).toBeInTheDocument() + expect(screen.getByRole("button", { name: "Cancel" })).toBeInTheDocument() + }) + + it("shows pending label when submitting", () => { + render( + , + ) + expect( + screen.getByRole("button", { name: "Creating..." }), + ).toBeInTheDocument() + }) + + it("shows pending label when isPending", () => { + render( + , + ) + expect( + screen.getByRole("button", { name: "Creating..." }), + ).toBeInTheDocument() + }) + + it("disables both buttons when submitting", () => { + render( + , + ) + expect(screen.getByRole("button", { name: "Creating..." })).toBeDisabled() + expect(screen.getByRole("button", { name: "Cancel" })).toBeDisabled() + }) + + it("calls onCancel when cancel is clicked", async () => { + const onCancel = vi.fn() + const user = userEvent.setup() + render( + , + ) + await user.click(screen.getByRole("button", { name: "Cancel" })) + expect(onCancel).toHaveBeenCalledOnce() + }) +}) diff --git a/hive/services/hiveui/frontend/src/components/theme-provider.test.tsx b/hive/services/hiveui/frontend/src/components/theme-provider.test.tsx new file mode 100644 index 000000000..b4474a410 --- /dev/null +++ b/hive/services/hiveui/frontend/src/components/theme-provider.test.tsx @@ -0,0 +1,126 @@ +import { act, render, renderHook, screen } from "@testing-library/react" +import type { ReactNode } from "react" +import { beforeEach, describe, expect, it, vi } from "vitest" + +import { ThemeProvider, useTheme } from "./theme-provider" + +function wrapper({ children }: { children: ReactNode }) { + return {children} +} + +beforeEach(() => { + localStorage.clear() + document.documentElement.classList.remove("light", "dark") + Object.defineProperty(window, "matchMedia", { + writable: true, + value: vi.fn().mockReturnValue({ matches: false }), + }) +}) + +describe("ThemeProvider", () => { + it("renders children", () => { + render( + +

Hello

+
, + ) + expect(screen.getByText("Hello")).toBeInTheDocument() + }) + + it("applies light class to documentElement", () => { + render( + +

Test

+
, + ) + expect(document.documentElement.classList.contains("light")).toBe(true) + }) + + it("applies dark class to documentElement", () => { + render( + +

Test

+
, + ) + expect(document.documentElement.classList.contains("dark")).toBe(true) + }) + + it("uses system theme when set to system", () => { + Object.defineProperty(window, "matchMedia", { + writable: true, + value: vi.fn().mockReturnValue({ matches: true }), + }) + render( + +

Test

+
, + ) + expect(document.documentElement.classList.contains("dark")).toBe(true) + }) + + it("reads stored theme from localStorage", () => { + localStorage.setItem("ui-theme", "dark") + render( + +

Test

+
, + ) + expect(document.documentElement.classList.contains("dark")).toBe(true) + }) + + it("ignores invalid stored theme", () => { + localStorage.setItem("ui-theme", "invalid") + render( + +

Test

+
, + ) + expect(document.documentElement.classList.contains("light")).toBe(true) + }) + + it("uses custom storageKey", () => { + localStorage.setItem("custom-key", "dark") + render( + +

Test

+
, + ) + expect(document.documentElement.classList.contains("dark")).toBe(true) + }) +}) + +describe("useTheme", () => { + it("throws when used outside ThemeProvider", () => { + expect(() => renderHook(() => useTheme())).toThrow( + "useTheme must be used within a ThemeProvider", + ) + }) + + it("returns current theme", () => { + const { result } = renderHook(() => useTheme(), { wrapper }) + expect(result.current.theme).toBe("light") + }) + + it("setTheme updates theme and localStorage", () => { + const { result } = renderHook(() => useTheme(), { wrapper }) + + act(() => { + result.current.setTheme("dark") + }) + + expect(result.current.theme).toBe("dark") + expect(localStorage.getItem("ui-theme")).toBe("dark") + }) + + it("removes previous class when theme changes", () => { + const { result } = renderHook(() => useTheme(), { wrapper }) + expect(document.documentElement.classList.contains("light")).toBe(true) + + act(() => { + result.current.setTheme("dark") + }) + + expect(document.documentElement.classList.contains("dark")).toBe(true) + expect(document.documentElement.classList.contains("light")).toBe(false) + }) +}) diff --git a/hive/services/hiveui/frontend/src/components/theme-switch.test.tsx b/hive/services/hiveui/frontend/src/components/theme-switch.test.tsx new file mode 100644 index 000000000..5f0893620 --- /dev/null +++ b/hive/services/hiveui/frontend/src/components/theme-switch.test.tsx @@ -0,0 +1,54 @@ +import { render, screen } from "@testing-library/react" +import userEvent from "@testing-library/user-event" +import type { ReactNode } from "react" +import { describe, expect, it } from "vitest" + +import { ThemeProvider, useTheme } from "./theme-provider" +import { ThemeSwitch } from "./theme-switch" + +function ThemeDisplay() { + const { theme } = useTheme() + return {theme} +} + +function renderWithTheme(defaultTheme: "light" | "dark" | "system" = "light") { + return render( + + + + , + ) +} + +describe("ThemeSwitch", () => { + it("renders toggle button", () => { + renderWithTheme() + expect( + screen.getByRole("button", { name: "Toggle theme" }), + ).toBeInTheDocument() + }) + + it("toggles from light to dark", async () => { + const user = userEvent.setup() + renderWithTheme("light") + + await user.click(screen.getByRole("button", { name: "Toggle theme" })) + expect(screen.getByTestId("theme")).toHaveTextContent("dark") + }) + + it("toggles from dark to light", async () => { + const user = userEvent.setup() + renderWithTheme("dark") + + await user.click(screen.getByRole("button", { name: "Toggle theme" })) + expect(screen.getByTestId("theme")).toHaveTextContent("light") + }) + + it("toggles from system to dark", async () => { + const user = userEvent.setup() + renderWithTheme("system") + + await user.click(screen.getByRole("button", { name: "Toggle theme" })) + expect(screen.getByTestId("theme")).toHaveTextContent("dark") + }) +}) diff --git a/hive/services/hiveui/frontend/src/mutations/iam.test.tsx b/hive/services/hiveui/frontend/src/mutations/iam.test.tsx new file mode 100644 index 000000000..99a120efb --- /dev/null +++ b/hive/services/hiveui/frontend/src/mutations/iam.test.tsx @@ -0,0 +1,269 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query" +import { renderHook, waitFor } from "@testing-library/react" +import type { ReactNode } from "react" +import { afterEach, describe, expect, it, vi } from "vitest" + +const mockSend = vi.fn().mockResolvedValue({}) + +vi.mock("@/lib/awsClient", () => ({ + getIamClient: () => ({ send: mockSend }), +})) + +import { + useAttachUserPolicy, + useCreateAccessKey, + useCreatePolicy, + useCreateUser, + useDeleteAccessKey, + useDeletePolicy, + useDeleteUser, + useDetachUserPolicy, + useUpdateAccessKey, +} from "./iam" + +let queryClient: QueryClient + +function wrapper({ children }: { children: ReactNode }) { + return ( + {children} + ) +} + +function createQueryClient() { + queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }) + return queryClient +} + +afterEach(() => { + mockSend.mockClear() +}) + +describe("useCreateUser", () => { + it("sends CreateUserCommand with userName", async () => { + createQueryClient() + const { result } = renderHook(() => useCreateUser(), { wrapper }) + + result.current.mutate({ userName: "admin" }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect(mockSend.mock.calls[0]?.[0].input).toEqual({ + UserName: "admin", + Path: undefined, + }) + }) + + it("includes Path when provided", async () => { + createQueryClient() + const { result } = renderHook(() => useCreateUser(), { wrapper }) + + result.current.mutate({ userName: "admin", path: "/engineering/" }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect(mockSend.mock.calls[0]?.[0].input).toEqual({ + UserName: "admin", + Path: "/engineering/", + }) + }) + + it("invalidates users query on success", async () => { + createQueryClient() + const spy = vi.spyOn(queryClient, "invalidateQueries") + const { result } = renderHook(() => useCreateUser(), { wrapper }) + + result.current.mutate({ userName: "admin" }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect(spy).toHaveBeenCalledWith({ queryKey: ["iam", "users"] }) + }) +}) + +describe("useDeleteUser", () => { + it("sends DeleteUserCommand with userName", async () => { + createQueryClient() + const { result } = renderHook(() => useDeleteUser(), { wrapper }) + + result.current.mutate("admin") + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect(mockSend.mock.calls[0]?.[0].input).toEqual({ UserName: "admin" }) + }) +}) + +describe("useCreateAccessKey", () => { + it("sends CreateAccessKeyCommand with userName", async () => { + createQueryClient() + const { result } = renderHook(() => useCreateAccessKey(), { wrapper }) + + result.current.mutate("admin") + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect(mockSend.mock.calls[0]?.[0].input).toEqual({ UserName: "admin" }) + }) + + it("invalidates access-keys query on success", async () => { + createQueryClient() + const spy = vi.spyOn(queryClient, "invalidateQueries") + const { result } = renderHook(() => useCreateAccessKey(), { wrapper }) + + result.current.mutate("admin") + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect(spy).toHaveBeenCalledWith({ queryKey: ["iam", "access-keys"] }) + }) +}) + +describe("useDeleteAccessKey", () => { + it("sends DeleteAccessKeyCommand with userName and accessKeyId", async () => { + createQueryClient() + const { result } = renderHook(() => useDeleteAccessKey(), { wrapper }) + + result.current.mutate({ userName: "admin", accessKeyId: "AKIA123" }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect(mockSend.mock.calls[0]?.[0].input).toEqual({ + UserName: "admin", + AccessKeyId: "AKIA123", + }) + }) +}) + +describe("useUpdateAccessKey", () => { + it("sends UpdateAccessKeyCommand with status", async () => { + createQueryClient() + const { result } = renderHook(() => useUpdateAccessKey(), { wrapper }) + + result.current.mutate({ + userName: "admin", + accessKeyId: "AKIA123", + status: "Inactive", + }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect(mockSend.mock.calls[0]?.[0].input).toEqual({ + UserName: "admin", + AccessKeyId: "AKIA123", + Status: "Inactive", + }) + }) +}) + +describe("useCreatePolicy", () => { + it("sends CreatePolicyCommand with policy data", async () => { + createQueryClient() + const { result } = renderHook(() => useCreatePolicy(), { wrapper }) + + result.current.mutate({ + policyName: "ReadOnly", + policyDocument: '{"Version":"2012-10-17"}', + }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect(mockSend.mock.calls[0]?.[0].input).toEqual({ + PolicyName: "ReadOnly", + Description: undefined, + PolicyDocument: '{"Version":"2012-10-17"}', + }) + }) + + it("includes Description when provided", async () => { + createQueryClient() + const { result } = renderHook(() => useCreatePolicy(), { wrapper }) + + result.current.mutate({ + policyName: "ReadOnly", + description: "Read-only access", + policyDocument: "{}", + }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect(mockSend.mock.calls[0]?.[0].input.Description).toBe( + "Read-only access", + ) + }) + + it("invalidates policies query on success", async () => { + createQueryClient() + const spy = vi.spyOn(queryClient, "invalidateQueries") + const { result } = renderHook(() => useCreatePolicy(), { wrapper }) + + result.current.mutate({ policyName: "ReadOnly", policyDocument: "{}" }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect(spy).toHaveBeenCalledWith({ queryKey: ["iam", "policies"] }) + }) +}) + +describe("useDeletePolicy", () => { + it("sends DeletePolicyCommand with policyArn", async () => { + createQueryClient() + const { result } = renderHook(() => useDeletePolicy(), { wrapper }) + + result.current.mutate("arn:aws:iam::123:policy/ReadOnly") + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect(mockSend.mock.calls[0]?.[0].input).toEqual({ + PolicyArn: "arn:aws:iam::123:policy/ReadOnly", + }) + }) +}) + +describe("useAttachUserPolicy", () => { + it("sends AttachUserPolicyCommand with userName and policyArn", async () => { + createQueryClient() + const { result } = renderHook(() => useAttachUserPolicy(), { wrapper }) + + result.current.mutate({ userName: "admin", policyArn: "arn:test" }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect(mockSend.mock.calls[0]?.[0].input).toEqual({ + UserName: "admin", + PolicyArn: "arn:test", + }) + }) + + it("invalidates attached-user-policies query on success", async () => { + createQueryClient() + const spy = vi.spyOn(queryClient, "invalidateQueries") + const { result } = renderHook(() => useAttachUserPolicy(), { wrapper }) + + result.current.mutate({ userName: "admin", policyArn: "arn:test" }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect(spy).toHaveBeenCalledWith({ + queryKey: ["iam", "attached-user-policies"], + }) + }) +}) + +describe("useDetachUserPolicy", () => { + it("sends DetachUserPolicyCommand with userName and policyArn", async () => { + createQueryClient() + const { result } = renderHook(() => useDetachUserPolicy(), { wrapper }) + + result.current.mutate({ userName: "admin", policyArn: "arn:test" }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect(mockSend.mock.calls[0]?.[0].input).toEqual({ + UserName: "admin", + PolicyArn: "arn:test", + }) + }) + + it("invalidates attached-user-policies query on success", async () => { + createQueryClient() + const spy = vi.spyOn(queryClient, "invalidateQueries") + const { result } = renderHook(() => useDetachUserPolicy(), { wrapper }) + + result.current.mutate({ userName: "admin", policyArn: "arn:test" }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect(spy).toHaveBeenCalledWith({ + queryKey: ["iam", "attached-user-policies"], + }) + }) +}) diff --git a/hive/services/hiveui/frontend/src/queries/iam.test.ts b/hive/services/hiveui/frontend/src/queries/iam.test.ts new file mode 100644 index 000000000..b915935d0 --- /dev/null +++ b/hive/services/hiveui/frontend/src/queries/iam.test.ts @@ -0,0 +1,171 @@ +import { afterEach, describe, expect, it, vi } from "vitest" + +const mockSend = vi.fn().mockResolvedValue({}) + +vi.mock("@/lib/awsClient", () => ({ + getIamClient: () => ({ send: mockSend }), +})) + +import { + iamAccessKeysQueryOptions, + iamAttachedUserPoliciesQueryOptions, + iamPoliciesQueryOptions, + iamPolicyQueryOptions, + iamPolicyVersionQueryOptions, + iamUserQueryOptions, + iamUsersQueryOptions, +} from "./iam" + +describe("query keys", () => { + it("iamUsersQueryOptions has correct key", () => { + expect(iamUsersQueryOptions.queryKey).toEqual(["iam", "users"]) + }) + + it("iamUserQueryOptions includes userName in key", () => { + expect(iamUserQueryOptions("admin").queryKey).toEqual([ + "iam", + "users", + "admin", + ]) + }) + + it("iamAccessKeysQueryOptions includes userName in key", () => { + expect(iamAccessKeysQueryOptions("admin").queryKey).toEqual([ + "iam", + "access-keys", + "admin", + ]) + }) + + it("iamPoliciesQueryOptions has correct key", () => { + expect(iamPoliciesQueryOptions.queryKey).toEqual(["iam", "policies"]) + }) + + it("iamPolicyQueryOptions includes policyArn in key", () => { + expect( + iamPolicyQueryOptions("arn:aws:iam::123:policy/ReadOnly").queryKey, + ).toEqual(["iam", "policies", "arn:aws:iam::123:policy/ReadOnly"]) + }) + + it("iamPolicyVersionQueryOptions includes policyArn and versionId in key", () => { + expect( + iamPolicyVersionQueryOptions("arn:aws:iam::123:policy/ReadOnly", "v1") + .queryKey, + ).toEqual([ + "iam", + "policy-versions", + "arn:aws:iam::123:policy/ReadOnly", + "v1", + ]) + }) + + it("iamAttachedUserPoliciesQueryOptions includes userName in key", () => { + expect(iamAttachedUserPoliciesQueryOptions("admin").queryKey).toEqual([ + "iam", + "attached-user-policies", + "admin", + ]) + }) +}) + +describe("staleTime", () => { + it("users use staleTime", () => { + expect(iamUsersQueryOptions.staleTime).toBe(300_000) + }) + + it("user uses staleTime", () => { + expect(iamUserQueryOptions("admin").staleTime).toBe(300_000) + }) + + it("access keys use staleTime", () => { + expect(iamAccessKeysQueryOptions("admin").staleTime).toBe(300_000) + }) + + it("policies use staleTime", () => { + expect(iamPoliciesQueryOptions.staleTime).toBe(300_000) + }) + + it("policy uses staleTime", () => { + expect(iamPolicyQueryOptions("arn:test").staleTime).toBe(300_000) + }) + + it("policy version uses staleTime", () => { + expect(iamPolicyVersionQueryOptions("arn:test", "v1").staleTime).toBe( + 300_000, + ) + }) + + it("attached user policies use staleTime", () => { + expect(iamAttachedUserPoliciesQueryOptions("admin").staleTime).toBe( + 300_000, + ) + }) +}) + +describe("queryFn", () => { + afterEach(() => { + mockSend.mockClear() + }) + + it("iamUsersQueryOptions sends ListUsersCommand", async () => { + const queryFn = iamUsersQueryOptions.queryFn as ( + ctx: never, + ) => Promise + await queryFn({} as never) + expect(mockSend).toHaveBeenCalledOnce() + expect(mockSend.mock.calls[0]?.[0].input).toEqual({}) + }) + + it("iamUserQueryOptions sends GetUserCommand with userName", async () => { + const queryFn = iamUserQueryOptions("admin").queryFn as ( + ctx: never, + ) => Promise + await queryFn({} as never) + expect(mockSend.mock.calls[0]?.[0].input).toEqual({ UserName: "admin" }) + }) + + it("iamAccessKeysQueryOptions sends ListAccessKeysCommand", async () => { + const queryFn = iamAccessKeysQueryOptions("admin").queryFn as ( + ctx: never, + ) => Promise + await queryFn({} as never) + expect(mockSend.mock.calls[0]?.[0].input).toEqual({ UserName: "admin" }) + }) + + it("iamPoliciesQueryOptions sends ListPoliciesCommand with Local scope", async () => { + const queryFn = iamPoliciesQueryOptions.queryFn as ( + ctx: never, + ) => Promise + await queryFn({} as never) + expect(mockSend.mock.calls[0]?.[0].input).toEqual({ Scope: "Local" }) + }) + + it("iamPolicyQueryOptions sends GetPolicyCommand with policyArn", async () => { + const queryFn = iamPolicyQueryOptions("arn:test").queryFn as ( + ctx: never, + ) => Promise + await queryFn({} as never) + expect(mockSend.mock.calls[0]?.[0].input).toEqual({ + PolicyArn: "arn:test", + }) + }) + + it("iamPolicyVersionQueryOptions sends GetPolicyVersionCommand", async () => { + const queryFn = iamPolicyVersionQueryOptions("arn:test", "v1").queryFn as ( + ctx: never, + ) => Promise + await queryFn({} as never) + expect(mockSend.mock.calls[0]?.[0].input).toEqual({ + PolicyArn: "arn:test", + VersionId: "v1", + }) + }) + + it("iamAttachedUserPoliciesQueryOptions sends ListAttachedUserPoliciesCommand", async () => { + const queryFn = iamAttachedUserPoliciesQueryOptions("admin").queryFn as ( + ctx: never, + ) => Promise + await queryFn({} as never) + expect(mockSend.mock.calls[0]?.[0].input).toEqual({ UserName: "admin" }) + }) +}) diff --git a/hive/services/hiveui/frontend/src/queries/s3.test.ts b/hive/services/hiveui/frontend/src/queries/s3.test.ts new file mode 100644 index 000000000..fba61cda0 --- /dev/null +++ b/hive/services/hiveui/frontend/src/queries/s3.test.ts @@ -0,0 +1,69 @@ +import { afterEach, describe, expect, it, vi } from "vitest" + +const mockSend = vi.fn().mockResolvedValue({}) + +vi.mock("@/lib/awsClient", () => ({ + getS3Client: () => ({ send: mockSend }), +})) + +import { s3BucketObjectsQueryOptions, s3BucketsQueryOptions } from "./s3" + +describe("query keys", () => { + it("s3BucketsQueryOptions has correct key", () => { + expect(s3BucketsQueryOptions.queryKey).toEqual(["s3", "buckets"]) + }) + + it("s3BucketObjectsQueryOptions includes bucket and empty prefix in key", () => { + expect(s3BucketObjectsQueryOptions("my-bucket").queryKey).toEqual([ + "s3", + "buckets", + "my-bucket", + "objects", + "", + ]) + }) + + it("s3BucketObjectsQueryOptions includes prefix in key", () => { + expect( + s3BucketObjectsQueryOptions("my-bucket", "photos/").queryKey, + ).toEqual(["s3", "buckets", "my-bucket", "objects", "photos/"]) + }) +}) + +describe("queryFn", () => { + afterEach(() => { + mockSend.mockClear() + }) + + it("s3BucketsQueryOptions sends ListBucketsCommand", async () => { + const queryFn = s3BucketsQueryOptions.queryFn as ( + ctx: never, + ) => Promise + await queryFn({} as never) + expect(mockSend).toHaveBeenCalledOnce() + expect(mockSend.mock.calls[0]?.[0].input).toEqual({}) + }) + + it("s3BucketObjectsQueryOptions sends ListObjectsV2Command with bucket and delimiter", async () => { + const queryFn = s3BucketObjectsQueryOptions("my-bucket").queryFn as ( + ctx: never, + ) => Promise + await queryFn({} as never) + expect(mockSend.mock.calls[0]?.[0].input).toEqual({ + Bucket: "my-bucket", + Prefix: undefined, + Delimiter: "/", + }) + }) + + it("s3BucketObjectsQueryOptions sends prefix when provided", async () => { + const queryFn = s3BucketObjectsQueryOptions("my-bucket", "docs/") + .queryFn as (ctx: never) => Promise + await queryFn({} as never) + expect(mockSend.mock.calls[0]?.[0].input).toEqual({ + Bucket: "my-bucket", + Prefix: "docs/", + Delimiter: "/", + }) + }) +}) diff --git a/hive/services/hiveui/frontend/src/routes/_auth/iam/-components/policy-document-viewer.test.tsx b/hive/services/hiveui/frontend/src/routes/_auth/iam/-components/policy-document-viewer.test.tsx new file mode 100644 index 000000000..0339f344f --- /dev/null +++ b/hive/services/hiveui/frontend/src/routes/_auth/iam/-components/policy-document-viewer.test.tsx @@ -0,0 +1,24 @@ +import { render, screen } from "@testing-library/react" +import { describe, expect, it } from "vitest" + +import { PolicyDocumentViewer } from "./policy-document-viewer" + +describe("PolicyDocumentViewer", () => { + it("formats valid JSON with indentation", () => { + const doc = '{"Version":"2012-10-17","Statement":[]}' + const { container } = render() + const pre = container.querySelector("pre") + expect(pre?.textContent).toBe(JSON.stringify(JSON.parse(doc), null, 2)) + }) + + it("renders invalid JSON as-is", () => { + render() + expect(screen.getByText("not valid json")).toBeInTheDocument() + }) + + it("renders in a pre element", () => { + render() + const pre = screen.getByText("{}").closest("pre") + expect(pre).toBeInTheDocument() + }) +}) diff --git a/hive/services/hiveui/frontend/src/types/iam.test.ts b/hive/services/hiveui/frontend/src/types/iam.test.ts new file mode 100644 index 000000000..cf741c36c --- /dev/null +++ b/hive/services/hiveui/frontend/src/types/iam.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from "vitest" + +import { createPolicySchema, createUserSchema } from "./iam" + +describe("createUserSchema", () => { + it("accepts a valid user name", () => { + const result = createUserSchema.safeParse({ userName: "admin" }) + expect(result.success).toBe(true) + }) + + it("rejects empty user name", () => { + const result = createUserSchema.safeParse({ userName: "" }) + expect(result.success).toBe(false) + }) + + it("rejects user name over 64 chars", () => { + const result = createUserSchema.safeParse({ userName: "a".repeat(65) }) + expect(result.success).toBe(false) + }) + + it("rejects user name with invalid characters", () => { + const result = createUserSchema.safeParse({ userName: "user name!" }) + expect(result.success).toBe(false) + }) + + it("accepts user name with allowed special characters", () => { + const result = createUserSchema.safeParse({ userName: "user+=,.@-test" }) + expect(result.success).toBe(true) + }) + + it("allows optional path", () => { + const result = createUserSchema.safeParse({ + userName: "admin", + path: "/engineering/", + }) + expect(result.success).toBe(true) + }) +}) + +describe("createPolicySchema", () => { + it("accepts valid policy params", () => { + const result = createPolicySchema.safeParse({ + policyName: "ReadOnly", + policyDocument: '{"Version":"2012-10-17","Statement":[]}', + }) + expect(result.success).toBe(true) + }) + + it("rejects empty policy name", () => { + const result = createPolicySchema.safeParse({ + policyName: "", + policyDocument: "{}", + }) + expect(result.success).toBe(false) + }) + + it("rejects policy name over 128 chars", () => { + const result = createPolicySchema.safeParse({ + policyName: "a".repeat(129), + policyDocument: "{}", + }) + expect(result.success).toBe(false) + }) + + it("rejects invalid JSON in policy document", () => { + const result = createPolicySchema.safeParse({ + policyName: "ReadOnly", + policyDocument: "not json", + }) + expect(result.success).toBe(false) + }) + + it("rejects empty policy document", () => { + const result = createPolicySchema.safeParse({ + policyName: "ReadOnly", + policyDocument: "", + }) + expect(result.success).toBe(false) + }) + + it("allows optional description", () => { + const result = createPolicySchema.safeParse({ + policyName: "ReadOnly", + description: "Read-only access", + policyDocument: "{}", + }) + expect(result.success).toBe(true) + }) +}) diff --git a/hive/services/hiveui/frontend/src/types/s3.test.ts b/hive/services/hiveui/frontend/src/types/s3.test.ts new file mode 100644 index 000000000..e0391b5c5 --- /dev/null +++ b/hive/services/hiveui/frontend/src/types/s3.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest" + +import { createBucketSchema } from "./s3" + +describe("createBucketSchema", () => { + it("accepts a valid bucket name", () => { + const result = createBucketSchema.safeParse({ bucketName: "my-bucket" }) + expect(result.success).toBe(true) + }) + + it("rejects bucket name under 3 characters", () => { + const result = createBucketSchema.safeParse({ bucketName: "ab" }) + expect(result.success).toBe(false) + }) + + it("rejects bucket name over 63 characters", () => { + const result = createBucketSchema.safeParse({ + bucketName: "a".repeat(64), + }) + expect(result.success).toBe(false) + }) + + it("rejects uppercase letters", () => { + const result = createBucketSchema.safeParse({ bucketName: "MyBucket" }) + expect(result.success).toBe(false) + }) + + it("rejects names starting with a period", () => { + const result = createBucketSchema.safeParse({ bucketName: ".my-bucket" }) + expect(result.success).toBe(false) + }) + + it("rejects names ending with a hyphen", () => { + const result = createBucketSchema.safeParse({ bucketName: "my-bucket-" }) + expect(result.success).toBe(false) + }) + + it("rejects consecutive periods", () => { + const result = createBucketSchema.safeParse({ bucketName: "my..bucket" }) + expect(result.success).toBe(false) + }) + + it("rejects adjacent period and hyphen", () => { + const result = createBucketSchema.safeParse({ bucketName: "my.-bucket" }) + expect(result.success).toBe(false) + }) + + it("rejects adjacent hyphen and period", () => { + const result = createBucketSchema.safeParse({ bucketName: "my-.bucket" }) + expect(result.success).toBe(false) + }) + + it("accepts bucket name with periods and hyphens", () => { + const result = createBucketSchema.safeParse({ + bucketName: "my.test-bucket.v2", + }) + expect(result.success).toBe(true) + }) + + it("accepts all-numeric bucket name", () => { + const result = createBucketSchema.safeParse({ bucketName: "123" }) + expect(result.success).toBe(true) + }) +}) diff --git a/hive/services/hiveui/frontend/vite.config.ts b/hive/services/hiveui/frontend/vite.config.ts index 5caa12f5f..bd61757f8 100644 --- a/hive/services/hiveui/frontend/vite.config.ts +++ b/hive/services/hiveui/frontend/vite.config.ts @@ -48,8 +48,20 @@ export default defineConfig({ environment: "jsdom", setupFiles: "./src/test/setup.ts", coverage: { + all: true, + include: ["src/**/*.{ts,tsx}"], + exclude: [ + "src/components/ui/**", + "src/layouts/**", + "src/routes/*.{ts,tsx}", + "src/routes/**/!(-components)/*.{ts,tsx}", + "src/test/**", + "src/**/*.test.*", + "src/routeTree.gen.ts", + "src/main.tsx", + ], thresholds: { - statements: 70, + lines: 70, }, }, },