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