mirror of
https://github.com/azaion/ui.git
synced 2026-06-21 09:21:10 +00:00
[AZ-447] autodev Steps 1-4 baseline: docs, tests, refactor specs
Captures the full output of autodev existing-code Phase A through Step 4 (Code Testability Revision) for the Azaion UI workspace: - Step 1 Document: _docs/02_document/ (FINAL_report, architecture, glossary, components/, modules/, diagrams/, system-flows, module-layout) plus _docs/00_problem/ + _docs/01_solution/ + _docs/legacy/ + _docs/how_to_test + README. - Step 2 Architecture Baseline: architecture_compliance_baseline.md. - Step 3 Test Spec: _docs/02_document/tests/ (environment, test-data, blackbox/performance/resilience/security/ resource-limit tests, traceability-matrix), enum_spec_snapshot, expected_results/results_report.md (98 rows), plus the run-tests.sh + run-performance-tests.sh runners. - Step 4 Code Testability Revision: 01-testability-refactoring/ run dir (list-of-changes C01-C07, deferred_to_refactor, analysis/research_findings + refactoring_roadmap) and the 7 child task specs AZ-448..AZ-454 under _docs/02_tasks/todo/ plus _dependencies_table.md. - _docs/_autodev_state.md pins the cursor at Step 4 / refactor Phase 4 entry so /autodev resumes cleanly. Epic AZ-447 (UI testability gates) tracks the 7 child tasks that will land in subsequent commits. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,76 @@
|
||||
# Acceptance Criteria — Azaion UI
|
||||
|
||||
> Output of `/document` Step 6c. Criteria derived from **measurable values
|
||||
> already evidenced in code or config**: server-side hard caps, validation
|
||||
> rules, health checks, perf configs, and architectural non-negotiables.
|
||||
> Aspirational targets without a concrete check are explicitly marked.
|
||||
|
||||
**Status**: synthesised-from-verified-docs (Step 6c — `/document`)
|
||||
**Date**: 2026-05-10
|
||||
|
||||
---
|
||||
|
||||
## Format
|
||||
|
||||
Every criterion must have a measurable value. Each row carries a unique ID
|
||||
(`AC-NN`), the criterion, a measurement method, and the source-of-truth.
|
||||
|
||||
| AC | Criterion | Measurable value | How to measure | Source |
|
||||
|----|-----------|------------------|----------------|--------|
|
||||
| AC-01 | Authenticated requests carry the HttpOnly refresh cookie | `credentials:'include'` on every authenticated `fetch` and on the refresh call | Static check (linter / test) on `src/api/client.ts` and `src/auth/AuthContext.tsx`; runtime test that 401 → POST refresh → retry succeeds | `src/api/client.ts:44`; `_docs/02_document/04_verification_log.md` F2 |
|
||||
| AC-02 | Bearer is never written to client storage | Zero `localStorage.*` / `sessionStorage.*` calls touching the bearer | Code-search regression test (Grep on `src/`) | P3; `_docs/02_document/architecture.md` § 7 |
|
||||
| AC-03 | Refresh cookie attributes | Cookie issued by `admin/` MUST carry `Secure HttpOnly SameSite=Strict` | Server-side concern; UI test asserts the cookie is non-readable from JS (`document.cookie` does not contain the refresh token) | `_docs/02_document/architecture.md` § 7 |
|
||||
| AC-04 | Numeric enums match the suite spec on the wire | `AnnotationStatus`, `MediaStatus`, `Affiliation`, `CombatReadiness` numeric values match the spec verbatim | Unit test asserting each enum's values; contract test on every `api.*()` payload using these enums | P9; `src/types/index.ts`; `04_verification_log.md` enum drift |
|
||||
| AC-05 | Annotation save endpoint | Save POSTs to `/api/annotations/annotations` (doubly-prefixed) | Integration test asserting the URL and body shape (must include `Source`, `WaypointId`, `videoTime`) | `src/features/annotations/AnnotationsPage.tsx:39`; `04_verification_log.md` F5 + finding #32 |
|
||||
| AC-06 | Selected-flight persistence path | Selection persists via `PUT /api/annotations/settings/user` with `{selectedFlightId}` (NOT a dedicated `/api/flights/select` endpoint) | Integration test on `FlightContext.selectFlight` round trip | `src/components/FlightContext.tsx:24,31,34,44`; `04_verification_log.md` F3 |
|
||||
| AC-07 | Bulk-validate works | `POST /api/annotations/dataset/bulk-status` transitions selected items to `AnnotationStatus.Validated` | E2E test: select N items → click Validate → assert status update | `src/features/dataset/DatasetPage.tsx:65-73,142-146`; `04_verification_log.md` F9 |
|
||||
| AC-08 | Live-GPS SSE per selected flight | `createSSE('/api/flights/${flightId}/live-gps', ...)` is open while a flight is selected; closes on unselect | Integration test: select flight, observe EventSource open; deselect, observe close | `src/features/flights/FlightsPage.tsx:67`; F13 |
|
||||
| AC-09 | Annotation-status SSE | `createSSE('/api/annotations/annotations/events', ...)` open during `06_annotations` page lifetime | Integration test on subscribe / unsubscribe | `src/features/annotations/AnnotationsSidebar.tsx:25`; F14 |
|
||||
| AC-10 | Upload size cap | Server-side hard cap is `client_max_body_size 500M`; UI error path on 413 produces a user-visible message | nginx config check; integration test posts 501 MB → asserts 413 + UI surfaces | `nginx.conf` `client_max_body_size 500M`; `architecture.md` § 6 |
|
||||
| AC-11 | Bundle size budget | Initial JS (gzipped) ≤ **~2 MB** target | `vite build` artifact size measured in CI; **no gate today** — adding the gate is a Phase B task | `architecture.md` § 6 NFR row "Bundle size" — **target, not currently enforced** |
|
||||
| AC-12 | i18n coverage | Every user-visible string has both an `en.json` and `ua.json` entry; no string literals in components beyond proper-noun acronyms | Lint rule + assertion test that `Object.keys(en) === Object.keys(ua)` | P6; `src/i18n/i18n.ts` |
|
||||
| AC-13 | i18n language detection / persistence | `i18next` `lng` resolves from a detector (cookie / `Accept-Language`) and persists across reloads. **Currently `lng:'en'` is hardcoded** — Step 4 fix | Manual + integration test that toggling language in Header survives reload | `src/i18n/i18n.ts`; finding |
|
||||
| AC-14 | Destructive actions require `ConfirmDialog` | Class delete (`AdminPage.handleDeleteClass`) and other destructive flows MUST present `ConfirmDialog`; **`alert()` is forbidden** | Static check + integration test for delete flows | O10; finding B4; `MediaList` `alert()` finding |
|
||||
| AC-15 | a11y — `ConfirmDialog` | `role=dialog` + `aria-modal=true` + focus-trap + Esc-to-cancel | Component test using `@testing-library/react` | finding (`ConfirmDialog` lacks `aria-modal/role=dialog`) |
|
||||
| AC-16 | a11y — Header flight dropdown | `role=combobox`, `aria-expanded`, Esc-to-close, focus-trap, outside-click handler attached only when open | Component test | finding (`Header.tsx` outside-click handler always attached; missing combobox roles) |
|
||||
| AC-17 | a11y — `ProtectedRoute` spinner | `role=status` + accessible label; loading state has a timeout | Component test asserting a11y attributes; integration test asserting timeout fallback | finding |
|
||||
| AC-18 | Browser support | Chromium-based + Firefox latest 2 versions render the SPA correctly | Manual smoke (no `browserslist` enforcement today) | `architecture.md` § 6 — **manual / aspirational** |
|
||||
| AC-19 | Mobile responsiveness | Header bottom-nav variant renders at < 768 px; main pages render at ≥ 768 px | Manual smoke at the two breakpoints | `Header.tsx:113-129`; `architecture.md` § 6 |
|
||||
| AC-20 | OpenWeatherMap key NOT in source | `import.meta.env.VITE_OPENWEATHERMAP_API_KEY` (or proxied via suite); zero hardcoded keys in any `src/` or `mission-planner/` module | Static check (regex against the current literal); CI step | P10; `mission-planner/src/utils/flightPlanUtils.ts:60` (current violation, Step 4 fix) |
|
||||
| AC-21 | UserSettings persistence — panel widths | Panel-width changes via `useResizablePanel` write back to `PUT /api/annotations/settings/user`; reload restores widths | Integration test: change width → reload → assert restored | P11; `src/hooks/useResizablePanel.ts` (current violation) |
|
||||
| AC-22 | RBAC client-side route gates | `/admin` and `/settings` redirect non-privileged users to `/flights` (or `/login` if not authenticated). Server-side 403 is the authoritative gate; UI gate is convenience | Integration test: log in as non-admin → navigate to `/admin` → assert redirect | finding (`/admin` route lacks role-gate — security PRIORITY) |
|
||||
| AC-23 | Auth refresh transparency | One refresh = one network round trip; **no UI re-render past `<ProtectedRoute>`** | Integration test asserting `<ProtectedRoute>` does not unmount during refresh | `architecture.md` § 6 NFR row "Auth refresh"; `04_verification_log.md` F2 |
|
||||
| AC-24 | SSE bearer-rotation handling | When the bearer rotates (refresh), open SSE connections **must** reconnect with the new bearer | Integration test: open SSE → trigger refresh → assert reconnection. **Currently NOT implemented (Step 8 hardening)** | `ADR-008`; `architecture.md` § 7 |
|
||||
| AC-25 | Detect endpoint correctness | Sync image detect uses `POST /api/detect/${mediaId}`. **Async video detect (`F7`) — when implemented in Phase B — uses `POST /api/detect/video/${mediaId}` returning a job ID + SSE on `/api/detect/stream/${jobId}`**. Long-video flows MUST send `X-Refresh-Token` (per `_docs/10_auth.md`) | Integration tests per path | `src/features/annotations/AnnotationsSidebar.tsx:39`; F6 / F7 / F14 |
|
||||
| AC-26 | Numeric input hygiene | Numeric form inputs in `09_settings` and `08_admin` reject empty input rather than silently writing `0` | Component tests on `parseInt(v) || 0` patterns (currently a finding) | finding B4 |
|
||||
| AC-27 | Save error surfacing | `09_settings` save handlers (`saveSystem`, `saveDirs`) use `try/finally` to reset `saving:true`; failure is surfaced via toast / inline error | Integration test that simulates a 500 on PUT and asserts state reset | finding B4 |
|
||||
| AC-28 | Annotation overlay time window | The on-canvas annotation overlay window is asymmetric `[-50 ms, +150 ms]` around the current frame (matches WPF source `_thresholdBefore=50ms / _thresholdAfter=150ms`). **Currently symmetric ±200 ms** — Step 4 fix | Component test asserting overlay membership at `currentTime ± 50/150 ms` | finding #6; `04_verification_log.md` §2d |
|
||||
| AC-29 | `mediaType` is typed | All `mediaType` references use the `MediaType` enum (`None=0`, `Image=1`, `Video=2`); zero magic literals | Static check (Grep `mediaType\s*===\s*[0-9]`) | finding #5 / #10; P9 |
|
||||
| AC-30 | Class delete confirmation | `AdminPage.handleDeleteClass` shows `ConfirmDialog` before issuing `DELETE /api/admin/classes/${id}` | Integration test | finding B4 |
|
||||
| AC-31 | `mission-planner/` is not in the production bundle | `vite build` output does not include any `mission-planner/**` chunk | Bundle inspection; static-import check | `vite.config.ts`; `ADR-009`; P2 |
|
||||
| AC-32 | CI tags + labels | Image is pushed with `${branch}-arm` tag and OCI labels (`org.opencontainers.image.{revision,created,source}`) | Pipeline assertion on the push step | `.woodpecker/build-arm.yml` |
|
||||
| AC-33 | Production runtime is `nginx:alpine` only | Final image stage is `nginx:alpine`; no Node.js binary in the production image | Container inspection (`docker inspect`) | `Dockerfile` |
|
||||
| AC-34 | nginx routes 9 services | `nginx.conf` declares `/api/admin/`, `/api/flights/`, `/api/annotations/`, `/api/detect/`, `/api/loader/`, `/api/gps-denied-desktop/`, `/api/gps-denied-onboard/`, `/api/autopilot/`, `/api/resource/` — each strips its `/api/<service>/` prefix | Config assertion test | `nginx.conf`; `ADR-006` |
|
||||
| AC-35 | Manual bbox draw on `CanvasEditor` | A mousedown → mousemove → mouseup gesture on the canvas creates one new local detection with `classNum = selectedClassNum + photoModeOffset` (per AC-38) and `x,y,w,h` (normalised) matching the dragged rectangle within ±1 normalised px-equivalent; the new detection is appended to local state and is rendered immediately | Component test on `CanvasEditor` with synthetic pointer events; verify local-state shape | `components/06_annotations/description.md`; `system-flows.md` Flow F5; `solution.md:165,224` |
|
||||
| AC-36 | 8-handle bbox resize + canvas modifier interactions | (a) Dragging any of the 8 resize handles (4 corners + 4 edge midpoints) of a selected bbox updates only the corresponding edges; (b) `Ctrl+click` on a bbox **adds it to the selection set** (multi-select); (c) `Ctrl+wheel` over the canvas zooms in/out around the cursor; (d) `Ctrl+drag` on empty canvas pans the view. Bboxes have a minimum normalised size > 0 so handle-drag past zero clamps instead of inverting. | Component tests on `CanvasEditor` with synthetic events (one per modifier path); assert resulting bbox / selection set / viewport state | `components/06_annotations/description.md`; `glossary.md:45` (CanvasEditor); `01_legacy_coverage_gaps.md:29-30`; `solution.md:224` |
|
||||
| AC-37 | Class picker (`DetectionClasses` widget) | Widget loads class list from `GET /api/annotations/classes`; **number-key 1–9** (window `keydown`) selects `classes[(num-1) + photoMode]` and emits `onSelect(class.id)`; clicking a class entry emits the same; the rendered visible label index `i+1` matches the hotkey number for that class **within the currently active PhotoMode** (per AC-38). Fallback list is used when the API returns empty or errors. Backend class ordering MUST be `[0..N-1] (Regular), [20..20+N-1] (Winter), [40..40+N-1] (Night)` — when it is not, this AC fails (Step 4 verification candidate). | Component test on `DetectionClasses` with mocked API + simulated keypresses + clicks; contract test asserting backend response ordering on a fixture | `components/03_shared-ui/description.md:37`; `modules/src__components__DetectionClasses.md`; `data_model.md:158`; `_docs/legacy/wpf-era.md` §10 |
|
||||
| AC-38 | PhotoMode switcher (Regular / Winter / Night) | PhotoMode buttons emit values from the set `{0, 20, 40}` (Regular=0, Winter=+20, Night=+40). Switching mode: (a) re-filters the class list to entries whose `photoMode` equals the new mode; (b) if the previously-selected `classNum` is not in the new filtered set, auto-selects the first class of the new mode and emits `onSelect`. On annotation save, the wire `Detection.classNum` (a.k.a. *yoloId*) equals `classId + photoModeOffset`. | Component test on the mode-switch effect + integration test on the save payload | `modules/src__components__DetectionClasses.md` §22, §31-43; `data_model.md:84`; `components/11_class-colors/description.md:31-35`; `ui_design/README.md:127-128`; `ui_design/annotations.html:84-93` |
|
||||
| AC-39 | Tile-splitting endpoint + wire shape | `POST /api/annotations/dataset/{id}/split` exists and is callable from the dataset surface; success response is JSON with HTTP 200. `AnnotationListItem.isSplit: boolean` and `AnnotationListItem.splitTile: string \| null` (YOLO label `<class> <cx> <cy> <w> <h>`) are honored on read. When `isSplit === true` and `splitTile` is non-null, the client parses the 5-token YOLO label without throwing; malformed `splitTile` surfaces a user-visible error (no silent swallow). `DatasetItem.isSplit?: boolean` is read on the dataset list path (parent-suite-doc fix applied — see `_docs/_process_leftovers/2026-05-10_parent-suite-doc-fixes.md`). | Integration test against a fixture response; unit test on the YOLO-label parser with valid + malformed inputs | `components/07_dataset/description.md:28`; `data_model.md:104-105,130,164`; `modules/src__features__annotations.md:31,75`; `modules/src__types__index.md:24-28` |
|
||||
| AC-40 | Tile-zoom auto-zoom on split-image annotation open | When the user opens a `splitTile`-bearing annotation (double-click in `AnnotationsSidebar` or seek via the annotation list), `CanvasEditor` auto-zooms to the tile region encoded by `splitTile` (parsed per AC-39). The visible viewport rectangle equals the tile rectangle within ±1 px on each edge. A small visual tile-zoom indicator (icon / badge) is rendered while the tile zoom is active so the operator knows the view is constrained. **Currently MISSING** — finding #24 in `modules/src__features__annotations.md`; Step 4 / Phase B fix. | Component test on `CanvasEditor` with a `splitTile`-bearing annotation; assert viewport rect + presence of the tile-zoom indicator | `components/06_annotations/description.md:62, 103`; `modules/src__features__annotations.md:75` finding #24; `legacy/wpf-era.md` (OpenAnnotationResult seek + ZoomTo) |
|
||||
|
||||
## Anti-criteria — explicit non-goals
|
||||
|
||||
| AC# | Statement | Source |
|
||||
|-----|-----------|--------|
|
||||
| AC-N1 | The UI does NOT support real-time multi-user collaborative annotation. | F14 caveat: server pushes status events, the UI consumes; no concurrent edit semantics |
|
||||
| AC-N2 | The UI does NOT host any in-browser ML model. All inference is server-side. | `package.json` has no ML libs |
|
||||
| AC-N3 | The UI does NOT support offline mode. (Tile cache for field deployments is a separate, future concern.) | `architecture.md` § 2 |
|
||||
| AC-N4 | The UI does NOT enforce a server-side response signature / checksum on REST replies. (Server is trusted within the suite network.) | absence of any signature library in `package.json` |
|
||||
| AC-N5 | The UI does NOT port WPF Sound Detections or Drone Maintenance — both **dropped** per Step 4.5 decision. | `01_legacy_coverage_gaps.md` Step 4.5 update |
|
||||
|
||||
## Coverage status
|
||||
|
||||
- **Currently met & enforced**: AC-02 (no token storage), AC-05 (annotation save URL — body shape pending), AC-06, AC-07, AC-08, AC-09, AC-10 (server cap; UI surface is a finding), AC-25 (sync path; async path is target-only), AC-31, AC-33, AC-34.
|
||||
- **Currently met but not enforced by CI**: AC-04 (enum values), AC-12 (i18n parity), AC-29 (typed `mediaType`), AC-35 (manual bbox draw), AC-37 (class picker — pending Step 4 backend-ordering verification), AC-38 (PhotoMode switcher).
|
||||
- **Currently violated — Step 4 fix candidates**: AC-01 (bootstrap refresh), AC-13 (i18n detector), AC-14 / AC-30 (class-delete dialog; `alert()` use), AC-15–AC-17 (a11y), AC-20 (OWM key), AC-21 (panel widths), AC-22 (route role-gate), AC-23 (refresh re-render — code-path correct, but bootstrap-refresh fix needed), AC-26 (numeric input hygiene), AC-27 (save error surfacing), AC-28 (overlay window), AC-36 (Ctrl-multi-select / Ctrl-wheel zoom / Ctrl-drag pan flagged "Partially missing"), AC-40 (tile-zoom auto-zoom — finding #24, no consumer of `splitTile` today).
|
||||
- **Phase B targets (not currently in scope of `/document` Step 6)**: AC-11 (bundle gate), AC-18 (browser-list), AC-19 (mobile floor), AC-24 (SSE refresh re-subscribe), AC-25 async path, AC-32 (CI label assertions), AC-39 (tile-split endpoint — parent-suite-doc fix applied for `isSplit`; the YOLO-label parser hardening lands when the splitTile consumer is wired in Phase B).
|
||||
@@ -0,0 +1,166 @@
|
||||
# Data Parameters — Azaion UI
|
||||
|
||||
> Output of `/document` Step 6d. The Azaion UI is a **thin client over a typed
|
||||
> REST + SSE contract**; it carries no database. "Input data" therefore means
|
||||
> the data shapes the SPA consumes (REST response payloads, SSE event
|
||||
> payloads, env config). All claims trace to `_docs/02_document/data_model.md`,
|
||||
> `architecture.md` § 4–5, and per-component descriptions.
|
||||
|
||||
**Status**: synthesised-from-verified-docs (Step 6d — `/document`)
|
||||
**Date**: 2026-05-10
|
||||
|
||||
---
|
||||
|
||||
## Categories of input data
|
||||
|
||||
The SPA consumes four categories:
|
||||
|
||||
1. **Typed REST entities** — see `_docs/02_document/data_model.md` for the
|
||||
full ER map; key shapes summarised below.
|
||||
2. **SSE event payloads** — `live-gps`, `annotation-status`, planned
|
||||
`detect-stream`.
|
||||
3. **Configuration / environment variables** — runtime config injected at
|
||||
build time or via env.
|
||||
4. **Static assets** — translation bundles, icons, design tokens (compiled
|
||||
into the bundle).
|
||||
|
||||
---
|
||||
|
||||
## 1. Typed REST entities (defined in `src/types/index.ts`)
|
||||
|
||||
> Every entity below mirrors the suite's REST contract. Values listed here
|
||||
> match the **suite spec**, which is the source of truth per principle P9.
|
||||
> Where the UI's current TypeScript enum drifts from the spec, the row notes
|
||||
> the drift and the Step 4 fix.
|
||||
|
||||
### Auth
|
||||
|
||||
| Entity | Fields | Source |
|
||||
|--------|--------|--------|
|
||||
| `AuthUser` | `id`, `email`, `name`, `role`, `permissions: string[]`, `aircraftId?` | `02_auth`; `admin/` service |
|
||||
| `LoginRequest` | `{ email, password }` | `POST /api/admin/auth/login` body |
|
||||
| `LoginResponse` | `{ bearer, user: AuthUser }` (refresh cookie set server-side) | `POST /api/admin/auth/login` 200 |
|
||||
|
||||
### Flights
|
||||
|
||||
| Entity | Fields | Source |
|
||||
|--------|--------|--------|
|
||||
| `Flight` | `id`, `name`, `aircraftId`, `startDate?`, `endDate?`, `description?` | `flights/` service |
|
||||
| `Waypoint` | **Spec**: `{ Geopoint: { Lat, Lon, MGRS }, Source, Objective, OrderNum, Height }`. **UI today**: `{ name, latitude, longitude, order }` — drift, finding #20 / Step 4 fix | `05_flights`; `flights/` service |
|
||||
| `Aircraft` | `id`, `name`, `model`, `isDefault`, `serialNumber?` | `flights/` (read+write); `08_admin` mutation |
|
||||
| `LiveGpsEvent` (SSE) | `{ flightId, lat, lon, alt, heading, speed, ts }` | `createSSE('/api/flights/${id}/live-gps')`; F13 |
|
||||
|
||||
### Annotations + Media
|
||||
|
||||
| Entity | Fields | Source |
|
||||
|--------|--------|--------|
|
||||
| `Media` | `id`, `flightId`, `mediaType: MediaType`, `mediaStatus: MediaStatus`, `filename`, `waypointId?`, `videoTime?`, `thumbnail?` | `annotations/` service |
|
||||
| `MediaType` enum | **Spec**: `None=0`, `Image=1`, `Video=2`. **UI**: same. | `00_foundation`; P9 |
|
||||
| `MediaStatus` enum | **Spec**: must include `None`, `Confirmed`, `Error` plus the existing `New`, `AiProcessing`, `AiProcessed`, `ManualCreated`. **UI today**: only `New=0` / `AiProcessing=1` / `AiProcessed=2` / `ManualCreated=3` — drift, Step 4 fix | `00_foundation`; finding |
|
||||
| `AnnotationListItem` | `id`, `mediaId`, `videoTime`, `status: AnnotationStatus`, `source: AnnotationSource`, `detections: Detection[]`, `isSeed?: boolean` | `annotations/` |
|
||||
| `AnnotationStatus` enum | **Spec**: `None=0`, `Created=10`, `Edited=20`, `Validated=30`, `Deleted=40`. **UI today**: `Created=0`, `Edited=1`, `Validated=2` — drift, Step 4 fix per P9 | `00_foundation`; `04_verification_log.md` |
|
||||
| `AnnotationSource` enum | `AI=0`, `Manual=1` (matches spec) | `00_foundation` |
|
||||
| `Detection` | `{ classNum: number, x, y, w, h: number, affiliation: Affiliation, combatReadiness: CombatReadiness, confidence?: number }` (normalised pixel coords) | `06_annotations` |
|
||||
| `Affiliation` enum | **Spec**: must include `None` plus `Unknown`, `Friendly`, `Hostile`. **UI today**: `Unknown=0`, `Friendly=1`, `Hostile=2` — drift, Step 4 fix | finding |
|
||||
| `CombatReadiness` enum | **Spec**: must include `Unknown` plus `NotReady`, `Ready`. **UI today**: `NotReady=0`, `Ready=1` — drift, Step 4 fix | finding |
|
||||
| `DetectionClass` | `{ id, name, color, photoMode, maxSizeM }` | `08_admin` (write) + `annotations/` (read) |
|
||||
| Annotation save body | **Required** (per finding #32): `Source`, `WaypointId`, `videoTime`, plus `mediaId`, `detections`, `status`. **UI today**: missing `Source` and `WaypointId`; uses `time` instead of `videoTime` — Step 4 fix | `06_annotations/AnnotationsPage.tsx` |
|
||||
| `AnnotationStatusEvent` (SSE) | `{ annotationId, mediaId, oldStatus, newStatus, ts }` | `createSSE('/api/annotations/annotations/events')`; F14 |
|
||||
|
||||
### Dataset
|
||||
|
||||
| Entity | Fields | Source |
|
||||
|--------|--------|--------|
|
||||
| `DatasetItem` | `id`, `mediaId`, `classNum`, `status: AnnotationStatus`, `thumbnail`, `isSeed?: boolean`, `isSplit?: boolean` (parent-suite-doc fix applied for `isSplit`) | `07_dataset`; `annotations/` |
|
||||
| `ClassDistributionItem` | `{ classNum, label, color, count }` | `annotations/` |
|
||||
| Bulk-validate body | `{ ids: number[], targetStatus: AnnotationStatus.Validated }` | `POST /api/annotations/dataset/bulk-status` |
|
||||
|
||||
### Settings + Admin
|
||||
|
||||
| Entity | Fields | Source |
|
||||
|--------|--------|--------|
|
||||
| `SystemSettings` | as defined per `09_settings/SettingsPage.tsx` (settings keys per the suite spec) | `annotations/` (`/api/annotations/settings/system`) |
|
||||
| `DirectorySettings` | per `SettingsPage` directory tab | `annotations/` (`/api/annotations/settings/directories`) |
|
||||
| `CameraSettings` | per `SettingsPage` camera tab | `annotations/` |
|
||||
| `UserSettings` | `selectedFlightId?: number`, `panelWidths?: { ... }`, plus other per-user UI state | `annotations/` (`/api/annotations/settings/user`) |
|
||||
| `User` | `id`, `email`, `role`, `isActive`, `createdAt?` | `admin/` |
|
||||
|
||||
### Pagination
|
||||
|
||||
| Entity | Shape | Source |
|
||||
|--------|-------|--------|
|
||||
| `PaginatedResponse<T>` | `{ items: T[], totalCount: number, page: number, pageSize: number }` | shared envelope used by every list endpoint |
|
||||
|
||||
---
|
||||
|
||||
## 2. SSE event payloads
|
||||
|
||||
| Stream | URL | Payload shape | Where consumed |
|
||||
|--------|-----|---------------|----------------|
|
||||
| Live-GPS per flight (F13) | `GET /api/flights/${flightId}/live-gps?token=${bearer}` | `LiveGpsEvent` (see above) | `src/features/flights/FlightsPage.tsx:67` |
|
||||
| Annotation-status events (F14) | `GET /api/annotations/annotations/events?token=${bearer}` | `AnnotationStatusEvent` | `src/features/annotations/AnnotationsSidebar.tsx:25` |
|
||||
| Async detect progress (F7) | `GET /api/detect/stream/${jobId}?token=${bearer}` — **target-only, NOT wired today** | `{ jobId, progress: 0..1, detections?: Detection[], status, ts }` (anticipated) | not consumed today; planned per `04_verification_log.md` F7 |
|
||||
|
||||
Bearer goes in the **query string** (`?token=...`) per `ADR-008` — `EventSource`
|
||||
cannot send headers. Refresh-rotation breaks live SSE; reconnect is missing
|
||||
today (Step 8 hardening per `architecture.md` § Architecture Vision).
|
||||
|
||||
---
|
||||
|
||||
## 3. Configuration / environment variables
|
||||
|
||||
| Variable | Where read | Type | Default | Source |
|
||||
|----------|-----------|------|---------|--------|
|
||||
| `VITE_OPENWEATHERMAP_API_KEY` | (target — Step 4 fix) `mission-planner/src/utils/flightPlanUtils.ts` | string (secret) | currently hardcoded `'335799082893fad97fa36118b131f919'` (must rotate) | P10 violation, Step 4 fix |
|
||||
| `VITE_SATELLITE_TILE_URL` | mission-planner Leaflet `TileLayer` | URL | none (unset breaks satellite imagery) | mission-planner only today |
|
||||
| `AZAION_REVISION` | stamped into the production image at build time | string (commit SHA) | `$CI_COMMIT_SHA` from CI | `Dockerfile`; `.woodpecker/build-arm.yml` |
|
||||
| `REGISTRY_HOST` | CI registry push | string | per pipeline secret | `.woodpecker/build-arm.yml` |
|
||||
| `i18next.lng` | `src/i18n/i18n.ts` | language code | hardcoded `'en'` (Step 4 fix — should resolve from detector) | `i18n.ts`; AC-13 |
|
||||
| nginx upstream hosts | `nginx.conf` | hostnames per service | docker-compose service names | `nginx.conf` |
|
||||
|
||||
The SPA bundle MUST NOT carry secrets at build time — except OpenWeatherMap
|
||||
once it is moved to `.env` (per P10 the proper long-term answer is to proxy
|
||||
the OWM call through `flights/` so no key reaches the browser; the `.env`
|
||||
move is the interim Step 4 testability fix).
|
||||
|
||||
---
|
||||
|
||||
## 4. Static assets
|
||||
|
||||
| Asset | Location | Notes |
|
||||
|-------|----------|-------|
|
||||
| Translation bundles | `src/i18n/en.json`, `src/i18n/ua.json` | English + Ukrainian; key parity is mandatory (AC-12) |
|
||||
| Design tokens | `src/index.css` (`az-bg`, `az-text`, `az-orange`, `az-success`, `az-danger`, `az-primary`, ...) | Tailwind 4; `ADR-005` |
|
||||
| Map icons | `src/features/flights/mapIcons.ts` | defaultIcon CDN URL pinned to `leaflet@1.7.1` (drift — finding) |
|
||||
| Aircraft / waypoint icons | bundled SVG / PNG under `src/features/flights/icons/*` (mission-planner port-source still has the larger set) | `05_flights` |
|
||||
| Detection-class colors | `src/features/annotations/classColors.ts` (logically owned by `11_class-colors`) | file-move pending (P11 / module-layout Verification Needed) |
|
||||
|
||||
---
|
||||
|
||||
## 5. Data flow summary
|
||||
|
||||
1. **Plan flight** — UI fetches `aircrafts` from `flights/`; submits flight +
|
||||
waypoints; receives the persisted flight (today: delete-then-recreate
|
||||
waypoint cycle, finding #19; lossy POST shape, finding #20).
|
||||
2. **Capture media** — out-of-band via the loader / annotations services;
|
||||
the UI surfaces uploaded items via `MediaList` polling.
|
||||
3. **Annotate** — operator edits → `POST /api/annotations/annotations`;
|
||||
`F14` SSE pushes other-user status changes (admin-wide stream,
|
||||
client-side filtered).
|
||||
4. **AI Detect (sync image)** — `POST /api/detect/${id}` returns inline
|
||||
detections. **Used for both image and video today** (silent UX hazard
|
||||
for long videos — `F7` to ship in Phase B).
|
||||
5. **AI Detect (async video — target)** — `POST /api/detect/video/${id}`
|
||||
returns a job ID → SSE on `/api/detect/stream/${jobId}` streams progress.
|
||||
Long videos require `X-Refresh-Token` header per `_docs/10_auth.md`.
|
||||
6. **Curate dataset** — UI queries `annotations/` with status filters;
|
||||
bulk-validate transitions to `AnnotationStatus.Validated`; class-distribution
|
||||
chart loads from `/api/annotations/dataset/class-distribution`.
|
||||
7. **Settings** — system / directory / camera saves go to `annotations/`;
|
||||
aircraft default-toggle goes to `flights/` (cross-service mutation,
|
||||
accepted).
|
||||
8. **GPS-Denied Test Mode (target — F12)** — `.tlog` + video upload to
|
||||
`gps-denied-desktop/`; SITL drives `gps-denied-onboard/`; results render
|
||||
back through `flights/` GPS-Denied tab.
|
||||
|
||||
Full sequence diagrams: `_docs/02_document/system-flows.md`.
|
||||
@@ -0,0 +1,80 @@
|
||||
{
|
||||
"$schema_note": "Pinned numeric values for the suite's wire-format enums per the suite spec. Tests FT-P-04, FT-P-05, FT-P-06 assert that src/types/index.ts matches these values exactly. Drift between the UI and this snapshot is a Step 4 fix candidate (see acceptance_criteria.md AC-04, restrictions.md O7).",
|
||||
"source_of_truth": [
|
||||
{"file": "../_docs/00_database_schema.md", "extracted_at": "2026-05-10T22:00:00+03:00", "note": "Authoritative — the DB schema pins the numeric values directly."},
|
||||
{"file": "../_docs/01_annotations.md", "note": "Wire-format declaration (line 26): all enum fields serialize as numeric integers."},
|
||||
{"file": "../_docs/09_dataset_explorer.md", "note": "JSON examples for Affiliation and CombatReadiness use stale sequential values (affiliation:2 // Hostile, combatReadiness:1 // Ready) and predate the schema's 0/10/20/30 scheme. Parent-suite doc fix pending — record in _docs/_process_leftovers/ when populated."}
|
||||
],
|
||||
"ui_drift_summary": {
|
||||
"AnnotationStatus": {"ui_values": {"Created": 0, "Edited": 1, "Validated": 2}, "spec_values": "see enums.AnnotationStatus", "fix_target": "src/types/index.ts (Step 4)"},
|
||||
"MediaStatus": {"ui_values": {"New": 0, "AiProcessing": 1, "AiProcessed": 2, "ManualCreated": 3}, "spec_values": "see enums.MediaStatus", "fix_target": "src/types/index.ts (Step 4) — UI must add None=0, Confirmed=5, Error=6 and renumber existing members"},
|
||||
"Affiliation": {"ui_values": {"Unknown": 0, "Friendly": 1, "Hostile": 2}, "spec_values": "see enums.Affiliation", "fix_target": "src/types/index.ts (Step 4) — UI must add None=0 and renumber existing members to spec values"},
|
||||
"CombatReadiness": {"ui_values": {"NotReady": 0, "Ready": 1}, "spec_values": "see enums.CombatReadiness", "fix_target": "src/types/index.ts (Step 4) — UI must add Unknown and confirm numeric values via .NET service inspection"},
|
||||
"MediaType": {"ui_values": {"None": 0, "Image": 1, "Video": 2}, "spec_values": "see enums.MediaType", "fix_target": "src/types/index.ts (Step 4) — spec schema order is None|Video|Image which implies Video=1, Image=2; UI has Image=1, Video=2. NEW DRIFT not previously called out in data_parameters.md."}
|
||||
},
|
||||
"enums": {
|
||||
"AnnotationStatus": {
|
||||
"source": "../_docs/00_database_schema.md line 79: enum AnnotationStatus \"None(0)|Created(10)|Edited(20)|Validated(30)|Deleted(40)\"",
|
||||
"values": {"None": 0, "Created": 10, "Edited": 20, "Validated": 30, "Deleted": 40},
|
||||
"verification_pending": false,
|
||||
"notes": "Authoritative. Wire format used by POST /annotations, PATCH /annotations/{id}/status, POST /dataset/bulk-status, and the F14 AnnotationStatusEvent SSE payload."
|
||||
},
|
||||
"MediaStatus": {
|
||||
"source": "../_docs/00_database_schema.md line 66: enum MediaStatus \"None(0)|New(1)|AIProcessing(2)|AIProcessed(3)|ManualCreated(4)|Confirmed(5)|Error(6)\"",
|
||||
"values": {"None": 0, "New": 1, "AIProcessing": 2, "AIProcessed": 3, "ManualCreated": 4, "Confirmed": 5, "Error": 6},
|
||||
"verification_pending": false,
|
||||
"case_note": "Schema uses 'AIProcessing' (uppercase AI); UI uses 'AiProcessing' (camelCase). The wire payload is numeric only, so the TypeScript identifier casing is internal. Recommend matching the spec casing on rename for consistency."
|
||||
},
|
||||
"Affiliation": {
|
||||
"source": "../_docs/00_database_schema.md line 94: enum Affiliation \"None(0)|Friendly(10)|Hostile(20)|Unknown(30)\"",
|
||||
"values": {"None": 0, "Friendly": 10, "Hostile": 20, "Unknown": 30},
|
||||
"verification_pending": false,
|
||||
"stale_example_note": "../_docs/01_annotations.md line 208 and ../_docs/09_dataset_explorer.md line 165 still show 'affiliation: 2 // Affiliation.Hostile' — STALE per the schema. Flag as parent-suite-doc fix leftover."
|
||||
},
|
||||
"CombatReadiness": {
|
||||
"source": "../_docs/00_database_schema.md line 95: enum CombatReadiness \"Ready|NotReady|Unknown\" — numeric values NOT pinned in the schema",
|
||||
"values": {"NotReady": 0, "Ready": 1, "Unknown": 2},
|
||||
"verification_pending": true,
|
||||
"verification_note": "Numeric values inferred as sequential per the spec's member-listing order. The 01_annotations.md JSON example shows 'combatReadiness: 1 // CombatReadiness.Ready' which is consistent with Ready=1. Step 4 .NET-service inspection must confirm or override. Alternative possibility: the spec lists Ready first by intent (Ready=0, NotReady=1, Unknown=2) — schema text 'Ready|NotReady|Unknown' is ambiguous on intent."
|
||||
},
|
||||
"MediaType": {
|
||||
"source": "../_docs/00_database_schema.md line 65: enum MediaType \"None|Video|Image\"",
|
||||
"values": {"None": 0, "Video": 1, "Image": 2},
|
||||
"verification_pending": true,
|
||||
"verification_note": "Numeric values inferred sequentially per schema member-order (None|Video|Image). This contradicts the UI's current src/types/index.ts which has Image=1, Video=2. Step 4 .NET-service inspection must confirm. If the .NET service in fact uses None=0, Image=1, Video=2 (the UI's current shape), then the schema text is misleading and the UI is correct; otherwise the UI is drifted and needs the fix."
|
||||
},
|
||||
"AnnotationSource": {
|
||||
"source": "../_docs/01_annotations.md lines 19-24 (table) + ../_docs/00_database_schema.md line 78 (enum Source \"AI|Manual\")",
|
||||
"values": {"AI": 0, "Manual": 1},
|
||||
"verification_pending": false,
|
||||
"notes": "Both files agree: AI=0, Manual=1. UI matches."
|
||||
},
|
||||
"WaypointSource": {
|
||||
"source": "../_docs/00_database_schema.md line 55: enum WaypointSource \"Auto|Manual\"",
|
||||
"values": {"Auto": 0, "Manual": 1},
|
||||
"verification_pending": true,
|
||||
"verification_note": "Inferred sequentially. Not asserted by any test in this round; recorded for completeness because data_parameters.md / acceptance_criteria.md flag Waypoint POST shape drift for Step 4."
|
||||
},
|
||||
"WaypointObjective": {
|
||||
"source": "../_docs/00_database_schema.md line 56: enum WaypointObjective \"Surveillance|Strike|Recon\"",
|
||||
"values": {"Surveillance": 0, "Strike": 1, "Recon": 2},
|
||||
"verification_pending": true,
|
||||
"verification_note": "Inferred sequentially. Same caveat as WaypointSource — not currently asserted by a UI test."
|
||||
}
|
||||
},
|
||||
"downstream_actions": {
|
||||
"step_4_fixes": [
|
||||
"src/types/index.ts AnnotationStatus → align to {None:0, Created:10, Edited:20, Validated:30, Deleted:40}",
|
||||
"src/types/index.ts MediaStatus → add None, Confirmed, Error; renumber per spec",
|
||||
"src/types/index.ts Affiliation → add None; renumber per spec",
|
||||
"src/types/index.ts CombatReadiness → add Unknown; confirm numerics via .NET inspection; renumber if needed",
|
||||
"src/types/index.ts MediaType → confirm numerics via .NET inspection; renumber if spec schema (Video=1, Image=2) wins"
|
||||
],
|
||||
"parent_suite_doc_fixes": [
|
||||
"../_docs/01_annotations.md line 208 — Affiliation example value (currently affiliation:2 // Hostile) must update to 20 per the schema",
|
||||
"../_docs/09_dataset_explorer.md line 165 — same fix",
|
||||
"Both files — surface CombatReadiness numeric pinning in the table-of-truth (currently only in member listing)"
|
||||
],
|
||||
"phase_3_disposition": "FT-P-04 (AnnotationStatus) gates today. FT-P-05 (MediaStatus + Affiliation) gates today. FT-P-06 partial (detection enum payload check) gates for Affiliation today; CombatReadiness assertion runs with the verification_pending: true caveat (Phase 4 runner script can downgrade it to documentary until Step 4 inspection lands)."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
# Expected Results — Azaion UI
|
||||
|
||||
> Maps every behavioral test trigger (REST request, SSE event, user action,
|
||||
> build/config artifact, static check) to a **quantifiable** expected result.
|
||||
> Sourced from `_docs/00_problem/acceptance_criteria.md` (34 ACs + 5
|
||||
> anti-criteria) and cross-checked against `restrictions.md`,
|
||||
> `data_parameters.md`, and `_docs/02_document/architecture.md`.
|
||||
>
|
||||
> **Project shape note.** The Azaion UI is a thin SPA over a typed REST + SSE
|
||||
> contract; it carries no database and consumes no sample-image / sample-video
|
||||
> input files of its own. "Input" here therefore means a trigger condition
|
||||
> (HTTP request, SSE message, click, build invocation, code search) and the
|
||||
> "expected result" is the observable behavior the test asserts. Almost every
|
||||
> row uses the **behavioral shape** defined in `test-spec/SKILL.md` ("trigger
|
||||
> + observable + quantifiable pass/fail"); a few rows that exchange concrete
|
||||
> JSON bodies use the **input/output shape**.
|
||||
>
|
||||
> No reference files are required at this stage — every observable in the
|
||||
> table below is small enough to inline. If `Phase 2` of `/test-spec` finds a
|
||||
> case that needs a multi-row expected payload (e.g. full
|
||||
> `traceability-matrix` output), a JSON / CSV file will be added in this
|
||||
> directory and referenced from the corresponding row.
|
||||
|
||||
**Status**: agent-drafted (autodev Step 3 — Test Spec, Phase 1 prereq)
|
||||
**Date**: 2026-05-10
|
||||
|
||||
---
|
||||
|
||||
## Result Format Legend
|
||||
|
||||
| Result Type | When to Use | Example |
|
||||
|-------------|-------------|---------|
|
||||
| Exact value | Output must match precisely | `status_code: 200`, `count: 0` |
|
||||
| Tolerance range | Numeric output with acceptable variance | `position ± 50ms`, `width ± 1px` |
|
||||
| Threshold | Output must exceed or stay below a limit | `latency ≤ 500ms`, `count == 0` |
|
||||
| Pattern match | Output must match a string/regex pattern | URL regex, header presence |
|
||||
| Set/count | Output must contain specific items or counts | `keys(en) == keys(ua)` |
|
||||
|
||||
## Comparison Methods
|
||||
|
||||
| Method | Description | Tolerance Syntax |
|
||||
|--------|-------------|------------------|
|
||||
| `exact` | Actual == Expected | N/A |
|
||||
| `numeric_tolerance` | abs(actual - expected) ≤ tolerance | `± <value>` |
|
||||
| `range` | min ≤ actual ≤ max | `[min, max]` |
|
||||
| `threshold_min` | actual ≥ threshold | `≥ <value>` |
|
||||
| `threshold_max` | actual ≤ threshold | `≤ <value>` |
|
||||
| `regex` | actual matches regex pattern | regex string |
|
||||
| `substring` | actual contains substring | substring |
|
||||
| `set_equals` | sets of items match exactly | set notation |
|
||||
| `set_contains` | actual set ⊇ expected | subset notation |
|
||||
| `present` / `absent` | observable exists / does not exist | N/A |
|
||||
|
||||
---
|
||||
|
||||
## Input → Expected Result Mapping
|
||||
|
||||
### Group 1 — Authentication & Token Handling (AC-01 / AC-02 / AC-03 / AC-22 / AC-23 / AC-24)
|
||||
|
||||
| # | Input | Input Description | Expected Result | Comparison | Tolerance | Reference File |
|
||||
|----|-------|-------------------|-----------------|------------|-----------|---------------|
|
||||
| 01 | Any authenticated `fetch` issued via `apiClient` | Outbound API call that requires the bearer | RequestInit MUST include `credentials: 'include'` | exact | N/A | N/A |
|
||||
| 02 | Bootstrap refresh call on app mount (`AuthContext` init) | The refresh-on-load flow before any user interaction | RequestInit MUST include `credentials: 'include'`; cookie sent | exact | N/A | N/A |
|
||||
| 03 | First 401 from `/api/admin/...` while a session is active | Bearer expired mid-session | Sequence: `POST /api/admin/auth/refresh` (cookie-bound) → original request retried with new bearer → final response 200 | exact (sequence) | N/A | N/A |
|
||||
| 04 | Code-search across `src/` for `localStorage.|sessionStorage.` references that touch the bearer | Static check on token-storage policy | `match_count == 0` | exact | N/A | N/A |
|
||||
| 05 | Code-search across `src/` for `document.cookie` reads that target the refresh token | Static check on cookie-readability policy | `match_count == 0` (cookie is HttpOnly server-side; UI must not even attempt) | exact | N/A | N/A |
|
||||
| 06 | Programmatic read of `document.cookie` after login | Browser-level visibility test of the refresh cookie | Returned string MUST NOT contain the refresh-token value | absent | N/A | N/A |
|
||||
| 07 | Refresh-cookie response header from `/api/admin/auth/login` (test fixture) | Set-Cookie attribute audit | Header value matches regex `Secure;.*HttpOnly;.*SameSite=Strict` (order tolerant, case insensitive) | regex | case-insensitive, attribute-order-tolerant | N/A |
|
||||
| 08 | Authenticated user with `role != admin` navigating to `/admin` | RBAC route gate (admin) | Final URL is `/flights`; `<AdminPage>` MUST NOT mount | exact (URL), absent (component) | N/A | N/A |
|
||||
| 09 | Unauthenticated user navigating to `/admin` | RBAC route gate (login) | Final URL is `/login` | exact | N/A | N/A |
|
||||
| 10 | Authenticated user without settings permission navigating to `/settings` | RBAC route gate (settings) | Final URL is `/flights`; `<SettingsPage>` MUST NOT mount | exact, absent | N/A | N/A |
|
||||
| 11 | Auth refresh occurring while the user is on `/flights` (mid-session) | Refresh transparency | `<ProtectedRoute>` MUST NOT unmount its children during refresh; render-counter delta ≤ 1 across refresh | numeric_tolerance | `≤ 1` re-render | N/A |
|
||||
| 12 | Single `/api/admin/auth/refresh` invocation | Refresh round-trip count | Exactly 1 outbound network call observed during one refresh cycle | exact | N/A | N/A |
|
||||
| 13 | Bearer rotation while two SSE streams are open | SSE refresh-rotation handling | Both EventSource instances MUST close, reconnect with new token in query string, and resume within 5 s | exact (close+open count), threshold_max | `≤ 5000ms` | N/A |
|
||||
|
||||
### Group 2 — Wire Contract & Enum Compliance (AC-04 / AC-29)
|
||||
|
||||
| # | Input | Input Description | Expected Result | Comparison | Tolerance | Reference File |
|
||||
|----|-------|-------------------|-----------------|------------|-----------|---------------|
|
||||
| 14 | Read `AnnotationStatus` enum from `src/types/index.ts` | Numeric values must match suite spec | `{None:0, Created:10, Edited:20, Validated:30, Deleted:40}` | exact (key+value map) | N/A | N/A |
|
||||
| 15 | Read `MediaStatus` enum | Numeric values must match suite spec | Members `{None, New, AiProcessing, AiProcessed, ManualCreated, Confirmed, Error}` present; numeric values match the spec map | set_contains, exact (per member) | N/A | N/A |
|
||||
| 16 | Read `Affiliation` enum | Numeric values must match suite spec | Members `{None, Unknown, Friendly, Hostile}` present; numeric values match the spec map | set_contains, exact | N/A | N/A |
|
||||
| 17 | Read `CombatReadiness` enum | Numeric values must match suite spec | Members `{Unknown, NotReady, Ready}` present; numeric values match the spec map | set_contains, exact | N/A | N/A |
|
||||
| 18 | Outbound payload of `POST /api/annotations/annotations` containing `status` field | Wire-format check | `body.status` is a number from the `AnnotationStatus` value set | set_contains | N/A | N/A |
|
||||
| 19 | Outbound payload containing a `Detection` array | Per-detection wire check | Every `detection.affiliation ∈ Affiliation values` and `detection.combatReadiness ∈ CombatReadiness values` | set_contains (per element) | N/A | N/A |
|
||||
| 20 | Code-search `src/` for `mediaType\s*[!=]==?\s*[0-9]` | Magic-literal hygiene for `MediaType` | `match_count == 0` | exact | N/A | N/A |
|
||||
| 21 | Code-search `src/` for `mediaType\s*[!=]==?\s*['"]` | Magic-string hygiene for `MediaType` | `match_count == 0` | exact | N/A | N/A |
|
||||
|
||||
### Group 3 — Annotations endpoints, payload, SSE, overlay window (AC-05 / AC-09 / AC-25 / AC-28)
|
||||
|
||||
| # | Input | Input Description | Expected Result | Comparison | Tolerance | Reference File |
|
||||
|----|-------|-------------------|-----------------|------------|-----------|---------------|
|
||||
| 22 | Save action in `<AnnotationsPage>` | Annotation save endpoint | Exactly one `POST` to URL matching `^/api/annotations/annotations$` | exact (count + URL) | N/A | N/A |
|
||||
| 23 | Annotation save body | Required fields | Body is JSON containing keys `{Source, WaypointId, videoTime, mediaId, detections, status}` (no `time` key) | set_contains, absent (`time`) | N/A | N/A |
|
||||
| 24 | Mount of `06_annotations` page | Status-events SSE subscribe | Exactly one EventSource opened to URL matching `^/api/annotations/annotations/events(\?|$)` | exact (count + URL regex) | N/A | N/A |
|
||||
| 25 | Unmount of `06_annotations` page | Status-events SSE unsubscribe | EventSource readyState transitions to `CLOSED` (2) within 1 s | exact (state), threshold_max | `≤ 1000ms` | N/A |
|
||||
| 26 | Sync image detect trigger (`<AnnotationsSidebar>` Detect on a `MediaType.Image`) | Detect endpoint correctness | Exactly one `POST` to URL matching `^/api/detect/[0-9]+$` | exact, regex | N/A | N/A |
|
||||
| 27 | Async video detect trigger (Phase B; behind feature flag if pre-shipped) | Async detect endpoint | Exactly one `POST` to URL matching `^/api/detect/video/[0-9]+$`; response `{jobId: <int>}`; subsequent EventSource opened to `^/api/detect/stream/[0-9]+(\?|$)` | exact, regex (3 assertions) | N/A | N/A |
|
||||
| 28 | Long-video async detect | Header policy per `_docs/10_auth.md` | Outgoing request includes `X-Refresh-Token` header (non-empty) | present (header) | N/A | N/A |
|
||||
| 29 | Annotation overlay membership at `currentTime = T` | Asymmetric time window | Annotation with `videoTime` in `[T-50ms, T+150ms]` is rendered; outside this window NOT rendered | range, absent | exact bounds | N/A |
|
||||
| 30 | Overlay membership at `currentTime = T` with annotation at `T - 60ms` | Lower-bound exclusion | Annotation NOT rendered | absent | N/A | N/A |
|
||||
| 31 | Overlay membership at `currentTime = T` with annotation at `T + 160ms` | Upper-bound exclusion | Annotation NOT rendered | absent | N/A | N/A |
|
||||
|
||||
### Group 4 — Flight selection persistence + Live-GPS SSE (AC-06 / AC-08)
|
||||
|
||||
| # | Input | Input Description | Expected Result | Comparison | Tolerance | Reference File |
|
||||
|----|-------|-------------------|-----------------|------------|-----------|---------------|
|
||||
| 32 | `FlightContext.selectFlight(flightId)` call | Selected-flight persistence path | Exactly one `PUT /api/annotations/settings/user` with body containing `{selectedFlightId: flightId}`; NO call to `/api/flights/select` (deprecated path must not exist) | exact (count + URL + body subset), absent | N/A | N/A |
|
||||
| 33 | Reload after a flight was selected | Selected-flight rehydration | On boot, `userSettings.selectedFlightId` is read and the flight is reselected without a user click | exact (selection state matches stored id) | N/A | N/A |
|
||||
| 34 | A flight is selected | Live-GPS SSE open | Exactly one EventSource to URL matching `^/api/flights/[0-9]+/live-gps(\?|$)`; readyState reaches `OPEN` (1) within 5 s | exact (count + URL regex), threshold_max | `≤ 5000ms` | N/A |
|
||||
| 35 | Flight is deselected | Live-GPS SSE close | All EventSources matching `^/api/flights/[0-9]+/live-gps` reach readyState `CLOSED` (2) within 1 s | exact (count → 0), threshold_max | `≤ 1000ms` | N/A |
|
||||
|
||||
### Group 5 — Dataset bulk-validate (AC-07)
|
||||
|
||||
| # | Input | Input Description | Expected Result | Comparison | Tolerance | Reference File |
|
||||
|----|-------|-------------------|-----------------|------------|-----------|---------------|
|
||||
| 36 | Select N dataset items → click `Validate` | Bulk-validate endpoint + body | Exactly one `POST /api/annotations/dataset/bulk-status` with body `{ids: <length N int array>, targetStatus: 30}` (`AnnotationStatus.Validated`) | exact (URL + body) | N/A | N/A |
|
||||
| 37 | Successful 200 response from bulk-validate | UI status reflection | Each selected item's row status changes to `Validated` within 2 s of response | exact (per-row state), threshold_max | `≤ 2000ms` | N/A |
|
||||
|
||||
### Group 6 — Upload size cap (AC-10)
|
||||
|
||||
| # | Input | Input Description | Expected Result | Comparison | Tolerance | Reference File |
|
||||
|----|-------|-------------------|-----------------|------------|-----------|---------------|
|
||||
| 38 | Read `client_max_body_size` from `nginx.conf` | nginx upload cap | Value equals `500M` | exact | N/A | N/A |
|
||||
| 39 | UI upload of a synthetic 501 MB file | 413 surfacing | API call resolves with HTTP 413; UI presents a user-visible error containing the i18n key for "file too large" (or its rendered string) — NO silent failure, NO `alert()` | exact (status), substring (rendered error), absent (`alert()`) | N/A | N/A |
|
||||
|
||||
### Group 7 — Build, bundle, and routing (AC-11 / AC-31 / AC-33 / AC-34)
|
||||
|
||||
| # | Input | Input Description | Expected Result | Comparison | Tolerance | Reference File |
|
||||
|----|-------|-------------------|-----------------|------------|-----------|---------------|
|
||||
| 40 | `vite build` output `dist/` | Initial JS bundle budget (target — not yet enforced in CI) | Sum of gzipped initial-route JS chunks ≤ 2 MB | threshold_max | `≤ 2 MB gzipped` | N/A |
|
||||
| 41 | `vite build` output `dist/` | mission-planner exclusion | No file under `dist/` originates from `mission-planner/**`; static-import scan from `src/main.tsx` does not reach into `mission-planner/` | absent (file origin), absent (graph edge) | N/A | N/A |
|
||||
| 42 | `docker inspect azaion/ui:<tag>` | Production runtime image | Final image is based on `nginx:alpine`; no `node` binary present in the image filesystem | exact (base image), absent (binary) | N/A | N/A |
|
||||
| 43 | Read `nginx.conf` route blocks | Service routing | Exactly the 9 `location` blocks present: `/api/admin/`, `/api/flights/`, `/api/annotations/`, `/api/detect/`, `/api/loader/`, `/api/gps-denied-desktop/`, `/api/gps-denied-onboard/`, `/api/autopilot/`, `/api/resource/` | set_equals | N/A | N/A |
|
||||
| 44 | Each of the 9 `location` blocks | Prefix stripping | Each block rewrites/strips its `/api/<service>/` prefix before forwarding upstream (verified via `proxy_pass`/`rewrite` directive shape) | regex per block | N/A | N/A |
|
||||
|
||||
### Group 8 — Internationalization (AC-12 / AC-13)
|
||||
|
||||
| # | Input | Input Description | Expected Result | Comparison | Tolerance | Reference File |
|
||||
|----|-------|-------------------|-----------------|------------|-----------|---------------|
|
||||
| 45 | Compare `src/i18n/en.json` vs `src/i18n/ua.json` | Key parity | `keys(en) == keys(ua)` (deep, sorted) | set_equals | N/A | N/A |
|
||||
| 46 | Lint sweep over `src/**/*.tsx` | No raw user-visible strings | Every JSX text node and every `aria-label` / `placeholder` / `title` string resolves through `t(...)` (or is a proper-noun acronym in the allow-list) | exact (lint findings == 0) | acronym allow-list | N/A |
|
||||
| 47 | First boot in a clean profile | i18n detector | `i18next.language` resolves from cookie or `Accept-Language`; not the literal `'en'` from a hardcoded init | exact (detector path used), absent (hardcoded `lng:'en'`) | N/A | N/A |
|
||||
| 48 | Toggle language in `<Header>` then reload | i18n persistence | After reload, `i18next.language` equals the previously selected language | exact | N/A | N/A |
|
||||
|
||||
### Group 9 — Destructive-action UX (AC-14 / AC-30)
|
||||
|
||||
| # | Input | Input Description | Expected Result | Comparison | Tolerance | Reference File |
|
||||
|----|-------|-------------------|-----------------|------------|-----------|---------------|
|
||||
| 49 | Click `Delete class` in `<AdminPage>` | Class-delete confirmation | `<ConfirmDialog>` is rendered before any HTTP request fires; on Cancel NO `DELETE` request is made; on Confirm exactly one `DELETE` to URL matching `^/api/admin/classes/[0-9]+$` | exact (sequence), exact (count + URL regex) | N/A | N/A |
|
||||
| 50 | Code-search `src/` and `mission-planner/src/` for `\balert\(` | `alert()` ban | `match_count == 0` | exact | N/A | N/A |
|
||||
| 51 | Each destructive action surfaced in `_docs/ui_design/` (delete, validate-with-overwrite, irreversible bulk) | Confirm-before-fire policy | A `<ConfirmDialog>` opens before the destructive request fires (no direct submit path) | present (dialog), exact (sequence) | N/A | N/A |
|
||||
|
||||
### Group 10 — Accessibility (AC-15 / AC-16 / AC-17)
|
||||
|
||||
| # | Input | Input Description | Expected Result | Comparison | Tolerance | Reference File |
|
||||
|----|-------|-------------------|-----------------|------------|-----------|---------------|
|
||||
| 52 | Render `<ConfirmDialog>` open | a11y attributes | Root element has `role="dialog"` AND `aria-modal="true"` | exact | N/A | N/A |
|
||||
| 53 | Open `<ConfirmDialog>` and Tab through | Focus trap | Focus stays inside the dialog (first ↔ last loop); never reaches an element outside | exact (focus stays in tree) | N/A | N/A |
|
||||
| 54 | Press `Escape` while `<ConfirmDialog>` is open | Cancel-on-Escape | Dialog unmounts; cancel callback invoked exactly once; no destructive HTTP request fires | exact (count) | N/A | N/A |
|
||||
| 55 | Render `<Header>` flight dropdown closed | Combobox a11y attrs | Trigger has `role="combobox"`, `aria-expanded="false"`, `aria-haspopup="listbox"` | exact | N/A | N/A |
|
||||
| 56 | Open the flight dropdown | Combobox a11y on open | `aria-expanded` switches to `"true"`; outside-click handler is now attached (was NOT attached while closed) | exact | N/A | N/A |
|
||||
| 57 | Press `Escape` while flight dropdown is open | Close-on-Escape | Dropdown closes; `aria-expanded` returns to `"false"`; outside-click handler is detached | exact | N/A | N/A |
|
||||
| 58 | `<ProtectedRoute>` shows the loading state | a11y on spinner | Spinner element has `role="status"` and an accessible label (non-empty `aria-label` or visually-hidden text) | exact, present (label) | N/A | N/A |
|
||||
| 59 | `<ProtectedRoute>` loading exceeds the timeout (10 s simulated) | Timeout fallback | A user-visible fallback (retry CTA or error message) is rendered; the indeterminate spinner is unmounted | present (fallback), absent (spinner) | timeout configurable | N/A |
|
||||
|
||||
### Group 11 — Browser support & responsive layout (AC-18 / AC-19)
|
||||
|
||||
| # | Input | Input Description | Expected Result | Comparison | Tolerance | Reference File |
|
||||
|----|-------|-------------------|-----------------|------------|-----------|---------------|
|
||||
| 60 | Smoke render of `/flights`, `/annotations`, `/dataset` in headless Chromium and Firefox (latest 2 versions) | Browser support floor | No console error logged; main page region has rendered with expected landmark roles | exact (errors == 0), present (landmarks) | N/A | N/A |
|
||||
| 61 | Headless render at viewport width 480 px | Mobile bottom-nav variant | `<Header>` bottom-nav variant renders; top-bar variant is NOT in DOM | present, absent | N/A | N/A |
|
||||
| 62 | Headless render at viewport width 1024 px | Desktop variant | Top-bar `<Header>` renders; bottom-nav variant NOT in DOM | present, absent | N/A | N/A |
|
||||
|
||||
### Group 12 — Secrets (AC-20)
|
||||
|
||||
| # | Input | Input Description | Expected Result | Comparison | Tolerance | Reference File |
|
||||
|----|-------|-------------------|-----------------|------------|-----------|---------------|
|
||||
| 63 | Code-search `src/` and `mission-planner/src/` for the literal OWM key value (and for any `appid=` / `api_key=` in source URLs) | Secrets-in-source check | `match_count == 0` for the literal key; the key is read only via `import.meta.env.VITE_OPENWEATHERMAP_API_KEY` (or proxied through the suite) | exact, exact (single read site) | N/A | N/A |
|
||||
|
||||
### Group 13 — User-settings persistence (AC-21)
|
||||
|
||||
| # | Input | Input Description | Expected Result | Comparison | Tolerance | Reference File |
|
||||
|----|-------|-------------------|-----------------|------------|-----------|---------------|
|
||||
| 64 | Drag a resizable panel divider via `useResizablePanel` and release | Persist on resize-end | Within 1 s of release, exactly one `PUT /api/annotations/settings/user` fires with body containing `panelWidths: { ... }` reflecting the new sizes | exact (count + URL), substring (key), threshold_max (1 s) | debounce-aware | N/A |
|
||||
| 65 | Reload after a panel resize | Width rehydration | Restored panel widths equal the pre-reload widths within 1 px | numeric_tolerance | `± 1px` | N/A |
|
||||
|
||||
### Group 14 — Form hygiene (AC-26 / AC-27)
|
||||
|
||||
| # | Input | Input Description | Expected Result | Comparison | Tolerance | Reference File |
|
||||
|----|-------|-------------------|-----------------|------------|-----------|---------------|
|
||||
| 66 | Submit a `09_settings` numeric field with empty value | No silent zero | Form does NOT save `0`; submit button disabled OR explicit validation error rendered; NO `PUT` request fires | absent (request), present (validation surface) | N/A | N/A |
|
||||
| 67 | Submit `09_settings` numeric field with non-numeric input | Reject non-numeric | Validation error rendered; NO `PUT` fires | absent (request), present (error) | N/A | N/A |
|
||||
| 68 | `09_settings` save action where the upstream PUT returns HTTP 500 | Error surfacing | A toast / inline error renders within 2 s; `saving` flag returns to `false` (i.e. button is re-enabled); NO route navigation occurs | present (error), exact (state), absent (navigation), threshold_max (2 s) | N/A | N/A |
|
||||
| 69 | `09_settings` save action where the PUT throws (network failure) | Error path via `try/finally` | `saving` flag returns to `false`; user-visible error rendered | exact (state), present (error) | N/A | N/A |
|
||||
|
||||
### Group 15 — CI / image / labels (AC-32)
|
||||
|
||||
| # | Input | Input Description | Expected Result | Comparison | Tolerance | Reference File |
|
||||
|----|-------|-------------------|-----------------|------------|-----------|---------------|
|
||||
| 70 | Image push step in `.woodpecker/build-arm.yml` for branch `main` | Tag scheme | Pushed tag matches `^main-arm$` | regex | N/A | N/A |
|
||||
| 71 | Image push step | OCI labels | Pushed image carries non-empty labels `org.opencontainers.image.revision`, `org.opencontainers.image.created`, `org.opencontainers.image.source` | present (each), exact (count == 3) | N/A | N/A |
|
||||
| 72 | `org.opencontainers.image.revision` label value | Revision plumbing | Equals `$CI_COMMIT_SHA` from the pipeline run | exact | N/A | N/A |
|
||||
|
||||
### Group 16 — Manual annotation interactions on `CanvasEditor` + `DetectionClasses` (AC-35 / AC-36 / AC-37 / AC-38)
|
||||
|
||||
| # | Input | Input Description | Expected Result | Comparison | Tolerance | Reference File |
|
||||
|----|-------|-------------------|-----------------|------------|-----------|---------------|
|
||||
| 73 | Synthetic `mousedown(x1,y1) → mousemove(x2,y2) → mouseup` over `CanvasEditor` with `selectedClassNum = C` and `photoMode = P` | Manual bbox draw (AC-35) | Exactly one new local detection appended with `classNum == C + P`, `x == min(x1,x2)/W`, `y == min(y1,y2)/H`, `w == |x2-x1|/W`, `h == |y2-y1|/H` (W,H = canvas pixel size) | numeric_tolerance (per coord), exact (count + classNum) | `± 1px / canvas px` | N/A |
|
||||
| 74 | `mousedown` on resize handle `h ∈ {NW, N, NE, W, E, SW, S, SE}` over a selected bbox, drag by `(dx, dy)`, mouseup | 8-handle resize (AC-36a) | Only the edges adjacent to `h` move; opposite edges unchanged; resulting bbox dimensions clamped to a minimum normalised size > 0 (no negative or zero w/h) | exact (per-edge invariance), threshold_min (w,h > 0) | N/A | N/A |
|
||||
| 75 | `Ctrl+click` on a bbox that is not currently selected | Canvas multi-select (AC-36b) | The bbox is added to the selection set; previous selection is preserved; clicking the same bbox a second time with Ctrl removes it from the set | exact (selection set delta), idempotent (toggle) | N/A | N/A |
|
||||
| 76 | `Ctrl+wheel` over the canvas at cursor position `(cx, cy)` | Canvas zoom (AC-36c) | Viewport zoom level changes; the world point at `(cx, cy)` before zoom maps back to `(cx, cy)` after zoom (zoom-around-cursor invariant) | numeric_tolerance | `± 1 viewport px` | N/A |
|
||||
| 77 | `Ctrl+drag` starting on empty canvas (no bbox under the pointer) | Canvas pan (AC-36d) | Viewport origin translates by exactly `(-dx, -dy)`; no bbox is created or modified | numeric_tolerance, absent (state delta) | `± 1 viewport px` | N/A |
|
||||
| 78 | Mount of `<DetectionClasses>` with a successful `GET /api/annotations/classes` response of N classes (mode-ordered) | Class list load (AC-37 / load path) | All N entries are rendered; the active-mode filter is applied; no fallback list is shown | exact (count rendered), absent (fallback) | N/A | N/A |
|
||||
| 79 | `keydown` on `window` with key `'1'..'9'` while `photoMode = P` and `classes` are mode-ordered per the contract | Class hotkey 1–9 (AC-37 / hotkey path) | `onSelect` fires exactly once with `class.id == classes[(key-1) + P].id`; the visible label index `i+1` on the rendered list element matches `key` | exact | N/A | N/A |
|
||||
| 80 | Click on a class entry in the strip | Class click path (AC-37 / click path) | `onSelect` fires once with that entry's `class.id` | exact (count + value) | N/A | N/A |
|
||||
| 81 | `GET /api/annotations/classes` returning empty or 5xx | Fallback list (AC-37 / fallback) | `FALLBACK_CLASS_NAMES` × 3 PhotoMode offsets is rendered (IDs in the contiguous `[0..N-1, 20..20+N-1, 40..40+N-1]` shape) | exact (set of IDs) | N/A | N/A |
|
||||
| 82 | Click the `Winter` PhotoMode button while `photoMode = 0` | PhotoMode switch — mode + filter (AC-38 / mode set + filter) | Outgoing `onPhotoModeChange(20)` fires once; rendered class list is filtered to entries whose `photoMode == 20` | exact (call + filter) | N/A | N/A |
|
||||
| 83 | PhotoMode switch where the previously-selected `classNum` is NOT in the new filtered set | PhotoMode auto-select (AC-38 / auto-select) | `onSelect` fires once with `modeClasses[0].id` (first class of the new mode) | exact (count + value) | N/A | N/A |
|
||||
| 84 | Save annotation after drawing bbox with `selectedClassNum = C` and `photoMode = P` | yoloId on wire (AC-38 / wire) | Outgoing `POST /api/annotations/annotations` body has `detections[i].classNum == C + P` for the newly drawn detection | exact | N/A | N/A |
|
||||
|
||||
### Group 17 — Tile splitting (AC-39 / AC-40)
|
||||
|
||||
| # | Input | Input Description | Expected Result | Comparison | Tolerance | Reference File |
|
||||
|----|-------|-------------------|-----------------|------------|-----------|---------------|
|
||||
| 85 | Click `Split tile` action on a dataset item | Split endpoint contract (AC-39 / endpoint) | Exactly one `POST` to URL matching `^/api/annotations/dataset/[0-9]+/split$`; success response is HTTP 200 JSON | exact (count + URL regex + status) | N/A | N/A |
|
||||
| 86 | `AnnotationListItem` with `isSplit: true, splitTile: "3 0.5 0.5 0.2 0.2"` | YOLO label parse — valid (AC-39 / parser happy) | Parser yields `{ classNum: 3, cx: 0.5, cy: 0.5, w: 0.2, h: 0.2 }` without throwing | exact | N/A | N/A |
|
||||
| 87 | `AnnotationListItem` with `isSplit: true, splitTile: "garbage"` | YOLO label parse — malformed (AC-39 / parser sad) | User-visible error surfaced (toast or inline); NO silent swallow; NO render with NaN values | present (error), absent (NaN render) | N/A | N/A |
|
||||
| 88 | `DatasetItem` response containing `isSplit: true` from `GET /api/annotations/dataset` | DatasetItem.isSplit honored (AC-39 / dataset list) | UI reads `item.isSplit` without crash; downstream rendering uses the boolean (rendering policy is per item type, but presence is required) | present (field read) | N/A | N/A |
|
||||
| 89 | Double-click a `splitTile`-bearing annotation in `<AnnotationsSidebar>` | Tile auto-zoom viewport (AC-40 / viewport) | `CanvasEditor` viewport rect equals the tile rect encoded by `splitTile` (per AC-39 parse) within ±1 px per edge | numeric_tolerance | `± 1px per edge` | N/A |
|
||||
| 90 | While tile zoom is active | Tile-zoom indicator (AC-40 / indicator) | A visible tile-zoom indicator (icon or badge) is present in the canvas chrome; clearing the tile zoom removes it | present, absent (after clear) | N/A | N/A |
|
||||
|
||||
### Group 18 — Anti-criteria (AC-N1..AC-N5) — negative behavioral assertions
|
||||
|
||||
| # | Input | Input Description | Expected Result | Comparison | Tolerance | Reference File |
|
||||
|----|-------|-------------------|-----------------|------------|-----------|---------------|
|
||||
| 91 | Two browsers editing the same annotation simultaneously | No collaborative-edit semantics (AC-N1) | UI MUST NOT reconcile concurrent edits; last-write-wins on the server is the expected behavior (no merge UI, no presence indicators) | absent (merge UI) | N/A | N/A |
|
||||
| 92 | Static dependency scan of `package.json` and `mission-planner/package.json` | No in-browser ML (AC-N2) | No package matching the pattern `(onnxruntime|tensorflow|tflite|coreml|tfjs|@tensorflow/.*|@huggingface/.*|transformers\.js)` is declared | absent | N/A | N/A |
|
||||
| 93 | App boot in a network-disabled environment (offline) | No offline mode (AC-N3) | App enters an error / login-failed state; does NOT serve cached app data; no service worker registered | present (error), absent (sw) | N/A | N/A |
|
||||
| 94 | Static dependency scan | No response-signature library (AC-N4) | No package matching `(jsrsasign|tweetnacl|@noble/.*|jose)` is imported on the request-validation path | absent | N/A | N/A |
|
||||
| 95 | Code-search across `src/` and `mission-planner/` for symbols/components named `SoundDetections|DroneMaintenance` | Dropped legacy features (AC-N5) | `match_count == 0` | exact | N/A | N/A |
|
||||
|
||||
### Group 19 — Phase-3-added (Data Validation Gate)
|
||||
|
||||
| # | Input | Input Description | Expected Result | Comparison | Tolerance | Reference File |
|
||||
|----|-------|-------------------|-----------------|------------|-----------|---------------|
|
||||
| 96 | Click `Download` in `<AnnotationsPage>` on a canvas tainted by a cross-origin video frame (CORS-less video source) | Tainted-canvas fallback (NFT-RES-09; derived from `modules/src__features__annotations.md` finding on `handleDownload`) | A user-visible error (toast or inline message) is rendered; NO silent swallow; NO `alert()` invoked; no fabricated blob is offered for download | present (error), absent (silent swallow), absent (`alert()` invocation) | N/A | N/A |
|
||||
| 97 | Server force-closes an open `EventSource` (live-GPS or annotation-status) without rotation | SSE server disconnect indicator (NFT-RES-10; derived from AC-08 + AC-09 + AC-24) | UI either renders a connection-lost indicator (badge/icon) OR invokes a reconnect attempt within 10 s. Stale event data is NOT re-rendered as live; the most recent live timestamp is frozen + flagged stale | present (indicator OR reconnect), absent (stale data rendered as live), exact (reconnect_attempts ≤ 1 in the 10 s window) | dt ≤ 10 000 ms | N/A |
|
||||
| 98 | Warm-cache navigation to `/flights` on the post-login route in headless Chromium on the edge profile (2 vCPU / 4 GB RAM) | First Contentful Paint baseline (NFT-PERF-10; derived from AC-11 target + H2 edge deploy) | `performance.getEntriesByName('first-contentful-paint')[0].startTime` reported by the browser is below the threshold | threshold_max | `≤ 3 000 ms` | N/A |
|
||||
|
||||
---
|
||||
|
||||
## Coverage Summary
|
||||
|
||||
| AC ID | Mapped row(s) | Coverage |
|
||||
|--------|---------------|----------|
|
||||
| AC-01 | 01, 02, 03 | full (default + bootstrap + 401 retry) |
|
||||
| AC-02 | 04 | full |
|
||||
| AC-03 | 05, 06, 07 | full (JS-readable check, fixture cookie regex) |
|
||||
| AC-04 | 14, 15, 16, 17, 18, 19 | full (per enum + payload contract) |
|
||||
| AC-05 | 22, 23 | full (URL + required body fields) |
|
||||
| AC-06 | 32, 33 | full (write path + boot rehydration) |
|
||||
| AC-07 | 36, 37 | full (request + UI reflection) |
|
||||
| AC-08 | 34, 35 | full (open + close) |
|
||||
| AC-09 | 24, 25 | full (subscribe + unsubscribe) |
|
||||
| AC-10 | 38, 39 | full (server cap + UI surfacing) |
|
||||
| AC-11 | 40 | target (no CI gate today; row documents the threshold) |
|
||||
| AC-12 | 45, 46 | full |
|
||||
| AC-13 | 47, 48 | full |
|
||||
| AC-14 | 49, 50, 51 | full (dialog presence + alert ban + general policy) |
|
||||
| AC-15 | 52, 53, 54 | full |
|
||||
| AC-16 | 55, 56, 57 | full |
|
||||
| AC-17 | 58, 59 | full |
|
||||
| AC-18 | 60 | manual smoke today (test row exists; gate is target) |
|
||||
| AC-19 | 61, 62 | full (two breakpoint smokes) |
|
||||
| AC-20 | 63 | full |
|
||||
| AC-21 | 64, 65 | full |
|
||||
| AC-22 | 08, 09, 10 | full (admin + login + settings) |
|
||||
| AC-23 | 11, 12 | full |
|
||||
| AC-24 | 13 | target (Phase B / Step 8 hardening) |
|
||||
| AC-25 | 26, 27, 28 | full (sync image + async video + token header) |
|
||||
| AC-26 | 66, 67 | full |
|
||||
| AC-27 | 68, 69 | full |
|
||||
| AC-28 | 29, 30, 31 | full (in-window + below + above) |
|
||||
| AC-29 | 20, 21 | full |
|
||||
| AC-30 | 49 | overlapped with AC-14 row 49 |
|
||||
| AC-31 | 41 | full |
|
||||
| AC-32 | 70, 71, 72 | full |
|
||||
| AC-33 | 42 | full |
|
||||
| AC-34 | 43, 44 | full (route set + prefix strip) |
|
||||
| AC-35 | 73 | full (one-shot draw assertion) |
|
||||
| AC-36 | 74, 75, 76, 77 | full (resize + multi-select + zoom + pan) |
|
||||
| AC-37 | 78, 79, 80, 81 | full (load + hotkey + click + fallback) |
|
||||
| AC-38 | 82, 83, 84 | full (mode set + filter + auto-select + wire yoloId) |
|
||||
| AC-39 | 85, 86, 87, 88 | full (endpoint + parser happy + parser sad + DatasetItem flag) |
|
||||
| AC-40 | 89, 90 | target (UX missing today — finding #24; rows assert when implementation lands) |
|
||||
| AC-N1 | 91 | full |
|
||||
| AC-N2 | 92 | full |
|
||||
| AC-N3 | 93 | full |
|
||||
| AC-N4 | 94 | full |
|
||||
| AC-N5 | 95 | full |
|
||||
| (NFT-RES-09 anchor) | 96 | added Phase 3 — tainted-canvas fallback observable |
|
||||
| (NFT-RES-10 anchor) | 97 | added Phase 3 — SSE server-disconnect observable |
|
||||
| (NFT-PERF-10 anchor) | 98 | added Phase 3 — FCP baseline threshold |
|
||||
|
||||
Every AC + anti-criterion has at least one row. Every row is quantifiable.
|
||||
|
||||
## Open Items For Phase 1 Validation
|
||||
|
||||
- **AC-04 enum maps**: rows 14–17 reference "the spec map" for `MediaStatus`,
|
||||
`Affiliation`, `CombatReadiness`. The exact numeric values must be harvested
|
||||
from the suite spec at Phase 1 time and inlined; they are deliberately left
|
||||
symbolic here because the UI today drifts (per `restrictions.md` O7 + the
|
||||
data-parameters drift notes) and we want the test to assert against the
|
||||
spec, not against the current `src/types/index.ts`.
|
||||
- **AC-11 / AC-18 / AC-24 / AC-25 (async) / AC-40** are flagged "Phase B /
|
||||
target" or "Step 4 fix" in `acceptance_criteria.md`. The rows above produce
|
||||
verifiable assertions when those features ship; until then `/test-spec`
|
||||
Phase 3 may downgrade them from blocking to documentary. AC-40 in particular
|
||||
has zero consumer of `splitTile` in the UI today (finding #24 in
|
||||
`modules/src__features__annotations.md`); its rows are written so the test
|
||||
exists the day the tile-zoom UX ships.
|
||||
- **AC-37 backend ordering**: the class-hotkey contract depends on the
|
||||
`annotations/` service returning classes in the contiguous
|
||||
`[0..N-1, 20..20+N-1, 40..40+N-1]` shape. This was flagged Step 4
|
||||
verification in `data_model.md:158`. If the backend ordering does not match,
|
||||
AC-37 row 79 will fail at integration time and we may need a server-side
|
||||
fix or a client-side resort.
|
||||
- **Reference files**: none required at this stage. If `/test-spec` Phase 2
|
||||
produces a per-route JSON expectation that doesn't fit a single row,
|
||||
reference files will be added in this folder following the
|
||||
`<input_name>_expected.<ext>` convention.
|
||||
@@ -0,0 +1,148 @@
|
||||
# Problem Statement — Azaion UI
|
||||
|
||||
> Output of `/document` Step 6a. Retrospective problem definition synthesised
|
||||
> from `_docs/02_document/architecture.md` (System Context, Components),
|
||||
> component descriptions, system flows, and the Step 4.5 Architecture Vision.
|
||||
> No README exists in the repo today (the workspace's tracked README is the
|
||||
> parent suite's; the UI repo has only the autodev-generated `README.md`
|
||||
> stub). All claims here trace to verified `_docs/02_document/` artifacts.
|
||||
|
||||
**Status**: synthesised-from-verified-docs (Step 6a — `/document`)
|
||||
**Date**: 2026-05-10
|
||||
|
||||
---
|
||||
|
||||
## What is this system?
|
||||
|
||||
Azaion UI is the **operator-facing browser** of the Azaion UAV operations
|
||||
suite — a single-page React 19 application served by nginx as a static bundle
|
||||
inside an ARM64 container. It is the **React rewrite of the front-end half**
|
||||
of the legacy WPF stack (`Azaion.Annotator` + `Azaion.Dataset` +
|
||||
`MapMatcher`); the heavyweight machinery (LibVLC playback, Cython sidecars,
|
||||
SQLite outbox, per-app DI host, binary-split key-fragment loader handoff)
|
||||
moved server-side into the parent suite (`suite/`) as separate services.
|
||||
|
||||
The UI's narrowed responsibility is to **render the suite's REST + SSE
|
||||
contract beautifully and accessibly** — it carries no domain logic that
|
||||
belongs on the server, no in-browser persistence beyond a single bearer
|
||||
in memory and a `Secure HttpOnly` refresh cookie, and no Node.js runtime in
|
||||
the production image.
|
||||
|
||||
## What problem does it solve?
|
||||
|
||||
Operators of UAV / aerial-imagery missions (military and defense use cases)
|
||||
need a single browser surface to:
|
||||
|
||||
1. **Plan flights** — define waypoints, altitudes, aircraft, GPS-Denied
|
||||
parameters; consult wind data; visualise routes on a map.
|
||||
2. **Capture and review media** — browse uploaded images and videos scoped
|
||||
to a flight; play video frame-accurately; tag bounding boxes manually or
|
||||
via AI inference.
|
||||
3. **Run AI object detection** — synchronously on images today; asynchronously
|
||||
on video (target — not wired today, see `04_verification_log.md` F7).
|
||||
4. **Curate datasets** — filter by class / status, validate annotations in
|
||||
bulk, view class-distribution analytics.
|
||||
5. **Administer** the system — manage detection classes, users, aircraft,
|
||||
AI / GPS settings.
|
||||
6. **Operate GPS-Denied positioning** — including a planned **Test Mode**
|
||||
that drives SITL simulation from a pre-recorded `.tlog` + video pair
|
||||
(`_docs/how_to_test.md`).
|
||||
|
||||
This UI replaces the WPF desktop applications; it is the **single browser
|
||||
client** for all of the above use cases. Every action goes through the
|
||||
suite's typed REST contract or SSE stream — the browser is treated as
|
||||
untrusted, so the server is authoritative for every state transition.
|
||||
|
||||
## Who are the users?
|
||||
|
||||
- **Operator** (primary persona) — flies missions, reviews captured media,
|
||||
runs AI detect, curates datasets. The UI's default authenticated route
|
||||
(`/flights`) targets this persona. Bilingual UI is mandatory (Ukrainian
|
||||
+ English).
|
||||
- **Admin** (privileged operator) — adds detection classes, manages users
|
||||
and aircraft, configures AI / GPS settings. Lives at `/admin`. Today this
|
||||
route lacks a client-side role-gate (server-side RBAC is authoritative;
|
||||
the missing UI gate is a finding).
|
||||
- **System integrator** — uses the GPS-Denied Test Mode and the Settings
|
||||
pages to validate end-to-end pipelines.
|
||||
|
||||
The UI does NOT have an end-customer / public-facing surface. It is internal
|
||||
to authenticated operators.
|
||||
|
||||
## How does it work at a high level?
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Operator[Operator browser] -->|HTTPS| Nginx[nginx<br/>static SPA + reverse-proxy]
|
||||
Nginx -->|/api/admin/*| Admin[admin/]
|
||||
Nginx -->|/api/flights/*| Flights[flights/]
|
||||
Nginx -->|/api/annotations/*| Ann[annotations/]
|
||||
Nginx -->|/api/detect/*| Detect[detect/]
|
||||
Nginx -->|/api/gps-denied-*/*| GPS[gps-denied-*/]
|
||||
Nginx -->|/api/resource/*| Resource[resource/]
|
||||
Nginx -->|/api/autopilot/*| Autopilot[autopilot/]
|
||||
Operator -->|HTTPS direct| OWM[OpenWeatherMap]
|
||||
Operator -->|HTTPS direct| OSM[OSM tile servers]
|
||||
```
|
||||
|
||||
1. Operator hits the nginx host. nginx serves `dist/index.html` + chunks.
|
||||
2. The SPA boots; `AuthContext` attempts a bootstrap refresh (currently
|
||||
broken — Step 4 fix candidate); on 401 the `ProtectedRoute` redirects
|
||||
to `/login`.
|
||||
3. Login (`POST /api/admin/auth/login`) returns a bearer in the response
|
||||
body and sets a `Secure HttpOnly` refresh cookie.
|
||||
4. Subsequent authenticated requests carry the bearer in the `Authorization`
|
||||
header. On 401, the `01_api-transport` layer issues `POST
|
||||
/api/admin/auth/refresh` with `credentials:'include'` and retries.
|
||||
5. Page-level fetches go to the matching suite service; nginx strips the
|
||||
`/api/<service>/` prefix and reverse-proxies. Long-lived streams (live-GPS
|
||||
per flight, annotation-status events) come over SSE.
|
||||
6. State is two React Contexts (`AuthContext`, `FlightContext`) plus
|
||||
page-local `useState`. No Redux, no Zustand, no TanStack Query
|
||||
(`ADR-004`, P4).
|
||||
|
||||
The dominant runtime pattern is **thin client over a typed REST contract** —
|
||||
no business logic in the browser; the server is the authority for every
|
||||
mutation.
|
||||
|
||||
## Cross-reference with README
|
||||
|
||||
The repo's tracked `README.md` is a placeholder (untracked at the time of
|
||||
this analysis — see `git status`). The parent suite's docs (`suite/_docs/*`)
|
||||
are the canonical product reference; the UI's own derived docs in
|
||||
`_docs/02_document/` complement those.
|
||||
|
||||
If a user-facing README is created in a future cycle, it should mirror the
|
||||
"What this system is" paragraph above and link to `_docs/02_document/architecture.md`
|
||||
for the full technical view.
|
||||
|
||||
## What this system explicitly does NOT do
|
||||
|
||||
- **No in-browser persistence beyond bearer + i18n cache** — every reload
|
||||
re-fetches.
|
||||
- **No SSR / no React Server Components** — `Dockerfile` + `nginx.conf` ship
|
||||
a static bundle (`ADR-001`, P2).
|
||||
- **No WebSocket** — REST + SSE only (`ADR-002`, P1).
|
||||
- **No localStorage / sessionStorage for tokens** — bearer is in memory;
|
||||
refresh is in HttpOnly cookie (`ADR-001` consequence, P3).
|
||||
- **No SEO** — operator-only application.
|
||||
- **No mobile-first design** — Header has a bottom-nav variant for ≥ 768 px;
|
||||
mobile is a P2 use-case (see `_docs/02_document/architecture.md` § 6 NFRs).
|
||||
- **No port of three legacy WPF features**: WPF-era encrypted-creds
|
||||
command-line handoff (P8 — security infra moved server-side), Sound
|
||||
Detections (Step 4.5 decision — dropped), Drone Maintenance / "Аналіз
|
||||
стану БПЛА" (Step 4.5 decision — dropped).
|
||||
|
||||
## Open product questions (carried forward)
|
||||
|
||||
These are **not blocking** Step 6 retrospective extraction; they are recorded
|
||||
in `_docs/02_document/architecture.md` § Architecture Vision "Open questions
|
||||
/ drift signals". Phase B feature cycles will resolve them per task.
|
||||
|
||||
1. Async video detect (`F7`) wiring — when in Phase B does the SSE consumer
|
||||
ship?
|
||||
2. `IsSeed` annotation visual — does the modern API still expose `isSeed`?
|
||||
3. Camera-config side panel (GSD) — per-user, per-flight, or per-detect-job?
|
||||
4. Status-bar clock + help-text-blink — port WPF UX or replace with toasts?
|
||||
5. `mission-planner/` end-state — delete after parity port (preferred per
|
||||
Step 4.5 decision) or keep as continuously-vendored reference?
|
||||
@@ -0,0 +1,82 @@
|
||||
# Restrictions — Azaion UI
|
||||
|
||||
> Output of `/document` Step 6b. Constraints **actually evidenced** in code,
|
||||
> configs, Dockerfiles, CI configs, and dependency manifests. Inferred
|
||||
> aspirations are NOT included unless the source is cited. Categorised as
|
||||
> Hardware / Software / Environment / Operational per the document skill
|
||||
> template.
|
||||
|
||||
**Status**: synthesised-from-verified-docs (Step 6b — `/document`)
|
||||
**Date**: 2026-05-10
|
||||
|
||||
---
|
||||
|
||||
## Hardware
|
||||
|
||||
| # | Restriction | Source / Evidence |
|
||||
|---|-------------|--------------------|
|
||||
| H1 | **ARM64-only production image** today (no AMD64 build in CI). | `.woodpecker/build-arm.yml` (the only pipeline file); `_docs/02_document/architecture.md` § 3 Deployment Model "Missing from the pipeline today" |
|
||||
| H2 | **Edge-device deployment target** — operator laptops, OrangePi, Jetson — alongside suite services. | `_docs/legacy/wpf-era.md` §1; `_docs/02_document/architecture.md` § 2 |
|
||||
| H3 | **No GPU expectation in the UI image** — all AI inference happens server-side; the UI only renders detections. | `nginx:alpine` runtime; no client-side ML libs in `package.json` |
|
||||
| H4 | **Browser-rendering capability minimum**: HTML5 `<video>` + `<canvas>` + `EventSource`. Operates on Chromium-based + Firefox latest 2 versions. | `ADR-003` (HTML5 video over LibVLC); `_docs/02_document/architecture.md` § 6 NFR row "Browser support" |
|
||||
|
||||
## Software
|
||||
|
||||
| # | Restriction | Source / Evidence |
|
||||
|---|-------------|--------------------|
|
||||
| S1 | **TypeScript strict mode**. | `tsconfig.json` (`strict: true`) per `_docs/02_document/architecture.md` § 2 Tech Stack |
|
||||
| S2 | **React 19** — latest stable; React Server Components NOT used. | `package.json` `react@19`; `ADR-001` |
|
||||
| S3 | **Vite 6** as the bundler. | `package.json` `vite@6`; `vite.config.ts` |
|
||||
| S4 | **Bun 1.3.11** as the package manager (declared via `packageManager`). CI image is `oven/bun:1.3.11-alpine`. | `package.json` `packageManager` field; `Dockerfile`; `.woodpecker/build-arm.yml` |
|
||||
| S5 | **Static-bundle output only** — production runtime is `nginx:alpine`; **no Node.js in production**. | `Dockerfile` multi-stage build; `_docs/02_document/architecture.md` § 3 |
|
||||
| S6 | **REST + SSE only** — no WebSocket, no GraphQL, no gRPC-Web. | `src/api/client.ts` + `src/api/sse.ts` are the only transports; `ADR-002`, P1 |
|
||||
| S7 | **Two React Contexts only** for cross-cutting state (`AuthContext`, `FlightContext`). No Redux / Zustand / TanStack Query. | `src/auth/AuthContext.tsx`, `src/components/FlightContext.tsx`; `ADR-004`, P4 |
|
||||
| S8 | **Tailwind 4** + `az-*` design tokens are the styling source of truth. | `src/index.css`; `ADR-005` |
|
||||
| S9 | **Map**: `leaflet@1.9.4` + `react-leaflet@5` (+ `leaflet-draw`, `leaflet-polylinedecorator`). Not Mapbox / Cesium / OpenLayers. | `package.json` |
|
||||
| S10 | **Charts**: `chart.js@4` + `react-chartjs-2@4`. | `package.json` |
|
||||
| S11 | **DnD**: `@hello-pangea/dnd@18` for waypoint reorder. | `package.json` |
|
||||
| S12 | **i18n**: `i18next` + `react-i18next` with English + Ukrainian bundles only. | `src/i18n/i18n.ts`; `_docs/02_document/architecture.md` § ADR-007 |
|
||||
| S13 | **No client-side persistence library** (no IndexedDB wrapper, no localForage). Bearer is in memory; refresh is in HttpOnly cookie. | `src/auth/AuthContext.tsx`; P3 |
|
||||
| S14 | **No test framework configured today** — `package.json` has zero test deps; `src/**/*.test.*` is empty. Test runner choice deferred to autodev Step 5 (Decompose Tests) per Step 4.5 decision. | `04_verification_log.md` §1; `architecture.md` § Architecture Vision Open Questions item 7 |
|
||||
|
||||
## Environment
|
||||
|
||||
| # | Restriction | Source / Evidence |
|
||||
|---|-------------|--------------------|
|
||||
| E1 | **Air-gap-friendly bundle** — the SPA ships fully; only OpenWeatherMap and map tiles need internet. (Field deployments need an offline tile cache; not implemented today.) | `_docs/02_document/architecture.md` § 2 "Key constraints driving the stack" |
|
||||
| E2 | **nginx reverse-proxy strips `/api/<service>/` per service** before forwarding. The SPA's `/api/...` URLs are coupled to this routing. | `nginx.conf` (9 routes); `ADR-006` |
|
||||
| E3 | **`Secure HttpOnly SameSite=Strict` refresh cookie** issued by `admin/`. Browser MUST use the same origin (or proxied origin) so the cookie scopes correctly. | `_docs/02_document/architecture.md` § 7 Security Architecture |
|
||||
| E4 | **Vite dev proxy** at `/api → http://localhost:8080` (developers run the suite docker-compose locally). | `vite.config.ts` |
|
||||
| E5 | **`AZAION_REVISION` env var** is stamped into the production image at build time (`$CI_COMMIT_SHA`). | `Dockerfile`; `.woodpecker/build-arm.yml` |
|
||||
| E6 | **OCI image labels** — `org.opencontainers.image.{revision,created,source}` are mandatory at push time. | `.woodpecker/build-arm.yml` |
|
||||
| E7 | **Image registry** is `${REGISTRY_HOST}/azaion/ui:${branch}-arm`; tag scheme is `branch-arm`. | `.woodpecker/build-arm.yml` |
|
||||
| E8 | **Branch triggers**: CI runs on push to `dev` / `stage` / `main` (mapping to environment names). | `.woodpecker/build-arm.yml` |
|
||||
| E9 | **`client_max_body_size 500M`** — the server-side hard cap on file uploads (annotation-media batch). | `nginx.conf` |
|
||||
| E10 | **OpenWeatherMap is consumed directly from the browser** today (CORS-enabled OWM endpoint). The hardcoded API key (P10 violation) is the security concern; the routing pattern itself is the structural concern (Step 6 surface — proxy via suite). | `mission-planner/src/utils/flightPlanUtils.ts:60`; `architecture.md` § Architecture Vision Open Questions item 8 |
|
||||
|
||||
## Operational
|
||||
|
||||
| # | Restriction | Source / Evidence |
|
||||
|---|-------------|--------------------|
|
||||
| O1 | **Bilingual UI is mandatory** (English + Ukrainian). English-only UX is a regression. | P6; `ADR-007`; `_docs/legacy/wpf-era.md` |
|
||||
| O2 | **Bearer never written to localStorage / sessionStorage**. | P3; `src/auth/AuthContext.tsx` (zero `storage.*` calls) |
|
||||
| O3 | **All authenticated `fetch` requests must include `credentials:'include'`** for the HttpOnly refresh cookie to flow. The bootstrap refresh in `AuthContext.tsx:24` violates this and is a Step 4 fix. | `src/api/client.ts:44` (correct path); `src/auth/AuthContext.tsx:24` (broken path); `04_verification_log.md` F2 |
|
||||
| O4 | **RBAC is server-enforced**. The UI MUST NOT trust `AuthUser.role` for security; it is used only for nav rendering. | P3 / `architecture.md` § 7 Authorization |
|
||||
| O5 | **`Secure HttpOnly SameSite=Strict` refresh cookie** is the single source of refresh-token authority. | `architecture.md` § 7 |
|
||||
| O6 | **No hardcoded credentials in source** (P10). Current violation: OpenWeatherMap key in `mission-planner/src/utils/flightPlanUtils.ts:60` — Step 4 fix candidate. | P10; `architecture.md` § Architecture Vision |
|
||||
| O7 | **Spec is the source of truth for numeric enums** (`AnnotationStatus`, `MediaStatus`, `Affiliation`, `CombatReadiness`). UI types file matches the spec verbatim with inline numeric-meaning comments. | P9; `src/types/index.ts`; `04_verification_log.md` enum drift |
|
||||
| O8 | **Persist what you type** (P11) — fields declared in `UserSettings` (incl. resizable-panel widths) MUST be persisted by the writers; reading without writing back is a violation. Current violation: `useResizablePanel` (Step 4 fix). | P11; `src/hooks/useResizablePanel.ts` |
|
||||
| O9 | **Admin can edit existing detection classes** (P12) — full CRUD surface. Current code is add + delete only; edit (`PATCH /api/admin/classes/{id}`) is to be re-introduced. | P12; `04_verification_log.md` F10 |
|
||||
| O10 | **Destructive actions require `ConfirmDialog`** confirmation. Current violations: `AdminPage.handleDeleteClass` (no dialog); `MediaList` uses `alert()` instead. | `_docs/ui_design/README.md` confirmation-dialogs spec; finding B4 |
|
||||
| O11 | **No SSR / React Server Components** (P2). | `Dockerfile`; `ADR-001` |
|
||||
| O12 | **The `mission-planner/` tree is NOT compiled by the production Vite build**. It is the port-source for `05_flights` and is on a multi-cycle path to deletion. | `vite.config.ts`; `ADR-009`; `architecture.md` § Mission-planner convergence plan |
|
||||
| O13 | **Bundle size budget**: ≤ ~2 MB gzipped initial JS (target). Currently no CI gate. | `architecture.md` § 6 NFR row "Bundle size (initial JS)" |
|
||||
| O14 | **CI test step does not exist today**. To be added once a test framework is selected (autodev Step 5 — Decompose Tests). | `.woodpecker/build-arm.yml`; `architecture.md` § 3 "Missing from the pipeline today" |
|
||||
| O15 | **No vulnerability scan / SBOM emission / image signing** in the pipeline today. Step 6 surface (security_approach.md). | `.woodpecker/build-arm.yml` |
|
||||
|
||||
## Notes on items NOT in this list
|
||||
|
||||
- **Browser support matrix** is **not enforced** (no `browserslist` config). The "Chromium + Firefox latest 2" target is aspirational per `architecture.md` § 6.
|
||||
- **Performance budgets** beyond bundle size and the 500 MB upload cap are **not enforced** in code or CI today.
|
||||
- **Accessibility floor**: WCAG-level conformance is **not declared**. Multiple a11y findings are recorded for Step 4 / Step 8 (see `architecture.md` § 6 NFR row "Accessibility").
|
||||
- **Telemetry / observability**: no centralized client telemetry today. Logging is browser-console only. Step 6 surface (`_docs/02_document/deployment/observability.md`).
|
||||
@@ -0,0 +1,324 @@
|
||||
# Security Approach — Azaion UI
|
||||
|
||||
> Output of `/document` Step 6e. Retrospective security view of the SPA
|
||||
> grounded in code (`src/auth/AuthContext.tsx`, `src/api/client.ts`,
|
||||
> `src/api/sse.ts`), config (`nginx.conf`, `Dockerfile`,
|
||||
> `.woodpecker/build-arm.yml`), and the verified architecture
|
||||
> (`_docs/02_document/architecture.md` § 7). Every claim cites its evidence.
|
||||
|
||||
**Status**: synthesised-from-verified-docs (Step 6e — `/document`)
|
||||
**Date**: 2026-05-10
|
||||
|
||||
---
|
||||
|
||||
## Threat model summary
|
||||
|
||||
The UI is **operator-internal**, not public. The trust model is:
|
||||
|
||||
- **Trusted**: the suite services (reached via nginx reverse-proxy on the
|
||||
same origin); the suite's identity provider (`admin/`); the operator's
|
||||
authenticated browser session.
|
||||
- **Untrusted**: the browser itself (XSS-resistant design — bearer in
|
||||
memory only); operator network if not on the suite VPN; OpenWeatherMap
|
||||
(currently exfiltrated to via a hardcoded key — finding); OSM tile
|
||||
servers (read-only third-party).
|
||||
|
||||
Primary threats considered: **token theft via XSS**; **CSRF via cookie
|
||||
auto-attach**; **bearer leakage via SSE query string**; **secret leakage in
|
||||
bundle**; **privilege escalation via missing client-side route gates**;
|
||||
**clickjacking / framing**.
|
||||
|
||||
---
|
||||
|
||||
## 1. Authentication
|
||||
|
||||
### Login
|
||||
|
||||
- `POST /api/admin/auth/login` with `{ email, password }`.
|
||||
- `admin/` service responds with:
|
||||
- **Bearer JWT** in the response body — held in `AuthContext` memory
|
||||
only (never written to `localStorage` / `sessionStorage`, P3).
|
||||
- **`Secure HttpOnly SameSite=Strict` refresh cookie** — issued by
|
||||
server, scoped to the suite origin.
|
||||
|
||||
Source: `src/auth/AuthContext.tsx`; `architecture.md` § 7.
|
||||
|
||||
### Session bootstrap (cold load)
|
||||
|
||||
- On mount, `AuthContext` attempts `GET /api/admin/auth/refresh` to obtain
|
||||
a new bearer.
|
||||
- **Bug**: this call is missing `credentials:'include'` — the HttpOnly
|
||||
refresh cookie is NOT sent → cold-load refresh fails → user is
|
||||
redirected to `/login` even with a valid cookie. **Step 4 fix
|
||||
candidate**.
|
||||
|
||||
Source: `src/auth/AuthContext.tsx:24`; `04_verification_log.md` F2.
|
||||
|
||||
### 401-retry path
|
||||
|
||||
- The `01_api-transport` `client.ts` wraps every authenticated `fetch`.
|
||||
On 401 it issues `POST /api/admin/auth/refresh` **with**
|
||||
`credentials:'include'`, replaces the bearer in `AuthContext`, and
|
||||
retries the original request.
|
||||
- This path is correct and is the working refresh mechanism today.
|
||||
|
||||
Source: `src/api/client.ts:44`; `04_verification_log.md` F2.
|
||||
|
||||
### Logout
|
||||
|
||||
- `POST /api/admin/auth/logout` — clears the bearer in memory; server
|
||||
invalidates the refresh cookie.
|
||||
|
||||
Source: `src/auth/AuthContext.tsx`.
|
||||
|
||||
### Pre-port (legacy WPF)
|
||||
|
||||
- The WPF-era encrypted-creds command-line handoff (binary-split key
|
||||
fragments + DPAPI) is **intentionally not ported** — the browser cannot
|
||||
participate in that handoff and the suite identity infrastructure now
|
||||
lives server-side. P8.
|
||||
|
||||
Source: `_docs/legacy/wpf-era.md` §11.
|
||||
|
||||
---
|
||||
|
||||
## 2. Authorization
|
||||
|
||||
- RBAC is **server-enforced** — every authenticated endpoint validates
|
||||
`User.role` + `permissions[]` server-side.
|
||||
- The UI inspects `AuthUser.role` to render or hide nav links and pages,
|
||||
but does **NOT** treat the result as a security gate.
|
||||
- Browser is treated as untrusted; every action confirms with the server.
|
||||
|
||||
### Findings
|
||||
|
||||
- **`/admin` route lacks a client-side role-gate** (PRIORITY — security
|
||||
finding, AC-22). Server-side 403 IS the authoritative gate, but a
|
||||
non-admin user navigating to `/admin` today sees the broken admin UI
|
||||
flicker before the server rejects requests. **Step 4 / Step 8 fix.**
|
||||
- **`/settings` route gate is more nuanced** — there is no explicit
|
||||
`SETTINGS` permission code in the suite spec; gating relies on
|
||||
server-side 403. Treat as a soft gate (don't expose the link in the
|
||||
Header for non-admins) rather than a hard redirect.
|
||||
|
||||
Source: `architecture.md` § 7 Authorization; `App.tsx`.
|
||||
|
||||
---
|
||||
|
||||
## 3. Token handling
|
||||
|
||||
| Token | Lifetime | Where it lives | Where it appears on the wire |
|
||||
|-------|----------|----------------|------------------------------|
|
||||
| Bearer JWT | Short (server-issued; refreshed on 401) | `AuthContext` React state — **memory only** | `Authorization: Bearer ${token}` header on every authenticated `fetch`; `?token=${bearer}` query string on SSE (`ADR-008`) |
|
||||
| Refresh token | Long (server-issued) | **`Secure HttpOnly SameSite=Strict` cookie** — never accessible to JS | Cookie header on `POST /api/admin/auth/refresh` (and the broken bootstrap GET — Step 4 fix) |
|
||||
| `X-Refresh-Token` header | Per-request (long-running video detect) | passed in by `01_api-transport` for endpoints that need it | `X-Refresh-Token: ${value}` per `_docs/10_auth.md`. **Currently NOT sent on `POST /api/detect/${mediaId}` for video** — long videos can blow the access-token TTL → silent failure. Step 4 fix candidate (finding #29). |
|
||||
|
||||
### Key invariants (P3)
|
||||
|
||||
- Bearer is **never** written to `localStorage` / `sessionStorage` / IndexedDB.
|
||||
- Refresh token is **never** read from JS — `HttpOnly` enforces this.
|
||||
- Code-search regression test: zero matches for `localStorage|sessionStorage`
|
||||
touching the bearer token in `src/`.
|
||||
|
||||
---
|
||||
|
||||
## 4. SSE bearer-in-query-string
|
||||
|
||||
`EventSource` cannot send arbitrary headers, so `src/api/sse.ts` passes the
|
||||
bearer in the URL: `?token=${bearer}`.
|
||||
|
||||
### Trade-offs
|
||||
|
||||
- **Bearer is short-lived** — minimises window of compromise.
|
||||
- **HTTPS encrypts the URL on the wire** — but the URL still appears in:
|
||||
- **nginx access logs** (mitigation: log redaction at the nginx layer —
|
||||
Step 6 surface; not configured today).
|
||||
- **Browser history** (low risk for SSE URLs, but document).
|
||||
- **Refresh-rotation breaks open SSE connections** — the URL was created
|
||||
with the **old** bearer; no reconnect logic exists today (Step 8
|
||||
hardening — AC-24).
|
||||
|
||||
Source: `src/api/sse.ts`; `ADR-008`; `architecture.md` § 7.
|
||||
|
||||
---
|
||||
|
||||
## 5. Secrets management
|
||||
|
||||
### Hardcoded OpenWeatherMap API key — P10 violation
|
||||
|
||||
- **File**: `mission-planner/src/utils/flightPlanUtils.ts:60`
|
||||
- **Value**: a 32-char hex key shipped in the production bundle.
|
||||
- **Risk**: anyone with access to the bundle can extract and reuse the
|
||||
key (rate-limit theft; provider account abuse). The key is committed to
|
||||
git history.
|
||||
- **Fix sequence (Step 4 / Phase B)**:
|
||||
1. **Rotate** the key at OpenWeatherMap (out-of-band, user action).
|
||||
2. **Move to env** — `import.meta.env.VITE_OPENWEATHERMAP_API_KEY`
|
||||
read at build time (interim).
|
||||
3. **Proxy via suite** — long-term, route the wind compute through
|
||||
`flights/` so no key ever reaches the browser (preferred; per
|
||||
`architecture.md` § Architecture Vision Open Questions item 8).
|
||||
|
||||
### Other secrets
|
||||
|
||||
- **No other hardcoded keys** in `src/` per Grep audit at Step 4.
|
||||
- Suite service URLs are not secrets (they are docker-network hostnames).
|
||||
- The bearer is the only sensitive value in browser memory, and it is
|
||||
short-lived.
|
||||
|
||||
Source: P10; `architecture.md` § Architecture Vision; finding (security).
|
||||
|
||||
---
|
||||
|
||||
## 6. CORS, cookie scope, CSRF
|
||||
|
||||
- **Same-origin via nginx**: the SPA is served by the same nginx that
|
||||
reverse-proxies `/api/<service>/`. The browser sees a single origin →
|
||||
cookies scope cleanly; CORS preflight is unnecessary for the suite
|
||||
endpoints.
|
||||
- **`credentials:'include'`** is required on every authenticated `fetch`
|
||||
for the HttpOnly refresh cookie to flow. The 401-retry path
|
||||
(`api/client.ts:44`) is correct; the bootstrap refresh
|
||||
(`AuthContext.tsx:24`) is **broken**.
|
||||
- **CSRF**: `SameSite=Strict` on the refresh cookie + bearer-in-header on
|
||||
authenticated requests. The bearer header cannot be auto-attached by a
|
||||
cross-origin form submit. **No additional CSRF token** is used today —
|
||||
the architecture pattern (header-based bearer + SameSite=Strict cookie)
|
||||
obviates it.
|
||||
|
||||
Source: `src/api/client.ts`; `nginx.conf`; `architecture.md` § 7.
|
||||
|
||||
---
|
||||
|
||||
## 7. Input validation
|
||||
|
||||
- **Server is authoritative.** The UI does not duplicate validation logic
|
||||
it cannot guarantee.
|
||||
- **Numeric inputs in `09_settings`** use `parseInt(v) || 0` — clearing a
|
||||
field silently writes `0` (finding B4, AC-26). Step 4 fix.
|
||||
- **File upload**: `react-dropzone` filters by MIME / extension client-side;
|
||||
the server is authoritative on virus scanning and size enforcement
|
||||
(`client_max_body_size 500M`).
|
||||
- **Annotation save** body must include `Source`, `WaypointId`, `videoTime`
|
||||
(currently incomplete — finding #32). The wire format is validated by
|
||||
the `annotations/` service.
|
||||
|
||||
Source: `09_settings/SettingsPage.tsx`; `06_annotations/MediaList.tsx`;
|
||||
`nginx.conf`; finding B4 / #32.
|
||||
|
||||
---
|
||||
|
||||
## 8. Output encoding / XSS surface
|
||||
|
||||
- React 19 escapes JSX text by default — string content is safe.
|
||||
- **`dangerouslySetInnerHTML`** is **not used** in `src/` (Grep audit).
|
||||
- **`HelpModal`** ships hardcoded English strings inline — XSS-safe but
|
||||
P6 violation (i18n).
|
||||
- **Tainted-canvas** risk on annotation download (`AnnotationsPage.handleDownload`
|
||||
finding) — cross-origin image data may taint the canvas; the download
|
||||
silently fails. Pure UX bug, not a security defect, but flagged.
|
||||
|
||||
Source: `06_annotations/AnnotationsPage.tsx`; `HelpModal.tsx`.
|
||||
|
||||
---
|
||||
|
||||
## 9. Headers / hardening at the nginx layer
|
||||
|
||||
### Currently configured
|
||||
|
||||
- nginx serves `dist/` and reverse-proxies `/api/<service>/` to suite
|
||||
services.
|
||||
- `client_max_body_size 500M`.
|
||||
|
||||
### Currently MISSING (Step 6 surface)
|
||||
|
||||
- **`Content-Security-Policy`** — no CSP header. Recommended starting
|
||||
point: `default-src 'self'; img-src 'self' https: data:; connect-src
|
||||
'self' https://api.openweathermap.org/ https://*.tile.openstreetmap.org/;
|
||||
frame-ancestors 'none'; object-src 'none'`.
|
||||
- **`X-Frame-Options: DENY`** (or covered by CSP `frame-ancestors`) —
|
||||
clickjacking protection.
|
||||
- **`Referrer-Policy: strict-origin-when-cross-origin`**.
|
||||
- **`Strict-Transport-Security`** — depends on suite ingress; document the
|
||||
expected value.
|
||||
- **`X-Content-Type-Options: nosniff`**.
|
||||
- **Bearer-redaction** in nginx access logs for SSE URLs.
|
||||
|
||||
These are nginx config additions (server-side), not SPA changes — but the
|
||||
SPA depends on them for hardening. Track at suite level.
|
||||
|
||||
Source: `nginx.conf`; `architecture.md` § 7 row "Cross-site / clickjack".
|
||||
|
||||
---
|
||||
|
||||
## 10. Audit logging
|
||||
|
||||
- **Server-side concern** — the `admin/`, `flights/`, `annotations/`, etc.
|
||||
services are responsible for audit-event emission.
|
||||
- The SPA does **not** emit audit events directly. It does not maintain
|
||||
any client-side audit log.
|
||||
- The browser console is the only client-side log surface today; no
|
||||
centralized client telemetry (Step 6 surface — `_docs/02_document/deployment/observability.md`).
|
||||
|
||||
Source: `architecture.md` § 7 Audit logging.
|
||||
|
||||
---
|
||||
|
||||
## 11. Image / supply-chain
|
||||
|
||||
### Currently in pipeline
|
||||
|
||||
- Multi-stage Dockerfile: `oven/bun:1.3.11-alpine` (build) →
|
||||
`nginx:alpine` (runtime).
|
||||
- `bun install --frozen-lockfile` enforces lockfile fidelity.
|
||||
- `AZAION_REVISION=$CI_COMMIT_SHA` and OCI labels stamped at push time.
|
||||
|
||||
### Currently MISSING (Step 6 surface)
|
||||
|
||||
- **No vulnerability scan** (Trivy / Grype) on the produced image.
|
||||
- **No SBOM emission** (Syft / cyclonedx).
|
||||
- **No image signing** (cosign).
|
||||
- **No dependency audit step** in CI (`bun audit` equivalent — Bun does
|
||||
not yet have a first-party audit; `npm audit --omit=dev` against the
|
||||
lockfile is a reasonable substitute).
|
||||
|
||||
Source: `.woodpecker/build-arm.yml`; `architecture.md` § 3 "Missing from the
|
||||
pipeline today".
|
||||
|
||||
---
|
||||
|
||||
## 12. Findings → Fix Map
|
||||
|
||||
| Finding | AC | Fix step |
|
||||
|---------|----|----------|
|
||||
| Bootstrap refresh missing `credentials:'include'` (F2) | AC-01 | Step 4 (Code Testability Revision) |
|
||||
| Bearer-in-query SSE — refresh-rotation breaks subscription | AC-24 | Step 8 (Refactor — optional) or Phase B |
|
||||
| Hardcoded OpenWeatherMap key (P10) | AC-20 | Step 4 (env move); Phase B (suite proxy) |
|
||||
| `/admin` route lacks role-gate | AC-22 | Step 4 |
|
||||
| `09_settings` numeric input writes `0` on empty | AC-26 | Step 4 |
|
||||
| `09_settings` save handlers leak `saving:true` on PUT failure | AC-27 | Step 4 |
|
||||
| `AdminPage.handleDeleteClass` lacks ConfirmDialog | AC-30 | Step 4 |
|
||||
| `MediaList` uses `alert()` | AC-14 | Step 4 |
|
||||
| `ConfirmDialog` lacks `aria-modal/role=dialog` | AC-15 | Step 4 / Step 8 |
|
||||
| Header dropdown lacks combobox/expanded/Esc/focus-trap | AC-16 | Step 4 / Step 8 |
|
||||
| Annotation save body missing `Source`, `WaypointId`, wrong `time` field | AC-05 | Step 4 |
|
||||
| `X-Refresh-Token` not sent on long-video detect (#29) | — | Step 4 |
|
||||
| Numeric enum drift (`AnnotationStatus`, `MediaStatus`, `Affiliation`, `CombatReadiness`) | AC-04 | Step 4 (P9 alignment) |
|
||||
| No CSP / hardening headers in `nginx.conf` | — | Step 6 — track at suite level |
|
||||
| No vulnerability scan / SBOM / image signing in CI | — | Phase B |
|
||||
|
||||
---
|
||||
|
||||
## 13. Compliance / standards
|
||||
|
||||
The UI does NOT claim conformance to any specific standard today:
|
||||
|
||||
- **No WCAG-level declaration** (multiple a11y findings recorded).
|
||||
- **No SOC2 / ISO27001 controls** are implemented at the SPA layer
|
||||
(server-side concern of the suite).
|
||||
- **No FIPS / specific crypto-mode requirements** at the SPA layer (TLS
|
||||
is terminated server-side; bearer JWT signing is server-side).
|
||||
|
||||
These are recorded as anti-criteria (AC-N4) — the UI is **internal**,
|
||||
**operator-only**, and **trusts the suite** for compliance enforcement.
|
||||
Phase B may revisit if a regulated deployment surface emerges.
|
||||
Reference in New Issue
Block a user