Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions e2e/tests/web.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -372,4 +372,56 @@ test.describe('Web SPA', () => {
page.getByTestId('grants-table').getByText('Owner'),
).toBeVisible({ timeout: 10_000 })
})

test('library page renders with lookup cards (M18g)', async ({ page }) => {
await page.goto(`${urls.web}/library`)
await expect(page.getByTestId('library-list-page')).toBeVisible()
await expect(page.getByTestId('library-table')).toBeVisible()
await expect(page.getByTestId('library-search')).toBeVisible()
await expect(page.getByTestId('rule-lookup-card')).toBeVisible()
await expect(page.getByTestId('cci-lookup-card')).toBeVisible()
// Admin has stig-manager:stig so the Import button must render.
await expect(page.getByTestId('import-benchmark-button')).toBeVisible()
})

test('library rule lookup surfaces 404 for unknown rule (M18g)', async ({
page,
}) => {
await page.goto(`${urls.web}/library`)
await expect(page.getByTestId('rule-lookup-card')).toBeVisible()
await page
.getByTestId('rule-lookup-input')
.fill('SV-99999999r1_rule')
await page.getByTestId('rule-lookup-submit').click()
// Either a result or an error must render — the API returns 404
// for unknown rules, which our hook surfaces as an error.
await expect(page.getByTestId('rule-lookup-error')).toBeVisible({
timeout: 10_000,
})
})

test('library cci lookup surfaces 404 for unknown cci (M18g)', async ({
page,
}) => {
await page.goto(`${urls.web}/library`)
await expect(page.getByTestId('cci-lookup-card')).toBeVisible()
await page.getByTestId('cci-lookup-input').fill('999999')
await page.getByTestId('cci-lookup-submit').click()
await expect(page.getByTestId('cci-lookup-error')).toBeVisible({
timeout: 10_000,
})
})

test('library import dialog opens and validates file input (M18g)', async ({
page,
}) => {
await page.goto(`${urls.web}/library`)
await page.getByTestId('import-benchmark-button').click()
const dialog = page.getByTestId('import-stig-dialog')
await expect(dialog).toBeVisible()
await expect(dialog.getByTestId('import-file-input')).toBeVisible()
await expect(dialog.getByTestId('import-clobber-checkbox')).toBeVisible()
// Submit is disabled until a file is chosen.
await expect(dialog.getByTestId('import-stig-submit')).toBeDisabled()
})
})
116 changes: 116 additions & 0 deletions web/src/app/pages/library/cci-lookup.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
// CCI lookup card on the Library landing page.
//
// Hits GET /stigs/ccis/{cci}. The API accepts either "CCI-000366" or
// the six-digit form ("000366") and normalises both, so the user can
// paste whichever form they have in front of them.

import { Search } from 'lucide-react'
import * as React from 'react'

import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { useCci } from '@/lib/api/hooks'

function normaliseInput(raw: string): string {
const s = raw.trim()
if (!s) return ''
// Strip a "CCI-" prefix; the OpenAPI path param matches /^\d{6}$/.
return s.replace(/^cci-/i, '')
}

export function CciLookup() {
const [input, setInput] = React.useState('')
const [target, setTarget] = React.useState<string | undefined>(undefined)
const q = useCci(target)

function onSubmit(e: React.FormEvent) {
e.preventDefault()
const trimmed = normaliseInput(input)
setTarget(trimmed || undefined)
}

return (
<Card data-testid="cci-lookup-card">
<CardHeader>
<CardTitle>CCI lookup</CardTitle>
<CardDescription>
Look up a CCI by its six-digit ID (e.g. <span className="font-mono text-xs">000366</span> or <span className="font-mono text-xs">CCI-000366</span>).
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<form onSubmit={onSubmit} className="flex items-center gap-2">
<Input
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="000366 or CCI-000366"
data-testid="cci-lookup-input"
className="font-mono text-xs"
/>
<Button
type="submit"
size="sm"
disabled={!input.trim()}
data-testid="cci-lookup-submit"
>
<Search className="size-4" /> Look up
</Button>
</form>

{target && q.isLoading && (
<p className="text-sm text-[var(--color-muted-foreground)]">Loading…</p>
)}
{target && q.isError && (
<p className="text-sm text-red-500" data-testid="cci-lookup-error">
{q.error instanceof Error ? q.error.message : 'Lookup failed.'}
</p>
)}
{target && q.data && (
<div className="space-y-2 rounded-md border border-[var(--color-border)] bg-[var(--color-card)]/40 p-3 text-sm" data-testid="cci-lookup-result">
<div className="flex flex-wrap items-baseline gap-2">
<span className="font-mono text-xs">{q.data.cci}</span>
{q.data.status && (
<span className="rounded-sm bg-[var(--color-accent)]/40 px-1.5 py-0.5 text-xs uppercase tracking-wider">
{q.data.status}
</span>
)}
{q.data.type && (
<span className="text-xs text-[var(--color-muted-foreground)]">
{q.data.type}
</span>
)}
</div>
{q.data.definition && (
<p className="text-sm">{q.data.definition}</p>
)}
{q.data.publishdate && (
<p className="text-xs text-[var(--color-muted-foreground)]">
Published: {new Date(q.data.publishdate).toLocaleDateString()}
</p>
)}
{q.data.stigs && q.data.stigs.length > 0 && (
<details>
<summary className="cursor-pointer text-xs font-medium text-[var(--color-muted-foreground)]">
Referenced by {q.data.stigs.length} STIG revision{q.data.stigs.length === 1 ? '' : 's'}
</summary>
<ul className="mt-2 space-y-0.5 text-xs">
{q.data.stigs.map((s, idx) => (
<li key={`${s.benchmarkId}-${s.revisionStr}-${idx}`} className="font-mono">
{s.benchmarkId} · {s.revisionStr}
</li>
))}
</ul>
</details>
)}
</div>
)}
</CardContent>
</Card>
)
}
134 changes: 134 additions & 0 deletions web/src/app/pages/library/detail.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
// /library/:benchmarkId — STIG detail page.
//
// Shows the metadata projection for a single benchmark plus its
// revision list. Per-revision rule/group browsers (which require the
// not-yet-implemented /stigs/{benchmarkId}/revisions/{revisionStr}/rules
// endpoint) are intentionally not surfaced here.

