A hand-rolled token-bucket limiter (5 req/sec, 10-request burst by default; AUTH_RATE_PER_SEC/AUTH_RATE_BURST env knobs) gates the three auth-mutation endpoints. One bucket per AppState so tests stay isolated. Tower-governor wasn't wired in because the reverse proxy doesn't yet forward client IPs — a global bucket gives equivalent brute-force protection until that lands. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
659 lines
20 KiB
Rust
659 lines
20 KiB
Rust
mod common;
|
|
|
|
use axum::http::{header, StatusCode};
|
|
use serde_json::json;
|
|
use sqlx::PgPool;
|
|
use tower::ServiceExt;
|
|
|
|
fn creds(username: &str) -> serde_json::Value {
|
|
json!({ "username": username, "password": "hunter2hunter2" })
|
|
}
|
|
|
|
#[sqlx::test(migrations = "./migrations")]
|
|
async fn register_creates_user_and_sets_session_cookie(pool: PgPool) {
|
|
let h = common::harness(pool);
|
|
let resp = h
|
|
.app
|
|
.oneshot(common::post_json(
|
|
"/api/v1/auth/register",
|
|
creds("alice"),
|
|
))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::CREATED);
|
|
|
|
let cookie_header = resp
|
|
.headers()
|
|
.get(header::SET_COOKIE)
|
|
.expect("Set-Cookie present")
|
|
.to_str()
|
|
.unwrap()
|
|
.to_string();
|
|
assert!(cookie_header.starts_with("mangalord_session="));
|
|
assert!(cookie_header.contains("HttpOnly"));
|
|
assert!(cookie_header.contains("SameSite=Lax"));
|
|
assert!(cookie_header.contains("Path=/"));
|
|
// In the test harness cookie_secure is false; production has Secure.
|
|
assert!(!cookie_header.contains("Secure"));
|
|
|
|
let body = common::body_json(resp).await;
|
|
assert_eq!(body["user"]["username"], "alice");
|
|
assert!(body["user"]["id"].as_str().is_some());
|
|
assert!(
|
|
body["user"].get("password_hash").is_none(),
|
|
"password_hash must never leak to the API"
|
|
);
|
|
}
|
|
|
|
#[sqlx::test(migrations = "./migrations")]
|
|
async fn register_rejects_duplicate_username_with_conflict(pool: PgPool) {
|
|
let h = common::harness(pool);
|
|
let _ = h
|
|
.app
|
|
.clone()
|
|
.oneshot(common::post_json("/api/v1/auth/register", creds("alice")))
|
|
.await
|
|
.unwrap();
|
|
let resp = h
|
|
.app
|
|
.oneshot(common::post_json("/api/v1/auth/register", creds("alice")))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::CONFLICT);
|
|
let body = common::body_json(resp).await;
|
|
assert_eq!(body["error"]["code"], "conflict");
|
|
}
|
|
|
|
#[sqlx::test(migrations = "./migrations")]
|
|
async fn register_rejects_case_only_username_collisions(pool: PgPool) {
|
|
let h = common::harness(pool);
|
|
let _ = h
|
|
.app
|
|
.clone()
|
|
.oneshot(common::post_json("/api/v1/auth/register", creds("alice")))
|
|
.await
|
|
.unwrap();
|
|
// Mixed-case variant collides via the lower(username) index.
|
|
let resp = h
|
|
.app
|
|
.clone()
|
|
.oneshot(common::post_json("/api/v1/auth/register", creds("Alice")))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::CONFLICT);
|
|
|
|
// Login with either casing finds the same user.
|
|
let resp = h
|
|
.app
|
|
.oneshot(common::post_json("/api/v1/auth/login", creds("ALICE")))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
}
|
|
|
|
#[sqlx::test(migrations = "./migrations")]
|
|
async fn register_rejects_short_password(pool: PgPool) {
|
|
let h = common::harness(pool);
|
|
let resp = h
|
|
.app
|
|
.oneshot(common::post_json(
|
|
"/api/v1/auth/register",
|
|
json!({ "username": "alice", "password": "short" }),
|
|
))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
|
let body = common::body_json(resp).await;
|
|
assert_eq!(body["error"]["code"], "invalid_input");
|
|
}
|
|
|
|
#[sqlx::test(migrations = "./migrations")]
|
|
async fn login_succeeds_and_rotates_session(pool: PgPool) {
|
|
let h = common::harness(pool);
|
|
let register_resp = h
|
|
.app
|
|
.clone()
|
|
.oneshot(common::post_json("/api/v1/auth/register", creds("alice")))
|
|
.await
|
|
.unwrap();
|
|
let register_cookie = common::extract_session_cookie(®ister_resp)
|
|
.expect("register sets a cookie");
|
|
|
|
let login_resp = h
|
|
.app
|
|
.clone()
|
|
.oneshot(common::post_json("/api/v1/auth/login", creds("alice")))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(login_resp.status(), StatusCode::OK);
|
|
let login_cookie =
|
|
common::extract_session_cookie(&login_resp).expect("login sets a cookie");
|
|
assert!(login_cookie.starts_with("mangalord_session="));
|
|
|
|
// Login must mint a *new* session, not echo the registration one.
|
|
assert_ne!(
|
|
register_cookie, login_cookie,
|
|
"login should rotate the session token; got the register cookie back"
|
|
);
|
|
|
|
// The registration cookie is still valid until it expires naturally —
|
|
// that's the documented behaviour, asserted here so a regression that
|
|
// invalidates other devices' sessions on login would be noisy.
|
|
let me_resp = h
|
|
.app
|
|
.oneshot(common::get_with_cookie("/api/v1/auth/me", ®ister_cookie))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(me_resp.status(), StatusCode::OK);
|
|
}
|
|
|
|
#[sqlx::test(migrations = "./migrations")]
|
|
async fn login_rejects_wrong_password(pool: PgPool) {
|
|
let h = common::harness(pool);
|
|
let _ = h
|
|
.app
|
|
.clone()
|
|
.oneshot(common::post_json("/api/v1/auth/register", creds("alice")))
|
|
.await
|
|
.unwrap();
|
|
|
|
let resp = h
|
|
.app
|
|
.oneshot(common::post_json(
|
|
"/api/v1/auth/login",
|
|
json!({ "username": "alice", "password": "wrongpassword" }),
|
|
))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
|
let body = common::body_json(resp).await;
|
|
assert_eq!(body["error"]["code"], "unauthenticated");
|
|
}
|
|
|
|
#[sqlx::test(migrations = "./migrations")]
|
|
async fn login_rejects_unknown_user(pool: PgPool) {
|
|
let h = common::harness(pool);
|
|
let resp = h
|
|
.app
|
|
.oneshot(common::post_json("/api/v1/auth/login", creds("ghost")))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
|
}
|
|
|
|
#[sqlx::test(migrations = "./migrations")]
|
|
async fn me_returns_user_with_valid_cookie(pool: PgPool) {
|
|
let h = common::harness(pool);
|
|
let (username, cookie) = common::register_user(&h.app).await;
|
|
|
|
let resp = h
|
|
.app
|
|
.oneshot(common::get_with_cookie("/api/v1/auth/me", &cookie))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
let body = common::body_json(resp).await;
|
|
assert_eq!(body["user"]["username"], username);
|
|
}
|
|
|
|
#[sqlx::test(migrations = "./migrations")]
|
|
async fn me_returns_401_without_cookie(pool: PgPool) {
|
|
let h = common::harness(pool);
|
|
let resp = h
|
|
.app
|
|
.oneshot(common::get("/api/v1/auth/me"))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
|
}
|
|
|
|
#[sqlx::test(migrations = "./migrations")]
|
|
async fn logout_clears_session(pool: PgPool) {
|
|
let h = common::harness(pool);
|
|
let (_, cookie) = common::register_user(&h.app).await;
|
|
|
|
let resp = h
|
|
.app
|
|
.clone()
|
|
.oneshot(common::post_json_with_cookie(
|
|
"/api/v1/auth/logout",
|
|
json!({}),
|
|
&cookie,
|
|
))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::NO_CONTENT);
|
|
|
|
// Same cookie no longer works.
|
|
let resp = h
|
|
.app
|
|
.oneshot(common::get_with_cookie("/api/v1/auth/me", &cookie))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
|
}
|
|
|
|
#[sqlx::test(migrations = "./migrations")]
|
|
async fn change_password_rotates_sessions_and_swaps_credentials(pool: PgPool) {
|
|
let h = common::harness(pool);
|
|
let (username, cookie) = common::register_user(&h.app).await;
|
|
|
|
// Log in a second time to seed a "second device" session that
|
|
// should also be invalidated by the password change.
|
|
let second_resp = h
|
|
.app
|
|
.clone()
|
|
.oneshot(common::post_json(
|
|
"/api/v1/auth/login",
|
|
json!({ "username": username, "password": "hunter2hunter2" }),
|
|
))
|
|
.await
|
|
.unwrap();
|
|
let second_cookie = common::extract_session_cookie(&second_resp).unwrap();
|
|
assert_ne!(cookie, second_cookie);
|
|
|
|
// Change the password.
|
|
let resp = h
|
|
.app
|
|
.clone()
|
|
.oneshot(common::patch_json_with_cookie(
|
|
"/api/v1/auth/me/password",
|
|
json!({
|
|
"current_password": "hunter2hunter2",
|
|
"new_password": "freshpassfreshpass"
|
|
}),
|
|
&cookie,
|
|
))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::NO_CONTENT);
|
|
let rotated_cookie =
|
|
common::extract_session_cookie(&resp).expect("password change must mint a new cookie");
|
|
assert_ne!(cookie, rotated_cookie, "session must rotate");
|
|
|
|
// Both original cookies are dead (other devices signed out).
|
|
for stale in [&cookie, &second_cookie] {
|
|
let resp = h
|
|
.app
|
|
.clone()
|
|
.oneshot(common::get_with_cookie("/api/v1/auth/me", stale))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
resp.status(),
|
|
StatusCode::UNAUTHORIZED,
|
|
"stale cookie {stale} should be invalid"
|
|
);
|
|
}
|
|
|
|
// The rotated cookie is live.
|
|
let resp = h
|
|
.app
|
|
.clone()
|
|
.oneshot(common::get_with_cookie("/api/v1/auth/me", &rotated_cookie))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
|
|
// Old password no longer logs in.
|
|
let resp = h
|
|
.app
|
|
.clone()
|
|
.oneshot(common::post_json(
|
|
"/api/v1/auth/login",
|
|
json!({ "username": username, "password": "hunter2hunter2" }),
|
|
))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
|
|
|
// New password does.
|
|
let resp = h
|
|
.app
|
|
.oneshot(common::post_json(
|
|
"/api/v1/auth/login",
|
|
json!({ "username": username, "password": "freshpassfreshpass" }),
|
|
))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
}
|
|
|
|
#[sqlx::test(migrations = "./migrations")]
|
|
async fn change_password_via_bearer_leaves_bearer_working(pool: PgPool) {
|
|
// Bot scripts that call PATCH /me/password using Authorization:
|
|
// Bearer must keep their bearer working — change_password only
|
|
// wipes session rows, not api_tokens. Pin this behaviour so a
|
|
// future refactor that wipes everything would fail noisily.
|
|
let h = common::harness(pool);
|
|
let (_, cookie) = common::register_user(&h.app).await;
|
|
|
|
let resp = h
|
|
.app
|
|
.clone()
|
|
.oneshot(common::post_json_with_cookie(
|
|
"/api/v1/auth/tokens",
|
|
json!({ "name": "ci-bot" }),
|
|
&cookie,
|
|
))
|
|
.await
|
|
.unwrap();
|
|
let bearer = common::body_json(resp).await["bearer"]
|
|
.as_str()
|
|
.unwrap()
|
|
.to_string();
|
|
|
|
// Use the bearer to change the password.
|
|
let resp = h
|
|
.app
|
|
.clone()
|
|
.oneshot({
|
|
let body = json!({
|
|
"current_password": "hunter2hunter2",
|
|
"new_password": "freshpassfreshpass"
|
|
});
|
|
axum::http::Request::builder()
|
|
.method("PATCH")
|
|
.uri("/api/v1/auth/me/password")
|
|
.header(axum::http::header::CONTENT_TYPE, "application/json")
|
|
.header(axum::http::header::AUTHORIZATION, format!("Bearer {bearer}"))
|
|
.body(axum::body::Body::from(body.to_string()))
|
|
.unwrap()
|
|
})
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::NO_CONTENT);
|
|
|
|
// Cookie is dead (all sessions wiped).
|
|
let resp = h
|
|
.app
|
|
.clone()
|
|
.oneshot(common::get_with_cookie("/api/v1/auth/me", &cookie))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
|
|
|
// Bearer still works — that's the documented contract.
|
|
let resp = h
|
|
.app
|
|
.oneshot(common::get_with_bearer("/api/v1/auth/me", &bearer))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
}
|
|
|
|
#[sqlx::test(migrations = "./migrations")]
|
|
async fn change_password_rejects_wrong_current_with_401(pool: PgPool) {
|
|
let h = common::harness(pool);
|
|
let (_, cookie) = common::register_user(&h.app).await;
|
|
|
|
let resp = h
|
|
.app
|
|
.oneshot(common::patch_json_with_cookie(
|
|
"/api/v1/auth/me/password",
|
|
json!({
|
|
"current_password": "definitelyNotIt",
|
|
"new_password": "freshpassfreshpass"
|
|
}),
|
|
&cookie,
|
|
))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
|
let body = common::body_json(resp).await;
|
|
assert_eq!(body["error"]["code"], "unauthenticated");
|
|
}
|
|
|
|
#[sqlx::test(migrations = "./migrations")]
|
|
async fn change_password_rejects_weak_new_password(pool: PgPool) {
|
|
let h = common::harness(pool);
|
|
let (_, cookie) = common::register_user(&h.app).await;
|
|
|
|
let resp = h
|
|
.app
|
|
.oneshot(common::patch_json_with_cookie(
|
|
"/api/v1/auth/me/password",
|
|
json!({
|
|
"current_password": "hunter2hunter2",
|
|
"new_password": "short"
|
|
}),
|
|
&cookie,
|
|
))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
|
let body = common::body_json(resp).await;
|
|
assert_eq!(body["error"]["code"], "invalid_input");
|
|
}
|
|
|
|
#[sqlx::test(migrations = "./migrations")]
|
|
async fn change_password_requires_authentication(pool: PgPool) {
|
|
let h = common::harness(pool);
|
|
let resp = h
|
|
.app
|
|
.oneshot(common::patch_json(
|
|
"/api/v1/auth/me/password",
|
|
json!({
|
|
"current_password": "hunter2hunter2",
|
|
"new_password": "freshpassfreshpass"
|
|
}),
|
|
))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
|
}
|
|
|
|
#[sqlx::test(migrations = "./migrations")]
|
|
async fn me_rejects_expired_session(pool: PgPool) {
|
|
use chrono::{Duration, Utc};
|
|
use mangalord::auth::token::generate_token;
|
|
|
|
let h = common::harness(pool.clone());
|
|
common::register_user(&h.app).await;
|
|
|
|
// Grab the user that was just registered so we can hand-craft an
|
|
// expired session for them.
|
|
let user_id: uuid::Uuid = sqlx::query_scalar("SELECT id FROM users LIMIT 1")
|
|
.fetch_one(&pool)
|
|
.await
|
|
.unwrap();
|
|
|
|
let (raw, hash) = generate_token();
|
|
let expires_at = Utc::now() - Duration::hours(1);
|
|
sqlx::query(
|
|
"INSERT INTO sessions (user_id, token_hash, expires_at) VALUES ($1, $2, $3)",
|
|
)
|
|
.bind(user_id)
|
|
.bind(&hash[..])
|
|
.bind(expires_at)
|
|
.execute(&pool)
|
|
.await
|
|
.unwrap();
|
|
|
|
let cookie = format!("mangalord_session={raw}");
|
|
let resp = h
|
|
.app
|
|
.oneshot(common::get_with_cookie("/api/v1/auth/me", &cookie))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
|
let body = common::body_json(resp).await;
|
|
assert_eq!(body["error"]["code"], "unauthenticated");
|
|
}
|
|
|
|
#[sqlx::test(migrations = "./migrations")]
|
|
async fn create_and_use_bot_token(pool: PgPool) {
|
|
let h = common::harness(pool);
|
|
let (_, cookie) = common::register_user(&h.app).await;
|
|
|
|
let resp = h
|
|
.app
|
|
.clone()
|
|
.oneshot(common::post_json_with_cookie(
|
|
"/api/v1/auth/tokens",
|
|
json!({ "name": "ci-bot" }),
|
|
&cookie,
|
|
))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::CREATED);
|
|
let body = common::body_json(resp).await;
|
|
assert_eq!(body["name"], "ci-bot");
|
|
let bearer = body["bearer"]
|
|
.as_str()
|
|
.expect("raw bearer in response")
|
|
.to_string();
|
|
// `token_hash` is `#[serde(skip)]` on `ApiToken`, so it must be
|
|
// *absent* from the JSON. `is_null()` would also accept a
|
|
// `"token_hash": null` payload, which we don't want — use
|
|
// `get(...).is_none()` for the stronger assertion.
|
|
assert!(
|
|
body.get("token_hash").is_none(),
|
|
"token_hash must not appear in the response at all"
|
|
);
|
|
|
|
// Use the bearer to hit /me — should authenticate.
|
|
let resp = h
|
|
.app
|
|
.oneshot(common::get_with_bearer("/api/v1/auth/me", &bearer))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
}
|
|
|
|
#[sqlx::test(migrations = "./migrations")]
|
|
async fn user_a_cannot_delete_user_b_token(pool: PgPool) {
|
|
let h = common::harness(pool);
|
|
let (_, cookie_a) = common::register_user(&h.app).await;
|
|
let (_, cookie_b) = common::register_user(&h.app).await;
|
|
|
|
let resp = h
|
|
.app
|
|
.clone()
|
|
.oneshot(common::post_json_with_cookie(
|
|
"/api/v1/auth/tokens",
|
|
json!({ "name": "alice-bot" }),
|
|
&cookie_a,
|
|
))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::CREATED);
|
|
let body = common::body_json(resp).await;
|
|
let token_id = body["id"].as_str().unwrap().to_string();
|
|
|
|
// User B attempts to delete user A's token → 403.
|
|
let resp = h
|
|
.app
|
|
.clone()
|
|
.oneshot(common::delete_with_cookie(
|
|
&format!("/api/v1/auth/tokens/{token_id}"),
|
|
&cookie_b,
|
|
))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
|
let body = common::body_json(resp).await;
|
|
assert_eq!(body["error"]["code"], "forbidden");
|
|
|
|
// User A succeeds.
|
|
let resp = h
|
|
.app
|
|
.oneshot(common::delete_with_cookie(
|
|
&format!("/api/v1/auth/tokens/{token_id}"),
|
|
&cookie_a,
|
|
))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::NO_CONTENT);
|
|
}
|
|
|
|
/// Brute-force / spray protection: at default production limits, a
|
|
/// tight loop of /auth/login attempts should burst through the bucket
|
|
/// and then 429 every subsequent request until the bucket refills.
|
|
#[sqlx::test(migrations = "./migrations")]
|
|
async fn login_rate_limited_under_burst_pressure(pool: PgPool) {
|
|
let h = common::harness_with_auth_rate_limit(pool, 1, 3);
|
|
|
|
// Register a victim so the wrong-password branch is real work.
|
|
let _ = h
|
|
.app
|
|
.clone()
|
|
.oneshot(common::post_json("/api/v1/auth/register", creds("victim")))
|
|
.await
|
|
.unwrap();
|
|
|
|
// Register consumed one token from the burst-3 bucket. Fire 30
|
|
// wrong-password logins back-to-back; with per_sec=1 the refill
|
|
// is too slow to keep up and at least one must come back 429.
|
|
let mut saw_429 = false;
|
|
for _ in 0..30 {
|
|
let resp = h
|
|
.app
|
|
.clone()
|
|
.oneshot(common::post_json(
|
|
"/api/v1/auth/login",
|
|
json!({ "username": "victim", "password": "wrong" }),
|
|
))
|
|
.await
|
|
.unwrap();
|
|
if resp.status() == StatusCode::TOO_MANY_REQUESTS {
|
|
// RFC 6585 §4: 429 SHOULD include a Retry-After header. The
|
|
// value is in seconds; with per_sec=1 the bucket needs ~1s
|
|
// to refill, so the header should be 1 or 2.
|
|
let retry_after = resp
|
|
.headers()
|
|
.get(axum::http::header::RETRY_AFTER)
|
|
.and_then(|v| v.to_str().ok())
|
|
.and_then(|s| s.parse::<u32>().ok())
|
|
.expect("Retry-After header present and numeric");
|
|
assert!(
|
|
retry_after >= 1,
|
|
"Retry-After must be at least 1s, got {retry_after}"
|
|
);
|
|
let body = common::body_json(resp).await;
|
|
assert_eq!(body["error"]["code"], "too_many_requests");
|
|
saw_429 = true;
|
|
break;
|
|
}
|
|
}
|
|
assert!(
|
|
saw_429,
|
|
"expected at least one 429 within 30 rapid login attempts"
|
|
);
|
|
}
|
|
|
|
/// Default (test-harness) limits are disabled, so existing tests that
|
|
/// fire multiple auth requests don't start failing.
|
|
#[sqlx::test(migrations = "./migrations")]
|
|
async fn default_test_harness_does_not_rate_limit(pool: PgPool) {
|
|
let h = common::harness(pool);
|
|
for i in 0..50 {
|
|
let resp = h
|
|
.app
|
|
.clone()
|
|
.oneshot(common::post_json(
|
|
"/api/v1/auth/login",
|
|
json!({ "username": format!("nobody-{i}"), "password": "x" }),
|
|
))
|
|
.await
|
|
.unwrap();
|
|
// None of these should be 429 — only 401.
|
|
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED, "iter {i}");
|
|
}
|
|
}
|
|
|
|
#[sqlx::test(migrations = "./migrations")]
|
|
async fn delete_unknown_token_is_404(pool: PgPool) {
|
|
let h = common::harness(pool);
|
|
let (_, cookie) = common::register_user(&h.app).await;
|
|
let resp = h
|
|
.app
|
|
.oneshot(common::delete_with_cookie(
|
|
"/api/v1/auth/tokens/00000000-0000-0000-0000-000000000000",
|
|
&cookie,
|
|
))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
|
}
|