Files
AgentLab/backend/tests/test_bundle_masked_cards.py
T
ThearchyHelios 8ede152b6f 可验证报告:口径卡取的列是 SQL 里改了名字的遮罩列时,整张卡同样只给哈希
口径卡上记着每个输入的值和代入式,有输入取自遮罩列的整张卡只给哈希;但以前只按列名认:查询写 phone AS tel、经子查询
或 WITH 转一道、套个函数,卡上取 tel 那一格就是号码,卡照样原样嵌进要发给别人的文件。现在那几列算不算遮罩列另按 SQL
认改了名字的(_Store.hidden → _derived_columns,用 inquiry._derived_hidden,和质询、双盲复算同一个认法;SQL 按每种
读得通的读法各认一遍合起来,都读不通的写着遮罩列就整份的列都算,出错时整份的列都算)。同一条查询里没取自遮罩列的
列照常嵌入。新测试覆盖别名、子查询、WITH 加函数三种改名,以及没改名的列、认的过程出错;每处都做过去掉实现测试变红的
验证。
2026-10-06 17:11:41 +08:00

221 lines
14 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.
"""可验证报告里的口径卡和遮罩(期 4 I2a 复核):口径卡上记着每个输入的值和代入式,有输入取自遮罩列时整张卡只给 id。
以前只有查询快照会因遮罩不随文件附带,口径卡一律原样嵌入:证据面板上已经遮住的号码(cell() 取的遮罩格、按位置取的
vars.res.rows[0][1]),在这份要发给别人的文件里照样看得到。认哪个输入取自哪份查询结果的哪几列,和证据面板、推演同一个
认法(recompute.input_origin);核对不了的宁可多挡。那几列算不算遮罩列,除了列名对上的,SQL 里给遮罩列起了别名、套了函数、
经子查询或 WITH 转了一道的也算(同质询、双盲复算的 inquiry._derived_hidden):卡上取的 tel 可能就是 phone AS tel。
服务端的卡文件被改过(哈希对不上)也照卡上记的输入认:嵌进去的是原字节,号码照样在里面。读得出字节、却解析不出一张卡
(截断的 JSON、不是对象)的,认不出输入,整张按带遮罩算、不嵌:和读不出来的快照按带遮罩算是同一个道理。
不嵌的工件,服务端把它知道的情况照实带上:自己的文件和工件 id(哈希)对不上的标 mismatch,解析不出来的标 unparsable,
核对页据此判不通过、照实说原因(以前原字节嵌进去,浏览器自己算出对不上;现在不嵌了,这个证据不能跟着丢)。带遮罩的
查询快照同样标。
工件、事件都是手搭的(会员、号码都是编的),照 tests/test_bundle_build.py 的样子。
"""
from __future__ import annotations
import base64
import pytest
from app.bundle.build import build_bundle, render_html
from app.core.artifact_store import content_hash
from tests.test_bundle_build import GRAPH, MASKED_ID, RUN, SNAP_MASKED, STORE, doc_of, stored
#: SNAP_MASKED 里的号码(phone 设了遮罩)
PHONE = SNAP_MASKED["rows"][0][1]
CELL = "cell(nodes.members, 0, 'phone')"
def card_with(inputs, substituted="len('000-0001')", expression=f"len({CELL})"):
"""会员号码位数一张卡:报告引用的是它(m:gmv 指过来)。"""
return {"kind": "metric_set", "caliber": "会员口径", "caliber_version": "v1", "metrics": [
{"id": "gmv", "name": "号码位数", "value": 1280.5, "unit": "元", "decimals": 1, "expression": expression,
"substituted": substituted, "inputs": inputs}]}
def bundle_of(card, *, outputs=None, source_masks=None, tamper=None, rehash=False, files_changed=None):
"""一次手搭的运行:会员查询(带遮罩的快照)→ 数据整形(outputs 里的产出)→ 口径卡 → 报告。tamper 给了就把服务端的
卡文件换成 tamper(原字节),id 照旧(文件被改过、哈希对不上);rehash 为真时 id 按换过的字节算(服务端存的就是这一份,
哈希对得上)。files_changed 换掉服务端别的文件({工件 id: 新字节},id 照旧)。"""
card_id, card_raw = stored(card)
if tamper is not None:
card_raw = tamper(card_raw)
if rehash:
card_id = content_hash(card_raw)
doc = doc_of(extra_catalog={"m:gmv": {"alias": "m:gmv", "kind": "metric", "artifact": card_id, "node_id": "card",
"locator": {"metric": "gmv"}, "value": 1280.5}})
doc_id, doc_raw = stored(doc)
files = {**STORE, card_id: card_raw, doc_id: doc_raw, **(files_changed or {})}
events = [
(1, "run.started", None, {"nodes": 7}),
(2, "tool.end", "members", {"tool": "db_query__crm", "query_artifact": MASKED_ID}),
(3, "node.finished", "members", {"evidence": [{"kind": "query", "node_id": "members", "artifact": MASKED_ID}]}),
]
for node_id, content in (outputs or {}).items():
artifact, raw = stored(content) if content is not None else ("ee" * 32, None)
if raw is not None:
files[artifact] = raw
events.append((len(events) + 1, "node.finished", node_id, {"artifact": artifact}))
events += [
(len(events) + 1, "node.started", "card", {}),
(len(events) + 2, "node.finished", "card", {"evidence": [{"kind": "metric_set", "node_id": "card",
"artifact": card_id}]}),
(len(events) + 3, "report.checked", "write", {"doc_artifact": doc_id, "ok": True}),
(len(events) + 4, "run.finished", None, {"output": {}}),
]
bundle = build_bundle(RUN, events, manifest_seq=len(events), manifest_hash="0" * 64, graph=GRAPH,
source_masks=source_masks or {}, read=files.get, exported_at="2026-10-06T08:00:00+00:00")
entry = next(a for a in bundle["artifacts"] if a["id"] == card_id)
return bundle, entry
def cell_input(column, value):
return {"path": f"cell(nodes.members, 0, '{column}')", "value": value, "node_id": "members", "via": "tool_cell",
"artifact": MASKED_ID, "locator": {"row": 0, "column": column}, "status": "verified"}
def test_a_card_reading_a_masked_cell_ships_only_its_id():
bundle, entry = bundle_of(card_with([cell_input("phone", PHONE)]))
assert entry == {"id": entry["id"], "kind": "metric_set", "node_id": "card", "masked": True}
assert PHONE not in render_html(bundle)
def test_a_card_reading_only_unmasked_columns_is_embedded():
"""同一份快照里没遮的列(消费额):卡上没有遮罩列的值,照常嵌入、照常核对。"""
card = card_with([cell_input("spend", 320.0)], substituted="320.0", expression="cell(nodes.members, 0, 'spend')")
_, entry = bundle_of(card)
assert "masked" not in entry and base64.b64decode(entry["b64"]) == stored(card)[1]
@pytest.mark.parametrize("sql", [
"SELECT name, phone AS tel, spend FROM members",
"SELECT name, tel, spend FROM (SELECT name, phone AS tel, spend FROM members) t",
"WITH c AS (SELECT name, SUBSTR(phone, 1) AS tel, spend FROM members) SELECT name, tel, spend FROM c",
])
def test_a_card_reading_a_renamed_masked_column_ships_only_its_id(sql):
"""查询给号码起了别名、经子查询或 WITH 转了一道:卡上取的 tel 那一格就是号码。改了名字的遮罩列按 SQL 认(同质询、
双盲复算的 inquiry._derived_hidden),整张卡只给 id。以前只按列名认,号码原样进了文件。"""
snap = {**SNAP_MASKED, "columns": ["name", "tel", "spend"], "sql": sql}
snap_id, raw = stored(snap)
item = {**cell_input("tel", PHONE), "artifact": snap_id, "path": "cell(nodes.members, 0, 'tel')"}
bundle, entry = bundle_of(card_with([item], expression="len(cell(nodes.members, 0, 'tel'))"),
files_changed={snap_id: raw})
assert entry.get("masked") is True and "b64" not in entry
assert PHONE not in render_html(bundle)
def test_a_card_reading_an_unrenamed_column_of_that_query_is_embedded():
"""同一条起了别名的查询,卡上取的是消费额(不取自号码):照常嵌入。"""
snap = {**SNAP_MASKED, "columns": ["name", "tel", "spend"], "sql": "SELECT name, phone AS tel, spend FROM members"}
snap_id, raw = stored(snap)
item = {**cell_input("spend", 320.0), "artifact": snap_id}
card = card_with([item], substituted="320.0", expression="cell(nodes.members, 0, 'spend')")
_, entry = bundle_of(card, files_changed={snap_id: raw})
assert "masked" not in entry and base64.b64decode(entry["b64"]) == stored(card)[1]
def test_an_error_while_finding_renamed_columns_masks_the_whole_snapshot(monkeypatch):
"""按 SQL 认改了名字的遮罩列时出了意外:认不出哪几列取自遮罩列,整份快照的列都按遮罩算,卡上取消费额也只给 id。"""
from app.engine import inquiry
def broken(*_args, **_kwargs):
raise RuntimeError("解析器坏了")
monkeypatch.setattr(inquiry, "_derived_hidden", broken)
card = card_with([cell_input("spend", 320.0)], substituted="320.0", expression="cell(nodes.members, 0, 'spend')")
_, entry = bundle_of(card)
assert entry.get("masked") is True and "b64" not in entry
def test_a_mask_set_on_the_source_now_counts_too():
card = card_with([cell_input("spend", 320.0)], substituted="320.0", expression="cell(nodes.members, 0, 'spend')")
_, entry = bundle_of(card, source_masks={"crm": ["spend"]})
assert entry.get("masked") is True and "b64" not in entry
PARSED = {"columns": SNAP_MASKED["columns"], "rows": SNAP_MASKED["rows"]}
POSITIONAL = "vars.res.rows[0][1]"
@pytest.mark.parametrize("parsed, masked", [
# 数据整形原样解析的查询结果(带着快照的 id):认得出取自 phone 列
({**PARSED, "artifact": MASKED_ID, "source": "crm"}, True),
# 写变量的节点的产出读不出来:核对不了,宁可多挡
(None, True),
# 结果没存快照、只带着数据源名:查询当时记下的遮罩无从核对,宁可多挡
({**PARSED, "source": "crm"}, True),
# 改过形状、既没带快照的 id 也没带数据源名:认不出出处,照常嵌入(和证据面板、推演一样,是已知的缺口)
(PARSED, False),
])
def test_a_card_reading_a_cell_by_position_is_judged_the_same_way(parsed, masked):
item = {"path": POSITIONAL, "value": PHONE, "node_id": "parse", "via": "transform", "field": "rows[0][1]",
"status": "ok"}
card = card_with([item], substituted=f"len('{PHONE}')", expression=f"len({POSITIONAL})")
bundle, entry = bundle_of(card, outputs={"parse": parsed})
assert (entry.get("masked") is True) is masked and ("b64" in entry) is not masked
if masked:
assert PHONE not in render_html(bundle)
else:
assert PHONE in base64.b64decode(entry["b64"]).decode()
def test_a_card_file_changed_on_the_server_is_still_judged_by_its_inputs():
"""服务端的卡文件被改过(这里改了口径版本,哈希对不上):照样按卡上记的输入认遮罩,记着号码的只给 id。以前先看哈希、
对不上就当卡上没有输入,原字节照样嵌进页面,号码进了文件。不嵌了,就另标 mismatch:核对页判这一项不通过,不和
没被改过的遮罩卡一样说成正常的无法核对。"""
card = card_with([cell_input("phone", PHONE)])
bundle, entry = bundle_of(card, tamper=lambda raw: raw.replace(b'"caliber_version":"v1"', b'"caliber_version":"v9"'))
assert entry["id"] == stored(card)[0]
assert entry == {"id": entry["id"], "kind": "metric_set", "node_id": "card", "masked": True, "mismatch": True}
assert PHONE not in render_html(bundle)
def test_a_changed_card_without_masked_inputs_is_still_embedded_for_the_browser_to_judge():
"""改过的卡上没有遮罩列的值:照常原样嵌进去,浏览器自己算出哈希对不上(只是遮罩的判断不再看哈希)。"""
card = card_with([cell_input("spend", 320.0)], substituted="320.0", expression="cell(nodes.members, 0, 'spend')")
changed = stored(card)[1].replace(b'"caliber_version":"v1"', b'"caliber_version":"v9"')
_, entry = bundle_of(card, tamper=lambda raw: changed)
assert "masked" not in entry and base64.b64decode(entry["b64"]) == changed
@pytest.mark.parametrize("damage", [
# 截断的 JSON:号码还在已经写下的那半截里
lambda raw: raw[: raw.index(b"000-0001") + len(b"000-0001")],
# 解析得出、却不是一张卡(对象)
lambda raw: b'["000-0001"]',
])
def test_a_card_file_that_does_not_parse_as_a_card_is_not_embedded(damage):
"""服务端的卡文件读得出字节、解析不出一张卡:认不出卡上有哪些输入,整张按带遮罩算、只给 id,不原样嵌进页面。字节和
id 对不上(mismatch),也解析不出来(unparsable):两样都照实标上。"""
card = card_with([cell_input("phone", PHONE)])
bundle, entry = bundle_of(card, tamper=damage)
assert entry == {"id": entry["id"], "kind": "metric_set", "node_id": "card", "masked": True, "mismatch": True,
"unparsable": True}
assert PHONE not in render_html(bundle)
def test_a_card_stored_as_something_unparsable_is_marked_so():
"""服务端存的这一份本来就解析不出一张卡(哈希对得上,不是被改过):整张按带遮罩算、不嵌,只标 unparsable——核对页照实
说认不出是否含遮罩列,不说成「含遮罩列」,也不说被改过。"""
card = card_with([cell_input("phone", PHONE)])
bundle, entry = bundle_of(card, tamper=lambda raw: raw[: raw.index(b"000-0001") + len(b"000-0001")], rehash=True)
assert entry == {"id": entry["id"], "kind": "metric_set", "node_id": "card", "masked": True, "unparsable": True}
assert PHONE not in render_html(bundle)
def test_a_masked_snapshot_changed_on_the_server_is_marked_so():
"""带遮罩的查询快照在服务端被改过(哈希对不上):照旧只给 id,另标 mismatch,核对页判不通过。以前和没被改过的遮罩快照
一模一样,总结论还是「可核对的项全部通过」。没被改过的照旧只标遮罩。"""
card = card_with([cell_input("spend", 320.0)], substituted="320.0", expression="cell(nodes.members, 0, 'spend')")
snapshot = lambda bundle: next(a for a in bundle["artifacts"] if a["id"] == MASKED_ID) # noqa: E731
bundle, _ = bundle_of(card)
assert snapshot(bundle) == {"id": MASKED_ID, "kind": "query", "node_id": "members", "masked": True}
changed = stored(SNAP_MASKED)[1].replace("林一".encode(), "林二".encode())
bundle, _ = bundle_of(card, files_changed={MASKED_ID: changed})
assert snapshot(bundle) == {"id": MASKED_ID, "kind": "query", "node_id": "members", "masked": True,
"mismatch": True}
assert PHONE not in render_html(bundle)