mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-10 18:59:56 +00:00
Clean release snapshot
This commit is contained in:
@@ -0,0 +1,795 @@
|
||||
"""Tests for /api/settings/export and /api/settings/import (slopsmith#113).
|
||||
|
||||
The bundle round-trip covers four persistence stores; this file exercises
|
||||
the two server-managed ones (server config + plugin server-side files).
|
||||
The frontend localStorage layer is browser-only and out of scope here.
|
||||
|
||||
Each test stubs `LOADED_PLUGINS` directly rather than spinning up the
|
||||
plugin loader — the loader's job (manifest parsing → `_export_paths`)
|
||||
is exercised separately in `test_plugins.py`.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import importlib
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def server_mod(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("CONFIG_DIR", str(tmp_path))
|
||||
sys.modules.pop("server", None)
|
||||
mod = importlib.import_module("server")
|
||||
yield mod
|
||||
conn = getattr(getattr(mod, "meta_db", None), "conn", None)
|
||||
if conn is not None:
|
||||
conn.close()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(server_mod):
|
||||
c = TestClient(server_mod.app)
|
||||
try:
|
||||
yield c
|
||||
finally:
|
||||
c.close()
|
||||
|
||||
|
||||
def _stub_plugin(server_mod, plugin_id: str, export_paths: list[str]):
|
||||
"""Append a fake plugin record to LOADED_PLUGINS so export/import see
|
||||
it. The loader is bypassed entirely — the only fields the endpoints
|
||||
consult are `id` and `_export_paths`."""
|
||||
from plugins import LOADED_PLUGINS
|
||||
LOADED_PLUGINS.append({
|
||||
"id": plugin_id,
|
||||
"name": plugin_id,
|
||||
"nav": None,
|
||||
"type": None,
|
||||
"has_screen": False,
|
||||
"has_script": False,
|
||||
"has_settings": False,
|
||||
"_export_paths": export_paths,
|
||||
"_dir": Path("."),
|
||||
"_manifest": {},
|
||||
})
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_loaded_plugins():
|
||||
"""LOADED_PLUGINS is module-level state in plugins/__init__.py — it
|
||||
persists across tests within the same pytest process and gets
|
||||
populated by `load_plugins()` on import. Snapshot/restore so each
|
||||
test starts from the post-import baseline rather than carrying
|
||||
fakes from previous tests."""
|
||||
from plugins import LOADED_PLUGINS
|
||||
snapshot = list(LOADED_PLUGINS)
|
||||
yield
|
||||
LOADED_PLUGINS.clear()
|
||||
LOADED_PLUGINS.extend(snapshot)
|
||||
|
||||
|
||||
# ── Round-trip: server config only, no plugins ──────────────────────────────
|
||||
|
||||
def test_round_trip_no_plugins(client, tmp_path):
|
||||
(tmp_path / "config.json").write_text(json.dumps({
|
||||
"dlc_dir": "/some/path",
|
||||
"default_arrangement": "Lead",
|
||||
"master_difficulty": 75,
|
||||
}))
|
||||
|
||||
bundle = client.get("/api/settings/export").json()
|
||||
assert bundle["schema"] == 1
|
||||
assert bundle["server_config"]["master_difficulty"] == 75
|
||||
assert bundle["server_config"]["default_arrangement"] == "Lead"
|
||||
|
||||
# Wipe and re-import
|
||||
(tmp_path / "config.json").unlink()
|
||||
r = client.post("/api/settings/import", json=bundle)
|
||||
assert r.status_code == 200, r.json()
|
||||
assert r.json()["ok"] is True
|
||||
|
||||
cfg = json.loads((tmp_path / "config.json").read_text())
|
||||
assert cfg["master_difficulty"] == 75
|
||||
assert cfg["default_arrangement"] == "Lead"
|
||||
|
||||
|
||||
# ── Round-trip: plugin server files (binary + json + nested dir) ────────────
|
||||
|
||||
def test_round_trip_with_plugin_files(client, server_mod, tmp_path):
|
||||
_stub_plugin(server_mod, "fake_plugin", ["fake_plugin.db", "fake_models/"])
|
||||
|
||||
# Stage some files.
|
||||
binary = bytes(range(256)) * 4 # 1 KiB of binary noise
|
||||
(tmp_path / "fake_plugin.db").write_bytes(binary)
|
||||
(tmp_path / "fake_models").mkdir()
|
||||
(tmp_path / "fake_models" / "a.json").write_text(json.dumps({"k": "v"}))
|
||||
(tmp_path / "fake_models" / "sub").mkdir()
|
||||
(tmp_path / "fake_models" / "sub" / "b.bin").write_bytes(b"\x00\x01\x02")
|
||||
|
||||
bundle = client.get("/api/settings/export").json()
|
||||
files = bundle["plugin_server_configs"]["fake_plugin"]["files"]
|
||||
assert "fake_plugin.db" in files
|
||||
assert files["fake_plugin.db"]["encoding"] == "base64"
|
||||
assert "fake_models/a.json" in files
|
||||
assert files["fake_models/a.json"]["encoding"] == "json"
|
||||
assert files["fake_models/a.json"]["data"] == {"k": "v"}
|
||||
assert "fake_models/sub/b.bin" in files
|
||||
assert files["fake_models/sub/b.bin"]["encoding"] == "base64"
|
||||
|
||||
# Wipe everything plugin-owned, then import.
|
||||
(tmp_path / "fake_plugin.db").unlink()
|
||||
import shutil
|
||||
shutil.rmtree(tmp_path / "fake_models")
|
||||
|
||||
r = client.post("/api/settings/import", json=bundle)
|
||||
assert r.status_code == 200, r.json()
|
||||
|
||||
assert (tmp_path / "fake_plugin.db").read_bytes() == binary
|
||||
assert json.loads((tmp_path / "fake_models" / "a.json").read_text()) == {"k": "v"}
|
||||
assert (tmp_path / "fake_models" / "sub" / "b.bin").read_bytes() == b"\x00\x01\x02"
|
||||
|
||||
|
||||
# ── Schema gating ───────────────────────────────────────────────────────────
|
||||
|
||||
def test_schema_mismatch_refused(client, tmp_path):
|
||||
(tmp_path / "config.json").write_text(json.dumps({"master_difficulty": 50}))
|
||||
pre_mtime = (tmp_path / "config.json").stat().st_mtime_ns
|
||||
|
||||
r = client.post("/api/settings/import", json={
|
||||
"schema": 2,
|
||||
"server_config": {"master_difficulty": 99},
|
||||
})
|
||||
assert r.status_code == 400
|
||||
assert "schema" in r.json()["error"].lower()
|
||||
|
||||
# Disk untouched.
|
||||
assert (tmp_path / "config.json").stat().st_mtime_ns == pre_mtime
|
||||
assert json.loads((tmp_path / "config.json").read_text())["master_difficulty"] == 50
|
||||
|
||||
|
||||
def test_missing_schema_refused(client):
|
||||
r = client.post("/api/settings/import", json={"server_config": {}})
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
def test_non_dict_body_refused(client):
|
||||
# FastAPI's body validation (`bundle: dict` in the handler signature)
|
||||
# produces 422 before our phase-1 check runs; the explicit `isinstance`
|
||||
# guard inside the handler covers the case where someone calls the
|
||||
# function directly. Either way, non-dict input never reaches the
|
||||
# filesystem.
|
||||
r = client.post("/api/settings/import", json=[])
|
||||
assert r.status_code in (400, 422)
|
||||
|
||||
|
||||
# ── Version warning is non-blocking ─────────────────────────────────────────
|
||||
|
||||
def test_version_warning_nonblocking(client, tmp_path, server_mod):
|
||||
bundle = {
|
||||
"schema": 1,
|
||||
"slopsmith_version": "999.999.999",
|
||||
"server_config": {"master_difficulty": 42},
|
||||
"plugin_server_configs": {},
|
||||
}
|
||||
r = client.post("/api/settings/import", json=bundle)
|
||||
assert r.status_code == 200, r.json()
|
||||
body = r.json()
|
||||
assert body["ok"] is True
|
||||
assert any("version mismatch" in w for w in body["warnings"])
|
||||
assert json.loads((tmp_path / "config.json").read_text())["master_difficulty"] == 42
|
||||
|
||||
|
||||
# ── Path traversal / absolute path / undeclared file ─────────────────────────
|
||||
|
||||
def test_path_traversal_rejected(client, server_mod, tmp_path):
|
||||
_stub_plugin(server_mod, "fake_plugin", ["fake_plugin.db"])
|
||||
pre = json.dumps({"master_difficulty": 50})
|
||||
(tmp_path / "config.json").write_text(pre)
|
||||
|
||||
bundle = {
|
||||
"schema": 1,
|
||||
"server_config": {"master_difficulty": 99},
|
||||
"plugin_server_configs": {
|
||||
"fake_plugin": {
|
||||
"files": {
|
||||
"../../etc/passwd": {"encoding": "base64", "data": ""},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
r = client.post("/api/settings/import", json=bundle)
|
||||
assert r.status_code == 400
|
||||
# Server config also untouched — phase-1 validation refused before
|
||||
# any disk write.
|
||||
assert json.loads((tmp_path / "config.json").read_text())["master_difficulty"] == 50
|
||||
|
||||
|
||||
def test_dotdot_after_allowed_prefix_rejected(client, server_mod, tmp_path):
|
||||
"""Regression for the allowlist-bypass that would let a relpath
|
||||
starting with an allowed directory prefix smuggle a `..` past the
|
||||
matcher (e.g. `fake_models/../config.json`). The raw string passes
|
||||
a naive prefix check, but `posixpath.normpath` would collapse the
|
||||
`..` to a target outside the manifest's intent. `_validate_relpath`
|
||||
must reject any `..` segment in the *raw* relpath BEFORE
|
||||
normalization."""
|
||||
_stub_plugin(server_mod, "fake_plugin", ["fake_models/"])
|
||||
(tmp_path / "config.json").write_text(json.dumps({"master_difficulty": 50}))
|
||||
|
||||
bundle = {
|
||||
"schema": 1,
|
||||
"server_config": {"master_difficulty": 99},
|
||||
"plugin_server_configs": {
|
||||
"fake_plugin": {
|
||||
"files": {
|
||||
"fake_models/../config.json": {
|
||||
"encoding": "base64",
|
||||
"data": base64.b64encode(b"hijacked").decode(),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
r = client.post("/api/settings/import", json=bundle)
|
||||
assert r.status_code == 400
|
||||
# Disk untouched — phase-1 refusal blocks every write.
|
||||
assert json.loads((tmp_path / "config.json").read_text())["master_difficulty"] == 50
|
||||
assert (tmp_path / "config.json").read_text() != "hijacked"
|
||||
|
||||
|
||||
def test_directory_bare_name_rejected(client, server_mod, tmp_path):
|
||||
"""A directory entry in the allowlist (`fake_models/`) authorizes
|
||||
files *under* that prefix, never the directory itself. A bundle
|
||||
that targets the bare directory name (`fake_models`) would either
|
||||
fail `os.replace()` mid-apply (500, partial state) or — worse,
|
||||
on platforms where replace-over-empty-dir succeeds — silently
|
||||
overwrite a directory with a file. Phase-1 refuses outright."""
|
||||
_stub_plugin(server_mod, "fake_plugin", ["fake_models/"])
|
||||
(tmp_path / "fake_models").mkdir()
|
||||
|
||||
bundle = {
|
||||
"schema": 1,
|
||||
"server_config": {},
|
||||
"plugin_server_configs": {
|
||||
"fake_plugin": {
|
||||
"files": {
|
||||
"fake_models": {
|
||||
"encoding": "base64",
|
||||
"data": base64.b64encode(b"x").decode(),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
r = client.post("/api/settings/import", json=bundle)
|
||||
# Treated as undeclared (manifest rule was a directory entry, but
|
||||
# the bundle targets the bare dir name) → soft skip with warning,
|
||||
# no writes. Important: the fake_models directory is preserved.
|
||||
assert r.status_code == 200, r.json()
|
||||
assert any("fake_models" in w for w in r.json().get("warnings", []))
|
||||
assert (tmp_path / "fake_models").is_dir()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad", ["/etc/passwd", "C:/Windows/foo", r"C:\Windows\foo"])
|
||||
def test_absolute_path_rejected(client, server_mod, bad):
|
||||
_stub_plugin(server_mod, "fake_plugin", ["fake_plugin.db"])
|
||||
bundle = {
|
||||
"schema": 1,
|
||||
"server_config": {},
|
||||
"plugin_server_configs": {
|
||||
"fake_plugin": {"files": {bad: {"encoding": "base64", "data": ""}}},
|
||||
},
|
||||
}
|
||||
r = client.post("/api/settings/import", json=bundle)
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
def test_undeclared_file_skipped_with_warning(client, server_mod, tmp_path):
|
||||
"""A file in the bundle whose plugin no longer declares it (manifest
|
||||
tightened between export and import) is skipped with a warning, not
|
||||
a hard 400. The bundle's other files still apply. Path-traversal
|
||||
attempts (covered separately) remain hard failures."""
|
||||
_stub_plugin(server_mod, "fake_plugin", ["fake_plugin.db"])
|
||||
bundle = {
|
||||
"schema": 1,
|
||||
"server_config": {"master_difficulty": 77},
|
||||
"plugin_server_configs": {
|
||||
"fake_plugin": {
|
||||
"files": {
|
||||
"fake_plugin.db": {"encoding": "base64", "data": base64.b64encode(b"new").decode()},
|
||||
"secrets/api.key": {"encoding": "base64", "data": base64.b64encode(b"k").decode()},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
r = client.post("/api/settings/import", json=bundle)
|
||||
assert r.status_code == 200, r.json()
|
||||
body = r.json()
|
||||
assert body["ok"] is True
|
||||
assert any("secrets/api.key" in w for w in body["warnings"])
|
||||
# Declared file applied.
|
||||
assert (tmp_path / "fake_plugin.db").read_bytes() == b"new"
|
||||
# Undeclared file NOT written.
|
||||
assert not (tmp_path / "secrets" / "api.key").exists()
|
||||
# Server config still applied.
|
||||
assert json.loads((tmp_path / "config.json").read_text())["master_difficulty"] == 77
|
||||
|
||||
|
||||
# ── Unknown plugin: skip with warning, don't fail ───────────────────────────
|
||||
|
||||
def test_unknown_plugin_skipped(client, tmp_path):
|
||||
bundle = {
|
||||
"schema": 1,
|
||||
"server_config": {"master_difficulty": 33},
|
||||
"plugin_server_configs": {
|
||||
"mystery_plugin": {
|
||||
"files": {"any.txt": {"encoding": "base64", "data": ""}},
|
||||
},
|
||||
},
|
||||
}
|
||||
r = client.post("/api/settings/import", json=bundle)
|
||||
assert r.status_code == 200, r.json()
|
||||
body = r.json()
|
||||
assert body["ok"] is True
|
||||
assert any("mystery_plugin" in w and "not loaded" in w for w in body["warnings"])
|
||||
assert "mystery_plugin" not in body["applied"]["plugins"]
|
||||
# Server config still applied.
|
||||
assert json.loads((tmp_path / "config.json").read_text())["master_difficulty"] == 33
|
||||
|
||||
|
||||
# ── Atomicity: bad encoding rejects whole bundle in phase 1 ─────────────────
|
||||
|
||||
def test_atomicity_on_decode_failure(client, server_mod, tmp_path):
|
||||
_stub_plugin(server_mod, "fake_plugin", ["fake_plugin.db"])
|
||||
(tmp_path / "config.json").write_text(json.dumps({"master_difficulty": 50}))
|
||||
(tmp_path / "fake_plugin.db").write_bytes(b"original")
|
||||
|
||||
bundle = {
|
||||
"schema": 1,
|
||||
"server_config": {"master_difficulty": 99},
|
||||
"plugin_server_configs": {
|
||||
"fake_plugin": {
|
||||
"files": {
|
||||
"fake_plugin.db": {"encoding": "base64", "data": "this is not base64!!!"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
r = client.post("/api/settings/import", json=bundle)
|
||||
assert r.status_code == 400
|
||||
assert json.loads((tmp_path / "config.json").read_text())["master_difficulty"] == 50
|
||||
assert (tmp_path / "fake_plugin.db").read_bytes() == b"original"
|
||||
|
||||
|
||||
def test_unknown_encoding_rejected(client, server_mod, tmp_path):
|
||||
_stub_plugin(server_mod, "fake_plugin", ["fake_plugin.db"])
|
||||
bundle = {
|
||||
"schema": 1,
|
||||
"server_config": {},
|
||||
"plugin_server_configs": {
|
||||
"fake_plugin": {
|
||||
"files": {
|
||||
"fake_plugin.db": {"encoding": "rot13", "data": "abc"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
r = client.post("/api/settings/import", json=bundle)
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
# ── Export edge cases ───────────────────────────────────────────────────────
|
||||
|
||||
def test_export_skips_missing_files(client, server_mod):
|
||||
_stub_plugin(server_mod, "fake_plugin", ["does_not_exist.db", "absent_dir/"])
|
||||
bundle = client.get("/api/settings/export").json()
|
||||
# Plugin block is present but files map is empty — we still emit the
|
||||
# block so a round-trip preserves the manifest's namespace.
|
||||
assert bundle["plugin_server_configs"]["fake_plugin"]["files"] == {}
|
||||
|
||||
|
||||
def test_export_directory_walk(client, server_mod, tmp_path):
|
||||
_stub_plugin(server_mod, "fake_plugin", ["models/"])
|
||||
(tmp_path / "models").mkdir()
|
||||
(tmp_path / "models" / "a.bin").write_bytes(b"a")
|
||||
(tmp_path / "models" / "b.bin").write_bytes(b"b")
|
||||
(tmp_path / "models" / "nested").mkdir()
|
||||
(tmp_path / "models" / "nested" / "c.bin").write_bytes(b"c")
|
||||
|
||||
bundle = client.get("/api/settings/export").json()
|
||||
files = bundle["plugin_server_configs"]["fake_plugin"]["files"]
|
||||
assert set(files.keys()) == {"models/a.bin", "models/b.bin", "models/nested/c.bin"}
|
||||
|
||||
|
||||
def test_export_includes_schema_and_version(client):
|
||||
bundle = client.get("/api/settings/export").json()
|
||||
assert bundle["schema"] == 1
|
||||
assert "slopsmith_version" in bundle
|
||||
assert "exported_at" in bundle
|
||||
|
||||
|
||||
# ── Empty bundle round-trip (defaults config) ───────────────────────────────
|
||||
|
||||
def test_import_with_empty_plugin_blocks(client, tmp_path):
|
||||
bundle = {
|
||||
"schema": 1,
|
||||
"server_config": {"master_difficulty": 80},
|
||||
"plugin_server_configs": {},
|
||||
}
|
||||
r = client.post("/api/settings/import", json=bundle)
|
||||
assert r.status_code == 200
|
||||
assert json.loads((tmp_path / "config.json").read_text())["master_difficulty"] == 80
|
||||
|
||||
|
||||
def test_import_rejects_non_dict_server_config(client):
|
||||
r = client.post("/api/settings/import", json={
|
||||
"schema": 1,
|
||||
"server_config": [],
|
||||
"plugin_server_configs": {},
|
||||
})
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
def test_import_rejects_non_dict_plugin_blocks(client):
|
||||
r = client.post("/api/settings/import", json={
|
||||
"schema": 1,
|
||||
"server_config": {},
|
||||
"plugin_server_configs": "not an object",
|
||||
})
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
# ── Loader/importer rule consistency ────────────────────────────────────────
|
||||
|
||||
def test_normalize_export_paths_consistency(server_mod, tmp_path):
|
||||
"""Round-trip safety property: every entry the loader keeps in
|
||||
`_export_paths` must satisfy `_validate_relpath` at import time. If
|
||||
these two sides drift, exports become unimportable on the same
|
||||
server (the importer rejects entries the loader produced). The
|
||||
fix in `_normalize_export_paths` mirrors the import-side rules
|
||||
(whitespace / `.` segments / leading-dot first segment); this test
|
||||
locks that contract in."""
|
||||
from plugins import _normalize_export_paths
|
||||
|
||||
# Mix of bad shapes that older versions of the loader let through:
|
||||
# leading/trailing whitespace, embedded `.` segment, dotfile first
|
||||
# segment, and one good entry that should survive.
|
||||
settings_field = {
|
||||
"server_files": [
|
||||
" trim_me.db ",
|
||||
"models/./a.json",
|
||||
".cache/file",
|
||||
"good.db",
|
||||
]
|
||||
}
|
||||
cleaned = _normalize_export_paths(settings_field, "fake_plugin")
|
||||
assert cleaned == ["good.db"]
|
||||
|
||||
# Every survivor must pass `_validate_relpath` against an allowlist
|
||||
# that includes itself — i.e. the loader can't produce something
|
||||
# the importer would refuse with a `ValueError` on traversal /
|
||||
# absolute-path / illegal-segment grounds.
|
||||
for rel in cleaned:
|
||||
# Wraps `_validate_relpath` to assert it doesn't raise the
|
||||
# hard-failure ValueErrors. _UndeclaredFile would mean the
|
||||
# allowlist is wrong, not that the relpath shape is bad.
|
||||
server_mod._validate_relpath(rel, cleaned, tmp_path)
|
||||
|
||||
|
||||
# ── Atomic write: unique tmp + cleanup on failure ───────────────────────────
|
||||
|
||||
def test_atomic_write_cleans_up_tmp_on_failure(server_mod, tmp_path, monkeypatch):
|
||||
"""`_atomic_write_file` must remove its temp file when `os.replace`
|
||||
fails. Otherwise a failed import leaves `.tmp.import` litter in
|
||||
config_dir that persists across server restarts. Also verifies the
|
||||
contract that two failed calls don't collide on the same temp name
|
||||
(mkstemp ensures unique names; we assert both temps are gone)."""
|
||||
target = tmp_path / "out.db"
|
||||
|
||||
boom_calls = {"n": 0}
|
||||
real_replace = server_mod.os.replace
|
||||
|
||||
def boom(*args, **kwargs):
|
||||
boom_calls["n"] += 1
|
||||
raise OSError("simulated replace failure")
|
||||
|
||||
monkeypatch.setattr(server_mod.os, "replace", boom)
|
||||
|
||||
for _ in range(2):
|
||||
with pytest.raises(OSError):
|
||||
server_mod._atomic_write_file(target, b"payload")
|
||||
|
||||
# Both attempts cleaned up. No .tmp.import residue means the
|
||||
# mkstemp + finally-unlink pattern held even across failures.
|
||||
leftover = list(tmp_path.glob("*.tmp.import"))
|
||||
assert leftover == [], f"temp files leaked: {leftover}"
|
||||
assert boom_calls["n"] == 2
|
||||
|
||||
# Restoring real replace, the function should still work end-to-end.
|
||||
monkeypatch.setattr(server_mod.os, "replace", real_replace)
|
||||
server_mod._atomic_write_file(target, b"payload")
|
||||
assert target.read_bytes() == b"payload"
|
||||
assert list(tmp_path.glob("*.tmp.import")) == []
|
||||
|
||||
|
||||
# ── Partial-failure response uses relpaths, not absolute paths ──────────────
|
||||
|
||||
def test_partial_field_uses_relpaths_not_absolute(client, server_mod, tmp_path, monkeypatch):
|
||||
"""When `_atomic_write_file` fails mid-apply, the 500 response
|
||||
surfaces a `partial` list. Returning absolute resolved paths there
|
||||
leaks deployment layout (e.g. `/srv/slopsmith/config/...`); the
|
||||
importer instead returns the bundle's own relpaths, which are
|
||||
portable and meaningful to the user."""
|
||||
_stub_plugin(server_mod, "fake_plugin", ["a.db", "b.db"])
|
||||
|
||||
# Fail on the second `os.replace` call so the first file commits
|
||||
# but the second triggers the OSError branch. `partial` should
|
||||
# then list the first file as `<plugin_id>/<relpath>` — never an
|
||||
# absolute path containing the tmp_path config dir.
|
||||
call_count = {"n": 0}
|
||||
real_replace = server_mod.os.replace
|
||||
|
||||
def selective_boom(src, dst, *args, **kwargs):
|
||||
call_count["n"] += 1
|
||||
if call_count["n"] == 2:
|
||||
raise OSError("simulated mid-apply failure")
|
||||
return real_replace(src, dst, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(server_mod.os, "replace", selective_boom)
|
||||
|
||||
bundle = {
|
||||
"schema": 1,
|
||||
"server_config": {"master_difficulty": 99},
|
||||
"plugin_server_configs": {
|
||||
"fake_plugin": {
|
||||
"files": {
|
||||
"a.db": {"encoding": "base64", "data": base64.b64encode(b"A").decode()},
|
||||
"b.db": {"encoding": "base64", "data": base64.b64encode(b"B").decode()},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
r = client.post("/api/settings/import", json=bundle)
|
||||
assert r.status_code == 500
|
||||
body = r.json()
|
||||
partial = body.get("partial")
|
||||
assert isinstance(partial, list) and partial, body
|
||||
config_root = str(tmp_path)
|
||||
for p in partial:
|
||||
# Relpath form: no absolute prefix, no drive letter, no leak
|
||||
# of the local config_dir's filesystem path.
|
||||
assert not p.startswith("/"), p
|
||||
assert config_root not in p, p
|
||||
assert ":" not in p[:3], p # no drive letter
|
||||
# First file actually got written (because we let the first
|
||||
# replace through); listed in `partial` under its plugin-prefixed
|
||||
# relpath form.
|
||||
assert any(p.endswith("a.db") for p in partial)
|
||||
|
||||
|
||||
# ── Import validates server_config types/ranges ─────────────────────────────
|
||||
|
||||
@pytest.mark.parametrize("bad_cfg, needle", [
|
||||
({"master_difficulty": 150}, "master_difficulty"),
|
||||
({"master_difficulty": -1}, "master_difficulty"),
|
||||
({"master_difficulty": True}, "master_difficulty"),
|
||||
({"master_difficulty": "75"}, "master_difficulty"),
|
||||
({"av_offset_ms": 5000}, "av_offset_ms"),
|
||||
({"av_offset_ms": False}, "av_offset_ms"),
|
||||
({"demucs_server_url": 42}, "demucs_server_url"),
|
||||
({"default_arrangement": ["Lead"]}, "default_arrangement"),
|
||||
({"dlc_dir": 42}, "dlc_dir"),
|
||||
({"psarc_platform": "windows"}, "psarc_platform"),
|
||||
({"psarc_platform": 42}, "psarc_platform"),
|
||||
])
|
||||
def test_import_refuses_invalid_server_config(client, tmp_path, bad_cfg, needle):
|
||||
"""Importer must apply the same per-key type/range gates that
|
||||
POST /api/settings enforces; otherwise a hand-edited bundle could
|
||||
persist values that downstream code crashes on (e.g. non-string
|
||||
demucs_server_url, out-of-range difficulty)."""
|
||||
pre = json.dumps({"master_difficulty": 50})
|
||||
(tmp_path / "config.json").write_text(pre)
|
||||
|
||||
bundle = {
|
||||
"schema": 1,
|
||||
"server_config": bad_cfg,
|
||||
"plugin_server_configs": {},
|
||||
}
|
||||
r = client.post("/api/settings/import", json=bundle)
|
||||
assert r.status_code == 400, r.json()
|
||||
assert needle in r.json()["error"]
|
||||
# Disk untouched — phase-1 refusal blocks every write.
|
||||
assert json.loads((tmp_path / "config.json").read_text())["master_difficulty"] == 50
|
||||
|
||||
|
||||
def test_import_passes_through_unknown_server_config_keys(client, tmp_path):
|
||||
"""Unknown server_config keys round-trip verbatim. The import path
|
||||
isn't the place to gatekeep what plugins / future versions may add
|
||||
to the settings dict — gates apply only to keys we know about."""
|
||||
bundle = {
|
||||
"schema": 1,
|
||||
"server_config": {
|
||||
"master_difficulty": 60,
|
||||
"future_setting": {"nested": True},
|
||||
"unknown_string": "hello",
|
||||
},
|
||||
"plugin_server_configs": {},
|
||||
}
|
||||
r = client.post("/api/settings/import", json=bundle)
|
||||
assert r.status_code == 200, r.json()
|
||||
cfg = json.loads((tmp_path / "config.json").read_text())
|
||||
assert cfg["future_setting"] == {"nested": True}
|
||||
assert cfg["unknown_string"] == "hello"
|
||||
assert cfg["master_difficulty"] == 60
|
||||
|
||||
|
||||
def test_import_allows_psarc_platform_null(client, tmp_path):
|
||||
"""psarc_platform: null passes validation and is written verbatim
|
||||
(import is full-replace, not partial-update). The server treats a
|
||||
null on-disk value the same as 'both' via the .get(..., 'both')
|
||||
fallback in _background_scan."""
|
||||
(tmp_path / "config.json").write_text(json.dumps({"psarc_platform": "pc"}))
|
||||
|
||||
bundle = {
|
||||
"schema": 1,
|
||||
"server_config": {"psarc_platform": None},
|
||||
"plugin_server_configs": {},
|
||||
}
|
||||
r = client.post("/api/settings/import", json=bundle)
|
||||
assert r.status_code == 200, r.json()
|
||||
# Import is full-replace: null is written verbatim; validate the
|
||||
# disk state so a future regression (e.g. null erroneously rejected
|
||||
# or silently dropped) is caught immediately.
|
||||
on_disk = json.loads((tmp_path / "config.json").read_text())
|
||||
assert "psarc_platform" in on_disk
|
||||
assert on_disk["psarc_platform"] is None
|
||||
|
||||
|
||||
# ── Export refuses to follow symlinked subdirectories ───────────────────────
|
||||
|
||||
def test_export_skips_symlinked_subdir_contents(client, server_mod, tmp_path):
|
||||
"""Manifest declares `models/`, the user's config_dir contains a
|
||||
real file `models/a.bin` and a symlinked subdirectory
|
||||
`models/linked → outside/`. `os.walk(followlinks=False)` plus the
|
||||
explicit `dirnames`/`islink` filters must keep the bundle from
|
||||
capturing the symlinked contents — otherwise an attacker (or just
|
||||
a misconfigured volume mount) can leak data outside config_dir."""
|
||||
_stub_plugin(server_mod, "fake_plugin", ["models/"])
|
||||
|
||||
(tmp_path / "models").mkdir()
|
||||
(tmp_path / "models" / "a.bin").write_bytes(b"real")
|
||||
(tmp_path / "outside").mkdir()
|
||||
(tmp_path / "outside" / "secret.bin").write_bytes(b"SECRET")
|
||||
|
||||
try:
|
||||
os.symlink(
|
||||
str(tmp_path / "outside"),
|
||||
str(tmp_path / "models" / "linked"),
|
||||
target_is_directory=True,
|
||||
)
|
||||
except (OSError, NotImplementedError):
|
||||
# Windows without Developer Mode / admin can't create symlinks;
|
||||
# that's a privilege limitation of the test host, not a bug in
|
||||
# the code under test. Skip rather than xfail so devs with the
|
||||
# privilege still exercise the assertion.
|
||||
pytest.skip("symlink creation not permitted on this host")
|
||||
|
||||
bundle = client.get("/api/settings/export").json()
|
||||
files = bundle["plugin_server_configs"]["fake_plugin"]["files"]
|
||||
assert "models/a.bin" in files
|
||||
# The leak we're guarding against: the symlinked subdir's contents
|
||||
# must NOT appear in the bundle in any form.
|
||||
assert "models/linked/secret.bin" not in files
|
||||
assert not any("secret.bin" in k for k in files)
|
||||
|
||||
|
||||
# ── Import refuses symlink target / symlinked parent ────────────────────────
|
||||
|
||||
def test_import_rejects_symlinked_target_file(client, server_mod, tmp_path):
|
||||
"""Even a fully-allowlisted relpath must not redirect through a
|
||||
symlink on import: the manifest declares `fake_plugin.db`, but the
|
||||
user's config_dir contains a symlink at that name pointing at a
|
||||
different in-config file. Writing through the symlink would defeat
|
||||
the manifest's allowlist intent (the bundle authors `fake_plugin.db`
|
||||
but bytes land at `decoy.db`). Phase-1 must refuse outright.
|
||||
"""
|
||||
_stub_plugin(server_mod, "fake_plugin", ["fake_plugin.db"])
|
||||
(tmp_path / "decoy.db").write_bytes(b"original")
|
||||
try:
|
||||
os.symlink(str(tmp_path / "decoy.db"), str(tmp_path / "fake_plugin.db"))
|
||||
except (OSError, NotImplementedError):
|
||||
pytest.skip("symlink creation not permitted on this host")
|
||||
|
||||
bundle = {
|
||||
"schema": 1,
|
||||
"server_config": {},
|
||||
"plugin_server_configs": {
|
||||
"fake_plugin": {
|
||||
"files": {
|
||||
"fake_plugin.db": {
|
||||
"encoding": "base64",
|
||||
"data": base64.b64encode(b"hijacked").decode(),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
r = client.post("/api/settings/import", json=bundle)
|
||||
assert r.status_code == 400, r.json()
|
||||
# Decoy file untouched — write was refused, not just redirected to
|
||||
# a different output destination.
|
||||
assert (tmp_path / "decoy.db").read_bytes() == b"original"
|
||||
|
||||
|
||||
def test_import_rejects_symlinked_parent_dir(client, server_mod, tmp_path):
|
||||
"""Manifest declares `models/`, but the user's config_dir has
|
||||
`models` as a symlink to a sibling directory. Writing through it
|
||||
would let the bundle author `models/<anything>` and have bytes land
|
||||
in the symlink target — bypassing the spirit of the allowlist
|
||||
even if the resolved path stays inside config_dir."""
|
||||
_stub_plugin(server_mod, "fake_plugin", ["models/"])
|
||||
(tmp_path / "real_models").mkdir()
|
||||
try:
|
||||
os.symlink(
|
||||
str(tmp_path / "real_models"),
|
||||
str(tmp_path / "models"),
|
||||
target_is_directory=True,
|
||||
)
|
||||
except (OSError, NotImplementedError):
|
||||
pytest.skip("symlink creation not permitted on this host")
|
||||
|
||||
bundle = {
|
||||
"schema": 1,
|
||||
"server_config": {},
|
||||
"plugin_server_configs": {
|
||||
"fake_plugin": {
|
||||
"files": {
|
||||
"models/x.bin": {
|
||||
"encoding": "base64",
|
||||
"data": base64.b64encode(b"x").decode(),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
r = client.post("/api/settings/import", json=bundle)
|
||||
assert r.status_code == 400, r.json()
|
||||
# No file written through the symlink either.
|
||||
assert not (tmp_path / "real_models" / "x.bin").exists()
|
||||
|
||||
|
||||
# ── _atomic_write_file: closes raw fd if os.fdopen raises ───────────────────
|
||||
|
||||
def test_atomic_write_closes_fd_when_fdopen_fails(server_mod, tmp_path, monkeypatch):
|
||||
"""`tempfile.mkstemp` returns a raw fd; `os.fdopen` is the only
|
||||
code path that takes ownership. If `os.fdopen` itself raises
|
||||
(rare — EMFILE / ENOMEM), the fd would otherwise leak, and on
|
||||
Windows the temp file would remain locked. Verify the
|
||||
fdopen-failure path explicitly closes the fd and removes the
|
||||
temp file."""
|
||||
target = tmp_path / "out.db"
|
||||
|
||||
closed_fds: list[int] = []
|
||||
real_close = os.close
|
||||
|
||||
def tracking_close(fd):
|
||||
closed_fds.append(fd)
|
||||
return real_close(fd)
|
||||
|
||||
def boom_fdopen(*args, **kwargs):
|
||||
raise OSError("simulated EMFILE")
|
||||
|
||||
monkeypatch.setattr(server_mod.os, "close", tracking_close)
|
||||
monkeypatch.setattr(server_mod.os, "fdopen", boom_fdopen)
|
||||
|
||||
with pytest.raises(OSError, match="simulated EMFILE"):
|
||||
server_mod._atomic_write_file(target, b"payload")
|
||||
|
||||
# fd was closed (so it didn't leak), and the temp file mkstemp
|
||||
# created was removed (so it doesn't litter / lock on Windows).
|
||||
assert closed_fds, "raw fd was never closed after fdopen failure"
|
||||
assert list(tmp_path.glob("*.tmp.import")) == []
|
||||
Reference in New Issue
Block a user