mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-10-24 12:07:58 +09:00
303 lines
12 KiB
Python
303 lines
12 KiB
Python
"""Tests for the centralized error log: service dedup, admin API, client
|
|
ingestion, and the 500-handler recording hook."""
|
|
import pytest
|
|
|
|
from app import db
|
|
from app.models.error_log import ErrorLog
|
|
from app.services import error_log_service
|
|
|
|
|
|
def _record(message='boom', **kwargs):
|
|
kwargs.setdefault('source', 'backend')
|
|
return error_log_service.record_error(message=message, **kwargs)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Service: recording + dedup
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
def test_record_creates_entry(app):
|
|
entry, deduplicated = _record(
|
|
message='something broke', exception_type='RuntimeError',
|
|
endpoint='/api/v1/apps', method='GET', context={'k': 'v'})
|
|
assert not deduplicated
|
|
assert entry.id is not None
|
|
assert entry.count == 1
|
|
assert entry.fingerprint
|
|
assert entry.get_context() == {'k': 'v'}
|
|
assert entry.resolved is False
|
|
|
|
|
|
def test_dedup_increments_count(app):
|
|
first, _ = _record(message='same crash', exception_type='ValueError')
|
|
second, deduplicated = _record(message='same crash', exception_type='ValueError')
|
|
assert deduplicated
|
|
assert second.id == first.id
|
|
assert second.count == 2
|
|
assert ErrorLog.query.count() == 1
|
|
|
|
|
|
def test_resolved_error_dedups_no_more(app):
|
|
first, _ = _record(message='fixed crash')
|
|
error_log_service.set_resolved(first.id, True)
|
|
second, deduplicated = _record(message='fixed crash')
|
|
assert not deduplicated
|
|
assert second.id != first.id
|
|
assert ErrorLog.query.count() == 2
|
|
|
|
|
|
def test_record_error_never_raises(app, monkeypatch):
|
|
# Broken query object -> failure is swallowed, session rolled back.
|
|
monkeypatch.setattr(error_log_service.ErrorLog, 'query', None)
|
|
entry, deduplicated = _record(message='still safe')
|
|
assert entry is None and deduplicated is False
|
|
assert db.session # session usable after rollback
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Admin API
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
def test_list_filters(client, auth_headers):
|
|
_record(message='backend crash A', endpoint='/api/v1/x')
|
|
_record(message='frontend crash B', source='frontend')
|
|
entry, _ = _record(message='backend crash C resolved')
|
|
error_log_service.set_resolved(entry.id, True)
|
|
|
|
res = client.get('/api/v1/error-logs?source=frontend', headers=auth_headers)
|
|
assert res.status_code == 200
|
|
data = res.get_json()
|
|
assert data['total'] == 1
|
|
assert data['items'][0]['message'] == 'frontend crash B'
|
|
|
|
res = client.get('/api/v1/error-logs?resolved=true', headers=auth_headers)
|
|
assert res.get_json()['total'] == 1
|
|
|
|
res = client.get('/api/v1/error-logs?search=crash+A', headers=auth_headers)
|
|
assert res.get_json()['total'] == 1
|
|
|
|
|
|
def test_list_requires_admin(client, app):
|
|
from app.models import User
|
|
from flask_jwt_extended import create_access_token
|
|
from werkzeug.security import generate_password_hash
|
|
with app.app_context():
|
|
user = User(email='dev@t.local', username='dev',
|
|
password_hash=generate_password_hash('x'),
|
|
role=User.ROLE_DEVELOPER, is_active=True)
|
|
db.session.add(user)
|
|
db.session.commit()
|
|
headers = {'Authorization': f'Bearer {create_access_token(identity=user.id)}'}
|
|
assert client.get('/api/v1/error-logs', headers=headers).status_code == 403
|
|
|
|
|
|
def test_resolve_and_unresolve(client, auth_headers):
|
|
entry, _ = _record(message='to resolve')
|
|
res = client.post(f'/api/v1/error-logs/{entry.id}/resolve',
|
|
json={'resolved': True}, headers=auth_headers)
|
|
assert res.status_code == 200
|
|
assert res.get_json()['resolved'] is True
|
|
|
|
res = client.post(f'/api/v1/error-logs/{entry.id}/resolve',
|
|
json={'resolved': False}, headers=auth_headers)
|
|
assert res.get_json()['resolved'] is False
|
|
|
|
assert client.post('/api/v1/error-logs/9999/resolve', json={'resolved': True},
|
|
headers=auth_headers).status_code == 404
|
|
assert client.post(f'/api/v1/error-logs/{entry.id}/resolve', json={},
|
|
headers=auth_headers).status_code == 400
|
|
|
|
|
|
def test_delete(client, auth_headers):
|
|
entry, _ = _record(message='to delete')
|
|
assert client.delete(f'/api/v1/error-logs/{entry.id}',
|
|
headers=auth_headers).status_code == 200
|
|
assert ErrorLog.query.count() == 0
|
|
assert client.delete(f'/api/v1/error-logs/{entry.id}',
|
|
headers=auth_headers).status_code == 404
|
|
|
|
|
|
def test_stats(client, auth_headers):
|
|
_record(message='s1')
|
|
_record(message='s2', source='frontend')
|
|
entry, _ = _record(message='s3')
|
|
error_log_service.set_resolved(entry.id, True)
|
|
|
|
res = client.get('/api/v1/error-logs/stats', headers=auth_headers)
|
|
assert res.status_code == 200
|
|
stats = res.get_json()
|
|
assert stats['total'] == 3
|
|
assert stats['unresolved'] == 2
|
|
assert stats['last_24h'] == 3
|
|
assert stats['by_source'] == {'backend': 2, 'frontend': 1}
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Client ingestion endpoint
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
def test_client_endpoint_requires_message(client):
|
|
res = client.post('/api/v1/error-logs/client', json={})
|
|
assert res.status_code == 400
|
|
res = client.post('/api/v1/error-logs/client', json={'message': 42})
|
|
assert res.status_code == 400
|
|
|
|
|
|
def test_client_endpoint_records_frontend_error(client):
|
|
res = client.post('/api/v1/error-logs/client', json={
|
|
'message': 'TypeError: x is not a function',
|
|
'exception_type': 'TypeError',
|
|
'endpoint': '/apps/123',
|
|
'context': {'component': 'AppList'},
|
|
})
|
|
assert res.status_code == 201
|
|
body = res.get_json()
|
|
assert body['deduplicated'] is False
|
|
|
|
entry = ErrorLog.query.get(body['id'])
|
|
assert entry.source == 'frontend'
|
|
assert entry.endpoint == '/apps/123'
|
|
|
|
# Same error again dedups.
|
|
res = client.post('/api/v1/error-logs/client', json={
|
|
'message': 'TypeError: x is not a function',
|
|
'exception_type': 'TypeError',
|
|
'endpoint': '/apps/123',
|
|
})
|
|
assert res.get_json()['deduplicated'] is True
|
|
assert entry.count == 2
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# 500-handler hook
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
@pytest.mark.fresh_app
|
|
def test_500_handler_records_error_and_keeps_response(client, app):
|
|
@app.route('/api/v1/__test_boom')
|
|
def _boom():
|
|
raise RuntimeError('kaboom')
|
|
|
|
# Flask only routes to the 500 handler when not propagating exceptions.
|
|
app.config['TESTING'] = False
|
|
app.config['PROPAGATE_EXCEPTIONS'] = False
|
|
|
|
res = client.get('/api/v1/__test_boom')
|
|
assert res.status_code == 500
|
|
request_id = res.headers['X-Request-ID']
|
|
assert res.get_json() == {
|
|
'error': 'Internal server error',
|
|
'status': 500,
|
|
'code': 'internal_error',
|
|
'request_id': request_id,
|
|
}
|
|
|
|
entry = ErrorLog.query.filter_by(source='backend').first()
|
|
assert entry is not None
|
|
assert entry.exception_type == 'RuntimeError'
|
|
assert 'kaboom' in entry.message
|
|
assert 'RuntimeError' in entry.traceback
|
|
assert entry.endpoint == '/api/v1/__test_boom'
|
|
assert entry.method == 'GET'
|
|
|
|
|
|
@pytest.mark.fresh_app
|
|
def test_500_from_a_db_error_is_still_recorded(client, app):
|
|
"""A crash caused by the database must not be the one crash we cannot log.
|
|
|
|
The 500 handler shares the app-context-scoped ``db.session`` with the view
|
|
that just failed. When the failure IS a database error, that session is
|
|
already in a failed transaction, so ``record_error``'s first query raises
|
|
PendingRollbackError -- which its own never-raise contract then swallows,
|
|
silently dropping the report. Rolling back before recording is what makes
|
|
this class of 500 visible in the tracker at all.
|
|
"""
|
|
@app.route('/api/v1/__test_db_boom')
|
|
def _db_boom():
|
|
# A REAL failed flush, not a hand-built IntegrityError: only an actual
|
|
# constraint violation leaves the session in the failed-transaction
|
|
# state that makes the next query raise. `message` is NOT NULL.
|
|
db.session.add(ErrorLog(fingerprint='x' * 64, source='backend',
|
|
level='error', message=None))
|
|
db.session.flush() # raises IntegrityError, poisoning the session
|
|
|
|
app.config['TESTING'] = False
|
|
app.config['PROPAGATE_EXCEPTIONS'] = False
|
|
|
|
res = client.get('/api/v1/__test_db_boom')
|
|
assert res.status_code == 500
|
|
|
|
entry = ErrorLog.query.filter_by(endpoint='/api/v1/__test_db_boom').first()
|
|
assert entry is not None, 'a DB-caused 500 was not recorded'
|
|
assert entry.exception_type == 'IntegrityError'
|
|
|
|
|
|
@pytest.mark.fresh_app
|
|
def test_500_handler_does_not_commit_the_crashed_request_work(client, app):
|
|
"""record_error's commit() must not persist what the failing view left pending.
|
|
|
|
Without the rollback, the handler's commit flushes any ORM state still
|
|
sitting in the shared session -- so a view that add()s a row and then
|
|
crashes ships a half-applied write.
|
|
"""
|
|
from app.models.error_log import ErrorLog as _EL
|
|
|
|
@app.route('/api/v1/__test_partial_write')
|
|
def _partial():
|
|
db.session.add(_EL(fingerprint='pending-should-not-persist',
|
|
source='backend', level='error', message='pending'))
|
|
db.session.flush()
|
|
raise RuntimeError('crash after a pending write')
|
|
|
|
app.config['TESTING'] = False
|
|
app.config['PROPAGATE_EXCEPTIONS'] = False
|
|
|
|
assert client.get('/api/v1/__test_partial_write').status_code == 500
|
|
|
|
assert ErrorLog.query.filter_by(
|
|
fingerprint='pending-should-not-persist').count() == 0
|
|
# ...but the crash itself was still recorded.
|
|
assert ErrorLog.query.filter_by(
|
|
endpoint='/api/v1/__test_partial_write').count() == 1
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Client ingestion: attribution
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
def test_client_report_is_attributed_when_a_token_is_sent(client, auth_headers):
|
|
"""The frontend attaches Authorization so the row is not anonymous.
|
|
|
|
ErrorLog.user_id, to_dict()['username'] and the "User #N" row in
|
|
pages/Errors.jsx were dead end-to-end while reportClientError sent no
|
|
header: every frontend row was anonymous no matter who was logged in.
|
|
"""
|
|
res = client.post('/api/v1/error-logs/client',
|
|
json={'message': 'boom in the browser'},
|
|
headers=auth_headers)
|
|
assert res.status_code == 201
|
|
entry = db.session.get(ErrorLog, res.get_json()['id'])
|
|
assert entry.user_id is not None
|
|
assert entry.to_dict()['username'] == 'testadmin'
|
|
|
|
|
|
def test_client_report_without_a_token_is_anonymous_not_rejected(client):
|
|
"""Reporting must still work with no session -- that is when crashes happen."""
|
|
res = client.post('/api/v1/error-logs/client',
|
|
json={'message': 'anonymous browser crash'})
|
|
assert res.status_code == 201
|
|
entry = db.session.get(ErrorLog, res.get_json()['id'])
|
|
assert entry.user_id is None
|
|
|
|
|
|
def test_client_report_with_a_junk_token_is_anonymous_not_a_500(client):
|
|
"""verify_jwt_in_request(optional=True) tolerates a MISSING token only --
|
|
a malformed or expired one raises, and the route's except is what turns
|
|
that into an anonymous report instead of a crash."""
|
|
res = client.post('/api/v1/error-logs/client',
|
|
json={'message': 'stale session crash'},
|
|
headers={'Authorization': 'Bearer not.a.jwt'})
|
|
assert res.status_code == 201
|
|
entry = db.session.get(ErrorLog, res.get_json()['id'])
|
|
assert entry.user_id is None
|