import { ArrowLeft, Loader2 } from 'lucide-react'
import { Link, useParams } from 'react-router-dom'

import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { useSTIG } from '@/lib/api/hooks'

function formatDate(iso: string | null | undefined): string {
if (!iso) return '—'
const d = new Date(iso)
if (Number.isNaN(d.valueOf())) return '—'
return d.toLocaleDateString()
}

export function LibraryDetailPage() {
const params = useParams<{ benchmarkId: string }>()
const benchmarkId = params.benchmarkId ?? ''
const q = useSTIG(benchmarkId)

return (
<div className="space-y-6" data-testid="library-detail-page">
<header className="flex items-center justify-between gap-3">
<div>
<Link
to="/library"
className="inline-flex items-center gap-1 text-sm text-[var(--color-muted-foreground)] hover:text-[var(--color-foreground)]"
>
<ArrowLeft className="size-4" /> Back to Library
</Link>
<h1 className="mt-1 text-3xl font-semibold tracking-tight" data-testid="library-detail-heading">
{benchmarkId}
</h1>
</div>
</header>

{q.isLoading && (
<div className="flex items-center gap-2 text-sm text-[var(--color-muted-foreground)]">
<Loader2 className="size-4 animate-spin" /> Loading benchmark…
</div>
)}
{q.isError && (
<Card>
<CardContent className="pt-6">
<p className="text-sm text-red-500">
{q.error instanceof Error
? q.error.message
: 'Failed to load benchmark.'}
</p>
<Button asChild variant="link" size="sm">
<Link to="/library">Back to Library</Link>
</Button>
</CardContent>
</Card>
)}

{q.data && (
<div className="grid gap-6 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>{q.data.title}</CardTitle>
<CardDescription>Benchmark metadata.</CardDescription>
</CardHeader>
<CardContent>
<dl className="grid grid-cols-[max-content_1fr] gap-x-6 gap-y-2 text-sm">
<dt className="text-[var(--color-muted-foreground)]">Benchmark ID</dt>
<dd className="font-mono text-xs">{q.data.benchmarkId}</dd>
<dt className="text-[var(--color-muted-foreground)]">Latest revision</dt>
<dd className="font-mono text-xs">{q.data.lastRevisionStr ?? '—'}</dd>
<dt className="text-[var(--color-muted-foreground)]">Revision date</dt>
<dd>{formatDate(q.data.lastRevisionDate)}</dd>
<dt className="text-[var(--color-muted-foreground)]">Rule count</dt>
<dd className="tabular-nums">{q.data.ruleCount ?? '—'}</dd>
{q.data.marking && (
<>
<dt className="text-[var(--color-muted-foreground)]">Marking</dt>
<dd>{q.data.marking}</dd>
</>
)}
{q.data.status && (
<>
<dt className="text-[var(--color-muted-foreground)]">Status</dt>
<dd>{q.data.status}</dd>
</>
)}
</dl>
</CardContent>
</Card>

<Card>
<CardHeader>
<CardTitle>Revisions</CardTitle>
<CardDescription>
{q.data.revisionStrs && q.data.revisionStrs.length > 0
? `${q.data.revisionStrs.length} revision${q.data.revisionStrs.length === 1 ? '' : 's'} imported.`
: 'No revisions imported yet.'}
</CardDescription>
</CardHeader>
<CardContent>
{q.data.revisionStrs && q.data.revisionStrs.length > 0 ? (
<ul
className="space-y-1 text-sm"
data-testid="library-detail-revisions"
>
{q.data.revisionStrs.map((r) => (
<li key={r} className="font-mono text-xs">
{r}
</li>
))}
</ul>
) : (
<p className="text-sm text-[var(--color-muted-foreground)]">
Revisions appear here once an XCCDF bundle is imported.
</p>
)}
</CardContent>
</Card>
</div>
)}
</div>
)
}
Loading
Loading