wiki: маршруты и вложения по запросу (#91)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-24 02:40:33 +04:00
parent cd8f5505b0
commit a95230dc33
6 changed files with 529 additions and 7 deletions

View File

@ -1,6 +1,6 @@
"""Модуль wiki: Markdown-копия вики Eva по проектам, поиск, вложения по запросу."""
from ...core.modules import Module
from . import api, commands, projects, schema, settings, tick
from . import api, attachments, commands, projects, schema, settings, tick
MODULE = Module(
name="wiki",
@ -11,6 +11,7 @@ MODULE = Module(
validate_setting=settings.validate_setting,
commands=commands.HOOKS,
routes=api.ROUTES,
file_refs=attachments.file_refs,
tick=(tick.housekeeping_tick, tick.auto_sync_tick),
project_fields=projects.FIELDS,
project_view=projects.view,

View File

@ -1,11 +1,150 @@
"""HTTP модуля wiki: маршруты агента (приём пачек страниц)."""
from . import stage
"""HTTP модуля wiki: маршруты клиента (состояние, дерево, страница, поиск, синхронизация, вложения) и агента."""
import json
from urllib.parse import unquote
from ...core import db, files, queue
from ...core.http import ApiError, Raw, int_group, not_found, project_param
from . import attachments, commands, paths, search, stage
MIME = "application/octet-stream" # типа в базе вики нет; скачивание — всегда вложением, без угадывания
def h_agent_pages(hub, conn, m, q, body):
return 200, stage.accept(conn, body.get("command_id"), body.get("pages"))
def _project(conn, q) -> tuple[str, dict]:
"""(метка, строка wiki_project): без project — 422, неизвестный — 404, без корня — 409 no_root."""
label = project_param(conn, q)
if label is None:
raise ApiError(422, "bad_request", "нужен параметр project=<метка>")
row = conn.execute("SELECT * FROM wiki_project WHERE label = ?", (label,)).fetchone()
if row is None:
raise ApiError(409, "no_root", f"у проекта {label} нет корня вики")
return label, row
def _batch(conn, label):
b = commands.active_sync(conn, label)
if b is None:
row = conn.execute("SELECT id FROM batch b WHERE b.project = ? AND EXISTS (SELECT 1 FROM command c WHERE "
"c.batch_id = b.id AND c.type = 'wiki_sync') ORDER BY b.id DESC LIMIT 1", (label,)).fetchone()
if row is None:
return None
b = queue.batch_view(conn, row["id"], with_commands=False)
return {"id": b["id"], "status": b["status"]}
def h_status(hub, conn, m, q, body):
label, r = _project(conn, q)
return 200, {"root": r["root_code"], "root_title": r["root_title"], "synced_at": r["synced_at"],
"pages": r["pages_count"], "error": r["sync_error"], "batch": _batch(conn, label)}
def _walk_order(conn, label) -> list[str]:
"""Коды страниц в порядке обхода: по _tree.json копии, при его отсутствии — по пути."""
rows = {r["code"]: r for r in conn.execute("SELECT code, path FROM wiki_page WHERE label = ?", (label,))}
try:
tree = json.loads((paths.project_dir(conn, label) / "_tree.json").read_text(encoding="utf-8"))
order = [t["code"] for t in tree if t["code"] in rows]
if len(order) == len(rows):
return order
except (OSError, ValueError, RuntimeError, KeyError, TypeError):
pass
return sorted(rows, key=lambda c: (rows[c]["path"] or "").split("/"))
def h_tree(hub, conn, m, q, body):
label, _ = _project(conn, q)
rows = {r["code"]: r for r in conn.execute("SELECT * FROM wiki_page WHERE label = ?", (label,))}
return 200, {"pages": [{"code": c, "title": rows[c]["title"], "depth": rows[c]["depth"],
"parent_code": rows[c]["parent_code"], "size": rows[c]["size"]} for c in _walk_order(conn, label)]}
def h_page(hub, conn, m, q, body):
label, _ = _project(conn, q)
code = unquote(m.group(1))
r = not_found(conn.execute("SELECT * FROM wiki_page WHERE label = ? AND code = ?", (label, code)).fetchone(), f"страница {code}")
try:
text = (paths.project_dir(conn, label) / r["path"] / "index.md").read_text(encoding="utf-8")
except (OSError, RuntimeError, TypeError):
raise ApiError(404, "not_found", f"файл страницы {code} не найден в копии") from None
if text.startswith("---\n"): # front matter копии клиенту не нужен
end = text.find("\n---\n", 4)
text = text[end + 5:].lstrip("\n") if end != -1 else text
atts = [{"id": a["id"], "name": a["name"], "state": a["state"], "size": a["size"]} for a in conn.execute(
"SELECT id, name, state, size FROM wiki_attachment WHERE label = ? AND page_code = ? ORDER BY id", (label, code))]
return 200, {"code": code, "title": r["title"], "path": r["path"], "url": r["url"], "synced_at": r["synced_at"],
"markdown": text, "attachments": atts}
def h_search(hub, conn, m, q, body):
label, _ = _project(conn, q)
text = q.get("q") or ""
if not text.strip():
raise ApiError(422, "bad_query", "q — непустой запрос")
try:
limit = int(q.get("limit", 10))
except (TypeError, ValueError):
raise ApiError(422, "bad_limit", "limit — целое от 1 до 50") from None
if not 1 <= limit <= 50:
raise ApiError(422, "bad_limit", "limit — целое от 1 до 50")
return 200, {"results": search.query(conn, label, text, limit)}
def h_sync(hub, conn, m, q, body):
q = dict(q, project=body.get("project"))
author = body.get("author") or "api"
if not isinstance(author, str):
raise ApiError(422, "bad_request", "author — строка")
with db.transaction(conn):
label, r = _project(conn, q)
active = commands.active_sync(conn, label)
if active is not None:
raise ApiError(409, "sync_in_progress", {"batch_id": active["id"]})
batch = commands.enqueue_sync(conn, label, r["root_code"], author)
return 201, {"batch_id": batch["id"]}
def _attachment(conn, m) -> dict:
return not_found(attachments.get(conn, int_group(m)), f"вложение {m.group(1)}")
def h_attachment_get(hub, conn, m, q, body):
return 200, _attachment(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:
info = attachments.request_fetch(conn, int_group(m), author)
except KeyError:
raise ApiError(404, "not_found", f"вложение {m.group(1)}") from None
queued = info.pop("queued")
return (202 if queued else 200), info
def h_attachment_content(hub, conn, m, q, body):
att = _attachment(conn, m)
row = conn.execute("SELECT file FROM wiki_attachment WHERE id = ?", (att["id"],)).fetchone()
data = files.read(hub.files_dir, row["file"]) if att["state"] == "ready" else None
if data is None:
raise ApiError(409, "not_ready", "вложение не скачано — запросите скачивание")
files.touch(hub.files_dir, row["file"])
return 200, Raw(MIME, data, {"Content-Disposition": attachments.disposition(attachments.file_name(att)),
"X-Content-Type-Options": "nosniff", "Cache-Control": "no-cache"})
ROUTES = (
("POST", r"^/agent/wiki/pages$", h_agent_pages, "agent"),
("GET", r"^/wiki/status$", h_status, "client"),
("GET", r"^/wiki/tree$", h_tree, "client"),
("GET", r"^/wiki/pages/([^/]+)$", h_page, "client"),
("GET", r"^/wiki/search$", h_search, "client"),
("POST", r"^/wiki/sync$", h_sync, "client"),
("GET", r"^/wiki/attachments/(\d+)$", h_attachment_get, "client"),
("POST", r"^/wiki/attachments/(\d+)/fetch$", h_attachment_fetch, "client"),
("GET", r"^/wiki/attachments/(\d+)/content$", h_attachment_content, "client"),
)

View File

@ -0,0 +1,94 @@
"""Вложения вики по запросу: состояние, постановка wiki_fetch и хуки его результата (spec wiki «Вложения вики по запросу»).
Адрес вложения хаб берёт только из своей таблицы wiki_attachment — из запросов клиента он не приходит."""
import json
from urllib.parse import quote
from ...core import db, files, queue
FIELDS = "id, label, page_code, url, name, state, file, size, error"
def _public(row) -> dict:
return {"id": row["id"], "name": row["name"], "page_code": row["page_code"], "url": row["url"],
"state": row["state"], "size": row["size"], "error": row["error"]}
def get(conn, attachment_id: int) -> dict | None:
row = conn.execute(f"SELECT {FIELDS} FROM wiki_attachment WHERE id = ?", (attachment_id,)).fetchone()
return _public(row) if row else None
def file_name(att: dict) -> str:
"""Имя для скачивания: из недоверенных данных Eva; пустое — запасное."""
return (att.get("name") or "").strip() or f"attachment-{att['id']}"
def disposition(name: str) -> str:
"""Content-Disposition с именем по RFC 5987: quote(safe="") кодирует CR/LF, кавычки, «;» и любые управляющие."""
return "attachment; filename*=UTF-8''" + quote(name, safe="")
def _active(conn, attachment_id: int) -> bool:
"""Есть ли выданная или ожидающая команда wiki_fetch этого вложения."""
return conn.execute("SELECT 1 FROM command WHERE type = 'wiki_fetch' AND status IN ('queued', 'claimed') "
"AND json_extract(params, '$.attachment_id') = ?", (attachment_id,)).fetchone() is not None
def request_fetch(conn, attachment_id: int, author: str | None) -> dict:
"""Поставить скачивание. Одна транзакция: `ready` и `fetching` отвечают как есть; иначе state = fetching и пакет
wiki_fetch. `fetching` без живой команды (пакет отменён) — зависшее состояние: ставится новый пакет.
Ответ — {id, name, page_code, url, state, size, error, queued}. KeyError — вложения нет."""
with db.transaction(conn):
row = conn.execute(f"SELECT {FIELDS} FROM wiki_attachment WHERE id = ?", (attachment_id,)).fetchone()
if row is None:
raise KeyError(attachment_id)
if row["state"] == "ready" or (row["state"] == "fetching" and _active(conn, attachment_id)):
return dict(_public(row), queued=False)
title = f"Вложение вики {file_name(_public(row))}"
queue.insert_batch(conn, title, author, [{"type": "wiki_fetch", "params": {"attachment_id": row["id"], "url": row["url"]}}],
project=row["label"])
conn.execute("UPDATE wiki_attachment SET state = 'fetching', error = NULL WHERE id = ?", (attachment_id,))
return dict(_public(conn.execute(f"SELECT {FIELDS} FROM wiki_attachment WHERE id = ?", (attachment_id,)).fetchone()),
queued=True)
def check_result(conn, cmd, status, result, files_dir) -> tuple[str, str | None]:
"""done требует файл в files/ с заявленным размером; иначе отчёт принимается как failed."""
if status != "done":
return status, None
sha = result.get("file") if isinstance(result, dict) else None
size = result.get("size") if isinstance(result, dict) else None
real = files.size(files_dir, sha) if files_dir is not None else None
if real is None:
return "failed", f"хаб: файл {sha} не найден в хранилище"
if isinstance(size, bool) or real != size:
return "failed", f"хаб: размер файла {real} не совпал с заявленным {size}"
return status, None
def _error(conn, cmd) -> str:
row = conn.execute("SELECT error FROM command WHERE id = ?", (cmd["id"],)).fetchone()
return ((row["error"] if row else None) or "скачивание не удалось")[:1500]
def _failed(conn, cmd, error) -> None:
conn.execute("UPDATE wiki_attachment SET state = 'failed', error = ? WHERE id = ?",
(error, cmd["params"]["attachment_id"]))
def on_result(conn, cmd, status, result, now) -> None:
aid = cmd["params"]["attachment_id"]
if status == "done": # check_result уже сверил файл и размер
conn.execute("UPDATE wiki_attachment SET state = 'ready', file = ?, size = ?, error = NULL, fetched_at = ? WHERE id = ?",
(result["file"], result["size"], now, aid))
else:
_failed(conn, cmd, _error(conn, cmd))
def on_failed(conn, cmd, now) -> None:
_failed(conn, cmd, _error(conn, cmd))
def file_refs(conn) -> set[str]:
"""sha файлов вложений в ready: уборка files/ их не трогает."""
return {r["file"] for r in conn.execute("SELECT file FROM wiki_attachment WHERE state = 'ready' AND file IS NOT NULL")}

View File

@ -4,7 +4,7 @@ import re
from ...core import queue
from ...core.errors import CommandError
from ...core.modules import CommandHooks
from . import sync
from . import attachments, sync
TYPES = {"wiki_sync", "wiki_fetch"}
SPEC = {"wiki_sync": {"required": ["label", "root"], "optional": []},
@ -69,13 +69,35 @@ def active_sync(conn, label: str) -> dict | None:
return queue.batch_view(conn, row["id"], with_commands=False) if row else None
# wiki_fetch: хуки результата заполнит Task 8; wiki_sync — sync.py
def on_failed(conn, cmd, now) -> None:
(attachments if cmd["type"] == "wiki_fetch" else sync).on_failed(conn, cmd, now)
def check_result(conn, cmd, status, result, files_dir) -> tuple[str, str | None]:
if cmd["type"] == "wiki_fetch":
return attachments.check_result(conn, cmd, status, result, files_dir)
return sync.check_result(conn, cmd, status, result, files_dir)
def on_result(conn, cmd, status, result, jira_calls, rate_limited, now, files_dir) -> None:
if cmd["type"] == "wiki_fetch":
attachments.on_result(conn, cmd, status, result, now)
else:
sync.on_result(conn, cmd, status, result, jira_calls, rate_limited, now, files_dir)
def after_result(conn, cmd, status) -> None:
if cmd["type"] == "wiki_sync":
sync.after_result(conn, cmd, status)
# wiki_sync — sync.py (хуки before/abort/after только у него), wiki_fetch — attachments.py
HOOKS = CommandHooks(
types=frozenset(TYPES), read_types=frozenset(TYPES), validate=validate, check_batch=check_batch,
tail=lambda cmds: None, pause=lambda conn, ctype: 0, deliver_params=lambda conn, cmd, files_dir: cmd["params"],
temp_definer=None, temp_keys_used=lambda cmd: [], substitute=lambda ctype, params, mapping: params,
drop_unresolved=lambda cmd, unresolved: unresolved, retry_params=lambda cmd: cmd["params"],
check_confirmed=lambda conn, cmd: (False, None), on_claim=sync.on_claim, on_failed=sync.on_failed,
check_result=sync.check_result, on_result=sync.on_result, after_result=sync.after_result,
check_confirmed=lambda conn, cmd: (False, None), on_claim=sync.on_claim, on_failed=on_failed,
check_result=check_result, on_result=on_result, after_result=after_result,
on_late_result=lambda *a: False, before_result=sync.before_result,
abort_result=sync.abort_result)

View File

@ -220,5 +220,53 @@
"^/sync-runs$",
"client",
"h_sync_runs"
],
[
"GET",
"^/wiki/attachments/(\\d+)$",
"client",
"h_attachment_get"
],
[
"GET",
"^/wiki/attachments/(\\d+)/content$",
"client",
"h_attachment_content"
],
[
"POST",
"^/wiki/attachments/(\\d+)/fetch$",
"client",
"h_attachment_fetch"
],
[
"GET",
"^/wiki/pages/([^/]+)$",
"client",
"h_page"
],
[
"GET",
"^/wiki/search$",
"client",
"h_search"
],
[
"GET",
"^/wiki/status$",
"client",
"h_status"
],
[
"POST",
"^/wiki/sync$",
"client",
"h_sync"
],
[
"GET",
"^/wiki/tree$",
"client",
"h_tree"
]
]

218
tests/test_wiki_api.py Normal file
View File

@ -0,0 +1,218 @@
"""Маршруты вики и вложения по запросу (wiki-module, spec wiki «Маршруты вики», «Вложения вики по запросу»).
Агента имитирует тест токеном agent wiki (фикстура api из test_wiki_sync)."""
import base64
import os
import urllib.error
import urllib.request
from urllib.parse import quote
import pytest
from jira_hub.core import files
from jira_hub.core.client import HubError
from test_wiki_convert import FILE_REF, FILE_URL, THUMB_REF, _html
from test_wiki_sync import CODES, TREE, api, first_sync, page # noqa: F401 — api — фикстура
ATTS = [{"ref": THUMB_REF, "url": FILE_URL, "name": "model.xlsx"}, {"ref": FILE_REF, "url": FILE_URL, "name": "model.xlsx"}]
def synced(api, name_html=None, attachments=None):
"""Синхронизация дерева, где у DOC-3 есть вложение model.xlsx; возвращает id вложения."""
html = name_html if name_html is not None else _html("page_file.html")
tree = [dict(p) for p in TREE]
tree[2] = page("DOC-3", "Статусы", "DOC-1", 1, html, attachments=[{"url": FILE_URL, "name": "model.xlsx"}] if attachments is None
else attachments)
# вложения присылает агент уже разобранными: ref → url, как в test_wiki_sync
tree[2]["attachments"] = ATTS if attachments is None else attachments
assert api.run(tree, CODES)["status"] == "done"
return api.rows("SELECT id FROM wiki_attachment ORDER BY id")[0]["id"]
def upload(api, data: bytes) -> dict:
return api.wiki.post("/agent/files", {"content": base64.b64encode(data).decode()})
def fetch_cmd(api):
cmd = api.claim()
assert cmd and cmd["type"] == "wiki_fetch", cmd
return cmd
def content(api, aid):
req = urllib.request.Request(f"{api.base}/wiki/attachments/{aid}/content", headers={"Authorization": "Bearer " + api.client.token})
return urllib.request.urlopen(req)
def err(fn, *a, **kw):
with pytest.raises((HubError, urllib.error.HTTPError)) as e:
fn(*a, **kw)
if isinstance(e.value, urllib.error.HTTPError):
e.value.close() # иначе ResourceWarning о незакрытом сокете
return e.value
def test_status_tree_page(api):
aid = synced(api)
st = api.client.get("/wiki/status", project="AWPCA")
assert st["root"] == "DOC-1" and st["root_title"] == "АРМ КА" and st["pages"] == 3 and st["error"] is None
assert st["synced_at"] and st["batch"]["status"] == "done"
tree = api.client.get("/wiki/tree", project="AWPCA")["pages"]
assert [(n["code"], n["depth"], n["parent_code"]) for n in tree] == [("DOC-1", 0, None), ("DOC-3", 1, "DOC-1"), ("DOC-2", 1, "DOC-1")]
assert all(set(n) == {"code", "title", "depth", "parent_code", "size"} for n in tree)
p = api.client.get("/wiki/pages/DOC-3", project="AWPCA")
assert p["title"] == "Статусы" and p["url"].endswith("DOC-3") and p["synced_at"]
assert f"attachment:{aid}" in p["markdown"] and not p["markdown"].startswith("---")
assert p["attachments"] == [{"id": aid, "name": "model.xlsx", "state": "none", "size": None}]
assert err(api.client.get, "/wiki/pages/DOC-99", project="AWPCA").status == 404
def test_page_code_with_slash(api):
first_sync(api)
c = api.hub.conn() # код с «/» бывает только в адресе как %2F
c.execute("UPDATE wiki_page SET code = 'A/B' WHERE code = 'DOC-2'")
c.commit()
c.close()
assert api.client.get("/wiki/pages/" + quote("A/B", safe=""), project="AWPCA")["code"] == "A/B"
def test_no_root(api):
api.client.post("/projects", {"label": "sfm_kedr", "name": "СФМ"})
assert err(api.client.get, "/wiki/tree", project="sfm_kedr").error == "no_root"
assert err(api.client.get, "/wiki/status", project="sfm_kedr").status == 409
assert err(api.client.get, "/wiki/tree", project="нет").status == 404
assert err(api.client.get, "/wiki/tree").status == 422
assert err(api.client.post, "/wiki/sync", {"project": "sfm_kedr"}).error == "no_root"
def test_sync_in_progress(api):
first_sync(api)
r = api.client.post("/wiki/sync", {"project": "AWPCA", "author": "t"})
assert set(r) == {"batch_id"}
e = err(api.client.post, "/wiki/sync", {"project": "AWPCA"})
assert e.status == 409 and e.error == "sync_in_progress" and e.detail["batch_id"] == r["batch_id"]
assert api.client.get("/wiki/status", project="AWPCA")["batch"] == {"id": r["batch_id"], "status": "queued"}
assert len(api.rows("SELECT id FROM batch WHERE project = 'AWPCA'")) == 2
def test_fetch_flow(api):
aid = synced(api)
data = b"\x00xlsx-bytes" * 100
r = api.client.post(f"/wiki/attachments/{aid}/fetch", {})
assert r == {"id": aid, "name": "model.xlsx", "page_code": "DOC-3", "url": FILE_URL, "state": "fetching", "size": None, "error": None}
cmd = fetch_cmd(api)
assert cmd["params"] == {"attachment_id": aid, "url": FILE_URL}
assert err(content, api, aid).status == 409
f = upload(api, data)
api.report(cmd, "done", {"file": f["file"], "size": f["size"]})
st = api.client.get(f"/wiki/attachments/{aid}")
assert st["state"] == "ready" and st["size"] == len(data) and st["error"] is None
with content(api, aid) as resp:
assert resp.read() == data
assert resp.headers["Content-Disposition"] == "attachment; filename*=UTF-8''model.xlsx"
assert resp.headers["Content-Type"] == "application/octet-stream" and resp.headers["X-Content-Type-Options"] == "nosniff"
again = api.client.post(f"/wiki/attachments/{aid}/fetch", {}) # ready — как есть, нового пакета нет
assert again["state"] == "ready" and len(api.rows("SELECT id FROM command WHERE type = 'wiki_fetch'")) == 1
def test_fetch_repeat_while_fetching(api):
aid = synced(api)
api.client.post(f"/wiki/attachments/{aid}/fetch", {})
r = api.client.post(f"/wiki/attachments/{aid}/fetch", {})
assert r["state"] == "fetching" and len(api.rows("SELECT id FROM command WHERE type = 'wiki_fetch'")) == 1
def test_fetch_requeues_after_cancel(api):
aid = synced(api)
api.client.post(f"/wiki/attachments/{aid}/fetch", {})
batch = api.rows("SELECT batch_id FROM command WHERE type = 'wiki_fetch'")[0]["batch_id"]
api.client.post(f"/batches/{batch}/cancel", {})
assert api.client.get(f"/wiki/attachments/{aid}")["state"] == "fetching"
api.client.post(f"/wiki/attachments/{aid}/fetch", {}) # зависшее fetching — новый пакет
assert len(api.rows("SELECT id FROM command WHERE type = 'wiki_fetch' AND status = 'queued'")) == 1
def test_fetch_too_big(api):
aid = synced(api)
api.client.post(f"/wiki/attachments/{aid}/fetch", {})
api.report(fetch_cmd(api), "failed", error="файл больше 10 МБ — откройте в Eva")
st = api.client.get(f"/wiki/attachments/{aid}")
assert st["state"] == "failed" and st["error"] == "файл больше 10 МБ — откройте в Eva"
e = err(content, api, aid)
assert e.status == 409
assert api.client.post(f"/wiki/attachments/{aid}/fetch", {})["state"] == "fetching" # из failed — повтор
def test_fetch_size_mismatch(api):
aid = synced(api)
api.client.post(f"/wiki/attachments/{aid}/fetch", {})
cmd = fetch_cmd(api)
f = upload(api, b"abc")
api.report(cmd, "done", {"file": f["file"], "size": f["size"] + 1})
st = api.client.get(f"/wiki/attachments/{aid}")
assert st["state"] == "failed" and "размер" in st["error"]
assert err(content, api, aid).status == 409
def test_fetch_file_missing(api):
aid = synced(api)
api.client.post(f"/wiki/attachments/{aid}/fetch", {})
api.report(fetch_cmd(api), "done", {"file": "0" * 64, "size": 5})
assert api.client.get(f"/wiki/attachments/{aid}")["state"] == "failed"
def test_fetch_unknown_attachment(api):
first_sync(api)
assert err(api.client.post, "/wiki/attachments/999/fetch", {}).status == 404
assert err(api.client.get, "/wiki/attachments/999").status == 404
assert err(content, api, 999).status == 404
assert api.rows("SELECT id FROM batch WHERE title LIKE 'Вложение%'") == []
def test_fetch_url_not_from_client(api):
aid = synced(api)
api.client.post(f"/wiki/attachments/{aid}/fetch", {"url": "https://evil.example/x", "author": "t"})
assert fetch_cmd(api)["params"]["url"] == FILE_URL
def test_content_name_is_safe(api):
aid = synced(api)
c = api.hub.conn()
f = upload(api, b"x")
evil = 'a"\r\nSet-Cookie: x=1;/../é.txt'
c.execute("UPDATE wiki_attachment SET state = 'ready', file = ?, size = 1, name = ? WHERE id = ?", (f["file"], evil, aid))
c.commit()
c.close()
with content(api, aid) as resp:
disp = resp.headers["Content-Disposition"]
assert disp == "attachment; filename*=UTF-8''" + quote(evil, safe="")
assert "\r" not in disp and "\n" not in disp and resp.headers["Set-Cookie"] is None
def test_file_refs_keep_ready(api):
aid = synced(api)
api.client.post(f"/wiki/attachments/{aid}/fetch", {})
cmd = fetch_cmd(api)
f = upload(api, b"keep me")
stale = upload(api, b"drop me")
api.report(cmd, "done", {"file": f["file"], "size": f["size"]})
for sha in (f["file"], stale["file"]):
os.utime(files.path(api.hub.files_dir, sha), (1, 1)) # старше KEEP_DAYS
c = api.hub.conn()
removed = files.sweep(c, api.hub.files_dir)
c.close()
assert stale["file"] in removed and f["file"] not in removed
assert files.exists(api.hub.files_dir, f["file"])
def test_search_empty_query(api):
first_sync(api)
assert err(api.client.get, "/wiki/search", project="AWPCA", q="").error == "bad_query"
assert err(api.client.get, "/wiki/search", project="AWPCA", q=" ").status == 422
assert err(api.client.get, "/wiki/search", project="AWPCA", q="x", limit=0).status == 422
assert err(api.client.get, "/wiki/search", project="AWPCA", q="x", limit=51).status == 422
def test_search_results(api):
first_sync(api)
r = api.client.get("/wiki/search", project="AWPCA", q="согласов")["results"]
assert [x["code"] for x in r] == ["DOC-3"] and set(r[0]) == {"code", "title", "path", "snippet"}