Updates for testing and minor bug fixes

This commit is contained in:
seniorswe
2025-12-26 18:00:01 -05:00
parent bab1f03172
commit 5ddc58c6c8
41 changed files with 839 additions and 327 deletions
+2 -2
View File
@@ -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'
+274
View File
@@ -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
+8 -5
View File
@@ -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
+5 -6
View File
@@ -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)}')
+1 -1
View File
@@ -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'
+18 -4
View File
@@ -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(
+9 -2
View File
@@ -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,
+3 -3
View File
@@ -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,
)
+7 -5
View File
@@ -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
+6 -6
View File
@@ -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
+3 -3
View File
@@ -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
+2 -2
View File
@@ -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')
+2 -2
View File
@@ -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')
+13 -6
View File
@@ -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')}
+37 -13
View File
@@ -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))
+6 -2
View File
@@ -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 {
+3 -8
View File
@@ -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()