- 删除 数据库/旧库迁移 工具树、旧库迁移用例与旧库样本夹具;摘除 Makefile/pyproject/ruff/pyright 与 PYTHONPATH 引用,同步 README/AGENTS/设计文档与索引、分片工具 - 清空业务数据改按 种子表 登记过滤:未知 schema 与新增运行时表默认清空,迁移账本/索引代次单例/原文命名空间/结构种子保留;迁移新增种子表漏登记由新单测机械拦截 - 共享库复用前兜底清空,防上例强杀残留;例间只清数据不删表,个案探针表须自带 DROP TABLE IF EXISTS(规则与迁移用例同步) - 底座生命周期单测由 15 例精简为 5 例(保留清理、未封存模板、失败清理相关用例),其余 11 例随本批移除
870 lines
38 KiB
Python
870 lines
38 KiB
Python
from __future__ import annotations
|
||
|
||
import hashlib
|
||
import json
|
||
from decimal import Decimal
|
||
from pathlib import Path
|
||
from types import SimpleNamespace
|
||
|
||
import pytest
|
||
from psycopg.conninfo import conninfo_to_dict, make_conninfo
|
||
|
||
from muse.共享.调用身份 import 用途
|
||
from muse.基础设施 import 备份恢复
|
||
from muse.基础设施.备份恢复 import 创建备份, 备份恢复错误, 恢复备份, 核对备份
|
||
from muse.基础设施.数据库.维护锁 import 迁移维护锁
|
||
from muse.基础设施.数据库.连接 import 数据库工厂
|
||
from muse.资源加载 import 加载清单
|
||
from muse.配置 import 应用配置, 数据库引用
|
||
|
||
pytestmark = pytest.mark.数据库
|
||
|
||
|
||
def _资源身份() -> str:
|
||
return 加载清单()["构建身份"]
|
||
|
||
|
||
敏感标记 = "w29-password-must-never-leak"
|
||
|
||
|
||
def _断言目标尚无迁移表(工厂: 数据库工厂) -> None:
|
||
with 工厂.连接() as 连:
|
||
assert 连.execute("SELECT to_regclass('public.muse_migration')").fetchone() == (None,)
|
||
|
||
|
||
def _记录客户端动作(monkeypatch: pytest.MonkeyPatch) -> list[str]:
|
||
动作: list[str] = []
|
||
原始 = 备份恢复._运行客户端
|
||
|
||
def 记录(命令, 参数, 引用, 动作名):
|
||
动作.append(动作名)
|
||
原始(命令, 参数, 引用, 动作名)
|
||
|
||
monkeypatch.setattr(备份恢复, "_运行客户端", 记录)
|
||
return 动作
|
||
|
||
|
||
def _记录客户端命令(monkeypatch: pytest.MonkeyPatch) -> list[list[str]]:
|
||
命令行 = []
|
||
原始 = 备份恢复.subprocess.run
|
||
|
||
def 记录(args, **kwargs):
|
||
命令行.append(list(args))
|
||
return 原始(args, **kwargs)
|
||
|
||
monkeypatch.setattr(备份恢复.subprocess, "run", 记录)
|
||
return 命令行
|
||
|
||
|
||
def _应用配置(
|
||
工厂: 数据库工厂,
|
||
raw: Path,
|
||
drafts: Path,
|
||
*,
|
||
引用: 数据库引用 | None = None,
|
||
) -> 应用配置:
|
||
return 应用配置(
|
||
引用 or 工厂.引用,
|
||
_资源身份(),
|
||
运行用途=用途.维护,
|
||
原文暂存=str(raw),
|
||
探索草稿=str(drafts),
|
||
)
|
||
|
||
|
||
def _带敏感值引用(工厂: 数据库工厂, tmp_path: Path, 名称: str) -> 数据库引用:
|
||
原串 = Path(工厂.引用.位置).read_text(encoding="utf-8")
|
||
参数 = conninfo_to_dict(原串)
|
||
参数["password"] = 敏感标记
|
||
路径 = tmp_path / 名称
|
||
路径.write_text(make_conninfo(**参数), encoding="utf-8")
|
||
路径.chmod(0o600)
|
||
return 数据库引用("受控存储", str(路径))
|
||
|
||
|
||
def _准备文件(tmp_path: Path, 前缀: str) -> tuple[Path, Path]:
|
||
raw = tmp_path / f"{前缀}-raw"
|
||
drafts = tmp_path / f"{前缀}-drafts"
|
||
raw.mkdir()
|
||
drafts.mkdir()
|
||
(raw / "供应方原文.bin").write_bytes(b"\x00\xffraw-evidence\x80")
|
||
(drafts / "第一章 草稿.txt").write_text("风从旧城来。\n", encoding="utf-8")
|
||
return raw, drafts
|
||
|
||
|
||
def _建立数据库探针(工厂: 数据库工厂) -> None:
|
||
with 工厂.连接() as 连, 连.transaction():
|
||
连.execute("DROP TABLE IF EXISTS public.w29_child, public.w29_parent CASCADE")
|
||
连.execute(
|
||
"""
|
||
CREATE TABLE public.w29_parent (
|
||
id text PRIMARY KEY,
|
||
dynamic_payload jsonb NOT NULL,
|
||
raw_bytes bytea NOT NULL
|
||
);
|
||
CREATE TABLE public.w29_child (
|
||
id text PRIMARY KEY,
|
||
parent_id text NOT NULL REFERENCES public.w29_parent(id),
|
||
audit_note text NOT NULL
|
||
);
|
||
GRANT SELECT, INSERT, UPDATE, DELETE ON public.w29_parent, public.w29_child
|
||
TO muse_app;
|
||
GRANT SELECT ON public.w29_parent, public.w29_child TO muse_eval;
|
||
"""
|
||
)
|
||
连.execute(
|
||
"INSERT INTO public.w29_parent VALUES (%s, %s, %s)",
|
||
(
|
||
"parent-1",
|
||
json.dumps({"扩展字段": {"层级": [1, "二", {"保留": True}]}}),
|
||
b"\x00\xff\x10" + "原文-bytea".encode(),
|
||
),
|
||
)
|
||
连.execute(
|
||
"INSERT INTO public.w29_child VALUES (%s, %s, %s)",
|
||
("child-1", "parent-1", "审计理由保留"),
|
||
)
|
||
|
||
|
||
@pytest.mark.case_id(
|
||
"NC-w29-29b001",
|
||
environment="隔离PG",
|
||
given="显式隔离配置、合成数据与已知发布构建",
|
||
when="经真实维护、业务或浏览器入口执行并读取持久结果",
|
||
then=["完整PG文件权限关系与动态内容实际往返"],
|
||
contract="docs/系统架构/新版设计/迁移与验收.md#备份与恢复入口",
|
||
)
|
||
def test_完整PG文件权限关系与动态内容实际往返__29b001(
|
||
应用测试库: dict, 新空目标库: SimpleNamespace, tmp_path: Path
|
||
) -> None:
|
||
来源工厂 = 应用测试库[用途.维护]
|
||
_建立数据库探针(来源工厂)
|
||
raw, drafts = _准备文件(tmp_path, "source")
|
||
敏感引用 = _带敏感值引用(来源工厂, tmp_path, "source-secret.txt")
|
||
来源配置 = _应用配置(来源工厂, raw, drafts, 引用=敏感引用)
|
||
备份目录 = tmp_path / "backup"
|
||
|
||
清单 = 创建备份(来源配置, 备份目录)
|
||
目标raw = tmp_path / "restored-raw"
|
||
目标drafts = tmp_path / "restored-drafts"
|
||
目标配置 = _应用配置(新空目标库.工厂, 目标raw, 目标drafts)
|
||
结果 = 恢复备份(备份目录, 目标配置, 清单["backup_id"])
|
||
|
||
assert 结果["status"] == "verified"
|
||
assert 结果["services_started"] is False
|
||
assert 结果["external_model_calls_restored"] is False
|
||
assert 核对备份(备份目录) == 清单
|
||
assert (目标raw / "供应方原文.bin").read_bytes() == b"\x00\xffraw-evidence\x80"
|
||
assert (目标drafts / "第一章 草稿.txt").read_text(encoding="utf-8") == "风从旧城来。\n"
|
||
with 新空目标库.工厂.连接() as 连:
|
||
行 = 连.execute(
|
||
"SELECT dynamic_payload, raw_bytes FROM public.w29_parent WHERE id = 'parent-1'"
|
||
).fetchone()
|
||
assert 行 == (
|
||
{"扩展字段": {"层级": [1, "二", {"保留": True}]}},
|
||
b"\x00\xff\x10" + "原文-bytea".encode(),
|
||
)
|
||
assert 连.execute(
|
||
"SELECT audit_note FROM public.w29_child WHERE parent_id = 'parent-1'"
|
||
).fetchone() == ("审计理由保留",)
|
||
assert 连.execute(
|
||
"SELECT has_table_privilege('muse_app', 'public.w29_parent', 'SELECT'), "
|
||
"has_table_privilege('muse_eval', 'public.w29_parent', 'SELECT')"
|
||
).fetchone() == (True, True)
|
||
assert 连.execute(
|
||
"SELECT count(*) FROM pg_constraint WHERE conrelid = 'public.w29_child'::regclass "
|
||
"AND contype = 'f'"
|
||
).fetchone() == (1,)
|
||
清单正文 = (备份目录 / "manifest.json").read_text(encoding="utf-8")
|
||
assert 敏感标记 not in 清单正文
|
||
assert str(raw) not in 清单正文
|
||
assert str(drafts) not in 清单正文
|
||
assert 来源工厂.引用.位置 not in 清单正文
|
||
|
||
|
||
@pytest.mark.case_id(
|
||
"NC-w29-29b002",
|
||
environment="隔离PG",
|
||
given="显式隔离配置、合成数据与已知发布构建",
|
||
when="经真实维护、业务或浏览器入口执行并读取持久结果",
|
||
then=["并发业务写锁存在时备份立即失败且无半成品"],
|
||
contract="docs/系统架构/新版设计/迁移与验收.md#备份与恢复入口",
|
||
)
|
||
def test_并发业务写锁存在时备份立即失败且无半成品__29b002(应用测试库: dict, tmp_path: Path) -> None:
|
||
维护工厂 = 应用测试库[用途.维护]
|
||
_建立数据库探针(维护工厂)
|
||
raw, drafts = _准备文件(tmp_path, "locked")
|
||
配置 = _应用配置(维护工厂, raw, drafts)
|
||
输出 = tmp_path / "locked-backup"
|
||
with 应用测试库[用途.生产].连接() as 写连, 写连.transaction():
|
||
写连.execute(
|
||
"INSERT INTO public.w29_parent VALUES (%s, %s, %s)",
|
||
("uncommitted", json.dumps({"状态": "writing"}), b"pending"),
|
||
)
|
||
with pytest.raises(备份恢复错误, match="备份锁"):
|
||
创建备份(配置, 输出, pg_dump命令="should-not-run")
|
||
assert not 输出.exists()
|
||
|
||
|
||
@pytest.mark.case_id(
|
||
"NC-w29-29b003",
|
||
environment="隔离PG",
|
||
given="显式隔离配置、合成数据与已知发布构建",
|
||
when="经真实维护、业务或浏览器入口执行并读取持久结果",
|
||
then=["数据库归档损坏与清单字段篡改均在连接目标前拒绝"],
|
||
contract="docs/系统架构/新版设计/迁移与验收.md#备份与恢复入口",
|
||
)
|
||
def test_数据库归档损坏与清单字段篡改均在连接目标前拒绝__29b003(
|
||
应用测试库: dict, 新空目标库: SimpleNamespace, tmp_path: Path
|
||
) -> None:
|
||
来源工厂 = 应用测试库[用途.维护]
|
||
_建立数据库探针(来源工厂)
|
||
raw, drafts = _准备文件(tmp_path, "damaged")
|
||
备份目录 = tmp_path / "damaged-backup"
|
||
清单 = 创建备份(_应用配置(来源工厂, raw, drafts), 备份目录)
|
||
目标raw, 目标drafts = tmp_path / "target-raw", tmp_path / "target-drafts"
|
||
目标配置 = _应用配置(新空目标库.工厂, 目标raw, 目标drafts)
|
||
|
||
数据库归档 = 备份目录 / "database.dump"
|
||
原字节 = 数据库归档.read_bytes()
|
||
数据库归档.write_bytes(原字节[:-1] + bytes([原字节[-1] ^ 0xFF]))
|
||
数据库归档.chmod(0o600)
|
||
with pytest.raises(备份恢复错误, match="哈希"):
|
||
恢复备份(备份目录, 目标配置, 清单["backup_id"])
|
||
assert not 目标raw.exists() and not 目标drafts.exists()
|
||
_断言目标尚无迁移表(新空目标库.工厂)
|
||
|
||
数据库归档.write_bytes(原字节)
|
||
数据库归档.chmod(0o600)
|
||
清单路径 = 备份目录 / "manifest.json"
|
||
篡改 = json.loads(清单路径.read_text(encoding="utf-8"))
|
||
篡改["resource_release"] = "tampered-release"
|
||
清单路径.write_text(json.dumps(篡改), encoding="utf-8")
|
||
清单路径.chmod(0o600)
|
||
with pytest.raises(备份恢复错误, match="备份ID"):
|
||
核对备份(备份目录)
|
||
|
||
|
||
@pytest.mark.case_id(
|
||
"NC-w29-29b004",
|
||
environment="隔离PG",
|
||
given="显式隔离配置、合成数据与已知发布构建",
|
||
when="经真实维护、业务或浏览器入口执行并读取持久结果",
|
||
then=["拒绝来源库与已有内容目标且不覆盖文件"],
|
||
contract="docs/系统架构/新版设计/迁移与验收.md#备份与恢复入口",
|
||
)
|
||
def test_拒绝来源库与已有内容目标且不覆盖文件__29b004(
|
||
应用测试库: dict, 新空目标库: SimpleNamespace, tmp_path: Path
|
||
) -> None:
|
||
来源工厂 = 应用测试库[用途.维护]
|
||
_建立数据库探针(来源工厂)
|
||
raw, drafts = _准备文件(tmp_path, "guard")
|
||
备份目录 = tmp_path / "guard-backup"
|
||
清单 = 创建备份(_应用配置(来源工厂, raw, drafts), 备份目录)
|
||
|
||
来源目标配置 = _应用配置(
|
||
来源工厂, tmp_path / "source-target-raw", tmp_path / "source-target-drafts"
|
||
)
|
||
with pytest.raises(备份恢复错误, match="来源数据库"):
|
||
恢复备份(备份目录, 来源目标配置, 清单["backup_id"])
|
||
|
||
with 新空目标库.工厂.连接() as 连, 连.transaction():
|
||
连.execute("CREATE TABLE public.keep_me (value text NOT NULL)")
|
||
连.execute("INSERT INTO public.keep_me VALUES ('保留')")
|
||
目标raw = tmp_path / "occupied-target-raw"
|
||
目标drafts = tmp_path / "occupied-target-drafts"
|
||
with pytest.raises(备份恢复错误, match="全新空库"):
|
||
恢复备份(
|
||
备份目录,
|
||
_应用配置(新空目标库.工厂, 目标raw, 目标drafts),
|
||
清单["backup_id"],
|
||
)
|
||
with 新空目标库.工厂.连接() as 连:
|
||
assert 连.execute("SELECT value FROM public.keep_me").fetchone() == ("保留",)
|
||
assert not 目标raw.exists() and not 目标drafts.exists()
|
||
|
||
|
||
@pytest.mark.case_id(
|
||
"NC-w29-29b005",
|
||
environment="隔离PG",
|
||
given="显式隔离配置、合成数据与已知发布构建",
|
||
when="经真实维护、业务或浏览器入口执行并读取持久结果",
|
||
then=["pg_restore中途失败时单事务回滚并清理文件目标"],
|
||
contract="docs/系统架构/新版设计/迁移与验收.md#备份与恢复入口",
|
||
)
|
||
def test_pg_restore中途失败时单事务回滚并清理文件目标__29b005(
|
||
应用测试库: dict,
|
||
新空目标库: SimpleNamespace,
|
||
tmp_path: Path,
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
caplog: pytest.LogCaptureFixture,
|
||
) -> None:
|
||
来源工厂 = 应用测试库[用途.维护]
|
||
_建立数据库探针(来源工厂)
|
||
raw, drafts = _准备文件(tmp_path, "rollback")
|
||
备份目录 = tmp_path / "rollback-backup"
|
||
清单 = 创建备份(_应用配置(来源工厂, raw, drafts), 备份目录)
|
||
目标raw = tmp_path / "rollback-target-raw"
|
||
目标drafts = tmp_path / "rollback-target-drafts"
|
||
敏感引用 = _带敏感值引用(新空目标库.工厂, tmp_path, "restore-secret.txt")
|
||
目标配置 = _应用配置(新空目标库.工厂, 目标raw, 目标drafts, 引用=敏感引用)
|
||
命令行 = _记录客户端命令(monkeypatch)
|
||
原运行 = 备份恢复._运行客户端
|
||
|
||
def 注入恢复竞争(命令: str, 参数: tuple[str, ...], 引用: 数据库引用, 动作: str) -> None:
|
||
if 动作 == "数据库恢复":
|
||
with 数据库工厂(引用, 用途.维护).连接() as 连, 连.transaction():
|
||
连.execute(
|
||
"CREATE TABLE public.muse_migration "
|
||
"(version integer PRIMARY KEY, marker text NOT NULL)"
|
||
)
|
||
连.execute("INSERT INTO public.muse_migration VALUES (9999, 'keep-race-marker')")
|
||
原运行(命令, 参数, 引用, 动作)
|
||
|
||
monkeypatch.setattr(备份恢复, "_运行客户端", 注入恢复竞争)
|
||
with pytest.raises(备份恢复错误, match="^数据库恢复客户端失败$") as 捕获:
|
||
恢复备份(备份目录, 目标配置, 清单["backup_id"])
|
||
|
||
assert 命令行
|
||
assert 敏感标记 not in str(捕获.value.呈现())
|
||
assert 敏感标记 not in caplog.text
|
||
assert 敏感标记 not in json.dumps(命令行)
|
||
assert not 目标raw.exists() and not 目标drafts.exists()
|
||
with 新空目标库.工厂.连接() as 连:
|
||
assert 连.execute("SELECT marker FROM public.muse_migration").fetchone() == (
|
||
"keep-race-marker",
|
||
)
|
||
assert 连.execute("SELECT to_regnamespace('metadata')").fetchone() == (None,)
|
||
assert 连.execute("SELECT to_regtype('public.muse_purpose')").fetchone() == (None,)
|
||
|
||
|
||
@pytest.mark.case_id(
|
||
"NC-w29-29b006",
|
||
environment="隔离PG",
|
||
given="显式隔离配置、合成数据与已知发布构建",
|
||
when="经真实维护、业务或浏览器入口执行并读取持久结果",
|
||
then=["错误客户端输出不回显DSN凭据或绝对来源"],
|
||
contract="docs/系统架构/新版设计/迁移与验收.md#备份与恢复入口",
|
||
)
|
||
def test_错误客户端输出不回显DSN凭据或绝对来源__29b006(
|
||
应用测试库: dict,
|
||
tmp_path: Path,
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
caplog: pytest.LogCaptureFixture,
|
||
) -> None:
|
||
来源工厂 = 应用测试库[用途.维护]
|
||
_建立数据库探针(来源工厂)
|
||
raw, drafts = _准备文件(tmp_path, "secret")
|
||
引用 = _带敏感值引用(来源工厂, tmp_path, "failed-secret.txt")
|
||
配置 = _应用配置(来源工厂, raw, drafts, 引用=引用)
|
||
假客户端 = tmp_path / "failing-pg-dump"
|
||
假客户端.write_text('#!/bin/sh\nprintf "%s" "$PGPASSWORD" >&2\nexit 1\n')
|
||
假客户端.chmod(0o700)
|
||
命令行 = _记录客户端命令(monkeypatch)
|
||
|
||
with pytest.raises(备份恢复错误) as 捕获:
|
||
创建备份(配置, tmp_path / "failed-backup", pg_dump命令=str(假客户端))
|
||
|
||
呈现 = json.dumps(捕获.value.呈现(), ensure_ascii=False)
|
||
assert 命令行
|
||
assert 捕获.value.说明 == "数据库备份客户端失败"
|
||
assert 捕获.value.上下文["stderr_sha256"] == hashlib.sha256(敏感标记.encode()).hexdigest()
|
||
assert 敏感标记 not in caplog.text
|
||
assert 敏感标记 not in json.dumps(命令行)
|
||
assert 敏感标记 not in 呈现
|
||
assert str(raw) not in 呈现
|
||
assert str(drafts) not in 呈现
|
||
assert 来源工厂.引用.位置 not in 呈现
|
||
assert not (tmp_path / "failed-backup").exists()
|
||
|
||
|
||
@pytest.mark.case_id(
|
||
"NC-w29-29b007",
|
||
environment="隔离PG",
|
||
given="显式隔离配置、合成数据与已知发布构建",
|
||
when="经真实维护、业务或浏览器入口执行并读取持久结果",
|
||
then=["默认ACL等价归一仍拒绝恢复后额外生产权限"],
|
||
contract="docs/系统架构/新版设计/迁移与验收.md#备份与恢复入口",
|
||
)
|
||
def test_默认ACL等价归一仍拒绝恢复后额外生产权限__29b007(
|
||
应用测试库, 新空目标库, tmp_path, monkeypatch
|
||
):
|
||
raw, drafts = _准备文件(tmp_path, "acl-source")
|
||
source = _应用配置(应用测试库[用途.维护], raw, drafts)
|
||
archive = tmp_path / "acl-backup"
|
||
record = 创建备份(source, archive)
|
||
target = _应用配置(新空目标库.工厂, tmp_path / "acl-raw", tmp_path / "acl-drafts")
|
||
original = 备份恢复._运行客户端
|
||
|
||
def changed(command, args, reference, action):
|
||
original(command, args, reference, action)
|
||
if action == "数据库恢复":
|
||
with 数据库工厂(reference, 用途.维护).连接() as conn, conn.transaction():
|
||
conn.execute("GRANT SELECT ON public.muse_enablement_receipt TO muse_app")
|
||
|
||
monkeypatch.setattr(备份恢复, "_运行客户端", changed)
|
||
with pytest.raises(备份恢复错误, match="权限或关系摘要不一致"):
|
||
恢复备份(archive, target, record["backup_id"])
|
||
with 应用测试库[用途.维护].连接() as conn:
|
||
assert conn.execute(
|
||
"SELECT has_table_privilege('muse_app','public.muse_enablement_receipt','SELECT')"
|
||
).fetchone() == (False,)
|
||
|
||
|
||
def _建立类型探针(维护工厂: 数据库工厂) -> None:
|
||
"""decimal 精度、timestamptz、嵌套 jsonb 与序列推进,供确定性往返用。"""
|
||
with 维护工厂.连接() as 连, 连.transaction():
|
||
连.execute("DROP TABLE IF EXISTS public.w29_seq_probe, public.w29_typed CASCADE")
|
||
连.execute(
|
||
"""
|
||
CREATE TABLE public.w29_typed (
|
||
id integer PRIMARY KEY,
|
||
at timestamptz NOT NULL,
|
||
score numeric(10,4) NOT NULL,
|
||
payload jsonb NOT NULL
|
||
);
|
||
CREATE TABLE public.w29_seq_probe (
|
||
id serial PRIMARY KEY,
|
||
note text NOT NULL
|
||
);
|
||
"""
|
||
)
|
||
连.execute(
|
||
"INSERT INTO public.w29_typed VALUES (%s,%s,%s,%s)",
|
||
(
|
||
10,
|
||
"2026-07-21T08:00:00+00",
|
||
Decimal("1.2300"),
|
||
json.dumps({"扩展": {"层级": [1, "二"]}}),
|
||
),
|
||
)
|
||
连.execute(
|
||
"INSERT INTO public.w29_typed VALUES (%s,%s,%s,%s)",
|
||
(2, "2026-07-20T00:00:00+00", Decimal("0.10"), json.dumps({"序": 2})),
|
||
)
|
||
连.execute("INSERT INTO public.w29_seq_probe(note) VALUES ('甲'),('乙'),('丙')")
|
||
|
||
|
||
@pytest.mark.case_id(
|
||
"TC-e95a51d27a2b",
|
||
environment="隔离 PostgreSQL 与临时目录",
|
||
when="备份、校验、恢复或模拟切换失败。",
|
||
contract="docs/系统架构/新版设计/迁移与验收.md",
|
||
)
|
||
def test_相同主键集合的确定性清单与特殊数据库类型往返__e95a51(
|
||
应用测试库: dict, 新空目标库: SimpleNamespace, tmp_path: Path
|
||
) -> None:
|
||
"""相同内容两次备份得到相同清单与指纹;decimal/timestamptz/jsonb 往返保持值。"""
|
||
来源工厂 = 应用测试库[用途.维护]
|
||
_建立类型探针(来源工厂)
|
||
raw, drafts = _准备文件(tmp_path, "typed")
|
||
配置 = _应用配置(来源工厂, raw, drafts)
|
||
一 = 创建备份(配置, tmp_path / "typed-backup-1")
|
||
二 = 创建备份(配置, tmp_path / "typed-backup-2")
|
||
# 表内容、模式、约束与序列摘要按确定性顺序哈希,不因查询返回顺序改变;
|
||
# pg_dump 归档字节本身可能带非确定性头,不强求 backup_id 相同。
|
||
assert 一["database"]["summary"] == 二["database"]["summary"]
|
||
assert 一["database"]["source_fingerprint"] == 二["database"]["source_fingerprint"]
|
||
|
||
目标raw, 目标drafts = tmp_path / "typed-target-raw", tmp_path / "typed-target-drafts"
|
||
目标配置 = _应用配置(新空目标库.工厂, 目标raw, 目标drafts)
|
||
恢复备份(tmp_path / "typed-backup-1", 目标配置, 一["backup_id"])
|
||
with 新空目标库.工厂.连接() as 连:
|
||
连.execute("SELECT set_config('TimeZone', 'UTC', true)")
|
||
assert 连.execute(
|
||
"SELECT id, to_char(at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS'), "
|
||
"score::text, payload::text FROM public.w29_typed ORDER BY id"
|
||
).fetchall() == [
|
||
(2, "2026-07-20 00:00:00", "0.1000", '{"序": 2}'),
|
||
(
|
||
10,
|
||
"2026-07-21 08:00:00",
|
||
"1.2300",
|
||
'{"扩展": {"层级": [1, "二"]}}',
|
||
),
|
||
]
|
||
|
||
|
||
@pytest.mark.case_id(
|
||
"TC-f14761e9f048",
|
||
environment="隔离 PostgreSQL 与临时目录",
|
||
when="备份、校验、恢复或模拟切换失败。",
|
||
contract="docs/系统架构/新版设计/迁移与验收.md",
|
||
)
|
||
def test_备份输出只允许全新目录且不与来源重叠__f14761(应用测试库: dict, tmp_path: Path) -> None:
|
||
来源工厂 = 应用测试库[用途.维护]
|
||
raw, drafts = _准备文件(tmp_path, "path")
|
||
配置 = _应用配置(来源工厂, raw, drafts)
|
||
已存在 = tmp_path / "occupied"
|
||
已存在.mkdir()
|
||
(已存在 / "保留文件").write_text("keep", encoding="utf-8")
|
||
with pytest.raises(备份恢复错误, match="全新"):
|
||
创建备份(配置, 已存在)
|
||
assert (已存在 / "保留文件").read_text(encoding="utf-8") == "keep"
|
||
|
||
with pytest.raises(备份恢复错误, match="重叠"):
|
||
创建备份(配置, raw / "nested-backup")
|
||
|
||
|
||
@pytest.mark.case_id(
|
||
"TC-0f7c45927fbe",
|
||
environment="隔离 PostgreSQL 与临时目录",
|
||
when="备份、校验、恢复或模拟切换失败。",
|
||
contract="docs/系统架构/新版设计/迁移与验收.md",
|
||
)
|
||
def test_备份单快照七域往返且同内容重放一致__0f7c45(
|
||
应用测试库: dict, 新空目标库: SimpleNamespace, tmp_path: Path
|
||
) -> None:
|
||
"""数据库与文件七域进入同一份校验清单;关闭连接后从磁盘复验一致。"""
|
||
来源工厂 = 应用测试库[用途.维护]
|
||
_建立类型探针(来源工厂)
|
||
raw, drafts = _准备文件(tmp_path, "domains")
|
||
备份目录 = tmp_path / "domains-backup"
|
||
清单 = 创建备份(_应用配置(来源工厂, raw, drafts), 备份目录)
|
||
复验 = 核对备份(备份目录)
|
||
assert 复验 == 清单
|
||
assert set(清单["database"]) == {
|
||
"archive",
|
||
"size",
|
||
"sha256",
|
||
"source_fingerprint",
|
||
"fingerprint_scope",
|
||
"server_version_num",
|
||
"migrations",
|
||
"summary",
|
||
}
|
||
assert 清单["database"]["migrations"] and 清单["file_archive"]["file_count"] == 2
|
||
assert 清单["file_archive"]["roots"] == ["drafts", "raw"]
|
||
|
||
目标raw, 目标drafts = tmp_path / "domains-target-raw", tmp_path / "domains-target-drafts"
|
||
恢复备份(备份目录, _应用配置(新空目标库.工厂, 目标raw, 目标drafts), 清单["backup_id"])
|
||
with 新空目标库.工厂.连接() as 连:
|
||
assert 连.execute("SELECT count(*) FROM public.w29_typed").fetchone() == (2,)
|
||
|
||
|
||
@pytest.mark.case_id(
|
||
"TC-9a68f7f96bf6",
|
||
environment="隔离 PostgreSQL 与临时目录",
|
||
when="备份、校验、恢复或模拟切换失败。",
|
||
contract="docs/系统架构/新版设计/迁移与验收.md",
|
||
)
|
||
def test_文件归档意外损坏后离线核对失败__9a68f7(应用测试库: dict, tmp_path: Path) -> None:
|
||
来源工厂 = 应用测试库[用途.维护]
|
||
raw, drafts = _准备文件(tmp_path, "corrupt")
|
||
备份目录 = tmp_path / "corrupt-backup"
|
||
创建备份(_应用配置(来源工厂, raw, drafts), 备份目录)
|
||
原文 = 备份目录 / "files" / "data" / "raw" / "供应方原文.bin"
|
||
原文.write_bytes(原文.read_bytes() + b"\x00tampered")
|
||
with pytest.raises(备份恢复错误):
|
||
核对备份(备份目录)
|
||
|
||
|
||
@pytest.mark.case_id(
|
||
"TC-545fb1541c3a",
|
||
environment="隔离 PostgreSQL 与临时目录",
|
||
when="备份、校验、恢复或模拟切换失败。",
|
||
contract="docs/系统架构/新版设计/迁移与验收.md",
|
||
)
|
||
def test_备份清单拒绝重复键和未知字段__545fb1(应用测试库: dict, tmp_path: Path) -> None:
|
||
来源工厂 = 应用测试库[用途.维护]
|
||
raw, drafts = _准备文件(tmp_path, "contract")
|
||
备份目录 = tmp_path / "contract-backup"
|
||
创建备份(_应用配置(来源工厂, raw, drafts), 备份目录)
|
||
清单路径 = 备份目录 / "manifest.json"
|
||
原文 = 清单路径.read_text(encoding="utf-8")
|
||
重复 = 原文.replace('"backup_id":"', '"backup_id":"", "backup_id":"', 1)
|
||
清单路径.write_text(重复, encoding="utf-8")
|
||
清单路径.chmod(0o600)
|
||
with pytest.raises(备份恢复错误, match="重复字段"):
|
||
核对备份(备份目录)
|
||
|
||
清单路径.write_text(原文, encoding="utf-8")
|
||
清单路径.chmod(0o600)
|
||
未知 = 原文.rstrip()[:-1] + ',"extra_field":1}'
|
||
清单路径.write_text(未知, encoding="utf-8")
|
||
清单路径.chmod(0o600)
|
||
with pytest.raises(备份恢复错误):
|
||
核对备份(备份目录)
|
||
|
||
|
||
@pytest.mark.case_id(
|
||
"TC-2d7f171ffd33",
|
||
environment="隔离 PostgreSQL 与临时目录",
|
||
when="备份、校验、恢复或模拟切换失败。",
|
||
contract="docs/系统架构/新版设计/迁移与验收.md",
|
||
)
|
||
def test_资源构建身份漂移在连接前拒绝恢复__2d7f17(
|
||
应用测试库: dict, 新空目标库: SimpleNamespace, tmp_path: Path, monkeypatch
|
||
) -> None:
|
||
来源工厂 = 应用测试库[用途.维护]
|
||
raw, drafts = _准备文件(tmp_path, "identity")
|
||
备份目录 = tmp_path / "identity-backup"
|
||
清单 = 创建备份(_应用配置(来源工厂, raw, drafts), 备份目录)
|
||
目标raw, 目标drafts = tmp_path / "identity-target-raw", tmp_path / "identity-target-drafts"
|
||
目标配置 = _应用配置(新空目标库.工厂, 目标raw, 目标drafts)
|
||
|
||
monkeypatch.setattr(
|
||
备份恢复,
|
||
"_核对安装",
|
||
lambda 配置: {"发布身份": "wrong-release", "构建身份": "0" * 64},
|
||
)
|
||
with pytest.raises(备份恢复错误, match="构建身份不属于此备份"):
|
||
恢复备份(备份目录, 目标配置, 清单["backup_id"], pg_restore命令="never")
|
||
assert not 目标raw.exists() and not 目标drafts.exists()
|
||
_断言目标尚无迁移表(新空目标库.工厂)
|
||
|
||
|
||
@pytest.mark.case_id(
|
||
"TC-7c7fadfd3680",
|
||
environment="隔离 PostgreSQL 与临时目录",
|
||
when="备份、校验、恢复或模拟切换失败。",
|
||
contract="docs/系统架构/新版设计/迁移与验收.md",
|
||
)
|
||
def test_占用的恢复文件目标在破坏性写入前拒绝__7c7fad(
|
||
应用测试库: dict, 新空目标库: SimpleNamespace, tmp_path: Path, monkeypatch
|
||
) -> None:
|
||
来源工厂 = 应用测试库[用途.维护]
|
||
raw, drafts = _准备文件(tmp_path, "occupied-target")
|
||
备份目录 = tmp_path / "occupied-target-backup"
|
||
清单 = 创建备份(_应用配置(来源工厂, raw, drafts), 备份目录)
|
||
占用根 = tmp_path / "occupied-target-root"
|
||
占用根.mkdir()
|
||
目标配置 = _应用配置(新空目标库.工厂, 占用根, tmp_path / "occupied-target-drafts")
|
||
动作 = _记录客户端动作(monkeypatch)
|
||
with pytest.raises(备份恢复错误, match="恢复文件目标必须全新"):
|
||
恢复备份(备份目录, 目标配置, 清单["backup_id"], pg_restore命令="should-not-run")
|
||
assert "数据库恢复" not in 动作
|
||
_断言目标尚无迁移表(新空目标库.工厂)
|
||
|
||
|
||
@pytest.mark.case_id(
|
||
"TC-2b32b67a3482",
|
||
environment="隔离 PostgreSQL 与临时目录",
|
||
when="备份、校验、恢复或模拟切换失败。",
|
||
contract="docs/系统架构/新版设计/迁移与验收.md",
|
||
)
|
||
def test_恢复要求资源发布身份与安装一致并在连接前拒绝__2b32b6(
|
||
应用测试库: dict, 新空目标库: SimpleNamespace, tmp_path: Path, monkeypatch
|
||
) -> None:
|
||
来源工厂 = 应用测试库[用途.维护]
|
||
raw, drafts = _准备文件(tmp_path, "code-identity")
|
||
备份目录 = tmp_path / "code-identity-backup"
|
||
清单 = 创建备份(_应用配置(来源工厂, raw, drafts), 备份目录)
|
||
目标raw, 目标drafts = tmp_path / "code-id-raw", tmp_path / "code-id-drafts"
|
||
错误配置 = 应用配置(
|
||
新空目标库.工厂.引用,
|
||
"wrong-build-identity",
|
||
运行用途=用途.维护,
|
||
原文暂存=str(目标raw),
|
||
探索草稿=str(目标drafts),
|
||
)
|
||
动作 = _记录客户端动作(monkeypatch)
|
||
with pytest.raises(备份恢复错误, match="当前程序、资源或配置不一致"):
|
||
恢复备份(备份目录, 错误配置, 清单["backup_id"], pg_restore命令="never")
|
||
assert 动作 == []
|
||
assert not 目标raw.exists() and not 目标drafts.exists()
|
||
_断言目标尚无迁移表(新空目标库.工厂)
|
||
|
||
|
||
@pytest.mark.case_id(
|
||
"TC-863e5ced39df",
|
||
environment="隔离 PostgreSQL 与临时目录",
|
||
when="备份、校验、恢复或模拟切换失败。",
|
||
contract="docs/系统架构/新版设计/迁移与验收.md",
|
||
)
|
||
def test_备份确认不匹配不创建业务连接__863e5c(
|
||
应用测试库: dict, 新空目标库: SimpleNamespace, tmp_path: Path, monkeypatch
|
||
) -> None:
|
||
来源工厂 = 应用测试库[用途.维护]
|
||
raw, drafts = _准备文件(tmp_path, "confirm")
|
||
备份目录 = tmp_path / "confirm-backup"
|
||
清单 = 创建备份(_应用配置(来源工厂, raw, drafts), 备份目录)
|
||
目标raw, 目标drafts = tmp_path / "confirm-target-raw", tmp_path / "confirm-target-drafts"
|
||
目标配置 = _应用配置(新空目标库.工厂, 目标raw, 目标drafts)
|
||
动作 = _记录客户端动作(monkeypatch)
|
||
with pytest.raises(备份恢复错误, match="预期备份ID"):
|
||
恢复备份(备份目录, 目标配置, "bkp-" + "0" * 40, pg_restore命令="never")
|
||
assert 动作 == []
|
||
assert not 目标raw.exists() and not 目标drafts.exists()
|
||
_断言目标尚无迁移表(新空目标库.工厂)
|
||
assert 清单["backup_id"].startswith("bkp-")
|
||
|
||
|
||
@pytest.mark.case_id(
|
||
"TC-c2a43647c057",
|
||
environment="隔离 PostgreSQL 与临时目录",
|
||
when="备份、校验、恢复或模拟切换失败。",
|
||
contract="docs/系统架构/新版设计/迁移与验收.md",
|
||
)
|
||
def test_备份迁移与安装清单漂移在恢复任何客户端前拒绝__c2a436(
|
||
应用测试库: dict, 新空目标库: SimpleNamespace, tmp_path: Path, monkeypatch
|
||
) -> None:
|
||
"""正文/字段/数据库身份类漂移由迁移版本与校验和清单承接:不一致在连接前拒绝。"""
|
||
来源工厂 = 应用测试库[用途.维护]
|
||
raw, drafts = _准备文件(tmp_path, "migration-drift")
|
||
备份目录 = tmp_path / "migration-drift-backup"
|
||
清单 = 创建备份(_应用配置(来源工厂, raw, drafts), 备份目录)
|
||
目标raw, 目标drafts = (
|
||
tmp_path / "migration-drift-target-raw",
|
||
tmp_path / "migration-drift-target-drafts",
|
||
)
|
||
目标配置 = _应用配置(新空目标库.工厂, 目标raw, 目标drafts)
|
||
monkeypatch.setattr(
|
||
备份恢复,
|
||
"_安装迁移清单",
|
||
lambda: [{"version": 999, "name": "V999__fake.sql", "checksum": "0" * 64}],
|
||
)
|
||
动作 = _记录客户端动作(monkeypatch)
|
||
with pytest.raises(备份恢复错误, match="迁移版本或校验和"):
|
||
恢复备份(备份目录, 目标配置, 清单["backup_id"], pg_restore命令="never")
|
||
assert 动作 == []
|
||
assert not 目标raw.exists() and not 目标drafts.exists()
|
||
_断言目标尚无迁移表(新空目标库.工厂)
|
||
|
||
|
||
@pytest.mark.case_id(
|
||
"TC-a0e1e2234d2d",
|
||
environment="隔离 PostgreSQL 与临时目录",
|
||
when="备份、校验、恢复或模拟切换失败。",
|
||
contract="docs/系统架构/新版设计/迁移与验收.md",
|
||
)
|
||
def test_缺少可用备份在取锁连接前失败__a0e1e2(
|
||
应用测试库: dict, 新空目标库: SimpleNamespace, tmp_path: Path, monkeypatch
|
||
) -> None:
|
||
raw, drafts = _准备文件(tmp_path, "missing-archive")
|
||
目标raw, 目标drafts = tmp_path / "missing-raw", tmp_path / "missing-drafts"
|
||
目标配置 = _应用配置(新空目标库.工厂, 目标raw, 目标drafts)
|
||
动作 = _记录客户端动作(monkeypatch)
|
||
with pytest.raises(备份恢复错误, match="无法核对"):
|
||
恢复备份(tmp_path / "不存在备份", 目标配置, "bkp-" + "0" * 40, pg_restore命令="never")
|
||
assert 动作 == []
|
||
assert not 目标raw.exists() and not 目标drafts.exists()
|
||
|
||
|
||
@pytest.mark.case_id(
|
||
"TC-c8063c15e318",
|
||
environment="隔离 PostgreSQL 与临时目录",
|
||
when="备份、校验、恢复或模拟切换失败。",
|
||
contract="docs/系统架构/新版设计/迁移与验收.md",
|
||
)
|
||
def test_恢复在并发维护锁下不执行数据库客户端__c8063c(
|
||
应用测试库: dict, 新空目标库: SimpleNamespace, tmp_path: Path, monkeypatch
|
||
) -> None:
|
||
来源工厂 = 应用测试库[用途.维护]
|
||
_建立类型探针(来源工厂)
|
||
raw, drafts = _准备文件(tmp_path, "lock")
|
||
备份目录 = tmp_path / "lock-backup"
|
||
清单 = 创建备份(_应用配置(来源工厂, raw, drafts), 备份目录)
|
||
目标raw, 目标drafts = tmp_path / "lock-target-raw", tmp_path / "lock-target-drafts"
|
||
目标配置 = _应用配置(新空目标库.工厂, 目标raw, 目标drafts)
|
||
动作 = _记录客户端动作(monkeypatch)
|
||
with 新空目标库.工厂.连接() as 连, 连.transaction():
|
||
持锁 = 连.execute("SELECT pg_try_advisory_lock(%s)", (迁移维护锁,)).fetchone()[0]
|
||
assert 持锁
|
||
with pytest.raises(备份恢复错误, match="维护锁"):
|
||
恢复备份(备份目录, 目标配置, 清单["backup_id"], pg_restore命令="never")
|
||
assert "数据库恢复" not in 动作
|
||
assert not 目标raw.exists()
|
||
|
||
|
||
@pytest.mark.case_id(
|
||
"TC-74a6ec3a8017",
|
||
environment="隔离 PostgreSQL 与临时目录",
|
||
when="备份、校验、恢复或模拟切换失败。",
|
||
contract="docs/系统架构/新版设计/迁移与验收.md",
|
||
)
|
||
def test_非维护用途在创建备份前拒绝__74a6ec(应用测试库: dict, tmp_path: Path) -> None:
|
||
raw, drafts = _准备文件(tmp_path, "purpose")
|
||
生产配置 = 应用配置(
|
||
应用测试库[用途.生产].引用,
|
||
_资源身份(),
|
||
运行用途=用途.生产,
|
||
原文暂存=str(raw),
|
||
探索草稿=str(drafts),
|
||
)
|
||
with pytest.raises(备份恢复错误, match="maintenance"):
|
||
创建备份(生产配置, tmp_path / "purpose-backup", pg_dump命令="never")
|
||
assert not (tmp_path / "purpose-backup").exists()
|
||
|
||
|
||
@pytest.mark.case_id(
|
||
"TC-b40e21e62183",
|
||
environment="隔离 PostgreSQL 与临时目录",
|
||
when="备份、校验、恢复或模拟切换失败。",
|
||
contract="docs/系统架构/新版设计/迁移与验收.md",
|
||
)
|
||
def test_磁盘归档摘要漂移在恢复任何写入前失败__b40e21(
|
||
应用测试库: dict, 新空目标库: SimpleNamespace, tmp_path: Path
|
||
) -> None:
|
||
来源工厂 = 应用测试库[用途.维护]
|
||
raw, drafts = _准备文件(tmp_path, "drift")
|
||
备份目录 = tmp_path / "drift-backup"
|
||
清单 = 创建备份(_应用配置(来源工厂, raw, drafts), 备份目录)
|
||
目标raw, 目标drafts = tmp_path / "drift-target-raw", tmp_path / "drift-target-drafts"
|
||
目标配置 = _应用配置(新空目标库.工厂, 目标raw, 目标drafts)
|
||
原文 = 备份目录 / "files" / "data" / "raw" / "供应方原文.bin"
|
||
原文.write_bytes(原文.read_bytes() + b"changed")
|
||
with pytest.raises(备份恢复错误):
|
||
恢复备份(备份目录, 目标配置, 清单["backup_id"], pg_restore命令="never")
|
||
assert not 目标raw.exists() and not 目标drafts.exists()
|
||
_断言目标尚无迁移表(新空目标库.工厂)
|
||
|
||
|
||
@pytest.mark.case_id(
|
||
"TC-6740be8d17a3",
|
||
environment="隔离 PostgreSQL 与临时目录",
|
||
when="备份、校验、恢复或模拟切换失败。",
|
||
contract="docs/系统架构/新版设计/迁移与验收.md",
|
||
)
|
||
def test_序列恢复只向前且保持确认__6740be(
|
||
应用测试库: dict, 新空目标库: SimpleNamespace, tmp_path: Path
|
||
) -> None:
|
||
来源工厂 = 应用测试库[用途.维护]
|
||
_建立类型探针(来源工厂)
|
||
raw, drafts = _准备文件(tmp_path, "sequence")
|
||
备份目录 = tmp_path / "sequence-backup"
|
||
清单 = 创建备份(_应用配置(来源工厂, raw, drafts), 备份目录)
|
||
目标raw, 目标drafts = tmp_path / "sequence-target-raw", tmp_path / "sequence-target-drafts"
|
||
目标配置 = _应用配置(新空目标库.工厂, 目标raw, 目标drafts)
|
||
恢复备份(备份目录, 目标配置, 清单["backup_id"])
|
||
with 新空目标库.工厂.连接() as 连, 连.transaction():
|
||
语句 = 连.execute(
|
||
"SELECT last_value, is_called FROM public.w29_seq_probe_id_seq"
|
||
).fetchone()
|
||
assert 语句 == (3, True)
|
||
连.execute("INSERT INTO public.w29_seq_probe(note) VALUES ('丁')")
|
||
assert 连.execute("SELECT max(id) FROM public.w29_seq_probe").fetchone() == (4,)
|
||
|
||
|
||
@pytest.mark.case_id(
|
||
"TC-d07d1f9ee590",
|
||
environment="隔离 PostgreSQL 与临时目录",
|
||
when="备份、校验、恢复或模拟切换失败。",
|
||
contract="docs/系统架构/新版设计/迁移与验收.md",
|
||
)
|
||
def test_恢复后复核失败清理文件根并报错__d07d1f(
|
||
应用测试库: dict, 新空目标库: SimpleNamespace, tmp_path: Path, monkeypatch
|
||
) -> None:
|
||
来源工厂 = 应用测试库[用途.维护]
|
||
_建立类型探针(来源工厂)
|
||
raw, drafts = _准备文件(tmp_path, "postcheck")
|
||
备份目录 = tmp_path / "postcheck-backup"
|
||
清单 = 创建备份(_应用配置(来源工厂, raw, drafts), 备份目录)
|
||
目标raw, 目标drafts = tmp_path / "postcheck-target-raw", tmp_path / "postcheck-target-drafts"
|
||
目标配置 = _应用配置(新空目标库.工厂, 目标raw, 目标drafts)
|
||
原摘要 = 备份恢复._数据库摘要
|
||
|
||
def 漂移(连接, 关系):
|
||
库 = 原摘要(连接, 关系)
|
||
if "table_data" in 库:
|
||
库 = json.loads(json.dumps(库))
|
||
条目 = dict(库["table_data"][0])
|
||
条目["sha256"] = "0" * 64
|
||
库["table_data"] = [条目]
|
||
return 库
|
||
|
||
monkeypatch.setattr(备份恢复, "_数据库摘要", 漂移)
|
||
with pytest.raises(备份恢复错误, match="摘要不一致"):
|
||
恢复备份(备份目录, 目标配置, 清单["backup_id"])
|
||
assert not 目标raw.exists() and not 目标drafts.exists()
|