from fastapi.testclient import TestClient import gateway.app as gateway_module class DummyResponse: def __init__( self, status_code: int, headers: dict[str, str], json_payload: dict | None = None, text: str = '', content: bytes | None = None, ): self.status_code = status_code self.headers = headers self._json_payload = json_payload or {} self.text = text self.content = content if content is not None else text.encode("utf-8") def json(self) -> dict: return self._json_payload class DummyAsyncClient: last_request: dict = {} def __init__(self, timeout: int): self.timeout = timeout async def __aenter__(self): return self async def __aexit__(self, exc_type, exc, tb): return None async def request(self, method, url, params=None, content=None, headers=None): DummyAsyncClient.last_request = { 'method': method, 'url': url, 'params': params, 'content': content, 'headers': headers or {}, } return DummyResponse(200, {'content-type': 'application/json'}, {'ok': True}) def test_gateway_forwards_authorization_header(monkeypatch): monkeypatch.setattr(gateway_module.httpx, 'AsyncClient', DummyAsyncClient) client = TestClient(gateway_module.app) response = client.get( '/proxy/auth/auth/me', headers={'Authorization': 'Bearer test-token', 'X-User': 'admin', 'X-Role': 'admin'}, ) assert response.status_code == 200 assert DummyAsyncClient.last_request['headers']['Authorization'] == 'Bearer test-token' assert DummyAsyncClient.last_request['headers']['X-User'] == 'admin' def test_gateway_forwards_telegram_secret_header(monkeypatch): monkeypatch.setattr(gateway_module.httpx, 'AsyncClient', DummyAsyncClient) client = TestClient(gateway_module.app) response = client.post( '/proxy/telegram/integrations/telegram/bot/webhook', headers={'X-Telegram-Bot-Api-Secret-Token': 'secret-token'}, json={'update_id': 1}, ) assert response.status_code == 200 assert DummyAsyncClient.last_request['headers']['X-Telegram-Bot-Api-Secret-Token'] == 'secret-token' def test_gateway_forwards_whatsapp_secret_header(monkeypatch): monkeypatch.setattr(gateway_module.httpx, 'AsyncClient', DummyAsyncClient) client = TestClient(gateway_module.app) response = client.post( '/proxy/whatsapp/integrations/whatsapp/provider/webhook', headers={'X-WhatsApp-Webhook-Secret': 'wa-secret'}, json={'entry': []}, ) assert response.status_code == 200 assert DummyAsyncClient.last_request['headers']['X-WhatsApp-Webhook-Secret'] == 'wa-secret' assert DummyAsyncClient.last_request['content'] == b'{"entry":[]}' def test_gateway_forwards_meta_signature_header_and_raw_body(monkeypatch): monkeypatch.setattr(gateway_module.httpx, 'AsyncClient', DummyAsyncClient) client = TestClient(gateway_module.app) response = client.post( '/proxy/whatsapp/integrations/whatsapp/provider/webhook', headers={ 'X-Hub-Signature-256': 'sha256=test-signature', 'Content-Type': 'application/json', }, content=b'{"entry":[{"changes":[]}]}' , ) assert response.status_code == 200 assert DummyAsyncClient.last_request['headers']['X-Hub-Signature-256'] == 'sha256=test-signature' assert DummyAsyncClient.last_request['headers']['Content-Type'] == 'application/json' assert DummyAsyncClient.last_request['content'] == b'{"entry":[{"changes":[]}]}' def test_gateway_preserves_html_response(monkeypatch): async def request(self, method, url, params=None, content=None, headers=None): # noqa: ANN001, ANN201 return DummyResponse(200, {'content-type': 'text/html'}, text='bridge') monkeypatch.setattr(DummyAsyncClient, 'request', request) monkeypatch.setattr(gateway_module.httpx, 'AsyncClient', DummyAsyncClient) client = TestClient(gateway_module.app) response = client.get('/proxy/auth/auth/oidc/callback') assert response.status_code == 200 assert 'bridge' in response.text def test_gateway_preserves_plain_text_response(monkeypatch): async def request(self, method, url, params=None, content=None, headers=None): # noqa: ANN001, ANN201 return DummyResponse(200, {'content-type': 'text/plain; charset=utf-8'}, text='123456') monkeypatch.setattr(DummyAsyncClient, 'request', request) monkeypatch.setattr(gateway_module.httpx, 'AsyncClient', DummyAsyncClient) client = TestClient(gateway_module.app) response = client.get('/proxy/whatsapp/integrations/whatsapp/provider/webhook') assert response.status_code == 200 assert response.text == '123456' assert response.headers['content-type'].startswith('text/plain') def test_gateway_forwards_delete_requests(monkeypatch): monkeypatch.setattr(gateway_module.httpx, 'AsyncClient', DummyAsyncClient) client = TestClient(gateway_module.app) response = client.delete( '/proxy/routing/queues/que_delete_me', headers={'X-User': 'admin', 'X-Role': 'admin'}, ) assert response.status_code == 200 assert DummyAsyncClient.last_request['method'] == 'DELETE' assert DummyAsyncClient.last_request['url'].endswith('/queues/que_delete_me') def test_gateway_prefixes_sales_public_api_paths(monkeypatch): monkeypatch.setattr(gateway_module.httpx, 'AsyncClient', DummyAsyncClient) client = TestClient(gateway_module.app) response = client.get('/proxy/sales/pipelines?limit=25') assert response.status_code == 200 assert DummyAsyncClient.last_request['url'].endswith('/api/v1/pipelines') assert DummyAsyncClient.last_request['params'] == {'limit': '25'} def test_gateway_does_not_prefix_sales_internal_paths(monkeypatch): monkeypatch.setattr(gateway_module.httpx, 'AsyncClient', DummyAsyncClient) client = TestClient(gateway_module.app) response = client.post('/proxy/sales/internal/sales-sync/telegram', json={'chat_id': '123'}) assert response.status_code == 200 assert DummyAsyncClient.last_request['url'].endswith('/internal/sales-sync/telegram') def test_gateway_preserves_binary_audio_response(monkeypatch): async def request(self, method, url, params=None, content=None, headers=None): # noqa: ANN001, ANN201 return DummyResponse( 200, { 'content-type': 'audio/wav', 'content-disposition': 'attachment; filename="demo-call.wav"', }, content=b'RIFFdemo', ) monkeypatch.setattr(DummyAsyncClient, 'request', request) monkeypatch.setattr(gateway_module.httpx, 'AsyncClient', DummyAsyncClient) client = TestClient(gateway_module.app) response = client.get('/proxy/recording/recordings/rec_demo/content') assert response.status_code == 200 assert response.content == b'RIFFdemo' assert response.headers['content-type'].startswith('audio/wav') assert 'demo-call.wav' in response.headers['content-disposition'] def test_gateway_returns_bad_gateway_when_upstream_unavailable(monkeypatch): async def request(self, method, url, params=None, content=None, headers=None): # noqa: ANN001, ANN201 raise gateway_module.httpx.ConnectError("All connection attempts failed") monkeypatch.setattr(DummyAsyncClient, 'request', request) monkeypatch.setattr(gateway_module.httpx, 'AsyncClient', DummyAsyncClient) client = TestClient(gateway_module.app) response = client.get('/proxy/sales/pipelines') assert response.status_code == 502 assert response.json() == { 'detail': 'Upstream service unavailable', 'service': 'sales', } def test_gateway_queues_error_alert_for_proxy_unreachable(monkeypatch): async def request(self, method, url, params=None, content=None, headers=None): # noqa: ANN001, ANN201 raise gateway_module.httpx.ConnectError("All connection attempts failed") alerts: list[dict] = [] def queue_alert(service: str, method: str, path: str, status: int, detail: str) -> None: # noqa: ARG001 alerts.append({ 'service': service, 'method': method, 'path': path, 'status': status, 'detail': detail, }) monkeypatch.setattr(DummyAsyncClient, 'request', request) monkeypatch.setattr(gateway_module.httpx, 'AsyncClient', DummyAsyncClient) monkeypatch.setattr(gateway_module, '_queue_telegram_error_alert', queue_alert) monkeypatch.setenv('ERROR_TELEGRAM_ALERTS', '1') monkeypatch.setenv('ERROR_TELEGRAM_ALERT_BOT_TOKEN', 'token') monkeypatch.setenv('ERROR_TELEGRAM_ALERT_CHAT_IDS', '999') client = TestClient(gateway_module.app) response = client.get('/proxy/sales/pipelines') assert response.status_code == 502 assert alerts == [ { 'service': 'api-gateway', 'method': 'GET', 'path': '/proxy/sales/pipelines', 'status': 502, 'detail': 'Upstream service unavailable: service=sales base_url=http://localhost:8020', }, ] def test_gateway_queues_error_alert_for_channel_alias(monkeypatch): async def request(self, method, url, params=None, content=None, headers=None): # noqa: ANN001, ANN201 raise gateway_module.httpx.ConnectError("All connection attempts failed") alerts: list[dict] = [] def queue_alert(service: str, method: str, path: str, status: int, detail: str) -> None: # noqa: ARG001 alerts.append({ 'service': service, 'method': method, 'path': path, 'status': status, 'detail': detail, }) monkeypatch.setattr(DummyAsyncClient, 'request', request) monkeypatch.setattr(gateway_module.httpx, 'AsyncClient', DummyAsyncClient) monkeypatch.setattr(gateway_module, '_queue_telegram_error_alert', queue_alert) monkeypatch.setenv('ERROR_TELEGRAM_ALERTS', '1') monkeypatch.setenv('ERROR_TELEGRAM_ALERT_BOT_TOKEN', 'token') monkeypatch.setenv('ERROR_TELEGRAM_ALERT_CHANNEL_ID', '@my_alert_channel') client = TestClient(gateway_module.app) client.get('/proxy/sales/pipelines') assert alerts assert alerts[0]['status'] == 502 def test_gateway_queues_error_alert_for_internal_exception(monkeypatch): async def broken_forward(method: str, service: str, path: str, request: object) -> None: # noqa: ANN001, ANN201 raise ValueError('proxy exploded') alerts: list[dict] = [] def queue_alert(service: str, method: str, path: str, status: int, detail: str) -> None: # noqa: ARG001 alerts.append({ 'service': service, 'method': method, 'path': path, 'status': status, 'detail': detail, }) monkeypatch.setattr(gateway_module, '_forward', broken_forward) monkeypatch.setattr(gateway_module, '_queue_telegram_error_alert', queue_alert) monkeypatch.setenv('ERROR_TELEGRAM_ALERTS', '1') monkeypatch.setenv('ERROR_TELEGRAM_ALERT_BOT_TOKEN', 'token') monkeypatch.setenv('ERROR_TELEGRAM_ALERT_CHAT_IDS', '999') client = TestClient(gateway_module.app, raise_server_exceptions=False) response = client.get('/proxy/sales/pipelines') assert response.status_code == 500 assert alerts assert alerts[0]['service'] == 'api-gateway' assert alerts[0]['status'] == 500 assert alerts[0]['path'] == '/proxy/sales/pipelines'