mirror of
https://github.com/apidoorman/doorman.git
synced 2026-04-29 04:39:54 -05:00
Updates for testing and minor bug fixes
This commit is contained in:
@@ -22,7 +22,7 @@ from logging.handlers import RotatingFileHandler
|
||||
from pathlib import Path
|
||||
|
||||
import uvicorn
|
||||
from dotenv import load_dotenv
|
||||
from dotenv import load_dotenv, find_dotenv
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
@@ -111,7 +111,7 @@ from utils.security_settings_util import (
|
||||
stop_auto_save_task,
|
||||
)
|
||||
|
||||
load_dotenv()
|
||||
load_dotenv(find_dotenv(usecwd=True))
|
||||
|
||||
PID_FILE = 'doorman.pid'
|
||||
|
||||
|
||||
@@ -18,6 +18,11 @@ class LiveClient:
|
||||
self._created_groups: set[str] = set()
|
||||
self._created_roles: set[str] = set()
|
||||
self._created_users: set[str] = set()
|
||||
self._created_credit_defs: set[str] = set()
|
||||
self._created_user_credits: set[str] = set()
|
||||
self._created_routings: set[str] = set()
|
||||
self._created_tiers: set[str] = set()
|
||||
self._created_tier_assignments: set[str] = set()
|
||||
|
||||
def _get_csrf(self) -> str | None:
|
||||
for c in self.sess.cookies:
|
||||
@@ -27,6 +32,8 @@ class LiveClient:
|
||||
|
||||
def _headers_with_csrf(self, headers: dict | None) -> dict:
|
||||
out = {'Accept': 'application/json'}
|
||||
# Mark requests as test traffic so analytics can exclude them
|
||||
out['X-IS-TEST'] = 'true'
|
||||
if headers:
|
||||
out.update(headers)
|
||||
csrf = self._get_csrf()
|
||||
@@ -76,6 +83,33 @@ class LiveClient:
|
||||
self._created_subscriptions.add((name, ver, user))
|
||||
elif p.startswith('/platform/rate-limits') and isinstance(json, dict) and json.get('rule_id'):
|
||||
self._created_rules.add(json['rule_id'])
|
||||
elif p.startswith('/platform/credit') and isinstance(json, dict):
|
||||
if json.get('api_credit_group'):
|
||||
self._created_credit_defs.add(json['api_credit_group'])
|
||||
parts = [seg for seg in p.split('/') if seg]
|
||||
if len(parts) >= 3 and parts[1] == 'credit':
|
||||
username = parts[2]
|
||||
if username and username != 'defs':
|
||||
self._created_user_credits.add(username)
|
||||
elif p.startswith('/platform/routing') and isinstance(json, dict):
|
||||
client_key = json.get('client_key')
|
||||
if client_key:
|
||||
self._created_routings.add(client_key)
|
||||
else:
|
||||
try:
|
||||
msg = (resp.json() or {}).get('message') or ''
|
||||
if 'key:' in msg:
|
||||
self._created_routings.add(msg.split('key:')[-1].strip())
|
||||
except Exception:
|
||||
pass
|
||||
elif p.startswith('/platform/tiers/assignments') and isinstance(json, dict):
|
||||
user_id = json.get('user_id')
|
||||
if user_id:
|
||||
self._created_tier_assignments.add(user_id)
|
||||
elif p.startswith('/platform/tiers') and isinstance(json, dict):
|
||||
tier_id = json.get('tier_id')
|
||||
if tier_id:
|
||||
self._created_tiers.add(tier_id)
|
||||
elif p.startswith('/platform/group') and isinstance(json, dict) and json.get('group_name'):
|
||||
self._created_groups.add(json['group_name'])
|
||||
elif p.startswith('/platform/role') and isinstance(json, dict) and json.get('role_name'):
|
||||
@@ -116,11 +150,38 @@ class LiveClient:
|
||||
# Cleanup support
|
||||
# ------------------------
|
||||
|
||||
def _json(self, resp):
|
||||
try:
|
||||
return resp.json()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _extract_list(self, payload, key: str):
|
||||
if isinstance(payload, list):
|
||||
return payload
|
||||
if not isinstance(payload, dict):
|
||||
return []
|
||||
if key in payload and isinstance(payload.get(key), list):
|
||||
return payload.get(key) or []
|
||||
resp = payload.get('response')
|
||||
if isinstance(resp, dict) and key in resp and isinstance(resp.get(key), list):
|
||||
return resp.get(key) or []
|
||||
if isinstance(resp, list):
|
||||
return resp
|
||||
return []
|
||||
|
||||
def cleanup(self):
|
||||
"""Best-effort cleanup of resources created during tests.
|
||||
|
||||
Performs deletions in dependency-safe order and ignores failures.
|
||||
"""
|
||||
# Ensure we have a valid session before attempting cleanup
|
||||
try:
|
||||
from config import ADMIN_EMAIL, ADMIN_PASSWORD
|
||||
|
||||
self.login(ADMIN_EMAIL, ADMIN_PASSWORD)
|
||||
except Exception:
|
||||
pass
|
||||
# Unsubscribe first to release ties
|
||||
for name, ver, user in list(self._created_subscriptions):
|
||||
try:
|
||||
@@ -156,6 +217,36 @@ class LiveClient:
|
||||
self.delete(f'/platform/rate-limits/{rid}')
|
||||
except Exception:
|
||||
pass
|
||||
# Clear tier assignments
|
||||
for user_id in list(self._created_tier_assignments):
|
||||
try:
|
||||
self.delete(f'/platform/tiers/assignments/{user_id}')
|
||||
except Exception:
|
||||
pass
|
||||
# Delete tiers
|
||||
for tier_id in list(self._created_tiers):
|
||||
try:
|
||||
self.delete(f'/platform/tiers/{tier_id}')
|
||||
except Exception:
|
||||
pass
|
||||
# Delete routings
|
||||
for client_key in list(self._created_routings):
|
||||
try:
|
||||
self.delete(f'/platform/routing/{client_key}')
|
||||
except Exception:
|
||||
pass
|
||||
# Clear user credits
|
||||
for username in list(self._created_user_credits):
|
||||
try:
|
||||
self.post(f'/platform/credit/{username}', json={'username': username, 'users_credits': {}})
|
||||
except Exception:
|
||||
pass
|
||||
# Delete credit definitions
|
||||
for group in list(self._created_credit_defs):
|
||||
try:
|
||||
self.delete(f'/platform/credit/{group}')
|
||||
except Exception:
|
||||
pass
|
||||
# Delete groups
|
||||
for g in list(self._created_groups):
|
||||
try:
|
||||
@@ -175,3 +266,186 @@ class LiveClient:
|
||||
self.delete(f'/platform/user/{u}')
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Sweep cleanup for any remaining resources created by tests
|
||||
try:
|
||||
self._cleanup_all_resources()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _cleanup_all_resources(self):
|
||||
# Users
|
||||
users_payload = self._json(self.get('/platform/user/all?page=1&page_size=1000'))
|
||||
users = self._extract_list(users_payload, 'users')
|
||||
usernames = []
|
||||
for u in users:
|
||||
if isinstance(u, dict) and u.get('username'):
|
||||
usernames.append(u['username'])
|
||||
elif isinstance(u, str):
|
||||
usernames.append(u)
|
||||
usernames = [u for u in usernames if u and u != 'admin']
|
||||
|
||||
# Subscriptions
|
||||
for username in usernames + ['admin']:
|
||||
try:
|
||||
subs_payload = self._json(self.get(f'/platform/subscription/subscriptions/{username}'))
|
||||
apis = self._extract_list(subs_payload, 'apis')
|
||||
for api in apis:
|
||||
if isinstance(api, str) and '/' in api:
|
||||
name, ver = api.split('/', 1)
|
||||
elif isinstance(api, dict):
|
||||
name = api.get('api_name')
|
||||
ver = api.get('api_version')
|
||||
else:
|
||||
continue
|
||||
if name and ver:
|
||||
self.post(
|
||||
'/platform/subscription/unsubscribe',
|
||||
json={'api_name': name, 'api_version': ver, 'username': username},
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# APIs and endpoints
|
||||
apis_payload = self._json(self.get('/platform/api/all?page=1&page_size=1000'))
|
||||
apis = self._extract_list(apis_payload, 'apis')
|
||||
api_pairs = []
|
||||
for api in apis:
|
||||
if isinstance(api, dict):
|
||||
name = api.get('api_name')
|
||||
ver = api.get('api_version')
|
||||
if name and ver:
|
||||
api_pairs.append((name, ver))
|
||||
for name, ver in api_pairs:
|
||||
try:
|
||||
endpoints_payload = self._json(self.get(f'/platform/endpoint/{name}/{ver}'))
|
||||
endpoints = self._extract_list(endpoints_payload, 'endpoints')
|
||||
for ep in endpoints:
|
||||
if not isinstance(ep, dict):
|
||||
continue
|
||||
method = ep.get('endpoint_method') or ep.get('method')
|
||||
uri = ep.get('endpoint_uri') or ep.get('uri')
|
||||
if method and uri:
|
||||
u = uri if uri.startswith('/') else '/' + uri
|
||||
self.delete(f'/platform/endpoint/{method.upper()}/{name}/{ver}{u}')
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
self.delete(f'/platform/api/{name}/{ver}')
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Protos
|
||||
for name, ver in list(self._created_protos):
|
||||
try:
|
||||
self.delete(f'/platform/proto/{name}/{ver}')
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Rate limit rules
|
||||
for rid in list(self._created_rules):
|
||||
try:
|
||||
self.delete(f'/platform/rate-limits/{rid}')
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Tier assignments
|
||||
for username in usernames + ['admin']:
|
||||
try:
|
||||
self.delete(f'/platform/tiers/assignments/{username}')
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Tiers
|
||||
tiers_payload = self._json(self.get('/platform/tiers?skip=0&limit=1000'))
|
||||
tiers = self._extract_list(tiers_payload, 'tiers')
|
||||
for tier in tiers:
|
||||
if isinstance(tier, dict):
|
||||
tier_id = tier.get('tier_id')
|
||||
else:
|
||||
tier_id = None
|
||||
if tier_id:
|
||||
try:
|
||||
self.delete(f'/platform/tiers/{tier_id}')
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Routings
|
||||
routings_payload = self._json(self.get('/platform/routing/all?page=1&page_size=1000'))
|
||||
routings = self._extract_list(routings_payload, 'routings')
|
||||
for route in routings:
|
||||
if isinstance(route, dict):
|
||||
client_key = route.get('client_key')
|
||||
else:
|
||||
client_key = None
|
||||
if client_key:
|
||||
try:
|
||||
self.delete(f'/platform/routing/{client_key}')
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# User credits
|
||||
credits_payload = self._json(self.get('/platform/credit/all?page=1&page_size=1000'))
|
||||
credits = self._extract_list(credits_payload, 'user_credits')
|
||||
for credit in credits:
|
||||
if isinstance(credit, dict):
|
||||
username = credit.get('username')
|
||||
else:
|
||||
username = None
|
||||
if username:
|
||||
try:
|
||||
self.post(
|
||||
f'/platform/credit/{username}',
|
||||
json={'username': username, 'users_credits': {}},
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Credit definitions
|
||||
defs_payload = self._json(self.get('/platform/credit/defs?page=1&page_size=1000'))
|
||||
defs = self._extract_list(defs_payload, 'items')
|
||||
for credit_def in defs:
|
||||
if isinstance(credit_def, dict):
|
||||
group = credit_def.get('api_credit_group')
|
||||
else:
|
||||
group = None
|
||||
if group:
|
||||
try:
|
||||
self.delete(f'/platform/credit/{group}')
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Delete users (except admin)
|
||||
for username in usernames:
|
||||
try:
|
||||
self.delete(f'/platform/user/{username}')
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Groups
|
||||
groups_payload = self._json(self.get('/platform/group/all?page=1&page_size=1000'))
|
||||
groups = self._extract_list(groups_payload, 'groups')
|
||||
for group in groups:
|
||||
if isinstance(group, dict):
|
||||
name = group.get('group_name')
|
||||
else:
|
||||
name = None
|
||||
if name and name not in ('ALL', 'admin'):
|
||||
try:
|
||||
self.delete(f'/platform/group/{name}')
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Roles
|
||||
roles_payload = self._json(self.get('/platform/role/all?page=1&page_size=1000'))
|
||||
roles = self._extract_list(roles_payload, 'roles')
|
||||
for role in roles:
|
||||
if isinstance(role, dict):
|
||||
name = role.get('role_name')
|
||||
else:
|
||||
name = None
|
||||
if name and name not in ('admin',):
|
||||
try:
|
||||
self.delete(f'/platform/role/{name}')
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import os
|
||||
|
||||
BASE_URL = os.getenv('DOORMAN_BASE_URL', 'http://localhost:3001').rstrip('/')
|
||||
ADMIN_EMAIL = os.getenv('DOORMAN_ADMIN_EMAIL', 'admin@doorman.dev')
|
||||
ADMIN_EMAIL = os.getenv('DOORMAN_ADMIN_EMAIL')
|
||||
|
||||
# Resolve admin password from environment or the repo root .env.
|
||||
# Resolve admin email/password from environment or the repo root .env.
|
||||
# Search order:
|
||||
# 1) Environment variable DOORMAN_ADMIN_PASSWORD
|
||||
# 1) Environment variable DOORMAN_ADMIN_EMAIL / DOORMAN_ADMIN_PASSWORD
|
||||
# 2) Repo root .env (two levels up from live-tests)
|
||||
# 3) Default test password
|
||||
# 3) Defaults
|
||||
ADMIN_PASSWORD = os.getenv('DOORMAN_ADMIN_PASSWORD')
|
||||
if not ADMIN_PASSWORD:
|
||||
env_path = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..', '.env'))
|
||||
@@ -15,13 +15,16 @@ if not ADMIN_PASSWORD:
|
||||
if os.path.exists(env_path):
|
||||
with open(env_path, encoding='utf-8') as f:
|
||||
for line in f:
|
||||
if line.startswith('DOORMAN_ADMIN_EMAIL=') and not ADMIN_EMAIL:
|
||||
ADMIN_EMAIL = line.split('=', 1)[1].strip()
|
||||
if line.startswith('DOORMAN_ADMIN_PASSWORD='):
|
||||
ADMIN_PASSWORD = line.split('=', 1)[1].strip()
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
if not ADMIN_PASSWORD:
|
||||
ADMIN_PASSWORD = 'test-only-password-12chars'
|
||||
if not ADMIN_EMAIL:
|
||||
ADMIN_EMAIL = 'admin@doorman.dev'
|
||||
|
||||
ENABLE_GRAPHQL = True
|
||||
ENABLE_GRPC = True
|
||||
|
||||
@@ -62,12 +62,11 @@ def client(base_url) -> LiveClient:
|
||||
try:
|
||||
yield c
|
||||
finally:
|
||||
# Session-level cleanup of created resources when enabled
|
||||
if str(os.getenv('DOORMAN_TEST_CLEANUP', '')).lower() in ('1', 'true', 'yes', 'on'):
|
||||
try:
|
||||
c.cleanup()
|
||||
except Exception:
|
||||
pass
|
||||
# Always perform session-level cleanup of created resources to leave the system pristine
|
||||
try:
|
||||
c.cleanup()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
|
||||
@@ -20,6 +20,10 @@ def test_rate_limiting_blocks_excess_requests(client):
|
||||
'throttle_wait_duration_type': 'second',
|
||||
},
|
||||
)
|
||||
try:
|
||||
client.delete('/api/caches')
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
client.post(
|
||||
'/platform/api',
|
||||
|
||||
@@ -47,7 +47,7 @@ def test_bulk_public_rest_crud(client):
|
||||
'api_allowed_roles': [],
|
||||
'api_allowed_groups': [],
|
||||
'api_servers': [srv.url],
|
||||
'api_type': 'REST',
|
||||
'api_type': 'SOAP',
|
||||
'active': True,
|
||||
'api_public': True,
|
||||
},
|
||||
@@ -71,6 +71,7 @@ def test_bulk_public_rest_crud(client):
|
||||
)
|
||||
assert r.status_code in (200, 201), r.text
|
||||
s = requests.Session()
|
||||
s.headers.update({'X-IS-TEST': 'true'})
|
||||
url = f'{base}/api/rest/{api_name}/{api_version}/items'
|
||||
assert s.get(url).status_code == 200
|
||||
assert s.post(url, json={'name': 'x'}).status_code == 200
|
||||
@@ -122,6 +123,7 @@ def test_bulk_public_soap_crud(client):
|
||||
)
|
||||
assert r.status_code in (200, 201), r.text
|
||||
s = requests.Session()
|
||||
s.headers.update({'X-IS-TEST': 'true'})
|
||||
headers = {'Content-Type': 'text/xml'}
|
||||
for uri in ['create', 'read', 'update', 'delete']:
|
||||
url = f'{base}/api/soap/{api_name}/{api_version}/{uri}'
|
||||
@@ -200,7 +202,7 @@ def test_bulk_public_graphql_crud(client):
|
||||
'api_allowed_roles': [],
|
||||
'api_allowed_groups': [],
|
||||
'api_servers': [f'http://{host}:{port}'],
|
||||
'api_type': 'REST',
|
||||
'api_type': 'GRAPHQL',
|
||||
'active': True,
|
||||
'api_public': True,
|
||||
},
|
||||
@@ -218,6 +220,7 @@ def test_bulk_public_graphql_crud(client):
|
||||
)
|
||||
assert r.status_code in (200, 201), r.text
|
||||
s = requests.Session()
|
||||
s.headers.update({'X-IS-TEST': 'true'})
|
||||
url = f'{base}/api/graphql/{api_name}'
|
||||
q_create = {'query': 'mutation { create(name:"A") }'}
|
||||
assert (
|
||||
@@ -350,7 +353,7 @@ message DeleteReply { bool ok = 1; }
|
||||
'api_allowed_roles': [],
|
||||
'api_allowed_groups': [],
|
||||
'api_servers': [f'grpc://127.0.0.1:{port}'],
|
||||
'api_type': 'REST',
|
||||
'api_type': 'GRPC',
|
||||
'active': True,
|
||||
'api_public': True,
|
||||
},
|
||||
|
||||
@@ -136,7 +136,7 @@ message DeleteReply { bool ok = 1; }
|
||||
'api_allowed_roles': [],
|
||||
'api_allowed_groups': [],
|
||||
'api_servers': [f'grpc://{host_ref}:{port}'],
|
||||
'api_type': 'REST',
|
||||
'api_type': 'GRPC',
|
||||
'active': True,
|
||||
'api_public': True,
|
||||
'api_grpc_package': pkg,
|
||||
@@ -157,7 +157,7 @@ message DeleteReply { bool ok = 1; }
|
||||
assert r_ep.status_code in (200, 201), r_ep.text
|
||||
|
||||
url = f'{base}/api/grpc/{api_name}'
|
||||
hdr = {'X-API-Version': api_version}
|
||||
hdr = {'X-API-Version': api_version, 'X-IS-TEST': 'true'}
|
||||
assert (
|
||||
requests.post(
|
||||
url, json={'method': 'Resource.Create', 'message': {'name': 'A'}}, headers=hdr
|
||||
|
||||
@@ -3,10 +3,13 @@ import os
|
||||
|
||||
import pytest
|
||||
|
||||
from servers import (
|
||||
start_rest_echo_server,
|
||||
start_soap_echo_server,
|
||||
start_graphql_json_server,
|
||||
from live_targets import GRAPHQL_TARGETS, GRPC_TARGETS, REST_TARGETS, SOAP_TARGETS
|
||||
|
||||
TOTAL_PUBLIC_APIS = (
|
||||
min(10, len(REST_TARGETS))
|
||||
+ min(10, len(SOAP_TARGETS))
|
||||
+ min(10, len(GRAPHQL_TARGETS))
|
||||
+ min(10, len(GRPC_TARGETS))
|
||||
)
|
||||
|
||||
|
||||
@@ -15,7 +18,14 @@ from servers import (
|
||||
# -----------------------------
|
||||
|
||||
|
||||
def _mk_api(client, name: str, ver: str, servers: List[str], extra: Dict[str, Any] | None = None) -> None:
|
||||
def _mk_api(
|
||||
client,
|
||||
name: str,
|
||||
ver: str,
|
||||
servers: List[str],
|
||||
api_type: str,
|
||||
extra: Dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
r = client.post(
|
||||
"/platform/api",
|
||||
json={
|
||||
@@ -23,7 +33,7 @@ def _mk_api(client, name: str, ver: str, servers: List[str], extra: Dict[str, An
|
||||
"api_version": ver,
|
||||
"api_description": f"Public API {name}",
|
||||
"api_servers": servers,
|
||||
"api_type": "REST",
|
||||
"api_type": api_type,
|
||||
"api_public": True,
|
||||
"api_allowed_roles": ["admin"],
|
||||
"api_allowed_groups": ["ALL"],
|
||||
@@ -68,56 +78,35 @@ def provisioned_public_apis(client):
|
||||
catalog: List[Tuple[str, str, str, Dict[str, Any]]] = []
|
||||
ver = "v1"
|
||||
|
||||
rest_srv = start_rest_echo_server()
|
||||
soap_srv = start_soap_echo_server()
|
||||
gql_srv1 = start_graphql_json_server({'data': {'characters': {'info': {'count': 42}}}})
|
||||
gql_srv2 = start_graphql_json_server({'data': {'company': {'name': 'SpaceX'}}})
|
||||
gql_srv3 = start_graphql_json_server({'data': {'country': {'name': 'United States'}}})
|
||||
|
||||
def add_rest(name: str, server_url: str, uri: str):
|
||||
_mk_api(client, name, ver, [server_url])
|
||||
_mk_api(client, name, ver, [server_url], "REST")
|
||||
# Normalize endpoint registration to exclude querystring; gateway matches path-only.
|
||||
path_only = uri.split("?")[0]
|
||||
_mk_endpoint(client, name, ver, "GET", path_only)
|
||||
catalog.append(("REST", name, ver, {"uri": uri}))
|
||||
|
||||
# REST (12+)
|
||||
base = rest_srv.url
|
||||
for i, uri in enumerate([
|
||||
"/get",
|
||||
"/anything",
|
||||
"/posts/1",
|
||||
"/users/1",
|
||||
"/breeds/image/random",
|
||||
"/fact",
|
||||
"/activity",
|
||||
"/random",
|
||||
"/v6/USD",
|
||||
"/ip",
|
||||
"/gender?p=peter",
|
||||
"/age?name=lucy",
|
||||
]):
|
||||
add_rest(f"rest_local_{i}", base, uri)
|
||||
# REST (3-10)
|
||||
for i, (server_url, uri) in enumerate(REST_TARGETS[:10]):
|
||||
add_rest(f"rest_live_{i}", server_url, uri)
|
||||
|
||||
# SOAP (3)
|
||||
def add_soap(name: str, server_url: str, uri: str):
|
||||
_mk_api(client, name, ver, [server_url])
|
||||
# SOAP (3-10)
|
||||
def add_soap(name: str, server_url: str, uri: str, action: str):
|
||||
_mk_api(client, name, ver, [server_url], "SOAP")
|
||||
_mk_endpoint(client, name, ver, "POST", uri)
|
||||
catalog.append(("SOAP", name, ver, {"uri": uri}))
|
||||
catalog.append(("SOAP", name, ver, {"uri": uri, "soap_action": action}))
|
||||
|
||||
add_soap("soap_calc", soap_srv.url, "/calculator.asmx")
|
||||
add_soap("soap_numberconv", soap_srv.url, "/NumberConversion.wso")
|
||||
add_soap("soap_countryinfo", soap_srv.url, "/CountryInfoService.wso")
|
||||
for i, (server_url, uri, kind, action) in enumerate(SOAP_TARGETS[:10]):
|
||||
add_soap(f"soap_live_{i}", server_url, uri, action)
|
||||
catalog[-1][3]["sk"] = kind
|
||||
|
||||
# GraphQL (3) - Upstreams must expose /graphql path
|
||||
def add_gql(name: str, server_url: str, query: str):
|
||||
_mk_api(client, name, ver, [server_url])
|
||||
_mk_api(client, name, ver, [server_url], "GRAPHQL")
|
||||
_mk_endpoint(client, name, ver, "POST", "/graphql")
|
||||
catalog.append(("GRAPHQL", name, ver, {"query": query}))
|
||||
|
||||
add_gql("gql_rickandmorty", gql_srv1.url, "{ characters(page: 1) { info { count } } }")
|
||||
add_gql("gql_spacex", gql_srv2.url, "{ company { name } }")
|
||||
add_gql("gql_countries", gql_srv3.url, "{ country(code: \"US\") { name } }")
|
||||
for i, (server_url, query) in enumerate(GRAPHQL_TARGETS[:10]):
|
||||
add_gql(f"gql_live_{i}", server_url, query)
|
||||
|
||||
# gRPC (3) - Use public grpcbin with published Empty endpoint; upload minimal proto preserving package
|
||||
PROTO_GRPCBIN = (
|
||||
@@ -130,18 +119,24 @@ def provisioned_public_apis(client):
|
||||
)
|
||||
|
||||
def add_grpc(name: str, server_url: str, method: str, message: Dict[str, Any]):
|
||||
# Use HTTP fallback to /grpc on local REST server; no proto upload required
|
||||
_mk_api(client, name, ver, [server_url])
|
||||
files = {"file": ("grpcbin.proto", PROTO_GRPCBIN.encode("utf-8"), "application/octet-stream")}
|
||||
up = client.post(f"/platform/proto/{name}/{ver}", files=files)
|
||||
assert up.status_code == 200, up.text
|
||||
_mk_api(
|
||||
client,
|
||||
name,
|
||||
ver,
|
||||
[server_url],
|
||||
"GRPC",
|
||||
extra={"api_grpc_package": "grpcbin"},
|
||||
)
|
||||
_mk_endpoint(client, name, ver, "POST", "/grpc")
|
||||
catalog.append(("GRPC", name, ver, {"method": method, "message": message}))
|
||||
|
||||
# Cover both plaintext and TLS endpoints
|
||||
add_grpc("grpc_local1", rest_srv.url, "Svc.M", {})
|
||||
add_grpc("grpc_local2", rest_srv.url, "Svc.M", {})
|
||||
add_grpc("grpc_local3", rest_srv.url, "Svc.M", {})
|
||||
for i, (server_url, method) in enumerate(GRPC_TARGETS[:10]):
|
||||
add_grpc(f"grpc_live_{i}", server_url, method, {})
|
||||
|
||||
# Ensure minimum of 20 APIs
|
||||
assert len(catalog) >= 20
|
||||
assert len(catalog) >= TOTAL_PUBLIC_APIS
|
||||
try:
|
||||
yield catalog
|
||||
finally:
|
||||
@@ -189,8 +184,12 @@ def _call_public(client, kind: str, name: str, ver: str, meta: Dict[str, Any]):
|
||||
return client.get(f"/api/rest/{name}/{ver}{uri}")
|
||||
if kind == "SOAP":
|
||||
uri = meta["uri"]
|
||||
headers = {"Content-Type": "text/xml"}
|
||||
if meta.get("soap_action"):
|
||||
headers["SOAPAction"] = meta["soap_action"]
|
||||
kind_key = meta.get("sk") or ""
|
||||
# Minimal SOAP envelopes for public services
|
||||
if "calculator.asmx" in uri:
|
||||
if kind_key == "calc" or "calculator.asmx" in uri:
|
||||
envelope = (
|
||||
"<?xml version=\"1.0\" encoding=\"utf-8\"?>"
|
||||
"<soap:Envelope xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" "
|
||||
@@ -199,7 +198,7 @@ def _call_public(client, kind: str, name: str, ver: str, meta: Dict[str, Any]):
|
||||
"<soap:Body><Add xmlns=\"http://tempuri.org/\"><intA>1</intA><intB>2</intB></Add>"
|
||||
"</soap:Body></soap:Envelope>"
|
||||
)
|
||||
elif "NumberConversion" in uri:
|
||||
elif kind_key == "num" or "NumberConversion" in uri:
|
||||
envelope = (
|
||||
"<?xml version=\"1.0\" encoding=\"utf-8\"?>"
|
||||
"<soap:Envelope xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" "
|
||||
@@ -208,6 +207,15 @@ def _call_public(client, kind: str, name: str, ver: str, meta: Dict[str, Any]):
|
||||
"<soap:Body><NumberToWords xmlns=\"http://www.dataaccess.com/webservicesserver/\">"
|
||||
"<ubiNum>7</ubiNum></NumberToWords></soap:Body></soap:Envelope>"
|
||||
)
|
||||
elif kind_key == "temp" or "tempconvert" in uri:
|
||||
envelope = (
|
||||
"<?xml version=\"1.0\" encoding=\"utf-8\"?>"
|
||||
"<soap:Envelope xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" "
|
||||
"xmlns:xsd=\"http://www.w3.org/2001/XMLSchema\" "
|
||||
"xmlns:soap=\"http://schemas.xmlsoap.org/soap/envelope/\">"
|
||||
"<soap:Body><CelsiusToFahrenheit xmlns=\"https://www.w3schools.com/xml/\">"
|
||||
"<Celsius>20</Celsius></CelsiusToFahrenheit></soap:Body></soap:Envelope>"
|
||||
)
|
||||
else:
|
||||
envelope = (
|
||||
"<?xml version=\"1.0\" encoding=\"utf-8\"?>"
|
||||
@@ -217,9 +225,7 @@ def _call_public(client, kind: str, name: str, ver: str, meta: Dict[str, Any]):
|
||||
"<soap:Body><CapitalCity xmlns=\"http://www.oorsprong.org/websamples.countryinfo\">"
|
||||
"<sCountryISOCode>US</sCountryISOCode></CapitalCity></soap:Body></soap:Envelope>"
|
||||
)
|
||||
return client.post(
|
||||
f"/api/soap/{name}/{ver}{uri}", data=envelope, headers={"Content-Type": "text/xml"}
|
||||
)
|
||||
return client.post(f"/api/soap/{name}/{ver}{uri}", data=envelope, headers=headers)
|
||||
if kind == "GRAPHQL":
|
||||
q = meta.get("query") or "{ hello }"
|
||||
return client.post(
|
||||
@@ -242,7 +248,7 @@ def _ok_status(code: int) -> bool:
|
||||
|
||||
|
||||
@pytest.mark.parametrize("repeat", list(range(1, 2)))
|
||||
@pytest.mark.parametrize("idx", list(range(0, 20)))
|
||||
@pytest.mark.parametrize("idx", list(range(0, TOTAL_PUBLIC_APIS)))
|
||||
def test_public_api_reachability_smoke(client, provisioned_public_apis, idx, repeat):
|
||||
kind, name, ver, meta = provisioned_public_apis[idx]
|
||||
r = _call_public(client, kind, name, ver, meta)
|
||||
@@ -250,7 +256,7 @@ def test_public_api_reachability_smoke(client, provisioned_public_apis, idx, rep
|
||||
assert _ok_status(r.status_code), r.text
|
||||
|
||||
|
||||
@pytest.mark.parametrize("idx", list(range(0, 20)))
|
||||
@pytest.mark.parametrize("idx", list(range(0, TOTAL_PUBLIC_APIS)))
|
||||
def test_public_api_allows_header_forwarding(client, provisioned_public_apis, idx):
|
||||
kind, name, ver, meta = provisioned_public_apis[idx]
|
||||
# Do not skip live checks; tolerate upstream variability instead
|
||||
@@ -259,6 +265,9 @@ def test_public_api_allows_header_forwarding(client, provisioned_public_apis, id
|
||||
if kind == "REST":
|
||||
r = client.get(f"/api/rest/{name}/{ver}{meta['uri']}", headers={"X-Test": "1"})
|
||||
elif kind == "SOAP":
|
||||
headers = {"Content-Type": "text/xml", "X-Test": "1"}
|
||||
if meta.get("soap_action"):
|
||||
headers["SOAPAction"] = meta["soap_action"]
|
||||
envelope = (
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>"
|
||||
"<soap:Envelope xmlns:soap=\"http://schemas.xmlsoap.org/soap/envelope/\">"
|
||||
@@ -268,7 +277,7 @@ def test_public_api_allows_header_forwarding(client, provisioned_public_apis, id
|
||||
r = client.post(
|
||||
f"/api/soap/{name}/{ver}{meta['uri']}",
|
||||
data=envelope,
|
||||
headers={"Content-Type": "text/xml", "X-Test": "1"},
|
||||
headers=headers,
|
||||
)
|
||||
elif kind == "GRAPHQL":
|
||||
q = meta.get("query") or "{ hello }"
|
||||
@@ -286,7 +295,7 @@ def test_public_api_allows_header_forwarding(client, provisioned_public_apis, id
|
||||
assert _ok_status(r.status_code), r.text
|
||||
|
||||
|
||||
@pytest.mark.parametrize("idx", list(range(0, 20)))
|
||||
@pytest.mark.parametrize("idx", list(range(0, TOTAL_PUBLIC_APIS)))
|
||||
def test_public_api_cors_preflight(client, provisioned_public_apis, idx):
|
||||
kind, name, ver, meta = provisioned_public_apis[idx]
|
||||
if meta.get("skip"):
|
||||
@@ -338,7 +347,7 @@ def test_public_api_cors_preflight(client, provisioned_public_apis, idx):
|
||||
assert _ok_status(r.status_code), r.text
|
||||
|
||||
|
||||
@pytest.mark.parametrize("idx", list(range(0, 20)))
|
||||
@pytest.mark.parametrize("idx", list(range(0, TOTAL_PUBLIC_APIS)))
|
||||
def test_public_api_querystring_passthrough(client, provisioned_public_apis, idx):
|
||||
kind, name, ver, meta = provisioned_public_apis[idx]
|
||||
if meta.get("skip"):
|
||||
@@ -374,7 +383,7 @@ def test_public_api_querystring_passthrough(client, provisioned_public_apis, idx
|
||||
assert _ok_status(r.status_code), r.text
|
||||
|
||||
|
||||
@pytest.mark.parametrize("idx", list(range(0, 20)))
|
||||
@pytest.mark.parametrize("idx", list(range(0, TOTAL_PUBLIC_APIS)))
|
||||
def test_public_api_multiple_calls_stability(client, provisioned_public_apis, idx):
|
||||
kind, name, ver, meta = provisioned_public_apis[idx]
|
||||
# Two quick back-to-back calls to catch simple race/limits; only assert not auth failure.
|
||||
@@ -385,7 +394,7 @@ def test_public_api_multiple_calls_stability(client, provisioned_public_apis, id
|
||||
assert _ok_status(r2.status_code), r2.text
|
||||
|
||||
|
||||
@pytest.mark.parametrize("idx", list(range(0, 20)))
|
||||
@pytest.mark.parametrize("idx", list(range(0, TOTAL_PUBLIC_APIS)))
|
||||
def test_public_api_subscribe_and_call(client, provisioned_public_apis, idx):
|
||||
kind, name, ver, meta = provisioned_public_apis[idx]
|
||||
# Subscribe admin to the API; treat already-subscribed as success
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
import time
|
||||
|
||||
import os
|
||||
import pytest
|
||||
from servers import start_rest_echo_server, start_soap_echo_server, start_graphql_json_server
|
||||
from live_targets import GRAPHQL_TARGETS, GRPC_TARGETS, REST_TARGETS, SOAP_TARGETS
|
||||
pytestmark = [pytest.mark.public, pytest.mark.auth]
|
||||
|
||||
|
||||
def _mk_api(client, name: str, ver: str, servers: list[str], extra: dict | None = None):
|
||||
def _mk_api(
|
||||
client,
|
||||
name: str,
|
||||
ver: str,
|
||||
servers: list[str],
|
||||
api_type: str,
|
||||
extra: dict | None = None,
|
||||
):
|
||||
r = client.post(
|
||||
'/platform/api',
|
||||
json={
|
||||
@@ -14,7 +20,7 @@ def _mk_api(client, name: str, ver: str, servers: list[str], extra: dict | None
|
||||
'api_version': ver,
|
||||
'api_description': f'Restricted {name}',
|
||||
'api_servers': servers,
|
||||
'api_type': 'REST',
|
||||
'api_type': api_type,
|
||||
'api_public': False,
|
||||
'api_allowed_roles': ['admin'],
|
||||
'api_allowed_groups': ['ALL'],
|
||||
@@ -55,30 +61,41 @@ def restricted_apis(client):
|
||||
|
||||
# REST (requires subscription)
|
||||
name = f'rx-rest-{stamp}'
|
||||
rest = start_rest_echo_server()
|
||||
_mk_api(client, name, ver, [rest.url])
|
||||
_mk_endpoint(client, name, ver, 'GET', '/r')
|
||||
out.append(('REST', name, ver, {'uri': '/r'}))
|
||||
rest_server, rest_uri = REST_TARGETS[0]
|
||||
rest_path = rest_uri.split('?')[0] or '/'
|
||||
_mk_api(client, name, ver, [rest_server], 'REST')
|
||||
_mk_endpoint(client, name, ver, 'GET', rest_path)
|
||||
out.append(('REST', name, ver, {'uri': rest_uri}))
|
||||
|
||||
# SOAP (requires subscription)
|
||||
name = f'rx-soap-{stamp}'
|
||||
soap = start_soap_echo_server()
|
||||
_mk_api(client, name, ver, [soap.url])
|
||||
_mk_endpoint(client, name, ver, 'POST', '/svc')
|
||||
out.append(('SOAP', name, ver, {'uri': '/svc'}))
|
||||
soap_server, soap_uri, soap_kind, soap_action = SOAP_TARGETS[0]
|
||||
_mk_api(client, name, ver, [soap_server], 'SOAP')
|
||||
_mk_endpoint(client, name, ver, 'POST', soap_uri)
|
||||
out.append(
|
||||
('SOAP', name, ver, {'uri': soap_uri, 'sk': soap_kind, 'soap_action': soap_action})
|
||||
)
|
||||
|
||||
# GraphQL (requires subscription)
|
||||
name = f'rx-gql-{stamp}'
|
||||
gql = start_graphql_json_server({'data': {'ok': True}})
|
||||
_mk_api(client, name, ver, [gql.url])
|
||||
gql_server, gql_query = GRAPHQL_TARGETS[0]
|
||||
_mk_api(client, name, ver, [gql_server], 'GRAPHQL')
|
||||
_mk_endpoint(client, name, ver, 'POST', '/graphql')
|
||||
out.append(('GRAPHQL', name, ver, {'query': '{ ok }'}))
|
||||
out.append(('GRAPHQL', name, ver, {'query': gql_query}))
|
||||
|
||||
# gRPC (requires subscription) — do not upload proto here; we only assert auth behavior
|
||||
name = f'rx-grpc-{stamp}'
|
||||
_mk_api(client, name, ver, [rest.url])
|
||||
grpc_server, grpc_method = GRPC_TARGETS[0]
|
||||
_mk_api(
|
||||
client,
|
||||
name,
|
||||
ver,
|
||||
[grpc_server],
|
||||
'GRPC',
|
||||
extra={'api_grpc_package': 'grpcbin'},
|
||||
)
|
||||
_mk_endpoint(client, name, ver, 'POST', '/grpc')
|
||||
out.append(('GRPC', name, ver, {'method': 'Svc.M', 'message': {}}))
|
||||
out.append(('GRPC', name, ver, {'method': grpc_method, 'message': {}}))
|
||||
|
||||
try:
|
||||
yield out
|
||||
@@ -121,16 +138,39 @@ def _call(client, kind: str, name: str, ver: str, meta: dict):
|
||||
if kind == 'REST':
|
||||
return client.get(f'/api/rest/{name}/{ver}{meta["uri"]}')
|
||||
if kind == 'SOAP':
|
||||
envelope = (
|
||||
"<?xml version=\"1.0\" encoding=\"utf-8\"?>"
|
||||
"<soap:Envelope xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" "
|
||||
"xmlns:xsd=\"http://www.w3.org/2001/XMLSchema\" "
|
||||
"xmlns:soap=\"http://schemas.xmlsoap.org/soap/envelope/\">"
|
||||
"<soap:Body><Add xmlns=\"http://tempuri.org/\"><intA>1</intA><intB>2</intB></Add>"
|
||||
"</soap:Body></soap:Envelope>"
|
||||
)
|
||||
kind_key = meta.get('sk') or ''
|
||||
headers = {'Content-Type': 'text/xml'}
|
||||
if meta.get('soap_action'):
|
||||
headers['SOAPAction'] = meta['soap_action']
|
||||
if kind_key == 'calc':
|
||||
envelope = (
|
||||
"<?xml version=\"1.0\" encoding=\"utf-8\"?>"
|
||||
"<soap:Envelope xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" "
|
||||
"xmlns:xsd=\"http://www.w3.org/2001/XMLSchema\" "
|
||||
"xmlns:soap=\"http://schemas.xmlsoap.org/soap/envelope/\">"
|
||||
"<soap:Body><Add xmlns=\"http://tempuri.org/\"><intA>1</intA><intB>2</intB></Add>"
|
||||
"</soap:Body></soap:Envelope>"
|
||||
)
|
||||
elif kind_key == 'num':
|
||||
envelope = (
|
||||
"<?xml version=\"1.0\" encoding=\"utf-8\"?>"
|
||||
"<soap:Envelope xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" "
|
||||
"xmlns:xsd=\"http://www.w3.org/2001/XMLSchema\" "
|
||||
"xmlns:soap=\"http://schemas.xmlsoap.org/soap/envelope/\">"
|
||||
"<soap:Body><NumberToWords xmlns=\"http://www.dataaccess.com/webservicesserver/\">"
|
||||
"<ubiNum>7</ubiNum></NumberToWords></soap:Body></soap:Envelope>"
|
||||
)
|
||||
else:
|
||||
envelope = (
|
||||
"<?xml version=\"1.0\" encoding=\"utf-8\"?>"
|
||||
"<soap:Envelope xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" "
|
||||
"xmlns:xsd=\"http://www.w3.org/2001/XMLSchema\" "
|
||||
"xmlns:soap=\"http://schemas.xmlsoap.org/soap/envelope/\">"
|
||||
"<soap:Body><CapitalCity xmlns=\"http://www.oorsprong.org/websamples.countryinfo\">"
|
||||
"<sCountryISOCode>US</sCountryISOCode></CapitalCity></soap:Body></soap:Envelope>"
|
||||
)
|
||||
return client.post(
|
||||
f'/api/soap/{name}/{ver}{meta["uri"]}', data=envelope, headers={'Content-Type': 'text/xml'}
|
||||
f'/api/soap/{name}/{ver}{meta["uri"]}', data=envelope, headers=headers
|
||||
)
|
||||
if kind == 'GRAPHQL':
|
||||
q = meta.get('query') or '{ __typename }'
|
||||
|
||||
@@ -1,72 +1,108 @@
|
||||
import time
|
||||
|
||||
from servers import start_soap_echo_server
|
||||
from live_targets import SOAP_TARGETS
|
||||
|
||||
|
||||
def test_soap_gateway_basic_flow(client):
|
||||
srv = start_soap_echo_server()
|
||||
try:
|
||||
api_name = f'soap-demo-{int(time.time())}'
|
||||
last_error = None
|
||||
for idx, (server_url, uri, kind, action) in enumerate(SOAP_TARGETS):
|
||||
api_name = f'soap-demo-{int(time.time())}-{idx}'
|
||||
api_version = 'v1'
|
||||
|
||||
r = client.post(
|
||||
'/platform/api',
|
||||
json={
|
||||
'api_name': api_name,
|
||||
'api_version': api_version,
|
||||
'api_description': 'SOAP demo',
|
||||
'api_allowed_roles': ['admin'],
|
||||
'api_allowed_groups': ['ALL'],
|
||||
'api_servers': [srv.url],
|
||||
'api_type': 'REST',
|
||||
'api_allowed_retry_count': 0,
|
||||
'active': True,
|
||||
},
|
||||
)
|
||||
assert r.status_code in (200, 201), r.text
|
||||
|
||||
r = client.post(
|
||||
'/platform/endpoint',
|
||||
json={
|
||||
'api_name': api_name,
|
||||
'api_version': api_version,
|
||||
'endpoint_method': 'POST',
|
||||
'endpoint_uri': '/soap',
|
||||
'endpoint_description': 'soap',
|
||||
},
|
||||
)
|
||||
assert r.status_code in (200, 201)
|
||||
|
||||
r = client.post(
|
||||
'/platform/subscription/subscribe',
|
||||
json={'api_name': api_name, 'api_version': api_version, 'username': 'admin'},
|
||||
)
|
||||
assert r.status_code in (200, 201)
|
||||
|
||||
body = """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
|
||||
<soap:Body>
|
||||
<EchoRequest><message>hi</message></EchoRequest>
|
||||
</soap:Body>
|
||||
</soap:Envelope>
|
||||
""".strip()
|
||||
r = client.post(
|
||||
f'/api/soap/{api_name}/{api_version}/soap',
|
||||
data=body,
|
||||
headers={'Content-Type': 'text/xml'},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
finally:
|
||||
try:
|
||||
client.delete(f'/platform/endpoint/POST/{api_name}/{api_version}/soap')
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
client.delete(f'/platform/api/{api_name}/{api_version}')
|
||||
except Exception:
|
||||
pass
|
||||
srv.stop()
|
||||
r = client.post(
|
||||
'/platform/api',
|
||||
json={
|
||||
'api_name': api_name,
|
||||
'api_version': api_version,
|
||||
'api_description': 'SOAP demo',
|
||||
'api_allowed_roles': ['admin'],
|
||||
'api_allowed_groups': ['ALL'],
|
||||
'api_servers': [server_url],
|
||||
'api_type': 'SOAP',
|
||||
'api_allowed_retry_count': 0,
|
||||
'active': True,
|
||||
},
|
||||
)
|
||||
assert r.status_code in (200, 201), r.text
|
||||
|
||||
r = client.post(
|
||||
'/platform/endpoint',
|
||||
json={
|
||||
'api_name': api_name,
|
||||
'api_version': api_version,
|
||||
'endpoint_method': 'POST',
|
||||
'endpoint_uri': uri,
|
||||
'endpoint_description': 'soap',
|
||||
},
|
||||
)
|
||||
assert r.status_code in (200, 201)
|
||||
|
||||
r = client.post(
|
||||
'/platform/subscription/subscribe',
|
||||
json={'api_name': api_name, 'api_version': api_version, 'username': 'admin'},
|
||||
)
|
||||
assert r.status_code in (200, 201)
|
||||
|
||||
if kind == 'calc':
|
||||
body = (
|
||||
"<?xml version=\"1.0\" encoding=\"utf-8\"?>"
|
||||
"<soap:Envelope xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" "
|
||||
"xmlns:xsd=\"http://www.w3.org/2001/XMLSchema\" "
|
||||
"xmlns:soap=\"http://schemas.xmlsoap.org/soap/envelope/\">"
|
||||
"<soap:Body><Add xmlns=\"http://tempuri.org/\"><intA>1</intA><intB>2</intB></Add>"
|
||||
"</soap:Body></soap:Envelope>"
|
||||
)
|
||||
elif kind == 'num':
|
||||
body = (
|
||||
"<?xml version=\"1.0\" encoding=\"utf-8\"?>"
|
||||
"<soap:Envelope xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" "
|
||||
"xmlns:xsd=\"http://www.w3.org/2001/XMLSchema\" "
|
||||
"xmlns:soap=\"http://schemas.xmlsoap.org/soap/envelope/\">"
|
||||
"<soap:Body><NumberToWords xmlns=\"http://www.dataaccess.com/webservicesserver/\">"
|
||||
"<ubiNum>7</ubiNum></NumberToWords></soap:Body></soap:Envelope>"
|
||||
)
|
||||
elif kind == 'temp':
|
||||
body = (
|
||||
"<?xml version=\"1.0\" encoding=\"utf-8\"?>"
|
||||
"<soap:Envelope xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" "
|
||||
"xmlns:xsd=\"http://www.w3.org/2001/XMLSchema\" "
|
||||
"xmlns:soap=\"http://schemas.xmlsoap.org/soap/envelope/\">"
|
||||
"<soap:Body><CelsiusToFahrenheit xmlns=\"https://www.w3schools.com/xml/\">"
|
||||
"<Celsius>20</Celsius></CelsiusToFahrenheit></soap:Body></soap:Envelope>"
|
||||
)
|
||||
else:
|
||||
body = (
|
||||
"<?xml version=\"1.0\" encoding=\"utf-8\"?>"
|
||||
"<soap:Envelope xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" "
|
||||
"xmlns:xsd=\"http://www.w3.org/2001/XMLSchema\" "
|
||||
"xmlns:soap=\"http://schemas.xmlsoap.org/soap/envelope/\">"
|
||||
"<soap:Body><CapitalCity xmlns=\"http://www.oorsprong.org/websamples.countryinfo\">"
|
||||
"<sCountryISOCode>US</sCountryISOCode></CapitalCity></soap:Body></soap:Envelope>"
|
||||
)
|
||||
headers = {'Content-Type': 'text/xml'}
|
||||
if action:
|
||||
headers['SOAPAction'] = action
|
||||
r = client.post(
|
||||
f'/api/soap/{api_name}/{api_version}{uri}',
|
||||
data=body,
|
||||
headers=headers,
|
||||
)
|
||||
if r.status_code == 200:
|
||||
return
|
||||
last_error = r
|
||||
finally:
|
||||
try:
|
||||
client.delete(f'/platform/endpoint/POST/{api_name}/{api_version}{uri}')
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
client.delete(f'/platform/api/{api_name}/{api_version}')
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
assert last_error is None or last_error.status_code == 200, (
|
||||
last_error.text if last_error else 'No SOAP targets available'
|
||||
)
|
||||
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import time
|
||||
|
||||
from servers import start_soap_echo_server
|
||||
from live_targets import SOAP_TARGETS
|
||||
|
||||
|
||||
def test_soap_validation_blocks_missing_field(client):
|
||||
srv = start_soap_echo_server()
|
||||
try:
|
||||
api_name = f'soapval-{int(time.time())}'
|
||||
api_version = 'v1'
|
||||
server_url, uri, kind, action = SOAP_TARGETS[0]
|
||||
client.post(
|
||||
'/platform/api',
|
||||
json={
|
||||
@@ -16,8 +16,8 @@ def test_soap_validation_blocks_missing_field(client):
|
||||
'api_description': 'soap val',
|
||||
'api_allowed_roles': ['admin'],
|
||||
'api_allowed_groups': ['ALL'],
|
||||
'api_servers': [srv.url],
|
||||
'api_type': 'REST',
|
||||
'api_servers': [server_url],
|
||||
'api_type': 'SOAP',
|
||||
'active': True,
|
||||
},
|
||||
)
|
||||
@@ -27,7 +27,7 @@ def test_soap_validation_blocks_missing_field(client):
|
||||
'api_name': api_name,
|
||||
'api_version': api_version,
|
||||
'endpoint_method': 'POST',
|
||||
'endpoint_uri': '/soap',
|
||||
'endpoint_uri': uri,
|
||||
'endpoint_description': 'soap',
|
||||
},
|
||||
)
|
||||
@@ -36,11 +36,19 @@ def test_soap_validation_blocks_missing_field(client):
|
||||
json={'api_name': api_name, 'api_version': api_version, 'username': 'admin'},
|
||||
)
|
||||
|
||||
r = client.get(f'/platform/endpoint/POST/{api_name}/{api_version}/soap')
|
||||
r = client.get(f'/platform/endpoint/POST/{api_name}/{api_version}{uri}')
|
||||
ep = r.json().get('response', r.json())
|
||||
endpoint_id = ep.get('endpoint_id')
|
||||
assert endpoint_id
|
||||
schema = {'validation_schema': {'message': {'required': True, 'type': 'string', 'min': 2}}}
|
||||
if kind == 'num':
|
||||
schema_field = 'ubiNum'
|
||||
elif kind == 'temp':
|
||||
schema_field = 'Celsius'
|
||||
elif kind == 'country':
|
||||
schema_field = 'sCountryISOCode'
|
||||
else:
|
||||
schema_field = 'intA'
|
||||
schema = {'validation_schema': {schema_field: {'required': True, 'type': 'string', 'min': 2}}}
|
||||
r = client.post(
|
||||
'/platform/endpoint/endpoint/validation',
|
||||
json={
|
||||
@@ -51,35 +59,84 @@ def test_soap_validation_blocks_missing_field(client):
|
||||
)
|
||||
assert r.status_code in (200, 201)
|
||||
|
||||
xml = """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
|
||||
<soap:Body>
|
||||
<EchoRequest><message>A</message></EchoRequest>
|
||||
</soap:Body>
|
||||
</soap:Envelope>
|
||||
""".strip()
|
||||
if kind == 'calc':
|
||||
xml = (
|
||||
"<?xml version=\"1.0\" encoding=\"utf-8\"?>"
|
||||
"<soap:Envelope xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" "
|
||||
"xmlns:xsd=\"http://www.w3.org/2001/XMLSchema\" "
|
||||
"xmlns:soap=\"http://schemas.xmlsoap.org/soap/envelope/\">"
|
||||
"<soap:Body><Add xmlns=\"http://tempuri.org/\"><intA>1</intA><intB>2</intB></Add>"
|
||||
"</soap:Body></soap:Envelope>"
|
||||
)
|
||||
elif kind == 'num':
|
||||
xml = (
|
||||
"<?xml version=\"1.0\" encoding=\"utf-8\"?>"
|
||||
"<soap:Envelope xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" "
|
||||
"xmlns:xsd=\"http://www.w3.org/2001/XMLSchema\" "
|
||||
"xmlns:soap=\"http://schemas.xmlsoap.org/soap/envelope/\">"
|
||||
"<soap:Body><NumberToWords xmlns=\"http://www.dataaccess.com/webservicesserver/\">"
|
||||
"<ubiNum>7</ubiNum></NumberToWords></soap:Body></soap:Envelope>"
|
||||
)
|
||||
elif kind == 'temp':
|
||||
xml = (
|
||||
"<?xml version=\"1.0\" encoding=\"utf-8\"?>"
|
||||
"<soap:Envelope xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" "
|
||||
"xmlns:xsd=\"http://www.w3.org/2001/XMLSchema\" "
|
||||
"xmlns:soap=\"http://schemas.xmlsoap.org/soap/envelope/\">"
|
||||
"<soap:Body><CelsiusToFahrenheit xmlns=\"https://www.w3schools.com/xml/\">"
|
||||
"<Celsius>1</Celsius></CelsiusToFahrenheit></soap:Body></soap:Envelope>"
|
||||
)
|
||||
elif kind == 'country':
|
||||
xml = (
|
||||
"<?xml version=\"1.0\" encoding=\"utf-8\"?>"
|
||||
"<soap:Envelope xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" "
|
||||
"xmlns:xsd=\"http://www.w3.org/2001/XMLSchema\" "
|
||||
"xmlns:soap=\"http://schemas.xmlsoap.org/soap/envelope/\">"
|
||||
"<soap:Body><CapitalCity xmlns=\"http://www.oorsprong.org/websamples.countryinfo\">"
|
||||
"<sCountryISOCode>U</sCountryISOCode></CapitalCity></soap:Body></soap:Envelope>"
|
||||
)
|
||||
else:
|
||||
xml = (
|
||||
"<?xml version=\"1.0\" encoding=\"utf-8\"?>"
|
||||
"<soap:Envelope xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" "
|
||||
"xmlns:xsd=\"http://www.w3.org/2001/XMLSchema\" "
|
||||
"xmlns:soap=\"http://schemas.xmlsoap.org/soap/envelope/\">"
|
||||
"<soap:Body><Add xmlns=\"http://tempuri.org/\"><intA>1</intA><intB>2</intB></Add>"
|
||||
"</soap:Body></soap:Envelope>"
|
||||
)
|
||||
headers = {'Content-Type': 'text/xml'}
|
||||
if action:
|
||||
if action.startswith('"') and action.endswith('"'):
|
||||
headers['SOAPAction'] = action
|
||||
else:
|
||||
headers['SOAPAction'] = f'"{action}"'
|
||||
r = client.post(
|
||||
f'/api/soap/{api_name}/{api_version}/soap',
|
||||
f'/api/soap/{api_name}/{api_version}{uri}',
|
||||
data=xml,
|
||||
headers={'Content-Type': 'text/xml'},
|
||||
headers=headers,
|
||||
)
|
||||
assert r.status_code == 400
|
||||
|
||||
xml2 = xml.replace('>A<', '>AB<')
|
||||
if kind == 'num':
|
||||
xml2 = xml.replace('<ubiNum>7</ubiNum>', '<ubiNum>12</ubiNum>')
|
||||
elif kind == 'temp':
|
||||
xml2 = xml.replace('<Celsius>1</Celsius>', '<Celsius>20</Celsius>')
|
||||
elif kind == 'country':
|
||||
xml2 = xml.replace('<sCountryISOCode>U</sCountryISOCode>', '<sCountryISOCode>US</sCountryISOCode>')
|
||||
else:
|
||||
xml2 = xml.replace('<intA>1</intA>', '<intA>12</intA>')
|
||||
r = client.post(
|
||||
f'/api/soap/{api_name}/{api_version}/soap',
|
||||
f'/api/soap/{api_name}/{api_version}{uri}',
|
||||
data=xml2,
|
||||
headers={'Content-Type': 'text/xml'},
|
||||
headers=headers,
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert r.status_code in (200, 401, 403, 404, 500, 502, 503, 504)
|
||||
finally:
|
||||
try:
|
||||
client.delete(f'/platform/endpoint/POST/{api_name}/{api_version}/soap')
|
||||
client.delete(f'/platform/endpoint/POST/{api_name}/{api_version}{uri}')
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
client.delete(f'/platform/api/{api_name}/{api_version}')
|
||||
except Exception:
|
||||
pass
|
||||
srv.stop()
|
||||
|
||||
@@ -17,7 +17,7 @@ def test_soap_cors_preflight(client):
|
||||
'api_allowed_roles': ['admin'],
|
||||
'api_allowed_groups': ['ALL'],
|
||||
'api_servers': ['http://127.0.0.1:9'],
|
||||
'api_type': 'REST',
|
||||
'api_type': 'SOAP',
|
||||
'active': True,
|
||||
'api_cors_allow_origins': ['http://example.com'],
|
||||
'api_cors_allow_methods': ['POST'],
|
||||
|
||||
@@ -75,7 +75,7 @@ def test_graphql_gateway_basic_flow(client):
|
||||
'api_allowed_roles': ['admin'],
|
||||
'api_allowed_groups': ['ALL'],
|
||||
'api_servers': [f'http://{host}:{port}'],
|
||||
'api_type': 'REST',
|
||||
'api_type': 'GRAPHQL',
|
||||
'api_allowed_retry_count': 0,
|
||||
'active': True,
|
||||
},
|
||||
|
||||
@@ -71,7 +71,7 @@ def test_graphql_validation_blocks_invalid_variables(client):
|
||||
'api_allowed_roles': ['admin'],
|
||||
'api_allowed_groups': ['ALL'],
|
||||
'api_servers': [f'http://{host}:{port}'],
|
||||
'api_type': 'REST',
|
||||
'api_type': 'GRAPHQL',
|
||||
'active': True,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
import pytest
|
||||
from servers import start_graphql_json_server
|
||||
from live_targets import GRAPHQL_TARGETS
|
||||
|
||||
|
||||
def test_graphql_public_local_via_gateway(client):
|
||||
"""Exercise a public GraphQL API through the gateway using a local helper server."""
|
||||
"""Exercise a public GraphQL API through the gateway using a live upstream."""
|
||||
api_name = 'gqlpub'
|
||||
api_version = 'v1'
|
||||
|
||||
srv = start_graphql_json_server({'data': {'characters': {'info': {'count': 123}}}})
|
||||
server_url, query = GRAPHQL_TARGETS[0]
|
||||
|
||||
r = client.post(
|
||||
'/platform/api',
|
||||
json={
|
||||
'api_name': api_name,
|
||||
'api_version': api_version,
|
||||
'api_description': 'Public GraphQL (local)',
|
||||
'api_description': 'Public GraphQL (live)',
|
||||
'api_allowed_roles': [],
|
||||
'api_allowed_groups': ['ALL'],
|
||||
'api_servers': [srv.url],
|
||||
'api_type': 'REST',
|
||||
'api_servers': [server_url],
|
||||
'api_type': 'GRAPHQL',
|
||||
'api_allowed_retry_count': 0,
|
||||
'api_public': True,
|
||||
'api_auth_required': False,
|
||||
@@ -38,7 +38,7 @@ def test_graphql_public_local_via_gateway(client):
|
||||
)
|
||||
assert r.status_code in (200, 201), r.text
|
||||
|
||||
q = '{ characters(page: 1) { info { count } } }'
|
||||
q = query
|
||||
r = client.post(
|
||||
f'/api/graphql/{api_name}',
|
||||
json={'query': q, 'variables': {}},
|
||||
@@ -49,4 +49,4 @@ def test_graphql_public_local_via_gateway(client):
|
||||
data = body.get('response', body).get('data') if isinstance(body, dict) else None
|
||||
if data is None and 'data' in body:
|
||||
data = body.get('data')
|
||||
assert isinstance(data, dict) and 'characters' in data
|
||||
assert isinstance(data, dict)
|
||||
|
||||
@@ -35,7 +35,7 @@ service Greeter {}
|
||||
'api_allowed_roles': ['admin'],
|
||||
'api_allowed_groups': ['ALL'],
|
||||
'api_servers': ['grpc://127.0.0.1:9'],
|
||||
'api_type': 'REST',
|
||||
'api_type': 'GRPC',
|
||||
'active': True,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -112,7 +112,7 @@ def test_grpc_gateway_basic_flow(client):
|
||||
'api_allowed_roles': ['admin'],
|
||||
'api_allowed_groups': ['ALL'],
|
||||
'api_servers': [f'grpc://127.0.0.1:{port}'],
|
||||
'api_type': 'REST',
|
||||
'api_type': 'GRPC',
|
||||
'api_allowed_retry_count': 0,
|
||||
'active': True,
|
||||
},
|
||||
|
||||
@@ -51,7 +51,7 @@ def test_graphql_missing_version_header_returns_400(client):
|
||||
'api_allowed_roles': ['admin'],
|
||||
'api_allowed_groups': ['ALL'],
|
||||
'api_servers': [f'http://127.0.0.1:{port}'],
|
||||
'api_type': 'REST',
|
||||
'api_type': 'GRAPHQL',
|
||||
'active': True,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -36,6 +36,7 @@ def test_public_api_no_auth_required(client):
|
||||
)
|
||||
assert r.status_code in (200, 201)
|
||||
s = requests.Session()
|
||||
s.headers.update({'X-IS-TEST': 'true'})
|
||||
url = client.base_url.rstrip('/') + f'/api/rest/{api_name}/{api_version}/status'
|
||||
r = s.get(url)
|
||||
assert r.status_code == 200
|
||||
@@ -86,6 +87,7 @@ def test_auth_not_required_but_not_public_allows_unauthenticated(client):
|
||||
import requests
|
||||
|
||||
s = requests.Session()
|
||||
s.headers.update({'X-IS-TEST': 'true'})
|
||||
url = client.base_url.rstrip('/') + f'/api/rest/{api_name}/{api_version}/ping'
|
||||
r = s.get(url)
|
||||
assert r.status_code == 200
|
||||
|
||||
@@ -13,7 +13,7 @@ async def _setup(client, upstream_url: str, name='gllive', ver='v1'):
|
||||
'api_allowed_roles': ['admin'],
|
||||
'api_allowed_groups': ['ALL'],
|
||||
'api_servers': [upstream_url],
|
||||
'api_type': 'REST',
|
||||
'api_type': 'GRAPHQL',
|
||||
'api_allowed_retry_count': 0,
|
||||
'api_public': True,
|
||||
},
|
||||
|
||||
@@ -19,7 +19,7 @@ def test_grpc_reflection_no_proto(client):
|
||||
'api_allowed_roles': ['admin'],
|
||||
'api_allowed_groups': ['ALL'],
|
||||
'api_servers': ['grpcs://grpcb.in:9001'],
|
||||
'api_type': 'REST',
|
||||
'api_type': 'GRPC',
|
||||
'api_allowed_retry_count': 0,
|
||||
'active': True,
|
||||
'api_grpc_package': 'grpcbin',
|
||||
|
||||
@@ -6,50 +6,25 @@ from typing import Any, Dict, List, Tuple
|
||||
import pytest
|
||||
|
||||
from client import LiveClient
|
||||
from live_targets import GRAPHQL_TARGETS, GRPC_TARGETS, REST_TARGETS, SOAP_TARGETS
|
||||
|
||||
_RUN_EXT = os.getenv('DOORMAN_TEST_EXTERNAL', '0') in ('1', 'true', 'True')
|
||||
pytestmark = [
|
||||
pytest.mark.public,
|
||||
pytest.mark.credits,
|
||||
pytest.mark.gateway,
|
||||
pytest.mark.skipif(not _RUN_EXT, reason='Requires external network (DOORMAN_TEST_EXTERNAL=1)')
|
||||
]
|
||||
pytestmark = [pytest.mark.public, pytest.mark.credits, pytest.mark.gateway]
|
||||
|
||||
|
||||
def _rest_targets() -> List[Tuple[str, str]]:
|
||||
return [
|
||||
("https://httpbin.org", "/get"),
|
||||
("https://jsonplaceholder.typicode.com", "/posts/1"),
|
||||
("https://api.ipify.org", "/?format=json"),
|
||||
]
|
||||
return REST_TARGETS
|
||||
|
||||
|
||||
def _soap_targets() -> List[Tuple[str, str, str]]:
|
||||
return [
|
||||
("http://www.dneonline.com", "/calculator.asmx", "calc"),
|
||||
("https://www.dataaccess.com", "/webservicesserver/NumberConversion.wso", "num"),
|
||||
(
|
||||
"http://webservices.oorsprong.org",
|
||||
"/websamples.countryinfo/CountryInfoService.wso",
|
||||
"country",
|
||||
),
|
||||
]
|
||||
def _soap_targets() -> List[Tuple[str, str, str, str]]:
|
||||
return SOAP_TARGETS
|
||||
|
||||
|
||||
def _gql_targets() -> List[Tuple[str, str]]:
|
||||
return [
|
||||
("https://rickandmortyapi.com", "{ characters(page: 1) { info { count } } }"),
|
||||
("https://api.spacex.land", "{ company { name } }"),
|
||||
("https://countries.trevorblades.com", "{ country(code: \"US\") { name } }")
|
||||
]
|
||||
return GRAPHQL_TARGETS
|
||||
|
||||
|
||||
def _grpc_targets() -> List[Tuple[str, str]]:
|
||||
return [
|
||||
("grpc://grpcb.in:9000", "GRPCBin.Empty"),
|
||||
("grpcs://grpcb.in:9001", "GRPCBin.Empty"),
|
||||
("grpc://grpcb.in:9000", "GRPCBin.Empty"),
|
||||
]
|
||||
return GRPC_TARGETS
|
||||
|
||||
|
||||
PROTO_GRPCBIN = (
|
||||
@@ -91,6 +66,15 @@ def _soap_envelope(kind: str) -> str:
|
||||
"<soap:Body><NumberToWords xmlns=\"http://www.dataaccess.com/webservicesserver/\">"
|
||||
"<ubiNum>7</ubiNum></NumberToWords></soap:Body></soap:Envelope>"
|
||||
)
|
||||
if kind == "temp":
|
||||
return (
|
||||
"<?xml version=\"1.0\" encoding=\"utf-8\"?>"
|
||||
"<soap:Envelope xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" "
|
||||
"xmlns:xsd=\"http://www.w3.org/2001/XMLSchema\" "
|
||||
"xmlns:soap=\"http://schemas.xmlsoap.org/soap/envelope/\">"
|
||||
"<soap:Body><CelsiusToFahrenheit xmlns=\"https://www.w3schools.com/xml/\">"
|
||||
"<Celsius>20</Celsius></CelsiusToFahrenheit></soap:Body></soap:Envelope>"
|
||||
)
|
||||
return (
|
||||
"<?xml version=\"1.0\" encoding=\"utf-8\"?>"
|
||||
"<soap:Envelope xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" "
|
||||
@@ -168,8 +152,11 @@ def _one_call(client: LiveClient, kind: str, name: str, ver: str, meta: Dict[str
|
||||
return client.get(f"/api/rest/{name}/{ver}{meta['uri']}")
|
||||
if kind == "SOAP":
|
||||
env = _soap_envelope(meta["sk"]) # soap kind
|
||||
headers = {"Content-Type": "text/xml"}
|
||||
if meta.get("soap_action"):
|
||||
headers["SOAPAction"] = meta["soap_action"]
|
||||
return client.post(
|
||||
f"/api/soap/{name}/{ver}{meta['uri']}", data=env, headers={"Content-Type": "text/xml"}
|
||||
f"/api/soap/{name}/{ver}{meta['uri']}", data=env, headers=headers
|
||||
)
|
||||
if kind == "GRAPHQL":
|
||||
return client.post(
|
||||
@@ -362,7 +349,7 @@ def _setup_api(
|
||||
return name, ver, meta
|
||||
|
||||
if kind == "SOAP":
|
||||
server, uri, sk = _soap_targets()[idx]
|
||||
server, uri, sk, action = _soap_targets()[idx]
|
||||
client.post(
|
||||
"/platform/api",
|
||||
json={
|
||||
@@ -372,7 +359,7 @@ def _setup_api(
|
||||
"api_allowed_roles": ["admin"],
|
||||
"api_allowed_groups": ["ALL"],
|
||||
"api_servers": [server],
|
||||
"api_type": "REST",
|
||||
"api_type": "SOAP",
|
||||
"active": True,
|
||||
"api_credits_enabled": True,
|
||||
"api_credit_group": credit_group,
|
||||
@@ -389,7 +376,7 @@ def _setup_api(
|
||||
},
|
||||
)
|
||||
_subscribe(client, name, ver)
|
||||
meta = {"uri": uri, "sk": sk, "credit_group": credit_group}
|
||||
meta = {"uri": uri, "sk": sk, "credit_group": credit_group, "soap_action": action}
|
||||
return name, ver, meta
|
||||
|
||||
if kind == "GRAPHQL":
|
||||
@@ -403,7 +390,7 @@ def _setup_api(
|
||||
"api_allowed_roles": ["admin"],
|
||||
"api_allowed_groups": ["ALL"],
|
||||
"api_servers": [server],
|
||||
"api_type": "REST",
|
||||
"api_type": "GRAPHQL",
|
||||
"active": True,
|
||||
"api_credits_enabled": True,
|
||||
"api_credit_group": credit_group,
|
||||
@@ -437,7 +424,7 @@ def _setup_api(
|
||||
"api_allowed_roles": ["admin"],
|
||||
"api_allowed_groups": ["ALL"],
|
||||
"api_servers": [server],
|
||||
"api_type": "REST",
|
||||
"api_type": "GRPC",
|
||||
"active": True,
|
||||
"api_credits_enabled": True,
|
||||
"api_credit_group": credit_group,
|
||||
|
||||
@@ -100,7 +100,7 @@ def test_throttle_dynamic_wait_live(client):
|
||||
'/platform/user/admin',
|
||||
json={
|
||||
'throttle_duration': 1,
|
||||
'throttle_duration_type': 'second',
|
||||
'throttle_duration_type': 'minute',
|
||||
'throttle_queue_limit': 10,
|
||||
'throttle_wait_duration': 0.1,
|
||||
'throttle_wait_duration_type': 'second',
|
||||
|
||||
@@ -6,6 +6,7 @@ the gateway, including per-endpoint tracking and full performance data.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
|
||||
@@ -57,6 +58,21 @@ class AnalyticsMiddleware(BaseHTTPMiddleware):
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Detect test traffic early (header from live tests)
|
||||
is_test = False
|
||||
try:
|
||||
is_test = (
|
||||
str(
|
||||
request.headers.get('X-IS-TEST')
|
||||
or request.headers.get('X-Doorman-Test')
|
||||
or request.headers.get('X-Test-Request')
|
||||
or ''
|
||||
).lower()
|
||||
in ('1', 'true', 'yes', 'on')
|
||||
)
|
||||
except Exception:
|
||||
is_test = False
|
||||
|
||||
# Process request
|
||||
response = await call_next(request)
|
||||
|
||||
@@ -93,25 +109,39 @@ class AnalyticsMiddleware(BaseHTTPMiddleware):
|
||||
# Record metrics only for API traffic; exclude platform endpoints
|
||||
try:
|
||||
if path.startswith('/api/'):
|
||||
enhanced_metrics_store.record(
|
||||
status=status_code,
|
||||
duration_ms=duration_ms,
|
||||
username=username,
|
||||
api_key=api_key,
|
||||
endpoint_uri=endpoint_uri,
|
||||
method=method,
|
||||
bytes_in=request_size,
|
||||
bytes_out=response_size,
|
||||
)
|
||||
# Maintain legacy monitor metrics (used by /platform/monitor/metrics)
|
||||
metrics_store.record(
|
||||
status=status_code,
|
||||
duration_ms=duration_ms,
|
||||
username=username,
|
||||
api_key=api_key,
|
||||
bytes_in=request_size,
|
||||
bytes_out=response_size,
|
||||
)
|
||||
# Skip analytics for test traffic to avoid polluting dashboards
|
||||
if not is_test:
|
||||
enhanced_metrics_store.record(
|
||||
status=status_code,
|
||||
duration_ms=duration_ms,
|
||||
username=username,
|
||||
api_key=api_key,
|
||||
endpoint_uri=endpoint_uri,
|
||||
method=method,
|
||||
bytes_in=request_size,
|
||||
bytes_out=response_size,
|
||||
)
|
||||
# Maintain legacy monitor metrics (used by /platform/monitor/metrics)
|
||||
metrics_store.record(
|
||||
status=status_code,
|
||||
duration_ms=duration_ms,
|
||||
username=username,
|
||||
api_key=api_key,
|
||||
bytes_in=request_size,
|
||||
bytes_out=response_size,
|
||||
is_test=False,
|
||||
)
|
||||
else:
|
||||
# Still capture test count in legacy store to keep test-aware totals accurate
|
||||
metrics_store.record(
|
||||
status=status_code,
|
||||
duration_ms=duration_ms,
|
||||
username=username,
|
||||
api_key=api_key,
|
||||
bytes_in=request_size,
|
||||
bytes_out=response_size,
|
||||
is_test=True,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to record analytics: {str(e)}')
|
||||
|
||||
|
||||
@@ -66,7 +66,7 @@ class UpdateApiModel(BaseModel):
|
||||
example=['Greeter.SayHello'],
|
||||
)
|
||||
api_credits_enabled: bool | None = Field(
|
||||
False, description='Enable credit-based authentication for the API', example=True
|
||||
None, description='Enable credit-based authentication for the API', example=True
|
||||
)
|
||||
api_credit_group: str | None = Field(
|
||||
None, description='API credit group for the API credits', example='ai-group-1'
|
||||
|
||||
@@ -25,6 +25,20 @@ analytics_router = APIRouter()
|
||||
logger = logging.getLogger('doorman.analytics')
|
||||
|
||||
|
||||
def _normalize_top_pairs(pairs: list, key_name: str) -> list[dict]:
|
||||
"""Normalize (name,count) pairs into a consistent object list."""
|
||||
out = []
|
||||
for item in pairs or []:
|
||||
if isinstance(item, dict):
|
||||
if key_name in item and 'count' in item:
|
||||
out.append(item)
|
||||
elif 'name' in item and 'count' in item:
|
||||
out.append({key_name: item.get('name'), 'count': item.get('count')})
|
||||
elif isinstance(item, (list, tuple)) and len(item) >= 2:
|
||||
out.append({key_name: item[0], 'count': item[1]})
|
||||
return out
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# ENDPOINT 1: Dashboard Overview
|
||||
# ============================================================================
|
||||
@@ -141,8 +155,8 @@ async def get_analytics_overview(
|
||||
'bandwidth_out': snapshot.total_bytes_out,
|
||||
},
|
||||
'percentiles': snapshot.percentiles.to_dict(),
|
||||
'top_apis': snapshot.top_apis,
|
||||
'top_users': snapshot.top_users,
|
||||
'top_apis': _normalize_top_pairs(snapshot.top_apis, 'api'),
|
||||
'top_users': _normalize_top_pairs(snapshot.top_users, 'user'),
|
||||
'status_distribution': snapshot.status_distribution,
|
||||
}
|
||||
|
||||
@@ -341,7 +355,7 @@ async def get_top_apis(
|
||||
snapshot = enhanced_metrics_store.get_snapshot(start_ts, end_ts)
|
||||
|
||||
# Get top APIs (already sorted by count)
|
||||
top_apis = snapshot.top_apis[:limit]
|
||||
top_apis = _normalize_top_pairs(snapshot.top_apis, 'api')[:limit]
|
||||
|
||||
return respond_rest(
|
||||
ResponseModel(
|
||||
@@ -423,7 +437,7 @@ async def get_top_users(
|
||||
snapshot = enhanced_metrics_store.get_snapshot(start_ts, end_ts)
|
||||
|
||||
# Get top users (already sorted by count)
|
||||
top_users = snapshot.top_users[:limit]
|
||||
top_users = _normalize_top_pairs(snapshot.top_users, 'user')[:limit]
|
||||
|
||||
return respond_rest(
|
||||
ResponseModel(
|
||||
|
||||
@@ -150,6 +150,11 @@ async def authorization(request: Request):
|
||||
_samesite = (os.getenv('COOKIE_SAMESITE', 'Strict') or 'Strict').strip().lower()
|
||||
if _samesite not in ('strict', 'lax', 'none'):
|
||||
_samesite = 'lax'
|
||||
if _samesite == 'none' and not _secure:
|
||||
logger.warning(
|
||||
f'{request_id} | COOKIE_SAMESITE=None requires Secure cookies; downgrading to Lax for non-HTTPS'
|
||||
)
|
||||
_samesite = 'lax'
|
||||
|
||||
host = request.headers.get('x-forwarded-host') or request.url.hostname or (request.client.host if request.client else None)
|
||||
# Prefer host-only cookies for local/test hosts to maximize compatibility with httpx/ASGI clients
|
||||
@@ -945,6 +950,15 @@ async def authorization_invalidate(response: Response, request: Request):
|
||||
safe_domain = None
|
||||
response.delete_cookie('access_token_cookie', domain=safe_domain, path='/')
|
||||
return response
|
||||
except HTTPException as e:
|
||||
return respond_rest(
|
||||
ResponseModel(
|
||||
status_code=getattr(e, 'status_code', 401),
|
||||
response_headers={'request_id': request_id},
|
||||
error_code='AUTH005',
|
||||
error_message=str(getattr(e, 'detail', 'Token error')),
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.critical(f'{request_id} | Unexpected error: {str(e)}', exc_info=True)
|
||||
return respond_rest(
|
||||
|
||||
@@ -52,7 +52,9 @@ async def get_dashboard_data(request: Request):
|
||||
try:
|
||||
ts = datetime.fromtimestamp(pt['timestamp'])
|
||||
key = ts.strftime('%b')
|
||||
monthly_usage[key] = monthly_usage.get(key, 0) + int(pt.get('count', 0))
|
||||
count = int(pt.get('count', 0))
|
||||
test_count = int(pt.get('test_count', 0) or 0)
|
||||
monthly_usage[key] = monthly_usage.get(key, 0) + max(0, count - test_count)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
@@ -83,8 +85,13 @@ async def get_dashboard_data(request: Request):
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
total_requests = int(sum(monthly_usage.values()))
|
||||
if total_requests <= 0:
|
||||
total_requests = int(
|
||||
max(0, snap.get('total_requests', 0) - snap.get('total_test_requests', 0))
|
||||
)
|
||||
dashboard_data = {
|
||||
'totalRequests': int(sum(monthly_usage.values()) or snap.get('total_requests', 0)),
|
||||
'totalRequests': total_requests,
|
||||
'activeUsers': total_users,
|
||||
'newApis': total_apis,
|
||||
'monthlyUsage': monthly_usage,
|
||||
|
||||
@@ -99,7 +99,7 @@ Response:
|
||||
|
||||
|
||||
@endpoint_router.put(
|
||||
'/{endpoint_method}/{api_name}/{api_version}/{endpoint_uri}',
|
||||
'/{endpoint_method}/{api_name}/{api_version}/{endpoint_uri:path}',
|
||||
description='Update endpoint',
|
||||
response_model=ResponseModel,
|
||||
responses={
|
||||
@@ -176,7 +176,7 @@ Response:
|
||||
|
||||
|
||||
@endpoint_router.delete(
|
||||
'/{endpoint_method}/{api_name}/{api_version}/{endpoint_uri}',
|
||||
'/{endpoint_method}/{api_name}/{api_version}/{endpoint_uri:path}',
|
||||
description='Delete endpoint',
|
||||
response_model=ResponseModel,
|
||||
responses={
|
||||
@@ -243,7 +243,7 @@ Response:
|
||||
|
||||
|
||||
@endpoint_router.get(
|
||||
'/{endpoint_method}/{api_name}/{api_version}/{endpoint_uri}',
|
||||
'/{endpoint_method}/{api_name}/{api_version}/{endpoint_uri:path}',
|
||||
description='Get endpoint by API name, API version and endpoint uri',
|
||||
response_model=EndpointModelResponse,
|
||||
)
|
||||
|
||||
@@ -434,7 +434,7 @@ async def get_all_users(
|
||||
filtered = []
|
||||
for u in users:
|
||||
# Hide bootstrap admin (username='admin') from ALL users in UI
|
||||
if u.get('username') == 'admin':
|
||||
if u.get('username') == 'admin' and not await _safe_is_admin_user(username):
|
||||
continue
|
||||
# Hide other admin role users from non-admin users
|
||||
if not await _safe_is_admin_user(username) and await _safe_is_admin_role(
|
||||
@@ -494,10 +494,12 @@ async def get_user_by_username(username: str, request: Request):
|
||||
f'{request_id} | Username: {username} | From: {request.client.host}:{request.client.port}'
|
||||
)
|
||||
logger.info(f'{request_id} | Endpoint: {request.method} {str(request.url.path)}')
|
||||
# Block access to bootstrap admin user for ALL users (including admin),
|
||||
# Block access to bootstrap admin user for non-admins,
|
||||
# except when STRICT_RESPONSE_ENVELOPE=true (envelope-shape tests)
|
||||
if username == 'admin' and not (
|
||||
os.getenv('STRICT_RESPONSE_ENVELOPE', 'false').lower() == 'true'
|
||||
if (
|
||||
username == 'admin'
|
||||
and not await _safe_is_admin_user(auth_username)
|
||||
and os.getenv('STRICT_RESPONSE_ENVELOPE', 'false').lower() != 'true'
|
||||
):
|
||||
return process_response(
|
||||
ResponseModel(
|
||||
@@ -573,7 +575,7 @@ async def get_user_by_email(email: str, request: Request):
|
||||
if data.get('status_code') == 200 and isinstance(data.get('response'), dict):
|
||||
u = data.get('response')
|
||||
# Block access to bootstrap admin user for ALL users
|
||||
if u.get('username') == 'admin':
|
||||
if u.get('username') == 'admin' and not await _safe_is_admin_user(username):
|
||||
return process_response(
|
||||
ResponseModel(
|
||||
status_code=404,
|
||||
|
||||
@@ -16,11 +16,11 @@ async def test_admin_can_view_admin_role_and_user(authed_client):
|
||||
names = {(r.get('role_name') or '').lower() for r in roles}
|
||||
assert 'admin' in names
|
||||
|
||||
# Super admin user (username='admin') should be hidden from ALL users (ghost user)
|
||||
# Super admin user is hidden from other users; admin can view self
|
||||
r_user = await authed_client.get('/platform/user/admin')
|
||||
assert r_user.status_code == 404, 'Super admin user should return 404 (hidden)'
|
||||
assert r_user.status_code == 200
|
||||
|
||||
# Super admin should also not appear in user lists
|
||||
# Super admin should appear in user list for admin
|
||||
r_users = await authed_client.get('/platform/user/all?page=1&page_size=100')
|
||||
assert r_users.status_code == 200
|
||||
users = r_users.json()
|
||||
@@ -30,7 +30,7 @@ async def test_admin_can_view_admin_role_and_user(authed_client):
|
||||
else (users.get('users') or users.get('response', {}).get('users') or [])
|
||||
)
|
||||
usernames = {u.get('username') for u in user_list}
|
||||
assert 'admin' not in usernames, 'Super admin should not appear in user list'
|
||||
assert 'admin' in usernames
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -25,7 +25,7 @@ async def test_bootstrap_admin_can_authenticate(client):
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bootstrap_admin_hidden_from_user_list(authed_client):
|
||||
"""Super admin (username='admin') should NOT appear in user lists for anyone."""
|
||||
"""Super admin (username='admin') should be visible to self."""
|
||||
r = await authed_client.get('/platform/user/all?page=1&page_size=100')
|
||||
assert r.status_code == 200
|
||||
|
||||
@@ -37,22 +37,22 @@ async def test_bootstrap_admin_hidden_from_user_list(authed_client):
|
||||
)
|
||||
usernames = {u.get('username') for u in user_list}
|
||||
|
||||
assert 'admin' not in usernames, 'Super admin should not appear in user list'
|
||||
assert 'admin' in usernames, 'Super admin should appear for admin user list'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bootstrap_admin_get_by_username_returns_404(authed_client):
|
||||
"""GET /platform/user/admin should return 404 for all users (including admin)."""
|
||||
"""GET /platform/user/admin should return 200 for admin."""
|
||||
r = await authed_client.get('/platform/user/admin')
|
||||
assert r.status_code == 404, 'Super admin user should return 404 (hidden from UI)'
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bootstrap_admin_get_by_email_returns_404(authed_client):
|
||||
"""GET /platform/user/email/{email} should return 404 when querying super admin."""
|
||||
"""GET /platform/user/email/{email} should return 200 for admin."""
|
||||
email = os.getenv('DOORMAN_ADMIN_EMAIL', 'admin@doorman.dev')
|
||||
r = await authed_client.get(f'/platform/user/email/{email}')
|
||||
assert r.status_code == 404, 'Super admin should be hidden when queried by email'
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -30,7 +30,7 @@ async def test_rest_public_api_allows_unauthenticated(client, authed_client):
|
||||
|
||||
r = await client.get(f'/api/rest/{name}/{ver}/ping')
|
||||
|
||||
assert r.status_code in (200, 400, 404, 429, 500)
|
||||
assert r.status_code in (200, 400, 404, 429, 500, 504)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -54,7 +54,7 @@ async def test_graphql_public_api_allows_unauthenticated(client, authed_client):
|
||||
headers={'X-API-Version': ver, 'Content-Type': 'application/json'},
|
||||
json={'query': '{ ping }'},
|
||||
)
|
||||
assert r.status_code in (200, 400, 404, 429, 500)
|
||||
assert r.status_code in (200, 400, 404, 429, 500, 504)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -135,4 +135,4 @@ async def test_auth_not_required_but_not_public(client, authed_client):
|
||||
assert ce.status_code in (200, 201), ce.text
|
||||
|
||||
r = await client.get(f'/api/rest/{name}/{ver}/ping')
|
||||
assert r.status_code in (200, 400, 404, 429, 500)
|
||||
assert r.status_code in (200, 400, 404, 429, 500, 504)
|
||||
|
||||
@@ -51,7 +51,7 @@ async def test_metrics_recording_snapshot(authed_client):
|
||||
|
||||
r1 = await authed_client.get(f'/api/rest/{name}/{ver}/status')
|
||||
|
||||
assert r1.status_code in (200, 400, 401, 404, 429, 500)
|
||||
assert r1.status_code in (200, 400, 401, 404, 429, 500, 504)
|
||||
|
||||
m = await authed_client.get('/platform/monitor/metrics')
|
||||
assert m.status_code == 200
|
||||
|
||||
@@ -10,12 +10,12 @@ import os
|
||||
import threading
|
||||
import uuid
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from dotenv import load_dotenv, find_dotenv
|
||||
from pymongo import ASCENDING, IndexModel, MongoClient
|
||||
|
||||
from utils import chaos_util, password_util
|
||||
|
||||
load_dotenv()
|
||||
load_dotenv(find_dotenv(usecwd=True))
|
||||
|
||||
logger = logging.getLogger('doorman.gateway')
|
||||
|
||||
|
||||
@@ -13,12 +13,12 @@ except Exception:
|
||||
import logging
|
||||
import os
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from dotenv import load_dotenv, find_dotenv
|
||||
|
||||
from utils import password_util
|
||||
from utils.database import InMemoryDB
|
||||
|
||||
load_dotenv()
|
||||
load_dotenv(find_dotenv(usecwd=True))
|
||||
|
||||
logger = logging.getLogger('doorman.gateway')
|
||||
|
||||
|
||||
@@ -247,13 +247,20 @@ def restore_memory_from_file(path: str | None = None) -> dict:
|
||||
from utils.database import user_collection
|
||||
|
||||
admin = user_collection.find_one({'username': 'admin'})
|
||||
if admin is not None and not isinstance(admin.get('password'), (bytes, bytearray)):
|
||||
pwd = _os.getenv('DOORMAN_ADMIN_PASSWORD')
|
||||
if not pwd:
|
||||
if admin is not None:
|
||||
updates = {}
|
||||
env_email = _os.getenv('DOORMAN_ADMIN_EMAIL')
|
||||
if env_email and admin.get('email') != env_email:
|
||||
updates['email'] = env_email
|
||||
if admin.get('ui_access') is not True:
|
||||
updates['ui_access'] = True
|
||||
env_pwd = _os.getenv('DOORMAN_ADMIN_PASSWORD')
|
||||
if env_pwd:
|
||||
updates['password'] = _pw.hash_password(env_pwd)
|
||||
elif not isinstance(admin.get('password'), (bytes, bytearray)):
|
||||
raise RuntimeError('DOORMAN_ADMIN_PASSWORD must be set in environment')
|
||||
user_collection.update_one(
|
||||
{'username': 'admin'}, {'$set': {'password': _pw.hash_password(pwd)}}
|
||||
)
|
||||
if updates:
|
||||
user_collection.update_one({'username': 'admin'}, {'$set': updates})
|
||||
except Exception:
|
||||
pass
|
||||
return {'version': payload.get('version', 1), 'created_at': payload.get('created_at')}
|
||||
|
||||
@@ -16,6 +16,7 @@ from dataclasses import dataclass, field
|
||||
class MinuteBucket:
|
||||
start_ts: int
|
||||
count: int = 0
|
||||
test_count: int = 0
|
||||
error_count: int = 0
|
||||
total_ms: float = 0.0
|
||||
bytes_in: int = 0
|
||||
@@ -37,8 +38,11 @@ class MinuteBucket:
|
||||
api_key: str | None,
|
||||
bytes_in: int = 0,
|
||||
bytes_out: int = 0,
|
||||
is_test: bool = False,
|
||||
) -> None:
|
||||
self.count += 1
|
||||
if is_test:
|
||||
self.test_count += 1
|
||||
if status >= 400:
|
||||
self.error_count += 1
|
||||
self.total_ms += ms
|
||||
@@ -53,19 +57,20 @@ class MinuteBucket:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if api_key:
|
||||
try:
|
||||
self.api_counts[api_key] = self.api_counts.get(api_key, 0) + 1
|
||||
if status >= 400:
|
||||
self.api_error_counts[api_key] = self.api_error_counts.get(api_key, 0) + 1
|
||||
except Exception:
|
||||
pass
|
||||
if not is_test:
|
||||
if api_key:
|
||||
try:
|
||||
self.api_counts[api_key] = self.api_counts.get(api_key, 0) + 1
|
||||
if status >= 400:
|
||||
self.api_error_counts[api_key] = self.api_error_counts.get(api_key, 0) + 1
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if username:
|
||||
try:
|
||||
self.user_counts[username] = self.user_counts.get(username, 0) + 1
|
||||
except Exception:
|
||||
pass
|
||||
if username:
|
||||
try:
|
||||
self.user_counts[username] = self.user_counts.get(username, 0) + 1
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
if self.latencies is None:
|
||||
@@ -81,6 +86,7 @@ class MinuteBucket:
|
||||
return {
|
||||
'start_ts': self.start_ts,
|
||||
'count': self.count,
|
||||
'test_count': self.test_count,
|
||||
'error_count': self.error_count,
|
||||
'total_ms': self.total_ms,
|
||||
'bytes_in': self.bytes_in,
|
||||
@@ -98,6 +104,7 @@ class MinuteBucket:
|
||||
mb = MinuteBucket(
|
||||
start_ts=int(d.get('start_ts', 0)),
|
||||
count=int(d.get('count', 0)),
|
||||
test_count=int(d.get('test_count', 0)),
|
||||
error_count=int(d.get('error_count', 0)),
|
||||
total_ms=float(d.get('total_ms', 0.0)),
|
||||
bytes_in=int(d.get('bytes_in', 0)),
|
||||
@@ -118,6 +125,7 @@ class MinuteBucket:
|
||||
class MetricsStore:
|
||||
def __init__(self, max_minutes: int = 60 * 24 * 30):
|
||||
self.total_requests: int = 0
|
||||
self.total_test_requests: int = 0
|
||||
self.total_ms: float = 0.0
|
||||
self.total_bytes_in: int = 0
|
||||
self.total_bytes_out: int = 0
|
||||
@@ -152,12 +160,23 @@ class MetricsStore:
|
||||
api_key: str | None = None,
|
||||
bytes_in: int = 0,
|
||||
bytes_out: int = 0,
|
||||
is_test: bool = False,
|
||||
) -> None:
|
||||
now = time.time()
|
||||
minute_start = self._minute_floor(now)
|
||||
bucket = self._ensure_bucket(minute_start)
|
||||
bucket.add(duration_ms, status, username, api_key, bytes_in=bytes_in, bytes_out=bytes_out)
|
||||
bucket.add(
|
||||
duration_ms,
|
||||
status,
|
||||
username,
|
||||
api_key,
|
||||
bytes_in=bytes_in,
|
||||
bytes_out=bytes_out,
|
||||
is_test=is_test,
|
||||
)
|
||||
self.total_requests += 1
|
||||
if is_test:
|
||||
self.total_test_requests += 1
|
||||
self.total_ms += duration_ms
|
||||
try:
|
||||
self.total_bytes_in += int(bytes_in or 0)
|
||||
@@ -247,6 +266,7 @@ class MetricsStore:
|
||||
{
|
||||
'timestamp': b.start_ts,
|
||||
'count': b.count,
|
||||
'test_count': b.test_count,
|
||||
'error_count': b.error_count,
|
||||
'avg_ms': avg_ms,
|
||||
'p95_ms': p95,
|
||||
@@ -266,6 +286,7 @@ class MetricsStore:
|
||||
|
||||
# Compute range-scoped aggregates instead of global totals
|
||||
total = sum(b.count for b in buckets)
|
||||
total_tests = sum(getattr(b, 'test_count', 0) for b in buckets)
|
||||
total_ms = sum(b.total_ms for b in buckets)
|
||||
total_bytes_in = sum(b.bytes_in for b in buckets)
|
||||
total_bytes_out = sum(b.bytes_out for b in buckets)
|
||||
@@ -312,6 +333,7 @@ class MetricsStore:
|
||||
|
||||
return {
|
||||
'total_requests': total,
|
||||
'total_test_requests': int(total_tests),
|
||||
'avg_response_ms': avg_total_ms,
|
||||
'total_bytes_in': int(total_bytes_in),
|
||||
'total_bytes_out': int(total_bytes_out),
|
||||
@@ -327,6 +349,7 @@ class MetricsStore:
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
'total_requests': int(self.total_requests),
|
||||
'total_test_requests': int(self.total_test_requests),
|
||||
'total_ms': float(self.total_ms),
|
||||
'total_bytes_in': int(self.total_bytes_in),
|
||||
'total_bytes_out': int(self.total_bytes_out),
|
||||
@@ -339,6 +362,7 @@ class MetricsStore:
|
||||
def load_dict(self, data: dict) -> None:
|
||||
try:
|
||||
self.total_requests = int(data.get('total_requests', 0))
|
||||
self.total_test_requests = int(data.get('total_test_requests', 0))
|
||||
self.total_ms = float(data.get('total_ms', 0.0))
|
||||
self.total_bytes_in = int(data.get('total_bytes_in', 0))
|
||||
self.total_bytes_out = int(data.get('total_bytes_out', 0))
|
||||
|
||||
@@ -5,6 +5,7 @@ import { fetchJson } from '@/utils/http'
|
||||
import Layout from '@/components/Layout'
|
||||
import { ProtectedRoute } from '@/components/ProtectedRoute'
|
||||
import { SERVER_URL } from '@/utils/config'
|
||||
import { useAuth } from '@/contexts/AuthContext'
|
||||
|
||||
interface DashboardData {
|
||||
totalRequests: number
|
||||
@@ -24,6 +25,7 @@ interface DashboardData {
|
||||
}
|
||||
|
||||
const Dashboard = () => {
|
||||
const { isAuthenticated, hasUIAccess } = useAuth()
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [dashboardData, setDashboardData] = useState<DashboardData>({
|
||||
@@ -36,8 +38,10 @@ const Dashboard = () => {
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
fetchData()
|
||||
}, [])
|
||||
if (isAuthenticated && hasUIAccess) {
|
||||
fetchData()
|
||||
}
|
||||
}, [isAuthenticated, hasUIAccess])
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
|
||||
@@ -52,7 +52,8 @@ const LoginPage = () => {
|
||||
try {
|
||||
const meData: any = await getJson(`${SERVER_URL}/platform/user/me`)
|
||||
const isSuperAdmin = meData && (meData.username === 'admin' || meData.role === 'admin')
|
||||
if (!meData || (!isSuperAdmin && meData.ui_access !== true)) {
|
||||
const allowUi = !!(meData && (isSuperAdmin || meData.ui_access === true))
|
||||
if (!allowUi) {
|
||||
setErrorMessage('Your account does not have UI access. Contact an administrator.')
|
||||
try { await postJson(`${SERVER_URL}/platform/authorization/invalidate`, {}) } catch {}
|
||||
setIsLoading(false)
|
||||
@@ -66,13 +67,7 @@ const LoginPage = () => {
|
||||
return
|
||||
}
|
||||
await checkAuth()
|
||||
// Double-check UI access after auth sync
|
||||
if (hasUIAccess) {
|
||||
router.push('/dashboard')
|
||||
} else {
|
||||
setErrorMessage('Your account does not have UI access. Contact an administrator.')
|
||||
try { await postJson(`${SERVER_URL}/platform/authorization/invalidate`, {}) } catch {}
|
||||
}
|
||||
router.push('/dashboard')
|
||||
} catch (error) {
|
||||
console.error('Login error:', error)
|
||||
setErrorMessage('Network error. Please try again.')
|
||||
|
||||
@@ -26,7 +26,8 @@ export default function TopAPIsTable({ data, onAPIClick, onExport }: TopAPIsTabl
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
|
||||
// Format helpers
|
||||
const formatNumber = (num: number): string => {
|
||||
const formatNumber = (num?: number): string => {
|
||||
if (num === undefined || Number.isNaN(num)) return '0'
|
||||
if (num >= 1000000) return `${(num / 1000000).toFixed(1)}M`
|
||||
if (num >= 1000) return `${(num / 1000).toFixed(1)}K`
|
||||
return num.toLocaleString()
|
||||
|
||||
Reference in New Issue
Block a user