use axum::{extract::State, http::StatusCode, Json};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use crate::api::server::AppState;
#[derive(Debug, Deserialize)]
pub struct LoginRequest {
pub password: String,
}
#[derive(Debug, Serialize)]
pub struct LoginResponse {
pub token: String,
}
pub async fn login(
State(state): State<Arc<AppState>>,
Json(body): Json<LoginRequest>,
) -> Result<Json<LoginResponse>, StatusCode> {
match &state.password_hash {
Some(hash) => {
let ok = crate::api::auth::verify_password(&body.password, hash)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
if ok {
let token = crate::api::auth::generate_jwt("admin", &state.jwt_secret, 86_400)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(Json(LoginResponse { token }))
} else {
Err(StatusCode::UNAUTHORIZED)
}
}
None => Err(StatusCode::NOT_FOUND),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::api::{auth as panel_auth, events::EventBus};
use axum::{body::Body, http::Request, routing::post, Router};
use std::sync::Arc;
use tower::util::ServiceExt;
fn make_state_with_password(password: &str) -> Arc<AppState> {
let hash = panel_auth::hash_password(password).expect("hash must succeed");
let bus = EventBus::new(8);
let mut state = AppState::new("tok".into(), bus);
state.password_hash = Some(hash);
Arc::new(state)
}
fn make_state_no_password() -> Arc<AppState> {
let bus = EventBus::new(8);
Arc::new(AppState::new("tok".into(), bus))
}
fn make_app(state: Arc<AppState>) -> Router {
Router::new()
.route("/api/auth/login", post(login))
.with_state(state)
}
#[tokio::test]
async fn test_login_correct_password_returns_token() {
let state = make_state_with_password("hunter2");
let app = make_app(state.clone());
let body = serde_json::json!({ "password": "hunter2" }).to_string();
let req = Request::builder()
.method("POST")
.uri("/api/auth/login")
.header("content-type", "application/json")
.body(Body::from(body))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert!(json["token"].as_str().is_some(), "token must be present");
let token = json["token"].as_str().unwrap();
let claims =
panel_auth::validate_jwt(token, &state.jwt_secret).expect("returned JWT must be valid");
assert_eq!(claims.sub, "admin");
}
#[tokio::test]
async fn test_login_wrong_password_returns_401() {
let state = make_state_with_password("hunter2");
let app = make_app(state);
let body = serde_json::json!({ "password": "wrong" }).to_string();
let req = Request::builder()
.method("POST")
.uri("/api/auth/login")
.header("content-type", "application/json")
.body(Body::from(body))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn test_login_no_password_configured_returns_404() {
let state = make_state_no_password();
let app = make_app(state);
let body = serde_json::json!({ "password": "anything" }).to_string();
let req = Request::builder()
.method("POST")
.uri("/api/auth/login")
.header("content-type", "application/json")
.body(Body::from(body))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
}