Files
MyAiDesk/nomifun-tauri/crates/backend/nomifun-auth/tests/middleware_tests.rs
T
freedak f7a720204a Update: 将子项目从 submodule 转为完整内容
- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用
- 添加所有子项目的完整源代码
- 保留原始 .git 为 .git.bak 备份
2026-07-04 19:20:46 +08:00

330 lines
10 KiB
Rust

use std::sync::Arc;
use std::time::Duration;
use axum::Router;
use axum::body::Body;
use axum::http::{Request, StatusCode, header};
use axum::middleware;
use axum::routing::{get, post};
use tower::ServiceExt;
use nomifun_auth::{
CookieConfig, CurrentUser, RateLimiter, api_rate_limit_middleware, auth_rate_limit_middleware,
authenticated_action_rate_limit_middleware, csrf_middleware, security_headers_middleware,
};
// ============================================================
// T12.1 — Security response headers
// ============================================================
#[tokio::test]
async fn t12_1_security_headers_on_get() {
let app = Router::new()
.route("/test", get(|| async { "ok" }))
.layer(middleware::from_fn(security_headers_middleware));
let resp = app
.oneshot(Request::get("/test").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(resp.headers().get("x-frame-options").unwrap(), "DENY");
assert_eq!(resp.headers().get("x-content-type-options").unwrap(), "nosniff");
assert_eq!(resp.headers().get("x-xss-protection").unwrap(), "1; mode=block");
assert_eq!(
resp.headers().get("referrer-policy").unwrap(),
"strict-origin-when-cross-origin"
);
}
// ============================================================
// T12.2 — CSRF protection
// ============================================================
fn csrf_app() -> Router {
let config = Arc::new(CookieConfig {
secure: false,
same_site: "Lax",
});
Router::new()
.route("/api/test", post(|| async { "ok" }))
.route("/login", post(|| async { "logged in" }))
.route("/api/auth/qr-login", post(|| async { "qr ok" }))
.route("/get-test", get(|| async { "get ok" }))
.layer(middleware::from_fn_with_state(config, csrf_middleware))
}
#[tokio::test]
async fn t12_2_get_requests_bypass_csrf() {
let app = csrf_app();
let resp = app
.oneshot(Request::get("/get-test").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn t12_2_post_without_csrf_token_rejected() {
let app = csrf_app();
let resp = app
.oneshot(Request::post("/api/test").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn t12_2_post_with_matching_csrf_tokens_accepted() {
let app = csrf_app();
let token = "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890";
let resp = app
.oneshot(
Request::post("/api/test")
.header("cookie", format!("nomifun-csrf-token={token}"))
.header("x-csrf-token", token)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn t12_2_post_with_mismatched_csrf_tokens_rejected() {
let app = csrf_app();
let resp = app
.oneshot(
Request::post("/api/test")
.header("cookie", "nomifun-csrf-token=token_a")
.header("x-csrf-token", "token_b")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn t12_2_login_exempt_from_csrf() {
let app = csrf_app();
let resp = app
.oneshot(Request::post("/login").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn t12_2_qr_login_exempt_from_csrf() {
let app = csrf_app();
let resp = app
.oneshot(Request::post("/api/auth/qr-login").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn t12_2_csrf_cookie_set_on_first_request() {
let app = csrf_app();
let resp = app
.oneshot(Request::get("/get-test").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let set_cookie = resp.headers().get(header::SET_COOKIE).unwrap().to_str().unwrap();
assert!(set_cookie.contains("nomifun-csrf-token="));
// NOT HttpOnly (JS must read it)
assert!(!set_cookie.contains("HttpOnly"));
}
// ============================================================
// Rate limiter middleware
// ============================================================
fn rate_limit_app(limiter: Arc<RateLimiter>) -> Router {
Router::new()
.route("/test", get(|| async { "ok" }))
.layer(middleware::from_fn_with_state(limiter, api_rate_limit_middleware))
}
#[tokio::test]
async fn api_rate_limit_allows_within_quota() {
let limiter = Arc::new(RateLimiter::new(3, Duration::from_secs(60)));
let app = rate_limit_app(limiter);
for _ in 0..3 {
let resp = app
.clone()
.oneshot(Request::get("/test").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
}
#[tokio::test]
async fn api_rate_limit_rejects_over_quota() {
let limiter = Arc::new(RateLimiter::new(2, Duration::from_secs(60)));
let app = rate_limit_app(limiter);
// First two pass
for _ in 0..2 {
let resp = app
.clone()
.oneshot(Request::get("/test").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
// Third rejected
let resp = app
.oneshot(Request::get("/test").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::TOO_MANY_REQUESTS);
}
#[tokio::test]
async fn auth_rate_limit_skips_successful_responses() {
let limiter = Arc::new(RateLimiter::new(2, Duration::from_secs(60)));
let app = Router::new()
.route("/login", post(|| async { "ok" }))
.layer(middleware::from_fn_with_state(limiter, auth_rate_limit_middleware));
// Successful responses (200) don't count toward the limit
for _ in 0..5 {
let resp = app
.clone()
.oneshot(Request::post("/login").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
}
#[tokio::test]
async fn auth_rate_limit_counts_failed_responses() {
let limiter = Arc::new(RateLimiter::new(2, Duration::from_secs(60)));
let app = Router::new()
.route("/login", post(|| async { StatusCode::UNAUTHORIZED }))
.layer(middleware::from_fn_with_state(limiter, auth_rate_limit_middleware));
// First two failures pass through
for _ in 0..2 {
let resp = app
.clone()
.oneshot(Request::post("/login").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
// Third request blocked by rate limiter
let resp = app
.oneshot(Request::post("/login").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::TOO_MANY_REQUESTS);
}
#[tokio::test]
async fn authenticated_action_limit_uses_user_id_key() {
let limiter = Arc::new(RateLimiter::new(1, Duration::from_secs(60)));
// Handler that injects a CurrentUser extension before the limiter
let app = Router::new()
.route("/action", post(|| async { "done" }))
.layer(middleware::from_fn_with_state(
limiter.clone(),
authenticated_action_rate_limit_middleware,
))
.layer(middleware::from_fn(
|mut request: axum::extract::Request, next: axum::middleware::Next| async {
request.extensions_mut().insert(CurrentUser {
id: "user_42".into(),
username: "admin".into(),
});
Ok::<_, std::convert::Infallible>(next.run(request).await)
},
));
// First request passes
let resp = app
.clone()
.oneshot(Request::post("/action").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
// Second request for same user is rate limited
let resp = app
.oneshot(Request::post("/action").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::TOO_MANY_REQUESTS);
}
// ============================================================
// T12.3 — Cookie security attributes (via CookieConfig)
// ============================================================
#[test]
fn t12_3_session_cookie_is_httponly() {
let config = CookieConfig {
secure: false,
same_site: "Lax",
};
let cookie = config.build_session_cookie("token123");
assert!(cookie.contains("HttpOnly"));
assert!(cookie.contains("SameSite=Lax"));
assert!(cookie.contains("Max-Age="));
}
#[test]
fn t12_3_session_cookie_secure_when_https() {
let config = CookieConfig {
secure: true,
same_site: "Strict",
};
let cookie = config.build_session_cookie("token123");
assert!(cookie.contains("; Secure"));
assert!(cookie.contains("SameSite=Strict"));
}
// ============================================================
// T13 — Token extraction strategy
// ============================================================
#[test]
fn t13_1_authorization_header_takes_priority() {
let mut headers = axum::http::HeaderMap::new();
headers.insert(header::AUTHORIZATION, "Bearer header_tok".parse().unwrap());
headers.insert(header::COOKIE, "nomifun-session=cookie_tok".parse().unwrap());
assert_eq!(
nomifun_auth::extract_token_from_headers(&headers),
Some("header_tok".into())
);
}
#[test]
fn t13_2_cookie_fallback() {
let mut headers = axum::http::HeaderMap::new();
headers.insert(header::COOKIE, "nomifun-session=fallback_tok".parse().unwrap());
assert_eq!(
nomifun_auth::extract_token_from_headers(&headers),
Some("fallback_tok".into())
);
}
#[test]
fn t13_3_no_token_returns_none() {
let headers = axum::http::HeaderMap::new();
assert_eq!(nomifun_auth::extract_token_from_headers(&headers), None);
}