Skip to content

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.

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, so 403 confirms existence by design (see route model binding). A 500 means 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.
apps/api/tests/e2e/http.rs (abridged)
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.

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);
}
#[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());
}

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).

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, compression

Splitting 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.

  • Policy testsAbility::mask and condition_for as 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