f7a720204a
- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用 - 添加所有子项目的完整源代码 - 保留原始 .git 为 .git.bak 备份
833 lines
27 KiB
Rust
833 lines
27 KiB
Rust
//! 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");
|
|
}
|