| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223 |
- """使用真实路由和 SQLite;对象存储替身仅隔离外部 MinIO。"""
- import io
- import pytest
- from fastapi import FastAPI
- from fastapi.testclient import TestClient
- from test_doclib import db, svc, seed_file
- from app.api.doclib import router
- from app.core.auth import get_current_user
- from app.core.config import settings
- from app.core.database import get_db
- from app.services import doclib_storage
- class StoredResponse(io.BytesIO):
- def release_conn(self):
- self.released = True
- def stream(self, amt=65536):
- while True:
- chunk = self.read(amt)
- if not chunk:
- break
- yield chunk
- class MemoryStorage:
- data = {}
- response = None
- def upload(self, key, file, length):
- self.data[key] = file.read()
- def open(self, key):
- self.response = StoredResponse(self.data[key])
- return self.response
- def discard(self, key):
- self.data.pop(key, None)
- @pytest.fixture
- def client(db, monkeypatch):
- storage = MemoryStorage()
- storage.data = {}
- monkeypatch.setattr(doclib_storage, 'private_storage', lambda: storage)
- app = FastAPI()
- app.include_router(router, prefix='/api')
- app.dependency_overrides[get_db] = lambda: db
- identity = {'user_id': 'owner', 'tenant_no': 'a', 'role': 'admin'}
- app.dependency_overrides[get_current_user] = lambda: identity
- with TestClient(app, headers={'Authorization': 'Bearer test'}) as client:
- yield client, identity, storage
- def test_upload_download_and_revoked_access(client):
- c, identity, storage = client
- result = c.post('/api/web/doclib/upload', files={'file': ('中文.json', b'{"hello":true}', 'application/json')})
- assert result.status_code == 200, result.text
- doc = result.json()['data']
- assert doc['name'] == '中文.json' and doc['size'] == 14
- assert c.put('/api/web/doclib/files/'+doc['id']+'/shares', json={'userIds': ['reader']}).status_code == 200
- identity['user_id'] = 'reader'
- downloaded = c.get('/api/web/doclib/files/'+doc['id']+'/download')
- assert downloaded.content == b'{"hello":true}'
- assert downloaded.headers['content-type'] == 'application/octet-stream'
- assert 'attachment;' in downloaded.headers['content-disposition']
- assert downloaded.headers['cache-control'] == 'no-store'
- assert storage.response.closed and storage.response.released
- assert c.post('/api/web/doclib/files/batch-delete', json={'ids': [doc['id']]}).status_code == 403
- identity['user_id'] = 'owner'
- c.put('/api/web/doclib/files/'+doc['id']+'/shares', json={'userIds': []})
- identity['user_id'] = 'reader'
- assert c.get('/api/web/doclib/files/'+doc['id']+'/download').status_code == 404
- def test_upload_limits_folder_access_and_malicious_body(client, db, monkeypatch):
- c, identity, storage = client
- monkeypatch.setattr(settings, 'DOCLIB_MAX_UPLOAD_BYTES', 5)
- assert c.post('/api/web/doclib/upload', files={'file': ('large.txt', b'123456')}).status_code == 413
- assert not storage.data
- foreign = svc(db, 'reader').create_folder('other', '')
- assert c.post('/api/web/doclib/upload', data={'folderId': foreign['folderId']}, files={'file': ('a', b'1')}).status_code == 403
- assert not storage.data
- assert c.post('/api/web/doclib/folders', json={'name': 'bad', 'tenant_no': 'b'}).status_code == 422
- assert c.get('/api/web/doclib/files?page=0').status_code == 422
- assert c.get('/api/web/doclib/files?pageSize=999').status_code == 422
- def test_download_requires_authentication(db):
- app = FastAPI()
- app.include_router(router, prefix='/api')
- app.dependency_overrides[get_db] = lambda: db
- with TestClient(app) as c:
- assert c.get('/api/web/doclib/files/any/download').status_code == 401
- def test_audit_does_not_consume_document_stream_and_still_logs(monkeypatch):
- import asyncio
- from starlette.requests import Request
- from starlette.responses import StreamingResponse
- from app.core.api_request_log_middleware import ApiRequestLogMiddleware
- from app.core import api_request_log_middleware as audit
- logs, consumed = [], []
- monkeypatch.setattr(settings, 'API_REQUEST_LOG_ENABLED', True)
- monkeypatch.setattr(audit, '_schedule_persist_log', lambda **data: logs.append(data))
- async def content():
- consumed.append(True)
- yield b'large binary'
- response = StreamingResponse(content(), media_type='application/octet-stream', headers={'Content-Disposition': 'attachment; filename=test'})
- async def receive():
- return {'type': 'http.request', 'body': b'', 'more_body': False}
- request = Request({'type': 'http', 'method': 'GET', 'path': '/api/web/doclib/files/x/download', 'headers': [], 'query_string': b''}, receive)
- async def call_next(req):
- return response
- result = asyncio.run(ApiRequestLogMiddleware(FastAPI()).dispatch(request, call_next))
- assert result is response
- assert not consumed
- assert logs and logs[0]['status_code'] == 200
- assert 'omitted' in logs[0]['response_body']
- def test_encrypted_file_list_and_download_keep_existing_middleware(client, db, monkeypatch):
- import time
- from jose import jwt
- from app.core import api_crypto, api_crypto_middleware as crypto, api_request_log_middleware as audit
- c, identity, storage = client
- app = FastAPI()
- app.include_router(router, prefix="/api")
- app.dependency_overrides = c.app.dependency_overrides.copy()
- app.add_middleware(crypto.ApiCryptoMiddleware)
- app.add_middleware(audit.ApiRequestLogMiddleware)
- c = TestClient(app, headers={'Authorization': 'Bearer test'})
- logs = []
- monkeypatch.setattr(settings, 'API_ENCRYPT_ENABLED', True)
- monkeypatch.setattr(settings, 'API_ENCRYPT_FIXED_SECRET', 'doclib-test-key')
- monkeypatch.setattr(settings, 'API_REQUEST_LOG_ENABLED', True)
- monkeypatch.setattr(audit, '_schedule_persist_log', lambda **kwargs: logs.append(kwargs))
- async def nonce_ok(*args):
- return True
- monkeypatch.setattr(crypto, '_mark_nonce_once', nonce_ok)
- token = jwt.encode({'sub': 'owner'}, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
- uploaded = c.post('/api/web/doclib/upload', files={'file': ('file.txt', b'streamed')}).json()['data']
- assert any(log['request_body'] == '[multipart/form-data body omitted]' for log in logs)
- def encrypted_get(path, params):
- ts, nonce = int(time.time()*1000), api_crypto.gen_nonce_hex()
- key = api_crypto.derive_key(fixed_secret='doclib-test-key', user_id='owner', ts=ts, nonce=nonce)
- return c.post(path, json={'enc': 1, 'ts': ts, 'nonce': nonce, 'edata': api_crypto.encrypt_json({'data': params}, key)},
- headers={'Authorization': 'Bearer '+token, 'X-HTTP-Method-Override': 'GET'})
- listed = encrypted_get('/api/web/doclib/files', {'scope': 'mine', 'page': 1, 'pageSize': 20})
- body = listed.json()
- assert body['encrypted'] is True
- key = api_crypto.derive_key(fixed_secret='doclib-test-key', user_id='owner', ts=body['ts'], nonce=body['nonce'])
- assert api_crypto.decrypt_json(body['edata'], key)['data']['total'] == 1
- downloaded = encrypted_get('/api/web/doclib/files/'+uploaded['id']+'/download', {})
- assert downloaded.content == b'streamed'
- assert any(log['response_body'] == '[document attachment body omitted]' for log in logs)
- c.close()
- def test_failed_upload_discards_ambiguous_object(client):
- c, identity, storage = client
- def upload_then_timeout(key, file, length):
- storage.data[key] = file.read()
- raise TimeoutError('reply lost after MinIO stored object')
- storage.upload = upload_then_timeout
- result = c.post('/api/web/doclib/upload', files={'file': ('file.txt', b'abc')})
- assert result.status_code == 503
- assert storage.data == {}
- def test_chunked_upload_stops_before_consuming_full_body(client, monkeypatch):
- import asyncio
- c, _, _ = client
- monkeypatch.setattr(settings, 'DOCLIB_MAX_UPLOAD_BYTES', 5)
- prefix = b'--test\r\nContent-Disposition: form-data; name="file"; filename="large.txt"\r\n\r\n'
- chunks = [prefix] + [b'x' * 16384] * 128 + [b'\r\n--test--\r\n']
- consumed, output = [], []
- async def receive():
- index = len(consumed)
- consumed.append(index)
- return {'type': 'http.request', 'body': chunks[index], 'more_body': index < len(chunks)-1}
- async def send(message):
- output.append(message)
- scope = {'type': 'http', 'asgi': {'version': '3.0'}, 'http_version': '1.1', 'method': 'POST',
- 'scheme': 'http', 'path': '/api/web/doclib/upload', 'raw_path': b'/api/web/doclib/upload',
- 'query_string': b'', 'headers': [(b'content-type', b'multipart/form-data; boundary=test'), (b'authorization', b'Bearer test')],
- 'server': ('testserver', 80), 'client': ('testclient', 1234)}
- asyncio.run(c.app(scope, receive, send))
- assert next(m for m in output if m['type'] == 'http.response.start')['status'] == 413
- assert len(consumed) < 10
- @pytest.mark.parametrize('encrypted', [True, False])
- def test_upload_rejects_nonmultipart_without_reading_body(client, monkeypatch, encrypted):
- from app.core import api_crypto_middleware as crypto, api_request_log_middleware as audit
- c, _, _ = client
- app = FastAPI()
- app.include_router(router, prefix='/api')
- app.dependency_overrides = c.app.dependency_overrides.copy()
- app.add_middleware(audit.ApiRequestLogMiddleware)
- app.add_middleware(crypto.ApiCryptoMiddleware)
- monkeypatch.setattr(settings, 'API_ENCRYPT_ENABLED', encrypted)
- monkeypatch.setattr(settings, 'API_ENCRYPT_FIXED_SECRET', 'test-key')
- monkeypatch.setattr(settings, 'API_ENCRYPT_EXCLUDE_PATHS', '')
- monkeypatch.setattr(settings, 'API_REQUEST_LOG_ENABLED', True)
- monkeypatch.setattr(settings, 'DOCLIB_MAX_UPLOAD_BYTES', 5)
- logs, body_reads = [], []
- monkeypatch.setattr(audit, '_schedule_persist_log', lambda **data: logs.append(data))
- from starlette.requests import Request
- original = Request.body
- async def tracked_body(request):
- body_reads.append(True)
- return await original(request)
- monkeypatch.setattr(Request, 'body', tracked_body)
- with TestClient(app) as test_client:
- response = test_client.post('/api/web/doclib/upload', content=b'x' * (2*1024*1024),
- headers={'Content-Type': 'application/octet-stream', 'Authorization': 'Bearer test', 'X-HTTP-Method-Override': 'GET'})
- assert response.status_code == 415
- assert not body_reads
- assert logs and logs[0]['status_code'] == 415
|