muse-agent-example/tests/集成/test_数据库连接与迁移.py
zizi 73f96255af W03 数据库、用途隔离与迁移机制:受控连接、用途角色隔离、Flyway 式迁移执行与隔离库夹具。
按 R2 串行阶段整理提交;包内文件为该阶段交付(含后续小增量),状态以工作包清单为准。
2026-09-10 19:25:40 +08:00

268 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""W03 的真实连接、权限、事务与迁移验证;仅使用显式隔离 PostgreSQL。"""
from __future__ import annotations
import hashlib
import uuid
from collections.abc import Iterator
from pathlib import Path
import psycopg
import pytest
from psycopg import sql
from muse.__main__ import main
from muse.共享.调用身份 import 用途
from muse.共享.错误 import 环境缺失错误
from muse.启动 import 构建
from muse.基础设施.数据库.事务 import 事务, 事务失败, 保存点
from muse.基础设施.数据库.用途隔离 import 用途越权
from muse.基础设施.数据库.迁移 import 已应用版本, 执行迁移, 迁移错误
from muse.基础设施.数据库.连接 import 解析连接串, 连接
from muse.配置 import 应用配置, 数据库引用
迁移目录 = Path(__file__).resolve().parents[2] / "数据库" / "迁移"
角色表 = {用途.生产: "muse_app", 用途.评测: "muse_eval", 用途.维护: "muse_maint"}
@pytest.fixture
def 用途角色(隔离数据库URL: str) -> None:
"""隔离集群管理员只创建三个普通角色,不把管理员作为应用角色。"""
with psycopg.connect(隔离数据库URL, autocommit=True) as 管理员:
初始化文件 = 迁移目录.parent / "初始化" / "用途角色.sql"
管理员.execute(初始化文件.read_text(encoding="utf-8"))
@pytest.fixture
def 临时库(
隔离数据库URL: str, 用途角色: None, monkeypatch: pytest.MonkeyPatch
) -> Iterator[dict[用途, 数据库引用]]:
"""每例独占一库;维护角色拥有目标库,退出后由隔离管理员销毁。"""
库名 = f"muse_case_{uuid.uuid4().hex[:12]}"
with psycopg.connect(隔离数据库URL, autocommit=True) as 管理员:
管理员.execute(
sql.SQL("CREATE DATABASE {} OWNER muse_maint TEMPLATE template0").format(
sql.Identifier(库名)
)
)
引用表 = {}
try:
for 声明用途, 角色 in 角色表.items():
参数 = psycopg.conninfo.conninfo_to_dict(隔离数据库URL)
参数.update(dbname=库名, user=角色)
环境名 = f"MUSE_CASE_{声明用途.name}_URL"
monkeypatch.setenv(环境名, psycopg.conninfo.make_conninfo(**参数))
引用表[声明用途] = 数据库引用("环境变量", 环境名)
yield 引用表
finally:
with psycopg.connect(隔离数据库URL, autocommit=True) as 管理员:
管理员.execute(sql.SQL("DROP DATABASE {} WITH (FORCE)").format(sql.Identifier(库名)))
def 维护连接(临时库: dict[用途, 数据库引用], **参数: object) -> psycopg.Connection:
return 连接(临时库[用途.维护], 用途标记=用途.维护, **参数)
def test_缺配置拒绝回退默认库__d10001(monkeypatch: pytest.MonkeyPatch) -> None:
"""given 缺少连接引用;when 装配连接;then 拒绝默认库回退。"""
monkeypatch.delenv("MUSE_MISSING_DATABASE_URL", raising=False)
配置 = 应用配置(数据库引用("环境变量", "MUSE_MISSING_DATABASE_URL"), "test")
with pytest.raises(环境缺失错误, match="拒绝回退"):
构建(配置).要求数据库().连接()
@pytest.mark.数据库
@pytest.mark.parametrize(
"options", ["", "-c application_name=readonly-probe"], ids=["default", "options"]
)
def test_只读连接拒绝写__d50005(临时库: dict[用途, 数据库引用], options: str) -> None:
"""given 普通连接选项;when 请求只读;then 数据库拒绝写入。"""
with 维护连接(临时库, 只读=True, options=options) as 连:
with pytest.raises(psycopg.errors.ReadOnlySqlTransaction):
连.execute("CREATE TABLE readonly_probe (id int)")
连.rollback()
@pytest.mark.数据库
@pytest.mark.parametrize("autocommit", [False, True], ids=["manual", "auto"])
def test_事务回滚不留半成品__d60006(临时库: dict[用途, 数据库引用], autocommit: bool) -> None:
"""given 两种提交模式;when 第二参与者失败;then 全部写入回滚。"""
with 维护连接(临时库, autocommit=autocommit) as 连:
with pytest.raises(事务失败) as 记录:
with 事务(连):
连.execute("CREATE TABLE participant_one (id int)")
连.execute("CREATE TABLE participant_two (id int)")
raise RuntimeError("第二参与者失败,正文与凭据不得被拼入错误")
assert "正文与凭据" not in str(记录.value.呈现())
assert (
连.execute(
"SELECT count(*) FROM information_schema.tables "
"WHERE table_name IN ('participant_one', 'participant_two')"
).fetchone()[0]
== 0
)
@pytest.mark.数据库
@pytest.mark.parametrize(
("登录用途", "声明用途"),
[(角色, 声明) for 角色 in 用途 for 声明 in 用途 if 角色 != 声明],
ids=[f"{角色.value}-as-{声明.value}" for 角色 in 用途 for 声明 in 用途 if 角色 != 声明],
)
def test_用途角色不匹配被拒__d70007(
临时库: dict[用途, 数据库引用], 登录用途: 用途, 声明用途: 用途
) -> None:
"""given 固定普通角色;when 正式连接入口声明其他用途;then 连接被拒。"""
with pytest.raises(用途越权):
连接(临时库[登录用途], 用途标记=声明用途)
with 连接(临时库[登录用途], 用途标记=登录用途) as 连:
assert 连.execute("SELECT current_user").fetchone()[0] == 角色表[登录用途]
@pytest.mark.数据库
@pytest.mark.parametrize("目标表", ["answer", "blind_assignment"], ids=["oracle", "blind-map"])
def test_生产用途拒绝oracle及匿名映射读取__d80008(
临时库: dict[用途, 数据库引用], 目标表: str
) -> None:
"""given 维护角色建立答案或匿名映射;when 生产连接查询;then PG 拒权。"""
目标 = sql.Identifier("oracle", 目标表)
with 维护连接(临时库) as 连:
执行迁移(连, 迁移目录, 目标版本=1)
连.execute(sql.SQL("CREATE TABLE {} (id int)").format(目标))
连.execute(sql.SQL("INSERT INTO {} VALUES (1)").format(目标))
with 连接(临时库[用途.生产], 用途标记=用途.生产) as 连:
with pytest.raises(psycopg.errors.InsufficientPrivilege):
连.execute(sql.SQL("SELECT * FROM {}").format(目标))
连.rollback()
with 连接(临时库[用途.评测], 用途标记=用途.评测) as 连:
assert 连.execute(sql.SQL("SELECT id FROM {}").format(目标)).fetchone()[0] == 1
@pytest.mark.数据库
@pytest.mark.parametrize("autocommit", [False, True], ids=["manual", "auto"])
def test_保存点局部失败保留外层事务__d90009(
临时库: dict[用途, 数据库引用], autocommit: bool
) -> None:
"""given 外层事务已有写入;when 保存点失败;then 局部回滚且外层可提交。"""
with 维护连接(临时库, autocommit=autocommit) as 连:
with 事务(连):
连.execute("CREATE TABLE savepoint_probe (id int)")
连.execute("INSERT INTO savepoint_probe VALUES (1)")
with pytest.raises(RuntimeError):
with 保存点(连, '中文保存点"'):
连.execute("INSERT INTO savepoint_probe VALUES (2)")
raise RuntimeError("局部失败")
连.execute("INSERT INTO savepoint_probe VALUES (3)")
assert 连.execute("SELECT id FROM savepoint_probe ORDER BY id").fetchall() == [(1,), (3,)]
with 维护连接(临时库) as 再读:
assert 再读.execute("SELECT count(*) FROM savepoint_probe").fetchone()[0] == 2
@pytest.mark.数据库
def test_评测角色仅可写评测对象__da000a(临时库: dict[用途, 数据库引用]) -> None:
"""given 生产与评测对象;when 评测连接写入;then 仅评测对象可写。"""
with 维护连接(临时库) as 连:
执行迁移(连, 迁移目录, 目标版本=1)
连.execute("CREATE TABLE public.production_probe (id int)")
连.execute("CREATE TABLE evaluation.sample_probe (id int)")
with 连接(临时库[用途.生产], 用途标记=用途.生产) as 连:
连.execute("INSERT INTO public.production_probe VALUES (1)")
with 连接(临时库[用途.评测], 用途标记=用途.评测) as 连:
with pytest.raises(psycopg.errors.InsufficientPrivilege):
连.execute("INSERT INTO public.production_probe VALUES (2)")
连.rollback()
连.execute("INSERT INTO evaluation.sample_probe VALUES (3)")
assert 连.execute("SELECT id FROM evaluation.sample_probe").fetchone()[0] == 3
with 维护连接(临时库) as 连:
assert 连.execute("SELECT id FROM public.production_probe").fetchall() == [(1,)]
@pytest.mark.数据库
def test_迁移幂等与篡改拦截__d20002(临时库: dict[用途, 数据库引用], tmp_path: Path) -> None:
"""given 已登记迁移;when 重跑或篡改同版本;then 幂等跳过或拒绝。"""
with 维护连接(临时库) as 连:
assert [项.版本 for 项 in 执行迁移(连, 迁移目录, 目标版本=1)] == [1]
assert len(已应用版本(连)[1]) == 64
assert 执行迁移(连, 迁移目录, 目标版本=1) == []
(tmp_path / "V0001__共享标识与版本.sql").write_text("-- 改动\n", encoding="utf-8")
with pytest.raises(迁移错误, match="校验和"):
执行迁移(连, tmp_path)
@pytest.mark.数据库
def test_迁移失败不登记且可重跑__d30003(临时库: dict[用途, 数据库引用], tmp_path: Path) -> None:
"""given 两个版本;when 第二版本失败;then 仅第一版入账且修复可重跑。"""
(tmp_path / "V0001__共享标识与版本.sql").write_bytes(
(迁移目录 / "V0001__共享标识与版本.sql").read_bytes()
)
V2 = tmp_path / "V0002__迁移.sql"
V2.write_text("CREATE TABLE migration_probe (id int);\n这不是合法 SQL;\n", encoding="utf-8")
with 维护连接(临时库) as 连:
with pytest.raises(迁移错误, match="未登记成功"):
执行迁移(连, tmp_path)
assert set(已应用版本(连)) == {1}
with 连.transaction():
assert 连.execute("SELECT to_regclass('public.migration_probe')").fetchone()[0] is None
V2.write_text("CREATE TABLE migration_probe (id int);\n", encoding="utf-8")
assert [项.版本 for 项 in 执行迁移(连, tmp_path)] == [2]
assert set(已应用版本(连)) == {1, 2}
@pytest.mark.数据库
def test_低版本补录被拒绝__d40004(临时库: dict[用途, 数据库引用], tmp_path: Path) -> None:
"""given 已登记较高版本;when 提供未应用低版本;then 拒绝补录。"""
with 维护连接(临时库) as 连:
执行迁移(连, 迁移目录, 目标版本=1)
with 连.transaction():
连.execute(
"INSERT INTO public.muse_migration (version, name, checksum) VALUES (%s, %s, %s)",
(3, "V0003__既往.sql", hashlib.sha256(b"past").hexdigest()),
)
(tmp_path / "V0002__补录.sql").write_text(
"CREATE TABLE backfill (id int);", encoding="utf-8"
)
with pytest.raises(迁移错误, match="低版本"):
执行迁移(连, tmp_path)
@pytest.mark.数据库
def test_迁移先取锁再判定账本__db000b(临时库: dict[用途, 数据库引用]) -> None:
"""given 一个进程持有迁移锁;when 另一连接重跑;then 不读取未保护账本。"""
with 维护连接(临时库, autocommit=True) as 持有者:
执行迁移(持有者, 迁移目录, 目标版本=1)
持有者.execute("SELECT pg_advisory_lock(%s)", (0x6D757365,))
with 维护连接(临时库) as 竞争者:
with pytest.raises(迁移错误, match="持有锁"):
执行迁移(竞争者, 迁移目录, 目标版本=1)
持有者.execute("SELECT pg_advisory_unlock(%s)", (0x6D757365,))
assert 执行迁移(竞争者, 迁移目录, 目标版本=1) == []
@pytest.mark.数据库
def test_迁移命令使用维护装配与包资源__dc000c(
临时库: dict[用途, 数据库引用], tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
"""given 用途配置;when CLI 迁移;then 生产被拒且维护从安装资源执行。"""
配置文件 = tmp_path / "连接.toml"
for 声明用途, 预期码 in [(用途.生产, 1), (用途.维护, 0)]:
配置文件.write_text(
'["数据库"]\n"取值方式" = "环境变量"\n'
f'"位置" = "{临时库[声明用途].位置}"\n'
'["资源"]\n"发布身份" = "test"\n'
f'["运行"]\n"用途" = "{声明用途.value}"\n',
encoding="utf-8",
)
assert main(["迁移", str(配置文件), "--目标版本", "1"]) == 预期码
assert "已应用 V0001" in capsys.readouterr().out
with 维护连接(临时库) as 连:
assert set(已应用版本(连)) == {1}
def test_数据库引用通过唯一凭据入口读取受控文件__dd000d(tmp_path: Path) -> None:
"""given 受控文件引用;when 解析连接;then 与环境引用共用读取入口。"""
文件 = tmp_path / "合成连接.txt"
文件.write_text("dbname=isolated_example user=muse_app\n", encoding="utf-8")
assert 解析连接串(数据库引用("受控存储", str(文件))) == (
"dbname=isolated_example user=muse_app"
)