593 lines
18 KiB
Rust
593 lines
18 KiB
Rust
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}"
|
||
);
|
||
}
|
||
}
|