Complete private authentication, server integrations and activity reporting

Add Fabric/Forge version builds and Paper integration, preserve permanent player identities across renames, harden session authorization, and surface privacy-conscious launcher/server activity in the panel.
This commit is contained in:
SCOPEDD committed 2026-09-28 13:31:17 -04:00
commit a1f19e86c6
74 files changed
+2108 -95

No files matched your search

+68 -17
View File
@@ -20,8 +20,8 @@ async fn create_server(t: &TestApp, admin: &str, body: Value) -> (i64, String) {
(v["server"]["id"].as_i64().unwrap(), token)
}
fn steve() -> String {
scopenet_shared::offline_uuid("Steve")
async fn steve(t: &TestApp) -> String {
t.uuid("Steve").await
}
#[tokio::test]
@@ -80,7 +80,7 @@ async fn login_rules() {
let (_, open) = create_server(&t, &admin, json!({"name": "Open"})).await;
let v = login(open.clone(), "00000000-0000-0000-0000-000000000001".into(), "Stranger", None).await;
assert_eq!(v["allowed"], true, "{v}");
let v = login(open.clone(), steve().replace('-', ""), "Steve", None).await;
let v = login(open.clone(), steve(&t).await.replace('-', ""), "Steve", None).await;
assert_eq!(v["allowed"], true);
assert_eq!(v["account"]["username"], "Steve");
@@ -94,20 +94,20 @@ async fn login_rules() {
let (s, v) = t.call("POST", "/api/admin/servers", Some(&admin), Some(json!({"name": "Staff", "access": "groups"}))).await;
assert_eq!(s, StatusCode::BAD_REQUEST, "{v}");
let (_, staff) = create_server(&t, &admin, json!({"name": "Staff", "access": "groups", "allowed_groups": ["builders"]})).await;
let v = login(staff.clone(), steve(), "Steve", None).await;
let v = login(staff.clone(), steve(&t).await, "Steve", None).await;
assert_eq!(v["allowed"], false);
let admin_uuid = scopenet_shared::offline_uuid("admin");
let admin_uuid = t.uuid("admin").await;
let v = login(staff.clone(), admin_uuid, "admin", None).await;
assert_eq!(v["allowed"], true, "{v}");
t.call("POST", "/api/admin/groups", Some(&admin), Some(json!({"name": "builders"}))).await;
let (s, v) = t.call("PATCH", &format!("/api/admin/users/{steve_id}"), Some(&admin), Some(json!({"groups": ["builders"]}))).await;
assert_eq!(s, StatusCode::OK, "{v}");
let v = login(staff.clone(), steve(), "Steve", None).await;
let v = login(staff.clone(), steve(&t).await, "Steve", None).await;
assert_eq!(v["allowed"], true, "{v}");
// Require launcher: a launch from the same IP is needed.
let (_, strict) = create_server(&t, &admin, json!({"name": "Strict", "require_launcher": true})).await;
let v = login(strict.clone(), steve(), "Steve", Some("203.0.113.9")).await;
let v = login(strict.clone(), steve(&t).await, "Steve", Some("203.0.113.9")).await;
assert_eq!(v["allowed"], false);
assert!(v["message"].as_str().unwrap().contains("launcher"));
let player = t.login("Steve", "password123").await;
@@ -121,9 +121,9 @@ async fn login_rules() {
.unwrap();
let (s, v) = t.send(req).await;
assert_eq!(s, StatusCode::OK, "{v}");
let v = login(strict.clone(), steve(), "Steve", Some("203.0.113.9")).await;
let v = login(strict.clone(), steve(&t).await, "Steve", Some("203.0.113.9")).await;
assert_eq!(v["allowed"], true, "{v}");
let v = login(strict.clone(), steve(), "Steve", Some("198.51.100.1")).await;
let v = login(strict.clone(), steve(&t).await, "Steve", Some("198.51.100.1")).await;
assert_eq!(v["allowed"], false);
// Disabled accounts see the reason.
@@ -134,7 +134,7 @@ async fn login_rules() {
Some(json!({"status": "disabled", "status_reason": "Griefing"})),
)
.await;
let v = login(open, steve(), "Steve", None).await;
let v = login(open, steve(&t).await, "Steve", None).await;
assert_eq!(v["allowed"], false);
assert!(v["message"].as_str().unwrap().contains("Griefing"));
}
@@ -148,11 +148,11 @@ async fn sync_tracks_players_stats_and_kicks() {
let sync = json!({
"tps": 19.8,
"online": [{"uuid": steve().replace('-', ""), "name": "Steve"}],
"stats": [{"uuid": steve(), "name": "Steve", "playtime_secs": 30, "joins": 1, "blocks_broken": 12, "deaths": 1}],
"online": [{"uuid": steve(&t).await.replace('-', ""), "name": "Steve"}],
"stats": [{"uuid": steve(&t).await, "name": "Steve", "playtime_secs": 30, "joins": 1, "blocks_broken": 12, "deaths": 1}],
"events": [
{"uuid": steve(), "name": "Steve", "kind": "join"},
{"uuid": steve(), "name": "Steve", "kind": "death", "detail": "Steve fell from a high place"}
{"uuid": steve(&t).await, "name": "Steve", "kind": "join"},
{"uuid": steve(&t).await, "name": "Steve", "kind": "death", "detail": "Steve fell from a high place"}
]
});
let (s, v) = t.call("POST", "/api/server/v1/sync", Some(&token), Some(sync)).await;
@@ -164,7 +164,7 @@ async fn sync_tracks_players_stats_and_kicks() {
"POST",
"/api/server/v1/sync",
Some(&token),
Some(json!({"tps": 20.0, "online": [{"uuid": steve(), "name": "Steve"}], "stats": [{"uuid": steve(), "name": "Steve", "playtime_secs": 30, "blocks_broken": 3}]})),
Some(json!({"tps": 20.0, "online": [{"uuid": steve(&t).await, "name": "Steve"}], "stats": [{"uuid": steve(&t).await, "name": "Steve", "playtime_secs": 30, "blocks_broken": 3}]})),
)
.await;
assert_eq!(s, StatusCode::OK);
@@ -201,8 +201,9 @@ async fn sync_tracks_players_stats_and_kicks() {
Some(json!({"status": "disabled", "status_reason": "Cheating"})),
)
.await;
let (_, v) = t.call("POST", "/api/server/v1/sync", Some(&token), Some(json!({"online": [{"uuid": steve(), "name": "Steve"}]}))).await;
assert_eq!(v["kick"][0]["uuid"], steve());
let (_, v) =
t.call("POST", "/api/server/v1/sync", Some(&token), Some(json!({"online": [{"uuid": steve(&t).await, "name": "Steve"}]}))).await;
assert_eq!(v["kick"][0]["uuid"], steve(&t).await);
assert!(v["kick"][0]["message"].as_str().unwrap().contains("Cheating"));
// Leaving empties the online list; deleting the server removes its data.
@@ -214,3 +215,53 @@ async fn sync_tracks_players_stats_and_kicks() {
let (s, _) = t.call("POST", "/api/server/v1/sync", Some(&token), Some(json!({}))).await;
assert_eq!(s, StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn unknown_uuid_cannot_claim_a_members_name_or_skip_launcher_check() {
let t = setup().await;
let (admin, _) = admin_and_player(&t).await;
let (_, token) = create_server(&t, &admin, json!({"name":"Private", "access":"members"})).await;
let (_, verdict) =
t.call("POST", "/api/server/v1/login", Some(&token), Some(json!({"uuid":uuid::Uuid::new_v4(),"name":"Steve"}))).await;
assert_eq!(verdict["allowed"], false);
let (_, strict) = create_server(&t, &admin, json!({"name":"Launcher", "require_launcher":true})).await;
for body in [json!({"uuid":steve(&t).await,"name":"Steve"}), json!({"uuid":uuid::Uuid::new_v4(),"name":"Stranger","ip":"203.0.113.9"})]
{
let (_, verdict) = t.call("POST", "/api/server/v1/login", Some(&strict), Some(body)).await;
assert_eq!(verdict["allowed"], false);
}
}
#[tokio::test]
async fn retry_does_not_duplicate_stats_or_events() {
let t = setup().await;
let (admin, _) = admin_and_player(&t).await;
let (id, token) = create_server(&t, &admin, json!({"name":"Retry"})).await;
let body = json!({"batch_id":uuid::Uuid::new_v4(), "stats":[{"uuid":steve(&t).await,"name":"Steve","playtime_secs":30}],"events":[{"kind":"join","name":"Steve","uuid":steve(&t).await}]});
for _ in 0..2 {
assert_eq!(t.call("POST", "/api/server/v1/sync", Some(&token), Some(body.clone())).await.0, StatusCode::OK);
}
let (_, detail) = t.call("GET", &format!("/api/admin/servers/{id}"), Some(&admin), None).await;
assert_eq!(detail["leaderboard"][0]["playtime_secs"], 30);
assert_eq!(detail["events"].as_array().unwrap().len(), 1);
}
#[tokio::test]
async fn untrusted_forwarded_header_cannot_forge_a_launcher_ip() {
let t = setup().await;
let (admin, _) = admin_and_player(&t).await;
let token = t.login("Steve", "password123").await;
let mut request = Request::post("/api/v1/launcher/events")
.header("authorization", format!("Bearer {token}"))
.header("content-type", "application/json")
.header("x-forwarded-for", "203.0.113.9")
.body(Body::from(json!({"kind":"launch","instance_id":"survival"}).to_string()))
.unwrap();
request.extensions_mut().insert(axum::extract::ConnectInfo("198.51.100.1:12345".parse::<std::net::SocketAddr>().unwrap()));
assert_eq!(t.send(request).await.0, StatusCode::OK);
let (_, strict) = create_server(&t, &admin, json!({"name":"Strict", "require_launcher":true})).await;
let (_, verdict) = t
.call("POST", "/api/server/v1/login", Some(&strict), Some(json!({"uuid":steve(&t).await,"name":"Steve","ip":"203.0.113.9"})))
.await;
assert_eq!(verdict["allowed"], false);
}