Files
浅念 8884e100f9 upgrade: adopt V8.31.1 (theme 1.3/1.4 redesigns, editor deep-canvas, guided launch, component library)
Overlay 190 upstream items (V8.22→8.31) preserving trade-pro theme,
registry tests, and design docs. Re-applied full patch set on identical
anchors (discovery/env_for/blocks.css/type-mapping/locale-override/
theme_color/frontend entries/test_theme_pro_v3 mods) plus compose pins.

Verified: theme suites 35/35; dual-baseline full run 415 passed vs
pristine 386+29 ours, FAILED-set diff EMPTY (same 10 Windows env fails).
Live: container 8.31.1-dev.1 healthy, hesupipe 192 entries intact,
trade-pro build 203 pages 0 errors.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-26 09:43:17 +08:00

163 lines
9.4 KiB
Python

import io,json,zipfile
import pytest
from tests.legacy import LegacyTestClient as TestClient
from app import db,theme_store
from app.main import app
@pytest.fixture
def factory(tmp_path,monkeypatch):
monkeypatch.setattr(db,'DATA',tmp_path)
monkeypatch.setattr(db,'DB_FILE',tmp_path/'site.sqlite3')
monkeypatch.setattr(theme_store,'THEME_ROOT',tmp_path/'theme_packages')
monkeypatch.setenv('SITEFORGE_ALLOW_LEGACY_DIRECT_DESIGN_WRITE','1')
monkeypatch.setenv('SITEFORGE_ADMIN_PASSWORD','strong-admin-2026-secret')
monkeypatch.setenv('SITEFORGE_SESSION_SECRET','longer-test-session-secret-2026-not-for-production')
with TestClient(app) as c:
r=c.post('/api/auth/login',json={'username':'admin','password':'strong-admin-2026-secret'})
assert r.status_code==200,r.text
c.headers.update({'X-SiteForge-CSRF':c.get('/api/auth/me').json()['csrf']})
yield c
def site(c,mode='new'):
r=c.post('/api/sites',json={'name':'Factory Sandbox','mode':mode,'theme':'automotive','domain':'https://test.example'})
assert r.status_code==201,r.text
return r.json()['id']
def zip_theme(id='brand-x',version='1.0.0',bad=False):
from pathlib import Path
root=Path(__file__).resolve().parent.parent/'themes'/'corporate'
arr=io.BytesIO()
with zipfile.ZipFile(arr,'w') as z:
data=json.loads((root/'manifest.json').read_text());data.update({'id':id,'version':version,'name':'V2 Test Brand'})
z.writestr('manifest.json',json.dumps(data))
z.writestr('theme.css','body{background:#f5f5f5}')
z.writestr('page-presets.json',(root/'page-presets.json').read_bytes())
if bad:z.writestr('../hack.py','print(1)')
return arr.getvalue()
def test_install_theme_and_pinned_build(factory):
sid=site(factory)
r=factory.post('/api/factory/themes/install',files={'file':('theme.zip',zip_theme(),'application/zip')})
assert r.status_code==200,r.text
assert r.json()['id']=='brand-x'
assert 'brand-x' in [x.get('id',x.get('theme_id')) for x in factory.get('/api/factory/themes').json()]
update=factory.put(f'/api/factory/sites/{sid}/theme',json={'theme':'brand-x','version':'1.0.0'})
assert update.status_code==200,update.text
assert factory.post(f'/api/factory/sites/{sid}/bootstrap').status_code==200
b=factory.post(f'/api/sites/{sid}/builds',json={})
assert b.status_code==200,b.text
info=factory.get(f'/api/sites/{sid}/builds/{b.json()["version"]}/report').json()
assert info['theme']=='brand-x' and info['theme_version']=='1.0.0'
file=factory.get(f'/preview/{sid}/{b.json()["version"]}/search/').text
assert '站内搜索' in file
assert factory.get(f'/api/factory/sites/{sid}/theme').json()['version']=='1.0.0'
def test_theme_rejects_zip_traversal(factory):
r=factory.post('/api/factory/themes/install',files={'file':('evil.zip',zip_theme(bad=True),'application/zip')})
assert r.status_code==400
def test_complete_bootstrap_repeated_safe(factory):
sid=site(factory)
r=factory.post(f'/api/factory/sites/{sid}/bootstrap').json()
assert len(r['created'])>=7 and '/about/' in r['created']
r2=factory.post(f'/api/factory/sites/{sid}/bootstrap').json()
assert not r2['created']
records=factory.get(f'/api/sites/{sid}/entries').json()
assert all(r['route'] for r in records)
build=factory.post(f'/api/sites/{sid}/builds',json={}).json()
html=factory.get(f'/preview/{sid}/{build["version"]}/').text
assert 'sf-site-head' in html
assert 'auto-compiled.css' in html or 'factory.css' in html
assert factory.get(f'/preview/{sid}/{build["version"]}/assets/site-runtime.js').status_code==200
def test_mapping_preview_commit_and_cross_site(factory):
a=site(factory);b=site(factory)
rows=[{'车型名称':'A SUV','品牌':'Alpha','原网址':'/legacy/suv-a.html','续航':'400km'},
{'车型名称':'B SUV','品牌':'Beta','原网址':'/legacy/suv-b.html','续航':'500km'}]
rules={'title':'车型名称','brand':'品牌','route':'原网址','range':'续航'}
mapping=factory.post(f'/api/factory/sites/{a}/mapping/run',json={'rows':rows,'rules':rules,'content_type':'vehicle','dry_run':True})
assert mapping.status_code==200,mapping.text
assert mapping.json()['mapped']==2
assert factory.get(f'/api/sites/{b}/entries').json()[0]['route']=='/'
saved=factory.post(f'/api/factory/sites/{a}/mapping/run',json={'rows':rows,'rules':rules,'content_type':'vehicle','dry_run':False})
assert saved.status_code==200,saved.text
assert saved.json()['created']==2
imported=factory.get(f'/api/sites/{a}/entries').json()
suv=next(r for r in imported if r['title']=='A SUV')
assert suv['route']=='/legacy/suv-a.html' and suv['meta']['fields']['brand']=='Alpha'
assert all(r['title']!='A SUV' for r in factory.get(f'/api/sites/{b}/entries').json())
again=factory.post(f'/api/factory/sites/{a}/mapping/run',json={'rows':rows,'rules':rules,'content_type':'vehicle','dry_run':False})
assert again.json()['skipped']==2
def test_migration_readiness_wont_greenwash(factory):
sid=site(factory,mode='migration')
r=factory.get(f'/api/factory/sites/{sid}/migration/readiness').json()
assert not r['can_publish']
assert any('基线' in x['message'] for x in r['gates'])
assert any('构建' in x['message'] for x in r['gates'])
assert factory.post(f'/api/factory/sites/{sid}/mapping/run',json={'rows':[{'name':''}],'rules':{'title':'name'},'dry_run':True}).json()['errors']
def test_editor_supports_responsive_settings(factory):
sid=site(factory)
homepage=next(x for x in factory.get(f'/api/sites/{sid}/entries').json() if x['route']=='/')
blocks=homepage['meta']['blocks'];blocks[0]['style']={'mobile_padding':19,'tablet_padding':34,'hide_mobile':True}
r=factory.put(f'/api/sites/{sid}/pages/{homepage["id"]}/blueprint',json={'blocks':blocks,'page_template':'home'})
assert r.status_code==200,r.text
preview=factory.post(f'/api/sites/{sid}/pages/{homepage["id"]}/editor-preview',json={'blocks':blocks,'page_template':'home'})
assert preview.status_code==200
assert 'siteforge-studio-canvas' in preview.text and '--sf-pad:19px' in preview.text
build=factory.post(f'/api/sites/{sid}/builds',json={}).json()
css=factory.get(f'/preview/{sid}/{build["version"]}/assets/custom.css').text
assert '--sf-pad:19px' in css and 'display:none' in css
def test_release_requires_separate_approval(factory,monkeypatch):
sid=site(factory)
factory.post(f'/api/factory/sites/{sid}/bootstrap')
build=factory.post(f'/api/sites/{sid}/builds',json={}).json()
bid=build['version']
monkeypatch.setenv('SITEFORGE_REQUIRE_RELEASE_APPROVAL','true')
monkeypatch.setenv('SITEFORGE_PUBLISH_ROOT',str(db.DATA/'published'))
fail=factory.post(f'/api/sites/{sid}/builds/{bid}/publish-local')
assert fail.status_code==409,fail.text
req=factory.post(f'/api/factory/sites/{sid}/release-requests',json={'build_id':bid})
# If QA reports a real error we must not silently approve the release.
if req.status_code==409:
from app import quality
issues=quality.report(sid,bid)
assert issues['errors']>0,issues
return
assert req.status_code==200,req.text
rid=req.json()['id']
same_user=factory.post(f'/api/factory/sites/{sid}/release-requests/{rid}/review',json={'state':'approved'})
assert same_user.status_code==403
user=factory.post('/api/enterprise/team/users',json={'username':'review_v2','password':'very-long-review-password-2026','display_name':'Reviewer'}).json()
factory.put(f'/api/enterprise/sites/{sid}/members',json={'user_id':user['id'],'role':'reviewer'})
factory.post('/api/auth/logout')
assert factory.post('/api/auth/login',json={'username':'review_v2','password':'very-long-review-password-2026'}).status_code==200
factory.headers.update({'X-SiteForge-CSRF':factory.get('/api/auth/me').json()['csrf']})
approved=factory.post(f'/api/factory/sites/{sid}/release-requests/{rid}/review',json={'state':'approved'})
assert approved.status_code==200,approved.text
# Reviewer cannot run owner's publish action.
assert factory.post(f'/api/sites/{sid}/builds/{bid}/publish-local').status_code==403
factory.post('/api/auth/logout')
factory.post('/api/auth/login',json={'password':'strong-admin-2026-secret'})
factory.headers.update({'X-SiteForge-CSRF':factory.get('/api/auth/me').json()['csrf']})
assert factory.post(f'/api/sites/{sid}/builds/{bid}/publish-local').status_code==200
def test_theme_previews_are_rendered_by_trusted_templates(factory):
for name in ('automotive','industrial','corporate','minimal'):
result=factory.get(f'/api/factory/themes/{name}/preview')
assert result.status_code==200,result.text[:500]
assert 'sf-site-head' in result.text
assert 'assets/theme.css' not in result.text
assert 'sf-canvas' in result.text
assert factory.get(f'/api/factory/themes/{name}/preview.css').status_code==200
def test_theme_page_type_previews(factory):
for theme in ('automotive','industrial','corporate','minimal'):
for page_type in ('home','about','product','article','brand','listing','contact','standard','notfound'):
p=factory.get(f'/api/factory/themes/{theme}/preview',params={'page_type':page_type})
assert p.status_code==200,(theme,page_type,p.text[:300])
assert 'text/html' in p.headers['content-type']
assert factory.get('/api/factory/themes/automotive/preview?page_type=wrong').status_code==400