155 lines
5.8 KiB
Python
155 lines
5.8 KiB
Python
"""一键自更新加固 + 改密码不掉线:并发锁、SHA256 校验、缺 SHA256SUMS 拒绝、坏包可读报错、临时文件清理、.bak 备份。"""
|
|
import hashlib
|
|
import io
|
|
import os
|
|
import tarfile
|
|
import threading
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
from app import security, selfupdate
|
|
from app.main import app
|
|
|
|
|
|
def _tar(name="ros-dpi", size=selfupdate._MIN_SIZE + 10) -> bytes:
|
|
buf = io.BytesIO()
|
|
with tarfile.open(fileobj=buf, mode="w:gz") as tf:
|
|
data = b"\x7fELF" + b"\0" * (size - 4)
|
|
ti = tarfile.TarInfo(name)
|
|
ti.size = len(data)
|
|
tf.addfile(ti, io.BytesIO(data))
|
|
return buf.getvalue()
|
|
|
|
|
|
@pytest.fixture
|
|
def fake(tmp_path, monkeypatch):
|
|
"""模拟冻结环境:二进制在 tmp_path 里,GitHub 下载由 state 决定"""
|
|
exe = tmp_path / "ros-dpi"
|
|
exe.write_bytes(b"old-binary")
|
|
state = {"tar": _tar(), "sums": None, "restarted": 0}
|
|
state["sums"] = f"{hashlib.sha256(state['tar']).hexdigest()} ros-dpi-linux-amd64.tar.gz\n"
|
|
|
|
def get_text(url):
|
|
assert url.endswith("/SHA256SUMS")
|
|
return state["sums"]
|
|
|
|
def download(url, f):
|
|
f.write(state["tar"])
|
|
return hashlib.sha256(state["tar"]).hexdigest()
|
|
|
|
monkeypatch.setattr(selfupdate, "_frozen", lambda: True)
|
|
monkeypatch.setattr(selfupdate, "latest_release", lambda: ("9.9.9", ""))
|
|
monkeypatch.setattr(selfupdate, "_arch", lambda: "amd64")
|
|
monkeypatch.setattr(selfupdate, "_exe", lambda: str(exe))
|
|
monkeypatch.setattr(selfupdate, "_get_text", get_text)
|
|
monkeypatch.setattr(selfupdate, "_download", download)
|
|
monkeypatch.setattr(selfupdate, "_restart", lambda: state.__setitem__("restarted", state["restarted"] + 1))
|
|
state["exe"] = exe
|
|
state["dir"] = tmp_path
|
|
return state
|
|
|
|
|
|
def _leftovers(d):
|
|
return [p for p in os.listdir(d) if p.startswith(".ros-dpi-update-")]
|
|
|
|
|
|
def test_update_ok_with_backup(fake):
|
|
res = selfupdate.perform("0.6.0")
|
|
assert res["ok"] and ".bak" in res["message"]
|
|
assert fake["exe"].stat().st_size > selfupdate._MIN_SIZE
|
|
assert (fake["dir"] / "ros-dpi.bak").read_bytes() == b"old-binary"
|
|
assert fake["restarted"] == 1 and _leftovers(fake["dir"]) == []
|
|
|
|
|
|
def test_update_refuses_without_sha256sums(fake):
|
|
fake["sums"] = None
|
|
with pytest.raises(RuntimeError, match="SHA256SUMS"):
|
|
selfupdate.perform("0.6.0")
|
|
assert fake["exe"].read_bytes() == b"old-binary" and fake["restarted"] == 0
|
|
|
|
|
|
def test_update_hash_mismatch(fake):
|
|
fake["sums"] = "0" * 64 + " ros-dpi-linux-amd64.tar.gz\n"
|
|
with pytest.raises(RuntimeError, match="校验失败"):
|
|
selfupdate.perform("0.6.0")
|
|
assert fake["exe"].read_bytes() == b"old-binary" and _leftovers(fake["dir"]) == []
|
|
|
|
|
|
def test_update_tar_without_binary(fake):
|
|
fake["tar"] = _tar(name="README")
|
|
fake["sums"] = f"{hashlib.sha256(fake['tar']).hexdigest()} ros-dpi-linux-amd64.tar.gz\n"
|
|
with pytest.raises(RuntimeError, match="找不到"):
|
|
selfupdate.perform("0.6.0")
|
|
assert fake["exe"].read_bytes() == b"old-binary" and _leftovers(fake["dir"]) == []
|
|
|
|
|
|
def test_update_network_error_readable(fake, monkeypatch):
|
|
import httpx
|
|
|
|
def boom(url, f):
|
|
raise httpx.ConnectError("no route")
|
|
monkeypatch.setattr(selfupdate, "_download", boom)
|
|
with pytest.raises(RuntimeError, match="连接 GitHub 失败"):
|
|
selfupdate.perform("0.6.0")
|
|
assert _leftovers(fake["dir"]) == []
|
|
|
|
|
|
def test_update_concurrent_busy(fake, monkeypatch):
|
|
entered, release = threading.Event(), threading.Event()
|
|
orig = fake["tar"]
|
|
|
|
def slow(url, f):
|
|
entered.set()
|
|
release.wait(5)
|
|
f.write(orig)
|
|
return hashlib.sha256(orig).hexdigest()
|
|
monkeypatch.setattr(selfupdate, "_download", slow)
|
|
t = threading.Thread(target=selfupdate.perform, args=("0.6.0",))
|
|
t.start()
|
|
try:
|
|
assert entered.wait(5)
|
|
with pytest.raises(selfupdate.Busy):
|
|
selfupdate.perform("0.6.0")
|
|
finally:
|
|
release.set()
|
|
t.join(5)
|
|
assert not selfupdate._lock.locked()
|
|
|
|
|
|
def _admin(c):
|
|
security._login_fails.clear()
|
|
if not c.get("/api/auth/status").json()["initialized"]:
|
|
c.post("/api/auth/setup", json={"username": "admin", "password": "secret1"})
|
|
return {"Authorization": "Bearer " + c.post("/api/auth/login", json={"username": "admin", "password": "secret1"}).json()["token"]}
|
|
|
|
|
|
def test_update_route_409_and_400(monkeypatch):
|
|
with TestClient(app) as c:
|
|
h = _admin(c)
|
|
|
|
def busy(cur):
|
|
raise selfupdate.Busy("已有一次更新正在进行,请稍候")
|
|
monkeypatch.setattr(selfupdate, "perform", busy)
|
|
assert c.post("/api/system/update", headers=h).status_code == 409
|
|
|
|
def bad(cur):
|
|
raise StopIteration
|
|
monkeypatch.setattr(selfupdate, "perform", bad)
|
|
r = c.post("/api/system/update", headers=h)
|
|
assert r.status_code == 400 and "StopIteration" in r.json()["detail"]
|
|
|
|
|
|
def test_change_password_returns_fresh_token():
|
|
with TestClient(app) as c:
|
|
ha = _admin(c)
|
|
assert c.post("/api/auth/users", json={"username": "pwfresh", "password": "fresh111", "role": "user"},
|
|
headers=ha).status_code == 200
|
|
security._login_fails.clear()
|
|
h = {"Authorization": "Bearer " + c.post("/api/auth/login", json={"username": "pwfresh", "password": "fresh111"}).json()["token"]}
|
|
r = c.post("/api/auth/password", json={"old_password": "fresh111", "new_password": "fresh222"}, headers=h)
|
|
assert r.status_code == 200 and r.json()["token"]
|
|
assert c.get("/api/auth/me", headers=h).status_code == 401 # 旧 token 失效
|
|
h2 = {"Authorization": "Bearer " + r.json()["token"]}
|
|
assert c.get("/api/auth/me", headers=h2).json()["username"] == "pwfresh" # 新 token 直接可用
|