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

Demo

'): b=io.BytesIO() with zipfile.ZipFile(b,'w') as z: z.writestr('index.html',html) z.writestr('404.html','

Not found

') z.writestr('products/index.html','

Products

') 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