Files
DadaoTask/backend/tests/test_attachments.py
T
dadao a24a493403 feat(rich): 备注/工单说明/知识正文支持 Markdown 与图片附件
写一件事的时候,光有纯文本不够:要贴截图、要分段、要标重点。
这次把「正文」这条路补全,思路是**存 Markdown 而不是 HTML**——
能导出、能全文检索、能直接喂给 LLM,也不必每次渲染都重新清洗一遍 HTML。

后端
- attachment 表 + RLS + 迁移 0005;本地磁盘存储,key 由内容 sha256 决定,
  换对象存储只改 services/storage.py 一个文件
- 上传在**读流时**卡 20MB,不先落盘再判断;磁盘名由服务端生成,用户文件名只进库和响应头
- 列表带 attachment_count、详情带 attachments,避免一屏 N 次请求
- raw 直链给 <img src> 用(图片带不了 Authorization 头),只允许本工作区成员,
  非成员一律 8001 不区分「不存在」与「没权限」
- 5 个用例:上传下载、超限、跨租户、删除确认与权限、raw 鉴权

前端
- 自研零依赖 Markdown 渲染器:先转义再上标签,链接协议白名单
- RichTextEditor:工具栏 + Ctrl+B/I + 预览,粘贴或拖入图片自动上传并插入引用
- AttachmentPanel:上传/下载/删除,下载走带 Authorization 的 fetch 转 blob
- 任务备注、工单说明与验收结论、知识正文、评论全部接入

顺手修的
- TaskDetail.hydrate 漏了 setAttachments,导致附件面板永远是空的(e2e 抓到的)

71 passed;web tsc 0 错、构建 255.07 kB │ gzip 80.85 kB;
e2e:richtext 9 项 + smoke 7 + tickets 8 + knowledge 7,全绿。
2026-10-29 01:53:46 +08:00

