Update: 将子项目从 submodule 转为完整内容

- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用
- 添加所有子项目的完整源代码
- 保留原始 .git 为 .git.bak 备份
This commit is contained in:
freedak
2026-07-04 19:20:46 +08:00
parent 54d6465fa7
commit f7a720204a
3360 changed files with 802660 additions and 3 deletions
@@ -0,0 +1,72 @@
//! JWT service integration tests.
//!
//! Tests the full lifecycle of JWT operations: sign, verify, blacklist, rotate.
use nomifun_auth::{AuthError, JwtService, resolve_jwt_secret};
#[test]
fn full_lifecycle_sign_verify_blacklist() {
let service = JwtService::new("integration_test_secret".into());
// Sign a token
let token = service.sign("user_42", "testuser").unwrap();
assert!(!token.is_empty());
// Verify the token
let payload = service.verify(&token).unwrap();
assert_eq!(payload.user_id, "user_42");
assert_eq!(payload.username, "testuser");
// Blacklist the token
service.blacklist_token(&token);
// Verification should now fail
let result = service.verify(&token);
assert!(matches!(result, Err(AuthError::TokenBlacklisted)));
}
#[test]
fn secret_rotation_invalidates_all_tokens() {
let service = JwtService::new("original_secret".into());
let token1 = service.sign("user_1", "alice").unwrap();
let token2 = service.sign("user_2", "bob").unwrap();
// Both tokens are valid
assert!(service.verify(&token1).is_ok());
assert!(service.verify(&token2).is_ok());
// Rotate the secret
service.rotate_secret().unwrap();
// Both old tokens are now invalid
assert!(service.verify(&token1).is_err());
assert!(service.verify(&token2).is_err());
// New tokens with the new secret work
let new_token = service.sign("user_3", "charlie").unwrap();
let payload = service.verify(&new_token).unwrap();
assert_eq!(payload.user_id, "user_3");
}
#[test]
fn resolve_secret_priority_order() {
// Environment variable takes precedence
let (secret, generated) = resolve_jwt_secret(Some("env"), Some("db"));
assert_eq!(secret, "env");
assert!(!generated);
// Database value is fallback
let (secret, generated) = resolve_jwt_secret(None, Some("db"));
assert_eq!(secret, "db");
assert!(!generated);
// Random generation as last resort
let (secret, generated) = resolve_jwt_secret(None, None);
assert!(!secret.is_empty());
assert!(generated);
// Generated secrets are unique
let (secret2, _) = resolve_jwt_secret(None, None);
assert_ne!(secret, secret2);
}
@@ -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);
}
@@ -0,0 +1,832 @@
//! Black-box integration tests for auth REST API routes.
//!
//! Covers test-plan items T4 (login), T5 (logout), T6 (auth status),
//! T7 (current user), T8 (change password), T9 (refresh token),
//! T10 (ws token), T11 (QR login).
use std::sync::Arc;
use axum::Router;
use axum::body::Body;
use axum::http::{Request, StatusCode, header};
use http_body_util::BodyExt;
use tower::ServiceExt;
use nomifun_auth::{
AuthPolicy, AuthRouterState, CookieConfig, JwtService, QrTokenStore, TrustState, auth_routes, hash_password,
trust_resolve_middleware,
};
use nomifun_db::{IUserRepository, SqliteUserRepository, init_database_memory};
// ---------------------------------------------------------------------------
// Test helpers
// ---------------------------------------------------------------------------
/// Create a test app with an in-memory database.
async fn test_app() -> (Router, TestContext) {
test_app_with_local(false).await
}
async fn test_app_with_local(local: bool) -> (Router, TestContext) {
let db = init_database_memory().await.unwrap();
let user_repo = Arc::new(SqliteUserRepository::new(db.pool().clone())) as Arc<dyn IUserRepository>;
let jwt_service = Arc::new(JwtService::new("test_secret_for_routes".into()));
let cookie_config = Arc::new(CookieConfig {
secure: false,
same_site: "Lax",
});
let qr_token_store = Arc::new(QrTokenStore::new());
let state = AuthRouterState {
jwt_service: jwt_service.clone(),
user_repo: user_repo.clone(),
cookie_config,
qr_token_store: qr_token_store.clone(),
};
// Mirror `create_router`: the global trust middleware resolves local trust
// (and injects the system user / `LocalTrusted` marker) before the per-route
// auth + local-only gates run. `local` maps to NoAuth (everything trusted),
// otherwise Required (JWT enforced).
let trust_state = TrustState {
policy: if local { AuthPolicy::NoAuth } else { AuthPolicy::Required },
local_trust_secret: None,
};
let app = auth_routes(state).layer(axum::middleware::from_fn_with_state(trust_state, trust_resolve_middleware));
let ctx = TestContext {
jwt_service,
user_repo,
qr_token_store,
_db: db,
};
(app, ctx)
}
/// Holds references needed by test assertions.
struct TestContext {
jwt_service: Arc<JwtService>,
user_repo: Arc<dyn IUserRepository>,
qr_token_store: Arc<QrTokenStore>,
_db: nomifun_db::Database,
}
/// Helper: create a test user with known credentials.
///
/// The seeded `system_default_user` row already uses `username = "admin"` with
/// an empty password hash. If the test asks for that username, update the seed
/// row in place instead of trying to INSERT a duplicate. Any other username
/// takes the normal create_user path.
async fn create_test_user(ctx: &TestContext, username: &str, password: &str) {
let hash = hash_password(password).unwrap();
if username == "admin" {
ctx.user_repo
.set_system_user_credentials(username, &hash)
.await
.unwrap();
} else {
ctx.user_repo.create_user(username, &hash).await.unwrap();
}
}
/// Helper: perform a JSON POST request.
fn json_post(uri: &str, body: &str) -> Request<Body> {
Request::builder()
.method("POST")
.uri(uri)
.header("content-type", "application/json")
.body(Body::from(body.to_owned()))
.unwrap()
}
/// Helper: perform a JSON POST request with auth token.
fn json_post_with_token(uri: &str, body: &str, token: &str) -> Request<Body> {
Request::builder()
.method("POST")
.uri(uri)
.header("content-type", "application/json")
.header("authorization", format!("Bearer {token}"))
.body(Body::from(body.to_owned()))
.unwrap()
}
/// Helper: perform a GET request with auth token.
fn get_with_token(uri: &str, token: &str) -> Request<Body> {
Request::builder()
.method("GET")
.uri(uri)
.header("authorization", format!("Bearer {token}"))
.body(Body::empty())
.unwrap()
}
/// Helper: perform a GET request without auth.
fn get_anonymous(uri: &str) -> Request<Body> {
Request::builder().method("GET").uri(uri).body(Body::empty()).unwrap()
}
/// Helper: extract response body as JSON.
async fn body_json(resp: axum::response::Response) -> serde_json::Value {
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
serde_json::from_slice(&bytes).unwrap()
}
/// Helper: login and return (token, user_id).
async fn login(app: &mut Router, username: &str, password: &str) -> (String, String) {
let req = json_post(
"/login",
&format!(r#"{{"username":"{username}","password":"{password}"}}"#),
);
let resp = app.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let json = body_json(resp).await;
let token = json["token"].as_str().unwrap().to_owned();
let user_id = json["user"]["id"].as_str().unwrap().to_owned();
(token, user_id)
}
fn json_post_anonymous(uri: &str, body: &str) -> Request<Body> {
json_post(uri, body)
}
// ===========================================================================
// T4. Login (POST /login)
// ===========================================================================
#[tokio::test]
async fn t4_1_login_success() {
let (app, ctx) = test_app().await;
create_test_user(&ctx, "admin", "StrongP@ss1").await;
let req = json_post("/login", r#"{"username":"admin","password":"StrongP@ss1"}"#);
let resp = app.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
// Check Set-Cookie header
let set_cookie = resp.headers().get(header::SET_COOKIE).unwrap().to_str().unwrap();
assert!(set_cookie.contains("nomifun-session="));
assert!(set_cookie.contains("HttpOnly"));
let json = body_json(resp).await;
assert_eq!(json["success"], true);
assert_eq!(json["message"], "Login successful");
assert!(json["token"].is_string());
assert_eq!(json["user"]["username"], "admin");
assert!(json["user"]["id"].is_string());
// Verify the returned token is valid
let token = json["token"].as_str().unwrap();
assert!(ctx.jwt_service.verify(token).is_ok());
}
#[tokio::test]
async fn t4_2_login_nonexistent_user() {
let (app, _ctx) = test_app().await;
let req = json_post("/login", r#"{"username":"ghost","password":"whatever"}"#);
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
let json = body_json(resp).await;
assert_eq!(json["success"], false);
}
#[tokio::test]
async fn t4_3_login_wrong_password() {
let (app, ctx) = test_app().await;
create_test_user(&ctx, "admin", "CorrectP@ss1").await;
let req = json_post("/login", r#"{"username":"admin","password":"WrongPass1"}"#);
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn t4_4_login_missing_fields() {
let (app, _ctx) = test_app().await;
// Missing password
let req = json_post("/login", r#"{"username":"admin"}"#);
let resp = app.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
// Missing username
let req = json_post("/login", r#"{"password":"test"}"#);
let resp = app.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
// Empty body
let req = json_post("/login", r#"{}"#);
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn t4_5_login_empty_password_hash_returns_401() {
// Regression: when the seeded system user has an empty password_hash
// (first-run local mode), POST /login must return 401, not 500.
let (app, _ctx) = test_app_with_local(true).await;
let req = json_post("/login", r#"{"username":"admin","password":"anything"}"#);
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
let json = body_json(resp).await;
assert_eq!(json["success"], false);
assert_eq!(json["code"], "UNAUTHORIZED");
}
#[tokio::test]
async fn t4_6_login_username_too_long() {
let (app, _ctx) = test_app().await;
let long_name = "a".repeat(33);
let body = format!(r#"{{"username":"{long_name}","password":"test1234"}}"#);
let req = json_post("/login", &body);
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn t4_6_login_password_too_long() {
let (app, _ctx) = test_app().await;
let long_pass = "a".repeat(129);
let body = format!(r#"{{"username":"admin","password":"{long_pass}"}}"#);
let req = json_post("/login", &body);
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
// ===========================================================================
// T5. Logout (POST /logout)
// ===========================================================================
#[tokio::test]
async fn t5_1_logout_success() {
let (mut app, ctx) = test_app().await;
create_test_user(&ctx, "admin", "StrongP@ss1").await;
let (token, _) = login(&mut app, "admin", "StrongP@ss1").await;
let req = json_post_with_token("/logout", "", &token);
let resp = app.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
// Cookie should be cleared
let set_cookie = resp.headers().get(header::SET_COOKIE).unwrap().to_str().unwrap();
assert!(set_cookie.contains("Max-Age=0"));
let json = body_json(resp).await;
assert_eq!(json["success"], true);
assert_eq!(json["message"], "Logged out successfully");
}
#[tokio::test]
async fn t5_2_logout_token_becomes_invalid() {
let (mut app, ctx) = test_app().await;
create_test_user(&ctx, "admin", "StrongP@ss1").await;
let (token, _) = login(&mut app, "admin", "StrongP@ss1").await;
// Logout
let req = json_post_with_token("/logout", "", &token);
let resp = app.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
// Try to use the token
let req = get_with_token("/api/auth/user", &token);
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn t5_3_logout_unauthenticated() {
let (app, _ctx) = test_app().await;
let req = Request::builder()
.method("POST")
.uri("/logout")
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
}
// ===========================================================================
// T6. Auth Status (GET /api/auth/status)
// ===========================================================================
#[tokio::test]
async fn t6_1_status_needs_setup() {
let (app, _ctx) = test_app().await;
let req = get_anonymous("/api/auth/status");
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let json = body_json(resp).await;
assert_eq!(json["success"], true);
assert_eq!(json["needs_setup"], true);
assert_eq!(json["is_authenticated"], false);
}
#[tokio::test]
async fn t6_2_status_has_users() {
let (app, ctx) = test_app().await;
create_test_user(&ctx, "admin", "StrongP@ss1").await;
let req = get_anonymous("/api/auth/status");
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let json = body_json(resp).await;
assert_eq!(json["needs_setup"], false);
}
#[tokio::test]
async fn t6_3_status_authenticated() {
let (mut app, ctx) = test_app().await;
create_test_user(&ctx, "admin", "StrongP@ss1").await;
let (token, _) = login(&mut app, "admin", "StrongP@ss1").await;
let req = get_with_token("/api/auth/status", &token);
let resp = app.oneshot(req).await.unwrap();
let json = body_json(resp).await;
assert_eq!(json["is_authenticated"], true);
}
#[tokio::test]
async fn t6_4_status_unauthenticated() {
let (app, _ctx) = test_app().await;
let req = get_anonymous("/api/auth/status");
let resp = app.oneshot(req).await.unwrap();
let json = body_json(resp).await;
assert_eq!(json["is_authenticated"], false);
}
// ===========================================================================
// T7. Current User (GET /api/auth/user)
// ===========================================================================
#[tokio::test]
async fn t7_1_get_user_success() {
let (mut app, ctx) = test_app().await;
create_test_user(&ctx, "admin", "StrongP@ss1").await;
let (token, _) = login(&mut app, "admin", "StrongP@ss1").await;
let req = get_with_token("/api/auth/user", &token);
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let json = body_json(resp).await;
assert_eq!(json["success"], true);
assert_eq!(json["user"]["username"], "admin");
assert!(json["user"]["id"].is_string());
}
#[tokio::test]
async fn t7_2_get_user_invalid_token() {
let (app, _ctx) = test_app().await;
let req = get_with_token("/api/auth/user", "invalid.jwt.token");
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn t7_3_get_user_no_token() {
let (app, _ctx) = test_app().await;
let req = get_anonymous("/api/auth/user");
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
}
// ===========================================================================
// T8. Change Password (POST /api/auth/change-password)
// ===========================================================================
#[tokio::test]
async fn t8_1_change_password_success() {
let (mut app, ctx) = test_app().await;
create_test_user(&ctx, "admin", "OldP@ssword1").await;
let (token, _) = login(&mut app, "admin", "OldP@ssword1").await;
let req = json_post_with_token(
"/api/auth/change-password",
r#"{"current_password":"OldP@ssword1","new_password":"NewP@ssword2"}"#,
&token,
);
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let json = body_json(resp).await;
assert_eq!(json["success"], true);
assert_eq!(json["message"], "Password changed successfully");
}
#[tokio::test]
async fn t8_2_change_password_old_token_invalidated() {
let (mut app, ctx) = test_app().await;
create_test_user(&ctx, "admin", "OldP@ssword1").await;
let (token, _) = login(&mut app, "admin", "OldP@ssword1").await;
// Change password
let req = json_post_with_token(
"/api/auth/change-password",
r#"{"current_password":"OldP@ssword1","new_password":"NewP@ssword2"}"#,
&token,
);
let resp = app.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
// Old token should be invalid (JWT secret rotated)
let req = get_with_token("/api/auth/user", &token);
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn t8_3_change_password_wrong_current() {
let (mut app, ctx) = test_app().await;
create_test_user(&ctx, "admin", "CorrectP@ss1").await;
let (token, _) = login(&mut app, "admin", "CorrectP@ss1").await;
let req = json_post_with_token(
"/api/auth/change-password",
r#"{"current_password":"WrongP@ss1","new_password":"NewP@ssword2"}"#,
&token,
);
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn t8_4_change_password_new_too_short() {
let (mut app, ctx) = test_app().await;
create_test_user(&ctx, "admin", "OldP@ssword1").await;
let (token, _) = login(&mut app, "admin", "OldP@ssword1").await;
let req = json_post_with_token(
"/api/auth/change-password",
r#"{"current_password":"OldP@ssword1","new_password":"short"}"#,
&token,
);
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn t8_6_change_password_weak() {
let (mut app, ctx) = test_app().await;
create_test_user(&ctx, "admin", "OldP@ssword1").await;
let (token, _) = login(&mut app, "admin", "OldP@ssword1").await;
let req = json_post_with_token(
"/api/auth/change-password",
r#"{"current_password":"OldP@ssword1","new_password":"password"}"#,
&token,
);
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn t8_7_change_password_missing_fields() {
let (mut app, ctx) = test_app().await;
create_test_user(&ctx, "admin", "OldP@ssword1").await;
let (token, _) = login(&mut app, "admin", "OldP@ssword1").await;
// Missing newPassword
let req = json_post_with_token(
"/api/auth/change-password",
r#"{"current_password":"OldP@ssword1"}"#,
&token,
);
let resp = app.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
// Missing currentPassword
let req = json_post_with_token(
"/api/auth/change-password",
r#"{"new_password":"NewP@ssword2"}"#,
&token,
);
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
// ===========================================================================
// T9. Refresh Token (POST /api/auth/refresh)
// ===========================================================================
#[tokio::test]
async fn t9_1_refresh_token_success() {
let (mut app, ctx) = test_app().await;
create_test_user(&ctx, "admin", "StrongP@ss1").await;
let (token, _) = login(&mut app, "admin", "StrongP@ss1").await;
let body = format!(r#"{{"token":"{token}"}}"#);
let req = json_post("/api/auth/refresh", &body);
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let json = body_json(resp).await;
assert_eq!(json["success"], true);
assert!(json["token"].is_string());
// New token should be valid
let new_token = json["token"].as_str().unwrap();
assert!(ctx.jwt_service.verify(new_token).is_ok());
}
#[tokio::test]
async fn t9_2_refresh_invalid_token() {
let (app, _ctx) = test_app().await;
let req = json_post("/api/auth/refresh", r#"{"token":"fake.jwt.token"}"#);
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn t9_3_refresh_missing_token() {
let (app, _ctx) = test_app().await;
let req = json_post("/api/auth/refresh", r#"{}"#);
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
// ===========================================================================
// T10. WebSocket Token (GET /api/ws-token)
// ===========================================================================
#[tokio::test]
async fn t10_1_ws_token_success() {
let (mut app, _ctx) = test_app().await;
create_test_user(&_ctx, "admin", "StrongP@ss1").await;
let (token, _) = login(&mut app, "admin", "StrongP@ss1").await;
let req = get_with_token("/api/ws-token", &token);
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let json = body_json(resp).await;
assert_eq!(json["success"], true);
assert!(json["ws_token"].is_string());
assert!(json["expires_in"].is_number());
// expires_in should be 30 days in milliseconds
let expires_in = json["expires_in"].as_u64().unwrap();
assert_eq!(expires_in, 30 * 24 * 60 * 60 * 1000);
}
#[tokio::test]
async fn t10_2_ws_token_unauthenticated() {
let (app, _ctx) = test_app().await;
let req = get_anonymous("/api/ws-token");
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
}
// ===========================================================================
// T11. QR Login (POST /api/auth/qr-login)
// ===========================================================================
#[tokio::test]
async fn t11_1_qr_login_success() {
let (app, ctx) = test_app().await;
// Set up system user with credentials so login works
let hash = hash_password("syspass123").unwrap();
ctx.user_repo
.set_system_user_credentials("sysadmin", &hash)
.await
.unwrap();
// Generate QR token
let qr_token = ctx.qr_token_store.generate();
let body = format!(r#"{{"qr_token":"{qr_token}"}}"#);
let req = json_post("/api/auth/qr-login", &body);
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
// Check Set-Cookie
let set_cookie = resp.headers().get(header::SET_COOKIE).unwrap().to_str().unwrap();
assert!(set_cookie.contains("nomifun-session="));
let json = body_json(resp).await;
assert_eq!(json["success"], true);
assert!(json["token"].is_string());
assert_eq!(json["user"]["username"], "sysadmin");
}
#[tokio::test]
async fn t11_2_qr_login_invalid_token() {
let (app, _ctx) = test_app().await;
let req = json_post("/api/auth/qr-login", r#"{"qr_token":"nonexistent"}"#);
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn t11_4_qr_login_already_used() {
let (app, ctx) = test_app().await;
let hash = hash_password("syspass123").unwrap();
ctx.user_repo
.set_system_user_credentials("sysadmin", &hash)
.await
.unwrap();
let qr_token = ctx.qr_token_store.generate();
// First use succeeds
let body = format!(r#"{{"qr_token":"{qr_token}"}}"#);
let req = json_post("/api/auth/qr-login", &body);
let resp = app.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
// Second use fails
let req = json_post("/api/auth/qr-login", &body);
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn t11_5_qr_login_missing_token() {
let (app, _ctx) = test_app().await;
let req = json_post("/api/auth/qr-login", r#"{}"#);
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
// ===========================================================================
// QR Login Page (GET /qr-login)
// ===========================================================================
#[tokio::test]
async fn qr_login_page_returns_html() {
let (app, _ctx) = test_app().await;
let req = get_anonymous("/qr-login");
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let content_type = resp.headers().get("content-type").unwrap().to_str().unwrap();
assert!(content_type.contains("text/html"));
}
/// Regression: the served QR-login page must POST the snake_case field that
/// `QrLoginRequest` deserializes (`qr_token`), not camelCase `qrToken`. A
/// mismatch made every phone scan fail with the serde body-rejection
/// "missing field `qr_token`".
#[tokio::test]
async fn qr_login_page_posts_snake_case_qr_token() {
let (app, _ctx) = test_app().await;
let resp = app.oneshot(get_anonymous("/qr-login")).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
let html = String::from_utf8(bytes.to_vec()).unwrap();
assert!(
html.contains("qr_token"),
"QR-login page must POST the snake_case `qr_token` field"
);
assert!(
!html.contains("qrToken"),
"QR-login page must not POST camelCase `qrToken` (serde rejects it)"
);
}
#[tokio::test]
async fn qr_login_page_hands_success_state_to_spa_before_redirecting() {
let (app, _ctx) = test_app().await;
let resp = app.oneshot(get_anonymous("/qr-login")).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
let html = String::from_utf8(bytes.to_vec()).unwrap();
assert!(
html.contains("credentials: 'same-origin'"),
"QR-login POST must explicitly keep same-origin cookies"
);
assert!(
html.contains("nomifun:qr-login-resume"),
"QR-login page must stash the successful user for the SPA auth bridge"
);
assert!(
html.contains("window.location.replace('/#/guid')"),
"QR-login page must enter the conversation landing route explicitly after success"
);
}
#[tokio::test]
async fn qr_login_page_checks_app_shell_before_redirecting() {
let (app, _ctx) = test_app().await;
let resp = app.oneshot(get_anonymous("/qr-login")).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
let html = String::from_utf8(bytes.to_vec()).unwrap();
assert!(
html.contains("verifyAppShellThenRedirect"),
"QR-login page must verify the SPA shell before navigating away"
);
assert!(
html.contains("nomifun_spa_shell_check=1"),
"SPA shell probe should be identifiable in server/client diagnostics"
);
assert!(
html.contains("WebUI app shell is not reachable"),
"QR-login page should report app-shell HTTP failures instead of surfacing a browser error"
);
}
// ===========================================================================
// T12. Local-only internal user routes
// ===========================================================================
#[tokio::test]
async fn t12_1_internal_user_routes_forbidden_outside_local_mode() {
let (app, _ctx) = test_app().await;
let resp = app
.oneshot(get_anonymous("/api/auth/internal/users/system"))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn t12_2_internal_user_routes_work_in_local_mode() {
let (app, ctx) = test_app_with_local(true).await;
create_test_user(&ctx, "admin", "StrongP@ss1").await;
let system_resp = app
.clone()
.oneshot(get_anonymous("/api/auth/internal/users/system"))
.await
.unwrap();
assert_eq!(system_resp.status(), StatusCode::OK);
let system_json = body_json(system_resp).await;
assert_eq!(system_json["data"]["id"], "system_default_user");
let user_resp = app
.clone()
.oneshot(get_anonymous("/api/auth/internal/users/by-username/admin"))
.await
.unwrap();
assert_eq!(user_resp.status(), StatusCode::OK);
let user_json = body_json(user_resp).await;
let user_id = user_json["data"]["id"].as_str().unwrap().to_owned();
let update_resp = app
.clone()
.oneshot(json_post_anonymous(
&format!("/api/auth/internal/users/{user_id}/username"),
r#"{"username":"renamed-admin"}"#,
))
.await
.unwrap();
assert_eq!(update_resp.status(), StatusCode::OK);
let renamed_resp = app
.oneshot(get_anonymous("/api/auth/internal/users/by-username/renamed-admin"))
.await
.unwrap();
assert_eq!(renamed_resp.status(), StatusCode::OK);
let renamed_json = body_json(renamed_resp).await;
assert_eq!(renamed_json["data"]["id"], user_id);
assert_eq!(renamed_json["data"]["username"], "renamed-admin");
}
@@ -0,0 +1,122 @@
//! Black-box validation tests (test-plan T15).
//!
//! Tests password and username validation rules as specified in API Spec 03-auth.md.
use nomifun_auth::{validate_password, validate_username};
// --- T15.1: Username legal ---
#[test]
fn t15_1_valid_username_with_underscore_and_hyphen() {
assert!(validate_username("test_user-1").is_ok());
}
#[test]
fn t15_1_valid_username_alphanumeric() {
assert!(validate_username("admin123").is_ok());
}
#[test]
fn t15_1_valid_username_mixed_case() {
assert!(validate_username("TestUser").is_ok());
}
// --- T15.2: Username too short ---
#[test]
fn t15_2_username_two_chars() {
assert!(validate_username("ab").is_err());
}
#[test]
fn t15_2_username_one_char() {
assert!(validate_username("a").is_err());
}
#[test]
fn t15_2_username_empty() {
assert!(validate_username("").is_err());
}
// --- T15.3: Username too long ---
#[test]
fn t15_3_username_33_chars() {
let name = "a".repeat(33);
assert!(validate_username(&name).is_err());
}
#[test]
fn t15_3_username_100_chars() {
let name = "a".repeat(100);
assert!(validate_username(&name).is_err());
}
// --- T15.4: Username illegal characters ---
#[test]
fn t15_4_username_with_at() {
assert!(validate_username("test@user").is_err());
}
#[test]
fn t15_4_username_with_space() {
assert!(validate_username("test user").is_err());
}
#[test]
fn t15_4_username_with_dot() {
assert!(validate_username("test.user").is_err());
}
#[test]
fn t15_4_username_with_slash() {
assert!(validate_username("test/user").is_err());
}
// --- T15.5: Username starts/ends with special chars ---
#[test]
fn t15_5_starts_with_underscore() {
assert!(validate_username("_test").is_err());
}
#[test]
fn t15_5_starts_with_hyphen() {
assert!(validate_username("-test").is_err());
}
#[test]
fn t15_5_ends_with_underscore() {
assert!(validate_username("test_").is_err());
}
#[test]
fn t15_5_ends_with_hyphen() {
assert!(validate_username("test-").is_err());
}
// --- Password validation (supplement) ---
#[test]
fn valid_password_accepted() {
assert!(validate_password("StrongP@ss123").is_ok());
}
#[test]
fn password_too_short_rejected() {
assert!(validate_password("short").is_err());
}
#[test]
fn password_too_long_rejected() {
let long = "a".repeat(129);
assert!(validate_password(&long).is_err());
}
#[test]
fn weak_password_rejected() {
assert!(validate_password("password").is_err());
assert!(validate_password("12345678").is_err());
assert!(validate_password("qwertyui").is_err());
}