ironflow-api 2.52.4

REST API for ironflow run management and observability
Documentation
//! The account bucket of the credential routes.

use axum::body::Body;
use axum::http::{Request, StatusCode};
use http_body_util::BodyExt;
use serde_json::{Value as JsonValue, from_slice};
use tower::ServiceExt;

use super::*;

fn by_email(burst: u32) -> RateLimitContext {
    let mut ctx = test_ctx(burst);
    ctx.account_limit = AccountLimit::ByEmail;
    ctx
}

#[tokio::test]
async fn account_limit_counts_one_email_across_addresses() {
    let ctx = by_email(2);
    let attempts = [
        (
            r#"{"email":"alice@ironflow.dev","password":"a"}"#,
            StatusCode::OK,
        ),
        (
            r#"{"email":" ALICE@ironflow.dev","password":"b"}"#,
            StatusCode::OK,
        ),
        (
            r#"{"email":"alice@IRONFLOW.dev ","password":"c"}"#,
            StatusCode::TOO_MANY_REQUESTS,
        ),
    ];
    for (i, (body, expected)) in attempts.into_iter().enumerate() {
        let peer = format!("198.51.100.{}", i + 1);
        let resp = test_app(ctx.clone())
            .oneshot(email_request(&peer, body))
            .await
            .unwrap();
        assert_eq!(resp.status(), expected, "attempt {}", i + 1);
    }
}

#[tokio::test]
async fn account_limit_off_does_not_count_emails() {
    let ctx = test_ctx(1);
    for i in 1..=3 {
        let peer = format!("198.51.100.{i}");
        let resp = test_app(ctx.clone())
            .oneshot(email_request(&peer, r#"{"email":"alice@ironflow.dev"}"#))
            .await
            .unwrap();
        assert_eq!(resp.status(), StatusCode::OK);
    }
}

#[tokio::test]
async fn account_limit_hands_the_body_to_the_handler_unchanged() {
    let ctx = by_email(5);
    for body in [
        r#"{"email":"bob@ironflow.dev","password":"pw"}"#,
        "not json",
    ] {
        let resp = test_app(ctx.clone())
            .oneshot(email_request("198.51.100.1", body))
            .await
            .unwrap();
        assert_eq!(resp.status(), StatusCode::OK);
        let echoed = resp.into_body().collect().await.unwrap().to_bytes();
        assert_eq!(echoed, body.as_bytes());
    }
}

#[tokio::test]
async fn account_limit_reports_the_tightest_bucket() {
    let ctx = by_email(3);
    let body = r#"{"email":"carol@ironflow.dev"}"#;
    let first = test_app(ctx.clone())
        .oneshot(email_request("198.51.100.1", body))
        .await
        .unwrap();
    assert_eq!(first.status(), StatusCode::OK);
    // New address, so its bucket has 2 left; the account has 1 left.
    let resp = test_app(ctx)
        .oneshot(email_request("198.51.100.2", body))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    assert_eq!(resp.headers()["x-ratelimit-remaining"], "1");
}

#[tokio::test]
async fn account_limit_refuses_an_oversized_body() {
    let ctx = by_email(5);
    let body = format!(r#"{{"email":"{}"}}"#, "a".repeat(ACCOUNT_BODY_LIMIT));
    let resp = test_app(ctx)
        .oneshot(email_request("198.51.100.1", &body))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::PAYLOAD_TOO_LARGE);
    let bytes = resp.into_body().collect().await.unwrap().to_bytes();
    let json_val: JsonValue = from_slice(&bytes).unwrap();
    assert_eq!(json_val["error"]["code"], "PAYLOAD_TOO_LARGE");
}

#[tokio::test]
async fn expired_counters_are_swept_past_the_threshold() {
    let ctx = test_ctx(1);
    let stale = now_epoch() - WINDOW_SECS - 1;
    for i in 0..SWEEP_THRESHOLD {
        ctx.limiter.counters.insert(
            RateLimitKey::Account {
                route: "/sign-in".to_string(),
                email: format!("user{i}@x.dev"),
            },
            WindowEntry {
                count: AtomicU32::new(1),
                window_start: AtomicU64::new(stale),
            },
        );
    }
    let resp = test_app(ctx.clone())
        .oneshot(ip_request("198.51.100.1"))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    assert_eq!(ctx.limiter.counters.len(), 1);
}

#[tokio::test]
async fn sign_up_flood_does_not_block_sign_in() {
    let ctx = by_email(2);
    let body = r#"{"email":"alice@ironflow.dev","password":"a"}"#;
    let attempts = [
        StatusCode::OK,
        StatusCode::OK,
        StatusCode::TOO_MANY_REQUESTS,
    ];
    for (i, expected) in attempts.into_iter().enumerate() {
        let peer = format!("198.51.100.{}", i + 1);
        let resp = test_app(ctx.clone())
            .oneshot(email_request_to("/sign-up", &peer, body))
            .await
            .unwrap();
        assert_eq!(resp.status(), expected, "sign-up {}", i + 1);
    }

    let resp = test_app(ctx)
        .oneshot(email_request_to("/sign-in", "198.51.100.9", body))
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
}

#[tokio::test]
async fn credential_request_without_peer_is_counted_on_the_account() {
    let ctx = by_email(2);
    let body = r#"{"email":"dave@ironflow.dev","password":"pw"}"#;
    let no_peer_sign_in = || {
        Request::builder()
            .method("POST")
            .uri("/sign-in")
            .header("content-type", "application/json")
            .body(Body::from(body))
            .unwrap()
    };

    for expected_remaining in ["1", "0"] {
        let resp = test_app(ctx.clone())
            .oneshot(no_peer_sign_in())
            .await
            .unwrap();
        assert_eq!(resp.status(), StatusCode::OK);
        assert_eq!(resp.headers()["x-ratelimit-limit"], "2");
        assert_eq!(resp.headers()["x-ratelimit-remaining"], expected_remaining);
    }
    let resp = test_app(ctx).oneshot(no_peer_sign_in()).await.unwrap();
    assert_eq!(resp.status(), StatusCode::TOO_MANY_REQUESTS);
}