Files
ServerKit/backend/tests/test_theme_registry.py
Juan Denis ad96423966 feat(themes): hardening — preview validation, registry caching, token wiring (plan 61)
- Validate preview swatches as colors server-side (closes url() smuggling
  via POST /import); import tolerates unknown metadata (license, image, modes)
- theme_registry_service: negative caching on fetch failure (TTL-stamped
  last-good + 60s failure backoff — no more 15s gallery stalls offline);
  drop dead base_url cache key; refresh stale comment
- .env.example: document SERVERKIT_THEMES_REGISTRY_URL/_TTL
- SCSS: route $success/$warning/$danger/$info through --green/--amber/
  --red/--cyan (raw twins for color-function call sites), $font-main/$font-mono
  through --sans/--mono, scrollbar track through --scrollbar-track
- Fix 5 undefined CSS var consumers (--radius-md/--card-bg/--danger/
  --accent-subtle/--accent-soft -> existing tokens)
- Nits: drop dead ThemeStudioTrigger export, dedupe lucide import,
  run check-theme-tokens in pre-commit hook
- docs/plans/61_THEMES_HARDENING_PLAN.md

23 backend theme tests pass; check-theme-tokens, lint (0 errors), build green
2026-07-23 09:25:01 -04:00

181 lines
6.4 KiB
Python

"""Theme registry tests (plan 60, Phase 3).
Proves the panel-side registry pipeline: offline-tolerant fallback to the
bundled index, the admin-gated install, and a mocked end-to-end install flow
(fetch index → fetch theme.json → validate → store).
"""
import pytest
from app.services import theme_registry_service
@pytest.fixture(autouse=True)
def _reset_registry_cache():
"""The registry cache is module-level; clear it around each test."""
theme_registry_service._cache.update(
{'ts': 0.0, 'entries': None, 'source': None}
)
yield
theme_registry_service._cache.update(
{'ts': 0.0, 'entries': None, 'source': None}
)
class _FakeResp:
def __init__(self, payload):
self._payload = payload
def raise_for_status(self):
return None
def json(self):
return self._payload
FAKE_INDEX = {
'schema_version': 1,
'themes': [{
'slug': 'aurora',
'name': 'Aurora',
'base': 'dark',
'description': 'Test registry theme',
'preview': ['#111111', '#222222', '#88c0d0', '#eeeeee'],
'modes': ['dark'],
'theme': 'themes/aurora/theme.json',
}],
}
FAKE_THEME = {
'schema_version': 1,
'slug': 'aurora',
'name': 'Aurora',
'base': 'dark',
'tokens': {'dark': {'--surface': '#123456', '--text': '#ffffff'}},
'accent': '#88c0d0',
'preview': ['#111111', '#222222', '#88c0d0', '#eeeeee'],
}
def _mock_requests(monkeypatch):
def fake_get(url, *a, **k):
if url.endswith('/theme.json'):
return _FakeResp(FAKE_THEME)
return _FakeResp(FAKE_INDEX)
monkeypatch.setattr(theme_registry_service.requests, 'get', fake_get)
monkeypatch.setenv('SERVERKIT_THEMES_REGISTRY_URL', 'https://fake.local/index.json')
def test_registry_offline_falls_back_to_bundled(client, auth_headers, monkeypatch):
"""A disabled/unreachable registry serves the bundled index, and bundled
seed slugs are excluded from the browse catalogue (they already show)."""
monkeypatch.setenv('SERVERKIT_THEMES_REGISTRY_URL', '') # disabled → bundled
resp = client.get('/api/v1/themes/registry', headers=auth_headers)
assert resp.status_code == 200
data = resp.get_json()
assert data['source'] == 'bundled'
slugs = {t['slug'] for t in data['themes']}
assert 'nord-deep' not in slugs and 'default' not in slugs
def test_registry_lists_remote_theme(client, auth_headers, monkeypatch):
_mock_requests(monkeypatch)
resp = client.get('/api/v1/themes/registry', headers=auth_headers)
assert resp.status_code == 200
themes = resp.get_json()['themes']
aurora = next((t for t in themes if t['slug'] == 'aurora'), None)
assert aurora is not None
assert aurora['installed'] is False
def test_registry_install_flow(client, auth_headers, monkeypatch):
_mock_requests(monkeypatch)
resp = client.post('/api/v1/themes/registry/aurora/install', headers=auth_headers)
assert resp.status_code == 201, resp.get_json()
stored = resp.get_json()
assert stored['slug'] == 'aurora'
assert stored['source'] == 'registry'
assert stored['tokens']['dark']['--surface'] == '#123456'
installed = client.get('/api/v1/themes/installed', headers=auth_headers).get_json()
assert 'aurora' in {t['slug'] for t in installed['themes']}
def test_registry_install_unknown_slug_404(client, auth_headers, monkeypatch):
_mock_requests(monkeypatch)
resp = client.post('/api/v1/themes/registry/does-not-exist/install', headers=auth_headers)
assert resp.status_code == 404
def test_registry_install_requires_admin(client, app, monkeypatch):
from app import db
from app.models import User
from flask_jwt_extended import create_access_token
from werkzeug.security import generate_password_hash
_mock_requests(monkeypatch)
with app.app_context():
u = User(email='d2@t.local', username='d2',
password_hash=generate_password_hash('x'),
role=User.ROLE_DEVELOPER, is_active=True)
db.session.add(u)
db.session.commit()
headers = {'Authorization': f'Bearer {create_access_token(identity=u.id)}'}
resp = client.post('/api/v1/themes/registry/aurora/install', headers=headers)
assert resp.status_code == 403
def test_registry_failure_serves_last_good_without_refetch(monkeypatch):
"""A failed refresh with a last-good cache stamps the cache timestamp, so
the TTL applies and follow-up calls don't retry (and stall on) the network."""
calls = []
def flaky_get(url, *a, **k):
calls.append(url)
if len(calls) == 1:
return _FakeResp(FAKE_INDEX)
raise RuntimeError('offline')
monkeypatch.setattr(theme_registry_service.requests, 'get', flaky_get)
monkeypatch.setenv('SERVERKIT_THEMES_REGISTRY_URL', 'https://fake.local/index.json')
first = theme_registry_service.refresh()
assert [t['slug'] for t in first] == ['aurora']
# Expire the cache, then fail the refresh: last-good entries are served
# and the timestamp is stamped.
theme_registry_service._cache['ts'] = 0.0
second = theme_registry_service.refresh()
assert [t['slug'] for t in second] == ['aurora']
assert len(calls) == 2
# Within the TTL the fallback is served from cache — no network retry.
third = theme_registry_service.refresh()
assert [t['slug'] for t in third] == ['aurora']
assert len(calls) == 2
def test_registry_failure_without_cache_backs_off(monkeypatch):
"""With no cache to serve, a failed fetch falls back to the bundled index
under a short failure backoff: repeated calls don't hammer upstream, and
the registry is retried once the backoff expires."""
calls = []
def failing_get(url, *a, **k):
calls.append(url)
raise RuntimeError('offline')
monkeypatch.setattr(theme_registry_service.requests, 'get', failing_get)
monkeypatch.setenv('SERVERKIT_THEMES_REGISTRY_URL', 'https://fake.local/index.json')
first = theme_registry_service.refresh()
assert first # bundled fallback served
# Within the failure backoff, no second upstream attempt.
second = theme_registry_service.refresh()
assert second == first
assert len(calls) == 1
# After the failure backoff expires, the registry is retried.
theme_registry_service._cache['ts'] -= theme_registry_service._FAILURE_TTL + 1
theme_registry_service.refresh()
assert len(calls) == 2