diff --git a/MeetingBar/Core/EventStores/GCEventStore.swift b/MeetingBar/Core/EventStores/GCEventStore.swift index ebc3f24d..deb4dce9 100644 --- a/MeetingBar/Core/EventStores/GCEventStore.swift +++ b/MeetingBar/Core/EventStores/GCEventStore.swift @@ -93,8 +93,8 @@ final class GCEventStore: NSObject, // MARK: Public API func signIn(forcePrompt: Bool = false) async throws { - // if already authorised, nothing to do - if authState?.isAuthorized == true { return } + // Skip sign-in only for reusable sessions and when consent is not forced. + if Self.shouldSkipSignIn(forcePrompt: forcePrompt, state: authState) { return } // discover configuration for Google issuer let config = try await withCheckedThrowingContinuation { cont in @@ -156,7 +156,7 @@ final class GCEventStore: NSObject, // Revoke tokens in parallel let access = state.lastTokenResponse?.accessToken - let refresh = state.lastTokenResponse?.refreshToken + let refresh = state.refreshToken await withTaskGroup(of: Void.self) { grp in if let acc = access { grp.addTask { try? await self.revoke(token: acc) } } if let ref = refresh { grp.addTask { try? await self.revoke(token: ref) } } @@ -221,14 +221,13 @@ final class GCEventStore: NSObject, // MARK: - Private helpers private func ensureSignedIn() async throws { if authState?.isAuthorized == true, - authState?.lastTokenResponse?.refreshToken != nil { + authState?.refreshToken != nil { return } + let forceConsent = authState?.refreshToken == nil if let running = signInTask { return try await running.value } - let forceConsent = authState?.lastTokenResponse?.refreshToken == nil - let task = Task { try await signIn(forcePrompt: forceConsent) } @@ -237,6 +236,30 @@ final class GCEventStore: NSObject, try await task.value } + nonisolated static func shouldSkipSignIn(forcePrompt: Bool, state: OIDAuthState?) -> Bool { + guard !forcePrompt, let state else { return false } + return state.isAuthorized && state.refreshToken != nil + } + +#if DEBUG + func _test_getAuthState() -> OIDAuthState? { + authState + } + + func _test_setAuthState(_ state: OIDAuthState?) { + authState = state + userEmail = state?.userEmail + } + + func _test_ensureSignedIn() async throws { + try await ensureSignedIn() + } + + func _test_setSignInTask(_ task: Task?) { + signInTask = task + } +#endif + private func validAccessToken(forceRefresh: Bool = false) async throws -> String { guard let state = authState else { throw AuthError.notSignedIn } diff --git a/MeetingBarTests/EventManagerTests.swift b/MeetingBarTests/EventManagerTests.swift index 0f7d4645..35911a27 100644 --- a/MeetingBarTests/EventManagerTests.swift +++ b/MeetingBarTests/EventManagerTests.swift @@ -9,6 +9,7 @@ import Combine @testable import MeetingBar import XCTest import Defaults +import AppAuthCore @MainActor class EventManagerTests: BaseTestCase { @@ -196,3 +197,97 @@ final class RefreshTriggerTests: BaseTestCase { await fulfillment(of: [exp], timeout: 1) } } + +@MainActor +final class GCEventStoreCoverageTests: BaseTestCase { + func testSignInSkipLogicHonorsForcePrompt() { + let authorizedWithRefresh = makeAuthState(refreshToken: "refresh-token-1") + XCTAssertTrue(GCEventStore.shouldSkipSignIn(forcePrompt: false, state: authorizedWithRefresh)) + XCTAssertFalse(GCEventStore.shouldSkipSignIn(forcePrompt: true, state: authorizedWithRefresh)) + + let authorizedWithoutRefresh = makeAuthState(refreshToken: nil) + XCTAssertFalse(GCEventStore.shouldSkipSignIn(forcePrompt: false, state: authorizedWithoutRefresh)) + } + + func testEnsureSignedInUsesRefreshTokenSessionChecks() async throws { + let store = GCEventStore.shared + let originalState = store._test_getAuthState() + defer { + store._test_setAuthState(originalState) + store._test_setSignInTask(nil) + } + + let precompletedTask = Task {} + store._test_setSignInTask(precompletedTask) + + store._test_setAuthState(makeAuthState(refreshToken: nil)) + try await store._test_ensureSignedIn() + + store._test_setAuthState(makeAuthState(refreshToken: "refresh-token-1")) + store._test_setSignInTask(nil) + try await store._test_ensureSignedIn() + } + + func testSignOutReadsRefreshTokenFromAuthState() async throws { + let store = GCEventStore.shared + let originalState = store._test_getAuthState() + defer { + store._test_setAuthState(originalState) + store._test_setSignInTask(nil) + } + + store._test_setAuthState(makeAuthorizationOnlyState()) + await store.signOut() + } + + private func makeAuthState(refreshToken: String?) -> OIDAuthState { + let request = authorizationRequest() + let response = OIDAuthorizationResponse( + request: request, + parameters: [ + "code": "authorization-code" as NSString + ] + ) + guard let tokenRequest = response.tokenExchangeRequest() else { + fatalError("Failed to build token request for GCEventStore coverage test") + } + + var tokenParameters: [String: NSObject & NSCopying] = [ + "access_token": "access-token-1" as NSString, + "token_type": "Bearer" as NSString, + "expires_in": 3600 as NSNumber + ] + if let refreshToken { + tokenParameters["refresh_token"] = refreshToken as NSString + } + let tokenResponse = OIDTokenResponse(request: tokenRequest, parameters: tokenParameters) + return OIDAuthState(authorizationResponse: response, tokenResponse: tokenResponse) + } + + private func makeAuthorizationOnlyState() -> OIDAuthState { + let request = authorizationRequest() + let response = OIDAuthorizationResponse( + request: request, + parameters: [ + "code": "authorization-code" as NSString + ] + ) + return OIDAuthState(authorizationResponse: response) + } + + private func authorizationRequest() -> OIDAuthorizationRequest { + let configuration = OIDServiceConfiguration( + authorizationEndpoint: URL(string: "https://accounts.google.com/o/oauth2/v2/auth")!, + tokenEndpoint: URL(string: "https://oauth2.googleapis.com/token")! + ) + return OIDAuthorizationRequest( + configuration: configuration, + clientId: "client-id", + clientSecret: nil, + scopes: ["email"], + redirectURL: URL(string: "com.test.app:/oauthredirect")!, + responseType: OIDResponseTypeCode, + additionalParameters: nil + ) + } +}