Files
Termind/FrameworkTests/IdentityCredentialUsageTests.swift
T
2026-09-15 23:24:37 +08:00

270 lines
10 KiB
Swift

import Foundation
import TermindApplication
import TermindDomain
import TermindInfrastructure
import Testing
@Suite(.serialized)
@MainActor
struct IdentityCredentialUsageTests {
@Test
func connectionReadsUpdateUsageButAvailabilityChecksDoNot() throws {
let keychain = InMemorySecretStore()
let reference = "termind.password.\(UUID().uuidString)"
let identity = Identity(
name: "Deploy", username: "deploy",
credentialRef: reference, passwordStorage: .thisDevice
)
let store = HostLibraryModel(identities: [identity], secrets: keychain)
try keychain.setString("secret", for: reference, storage: .thisDevice)
#expect(store.passwordAvailability(for: identity) == .available)
#expect(store.identity(identity.id)?.lastUsedAt == nil)
_ = store.sshCredentials(for: identity)
#expect(store.identity(identity.id)?.lastUsedAt != nil)
}
@Test
func missingConnectionSecretsDoNotUpdateUsage() {
let identity = Identity(
name: "Deploy", username: "deploy",
credentialRef: "termind.password.missing", passwordStorage: .thisDevice
)
let store = HostLibraryModel(
identities: [identity], secrets: InMemorySecretStore()
)
_ = store.sshCredentials(for: identity)
#expect(store.identity(identity.id)?.lastUsedAt == nil)
}
@Test
func privateKeyAvailabilityCheckDoesNotUpdateUsage() throws {
let keychain = InMemorySecretStore()
let key = SSHKeyRef(
label: "Deploy", publicKey: "ssh-ed25519 test",
credentialRef: "termind.key.availability"
)
let identity = Identity(name: "Deploy", username: "deploy", keyID: key.id)
let store = HostLibraryModel(
keys: [key], identities: [identity], secrets: keychain
)
try keychain.setKeyCredential(
.software(SSHPrivateKeySecret(privateKey: "private")),
for: key.credentialRef, storage: key.storage
)
#expect(store.keyAvailability(for: key) == .available)
#expect(store.identity(identity.id)?.lastUsedAt == nil)
}
@Test(arguments: [
StoredSSHKeyCredential.software(SSHPrivateKeySecret(privateKey: "private")),
StoredSSHKeyCredential.secureEnclave(
SecureEnclaveKeyCredential(dataRepresentation: Data("handle".utf8))
),
])
func savedPrivateKeyReadsUpdateUsage(_ credential: StoredSSHKeyCredential) throws {
let keychain = InMemorySecretStore()
let key = SSHKeyRef(
label: "Deploy", publicKey: "ssh-ed25519 test",
credentialRef: "termind.key.\(UUID().uuidString)"
)
let identity = Identity(name: "Deploy", username: "deploy", keyID: key.id)
let store = HostLibraryModel(
keys: [key], identities: [identity], secrets: keychain
)
try keychain.setKeyCredential(
credential, for: key.credentialRef, storage: key.storage
)
_ = store.sshCredentials(for: identity)
#expect(store.identity(identity.id)?.lastUsedAt != nil)
}
@Test
func routeCredentialReadsArePersistedOnceForTargetJumpAndProxy() throws {
let keychain = InMemorySecretStore()
let targetIdentity = Identity(
name: "Target", username: "target",
credentialRef: "password.target", passwordStorage: .thisDevice
)
let jumpIdentity = Identity(
name: "Jump", username: "jump",
credentialRef: "password.jump", passwordStorage: .thisDevice
)
let proxyIdentity = Identity(
name: "Proxy", username: "proxy",
credentialRef: "password.proxy", passwordStorage: .thisDevice
)
let jump = Host(
name: "Jump", host: "jump.invalid", identityID: jumpIdentity.id,
proxy: HostProxy(
hostname: "proxy.invalid", username: "proxy",
identityID: proxyIdentity.id
)
)
let target = Host(
name: "Target", host: "target.invalid", identityID: targetIdentity.id,
jumpHostID: jump.id
)
let snapshot = VaultSnapshot(
hosts: [jump, target],
identities: [targetIdentity, jumpIdentity, proxyIdentity]
)
let persistence = CredentialAccessPersistence(snapshot: snapshot)
let store = HostLibraryModel(persistence: persistence, secrets: keychain)
for identity in snapshot.identities {
try keychain.setString(
"secret", for: identity.credentialRef, storage: identity.passwordStorage
)
}
_ = try store.sshConnectionConfiguration(
for: target, trustPolicy: .allowChallenge
)
let dates = snapshot.identities.compactMap { store.identity($0.id)?.lastUsedAt }
#expect(dates.count == 3)
#expect(Set(dates).count == 1)
#expect(persistence.identityUsageSaveCount == 1)
#expect(persistence.saveCount == 0)
}
@Test
func usageDatesAreMonotonicAndRepeatedReadsPersistOncePerProcess() {
let identity = Identity(name: "Deploy", username: "deploy")
let persistence = CredentialAccessPersistence(
snapshot: VaultSnapshot(identities: [identity])
)
let store = HostLibraryModel(persistence: persistence)
let newer = Date(timeIntervalSince1970: 200)
store.recordIdentityCredentialAccess([identity.id, UUID()], at: newer)
store.recordIdentityCredentialAccess(
[identity.id], at: Date(timeIntervalSince1970: 100)
)
#expect(store.identity(identity.id)?.lastUsedAt == newer)
#expect(persistence.identityUsageSaveCount == 1)
store.recordIdentityCredentialAccess(
[identity.id], at: Date(timeIntervalSince1970: 300)
)
#expect(store.identity(identity.id)?.lastUsedAt == newer)
#expect(persistence.identityUsageSaveCount == 1)
}
@Test
func failedUsagePersistenceRollsBackAndCanRetry() {
let identity = Identity(name: "Deploy", username: "deploy")
let persistence = CredentialAccessPersistence(
snapshot: VaultSnapshot(identities: [identity])
)
let store = HostLibraryModel(persistence: persistence)
let usedAt = Date(timeIntervalSince1970: 300)
persistence.failsIdentityUsageSave = true
store.recordIdentityCredentialAccess([identity.id], at: usedAt)
#expect(store.identity(identity.id)?.lastUsedAt == nil)
#expect(store.storageError != nil)
persistence.failsIdentityUsageSave = false
store.recordIdentityCredentialAccess([identity.id], at: usedAt)
#expect(store.identity(identity.id)?.lastUsedAt == usedAt)
#expect(persistence.identityUsageSaveCount == 2)
#expect(store.storageError == nil)
}
@Test
func staleIdentitySavePreservesUsageAndCredentialChangesAllowAnotherRecord() throws {
let usedAt = Date(timeIntervalSince1970: 200)
let identity = Identity(name: "Deploy", username: "deploy")
let persistence = CredentialAccessPersistence(
snapshot: VaultSnapshot(identities: [identity])
)
let store = HostLibraryModel(persistence: persistence)
store.recordIdentityCredentialAccess([identity.id], at: usedAt)
var edited = identity
edited.name = "Renamed"
edited.lastUsedAt = Date(timeIntervalSince1970: 100)
#expect(store.upsert(edited))
#expect(store.identity(identity.id)?.lastUsedAt == usedAt)
edited = try #require(store.identity(identity.id))
edited.username = "root"
#expect(store.upsert(edited))
let later = Date(timeIntervalSince1970: 300)
store.recordIdentityCredentialAccess([identity.id], at: later)
#expect(store.identity(identity.id)?.lastUsedAt == later)
#expect(persistence.identityUsageSaveCount == 2)
}
@Test
func detachedPreparationPublishesCredentialUsageBackToTheLibrary() async throws {
let keychain = InMemorySecretStore()
let identity = Identity(
name: "Deploy", username: "deploy",
credentialRef: "password.detached", passwordStorage: .thisDevice
)
let host = Host(
name: "Server", host: "server.invalid", identityID: identity.id
)
let store = HostLibraryModel(
hosts: [host], identities: [identity], secrets: keychain
)
try keychain.setString(
"secret", for: identity.credentialRef, storage: identity.passwordStorage
)
let preparation = try store.prepareSSHConnection(for: host, submissions: [:])
_ = try await preparation.resolve(trustPolicy: .allowChallenge)
for _ in 0..<100 where store.identity(identity.id)?.lastUsedAt == nil {
await Task.yield()
}
#expect(store.identity(identity.id)?.lastUsedAt != nil)
}
}
@MainActor
private final class CredentialAccessPersistence: VaultPersisting {
var snapshot: VaultSnapshot
var saveCount = 0
var failsSave = false
var identityUsageSaveCount = 0
var failsIdentityUsageSave = false
init(snapshot: VaultSnapshot) {
self.snapshot = snapshot
}
func load() throws -> VaultSnapshot { snapshot }
func save(_ snapshot: VaultSnapshot) throws -> VaultMutationSet {
saveCount += 1
if failsSave { throw CocoaError(.fileWriteUnknown) }
self.snapshot = snapshot
return .empty
}
func saveIdentityLastUsedAt(_ updates: [Identity.ID: Date]) throws {
identityUsageSaveCount += 1
if failsIdentityUsageSave { throw CocoaError(.fileWriteUnknown) }
for index in snapshot.identities.indices {
guard let date = updates[snapshot.identities[index].id],
snapshot.identities[index].lastUsedAt.map({ $0 >= date }) != true
else { continue }
snapshot.identities[index].lastUsedAt = date
}
}
func applyRemote(_ envelopes: [VaultEnvelope]) throws -> VaultRemoteApplyResult {
VaultRemoteApplyResult(snapshot: snapshot, acceptedKeys: [], needsUpload: [])
}
}