"""源库断开后从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