add auth system: users, login, JWT, protected routes
Domain: User entity, AuthPort/PasswordHashPort/SecretStore ports. Adapters: auth (argon2 hashing, JWT tokens), secret-store (env-based), config-sqlite user repository, http-api auth routes + extractors. Application: auth_service. SPA: login page, auth client, protected router.
This commit is contained in:
@@ -2,11 +2,32 @@ use application::DataProjection;
|
||||
use axum::body::Body;
|
||||
use axum::http::{Request, StatusCode};
|
||||
use config_memory::MemoryConfigStore;
|
||||
use domain::{AuthPort, PasswordHashPort, UserId};
|
||||
use http_api::{AppState, router};
|
||||
use std::sync::Arc;
|
||||
use tcp_server::{ClientTracker, TcpBroadcaster, TcpEventBus};
|
||||
use tower::ServiceExt;
|
||||
|
||||
struct TestAuth;
|
||||
impl AuthPort for TestAuth {
|
||||
fn generate_token(&self, _user_id: UserId) -> String {
|
||||
"test-token".into()
|
||||
}
|
||||
fn validate_token(&self, token: &str) -> Option<UserId> {
|
||||
if token == "test-token" { Some(1) } else { None }
|
||||
}
|
||||
}
|
||||
|
||||
struct TestHasher;
|
||||
impl PasswordHashPort for TestHasher {
|
||||
async fn hash(&self, _plain: &str) -> Result<String, String> {
|
||||
Ok("hashed".into())
|
||||
}
|
||||
async fn verify(&self, _plain: &str, _hash: &str) -> Result<bool, String> {
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
|
||||
fn test_app() -> axum::Router {
|
||||
let state = AppState {
|
||||
config: Arc::new(MemoryConfigStore::new()),
|
||||
@@ -14,13 +35,29 @@ fn test_app() -> axum::Router {
|
||||
widget_states: Arc::new(DataProjection::new()),
|
||||
broadcaster: Arc::new(TcpBroadcaster::new(16)),
|
||||
clients: Arc::new(ClientTracker::new()),
|
||||
auth: Arc::new(TestAuth),
|
||||
hasher: Arc::new(TestHasher),
|
||||
spa_dir: None,
|
||||
};
|
||||
router(state)
|
||||
}
|
||||
|
||||
fn authed_json_request(method: &str, uri: &str, body: Option<&str>) -> Request<Body> {
|
||||
let builder = Request::builder()
|
||||
.method(method)
|
||||
.uri(uri)
|
||||
.header("content-type", "application/json")
|
||||
.header("authorization", "Bearer test-token");
|
||||
|
||||
if let Some(b) = body {
|
||||
builder.body(Body::from(b.to_string())).unwrap()
|
||||
} else {
|
||||
builder.body(Body::empty()).unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
fn json_request(method: &str, uri: &str, body: Option<&str>) -> Request<Body> {
|
||||
let mut builder = Request::builder()
|
||||
let builder = Request::builder()
|
||||
.method(method)
|
||||
.uri(uri)
|
||||
.header("content-type", "application/json");
|
||||
@@ -32,6 +69,16 @@ fn json_request(method: &str, uri: &str, body: Option<&str>) -> Request<Body> {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unauthenticated_request_returns_401() {
|
||||
let app = test_app();
|
||||
let resp = app
|
||||
.oneshot(json_request("GET", "/api/widgets", None))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_and_get_widget() {
|
||||
let app = test_app();
|
||||
@@ -46,13 +93,13 @@ async fn create_and_get_widget() {
|
||||
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(json_request("POST", "/api/widgets", Some(body)))
|
||||
.oneshot(authed_json_request("POST", "/api/widgets", Some(body)))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::CREATED);
|
||||
|
||||
let resp = app
|
||||
.oneshot(json_request("GET", "/api/widgets/1", None))
|
||||
.oneshot(authed_json_request("GET", "/api/widgets/1", None))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
@@ -62,8 +109,6 @@ async fn create_and_get_widget() {
|
||||
.unwrap();
|
||||
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||
assert_eq!(json["name"], "weather");
|
||||
assert_eq!(json["display_hint"], "icon_value");
|
||||
assert_eq!(json["data_source_id"], 10);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -74,16 +119,16 @@ async fn list_widgets() {
|
||||
let w2 = r#"{"id":2,"name":"b","display_hint":"key_value","data_source_id":2,"mappings":[]}"#;
|
||||
|
||||
app.clone()
|
||||
.oneshot(json_request("POST", "/api/widgets", Some(w1)))
|
||||
.oneshot(authed_json_request("POST", "/api/widgets", Some(w1)))
|
||||
.await
|
||||
.unwrap();
|
||||
app.clone()
|
||||
.oneshot(json_request("POST", "/api/widgets", Some(w2)))
|
||||
.oneshot(authed_json_request("POST", "/api/widgets", Some(w2)))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let resp = app
|
||||
.oneshot(json_request("GET", "/api/widgets", None))
|
||||
.oneshot(authed_json_request("GET", "/api/widgets", None))
|
||||
.await
|
||||
.unwrap();
|
||||
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
||||
@@ -100,19 +145,19 @@ async fn delete_widget() {
|
||||
let body =
|
||||
r#"{"id":1,"name":"a","display_hint":"icon_value","data_source_id":1,"mappings":[]}"#;
|
||||
app.clone()
|
||||
.oneshot(json_request("POST", "/api/widgets", Some(body)))
|
||||
.oneshot(authed_json_request("POST", "/api/widgets", Some(body)))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(json_request("DELETE", "/api/widgets/1", None))
|
||||
.oneshot(authed_json_request("DELETE", "/api/widgets/1", None))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::NO_CONTENT);
|
||||
|
||||
let resp = app
|
||||
.oneshot(json_request("GET", "/api/widgets/1", None))
|
||||
.oneshot(authed_json_request("GET", "/api/widgets/1", None))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||
@@ -134,23 +179,16 @@ async fn create_and_get_data_source() {
|
||||
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(json_request("POST", "/api/data-sources", Some(body)))
|
||||
.oneshot(authed_json_request("POST", "/api/data-sources", Some(body)))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::CREATED);
|
||||
|
||||
let resp = app
|
||||
.oneshot(json_request("GET", "/api/data-sources/10", None))
|
||||
.oneshot(authed_json_request("GET", "/api/data-sources/10", None))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||
assert_eq!(json["name"], "weather_api");
|
||||
assert_eq!(json["poll_interval_secs"], 300);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -172,24 +210,16 @@ async fn update_and_get_layout() {
|
||||
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(json_request("PUT", "/api/layout", Some(body)))
|
||||
.oneshot(authed_json_request("PUT", "/api/layout", Some(body)))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
let resp = app
|
||||
.oneshot(json_request("GET", "/api/layout", None))
|
||||
.oneshot(authed_json_request("GET", "/api/layout", None))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||
assert_eq!(json["root"]["type"], "container");
|
||||
assert_eq!(json["root"]["direction"], "row");
|
||||
assert_eq!(json["root"]["children"].as_array().unwrap().len(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -198,14 +228,23 @@ async fn get_nonexistent_returns_404() {
|
||||
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(json_request("GET", "/api/widgets/99", None))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||
|
||||
let resp = app
|
||||
.oneshot(json_request("GET", "/api/data-sources/99", None))
|
||||
.oneshot(authed_json_request("GET", "/api/widgets/99", None))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn auth_status_returns_needs_setup() {
|
||||
let app = test_app();
|
||||
let resp = app
|
||||
.oneshot(json_request("GET", "/api/auth/status", None))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||
assert_eq!(json["needs_setup"], true);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user