MangoVM 工程指南

云端 Mac 上的 iOS Keychain 迁移回归测试

云端 Mac 上的 iOS Keychain 迁移回归测试

一次普通的 iOS 版本升级,可能让登录令牌突然变成“查无此项”。新版本本身没有崩溃,接口也正常,真正的变化往往藏在 Keychain 的 serviceaccount、访问组或保护属性里。要在云端 Mac 上稳定发现这类问题,测试必须从“旧版写入、新版读取”开始,而不是只跑全新安装。

先定义迁移契约

先把 Keychain 条目当成需要兼容的数据结构。至少记录五项:条目类别、serviceaccount、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 用例。为每个任务创建独立模拟器,或按测试套件串行运行。serviceaccount 应加入随机用例后缀,防止上一次异常中断留下的数据让下一次测试误判成功。

在代码里保留可诊断结果

不要把所有失败都转换成 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 附件保存,但不要把签名材料复制进构建日志。若访问组确实需要调整,应设计由同时具备旧组与新组访问能力的过渡版本完成搬迁,而不是假设新版能直接读取任意旧组。

模拟器适合验证迁移算法和错误分支,但不能覆盖所有签名与访问控制行为。发布前还应在受控真机上执行一次同样的旧版安装、数据播种和覆盖升级流程,并将结果与模拟器测试分开记录。

清理、失败取证与发布门禁

成功用例结束后,通过应用内测试接口按精确的 serviceaccount 删除测试条目。不要调用宽泛删除查询,以免误清除同一测试设备上的其他数据。失败用例则先保留模拟器,收集新版和旧版 entitlements、OSStatus、迁移步骤及应用日志,再决定是否销毁。

发布门禁应检查三件事:旧版数据可由新版读取;迁移连续执行两次仍得到同一结果;异常输入不会删除旧条目。任何一项失败都应阻止产物进入后续分发阶段。

最后为迁移脚本保留固定输入:旧版构建标识、新版构建标识、模拟器系统版本和测试用例编号。这样即使云端 Mac 被重新初始化,也能从归档产物恢复同一条升级路径。Keychain 问题最难处理的不是写入,而是缺少旧状态;只要旧版夹具、签名差异和错误码都能复现,迁移就能从线上风险变成普通回归测试。

常见问题

为什么不能只测试全新安装后的 Keychain 写入?

全新安装只能证明当前版本可以写入,无法验证旧版本留下的服务名、账户名、访问组和可访问性属性是否仍能被新版本读取。迁移测试必须保留旧数据并执行覆盖安装。

Keychain 迁移失败时应该先检查什么?

先记录 SecItemCopyMatching 返回的 OSStatus,再对比新旧构建的 entitlements、access group、service、account 和 kSecAttrAccessible。不要在诊断前删除旧条目,否则会丢失现场。

模拟器测试能否完全替代真机验收?

不能。模拟器适合持续验证查询和迁移逻辑,但访问控制、签名及部分安全行为仍需在受控真机上做最终验收,两类结果应分别记录。

独享物理节点

为开发流水线配置固定的云端 Mac

MangoVM 提供 M4 与 M4 Pro 两档 Apple Silicon 物理节点。选择租期、区域和存储附加项后,即可核对完整订单明细。

选择配置并订购