2026-10-22 16:49:25 +08:00
|
|
|
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
|
2026-10-26 09:43:17 +08:00
|
|
|
assert 'sf-site-head' in html
|
|
|
|
|
assert 'auto-compiled.css' in html or 'factory.css' in html
|
2026-10-22 16:49:25 +08:00
|
|
|
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
|
2026-10-26 09:43:17 +08:00
|
|
|
assert 'siteforge-studio-canvas' in preview.text and '--sf-pad:19px' in preview.text
|
2026-10-22 16:49:25 +08:00
|
|
|
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
|