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