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,
},
},
},