diff --git a/backend/src/main/resources/content/fleet/artifacts/testing.md b/backend/src/main/resources/content/fleet/artifacts/testing.md new file mode 100644 index 0000000..5f55ad8 --- /dev/null +++ b/backend/src/main/resources/content/fleet/artifacts/testing.md @@ -0,0 +1,101 @@ +--- +module: fleet.artifacts +title: Artifact Import Guide +tab: Testing +order: 35 +audience: internal +--- + +> How to test the Fleet **Artifacts** module (owner: `hiveops-fleet`, port 8097, DB `hiveiq_fleet`, table `fleet_artifacts`). All examples target bcos.dev. External base for this module is `https://api.bcos.dev/fleet`; the frontend `fleetAPI` prefixes `/api/fleet`, so full paths are `https://api.bcos.dev/fleet/api/fleet/...`. + +## Test accounts + +All bcos.dev test users share password `Passw0rd-d3v!`. + +| Account | Role | Why it matters here | +|---------|------|---------------------| +| `bcos_a@bcos.dev` | `BCOS_ADMIN` | Sees all institutions. Can import (CDN + multipart), toggle, delete, and browse the CDN manifest. Use for the happy-path mutations. | +| `msp_a@msp1.bcos.dev` | `MSP_ADMIN` | Scoped to C1BD + C2BD. Can also import/toggle/delete + browse manifest. Use to confirm scoping and that mutate roles work below BCOS. | +| `customer_a@customer1.bcos.dev` | `CUSTOMER` | Scoped to C1BD. Read-only: **can list/view** artifacts but must **not** import, delete, toggle, or browse the CDN manifest. Use for negative role checks. | + +Only these three roles are needed. The mutate set is `ADMIN,MSP_ADMIN,BCOS_ADMIN`; the read set is `USER,CUSTOMER,ADMIN,MSP_ADMIN,BCOS_ADMIN`; the CDN-manifest browse is the narrower `MSP_ADMIN,BCOS_ADMIN`. + +## Manual test flow + +Open the Fleet app → **Artifacts** tab (`FleetArtifacts.svelte`). Log in as `bcos_a@bcos.dev` unless a step says otherwise. + +**Happy path — CDN import (preferred):** +1. Click **Import Agent**. → The panel calls `GET /api/fleet/cdn-manifest/agent` and lists packages from `cdn.bcos.cloud/downloads/agent/latest.json` (patch + standard, Windows + Linux). Expected: rows appear, no "Failed to load CDN manifest" error. +2. Import a **patch** package. → A new row appears with `artifactType` `UPDATE_AGENT` (UI label **Agent Patch**), **Enabled** = on, and a **SHA-256** column that is blank/`-`. That blank hash is EXPECTED for CDN imports — the file is HEAD-probed, never downloaded to fleet. +3. Confirm the row shows a **Global** or **Institution** badge matching whether you set an institution key (blank = Global / all institutions). +4. Click **Toggle Enabled** on the row. → It flips to disabled, then back on. (`POST /api/fleet/artifacts/{id}/toggle-enabled`.) +5. **Delete** the test row while no task references it. → Row disappears (`DELETE /api/fleet/artifacts/{id}` returns 200/204). + +**Edge case — delete blocked by an active task (409):** +1. Import an artifact, then create a fleet task that references it and leave it `PENDING/QUEUED/RUNNING`. +2. Try to delete the artifact. → Expected **409 Conflict** ("delete blocked while a task references it"). This is intentional. Cancel/drain the task, then delete succeeds. + +**Edge case — unsupported manifest type:** there is no Module manifest. `GET /api/fleet/cdn-manifest/modules` returns **400 Unknown manifest type** — only `agent`, `hotfix`, `software` resolve. Don't expect a "Import Module" from CDN to work. + +## API smoke test + +```bash +# 1. Login → capture JWT +TOKEN=$(curl -s -X POST https://api.bcos.dev/auth/api/login \ + -H 'Content-Type: application/json' \ + -d '{"email":"bcos_a@bcos.dev","password":"Passw0rd-d3v!"}' \ + | python3 -c 'import sys,json;print(json.load(sys.stdin)["token"])') + +BASE=https://api.bcos.dev/fleet/api/fleet + +# 2. List artifacts (read) → 200, JSON array +curl -s -o /dev/null -w '%{http_code}\n' "$BASE/artifacts" \ + -H "Authorization: Bearer $TOKEN" + +# 3. Filter by exact enum value (NOT the UI label) → 200 +curl -s "$BASE/artifacts?type=UPDATE_AGENT" \ + -H "Authorization: Bearer $TOKEN" | python3 -m json.tool | head + +# 4. Browse the CDN agent manifest (MSP_ADMIN/BCOS_ADMIN only) → 200 +curl -s -o /dev/null -w '%{http_code}\n' "$BASE/cdn-manifest/agent" \ + -H "Authorization: Bearer $TOKEN" + +# 5. Unsupported manifest type → 400 Unknown manifest type +curl -s -o /dev/null -w '%{http_code}\n' "$BASE/cdn-manifest/modules" \ + -H "Authorization: Bearer $TOKEN" +``` + +**Mutation (WRITES a row — run only against dev, then delete it):** +```bash +# import-cdn: JSON body. artifactType must be the enum value, not the UI label. +curl -s "$BASE/artifacts/import-cdn" \ + -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ + -d '{"cdnUrl":"https://cdn.bcos.cloud/downloads/agent/latest.json", + "name":"smoke-test-agent","version":"0.0.0-test", + "artifactType":"UPDATE_AGENT","institutionKey":"C1BD"}' +# → 200 + new artifact JSON (cdn_url set, no sha256_hash). Delete it afterward: +# curl -s -X DELETE "$BASE/artifacts/" -H "Authorization: Bearer $TOKEN" +``` + +Expected shapes: list/get return artifact objects with `artifactType`, `enabled`, `institutionKey` (null = Global), `cdnUrl`, and `sha256Hash` (null for CDN imports, set for multipart uploads). + +## Verify roles & scoping + +Get a token for each role (swap the email/password in the login snippet), then against `$BASE`: + +- **CUSTOMER read allowed:** `GET /artifacts` as `customer_a@customer1.bcos.dev` → **200**. +- **CUSTOMER mutate denied:** `POST /artifacts/import-cdn` and `DELETE /artifacts/{id}` as `customer_a` → **403** (mutate set is `ADMIN,MSP_ADMIN,BCOS_ADMIN`). +- **CUSTOMER manifest denied:** `GET /cdn-manifest/agent` as `customer_a` → **403** (browse is `MSP_ADMIN,BCOS_ADMIN` only). This is the narrowest gate — verify a `CUSTOMER` and a plain `USER`/`ADMIN`-below check both fail it. +- **MSP_ADMIN allowed:** as `msp_a@msp1.bcos.dev`, `import-cdn`, `toggle-enabled`, `delete`, and `cdn-manifest/agent` all succeed. +- **Institution scoping:** import an artifact with `institutionKey: "C1BD"` and one with a blank key (Global). Confirm `customer_a` (C1BD) sees the C1BD-scoped one and the Global one, but not a C2BD/C3BD-scoped artifact. An artifact with a blank `institutionKey` is Global (all institutions); a set key scopes it. `institution_key` is capped at 10 chars. + +## Watch for (regressions) + +- **Blank SHA-256 on CDN imports flagged as a bug** — it is expected. Only multipart uploads (`POST /artifacts`) compute `sha256_hash` + `storage_path`. Don't "fix" a CDN row's missing hash. +- **`410 GONE` on task download** — pointing an agent at fleet's chunk endpoint (`/tasks/{id}/download`) for a CDN-only artifact. CDN artifacts must download from `cdnUrl` directly; the agent must support `artifactCdnUrl` in `tasks/next`. +- **`download_offset` stuck at 0 read as a stall** — for CDN artifacts the download bypasses fleet, so offset 0 is NORMAL. +- **UI label sent as `artifactType`** — sending `"Agent Patch"` instead of `UPDATE_AGENT` (or `modules` to the manifest endpoint) must fail. Enum values are `UPDATE_AGENT · UPDATE_HOTFIX · UPDATE_SOFTWARE · UPDATE_MODULE · SCRIPT`. +- **Manifest panel "Failed to load CDN manifest"** — usually `CDN_TOKEN` missing/stale on the fleet container (sent as `X-CDN-Token`). Curl the `latest.json` with that header from the fleet host to confirm. +- **Delete not returning 409 while a `PENDING/QUEUED/RUNNING` task references the artifact** — that guard must hold; a successful delete under an active task is a regression. +- **Multipart uploads over 100 MB accepted** — the cap is 100 MB; oversized files must be rejected and routed via CDN import. +- **Artifact written outside `hiveiq_fleet.fleet_artifacts`** — fleet is the sole owner; no artifact endpoint should appear on incident/devices/mgmt. diff --git a/backend/src/main/resources/content/fleet/patch-windows/testing.md b/backend/src/main/resources/content/fleet/patch-windows/testing.md new file mode 100644 index 0000000..6ef7012 --- /dev/null +++ b/backend/src/main/resources/content/fleet/patch-windows/testing.md @@ -0,0 +1,105 @@ +--- +module: fleet.patch-windows +title: Patch Window Guide +tab: Testing +order: 35 +audience: internal +--- + +How to test the Patch Windows module on **bcos.dev**. Backend: `hiveops-fleet` (port 8097, DB `hiveiq_fleet`). Frontend: `FleetManagement/PatchWindows.svelte` at `fleet.bcos.dev` → Fleet Management → **Patch Windows** tab. + +All test users share password `Passw0rd-d3v!`. + +## Test accounts + +Which accounts matter for this module and why: + +| Account | Role | Use it to test | +|---------|------|----------------| +| `bcos_a@bcos.dev` | `BCOS_ADMIN` | Unrestricted (scope = null). Create/edit/delete any window, assign ATMs, verify cross-institution visibility. | +| `msp_a@msp1.bcos.dev` | `MSP_ADMIN` | Scoped to **C1BD + C2BD**. Can create/edit within scope; must get **403** on windows for `C3BD`. | +| `customer_a@customer1.bcos.dev` | `CUSTOMER` | Scoped to **C1BD**, read-only. Can list/view C1BD windows; must **403** on any create/update/delete and on cross-institution reads. | + +Institutions: `C1BD`, `C2BD`, `C3BD`. Live simulator devices: `C1-ATM-001…`, `C1-TCR-001…`, `C1-SRV-001…` (use a `C1-ATM-*` id for assignment tests). + +## Manual test flow + +Log in to `fleet.bcos.dev` as `bcos_a@bcos.dev`, open Fleet Management → **Patch Windows**. + +### Happy path — create, assign, schedule + +1. Click **+ New Patch Window**. Name it `qa-weekly`, institution `C1BD`, pick a day, set start/end time and timezone, save. + - **Expected:** row appears in the table; toolbar count increments; Schedule and Timezone columns render. +2. Click the **terminals** link (ATMs column) on the `qa-weekly` row. + - **Expected:** ATM assignment panel opens listing currently-assigned devices (empty at first). +3. Assign a `C1-ATM-*` device to the window (via the assignment action / Device Management per the Customer tab), then reopen the panel. + - **Expected:** the device is listed. DB check: `SELECT * FROM patch_window_devices WHERE atm_id='C1-ATM-001';` returns one row. +4. Create a `HOTFIX_INSTALL` fleet task for that ATM with **no explicit `scheduledAt`**. + - **Expected:** task lands in the **Scheduled** tab as `PENDING_APPROVAL` with `scheduled_at` = the window's next start slot (stored UTC), and the Fleet Tasks row shows `Window: qa-weekly · Scheduled …`. + +### Edge case — the `days_of_week` short-code gotcha (#1 failure mode) + +5. Create a window through the **UI** and assign an ATM, then create a `HOTFIX_INSTALL` for it. + - **What to watch:** the UI stores short codes (`MON,TUE,…`) but `PatchWindowService.nextOccurrence` calls `DayOfWeek.valueOf(...)`, which needs full names (`MONDAY`). Short codes throw, `FleetTaskService` catches + logs `Could not compute next patch window for device …`, and leaves `scheduled_at = null`. + - **Expected (current behavior):** the install becomes **immediately dispatchable after approval** — no window hold. Confirm with `SELECT days_of_week FROM patch_windows WHERE name='...';` (short codes) and a null `scheduled_at`. This is the canonical "window ignored" regression — verify it every release. + +### Edge case — approval gate + +6. With a correctly scheduled `HOTFIX_INSTALL` (`scheduled_at` in the future), do nothing. + - **Expected:** task stays `PENDING_APPROVAL`; it will **not** dispatch until an approver (`FLEET_APPROVER` claim or `BCOS_ADMIN`) approves — even after the window opens. + +## API smoke test + +External base on bcos.dev is `https://api.bcos.dev`; the fleet gateway prefix is `/fleet`, so paths below map to the backend controller `/api/fleet/patch-windows`. + +```bash +# 1. Login → $TOKEN +TOKEN=$(curl -s -X POST https://api.bcos.dev/auth/api/login \ + -H 'Content-Type: application/json' \ + -d '{"email":"bcos_a@bcos.dev","password":"Passw0rd-d3v!"}' \ + | python3 -c "import sys,json; print(json.load(sys.stdin)['token'])") + +# 2. GET list — 200, JSON array of windows (each has id, name, institutionKey, daysOfWeek[], timezone, deviceCount) +curl -s https://api.bcos.dev/fleet/patch-windows \ + -H "Authorization: Bearer $TOKEN" | python3 -m json.tool + +# 3. GET one window by id — 200 (use an id from step 2); 404 if missing +curl -s https://api.bcos.dev/fleet/patch-windows/1 \ + -H "Authorization: Bearer $TOKEN" + +# 4. GET devices attached to a window — 200, array (empty [] if none) +curl -s https://api.bcos.dev/fleet/patch-windows/1/atms \ + -H "Authorization: Bearer $TOKEN" + +# 5. (MUTATION — writes a row) POST create a window. +# Body = CreatePatchWindowRequest: institutionKey, name, daysOfWeek[], weekOfMonth?, startTime, endTime, timezone. +# NOTE: send FULL DayOfWeek names (MONDAY) so nextOccurrence can schedule — short codes (MON) silently break scheduling. +curl -s -X POST https://api.bcos.dev/fleet/patch-windows \ + -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ + -d '{"institutionKey":"C1BD","name":"qa-smoke","daysOfWeek":["MONDAY"],"startTime":"02:00","endTime":"04:00","timezone":"America/New_York"}' +# Expected: 200/201 with the created window. Re-running with the same (institutionKey,name) → 409 (unique per institution). +``` + +> The `?institution=` param the UI sends is **ignored** by the controller (it reads `institutionKey`). A bare list returns everything the caller's scope allows, not a filtered set. To actually filter, pass `?institutionKey=C1BD`. + +## Verify roles & scoping + +`create` does **not** scope-check (trusts body `institutionKey`); every other read/mutate runs `assertInScope`. Run each check with the account's `$TOKEN`. + +- **CUSTOMER read-only:** as `customer_a@customer1.bcos.dev`, `GET /fleet/patch-windows` → 200 (C1BD windows only). `POST/PUT/DELETE /fleet/patch-windows/...` → **403**. +- **MSP_ADMIN in scope:** as `msp_a@msp1.bcos.dev`, list/create/edit a **C1BD** or **C2BD** window → allowed. `GET /fleet/patch-windows/{id}` or `PUT` on a **C3BD** window → **403** (out of scope). +- **BCOS_ADMIN unrestricted:** as `bcos_a@bcos.dev`, sees and mutates windows across C1BD/C2BD/C3BD (scope resolves to null). +- **Cross-institution read block:** as `customer_a`, GET a window whose `institution_key` is `C2BD`/`C3BD` → **403**, not 200. +- **Guide/role-gated content:** the ⓘ **Guide** panel (`GuidePanel pageKey="fleet.patch-windows"`) should show the Customer tab to customers; internal tabs (Internal/Claude/Testing, `audience: internal`) must **not** surface to a `CUSTOMER` login. + +## Watch for (regressions) + +Tied to the module's known gotchas: + +- **Window ignored / install runs immediately** — the `days_of_week` short-code vs `DayOfWeek.valueOf` bug. If a UI-created window stores `MON,TUE` instead of `MONDAY,TUESDAY`, `scheduled_at` stays null and the hold is lost. Grep logs for `Could not compute next patch window for device`. #1 thing to re-verify. +- **ATM count column blank/`undefined terminals`** — DTO returns `deviceCount` but the frontend reads `atmCount` (field drift). The DB count via `countByPatchWindowId` is authoritative: `SELECT count(*) FROM patch_window_devices WHERE patch_window_id = ?;`. +- **Institution filter does nothing** — UI sends `?institution=`, controller wants `?institutionKey=`. Filtering "returns everything" is expected until aligned. +- **No end-time cutoff** — dispatch (`GET /api/internal/fleet/tasks/next`) gates only on `scheduledAt <= now`. A task eligible at the window **start** stays dispatchable indefinitely; there is no hard stop at window end. Don't file "task ran after the window closed" as a new bug — it's by design today. +- **ATM in two windows** — schema only enforces `UNIQUE(patch_window_id, atm_id)`, not one-window-per-ATM; the scheduler uses `findByAtmId(...).findFirst()` (arbitrary pick). Assigning the same ATM to two windows should not error but is unsupported. +- **Approval swallows the schedule** — `HOTFIX_INSTALL`/`SOFTWARE_INSTALL` are `PENDING_APPROVAL`; only these `_INSTALL` kinds are window-scheduled. Immediate `HOTFIX`/`SOFTWARE` (download) tasks are never window-gated — don't expect them to schedule. +- **Duplicate name** — create/update with an existing `(institution_key, name)` must return **409**. diff --git a/backend/src/main/resources/content/fleet/tasks/testing.md b/backend/src/main/resources/content/fleet/tasks/testing.md new file mode 100644 index 0000000..1dbfd53 --- /dev/null +++ b/backend/src/main/resources/content/fleet/tasks/testing.md @@ -0,0 +1,114 @@ +--- +module: fleet.tasks +title: Fleet Task Workflow +tab: Testing +order: 35 +audience: internal +--- + +How to test Fleet Task Workflow (owner service **hiveops-fleet**) on the bcos.dev environment. Everything below maps to real endpoints (`FleetApiController` / `InternalFleetController`) and the `FleetManagement/FleetTasks.svelte` UI. Test env base URL: `https://api.bcos.dev/fleet`, so full user endpoint = `https://api.bcos.dev/fleet/api/fleet/...`. Frontend: `fleet.bcos.dev`. + +## Test accounts + +All test users share password `Passw0rd-d3v!`. For this module you mainly need three: + +| Account | Role | Why it matters here | +|---------|------|--------------------| +| `bcos_a@bcos.dev` | BCOS_ADMIN | Sees all institutions; **can always approve** (BCOS_ADMIN bypasses the `fleetTaskApprover` claim). Use to create + approve HOTFIX/SOFTWARE tasks and to confirm cross-institution visibility. | +| `msp_a@msp1.bcos.dev` | MSP_ADMIN (scoped C1BD+C2BD) | Can **create/cancel/retry** but **cannot approve** unless it also carries the `fleetTaskApprover` claim. Use to test institution scoping and the "MSP can create, not approve" gotcha. | +| `customer_a@customer1.bcos.dev` | CUSTOMER (scoped C1BD) | Read-only for this module: can **view** tasks/history/stats/devices but any create/cancel/retry/approve must **403**. Use to verify scoping (should see only C1BD) and role gating. | + +Target devices are the live simulators per institution: `C1-ATM-001..`, `C1-TCR-001..`, `C1-SRV-001..` (and C2-/C3- for the other institutions). `atmIds` in the create request are `device_summary.id` values — get them from `GET /api/fleet/devices`, not the device name. + +## Manual test flow + +Do these in the `fleet.bcos.dev` UI (Fleet Tasks → tabs **Current / Completed / Failed / Approvals**). + +### Happy path — create a non-approval task (as `msp_a`) + +1. Log in as `msp_a@msp1.bcos.dev`, open **Fleet Tasks**. +2. Click **Create Task**. Pick a simple kind such as **REBOOT** or **CONFIG_UPDATE**, select one online C1 device (e.g. `C1-ATM-001`), submit. + - **Expected:** task appears in the **Current** tab with status **PENDING** (non-approval kinds start PENDING). +3. Wait for the device to poll (agent claims via `/tasks/next`). + - **Expected:** status advances **PENDING → QUEUED → RUNNING → COMPLETED**. Once **COMPLETED** it is **archived immediately** — it disappears from Current and shows under **Completed** (history), not in the active list. +4. Open the **Failed** tab and confirm nothing spurious landed there. + +### Approval path — HOTFIX/SOFTWARE (needs `bcos_a`) + +1. As `msp_a`, create a **HOTFIX** (or **SOFTWARE**) task against a C1 device using an **enabled** artifact. + - **Expected:** task is created with status **PENDING_APPROVAL** (only `HOTFIX`, `HOTFIX_INSTALL`, `SOFTWARE`, `SOFTWARE_INSTALL` start here). +2. Still as `msp_a`, open the **Approvals** tab. + - **Expected:** if `msp_a` lacks the `fleetTaskApprover` claim, it can see the queue (MSP_ADMIN may list) but **Approve/Reject must fail** — the action returns 403 / is not offered. +3. Log in as `bcos_a@bcos.dev`, open **Approvals**, add an optional note, click **Approve**. + - **Expected:** task moves **PENDING_APPROVAL → PENDING**, then proceeds as the happy path once the device polls. **Reject** instead → **REJECTED**, archived immediately. + +### Edge case — all targets skipped (silent skip / HTTP 207) + +Grounded in the "create silently skips" gotcha. + +1. As `msp_a`, create any task but target **only an offline device** (a device whose `lastHeartbeat` is null or > 15 min old) or a device outside C1BD/C2BD scope. + - **Expected:** **no task appears** in Current. The POST returned **HTTP 207** with an **empty array** — it does *not* error loudly. Confirm via API (below) or by grepping the fleet backend log (Loki) for `skipping task creation` / `is offline` / `out of scope`. +2. Second edge case: create a HOTFIX referencing a **disabled** artifact. + - **Expected:** **HTTP 400** `Artifact '' is disabled`. Enable it in the Artifacts view first, then retry. + +## API smoke test + +Get a token, then hit this module's real endpoints. Reads are safe; the mutation is flagged. + +```bash +# 1) Login → $TOKEN +TOKEN=$(curl -s -X POST https://api.bcos.dev/auth/api/login \ + -H 'Content-Type: application/json' \ + -d '{"email":"bcos_a@bcos.dev","password":"Passw0rd-d3v!"}' \ + | python3 -c 'import sys,json;print(json.load(sys.stdin)["token"])') + +BASE=https://api.bcos.dev/fleet + +# 2) List tasks (paginated) — expect 200, page object of active tasks +curl -s "$BASE/api/fleet/tasks/paginated?page=0&size=20" \ + -H "Authorization: Bearer $TOKEN" | head -c 400 + +# 3) Am I an approver? — expect 200 {"fleetTaskApprover": true|false} +curl -s "$BASE/api/fleet/users/me" -H "Authorization: Bearer $TOKEN" + +# 4) Target devices (mirror) — grab device_summary.id values for atmIds +curl -s "$BASE/api/fleet/devices" -H "Authorization: Bearer $TOKEN" | head -c 400 + +# 5) Pending-approval queue — expect 200 array (empty if none) +curl -s "$BASE/api/fleet/tasks/pending-approval" -H "Authorization: Bearer $TOKEN" + +# 6) History (defaults to last 90 days) — expect 200 +curl -s "$BASE/api/fleet/tasks/history/recent" -H "Authorization: Bearer $TOKEN" | head -c 400 +``` + +Mutation (WRITES — creates a real task, only run against a disposable device): + +```bash +# POST /api/fleet/tasks — REBOOT one device. Replace with a real device_summary.id. +# Expect 200/201 with the created task, OR HTTP 207 + [] if the target was skipped (offline/out-of-scope). +curl -s -o /dev/null -w '%{http_code}\n' -X POST "$BASE/api/fleet/tasks" \ + -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ + -d '{"atmIds":[],"taskKind":"REBOOT"}' +``` + +Do **not** call `/api/internal/fleet/**` in smoke tests — those require the `X-Internal-Secret` header (agent-proxy only) and a user JWT will 403. + +## Verify roles & scoping + +- **CUSTOMER is read-only.** As `customer_a`, `GET /api/fleet/tasks/paginated` → **200**, but `POST /api/fleet/tasks` (and `/cancel`, `/retry`, `/approve`) → **403** (create/cancel/retry require `ADMIN`/`MSP_ADMIN`/`BCOS_ADMIN`). +- **Institution scoping.** As `customer_a` (C1BD) you should see **only C1** devices/tasks. As `msp_a` (C1BD+C2BD) you should see C1 and C2 but **not C3**. As `bcos_a` you should see **all** institutions. Confirm via `GET /api/fleet/devices` and `GET /api/fleet/tasks` returning only in-scope institutions. +- **Approver gating (the key one).** `GET /api/fleet/users/me` → check `fleetTaskApprover`. A plain `MSP_ADMIN` **without** the claim: `POST /api/fleet/tasks/{id}/approve` and `/reject` must **403**. `BCOS_ADMIN` (`bcos_a`) always succeeds. `fleetTaskApprover` is a Boolean JWT claim, **not** a `ROLE_`. +- **Scope on create.** Have `msp_a` target a **C3** device id in `atmIds` → it is silently dropped (out of scope); if that was the only target the POST returns **207 + []**, not a task. +- **Clear history is scoped.** `DELETE /api/fleet/tasks/history` only clears the caller's own institutions — as `msp_a` it must not touch C3 history. + +## Watch for (regressions) + +Tied to the known gotchas — these are the things most likely to break: + +- **Silent-skip masking failures.** A create that targets offline (>15 min heartbeat) / out-of-scope / unknown devices returns **207 + empty array** instead of erroring. Regression = it starts 500-ing, or (worse) creates tasks against offline/out-of-scope devices. Always check the returned array length, not just the HTTP status. +- **COMPLETED vs FAILED archival.** COMPLETED must be archived+deleted from `fleet_tasks` immediately (shows only in Completed/history). FAILED must **stay** in the active list (retryable) and must **not** be swept by the 5-min archiver. Regression = FAILED disappearing, or COMPLETED lingering in Current. +- **Stale self-heal.** A QUEUED/RUNNING task with no update for >5 min must auto-reset to **PENDING** with `downloadOffset=0` on the next poll. Regression = tasks wedged in QUEUED/RUNNING forever. +- **Approval enum coverage.** Only `HOTFIX`, `HOTFIX_INSTALL`, `SOFTWARE`, `SOFTWARE_INSTALL` start `PENDING_APPROVAL`; all other kinds start `PENDING`. Regression = a normal kind (REBOOT/CONFIG_UPDATE) getting stuck in PENDING_APPROVAL, or a hotfix skipping approval. +- **Approve/reject 409.** Approving a task that is no longer `PENDING_APPROVAL` must **409** (not silently double-apply). Same for cancel on a terminal task and retry on a non-FAILED/CANCELLED task. +- **Disabled-artifact guard.** Create with a disabled artifact must **400** `Artifact '' is disabled` — not create a task that later fails on the device. +- **Don't trust `GET /stats`.** `pendingTasks` only counts `AUTO_UPDATE`+`PENDING`; `completed`/`failed` are hardcoded `0`. Verify counts against the tabs/history, and don't file a "stats wrong" bug for this known limitation. diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index db4959c..f6de551 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -11,6 +11,14 @@ let showPassword = false; let loginError = ''; let loggingIn = false; + let userInfo: { email: string; role: string } | null = null; + + function decodeJwt(t: string): { email: string; role: string } | null { + try { + const p = JSON.parse(atob(t.split('.')[1].replace(/-/g, '+').replace(/_/g, '/'))); + return { email: p.email ?? '', role: p.role ?? '' }; + } catch { return null; } + } async function checkAuth() { const t = getToken(); @@ -18,6 +26,7 @@ try { await authApi.get('/api/users/me', { headers: { Authorization: `Bearer ${t}` } }); authenticated = true; + userInfo = decodeJwt(t); } catch { clearToken(); } finally { @@ -33,6 +42,7 @@ const token = res.data.token; if (!token) throw new Error('No token in response'); setToken(token); + userInfo = decodeJwt(token); authenticated = true; } catch (e: any) { loginError = e?.response?.data?.message ?? 'Invalid email or password.'; @@ -41,7 +51,7 @@ } } - function logout() { clearToken(); authenticated = false; } + function logout() { clearToken(); authenticated = false; userInfo = null; } onMount(checkAuth); @@ -80,7 +90,7 @@ {:else} - + {/if} diff --git a/frontend/src/components/GuideReader.svelte b/frontend/src/components/GuideReader.svelte index 889bc69..70a5961 100644 --- a/frontend/src/components/GuideReader.svelte +++ b/frontend/src/components/GuideReader.svelte @@ -6,6 +6,8 @@ import { addToast } from '../lib/stores'; const dispatch = createEventDispatcher(); + export let userEmail = ''; + export let userRole = ''; let nav: NavApp[] = []; let loadingNav = true; @@ -33,7 +35,7 @@ // role-tab icons (Customer / Internal / Architect / Claude) const ROLE_ICON: Record = { - Customer: '👤', Internal: '🔧', Architect: '📐', Claude: '✨', Overview: '📄', Data: '🗄️', Technical: '📐', + Customer: '👤', Internal: '🔧', Architect: '📐', Testing: '🧪', Claude: '✨', Overview: '📄', Data: '🗄️', Technical: '📐', }; $: filtered = !search.trim() @@ -161,6 +163,13 @@ {/if} + {#if userEmail} +
+ Signed in as + {userEmail} + {#if userRole}{userRole}{/if} +
+ {/if} @@ -258,6 +267,19 @@ .nav-item:hover { background: rgba(255,255,255,0.08); color: white; } .nav-item.active { background: rgba(255,255,255,0.14); color: white; border-left-color: white; font-weight: 600; } + .side-user { + margin: 0.4rem 1rem 0.2rem; padding: 0.5rem 0.15rem 0.4rem; + border-top: 1px solid rgba(255,255,255,0.12); + display: flex; flex-direction: column; gap: 1px; + } + .side-user-label { color: rgba(255,255,255,0.5); font-size: 0.62rem; text-transform: uppercase; letter-spacing: 0.05em; } + .side-user-email { color: rgba(255,255,255,0.92); font-size: 0.78rem; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .side-user-role { + align-self: flex-start; margin-top: 3px; + color: #93c5fd; background: rgba(59,130,246,0.18); border: 1px solid rgba(59,130,246,0.35); + font-size: 0.6rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em; + border-radius: 4px; padding: 1px 6px; + } .side-logout { margin: 0.5rem 1rem 1rem; padding: 0.5rem; border-radius: 6px; background: rgba(255,255,255,0.1); border: 1px solid rgba(255,255,255,0.2);