268 lines
13 KiB
Python
268 lines
13 KiB
Python
"""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"
|
||
)
|