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>
56 KiB
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 for the feature map, 01-end-to-end-flows.md for the request lifecycle, 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 theagent_networkparent 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 PUTs 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
- §3 Bootstrap → §4 Providers → §5 Policies — nothing else can be tested until an endpoint serves.
- §8 Runtime routing — prove one request works end to end over the tunnel.
- §7 Limits and §9 Usage/cost — the highest-risk correctness areas; budget the most time here.
- §6 Guardrails and §10 Access logs — depend on live traffic from step 2.
- §11 Permissions/tenancy — run against the data created above.
- §12–§14 Dashboard, focused mode, onboarding — needs a second fresh account for the onboarding cases.
- §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.