171 lines
6.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""附件:上传、列表、下载、删除、跨租户与配额。
用例守的是三条约束(见 app/api/attachments.py 文件头):
1. 只认本工作区的条目,别人的 item_id 一律 5001;
2. 超限在读流时卡住,且不留垃圾文件;
3. 磁盘名服务端生成,用户文件名只进库与 Content-Disposition。
"""
from __future__ import annotations
from typing import Any
import pytest
from httpx import AsyncClient
from app.config import get_settings
from .conftest import create_workspace, login, unwrap
BASE = "/api/v1"
async def _ws_with_item(client: AsyncClient, name: str = "附件工作区") -> tuple[Any, Any, str]:
user = await login(client)
ws = await create_workspace(client, user, name)
item = unwrap(
await client.post(
f"{BASE}/workspaces/{ws['id']}/items",
json={"title": "整理发票原件"},
headers=user["headers"],
)
)
return user, ws, item["id"]
async def _upload(
client: AsyncClient, user: dict[str, Any], ws_id: str, item_id: str, content: bytes, name: str
) -> Any:
return await client.post(
f"{BASE}/workspaces/{ws_id}/items/{item_id}/attachments",
files={"file": (name, content, "text/plain")},
headers=user["headers"],
)
async def test_upload_list_and_download(client: AsyncClient) -> None:
user, ws, item_id = await _ws_with_item(client)
body = "发票号 044031900111,金额 1280.00".encode()
created = unwrap(await _upload(client, user, ws["id"], item_id, body, "发票.txt"))
assert created["filename"] == "发票.txt"
assert created["size"] == len(body)
assert created["sha256"]
assert created["can_delete"] is True
listed = unwrap(
await client.get(
f"{BASE}/workspaces/{ws['id']}/items/{item_id}/attachments", headers=user["headers"]
)
)
assert [a["id"] for a in listed["items"]] == [created["id"]]
assert listed["items"][0]["content_type"] == "text/plain"
# 计数要出现在任务列表与详情里,否则前端没法显示「3 个附件」
items = unwrap(
await client.get(f"{BASE}/workspaces/{ws['id']}/items?view=all", headers=user["headers"])
)
row = next(i for i in items["items"] if i["id"] == item_id)
assert row["attachment_count"] == 1
detail = unwrap(
await client.get(f"{BASE}/workspaces/{ws['id']}/items/{item_id}", headers=user["headers"])
)
assert detail["attachment_count"] == 1
assert detail["attachments"][0]["id"] == created["id"]
# 下载要能原样取回,并给出可辨识的文件名
resp = await client.get(
f"{BASE}/workspaces/{ws['id']}/attachments/{created['id']}/download",
headers=user["headers"],
)
assert resp.status_code == 200
assert resp.content == body
assert "filename*=UTF-8''" in resp.headers["content-disposition"]
assert resp.headers["x-content-type-options"] == "nosniff"
async def test_reject_oversized(client: AsyncClient, monkeypatch: pytest.MonkeyPatch) -> None:
user, ws, item_id = await _ws_with_item(client)
monkeypatch.setattr(get_settings(), "max_upload_mb", 0)
body = (await _upload(client, user, ws["id"], item_id, b"x" * 4096, "big.txt")).json()
assert body["code"] == 8002
assert "上限" in body["message"]
async def test_cannot_attach_to_other_tenant_item(client: AsyncClient) -> None:
"""跨租户的 item_id 不能成为落点——否则等于往别人的时间线上塞文件。"""
user, _ws, item_id = await _ws_with_item(client, "甲的组")
other, other_ws, other_item = await _ws_with_item(client, "乙的组")
# 拿别人工作区的 id 上传:自己不是成员
resp = (await _upload(client, user, other_ws["id"], other_item, b"x", "x.txt")).json()
assert resp["code"] in (3003, 5001)
# 在自己的工作区里塞别人的 item_id:条目找不到,也不该暴露它存在
resp2 = (await _upload(client, user, _ws["id"], other_item, b"x", "x.txt")).json()
assert resp2["code"] == 5001
async def test_delete_requires_confirm_and_ownership(client: AsyncClient) -> None:
owner, ws, item_id = await _ws_with_item(client)
created = unwrap(await _upload(client, owner, ws["id"], item_id, b"abc", "a.txt"))
# 不确认不给删:删除不可逆,前端容易误触
resp = (
await client.delete(
f"{BASE}/workspaces/{ws['id']}/attachments/{created['id']}", headers=owner["headers"]
)
).json()
assert resp["code"] == 8004
assert resp["data"] == {"confirm": True}
# 同工作区但非上传者的普通成员也删不了
outsider = await login(client)
resp = (
await client.delete(
f"{BASE}/workspaces/{ws['id']}/attachments/{created['id']}?confirm=true",
headers=outsider["headers"],
)
).json()
assert resp["code"] in (3003, 8003)
gone = unwrap(
await client.delete(
f"{BASE}/workspaces/{ws['id']}/attachments/{created['id']}?confirm=true",
headers=owner["headers"],
)
)
assert gone["deleted"] is True
after = await client.get(
f"{BASE}/workspaces/{ws['id']}/attachments/{created['id']}/download",
headers=owner["headers"],
)
assert after.json()["code"] == 8001
async def test_raw_image_url_takes_token_in_query(client: AsyncClient) -> None:
"""Markdown 里的 `![](…/raw?token=…)` 给 <img> 用:头带不了令牌,只能走 query。"""
user, ws, item_id = await _ws_with_item(client)
body = b"\x89PNG\r\n\x1a\n fake png"
created = unwrap(await _upload(client, user, ws["id"], item_id, body, "图.png"))
resp = await client.get(
f"{BASE}/workspaces/{ws['id']}/attachments/{created['id']}/raw?token={user['token']}"
)
assert resp.status_code == 200
assert resp.content == body
assert resp.headers["content-disposition"] == "inline"
assert resp.headers["cache-control"].startswith("private")
# 不带 token 直接 422(参数必填),带了别人的 token 一律当不存在
missing = await client.get(f"{BASE}/workspaces/{ws['id']}/attachments/{created['id']}/raw")
assert missing.status_code == 422
outsider = await login(client)
leaked = await client.get(
f"{BASE}/workspaces/{ws['id']}/attachments/{created['id']}/raw?token={outsider['token']}"
)
assert leaked.json()["code"] == 8001