Files
Webka/backend/src/tests.rs
2026-09-06 16:47:05 +05:00

593 lines
18 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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::<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());
}
#[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}"
);
}
}