3c57c167f2
导入 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>
270 lines
15 KiB
Python
270 lines
15 KiB
Python
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
|