Files
Termind/FrameworkTests/ModelProviderVertexTests.swift

243 lines
13 KiB
Swift

// SPDX-FileCopyrightText: 2026 Kaede Akino
// SPDX-License-Identifier: GPL-3.0-only
import AgentKit
import Foundation
import Security
import SwiftData
import TermindApplication
import TermindDomain
import TermindInfrastructure
import TermindPersistence
import Testing
@Suite @MainActor
struct ModelProviderVertexTests {
private func vertexProvider() -> ModelProvider {
ModelProvider(
name: "Google", apiFormat: .generateContent, credentialRef: "api-key", usesVertex: true,
vertex: VertexConfig(
projectID: "test-project", location: "us-central1", credentialRef: "service-account",
clientEmail: "fixture@example.invalid"),
models: [AIModel(id: "gemini-test", displayName: "Gemini Test")])
}
@Test func vertexPersistsAlongsideTheFrozenProviderRowAndDeletesWithIt() throws {
let container = try TermindPersistence.makeContainer(inMemory: true)
let persistence = AIPersistence(context: container.mainContext)
let ai = AIConfigurationModel(persistence: persistence, persistsSecrets: false)
let provider = vertexProvider()
#expect(ai.saveProvider(provider, endpoint: .init(apiType: .generateContent)))
let reopened = AIConfigurationModel(persistence: persistence, persistsSecrets: false)
let saved = try #require(reopened.modelProvider(provider.id))
#expect(saved.usesVertex)
#expect(saved.vertex == provider.vertex)
#expect(saved.models == provider.models)
#expect(saved.requestURL().hasPrefix("https://us-central1-aiplatform.googleapis.com/"))
#expect(reopened.endpoint(for: provider.id).url == ModelAPIType.generateContent.defaultURL)
reopened.delete(modelProviderID: provider.id)
#expect(try container.mainContext.fetch(FetchDescriptor<PersistentModelProviderVertexConfiguration>()).isEmpty)
}
@Test func v18MigratesWithoutChangingExistingProviderData() throws {
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: directory) }
let url = directory.appendingPathComponent("Store.sqlite")
let schema = Schema(versionedSchema: TermindSchemaV18.self)
let provider = ModelProvider(name: "Legacy", models: [AIModel(id: "private")])
let endpoint = ProviderEndpoint(apiType: .messages, url: "https://private.invalid/v1")
do {
let legacy = try ModelContainer(
for: schema,
configurations: [
ModelConfiguration("Termind", schema: schema, url: url, cloudKitDatabase: .none)
])
legacy.mainContext.insert(PersistentModelProvider(provider, endpoint: endpoint, sortIndex: 0))
legacy.mainContext.insert(PersistentAIModel(provider.models[0], providerID: provider.id, sortIndex: 0))
try legacy.mainContext.save()
}
let container = try TermindPersistence.makeContainer(url: url)
let snapshot = try AIPersistence(context: container.mainContext).load()
#expect(snapshot.providers.first?.name == "Legacy")
#expect(snapshot.providers.first?.models == provider.models)
#expect(snapshot.endpoints[provider.id] == endpoint)
#expect(snapshot.providers.first?.usesVertex == false)
}
@Test func syncRoundTripsVertexAndPreservesItWhenAnOlderWriterOmitsIt() throws {
let container = try TermindPersistence.makeContainer(inMemory: true)
let defaults = UserDefaults(suiteName: "ProviderVertexTests.\(UUID())")!
let store = SettingsSyncPersistence(context: container.mainContext, defaults: defaults)
try store.prepare()
let provider = vertexProvider()
let endpoint = ProviderEndpoint(apiType: .generateContent, url: ModelAPIType.generateContent.defaultURL)
var value = SyncedModelProvider(provider, endpoint: endpoint, sortIndex: 0)
func envelope(_ value: SyncedModelProvider, time: TimeInterval) -> SettingsEnvelope {
let payload = SettingsRecordPayload.provider(value)
return SettingsEnvelope(
key: payload.key,
version: .next(after: nil, writerID: UUID(), now: Date(timeIntervalSinceNow: time)), payload: payload)
}
let first = envelope(value, time: 10)
let roundTrip = try JSONDecoder().decode(SettingsEnvelope.self, from: JSONEncoder().encode(first))
#expect(roundTrip == first)
let received = try store.applyRemote([roundTrip]).snapshot.configuration.providers[0]
#expect(received.vertex == provider.vertex)
#expect(received.usesVertex)
value.name = "Old client rename"
value.vertex = nil
let legacyData = try JSONEncoder().encode(value)
#expect(try JSONDecoder().decode(SyncedModelProvider.self, from: legacyData).vertex == nil)
let legacy = try store.applyRemote([envelope(value, time: 20)])
#expect(legacy.snapshot.configuration.providers[0].name == "Old client rename")
#expect(legacy.snapshot.configuration.providers[0].vertex == provider.vertex)
#expect(legacy.snapshot.configuration.providers[0].usesVertex)
#expect(legacy.needsUpload.contains(first.key))
value.vertex = SyncedProviderVertex(provider)
value.vertex?.isEnabled = false
value.vertex?.credentialRef = ""
let cleared = try store.applyRemote([envelope(value, time: 30)]).snapshot.configuration.providers[0]
#expect(!cleared.usesVertex)
#expect(cleared.vertex.credentialRef.isEmpty)
}
@Test func failedMetadataSaveRollsBackBothCredentialsAndKeepsDraftSecrets() throws {
let provider = vertexProvider()
let secrets = VertexSecrets()
secrets.values = ["api-key": "old-key", "service-account": "old-json"]
let persistence = FailingProviderPersistence(provider: provider)
let ai = AIConfigurationModel(persistence: persistence, aiSecrets: secrets)
let edits: [ModelProviderCredentialEdit] = [
.init(kind: .apiKey, reference: "api-key", previous: "old-key", replacement: "new-key"),
.init(kind: .serviceAccount, reference: "service-account", previous: "old-json", replacement: "new-json"),
]
#expect(throws: (any Error).self) {
try ai.saveProvider(provider, endpoint: .init(apiType: .generateContent), credentials: edits)
}
#expect(secrets.values == ["api-key": "old-key", "service-account": "old-json"])
#expect(ai.modelProvider(provider.id) == provider)
#expect(edits[1].replacement == "new-json")
}
@Test func credentialConflictsFailBeforeWritingAndDeletionCleansBothReferences() throws {
let provider = vertexProvider()
let secrets = VertexSecrets()
secrets.values = ["api-key": "key", "service-account": "newer-json"]
let ai = AIConfigurationModel(modelProviders: [provider], aiSecrets: secrets)
#expect(throws: SettingsDraftMerge.Conflict.self) {
try ai.saveProvider(
provider, endpoint: .init(apiType: .generateContent),
credentials: [
.init(
kind: .serviceAccount, reference: "service-account", previous: "old-json", replacement: "stale")
])
}
#expect(secrets.writes.isEmpty)
ai.delete(modelProviderID: provider.id)
#expect(secrets.values.isEmpty)
#expect(Set(secrets.writes) == ["api-key", "service-account"])
}
@Test func fileAndPastedAccountsShareValidationAndRejectUntrustedTokenHosts() async throws {
// Ephemeral test-only key: never written to the repository, Keychain, or a real Google account.
let key = try #require(
SecKeyCreateRandomKey(
[
kSecAttrKeyType: kSecAttrKeyTypeRSA, kSecAttrKeySizeInBits: 1024,
] as CFDictionary, nil))
let rsa = try #require(SecKeyCopyExternalRepresentation(key, nil)) as Data
func der(_ tag: UInt8, _ data: Data) -> Data {
let length = data.count
return Data([tag, 0x82, UInt8(length >> 8), UInt8(length & 255)]) + data
}
let algorithm = Data([
0x02, 0x01, 0x00, 0x30, 0x0d, 0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7,
0x0d, 0x01, 0x01, 0x01, 0x05, 0x00,
])
let pem =
"-----BEGIN PRIVATE KEY-----\n" + der(0x30, algorithm + der(0x04, rsa)).base64EncodedString()
+ "\n-----END PRIVATE KEY-----\n"
let object = [
"type": "service_account", "project_id": "test-project", "client_email": "fixture@example.invalid",
"private_key": pem, "token_uri": "https://oauth2.googleapis.com/token",
]
let json = String(decoding: try JSONEncoder().encode(object), as: UTF8.self)
let account = try ModelProviderServiceAccount(json: json)
#expect(account.projectID == "test-project")
#expect(account.clientEmail == "fixture@example.invalid")
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: directory) }
let url = directory.appendingPathComponent("fixture.json")
try Data(json.utf8).write(to: url)
#expect(try await ModelProviderServiceAccountFile.read(url) == account.json)
#expect(throws: (any Error).self) {
try ModelProviderServiceAccount(
json: json.replacingOccurrences(of: "oauth2.googleapis.com", with: "evil.invalid"))
}
try Data(repeating: 32, count: ModelProviderServiceAccount.maximumBytes + 1).write(to: url)
await #expect(throws: (any Error).self) { try await ModelProviderServiceAccountFile.read(url) }
}
@Test func clearingAnUncommittedCredentialDoesNotMintAKeychainReference() throws {
let secrets = VertexSecrets()
let ai = AIConfigurationModel(aiSecrets: secrets)
let provider = ModelProvider(name: "")
try ai.saveProvider(
provider, endpoint: .init(),
credentials: [
.init(kind: .serviceAccount, reference: "", previous: nil, replacement: nil)
])
#expect(secrets.writes.isEmpty)
#expect(ai.modelProvider(provider.id)?.vertex.credentialRef == "")
#expect(ai.modelProvider(provider.id)?.name == "OpenAI")
}
@Test func failedCredentialCleanupRemainsVisibleAfterTheOtherReferenceSucceeds() {
let provider = vertexProvider()
let secrets = VertexSecrets()
secrets.values = ["api-key": "key", "service-account": "json"]
secrets.failingReference = "service-account"
let ai = AIConfigurationModel(modelProviders: [provider], aiSecrets: secrets)
ai.delete(modelProviderID: provider.id)
#expect(ai.modelProvider(provider.id) == nil)
#expect(secrets.values["api-key"] == nil)
#expect(secrets.values["service-account"] == "json")
#expect(ai.secretStorageError != nil)
#expect(Set(secrets.writes) == ["api-key", "service-account"])
}
@Test func invalidServiceAccountErrorsNeverEchoInput() {
for json in ["private-secret", "{}", String(repeating: "private-secret", count: 100_000)] {
do {
_ = try ModelProviderServiceAccount(json: json)
Issue.record("Invalid service account accepted")
} catch {
#expect(!error.localizedDescription.contains("private-secret"))
}
}
}
}
@MainActor private final class VertexSecrets: AISecretStoring {
var values: [String: String] = [:]
var writes: [String] = []
var failingReference: String?
enum Failure: Error { case inaccessible }
func string(for reference: String) throws -> String? { values[reference] }
func setString(_ value: String?, for reference: String) throws {
writes.append(reference)
if reference == failingReference { throw Failure.inaccessible }
values[reference] = value
}
}
@MainActor private final class FailingProviderPersistence: AIConfigurationPersisting {
let provider: ModelProvider
init(provider: ModelProvider) { self.provider = provider }
func load() throws -> AIConfigurationSnapshot { .init(providers: [provider], roles: [:]) }
func save(_ snapshot: AIConfigurationSnapshot) throws { throw Failure.failed }
enum Failure: Error { case failed }
}