- Multi-arch (amd64/arm64) panel image: static musl binary on scratch, cross-compiled with cargo-zigbuild (no QEMU), non-root, healthcheck - docker-compose.yml + .env.example for one-command deployment - CI: fmt, clippy, tests, web type-checks, Windows launcher build, real-network installs of vanilla/Fabric/Quilt/Forge/NeoForge, Docker build - Release: Windows NSIS installer with baked-in panel URL, GHCR image push and GitHub release (tag push or manual dispatch) - README, admin guide, Microsoft auth setup, architecture, development - rustfmt config and formatting pass Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011ARcGWxLx21FwXJ3yfGriS
311 lines
14 KiB
Rust
311 lines
14 KiB
Rust
//! End-to-end tests against the real router with an in-memory database.
|
|
|
|
use axum::body::Body;
|
|
use axum::http::{Request, StatusCode};
|
|
use scopenet_panel::{app, bootstrap_admin, build_state, config::Config, db};
|
|
use serde_json::{json, Value};
|
|
use std::io::Write;
|
|
use tower::ServiceExt;
|
|
|
|
struct TestApp {
|
|
router: axum::Router,
|
|
_dir: tempfile::TempDir,
|
|
}
|
|
|
|
async fn setup() -> TestApp {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let cfg = Config {
|
|
bind: "127.0.0.1:0".into(),
|
|
data_dir: dir.path().to_path_buf(),
|
|
web_dir: dir.path().join("web"),
|
|
admin_username: "admin".into(),
|
|
admin_password: Some("supersecret".into()),
|
|
jwt_secret: Some("test-secret-test-secret-test-secret".into()),
|
|
curseforge_api_key: None,
|
|
max_upload_mb: 64,
|
|
};
|
|
let pool = db::connect_memory().await.unwrap();
|
|
let state = build_state(cfg, pool).await.unwrap();
|
|
bootstrap_admin(&state).await.unwrap();
|
|
TestApp { router: app(state), _dir: dir }
|
|
}
|
|
|
|
impl TestApp {
|
|
async fn call(&self, method: &str, uri: &str, token: Option<&str>, body: Option<Value>) -> (StatusCode, Value) {
|
|
let mut req = Request::builder().method(method).uri(uri);
|
|
if let Some(t) = token {
|
|
req = req.header("authorization", format!("Bearer {t}"));
|
|
}
|
|
let req = match body {
|
|
Some(b) => req.header("content-type", "application/json").body(Body::from(b.to_string())).unwrap(),
|
|
None => req.body(Body::empty()).unwrap(),
|
|
};
|
|
self.send(req).await
|
|
}
|
|
|
|
async fn send(&self, req: Request<Body>) -> (StatusCode, Value) {
|
|
let resp = self.router.clone().oneshot(req).await.unwrap();
|
|
let status = resp.status();
|
|
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX).await.unwrap();
|
|
(status, serde_json::from_slice(&bytes).unwrap_or(Value::String(String::from_utf8_lossy(&bytes).into())))
|
|
}
|
|
|
|
async fn login(&self, user: &str, pass: &str) -> String {
|
|
let (s, v) = self.call("POST", "/api/v1/auth/login", None, Some(json!({"username": user, "password": pass}))).await;
|
|
assert_eq!(s, StatusCode::OK, "{v}");
|
|
v["token"].as_str().unwrap().to_string()
|
|
}
|
|
}
|
|
|
|
fn multipart(fields: &[(&str, &str)], file: (&str, &[u8])) -> (String, Vec<u8>) {
|
|
let boundary = "----scopenettest";
|
|
let mut body = Vec::new();
|
|
for (k, v) in fields {
|
|
write!(body, "--{boundary}\r\nContent-Disposition: form-data; name=\"{k}\"\r\n\r\n{v}\r\n").unwrap();
|
|
}
|
|
write!(
|
|
body,
|
|
"--{boundary}\r\nContent-Disposition: form-data; name=\"file\"; filename=\"{}\"\r\nContent-Type: application/octet-stream\r\n\r\n",
|
|
file.0
|
|
)
|
|
.unwrap();
|
|
body.extend_from_slice(file.1);
|
|
write!(body, "\r\n--{boundary}--\r\n").unwrap();
|
|
(format!("multipart/form-data; boundary={boundary}"), body)
|
|
}
|
|
|
|
fn zip_bytes(entries: &[(&str, &[u8])]) -> Vec<u8> {
|
|
let mut buf = std::io::Cursor::new(Vec::new());
|
|
{
|
|
let mut z = zip::ZipWriter::new(&mut buf);
|
|
let opts = zip::write::SimpleFileOptions::default();
|
|
for (name, data) in entries {
|
|
z.start_file(*name, opts).unwrap();
|
|
z.write_all(data).unwrap();
|
|
}
|
|
z.finish().unwrap();
|
|
}
|
|
buf.into_inner()
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn health_and_default_manifest() {
|
|
let t = setup().await;
|
|
let (s, v) = t.call("GET", "/healthz", None, None).await;
|
|
assert_eq!(s, StatusCode::OK);
|
|
assert_eq!(v, "ok");
|
|
let (s, v) = t.call("GET", "/api/v1/launcher/manifest", None, None).await;
|
|
assert_eq!(s, StatusCode::OK);
|
|
assert_eq!(v["branding"]["name"], "ScopeNet");
|
|
assert_eq!(v["api_version"], 1);
|
|
assert!(v["user"].is_null());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn admin_only_routes_are_guarded() {
|
|
let t = setup().await;
|
|
let (s, _) = t.call("GET", "/api/admin/users", None, None).await;
|
|
assert_eq!(s, StatusCode::UNAUTHORIZED);
|
|
let (s, _) = t.call("GET", "/api/admin/users", Some("garbage"), None).await;
|
|
assert_eq!(s, StatusCode::UNAUTHORIZED);
|
|
|
|
let admin = t.login("admin", "supersecret").await;
|
|
let (s, v) = t.call("POST", "/api/admin/users", Some(&admin), Some(json!({"username": "Steve", "password": "password123"}))).await;
|
|
assert_eq!(s, StatusCode::OK, "{v}");
|
|
assert_eq!(v["uuid"], scopenet_shared::offline_uuid("Steve"));
|
|
|
|
let player = t.login("steve", "password123").await; // usernames are case-insensitive
|
|
let (s, _) = t.call("GET", "/api/admin/users", Some(&player), None).await;
|
|
assert_eq!(s, StatusCode::FORBIDDEN);
|
|
let (s, v) = t.call("GET", "/api/v1/auth/me", Some(&player), None).await;
|
|
assert_eq!(s, StatusCode::OK);
|
|
assert_eq!(v["username"], "Steve");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn wrong_password_and_lockout() {
|
|
let t = setup().await;
|
|
for _ in 0..10 {
|
|
let (s, _) = t.call("POST", "/api/v1/auth/login", None, Some(json!({"username": "admin", "password": "nope"}))).await;
|
|
assert_eq!(s, StatusCode::UNAUTHORIZED);
|
|
}
|
|
let (s, _) = t.call("POST", "/api/v1/auth/login", None, Some(json!({"username": "admin", "password": "supersecret"}))).await;
|
|
assert_eq!(s, StatusCode::TOO_MANY_REQUESTS);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn registration_modes() {
|
|
let t = setup().await;
|
|
let admin = t.login("admin", "supersecret").await;
|
|
let reg = json!({"username": "Alex", "password": "password123"});
|
|
let (s, _) = t.call("POST", "/api/v1/auth/register", None, Some(reg.clone())).await;
|
|
assert_eq!(s, StatusCode::FORBIDDEN, "closed by default");
|
|
|
|
let (s, v) = t.call("PUT", "/api/admin/settings", Some(&admin), Some(json!({"auth": {"registration": "approval"}}))).await;
|
|
assert_eq!(s, StatusCode::OK, "{v}");
|
|
let (s, v) = t.call("POST", "/api/v1/auth/register", None, Some(reg.clone())).await;
|
|
assert_eq!(s, StatusCode::OK, "{v}");
|
|
assert_eq!(v["pending"], true);
|
|
let (s, _) = t.call("POST", "/api/v1/auth/login", None, Some(json!({"username": "Alex", "password": "password123"}))).await;
|
|
assert_eq!(s, StatusCode::FORBIDDEN, "pending accounts can't sign in");
|
|
|
|
let (s, _) = t.call("POST", "/api/v1/auth/register", None, Some(reg)).await;
|
|
assert_eq!(s, StatusCode::CONFLICT);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn instance_visibility_and_zip_upload() {
|
|
let t = setup().await;
|
|
let admin = t.login("admin", "supersecret").await;
|
|
let (_, _) = t.call("POST", "/api/admin/groups", Some(&admin), Some(json!({"name": "VIP"}))).await;
|
|
t.call("POST", "/api/admin/users", Some(&admin), Some(json!({"username": "Vip1", "password": "password123", "groups": ["VIP"]}))).await;
|
|
t.call("POST", "/api/admin/users", Some(&admin), Some(json!({"username": "Pleb", "password": "password123"}))).await;
|
|
|
|
let (s, v) = t
|
|
.call(
|
|
"POST",
|
|
"/api/admin/instances",
|
|
Some(&admin),
|
|
Some(json!({"name": "VIP Survival!", "mc_version": "1.21.1", "visibility": "groups", "allowed_groups": ["VIP"],
|
|
"server": {"name": "SMP", "address": "play.example.net", "port": 25565, "auto_join": true, "inject": true}})),
|
|
)
|
|
.await;
|
|
assert_eq!(s, StatusCode::OK, "{v}");
|
|
assert_eq!(v["id"], "vip-survival");
|
|
let (_, v) = t.call("POST", "/api/admin/instances", Some(&admin), Some(json!({"name": "Public", "mc_version": "1.20.1"}))).await;
|
|
assert_eq!(v["id"], "public");
|
|
|
|
let names = |v: &Value| v["instances"].as_array().unwrap().iter().map(|i| i["id"].as_str().unwrap().to_string()).collect::<Vec<_>>();
|
|
let (_, anon) = t.call("GET", "/api/v1/launcher/manifest", None, None).await;
|
|
assert_eq!(names(&anon), vec!["public"]);
|
|
let vip = t.login("Vip1", "password123").await;
|
|
let (_, m) = t.call("GET", "/api/v1/launcher/manifest", Some(&vip), None).await;
|
|
assert_eq!(names(&m).len(), 2);
|
|
assert_eq!(m["user"]["groups"][0], "VIP");
|
|
let pleb = t.login("Pleb", "password123").await;
|
|
let (_, m) = t.call("GET", "/api/v1/launcher/manifest", Some(&pleb), None).await;
|
|
assert_eq!(names(&m), vec!["public"]);
|
|
let (s, _) = t.call("GET", "/api/v1/launcher/instances/vip-survival", Some(&pleb), None).await;
|
|
assert_eq!(s, StatusCode::NOT_FOUND);
|
|
|
|
// Plain zip without version info → needs the fallback fields.
|
|
let zip = zip_bytes(&[("MyPack/mods/cool.jar", b"jarjar"), ("MyPack/config/x.toml", b"a=1"), ("MyPack/logs/latest.log", b"junk")]);
|
|
let (ct, body) = multipart(&[], ("pack.zip", &zip));
|
|
let req = Request::post("/api/admin/instances/public/import/upload")
|
|
.header("authorization", format!("Bearer {admin}"))
|
|
.header("content-type", &ct)
|
|
.body(Body::from(body))
|
|
.unwrap();
|
|
let (s, v) = t.send(req).await;
|
|
assert_eq!(s, StatusCode::BAD_REQUEST, "{v}");
|
|
|
|
let (ct, body) = multipart(&[("mc_version", "1.20.1"), ("loader", "vanilla")], ("pack.zip", &zip));
|
|
let req = Request::post("/api/admin/instances/public/import/upload")
|
|
.header("authorization", format!("Bearer {admin}"))
|
|
.header("content-type", &ct)
|
|
.body(Body::from(body))
|
|
.unwrap();
|
|
let (s, v) = t.send(req).await;
|
|
assert_eq!(s, StatusCode::OK, "{v}");
|
|
let paths: Vec<&str> = v["files"].as_array().unwrap().iter().map(|f| f["path"].as_str().unwrap()).collect();
|
|
assert_eq!(paths, vec!["config/x.toml", "mods/cool.jar"]);
|
|
assert_eq!(v["instance"]["revision"], 2);
|
|
|
|
// Launcher sees the files and can download them.
|
|
let (_, im) = t.call("GET", "/api/v1/launcher/instances/public", None, None).await;
|
|
let url = im["files"][1]["url"].as_str().unwrap().to_string();
|
|
assert_eq!(url, "/files/public/mods/cool.jar");
|
|
assert_eq!(im["files"][1]["sha1"], scopenet_core::http::sha1_bytes(b"jarjar"));
|
|
let resp = t.router.clone().oneshot(Request::get(&url).body(Body::empty()).unwrap()).await.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX).await.unwrap();
|
|
assert_eq!(&bytes[..], b"jarjar");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn mrpack_upload_sets_versions_and_overrides() {
|
|
let t = setup().await;
|
|
let admin = t.login("admin", "supersecret").await;
|
|
t.call("POST", "/api/admin/instances", Some(&admin), Some(json!({"name": "Pack", "mc_version": "1.20.1"}))).await;
|
|
let index = json!({
|
|
"formatVersion": 1, "game": "minecraft", "versionId": "6.2.0", "name": "Fabulous",
|
|
"files": [
|
|
{"path": "mods/sodium.jar", "hashes": {"sha1": "aaa", "sha512": "x"}, "downloads": ["https://cdn.modrinth.com/sodium.jar"], "fileSize": 10},
|
|
{"path": "mods/server-only.jar", "hashes": {"sha1": "bbb"}, "env": {"client": "unsupported", "server": "required"}, "downloads": ["https://cdn.modrinth.com/s.jar"], "fileSize": 5},
|
|
{"path": "../escape.jar", "hashes": {"sha1": "ccc"}, "downloads": ["https://x/e.jar"], "fileSize": 1}
|
|
],
|
|
"dependencies": {"minecraft": "1.21.1", "fabric-loader": "0.16.9"}
|
|
});
|
|
let zip = zip_bytes(&[
|
|
("modrinth.index.json", index.to_string().as_bytes()),
|
|
("overrides/options.txt", b"fov:0.5"),
|
|
("overrides/config/a.json", b"{}"),
|
|
("client-overrides/config/a.json", b"{\"client\":1}"),
|
|
]);
|
|
let (ct, body) = multipart(&[], ("fab.mrpack", &zip));
|
|
let req = Request::post("/api/admin/instances/pack/import/upload")
|
|
.header("authorization", format!("Bearer {admin}"))
|
|
.header("content-type", &ct)
|
|
.body(Body::from(body))
|
|
.unwrap();
|
|
let (s, v) = t.send(req).await;
|
|
assert_eq!(s, StatusCode::OK, "{v}");
|
|
assert_eq!(v["instance"]["mc_version"], "1.21.1");
|
|
assert_eq!(v["instance"]["loader"], "fabric");
|
|
assert_eq!(v["instance"]["loader_version"], "0.16.9");
|
|
assert_eq!(v["instance"]["source_kind"], "modrinth");
|
|
assert_eq!(v["instance"]["source_label"], "Fabulous 6.2.0");
|
|
let files = v["files"].as_array().unwrap();
|
|
let paths: Vec<&str> = files.iter().map(|f| f["path"].as_str().unwrap()).collect();
|
|
assert_eq!(paths, vec!["config/a.json", "mods/sodium.jar", "options.txt"]);
|
|
let cfg = files.iter().find(|f| f["path"] == "config/a.json").unwrap();
|
|
assert_eq!(cfg["sha1"], scopenet_core::http::sha1_bytes(b"{\"client\":1}"), "client-overrides win");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn branding_roundtrip_and_media_validation() {
|
|
let t = setup().await;
|
|
let admin = t.login("admin", "supersecret").await;
|
|
let (_, mut b) = t.call("GET", "/api/admin/branding", Some(&admin), None).await;
|
|
b["name"] = json!("Craftopia");
|
|
b["colors"]["accent"] = json!("#ff5500");
|
|
let (s, _) = t.call("PUT", "/api/admin/branding", Some(&admin), Some(b)).await;
|
|
assert_eq!(s, StatusCode::OK);
|
|
let (_, m) = t.call("GET", "/api/v1/launcher/manifest", None, None).await;
|
|
assert_eq!(m["branding"]["name"], "Craftopia");
|
|
assert_eq!(m["branding"]["colors"]["accent"], "#ff5500");
|
|
|
|
let (ct, body) = multipart(&[], ("evil.svg", b"<svg onload=alert(1)>"));
|
|
let req = Request::post("/api/admin/uploads")
|
|
.header("authorization", format!("Bearer {admin}"))
|
|
.header("content-type", &ct)
|
|
.body(Body::from(body))
|
|
.unwrap();
|
|
let (s, _) = t.send(req).await;
|
|
assert_eq!(s, StatusCode::BAD_REQUEST);
|
|
let (ct, body) = multipart(&[], ("logo.png", b"\x89PNG"));
|
|
let req = Request::post("/api/admin/uploads")
|
|
.header("authorization", format!("Bearer {admin}"))
|
|
.header("content-type", &ct)
|
|
.body(Body::from(body))
|
|
.unwrap();
|
|
let (s, v) = t.send(req).await;
|
|
assert_eq!(s, StatusCode::OK);
|
|
assert!(v["url"].as_str().unwrap().starts_with("/uploads/"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn settings_never_leak_curseforge_key() {
|
|
let t = setup().await;
|
|
let admin = t.login("admin", "supersecret").await;
|
|
let (s, v) = t.call("PUT", "/api/admin/settings", Some(&admin), Some(json!({"curseforge_api_key": "secret-key"}))).await;
|
|
assert_eq!(s, StatusCode::OK);
|
|
assert_eq!(v["curseforge_key_set"], true);
|
|
assert!(v["curseforge_api_key"].is_null());
|
|
// Saving again with an empty key keeps it.
|
|
let (_, v) = t.call("PUT", "/api/admin/settings", Some(&admin), Some(json!({"curseforge_api_key": ""}))).await;
|
|
assert_eq!(v["curseforge_key_set"], true);
|
|
let (s, _) = t.call("PUT", "/api/admin/settings", Some(&admin), Some(json!({"auth": {"microsoft": true}}))).await;
|
|
assert_eq!(s, StatusCode::BAD_REQUEST, "MS needs a client id");
|
|
}
|