upgrade: adopt V8.21.3 (V8.13→8.21.3: P5 SFTP/S3, P6 multilingual, editor overhaul, enterprise hardening)

Overlay ~150 upstream items onto our V8.12 base, preserving trade-pro
theme, registry tests, and design docs. Re-applied our patch set
(discovery/env_for/blocks.css/type-mapping/theme_color/frontend entries,
test_theme_pro_v3 mods) — all anchors survived upstream evolution.

New platform enhancement (fixes upgrade-introduced regression):
multilingual.site_locale now honors settings_json.locale, plus
PUT /api/i18n/sites/{id}/locale — standalone English sites get
<html lang='en'> under the V8.15 locale-rewrite pipeline.

compose: adopt V8.21.3 (image 8.21.3-dev.1, /api/ready probes, MySQL/S3
env) with project name pinned to siteforge-v8-10 (data volumes).

Tests: 356 passed / 11 failed — all 11 confirmed pre-existing in
pristine V8.21.3 on this host (10 upstream Windows env + our build test
now green after locale fix). Patch registry updated in plan doc.

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-10-23 12:31:03 +08:00
parent 257c89e159
commit 601451f22b
218 changed files with 10441 additions and 628 deletions
+120
View File
@@ -0,0 +1,120 @@
"""V8.19 unified console contract; server permissions remain authoritative."""
from app.version import VERSION
import json
import pytest
from tests.test_enterprise import root,site
from scripts.install_into_art_design_pro import install, transform_routes
def test_bootstrap_admin_uses_one_navigation_and_no_secrets(root):
one=site(root,'HESU A');two=site(root,'HESU B')
r=root.get('/api/console/bootstrap')
assert r.status_code==200,r.text
info=r.json()
assert info['version']==VERSION
assert {x['id'] for x in info['sites']}=={one,two}
assert info['selected_site_id'] in {one,two}
assert info['capabilities']['create_site'] and info['capabilities']['deploy']
assert [i['key'] for i in info['navigation']]==['dashboard','sites','content','design','migration','release','system']
assert 'csrf' not in json.dumps(info).lower()
assert root.get('/api/console/bootstrap?site_id=not-a-site').status_code==403
def test_scope_does_not_disclose_foreign_site_and_rejects_unassigned(root):
one=site(root,'Assigned');two=site(root,'Not assigned')
uid=root.post('/api/enterprise/team/users',json={
'username':'v819view','display_name':'Viewer','password':'viewer-strong-password-2026'}).json()['id']
assert root.put(f'/api/enterprise/sites/{one}/members',json={'user_id':uid,'role':'viewer'}).status_code==200
root.post('/api/auth/logout')
assert root.post('/api/auth/login',json={'username':'v819view','password':'viewer-strong-password-2026'}).status_code==200
root.headers['X-SiteForge-CSRF']=root.get('/api/auth/me').json()['csrf']
r=root.get('/api/console/bootstrap')
assert r.status_code==200,r.text
b=r.json()
assert [s['id'] for s in b['sites']]==[one]
assert b['capabilities']['role']=='viewer'
assert not b['capabilities']['edit'] and not b['capabilities']['deploy']
assert not b['capabilities']['manage_users']
assert all(item['key']!='system' for item in b['navigation'])
assert root.get(f'/api/console/bootstrap?site_id={two}').status_code==403
assert root.get(f'/api/sites/{two}/entries').status_code==403
assert root.get(f'/api/console/bootstrap?site_id={one}').status_code==200
def test_empty_site_roles_still_have_dashboard(root):
resp=root.get('/api/console/bootstrap')
assert resp.status_code==200
nav=resp.json()['navigation']
assert nav[0]['key']=='dashboard'
assert all(n['key'] not in {'content','design','migration','release'} for n in nav)
def fixture(tmp_path):
repo=tmp_path/'art';route=repo/'src/router/routes/asyncRoutes.ts'
route.parent.mkdir(parents=True,exist_ok=True)
route.write_text('export const asyncRoutes: MenuListType[] = [\n{path:"/unrelated",name:"Safe"},\n];\n')
(repo/'package.json').write_text('{"name":"art-design-pro"}')
return repo,route
def test_art_installer_single_menu_and_safe_backup(tmp_path):
repo,route=fixture(tmp_path)
original=route.read_text()
result=install(repo,dry_run=True)
assert result['dry_run'] and not result['conflicting_views']
assert route.read_text()==original
install(repo,dry_run=False)
updated=route.read_text()
assert updated.count("path: '/siteforge'")==1
assert "path: 'production'" in updated
assert updated.count('// SITEFORGE_FACTORY_V8_REGISTERED')==1
assert 'Safe' in updated
assert install(repo,verify=True)['installed']
assert route.with_name('asyncRoutes.ts.siteforge-backup').read_text()==original
install(repo,dry_run=False)
assert route.read_text()==updated
def test_installer_fails_closed_on_conflicting_views(tmp_path):
repo,route=fixture(tmp_path)
target=repo/'src/views/siteforge/ConsoleHome.vue'
target.parent.mkdir(parents=True)
target.write_text('someone else code')
with pytest.raises(ValueError,match='拒绝覆盖'):
install(repo,dry_run=False)
assert 'SITEFORGE' not in route.read_text()
install(repo,dry_run=False,overwrite_views=True)
assert target.with_name('ConsoleHome.vue.siteforge-backup').read_text()=='someone else code'
target.write_text('broken')
with pytest.raises(ValueError,match='不匹配'):
install(repo,verify=True)
def test_installer_rejects_partial_routes(tmp_path):
repo,route=fixture(tmp_path)
route.write_text('export const asyncRoutes = [ /* SITEFORGE_V819_ROUTES_BEGIN */ ]')
with pytest.raises(ValueError,match='标记不完整'):
install(repo,dry_run=False)
def test_legacy_upgrade_requires_explicit_confirmation(tmp_path):
repo,route=fixture(tmp_path)
route.write_text('export const asyncRoutes: any[] = [\n // SITEFORGE_FACTORY_V8_REGISTERED\n {path:"/siteforge/production",meta:{a:1}},\n // SITEFORGE_FACTORY_V7_REGISTERED\n {path:"/siteforge",children:[{path:"factory",name:"A"}]},\n {path:"/unrelated"},\n]')
with pytest.raises(ValueError,match='--migrate-legacy'):
install(repo,dry_run=False)
install(repo,dry_run=False,migrate_legacy=True)
routes=route.read_text()
assert routes.count("path: '/siteforge'")==1
assert 'unrelated' in routes
assert routes.count('// SITEFORGE_FACTORY_V8_REGISTERED')==1
def test_shared_sdk_and_vue_pages_exist():
from pathlib import Path
base=Path(__file__).resolve().parents[1]/'integrations/art-design-pro/src/views/siteforge'
assert (base/'_shared.ts').is_file()
assert 'X-SiteForge-CSRF' in (base/'_shared.ts').read_text()
for name in ('ConsoleHome.vue','ContentCenter.vue'):
text=(base/name).read_text()
assert '<template>' in text and "from './_shared'" in text
assert 'POST' in (base/'ContentCenter.vue').read_text() or 'postJson' in (base/'ContentCenter.vue').read_text()
+33
View File
@@ -0,0 +1,33 @@
"""V8.21.2 task-first studio surface and backward-compatibility checks."""
from pathlib import Path
from bs4 import BeautifulSoup
ROOT=Path(__file__).resolve().parents[1]
def test_primary_workflow_is_visible_and_document_has_unique_controls():
soup=BeautifulSoup((ROOT/'app/templates/editor.html').read_text(), 'html.parser')
for control in ('saveDraftButton','publishDesignButton','sfAddBlock','sfGuideDialog','pageSelect','blockList','sitePreview','inspector','sfLibraryPanel'):
assert len(soup.select('#'+control))==1,control
assert soup.select_one('#saveDraftButton').find_parent('details') is None
assert soup.select_one('#publishDesignButton').find_parent('details') is None
assert soup.select_one('#sfLibraryPanel').has_attr('hidden')
assert len(soup.select('.sf-step'))==3
assert soup.select_one('#sitePreview')['sandbox']==['allow-scripts']
def test_editor_add_and_direct_canvas_selection():
js=(ROOT/'app/static/editor.js').read_text()
api=(ROOT/'app/main.py').read_text()
for method in ('toggleLibrary(force)','updateGuidance()','showHelp()','select(i)','arrayEditor(field,value,type)','itemField(field,index,key,value)','addItem(field)','removeItem(field,index)','moveItem(field,index,by)'):
assert method in js
assert "siteforge-select-block" in js
assert "siteforge-select-block" in api
assert 'event.source!==frame.contentWindow' in js
assert "studio-draft/publish" in js
assert 'expected_revision' in js
assert 'eval(' not in js
def test_no_change_to_block_blueprint_or_database_schema():
demo=ROOT/'examples/Studio_V8_21_2_新手友好版交互预览.html'
assert demo.exists()
assert '演示操作不会修改正式网站' in demo.read_text()
from app.version import VERSION
assert VERSION.startswith('8.21.') and VERSION.endswith('-dev.1')
+23
View File
@@ -0,0 +1,23 @@
"""V8.21.1 editor integration wiring checks, side effect free."""
from pathlib import Path
ROOT=Path(__file__).resolve().parents[1]
def test_editor_scripts_and_tabs():
template=(ROOT/'app/templates/editor.html').read_text()
js=(ROOT/'app/static/editor.js').read_text()
for marker in ('/ui/editor-helpers.js','id="blockSearch"','id="tabStyle"','id="tabResponsive"','id="studioUndo"','id="studioRedo"','id="previewState"'):
assert marker in template
for marker in ('editorTools.filterBlocks','editorTools.reorder','editorTools.duplicate','shouldAcceptPreview','AbortController','savePromise','applyInspectorTab'):
assert marker in js
def test_studio_keeps_existing_publishing_contract():
js=(ROOT/'app/static/editor.js').read_text()
for marker in ('studio-draft/publish','studio-history','expected_revision','editor-preview','/api/sites/','buildSite()'):
assert marker in js
assert 'localStorage.setItem(' not in js
assert 'eval(' not in js
def test_editor_structure_no_anonymous_iframe_unsafe_permissions():
html=(ROOT/'app/templates/editor.html').read_text()
assert 'sandbox="allow-scripts"' in html
assert 'allow-same-origin' not in html
assert 'aria-label="当前页面区块"' in html
+83
View File
@@ -0,0 +1,83 @@
"""V8.21.3 mobile navigation must be optional, safe and theme-independent."""
import copy
import pytest
from tests.test_factory import client, create
from app import site_design_v87, builder, db
def config(tabs=True):
x=copy.deepcopy(site_design_v87.DEFAULTS)
x['mobile_tabs']={'enabled':tabs,'items':[
{'title':'首页','icon':'home','route':'/','action':'link'},
{'title':'产品','icon':'products','route':'/products/','action':'link'},
{'title':'菜单','icon':'menu','route':'/','action':'menu'},
]}
return x
@pytest.mark.parametrize('theme',['automotive','industrial','corporate','minimal'])
def test_all_four_professional_themes_render_optional_bar(client,theme):
sid=create(client,theme)
url=f'/api/sites/{sid}/site-design'
before=builder.build(sid,False)
previous=(db.DATA/'builds'/sid/before['version']/'dist/index.html').read_text()
assert 'data-mobile-tabs' not in previous
payload=config()
stored=client.put(url,json={'config':payload,'expected_revision':0})
assert stored.status_code==200,stored.text
waiting=builder.build(sid,False)
assert 'data-mobile-tabs' not in (db.DATA/'builds'/sid/waiting['version']/'dist/index.html').read_text()
published=client.post(url+'/publish',json={'expected_revision':1})
assert published.status_code==200,published.text
after=builder.build(sid,False)
dist=db.DATA/'builds'/sid/after['version']/'dist'
html=(dist/'index.html').read_text()
css=(dist/'assets/commercial.css').read_text()
js=(dist/'assets/pro.js').read_text()
assert 'data-mobile-tabs' in html and 'data-mobile-open-menu' in html
assert 'aria-current="page"' in html
assert '手机底部快捷导航' in html
assert 'sf-drawer-header' in css and 'sf-drawer-open' in css
assert 'sf-mobile-drawer-backdrop' in js and 'Escape' in js
assert len(html)>2000
def test_tabs_can_be_disabled_while_items_remain(client):
sid=create(client)
url=f'/api/sites/{sid}/site-design'
payload=config(False)
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
dist=db.DATA/'builds'/sid/builder.build(sid,False)['version']/'dist'
assert 'data-mobile-tabs' not in (dist/'index.html').read_text()
@pytest.mark.parametrize('bad',[{'enabled':True,'items':[]},
{'enabled':'yes','items':[]},
{'enabled':True,'items':[{'title':'Hello','icon':'script','route':'/'}]},
{'enabled':True,'items':[{'title':'测试','icon':'home','route':'javascript:alert(1)'}]},
{'enabled':True,'items':[{'title':'测试','icon':'home','route':'//127.0.0.1'}]},
{'enabled':True,'items':[{'title':'X'*20,'icon':'home','route':'/'}]},
{'enabled':True,'items':[{'title':'1','icon':'home','route':'/'}]*6},
{'enabled':True,'items':[{'title':'ok','icon':'home','action':'eval','route':'/'}]}])
def test_invalid_mobile_tabs_rejected(bad):
payload=config()
payload['mobile_tabs']=bad
with pytest.raises(Exception):site_design_v87.validate(payload)
def test_tab_config_is_normalized_and_legacy_defaults_are_safe():
legacy={'header':{},'responsive':{},'tokens':{},'footer':{},'nav':[]}
assert site_design_v87.validate(legacy)['mobile_tabs']=={'enabled':False,'items':[]}
planned=config()
out=site_design_v87.validate(planned)
assert out['mobile_tabs']['items'][-1]['action']=='menu'
assert out['mobile_tabs']['items'][-1]['route']=='/'
def test_contact_links_are_configurable_but_unsafe_schemes_are_blocked():
for url in ('https://wa.me/12345678','tel:+8613800000000','mailto:hello@example.com'):
v=config();v['mobile_tabs']['items'][1]['route']=url
assert site_design_v87.validate(v)['mobile_tabs']['items'][1]['route']==url
for url in ('http://example.com','javascript:alert(1)','data:text/html,hi','https://user:pass@example.com/','https://bad.example/\nnewline'):
v=config();v['mobile_tabs']['items'][1]['route']=url
with pytest.raises(Exception):site_design_v87.validate(v)
+107
View File
@@ -0,0 +1,107 @@
"""P6 local deterministic tests: locale associations, SEO reciprocity, draft safety."""
from pathlib import Path
import re
import pytest
from fastapi.testclient import TestClient
from app import db, multilingual, builder
from app.main import app
@pytest.fixture
def demo(tmp_path, monkeypatch):
monkeypatch.setattr(db,'DATA',tmp_path)
monkeypatch.setattr(db,'DB_FILE',tmp_path/'siteforge.sqlite3')
monkeypatch.setenv('SITEFORGE_ADMIN_PASSWORD','very-long-test-password-not-production')
monkeypatch.setenv('SITEFORGE_SESSION_SECRET','v815-ci-session-key-testing-only-not-a-secret')
with TestClient(app) as client:
assert client.post('/api/auth/login',json={'username':'admin','password':'very-long-test-password-not-production'}).status_code==200
csrf=client.get('/api/auth/me').json()['csrf']
client.headers.update({'X-SiteForge-CSRF':csrf})
def make(name,domain):
x=client.post('/api/sites',json={'name':name,'domain':domain,'theme':'corporate'})
assert x.status_code==201,x.text
return x.json()['id']
yield client,make,tmp_path
def post(client,path,body):
r=client.post(path,json=body)
assert r.status_code in (200,201),r.text
return r.json()
def prepare(client,make):
chinese=make('Chinese site','https://zh.example.org')
english=make('English site','https://en.example.org')
group=post(client,'/api/i18n/groups',{'name':'Hesu Global','default_locale':'en'})
gid=group['id']
post(client,f'/api/i18n/groups/{gid}/sites',{'site_id':chinese,'locale':'zh-CN'})
post(client,f'/api/i18n/groups/{gid}/sites',{'site_id':english,'locale':'en'})
a=post(client,f'/api/sites/{chinese}/entries',{'type':'page','title':'关于我们','route':'/about/','body':'<p>中文内容</p>'})['id']
b=post(client,f'/api/sites/{english}/entries',{'type':'page','title':'About','route':'/company/','body':'<p>English content</p>'})['id']
unit=post(client,f'/api/i18n/groups/{gid}/units',{'reference_key':'about-page'})['id']
post(client,f'/api/i18n/groups/{gid}/units/{unit}/members',{'site_id':chinese,'entry_id':a,'state':'approved'})
post(client,f'/api/i18n/groups/{gid}/units/{unit}/members',{'site_id':english,'entry_id':b,'state':'approved'})
return chinese,english,gid,unit
def test_reciprocal_hreflang_canonical_sitemap_and_language(demo):
client,make,tmp=demo
zh,en,gid,unit=prepare(client,make)
for sid,route,locale in [(zh,'about','zh-CN'),(en,'company','en')]:
result=builder.build(sid)
assert result['locale']==locale and result['alternate_pages']==1
build_id=result['version']
dist=tmp/'builds'/sid/build_id/'dist'
page=(dist/route/'index.html').read_text()
assert '<html lang="'+locale+'"' in page
assert '<link rel="alternate" hreflang="zh-CN" href="https://zh.example.org/about/">' in page
assert '<link rel="alternate" hreflang="en" href="https://en.example.org/company/">' in page
assert '<link rel="alternate" hreflang="x-default" href="https://en.example.org/company/">' in page
sitemap=(dist/'sitemap.xml').read_text()
assert 'xmlns:xhtml="http://www.w3.org/1999/xhtml"' in sitemap
assert 'xhtml:link rel="alternate" hreflang="en"' in sitemap
# No duplicate locale-specific path for homepage when there is no translation link.
assert 'hreflang="en"' not in (dist/'index.html').read_text()
detail=client.get('/api/i18n/sites/'+zh)
assert detail.status_code==200
assert len(detail.json()['units'])==2
def test_pending_translation_cannot_be_published_or_linked(demo):
client,make,tmp=demo
zh,en,gid,unit=prepare(client,make)
draft=post(client,f'/api/sites/{en}/entries',{'type':'page','title':'Pending English','route':'/pending/','status':'draft'})['id']
new_unit=post(client,f'/api/i18n/groups/{gid}/units',{'reference_key':'pending-page'})['id']
assert client.post(f'/api/i18n/groups/{gid}/units/{new_unit}/members',json={'site_id':en,'entry_id':draft,'state':'pending'}).status_code==200
assert multilingual.alternates(en).get('/pending/') is None
db.execute('UPDATE entries SET status=? WHERE id=?',('published',draft))
with pytest.raises(ValueError,match='待翻译'):
builder.build(en)
def test_group_validation_and_security_conflicts(demo):
client,make,tmp=demo
zh,en,gid,unit=prepare(client,make)
forbidden=make('Duplicate domain','https://zh.example.org')
assert client.post(f'/api/i18n/groups/{gid}/sites',json={'site_id':forbidden,'locale':'fr-FR'}).status_code==409
third=make('French','https://fr.example.org')
assert client.post(f'/api/i18n/groups/{gid}/sites',json={'site_id':third,'locale':'en'}).status_code==409
assert client.post(f'/api/i18n/groups/{gid}/sites',json={'site_id':third,'locale':'??'}).status_code==400
foreign=post(client,f'/api/sites/{third}/entries',{'title':'Page in other site','route':'/t/'})['id']
assert client.post(f'/api/i18n/groups/{gid}/units/{unit}/members',json={'site_id':zh,'entry_id':foreign,'state':'approved'}).status_code==404
home=db.one('SELECT id FROM entries WHERE site_id=? AND route=?',(zh,'/'))['id']
assert client.post(f'/api/i18n/groups/{gid}/units/{unit}/members',json={'site_id':zh,'entry_id':home,'state':'pending'}).status_code==409
unapproved=db.one('SELECT id FROM entries WHERE site_id=? AND route=?',(zh,'/about/'))['id']
assert client.post(f'/api/i18n/groups/{gid}/units/{unit}/members',json={'site_id':zh,'entry_id':unapproved,'state':'approved'}).status_code==200
def test_noindex_or_unapproved_peer_not_in_hreflang(demo):
client,make,tmp=demo
zh,en,gid,unit=prepare(client,make)
db.execute('UPDATE entries SET robots=? WHERE site_id=? AND route=?',('noindex,follow',en,'/company/'))
assert multilingual.alternates(zh)=={}
report=builder.build(zh)
html=(tmp/'builds'/zh/report['version']/'dist'/'about'/'index.html').read_text()
assert 'hreflang=' not in html
def test_injection_escape_and_valid_locale():
with pytest.raises(ValueError):multilingual.locale_value('<img onerror=x>')
assert multilingual.inject_seo('<!doctype html><html lang="zh-CN"><head></head><body>x</body>','fr-FR',[]) .find('lang="fr-FR"')>0
+88
View File
@@ -0,0 +1,88 @@
"""Production-hardening regressions that run with no external PostgreSQL server."""
import os
import time
from pathlib import Path
from unittest.mock import MagicMock
from types import SimpleNamespace
import pytest
from fastapi.testclient import TestClient
from app import pg_compat, worker_health, worker, db, healthchecks
def test_postgres_row_accepts_both_access_styles():
row=pg_compat.PGRow(['count','name'],[9,'sample'])
assert row[0]==9 and row[-1]=='sample'
assert row['count']==9 and row['name']=='sample'
assert dict(row)=={'count':9,'name':'sample'}
assert list(row.keys())==['count','name']
with pytest.raises(IndexError):_=row[2]
with pytest.raises(KeyError):_=row['missing']
def test_pg_row_factory_uses_description():
cursor=MagicMock()
cursor.description=[SimpleNamespace(name='id'),SimpleNamespace(name='status')]
row=pg_compat.row_factory(cursor)(('a','queued'))
assert row[0]=='a' and row['status']=='queued'
def test_pg_sql_lexer_preserves_literals_comments_and_semicolon():
statement="SELECT '?' AS q, id FROM a WHERE id=? -- ignore ?\n AND x='it''s ?' /* ? */ AND y=?"
normalized=pg_compat.placeholders(statement)
assert normalized.count('%s')==2
assert "'it''s ?'" in normalized and '-- ignore ?' in normalized
ddl="CREATE TABLE t(a TEXT DEFAULT 'semi;colon'); -- ignored; comment\n CREATE INDEX idx ON t(a);"
assert len(list(pg_compat.statements(ddl)))==2
def test_pragma_table_info_identifier_rejection():
pg=object.__new__(pg_compat.PGConnection)
pg.connection=MagicMock()
with pytest.raises(ValueError):
pg.execute('PRAGMA table_info(users;DROP TABLE accounts)')
pg.execute('PRAGMA table_info(build_jobs)')
assert pg.connection.execute.call_args.args[1]==('build_jobs',)
def test_worker_heartbeat_isolated_by_instance(monkeypatch,tmp_path):
monkeypatch.setattr(db,'DATA',tmp_path)
monkeypatch.setenv('SITEFORGE_WORKER_INSTANCE','worker-one')
worker.heartbeat()
first=worker_health.heartbeat_path()
assert first==tmp_path/'worker-heartbeats'/'worker-one'
assert worker_health.healthy()
monkeypatch.setenv('SITEFORGE_WORKER_INSTANCE','worker-two')
assert not worker_health.healthy()
worker.heartbeat()
assert worker_health.healthy()
monkeypatch.setenv('SITEFORGE_WORKER_INSTANCE','../../unsafe')
with pytest.raises(ValueError):worker_health.heartbeat_path()
def test_ready_requires_db_and_writable_storage(monkeypatch,tmp_path):
monkeypatch.setattr(db,'DATA',tmp_path)
monkeypatch.setattr(db,'DB_FILE',tmp_path/'siteforge.sqlite3')
monkeypatch.setattr(db,'POSTGRES',False)
db.init()
assert healthchecks.readiness()
def offline():raise OSError('internal connection error with secret')
monkeypatch.setattr(db,'connect',offline)
assert not healthchecks.readiness()
def test_http_readiness_is_public_but_redacted(monkeypatch,tmp_path):
monkeypatch.setenv('SITEFORGE_ADMIN_PASSWORD','test-admin-password-for-ci-strong')
monkeypatch.setenv('SITEFORGE_SESSION_SECRET','test-session-secret-for-ci-strong-and-long')
monkeypatch.setattr(db,'DATA',tmp_path)
monkeypatch.setattr(db,'DB_FILE',tmp_path/'test.sqlite3')
monkeypatch.setattr(db,'POSTGRES',False)
from app.main import app
with TestClient(app) as client:
with pytest.MonkeyPatch.context() as mp:
mp.setattr(healthchecks,'readiness',lambda:False)
failed=client.get('/api/ready')
assert failed.status_code==503 and failed.json()=={'status':'unavailable'}
with pytest.MonkeyPatch.context() as mp:
mp.setattr(healthchecks,'readiness',lambda:True)
success=client.get('/api/ready')
assert success.status_code==200 and success.json()=={'status':'ready'}
+2 -1
View File
@@ -1,4 +1,5 @@
"""V8.11: specialized tools share a single task-oriented UI without changing APIs."""
from app.version import VERSION
from test_factory import client
PROFESSIONAL = {
@@ -73,7 +74,7 @@ def test_editor_preserves_draft_publish_and_mobile_panels(client):
def test_health_and_main_workspace_match_unified_release(client):
assert client.get('/api/health').json()['version'] == '8.12.0-dev.1'
assert client.get('/api/health').json()['version'] == VERSION
assert 'const headings=' in client.get('/ui/workspace.js').text
assert 'new URLSearchParams(location.search)' in client.get('/ui/workspace.js').text
+47
View File
@@ -0,0 +1,47 @@
"""V8.17 runner contract: artifacts never hide failures or timeouts."""
import importlib.util
import json
import os
import sys
from pathlib import Path
import pytest
PATH=Path(__file__).resolve().parents[1]/'scripts'/'regression_v817.py'
spec=importlib.util.spec_from_file_location('regression_v817',PATH)
m=importlib.util.module_from_spec(spec)
sys.modules[spec.name]=m
spec.loader.exec_module(m)
def test_clean_report(tmp_path):
r=m.Run('round1-batch1',['tests/test_example.py'],'PASS',1.25,0,4,str(tmp_path/'e.log'))
out=m.write_reports([r],tmp_path/'report',1,1)
assert out['status']=='PASS'
assert out['tests_last_complete_round']==4
assert (tmp_path/'report.html').read_text().startswith('<!doctype html>')
def test_failure_not_erased_by_diagnostic_retry(tmp_path):
original=m.Run('round1-batch1',['tests/test_example.py'],'TIMEOUT',1.0,None,0,str(tmp_path/'1.log'))
isolated=m.Run('round1-batch1-isolated-1',['tests/test_example.py'],'PASS',1.0,0,2,str(tmp_path/'2.log'))
out=m.write_reports([original,isolated],tmp_path/'report',1,1)
assert out['status']=='FAIL'
assert len(out['failures'])==1
assert json.loads((tmp_path/'report.json').read_text())['status']=='FAIL'
def test_runner_rejects_bad_parameters():
with pytest.raises(SystemExit) as exc:
m.main(['--repeat','0'])
assert exc.value.code==2
@pytest.mark.skipif(os.name=='nt',reason='POSIX signal process-group check')
def test_slow_pytest_process_times_out_and_is_reaped(tmp_path):
test=tmp_path/'test_slow_v817.py'
test.write_text('import time\ndef test_slow():\n time.sleep(15)\n')
out=m.run_pytest('unit-timeout',[str(test)],1,tmp_path,os.environ.copy())
assert out.status=='TIMEOUT'
assert out.seconds<7
assert 'TIMEOUT' in Path(out.log).read_text()
+186
View File
@@ -0,0 +1,186 @@
"""Local, deterministic SFTP + S3 publisher contract tests; no external servers."""
from __future__ import annotations
from contextlib import contextmanager
import hashlib, io, json, os, stat, zipfile
from pathlib import Path
from types import SimpleNamespace
import pytest
from fastapi import HTTPException
from app import release_targets, release_api, db, enterprise
def package(html='first version'):
obj=io.BytesIO()
with zipfile.ZipFile(obj,'w') as z:
z.writestr('index.html',html)
z.writestr('products/index.html','Products')
return obj.getvalue()
class FakeSFTP:
def lstat(self,p):return os.lstat(p)
def mkdir(self,p):Path(p).mkdir()
def file(self,p,mode):return open(p,mode)
def symlink(self,to,p):os.symlink(to,p)
def readlink(self,p):return os.readlink(p)
def remove(self,p):os.remove(p)
def rename(self,a,b):os.rename(a,b)
def posix_rename(self,a,b):os.replace(a,b)
def test_sftp_immutable_release_atomic_switch_rollback_and_verification(monkeypatch,tmp_path):
root=str(tmp_path/'safe-root')
cfg={'type':'sftp','root':root,'host':'myhost','user':'publisher','port':22,'known_hosts':'mounted','ssh_key':'mounted'}
@contextmanager
def conn(_cfg):yield FakeSFTP()
monkeypatch.setattr(release_targets,'_sftp_connection',conn)
def probe(target,site,body):
assert (Path(root)/site/'current'/'index.html').read_bytes()==body
monkeypatch.setattr(release_targets,'_probe',probe)
a=release_targets.deploy_sftp('staging','mysite',package('first'),cfg)
b=release_targets.deploy_sftp('staging','mysite',package('second'),cfg)
assert b['previous']==a['release']
assert (Path(root)/'mysite'/'current').is_symlink()
assert (Path(root)/'mysite'/'current'/'index.html').read_text()=='second'
# Immutable retry does not change release ID or create additional release.
assert release_targets.deploy_sftp('staging','mysite',package('second'),cfg)['release']==b['release']
rolled=release_targets.rollback_sftp('staging','mysite',a['release'],cfg)
assert rolled['rolled_back']
assert (Path(root)/'mysite'/'current'/'index.html').read_text()=='first'
# Tampering with a staged/previous version causes verification to fail.
(Path(root)/'mysite'/'releases'/b['release']/'index.html').write_text('tampered')
with pytest.raises(ValueError,match='哈希'):release_targets.rollback_sftp('staging','mysite',b['release'],cfg)
assert (Path(root)/'mysite'/'current'/'index.html').read_text()=='first'
def test_sftp_probe_failure_restores_previous_release(monkeypatch,tmp_path):
cfg={'type':'sftp','root':str(tmp_path/'release'),'host':'host','user':'user','port':22,'known_hosts':'kh','ssh_key':'key'}
@contextmanager
def fake(_):yield FakeSFTP()
monkeypatch.setattr(release_targets,'_sftp_connection',fake)
monkeypatch.setattr(release_targets,'_probe',lambda *x:True)
old=release_targets.deploy_sftp('staging','abc',package('before'),cfg)
def fail(*_):raise HTTPException(502,'probe fails')
monkeypatch.setattr(release_targets,'_probe',fail)
with pytest.raises(HTTPException):release_targets.deploy_sftp('staging','abc',package('after'),cfg)
assert (Path(cfg['root'])/'abc'/'current'/'index.html').read_text()=='before'
class MissingS3(Exception):
def __init__(self):self.response={'Error':{'Code':'NoSuchKey'}}
class FakeS3:
def __init__(self):self.data={};self.etags={}
def get_object(self,*,Bucket,Key):
k=(Bucket,Key)
if k not in self.data:raise MissingS3()
return {'Body':io.BytesIO(self.data[k]),'ETag':self.etags[k]}
def put_object(self,*,Bucket,Key,Body,IfMatch=None,IfNoneMatch=None,**kwargs):
k=(Bucket,Key)
if IfMatch and self.etags.get(k)!=IfMatch:raise ValueError('PreconditionFailed')
if IfNoneMatch=='*' and k in self.data:raise ValueError('PreconditionFailed')
self.data[k]=bytes(Body)
self.etags[k]='"'+hashlib.md5(Body).hexdigest()+'"'
return {'ETag':self.etags[k]}
def test_s3_pointer_preflight_and_conditional_activation(monkeypatch):
cfg={'type':'s3','bucket':'example-bucket','region':'us-east-1','prefix':'web','endpoint':''}
s3=FakeS3()
monkeypatch.setattr(release_targets,'_s3_client',lambda _:s3)
def probe(target,site,body):
cur=json.loads(s3.data[('example-bucket','web/sites/mysite/current.json')])['release']
assert s3.data[('example-bucket',f'web/sites/mysite/releases/{cur}/index.html')]==body
monkeypatch.setattr(release_targets,'_probe',probe)
a=release_targets.deploy_s3('staging','mysite',package('before'),cfg)
b=release_targets.deploy_s3('staging','mysite',package('after'),cfg)
assert b['previous']==a['release']
assert release_targets.deploy_s3('staging','mysite',package('after'),cfg)['release']==b['release']
assert release_targets.rollback_s3('staging','mysite',a['release'],cfg)['rolled_back']
curr,_=release_targets._s3_current(s3,cfg,'mysite')
assert curr==a['release']
# Fail closed if no pointer-aware edge is configured.
monkeypatch.setenv('SITEFORGE_DEPLOY_STAGING_TYPE','s3')
monkeypatch.setenv('SITEFORGE_DEPLOY_STAGING_BUCKET','example-bucket')
monkeypatch.setenv('SITEFORGE_DEPLOY_STAGING_REGION','us-east-1')
monkeypatch.setenv('SITEFORGE_DEPLOY_STAGING_PUBLIC_URL','https://preview.example.com/mysite')
with pytest.raises(HTTPException,match='POINTER_AWARE'):release_targets.config('staging')
monkeypatch.setenv('SITEFORGE_DEPLOY_STAGING_PUBLIC_URL','https://preview.example.com/mysite')
monkeypatch.setenv('SITEFORGE_DEPLOY_STAGING_POINTER_AWARE','1')
assert release_targets.config('staging')['type']=='s3'
def test_s3_verification_failure_does_not_activate(monkeypatch):
cfg={'type':'s3','bucket':'test-bucket','region':'us-east-1','prefix':'x','endpoint':''}
obj=FakeS3();monkeypatch.setattr(release_targets,'_s3_client',lambda _:obj)
monkeypatch.setattr(release_targets,'_probe',lambda *a:True)
first=release_targets.deploy_s3('staging','one',package('ok'),cfg)
raw=package('bad');release,digest,files,hashes=release_targets._archive(raw)
manifest=release_targets._manifest(digest,hashes)
key=f'x/sites/one/releases/{release}/'
for path,body in files.items():obj.put_object(Bucket='test-bucket',Key=key+path,Body=body)
obj.put_object(Bucket='test-bucket',Key=key+'.siteforge-manifest.json',Body=manifest)
obj.put_object(Bucket='test-bucket',Key=key+'index.html',Body=b'corrupt')
with pytest.raises(ValueError,match='校验'):release_targets.deploy_s3('staging','one',raw,cfg)
current,_=release_targets._s3_current(obj,cfg,'one')
assert current==first['release']
def test_config_fail_closed_and_no_url_secret(monkeypatch,tmp_path):
monkeypatch.setenv('SITEFORGE_DEPLOY_STAGING_TYPE','sftp')
monkeypatch.setenv('SITEFORGE_DEPLOY_STAGING_PUBLIC_URL','https://preview.example.com/mysite')
monkeypatch.setenv('SITEFORGE_DEPLOY_STAGING_HOST','example.org')
monkeypatch.setenv('SITEFORGE_DEPLOY_STAGING_USERNAME','siteforge')
monkeypatch.setenv('SITEFORGE_DEPLOY_STAGING_REMOTE_ROOT','/srv/sites')
monkeypatch.setenv('SITEFORGE_DEPLOY_STAGING_KNOWN_HOSTS_FILE','/run/secrets/known_hosts')
monkeypatch.setenv('SITEFORGE_DEPLOY_STAGING_SSH_KEY_FILE','/run/secrets/publish_key')
assert release_targets.config('staging')['type']=='sftp'
with pytest.raises(HTTPException):release_targets.config('staging',check_files=True)
monkeypatch.setenv('SITEFORGE_DEPLOY_STAGING_REMOTE_ROOT','/../etc')
with pytest.raises(HTTPException):release_targets.config('staging')
monkeypatch.setenv('SITEFORGE_DEPLOY_STAGING_TYPE','unsupported')
with pytest.raises(HTTPException):release_targets.backend('staging')
def test_s3_gateway_reads_atomic_pointer(monkeypatch):
from app import s3_gateway
from fastapi.testclient import TestClient
cfg={'type':'s3','bucket':'test-bucket','region':'ap-southeast-1','prefix':'sitesdemo','endpoint':''}
store=FakeS3()
monkeypatch.setattr(release_targets,'_s3_client',lambda _:store)
monkeypatch.setattr(release_targets,'_probe',lambda *a:True)
one=release_targets.deploy_s3('staging','sample',package('hello'),cfg)
monkeypatch.setenv('SITEFORGE_S3_GATEWAY_BUCKET','test-bucket')
monkeypatch.setenv('SITEFORGE_S3_GATEWAY_REGION','ap-southeast-1')
monkeypatch.setenv('SITEFORGE_S3_GATEWAY_PREFIX','sitesdemo')
monkeypatch.setattr(s3_gateway,'client',lambda _:store)
client=TestClient(s3_gateway.app)
assert client.get('/sample/').status_code==200
assert client.get('/sample/').text=='hello'
assert client.get('/sample/products/').text=='Products'
assert client.get('/sample/').headers['cache-control'].startswith('no-cache')
two=release_targets.deploy_s3('staging','sample',package('world'),cfg)
assert one['release']!=two['release']
assert client.get('/sample/').text=='world'
assert client.get('/sample/%2e%2e/private').status_code in (404,)
assert client.get('/sample/no-such-file').status_code==404
def test_native_provider_keeps_existing_approval_queue_and_history(monkeypatch,tmp_path):
from app import release_api
from app import db,enterprise
monkeypatch.setattr(db,'DATA',tmp_path)
monkeypatch.setattr(db,'DB_FILE',tmp_path/'data.sqlite3')
db.init();enterprise.setup();release_api.setup()
db.insert('INSERT INTO sites(id,name,created_at,updated_at) VALUES(?,?,?,?)',
('proj','Project',db.now(),db.now()))
archive_dir=tmp_path/'archives';archive_dir.mkdir()
path=archive_dir/'proj-build1.zip';path.write_bytes(package('new'))
monkeypatch.setattr(release_api,'approved',lambda site,build:{'zip_path':str(path)})
monkeypatch.setenv('SITEFORGE_DEPLOY_STAGING_TYPE','sftp')
monkeypatch.setattr(release_targets,'deploy',lambda target,site,archive:{
'published':True,'release':'a'*20,'sha256':'b'*64,'files':2,'target':target,'backend':'sftp'})
monkeypatch.setattr(release_targets,'rollback',lambda target,site,release:{
'rolled_back':True,'release':release,'target':target,'backend':'sftp'})
assert release_api.run_delivery('proj','remote_deploy',{'target':'staging','build_id':'build1'},'task1')['published']
assert db.one('SELECT release_id FROM remote_releases WHERE site_id=?',('proj',))['release_id']=='a'*20
assert release_api.run_delivery('proj','remote_rollback',{'target':'staging','release_id':'a'*20},'task2')['rolled_back']
assert len(db.rows('SELECT * FROM release_history WHERE site_id=?',('proj',)))==2
+196
View File
@@ -0,0 +1,196 @@
"""Deterministic release tests with an in-process signed Agent fixture, no internet."""
from __future__ import annotations
import hashlib, io, json, time, zipfile
from pathlib import Path
import pytest
from fastapi import HTTPException
from fastapi.testclient import TestClient
from app import db, enterprise, release_api, deploy_agent, jobs
from app.safe_http import Fetched
SECRET='release-secret-strong-enough-for-test-environment-123'
def archive(html='<h1>Demo</h1>'):
b=io.BytesIO()
with zipfile.ZipFile(b,'w') as z:
z.writestr('index.html',html)
z.writestr('404.html','<h1>Not found</h1>')
z.writestr('products/index.html','<h1>Products</h1>')
return b.getvalue()
def signed(site,method,path,body=b''):
stamp=str(int(time.time()))
return {'X-SiteForge-Timestamp':stamp,
'X-SiteForge-Signature':deploy_agent.signature(SECRET,site,stamp,body,method,path)}
def test_staging_and_activation_are_separate(monkeypatch,tmp_path):
monkeypatch.setattr(deploy_agent,'ROOT',tmp_path)
monkeypatch.setenv('SITEFORGE_DEPLOY_SECRET',SECRET)
client=TestClient(deploy_agent.app)
raw=archive()
staged=client.post('/staging/example',content=raw,headers=signed('example','POST','/staging/example',raw))
assert staged.status_code==200, staged.text
version=staged.json()['release']
assert staged.json()['current'] is None
assert not (tmp_path/'example'/'current').exists()
check=client.get(f'/sites/example/verify/{version}',headers=signed('example','GET',f'/sites/example/verify/{version}'))
assert check.status_code==200 and check.json()['files']==3
assert check.json()['active'] is False
activated=client.post(f'/sites/example/activate/{version}',headers=signed('example','POST',f'/sites/example/activate/{version}'))
assert activated.status_code==200 and activated.json()['current']==version
assert activated.json()['previous'] is None
# The preview HTTP path resolves from pointer only once activated.
assert client.get('/public/example/products/').status_code==200
def test_tampering_blocks_activation_and_retains_current(monkeypatch,tmp_path):
monkeypatch.setattr(deploy_agent,'ROOT',tmp_path)
monkeypatch.setenv('SITEFORGE_DEPLOY_SECRET',SECRET)
client=TestClient(deploy_agent.app)
first=archive('first'),archive('second')
old=client.post('/staging/example',content=first[0],headers=signed('example','POST','/staging/example',first[0])).json()['release']
assert client.post(f'/sites/example/activate/{old}',headers=signed('example','POST',f'/sites/example/activate/{old}')).status_code==200
new=client.post('/staging/example',content=first[1],headers=signed('example','POST','/staging/example',first[1])).json()['release']
(tmp_path/'example'/'releases'/new/'index.html').write_text('tampered',encoding='utf-8')
denied=client.post(f'/sites/example/activate/{new}',headers=signed('example','POST',f'/sites/example/activate/{new}'))
assert denied.status_code==409
assert (tmp_path/'example'/'current').read_text()==old
assert client.get('/public/example/').text=='first'
def test_agent_rejects_invalid_signature_and_paths(monkeypatch,tmp_path):
monkeypatch.setattr(deploy_agent,'ROOT',tmp_path)
monkeypatch.setenv('SITEFORGE_DEPLOY_SECRET',SECRET)
client=TestClient(deploy_agent.app)
raw=archive()
assert client.post('/staging/example',content=raw,headers={'X-SiteForge-Signature':'bad'}).status_code==401
with pytest.raises(ValueError):deploy_agent.validate_archive(_bad_zip('../escape.html'))
with pytest.raises(ValueError):deploy_agent.validate_archive(_bad_zip('evil\\index.html'))
def _bad_zip(name):
b=io.BytesIO()
with zipfile.ZipFile(b,'w') as z:
z.writestr('index.html','ok')
z.writestr(name,'bad')
return b.getvalue()
def setup_db(monkeypatch,tmp_path):
monkeypatch.setattr(db,'DATA',tmp_path)
monkeypatch.setattr(db,'DB_FILE',tmp_path/'db.sqlite3')
db.init();enterprise.setup();release_api.setup()
db.insert('INSERT INTO sites(id,name,created_at,updated_at) VALUES(?,?,?,?)',
('s1','Demo',db.now(),db.now()))
def patch_agent_http(monkeypatch,client):
def make_result(r,url):
return Fetched(url,r.status_code,{k.lower():v for k,v in r.headers.items()},r.content)
def post(url,body=b'',headers=None,timeout=120,**kw):
path=url.removeprefix('https://agent.example')
return make_result(client.post(path,content=body,headers=headers or {}),url)
def fetch(url,headers=None,method='GET',timeout=30,**kw):
path=url.removeprefix('https://agent.example')
return make_result(client.get(path,headers=headers or {}),url)
monkeypatch.setattr(release_api.safe_http,'post',post)
monkeypatch.setattr(release_api.safe_http,'fetch',fetch)
def test_queued_deploy_with_signed_agent_and_rollback(monkeypatch,tmp_path):
setup_db(monkeypatch,tmp_path)
monkeypatch.setattr(deploy_agent,'ROOT',tmp_path/'agent')
monkeypatch.setenv('SITEFORGE_DEPLOY_SECRET',SECRET)
monkeypatch.setenv('SITEFORGE_DEPLOY_STAGING_URL','https://agent.example')
monkeypatch.setenv('SITEFORGE_DEPLOY_STAGING_SECRET',SECRET)
monkeypatch.setattr(release_api,'approved',lambda sid,bid:{'zip_path':str(tmp_path/'archives'/f'{sid}-{bid}.zip')})
(tmp_path/'archives').mkdir()
client=TestClient(deploy_agent.app)
patch_agent_http(monkeypatch,client)
for bid,content in [('build1','one'),('build2','two')]:
(tmp_path/'archives'/f's1-{bid}.zip').write_bytes(archive(content))
d=jobs.submit_delivery('s1','remote_deploy',{'target':'staging','build_id':bid},'admin','key-'+bid+'-3333')
assert d['state']=='queued'
assert jobs.submit_delivery('s1','remote_deploy',{'target':'staging','build_id':bid},'admin','key-'+bid+'-3333')['existing']
r=release_api.run_delivery('s1','remote_deploy',{'target':'staging','build_id':bid},d['id'])
assert r['published'] and r['files']==3
hist=db.rows('SELECT * FROM remote_releases ORDER BY deployed_at')
first=hist[0]['release_id']
assert (tmp_path/'agent'/'s1'/'current').read_text()!=first
result=release_api.run_delivery('s1','remote_rollback',{'target':'staging','release_id':first},'revert123')
assert result['rolled_back']
assert (tmp_path/'agent'/'s1'/'current').read_text()==first
def test_delivery_queue_enforces_key_and_cap(monkeypatch,tmp_path):
setup_db(monkeypatch,tmp_path)
monkeypatch.setenv('SITEFORGE_MAX_PENDING_JOBS_PER_SITE','1')
payload={'target':'production','build_id':'alpha'}
one=jobs.submit_delivery('s1','remote_deploy',payload,'owner','release-key-11')
assert jobs.submit_delivery('s1','remote_deploy',payload,'owner','release-key-11')['existing']
with pytest.raises(jobs.JobConflict):jobs.submit_delivery('s1','remote_deploy',{'target':'staging','build_id':'beta'},'owner','release-key-11')
with pytest.raises(jobs.JobCapacityError):jobs.submit_delivery('s1','remote_rollback',{'target':'production','release_id':'a'*20},'owner','release-key-22')
assert jobs.cancel('s1',one['id'])['state']=='cancelled'
def test_release_worker_registered_and_api_uses_queue():
from app import job_runner
source=Path(job_runner.__file__).read_text(encoding='utf-8')
assert "('remote_deploy','remote_rollback')" in source
source=Path(release_api.__file__).read_text(encoding='utf-8')
assert 'jobs.submit_delivery' in source
assert 'preflight' in source
def test_release_endpoint_requires_approval_then_queues(monkeypatch,tmp_path):
from app import factory_api, builder
from app.main import app
setup_db(monkeypatch,tmp_path)
factory_api.setup()
monkeypatch.setenv('SITEFORGE_ADMIN_PASSWORD','test-admin-password-for-v813')
monkeypatch.setenv('SITEFORGE_SESSION_SECRET','test-session-secret-for-v813-xxxxxxxx')
monkeypatch.setenv('SITEFORGE_DEPLOY_STAGING_URL','https://agent.example')
monkeypatch.setenv('SITEFORGE_DEPLOY_STAGING_SECRET_REF','env:SITEFORGE_STAGING_TOKEN')
with TestClient(app) as c:
assert c.post('/api/auth/login',json={'password':'test-admin-password-for-v813'}).status_code==200
c.headers['X-SiteForge-CSRF']=c.get('/api/auth/me').json()['csrf']
version=builder.build('s1')['version']
unauth=c.post('/api/production/sites/s1/deploy',json={'build_id':version,'target':'staging'})
assert unauth.status_code==409,unauth.text
preflight=c.get(f'/api/production/sites/s1/preflight/{version}').json()
assert preflight['ready'] is False
# Simulate a separate reviewer; requester cannot approve their own release.
db.insert('''INSERT INTO release_requests(id,site_id,build_id,requested_by,requested_at,state,reviewed_by)
VALUES(?,?,?,?,?,?,?)''',('approval13','s1',version,'editor',db.now(),'approved','reviewer'))
assert c.get(f'/api/production/sites/s1/preflight/{version}').json()['ready']
sent=c.post('/api/production/sites/s1/deploy',json={'build_id':version,'target':'staging'},headers={'Idempotency-Key':'v813-release-idem'})
assert sent.status_code==202,sent.text
assert sent.json()['state']=='queued'
repeat=c.post('/api/production/sites/s1/deploy',json={'build_id':version,'target':'staging'},headers={'Idempotency-Key':'v813-release-idem'})
assert repeat.status_code==202 and repeat.json()['existing']
status=c.get('/api/production/sites/s1/delivery-status').json()
assert status['jobs'][0]['kind']=='remote_deploy'
assert status['configured']['staging'] is True
assert 'STAGING_TOKEN' not in str(status)
def test_staged_zip_reserved_manifest_rejected():
with pytest.raises(ValueError,match='reserved'):
deploy_agent.validate_archive(_bad_zip('.siteforge-manifest.json'))
b=io.BytesIO()
with zipfile.ZipFile(b,'w') as z:
z.writestr('index.html','first')
z.writestr('INDEX.HTML','second')
with pytest.raises(ValueError,match='Duplicate'):
deploy_agent.validate_archive(b.getvalue())
def test_workspace_has_release_preflight_and_rollback():
path=Path(__file__).resolve().parents[1]/'app/static/workspace.js'
script=path.read_text(encoding='utf-8')
for token in ('release-preflight','release-deploy','release-approval','release-rollback',
'delivery-status','Idempotency-Key'):
assert token in script
+173
View File
@@ -0,0 +1,173 @@
import importlib.util
import json
import os
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
MODULE_PATH = Path(__file__).resolve().parents[1] / 'scripts' / 'siteforge_v817_audit.py'
spec = importlib.util.spec_from_file_location('siteforge_audit', MODULE_PATH)
a = importlib.util.module_from_spec(spec)
import sys
sys.modules[spec.name] = a
spec.loader.exec_module(a)
class DummyArgs: pass
class AuditTests(unittest.TestCase):
def setUp(self):
self.r = a.Report(DummyArgs())
self.temp = tempfile.TemporaryDirectory()
self.root = Path(self.temp.name)
def tearDown(self):
self.temp.cleanup()
def statuses(self):
return {c.code: c.status for c in self.r.checks}
def test_loopback_url(self):
self.assertTrue(a._local_host('http://127.0.0.1:8787'))
self.assertTrue(a._local_host('http://[::1]:8787'))
self.assertTrue(a._local_host('https://localhost'))
def test_remote_is_not_loopback(self):
for host in ('example.com', '192.168.1.1', '169.254.169.254', '10.0.0.3'):
self.assertFalse(a._local_host('http://' + host))
def test_credentials_in_url_forbidden(self):
self.assertFalse(a._local_host('http://admin:secret@localhost:8787'))
def test_http_remote_refused_without_consent(self):
with patch.object(a, '_probe', side_effect=AssertionError('should not send a request')):
a.audit_http(self.r, 'https://example.com', False)
self.assertEqual(self.statuses()['http.scope'], 'fail')
def test_http_health_pass(self):
with patch.object(a, '_probe', side_effect=[(200, '{"status":"ok"}', ''), (200, '{"status":"ok"}', ''), (200, 'OK', '')]):
a.audit_http(self.r, 'http://127.0.0.1:8787', False)
self.assertEqual(self.statuses()['http.ready'], 'pass')
def test_http_not_ready_fails(self):
with patch.object(a, '_probe', side_effect=[(200, '{"status":"ok"}', ''), (503, '', 'bad'), (200, 'OK', '')]):
a.audit_http(self.r, 'http://127.0.0.1:8787', False)
self.assertEqual(self.statuses()['http.ready'], 'fail')
def test_http_unavailable_status_fails(self):
with patch.object(a, '_probe', side_effect=[(200, '{"status":"ok"}', ''), (200, '{"status":"unavailable"}', ''), (200, 'OK', '')]):
a.audit_http(self.r, 'http://127.0.0.1:8787', False)
self.assertEqual(self.statuses()['http.ready'], 'fail')
def test_route_with_path_traversal_rejected(self):
self.assertIsNone(a.normalized_route('/../private'))
self.assertIsNone(a.normalized_route('/%2e%2e/secret'))
self.assertIsNone(a.normalized_route('/bad\\path'))
self.assertIsNone(a.normalized_route('/a//b'))
def test_route_query_disallowed(self):
self.assertIsNone(a.normalized_route('/foo/?hello=1'))
self.assertIsNone(a.normalized_route('https://example.com/'))
self.assertEqual(a.normalized_route('/product/'), '/product/')
def test_static_html_pass(self):
(self.root / 'index.html').write_text('<html lang="en"><head><link rel="canonical" href="https://example.com/"></head><body><a href="/">Home</a></body></html>')
(self.root / 'sitemap.xml').write_text('ok')
(self.root / 'robots.txt').write_text('ok')
a.audit_build(self.r, self.root, None)
self.assertEqual(self.statuses()['build.internal_links'], 'pass')
self.assertEqual(self.statuses()['build.canonical_scheme'], 'pass')
def test_broken_internal_link_fails(self):
(self.root / 'index.html').write_text('<html lang="en"><a href="/missing/">Missing</a></html>')
a.audit_build(self.r, self.root, None)
self.assertEqual(self.statuses()['build.internal_links'], 'fail')
def test_duplicate_canonicals_fail(self):
(self.root / 'index.html').write_text('<html lang="zh"><link rel="canonical" href="https://e.com/"><link rel="canonical" href="https://e.com/2"></html>')
a.audit_build(self.r, self.root, None)
self.assertEqual(self.statuses()['build.canonical_duplicate'], 'fail')
def test_url_baseline_coverage(self):
(self.root / 'index.html').write_text('<html lang="en"></html>')
b = self.root / 'urls.txt'
b.write_text('/\n/product/\n')
a.audit_build(self.r, self.root, b)
self.assertEqual(self.statuses()['build.url_baseline'], 'fail')
(self.root / 'product').mkdir()
(self.root / 'product' / 'index.html').write_text('<html lang="en"></html>')
r2 = a.Report(DummyArgs())
a.audit_build(r2, self.root, b)
self.assertEqual({x.code:x.status for x in r2.checks}['build.url_baseline'], 'pass')
def test_invalid_baseline_route_fails(self):
(self.root / 'index.html').write_text('<html lang="en"></html>')
b = self.root / 'urls.txt'
b.write_text('/\n/../secret\n')
a.audit_build(self.r, self.root, b)
self.assertEqual(self.statuses()['build.url_baseline'], 'fail')
def test_html_reports_escape_untrusted_text(self):
self.r.add('test', 'http', 'warn', '<script>alert(1)</script>', '<img src=x onerror=alert(2)>')
x = a.html_report(self.r.data())
self.assertNotIn('<script>alert(1)</script>', x)
self.assertNotIn('<img src=x onerror=alert(2)>', x)
self.assertIn('&lt;script&gt;', x)
def test_report_ready_requires_all_pass(self):
self.r.add('test', 'http', 'pass', 'ok')
self.assertTrue(self.r.data()['selected_checks_clear'])
self.assertFalse(self.r.data()['production_ready'])
self.r.add('skip', 'release', 'skip', 'not done')
self.assertFalse(self.r.data()['selected_checks_clear'])
def test_release_evidence_requires_proof_fields(self):
f = self.root/'evidence.json'
f.write_text('{"isolated_pg":{"status":"passed","operator":"team"}}')
a.release_gate(self.r, f)
self.assertEqual(self.statuses()['release.isolated_pg'], 'warn')
self.assertEqual(len(self.r.checks), 10)
def test_release_no_evidence_not_passed(self):
a.release_gate(self.r, None)
self.assertEqual(self.statuses()['release.evidence'], 'skip')
def test_repo_minimal_fails_softly(self):
(self.root/'app').mkdir()
(self.root/'app'/'test.py').write_text('x = 1')
a.audit_repo(self.r, self.root)
self.assertEqual(self.statuses()['repo.python_syntax'], 'pass')
self.assertEqual(self.statuses()['repo.app.builder.py'], 'warn')
def test_repo_python_syntax_fail(self):
(self.root/'app').mkdir()
(self.root/'app'/'bad.py').write_text('def oops(\n')
a.audit_repo(self.r, self.root)
self.assertEqual(self.statuses()['repo.python_syntax'], 'fail')
def test_reports_generated_cli(self):
out = self.root/'audit'
f = self.root/'build'
f.mkdir()
(f/'index.html').write_text('<html lang="zh-CN"></html>')
code = a.main(['--build-dir', str(f), '--output', str(out)])
self.assertEqual(code, 0)
for ext in ('.json', '.md', '.html'):
self.assertTrue(out.with_suffix(ext).exists())
self.assertEqual(json.loads(out.with_suffix('.json').read_text())['live_production_verified'], False)
def test_no_redis_settings_skipped(self):
with patch.dict(os.environ, {'SITEFORGE_REDIS_URL':''}):
a.audit_redis(self.r)
self.assertEqual(self.statuses()['redis.config'], 'skip')
def test_no_pg_settings_skipped(self):
with patch.dict(os.environ, {'SITEFORGE_DATABASE_URL':''}):
a.audit_postgres(self.r)
self.assertEqual(self.statuses()['postgres.config'], 'skip')
if __name__ == '__main__':
unittest.main()
+8 -15
View File
@@ -6,9 +6,10 @@ import zipfile
from pathlib import Path
from bs4 import BeautifulSoup
from test_factory_v2 import factory, site, zip_theme
from app.core import THEMES
def test_complete_theme_families_have_distinct_markup(factory):
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'):
@@ -19,23 +20,15 @@ def test_complete_theme_families_have_distinct_markup(factory):
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
# trade-pro 的产品详情自 Task 10b 起为 cms 移植布局(sf-tp-product-*),
# 骨架断言只覆盖其余沿用 pro-base 骨架的主题(test_theme_registry.py
# 的 test_trade_pro_product_layout_structure 覆盖 trade-pro 侧)。
if page_type=='product' and theme!='trade-pro':assert soup.select_one('.pro-product-layout .pro-specs')
if page_type=='product' and theme!='trade-pro':assert soup.select_one('.pro-product-layout .pro-specs') # trade-pro covered by test_theme_registry
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)
# Every theme must carry its own hero markup signature. The legacy families
# share the pro-home-hero base class with a per-theme variant; trade-pro
# ports the cms design with its own sf-tp-hero class. Five themes, five
# distinct hero class strings ("4" was the pre-trade-pro coincidence).
def hero_classes(html):
soup=BeautifulSoup(html,'html.parser')
el=soup.find('section',class_=re.compile(r'^(pro-home-hero|sf-tp-hero)$'))
assert el is not None,'home hero section not found'
return ' '.join(el.get('class',[]))
assert len(set(hero_classes(s) for s in main))==5
def _hero_sig(html):
import re as _re
m=_re.search(r'class="((?:pro-home-hero|sf-tp-hero)[^"]*)"',html)
return m.group(1) if m else html[:40]
assert len(set(_hero_sig(s) for s in main))==5 # 5 themes, 5 distinct hero signatures (was 4 before trade-pro)
def test_theme_build_derived_category_brand_search_and_seo(factory):
+3
View File
@@ -105,6 +105,9 @@ def test_trade_pro_builds_real_site(factory):
assert factory.post(f'/api/factory/sites/{sid}/bootstrap').status_code == 200
selected = factory.put(f'/api/factory/sites/{sid}/theme', json={'theme': 'trade-pro', 'version': '1.0.0'})
assert selected.status_code == 200, selected.text
# V8.15+ multilingual rewrites <html lang> per site locale — declare English
loc = factory.put(f'/api/i18n/sites/{sid}/locale', json={'locale': 'en'})
assert loc.status_code == 200, loc.text
result = factory.post(f'/api/sites/{sid}/builds', json={})
assert result.status_code == 200, result.text
bid = result.json()['version']
+141
View File
@@ -0,0 +1,141 @@
"""V8.18 recovery safety tests; never touch a real database/server."""
import hashlib
import io
import json
import subprocess
import zipfile
import pytest
from pathlib import Path
from app import backup, db, jobs, enterprise, pg_disaster_recovery as pg
URL='postgresql://backup_user:super-secret@127.0.0.1:5432/empty_restore?sslmode=require'
def test_pg_env_never_passes_secret_in_commands():
env=pg.connection_env(URL, {'PATH':'/usr/bin'})
assert env['PGPASSWORD']=='super-secret'
assert env['PGHOST']=='127.0.0.1'
assert env['PGSSLMODE']=='require'
assert env['PGPASSFILE']=='/dev/null'
with pytest.raises(pg.RecoveryError):pg.connection_env('not a pg url')
with pytest.raises(pg.RecoveryError):pg.connection_env(URL+'&danger=1')
def test_sqlite_backup_cannot_run_in_postgres_mode(monkeypatch,tmp_path):
monkeypatch.setattr(db,'POSTGRES',True)
monkeypatch.setattr(db,'DATA',tmp_path)
monkeypatch.setattr(db,'DB_FILE',tmp_path/'no-fake.sqlite3')
with pytest.raises(RuntimeError,match='PostgreSQL'):
backup.backup()
assert not (tmp_path/'no-fake.sqlite3').exists()
def _fake_tools(commands):
def fake_run(args,env,timeout=1800):
assert 'super-secret' not in ' '.join(args)
commands.append(args)
if args[0]=='pg_dump':
Path(args[-1]).write_bytes(b'PGDMP\0offline-mock')
return subprocess.CompletedProcess(args,0,b'',b'')
if args[0]=='psql':
return subprocess.CompletedProcess(args,0,b'0\n',b'')
return subprocess.CompletedProcess(args,0,b'',b'')
return fake_run
def test_backup_verify_and_restore_to_empty_destination(tmp_path,monkeypatch):
storage=tmp_path/'storage';(storage/'media').mkdir(parents=True)
(storage/'media'/'logo.png').write_bytes(b'fake-png')
archive=tmp_path/'external'/'backup.zip';commands=[]
monkeypatch.setattr(pg,'run_pg',_fake_tools(commands))
with pytest.raises(pg.RecoveryError,match='Stop all'):
pg.backup(URL,storage,archive)
result=pg.backup(URL,storage,archive,confirmed_offline=True)
assert result['files']==2
manifest=pg.verify(archive)
assert manifest['database']=='postgresql'
assert 'media/logo.png' in manifest['files']
assert [x[0] for x in commands[:2]]==['pg_dump','pg_restore']
with pytest.raises(pg.RecoveryError,match='overwrite'):
pg.backup(URL,storage,archive,confirmed_offline=True)
target=tmp_path/'restored'
with pytest.raises(pg.RecoveryError,match='confirmation'):
pg.restore_new(URL,archive,target)
restored=pg.restore_new(URL,archive,target,confirmed_offline=True)
assert restored['media_files']==1
assert (target/'media'/'logo.png').read_bytes()==b'fake-png'
assert not (target/'database.dump').exists()
assert [x[0] for x in commands[-2:]]==['psql','pg_restore']
with pytest.raises(pg.RecoveryError,match='must not exist'):
pg.restore_new(URL,archive,target,confirmed_offline=True)
def test_tamper_rejected_before_db_restore(tmp_path,monkeypatch):
src=tmp_path/'media';src.mkdir()
z=tmp_path/'good.zip'
monkeypatch.setattr(pg,'run_pg',_fake_tools([]))
pg.backup(URL,tmp_path,z,confirmed_offline=True)
corrupt=tmp_path/'corrupt.zip'
with zipfile.ZipFile(z) as original, zipfile.ZipFile(corrupt,'w') as out:
for name in original.namelist():
payload=original.read(name)
if name=='database.dump':payload=b'PODMP\0offline-mock'
out.writestr(name,payload)
with pytest.raises(pg.RecoveryError,match='Checksum'):
pg.verify(corrupt)
assert not (tmp_path/'restored').exists()
def test_traversal_and_symlink_refused(tmp_path,monkeypatch):
src=tmp_path/'storage';src.mkdir()
(src/'media').symlink_to(tmp_path)
monkeypatch.setattr(pg,'run_pg',_fake_tools([]))
with pytest.raises(pg.RecoveryError,match='Symlink'):
pg.backup(URL,src,tmp_path/'bad.zip',confirmed_offline=True)
evil=tmp_path/'evil.zip'
with zipfile.ZipFile(evil,'w') as z:
z.writestr('manifest.json','{}')
z.writestr('database.dump',b'PGDMP')
z.writestr('../hijack',b'evil')
with pytest.raises(pg.RecoveryError,match='Unsafe'):
pg.verify(evil)
def test_expired_release_is_never_auto_retried(tmp_path,monkeypatch):
monkeypatch.setattr(db,'POSTGRES',False)
monkeypatch.setattr(db,'DATA',tmp_path)
monkeypatch.setattr(db,'DB_FILE',tmp_path/'siteforge.sqlite3')
db.init(); enterprise.setup()
db.insert('INSERT INTO sites(id,name,created_at,updated_at) VALUES (?,?,?,?)',
('site818','Sample',db.now(),db.now()))
for jobid,kind in [('release818','remote_deploy'),('roll818','remote_rollback'),('build818','build')]:
db.insert('''INSERT INTO build_jobs(id,site_id,kind,state,created_by,created_at,
attempts,max_attempts,worker_id,lease_until) VALUES (?,?,?,?,?,?,?,?,?,?)''',
(jobid,'site818',kind,'running','system',db.now(),1,3,'dead-worker','2020-01-01T00:00:00+00:00'))
assert jobs.recover_expired()==3
assert db.one('SELECT state FROM build_jobs WHERE id=?',('release818',))['state']=='failed'
assert db.one('SELECT state FROM build_jobs WHERE id=?',('roll818',))['state']=='failed'
assert db.one('SELECT state FROM build_jobs WHERE id=?',('build818',))['state']=='retrying'
assert 'reconcile' in db.one('SELECT error FROM build_jobs WHERE id=?',('release818',))['error']
def test_unknown_remote_outcome_is_never_retried(tmp_path,monkeypatch):
from app import worker
monkeypatch.setattr(db,'POSTGRES',False)
monkeypatch.setattr(db,'DATA',tmp_path)
monkeypatch.setattr(db,'DB_FILE',tmp_path/'siteforge.sqlite3')
db.init();enterprise.setup()
db.insert('INSERT INTO sites(id,name,created_at,updated_at) VALUES (?,?,?,?)',
('release-site','Sample',db.now(),db.now()))
for jid in ('release-retry','release-cancel'):
db.insert('''INSERT INTO build_jobs(id,site_id,kind,state,created_by,created_at,
attempts,max_attempts,worker_id,lease_until) VALUES (?,?,?,?,?,?,?,?,?,?)''',
(jid,'release-site','remote_deploy','running','system',db.now(),1,3,
worker.WORKER_ID,'2030-01-01T00:00:00+00:00'))
old=db.one('SELECT * FROM build_jobs WHERE id=?',('release-retry',))
worker.complete(old,error='network error',retryable=True)
assert db.one('SELECT state FROM build_jobs WHERE id=?',('release-retry',))['state']=='failed'
old=db.one('SELECT * FROM build_jobs WHERE id=?',('release-cancel',))
worker.complete(old,cancelled=True)
state=db.one('SELECT state,error FROM build_jobs WHERE id=?',('release-cancel',))
assert state['state']=='failed' and 'reconcile' in state['error']
+78
View File
@@ -0,0 +1,78 @@
"""Offline cross-engine transfer planning and all-or-nothing checks using a
SQLite-backed PG contract fixture. Actual PostgreSQL is tested only in CI."""
import sqlite3
import pytest
from pathlib import Path
from app.sqlite_pg_transfer import migrate, plan_tables, quote
from app.pg_disaster_recovery import RecoveryError
class PgCompatibleSQLite:
def __init__(self, path):
self.path=path
self.conn=None
def __enter__(self):
self.conn=sqlite3.connect(self.path)
self.conn.row_factory=sqlite3.Row
self.conn.execute('PRAGMA foreign_keys=ON')
return self
def __exit__(self,typ,val,tb):
if typ:self.conn.rollback()
else:self.conn.commit()
self.conn.close()
def execute(self,sql,params=()):
if 'information_schema.columns' in sql:
return self.conn.execute('SELECT name AS column_name FROM pragma_table_info(?)', params)
return self.conn.execute(sql, params)
def fixture_data(src,dest):
schema='''CREATE TABLE sites(id TEXT PRIMARY KEY,name TEXT);
CREATE TABLE entries(id TEXT PRIMARY KEY,site_id TEXT REFERENCES sites(id),title TEXT);
CREATE TABLE schema_migrations(version TEXT PRIMARY KEY,checksum TEXT);'''
for path in (src,dest):
with sqlite3.connect(path) as conn:conn.executescript(schema)
with sqlite3.connect(src) as conn:
conn.execute('INSERT INTO sites VALUES (?,?)',('a','中文站'))
conn.executemany('INSERT INTO entries VALUES (?,?,?)', [('1','a','Rox 01'),('2','a','BAO 5')])
conn.execute('INSERT INTO schema_migrations VALUES (?,?)',('v1','abc'))
with sqlite3.connect(dest) as conn:conn.execute('INSERT INTO schema_migrations VALUES (?,?)',('v1','abc'))
def test_dependent_tables_transferred_with_hashes(tmp_path):
src=tmp_path/'src.db';dest=tmp_path/'dest.db';fixture_data(src,dest)
with sqlite3.connect(src) as conn:
assert plan_tables(conn).index('sites')<plan_tables(conn).index('entries')
report=migrate(src,confirmed_offline=True,connect=lambda:PgCompatibleSQLite(dest),bootstrap=False,batch=1)
assert report['verified'] and report['tables']==2 and report['records']==3
with sqlite3.connect(dest) as conn:
assert conn.execute('SELECT title FROM entries WHERE id="2"').fetchone()[0]=='BAO 5'
assert 'sha256' in report['details']['entries']
def test_target_nonempty_refuses_all_changes(tmp_path):
src=tmp_path/'src.db';dest=tmp_path/'dest.db';fixture_data(src,dest)
with sqlite3.connect(dest) as conn:conn.execute('INSERT INTO sites VALUES (?,?)',('existing','keep'))
with pytest.raises(RecoveryError,match='not empty'):
migrate(src,confirmed_offline=True,connect=lambda:PgCompatibleSQLite(dest),bootstrap=False)
with sqlite3.connect(dest) as conn:
assert conn.execute('SELECT COUNT(*) FROM entries').fetchone()[0]==0
assert conn.execute('SELECT name FROM sites').fetchone()[0]=='keep'
def test_migration_guard_and_schema_mismatch(tmp_path):
src=tmp_path/'src.db';dest=tmp_path/'dest.db';fixture_data(src,dest)
with pytest.raises(RecoveryError,match='confirmed-offline'):
migrate(src,connect=lambda:PgCompatibleSQLite(dest),bootstrap=False)
with sqlite3.connect(dest) as conn:
conn.execute('DELETE FROM schema_migrations')
conn.execute('INSERT INTO schema_migrations VALUES (?,?)',('v1','wrong-checksum'))
with pytest.raises(RecoveryError,match='ledger'):
migrate(src,confirmed_offline=True,connect=lambda:PgCompatibleSQLite(dest),bootstrap=False)
def test_identifiers_are_validated(tmp_path):
with pytest.raises(RecoveryError):quote('entries; DROP TABLE sites')
conn=sqlite3.connect(':memory:')
with pytest.raises(RecoveryError,match='no sites'):
plan_tables(conn)
@@ -0,0 +1,112 @@
"""V8.21 integration tests: persisted design history + deterministic safe import plans."""
import json
from tests.test_factory import client,create
from app import db,blueprints
def _home(c,site):
return next(e for e in c.get(f'/api/sites/{site}/entries').json() if e['route']=='/')
def test_history_restores_as_draft_without_mutating_published_page(client):
site=create(client); entry=_home(client,site); eid=entry['id']
draft=f'/api/sites/{site}/pages/{eid}/studio-draft'
history=f'/api/sites/{site}/pages/{eid}/studio-history'
initial=db.one('SELECT meta_json FROM entries WHERE id=?',(eid,))['meta_json']
one={'blocks':[blueprints.new_block('cta',{'title':'FIRST_HISTORY'})],
'page_template':'home','layout_mode':'replace','expected_revision':0}
two={'blocks':[blueprints.new_block('cta',{'title':'SECOND_HISTORY'})],
'page_template':'home','layout_mode':'replace','expected_revision':1}
assert client.put(draft,json=one).json()['revision']==1
assert client.put(draft,json=two).json()['revision']==2
list_resp=client.get(history).json()['items']
assert len(list_resp)==2 and set(x['revision'] for x in list_resp)=={1,2}
first_id=next(x['id'] for x in list_resp if x['revision']==1)
item=client.get(history+'/'+first_id).json()
assert item['plan']['blocks'][0]['title']=='FIRST_HISTORY'
assert len(item['snapshot_hash'])==64
assert db.one('SELECT meta_json FROM entries WHERE id=?',(eid,))['meta_json']==initial
stale=client.post(history+'/'+first_id+'/restore',json={'expected_revision':1})
assert stale.status_code==409
restored=client.post(history+'/'+first_id+'/restore',json={'expected_revision':2})
assert restored.status_code==200 and restored.json()['revision']==3
assert restored.json()['requires_publish']
assert client.get(draft).json()['blocks'][0]['title']=='FIRST_HISTORY'
assert db.one('SELECT meta_json FROM entries WHERE id=?',(eid,))['meta_json']==initial
published=client.post(draft+'/publish',json={'expected_revision':3})
assert published.status_code==200
assert 'FIRST_HISTORY' in db.one('SELECT meta_json FROM entries WHERE id=?',(eid,))['meta_json']
assert len(client.get(history).json()['items'])==4
assert any(x['kind']=='published' for x in client.get(history).json()['items'])
def test_history_site_isolation_and_bad_limit(client):
a=create(client);b=create(client);eid=_home(client,a)['id']
draft=f'/api/sites/{a}/pages/{eid}/studio-draft'
h=f'/api/sites/{a}/pages/{eid}/studio-history'
assert client.put(draft,json={'blocks':[],'page_template':'standard','expected_revision':0}).status_code==200
hid=client.get(h).json()['items'][0]['id']
assert client.get(f'/api/sites/{b}/pages/{eid}/studio-history').status_code==404
assert client.get(f'/api/sites/{b}/pages/{eid}/studio-history/{hid}').status_code==404
assert client.get(h+'?limit=101').status_code==400
assert client.get(h+'/not-found').status_code==404
def test_mapping_transforms_and_stale_preview_are_blocked(client):
site=create(client);url=f'/api/factory/sites/{site}/mapping/run'
rows=[{'name':' z9 pickup ','sku':'a-01','brand':' NISSAN '}]
rules={'title':{'source':'name','case':'upper','prefix':'MODEL: '},
'sku':{'source':'sku','case':'upper'},
'brand':{'source':'brand','case':'lower'}}
payload={'rows':rows,'rules':rules,'content_type':'product'}
preview=client.post(url,json={**payload,'dry_run':True})
assert preview.status_code==200,preview.text
plan=preview.json(); assert plan['sample'][0]['title']=='MODEL: Z9 PICKUP'
assert plan['sample'][0]['meta']['fields']['sku']=='A-01'
assert plan['sample'][0]['meta']['fields']['brand']=='nissan'
assert len(plan['plan_hash'])==64
bad=client.post(url,json={**payload,'dry_run':False,'expected_plan_hash':'f'*64})
assert bad.status_code==400
accepted=client.post(url,json={**payload,'dry_run':False,'on_conflict':'abort','expected_plan_hash':plan['plan_hash']})
assert accepted.status_code==200,accepted.text
assert accepted.json()['created']==1
reread=client.post(url,json={**payload,'dry_run':False,'on_conflict':'abort','expected_plan_hash':plan['plan_hash']})
assert reread.status_code==400
assert len([x for x in client.get(f'/api/sites/{site}/entries').json() if x['title']=='MODEL: Z9 PICKUP'])==1
def test_mapping_abort_is_noop_when_existing_or_invalid(client):
site=create(client);url=f'/api/factory/sites/{site}/mapping/run'
rows=[{'name':'A','route':'/my-product/'},{'name':'B','route':'/my-product/'}]
p={'rows':rows,'rules':{'title':'name','route':'route'},'dry_run':True}
preview=client.post(url,json=p).json()
assert preview['errors']
result=client.post(url,json={**p,'dry_run':False,'on_conflict':'abort','expected_plan_hash':preview['plan_hash']})
assert result.status_code==400
assert db.one('SELECT id FROM entries WHERE site_id=? AND route=?',(site,'/my-product/')) is None
for config in ({'source':'name','case':'swapcase'},{'source':'name','extra':'injection'},{'source':'x'}):
ret=client.post(url,json={**p,'rules':{'title':config}})
assert ret.status_code==400
assert client.post(url,json={**p,'rules':{'title':{'source':'name','prefix':'x'*501}}}).status_code==400
def test_mapping_frontend_and_history_controls_are_available(client):
sid=create(client); eid=_home(client,sid)['id']
editor=client.get(f'/editor/{sid}/{eid}').text
assert 'studioHistoryModal' in editor and '版本历史' in editor
js=client.get('/ui/editor.js').text
assert 'restoreHistory(' in js and '/studio-history' in js
mapping_js=client.get('/ui/factory-v2.js').text
assert 'expected_plan_hash' in mapping_js and 'mapConflict' in mapping_js
def test_history_snapshot_checksum_prevents_tampered_restoration(client):
site=create(client); eid=_home(client,site)['id']
endpoint=f'/api/sites/{site}/pages/{eid}/studio-draft'
client.put(endpoint,json={'blocks':[],'page_template':'home','expected_revision':0})
path=f'/api/sites/{site}/pages/{eid}/studio-history'
record=client.get(path).json()['items'][0]
db.execute('UPDATE studio_history SET snapshot_json=? WHERE id=?',
('{"blocks":[],"page_template":"about","layout_mode":"replace"}',record['id']))
assert client.get(path+'/'+record['id']).status_code==409
assert client.post(path+'/'+record['id']+'/restore',json={'expected_revision':1}).status_code==409
+2 -1
View File
@@ -1,3 +1,4 @@
from app.version import VERSION
import json
import pytest
from fastapi import HTTPException
@@ -145,7 +146,7 @@ def test_v8_api_authenticated_and_browser_page(env):
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.12.0-dev.1'
assert client.get('/api/health').json()['version']==VERSION
def test_wxr_preserves_post_type(env):
+47
View File
@@ -0,0 +1,47 @@
import importlib.util
import tempfile
import unittest
from pathlib import Path
import sys
p = Path(__file__).resolve().parents[1] / 'scripts' / 'export_wordpress_url_baseline.py'
spec = importlib.util.spec_from_file_location('url_baseline', p)
a = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = a
spec.loader.exec_module(a)
class Tests(unittest.TestCase):
def test_legacy_url_route(self):
self.assertEqual(a.route_from_url('https://example.com/pickup-truck/', {'example.com'}), '/pickup-truck/')
def test_query_legacy_requires_manual_mapping(self):
self.assertIsNone(a.route_from_url('https://example.com/?p=22', {'example.com'}))
def test_foreign_host_excluded(self):
self.assertIsNone(a.route_from_url('https://bad.example.com/x/', {'example.com'}))
def test_wxr_only_published_not_attachments(self):
with tempfile.TemporaryDirectory() as d:
f = Path(d)/'wp.xml'
f.write_text('''<?xml version="1.0" encoding="UTF-8"?><rss xmlns:wp="http://wordpress.org/export/1.2/"><channel>
<item><link>https://example.com/p1/</link><wp:post_type>page</wp:post_type><wp:status>publish</wp:status></item>
<item><link>https://example.com/p2/</link><wp:post_type>page</wp:post_type><wp:status>draft</wp:status></item>
<item><link>https://example.com/upload/</link><wp:post_type>attachment</wp:post_type><wp:status>publish</wp:status></item>
</channel></rss>''')
out=Path(d)/'old.txt'
self.assertEqual(a.main([str(f),'--host','example.com','--output',str(out)]),0)
self.assertIn('/p1/',out.read_text())
self.assertNotIn('/p2/',out.read_text())
self.assertNotIn('/upload/',out.read_text())
def test_sitemap_support(self):
with tempfile.TemporaryDirectory() as d:
f=Path(d)/'s.xml'
f.write_text('<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"><url><loc>https://example.com/about/</loc></url></urlset>')
out=Path(d)/'urls.txt'
self.assertEqual(a.main([str(f),'--host','example.com','--output',str(out)]),0)
self.assertIn('/about/',out.read_text())
def test_dtd_rejected(self):
with tempfile.TemporaryDirectory() as d:
f=Path(d)/'s.xml'
f.write_text('<!DOCTYPE urlset [<!ENTITY hacked "HACK">]><urlset>&hacked;</urlset>')
self.assertNotEqual(a.main([str(f),'--host','example.com','--output',str(Path(d)/'urls.txt')]),0)
if __name__=='__main__': unittest.main()
+2 -1
View File
@@ -1,4 +1,5 @@
"""V8.12 workbench usability, security boundaries and backend compatibility."""
from app.version import VERSION
from test_factory import client
import subprocess
from pathlib import Path
@@ -64,7 +65,7 @@ def test_live_job_endpoint_can_list_user_site_jobs(client):
def test_version_is_updated(client):
assert client.get('/api/health').json()['version']=='8.12.0-dev.1'
assert client.get('/api/health').json()['version']==VERSION
def test_js_standalone_syntax():
+124
View File
@@ -0,0 +1,124 @@
"""V8.20 offline CMS compatibility tests, independent of the public network."""
import io
import json
import pytest
from app import wordpress_compat
from app.wp_complex_v820 import inspect_wxr, extract_acf
from app.wp_migration_v82 import _media_urls, _rewrite_media_html
from test_factory_v2 import factory,site
def wxr(records):
return ('''<?xml version="1.0"?><rss version="2.0" xmlns:wp="http://wordpress.org/export/1.2/" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel>'''+records+'</channel></rss>').encode()
def entry(wp_id,link,custom='',body='',kind='page'):
return f'''<item><title>Page {wp_id}</title><link>{link.replace('&','&amp;')}</link>
<wp:post_type>{kind}</wp:post_type><wp:post_id>{wp_id}</wp:post_id>
<wp:status>publish</wp:status><content:encoded><![CDATA[{body}]]></content:encoded>{custom}</item>'''
def meta(key,value):
return f'<wp:postmeta><wp:meta_key>{key}</wp:meta_key><wp:meta_value><![CDATA[{value}]]></wp:meta_value></wp:postmeta>'
def test_wxr_duplicate_route_and_dynamic_query_blocked():
data=wxr(entry(1,'https://old.example/products/a/')+entry(2,'https://old.example/products/a/')+entry(3,'https://old.example/?p=5'))
out=inspect_wxr(data)
assert out['unique_published_routes']==1
assert out['blocking_issues']==2
assert {x['code'] for x in out['issues']}=={'duplicate_route','invalid_permalink'}
assert out['ready_for_automatic_publish'] is False
def test_wxr_counts_attachments_and_custom_types():
data=wxr(entry(1,'https://old.example/a/',kind='attachment')+entry(2,'https://old.example/b/',kind='portfolio'))
out=inspect_wxr(data)
assert out['counts']['attachments']==1
assert out['features']['custom_post_types']==1
assert out['sample_routes'][0]['route']=='/b/'
def test_wxr_dynamic_shortcode_not_claimed_as_static():
data=wxr(entry(1,'https://old.example/contact/',body='[contact-form-7 id="4"]'))
out=inspect_wxr(data)
assert out['blocking_issues']==1
assert out['issues'][0]['code']=='dynamic_shortcode'
def test_acf_simple_repeater_explicit_field_references():
values={'_features':'field_parent','features':'2','features_0_title':'First','_features_0_title':'field_child',
'features_1_title':'Second','_features_1_title':'field_child','headline':'About','_headline':'field_header'}
parsed=extract_acf(values)
assert parsed['repeaters']['features']==[{'title':'First'},{'title':'Second'}]
assert parsed['fields']['headline']=='About'
assert any('核对' in s for s in parsed['warnings'])
def test_acf_serialized_does_not_execute_or_decode():
parsed=extract_acf({'_links':'field_test','links':'a:1:{i:0;s:4:"evil";}'})
assert 'links' not in parsed['fields']
assert any('序列化' in s for s in parsed['warnings'])
def test_acf_incomplete_repeater_kept_raw_only():
meta_values={'_list':'field_list','list':'2','_list_0_title':'field_title','list_0_title':'First'}
parsed=extract_acf(meta_values)
assert 'list' not in parsed['repeaters']
assert any('不连续' in x for x in parsed['warnings'])
def test_elementor_unknown_widget_reported():
data=json.dumps([{'elType':'widget','widgetType':'unknown_gallery','settings':{}},
{'elType':'widget','widgetType':'heading','settings':{'title':'A & B'}}])
out=wordpress_compat.elementor_to_html(data)
assert out['widgets']==2
assert 'A &amp; B' in out['html']
assert any('unknown_gallery' in x for x in out['warnings'])
def test_css_remote_image_inventory_and_rewrite():
source={'body':'''<div style="background-image: url('https://old.example/bg.webp')"><img data-background="https://old.example/lazy.png"></div>'''}
assert _media_urls(source)==['https://old.example/bg.webp','https://old.example/lazy.png']
converted={'https://old.example/bg.webp':'/media/a.webp','https://old.example/lazy.png':'/media/b.png'}
result,changed=_rewrite_media_html(source['body'],converted)
assert changed and '/media/a.webp' in result and '/media/b.png' in result
assert not _media_urls({'body':result})
def test_xxe_and_invalid_input_are_rejected():
with pytest.raises(ValueError):inspect_wxr(b'')
with pytest.raises(Exception):inspect_wxr(b'<!DOCTYPE rss [<!ENTITY x SYSTEM "file:///etc/passwd">]><rss>&x;</rss>')
with pytest.raises(ValueError):inspect_wxr(b'<urlset/>')
def test_wxr_api_enforces_auth_and_csrf(factory):
sid=site(factory,mode='migration')
payload=wxr(entry(1,'https://old.example/about/'))
response=factory.post(f'/api/migrations/sites/{sid}/wordpress/wxr-preflight',files={'file':('site.xml',payload)})
assert response.status_code==200,response.text
assert response.json()['unique_published_routes']==1
assert response.json()['ready_for_automatic_publish'] is False
bad=factory.post(f'/api/migrations/sites/{sid}/wordpress/wxr-preflight',files={'file':('site.xml',b'<urlset/>')})
assert bad.status_code==400
def test_import_wxr_rejects_dynamic_path_before_writing(factory):
sid=site(factory,mode='migration')
payload=wxr(entry(1,'https://old.example/?p=13'))
response=factory.post(f'/api/sites/{sid}/import',files={'file':('site.xml',payload)})
assert response.status_code in (400,422)
assert not any(x['route']=='/' and x['title']=='Page 1' for x in factory.get(f'/api/sites/{sid}/entries').json())
def test_wxr_acf_repeater_survives_import(factory):
sid=site(factory,mode='migration')
custom=''.join(meta(k,v) for k,v in {'_features':'field_parent','features':'2',
'_features_0_name':'field_child','features_0_name':'SUV',
'_features_1_name':'field_child','features_1_name':'Pickup'}.items())
payload=wxr(entry(1,'https://old.example/products/a/',custom=custom))
response=factory.post(f'/api/sites/{sid}/import',files={'file':('site.xml',payload)})
assert response.status_code==200,response.text
item=next(x for x in factory.get(f'/api/sites/{sid}/entries').json() if x['route']=='/products/a/')
assert item['meta']['fields']['features']==[{'name':'SUV'},{'name':'Pickup'}]
assert item['meta']['acf_repeaters']=={'features':[{'name':'SUV'},{'name':'Pickup'}]}