3c57c167f2
导入 SiteForge Factory V5 基线源码: - FastAPI + SQLite + Docker Compose(Web + Worker)架构 - 四套商业主题 × 14 页面模板(1.1.0,含历史版本快照) - 四种建站模式骨架 + 迁移审计 + 发布门禁 - 52 项 pytest 测试 - 补全计划主文档 docs/superpowers/plans/ Co-Authored-By: Claude Code <noreply@anthropic.com>
135 lines
7.5 KiB
Python
135 lines
7.5 KiB
Python
import json
|
|
import os
|
|
from fastapi.testclient import TestClient
|
|
import pytest
|
|
from app import db, enterprise, worker
|
|
from app.main import app
|
|
from app.worker import run_once
|
|
|
|
@pytest.fixture
|
|
def root(tmp_path,monkeypatch):
|
|
monkeypatch.setattr(db,'DATA',tmp_path)
|
|
monkeypatch.setattr(db,'DB_FILE',tmp_path/'app.sqlite3')
|
|
monkeypatch.setenv('SITEFORGE_ADMIN_PASSWORD','administrator-strong-password-2026')
|
|
monkeypatch.setenv('SITEFORGE_SESSION_SECRET','secure-random-test-key-with-long-length-1234')
|
|
monkeypatch.setenv('SITEFORGE_PUBLISH_ROOT',str(tmp_path/'published'))
|
|
with TestClient(app) as c:
|
|
assert c.post('/api/auth/login',json={'username':'admin','password':'administrator-strong-password-2026'}).status_code==200
|
|
csrf=c.get('/api/auth/me').json()['csrf']
|
|
c.headers.update({'X-SiteForge-CSRF':csrf})
|
|
yield c
|
|
|
|
def site(c,name):
|
|
r=c.post('/api/sites',json={'name':name,'theme':'corporate','mode':'migration','domain':'https://example.org'})
|
|
assert r.status_code==201,r.text
|
|
return r.json()['id']
|
|
|
|
def test_team_rbac_isolation(root):
|
|
one=site(root,'Company A')
|
|
two=site(root,'Company B')
|
|
response=root.post('/api/enterprise/team/users',json={'username':'editor1','password':'long-editor-password-2026','display_name':'Editor'})
|
|
assert response.status_code==201,response.text
|
|
uid=response.json()['id']
|
|
r=root.put(f'/api/enterprise/sites/{one}/members',json={'user_id':uid,'role':'editor'})
|
|
assert r.status_code==200,r.text
|
|
root.post('/api/auth/logout')
|
|
assert root.post('/api/auth/login',json={'username':'editor1','password':'long-editor-password-2026'}).status_code==200
|
|
root.headers.update({'X-SiteForge-CSRF':root.get('/api/auth/me').json()['csrf']})
|
|
visible=root.get('/api/sites').json()
|
|
assert [x['id'] for x in visible]==[one]
|
|
assert root.get(f'/api/sites/{two}').status_code==403
|
|
assert root.post(f'/api/sites/{two}/entries',json={'title':'Blocked'}).status_code==403
|
|
assert root.post('/api/sites',json={'name':'Blocked'}).status_code==403
|
|
assert root.post(f'/api/sites/{one}/entries',json={'title':'Allowed','type':'page','route':'/allowed/'}).status_code==201
|
|
assert root.post(f'/api/sites/{one}/builds/invalid/publish-local').status_code==403
|
|
assert root.get('/api/enterprise/team/users').status_code==403
|
|
assert root.get(f'/api/enterprise/sites/{one}/baseline/report').status_code==200
|
|
|
|
|
|
def test_baseline_and_release_gate(root):
|
|
sid=site(root,'Legacy')
|
|
x=root.post(f'/api/sites/{sid}/entries',json={'type':'page','title':'Original','route':'/legacy/a.html','seo_title':'Old Title'})
|
|
assert x.status_code==201
|
|
assert root.post(f'/api/enterprise/sites/{sid}/baseline/snapshot').json()['captured']==2
|
|
e=x.json()['id']
|
|
assert root.delete(f'/api/sites/{sid}/entries/{e}').status_code==200
|
|
report=root.get(f'/api/enterprise/sites/{sid}/baseline/report').json()
|
|
assert report['errors']==1
|
|
build=root.post(f'/api/sites/{sid}/builds',json={})
|
|
assert build.status_code==200,build.text
|
|
bid=build.json()['version']
|
|
publish=root.post(f'/api/sites/{sid}/builds/{bid}/publish-local')
|
|
assert publish.status_code==409
|
|
assert root.post(f'/api/sites/{sid}/entries',json={'type':'page','title':'Replaced','route':'/legacy/a.html','seo_title':'Old Title'}).status_code==201
|
|
assert root.get(f'/api/enterprise/sites/{sid}/baseline/report').json()['errors']==0
|
|
rebuilt=root.post(f'/api/sites/{sid}/builds',json={}).json()['version']
|
|
publish=root.post(f'/api/sites/{sid}/builds/{rebuilt}/publish-local')
|
|
assert publish.status_code==200,publish.text
|
|
qa=root.get(f'/api/enterprise/sites/{sid}/builds/{rebuilt}/quality')
|
|
assert qa.status_code==200,qa.text
|
|
assert qa.json()['files']>=2
|
|
history=root.get(f'/api/enterprise/sites/{sid}/releases').json()
|
|
assert history and history[0]['build_id']==rebuilt
|
|
rollback=root.post(f'/api/enterprise/sites/{sid}/releases/{rebuilt}/rollback')
|
|
assert rollback.status_code==200,rollback.text
|
|
|
|
|
|
def test_revision_restore_and_design_tokens(root):
|
|
sid=site(root,'Design')
|
|
x=root.post(f'/api/sites/{sid}/entries',json={'type':'page','title':'Draft v1','route':'/old-page/'})
|
|
entry=x.json()['id']
|
|
assert root.patch(f'/api/sites/{sid}/entries/{entry}',json={'title':'Draft v2'}).status_code==200
|
|
revs=root.get(f'/api/enterprise/sites/{sid}/entries/{entry}/revisions').json()
|
|
assert len(revs)>=1
|
|
old=root.get(f'/api/enterprise/sites/{sid}/revisions/{revs[0]["id"]}').json()
|
|
assert old['entry']['title']=='Draft v1'
|
|
assert root.post(f'/api/enterprise/sites/{sid}/revisions/{revs[0]["id"]}/restore').status_code==200
|
|
refreshed=root.get(f'/api/sites/{sid}/entries').json()
|
|
assert any(i['title']=='Draft v1' for i in refreshed)
|
|
invalid=root.put(f'/api/enterprise/sites/{sid}/design-tokens',json={'font':'system','radius':999})
|
|
assert invalid.status_code==400
|
|
payload={'font':'serif','heading_scale':1.2,'radius':18,'section_spacing':80,'container_width':1360,'button_style':'pill','header_style':'compact'}
|
|
assert root.put(f'/api/enterprise/sites/{sid}/design-tokens',json=payload).status_code==200
|
|
assert root.get(f'/api/enterprise/sites/{sid}/design-tokens').json()==payload
|
|
build=root.post(f'/api/sites/{sid}/builds',json={}).json()['version']
|
|
import pathlib
|
|
css=(db.DATA/'builds'/sid/build/'dist'/'assets'/'custom.css').read_text(encoding='utf-8')
|
|
assert 'Georgia' in css and '--sf-width:1360px' in css
|
|
|
|
|
|
def test_background_worker_and_audit(root):
|
|
sid=site(root,'Jobs')
|
|
res=root.post(f'/api/enterprise/sites/{sid}/jobs',json={'kind':'build'})
|
|
assert res.status_code==202,res.text
|
|
assert run_once()==res.json()['id']
|
|
jobs=root.get(f'/api/enterprise/sites/{sid}/jobs').json()
|
|
assert jobs[0]['state']=='done'
|
|
audits=root.get('/api/enterprise/audit').json()
|
|
assert any(x['operation']=='POST' for x in audits)
|
|
assert root.get('/ui/enterprise-ui.js').status_code==200
|
|
|
|
|
|
def test_background_worker_retries_transient_failure(root,monkeypatch):
|
|
sid=site(root,'Retry Jobs')
|
|
job=root.post(f'/api/enterprise/sites/{sid}/jobs',json={'kind':'build'}).json()['id']
|
|
monkeypatch.setenv('SITEFORGE_JOB_MAX_ATTEMPTS','3')
|
|
monkeypatch.setattr(worker.builder,'build',lambda *_: (_ for _ in ()).throw(RuntimeError('temporary failure')))
|
|
assert worker.run_once()==job
|
|
queued=db.one('SELECT state,attempts,error,retry_at FROM build_jobs WHERE id=?',(job,))
|
|
assert queued['state']=='queued' and queued['attempts']==1
|
|
assert queued['retry_at'] and 'temporary failure' in queued['error']
|
|
|
|
def test_viewer_cannot_modify_or_see_other_site(root):
|
|
own=site(root,'Site One'); foreign=site(root,'Site Two')
|
|
user=root.post('/api/enterprise/team/users',json={'username':'viewer22','display_name':'Viewer','password':'long-viewer-password-2026'}).json()['id']
|
|
assert root.put(f'/api/enterprise/sites/{own}/members',json={'user_id':user,'role':'viewer'}).status_code==200
|
|
root.post('/api/auth/logout')
|
|
root.post('/api/auth/login',json={'username':'viewer22','password':'long-viewer-password-2026'})
|
|
root.headers['X-SiteForge-CSRF']=root.get('/api/auth/me').json()['csrf']
|
|
assert root.get(f'/api/sites/{own}/entries').status_code==200
|
|
assert root.get(f'/preview/{foreign}/any/').status_code==403
|
|
assert root.post(f'/api/sites/{own}/builds',json={}).status_code==403
|
|
assert root.put(f'/api/enterprise/sites/{own}/design-tokens',json={}).status_code==403
|
|
assert root.get(f'/api/enterprise/sites/{own}/jobs').status_code==200
|
|
assert root.get(f'/api/enterprise/sites/{foreign}/jobs').status_code==403
|