From ab3cfc7dd6db90760103ee6a22cb5f621939f74b Mon Sep 17 00:00:00 2001 From: Worthing ~ <115107835+w-goog@users.noreply.github.com> Date: Mon, 28 Sep 2026 14:40:27 -0700 Subject: [PATCH 1/3] g-orchestrated: Clear EMM dialog state; report handling via completion only --- GoogleSignIn/Sources/GIDEMMErrorHandler.h | 14 ++++----- GoogleSignIn/Sources/GIDEMMErrorHandler.m | 19 +++++++----- GoogleSignIn/Sources/GIDEMMSupport.m | 5 ++- GoogleSignIn/Sources/GIDSignIn.m | 38 +++++++++++++---------- 4 files changed, 41 insertions(+), 35 deletions(-) diff --git a/GoogleSignIn/Sources/GIDEMMErrorHandler.h b/GoogleSignIn/Sources/GIDEMMErrorHandler.h index 4ff43ac3..930839b5 100644 --- a/GoogleSignIn/Sources/GIDEMMErrorHandler.h +++ b/GoogleSignIn/Sources/GIDEMMErrorHandler.h @@ -27,13 +27,13 @@ NS_ASSUME_NONNULL_BEGIN // Retrieve the shared instance of this class. + (instancetype)sharedInstance; -// Handles EMM specific error that is returned in server response. -// Returns whether or not an EMM-specific error is being handled by this invocation. -// If the return value is |YES|, |completion| will be called asynchronously in the main thread -// after the user interacts with the error dialog; -// if the return value is |NO|, |completion| will be called before returning. -- (BOOL)handleErrorFromResponse:(NSDictionary *)response - completion:(void (^)(void))completion; +// Handles EMM-specific errors in the server |response|. |completion| is always called +// exactly once. If |response| carries an EMM error and no EMM dialog is already pending, +// |completion| is called asynchronously on the main thread with |YES| — after the user +// dismisses the remediation dialog, or immediately if no dialog could be presented. +// Otherwise |completion| is called with |NO| before this method returns. +- (void)handleErrorFromResponse:(NSDictionary *)response + completion:(void (^)(BOOL handled))completion; @end diff --git a/GoogleSignIn/Sources/GIDEMMErrorHandler.m b/GoogleSignIn/Sources/GIDEMMErrorHandler.m index 1e00d934..ca1d8d79 100644 --- a/GoogleSignIn/Sources/GIDEMMErrorHandler.m +++ b/GoogleSignIn/Sources/GIDEMMErrorHandler.m @@ -56,8 +56,8 @@ + (instancetype)sharedInstance { return sharedInstance; } -- (BOOL)handleErrorFromResponse:(NSDictionary *)response - completion:(void (^)(void))completion { +- (void)handleErrorFromResponse:(NSDictionary *)response + completion:(void (^)(BOOL handled))completion { ErrorCode errorCode = ErrorCodeNone; NSURL *appVerificationURL; @synchronized(self) { // for accessing _pendingDialog @@ -90,15 +90,19 @@ - (BOOL)handleErrorFromResponse:(NSDictionary *)response } } if (!errorCode) { - completion(); - return NO; + completion(NO); + return; } // All UI must happen in the main thread. dispatch_async(dispatch_get_main_queue(), ^() { + void (^clearPendingDialog)(void) = ^{ + @synchronized(self) { self->_pendingDialog = NO; } + }; UIWindow *keyWindow = [self keyWindow]; if (!keyWindow) { // Shouldn't happen, just in case. - completion(); + clearPendingDialog(); + completion(YES); return; } UIWindow *alertWindow; @@ -121,8 +125,8 @@ - (BOOL)handleErrorFromResponse:(NSDictionary *)response alertWindow.hidden = YES; alertWindow.rootViewController = nil; [keyWindow makeKeyAndVisible]; - self->_pendingDialog = NO; - completion(); + clearPendingDialog(); + completion(YES); }; UIAlertController *alert; switch (errorCode) { @@ -145,7 +149,6 @@ - (BOOL)handleErrorFromResponse:(NSDictionary *)response finish(); } }); - return YES; } // This method is exposed to the unit test. diff --git a/GoogleSignIn/Sources/GIDEMMSupport.m b/GoogleSignIn/Sources/GIDEMMSupport.m index e812c0e3..1940df4f 100644 --- a/GoogleSignIn/Sources/GIDEMMSupport.m +++ b/GoogleSignIn/Sources/GIDEMMSupport.m @@ -68,9 +68,8 @@ + (void)handleTokenFetchEMMError:(nullable NSError *)error completion:(void (^)(NSError *_Nullable))completion { NSDictionary *errorJSON = error.userInfo[OIDOAuthErrorResponseErrorKey]; if (errorJSON) { - __block BOOL handled = NO; - handled = [[GIDEMMErrorHandler sharedInstance] handleErrorFromResponse:errorJSON - completion:^() { + [[GIDEMMErrorHandler sharedInstance] handleErrorFromResponse:errorJSON + completion:^(BOOL handled) { if (handled) { completion([NSError errorWithDomain:kGIDSignInErrorDomain code:kGIDSignInErrorCodeEMM diff --git a/GoogleSignIn/Sources/GIDSignIn.m b/GoogleSignIn/Sources/GIDSignIn.m index abbb803e..56c02c8c 100644 --- a/GoogleSignIn/Sources/GIDSignIn.m +++ b/GoogleSignIn/Sources/GIDSignIn.m @@ -984,29 +984,21 @@ - (void)processAuthorizationResponse:(OIDAuthorizationResponse *)authorizationRe [self maybeFetchToken:authFlow]; } else { // There was a failure, convert to appropriate error code. - NSString *errorString; - GIDSignInErrorCode errorCode = kGIDSignInErrorCodeUnknown; NSDictionary *params = authorizationResponse.additionalParameters; - #if TARGET_OS_IOS && !TARGET_OS_MACCATALYST if (authFlow.emmSupport) { [authFlow wait]; - BOOL isEMMError = [[GIDEMMErrorHandler sharedInstance] - handleErrorFromResponse:params - completion:^{ - [authFlow next]; - }]; - if (isEMMError) { - errorCode = kGIDSignInErrorCodeEMM; - } - } + [[GIDEMMErrorHandler sharedInstance] handleErrorFromResponse:params + completion:^(BOOL handled) { + // Set the error before -next, which runs the queued callbacks that read it. + authFlow.error = [self authorizationErrorWithParameters:params isEMMError:handled]; + [authFlow next]; + }]; + } else #endif // TARGET_OS_IOS && !TARGET_OS_MACCATALYST - errorString = (NSString *)params[kOAuth2ErrorKeyName]; - if ([errorString isEqualToString:kOAuth2AccessDenied]) { - errorCode = kGIDSignInErrorCodeCanceled; + { + authFlow.error = [self authorizationErrorWithParameters:params isEMMError:NO]; } - - authFlow.error = [self errorWithString:errorString code:errorCode]; } } else { NSString *errorString = [error localizedDescription]; @@ -1289,6 +1281,18 @@ - (BOOL)handleDevicePolicyAppURL:(NSURL *)url { #pragma mark - Helpers +// Returns the error for an authorization response that carried no authorization code. +- (NSError *)authorizationErrorWithParameters:(NSDictionary *)params + isEMMError:(BOOL)isEMMError { + NSString *errorString = (NSString *)params[kOAuth2ErrorKeyName]; + GIDSignInErrorCode errorCode = + isEMMError ? kGIDSignInErrorCodeEMM : kGIDSignInErrorCodeUnknown; + if ([errorString isEqualToString:kOAuth2AccessDenied]) { + errorCode = kGIDSignInErrorCodeCanceled; + } + return [self errorWithString:errorString code:errorCode]; +} + - (NSError *)errorWithString:(NSString *)errorString code:(GIDSignInErrorCode)code { if (errorString == nil) { errorString = @"Unknown error"; From 4ecf2739f40989730977dcec87bccc85a64fd7b6 Mon Sep 17 00:00:00 2001 From: Worthing ~ <115107835+w-goog@users.noreply.github.com> Date: Mon, 28 Sep 2026 14:40:27 -0700 Subject: [PATCH 2/3] g-orchestrated: Test: EMM dialog state and completion-only handling --- .../Tests/Unit/GIDEMMErrorHandlerTest.m | 244 ++++++++++++------ GoogleSignIn/Tests/Unit/GIDEMMSupportTest.m | 12 +- GoogleSignIn/Tests/Unit/GIDSignInTest.m | 6 +- 3 files changed, 170 insertions(+), 92 deletions(-) diff --git a/GoogleSignIn/Tests/Unit/GIDEMMErrorHandlerTest.m b/GoogleSignIn/Tests/Unit/GIDEMMErrorHandlerTest.m index e73da669..9286366a 100644 --- a/GoogleSignIn/Tests/Unit/GIDEMMErrorHandlerTest.m +++ b/GoogleSignIn/Tests/Unit/GIDEMMErrorHandlerTest.m @@ -35,6 +35,25 @@ NS_ASSUME_NONNULL_BEGIN +// Records how, and whether, a GIDEMMErrorHandler completion was called. +@interface GIDEMMCompletionSpy : NSObject +@property(nonatomic, readonly) NSInteger callCount; +@property(nonatomic, readonly) BOOL handled; +// A completion to pass to -handleErrorFromResponse:completion:. +- (void (^)(BOOL handled))completion; +@end + +@implementation GIDEMMCompletionSpy + +- (void (^)(BOOL handled))completion { + return ^(BOOL handled) { + self->_callCount++; + self->_handled = handled; + }; +} + +@end + // Unit test for GIDEMMErrorHandler. @interface GIDEMMErrorHandlerTest : XCTestCase @end @@ -143,13 +162,11 @@ - (void)expectOpenURLString:(NSString *)urlString inAction:(void (^)(void))actio // Verifies that the handler doesn't handle non-exist error. - (void)testNoError { - __block BOOL completionCalled = NO; - BOOL result = [[GIDEMMErrorHandler sharedInstance] handleErrorFromResponse:@{ @"abc" : @123 } - completion:^() { - completionCalled = YES; - }]; - XCTAssertFalse(result); - XCTAssertTrue(completionCalled); + GIDEMMCompletionSpy *spy = [[GIDEMMCompletionSpy alloc] init]; + [[GIDEMMErrorHandler sharedInstance] handleErrorFromResponse:@{ @"abc" : @123 } + completion:spy.completion]; + XCTAssertEqual(spy.callCount, 1); + XCTAssertFalse(spy.handled); XCTAssertFalse(_keyWindowSet); XCTAssertNil(_presentedViewController); } @@ -161,52 +178,44 @@ - (void)testNoError { - (void)testNonStringErrorValue { NSArray *nonStringValues = @[ @123, @[ @"emm_passcode_required" ], @{ @"a" : @"b" } ]; for (id nonStringValue in nonStringValues) { - __block BOOL completionCalled = NO; + GIDEMMCompletionSpy *spy = [[GIDEMMCompletionSpy alloc] init]; NSDictionary *response = @{ @"error" : nonStringValue }; - BOOL result = [[GIDEMMErrorHandler sharedInstance] handleErrorFromResponse:response - completion:^() { - completionCalled = YES; - }]; - XCTAssertFalse(result); - XCTAssertTrue(completionCalled); + [[GIDEMMErrorHandler sharedInstance] handleErrorFromResponse:response + completion:spy.completion]; + XCTAssertEqual(spy.callCount, 1); + XCTAssertFalse(spy.handled); } } // Verifies that the handler doesn't handle non-EMM error. - (void)testNoEMMError { - __block BOOL completionCalled = NO; + GIDEMMCompletionSpy *spy = [[GIDEMMCompletionSpy alloc] init]; NSDictionary *response = @{ @"error" : @"invalid_token" }; - BOOL result = [[GIDEMMErrorHandler sharedInstance] handleErrorFromResponse:response - completion:^() { - completionCalled = YES; - }]; - XCTAssertFalse(result); - XCTAssertTrue(completionCalled); + [[GIDEMMErrorHandler sharedInstance] handleErrorFromResponse:response + completion:spy.completion]; + XCTAssertEqual(spy.callCount, 1); + XCTAssertFalse(spy.handled); XCTAssertFalse(_keyWindowSet); XCTAssertNil(_presentedViewController); } // Verifies that the handler handles general EMM error with user tapping 'OK'. - (void)testGeneralEMMErrorOK { - __block BOOL completionCalled = NO; + GIDEMMCompletionSpy *spy = [[GIDEMMCompletionSpy alloc] init]; NSDictionary *response = @{ @"error" : @"emm_something_wrong" }; - BOOL result = [[GIDEMMErrorHandler sharedInstance] handleErrorFromResponse:response - completion:^() { - completionCalled = YES; - }]; - XCTAssertTrue(result); - XCTAssertFalse(completionCalled); + [[GIDEMMErrorHandler sharedInstance] handleErrorFromResponse:response + completion:spy.completion]; + // The dialog is presented asynchronously on the main queue, so nothing has happened yet. + XCTAssertEqual(spy.callCount, 0); XCTAssertFalse(_keyWindowSet); XCTAssertNil(_presentedViewController); // Should handle no more error while the previous one is being handled. - __block BOOL secondCompletionCalled = NO; - BOOL secondResult = [[GIDEMMErrorHandler sharedInstance] handleErrorFromResponse:response - completion:^() { - secondCompletionCalled = YES; - }]; - XCTAssertFalse(secondResult); - XCTAssertTrue(secondCompletionCalled); + GIDEMMCompletionSpy *secondSpy = [[GIDEMMCompletionSpy alloc] init]; + [[GIDEMMErrorHandler sharedInstance] handleErrorFromResponse:response + completion:secondSpy.completion]; + XCTAssertEqual(secondSpy.callCount, 1); + XCTAssertFalse(secondSpy.handled); XCTAssertFalse(_keyWindowSet); XCTAssertNil(_presentedViewController); @@ -215,22 +224,95 @@ - (void)testGeneralEMMErrorOK { UIAlertController *alert = [self presentedAlert]; if (!alert) return; [self assertAlert:alert hasActionTitles:@[ @"OK" ]]; - XCTAssertFalse(completionCalled); + XCTAssertEqual(spy.callCount, 0); [self tapActionTitled:@"OK" inAlert:alert]; - XCTAssertTrue(completionCalled); + XCTAssertEqual(spy.callCount, 1); + XCTAssertTrue(spy.handled); +} + +// Verifies that the pending-dialog flag is cleared when there is no key window, so a later +// EMM error can still present its dialog. `GIDEMMErrorHandler` is a process-wide singleton, +// so before this fix a single windowless error suppressed every dialog that followed. +- (void)testNoKeyWindow_ClearsPendingDialogForNextError { + [GULSwizzler unswizzleClass:[GIDEMMErrorHandler class] + selector:@selector(keyWindow) + isClassSelector:NO]; + [GULSwizzler swizzleClass:[GIDEMMErrorHandler class] + selector:@selector(keyWindow) + isClassSelector:NO + withBlock:^() { return nil; }]; + + GIDEMMCompletionSpy *firstSpy = [[GIDEMMCompletionSpy alloc] init]; + NSDictionary *response = @{ @"error" : @"emm_something_wrong" }; + [[GIDEMMErrorHandler sharedInstance] handleErrorFromResponse:response + completion:firstSpy.completion]; + + [self waitForMainQueue]; + XCTAssertEqual(firstSpy.callCount, 1); + XCTAssertTrue(firstSpy.handled); + XCTAssertNil(_presentedViewController); + + // Restore a working key window. + [GULSwizzler unswizzleClass:[GIDEMMErrorHandler class] + selector:@selector(keyWindow) + isClassSelector:NO]; + UIWindow *fakeKeyWindow = [[UIWindow alloc] initWithFrame:[UIScreen mainScreen].bounds]; + [GULSwizzler swizzleClass:[GIDEMMErrorHandler class] + selector:@selector(keyWindow) + isClassSelector:NO + withBlock:^() { return fakeKeyWindow; }]; + + GIDEMMCompletionSpy *secondSpy = [[GIDEMMCompletionSpy alloc] init]; + [[GIDEMMErrorHandler sharedInstance] handleErrorFromResponse:response + completion:secondSpy.completion]; + // Before the fix, this completed immediately with NO: the pending-dialog flag was still YES. + XCTAssertEqual(secondSpy.callCount, 0); + + [self waitForMainQueue]; + UIAlertController *alert = [self presentedAlert]; + if (!alert) return; + [self assertAlert:alert hasActionTitles:@[ @"OK" ]]; + [self tapActionTitled:@"OK" inAlert:alert]; + XCTAssertEqual(secondSpy.callCount, 1); + XCTAssertTrue(secondSpy.handled); +} + +// Verifies the flag handed to the completion on both the non-EMM path (synchronous, NO) and +// the EMM path (after dialog dismissal, YES). +- (void)testCompletionReceivesHandledFlag { + // First half — non-EMM. + GIDEMMCompletionSpy *nonEMMSpy = [[GIDEMMCompletionSpy alloc] init]; + [[GIDEMMErrorHandler sharedInstance] handleErrorFromResponse:@{ @"error" : @"invalid_token" } + completion:nonEMMSpy.completion]; + XCTAssertEqual(nonEMMSpy.callCount, 1); + XCTAssertFalse(nonEMMSpy.handled); + + // Second half — EMM. + GIDEMMCompletionSpy *emmSpy = [[GIDEMMCompletionSpy alloc] init]; + [[GIDEMMErrorHandler sharedInstance] handleErrorFromResponse:@{ @"error" : @"emm_something_wrong" } + completion:emmSpy.completion]; + XCTAssertEqual(emmSpy.callCount, 0); + + [self waitForMainQueue]; + UIAlertController *alert = [self presentedAlert]; + if (!alert) return; + [self assertAlert:alert hasActionTitles:@[ @"OK" ]]; + XCTAssertEqual(emmSpy.callCount, 0); + + [self tapActionTitled:@"OK" inAlert:alert]; + XCTAssertEqual(emmSpy.callCount, 1); + XCTAssertTrue(emmSpy.handled); } // Verifies that the handler handles EMM screenlock required error with user tapping 'Cancel'. - (void)testScreenlockRequiredCancel { - __block BOOL completionCalled = NO; + GIDEMMCompletionSpy *spy = [[GIDEMMCompletionSpy alloc] init]; NSDictionary *response = @{ @"error" : @"emm_passcode_required" }; - BOOL result = [[GIDEMMErrorHandler sharedInstance] handleErrorFromResponse:response - completion:^() { - completionCalled = YES; - }]; - XCTAssertTrue(result); - XCTAssertFalse(completionCalled); + [[GIDEMMErrorHandler sharedInstance] handleErrorFromResponse:response + completion:spy.completion]; + // The dialog is presented asynchronously on the main queue, so nothing has happened yet. + XCTAssertEqual(spy.callCount, 0); XCTAssertFalse(_keyWindowSet); XCTAssertNil(_presentedViewController); @@ -239,22 +321,21 @@ - (void)testScreenlockRequiredCancel { UIAlertController *alert = [self presentedAlert]; if (!alert) return; [self assertAlert:alert hasActionTitles:@[ @"Cancel", @"Settings" ]]; - XCTAssertFalse(completionCalled); + XCTAssertEqual(spy.callCount, 0); [self tapActionTitled:@"Cancel" inAlert:alert]; - XCTAssertTrue(completionCalled); + XCTAssertEqual(spy.callCount, 1); + XCTAssertTrue(spy.handled); } // Verifies that the handler handles EMM screenlock required error with user tapping 'Settings'. - (void)testScreenlockRequiredSettings { - __block BOOL completionCalled = NO; + GIDEMMCompletionSpy *spy = [[GIDEMMCompletionSpy alloc] init]; NSDictionary *response = @{ @"error" : @"emm_passcode_required" }; - BOOL result = [[GIDEMMErrorHandler sharedInstance] handleErrorFromResponse:response - completion:^() { - completionCalled = YES; - }]; - XCTAssertTrue(result); - XCTAssertFalse(completionCalled); + [[GIDEMMErrorHandler sharedInstance] handleErrorFromResponse:response + completion:spy.completion]; + // The dialog is presented asynchronously on the main queue, so nothing has happened yet. + XCTAssertEqual(spy.callCount, 0); XCTAssertFalse(_keyWindowSet); XCTAssertNil(_presentedViewController); @@ -263,24 +344,23 @@ - (void)testScreenlockRequiredSettings { UIAlertController *alert = [self presentedAlert]; if (!alert) return; [self assertAlert:alert hasActionTitles:@[ @"Cancel", @"Settings" ]]; - XCTAssertFalse(completionCalled); + XCTAssertEqual(spy.callCount, 0); [self expectOpenURLString:UIApplicationOpenSettingsURLString inAction:^() { [self tapActionTitled:@"Settings" inAlert:alert]; }]; - XCTAssertTrue(completionCalled); + XCTAssertEqual(spy.callCount, 1); + XCTAssertTrue(spy.handled); } // Verifies that the handler handles EMM app verification required error without a URL. - (void)testAppVerificationNoURL { - __block BOOL completionCalled = NO; + GIDEMMCompletionSpy *spy = [[GIDEMMCompletionSpy alloc] init]; NSDictionary *response = @{ @"error" : @"emm_app_verification_required" }; - BOOL result = [[GIDEMMErrorHandler sharedInstance] handleErrorFromResponse:response - completion:^() { - completionCalled = YES; - }]; - XCTAssertTrue(result); - XCTAssertFalse(completionCalled); + [[GIDEMMErrorHandler sharedInstance] handleErrorFromResponse:response + completion:spy.completion]; + // The dialog is presented asynchronously on the main queue, so nothing has happened yet. + XCTAssertEqual(spy.callCount, 0); XCTAssertFalse(_keyWindowSet); XCTAssertNil(_presentedViewController); @@ -289,24 +369,22 @@ - (void)testAppVerificationNoURL { UIAlertController *alert = [self presentedAlert]; if (!alert) return; [self assertAlert:alert hasActionTitles:@[ @"OK" ]]; - XCTAssertFalse(completionCalled); + XCTAssertEqual(spy.callCount, 0); [self tapActionTitled:@"OK" inAlert:alert]; - XCTAssertTrue(completionCalled); + XCTAssertEqual(spy.callCount, 1); + XCTAssertTrue(spy.handled); } - // Verifies that the handler handles EMM app verification required error user tapping 'Cancel'. - (void)testAppVerificationCancel { - __block BOOL completionCalled = NO; + GIDEMMCompletionSpy *spy = [[GIDEMMCompletionSpy alloc] init]; NSDictionary *response = @{ @"error" : @"emm_app_verification_required: https://host.domain/path" }; - BOOL result = [[GIDEMMErrorHandler sharedInstance] handleErrorFromResponse:response - completion:^() { - completionCalled = YES; - }]; - XCTAssertTrue(result); - XCTAssertFalse(completionCalled); + [[GIDEMMErrorHandler sharedInstance] handleErrorFromResponse:response + completion:spy.completion]; + // The dialog is presented asynchronously on the main queue, so nothing has happened yet. + XCTAssertEqual(spy.callCount, 0); XCTAssertFalse(_keyWindowSet); XCTAssertNil(_presentedViewController); @@ -315,23 +393,22 @@ - (void)testAppVerificationCancel { UIAlertController *alert = [self presentedAlert]; if (!alert) return; [self assertAlert:alert hasActionTitles:@[ @"Cancel", @"Connect" ]]; - XCTAssertFalse(completionCalled); + XCTAssertEqual(spy.callCount, 0); [self tapActionTitled:@"Cancel" inAlert:alert]; - XCTAssertTrue(completionCalled); + XCTAssertEqual(spy.callCount, 1); + XCTAssertTrue(spy.handled); } // Verifies that the handler handles EMM app verification required error user tapping 'Connect'. - (void)testAppVerificationConnect { - __block BOOL completionCalled = NO; + GIDEMMCompletionSpy *spy = [[GIDEMMCompletionSpy alloc] init]; NSDictionary *response = @{ @"error" : @"emm_app_verification_required: https://host.domain/path" }; - BOOL result = [[GIDEMMErrorHandler sharedInstance] handleErrorFromResponse:response - completion:^() { - completionCalled = YES; - }]; - XCTAssertTrue(result); - XCTAssertFalse(completionCalled); + [[GIDEMMErrorHandler sharedInstance] handleErrorFromResponse:response + completion:spy.completion]; + // The dialog is presented asynchronously on the main queue, so nothing has happened yet. + XCTAssertEqual(spy.callCount, 0); XCTAssertFalse(_keyWindowSet); XCTAssertNil(_presentedViewController); @@ -340,12 +417,13 @@ - (void)testAppVerificationConnect { UIAlertController *alert = [self presentedAlert]; if (!alert) return; [self assertAlert:alert hasActionTitles:@[ @"Cancel", @"Connect" ]]; - XCTAssertFalse(completionCalled); + XCTAssertEqual(spy.callCount, 0); [self expectOpenURLString:@"https://host.domain/path" inAction:^() { [self tapActionTitled:@"Connect" inAlert:alert]; }]; - XCTAssertTrue(completionCalled); + XCTAssertEqual(spy.callCount, 1); + XCTAssertTrue(spy.handled); } // Verifies that the handler can handle sequential errors independently. diff --git a/GoogleSignIn/Tests/Unit/GIDEMMSupportTest.m b/GoogleSignIn/Tests/Unit/GIDEMMSupportTest.m index c780c11f..7f0e44dc 100644 --- a/GoogleSignIn/Tests/Unit/GIDEMMSupportTest.m +++ b/GoogleSignIn/Tests/Unit/GIDEMMSupportTest.m @@ -220,8 +220,8 @@ - (void)testHandleTokenFetchEMMError_errorIsEMM { userInfo:@{ OIDOAuthErrorResponseErrorKey : errorJSON }]; id mockEMMErrorHandler = OCMStrictClassMock([GIDEMMErrorHandler class]); [[[mockEMMErrorHandler stub] andReturn:mockEMMErrorHandler] sharedInstance]; - __block void (^savedCompletion)(void); - [[[mockEMMErrorHandler stub] andReturnValue:@YES] + __block void (^savedCompletion)(BOOL); + [[mockEMMErrorHandler stub] handleErrorFromResponse:errorJSON completion:[OCMArg checkWithBlock:^(id arg) { savedCompletion = arg; return YES; @@ -239,7 +239,7 @@ - (void)testHandleTokenFetchEMMError_errorIsEMM { }]; [self waitForExpectations:@[ notCalled ] timeout:1]; - savedCompletion(); + savedCompletion(YES); [self waitForExpectations:@[ called ] timeout:1]; } @@ -251,8 +251,8 @@ - (void)testHandleTokenFetchEMMError_errorIsNotEMM { userInfo:@{ OIDOAuthErrorResponseErrorKey : errorJSON }]; id mockEMMErrorHandler = OCMStrictClassMock([GIDEMMErrorHandler class]); [[[mockEMMErrorHandler stub] andReturn:mockEMMErrorHandler] sharedInstance]; - __block void (^savedCompletion)(void); - [[[mockEMMErrorHandler stub] andReturnValue:@NO] + __block void (^savedCompletion)(BOOL); + [[mockEMMErrorHandler stub] handleErrorFromResponse:errorJSON completion:[OCMArg checkWithBlock:^(id arg) { savedCompletion = arg; return YES; @@ -270,7 +270,7 @@ - (void)testHandleTokenFetchEMMError_errorIsNotEMM { }]; [self waitForExpectations:@[ notCalled ] timeout:1]; - savedCompletion(); + savedCompletion(NO); [self waitForExpectations:@[ called ] timeout:1]; } diff --git a/GoogleSignIn/Tests/Unit/GIDSignInTest.m b/GoogleSignIn/Tests/Unit/GIDSignInTest.m index ed959f12..5014d78b 100644 --- a/GoogleSignIn/Tests/Unit/GIDSignInTest.m +++ b/GoogleSignIn/Tests/Unit/GIDSignInTest.m @@ -1819,9 +1819,9 @@ - (void)testAuthEndpointEMMError { id mockEMMErrorHandler = OCMStrictClassMock([GIDEMMErrorHandler class]); [[[mockEMMErrorHandler stub] andReturn:mockEMMErrorHandler] sharedInstance]; - __block void (^completion)(void); + __block void (^completion)(BOOL handled); NSDictionary *callbackParams = @{ @"error" : @"EMM Specific Error" }; - [[[mockEMMErrorHandler expect] andReturnValue:@YES] + [[mockEMMErrorHandler expect] handleErrorFromResponse:callbackParams completion:SAVE_TO_ARG_BLOCK(completion)]; @@ -1837,7 +1837,7 @@ - (void)testAuthEndpointEMMError { [mockEMMErrorHandler verify]; [mockEMMErrorHandler stopMocking]; - completion(); + completion(YES); [self waitForExpectationsWithTimeout:1 handler:nil]; From 1635d5d35087703aeb336e3c359d6065524db5ce Mon Sep 17 00:00:00 2001 From: Worthing ~ <115107835+w-goog@users.noreply.github.com> Date: Mon, 28 Sep 2026 14:40:27 -0700 Subject: [PATCH 3/3] g-orchestrated: Changelog: EMM dialog state and error reporting fixes --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7703fa75..d8a7ad2a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,8 @@ # Unreleased - Fix a crash when a server error response carries a non-string value under its `error` key. The EMM error handler sent `-hasPrefix:` to whatever value was present, raising an unrecognized selector exception on a number, array or object. - Fix a custom `nonce` and requested token `claims` being dropped when a sign-in is continued after a Device Policy app restart. +- Fix EMM remediation dialogs being suppressed for the rest of the app's lifetime after a single error arrived while no key window was available. The pending-dialog flag is now cleared on every exit path, and its clearing write is synchronized to match the read. +- Fix a token request that failed with an EMM error sometimes being reported with the underlying OAuth error instead of `kGIDSignInErrorCodeEMM` when it completed off the main thread. # 10.0.0 - **BREAKING**: Update to AppAuth 3.0.0 and GTMAppAuth 6.0.0, which raises the minimum deployment targets to iOS 15.0 and macOS 12.0, widens the `GTMSessionFetcher` dependency to allow 4.x and 5.x, and renames the version-specific Swift Package Manager manifest to `Package@swift-5.7.swift`. Projects that must keep supporting earlier OS versions should stay on GoogleSignIn 9.2.0. ([#628](https://github.com/google/GoogleSignIn-iOS/pull/628))