feat(guide): Testing role-tab (fleet pilot) + sidebar identity indicator

- New Testing tab (order 35, audience internal, 🧪): test accounts, manual flow,
  copy-paste API smoke tests, role/scoping checks, regression watch. Fleet pilot.
- Reader shows 'Signed in as {email} · {role}' above Sign out (decoded from JWT),
  so you always know which identity you're testing as.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-01 15:26:29 -04:00
parent 3b80409718
commit 3976251b90
5 changed files with 355 additions and 3 deletions
@@ -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/<id>" -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.
@@ -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**.
@@ -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 '<name>' 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 <ID> 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":[<ID>],"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 '<name>' 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.
+12 -2
View File
@@ -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);
</script>
@@ -80,7 +90,7 @@
</div>
</div>
{:else}
<GuideReader on:logout={logout} />
<GuideReader on:logout={logout} userEmail={userInfo?.email ?? ''} userRole={userInfo?.role ?? ''} />
{/if}
<!-- Toast notifications -->
+23 -1
View File
@@ -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<string, string> = {
Customer: '👤', Internal: '🔧', Architect: '📐', Claude: '✨', Overview: '📄', Data: '🗄️', Technical: '📐',
Customer: '👤', Internal: '🔧', Architect: '📐', Testing: '🧪', Claude: '✨', Overview: '📄', Data: '🗄️', Technical: '📐',
};
$: filtered = !search.trim()
@@ -161,6 +163,13 @@
{/if}
</nav>
{#if userEmail}
<div class="side-user">
<span class="side-user-label">Signed in as</span>
<span class="side-user-email" title={userEmail}>{userEmail}</span>
{#if userRole}<span class="side-user-role">{userRole}</span>{/if}
</div>
{/if}
<button class="side-logout" on:click={() => dispatch('logout')}>Sign out</button>
</aside>
@@ -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);