Files

187 lines
10 KiB
Python
Raw Permalink Normal View History

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