From 9c5e67ab7cf1c922ff27bdad662dac1cf79290b5 Mon Sep 17 00:00:00 2001 From: "lorenzo.padovani@padosoft.com" Date: Tue, 19 May 2026 09:42:21 +0200 Subject: [PATCH 1/3] feat(slice-4b): admin Import-manifest wizard + POST /api/packs/import MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v1.7 slice 4b — wires the "Import manifest" placeholder button on the Packs page to a real wizard that POSTs a YAML pack manifest to a new server endpoint, which parses + validates + installs into the store. ## Server side (@aqa/server) - New `POST /api/packs/import` endpoint (`packs:install` permission) - Body: `{ yaml: string, force?: boolean }` - Parses YAML server-side (the existing `POST /api/packs` accepts pre-parsed JSON; this endpoint accepts raw `pack.yaml` text so the admin doesn't need to YAML-parse client-side) - Validates against `@aqa/schemas/PackManifest` (canonical Zod schema) - On duplicate name without `force=true`, returns 409 with `code: 'EEXIST'` (reuses the structured-error pattern from PR #26) - Adds `yaml` workspace dep to `@aqa/server` - 8 new unit tests: happy-path 201, missing body.yaml, YAML parse error, schema-invalid manifest, 409 duplicate, force=true overwrite, non-boolean force rejected, permission requirement ## Admin UI (@aqa/admin) - New `` modal opened from the (previously silent) "Import manifest" button - YAML textarea + native file picker for loading a `pack.yaml` from disk; selecting a file fills the textarea, submit still requires explicit click - Force-overwrite checkbox surfaces the 409-retry path - Result panel shows installed name + version + next-step guidance (matching pack directory still needs to exist on disk for `aqa run` to discover it) - All errors render inline as `` (a11y carried over from slice 4a) - 4 Playwright e2e tests: open/disabled-submit, happy-path 201, schema-validation 400 keeps wizard open, 409 → toggle force → retry succeeds ## Audit doc - Lines 6850/6853 marked **DONE** (Install pack via PR #26 + Import manifest via this PR) - Slice 4b checklist item marked **SHIPPED** ## Tests - @aqa/server: 34 unit (was 26, +8 import tests) - @aqa/admin: 55 Playwright (51 existing + 4 import wizard) - Lint + typecheck clean Co-Authored-By: Claude Opus 4.7 (1M context) --- bun.lock | 1 + docs/internal/admin-placeholder-audit.md | 4 +- packages/admin/src/app.tsx | 221 +++++++++++++++++- .../admin/test/e2e/import-manifest.e2e.ts | 136 +++++++++++ packages/server/package.json | 3 +- packages/server/src/api.ts | 80 +++++++ packages/server/test/api.test.ts | 110 +++++++++ 7 files changed, 551 insertions(+), 4 deletions(-) create mode 100644 packages/admin/test/e2e/import-manifest.e2e.ts diff --git a/bun.lock b/bun.lock index d957745..d41687e 100644 --- a/bun.lock +++ b/bun.lock @@ -159,6 +159,7 @@ "@aqa/kit": "workspace:*", "@aqa/schemas": "workspace:*", "@aqa/store": "workspace:*", + "yaml": "^2.9.0", }, }, "packages/store": { diff --git a/docs/internal/admin-placeholder-audit.md b/docs/internal/admin-placeholder-audit.md index ac60d3e..eec7a2d 100644 --- a/docs/internal/admin-placeholder-audit.md +++ b/docs/internal/admin-placeholder-audit.md @@ -54,7 +54,7 @@ The line numbers below were captured against commit `~v1.6.0` (post-merge of PR ### Packs (lines ~6850-7040) -- ~~`6850`, `6853` — top-bar "Import manifest" / "Install pack" (slice 3 wires "Install pack" via the new wizard)~~ **DONE (slice 3, PR #26):** the "Install pack" placeholder was renamed to "Create pack" and wired to the new `` component that POSTs to `/api/packs/scaffold`. "Import manifest" remains a placeholder for slice 4b. +- ~~`6850`, `6853` — top-bar "Import manifest" / "Install pack"~~ **DONE:** "Install pack" → "Create pack" wizard (slice 3, PR #26) wired to `POST /api/packs/scaffold`; "Import manifest" wired in slice 4b (PR #28) to a new `` that POSTs to `POST /api/packs/import` — server parses YAML, validates against `@aqa/schemas/PackManifest`, installs via store. 4 Playwright e2e tests cover open/disabled, happy-path 201, schema-validation 400, 409-duplicate-with-force-retry path. 8 server unit tests cover the endpoint contract. - `6910`, `6914`, `6925` — pack row actions (toggle / inspect) - `6986` — pack detail actions - `7028`, `7033`, `7038` — scenario picker inside pack detail @@ -72,7 +72,7 @@ Not yet line-counted — comes after the first 49 fit. Doing all 81 in one PR is unreviewable. Plan: **one PR per page** so each is a manageable review surface. 1. ~~**slice 4a — Findings page actions** (verify, reject, mark-fixed, mark-duplicate row actions). Needs `PATCH /api/findings/:id/status` (already exists in `packages/server` from v1.4). Just wire the UI buttons.~~ **SHIPPED (PR #27).** Kanban terminal-transition modal wired to `POST /api/findings/:id/status`. Drag-and-drop, required-reason capture, error-on-fail, optimistic UI only after server confirmation. The server *persists* the new status to the store; appending a corresponding `finding.status_changed` event to the audit chain is a separate task tracked as a v1.7.x follow-up (requires extending the `EventKind` enum in `@aqa/schemas` and the store's `updateFindingStatus` to append the event). Remaining row actions in clusters/list views (verify/reject inline buttons) also deferred to a follow-up PR — today the kanban is the canonical status-change surface. -2. **slice 4b — Packs page** (Import manifest, Install pack). "Install pack" delegates to the wizard from slice 3; "Import manifest" needs a `POST /api/packs/import` route. +2. ~~**slice 4b — Packs page** (Import manifest, Install pack). "Install pack" delegates to the wizard from slice 3; "Import manifest" needs a `POST /api/packs/import` route.~~ **SHIPPED (PR #28).** `POST /api/packs/import` accepts YAML text, parses + validates against the canonical `PackManifest` schema, installs via store. Admin `` lets users paste YAML or load a file from disk, surfaces 4xx/5xx errors inline, and offers a Force-overwrite path on 409. 3. **slice 4c — Scenarios / Risks / Profiles CRUD** (edit/save/clone/delete). Heaviest slice — needs full CRUD flows on three resources. 4. **slice 4d — Agents page** (install-instructions copy, "Install for X"). Mostly client-side (copy to clipboard, download files). 5. **slice 4e — Replay / Audit / Cost / Queue / Notifications** (export CSV, mark-read, drain, pause). Mix of W + C. diff --git a/packages/admin/src/app.tsx b/packages/admin/src/app.tsx index 49862b7..a669757 100644 --- a/packages/admin/src/app.tsx +++ b/packages/admin/src/app.tsx @@ -4671,6 +4671,219 @@ function CreatePackWizard({ open, onClose }) { } Object.assign(window, { CreatePackWizard }); +// ============================================================= +// Import-manifest wizard (v1.7 slice 4b) +// ============================================================= +// Front-end for POST /api/packs/import. Lets a maintainer paste a +// pack.yaml manifest text (or load a file from disk via the native +// file input), then POSTs the YAML body to the server which parses, +// validates against `@aqa/schemas/PackManifest`, and installs into +// the store. The wizard is deliberately thin — the server is the +// source of truth for validation; client only does empty/whitespace +// checks to avoid wasting a round-trip on obvious failures. +function ImportManifestWizard({ open, onClose }) { + const [yamlText, setYamlText] = React.useState(''); + const [force, setForce] = React.useState(false); + const [submitting, setSubmitting] = React.useState(false); + const [error, setError] = React.useState(null); + const [result, setResult] = React.useState(null); + const toast = useToast(); + + const trimmed = yamlText.trim(); + const canSubmit = trimmed !== '' && !submitting; + + function reset() { + setYamlText(''); + setForce(false); + setError(null); + setResult(null); + setSubmitting(false); + } + function handleClose() { + if (submitting) return; + reset(); + onClose?.(); + } + + async function handleFileChange(e) { + const file = e.target.files?.[0]; + if (!file) return; + try { + const text = await file.text(); + setYamlText(text); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + setError(`Could not read file: ${msg}`); + } + } + + async function handleSubmit() { + if (!canSubmit) return; + setSubmitting(true); + setError(null); + const reqUrl = apiUrl('/api/packs/import'); + try { + const res = await fetch(reqUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ yaml: trimmed, ...(force ? { force: true } : {}) }), + }); + const text = await res.text(); + let parsed = null; + try { + parsed = text ? JSON.parse(text) : null; + } catch { + parsed = null; + } + if (!res.ok) { + const msg = parsed?.error ?? `HTTP ${res.status}`; + setError(msg); + toast.push({ kind: 'error', title: 'Import manifest failed', body: msg }); + return; + } + setResult(parsed); + toast.push({ + kind: 'success', + title: 'Pack imported', + body: parsed?.pack?.name ?? 'unknown pack', + }); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + const full = `Could not reach ${reqUrl} (${msg}). The admin is in mock-data mode or the server is down — the manifest was not imported.`; + setError(full); + toast.push({ kind: 'error', title: 'Import manifest failed', body: full }); + } finally { + setSubmitting(false); + } + } + + return ( + + Done + + ) : ( + <> + + + + ) + } + > + {result ? ( +
+ +
+
+ Name:{' '} + {result.pack?.name} +
+
+ Version:{' '} + {result.pack?.version} +
+
+
+
+ Next: make sure the matching pack directory exists at{' '} + <project>/packs/{result.pack?.name}/ with scenarios + risks. The + store records the manifest; the file tree on disk is what aqa run reads. +
+
+ ) : ( +
+ {error && ( + + {error} + + )} +
+ + +
+ Reads the file into the textarea below — submit happens on Import, not on selection. +
+
+
+ +