API вложений: файл от агента, скачивание, уборка копий (#83)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-22 14:34:10 +04:00
parent d2e68a9ab1
commit 722393daf9
6 changed files with 278 additions and 12 deletions

View File

@ -16,8 +16,8 @@
| `commands` | контракт типов команд, валидация, временные ключи, оценка, подтверждение по состоянию | `mirror` | | `commands` | контракт типов команд, валидация, временные ключи, оценка, подтверждение по состоянию | `mirror` |
| `queue` | пакеты, проверки постановки (`_check_setup`: `bug` без настройки, файл вложения; `_check_project`: проверки пакета проекта — метка, чужие метки, задачи вне проекта; `_assign_boards`: `board_id` проекта у `ensure_sprint`), выдача (`claim`, `size` у `ensure_attachment` и `boards` у `refresh` — `deliver_params`), отчёты (для `fetch_attachment` в `done` — проверка файла в хранилище и запись `attachment_file`), `request_fetch` (постановка скачивания вложения в одной транзакции, `TooLarge`, `AgentTooOld`; пакет без хвостового `refresh` и без резервной копии), отмена, повтор, автообновление | `commands`, `mirror`, `settings`, `files`, `projects`, `attachments`, `db` | | `queue` | пакеты, проверки постановки (`_check_setup`: `bug` без настройки, файл вложения; `_check_project`: проверки пакета проекта — метка, чужие метки, задачи вне проекта; `_assign_boards`: `board_id` проекта у `ensure_sprint`), выдача (`claim`, `size` у `ensure_attachment` и `boards` у `refresh` — `deliver_params`), отчёты (для `fetch_attachment` в `done` — проверка файла в хранилище и запись `attachment_file`), `request_fetch` (постановка скачивания вложения в одной транзакции, `TooLarge`, `AgentTooOld`; пакет без хвостового `refresh` и без резервной копии), отмена, повтор, автообновление | `commands`, `mirror`, `settings`, `files`, `projects`, `attachments`, `db` |
| `backup` | копии файла базы, ротация | `paths` | | `backup` | копии файла базы, ротация | `paths` |
| `files` | хранилище файлов вложений `<данные>/files/<sha256>` (каталог — параметр, `Hub.files_dir`): `put` (атомарно, одна копия на содержимое, mtime при повторе, предел 10 МБ, пустой — отказ), `path`/`exists`/`size`/`read` (только sha из 64 hex нижнего регистра), `sweep` (старше 30 дней, кроме файлов `ensure_attachment` в `queued`/`claimed`) | `paths` | | `files` | хранилище файлов вложений `<данные>/files/<sha256>` (каталог — параметр, `Hub.files_dir`): `put` (атомарно, одна копия на содержимое, mtime при повторе, предел 10 МБ, пустой — отказ, кроме `allow_empty` для файла от агента), `touch` (mtime при выдаче содержимого), `sweep_downloads` (каталоги копий MCP `downloads/<id>/`, старше 30 дней), `path`/`exists`/`size`/`read` (только sha из 64 hex нижнего регистра), `sweep` (старше 30 дней, кроме файлов `ensure_attachment` в `queued`/`claimed`) | `paths` |
| `api` | HTTP-сервер, маршруты, авторизация по токенам, фоновый `tick` (автообновление и уборка `files/` раз в сутки); маршруты файлов `POST /files`, `GET /agent/files/{sha256}` (каталог `Hub.files_dir`, 0700 при старте); раздача веб-приложения `/app…` (каталог `web/app` пакета, без токена) и перенаправление `/`, `/links`; снимок `GET /board`; маршруты `/projects` | `queue`, `mirror`, `projects`, `settings`, `backup`, `files`, `relay`, `release` | | `api` | HTTP-сервер, маршруты, авторизация по токенам, фоновый `tick` (автообновление и уборка `files/` раз в сутки); маршруты файлов `POST /files`, `GET /agent/files/{sha256}`, `POST /agent/files` (от агента, пустой допустим; каталог `Hub.files_dir`, 0700 при старте); вложения `GET /attachments/{id}`, `POST /attachments/{id}/fetch` (`queue.request_fetch`: `TooLarge` → 413, `AgentTooOld` → 409, нет → 404 `no_attachment`), `GET /attachments/{id}/content` (`Content-Disposition: filename*=UTF-8''…`, `nosniff`, `files.touch`); уборка раз в сутки трогает и `Hub.downloads_dir`; раздача веб-приложения `/app…` (каталог `web/app` пакета, без токена) и перенаправление `/`, `/links`; снимок `GET /board`; маршруты `/projects` | `queue`, `mirror`, `projects`, `settings`, `backup`, `files`, `attachments`, `relay`, `release` |
| `relay` | реле общей папки (настройка `relay_dir`): поток в `serve`, файлы `req/` → свой HTTP (адрес `serve`, для `0.0.0.0` — `127.0.0.1`) → `resp/`; агент с `hub_url = folder:<путь>` ходит через него; HTTP-обработчики о нём не знают | `settings` | | `relay` | реле общей папки (настройка `relay_dir`): поток в `serve`, файлы `req/` → свой HTTP (адрес `serve`, для `0.0.0.0` — `127.0.0.1`) → `resp/`; агент с `hub_url = folder:<путь>` ходит через него; HTTP-обработчики о нём не знают | `settings` |
| `release` | опубликованная версия агента: `release/agent.py` + `manifest.json` в каталоге данных, `publish` (только вверх; запись с fsync, прежний файл на время записи — `agent.py.prev`, при сбое возвращается), `current` (проверка манифеста, предупреждение один раз на ошибку), `load` (с проверкой sha256 и UTF-8), подсказка `hint` для `agent_status` | `paths` | | `release` | опубликованная версия агента: `release/agent.py` + `manifest.json` в каталоге данных, `publish` (только вверх; запись с fsync, прежний файл на время записи — `agent.py.prev`, при сбое возвращается), `current` (проверка манифеста, предупреждение один раз на ошибку), `load` (с проверкой sha256 и UTF-8), подсказка `hint` для `agent_status` | `paths` |
| `client` | HTTP-клиент хаба | `db`, `paths`, `settings` (только для `from_home`) | | `client` | HTTP-клиент хаба | `db`, `paths`, `settings` (только для `from_home`) |

View File

@ -9,7 +9,7 @@
- Хаб не знает пароль Jira и не ходит в Jira: учётные данные и сеть до Jira только у агента. - Хаб не знает пароль Jira и не ходит в Jira: учётные данные и сеть до Jira только у агента.
- Зеркало задач пишет только `mirror.apply_state` по загрузке состояния от агента (`POST /agent/state`); правки идут командами через очередь. - Зеркало задач пишет только `mirror.apply_state` по загрузке состояния от агента (`POST /agent/state`); правки идут командами через очередь.
- Хаб не создаёт команды сам: повтор после failed — новая команда по запросу человека/интерфейса. Команду, выданную агенту и оставшуюся без отчёта, хаб довыдаёт при следующем claim (до 3 выдач, затем failed). - Хаб не создаёт команды сам: повтор после failed — новая команда по запросу человека/интерфейса. Команду, выданную агенту и оставшуюся без отчёта, хаб довыдаёт при следующем claim (до 3 выдач, затем failed).
- Данные: `~/.local/share/jira-hub/` (`JIRA_HUB_HOME`): `hub.sqlite` (зеркало, очередь, журнал изменений `issue_change`), `backups/`, `release/` (опубликованная версия агента), `files/` (файлы вложений по sha256, уборка через 30 дней); каталог данных, `backups/`, `release/` и `files/` — права 0700. Порт по умолчанию 8790. - Данные: `~/.local/share/jira-hub/` (`JIRA_HUB_HOME`): `hub.sqlite` (зеркало, очередь, журнал изменений `issue_change`), `backups/`, `release/` (опубликованная версия агента), `files/` (файлы вложений по sha256, уборка через 30 дней), `downloads/` (копии вложений для MCP `<id>/<имя>`, уборка 30 дней); каталог данных, `backups/`, `release/` и `files/` — права 0700. Порт по умолчанию 8790.
- Два статических токена (в таблице `setting`): агентский открывает только `/agent/*`, клиентский — остальное. - Два статических токена (в таблице `setting`): агентский открывает только `/agent/*`, клиентский — остальное.
## Архитектура ## Архитектура

View File

@ -13,9 +13,9 @@ from datetime import timedelta
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path from pathlib import Path
from typing import NamedTuple from typing import NamedTuple
from urllib.parse import parse_qs, unquote, urlparse from urllib.parse import parse_qs, quote, unquote, urlparse
from . import backup, db, files, history, mirror, projects, queue, relay, release, settings from . import attachments, backup, db, files, history, mirror, projects, queue, relay, release, settings
from .commands import CommandError from .commands import CommandError
from .paths import backups_dir as default_backups_dir from .paths import backups_dir as default_backups_dir
from .paths import secure_dir from .paths import secure_dir
@ -31,6 +31,7 @@ ASSET_CACHE = {"Cache-Control": "public, max-age=31536000, immutable"} # им
NOT_BUILT = ("<!doctype html><meta charset=\"utf-8\"><title>Хаб Jira</title><p>Веб-приложение не собрано: выполните " NOT_BUILT = ("<!doctype html><meta charset=\"utf-8\"><title>Хаб Jira</title><p>Веб-приложение не собрано: выполните "
"<code>make web</code> в каталоге jira-hub и перезапустите хаб.</p>").encode("utf-8") "<code>make web</code> в каталоге jira-hub и перезапустите хаб.</p>").encode("utf-8")
SOCKET_TIMEOUT = 60 # чтение запроса; ожидание долгого claim идёт уже после чтения тела и сокет не держит SOCKET_TIMEOUT = 60 # чтение запроса; ожидание долгого claim идёт уже после чтения тела и сокет не держит
MIME_RE = re.compile(r"[\w!#$&^.+-]+/[\w!#$&^.+-]+") # тип/подтип без параметров и пробелов: заголовок не внушить
SWEEP_EVERY = 24 * 3600 # уборка files/ не чаще раза в сутки (hub-api «Хранилище файлов вложений») SWEEP_EVERY = 24 * 3600 # уборка files/ не чаще раза в сутки (hub-api «Хранилище файлов вложений»)
@ -54,6 +55,7 @@ class Hub:
self.backups_dir = Path(backups_dir) if backups_dir else default_backups_dir() self.backups_dir = Path(backups_dir) if backups_dir else default_backups_dir()
self.release_dir = Path(release_dir) if release_dir else self.db_path.parent / "release" self.release_dir = Path(release_dir) if release_dir else self.db_path.parent / "release"
self.files_dir = Path(files_dir) if files_dir else self.db_path.parent / "files" self.files_dir = Path(files_dir) if files_dir else self.db_path.parent / "files"
self.downloads_dir = self.db_path.parent / "downloads" # копии вложений для MCP; каталог создаёт MCP
self.last_sweep = None # время прошлой уборки files/, в памяти (решение 15) self.last_sweep = None # время прошлой уборки files/, в памяти (решение 15)
# последнее из claim; в памяти (R-3). reported — был ли claim с запуска хаба: до него launcher_version # последнее из claim; в памяти (R-3). reported — был ли claim с запуска хаба: до него launcher_version
# неизвестен, а не null (hub-hygiene-d, решение 7) # неизвестен, а не null (hub-hygiene-d, решение 7)
@ -96,6 +98,12 @@ class Hub:
removed = files.sweep(conn, self.files_dir, now) removed = files.sweep(conn, self.files_dir, now)
if removed: if removed:
log.info("уборка файлов: удалено %d", len(removed)) log.info("уборка файлов: удалено %d", len(removed))
try:
gone = files.sweep_downloads(self.downloads_dir, now)
if gone:
log.info("уборка копий вложений: удалено %d", len(gone))
except Exception: # noqa: BLE001 — копии MCP не должны мешать основной уборке
log.exception("уборка копий вложений: сбой")
return removed return removed
def note_agent_update(self, body): def note_agent_update(self, body):
@ -355,9 +363,9 @@ def h_batch_cancel(hub, conn, m, q, body):
raise ApiError(404, "not_found", "batch") raise ApiError(404, "not_found", "batch")
def h_files_post(hub, conn, m, q, body): def _put_file(hub, body, allow_empty=False):
"""Загрузка файла вложения: {content: base64} → {file: sha256, size}. Пробельные символы (переносы строк base64) """Общая часть POST /files и POST /agent/files: {content: base64} → {file: sha256, size}. Пробельные символы
отбрасываются до строгой проверки (решение 14).""" (переносы строк base64) отбрасываются до строгой проверки (решение 14)."""
content = body.get("content") content = body.get("content")
if not isinstance(content, str): if not isinstance(content, str):
raise ApiError(400, "bad_request", "content — строка base64") raise ApiError(400, "bad_request", "content — строка base64")
@ -366,7 +374,7 @@ def h_files_post(hub, conn, m, q, body):
except ValueError: # binascii.Error — подкласс ValueError except ValueError: # binascii.Error — подкласс ValueError
raise ApiError(400, "bad_request", "content — не base64") from None raise ApiError(400, "bad_request", "content — не base64") from None
try: try:
sha, size = files.put(hub.files_dir, data) sha, size = files.put(hub.files_dir, data, allow_empty=allow_empty)
except files.EmptyFile: except files.EmptyFile:
raise ApiError(422, "empty_file", "пустой файл") from None raise ApiError(422, "empty_file", "пустой файл") from None
except files.TooLarge: except files.TooLarge:
@ -374,6 +382,55 @@ def h_files_post(hub, conn, m, q, body):
return 200, {"file": sha, "size": size} return 200, {"file": sha, "size": size}
def h_files_post(hub, conn, m, q, body):
return _put_file(hub, body)
def h_agent_files_post(hub, conn, m, q, body):
"""Файл от агента (fetch_attachment, решение 5): как POST /files, но пустой файл принимается (решение 13)."""
return _put_file(hub, body, allow_empty=True)
def _attachment(hub, conn, m):
att = attachments.get(conn, hub.files_dir, m.group(1))
if att is None:
raise ApiError(404, "no_attachment", f"вложения #{m.group(1)} нет в зеркале — обновите задачу")
return att
def h_attachment_get(hub, conn, m, q, body):
return 200, _attachment(hub, conn, m)
def h_attachment_fetch(hub, conn, m, q, body):
author = body.get("author") or "api"
if not isinstance(author, str):
raise ApiError(422, "bad_request", "author — строка")
try:
return 200, queue.request_fetch(conn, hub.files_dir, m.group(1), author)
except KeyError:
raise ApiError(404, "no_attachment", f"вложения #{m.group(1)} нет в зеркале — обновите задачу") from None
except queue.TooLarge as e: # раньше CommandError: TooLarge — его подкласс (решение 17)
raise ApiError(413, "too_large", str(e)) from None
except queue.AgentTooOld as e:
raise ApiError(409, "agent_too_old", str(e)) from None
except CommandError as e:
raise ApiError(422, "bad_batch", str(e)) from None
def h_attachment_content(hub, conn, m, q, body):
"""Содержимое скачанного вложения; mime — из вложения, негодный → octet-stream; имя — filename* (RFC 5987)."""
att = _attachment(hub, conn, m)
row = conn.execute("SELECT file FROM attachment_file WHERE attachment_id = ?", (m.group(1),)).fetchone()
data = files.read(hub.files_dir, row["file"]) if att["state"] == "ready" and row else None
if data is None:
raise ApiError(404, "not_fetched", "вложение не скачано — запросите скачивание")
files.touch(hub.files_dir, row["file"])
mime = att["mime_type"] if isinstance(att["mime_type"], str) and MIME_RE.fullmatch(att["mime_type"]) else "application/octet-stream"
return 200, Raw(mime, data, {"Content-Disposition": "attachment; filename*=UTF-8''" + quote(att["filename"], safe=""),
"X-Content-Type-Options": "nosniff", "Cache-Control": "no-cache"})
def h_command_retry(hub, conn, m, q, body): def h_command_retry(hub, conn, m, q, body):
if not isinstance(body.get("author") or "api", str): if not isinstance(body.get("author") or "api", str):
raise ApiError(422, "bad_retry", "author — строка") raise ApiError(422, "bad_retry", "author — строка")
@ -496,6 +553,7 @@ ROUTES = [
("GET", r"^/agent/known-keys$", h_known_keys, "agent"), ("GET", r"^/agent/known-keys$", h_known_keys, "agent"),
("GET", r"^/agent/release$", h_agent_release, "agent"), ("GET", r"^/agent/release$", h_agent_release, "agent"),
("GET", r"^/agent/files/([^/]+)$", h_agent_file, "agent"), ("GET", r"^/agent/files/([^/]+)$", h_agent_file, "agent"),
("POST", r"^/agent/files$", h_agent_files_post, "agent"),
("GET", r"^/issues$", h_issues, "client"), ("GET", r"^/issues$", h_issues, "client"),
("GET", r"^/issues/([^/]+)$", h_issue, "client"), ("GET", r"^/issues/([^/]+)$", h_issue, "client"),
("GET", r"^/board$", h_board, "client"), ("GET", r"^/board$", h_board, "client"),
@ -506,6 +564,9 @@ ROUTES = [
("GET", r"^/batches/(\d+)$", h_batch_get, "client"), ("GET", r"^/batches/(\d+)$", h_batch_get, "client"),
("POST", r"^/batches/(\d+)/cancel$", h_batch_cancel, "client"), ("POST", r"^/batches/(\d+)/cancel$", h_batch_cancel, "client"),
("POST", r"^/files$", h_files_post, "client"), ("POST", r"^/files$", h_files_post, "client"),
("GET", r"^/attachments/(\d+)$", h_attachment_get, "client"),
("POST", r"^/attachments/(\d+)/fetch$", h_attachment_fetch, "client"),
("GET", r"^/attachments/(\d+)/content$", h_attachment_content, "client"),
("POST", r"^/commands/(\d+)/retry$", h_command_retry, "client"), ("POST", r"^/commands/(\d+)/retry$", h_command_retry, "client"),
("POST", r"^/commands/(\d+)/cancel$", h_command_cancel, "client"), ("POST", r"^/commands/(\d+)/cancel$", h_command_cancel, "client"),
("POST", r"^/refresh$", h_refresh, "client"), ("POST", r"^/refresh$", h_refresh, "client"),

View File

@ -4,6 +4,7 @@
import hashlib import hashlib
import os import os
import re import re
import shutil
import tempfile import tempfile
import time import time
from pathlib import Path from pathlib import Path
@ -65,12 +66,12 @@ def _intact(target, sha) -> bool:
return False return False
def put(root, data: bytes) -> tuple[str, int]: def put(root, data: bytes, allow_empty: bool = False) -> tuple[str, int]:
"""Сохранить содержимое, вернуть (sha256, размер). Целая копия уже есть (тот же sha256 содержимого) — только обновить """Сохранить содержимое, вернуть (sha256, размер). Целая копия уже есть (тот же sha256 содержимого) — только обновить
mtime (уборка считает возраст от последней загрузки); оборванная или испорченная — переписать (решение 13). Запись mtime (уборка считает возраст от последней загрузки); оборванная или испорченная — переписать (решение 13). Запись
атомарная: временный файл mkstemp в том же каталоге, fsync, os.replace; при сбое временный файл удаляется атомарная: временный файл mkstemp в том же каталоге, fsync, os.replace; при сбое временный файл удаляется
(решение 11).""" (решение 11). allow_empty — пустое содержимое принимается (файл от агента: в Jira бывают пустые вложения, решение 13)."""
if not data: if not data and not allow_empty:
raise EmptyFile("пустой файл") raise EmptyFile("пустой файл")
if len(data) > MAX_SIZE: if len(data) > MAX_SIZE:
raise TooLarge(f"файл больше {MAX_SIZE} байт ({len(data)})") raise TooLarge(f"файл больше {MAX_SIZE} байт ({len(data)})")
@ -97,6 +98,37 @@ def put(root, data: bytes) -> tuple[str, int]:
return sha, len(data) return sha, len(data)
def touch(root, sha) -> None:
"""Обновить mtime файла (выдача содержимого продлевает ему жизнь, решение 14); файла нет или sha негоден — ничего."""
if not is_sha(sha):
return
try:
os.utime(path(root, sha))
except FileNotFoundError:
pass
def sweep_downloads(root, now: float | None = None) -> list[str]:
"""Удалить каталоги копий MCP <root>/<attachment_id>/, в которых ничего не менялось KEEP_DAYS (новее из mtime каталога
и его файлов); вернуть имена. Файлы прямо в корне и сбой удаления одного каталога уборку не прерывают."""
root = Path(root)
if not root.is_dir():
return []
cutoff = (time.time() if now is None else now) - KEEP_DAYS * 86400
removed = []
for d in sorted(root.iterdir()):
try:
if d.is_symlink() or not d.is_dir():
continue
newest = max([d.stat().st_mtime] + [f.stat().st_mtime for f in d.iterdir() if f.is_file()])
if newest < cutoff:
shutil.rmtree(d)
removed.append(d.name)
except OSError:
continue
return removed
def sweep(conn, root, now: float | None = None) -> list[str]: def sweep(conn, root, now: float | None = None) -> list[str]:
"""Удалить файлы старше KEEP_DAYS (по mtime), на которые не ссылается ни одна ensure_attachment в queued или """Удалить файлы старше KEEP_DAYS (по mtime), на которые не ссылается ни одна ensure_attachment в queued или
claimed; вернуть имена удалённых. Правило одно для всех обычных файлов каталога: брошенные временные .*.tmp уходят claimed; вернуть имена удалённых. Правило одно для всех обычных файлов каталога: брошенные временные .*.tmp уходят

View File

@ -1030,3 +1030,142 @@ def test_issue_has_attachments(hub):
got = c.get("/issues/A-1") got = c.get("/issues/A-1")
assert got["attachments"][0]["attachment_id"] == "7" and got["attachments"][0]["state"] == "none" assert got["attachments"][0]["attachment_id"] == "7" and got["attachments"][0]["state"] == "none"
assert got["description_attachment_ids"] == ["7"] assert got["description_attachment_ids"] == ["7"]
# --- вложения из Jira по запросу (#83) ----------------------------------------------
ATT_NAME = "скрин 1.png"
def _seed_attachment(hub, mime="image/png", size=3, name=ATT_NAME):
conn = hub["hub"].conn()
try:
from jira_hub import mirror
mirror.apply_state(conn, [{"key": "A-1", "summary": "т", "labels": [], "attachments": [
{"id": "31337", "filename": name, "size": size, "mime_type": mime, "created": "2026-10-20T10:00:00.000+0300"}]}], [])
finally:
conn.close()
def _content(hub, path):
req = urllib.request.Request(hub["base"] + path, headers={"Authorization": "Bearer " + hub["client"].token})
return urllib.request.urlopen(req)
def _fetch_done(hub, data=b"abc"):
"""Запрос скачивания, claim агентом, загрузка файла и отчёт done."""
out = hub["client"].post("/attachments/31337/fetch", {"author": "web"})
cmd = hub["agent"].post("/agent/claim", {})["command"]
up = hub["agent"].post("/agent/files", {"content": _b64(data)})
hub["agent"].post(f"/agent/commands/{cmd['id']}/result", {"status": "done", "result": up})
return out, cmd, up
def test_agent_files_post(hub):
a = hub["agent"]
out = a.post("/agent/files", {"content": _b64(b"abc")})
assert out == {"file": hashlib.sha256(b"abc").hexdigest(), "size": 3}
with pytest.raises(HubError) as e:
hub["client"].post("/agent/files", {"content": _b64(b"abc")})
assert e.value.status == 401
assert a.post("/agent/files", {"content": ""}) == {"file": hashlib.sha256(b"").hexdigest(), "size": 0}
with pytest.raises(HubError) as e:
a.post("/agent/files", {"content": _b64(b"\0" * (11 * 1024 * 1024))})
assert e.value.status == 413 and e.value.error == "too_large"
with pytest.raises(HubError) as e:
a.post("/agent/files", {"content": "***"})
assert e.value.status == 400
def test_attachment_get_404(hub):
for method, path in (("get", "/attachments/1"), ("post", "/attachments/1/fetch"), ("get", "/attachments/1/content")):
with pytest.raises(HubError) as e:
getattr(hub["client"], method)(path)
assert e.value.status == 404 and e.value.error == "no_attachment"
def test_attachment_fetch_flow(hub):
_seed_attachment(hub)
out, cmd, up = _fetch_done(hub)
assert out["state"] == "fetching" and out["command_id"] and out["batch_id"]
assert cmd["type"] == "fetch_attachment" and cmd["pause_seconds"] == 2
got = hub["client"].get("/attachments/31337")
assert got["state"] == "ready" and got["filename"] == ATT_NAME and got["key"] == "A-1"
resp = _content(hub, "/attachments/31337/content")
assert resp.status == 200 and resp.read() == b"abc"
assert resp.headers["Content-Type"] == "image/png"
assert resp.headers["Content-Disposition"] == "attachment; filename*=UTF-8''%D1%81%D0%BA%D1%80%D0%B8%D0%BD%201.png"
assert resp.headers["X-Content-Type-Options"] == "nosniff"
assert resp.headers["Content-Length"] == "3"
def test_attachment_content_updates_mtime(hub):
_seed_attachment(hub)
_, _, up = _fetch_done(hub)
p = hub["hub"].files_dir / up["file"]
old = time.time() - 10 * 86400
os.utime(p, (old, old))
_content(hub, "/attachments/31337/content").read()
assert p.stat().st_mtime > time.time() - 60
def test_attachment_content_special_name(hub):
name = "a:b/c d\u00e9.txt"
_seed_attachment(hub, name=name, mime="text/plain")
_fetch_done(hub)
resp = _content(hub, "/attachments/31337/content")
assert resp.headers["Content-Disposition"] == "attachment; filename*=UTF-8''a%3Ab%2Fc%20d%C3%A9.txt"
def test_attachment_content_not_fetched(hub):
_seed_attachment(hub)
with pytest.raises(HubError) as e:
hub["client"].get("/attachments/31337/content")
assert e.value.status == 404 and e.value.error == "not_fetched"
def test_attachment_content_bad_mime(hub):
_seed_attachment(hub, mime="weird")
_fetch_done(hub)
assert _content(hub, "/attachments/31337/content").headers["Content-Type"] == "application/octet-stream"
def test_attachment_fetch_twice_same_command(hub):
_seed_attachment(hub)
a = hub["client"].post("/attachments/31337/fetch", {})
b = hub["client"].post("/attachments/31337/fetch", {})
assert a["command_id"] == b["command_id"] and b["state"] == "fetching"
def test_attachment_fetch_too_large_413(hub):
_seed_attachment(hub, size=25_000_000)
with pytest.raises(HubError) as e:
hub["client"].post("/attachments/31337/fetch", {})
assert e.value.status == 413 and e.value.error == "too_large"
assert e.value.detail == "файл больше 10 МБ (23,8 МБ) — скачайте в Jira"
assert hub["client"].get("/batches")["batches"] == []
def test_attachment_fetch_agent_too_old_409(hub):
_seed_attachment(hub)
hub["agent"].post("/agent/claim", {"agent_version": 13})
with pytest.raises(HubError) as e:
hub["client"].post("/attachments/31337/fetch", {})
assert e.value.status == 409 and e.value.error == "agent_too_old"
assert e.value.detail == "агент v13 не умеет скачивать вложения — нужен v14"
def test_sweep_files_downloads(hub):
h = hub["hub"]
d = h.downloads_dir / "31337"
d.mkdir(parents=True)
(d / "a.png").write_bytes(b"x")
old = time.time() - 31 * 86400
os.utime(d, (old, old))
os.utime(d / "a.png", (old, old))
conn = h.conn()
try:
h.sweep_files(conn)
finally:
conn.close()
assert not d.exists()

View File

@ -155,3 +155,37 @@ def test_put_rewrites_corrupted_copy_of_same_size(root):
(root / ABC).write_bytes(b"xyz") # тот же размер, другое содержимое (root / ABC).write_bytes(b"xyz") # тот же размер, другое содержимое
assert files.put(root, b"abc") == (ABC, 3) assert files.put(root, b"abc") == (ABC, 3)
assert (root / ABC).read_bytes() == b"abc" assert (root / ABC).read_bytes() == b"abc"
# --- allow_empty, touch, sweep_downloads (#83) ---------------------------------
def test_put_allow_empty(root):
assert files.put(root, b"", allow_empty=True) == (hashlib.sha256(b"").hexdigest(), 0)
with pytest.raises(files.EmptyFile):
files.put(root, b"")
def test_touch_updates_mtime(root):
sha, _ = files.put(root, b"abc")
old = time.time() - 5 * DAY
os.utime(root / sha, (old, old))
files.touch(root, sha)
assert (root / sha).stat().st_mtime > time.time() - 60
files.touch(root, "0" * 64) # файла нет — не ошибка
files.touch(root, "../x") # негодный sha — не ошибка
def test_sweep_downloads(tmp_path):
root = tmp_path / "downloads"
old, fresh = root / "31337", root / "31338"
for d in (old, fresh):
d.mkdir(parents=True)
(d / "a.png").write_bytes(b"x")
(root / "loose.txt").write_text("x")
t = time.time() - 31 * DAY
os.utime(old / "a.png", (t, t))
os.utime(old, (t, t))
os.utime(root / "loose.txt", (t, t))
assert files.sweep_downloads(root) == ["31337"]
assert not old.exists() and fresh.exists() and (root / "loose.txt").exists()
assert files.sweep_downloads(tmp_path / "нет") == []