mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-10-28 23:57:58 +09:00
- 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
181 lines
6.4 KiB
Python
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
|