mirror of
https://github.com/home-assistant/iOS.git
synced 2026-06-17 17:49:07 -05:00
Fix mtls profile info request on macos (#4555)
<!-- 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 --> ## 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. -->
This commit is contained in:
committed by
GitHub
parent
1ec9940ad1
commit
3310bb6ada
@@ -104,6 +104,7 @@
|
||||
1130A5742751B29E00640E38 /* PerServerContainer.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1130A5732751B29E00640E38 /* PerServerContainer.test.swift */; };
|
||||
1130A5762751BA1800640E38 /* Server.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1130A5752751BA1800640E38 /* Server.test.swift */; };
|
||||
1130A5782751BDD900640E38 /* ServerManager.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1130A5772751BDD900640E38 /* ServerManager.test.swift */; };
|
||||
42F1C0013140B00011AABB01 /* HomeAssistantAPIIdentity.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42F1C0023140B00011AABB01 /* HomeAssistantAPIIdentity.test.swift */; };
|
||||
1130F532253A1E7400F371BE /* ComplicationListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1130F531253A1E7400F371BE /* ComplicationListViewController.swift */; };
|
||||
1130F57E253A2ED500F371BE /* ComplicationFamilySelectViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1130F57D253A2ED500F371BE /* ComplicationFamilySelectViewController.swift */; };
|
||||
113199DC28ADEEF700FA7572 /* OnboardingAuthLoginViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 113199DB28ADEEF700FA7572 /* OnboardingAuthLoginViewController.swift */; };
|
||||
@@ -1949,6 +1950,7 @@
|
||||
1130A5732751B29E00640E38 /* PerServerContainer.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PerServerContainer.test.swift; sourceTree = "<group>"; };
|
||||
1130A5752751BA1800640E38 /* Server.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Server.test.swift; sourceTree = "<group>"; };
|
||||
1130A5772751BDD900640E38 /* ServerManager.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerManager.test.swift; sourceTree = "<group>"; };
|
||||
42F1C0023140B00011AABB01 /* HomeAssistantAPIIdentity.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeAssistantAPIIdentity.test.swift; sourceTree = "<group>"; };
|
||||
1130F531253A1E7400F371BE /* ComplicationListViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComplicationListViewController.swift; sourceTree = "<group>"; };
|
||||
1130F57D253A2ED500F371BE /* ComplicationFamilySelectViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComplicationFamilySelectViewController.swift; sourceTree = "<group>"; };
|
||||
113199DB28ADEEF700FA7572 /* OnboardingAuthLoginViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingAuthLoginViewController.swift; sourceTree = "<group>"; };
|
||||
@@ -7309,6 +7311,7 @@
|
||||
1130A5732751B29E00640E38 /* PerServerContainer.test.swift */,
|
||||
1130A5752751BA1800640E38 /* Server.test.swift */,
|
||||
1130A5772751BDD900640E38 /* ServerManager.test.swift */,
|
||||
42F1C0023140B00011AABB01 /* HomeAssistantAPIIdentity.test.swift */,
|
||||
11B38EDE275BE29F00205C7B /* ConnectionInfo.test.swift */,
|
||||
480E9A5D40714BBAA81B15F7 /* ClientCertificate.test.swift */,
|
||||
114CBAEA2839FC2500A9BAFF /* SecurityExceptions.test.swift */,
|
||||
@@ -10464,6 +10467,7 @@
|
||||
11CD94B924B2D16F00BA801D /* WebhookResponseServiceCall.test.swift in Sources */,
|
||||
11CD94B524B2C06700BA801D /* WebhookResponseUpdateSensors.test.swift in Sources */,
|
||||
1130A5782751BDD900640E38 /* ServerManager.test.swift in Sources */,
|
||||
42F1C0013140B00011AABB01 /* HomeAssistantAPIIdentity.test.swift in Sources */,
|
||||
11BC9E5724FDC1C900B9FBF7 /* ActiveSensor.test.swift in Sources */,
|
||||
11F2F2A92587288200F61F7C /* NotificationAttachmentParserCamera.test.swift in Sources */,
|
||||
1104FCCF253275CF00B8BE34 /* WatchBackgroundRefreshScheduler.test.swift in Sources */,
|
||||
|
||||
@@ -140,12 +140,15 @@ struct ServerSelectViewRow: View {
|
||||
}
|
||||
|
||||
private func loadUserNameAndProfilePicture() {
|
||||
Current.api(for: server)?.connection.caches.user.once { user in
|
||||
userName = user.name.orEmpty
|
||||
}
|
||||
guard let api = Current.api(for: server) else { return }
|
||||
|
||||
Current.api(for: server)?.profilePicture { image in
|
||||
profilePictureImage = image
|
||||
api.currentUser { user in
|
||||
userName = user?.name ?? ""
|
||||
|
||||
guard let user else { return }
|
||||
api.profilePicture(for: user) { image in
|
||||
profilePictureImage = image
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,6 +124,11 @@ final class ConnectionSettingsViewModel: ObservableObject {
|
||||
updateFromServerInfo(server.info)
|
||||
updateURLs()
|
||||
clientCertificate = server.info.connection.clientCertificate
|
||||
Current.api(for: server)?.currentUser { [weak self] user in
|
||||
Task { @MainActor [weak self] in
|
||||
self?.loggedInUser = user?.name ?? ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func updateFromServerInfo(_ info: ServerInfo) {
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import Alamofire
|
||||
import Eureka
|
||||
import Foundation
|
||||
import HAKit
|
||||
import PromiseKit
|
||||
import Shared
|
||||
|
||||
enum AccountRowValue: Equatable, CustomStringConvertible {
|
||||
@@ -135,19 +133,19 @@ final class HomeAssistantAccountRow: Row<AccountCell>, RowType {
|
||||
}
|
||||
|
||||
deinit {
|
||||
accountSubscription?.cancel()
|
||||
avatarSubscription?.cancel()
|
||||
currentUserRequest?.cancel()
|
||||
avatarRequest?.cancel()
|
||||
}
|
||||
|
||||
fileprivate var cachedImage: UIImage?
|
||||
fileprivate var cachedUserName: String?
|
||||
private var accountSubscription: HACancellable? {
|
||||
private var currentUserRequest: HACancellable? {
|
||||
didSet {
|
||||
oldValue?.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
private var avatarSubscription: HACancellable? {
|
||||
private var avatarRequest: HACancellable? {
|
||||
didSet {
|
||||
oldValue?.cancel()
|
||||
}
|
||||
@@ -161,22 +159,10 @@ final class HomeAssistantAccountRow: Row<AccountCell>, RowType {
|
||||
}
|
||||
}
|
||||
|
||||
enum FetchAvatarError: Error, CancellableError {
|
||||
case missingPerson
|
||||
case missingURLForUserEntityPicture
|
||||
case alreadySet
|
||||
case couldntDecode
|
||||
|
||||
var isCancelled: Bool {
|
||||
if self == .alreadySet {
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func fetchAvatar() {
|
||||
currentUserRequest = nil
|
||||
avatarRequest = nil
|
||||
|
||||
guard let server = value?.server else {
|
||||
cachedImage = nil
|
||||
cachedUserName = nil
|
||||
@@ -184,64 +170,32 @@ final class HomeAssistantAccountRow: Row<AccountCell>, RowType {
|
||||
return
|
||||
}
|
||||
|
||||
cachedImage = nil
|
||||
cachedUserName = nil
|
||||
updateCell()
|
||||
|
||||
guard let api = Current.api(for: server) else {
|
||||
Current.Log.error("No API available to fetch avatar")
|
||||
return
|
||||
}
|
||||
|
||||
accountSubscription = api.connection.caches.user.once { [weak self] user in
|
||||
currentUserRequest = api.currentUser { [weak self] user in
|
||||
guard let self else { return }
|
||||
Current.Log.verbose("got user from user \(user)")
|
||||
cachedUserName = user.name
|
||||
guard value?.server?.identifier == server.identifier else { return }
|
||||
|
||||
cachedUserName = user?.name
|
||||
updateCell()
|
||||
|
||||
var lastTask: Request? {
|
||||
didSet {
|
||||
oldValue?.cancel()
|
||||
lastTask?.resume()
|
||||
}
|
||||
guard let user else {
|
||||
return
|
||||
}
|
||||
|
||||
avatarSubscription = api.connection.caches.states().once { [weak self] states in
|
||||
firstly { () -> Guarantee<Set<HAEntity>> in
|
||||
Guarantee.value(states.all)
|
||||
}.map { states throws -> HAEntity in
|
||||
if let person = states.first(where: { $0.attributes["user_id"] as? String == user.id }) {
|
||||
return person
|
||||
} else {
|
||||
throw FetchAvatarError.missingPerson
|
||||
}
|
||||
}.map { entity -> String in
|
||||
if let urlString = entity.attributes["entity_picture"] as? String {
|
||||
return urlString
|
||||
} else {
|
||||
throw FetchAvatarError.missingURLForUserEntityPicture
|
||||
}
|
||||
}.map { path throws -> URL in
|
||||
guard let url = server.info.connection.activeURL()?.appendingPathComponent(path) else {
|
||||
throw ServerConnectionError.noActiveURL(server.info.name)
|
||||
}
|
||||
if let lastTask, lastTask.error == nil, lastTask.request?.url == url {
|
||||
throw FetchAvatarError.alreadySet
|
||||
}
|
||||
return url
|
||||
}.then { url -> Promise<Data> in
|
||||
Promise<Data> { seal in
|
||||
lastTask = api.manager.download(url).validate().responseData { result in
|
||||
seal.resolve(result.result)
|
||||
}
|
||||
}
|
||||
}.map { data throws -> UIImage in
|
||||
if let image = UIImage(data: data) {
|
||||
return image
|
||||
} else {
|
||||
throw FetchAvatarError.couldntDecode
|
||||
}
|
||||
}.done { [weak self] image in
|
||||
Current.Log.verbose("got image \(image.size)")
|
||||
self?.cachedImage = image
|
||||
self?.updateCell()
|
||||
}.cauterize()
|
||||
avatarRequest = api.profilePicture(for: user) { [weak self] image in
|
||||
guard let self else { return }
|
||||
guard value?.server?.identifier == server.identifier else { return }
|
||||
|
||||
cachedImage = image
|
||||
updateCell()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,12 +62,15 @@ struct HomeAssistantAccountRowView: View {
|
||||
}
|
||||
|
||||
private func loadUserNameAndProfilePicture() {
|
||||
Current.api(for: server)?.connection.caches.user.once { user in
|
||||
userName = user.name.orEmpty
|
||||
}
|
||||
guard let api = Current.api(for: server) else { return }
|
||||
|
||||
Current.api(for: server)?.profilePicture { image in
|
||||
profilePicture = image
|
||||
api.currentUser { user in
|
||||
userName = user?.name ?? ""
|
||||
|
||||
guard let user else { return }
|
||||
api.profilePicture(for: user) { image in
|
||||
profilePicture = image
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1069,15 +1069,59 @@ public class HomeAssistantAPI {
|
||||
}
|
||||
#endif
|
||||
|
||||
public func profilePictureURL(completion: @escaping (URL?) -> Void) {
|
||||
connection.caches.user.once { [weak self] user in
|
||||
guard let self else {
|
||||
Current.Log.error("Failed to retrieve profile picture URL: self is nil")
|
||||
completion(nil)
|
||||
return
|
||||
private final class ProfilePictureCancellable: HACancellable {
|
||||
private(set) var isCancelled = false
|
||||
private var cancellables = [HACancellable]()
|
||||
private var downloadRequest: Request?
|
||||
|
||||
func add(_ cancellable: HACancellable) {
|
||||
if isCancelled {
|
||||
cancellable.cancel()
|
||||
} else {
|
||||
cancellables.append(cancellable)
|
||||
}
|
||||
connection.caches.states().once { [weak self] states in
|
||||
let states = states.all
|
||||
}
|
||||
|
||||
func setDownloadRequest(_ request: Request) {
|
||||
if isCancelled {
|
||||
request.cancel()
|
||||
} else {
|
||||
downloadRequest = request
|
||||
}
|
||||
}
|
||||
|
||||
func cancel() {
|
||||
guard !isCancelled else { return }
|
||||
|
||||
isCancelled = true
|
||||
cancellables.forEach { $0.cancel() }
|
||||
downloadRequest?.cancel()
|
||||
cancellables.removeAll()
|
||||
downloadRequest = nil
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func currentUser(completion: @escaping (HAResponseCurrentUser?) -> Void) -> HACancellable {
|
||||
connection.send(HATypedRequest<HAResponseCurrentUser>.fetchCurrentUser()) { result in
|
||||
switch result {
|
||||
case let .success(user):
|
||||
completion(user)
|
||||
case let .failure(error):
|
||||
Current.Log.error("Failed to retrieve current user: \(error)")
|
||||
completion(nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func profilePictureURL(
|
||||
for user: HAResponseCurrentUser,
|
||||
completion: @escaping (URL?) -> Void
|
||||
) -> HACancellable {
|
||||
connection.send(HATypedRequest<[HAEntity]>.fetchStates()) { [weak self] result in
|
||||
switch result {
|
||||
case let .success(states):
|
||||
guard let person = states.first(where: { $0.attributes["user_id"] as? String == user.id }) else {
|
||||
Current.Log.error("Profile picture: No person found for user \(user.id)")
|
||||
completion(nil)
|
||||
@@ -1090,25 +1134,58 @@ public class HomeAssistantAPI {
|
||||
return
|
||||
}
|
||||
|
||||
guard let url = self?.server.info.connection.activeURL()?.appendingPathComponent(path) else {
|
||||
Current.Log.error("Profile picture: Missing active URL for user entity picture, user id \(user.id)")
|
||||
guard let url = self?.resolvedProfilePictureURL(from: path) else {
|
||||
Current.Log.error("Profile picture: Invalid URL for user entity picture, user id \(user.id)")
|
||||
completion(nil)
|
||||
return
|
||||
}
|
||||
|
||||
completion(url)
|
||||
case let .failure(error):
|
||||
Current.Log.error("Failed to retrieve states for profile picture: \(error)")
|
||||
completion(nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func profilePicture(completion: @escaping (UIImage?) -> Void) {
|
||||
profilePictureURL { [weak self] url in
|
||||
@discardableResult
|
||||
public func profilePictureURL(completion: @escaping (URL?) -> Void) -> HACancellable {
|
||||
let cancellable = ProfilePictureCancellable()
|
||||
|
||||
cancellable.add(currentUser { [weak self] user in
|
||||
guard !cancellable.isCancelled else { return }
|
||||
guard let self, let user else {
|
||||
completion(nil)
|
||||
return
|
||||
}
|
||||
|
||||
cancellable.add(profilePictureURL(for: user) { url in
|
||||
guard !cancellable.isCancelled else { return }
|
||||
completion(url)
|
||||
})
|
||||
})
|
||||
|
||||
return cancellable
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func profilePicture(
|
||||
for user: HAResponseCurrentUser,
|
||||
completion: @escaping (UIImage?) -> Void
|
||||
) -> HACancellable {
|
||||
let cancellable = ProfilePictureCancellable()
|
||||
|
||||
cancellable.add(profilePictureURL(for: user) { [weak self] url in
|
||||
guard !cancellable.isCancelled else { return }
|
||||
guard let self, let url else {
|
||||
completion(nil)
|
||||
return
|
||||
}
|
||||
|
||||
manager.download(url).validate().responseData { response in
|
||||
let request = manager.download(url).validate()
|
||||
cancellable.setDownloadRequest(request)
|
||||
request.responseData { response in
|
||||
guard !cancellable.isCancelled else { return }
|
||||
switch response.result {
|
||||
case let .success(data):
|
||||
completion(UIImage(data: data))
|
||||
@@ -1117,6 +1194,74 @@ public class HomeAssistantAPI {
|
||||
completion(nil)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return cancellable
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func profilePicture(completion: @escaping (UIImage?) -> Void) -> HACancellable {
|
||||
let cancellable = ProfilePictureCancellable()
|
||||
|
||||
cancellable.add(currentUser { [weak self] user in
|
||||
guard !cancellable.isCancelled else { return }
|
||||
guard let self, let user else {
|
||||
completion(nil)
|
||||
return
|
||||
}
|
||||
|
||||
cancellable.add(profilePicture(for: user) { image in
|
||||
guard !cancellable.isCancelled else { return }
|
||||
completion(image)
|
||||
})
|
||||
})
|
||||
|
||||
return cancellable
|
||||
}
|
||||
|
||||
private func resolvedProfilePictureURL(from path: String) -> URL? {
|
||||
guard let activeURL = server.info.connection.activeURL() else {
|
||||
return nil
|
||||
}
|
||||
|
||||
guard let url = URL(string: path, relativeTo: activeURL)?.absoluteURL else {
|
||||
return nil
|
||||
}
|
||||
|
||||
guard url.hasSameOrigin(as: activeURL) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
private extension URL {
|
||||
func hasSameOrigin(as other: URL) -> Bool {
|
||||
guard let scheme = scheme?.lowercased(),
|
||||
let otherScheme = other.scheme?.lowercased(),
|
||||
let host = host?.lowercased(),
|
||||
let otherHost = other.host?.lowercased() else {
|
||||
return false
|
||||
}
|
||||
|
||||
return scheme == otherScheme &&
|
||||
host == otherHost &&
|
||||
normalizedOriginPort == other.normalizedOriginPort
|
||||
}
|
||||
|
||||
private var normalizedOriginPort: Int? {
|
||||
if let port {
|
||||
return port
|
||||
}
|
||||
|
||||
switch scheme?.lowercased() {
|
||||
case "http":
|
||||
return 80
|
||||
case "https":
|
||||
return 443
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,6 +146,10 @@ public extension HATypedRequest {
|
||||
))
|
||||
}
|
||||
|
||||
static func fetchCurrentUser() -> HATypedRequest<HAResponseCurrentUser> {
|
||||
HATypedRequest<HAResponseCurrentUser>.currentUser()
|
||||
}
|
||||
|
||||
static func fetchStates() -> HATypedRequest<[HAEntity]> {
|
||||
HATypedRequest<[HAEntity]>(request: .init(
|
||||
type: .rest(.get, "states")
|
||||
|
||||
229
Tests/Shared/HomeAssistantAPIIdentity.test.swift
Normal file
229
Tests/Shared/HomeAssistantAPIIdentity.test.swift
Normal file
@@ -0,0 +1,229 @@
|
||||
import HAKit
|
||||
@testable import Shared
|
||||
import XCTest
|
||||
|
||||
final class HomeAssistantAPIIdentityTests: XCTestCase {
|
||||
override func tearDown() {
|
||||
ServerFixture.reset()
|
||||
super.tearDown()
|
||||
}
|
||||
|
||||
func testCurrentUserUsesWebSocketEndpoint() {
|
||||
let api = HomeAssistantAPI(server: ServerFixture.withRemoteConnection)
|
||||
let connection = FakeHAConnection()
|
||||
connection.mockResponses["auth/current_user"] = .dictionary([
|
||||
"id": "user-id",
|
||||
"name": "cepresso",
|
||||
"is_owner": false,
|
||||
"is_admin": true,
|
||||
"credentials": [],
|
||||
"mfa_modules": [],
|
||||
])
|
||||
api.connection = connection
|
||||
|
||||
let expectation = expectation(description: "current user")
|
||||
|
||||
api.currentUser { user in
|
||||
XCTAssertEqual(user?.id, "user-id")
|
||||
XCTAssertEqual(user?.name, "cepresso")
|
||||
expectation.fulfill()
|
||||
}
|
||||
|
||||
wait(for: [expectation], timeout: 1)
|
||||
XCTAssertEqual(connection.sentRequests.count, 1)
|
||||
|
||||
guard case let .webSocket(command) = connection.sentRequests[0].type else {
|
||||
XCTFail("Expected WebSocket request")
|
||||
return
|
||||
}
|
||||
|
||||
XCTAssertEqual(command, "auth/current_user")
|
||||
}
|
||||
|
||||
func testProfilePictureURLUsesWebSocketCurrentUserAndRestStates() {
|
||||
let api = HomeAssistantAPI(server: ServerFixture.withRemoteConnection)
|
||||
let connection = FakeHAConnection()
|
||||
connection.mockResponses["auth/current_user"] = .dictionary([
|
||||
"id": "user-id",
|
||||
"name": "cepresso",
|
||||
"is_owner": false,
|
||||
"is_admin": true,
|
||||
"credentials": [],
|
||||
"mfa_modules": [],
|
||||
])
|
||||
connection.mockResponses["states"] = .array([
|
||||
.dictionary([
|
||||
"entity_id": "person.cepresso",
|
||||
"state": "home",
|
||||
"last_changed": "2026-04-23T10:00:00Z",
|
||||
"last_updated": "2026-04-23T10:00:00Z",
|
||||
"attributes": [
|
||||
"user_id": "user-id",
|
||||
"entity_picture": "/api/image/serve/abc/original?token=123",
|
||||
],
|
||||
"context": [
|
||||
"id": "context-id",
|
||||
],
|
||||
]),
|
||||
])
|
||||
api.connection = connection
|
||||
|
||||
let expectation = expectation(description: "profile picture URL")
|
||||
|
||||
api.profilePictureURL { url in
|
||||
XCTAssertEqual(
|
||||
url?.absoluteString,
|
||||
"https://external.example.com/api/image/serve/abc/original?token=123"
|
||||
)
|
||||
expectation.fulfill()
|
||||
}
|
||||
|
||||
wait(for: [expectation], timeout: 1)
|
||||
XCTAssertEqual(connection.sentRequests.count, 2)
|
||||
|
||||
guard case let .webSocket(firstCommand) = connection.sentRequests[0].type else {
|
||||
XCTFail("Expected first request to use WebSocket")
|
||||
return
|
||||
}
|
||||
|
||||
XCTAssertEqual(firstCommand, "auth/current_user")
|
||||
|
||||
guard case let .rest(secondMethod, secondCommand) = connection.sentRequests[1].type else {
|
||||
XCTFail("Expected second request to use REST")
|
||||
return
|
||||
}
|
||||
|
||||
XCTAssertEqual(secondMethod, .get)
|
||||
XCTAssertEqual(secondCommand, "states")
|
||||
}
|
||||
|
||||
func testProfilePictureURLRejectsExternalEntityPictureURL() {
|
||||
let api = HomeAssistantAPI(server: ServerFixture.withRemoteConnection)
|
||||
let connection = FakeHAConnection()
|
||||
connection.mockResponses["auth/current_user"] = .dictionary([
|
||||
"id": "user-id",
|
||||
"name": "cepresso",
|
||||
"is_owner": false,
|
||||
"is_admin": true,
|
||||
"credentials": [],
|
||||
"mfa_modules": [],
|
||||
])
|
||||
connection.mockResponses["states"] = .array([
|
||||
.dictionary([
|
||||
"entity_id": "person.cepresso",
|
||||
"state": "home",
|
||||
"last_changed": "2026-04-23T10:00:00Z",
|
||||
"last_updated": "2026-04-23T10:00:00Z",
|
||||
"attributes": [
|
||||
"user_id": "user-id",
|
||||
"entity_picture": "https://attacker.example.com/avatar.png",
|
||||
],
|
||||
"context": [
|
||||
"id": "context-id",
|
||||
],
|
||||
]),
|
||||
])
|
||||
api.connection = connection
|
||||
|
||||
let expectation = expectation(description: "profile picture URL")
|
||||
|
||||
api.profilePictureURL { url in
|
||||
XCTAssertNil(url)
|
||||
expectation.fulfill()
|
||||
}
|
||||
|
||||
wait(for: [expectation], timeout: 1)
|
||||
XCTAssertEqual(connection.sentRequests.count, 2)
|
||||
}
|
||||
}
|
||||
|
||||
private final class FakeHAConnection: HAConnection {
|
||||
weak var delegate: HAConnectionDelegate?
|
||||
var configuration = HAConnectionConfiguration(
|
||||
connectionInfo: { nil },
|
||||
fetchAuthToken: { completion in completion(.success("token")) }
|
||||
)
|
||||
var state: HAConnectionState = .disconnected(reason: .disconnected)
|
||||
lazy var caches: HACachesContainer = .init(connection: self)
|
||||
var callbackQueue: DispatchQueue = .main
|
||||
|
||||
var sentRequests = [HARequest]()
|
||||
var mockResponses = [String: HAData]()
|
||||
|
||||
func connect() {}
|
||||
|
||||
func disconnect() {}
|
||||
|
||||
@discardableResult
|
||||
func send(
|
||||
_ request: HARequest,
|
||||
completion: @escaping RequestCompletion
|
||||
) -> HACancellable {
|
||||
sentRequests.append(request)
|
||||
completion(.failure(.internal(debugDescription: "Raw request not mocked")))
|
||||
return HANoopCancellable()
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func send<T>(
|
||||
_ request: HATypedRequest<T>,
|
||||
completion: @escaping (Result<T, HAError>) -> Void
|
||||
) -> HACancellable where T: HADataDecodable {
|
||||
sentRequests.append(request.request)
|
||||
|
||||
let command = request.request.type.command
|
||||
|
||||
guard let data = mockResponses[command] else {
|
||||
completion(.failure(.internal(debugDescription: "Missing mock response for \(command)")))
|
||||
return HANoopCancellable()
|
||||
}
|
||||
|
||||
do {
|
||||
try completion(.success(T(data: data)))
|
||||
} catch {
|
||||
completion(.failure(.underlying(error as NSError)))
|
||||
}
|
||||
|
||||
return HANoopCancellable()
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func subscribe(
|
||||
to request: HARequest,
|
||||
handler: @escaping SubscriptionHandler
|
||||
) -> HACancellable {
|
||||
sentRequests.append(request)
|
||||
return HANoopCancellable()
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func subscribe(
|
||||
to request: HARequest,
|
||||
initiated: @escaping SubscriptionInitiatedHandler,
|
||||
handler: @escaping SubscriptionHandler
|
||||
) -> HACancellable {
|
||||
sentRequests.append(request)
|
||||
initiated(.failure(.internal(debugDescription: "Subscriptions not mocked")))
|
||||
return HANoopCancellable()
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func subscribe<T>(
|
||||
to request: HATypedSubscription<T>,
|
||||
handler: @escaping (HACancellable, T) -> Void
|
||||
) -> HACancellable {
|
||||
sentRequests.append(request.request)
|
||||
return HANoopCancellable()
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func subscribe<T>(
|
||||
to request: HATypedSubscription<T>,
|
||||
initiated: @escaping SubscriptionInitiatedHandler,
|
||||
handler: @escaping (HACancellable, T) -> Void
|
||||
) -> HACancellable {
|
||||
sentRequests.append(request.request)
|
||||
initiated(.failure(.internal(debugDescription: "Subscriptions not mocked")))
|
||||
return HANoopCancellable()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user