Merge branch 'claude/downloads-review-fixes-smcxyr' into claude/production-system-image-smcxyr
Этот коммит содержится в:
+272
-20
@@ -2,8 +2,11 @@
|
||||
проверка ссылок, фоновая загрузка с локального http-сервера, отмена."""
|
||||
import hashlib
|
||||
import lzma
|
||||
import socket
|
||||
import ssl
|
||||
import threading
|
||||
import time
|
||||
import urllib.error
|
||||
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
@@ -19,10 +22,12 @@ import features.downloads as downloads
|
||||
FIXTURES = Path(__file__).resolve().parent / "fixtures"
|
||||
MB = 1024 * 1024
|
||||
SLOW_TOTAL = 4 * MB
|
||||
CHUNKED_TOTAL = 40 * 64 * 1024
|
||||
|
||||
|
||||
class Handler(SimpleHTTPRequestHandler):
|
||||
"""Раздаёт файлы из каталога www; /slow.iso отдаёт 4 МБ по 64 КБ с паузами, /redirect.iso — перенаправляет."""
|
||||
"""Раздаёт файлы из каталога www; /slow.iso отдаёт 4 МБ по 64 КБ с паузами, /redirect.iso — перенаправляет,
|
||||
/chunked.iso — 2,5 МБ с паузами без заголовка Content-Length (размер заранее неизвестен)."""
|
||||
|
||||
root = ""
|
||||
|
||||
@@ -38,13 +43,15 @@ class Handler(SimpleHTTPRequestHandler):
|
||||
self.send_header("Location", "/small.iso")
|
||||
self.end_headers()
|
||||
return
|
||||
if self.path == "/slow.iso":
|
||||
if self.path in ("/slow.iso", "/chunked.iso"):
|
||||
total = SLOW_TOTAL if self.path == "/slow.iso" else CHUNKED_TOTAL
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/octet-stream")
|
||||
self.send_header("Content-Length", str(SLOW_TOTAL))
|
||||
if self.path == "/slow.iso":
|
||||
self.send_header("Content-Length", str(total))
|
||||
self.end_headers()
|
||||
try:
|
||||
for _ in range(SLOW_TOTAL // (64 * 1024)):
|
||||
for _ in range(total // (64 * 1024)):
|
||||
self.wfile.write(b"\0" * 64 * 1024)
|
||||
self.wfile.flush()
|
||||
time.sleep(0.05)
|
||||
@@ -87,6 +94,18 @@ def wait_until(predicate, timeout: float = 10.0) -> None:
|
||||
raise AssertionError("условие не наступило вовремя")
|
||||
|
||||
|
||||
def message_of(operation_id: str) -> str:
|
||||
return str((core.read_operation(operation_id) or {}).get("message", ""))
|
||||
|
||||
|
||||
def seed_running_download(kind: str, filename: str, **meta) -> dict:
|
||||
"""Операция «скачивается» без потока — как после перезапуска панели."""
|
||||
target = downloads.target_path(kind, filename)
|
||||
operation = core.new_operation("download", f"Скачивание {filename}", download_kind=kind, url=f"http://127.0.0.1:9/{filename}", filename=filename, target_path=str(target), cancel_requested=False, **meta)
|
||||
core.update_operation(operation, status="running")
|
||||
return operation
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- parsers & formatting
|
||||
def test_parse_checksums_ubuntu_star_format():
|
||||
text = (FIXTURES / "SHA256SUMS").read_text()
|
||||
@@ -130,12 +149,35 @@ def test_progress_message():
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- validation
|
||||
@pytest.mark.parametrize("url", ["", "ftp://example.org/x.iso", "file:///etc/passwd", "javascript:alert(1)", "http://", "http:///x.iso", "http://example.org/a b.iso"])
|
||||
@pytest.mark.parametrize("url", ["", "ftp://example.org/x.iso", "file:///etc/passwd", "javascript:alert(1)", "http://", "http:///x.iso", "http://example.org/a b.iso", "http://example.org:abc/x.iso", "http://[::1/x.iso"])
|
||||
def test_validate_url_rejects(url):
|
||||
with pytest.raises(downloads.DownloadError):
|
||||
downloads.validate_url(url)
|
||||
|
||||
|
||||
def test_encode_url_handles_cyrillic_and_keeps_encoded_links():
|
||||
encoded = downloads.encode_url("http://пример.рф:8080/папка/образ диска.iso?имя=ы#x")
|
||||
assert encoded == "http://xn--e1afmkfd.xn--p1ai:8080/%D0%BF%D0%B0%D0%BF%D0%BA%D0%B0/%D0%BE%D0%B1%D1%80%D0%B0%D0%B7%20%D0%B4%D0%B8%D1%81%D0%BA%D0%B0.iso?%D0%B8%D0%BC%D1%8F=%D1%8B"
|
||||
assert downloads.encode_url(encoded) == encoded
|
||||
for same in ("https://user:pw@h.example/a%20b/x.iso?a=1&b=%2F", "http://[::1]:9/x.iso", "http://127.0.0.1/x.iso"):
|
||||
assert downloads.encode_url(same) == same
|
||||
assert downloads.validate_url("https://пример.рф/образ.iso") == "https://пример.рф/образ.iso"
|
||||
|
||||
|
||||
def test_describe_network_error_is_friendly():
|
||||
describe = downloads.describe_network_error
|
||||
assert describe(urllib.error.HTTPError("u", 404, "Not Found", {}, None)) == "Файл по ссылке не найден (ошибка 404)"
|
||||
assert describe(urllib.error.HTTPError("u", 403, "Forbidden", {}, None)) == "Сервер запретил скачивание (ошибка 403)"
|
||||
assert describe(urllib.error.HTTPError("u", 418, "Teapot", {}, None)) == "Сервер ответил ошибкой 418"
|
||||
assert describe(urllib.error.URLError(socket.gaierror(-2, "Name or service not known"))) == "Не удалось найти сервер по этой ссылке — проверьте адрес"
|
||||
assert describe(urllib.error.URLError(ConnectionRefusedError(111, "Connection refused"))) == "Сервер отказал в подключении"
|
||||
assert describe(urllib.error.URLError(ssl.SSLCertVerificationError("CERTIFICATE_VERIFY_FAILED"))) == "У сервера проблема с сертификатом безопасности"
|
||||
assert describe(urllib.error.URLError(TimeoutError())).startswith("Сервер не отвечает")
|
||||
assert describe(UnicodeEncodeError("ascii", "образ", 0, 5, "ordinal not in range(128)")) == "Ссылка содержит символы, которые сервер не понимает"
|
||||
assert describe(ConnectionResetError(104, "reset")) == "Сервер разорвал соединение"
|
||||
assert describe(RuntimeError("boom")) == "boom"
|
||||
|
||||
|
||||
def test_filename_from_url_sanitises_and_checks_suffix():
|
||||
assert downloads.filename_from_url("https://h.example/a%20b%20(1).ISO?x=1#frag", "iso") == "a-b-_1.iso"
|
||||
assert downloads.filename_from_url("https://h.example/dir/debian-13.qcow2", "disk") == "debian-13.qcow2"
|
||||
@@ -152,6 +194,28 @@ def test_start_download_refuses_existing_file(data_dirs):
|
||||
downloads.start_download("http://127.0.0.1:9/small.iso", "iso")
|
||||
|
||||
|
||||
def test_start_download_refuses_when_extracted_image_exists(data_dirs):
|
||||
# Из pi.img.xz после скачивания остаётся pi.img (или pi.qcow2 после конвертации) — это тот же образ.
|
||||
(data_dirs["disk_images"] / "pi.img").write_bytes(b"x")
|
||||
with pytest.raises(downloads.DownloadError, match="pi.img уже есть"):
|
||||
downloads.start_download("http://127.0.0.1:9/pi.img.xz", "disk")
|
||||
(data_dirs["disk_images"] / "pi.img").unlink()
|
||||
(data_dirs["disk_images"] / "pi.qcow2").write_bytes(b"x")
|
||||
with pytest.raises(downloads.DownloadError, match="pi.qcow2 уже есть"):
|
||||
downloads.start_download("http://127.0.0.1:9/pi.img", "disk")
|
||||
|
||||
|
||||
def test_start_download_refuses_when_already_downloading(data_dirs, monkeypatch):
|
||||
seed_running_download("iso", "small.iso")
|
||||
with pytest.raises(downloads.DownloadError, match="уже скачивается"):
|
||||
downloads.start_download("http://127.0.0.1:9/small.iso", "iso")
|
||||
# проверка идёт по всем операциям, а не по последним 50
|
||||
for index in range(60):
|
||||
core.new_operation("vm_create", f"Создание VM {index}")
|
||||
with pytest.raises(downloads.DownloadError, match="уже скачивается"):
|
||||
downloads.start_download("http://127.0.0.1:9/small.iso", "iso")
|
||||
|
||||
|
||||
def test_start_download_checks_free_space(data_dirs, monkeypatch):
|
||||
monkeypatch.setattr(downloads.shutil, "disk_usage", lambda path: SimpleNamespace(free=1000, total=1000, used=0))
|
||||
with pytest.raises(downloads.DownloadError, match="мало места"):
|
||||
@@ -178,6 +242,7 @@ def test_download_iso_success_refreshes_pool(data_dirs, www, monkeypatch):
|
||||
assert not (data_dirs["iso"] / ".small.iso.part").exists()
|
||||
assert ["virsh", "pool-refresh", "virtuality-iso"] in calls
|
||||
assert "Готово" in done["message"]
|
||||
assert not downloads.is_live(operation["id"])
|
||||
|
||||
|
||||
def test_download_follows_redirect(data_dirs, www):
|
||||
@@ -188,11 +253,22 @@ def test_download_follows_redirect(data_dirs, www):
|
||||
assert "Перенаправление" in done["log_tail"]
|
||||
|
||||
|
||||
def test_download_with_cyrillic_file_name_in_url(data_dirs, www):
|
||||
(www.root / "образ.iso").write_bytes(b"cyrillic" * 10)
|
||||
operation = downloads.start_download(f"{www.url}/образ.iso", "iso")
|
||||
assert operation["filename"] == "virtuality-iso.iso"
|
||||
done = wait_for(operation["id"])
|
||||
assert done["status"] == "success", done
|
||||
assert (data_dirs["iso"] / "virtuality-iso.iso").read_bytes() == b"cyrillic" * 10
|
||||
assert "Перенаправление" not in done["log_tail"]
|
||||
|
||||
|
||||
def test_download_missing_file_fails(data_dirs, www):
|
||||
operation = downloads.start_download(f"{www.url}/nope.iso", "iso")
|
||||
done = wait_for(operation["id"])
|
||||
assert done["status"] == "error"
|
||||
assert "404" in done["message"]
|
||||
assert done["message"] == "Файл по ссылке не найден (ошибка 404)"
|
||||
assert "HTTP Error 404" in done["log_tail"] # исходный текст остаётся в журнале
|
||||
assert not list(data_dirs["iso"].iterdir())
|
||||
|
||||
|
||||
@@ -212,6 +288,24 @@ def test_download_refuses_when_disk_fills_up(data_dirs, www, monkeypatch):
|
||||
assert not list(data_dirs["iso"].iterdir())
|
||||
|
||||
|
||||
def test_download_without_content_length_stops_when_disk_fills_up(data_dirs, www, monkeypatch):
|
||||
real = downloads.free_space_ok
|
||||
seen = []
|
||||
|
||||
def fake(directory, needed):
|
||||
seen.append(needed)
|
||||
return real(directory, needed) if len(seen) < 3 else (False, 42)
|
||||
|
||||
monkeypatch.setattr(downloads, "free_space_ok", fake)
|
||||
operation = downloads.start_download(f"{www.url}/chunked.iso", "iso")
|
||||
done = wait_for(operation["id"])
|
||||
assert done["status"] == "error"
|
||||
assert "закончилось место" in done["message"] and "42 Б" in done["message"]
|
||||
assert done["total_bytes"] is None and seen == [None, None, None]
|
||||
assert not (data_dirs["iso"] / ".chunked.iso.part").exists()
|
||||
assert not list(data_dirs["iso"].iterdir())
|
||||
|
||||
|
||||
def test_download_cancel(data_dirs, www, logged_in):
|
||||
operation = downloads.start_download(f"{www.url}/slow.iso", "iso")
|
||||
wait_until(lambda: (core.read_operation(operation["id"]) or {}).get("status") == "running")
|
||||
@@ -224,7 +318,77 @@ def test_download_cancel(data_dirs, www, logged_in):
|
||||
assert done["cancelled"] is True and done["cancel_requested"] is True
|
||||
assert not (data_dirs["iso"] / ".slow.iso.part").exists()
|
||||
assert not (data_dirs["iso"] / "slow.iso").exists()
|
||||
assert not downloads.cancel_requested(operation["id"])
|
||||
assert not downloads.cancel_requested(operation["id"]) and not downloads.is_live(operation["id"])
|
||||
|
||||
|
||||
def test_cancel_flag_on_disk_is_honoured_and_not_overwritten(data_dirs, www):
|
||||
# Отмену могла принять другая копия панели: поток видит флаг в JSON операции раз в секунду.
|
||||
operation = downloads.start_download(f"{www.url}/slow.iso", "iso")
|
||||
wait_until(lambda: (data_dirs["iso"] / ".slow.iso.part").exists())
|
||||
fresh = core.read_operation(operation["id"])
|
||||
fresh.pop("log_tail", None)
|
||||
core.update_operation(fresh, cancel_requested=True, message="Останавливаем скачивание…")
|
||||
done = wait_for(operation["id"])
|
||||
assert done["status"] == "error" and done["cancelled"] is True and done["cancel_requested"] is True
|
||||
assert not (data_dirs["iso"] / ".slow.iso.part").exists()
|
||||
|
||||
|
||||
def test_second_download_of_same_file_does_not_touch_the_first(data_dirs, www):
|
||||
first = downloads.start_download(f"{www.url}/slow.iso", "iso")
|
||||
part = data_dirs["iso"] / ".slow.iso.part"
|
||||
wait_until(part.exists)
|
||||
# Второй запуск, проскочивший проверку «уже скачивается» (например, из другого процесса): операцию создаём руками.
|
||||
second = core.new_operation("download", "Скачивание slow.iso", download_kind="iso", url=f"{www.url}/slow.iso", filename="slow.iso", target_path=first["target_path"], cancel_requested=False)
|
||||
core.update_operation(second, status="running")
|
||||
downloads.download_worker(second)
|
||||
done = core.read_operation(second["id"])
|
||||
assert done["status"] == "error" and "уже скачивается" in done["message"]
|
||||
assert part.exists() # чужой файл не тронут
|
||||
assert wait_for(first["id"])["status"] == "success"
|
||||
assert (data_dirs["iso"] / "slow.iso").stat().st_size == SLOW_TOTAL
|
||||
|
||||
|
||||
def test_leftover_part_file_without_owner_does_not_block_download(data_dirs, www):
|
||||
part = data_dirs["iso"] / ".small.iso.part"
|
||||
part.write_bytes(b"half of a crashed download")
|
||||
operation = downloads.start_download(f"{www.url}/small.iso", "iso")
|
||||
done = wait_for(operation["id"])
|
||||
assert done["status"] == "success", done
|
||||
assert (data_dirs["iso"] / "small.iso").read_bytes() == b"ISO" * 5000
|
||||
assert not part.exists()
|
||||
|
||||
|
||||
def test_stale_downloads_are_reaped_on_startup(data_dirs, www, monkeypatch):
|
||||
stale = seed_running_download("iso", "small.iso")
|
||||
part = downloads.part_path(Path(stale["target_path"]))
|
||||
part.write_bytes(b"half")
|
||||
(data_dirs["disk_images"] / ".orphan.img.part").write_bytes(b"x")
|
||||
live = downloads.start_download(f"{www.url}/slow.iso", "iso")
|
||||
wait_until(lambda: (data_dirs["iso"] / ".slow.iso.part").exists())
|
||||
|
||||
assert downloads.reap_stale_downloads() == 1
|
||||
closed = core.read_operation(stale["id"])
|
||||
assert closed["status"] == "error" and closed["interrupted"] is True and "перезапущена" in closed["message"]
|
||||
assert not part.exists() and not (data_dirs["disk_images"] / ".orphan.img.part").exists()
|
||||
assert (data_dirs["iso"] / ".slow.iso.part").exists() # живое скачивание не трогаем
|
||||
assert wait_for(live["id"])["status"] == "success"
|
||||
# после закрытия зависшей операции тот же файл можно скачать снова
|
||||
done = wait_for(downloads.start_download(f"{www.url}/small.iso", "iso")["id"])
|
||||
assert done["status"] == "success" and (data_dirs["iso"] / "small.iso").read_bytes() == b"ISO" * 5000
|
||||
assert downloads.close_stale_downloads() is None
|
||||
|
||||
|
||||
def test_cancel_of_stale_download_finishes_it_immediately(data_dirs, logged_in):
|
||||
stale = seed_running_download("disk", "y.qcow2")
|
||||
part = downloads.part_path(Path(stale["target_path"]))
|
||||
part.write_bytes(b"x")
|
||||
response = logged_in.post(f"/api/operations/{stale['id']}/cancel")
|
||||
operation = response.json()["operation"]
|
||||
assert response.status_code == 200 and operation["status"] == "error" and operation["cancelled"] is True
|
||||
assert operation["message"] == "Скачивание отменено" and operation["cancel_requested"] is True
|
||||
assert not part.exists()
|
||||
assert not downloads.cancel_requested(stale["id"])
|
||||
assert logged_in.post(f"/api/operations/{stale['id']}/cancel").status_code == 409
|
||||
|
||||
|
||||
def test_catalog_download_verifies_checksum(data_dirs, www):
|
||||
@@ -258,6 +422,28 @@ def test_catalog_download_fails_without_sums_entry(data_dirs, www):
|
||||
assert not list(data_dirs["disk_images"].iterdir())
|
||||
|
||||
|
||||
def test_cancel_during_checksum_verification_removes_file(data_dirs, www, logged_in, monkeypatch):
|
||||
payload = b"QCOW2 image bytes" * 100
|
||||
(www.root / "disk.qcow2").write_bytes(payload)
|
||||
(www.root / "SHA256SUMS").write_text(f"{hashlib.sha256(payload).hexdigest()} *disk.qcow2\n")
|
||||
|
||||
def slow_digest(path, algo, on_progress=None):
|
||||
for _ in range(200):
|
||||
time.sleep(0.05)
|
||||
if on_progress:
|
||||
on_progress(1)
|
||||
return hashlib.sha256(payload).hexdigest()
|
||||
|
||||
monkeypatch.setattr(downloads, "file_digest", slow_digest)
|
||||
operation = downloads.start_download(f"{www.url}/disk.qcow2", "disk", sums_url=f"{www.url}/SHA256SUMS", algo="sha256")
|
||||
wait_until(lambda: "Проверяем контрольную сумму" in message_of(operation["id"]))
|
||||
assert logged_in.post(f"/api/operations/{operation['id']}/cancel").status_code == 200
|
||||
done = wait_for(operation["id"])
|
||||
assert done["status"] == "error" and done["cancelled"] is True and done["message"] == "Скачивание отменено"
|
||||
assert "во время проверки" in done["log_tail"]
|
||||
assert not list(data_dirs["disk_images"].iterdir())
|
||||
|
||||
|
||||
def test_disk_download_extracts_xz_and_starts_conversion(data_dirs, www, monkeypatch):
|
||||
raw = bytes(range(256)) * 256
|
||||
(www.root / "pi.img.xz").write_bytes(lzma.compress(raw))
|
||||
@@ -273,6 +459,55 @@ def test_disk_download_extracts_xz_and_starts_conversion(data_dirs, www, monkeyp
|
||||
assert "qcow2" in done["message"]
|
||||
|
||||
|
||||
def test_cancel_during_unpack_removes_archive_and_partial_image(data_dirs, www, logged_in, monkeypatch):
|
||||
raw = bytes(range(256)) * 4096 # 1 МБ, распаковка кусками по 16 КБ с паузой — успеваем отменить
|
||||
(www.root / "pi.img.xz").write_bytes(lzma.compress(raw))
|
||||
real = downloads.extract_xz_image
|
||||
|
||||
def slow_extract(compressed, should_stop=None):
|
||||
def stop():
|
||||
time.sleep(0.05)
|
||||
return bool(should_stop and should_stop())
|
||||
|
||||
return real(compressed, stop)
|
||||
|
||||
started = []
|
||||
monkeypatch.setattr(downloads, "extract_xz_image", slow_extract)
|
||||
monkeypatch.setattr(downloads, "CHUNK_SIZE", 16 * 1024)
|
||||
monkeypatch.setattr(app, "start_disk_convert_operation", lambda path: started.append(path))
|
||||
operation = downloads.start_download(f"{www.url}/pi.img.xz", "disk")
|
||||
wait_until(lambda: "Распаковываем" in message_of(operation["id"]))
|
||||
assert logged_in.post(f"/api/operations/{operation['id']}/cancel").status_code == 200
|
||||
done = wait_for(operation["id"])
|
||||
assert done["status"] == "error" and done["cancelled"] is True and done["message"] == "Скачивание отменено"
|
||||
assert "Распаковка остановлена" in done["log_tail"]
|
||||
assert not list(data_dirs["disk_images"].iterdir()) and started == []
|
||||
|
||||
|
||||
def test_corrupt_xz_archive_is_removed_and_can_be_downloaded_again(data_dirs, www, monkeypatch):
|
||||
(www.root / "bad.img.xz").write_bytes(b"definitely not an xz archive" * 100)
|
||||
started = []
|
||||
monkeypatch.setattr(app, "start_disk_convert_operation", lambda path: started.append(path))
|
||||
done = wait_for(downloads.start_download(f"{www.url}/bad.img.xz", "disk")["id"])
|
||||
assert done["status"] == "error"
|
||||
assert done["message"] == "Не удалось распаковать скачанный архив — файл повреждён и удалён. Попробуйте скачать ещё раз"
|
||||
assert "Ошибка распаковки" in done["log_tail"]
|
||||
assert not list(data_dirs["disk_images"].iterdir()) and started == []
|
||||
# архив убран, поэтому повторное скачивание не упирается в «уже есть на сервере»
|
||||
assert wait_for(downloads.start_download(f"{www.url}/bad.img.xz", "disk")["id"])["status"] == "error"
|
||||
|
||||
|
||||
def test_extract_xz_image_stops_and_cleans_up(data_dirs):
|
||||
raw = bytes(range(256)) * 1024
|
||||
compressed = data_dirs["disk_images"] / "pi.img.xz"
|
||||
compressed.write_bytes(lzma.compress(raw))
|
||||
assert downloads.extract_xz_image(compressed).read_bytes() == raw
|
||||
(data_dirs["disk_images"] / "pi.img").unlink()
|
||||
with pytest.raises(downloads.DownloadCancelled):
|
||||
downloads.extract_xz_image(compressed, lambda: True)
|
||||
assert sorted(item.name for item in data_dirs["disk_images"].iterdir()) == ["pi.img.xz"]
|
||||
|
||||
|
||||
def test_qcow2_download_needs_no_conversion(data_dirs, www, monkeypatch):
|
||||
(www.root / "ready.qcow2").write_bytes(b"q" * 10)
|
||||
monkeypatch.setattr(app, "start_disk_convert_operation", lambda path: None)
|
||||
@@ -282,13 +517,17 @@ def test_qcow2_download_needs_no_conversion(data_dirs, www, monkeypatch):
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- routes
|
||||
def test_url_route_starts_download(logged_in, data_dirs, www):
|
||||
def test_url_route_starts_download(logged_in, data_dirs, www, monkeypatch):
|
||||
# The worker is started by hand below: a 15 KB file would otherwise finish before the checks.
|
||||
pending = {}
|
||||
monkeypatch.setattr(downloads, "run_operation", lambda operation, worker: pending.update(operation=operation, worker=worker))
|
||||
response = logged_in.post("/downloads/url", data={"url": f"{www.url}/small.iso", "kind": "iso"}, follow_redirects=False)
|
||||
assert response.status_code == 303
|
||||
location = unquote(response.headers["location"])
|
||||
assert location.startswith("/iso?download_message=") and "small.iso" in location
|
||||
running = downloads.active_downloads("iso")
|
||||
assert len(running) == 1 and running[0]["filename"] == "small.iso"
|
||||
core.run_operation(pending["operation"], pending["worker"])
|
||||
assert wait_for(running[0]["id"])["status"] == "success"
|
||||
assert (data_dirs["iso"] / "small.iso").exists()
|
||||
|
||||
@@ -299,7 +538,7 @@ def test_url_route_rejects_bad_links(logged_in, data_dirs):
|
||||
assert "download_error=" in response.headers["location"] and response.headers["location"].startswith("/iso?")
|
||||
response = logged_in.post("/downloads/url", data={"url": "https://example.org/setup.exe", "kind": "disk"}, follow_redirects=False)
|
||||
assert response.headers["location"].startswith("/disk-images?download_error=")
|
||||
assert not core.running_operations("download")
|
||||
assert not core.running_operations("download") and not downloads.unfinished_downloads()
|
||||
|
||||
|
||||
def test_url_route_requires_login(client):
|
||||
@@ -340,7 +579,7 @@ def test_catalog_route_skips_missing_arch(logged_in, data_dirs, monkeypatch):
|
||||
monkeypatch.setattr(catalog, "CLOUD_IMAGES", [{"id": "only-arm", "title": "ARM only", "arches": {"aarch64": {"url": "https://example.org/a.qcow2"}}}])
|
||||
monkeypatch.setattr(downloads, "host_arch", lambda: "x86_64")
|
||||
location = unquote(logged_in.post("/downloads/catalog/only-arm", follow_redirects=False).headers["location"])
|
||||
assert "не выпускается для процессора x86_64" in location
|
||||
assert "нет версии для этого сервера (x86_64)" in location
|
||||
assert downloads.catalog_entries() == []
|
||||
assert "готовых образов в каталоге нет" in logged_in.get("/disk-images").text
|
||||
|
||||
@@ -353,33 +592,46 @@ def test_cancel_api_rules(logged_in, data_dirs):
|
||||
finished = core.new_operation("download", "Скачивание x", download_kind="iso", filename="x.iso")
|
||||
core.finish_operation(finished, True, "Готово")
|
||||
assert logged_in.post(f"/api/operations/{finished['id']}/cancel").status_code == 409
|
||||
# операция без потока и без файла (панель перезапускалась) закрывается сразу
|
||||
running = core.new_operation("download", "Скачивание y", download_kind="disk", filename="y.qcow2")
|
||||
core.update_operation(running, status="running")
|
||||
response = logged_in.post(f"/api/operations/{running['id']}/cancel")
|
||||
assert response.status_code == 200 and response.json()["operation"]["cancel_requested"] is True
|
||||
assert downloads.cancel_requested(running["id"])
|
||||
downloads.clear_cancel(running["id"])
|
||||
operation = response.json()["operation"]
|
||||
assert response.status_code == 200 and operation["cancel_requested"] is True and operation["status"] == "error" and operation["cancelled"] is True
|
||||
assert not downloads.cancel_requested(running["id"])
|
||||
|
||||
|
||||
def test_cancel_form_redirects_to_page(logged_in, data_dirs):
|
||||
running = core.new_operation("download", "Скачивание y", download_kind="disk", filename="y.qcow2")
|
||||
core.update_operation(running, status="running")
|
||||
response = logged_in.post(f"/downloads/{running['id']}/cancel", follow_redirects=False)
|
||||
def test_cancel_form_redirects_to_page(logged_in, data_dirs, www):
|
||||
stale = core.new_operation("download", "Скачивание y", download_kind="disk", filename="y.qcow2")
|
||||
core.update_operation(stale, status="running")
|
||||
response = logged_in.post(f"/downloads/{stale['id']}/cancel", follow_redirects=False)
|
||||
assert response.status_code == 303
|
||||
assert unquote(response.headers["location"]).startswith("/disk-images?download_message=Останавливаем скачивание y.qcow2")
|
||||
assert unquote(response.headers["location"]).startswith("/disk-images?download_message=Скачивание y.qcow2 отменено")
|
||||
assert core.read_operation(stale["id"])["status"] == "error"
|
||||
live = downloads.start_download(f"{www.url}/slow.iso", "iso")
|
||||
response = logged_in.post(f"/downloads/{live['id']}/cancel", follow_redirects=False)
|
||||
assert unquote(response.headers["location"]).startswith("/iso?download_message=Останавливаем скачивание slow.iso")
|
||||
assert wait_for(live["id"])["cancelled"] is True
|
||||
assert unquote(logged_in.post("/downloads/nope/cancel", follow_redirects=False).headers["location"]).startswith("/iso?download_error=")
|
||||
downloads.clear_cancel(running["id"])
|
||||
|
||||
|
||||
def test_pages_show_download_cards(logged_in, data_dirs):
|
||||
def test_pages_show_download_cards(logged_in, data_dirs, monkeypatch):
|
||||
reads = []
|
||||
real = downloads.unfinished_downloads
|
||||
monkeypatch.setattr(downloads, "unfinished_downloads", lambda: reads.append(1) or real())
|
||||
iso_page = logged_in.get("/iso").text
|
||||
assert "Скачать по ссылке" in iso_page and 'name="kind" value="iso"' in iso_page
|
||||
assert "Скачиваем на сервер" not in iso_page
|
||||
assert "не нужно сначала загружать его на компьютер" in iso_page
|
||||
disk_page = logged_in.get("/disk-images").text
|
||||
assert "Каталог готовых систем" in disk_page and "Рекомендуем" in disk_page and 'name="kind" value="disk"' in disk_page
|
||||
assert "подходящие этому серверу" in disk_page
|
||||
assert len(reads) == 2 # каталог операций читается один раз на страницу, а не в каждой вставке
|
||||
running = core.new_operation("download", "Скачивание big.iso", download_kind="iso", filename="big.iso", url="https://example.org/big.iso", total_bytes=None)
|
||||
core.update_operation(running, status="running", progress=0, message="Подключаемся к серверу…")
|
||||
iso_page = logged_in.get("/iso?download_message=Скачиваем").text
|
||||
assert "Скачиваем на сервер" in iso_page and "big.iso" in iso_page and "indeterminate" in iso_page
|
||||
assert f"/downloads/{running['id']}/cancel" in iso_page and "Скачиваем</span>" in iso_page
|
||||
# отмена — не ошибка: без тоста при отправке формы, опрос показывает её как предупреждение
|
||||
assert 'data-toast="Скачивание отменено"' not in iso_page and "op.cancelled" in iso_page
|
||||
assert "big.iso" not in logged_in.get("/disk-images").text
|
||||
|
||||
Ссылка в новой задаче
Block a user