Files

261 lines
15 KiB
Python
Raw Permalink Normal View History

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 '&lt;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