424 lines
16 KiB
Python
424 lines
16 KiB
Python
import http.client
|
||
import json
|
||
import os
|
||
import shutil
|
||
import threading
|
||
import time
|
||
from datetime import datetime, timedelta, timezone
|
||
|
||
import pytest
|
||
|
||
from sdlc_hub.core import api, relay, settings
|
||
from sdlc_hub.core.client import HubClient
|
||
|
||
|
||
@pytest.fixture
|
||
def env(tmp_path):
|
||
h = api.Hub(tmp_path / "hub.sqlite", backups_dir=tmp_path / "backups")
|
||
h.claim_wait = 0
|
||
conn = h.conn()
|
||
root = tmp_path / "bridge"
|
||
root.mkdir()
|
||
settings.set_(conn, "relay_dir", str(root))
|
||
tok = settings.tokens(conn)
|
||
conn.close()
|
||
srv = api.make_server(h, "127.0.0.1", 0)
|
||
th = threading.Thread(target=srv.serve_forever, daemon=True)
|
||
th.start()
|
||
r = relay.Relay(h, srv.server_port)
|
||
client = HubClient(f"http://127.0.0.1:{srv.server_port}", tok["client"])
|
||
yield {"hub": h, "root": root, "relay": r, "agent": "Bearer " + tok["agents"]["jira"], "client": client,
|
||
"port": srv.server_port}
|
||
srv.shutdown()
|
||
srv.server_close()
|
||
th.join(5)
|
||
|
||
|
||
def deadline(seconds):
|
||
return (datetime.now(timezone.utc) + timedelta(seconds=seconds)).isoformat()
|
||
|
||
|
||
def put_request(root, rid, method, path, auth, body=None, seconds=60, **extra):
|
||
req = {"id": rid, "method": method, "path": path, "body": body, "authorization": auth,
|
||
"deadline": deadline(seconds), **extra}
|
||
relay.write_atomic(root / "req", f"{rid}.json", req)
|
||
|
||
|
||
def response(root, rid):
|
||
p = root / "resp" / f"{rid}.json"
|
||
return json.loads(p.read_text(encoding="utf-8")) if p.exists() else None
|
||
|
||
|
||
def run_once(r):
|
||
r.step()
|
||
r.join()
|
||
|
||
|
||
def test_request_roundtrip(env):
|
||
r, root = env["relay"], env["root"]
|
||
r.step() # подготовка: req/, resp/, work/
|
||
put_request(root, "a1", "GET", "/agent/settings", env["agent"])
|
||
run_once(r)
|
||
resp = response(root, "a1")
|
||
assert resp["status"] == 200 and resp["id"] == "a1"
|
||
assert json.loads(resp["body"])["project_key"] == "AURORA"
|
||
assert not list((root / "work").iterdir()) and not list((root / "req").iterdir())
|
||
|
||
|
||
def test_status_passes_through(env):
|
||
r, root = env["relay"], env["root"]
|
||
r.step()
|
||
put_request(root, "u1", "GET", "/issues", env["agent"]) # агентский токен на клиентский маршрут
|
||
put_request(root, "u2", "GET", "/agent/commands/999", env["agent"])
|
||
run_once(r)
|
||
assert response(root, "u1")["status"] == 401
|
||
assert response(root, "u2")["status"] == 404
|
||
|
||
|
||
def test_expired_and_short_claim_dropped(env):
|
||
r, root, c = env["relay"], env["root"], env["client"]
|
||
b = c.post("/batches", {"title": "t", "author": "test",
|
||
"commands": [{"type": "ensure_fields", "params": {"key": "A-1", "duedate": "2026-10-23"}}]})
|
||
r.step()
|
||
put_request(root, "c1", "POST", "/agent/claim", env["agent"], body={}, seconds=20)
|
||
put_request(root, "s1", "GET", "/agent/settings", env["agent"], seconds=-1)
|
||
run_once(r)
|
||
assert response(root, "c1") is None and response(root, "s1") is None
|
||
statuses = [x["status"] for x in c.get(f"/batches/{b['id']}")["commands"]]
|
||
assert statuses[0] == "queued"
|
||
assert not list((root / "work").iterdir())
|
||
|
||
|
||
def test_claim_with_enough_time_is_forwarded(env):
|
||
r, root = env["relay"], env["root"]
|
||
r.step()
|
||
put_request(root, "c2", "POST", "/agent/claim", env["agent"], body={"agent_version": 2}, seconds=50)
|
||
run_once(r)
|
||
assert response(root, "c2")["status"] == 200
|
||
|
||
|
||
def test_long_claim_does_not_block_other_requests(env):
|
||
env["hub"].claim_wait = 3
|
||
r, root = env["relay"], env["root"]
|
||
r.step()
|
||
put_request(root, "c3", "POST", "/agent/claim", env["agent"], body={}, seconds=60)
|
||
r.step()
|
||
put_request(root, "s3", "GET", "/agent/settings", env["agent"])
|
||
t0 = time.monotonic()
|
||
while response(root, "s3") is None and time.monotonic() - t0 < 2.5:
|
||
r.step()
|
||
time.sleep(0.05)
|
||
assert response(root, "s3")["status"] == 200
|
||
assert response(root, "c3") is None # claim ещё ждёт
|
||
r.join()
|
||
assert response(root, "c3")["status"] == 200
|
||
|
||
|
||
def test_unreachable_hub_gives_502(env):
|
||
r = relay.Relay(env["hub"], 1) # на порту 1 никто не слушает
|
||
root = env["root"]
|
||
r.step()
|
||
put_request(root, "p1", "GET", "/agent/settings", env["agent"])
|
||
run_once(r)
|
||
resp = response(root, "p1")
|
||
assert resp["status"] == 502 and "хаб недоступен" in resp["body"]
|
||
|
||
|
||
def test_dotfiles_ignored_and_bad_json_dropped(env, monkeypatch, capsys):
|
||
monkeypatch.setattr(relay, "BAD_JSON_WAIT", 0.2)
|
||
r, root = env["relay"], env["root"]
|
||
r.step()
|
||
(root / "req" / ".x1.json.tmp").write_text("{}", encoding="utf-8")
|
||
(root / "req" / "bad.json").write_text("{не json", encoding="utf-8")
|
||
run_once(r)
|
||
assert (root / "req" / ".x1.json.tmp").exists()
|
||
assert response(root, "bad") is None and not list((root / "work").iterdir())
|
||
assert "нечитаемый запрос bad.json" in capsys.readouterr().out
|
||
|
||
|
||
def test_bad_request_fields_get_400(env):
|
||
r, root = env["relay"], env["root"]
|
||
r.step()
|
||
put_request(root, "b1", "GET", "agent/settings", env["agent"]) # путь без /
|
||
put_request(root, "b2", "DELETE", "/agent/settings", env["agent"]) # метод не из списка
|
||
put_request(root, "b3", "GET", "/agent/settings", "Bearer x\r\nX-Evil: 1") # перевод строки в заголовке
|
||
run_once(r)
|
||
for rid in ("b1", "b2", "b3"):
|
||
assert response(root, rid)["status"] == 400, rid
|
||
|
||
|
||
def test_missing_deadline_is_dropped(env):
|
||
r, root = env["relay"], env["root"]
|
||
r.step()
|
||
relay.write_atomic(root / "req", "d1.json", {"id": "d1", "method": "GET", "path": "/agent/settings",
|
||
"authorization": env["agent"]})
|
||
relay.write_atomic(root / "req", "d2.json", {"id": "d2", "method": "GET", "path": "/agent/settings",
|
||
"authorization": env["agent"], "deadline": "завтра"})
|
||
run_once(r)
|
||
assert response(root, "d1") is None and response(root, "d2") is None
|
||
|
||
|
||
def test_vanished_request_is_skipped(env, capsys):
|
||
r, root = env["relay"], env["root"]
|
||
r.step()
|
||
put_request(root, "v1", "GET", "/agent/settings", env["agent"])
|
||
orig = os.rename
|
||
|
||
def vanish(src, dst):
|
||
os.remove(src) # агент удалил свой запрос раньше реле
|
||
return orig(src, dst)
|
||
|
||
os.rename = vanish
|
||
try:
|
||
run_once(r)
|
||
finally:
|
||
os.rename = orig
|
||
assert response(root, "v1") is None
|
||
assert "Traceback" not in capsys.readouterr().err
|
||
|
||
|
||
def test_work_cleared_on_prepare(env, capsys):
|
||
r, root = env["relay"], env["root"]
|
||
(root / "work").mkdir()
|
||
(root / "work" / "old.json").write_text("{}", encoding="utf-8")
|
||
r.step()
|
||
assert not (root / "work" / "old.json").exists()
|
||
assert "old.json" in capsys.readouterr().out
|
||
|
||
|
||
def test_missing_dir_warns_once_then_works(env, capsys):
|
||
r, root = env["relay"], env["root"]
|
||
gone = root.parent / "bridge-later"
|
||
conn = env["hub"].conn()
|
||
settings.set_(conn, "relay_dir", str(gone))
|
||
conn.close()
|
||
for _ in range(5):
|
||
r.step()
|
||
out = capsys.readouterr().out
|
||
assert out.count("нет — жду") == 1
|
||
gone.mkdir()
|
||
r.step()
|
||
assert (gone / "req").is_dir() and (gone / "resp").is_dir() and (gone / "work").is_dir()
|
||
put_request(gone, "m1", "GET", "/agent/settings", env["agent"])
|
||
run_once(r)
|
||
assert response(gone, "m1")["status"] == 200
|
||
|
||
|
||
def test_disabled_and_reenabled_without_restart(env, monkeypatch):
|
||
r, root = env["relay"], env["root"]
|
||
conn = env["hub"].conn()
|
||
settings.set_(conn, "relay_dir", None)
|
||
conn.close()
|
||
r.step()
|
||
assert r.root is None and not (root / "req").exists()
|
||
monkeypatch.setattr(relay, "SETTINGS_EVERY", 0)
|
||
conn = env["hub"].conn()
|
||
settings.set_(conn, "relay_dir", str(root))
|
||
conn.close()
|
||
r.step()
|
||
assert r.root == root and (root / "req").is_dir()
|
||
|
||
|
||
def test_cleanup_by_age(env):
|
||
r, root = env["relay"], env["root"]
|
||
r.step()
|
||
old, fresh = root / "resp" / "old.json", root / "resp" / "fresh.json"
|
||
tmp_old = root / "resp" / ".t.json.tmp"
|
||
for p in (old, fresh, tmp_old):
|
||
p.write_text("{}", encoding="utf-8")
|
||
now = time.time()
|
||
os.utime(old, (now - 700, now - 700))
|
||
os.utime(tmp_old, (now - 700, now - 700))
|
||
stale_req = root / "req" / ".q.json.tmp"
|
||
stale_req.write_text("{}", encoding="utf-8")
|
||
os.utime(stale_req, (now - 700, now - 700))
|
||
r.cleanup(now)
|
||
assert not old.exists() and not tmp_old.exists() and not stale_req.exists()
|
||
assert fresh.exists()
|
||
|
||
|
||
def test_log_line(env, capsys):
|
||
r, root = env["relay"], env["root"]
|
||
r.step()
|
||
put_request(root, "l1", "GET", "/agent/settings", env["agent"])
|
||
run_once(r)
|
||
assert "relay GET /agent/settings 200" in capsys.readouterr().out
|
||
|
||
|
||
def test_serve_runs_relay(tmp_path):
|
||
h = api.Hub(tmp_path / "hub.sqlite", backups_dir=tmp_path / "backups")
|
||
root = tmp_path / "bridge"
|
||
root.mkdir()
|
||
conn = h.conn()
|
||
settings.set_(conn, "relay_dir", str(root))
|
||
auth = "Bearer " + settings.tokens(conn)["agents"]["jira"]
|
||
conn.close()
|
||
srv, stop = api.start(h, "127.0.0.1", 0) # реле стучится на тот же хост, что слушает HTTP
|
||
th = threading.Thread(target=srv.serve_forever, daemon=True)
|
||
th.start()
|
||
try:
|
||
t0 = time.monotonic()
|
||
while not (root / "req").is_dir() and time.monotonic() - t0 < 3:
|
||
time.sleep(0.05)
|
||
put_request(root, "e1", "GET", "/agent/settings", auth)
|
||
while response(root, "e1") is None and time.monotonic() - t0 < 5:
|
||
time.sleep(0.05)
|
||
assert response(root, "e1")["status"] == 200
|
||
finally:
|
||
stop.set()
|
||
srv.shutdown()
|
||
srv.server_close()
|
||
th.join(5)
|
||
|
||
|
||
def test_permission_denied_warns_once_then_works(env, capsys):
|
||
r, root = env["relay"], env["root"]
|
||
locked = root.parent / "locked"
|
||
target = locked / "bridge"
|
||
target.mkdir(parents=True)
|
||
conn = env["hub"].conn()
|
||
settings.set_(conn, "relay_dir", str(target))
|
||
conn.close()
|
||
locked.chmod(0) # так выглядит отказ доступа (TCC) у процесса launchd
|
||
try:
|
||
for _ in range(5):
|
||
r.step()
|
||
finally:
|
||
locked.chmod(0o755)
|
||
out, err = capsys.readouterr()
|
||
assert out.count("нет доступа к папке") == 1
|
||
assert "Traceback" not in err
|
||
r.step()
|
||
put_request(target, "p1", "GET", "/agent/settings", env["agent"])
|
||
run_once(r)
|
||
assert response(target, "p1")["status"] == 200
|
||
|
||
|
||
def test_removed_req_dir_recovers(env, capsys):
|
||
r, root = env["relay"], env["root"]
|
||
r.step()
|
||
(root / "req").rmdir() # папку моста почистили при живом реле
|
||
for _ in range(3):
|
||
r.step()
|
||
assert (root / "req").is_dir()
|
||
assert "Traceback" not in capsys.readouterr().err
|
||
put_request(root, "q1", "GET", "/agent/settings", env["agent"])
|
||
run_once(r)
|
||
assert response(root, "q1")["status"] == 200
|
||
|
||
|
||
def test_removed_work_dir_recovers(env, capsys):
|
||
r, root = env["relay"], env["root"]
|
||
r.step()
|
||
(root / "work").rmdir()
|
||
put_request(root, "w1", "GET", "/agent/settings", env["agent"])
|
||
for _ in range(3):
|
||
r.step()
|
||
r.join()
|
||
assert response(root, "w1")["status"] == 200
|
||
assert "нет доступа к папке" in capsys.readouterr().out
|
||
|
||
|
||
def test_removed_resp_dir_recreated_before_forward(env):
|
||
r, root = env["relay"], env["root"]
|
||
r.step()
|
||
(root / "resp").rmdir()
|
||
put_request(root, "r1", "GET", "/agent/settings", env["agent"])
|
||
run_once(r)
|
||
assert response(root, "r1")["status"] == 200
|
||
|
||
|
||
def test_protocol_error_gives_502(env, monkeypatch):
|
||
r, root = env["relay"], env["root"]
|
||
r.step()
|
||
|
||
def broken(*a, **k):
|
||
raise http.client.BadStatusLine("мусор")
|
||
monkeypatch.setattr("urllib.request.urlopen", broken)
|
||
put_request(root, "b1", "GET", "/agent/settings", env["agent"])
|
||
run_once(r)
|
||
resp = response(root, "b1")
|
||
assert resp["status"] == 502 and "хаб недоступен" in resp["body"]
|
||
|
||
|
||
def test_reprepare_keeps_work(env, capsys):
|
||
r, root = env["relay"], env["root"]
|
||
r.step() # первая подготовка
|
||
(root / "work" / "busy.json").write_text("{}", encoding="utf-8")
|
||
(root / "req").rmdir() # пропал подкаталог — реле подготовит папку заново
|
||
for _ in range(3):
|
||
r.step()
|
||
assert (root / "req").is_dir() and (root / "work" / "busy.json").exists()
|
||
out = capsys.readouterr().out
|
||
assert "удалён незавершённый запрос" not in out and "подготовлена заново" in out
|
||
|
||
|
||
def test_vanished_tree_keeps_work(env, capsys):
|
||
r, root = env["relay"], env["root"]
|
||
r.step()
|
||
(root / "work" / "busy.json").write_text("{}", encoding="utf-8")
|
||
capsys.readouterr()
|
||
shutil.rmtree(root) # папка пропала целиком и появилась снова
|
||
root.mkdir()
|
||
(root / "work").mkdir()
|
||
(root / "work" / "busy.json").write_text("{}", encoding="utf-8") # взятый запрос, который ещё обрабатывается
|
||
for _ in range(3):
|
||
r.step()
|
||
assert (root / "work" / "busy.json").exists() and (root / "req").is_dir()
|
||
out = capsys.readouterr().out
|
||
assert "удалён незавершённый запрос" not in out and "подготовлена заново" in out # minor-3 #16
|
||
|
||
|
||
def test_each_dir_cleaned_once(env, tmp_path, capsys):
|
||
r, root, h = env["relay"], env["root"], env["hub"]
|
||
other = tmp_path / "other"
|
||
(other / "work").mkdir(parents=True)
|
||
(other / "work" / "old.json").write_text("{}", encoding="utf-8")
|
||
r.step() # root вычищен
|
||
conn = h.conn()
|
||
settings.set_(conn, "relay_dir", str(other))
|
||
r._settings_at = None
|
||
r.step()
|
||
assert not (other / "work" / "old.json").exists() # новая папка чистится
|
||
settings.set_(conn, "relay_dir", str(root))
|
||
r._settings_at = None
|
||
(root / "work" / "busy.json").write_text("{}", encoding="utf-8")
|
||
r.step()
|
||
conn.close()
|
||
assert (root / "work" / "busy.json").exists() # возврат A->B->A: A повторно не чистится
|
||
|
||
|
||
def test_relay_targets_serve_host(tmp_path, monkeypatch):
|
||
seen = []
|
||
|
||
class Spy(relay.Relay):
|
||
def __init__(self, hub, port, host="127.0.0.1", poll=0.1):
|
||
seen.append(host)
|
||
super().__init__(hub, port, host, poll)
|
||
|
||
monkeypatch.setattr(relay, "Relay", Spy)
|
||
h = api.Hub(tmp_path / "hub.sqlite", backups_dir=tmp_path / "backups")
|
||
for host in ("0.0.0.0", "localhost"):
|
||
srv, stop = api.start(h, host, 0)
|
||
stop.set()
|
||
srv.server_close()
|
||
assert seen == ["127.0.0.1", "localhost"] # конкретный адрес serve — туда же ходит реле (M-1 #8)
|
||
|
||
|
||
def test_symlink_to_same_dir_not_cleaned_again(env, tmp_path, capsys):
|
||
"""minor-1 #16, решение 12: relay_dir сменили на симлинк к той же папке — work/ не чистится второй раз."""
|
||
r, root, h = env["relay"], env["root"], env["hub"]
|
||
r.step() # root вычищен
|
||
link = tmp_path / "bridge-link"
|
||
link.symlink_to(root, target_is_directory=True)
|
||
(root / "work" / "busy.json").write_text("{}", encoding="utf-8") # взятый запрос ещё обрабатывается
|
||
conn = h.conn()
|
||
settings.set_(conn, "relay_dir", str(link))
|
||
conn.close()
|
||
r._settings_at = None
|
||
capsys.readouterr()
|
||
r.step()
|
||
out = capsys.readouterr().out
|
||
assert (root / "work" / "busy.json").exists()
|
||
assert "удалён незавершённый запрос" not in out and "подготовлена заново" in out
|