Skip to content
White's Tech Notes
Go back

加了保護但保護不存在的三種形狀

Engineering PracticesEdit page

問題

一套會自動回覆 Threads 公開留言的產線,上線前補四道防護。四道都寫完、測試全綠、 覆蓋率看起來很好,實際去打真的 API 才發現其中一道從來沒有生效過,另一道的 測試把常數改成 999 照樣綠。

原因

1. hide_status 沒有 HUSHED 這個值

"hidden": item.get("hide_status") == "HUSHED",

隱藏一則留言之後再查它,Threads 回的是 "HIDDEN"。HUSHED 這個值這個欄位根本 不會回,所以 hidden 永遠是 False。

沒有任何跡象可以察覺:欄位在、型別對、POST /{reply-id}/manage_reply 回 {"success":true}、raise_for_status() 過。而且被隱藏的留言仍然留在 GET /{media-id}/conversation 的結果裡,所以這個欄位是唯一分得出它還在不在的 東西——讀錯了就等於這個資訊完全不存在。

這是同一天第三個「回 200 但是錯答案」的坑:

端點以為它回什麼實際回什麼
GET /{user-id}/replies這個帳號收到的留言這個帳號自己發出的回覆
GET /{media-id}/replies這則貼文的整串留言只有第一層
hide_statusHUSHED / 其他NOT_HUSHED / HIDDEN / COVERED / …

三個共同點:都回 200,都給出型別正確的資料,錯的樣子跟「本來就沒東西」一模一樣。

2. 所有 fixture 的值都一樣,所以錯的分支沒人走過

測試檔裡每一筆假資料的 hide_status 都是 "NOT_HUSHED"。那條路被覆蓋到了, 但那個值沒有。覆蓋率統計不會告訴你這件事——x == "HUSHED" 這行執行過了, 只是永遠回 False。

3. 自我指涉的測試

fetched = [_fetched(reply_id=f"r{i}") for i in range(MAX_REPLIES_PER_RUN + 3)]
...
assert len(sent) == MAX_REPLIES_PER_RUN

把 MAX_REPLIES_PER_RUN 改成 999:餵 1002 則、送出 999 則、斷言成立、綠的。 同一個常數同時當輸入跟預期值,測到的只有「程式碼有讀這個常數」。

解法

讀狀態欄位要反過來列白名單。 列「看得見的是哪幾個」,其餘一律當看不見:

_VISIBLE_STATUSES = ("NOT_HUSHED", "UNHUSHED")

def _is_hidden(status) -> bool:
    """欄位沒回來(None)就當它看得見——沒有證據不等於隱藏了。"""
    return bool(status) and status not in _VISIBLE_STATUSES

黑名單漏一個值 = 靜默失敗;白名單漏一個值 = 多擋一則,看得出來。順帶把 COVERED / BLOCKED / RESTRICTED 這些不是自己動作造成的狀態一起涵蓋掉。

上限類的測試要把數字寫死。 上限 monkeypatch 成 3、餵 5 則、斷言送出 3 則、 剩 2 則還在 pending。常數本身另外測它跟環境的關係(真正的限制),而不是測它等於 自己:

def test_the_cap_fits_inside_one_scheduling_interval():
    worst_case = MAX_REPLIES_PER_RUN * REPLY_PACING_SECONDS[1]
    assert worst_case < 3600 / 2      # 排程一小時一次,還要留時間給別的工作

寫完測試就地做一次變異測試。 不用工具,一個 heredoc 就夠:

cp target.py /tmp/bak && python3 - <<'EOF'
p="target.py"; s=open(p).read()
s=s.replace("MAX_REPLIES_PER_RUN = 8", "MAX_REPLIES_PER_RUN = 999")
open(p,"w").write(s)
EOF
pytest -q; cp /tmp/bak target.py

沒紅就是沒測到。上面第 3 點就是這樣抓出來的——測試「跑起來是綠的」跟「它擋得住 迴歸」是兩件事,而前者完全無法推論後者。

寫不出可反覆實測的路徑,就先補反向操作。 hide_reply 過去沒辦法驗,因為驗 一次就要留下一則被隱藏的留言。補了 unhide_reply(同一個端點 hide=false)之後 變成:拿一則自己人的留言隱藏 → 確認狀態 → 放回去,隨時可以重跑。不然那條路第一次 真正執行會是在一則真的騷擾留言上。

關鍵洞察

「靜默失敗」的判準是:壞掉的樣子跟正常的樣子長得一模一樣。 空清單、False、200——這三個東西同時是「正確的答案」也是「壞掉的答案」,而區分 它們的唯一方法是拿真實資料對照一次。單元測試對這一類完全沒有意見,因為 fixture 是自己寫的,寫的時候就已經把誤解編碼進去了。

推論:每一個「回傳固定字串集合」的外部欄位,都要有一筆用真實觀測值寫的測試。 不是自己猜的值——猜的值會跟程式碼裡的誤解一致,然後兩邊互相證明對方是對的。

還有一條同源的:唯一的硬規則不能只寫在提示詞裡。 同一套系統裡「不要給任何 聯絡方式」原本只是 prompt 的一句話,輸出端只檢查長度。規則會被當禁令繞過去, 事實才會被拿來推理——會造成公開且不可逆後果的約束,要在程式碼裡再擋一次。



Previous Post
Prompt 漏抓時先查「是不是被某條排除規則主動擋掉」,不要先調取捨強度
Next Post
claude -p 子行程限制 Read 權限,只有 --settings 的 deny 有效