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:
2026-06-19 01:39:42 +02:00
parent 4139330234
commit adda731dc6
41 changed files with 1331 additions and 153 deletions

View File

@@ -0,0 +1,42 @@
use axum::{
extract::FromRequestParts,
http::{StatusCode, request::Parts},
};
use domain::{AuthPort, UserId};
pub struct AuthUser(pub UserId);
impl<C, E, W, B, R, A, H> FromRequestParts<crate::AppState<C, E, W, B, R, A, H>> for AuthUser
where
A: AuthPort + Send + Sync + 'static,
C: Send + Sync + 'static,
E: Send + Sync + 'static,
W: Send + Sync + 'static,
B: Send + Sync + 'static,
R: Send + Sync + 'static,
H: Send + Sync + 'static,
{
type Rejection = StatusCode;
async fn from_request_parts(
parts: &mut Parts,
state: &crate::AppState<C, E, W, B, R, A, H>,
) -> Result<Self, Self::Rejection> {
let header = parts
.headers
.get("authorization")
.and_then(|v| v.to_str().ok())
.ok_or(StatusCode::UNAUTHORIZED)?;
let token = header
.strip_prefix("Bearer ")
.ok_or(StatusCode::UNAUTHORIZED)?;
let user_id = state
.auth
.validate_token(token)
.ok_or(StatusCode::UNAUTHORIZED)?;
Ok(AuthUser(user_id))
}
}

View File

