Skip to content

Commit 3c55fcf

Browse files
feat(reports): attestation kind selector + kind-aware CSV download (B1b) (#642)
Wire the Framework Attestation kind (B1a backend) into the Reports UI: - Header gains a report-kind selector (Executive / Attestation), a host:write affordance beside the scope pickers. The generate body sends kind only when attestation is chosen; executive stays the implicit default. - The detail header's primary download is now kind-aware: an executive report offers Download PDF, an attestation report offers Download CSV (the per-host, per-rule evidence face). JSON (the signed canonical face) is offered for both. downloadReportFace accepts the csv format. - kindLabel maps attestation to "Attestation" for the Library type chip. - Widen the Report.kind wire enum to [executive, attestation] and regenerate server.gen.go + schema.d.ts. Spec frontend-reports v1.6.0: C-09 + AC-10 (source-inspection test). AC-07 assertion updated for the kind-aware primary control.
1 parent e632f5e commit 3c55fcf

6 files changed

Lines changed: 456 additions & 360 deletions

File tree

api/openapi.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5403,7 +5403,7 @@ components:
54035403
title: {type: string}
54045404
kind:
54055405
type: string
5406-
enum: [executive]
5406+
enum: [executive, attestation]
54075407
scope_label: {type: string}
54085408
scope: {$ref: '#/components/schemas/ReportScope'}
54095409
data_as_of: {type: string, format: date-time}

frontend/src/api/schema.d.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3585,7 +3585,7 @@ export interface components {
35853585
id: string;
35863586
title: string;
35873587
/** @enum {string} */
3588-
kind: "executive";
3588+
kind: "executive" | "attestation";
35893589
scope_label: string;
35903590
scope: components["schemas"]["ReportScope"];
35913591
/** Format: date-time */

frontend/src/pages/reports/ReportsPage.tsx

Lines changed: 48 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ function asExecutiveContent(content: Report['content']): ExecutiveContent {
6767

6868
function kindLabel(kind: Report['kind']): string {
6969
if (kind === 'executive') return 'Executive';
70+
if (kind === 'attestation') return 'Attestation';
7071
return kind;
7172
}
7273

@@ -85,7 +86,8 @@ export function ReportsPage() {
8586

8687
const [tab, setTab] = useState<'library' | 'templates' | 'scheduled'>('library');
8788
const [selectedId, setSelectedId] = useState<string | null>(null);
88-
// Scope for the next Generate. '' = all hosts / all frameworks.
89+
// Kind + scope for the next Generate. '' = all hosts / all frameworks.
90+
const [reportKind, setReportKind] = useState<'executive' | 'attestation'>('executive');
8991
const [scopeGroupId, setScopeGroupId] = useState<string>('');
9092
const [scopeFramework, setScopeFramework] = useState<string>('');
9193

@@ -133,7 +135,12 @@ export function ReportsPage() {
133135

134136
const generateMutation = useMutation({
135137
mutationFn: async () => {
136-
const body: { group_id?: string; framework?: string } = {};
138+
const body: {
139+
kind?: 'executive' | 'attestation';
140+
group_id?: string;
141+
framework?: string;
142+
} = {};
143+
if (reportKind === 'attestation') body.kind = 'attestation';
137144
if (scopeGroupId) body.group_id = scopeGroupId;
138145
if (scopeFramework) body.framework = scopeFramework;
139146
const { data, error, response } = await api.POST('/api/v1/reports:generate', { body });
@@ -172,6 +179,29 @@ export function ReportsPage() {
172179
</div>
173180
</div>
174181
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexShrink: 0 }}>
182+
{canGenerate && (
183+
<select
184+
aria-label="Report kind"
185+
value={reportKind}
186+
onChange={(e) => setReportKind(e.target.value as 'executive' | 'attestation')}
187+
disabled={generateMutation.isPending}
188+
title="Executive summary (leadership) or Framework Attestation (auditor CSV evidence)"
189+
style={{
190+
height: 34,
191+
padding: '0 10px',
192+
borderRadius: 'var(--ow-radius-sm, 6px)',
193+
border: '1px solid var(--ow-line)',
194+
background: 'var(--ow-bg-2)',
195+
color: 'var(--ow-fg-0)',
196+
fontFamily: 'inherit',
197+
fontSize: 13,
198+
cursor: generateMutation.isPending ? 'default' : 'pointer',
199+
}}
200+
>
201+
<option value="executive">Executive</option>
202+
<option value="attestation">Attestation</option>
203+
</select>
204+
)}
175205
{canGenerate && (
176206
<select
177207
aria-label="Report scope"
@@ -439,7 +469,7 @@ function LibraryTab({
439469
// session cookie authenticates it (same-origin credentials) and no CSRF
440470
// token is needed; the filename comes from the server's
441471
// Content-Disposition. Errors are surfaced to the caller.
442-
async function downloadReportFace(id: string, format: 'pdf' | 'json'): Promise<void> {
472+
async function downloadReportFace(id: string, format: 'pdf' | 'json' | 'csv'): Promise<void> {
443473
const res = await fetch(`/api/v1/reports/${id}/export?format=${format}`, {
444474
credentials: 'same-origin',
445475
});
@@ -565,12 +595,12 @@ function ReportDetail({
565595
id: string;
566596
onClose: () => void;
567597
}) {
568-
const [downloading, setDownloading] = useState<'pdf' | 'json' | null>(null);
598+
const [downloading, setDownloading] = useState<'pdf' | 'json' | 'csv' | null>(null);
569599
const [downloadError, setDownloadError] = useState<string | null>(null);
570600
const [verifying, setVerifying] = useState(false);
571601
const [verifyResult, setVerifyResult] = useState<VerifyResult | null>(null);
572602

573-
async function onDownload(format: 'pdf' | 'json') {
603+
async function onDownload(format: 'pdf' | 'json' | 'csv') {
574604
setDownloading(format);
575605
setDownloadError(null);
576606
try {
@@ -615,6 +645,16 @@ function ReportDetail({
615645

616646
const resolved = report ?? detailQ.data ?? null;
617647

648+
// The primary face follows the report kind: executive renders a one-page
649+
// PDF, attestation renders the per-(host, rule) CSV evidence bundle. JSON
650+
// is offered for both (it is the signed canonical face).
651+
const isAttestation = resolved?.kind === 'attestation';
652+
const primaryFace: 'pdf' | 'csv' = isAttestation ? 'csv' : 'pdf';
653+
const primaryLabel = isAttestation ? 'Download CSV' : 'Download PDF';
654+
const primaryTitle = isAttestation
655+
? 'Download the per-host, per-rule CSV evidence'
656+
: 'Download the one-page executive PDF';
657+
618658
return (
619659
<div
620660
role="dialog"
@@ -709,9 +749,9 @@ function ReportDetail({
709749
)}
710750
<button
711751
type="button"
712-
onClick={() => onDownload('pdf')}
752+
onClick={() => onDownload(primaryFace)}
713753
disabled={downloading !== null}
714-
title="Download the one-page executive PDF"
754+
title={primaryTitle}
715755
style={{
716756
height: 32,
717757
padding: '0 12px',
@@ -727,7 +767,7 @@ function ReportDetail({
727767
whiteSpace: 'nowrap',
728768
}}
729769
>
730-
{downloading === 'pdf' ? 'Preparing…' : 'Download PDF'}
770+
{downloading === primaryFace ? 'Preparing…' : primaryLabel}
731771
</button>
732772
<button
733773
type="button"

frontend/tests/pages/reports.test.ts

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@
1010
// honest loading / empty / error states via apiErrorMessage
1111
// AC-04 only mutation is the generate POST (no PUT/DELETE); --ow-* tokens;
1212
// no prose em-dash
13+
// AC-10 report-kind selector (executive/attestation) drives the generate
14+
// body; kind-aware primary download (csv for attestation, pdf
15+
// otherwise); kindLabel maps attestation -> "Attestation"
1316

1417
import { describe, expect, test } from 'vitest';
1518
import { readFileSync } from 'node:fs';
@@ -119,10 +122,12 @@ describe('frontend-reports — reports library page', () => {
119122
expect(PAGE_SRC.includes('Authorization')).toBe(false);
120123
// The blob is saved via an object URL.
121124
expect(PAGE_SRC).toContain('URL.createObjectURL');
122-
// The detail renders Download PDF + JSON controls calling onDownload,
123-
// with an in-flight disabled state and an error surface.
125+
// The detail renders a primary (kind-aware) download + a JSON control
126+
// calling onDownload, with an in-flight disabled state and an error
127+
// surface. (The primary face is computed from the report kind in B1b;
128+
// the literal "Download PDF" label still appears for executive reports.)
124129
expect(PAGE_SRC).toContain('Download PDF');
125-
expect(PAGE_SRC).toMatch(/onDownload\('pdf'\)/);
130+
expect(PAGE_SRC).toMatch(/onDownload\(primaryFace\)/);
126131
expect(PAGE_SRC).toMatch(/onDownload\('json'\)/);
127132
expect(PAGE_SRC).toContain('downloading');
128133
expect(PAGE_SRC).toContain('downloadError');
@@ -159,6 +164,24 @@ describe('frontend-reports — reports library page', () => {
159164
expect(PAGE_SRC).toMatch(/if \(scopeFramework\) body\.framework = scopeFramework/);
160165
});
161166

167+
// @ac AC-10
168+
test('frontend-reports/AC-10 — report-kind selector + kind-aware download', () => {
169+
// A kind select bound to reportKind, defaulting to executive.
170+
expect(PAGE_SRC).toContain('reportKind');
171+
expect(PAGE_SRC).toMatch(/<select[\s\S]*?value=\{reportKind\}/);
172+
expect(PAGE_SRC).toContain('aria-label="Report kind"');
173+
// The generate body sets kind only when attestation is chosen.
174+
expect(PAGE_SRC).toMatch(/if \(reportKind === 'attestation'\) body\.kind = 'attestation'/);
175+
// The primary download is kind-aware: csv face for attestation, pdf
176+
// otherwise, with a Download CSV / Download PDF label switch.
177+
expect(PAGE_SRC).toMatch(/primaryFace[^\n]*isAttestation \? 'csv' : 'pdf'/);
178+
expect(PAGE_SRC).toContain('Download CSV');
179+
// downloadReportFace accepts the csv format.
180+
expect(PAGE_SRC).toMatch(/format: 'pdf' \| 'json' \| 'csv'/);
181+
// The type chip labels attestation reports "Attestation".
182+
expect(PAGE_SRC).toMatch(/kind === 'attestation'\) return 'Attestation'/);
183+
});
184+
162185
// @ac AC-04
163186
test('frontend-reports/AC-04 — generate is the only mutation, tokens, no em-dash', () => {
164187
// The only mutating call is the generate POST; no PUT/DELETE.

0 commit comments

Comments
 (0)