test: prove old telemetry tables gain the tenant column safely

This commit is contained in:
2026-08-17 11:49:07 -04:00
parent 6ad58a6553
commit d553d142c3
2 changed files with 360 additions and 0 deletions
@@ -390,3 +390,237 @@ class TestLeastPrivilegeDeployment:
assert schema # teardown 会连表带角色删净
finally:
await recorder.aclose()
# issue #11 之前的表形态: 22 个 recorder 字段 + created_at = 23 个物理列,没有任何租户维度
_PRE_TENANT_DDL = """
CREATE TABLE {schema}.llm_calls (
call_id TEXT PRIMARY KEY,
parent_call_id TEXT,
session_id TEXT,
model TEXT NOT NULL,
provider TEXT NOT NULL,
source_name TEXT NOT NULL,
messages TEXT NOT NULL,
response TEXT NOT NULL,
thinking TEXT NOT NULL DEFAULT '',
prompt_tokens INTEGER NOT NULL,
completion_tokens INTEGER NOT NULL,
usage_source TEXT NOT NULL,
latency_ms INTEGER NOT NULL,
ttft_ms DOUBLE PRECISION,
max_inter_token_ms DOUBLE PRECISION,
cache_hit BOOLEAN NOT NULL DEFAULT FALSE,
error TEXT,
cost DOUBLE PRECISION,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
cached_prompt_tokens INTEGER,
model_reported TEXT,
sampling TEXT,
reasoning_tokens INTEGER
)
"""
_PRE_TENANT_INSERT = (
"INSERT INTO {schema}.llm_calls (call_id, model, provider, source_name, messages, response, "
"prompt_tokens, completion_tokens, usage_source, latency_ms) "
"VALUES ($1, 'm', 'p', 's1', '[]', 'contract text', 1, 2, 'measured', 10)"
)
def _search_path_dsn(dsn: str, schema: str) -> str:
sep = "&" if "?" in dsn else "?"
return f"{dsn}{sep}options=-csearch_path%3D{schema}"
@pytest.fixture
async def warnings():
"""捕获库发出的 WARNING;loguru 不经标准 logging,pytest 的 caplog 抓不到。"""
from loguru import logger
messages: list[str] = []
sink_id = logger.add(messages.append, level="WARNING")
yield messages
logger.remove(sink_id)
@pytest.fixture
async def pre_tenant_schema(dsn):
"""自建临时 schema 里造一张 **22 字段的 issue #11 之前的表**,并留一行历史数据。
绝不碰共享的 public.llm_calls——本机那张表早已被 `_BACKFILL` 真实补过列,
指望它还是旧形态的测试第二次跑就会空转。schema 名带 uuid,可重复运行。
"""
import asyncpg
name = f"pgwtest_pre_{uuid4().hex[:8]}"
conn = await asyncpg.connect(dsn, timeout=10)
try:
await conn.execute(f"CREATE SCHEMA {name}")
await conn.execute(_PRE_TENANT_DDL.format(schema=name))
await conn.execute(_PRE_TENANT_INSERT.format(schema=name), _cid("old"))
finally:
await conn.close()
yield _search_path_dsn(dsn, name), name
conn = await asyncpg.connect(dsn, timeout=10)
try:
await conn.execute(f"DROP SCHEMA {name} CASCADE")
finally:
await conn.close()
@pytest.fixture
async def fresh_schema(dsn):
"""空 schema: recorder 自己建表,验"新建库"这条路径而不依赖共享表的历史状态。"""
import asyncpg
name = f"pgwtest_new_{uuid4().hex[:8]}"
conn = await asyncpg.connect(dsn, timeout=10)
try:
await conn.execute(f"CREATE SCHEMA {name}")
finally:
await conn.close()
yield _search_path_dsn(dsn, name), name
conn = await asyncpg.connect(dsn, timeout=10)
try:
await conn.execute(f"DROP SCHEMA {name} CASCADE")
finally:
await conn.close()
@pytest.fixture
async def least_privilege_pre_tenant_dsn(dsn):
"""22 字段旧表 + 只有 `SELECT, INSERT` 权限的角色: 补列必然失败的现场。
与 `least_privilege_dsn` 分开而非复用: 那个 fixture 建的是列已齐全的当前表
(测的是 CREATE 被拒),这里必须是缺列的旧表,才能让 `ALTER TABLE` 真的发出去
并撞上 ownership 检查(该检查早于 `IF NOT EXISTS` 的存在性判断)。
"""
import asyncpg
name = f"pgwtest_lppre_{uuid4().hex[:8]}"
admin = await asyncpg.connect(dsn, timeout=10)
try:
if not await admin.fetchval(
"SELECT rolcreaterole OR rolsuper FROM pg_roles WHERE rolname = current_user"
):
pytest.skip("当前账号无权建临时角色,跳过最小权限用例")
await admin.execute(f"CREATE ROLE {name} LOGIN PASSWORD '{_PROBE_PASSWORD}'")
await admin.execute(f"CREATE SCHEMA {name}")
await admin.execute(_PRE_TENANT_DDL.format(schema=name)) # 表属主是 admin,不是应用账号
await admin.execute(f"GRANT USAGE ON SCHEMA {name} TO {name}")
await admin.execute(f"GRANT SELECT, INSERT ON {name}.llm_calls TO {name}")
finally:
await admin.close()
low = re.sub(r"//[^@/]+@", f"//{name}:{_PROBE_PASSWORD}@", dsn, count=1)
yield _search_path_dsn(low, name)
admin = await asyncpg.connect(dsn, timeout=10)
try:
await admin.execute(f"DROP SCHEMA IF EXISTS {name} CASCADE")
await admin.execute(f"DROP OWNED BY {name}")
await admin.execute(f"DROP ROLE IF EXISTS {name}")
finally:
await admin.close()
class TestCallerDimensionsAcceptance:
"""issue #11 的机械化验收(PG 侧,真实实例): 新建库 / 旧表补列 / 补列失败方向。"""
async def test_fresh_schema_round_trips_the_dimensions(self, fresh_schema):
"""新建库: 列齐全,且维度值原样读回——只验列存在会漏掉写错列位的错。"""
fresh_dsn, schema = fresh_schema
recorder = PostgresRecorder(fresh_dsn)
try:
await _record_minimal(
recorder, call_id=_cid("dim"), tenant_id="tenant-a", meta='{"batch": "b7"}'
)
cols = await _fetch(
fresh_dsn,
"SELECT column_name FROM information_schema.columns "
"WHERE table_schema = $1 AND table_name = 'llm_calls' ORDER BY ordinal_position",
schema,
)
assert [r["column_name"] for r in cols] == _EXPECTED_COLUMNS
rows = await _fetch(
fresh_dsn,
"SELECT tenant_id, meta FROM llm_calls WHERE call_id = $1",
_cid("dim"),
)
assert rows[0]["tenant_id"] == "tenant-a"
assert json.loads(rows[0]["meta"]) == {"batch": "b7"}
finally:
await recorder.aclose()
async def test_pre_tenant_table_gains_columns_and_old_rows_stay_auditable(
self, pre_tenant_schema
):
"""22 字段旧表补列后,新行带维度,而**老行的 tenant_id 是空串而非 NULL**。
这条直接验收 issue #11 的核心论点(先启用落库、后加列,补列之前的行没有
租户归属)。断言方向必须是空串: PG 的 RLS `USING` 表达式对返回 false **或
NULL** 的行一律隐藏且不报错,故 NULL 的 `tenant_id` 不是"未归属",而是对
所有人永久不可见的黑洞;哨兵空串则能被一条 `COUNT(*) WHERE tenant_id = ''`
审计出来,历史欠账是可见、可量化、可补录的。
"""
schema_dsn, schema = pre_tenant_schema
recorder = PostgresRecorder(schema_dsn)
try:
await _record_minimal(
recorder, call_id=_cid("new"), tenant_id="tenant-a", meta='{"k": 1}'
)
cols = await _fetch(
schema_dsn,
"SELECT column_name FROM information_schema.columns "
"WHERE table_schema = $1 AND table_name = 'llm_calls' ORDER BY ordinal_position",
schema,
)
# 22 → 24 个 recorder 字段(加 created_at 共 25 个物理列),且新列追加在末尾
assert [r["column_name"] for r in cols] == _EXPECTED_COLUMNS
rows = await _fetch(
schema_dsn,
"SELECT call_id, tenant_id, meta FROM llm_calls "
"WHERE call_id = ANY($1::text[]) ORDER BY call_id",
[_cid("new"), _cid("old")],
)
by_id = {r["call_id"]: r for r in rows}
assert by_id[_cid("new")]["tenant_id"] == "tenant-a"
assert json.loads(by_id[_cid("new")]["meta"]) == {"k": 1}
assert by_id[_cid("old")]["tenant_id"] == "" # 不是 None: NULL 会被 RLS 静默吞掉
assert json.loads(by_id[_cid("old")]["meta"]) == {}
finally:
await recorder.aclose()
async def test_alter_is_denied_for_a_role_that_can_still_insert(
self, least_privilege_pre_tenant_dsn
):
"""库外事实先钉死: 表存在、写得进去,补列的 ALTER 仍被拒(ownership 检查早于存在性判断)。
没有这条,下面那个降级用例可能因为 ALTER 其实成功了而变成"永远通过"的空断言。
"""
import asyncpg
conn = await asyncpg.connect(least_privilege_pre_tenant_dsn, timeout=10)
try:
assert await conn.fetchval("SELECT to_regclass('llm_calls')") is not None
with pytest.raises(asyncpg.exceptions.InsufficientPrivilegeError):
await conn.execute("ALTER TABLE llm_calls ADD COLUMN IF NOT EXISTS tenant_id TEXT")
finally:
await conn.close()
async def test_backfill_failure_degrades_per_row_not_wholesale(
self, least_privilege_pre_tenant_dsn, warnings
):
"""补列失败的降级方向: 记 warning、不置 `_failed`、后续 INSERT 仍照发。
置 `_failed` 会让整个进程从此一条遥测都不写(比逐行丢弃严重得多),
且一旦 DBA 补上列也不会自愈——必须等重启。
"""
recorder = PostgresRecorder(least_privilege_pre_tenant_dsn)
try:
await _record_minimal(recorder, call_id=_cid("lpp1")) # 不得抛
assert recorder._failed is False
assert any("补列失败" in m for m in warnings)
# 缺列的表上 INSERT 必然失败;逐行 warning 正是"INSERT 照发了"的证据
assert any("写入失败" in m for m in warnings)
finally:
await recorder.aclose()