from __future__ import annotations import json from datetime import UTC, datetime, timedelta from io import BytesIO from urllib.parse import parse_qs, urlsplit from zipfile import ZIP_DEFLATED, ZipFile import jwt import pytest from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker from sqlalchemy.pool import StaticPool from app.api.deps import CurrentUserContext from app.db.base import Base from app.models.knowledge_security import KnowledgeOnlyOfficeSession from app.models.tenant import Tenant from app.services import knowledge_onlyoffice_security as security_module from app.services.knowledge import KnowledgeService from app.services.knowledge_onlyoffice_callback import ( handle_onlyoffice_callback, resolve_onlyoffice_content, ) from app.services.knowledge_onlyoffice_security import ( ONLYOFFICE_TOKEN_AUDIENCE, KnowledgeOnlyOfficeSessionService, OnlyOfficeReplayError, OnlyOfficeSecurityError, download_onlyoffice_document, ) from app.services.knowledge_rag import KnowledgeRagService from app.services.knowledge_tenant_scope import PLATFORM_KNOWLEDGE_SCOPE from app.services.settings import OnlyOfficeRuntimeConfig JWT_SECRET = "test-onlyoffice-security-secret-32bytes" def _docx_bytes(text: str) -> bytes: stream = BytesIO() with ZipFile(stream, mode="w", compression=ZIP_DEFLATED) as archive: archive.writestr( "[Content_Types].xml", """ """, ) archive.writestr( "word/document.xml", """ """ + text + """ """, ) return stream.getvalue() def _user(tenant_id: str, *, admin: bool = True) -> CurrentUserContext: return CurrentUserContext( username=f"user-{tenant_id}", name=f"用户 {tenant_id}", role_codes=["manager"] if admin else ["employee"], is_admin=admin, tenant_id=tenant_id, ) def _factory(): assert "tenants" in Base.metadata.tables engine = create_engine( "sqlite+pysqlite:///:memory:", connect_args={"check_same_thread": False}, poolclass=StaticPool, ) Tenant.__table__.create(bind=engine) KnowledgeOnlyOfficeSession.__table__.create(bind=engine) factory = sessionmaker(bind=engine, autoflush=False, autocommit=False) with factory() as db: db.add_all( [ Tenant(tenant_id="tenant-a", tenant_code="A", name="A", status="active"), Tenant(tenant_id="tenant-b", tenant_code="B", name="B", status="active"), ] ) db.commit() return factory def _configure_onlyoffice(monkeypatch) -> OnlyOfficeRuntimeConfig: runtime = OnlyOfficeRuntimeConfig( enabled=True, public_url="https://docs.example.com", backend_url="https://app.example.com", jwt_secret=JWT_SECRET, ) monkeypatch.setattr( "app.services.knowledge_onlyoffice.resolve_onlyoffice_settings", lambda *_args, **_kwargs: runtime, ) monkeypatch.setattr( security_module, "resolve_onlyoffice_settings", lambda *_args, **_kwargs: runtime, ) monkeypatch.setattr( KnowledgeRagService, "get_document_status_map", lambda _self, _document_ids: {}, ) monkeypatch.setattr(KnowledgeRagService, "delete_document", lambda *_args: None) return runtime def _token_from_url(url: str, parameter: str) -> str: return parse_qs(urlsplit(url).query)[parameter][0] def test_onlyoffice_tokens_bind_tenant_resource_key_version_and_audience( tmp_path, monkeypatch, ) -> None: _configure_onlyoffice(monkeypatch) factory = _factory() with factory() as db: service = KnowledgeService(storage_root=tmp_path, db=db, tenant_id="tenant-a") uploaded = service.upload_document( "制度政策", "制度.docx", _docx_bytes("version one"), _user("tenant-a"), ) config = service.build_onlyoffice_config(uploaded.id, _user("tenant-a")) content_token = _token_from_url(config.config["document"]["url"], "access_token") callback_token = _token_from_url( config.config["editorConfig"]["callbackUrl"], "callback_token", ) claims = jwt.decode( content_token, JWT_SECRET, algorithms=["HS256"], audience=ONLYOFFICE_TOKEN_AUDIENCE, ) callback_claims = jwt.decode( callback_token, JWT_SECRET, algorithms=["HS256"], audience=ONLYOFFICE_TOKEN_AUDIENCE, ) assert claims["tenant_id"] == "tenant-a" assert claims["document_id"] == uploaded.id assert claims["document_key"] == config.config["document"]["key"] assert claims["document_version"] == 1 assert claims["editable"] is False assert callback_claims["jti"] == claims["jti"] assert callback_claims["exp"] - claims["exp"] >= 3 * 60 * 60 row = db.get(KnowledgeOnlyOfficeSession, claims["jti"]) assert row is not None and row.tenant_id == "tenant-a" and row.status == "active" content_path, _, _ = resolve_onlyoffice_content( db=db, storage_root=tmp_path, document_id=uploaded.id, access_token=content_token, ) assert content_path.is_relative_to( tmp_path / "knowledge" / "tenants" / "tenant-a" ) with ZipFile(content_path) as archive: assert b"version one" in archive.read("word/document.xml") tampered_claims = dict(claims) tampered_claims["tenant_id"] = "tenant-b" tampered_token = jwt.encode(tampered_claims, JWT_SECRET, algorithm="HS256") with pytest.raises(OnlyOfficeSecurityError, match="不匹配"): KnowledgeOnlyOfficeSessionService(db).validate_content( document_id=uploaded.id, token=tampered_token, ) assert callback_token def test_platform_document_session_is_tenant_bound_and_strictly_read_only( tmp_path, monkeypatch, ) -> None: _configure_onlyoffice(monkeypatch) document_id = "platform-doc" content = _docx_bytes("platform policy") filename = "平台制度.docx" stored_name = f"{document_id}__{filename}" platform_root = tmp_path / "knowledge" / "platform" folder_root = platform_root / "制度政策" folder_root.mkdir(parents=True) (folder_root / stored_name).write_bytes(content) (platform_root / ".index.json").write_text( json.dumps( { "version": 1, "documents": [ { "id": document_id, "folder": "制度政策", "original_name": filename, "stored_name": stored_name, "mime_type": ( "application/vnd.openxmlformats-officedocument." "wordprocessingml.document" ), "extension": "docx", "size_bytes": len(content), "sha256": "platform-checksum", "created_at": "2026-07-17T00:00:00+00:00", "updated_at": "2026-07-17T00:00:00+00:00", "uploaded_by": "平台", "version_number": 1, "ingest_status": 1, } ], }, ensure_ascii=False, ), encoding="utf-8", ) factory = _factory() with factory() as db: service = KnowledgeService(storage_root=tmp_path, db=db, tenant_id="tenant-a") config = service.build_onlyoffice_config(document_id, _user("tenant-a")) content_token = _token_from_url(config.config["document"]["url"], "access_token") claims = jwt.decode( content_token, JWT_SECRET, algorithms=["HS256"], audience=ONLYOFFICE_TOKEN_AUDIENCE, ) assert claims["tenant_id"] == "tenant-a" assert claims["resource_scope"] == PLATFORM_KNOWLEDGE_SCOPE assert claims["editable"] is False resolved, _, _ = resolve_onlyoffice_content( db=db, storage_root=tmp_path, document_id=document_id, access_token=content_token, ) assert resolved.read_bytes() == content assert resolved.is_relative_to(platform_root) with pytest.raises(ValueError, match="只读"): service.build_onlyoffice_config( document_id, _user("tenant-a"), editable=True, ) def test_view_session_never_writes_and_wrong_key_does_not_claim_session( tmp_path, monkeypatch, ) -> None: _configure_onlyoffice(monkeypatch) factory = _factory() with factory() as db: service = KnowledgeService(storage_root=tmp_path, db=db, tenant_id="tenant-a") uploaded = service.upload_document( "制度政策", "制度.docx", _docx_bytes("original"), _user("tenant-a"), ) view_config = service.build_onlyoffice_config(uploaded.id, _user("tenant-a")) view_token = _token_from_url( view_config.config["editorConfig"]["callbackUrl"], "callback_token", ) with pytest.raises(OnlyOfficeSecurityError, match="只读"): handle_onlyoffice_callback( db=db, storage_root=tmp_path, document_id=uploaded.id, callback_token=view_token, payload={ "status": 2, "key": view_config.config["document"]["key"], "url": "https://docs.example.com/download/view", }, ) edit_config = service.build_onlyoffice_config( uploaded.id, _user("tenant-a"), editable=True, ) edit_token = _token_from_url( edit_config.config["editorConfig"]["callbackUrl"], "callback_token", ) with pytest.raises(OnlyOfficeSecurityError, match="key"): handle_onlyoffice_callback( db=db, storage_root=tmp_path, document_id=uploaded.id, callback_token=edit_token, payload={ "status": 2, "key": "wrong-key", "url": "https://docs.example.com/download/edit", }, ) edit_claims = jwt.decode( edit_token, JWT_SECRET, algorithms=["HS256"], audience=ONLYOFFICE_TOKEN_AUDIENCE, ) assert db.get(KnowledgeOnlyOfficeSession, edit_claims["jti"]).status == "active" assert service.get_document_entry(uploaded.id)["version_number"] == 1 def test_edit_callback_is_one_time_and_replay_is_rejected(tmp_path, monkeypatch) -> None: _configure_onlyoffice(monkeypatch) replacement = _docx_bytes("replacement") monkeypatch.setattr( "app.services.knowledge_onlyoffice_callback.download_onlyoffice_document", lambda _url, *, expected_filename: replacement, ) factory = _factory() with factory() as db: service = KnowledgeService(storage_root=tmp_path, db=db, tenant_id="tenant-a") uploaded = service.upload_document( "制度政策", "制度.docx", _docx_bytes("original"), _user("tenant-a"), ) config = service.build_onlyoffice_config( uploaded.id, _user("tenant-a"), editable=True, ) callback_token = _token_from_url( config.config["editorConfig"]["callbackUrl"], "callback_token", ) payload = { "status": 2, "key": config.config["document"]["key"], "url": "https://docs.example.com/download/final", "users": ["editor"], } handle_onlyoffice_callback( db=db, storage_root=tmp_path, document_id=uploaded.id, callback_token=callback_token, payload=payload, ) claims = jwt.decode( callback_token, JWT_SECRET, algorithms=["HS256"], audience=ONLYOFFICE_TOKEN_AUDIENCE, ) assert db.get(KnowledgeOnlyOfficeSession, claims["jti"]).status == "consumed" assert service.get_document_entry(uploaded.id)["version_number"] == 2 assert service.get_document_content(uploaded.id)[0].read_bytes() == replacement with pytest.raises(OnlyOfficeReplayError): handle_onlyoffice_callback( db=db, storage_root=tmp_path, document_id=uploaded.id, callback_token=callback_token, payload=payload, ) def test_expired_or_stale_version_session_is_rejected(tmp_path, monkeypatch) -> None: _configure_onlyoffice(monkeypatch) factory = _factory() with factory() as db: service = KnowledgeService(storage_root=tmp_path, db=db, tenant_id="tenant-a") uploaded = service.upload_document( "制度政策", "制度.docx", _docx_bytes("original"), _user("tenant-a"), ) config = service.build_onlyoffice_config(uploaded.id, _user("tenant-a")) content_token = _token_from_url(config.config["document"]["url"], "access_token") claims = jwt.decode( content_token, JWT_SECRET, algorithms=["HS256"], audience=ONLYOFFICE_TOKEN_AUDIENCE, ) row = db.get(KnowledgeOnlyOfficeSession, claims["jti"]) row.expires_at = datetime.now(UTC) - timedelta(seconds=1) db.commit() with pytest.raises(OnlyOfficeSecurityError): resolve_onlyoffice_content( db=db, storage_root=tmp_path, document_id=uploaded.id, access_token=content_token, ) def test_callback_ssrf_attempt_fails_session_without_overwriting_document( tmp_path, monkeypatch, ) -> None: _configure_onlyoffice(monkeypatch) factory = _factory() with factory() as db: service = KnowledgeService(storage_root=tmp_path, db=db, tenant_id="tenant-a") original = _docx_bytes("original") uploaded = service.upload_document( "制度政策", "制度.docx", original, _user("tenant-a"), ) config = service.build_onlyoffice_config( uploaded.id, _user("tenant-a"), editable=True, ) callback_token = _token_from_url( config.config["editorConfig"]["callbackUrl"], "callback_token", ) with pytest.raises(OnlyOfficeSecurityError, match="不属于"): handle_onlyoffice_callback( db=db, storage_root=tmp_path, document_id=uploaded.id, callback_token=callback_token, payload={ "status": 2, "key": config.config["document"]["key"], "url": "https://evil.example/internal-metadata", }, ) claims = jwt.decode( callback_token, JWT_SECRET, algorithms=["HS256"], audience=ONLYOFFICE_TOKEN_AUDIENCE, ) assert db.get(KnowledgeOnlyOfficeSession, claims["jti"]).status == "failed" assert service.get_document_content(uploaded.id)[0].read_bytes() == original assert service.get_document_entry(uploaded.id)["version_number"] == 1 def test_download_target_rejects_wrong_origin_private_dns_and_redirects(monkeypatch) -> None: _configure_onlyoffice(monkeypatch) with pytest.raises(OnlyOfficeSecurityError, match="不属于"): security_module._validate_download_target("https://evil.example/download") monkeypatch.setattr( security_module.socket, "getaddrinfo", lambda *_args, **_kwargs: [ (2, 1, 6, "", ("127.0.0.1", 443)), ], ) with pytest.raises(OnlyOfficeSecurityError, match="非公网"): security_module._validate_download_target("https://docs.example.com/download") monkeypatch.setattr( security_module.socket, "getaddrinfo", lambda *_args, **_kwargs: [ (2, 1, 6, "", ("8.8.8.8", 443)), ], ) class RedirectResponse: status = 302 @staticmethod def getheader(_name): return None class FakeConnection: def request(self, *_args, **_kwargs) -> None: pass @staticmethod def getresponse(): return RedirectResponse() def close(self) -> None: pass pinned: list[str] = [] def fake_open(_parsed, resolved_ip): pinned.append(resolved_ip) return FakeConnection() monkeypatch.setattr(security_module, "_open_pinned_connection", fake_open) with pytest.raises(OnlyOfficeSecurityError, match="状态码 302"): download_onlyoffice_document( "https://docs.example.com/download", expected_filename="制度.docx", ) assert pinned == ["8.8.8.8"] def test_download_enforces_mime_size_and_ooxml_structure(monkeypatch) -> None: _configure_onlyoffice(monkeypatch) monkeypatch.setattr( security_module.socket, "getaddrinfo", lambda *_args, **_kwargs: [(2, 1, 6, "", ("8.8.8.8", 443))], ) class Response: status = 200 def __init__(self, body: bytes, content_type: str, declared_size: int | None = None): self.body = body self.content_type = content_type self.declared_size = declared_size def getheader(self, name): if name == "Content-Type": return self.content_type if name == "Content-Length": return None if self.declared_size is None else str(self.declared_size) return None def read(self, limit): return self.body[:limit] class Connection: def __init__(self, response): self.response = response def request(self, *_args, **_kwargs) -> None: pass def getresponse(self): return self.response def close(self) -> None: pass response = Response( _docx_bytes("safe"), "application/vnd.openxmlformats-officedocument.wordprocessingml.document", ) monkeypatch.setattr( security_module, "_open_pinned_connection", lambda *_args: Connection(response), ) downloaded = download_onlyoffice_document( "https://docs.example.com/download", expected_filename="制度.docx", ) with ZipFile(BytesIO(downloaded)) as archive: assert b"safe" in archive.read("word/document.xml") response.content_type = "text/html" with pytest.raises(OnlyOfficeSecurityError, match="MIME"): download_onlyoffice_document( "https://docs.example.com/download", expected_filename="制度.docx", ) response.content_type = "application/octet-stream" response.declared_size = 200 * 1024 * 1024 with pytest.raises(OnlyOfficeSecurityError, match="大小"): download_onlyoffice_document( "https://docs.example.com/download", expected_filename="制度.docx", ) response.declared_size = None response.body = b"not-a-zip" with pytest.raises(OnlyOfficeSecurityError, match="OOXML"): download_onlyoffice_document( "https://docs.example.com/download", expected_filename="制度.docx", )