Skip to main content

kasl_server/
app.rs

1use axum::{
2    Json, Router,
3    extract::{DefaultBodyLimit, State},
4    http::StatusCode,
5    response::{IntoResponse, Response},
6    routing::{delete, get, patch, post, put},
7};
8use serde_json::json;
9use sqlx::PgPool;
10use tower_http::trace::TraceLayer;
11
12use crate::{admin, audit, config::Config, department, ingest, login};
13
14#[derive(Clone)]
15pub struct AppState {
16    pub pool: PgPool,
17    /// Days one batch may carry; enforced by the batch handler.
18    pub max_batch_days: usize,
19    /// Whether session cookies carry `Secure`.
20    pub secure_cookies: bool,
21}
22
23/// Builds the router with the operator's limits applied.
24pub fn router_with(pool: PgPool, config: &Config) -> Router {
25    // `/api/v1` from the very first endpoint: kasl agents update on their own
26    // schedule, so the path a working agent calls must keep meaning what it
27    // meant when that agent shipped (ADR 0001).
28    let api_v1 = Router::new()
29        .route("/days", post(ingest::upload_day))
30        .route("/days/batch", post(ingest::upload_batch))
31        // People, not agents: these carry a session cookie rather than a
32        // bearer token, and the two never mix.
33        .route("/auth/login", post(login::login))
34        .route("/auth/logout", post(login::logout))
35        .route("/auth/logout-everywhere", post(login::logout_everywhere))
36        .route("/auth/me", get(login::me))
37        .route("/auth/password", post(admin::change_own_password))
38        // Administration. Reading the team is a manager's; changing it is not,
39        // until departments give a manager something to be in charge of.
40        .route("/users", get(admin::list_users).post(admin::create_user))
41        .route("/users/{id}", patch(admin::update_user))
42        .route("/users/{id}/agents", get(admin::list_agents).post(admin::create_agent))
43        .route("/agents/{id}", delete(admin::revoke_agent))
44        // Departments: what gives a manager a boundary to be in charge of.
45        .route("/departments", get(department::list).post(department::create))
46        .route("/departments/{id}", patch(department::update).delete(department::delete))
47        .route("/users/{id}/department", put(department::assign))
48        // The record of who did what. Administrators only, and no way to
49        // delete from it (ADR 0010).
50        .route("/audit", get(audit::list));
51
52    Router::new()
53        .route("/health", get(health))
54        .nest("/api/v1", api_v1)
55        .with_state(AppState {
56            pool,
57            max_batch_days: config.max_batch_days,
58            secure_cookies: config.secure_cookies,
59        })
60        // A body larger than this is refused before it is buffered: backfilling
61        // a year and attacking the server look identical up to the size.
62        .layer(DefaultBodyLimit::max(config.max_body_bytes))
63        .layer(TraceLayer::new_for_http())
64}
65
66/// The router with default limits - what the tests and `/health` callers want
67/// when the limits are not what is under test.
68pub fn router(pool: PgPool) -> Router {
69    router_with(pool, &Config::defaults_for_database(String::new()))
70}
71
72/// Liveness + readiness in one place: the process answers, and the database
73/// round-trip tells whether the server can actually do its job.
74async fn health(State(state): State<AppState>) -> Response {
75    match sqlx::query("SELECT 1").execute(&state.pool).await {
76        Ok(_) => (
77            StatusCode::OK,
78            Json(json!({
79                "status": "ok",
80                "version": env!("CARGO_PKG_VERSION"),
81                "database": "ok",
82            })),
83        )
84            .into_response(),
85        Err(error) => {
86            tracing::error!(%error, "health check: database unreachable");
87            (
88                StatusCode::SERVICE_UNAVAILABLE,
89                Json(json!({
90                    "status": "degraded",
91                    "version": env!("CARGO_PKG_VERSION"),
92                    "database": "unavailable",
93                })),
94            )
95                .into_response()
96        }
97    }
98}
99
100#[cfg(test)]
101mod tests {
102    use axum::{body::Body, http::Request};
103    use http_body_util::BodyExt;
104    use sqlx::postgres::PgPoolOptions;
105    use tower::ServiceExt;
106
107    use super::*;
108
109    /// A pool pointing nowhere: `connect_lazy` never dials until a query runs,
110    /// so the router can be exercised without a live database.
111    fn dead_pool() -> PgPool {
112        PgPoolOptions::new()
113            // Keep the failure fast: the default acquire timeout is 30 s.
114            .acquire_timeout(std::time::Duration::from_secs(1))
115            .connect_lazy("postgres://nobody:nowhere@127.0.0.1:1/kasl")
116            .expect("lazy pool creation does not touch the network")
117    }
118
119    #[tokio::test]
120    async fn health_reports_degraded_without_a_database() {
121        let response = router(dead_pool()).oneshot(Request::get("/health").body(Body::empty()).unwrap()).await.unwrap();
122        assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
123
124        let body = response.into_body().collect().await.unwrap().to_bytes();
125        let body: serde_json::Value = serde_json::from_slice(&body).unwrap();
126        assert_eq!(body["status"], "degraded");
127        assert_eq!(body["database"], "unavailable");
128        assert_eq!(body["version"], env!("CARGO_PKG_VERSION"));
129    }
130
131    #[tokio::test]
132    async fn unknown_routes_return_404() {
133        let response = router(dead_pool()).oneshot(Request::get("/nope").body(Body::empty()).unwrap()).await.unwrap();
134        assert_eq!(response.status(), StatusCode::NOT_FOUND);
135    }
136}