docs(rich): 表格可读性、安卓编辑器、签名直链都验证过了
CI / backend (push) Canceled after 0s

- README:富文本行改成「Web Vditor 编辑 / 安卓 Markwon 预览编辑」,e2e 15 项
- ROADMAP 第 5 节:勾掉表格与超过一两行的旧描述(表格是只读渲染,
  HTML 片段仍按文本显示);附件改为短期签名 raw_url;安卓从"未做"改成已完成
- 记下仍欠的三件事:保存后回列表而不是回详情、图片压缩/缩略图、离线缓存

验证:pytest 74 passed;richtext 15 / smoke 7 / tickets 8 / knowledge 7 全绿;
tsc -b 0 错;vite build 通过;安卓 assembleDebug 与模拟器逐屏走通。
This commit is contained in:
dadao
2026-10-29 03:19:33 +08:00
parent fbdf54cb4c
commit 8d61289f90
22 changed files with 911 additions and 90 deletions
+60 -2
View File
@@ -13,7 +13,10 @@ from typing import Any
import pytest
from httpx import AsyncClient
import uuid
from app.config import get_settings
from app.services import signing
from .conftest import create_workspace, login, unwrap
@@ -160,11 +163,66 @@ async def test_raw_image_url_takes_token_in_query(client: AsyncClient) -> None:
assert resp.headers["content-disposition"] == "inline"
assert resp.headers["cache-control"].startswith("private")
# 不带 token 直接 422(参数必填),带了别人的 token 一律当不存在
# 既没签名也没令牌:401(而不是把"缺凭据"说成"文件不存在")
missing = await client.get(f"{BASE}/workspaces/{ws['id']}/attachments/{created['id']}/raw")
assert missing.status_code == 422
assert missing.status_code == 401
assert missing.json()["code"] == 2001
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
async def test_raw_url_uses_short_lived_signature(client: AsyncClient) -> None:
"""图片直链自带短期签名:不需要把登录令牌贴进 URL,也不需要 Authorization 头。"""
user, ws, item_id = await _ws_with_item(client)
body = b"\x89PNG\r\n\x1a\n signed png"
created = unwrap(await _upload(client, user, ws["id"], item_id, body, "签名图.png"))
listed = unwrap(
await client.get(
f"{BASE}/workspaces/{ws['id']}/items/{item_id}/attachments", headers=user["headers"]
)
)
raw_url = listed["items"][0]["raw_url"]
assert "exp=" in raw_url and "sig=" in raw_url
assert "token=" not in raw_url # 不该再把登录令牌塞进 URL
# 裸请求(无 Authorization、无 cookie)也要能取到图——这才是 <img> 的真实处境
resp = await client.get(raw_url)
assert resp.status_code == 200
assert resp.content == body
async def test_raw_url_rejects_tampered_or_expired_signature(client: AsyncClient) -> None:
user, ws, item_id = await _ws_with_item(client)
created = unwrap(await _upload(client, user, ws["id"], item_id, b"data", "图.png"))
base = f"{BASE}/workspaces/{ws['id']}/attachments/{created['id']}/raw"
# 改一个字符就该失效:签名不是"看起来对就行"
good = signing.sign(uuid.UUID(ws["id"]), uuid.UUID(created["id"]))
tampered = good[:-1] + ("0" if good[-1] != "0" else "1")
bad = await client.get(f"{base}?{tampered}")
assert bad.status_code == 403
assert bad.json()["code"] == 8003
# 过期签名:就算 HMAC 算得对,时间过了也不给
stale = signing.sign(uuid.UUID(ws["id"]), uuid.UUID(created["id"]), ttl_seconds=-10)
expired = await client.get(f"{base}?{stale}")
assert expired.status_code == 403
assert expired.json()["code"] == 8003
async def test_raw_url_signature_is_workspace_scoped(client: AsyncClient) -> None:
"""拿 A 工作区签出来的链接去请求 B 工作区,必须失败(签名里带 workspace_id)。"""
user, ws_a, item_a = await _ws_with_item(client, "甲工作区")
ws_b = await create_workspace(client, user, "乙工作区")
created = unwrap(await _upload(client, user, ws_a["id"], item_a, b"data", "图.png"))
query = signing.sign(uuid.UUID(ws_a["id"]), uuid.UUID(created["id"]))
crossed = await client.get(
f"{BASE}/workspaces/{ws_b['id']}/attachments/{created['id']}/raw?{query}"
)
assert crossed.status_code == 403
assert crossed.json()["code"] == 8003