Files
netbird/docs/agent-networks/60-qa-test-cases.md
T
Claude f670a9cbff [docs] Add Agent Network QA test-case catalogue
Adds docs/agent-networks/60-qa-test-cases.md: a prioritised test-case
list covering bootstrap/settings, provider and policy CRUD and
validation, guardrails, policy selection and limit enforcement, runtime
routing over the tunnel, usage/cost accounting, access logs and
retention, RBAC and tenancy, the dashboard surfaces, focused mode and
onboarding, plus cross-cutting resilience and security checks.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 03:34:37 +00:00

405 lines
56 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Agent Network — QA test cases
A manual/automatable test-case catalogue for the Agent Network feature,
covering the management API (`management/internals/modules/agentnetwork`),
the synthesised reverse-proxy runtime, and the dashboard surface
(`netbirdio/dashboard`, `src/modules/agent-network` + `src/app/(dashboard)/agent-network`).
Companion reading: [00-overview.md](00-overview.md) for the feature map,
[01-end-to-end-flows.md](01-end-to-end-flows.md) for the request lifecycle,
[modules/40-dashboard.md](modules/40-dashboard.md) for the UI.
---
## 1. Scope and conventions
**In scope:** providers, policies, guardrails, account budget rules,
agent-network settings, endpoint bootstrap, request authorisation and routing,
limit enforcement, cost/usage accounting, access logs and sessions, log/prompt
collection, RBAC, the dashboard surfaces and the Agent Network onboarding.
**Out of scope (covered elsewhere):** generic reverse-proxy cluster
provisioning, WireGuard connectivity, IdP/SSO login, billing.
**Priorities**
| Level | Meaning |
|---|---|
| **P0** | Blocks release. Core happy path, data isolation, security, money/limits correctness. |
| **P1** | Important. Validation, secondary flows, most negative cases. |
| **P2** | Nice to have. Cosmetic, rare edge cases, resilience. |
**HTTP status conventions used below** (from `shared/management/http/util/util.go`):
| Condition | Status |
|---|---|
| Validation failure (`status.InvalidArgument`) | **422** Unprocessable Entity |
| Malformed JSON body | **400** Bad Request |
| Missing/insufficient permission | **403** Forbidden |
| Unknown id / not bootstrapped | **404** Not Found |
All endpoints are under `/api/agent-network/...`.
---
## 2. Environment prerequisites
| # | Requirement |
|---|---|
| E1 | A management deployment with at least one **validated FREE reverse-proxy cluster/domain** (the provider wizard only offers `type=FREE && validated=true`). Without one, provider create is blocked in the UI. |
| E2 | At least one proxy instance connected and serving the cluster. |
| E3 | Two NetBird accounts (**Account A**, **Account B**) for isolation tests. |
| E4 | In Account A: users `owner@`, `admin@`, `user@`, plus an `auditor`-role user if available. |
| E5 | Groups: `g-eng`, `g-contractors`, `g-none` (a group with no policy). Peers enrolled and assigned so caller group membership is deterministic. |
| E6 | At least one client machine running the NetBird agent and connected to the tunnel (traffic reaches the endpoint **only** over the tunnel). |
| E7 | Real or mocked upstream credentials for: OpenAI (`openai_api`), Anthropic (`anthropic_api`), one gateway (`litellm_proxy` or `portkey`), and — if testing path-routed providers — Bedrock and/or Vertex (`keyfile::<base64 GCP SA JSON>`). |
| E8 | A tool to issue raw HTTP (curl / Postman) plus at least one real agent (Claude Code or Codex) for the connect-snippet checks. |
**Reference data**
- Catalog ids: `openai_api`, `anthropic_api`, `azure_openai_api`, `bedrock_api`,
`vertex_ai_api`, `mistral_api`, `kimi_api` (kind=provider); `litellm_proxy`,
`portkey`, `bifrost`, `cloudflare_ai_gateway`, `vercel_ai_gateway`,
`openrouter` (kind=gateway); `vllm`, `custom` (kind=custom).
- Deny codes: `llm_policy.token_cap_exceeded`, `llm_policy.budget_cap_exceeded`,
`llm_account.token_cap_exceeded`, `llm_account.budget_cap_exceeded`,
`llm_policy.model_blocked`.
- Permission modules: `agent_network.providers|policies|guardrails|budgets|usage|logs|settings`,
with the `agent_network` parent cascading to all of them.
---
## 3. Bootstrap and account settings
| ID | Title | P | Preconditions | Steps | Expected |
|---|---|---|---|---|---|
| **AN-BS-01** | Settings read before bootstrap returns defaults | P0 | Fresh account, no provider created | `GET /agent-network/settings` | 200. `cluster`, `subdomain`, `endpoint` all empty; `enable_log_collection=true`; `enable_prompt_collection=false`; `redact_pii=false`; `access_log_retention_days=30`; no `created_at`/`updated_at`. Nothing is persisted (repeat the GET — still no row). |
| **AN-BS-02** | First provider create bootstraps settings | P0 | AN-BS-01 | Create a provider with `bootstrap_cluster=<validated cluster>` | Provider created. `GET /agent-network/settings` now returns the pinned `cluster`, a generated word-based `subdomain`, and `endpoint = "<subdomain>.<cluster>"`. Timestamps present. |
| **AN-BS-03** | Subdomain is unique per cluster | P1 | Several accounts bootstrapped on the same cluster | Bootstrap 5+ accounts on one cluster; collect subdomains | All subdomains distinct; each is a readable word-label with a short account-id suffix. |
| **AN-BS-04** | Bootstrap hint ignored once settings exist | P1 | AN-BS-02 done | Create a second provider passing a *different* `bootstrap_cluster` | Provider created; settings `cluster`/`subdomain`/`endpoint` unchanged. |
| **AN-BS-05** | PUT settings bootstraps when a cluster is supplied | P1 | Fresh account | `PUT /agent-network/settings` with `cluster=<validated cluster>` and the toggles | 200; row created with that cluster and a generated subdomain; toggles applied. |
| **AN-BS-06** | PUT settings without cluster on un-bootstrapped account | P1 | Fresh account | `PUT /agent-network/settings` omitting `cluster` | 404, message names that settings have not been bootstrapped and that `cluster` (or a provider create with `bootstrap_cluster`) is required. |
| **AN-BS-07** | Cluster is immutable | P0 | Bootstrapped account | `PUT /agent-network/settings` with a *different* `cluster` | 422, message `cluster is immutable once assigned (current: <cluster>)`. Stored cluster unchanged. |
| **AN-BS-08** | PUT settings with the same cluster is accepted | P1 | Bootstrapped | `PUT` echoing the current cluster + changed toggles | 200; toggles updated; cluster/subdomain unchanged. |
| **AN-BS-09** | Subdomain can never be set by the client | P1 | Bootstrapped | `PUT` with a `subdomain` field in the body | Server value unchanged (field is server-assigned and ignored). |
| **AN-BS-10** | Collection toggles round-trip | P1 | Bootstrapped | Toggle `enable_log_collection`, `enable_prompt_collection`, `redact_pii`, and each retention option (7/14/30/60/90/0) | Each `PUT` returns the requested value; a subsequent `GET` matches; the dashboard **Configuration → Log Collection** tab reflects it after reload. |
| **AN-BS-11** | Settings change is audited | P2 | Bootstrapped | Change a toggle, then open **Activity/Events** | An `agent network settings updated` event exists with `log_collection`, `prompt_collection`, `redact_pii` in its metadata. |
| **AN-BS-12** | Concurrent settings PUTs don't interleave | P2 | Bootstrapped | Fire 10 concurrent `PUT`s with alternating toggle values | No 500s; final stored state equals one of the submitted payloads (no torn write); cluster unchanged. |
---
## 4. Providers — CRUD and validation
| ID | Title | P | Preconditions | Steps | Expected |
|---|---|---|---|---|---|
| **AN-PR-01** | Catalog listing | P0 | Authenticated | `GET /agent-network/catalog/providers` | 200 with all catalog entries; each has `id`, `name`, `kind` (`provider`/`gateway`/`custom`), `default_host`, and models with `input_per_1k`/`output_per_1k` (+ cache rates where applicable). Rates match the **live** default pricing table, not stale compiled rates. |
| **AN-PR-02** | Create OpenAI provider (happy path) | P0 | Cluster available | POST with `provider_id=openai_api`, name, `upstream_url=https://api.openai.com`, `api_key`, models, `enabled=true` | 201/200; response contains the provider with `id`; **`api_key` is absent from the response**. |
| **AN-PR-03** | Create Anthropic provider | P0 | — | Same with `provider_id=anthropic_api`, `https://api.anthropic.com` | Created. |
| **AN-PR-04** | Create gateway provider with extra headers | P1 | — | Create `portkey` and fill `extra_values["x-portkey-config"]` | Created; `extra_values` round-trips on GET. |
| **AN-PR-05** | Extra values are restricted to catalog-declared keys | P1 | AN-PR-04 | Send an `extra_values` key the catalog doesn't declare (e.g. `x-bogus`) | Undeclared key is not persisted/returned. Empty-string values are dropped rather than stored. |
| **AN-PR-06** | `provider_id` is required and must be a known catalog id | P0 | — | POST with `provider_id=""`, then `provider_id="not_a_provider"` | 422 `provider_id is required`; 422 `provider_id "not_a_provider" is not a known catalog provider`. |
| **AN-PR-07** | `name` required | P1 | — | POST with blank/whitespace name | 422 `name is required`. |
| **AN-PR-08** | `upstream_url` validation | P0 | — | POST with: empty; `not-a-url`; `ftp://x`; `https://` (no host) | 422 in every case (`upstream_url is required` / `must be a full http(s) URL`). |
| **AN-PR-09** | `api_key` required on create | P0 | — | POST omitting `api_key`, then with `" "` | 422 `api_key is required`. No provider row is created. |
| **AN-PR-10** | Model rate validation | P0 | — | POST models with: empty `id`; `input_per_1k = -1`; `output_per_1k = NaN`; `cached_input_per_1k = Inf` | 422 each; message names the index, model id and offending field (`models[0] (gpt-4o): input_per_1k must be a finite, non-negative USD rate`). |
| **AN-PR-11** | Empty model list means "all catalog models at catalog prices" | P1 | — | Create with `models: []`, then route a request for any catalog model | Request routes and is billed at the default catalog rate for that model. |
| **AN-PR-12** | Update preserves API key when omitted | P0 | Provider exists | `PUT` the provider without `api_key`, changing only the name | 200; name changed; upstream calls still succeed (stored key intact). |
| **AN-PR-13** | Blank API key on update is rejected | P0 | Provider exists | `PUT` with `api_key: " "` | 422 `api_key must be non-blank when rotating an agent network provider`. Stored key untouched. |
| **AN-PR-14** | API key rotation | P0 | Provider exists, requests succeeding | `PUT` with a new valid key | 200; subsequent agent requests use the new key (verify with a deliberately wrong key → upstream 401 surfaced; then restore). |
| **AN-PR-15** | Session keypair survives updates | P1 | Provider exists | Update the provider several times | No error; the synthesised service keeps working (existing agent sessions are not broken). |
| **AN-PR-16** | `created_at` is preserved on update | P2 | Provider exists | Update it | `created_at` unchanged; `updated_at` advanced. |
| **AN-PR-17** | Disable a provider | P0 | Provider + policy exist, traffic flowing | Set `enabled=false` | Requests targeting that provider stop being routed. If it was the only enabled provider, the whole endpoint stops serving (see AN-SY-01). |
| **AN-PR-18** | Delete blocked while referenced by a policy | P0 | Policy references the provider | `DELETE /agent-network/providers/{id}` | 422 `provider is in use by N policy/policies (<names>); detach it before deleting`. Provider still present. Message pluralises correctly for 1 vs many. |
| **AN-PR-19** | Delete after detaching | P0 | AN-PR-18, then remove it from the policy | `DELETE` again | 200; provider gone from `GET /agent-network/providers`; the synthesised routing table no longer offers its models. |
| **AN-PR-20** | Unknown provider id | P1 | — | `GET`/`PUT`/`DELETE` with a random id | 404. |
| **AN-PR-21** | `skip_tls_verification` | P1 | A self-hosted upstream with a self-signed cert (`vllm`/`custom`) | Create with the flag off → request; then on → request | Off: upstream dial fails on TLS. On: request succeeds. |
| **AN-PR-22** | `metadata_disabled` | P1 | Gateway provider that receives identity metadata | Create with `metadata_disabled=false`, inspect upstream request; then set `true` | False: user/group identity headers/metadata are stamped. True: they are absent. Catalog `extra_values` routing headers are still stamped in both cases. |
| **AN-PR-23** | Customisable identity header names (Bifrost) | P1 | — | Create `bifrost`, set `identity_header_user_id` / `identity_header_groups`; then clear one | Configured names are stamped upstream; a cleared name disables stamping for that dimension and round-trips as `""` (not omitted) in the API response. |
| **AN-PR-24** | Identity-header anti-spoofing | P0 | Gateway with identity injection | From the client, send a request that *already* sets the identity header with a fake user | The upstream receives NetBird's real caller identity — the client-supplied value is stripped, never forwarded. |
| **AN-PR-25** | Vertex `keyfile::` credential | P1 | GCP SA JSON | Create `vertex_ai_api` with `api_key = keyfile::<base64 SA JSON>` and a `<region>-aiplatform.googleapis.com` upstream | Requests succeed; upstream sees `Authorization: Bearer <short-lived OAuth token>`, never the key material. |
| **AN-PR-26** | Bedrock path-routed provider | P1 | Bedrock creds | Create `bedrock_api`; send a `/model/{modelId}/invoke` (and `/bedrock`-prefixed) request | Routed to the Bedrock provider by path, not by body model; the model recorded in the log is the path model with region/version stripped. |
| **AN-PR-27** | Secrets at rest | P0 | Provider created | Inspect the `agent_network_providers` table | `api_key` and `session_private_key` are ciphertext; `session_public_key` is plaintext. Neither key ever appears in an API response or in logs. |
| **AN-PR-28** | Malformed JSON | P2 | — | POST with a truncated JSON body | 400 `couldn't parse JSON request` (not 422/500). |
---
## 5. Policies — CRUD and validation
| ID | Title | P | Preconditions | Steps | Expected |
|---|---|---|---|---|---|
| **AN-PO-01** | Create policy (happy path) | P0 | 1 provider, 1 group | POST with name, `source_groups=[g-eng]`, `destination_provider_ids=[p1]`, no limits | Created with an `ainpol_`-prefixed id, `enabled=true` by default. |
| **AN-PO-02** | `name` required | P1 | — | POST with blank name | 422 `name is required`. |
| **AN-PO-03** | `source_groups` must be non-empty and non-blank | P0 | — | POST with `[]`, then `[""]` | 422 `source_groups must contain at least one group id` / `must not contain empty entries`. |
| **AN-PO-04** | `destination_provider_ids` must be non-empty and non-blank | P0 | — | POST with `[]`, then `[""]` | 422 with the matching messages. |
| **AN-PO-05** | Destination provider must exist in the account | P0 | — | POST referencing an id from Account B, and a random id | 422 `destination_provider_ids: provider <id> does not exist` for both. |
| **AN-PO-06** | `guardrail_ids` entries must be non-blank | P1 | — | POST with `guardrail_ids: [""]` | 422 `guardrail_ids must not contain empty entries`. |
| **AN-PO-07** | Token limit window floor | P0 | — | POST with `token_limit.enabled=true`, `window_seconds=59` (and `0`) | 422 `limits.token_limit.window_seconds must be at least 60 (one minute) when enabled`. |
| **AN-PO-08** | Token limit needs a positive cap | P0 | — | POST with `token_limit.enabled=true`, window 3600, both caps `0` | 422 `limits.token_limit requires group_cap or user_cap to be greater than zero when enabled`. |
| **AN-PO-09** | Negative caps rejected | P1 | — | `group_cap=-1`, then `user_cap=-1` | 422 `must not be negative`. |
| **AN-PO-10** | Budget limit mirrors the token rules | P0 | — | Repeat AN-PO-07..09 for `budget_limit` (`group_cap_usd`, `user_cap_usd`) | Same class of 422s with `budget_limit` wording. |
| **AN-PO-11** | Limits omitted → uncapped | P1 | — | POST with `limits` absent | Created; both halves disabled; policy behaves as catch-all-allow (see AN-LM-09). |
| **AN-PO-12** | Disabled limits skip validation | P2 | — | POST with `enabled=false` and `window_seconds=0`, caps 0 | Accepted (validation only applies when the half is enabled). |
| **AN-PO-13** | Update policy | P0 | Policy exists | `PUT` changing name, groups, providers, guardrails, limits | 200; all fields replaced; `created_at` preserved, `updated_at` advanced; changes take effect on the next request without a restart. |
| **AN-PO-14** | Disable a policy | P0 | Policy governs a caller | Set `enabled=false` | Caller loses access through that policy; if no other enabled policy exists, the account's endpoint stops serving (AN-SY-01). |
| **AN-PO-15** | Delete a policy | P0 | Policy exists | `DELETE` | 200; policy gone; a caller authorised only by it can no longer reach the endpoint. |
| **AN-PO-16** | Deleting a group referenced by a policy | P1 | Group used in `source_groups` | Try to delete the group in **Groups** | Deletion is refused with a message naming the agent-network policy (no dangling group reference is left behind). |
| **AN-PO-17** | Policy events are audited | P2 | — | Create/update/delete a policy | Three distinct activity events recorded with the policy name and enabled state. |
| **AN-PO-18** | Same group in two policies | P1 | — | Create two enabled policies with overlapping `source_groups` and the same provider | Both are valid; selection follows the ranking in §7. |
---
## 6. Guardrails
| ID | Title | P | Preconditions | Steps | Expected |
|---|---|---|---|---|---|
| **AN-GR-01** | Create guardrail with model allowlist | P0 | — | POST name + `checks.model_allowlist = {enabled:true, models:["gpt-4o","gpt-5.4-mini"]}` | Created with an `ainguard_`-prefixed id. |
| **AN-GR-02** | Create guardrail with prompt capture | P0 | — | POST with `checks.prompt_capture = {enabled:true, redact_pii:true}` | Created; both flags round-trip. |
| **AN-GR-03** | Empty allowlist arrays normalise | P2 | — | POST with `models` omitted / `null` | Response returns `models: []`, never `null`. |
| **AN-GR-04** | Update / delete guardrail | P1 | Guardrail exists | `PUT` then `DELETE` | 200 each; `created_at` preserved on update; deletion removes it from the attach picker. |
| **AN-GR-05** | Deleting an attached guardrail | P1 | Guardrail attached to a policy | Delete it | Observe and record the behaviour: either the delete is refused, or the policy's `guardrail_ids` no longer resolves. Follow-up requests must **not** 500, and a stale id must not silently turn a restricted policy into an unrestricted one — verify the effective model gate after deletion. |
| **AN-GR-06** | Model allowlist enforcement — allowed | P0 | Policy → guardrail with `models:["gpt-4o"]` | Agent requests `gpt-4o` | 200; served. |
| **AN-GR-07** | Model allowlist enforcement — blocked | P0 | Same | Agent requests `gpt-4.1` | Denied with `llm_policy.model_blocked`; reason reads `model "gpt-4.1" is not permitted by any applicable policy allowlist`. Access log shows decision `deny` and that reason. |
| **AN-GR-08** | Allowlist matching is case/whitespace tolerant | P1 | Allowlist `["GPT-4o"]` | Request ` gpt-4o ` | Allowed (comparison lowercases and trims on both sides). |
| **AN-GR-09** | Undetermined model fails closed | P0 | Restricted policy | Send a request whose body has no resolvable model | Denied with `llm_policy.model_blocked`; reason `request model could not be determined for the policy allowlist`. |
| **AN-GR-10** | Undetermined model is allowed when unrestricted | P1 | Policy with no allowlist-enabled guardrail | Same request | Not blocked by the allowlist gate. |
| **AN-GR-11** | Union across multiple guardrails on one policy | P1 | Policy with guardrail A (`gpt-4o`) and B (`gpt-4.1`) | Request each model | Both allowed — a policy permits the **union** of its enabled allowlists. |
| **AN-GR-12** | Union across multiple policies | P0 | Policy 1 allows `gpt-4o`, Policy 2 (same caller + provider) allows `gpt-4.1` | Request each | Both allowed; the blocked case only fires when **no** applicable policy permits the model. |
| **AN-GR-13** | Guardrail with allowlist disabled is not restrictive | P1 | Guardrail with `model_allowlist.enabled=false` but a populated `models` array | Request a model not in the array | Allowed. |
| **AN-GR-14** | Prompt capture requires both gates | P0 | Account `enable_prompt_collection=false`, policy guardrail `prompt_capture.enabled=true` | Send a request, then open the access-log row | **No** prompt/completion body captured. Flip the account toggle on → new requests capture bodies. Neither gate alone is sufficient. |
| **AN-GR-15** | PII redaction is account **OR** policy | P0 | Account `redact_pii=false`, policy guardrail `redact_pii=true`; prompt capture on both gates | Send a prompt containing an email/phone/credit-card-shaped string | Captured prompt is redacted. Repeat with the account flag on and the policy flag off → still redacted. |
| **AN-GR-16** | Browse-and-attach modal | P2 | ≥2 guardrails exist, 1 already attached | Open policy → Guardrails tab → **Browse** | Already-attached guardrails are not listed; multi-select works; the Attach button is disabled with nothing selected and shows a correct singular/plural count; when everything is attached, the empty-state copy is shown. |
---
## 7. Policy selection and limit enforcement
These are the money-correctness cases. Use short windows (60–300 s) so caps
can actually be exhausted inside a test run, and record the exact request/response
pairs.
| ID | Title | P | Preconditions | Steps | Expected |
|---|---|---|---|---|---|
| **AN-LM-01** | Caller with no matching policy | P0 | Caller in `g-none`; no policy names that group | Send a request | Denied — the router is the authorisation gate; no upstream call is made and no upstream cost is incurred. |
| **AN-LM-02** | Policy targets a different provider | P0 | Policy allows provider P1 only | Request a model served by P2 | Not authorised through that policy. |
| **AN-LM-03** | Per-user token cap | P0 | Policy: token limit on, `user_cap=1000`, window 300 s | Drive user U past 1000 total tokens (in+out) in the window, then send one more | The over-cap request is denied with `llm_policy.token_cap_exceeded`; reason reads `user token cap exhausted on policy <id> (used N of 1000)`. |
| **AN-LM-04** | Per-group token cap is shared | P0 | Policy: `group_cap=1000`, window 300 s; users U1 and U2 both in `g-eng` | U1 consumes ~700 tokens; U2 then sends requests | U2 is denied once the **combined** group total reaches 1000 (`group token cap exhausted…`). |
| **AN-LM-05** | Per-user budget cap | P0 | `budget_limit.user_cap_usd=0.01`, window 300 s | Spend past $0.01 | Denied with `llm_policy.budget_cap_exceeded`; reason shows used vs cap to 4 decimals. |
| **AN-LM-06** | Per-group budget cap | P0 | `group_cap_usd` set | Two users in the group spend past it | Denied for whoever crosses the shared total. |
| **AN-LM-07** | Window rollover restores headroom | P0 | AN-LM-03 exhausted | Wait for the aligned window to roll over, then retry | Request is served again; counters restart from zero in the new window. Windows are **aligned** (not sliding) — confirm the reset happens at the window boundary, not exactly N seconds after the first request. |
| **AN-LM-08** | Cap of zero means uncapped | P1 | `token_limit.enabled=true`, `user_cap=0`, `group_cap=5000` | Drive one user past 5000 tokens alone | The user cap never binds; only the group cap does. |
| **AN-LM-09** | Uncapped policy wins over capped | P0 | Policy A (no caps) and Policy B (`group_cap=100`), both matching the caller and provider | Send requests past 100 tokens | All requests are attributed to A and keep succeeding; B's cap never blocks the caller. |
| **AN-LM-10** | Bigger pool drains first | P0 | Policy A `group_cap=10000`, Policy B `group_cap=1000`, both matching | Send requests | A is selected while it has headroom; only after A is exhausted does B pay; when both are exhausted the request is denied. |
| **AN-LM-11** | Ranking tiebreaks | P1 | Two policies with equal group token caps but different group budget caps; then equal on both but different user caps | Send requests | Order: group token cap → group budget cap → user token cap → user budget cap → **older `created_at`**. Repeat the run: the winner is stable and deterministic. |
| **AN-LM-12** | Multi-group attribution is deterministic | P0 | Caller in `g-alpha` and `g-beta`, both in the policy's `source_groups` | Send several requests | Every request attributes to the **lowest by string sort** intersecting group id. Verify the group column in the access log is identical across requests and across management replicas. |
| **AN-LM-13** | Effective window when both halves are enabled | P1 | Policy with token window 300 s and budget window 3600 s, both enabled | Send a request and inspect the recorded consumption window | The **token** window (300 s) is used for the policy-window booking. |
| **AN-LM-14** | Account budget rule — account-wide | P0 | Budget rule with **no** target groups/users, `user_cap_usd` small | Any caller spends past it | Denied with `llm_account.budget_cap_exceeded`, even for a caller on an uncapped policy. |
| **AN-LM-15** | Account rule binds with no matching policy | P0 | Account rule exists; caller matches **no** agent-network policy | Send a request | Ceiling is still evaluated (and denies once exhausted) — account rules are independent of policy selection. |
| **AN-LM-16** | Account rule targeting by user | P0 | Rule with `target_users=[U1]` | U1 and U2 both send traffic past the cap | U1 is denied; U2 is unaffected. |
| **AN-LM-17** | Account rule targeting by group | P0 | Rule with `target_groups=[g-eng]` | Member and non-member both send traffic | Only the member is bound. |
| **AN-LM-18** | Min-wins across rules | P0 | Rule A `user_cap_usd=$10`, Rule B `user_cap_usd=$1`, both applying | Spend past $1 | Denied at $1 — every applicable rule must pass; a looser rule cannot raise the ceiling. |
| **AN-LM-19** | Disabled rule doesn't bind | P1 | Rule set to `enabled=false` | Spend past its cap | Not enforced. |
| **AN-LM-20** | Account and policy caps coexist | P0 | Policy cap 1000 tokens/5 min; account rule 100000 tokens/month | Exhaust the policy cap | `llm_policy.*` deny code (not the account one); the account counter keeps accruing in its own monthly window. |
| **AN-LM-21** | One request never double-counts a cap | P0 | Policy window == an account rule window, same dimension | Send one request and read the consumption rows | The (dimension, window) counter is incremented **once**, not twice. |
| **AN-LM-22** | Deny does not consume upstream quota | P0 | Any exhausted cap | Send the denied request | No upstream call is made; no cost is added for the denied request; the access log records it with decision `deny` and zero/absent tokens. |
| **AN-LM-23** | Concurrency under a near-exhausted cap | P1 | Cap with ~1 request of headroom | Fire 20 concurrent requests | No 500s; counters stay consistent (no negative/duplicated totals); over-cap requests are denied. Record whether any overshoot occurs and by how much — the check is pre-flight, so a small overshoot may be by design; it must be bounded and must not corrupt totals. |
| **AN-LM-24** | Usage deltas are validated | P1 | — | Exercise the usage-recording path with a negative or non-finite delta (unit/API level) | Rejected with `usage deltas must be non-negative and finite`; totals are never decremented. |
| **AN-LM-25** | Deny surfaces usefully to the agent | P1 | Any deny case | Observe the client-side response in Claude Code / curl | A well-formed, upstream-shaped error the SDK can render — not a raw proxy 500 or a hung connection. |
---
## 8. Runtime routing and the tunnel
| ID | Title | P | Preconditions | Steps | Expected |
|---|---|---|---|---|---|
| **AN-SY-01** | Endpoint requires settings + enabled provider + enabled policy | P0 | Bootstrapped account | Test all four states: (a) all three present; (b) no enabled provider; (c) no enabled policy; (d) no settings row | Only (a) serves. (b)/(c)/(d) produce no synthesised service and the endpoint does not serve LLM traffic. Re-enabling restores service without a restart. |
| **AN-SY-02** | Tunnel-only access | P0 | Endpoint serving | Call `https://<endpoint>/v1/chat/completions` from a machine **not** connected to the NetBird tunnel | Rejected — the synthesised service is private and gated by tunnel-peer access. |
| **AN-SY-03** | Access groups follow policy source groups | P0 | Endpoint serving; policies target `g-eng` only | Connect a peer whose user is only in `g-contractors` and call the endpoint | Rejected at the tunnel-peer gate. Add the user to `g-eng` → allowed (no restart). |
| **AN-SY-04** | No client API key needed | P0 | Endpoint serving | Call with `api_key=none` / no upstream credential | Succeeds; NetBird injects the real key server-side. Also confirm the caller's own bogus `Authorization` header is stripped and replaced. |
| **AN-SY-05** | Model → provider routing | P0 | Two providers with disjoint model lists | Request a model from each | Each routes to the correct upstream; the resolved provider is recorded in the access log. |
| **AN-SY-06** | Streaming (SSE) requests | P0 | — | Send a streaming chat completion (`stream: true`) | Stream is relayed correctly to the client; token usage from the trailing chunk is recorded; cost is non-zero. |
| **AN-SY-07** | Non-streaming requests | P0 | — | Same with `stream: false` | Usage and cost recorded. |
| **AN-SY-08** | Large request body | P1 | — | Send a request whose body exceeds the ~1 MiB capture cap (long context) | Request still routes and succeeds; routing fields (model) are still resolved; capture is truncated, not fatal. |
| **AN-SY-09** | Large / long streaming response | P1 | — | Generate a very long response | Usage is still recorded (the trailing usage event is within the 8 MiB response capture cap). |
| **AN-SY-10** | Config change propagation | P0 | Traffic flowing | Change a policy cap, add a provider, toggle a guardrail | New behaviour is observed on subsequent requests within seconds, with no manual proxy restart. |
| **AN-SY-11** | Upstream failure passthrough | P1 | Provider with a deliberately invalid key | Send a request | The upstream's 401/429/5xx is surfaced to the client intelligibly; the access log records the real status code; NetBird does not mask it as a generic 500. |
| **AN-SY-12** | Unreachable upstream | P1 | Provider pointing at a dead host | Send a request | Clean error to the client within a reasonable timeout; no hang; logged. |
| **AN-SY-13** | Non-LLM path on the endpoint | P2 | — | `GET https://<endpoint>/` and a random path | A sane response/404 — no panic, no stack trace, no credential leakage. |
| **AN-SY-14** | Multiple accounts on one cluster | P0 | Accounts A and B bootstrapped on the same cluster | Call A's endpoint with B's peer, and vice versa | Cross-account calls are rejected; each endpoint only serves its own account's providers and policies. |
---
## 9. Usage, cost and consumption accounting
| ID | Title | P | Preconditions | Steps | Expected |
|---|---|---|---|---|---|
| **AN-US-01** | Cost matches configured rates | P0 | Provider with known `input_per_1k`/`output_per_1k` | Send a request with known token counts | Recorded `cost_usd` == `(in/1000 × input_rate) + (out/1000 × output_rate)` within rounding. |
| **AN-US-02** | Operator override beats catalog default | P0 | Provider model row priced deliberately differently from the catalog | Send a request | The operator's rate is used. |
| **AN-US-03** | OpenAI-shape cached input tokens | P0 | OpenAI model with `cached_input_per_1k` set; a prompt that hits the cache | Send two identical large prompts | Second call reports cached input tokens; they are billed at the cached rate, and cached tokens are a **subset** of input tokens (not added on top). |
| **AN-US-04** | Anthropic-shape cache read / creation | P0 | Anthropic model with `cache_read_per_1k` and `cache_creation_per_1k` | Run a prompt-caching sequence | Cache-read and cache-creation tokens are billed at their own rates and are **additive** to input tokens. Total tokens in the UI include them; the hover breakdown splits input / output / cache read / cache write. |
| **AN-US-05** | `nil` vs explicit `0` cache rate | P1 | One model with the cache rate omitted, one with it explicitly `0` | Send cache-hitting requests to each | Omitted → NetBird's default rate for that model applies. Explicit `0` → that bucket bills at the **input** rate (no discount), not free. |
| **AN-US-06** | Usage is recorded even with log collection off | P0 | `enable_log_collection=false` | Send several requests | No access-log rows appear, but the **Usage** tab and cost/token totals still update. |
| **AN-US-07** | Usage is recorded with no caps configured | P1 | Policy with no limits | Send requests | Usage/cost rows exist (consumption counters may be skipped, but usage history is not). |
| **AN-US-08** | Usage overview default window | P1 | Usage across >90 days (or seeded data) | `GET /agent-network/usage/overview` with no dates | Defaults to the last 90 days; response doesn't attempt to aggregate all history. |
| **AN-US-09** | Usage overview range clamp | P1 | — | Request a range wider than 366 days | Clamped to 366 days back from the end date; no error, no timeout. |
| **AN-US-10** | Granularity bucketing | P1 | Usage over several days | Request each supported granularity | Buckets are correct and ordered oldest-first; sparse days are represented rather than collapsed. |
| **AN-US-11** | Usage chart vs table agreement | P1 | Any usage | Open **Usage & Logs → Usage** | The token/cost chart, the per-day table and the hover breakdowns agree with each other and with the access-log sum for the same window. |
| **AN-US-12** | Consumption listing | P2 | Caps configured and partly consumed | `GET /agent-network/consumption` | Rows ordered window-newest-first, with dimension kind (user/group), dimension id, window seconds, window start, tokens and cost. |
| **AN-US-13** | Empty state | P2 | Fresh account | Open the Usage tab | "No usage recorded yet" empty state, no chart errors, no NaN/`$NaN` values. |
| **AN-US-14** | Zero-cost models | P2 | Embeddings model with `output_per_1k = 0` | Send an embeddings request | Cost computed from input only; no divide-by-zero or NaN in the UI. |
---
## 10. Access logs and sessions
| ID | Title | P | Preconditions | Steps | Expected |
|---|---|---|---|---|---|
| **AN-AL-01** | Row content | P0 | Log collection on; a served request | Open **Usage & Logs → Access Logs** and expand the row | Timestamp, user (real identity, not the peer), authorising group, provider, model, path, tokens, cost, status code, duration, decision, session id. Values match the request. |
| **AN-AL-02** | Deny rows | P0 | A capped or model-blocked request | Inspect the row | Decision `deny`; the reason column carries the human-readable deny reason; the deny code is recoverable. |
| **AN-AL-03** | Log collection off suppresses rows | P0 | `enable_log_collection=false` | Send requests, then look | No new access-log rows (existing ones remain). Turn it back on → new requests appear. |
| **AN-AL-04** | Prompt capture content | P0 | Both prompt gates on | Send a request with a distinctive prompt | The expanded row shows the request prompt and the response completion. |
| **AN-AL-05** | Redaction of captured prompts | P0 | AN-GR-15 setup | Send a prompt containing an email address, phone number and card-shaped number | Stored/displayed prompt is redacted. Verify at the **database** level too, not just in the UI. |
| **AN-AL-06** | Pagination | P1 | >100 log rows | Page through; also call the API with `page_size=1000` | Default page size 50 (dashboard uses 25); `page_size` is clamped to 100; page navigation is consistent with no duplicate/missing rows. |
| **AN-AL-07** | Sorting | P1 | Mixed data | Sort by timestamp, model, provider, status_code, duration, cost_usd, total_tokens, user_id, decision, asc and desc | Each sort is correct; an unknown `sort_by` falls back to `timestamp desc` rather than erroring. |
| **AN-AL-08** | Filters | P0 | Mixed data | Filter by date range, user, group, provider (multi), model (multi), decision, path prefix, and free-text search | Each narrows correctly; multi-select is OR-within-field and AND-across-fields; combining filters with sorting and paging stays consistent. |
| **AN-AL-09** | Multi-value query forms | P1 | — | Call the API with `?provider_id=a&provider_id=b` and with `?provider_id=a,b` | Both forms behave identically. |
| **AN-AL-10** | Invalid date is rejected, not ignored | P1 | — | `?start_date=yesterday` and `?end_date=2026-13-45` | 422 `invalid start_date/end_date: …` — the filter must not silently broaden the query. |
| **AN-AL-11** | Default UI window | P2 | — | Open the Access Logs tab fresh | Defaults to the last 14 days; the reset-filters action returns to that window. |
| **AN-AL-12** | Session grouping | P0 | An agent session spanning many requests (e.g. a Claude Code task) | Toggle **group by session** | One row per session with request count, first/last activity, summed tokens, summed cost, and a max status; expanding shows the constituent requests. Toggling back to flat view shows the same data ungrouped. |
| **AN-AL-13** | Session sorting | P1 | — | Sort sessions by last activity, started_at, request_count, cost, tokens, duration, status, user, decision | Correct aggregate ordering; sorting by a flat-only field (model/provider) falls back to the default without error. |
| **AN-AL-14** | Session filter round-trip | P1 | — | From a flat row, filter by its `session_id` | Only that session's requests are listed. |
| **AN-AL-15** | Retention sweep deletes old rows | P0 | Retention set to 7 days; seeded rows older than that | Wait for/trigger the cleanup sweep (runs at startup and on the configured interval, default 24 h) | Rows older than the cutoff are deleted; newer rows and **all usage records** are untouched. |
| **AN-AL-16** | Indefinite retention | P1 | Retention set to `0` (Indefinite) | Run the sweep | No access-log rows are deleted for that account. |
| **AN-AL-17** | Per-account retention isolation | P1 | Account A retention 7 days, Account B indefinite | Run the sweep | Only A's old rows are removed. |
| **AN-AL-18** | Retention change takes effect | P1 | Retention 90 → 7 | Change it and run the sweep | Rows between 7 and 90 days old are removed on the next sweep. |
| **AN-AL-19** | Log volume under load | P2 | — | Drive sustained request volume | Ingest keeps up; no request failures caused by log writes; a log-write failure must not fail the user's LLM request. |
---
## 11. Permissions, roles and tenancy
| ID | Title | P | Preconditions | Steps | Expected |
|---|---|---|---|---|---|
| **AN-RB-01** | Owner/admin full access | P0 | Owner and admin users | Exercise create/read/update/delete on providers, policies, guardrails, budget rules, settings; read usage and logs | All succeed. |
| **AN-RB-02** | Regular user is denied writes | P0 | `user`-role account member | Attempt each mutating call | 403 on each; the dashboard shows the restricted-access state rather than a broken page. |
| **AN-RB-03** | Read-only role | P1 | Auditor/read-only role, if available | GET providers, policies, guardrails, budgets, usage, logs; then attempt a write | Reads succeed per the role's grants; writes 403. |
| **AN-RB-04** | Submodule grants are independent | P1 | A role granted only `agent_network.logs` read | Read logs; then read providers | Logs succeed; providers 403. |
| **AN-RB-05** | Parent grant cascades | P1 | A role granted `agent_network` | Access every submodule surface | All submodules resolve through the parent grant. |
| **AN-RB-06** | Bootstrap needs the settings permission too | P0 | A user with provider-create but **not** settings-create, on an un-bootstrapped account | Create a provider with `bootstrap_cluster` set | 403 — pinning the cluster/subdomain is a settings write. Without `bootstrap_cluster`, the provider create is allowed. |
| **AN-RB-07** | Cross-account read isolation | P0 | Accounts A and B each with providers/policies/logs | As an A user, `GET` B's provider/policy/guardrail/budget-rule ids directly | 404/403 — never B's data. |
| **AN-RB-08** | Cross-account write isolation | P0 | Same | As an A user, `PUT`/`DELETE` a B resource id | Rejected; B's data unchanged. |
| **AN-RB-09** | Cross-account log isolation | P0 | Both accounts have traffic | List A's access logs and usage | Only A's rows; no B user ids, models or costs. |
| **AN-RB-10** | Unauthenticated access | P0 | — | Call each agent-network endpoint with no token and with an invalid token | 401; no data leaked in the error body. |
| **AN-RB-11** | Service-user / PAT access | P2 | A PAT for an admin | Drive the full provider→policy→request flow via PAT | Works identically to session auth. |
---
## 12. Dashboard — providers page and the provider wizard
| ID | Title | P | Preconditions | Steps | Expected |
|---|---|---|---|---|---|
| **AN-UI-01** | Empty state before bootstrap | P0 | Fresh account | Open **Agent Network → Providers** | The API-Base-URL card shows the dashed "Connect your first provider to set up your agent network endpoint" prompt and opens the wizard on click. |
| **AN-UI-02** | Endpoint badge after bootstrap | P0 | Bootstrapped | Reload the page | Badge shows `https://<subdomain>.<cluster>`; the tooltip explains base-URL usage; **Copy** copies the full `https://` URL and confirms with a toast. |
| **AN-UI-03** | No validated cluster blocks create | P0 | Account with no validated FREE cluster | Open the wizard | The no-clusters state is shown and the operator cannot complete a first create. Add a validated cluster → the wizard proceeds. |
| **AN-UI-04** | Cluster auto-pick | P1 | ≥1 validated cluster, not bootstrapped | Open the wizard | The first validated cluster is preselected; the operator can change it before the first create. |
| **AN-UI-05** | Cluster picker hidden after bootstrap | P1 | Bootstrapped | Open the wizard for a second provider | No cluster choice is offered (the hint would be ignored anyway). |
| **AN-UI-06** | Provider type switching | P1 | — | Switch between OpenAI / Anthropic / Bedrock / Vertex / LiteLLM / Portkey / Bifrost / OpenRouter / vLLM / Custom | Name and upstream URL prefill from the catalog per type; Vertex shows the clean `https://aiplatform.googleapis.com` placeholder rather than a templated host; provider entries are grouped by kind (provider / gateway / custom). |
| **AN-UI-07** | Continue is gated | P0 | — | Leave name blank, then enter an invalid URL, then a 3-character API key | The Continue/Save action stays disabled until: a type is chosen, the name is non-blank, the URL matches `http(s)://…`, the key is ≥4 characters, and (first create) a cluster is picked. |
| **AN-UI-08** | Mappings tab visibility | P1 | — | Select `litellm_proxy`, `portkey`, `bifrost`, `cloudflare_ai_gateway`, `vercel_ai_gateway`, `openrouter`, `bedrock_api`; then switch to `openai_api` while on the Mappings tab | Mappings appear only for those types; switching to a type without mappings snaps the wizard back to the Provider tab (no blank tab). |
| **AN-UI-09** | Model rows | P1 | — | Add catalog models, add a custom model row, add an empty row, add two rows with the same id | Catalog picks prefill prices; a model already added can't be picked twice; empty rows are dropped on save; duplicate ids collapse to the first row (no ambiguous price is sent). |
| **AN-UI-10** | Price prefill matches live defaults | P1 | An operator pricing override is configured | Add a catalog model | The prefilled price matches what the proxy will actually bill (live default table), not the compiled catalog rate. |
| **AN-UI-11** | Edit shows a masked key | P0 | Provider exists | Open it for edit | The API key field shows a masked placeholder; saving without touching it preserves the stored key (verify a request still succeeds). |
| **AN-UI-12** | Stale extra values are dropped on type switch | P2 | — | Fill Portkey's config header, then switch the type to OpenAI and save | The Portkey-only key is not persisted. |
| **AN-UI-13** | Providers table | P1 | Several providers | Review the table | Name, logo, type, upstream, model count, enabled state; row actions (edit/delete/enable) behave; the delete-blocked error from AN-PR-18 is surfaced as a readable message, not a raw payload. |
| **AN-UI-14** | Agent Config modal — tabs | P0 | Bootstrapped, providers connected | Click **Agent Config** | Tabs: Claude Code, Codex, OpenAI SDK, cURL. Every snippet embeds the account's real endpoint (`https://<endpoint>`, `/v1` where appropriate) and copies to clipboard correctly. |
| **AN-UI-15** | Claude Code backend selector | P1 | — | Switch between Anthropic API / Vertex AI / Bedrock, and toggle JSON ↔ Shell | Each variant renders the right env vars (`ANTHROPIC_BASE_URL`, `ANTHROPIC_VERTEX_BASE_URL` with `/v1`, `ANTHROPIC_BEDROCK_BASE_URL` with `/bedrock`) and JSON/Shell forms are equivalent. |
| **AN-UI-16** | Kimi surfaces are gated on a Kimi provider | P1 | No `kimi_api` provider | Open the modal | No **Kimi CLI** tab and no Kimi option in the Claude Code selector. Add a `kimi_api` provider → both appear, and when Kimi is the only Anthropic-shaped provider the selector opens on Kimi. |
| **AN-UI-17** | Kimi snippets actually work | P1 | `kimi_api` provider with the default `https://api.moonshot.ai` upstream | Follow the Claude Code Kimi snippet (`ANTHROPIC_BASE_URL=<endpoint>/anthropic`) and the Kimi CLI snippet (bare endpoint, `type="anthropic"`) | Both drive real completions through NetBird; usage is logged against the Kimi provider. |
| **AN-UI-18** | cURL snippet is copy-paste runnable | P0 | Tunnel connected, OpenAI provider + policy | Copy the cURL snippet and run it | Returns a completion; the copied text is a single-line command even though it is displayed multi-line. |
| **AN-UI-19** | Codex snippet | P1 | — | Apply the `~/.codex/config.toml` snippet and run Codex | Requests route through NetBird. |
| **AN-UI-20** | OpenAI SDK snippet | P1 | — | Run the Python snippet with `api_key="not-needed"` | Returns a completion. |
---
## 13. Dashboard — policies, guardrails, limits, configuration
| ID | Title | P | Preconditions | Steps | Expected |
|---|---|---|---|---|---|
| **AN-UI-21** | Policies table | P1 | Several policies | Open **Agent Network → Policies** | Name, enabled toggle, source groups, destination providers, guardrail summary and limits render correctly; the enabled toggle persists. |
| **AN-UI-22** | Policy modal tabs | P1 | — | Walk the General / Guardrails / Limits tabs | Tab state is preserved while the modal is open; validation errors are shown next to the offending field, not only as a toast. |
| **AN-UI-23** | Limits tab window authoring | P1 | — | Author a window in minutes, hours, and days | The wire value is seconds; the default is 30 days (2,592,000 s); Save is disabled unless at least one cap is > 0 and the window is ≥ 60 s. |
| **AN-UI-24** | Limits display formatting | P2 | Policies with several window lengths | Review the limits column | Windows render as human units (e.g. "30 days", "1 hour"), and USD caps are formatted as currency. |
| **AN-UI-25** | Guardrail checks cell | P2 | Guardrails with different check combinations | Review the policy table | The cell summarises model allowlist and prompt capture accurately, including the "no checks" case. |
| **AN-UI-26** | Global Limits tab | P0 | — | **Configuration → Global Limits**: create, edit, disable and delete a budget rule; scope it to groups, to users, to both, and to neither | Each variant saves and reloads correctly; the "applies to everyone" (no targets) case is clearly communicated in the UI. |
| **AN-UI-27** | Budget rule save gating | P1 | — | Leave the name blank; then set a rule with no positive cap | Save stays disabled / the request is rejected with a readable message. |
| **AN-UI-28** | Log Collection tab | P0 | — | Toggle log collection, change retention, toggle prompt collection, click **Save Changes** | Save is disabled until something changes; the retention select is disabled (and visually dimmed) while log collection is off; a failed save keeps the unsaved-changes state; a successful save clears it. |
| **AN-UI-29** | Prompt-collection help copy is accurate | P2 | — | Read the helper text | It states that a policy guardrail must also be enabled (matching AN-GR-14). |
| **AN-UI-30** | Clusters tab | P1 | — | **Configuration → Clusters** | Lists clusters with validation state; the copy differs appropriately between focused (agent-network-only) mode and the full dashboard; the docs link opens. |
| **AN-UI-31** | Deep-linkable tabs | P2 | — | Open `/agent-network/configuration?tab=log-settings`, `?tab=clusters`, `/agent-network/usage?tab=access-logs`; use browser back/forward | The right tab opens; the active tab is reflected in the URL; back/forward restores the previous tab; an invalid `?tab=` value falls back to the default. |
| **AN-UI-32** | Restricted-access states | P1 | A user without `services.read` | Open each Agent Network page | The restricted-access component renders; no underlying data fetches are made (check the network tab); no console errors. |
---
## 14. Focused mode, navigation and onboarding
| ID | Title | P | Preconditions | Steps | Expected |
|---|---|---|---|---|---|
| **AN-NAV-01** | Menu hidden by default | P1 | Account with neither the feature flag nor the account setting | Open the dashboard | No Agent Network menu entry; direct navigation to `/agent-network/providers` behaves per the guard (redirect or restricted state), not a crash. |
| **AN-NAV-02** | Feature flag shows the menu | P0 | `dashboard_features.agent_network = true` | Reload | Agent Network appears in the nav with Providers, Policies, Usage & Logs, Configuration — **alongside** the rest of the dashboard. |
| **AN-NAV-03** | Account-level focused mode | P0 | `settings.agent_network_only = true` | Reload | The dashboard is focused on Agent Network; unrelated sections are hidden. Toggling it back off restores the full dashboard. |
| **AN-NAV-04** | Deployment-level focused mode is a floor | P0 | `NETBIRD_AGENT_NETWORK_ONLY` set on the deployment | Set the account setting to `false` and reload | Focused mode still applies — the env flag cannot be overridden by the per-account setting. |
| **AN-NAV-05** | Signup-source optimism | P1 | New account from the netbird.ai signup source, `signup_form_pending=true`, no explicit `agent_network_only` | Log in | Focused view applies immediately — the regular onboarding must not flash first. |
| **AN-NAV-06** | Explicit choice beats optimism | P1 | Same, but `agent_network_only = false` explicitly | Log in | The user's explicit choice is respected (no focused view). |
| **AN-NAV-07** | Loading guard | P2 | Slow `/accounts` response (throttle the network) | Load the dashboard | No premature redirect or flicker between modes while the account is still loading. |
| **AN-ONB-01** | Full onboarding walkthrough | P0 | Fresh focused-mode account, `signup_form_pending=true` | Walk Signup → Welcome → Device → Provider → Policy → Configure → End | Every step advances; the stepper reflects progress; the modal cannot be dismissed with Esc or an outside click. |
| **AN-ONB-02** | Signup step is skipped when already submitted | P1 | `signup_form_pending=false` | Enter onboarding | Opens on Welcome, never on Signup, and never returns to Signup. |
| **AN-ONB-03** | Device step detects a connection | P0 | No peers | Sit on the Device step and install/connect a client | The step detects the peer within ~5 s (it polls) without a manual refresh, and Continue unblocks. |
| **AN-ONB-04** | First-run seeding | P0 | Fresh account | Reach the Policy step | A "Users" source group exists containing the current user, and the permissive **Default** access-control policy has been removed. Verify both in Groups / Access Control. |
| **AN-ONB-05** | Provider step creates a working provider | P0 | — | Complete the Provider step | A provider is created and settings are bootstrapped (endpoint assigned). |
| **AN-ONB-06** | Policy step creates a working policy | P0 | — | Complete the Policy step | A policy exists linking the seeded group to the new provider. |
| **AN-ONB-07** | Configure step snippets work | P0 | — | Copy the inline snippet on the Configure step and run it from the connected device | A real completion is returned end to end. |
| **AN-ONB-08** | Resume after refresh | P1 | Mid-onboarding (e.g. step 5) | Hard-refresh the browser | Onboarding resumes on the same step, not from the beginning. |
| **AN-ONB-09** | Skip to dashboard | P1 | Any step except Signup and End | Click **Skip to Dashboard** | Onboarding closes and does not reappear on the next load. |
| **AN-ONB-10** | Finish | P1 | End step | Click Finish | Lands on the Agent Network dashboard with the created provider/policy visible and the endpoint badge populated. |
| **AN-ONB-11** | Onboarding is idempotent | P2 | Completed onboarding | Log out and back in; reload several times | Onboarding does not restart; the seeded group is not duplicated. |
---
## 15. Cross-cutting, resilience and security
| ID | Title | P | Steps | Expected |
|---|---|---|---|---|
| **AN-X-01** | Secrets never leak | P0 | Grep every provider/policy/settings API response, the dashboard network tab, and management/proxy logs at debug level for the upstream API key and the session private key | Neither value appears anywhere. |
| **AN-X-02** | Prompt content respects the gates in logs | P0 | With prompt capture **off**, send a distinctive prompt and grep management/proxy logs and the DB | The prompt body appears nowhere. |
| **AN-X-03** | Management restart | P0 | Restart management with traffic in flight | Service resumes; providers/policies/limits/counters survive; the endpoint serves again without reconfiguration. |
| **AN-X-04** | Proxy restart | P0 | Restart the proxy | Config is re-synthesised and pushed; requests resume without a management change. |
| **AN-X-05** | Migration of an existing account | P0 | Upgrade a deployment that already has agent-network rows from the previous release | Existing providers/policies/guardrails/budget rules/settings survive; providers missing a session keypair are backfilled automatically on the next synthesis (no dial failures). |
| **AN-X-06** | Fresh install | P1 | Deploy from scratch and run §3 → §8 | The full flow works with no manual DB steps. |
| **AN-X-07** | SQLite and PostgreSQL parity | P1 | Run §7, §9 and §10 against both store backends | Identical behaviour, especially access-log sorting/grouping and consumption aggregation. |
| **AN-X-08** | Injection safety on filters | P0 | Pass SQL/JS payloads into `search`, `path`, `model`, `user_id`, `session_id` and the group/provider multi-filters | Treated as literal text; no SQL error, no 500, no reflected script execution in the dashboard. |
| **AN-X-09** | Pagination overflow | P2 | Request `?page=99999999999&page_size=100` | No overflow, no 500 — an empty page is fine. |
| **AN-X-10** | Unicode and long strings | P2 | Create providers/policies/guardrails with emoji, RTL text and 500-character names | Stored and rendered correctly (or rejected with a clear length error) — never truncated silently mid-codepoint. |
| **AN-X-11** | Idempotent double-submit | P1 | Double-click Save on the provider, policy and budget-rule modals | Exactly one record is created. |
| **AN-X-12** | Concurrent edits | P2 | Two admins edit the same policy simultaneously | Last write wins without corruption; neither session gets a 500. |
| **AN-X-13** | Activity log coverage | P1 | Create/update/delete one of each entity (provider, policy, guardrail, budget rule) and change settings | Every mutation produces a distinct, correctly attributed activity event. |
| **AN-X-14** | Dark/light and responsive rendering | P2 | View every Agent Network page at 1280 px and 1440 px, and check wide tables | No horizontal page scroll; wide tables scroll within their own container; no clipped controls. |
---
## 16. Suggested execution order
1. **§3 Bootstrap → §4 Providers → §5 Policies** — nothing else can be tested until an endpoint serves.
2. **§8 Runtime routing** — prove one request works end to end over the tunnel.
3. **§7 Limits** and **§9 Usage/cost** — the highest-risk correctness areas; budget the most time here.
4. **§6 Guardrails** and **§10 Access logs** — depend on live traffic from step 2.
5. **§11 Permissions/tenancy** — run against the data created above.
6. **§12–§14 Dashboard, focused mode, onboarding** — needs a second fresh account for the onboarding cases.
7. **§15 Cross-cutting** — restart/migration cases last, since they disturb the environment.
## 17. Reporting template
For each failure, capture: test id, environment (deployment, cluster, account),
the request (method, path, body with secrets redacted), the actual response
(status + body), the access-log row id and session id if one exists, the
management and proxy log excerpts around the timestamp, and the deny code where
one was returned.