Files
openless/Tests/OpenLessPersistenceTests/CredentialsMigrationTests.swift
T
baiqing c072816beb feat(persistence): migrate CredentialsVault to versioned v1 schema
Split flat v0 credentials.json into version=1 schema with provider
sections (asr/llm), each keyed by providerId. Add automatic migration
on first load with safe backup of the original v0 file.

- New types: CredentialsSchemaV1, CredentialsProviderASRVolcengine,
  CredentialsProviderLLMArk, CredentialsActiveSelection
- New CredentialsError enum: unparseable / futureVersion / ioError /
  backupFailed / writeFailed
- Pure migration function (CredentialsMigration.parseAndMigrate) for
  unit testability — no disk I/O
- Vault: atomic write via tmp + replaceItemAt, mode 0600 preserved
- Backup format: credentials.v0.bak.<timestamp>[-N].json
- Existing public API (get/set/remove/snapshot, CredentialAccount
  constants) is unchanged so SettingsHubTab / Sidebar continue to work
- New OpenLessPersistenceTests target with 13 migration tests covering
  fresh install, full v0, partial v0 (asr-only / llm-only), empty
  ark.endpoint default, future-version safety, damaged-JSON safety,
  backup-name conflict suffix, idempotent save, atomic write, and
  0600 permissions
2026-04-29 11:25:13 +08:00

