chore: baseline V8.10.0-dev.1 with v8_workflows type-mapping fix and trade-pro spec
- SiteForge Enterprise V8.10.0-dev.1 upstream source (210 tests) - Fix: v8_workflows._mapped_input now reads both 类型/分类 columns so WXR imports keep post/product types instead of collapsing to page - Docs: trade-pro theme port spec (R3) and implementation plan Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,260 @@
|
||||
import io
|
||||
import json
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
import pytest
|
||||
from tests.legacy import LegacyTestClient as 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_ALLOW_LEGACY_DIRECT_DESIGN_WRITE','1')
|
||||
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
|
||||
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_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
|
||||
Reference in New Issue
Block a user