1pub mod auth;
7pub mod error;
8pub mod handlers;
9pub mod sse;
10pub mod state;
11pub mod views;
12
13use axum::middleware;
14use axum::routing::{get, post};
15use axum::Router;
16use tower_http::services::ServeDir;
17
18use state::AppState;
19
20pub fn build_router(state: AppState) -> Router {
22 let public = Router::new()
24 .route("/login", get(auth::login_page))
25 .route("/login", post(auth::login_handler));
26
27 let protected = Router::new()
29 .route("/", get(handlers::overview::overview_handler))
30 .route(
31 "/sessions/{session_id}",
32 get(handlers::session_detail::session_detail_handler),
33 )
34 .route(
35 "/sessions/{session_id}/dag",
36 get(handlers::dag::dag_handler),
37 )
38 .route(
39 "/sessions/{session_id}/energy",
40 get(handlers::energy::energy_handler),
41 )
42 .route(
43 "/sessions/{session_id}/llm",
44 get(handlers::llm::llm_handler),
45 )
46 .route(
47 "/sessions/{session_id}/sandbox",
48 get(handlers::sandbox::sandbox_handler),
49 )
50 .route(
51 "/sessions/{session_id}/decisions",
52 get(handlers::decisions::decisions_handler),
53 )
54 .route("/sse/{session_id}", get(sse::sse_handler))
55 .layer(middleware::from_fn_with_state(
56 state.clone(),
57 auth::auth_middleware,
58 ));
59
60 let static_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("static");
61
62 Router::new()
63 .merge(public)
64 .merge(protected)
65 .nest_service("/static", ServeDir::new(static_dir))
66 .with_state(state)
67}
68
69#[cfg(test)]
70mod tests {
71 use super::*;
72 use axum::body::Body;
73 use axum::http::{Request, StatusCode};
74 use perspt_store::SessionStore;
75 use std::sync::Arc;
76 use tokio::sync::Mutex;
77 use tower::ServiceExt;
78
79 fn test_db_path() -> std::path::PathBuf {
80 std::env::temp_dir().join(format!("perspt_dash_test_{}.db", rand::random::<u64>()))
81 }
82
83 fn test_state_open() -> AppState {
85 let db = test_db_path();
86 let store = SessionStore::open(&db).expect("temp store");
87 AppState {
88 store: Arc::new(store),
89 password: None,
90 session_token: Arc::new(Mutex::new(None)),
91 working_dir: std::path::PathBuf::from("/tmp"),
92 is_localhost: true,
93 }
94 }
95
96 fn test_state_auth(password: &str) -> AppState {
98 let db = test_db_path();
99 let store = SessionStore::open(&db).expect("temp store");
100 AppState {
101 store: Arc::new(store),
102 password: Some(password.to_string()),
103 session_token: Arc::new(Mutex::new(None)),
104 working_dir: std::path::PathBuf::from("/tmp"),
105 is_localhost: true,
106 }
107 }
108
109 #[tokio::test]
112 async fn overview_returns_200() {
113 let app = build_router(test_state_open());
114 let req = Request::builder().uri("/").body(Body::empty()).unwrap();
115 let res = app.oneshot(req).await.unwrap();
116 assert_eq!(res.status(), StatusCode::OK);
117 }
118
119 #[tokio::test]
120 async fn session_detail_returns_200() {
121 let app = build_router(test_state_open());
122 let req = Request::builder()
123 .uri("/sessions/test-session")
124 .body(Body::empty())
125 .unwrap();
126 let res = app.oneshot(req).await.unwrap();
127 assert_eq!(res.status(), StatusCode::OK);
128 }
129
130 #[tokio::test]
131 async fn login_page_returns_200() {
132 let app = build_router(test_state_open());
133 let req = Request::builder()
134 .uri("/login")
135 .body(Body::empty())
136 .unwrap();
137 let res = app.oneshot(req).await.unwrap();
138 assert_eq!(res.status(), StatusCode::OK);
139 }
140
141 #[tokio::test]
142 async fn dag_page_returns_200() {
143 let app = build_router(test_state_open());
144 let req = Request::builder()
145 .uri("/sessions/test-session/dag")
146 .body(Body::empty())
147 .unwrap();
148 let res = app.oneshot(req).await.unwrap();
149 assert_eq!(res.status(), StatusCode::OK);
150 }
151
152 #[tokio::test]
153 async fn energy_page_returns_200() {
154 let app = build_router(test_state_open());
155 let req = Request::builder()
156 .uri("/sessions/test-session/energy")
157 .body(Body::empty())
158 .unwrap();
159 let res = app.oneshot(req).await.unwrap();
160 assert_eq!(res.status(), StatusCode::OK);
161 }
162
163 #[tokio::test]
164 async fn llm_page_returns_200() {
165 let app = build_router(test_state_open());
166 let req = Request::builder()
167 .uri("/sessions/test-session/llm")
168 .body(Body::empty())
169 .unwrap();
170 let res = app.oneshot(req).await.unwrap();
171 assert_eq!(res.status(), StatusCode::OK);
172 }
173
174 #[tokio::test]
175 async fn sandbox_page_returns_200() {
176 let app = build_router(test_state_open());
177 let req = Request::builder()
178 .uri("/sessions/test-session/sandbox")
179 .body(Body::empty())
180 .unwrap();
181 let res = app.oneshot(req).await.unwrap();
182 assert_eq!(res.status(), StatusCode::OK);
183 }
184
185 #[tokio::test]
186 async fn decisions_page_returns_200() {
187 let app = build_router(test_state_open());
188 let req = Request::builder()
189 .uri("/sessions/test-session/decisions")
190 .body(Body::empty())
191 .unwrap();
192 let res = app.oneshot(req).await.unwrap();
193 assert_eq!(res.status(), StatusCode::OK);
194 }
195
196 #[tokio::test]
199 async fn sse_returns_event_stream() {
200 let app = build_router(test_state_open());
201 let req = Request::builder()
202 .uri("/sse/test-session")
203 .body(Body::empty())
204 .unwrap();
205 let res = app.oneshot(req).await.unwrap();
206 assert_eq!(res.status(), StatusCode::OK);
207 let ct = res.headers().get("content-type").unwrap().to_str().unwrap();
208 assert!(ct.contains("text/event-stream"));
209 }
210
211 #[tokio::test]
214 async fn unauth_request_redirects_to_login() {
215 let app = build_router(test_state_auth("secret123"));
216 let req = Request::builder().uri("/").body(Body::empty()).unwrap();
217 let res = app.oneshot(req).await.unwrap();
218 assert_eq!(res.status(), StatusCode::SEE_OTHER);
219 let location = res.headers().get("location").unwrap().to_str().unwrap();
220 assert_eq!(location, "/login");
221 }
222
223 #[tokio::test]
224 async fn invalid_cookie_redirects_to_login() {
225 let app = build_router(test_state_auth("secret123"));
226 let req = Request::builder()
227 .uri("/")
228 .header("cookie", "perspt_session=wrong-token")
229 .body(Body::empty())
230 .unwrap();
231 let res = app.oneshot(req).await.unwrap();
232 assert_eq!(res.status(), StatusCode::SEE_OTHER);
233 }
234
235 #[tokio::test]
236 async fn valid_cookie_passes_auth() {
237 let state = test_state_auth("secret123");
238 *state.session_token.lock().await = Some("valid-token-123".to_string());
240
241 let app = build_router(state);
242 let req = Request::builder()
243 .uri("/")
244 .header("cookie", "perspt_session=valid-token-123")
245 .body(Body::empty())
246 .unwrap();
247 let res = app.oneshot(req).await.unwrap();
248 assert_eq!(res.status(), StatusCode::OK);
249 }
250
251 #[tokio::test]
252 async fn sse_behind_auth() {
253 let app = build_router(test_state_auth("secret123"));
254 let req = Request::builder()
255 .uri("/sse/test-session")
256 .body(Body::empty())
257 .unwrap();
258 let res = app.oneshot(req).await.unwrap();
259 assert_eq!(res.status(), StatusCode::SEE_OTHER);
261 }
262}