test_doclib_api.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223
  1. """使用真实路由和 SQLite;对象存储替身仅隔离外部 MinIO。"""
  2. import io
  3. import pytest
  4. from fastapi import FastAPI
  5. from fastapi.testclient import TestClient
  6. from test_doclib import db, svc, seed_file
  7. from app.api.doclib import router
  8. from app.core.auth import get_current_user
  9. from app.core.config import settings
  10. from app.core.database import get_db
  11. from app.services import doclib_storage
  12. class StoredResponse(io.BytesIO):
  13. def release_conn(self):
  14. self.released = True
  15. def stream(self, amt=65536):
  16. while True:
  17. chunk = self.read(amt)
  18. if not chunk:
  19. break
  20. yield chunk
  21. class MemoryStorage:
  22. data = {}
  23. response = None
  24. def upload(self, key, file, length):
  25. self.data[key] = file.read()
  26. def open(self, key):
  27. self.response = StoredResponse(self.data[key])
  28. return self.response
  29. def discard(self, key):
  30. self.data.pop(key, None)
  31. @pytest.fixture
  32. def client(db, monkeypatch):
  33. storage = MemoryStorage()
  34. storage.data = {}
  35. monkeypatch.setattr(doclib_storage, 'private_storage', lambda: storage)
  36. app = FastAPI()
  37. app.include_router(router, prefix='/api')
  38. app.dependency_overrides[get_db] = lambda: db
  39. identity = {'user_id': 'owner', 'tenant_no': 'a', 'role': 'admin'}
  40. app.dependency_overrides[get_current_user] = lambda: identity
  41. with TestClient(app, headers={'Authorization': 'Bearer test'}) as client:
  42. yield client, identity, storage
  43. def test_upload_download_and_revoked_access(client):
  44. c, identity, storage = client
  45. result = c.post('/api/web/doclib/upload', files={'file': ('中文.json', b'{"hello":true}', 'application/json')})
  46. assert result.status_code == 200, result.text
  47. doc = result.json()['data']
  48. assert doc['name'] == '中文.json' and doc['size'] == 14
  49. assert c.put('/api/web/doclib/files/'+doc['id']+'/shares', json={'userIds': ['reader']}).status_code == 200
  50. identity['user_id'] = 'reader'
  51. downloaded = c.get('/api/web/doclib/files/'+doc['id']+'/download')
  52. assert downloaded.content == b'{"hello":true}'
  53. assert downloaded.headers['content-type'] == 'application/octet-stream'
  54. assert 'attachment;' in downloaded.headers['content-disposition']
  55. assert downloaded.headers['cache-control'] == 'no-store'
  56. assert storage.response.closed and storage.response.released
  57. assert c.post('/api/web/doclib/files/batch-delete', json={'ids': [doc['id']]}).status_code == 403
  58. identity['user_id'] = 'owner'
  59. c.put('/api/web/doclib/files/'+doc['id']+'/shares', json={'userIds': []})
  60. identity['user_id'] = 'reader'
  61. assert c.get('/api/web/doclib/files/'+doc['id']+'/download').status_code == 404
  62. def test_upload_limits_folder_access_and_malicious_body(client, db, monkeypatch):
  63. c, identity, storage = client
  64. monkeypatch.setattr(settings, 'DOCLIB_MAX_UPLOAD_BYTES', 5)
  65. assert c.post('/api/web/doclib/upload', files={'file': ('large.txt', b'123456')}).status_code == 413
  66. assert not storage.data
  67. foreign = svc(db, 'reader').create_folder('other', '')
  68. assert c.post('/api/web/doclib/upload', data={'folderId': foreign['folderId']}, files={'file': ('a', b'1')}).status_code == 403
  69. assert not storage.data
  70. assert c.post('/api/web/doclib/folders', json={'name': 'bad', 'tenant_no': 'b'}).status_code == 422
  71. assert c.get('/api/web/doclib/files?page=0').status_code == 422
  72. assert c.get('/api/web/doclib/files?pageSize=999').status_code == 422
  73. def test_download_requires_authentication(db):
  74. app = FastAPI()
  75. app.include_router(router, prefix='/api')
  76. app.dependency_overrides[get_db] = lambda: db
  77. with TestClient(app) as c:
  78. assert c.get('/api/web/doclib/files/any/download').status_code == 401
  79. def test_audit_does_not_consume_document_stream_and_still_logs(monkeypatch):
  80. import asyncio
  81. from starlette.requests import Request
  82. from starlette.responses import StreamingResponse
  83. from app.core.api_request_log_middleware import ApiRequestLogMiddleware
  84. from app.core import api_request_log_middleware as audit
  85. logs, consumed = [], []
  86. monkeypatch.setattr(settings, 'API_REQUEST_LOG_ENABLED', True)
  87. monkeypatch.setattr(audit, '_schedule_persist_log', lambda **data: logs.append(data))
  88. async def content():
  89. consumed.append(True)
  90. yield b'large binary'
  91. response = StreamingResponse(content(), media_type='application/octet-stream', headers={'Content-Disposition': 'attachment; filename=test'})
  92. async def receive():
  93. return {'type': 'http.request', 'body': b'', 'more_body': False}
  94. request = Request({'type': 'http', 'method': 'GET', 'path': '/api/web/doclib/files/x/download', 'headers': [], 'query_string': b''}, receive)
  95. async def call_next(req):
  96. return response
  97. result = asyncio.run(ApiRequestLogMiddleware(FastAPI()).dispatch(request, call_next))
  98. assert result is response
  99. assert not consumed
  100. assert logs and logs[0]['status_code'] == 200
  101. assert 'omitted' in logs[0]['response_body']
  102. def test_encrypted_file_list_and_download_keep_existing_middleware(client, db, monkeypatch):
  103. import time
  104. from jose import jwt
  105. from app.core import api_crypto, api_crypto_middleware as crypto, api_request_log_middleware as audit
  106. c, identity, storage = client
  107. app = FastAPI()
  108. app.include_router(router, prefix="/api")
  109. app.dependency_overrides = c.app.dependency_overrides.copy()
  110. app.add_middleware(crypto.ApiCryptoMiddleware)
  111. app.add_middleware(audit.ApiRequestLogMiddleware)
  112. c = TestClient(app, headers={'Authorization': 'Bearer test'})
  113. logs = []
  114. monkeypatch.setattr(settings, 'API_ENCRYPT_ENABLED', True)
  115. monkeypatch.setattr(settings, 'API_ENCRYPT_FIXED_SECRET', 'doclib-test-key')
  116. monkeypatch.setattr(settings, 'API_REQUEST_LOG_ENABLED', True)
  117. monkeypatch.setattr(audit, '_schedule_persist_log', lambda **kwargs: logs.append(kwargs))
  118. async def nonce_ok(*args):
  119. return True
  120. monkeypatch.setattr(crypto, '_mark_nonce_once', nonce_ok)
  121. token = jwt.encode({'sub': 'owner'}, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
  122. uploaded = c.post('/api/web/doclib/upload', files={'file': ('file.txt', b'streamed')}).json()['data']
  123. assert any(log['request_body'] == '[multipart/form-data body omitted]' for log in logs)
  124. def encrypted_get(path, params):
  125. ts, nonce = int(time.time()*1000), api_crypto.gen_nonce_hex()
  126. key = api_crypto.derive_key(fixed_secret='doclib-test-key', user_id='owner', ts=ts, nonce=nonce)
  127. return c.post(path, json={'enc': 1, 'ts': ts, 'nonce': nonce, 'edata': api_crypto.encrypt_json({'data': params}, key)},
  128. headers={'Authorization': 'Bearer '+token, 'X-HTTP-Method-Override': 'GET'})
  129. listed = encrypted_get('/api/web/doclib/files', {'scope': 'mine', 'page': 1, 'pageSize': 20})
  130. body = listed.json()
  131. assert body['encrypted'] is True
  132. key = api_crypto.derive_key(fixed_secret='doclib-test-key', user_id='owner', ts=body['ts'], nonce=body['nonce'])
  133. assert api_crypto.decrypt_json(body['edata'], key)['data']['total'] == 1
  134. downloaded = encrypted_get('/api/web/doclib/files/'+uploaded['id']+'/download', {})
  135. assert downloaded.content == b'streamed'
  136. assert any(log['response_body'] == '[document attachment body omitted]' for log in logs)
  137. c.close()
  138. def test_failed_upload_discards_ambiguous_object(client):
  139. c, identity, storage = client
  140. def upload_then_timeout(key, file, length):
  141. storage.data[key] = file.read()
  142. raise TimeoutError('reply lost after MinIO stored object')
  143. storage.upload = upload_then_timeout
  144. result = c.post('/api/web/doclib/upload', files={'file': ('file.txt', b'abc')})
  145. assert result.status_code == 503
  146. assert storage.data == {}
  147. def test_chunked_upload_stops_before_consuming_full_body(client, monkeypatch):
  148. import asyncio
  149. c, _, _ = client
  150. monkeypatch.setattr(settings, 'DOCLIB_MAX_UPLOAD_BYTES', 5)
  151. prefix = b'--test\r\nContent-Disposition: form-data; name="file"; filename="large.txt"\r\n\r\n'
  152. chunks = [prefix] + [b'x' * 16384] * 128 + [b'\r\n--test--\r\n']
  153. consumed, output = [], []
  154. async def receive():
  155. index = len(consumed)
  156. consumed.append(index)
  157. return {'type': 'http.request', 'body': chunks[index], 'more_body': index < len(chunks)-1}
  158. async def send(message):
  159. output.append(message)
  160. scope = {'type': 'http', 'asgi': {'version': '3.0'}, 'http_version': '1.1', 'method': 'POST',
  161. 'scheme': 'http', 'path': '/api/web/doclib/upload', 'raw_path': b'/api/web/doclib/upload',
  162. 'query_string': b'', 'headers': [(b'content-type', b'multipart/form-data; boundary=test'), (b'authorization', b'Bearer test')],
  163. 'server': ('testserver', 80), 'client': ('testclient', 1234)}
  164. asyncio.run(c.app(scope, receive, send))
  165. assert next(m for m in output if m['type'] == 'http.response.start')['status'] == 413
  166. assert len(consumed) < 10
  167. @pytest.mark.parametrize('encrypted', [True, False])
  168. def test_upload_rejects_nonmultipart_without_reading_body(client, monkeypatch, encrypted):
  169. from app.core import api_crypto_middleware as crypto, api_request_log_middleware as audit
  170. c, _, _ = client
  171. app = FastAPI()
  172. app.include_router(router, prefix='/api')
  173. app.dependency_overrides = c.app.dependency_overrides.copy()
  174. app.add_middleware(audit.ApiRequestLogMiddleware)
  175. app.add_middleware(crypto.ApiCryptoMiddleware)
  176. monkeypatch.setattr(settings, 'API_ENCRYPT_ENABLED', encrypted)
  177. monkeypatch.setattr(settings, 'API_ENCRYPT_FIXED_SECRET', 'test-key')
  178. monkeypatch.setattr(settings, 'API_ENCRYPT_EXCLUDE_PATHS', '')
  179. monkeypatch.setattr(settings, 'API_REQUEST_LOG_ENABLED', True)
  180. monkeypatch.setattr(settings, 'DOCLIB_MAX_UPLOAD_BYTES', 5)
  181. logs, body_reads = [], []
  182. monkeypatch.setattr(audit, '_schedule_persist_log', lambda **data: logs.append(data))
  183. from starlette.requests import Request
  184. original = Request.body
  185. async def tracked_body(request):
  186. body_reads.append(True)
  187. return await original(request)
  188. monkeypatch.setattr(Request, 'body', tracked_body)
  189. with TestClient(app) as test_client:
  190. response = test_client.post('/api/web/doclib/upload', content=b'x' * (2*1024*1024),
  191. headers={'Content-Type': 'application/octet-stream', 'Authorization': 'Bearer test', 'X-HTTP-Method-Override': 'GET'})
  192. assert response.status_code == 415
  193. assert not body_reads
  194. assert logs and logs[0]['status_code'] == 415