| """知識庫文件批次操作測試(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) |
| |
| 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" |
| |
| assert all(_vis(d) == "department" for d in ids) |
| |
| 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 |
| 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) |
|
|