@@ -1,21 +1,27 @@
pub mod extractors;
mod routes;
use axum::Router;
use domain::{BroadcastPort, ClientRegistry, ConfigRepository, EventPublisher, WidgetStateReader};
use domain::{
AuthPort, BroadcastPort, ClientRegistry, ConfigRepository, EventPublisher, PasswordHashPort,
WidgetStateReader,
};
use std::sync::Arc;
use tower_http::cors::CorsLayer;
use tower_http::services::{ServeDir, ServeFile};
pub struct AppState<C, E, W, B, R> {
pub struct AppState<C, E, W, B, R, A, H> {
pub config: Arc<C>,
pub events: Arc<E>,
pub widget_states: Arc<W>,
pub broadcaster: Arc<B>,
pub clients: Arc<R>,
pub auth: Arc<A>,
pub hasher: Arc<H>,
pub spa_dir: Option<String>,
}
impl<C, E, W, B, R> Clone for AppState<C, E, W, B, R> {
impl<C, E, W, B, R, A, H> Clone for AppState<C, E, W, B, R, A, H> {
fn clone(&self) -> Self {
Self {
config: self.config.clone(),
@@ -23,12 +29,14 @@ impl<C, E, W, B, R> Clone for AppState<C, E, W, B, R> {
widget_states: self.widget_states.clone(),
broadcaster: self.broadcaster.clone(),
clients: self.clients.clone(),
auth: self.auth.clone(),
hasher: self.hasher.clone(),
spa_dir: self.spa_dir.clone(),
}
}
}
pub fn router<C, E, W, B, R>(state: AppState<C, E, W, B, R>) -> Router
pub fn router<C, E, W, B, R, A, H>(state: AppState<C, E, W, B, R, A, H>) -> Router
where
C: ConfigRepository + Send + Sync + 'static,
C::Error: std::fmt::Debug + Send,
@@ -38,6 +46,8 @@ where
B: BroadcastPort + Send + Sync + 'static,
B::Error: std::fmt::Debug + Send,
R: ClientRegistry + Send + Sync + 'static,
A: AuthPort + Send + Sync + 'static,
H: PasswordHashPort + Send + Sync + 'static,
{
let spa_dir = state.spa_dir.clone();
@@ -54,9 +64,9 @@ where
}
}
pub async fn serve<C, E, W, B, R>(
pub async fn serve<C, E, W, B, R, A, H>(
addr: &str,
state: AppState<C, E, W, B, R>,
state: AppState<C, E, W, B, R, A, H>,
) -> Result<(), std::io::Error>
where
C: ConfigRepository + Send + Sync + 'static,
@@ -67,6 +77,8 @@ where
B: BroadcastPort + Send + Sync + 'static,
B::Error: std::fmt::Debug + Send,
R: ClientRegistry + Send + Sync + 'static,
A: AuthPort + Send + Sync + 'static,
H: PasswordHashPort + Send + Sync + 'static,
{
let app = router(state);
let listener = tokio::net::TcpListener::bind(addr).await?;

View File

@@ -0,0 +1,85 @@
use crate::AppState;
use axum::extract::State;
use axum::http::StatusCode;
use axum::response::Json;
use domain::{AuthPort, ConfigRepository, PasswordHashPort};
use serde::{Deserialize, Serialize};
type S<C, E, W, B, R, A, H> = State<AppState<C, E, W, B, R, A, H>>;
#[derive(Deserialize)]
pub struct LoginRequest {
pub username: String,
pub password: String,
}
#[derive(Serialize)]
pub struct LoginResponse {
pub token: String,
}
#[derive(Serialize)]
pub struct StatusResponse {
pub needs_setup: bool,
}
pub async fn login<C, E, W, B, R, A, H>(
State(state): S<C, E, W, B, R, A, H>,
Json(body): Json<LoginRequest>,
) -> Result<Json<LoginResponse>, (StatusCode, String)>
where
C: ConfigRepository,
C::Error: std::fmt::Debug,
A: AuthPort,
H: PasswordHashPort,
{
let token = application::auth_service::login(
state.config.as_ref(),
state.auth.as_ref(),
state.hasher.as_ref(),
&body.username,
&body.password,
)
.await
.map_err(|e| (StatusCode::UNAUTHORIZED, e.to_string()))?;
Ok(Json(LoginResponse { token }))
}
pub async fn register<C, E, W, B, R, A, H>(
State(state): S<C, E, W, B, R, A, H>,
Json(body): Json<LoginRequest>,
) -> Result<StatusCode, (StatusCode, String)>
where
C: ConfigRepository,
C::Error: std::fmt::Debug,
H: PasswordHashPort,
{
application::auth_service::register(
state.config.as_ref(),
state.hasher.as_ref(),
&body.username,
&body.password,
)
.await
.map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?;
Ok(StatusCode::CREATED)
}
pub async fn auth_status<C, E, W, B, R, A, H>(
State(state): S<C, E, W, B, R, A, H>,
) -> Result<Json<StatusResponse>, StatusCode>
where
C: ConfigRepository,
C::Error: std::fmt::Debug,
{
let count = state
.config
.count_users()
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(Json(StatusResponse {
needs_setup: count == 0,
}))
}

View File

@@ -1,12 +1,16 @@
use crate::AppState;
use crate::extractors::AuthUser;
use api_types::ClientDto;
use axum::extract::State;
use axum::response::Json;
use domain::{ClientRegistry, ConfigRepository, EventPublisher};
type S<C, E, W, B, R> = State<AppState<C, E, W, B, R>>;
type S<C, E, W, B, R, A, H> = State<AppState<C, E, W, B, R, A, H>>;
pub async fn list_clients<C, E, W, B, R>(State(state): S<C, E, W, B, R>) -> Json<Vec<ClientDto>>
pub async fn list_clients<C, E, W, B, R, A, H>(
_auth: AuthUser,
State(state): S<C, E, W, B, R, A, H>,
) -> Json<Vec<ClientDto>>
where
C: ConfigRepository,
C::Error: std::fmt::Debug,

View File

@@ -1,4 +1,5 @@
use crate::AppState;
use crate::extractors::AuthUser;
use api_types::DataSourceDto;
use application::ConfigService;
use axum::{
@@ -8,10 +9,11 @@ use axum::{
};
use domain::{ConfigRepository, EventPublisher};
type S<C, E, W, B, R> = State<AppState<C, E, W, B, R>>;
type S<C, E, W, B, R, A, H> = State<AppState<C, E, W, B, R, A, H>>;
pub async fn list_data_sources<C, E, W, B, R>(
State(state): S<C, E, W, B, R>,
pub async fn list_data_sources<C, E, W, B, R, A, H>(
_auth: AuthUser,
State(state): S<C, E, W, B, R, A, H>,
) -> Result<Json<Vec<DataSourceDto>>, StatusCode>
where
C: ConfigRepository,
@@ -27,8 +29,9 @@ where
Ok(Json(sources.iter().map(DataSourceDto::from).collect()))
}
pub async fn get_data_source<C, E, W, B, R>(
State(state): S<C, E, W, B, R>,
pub async fn get_data_source<C, E, W, B, R, A, H>(
_auth: AuthUser,
State(state): S<C, E, W, B, R, A, H>,
Path(id): Path<u16>,
) -> Result<Json<DataSourceDto>, StatusCode>
where
@@ -48,8 +51,9 @@ where
}
}
pub async fn create_data_source<C, E, W, B, R>(
State(state): S<C, E, W, B, R>,
pub async fn create_data_source<C, E, W, B, R, A, H>(
_auth: AuthUser,
State(state): S<C, E, W, B, R, A, H>,
Json(body): Json<DataSourceDto>,
) -> Result<StatusCode, (StatusCode, String)>
where
@@ -68,8 +72,9 @@ where
Ok(StatusCode::CREATED)
}
pub async fn update_data_source<C, E, W, B, R>(
State(state): S<C, E, W, B, R>,
pub async fn update_data_source<C, E, W, B, R, A, H>(
_auth: AuthUser,
State(state): S<C, E, W, B, R, A, H>,
Path(_id): Path<u16>,
Json(body): Json<DataSourceDto>,
) -> Result<StatusCode, (StatusCode, String)>
@@ -89,8 +94,9 @@ where
Ok(StatusCode::OK)
}
pub async fn delete_data_source<C, E, W, B, R>(
State(state): S<C, E, W, B, R>,
pub async fn delete_data_source<C, E, W, B, R, A, H>(
_auth: AuthUser,
State(state): S<C, E, W, B, R, A, H>,
Path(id): Path<u16>,
) -> Result<StatusCode, StatusCode>
where

View File

@@ -1,13 +1,15 @@
use crate::AppState;
use crate::extractors::AuthUser;
use api_types::LayoutDto;
use application::ConfigService;
use axum::{extract::State, http::StatusCode, response::Json};
use domain::{ConfigRepository, EventPublisher};
type S<C, E, W, B, R> = State<AppState<C, E, W, B, R>>;
type S<C, E, W, B, R, A, H> = State<AppState<C, E, W, B, R, A, H>>;
pub async fn get_layout<C, E, W, B, R>(
State(state): S<C, E, W, B, R>,
pub async fn get_layout<C, E, W, B, R, A, H>(
_auth: AuthUser,
State(state): S<C, E, W, B, R, A, H>,
) -> Result<Json<Option<LayoutDto>>, StatusCode>
where
C: ConfigRepository,
@@ -23,8 +25,9 @@ where
Ok(Json(layout.as_ref().map(LayoutDto::from)))
}
pub async fn update_layout<C, E, W, B, R>(
State(state): S<C, E, W, B, R>,
pub async fn update_layout<C, E, W, B, R, A, H>(
_auth: AuthUser,
State(state): S<C, E, W, B, R, A, H>,
Json(body): Json<LayoutDto>,
) -> Result<StatusCode, (StatusCode, String)>
where

View File

@@ -1,3 +1,4 @@
mod auth;
mod clients;
mod data_sources;
mod layout;
@@ -8,9 +9,12 @@ mod widgets;
use crate::AppState;
use axum::Router;
use axum::routing::{get, post};
use domain::{BroadcastPort, ClientRegistry, ConfigRepository, EventPublisher, WidgetStateReader};
use domain::{
AuthPort, BroadcastPort, ClientRegistry, ConfigRepository, EventPublisher, PasswordHashPort,
WidgetStateReader,
};
pub fn api_routes<C, E, W, B, R>() -> Router<AppState<C, E, W, B, R>>
pub fn api_routes<C, E, W, B, R, A, H>() -> Router<AppState<C, E, W, B, R, A, H>>
where
C: ConfigRepository + Send + Sync + 'static,
C::Error: std::fmt::Debug + Send,
@@ -20,55 +24,72 @@ where
B: BroadcastPort + Send + Sync + 'static,
B::Error: std::fmt::Debug + Send,
R: ClientRegistry + Send + Sync + 'static,
A: AuthPort + Send + Sync + 'static,
H: PasswordHashPort + Send + Sync + 'static,
{
Router::new()
// Public auth routes
.route(
"/auth/status",
get(auth::auth_status::<C, E, W, B, R, A, H>),
)
.route("/auth/login", post(auth::login::<C, E, W, B, R, A, H>))
.route(
"/auth/register",
post(auth::register::<C, E, W, B, R, A, H>),
)
// Protected routes
.route(
"/widgets",
get(widgets::list_widgets::<C, E, W, B, R>)
.post(widgets::create_widget::<C, E, W, B, R>),
get(widgets::list_widgets::<C, E, W, B, R, A, H>)
.post(widgets::create_widget::<C, E, W, B, R, A, H>),
)
.route(
"/widgets/{id}",
get(widgets::get_widget::<C, E, W, B, R>)
.put(widgets::update_widget::<C, E, W, B, R>)
.delete(widgets::delete_widget::<C, E, W, B, R>),
get(widgets::get_widget::<C, E, W, B, R, A, H>)
.put(widgets::update_widget::<C, E, W, B, R, A, H>)
.delete(widgets::delete_widget::<C, E, W, B, R, A, H>),
)
.route(
"/widgets/{id}/preview",
get(widgets::preview_widget::<C, E, W, B, R>),
get(widgets::preview_widget::<C, E, W, B, R, A, H>),
)
.route(
"/data-sources",
get(data_sources::list_data_sources::<C, E, W, B, R>)
.post(data_sources::create_data_source::<C, E, W, B, R>),
get(data_sources::list_data_sources::<C, E, W, B, R, A, H>)
.post(data_sources::create_data_source::<C, E, W, B, R, A, H>),
)
.route(
"/data-sources/{id}",
get(data_sources::get_data_source::<C, E, W, B, R>)
.put(data_sources::update_data_source::<C, E, W, B, R>)
.delete(data_sources::delete_data_source::<C, E, W, B, R>),
get(data_sources::get_data_source::<C, E, W, B, R, A, H>)
.put(data_sources::update_data_source::<C, E, W, B, R, A, H>)
.delete(data_sources::delete_data_source::<C, E, W, B, R, A, H>),
)
.route(
"/layout",
get(layout::get_layout::<C, E, W, B, R>).put(layout::update_layout::<C, E, W, B, R>),
get(layout::get_layout::<C, E, W, B, R, A, H>)
.put(layout::update_layout::<C, E, W, B, R, A, H>),
)
.route(
"/presets",
get(presets::list_presets::<C, E, W, B, R>)
.post(presets::create_preset::<C, E, W, B, R>),
get(presets::list_presets::<C, E, W, B, R, A, H>)
.post(presets::create_preset::<C, E, W, B, R, A, H>),
)
.route(
"/presets/{id}",
get(presets::get_preset::<C, E, W, B, R>)
.delete(presets::delete_preset::<C, E, W, B, R>),
get(presets::get_preset::<C, E, W, B, R, A, H>)
.delete(presets::delete_preset::<C, E, W, B, R, A, H>),
)
.route(
"/presets/{id}/load",
post(presets::load_preset::<C, E, W, B, R>),
post(presets::load_preset::<C, E, W, B, R, A, H>),
)
.route(
"/clients",
get(clients::list_clients::<C, E, W, B, R, A, H>),
)
.route("/clients", get(clients::list_clients::<C, E, W, B, R>))
.route(
"/webhook/{source_id}",
post(webhook::receive_webhook::<C, E, W, B, R>),
post(webhook::receive_webhook::<C, E, W, B, R, A, H>),
)
}

View File

@@ -1,4 +1,5 @@
use crate::AppState;
use crate::extractors::AuthUser;
use api_types::{CreatePresetDto, PresetDto};
use application::ConfigService;
use axum::{
@@ -8,10 +9,11 @@ use axum::{
};
use domain::{ConfigRepository, EventPublisher};
type S<C, E, W, B, R> = State<AppState<C, E, W, B, R>>;
type S<C, E, W, B, R, A, H> = State<AppState<C, E, W, B, R, A, H>>;
pub async fn list_presets<C, E, W, B, R>(
State(state): S<C, E, W, B, R>,
pub async fn list_presets<C, E, W, B, R, A, H>(
_auth: AuthUser,
State(state): S<C, E, W, B, R, A, H>,
) -> Result<Json<Vec<PresetDto>>, StatusCode>
where
C: ConfigRepository,
@@ -27,8 +29,9 @@ where
Ok(Json(presets.iter().map(PresetDto::from).collect()))
}
pub async fn get_preset<C, E, W, B, R>(
State(state): S<C, E, W, B, R>,
pub async fn get_preset<C, E, W, B, R, A, H>(
_auth: AuthUser,
State(state): S<C, E, W, B, R, A, H>,
Path(id): Path<u16>,
) -> Result<Json<PresetDto>, StatusCode>
where
@@ -48,8 +51,9 @@ where
}
}
pub async fn create_preset<C, E, W, B, R>(
State(state): S<C, E, W, B, R>,
pub async fn create_preset<C, E, W, B, R, A, H>(
_auth: AuthUser,
State(state): S<C, E, W, B, R, A, H>,
Json(body): Json<CreatePresetDto>,
) -> Result<StatusCode, (StatusCode, String)>
where
@@ -68,8 +72,9 @@ where
Ok(StatusCode::CREATED)
}
pub async fn delete_preset<C, E, W, B, R>(
State(state): S<C, E, W, B, R>,
pub async fn delete_preset<C, E, W, B, R, A, H>(
_auth: AuthUser,
State(state): S<C, E, W, B, R, A, H>,
Path(id): Path<u16>,
) -> Result<StatusCode, StatusCode>
where
@@ -85,8 +90,9 @@ where
Ok(StatusCode::NO_CONTENT)
}
pub async fn load_preset<C, E, W, B, R>(
State(state): S<C, E, W, B, R>,
pub async fn load_preset<C, E, W, B, R, A, H>(
_auth: AuthUser,
State(state): S<C, E, W, B, R, A, H>,
Path(id): Path<u16>,
) -> Result<StatusCode, (StatusCode, String)>
where

View File

@@ -4,10 +4,10 @@ use axum::http::StatusCode;
use axum::response::Json;
use domain::{BroadcastPort, ConfigRepository, EventPublisher, WidgetStateReader};
type S<C, E, W, B, R> = State<AppState<C, E, W, B, R>>;
type S<C, E, W, B, R, A, H> = State<AppState<C, E, W, B, R, A, H>>;
pub async fn receive_webhook<C, E, W, B, R>(
State(state): S<C, E, W, B, R>,
pub async fn receive_webhook<C, E, W, B, R, A, H>(
State(state): S<C, E, W, B, R, A, H>,
Path(source_id): Path<u16>,
Json(body): Json<serde_json::Value>,
) -> Result<StatusCode, (StatusCode, String)>

View File

@@ -1,4 +1,5 @@
use crate::AppState;
use crate::extractors::AuthUser;
use api_types::{CreateWidgetDto, WidgetDto};
use application::ConfigService;
use axum::{
@@ -8,10 +9,11 @@ use axum::{
};
use domain::{ConfigRepository, EventPublisher, WidgetStateReader};
type S<C, E, W, B, R> = State<AppState<C, E, W, B, R>>;
type S<C, E, W, B, R, A, H> = State<AppState<C, E, W, B, R, A, H>>;
pub async fn list_widgets<C, E, W, B, R>(
State(state): S<C, E, W, B, R>,
pub async fn list_widgets<C, E, W, B, R, A, H>(
_auth: AuthUser,
State(state): S<C, E, W, B, R, A, H>,
) -> Result<Json<Vec<WidgetDto>>, StatusCode>
where
C: ConfigRepository,
@@ -27,8 +29,9 @@ where
Ok(Json(widgets.iter().map(WidgetDto::from).collect()))
}
pub async fn get_widget<C, E, W, B, R>(
State(state): S<C, E, W, B, R>,
pub async fn get_widget<C, E, W, B, R, A, H>(
_auth: AuthUser,
State(state): S<C, E, W, B, R, A, H>,
Path(id): Path<u16>,
) -> Result<Json<WidgetDto>, StatusCode>
where
@@ -48,8 +51,9 @@ where
}
}
pub async fn create_widget<C, E, W, B, R>(
State(state): S<C, E, W, B, R>,
pub async fn create_widget<C, E, W, B, R, A, H>(
_auth: AuthUser,
State(state): S<C, E, W, B, R, A, H>,
Json(body): Json<CreateWidgetDto>,
) -> Result<StatusCode, (StatusCode, String)>
where
@@ -68,8 +72,9 @@ where
Ok(StatusCode::CREATED)
}
pub async fn update_widget<C, E, W, B, R>(
State(state): S<C, E, W, B, R>,
pub async fn update_widget<C, E, W, B, R, A, H>(
_auth: AuthUser,
State(state): S<C, E, W, B, R, A, H>,
Path(_id): Path<u16>,
Json(body): Json<CreateWidgetDto>,
) -> Result<StatusCode, (StatusCode, String)>
@@ -89,8 +94,9 @@ where
Ok(StatusCode::OK)
}
pub async fn delete_widget<C, E, W, B, R>(
State(state): S<C, E, W, B, R>,
pub async fn delete_widget<C, E, W, B, R, A, H>(
_auth: AuthUser,
State(state): S<C, E, W, B, R, A, H>,
Path(id): Path<u16>,
) -> Result<StatusCode, StatusCode>
where
@@ -106,8 +112,9 @@ where
Ok(StatusCode::NO_CONTENT)
}
pub async fn preview_widget<C, E, W, B, R>(
State(state): S<C, E, W, B, R>,
pub async fn preview_widget<C, E, W, B, R, A, H>(
_auth: AuthUser,
State(state): S<C, E, W, B, R, A, H>,
Path(id): Path<u16>,
) -> Result<Json<serde_json::Value>, StatusCode>
where

View File

@@ -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);
}