feat: publish Dever core with project scaffolding and versioned AI skills

This commit is contained in:
Antigravity committed 2026-10-04 13:37:30 +00:00
1 parent 70fa73cdc2
commit ac458ffa7f
960 files changed
+221319 -1163

No files matched your search

+13
View File
@@ -0,0 +1,13 @@
# CMS 业务验收
显式运行,使用随机 loopback 端口、真实登录及核心角色授权、临时 SQLite 和正常 Job Worker;不访问部署数据库,不启动压测。
```sh
python3 test/cms/acceptance.py --compiler /absolute/path/to/dever --output /new/report-directory
```
运行器复制 plain CMS,并只在副本追加已有 `test/dever-tests/fixtures/postgres_api` 的真实账号/成员建立 CMD。输出目录保存无配置/凭据的 `cms-app`,可用 `--executable /absolute/path/to/cms-app` 跳过后续重复构建;该程序必须包含同一个 `fixture_member` CMD,`--project` 提供匹配的源项目配置。输入源码、二进制和部署配置均不修改。临时密钥、密码和数据库随工作目录清理。
覆盖分类、上传与文件清理、关联文章、修订快照、过期/并发版本冲突、作者和角色权限、发布快照、定时/同刻任务、重复执行、失败回滚/重试、排队后撤权和会话撤销/到期。仅在自有 SQLite 加临时拒绝 INSERT 的 trigger 注入晚期故障;重放只重置已完成队列行的执行状态,保留原身份、租户、权限、schema 与 payload;会话到期通过把自有平台库的 admin 会话截止时间设为过去验证,不伪造身份。所有普通业务和授权经真实 API/CMD。不会将数据库回滚等同于已提交存储文件回滚。
每个 HTTP 请求、构建和进程均有时限;退出时停止并回收自有进程。此验收是有界业务正确性检查,不代表 PostgreSQL、浏览器 HTTPS、长期负载或性能测试。双源码等价由现有编译器 CMS 合同检查负责,不在此复制 Markdown 解析器。
+476
View File
@@ -0,0 +1,476 @@
"""自有 SQLite/HTTP CMS 业务验收;不启动压测,不使用部署数据库。"""
import argparse
from concurrent.futures import ThreadPoolExecutor
from contextlib import closing, contextmanager
from datetime import datetime, timezone
import http.client
import json
from pathlib import Path
import secrets
import shutil
import sqlite3
import sys
import tempfile
from threading import Barrier
import time
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "test/performance"))
from cms import (CmsClient, available_port, catalog_key, copy_cms_project,
decode_response, execute, isolated_settings, login_client,
provision_identity, require)
from process import Process
from settings import write_private_json
ARTICLE = "/news/article/admin/manage"
REVISION = "/content/revision/admin/manage/create"
CATEGORY = "/content/category/admin/manage"
ASSET = "/media/asset/admin/manage/upload"
SCHEDULE = "/publishing/schedule/admin/manage/schedule"
ROLES = "/user/authorization/admin/manage"
BUSINESS_TABLES = ("article", "revision", "publication", "schedule", "operation", "asset", "category")
def append_member_fixture(project):
"""复用真实凭据/成员关系 fixture;仅追加到本运行器复制的 plain 项目。"""
fixtures = ROOT / "test/dever-tests/fixtures/postgres_api"
for target, name in (("user/account/app.dever", "account_app.dever"),
("user/membership/app.dever", "membership_app.dever"),
("user/account/api.dever", "account_api.dever")):
path = project / "module" / target
require(path.is_file(), "compiler mode requires the plain CMS project")
with path.open("a") as destination:
destination.write("\n" + (fixtures / name).read_text())
def sql_read(database, sql, parameters=()):
with closing(sqlite3.connect(database.as_uri() + "?mode=ro", uri=True, timeout=5)) as connection:
connection.row_factory = sqlite3.Row
return [dict(row) for row in connection.execute(sql, parameters)]
def snapshot(database):
return {table: sql_read(database, f'SELECT * FROM "{table}" ORDER BY id')
for table in BUSINESS_TABLES}
def fixture_write(database, sql, parameters=()):
# 此入口只接收自有临时数据库。所有正常业务创建和授权仍走真实 API/CMD。
with closing(sqlite3.connect(database, timeout=5)) as connection:
with connection:
connection.execute(sql, parameters)
@contextmanager
def reject_inserts(database, table):
require(table in BUSINESS_TABLES, "unsupported failure injection table")
fixture_write(database, f'''CREATE TRIGGER cms_reject_insert BEFORE INSERT ON "{table}"
BEGIN SELECT RAISE(ABORT, 'owned CMS acceptance failure'); END''')
try:
yield
finally:
fixture_write(database, "DROP TRIGGER cms_reject_insert")
def wait_for(check, process, label, timeout=15):
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
require(process.child.poll() is None, f"{label}: child exited; see {process.directory}")
value = check()
if value:
return value
time.sleep(0.05)
raise TimeoutError(f"{label}: timed out after {timeout}s")
def ready(process, client):
def probe():
try:
client.request("GET", ARTICLE + "/list", expected=401)
return True
except (OSError, http.client.HTTPException):
client.close()
return False
wait_for(probe, process, "API startup", timeout=20)
def multipart(client, content, *, expected=200, field="file"):
boundary = "cms-" + secrets.token_hex(12)
body = (f'--{boundary}\r\nContent-Disposition: form-data; name="{field}"; '
'filename="article.txt"\r\nContent-Type: text/plain\r\n\r\n').encode()
body += content + f"\r\n--{boundary}--\r\n".encode()
client.connection.request("POST", ASSET, body, {
"Content-Type": f"multipart/form-data; boundary={boundary}",
"Origin": client.origin, "Cookie": client.cookies.get("admin", ""),
})
response = client.connection.getresponse()
reply = response.read()
require(response.status == expected,
f"upload: HTTP {response.status}, expected {expected}: {reply!r}")
if expected != 200:
require(json.loads(reply)["code"] != 0, "upload rejection returned success")
return None
return decode_response(reply, response.status)
class Acceptance:
def __init__(self, executable, directory, settings, identity, password, member, member_password):
self.executable = executable
self.directory = directory
self.settings = settings
self.identity = identity
self.password = password
self.member_identity = member
self.member_password = member_password
self.port = settings["http"]["port"]
self.owner = CmsClient(self.port)
self.member = CmsClient(self.port)
self.anonymous = CmsClient(self.port)
self.database = directory / "data/tenants" / f"tenant_{identity['tenant_id']}.db"
self.worker_number = 0
self.checks = []
def close(self):
for client in (self.owner, self.member, self.anonymous):
client.close()
def passed(self, name):
self.checks.append(name)
print(f"PASS {name}", flush=True)
def assert_unchanged(self, before, label):
require(snapshot(self.database) == before, f"{label}: business rows changed")
def create(self, client, slug, **fields):
return client.request("POST", ARTICLE + "/create", {
"slug": slug, "title": f"Title {slug}", "body": "Draft body", **fields,
}, site="admin")
def edit(self, client, article, *, title="Edited title", expected=200):
revision = client.request("POST", REVISION, {
"selected_article_id": article["id"], "title": title, "body": "Edited body",
"expected_version": article["version"], "category_id": article.get("category_id"),
"asset_id": article.get("asset_id"),
}, site="admin", expected=expected)
if expected != 200:
return None
current = client.request("GET", ARTICLE + "/detail", {"id": article["id"]}, site="admin")
require(revision["article_id"] == current["id"] and revision["version"] == current["version"]
and revision["title"] == current["title"] and revision["body"] == current["body"],
"revision and current article disagree")
return current
def schedule(self, client, article, milliseconds):
instant = datetime.fromtimestamp(milliseconds / 1000, timezone.utc)
return client.request("POST", SCHEDULE, {
"article_id": article["id"],
"publish_at": instant.isoformat(timespec="milliseconds").replace("+00:00", "Z"),
}, site="admin")
def job(self, schedule):
rows = sql_read(self.database,
"SELECT * FROM _dever_jobs WHERE target=? ORDER BY id",
("publishing.schedule.job.publish",))
matches = [row for row in rows if json.loads(row["payload"])["schedule_id"] == schedule["id"]]
require(len(matches) == 1, f"schedule {schedule['id']} must own exactly one Job: {rows}")
return matches[0]
@contextmanager
def worker(self):
self.worker_number += 1
settings = {**self.settings, "runtime": {**self.settings["runtime"], "mode": "worker"}}
write_private_json(self.directory / "config/setting.json", settings, replace=True)
try:
with Process([self.executable], self.directory / f"worker-{self.worker_number}") as process:
yield process
require(process.child.returncode == 0, f"Worker did not shut down cleanly: {process.directory}")
finally:
write_private_json(self.directory / "config/setting.json", self.settings, replace=True)
def authorization(self):
login_client(self.owner, self.identity, self.password, "cms")
login_client(self.member, self.member_identity, self.member_password, "cms")
before = snapshot(self.database)
self.anonymous.request("GET", ARTICLE + "/list", expected=401)
self.member.request("GET", ARTICLE + "/list", site="admin", expected=403)
for client, status in ((self.anonymous, 401), (self.member, 403)):
client.request("POST", ARTICLE + "/create", {
"slug": "must-not-create", "title": "Rejected", "body": "Rejected",
}, site="admin", expected=status)
self.assert_unchanged(before, "unauthenticated and ungranted writes")
catalog = self.owner.request("GET", ROLES + "/permissions", site="admin")
selections = (("news", "article", "create", "POST"),
("news", "article", "list", "GET"),
("news", "article", "detail", "GET"),
("news", "article", "publish", "POST"),
("news", "article", "remove", "DELETE"),
("content", "revision", "create", "POST"),
("publishing", "schedule", "schedule", "POST"))
keys = [catalog_key(catalog, component, domain, "admin", action, method)
for component, domain, action, method in selections]
self.owner.request("POST", ROLES + "/save", {
"id": "cms-editor", "name": "CMS editor", "all_permissions": False,
"permission_keys": keys,
}, site="admin")
self.owner.request("POST", ROLES + "/grant", {
"user_id": self.member_identity["user_id"], "role_id": "cms-editor",
}, site="admin")
self.member.request("GET", ARTICLE + "/list", {"size": 20}, site="admin")
self.passed("real login, ungranted denial and explicit member role")
def editorial(self):
category = self.owner.request("POST", CATEGORY + "/create", {"name": " CMS News "}, site="admin")
upload = multipart(self.owner, b"CMS attachment\n")
stored = self.directory / "data/upload" / upload["storage_key"]
require(stored.read_bytes() == b"CMS attachment\n", "uploaded bytes changed")
files = sorted(path.name for path in stored.parent.iterdir())
rows = snapshot(self.database)
multipart(self.owner, b"x" * 2049, expected=400)
multipart(self.owner, b"bad field", field="unknown", expected=400)
require(sorted(path.name for path in stored.parent.iterdir()) == files,
"rejected upload persisted a file")
require(not list((self.directory / "data/tmp").iterdir()), "upload temporary files leaked")
self.assert_unchanged(rows, "rejected uploads")
article = self.create(self.owner, "linked-article", category_id=category["id"], asset_id=upload["id"])
require(article["category_id"] == category["id"] and article["asset_id"] == upload["id"],
"article lost category/asset references")
before = snapshot(self.database)
self.edit(self.member, article, expected=403)
self.member.request("POST", ARTICLE + "/publish", {"article_id": article["id"]},
site="admin", expected=403)
self.member.request("DELETE", ARTICLE + "/remove", {"id": article["id"]},
site="admin", expected=403)
self.member.request("POST", SCHEDULE, {
"article_id": article["id"],
"publish_at": datetime.fromtimestamp(time.time() + 30, timezone.utc).isoformat(timespec="milliseconds"),
}, site="admin", expected=403)
self.assert_unchanged(before, "non-author mutation")
edited = self.edit(self.owner, article)
require(edited["version"] == article["version"] + 1, "edit did not advance version")
before = snapshot(self.database)
self.edit(self.owner, article, expected=409)
self.assert_unchanged(before, "stale edit")
self.passed("multipart cleanup, attachment references, author and stale-version rejection")
# 失败发生在文章写入之后,必须连同版本/修订一起回滚。
with reject_inserts(self.database, "revision"):
self.edit(self.owner, edited, title="Must roll back", expected=500)
self.assert_unchanged(before, "revision insert failure")
self.passed("HTTP edit failure rolls back article and revision")
winner = self.concurrent_edit(edited)
before = snapshot(self.database)
with reject_inserts(self.database, "publication"):
self.owner.request("POST", ARTICLE + "/publish", {"article_id": article["id"]},
site="admin", expected=500)
self.assert_unchanged(before, "publication insert failure")
published = self.owner.request("POST", ARTICLE + "/publish", {"article_id": article["id"]}, site="admin")
require(published["title"] == winner["title"] and published["body"] == winner["body"],
"publication is not the winning stored revision")
before = snapshot(self.database)
self.owner.request("POST", ARTICLE + "/publish", {"article_id": article["id"]}, site="admin", expected=409)
self.assert_unchanged(before, "repeated manual publish")
self.owner.request("DELETE", CATEGORY + "/disable", {"id": category["id"]}, site="admin")
before = snapshot(self.database)
self.owner.request("POST", ARTICLE + "/create", {
"slug": "disabled-category", "title": "Rejected", "body": "Rejected", "category_id": category["id"],
}, site="admin", expected=409)
self.assert_unchanged(before, "disabled category")
self.passed("publish snapshot, late-failure rollback, repeat conflict and disabled category")
def concurrent_edit(self, article):
barrier = Barrier(2, timeout=5)
def compete(index):
client = CmsClient(self.port)
client.cookies = self.owner.cookies.copy()
try:
barrier.wait()
payload = {"selected_article_id": article["id"], "title": f"Concurrent {index}",
"body": "Winning body", "expected_version": article["version"],
"category_id": article["category_id"], "asset_id": article["asset_id"]}
client.connection.request("POST", REVISION, json.dumps(payload), {
"Content-Type": "application/json", "Origin": client.origin,
"Cookie": client.cookies["admin"],
})
response = client.connection.getresponse()
body = response.read()
document = json.loads(body)
require(set(document) == {"code", "message", "data"}, "invalid race envelope")
require((response.status, document["code"]) in ((200, 0), (409, 409)),
f"unexpected concurrent edit: {response.status} {body!r}")
return response.status, document["data"]
finally:
client.close()
prior = len(sql_read(self.database, "SELECT id FROM revision WHERE article_id=?", (article["id"],)))
with ThreadPoolExecutor(max_workers=2) as pool:
results = list(pool.map(compete, (1, 2)))
require(sorted(status for status, _ in results) == [200, 409], "concurrent edit must have one winner")
winner = next(value for status, value in results if status == 200)
require(winner["version"] == article["version"] + 1, "concurrent version advanced twice")
revisions = sql_read(self.database, "SELECT * FROM revision WHERE article_id=? ORDER BY version", (article["id"],))
require(len(revisions) == prior + 1 and revisions[-1]["title"] == winner["title"],
"concurrent edit persisted duplicate or mismatched revisions")
self.passed("two simultaneous edits produce one version and one revision")
return winner
def scheduled_publish(self):
articles = [self.create(self.owner, f"scheduled-{index}") for index in (1, 2)]
due = int(time.time() * 1000) + 3000
schedules = [self.schedule(self.owner, article, due) for article in articles]
jobs = [self.job(schedule) for schedule in schedules]
require(len({job["id"] for job in jobs}) == 2 and all(job["run_at"] == due for job in jobs),
"same-time schedules merged or enqueue_at lost the due time")
before = snapshot(self.database)
with self.worker() as worker:
require(time.time() * 1000 < due - 500, "fixture did not leave a pre-due observation window")
while time.time() * 1000 < due - 300:
require(worker.child.poll() is None, "worker stopped before due time")
require(all(self.job(value)["state"] == "pending" for value in schedules), "Job ran early")
self.assert_unchanged(before, "not-yet-due schedule")
time.sleep(0.05)
wait_for(lambda: all(self.job(value)["state"] == "succeeded" for value in schedules), worker,
"same-time scheduled publications")
for article in articles:
require(len(sql_read(self.database, "SELECT id FROM publication WHERE slug=?", (article["slug"],))) == 1,
"scheduled article was not published exactly once")
self.passed("real future enqueue_at and independent same-time publications")
before = snapshot(self.database)
for schedule in schedules:
# 重放真实已完成任务;保留原身份/租户/权限/指纹/payload,不伪造执行身份。
fixture_write(self.database, "UPDATE _dever_jobs SET state='pending',attempt=0,run_at=?,"
"claim_token='',lease_until=0,error='' WHERE id=? AND state='succeeded'",
(int(time.time() * 1000), self.job(schedule)["id"]))
with self.worker() as worker:
wait_for(lambda: all(self.job(value)["state"] == "succeeded" for value in schedules), worker,
"completed task replay")
self.assert_unchanged(before, "completed task replay")
self.passed("completed Job replay adds no publication or audit")
def retry_and_revocation(self):
article = self.create(self.owner, "retry-publish")
schedule = self.schedule(self.owner, article, int(time.time() * 1000) + 500)
before = snapshot(self.database)
with reject_inserts(self.database, "publication"):
with self.worker() as worker:
wait_for(lambda: self.job(schedule)["attempt"] == 1
and self.job(schedule)["state"] == "pending", worker, "first failed Job attempt")
failed = self.job(schedule)
require(failed["error"] == "handler_failed", "fixture did not reach business failure")
self.assert_unchanged(before, "failed Job transaction")
with self.worker() as worker:
wait_for(lambda: self.job(schedule)["state"] == "succeeded", worker, "automatic delayed retry")
require(self.job(schedule)["attempt"] == 2, "retry did not use exactly one new claim")
self.passed("Job late failure rolls back publication/audit/status and retries successfully")
article = self.create(self.member, "revoked-publish")
schedule = self.schedule(self.member, article, int(time.time() * 1000) + 500)
before = snapshot(self.database)
self.owner.request("DELETE", ROLES + "/revoke", {
"user_id": self.member_identity["user_id"], "role_id": "cms-editor",
}, site="admin")
self.member.request("GET", ARTICLE + "/list", site="admin", expected=403)
with self.worker() as worker:
wait_for(lambda: self.job(schedule)["state"] == "blocked", worker, "revoked queued permission")
require(self.job(schedule)["error"] == "identity_rejected", "Job blocked for the wrong reason")
self.assert_unchanged(before, "permission revoked before Job execution")
self.passed("immediate role revocation blocks queued Job before business writes")
def session_lifecycle(self):
before = snapshot(self.database)
logged_out = self.owner.request("POST", "/user/account/admin/session/logout", {}, site="admin")
require(logged_out["revoked"], "logout did not revoke the session")
# 保留旧 Cookie 再请求,证明服务端撤销而非仅清除浏览器 Cookie。
self.owner.request("GET", ARTICLE + "/list", site="admin", expected=401)
login_client(self.owner, self.identity, self.password, "cms")
self.owner.request("GET", ARTICLE + "/list", {"size": 20}, site="admin")
control = self.directory / "data/db/platform.db"
expired = int(time.time() * 1000) - 1
fixture_write(control, "UPDATE session SET expires_at=? WHERE user_id=? AND site='admin'",
(expired, self.identity["user_id"]))
self.owner.request("GET", ARTICLE + "/list", site="admin", expected=401)
self.owner.request("GET", "/news/article/front/browse/list", {"size": 100}, site="front")
self.assert_unchanged(before, "revoked and expired sessions")
self.passed("logout invalidates the old cookie; expired admin session preserves front isolation")
def run(self):
with Process([self.executable], self.directory / "api") as process:
ready(process, self.anonymous)
self.authorization()
self.editorial()
self.scheduled_publish()
self.retry_and_revocation()
self.session_lifecycle()
require(process.child.returncode == 0, f"API did not shut down cleanly: {process.directory}")
def run(args, directory, report):
project = directory / "project"
copy_cms_project(args.project, project)
# 忽略输入工程的任何数据库地址;本验收只生成自有 SQLite 配置。
settings = json.loads((project / "config/setting.json").read_text())
settings["database"] = {"default": {"type": "sqlite", "path": "data/db/platform.db",
"tenant_directory": "data/tenants", "max_connections": 4,
"max_page_size": 100}}
settings = isolated_settings(settings, available_port())
settings["runtime"] = {"mode": "api", "shutdown_ms": 3000}
settings["job"] = {"workers": 1, "poll_ms": 50, "lease_ms": 60000,
"retry_base_ms": 2000, "retry_max_ms": 2000}
settings["http"]["upload"] = {"file_bytes": 2048}
write_private_json(project / "config/setting.json", settings, replace=True)
if args.compiler:
append_member_fixture(project)
execute([args.compiler, "check", project], project, "check")
execute([args.compiler, "build", project, "--output", project / "cms-app"], project, "build", timeout=300)
else:
shutil.copy2(args.executable, project / "cms-app")
executable = project / "cms-app"
# 二进制不含部署配置或fixture凭据,可复用以免调试断言时反复编译。
shutil.copy2(executable, args.output / "cms-app")
report["executable_bytes"] = executable.stat().st_size
identity, password = provision_identity(executable, project, "cms")
member_password = secrets.token_urlsafe(24)
member = execute([executable, "user.account.fixture_member", json.dumps({
"selected_tenant_id": identity["tenant_id"], "email": "member@example.invalid",
"display_name": "CMS member", "password": member_password,
})], project, "member", envelope=True)
acceptance = Acceptance(executable, project, settings, identity, password, member, member_password)
report["passed"] = acceptance.checks
try:
acceptance.run()
return {"passed": acceptance.checks, "database": "owned SQLite", "load_test": False}
finally:
acceptance.close()
def main():
parser = argparse.ArgumentParser(description=__doc__)
source = parser.add_mutually_exclusive_group(required=True)
source.add_argument("--compiler", type=Path, help="explicit Dever compiler; builds an owned plain source copy")
source.add_argument("--executable", type=Path, help="prepared CMS executable including fixture_member CMD")
parser.add_argument("--project", type=Path, default=ROOT / "examples/cms/dever")
parser.add_argument("--output", type=Path, required=True, help="new directory for credential-free report/logs")
args = parser.parse_args()
args.project = args.project.resolve(strict=True)
for name in ("compiler", "executable"):
if getattr(args, name):
setattr(args, name, getattr(args, name).resolve(strict=True))
args.output.mkdir(parents=True, exist_ok=False)
report = {"success": False}
with tempfile.TemporaryDirectory(prefix="dever-cms-acceptance-") as temporary:
directory = Path(temporary)
try:
report.update(run(args, directory, report), success=True)
except BaseException as error:
report["error"] = f"{type(error).__name__}: {error}"
raise
finally:
for log in directory.rglob("*.log"):
destination = args.output / log.relative_to(directory)
destination.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(log, destination)
write_private_json(args.output / "report.json", report)
print(json.dumps(report, ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()