Update: 将子项目从 submodule 转为完整内容
- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用 - 添加所有子项目的完整源代码 - 保留原始 .git 为 .git.bak 备份
This commit is contained in:
@@ -0,0 +1,329 @@
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user