"""附件:上传、列表、下载、删除、跨租户与配额。 用例守的是三条约束(见 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=…)` 给 用:头带不了令牌,只能走 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