"""资质库(FR-M6-08,qualification-plan §2):增删改、附件(企业级密钥加密、签名下载)、到期提醒按进行中项目的 投标截止日算、审计、企业之间隔离。""" from __future__ import annotations from datetime import UTC, date, datetime, timedelta from urllib.parse import urlsplit from uuid import UUID import httpx from tests.conftest import User, register PNG = b"\x89PNG\r\n\x1a\n" + b"\x00" * 64 async def attach(u: User, qid: str, filename: str, data: bytes) -> httpx.Response: r = await u.post("/uploads", json={"filename": filename, "size_bytes": len(data), "purpose": "qualification_file"}) assert r.status_code == 201, r.text up = r.json() assert (await u.put(f"/uploads/{up['id']}/parts/1", content=data)).status_code == 204 done = await u.post(f"/uploads/{up['id']}/complete") if done.status_code != 200: return done return await u.post(f"/qualifications/{qid}/files", json={"upload_id": up["id"]}) async def set_deadline(u: User, project_id: str, at: datetime) -> None: from sqlalchemy import update from bid_doctor.core.db import TenantContext, tenant_tx from bid_doctor.project.models import Project ctx = TenantContext(tenant_id=UUID(u.tenant_id), user_id=UUID(str(u.me["user"]["id"]))) async with tenant_tx(ctx) as s: await s.execute(update(Project).where(Project.id == UUID(project_id)).values(deadline_at=at)) async def test_qualification_crud_files_and_late_for(user: User) -> None: body = { "name": "ISO/IEC 27001 信息安全管理体系认证", "kind": "system_cert", "cert_no": " 01226IS0458 ", "issuer": "", "valid_from": "2023-11-16", "valid_to": "2026-11-15", } r = await user.post("/qualifications", json=body) assert r.status_code == 201, r.text q = r.json() assert q["cert_no"] == "01226IS0458" and q["issuer"] is None and q["files"] == [] and q["late_for"] == [] bad = await user.put(f"/qualifications/{q['id']}", json={**body, "valid_to": "2023-01-01"}) assert bad.status_code == 422 # 进行中项目的截止日晚于有效期至 → late_for;已递交的项目不算 today = datetime.now(UTC).date() soon = today + timedelta(days=20) late_project = (await user.post("/projects", json={})).json()["id"] await set_deadline(user, late_project, datetime(soon.year, soon.month, soon.day, 1, 30, tzinfo=UTC)) r = await user.put(f"/qualifications/{q['id']}", json={**body, "valid_to": (soon - timedelta(days=1)).isoformat()}) assert r.status_code == 200, r.text assert [x["project_id"] for x in r.json()["late_for"]] == [late_project] r = await user.put(f"/qualifications/{q['id']}", json={**body, "valid_to": soon.isoformat()}) assert r.json()["late_for"] == [] # 截止日当天到期仍算有效 long_term = ( await user.post("/qualifications", json={"name": "政务大厅排队叫号系统 V3.0", "kind": "software_copyright"}) ).json() items = (await user.get("/qualifications")).json()["items"] assert [x["id"] for x in items] == [q["id"], long_term["id"]] # 长期有效的排最后 assert [x["id"] for x in (await user.get("/qualifications", params={"q": "IS0458"})).json()["items"]] == [q["id"]] kind = (await user.get("/qualifications", params={"kind": "software_copyright"})).json()["items"] assert [x["id"] for x in kind] == [long_term["id"]] # 附件:PNG 可以,Word 不行;下载回来是原文件 r = await attach(user, q["id"], "ISO27001 证书.png", PNG) assert r.status_code == 201, r.text (f,) = r.json()["files"] assert f["filename"] == "ISO27001 证书.png" and f["size_bytes"] == len(PNG) rejected = await attach(user, q["id"], "证书.docx", b"PK\x03\x04" + b"\x00" * 30) assert rejected.status_code == 422 link = (await user.get(f"/qualifications/{q['id']}/files/{f['id']}")).json() parts = urlsplit(link["url"]) got = await user.client.get(parts.path.removeprefix("/api/v1") + "?" + parts.query) assert got.status_code == 200 and got.content == PNG and got.headers["content-type"] == "image/png" tampered = await user.client.get(parts.path.removeprefix("/api/v1") + "?" + parts.query.replace("sig=", "sig=0")) assert tampered.status_code == 404 # 删附件、删资质:对象一并删除,写审计 assert (await user.delete(f"/qualifications/{q['id']}/files/{f['id']}")).status_code == 204 r = await attach(user, q["id"], "ISO27001 证书.png", PNG) key = await object_key(user, r.json()["files"][0]["id"]) from bid_doctor.core.storage import storage assert storage().exists(key) assert (await user.delete(f"/qualifications/{q['id']}")).status_code == 204 assert not storage().exists(key) assert [x["id"] for x in (await user.get("/qualifications")).json()["items"]] == [long_term["id"]] assert await audit_actions(user) >= { "qualification.create", "qualification.update", "qualification.delete", "qualification_file.attach", "qualification_file.delete", } async def object_key(u: User, file_id: str) -> str: from sqlalchemy import select from bid_doctor.core.db import TenantContext, tenant_tx from bid_doctor.qualification.models import QualificationFile ctx = TenantContext(tenant_id=UUID(u.tenant_id), user_id=UUID(int=0)) async with tenant_tx(ctx) as s: return ( await s.execute(select(QualificationFile.object_key).where(QualificationFile.id == UUID(file_id))) ).scalar_one() async def audit_actions(u: User) -> set[str]: from sqlalchemy import select from bid_doctor.audit.models import AuditEvent from bid_doctor.core.db import TenantContext, tenant_tx ctx = TenantContext(tenant_id=UUID(u.tenant_id), user_id=UUID(int=0)) async with tenant_tx(ctx) as s: return set((await s.execute(select(AuditEvent.action))).scalars()) async def test_qualifications_are_isolated(app_client: httpx.AsyncClient) -> None: a = await register(app_client, "甲公司") b = await register(app_client, "乙公司") q = ( await a.post( "/qualifications", json={"name": "ISO 9001", "kind": "system_cert", "valid_to": str(date(2028, 3, 9))} ) ).json() assert (await b.get("/qualifications")).json()["items"] == [] assert (await b.put(f"/qualifications/{q['id']}", json={"name": "x", "kind": "other"})).status_code == 404 assert (await b.delete(f"/qualifications/{q['id']}")).status_code == 404 async def test_check_reports_qualification_issues(user: User) -> None: """端到端:投标文件的资质情况表里,安全生产许可证早于投标截止日到期 → 致命「证书在投标截止日已过期」,挂投标原文; ISO 27001 是评分项(不逐条找响应),投标文件里没写、资质库里那张早于截止日 → 按资质库记录提示,降为重要。 资质情况表的名称格不能被当成「照抄的要求、响应对不上」。""" from tests.conftest import project_with_tender, run_parse from tests.test_check_flow import findings, run_check, upload_bid project_id, tender_id = await project_with_tender(user) assert (await run_parse(user, tender_id))["status"] == "succeeded" await set_deadline(user, project_id, datetime(2026, 11, 20, 1, 30, tzinfo=UTC)) for title, cats in ( ("投标人须具有有效的安全生产许可证", ["qualification"]), ("具有 ISO 27001 信息安全管理体系认证证书(2 分)", ["scoring"]), ): r = await user.post(f"/projects/{project_id}/checklist/items", json={"categories": cats, "title": title}) assert r.status_code == 201, r.text r = await user.post( "/qualifications", json={"name": "ISO/IEC 27001 信息安全管理体系认证", "kind": "system_cert", "valid_to": "2026-10-31"}, ) assert r.status_code == 201 bid = await upload_bid(user, project_id, "bid-mini-quals.pdf") run = await run_check(user, project_id, [bid["id"]]) assert run["status"] == "succeeded", run items = await findings(user, run["id"]) quals = {f["checklist_item"]["title"]: f for f in items if f["type"] == "qualification"} safety = quals["投标人须具有有效的安全生产许可证"] assert safety["rule"] == {"code": "QUAL.EXPIRED", "description": "证书有效期(按投标截止日)"} assert safety["severity"] == "fatal" and safety["detail"]["source"] == "bid_text" assert "2026年11月15日" in safety["explanation"] and "2026年11月20日" in safety["explanation"] assert any("2026年11月15日" in c["quote"] for c in safety["bid_citations"]) iso = quals["具有 ISO 27001 信息安全管理体系认证证书(2 分)"] assert iso["severity"] == "important" and iso["detail"]["source"] == "library" and iso["bid_citations"] == [] assert iso["detail"]["issues"][0]["library"]["valid_to"] == "2026-10-31" assert not any(f["title"].startswith("响应可能对不上") for f in items) assert any(x.startswith("资质核对") for x in run["coverage"]["checked"]) assert run["coverage"]["qualification"] == { "library": 1, "checked": 2, "by_library": 1, "unrecognized": 0, "excluded": 0, }