一次普通的 iOS 版本升級,就可能讓登入權杖突然變成「找不到項目」。新版應用程式本身沒有當機,API 也運作正常,真正的變化往往藏在 Keychain 的 service、account、存取群組或保護屬性中。若要在雲端 Mac 上穩定找出這類問題,測試必須從「舊版寫入、新版讀取」開始,而不是只測試全新安裝。
先定義遷移契約
先將 Keychain 項目視為需要維持相容性的資料結構。至少記錄五項資訊:項目類別、service、account、access group 與 kSecAttrAccessible。如果新版需要重新命名,不要直接更換查詢條件;應先使用舊條件讀取,再依照新結構寫入,確認成功後才刪除舊項目。
建議為每次遷移指定整數版本,例如 keychainSchemaVersion = 2,並將完成標記寫入一般偏好設定。這項標記只用來避免重複遷移,不能證明 Keychain 資料確實存在。應用程式啟動時仍應檢查目標項目,以處理「標記存在,但資料已被清除」的情況。
遷移成功的判定標準,不是函式回傳成功,而是新版能讀取舊資料、重複執行不會破壞資料,且失敗後仍保留可供復原的原始項目。
先列出最小驗收矩陣:
| 情境 | 初始狀態 | 預期結果 |
|---|---|---|
| 全新安裝 | 沒有舊項目 | 建立新版項目 |
| 覆蓋升級 | 舊版項目完整 | 遷移後可讀取 |
| 重複啟動 | 已完成遷移 | 不重複寫入 |
| 舊項目損壞 | 資料格式錯誤 | 保留現場並回傳明確錯誤 |
| 存取群組變更 | 新版無法存取舊群組 | 阻止發布並核對簽章設定 |
建立舊版與新版測試夾具
保留兩個可安裝至模擬器的 .app:舊版負責植入資料,新版負責遷移與驗證。不要在兩個建置版本之間執行 erase 或解除安裝,否則會改變測試前提。為測試建置加入只在自動化環境中啟用的啟動參數,例如 -UITestSeedLegacyKeychain 與 -UITestVerifyKeychainV2;正式環境建置不應回應這些參數。
set -euo pipefail
UDID="${SIMULATOR_UDID:?missing simulator id}"
OLD_APP="${OLD_APP_PATH:?missing old app path}"
NEW_APP="${NEW_APP_PATH:?missing new app path}"
BUNDLE_ID="com.example.product"
xcrun simctl bootstatus "$UDID" -b
xcrun simctl install "$UDID" "$OLD_APP"
xcrun simctl launch --terminate-running-process \
"$UDID" "$BUNDLE_ID" -UITestSeedLegacyKeychain
xcrun simctl install "$UDID" "$NEW_APP"
xcrun simctl launch --terminate-running-process \
"$UDID" "$BUNDLE_ID" -UITestVerifyKeychainV2
資料植入動作必須可供驗證。舊版寫入後,測試介面可以輸出不含敏感值的狀態檔案,只記錄項目版本、寫入結果與隨機測試案例編號。新版完成驗證後也寫入一份結果,CI 只比較狀態,不輸出權杖內容。
避免並行測試互相污染
不要在同一個模擬器上並行執行多組 Keychain 測試案例。請為每項工作建立獨立模擬器,或依測試套件循序執行。service 與 account 應加入隨機測試案例後綴,避免上一次異常中斷所留下的資料,讓下一次測試誤判為成功。
在程式碼中保留可診斷的結果
不要將所有失敗都轉換成 nil。保留 OSStatus,才能區分找不到項目、權限不符與參數錯誤。查詢函式可以回傳明確的結果型別:
enum KeychainReadResult {
case found(Data)
case missing
case failed(OSStatus)
}
func readLegacyToken(service: String, account: String) -> KeychainReadResult {
let query: [CFString: Any] = [
kSecClass: kSecClassGenericPassword,
kSecAttrService: service,
kSecAttrAccount: account,
kSecReturnData: true,
kSecMatchLimit: kSecMatchLimitOne
]
var item: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &item)
if status == errSecItemNotFound {
return .missing
}
guard status == errSecSuccess, let data = item as? Data else {
return .failed(status)
}
return .found(data)
}
遷移時應先新增新版項目,再重新讀取並比較內容摘要,最後才刪除舊項目。不要先刪除再寫入;一旦寫入因權限或參數問題而失敗,使用者資料將無法復原。日誌只記錄狀態碼、遷移版本與測試案例編號,不記錄原始憑證。
核對簽章與存取群組
如果程式碼沒有變更,但查詢突然回傳 errSecItemNotFound,應比較新舊產物的 entitlements。存取群組前綴、應用程式識別碼或 Keychain access groups 只要有一項變更,都可能讓新版無法看到原有項目。
codesign -d --entitlements :- "$OLD_APP_PATH" > old-entitlements.plist
codesign -d --entitlements :- "$NEW_APP_PATH" > new-entitlements.plist
diff -u old-entitlements.plist new-entitlements.plist
將差異檔案儲存為 CI 附件,但不要把簽章資料複製到建置日誌中。如果確實需要調整存取群組,應設計一個同時具備舊群組與新群組存取權限的過渡版本來完成搬遷,而不是假設新版能直接讀取任何舊群組。
模擬器適合驗證遷移演算法與錯誤分支,但無法涵蓋所有簽章和存取控制行為。發布前還應在受控的實體裝置上執行一次相同的舊版安裝、資料植入與覆蓋升級流程,並將結果與模擬器測試分開記錄。
清理、失敗取證與發布門檻
成功案例結束後,透過應用程式內的測試介面,依照精確的 service 與 account 刪除測試項目。不要使用範圍過廣的刪除查詢,以免誤刪同一台測試裝置上的其他資料。若測試失敗,則先保留模擬器,收集新版與舊版的 entitlements、OSStatus、遷移步驟及應用程式日誌,再決定是否銷毀。
發布門檻應檢查三件事:新版可讀取舊版資料;連續執行兩次遷移仍得到相同結果;異常輸入不會刪除舊項目。任何一項失敗,都應阻止產物進入後續發布階段。
最後,為遷移指令碼保留固定輸入:舊版建置識別碼、新版建置識別碼、模擬器系統版本與測試案例編號。如此一來,即使雲端 Mac 重新初始化,也能從封存產物還原相同的升級路徑。Keychain 問題最難處理的並不是寫入,而是缺少舊狀態;只要舊版夾具、簽章差異與錯誤碼都能重現,遷移就能從線上風險轉變為一般的回歸測試。
常見問題
為什麼不能只測試全新安裝後的 Keychain 寫入?
全新安裝只能證明目前版本能建立資料,無法驗證舊版留下的 service、account、存取群組與保護屬性是否仍能由新版讀取。
Keychain 遷移失敗時應先檢查什麼?
先保存 SecItemCopyMatching 回傳的 OSStatus,再比較新舊版本的 entitlements、存取群組、service、account 與 kSecAttrAccessible,診斷前不要刪除舊資料。
模擬器測試可以完全取代實機驗收嗎?
不可以。模擬器適合持續驗證查詢與遷移邏輯,但簽署、存取控制及部分安全行為仍需在受控實機上完成最終驗收。
為開發流程設定固定的雲端 Mac
MangoVM 提供 M4 與 M4 Pro 兩款 Apple Silicon 實體節點。選擇租期、地區與儲存空間附加項目後,即可核對完整訂單明細。