Panel: Yggdrasil auth server (authlib-injector), skins and capes
- Yggdrasil API per the authlib-injector spec: metadata with signing key and skin domains, authenticate/refresh/validate/invalidate/signout, join/hasJoined, profile lookup, texture upload, and the minecraftservices endpoints (chat certificates, publickeys, attributes, blocklist) - 4096-bit signing key generated once into the data volume; textures and chat certificates signed SHA1withRSA (verified with Java's own crypto) - Skins/capes stored content-addressed after validation and re-encoding; cape library with public/group/private visibility; head avatars API - Launcher login returns a game session; launcher sessions recorded for launcher-only servers; authlib-injector download mirror - Schema v2: player UUIDs (offline UUID backfilled), skins, capes, tokens, sessions, chat keys, game server tables - Remove Microsoft sign-in from the engine and panel Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011ARcGWxLx21FwXJ3yfGriS
This commit is contained in:
29 files changed
+2436
-400
No files matched your search
@@ -1,92 +1,7 @@
|
||||
//! 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()
|
||||
}
|
||||
mod common;
|
||||
use common::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn health_and_default_manifest() {
|
||||
@@ -305,6 +220,6 @@ async fn settings_never_leak_curseforge_key() {
|
||||
// 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");
|
||||
let (s, _) = t.call("PUT", "/api/admin/settings", Some(&admin), Some(json!({"public_url": "ftp://nope"}))).await;
|
||||
assert_eq!(s, StatusCode::BAD_REQUEST, "public URL must be http(s)");
|
||||
}
|
||||
Reference in new issue
Block a user