chore: baseline V8.10.0-dev.1 with v8_workflows type-mapping fix and trade-pro spec
- SiteForge Enterprise V8.10.0-dev.1 upstream source (210 tests) - Fix: v8_workflows._mapped_input now reads both 类型/分类 columns so WXR imports keep post/product types instead of collapsing to page - Docs: trade-pro theme port spec (R3) and implementation plan Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -0,0 +1,196 @@
|
||||
"""Regression tests for V4: theme layouts, migration audit, assembly, WXR.
|
||||
No external HTTP/DNS or Docker engine required.
|
||||
"""
|
||||
import io,json,zipfile
|
||||
from pathlib import Path
|
||||
from html import escape
|
||||
from openpyxl import Workbook
|
||||
from app import db,blueprints,theme_store,migration_audit,wordpress_compat
|
||||
from test_factory_v2 import factory,site
|
||||
|
||||
|
||||
def test_nested_grid_validates_and_builds(factory):
|
||||
sid=site(factory)
|
||||
home=next(x for x in factory.get(f'/api/sites/{sid}/entries').json() if x['route']=='/')
|
||||
layout=blueprints.new_block('layout',{'columns':[
|
||||
{'width':2,'blocks':[blueprints.new_block('richtext',{'title':'品牌故事','body':'<p>我们的企业介绍</p>'})]},
|
||||
{'width':1,'blocks':[blueprints.new_block('cta',{'title':'欢迎合作','href':'/contact/'})]}]})
|
||||
result=factory.put(f'/api/sites/{sid}/pages/{home["id"]}/blueprint',json={'blocks':[layout],
|
||||
'page_template':'home','layout_mode':'replace'})
|
||||
assert result.status_code==200,result.text
|
||||
assert result.json()['layout_mode']=='replace'
|
||||
preview=factory.post(f'/api/sites/{sid}/pages/{home["id"]}/editor-preview',json={'blocks':[layout],'page_template':'home','layout_mode':'replace'})
|
||||
assert preview.status_code==200,preview.text
|
||||
assert '品牌故事' in preview.text and 'sf-layout-grid' in preview.text and 'pro-header' in preview.text
|
||||
version=factory.post(f'/api/sites/{sid}/builds',json={}).json()['version']
|
||||
published=factory.get(f'/preview/{sid}/{version}/').text
|
||||
assert '品牌故事' in published and 'sf-layout-grid' in published and 'pro-header' in published
|
||||
css=factory.get(f'/preview/{sid}/{version}/assets/blocks.css').text
|
||||
assert '@media(max-width:540px)' in css
|
||||
|
||||
|
||||
def test_layout_rejects_duplicate_and_nested_excess(factory):
|
||||
a=blueprints.new_block('richtext');b=blueprints.new_block('richtext');b['id']=a['id']
|
||||
bad=blueprints.new_block('layout',{'columns':[{'width':1,'blocks':[a,b]}]})
|
||||
try:blueprints.validate_blocks([bad]);assert False,'duplicate should be rejected'
|
||||
except ValueError:pass
|
||||
nested=blueprints.new_block('layout');child=blueprints.new_block('layout');grand=blueprints.new_block('layout')
|
||||
child['columns'][0]['blocks']=[grand];nested['columns'][0]['blocks']=[child]
|
||||
try:blueprints.validate_blocks([nested]);assert False,'deep nesting should be rejected'
|
||||
except ValueError:pass
|
||||
|
||||
|
||||
def test_wordpress_elementor_media_taxonomy_and_woo_gallery(factory):
|
||||
sid=site(factory)
|
||||
widgets=json.dumps([{'widgetType':'heading','settings':{'title':'核心价值','header_size':'h2'}},
|
||||
{'widgetType':'image','settings':{'image':{'url':'https://img.example/media.jpg'}}},
|
||||
{'widgetType':'unknown_plugin_widget','settings':{'value':'secret'}}],ensure_ascii=False)
|
||||
wxr='''<?xml version="1.0"?><rss xmlns:wp="http://wordpress.org/export/1.2/" xmlns:content="http://purl.org/rss/1.0/modules/content/" version="2.0"><channel>
|
||||
<item><title>图片</title><wp:post_type>attachment</wp:post_type><wp:post_id>12</wp:post_id><wp:attachment_url>https://img.example/cover.jpg</wp:attachment_url></item>
|
||||
<item><title>产品A</title><link>https://wp.example/product/test-a/</link><wp:post_type>product</wp:post_type><wp:status>publish</wp:status><content:encoded></content:encoded>
|
||||
<category domain="product_cat">运动型多用途车</category>
|
||||
<wp:postmeta><wp:meta_key>_thumbnail_id</wp:meta_key><wp:meta_value>12</wp:meta_value></wp:postmeta>
|
||||
<wp:postmeta><wp:meta_key>_product_image_gallery</wp:meta_key><wp:meta_value>12</wp:meta_value></wp:postmeta>
|
||||
<wp:postmeta><wp:meta_key>_elementor_data</wp:meta_key><wp:meta_value><![CDATA['''+widgets+''']]></wp:meta_value></wp:postmeta>
|
||||
<wp:postmeta><wp:meta_key>_price</wp:meta_key><wp:meta_value>1200</wp:meta_value></wp:postmeta>
|
||||
</item></channel></rss>'''
|
||||
response=factory.post(f'/api/sites/{sid}/import',files={'file':('export.xml',wxr.encode(),'application/xml')})
|
||||
assert response.status_code==200,response.text
|
||||
item=next(x for x in factory.get(f'/api/sites/{sid}/entries').json() if x['title']=='产品A')
|
||||
assert item['cover']=='https://img.example/cover.jpg'
|
||||
assert item['meta']['fields']['category']=='运动型多用途车'
|
||||
assert item['meta']['fields']['price']=='1200'
|
||||
assert '核心价值' in item['body']
|
||||
assert item['meta']['migration_warnings']
|
||||
assert item['meta']['fields']['gallery']==['https://img.example/cover.jpg']
|
||||
|
||||
|
||||
def test_excel_mapping_relations_preview_and_repeat(factory):
|
||||
sid=site(factory)
|
||||
w=Workbook();ws=w.active;ws.append(['车型名称','品牌','分类','动力形式','图库','原网址'])
|
||||
ws.append(['SUV Alpha','Hesu','SUV','PHEV','https://img.example/1.jpg;https://img.example/2.jpg','/cars/alpha.html'])
|
||||
b=io.BytesIO();w.save(b)
|
||||
r=factory.post(f'/api/factory/sites/{sid}/mapping/parse-file',files={'file':('cars.xlsx',b.getvalue())})
|
||||
assert r.status_code==200,r.text
|
||||
parsed=r.json();assert parsed['count']==1
|
||||
assert parsed['suggested']['title']=='车型名称'
|
||||
report=factory.post(f'/api/factory/sites/{sid}/mapping/run',json={'rows':parsed['rows'],
|
||||
'rules':{**parsed['suggested'],'gallery':'图库'},'content_type':'vehicle','dry_run':True}).json()
|
||||
assert report['mapped']==1 and report['sample'][0]['route']=='/cars/alpha.html'
|
||||
committed=factory.post(f'/api/factory/sites/{sid}/mapping/run',json={'rows':parsed['rows'],
|
||||
'rules':{**parsed['suggested'],'gallery':'图库'},'content_type':'vehicle','dry_run':False})
|
||||
assert committed.status_code==200
|
||||
x=factory.get(f'/api/factory/sites/{sid}/relationships').json()
|
||||
assert any(i['brand']=='Hesu' for i in x['brand_pages_missing'])
|
||||
y=factory.post(f'/api/factory/sites/{sid}/relationships/assemble').json()
|
||||
assert y['count']==1
|
||||
assert factory.post(f'/api/factory/sites/{sid}/relationships/assemble').json()['count']==0
|
||||
entries=factory.get(f'/api/sites/{sid}/entries').json()
|
||||
assert any(e['type']=='brand' and e['title']=='Hesu' for e in entries)
|
||||
assert next(e for e in entries if e['title']=='SUV Alpha')['meta']['fields']['gallery']==['https://img.example/1.jpg','https://img.example/2.jpg']
|
||||
for theme in ('industrial','corporate','minimal'):
|
||||
assert factory.put(f'/api/factory/sites/{sid}/theme',json={'theme':theme}).status_code==200
|
||||
version=factory.post(f'/api/sites/{sid}/builds',json={}).json()['version']
|
||||
assert 'SUV Alpha' in factory.get(f'/preview/{sid}/{version}/cars/alpha.html').text
|
||||
|
||||
|
||||
def test_migration_manifest_and_built_qa_blocks_missing(factory):
|
||||
sid=site(factory,mode='migration')
|
||||
baseline='url,seo_title,description,canonical,status\nhttps://old.example/,Title from old,Old description,,200\nhttps://old.example/legacy-only.html,Missing,,,200\n'
|
||||
r=factory.post(f'/api/audit/sites/{sid}/import-baseline',files={'file':('urls.csv',baseline.encode())})
|
||||
assert r.status_code==200,r.text
|
||||
assert r.json()['imported']==2
|
||||
version=factory.post(f'/api/sites/{sid}/builds',json={}).json()['version']
|
||||
report=factory.get(f'/api/audit/sites/{sid}/builds/{version}/report').json()
|
||||
assert not report['passed'] and report['errors']>=1
|
||||
assert any(i['route']=='/legacy-only.html' and i['field']=='url' for i in report['issues'])
|
||||
assert any(i['route']=='/' and i['severity']=='warning' for i in report['issues'])
|
||||
readiness=factory.get(f'/api/factory/sites/{sid}/migration/readiness?build_id={version}').json()
|
||||
assert not readiness['can_publish']
|
||||
csv=factory.get(f'/api/audit/sites/{sid}/builds/{version}/report.csv')
|
||||
assert csv.status_code==200 and 'legacy-only.html' in csv.text
|
||||
|
||||
|
||||
def test_sitemap_import_and_invalid_baseline_atomic(factory):
|
||||
sid=site(factory,mode='migration')
|
||||
xml='''<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"><url><loc>https://old.example/about/</loc></url></urlset>'''
|
||||
assert factory.post(f'/api/audit/sites/{sid}/import-baseline',files={'file':('sitemap.xml',xml.encode())}).json()['imported']==1
|
||||
bad='url\nhttps://old.example/about/\nhttps://old.example/about/?page=5\n'
|
||||
response=factory.post(f'/api/audit/sites/{sid}/import-baseline',files={'file':('bad.csv',bad.encode())})
|
||||
assert response.status_code==200 and response.json()['errors']
|
||||
baseline=factory.get(f'/api/enterprise/sites/{sid}/baseline/report').json()
|
||||
assert baseline['baseline_count']==1
|
||||
|
||||
|
||||
def test_theme_gallery_and_compatibility(factory):
|
||||
sid=site(factory)
|
||||
for theme in ('automotive','industrial','corporate','minimal'):
|
||||
r=factory.get(f'/api/factory/themes/{theme}/compatibility')
|
||||
assert r.status_code==200 and r.json()['valid'],r.text
|
||||
product=factory.post(f'/api/sites/{sid}/entries',json={'title':'Unique Product','type':'product','route':'/product/unique/',
|
||||
'cover':'https://imgs.example/cover.jpg','meta':{'fields':{'gallery':['https://imgs.example/cover.jpg','https://imgs.example/second.jpg']}}})
|
||||
assert product.status_code in (200,201),product.text
|
||||
ver=factory.post(f'/api/sites/{sid}/builds',json={}).json()['version']
|
||||
html=factory.get(f'/preview/{sid}/{ver}/product/unique/').text
|
||||
assert 'pro-gallery-thumbs' in html and 'data-gallery-image' in html
|
||||
assert 'pro.js' in html
|
||||
|
||||
|
||||
def test_css_style_breakout_rejected(factory):
|
||||
z=io.BytesIO()
|
||||
with zipfile.ZipFile(z,'w') as f:
|
||||
data={'id':'test-css-escape','version':'1.0.0','name':'Safe Test','layout_family':'corporate'}
|
||||
f.writestr('manifest.json',json.dumps(data));f.writestr('page-presets.json',json.dumps({'home':[]}))
|
||||
f.writestr('theme.css','body{color:red}</style><script>alert(1)</script>')
|
||||
result=factory.post('/api/factory/themes/install',files={'file':('unsafe.zip',z.getvalue())})
|
||||
assert result.status_code==400
|
||||
|
||||
def test_migration_warning_requires_explicit_review(factory):
|
||||
sid=site(factory,mode='migration')
|
||||
source='url,seo_title\nhttps://old.example/,Old SEO Title\n'
|
||||
factory.post(f'/api/audit/sites/{sid}/import-baseline',files={'file':('old.csv',source.encode())})
|
||||
build=factory.post(f'/api/sites/{sid}/builds',json={}).json()['version']
|
||||
current=factory.get(f'/api/audit/sites/{sid}/builds/{build}/report').json()
|
||||
warnings=[i for i in current['issues'] if i['severity']=='warning' and i['field']=='title']
|
||||
assert warnings and current['unreviewed_warnings']>=1
|
||||
readiness=factory.get(f'/api/factory/sites/{sid}/migration/readiness?build_id={build}').json()
|
||||
assert not readiness['can_publish']
|
||||
assert any('未经审核' in x['message'] for x in readiness['gates'])
|
||||
denied=factory.post(f'/api/audit/sites/{sid}/builds/{build}/review-warning',json={
|
||||
'issue_id':warnings[0]['issue_id'],'reason':'短'});
|
||||
assert denied.status_code==400
|
||||
accepted=factory.post(f'/api/audit/sites/{sid}/builds/{build}/review-warning',json={
|
||||
'issue_id':warnings[0]['issue_id'],'reason':'企业管理人员已经核查该 SEO 标题调整,并批准本构建中的改动。'})
|
||||
assert accepted.status_code==200,accepted.text
|
||||
after=factory.get(f'/api/audit/sites/{sid}/builds/{build}/report').json()
|
||||
assert after['unreviewed_warnings']==0
|
||||
next_gate=factory.get(f'/api/factory/sites/{sid}/migration/readiness?build_id={build}').json()
|
||||
assert not any('未经审核' in x['message'] for x in next_gate['gates'])
|
||||
# Unrelated broken links may still correctly block publication.
|
||||
|
||||
|
||||
def test_site_scoped_reusable_block_library(factory):
|
||||
first=site(factory); second=site(factory)
|
||||
block=blueprints.new_block('layout',{'columns':[{'width':1,'blocks':[blueprints.new_block('richtext',{'title':'跨页面共用模块'})]}]})
|
||||
created=factory.post(f'/api/sites/{first}/components',json={'name':'品牌单列布局','block':block})
|
||||
assert created.status_code==200,created.text
|
||||
cid=created.json()['id']
|
||||
rows=factory.get(f'/api/sites/{first}/components').json()
|
||||
assert len(rows)==1 and rows[0]['block']['columns'][0]['blocks'][0]['title']=='跨页面共用模块'
|
||||
assert not factory.get(f'/api/sites/{second}/components').json()
|
||||
assert factory.put(f'/api/sites/{first}/components/{cid}',json={'name':'新版', 'expected_version':0,'block':block}).status_code==409
|
||||
assert factory.put(f'/api/sites/{first}/components/{cid}',json={'name':'新版', 'expected_version':1,'block':block}).json()['version']==2
|
||||
assert factory.delete(f'/api/sites/{first}/components/{cid}').json()['deleted']
|
||||
|
||||
|
||||
def test_contact_form_requires_configured_https_endpoint(factory):
|
||||
sid=site(factory)
|
||||
assert factory.put(f'/api/sites/{sid}/contact-settings',json={'email':'hello@example.org', 'form_endpoint':'http://unsecure.example/forms'}).status_code==400
|
||||
config=factory.put(f'/api/sites/{sid}/contact-settings',json={
|
||||
'email':'hello@example.org','form_endpoint':'https://forms.example.org/receive','footer_text':'Official inquiry channel'})
|
||||
assert config.status_code==200,config.text
|
||||
factory.post(f'/api/factory/sites/{sid}/bootstrap')
|
||||
build=factory.post(f'/api/sites/{sid}/builds',json={}).json()['version']
|
||||
contact=factory.get(f'/preview/{sid}/{build}/contact/').text
|
||||
assert '<form' in contact and 'forms.example.org/receive' in contact
|
||||
assert 'Official inquiry channel' in contact
|
||||
@@ -0,0 +1,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
|
||||
@@ -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
|
||||
@@ -0,0 +1,260 @@
|
||||
import io
|
||||
import json
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
import pytest
|
||||
from tests.legacy import LegacyTestClient as TestClient
|
||||
from app import db
|
||||
from app.main import app
|
||||
from app.core import route_path,ensure_public_url
|
||||
|
||||
@pytest.fixture
|
||||
def client(tmp_path,monkeypatch):
|
||||
monkeypatch.setattr(db,'DATA',tmp_path)
|
||||
monkeypatch.setattr(db,'DB_FILE',tmp_path/'test.sqlite3')
|
||||
monkeypatch.setenv('SITEFORGE_ALLOW_LEGACY_DIRECT_DESIGN_WRITE','1')
|
||||
monkeypatch.setenv('SITEFORGE_ADMIN_PASSWORD','strong-supervisor-password-2026')
|
||||
monkeypatch.setenv('SITEFORGE_SESSION_SECRET','large-random-key-for-test-only-2026-the-test-environment')
|
||||
with TestClient(app) as c:
|
||||
assert c.get('/api/sites').status_code == 401
|
||||
assert c.post('/api/auth/login',json={'password':'strong-supervisor-password-2026'}).status_code == 200
|
||||
auth=c.get('/api/auth/me').json()
|
||||
c.headers.update({'X-SiteForge-CSRF': auth['csrf']})
|
||||
yield c
|
||||
|
||||
def create(c,theme='automotive'):
|
||||
r=c.post('/api/sites',json={'name':'Example Test','theme':theme,'domain':'https://example.test','mode':'migration'})
|
||||
assert r.status_code==201,r.text
|
||||
return r.json()['id']
|
||||
|
||||
def test_site_and_theme_switch_preserve_content(client):
|
||||
a=create(client)
|
||||
b=create(client,'industrial')
|
||||
payload={'type':'product','title':'Product A','route':'/products/legacy.html','seo_title':'Original SEO Title','seo_description':'Original SEO Description','body':'<p>Hello <strong>world</strong></p>'}
|
||||
x=client.post(f'/api/sites/{a}/entries',json=payload)
|
||||
assert x.status_code==201,x.text
|
||||
assert len(client.get(f'/api/sites/{b}/entries').json())==1
|
||||
for theme in ('automotive','industrial','corporate','minimal'):
|
||||
assert client.patch(f'/api/sites/{a}',json={'theme':theme}).status_code==200
|
||||
info=client.post(f'/api/sites/{a}/builds',json={'mirror_images':False})
|
||||
assert info.status_code==200,info.text
|
||||
bid=info.json()['version']
|
||||
resp=client.get(f'/preview/{a}/{bid}/products/legacy.html')
|
||||
assert resp.status_code==200,resp.text[:150]
|
||||
assert '<title>Original SEO Title</title>' in resp.text
|
||||
assert '<link rel="canonical" href="https://example.test/products/legacy.html">' in resp.text
|
||||
assert 'Original SEO Description' in resp.text
|
||||
assert 'Hello' in resp.text
|
||||
dl=client.get(f'/api/sites/{a}/builds/{bid}/download')
|
||||
with zipfile.ZipFile(io.BytesIO(dl.content)) as z:
|
||||
assert 'products/legacy.html' in z.namelist()
|
||||
assert 'assets/theme.css' in z.namelist()
|
||||
assert 'sitemap.xml' in z.namelist()
|
||||
assert len(client.get(f'/api/sites/{a}/entries').json())==2
|
||||
|
||||
def test_home_blueprint_and_static_links(client):
|
||||
sid=create(client)
|
||||
pages=client.get(f'/api/sites/{sid}/entries').json()
|
||||
home=pages[0]
|
||||
assert home['route']=='/'
|
||||
meta={'blocks':[{'type':'features','title':'优势清单','items':[{'title':'服务能力','text':'广泛覆盖'}]},
|
||||
{'type':'cta','title':'马上合作','href':'/contact/'}]}
|
||||
assert client.patch(f'/api/sites/{sid}/entries/{home["id"]}',json={'meta':meta}).status_code==200
|
||||
r=client.post(f'/api/sites/{sid}/builds',json={})
|
||||
bid=r.json()['version']
|
||||
txt=client.get(f'/preview/{sid}/{bid}/').text
|
||||
assert '优势清单' in txt and '马上合作' in txt
|
||||
|
||||
def test_duplicate_routes_and_unsafe_html(client):
|
||||
sid=create(client)
|
||||
x=client.post(f'/api/sites/{sid}/entries',json={'type':'page','title':'X','route':'/test/','body':'<script>alert(1)</script><a href="javascript:alert(1)">x</a>'})
|
||||
assert x.status_code==201
|
||||
y=client.post(f'/api/sites/{sid}/entries',json={'type':'page','title':'Y','route':'/test/'})
|
||||
assert y.status_code==400
|
||||
z=client.get(f'/api/sites/{sid}/entries').json()
|
||||
it=next(x for x in z if x['route']=='/test/')
|
||||
assert '<script>' not in it['body']
|
||||
assert 'href="javascript:' not in it['body']
|
||||
assert client.post(f'/api/sites/{sid}/entries',json={'type':'page','title':'unsafe','route':'/x/../y/'}).status_code==400
|
||||
|
||||
def test_data_import_and_presets(client):
|
||||
sid=create(client)
|
||||
csv_data=b'type,title,route,seo_title\nproduct,Vehicle 1,/cars/vehicle-1/,Vehicle One SEO\nproduct,Vehicle 2,/cars/vehicle-2/,Vehicle Two SEO\n'
|
||||
response=client.post(f'/api/sites/{sid}/import',data={'on_conflict':'skip'},files={'file':('cars.csv',csv_data,'text/csv')})
|
||||
assert response.status_code==200,response.text
|
||||
assert response.json()['imported']==2
|
||||
r2=client.post(f'/api/sites/{sid}/import',data={'on_conflict':'skip'},files={'file':('cars.csv',csv_data,'text/csv')})
|
||||
assert r2.json()['skipped']==2
|
||||
save=client.post(f'/api/sites/{sid}/save-preset',json={'name':'My Master Theme'})
|
||||
assert save.status_code==200
|
||||
reuse=client.post('/api/sites',json={'name':'New from Preset','preset_id':save.json()['id']})
|
||||
assert reuse.status_code==201
|
||||
assert reuse.json()['theme']=='automotive'
|
||||
|
||||
def test_wxr_legacy_urls_meta(client):
|
||||
sid=create(client)
|
||||
wxr=b'''<?xml version="1.0" encoding="utf-8"?><rss xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:wp="http://wordpress.org/export/1.2/" version="2.0"><channel><item>
|
||||
<title>News</title><link>https://old.example.org/news/article-01.html</link>
|
||||
<wp:post_type>post</wp:post_type><wp:status>publish</wp:status><wp:post_name>article-01</wp:post_name>
|
||||
<content:encoded><![CDATA[<h2>Article</h2><p>WordPress body</p>]]></content:encoded>
|
||||
<wp:postmeta><wp:meta_key>_yoast_wpseo_title</wp:meta_key><wp:meta_value>Exact SEO Title</wp:meta_value></wp:postmeta>
|
||||
</item></channel></rss>'''
|
||||
response=client.post(f'/api/sites/{sid}/import',files={'file':('wordpress.xml',wxr,'application/xml')},data={'on_conflict':'skip'})
|
||||
assert response.status_code==200,response.text
|
||||
arr=client.get(f'/api/sites/{sid}/entries').json()
|
||||
match=next(p for p in arr if p['title']=='News')
|
||||
assert match['route']=='/news/article-01.html'
|
||||
assert match['seo_title']=='Exact SEO Title'
|
||||
assert 'WordPress body' in match['body']
|
||||
|
||||
def test_media_archive(client):
|
||||
sid=create(client)
|
||||
buffer=io.BytesIO()
|
||||
with zipfile.ZipFile(buffer,'w') as zipf:
|
||||
zipf.writestr('wp-content/uploads/2024/01/product.jpg',b'\xff\xd8\xff\xd9')
|
||||
zipf.writestr('../bad.php',b'evil')
|
||||
buffer.seek(0)
|
||||
r=client.post(f'/api/sites/{sid}/media-archive',files={'file':('uploads.zip',buffer.getvalue(),'application/zip')})
|
||||
assert r.status_code==200,r.text
|
||||
assert r.json()['imported']==1
|
||||
built=client.post(f'/api/sites/{sid}/builds',json={})
|
||||
assert built.status_code==200
|
||||
z=client.get(f'/api/sites/{sid}/builds/{built.json()["version"]}/download')
|
||||
with zipfile.ZipFile(io.BytesIO(z.content)) as archive:
|
||||
assert 'wp-content/uploads/2024/01/product.jpg' in archive.namelist()
|
||||
assert not any('bad.php' in s for s in archive.namelist())
|
||||
|
||||
def test_redirect_and_ssrf_reject(client):
|
||||
sid=create(client)
|
||||
assert client.post(f'/api/sites/{sid}/redirects',json={'old_path':'/old.html','new_path':'/new/','status_code':301}).status_code==200
|
||||
r=client.post(f'/api/sites/{sid}/builds',json={})
|
||||
assert r.status_code==200
|
||||
assert len(client.get(f'/api/sites/{sid}/redirects').json())==1
|
||||
bad=client.post(f'/api/sites/{sid}/crawl',json={'url':'http://127.0.0.1:8787/'})
|
||||
assert bad.status_code==400
|
||||
bad=client.post(f'/api/sites/{sid}/crawl',json={'url':'file:///etc/passwd'})
|
||||
assert bad.status_code==400
|
||||
|
||||
|
||||
def test_login_and_csrf_enforcement(client):
|
||||
session=client.cookies.get('siteforge_session')
|
||||
assert session
|
||||
original=client.headers.pop('X-SiteForge-CSRF')
|
||||
assert client.post('/api/sites',json={'name':'Blocked'}).status_code == 403
|
||||
client.headers['X-SiteForge-CSRF']=original
|
||||
assert client.get('/api/sites').status_code == 200
|
||||
assert client.post('/api/auth/logout').status_code == 200
|
||||
assert client.get('/api/sites').status_code == 401
|
||||
assert client.get('/').status_code in (200, 303) # TestClient follows /login
|
||||
assert '管理员密码' in client.get('/login').text
|
||||
|
||||
def test_build_download_requires_login(client):
|
||||
sid=create(client)
|
||||
result=client.post(f'/api/sites/{sid}/builds',json={}).json()
|
||||
bid=result['version']
|
||||
ok=client.get(f'/api/sites/{sid}/builds/{bid}/download')
|
||||
assert ok.status_code==200
|
||||
client.cookies.clear()
|
||||
assert client.get(f'/api/sites/{sid}/builds/{bid}/download').status_code==401
|
||||
assert '/login' in client.get(f'/preview/{sid}/{bid}/').url.path
|
||||
|
||||
def test_visual_blocks_auto_bind_save_build_and_site_isolation(client):
|
||||
sid=create(client,'corporate')
|
||||
other=create(client,'minimal')
|
||||
prod=client.post(f'/api/sites/{sid}/entries',json={'type':'product','title':'CUSTOM SUV 01','route':'/autos/custom-01/','summary':'Quality example SUV'}).json()
|
||||
homepage=next(x for x in client.get(f'/api/sites/{sid}/entries').json() if x['route']=='/')
|
||||
blocks=[
|
||||
{'id':'hero-block-1','type':'hero','title':'{{site.name}}','subtitle':'WELCOME','description':'{{site.tagline}}','button':'汽车列表','href':'/product/'},
|
||||
{'id':'products-1','type':'collection','title':'车型推荐','source':'product','limit':6,'columns':3},
|
||||
{'id':'stats-block-1','type':'stats','title':'统计数据','items':[{'value':'3,000+','label':'全球交付'}]}
|
||||
]
|
||||
save=client.put(f'/api/sites/{sid}/pages/{homepage["id"]}/blueprint',json={'blocks':blocks,'page_template':'home'})
|
||||
assert save.status_code==200,save.text
|
||||
preview=client.post(f'/api/sites/{sid}/pages/{homepage["id"]}/editor-preview',json={'blocks':blocks,'page_template':'home'})
|
||||
assert preview.status_code==200
|
||||
assert 'CUSTOM SUV 01' in preview.text and '车型推荐' in preview.text
|
||||
assert 'AUTOS' not in preview.text
|
||||
resp=client.post(f'/api/sites/{sid}/builds',json={})
|
||||
assert resp.status_code==200,resp.text
|
||||
assert resp.json()['blueprint_blocks']>=3
|
||||
html=client.get(f'/preview/{sid}/{resp.json()["version"]}/').text
|
||||
assert 'CUSTOM SUV 01' in html and '车型推荐' in html
|
||||
assert 'assets/blocks.css' in html
|
||||
assert client.get(f'/api/sites/{other}/pages/{homepage["id"]}/blueprint').status_code==404
|
||||
assert client.get(f'/editor/{sid}/{homepage["id"]}').status_code==200
|
||||
|
||||
|
||||
def test_schema_navigation_and_preset(client):
|
||||
sid=create(client)
|
||||
fields=[{'name':'model_code','label':'产品编号','type':'text'},{'name':'horsepower','label':'马力','type':'number'}]
|
||||
r=client.put(f'/api/sites/{sid}/schemas/product',json=fields)
|
||||
assert r.status_code==200,r.text
|
||||
assert client.get(f'/api/sites/{sid}/schemas').json()['product']==fields
|
||||
assert client.put(f'/api/sites/{sid}/schemas/product',json=[{'name':'../not_safe','type':'text'}]).status_code==400
|
||||
nav=[{'title':'首页','route':'/'},{'title':'车型','route':'/product/'}]
|
||||
assert client.put(f'/api/sites/{sid}/navigation',json=nav).status_code==200
|
||||
assert client.get(f'/api/sites/{sid}').json()['settings']['nav']==nav
|
||||
homepage=next(x for x in client.get(f'/api/sites/{sid}/entries').json() if x['route']=='/')
|
||||
r=client.post(f'/api/sites/{sid}/pages/{homepage["id"]}/blueprint/preset',json={'page_template':'about'})
|
||||
assert r.status_code==200
|
||||
assert len(r.json()['blocks'])==3
|
||||
assert client.get('/api/themes').json()[0]['page_templates']
|
||||
|
||||
|
||||
def test_blocks_validation_escaping_and_disabled(client):
|
||||
sid=create(client)
|
||||
homepage=next(x for x in client.get(f'/api/sites/{sid}/entries').json() if x['route']=='/')
|
||||
blocks=[{'id':'dangerous-x','type':'hero','title':'<img src=x onerror=alert(1)>','image':'javascript:alert(1)','href':'javascript:alert(1)'},
|
||||
{'id':'hidden-one','type':'richtext','title':'should be hidden','body':'<script>alert(1)</script>','visible':False}]
|
||||
res=client.post(f'/api/sites/{sid}/pages/{homepage["id"]}/editor-preview',json={'blocks':blocks,'page_template':'home'})
|
||||
assert res.status_code==200,res.text
|
||||
assert 'onerror=alert' in res.text # text escaped, never a literal img tag
|
||||
assert '<img src=x onerror' in res.text
|
||||
assert 'href="/"' in res.text or 'href="./"' in res.text
|
||||
assert 'should be hidden' not in res.text
|
||||
assert client.put(f'/api/sites/{sid}/pages/{homepage["id"]}/blueprint',json={'blocks':[{'id':'bad-id','type':'script','title':'Bad'}],'page_template':'home'}).status_code==400
|
||||
|
||||
def test_bulk_theme_assembly_preserves_legacy_url_and_seo(client):
|
||||
sid=create(client,'industrial')
|
||||
resp=client.post(f'/api/sites/{sid}/entries',json={'type':'product','title':'HDPE Pipe','route':'/old-product/hdpe-pipe.html','body':'<h2>Legacy Technical Details</h2>','seo_title':'HDPE pipe SEO forever','seo_description':'Exact old metadata'})
|
||||
assert resp.status_code==201
|
||||
entry=resp.json()
|
||||
assert not entry['meta'].get('blocks')
|
||||
done=client.post(f'/api/sites/{sid}/apply-theme',json={'mode':'missing'})
|
||||
assert done.status_code==200,done.text
|
||||
assert done.json()['updated']>=1
|
||||
updated=client.get(f'/api/sites/{sid}/entries').json()
|
||||
final=next(x for x in updated if x['id']==entry['id'])
|
||||
assert final['route']=='/old-product/hdpe-pipe.html'
|
||||
assert final['seo_title']=='HDPE pipe SEO forever'
|
||||
assert len(final['meta']['blocks'])>=2
|
||||
built=client.post(f'/api/sites/{sid}/builds',json={})
|
||||
assert built.status_code==200,built.text
|
||||
html=client.get(f'/preview/{sid}/{built.json()["version"]}/old-product/hdpe-pipe.html').text
|
||||
assert 'Legacy Technical Details' in html
|
||||
assert '<title>HDPE pipe SEO forever</title>' in html
|
||||
|
||||
def test_scaffold_standard_pages_safe_idempotent(client):
|
||||
sid=create(client)
|
||||
first=client.post(f'/api/sites/{sid}/scaffold')
|
||||
assert first.status_code==200
|
||||
assert '/about/' in first.json()['created']
|
||||
second=client.post(f'/api/sites/{sid}/scaffold')
|
||||
assert second.status_code==200 and not second.json()['created']
|
||||
lst=client.get(f'/api/sites/{sid}/entries').json()
|
||||
assert len([x for x in lst if x['route']=='/about/'])==1
|
||||
build=client.post(f'/api/sites/{sid}/builds',json={})
|
||||
assert build.status_code==200,build.text
|
||||
|
||||
def test_attributes_bind_imported_and_typed_fields(client):
|
||||
sid=create(client,'automotive')
|
||||
product=client.post(f'/api/sites/{sid}/entries',json={'type':'product','title':'BYD SUV','route':'/product/byd-suv/','meta':{'fields':{'brand':'BYD','model':'SUV 1','specs':'Electric'}}})
|
||||
assert product.status_code==201
|
||||
eid=product.json()['id']
|
||||
blocks=[{'id':'attributes-1','type':'attributes','title':'Technical Parameters'}]
|
||||
save=client.put(f'/api/sites/{sid}/pages/{eid}/blueprint',json={'blocks':blocks,'page_template':'product'})
|
||||
assert save.status_code==200,save.text
|
||||
b=client.post(f'/api/sites/{sid}/builds',json={})
|
||||
assert b.status_code==200,b.text
|
||||
page=client.get(f'/preview/{sid}/{b.json()["version"]}/product/byd-suv/').text
|
||||
assert 'Technical Parameters' in page and '>BYD<' in page and 'SUV 1' in page
|
||||
@@ -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
|
||||
@@ -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'])
|
||||
@@ -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/>'
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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')
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 '<script>' 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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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']
|
||||
@@ -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())
|
||||
@@ -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
|
||||
@@ -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']
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user