173 lines
7.2 KiB
Swift
173 lines
7.2 KiB
Swift
import Foundation
|
|
import TermindApplication
|
|
import TermindDomain
|
|
import TermindInfrastructure
|
|
import TermindPersistence
|
|
import Testing
|
|
|
|
@Suite(.serialized)
|
|
@MainActor
|
|
struct IdentityModificationDateTests {
|
|
@Test
|
|
func legacyIdentityUsageFieldIsIgnoredAndNotReencoded() throws {
|
|
let identity = Identity(name: "Deploy", username: "deploy")
|
|
var object = try #require(
|
|
JSONSerialization.jsonObject(with: JSONEncoder().encode(identity)) as? [String: Any])
|
|
object["lastUsedAt"] = 123
|
|
let legacyData = try JSONSerialization.data(withJSONObject: object)
|
|
|
|
let decoded = try JSONDecoder().decode(Identity.self, from: legacyData)
|
|
let reencoded = try #require(
|
|
JSONSerialization.jsonObject(with: JSONEncoder().encode(decoded)) as? [String: Any])
|
|
|
|
#expect(decoded == identity)
|
|
#expect(reencoded["lastUsedAt"] == nil)
|
|
}
|
|
|
|
@Test
|
|
func credentialReadsDoNotTouchIdentityModificationDate() throws {
|
|
let secrets = InMemorySecretStore()
|
|
let identity = Identity(
|
|
name: "Deploy", username: "deploy",
|
|
credentialRef: "termind.password.deploy", passwordStorage: .thisDevice
|
|
)
|
|
let persistence = IdentityModificationPersistence(
|
|
snapshot: VaultSnapshot(identities: [identity]))
|
|
let store = HostLibraryModel(persistence: persistence, secrets: secrets)
|
|
try secrets.setString("secret", for: identity.credentialRef, storage: identity.passwordStorage)
|
|
let before = store.identityModifiedAt(identity.id)
|
|
|
|
#expect(store.passwordAvailability(for: identity) == .available)
|
|
_ = store.sshCredentials(for: identity)
|
|
|
|
#expect(persistence.saveCount == 0)
|
|
#expect(store.identityModifiedAt(identity.id) == before)
|
|
}
|
|
|
|
@Test
|
|
func replacingAnExistingPasswordTouchesTheIdentityRecord() throws {
|
|
let secrets = InMemorySecretStore()
|
|
let identity = Identity(
|
|
name: "Deploy", username: "deploy",
|
|
credentialRef: "termind.password.deploy", passwordStorage: .thisDevice
|
|
)
|
|
let persistence = IdentityModificationPersistence(
|
|
snapshot: VaultSnapshot(identities: [identity]))
|
|
let store = HostLibraryModel(persistence: persistence, secrets: secrets)
|
|
try secrets.setString("old", for: identity.credentialRef, storage: identity.passwordStorage)
|
|
let before = try #require(store.identityModifiedAt(identity.id))
|
|
|
|
try store.upsert(identity, passwordUpdate: .replace("new"))
|
|
|
|
#expect(try secrets.string(for: identity.credentialRef, storage: identity.passwordStorage) == "new")
|
|
#expect(persistence.lastTouchedKeys == [VaultRecordKey(kind: .identity, id: identity.id)])
|
|
#expect(try #require(store.identityModifiedAt(identity.id)) > before)
|
|
}
|
|
|
|
@Test
|
|
func addingAndRemovingAPasswordEachTouchTheIdentityRecord() throws {
|
|
let secrets = InMemorySecretStore()
|
|
let identity = Identity(name: "Deploy", username: "deploy", passwordStorage: .thisDevice)
|
|
let persistence = IdentityModificationPersistence(
|
|
snapshot: VaultSnapshot(identities: [identity]))
|
|
let store = HostLibraryModel(persistence: persistence, secrets: secrets)
|
|
let original = try #require(store.identityModifiedAt(identity.id))
|
|
|
|
try store.upsert(identity, passwordUpdate: .replace("new"))
|
|
let added = try #require(store.identity(identity.id))
|
|
let addedAt = try #require(store.identityModifiedAt(identity.id))
|
|
#expect(added.usesPassword)
|
|
#expect(addedAt > original)
|
|
|
|
try store.upsert(added, passwordUpdate: .remove)
|
|
let removed = try #require(store.identity(identity.id))
|
|
#expect(!removed.usesPassword)
|
|
#expect(try #require(store.identityModifiedAt(identity.id)) > addedAt)
|
|
}
|
|
|
|
@Test
|
|
func changingTheSelectedKeyAdvancesIdentityButEditingTheKeyDoesNot() throws {
|
|
let first = SSHKeyRef(label: "First")
|
|
var second = SSHKeyRef(label: "Second")
|
|
var identity = Identity(name: "Deploy", username: "deploy", keyID: first.id)
|
|
let persistence = IdentityModificationPersistence(
|
|
snapshot: VaultSnapshot(identities: [identity], keys: [first, second]))
|
|
let store = HostLibraryModel(persistence: persistence)
|
|
let original = try #require(store.identityModifiedAt(identity.id))
|
|
|
|
identity.keyID = second.id
|
|
#expect(store.upsert(identity))
|
|
let selectedKeyChanged = try #require(store.identityModifiedAt(identity.id))
|
|
#expect(selectedKeyChanged > original)
|
|
|
|
second.label = "Renamed"
|
|
try store.upsert(second)
|
|
#expect(store.identityModifiedAt(identity.id) == selectedKeyChanged)
|
|
}
|
|
|
|
@Test
|
|
func failedPasswordSaveRollsBackSecretAndModificationDate() throws {
|
|
let secrets = InMemorySecretStore()
|
|
let identity = Identity(
|
|
name: "Deploy", username: "deploy",
|
|
credentialRef: "termind.password.deploy", passwordStorage: .thisDevice
|
|
)
|
|
let persistence = IdentityModificationPersistence(
|
|
snapshot: VaultSnapshot(identities: [identity]))
|
|
let store = HostLibraryModel(persistence: persistence, secrets: secrets)
|
|
try secrets.setString("old", for: identity.credentialRef, storage: identity.passwordStorage)
|
|
let before = store.identityModifiedAt(identity.id)
|
|
persistence.failsSave = true
|
|
|
|
#expect(throws: (any Error).self) {
|
|
try store.upsert(identity, passwordUpdate: .replace("new"))
|
|
}
|
|
|
|
#expect(try secrets.string(for: identity.credentialRef, storage: identity.passwordStorage) == "old")
|
|
#expect(store.identityModifiedAt(identity.id) == before)
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
private final class IdentityModificationPersistence: VaultPersisting {
|
|
var snapshot: VaultSnapshot
|
|
var dates: [VaultRecordKey: Date]
|
|
var saveCount = 0
|
|
var failsSave = false
|
|
var lastTouchedKeys: Set<VaultRecordKey> = []
|
|
private var clock = Date(timeIntervalSince1970: 100)
|
|
|
|
init(snapshot: VaultSnapshot) {
|
|
self.snapshot = snapshot
|
|
let initialDate = Date(timeIntervalSince1970: 100)
|
|
dates = Dictionary(
|
|
uniqueKeysWithValues: snapshot.recordPayloads.keys.map { ($0, initialDate) })
|
|
}
|
|
|
|
func load() throws -> VaultSnapshot { snapshot }
|
|
|
|
func save(
|
|
_ snapshot: VaultSnapshot, touching keys: Set<VaultRecordKey>
|
|
) throws -> VaultMutationSet {
|
|
saveCount += 1
|
|
if failsSave { throw CocoaError(.fileWriteUnknown) }
|
|
let oldPayloads = self.snapshot.recordPayloads
|
|
let newPayloads = snapshot.recordPayloads
|
|
let changed = Set(
|
|
newPayloads.compactMap { key, payload in
|
|
oldPayloads[key] != payload || keys.contains(key) ? key : nil
|
|
})
|
|
clock = clock.addingTimeInterval(1)
|
|
for key in changed { dates[key] = clock }
|
|
self.snapshot = snapshot
|
|
lastTouchedKeys = keys
|
|
return VaultMutationSet(keys: changed)
|
|
}
|
|
|
|
func applyRemote(_ envelopes: [VaultEnvelope]) throws -> VaultRemoteApplyResult {
|
|
VaultRemoteApplyResult(snapshot: snapshot, acceptedKeys: [], needsUpload: [])
|
|
}
|
|
|
|
func modificationDates() throws -> [VaultRecordKey: Date] { dates }
|
|
}
|