| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510 |
- import hashlib
- import json
- import unittest
- from unittest.mock import MagicMock, patch
- from services.diagnostic_event import (
- RequestDiagnosticEmitter,
- build_diagnostic_event,
- )
- from services.diagnostic_reporter import (
- DiagnosticReporter,
- NullDiagnosticReporter,
- _http_transport,
- diagnostic_reporter_from_config,
- )
- class DiagnosticReporterTest(unittest.TestCase):
- def test_event_builder_rejects_invalid_required_and_optional_fields(self):
- base = {
- 'request_id': 'rq_valid',
- 'stage': 'request_ingress',
- 'status': 'started',
- 'event_code': 'REQUEST_RECEIVED',
- }
- cases = (
- ('request_id', None),
- ('request_id', 'bad'),
- ('stage', 'unknown'),
- ('status', 'unknown'),
- ('event_code', 1),
- ('event_code', 'A' * 65),
- ('event_code', 'lowercase'),
- ('company_id', True),
- ('company_id', '1'),
- ('company_id', 0),
- ('tool_code', 1),
- ('tool_code', 'UPPER'),
- ('session_credential', 1),
- ('response_code', 'bad-code'),
- ('summary_code', 'A' * 65),
- ('cost_ms', True),
- ('cost_ms', '1'),
- ('cost_ms', -1),
- ('cost_ms', 3600001),
- )
- for field, value in cases:
- with self.subTest(field=field, value=value):
- values = dict(base)
- values[field] = value
- with self.assertRaises(ValueError):
- build_diagnostic_event(**values)
- def test_event_builder_enforces_stage_context_types(self):
- cases = (
- ('request_ingress', 'not-object'),
- ('request_ingress', {'secret': 'x'}),
- ('request_ingress', {'http_status': True}),
- ('request_ingress', {'http_status': '200'}),
- ('request_ingress', {'http_status': 99}),
- ('protocol_validation', {'jsonrpc_code': True}),
- ('protocol_validation', {'jsonrpc_code': 'bad'}),
- ('response_write', {'client_disconnected': 1}),
- ('request_ingress', {'transport': 1}),
- ('request_ingress', {'transport': ''}),
- ('request_ingress', {'transport': 'x' * 65}),
- )
- for stage, context in cases:
- with self.subTest(stage=stage, context=context):
- with self.assertRaises(ValueError):
- build_diagnostic_event(
- request_id='rq_valid',
- stage=stage,
- status='failed',
- event_code='INVALID_EVENT',
- context=context,
- )
- def test_event_builder_accepts_complete_whitelisted_event(self):
- event = build_diagnostic_event(
- request_id='rq_complete',
- stage='response_write',
- status='succeeded',
- event_code='RESPONSE_WRITE_COMPLETED',
- company_id=1,
- admin_id=2,
- tool_code='query_order',
- response_code='MCP_0000',
- summary_code='OK',
- cost_ms=0,
- context={
- 'http_status': 200,
- 'client_disconnected': False,
- 'transport': 'http',
- },
- )
- self.assertTrue(event['occurred_at'].endswith('Z'))
- self.assertEqual('OK', event['summary_code'])
- def test_event_builder_hashes_session_and_keeps_whitelist(self):
- event = build_diagnostic_event(
- request_id='rq_http_test_1',
- stage='gateway_session',
- status='failed',
- event_code='GATEWAY_SESSION_NOT_FOUND',
- occurred_at='2026-07-20T02:00:00.000Z',
- session_credential='GWS_super_secret',
- company_id=1002,
- admin_id=88,
- tool_code='query_order_detail',
- context={'transport': 'http'},
- )
- self.assertTrue(event['event_id'].startswith('evt_gateway_'))
- self.assertEqual(
- hashlib.sha256(b'GWS_super_secret').hexdigest()[:12],
- event['session_hash'],
- )
- self.assertNotIn('session_credential', event)
- self.assertNotIn('GWS_super_secret', json.dumps(event))
- self.assertEqual('gateway', event['source'])
- def test_request_emitter_caps_each_request_at_twenty_events(self):
- reporter = RecordingReporter()
- emitter = RequestDiagnosticEmitter(reporter, 'rq_http_test_1')
- for index in range(25):
- emitter.emit(
- stage='request_ingress',
- status='started',
- event_code='REQUEST_RECEIVED',
- context={'transport': 'http'},
- )
- self.assertEqual(20, len(reporter.events))
- self.assertEqual(5, emitter.dropped_count)
- self.assertEqual(20, len({item['event_id'] for item in reporter.events}))
- def test_request_emitter_counts_reporter_rejection(self):
- reporter = RecordingReporter(accepted=False)
- emitter = RequestDiagnosticEmitter(reporter, 'rq_rejected')
- emitter.set_defaults(context={'transport': 'http'})
- self.assertFalse(emitter.emit(
- stage='request_ingress',
- status='started',
- event_code='REQUEST_RECEIVED',
- ))
- self.assertEqual(1, emitter.dropped_count)
- def test_deferred_emitter_enriches_prestage_events_after_identity_resolution(self):
- reporter = RecordingReporter()
- emitter = RequestDiagnosticEmitter(
- reporter,
- 'rq_deferred',
- defer_until_identity=True,
- )
- emitter.emit(
- stage='request_ingress',
- status='started',
- event_code='REQUEST_RECEIVED',
- context={'transport': 'http'},
- )
- emitter.emit(
- stage='protocol_validation',
- status='succeeded',
- event_code='PROTOCOL_VALIDATION_COMPLETED',
- context={'transport': 'http'},
- )
- self.assertEqual([], reporter.events)
- emitter.set_defaults(
- company_id=1002,
- admin_id=88,
- session_credential='GWS_private',
- )
- self.assertEqual(2, len(reporter.events))
- self.assertTrue(all(
- event['company_id'] == 1002 for event in reporter.events
- ))
- self.assertNotIn('GWS_private', str(reporter.events))
- def test_deferred_emitter_flushes_unscoped_failures(self):
- reporter = RecordingReporter()
- emitter = RequestDiagnosticEmitter(
- reporter,
- 'rq_unscoped',
- defer_until_identity=True,
- )
- emitter.emit(
- stage='gateway_session',
- status='failed',
- event_code='GATEWAY_SESSION_NOT_FOUND',
- session_credential='GWS_missing',
- context={'transport': 'http'},
- )
- self.assertTrue(emitter.flush())
- self.assertEqual(1, len(reporter.events))
- self.assertIn('session_hash', reporter.events[0])
- self.assertNotIn('company_id', reporter.events[0])
- def test_queue_full_only_increments_drop_count(self):
- reporter = self.reporter(queue_size=1)
- first = build_diagnostic_event(
- request_id='rq_http_1', stage='request_ingress',
- status='started', event_code='REQUEST_RECEIVED',
- )
- second = build_diagnostic_event(
- request_id='rq_http_2', stage='request_ingress',
- status='started', event_code='REQUEST_RECEIVED',
- )
- self.assertTrue(reporter.report(first))
- self.assertFalse(reporter.report(second))
- self.assertEqual(1, reporter.drop_count)
- def test_batch_signs_exact_json_bytes_and_accepts_success(self):
- calls = []
- def transport(url, body, headers, timeout):
- calls.append((url, body, headers, timeout))
- return 200, json.dumps({
- 'code': 'MCP_DIAG_INGEST_0000',
- 'msg': 'success',
- 'data': {'accepted': 1, 'duplicate': 0, 'rejected': 0},
- }).encode('utf-8')
- reporter = self.reporter(transport=transport)
- reporter.report(build_diagnostic_event(
- request_id='rq_http_1', stage='request_ingress',
- status='started', event_code='REQUEST_RECEIVED',
- ))
- self.assertTrue(reporter.process_once())
- self.assertEqual(1, len(calls))
- url, body, headers, timeout = calls[0]
- self.assertEqual('https://support.internal/internal/mcp-diagnostics/events', url)
- self.assertEqual(0.5, timeout)
- self.assertEqual({'events'}, set(json.loads(body.decode('utf-8'))))
- canonical = 'POST\n/internal/mcp-diagnostics/events\n{0}\n{1}\n{2}'.format(
- headers['X-MCP-Timestamp'],
- headers['X-MCP-Nonce'],
- hashlib.sha256(body).hexdigest(),
- )
- expected = hashlib.pbkdf2_hmac(
- 'sha256', canonical.encode('utf-8'), b'x', 1
- )
- self.assertNotEqual(expected.hex(), headers['X-MCP-Signature'])
- import hmac
- self.assertEqual(
- hmac.new(b's' * 32, canonical.encode('utf-8'), hashlib.sha256).hexdigest(),
- headers['X-MCP-Signature'],
- )
- def test_failed_send_keeps_batch_and_uses_bounded_backoff(self):
- attempts = []
- sleeps = []
- def transport(_url, _body, _headers, _timeout):
- attempts.append(1)
- if len(attempts) == 1:
- raise OSError('support unavailable secret')
- return 200, b'{"code":"MCP_DIAG_INGEST_0000","data":{}}'
- reporter = self.reporter(
- transport=transport,
- sleeper=sleeps.append,
- initial_backoff=0.25,
- max_backoff=1.0,
- )
- reporter.report(build_diagnostic_event(
- request_id='rq_http_1', stage='request_ingress',
- status='started', event_code='REQUEST_RECEIVED',
- ))
- self.assertFalse(reporter.process_once())
- self.assertEqual([0.25], sleeps)
- self.assertTrue(reporter.process_once())
- self.assertEqual(2, len(attempts))
- self.assertEqual(0, reporter.pending_count)
- def test_thread_start_failure_and_null_reporter_are_non_fatal(self):
- class BrokenThread:
- def start(self):
- raise RuntimeError('thread unavailable')
- reporter = self.reporter(thread_factory=lambda **_kwargs: BrokenThread())
- self.assertFalse(reporter.start())
- reporter.close()
- null = NullDiagnosticReporter()
- self.assertTrue(null.start())
- self.assertTrue(null.report({'ignored': True}))
- null.close()
- def test_start_is_idempotent_and_close_joins_live_thread(self):
- thread = AliveThread()
- reporter = self.reporter(thread_factory=lambda **_kwargs: thread)
- self.assertTrue(reporter.start())
- self.assertTrue(reporter.start())
- reporter.close()
- self.assertEqual([0.5], thread.join_timeouts)
- def test_process_once_accepts_empty_queue_and_rejects_bad_responses(self):
- reporter = self.reporter()
- self.assertTrue(reporter.process_once())
- responses = (
- (199, b'{"code":"MCP_DIAG_INGEST_0000"}'),
- (300, b'{"code":"MCP_DIAG_INGEST_0000"}'),
- (200, b'{"code":"MCP_DIAG_INGEST_INVALID"}'),
- )
- for status, body in responses:
- with self.subTest(status=status, body=body):
- reporter = self.reporter(
- transport=lambda *_args, result=(status, body): result,
- )
- reporter.report(build_diagnostic_event(
- request_id='rq_rejected',
- stage='request_ingress',
- status='started',
- event_code='REQUEST_RECEIVED',
- ))
- self.assertFalse(reporter.process_once())
- self.assertEqual(1, reporter.pending_count)
- def test_batch_size_caps_single_send_and_run_loop_handles_retry(self):
- reporter = self.reporter(batch_size=1)
- for request_id in ('rq_one', 'rq_two'):
- reporter.report(build_diagnostic_event(
- request_id=request_id,
- stage='request_ingress',
- status='started',
- event_code='REQUEST_RECEIVED',
- ))
- self.assertTrue(reporter.process_once())
- self.assertEqual(0, reporter.pending_count)
- self.assertFalse(reporter._queue.empty())
- reporter = self.reporter()
- reporter._stop = MagicMock()
- reporter._stop.is_set.side_effect = (False, False, True)
- reporter.process_once = MagicMock(side_effect=(False, True))
- reporter._run()
- reporter._stop.wait.assert_called_once_with(0.1)
- def test_default_http_transport_posts_and_reads_response(self):
- response = MagicMock()
- response.getcode.return_value = 200
- response.read.return_value = b'{}'
- response.__enter__.return_value = response
- response.__exit__.return_value = False
- with patch(
- 'services.diagnostic_reporter.urllib.request.urlopen',
- return_value=response,
- ) as urlopen:
- result = _http_transport(
- 'https://support.test/path',
- b'{}',
- {'X-Test': '1'},
- 0.5,
- )
- self.assertEqual((200, b'{}'), result)
- self.assertEqual(0.5, urlopen.call_args.kwargs['timeout'])
- def test_config_factory_uses_null_when_disabled_or_invalid(self):
- disabled = type('Config', (), {'diagnosis_enabled': False})()
- invalid = type('Config', (), {
- 'diagnosis_enabled': True,
- 'diagnosis_url': '',
- 'diagnosis_key_id': '',
- 'diagnosis_secret': '',
- })()
- self.assertIsInstance(
- diagnostic_reporter_from_config(disabled),
- NullDiagnosticReporter,
- )
- self.assertIsInstance(
- diagnostic_reporter_from_config(invalid),
- NullDiagnosticReporter,
- )
- def test_config_factory_starts_enabled_reporter(self):
- config = type('Config', (), {
- 'diagnosis_enabled': True,
- 'diagnosis_url': 'https://support.test/internal/mcp-diagnostics/events',
- 'diagnosis_key_id': 'gateway-current',
- 'diagnosis_secret': 's' * 32,
- 'diagnosis_queue_size': 10,
- 'diagnosis_batch_size': 20,
- 'diagnosis_timeout_seconds': 0.5,
- 'diagnosis_initial_backoff_seconds': 0.25,
- 'diagnosis_max_backoff_seconds': 2.0,
- })()
- reporter = diagnostic_reporter_from_config(
- config,
- thread_factory=lambda **_kwargs: StartedThread(),
- )
- self.assertIsInstance(reporter, DiagnosticReporter)
- self.assertTrue(reporter._thread.started)
- reporter.close()
- def test_config_factory_requires_explicit_opt_in_for_http(self):
- values = {
- 'diagnosis_enabled': True,
- 'diagnosis_url': 'http://support.test/internal/mcp-diagnostics/events',
- 'diagnosis_key_id': 'gateway-current',
- 'diagnosis_secret': 's' * 32,
- }
- default_config = type('Config', (), values)()
- allowed_config = type(
- 'Config',
- (),
- dict(values, diagnosis_allow_insecure_http=True),
- )()
- self.assertIsInstance(
- diagnostic_reporter_from_config(default_config),
- NullDiagnosticReporter,
- )
- reporter = diagnostic_reporter_from_config(
- allowed_config,
- thread_factory=lambda **_kwargs: StartedThread(),
- )
- self.assertIsInstance(reporter, DiagnosticReporter)
- reporter.close()
- def test_config_factory_falls_back_when_thread_cannot_start(self):
- config = type('Config', (), {
- 'diagnosis_enabled': True,
- 'diagnosis_url': 'https://support.test/events',
- 'diagnosis_key_id': 'gateway-current',
- 'diagnosis_secret': 's' * 32,
- })()
- reporter = diagnostic_reporter_from_config(
- config,
- thread_factory=lambda **_kwargs: BrokenThread(),
- )
- self.assertIsInstance(reporter, NullDiagnosticReporter)
- def reporter(self, **overrides):
- options = {
- 'url': 'https://support.internal/internal/mcp-diagnostics/events',
- 'key_id': 'gateway-current',
- 'secret': 's' * 32,
- 'queue_size': 10,
- 'batch_size': 100,
- 'timeout_seconds': 0.5,
- 'transport': lambda *_args: (200, b'{"code":"MCP_DIAG_INGEST_0000","data":{}}'),
- 'clock': lambda: 1784512800,
- 'nonce_factory': lambda: 'nonce-gateway-1234',
- 'sleeper': lambda _seconds: None,
- }
- options.update(overrides)
- return DiagnosticReporter(**options)
- class RecordingReporter:
- def __init__(self, accepted=True):
- self.events = []
- self.accepted = accepted
- def report(self, event):
- self.events.append(event)
- return self.accepted
- class StartedThread:
- def __init__(self):
- self.started = False
- def start(self):
- self.started = True
- def is_alive(self):
- return False
- class AliveThread(StartedThread):
- def __init__(self):
- super().__init__()
- self.join_timeouts = []
- def is_alive(self):
- return True
- def join(self, timeout=None):
- self.join_timeouts.append(timeout)
- class BrokenThread:
- def start(self):
- raise RuntimeError('thread unavailable')
- if __name__ == '__main__':
- unittest.main()
|