mirror of
https://github.com/jayanezj/traccar-client-macos.git
synced 2026-11-05 03:00:46 +01:00
feat: persist local configuration and device identity (#3)
Store receiver and identity together in Keychain, with versioned tracking preferences, explicit authorization retry and stable Settings focus. Adopt free Apple Development locally and credential-free ad hoc CI. Run configuration and focus tests through both CI toolchains. Final decisions: https://github.com/jayanezj/traccar-client-macos/issues/3#issuecomment-6396087703
This commit is contained in:
23 files changed
+1442
-42
No files matched your search
@@ -0,0 +1,371 @@
|
||||
import Foundation
|
||||
import Security
|
||||
import Testing
|
||||
|
||||
@Suite @MainActor
|
||||
struct ConfigurationTests {
|
||||
@Test func defaultsDoNotGenerateIdentity() throws {
|
||||
let fixture = try Fixture()
|
||||
defer { fixture.cleanUp() }
|
||||
var generated = 0
|
||||
let model = ConfigurationModel(storage: fixture.storage, generateIdentifier: {
|
||||
generated += 1
|
||||
return "12345678"
|
||||
})
|
||||
model.loadIfNeeded()
|
||||
model.loadIfNeeded()
|
||||
#expect(model.draft == AppConfiguration())
|
||||
#expect(model.draft.connection.serverURL == "http://demo.traccar.org:5055")
|
||||
#expect(model.draft.tracking.intervalSeconds == 300)
|
||||
#expect(model.draft.tracking.distanceMetres == 75)
|
||||
#expect(model.draft.tracking.accuracy == .medium)
|
||||
#expect(!model.draft.tracking.launchAtLogin && !model.draft.tracking.restoreTracking)
|
||||
#expect(generated == 0 && fixture.connection.writes == 0)
|
||||
#expect(model.save())
|
||||
#expect(generated == 1)
|
||||
#expect(model.save())
|
||||
#expect(generated == 1)
|
||||
#expect(try fixture.storage.load().connection.deviceIdentifier == "12345678")
|
||||
}
|
||||
|
||||
@Test func randomIdentityHasEightDigitsAndSurvivesModelRecreation() throws {
|
||||
let fixture = try Fixture()
|
||||
defer { fixture.cleanUp() }
|
||||
let model = ConfigurationModel(storage: fixture.storage)
|
||||
model.loadIfNeeded()
|
||||
#expect(model.save())
|
||||
let identifier = model.draft.connection.deviceIdentifier
|
||||
#expect(identifier.count == 8 && identifier.utf8.allSatisfy { (48...57).contains($0) })
|
||||
let reopened = ConfigurationModel(storage: fixture.storage, generateIdentifier: {
|
||||
Issue.record("Loading an existing identity must never generate another one")
|
||||
return "unexpected"
|
||||
})
|
||||
reopened.loadIfNeeded()
|
||||
#expect(reopened.draft.connection.deviceIdentifier == identifier)
|
||||
}
|
||||
|
||||
@Test func roundTripPreservesSettingsAndExactIdentifier() throws {
|
||||
let fixture = try Fixture()
|
||||
defer { fixture.cleanUp() }
|
||||
var configuration = AppConfiguration()
|
||||
configuration.connection = ReceiverConnection(
|
||||
serverURL: "https://tracker.example:8443/OsmAnd?token=synthetic", deviceIdentifier: "000123-AbC"
|
||||
)
|
||||
configuration.tracking.intervalSeconds = 42
|
||||
configuration.tracking.distanceMetres = 12.5
|
||||
configuration.tracking.accuracy = .high
|
||||
configuration.tracking.angleDegrees = 30
|
||||
configuration.tracking.heartbeatSeconds = 60
|
||||
configuration.tracking.bufferingEnabled = false
|
||||
configuration.tracking.stationaryDetectionEnabled = false
|
||||
try fixture.storage.save(configuration)
|
||||
let reopened = PreferencesConfigurationStorage(
|
||||
preferences: fixture.preferences, connectionStorage: fixture.connection
|
||||
)
|
||||
#expect(try reopened.load() == configuration)
|
||||
let data = try #require(fixture.preferences.data(forKey: PreferencesConfigurationStorage.preferencesKey))
|
||||
let text = try #require(String(data: data, encoding: .utf8))
|
||||
#expect(!text.contains("000123") && !text.contains("tracker.example") && !text.contains("synthetic"))
|
||||
}
|
||||
|
||||
@Test(arguments: [
|
||||
"", "ftp://example.com", "https:///", "example.com:5055", "http://host:0", "http://host:65536",
|
||||
"http://host:abc", "http://host:", "https://host/a%xx", " https://host", "https://host/a b",
|
||||
"https://user:password@host", "https://host/#fragment",
|
||||
])
|
||||
func invalidURLsAreRejected(value: String) {
|
||||
#expect(throws: ConfigurationError.invalidURL) { try ReceiverConnection.canonicalURL(value) }
|
||||
}
|
||||
|
||||
@Test(arguments: [
|
||||
"http://192.0.2.1:5055", "https://tracker.example:8443/OsmAnd?key=synthetic",
|
||||
"http://[2001:db8::1]:5055/receiver", "https://tracker.example/a%20b",
|
||||
"https://tracker.example/receiver?next=http://synthetic:",
|
||||
])
|
||||
func supportedURLsRoundTrip(value: String) throws {
|
||||
#expect(try ReceiverConnection.canonicalURL(value) == value)
|
||||
}
|
||||
|
||||
@Test func URLNormalizationPreservesPathAndQueryCase() throws {
|
||||
#expect(try ReceiverConnection.canonicalURL("HTTPS://TRACKER.EXAMPLE:443/") == "https://tracker.example")
|
||||
#expect(try ReceiverConnection.canonicalURL("http://TRACKER.EXAMPLE:80/Path?Key=Value")
|
||||
== "http://tracker.example/Path?Key=Value")
|
||||
}
|
||||
|
||||
@Test func invalidTrackingAndIdentifierDoNotWrite() throws {
|
||||
let fixture = try Fixture()
|
||||
defer { fixture.cleanUp() }
|
||||
let model = ConfigurationModel(storage: fixture.storage)
|
||||
model.loadIfNeeded()
|
||||
model.draft.tracking.intervalSeconds = -.infinity
|
||||
#expect(!model.save())
|
||||
model.draft.tracking = TrackingPreferences()
|
||||
model.draft.connection.deviceIdentifier = "\n"
|
||||
#expect(!model.save())
|
||||
#expect(fixture.connection.writes == 0)
|
||||
#expect(model.saved == AppConfiguration())
|
||||
for value in [Double.nan, .infinity, -1] {
|
||||
var tracking = TrackingPreferences()
|
||||
tracking.distanceMetres = value
|
||||
#expect(throws: ConfigurationError.invalidTracking) { try tracking.validate() }
|
||||
}
|
||||
var tracking = TrackingPreferences()
|
||||
tracking.angleDegrees = 181
|
||||
#expect(throws: ConfigurationError.invalidTracking) { try tracking.validate() }
|
||||
}
|
||||
|
||||
@Test func failedSaveRetainsGeneratedIdentityAndPreferences() throws {
|
||||
let fixture = try Fixture()
|
||||
defer { fixture.cleanUp() }
|
||||
var generated = 0
|
||||
let model = ConfigurationModel(storage: fixture.storage, generateIdentifier: {
|
||||
generated += 1
|
||||
return "87654321"
|
||||
})
|
||||
model.loadIfNeeded()
|
||||
fixture.connection.writeError = .keychain(errSecAuthFailed)
|
||||
#expect(!model.save())
|
||||
#expect(model.draft.connection.deviceIdentifier == "87654321")
|
||||
#expect(model.saved == AppConfiguration())
|
||||
#expect(fixture.preferences.object(forKey: PreferencesConfigurationStorage.preferencesKey) == nil)
|
||||
fixture.connection.writeError = nil
|
||||
#expect(model.save())
|
||||
#expect(generated == 1)
|
||||
}
|
||||
|
||||
@Test func unavailableKeychainBlocksSaveUntilReload() throws {
|
||||
let fixture = try Fixture()
|
||||
defer { fixture.cleanUp() }
|
||||
fixture.connection.readError = .keychain(errSecInteractionNotAllowed)
|
||||
let model = ConfigurationModel(storage: fixture.storage)
|
||||
model.loadIfNeeded()
|
||||
#expect(!model.hasLoaded && model.needsKeychainAuthorization)
|
||||
#expect(!model.save())
|
||||
#expect(fixture.connection.writes == 0)
|
||||
fixture.connection.readError = nil
|
||||
model.reload()
|
||||
#expect(model.hasLoaded)
|
||||
}
|
||||
|
||||
@Test func missingIdentityDoesNotSilentlyReplaceDevice() throws {
|
||||
let fixture = try Fixture()
|
||||
defer { fixture.cleanUp() }
|
||||
var configuration = AppConfiguration()
|
||||
configuration.connection.deviceIdentifier = "00123"
|
||||
try fixture.storage.save(configuration)
|
||||
fixture.connection.value = nil
|
||||
#expect(throws: ConfigurationError.missingIdentity) { try fixture.storage.load() }
|
||||
}
|
||||
|
||||
@Test func corruptOrFuturePreferencesAreNotReset() throws {
|
||||
let fixture = try Fixture()
|
||||
defer { fixture.cleanUp() }
|
||||
let key = PreferencesConfigurationStorage.preferencesKey
|
||||
fixture.preferences.set(Data("invalid".utf8), forKey: key)
|
||||
#expect(throws: ConfigurationError.unreadableStorage) { try fixture.storage.load() }
|
||||
let encoded = try JSONEncoder().encode(TrackingPreferences())
|
||||
let tracking = try #require(String(data: encoded, encoding: .utf8))
|
||||
fixture.preferences.set(Data("{\"version\":2,\"tracking\":\(tracking)}".utf8), forKey: key)
|
||||
#expect(throws: ConfigurationError.unsupportedStorageVersion) { try fixture.storage.load() }
|
||||
fixture.preferences.set("unexpected type", forKey: key)
|
||||
#expect(throws: ConfigurationError.unreadableStorage) { try fixture.storage.load() }
|
||||
#expect(fixture.connection.writes == 0)
|
||||
}
|
||||
|
||||
@Test func connectionGuardRunsBeforeMutationAndSkipsTrackingOnlyChanges() throws {
|
||||
let fixture = try Fixture()
|
||||
defer { fixture.cleanUp() }
|
||||
fixture.connection.value = ReceiverConnection(serverURL: "https://tracker.example", deviceIdentifier: "00123")
|
||||
var guarded = 0
|
||||
let model = ConfigurationModel(storage: fixture.storage, beforeConnectionChange: { _, _ in
|
||||
guarded += 1
|
||||
throw ConfigurationError.notLoaded
|
||||
})
|
||||
model.loadIfNeeded()
|
||||
model.draft.connection.serverURL = "HTTPS://TRACKER.EXAMPLE:443/"
|
||||
model.draft.tracking.intervalSeconds = 90
|
||||
#expect(model.save())
|
||||
#expect(guarded == 0)
|
||||
model.draft.connection.deviceIdentifier = "other-device"
|
||||
#expect(!model.save())
|
||||
#expect(guarded == 1 && fixture.connection.writes == 1)
|
||||
#expect(fixture.connection.value?.deviceIdentifier == "00123")
|
||||
model.draft.connection.deviceIdentifier = "00123"
|
||||
model.draft.connection.serverURL = "https://other.example"
|
||||
#expect(!model.save())
|
||||
#expect(guarded == 2 && fixture.connection.writes == 1)
|
||||
}
|
||||
|
||||
@Test func keychainUsesUpdateThenAddOnlyWhenMissing() throws {
|
||||
let operations = FakeKeychainOperations()
|
||||
let storage = KeychainConnectionStorage(service: "test.synthetic", operations: operations)
|
||||
let connection = ReceiverConnection(deviceIdentifier: "000123")
|
||||
#expect(try storage.read() == nil)
|
||||
try storage.write(connection)
|
||||
#expect(operations.adds == 1)
|
||||
operations.updateStatus = errSecSuccess
|
||||
try storage.write(connection)
|
||||
#expect(operations.adds == 1)
|
||||
operations.readStatus = errSecSuccess
|
||||
#expect(try storage.read() == connection)
|
||||
operations.updateStatus = errSecAuthFailed
|
||||
#expect(throws: ConfigurationError.keychain(errSecAuthFailed)) { try storage.write(connection) }
|
||||
#expect(operations.adds == 1)
|
||||
operations.readStatus = errSecInteractionNotAllowed
|
||||
#expect(throws: ConfigurationError.keychain(errSecInteractionNotAllowed)) { try storage.read() }
|
||||
operations.readStatus = errSecSuccess
|
||||
operations.data = Data("broken".utf8)
|
||||
#expect(throws: ConfigurationError.unreadableStorage) { try storage.read() }
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
struct Fixture {
|
||||
let suite = "TraccarConfigurationTests.\(UUID().uuidString)"
|
||||
let preferences: UserDefaults
|
||||
let connection = FakeConnectionStorage()
|
||||
let storage: PreferencesConfigurationStorage
|
||||
|
||||
init() throws {
|
||||
preferences = try #require(UserDefaults(suiteName: suite))
|
||||
storage = PreferencesConfigurationStorage(preferences: preferences, connectionStorage: connection)
|
||||
}
|
||||
|
||||
func cleanUp() { preferences.removePersistentDomain(forName: suite) }
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class FakeConnectionStorage: ConnectionStorage {
|
||||
var value: ReceiverConnection?
|
||||
var readError: ConfigurationError?
|
||||
var writeError: ConfigurationError?
|
||||
var writes = 0
|
||||
var reads = 0
|
||||
var readModes: [Bool] = []
|
||||
|
||||
func read(allowInteraction: Bool) throws -> ReceiverConnection? {
|
||||
reads += 1
|
||||
readModes.append(allowInteraction)
|
||||
if let readError { throw readError }
|
||||
return value
|
||||
}
|
||||
|
||||
func write(_ connection: ReceiverConnection) throws {
|
||||
if let writeError { throw writeError }
|
||||
writes += 1
|
||||
value = connection
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class FakeKeychainOperations: KeychainOperations {
|
||||
var readStatus = errSecItemNotFound
|
||||
var updateStatus = errSecItemNotFound
|
||||
var adds = 0
|
||||
var data: Data?
|
||||
var readModes: [Bool] = []
|
||||
|
||||
func copyMatching(
|
||||
_ query: CFDictionary, allowInteraction: Bool, result: UnsafeMutablePointer<CFTypeRef?>
|
||||
) -> OSStatus {
|
||||
readModes.append(allowInteraction)
|
||||
let attributes = query as NSDictionary
|
||||
#expect(attributes[kSecAttrService] as? String == "test.synthetic")
|
||||
#expect(attributes[kSecAttrSynchronizable] as? Bool == false)
|
||||
result.pointee = data as CFData?
|
||||
return readStatus
|
||||
}
|
||||
|
||||
func update(_ query: CFDictionary, attributes: CFDictionary) -> OSStatus {
|
||||
if updateStatus == errSecSuccess { data = (attributes as NSDictionary)[kSecValueData] as? Data }
|
||||
return updateStatus
|
||||
}
|
||||
|
||||
func add(_ attributes: CFDictionary) -> OSStatus {
|
||||
adds += 1
|
||||
data = (attributes as NSDictionary)[kSecValueData] as? Data
|
||||
return errSecSuccess
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class ControlledStatusDelay {
|
||||
private var continuations: [CheckedContinuation<Void, Never>?] = []
|
||||
private var returned: Set<Int> = []
|
||||
|
||||
func wait() async {
|
||||
let index = continuations.count
|
||||
await withCheckedContinuation { continuations.append($0) }
|
||||
returned.insert(index)
|
||||
}
|
||||
|
||||
func waitUntilScheduled(_ count: Int) async {
|
||||
while continuations.count < count { await Task.yield() }
|
||||
}
|
||||
|
||||
func finish(_ index: Int) {
|
||||
continuations[index]?.resume()
|
||||
continuations[index] = nil
|
||||
}
|
||||
|
||||
func waitUntilReturned(_ index: Int) async {
|
||||
while !returned.contains(index) { await Task.yield() }
|
||||
}
|
||||
}
|
||||
|
||||
extension ConfigurationTests {
|
||||
@Test func informationalStatusExpiresAfterDelay() async throws {
|
||||
let fixture = try Fixture()
|
||||
defer { fixture.cleanUp() }
|
||||
let delay = ControlledStatusDelay()
|
||||
let model = ConfigurationModel(storage: fixture.storage, statusDelay: { await delay.wait() })
|
||||
model.loadIfNeeded()
|
||||
#expect(model.save())
|
||||
#expect(model.statusMessage != nil)
|
||||
await delay.waitUntilScheduled(1)
|
||||
delay.finish(0)
|
||||
await delay.waitUntilReturned(0)
|
||||
#expect(model.statusMessage == nil)
|
||||
model.reload()
|
||||
#expect(model.statusMessage == "Configuration loaded.")
|
||||
await delay.waitUntilScheduled(2)
|
||||
delay.finish(1)
|
||||
await delay.waitUntilReturned(1)
|
||||
#expect(model.statusMessage == nil)
|
||||
}
|
||||
|
||||
@Test func olderTimerCannotDismissNewerStatus() async throws {
|
||||
let fixture = try Fixture()
|
||||
defer { fixture.cleanUp() }
|
||||
let delay = ControlledStatusDelay()
|
||||
let model = ConfigurationModel(storage: fixture.storage, statusDelay: { await delay.wait() })
|
||||
model.loadIfNeeded()
|
||||
#expect(model.save())
|
||||
await delay.waitUntilScheduled(1)
|
||||
#expect(model.save())
|
||||
await delay.waitUntilScheduled(2)
|
||||
delay.finish(0)
|
||||
await delay.waitUntilReturned(0)
|
||||
#expect(model.statusMessage != nil)
|
||||
delay.finish(1)
|
||||
await delay.waitUntilReturned(1)
|
||||
#expect(model.statusMessage == nil)
|
||||
}
|
||||
|
||||
@Test func errorsRemainVisibleWhenOldTimerFinishes() async throws {
|
||||
let fixture = try Fixture()
|
||||
defer { fixture.cleanUp() }
|
||||
let delay = ControlledStatusDelay()
|
||||
let model = ConfigurationModel(storage: fixture.storage, statusDelay: { await delay.wait() })
|
||||
model.loadIfNeeded()
|
||||
#expect(model.save())
|
||||
await delay.waitUntilScheduled(1)
|
||||
model.draft.connection.serverURL = "invalid"
|
||||
#expect(!model.save())
|
||||
let error = model.errorMessage
|
||||
delay.finish(0)
|
||||
await delay.waitUntilReturned(0)
|
||||
#expect(model.statusMessage == nil && model.errorMessage == error && error != nil)
|
||||
}
|
||||
|
||||
}
|
||||
Reference in new issue
Block a user