Files
amnezia-web-panel/tests/test_api_error_responses.py
hell cf25956a9f fix(api): answer JSON on unhandled errors, and stop parsing blind on the client
"Error loading connections: JSON.parse: unexpected character at line 1 column
1 of the JSON data" is what the panel shows whenever a response is not JSON,
and it says nothing about the actual failure. Two halves:

The app has no handler for unhandled exceptions, so Starlette answers with a
plain-text "Internal Server Error" - unparseable for every caller that does
`await res.json()`. /api/* now answers `{"error": "Internal server error"}`
with the traceback logged; pages keep the plain-text body, since a browser
showing an error page is fine.

apiCall read the body as JSON unconditionally, so a proxy error page, a
restart mid-request or an empty body surfaced as a parser complaint. It now
reads text, parses defensively and throws "HTTP <status>: <first line of the
body>", which points at what happened. The existing err.payload passthrough
is kept, so the wg-easy import log still survives the throw, and data.detail
is consulted for FastAPI's own validation errors.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 22:27:57 +07:00

69 lines
2.4 KiB
Python

"""/api/* must answer JSON even when a handler blows up, and the browser
helper must survive an answer that is not JSON at all.
Without both halves a proxy error page, a restart mid-request or an unhandled
exception surfaces in the UI as "JSON.parse: unexpected character at line 1
column 1 of the JSON data", which says nothing about what actually happened.
"""
import os
import re
import unittest
from unittest import mock
from fastapi.responses import JSONResponse, PlainTextResponse
import app as panel
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
def call_handler(path):
request = mock.Mock()
request.method = 'GET'
request.url.path = path
import asyncio
return asyncio.run(panel.api_json_error_handler(request, RuntimeError('boom')))
class ApiErrorHandlerTests(unittest.TestCase):
def test_handler_is_registered_for_unhandled_exceptions(self):
self.assertIn(Exception, panel.app.exception_handlers)
def test_api_paths_answer_json(self):
response = call_handler('/api/servers/0/connections')
self.assertIsInstance(response, JSONResponse)
self.assertEqual(response.status_code, 500)
self.assertIn(b'"error"', response.body)
def test_pages_keep_plain_text(self):
response = call_handler('/server/0')
self.assertIsInstance(response, PlainTextResponse)
self.assertEqual(response.status_code, 500)
class ApiCallHelperTests(unittest.TestCase):
"""Text-level checks of the fetch helper every page uses."""
def setUp(self):
with open(os.path.join(ROOT, 'templates', 'base.html'), encoding='utf-8') as f:
page = f.read()
start = page.index('async function apiCall(')
self.helper = page[start:page.index('\n }', start)]
def test_reads_text_and_parses_defensively(self):
self.assertIn('await res.text()', self.helper)
self.assertIn('JSON.parse(text)', self.helper)
self.assertNotIn('await res.json()', self.helper)
self.assertRegex(self.helper, r'catch \(parseError\)')
# the thrown message carries the status, not just the parser complaint
self.assertIn('HTTP ${res.status}', self.helper)
def test_still_redirects_on_an_expired_session(self):
self.assertIn("res.status === 401", self.helper)
self.assertIn("window.location.href = '/login'", self.helper)
if __name__ == '__main__':
unittest.main()