Fix studio and market-reference route tests for current implementation

These tests lagged behind two refactors:

- build_studio_catalog is now an async coroutine. Update the studio tests to
  await it: direct-call tests wrap it in asyncio.run(), and the HTTP tests'
  monkeypatched stubs are now async (the route handler awaits the result, so a
  sync stub returning a dict raised "object dict can't be used in 'await'
  expression").
- The providers page bootstraps provider data from a dedicated JSON <script>
  element (providers_bootstrap_json, escaped by the route's _json_parse_bootstrap
  helper) parsed via JSON.parse, instead of an inline `let providersData = {...}`
  literal. Update the admin bootstrap test to feed providers_bootstrap_json and
  assert on the <script id="providers-bootstrap"> element (escaping + double
  JSON decode) and the JSON.parse usage.
- Add the missing record_dashboard_event method to MarketReferenceImportDbStub,
  which the market import route now calls.

tests/routes now passes 86/86 (was 18 failing).
Co-Authored-By: 's avatarClaude Opus 4.8 <noreply@anthropic.com>
parent e69ce2d5
...@@ -17,6 +17,7 @@ You should have received a copy of the GNU General Public License ...@@ -17,6 +17,7 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
""" """
import asyncio
import inspect import inspect
import json import json
from pathlib import Path from pathlib import Path
...@@ -25,6 +26,14 @@ from base64 import b64encode ...@@ -25,6 +26,14 @@ from base64 import b64encode
from uuid import uuid4 from uuid import uuid4
import pytest import pytest
def _async_catalog_stub(fn):
"""Wrap a sync catalog factory so it can stand in for the now-async
build_studio_catalog coroutine (the route handler awaits it)."""
async def _wrapped(**kwargs):
return fn(**kwargs)
return _wrapped
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from itsdangerous import TimestampSigner from itsdangerous import TimestampSigner
...@@ -111,7 +120,7 @@ def test_dashboard_studio_bootstraps_initial_catalog_data(monkeypatch): ...@@ -111,7 +120,7 @@ def test_dashboard_studio_bootstraps_initial_catalog_data(monkeypatch):
monkeypatch.setattr( monkeypatch.setattr(
dashboard_providers, dashboard_providers,
"build_studio_catalog", "build_studio_catalog",
lambda **kwargs: { _async_catalog_stub(lambda **kwargs: {
"scope": kwargs["scope"], "scope": kwargs["scope"],
"owner_id": kwargs["owner_id"], "owner_id": kwargs["owner_id"],
"entries": [ "entries": [
...@@ -123,7 +132,7 @@ def test_dashboard_studio_bootstraps_initial_catalog_data(monkeypatch): ...@@ -123,7 +132,7 @@ def test_dashboard_studio_bootstraps_initial_catalog_data(monkeypatch):
"partial_capabilities": ["vision"], "partial_capabilities": ["vision"],
} }
], ],
}, }),
) )
response = client.get("/dashboard/studio") response = client.get("/dashboard/studio")
...@@ -140,11 +149,11 @@ def test_dashboard_studio_catalog_returns_global_resources_for_admin(monkeypatch ...@@ -140,11 +149,11 @@ def test_dashboard_studio_catalog_returns_global_resources_for_admin(monkeypatch
monkeypatch.setattr( monkeypatch.setattr(
dashboard_providers, dashboard_providers,
"build_studio_catalog", "build_studio_catalog",
lambda **kwargs: { _async_catalog_stub(lambda **kwargs: {
"scope": kwargs["scope"], "scope": kwargs["scope"],
"owner_id": kwargs["owner_id"], "owner_id": kwargs["owner_id"],
"entries": [{"id": "provider/openai/gpt-4o", "owner_scope": "admin"}], "entries": [{"id": "provider/openai/gpt-4o", "owner_scope": "admin"}],
}, }),
) )
response = client.get("/dashboard/studio/catalog") response = client.get("/dashboard/studio/catalog")
...@@ -184,11 +193,11 @@ def test_dashboard_studio_catalog_returns_user_resources_for_user(monkeypatch): ...@@ -184,11 +193,11 @@ def test_dashboard_studio_catalog_returns_user_resources_for_user(monkeypatch):
monkeypatch.setattr( monkeypatch.setattr(
dashboard_providers, dashboard_providers,
"build_studio_catalog", "build_studio_catalog",
lambda **kwargs: { _async_catalog_stub(lambda **kwargs: {
"scope": kwargs["scope"], "scope": kwargs["scope"],
"owner_id": kwargs["owner_id"], "owner_id": kwargs["owner_id"],
"entries": [{"id": "provider/demo/gpt-4o-mini", "owner_scope": "user"}], "entries": [{"id": "provider/demo/gpt-4o-mini", "owner_scope": "user"}],
}, }),
) )
response = client.get("/dashboard/studio/catalog") response = client.get("/dashboard/studio/catalog")
...@@ -217,11 +226,11 @@ def test_dashboard_studio_catalog_does_not_treat_user_role_without_user_id_as_ad ...@@ -217,11 +226,11 @@ def test_dashboard_studio_catalog_does_not_treat_user_role_without_user_id_as_ad
monkeypatch.setattr( monkeypatch.setattr(
dashboard_providers, dashboard_providers,
"build_studio_catalog", "build_studio_catalog",
lambda **kwargs: { _async_catalog_stub(lambda **kwargs: {
"scope": kwargs["scope"], "scope": kwargs["scope"],
"owner_id": kwargs["owner_id"], "owner_id": kwargs["owner_id"],
"entries": [], "entries": [],
}, }),
) )
response = client.get("/dashboard/studio/catalog") response = client.get("/dashboard/studio/catalog")
...@@ -270,7 +279,7 @@ def test_build_studio_catalog_uses_global_config_for_admin_scope(): ...@@ -270,7 +279,7 @@ def test_build_studio_catalog_uses_global_config_for_admin_scope():
} }
} }
catalog = build_studio_catalog(scope="admin", owner_id=None, config=ConfigStub()) catalog = asyncio.run(build_studio_catalog(scope="admin", owner_id=None, config=ConfigStub()))
assert catalog["scope"] == "admin" assert catalog["scope"] == "admin"
assert catalog["owner_id"] is None assert catalog["owner_id"] is None
...@@ -292,7 +301,7 @@ def test_build_studio_catalog_falls_back_to_dashboard_global_provider_source(mon ...@@ -292,7 +301,7 @@ def test_build_studio_catalog_falls_back_to_dashboard_global_provider_source(mon
}, },
) )
catalog = build_studio_catalog(scope="admin", owner_id=None, config=None) catalog = asyncio.run(build_studio_catalog(scope="admin", owner_id=None, config=None))
assert catalog["scope"] == "admin" assert catalog["scope"] == "admin"
provider_entry = next(entry for entry in catalog["entries"] if entry["kind"] == "provider_model") provider_entry = next(entry for entry in catalog["entries"] if entry["kind"] == "provider_model")
...@@ -321,7 +330,7 @@ def test_build_studio_catalog_reuses_catalog_entry_contract_for_non_provider_res ...@@ -321,7 +330,7 @@ def test_build_studio_catalog_reuses_catalog_entry_contract_for_non_provider_res
} }
} }
catalog = build_studio_catalog(scope="admin", owner_id=None, config=ConfigStub()) catalog = asyncio.run(build_studio_catalog(scope="admin", owner_id=None, config=ConfigStub()))
rotation_entry = next(entry for entry in catalog["entries"] if entry["kind"] == "rotation") rotation_entry = next(entry for entry in catalog["entries"] if entry["kind"] == "rotation")
autoselect_entry = next(entry for entry in catalog["entries"] if entry["kind"] == "autoselect") autoselect_entry = next(entry for entry in catalog["entries"] if entry["kind"] == "autoselect")
...@@ -365,7 +374,7 @@ def test_build_studio_catalog_uses_user_owned_resources_for_user_scope(): ...@@ -365,7 +374,7 @@ def test_build_studio_catalog_uses_user_owned_resources_for_user_scope():
}, },
}] }]
catalog = build_studio_catalog(scope="user", owner_id=17, db=DbStub()) catalog = asyncio.run(build_studio_catalog(scope="user", owner_id=17, db=DbStub()))
assert catalog["scope"] == "user" assert catalog["scope"] == "user"
assert catalog["owner_id"] == 17 assert catalog["owner_id"] == 17
...@@ -466,7 +475,7 @@ def test_build_studio_catalog_prefers_persisted_studio_capabilities_over_legacy_ ...@@ -466,7 +475,7 @@ def test_build_studio_catalog_prefers_persisted_studio_capabilities_over_legacy_
rotations = {} rotations = {}
autoselect = {} autoselect = {}
catalog = build_studio_catalog(scope="admin", owner_id=None, config=ConfigStub()) catalog = asyncio.run(build_studio_catalog(scope="admin", owner_id=None, config=ConfigStub()))
provider_entry = next(entry for entry in catalog["entries"] if entry["kind"] == "provider_model") provider_entry = next(entry for entry in catalog["entries"] if entry["kind"] == "provider_model")
assert provider_entry["capabilities"] == ["audio_input", "transcription"] assert provider_entry["capabilities"] == ["audio_input", "transcription"]
......
import json import json
import re
import sys import sys
from base64 import b64encode from base64 import b64encode
from pathlib import Path from pathlib import Path
...@@ -11,6 +12,7 @@ from jinja2 import Environment, FileSystemLoader, select_autoescape ...@@ -11,6 +12,7 @@ from jinja2 import Environment, FileSystemLoader, select_autoescape
from aisbf.models import ChatCompletionRequest from aisbf.models import ChatCompletionRequest
from aisbf.routes.dashboard import market as dashboard_market from aisbf.routes.dashboard import market as dashboard_market
from aisbf.routes.dashboard import providers as dashboard_providers
sys.path.insert(0, str(Path(__file__).resolve().parents[2])) sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from main import app from main import app
...@@ -46,6 +48,7 @@ class MarketReferenceImportDbStub: ...@@ -46,6 +48,7 @@ class MarketReferenceImportDbStub:
self.saved_user_rotations = [] self.saved_user_rotations = []
self.saved_user_autoselects = [] self.saved_user_autoselects = []
self.recorded_imports = [] self.recorded_imports = []
self.recorded_events = []
self.created_references = [] self.created_references = []
self.reference_rows = [] self.reference_rows = []
self.user_providers = [] self.user_providers = []
...@@ -234,6 +237,10 @@ class MarketReferenceImportDbStub: ...@@ -234,6 +237,10 @@ class MarketReferenceImportDbStub:
) )
return len(self.recorded_imports) return len(self.recorded_imports)
def record_dashboard_event(self, **kwargs):
self.recorded_events.append(kwargs)
return len(self.recorded_events)
def get_user_providers(self, user_id): def get_user_providers(self, user_id):
return [dict(row) for row in self.user_providers] return [dict(row) for row in self.user_providers]
...@@ -636,6 +643,9 @@ def test_dashboard_admin_providers_bootstrap_uses_json_parse(monkeypatch): ...@@ -636,6 +643,9 @@ def test_dashboard_admin_providers_bootstrap_uses_json_parse(monkeypatch):
"session": {}, "session": {},
"__version__": "test", "__version__": "test",
"providers_data": providers_payload, "providers_data": providers_payload,
# The page now bootstraps providers from a JSON <script> element built
# by the route's escaping helper, not an inline JS object literal.
"providers_bootstrap_json": dashboard_providers._json_parse_bootstrap(providers_payload),
"studio_capability_choices": [], "studio_capability_choices": [],
"studio_adapter_choices": [], "studio_adapter_choices": [],
"studio_adapter_profile_choices": [], "studio_adapter_profile_choices": [],
...@@ -648,11 +658,21 @@ def test_dashboard_admin_providers_bootstrap_uses_json_parse(monkeypatch): ...@@ -648,11 +658,21 @@ def test_dashboard_admin_providers_bootstrap_uses_json_parse(monkeypatch):
response_text = response.body.decode() response_text = response.body.decode()
assert response.status_code == 200 assert response.status_code == 200
assert "let providersData = {" in response_text # Providers are parsed via JSON.parse of a dedicated <script> element rather
bootstrap_fragment = response_text.split("let providersData = ", 1)[1].split(";\n", 1)[0] # than being inlined as an executable JS object literal.
providers_bootstrap = json.loads(bootstrap_fragment) assert "JSON.parse(document.getElementById('providers-bootstrap').textContent)" in response_text
match = re.search(
r'<script id="providers-bootstrap"[^>]*>(.*?)</script>',
response_text,
re.S,
)
assert match, "providers-bootstrap script element not found"
bootstrap_fragment = match.group(1)
# Dangerous markup must be escaped, never emitted raw (no </script> breakout).
assert '</script><script>alert(2)</script>' not in bootstrap_fragment assert '</script><script>alert(2)</script>' not in bootstrap_fragment
assert '\\u003c/script\\u003e\\u003cscript\\u003ealert(2)\\u003c/script\\u003e' in bootstrap_fragment assert '\\u003c/script\\u003e\\u003cscript\\u003ealert(2)\\u003c/script\\u003e' in bootstrap_fragment
# The fragment is a JSON-encoded string wrapping the JSON payload; decode twice.
providers_bootstrap = json.loads(json.loads(bootstrap_fragment))
assert providers_bootstrap["danger-provider"]["name"] == 'Admin "Provider" </script><script>alert(2)</script>' assert providers_bootstrap["danger-provider"]["name"] == 'Admin "Provider" </script><script>alert(2)</script>'
assert providers_bootstrap["danger-provider"]["endpoint"] == "https://danger.example/v1</script>" assert providers_bootstrap["danger-provider"]["endpoint"] == "https://danger.example/v1</script>"
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment