DUNGEONS-34: implement Webka local streaming app

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
2026-09-06 16:23:09 +05:00
parent 5540b9a1f4
commit 7c737c1011
36 changed files with 21596 additions and 0 deletions

558
backend/src/tests.rs Normal file
View File

@@ -0,0 +1,558 @@
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()),
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::<SocketAddr>().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::<Value>(&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::<Value>(&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::<SocketAddr>();
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::<Value>(&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::<Value>(&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::<Value>(&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::<Value>(&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::<Value>(&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());
}