chore: import SiteForge Factory V5 source (baseline)
导入 SiteForge Factory V5 基线源码: - FastAPI + SQLite + Docker Compose(Web + Worker)架构 - 四套商业主题 × 14 页面模板(1.1.0,含历史版本快照) - 四种建站模式骨架 + 迁移审计 + 发布门禁 - 52 项 pytest 测试 - 补全计划主文档 docs/superpowers/plans/ Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
"""Acceptance tests for V5 visual packages and honest commercial readiness."""
|
||||
import json
|
||||
from pathlib import Path
|
||||
from bs4 import BeautifulSoup
|
||||
from app import theme_store,db,builder,commercial_readiness
|
||||
from test_factory_v2 import factory,site
|
||||
|
||||
def test_all_four_new_versions_have_complete_offline_artwork(factory):
|
||||
for theme in ('automotive','industrial','corporate','minimal'):
|
||||
versions=factory.get(f'/api/factory/themes/{theme}/versions').json()
|
||||
names={row['version'] for row in versions}
|
||||
assert {'1.1.0','1.0.0','0.3.0'} <= names
|
||||
folder=theme_store.resolve(theme,'1.1.0')
|
||||
assert (folder/'visual/hero.svg').read_text(encoding='utf-8').startswith('<svg ')
|
||||
assert (folder/'pages/product.html').is_file()
|
||||
assert theme_store.compatibility(theme,'1.1.0')['valid']
|
||||
|
||||
def test_site_build_contains_commercial_css_artwork_and_report(factory):
|
||||
sid=site(factory)
|
||||
assert factory.post(f'/api/factory/sites/{sid}/bootstrap').status_code==200
|
||||
assert factory.put(f'/api/factory/sites/{sid}/theme',json={'theme':'industrial','version':'1.1.0'}).status_code==200
|
||||
result=factory.post(f'/api/sites/{sid}/builds',json={})
|
||||
assert result.status_code==200,result.text
|
||||
bid=result.json()['version']
|
||||
url=f'/preview/{sid}/{bid}'
|
||||
home=factory.get(url+'/').text
|
||||
soup=BeautifulSoup(home,'html.parser')
|
||||
assert soup.select_one('link[href="assets/commercial.css"]')
|
||||
assert len(soup.select('h1'))==1
|
||||
assert soup.select_one('meta[property="og:title"]')
|
||||
assert factory.get(url+'/assets/commercial.css').status_code==200
|
||||
svg=factory.get(url+'/assets/visual/hero.svg')
|
||||
assert svg.status_code==200 and '<svg' in svg.text
|
||||
report=factory.get(f'/api/sites/{sid}/builds/{bid}/report').json()
|
||||
assert not report['errors']
|
||||
assert report['commercial_readiness']['commercially_certified'] is False
|
||||
handoff=factory.get(url+'/commercial-readiness.json')
|
||||
assert handoff.status_code==200
|
||||
obj=handoff.json();assert obj['commercially_certified'] is False
|
||||
assert any(w['code']=='brand_logo' for w in obj['warnings'])
|
||||
|
||||
def test_theme_rollback_keeps_previous_version(factory):
|
||||
sid=site(factory)
|
||||
for version in ('1.0.0','1.1.0'):
|
||||
selected=factory.put(f'/api/factory/sites/{sid}/theme',json={'theme':'automotive','version':version})
|
||||
assert selected.status_code==200,selected.text
|
||||
result=factory.post(f'/api/sites/{sid}/builds',json={})
|
||||
assert result.status_code==200,result.text
|
||||
html=factory.get(f'/preview/{sid}/{result.json()["version"]}/').text
|
||||
assert ('assets/commercial.css' in html)==(version=='1.1.0')
|
||||
|
||||
def test_readiness_detects_missing_required_pages_and_contact(tmp_path):
|
||||
root=tmp_path
|
||||
(root/'index.html').write_text('<html><head><title>Test</title></head><body><h1>Test</h1></body></html>')
|
||||
report=commercial_readiness.inspect(root)
|
||||
assert not report['ready_for_review']
|
||||
assert report['commercially_certified'] is False
|
||||
assert any(x['code']=='page_missing' for x in report['blockers'])
|
||||
|
||||
def test_online_theme_preview_loads_same_css_and_hero(factory):
|
||||
preview=factory.get('/api/factory/themes/automotive/preview',params={'version':'1.1.0','page_type':'home'})
|
||||
assert preview.status_code==200
|
||||
assert '/ui/commercial.css' in preview.text
|
||||
sheet=factory.get('/ui/commercial.css')
|
||||
assert sheet.status_code==200 and 'pro-filter-panel' in sheet.text
|
||||
art=factory.get('/api/factory/themes/automotive/visual/hero.svg',params={'version':'1.1.0'})
|
||||
assert art.status_code==200 and 'image/svg+xml' in art.headers['content-type']
|
||||
assert factory.get('/api/factory/themes/automotive/visual/hero.svg',params={'version':'1.0.0'}).status_code==404
|
||||
|
||||
def test_custom_declarative_theme_inherits_trusted_visuals(factory):
|
||||
import io,zipfile
|
||||
from test_factory_v2 import zip_theme
|
||||
blob=io.BytesIO(zip_theme('art-compatible','2.0.0'))
|
||||
response=factory.post('/api/factory/themes/install',files={'file':('theme.zip',blob.getvalue(),'application/zip')})
|
||||
assert response.status_code==200,response.text
|
||||
folder=theme_store.resolve('art-compatible','2.0.0')
|
||||
assert (folder/'visual/hero.svg').is_file()
|
||||
@@ -0,0 +1,196 @@
|
||||
"""Regression tests for V4: theme layouts, migration audit, assembly, WXR.
|
||||
No external HTTP/DNS or Docker engine required.
|
||||
"""
|
||||
import io,json,zipfile
|
||||
from pathlib import Path
|
||||
from html import escape
|
||||
from openpyxl import Workbook
|
||||
from app import db,blueprints,theme_store,migration_audit,wordpress_compat
|
||||
from test_factory_v2 import factory,site
|
||||
|
||||
|
||||
def test_nested_grid_validates_and_builds(factory):
|
||||
sid=site(factory)
|
||||
home=next(x for x in factory.get(f'/api/sites/{sid}/entries').json() if x['route']=='/')
|
||||
layout=blueprints.new_block('layout',{'columns':[
|
||||
{'width':2,'blocks':[blueprints.new_block('richtext',{'title':'品牌故事','body':'<p>我们的企业介绍</p>'})]},
|
||||
{'width':1,'blocks':[blueprints.new_block('cta',{'title':'欢迎合作','href':'/contact/'})]}]})
|
||||
result=factory.put(f'/api/sites/{sid}/pages/{home["id"]}/blueprint',json={'blocks':[layout],
|
||||
'page_template':'home','layout_mode':'replace'})
|
||||
assert result.status_code==200,result.text
|
||||
assert result.json()['layout_mode']=='replace'
|
||||
preview=factory.post(f'/api/sites/{sid}/pages/{home["id"]}/editor-preview',json={'blocks':[layout],'page_template':'home','layout_mode':'replace'})
|
||||
assert preview.status_code==200,preview.text
|
||||
assert '品牌故事' in preview.text and 'sf-layout-grid' in preview.text and 'pro-header' in preview.text
|
||||
version=factory.post(f'/api/sites/{sid}/builds',json={}).json()['version']
|
||||
published=factory.get(f'/preview/{sid}/{version}/').text
|
||||
assert '品牌故事' in published and 'sf-layout-grid' in published and 'pro-header' in published
|
||||
css=factory.get(f'/preview/{sid}/{version}/assets/blocks.css').text
|
||||
assert '@media(max-width:540px)' in css
|
||||
|
||||
|
||||
def test_layout_rejects_duplicate_and_nested_excess(factory):
|
||||
a=blueprints.new_block('richtext');b=blueprints.new_block('richtext');b['id']=a['id']
|
||||
bad=blueprints.new_block('layout',{'columns':[{'width':1,'blocks':[a,b]}]})
|
||||
try:blueprints.validate_blocks([bad]);assert False,'duplicate should be rejected'
|
||||
except ValueError:pass
|
||||
nested=blueprints.new_block('layout');child=blueprints.new_block('layout');grand=blueprints.new_block('layout')
|
||||
child['columns'][0]['blocks']=[grand];nested['columns'][0]['blocks']=[child]
|
||||
try:blueprints.validate_blocks([nested]);assert False,'deep nesting should be rejected'
|
||||
except ValueError:pass
|
||||
|
||||
|
||||
def test_wordpress_elementor_media_taxonomy_and_woo_gallery(factory):
|
||||
sid=site(factory)
|
||||
widgets=json.dumps([{'widgetType':'heading','settings':{'title':'核心价值','header_size':'h2'}},
|
||||
{'widgetType':'image','settings':{'image':{'url':'https://img.example/media.jpg'}}},
|
||||
{'widgetType':'unknown_plugin_widget','settings':{'value':'secret'}}],ensure_ascii=False)
|
||||
wxr='''<?xml version="1.0"?><rss xmlns:wp="http://wordpress.org/export/1.2/" xmlns:content="http://purl.org/rss/1.0/modules/content/" version="2.0"><channel>
|
||||
<item><title>图片</title><wp:post_type>attachment</wp:post_type><wp:post_id>12</wp:post_id><wp:attachment_url>https://img.example/cover.jpg</wp:attachment_url></item>
|
||||
<item><title>产品A</title><link>https://wp.example/product/test-a/</link><wp:post_type>product</wp:post_type><wp:status>publish</wp:status><content:encoded></content:encoded>
|
||||
<category domain="product_cat">运动型多用途车</category>
|
||||
<wp:postmeta><wp:meta_key>_thumbnail_id</wp:meta_key><wp:meta_value>12</wp:meta_value></wp:postmeta>
|
||||
<wp:postmeta><wp:meta_key>_product_image_gallery</wp:meta_key><wp:meta_value>12</wp:meta_value></wp:postmeta>
|
||||
<wp:postmeta><wp:meta_key>_elementor_data</wp:meta_key><wp:meta_value><![CDATA['''+widgets+''']]></wp:meta_value></wp:postmeta>
|
||||
<wp:postmeta><wp:meta_key>_price</wp:meta_key><wp:meta_value>1200</wp:meta_value></wp:postmeta>
|
||||
</item></channel></rss>'''
|
||||
response=factory.post(f'/api/sites/{sid}/import',files={'file':('export.xml',wxr.encode(),'application/xml')})
|
||||
assert response.status_code==200,response.text
|
||||
item=next(x for x in factory.get(f'/api/sites/{sid}/entries').json() if x['title']=='产品A')
|
||||
assert item['cover']=='https://img.example/cover.jpg'
|
||||
assert item['meta']['fields']['category']=='运动型多用途车'
|
||||
assert item['meta']['fields']['price']=='1200'
|
||||
assert '核心价值' in item['body']
|
||||
assert item['meta']['migration_warnings']
|
||||
assert item['meta']['fields']['gallery']==['https://img.example/cover.jpg']
|
||||
|
||||
|
||||
def test_excel_mapping_relations_preview_and_repeat(factory):
|
||||
sid=site(factory)
|
||||
w=Workbook();ws=w.active;ws.append(['车型名称','品牌','分类','动力形式','图库','原网址'])
|
||||
ws.append(['SUV Alpha','Hesu','SUV','PHEV','https://img.example/1.jpg;https://img.example/2.jpg','/cars/alpha.html'])
|
||||
b=io.BytesIO();w.save(b)
|
||||
r=factory.post(f'/api/factory/sites/{sid}/mapping/parse-file',files={'file':('cars.xlsx',b.getvalue())})
|
||||
assert r.status_code==200,r.text
|
||||
parsed=r.json();assert parsed['count']==1
|
||||
assert parsed['suggested']['title']=='车型名称'
|
||||
report=factory.post(f'/api/factory/sites/{sid}/mapping/run',json={'rows':parsed['rows'],
|
||||
'rules':{**parsed['suggested'],'gallery':'图库'},'content_type':'vehicle','dry_run':True}).json()
|
||||
assert report['mapped']==1 and report['sample'][0]['route']=='/cars/alpha.html'
|
||||
committed=factory.post(f'/api/factory/sites/{sid}/mapping/run',json={'rows':parsed['rows'],
|
||||
'rules':{**parsed['suggested'],'gallery':'图库'},'content_type':'vehicle','dry_run':False})
|
||||
assert committed.status_code==200
|
||||
x=factory.get(f'/api/factory/sites/{sid}/relationships').json()
|
||||
assert any(i['brand']=='Hesu' for i in x['brand_pages_missing'])
|
||||
y=factory.post(f'/api/factory/sites/{sid}/relationships/assemble').json()
|
||||
assert y['count']==1
|
||||
assert factory.post(f'/api/factory/sites/{sid}/relationships/assemble').json()['count']==0
|
||||
entries=factory.get(f'/api/sites/{sid}/entries').json()
|
||||
assert any(e['type']=='brand' and e['title']=='Hesu' for e in entries)
|
||||
assert next(e for e in entries if e['title']=='SUV Alpha')['meta']['fields']['gallery']==['https://img.example/1.jpg','https://img.example/2.jpg']
|
||||
for theme in ('industrial','corporate','minimal'):
|
||||
assert factory.put(f'/api/factory/sites/{sid}/theme',json={'theme':theme}).status_code==200
|
||||
version=factory.post(f'/api/sites/{sid}/builds',json={}).json()['version']
|
||||
assert 'SUV Alpha' in factory.get(f'/preview/{sid}/{version}/cars/alpha.html').text
|
||||
|
||||
|
||||
def test_migration_manifest_and_built_qa_blocks_missing(factory):
|
||||
sid=site(factory,mode='migration')
|
||||
baseline='url,seo_title,description,canonical,status\nhttps://old.example/,Title from old,Old description,,200\nhttps://old.example/legacy-only.html,Missing,,,200\n'
|
||||
r=factory.post(f'/api/audit/sites/{sid}/import-baseline',files={'file':('urls.csv',baseline.encode())})
|
||||
assert r.status_code==200,r.text
|
||||
assert r.json()['imported']==2
|
||||
version=factory.post(f'/api/sites/{sid}/builds',json={}).json()['version']
|
||||
report=factory.get(f'/api/audit/sites/{sid}/builds/{version}/report').json()
|
||||
assert not report['passed'] and report['errors']>=1
|
||||
assert any(i['route']=='/legacy-only.html' and i['field']=='url' for i in report['issues'])
|
||||
assert any(i['route']=='/' and i['severity']=='warning' for i in report['issues'])
|
||||
readiness=factory.get(f'/api/factory/sites/{sid}/migration/readiness?build_id={version}').json()
|
||||
assert not readiness['can_publish']
|
||||
csv=factory.get(f'/api/audit/sites/{sid}/builds/{version}/report.csv')
|
||||
assert csv.status_code==200 and 'legacy-only.html' in csv.text
|
||||
|
||||
|
||||
def test_sitemap_import_and_invalid_baseline_atomic(factory):
|
||||
sid=site(factory,mode='migration')
|
||||
xml='''<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"><url><loc>https://old.example/about/</loc></url></urlset>'''
|
||||
assert factory.post(f'/api/audit/sites/{sid}/import-baseline',files={'file':('sitemap.xml',xml.encode())}).json()['imported']==1
|
||||
bad='url\nhttps://old.example/about/\nhttps://old.example/about/?page=5\n'
|
||||
response=factory.post(f'/api/audit/sites/{sid}/import-baseline',files={'file':('bad.csv',bad.encode())})
|
||||
assert response.status_code==200 and response.json()['errors']
|
||||
baseline=factory.get(f'/api/enterprise/sites/{sid}/baseline/report').json()
|
||||
assert baseline['baseline_count']==1
|
||||
|
||||
|
||||
def test_theme_gallery_and_compatibility(factory):
|
||||
sid=site(factory)
|
||||
for theme in ('automotive','industrial','corporate','minimal'):
|
||||
r=factory.get(f'/api/factory/themes/{theme}/compatibility')
|
||||
assert r.status_code==200 and r.json()['valid'],r.text
|
||||
product=factory.post(f'/api/sites/{sid}/entries',json={'title':'Unique Product','type':'product','route':'/product/unique/',
|
||||
'cover':'https://imgs.example/cover.jpg','meta':{'fields':{'gallery':['https://imgs.example/cover.jpg','https://imgs.example/second.jpg']}}})
|
||||
assert product.status_code in (200,201),product.text
|
||||
ver=factory.post(f'/api/sites/{sid}/builds',json={}).json()['version']
|
||||
html=factory.get(f'/preview/{sid}/{ver}/product/unique/').text
|
||||
assert 'pro-gallery-thumbs' in html and 'data-gallery-image' in html
|
||||
assert 'pro.js' in html
|
||||
|
||||
|
||||
def test_css_style_breakout_rejected(factory):
|
||||
z=io.BytesIO()
|
||||
with zipfile.ZipFile(z,'w') as f:
|
||||
data={'id':'test-css-escape','version':'1.0.0','name':'Safe Test','layout_family':'corporate'}
|
||||
f.writestr('manifest.json',json.dumps(data));f.writestr('page-presets.json',json.dumps({'home':[]}))
|
||||
f.writestr('theme.css','body{color:red}</style><script>alert(1)</script>')
|
||||
result=factory.post('/api/factory/themes/install',files={'file':('unsafe.zip',z.getvalue())})
|
||||
assert result.status_code==400
|
||||
|
||||
def test_migration_warning_requires_explicit_review(factory):
|
||||
sid=site(factory,mode='migration')
|
||||
source='url,seo_title\nhttps://old.example/,Old SEO Title\n'
|
||||
factory.post(f'/api/audit/sites/{sid}/import-baseline',files={'file':('old.csv',source.encode())})
|
||||
build=factory.post(f'/api/sites/{sid}/builds',json={}).json()['version']
|
||||
current=factory.get(f'/api/audit/sites/{sid}/builds/{build}/report').json()
|
||||
warnings=[i for i in current['issues'] if i['severity']=='warning' and i['field']=='title']
|
||||
assert warnings and current['unreviewed_warnings']>=1
|
||||
readiness=factory.get(f'/api/factory/sites/{sid}/migration/readiness?build_id={build}').json()
|
||||
assert not readiness['can_publish']
|
||||
assert any('未经审核' in x['message'] for x in readiness['gates'])
|
||||
denied=factory.post(f'/api/audit/sites/{sid}/builds/{build}/review-warning',json={
|
||||
'issue_id':warnings[0]['issue_id'],'reason':'短'});
|
||||
assert denied.status_code==400
|
||||
accepted=factory.post(f'/api/audit/sites/{sid}/builds/{build}/review-warning',json={
|
||||
'issue_id':warnings[0]['issue_id'],'reason':'企业管理人员已经核查该 SEO 标题调整,并批准本构建中的改动。'})
|
||||
assert accepted.status_code==200,accepted.text
|
||||
after=factory.get(f'/api/audit/sites/{sid}/builds/{build}/report').json()
|
||||
assert after['unreviewed_warnings']==0
|
||||
next_gate=factory.get(f'/api/factory/sites/{sid}/migration/readiness?build_id={build}').json()
|
||||
assert not any('未经审核' in x['message'] for x in next_gate['gates'])
|
||||
# Unrelated broken links may still correctly block publication.
|
||||
|
||||
|
||||
def test_site_scoped_reusable_block_library(factory):
|
||||
first=site(factory); second=site(factory)
|
||||
block=blueprints.new_block('layout',{'columns':[{'width':1,'blocks':[blueprints.new_block('richtext',{'title':'跨页面共用模块'})]}]})
|
||||
created=factory.post(f'/api/sites/{first}/components',json={'name':'品牌单列布局','block':block})
|
||||
assert created.status_code==200,created.text
|
||||
cid=created.json()['id']
|
||||
rows=factory.get(f'/api/sites/{first}/components').json()
|
||||
assert len(rows)==1 and rows[0]['block']['columns'][0]['blocks'][0]['title']=='跨页面共用模块'
|
||||
assert not factory.get(f'/api/sites/{second}/components').json()
|
||||
assert factory.put(f'/api/sites/{first}/components/{cid}',json={'name':'新版', 'expected_version':0,'block':block}).status_code==409
|
||||
assert factory.put(f'/api/sites/{first}/components/{cid}',json={'name':'新版', 'expected_version':1,'block':block}).json()['version']==2
|
||||
assert factory.delete(f'/api/sites/{first}/components/{cid}').json()['deleted']
|
||||
|
||||
|
||||
def test_contact_form_requires_configured_https_endpoint(factory):
|
||||
sid=site(factory)
|
||||
assert factory.put(f'/api/sites/{sid}/contact-settings',json={'email':'hello@example.org', 'form_endpoint':'http://unsecure.example/forms'}).status_code==400
|
||||
config=factory.put(f'/api/sites/{sid}/contact-settings',json={
|
||||
'email':'hello@example.org','form_endpoint':'https://forms.example.org/receive','footer_text':'Official inquiry channel'})
|
||||
assert config.status_code==200,config.text
|
||||
factory.post(f'/api/factory/sites/{sid}/bootstrap')
|
||||
build=factory.post(f'/api/sites/{sid}/builds',json={}).json()['version']
|
||||
contact=factory.get(f'/preview/{sid}/{build}/contact/').text
|
||||
assert '<form' in contact and 'forms.example.org/receive' in contact
|
||||
assert 'Official inquiry channel' in contact
|
||||
@@ -0,0 +1,134 @@
|
||||
import json
|
||||
import os
|
||||
from fastapi.testclient import TestClient
|
||||
import pytest
|
||||
from app import db, enterprise, worker
|
||||
from app.main import app
|
||||
from app.worker import run_once
|
||||
|
||||
@pytest.fixture
|
||||
def root(tmp_path,monkeypatch):
|
||||
monkeypatch.setattr(db,'DATA',tmp_path)
|
||||
monkeypatch.setattr(db,'DB_FILE',tmp_path/'app.sqlite3')
|
||||
monkeypatch.setenv('SITEFORGE_ADMIN_PASSWORD','administrator-strong-password-2026')
|
||||
monkeypatch.setenv('SITEFORGE_SESSION_SECRET','secure-random-test-key-with-long-length-1234')
|
||||
monkeypatch.setenv('SITEFORGE_PUBLISH_ROOT',str(tmp_path/'published'))
|
||||
with TestClient(app) as c:
|
||||
assert c.post('/api/auth/login',json={'username':'admin','password':'administrator-strong-password-2026'}).status_code==200
|
||||
csrf=c.get('/api/auth/me').json()['csrf']
|
||||
c.headers.update({'X-SiteForge-CSRF':csrf})
|
||||
yield c
|
||||
|
||||
def site(c,name):
|
||||
r=c.post('/api/sites',json={'name':name,'theme':'corporate','mode':'migration','domain':'https://example.org'})
|
||||
assert r.status_code==201,r.text
|
||||
return r.json()['id']
|
||||
|
||||
def test_team_rbac_isolation(root):
|
||||
one=site(root,'Company A')
|
||||
two=site(root,'Company B')
|
||||
response=root.post('/api/enterprise/team/users',json={'username':'editor1','password':'long-editor-password-2026','display_name':'Editor'})
|
||||
assert response.status_code==201,response.text
|
||||
uid=response.json()['id']
|
||||
r=root.put(f'/api/enterprise/sites/{one}/members',json={'user_id':uid,'role':'editor'})
|
||||
assert r.status_code==200,r.text
|
||||
root.post('/api/auth/logout')
|
||||
assert root.post('/api/auth/login',json={'username':'editor1','password':'long-editor-password-2026'}).status_code==200
|
||||
root.headers.update({'X-SiteForge-CSRF':root.get('/api/auth/me').json()['csrf']})
|
||||
visible=root.get('/api/sites').json()
|
||||
assert [x['id'] for x in visible]==[one]
|
||||
assert root.get(f'/api/sites/{two}').status_code==403
|
||||
assert root.post(f'/api/sites/{two}/entries',json={'title':'Blocked'}).status_code==403
|
||||
assert root.post('/api/sites',json={'name':'Blocked'}).status_code==403
|
||||
assert root.post(f'/api/sites/{one}/entries',json={'title':'Allowed','type':'page','route':'/allowed/'}).status_code==201
|
||||
assert root.post(f'/api/sites/{one}/builds/invalid/publish-local').status_code==403
|
||||
assert root.get('/api/enterprise/team/users').status_code==403
|
||||
assert root.get(f'/api/enterprise/sites/{one}/baseline/report').status_code==200
|
||||
|
||||
|
||||
def test_baseline_and_release_gate(root):
|
||||
sid=site(root,'Legacy')
|
||||
x=root.post(f'/api/sites/{sid}/entries',json={'type':'page','title':'Original','route':'/legacy/a.html','seo_title':'Old Title'})
|
||||
assert x.status_code==201
|
||||
assert root.post(f'/api/enterprise/sites/{sid}/baseline/snapshot').json()['captured']==2
|
||||
e=x.json()['id']
|
||||
assert root.delete(f'/api/sites/{sid}/entries/{e}').status_code==200
|
||||
report=root.get(f'/api/enterprise/sites/{sid}/baseline/report').json()
|
||||
assert report['errors']==1
|
||||
build=root.post(f'/api/sites/{sid}/builds',json={})
|
||||
assert build.status_code==200,build.text
|
||||
bid=build.json()['version']
|
||||
publish=root.post(f'/api/sites/{sid}/builds/{bid}/publish-local')
|
||||
assert publish.status_code==409
|
||||
assert root.post(f'/api/sites/{sid}/entries',json={'type':'page','title':'Replaced','route':'/legacy/a.html','seo_title':'Old Title'}).status_code==201
|
||||
assert root.get(f'/api/enterprise/sites/{sid}/baseline/report').json()['errors']==0
|
||||
rebuilt=root.post(f'/api/sites/{sid}/builds',json={}).json()['version']
|
||||
publish=root.post(f'/api/sites/{sid}/builds/{rebuilt}/publish-local')
|
||||
assert publish.status_code==200,publish.text
|
||||
qa=root.get(f'/api/enterprise/sites/{sid}/builds/{rebuilt}/quality')
|
||||
assert qa.status_code==200,qa.text
|
||||
assert qa.json()['files']>=2
|
||||
history=root.get(f'/api/enterprise/sites/{sid}/releases').json()
|
||||
assert history and history[0]['build_id']==rebuilt
|
||||
rollback=root.post(f'/api/enterprise/sites/{sid}/releases/{rebuilt}/rollback')
|
||||
assert rollback.status_code==200,rollback.text
|
||||
|
||||
|
||||
def test_revision_restore_and_design_tokens(root):
|
||||
sid=site(root,'Design')
|
||||
x=root.post(f'/api/sites/{sid}/entries',json={'type':'page','title':'Draft v1','route':'/old-page/'})
|
||||
entry=x.json()['id']
|
||||
assert root.patch(f'/api/sites/{sid}/entries/{entry}',json={'title':'Draft v2'}).status_code==200
|
||||
revs=root.get(f'/api/enterprise/sites/{sid}/entries/{entry}/revisions').json()
|
||||
assert len(revs)>=1
|
||||
old=root.get(f'/api/enterprise/sites/{sid}/revisions/{revs[0]["id"]}').json()
|
||||
assert old['entry']['title']=='Draft v1'
|
||||
assert root.post(f'/api/enterprise/sites/{sid}/revisions/{revs[0]["id"]}/restore').status_code==200
|
||||
refreshed=root.get(f'/api/sites/{sid}/entries').json()
|
||||
assert any(i['title']=='Draft v1' for i in refreshed)
|
||||
invalid=root.put(f'/api/enterprise/sites/{sid}/design-tokens',json={'font':'system','radius':999})
|
||||
assert invalid.status_code==400
|
||||
payload={'font':'serif','heading_scale':1.2,'radius':18,'section_spacing':80,'container_width':1360,'button_style':'pill','header_style':'compact'}
|
||||
assert root.put(f'/api/enterprise/sites/{sid}/design-tokens',json=payload).status_code==200
|
||||
assert root.get(f'/api/enterprise/sites/{sid}/design-tokens').json()==payload
|
||||
build=root.post(f'/api/sites/{sid}/builds',json={}).json()['version']
|
||||
import pathlib
|
||||
css=(db.DATA/'builds'/sid/build/'dist'/'assets'/'custom.css').read_text(encoding='utf-8')
|
||||
assert 'Georgia' in css and '--sf-width:1360px' in css
|
||||
|
||||
|
||||
def test_background_worker_and_audit(root):
|
||||
sid=site(root,'Jobs')
|
||||
res=root.post(f'/api/enterprise/sites/{sid}/jobs',json={'kind':'build'})
|
||||
assert res.status_code==202,res.text
|
||||
assert run_once()==res.json()['id']
|
||||
jobs=root.get(f'/api/enterprise/sites/{sid}/jobs').json()
|
||||
assert jobs[0]['state']=='done'
|
||||
audits=root.get('/api/enterprise/audit').json()
|
||||
assert any(x['operation']=='POST' for x in audits)
|
||||
assert root.get('/ui/enterprise-ui.js').status_code==200
|
||||
|
||||
|
||||
def test_background_worker_retries_transient_failure(root,monkeypatch):
|
||||
sid=site(root,'Retry Jobs')
|
||||
job=root.post(f'/api/enterprise/sites/{sid}/jobs',json={'kind':'build'}).json()['id']
|
||||
monkeypatch.setenv('SITEFORGE_JOB_MAX_ATTEMPTS','3')
|
||||
monkeypatch.setattr(worker.builder,'build',lambda *_: (_ for _ in ()).throw(RuntimeError('temporary failure')))
|
||||
assert worker.run_once()==job
|
||||
queued=db.one('SELECT state,attempts,error,retry_at FROM build_jobs WHERE id=?',(job,))
|
||||
assert queued['state']=='queued' and queued['attempts']==1
|
||||
assert queued['retry_at'] and 'temporary failure' in queued['error']
|
||||
|
||||
def test_viewer_cannot_modify_or_see_other_site(root):
|
||||
own=site(root,'Site One'); foreign=site(root,'Site Two')
|
||||
user=root.post('/api/enterprise/team/users',json={'username':'viewer22','display_name':'Viewer','password':'long-viewer-password-2026'}).json()['id']
|
||||
assert root.put(f'/api/enterprise/sites/{own}/members',json={'user_id':user,'role':'viewer'}).status_code==200
|
||||
root.post('/api/auth/logout')
|
||||
root.post('/api/auth/login',json={'username':'viewer22','password':'long-viewer-password-2026'})
|
||||
root.headers['X-SiteForge-CSRF']=root.get('/api/auth/me').json()['csrf']
|
||||
assert root.get(f'/api/sites/{own}/entries').status_code==200
|
||||
assert root.get(f'/preview/{foreign}/any/').status_code==403
|
||||
assert root.post(f'/api/sites/{own}/builds',json={}).status_code==403
|
||||
assert root.put(f'/api/enterprise/sites/{own}/design-tokens',json={}).status_code==403
|
||||
assert root.get(f'/api/enterprise/sites/{own}/jobs').status_code==200
|
||||
assert root.get(f'/api/enterprise/sites/{foreign}/jobs').status_code==403
|
||||
@@ -0,0 +1,269 @@
|
||||
import io
|
||||
import json
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db
|
||||
from app.main import app
|
||||
from app.core import route_path,ensure_public_url
|
||||
|
||||
@pytest.fixture
|
||||
def client(tmp_path,monkeypatch):
|
||||
monkeypatch.setattr(db,'DATA',tmp_path)
|
||||
monkeypatch.setattr(db,'DB_FILE',tmp_path/'test.sqlite3')
|
||||
monkeypatch.setenv('SITEFORGE_ADMIN_PASSWORD','strong-supervisor-password-2026')
|
||||
monkeypatch.setenv('SITEFORGE_SESSION_SECRET','large-random-key-for-test-only-2026-the-test-environment')
|
||||
with TestClient(app) as c:
|
||||
assert c.get('/api/sites').status_code == 401
|
||||
assert c.post('/api/auth/login',json={'password':'strong-supervisor-password-2026'}).status_code == 200
|
||||
auth=c.get('/api/auth/me').json()
|
||||
c.headers.update({'X-SiteForge-CSRF': auth['csrf']})
|
||||
yield c
|
||||
|
||||
def create(c,theme='automotive'):
|
||||
r=c.post('/api/sites',json={'name':'Example Test','theme':theme,'domain':'https://example.test','mode':'migration'})
|
||||
assert r.status_code==201,r.text
|
||||
return r.json()['id']
|
||||
|
||||
def test_site_and_theme_switch_preserve_content(client):
|
||||
a=create(client)
|
||||
b=create(client,'industrial')
|
||||
payload={'type':'product','title':'Product A','route':'/products/legacy.html','seo_title':'Original SEO Title','seo_description':'Original SEO Description','body':'<p>Hello <strong>world</strong></p>'}
|
||||
x=client.post(f'/api/sites/{a}/entries',json=payload)
|
||||
assert x.status_code==201,x.text
|
||||
assert len(client.get(f'/api/sites/{b}/entries').json())==1
|
||||
for theme in ('automotive','industrial','corporate','minimal'):
|
||||
assert client.patch(f'/api/sites/{a}',json={'theme':theme}).status_code==200
|
||||
info=client.post(f'/api/sites/{a}/builds',json={'mirror_images':False})
|
||||
assert info.status_code==200,info.text
|
||||
bid=info.json()['version']
|
||||
resp=client.get(f'/preview/{a}/{bid}/products/legacy.html')
|
||||
assert resp.status_code==200,resp.text[:150]
|
||||
assert '<title>Original SEO Title</title>' in resp.text
|
||||
assert '<link rel="canonical" href="https://example.test/products/legacy.html">' in resp.text
|
||||
assert 'Original SEO Description' in resp.text
|
||||
assert 'Hello' in resp.text
|
||||
dl=client.get(f'/api/sites/{a}/builds/{bid}/download')
|
||||
with zipfile.ZipFile(io.BytesIO(dl.content)) as z:
|
||||
assert 'products/legacy.html' in z.namelist()
|
||||
assert 'assets/theme.css' in z.namelist()
|
||||
assert 'sitemap.xml' in z.namelist()
|
||||
assert len(client.get(f'/api/sites/{a}/entries').json())==2
|
||||
|
||||
def test_home_blueprint_and_static_links(client):
|
||||
sid=create(client)
|
||||
pages=client.get(f'/api/sites/{sid}/entries').json()
|
||||
home=pages[0]
|
||||
assert home['route']=='/'
|
||||
meta={'blocks':[{'type':'features','title':'优势清单','items':[{'title':'服务能力','text':'广泛覆盖'}]},
|
||||
{'type':'cta','title':'马上合作','href':'/contact/'}]}
|
||||
assert client.patch(f'/api/sites/{sid}/entries/{home["id"]}',json={'meta':meta}).status_code==200
|
||||
r=client.post(f'/api/sites/{sid}/builds',json={})
|
||||
bid=r.json()['version']
|
||||
txt=client.get(f'/preview/{sid}/{bid}/').text
|
||||
assert '优势清单' in txt and '马上合作' in txt
|
||||
|
||||
def test_duplicate_routes_and_unsafe_html(client):
|
||||
sid=create(client)
|
||||
x=client.post(f'/api/sites/{sid}/entries',json={'type':'page','title':'X','route':'/test/','body':'<script>alert(1)</script><a href="javascript:alert(1)">x</a>'})
|
||||
assert x.status_code==201
|
||||
y=client.post(f'/api/sites/{sid}/entries',json={'type':'page','title':'Y','route':'/test/'})
|
||||
assert y.status_code==400
|
||||
z=client.get(f'/api/sites/{sid}/entries').json()
|
||||
it=next(x for x in z if x['route']=='/test/')
|
||||
assert '<script>' not in it['body']
|
||||
assert 'href="javascript:' not in it['body']
|
||||
assert client.post(f'/api/sites/{sid}/entries',json={'type':'page','title':'unsafe','route':'/x/../y/'}).status_code==400
|
||||
|
||||
def test_data_import_and_presets(client):
|
||||
sid=create(client)
|
||||
csv_data=b'type,title,route,seo_title\nproduct,Vehicle 1,/cars/vehicle-1/,Vehicle One SEO\nproduct,Vehicle 2,/cars/vehicle-2/,Vehicle Two SEO\n'
|
||||
response=client.post(f'/api/sites/{sid}/import',data={'on_conflict':'skip'},files={'file':('cars.csv',csv_data,'text/csv')})
|
||||
assert response.status_code==200,response.text
|
||||
assert response.json()['imported']==2
|
||||
r2=client.post(f'/api/sites/{sid}/import',data={'on_conflict':'skip'},files={'file':('cars.csv',csv_data,'text/csv')})
|
||||
assert r2.json()['skipped']==2
|
||||
save=client.post(f'/api/sites/{sid}/save-preset',json={'name':'My Master Theme'})
|
||||
assert save.status_code==200
|
||||
reuse=client.post('/api/sites',json={'name':'New from Preset','preset_id':save.json()['id']})
|
||||
assert reuse.status_code==201
|
||||
assert reuse.json()['theme']=='automotive'
|
||||
|
||||
def test_wxr_legacy_urls_meta(client):
|
||||
sid=create(client)
|
||||
wxr=b'''<?xml version="1.0" encoding="utf-8"?><rss xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:wp="http://wordpress.org/export/1.2/" version="2.0"><channel><item>
|
||||
<title>News</title><link>https://old.example.org/news/article-01.html</link>
|
||||
<wp:post_type>post</wp:post_type><wp:status>publish</wp:status><wp:post_name>article-01</wp:post_name>
|
||||
<content:encoded><![CDATA[<h2>Article</h2><p>WordPress body</p>]]></content:encoded>
|
||||
<wp:postmeta><wp:meta_key>_yoast_wpseo_title</wp:meta_key><wp:meta_value>Exact SEO Title</wp:meta_value></wp:postmeta>
|
||||
</item></channel></rss>'''
|
||||
response=client.post(f'/api/sites/{sid}/import',files={'file':('wordpress.xml',wxr,'application/xml')},data={'on_conflict':'skip'})
|
||||
assert response.status_code==200,response.text
|
||||
arr=client.get(f'/api/sites/{sid}/entries').json()
|
||||
match=next(p for p in arr if p['title']=='News')
|
||||
assert match['route']=='/news/article-01.html'
|
||||
assert match['seo_title']=='Exact SEO Title'
|
||||
assert 'WordPress body' in match['body']
|
||||
|
||||
def test_media_archive(client):
|
||||
sid=create(client)
|
||||
buffer=io.BytesIO()
|
||||
with zipfile.ZipFile(buffer,'w') as zipf:
|
||||
zipf.writestr('wp-content/uploads/2024/01/product.jpg',b'\xff\xd8\xff\xd9')
|
||||
zipf.writestr('../bad.php',b'evil')
|
||||
buffer.seek(0)
|
||||
r=client.post(f'/api/sites/{sid}/media-archive',files={'file':('uploads.zip',buffer.getvalue(),'application/zip')})
|
||||
assert r.status_code==200,r.text
|
||||
assert r.json()['imported']==1
|
||||
built=client.post(f'/api/sites/{sid}/builds',json={})
|
||||
assert built.status_code==200
|
||||
z=client.get(f'/api/sites/{sid}/builds/{built.json()["version"]}/download')
|
||||
with zipfile.ZipFile(io.BytesIO(z.content)) as archive:
|
||||
assert 'wp-content/uploads/2024/01/product.jpg' in archive.namelist()
|
||||
assert not any('bad.php' in s for s in archive.namelist())
|
||||
|
||||
def test_redirect_and_ssrf_reject(client):
|
||||
sid=create(client)
|
||||
assert client.post(f'/api/sites/{sid}/redirects',json={'old_path':'/old.html','new_path':'/new/','status_code':301}).status_code==200
|
||||
r=client.post(f'/api/sites/{sid}/builds',json={})
|
||||
assert r.status_code==200
|
||||
assert len(client.get(f'/api/sites/{sid}/redirects').json())==1
|
||||
bad=client.post(f'/api/sites/{sid}/crawl',json={'url':'http://127.0.0.1:8787/'})
|
||||
assert bad.status_code==400
|
||||
bad=client.post(f'/api/sites/{sid}/crawl',json={'url':'file:///etc/passwd'})
|
||||
assert bad.status_code==400
|
||||
|
||||
|
||||
def test_login_and_csrf_enforcement(client):
|
||||
session=client.cookies.get('siteforge_session')
|
||||
assert session
|
||||
me=client.get('/api/auth/me')
|
||||
assert me.json()['version']=='5.0.0'
|
||||
assert me.headers['x-frame-options']=='SAMEORIGIN'
|
||||
assert "frame-ancestors 'self'" in me.headers['content-security-policy']
|
||||
assert 'camera=()' in me.headers['permissions-policy']
|
||||
original=client.headers.pop('X-SiteForge-CSRF')
|
||||
assert client.post('/api/sites',json={'name':'Blocked'}).status_code == 403
|
||||
client.headers['X-SiteForge-CSRF']=original
|
||||
assert client.get('/api/sites').status_code == 200
|
||||
assert client.post('/api/auth/logout').status_code == 200
|
||||
assert client.get('/api/sites').status_code == 401
|
||||
assert client.get('/').status_code in (200, 303) # TestClient follows /login
|
||||
assert '管理员密码' in client.get('/login').text
|
||||
|
||||
|
||||
def test_health_uses_current_application_version(client):
|
||||
client.cookies.clear()
|
||||
assert client.get('/api/health').json()=={'status':'ok','name':'SiteForge','version':'5.0.0'}
|
||||
|
||||
def test_build_download_requires_login(client):
|
||||
sid=create(client)
|
||||
result=client.post(f'/api/sites/{sid}/builds',json={}).json()
|
||||
bid=result['version']
|
||||
ok=client.get(f'/api/sites/{sid}/builds/{bid}/download')
|
||||
assert ok.status_code==200
|
||||
client.cookies.clear()
|
||||
assert client.get(f'/api/sites/{sid}/builds/{bid}/download').status_code==401
|
||||
assert '/login' in client.get(f'/preview/{sid}/{bid}/').url.path
|
||||
|
||||
def test_visual_blocks_auto_bind_save_build_and_site_isolation(client):
|
||||
sid=create(client,'corporate')
|
||||
other=create(client,'minimal')
|
||||
prod=client.post(f'/api/sites/{sid}/entries',json={'type':'product','title':'CUSTOM SUV 01','route':'/autos/custom-01/','summary':'Quality example SUV'}).json()
|
||||
homepage=next(x for x in client.get(f'/api/sites/{sid}/entries').json() if x['route']=='/')
|
||||
blocks=[
|
||||
{'id':'hero-block-1','type':'hero','title':'{{site.name}}','subtitle':'WELCOME','description':'{{site.tagline}}','button':'汽车列表','href':'/product/'},
|
||||
{'id':'products-1','type':'collection','title':'车型推荐','source':'product','limit':6,'columns':3},
|
||||
{'id':'stats-block-1','type':'stats','title':'统计数据','items':[{'value':'3,000+','label':'全球交付'}]}
|
||||
]
|
||||
save=client.put(f'/api/sites/{sid}/pages/{homepage["id"]}/blueprint',json={'blocks':blocks,'page_template':'home'})
|
||||
assert save.status_code==200,save.text
|
||||
preview=client.post(f'/api/sites/{sid}/pages/{homepage["id"]}/editor-preview',json={'blocks':blocks,'page_template':'home'})
|
||||
assert preview.status_code==200
|
||||
assert 'CUSTOM SUV 01' in preview.text and '车型推荐' in preview.text
|
||||
assert 'AUTOS' not in preview.text
|
||||
resp=client.post(f'/api/sites/{sid}/builds',json={})
|
||||
assert resp.status_code==200,resp.text
|
||||
assert resp.json()['blueprint_blocks']>=3
|
||||
html=client.get(f'/preview/{sid}/{resp.json()["version"]}/').text
|
||||
assert 'CUSTOM SUV 01' in html and '车型推荐' in html
|
||||
assert 'assets/blocks.css' in html
|
||||
assert client.get(f'/api/sites/{other}/pages/{homepage["id"]}/blueprint').status_code==404
|
||||
assert client.get(f'/editor/{sid}/{homepage["id"]}').status_code==200
|
||||
|
||||
|
||||
def test_schema_navigation_and_preset(client):
|
||||
sid=create(client)
|
||||
fields=[{'name':'model_code','label':'产品编号','type':'text'},{'name':'horsepower','label':'马力','type':'number'}]
|
||||
r=client.put(f'/api/sites/{sid}/schemas/product',json=fields)
|
||||
assert r.status_code==200,r.text
|
||||
assert client.get(f'/api/sites/{sid}/schemas').json()['product']==fields
|
||||
assert client.put(f'/api/sites/{sid}/schemas/product',json=[{'name':'../not_safe','type':'text'}]).status_code==400
|
||||
nav=[{'title':'首页','route':'/'},{'title':'车型','route':'/product/'}]
|
||||
assert client.put(f'/api/sites/{sid}/navigation',json=nav).status_code==200
|
||||
assert client.get(f'/api/sites/{sid}').json()['settings']['nav']==nav
|
||||
homepage=next(x for x in client.get(f'/api/sites/{sid}/entries').json() if x['route']=='/')
|
||||
r=client.post(f'/api/sites/{sid}/pages/{homepage["id"]}/blueprint/preset',json={'page_template':'about'})
|
||||
assert r.status_code==200
|
||||
assert len(r.json()['blocks'])==3
|
||||
assert client.get('/api/themes').json()[0]['page_templates']
|
||||
|
||||
|
||||
def test_blocks_validation_escaping_and_disabled(client):
|
||||
sid=create(client)
|
||||
homepage=next(x for x in client.get(f'/api/sites/{sid}/entries').json() if x['route']=='/')
|
||||
blocks=[{'id':'dangerous-x','type':'hero','title':'<img src=x onerror=alert(1)>','image':'javascript:alert(1)','href':'javascript:alert(1)'},
|
||||
{'id':'hidden-one','type':'richtext','title':'should be hidden','body':'<script>alert(1)</script>','visible':False}]
|
||||
res=client.post(f'/api/sites/{sid}/pages/{homepage["id"]}/editor-preview',json={'blocks':blocks,'page_template':'home'})
|
||||
assert res.status_code==200,res.text
|
||||
assert 'onerror=alert' in res.text # text escaped, never a literal img tag
|
||||
assert '<img src=x onerror' in res.text
|
||||
assert 'href="/"' in res.text or 'href="./"' in res.text
|
||||
assert 'should be hidden' not in res.text
|
||||
assert client.put(f'/api/sites/{sid}/pages/{homepage["id"]}/blueprint',json={'blocks':[{'id':'bad-id','type':'script','title':'Bad'}],'page_template':'home'}).status_code==400
|
||||
|
||||
def test_bulk_theme_assembly_preserves_legacy_url_and_seo(client):
|
||||
sid=create(client,'industrial')
|
||||
resp=client.post(f'/api/sites/{sid}/entries',json={'type':'product','title':'HDPE Pipe','route':'/old-product/hdpe-pipe.html','body':'<h2>Legacy Technical Details</h2>','seo_title':'HDPE pipe SEO forever','seo_description':'Exact old metadata'})
|
||||
assert resp.status_code==201
|
||||
entry=resp.json()
|
||||
assert not entry['meta'].get('blocks')
|
||||
done=client.post(f'/api/sites/{sid}/apply-theme',json={'mode':'missing'})
|
||||
assert done.status_code==200,done.text
|
||||
assert done.json()['updated']>=1
|
||||
updated=client.get(f'/api/sites/{sid}/entries').json()
|
||||
final=next(x for x in updated if x['id']==entry['id'])
|
||||
assert final['route']=='/old-product/hdpe-pipe.html'
|
||||
assert final['seo_title']=='HDPE pipe SEO forever'
|
||||
assert len(final['meta']['blocks'])>=2
|
||||
built=client.post(f'/api/sites/{sid}/builds',json={})
|
||||
assert built.status_code==200,built.text
|
||||
html=client.get(f'/preview/{sid}/{built.json()["version"]}/old-product/hdpe-pipe.html').text
|
||||
assert 'Legacy Technical Details' in html
|
||||
assert '<title>HDPE pipe SEO forever</title>' in html
|
||||
|
||||
def test_scaffold_standard_pages_safe_idempotent(client):
|
||||
sid=create(client)
|
||||
first=client.post(f'/api/sites/{sid}/scaffold')
|
||||
assert first.status_code==200
|
||||
assert '/about/' in first.json()['created']
|
||||
second=client.post(f'/api/sites/{sid}/scaffold')
|
||||
assert second.status_code==200 and not second.json()['created']
|
||||
lst=client.get(f'/api/sites/{sid}/entries').json()
|
||||
assert len([x for x in lst if x['route']=='/about/'])==1
|
||||
build=client.post(f'/api/sites/{sid}/builds',json={})
|
||||
assert build.status_code==200,build.text
|
||||
|
||||
def test_attributes_bind_imported_and_typed_fields(client):
|
||||
sid=create(client,'automotive')
|
||||
product=client.post(f'/api/sites/{sid}/entries',json={'type':'product','title':'BYD SUV','route':'/product/byd-suv/','meta':{'fields':{'brand':'BYD','model':'SUV 1','specs':'Electric'}}})
|
||||
assert product.status_code==201
|
||||
eid=product.json()['id']
|
||||
blocks=[{'id':'attributes-1','type':'attributes','title':'Technical Parameters'}]
|
||||
save=client.put(f'/api/sites/{sid}/pages/{eid}/blueprint',json={'blocks':blocks,'page_template':'product'})
|
||||
assert save.status_code==200,save.text
|
||||
b=client.post(f'/api/sites/{sid}/builds',json={})
|
||||
assert b.status_code==200,b.text
|
||||
page=client.get(f'/preview/{sid}/{b.json()["version"]}/product/byd-suv/').text
|
||||
assert 'Technical Parameters' in page and '>BYD<' in page and 'SUV 1' in page
|
||||
@@ -0,0 +1,160 @@
|
||||
import io,json,zipfile
|
||||
import pytest
|
||||
from fastapi.testclient import 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_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(encoding='utf-8'));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 and '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-inline-editor' 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
|
||||
@@ -0,0 +1,122 @@
|
||||
"""Professional theme system integration tests use the real FastAPI and static builder."""
|
||||
import io
|
||||
import json
|
||||
import re
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from bs4 import BeautifulSoup
|
||||
from test_factory_v2 import factory, site, zip_theme
|
||||
|
||||
THEMES=('automotive','industrial','corporate','minimal')
|
||||
|
||||
def test_four_complete_theme_families_have_distinct_markup(factory):
|
||||
main=[]
|
||||
for theme in THEMES:
|
||||
for page_type in ('home','about','contact','listing','product','article','brand','standard','notfound'):
|
||||
r=factory.get(f'/api/factory/themes/{theme}/preview',params={'page_type':page_type})
|
||||
assert r.status_code==200,(theme,page_type,r.text[:400])
|
||||
soup=BeautifulSoup(r.text,'html.parser')
|
||||
assert soup.find('header',class_='pro-header')
|
||||
assert soup.find('footer',class_='pro-footer')
|
||||
assert len(soup.find_all('h1'))==1,(theme,page_type,len(soup.find_all('h1')))
|
||||
assert 'pro-base.css' in r.text and 'pro.js' in r.text
|
||||
if page_type=='product':assert soup.select_one('.pro-product-layout .pro-specs')
|
||||
if page_type=='listing':assert soup.select_one('[data-pro-filter-search]')
|
||||
if page_type=='article':assert soup.select_one('.pro-article-hero')
|
||||
main.append(factory.get(f'/api/factory/themes/{theme}/preview?page_type=home').text)
|
||||
assert len(set(re.findall(r'class="pro-home-hero [^"]+"',s)[0] for s in main))==4
|
||||
|
||||
|
||||
def test_theme_build_derived_category_brand_search_and_seo(factory):
|
||||
sid=site(factory)
|
||||
assert factory.post(f'/api/factory/sites/{sid}/bootstrap').status_code==200
|
||||
r=factory.post(f'/api/sites/{sid}/entries',json={
|
||||
'type':'product','title':'Electric A','route':'/old/product-a.html',
|
||||
'summary':'Long-range sample','seo_title':'Keep Me SEO','seo_description':'Original description',
|
||||
'meta':{'fields':{'brand':'Quanta Motors','category':'SUV','range':'500 km'}}})
|
||||
assert r.status_code==201,r.text
|
||||
for theme in THEMES:
|
||||
selected=factory.put(f'/api/factory/sites/{sid}/theme',json={'theme':theme,'version':'1.0.0'})
|
||||
assert selected.status_code==200,selected.text
|
||||
result=factory.post(f'/api/sites/{sid}/builds',json={})
|
||||
assert result.status_code==200,result.text
|
||||
bid=result.json()['version']
|
||||
for url in ['/','/about/','/contact/','/products/','/brands/','/news/','/cases/','/categories/suv/','/brands/quanta-motors/','/search/','/old/product-a.html']:
|
||||
page=factory.get(f'/preview/{sid}/{bid}{url}')
|
||||
assert page.status_code==200,(theme,url,page.text[:200])
|
||||
soup=BeautifulSoup(page.text,'html.parser')
|
||||
assert len(soup.find_all('h1'))==1,(theme,url)
|
||||
assert soup.select_one('.pro-header') and soup.select_one('.pro-footer')
|
||||
product=factory.get(f'/preview/{sid}/{bid}/old/product-a.html').text
|
||||
assert '<title>Keep Me SEO</title>' in product
|
||||
assert 'range' in product.lower() and '500 km' in product
|
||||
assert 'https://test.example/old/product-a.html' in product
|
||||
listing=factory.get(f'/preview/{sid}/{bid}/categories/suv/').text
|
||||
assert 'Electric A' in listing
|
||||
theme_css=factory.get(f'/preview/{sid}/{bid}/assets/pro.css')
|
||||
assert theme_css.status_code==200 and len(theme_css.content)>2000
|
||||
report=factory.get(f'/api/sites/{sid}/builds/{bid}/report').json()
|
||||
assert not report['errors'] and not report['broken_links'] and not report['missing_local_images']
|
||||
|
||||
|
||||
def test_builtin_version_pin_is_real(factory):
|
||||
sid=site(factory)
|
||||
versions=factory.get('/api/factory/themes/automotive/versions').json()
|
||||
assert {v['version'] for v in versions}=={'1.1.0','1.0.0','0.3.0'}
|
||||
assert factory.put(f'/api/factory/sites/{sid}/theme',json={'theme':'automotive','version':'0.3.0'}).status_code==200
|
||||
b=factory.post(f'/api/sites/{sid}/builds',json={}).json()
|
||||
old=factory.get(f'/preview/{sid}/{b["version"]}/').text
|
||||
assert 'pro-auto-hero' not in old
|
||||
assert factory.put(f'/api/factory/sites/{sid}/theme',json={'theme':'automotive','version':'1.0.0'}).status_code==200
|
||||
b=factory.post(f'/api/sites/{sid}/builds',json={}).json()
|
||||
new=factory.get(f'/preview/{sid}/{b["version"]}/').text
|
||||
assert 'pro-auto-hero' in new
|
||||
|
||||
|
||||
def test_custom_theme_package_uses_trusted_layout_and_css(factory):
|
||||
sid=site(factory)
|
||||
raw=io.BytesIO(zip_theme('custom-v3','2.0.0'))
|
||||
data=io.BytesIO()
|
||||
with zipfile.ZipFile(raw) as old,zipfile.ZipFile(data,'w') as new:
|
||||
for fn in old.namelist():
|
||||
content=old.read(fn)
|
||||
if fn=='manifest.json':
|
||||
obj=json.loads(content);obj['layout_family']='industrial'
|
||||
content=json.dumps(obj).encode()
|
||||
if fn=='theme.css':content=b':root{--pro-accent:#dd5500} .pro-card{border-width:2px}'
|
||||
new.writestr(fn,content)
|
||||
install=factory.post('/api/factory/themes/install',files={'file':('theme.zip',data.getvalue(),'application/zip')})
|
||||
assert install.status_code==200,install.text
|
||||
assert factory.put(f'/api/factory/sites/{sid}/theme',json={'theme':'custom-v3','version':'2.0.0'}).status_code==200
|
||||
b=factory.post(f'/api/sites/{sid}/builds',json={}).json()
|
||||
assert 'pro-industrial-hero' in factory.get(f'/preview/{sid}/{b["version"]}/').text
|
||||
css=factory.get(f'/preview/{sid}/{b["version"]}/assets/theme-overrides.css').text
|
||||
assert '--pro-accent:#dd5500' in css
|
||||
pkg=factory.get('/api/factory/themes/custom-v3/package?version=2.0.0')
|
||||
assert pkg.status_code==200
|
||||
with zipfile.ZipFile(io.BytesIO(pkg.content)) as z:
|
||||
assert {'manifest.json','theme.css','page-presets.json'}<=set(z.namelist())
|
||||
|
||||
|
||||
def test_static_responsive_and_search_facilities(factory):
|
||||
sid=site(factory)
|
||||
b=factory.post(f'/api/sites/{sid}/builds',json={}).json()
|
||||
bid=b['version']
|
||||
css=factory.get(f'/preview/{sid}/{bid}/assets/pro-base.css').text
|
||||
js=factory.get(f'/preview/{sid}/{bid}/assets/pro.js').text
|
||||
assert '@media(max-width:560px)' in css
|
||||
assert 'aria-expanded' in js and 'data-pro-filter-brand' in js
|
||||
assert 'search-index.json' in factory.get(f'/preview/{sid}/{bid}/assets/site-runtime.js').text
|
||||
assert 'noindex' in factory.get(f'/preview/{sid}/{bid}/search/').text
|
||||
|
||||
|
||||
def test_all_fifty_six_page_templates_and_live_gallery(factory):
|
||||
base=Path(__file__).resolve().parent.parent/'themes'
|
||||
expected={'home','about','contact','listing','category','product','brand','article','case','service','faq','standard','search','notfound'}
|
||||
for theme in THEMES:
|
||||
assert {p.stem for p in (base/theme/'pages').glob('*.html')}==expected
|
||||
for page_type in ('case','service','faq','category'):
|
||||
r=factory.get(f'/api/factory/themes/{theme}/preview?page_type={page_type}')
|
||||
assert r.status_code==200,(theme,page_type,r.text[:300])
|
||||
page=factory.get('/theme-gallery')
|
||||
assert page.status_code==200 and 'iframe' in page.text and 'page_type' in page.text
|
||||
Reference in New Issue
Block a user