DUNGEONS-34: implement Webka local streaming app
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
558
backend/src/tests.rs
Normal file
558
backend/src/tests.rs
Normal 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());
|
||||
}
|
||||
Reference in New Issue
Block a user