410 lines
16 KiB
Swift
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.
import XCTest
@testable import OpenLessPersistence
/// 凭据 v0 → v1 迁移与原子写入的集成测试。
///
/// 用例策略:
/// - 每个 case 独立 temp 目录,`addTeardownBlock` 清理。
/// - 直接构造期望的 v0 文件(点号风格 key,与历史磁盘一致)让 vault 加载。
/// - 通过 vault 的 public API(`get` / `set` / `snapshot`) 间接验证迁移结果与 v1 schema。
final class CredentialsMigrationTests: XCTestCase {
// MARK: - 辅助
/// 给当前用例分配独立的 temp 目录,并在 teardown 时清理。
private func makeTempDirectory() -> URL {
let dir = FileManager.default.temporaryDirectory
.appendingPathComponent("openless-credtest-\(UUID().uuidString)", isDirectory: true)
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
addTeardownBlock {
try? FileManager.default.removeItem(at: dir)
}
return dir
}
/// 直接写一个 v0 扁平字典文件到指定目录。
private func writeV0File(_ dict: [String: String], to dir: URL) throws {
let url = dir.appendingPathComponent("credentials.json")
let data = try JSONSerialization.data(withJSONObject: dict, options: [.prettyPrinted])
try data.write(to: url)
}
/// 直接写一个 v1 schema 文件到指定目录。
private func writeV1File(_ schema: CredentialsSchemaV1, to dir: URL) throws {
let url = dir.appendingPathComponent("credentials.json")
let data = try JSONEncoder().encode(schema)
try data.write(to: url)
}
/// 列目录里所有以 `credentials.v0.bak.` 开头的备份文件。
private func backupFiles(in dir: URL) -> [URL] {
let urls = (try? FileManager.default.contentsOfDirectory(
at: dir,
includingPropertiesForKeys: nil,
options: []
)) ?? []
return urls.filter { $0.lastPathComponent.hasPrefix("credentials.v0.bak.") }
}
/// 完整 v0 字典(6 个字段都填充)。
private func fullV0Dict() -> [String: String] {
[
"volcengine.app_key": "vk-app",
"volcengine.access_key": "vk-access",
"volcengine.resource_id": "vk-resource",
"ark.api_key": "ark-key",
"ark.model_id": "ark-model",
"ark.endpoint": "https://example.test/api/v3"
]
}
// MARK: - 用例
func test_freshInstall_noFile_loadsEmptyV1() {
// Arrange
let dir = makeTempDirectory()
// Act
let vault = CredentialsVault(directoryURL: dir)
let snap = vault.snapshot()
// Assert
XCTAssertNil(snap.volcengineAppKey)
XCTAssertNil(snap.arkApiKey)
// 文件没存在(等到第一次 set 才写)。
let fileURL = dir.appendingPathComponent("credentials.json")
XCTAssertFalse(FileManager.default.fileExists(atPath: fileURL.path))
}
func test_v0_full_migratesToV1WithBackup() throws {
// Arrange
let dir = makeTempDirectory()
try writeV0File(fullV0Dict(), to: dir)
let originalData = try Data(contentsOf: dir.appendingPathComponent("credentials.json"))
// Act
let vault = CredentialsVault(directoryURL: dir)
let snap = vault.snapshot()
// Assert: v1 字段读取一致
XCTAssertEqual(snap.volcengineAppKey, "vk-app")
XCTAssertEqual(snap.volcengineAccessKey, "vk-access")
XCTAssertEqual(snap.volcengineResourceId, "vk-resource")
XCTAssertEqual(snap.arkApiKey, "ark-key")
XCTAssertEqual(snap.arkModelId, "ark-model")
XCTAssertEqual(snap.arkEndpoint, "https://example.test/api/v3")
// 当前文件应该是 v1 格式(有 version 字段)
let mainURL = dir.appendingPathComponent("credentials.json")
let curData = try Data(contentsOf: mainURL)
let curJson = try JSONSerialization.jsonObject(with: curData) as? [String: Any]
XCTAssertEqual(curJson?["version"] as? Int, 1)
// 备份文件存在,且内容等于原 v0 bytes
let backups = backupFiles(in: dir)
XCTAssertEqual(backups.count, 1)
let backupData = try Data(contentsOf: backups[0])
XCTAssertEqual(backupData, originalData)
}
func test_v0_partialOnlyArk_migrates() throws {
// Arrange
let dir = makeTempDirectory()
try writeV0File([
"ark.api_key": "ark-key",
"ark.model_id": "ark-model",
"ark.endpoint": "https://example.test/api/v3"
], to: dir)
// Act
let vault = CredentialsVault(directoryURL: dir)
// Assert: 没有 volcengine 节
let s = vault.currentSchema()
XCTAssertNil(s.providers.asr[defaultActiveASRProviderId])
XCTAssertNotNil(s.providers.llm[defaultActiveLLMProviderId])
XCTAssertEqual(s.providers.llm[defaultActiveLLMProviderId]?.apiKey, "ark-key")
// 写盘的 v1 JSON 不应包含 asr.volcengine
let main = try Data(contentsOf: dir.appendingPathComponent("credentials.json"))
let json = try JSONSerialization.jsonObject(with: main) as? [String: Any]
let providers = json?["providers"] as? [String: Any]
let asr = providers?["asr"] as? [String: Any]
XCTAssertTrue(asr?.isEmpty ?? true)
}
func test_v0_partialOnlyVolcengine_migrates() throws {
// Arrange
let dir = makeTempDirectory()
try writeV0File([
"volcengine.app_key": "vk-app",
"volcengine.access_key": "vk-access",
"volcengine.resource_id": "vk-resource"
], to: dir)
// Act
let vault = CredentialsVault(directoryURL: dir)
// Assert
let s = vault.currentSchema()
XCTAssertNotNil(s.providers.asr[defaultActiveASRProviderId])
XCTAssertNil(s.providers.llm[defaultActiveLLMProviderId])
XCTAssertEqual(s.providers.asr[defaultActiveASRProviderId]?.appKey, "vk-app")
let main = try Data(contentsOf: dir.appendingPathComponent("credentials.json"))
let json = try JSONSerialization.jsonObject(with: main) as? [String: Any]
let providers = json?["providers"] as? [String: Any]
let llm = providers?["llm"] as? [String: Any]
XCTAssertTrue(llm?.isEmpty ?? true)
}
func test_v0_emptyArkEndpoint_usesDefault() throws {
// Arrange
let dir = makeTempDirectory()
try writeV0File([
"ark.api_key": "ark-key",
"ark.model_id": "ark-model",
"ark.endpoint": "" // 空字符串
], to: dir)
// Act
let vault = CredentialsVault(directoryURL: dir)
// Assert: baseURL 落 default
XCTAssertEqual(
vault.snapshot().arkEndpoint,
defaultArkBaseURL
)
}
func test_v0_emptyArkEndpoint_missing_usesDefault() throws {
// Arrange
let dir = makeTempDirectory()
try writeV0File([
"ark.api_key": "ark-key",
"ark.model_id": "ark-model"
// ark.endpoint 完全不在 dict 里
], to: dir)
// Act
let vault = CredentialsVault(directoryURL: dir)
// Assert
XCTAssertEqual(vault.snapshot().arkEndpoint, defaultArkBaseURL)
}
func test_alreadyV1_loadsWithoutMigration() throws {
// Arrange
let dir = makeTempDirectory()
let schema = CredentialsSchemaV1(
version: 1,
providers: CredentialsProviders(
asr: [defaultActiveASRProviderId: CredentialsProviderASRVolcengine(appKey: "vk-app")],
llm: [defaultActiveLLMProviderId: CredentialsProviderLLMArk(apiKey: "ark-key")]
),
active: .defaults
)
try writeV1File(schema, to: dir)
// Act
let vault = CredentialsVault(directoryURL: dir)
// Assert: 字段读取一致
XCTAssertEqual(vault.snapshot().volcengineAppKey, "vk-app")
XCTAssertEqual(vault.snapshot().arkApiKey, "ark-key")
// 没有备份文件
XCTAssertEqual(backupFiles(in: dir).count, 0)
}
func test_futureVersion_throws() throws {
// Arrange
let dir = makeTempDirectory()
let url = dir.appendingPathComponent("credentials.json")
let payload = ["version": 99] as [String: Any]
let data = try JSONSerialization.data(withJSONObject: payload)
try data.write(to: url)
let originalBytes = try Data(contentsOf: url)
// Act:直接调用 pure migration 函数检查错误类型。
XCTAssertThrowsError(try CredentialsMigration.parseAndMigrate(rawData: originalBytes, fileURL: url)) { err in
guard case CredentialsError.futureVersion(let v) = err else {
XCTFail("应该抛 futureVersion,实际: \(err)")
return
}
XCTAssertEqual(v, 99)
}
// Vault 路径下文件不应被修改(snapshot 触发 load)
let vault = CredentialsVault(directoryURL: dir)
_ = vault.snapshot()
let after = try Data(contentsOf: url)
XCTAssertEqual(after, originalBytes)
// 也不应有备份
XCTAssertEqual(backupFiles(in: dir).count, 0)
// 错误应被记录到 loadError
if case .futureVersion(let v) = vault.loadError() {
XCTAssertEqual(v, 99)
} else {
XCTFail("应该把 futureVersion 错误存到 loadError")
}
}
func test_damagedJson_throws_fileUntouched() throws {
// Arrange
let dir = makeTempDirectory()
let url = dir.appendingPathComponent("credentials.json")
let bad = "not-valid-json{".data(using: .utf8)!
try bad.write(to: url)
let originalBytes = try Data(contentsOf: url)
// Act
XCTAssertThrowsError(try CredentialsMigration.parseAndMigrate(rawData: originalBytes, fileURL: url)) { err in
guard case CredentialsError.unparseable(let u) = err else {
XCTFail("应该抛 unparseable,实际: \(err)")
return
}
XCTAssertEqual(u, url)
}
// 通过 vault 加载也应保持文件不动
let vault = CredentialsVault(directoryURL: dir)
_ = vault.snapshot()
let after = try Data(contentsOf: url)
XCTAssertEqual(after, originalBytes)
XCTAssertEqual(backupFiles(in: dir).count, 0)
if case .unparseable(let u) = vault.loadError() {
XCTAssertEqual(u, url)
} else {
XCTFail("应该把 unparseable 错误存到 loadError")
}
}
func test_backupConflict_appendsSuffix() throws {
// Arrange
let dir = makeTempDirectory()
try writeV0File(fullV0Dict(), to: dir)
// 预先放一个会冲突的备份占位文件(覆盖当前可能生成的所有时间戳)。
// 最稳妥的办法:在迁移之前就把"所有"可能的时间戳备份名都占住。
// 实际行为:迁移会生成 timestamp 形如 `yyyyMMddTHHmmss`。
// 我们通过先迁移、抓到生成的备份名,再恢复 v0 文件、占住该名字、重新加载来制造冲突。
// 第一次迁移:先得到一个真实的备份文件名。
do {
let v = CredentialsVault(directoryURL: dir)
_ = v.snapshot()
}
let firstBackups = backupFiles(in: dir)
XCTAssertEqual(firstBackups.count, 1, "首次迁移应生成 1 个备份")
let conflictName = firstBackups[0].lastPathComponent
// 重写 v0 文件 + 让冲突名字占住(不动它),然后清掉之前生成的 v1 main 文件并触发新一轮迁移。
// 改成:不删除 conflictName,而是再次写入 v0 主文件,重新 init vault。
// 由于 currentSchema 已经被 vault A 持久化为 v1,需要重置 main 为 v0 才能重新触发迁移。
try FileManager.default.removeItem(at: dir.appendingPathComponent("credentials.json"))
try writeV0File(fullV0Dict(), to: dir)
// Act:再次加载,时间戳同秒会撞 conflictName,应改用 `-1` 后缀。
// 注意:测试可能跨秒,所以这里还是按"如果新备份文件名等于 conflictName,就要看 -1 文件"的逻辑判断。
let vault2 = CredentialsVault(directoryURL: dir)
XCTAssertNotNil(vault2.snapshot().volcengineAppKey)
let allBackups = backupFiles(in: dir)
// 至少 2 个备份文件存在(旧的 + 新的)
XCTAssertGreaterThanOrEqual(allBackups.count, 2)
// 如果第二次迁移的时间戳跟 conflictName 一样,应该出现 `-1` 后缀
let suffixed = allBackups.first { $0.lastPathComponent.contains("-1.json") }
let differentTimestamp = allBackups.first {
$0.lastPathComponent != conflictName && !$0.lastPathComponent.contains("-1.json")
}
XCTAssertTrue(suffixed != nil || differentTimestamp != nil,
"应该要么生成 -1 后缀文件,要么时间戳不同")
// conflict 文件本身不应该被覆盖
XCTAssertTrue(FileManager.default.fileExists(atPath: dir.appendingPathComponent(conflictName).path))
}
func test_idempotentSave() throws {
// Arrange
let dir = makeTempDirectory()
let schema = CredentialsSchemaV1(
version: 1,
providers: CredentialsProviders(
asr: [defaultActiveASRProviderId: CredentialsProviderASRVolcengine(
appKey: "vk-app", accessKey: "vk-access", resourceId: "vk-resource"
)],
llm: [defaultActiveLLMProviderId: CredentialsProviderLLMArk(
apiKey: "ark-key", baseURL: defaultArkBaseURL, model: "ark-model"
)]
),
active: .defaults
)
try writeV1File(schema, to: dir)
// Act:load → save (随便 set 一次再设回原值) → load 再读
let vault1 = CredentialsVault(directoryURL: dir)
let s1 = vault1.currentSchema()
try vault1.set("vk-app", for: CredentialAccount.volcengineAppKey)
let vault2 = CredentialsVault(directoryURL: dir)
let s2 = vault2.currentSchema()
// Assert: schema 完全一致
XCTAssertEqual(s1, s2)
}
func test_atomicWrite_noTempLeftBehind() throws {
// Arrange
let dir = makeTempDirectory()
// Act
let vault = CredentialsVault(directoryURL: dir)
try vault.set("vk-app", for: CredentialAccount.volcengineAppKey)
// Assert
let tmp = dir.appendingPathComponent("credentials.json.tmp")
XCTAssertFalse(FileManager.default.fileExists(atPath: tmp.path))
}
func test_filePermissions_0600() throws {
// Arrange
let dir = makeTempDirectory()
// Act
let vault = CredentialsVault(directoryURL: dir)
try vault.set("vk-app", for: CredentialAccount.volcengineAppKey)
// Assert
let main = dir.appendingPathComponent("credentials.json")
let attrs = try FileManager.default.attributesOfItem(atPath: main.path)
let perms = attrs[.posixPermissions] as? NSNumber
XCTAssertEqual(perms?.intValue, 0o600)
}
// MARK: - 兼容性补充:snake_case 风格 v0 也能识别(防御性)
func test_v0_snakeCaseKeys_alsoMigrates() throws {
// Arrange:用文档里描述的下划线风格 key 写入。
let dir = makeTempDirectory()
try writeV0File([
"volcengine_app_key": "vk-app",
"volcengine_access_key": "vk-access",
"volcengine_resource_id": "vk-resource",
"ark_api_key": "ark-key",
"ark_model_id": "ark-model",
"ark_endpoint": "https://example.test/api/v3"
], to: dir)
// Act
let vault = CredentialsVault(directoryURL: dir)
// Assert
XCTAssertEqual(vault.snapshot().volcengineAppKey, "vk-app")
XCTAssertEqual(vault.snapshot().arkApiKey, "ark-key")
XCTAssertEqual(vault.snapshot().arkEndpoint, "https://example.test/api/v3")
}
}