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:
@@ -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()
|
||||
@@ -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')
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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'}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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('<script>', 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()
|
||||
@@ -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):
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -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']
|
||||
@@ -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
|
||||
@@ -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):
|
||||
|
||||
@@ -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()
|
||||
@@ -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():
|
||||
|
||||
@@ -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('&','&')}</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 & 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'}]}
|
||||
Reference in New Issue
Block a user