use super::*; use axum::{body::Body, http::Request}; use tower::ServiceExt; async fn app() -> (App, tempfile::TempDir) { let dir = tempfile::tempdir().unwrap(); let app = App { db: database(&format!( "sqlite://{}", dir.path().join("test.db").display() )) .await .unwrap(), key: Arc::new(vec![42; 32]), setup: Arc::new("setup-test".into()), origin: Arc::new("http://localhost:3000".into()), allowed_origins: Arc::new(vec![]), secure: false, peers: Default::default(), limits: Default::default(), hashes: Arc::new(tokio::sync::Semaphore::new(2)), }; let dummy = hash_password(&app, "dummy".into()).await.unwrap(); sqlx::query("INSERT INTO settings(key,value) VALUES('dummy_hash',?)") .bind(dummy) .execute(&app.db) .await .unwrap(); (app, dir) } async fn request(app: &App, method: &str, path: &str, cookie: &str, data: Value) -> Response { let mut req = Request::builder() .method(method) .uri(path) .header("origin", "http://localhost:3000") .header("content-type", "application/json") .header("cookie", cookie) .body(Body::from(data.to_string())) .unwrap(); req.extensions_mut() .insert(ConnectInfo("127.0.0.1:1234".parse::().unwrap())); router(app.clone(), "/nonexistent".into()) .oneshot(req) .await .unwrap() } async fn setup(app: &App) -> String { let response = request( app, "POST", "/api/bootstrap", "", json!({"username":"owner","password":"strong-password123","setup_token":"setup-test"}), ) .await; assert_eq!(response.status(), StatusCode::CREATED); login_as(app, "owner", "strong-password123").await } async fn login_as(app: &App, username: &str, password: &str) -> String { let response = request( app, "POST", "/api/login", "", json!({"username":username,"password":password}), ) .await; assert_eq!(response.status(), StatusCode::OK); let cookie = response.headers()[header::SET_COOKIE].to_str().unwrap(); assert!(cookie.contains("HttpOnly") && cookie.contains("SameSite=Strict")); cookie.split(';').next().unwrap().into() } async fn json_body(response: Response) -> Value { serde_json::from_slice( &axum::body::to_bytes(response.into_body(), 1024 * 1024) .await .unwrap(), ) .unwrap() } #[tokio::test] async fn argon2id_hashes_are_salted_and_verify() { let (app, _dir) = app().await; let a = hash_password(&app, "a long password 123".into()) .await .unwrap(); let b = hash_password(&app, "a long password 123".into()) .await .unwrap(); assert!(a.starts_with("$argon2id$")); assert_ne!(a, b); assert!(verify_password(&app, "a long password 123".into(), a.clone()).await); assert!(!verify_password(&app, "wrong".into(), a).await); assert!(!password_valid("short1")); assert!(!password_valid("longwithoutnumbers")); assert!(password_valid("длинныйпароль123")); } #[tokio::test] async fn bootstrap_is_token_protected_and_one_time() { let (app, _dir) = app().await; assert_eq!( request( &app, "POST", "/api/bootstrap", "", json!({"username":"owner","password":"strong-password123","setup_token":"wrong"}) ) .await .status(), StatusCode::FORBIDDEN ); setup(&app).await; assert_eq!( request( &app, "POST", "/api/bootstrap", "", json!({"username":"second","password":"strong-password123","setup_token":"setup-test"}) ) .await .status(), StatusCode::CONFLICT ); assert_eq!( json_body(request(&app, "GET", "/api/bootstrap", "", json!(null)).await).await["needs_setup"], false ); } #[tokio::test] async fn sessions_expire_on_idle_and_absolute_deadlines_and_reject_tampering() { let (app, _dir) = app().await; let cookie = setup(&app).await; assert_eq!( request( &app, "GET", "/api/session", &format!("{cookie}0"), json!(null) ) .await .status(), StatusCode::UNAUTHORIZED ); sqlx::query("UPDATE sessions SET last_seen=?") .bind(now() - IDLE) .execute(&app.db) .await .unwrap(); assert_eq!( request(&app, "GET", "/api/session", &cookie, json!(null)) .await .status(), StatusCode::UNAUTHORIZED ); let cookie = login_as(&app, "owner", "strong-password123").await; sqlx::query("UPDATE sessions SET created=?") .bind(now() - ABSOLUTE) .execute(&app.db) .await .unwrap(); assert_eq!( request(&app, "GET", "/api/session", &cookie, json!(null)) .await .status(), StatusCode::UNAUTHORIZED ); } #[tokio::test] async fn accounts_enforce_roles_and_reset_revokes_sessions() { let (app, _dir) = app().await; let owner = setup(&app).await; assert_eq!( request( &app, "POST", "/api/accounts", &owner, json!({"username":"viewer","password":"viewer-password123"}) ) .await .status(), StatusCode::CREATED ); assert_eq!( request( &app, "POST", "/api/accounts", &owner, json!({"username":"VIEWER","password":"viewer-password123"}) ) .await .status(), StatusCode::CONFLICT ); let viewer = login_as(&app, "viewer", "viewer-password123").await; assert_eq!( request(&app, "GET", "/api/accounts", &viewer, json!(null)) .await .status(), StatusCode::FORBIDDEN ); assert_eq!( request(&app, "GET", "/api/sessions", &viewer, json!(null)) .await .status(), StatusCode::FORBIDDEN ); assert_eq!( request( &app, "POST", "/api/accounts", &viewer, json!({"username":"intruder","password":"viewer-password123"}) ) .await .status(), StatusCode::FORBIDDEN ); let me = json_body(request(&app, "GET", "/api/session", &viewer, json!(null)).await).await; let uid = me["id"].as_str().unwrap(); assert_eq!( request( &app, "POST", &format!("/api/accounts/{uid}/password"), &owner, json!({"password":"replacement-pass123"}) ) .await .status(), StatusCode::NO_CONTENT ); assert_eq!( request(&app, "GET", "/api/session", &viewer, json!(null)) .await .status(), StatusCode::UNAUTHORIZED ); let viewer = login_as(&app, "viewer", "replacement-pass123").await; assert_eq!( request( &app, "DELETE", &format!("/api/accounts/{uid}"), &owner, json!(null) ) .await .status(), StatusCode::NO_CONTENT ); assert_eq!( request(&app, "GET", "/api/session", &viewer, json!(null)) .await .status(), StatusCode::UNAUTHORIZED ); let owner_me = json_body(request(&app, "GET", "/api/session", &owner, json!(null)).await).await; assert_eq!( request( &app, "DELETE", &format!("/api/accounts/{}", owner_me["id"].as_str().unwrap()), &owner, json!(null) ) .await .status(), StatusCode::BAD_REQUEST ); } #[tokio::test] async fn origins_and_bruteforce_are_rejected() { let (app, _dir) = app().await; let req = Request::builder() .method("POST") .uri("/api/login") .header("origin", "https://evil.example") .body(Body::empty()) .unwrap(); assert_eq!( router(app.clone(), "/none".into()) .oneshot(req) .await .unwrap() .status(), StatusCode::FORBIDDEN ); let req = Request::builder() .uri("/api/signal") .header("origin", "https://evil.example") .body(Body::empty()) .unwrap(); assert_eq!( router(app.clone(), "/none".into()) .oneshot(req) .await .unwrap() .status(), StatusCode::FORBIDDEN ); for _ in 0..10 { assert!(throttle(&app, "test".into()).await.is_ok()); } assert_eq!( throttle(&app, "test".into()).await.unwrap_err().0, StatusCode::TOO_MANY_REQUESTS ); } #[tokio::test] async fn logout_revokes_the_cookie() { let (app, _dir) = app().await; let owner = setup(&app).await; assert_eq!( request(&app, "POST", "/api/logout", &owner, json!(null)) .await .status(), StatusCode::NO_CONTENT ); assert_eq!( request(&app, "GET", "/api/session", &owner, json!(null)) .await .status(), StatusCode::UNAUTHORIZED ); } #[tokio::test] async fn revoking_viewer_closes_transport_and_notifies_host() { let (app, _dir) = app().await; let (host_tx, mut host_rx) = mpsc::channel(16); let (viewer_tx, mut viewer_rx) = mpsc::channel(16); { let mut peers = app.peers.lock().await; peers.host = Some("host".into()); peers.clients.insert( "host".into(), Client { session: "hs".into(), user: "h".into(), name: "h".into(), mode: "host".into(), tx: host_tx, shutdown: tokio::sync::watch::channel(false).0, }, ); peers.clients.insert( "viewer".into(), Client { session: "vs".into(), user: "v".into(), name: "v".into(), mode: "viewer".into(), tx: viewer_tx, shutdown: tokio::sync::watch::channel(false).0, }, ); } disconnect_session(&app, "vs").await; assert!(matches!(viewer_rx.recv().await, Some(Message::Close(_)))); let Some(Message::Text(msg)) = host_rx.recv().await else { panic!("missing leave notification") }; assert_eq!( serde_json::from_str::(&msg).unwrap()["type"], "viewer-left" ); assert!(!app.peers.lock().await.clients.contains_key("viewer")); } #[tokio::test] async fn revoking_host_notifies_viewers_to_stop_media() { let (app, _dir) = app().await; let (host_tx, _host_rx) = mpsc::channel(16); let (viewer_tx, mut viewer_rx) = mpsc::channel(16); { let mut peers = app.peers.lock().await; peers.host = Some("host".into()); peers.since = Some(now()); peers.clients.insert( "host".into(), Client { session: "hs".into(), user: "h".into(), name: "h".into(), mode: "host".into(), tx: host_tx, shutdown: tokio::sync::watch::channel(false).0, }, ); peers.clients.insert( "viewer".into(), Client { session: "vs".into(), user: "v".into(), name: "v".into(), mode: "viewer".into(), tx: viewer_tx, shutdown: tokio::sync::watch::channel(false).0, }, ); } disconnect_session(&app, "hs").await; let Some(Message::Text(msg)) = viewer_rx.recv().await else { panic!("missing offline notification") }; assert_eq!( serde_json::from_str::(&msg).unwrap()["type"], "host-offline" ); assert!(app.peers.lock().await.host.is_none()); } #[tokio::test] async fn websocket_routes_only_authorized_peers_and_closes_on_revocation() { use tokio_tungstenite::{ connect_async, tungstenite::{Message as WsMessage, client::IntoClientRequest}, }; let (app, _dir) = app().await; let host_cookie = setup(&app).await; request( &app, "POST", "/api/accounts", &host_cookie, json!({"username":"viewer","password":"viewer-password123"}), ) .await; let viewer_cookie = login_as(&app, "viewer", "viewer-password123").await; let viewer_me = json_body(request(&app, "GET", "/api/session", &viewer_cookie, json!(null)).await).await; let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>(); let service = router(app.clone(), "/nonexistent".into()) .into_make_service_with_connect_info::(); let server = tokio::spawn(async move { axum::serve(listener, service) .with_graceful_shutdown(async { let _ = shutdown_rx.await; }) .await .unwrap(); }); let ws_request = |mode: &str, cookie: &str| { let mut req = format!("ws://{addr}/api/signal?mode={mode}") .into_client_request() .unwrap(); req.headers_mut() .insert("origin", "http://localhost:3000".parse().unwrap()); req.headers_mut().insert("cookie", cookie.parse().unwrap()); req }; let forbidden = connect_async(ws_request("host", &viewer_cookie)) .await .unwrap_err(); assert!( matches!(forbidden, tokio_tungstenite::tungstenite::Error::Http(ref r) if r.status()==StatusCode::FORBIDDEN) ); let anonymous = connect_async(ws_request("viewer", "")).await.unwrap_err(); assert!( matches!(anonymous, tokio_tungstenite::tungstenite::Error::Http(ref r) if r.status()==StatusCode::UNAUTHORIZED) ); let (mut host, _) = connect_async(ws_request("host", &host_cookie)) .await .unwrap(); let host_welcome = host.next().await.unwrap().unwrap().into_text().unwrap(); let host_id = serde_json::from_str::(&host_welcome).unwrap()["peer"] .as_str() .unwrap() .to_owned(); let (mut viewer, _) = connect_async(ws_request("viewer", &viewer_cookie)) .await .unwrap(); let viewer_welcome = viewer.next().await.unwrap().unwrap().into_text().unwrap(); let viewer_id = serde_json::from_str::(&viewer_welcome).unwrap()["peer"] .as_str() .unwrap() .to_owned(); let joined = host.next().await.unwrap().unwrap().into_text().unwrap(); assert_eq!( serde_json::from_str::(&joined).unwrap()["type"], "viewer-joined" ); host.send(WsMessage::Text(json!({"type":"offer","target":viewer_id,"peer":"forged","data":{"type":"offer","sdp":"test"}}).to_string().into())).await.unwrap(); let offer = viewer.next().await.unwrap().unwrap().into_text().unwrap(); let offer: Value = serde_json::from_str(&offer).unwrap(); assert_eq!(offer["peer"], host_id); assert_eq!(offer["type"], "offer"); viewer .send(WsMessage::Text( json!({"type":"offer","target":host_id,"data":{}}) .to_string() .into(), )) .await .unwrap(); assert!( tokio::time::timeout(std::time::Duration::from_millis(100), host.next()) .await .is_err() ); viewer .send(WsMessage::Text( json!({"type":"answer","target":host_id,"data":{"type":"answer","sdp":"test"}}) .to_string() .into(), )) .await .unwrap(); let answer = host.next().await.unwrap().unwrap().into_text().unwrap(); assert_eq!( serde_json::from_str::(&answer).unwrap()["peer"], viewer_id ); revoke(&app, viewer_me["session_id"].as_str().unwrap()) .await .unwrap(); let closed = tokio::time::timeout(std::time::Duration::from_secs(3), viewer.next()) .await .unwrap(); assert!(matches!(closed, Some(Ok(WsMessage::Close(_))))); let left = host.next().await.unwrap().unwrap().into_text().unwrap(); assert_eq!( serde_json::from_str::(&left).unwrap()["type"], "viewer-left" ); host.close(None).await.unwrap(); drop(viewer); drop(host); let _ = shutdown_tx.send(()); tokio::time::timeout(std::time::Duration::from_secs(3), server) .await .unwrap() .unwrap(); } #[tokio::test] async fn saturated_signaling_queue_fails_closed() { let (tx, _rx) = mpsc::channel(1); let (shutdown, rx) = tokio::sync::watch::channel(false); let client = Client { session: "s".into(), user: "u".into(), name: "u".into(), mode: "host".into(), tx, shutdown, }; send(&client, json!({"type":"offer"})); assert!(!*rx.borrow()); send(&client, json!({"type":"viewer-left"})); assert!(*rx.borrow()); } #[tokio::test] async fn launcher_origins_are_explicit_and_port_specific() { let (mut app, _dir) = app().await; app.allowed_origins = Arc::new(vec![ "http://127.0.0.1:3002".into(), "http://192.168.1.23:3002".into(), ]); for (origin, expected) in [ ("http://127.0.0.1:3002", StatusCode::UNAUTHORIZED), ("http://192.168.1.23:3002", StatusCode::UNAUTHORIZED), ("http://localhost:3000", StatusCode::UNAUTHORIZED), ("http://192.168.1.23:3003", StatusCode::FORBIDDEN), ("http://evil.example", StatusCode::FORBIDDEN), ("null", StatusCode::FORBIDDEN), ] { let req = Request::builder() .method("POST") .uri("/api/logout") .header("origin", origin) .body(Body::empty()) .unwrap(); assert_eq!( router(app.clone(), "/none".into()) .oneshot(req) .await .unwrap() .status(), expected, "{origin}" ); } }