From ff21911ac51600552c9a191007d051cd3fc95569 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 17 Jun 2026 07:04:07 +0200 Subject: [PATCH] Harden export preview handling --- CHANGELOG.md | 8 +++++++ backend/app/services/export_service.py | 7 ++++++ .../tests/test_sprint17_export_foundation.py | 22 +++++++++++++++++++ docs/API_CONTRACTS.md | 18 ++++++++++----- docs/CODEX_EXECUTION_LOG.md | 22 +++++++++++++++++++ docs/TODO.md | 3 ++- frontend/README.md | 2 ++ frontend/src/App.tsx | 8 ++----- .../src/components/exports/ExportCenter.tsx | 20 ++++++++++++++--- .../src/components/exports/ExportPreview.tsx | 16 ++++++++++++++ 10 files changed, 111 insertions(+), 15 deletions(-) create mode 100644 frontend/src/components/exports/ExportPreview.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index b549c0a9..10239dc8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ # Changelog +## Sprint 38 Export Center preview hardening (2026-06-17) + +- Prevented HTML project report artifacts from being offered through the JSON preview path in the frontend Export Center. +- Added a clear backend `EXPORT_CONTENT_UNSUPPORTED` response when `/api/v1/exports/{export_id}/content` is called for HTML report artifacts. +- Extracted export JSON preview rendering into `frontend/src/components/exports/ExportPreview.tsx`. +- Added regression coverage for HTML report content-preview rejection. +- No API routes, migrations, product features, provider fetching or AI behavior were introduced. + ## Sprint 37 Tower PostgreSQL collation maintenance (2026-06-17) - Created a live Tower database backup before collation maintenance: `backups/geointel-before-collation-refresh-20260617-065707.dump`. diff --git a/backend/app/services/export_service.py b/backend/app/services/export_service.py index 163a108d..bd039d0d 100644 --- a/backend/app/services/export_service.py +++ b/backend/app/services/export_service.py @@ -192,6 +192,13 @@ class ExportService: if not export: raise AppError(code="EXPORT_NOT_FOUND", message="Export not found", status_code=404) path = ExportService.get_export_download_path(db, export_id) + if export.export_type == "project_report_html" or path.suffix.lower() in {".html", ".htm"}: + raise AppError( + code="EXPORT_CONTENT_UNSUPPORTED", + message="Export content preview is only available for JSON and GeoJSON artifacts. Download HTML report artifacts instead.", + details={"export_type": export.export_type}, + status_code=415, + ) try: content = json.loads(path.read_text(encoding="utf-8")) except json.JSONDecodeError as exc: diff --git a/backend/tests/test_sprint17_export_foundation.py b/backend/tests/test_sprint17_export_foundation.py index 3c49e993..b1cc7c84 100644 --- a/backend/tests/test_sprint17_export_foundation.py +++ b/backend/tests/test_sprint17_export_foundation.py @@ -259,6 +259,28 @@ def test_export_content_reads_persisted_artifact(tmp_path) -> None: assert response.content == {"hello": "world"} +def test_export_content_rejects_html_report_preview(tmp_path) -> None: + export_id = uuid4() + export_path = tmp_path / "report.html" + export_path.write_text("report", encoding="utf-8") + export = Export( + id=export_id, + project_id=uuid4(), + export_type="project_report_html", + storage_path=str(export_path), + metadata_json={"format": "html"}, + ) + db = FakeSession({(Export, export_id): export}) + + try: + ExportService.get_export_content(db, export_id) + except AppError as exc: + assert exc.code == "EXPORT_CONTENT_UNSUPPORTED" + assert exc.status_code == 415 + else: + raise AssertionError("HTML report artifacts must be download-only through the content preview API") + + def test_export_download_path_rejects_missing_artifact(tmp_path) -> None: export_id = uuid4() export = Export( diff --git a/docs/API_CONTRACTS.md b/docs/API_CONTRACTS.md index 67ad86f1..4639599d 100644 --- a/docs/API_CONTRACTS.md +++ b/docs/API_CONTRACTS.md @@ -1010,14 +1010,22 @@ Returns one persisted export record. ### GET `/api/v1/exports/{export_id}/content` Returns the stored JSON artifact content through the standard API envelope. +HTML report artifacts are intentionally download-only through `/download`. +Calling `/content` for `project_report_html` returns: + +```text +code: EXPORT_CONTENT_UNSUPPORTED +message: Export content preview is only available for JSON and GeoJSON artifacts. Download HTML report artifacts instead. +``` ### GET `/api/v1/exports/{export_id}/download` -Downloads the stored JSON/GeoJSON export artifact as a raw file response with -`application/json` content type and a `Content-Disposition` attachment -filename. This endpoint intentionally does not use the JSON envelope because -it is a browser/file-download path; callers that need canonical API JSON should -use `/content`. +Downloads the stored JSON/GeoJSON/HTML export artifact as a raw file response +with a `Content-Disposition` attachment filename. JSON and GeoJSON artifacts +use `application/json`; HTML report artifacts use `text/html`. This endpoint +intentionally does not use the JSON envelope because it is a browser/file-download +path; callers that need canonical API JSON should use `/content` for JSON/GeoJSON +artifacts. ### POST `/api/v1/exports/yolo` diff --git a/docs/CODEX_EXECUTION_LOG.md b/docs/CODEX_EXECUTION_LOG.md index 40daba8e..86170606 100644 --- a/docs/CODEX_EXECUTION_LOG.md +++ b/docs/CODEX_EXECUTION_LOG.md @@ -1,3 +1,25 @@ +## Sprint 38 Export Center preview hardening (2026-06-17) + +Changed: +- Hardened the export content preview path so HTML report artifacts return `EXPORT_CONTENT_UNSUPPORTED` instead of a generic JSON parse failure. +- Updated the frontend Export Center to offer JSON preview only for JSON/GeoJSON artifacts. +- HTML project report artifacts now display as download-only in the export list. +- Extracted export preview rendering from `frontend/src/App.tsx` into `frontend/src/components/exports/ExportPreview.tsx`. +- Updated API/frontend docs, changelog and TODO status. + +Validation: +- `cd backend && python -m pytest tests/test_sprint17_export_foundation.py -q` passed: 10 tests. +- `cd frontend && npm run typecheck` passed. + +Open: +- Full release readiness and Tower redeploy/browser smoke are still recommended before treating this pass as deployed. + +Limitations: +- This pass does not add new export formats, PDF reports, provider fetching, AI inference or migrations. + +Next recommended pass: +- Run the full readiness gate, frontend build and Tower deploy smoke; then continue with shared workbench orchestration decomposition or export cleanup/history filtering. + ## Sprint 37 Tower PostgreSQL collation maintenance (2026-06-17) Changed: diff --git a/docs/TODO.md b/docs/TODO.md index d4c7f1be..2cbad5c2 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -50,7 +50,8 @@ This file now starts with the current implementation status. Older preparation/b - [x] QA/QC results and map workspace component decomposition. - [x] Docker Compose port/storage/database configuration via `.env` defaults for Unraid. - [x] Single-container `geointel` Unraid compose/template runtime. -- [ ] Further frontend component decomposition for export preview and shared workbench orchestration. +- [x] Export preview component decomposition and HTML report download-only UX hardening. +- [ ] Further frontend shared workbench orchestration decomposition. ## Sprint 8 status diff --git a/frontend/README.md b/frontend/README.md index ef1da1c9..599d8fbb 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -208,6 +208,8 @@ React + TypeScript + MapLibre foundation for project/area/dataset workflow. - Production builds split application code, React vendor code and MapLibre vendor code into separate chunks. - The MapLibre chunk is intentionally larger than generic app chunks because it contains the GIS map runtime; the Vite warning threshold is set to keep this known vendor dependency visible without warning on every release build. +- Export preview rendering lives in `src/components/exports/ExportPreview.tsx`. +- The Export Center only offers JSON preview for JSON/GeoJSON artifacts; HTML project reports are shown as download-only artifacts. ## Raster dependency visibility diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 49eb4fc4..5e40a08a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -9,6 +9,7 @@ import { DatasetDetailPanel } from './components/datasets/DatasetDetailPanel' import { DatasetPanel } from './components/datasets/DatasetPanel' import { DetectionLab } from './components/detection/DetectionLab' import { ExportCenter } from './components/exports/ExportCenter' +import { ExportPreview } from './components/exports/ExportPreview' import { MapWorkspace } from './components/map/MapWorkspace' import { AreaPanel } from './components/project/AreaPanel' import { ProjectPanel } from './components/project/ProjectPanel' @@ -740,12 +741,7 @@ function App(): JSX.Element { onPreviewContent={previewExportContent} onDownload={downloadExportArtifact} /> - {exportPreview ? ( -
-

Export Preview

-
{JSON.stringify(exportPreview, null, 2)}
-
- ) : null} + status: {item.status}
path: {item.storage_path}
export id: {item.id}
- + {canPreviewJson(item) ? ( + + ) : ( +
Preview: download-only HTML artifact
+ )} diff --git a/frontend/src/components/exports/ExportPreview.tsx b/frontend/src/components/exports/ExportPreview.tsx new file mode 100644 index 00000000..c4941694 --- /dev/null +++ b/frontend/src/components/exports/ExportPreview.tsx @@ -0,0 +1,16 @@ +interface ExportPreviewProps { + content: Record | null +} + +export function ExportPreview({ content }: ExportPreviewProps): JSX.Element | null { + if (!content) { + return null + } + + return ( +
+

Export Preview

+
{JSON.stringify(content, null, 2)}
+
+ ) +}