From 3d343ce0c24944f6da3a76c75036267a78d627e5 Mon Sep 17 00:00:00 2001 From: Schnitz <12687466+CptSchnitz@users.noreply.github.com> Date: Tue, 22 Sep 2026 10:40:17 +0300 Subject: [PATCH 1/8] feat(auth-ui): bundles page skeleton Add a read-only Bundles page listing GET /bundle records, reachable from the sidebar nav. No filtering yet. Co-Authored-By: Claude Sonnet 5 --- .../auth-ui/src/components/layout/Sidebar.tsx | 7 +- .../auth-ui/src/pages/bundles/BundlesPage.tsx | 40 ++++++++++ .../src/pages/bundles/BundlesTable.tsx | 49 ++++++++++++ apps/auth-ui/src/pages/bundles/index.ts | 1 + apps/auth-ui/src/routes.tsx | 2 + .../auth-ui/tests/assets/assets-list.spec.tsx | 2 +- apps/auth-ui/tests/bundle-fixtures.ts | 17 +++++ .../tests/bundles/bundles-list.spec.tsx | 76 +++++++++++++++++++ apps/auth-ui/vitest.config.ts | 4 +- 9 files changed, 194 insertions(+), 4 deletions(-) create mode 100644 apps/auth-ui/src/pages/bundles/BundlesPage.tsx create mode 100644 apps/auth-ui/src/pages/bundles/BundlesTable.tsx create mode 100644 apps/auth-ui/src/pages/bundles/index.ts create mode 100644 apps/auth-ui/tests/bundle-fixtures.ts create mode 100644 apps/auth-ui/tests/bundles/bundles-list.spec.tsx diff --git a/apps/auth-ui/src/components/layout/Sidebar.tsx b/apps/auth-ui/src/components/layout/Sidebar.tsx index b348ab4..dfe1483 100644 --- a/apps/auth-ui/src/components/layout/Sidebar.tsx +++ b/apps/auth-ui/src/components/layout/Sidebar.tsx @@ -1,6 +1,6 @@ import { Link, useLocation } from 'react-router-dom'; import { cn } from '../../lib/utils'; -import { Users, Link as LinkIcon, Globe, Menu, X, Key, Shield, FileCode } from 'lucide-react'; +import { Users, Link as LinkIcon, Globe, Menu, X, Key, Shield, FileCode, Package } from 'lucide-react'; import { Button } from '../ui/button'; import { SiteSwitcher } from './SiteSwitcher'; import { ThemeToggle } from '../theme-toggle'; @@ -35,6 +35,11 @@ export const Sidebar = ({ className, isCollapsed, onCollapse }: SidebarProps) => href: '/assets', icon: FileCode, }, + { + title: 'Bundles', + href: '/bundles', + icon: Package, + }, { title: 'JWT Inspector', href: '/jwt-inspector', diff --git a/apps/auth-ui/src/pages/bundles/BundlesPage.tsx b/apps/auth-ui/src/pages/bundles/BundlesPage.tsx new file mode 100644 index 0000000..7c281d3 --- /dev/null +++ b/apps/auth-ui/src/pages/bundles/BundlesPage.tsx @@ -0,0 +1,40 @@ +import { Loader2 } from 'lucide-react'; +import { Button } from '../../components/ui/button'; +import { $api } from '../../fetch'; +import { BundlesTable } from './BundlesTable'; + +export const BundlesPage = () => { + const { data, isLoading, isError, error, refetch } = $api.useQuery('get', '/bundle'); + + if (isError) { + return ( +
+
+

Failed to load bundles

+

{error?.message ?? 'Please try again later'}

+ +
+
+ ); + } + + return ( +
+
+

Bundles

+
+ +
+ {isLoading ? ( +
+ +
+ ) : ( + + )} +
+
+ ); +}; diff --git a/apps/auth-ui/src/pages/bundles/BundlesTable.tsx b/apps/auth-ui/src/pages/bundles/BundlesTable.tsx new file mode 100644 index 0000000..1cca783 --- /dev/null +++ b/apps/auth-ui/src/pages/bundles/BundlesTable.tsx @@ -0,0 +1,49 @@ +import type { components } from 'auth-openapi'; +import { Badge } from '../../components/ui/badge'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../../components/ui/table'; +import { formatTimestamp } from '../../lib/utils'; + +type Bundle = components['schemas']['bundle']; + +interface BundlesTableProps { + bundles: Bundle[]; +} + +const COLUMNS = ['Created At', 'Environment', 'Revision', 'OPA Version', 'Id']; + +export const BundlesTable = ({ bundles }: BundlesTableProps) => ( +
+ + + + {COLUMNS.map((label) => ( + {label} + ))} + + + + {bundles.length === 0 ? ( + + + No bundles found. + + + ) : ( + bundles.map((bundle) => ( + + {bundle.createdAt ? formatTimestamp(bundle.createdAt) : '—'} + + + {bundle.environment} + + + {bundle.revision} + {bundle.opaVersion} + {bundle.id} + + )) + )} + +
+
+); diff --git a/apps/auth-ui/src/pages/bundles/index.ts b/apps/auth-ui/src/pages/bundles/index.ts new file mode 100644 index 0000000..c69f00e --- /dev/null +++ b/apps/auth-ui/src/pages/bundles/index.ts @@ -0,0 +1 @@ +export { BundlesPage } from './BundlesPage'; diff --git a/apps/auth-ui/src/routes.tsx b/apps/auth-ui/src/routes.tsx index 25d5c9b..6c85191 100644 --- a/apps/auth-ui/src/routes.tsx +++ b/apps/auth-ui/src/routes.tsx @@ -1,6 +1,7 @@ import { Navigate, type RouteObject } from 'react-router-dom'; import { Layout } from './components/layout'; import { AssetPage, AssetsPage, CreateAssetPage } from './pages/assets'; +import { BundlesPage } from './pages/bundles'; import { ClientsPage } from './pages/clients'; import { ConnectionsPage } from './pages/connections'; import { DomainsPage } from './pages/domains'; @@ -32,6 +33,7 @@ export const appRoutes: RouteObject[] = [ { path: ':assetName', element: }, ], }, + { path: 'bundles', element: }, { path: 'jwt-inspector', element: }, { path: 'opa-validator', element: }, { path: '*', element: }, diff --git a/apps/auth-ui/tests/assets/assets-list.spec.tsx b/apps/auth-ui/tests/assets/assets-list.spec.tsx index a5496ec..fe9753e 100644 --- a/apps/auth-ui/tests/assets/assets-list.spec.tsx +++ b/apps/auth-ui/tests/assets/assets-list.spec.tsx @@ -28,7 +28,7 @@ describe('assets list', () => { const entries = within(nav) .getAllByRole('link') .map((link) => link.textContent); - expect(entries).toEqual(['Clients', 'Connections', 'Domains', 'Assets', 'JWT Inspector', 'OPA Validator']); + expect(entries).toEqual(['Clients', 'Connections', 'Domains', 'Assets', 'Bundles', 'JWT Inspector', 'OPA Validator']); }); it('marks the Assets entry in the sidebar from anywhere underneath it', async () => { diff --git a/apps/auth-ui/tests/bundle-fixtures.ts b/apps/auth-ui/tests/bundle-fixtures.ts new file mode 100644 index 0000000..1e4ecf4 --- /dev/null +++ b/apps/auth-ui/tests/bundle-fixtures.ts @@ -0,0 +1,17 @@ +import type { components } from 'auth-openapi'; + +export type Bundle = components['schemas']['bundle']; + +export const aBundle = (overrides: Partial = {}): Bundle => ({ + id: 1, + hash: 'sha256:abc123', + revision: 'rev-1', + metadata: { builtBy: 'ci' }, + assets: [{ name: 'authz.rego', version: 1 }], + connections: [{ name: 'my-connection', version: 1 }], + environment: 'np', + createdAt: '2026-01-01T00:00:00.000Z', + keyVersion: 1, + opaVersion: '0.60.0', + ...overrides, +}); diff --git a/apps/auth-ui/tests/bundles/bundles-list.spec.tsx b/apps/auth-ui/tests/bundles/bundles-list.spec.tsx new file mode 100644 index 0000000..bdf0d7f --- /dev/null +++ b/apps/auth-ui/tests/bundles/bundles-list.spec.tsx @@ -0,0 +1,76 @@ +import { screen, within } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { appRoutes } from '../../src/routes'; +import { aBundle } from '../bundle-fixtures'; +import { http } from '../http-stub'; +import { renderRoutes } from '../render'; + +const openBundles = (search = '') => renderRoutes(appRoutes, `/bundles${search}`); + +describe('bundles list', () => { + it('appends a Bundles entry to the sidebar after Assets, leaving the others in place', async () => { + http.on('GET', '/bundle', { body: [] }); + + openBundles(); + + const nav = await screen.findByRole('navigation'); + const entries = within(nav) + .getAllByRole('link') + .map((link) => link.textContent); + expect(entries).toEqual(['Clients', 'Connections', 'Domains', 'Assets', 'Bundles', 'JWT Inspector', 'OPA Validator']); + }); + + it('renders a table fetched from GET /bundle with no query parameters', async () => { + http.on('GET', '/bundle', { body: [aBundle()] }); + + openBundles(); + + expect(await screen.findByText('rev-1')).toBeInTheDocument(); + expect(http.lastRequestFor('GET', '/bundle')?.query.toString()).toBe(''); + }); + + it('shows Created At, Environment, Revision, OPA Version, and Id columns in that order', async () => { + http.on('GET', '/bundle', { body: [aBundle({ id: 42, revision: 'rev-7', opaVersion: '0.61.0', environment: 'prod' })] }); + + openBundles(); + + const table = await screen.findByRole('table'); + const headers = within(table) + .getAllByRole('columnheader') + .map((header) => header.textContent); + expect(headers).toEqual(['Created At', 'Environment', 'Revision', 'OPA Version', 'Id']); + + const row = within(table).getAllByRole('row')[1]!; + expect(within(row).getByText('prod')).toBeInTheDocument(); + expect(within(row).getByText('rev-7')).toBeInTheDocument(); + expect(within(row).getByText('0.61.0')).toBeInTheDocument(); + expect(within(row).getByText('42')).toBeInTheDocument(); + }); + + it('shows a loading state while the request is in flight', async () => { + http.on('GET', '/bundle', { body: [aBundle()], delayMs: 30 }); + + openBundles(); + + expect(await screen.findByRole('status', { name: 'Loading bundles' })).toBeInTheDocument(); + }); + + it('shows an empty state when no bundles are returned', async () => { + http.on('GET', '/bundle', { body: [] }); + + openBundles(); + + expect(await screen.findByText('No bundles found.')).toBeInTheDocument(); + }); + + it('renders no create, edit, or delete controls', async () => { + http.on('GET', '/bundle', { body: [aBundle()] }); + + openBundles(); + await screen.findByText('rev-1'); + + expect(screen.queryByRole('button', { name: /add bundle/i })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /edit/i })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /delete/i })).not.toBeInTheDocument(); + }); +}); diff --git a/apps/auth-ui/vitest.config.ts b/apps/auth-ui/vitest.config.ts index 5bc3ffd..28dfd20 100644 --- a/apps/auth-ui/vitest.config.ts +++ b/apps/auth-ui/vitest.config.ts @@ -21,7 +21,7 @@ export default defineConfig({ environment: 'jsdom', environmentOptions: { jsdom: { url: 'http://localhost:3000/' } }, setupFiles: ['./tests/setup.ts'], - // Scoped to the assets feature. The rest of the application has no tests yet. - include: ['tests/assets/**/*.spec.{ts,tsx}'], + // Scoped to the assets and bundles features. The rest of the application has no tests yet. + include: ['tests/assets/**/*.spec.{ts,tsx}', 'tests/bundles/**/*.spec.{ts,tsx}'], }, }); From 0277cc24705aa25396dbaa5de2ecda46c938b9c5 Mon Sep 17 00:00:00 2001 From: Schnitz <12687466+CptSchnitz@users.noreply.github.com> Date: Tue, 22 Sep 2026 10:42:10 +0300 Subject: [PATCH 2/8] feat(auth-ui): environment and date-range filters for bundles Add an Environment dropdown and createdAfter/createdBefore date inputs to the Bundles page, synced to the url like the existing Connections/Clients/Domains/Assets pages. Co-Authored-By: Claude Sonnet 5 --- .../auth-ui/src/pages/bundles/BundlesPage.tsx | 87 ++++++++++++++++++- .../tests/bundles/bundles-list.spec.tsx | 60 ++++++++++++- 2 files changed, 145 insertions(+), 2 deletions(-) diff --git a/apps/auth-ui/src/pages/bundles/BundlesPage.tsx b/apps/auth-ui/src/pages/bundles/BundlesPage.tsx index 7c281d3..6af5d22 100644 --- a/apps/auth-ui/src/pages/bundles/BundlesPage.tsx +++ b/apps/auth-ui/src/pages/bundles/BundlesPage.tsx @@ -1,10 +1,52 @@ +import type { components } from 'auth-openapi'; import { Loader2 } from 'lucide-react'; +import { useSearchParams } from 'react-router-dom'; import { Button } from '../../components/ui/button'; +import { Input } from '../../components/ui/input'; +import { Label } from '../../components/ui/label'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../../components/ui/select'; import { $api } from '../../fetch'; import { BundlesTable } from './BundlesTable'; +type Environment = components['schemas']['environment']; + +const ENVIRONMENTS: Environment[] = ['np', 'stage', 'prod']; + +/** Radix rejects an empty option value, so "no filter" needs a name of its own. */ +const ANY = 'all'; + +const oneOf = (value: string | null, allowed: readonly T[], fallback: T): T => + allowed.includes(value as T) ? (value as T) : fallback; + export const BundlesPage = () => { - const { data, isLoading, isError, error, refetch } = $api.useQuery('get', '/bundle'); + const [searchParams, setSearchParams] = useSearchParams(); + + // The url is external input: an unknown value would otherwise travel to the server as a filter and come back a 400. + const environment = oneOf(searchParams.get('environment'), ENVIRONMENTS, ANY); + const createdAfter = searchParams.get('createdAfter') ?? ''; + const createdBefore = searchParams.get('createdBefore') ?? ''; + + const updateParams = (changes: Record) => { + setSearchParams( + (current) => { + const next = new URLSearchParams(current); + for (const [key, value] of Object.entries(changes)) { + if (value === null || value === '' || value === ANY) next.delete(key); + else next.set(key, value); + } + return next; + }, + { replace: true } + ); + }; + + const query = { + ...(environment === ANY ? {} : { environment: [environment] }), + ...(createdAfter && { createdAfter }), + ...(createdBefore && { createdBefore }), + }; + + const { data, isLoading, isError, error, refetch } = $api.useQuery('get', '/bundle', { params: { query } }); if (isError) { return ( @@ -26,6 +68,49 @@ export const BundlesPage = () => {

Bundles

+
+
+ + +
+ +
+ + updateParams({ createdAfter: event.target.value || null })} + /> +
+ +
+ + updateParams({ createdBefore: event.target.value || null })} + /> +
+
+
{isLoading ? (
diff --git a/apps/auth-ui/tests/bundles/bundles-list.spec.tsx b/apps/auth-ui/tests/bundles/bundles-list.spec.tsx index bdf0d7f..dcf7ca7 100644 --- a/apps/auth-ui/tests/bundles/bundles-list.spec.tsx +++ b/apps/auth-ui/tests/bundles/bundles-list.spec.tsx @@ -1,4 +1,5 @@ -import { screen, within } from '@testing-library/react'; +import { screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import { describe, expect, it } from 'vitest'; import { appRoutes } from '../../src/routes'; import { aBundle } from '../bundle-fixtures'; @@ -73,4 +74,61 @@ describe('bundles list', () => { expect(screen.queryByRole('button', { name: /edit/i })).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: /delete/i })).not.toBeInTheDocument(); }); + + it('sends no environment filter until one is chosen, then sends it as a one-element array', async () => { + http.on('GET', '/bundle', { body: [aBundle()] }); + + openBundles(); + await screen.findByText('rev-1'); + expect(http.lastRequestFor('GET', '/bundle')?.query.getAll('environment')).toEqual([]); + + await userEvent.click(screen.getByRole('combobox', { name: 'Environment' })); + await userEvent.click(await screen.findByRole('option', { name: 'prod' })); + + await waitFor(() => expect(http.lastRequestFor('GET', '/bundle')?.query.getAll('environment')).toEqual(['prod'])); + }); + + it('sends the createdAfter and createdBefore date filters to the server', async () => { + http.on('GET', '/bundle', { body: [aBundle()] }); + + openBundles(); + await screen.findByText('rev-1'); + + await userEvent.type(screen.getByLabelText('Created after'), '2026-01-01'); + await waitFor(() => expect(http.lastRequestFor('GET', '/bundle')?.query.get('createdAfter')).toBe('2026-01-01')); + + await userEvent.type(screen.getByLabelText('Created before'), '2026-06-01'); + await waitFor(() => expect(http.lastRequestFor('GET', '/bundle')?.query.get('createdBefore')).toBe('2026-06-01')); + }); + + it('combines the environment and date filters into a single request', async () => { + http.on('GET', '/bundle', { body: [aBundle()] }); + + openBundles('?environment=stage&createdAfter=2026-01-01&createdBefore=2026-06-01'); + await screen.findByText('rev-1'); + + const request = http.lastRequestFor('GET', '/bundle'); + expect(request?.query.getAll('environment')).toEqual(['stage']); + expect(request?.query.get('createdAfter')).toBe('2026-01-01'); + expect(request?.query.get('createdBefore')).toBe('2026-06-01'); + }); + + it('reads the filters back out of the url on load, and restores them across a refresh', async () => { + http.on('GET', '/bundle', { body: [aBundle()] }); + + openBundles('?environment=prod&createdAfter=2026-01-01&createdBefore=2026-06-01'); + + await screen.findByText('rev-1'); + expect(screen.getByRole('combobox', { name: 'Environment' })).toHaveTextContent('prod'); + expect(screen.getByLabelText('Created after')).toHaveValue('2026-01-01'); + expect(screen.getByLabelText('Created before')).toHaveValue('2026-06-01'); + }); + + it('shows an empty state when the active filters match no bundles', async () => { + http.on('GET', '/bundle', { body: [] }); + + openBundles('?environment=prod'); + + expect(await screen.findByText('No bundles found.')).toBeInTheDocument(); + }); }); From a6b0f7bec7d2c7280eb0d40764e324784eb7ebee Mon Sep 17 00:00:00 2001 From: Schnitz <12687466+CptSchnitz@users.noreply.github.com> Date: Tue, 22 Sep 2026 10:44:03 +0300 Subject: [PATCH 3/8] feat(auth-ui): bundle details modal Clicking a bundle row opens a read-only modal showing the hash, keyVersion, assets, connections and metadata, using the row data already fetched by the list request. Co-Authored-By: Claude Sonnet 5 --- .../src/pages/bundles/BundleDetailsModal.tsx | 60 +++++++++++++ .../auth-ui/src/pages/bundles/BundlesPage.tsx | 11 ++- .../src/pages/bundles/BundlesTable.tsx | 5 +- .../bundles/bundle-details-modal.spec.tsx | 84 +++++++++++++++++++ 4 files changed, 157 insertions(+), 3 deletions(-) create mode 100644 apps/auth-ui/src/pages/bundles/BundleDetailsModal.tsx create mode 100644 apps/auth-ui/tests/bundles/bundle-details-modal.spec.tsx diff --git a/apps/auth-ui/src/pages/bundles/BundleDetailsModal.tsx b/apps/auth-ui/src/pages/bundles/BundleDetailsModal.tsx new file mode 100644 index 0000000..12225da --- /dev/null +++ b/apps/auth-ui/src/pages/bundles/BundleDetailsModal.tsx @@ -0,0 +1,60 @@ +import type { components } from 'auth-openapi'; +import { Badge } from '../../components/ui/badge'; +import { DialogContent, DialogDescription, DialogHeader, DialogTitle } from '../../components/ui/dialog'; + +type Bundle = components['schemas']['bundle']; + +interface BundleDetailsModalProps { + bundle: Bundle; +} + +export const BundleDetailsModal = ({ bundle }: BundleDetailsModalProps) => ( + + + Bundle {bundle.id} + + {bundle.environment} · {bundle.revision} + + + +
+
+ + +
+ + + + +
+

Metadata

+
{JSON.stringify(bundle.metadata ?? {}, null, 2)}
+
+
+
+); + +const Field = ({ label, value }: { label: string; value?: string }) => ( +
+

{label}

+

{value ?? '—'}

+
+); + +const BundleEntryList = ({ title, emptyLabel, entries }: { title: string; emptyLabel: string; entries?: { name: string; version: number }[] }) => ( +
+

{title}

+ {entries && entries.length > 0 ? ( +
    + {entries.map((entry) => ( +
  • + {entry.name} + v{entry.version} +
  • + ))} +
+ ) : ( +

{emptyLabel}

+ )} +
+); diff --git a/apps/auth-ui/src/pages/bundles/BundlesPage.tsx b/apps/auth-ui/src/pages/bundles/BundlesPage.tsx index 6af5d22..42b1a32 100644 --- a/apps/auth-ui/src/pages/bundles/BundlesPage.tsx +++ b/apps/auth-ui/src/pages/bundles/BundlesPage.tsx @@ -1,13 +1,17 @@ import type { components } from 'auth-openapi'; import { Loader2 } from 'lucide-react'; +import { useState } from 'react'; import { useSearchParams } from 'react-router-dom'; import { Button } from '../../components/ui/button'; +import { Dialog } from '../../components/ui/dialog'; import { Input } from '../../components/ui/input'; import { Label } from '../../components/ui/label'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../../components/ui/select'; import { $api } from '../../fetch'; +import { BundleDetailsModal } from './BundleDetailsModal'; import { BundlesTable } from './BundlesTable'; +type Bundle = components['schemas']['bundle']; type Environment = components['schemas']['environment']; const ENVIRONMENTS: Environment[] = ['np', 'stage', 'prod']; @@ -20,6 +24,7 @@ const oneOf = (value: string | null, allowed: readonly T[], fa export const BundlesPage = () => { const [searchParams, setSearchParams] = useSearchParams(); + const [selectedBundle, setSelectedBundle] = useState(null); // The url is external input: an unknown value would otherwise travel to the server as a filter and come back a 400. const environment = oneOf(searchParams.get('environment'), ENVIRONMENTS, ANY); @@ -117,9 +122,13 @@ export const BundlesPage = () => {
) : ( - + )}
+ + !open && setSelectedBundle(null)}> + {selectedBundle && } + ); }; diff --git a/apps/auth-ui/src/pages/bundles/BundlesTable.tsx b/apps/auth-ui/src/pages/bundles/BundlesTable.tsx index 1cca783..79b71ff 100644 --- a/apps/auth-ui/src/pages/bundles/BundlesTable.tsx +++ b/apps/auth-ui/src/pages/bundles/BundlesTable.tsx @@ -7,11 +7,12 @@ type Bundle = components['schemas']['bundle']; interface BundlesTableProps { bundles: Bundle[]; + onSelectBundle: (bundle: Bundle) => void; } const COLUMNS = ['Created At', 'Environment', 'Revision', 'OPA Version', 'Id']; -export const BundlesTable = ({ bundles }: BundlesTableProps) => ( +export const BundlesTable = ({ bundles, onSelectBundle }: BundlesTableProps) => (
@@ -30,7 +31,7 @@ export const BundlesTable = ({ bundles }: BundlesTableProps) => ( ) : ( bundles.map((bundle) => ( - + onSelectBundle(bundle)} className="cursor-pointer hover:bg-muted/50"> {bundle.createdAt ? formatTimestamp(bundle.createdAt) : '—'} diff --git a/apps/auth-ui/tests/bundles/bundle-details-modal.spec.tsx b/apps/auth-ui/tests/bundles/bundle-details-modal.spec.tsx new file mode 100644 index 0000000..aec481e --- /dev/null +++ b/apps/auth-ui/tests/bundles/bundle-details-modal.spec.tsx @@ -0,0 +1,84 @@ +import { screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it } from 'vitest'; +import { appRoutes } from '../../src/routes'; +import { aBundle } from '../bundle-fixtures'; +import { http } from '../http-stub'; +import { renderRoutes } from '../render'; + +const openBundles = (search = '') => renderRoutes(appRoutes, `/bundles${search}`); + +describe('bundle details modal', () => { + it('opens from a row and shows the hash, keyVersion, assets, connections and metadata', async () => { + http.on('GET', '/bundle', { + body: [ + aBundle({ + id: 7, + hash: 'sha256:deadbeef', + keyVersion: 3, + assets: [{ name: 'authz.rego', version: 2 }], + connections: [{ name: 'billing-connection', version: 5 }], + metadata: { builtBy: 'ci', commit: 'abc123' }, + }), + ], + }); + + openBundles(); + await userEvent.click(await screen.findByText('rev-1')); + + const dialog = await screen.findByRole('dialog'); + expect(within(dialog).getByText('sha256:deadbeef')).toBeInTheDocument(); + expect(within(dialog).getByText('3')).toBeInTheDocument(); + expect(within(dialog).getByText('authz.rego')).toBeInTheDocument(); + expect(within(dialog).getByText('v2')).toBeInTheDocument(); + expect(within(dialog).getByText('billing-connection')).toBeInTheDocument(); + expect(within(dialog).getByText('v5')).toBeInTheDocument(); + expect(within(dialog).getByText(/"builtBy": "ci"/)).toBeInTheDocument(); + expect(within(dialog).getByText(/"commit": "abc123"/)).toBeInTheDocument(); + }); + + it('shows a placeholder when a bundle has no assets or connections', async () => { + http.on('GET', '/bundle', { body: [aBundle({ assets: [], connections: [] })] }); + + openBundles(); + await userEvent.click(await screen.findByText('rev-1')); + + const dialog = await screen.findByRole('dialog'); + expect(within(dialog).getByText('No assets')).toBeInTheDocument(); + expect(within(dialog).getByText('No connections')).toBeInTheDocument(); + }); + + it('makes no additional request when opening the modal', async () => { + http.on('GET', '/bundle', { body: [aBundle()] }); + + openBundles(); + await userEvent.click(await screen.findByText('rev-1')); + + await screen.findByRole('dialog'); + expect(http.requestsFor('GET', '/bundle')).toHaveLength(1); + }); + + it('is read-only, with no edit or delete controls', async () => { + http.on('GET', '/bundle', { body: [aBundle()] }); + + openBundles(); + await userEvent.click(await screen.findByText('rev-1')); + + const dialog = await screen.findByRole('dialog'); + expect(within(dialog).queryByRole('button', { name: /edit/i })).not.toBeInTheDocument(); + expect(within(dialog).queryByRole('button', { name: /delete/i })).not.toBeInTheDocument(); + }); + + it('closes the modal without affecting the underlying list', async () => { + http.on('GET', '/bundle', { body: [aBundle()] }); + + openBundles(); + await userEvent.click(await screen.findByText('rev-1')); + await screen.findByRole('dialog'); + + await userEvent.click(screen.getByRole('button', { name: /close/i })); + + await screen.findByText('rev-1'); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); +}); From e3120400dd7a4f116aa14e05ff3a9e0b992e73e3 Mon Sep 17 00:00:00 2001 From: Schnitz <12687466+CptSchnitz@users.noreply.github.com> Date: Tue, 22 Sep 2026 11:02:03 +0300 Subject: [PATCH 4/8] fix(auth-ui): send full date-time bounds for bundle date filters GET /bundle's createdAfter/createdBefore are date-time, not bare dates. Widen the picked day to its first and last instant (UTC) before sending, so the day stays inclusive on both ends and the request matches the endpoint's actual contract. Co-Authored-By: Claude Sonnet 5 --- apps/auth-ui/src/pages/bundles/BundlesPage.tsx | 9 +++++++-- apps/auth-ui/tests/bundles/bundles-list.spec.tsx | 10 +++++----- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/apps/auth-ui/src/pages/bundles/BundlesPage.tsx b/apps/auth-ui/src/pages/bundles/BundlesPage.tsx index 42b1a32..efae584 100644 --- a/apps/auth-ui/src/pages/bundles/BundlesPage.tsx +++ b/apps/auth-ui/src/pages/bundles/BundlesPage.tsx @@ -22,6 +22,11 @@ const ANY = 'all'; const oneOf = (value: string | null, allowed: readonly T[], fallback: T): T => allowed.includes(value as T) ? (value as T) : fallback; +// The date inputs pick a day; the endpoint filters on a full timestamp. Widening to the +// day's first and last instant keeps the picked day inclusive on both ends. +const startOfDay = (date: string): string => `${date}T00:00:00.000Z`; +const endOfDay = (date: string): string => `${date}T23:59:59.999Z`; + export const BundlesPage = () => { const [searchParams, setSearchParams] = useSearchParams(); const [selectedBundle, setSelectedBundle] = useState(null); @@ -47,8 +52,8 @@ export const BundlesPage = () => { const query = { ...(environment === ANY ? {} : { environment: [environment] }), - ...(createdAfter && { createdAfter }), - ...(createdBefore && { createdBefore }), + ...(createdAfter && { createdAfter: startOfDay(createdAfter) }), + ...(createdBefore && { createdBefore: endOfDay(createdBefore) }), }; const { data, isLoading, isError, error, refetch } = $api.useQuery('get', '/bundle', { params: { query } }); diff --git a/apps/auth-ui/tests/bundles/bundles-list.spec.tsx b/apps/auth-ui/tests/bundles/bundles-list.spec.tsx index dcf7ca7..5ffbc8b 100644 --- a/apps/auth-ui/tests/bundles/bundles-list.spec.tsx +++ b/apps/auth-ui/tests/bundles/bundles-list.spec.tsx @@ -88,17 +88,17 @@ describe('bundles list', () => { await waitFor(() => expect(http.lastRequestFor('GET', '/bundle')?.query.getAll('environment')).toEqual(['prod'])); }); - it('sends the createdAfter and createdBefore date filters to the server', async () => { + it('sends the createdAfter and createdBefore date filters to the server as full timestamps, covering the whole day picked', async () => { http.on('GET', '/bundle', { body: [aBundle()] }); openBundles(); await screen.findByText('rev-1'); await userEvent.type(screen.getByLabelText('Created after'), '2026-01-01'); - await waitFor(() => expect(http.lastRequestFor('GET', '/bundle')?.query.get('createdAfter')).toBe('2026-01-01')); + await waitFor(() => expect(http.lastRequestFor('GET', '/bundle')?.query.get('createdAfter')).toBe('2026-01-01T00:00:00.000Z')); await userEvent.type(screen.getByLabelText('Created before'), '2026-06-01'); - await waitFor(() => expect(http.lastRequestFor('GET', '/bundle')?.query.get('createdBefore')).toBe('2026-06-01')); + await waitFor(() => expect(http.lastRequestFor('GET', '/bundle')?.query.get('createdBefore')).toBe('2026-06-01T23:59:59.999Z')); }); it('combines the environment and date filters into a single request', async () => { @@ -109,8 +109,8 @@ describe('bundles list', () => { const request = http.lastRequestFor('GET', '/bundle'); expect(request?.query.getAll('environment')).toEqual(['stage']); - expect(request?.query.get('createdAfter')).toBe('2026-01-01'); - expect(request?.query.get('createdBefore')).toBe('2026-06-01'); + expect(request?.query.get('createdAfter')).toBe('2026-01-01T00:00:00.000Z'); + expect(request?.query.get('createdBefore')).toBe('2026-06-01T23:59:59.999Z'); }); it('reads the filters back out of the url on load, and restores them across a refresh', async () => { From 974212be1345c7b6d199028fd4e73f33740528aa Mon Sep 17 00:00:00 2001 From: Schnitz <12687466+CptSchnitz@users.noreply.github.com> Date: Tue, 22 Sep 2026 11:50:35 +0300 Subject: [PATCH 5/8] fix(auth-ui): keep bundle details modal usable with large lists Playwright testing against a bundle with 350 connections found two bugs: the whole modal, including its header and close button, scrolled as one block, so scrolling into the list scrolled the close button out of the viewport with no way back to the top short of scrolling back up; and duplicate name+version pairs across entries produced a React duplicate-key warning. Give the header a fixed position and let only the body scroll, cap each entries list to its own bounded, independently scrollable box so a long list doesn't bury the Metadata section, and key rows by index rather than by content, which isn't guaranteed unique at this layer even though the API schema marks it so. Co-Authored-By: Claude Sonnet 5 --- .../src/pages/bundles/BundleDetailsModal.tsx | 19 +++++++++----- .../bundles/bundle-details-modal.spec.tsx | 25 ++++++++++++++++++- 2 files changed, 37 insertions(+), 7 deletions(-) diff --git a/apps/auth-ui/src/pages/bundles/BundleDetailsModal.tsx b/apps/auth-ui/src/pages/bundles/BundleDetailsModal.tsx index 12225da..51a36a8 100644 --- a/apps/auth-ui/src/pages/bundles/BundleDetailsModal.tsx +++ b/apps/auth-ui/src/pages/bundles/BundleDetailsModal.tsx @@ -9,7 +9,10 @@ interface BundleDetailsModalProps { } export const BundleDetailsModal = ({ bundle }: BundleDetailsModalProps) => ( - + // A bundle's assets/connections lists have no upper bound in practice, so the header + // (and its close button) stays outside the scrolling area rather than scrolling away + // with a long list. + Bundle {bundle.id} @@ -17,7 +20,7 @@ export const BundleDetailsModal = ({ bundle }: BundleDetailsModalProps) => ( -
+
@@ -28,7 +31,7 @@ export const BundleDetailsModal = ({ bundle }: BundleDetailsModalProps) => (

Metadata

-
{JSON.stringify(bundle.metadata ?? {}, null, 2)}
+
{JSON.stringify(bundle.metadata ?? {}, null, 2)}
@@ -45,9 +48,13 @@ const BundleEntryList = ({ title, emptyLabel, entries }: { title: string; emptyL

{title}

{entries && entries.length > 0 ? ( -
    - {entries.map((entry) => ( -
  • + // Bounded and independently scrollable: a bundle can carry hundreds of these, and + // the list shouldn't force scrolling past everything else to reach what follows it. +
      + {entries.map((entry, index) => ( + // The index guards against a duplicate name+version pair; the API contract + // marks this array uniqueItems, but nothing enforces that at this layer. +
    • {entry.name} v{entry.version}
    • diff --git a/apps/auth-ui/tests/bundles/bundle-details-modal.spec.tsx b/apps/auth-ui/tests/bundles/bundle-details-modal.spec.tsx index aec481e..368ccd1 100644 --- a/apps/auth-ui/tests/bundles/bundle-details-modal.spec.tsx +++ b/apps/auth-ui/tests/bundles/bundle-details-modal.spec.tsx @@ -1,6 +1,6 @@ import { screen, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { appRoutes } from '../../src/routes'; import { aBundle } from '../bundle-fixtures'; import { http } from '../http-stub'; @@ -69,6 +69,29 @@ describe('bundle details modal', () => { expect(within(dialog).queryByRole('button', { name: /delete/i })).not.toBeInTheDocument(); }); + it('renders a large connections list, keeping the close button reachable and each row uniquely keyed', async () => { + // A real bundle can carry hundreds of connections; two rows share a name/version pair + // (the API contract marks this array uniqueItems, but nothing enforces that here), to + // guard the list against relying on that guarantee for React's row keys. + const manyConnections = Array.from({ length: 320 }, (_, index) => ({ name: `connection-${index % 300}`, version: 1 })); + http.on('GET', '/bundle', { body: [aBundle({ connections: manyConnections })] }); + + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); + + openBundles(); + await userEvent.click(await screen.findByText('rev-1')); + + const dialog = await screen.findByRole('dialog'); + expect(within(dialog).getAllByText('connection-0')).toHaveLength(2); + expect(within(dialog).getByText('connection-299')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /close/i })).toBeInTheDocument(); + + const keyWarning = consoleError.mock.calls.find((call) => typeof call[0] === 'string' && call[0].includes('two children with the same key')); + expect(keyWarning).toBeUndefined(); + + consoleError.mockRestore(); + }); + it('closes the modal without affecting the underlying list', async () => { http.on('GET', '/bundle', { body: [aBundle()] }); From 21148ffe39179b6f126cdf63ec5de3b8730cea45 Mon Sep 17 00:00:00 2001 From: Schnitz <12687466+CptSchnitz@users.noreply.github.com> Date: Tue, 22 Sep 2026 13:05:23 +0300 Subject: [PATCH 6/8] fix(auth-ui): list bundles newest first GET /bundle has no sort parameter, so ordering is whatever the database returns. Sort client-side by createdAt descending so the most recently built bundle is always the first row. Co-Authored-By: Claude Sonnet 5 --- .../auth-ui/src/pages/bundles/BundlesPage.tsx | 6 +++++- .../tests/bundles/bundles-list.spec.tsx | 19 +++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/apps/auth-ui/src/pages/bundles/BundlesPage.tsx b/apps/auth-ui/src/pages/bundles/BundlesPage.tsx index efae584..9711252 100644 --- a/apps/auth-ui/src/pages/bundles/BundlesPage.tsx +++ b/apps/auth-ui/src/pages/bundles/BundlesPage.tsx @@ -27,6 +27,10 @@ const oneOf = (value: string | null, allowed: readonly T[], fa const startOfDay = (date: string): string => `${date}T00:00:00.000Z`; const endOfDay = (date: string): string => `${date}T23:59:59.999Z`; +// The endpoint has no sort parameter, so the newest-first order is applied here rather +// than relied upon from the response. +const byCreatedAtDesc = (a: Bundle, b: Bundle): number => new Date(b.createdAt ?? 0).getTime() - new Date(a.createdAt ?? 0).getTime(); + export const BundlesPage = () => { const [searchParams, setSearchParams] = useSearchParams(); const [selectedBundle, setSelectedBundle] = useState(null); @@ -127,7 +131,7 @@ export const BundlesPage = () => {
) : ( - + )}
diff --git a/apps/auth-ui/tests/bundles/bundles-list.spec.tsx b/apps/auth-ui/tests/bundles/bundles-list.spec.tsx index 5ffbc8b..6bfe795 100644 --- a/apps/auth-ui/tests/bundles/bundles-list.spec.tsx +++ b/apps/auth-ui/tests/bundles/bundles-list.spec.tsx @@ -48,6 +48,25 @@ describe('bundles list', () => { expect(within(row).getByText('42')).toBeInTheDocument(); }); + it('orders bundles with the newest createdAt first, regardless of the order returned by the server', async () => { + http.on('GET', '/bundle', { + body: [ + aBundle({ id: 1, createdAt: '2026-01-01T00:00:00.000Z' }), + aBundle({ id: 3, createdAt: '2026-03-01T00:00:00.000Z' }), + aBundle({ id: 2, createdAt: '2026-02-01T00:00:00.000Z' }), + ], + }); + + openBundles(); + + const table = await screen.findByRole('table'); + const idsInOrder = within(table) + .getAllByRole('row') + .slice(1) + .map((row) => within(row).getAllByRole('cell').at(-1)?.textContent); + expect(idsInOrder).toEqual(['3', '2', '1']); + }); + it('shows a loading state while the request is in flight', async () => { http.on('GET', '/bundle', { body: [aBundle()], delayMs: 30 }); From f9057fdac84eea80115c8aee84da9c0febe36fe3 Mon Sep 17 00:00:00 2001 From: Schnitz <12687466+CptSchnitz@users.noreply.github.com> Date: Thu, 24 Sep 2026 14:13:05 +0300 Subject: [PATCH 7/8] fix(auth-ui): interpret bundle date filters in the user's local day MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit createdAfter/createdBefore were built by string-concatenating the date input's value with a hardcoded UTC offset, so "the day the user picked" was actually being read as UTC. Anyone outside UTC would get a filter shifted by their offset from local midnight — a bundle built just after local midnight on the picked day could fall on the wrong side of the boundary and get excluded. Construct the boundary from the local calendar day instead (matching the local-day, UTC-on-the-wire pattern ClientsPage already uses via its Calendar picker) and convert to the equivalent UTC instant for the request. Co-Authored-By: Claude Sonnet 5 --- .../auth-ui/src/pages/bundles/BundlesPage.tsx | 17 ++++++++--- .../tests/bundles/bundles-list.spec.tsx | 30 +++++++++++++++---- 2 files changed, 38 insertions(+), 9 deletions(-) diff --git a/apps/auth-ui/src/pages/bundles/BundlesPage.tsx b/apps/auth-ui/src/pages/bundles/BundlesPage.tsx index 9711252..4ec5c98 100644 --- a/apps/auth-ui/src/pages/bundles/BundlesPage.tsx +++ b/apps/auth-ui/src/pages/bundles/BundlesPage.tsx @@ -22,10 +22,19 @@ const ANY = 'all'; const oneOf = (value: string | null, allowed: readonly T[], fallback: T): T => allowed.includes(value as T) ? (value as T) : fallback; -// The date inputs pick a day; the endpoint filters on a full timestamp. Widening to the -// day's first and last instant keeps the picked day inclusive on both ends. -const startOfDay = (date: string): string => `${date}T00:00:00.000Z`; -const endOfDay = (date: string): string => `${date}T23:59:59.999Z`; +// The date input hands back a bare YYYY-MM-DD with no timezone attached — the day the +// user pointed at in their own local time, not in UTC. `new Date(y, m, d, ...)` reads +// its numeric arguments as local time, so the boundary lands on the right side of +// midnight for that user; `.toISOString()` then converts it to the UTC instant the +// endpoint expects. Matches the local-day-picked, UTC-on-the-wire pattern ClientsPage +// already uses via its Calendar picker. +const dayBoundary = (date: string, end: boolean): string => { + const [year, month, day] = date.split('-').map(Number) as [number, number, number]; + return end ? new Date(year, month - 1, day, 23, 59, 59, 999).toISOString() : new Date(year, month - 1, day, 0, 0, 0, 0).toISOString(); +}; + +const startOfDay = (date: string): string => dayBoundary(date, false); +const endOfDay = (date: string): string => dayBoundary(date, true); // The endpoint has no sort parameter, so the newest-first order is applied here rather // than relied upon from the response. diff --git a/apps/auth-ui/tests/bundles/bundles-list.spec.tsx b/apps/auth-ui/tests/bundles/bundles-list.spec.tsx index 6bfe795..62580fc 100644 --- a/apps/auth-ui/tests/bundles/bundles-list.spec.tsx +++ b/apps/auth-ui/tests/bundles/bundles-list.spec.tsx @@ -8,6 +8,26 @@ import { renderRoutes } from '../render'; const openBundles = (search = '') => renderRoutes(appRoutes, `/bundles${search}`); +/** + * Asserts a sent timestamp is the local midnight (or local end-of-instant) of the given + * calendar day, without hardcoding a UTC literal — the conversion depends on the host's + * timezone, so the assertion has to round-trip through the same local getters rather + * than assume a fixed UTC offset. + */ +const expectLocalDayBoundary = (iso: string | null | undefined, year: number, month: number, day: number, end: boolean): void => { + expect(iso).not.toBeNull(); + const parsed = new Date(iso!); + expect([ + parsed.getFullYear(), + parsed.getMonth() + 1, + parsed.getDate(), + parsed.getHours(), + parsed.getMinutes(), + parsed.getSeconds(), + parsed.getMilliseconds(), + ]).toEqual([year, month, day, ...(end ? [23, 59, 59, 999] : [0, 0, 0, 0])]); +}; + describe('bundles list', () => { it('appends a Bundles entry to the sidebar after Assets, leaving the others in place', async () => { http.on('GET', '/bundle', { body: [] }); @@ -107,17 +127,17 @@ describe('bundles list', () => { await waitFor(() => expect(http.lastRequestFor('GET', '/bundle')?.query.getAll('environment')).toEqual(['prod'])); }); - it('sends the createdAfter and createdBefore date filters to the server as full timestamps, covering the whole day picked', async () => { + it('sends the createdAfter and createdBefore date filters to the server as the local midnight/end-of-day of the picked day', async () => { http.on('GET', '/bundle', { body: [aBundle()] }); openBundles(); await screen.findByText('rev-1'); await userEvent.type(screen.getByLabelText('Created after'), '2026-01-01'); - await waitFor(() => expect(http.lastRequestFor('GET', '/bundle')?.query.get('createdAfter')).toBe('2026-01-01T00:00:00.000Z')); + await waitFor(() => expectLocalDayBoundary(http.lastRequestFor('GET', '/bundle')?.query.get('createdAfter'), 2026, 1, 1, false)); await userEvent.type(screen.getByLabelText('Created before'), '2026-06-01'); - await waitFor(() => expect(http.lastRequestFor('GET', '/bundle')?.query.get('createdBefore')).toBe('2026-06-01T23:59:59.999Z')); + await waitFor(() => expectLocalDayBoundary(http.lastRequestFor('GET', '/bundle')?.query.get('createdBefore'), 2026, 6, 1, true)); }); it('combines the environment and date filters into a single request', async () => { @@ -128,8 +148,8 @@ describe('bundles list', () => { const request = http.lastRequestFor('GET', '/bundle'); expect(request?.query.getAll('environment')).toEqual(['stage']); - expect(request?.query.get('createdAfter')).toBe('2026-01-01T00:00:00.000Z'); - expect(request?.query.get('createdBefore')).toBe('2026-06-01T23:59:59.999Z'); + expectLocalDayBoundary(request?.query.get('createdAfter'), 2026, 1, 1, false); + expectLocalDayBoundary(request?.query.get('createdBefore'), 2026, 6, 1, true); }); it('reads the filters back out of the url on load, and restores them across a refresh', async () => { From 20a90105d07166ba941d4edaf8c5f4d3c06b5984 Mon Sep 17 00:00:00 2001 From: Schnitz <12687466+CptSchnitz@users.noreply.github.com> Date: Thu, 24 Sep 2026 15:09:52 +0300 Subject: [PATCH 8/8] refactor(auth-ui): trim bundle feature comments Cut comments down to the non-obvious point, dropping restatements of what the surrounding code already shows. Co-Authored-By: Claude Sonnet 5 --- .../src/pages/bundles/BundleDetailsModal.tsx | 10 +++------- apps/auth-ui/src/pages/bundles/BundlesPage.tsx | 13 ++++--------- .../tests/bundles/bundle-details-modal.spec.tsx | 4 +--- apps/auth-ui/tests/bundles/bundles-list.spec.tsx | 7 +------ 4 files changed, 9 insertions(+), 25 deletions(-) diff --git a/apps/auth-ui/src/pages/bundles/BundleDetailsModal.tsx b/apps/auth-ui/src/pages/bundles/BundleDetailsModal.tsx index 51a36a8..dd24702 100644 --- a/apps/auth-ui/src/pages/bundles/BundleDetailsModal.tsx +++ b/apps/auth-ui/src/pages/bundles/BundleDetailsModal.tsx @@ -9,9 +9,7 @@ interface BundleDetailsModalProps { } export const BundleDetailsModal = ({ bundle }: BundleDetailsModalProps) => ( - // A bundle's assets/connections lists have no upper bound in practice, so the header - // (and its close button) stays outside the scrolling area rather than scrolling away - // with a long list. + // Only the body scrolls, so a long list can't carry the close button off-screen with it. Bundle {bundle.id} @@ -48,12 +46,10 @@ const BundleEntryList = ({ title, emptyLabel, entries }: { title: string; emptyL

{title}

{entries && entries.length > 0 ? ( - // Bounded and independently scrollable: a bundle can carry hundreds of these, and - // the list shouldn't force scrolling past everything else to reach what follows it. + // Bounded so a long list doesn't bury what follows it.
    {entries.map((entry, index) => ( - // The index guards against a duplicate name+version pair; the API contract - // marks this array uniqueItems, but nothing enforces that at this layer. + // uniqueItems isn't enforced at this layer, so the index guards the key.
  • {entry.name} v{entry.version} diff --git a/apps/auth-ui/src/pages/bundles/BundlesPage.tsx b/apps/auth-ui/src/pages/bundles/BundlesPage.tsx index 4ec5c98..d542b45 100644 --- a/apps/auth-ui/src/pages/bundles/BundlesPage.tsx +++ b/apps/auth-ui/src/pages/bundles/BundlesPage.tsx @@ -22,12 +22,8 @@ const ANY = 'all'; const oneOf = (value: string | null, allowed: readonly T[], fallback: T): T => allowed.includes(value as T) ? (value as T) : fallback; -// The date input hands back a bare YYYY-MM-DD with no timezone attached — the day the -// user pointed at in their own local time, not in UTC. `new Date(y, m, d, ...)` reads -// its numeric arguments as local time, so the boundary lands on the right side of -// midnight for that user; `.toISOString()` then converts it to the UTC instant the -// endpoint expects. Matches the local-day-picked, UTC-on-the-wire pattern ClientsPage -// already uses via its Calendar picker. +// Reads the picked day as local time, not UTC, before converting to the wire format — +// same pattern as ClientsPage's Calendar picker. const dayBoundary = (date: string, end: boolean): string => { const [year, month, day] = date.split('-').map(Number) as [number, number, number]; return end ? new Date(year, month - 1, day, 23, 59, 59, 999).toISOString() : new Date(year, month - 1, day, 0, 0, 0, 0).toISOString(); @@ -36,15 +32,14 @@ const dayBoundary = (date: string, end: boolean): string => { const startOfDay = (date: string): string => dayBoundary(date, false); const endOfDay = (date: string): string => dayBoundary(date, true); -// The endpoint has no sort parameter, so the newest-first order is applied here rather -// than relied upon from the response. +// No sort parameter on the endpoint, so newest-first is applied here. const byCreatedAtDesc = (a: Bundle, b: Bundle): number => new Date(b.createdAt ?? 0).getTime() - new Date(a.createdAt ?? 0).getTime(); export const BundlesPage = () => { const [searchParams, setSearchParams] = useSearchParams(); const [selectedBundle, setSelectedBundle] = useState(null); - // The url is external input: an unknown value would otherwise travel to the server as a filter and come back a 400. + // Guards against a bad url value reaching the server as a 400. const environment = oneOf(searchParams.get('environment'), ENVIRONMENTS, ANY); const createdAfter = searchParams.get('createdAfter') ?? ''; const createdBefore = searchParams.get('createdBefore') ?? ''; diff --git a/apps/auth-ui/tests/bundles/bundle-details-modal.spec.tsx b/apps/auth-ui/tests/bundles/bundle-details-modal.spec.tsx index 368ccd1..621505b 100644 --- a/apps/auth-ui/tests/bundles/bundle-details-modal.spec.tsx +++ b/apps/auth-ui/tests/bundles/bundle-details-modal.spec.tsx @@ -70,9 +70,7 @@ describe('bundle details modal', () => { }); it('renders a large connections list, keeping the close button reachable and each row uniquely keyed', async () => { - // A real bundle can carry hundreds of connections; two rows share a name/version pair - // (the API contract marks this array uniqueItems, but nothing enforces that here), to - // guard the list against relying on that guarantee for React's row keys. + // Duplicate name+version pair, despite uniqueItems in the schema. const manyConnections = Array.from({ length: 320 }, (_, index) => ({ name: `connection-${index % 300}`, version: 1 })); http.on('GET', '/bundle', { body: [aBundle({ connections: manyConnections })] }); diff --git a/apps/auth-ui/tests/bundles/bundles-list.spec.tsx b/apps/auth-ui/tests/bundles/bundles-list.spec.tsx index 62580fc..d672fde 100644 --- a/apps/auth-ui/tests/bundles/bundles-list.spec.tsx +++ b/apps/auth-ui/tests/bundles/bundles-list.spec.tsx @@ -8,12 +8,7 @@ import { renderRoutes } from '../render'; const openBundles = (search = '') => renderRoutes(appRoutes, `/bundles${search}`); -/** - * Asserts a sent timestamp is the local midnight (or local end-of-instant) of the given - * calendar day, without hardcoding a UTC literal — the conversion depends on the host's - * timezone, so the assertion has to round-trip through the same local getters rather - * than assume a fixed UTC offset. - */ +/** Round-trips through local getters instead of a hardcoded UTC literal, since the offset depends on the host's timezone. */ const expectLocalDayBoundary = (iso: string | null | undefined, year: number, month: number, day: number, end: boolean): void => { expect(iso).not.toBeNull(); const parsed = new Date(iso!);