mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-10-30 00:27:11 +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
186 lines
7.4 KiB
Python
186 lines
7.4 KiB
Python
"""Themes platform tests (plan 60).
|
|
|
|
Proves the backend gate: server-side token validation on import, the admin
|
|
gate, bundled-seed listing, panel-default selection, and the UNAUTHENTICATED
|
|
pre-auth GET /public/active channel.
|
|
"""
|
|
|
|
|
|
def _dev_headers(app):
|
|
"""A non-admin (developer) user's auth headers."""
|
|
from app import db
|
|
from app.models import User
|
|
from flask_jwt_extended import create_access_token
|
|
from werkzeug.security import generate_password_hash
|
|
with app.app_context():
|
|
u = User(
|
|
email='dev@test.local', username='devuser',
|
|
password_hash=generate_password_hash('x'),
|
|
role=User.ROLE_DEVELOPER, is_active=True,
|
|
)
|
|
db.session.add(u)
|
|
db.session.commit()
|
|
token = create_access_token(identity=u.id)
|
|
return {'Authorization': f'Bearer {token}'}
|
|
|
|
|
|
VALID_THEME = {
|
|
'schema_version': 1,
|
|
'slug': 'test-theme',
|
|
'name': 'Test Theme',
|
|
'author': 'tester',
|
|
'base': 'dark',
|
|
'tokens': {'dark': {'--surface': '#123456', '--text': '#ffffff', '--radius': '12px'}},
|
|
'accent': '#88c0d0',
|
|
'preview': ['#111111', '#222222', '#333333', '#444444'],
|
|
}
|
|
|
|
|
|
def test_public_active_is_unauthenticated_and_defaults_to_stock(client):
|
|
"""The pre-auth channel needs no token and returns the stock look by default."""
|
|
resp = client.get('/api/v1/themes/public/active')
|
|
assert resp.status_code == 200
|
|
data = resp.get_json()
|
|
assert data['slug'] == 'default'
|
|
assert data['tokens'] == {}
|
|
|
|
|
|
def test_installed_requires_auth(client):
|
|
assert client.get('/api/v1/themes/installed').status_code == 401
|
|
|
|
|
|
def test_installed_lists_bundled_seeds(client, auth_headers):
|
|
resp = client.get('/api/v1/themes/installed', headers=auth_headers)
|
|
assert resp.status_code == 200
|
|
data = resp.get_json()
|
|
slugs = {t['slug'] for t in data['themes']}
|
|
# The bundled seeds ship with the panel and always list.
|
|
assert {'default', 'nord-deep', 'paper', 'phosphor'}.issubset(slugs)
|
|
assert data['default'] == 'default'
|
|
|
|
|
|
def test_import_valid_theme_stores_and_lists(client, auth_headers):
|
|
resp = client.post('/api/v1/themes/import', json=VALID_THEME, headers=auth_headers)
|
|
assert resp.status_code == 201, resp.get_json()
|
|
stored = resp.get_json()
|
|
assert stored['slug'] == 'test-theme'
|
|
assert stored['tokens']['dark']['--surface'] == '#123456'
|
|
assert stored['source'] == 'import'
|
|
|
|
listing = client.get('/api/v1/themes/installed', headers=auth_headers).get_json()
|
|
assert 'test-theme' in {t['slug'] for t in listing['themes']}
|
|
|
|
|
|
def test_import_sanitizes_malicious_token_values(client, auth_headers):
|
|
"""url()/CSS-breakout values are dropped server-side; clean tokens survive."""
|
|
payload = {
|
|
**VALID_THEME,
|
|
'slug': 'sneaky',
|
|
'tokens': {'dark': {
|
|
'--surface': 'url(https://evil.example/x.png)', # exfil vector
|
|
'--text': '#fff}html{display:none', # CSS breakout
|
|
'--border': '#23272f', # legitimate
|
|
'--evil': '#000000', # not whitelisted
|
|
}},
|
|
}
|
|
resp = client.post('/api/v1/themes/import', json=payload, headers=auth_headers)
|
|
assert resp.status_code == 201, resp.get_json()
|
|
tokens = resp.get_json()['tokens']['dark']
|
|
assert tokens == {'--border': '#23272f'}
|
|
assert '--surface' not in tokens and '--text' not in tokens and '--evil' not in tokens
|
|
|
|
|
|
def test_import_rejects_theme_with_no_valid_tokens(client, auth_headers):
|
|
payload = {**VALID_THEME, 'slug': 'all-bad', 'tokens': {'dark': {'--surface': 'url(x)'}}}
|
|
resp = client.post('/api/v1/themes/import', json=payload, headers=auth_headers)
|
|
assert resp.status_code == 400
|
|
|
|
|
|
def test_import_rejects_malicious_preview_swatch(client, auth_headers):
|
|
"""Preview swatches are painted as inline CSS — an url() payload must be
|
|
rejected, not stored."""
|
|
payload = {
|
|
**VALID_THEME,
|
|
'slug': 'evil-preview',
|
|
'preview': ['#111111', '#222222', 'url(https://evil.example/x)', '#444444'],
|
|
}
|
|
resp = client.post('/api/v1/themes/import', json=payload, headers=auth_headers)
|
|
assert resp.status_code == 400
|
|
|
|
|
|
def test_import_accepts_valid_mixed_preview(client, auth_headers):
|
|
"""Hex, functional rgb()/hsl() and named colors are all legit swatches."""
|
|
payload = {
|
|
**VALID_THEME,
|
|
'slug': 'mixed-preview',
|
|
'preview': ['#88c0d0', 'rgb(136, 192, 208)', 'rebeccapurple', 'hsl(210, 50%, 40%)'],
|
|
}
|
|
resp = client.post('/api/v1/themes/import', json=payload, headers=auth_headers)
|
|
assert resp.status_code == 201, resp.get_json()
|
|
assert resp.get_json()['preview'] == payload['preview']
|
|
|
|
|
|
def test_import_tolerates_unknown_metadata_fields(client, auth_headers):
|
|
"""Unknown-but-harmless top-level metadata (license, index-only fields like
|
|
image/modes) is ignored, not rejected and not stored."""
|
|
payload = {
|
|
**VALID_THEME,
|
|
'slug': 'licensed',
|
|
'license': 'MIT',
|
|
'image': 'https://example.com/shot.png',
|
|
'modes': ['dark'],
|
|
}
|
|
resp = client.post('/api/v1/themes/import', json=payload, headers=auth_headers)
|
|
assert resp.status_code == 201, resp.get_json()
|
|
stored = resp.get_json()
|
|
assert stored['slug'] == 'licensed'
|
|
assert 'license' not in stored and 'image' not in stored and 'modes' not in stored
|
|
|
|
|
|
def test_import_rejects_reserved_default_slug(client, auth_headers):
|
|
payload = {**VALID_THEME, 'slug': 'default'}
|
|
resp = client.post('/api/v1/themes/import', json=payload, headers=auth_headers)
|
|
assert resp.status_code == 400
|
|
|
|
|
|
def test_import_requires_admin(client, app):
|
|
resp = client.post('/api/v1/themes/import', json=VALID_THEME, headers=_dev_headers(app))
|
|
assert resp.status_code == 403
|
|
|
|
|
|
def test_delete_installed_theme(client, auth_headers):
|
|
client.post('/api/v1/themes/import', json=VALID_THEME, headers=auth_headers)
|
|
resp = client.delete('/api/v1/themes/test-theme', headers=auth_headers)
|
|
assert resp.status_code == 200
|
|
listing = client.get('/api/v1/themes/installed', headers=auth_headers).get_json()
|
|
assert 'test-theme' not in {t['slug'] for t in listing['themes']}
|
|
|
|
|
|
def test_bundled_theme_cannot_be_deleted(client, auth_headers):
|
|
resp = client.delete('/api/v1/themes/nord-deep', headers=auth_headers)
|
|
assert resp.status_code == 400
|
|
|
|
|
|
def test_set_default_flows_to_public_active(client, auth_headers):
|
|
"""Setting the panel default surfaces its tokens on the pre-auth channel."""
|
|
client.post('/api/v1/themes/import', json=VALID_THEME, headers=auth_headers)
|
|
resp = client.post('/api/v1/themes/default', json={'slug': 'test-theme'}, headers=auth_headers)
|
|
assert resp.status_code == 200
|
|
|
|
active = client.get('/api/v1/themes/public/active').get_json()
|
|
assert active['slug'] == 'test-theme'
|
|
assert active['tokens']['dark']['--surface'] == '#123456'
|
|
|
|
|
|
def test_set_default_rejects_unknown_theme(client, auth_headers):
|
|
resp = client.post('/api/v1/themes/default', json={'slug': 'nope-nope'}, headers=auth_headers)
|
|
assert resp.status_code == 400
|
|
|
|
|
|
def test_deleting_default_theme_reverts_to_stock(client, auth_headers):
|
|
client.post('/api/v1/themes/import', json=VALID_THEME, headers=auth_headers)
|
|
client.post('/api/v1/themes/default', json={'slug': 'test-theme'}, headers=auth_headers)
|
|
client.delete('/api/v1/themes/test-theme', headers=auth_headers)
|
|
active = client.get('/api/v1/themes/public/active').get_json()
|
|
assert active['slug'] == 'default'
|