Negative-path tests
A 2xx test is half a test. The bugs that ship to production live in
the failure modes — the request that should have been refused, the
transaction that should have rolled back, the field that should have
been masked. tests/e2e/ is the natural home: the same TestApp and
EphemeralDatabase that drive the e2e happy paths,
just asserting on the failure shape.
Four shapes worth covering
Section titled “Four shapes worth covering”For every endpoint that mutates or returns scoped data, four failures catch bugs the happy path can’t:
- No auth → 401. A controller missing
#[use_guards(AuthnGuard, …)]silently exposes the route — this test forces the regression to surface. - Cross-tenant → 403, not 500. The framework’s posture is an honest
403: the row exists but is off-limits, so403confirms existence by design (see route model binding). A500means the authz scope never installed — that one is a bug. - Malformed input → 400 with the wire error shape. Confirms the pipe runs, the error mapping survives, and the body matches the client contract.
- Server error → rollback verified. The ambient executor rolls back on non-2xx; a test that triggers a 500 then queries the DB confirms the row is absent.
Auth refused
Section titled “Auth refused”use poem::http::{StatusCode, header};
use super::harness::*;
#[tokio::test]async fn protected_route_rejects_a_missing_or_bogus_bearer_token() { let (_db, app) = boot().await;
app.http() .get("/orgs") .send() .await .assert_status(StatusCode::UNAUTHORIZED);
app.http() .get("/orgs") .header(header::AUTHORIZATION, "Bearer not-a-real-jwt") .send() .await .assert_status(StatusCode::UNAUTHORIZED);}One test, both refusals: no credential at all and a credential that fails
verification are the same 401 to the caller, and asserting them together is
what catches a guard that only checks for the presence of a header.
Cross-tenant refused
Section titled “Cross-tenant refused”A caller authenticated as tenant A asking for tenant B’s resource
returns 403 — the row exists, the caller cannot see it. 404 would
hide existence instead (a stricter posture you opt into per
route model binding); 500
would mean Ability::condition_for never installed.
#[tokio::test]async fn get_other_tenants_user_returns_403() { let (db, app) = boot().await; let other_user = seed_user_in_org(&db, ORG_B).await; let bearer = format!( "Bearer {}", token_for(ORG_A, "admin").await, );
let resp = app .http() .get(format!("/users/{}", other_user.id)) .header(header::AUTHORIZATION, &bearer) .send() .await; resp.assert_status(StatusCode::FORBIDDEN);}Validation failed
Section titled “Validation failed”#[tokio::test]async fn create_with_invalid_email_returns_400_with_field_errors() { let (_db, app) = boot().await; let bearer = format!("Bearer {}", login().await);
let resp = app .http() .post("/users") .header(header::AUTHORIZATION, &bearer) .body_json(&json!({ "email": "not-an-email" })) .send() .await; resp.assert_status(StatusCode::BAD_REQUEST);
let body = resp.json().await; let value = body.value(); assert_eq!(value.object().get("error").string(), "validation"); assert!(value.object().get("fields").object().get_opt("email").is_some());}Rollback verified
Section titled “Rollback verified”A passing happy-path test confirms the transaction wraps; a failing test next to it confirms it unwinds:
#[tokio::test]async fn handler_error_rolls_back_the_create() { let (db, app) = boot().await; let bearer = format!("Bearer {}", login().await);
let resp = app .http() .post("/users/with-broken-hook") .header(header::AUTHORIZATION, &bearer) .body_json(&json!({ "name": "Bob" })) .send() .await; resp.assert_status(StatusCode::INTERNAL_SERVER_ERROR);
let count = count_users_named(&db, "Bob").await; assert_eq!(count, 0, "the failed mutation must not have committed");}The two tests together prove both halves of the transaction promise: the wrapper installs on the mutating method (good 2xx commits), and it unwinds on failure (failed 5xx leaves no trace).
Where they live
Section titled “Where they live”One module per feature under tests/e2e/, positives and negatives together —
the reference suite interleaves them, because a 403 assertion reads best right
beside the 200 it contrasts with:
apps/api/tests/e2e/├── main.rs ← mod harness; mod users; mod orgs; …├── harness.rs ← boot(), token_for(), fixtures├── users.rs ← scoping, masking, by-id 403/404├── orgs.rs└── http.rs ← 401s, the problem+json envelope, compressionSplitting failure modes into their own *_negatives.rs files works too — the
split matters less than the coverage. What matters is that every endpoint with a
mutation or a scoped read has its four failure modes asserted somewhere.
Going further
Section titled “Going further”- Policy tests —
Ability::maskandcondition_foras pure units. - Security — the guards, abilities, and scoping these tests exercise.
- End-to-end tests — the happy paths these failure modes pair with.
Built by YV17labs