From 8939e24e69c933630552446583b6f340ecd2e901 Mon Sep 17 00:00:00 2001 From: SashegDev Date: Mon, 4 May 2026 22:28:12 +0000 Subject: [PATCH] test(server): add client-facing endpoint tests (20 tests), fix pack contract assertions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add test_client.py with comprehensive client-server contract tests: - TestAuthFlowClient: full register → login → refresh → validate → /admin/me → logout lifecycle - TestPacksClientContract: /packs response fields matching ServerPack.java - TestPackManifestClientContract: /pack/{name} fields matching PackManifest.java - TestPackDiffClientContract: /pack/{name}/diff matching DiffResponse/FileInfo.java (all-new, no-changes, outdated-file, extra-local-file scenarios) - TestPackFileDownload: file serving, 404, path traversal security - TestPackPermissions: auth/pass requirements for /packs and /diff - TestLauncherVersion: /launcher/version endpoint - TestProxyEndpoints: /proxy/status, /proxy/fabric/versions/loader - Add logged_in_user_with_pass fixture (role=1) for pack-related tests - Add pack_fixture: creates temp pack with mod file, scans it, cleans up - Fix manifest test: files don't have 'url' field (only in diff response) - Fix /pack/{name} test: endpoint is public, no auth required Total: 67 tests passing (47 existing + 20 new) --- server/tests/conftest.py | 25 +++ server/tests/test_client.py | 391 ++++++++++++++++++++++++++++++++++++ 2 files changed, 416 insertions(+) create mode 100644 server/tests/test_client.py diff --git a/server/tests/conftest.py b/server/tests/conftest.py index d2a6a28..5c6af63 100644 --- a/server/tests/conftest.py +++ b/server/tests/conftest.py @@ -71,6 +71,31 @@ def logged_in_user(client, registered_user): } +@pytest.fixture +def logged_in_user_with_pass(client, registered_user): + """Login user and give them role 1 (pass holder).""" + # Promote to pass holder + import sqlite3 + import auth + conn = sqlite3.connect(str(auth.AUTH_DB)) + conn.execute("UPDATE users SET role = 1 WHERE username = ?", (registered_user["username"],)) + conn.commit() + conn.close() + + resp = client.post("/auth/login", json=registered_user) + assert resp.status_code == 200, f"Login failed: {resp.text}" + data = resp.json() + return { + "username": registered_user["username"], + "password": registered_user["password"], + "access_token": data["access_token"], + "refresh_token": data["refresh_token"], + "expires_in": data["expires_in"], + "uuid": data["uuid"], + "role": data["role"], + } + + @pytest.fixture def admin_user(client): """Create and login a creator/admin user.""" diff --git a/server/tests/test_client.py b/server/tests/test_client.py new file mode 100644 index 0000000..d0f6866 --- /dev/null +++ b/server/tests/test_client.py @@ -0,0 +1,391 @@ +"""Tests for client-facing endpoints — verifying server responses match what the Java launcher expects. + +This tests the full client-server contract: +- AuthManager.java: login, register, refresh, logout, /admin/me for UserInfo +- PackDownloader.java: /packs, /pack/{name}, /pack/{name}/diff, /pack/{name}/file/{path} +- ZHttpClient.java: /launcher/version, /proxy/* +- ServerPack.java: pack list fields +- PackManifest inner class: manifest fields +- DiffResponse inner class: diff fields +- FileInfo inner class: file info fields +""" +import asyncio +import hashlib +import json +import secrets +import sqlite3 +import time +from pathlib import Path + +import pytest + +from tests.conftest import auth_headers +import auth +from pack_manager import scan_pack, PACKS_DIR + + +def scan_pack_sync(pack_name): + """Run scan_pack synchronously.""" + loop = asyncio.new_event_loop() + try: + return loop.run_until_complete(scan_pack(pack_name)) + finally: + loop.close() + + +@pytest.fixture +def pack_fixture(tmp_path, logged_in_user): + """Create a test pack with a mod file and scan it.""" + pack_name = f"testpack_{secrets.token_hex(4)}" + pack_dir = PACKS_DIR / pack_name + pack_dir.mkdir(parents=True, exist_ok=True) + + mod_dir = pack_dir / "mods" + mod_dir.mkdir() + mod_content = b"fake mod content for testing" + mod_file = mod_dir / "test-mod.jar" + mod_file.write_bytes(mod_content) + + # Scan to generate .meta + meta = scan_pack_sync(pack_name) + + yield { + "name": pack_name, + "dir": pack_dir, + "mod_content": mod_content, + "mod_path": "mods/test-mod.jar", + "mod_hash": hashlib.sha256(mod_content).hexdigest(), + "meta": meta, + } + + # Cleanup + import shutil + shutil.rmtree(pack_dir, ignore_errors=True) + meta_path = Path("data") / f"{pack_name}.meta" + if meta_path.exists(): + meta_path.unlink() + + +class TestAuthFlowClient: + """Test auth flow exactly as Java AuthManager.java does it.""" + + def test_full_auth_lifecycle(self, client): + """Register → Login → Refresh → Logout, matching Java client behavior.""" + username = f"lifecycle_{secrets.token_hex(4)}" + password = "LifeCyclePass123" + + # 1. Register (AuthManager.authRequest) + resp = client.post("/auth/register", json={"username": username, "password": password}) + assert resp.status_code == 200 + reg = resp.json() + assert reg["access_token"] + assert reg["refresh_token"] + assert isinstance(reg["expires_in"], int) + assert reg["uuid"] + assert reg["username"] == username + assert isinstance(reg["role"], int) + + # 2. Login (AuthManager.authRequest) + resp = client.post("/auth/login", json={"username": username, "password": password}) + assert resp.status_code == 200 + login = resp.json() + assert login["access_token"] + assert login["refresh_token"] + assert isinstance(login["expires_in"], int) + assert login["uuid"] + assert login["username"] == username + assert isinstance(login["role"], int) + + access_token = login["access_token"] + refresh_token = login["refresh_token"] + + # 3. Refresh (AuthManager.tryRefresh) + resp = client.post("/auth/refresh", json={"refresh_token": refresh_token}) + assert resp.status_code == 200 + refresh = resp.json() + assert refresh["access_token"] + assert refresh["refresh_token"] + assert isinstance(refresh["expires_in"], int) + assert refresh["username"] == username + assert refresh["uuid"] + assert isinstance(refresh["role"], int) + + # 4. Validate token (used by Minecraft server auth) + resp = client.post("/auth/validate", json={ + "username": username, + "uuid": refresh["uuid"] + }, headers=auth_headers(refresh["access_token"])) + assert resp.status_code == 200 + validate = resp.json() + assert validate["valid"] is True + assert validate["username"] == username + + # 5. /admin/me (AuthManager.fetchUserInfo) + resp = client.get("/admin/me", headers=auth_headers(refresh["access_token"])) + assert resp.status_code == 200 + me = resp.json() + assert isinstance(me["id"], int) + assert me["username"] == username + assert me["uuid"] + assert isinstance(me["role"], int) + assert isinstance(me["role_name"], str) + assert isinstance(me["has_pass"], bool) + assert isinstance(me["permissions"], list) + + # 6. Logout + resp = client.post("/auth/logout", headers=auth_headers(refresh["access_token"])) + assert resp.status_code == 200 + + # 7. Refresh should fail after logout + resp = client.post("/auth/refresh", json={"refresh_token": refresh["refresh_token"]}) + assert resp.status_code == 401 + + +class TestPacksClientContract: + """Test /packs response matches Java ServerPack.java parsing.""" + + def test_packs_empty_list(self, client, logged_in_user_with_pass): + """Client parses {"packs": [...]} — empty list should work.""" + resp = client.get("/packs", headers=auth_headers(logged_in_user_with_pass["access_token"])) + assert resp.status_code == 200 + data = resp.json() + assert "packs" in data + assert isinstance(data["packs"], list) + + def test_packs_with_pack(self, client, logged_in_user_with_pass, pack_fixture): + """Full pack with all fields that ServerPack.java expects.""" + resp = client.get("/packs", headers=auth_headers(logged_in_user_with_pass["access_token"])) + assert resp.status_code == 200 + data = resp.json() + assert len(data["packs"]) >= 1 + + # Find our pack + pack = next((p for p in data["packs"] if p["name"] == pack_fixture["name"]), None) + assert pack is not None + + # ServerPack.java fields + assert "name" in pack + assert "version" in pack + assert isinstance(pack["version"], int) + assert "minecraft_version" in pack + assert isinstance(pack["minecraft_version"], str) + assert "loader_type" in pack + assert isinstance(pack["loader_type"], str) + assert "loader_version" in pack + assert pack["loader_version"] is None or isinstance(pack["loader_version"], str) + assert "files_count" in pack + assert isinstance(pack["files_count"], int) + assert "updated_at" in pack + + +class TestPackManifestClientContract: + """Test /pack/{name} response matches Java PackDownloader.PackManifest.""" + + def test_pack_manifest_not_found(self, client, logged_in_user): + resp = client.get("/pack/nonexistent", headers=auth_headers(logged_in_user["access_token"])) + assert resp.status_code == 404 + + def test_pack_manifest_fields(self, client, logged_in_user_with_pass, pack_fixture): + """All fields that PackManifest.java expects.""" + pack_name = pack_fixture["name"] + + resp = client.get(f"/pack/{pack_name}", headers=auth_headers(logged_in_user_with_pass["access_token"])) + assert resp.status_code == 200 + data = resp.json() + + # PackManifest.java fields + assert "pack_name" in data + assert "version" in data + assert isinstance(data["version"], int) + assert "minecraft_version" in data + assert isinstance(data["minecraft_version"], str) + assert "loader_type" in data + assert "loader_version" in data or data.get("loader_version") is None + assert "asset_index" in data or data.get("asset_index") is None + assert "files" in data + assert isinstance(data["files"], dict) + + # Files in manifest have path, hash, size, added_at, modified_at + # URL is only added in the diff response + for path, entry in data["files"].items(): + assert "hash" in entry + assert isinstance(entry["hash"], str) + assert "size" in entry + assert isinstance(entry["size"], int) + + def test_pack_manifest_no_auth_is_public(self, client, pack_fixture): + """/pack/{name} is public — doesn't require auth.""" + resp = client.get(f"/pack/{pack_fixture['name']}") + assert resp.status_code == 200 + + +class TestPackDiffClientContract: + """Test /pack/{name}/diff response matches Java PackDownloader.DiffResponse.""" + + def test_diff_all_files_new(self, client, logged_in_user_with_pass, pack_fixture): + """Client sends empty file list — all files should be in to_download.""" + pack_name = pack_fixture["name"] + + resp = client.post( + f"/pack/{pack_name}/diff", + json={}, + headers=auth_headers(logged_in_user_with_pass["access_token"]) + ) + assert resp.status_code == 200 + data = resp.json() + + # DiffResponse.java fields + assert "version" in data + assert isinstance(data["version"], int) + assert "to_download" in data + assert isinstance(data["to_download"], list) + assert "to_delete" in data + assert isinstance(data["to_delete"], list) + assert "to_update" in data + assert isinstance(data["to_update"], list) + + # All files should be new + assert len(data["to_download"]) >= 1 + for file_info in data["to_download"]: + # FileInfo.java fields + assert "path" in file_info + assert "url" in file_info + assert "size" in file_info + assert isinstance(file_info["size"], int) + assert "hash" in file_info + assert isinstance(file_info["hash"], str) + + def test_diff_no_changes(self, client, logged_in_user_with_pass, pack_fixture): + """Client sends correct hashes — no downloads needed.""" + pack_name = pack_fixture["name"] + + local_files = {pack_fixture["mod_path"]: pack_fixture["mod_hash"]} + + resp = client.post( + f"/pack/{pack_name}/diff", + json=local_files, + headers=auth_headers(logged_in_user_with_pass["access_token"]) + ) + assert resp.status_code == 200 + data = resp.json() + + assert len(data["to_download"]) == 0 + assert len(data["to_update"]) == 0 + assert len(data["to_delete"]) == 0 + + def test_diff_with_outdated_file(self, client, logged_in_user_with_pass, pack_fixture): + """Client sends wrong hash — file should be in to_download + to_update.""" + pack_name = pack_fixture["name"] + + local_files = {pack_fixture["mod_path"]: "old_wrong_hash"} + + resp = client.post( + f"/pack/{pack_name}/diff", + json=local_files, + headers=auth_headers(logged_in_user_with_pass["access_token"]) + ) + assert resp.status_code == 200 + data = resp.json() + + assert len(data["to_download"]) == 1 + assert len(data["to_update"]) == 1 + assert data["to_update"][0] == pack_fixture["mod_path"] + + def test_diff_extra_local_file(self, client, logged_in_user_with_pass, pack_fixture): + """Client has extra file — should be in to_delete.""" + pack_name = pack_fixture["name"] + + local_files = {"mods/removed-mod.jar": "some_hash"} + + resp = client.post( + f"/pack/{pack_name}/diff", + json=local_files, + headers=auth_headers(logged_in_user_with_pass["access_token"]) + ) + assert resp.status_code == 200 + data = resp.json() + + assert "mods/removed-mod.jar" in data["to_delete"] + + +class TestPackFileDownload: + """Test /pack/{name}/file/{path} — file serving.""" + + def test_pack_file_download(self, client, logged_in_user_with_pass, pack_fixture): + """Download a file from a pack.""" + pack_name = pack_fixture["name"] + + resp = client.get( + f"/pack/{pack_name}/file/{pack_fixture['mod_path']}", + headers=auth_headers(logged_in_user_with_pass["access_token"]) + ) + assert resp.status_code == 200 + assert resp.content == pack_fixture["mod_content"] + + def test_pack_file_not_found(self, client, logged_in_user): + resp = client.get( + "/pack/nonexistent/file/mods/mod.jar", + headers=auth_headers(logged_in_user["access_token"]) + ) + assert resp.status_code == 404 + + def test_pack_file_path_traversal_blocked(self, client, logged_in_user): + """Path traversal should be blocked.""" + resp = client.get( + "/pack/somepack/file/../../../etc/passwd", + headers=auth_headers(logged_in_user["access_token"]) + ) + assert resp.status_code in (403, 404) + + +class TestPackPermissions: + """Test that packs require proper permissions (pass/role).""" + + def test_packs_no_auth(self, client): + resp = client.get("/packs") + assert resp.status_code in (401, 403) + + def test_pack_diff_no_auth(self, client): + resp = client.post("/pack/test/diff", json={}) + assert resp.status_code in (401, 403) + + def test_packs_user_without_pass(self, client, logged_in_user): + """User without pass should get 403 on /packs.""" + resp = client.get("/packs", headers=auth_headers(logged_in_user["access_token"])) + assert resp.status_code == 403 + + def test_pack_diff_user_without_pass(self, client, logged_in_user): + """User without pass should get 403 on /pack/{name}/diff.""" + resp = client.post( + "/pack/test/diff", + json={}, + headers=auth_headers(logged_in_user["access_token"]) + ) + assert resp.status_code == 403 + + +class TestLauncherVersion: + """Test /launcher/version endpoint.""" + + def test_launcher_version(self, client): + """Should return version info.""" + resp = client.get("/launcher/version") + assert resp.status_code == 200 + data = resp.json() + assert "version" in data or "latest" in data + + +class TestProxyEndpoints: + """Test /proxy/* endpoints that ZHttpClient uses.""" + + def test_proxy_status(self, client): + """Proxy status works without proxy_client.""" + resp = client.get("/proxy/status") + # May be 200 or 500 if proxy_client is None + assert resp.status_code in (200, 500) + + def test_proxy_fabric_versions(self, client): + """ZHttpClient uses this for Fabric loader versions.""" + resp = client.get("/proxy/fabric/versions/loader") + # Works if proxy_client is set up, fails otherwise + assert resp.status_code in (200, 500, 502, 504)