Files
openclaw/apps/ios/Tests/GatewaySettingsStoreTests.swift
Peter Steinberger ac89350327 feat(apps): review durable approvals on mobile (#104913)
* feat(apps): Android, iPhone, and Watch approval clients

Squash-rebased #103912 segment onto the deep-links tip on current main.
Native approval surfaces: iOS approval presentation with gateway-switch
lease preservation and resolution fencing, watchOS inbox + approval
actions with shipped-shape payload codec, Android approval notices with
publication-tokened dismissal. Native i18n inventory regenerated.

(cherry picked from commit 428a76670ffeede54248b7bd7aa4438e2589851b)
(cherry picked from commit 80225d5707c3645eeea5435f266131037b50ede6)
(cherry picked from commit 2a23b714dc30d773a294aa45adc617e46b80732e)
(cherry picked from commit 9ff1153827769e2cbab7c11c153f0f8634662c28)
(cherry picked from commit 5b25723525bd562e5f97478af492f7b46ad30fd1)
(cherry picked from commit 8c80e8467b5fe89aad0d4c74a0573f1600ed3af9)
(cherry picked from commit ad4037bc9846bf6f082b41c129ee68aa344576c3)
(cherry picked from commit fdf767dd662cff3c8a5a6d571f38f153410651ca)
(cherry picked from commit 00c120376ffd992ea68ce29254a9bc0a25ed1740)
(cherry picked from commit f36a95213e)
(cherry picked from commit e2c25cbe2baacab44d21871d8cb6734704f065ac)
(cherry picked from commit 7c4fda519080486d341a9f4df36d63f9e24b1235)
(cherry picked from commit 1b3d4eda3dc5988012124597f9454ae21fb187a1)
(cherry picked from commit 2a60619722)
(cherry picked from commit 6f0c386567)
(cherry picked from commit 784a5857b7)
(cherry picked from commit cbf294e026841c9bc2799da0fc7db666a69c52db)

* fix(apps): harden approval reconciliation and watch states
2026-07-11 19:59:07 -07:00

883 lines
40 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 `opaque identifier validation preserves protocol-valid edge bytes`() {
#expect(ExecApprovalIdentifier.exact("") == nil)
#expect(ExecApprovalIdentifier.key(".") == nil)
#expect(ExecApprovalIdentifier.key("..") == nil)
#expect(ExecApprovalIdentifier.exact(" approval ") == " approval ")
#expect(ExecApprovalIdentifier.exact("\u{0085}approval") == "\u{0085}approval")
#expect(GatewayStableIdentifier.exact(" gateway ") == " gateway ")
#expect(GatewayStableIdentifier.exact("\u{0085}gateway") == "\u{0085}gateway")
}
@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 headers keep canonically equivalent owners isolated`() {
let service = "\(gatewayService).custom-headers-exact-test.\(UUID().uuidString)"
let composedOwner = "gateway-\u{00E9}"
let decomposedOwner = "gateway-e\u{0301}"
let nextLineOwner = "\u{0085}gateway"
defer { GatewaySettingsStore.clearGatewayCustomHeaders(service: service) }
#expect(GatewaySettingsStore.saveGatewayCustomHeaders(
["X-Owner": "composed"],
gatewayStableID: composedOwner,
service: service))
#expect(GatewaySettingsStore.saveGatewayCustomHeaders(
["X-Owner": "decomposed"],
gatewayStableID: decomposedOwner,
service: service))
#expect(GatewaySettingsStore.saveGatewayCustomHeaders(
["X-Owner": "next-line"],
gatewayStableID: nextLineOwner,
service: service))
#expect(GatewaySettingsStore.loadGatewayCustomHeaders(
gatewayStableID: composedOwner,
service: service)["X-Owner"] == "composed")
#expect(GatewaySettingsStore.loadGatewayCustomHeaders(
gatewayStableID: decomposedOwner,
service: service)["X-Owner"] == "decomposed")
#expect(GatewaySettingsStore.loadGatewayCustomHeaders(
gatewayStableID: nextLineOwner,
service: service)["X-Owner"] == "next-line")
}
@Test func `legacy custom header account cannot alias encoded owner account`() {
let service = "\(gatewayService).custom-headers-prefix-test.\(UUID().uuidString)"
let exactOwner = "gateway-\(UUID().uuidString)"
let component = Data(exactOwner.utf8).base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
let collidingLegacyOwner = "v2.\(component)"
defer { GatewaySettingsStore.clearGatewayCustomHeaders(service: service) }
#expect(GatewaySettingsStore.saveGatewayCustomHeaders(
["X-Owner": "exact"],
gatewayStableID: exactOwner,
service: service))
#expect(GatewaySettingsStore.loadGatewayCustomHeaders(
gatewayStableID: collidingLegacyOwner,
service: service).isEmpty)
#expect(GatewaySettingsStore.clearGatewayCustomHeaders(
gatewayStableID: collidingLegacyOwner,
service: service))
#expect(GatewaySettingsStore.loadGatewayCustomHeaders(
gatewayStableID: exactOwner,
service: service)["X-Owner"] == "exact")
}
@Test func `legacy gateway defaults cannot alias encoded owner keys`() {
let exactOwner = "gateway-\(UUID().uuidString)"
let component = Data(exactOwner.utf8).base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
let collidingLegacyOwner = "v2.\(component)"
defer {
GatewaySettingsStore.saveGatewayClientIdOverride(stableID: exactOwner, clientId: nil)
GatewaySettingsStore.saveGatewayClientIdOverride(stableID: collidingLegacyOwner, clientId: nil)
GatewaySettingsStore.saveGatewaySelectedAgentId(stableID: exactOwner, agentId: nil)
GatewaySettingsStore.saveGatewaySelectedAgentId(stableID: collidingLegacyOwner, agentId: nil)
}
GatewaySettingsStore.saveGatewayClientIdOverride(stableID: exactOwner, clientId: "exact-client")
GatewaySettingsStore.saveGatewaySelectedAgentId(stableID: exactOwner, agentId: "exact-agent")
#expect(GatewaySettingsStore.loadGatewayClientIdOverride(stableID: collidingLegacyOwner) == nil)
#expect(GatewaySettingsStore.loadGatewaySelectedAgentId(stableID: collidingLegacyOwner) == nil)
GatewaySettingsStore.saveGatewayClientIdOverride(stableID: collidingLegacyOwner, clientId: nil)
GatewaySettingsStore.saveGatewaySelectedAgentId(stableID: collidingLegacyOwner, agentId: nil)
#expect(GatewaySettingsStore.loadGatewayClientIdOverride(stableID: exactOwner) == "exact-client")
#expect(GatewaySettingsStore.loadGatewaySelectedAgentId(stableID: exactOwner) == "exact-agent")
}
@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 `credentials preserve exact unicode gateway owners`() {
let instanceID = "credential-exact-owner-\(UUID().uuidString)"
let composedOwner = "gateway-\u{00E9}"
let decomposedOwner = "gateway-e\u{0301}"
let nextLineOwner = "\u{0085}gateway"
defer { GatewaySettingsStore.deleteAllGatewayCredentials(instanceId: instanceID) }
for (owner, token) in [
(composedOwner, "composed-token"),
(decomposedOwner, "decomposed-token"),
(nextLineOwner, "next-line-token"),
] {
#expect(GatewaySettingsStore.saveGatewayCredentials(
token: token,
bootstrapToken: nil,
password: nil,
gatewayStableID: owner,
suppressStoredDeviceAuth: false,
instanceId: instanceID))
}
#expect(GatewaySettingsStore.loadGatewayCredentials(
instanceId: instanceID,
gatewayStableID: composedOwner).token == "composed-token")
#expect(GatewaySettingsStore.loadGatewayCredentials(
instanceId: instanceID,
gatewayStableID: decomposedOwner).token == "decomposed-token")
#expect(GatewaySettingsStore.loadGatewayCredentials(
instanceId: instanceID,
gatewayStableID: nextLineOwner).token == "next-line-token")
GatewaySettingsStore.deleteGatewayCredentials(instanceId: instanceID, stableID: decomposedOwner)
#expect(GatewaySettingsStore.loadGatewayCredentials(
instanceId: instanceID,
gatewayStableID: composedOwner).token == "composed-token")
#expect(GatewaySettingsStore.loadGatewayCredentials(
instanceId: instanceID,
gatewayStableID: decomposedOwner) == .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 `registry preserves byte-distinct unicode gateway owners`() {
withLastGatewaySnapshot {
applyKeychain([gatewayRegistryKeychainEntry: nil, lastGatewayKeychainEntry: nil])
let composedOwner = "gateway-\u{00E9}"
let decomposedOwner = "gateway-e\u{0301}"
let nextLineOwner = "\u{0085}gateway"
for owner in [composedOwner, decomposedOwner, nextLineOwner] {
#expect(GatewaySettingsStore.upsertGatewayRegistryEntry(.init(
stableID: owner,
kind: .discovered,
name: "Gateway",
host: nil,
port: nil,
useTLS: true,
lastConnectedAtMs: nil)))
}
let registry = GatewaySettingsStore.loadGatewayRegistry()
#expect(Set(registry.entries.compactMap { GatewayStableIdentifier.key($0.stableID) }).count == 3)
#expect(GatewaySettingsStore.setActiveGateway(stableID: decomposedOwner))
#expect(GatewaySettingsStore.activeGatewayEntry().map { Array($0.stableID.utf8) } ==
Array(decomposedOwner.utf8))
#expect(GatewaySettingsStore.removeGatewayRegistryEntry(stableID: composedOwner))
let remaining = GatewaySettingsStore.loadGatewayRegistry().entries
#expect(remaining.contains(where: {
GatewayStableIdentifier.matches($0.stableID, decomposedOwner)
}))
}
}
@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)"
defer { GatewaySettingsStore.deleteAllGatewayCredentials(instanceId: instanceID) }
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)
}
}