mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-17 12:01:34 +00:00
* feat(ios): multi-gateway registry, per-gateway credentials, and switching * fix(ios): harden multi-gateway state isolation
720 lines
32 KiB
Swift
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)
|
|
}
|
|
}
|