Files
cms-SiteForge/tests/test_release_v813.py
T
浅念 601451f22b 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>
2026-10-23 12:31:03 +08:00

197 lines
10 KiB
Python

"""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