kb-demo / backend /tests /test_bulk_doc_ops.py
RayLi-Git
fix: 檔案移至根目錄 + 套用示範 README
a7cd101
Raw
History Blame Contribute Delete
9.36 kB
"""知識庫文件批次操作測試(PRD §10.4 + 745 + D52 §10.4.1)。
涵蓋:
- 批次改可見度(改為部門內部 / 改為公開)— 公開永遠 step-up(使用者裁決依 §10.4);
部門內部僅在「擴大受眾」時 step-up(沿用單篇方向性 A6 §11.3)。
- 批次軟刪除 — 一律 step-up(§10.4 軟刪除文件須 re-confirm 密碼)。
- 安全:權限不足的文件**跳過**(fail closed,不誤改他人/他部門);密碼錯 → 403。
安全模組 test-first(十二誡 §11):先寫此檔,再實作 bulk 端點。
"""
from __future__ import annotations
import pytest
from fastapi.testclient import TestClient
from app.auth.passwords import hash_password
from app.db import _utcnow, get_conn
from app.main import app
PWD = "TestPw123!"
@pytest.fixture()
def client():
return TestClient(app)
def _mkuser(username, role, dept):
get_conn().execute(
"INSERT INTO users (username, email, password_hash, role, department, "
"created_at, is_active) VALUES (?,?,?,?,?,?,1)",
(username, f"{username}@x.tw", hash_password(PWD), role, dept, _utcnow()),
)
get_conn().execute(
"INSERT OR IGNORE INTO departments (name, sort_order, is_active, created_at) "
"VALUES (?, 0, 1, ?)", (dept, _utcnow()),
)
return get_conn().execute(
"SELECT id FROM users WHERE username=?", (username,)).fetchone()["id"]
_DOC_SEQ = [0]
def _mkdoc(author_id, dept="業務部", vis="department"):
now = _utcnow()
_DOC_SEQ[0] += 1
cur = get_conn().execute(
"INSERT INTO documents (title, category, tags, summary, body, author_id, "
"owner_department, visibility, allowed_departments, access_list, status, "
"vault_path, created_at, updated_at) "
"VALUES (?,?,?,?,?,?,?,?,'[]','[]','active',?,?,?)",
("t", "知識/筆記", "[]", "s", "b", author_id, dept, vis,
f"vault/bulk-{_DOC_SEQ[0]}.md", now, now),
)
return cur.lastrowid
def _login(c, username="adm"):
return c.post("/api/auth/login",
json={"username": username, "password": PWD}).json()["access_token"]
def _auth(t):
return {"Authorization": f"Bearer {t}"}
def _vis(did):
return get_conn().execute(
"SELECT visibility FROM documents WHERE id=?", (did,)).fetchone()["visibility"]
def _deleted(did):
return get_conn().execute(
"SELECT is_deleted FROM documents WHERE id=?", (did,)).fetchone()["is_deleted"]
# ============ 批次改可見度 ============
def test_bulk_to_public_requires_step_up(client):
"""批次改公開 → 一律 step-up(使用者裁決依 §10.4)。"""
uid = _mkuser("adm", "admin", "業務部")
ids = [_mkdoc(uid), _mkdoc(uid), _mkdoc(uid)]
tok = _login(client)
# 無密碼 → 403 step_up_required
r = client.post("/api/documents/bulk/visibility",
json={"ids": ids, "visibility": "public"}, headers=_auth(tok))
assert r.status_code == 403, r.text
assert r.json()["detail"]["error"] == "step_up_required"
# 確認沒有任何文件被改(fail closed)
assert all(_vis(d) == "department" for d in ids)
# 帶密碼 → 200,全部改公開
r2 = client.post("/api/documents/bulk/visibility",
json={"ids": ids, "visibility": "public", "step_up_password": PWD},
headers=_auth(tok))
assert r2.status_code == 200, r2.text
assert r2.json()["count"] == 3
assert all(_vis(d) == "public" for d in ids)
def test_bulk_to_public_wrong_password_rejected(client):
"""密碼錯 → 403 step_up_failed,且不改任何文件。"""
uid = _mkuser("adm", "admin", "業務部")
ids = [_mkdoc(uid), _mkdoc(uid)]
tok = _login(client)
r = client.post("/api/documents/bulk/visibility",
json={"ids": ids, "visibility": "public", "step_up_password": "WRONG"},
headers=_auth(tok))
assert r.status_code == 403
assert r.json()["detail"]["error"] == "step_up_failed"
assert all(_vis(d) == "department" for d in ids)
def test_bulk_to_department_narrowing_no_password(client):
"""批次改部門內部:public → department 屬縮小受眾,免 step-up。"""
uid = _mkuser("adm", "admin", "業務部")
ids = [_mkdoc(uid, vis="public"), _mkdoc(uid, vis="public")]
tok = _login(client)
r = client.post("/api/documents/bulk/visibility",
json={"ids": ids, "visibility": "department"}, headers=_auth(tok))
assert r.status_code == 200, r.text
assert all(_vis(d) == "department" for d in ids)
def test_bulk_to_department_expanding_requires_step_up(client):
"""批次改部門內部:confidential → department 屬擴大受眾,需 step-up。"""
uid = _mkuser("adm", "admin", "業務部")
ids = [_mkdoc(uid, vis="confidential")]
tok = _login(client)
r = client.post("/api/documents/bulk/visibility",
json={"ids": ids, "visibility": "department"}, headers=_auth(tok))
assert r.status_code == 403
assert r.json()["detail"]["error"] == "step_up_required"
def test_bulk_visibility_skips_unpermitted_docs(client):
"""manager 只能改本部門:他部門文件被跳過(fail closed),不誤改。"""
adm = _mkuser("adm", "admin", "業務部")
_mkuser("mgr", "manager", "業務部")
mine = _mkdoc(adm, dept="業務部", vis="public")
other = _mkdoc(adm, dept="研發部", vis="public")
tok = _login(client, "mgr")
r = client.post("/api/documents/bulk/visibility",
json={"ids": [mine, other], "visibility": "department"},
headers=_auth(tok))
assert r.status_code == 200, r.text
assert _vis(mine) == "department" # 本部門:改了
assert _vis(other) == "public" # 他部門:跳過
assert r.json()["count"] == 1
assert any(s["id"] == other for s in r.json()["skipped"])
# ============ 批次軟刪除 ============
def test_bulk_delete_requires_step_up(client):
"""批次軟刪除須 step-up(§10.4)。缺密碼 → 422(欄位必填)。"""
uid = _mkuser("adm", "admin", "業務部")
ids = [_mkdoc(uid), _mkdoc(uid)]
tok = _login(client)
r = client.post("/api/documents/bulk/delete",
json={"ids": ids}, headers=_auth(tok))
assert r.status_code == 422 # step_up_password 必填
assert all(_deleted(d) == 0 for d in ids)
def test_bulk_delete_wrong_password_rejected(client):
"""密碼錯 → 403,不刪任何文件。"""
uid = _mkuser("adm", "admin", "業務部")
ids = [_mkdoc(uid), _mkdoc(uid)]
tok = _login(client)
r = client.post("/api/documents/bulk/delete",
json={"ids": ids, "step_up_password": "WRONG"}, headers=_auth(tok))
assert r.status_code == 403
assert all(_deleted(d) == 0 for d in ids)
def test_bulk_delete_success(client):
"""正確密碼 → 全部軟刪除。"""
uid = _mkuser("adm", "admin", "業務部")
ids = [_mkdoc(uid), _mkdoc(uid), _mkdoc(uid)]
tok = _login(client)
r = client.post("/api/documents/bulk/delete",
json={"ids": ids, "step_up_password": PWD}, headers=_auth(tok))
assert r.status_code == 200, r.text
assert r.json()["count"] == 3
assert all(_deleted(d) == 1 for d in ids)
def test_bulk_delete_skips_unpermitted(client):
"""manager 只能刪本部門:他部門被跳過。"""
adm = _mkuser("adm", "admin", "業務部")
_mkuser("mgr", "manager", "業務部")
mine = _mkdoc(adm, dept="業務部")
other = _mkdoc(adm, dept="研發部")
tok = _login(client, "mgr")
r = client.post("/api/documents/bulk/delete",
json={"ids": [mine, other], "step_up_password": PWD}, headers=_auth(tok))
assert r.status_code == 200, r.text
assert _deleted(mine) == 1
assert _deleted(other) == 0
assert r.json()["count"] == 1
# ============ 輸入防呆(#不信任輸入)============
def test_bulk_empty_ids_rejected(client):
"""空 ids → 400。"""
_mkuser("adm", "admin", "業務部")
tok = _login(client)
r = client.post("/api/documents/bulk/visibility",
json={"ids": [], "visibility": "department"}, headers=_auth(tok))
assert r.status_code == 400
def test_bulk_too_many_ids_rejected(client):
"""超過上限(200)→ 400,避免一次操作癱瘓。"""
_mkuser("adm", "admin", "業務部")
tok = _login(client)
r = client.post("/api/documents/bulk/delete",
json={"ids": list(range(1, 500)), "step_up_password": PWD},
headers=_auth(tok))
assert r.status_code == 400
def test_bulk_to_department_expanding_success_with_password(client):
"""批次改部門內部·擴大受眾路徑:帶正確密碼 → 200 並真的改成 department。"""
uid = _mkuser("adm", "admin", "業務部")
ids = [_mkdoc(uid, vis="confidential"), _mkdoc(uid, vis="restricted")]
tok = _login(client)
r = client.post("/api/documents/bulk/visibility",
json={"ids": ids, "visibility": "department", "step_up_password": PWD},
headers=_auth(tok))
assert r.status_code == 200, r.text
assert r.json()["count"] == 2
assert all(_vis(d) == "department" for d in ids)