601451f22b
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>
197 lines
10 KiB
Python
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
|