mirror of
https://github.com/firebase/firebase-ios-sdk
synced 2026-11-11 22:55:59 +05:45
655 lines
33 KiB
Objective-C
655 lines
33 KiB
Objective-C
/*
|
|
* Copyright 2021 Google LLC
|
|
*
|
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
* you may not use this file except in compliance with the License.
|
|
* You may obtain a copy of the License at
|
|
*
|
|
* http://www.apache.org/licenses/LICENSE-2.0
|
|
*
|
|
* Unless required by applicable law or agreed to in writing, software
|
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
* See the License for the specific language governing permissions and
|
|
* limitations under the License.
|
|
*/
|
|
|
|
#import <OCMock/OCMock.h>
|
|
#import <XCTest/XCTest.h>
|
|
|
|
#import "FirebaseMessaging/Sources/FIRMessagingUtilities.h"
|
|
#import "FirebaseMessaging/Sources/Public/FirebaseMessaging/FIRMessaging.h"
|
|
#import "FirebaseMessaging/Sources/Token/FIRMessagingAPNSInfo.h"
|
|
#import "FirebaseMessaging/Sources/Token/FIRMessagingBackupExcludedPlist.h"
|
|
#import "FirebaseMessaging/Sources/Token/FIRMessagingCheckinPreferences.h"
|
|
#import "FirebaseMessaging/Sources/Token/FIRMessagingCheckinService.h"
|
|
#import "FirebaseMessaging/Sources/Token/FIRMessagingCheckinStore.h"
|
|
#import "FirebaseMessaging/Sources/Token/FIRMessagingTokenInfo.h"
|
|
#import "FirebaseMessaging/Sources/Token/FIRMessagingTokenStore.h"
|
|
#import "FirebaseMessaging/Tests/UnitTests/FIRMessagingFakeKeychain.h"
|
|
#import "FirebaseMessaging/Tests/UnitTests/FIRMessagingLegacyArchiveFixtures.h"
|
|
|
|
static NSString *const kSubDirectoryName = @"FirebaseMessagingStoreTest";
|
|
|
|
static NSString *const kAuthorizedEntity = @"test-audience";
|
|
static NSString *const kScope = @"test-scope";
|
|
static NSString *const kToken = @"test-token";
|
|
static NSString *const kAuthID = @"test-auth-id";
|
|
static NSString *const kSecret = @"test-secret";
|
|
static NSString *const kFakeCheckinPlistName = @"com.google.test.TestTokenStore";
|
|
|
|
@interface FIRMessaging (ExposedForTest)
|
|
+ (BOOL)createSubDirectory:(NSString *)subDirectoryName;
|
|
@end
|
|
|
|
@interface FIRMessagingCheckinStore ()
|
|
|
|
@property(nonatomic, readwrite, strong) FIRMessagingAuthKeychain *keychain;
|
|
|
|
@end
|
|
|
|
@interface FIRMessagingTokenStore ()
|
|
|
|
+ (NSString *)serviceKeyForAuthorizedEntity:(NSString *)authorizedEntity scope:(NSString *)scope;
|
|
+ (nullable FIRMessagingTokenInfo *)tokenInfoFromKeychainItem:(NSData *)item;
|
|
|
|
@property(nonatomic, readwrite, strong) FIRMessagingAuthKeychain *keychain;
|
|
|
|
@end
|
|
|
|
@interface FIRMessagingBackupExcludedPlist (ExposedForTest)
|
|
|
|
- (BOOL)deleteFile:(NSError **)error;
|
|
|
|
@end
|
|
|
|
@interface FIRMessagingTokenStoreTest : XCTestCase
|
|
|
|
@property(strong, nonatomic) FIRMessagingBackupExcludedPlist *checkinPlist;
|
|
@property(strong, nonatomic) FIRMessagingCheckinStore *checkinStore;
|
|
@property(strong, nonatomic) FIRMessagingTokenStore *tokenStore;
|
|
@property(strong, nonatomic) id mockCheckinStore;
|
|
@property(strong, nonatomic) id mockTokenStore;
|
|
@property(strong, nonatomic) id mockMessagingStore;
|
|
|
|
@end
|
|
|
|
@implementation FIRMessagingTokenStoreTest
|
|
|
|
- (void)setUp {
|
|
[super setUp];
|
|
[FIRMessaging createSubDirectory:kSubDirectoryName];
|
|
|
|
self.checkinPlist =
|
|
[[FIRMessagingBackupExcludedPlist alloc] initWithPlistFile:kFakeCheckinPlistName
|
|
subDirectory:kSubDirectoryName];
|
|
|
|
// checkin store
|
|
FIRMessagingFakeKeychain *fakeKeychain = [[FIRMessagingFakeKeychain alloc] init];
|
|
_checkinStore = [[FIRMessagingCheckinStore alloc] init];
|
|
_checkinStore.keychain = fakeKeychain;
|
|
|
|
_mockCheckinStore = OCMPartialMock(_checkinStore);
|
|
// token store
|
|
_tokenStore = [[FIRMessagingTokenStore alloc] init];
|
|
_tokenStore.keychain = fakeKeychain;
|
|
_mockTokenStore = OCMPartialMock(_tokenStore);
|
|
|
|
// Global, so clear it per test rather than trusting run order.
|
|
FIRMessagingArchiveGadget.wasDecoded = NO;
|
|
}
|
|
|
|
- (void)tearDown {
|
|
[self.checkinPlist deleteFile:nil];
|
|
[_tokenStore removeAllTokensWithHandler:nil];
|
|
[_mockCheckinStore stopMocking];
|
|
[_mockTokenStore stopMocking];
|
|
[_mockMessagingStore stopMocking];
|
|
[super tearDown];
|
|
}
|
|
|
|
/**
|
|
* Tests that an Messaging token can be stored in the FIRMessagingStore for
|
|
* an authorizedEntity and scope.
|
|
*/
|
|
- (void)testSaveToken {
|
|
XCTestExpectation *tokenExpectation = [self expectationWithDescription:@"token is saved"];
|
|
FIRMessagingTokenInfo *tokenInfo =
|
|
[[FIRMessagingTokenInfo alloc] initWithAuthorizedEntity:kAuthorizedEntity
|
|
scope:kScope
|
|
token:kToken
|
|
appVersion:@"1.0"
|
|
firebaseAppID:@"firebaseAppID"
|
|
tokenType:@"V4"];
|
|
[self.tokenStore saveTokenInfo:tokenInfo
|
|
handler:^(NSError *error) {
|
|
XCTAssertNil(error);
|
|
FIRMessagingTokenInfo *retrievedTokenInfo =
|
|
[self.tokenStore tokenInfoWithAuthorizedEntity:kAuthorizedEntity
|
|
scope:kScope];
|
|
XCTAssertEqualObjects(retrievedTokenInfo.token, kToken);
|
|
[tokenExpectation fulfill];
|
|
}];
|
|
|
|
[self waitForExpectationsWithTimeout:1 handler:nil];
|
|
}
|
|
|
|
/**
|
|
* Tests that a token can be removed from from FIRMessagingStore's cache when specifying
|
|
* its authorizedEntity and scope.
|
|
*/
|
|
- (void)testRemoveCachedToken {
|
|
XCTestExpectation *tokenExpectation = [self expectationWithDescription:@"token is removed"];
|
|
FIRMessagingTokenInfo *tokenInfo =
|
|
[[FIRMessagingTokenInfo alloc] initWithAuthorizedEntity:kAuthorizedEntity
|
|
scope:kScope
|
|
token:kToken
|
|
appVersion:@"1.0"
|
|
firebaseAppID:@"firebaseAppID"
|
|
tokenType:@"V4"];
|
|
[self.tokenStore
|
|
saveTokenInfo:tokenInfo
|
|
handler:^(NSError *error) {
|
|
XCTAssertNotNil([self.tokenStore tokenInfoWithAuthorizedEntity:kAuthorizedEntity
|
|
scope:kScope]);
|
|
|
|
[self.tokenStore removeTokenWithAuthorizedEntity:kAuthorizedEntity scope:kScope];
|
|
XCTAssertNil([self.tokenStore tokenInfoWithAuthorizedEntity:kAuthorizedEntity
|
|
scope:kScope]);
|
|
[tokenExpectation fulfill];
|
|
}];
|
|
[self waitForExpectationsWithTimeout:1 handler:nil];
|
|
}
|
|
|
|
#pragma mark - Archive Compatibility
|
|
|
|
/// The keychain service the compatibility tests below read and write.
|
|
- (NSString *)tokenServiceKey {
|
|
return [FIRMessagingTokenStore serviceKeyForAuthorizedEntity:kAuthorizedEntity scope:kScope];
|
|
}
|
|
|
|
/// Writes `item` into the fake keychain as though a previous SDK version had left it there.
|
|
- (void)injectKeychainItem:(NSData *)item {
|
|
XCTestExpectation *injected = [self expectationWithDescription:@"Inject keychain item"];
|
|
[self.tokenStore.keychain setData:item
|
|
forService:[self tokenServiceKey]
|
|
account:FIRMessagingAppIdentifier()
|
|
handler:^(NSError *error) {
|
|
XCTAssertNil(error);
|
|
[injected fulfill];
|
|
}];
|
|
[self waitForExpectationsWithTimeout:1 handler:nil];
|
|
}
|
|
|
|
/// Reads the raw bytes currently stored for this test's token, bypassing any decoding.
|
|
- (NSData *)keychainItem {
|
|
return [self.tokenStore.keychain dataForService:[self tokenServiceKey]
|
|
account:FIRMessagingAppIdentifier()];
|
|
}
|
|
|
|
/// **Scenario:** Tests that the actual Store class connects to the keychain and passes the right
|
|
/// SecureCoding flags to read old data. **What it does:** Generates an insecure 10.19-era binary
|
|
/// blob and injects it into the mock keychain. We then call the public
|
|
/// `tokenInfoWithAuthorizedEntity:` API on the Token Store to prove the store itself extracts and
|
|
/// parses the legacy keychain item.
|
|
- (void)testTokenStoreReadsLegacyInsecureToken {
|
|
FIRMessagingTokenInfo *tokenInfo =
|
|
[[FIRMessagingTokenInfo alloc] initWithAuthorizedEntity:kAuthorizedEntity
|
|
scope:kScope
|
|
token:kToken
|
|
appVersion:@"1.0"
|
|
firebaseAppID:@"firebaseAppID"
|
|
tokenType:@"V4"];
|
|
|
|
// Archive the way 12.19.0's `saveTokenInfo:` did -- insecure, rooted at
|
|
// `FIRInstanceIDTokenInfo` -- and plant it in the keychain.
|
|
[self injectKeychainItem:[FIRMessagingLegacyArchiveFixtures archiveWrittenBy12:tokenInfo]];
|
|
|
|
// Verify that the TokenStore's public API can read and decode it.
|
|
FIRMessagingTokenInfo *retrievedTokenInfo =
|
|
[self.tokenStore tokenInfoWithAuthorizedEntity:kAuthorizedEntity scope:kScope];
|
|
|
|
XCTAssertNotNil(retrievedTokenInfo);
|
|
XCTAssertEqualObjects(retrievedTokenInfo.token, kToken);
|
|
}
|
|
|
|
/// **Scenario:** The reverse downgrade integration test.
|
|
/// **What it does:** Uses the new secure `saveTokenInfo:` API to write data to the mock keychain,
|
|
/// pulls the binary blob back out, and parses it with 12.19.0's `tokenInfoFromKeychainItem:` and
|
|
/// `initWithCoder:` to prove an older app version can read the bytes the new SDK produced.
|
|
///
|
|
/// Routing the root object to `FIRMessagingTokenInfo_Legacy12` is what makes this a real
|
|
/// downgrade test: mapping it onto the current `FIRMessagingTokenInfo` would run Firebase 13's
|
|
/// decoder and prove only that the container is parseable.
|
|
- (void)testLegacyTokenStoreReadsSecureToken {
|
|
FIRMessagingTokenInfo *tokenInfo =
|
|
[[FIRMessagingTokenInfo alloc] initWithAuthorizedEntity:kAuthorizedEntity
|
|
scope:kScope
|
|
token:kToken
|
|
appVersion:@"1.0"
|
|
firebaseAppID:@"firebaseAppID"
|
|
tokenType:@"V4"];
|
|
|
|
// 1. Use the NEW secure coding write path
|
|
XCTestExpectation *tokenExpectation = [self expectationWithDescription:@"token is saved"];
|
|
[self.tokenStore saveTokenInfo:tokenInfo
|
|
handler:^(NSError *error) {
|
|
XCTAssertNil(error);
|
|
[tokenExpectation fulfill];
|
|
}];
|
|
[self waitForExpectationsWithTimeout:1 handler:nil];
|
|
|
|
// 2. Manually read it from the keychain
|
|
NSData *secureArchive = [self keychainItem];
|
|
XCTAssertNotNil(secureArchive);
|
|
|
|
// 3. Decode it with the genuine 12.19.0 read path.
|
|
FIRMessagingTokenInfo *downgradedInfo =
|
|
[FIRMessagingLegacyArchiveFixtures tokenInfoReadBy12:secureArchive];
|
|
|
|
XCTAssertNotNil(downgradedInfo);
|
|
XCTAssertEqualObjects(downgradedInfo.token, kToken);
|
|
XCTAssertEqualObjects(downgradedInfo.authorizedEntity, kAuthorizedEntity);
|
|
XCTAssertEqualObjects(downgradedInfo.scope, kScope);
|
|
XCTAssertEqualObjects(downgradedInfo.appVersion, @"1.0");
|
|
XCTAssertEqualObjects(downgradedInfo.firebaseAppID, @"firebaseAppID");
|
|
XCTAssertEqualObjects(downgradedInfo.tokenType, @"V4");
|
|
XCTAssertNotNil(downgradedInfo.cacheTime, @"`saveTokenInfo:` stamps the cache time on write.");
|
|
}
|
|
|
|
/// The device token embedded in every `<= 10.18.0` fixture below.
|
|
- (NSData *)legacyDeviceToken {
|
|
return [@"deviceToken" dataUsingEncoding:NSUTF8StringEncoding];
|
|
}
|
|
|
|
/// Builds a keychain item in the genuine `<= 10.18.0` on-disk format, where `apns_info` is a
|
|
/// nested `NSKeyedArchiver` blob naming `FIRInstanceIDAPNSInfo`.
|
|
///
|
|
/// `tokenType:` is supplied but never lands in the archive: the 10.18.0 encoder had no
|
|
/// `token_type` key. That absence is the point -- see the `V4` assertions below.
|
|
- (NSData *)archiveWrittenBy10_18 {
|
|
FIRMessagingTokenInfo_Legacy10_18 *legacyTokenInfo =
|
|
[[FIRMessagingTokenInfo_Legacy10_18 alloc] initWithAuthorizedEntity:kAuthorizedEntity
|
|
scope:kScope
|
|
token:kToken
|
|
appVersion:@"1.0"
|
|
firebaseAppID:@"firebaseAppID"
|
|
tokenType:@"V4"];
|
|
legacyTokenInfo.APNSInfo =
|
|
[[FIRMessagingAPNSInfo alloc] initWithDeviceToken:[self legacyDeviceToken] isSandbox:NO];
|
|
legacyTokenInfo.cacheTime = [NSDate date];
|
|
NSData *archive = [FIRMessagingLegacyArchiveFixtures archiveWrittenBy10_18:legacyTokenInfo];
|
|
XCTAssertNotNil(archive);
|
|
return archive;
|
|
}
|
|
|
|
/// Asserts that `archive` really is in the pre-10.19 shape: `apns_info` must be a *nested*
|
|
/// archive blob, not a directly encoded object. Without this the tests below could pass against
|
|
/// a fixture that had silently drifted into the modern format, proving nothing. The legacy class
|
|
/// name lives inside the nested archive's bytes, so the outer plist has to be walked one level
|
|
/// down to find it.
|
|
- (void)assertArchiveEmbedsLegacyAPNSInfoBlob:(NSData *)archive {
|
|
NSDictionary *plist = [NSPropertyListSerialization propertyListWithData:archive
|
|
options:NSPropertyListImmutable
|
|
format:NULL
|
|
error:NULL];
|
|
BOOL embedsLegacyAPNSInfoArchive = NO;
|
|
for (id object in plist[@"$objects"]) {
|
|
// A nested archive is encoded as a dictionary carrying its raw bytes under "NS.data".
|
|
id nestedData = [object isKindOfClass:[NSDictionary class]] ? object[@"NS.data"] : nil;
|
|
if (![nestedData isKindOfClass:[NSData class]]) {
|
|
continue;
|
|
}
|
|
NSDictionary *nested = [NSPropertyListSerialization propertyListWithData:nestedData
|
|
options:NSPropertyListImmutable
|
|
format:NULL
|
|
error:NULL];
|
|
if ([[nested[@"$objects"] description] containsString:@"FIRInstanceIDAPNSInfo"]) {
|
|
embedsLegacyAPNSInfoArchive = YES;
|
|
break;
|
|
}
|
|
}
|
|
XCTAssertTrue(embedsLegacyAPNSInfoArchive,
|
|
@"Fixture should embed a nested archive naming the legacy APNSInfo class.");
|
|
}
|
|
|
|
/// **Scenario:** A user upgrades directly from FirebaseMessaging 10.18.0 (or earlier), skipping
|
|
/// the 10.19.0 - 12.x window entirely.
|
|
/// **What it does:** Writes a token in the genuine `<= 10.18.0` on-disk format, where `apns_info`
|
|
/// is a nested `NSData` blob, then decodes it through the production
|
|
/// `tokenInfoFromKeychainItem:` path.
|
|
///
|
|
/// Both the token and its `APNSInfo` must survive. The nested blob is read under secure coding,
|
|
/// so nothing in the read path falls back to `requiresSecureCoding = NO`.
|
|
- (void)testTokenInfoFrom10_18ArchiveIsDecodedSecurely {
|
|
NSData *legacyArchive = [self archiveWrittenBy10_18];
|
|
[self assertArchiveEmbedsLegacyAPNSInfoBlob:legacyArchive];
|
|
|
|
// Decode through the production read path.
|
|
FIRMessagingTokenInfo *decodedTokenInfo =
|
|
[FIRMessagingTokenStore tokenInfoFromKeychainItem:legacyArchive];
|
|
|
|
// Both the token and the legacy APNSInfo must survive.
|
|
XCTAssertNotNil(decodedTokenInfo,
|
|
@"A pre-10.19 keychain record must not be discarded wholesale just because its "
|
|
@"APNSInfo is in a retired format.");
|
|
XCTAssertEqualObjects(decodedTokenInfo.token, kToken);
|
|
XCTAssertEqualObjects(decodedTokenInfo.authorizedEntity, kAuthorizedEntity);
|
|
XCTAssertEqualObjects(decodedTokenInfo.scope, kScope);
|
|
XCTAssertEqualObjects(decodedTokenInfo.APNSInfo.deviceToken, [self legacyDeviceToken],
|
|
@"The nested blob is still readable under secure coding, so upgrading "
|
|
@"must not cost the cached APNS association.");
|
|
XCTAssertFalse(decodedTokenInfo.APNSInfo.isSandbox);
|
|
XCTAssertTrue(decodedTokenInfo.needsMigration,
|
|
@"A record carrying the legacy blob is exactly what `needsMigration` describes.");
|
|
XCTAssertEqualObjects(decodedTokenInfo.tokenType, @"V4",
|
|
@"10.18.0 archives predate the `token_type` key, so the decoder's `V4` "
|
|
@"default is the only thing standing between these users and a nil token "
|
|
@"type. It is a compatibility contract, not an implementation detail.");
|
|
}
|
|
|
|
#pragma mark - Secure coding is enforced
|
|
|
|
// The two tests below are the regression tests for the vulnerability this file's compatibility
|
|
// work exists to fix. Everything else here checks that legacy data still *decodes*; these check
|
|
// that hostile data still *does not*. Threat model: a local attacker or co-process sharing the
|
|
// keychain access group that can write to `com.google.iid-tokens`.
|
|
//
|
|
// Both would pass just as happily with `requiresSecureCoding = NO`, were it not for the
|
|
// `wasDecoded` assertion. That assertion is the test.
|
|
|
|
/// **Scenario:** A hostile writer replaces the keychain item with an archive rooted at a class
|
|
/// of its choosing, attacking the *outer* unarchiver.
|
|
/// **What it does:** Plants a `FIRMessagingArchiveGadget` archive and asserts
|
|
/// `tokenInfoFromKeychainItem:` rejects it without ever running the gadget's `initWithCoder:`.
|
|
- (void)testHostileRootObjectIsRejectedWithoutBeingConstructed {
|
|
NSData *hostileArchive = [FIRMessagingLegacyArchiveFixtures archiveOfGadget];
|
|
|
|
FIRMessagingTokenInfo *decoded =
|
|
[FIRMessagingTokenStore tokenInfoFromKeychainItem:hostileArchive];
|
|
|
|
XCTAssertNil(decoded, @"An archive that is not a token info must not yield one.");
|
|
XCTAssertFalse(FIRMessagingArchiveGadget.wasDecoded,
|
|
@"Secure coding must reject the class before `initWithCoder:` runs. If this "
|
|
@"fails, the outer unarchiver is back to `requiresSecureCoding = NO` and the "
|
|
@"keychain item is once again an arbitrary-class instantiation primitive.");
|
|
}
|
|
|
|
/// **Scenario:** The same attack aimed at the *nested* unarchiver, which the `<= 10.18.0`
|
|
/// APNSInfo fallback keeps reachable. This is the subtler half: the outer record is perfectly
|
|
/// well-formed, so only the inner decoder stands between the attacker and their gadget.
|
|
/// **What it does:** Plants a legacy-shaped record whose `apns_info` blob is a gadget archive,
|
|
/// and asserts the record still decodes -- minus its APNSInfo -- with the gadget never
|
|
/// constructed.
|
|
///
|
|
/// Keeping the fallback is what makes upgrades from 10.18 lossless; this test is the price of
|
|
/// keeping it, and the reason the nested unarchiver runs with secure coding on rather than being
|
|
/// deleted outright.
|
|
- (void)testHostileNestedAPNSInfoIsRejectedWithoutBeingConstructed {
|
|
NSData *hostileArchive = [FIRMessagingLegacyArchiveFixtures
|
|
archiveWithGadgetInNestedAPNSInfoForAuthorizedEntity:kAuthorizedEntity
|
|
scope:kScope
|
|
token:kToken];
|
|
|
|
FIRMessagingTokenInfo *decoded =
|
|
[FIRMessagingTokenStore tokenInfoFromKeychainItem:hostileArchive];
|
|
|
|
XCTAssertFalse(FIRMessagingArchiveGadget.wasDecoded,
|
|
@"The nested unarchiver must reject the class before `initWithCoder:` runs. If "
|
|
@"this fails, the legacy APNSInfo fallback has become the gadget surface the "
|
|
@"outer fix was meant to close.");
|
|
XCTAssertNotNil(decoded, @"A bad `apns_info` must not cost the user their token.");
|
|
XCTAssertEqualObjects(decoded.token, kToken);
|
|
XCTAssertNil(decoded.APNSInfo, @"An APNSInfo that failed to decode must not be substituted.");
|
|
XCTAssertTrue(decoded.needsMigration,
|
|
@"The record is in the legacy shape, so the store should rewrite it and be rid "
|
|
@"of the hostile blob.");
|
|
}
|
|
|
|
/// **Scenario:** A `<= 10.18.0` record whose nested `apns_info` blob is corrupt rather than
|
|
/// hostile: bit rot, a partial keychain write, or a bug in some other writer.
|
|
/// **What it does:** Plants each flavour of corruption in turn and asserts the record still
|
|
/// decodes with only its APNSInfo missing.
|
|
///
|
|
/// The three flavours fail at different depths of `NSKeyedUnarchiver`, which matters because the
|
|
/// shallow ones are the ones that can *raise* instead of returning an error. If a raise escaped
|
|
/// the nested decode it would unwind to the `@catch` in `tokenInfoFromKeychainItem:` and discard
|
|
/// the whole record -- costing the user their token over a damaged APNS association. Losing the
|
|
/// APNSInfo is recoverable on the next APNS registration; losing the token is not.
|
|
- (void)testCorruptNestedAPNSInfoCostsOnlyTheAPNSInfo {
|
|
NSDictionary<NSString *, NSNumber *> *kinds = @{
|
|
@"not a property list" : @(FIRMessagingCorruptPayloadKindNotAPropertyList),
|
|
@"property list but not an archive" :
|
|
@(FIRMessagingCorruptPayloadKindPropertyListButNotAnArchive),
|
|
@"truncated archive" : @(FIRMessagingCorruptPayloadKindTruncatedArchive),
|
|
};
|
|
|
|
for (NSString *description in kinds) {
|
|
FIRMessagingCorruptPayloadKind kind = kinds[description].integerValue;
|
|
NSData *corruptArchive = [FIRMessagingLegacyArchiveFixtures
|
|
archiveWithCorruptNestedAPNSInfoForAuthorizedEntity:kAuthorizedEntity
|
|
scope:kScope
|
|
token:kToken
|
|
payloadKind:kind];
|
|
|
|
FIRMessagingTokenInfo *decoded =
|
|
[FIRMessagingTokenStore tokenInfoFromKeychainItem:corruptArchive];
|
|
|
|
XCTAssertNotNil(decoded, @"%@: a damaged APNSInfo must not cost the user their token.",
|
|
description);
|
|
XCTAssertEqualObjects(decoded.token, kToken, @"%@", description);
|
|
XCTAssertEqualObjects(decoded.authorizedEntity, kAuthorizedEntity, @"%@", description);
|
|
XCTAssertNil(decoded.APNSInfo,
|
|
@"%@: nothing should be substituted for a blob that failed to "
|
|
@"decode.",
|
|
description);
|
|
XCTAssertTrue(decoded.needsMigration,
|
|
@"%@: the record is still in the legacy shape, so the store should rewrite it "
|
|
@"and be rid of the bad blob.",
|
|
description);
|
|
}
|
|
}
|
|
|
|
/// **Scenario:** A keychain item is corrupt as a whole (not just a nested blob): bit rot, a partial
|
|
/// keychain write, or malformed data.
|
|
/// **What it does:** Plants each corrupt payload directly as the keychain item and asserts
|
|
/// `+tokenInfoFromKeychainItem:` returns nil without raising.
|
|
- (void)testCorruptKeychainItemReturnsNilWithoutRaising {
|
|
FIRMessagingTokenInfo *tokenInfo =
|
|
[[FIRMessagingTokenInfo alloc] initWithAuthorizedEntity:kAuthorizedEntity
|
|
scope:kScope
|
|
token:kToken
|
|
appVersion:@"1.0"
|
|
firebaseAppID:@"firebaseAppID"
|
|
tokenType:@"V4"];
|
|
NSKeyedArchiver *archiver = [[NSKeyedArchiver alloc] initRequiringSecureCoding:YES];
|
|
[archiver encodeObject:tokenInfo forKey:NSKeyedArchiveRootObjectKey];
|
|
[archiver finishEncoding];
|
|
NSData *validArchive = archiver.encodedData;
|
|
NSData *truncatedArchive =
|
|
[validArchive subdataWithRange:NSMakeRange(0, validArchive.length / 2)];
|
|
|
|
NSArray<NSData *> *corruptItems = @[
|
|
[@"not a property list" dataUsingEncoding:NSUTF8StringEncoding],
|
|
[NSPropertyListSerialization dataWithPropertyList:@{@"foo" : @"bar"}
|
|
format:NSPropertyListXMLFormat_v1_0
|
|
options:0
|
|
error:nil],
|
|
truncatedArchive
|
|
];
|
|
|
|
for (NSData *item in corruptItems) {
|
|
FIRMessagingTokenInfo *decoded = [FIRMessagingTokenStore tokenInfoFromKeychainItem:item];
|
|
XCTAssertNil(decoded, @"Corrupt keychain item must yield nil tokenInfo without throwing.");
|
|
}
|
|
}
|
|
|
|
#pragma mark - Archive shape
|
|
|
|
/// **Scenario:** Guards the absence of a `FIRInstanceIDAPNSInfo` -> `FIRMessagingAPNSInfo` class
|
|
/// mapping on the *outer* decoder in `-[FIRMessagingTokenInfo initWithCoder:]`.
|
|
/// **What it does:** Archives a token in the current format and asserts the nested APNSInfo is
|
|
/// recorded under its present-day class name. Nothing renames `FIRMessagingAPNSInfo` on the write
|
|
/// path, so the outer decode needs no mapping. 10.18-and-earlier archives bury the legacy name
|
|
/// inside an opaque blob, where an outer mapping could never reach it: that name is resolved by
|
|
/// the dedicated nested unarchiver instead.
|
|
- (void)testModernArchiveRecordsAPNSInfoUnderItsCurrentClassName {
|
|
FIRMessagingTokenInfo *tokenInfo =
|
|
[[FIRMessagingTokenInfo alloc] initWithAuthorizedEntity:kAuthorizedEntity
|
|
scope:kScope
|
|
token:kToken
|
|
appVersion:@"1.0"
|
|
firebaseAppID:@"firebaseAppID"
|
|
tokenType:@"V4"];
|
|
tokenInfo.APNSInfo = [[FIRMessagingAPNSInfo alloc]
|
|
initWithDeviceToken:[@"deviceToken" dataUsingEncoding:NSUTF8StringEncoding]
|
|
isSandbox:NO];
|
|
|
|
NSError *error = nil;
|
|
NSData *archive = [NSKeyedArchiver archivedDataWithRootObject:tokenInfo
|
|
requiringSecureCoding:YES
|
|
error:&error];
|
|
XCTAssertNil(error);
|
|
|
|
NSDictionary *plist = [NSPropertyListSerialization propertyListWithData:archive
|
|
options:NSPropertyListImmutable
|
|
format:NULL
|
|
error:NULL];
|
|
NSString *objects = [plist[@"$objects"] description];
|
|
XCTAssertTrue([objects containsString:@"FIRMessagingAPNSInfo"]);
|
|
XCTAssertFalse([objects containsString:@"FIRInstanceIDAPNSInfo"],
|
|
@"Nothing maps APNSInfo onto its legacy name, so the read path needs no mapping.");
|
|
}
|
|
|
|
/// **Scenario:** A pre-10.19 keychain record is read through the full public store API.
|
|
/// **What it does:** Injects a legacy record into the keychain, reads it via
|
|
/// `tokenInfoWithAuthorizedEntity:scope:`, and asserts the store rewrites it in the modern
|
|
/// format so the legacy blob is not re-encountered on every subsequent read.
|
|
- (void)testReadingLegacyRecordMigratesItToTheModernFormat {
|
|
[self injectKeychainItem:[self archiveWrittenBy10_18]];
|
|
|
|
NSData *expectedDeviceToken = [self legacyDeviceToken];
|
|
FIRMessagingTokenInfo *retrieved =
|
|
[self.tokenStore tokenInfoWithAuthorizedEntity:kAuthorizedEntity scope:kScope];
|
|
XCTAssertNotNil(retrieved);
|
|
XCTAssertEqualObjects(retrieved.token, kToken);
|
|
XCTAssertEqualObjects(retrieved.APNSInfo.deviceToken, expectedDeviceToken);
|
|
|
|
// The store should have rewritten the record, so a second read no longer sees a legacy payload.
|
|
// The rewrite must be lossless: migration is invisible to the app only if the APNS association
|
|
// survives it, otherwise the token would be invalidated on the next APNS registration.
|
|
FIRMessagingTokenInfo *reread = [self.tokenStore tokenInfoWithAuthorizedEntity:kAuthorizedEntity
|
|
scope:kScope];
|
|
XCTAssertNotNil(reread);
|
|
XCTAssertEqualObjects(reread.token, kToken);
|
|
XCTAssertEqualObjects(reread.APNSInfo.deviceToken, expectedDeviceToken);
|
|
XCTAssertFalse(reread.needsMigration,
|
|
@"The legacy record should have been migrated on the first read.");
|
|
}
|
|
|
|
/// **Scenario:** The longest realistic version path: a user sitting on a `<= 10.18.0` record
|
|
/// upgrades to Firebase 13, which migrates the record, and then downgrades to 12.x.
|
|
/// **What it does:** Injects a 10.18 record, lets the store migrate it, then reads the migrated
|
|
/// bytes straight out of the keychain with 12.19.0's decoder.
|
|
///
|
|
/// This is the one cell of the compatibility matrix that neither the 10.18 test nor the
|
|
/// downgrade test covers on its own, and it is the cell where a mistake would be worst: the
|
|
/// migration rewrites bytes the user may never be able to get back, so a 12.x SDK has to be able
|
|
/// to read the result. It can, because migration writes the same modern shape 13 writes for
|
|
/// everyone else.
|
|
- (void)testRecordMigratedFrom10_18IsStillReadableBy12 {
|
|
[self injectKeychainItem:[self archiveWrittenBy10_18]];
|
|
|
|
// Reading through the store triggers the migration rewrite.
|
|
FIRMessagingTokenInfo *migrated = [self.tokenStore tokenInfoWithAuthorizedEntity:kAuthorizedEntity
|
|
scope:kScope];
|
|
XCTAssertNotNil(migrated);
|
|
XCTAssertTrue(migrated.needsMigration, @"The injected record should have been the legacy shape.");
|
|
|
|
NSData *migratedArchive = [self keychainItem];
|
|
XCTAssertNotNil(migratedArchive);
|
|
|
|
// The user downgrades. 12.19.0's read path must cope with the migrated bytes.
|
|
FIRMessagingTokenInfo *downgraded =
|
|
[FIRMessagingLegacyArchiveFixtures tokenInfoReadBy12:migratedArchive];
|
|
XCTAssertNotNil(downgraded, @"Migration must not strand the record on a newer SDK.");
|
|
XCTAssertEqualObjects(downgraded.token, kToken);
|
|
XCTAssertEqualObjects(downgraded.authorizedEntity, kAuthorizedEntity);
|
|
XCTAssertEqualObjects(downgraded.scope, kScope);
|
|
XCTAssertEqualObjects(downgraded.APNSInfo.deviceToken, [self legacyDeviceToken],
|
|
@"The APNS association has to survive both the migration and the "
|
|
@"downgrade, or the next APNS registration invalidates the token.");
|
|
XCTAssertEqualObjects(downgraded.tokenType, @"V4",
|
|
@"Migration should persist the `V4` default the 13 decoder substituted, "
|
|
@"so 12.x does not have to re-derive it.");
|
|
XCTAssertFalse(downgraded.needsMigration,
|
|
@"Post-migration the record is in the modern shape, so 12.x sees nothing legacy.");
|
|
}
|
|
|
|
/**
|
|
* Tests that a checkin authentication ID can be stored in the FIRMessagingStore.
|
|
*/
|
|
- (void)testSaveCheckinAuthID {
|
|
XCTestExpectation *checkinExpectation = [self expectationWithDescription:@"checkin is saved"];
|
|
NSDictionary *plistContent = @{
|
|
kFIRMessagingDigestStringKey : @"digest-xyz",
|
|
kFIRMessagingLastCheckinTimeKey : @(FIRMessagingCurrentTimestampInMilliseconds())
|
|
};
|
|
FIRMessagingCheckinPreferences *preferences =
|
|
[[FIRMessagingCheckinPreferences alloc] initWithDeviceID:kAuthID secretToken:kSecret];
|
|
[preferences updateWithCheckinPlistContents:plistContent];
|
|
[self.checkinStore saveCheckinPreferences:preferences
|
|
handler:^(NSError *_Nonnull error) {
|
|
XCTAssertNil(error);
|
|
FIRMessagingCheckinPreferences *cachedPreferences =
|
|
[self.checkinStore cachedCheckinPreferences];
|
|
|
|
XCTAssertEqualObjects(cachedPreferences.deviceID, kAuthID);
|
|
XCTAssertEqualObjects(cachedPreferences.secretToken, kSecret);
|
|
[checkinExpectation fulfill];
|
|
}];
|
|
|
|
[self waitForExpectationsWithTimeout:1 handler:nil];
|
|
}
|
|
|
|
/**
|
|
* Tests that a checkin authentication ID can be removed from FIRMessagingStore's cache.
|
|
*/
|
|
- (void)testRemoveCheckinPreferences {
|
|
XCTestExpectation *checkinExpectation = [self expectationWithDescription:@"checkin is removed"];
|
|
NSDictionary *plistContent = @{
|
|
kFIRMessagingDigestStringKey : @"digest-xyz",
|
|
kFIRMessagingLastCheckinTimeKey : @(FIRMessagingCurrentTimestampInMilliseconds())
|
|
};
|
|
FIRMessagingCheckinPreferences *preferences =
|
|
[[FIRMessagingCheckinPreferences alloc] initWithDeviceID:kAuthID secretToken:kSecret];
|
|
[preferences updateWithCheckinPlistContents:plistContent];
|
|
|
|
[self.checkinStore saveCheckinPreferences:preferences
|
|
handler:^(NSError *error) {
|
|
XCTAssertNil(error);
|
|
|
|
[self.checkinStore removeCheckinPreferencesWithHandler:^(
|
|
NSError *_Nullable error) {
|
|
XCTAssertNil(error);
|
|
|
|
FIRMessagingCheckinPreferences *cachedPreferences =
|
|
[self.checkinStore cachedCheckinPreferences];
|
|
XCTAssertNil(cachedPreferences.deviceID);
|
|
XCTAssertNil(cachedPreferences.secretToken);
|
|
[checkinExpectation fulfill];
|
|
}];
|
|
}];
|
|
|
|
[self waitForExpectationsWithTimeout:1 handler:nil];
|
|
}
|
|
|
|
#pragma mark - Private Helpers
|
|
- (NSString *)pathForCheckinPlist {
|
|
NSArray *paths =
|
|
NSSearchPathForDirectoriesInDomains(NSApplicationDirectory, NSUserDomainMask, YES);
|
|
NSString *plistNameWithExtension = [NSString stringWithFormat:@"%@.plist", kFakeCheckinPlistName];
|
|
return [paths[0] stringByAppendingPathComponent:plistNameWithExtension];
|
|
}
|
|
@end
|