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",
)