实现侧: - 上下文:任务范围拆分为 范围校验/范围授权;索引按可发现口径重建、索引新鲜度改对称差;依赖校验统一快照漂移说明。 - 知识方法:方法与材料读取口径统一;超限方法材料按可选省略,核对路径不再二次计费;删除无合同的读时重算。 - 任务运行:新增 context.usage/tool.denied 事件类型;连接池常驻并在装配生命周期内开关;调用结算与核对分列。 - 效果评测/审校修订/交付连载/作者经验/作品规划:凭据冻结、标定消费、导出补证、事实引文核对等收尾修复。 - 资源加载:能力正文不再夹带索引用的导航注记(该注记此前进入角色与技能的模型提示)。 - 元数据:受保护骨架与代码保护属性对齐;字段校验与内置结构口径同步。 - 基础设施:环境预检进入装配生命周期;数据库连接运行期字段不参与相等比较;索引指纹归一化 jsonb 浮点。 - 删除被替代实现:7 份旧提示词模板与空壳 资料来源 读取器。 用例侧: - 用例身份与导航元信息迁移;夹具补生命周期、同库暴露与模板封存; - 本轮定向修复:方法材料省略、事实引文、迁移回执、额度与暂停用例、慢用例超时预算等。
204 lines
9.0 KiB
Python
204 lines
9.0 KiB
Python
"""源库断开后从CLI恢复,再由真实业务owner读取候选和续接任务。"""
|
||
|
||
import io
|
||
import json
|
||
import subprocess
|
||
import sys
|
||
import tarfile
|
||
from dataclasses import replace
|
||
from pathlib import Path
|
||
|
||
import psycopg
|
||
import pytest
|
||
import test_定稿与导出 as 定稿测试
|
||
import test_生产评测权限隔离 as 接入测试
|
||
from fastapi.testclient import TestClient
|
||
from psycopg import sql
|
||
from psycopg.conninfo import conninfo_to_dict, make_conninfo
|
||
|
||
from muse.共享.调用身份 import 用途
|
||
from muse.启动 import 构建
|
||
from muse.基础设施.备份恢复 import 创建备份
|
||
from muse.接入.http.应用 import 创建应用
|
||
from muse.编排.定稿交付 import 发起定稿导出
|
||
from muse.资源加载 import 加载清单
|
||
from muse.配置 import 应用配置, 数据库引用, 读取配置
|
||
|
||
pytestmark = pytest.mark.数据库
|
||
候选环境 = 定稿测试.候选环境
|
||
交付环境 = 定稿测试.交付环境
|
||
|
||
|
||
@pytest.mark.case_id(
|
||
"NC-w29-29b008",
|
||
environment="隔离PG",
|
||
given="显式隔离配置、合成数据与已知发布构建",
|
||
when="经真实维护、业务或浏览器入口执行并读取持久结果",
|
||
then=["维护CLI断源恢复真实候选正文与排队交付任务"],
|
||
contract="docs/系统架构/新版设计/迁移与验收.md#备份与恢复入口",
|
||
)
|
||
def test_维护CLI断源恢复真实候选正文与排队交付任务__29b008(
|
||
交付环境, 新空目标库, 隔离数据库URL, tmp_path
|
||
):
|
||
app, author, pools, request = 交付环境
|
||
proposed = app.要求正文().创建人工候选(
|
||
author,
|
||
"backup-candidate",
|
||
"chapter-candidate",
|
||
1,
|
||
定稿测试.正文测试.草稿("备份中的未决候选"),
|
||
)
|
||
candidate_id = proposed["results"][0]["candidate_id"]
|
||
candidate = app.要求正文().读取候选(author, candidate_id)
|
||
app.要求交付().冻结定稿(author, "backup-delivery", request)
|
||
queued = 发起定稿导出(app, author, "backup-export", request.delivery_id, ["txt"])
|
||
raw, drafts = tmp_path / "source-raw", tmp_path / "source-drafts"
|
||
raw.mkdir()
|
||
drafts.mkdir()
|
||
(raw / "供应方原文.bin").write_bytes(b"\x00\xfffixture-raw")
|
||
(drafts / "随手记.txt").write_text("合成的作者草稿。")
|
||
|
||
def config(path, reference, raw_root, drafts_root):
|
||
path.write_text(
|
||
'["数据库"]\n"取值方式"="受控存储"\n"位置"='
|
||
+ json.dumps(reference.位置)
|
||
+ '\n["运行"]\n"用途"="maintenance"\n["资源"]\n"发布身份"='
|
||
+ json.dumps(加载清单()["构建身份"])
|
||
+ '\n["文件"]\n"原文暂存"='
|
||
+ json.dumps(str(raw_root))
|
||
+ '\n"探索草稿"='
|
||
+ json.dumps(str(drafts_root))
|
||
+ "\n"
|
||
)
|
||
return path
|
||
|
||
def cli(*args):
|
||
result = subprocess.run(
|
||
[sys.executable, "-I", "-m", "muse", "数据维护", *map(str, args)],
|
||
cwd=tmp_path,
|
||
capture_output=True,
|
||
text=True,
|
||
timeout=30,
|
||
)
|
||
assert result.returncode == 0, result.stderr
|
||
return json.loads(result.stdout)
|
||
|
||
source_cfg = config(tmp_path / "source.toml", pools[用途.维护].引用, raw, drafts)
|
||
archive = tmp_path / "cli-backup"
|
||
saved = cli("备份", source_cfg, archive)
|
||
assert saved["archive_verified"] and not saved["restore_verified"]
|
||
source_db = conninfo_to_dict(Path(pools[用途.维护].引用.位置).read_text())["dbname"]
|
||
# 只关闭本用例创建的源库连接,证明恢复并不依赖源库运行或任务调度。
|
||
with psycopg.connect(隔离数据库URL, autocommit=True) as admin:
|
||
admin.execute(
|
||
sql.SQL("ALTER DATABASE {} ALLOW_CONNECTIONS false").format(sql.Identifier(source_db))
|
||
)
|
||
assert cli("核对", archive)["backup_id"] == saved["backup_id"]
|
||
restored_raw, restored_drafts = tmp_path / "cli-raw", tmp_path / "cli-drafts"
|
||
target_cfg = config(
|
||
tmp_path / "target.toml", 新空目标库.工厂.引用, restored_raw, restored_drafts
|
||
)
|
||
outcome = cli("恢复", target_cfg, archive, "--预期备份ID", saved["backup_id"])
|
||
assert outcome["status"] == "verified" and not outcome["services_started"]
|
||
params = conninfo_to_dict(Path(新空目标库.工厂.引用.位置).read_text())
|
||
params["user"] = "muse_app"
|
||
secret = tmp_path / "target-production.txt"
|
||
secret.write_text(make_conninfo(**params))
|
||
secret.chmod(0o600)
|
||
restored = 构建(应用配置(数据库引用("受控存储", str(secret)), 加载清单()["构建身份"]))
|
||
with restored.生命周期():
|
||
current = restored.要求正文().读取候选(author, candidate_id)
|
||
assert current["candidate_hash"] == candidate["candidate_hash"]
|
||
assert current["decision"] == "undecided"
|
||
assert restored.要求正文().读取正文(author, "chapter-candidate")["revision"] == 1
|
||
assert 定稿测试.跑导出(restored, queued["task_id"]).状态.value == "completed"
|
||
assert (restored_raw / "供应方原文.bin").read_bytes() == (
|
||
raw / "供应方原文.bin"
|
||
).read_bytes()
|
||
|
||
|
||
@pytest.mark.case_id(
|
||
"NC-w29-29b009",
|
||
environment="隔离PG",
|
||
given="显式隔离配置、合成数据与已知发布构建",
|
||
when="经真实维护、业务或浏览器入口执行并读取持久结果",
|
||
then=["认证备份目录核对下载不依赖可用源库并拒绝坏份"],
|
||
contract="docs/系统架构/新版设计/迁移与验收.md#备份与恢复入口",
|
||
)
|
||
def test_认证备份目录核对下载不依赖可用源库并拒绝坏份__29b009(应用测试库, tmp_path):
|
||
root = tmp_path / "backups"
|
||
root.mkdir(mode=0o700)
|
||
good = root / "一份完整备份"
|
||
source = 应用配置(应用测试库[用途.维护].引用, 加载清单()["构建身份"], 运行用途=用途.维护)
|
||
archive = 创建备份(source, good)
|
||
bad = root / "不完整备份"
|
||
bad.mkdir(mode=0o700)
|
||
(bad / "manifest.json").write_text("{}")
|
||
(bad / "manifest.json").chmod(0o600)
|
||
cfg = 读取配置(接入测试._配置文件(应用测试库[用途.生产], tmp_path))
|
||
# 当前HTTP配置故意没有可连接的DB引用,备份查询仍是独立只读能力。
|
||
cfg = replace(
|
||
cfg,
|
||
备份目录=str(root),
|
||
数据库=数据库引用("受控存储", str(tmp_path / "missing-database")),
|
||
)
|
||
with TestClient(创建应用(cfg), headers={"origin": "http://testserver"}) as client:
|
||
assert client.get("/api/v1/system/backups").status_code == 401
|
||
assert (
|
||
client.post(
|
||
"/api/v1/session", json={"password": "synthetic-evaluation-only"}
|
||
).status_code
|
||
== 200
|
||
)
|
||
catalog = client.get("/api/v1/system/backups")
|
||
assert catalog.status_code == 200
|
||
assert str(tmp_path) not in catalog.text
|
||
rows = catalog.json()["items"]
|
||
assert {x["state"] for x in rows} == {"manifest_only", "unreadable"}
|
||
row = next(x for x in rows if x.get("backup_id") == archive["backup_id"])
|
||
path = "/api/v1/system/backups/" + row["entry_id"]
|
||
params = {"expected_backup_id": archive["backup_id"]}
|
||
checked = client.get(path + "/verify", params=params)
|
||
assert checked.status_code == 200, checked.text
|
||
assert checked.json()["archive_verified"] and not checked.json()["restore_verified"]
|
||
wrong = client.get(path + "/download", params={"expected_backup_id": "bkp-" + "0" * 40})
|
||
assert wrong.status_code != 200
|
||
downloaded = client.get(path + "/download", params=params)
|
||
assert downloaded.status_code == 200
|
||
assert downloaded.headers["content-type"].startswith("application/x-tar")
|
||
with tarfile.open(fileobj=io.BytesIO(downloaded.content)) as bundle:
|
||
assert {x.name.split("/")[0] for x in bundle.getmembers()} == {
|
||
"manifest.json",
|
||
"database.dump",
|
||
"files",
|
||
}
|
||
content = bundle.extractfile("database.dump")
|
||
assert content is not None and content.read() == (good / "database.dump").read_bytes()
|
||
downloaded_file = tmp_path / "downloaded.tar"
|
||
downloaded_file.write_bytes(downloaded.content)
|
||
unpacked = tmp_path / "unpacked"
|
||
command = subprocess.run(
|
||
[
|
||
sys.executable,
|
||
"-I",
|
||
"-m",
|
||
"muse",
|
||
"数据维护",
|
||
"解包",
|
||
str(downloaded_file),
|
||
str(unpacked),
|
||
"--预期备份ID",
|
||
archive["backup_id"],
|
||
],
|
||
cwd=tmp_path,
|
||
capture_output=True,
|
||
text=True,
|
||
timeout=30,
|
||
)
|
||
assert command.returncode == 0, command.stderr
|
||
assert json.loads(command.stdout)["archive_verified"] is True
|
||
assert (unpacked / "database.dump").read_bytes() == (good / "database.dump").read_bytes()
|
||
with (good / "database.dump").open("ab") as stream:
|
||
stream.write(b"corruption")
|
||
assert client.get(path + "/verify", params=params).status_code != 200
|