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:
浅念
2026-10-22 16:49:25 +08:00
commit 8cea7dee20
354 changed files with 20736 additions and 0 deletions
View File
+24
View File
@@ -0,0 +1,24 @@
"""Legacy tests assert a synchronous response. Adapt ONLY tests via the actual worker.
Production /builds always returns HTTP 202. This testing adapter submits to the
real queue, drains it through the worker executor and returns the legacy report.
It must never be imported from app/ or the production frontend.
"""
import json
import re
from fastapi.testclient import TestClient
from httpx import Response
from app import builder,db,worker
class LegacyTestClient(TestClient):
def post(self,url,*args,**kwargs):
r=super().post(url,*args,**kwargs)
if r.status_code==202 and re.fullmatch(r'/api/sites/[^/]+/builds',str(url)):
result=r.json()
job=result['id']
worker.run_once(executor=lambda sid,opts: builder.build(sid,bool(opts.get('mirror_images',False))))
record=db.one('SELECT * FROM build_jobs WHERE id=?',(job,))
if record['state']=='done':
return Response(200,json=json.loads(record['result_json']),request=r.request)
return Response(400,json={'detail':record['error']},request=r.request)
return r
+77
View File
@@ -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().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()
+205
View File
@@ -0,0 +1,205 @@
"""Deterministic V8.4 XML and public API connector regression fixtures."""
import json
from urllib.parse import parse_qs,urlsplit
import pytest
from fastapi import HTTPException
from fastapi.testclient import TestClient
from app import db,enterprise,v8_workflows,jobs,connectors_v84 as conn
from app.core import uid
from app.main import app
from app.safe_http import Fetched
@pytest.fixture
def env(tmp_path,monkeypatch):
monkeypatch.setattr(db,'DATA',tmp_path)
monkeypatch.setattr(db,'DB_FILE',tmp_path/'siteforge.sqlite3')
monkeypatch.setenv('SITEFORGE_ADMIN_PASSWORD','v84-unit-password-strong')
monkeypatch.setenv('SITEFORGE_SESSION_SECRET','v84-unit-secret-long-and-private-test-only')
db.init();enterprise.setup();v8_workflows.setup();conn.setup()
def site():
sid=uid();now=db.now()
db.insert('INSERT INTO sites(id,name,theme,mode,created_at,updated_at) VALUES(?,?,?,?,?,?)',
(sid,'Connector Test','automotive','new',now,now))
return sid
return site
def product_xml():
return b'<catalog><item><title>Pickup A</title><category><name>Trucks</name></category></item><item><title>Pickup B</title><category><name>SUV</name></category></item></catalog>'
def test_xml_flat_two_level_fields_and_mapping(env):
site=env()
rows=conn.parse_xml_records(product_xml(),'item')
assert rows==[{'title':'Pickup A','category.name':'Trucks'},{'title':'Pickup B','category.name':'SUV'}]
batch=v8_workflows.stage(site,rows,{'title':'title','category':'category.name'},'product','xml:catalog')
assert batch['mapped']==2
saved=v8_workflows.commit(site,batch['batch_id'])
assert saved['created']==2
meta=json.loads(db.one('SELECT meta_json FROM entries WHERE site_id=? AND title=?',(site,'Pickup A'))['meta_json'])
assert meta['fields']['category']=='Trucks'
assert v8_workflows.rollback(site,batch['batch_id'])['state']=='rolled_back'
def test_xml_blocks_external_entities_and_expansion():
for sample in [b'<!DOCTYPE foo [<!ENTITY exploit SYSTEM "file:///etc/passwd">]><foo><item><title>&exploit;</title></item></foo>',
b'<!DOCTYPE lolz [<!ENTITY a "9999">]><foo><item><title>&a;</title></item></foo>']:
with pytest.raises(ValueError):conn.parse_xml_records(sample,'item')
def test_xml_deep_large_duplicate_and_bad_shape():
with pytest.raises(ValueError):conn.parse_xml_records(b'<a>'+b'<a>'*11+b'<item/>'+b'</a>'*11+b'</a>','item')
with pytest.raises(ValueError):conn.parse_xml_records(b'<root><item><title>x</title><title>y</title></item></root>','item')
with pytest.raises(ValueError):conn.parse_xml_records(b'<root><item><a><b><c>x</c></b></a></item></root>','item')
with pytest.raises(ValueError):conn.parse_xml_records(b'<!DOCTYPE catalog><catalog><item><title>A</title></item></catalog>','item')
with pytest.raises(ValueError):conn.parse_xml_records(b'<root/>','item')
with pytest.raises(ValueError):conn.parse_xml_records(b' ', '../item')
with pytest.raises(ValueError):conn.parse_xml_records(b'x'*(conn.MAX_XML_BYTES+1),'item')
def test_api_public_https_guard(env):
site=env()
for u in ['http://api.example.com/a','https://admin:pw@api.example.com/a',
'https://127.0.0.1/items','https://api.example.com/a?access_token=foo',
'https://api.example.com:8443/a','https://api.example.com/a#frag']:
with pytest.raises(ValueError):conn.create_api_source(site,{'url':u})
with pytest.raises(ValueError):conn.create_api_source(site,{'url':'https://api.example.com/items','page_size':500})
with pytest.raises(ValueError):conn.create_api_source(site,{'url':'https://api.example.com/items','items_path':'data[0]'})
def fake_http(monkeypatch):
requested=[]
def fetch(url,**kwargs):
requested.append((url,kwargs))
q=parse_qs(urlsplit(url).query)
page=int(q['page'][0]);assert q['per_page']==['2']
if page==1:rows=[{'title':'Alpha','brand':'Brand A','id':100},{'title':'Beta','brand':'Brand B','id':101}]
else:rows=[]
return Fetched(url,200,{'content-type':'application/json; charset=utf-8'},
json.dumps({'data':{'items':rows}}).encode())
monkeypatch.setattr(conn.safe_http,'fetch',fetch)
return requested
def create(env):
site=env()
source=conn.create_api_source(site,{'url':'https://api.example.com/v1/products?lang=en',
'items_path':'data.items','page_size':2,'max_pages':10,
'mapping':{'title':'title','brand':'brand'}})
return site,source['id']
def test_api_scan_page_cursor_and_reuse_existing_batch(env,monkeypatch):
site,source=create(env);calls=fake_http(monkeypatch)
first=conn.scan_page(site,source)
assert first['count']==2 and first['state']=='ready'
second=conn.scan_page(site,source)
assert second['count']==0 and second['state']=='completed'
assert len(calls)==2
assert all(x[1]['redirects']==0 for x in calls)
assert conn.run_scan(site,source)['staged_count']==2
assert len(conn.status(site,source)['batches'])==2
bid=first['batch_id'];res=v8_workflows.commit(site,bid)
assert res['created']==2
assert v8_workflows.commit(site,bid)['existing']
def test_api_worker_enqueue_dedup_and_cancel(env):
site,source=create(env)
first=conn.submit_scan(site,source,'test-worker')
again=conn.submit_scan(site,source,'test-worker')
assert again['existing'] and again['id']==first['id']
assert db.one('SELECT kind FROM build_jobs WHERE id=?',(first['id'],))['kind']=='api_scan'
jobs.cancel(site,first['id'])
assert db.one('SELECT state FROM build_jobs WHERE id=?',(first['id'],))['state']=='cancelled'
def test_api_retry_safe_after_partial_stage(env,monkeypatch):
site,source=create(env)
origin=f'api:{source}:page:1'
pending=v8_workflows.stage(site,[{'title':'Alpha','brand':'Brand A'}],{'title':'title','brand':'brand'},'product',origin)
def should_not_request(*a,**k):raise AssertionError('Duplicate HTTP download')
monkeypatch.setattr(conn.safe_http,'fetch',should_not_request)
first=conn.scan_page(site,source)
assert first['batch_id']==pending['batch_id']
assert conn.status(site,source)['staged_count']==1
assert len(db.rows('SELECT id FROM v8_import_batches WHERE origin=?',(origin,)))==1
def test_api_limit_reached_requires_manual_attention(env,monkeypatch):
site=env()
s=conn.create_api_source(site,{'url':'https://api.example.com/products','items_path':'data.items','page_size':2,'max_pages':1,'mapping':{'title':'title'}})['id']
fake_http(monkeypatch)
state=conn.run_scan(site,s)
assert state['state']=='limit_reached'
assert conn.submit_scan(site,s,'test')['state']=='limit_reached'
def test_api_network_failure_does_not_move_cursor(env,monkeypatch):
site,source=create(env)
monkeypatch.setattr(conn.safe_http,'fetch',lambda *a,**k:(_ for _ in ()).throw(ConnectionError('network lost')))
with pytest.raises(ConnectionError):conn.scan_page(site,source)
assert conn.status(site,source)['next_page']==1
assert not conn.status(site,source)['batches']
def test_api_json_schema_and_bad_mime(env,monkeypatch):
site,source=create(env)
monkeypatch.setattr(conn.safe_http,'fetch',lambda url,**kw:Fetched(url,200,{'content-type':'text/html'},b'hi'))
with pytest.raises(ValueError):conn.scan_page(site,source)
monkeypatch.setattr(conn.safe_http,'fetch',lambda url,**kw:Fetched(url,200,{'content-type':'application/json'},b'{"data":{"items":{}}}'))
with pytest.raises(ValueError):conn.scan_page(site,source)
def test_api_site_isolation(env,monkeypatch):
site,source=create(env)
other=env()
with pytest.raises(HTTPException) as err:conn.status(other,source)
assert err.value.status_code==404
with pytest.raises(HTTPException):conn.submit_scan(other,source,'someone')
def test_xml_and_api_authenticated_ui(env):
site=env()
with TestClient(app) as c:
route=f'/api/connectors/sites/{site}/api-sources'
assert c.get(route).status_code==401
assert c.post(route,json={'url':'https://api.example.com/items'}).status_code==401
assert c.post('/api/auth/login',json={'password':'v84-unit-password-strong'}).status_code==200
c.headers['X-SiteForge-CSRF']=c.get('/api/auth/me').json()['csrf']
source=c.post(route,json={'url':'https://api.example.com/items','items_path':'items','page_size':2})
assert source.status_code==200,source.text
assert c.get(route).json()['items'][0]['id']==source.json()['id']
xml=b'<catalog><item><title>ABC Model</title></item></catalog>'
posted=c.post(f'/api/connectors/sites/{site}/xml/staging',
files={'file':('products.xml',xml,'application/xml')},data={'record_tag':'item','content_type':'product'})
assert posted.status_code==200,posted.text
assert posted.json()['mapped']==1
assert 'apiCreate' in c.get('/production-v8').text
assert c.post(route,json={'url':'http://localhost/test'}).status_code==400
def test_worker_api_task_finish_and_cancel(env,monkeypatch):
from app import worker
site,source=create(env);fake_http(monkeypatch)
queued=conn.submit_scan(site,source,'tester')
def execute(s,opts):
assert s==site
return conn.run_scan(s,opts['source_id'])
assert worker.run_once(executor=execute)==queued['id']
job=db.one('SELECT state,result_json FROM build_jobs WHERE id=?',(queued['id'],))
assert job['state']=='done'
assert json.loads(job['result_json'])['staged_count']==2
assert conn.status(site,source)['state']=='completed'
def test_api_job_runner_dispatch_to_api_scan(env,monkeypatch):
from app import job_runner
from sys import argv
site,source=create(env);fake_http(monkeypatch)
queued=conn.submit_scan(site,source,'tester')
monkeypatch.setattr(job_runner.sys,'argv',['worker',queued['id']])
assert job_runner.main()==0
result=json.loads((db.DATA/'job_results'/(queued['id']+'.json')).read_text())
assert result['ok'] and result['result']['staged_count']==2
+231
View File
@@ -0,0 +1,231 @@
"""Deterministic V8.5 API auth / MySQL staging and Worker regression tests.
No real production database or credentials are accessed.
"""
import json
from urllib.parse import urlsplit
import pytest
from fastapi import HTTPException
from fastapi.testclient import TestClient
from app import db, enterprise, jobs, v8_workflows, connectors_v84 as api, connectors_v85 as mysql
from app import safe_http, secret_refs
from app.core import uid
from app.main import app
from app.safe_http import Fetched, OutboundError
@pytest.fixture
def env(tmp_path,monkeypatch):
monkeypatch.setattr(db,'DATA',tmp_path)
monkeypatch.setattr(db,'DB_FILE',tmp_path/'siteforge.sqlite3')
monkeypatch.setenv('SITEFORGE_ADMIN_PASSWORD','v85-admin-strong-for-test')
monkeypatch.setenv('SITEFORGE_SESSION_SECRET','v85-long-private-session-secret-test')
monkeypatch.setenv('SITEFORGE_MYSQL_ALLOWED_HOSTS','mysql.example.org')
monkeypatch.setenv('SITEFORGE_MYSQL_ALLOWED_TABLES','catalog.products')
monkeypatch.setenv('SITEFORGE_API_TEST_TOKEN','unit-secret-never-return')
db.init();enterprise.setup();v8_workflows.setup();api.setup();mysql.setup()
def site():
sid=uid();now=db.now()
db.insert('INSERT INTO sites(id,name,theme,mode,created_at,updated_at) VALUES(?,?,?,?,?,?)',
(sid,'Connector V85','industrial','new',now,now))
return sid
return site
def mysql_config(**kw):
return {'host':'mysql.example.org','database':'catalog','table':'products','key_column':'id',
'columns':['id','name','brand'],'user_ref':'env:SITEFORGE_MYSQL_READ_USER',
'password_ref':'file:mysql_read_password','mapping':{'title':'name','brand':'brand'},
'page_size':2,'max_pages':4,**kw}
def test_existing_api_sources_upgraded_without_erasing(env):
site=env();s=api.create_api_source(site,{'url':'https://api.example.org/data'})
api.setup();row=db.one('SELECT auth_type FROM v84_api_sources WHERE id=?',(s['id'],))
assert row['auth_type']=='none' and api.status(site,s['id'])['state']=='ready'
def test_api_secret_reference_redacted_and_authenticated_worker(env,monkeypatch):
site=env()
s=api.create_api_source(site,{'url':'https://api.example.org/products','items_path':'data.items',
'page_size':2,'max_pages':2,'mapping':{'title':'name'},
'auth_type':'bearer','secret_ref':'env:SITEFORGE_API_TEST_TOKEN'})
assert s['auth_type']=='bearer' and 'secret_ref' not in s and 'unit-secret' not in str(s)
requests=[]
def fake_fetch(url,**kw):
requests.append(kw)
data={'data':{'items':[{'name':'Gamma','id':'3'}]}}
return Fetched(url,200,{'content-type':'application/json'},json.dumps(data).encode())
monkeypatch.setattr(api.safe_http,'fetch',fake_fetch)
assert api.run_scan(site,s['id'])['staged_count']==1
assert requests[0]['credential_headers']=={'Authorization':'Bearer unit-secret-never-return'}
assert not 'unit-secret' in str(api.status(site,s['id']))
assert 'unit-secret' not in json.dumps(db.rows('SELECT payload_json FROM build_jobs'))
def test_api_custom_header_and_credential_validation(env):
site=env()
for auth in ({'auth_type':'bearer','secret_ref':'my-token'},
{'auth_type':'custom','secret_ref':'file:partner','auth_header':'Cookie'},
{'auth_type':'none','secret_ref':'env:SITEFORGE_API_TEST_TOKEN'}):
with pytest.raises(ValueError):api.create_api_source(site,{'url':'https://api.example.org/data',**auth})
custom=api.create_api_source(site,{'url':'https://api.example.org/data','auth_type':'custom',
'secret_ref':'file:partner_api','auth_header':'X-Partner-Key'})
assert custom['auth_header']=='X-Partner-Key'
def test_http_rejects_credential_redirects_and_http():
with pytest.raises(OutboundError):safe_http.fetch('http://example.org',credential_headers={'Authorization':'Bearer abc'},redirects=0)
with pytest.raises(OutboundError):safe_http.fetch('https://example.org',credential_headers={'Authorization':'Bearer abc'},redirects=1)
with pytest.raises(OutboundError):safe_http.fetch('https://example.org',credential_headers={'Cookie':'abc'},redirects=0)
with pytest.raises(OutboundError):safe_http.fetch('https://example.org',credential_headers={'X-Api-Key':'abc\r\nHost: evil'},redirects=0)
def test_mysql_host_table_identifier_and_secret_validation(env):
site=env()
for patch in [{'host':'internal-unauthorized'},{'database':'other'},
{'table':'products;DROP TABLE users'},{'columns':['id','bad-name']},
{'key_column':'unknown'},{'password_ref':'literal-password'},
{'max_pages':51},{'page_size':1000}]:
with pytest.raises(ValueError):mysql.create_source(site,mysql_config(**patch))
source=mysql.create_source(site,mysql_config());record=mysql.status(site,source['id'])
assert 'password_ref' not in record and 'user_ref' not in record
assert record['host']=='mysql.example.org'
def test_mysql_query_is_fixed_and_parameterized(env):
site=env();created=mysql.create_source(site,mysql_config())
src=mysql._one(site,created['id']);sql,params=mysql.query_for(src,'12')
assert sql=='SELECT `id`, `name`, `brand` FROM `catalog`.`products` WHERE `id` > %s ORDER BY `id` ASC LIMIT %s'
assert params==('12',2)
assert '12' not in sql
def test_mysql_scan_stages_without_auto_committing_and_can_rollback(env,monkeypatch):
site=env();created=mysql.create_source(site,mysql_config());sid=created['id']
calls=[]
def fake_db_read(source,cursor):
calls.append(cursor)
if cursor is None:return [{'id':1,'name':'Engine A','brand':'Brand A'},{'id':2,'name':'Engine B','brand':'Brand B'}]
return []
monkeypatch.setattr(mysql,'_fetch_page',fake_db_read)
report=mysql.run_scan(site,sid)
assert report['staged_count']==2 and report['state']=='completed' and calls==[None,'2']
batches=mysql.status(site,sid)['pages']
assert len(batches)==2 and batches[0]['batch_id']
assert db.rows('SELECT id FROM entries WHERE site_id=?',(site,))==[]
committed=v8_workflows.commit(site,batches[0]['batch_id'])
assert committed['created']==2
assert v8_workflows.rollback(site,batches[0]['batch_id'])['state']=='rolled_back'
monkeypatch.setattr(mysql,'_fetch_page',lambda *a: (_ for _ in ()).throw(AssertionError('must not re-run')))
assert mysql.run_scan(site,sid)['staged_count']==2
def test_mysql_retry_after_staging_before_checkpoint(env,monkeypatch):
site=env();source=mysql.create_source(site,mysql_config())['id']
batch=v8_workflows.stage(site,[{'id':'4','name':'Item Four','brand':'Maker'}],
{'title':'name','brand':'brand'},'product',f'mysql:{source}:page:1',[json.dumps('4')])
monkeypatch.setattr(mysql,'_fetch_page',lambda *a:(_ for _ in ()).throw(AssertionError('should reuse batch')))
row=mysql.scan_page(site,source)
assert row['batch_id']==batch['batch_id'] and row['count']==1
assert len(db.rows('SELECT id FROM v8_import_batches WHERE site_id=?',(site,)))==1
def test_mysql_worker_job_dedup_site_isolation_cancel(env):
site=env();source=mysql.create_source(site,mysql_config())['id']
a=mysql.submit_scan(site,source,'tester')
b=mysql.submit_scan(site,source,'tester')
assert a['id']==b['id'] and b['existing']
assert db.one('SELECT kind FROM build_jobs WHERE id=?',(a['id'],))['kind']=='mysql_scan'
other=env()
with pytest.raises(HTTPException):mysql.status(other,source)
jobs.cancel(site,a['id'])
assert db.one('SELECT state FROM build_jobs WHERE id=?',(a['id'],))['state']=='cancelled'
def test_mysql_auth_required_and_redaction(env):
site=env()
with TestClient(app) as client:
route=f'/api/connectors/sites/{site}/mysql-sources'
assert client.get(route).status_code==401
assert client.post(route,json=mysql_config()).status_code==401
assert client.post('/api/auth/login',json={'password':'v85-admin-strong-for-test'}).status_code==200
client.headers['X-SiteForge-CSRF']=client.get('/api/auth/me').json()['csrf']
c=client.post(route,json=mysql_config())
assert c.status_code==201,c.text
assert 'file:mysql_read_password' not in c.text
assert 'env:SITEFORGE_MYSQL_READ_USER' not in c.text
assert len(client.get(route).json()['items'])==1
assert client.get('/production-v8').status_code==200
assert 'myCreate' in client.get('/production-v8').text
def test_mysql_worker_runner_dispatch(env,monkeypatch):
import sys
from app import job_runner
site=env();source=mysql.create_source(site,mysql_config())['id']
jid=mysql.submit_scan(site,source,'tester')['id']
monkeypatch.setattr(mysql,'run_scan',lambda sid,src,jid=None:{'state':'completed','source':src})
monkeypatch.setattr(sys,'argv',['job_runner',jid])
assert job_runner.main()==0
result=json.loads((db.DATA/'job_results'/f'{jid}.json').read_text())
assert result['ok'] and result['result']['state']=='completed'
def test_relationship_preflight_detects_brand_references(env):
site=env()
batch=v8_workflows.stage(site,[
{'title':'SUV Alpha','brand':'Brand A','category':'SUV'},
{'title':'SUV Beta','brand':'Brand A','category':'SUV'},
],{'title':'title','brand':'brand','category':'category'},'product','xml:relationship')
r=mysql.relationship_preview(site,batch['batch_id'])
assert r['brands']=={'Brand A':2} and r['categories']=={'SUV':2}
assert r['unlinked_brand_total']==2
v8_workflows.stage(site,[{'title':'Brand A'}],{'title':'title'},'brand','manual')
# Merely staging a separate batch is not equivalent to committing a brand page.
assert mysql.relationship_preview(site,batch['batch_id'])['unlinked_brand_total']==2
def test_relationship_preflight_site_scope(env):
one=env();two=env()
batch=v8_workflows.stage(one,[{'title':'Product'}],{'title':'title'},'product','manual')
with pytest.raises(HTTPException) as exc:mysql.relationship_preview(two,batch['batch_id'])
assert exc.value.status_code==404
def test_mysql_staged_products_can_generate_real_static_site(env,monkeypatch):
from app import builder
site=env()
src=mysql.create_source(site,mysql_config(page_size=2,max_pages=3))['id']
monkeypatch.setattr(mysql,'_fetch_page',lambda cfg,cursor: (
[{'id':1,'name':'Pump A','brand':'Brand A'},{'id':2,'name':'Pump B','brand':'Brand B'}]
if cursor is None else []))
outcome=mysql.run_scan(site,src)
assert outcome['staged_count']==2
batch=outcome['pages'][0]['batch_id']
assert v8_workflows.commit(site,batch)['created']==2
with TestClient(app):
built=builder.build(site)
assert built['pages']>=2
assert built['version']
def test_pinned_https_places_auth_only_in_explicit_secret_headers(monkeypatch):
captured=[]
class Response:
status=200
def getheaders(self):return [('Content-Type','application/json')]
def read(self,maxlen):return b'{}'
class Connection:
def __init__(self,host,ip,port,timeout):
captured.append((host,ip,port,timeout))
def request(self,method,path,body=None,headers=None):
captured.append((method,path,headers))
def getresponse(self):return Response()
def close(self):pass
monkeypatch.setattr(safe_http,'resolve_target',lambda url:(urlsplit(url),'api.example.org',443,'198.51.100.20'))
monkeypatch.setattr(safe_http,'_PinnedHTTPS',Connection)
r=safe_http.fetch('https://api.example.org/items',headers={'Authorization':'SHOULD-BE-DROPPED'},
credential_headers={'Authorization':'Bearer real-worker-token'},redirects=0)
assert r.status_code==200
assert captured[0][1]=='198.51.100.20'
assert captured[1][2]['Authorization']=='Bearer real-worker-token'
assert 'SHOULD-BE-DROPPED' not in str(captured)
+196
View 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
+124
View File
@@ -0,0 +1,124 @@
import json
import os
from tests.legacy import LegacyTestClient as TestClient
import pytest
from app import db, enterprise, builder
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_ALLOW_LEGACY_DIRECT_DESIGN_WRITE','1')
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()
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(executor=lambda sid,opts:builder.build(sid,opts.get('mirror_images',False)))==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_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
+124
View File
@@ -0,0 +1,124 @@
import io,json,os,tempfile,time,zipfile
from pathlib import Path
import pytest
from app import cms_pipeline,pg_compat,deploy_agent,redis_signal
def sample_zip(files=None):
out=io.BytesIO()
with zipfile.ZipFile(out,'w') as archive:
for path,data in (files or {'index.html':'<title>Site</title>','products/index.html':'Item'}).items():
archive.writestr(path,data)
return out.getvalue()
def test_pg_placeholder_lexer():
sql="SELECT '?', \"?\", data FROM things WHERE id=? AND key='abc''?def' AND name=?"
assert pg_compat.placeholders(sql).count('%s')==2
assert "'abc''?def'" in pg_compat.placeholders(sql)
assert pg_compat.normalize('INSERT OR IGNORE INTO t (a) VALUES (?)')=='INSERT INTO t (a) VALUES (%s) ON CONFLICT DO NOTHING'
def test_release_sign_verify(monkeypatch):
secret='superlong-secret-32-characters-minimum'
raw=b'example'
stamp=str(int(time.time()))
from starlette.requests import Request
monkeypatch.setenv('SITEFORGE_DEPLOY_SECRET',secret)
sig=deploy_agent.signature(secret,'example',stamp,raw,'POST','/deploy/example')
req=Request({'type':'http','method':'POST','path':'/deploy/example','headers':[(b'x-siteforge-timestamp',stamp.encode()),(b'x-siteforge-signature',sig.encode())]})
deploy_agent.authorize('example',req,raw)
from fastapi import HTTPException
with pytest.raises(HTTPException):deploy_agent.authorize('example',req,b'tampered')
def test_release_archive_path_rejection():
assert deploy_agent.validate_archive(sample_zip())
with pytest.raises(ValueError):deploy_agent.validate_archive(sample_zip({'index.html':'ok','../secret.txt':'x'}))
with pytest.raises(ValueError):deploy_agent.validate_archive(sample_zip({'missing.txt':'oops'}))
def test_release_install_rollback(monkeypatch,tmp_path):
monkeypatch.setattr(deploy_agent,'ROOT',tmp_path)
data=sample_zip()
one=deploy_agent.install('cars',data)
two=deploy_agent.install('cars',data)
assert one['release']==two['release']
parent=tmp_path/'cars'
assert (parent/'current').read_text()==one['release']
assert (parent/'releases'/one['release']/'index.html').is_file()
assert not any(x.name.startswith('.unpacked-') for x in parent.iterdir())
def test_cms_wordpress_acf_elementor_warning():
rows={'items':[{'id':15,'type':'page','slug':'about','title':{'rendered':'About Us'},'link':'https://example.org/about/',
'content':{'rendered':'<p>Hello</p>'},'acf':{'dealer':'HESU','gallery':[1,2]},'meta':{'_elementor_data':'[]'},'yoast_head_json':{'title':'SEO About'}}]}
report=cms_pipeline.parse_export('wordpress-rest',json.dumps(rows).encode())
assert report['mapped']==1 and report['entries'][0]['route']=='/about/'
assert report['entries'][0]['seo_title']=='SEO About'
assert report['warnings']
def test_cms_woocommerce():
rows={'products':[{'id':5,'name':'New Pickup','permalink':'https://example.org/product/pickup/',
'sku':'ABC','price':'100','images':[{'src':'https://example.org/a.png'}],
'variants':[], 'variations':[1,2]}]}
report=cms_pipeline.parse_export('woocommerce',json.dumps(rows).encode())
assert report['entries'][0]['type']=='product'
assert report['entries'][0]['meta']['fields']['gallery']==['https://example.org/a.png']
assert report['warnings']
def test_cms_drupal_and_shopify():
drupal={'data':[{'type':'node--article','id':'abc','attributes':{'title':'News','path':{'alias':'/news/hello/'},
'body':{'processed':'<p>News</p>'}},'relationships':{'field_brand':{'data':{'id':'12'}}}}]}
report=cms_pipeline.parse_export('drupal-jsonapi',json.dumps(drupal).encode())
assert report['mapped']==1 and report['entries'][0]['route']=='/news/hello/'
assert report['warnings']
shopify={'products':[{'id':1,'title':'Shop Item','handle':'shop-item','vendor':'HESU',
'variants':[{'price':'100','sku':'Z9'}]}]}
report=cms_pipeline.parse_export('shopify',json.dumps(shopify).encode())
assert report['entries'][0]['meta']['fields']['brand']=='HESU'
def test_cms_invalid_routes_duplicates():
rec={'products':[{'title':'Foo','handle':'foo'},{'title':'Foo Again','handle':'foo'}]}
result=cms_pipeline.parse_export('shopify',json.dumps(rec).encode())
assert len(result['errors'])==1
def test_redis_down_is_nonfatal(monkeypatch):
monkeypatch.setattr(redis_signal,'URL','redis://127.0.0.1:1/0')
assert redis_signal.notify() is False
def test_release_agent_http_roundtrip(monkeypatch,tmp_path):
from fastapi.testclient import TestClient
monkeypatch.setattr(deploy_agent,'ROOT',tmp_path)
secret='test-release-secret-32-chars-safe-test'
monkeypatch.setenv('SITEFORGE_DEPLOY_SECRET',secret)
stamp=str(int(time.time()))
blob=sample_zip()
c=TestClient(deploy_agent.app)
headers={'X-SiteForge-Timestamp':stamp,'X-SiteForge-Signature':deploy_agent.signature(secret,'newsite',stamp,blob,'POST','/deploy/newsite')}
upload=c.post('/deploy/newsite',headers=headers,content=blob)
assert upload.status_code==200,upload.text
release=upload.json()['release']
assert c.get('/public/newsite/index.html').status_code==200
rollback_headers={'X-SiteForge-Timestamp':stamp,'X-SiteForge-Signature':deploy_agent.signature(secret,'newsite',stamp,b'','POST',f'/sites/newsite/rollback/{release}')}
assert c.post(f'/sites/newsite/rollback/{release}',headers=rollback_headers).status_code==200
assert c.get('/public/newsite/products/index.html').status_code==200
assert c.post('/deploy/newsite',headers={'X-SiteForge-Timestamp':stamp,'X-SiteForge-Signature':'bad'},content=blob).status_code==401
def test_release_endpoint_requires_approval(monkeypatch,tmp_path):
from fastapi.testclient import TestClient
from app import db,builder
from app.main import app
monkeypatch.setattr(db,'DATA',tmp_path)
monkeypatch.setattr(db,'DB_FILE',tmp_path/'db.sqlite3')
monkeypatch.setenv('SITEFORGE_ADMIN_PASSWORD','test-admin-password-for-release')
monkeypatch.setenv('SITEFORGE_SESSION_SECRET','test-session-secret-for-release-app-2026')
with TestClient(app) as c:
assert c.get('/operations',follow_redirects=False).status_code in (302,303,307)
assert c.post('/api/auth/login',json={'password':'test-admin-password-for-release'}).status_code==200
c.headers['X-SiteForge-CSRF']=c.get('/api/auth/me').json()['csrf']
assert c.get('/operations').status_code==200
r=c.post('/api/sites',json={'name':'Release Review Site','theme':'corporate'})
assert r.status_code==201,r.text
sid=r.json()['id'];result=builder.build(sid)
res=c.post(f'/api/production/sites/{sid}/deploy',json={'build_id':result['version'],'target':'staging'})
assert res.status_code==409,res.text
# Migration upload requires same-site authentication and editor role
sample=c.post(f'/api/migrations/sites/{sid}/preview',files={'file':('sample.json',b'{"items":[]}','application/json')},data={'platform':'wordpress-rest'})
assert sample.status_code==200,sample.text
+260
View File
@@ -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 '&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
+161
View File
@@ -0,0 +1,161 @@
import io,json,zipfile
import pytest
from tests.legacy import LegacyTestClient as TestClient
from app import db,theme_store
from app.main import app
@pytest.fixture
def factory(tmp_path,monkeypatch):
monkeypatch.setattr(db,'DATA',tmp_path)
monkeypatch.setattr(db,'DB_FILE',tmp_path/'site.sqlite3')
monkeypatch.setattr(theme_store,'THEME_ROOT',tmp_path/'theme_packages')
monkeypatch.setenv('SITEFORGE_ALLOW_LEGACY_DIRECT_DESIGN_WRITE','1')
monkeypatch.setenv('SITEFORGE_ADMIN_PASSWORD','strong-admin-2026-secret')
monkeypatch.setenv('SITEFORGE_SESSION_SECRET','longer-test-session-secret-2026-not-for-production')
with TestClient(app) as c:
r=c.post('/api/auth/login',json={'username':'admin','password':'strong-admin-2026-secret'})
assert r.status_code==200,r.text
c.headers.update({'X-SiteForge-CSRF':c.get('/api/auth/me').json()['csrf']})
yield c
def site(c,mode='new'):
r=c.post('/api/sites',json={'name':'Factory Sandbox','mode':mode,'theme':'automotive','domain':'https://test.example'})
assert r.status_code==201,r.text
return r.json()['id']
def zip_theme(id='brand-x',version='1.0.0',bad=False):
from pathlib import Path
root=Path(__file__).resolve().parent.parent/'themes'/'corporate'
arr=io.BytesIO()
with zipfile.ZipFile(arr,'w') as z:
data=json.loads((root/'manifest.json').read_text());data.update({'id':id,'version':version,'name':'V2 Test Brand'})
z.writestr('manifest.json',json.dumps(data))
z.writestr('theme.css','body{background:#f5f5f5}')
z.writestr('page-presets.json',(root/'page-presets.json').read_bytes())
if bad:z.writestr('../hack.py','print(1)')
return arr.getvalue()
def test_install_theme_and_pinned_build(factory):
sid=site(factory)
r=factory.post('/api/factory/themes/install',files={'file':('theme.zip',zip_theme(),'application/zip')})
assert r.status_code==200,r.text
assert r.json()['id']=='brand-x'
assert 'brand-x' in [x.get('id',x.get('theme_id')) for x in factory.get('/api/factory/themes').json()]
update=factory.put(f'/api/factory/sites/{sid}/theme',json={'theme':'brand-x','version':'1.0.0'})
assert update.status_code==200,update.text
assert factory.post(f'/api/factory/sites/{sid}/bootstrap').status_code==200
b=factory.post(f'/api/sites/{sid}/builds',json={})
assert b.status_code==200,b.text
info=factory.get(f'/api/sites/{sid}/builds/{b.json()["version"]}/report').json()
assert info['theme']=='brand-x' and info['theme_version']=='1.0.0'
file=factory.get(f'/preview/{sid}/{b.json()["version"]}/search/').text
assert '站内搜索' in file
assert factory.get(f'/api/factory/sites/{sid}/theme').json()['version']=='1.0.0'
def test_theme_rejects_zip_traversal(factory):
r=factory.post('/api/factory/themes/install',files={'file':('evil.zip',zip_theme(bad=True),'application/zip')})
assert r.status_code==400
def test_complete_bootstrap_repeated_safe(factory):
sid=site(factory)
r=factory.post(f'/api/factory/sites/{sid}/bootstrap').json()
assert len(r['created'])>=7 and '/about/' in r['created']
r2=factory.post(f'/api/factory/sites/{sid}/bootstrap').json()
assert not r2['created']
records=factory.get(f'/api/sites/{sid}/entries').json()
assert all(r['route'] for r in records)
build=factory.post(f'/api/sites/{sid}/builds',json={}).json()
html=factory.get(f'/preview/{sid}/{build["version"]}/').text
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
+87
View File
@@ -0,0 +1,87 @@
"""Deterministic P0 stabilization regression tests (no external services)."""
import json
import time
from pathlib import Path
from bs4 import BeautifulSoup
from fastapi.testclient import TestClient
from app import db, builder
from app.main import app
from app.preview_rewrite import rewrite_preview_html
from app.version import VERSION
from app.worker import heartbeat
from app.worker_health import healthy
def test_preview_rewrites_only_internal_root_links():
old='''<html><head><base href="/"></head><body><a href="/products/">Products</a>
<img src="/assets/example.png"><form action="/contact/"></form>
<video poster="/media/poster.jpg"></video><a href="//cdn.example/image"></a>
<a href="https://outside.example/about/"></a><a href="#part"></a>
<a href="/preview/s1/b1/old/">Old</a></body></html>'''
new=rewrite_preview_html(old,'s1','b1')
soup=BeautifulSoup(new,'html.parser')
assert soup.base['href']=='/preview/s1/b1/'
assert soup.find('a',string='Products')['href']=='/preview/s1/b1/products/'
assert soup.img['src']=='/preview/s1/b1/assets/example.png'
assert soup.form['action']=='/preview/s1/b1/contact/'
assert soup.video['poster']=='/preview/s1/b1/media/poster.jpg'
assert soup.select('a[href="//cdn.example/image"]')
assert soup.select('a[href="https://outside.example/about/"]')
assert soup.select('a[href="#part"]')
assert soup.select('a[href="/preview/s1/b1/old/"]')
assert 'href="/preview/s1/b1/preview/s1/b1/"' not in new
def test_preview_does_not_modify_static_files(monkeypatch,tmp_path):
monkeypatch.setattr(db,'DATA',tmp_path)
monkeypatch.setattr(db,'DB_FILE',tmp_path/'siteforge.sqlite3')
monkeypatch.setenv('SITEFORGE_ADMIN_PASSWORD','test-p0-password-12345')
monkeypatch.setenv('SITEFORGE_SESSION_SECRET','test-p0-long-session-secret-value-123456')
with TestClient(app) as client:
assert client.get('/favicon.ico').status_code==200
health=client.get('/api/health').json()
assert health['version']==VERSION
assert isinstance(health['commit'],str) and health['commit']
assert client.post('/api/auth/login',json={'password':'test-p0-password-12345'}).status_code==200
client.headers['X-SiteForge-CSRF']=client.get('/api/auth/me').json()['csrf']
site=client.post('/api/sites',json={'name':'Preview Test','theme':'industrial'}).json()
sid=site['id']
built=builder.build(sid)['version']
root=tmp_path/'builds'/sid/built/'dist'
# Inject a link into an immutable build for precise comparison.
f=root/'index.html'
content=f.read_text(encoding='utf-8').replace('</body>','<a href="/products/">go to products</a></body>')
f.write_text(content,encoding='utf-8')
res=client.get(f'/preview/{sid}/{built}/')
assert res.status_code==200
s=BeautifulSoup(res.text,'html.parser')
assert s.find('a',string='go to products')['href']==f'/preview/{sid}/{built}/products/'
assert client.get(f'/preview/{sid}/{built}/products/').status_code==200
assert 'href="/products/"' in f.read_text(encoding='utf-8')
# Content types / 404 still preserved.
assert client.get(f'/preview/{sid}/{built}/does-not-exist/').status_code==404
def test_worker_probe_does_not_depend_on_web(monkeypatch,tmp_path):
monkeypatch.setattr(db,'DATA',tmp_path)
assert not healthy(tmp_path/'worker-heartbeat')
heartbeat()
assert healthy(tmp_path/'worker-heartbeat')
assert not healthy(tmp_path/'worker-heartbeat',max_age=0)
import os
old=time.time()-120
os.utime(tmp_path/'worker-heartbeat',(old,old))
assert not healthy(tmp_path/'worker-heartbeat',max_age=45)
def test_worker_compose_reuses_image_and_has_own_healthcheck():
import yaml
root=Path(__file__).resolve().parents[1]
for config in ('compose.yaml','compose.enterprise.yaml'):
comp=yaml.safe_load((root/config).read_text(encoding='utf-8'))
web=comp['services']['siteforge']
worker=comp['services']['worker']
assert web['image']==worker['image']
assert 'build' in web and 'build' not in worker
assert 'app.worker_health' in worker['healthcheck']['test']
assert 'http://127.0.0.1:8787/api/health' not in str(worker['healthcheck'])
+28
View File
@@ -0,0 +1,28 @@
"""Offline SQLite backup + restore smoke in an isolated directory."""
import sqlite3
import zipfile
from pathlib import Path
from app import db, backup
from scripts.restore_backup import restore
def test_sqlite_backup_and_restore_with_media(tmp_path,monkeypatch):
storage=tmp_path/'original'
storage.mkdir()
monkeypatch.setattr(db,'DATA',storage)
monkeypatch.setattr(db,'DB_FILE',storage/'siteforge.sqlite3')
db.init()
db.execute('INSERT INTO sites(id,name,created_at,updated_at) VALUES (?,?,?,?)',
('restorecheck','Recovered Site',db.now(),db.now()))
media=storage/'media'/'restorecheck'/'logo.svg'
media.parent.mkdir(parents=True)
media.write_text('<svg/>',encoding='utf-8')
dest=backup.backup()
with zipfile.ZipFile(dest) as z:
assert 'siteforge.sqlite3' in z.namelist()
assert 'media/restorecheck/logo.svg' in z.namelist()
recovered=tmp_path/'restored'
restore(dest,recovered,confirmed=True)
with sqlite3.connect(recovered/'siteforge.sqlite3') as c:
assert c.execute("SELECT name FROM sites WHERE id='restorecheck'").fetchone()[0]=='Recovered Site'
assert (recovered/'media'/'restorecheck'/'logo.svg').read_text(encoding='utf-8')=='<svg/>'
+178
View File
@@ -0,0 +1,178 @@
"""P0-P4 integration regression: responsive design and staged template changes."""
import json
import sqlite3
from fastapi import HTTPException
import pytest
from app import blueprints, responsive, db, builder, v8_templates, site_design_v87
from app.core import save_entry
from tests.test_factory import client, create
def layout(**opts):
return blueprints.new_block('layout',{
'columns':[{'width':2,'mobile_order':2,'tablet_span':2,'mobile_span':1,
'blocks':[blueprints.new_block('richtext',{'title':'COLUMN_A'})]},
{'width':1,'mobile_order':1,'hide_tablet':True,'blocks':[]}],
'tablet_columns':2,'mobile_columns':2,**opts})
def home(c,sid):
return next(r for r in c.get(f'/api/sites/{sid}/entries').json() if r['route']=='/')
def test_different_device_grid_rules_and_html_data_attributes():
blocks=blueprints.validate_blocks([layout()]);css=responsive.editor_css(blocks)
assert 'repeat(2,minmax(0,1fr))!important' in css
assert 'grid-column:span 2' in css
assert 'order:2' in css and 'order:1' in css
assert 'display:none!important' in css
out=blueprints.render_blocks(blocks,{}, {'title':'Home'}, [])
assert f'data-layout="{blocks[0]["id"]}"' in out
assert 'data-col="1"' in out and 'data-col="2"' in out
assert 'COLUMN_A' in out
@pytest.mark.parametrize('bad',[{'mobile_columns':3},{'tablet_columns':0},
{'columns':[{'width':True,'blocks':[]}]},
{'columns':[{'width':1,'mobile_order':'calc(1) !important','blocks':[]}]},
{'columns':[{'width':1,'mobile_span':5,'blocks':[]}]},
{'columns':[{'width':1,'hide_mobile':'yes','blocks':[]}]},
{'style':{'mobile_padding':'expression(1)'}},
{'style':{'background':'url(javascript:evil)'}}])
def test_bad_device_config_rejected(bad):
block=layout()
block.update(bad)
with pytest.raises(ValueError):blueprints.validate_blocks([block])
def test_per_page_responsive_css_is_scoped_and_reusable_ids(client):
sid=create(client)
a=layout(); b=json.loads(json.dumps(a))
a['columns'][0]['hide_mobile']=True
b['columns'][0]['hide_mobile']=False
eid=home(client,sid)['id']
first=db.one('SELECT meta_json FROM entries WHERE site_id=? AND id=?',(sid,eid))
meta=json.loads(first['meta_json']);meta['blocks']=[a];meta['layout_mode']='replace'
db.execute('UPDATE entries SET meta_json=? WHERE id=?',(json.dumps(meta),eid))
save_entry(sid,{'type':'page','title':'About','route':'/about/',
'meta':{'blocks':[b],'layout_mode':'replace'}})
result=builder.build(sid,False)
dist=db.DATA/'builds'/sid/result['version']/'dist'
css=(dist/'assets/custom.css').read_text()
key_home=responsive.route_key('/');key_about=responsive.route_key('/about/')
assert f'body[data-siteforge-route="{key_home}"]' in css
assert f'body[data-siteforge-route="{key_about}"]' in css
assert f'data-siteforge-route="{key_home}"' in (dist/'index.html').read_text()
assert f'data-siteforge-route="{key_about}"' in (dist/'about/index.html').read_text()
assert css.count('display:none!important')>=1
def test_responsive_structure_survives_snapshot_and_studio_publish(client):
sid=create(client);eid=home(client,sid)['id']
data={'blocks':[layout()], 'page_template':'home','layout_mode':'replace','expected_revision':0}
url=f'/api/sites/{sid}/pages/{eid}/studio-draft'
assert client.put(url,json=data).status_code==200
assert 'COLUMN_A' not in (db.DATA/'builds'/sid/builder.build(sid,False)['version']/'dist/index.html').read_text()
assert client.post(url+'/publish',json={'expected_revision':1}).status_code==200
doc=v8_templates.export_template(sid,'copy')
assert doc['pages'][0]['blocks'][0]['mobile_columns']==2
assert doc['pages'][0]['blocks'][0]['columns'][0]['mobile_order']==2
assert 'COLUMN_A' in (db.DATA/'builds'/sid/builder.build(sid,False)['version']/'dist/index.html').read_text()
def test_stage_requires_owner_publish_and_does_not_touch_live(client):
sid=create(client)
doc=v8_templates.export_template(sid,'site template')
doc['tokens']['accent']='#123abc'
before=db.one('SELECT accent,settings_json FROM sites WHERE id=?',(sid,))
stage=client.post(f'/api/factory/sites/{sid}/whole-site-templates/apply',json={
'template':doc,'confirm':True,'overwrite_layouts':True})
assert stage.status_code==200,stage.text
assert stage.json()['published_unchanged']
assert db.one('SELECT accent,settings_json FROM sites WHERE id=?',(sid,))==before
pending=client.get(f'/api/factory/sites/{sid}/whole-site-templates/staged/current').json()
assert pending['state']=='draft' and pending['conflict'] is False
applied=client.post(f'/api/factory/sites/{sid}/whole-site-templates/staged/publish',json={
'expected_revision':pending['revision']})
assert applied.status_code==200,applied.text
assert applied.json()['transactional'] is True
assert db.one('SELECT accent FROM sites WHERE id=?',(sid,))['accent']=='#123abc'
assert client.get(f'/api/factory/sites/{sid}/whole-site-templates/staged/current').json()['state']=='none'
def test_stale_stage_does_not_overwrite_changed_site(client):
sid=create(client)
doc=v8_templates.export_template(sid,'Template')
url=f'/api/factory/sites/{sid}/whole-site-templates'
assert client.post(url+'/apply',json={'template':doc,'confirm':True,'overwrite_layouts':True}).status_code==200
db.execute('UPDATE sites SET accent=? WHERE id=?',('#ff0000',sid))
result=client.post(url+'/staged/publish',json={'expected_revision':1})
assert result.status_code==409,result.text
assert db.one('SELECT accent FROM sites WHERE id=?',(sid,))['accent']=='#ff0000'
assert client.delete(url+'/staged/current').status_code==200
def test_page_draft_blocks_direct_template_application(client):
sid=create(client);eid=home(client,sid)['id'];doc=v8_templates.export_template(sid,'Template')
assert client.put(f'/api/sites/{sid}/pages/{eid}/studio-draft',json={
'blocks':[layout()],'page_template':'home','expected_revision':0}).status_code==200
with pytest.raises(HTTPException) as exc:
v8_templates.apply_template(sid,doc,overwrite_pages=True)
assert exc.value.status_code==409
def test_midway_atomic_template_failure_cannot_partially_update(client):
sid=create(client)
doc=v8_templates.export_template(sid,'Template')
doc['pages'].append({'route':'/trigger/','page_type':'standard','blocks':[]})
assert len(doc['pages'])==2
original=db.one('SELECT theme,settings_json,accent FROM sites WHERE id=?',(sid,))
entry=db.one('SELECT meta_json FROM entries WHERE site_id=? AND route=?',(sid,'/'))
with db.connect() as c:
c.execute("""CREATE TRIGGER if_not_now BEFORE INSERT ON entries
WHEN NEW.route='/trigger/' BEGIN SELECT RAISE(ABORT,'test failure'); END""")
with pytest.raises(sqlite3.IntegrityError):
v8_templates.apply_template(sid,doc,overwrite_pages=True)
assert db.one('SELECT theme,settings_json,accent FROM sites WHERE id=?',(sid,))==original
assert db.one('SELECT meta_json FROM entries WHERE site_id=? AND route=?',(sid,'/'))==entry
assert not db.one('SELECT id FROM entries WHERE site_id=? AND route=?',(sid,'/trigger/'))
def test_editor_frontend_has_two_step_template_and_device_controls(client):
sid=create(client);eid=home(client,sid)['id']
assert 'Owner 发布模板' in client.get('/production-v8').text
assert 'setDeviceColumns' in client.get('/ui/editor.js').text
assert 'setColumnDevice' in client.get('/ui/editor.js').text
def test_nested_page_uses_root_relative_media_and_cta(client):
from pathlib import Path
sid=create(client)
folder=db.DATA/'media'/sid;folder.mkdir(parents=True,exist_ok=True)
image='0123456789ab.png';(folder/image).write_bytes(b'\x89PNG\r\n\x1a\n'+b'x'*10)
p=save_entry(sid,{'type':'page','title':'Nested detail','route':'/products/demo/',
'meta':{'layout_mode':'replace','blocks':[
blueprints.new_block('image_text',{'title':'Detail','image':'media/'+image,'href':'/contact/'}),
blueprints.new_block('cta',{'title':'Contact','href':'/contact/'})]}})
result=builder.build(sid,False)
out=(db.DATA/'builds'/sid/result['version']/'dist/products/demo/index.html').read_text()
assert 'src="/media/'+image+'"' in out
assert 'href="/contact/"' in out
assert 'href="contact/"' not in out
assert client.get(f'/preview/{sid}/{result["version"]}/products/demo/').status_code==200
assert f'/preview/{sid}/{result["version"]}/media/{image}' in client.get(f'/preview/{sid}/{result["version"]}/products/demo/').text
def test_editor_asset_picker_and_local_image_preview(client):
sid=create(client)
eid=home(client,sid)['id']
assert 'siteMediaModal' in client.get(f'/editor/{sid}/{eid}').text
assert 'chooseMedia' in client.get('/ui/editor.js').text
folder=db.DATA/'media'/sid;folder.mkdir(parents=True,exist_ok=True)
image='0123456789ab.png';(folder/image).write_bytes(b'\x89PNG\r\n\x1a\n'+b'x'*10)
preview=client.post(f'/api/sites/{sid}/pages/{eid}/editor-preview',json={
'blocks':[blueprints.new_block('image_text',{'image':'media/'+image})],
'page_template':'home','layout_mode':'replace'})
assert preview.status_code==200,preview.text[:800]
assert f'src="/local-media/{sid}/{image}"' in preview.text
assert client.get(f'/local-media/{sid}/{image}').status_code==200
+134
View File
@@ -0,0 +1,134 @@
"""Deterministic P0/P2 regression fixtures; no outside network or Docker dependency."""
import io
import json
import sqlite3
import zipfile
from pathlib import Path
import pytest
from app import db, backup, wp_migration_v82 as media
from scripts.restore_backup import restore
def test_responsive_media_preserves_sizes_and_catches_cdn():
original={
'cover':'https://example.org/cover.jpg',
'body':'''<picture><source srcset="https://example.org/small.jpg 360w, https://example.org/big.jpg 1440w">
<img src="https://example.org/small.jpg" data-src="https://example.org/full.jpg"
srcset="https://example.org/small.jpg 360w, https://example.org/big.jpg 1440w"
data-srcset="https://cdn.example.net/cdn.jpg 2x"></picture>'''
}
targets=media._media_urls(original)
assert len(targets)==5 and 'https://example.org/big.jpg' in targets
assert 'https://cdn.example.net/cdn.jpg' in targets
converted={x:'/media/'+x.rsplit('/',1)[-1] for x in targets if 'example.org/' in x}
out,changed=media._rewrite_media_html(original['body'],converted)
assert changed
assert '/media/big.jpg 1440w' in out
assert '/media/small.jpg 360w' in out
assert 'data-src="/media/full.jpg"' in out
assert 'https://cdn.example.net/cdn.jpg 2x' in out
assert media._media_urls({'body':out})==['https://cdn.example.net/cdn.jpg']
def test_restore_tampered_archive_cannot_change_existing_data(tmp_path):
target=tmp_path/'restore';target.mkdir()
(target/'old.txt').write_text('must survive')
for filename in ('../outside.txt','evil.txt'):
archive=tmp_path/(filename.replace('/','_')+'.zip')
with zipfile.ZipFile(archive,'w') as z:
z.writestr('siteforge.sqlite3',b'not a database')
z.writestr(filename,'evil')
with pytest.raises(ValueError):restore(archive,target,confirmed=True)
assert (target/'old.txt').read_text()=='must survive'
assert not (tmp_path/'outside.txt').exists()
def test_restore_replaces_directory_atomically_and_preserves_previous(tmp_path,monkeypatch):
source=tmp_path/'original';source.mkdir()
monkeypatch.setattr(db,'DATA',source)
monkeypatch.setattr(db,'DB_FILE',source/'siteforge.sqlite3')
db.init()
db.insert('INSERT INTO sites(id,name,created_at,updated_at) VALUES(?,?,?,?)',
('testsite','Restore Success',db.now(),db.now()))
(source/'media').mkdir();(source/'media'/'image.png').write_bytes(b'img')
archive=backup.backup()
target=tmp_path/'production';target.mkdir()
(target/'old-sentinel').write_text('old installation')
out=restore(archive,target,confirmed=True)
assert (target/'media'/'image.png').read_bytes()==b'img'
assert not (target/'old-sentinel').exists()
assert out['previous'] and (Path(out['previous'])/'old-sentinel').read_text()=='old installation'
with sqlite3.connect(target/'siteforge.sqlite3') as conn:
assert conn.execute('SELECT name FROM sites WHERE id=?',('testsite',)).fetchone()[0]=='Restore Success'
def test_remote_only_media_candidate_reported():
assert media._media_urls({'body':'<img src="/media/local.png" srcset="https://cdn.example.net/a.png 720w">'})==['https://cdn.example.net/a.png']
def test_build_quality_reports_missing_local_assets_and_blocks_traversal(tmp_path,monkeypatch):
from app import quality
monkeypatch.setattr(db,'DATA',tmp_path)
monkeypatch.setattr(db,'DB_FILE',tmp_path/'db.sqlite3')
db.init()
sid='p0p4qa';now=db.now()
db.insert('INSERT INTO sites(id,name,domain,created_at,updated_at) VALUES(?,?,?,?,?)',
(sid,'QA','https://example.org',now,now))
dist=tmp_path/'builds'/sid/'sample'/'dist'
dist.mkdir(parents=True)
(dist/'index.html').write_text('''<html><head><title>Home</title><meta name="description" content="A Home"></head>
<body><h1>Welcome</h1><a href="/missing/">Missing</a>
<img alt="OK" src="/assets/not-there.png"><a href="/../secrets">Bad</a></body></html>''')
audit=quality.report(sid,'sample')
assert audit['files']==1 and audit['errors']==3
assert {x['type'] for x in audit['issues']}=={'broken_internal_reference'}
(dist/'assets').mkdir();(dist/'assets'/'not-there.png').write_bytes(b'demo')
(dist/'missing').mkdir();(dist/'missing'/'index.html').write_text('<title>Missing</title><h1>There</h1>')
audit=quality.report(sid,'sample')
assert audit['errors']==1 # the traversal never resolves to an outside file
def test_catalogue_prepared_context_reuses_groups_without_mutating_records():
from app import theme_pro
rows=[{'type':'vehicle','title':f'Model {i}','route':f'/vehicle/{i}/',
'meta_json':json.dumps({'fields':{'brand':'Acme','category':'SUV'}})} for i in range(30)]
prepared=theme_pro.prepare(rows)
assert prepared['brands']==[{'name':'Acme','url':'/brands/acme/'}]
assert prepared['categories']==[{'name':'SUV','url':'/categories/suv/'}]
site={'name':'Demo','theme':'automotive','domain':'','settings_json':'{}','tagline':''}
ctx=theme_pro.make_context(site,rows[0],rows,prepared=prepared)
assert ctx['kind']=='product' and ctx['listing']==[]
assert ctx['groups'] is prepared['groups']
assert rows[0]['title']=='Model 0'
def test_static_pagination_keeps_every_product_reachable(tmp_path,monkeypatch):
from app import builder,quality,v8_workflows as wf
from app.main import app
from app.core import uid
from fastapi.testclient import TestClient
monkeypatch.setattr(db,'DATA',tmp_path)
monkeypatch.setattr(db,'DB_FILE',tmp_path/'pagination.sqlite3')
monkeypatch.setenv('SITEFORGE_ADMIN_PASSWORD','pagination-demo-only-password-long')
monkeypatch.setenv('SITEFORGE_SESSION_SECRET','pagination-demo-only-secret-long-enough-1234')
with TestClient(app):
sid=uid();now=db.now()
db.insert('INSERT INTO sites(id,name,theme,created_at,updated_at) VALUES(?,?,?,?,?)',
(sid,'Pagination Pilot','automotive',now,now))
entries=[{'车型名称':f'Product {i:04d}','品牌':'Maker','分类':'SUV'} for i in range(235)]
b=wf.stage(sid,entries,kind='vehicle')
assert wf.commit(sid,b['batch_id'])['created']==235
result=builder.build(sid,False)
dist=tmp_path/'builds'/sid/result['version']/'dist'
first=(dist/'categories/suv/index.html').read_text()
second=(dist/'categories/suv/page/2/index.html').read_text()
third=(dist/'categories/suv/page/3/index.html').read_text()
assert '/categories/suv/page/2/' in first
assert '/categories/suv/page/3/' in second
assert '/categories/suv/page/2/' in third
assert first.count('pro-card pro-card-catalog')==100
assert second.count('pro-card pro-card-catalog')==100
assert third.count('pro-card pro-card-catalog')==35
assert (dist/'vehicle/page/2/index.html').is_file()
assert (dist/'products/page/2/index.html').is_file()
assert quality.report(sid,result['version'])['errors']==0
+120
View File
@@ -0,0 +1,120 @@
"""Deterministic P1 tests: no actual network or external infrastructure."""
import socket
from app import safe_http, secret_refs
from fastapi.testclient import TestClient
from app import db
from app.main import app
import pytest
def test_public_ip_pinned_and_bounded(monkeypatch):
seen=[]
def dns(host, port, **kwargs):
return [(socket.AF_INET,socket.SOCK_STREAM,6,'',('93.184.216.34',port))]
monkeypatch.setattr(safe_http.socket,'getaddrinfo',dns)
class Response:
status=200
def getheaders(self):return [('Content-Type','text/html')]
def read(self,n):return b'hello'
class FakeConnection:
def __init__(self,host,ip,port,timeout):seen.append((host,ip,port,timeout))
def request(self,method,url,headers,body=None):seen.append((method,url))
def getresponse(self):return Response()
def close(self):pass
monkeypatch.setattr(safe_http,'_PinnedHTTPS',FakeConnection)
r=safe_http.fetch('https://example.org/products/?page=1',max_bytes=8)
assert r.content==b'hello'
assert seen[0]==('example.org','93.184.216.34',443,12)
assert seen[1]==('GET','/products/?page=1')
def test_public_only_dns_and_url_restrictions(monkeypatch):
def dns(host,port,**kwargs):
ip={'rebinding.example':'10.0.0.5','mixed.example':'93.184.216.34'}.get(host,'93.184.216.34')
r=[(socket.AF_INET,socket.SOCK_STREAM,6,'',(ip,port))]
if host=='mixed.example':r.append((socket.AF_INET,socket.SOCK_STREAM,6,'',('127.0.0.1',port)))
return r
monkeypatch.setattr(safe_http.socket,'getaddrinfo',dns)
for url in ['http://localhost/','http://169.254.169.254/latest/meta-data/',
'https://rebinding.example/','https://mixed.example/',
'file:///etc/passwd','http://user:pass@example.org/',
'https://example.org:8080/']:
with pytest.raises(safe_http.OutboundError):safe_http.resolve_target(url)
assert safe_http.resolve_target('https://example.org/')[3]=='93.184.216.34'
def test_redirect_to_private_host_is_not_followed(monkeypatch):
def dns(host,port,**kwargs):
ip='10.0.0.2' if host=='internal.example' else '93.184.216.34'
return [(socket.AF_INET,socket.SOCK_STREAM,6,'',(ip,port))]
monkeypatch.setattr(safe_http.socket,'getaddrinfo',dns)
class Response:
status=302
def getheaders(self):return [('Location','http://internal.example/secret')]
def read(self,n):return b''
class FakeConnection:
def __init__(self,*args):pass
def request(self,*args,**kwargs):pass
def getresponse(self):return Response()
def close(self):pass
monkeypatch.setattr(safe_http,'_PinnedHTTPS',FakeConnection)
with pytest.raises(safe_http.OutboundError):safe_http.fetch('https://example.org/',redirects=3)
def test_secret_refs_deny_literals_and_path_traversal(monkeypatch):
monkeypatch.setenv('SITEFORGE_UNIT_TOKEN','sample-secret')
assert secret_refs.resolve('env:SITEFORGE_UNIT_TOKEN')=='sample-secret'
for ref in ['my-plain-password','env:PATH','file:../root','file:/etc/passwd','env:SITEFORGE_NOT_SET']:
with pytest.raises(secret_refs.SecretReferenceError):secret_refs.resolve(ref)
def test_queue_limits_and_repeated_requests(monkeypatch,tmp_path):
monkeypatch.setattr(db,'DATA',tmp_path)
monkeypatch.setattr(db,'DB_FILE',tmp_path/'p1.sqlite3')
monkeypatch.setenv('SITEFORGE_MAX_PENDING_JOBS_PER_SITE','1')
monkeypatch.setenv('SITEFORGE_MAX_PENDING_JOBS','10')
monkeypatch.setenv('SITEFORGE_ADMIN_PASSWORD','p1-test-password-with-strong-entropy')
monkeypatch.setenv('SITEFORGE_SESSION_SECRET','p1-test-secret-with-enough-entropy-1234567890')
with TestClient(app) as client:
assert client.post('/api/auth/login',json={'password':'p1-test-password-with-strong-entropy'}).status_code==200
client.headers['X-SiteForge-CSRF']=client.get('/api/auth/me').json()['csrf']
sid=client.post('/api/sites',json={'name':'Queue capacity'}).json()['id']
one=client.post(f'/api/sites/{sid}/builds',json={'mirror_images':False})
assert one.status_code==202
duplicated=client.post(f'/api/sites/{sid}/builds',json={'mirror_images':False})
assert duplicated.status_code==202 and duplicated.json()['existing']
over=client.post(f'/api/sites/{sid}/builds',json={'mirror_images':True})
assert over.status_code==429
health=client.get('/api/health')
assert health.status_code==200 and len(health.headers['X-Request-ID'])==16
def test_schema_ledger_idempotent_and_checksum_guard(monkeypatch,tmp_path):
monkeypatch.setattr(db,'DATA',tmp_path)
monkeypatch.setattr(db,'DB_FILE',tmp_path/'schema.sqlite3')
db.init()
db.init()
assert db.one('SELECT COUNT(*) AS n FROM schema_migrations')['n']==1
assert db.one("SELECT name FROM sqlite_master WHERE name='platform_limit_overrides'")
db.execute("UPDATE schema_migrations SET checksum='corrupted'")
with pytest.raises(RuntimeError,match='checksum mismatch'):db.init()
def test_post_uses_pinned_ip_and_never_redirects(monkeypatch):
monkeypatch.setattr(safe_http.socket,'getaddrinfo',lambda host,port,**k:[
(socket.AF_INET,socket.SOCK_STREAM,6,'',('93.184.216.34',port))])
seen=[]
class Response:
status=201
def getheaders(self):return [('Content-Type','application/json')]
def read(self,n):return b'{"release":"xyz"}'
class Connection:
def __init__(self,host,ip,port,timeout):seen.append((host,ip))
def request(self,method,path,body=None,headers=None):seen.append((method,path,body))
def getresponse(self):return Response()
def close(self):pass
monkeypatch.setattr(safe_http,'_PinnedHTTPS',Connection)
result=safe_http.post('https://releases.example.org/deploy/site1',body=b'zip-data')
assert result.status_code==201 and result.json()['release']=='xyz'
assert seen[0]==('releases.example.org','93.184.216.34')
assert seen[1]==('POST','/deploy/site1',b'zip-data')
+109
View File
@@ -0,0 +1,109 @@
"""Regression tests for release safety and CMS review persistence."""
import json
from fastapi.testclient import TestClient
from fastapi import HTTPException
import pytest
from app import db, cms_pipeline, release_api
from app.main import app
def test_pending_and_failed_build_cannot_be_deployed(monkeypatch,tmp_path):
monkeypatch.setattr(db,'DATA',tmp_path)
monkeypatch.setattr(db,'DB_FILE',tmp_path/'pending.sqlite3')
db.init()
with db.connect() as c:
c.execute('INSERT INTO sites(id,name,created_at,updated_at) VALUES (?,?,?,?)',('sitepending','Sample',db.now(),db.now()))
for state in ['queued','running','failed','unknown']:
c.execute('INSERT INTO builds(id,site_id,created_at,status,zip_path) VALUES (?,?,?,?,?)',
(state,'sitepending',db.now(),state,str(tmp_path/'missing.zip')))
for state in ['queued','running','failed','unknown']:
with pytest.raises(HTTPException) as exc:
release_api.approved('sitepending',state)
assert exc.value.status_code==409
def test_cms_warnings_saved_and_reviewed(monkeypatch,tmp_path):
monkeypatch.setattr(db,'DATA',tmp_path)
monkeypatch.setattr(db,'DB_FILE',tmp_path/'review.sqlite3')
monkeypatch.setenv('SITEFORGE_ADMIN_PASSWORD','test-admin-password-for-cms-reviews')
monkeypatch.setenv('SITEFORGE_SESSION_SECRET','test-session-secret-for-cms-reviews-long')
with TestClient(app) as c:
assert c.post('/api/auth/login',json={'password':'test-admin-password-for-cms-reviews'}).status_code==200
c.headers['X-SiteForge-CSRF']=c.get('/api/auth/me').json()['csrf']
res=c.post('/api/sites',json={'name':'CMS Review Test','mode':'migration'})
assert res.status_code==201,res.text
sid=res.json()['id']
blob={'items':[{'id':3,'type':'page','title':{'rendered':'About'},'link':'https://old.example/about/',
'content':{'rendered':'<p>old</p>'},'acf':{'related':[1,2]}}]}
res=c.post(f'/api/migrations/sites/{sid}/import',data={'platform':'wordpress-rest','conflict':'skip'},
files={'file':('export.json',json.dumps(blob).encode(),'application/json')})
assert res.status_code==200,res.text
assert res.json()['pending_review_count']==1
get=c.get(f'/api/migrations/sites/{sid}/reviews')
assert get.status_code==200 and get.json()['pending']==1
issue=get.json()['items'][0]['id']
reject=c.post(f'/api/migrations/sites/{sid}/reviews/{issue}/resolve',json={'resolution':'ok'})
assert reject.status_code==400
ok=c.post(f'/api/migrations/sites/{sid}/reviews/{issue}/resolve',json={'resolution':'已逐项检查关联产品及图片,确认无需迁移旧关联。'})
assert ok.status_code==200,ok.text
assert ok.json()['pending']==0
assert c.get(f'/api/migrations/sites/{sid}/reviews').json()['items']==[]
assert c.get(f'/api/migrations/sites/{sid}/reviews?state=resolved').json()['items'][0]['resolution']
def test_signatures_are_bound_to_http_path(monkeypatch,tmp_path):
from app import deploy_agent
import zipfile,io,time
from fastapi.testclient import TestClient
monkeypatch.setattr(deploy_agent,'ROOT',tmp_path)
secret='release-secret-very-long-at-least-thirtytwo-bytes'
monkeypatch.setenv('SITEFORGE_DEPLOY_SECRET',secret)
buf=io.BytesIO()
with zipfile.ZipFile(buf,'w') as z:z.writestr('index.html','<title>OK</title>')
client=TestClient(deploy_agent.app)
stamp=str(int(time.time()))
headers={'X-SiteForge-Timestamp':stamp,'X-SiteForge-Signature':deploy_agent.signature(secret,'company',stamp,buf.getvalue(),'POST','/deploy/company')}
created=client.post('/deploy/company',headers=headers,content=buf.getvalue())
assert created.status_code==200
version=created.json()['release']
# A read-only inventory signature must not authorize state-changing rollback.
inventory_headers={'X-SiteForge-Timestamp':stamp,'X-SiteForge-Signature':deploy_agent.signature(secret,'company',stamp,b'','GET','/sites/company/releases')}
assert client.get('/sites/company/releases',headers=inventory_headers).status_code==200
assert client.post(f'/sites/company/rollback/{version}',headers=inventory_headers).status_code==401
rollback_headers={'X-SiteForge-Timestamp':stamp,'X-SiteForge-Signature':deploy_agent.signature(secret,'company',stamp,b'','POST',f'/sites/company/rollback/{version}')}
assert client.post(f'/sites/company/rollback/{version}',headers=rollback_headers).status_code==200
def test_art_installer_idempotent_mock_checkout(tmp_path):
from scripts.install_into_art_design_pro import install
base=tmp_path/'official'/'src'/'router'/'routes'
base.mkdir(parents=True)
(base/'asyncRoutes.ts').write_text('export const asyncRoutes: MenuListType[] = [];',encoding='utf-8')
(tmp_path/'official'/'package.json').write_text('{"name":"art-design-pro"}',encoding='utf-8')
install(tmp_path/'official',dry_run=True)
assert 'SITEFORGE_FACTORY' not in (base/'asyncRoutes.ts').read_text()
install(tmp_path/'official',dry_run=False)
before=(base/'asyncRoutes.ts').read_text()
assert before.count('// SITEFORGE_FACTORY_V7_REGISTERED')==1
assert 'SiteForgeMigration' in before
install(tmp_path/'official',dry_run=False)
assert (base/'asyncRoutes.ts').read_text()==before
def test_new_site_missing_content_gets_noindex_scaffold(monkeypatch,tmp_path):
from app import builder,quality
from fastapi.testclient import TestClient
monkeypatch.setattr(db,'DATA',tmp_path)
monkeypatch.setattr(db,'DB_FILE',tmp_path/'scaffold.sqlite3')
monkeypatch.setenv('SITEFORGE_ADMIN_PASSWORD','test-admin-password-for-scaffold')
monkeypatch.setenv('SITEFORGE_SESSION_SECRET','test-session-secret-for-scaffold-extended')
with TestClient(app) as c:
assert c.post('/api/auth/login',json={'password':'test-admin-password-for-scaffold'}).status_code==200
c.headers['X-SiteForge-CSRF']=c.get('/api/auth/me').json()['csrf']
sid=c.post('/api/sites',json={'name':'New Company','theme':'automotive'}).json()['id']
version=builder.build(sid)['version']
root=tmp_path/'builds'/sid/version/'dist'
assert (root/'products/index.html').is_file()
assert (root/'brands/index.html').is_file()
assert 'noindex' in (root/'brands/index.html').read_text(encoding='utf-8')
assert quality.report(sid,version)['errors']==0
+171
View File
@@ -0,0 +1,171 @@
"""P4 whole-site chrome is editable, safe, versioned, and build-isolated."""
import json
import pytest
from fastapi import HTTPException
from tests.test_factory import client,create
from app import db,builder,site_design_v87
def _payload(**header):
base=json.loads(json.dumps(site_design_v87.DEFAULTS))
base['header'].update({'show_announcement':True,'announcement':'V87_GLOBAL_DRAFT_MARKER',
'cta_text':'Get Quote V87','cta_href':'/contact/'})
base['header'].update(header)
base['footer'].update({'title':'V87_ENTERPRISE_FOOTER','description':'International operations',
'email':'team@example.test','show_nav':True})
base['nav']=[{'title':'首页','route':'/'},{'title':'Solutions','route':'/services/',
'children':[{'title':'Consulting','route':'/about/'}]}]
base['responsive'].update({'tablet_gap':72,'mobile_gap':33,
'mobile_container_padding':16,'mobile_hide_announcement':False})
return base
def _get_page(sid,bid):
return (db.DATA/'builds'/sid/bid/'dist'/'index.html').read_text('utf-8')
def test_site_chrome_draft_isolation_publish_and_build(client):
sid=create(client)
before=builder.build(sid,False)
marker='V87_GLOBAL_DRAFT_MARKER'
assert marker not in _get_page(sid,before['version'])
url=f'/api/sites/{sid}/site-design'
payload=_payload()
saved=client.put(url,json={'config':payload,'expected_revision':0})
assert saved.status_code==200,saved.text
assert saved.json()['build_unchanged'] is True
assert client.get(url).json()['state']=='draft'
waiting=builder.build(sid,False)
assert marker not in _get_page(sid,waiting['version'])
published=client.post(url+'/publish',json={'expected_revision':1})
assert published.status_code==200,published.text
after=builder.build(sid,False)
html=_get_page(sid,after['version'])
assert marker in html and 'V87_ENTERPRISE_FOOTER' in html
assert 'Get Quote V87' in html and 'Consulting' in html
css=(db.DATA/'builds'/sid/after['version']/'dist/assets/custom.css').read_text('utf-8')
assert 'padding-block:33px' in css and 'padding-left:16px' in css
assert len(client.get(url+'/publications').json())==1
assert client.get(url).json()['state']=='published'
assert marker not in _get_page(sid,before['version'])
@pytest.mark.parametrize('theme',['automotive','industrial','corporate','minimal'])
def test_whole_site_theme_render_uses_published_design(client,theme):
sid=create(client,theme)
url=f'/api/sites/{sid}/site-design'
payload=_payload()
assert client.put(url,json={'config':payload,'expected_revision':0}).status_code==200
assert client.post(url+'/publish',json={'expected_revision':1}).status_code==200
result=builder.build(sid,False)
html=_get_page(sid,result['version'])
assert 'V87_GLOBAL_DRAFT_MARKER' in html
assert 'Get Quote V87' in html
assert 'team@example.test' in html
assert 'Consulting' in html
def test_site_design_preview_is_not_committed(client):
sid=create(client);url=f'/api/sites/{sid}/site-design'
assert client.put(url,json={'config':_payload(),'expected_revision':0}).status_code==200
eid=next(x['id'] for x in client.get(f'/api/sites/{sid}/entries').json() if x['route']=='/')
resp=client.post(f'/api/sites/{sid}/pages/{eid}/editor-preview',json={
'blocks':[],'page_template':'home','layout_mode':'append','site_design':_payload()})
assert resp.status_code==200,resp.text[:600]
assert 'V87_GLOBAL_DRAFT_MARKER' in resp.text
assert 'V87_GLOBAL_DRAFT_MARKER' not in _get_page(sid,builder.build(sid,False)['version'])
assert '全站页眉与页脚' in client.get(f'/editor/{sid}/{eid}').text
assert 'showSiteDesign' in client.get('/ui/editor.js').text
def test_conflicting_versions_and_website_updates(client):
sid=create(client);url=f'/api/sites/{sid}/site-design'
assert client.put(url,json={'config':_payload(),'expected_revision':0}).status_code==200
assert client.put(url,json={'config':_payload(),'expected_revision':0}).status_code==409
assert client.post(url+'/publish',json={'expected_revision':0}).status_code==409
assert client.put(f'/api/enterprise/sites/{sid}/design-tokens',json={'heading_scale':1.3}).status_code==200
assert client.get(url).json()['conflict'] is True
assert client.post(url+'/publish',json={'expected_revision':1}).status_code==409
assert client.get(url+'/publications').json()==[]
def test_invalid_navigation_and_restricted_responsive_values(client):
sid=create(client);url=f'/api/sites/{sid}/site-design'
for bad in [
{'nav':[{'title':'Bad','route':'javascript:alert(1)'}]},
{'nav':[{'title':'Bad','route':'//169.254.169.254/'}]},
{'nav':[{'title':'Bad','route':'/','children':[{'title':'A','route':'/','children':[{'title':'B','route':'/', 'children':[{'title':'C','route':'/'}]}]}]}]},
{'responsive':{'mobile_gap':-1}},
{'responsive':{'mobile_gap':'calc(1px * 3)'}},
{'header':{'announcement':'x'*141}},
{'footer':{'title':'<script>x</script>','unknown':'a'}},
]:
payload=_payload()
for k,v in bad.items():
if isinstance(v,dict):payload[k].update(v)
else:payload[k]=v
response=client.put(url,json={'config':payload,'expected_revision':0})
assert response.status_code==400,(bad,response.text)
assert client.get(url).json()['revision']==0
def test_cancel_draft_and_multi_tenant_isolation(client):
sid=create(client);other=create(client)
url=f'/api/sites/{sid}/site-design'
assert client.put(url,json={'config':_payload(),'expected_revision':0}).status_code==200
assert client.get(f'/api/sites/{other}/site-design').json()['state']=='published'
assert client.delete(url).status_code==200
assert client.get(url).json()['revision']==0
assert client.post(url+'/publish',json={'expected_revision':0}).status_code==409
assert 'V87_GLOBAL_DRAFT_MARKER' not in _get_page(other,builder.build(other,False)['version'])
def test_empty_nav_preserves_legacy_navigation_and_default_theme(client):
sid=create(client)
r=client.get(f'/api/sites/{sid}/site-design').json()
assert r['state']=='published' and r['config']['nav']==[]
html=_get_page(sid,builder.build(sid,False)['version'])
assert '首页' in html and '产品' in html
def test_legacy_direct_design_writes_blocked_by_default(client,monkeypatch):
sid=create(client)
eid=next(x['id'] for x in client.get(f'/api/sites/{sid}/entries').json() if x['route']=='/')
monkeypatch.delenv('SITEFORGE_ALLOW_LEGACY_DIRECT_DESIGN_WRITE',raising=False)
assert client.put(f'/api/sites/{sid}/pages/{eid}/blueprint',json={
'blocks':[],'page_template':'home'}).status_code==409
assert client.post(f'/api/sites/{sid}/pages/{eid}/blueprint/preset',json={
'page_template':'home'}).status_code==409
assert client.put(f'/api/sites/{sid}/navigation',json=[{'title':'Bad','route':'/'}]).status_code==409
assert client.put(f'/api/enterprise/sites/{sid}/design-tokens',json={
'font':'serif'}).status_code==409
assert client.patch(f'/api/sites/{sid}/entries/{eid}',json={
'meta':{'blocks':[]}}).status_code==409
# New draft APIs work while legacy direct-write interfaces are disabled.
assert client.put(f'/api/sites/{sid}/site-design',json={
'config':_payload(),'expected_revision':0}).status_code==200
assert client.get(f'/api/sites/{sid}/site-design').json()['state']=='draft'
def test_site_design_tokens_follow_draft_publish_boundary(client):
sid=create(client)
original=builder.build(sid,False)
path=f'/api/sites/{sid}/site-design'
config=_payload();config['tokens'].update({'font':'serif','container_width':1380,'heading_scale':1.2})
assert client.put(path,json={'config':config,'expected_revision':0}).status_code==200
waiting=builder.build(sid,False)
css=lambda bid:(db.DATA/'builds'/sid/bid/'dist/assets/custom.css').read_text('utf-8')
assert '--sf-width:1380px' not in css(original['version'])
assert '--sf-width:1380px' not in css(waiting['version'])
assert client.post(path+'/publish',json={'expected_revision':1}).status_code==200
done=builder.build(sid,False)
assert '--sf-width:1380px' in css(done['version']) and 'Georgia' in css(done['version'])
assert client.get(f'/api/enterprise/sites/{sid}/design-tokens').json()['container_width']==1380
def test_legacy_frontends_use_drafts(client):
script=client.get('/ui/admin.js').text
enterprise_script=client.get('/ui/enterprise-ui.js').text
assert "'/site-design'" in script or "'/site-design'" in script.replace('"',"'")
assert '/site-design' in enterprise_script
assert 'design-tokens`,' not in enterprise_script
+128
View File
@@ -0,0 +1,128 @@
"""Deterministic P4 regression checks. Drafts must never leak into builds."""
import json
import pytest
from tests.test_factory import client,create
from app import blueprints,db,v8_templates
def _home(c,sid):
return next(x for x in c.get(f'/api/sites/{sid}/entries').json() if x['route']=='/')
def test_six_new_schema_blocks_and_safe_rendering():
specs=blueprints.BLOCK_PROPERTY_SCHEMA
kinds=('carousel','video','tabs','pricing','map','form_link')
assert all(k in specs for k in kinds)
blocks=[blueprints.new_block(x) for x in kinds]
assert len(blueprints.validate_blocks(blocks))==6
video=blueprints.new_block('video',{'url':'https://www.youtube.com/watch?v=dQw4w9WgXcQ'})
html=blueprints.render_blocks([video],{}, {}, [])
assert 'youtube-nocookie.com/embed/dQw4w9WgXcQ' in html
assert 'iframe' in html
blocked=blueprints.new_block('video',{'url':'https://www.evil.example/video/123'})
assert 'iframe' not in blueprints.render_blocks([blocked],{}, {}, [])
item=blueprints.new_block('pricing',{'items':[{'name':'<script>alert(1)</script>', 'price':'$5','features':['<img src=x onerror=alert(1)>'], 'href':'javascript:alert(1)'}]})
text=blueprints.render_blocks([item],{}, {}, [])
assert '<script>' not in text and '<img src=x' not in text and 'javascript:' not in text
assert '&lt;script&gt;' in text
def test_malformed_and_oversized_blocks_rejected():
with pytest.raises(ValueError):blueprints.validate_blocks([blueprints.new_block('tabs',{'items':[{'text':'a'}]*17})])
with pytest.raises(ValueError):blueprints.validate_blocks([blueprints.new_block('pricing',{'items':[{'features':['a']*13}]})])
def test_editor_draft_is_separate_until_published(client):
sid=create(client)
eid=_home(client,sid)['id']
before=db.one('SELECT meta_json FROM entries WHERE id=? AND site_id=?',(eid,sid))['meta_json']
draft=blueprints.new_block('richtext',{'title':'Design unpublished V86'})
req={'blocks':[draft],'page_template':'home','layout_mode':'replace','expected_revision':0}
saved=client.put(f'/api/sites/{sid}/pages/{eid}/studio-draft',json=req)
assert saved.status_code==200,saved.text
assert saved.json()['revision']==1
assert db.one('SELECT meta_json FROM entries WHERE id=? AND site_id=?',(eid,sid))['meta_json']==before
read=client.get(f'/api/sites/{sid}/pages/{eid}/studio-draft').json()
assert read['blocks'][0]['title']=='Design unpublished V86'
assert read['entry']['route']=='/'
pub=client.post(f'/api/sites/{sid}/pages/{eid}/studio-draft/publish',json={'expected_revision':1})
assert pub.status_code==200,pub.text
after=db.one('SELECT meta_json FROM entries WHERE site_id=? AND id=?',(sid,eid))['meta_json']
assert 'Design unpublished V86' in after and before!=after
assert client.get(f'/api/sites/{sid}/pages/{eid}/studio-draft').json()['state']=='published'
assert db.one('SELECT COUNT(*) n FROM studio_publications WHERE site_id=?',(sid,))['n']==1
def test_draft_revision_and_changed_entry_conflicts(client):
sid=create(client);eid=_home(client,sid)['id']
endpoint=f'/api/sites/{sid}/pages/{eid}/studio-draft'
body={'blocks':[blueprints.new_block('cta',{'title':'draft'})], 'page_template':'home','expected_revision':0}
assert client.put(endpoint,json=body).status_code==200
assert client.put(endpoint,json=body).status_code==409
assert client.post(endpoint+'/publish',json={'expected_revision':0}).status_code==409
row=db.one('SELECT * FROM entries WHERE site_id=? AND id=?',(sid,eid))
db.execute('UPDATE entries SET title=? WHERE id=?',('Changed elsewhere',eid))
assert client.post(endpoint+'/publish',json={'expected_revision':1}).status_code==409
assert client.get(endpoint).json()['conflict'] is True
assert db.one('SELECT title FROM entries WHERE id=?',(eid,))['title']=='Changed elsewhere'
def test_discard_and_site_isolation(client):
sid=create(client);other=create(client);eid=_home(client,sid)['id']
endpoint=f'/api/sites/{sid}/pages/{eid}/studio-draft'
assert client.put(endpoint,json={'blocks':[],'page_template':'home','expected_revision':0}).status_code==200
assert client.get(f'/api/sites/{other}/pages/{eid}/studio-draft').status_code==404
assert client.delete(endpoint).status_code==200
assert client.get(endpoint).json()['revision']==0
assert client.post(endpoint+'/publish',json={'expected_revision':0}).status_code==409
def test_draft_preset_and_property_contract(client):
sid=create(client);eid=_home(client,sid)['id']
r=client.get(f'/api/sites/{sid}/pages/{eid}/studio-draft/preset?page_template=home')
assert r.status_code==200 and r.json()['blocks']
assert client.get('/api/editor/blocks').json()['property_schemas']['carousel']['allows_arbitrary_code'] is False
def test_whole_site_preflight_is_read_only(client):
sid=create(client)
snap=v8_templates.export_template(sid,'Sample')
assert snap['theme_version']
p=client.post(f'/api/factory/sites/{sid}/whole-site-templates/preflight',json={'template':snap})
assert p.status_code==200,p.text
assert p.json()['compatible'] is True and p.json()['no_changes_applied']
assert p.json()['existing_routes']==['/']
broken={**snap,'theme_version':'99.0.0'}
fail=client.post(f'/api/factory/sites/{sid}/whole-site-templates/preflight',json={'template':broken})
assert fail.status_code==200 and fail.json()['compatible'] is False
assert client.post(f'/api/factory/sites/{sid}/whole-site-templates/apply',json={'template':broken,'confirm':True,'overwrite_layouts':True}).status_code==422
def test_draft_not_rendered_in_static_build_until_publish(client):
from app import builder
from pathlib import Path
import zipfile
sid=create(client)
eid=_home(client,sid)['id']
title='V86_PRIVATE_UNPUBLISHED_DESIGN'
payload={'blocks':[blueprints.new_block('richtext',{'title':title,'body':'<p>release barrier</p>'})],
'page_template':'home','layout_mode':'replace','expected_revision':0}
endpoint=f'/api/sites/{sid}/pages/{eid}/studio-draft'
assert client.put(endpoint,json=payload).status_code==200
before=builder.build(sid,False)
with zipfile.ZipFile(db.DATA/'archives'/f'{sid}-{before["version"]}.zip') as archive:
assert title not in archive.read('index.html').decode('utf-8')
assert client.post(endpoint+'/publish',json={'expected_revision':1}).status_code==200
after=builder.build(sid,False)
with zipfile.ZipFile(db.DATA/'archives'/f'{sid}-{after["version"]}.zip') as archive:
assert title in archive.read('index.html').decode('utf-8')
def test_editor_page_uses_draft_ui(client):
sid=create(client)
eid=_home(client,sid)['id']
html=client.get(f'/editor/{sid}/{eid}')
assert html.status_code==200
assert '发布设计' in html.text and '保存草稿' in html.text
js=client.get('/ui/editor.js').text
assert '/studio-draft' in js and 'publishDesign' in js
+122
View File
@@ -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
+18
View File
@@ -0,0 +1,18 @@
from pathlib import Path
from scripts.install_into_art_design_pro import install
def test_art_route_registration_backup_and_idempotency(tmp_path):
fake=tmp_path/'official';routes=fake/'src/router/routes/asyncRoutes.ts'
routes.parent.mkdir(parents=True);routes.write_text('export const asyncRoutes: any[] = []\n')
(fake/'package.json').write_text('{"name":"art-test"}')
install(fake,dry_run=True)
assert routes.read_text()=='export const asyncRoutes: any[] = []\n'
install(fake,dry_run=False)
updated=routes.read_text()
assert updated.count('// SITEFORGE_FACTORY_V8_REGISTERED')==1
assert 'SiteForgeV8Production' in updated
assert (fake/'src/views/siteforge/V8Production.vue').is_file()
install(fake,dry_run=False)
assert routes.read_text().count('// SITEFORGE_FACTORY_V8_REGISTERED')==1
assert (routes.parent/'asyncRoutes.ts.siteforge-backup').exists()
+204
View File
@@ -0,0 +1,204 @@
import json
import pytest
from fastapi import HTTPException
from fastapi.testclient import TestClient
from app import db, v8_workflows as workflow, v8_templates as templating
from app.core import save_entry
from app.main import app
@pytest.fixture
def env(tmp_path,monkeypatch):
monkeypatch.setattr(db,'DATA',tmp_path)
monkeypatch.setattr(db,'DB_FILE',tmp_path/'siteforge.sqlite3')
monkeypatch.setenv('SITEFORGE_ADMIN_PASSWORD','dev-v8-admin-password-with-sufficient-entropy')
monkeypatch.setenv('SITEFORGE_SESSION_SECRET','v8-testing-session-secret-that-is-not-production')
db.init(); workflow.setup();
from app import cms_pipeline
cms_pipeline.setup();
from app import theme_store
theme_store.setup()
def site(name):
from app.core import uid
i=uid();t=db.now()
db.insert('''INSERT INTO sites(id,name,domain,tagline,theme,accent,logo,mode,settings_json,created_at,updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?)''',(i,name,'','', 'corporate','#2563eb','','new','{}',t,t))
save_entry(i,{'type':'page','title':'首页','route':'/','seo_title':'Example Home','meta':{'blocks':[]}})
return i
yield site
def rows():return [{'车型名称':'Model A','品牌':'Maker A'},{'车型名称':'Model B','品牌':'Maker B'}]
def test_stage_commit_and_rollback(env):
sid=env('Cars')
s=workflow.stage(sid,rows(),kind='vehicle')
assert s['mapped']==2 and not s['conflicts']
assert db.one('SELECT COUNT(*) n FROM entries WHERE site_id=?',(sid,))['n']==1
commit=workflow.commit(sid,s['batch_id'])
assert commit['created']==2 and commit['state']=='committed'
assert workflow.commit(sid,s['batch_id'])['existing']
rollback=workflow.rollback(sid,s['batch_id'])
assert rollback['restored']==2
assert workflow.rollback(sid,s['batch_id'])['existing']
assert db.one('SELECT COUNT(*) n FROM entries WHERE site_id=?',(sid,))['n']==1
def test_replace_restore_seo_and_body(env):
sid=env('Factory')
original={'type':'product','title':'Original','route':'/product/test/','seo_title':'Old SEO',
'body':'Old body'}
eid=save_entry(sid,original)
s=workflow.stage(sid,[{'title':'New title','route':'/product/test/','body':'New body','seo_title':'New SEO'}],
mapping={'title':'title','route':'route','body':'body','seo_title':'seo_title'})
assert s['conflicts']==['/product/test/']
assert workflow.commit(sid,s['batch_id'],'replace')['updated']==1
assert db.one('SELECT seo_title FROM entries WHERE id=?',(eid,))['seo_title']=='New SEO'
workflow.rollback(sid,s['batch_id'])
restored=db.one('SELECT title,seo_title,body FROM entries WHERE id=?',(eid,))
assert restored=={'title':'Original','seo_title':'Old SEO','body':'<p>Old body</p>'} or restored['seo_title']=='Old SEO' and restored['title']=='Original'
def test_rollback_refuses_clobbering_manual_edits(env):
sid=env('Brand'); s=workflow.stage(sid,rows()[:1],kind='vehicle')
workflow.commit(sid,s['batch_id'])
row=db.one("SELECT id FROM entries WHERE site_id=? AND type='vehicle'",(sid,))
db.execute('UPDATE entries SET title=? WHERE id=?',('MANUAL EDIT',row['id']))
with pytest.raises(HTTPException) as exc:workflow.rollback(sid,s['batch_id'])
assert exc.value.status_code==409
assert db.one('SELECT title FROM entries WHERE id=?',(row['id'],))['title']=='MANUAL EDIT'
def test_rollback_conflict_no_partial_side_effects(env):
sid=env('Batch'); s=workflow.stage(sid,rows(),kind='vehicle');workflow.commit(sid,s['batch_id'])
obj=db.rows("SELECT id FROM entries WHERE site_id=? AND type='vehicle' ORDER BY title",(sid,))
db.execute('UPDATE entries SET title=? WHERE id=?',('Changed',obj[1]['id']))
with pytest.raises(HTTPException):workflow.rollback(sid,s['batch_id'])
assert db.one("SELECT COUNT(*) n FROM entries WHERE site_id=? AND type='vehicle'",(sid,))['n']==2
def test_source_keys_must_be_unique(env):
sid=env('A')
with pytest.raises(ValueError,match='主键重复'):
workflow.stage(sid,rows(),kind='vehicle',source_keys=['same','same'])
def test_wxr_staging_and_xxe_rejection(env):
sid=env('WP')
raw=b'''<rss version="2.0" xmlns:wp="http://wordpress.org/export/1.2/"
xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><item>
<title>WordPress Page</title><link>https://old.example.org/about/</link>
<wp:post_id>42</wp:post_id><wp:post_type>page</wp:post_type><wp:status>publish</wp:status>
<content:encoded><![CDATA[<p>Hello</p>]]></content:encoded>
</item></channel></rss>'''
rows,ids,warnings=workflow.parse_wxr(raw)
assert rows[0]['路径']=='/about/' and ids==['wordpress:42'] and not warnings
payload=workflow.stage(sid,rows,{'title':'标题','route':'路径','body':'正文','seo_title':'SEO标题','seo_description':'SEO描述'},
kind='page',origin='wordpress-wxr',source_keys=ids)
assert workflow.commit(sid,payload['batch_id'])['created']==1
assert db.one('SELECT title FROM entries WHERE route=? AND site_id=?',('/about/',sid))['title']=='WordPress Page'
with pytest.raises(Exception):workflow.parse_wxr(b'<!DOCTYPE rss [<!ENTITY xxe SYSTEM "file:///etc/passwd">]><rss>&xxe;</rss>')
def test_whole_site_blueprint_no_business_copy(env):
source=env('Source'); dest=env('Destination')
from app import blueprints
page=blueprints.new_block('hero',{'title':'CONFIDENTIAL BRAND','description':'Secret marketing copy',
'image':'https://customer.example.org/private.png'})
save_entry(source,{'type':'page','title':'About Brand','route':'/about/',
'seo_title':'Confidential SEO','meta':{'blocks':[page]}})
document=templating.export_template(source,'Landing')
assert 'CONFIDENTIAL BRAND' not in json.dumps(document)
assert 'private.png' not in json.dumps(document)
assert 'Confidential SEO' not in json.dumps(document)
proposed=templating.preview_application(dest,document)
assert '/' in proposed['existing']
with pytest.raises(HTTPException):templating.apply_template(dest,document)
done=templating.apply_template(dest,document,overwrite_pages=True)
assert done['content_preserved'] is True
assert db.one('SELECT status,robots FROM entries WHERE site_id=? AND route=?',(dest,'/about/'))=={'status':'draft','robots':'noindex,follow'}
assert db.one('SELECT seo_title FROM entries WHERE site_id=? AND route=?',(dest,'/'))['seo_title']=='Example Home'
def test_template_invalid_and_duplicate_route(env):
sid=env('X')
doc=templating.export_template(sid,'Example')
doc['pages'].append(doc['pages'][0])
with pytest.raises(ValueError,match='重复'):templating.validate_template(doc)
doc['pages'].pop()
doc['theme']='untrusted-script-template'
with pytest.raises(ValueError,match='不可用'):templating.validate_template(doc)
def test_v8_api_authenticated_and_browser_page(env):
with TestClient(app) as client:
assert client.get('/production-v8',follow_redirects=False).status_code==303
assert client.post('/api/auth/login',json={'password':'dev-v8-admin-password-with-sufficient-entropy'}).status_code==200
client.headers['X-SiteForge-CSRF']=client.get('/api/auth/me').json()['csrf']
sid=env('API')
html=client.get('/production-v8')
assert html.status_code==200 and '网站生产控制台' in html.text
stage=client.post(f'/api/factory/sites/{sid}/staging',json={'rows':rows(),'content_type':'vehicle'})
assert stage.status_code==200,stage.text
bid=stage.json()['batch_id']
assert client.post(f'/api/factory/sites/{sid}/staging/{bid}/commit',json={}).status_code==200
assert client.get(f'/api/factory/sites/{sid}/staging').json()['items'][0]['state']=='committed'
snap=client.post(f'/api/factory/sites/{sid}/whole-site-templates',json={'name':'Test'})
assert snap.status_code==200,snap.text
assert client.get('/api/health').json()['version']=='8.10.0-dev.1'
def test_wxr_preserves_post_type(env):
sid=env('WXR post')
rows=[{'标题':'News','路径':'/news/news-a/','正文':'A','类型':'post'},
{'标题':'About','路径':'/about/','正文':'B','类型':'page'}]
s=workflow.stage(sid,rows,{'title':'标题','route':'路径','body':'正文'},'mixed','wordpress-wxr')
workflow.commit(sid,s['batch_id'])
assert db.one('SELECT type FROM entries WHERE site_id=? AND route=?',(sid,'/news/news-a/'))['type']=='post'
assert db.one('SELECT type FROM entries WHERE site_id=? AND route=?',(sid,'/about/'))['type']=='page'
def test_wordpress_complex_builder_creates_release_review(env):
sid=env('Complex WP')
raw=b'''<rss xmlns:wp="http://wordpress.org/export/1.2/">
<channel><item><title>Complex</title><link>https://example.org/a/</link>
<wp:post_type>page</wp:post_type><wp:status>publish</wp:status>
<wp:postmeta><wp:meta_key>_elementor_data</wp:meta_key>
<wp:meta_value><![CDATA[[{"elType":"section"}]]]></wp:meta_value></wp:postmeta>
</item></channel></rss>'''
rows,keys,warnings=workflow.parse_wxr(raw)
assert warnings
batch=workflow.stage(sid,rows,{'title':'标题','route':'路径','body':'正文'},'mixed','wordpress-wxr',keys)
db.execute('UPDATE v8_import_batches SET warnings_json=? WHERE id=?',
(json.dumps(warnings,ensure_ascii=False),batch['batch_id']))
workflow.commit(sid,batch['batch_id'])
assert db.one("SELECT COUNT(*) n FROM cms_import_reviews WHERE site_id=? AND state='pending'",(sid,))['n']==1
def test_csv_file_staged_then_committed_via_api(env):
sid=env('Excel Import')
with TestClient(app) as client:
assert client.post('/api/auth/login',json={'password':'dev-v8-admin-password-with-sufficient-entropy'}).status_code==200
client.headers['X-SiteForge-CSRF']=client.get('/api/auth/me').json()['csrf']
result=client.post(f'/api/factory/sites/{sid}/staging/file',
files={'file':('products.csv','车型名称,品牌\nMy New Vehicle,Acme\n'.encode(),'text/csv')},
data={'content_type':'vehicle'})
assert result.status_code==200,result.text
assert result.json()['mapped']==1
bid=result.json()['batch_id']
result=client.post(f'/api/factory/sites/{sid}/staging/{bid}/commit',json={})
assert result.status_code==200,result.text
assert db.one('SELECT title FROM entries WHERE site_id=? AND type=?',(sid,'vehicle'))['title']=='My New Vehicle'
def test_wordpress_rollback_resolves_its_own_warnings(env):
sid=env('Review rollback')
records=[{'标题':'Legacy','路径':'/legacy/','正文':'Text','类型':'page'}]
batch=workflow.stage(sid,records,{'title':'标题','route':'路径','body':'正文'},'mixed','wordpress-wxr')
db.execute('UPDATE v8_import_batches SET warnings_json=? WHERE id=?',
(json.dumps([{'row':1,'issue':'Unsupported component'}]),batch['batch_id']))
workflow.commit(sid,batch['batch_id'])
from app import cms_pipeline
assert cms_pipeline.open_reviews(sid)==1
workflow.rollback(sid,batch['batch_id'])
assert cms_pipeline.open_reviews(sid)==0
+109
View File
@@ -0,0 +1,109 @@
"""New V6 async contracts: these tests do not use legacy sync adapters."""
import json
from fastapi.testclient import TestClient
import pytest
from app import db,builder,jobs,worker,enterprise
from app.main import app
@pytest.fixture
def web(tmp_path,monkeypatch):
monkeypatch.setattr(db,'DATA',tmp_path)
monkeypatch.setattr(db,'DB_FILE',tmp_path/'siteforge.sqlite3')
monkeypatch.setenv('SITEFORGE_ADMIN_PASSWORD','test-admin-password-for-queue')
monkeypatch.setenv('SITEFORGE_SESSION_SECRET','test-session-secret-for-queue-2026-much-longer')
with TestClient(app) as c:
assert c.post('/api/auth/login',json={'password':'test-admin-password-for-queue'}).status_code==200
c.headers['X-SiteForge-CSRF']=c.get('/api/auth/me').json()['csrf']
sid=c.post('/api/sites',json={'name':'Queued Site','theme':'corporate'}).json()['id']
yield c,sid
def test_api_enqueue_idempotency_cancel_and_version(web):
c,sid=web
from app.version import VERSION
assert c.get('/api/health').json()['version']==VERSION
key='same-request-unique-123'
r=c.post(f'/api/sites/{sid}/builds',json={'mirror_images':False},headers={'Idempotency-Key':key})
assert r.status_code==202,r.text
j=r.json()['id']
duplicate=c.post(f'/api/sites/{sid}/builds',json={'mirror_images':False},headers={'Idempotency-Key':key})
assert duplicate.json()['id']==j and duplicate.json()['existing']
conflict=c.post(f'/api/sites/{sid}/builds',json={'mirror_images':True},headers={'Idempotency-Key':key})
assert conflict.status_code==409
cancelling=c.post(f'/api/enterprise/sites/{sid}/jobs/{j}/cancel')
assert cancelling.status_code==200 and cancelling.json()['state']=='cancelled'
assert worker.run_once(executor=lambda sid,options:builder.build(sid)) is None
assert c.get(f'/api/enterprise/sites/{sid}/jobs/{j}').json()['state']=='cancelled'
def test_only_worker_generates_static_build(web):
c,sid=web
start=c.get(f'/api/sites/{sid}/builds').json()
submitted=c.post(f'/api/sites/{sid}/builds',json={})
assert submitted.status_code==202
assert c.get(f'/api/sites/{sid}/builds').json()==start
job=submitted.json()['id']
assert worker.run_once(executor=lambda sid,opts:builder.build(sid,bool(opts.get('mirror_images',False))))==job
record=c.get(f'/api/enterprise/sites/{sid}/jobs/{job}').json()
assert record['state']=='done',record['error']
version=json.loads(record['result_json'])['version']
assert c.get(f'/preview/{sid}/{version}/').status_code==200
def test_failed_job_retries_and_lease_expiry(web):
c,sid=web
job=c.post(f'/api/sites/{sid}/builds',json={}).json()['id']
attempts=[]
def fail_once(sid,opts):
attempts.append(sid)
if len(attempts)==1:raise TimeoutError('transient timeout')
return {'version':'ok','pages':1}
worker.run_once(executor=fail_once)
row=db.one('SELECT * FROM build_jobs WHERE id=?',(job,))
assert row['state']=='retrying' and row['attempts']==1
db.execute('UPDATE build_jobs SET available_at=? WHERE id=?',(db.now(),job))
worker.run_once(executor=fail_once)
assert db.one('SELECT state,attempts FROM build_jobs WHERE id=?',(job,))=={'state':'done','attempts':2}
second=c.post(f'/api/sites/{sid}/builds',json={}).json()['id']
db.execute("UPDATE build_jobs SET state='running',worker_id='lost',lease_until='2020-01-01T00:00:00+00:00' WHERE id=?",(second,))
assert jobs.recover_expired()==1
assert db.one('SELECT state FROM build_jobs WHERE id=?',(second,))['state']=='retrying'
def test_real_subprocess_worker(web):
c,sid=web
jid=c.post(f'/api/sites/{sid}/builds',json={}).json()['id']
worker.run_once() # real Python subprocess, reads same test SQLite database
row=db.one('SELECT * FROM build_jobs WHERE id=?',(jid,))
assert row['state']=='done',row['error']
report=json.loads(row['result_json'])
assert report['pages']>=1
def test_cancel_actively_running_subprocess(web,monkeypatch):
import subprocess,sys,threading,time
c,sid=web
jobid=c.post(f'/api/sites/{sid}/builds',json={}).json()['id']
original=subprocess.Popen
monkeypatch.setattr(worker.subprocess,'Popen',lambda *args,**kwargs:original(
[sys.executable,'-c','import time;time.sleep(10)'],**kwargs))
t=threading.Thread(target=worker.run_once)
t.start()
for _ in range(100):
row=db.one('SELECT state FROM build_jobs WHERE id=?',(jobid,))
if row and row['state']=='running':break
time.sleep(0.02)
r=c.post(f'/api/enterprise/sites/{sid}/jobs/{jobid}/cancel')
assert r.status_code==200
t.join(timeout=6)
assert not t.is_alive()
assert db.one('SELECT state FROM build_jobs WHERE id=?',(jobid,))['state']=='cancelled'
def test_build_timeout_sets_retrying(web,monkeypatch):
import subprocess,sys
c,sid=web
jobid=c.post(f'/api/sites/{sid}/builds',json={}).json()['id']
db.execute('UPDATE build_jobs SET timeout_seconds=1 WHERE id=?',(jobid,))
original=subprocess.Popen
monkeypatch.setattr(worker.subprocess,'Popen',lambda *args,**kwargs:original(
[sys.executable,'-c','import time;time.sleep(10)'],**kwargs))
worker.run_once()
row=db.one('SELECT state,error FROM build_jobs WHERE id=?',(jobid,))
assert row['state']=='retrying',row
assert 'timed out' in row['error']
+49
View File
@@ -0,0 +1,49 @@
"""V8.10 usability regression: simplified admin replaces only the default UI.
Preserves existing API endpoints and the opt-in legacy console.
"""
from pathlib import Path
from test_factory import client
def test_default_workspace_has_guided_navigation(client):
page=client.get('/')
assert page.status_code==200
for label in ('工作台','我的网站','网站概览','内容与页面','外观与设计',
'导入与迁移','构建与交付','网站设置','高级工具'):
assert label in page.text
assert '/ui/workspace.js' in page.text
assert '/ui/workspace.css' in page.text
assert '/production-v8' in page.text and '/operations' in page.text
def test_old_console_still_available_but_not_default(client):
assert '/ui/workspace.js' not in client.get('/classic').text
assert 'admin.js' in client.get('/classic').text
assert client.get('/production-v8').status_code==200
assert client.get('/factory-studio').status_code==200
def test_workspace_static_assets_and_login_guard(client):
js=client.get('/ui/workspace.js');css=client.get('/ui/workspace.css')
assert js.status_code==200 and css.status_code==200
assert 'X-SiteForge-CSRF' in js.text
assert 'encodeURIComponent' in js.text
assert '/api/sites' in js.text
assert 'grid-template-columns' in css.text
assert 'max-width:570px' in css.text
def test_workspace_new_site_and_content_api(client):
created=client.post('/api/sites',json={'name':'UI Acceptance','theme':'industrial','mode':'new'})
assert created.status_code==201
sid=created.json()['id']
assert len(client.get(f'/api/sites/{sid}/entries').json())==1
created_entry=client.post(f'/api/sites/{sid}/entries',json={
'type':'product','title':'P001 产品','route':'/products/p001/','summary':'测试产品'})
assert created_entry.status_code==201
edited=client.patch(f'/api/sites/{sid}/entries/{created_entry.json()["id"]}',
json={'summary':'用户界面已修改'})
assert edited.status_code==200
switched=client.patch(f'/api/sites/{sid}',json={'theme':'corporate'})
assert switched.status_code==200
assert any(e['summary']=='用户界面已修改' for e in client.get(f'/api/sites/{sid}/entries').json())
+158
View File
@@ -0,0 +1,158 @@
"""Deterministic WP REST fixture tests, with no real external network."""
import json
import pytest
from fastapi import HTTPException
from fastapi.testclient import TestClient
from app import db, wp_migration_v81 as wp, v8_workflows as w, enterprise, migration_audit
from app.core import save_entry, uid
from app.main import app
@pytest.fixture
def env(tmp_path, monkeypatch):
monkeypatch.setattr(db,'DATA',tmp_path)
monkeypatch.setattr(db,'DB_FILE',tmp_path/'siteforge.sqlite3')
monkeypatch.setenv('SITEFORGE_ADMIN_PASSWORD','test-v8-admin-strong-password-12345')
monkeypatch.setenv('SITEFORGE_SESSION_SECRET','test-v8-super-secret-long-key-12345')
db.init(); enterprise.setup(); migration_audit.setup(); w.setup(); wp.setup()
from app import cms_pipeline
cms_pipeline.setup()
def site(name='Migration'):
i=uid(); t=db.now()
db.insert('''INSERT INTO sites(id,name,domain,tagline,theme,accent,logo,mode,settings_json,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?)''',(i,name,'','','automotive','#222222','','migration','{}',t,t))
return i
yield site
def item(wp_id,route,kind='pages'):
return {'id':wp_id,'link':'https://old.example.org'+route,
'title':{'rendered':f'WP Title {wp_id}'},
'excerpt':{'rendered':'<p>Intro</p>'},'content':{'rendered':'<p>Legacy HTML</p>'},
'yoast_head_json':{'title':f'Legacy SEO {wp_id}', 'description':f'Meta {wp_id}',
'canonical':'https://old.example.org'+route},
'_embedded':{'wp:featuredmedia':[{'source_url':'https://old.example.org/image.jpg'}]}}
def mock_pages(monkeypatch, pages):
def fetch(origin,phase,page):return pages.get((phase,page),[])
monkeypatch.setattr(wp,'_fetch',fetch)
def test_complete_scan_stage_and_baseline(env,monkeypatch):
sid=env(); mock_pages(monkeypatch,{('pages',1):[item(42,'/about/')],('posts',1):[item(84,'/news/item/')]})
sess=wp.create(sid,'https://old.example.org',200)
assert sess['state']=='created' and sess['next_page']==1
scan=wp.advance(sid,sess['id']); assert scan['state']=='paused' and scan['phase']=='posts'
assert not db.rows('SELECT * FROM entries WHERE site_id=?',(sid,))
scan=wp.advance(sid,sess['id']); assert scan['state']=='ready' and scan['fetched']==2
done=wp.finalize(sid,sess['id'])
assert done['baseline_saved']==2 and done['session']['state']=='completed_with_warnings'
assert {x['route']:x['type'] for x in db.rows('SELECT route,type FROM entries WHERE site_id=?',(sid,))}=={'/about/':'page','/news/item/':'post'}
assert db.one('SELECT seo_title FROM migration_baseline WHERE site_id=? AND route=?',(sid,'/about/'))['seo_title']=='Legacy SEO 42'
assert wp.finalize(sid,sess['id'])['state']=='completed_with_warnings'
def test_network_interruption_resumes_exact_page(env,monkeypatch):
sid=env(); seen=[]
def flakey(origin,phase,page):
seen.append((phase,page))
if len(seen)==1:raise OSError('simulated network outage')
return [item(1,'/about/')] if phase=='pages' else []
monkeypatch.setattr(wp,'_fetch',flakey)
sess=wp.create(sid,'https://old.example.org/',100)
with pytest.raises(HTTPException) as exc:wp.advance(sid,sess['id'])
assert exc.value.status_code==502
assert wp.details(sid,sess['id'])['next_page']==1
wp.advance(sid,sess['id'])
assert seen==[('pages',1),('pages',1)]
assert len(wp.details(sid,sess['id'])['batches'])==1
def test_bad_offsite_url_requires_review(env,monkeypatch):
sid=env(); wrong=item(1,'/');wrong['link']='https://other.example.org/not-ours/'
mock_pages(monkeypatch,{('pages',1):[wrong,item(2,'/safe/')],('posts',1):[]})
s=wp.create(sid,'https://old.example.org/',100)
wp.advance(sid,s['id']); result=wp.advance(sid,s['id'])
assert result['reviewed']>=1 and any('不同' in x['issue'] or '固定 URL' in x['issue'] for x in result['warnings'])
done=wp.finalize(sid,s['id'])
assert done['requires_review'] and done['session']['state']=='completed_with_warnings'
from app import cms_pipeline
assert cms_pipeline.open_reviews(sid)>0
assert db.one('SELECT COUNT(*) n FROM migration_baseline WHERE site_id=?',(sid,))['n']==1
def test_cancel_prevents_resume(env,monkeypatch):
sid=env();mock_pages(monkeypatch,{('pages',1):[item(1,'/a/')]})
s=wp.create(sid,'https://old.example.org/',100)
assert wp.cancel(sid,s['id'])['state']=='cancelled'
with pytest.raises(HTTPException) as exc:wp.advance(sid,s['id'])
assert exc.value.status_code==409
with pytest.raises(HTTPException):wp.finalize(sid,s['id'])
def test_limit_reached_not_marked_complete(env,monkeypatch):
sid=env();mock_pages(monkeypatch,{('pages',1):[item(1,'/a/'),item(2,'/b/')]})
s=wp.create(sid,'https://old.example.org/',1)
result=wp.advance(sid,s['id'])
assert result['state']=='limit_reached' and not result['batches']
with pytest.raises(HTTPException):wp.finalize(sid,s['id'])
def test_cross_site_isolation(env,monkeypatch):
sid=env('A');other=env('B');s=wp.create(sid,'https://old.example.org/',100)
with pytest.raises(HTTPException) as exc:wp.details(other,s['id'])
assert exc.value.status_code==404
def test_commit_preserves_existing_on_skip(env,monkeypatch):
sid=env(); save_entry(sid,{'type':'page','title':'Custom About','route':'/about/','seo_title':'Our SEO'})
mock_pages(monkeypatch,{('pages',1):[item(1,'/about/')],('posts',1):[]})
s=wp.create(sid,'https://old.example.org/',100);wp.advance(sid,s['id']);wp.advance(sid,s['id'])
wp.finalize(sid,s['id'])
assert db.one('SELECT title FROM entries WHERE site_id=? AND route=?',(sid,'/about/'))['title']=='Custom About'
assert db.one('SELECT seo_title FROM migration_baseline WHERE site_id=? AND route=?',(sid,'/about/'))['seo_title']=='Legacy SEO 1'
def test_authenticated_http_workflow(env,monkeypatch):
sid=env();mock_pages(monkeypatch,{('pages',1):[item(3,'/test/')],('posts',1):[]})
with TestClient(app) as c:
assert c.post(f'/api/migrations/sites/{sid}/wordpress/sessions',json={'origin':'https://old.example.org'}).status_code==401
assert c.post('/api/auth/login',json={'password':'test-v8-admin-strong-password-12345'}).status_code==200
c.headers['X-SiteForge-CSRF']=c.get('/api/auth/me').json()['csrf']
created=c.post(f'/api/migrations/sites/{sid}/wordpress/sessions',json={'origin':'https://old.example.org'})
assert created.status_code==200,created.text
session=created.json()['id']
assert c.get('/production-v8').status_code==200
for _ in range(2):
r=c.post(f'/api/migrations/sites/{sid}/wordpress/sessions/{session}/advance')
assert r.status_code==200,r.text
fin=c.post(f'/api/migrations/sites/{sid}/wordpress/sessions/{session}/finalize')
assert fin.status_code==200,fin.text
assert fin.json()['baseline_saved']==1
def test_invalid_origin_or_duplicate_processing(env,monkeypatch):
sid=env()
for origin in ['file:///etc/passwd','https://user:pass@example.org','http://example.org?token=a',
'https://example.org:9999/path']:
with pytest.raises(ValueError):wp.create(sid,origin)
s=wp.create(sid,'https://old.example.org/',100)
db.execute("UPDATE wp_migration_sessions SET state='running',lease_until='9999-01-01T00:00:00+00:00' WHERE id=?",(s['id'],))
with pytest.raises(HTTPException) as exc:wp.advance(sid,s['id'])
assert exc.value.status_code==409
def test_missing_seo_and_remote_media_create_release_review(env,monkeypatch):
sid=env(); bad=item(10,'/product/example/')
bad['yoast_head_json']={}
mock_pages(monkeypatch,{('pages',1):[bad],('posts',1):[]})
s=wp.create(sid,'https://old.example.org/',100)
r=wp.advance(sid,s['id'])
assert r['reviewed']>=2
wp.advance(sid,s['id'])
done=wp.finalize(sid,s['id'])
assert done['session']['state']=='completed_with_warnings'
from app.cms_pipeline import open_reviews
assert open_reviews(sid)>=2
assert wp.finalize(sid,s['id'])['state']=='completed_with_warnings'
assert open_reviews(sid)>=2
+167
View File
@@ -0,0 +1,167 @@
"""Deterministic tests for V8.2 sitemap, media, durable Worker, and release gates."""
import json
from pathlib import Path
import pytest
from fastapi import HTTPException
from fastapi.testclient import TestClient
from app import db, enterprise, migration_audit, v8_workflows as wf, wp_migration_v81 as wp
from app import wp_migration_v82 as ext, jobs, worker
from app.core import uid
from app.main import app
from app.safe_http import Fetched
@pytest.fixture
def sandbox(tmp_path,monkeypatch):
monkeypatch.setattr(db,'DATA',tmp_path)
monkeypatch.setattr(db,'DB_FILE',tmp_path/'siteforge.sqlite3')
monkeypatch.setenv('SITEFORGE_ADMIN_PASSWORD','test-v8.2-admin-password-long')
monkeypatch.setenv('SITEFORGE_SESSION_SECRET','test-v8.2-session-secret-strong-and-extra-words')
db.init();enterprise.setup();migration_audit.setup();wf.setup();wp.setup()
from app import cms_pipeline
cms_pipeline.setup()
def create_site(mode='migration'):
sid=uid();now=db.now()
db.insert('INSERT INTO sites(id,name,theme,mode,created_at,updated_at) VALUES(?,?,?,?,?,?)',
(sid,'Test site','corporate',mode,now,now))
return sid
return create_site
def _record(item_id,path,with_media=False):
url='https://example.org/wp-content/uploads/item.png'
body='<p>Product</p>'+('<img src="'+url+'" srcset="'+url+' 1x">' if with_media else '')
return {'id':item_id,'link':'https://example.org'+path,'title':{'rendered':f'Page {item_id}'},
'excerpt':{'rendered':'Intro'},'content':{'rendered':body},
'yoast_head_json':{'title':'SEO','description':'Meta','canonical':'https://example.org'+path},
'_embedded':{'wp:featuredmedia':[{'source_url':url}] if with_media else []}}
def _scan(monkeypatch,session,site_id,media=False):
monkeypatch.setattr(wp,'_fetch',lambda origin,phase,page: [_record(1,'/about/',media)] if phase=='pages' else [])
wp.advance(site_id,session);wp.advance(site_id,session)
def test_sitemap_index_detects_missing_original_urls(sandbox,monkeypatch):
site=sandbox();ses=wp.create(site,'https://example.org/',200)
files={
'wp-sitemap.xml':b'<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"><sitemap><loc>https://example.org/child.xml</loc></sitemap></sitemapindex>',
'child.xml':b'<urlset><url><loc>https://example.org/about/</loc></url><url><loc>https://example.org/missing/</loc></url></urlset>'}
monkeypatch.setattr(ext.safe_http,'fetch',lambda url,**kw:Fetched(url,200,{'content-type':'application/xml'},files[url.rsplit('/',1)[-1]]))
result=ext.discover(site,ses['id']);assert result['discovered']==2
_scan(monkeypatch,ses['id'],site)
assert ext.sitemap_status(site,ses['id'])['missing_count']==1
final=wp.finalize(site,ses['id'])
assert final['session']['state']=='completed_with_warnings'
from app.cms_pipeline import open_reviews
assert open_reviews(site)>=1
assert any('missing' in x['issue'] for x in db.rows('SELECT issue FROM cms_import_reviews WHERE site_id=?',(site,)))
def test_sitemap_rejects_xxe_and_external_host(sandbox,monkeypatch):
site=sandbox();ses=wp.create(site,'https://example.org/',200)
monkeypatch.setattr(ext.safe_http,'fetch',lambda url,**kw:Fetched(url,200,{},
b'<!DOCTYPE x [<!ENTITY payload SYSTEM "file:///etc/passwd">]><urlset/>'))
with pytest.raises(ValueError):ext.discover(site,ses['id'])
with pytest.raises(ValueError):ext.discover(site,ses['id'],'https://attacker.invalid/sitemap.xml')
with pytest.raises(ValueError):ext.discover(site,ses['id'],'http://example.org/wp-sitemap.xml')
def test_sitemap_fails_closed_on_decompression_bomb(sandbox,monkeypatch):
site=sandbox();ses=wp.create(site,'https://example.org/',200)
import gzip
compressed=gzip.compress(b'<urlset>'+b' '*3_000_200+b'</urlset>')
monkeypatch.setattr(ext.safe_http,'fetch',lambda url,**kw:Fetched(url,200,{},compressed))
with pytest.raises(ValueError,match='解压'):ext.discover(site,ses['id'])
def test_media_localized_into_site_scoped_files(sandbox,monkeypatch):
site=sandbox();ses=wp.create(site,'https://example.org/',200);_scan(monkeypatch,ses['id'],site,media=True)
image=b'\x89PNG\r\n\x1a\n'+b'\x00'*80
monkeypatch.setattr(ext.safe_http,'fetch',lambda url,**kw:Fetched(url,200,{'content-type':'image/png'},image))
report=ext.localize_media(site,ses['id'],20)
assert report['localized']==1 and report['remaining']==0
batch=wp.details(site,ses['id'])['batches'][0]['batch_id']
row=db.one('SELECT content_json FROM v8_import_items WHERE batch_id=?',(batch,))
content=json.loads(row['content_json']);assert content['cover'].startswith('/media/wp-')
assert 'srcset' not in content['body']
assert 'https://example.org/wp-content/uploads/item.png' not in content['body']
assert (db.DATA/'media'/site/content['cover'].split('/')[-1]).is_file()
assert ext.localize_media(site,ses['id'],20)['attempted']==0
# Do not accidentally copy source media between different projects.
other=sandbox();assert not (db.DATA/'media'/other).exists()
def test_media_cdn_requires_explicit_review(sandbox,monkeypatch):
site=sandbox();ses=wp.create(site,'https://example.org/',200)
_scan(monkeypatch,ses['id'],site,media=True)
# A cross-site URL is rejected without a network request.
monkeypatch.setattr(ext.safe_http,'fetch',lambda *a,**kw:pytest.fail('unexpected outbound connection'))
batch=wp.details(site,ses['id'])['batches'][0]['batch_id']
obj=json.loads(db.one('SELECT content_json FROM v8_import_items WHERE batch_id=?',(batch,))['content_json'])
obj['cover']='https://cdn.example.net/photo.png'
obj['body']='<p>No remote image</p>'
db.execute('UPDATE v8_import_items SET content_json=? WHERE batch_id=?',(json.dumps(obj),batch))
report=ext.localize_media(site,ses['id'],20)
assert report['failed'] and '单独授权' in report['failed'][0]['reason']
assert report['requires_review']
def test_migration_job_idempotency_and_capacity(sandbox,monkeypatch):
site=sandbox();ses=wp.create(site,'https://example.org/',200)
first=ext.submit_scan(site,ses['id'],'test','wp-migration:unique-id')
duplicate=ext.submit_scan(site,ses['id'],'test','wp-migration:unique-id')
assert first['id']==duplicate['id'] and duplicate['existing']
other=sandbox(); other_session=wp.create(other,'https://example.org/',200)
monkeypatch.setenv('SITEFORGE_MAX_PENDING_JOBS','1')
with pytest.raises(jobs.JobCapacityError):ext.submit_scan(other,other_session['id'],'test')
def test_worker_dispatch_and_partial_scan_resume(sandbox,monkeypatch):
site=sandbox();ses=wp.create(site,'https://example.org/',200)
monkeypatch.setattr(wp,'_fetch',lambda origin,phase,page: [_record(3,'/services/')] if phase=='pages' else [])
job=ext.submit_scan(site,ses['id'],'test')
# Independent process uses the same dispatch path; invoke the scanner directly with mocked fixture.
out=ext.run_scan(site,ses['id'],job['id'])
assert out['state']=='ready'
assert wp.details(site,ses['id'])['fetched']==1
assert wp.finalize(site,ses['id'])['baseline_saved']==1
def test_authenticated_sitemap_and_worker_endpoints(sandbox,monkeypatch):
site=sandbox();ses=wp.create(site,'https://example.org/',200)
xml=b'<urlset><url><loc>https://example.org/about/</loc></url></urlset>'
monkeypatch.setattr(ext.safe_http,'fetch',lambda url,**kw:Fetched(url,200,{},xml))
endpoint=f'/api/migrations/sites/{site}/wordpress/sessions/{ses["id"]}'
with TestClient(app) as client:
assert client.post(endpoint+'/scan',json={}).status_code==401
assert client.post('/api/auth/login',json={'password':'test-v8.2-admin-password-long'}).status_code==200
client.headers['X-SiteForge-CSRF']=client.get('/api/auth/me').json()['csrf']
assert client.post(endpoint+'/sitemap',json={}).json()['discovered']==1
job=client.post(endpoint+'/scan',json={})
assert job.status_code==200,job.text
assert client.post(endpoint+'/scan',json={}).json()['id']==job.json()['id']
assert client.get(endpoint+'/sitemap').json()['discovered']==1
assert 'wpWorker' in client.get('/production-v8').text
def test_worker_subprocess_executes_wordpress_job_kind(sandbox,monkeypatch):
site=sandbox();ses=wp.create(site,'https://example.org/',200)
job=ext.submit_scan(site,ses['id'],'test')
# Simulate another process finishing the scan after enqueue. The spawned
# subprocess must dispatch `wordpress_scan`, not accidentally build HTML.
db.execute("UPDATE wp_migration_sessions SET state='ready' WHERE id=?",(ses['id'],))
monkeypatch.setattr(worker,'running',True)
monkeypatch.setattr(worker,'WORKER_ID','testworker')
assert worker.run_once()==job['id']
status=db.one('SELECT state,result_json,kind FROM build_jobs WHERE id=?',(job['id'],))
assert status['kind']=='wordpress_scan' and status['state']=='done'
assert json.loads(status['result_json'])['state']=='ready'
def test_remote_media_stays_in_manual_review_if_not_localized(sandbox,monkeypatch):
site=sandbox();ses=wp.create(site,'https://example.org/',200)
_scan(monkeypatch,ses['id'],site,media=True)
issues=ext._findings(site,ses['id'])
assert any('外部图片' in x for x in issues)
assert wp.finalize(site,ses['id'])['requires_review']
+185
View File
@@ -0,0 +1,185 @@
"""V8.3 deterministic WordPress taxonomy and media catalog integration tests."""
import json
import pytest
from fastapi import HTTPException
from fastapi.testclient import TestClient
from app import db, enterprise, migration_audit, v8_workflows, wp_migration_v81 as wp
from app import wp_migration_v83 as catalog
from app.core import uid
from app.main import app
from app.safe_http import Fetched
@pytest.fixture
def site_env(tmp_path,monkeypatch):
monkeypatch.setattr(db,'DATA',tmp_path)
monkeypatch.setattr(db,'DB_FILE',tmp_path/'siteforge.sqlite3')
monkeypatch.setenv('SITEFORGE_ADMIN_PASSWORD','v83-test-password-with-good-entropy')
monkeypatch.setenv('SITEFORGE_SESSION_SECRET','v83-test-session-secret-long-enough-for-dev')
db.init();enterprise.setup();migration_audit.setup();v8_workflows.setup();wp.setup()
from app import cms_pipeline
cms_pipeline.setup()
def new():
sid=uid();now=db.now()
db.insert('INSERT INTO sites(id,name,theme,mode,created_at,updated_at) VALUES(?,?,?,?,?,?)',
(sid,'Migrating WordPress','automotive','migration',now,now))
return sid
return new
def post():
return {'id':101,'link':'https://source.example/blog/post/',
'title':{'rendered':'Source Post'},'content':{'rendered':'<p>Safe source text</p>'},
'excerpt':{'rendered':'Excerpt'},'categories':[10,22],'tags':[35],
'featured_media':77, 'acf':{'specification':'example'},
'yoast_head_json':{'title':'Title','description':'Description','canonical':'https://source.example/blog/post/'},
'_embedded':{'wp:featuredmedia':[{'source_url':'https://source.example/wp-content/uploads/a.png'}]}}
def install_source(monkeypatch):
monkeypatch.setattr(wp,'_fetch',lambda origin,phase,page:[post()] if phase=='posts' else [])
def sync_fixtures(monkeypatch):
from app.safe_http import Fetched
def fake(url,**kwargs):
if '/categories?' in url:body=[{'id':10,'name':'News','slug':'news','parent':0},{'id':22,'name':'Technology','slug':'technology','parent':10}]
elif '/tags?' in url:body=[{'id':35,'name':'Electric','slug':'electric'}]
elif '/media?' in url:body=[{'id':77,'title':{'rendered':'Image A'},'source_url':'https://source.example/wp-content/uploads/a.png','mime_type':'image/png','alt_text':'Cover','media_details':{'width':100,'height':200}}]
else:raise AssertionError('Unexpected remote path: '+url)
return Fetched(url,200,{'content-type':'application/json'},json.dumps(body).encode())
monkeypatch.setattr(catalog.safe_http,'fetch',fake)
def test_taxonomy_catalog_staged_persisted_and_committed(site_env,monkeypatch):
site=site_env();ses=wp.create(site,'https://source.example/',300)
install_source(monkeypatch)
wp.advance(site,ses['id']);wp.advance(site,ses['id'])
# Media, category and tag ID references are preserved before catalog sync.
refs=db.rows('SELECT resource,target_id FROM wp_catalog_refs WHERE session_id=?',(ses['id'],))
assert {(r['resource'],r['target_id']) for r in refs}=={('categories',10),('categories',22),('tags',35),('media',77)}
original=catalog.findings(site,ses['id'])
assert any('缺少 WordPress categories' in msg for msg in original)
sync_fixtures(monkeypatch)
for kind in ('categories','tags','media'):
result=catalog.sync_page(site,ses['id'],kind)
assert result['resources'][kind]['done']
staged_id=db.one('SELECT batch_id FROM wp_migration_pages WHERE session_id=? AND phase=?',(ses['id'],'posts'))['batch_id']
row=db.one('SELECT content_json FROM v8_import_items WHERE batch_id=?',(staged_id,))
payload=json.loads(row['content_json'])
assert payload['meta']['fields']['category']=='News'
assert payload['meta']['fields']['tags']=='Electric'
assert payload['meta']['wp_source']['category_ids']==[10,22]
assert payload['meta']['wp_source']['acf_snapshot']=={'specification':'example'}
assert payload['meta']['wp_source']['featured_media_original'].endswith('/a.png')
warnings=catalog.findings(site,ses['id'])
assert not any('缺少 WordPress' in issue for issue in warnings)
assert any('ACF' in issue for issue in warnings)
completed=wp.finalize(site,ses['id'])
assert completed['requires_review'] and completed['baseline_saved']==1
entry=db.one('SELECT * FROM entries WHERE site_id=? AND route=?',(site,'/blog/post/'))
meta=json.loads(entry['meta_json'])
assert meta['fields']['tags']=='Electric'
assert meta['wp_source']['wp_id']==101
def test_catalog_sync_idempotent_and_checkpointed(site_env,monkeypatch):
site=site_env();ses=wp.create(site,'https://source.example/',200)
sync_fixtures(monkeypatch)
first=catalog.sync_page(site,ses['id'],'tags')
again=catalog.sync_page(site,ses['id'],'tags')
assert first['resources']['tags']['fetched']==1==again['resources']['tags']['fetched']
assert db.one('SELECT COUNT(*) n FROM wp_catalog_items WHERE session_id=?',(ses['id'],))['n']==1
assert first['resources']['tags']['next_page']==2
def test_catalog_preserves_cursor_on_network_failure(site_env,monkeypatch):
site=site_env();ses=wp.create(site,'https://source.example/',200)
def fail(url,**kwargs):raise RuntimeError('Network disconnected')
monkeypatch.setattr(catalog.safe_http,'fetch',fail)
with pytest.raises(RuntimeError):catalog.sync_page(site,ses['id'],'categories')
assert catalog.catalog_status(site,ses['id'])['resources']['categories']['next_page']==1
assert db.one('SELECT COUNT(*) n FROM wp_catalog_items WHERE session_id=?',(ses['id'],))['n']==0
def test_catalog_does_not_allow_bad_resources_or_id_rows(site_env,monkeypatch):
site=site_env();ses=wp.create(site,'https://source.example/',200)
with pytest.raises(ValueError):catalog.sync_page(site,ses['id'],'../wp-users')
with pytest.raises(ValueError):catalog._record('categories',{'id':True,'name':'Wrong'})
with pytest.raises(ValueError):catalog._record('categories',{'id':'1','name':'Wrong'})
assert catalog._safe_id_list([0,True,12,12,'12',40])==[12,40]
def test_missing_term_and_media_references_require_review(site_env,monkeypatch):
site=site_env();ses=wp.create(site,'https://source.example/',200)
install_source(monkeypatch);wp.advance(site,ses['id']);wp.advance(site,ses['id'])
findings=catalog.findings(site,ses['id'])
assert any('media' not in f.lower() and '分类' not in f and 'categories' in f for f in findings)
assert any('特色图片媒体记录' in f for f in findings)
result=wp.finalize(site,ses['id'])
assert result['requires_review']
reviews=db.rows('SELECT * FROM cms_import_reviews WHERE site_id=?',(site,))
assert any('特色图片' in review['issue'] for review in reviews)
def test_access_control_and_ui_catalog_buttons(site_env,monkeypatch):
site=site_env();ses=wp.create(site,'https://source.example/',200)
sync_fixtures(monkeypatch)
base=f'/api/migrations/sites/{site}/wordpress/sessions/{ses["id"]}/catalog'
with TestClient(app) as client:
assert client.get(base).status_code==401
assert client.post(base+'/tags/next',json={}).status_code==401
assert client.post('/api/auth/login',json={'password':'v83-test-password-with-good-entropy'}).status_code==200
client.headers['X-SiteForge-CSRF']=client.get('/api/auth/me').json()['csrf']
assert client.get(base).status_code==200
result=client.post(base+'/tags/next',json={})
assert result.status_code==200,result.text
assert result.json()['term_counts']['tags']==1
assert 'wpCatalogSync' in client.get('/production-v8').text
def test_site_and_session_are_scoped(site_env,monkeypatch):
one=site_env();two=site_env();ses=wp.create(one,'https://source.example/',200)
with pytest.raises(HTTPException) as exc:catalog.catalog_status(two,ses['id'])
assert exc.value.status_code==404
sync_fixtures(monkeypatch)
catalog.sync_page(one,ses['id'],'categories')
assert db.one('SELECT COUNT(*) n FROM wp_catalog_items WHERE session_id=?',(ses['id'],))['n']==2
def test_catalog_not_mutable_after_finalization(site_env,monkeypatch):
site=site_env();ses=wp.create(site,'https://source.example/',200)
install_source(monkeypatch);wp.advance(site,ses['id']);wp.advance(site,ses['id']);wp.finalize(site,ses['id'])
sync_fixtures(monkeypatch)
with pytest.raises(HTTPException) as exc:catalog.sync_page(site,ses['id'],'tags')
assert exc.value.status_code==409
def test_taxonomy_archives_preserved_in_original_url_audit(site_env,monkeypatch):
site=site_env();ses=wp.create(site,'https://source.example/',200)
install_source(monkeypatch);wp.advance(site,ses['id']);wp.advance(site,ses['id'])
from app.safe_http import Fetched
def fetch(url,**kwargs):
if '/categories?' in url:
data=[{'id':10,'name':'News','slug':'news','parent':0,'link':'https://source.example/category/news/'}]
elif '/tags?' in url:
data=[{'id':35,'name':'Electric','slug':'electric','link':'https://source.example/tag/electric/'}]
else:data=[]
return Fetched(url,200,{'content-type':'application/json'},json.dumps(data).encode())
monkeypatch.setattr(catalog.safe_http,'fetch',fetch)
catalog.sync_page(site,ses['id'],'categories')
catalog.sync_page(site,ses['id'],'tags')
result=wp.finalize(site,ses['id'])
assert result['baseline_saved']==3 # article + both archives
urls={r['route'] for r in db.rows('SELECT route FROM wp_migration_urls WHERE session_id=?',(ses['id'],))}
assert urls=={'/blog/post/','/category/news/','/tag/electric/'}
assert catalog.register_archive_baseline(site,ses['id'])['taxonomy_archives_added']==0
def test_missing_parent_and_empty_term_name_flagged(site_env,monkeypatch):
site=site_env();ses=wp.create(site,'https://source.example/',200)
db.insert('''INSERT INTO wp_catalog_items(session_id,resource,wp_id,name,slug,parent_id,source_url,meta_json)
VALUES(?,?,?,?,?,?,?,?)''',(ses['id'],'categories',88,'','',300,'','{}'))
notes=catalog.findings(site,ses['id'])
assert any('上级分类' in note for note in notes)
assert any('缺少名称' in note for note in notes)