270 lines
10 KiB
Swift
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: [])
|
|
}
|
|
}
|