mirror of
https://github.com/home-assistant/iOS.git
synced 2026-06-25 07:32:12 -05:00
<!-- Thank you for submitting a Pull Request and helping to improve Home Assistant. Please complete the following sections to help the processing and review of your changes. Please do not delete anything from this template. --> ## Summary <!-- Provide a brief summary of the changes you have made and most importantly what they aim to achieve --> Ensure mirrorStore is kept in sync when servers are removed. ServerManagerImpl now removes per-server mirrorStore entries and updates restoredMirroredServers when removing an identifier, and clears mirrorStore and restoredMirroredServers when removeAll() is called. The allKeys set is captured before mutating the cache so mirror keys are included in deletedServers. Tests updated to assert mirrorStore is cleared and that mirror-restore state behaves as expected. ## Screenshots <!-- If this is a user-facing change not in the frontend, please include screenshots in light and dark mode. --> ## Link to pull request in Documentation repository <!-- Pull requests that add, change or remove functionality must have a corresponding pull request in the Companion App Documentation repository (https://github.com/home-assistant/companion.home-assistant). Please add the number of this pull request after the "#" --> Documentation: home-assistant/companion.home-assistant# ## Any other notes <!-- If there is any other information of note, like if this Pull Request is part of a bigger change, please include it here. -->
930 lines
35 KiB
Swift
930 lines
35 KiB
Swift
@testable import Shared
|
|
import Version
|
|
import XCTest
|
|
|
|
class ServerManagerTests: XCTestCase {
|
|
private var encoder: JSONEncoder!
|
|
private var keychain: FakeServerManagerKeychain!
|
|
private var historicKeychain: FakeServerManagerKeychain!
|
|
private var mirrorStore: FakeServerManagerMirrorStore!
|
|
private var servers: ServerManagerImpl!
|
|
|
|
override func setUp() {
|
|
super.setUp()
|
|
|
|
encoder = .init()
|
|
keychain = .init()
|
|
historicKeychain = .init()
|
|
mirrorStore = .init()
|
|
|
|
Current.settingsStore.prefs.removeObject(forKey: "deletedServers")
|
|
Current.settingsStore.prefs.removeObject(forKey: "restoredMirroredServers")
|
|
}
|
|
|
|
private func setupRegular(
|
|
_ serverInfos: [String: ServerInfo] = [:]
|
|
) throws {
|
|
for (key, value) in serverInfos {
|
|
try keychain.set(encoder.encode(value), key: key)
|
|
}
|
|
|
|
servers = ServerManagerImpl(keychain: keychain, historicKeychain: historicKeychain, mirrorStore: mirrorStore)
|
|
servers.setup()
|
|
}
|
|
|
|
func testInitiallyEmptyAndGainingServersWithCaching() throws {
|
|
Current.isAppExtension = false
|
|
try base_testInitiallyEmptyAndGainingServers()
|
|
}
|
|
|
|
func testInitiallyEmptyAndGainingServersWithoutCaching() throws {
|
|
Current.isAppExtension = true
|
|
try base_testInitiallyEmptyAndGainingServers()
|
|
}
|
|
|
|
private func base_testInitiallyEmptyAndGainingServers() throws {
|
|
try setupRegular()
|
|
|
|
let observer = FakeObserver()
|
|
|
|
func expectingObserver(_ block: () -> Void) {
|
|
let expectation = observer.addExpectation(from: self)
|
|
block()
|
|
wait(for: [expectation], timeout: 10.0)
|
|
}
|
|
|
|
servers.add(observer: observer)
|
|
|
|
XCTAssertEqual(servers.all.count, 0)
|
|
|
|
XCTAssertNil(servers.server(for: "fake1"))
|
|
XCTAssertNil(servers.server(forServerIdentifier: "fake1"))
|
|
XCTAssertNil(servers.server(forWebhookID: "fake1"))
|
|
XCTAssertNil(servers.server(for: FakeServerIdentifierProviding(serverIdentifier: "fake1")))
|
|
XCTAssertNil(servers.server(for: FakeServerIdentifierProviding(serverIdentifier: "fake1"), fallback: false))
|
|
|
|
let state = servers.restorableState()
|
|
XCTAssertEqual(String(decoding: state, as: UTF8.self), "{}")
|
|
|
|
expectingObserver {
|
|
servers.restoreState(state)
|
|
}
|
|
XCTAssertEqual(servers.all.count, 0)
|
|
XCTAssertTrue(keychain.data.isEmpty)
|
|
|
|
let info1 = with(ServerInfo.fake()) {
|
|
$0.connection.webhookID = "webhook1"
|
|
}
|
|
|
|
let info2 = with(ServerInfo.fake()) {
|
|
$0.connection.webhookID = "webhook2"
|
|
}
|
|
|
|
let info3 = with(ServerInfo.fake()) {
|
|
$0.connection.webhookID = "webhook3"
|
|
}
|
|
|
|
expectingObserver {
|
|
servers.add(identifier: "fake1", serverInfo: info1)
|
|
}
|
|
|
|
let server1 = try XCTUnwrap(servers.server(for: "fake1"))
|
|
|
|
XCTAssertTrue(servers.server(forWebhookID: "webhook1") === server1)
|
|
XCTAssertTrue(servers.server(forServerIdentifier: "fake1") === server1)
|
|
XCTAssertTrue(servers.server(for: FakeServerIdentifierProviding(serverIdentifier: "fake1")) === server1)
|
|
XCTAssertTrue(
|
|
servers
|
|
.server(for: FakeServerIntentProviding(server: .init(identifier: "fake1", display: "fake1"))) ===
|
|
server1
|
|
)
|
|
XCTAssertEqual(server1.info, with(info1) {
|
|
$0.sortOrder = 0
|
|
})
|
|
|
|
XCTAssertEqual(servers.all, [server1])
|
|
|
|
expectingObserver {
|
|
servers.add(identifier: "fake2", serverInfo: info2)
|
|
}
|
|
let server2 = try XCTUnwrap(servers.server(for: "fake2"))
|
|
XCTAssertTrue(servers.server(forWebhookID: "webhook2") === server2)
|
|
XCTAssertTrue(servers.server(forServerIdentifier: "fake2") === server2)
|
|
XCTAssertTrue(servers.server(for: FakeServerIdentifierProviding(serverIdentifier: "fake2")) === server2)
|
|
XCTAssertTrue(
|
|
servers
|
|
.server(for: FakeServerIntentProviding(server: .init(identifier: "fake2", display: "fake1"))) ===
|
|
server2
|
|
)
|
|
XCTAssertEqual(server2.info, with(info2) {
|
|
$0.sortOrder = 1000
|
|
})
|
|
|
|
XCTAssertEqual(servers.all, [server1, server2])
|
|
|
|
try XCTAssertEqual(keychain.getData("fake1")?.count, encoder.encode(server1.info).count)
|
|
try XCTAssertEqual(keychain.getData("fake2")?.count, encoder.encode(server2.info).count)
|
|
XCTAssertEqual(keychain.data.count, 2)
|
|
let stateS1S2 = servers.restorableState()
|
|
|
|
expectingObserver {
|
|
server1.info.connection.webhookID = "webhook1_2"
|
|
}
|
|
XCTAssertEqual(server1.info.connection.webhookID, "webhook1_2")
|
|
try XCTAssertEqual(keychain.getData("fake1")?.count, encoder.encode(server1.info).count)
|
|
|
|
expectingObserver {
|
|
servers.remove(identifier: "fake1")
|
|
}
|
|
try XCTAssertNil(keychain.getData("fake1"))
|
|
XCTAssertNil(mirrorStore.data["fake1"])
|
|
|
|
// grab it, which may also side-effect insert into cache, if buggy
|
|
_ = server1.info
|
|
|
|
expectingObserver {
|
|
// we just deleted it, so we re-add it to make sure _that_ works
|
|
let tempFake1 = with(info1) {
|
|
$0.connection.webhookID = "deleted_and_reset"
|
|
}
|
|
servers.add(identifier: "fake1", serverInfo: tempFake1)
|
|
XCTAssertEqual(servers.server(for: "fake1")?.info.connection.webhookID, "deleted_and_reset")
|
|
}
|
|
|
|
expectingObserver {
|
|
servers.remove(identifier: "fake1")
|
|
}
|
|
|
|
XCTAssertNil(servers.server(for: "fake1"))
|
|
XCTAssertEqual(servers.all, [server2])
|
|
XCTAssertNil(mirrorStore.data["fake1"])
|
|
|
|
var server3: Server!
|
|
|
|
expectingObserver {
|
|
server3 = servers.add(identifier: "fake3", serverInfo: info3)
|
|
}
|
|
XCTAssertEqual(servers.all, [server2, server3])
|
|
try XCTAssertEqual(keychain.getData("fake3")?.count, encoder.encode(server3.info).count)
|
|
XCTAssertEqual(server3.info, with(info3) {
|
|
$0.sortOrder = 2000
|
|
})
|
|
|
|
let stateS2S3 = servers.restorableState()
|
|
|
|
expectingObserver {
|
|
servers.removeAll()
|
|
}
|
|
XCTAssertEqual(servers.all, [])
|
|
XCTAssertNil(servers.server(for: "fake1"))
|
|
XCTAssertNil(servers.server(for: "fake2"))
|
|
XCTAssertNil(servers.server(for: "fake3"))
|
|
XCTAssertTrue(keychain.data.isEmpty)
|
|
XCTAssertTrue(mirrorStore.data.isEmpty)
|
|
|
|
expectingObserver {
|
|
servers.restoreState(stateS2S3)
|
|
}
|
|
XCTAssertEqual(servers.all.map(\.identifier), ["fake2", "fake3"])
|
|
XCTAssertEqual(Set(keychain.data.keys), Set(["fake2", "fake3"]))
|
|
|
|
XCTAssertNil(servers.server(for: "fake1"))
|
|
XCTAssertEqual(servers.server(for: "fake2")?.info, with(info2) {
|
|
$0.sortOrder = 1000
|
|
})
|
|
XCTAssertEqual(servers.server(for: "fake3")?.info, with(info3) {
|
|
$0.sortOrder = 2000
|
|
})
|
|
|
|
let server2_afterRestore = try XCTUnwrap(servers.server(for: "fake2"))
|
|
expectingObserver {
|
|
server2_afterRestore.info.connection.webhookID = "webhook2_2"
|
|
}
|
|
|
|
if Current.isAppExtension {
|
|
// do it again to handle the restricted caching case - this should notify even with no change
|
|
expectingObserver {
|
|
server2_afterRestore.info.connection.webhookID = "webhook2_2"
|
|
}
|
|
} else {
|
|
// opposite - should not notify
|
|
server2_afterRestore.info.connection.webhookID = "webhook2_2"
|
|
}
|
|
|
|
XCTAssertEqual(servers.server(for: "fake2")?.info.connection.webhookID, "webhook2_2")
|
|
try XCTAssertEqual(keychain.getData("fake2")?.count, encoder.encode(server2_afterRestore.info).count)
|
|
|
|
let s2RestoreExpectation = expectation(description: "server2notify")
|
|
_ = server2_afterRestore.observe { info in
|
|
XCTAssertEqual(info.connection.webhookID, "webhook2")
|
|
s2RestoreExpectation.fulfill()
|
|
}
|
|
|
|
expectingObserver {
|
|
servers.restoreState(stateS1S2)
|
|
}
|
|
wait(for: [s2RestoreExpectation], timeout: 10.0)
|
|
XCTAssertEqual(servers.all.map(\.identifier), ["fake1", "fake2"])
|
|
XCTAssertEqual(Set(keychain.data.keys), Set(["fake1", "fake2"]))
|
|
|
|
XCTAssertEqual(servers.server(for: "fake1")?.info, with(info1) {
|
|
$0.sortOrder = 0
|
|
})
|
|
XCTAssertEqual(servers.server(for: "fake2")?.info, with(info2) {
|
|
$0.sortOrder = 1000
|
|
})
|
|
XCTAssertNil(servers.server(for: "fake3"))
|
|
|
|
servers.remove(observer: observer)
|
|
servers.removeAll()
|
|
XCTAssertTrue(servers.all.isEmpty)
|
|
XCTAssertTrue(keychain.data.isEmpty)
|
|
}
|
|
|
|
func testWithInitialServers() throws {
|
|
let info1 = with(ServerInfo.fake()) {
|
|
$0.connection.webhookID = "webhook1"
|
|
$0.sortOrder = 3
|
|
}
|
|
|
|
let info2 = with(ServerInfo.fake()) {
|
|
$0.connection.webhookID = "webhook2"
|
|
$0.sortOrder = 2
|
|
}
|
|
|
|
let info3 = with(ServerInfo.fake()) {
|
|
$0.connection.webhookID = "webhook3"
|
|
$0.sortOrder = 1
|
|
}
|
|
|
|
try setupRegular([
|
|
"fake1": info1,
|
|
"fake2": info2,
|
|
"fake3": info3,
|
|
])
|
|
|
|
let server1 = try XCTUnwrap(servers.server(for: "fake1"))
|
|
let server2 = try XCTUnwrap(servers.server(for: "fake2"))
|
|
let server3 = try XCTUnwrap(servers.server(for: "fake3"))
|
|
|
|
XCTAssertEqual(server1.info, info1)
|
|
XCTAssertEqual(server2.info, info2)
|
|
XCTAssertEqual(server3.info, info3)
|
|
XCTAssertEqual(servers.all, [server3, server2, server1])
|
|
}
|
|
|
|
func testSortOrder() throws {
|
|
try setupRegular([
|
|
"fake1": with(.fake()) {
|
|
$0.sortOrder = 1
|
|
},
|
|
"fake2": with(.fake()) {
|
|
$0.sortOrder = 2
|
|
},
|
|
"fake3": with(.fake()) {
|
|
$0.sortOrder = 3
|
|
},
|
|
])
|
|
|
|
XCTAssertEqual(servers.all.map(\.identifier), ["fake1", "fake2", "fake3"])
|
|
|
|
servers.server(for: "fake2")?.info.sortOrder = 10
|
|
XCTAssertEqual(servers.all.map(\.identifier), ["fake1", "fake3", "fake2"])
|
|
|
|
servers.server(for: "fake3")?.info.sortOrder = 0
|
|
XCTAssertEqual(servers.all.map(\.identifier), ["fake3", "fake1", "fake2"])
|
|
}
|
|
|
|
private func notificationContent(webhookID: String?) -> UNNotificationContent {
|
|
let content = UNMutableNotificationContent()
|
|
if let webhookID {
|
|
content.userInfo["webhook_id"] = webhookID
|
|
}
|
|
return content
|
|
}
|
|
|
|
func testServerGetterHelpersWith1Server() throws {
|
|
try setupRegular([
|
|
"fake1": with(.fake()) {
|
|
$0.sortOrder = 1
|
|
$0.connection.webhookID = "webhook1"
|
|
},
|
|
])
|
|
|
|
let server1 = servers.server(for: "fake1")
|
|
let intentServer1 = IntentServer(identifier: "fake1", display: "fake1")
|
|
let intentServer2 = IntentServer(identifier: "fake2", display: "fake2")
|
|
|
|
XCTAssertEqual(servers.server(forServerIdentifier: nil), nil)
|
|
XCTAssertEqual(servers.server(forServerIdentifier: "fake1"), server1)
|
|
XCTAssertEqual(servers.server(forServerIdentifier: "fake2"), nil)
|
|
|
|
XCTAssertEqual(servers.server(forWebhookID: "webhook1"), server1)
|
|
XCTAssertEqual(servers.server(forWebhookID: "webhook2"), nil)
|
|
|
|
XCTAssertEqual(servers.server(for: notificationContent(webhookID: "webhook1")), server1)
|
|
XCTAssertEqual(servers.server(for: notificationContent(webhookID: "webhook2")), nil)
|
|
XCTAssertEqual(servers.server(for: notificationContent(webhookID: nil)), server1)
|
|
|
|
XCTAssertEqual(servers.server(for: FakeServerIntentProviding(server: intentServer1)), server1)
|
|
XCTAssertEqual(servers.server(for: FakeServerIntentProviding(server: intentServer2)), server1)
|
|
XCTAssertEqual(servers.server(for: FakeServerIntentProviding(server: intentServer2), fallback: false), nil)
|
|
|
|
XCTAssertEqual(servers.server(for: FakeServerIdentifierProviding(serverIdentifier: "fake1")), server1)
|
|
XCTAssertEqual(servers.server(for: FakeServerIdentifierProviding(serverIdentifier: "fake2")), server1)
|
|
XCTAssertEqual(
|
|
servers.server(for: FakeServerIdentifierProviding(serverIdentifier: "fake2"), fallback: false),
|
|
nil
|
|
)
|
|
}
|
|
|
|
func testServerGetterHelpersWith2Server() throws {
|
|
try setupRegular([
|
|
"fake1": with(.fake()) {
|
|
$0.sortOrder = 1
|
|
$0.connection.webhookID = "webhook1"
|
|
},
|
|
"fake2": with(.fake()) {
|
|
$0.sortOrder = 2
|
|
$0.connection.webhookID = "webhook2"
|
|
},
|
|
])
|
|
|
|
let server1 = servers.server(for: "fake1")
|
|
let server2 = servers.server(for: "fake2")
|
|
let intentServer1 = IntentServer(identifier: "fake1", display: "fake1")
|
|
let intentServer2 = IntentServer(identifier: "fake2", display: "fake2")
|
|
let intentServer3 = IntentServer(identifier: "fake3", display: "fake3")
|
|
|
|
XCTAssertEqual(servers.server(forServerIdentifier: nil), nil)
|
|
XCTAssertEqual(servers.server(forServerIdentifier: "fake1"), server1)
|
|
XCTAssertEqual(servers.server(forServerIdentifier: "fake2"), server2)
|
|
XCTAssertEqual(servers.server(forServerIdentifier: "fake3"), nil)
|
|
|
|
XCTAssertEqual(servers.server(forWebhookID: "webhook1"), server1)
|
|
XCTAssertEqual(servers.server(forWebhookID: "webhook2"), server2)
|
|
XCTAssertEqual(servers.server(forWebhookID: "webhook3"), nil)
|
|
|
|
XCTAssertEqual(servers.server(for: notificationContent(webhookID: "webhook1")), server1)
|
|
XCTAssertEqual(servers.server(for: notificationContent(webhookID: "webhook2")), server2)
|
|
XCTAssertEqual(servers.server(for: notificationContent(webhookID: nil)), server1)
|
|
XCTAssertEqual(servers.server(for: notificationContent(webhookID: "webhook3")), nil)
|
|
|
|
XCTAssertEqual(servers.server(for: FakeServerIntentProviding(server: intentServer1)), server1)
|
|
XCTAssertEqual(servers.server(for: FakeServerIntentProviding(server: intentServer2)), server2)
|
|
XCTAssertEqual(servers.server(for: FakeServerIntentProviding(server: intentServer3)), nil)
|
|
XCTAssertEqual(servers.server(for: FakeServerIntentProviding(server: intentServer3), fallback: false), nil)
|
|
|
|
XCTAssertEqual(servers.server(for: FakeServerIdentifierProviding(serverIdentifier: "fake1")), server1)
|
|
XCTAssertEqual(servers.server(for: FakeServerIdentifierProviding(serverIdentifier: "fake2")), server2)
|
|
XCTAssertEqual(servers.server(for: FakeServerIdentifierProviding(serverIdentifier: "fake3")), nil)
|
|
XCTAssertEqual(
|
|
servers.server(for: FakeServerIdentifierProviding(serverIdentifier: "fake3"), fallback: false),
|
|
nil
|
|
)
|
|
}
|
|
|
|
func testServerUpdatePerField() throws {
|
|
try setupRegular([
|
|
"fake1": with(.fake()) {
|
|
$0.sortOrder = 1
|
|
$0.connection.webhookID = "webhook1"
|
|
},
|
|
])
|
|
|
|
var decoded: ServerInfo {
|
|
get throws {
|
|
try JSONDecoder().decode(ServerInfo.self, from: XCTUnwrap(keychain.data["fake1"]))
|
|
}
|
|
}
|
|
|
|
let server = try XCTUnwrap(servers.all.first)
|
|
server.info.remoteName = "updated_name"
|
|
XCTAssertEqual(server.info.remoteName, "updated_name")
|
|
XCTAssertEqual(try decoded.remoteName, "updated_name")
|
|
|
|
server.info.sortOrder = 3
|
|
XCTAssertEqual(server.info.sortOrder, 3)
|
|
XCTAssertEqual(try decoded.sortOrder, 3)
|
|
|
|
server.info.version = Version(major: 11)
|
|
XCTAssertEqual(server.info.version.major, 11)
|
|
XCTAssertEqual(try decoded.version.major, 11)
|
|
|
|
server.info.connection.webhookID = "webhook2"
|
|
XCTAssertEqual(server.info.connection.webhookID, "webhook2")
|
|
XCTAssertEqual(try decoded.connection.webhookID, "webhook2")
|
|
|
|
server.info.token.accessToken = "access2"
|
|
XCTAssertEqual(server.info.token.accessToken, "access2")
|
|
XCTAssertEqual(try decoded.token.accessToken, "access2")
|
|
}
|
|
|
|
func testUpdateAfterDeleteDoesntPersist() throws {
|
|
try setupRegular()
|
|
|
|
let oldServers = try XCTUnwrap(servers)
|
|
let oldServer1 = oldServers.add(identifier: "fake1", serverInfo: .fake())
|
|
|
|
try setupRegular()
|
|
|
|
let newServer1 = try XCTUnwrap(servers.server(for: oldServer1.identifier))
|
|
oldServers.remove(identifier: oldServer1.identifier)
|
|
|
|
newServer1.info.remoteName = "updated"
|
|
XCTAssertTrue(keychain.data.isEmpty)
|
|
|
|
let newInfo = with(newServer1.info) {
|
|
$0.remoteName = "new_name1"
|
|
}
|
|
servers.add(identifier: newServer1.identifier, serverInfo: newInfo)
|
|
XCTAssertEqual(keychain.data[newServer1.identifier.rawValue]?.count, try encoder.encode(newInfo).count)
|
|
}
|
|
|
|
func testUpdateAfterDeleteInAnotherProcessDoesntPersist() throws {
|
|
try setupRegular()
|
|
|
|
let server1 = servers.add(identifier: "fake1", serverInfo: .fake())
|
|
servers.remove(identifier: server1.identifier)
|
|
|
|
try setupRegular()
|
|
|
|
server1.info.remoteName = "updated"
|
|
XCTAssertTrue(keychain.data.isEmpty)
|
|
|
|
let newInfo = with(server1.info) {
|
|
$0.remoteName = "new_name1"
|
|
}
|
|
servers.add(identifier: server1.identifier, serverInfo: newInfo)
|
|
XCTAssertEqual(keychain.data[server1.identifier.rawValue]?.count, try encoder.encode(newInfo).count)
|
|
}
|
|
|
|
func testSetupBackfillsMirrorForExistingKeychainServers() throws {
|
|
let securityExceptionTrust = try SecTrust.unitTestDotExampleDotCom1
|
|
let info = with(ServerInfo.fake()) {
|
|
$0.connection.cloudhookURL = URL(string: "https://hooks.nabu.casa/webhook-id")
|
|
$0.connection.securityExceptions.add(for: securityExceptionTrust)
|
|
$0.connection.clientCertificate = ClientCertificate(
|
|
keychainIdentifier: "client-cert-1",
|
|
displayName: "Client Certificate"
|
|
)
|
|
}
|
|
|
|
try keychain.set(encoder.encode(info), key: "fake1")
|
|
|
|
servers = ServerManagerImpl(keychain: keychain, historicKeychain: historicKeychain, mirrorStore: mirrorStore)
|
|
servers.setup()
|
|
|
|
let mirrored = try XCTUnwrap(mirrorStore.data["fake1"])
|
|
XCTAssertEqual(mirrored.remoteName, info.remoteName)
|
|
XCTAssertEqual(mirrored.connection.webhookID, ServerInfo.mirrorPlaceholderWebhookID)
|
|
XCTAssertEqual(mirrored.connection.isLocalPushEnabled, info.connection.isLocalPushEnabled)
|
|
XCTAssertNil(mirrored.connection.cloudhookURL)
|
|
XCTAssertNil(mirrored.connection.webhookSecret)
|
|
XCTAssertFalse(mirrored.connection.securityExceptions.hasExceptions)
|
|
XCTAssertEqual(mirrored.token, ServerInfo.mirrorPlaceholderToken)
|
|
XCTAssertNil(mirrored.connection.clientCertificate)
|
|
}
|
|
|
|
func testSetupReplacesOutdatedMirrorSnapshot() throws {
|
|
let staleInfo = with(ServerInfo.fake()) {
|
|
$0.remoteName = "Stale"
|
|
}
|
|
let currentInfo = with(ServerInfo.fake()) {
|
|
$0.remoteName = "Current"
|
|
}
|
|
|
|
mirrorStore.set(staleInfo, key: "stale")
|
|
try keychain.set(encoder.encode(currentInfo), key: "fake1")
|
|
|
|
servers = ServerManagerImpl(keychain: keychain, historicKeychain: historicKeychain, mirrorStore: mirrorStore)
|
|
servers.setup()
|
|
|
|
XCTAssertEqual(Set(mirrorStore.data.keys), Set(["fake1"]))
|
|
XCTAssertEqual(mirrorStore.data["fake1"]?.remoteName, "Current")
|
|
}
|
|
|
|
func testExplicitRestoreMirroredServersToKeychainWithoutSecrets() throws {
|
|
let securityExceptionTrust = try SecTrust.unitTestDotExampleDotCom1
|
|
let info = with(ServerInfo.fake()) {
|
|
$0.connection.cloudhookURL = URL(string: "https://hooks.nabu.casa/webhook-id")
|
|
$0.connection.securityExceptions.add(for: securityExceptionTrust)
|
|
$0.connection.clientCertificate = ClientCertificate(
|
|
keychainIdentifier: "client-cert-1",
|
|
displayName: "Client Certificate"
|
|
)
|
|
$0.connection.webhookSecret = "webhook_secret"
|
|
$0.hassDeviceId = "device-1"
|
|
}
|
|
|
|
mirrorStore.set(info, key: "fake1")
|
|
servers = ServerManagerImpl(keychain: keychain, historicKeychain: historicKeychain, mirrorStore: mirrorStore)
|
|
servers.setup()
|
|
XCTAssertNil(try keychain.getData("fake1"))
|
|
|
|
XCTAssertTrue(servers.restoreKeychainFromMirrorIfNeeded())
|
|
|
|
let restoredFromKeychain = try XCTUnwrap(keychain.getServerInfo(key: "fake1", decoder: JSONDecoder()))
|
|
XCTAssertEqual(restoredFromKeychain.remoteName, info.remoteName)
|
|
XCTAssertEqual(restoredFromKeychain.hassDeviceId, info.hassDeviceId)
|
|
XCTAssertEqual(restoredFromKeychain.connection.webhookID, ServerInfo.mirrorPlaceholderWebhookID)
|
|
XCTAssertEqual(restoredFromKeychain.connection.isLocalPushEnabled, info.connection.isLocalPushEnabled)
|
|
XCTAssertNil(restoredFromKeychain.connection.cloudhookURL)
|
|
XCTAssertNil(restoredFromKeychain.connection.webhookSecret)
|
|
XCTAssertEqual(restoredFromKeychain.token, ServerInfo.mirrorPlaceholderToken)
|
|
XCTAssertNil(restoredFromKeychain.connection.clientCertificate)
|
|
|
|
let restored = try XCTUnwrap(servers.server(for: "fake1"))
|
|
XCTAssertEqual(restored.info.remoteName, info.remoteName)
|
|
XCTAssertEqual(restored.info.hassDeviceId, info.hassDeviceId)
|
|
XCTAssertEqual(restored.info.connection.webhookID, ServerInfo.mirrorPlaceholderWebhookID)
|
|
XCTAssertEqual(restored.info.connection.isLocalPushEnabled, info.connection.isLocalPushEnabled)
|
|
XCTAssertNil(restored.info.connection.cloudhookURL)
|
|
XCTAssertNil(restored.info.connection.webhookSecret)
|
|
XCTAssertFalse(restored.info.connection.securityExceptions.hasExceptions)
|
|
XCTAssertEqual(restored.info.token, ServerInfo.mirrorPlaceholderToken)
|
|
XCTAssertNil(restored.info.connection.clientCertificate)
|
|
XCTAssertNotNil(mirrorStore.data["fake1"])
|
|
XCTAssertFalse(servers.restoreKeychainFromMirrorIfNeeded())
|
|
}
|
|
|
|
func testPreviouslyRestoredMirrorSnapshotIsNotImportedAgainAfterReopen() throws {
|
|
mirrorStore.set(.fake(), key: "fake1")
|
|
|
|
servers = ServerManagerImpl(keychain: keychain, historicKeychain: historicKeychain, mirrorStore: mirrorStore)
|
|
servers.setup()
|
|
XCTAssertTrue(servers.restoreKeychainFromMirrorIfNeeded())
|
|
XCTAssertNotNil(mirrorStore.data["fake1"])
|
|
|
|
try keychain.removeAll()
|
|
|
|
servers = ServerManagerImpl(keychain: keychain, historicKeychain: historicKeychain, mirrorStore: mirrorStore)
|
|
servers.setup()
|
|
|
|
XCTAssertFalse(servers.isMirrorRestorePending)
|
|
XCTAssertFalse(servers.restoreKeychainFromMirrorIfNeeded())
|
|
XCTAssertTrue(servers.all.isEmpty)
|
|
XCTAssertNil(servers.server(for: "fake1"))
|
|
XCTAssertNil(try keychain.getData("fake1"))
|
|
XCTAssertNotNil(mirrorStore.data["fake1"])
|
|
}
|
|
|
|
func testExplicitRestoreMirroredServersNotifiesObservers() {
|
|
mirrorStore.set(.fake(), key: "fake1")
|
|
|
|
servers = ServerManagerImpl(keychain: keychain, historicKeychain: historicKeychain, mirrorStore: mirrorStore)
|
|
servers.setup()
|
|
|
|
let observer = FakeObserver()
|
|
servers.add(observer: observer)
|
|
|
|
let expectation = observer.addExpectation(from: self)
|
|
XCTAssertTrue(servers.restoreKeychainFromMirrorIfNeeded())
|
|
wait(for: [expectation], timeout: 10.0)
|
|
}
|
|
|
|
func testSetupDoesNotRestoreDeletedMirroredServersToKeychain() throws {
|
|
Current.settingsStore.prefs.set(["fake1"], forKey: "deletedServers")
|
|
mirrorStore.set(.fake(), key: "fake1")
|
|
|
|
servers = ServerManagerImpl(keychain: keychain, historicKeychain: historicKeychain, mirrorStore: mirrorStore)
|
|
servers.setup()
|
|
|
|
XCTAssertNil(try keychain.getData("fake1"))
|
|
XCTAssertNil(servers.server(for: "fake1"))
|
|
XCTAssertTrue(mirrorStore.data.isEmpty)
|
|
}
|
|
|
|
func testSetupPreservesMirrorSnapshotWhileExplicitRestoreIsPending() throws {
|
|
mirrorStore.set(.fake(), key: "fake1")
|
|
|
|
servers = ServerManagerImpl(keychain: keychain, historicKeychain: historicKeychain, mirrorStore: mirrorStore)
|
|
servers.setup()
|
|
|
|
XCTAssertTrue(servers.all.isEmpty)
|
|
XCTAssertNil(servers.server(for: "fake1"))
|
|
XCTAssertNil(try keychain.getData("fake1"))
|
|
XCTAssertNotNil(mirrorStore.data["fake1"])
|
|
}
|
|
|
|
func testAddingServerUpdatesMirrorStoreImmediately() throws {
|
|
servers = ServerManagerImpl(keychain: keychain, historicKeychain: historicKeychain, mirrorStore: mirrorStore)
|
|
servers.setup()
|
|
|
|
let added = servers.add(identifier: "fake1", serverInfo: .fake())
|
|
|
|
let mirrored = try XCTUnwrap(mirrorStore.data["fake1"])
|
|
XCTAssertEqual(mirrored.remoteName, added.info.remoteName)
|
|
XCTAssertEqual(mirrored.connection.webhookID, ServerInfo.mirrorPlaceholderWebhookID)
|
|
XCTAssertEqual(mirrored.connection.isLocalPushEnabled, added.info.connection.isLocalPushEnabled)
|
|
XCTAssertNil(mirrored.connection.cloudhookURL)
|
|
XCTAssertNil(mirrored.connection.webhookSecret)
|
|
XCTAssertEqual(mirrored.token, ServerInfo.mirrorPlaceholderToken)
|
|
XCTAssertNil(mirrored.connection.clientCertificate)
|
|
}
|
|
|
|
func testDeletingLastServerDoesNotDeadlockWhenMirrorSnapshotExists() throws {
|
|
let info = ServerInfo.fake()
|
|
try keychain.set(encoder.encode(info), key: "fake1")
|
|
|
|
servers = ServerManagerImpl(keychain: keychain, historicKeychain: historicKeychain, mirrorStore: mirrorStore)
|
|
servers.setup()
|
|
|
|
let server = try XCTUnwrap(servers.server(for: "fake1"))
|
|
|
|
servers.remove(identifier: "fake1")
|
|
|
|
XCTAssertNil(servers.server(for: "fake1"))
|
|
XCTAssertEqual(server.info.remoteName, info.remoteName)
|
|
XCTAssertNil(mirrorStore.data["fake1"])
|
|
XCTAssertFalse(servers.isMirrorRestorePending)
|
|
XCTAssertFalse(servers.restoreKeychainFromMirrorIfNeeded())
|
|
XCTAssertNil(try keychain.getData("fake1"))
|
|
}
|
|
|
|
func testKeychainInfoWinsOverMirrorFallback() throws {
|
|
let keychainInfo = with(ServerInfo.fake()) {
|
|
$0.remoteName = "Keychain"
|
|
}
|
|
let mirroredInfo = with(ServerInfo.fake()) {
|
|
$0.remoteName = "Mirror"
|
|
}
|
|
|
|
try keychain.set(encoder.encode(keychainInfo), key: "fake1")
|
|
mirrorStore.set(mirroredInfo, key: "fake1")
|
|
servers = ServerManagerImpl(keychain: keychain, historicKeychain: historicKeychain, mirrorStore: mirrorStore)
|
|
servers.setup()
|
|
|
|
XCTAssertEqual(servers.server(for: "fake1")?.info.remoteName, "Keychain")
|
|
}
|
|
|
|
func testThreadsafeChangesWithoutCaching() throws {
|
|
Current.isAppExtension = true
|
|
try base_testThreadsafeChanges()
|
|
}
|
|
|
|
func testThreadsafeChangesWithCaching() throws {
|
|
Current.isAppExtension = false
|
|
try base_testThreadsafeChanges()
|
|
}
|
|
|
|
private func base_testThreadsafeChanges() throws {
|
|
try setupRegular()
|
|
|
|
enum ActionType {
|
|
case insertExisting(newValue: Bool)
|
|
case insertNew
|
|
case mutate
|
|
case delete
|
|
}
|
|
|
|
let cases: [ActionType] = [
|
|
// weight a little heavier the normal ones
|
|
.insertNew,
|
|
.insertNew,
|
|
.mutate,
|
|
.mutate,
|
|
.delete,
|
|
.delete,
|
|
// the rest
|
|
.insertExisting(newValue: false),
|
|
.insertExisting(newValue: true),
|
|
]
|
|
|
|
DispatchQueue.concurrentPerform(iterations: 1000) { _ in
|
|
let randomServerInfo: ServerInfo = with(.fake()) {
|
|
$0.connection.webhookID = UUID().uuidString
|
|
}
|
|
|
|
switch cases.randomElement()! {
|
|
case .insertNew:
|
|
let added = servers.add(identifier: .init(rawValue: UUID().uuidString), serverInfo: randomServerInfo)
|
|
_ = servers.server(for: added.identifier)
|
|
case let .insertExisting(newValue):
|
|
if let random = servers.all.randomElement() {
|
|
let used: ServerInfo = newValue ? randomServerInfo : .fake()
|
|
servers.add(identifier: random.identifier, serverInfo: used)
|
|
_ = servers.server(for: random.identifier)
|
|
}
|
|
case .mutate:
|
|
if let random = servers.all.randomElement() {
|
|
random.info = randomServerInfo
|
|
_ = servers.server(for: random.identifier)
|
|
}
|
|
case .delete:
|
|
if let random = servers.all.randomElement() {
|
|
servers.remove(identifier: random.identifier)
|
|
_ = servers.server(for: random.identifier)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct HistoricInfo {
|
|
var connectionInfo: ConnectionInfo
|
|
var tokenInfo: TokenInfo
|
|
}
|
|
|
|
private func setupHistoric(
|
|
version: String?,
|
|
overrideDeviceName: String?,
|
|
locationName: String?
|
|
) throws -> HistoricInfo {
|
|
let connectionInfo = ConnectionInfo(
|
|
externalURL: URL(string: "http://external.local:8123")!,
|
|
internalURL: URL(string: "http://internal.local:8123")!,
|
|
cloudhookURL: nil,
|
|
remoteUIURL: nil,
|
|
webhookID: "webhook_id",
|
|
webhookSecret: "webhook_secret",
|
|
internalSSIDs: ["internal_ssid"],
|
|
internalHardwareAddresses: ["internal_hardware"],
|
|
isLocalPushEnabled: true,
|
|
securityExceptions: .init(),
|
|
connectionAccessSecurityLevel: .undefined
|
|
)
|
|
|
|
let tokenInfo = TokenInfo(
|
|
accessToken: "access_token",
|
|
refreshToken: "refresh_token",
|
|
expiration: Date(timeIntervalSinceNow: 1000)
|
|
)
|
|
|
|
try historicKeychain.set(encoder.encode(connectionInfo), key: "connectionInfo")
|
|
try historicKeychain.set(encoder.encode(tokenInfo), key: "tokenInfo")
|
|
Current.settingsStore.prefs.set(version, forKey: "version")
|
|
Current.settingsStore.prefs.set(overrideDeviceName, forKey: "override_device_name")
|
|
Current.settingsStore.prefs.set(locationName, forKey: "location_name")
|
|
|
|
servers = ServerManagerImpl(keychain: keychain, historicKeychain: historicKeychain, mirrorStore: mirrorStore)
|
|
servers.setup()
|
|
|
|
return .init(connectionInfo: connectionInfo, tokenInfo: tokenInfo)
|
|
}
|
|
|
|
func testEmptyMigrateWithFullData() throws {
|
|
let setupInfo = try setupHistoric(
|
|
version: "2021.96",
|
|
overrideDeviceName: "device_name_1",
|
|
locationName: "location_name_1"
|
|
)
|
|
|
|
XCTAssertEqual(servers.all.count, 1)
|
|
|
|
// added the server
|
|
let server = try XCTUnwrap(servers.server(for: Server.historicId))
|
|
XCTAssertEqual(server.info.connection, setupInfo.connectionInfo)
|
|
XCTAssertEqual(server.info.token, setupInfo.tokenInfo)
|
|
XCTAssertEqual(server.info.version, Version(major: 2021, minor: 96))
|
|
XCTAssertEqual(server.info.name, "location_name_1")
|
|
XCTAssertEqual(server.info.setting(for: .overrideDeviceName), "device_name_1")
|
|
|
|
// removed the old keychain
|
|
XCTAssertTrue(historicKeychain.data.isEmpty)
|
|
}
|
|
|
|
func testEmptyMigrateWithMinimalData() throws {
|
|
let setupInfo = try setupHistoric(
|
|
version: nil,
|
|
overrideDeviceName: nil,
|
|
locationName: nil
|
|
)
|
|
|
|
XCTAssertEqual(servers.all.count, 1)
|
|
|
|
// added the server
|
|
let server = try XCTUnwrap(servers.server(for: Server.historicId))
|
|
XCTAssertEqual(server.info.connection, setupInfo.connectionInfo)
|
|
XCTAssertEqual(server.info.token, setupInfo.tokenInfo)
|
|
XCTAssertEqual(server.info.version, Version(major: 2021, minor: 1))
|
|
XCTAssertEqual(server.info.name, ServerInfo.defaultName)
|
|
XCTAssertNil(server.info.setting(for: .overrideDeviceName))
|
|
|
|
// removed the old keychain
|
|
XCTAssertTrue(historicKeychain.data.isEmpty)
|
|
}
|
|
|
|
func testMigrateDoesntOccurWithExisting() throws {
|
|
try setupRegular(["existing": .fake()])
|
|
_ = try setupHistoric(version: nil, overrideDeviceName: nil, locationName: nil)
|
|
|
|
XCTAssertEqual(servers.all.count, 1)
|
|
XCTAssertNotNil(servers.server(for: "existing"))
|
|
XCTAssertNil(servers.server(for: Server.historicId))
|
|
}
|
|
}
|
|
|
|
class FakeServerManagerKeychain: ServerManagerKeychain {
|
|
// The thread-safety tests hit this fake from many concurrent queues, so keep
|
|
// the backing dictionary serialized to avoid test-only memory corruption.
|
|
private let queue = DispatchQueue(label: "Tests.Shared.FakeServerManagerKeychain")
|
|
private var storage = [String: Data]()
|
|
|
|
var data: [String: Data] {
|
|
queue.sync { storage }
|
|
}
|
|
|
|
func removeAll() throws {
|
|
queue.sync {
|
|
storage.removeAll()
|
|
}
|
|
}
|
|
|
|
func allKeys() -> [String] {
|
|
queue.sync {
|
|
Array(storage.keys)
|
|
}
|
|
}
|
|
|
|
func getData(_ key: String) throws -> Data? {
|
|
queue.sync {
|
|
storage[key]
|
|
}
|
|
}
|
|
|
|
func set(_ value: Data, key: String) throws {
|
|
queue.sync {
|
|
storage[key] = value
|
|
}
|
|
}
|
|
|
|
func remove(_ key: String) throws {
|
|
queue.sync {
|
|
storage.removeValue(forKey: key)
|
|
}
|
|
}
|
|
}
|
|
|
|
class FakeServerManagerMirrorStore: ServerManagerMirrorStore {
|
|
// Mirror reads and writes can happen concurrently during the thread-safety
|
|
// tests, so this fake also needs serialized storage.
|
|
private let queue = DispatchQueue(label: "Tests.Shared.FakeServerManagerMirrorStore")
|
|
private var storage = [String: ServerInfo]()
|
|
|
|
var data: [String: ServerInfo] {
|
|
queue.sync { storage }
|
|
}
|
|
|
|
func removeAll() {
|
|
queue.sync {
|
|
storage.removeAll()
|
|
}
|
|
}
|
|
|
|
func allKeys() -> [String] {
|
|
queue.sync {
|
|
Array(storage.keys)
|
|
}
|
|
}
|
|
|
|
func allServerInfo() -> [(String, ServerInfo)] {
|
|
queue.sync {
|
|
Array(storage)
|
|
}
|
|
}
|
|
|
|
func getServerInfo(_ key: String) -> ServerInfo? {
|
|
queue.sync {
|
|
storage[key]
|
|
}
|
|
}
|
|
|
|
func set(_ serverInfo: ServerInfo, key: String) {
|
|
queue.sync {
|
|
storage[key] = serverInfo.mirroredForPersistence
|
|
}
|
|
}
|
|
|
|
func remove(_ key: String) {
|
|
queue.sync {
|
|
storage.removeValue(forKey: key)
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct FakeServerIdentifierProviding: ServerIdentifierProviding {
|
|
var serverIdentifier: String
|
|
}
|
|
|
|
private struct FakeServerIntentProviding: ServerIntentProviding {
|
|
var server: IntentServer?
|
|
}
|
|
|
|
private class FakeObserver: ServerObserver {
|
|
var expectation: XCTestExpectation?
|
|
|
|
func addExpectation(from testCase: XCTestCase) -> XCTestExpectation {
|
|
let expectation = testCase.expectation(description: "server observer")
|
|
self.expectation = expectation
|
|
return expectation
|
|
}
|
|
|
|
func serversDidChange(_ serverManager: ServerManager) {
|
|
if let expectation {
|
|
expectation.fulfill()
|
|
} else {
|
|
XCTFail("observed without expectation")
|
|
}
|
|
}
|
|
}
|