135 lines
6.9 KiB
Python
135 lines
6.9 KiB
Python
|
|
"""Deterministic P0/P2 regression fixtures; no outside network or Docker dependency."""
|
||
|
|
import io
|
||
|
|
import json
|
||
|
|
import sqlite3
|
||
|
|
import zipfile
|
||
|
|
from pathlib import Path
|
||
|
|
import pytest
|
||
|
|
from app import db, backup, wp_migration_v82 as media
|
||
|
|
from scripts.restore_backup import restore
|
||
|
|
|
||
|
|
|
||
|
|
def test_responsive_media_preserves_sizes_and_catches_cdn():
|
||
|
|
original={
|
||
|
|
'cover':'https://example.org/cover.jpg',
|
||
|
|
'body':'''<picture><source srcset="https://example.org/small.jpg 360w, https://example.org/big.jpg 1440w">
|
||
|
|
<img src="https://example.org/small.jpg" data-src="https://example.org/full.jpg"
|
||
|
|
srcset="https://example.org/small.jpg 360w, https://example.org/big.jpg 1440w"
|
||
|
|
data-srcset="https://cdn.example.net/cdn.jpg 2x"></picture>'''
|
||
|
|
}
|
||
|
|
targets=media._media_urls(original)
|
||
|
|
assert len(targets)==5 and 'https://example.org/big.jpg' in targets
|
||
|
|
assert 'https://cdn.example.net/cdn.jpg' in targets
|
||
|
|
converted={x:'/media/'+x.rsplit('/',1)[-1] for x in targets if 'example.org/' in x}
|
||
|
|
out,changed=media._rewrite_media_html(original['body'],converted)
|
||
|
|
assert changed
|
||
|
|
assert '/media/big.jpg 1440w' in out
|
||
|
|
assert '/media/small.jpg 360w' in out
|
||
|
|
assert 'data-src="/media/full.jpg"' in out
|
||
|
|
assert 'https://cdn.example.net/cdn.jpg 2x' in out
|
||
|
|
assert media._media_urls({'body':out})==['https://cdn.example.net/cdn.jpg']
|
||
|
|
|
||
|
|
|
||
|
|
def test_restore_tampered_archive_cannot_change_existing_data(tmp_path):
|
||
|
|
target=tmp_path/'restore';target.mkdir()
|
||
|
|
(target/'old.txt').write_text('must survive')
|
||
|
|
for filename in ('../outside.txt','evil.txt'):
|
||
|
|
archive=tmp_path/(filename.replace('/','_')+'.zip')
|
||
|
|
with zipfile.ZipFile(archive,'w') as z:
|
||
|
|
z.writestr('siteforge.sqlite3',b'not a database')
|
||
|
|
z.writestr(filename,'evil')
|
||
|
|
with pytest.raises(ValueError):restore(archive,target,confirmed=True)
|
||
|
|
assert (target/'old.txt').read_text()=='must survive'
|
||
|
|
assert not (tmp_path/'outside.txt').exists()
|
||
|
|
|
||
|
|
|
||
|
|
def test_restore_replaces_directory_atomically_and_preserves_previous(tmp_path,monkeypatch):
|
||
|
|
source=tmp_path/'original';source.mkdir()
|
||
|
|
monkeypatch.setattr(db,'DATA',source)
|
||
|
|
monkeypatch.setattr(db,'DB_FILE',source/'siteforge.sqlite3')
|
||
|
|
db.init()
|
||
|
|
db.insert('INSERT INTO sites(id,name,created_at,updated_at) VALUES(?,?,?,?)',
|
||
|
|
('testsite','Restore Success',db.now(),db.now()))
|
||
|
|
(source/'media').mkdir();(source/'media'/'image.png').write_bytes(b'img')
|
||
|
|
archive=backup.backup()
|
||
|
|
target=tmp_path/'production';target.mkdir()
|
||
|
|
(target/'old-sentinel').write_text('old installation')
|
||
|
|
out=restore(archive,target,confirmed=True)
|
||
|
|
assert (target/'media'/'image.png').read_bytes()==b'img'
|
||
|
|
assert not (target/'old-sentinel').exists()
|
||
|
|
assert out['previous'] and (Path(out['previous'])/'old-sentinel').read_text()=='old installation'
|
||
|
|
with sqlite3.connect(target/'siteforge.sqlite3') as conn:
|
||
|
|
assert conn.execute('SELECT name FROM sites WHERE id=?',('testsite',)).fetchone()[0]=='Restore Success'
|
||
|
|
|
||
|
|
|
||
|
|
def test_remote_only_media_candidate_reported():
|
||
|
|
assert media._media_urls({'body':'<img src="/media/local.png" srcset="https://cdn.example.net/a.png 720w">'})==['https://cdn.example.net/a.png']
|
||
|
|
|
||
|
|
|
||
|
|
def test_build_quality_reports_missing_local_assets_and_blocks_traversal(tmp_path,monkeypatch):
|
||
|
|
from app import quality
|
||
|
|
monkeypatch.setattr(db,'DATA',tmp_path)
|
||
|
|
monkeypatch.setattr(db,'DB_FILE',tmp_path/'db.sqlite3')
|
||
|
|
db.init()
|
||
|
|
sid='p0p4qa';now=db.now()
|
||
|
|
db.insert('INSERT INTO sites(id,name,domain,created_at,updated_at) VALUES(?,?,?,?,?)',
|
||
|
|
(sid,'QA','https://example.org',now,now))
|
||
|
|
dist=tmp_path/'builds'/sid/'sample'/'dist'
|
||
|
|
dist.mkdir(parents=True)
|
||
|
|
(dist/'index.html').write_text('''<html><head><title>Home</title><meta name="description" content="A Home"></head>
|
||
|
|
<body><h1>Welcome</h1><a href="/missing/">Missing</a>
|
||
|
|
<img alt="OK" src="/assets/not-there.png"><a href="/../secrets">Bad</a></body></html>''')
|
||
|
|
audit=quality.report(sid,'sample')
|
||
|
|
assert audit['files']==1 and audit['errors']==3
|
||
|
|
assert {x['type'] for x in audit['issues']}=={'broken_internal_reference'}
|
||
|
|
(dist/'assets').mkdir();(dist/'assets'/'not-there.png').write_bytes(b'demo')
|
||
|
|
(dist/'missing').mkdir();(dist/'missing'/'index.html').write_text('<title>Missing</title><h1>There</h1>')
|
||
|
|
audit=quality.report(sid,'sample')
|
||
|
|
assert audit['errors']==1 # the traversal never resolves to an outside file
|
||
|
|
|
||
|
|
|
||
|
|
def test_catalogue_prepared_context_reuses_groups_without_mutating_records():
|
||
|
|
from app import theme_pro
|
||
|
|
rows=[{'type':'vehicle','title':f'Model {i}','route':f'/vehicle/{i}/',
|
||
|
|
'meta_json':json.dumps({'fields':{'brand':'Acme','category':'SUV'}})} for i in range(30)]
|
||
|
|
prepared=theme_pro.prepare(rows)
|
||
|
|
assert prepared['brands']==[{'name':'Acme','url':'/brands/acme/'}]
|
||
|
|
assert prepared['categories']==[{'name':'SUV','url':'/categories/suv/'}]
|
||
|
|
site={'name':'Demo','theme':'automotive','domain':'','settings_json':'{}','tagline':''}
|
||
|
|
ctx=theme_pro.make_context(site,rows[0],rows,prepared=prepared)
|
||
|
|
assert ctx['kind']=='product' and ctx['listing']==[]
|
||
|
|
assert ctx['groups'] is prepared['groups']
|
||
|
|
assert rows[0]['title']=='Model 0'
|
||
|
|
|
||
|
|
|
||
|
|
def test_static_pagination_keeps_every_product_reachable(tmp_path,monkeypatch):
|
||
|
|
from app import builder,quality,v8_workflows as wf
|
||
|
|
from app.main import app
|
||
|
|
from app.core import uid
|
||
|
|
from fastapi.testclient import TestClient
|
||
|
|
monkeypatch.setattr(db,'DATA',tmp_path)
|
||
|
|
monkeypatch.setattr(db,'DB_FILE',tmp_path/'pagination.sqlite3')
|
||
|
|
monkeypatch.setenv('SITEFORGE_ADMIN_PASSWORD','pagination-demo-only-password-long')
|
||
|
|
monkeypatch.setenv('SITEFORGE_SESSION_SECRET','pagination-demo-only-secret-long-enough-1234')
|
||
|
|
with TestClient(app):
|
||
|
|
sid=uid();now=db.now()
|
||
|
|
db.insert('INSERT INTO sites(id,name,theme,created_at,updated_at) VALUES(?,?,?,?,?)',
|
||
|
|
(sid,'Pagination Pilot','automotive',now,now))
|
||
|
|
entries=[{'车型名称':f'Product {i:04d}','品牌':'Maker','分类':'SUV'} for i in range(235)]
|
||
|
|
b=wf.stage(sid,entries,kind='vehicle')
|
||
|
|
assert wf.commit(sid,b['batch_id'])['created']==235
|
||
|
|
result=builder.build(sid,False)
|
||
|
|
dist=tmp_path/'builds'/sid/result['version']/'dist'
|
||
|
|
first=(dist/'categories/suv/index.html').read_text()
|
||
|
|
second=(dist/'categories/suv/page/2/index.html').read_text()
|
||
|
|
third=(dist/'categories/suv/page/3/index.html').read_text()
|
||
|
|
assert '/categories/suv/page/2/' in first
|
||
|
|
assert '/categories/suv/page/3/' in second
|
||
|
|
assert '/categories/suv/page/2/' in third
|
||
|
|
assert first.count('pro-card pro-card-catalog')==100
|
||
|
|
assert second.count('pro-card pro-card-catalog')==100
|
||
|
|
assert third.count('pro-card pro-card-catalog')==35
|
||
|
|
assert (dist/'vehicle/page/2/index.html').is_file()
|
||
|
|
assert (dist/'products/page/2/index.html').is_file()
|
||
|
|
assert quality.report(sid,result['version'])['errors']==0
|