Files
openclaw/apps/ios/Tests/GatewaySettingsStoreTests.swift
Peter Steinberger 36b733bf5c feat(ios): pair multiple gateways and switch between them without re-pairing (#100948)
* feat(ios): multi-gateway registry, per-gateway credentials, and switching

* fix(ios): harden multi-gateway state isolation
2026-07-06 20:29:02 +01:00

720 lines
32 KiB
Swift

import Foundation
import OpenClawKit
import Testing
@testable import OpenClaw
private struct KeychainEntry: Hashable {
let service: String
let account: String
}
private let gatewayService = GatewaySettingsStore._testGatewayService
private let nodeService = "ai.openclawfoundation.app.node"
private let instanceIdEntry = KeychainEntry(service: nodeService, account: "instanceId")
private let preferredGatewayEntry = KeychainEntry(service: gatewayService, account: "preferredStableID")
private let lastGatewayEntry = KeychainEntry(service: gatewayService, account: "lastDiscoveredStableID")
private let bootstrapDefaultsKeys = [
"node.instanceId",
"gateway.preferredStableID",
"gateway.lastDiscoveredStableID",
]
private let bootstrapKeychainEntries = [instanceIdEntry, preferredGatewayEntry, lastGatewayEntry]
private let lastGatewayDefaultsKeys = [
"gateway.last.kind",
"gateway.last.host",
"gateway.last.port",
"gateway.last.tls",
"gateway.last.stableID",
]
private let lastGatewayKeychainEntry = KeychainEntry(service: gatewayService, account: "lastConnection")
private let gatewayRegistryKeychainEntry = KeychainEntry(service: gatewayService, account: "gateway-registry")
private func snapshotDefaults(_ keys: [String]) -> [String: Any?] {
let defaults = UserDefaults.standard
var snapshot: [String: Any?] = [:]
for key in keys {
snapshot[key] = defaults.object(forKey: key)
}
return snapshot
}
private func applyDefaults(_ values: [String: Any?]) {
let defaults = UserDefaults.standard
for (key, value) in values {
if let value {
defaults.set(value, forKey: key)
} else {
defaults.removeObject(forKey: key)
}
}
}
private func restoreDefaults(_ snapshot: [String: Any?]) {
applyDefaults(snapshot)
}
private func snapshotKeychain(_ entries: [KeychainEntry]) -> [KeychainEntry: String?] {
var snapshot: [KeychainEntry: String?] = [:]
for entry in entries {
snapshot[entry] = KeychainStore.loadString(service: entry.service, account: entry.account)
}
return snapshot
}
private func applyKeychain(_ values: [KeychainEntry: String?]) {
for (entry, value) in values {
if let value {
_ = KeychainStore.saveString(value, service: entry.service, account: entry.account)
} else {
_ = KeychainStore.delete(service: entry.service, account: entry.account)
}
}
}
private func restoreKeychain(_ snapshot: [KeychainEntry: String?]) {
applyKeychain(snapshot)
}
private func withBootstrapSnapshots(_ body: () -> Void) {
gatewayPersistenceTestSemaphore.wait()
let defaultsSnapshot = snapshotDefaults(bootstrapDefaultsKeys + lastGatewayDefaultsKeys)
let keychainSnapshot = snapshotKeychain(
bootstrapKeychainEntries + [lastGatewayKeychainEntry, gatewayRegistryKeychainEntry])
defer {
restoreDefaults(defaultsSnapshot)
restoreKeychain(keychainSnapshot)
gatewayPersistenceTestSemaphore.signal()
}
body()
}
private func withLastGatewaySnapshot(_ body: () -> Void) {
gatewayPersistenceTestSemaphore.wait()
let defaultsSnapshot = snapshotDefaults(lastGatewayDefaultsKeys)
let keychainSnapshot = snapshotKeychain([lastGatewayKeychainEntry, gatewayRegistryKeychainEntry])
defer {
restoreDefaults(defaultsSnapshot)
restoreKeychain(keychainSnapshot)
gatewayPersistenceTestSemaphore.signal()
}
body()
}
@Suite(.serialized) struct GatewaySettingsStoreTests {
@Test func `custom headers round trip per gateway`() {
let service = "\(gatewayService).custom-headers-test.\(UUID().uuidString)"
let gatewayID = "manual|headers.example.com|443|\(UUID().uuidString)"
let otherGatewayID = "manual|other.example.com|443|\(UUID().uuidString)"
defer { GatewaySettingsStore.clearGatewayCustomHeaders(service: service) }
#expect(GatewaySettingsStore.loadGatewayCustomHeaders(gatewayStableID: gatewayID, service: service).isEmpty)
#expect(GatewaySettingsStore.saveGatewayCustomHeaders(
["CF-Access-Client-Id": "client-id", "CF-Access-Client-Secret": "client-secret"],
gatewayStableID: gatewayID,
service: service))
#expect(GatewaySettingsStore.loadGatewayCustomHeaders(gatewayStableID: gatewayID, service: service) == [
"CF-Access-Client-Id": "client-id",
"CF-Access-Client-Secret": "client-secret",
])
#expect(GatewaySettingsStore.loadGatewayCustomHeaders(
gatewayStableID: otherGatewayID,
service: service).isEmpty)
#expect(GatewaySettingsStore.saveGatewayCustomHeaders(
["X-Other": "other-value"],
gatewayStableID: otherGatewayID,
service: service))
#expect(GatewaySettingsStore.saveGatewayCustomHeaders(
[:],
gatewayStableID: gatewayID,
service: service))
#expect(GatewaySettingsStore.loadGatewayCustomHeaders(
gatewayStableID: gatewayID,
service: service).isEmpty)
#expect(GatewaySettingsStore.loadGatewayCustomHeaders(
gatewayStableID: otherGatewayID,
service: service) == ["X-Other": "other-value"])
}
@Test func `custom header storage drops reserved names`() {
let service = "\(gatewayService).custom-headers-test.\(UUID().uuidString)"
let gatewayID = "manual|reserved.example.com|443|\(UUID().uuidString)"
defer { GatewaySettingsStore.clearGatewayCustomHeaders(service: service) }
#expect(GatewaySettingsStore.saveGatewayCustomHeaders(
["Host": "smuggled.example", "X-Allowed": "yes"],
gatewayStableID: gatewayID,
service: service))
#expect(GatewaySettingsStore.loadGatewayCustomHeaders(gatewayStableID: gatewayID, service: service)
== ["X-Allowed": "yes"])
}
@Test func `custom header reset clears every gateway but preserves unrelated credentials`() {
let service = "\(gatewayService).custom-headers-test.\(UUID().uuidString)"
let firstGatewayID = "manual|first-reset.example.com|443|\(UUID().uuidString)"
let secondGatewayID = "manual|second-reset.example.com|443|\(UUID().uuidString)"
let unrelatedAccount = "unrelated-reset-secret.\(UUID().uuidString)"
defer { _ = KeychainStore.delete(service: gatewayService, account: unrelatedAccount) }
#expect(GatewaySettingsStore.saveGatewayCustomHeaders(
["X-Proxy-Token": "first"],
gatewayStableID: firstGatewayID,
service: service))
#expect(GatewaySettingsStore.saveGatewayCustomHeaders(
["X-Proxy-Token": "second"],
gatewayStableID: secondGatewayID,
service: service))
#expect(KeychainStore.saveString("keep", service: gatewayService, account: unrelatedAccount))
#expect(GatewaySettingsStore.clearGatewayCustomHeaders(service: service))
#expect(GatewaySettingsStore.loadGatewayCustomHeaders(
gatewayStableID: firstGatewayID,
service: service).isEmpty)
#expect(GatewaySettingsStore.loadGatewayCustomHeaders(
gatewayStableID: secondGatewayID,
service: service).isEmpty)
#expect(KeychainStore.loadString(service: gatewayService, account: unrelatedAccount) == "keep")
}
@Test func `custom header forget clears only one gateway`() {
let service = "\(gatewayService).custom-headers-test.\(UUID().uuidString)"
let forgottenGatewayID = "manual|forgotten.example.com|443|\(UUID().uuidString)"
let keptGatewayID = "manual|kept.example.com|443|\(UUID().uuidString)"
defer { GatewaySettingsStore.clearGatewayCustomHeaders(service: service) }
for gatewayID in [forgottenGatewayID, keptGatewayID] {
#expect(GatewaySettingsStore.saveGatewayCustomHeaders(
["X-Proxy-Token": gatewayID],
gatewayStableID: gatewayID,
service: service))
}
#expect(GatewaySettingsStore.clearGatewayCustomHeaders(
gatewayStableID: forgottenGatewayID,
service: service))
#expect(GatewaySettingsStore.loadGatewayCustomHeaders(
gatewayStableID: forgottenGatewayID,
service: service).isEmpty)
#expect(GatewaySettingsStore.loadGatewayCustomHeaders(
gatewayStableID: keptGatewayID,
service: service) == ["X-Proxy-Token": keptGatewayID])
}
@Test func `credentials stay bound to their gateway`() {
let instanceID = "credential-owner-\(UUID().uuidString)"
defer { GatewaySettingsStore.deleteAllGatewayCredentials(instanceId: instanceID) }
let firstGatewayID = "manual|first.example.com|443"
let secondGatewayID = "manual|second.example.com|443"
GatewaySettingsStore.saveGatewayCredentials(
token: "first-token",
bootstrapToken: nil,
password: "first-password",
gatewayStableID: firstGatewayID,
suppressStoredDeviceAuth: true,
instanceId: instanceID)
let first = GatewaySettingsStore.loadGatewayCredentials(
instanceId: instanceID,
gatewayStableID: firstGatewayID)
#expect(first.token == "first-token")
#expect(first.password == "first-password")
#expect(first.suppressStoredDeviceAuth)
#expect(GatewaySettingsStore.loadGatewayCredentials(
instanceId: instanceID,
gatewayStableID: secondGatewayID) == .empty)
GatewaySettingsStore.discardUnscopedGatewayCredentials(instanceId: instanceID)
#expect(GatewaySettingsStore.loadGatewayCredentials(
instanceId: instanceID,
gatewayStableID: secondGatewayID) == .empty)
}
@Test func `shared tls certificate does not alias distinct routes`() {
let instanceID = "tls-owner-\(UUID().uuidString)"
let discoveredID = "bonjour|_openclaw._tcp|local|gateway-\(UUID().uuidString)"
let manualID = "manual|gateway-\(UUID().uuidString).local|443"
let fingerprint = "AA:BB:CC:DD"
defer {
GatewaySettingsStore.deleteAllGatewayCredentials(instanceId: instanceID)
GatewayTLSStore.clearFingerprint(stableID: discoveredID)
GatewayTLSStore.clearFingerprint(stableID: manualID)
}
GatewaySettingsStore.saveGatewayCredentials(
token: "shared-token",
bootstrapToken: nil,
password: "shared-password",
gatewayStableID: discoveredID,
suppressStoredDeviceAuth: false,
instanceId: instanceID)
GatewayTLSStore.saveFingerprint(fingerprint, stableID: discoveredID)
GatewayTLSStore.saveFingerprint(fingerprint, stableID: manualID)
let manualCredentials = GatewaySettingsStore.loadGatewayCredentials(
instanceId: instanceID,
gatewayStableID: manualID)
#expect(manualCredentials == .empty)
#expect(GatewaySettingsStore.authenticationOwnerID(routeStableID: discoveredID) == discoveredID)
#expect(GatewaySettingsStore.authenticationOwnerID(routeStableID: manualID) == manualID)
#expect(GatewaySettingsStore.loadGatewayCredentialMetadata(
instanceId: instanceID,
gatewayStableID: discoveredID)?.gatewayStableID == discoveredID)
}
@Test func `ambiguous legacy credentials are discarded`() {
let instanceID = "legacy-credential-owner-\(UUID().uuidString)"
defer { GatewaySettingsStore.deleteAllGatewayCredentials(instanceId: instanceID) }
let firstGatewayID = "manual|first.example.com|443"
let secondGatewayID = "manual|second.example.com|443"
GatewaySettingsStore.saveLegacyGatewayTokenForMigrationTest("legacy-token", instanceId: instanceID)
GatewaySettingsStore.discardUnscopedGatewayCredentials(instanceId: instanceID)
#expect(GatewaySettingsStore.loadGatewayCredentials(
instanceId: instanceID,
gatewayStableID: firstGatewayID) == .empty)
#expect(GatewaySettingsStore.loadGatewayCredentials(
instanceId: instanceID,
gatewayStableID: secondGatewayID) == .empty)
#expect(KeychainStore.loadString(
service: gatewayService,
account: "gateway-token.\(instanceID)") == nil)
#expect(KeychainStore.loadString(
service: gatewayService,
account: "gateway-credentials.\(instanceID)") == nil)
}
@Test func `proven relay migration does not overwrite a canonical credential bundle`() {
let instanceID = "relay-migration-owner-\(UUID().uuidString)"
defer { GatewaySettingsStore.deleteAllGatewayCredentials(instanceId: instanceID) }
let gatewayID = "manual|gateway.example.com|443"
GatewaySettingsStore.saveGatewayCredentials(
token: "current-token",
bootstrapToken: "current-bootstrap",
password: "current-password",
gatewayStableID: gatewayID,
suppressStoredDeviceAuth: true,
instanceId: instanceID)
GatewaySettingsStore.saveLegacyGatewayTokenForMigrationTest(
"obsolete-token",
instanceId: instanceID)
#expect(GatewaySettingsStore.migrateProvenRelayCredentials(
instanceId: instanceID,
gatewayStableID: gatewayID,
token: "stale-relay-token",
password: "stale-relay-password"))
let credentials = GatewaySettingsStore.loadGatewayCredentials(
instanceId: instanceID,
gatewayStableID: gatewayID)
#expect(credentials.token == "current-token")
#expect(credentials.bootstrapToken == "current-bootstrap")
#expect(credentials.password == "current-password")
#expect(credentials.suppressStoredDeviceAuth)
#expect(KeychainStore.loadString(
service: gatewayService,
account: "gateway-token.\(instanceID)") == nil)
}
@Test func `proven relay credentials are not reimported after legacy cleanup`() {
let instanceID = "completed-relay-migration-\(UUID().uuidString)"
defer { GatewaySettingsStore.deleteAllGatewayCredentials(instanceId: instanceID) }
let gatewayID = "manual|gateway.example.com|443"
#expect(GatewaySettingsStore.migrateProvenRelayCredentials(
instanceId: instanceID,
gatewayStableID: gatewayID,
token: "stale-relay-token",
password: "stale-relay-password"))
#expect(GatewaySettingsStore.loadGatewayCredentials(
instanceId: instanceID,
gatewayStableID: gatewayID) == .empty)
}
@Test func `credentialless setup suppresses stored auth until handoff completes`() {
let instanceID = "credentialless-owner-\(UUID().uuidString)"
defer { GatewaySettingsStore.deleteAllGatewayCredentials(instanceId: instanceID) }
let gatewayID = "manual|gateway.example.com|443"
GatewaySettingsStore.saveGatewayCredentials(
token: nil,
bootstrapToken: nil,
password: nil,
gatewayStableID: gatewayID,
suppressStoredDeviceAuth: true,
instanceId: instanceID)
let pending = GatewaySettingsStore.loadGatewayCredentials(
instanceId: instanceID,
gatewayStableID: gatewayID)
#expect(!pending.hasCredentials)
#expect(pending.suppressStoredDeviceAuth)
GatewaySettingsStore.completeGatewayCredentialHandoff(
instanceId: instanceID,
gatewayStableID: gatewayID)
#expect(GatewaySettingsStore.loadGatewayCredentials(
instanceId: instanceID,
gatewayStableID: gatewayID) == .empty)
#expect(GatewaySettingsStore.loadGatewayCredentialMetadata(
instanceId: instanceID,
gatewayStableID: gatewayID) == nil)
}
@Test func `bootstrap handoff clears bootstrap while enabling stored auth`() {
let instanceID = "bootstrap-handoff-\(UUID().uuidString)"
defer { GatewaySettingsStore.deleteAllGatewayCredentials(instanceId: instanceID) }
let gatewayID = "manual|gateway.example.com|443"
GatewaySettingsStore.saveGatewayCredentials(
token: "shared-token",
bootstrapToken: "one-time-bootstrap",
password: nil,
gatewayStableID: gatewayID,
suppressStoredDeviceAuth: true,
instanceId: instanceID)
#expect(GatewaySettingsStore.completeGatewayCredentialHandoff(
instanceId: instanceID,
gatewayStableID: gatewayID))
let completed = GatewaySettingsStore.loadGatewayCredentials(
instanceId: instanceID,
gatewayStableID: gatewayID)
#expect(completed.token == "shared-token")
#expect(completed.bootstrapToken == nil)
#expect(!completed.suppressStoredDeviceAuth)
}
@Test func `field edits preserve pending bootstrap handoff for the same gateway`() {
let instanceID = "edited-credential-owner-\(UUID().uuidString)"
defer { GatewaySettingsStore.deleteAllGatewayCredentials(instanceId: instanceID) }
let gatewayID = "manual|gateway.example.com|443"
GatewaySettingsStore.saveGatewayCredentials(
token: nil,
bootstrapToken: "bootstrap-token",
password: nil,
gatewayStableID: gatewayID,
suppressStoredDeviceAuth: true,
instanceId: instanceID)
GatewaySettingsStore.updateGatewayCredentials(
token: "edited-token",
password: "edited-password",
gatewayStableID: gatewayID,
instanceId: instanceID)
let credentials = GatewaySettingsStore.loadGatewayCredentials(
instanceId: instanceID,
gatewayStableID: gatewayID)
#expect(credentials.token == "edited-token")
#expect(credentials.bootstrapToken == "bootstrap-token")
#expect(credentials.password == "edited-password")
#expect(credentials.suppressStoredDeviceAuth)
}
@Test func `field edits do not carry pending handoff to another gateway`() {
let instanceID = "switched-credential-owner-\(UUID().uuidString)"
defer { GatewaySettingsStore.deleteAllGatewayCredentials(instanceId: instanceID) }
let firstGatewayID = "manual|first.example.com|443"
let secondGatewayID = "manual|second.example.com|443"
GatewaySettingsStore.saveGatewayCredentials(
token: "first-token",
bootstrapToken: "first-bootstrap-token",
password: "first-password",
gatewayStableID: firstGatewayID,
suppressStoredDeviceAuth: true,
instanceId: instanceID)
GatewaySettingsStore.updateGatewayCredentials(
token: "second-token",
password: nil,
gatewayStableID: secondGatewayID,
instanceId: instanceID)
let first = GatewaySettingsStore.loadGatewayCredentials(
instanceId: instanceID,
gatewayStableID: firstGatewayID)
#expect(first.token == "first-token")
#expect(first.bootstrapToken == "first-bootstrap-token")
#expect(first.password == "first-password")
#expect(first.suppressStoredDeviceAuth)
let second = GatewaySettingsStore.loadGatewayCredentials(
instanceId: instanceID,
gatewayStableID: secondGatewayID)
#expect(second.token == "second-token")
#expect(second.bootstrapToken == nil)
#expect(second.password == nil)
#expect(!second.suppressStoredDeviceAuth)
}
@Test func `clearing ordinary credentials removes their owner metadata`() {
let instanceID = "cleared-credential-owner-\(UUID().uuidString)"
defer { GatewaySettingsStore.deleteAllGatewayCredentials(instanceId: instanceID) }
let gatewayID = "manual|gateway.example.com|443"
GatewaySettingsStore.saveGatewayCredentials(
token: "one-time-token",
bootstrapToken: nil,
password: nil,
gatewayStableID: gatewayID,
suppressStoredDeviceAuth: false,
instanceId: instanceID)
GatewaySettingsStore.updateGatewayCredentials(
token: nil,
password: nil,
gatewayStableID: gatewayID,
instanceId: instanceID)
#expect(GatewaySettingsStore.loadGatewayCredentialMetadata(
instanceId: instanceID,
gatewayStableID: gatewayID) == nil)
#expect(GatewaySettingsStore.loadGatewayCredentials(
instanceId: instanceID,
gatewayStableID: gatewayID) == .empty)
}
@Test func `bootstrap copies defaults to keychain when missing`() {
withBootstrapSnapshots {
applyDefaults([
"node.instanceId": "node-test",
"gateway.preferredStableID": "preferred-test",
"gateway.lastDiscoveredStableID": "last-test",
])
applyKeychain([
instanceIdEntry: nil,
preferredGatewayEntry: nil,
lastGatewayEntry: nil,
])
GatewaySettingsStore.bootstrapPersistence()
#expect(KeychainStore.loadString(service: nodeService, account: "instanceId") == "node-test")
#expect(KeychainStore.loadString(service: gatewayService, account: "preferredStableID") == "preferred-test")
#expect(KeychainStore.loadString(service: gatewayService, account: "lastDiscoveredStableID") == "last-test")
}
}
@Test func `bootstrap copies keychain to defaults when missing`() {
withBootstrapSnapshots {
applyDefaults([
"node.instanceId": nil,
"gateway.preferredStableID": nil,
"gateway.lastDiscoveredStableID": nil,
])
applyKeychain([
instanceIdEntry: "node-from-keychain",
preferredGatewayEntry: "preferred-from-keychain",
lastGatewayEntry: "last-from-keychain",
])
GatewaySettingsStore.bootstrapPersistence()
let defaults = UserDefaults.standard
#expect(defaults.string(forKey: "node.instanceId") == "node-from-keychain")
#expect(defaults.string(forKey: "gateway.preferredStableID") == "preferred-from-keychain")
#expect(defaults.string(forKey: "gateway.lastDiscoveredStableID") == "last-from-keychain")
}
}
@Test func `registry CRUD round trip persists deterministic ordering`() {
withLastGatewaySnapshot {
applyKeychain([gatewayRegistryKeychainEntry: nil, lastGatewayKeychainEntry: nil])
let gatewayB = GatewaySettingsStore.GatewayRegistryEntry(
stableID: "manual|z.example.com|443",
kind: .manual,
name: "Zulu",
host: "z.example.com",
port: 443,
useTLS: true,
lastConnectedAtMs: nil)
let gatewayA = GatewaySettingsStore.GatewayRegistryEntry(
stableID: "bonjour|alpha",
kind: .discovered,
name: "Alpha",
host: nil,
port: nil,
useTLS: true,
lastConnectedAtMs: nil)
#expect(GatewaySettingsStore.upsertGatewayRegistryEntry(gatewayB, activate: true))
#expect(GatewaySettingsStore.upsertGatewayRegistryEntry(gatewayA))
#expect(GatewaySettingsStore.markGatewayConnected(stableID: gatewayB.stableID, atMs: 1234))
let firstJSON = KeychainStore.loadString(service: gatewayService, account: "gateway-registry")
let registry = GatewaySettingsStore.loadGatewayRegistry()
#expect(registry.entries.map(\.stableID) == [gatewayA.stableID, gatewayB.stableID])
#expect(registry.activeStableID == gatewayB.stableID)
#expect(registry.entries.last?.lastConnectedAtMs == 1234)
#expect(GatewaySettingsStore.upsertGatewayRegistryEntry(gatewayA))
#expect(KeychainStore.loadString(service: gatewayService, account: "gateway-registry") == firstJSON)
#expect(GatewaySettingsStore.removeGatewayRegistryEntry(stableID: gatewayB.stableID))
#expect(GatewaySettingsStore.loadGatewayRegistry().entries == [gatewayA])
#expect(GatewaySettingsStore.activeGatewayEntry() == nil)
}
}
@Test func `legacy manual last connection migrates once into active registry`() {
withLastGatewaySnapshot {
applyKeychain([
gatewayRegistryKeychainEntry: nil,
lastGatewayKeychainEntry:
#"{"kind":"manual","stableID":"manual|example.org|18789","useTLS":false,"host":"example.org","port":18789}"#,
])
GatewaySettingsStore.bootstrapPersistence()
let firstJSON = KeychainStore.loadString(service: gatewayService, account: "gateway-registry")
GatewaySettingsStore.bootstrapPersistence()
let active = GatewaySettingsStore.activeGatewayEntry()
#expect(active?.stableID == "manual|example.org|18789")
#expect(active?.host == "example.org")
#expect(active?.port == 18789)
#expect(active?.useTLS == false)
#expect(KeychainStore.loadString(service: gatewayService, account: "lastConnection") == nil)
#expect(KeychainStore.loadString(service: gatewayService, account: "gateway-registry") == firstJSON)
}
}
@Test func `legacy discovered last connection migrates into active registry`() {
withLastGatewaySnapshot {
applyKeychain([
gatewayRegistryKeychainEntry: nil,
lastGatewayKeychainEntry:
#"{"kind":"discovered","stableID":"bonjour|gateway-a","useTLS":true}"#,
])
applyDefaults([
"gateway.last.kind": "manual",
"gateway.last.host": "stale.example.org",
"gateway.last.port": 18789,
"gateway.last.tls": false,
"gateway.last.stableID": "manual|stale.example.org|18789",
])
GatewaySettingsStore.bootstrapPersistence()
let active = GatewaySettingsStore.activeGatewayEntry()
#expect(active?.stableID == "bonjour|gateway-a")
#expect(active?.kind == .discovered)
#expect(active?.name == "bonjour|gateway-a")
let defaults = UserDefaults.standard
#expect(defaults.object(forKey: "gateway.last.stableID") == nil)
#expect(defaults.object(forKey: "gateway.last.host") == nil)
}
}
@Test func `legacy defaults migrate directly into active registry`() {
withLastGatewaySnapshot {
applyKeychain([
gatewayRegistryKeychainEntry: nil,
lastGatewayKeychainEntry: nil,
])
applyDefaults([
"gateway.last.kind": "manual",
"gateway.last.host": "defaults.example.org",
"gateway.last.port": 443,
"gateway.last.tls": true,
"gateway.last.stableID": "manual|defaults.example.org|443",
])
GatewaySettingsStore.bootstrapPersistence()
let active = GatewaySettingsStore.activeGatewayEntry()
#expect(active?.stableID == "manual|defaults.example.org|443")
#expect(active?.host == "defaults.example.org")
#expect(active?.port == 443)
#expect(active?.useTLS == true)
for key in lastGatewayDefaultsKeys {
#expect(UserDefaults.standard.object(forKey: key) == nil)
}
}
}
@Test func `legacy unscoped credential bundle migrates to its gateway account`() {
withBootstrapSnapshots {
let instanceID = "legacy-bundle-\(UUID().uuidString)"
let gatewayID = "manual|credentials.example.com|443"
let legacyAccount = "gateway-credentials.\(instanceID)"
let scopedAccount = "\(legacyAccount).\(gatewayID)"
let entries = [
KeychainEntry(service: gatewayService, account: legacyAccount),
KeychainEntry(service: gatewayService, account: scopedAccount),
]
let credentialSnapshot = snapshotKeychain(entries)
defer { restoreKeychain(credentialSnapshot) }
applyKeychain([
instanceIdEntry: nil,
gatewayRegistryKeychainEntry: nil,
KeychainEntry(service: gatewayService, account: legacyAccount):
#"{"gatewayStableID":"manual|credentials.example.com|443","suppressStoredDeviceAuth":true,"token":"legacy-token","bootstrapToken":"legacy-bootstrap","password":"legacy-password"}"#,
KeychainEntry(service: gatewayService, account: scopedAccount): nil,
])
applyDefaults(["node.instanceId": instanceID])
GatewaySettingsStore.bootstrapPersistence()
GatewaySettingsStore.bootstrapPersistence()
let credentials = GatewaySettingsStore.loadGatewayCredentials(
instanceId: instanceID,
gatewayStableID: gatewayID)
#expect(credentials.token == "legacy-token")
#expect(credentials.bootstrapToken == "legacy-bootstrap")
#expect(credentials.password == "legacy-password")
#expect(credentials.suppressStoredDeviceAuth)
#expect(KeychainStore.loadString(service: gatewayService, account: legacyAccount) == nil)
#expect(KeychainStore.loadString(service: gatewayService, account: scopedAccount) != nil)
}
}
@Test func `deleting one gateway credentials leaves the other gateway intact`() {
let instanceID = "credential-forget-\(UUID().uuidString)"
let gatewayA = "manual|a.example.com|443"
let gatewayB = "manual|b.example.com|443"
defer { GatewaySettingsStore.deleteAllGatewayCredentials(instanceId: instanceID) }
for (gatewayID, token) in [(gatewayA, "token-a"), (gatewayB, "token-b")] {
GatewaySettingsStore.saveGatewayCredentials(
token: token,
bootstrapToken: nil,
password: nil,
gatewayStableID: gatewayID,
suppressStoredDeviceAuth: false,
instanceId: instanceID)
}
GatewaySettingsStore.deleteGatewayCredentials(instanceId: instanceID, stableID: gatewayB)
#expect(GatewaySettingsStore.loadGatewayCredentials(
instanceId: instanceID,
gatewayStableID: gatewayA).token == "token-a")
#expect(GatewaySettingsStore.loadGatewayCredentials(
instanceId: instanceID,
gatewayStableID: gatewayB) == .empty)
}
@Test func `deleting all credentials includes owners not yet in registry`() {
let instanceID = "credential-reset-\(UUID().uuidString)"
let gatewayA = "manual|pending-a.example.com|443"
let gatewayB = "manual|pending-b.example.com|443"
defer { GatewaySettingsStore.deleteAllGatewayCredentials(instanceId: instanceID) }
for gatewayID in [gatewayA, gatewayB] {
GatewaySettingsStore.saveGatewayCredentials(
token: "token-\(gatewayID)",
bootstrapToken: nil,
password: nil,
gatewayStableID: gatewayID,
suppressStoredDeviceAuth: false,
instanceId: instanceID)
}
GatewaySettingsStore.deleteAllGatewayCredentials(instanceId: instanceID)
#expect(GatewaySettingsStore.loadGatewayCredentials(
instanceId: instanceID,
gatewayStableID: gatewayA) == .empty)
#expect(GatewaySettingsStore.loadGatewayCredentials(
instanceId: instanceID,
gatewayStableID: gatewayB) == .empty)
}
}