Spaces:
Paused
Paused
Download api/tests/security.rs from stremio-db/a: direct link, hf CLI and curl.
- Browser
- Download file 31.4 kB
-
https://huggingface.co/spaces/stremio-db/a/resolve/main/api/tests/security.rs
- Command line
-
hf download hf://spaces/stremio-db/a/api/tests/security.rs
-
curl -L -o security.rs https://huggingface.co/spaces/stremio-db/a/resolve/main/api/tests/security.rs
31.4 kB
mod common;
use axum::body::Body;
use axum::http::{Request, StatusCode};
use http_body_util::BodyExt;
use sea_orm::ConnectionTrait;
use tower::ServiceExt;
fn json_body(json: serde_json::Value) -> Body {
Body::from(json.to_string())
}
// --- CORS origin enforcement (via preflight OPTIONS requests) ---
#[tokio::test]
async fn cors_preflight_without_origin_config_rejected() {
// No CORS origin configured — CorsLayer::new() rejects all cross-origin
let (app, _state) = common::setup_test_app().await;
let req = Request::builder()
.method("OPTIONS")
.uri("/api/auth/setup")
.header("origin", "https://example.com")
.header("access-control-request-method", "POST")
.body(Body::empty())
.unwrap();
let res = app.oneshot(req).await.unwrap();
// No Access-Control-Allow-Origin header should be present
assert!(res.headers().get("access-control-allow-origin").is_none());
}
#[tokio::test]
async fn cors_preflight_with_correct_origin_allowed() {
let (app, _state) =
common::setup_test_app_with_cors(Some("https://example.com".into())).await;
let req = Request::builder()
.method("OPTIONS")
.uri("/api/auth/setup")
.header("origin", "https://example.com")
.header("access-control-request-method", "POST")
.body(Body::empty())
.unwrap();
let res = app.oneshot(req).await.unwrap();
let acao = res
.headers()
.get("access-control-allow-origin")
.and_then(|v| v.to_str().ok());
assert_eq!(acao, Some("https://example.com"));
}
#[tokio::test]
async fn cors_preflight_with_wrong_origin_rejected() {
let (app, _state) =
common::setup_test_app_with_cors(Some("https://example.com".into())).await;
let req = Request::builder()
.method("OPTIONS")
.uri("/api/auth/setup")
.header("origin", "https://evil.com")
.header("access-control-request-method", "POST")
.body(Body::empty())
.unwrap();
let res = app.oneshot(req).await.unwrap();
// CorsLayer echoes the configured origin, not the request origin.
// The browser compares and rejects when they don't match.
let acao = res
.headers()
.get("access-control-allow-origin")
.and_then(|v| v.to_str().ok());
assert_ne!(acao, Some("https://evil.com"), "should not allow wrong origin");
}
#[tokio::test]
async fn cors_get_allowed_even_with_cors_origin_set() {
let (app, _state) =
common::setup_test_app_with_cors(Some("https://example.com".into())).await;
let req = Request::builder()
.uri("/api/auth/status")
.body(Body::empty())
.unwrap();
let res = app.oneshot(req).await.unwrap();
assert_eq!(res.status(), StatusCode::OK);
}
#[tokio::test]
async fn cors_delete_without_origin_no_cors_headers() {
let (app, _state) =
common::setup_test_app_with_cors(Some("https://example.com".into())).await;
let req = Request::builder()
.method("DELETE")
.uri("/api/keys/1")
.header("authorization", "Bearer fake")
.body(Body::empty())
.unwrap();
let res = app.oneshot(req).await.unwrap();
// Without matching origin, browser will block the response
let acao = res
.headers()
.get("access-control-allow-origin")
.and_then(|v| v.to_str().ok());
assert_ne!(acao, Some("https://evil.com"));
}
// --- Refresh token expiry ---
#[tokio::test]
async fn refresh_with_expired_token_returns_401() {
let (app, state) = common::setup_test_app().await;
// Setup admin
let req = Request::builder()
.method("POST")
.uri("/api/auth/setup")
.header("content-type", "application/json")
.body(json_body(serde_json::json!({
"username": "admin",
"password": "testpassword123"
})))
.unwrap();
let res = app.clone().oneshot(req).await.unwrap();
assert_eq!(res.status(), StatusCode::OK);
let set_cookie = common::extract_set_cookie(res.headers()).unwrap();
let refresh_token = common::extract_refresh_token(&set_cookie).unwrap();
// Manually expire all refresh tokens in the DB
state
.db
.execute_unprepared(
"UPDATE refresh_tokens SET expires_at = '2020-01-01 00:00:00'",
)
.await
.unwrap();
// Try to refresh with the expired token
let req = Request::builder()
.method("POST")
.uri("/api/auth/refresh")
.header("cookie", format!("refresh_token={refresh_token}"))
.body(Body::empty())
.unwrap();
let res = app.oneshot(req).await.unwrap();
assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
}
// --- Concurrent setup race condition ---
#[tokio::test]
async fn concurrent_setup_only_one_succeeds() {
let (app, _state) = common::setup_test_app().await;
let app1 = app.clone();
let app2 = app.clone();
let req1 = Request::builder()
.method("POST")
.uri("/api/auth/setup")
.header("content-type", "application/json")
.body(json_body(serde_json::json!({
"username": "admin1",
"password": "testpassword123"
})))
.unwrap();
let req2 = Request::builder()
.method("POST")
.uri("/api/auth/setup")
.header("content-type", "application/json")
.body(json_body(serde_json::json!({
"username": "admin2",
"password": "testpassword123"
})))
.unwrap();
let (res1, res2) = tokio::join!(app1.oneshot(req1), app2.oneshot(req2));
let status1 = res1.unwrap().status();
let status2 = res2.unwrap().status();
// Exactly one should succeed, one should fail
let successes = [status1, status2]
.iter()
.filter(|s| **s == StatusCode::OK)
.count();
let failures = [status1, status2]
.iter()
.filter(|s| **s == StatusCode::FORBIDDEN)
.count();
assert_eq!(successes, 1, "exactly one setup should succeed (got {status1}, {status2})");
assert_eq!(failures, 1, "exactly one setup should be forbidden (got {status1}, {status2})");
}
// --- API key negative caching ---
#[tokio::test]
async fn invalid_api_key_cached_returns_401_consistently() {
let (app, _state) = common::setup_test_app().await;
// First request with invalid key
let req = Request::builder()
.uri("/bogus-key/imdb/poster-default/tt0000001.jpg")
.body(Body::empty())
.unwrap();
let res = app.clone().oneshot(req).await.unwrap();
assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
// Second request with same invalid key should also fail (from cache)
let req = Request::builder()
.uri("/bogus-key/imdb/poster-default/tt0000001.jpg")
.body(Body::empty())
.unwrap();
let res = app.oneshot(req).await.unwrap();
assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
}
// --- Deleted API key is rejected after cache invalidation ---
#[tokio::test]
async fn deleted_api_key_rejected_after_cache_invalidation() {
let (app, state) = common::setup_test_app().await;
let token = common::setup_admin(&app).await;
// Create an API key
let req = Request::builder()
.method("POST")
.uri("/api/keys")
.header("content-type", "application/json")
.header("authorization", format!("Bearer {token}"))
.body(json_body(serde_json::json!({"name": "temp-key"})))
.unwrap();
let res = app.clone().oneshot(req).await.unwrap();
let body = res.into_body().collect().await.unwrap().to_bytes();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
let api_key = json["key"].as_str().unwrap().to_string();
let key_id = json["id"].as_i64().unwrap();
// Use the API key (populates cache)
let req = Request::builder()
.uri(format!("/{api_key}/imdb/poster-default/tt0000001.jpg"))
.body(Body::empty())
.unwrap();
let res = app.clone().oneshot(req).await.unwrap();
assert_ne!(res.status(), StatusCode::UNAUTHORIZED);
// Delete the API key via the endpoint (which calls invalidate_all)
let req = Request::builder()
.method("DELETE")
.uri(format!("/api/keys/{key_id}"))
.header("authorization", format!("Bearer {token}"))
.body(Body::empty())
.unwrap();
let res = app.clone().oneshot(req).await.unwrap();
assert_eq!(res.status(), StatusCode::OK);
// Ensure cache eviction completes
state.api_key_cache.run_pending_tasks().await;
// Now the API key should be rejected
let req = Request::builder()
.uri(format!("/{api_key}/imdb/poster-default/tt0000001.jpg"))
.body(Body::empty())
.unwrap();
let res = app.oneshot(req).await.unwrap();
assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
}
// --- Input validation on auth endpoints ---
#[tokio::test]
async fn setup_rejects_short_password() {
let (app, _state) = common::setup_test_app().await;
let req = Request::builder()
.method("POST")
.uri("/api/auth/setup")
.header("content-type", "application/json")
.body(json_body(serde_json::json!({
"username": "admin",
"password": "short"
})))
.unwrap();
let res = app.oneshot(req).await.unwrap();
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn setup_rejects_empty_username() {
let (app, _state) = common::setup_test_app().await;
let req = Request::builder()
.method("POST")
.uri("/api/auth/setup")
.header("content-type", "application/json")
.body(json_body(serde_json::json!({
"username": "",
"password": "testpassword123"
})))
.unwrap();
let res = app.oneshot(req).await.unwrap();
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn setup_rejects_username_with_whitespace() {
let (app, _state) = common::setup_test_app().await;
let req = Request::builder()
.method("POST")
.uri("/api/auth/setup")
.header("content-type", "application/json")
.body(json_body(serde_json::json!({
"username": "admin user",
"password": "testpassword123"
})))
.unwrap();
let res = app.oneshot(req).await.unwrap();
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
}
// --- Expired JWT token ---
#[tokio::test]
async fn expired_jwt_rejected() {
let (app, state) = common::setup_test_app().await;
let claims = serde_json::json!({
"sub": "admin",
"exp": 1000000000 // Way in the past (2001)
});
let token = jsonwebtoken::encode(
&jsonwebtoken::Header::default(),
&claims,
&jsonwebtoken::EncodingKey::from_secret(&state.jwt_secret),
)
.unwrap();
let req = Request::builder()
.uri("/api/keys")
.header("authorization", format!("Bearer {token}"))
.body(Body::empty())
.unwrap();
let res = app.oneshot(req).await.unwrap();
assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
}
// --- JWT signed with wrong secret ---
#[tokio::test]
async fn jwt_with_wrong_secret_rejected() {
let (app, _state) = common::setup_test_app().await;
let wrong_secret = vec![0xCD; 32];
let exp = (chrono::Utc::now() + chrono::Duration::hours(1)).timestamp() as usize;
let claims = serde_json::json!({
"sub": "admin",
"exp": exp
});
let token = jsonwebtoken::encode(
&jsonwebtoken::Header::default(),
&claims,
&jsonwebtoken::EncodingKey::from_secret(&wrong_secret),
)
.unwrap();
let req = Request::builder()
.uri("/api/keys")
.header("authorization", format!("Bearer {token}"))
.body(Body::empty())
.unwrap();
let res = app.oneshot(req).await.unwrap();
assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
}
// --- Poster endpoint with invalid id type ---
#[tokio::test]
async fn poster_endpoint_invalid_id_type() {
let (app, _state) = common::setup_test_app().await;
let token = common::setup_admin(&app).await;
// Create a valid API key
let req = Request::builder()
.method("POST")
.uri("/api/keys")
.header("content-type", "application/json")
.header("authorization", format!("Bearer {token}"))
.body(json_body(serde_json::json!({"name": "test"})))
.unwrap();
let res = app.clone().oneshot(req).await.unwrap();
let body = res.into_body().collect().await.unwrap().to_bytes();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
let api_key = json["key"].as_str().unwrap();
// Use invalid id type
let req = Request::builder()
.uri(format!("/{api_key}/invalid_type/poster-default/tt0000001.jpg"))
.body(Body::empty())
.unwrap();
let res = app.oneshot(req).await.unwrap();
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
}
// --- Refresh with fabricated token (not in DB) ---
#[tokio::test]
async fn refresh_with_fabricated_token_returns_401() {
let (app, _state) = common::setup_test_app().await;
common::setup_admin(&app).await;
let req = Request::builder()
.method("POST")
.uri("/api/auth/refresh")
.header("cookie", "refresh_token=aabbccdd00112233fabricatedtoken")
.body(Body::empty())
.unwrap();
let res = app.oneshot(req).await.unwrap();
assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
}
// --- Delete nonexistent API key ---
#[tokio::test]
async fn delete_nonexistent_api_key_succeeds() {
let (app, _state) = common::setup_test_app().await;
let token = common::setup_admin(&app).await;
let req = Request::builder()
.method("DELETE")
.uri("/api/keys/99999")
.header("authorization", format!("Bearer {token}"))
.body(Body::empty())
.unwrap();
let res = app.oneshot(req).await.unwrap();
assert_eq!(res.status(), StatusCode::OK);
}
// --- Path traversal prevention ---
#[tokio::test]
async fn poster_endpoint_rejects_path_traversal() {
let (app, _state) = common::setup_test_app().await;
let token = common::setup_admin(&app).await;
// Create a valid API key
let req = Request::builder()
.method("POST")
.uri("/api/keys")
.header("content-type", "application/json")
.header("authorization", format!("Bearer {token}"))
.body(json_body(serde_json::json!({"name": "test"})))
.unwrap();
let res = app.clone().oneshot(req).await.unwrap();
let body = res.into_body().collect().await.unwrap().to_bytes();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
let api_key = json["key"].as_str().unwrap();
// Attempt path traversal in id_value
let req = Request::builder()
.uri(format!("/{api_key}/imdb/poster-default/..%2F..%2Fetc%2Fpasswd.jpg"))
.body(Body::empty())
.unwrap();
let res = app.oneshot(req).await.unwrap();
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
}
// --- Error redaction (integration) ---
#[tokio::test]
async fn internal_error_response_is_redacted() {
let (app, _state) = common::setup_test_app().await;
let token = common::setup_admin(&app).await;
// Create a valid API key
let req = Request::builder()
.method("POST")
.uri("/api/keys")
.header("content-type", "application/json")
.header("authorization", format!("Bearer {token}"))
.body(json_body(serde_json::json!({"name": "test"})))
.unwrap();
let res = app.clone().oneshot(req).await.unwrap();
let body = res.into_body().collect().await.unwrap().to_bytes();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
let api_key = json["key"].as_str().unwrap();
// Request a poster that will fail internally (fake TMDB key)
let req = Request::builder()
.uri(format!("/{api_key}/imdb/poster-default/tt0000001.jpg"))
.body(Body::empty())
.unwrap();
let res = app.oneshot(req).await.unwrap();
assert_eq!(res.status(), StatusCode::INTERNAL_SERVER_ERROR);
let body = res.into_body().collect().await.unwrap().to_bytes();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
let error_msg = json["error"].as_str().unwrap();
// Must not leak internal details like URLs, connection errors, etc.
assert_eq!(error_msg, "Internal server error");
}
// --- HSTS header ---
#[tokio::test]
async fn hsts_header_present_when_secure_cookies() {
let (app, _state) = common::setup_test_app_with_options(common::TestAppOptions {
secure_cookies: true,
..Default::default()
})
.await;
let req = Request::builder()
.uri("/api/auth/status")
.body(Body::empty())
.unwrap();
let res = app.oneshot(req).await.unwrap();
assert_eq!(res.status(), StatusCode::OK);
let hsts = res
.headers()
.get("strict-transport-security")
.and_then(|v| v.to_str().ok());
assert_eq!(hsts, Some("max-age=63072000; includeSubDomains"));
}
#[tokio::test]
async fn hsts_header_absent_when_not_secure() {
let (app, _state) = common::setup_test_app().await;
let req = Request::builder()
.uri("/api/auth/status")
.body(Body::empty())
.unwrap();
let res = app.oneshot(req).await.unwrap();
assert_eq!(res.status(), StatusCode::OK);
assert!(res.headers().get("strict-transport-security").is_none());
}
// --- Password max length (integration) ---
#[tokio::test]
async fn setup_rejects_too_long_password() {
let (app, _state) = common::setup_test_app().await;
let long_pw = "a".repeat(257);
let req = Request::builder()
.method("POST")
.uri("/api/auth/setup")
.header("content-type", "application/json")
.body(json_body(serde_json::json!({
"username": "admin",
"password": long_pw
})))
.unwrap();
let res = app.oneshot(req).await.unwrap();
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
}
// --- Security headers ---
#[tokio::test]
async fn security_headers_present_on_all_responses() {
let (app, _state) = common::setup_test_app().await;
let req = Request::builder()
.uri("/api/auth/status")
.body(Body::empty())
.unwrap();
let res = app.oneshot(req).await.unwrap();
assert_eq!(res.status(), StatusCode::OK);
let xcto = res
.headers()
.get("x-content-type-options")
.and_then(|v| v.to_str().ok());
assert_eq!(xcto, Some("nosniff"), "X-Content-Type-Options should be nosniff");
let xfo = res
.headers()
.get("x-frame-options")
.and_then(|v| v.to_str().ok());
assert_eq!(xfo, Some("DENY"), "X-Frame-Options should be DENY");
}
#[tokio::test]
async fn security_headers_present_on_error_responses() {
let (app, _state) = common::setup_test_app().await;
let req = Request::builder()
.uri("/api/keys")
.body(Body::empty())
.unwrap();
let res = app.oneshot(req).await.unwrap();
assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
assert!(
res.headers().get("x-content-type-options").is_some(),
"X-Content-Type-Options should be present on error responses"
);
assert!(
res.headers().get("x-frame-options").is_some(),
"X-Frame-Options should be present on error responses"
);
}
// --- Username max length ---
#[tokio::test]
async fn setup_rejects_too_long_username() {
let (app, _state) = common::setup_test_app().await;
let long_name = "a".repeat(129);
let req = Request::builder()
.method("POST")
.uri("/api/auth/setup")
.header("content-type", "application/json")
.body(json_body(serde_json::json!({
"username": long_name,
"password": "testpassword123"
})))
.unwrap();
let res = app.oneshot(req).await.unwrap();
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
}
// --- API key name validation ---
#[tokio::test]
async fn create_api_key_rejects_too_long_name() {
let (app, _state) = common::setup_test_app().await;
let token = common::setup_admin(&app).await;
let long_name = "a".repeat(129);
let req = Request::builder()
.method("POST")
.uri("/api/keys")
.header("content-type", "application/json")
.header("authorization", format!("Bearer {token}"))
.body(json_body(serde_json::json!({"name": long_name})))
.unwrap();
let res = app.oneshot(req).await.unwrap();
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn create_api_key_rejects_control_chars_in_name() {
let (app, _state) = common::setup_test_app().await;
let token = common::setup_admin(&app).await;
let req = Request::builder()
.method("POST")
.uri("/api/keys")
.header("content-type", "application/json")
.header("authorization", format!("Bearer {token}"))
.body(json_body(serde_json::json!({"name": "key\u{0000}name"})))
.unwrap();
let res = app.oneshot(req).await.unwrap();
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
}
// --- Malformed JWT handling ---
#[tokio::test]
async fn truncated_jwt_rejected() {
let (app, _state) = common::setup_test_app().await;
let req = Request::builder()
.uri("/api/keys")
.header("authorization", "Bearer eyJhbGciOiJIUzI1NiJ9.truncated")
.body(Body::empty())
.unwrap();
let res = app.oneshot(req).await.unwrap();
assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn jwt_with_none_algorithm_rejected() {
let (app, _state) = common::setup_test_app().await;
use base64::Engine;
let header = base64::engine::general_purpose::URL_SAFE_NO_PAD
.encode(r#"{"alg":"none","typ":"JWT"}"#);
let exp = (chrono::Utc::now() + chrono::Duration::hours(1)).timestamp();
let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD
.encode(format!(r#"{{"sub":"admin","exp":{exp}}}"#));
let token = format!("{header}.{payload}.");
let req = Request::builder()
.uri("/api/keys")
.header("authorization", format!("Bearer {token}"))
.body(Body::empty())
.unwrap();
let res = app.oneshot(req).await.unwrap();
assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn jwt_with_empty_string_rejected() {
let (app, _state) = common::setup_test_app().await;
let req = Request::builder()
.uri("/api/keys")
.header("authorization", "Bearer ")
.body(Body::empty())
.unwrap();
let res = app.oneshot(req).await.unwrap();
assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn jwt_with_garbage_rejected() {
let (app, _state) = common::setup_test_app().await;
let req = Request::builder()
.uri("/api/keys")
.header("authorization", "Bearer not-a-jwt-at-all")
.body(Body::empty())
.unwrap();
let res = app.oneshot(req).await.unwrap();
assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
}
// --- Concurrent refresh token reuse ---
#[tokio::test]
async fn concurrent_refresh_token_reuse_at_most_one_succeeds() {
let (app, _state) = common::setup_test_app().await;
let req = Request::builder()
.method("POST")
.uri("/api/auth/setup")
.header("content-type", "application/json")
.body(json_body(serde_json::json!({
"username": "admin",
"password": "testpassword123"
})))
.unwrap();
let res = app.clone().oneshot(req).await.unwrap();
let set_cookie = common::extract_set_cookie(res.headers()).unwrap();
let refresh_token = common::extract_refresh_token(&set_cookie).unwrap();
let app1 = app.clone();
let app2 = app.clone();
let rt1 = refresh_token.clone();
let rt2 = refresh_token.clone();
let req1 = Request::builder()
.method("POST")
.uri("/api/auth/refresh")
.header("cookie", format!("refresh_token={rt1}"))
.body(Body::empty())
.unwrap();
let req2 = Request::builder()
.method("POST")
.uri("/api/auth/refresh")
.header("cookie", format!("refresh_token={rt2}"))
.body(Body::empty())
.unwrap();
let (res1, res2) = tokio::join!(app1.oneshot(req1), app2.oneshot(req2));
let status1 = res1.unwrap().status();
let status2 = res2.unwrap().status();
let successes = [status1, status2]
.iter()
.filter(|s| **s == StatusCode::OK)
.count();
assert!(
successes <= 1,
"at most one concurrent refresh should succeed (got {status1}, {status2})"
);
}
// --- Empty/malformed body handling ---
#[tokio::test]
async fn setup_with_empty_body_returns_error() {
let (app, _state) = common::setup_test_app().await;
let req = Request::builder()
.method("POST")
.uri("/api/auth/setup")
.header("content-type", "application/json")
.body(Body::empty())
.unwrap();
let res = app.oneshot(req).await.unwrap();
assert!(
res.status().is_client_error(),
"empty body should return 4xx, got {}",
res.status()
);
}
#[tokio::test]
async fn login_with_empty_body_returns_error() {
let (app, _state) = common::setup_test_app().await;
let req = Request::builder()
.method("POST")
.uri("/api/auth/login")
.header("content-type", "application/json")
.body(Body::empty())
.unwrap();
let res = app.oneshot(req).await.unwrap();
assert!(
res.status().is_client_error(),
"empty body should return 4xx, got {}",
res.status()
);
}
#[tokio::test]
async fn setup_with_wrong_content_type_returns_error() {
let (app, _state) = common::setup_test_app().await;
let req = Request::builder()
.method("POST")
.uri("/api/auth/setup")
.header("content-type", "text/plain")
.body(Body::from(r#"{"username":"admin","password":"testpassword123"}"#))
.unwrap();
let res = app.oneshot(req).await.unwrap();
assert!(
res.status().is_client_error(),
"wrong content-type should return 4xx, got {}",
res.status()
);
}
#[tokio::test]
async fn key_login_with_empty_body_returns_error() {
let (app, _state) = common::setup_test_app().await;
let req = Request::builder()
.method("POST")
.uri("/api/auth/key-login")
.header("content-type", "application/json")
.body(Body::empty())
.unwrap();
let res = app.oneshot(req).await.unwrap();
assert!(
res.status().is_client_error(),
"empty body should return 4xx, got {}",
res.status()
);
}
// --- Free API key cannot login ---
#[tokio::test]
async fn free_api_key_cannot_login_via_security() {
let (app, _state) = common::setup_test_app().await;
let token = common::setup_admin(&app).await;
// Enable free key
let req = Request::builder()
.method("PUT")
.uri("/api/admin/settings")
.header("content-type", "application/json")
.header("authorization", format!("Bearer {token}"))
.body(json_body(serde_json::json!({
"image_source": "t",
"free_api_key_enabled": true
})))
.unwrap();
app.clone().oneshot(req).await.unwrap();
let req = Request::builder()
.method("POST")
.uri("/api/auth/key-login")
.header("content-type", "application/json")
.body(json_body(serde_json::json!({"api_key": "t0-free-rpdb"})))
.unwrap();
let res = app.oneshot(req).await.unwrap();
assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
}
// --- Cookie Secure flag behavior ---
#[tokio::test]
async fn cookie_has_secure_flag_in_secure_mode() {
let (app, _state) = common::setup_test_app_with_options(common::TestAppOptions {
secure_cookies: true,
..Default::default()
})
.await;
let req = Request::builder()
.method("POST")
.uri("/api/auth/setup")
.header("content-type", "application/json")
.body(json_body(serde_json::json!({
"username": "admin",
"password": "testpassword123"
})))
.unwrap();
let res = app.oneshot(req).await.unwrap();
assert_eq!(res.status(), StatusCode::OK);
let set_cookie = common::extract_set_cookie(res.headers()).unwrap();
assert!(set_cookie.contains("; Secure"), "cookie should have Secure flag");
}
#[tokio::test]
async fn cookie_lacks_secure_flag_in_insecure_mode() {
let (app, _state) = common::setup_test_app().await;
let req = Request::builder()
.method("POST")
.uri("/api/auth/setup")
.header("content-type", "application/json")
.body(json_body(serde_json::json!({
"username": "admin",
"password": "testpassword123"
})))
.unwrap();
let res = app.oneshot(req).await.unwrap();
assert_eq!(res.status(), StatusCode::OK);
let set_cookie = common::extract_set_cookie(res.headers()).unwrap();
assert!(!set_cookie.contains("; Secure"), "cookie should not have Secure flag");
}
// --- Authorization header edge cases ---
#[tokio::test]
async fn auth_without_bearer_prefix_rejected() {
let (app, _state) = common::setup_test_app().await;
let req = Request::builder()
.uri("/api/keys")
.header("authorization", "Basic dXNlcjpwYXNz")
.body(Body::empty())
.unwrap();
let res = app.oneshot(req).await.unwrap();
assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn auth_with_no_authorization_header_rejected() {
let (app, _state) = common::setup_test_app().await;
let req = Request::builder()
.uri("/api/keys")
.body(Body::empty())
.unwrap();
let res = app.oneshot(req).await.unwrap();
assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
}
// --- Poster endpoint with null byte injection ---
#[tokio::test]
async fn poster_endpoint_rejects_null_bytes_in_id() {
let (app, _state) = common::setup_test_app().await;
let token = common::setup_admin(&app).await;
let req = Request::builder()
.method("POST")
.uri("/api/keys")
.header("content-type", "application/json")
.header("authorization", format!("Bearer {token}"))
.body(json_body(serde_json::json!({"name": "test"})))
.unwrap();
let res = app.clone().oneshot(req).await.unwrap();
let body = res.into_body().collect().await.unwrap().to_bytes();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
let api_key = json["key"].as_str().unwrap();
let req = Request::builder()
.uri(format!("/{api_key}/imdb/poster-default/tt123%00.jpg"))
.body(Body::empty())
.unwrap();
let res = app.oneshot(req).await.unwrap();
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
}
mod common;
use axum::body::Body;
use axum::http::{Request, StatusCode};
use http_body_util::BodyExt;
use sea_orm::ConnectionTrait;
use tower::ServiceExt;
fn json_body(json: serde_json::Value) -> Body {
Body::from(json.to_string())
}
// --- CORS origin enforcement (via preflight OPTIONS requests) ---
async fn cors_preflight_without_origin_config_rejected() {
// No CORS origin configured — CorsLayer::new() rejects all cross-origin
let (app, _state) = common::setup_test_app().await;
let req = Request::builder()
.method("OPTIONS")
.uri("/api/auth/setup")
.header("origin", "https://example.com")
.header("access-control-request-method", "POST")
.body(Body::empty())
.unwrap();
let res = app.oneshot(req).await.unwrap();
// No Access-Control-Allow-Origin header should be present
assert!(res.headers().get("access-control-allow-origin").is_none());
}
async fn cors_preflight_with_correct_origin_allowed() {
let (app, _state) =
common::setup_test_app_with_cors(Some("https://example.com".into())).await;
let req = Request::builder()
.method("OPTIONS")
.uri("/api/auth/setup")
.header("origin", "https://example.com")
.header("access-control-request-method", "POST")
.body(Body::empty())
.unwrap();
let res = app.oneshot(req).await.unwrap();
let acao = res
.headers()
.get("access-control-allow-origin")
.and_then(|v| v.to_str().ok());
assert_eq!(acao, Some("https://example.com"));
}
async fn cors_preflight_with_wrong_origin_rejected() {
let (app, _state) =
common::setup_test_app_with_cors(Some("https://example.com".into())).await;
let req = Request::builder()
.method("OPTIONS")
.uri("/api/auth/setup")
.header("origin", "https://evil.com")
.header("access-control-request-method", "POST")
.body(Body::empty())
.unwrap();
let res = app.oneshot(req).await.unwrap();
// CorsLayer echoes the configured origin, not the request origin.
// The browser compares and rejects when they don't match.
let acao = res
.headers()
.get("access-control-allow-origin")
.and_then(|v| v.to_str().ok());
assert_ne!(acao, Some("https://evil.com"), "should not allow wrong origin");
}
async fn cors_get_allowed_even_with_cors_origin_set() {
let (app, _state) =
common::setup_test_app_with_cors(Some("https://example.com".into())).await;
let req = Request::builder()
.uri("/api/auth/status")
.body(Body::empty())
.unwrap();
let res = app.oneshot(req).await.unwrap();
assert_eq!(res.status(), StatusCode::OK);
}
async fn cors_delete_without_origin_no_cors_headers() {
let (app, _state) =
common::setup_test_app_with_cors(Some("https://example.com".into())).await;
let req = Request::builder()
.method("DELETE")
.uri("/api/keys/1")
.header("authorization", "Bearer fake")
.body(Body::empty())