Files
traccar-client-macos/TraccarClientTests/ConfigurationTests.swift
T
jayanezj 026cd6aa40 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
2026-10-25 10:43:43 +01:00

372 lines
15 KiB
Swift

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)
}
}