Signal-iOS/SignalServiceKit/tests/Contacts/BlockingManagerStateTests.swift
2024-05-06 09:57:21 -07:00

376 lines
16 KiB
Swift

//
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import Foundation
import XCTest
@testable import SignalServiceKit
class BlockingManagerStateTests: SSKBaseTest {
var dut = BlockingManager.State._testing_createEmpty()
override func setUp() {
super.setUp()
databaseStorage.read { dut.reloadIfNecessary($0) }
assertInitalState(dut)
}
// MARK: Mutations
func testAddBlockedItems() {
// Setup
let originalChangeToken = dut.changeToken
let blockedGroup = generateRandomGroupModel()
let blockedAci = CommonGenerator.address(hasAci: true, hasPhoneNumber: false)
let blockedPhoneNumber = CommonGenerator.address(hasAci: false, hasPhoneNumber: true)
let blockedBoth = CommonGenerator.address(hasAci: true, hasPhoneNumber: true)
// Test
// We add everthing twice, only the first pass should return true (i.e. didChange)
XCTAssertTrue(dut.addBlockedGroup(blockedGroup))
XCTAssertTrue(dut.addBlockedAddress(blockedAci))
XCTAssertTrue(dut.addBlockedAddress(blockedPhoneNumber))
XCTAssertTrue(dut.addBlockedAddress(blockedBoth))
XCTAssertFalse(dut.addBlockedGroup(blockedGroup))
XCTAssertFalse(dut.addBlockedAddress(blockedAci))
XCTAssertFalse(dut.addBlockedAddress(blockedPhoneNumber))
XCTAssertFalse(dut.addBlockedAddress(blockedBoth))
// Verify All added addresses are contained in each set
XCTAssertEqual(dut.blockedGroupMap[blockedGroup.groupId], blockedGroup)
XCTAssertTrue(dut.blockedAcis.contains(blockedAci.aci!))
XCTAssertTrue(dut.blockedAcis.contains(blockedBoth.aci!))
XCTAssertTrue(dut.blockedPhoneNumbers.contains(blockedPhoneNumber.phoneNumber!))
XCTAssertTrue(dut.blockedPhoneNumbers.contains(blockedBoth.phoneNumber!))
XCTAssertTrue(dut.isDirty, "Mutations should mark the state as dirty")
XCTAssertTrue(dut.changeToken == originalChangeToken, "Change tokens shouldn't update until we persist")
}
func testRemoveBlockedItems() {
// Setup
let originalChangeToken = dut.changeToken
let victimAddress = CommonGenerator.address()
let victimGroup = generateRandomGroupModel()
dut.addBlockedAddress(victimAddress)
dut.addBlockedGroup(victimGroup)
for _ in 0..<100 {
dut.addBlockedGroup(generateRandomGroupModel())
dut.addBlockedAddress(CommonGenerator.address(hasAci: true, hasPhoneNumber: false))
dut.addBlockedAddress(CommonGenerator.address(hasAci: false, hasPhoneNumber: true))
dut.addBlockedAddress(CommonGenerator.address(hasAci: true, hasPhoneNumber: true))
}
let initialBlockedGroupCount = dut.blockedGroupMap.count
let initialPhoneNumberBlockCount = dut.blockedPhoneNumbers.count
let initialAciBlockCount = dut.blockedAcis.count
// Test
// Remove both a known entry and a known non-entry
XCTAssertNotNil(dut.removeBlockedGroup(victimGroup.groupId))
XCTAssertTrue(dut.removeBlockedAddress(victimAddress))
XCTAssertNil(dut.removeBlockedGroup(TSGroupModel.generateRandomV1GroupId()))
XCTAssertFalse(dut.removeBlockedAddress(CommonGenerator.address()))
// Verify One and only one item in each set should have been removed
XCTAssertEqual(dut.blockedGroupMap.count + 1, initialBlockedGroupCount)
XCTAssertEqual(dut.blockedAcis.count + 1, initialAciBlockCount)
XCTAssertEqual(dut.blockedPhoneNumbers.count + 1, initialPhoneNumberBlockCount)
XCTAssertTrue(dut.isDirty, "Mutations should mark the state as dirty")
XCTAssertTrue(dut.changeToken == originalChangeToken, "Change tokens shouldn't update until we persist")
}
func testIncomingSyncReplaces() {
// Setup
var replacementAddresses = generateAddresses(count: 50)
var replacementGroups = generateGroupMap(count: 50)
func replaceWithCurrentValues() {
dut.replace(
blockedPhoneNumbers: Set(replacementAddresses.compactMap({ $0.phoneNumber })),
blockedAcis: Set(replacementAddresses.compactMap({ $0.aci })),
blockedGroups: replacementGroups
)
}
// Test
dut.replace(blockedPhoneNumbers: Set(), blockedAcis: Set(), blockedGroups: Dictionary())
let replaceEmptyWithEmpty = dut.isDirty
dut._testingOnly_resetDirtyBit()
replaceWithCurrentValues()
let replaceEmptyWithFull = dut.isDirty
dut._testingOnly_resetDirtyBit()
replaceWithCurrentValues()
let replaceFullWithFull = dut.isDirty
dut._testingOnly_resetDirtyBit()
replacementAddresses.insert(CommonGenerator.address())
replaceWithCurrentValues()
let replaceFullWithAnExtraAddress = dut.isDirty
dut._testingOnly_resetDirtyBit()
let newGroup = generateRandomGroupModel()
replacementGroups[newGroup.groupId] = newGroup
replaceWithCurrentValues()
let replaceFullWithAnExtraGroup = dut.isDirty
dut._testingOnly_resetDirtyBit()
replacementAddresses.insert(CommonGenerator.address())
replaceWithCurrentValues()
replaceWithCurrentValues()
let replaceFullWithTheSameAddressesTwice = dut.isDirty
dut._testingOnly_resetDirtyBit()
// Verify
XCTAssertFalse(replaceEmptyWithEmpty)
XCTAssertTrue(replaceEmptyWithFull)
XCTAssertFalse(replaceFullWithFull)
XCTAssertTrue(replaceFullWithAnExtraAddress)
XCTAssertTrue(replaceFullWithAnExtraGroup)
XCTAssertTrue(replaceFullWithTheSameAddressesTwice)
}
func testDirtyBitUpdates() {
// Setup
// We apply a sequence of mutations, one after the other, and verify it updates the dirty bit
// as expected
let victimAddress = CommonGenerator.address()
let victimGroup = generateRandomGroupModel()
[
// Insert and remove a bunch of random addresses. Inserts should always mutate. Removes should never mutate.
(CommonGenerator.address(), false, false),
(CommonGenerator.address(), true, true),
(CommonGenerator.address(), true, true),
(CommonGenerator.address(), false, false),
(generateRandomGroupModel(), false, false),
(generateRandomGroupModel(), true, true),
(generateRandomGroupModel(), true, true),
(CommonGenerator.address(), false, false),
// Insert and remove the same address/group. Only the first insert or remove should mutate.
(victimAddress, false, false),
(victimAddress, true, true),
(victimAddress, true, false),
(victimAddress, true, false),
(victimAddress, false, true),
(victimAddress, false, false),
(victimGroup, false, false),
(victimGroup, true, true),
(victimGroup, true, false),
(victimGroup, true, false),
(victimGroup, false, true),
(victimGroup, false, false)
].forEach { (changedObject: Any, isInsertion: Bool, expectDirtyBit: Bool) in
// Force reset the dirty bit to test the effect of this single insert/remove
dut._testingOnly_resetDirtyBit()
// Test
let didChange: Bool = {
switch (isInsertion, changedObject) {
case (true, let changedObject as SignalServiceAddress):
return dut.addBlockedAddress(changedObject)
case (false, let changedObject as SignalServiceAddress):
return dut.removeBlockedAddress(changedObject)
case (true, let changedObject as TSGroupModel):
return dut.addBlockedGroup(changedObject)
case (false, let changedObject as TSGroupModel):
return dut.removeBlockedGroup(changedObject.groupId) != nil
default:
XCTFail("This case should be impossible")
return false
}
}()
// Verify
XCTAssertEqual(dut.isDirty, expectDirtyBit)
XCTAssertEqual(didChange, expectDirtyBit)
}
}
// MARK: Persistence and Migrations
func testFreshInstall() {
databaseStorage.read {
dut.reloadIfNecessary($0)
XCTAssertFalse(dut.needsSync(transaction: $0), "Fresh installs shouldn't need to implicitly sync")
}
}
func testMigrationFromOldKeys() {
// Setup Simulate migration to the new format by throwing a bunch of fake data into the KVS container
let oldAddresses = generateAddresses(count: 30)
let oldPhoneNumberStrings = oldAddresses.compactMap { $0.phoneNumber }
let oldAcis = oldAddresses.compactMap { $0.aci }
let oldGroupMap = generateGroupMap(count: 30)
typealias Key = BlockingManager.State.PersistenceKey
let storage = BlockingManager.State.keyValueStore
databaseStorage.write {
storage.setObject(oldPhoneNumberStrings, key: Key.blockedPhoneNumbersKey.rawValue, transaction: $0)
storage.setObject(oldPhoneNumberStrings, key: Key.Legacy.syncedBlockedPhoneNumbersKey.rawValue, transaction: $0)
storage.setObject(oldAcis.map { $0.serviceIdUppercaseString }, key: Key.blockedAciStringsKey.rawValue, transaction: $0)
storage.setObject(oldAcis.map { $0.serviceIdUppercaseString }, key: Key.Legacy.syncedBlockedUUIDsKey.rawValue, transaction: $0)
storage.setObject(oldGroupMap, key: Key.blockedGroupMapKey.rawValue, transaction: $0)
storage.setObject(Array(oldGroupMap.keys), key: Key.Legacy.syncedBlockedGroupIdsKey.rawValue, transaction: $0)
}
databaseStorage.read {
// Test
// We first reset our test object to ensure that it doesn't reuse any cached state.
// A reload would only occur if the change token was updated, which we're not testing here.
dut = BlockingManager.State._testing_createEmpty()
dut.reloadIfNecessary($0)
// Verify
XCTAssertEqual(Set(oldPhoneNumberStrings), dut.blockedPhoneNumbers)
XCTAssertEqual(Set(oldAcis), dut.blockedAcis)
XCTAssertEqual(oldGroupMap, dut.blockedGroupMap)
XCTAssertTrue(dut.needsSync(transaction: $0), "Block state requires a sync on first migration")
}
}
func testPersistAndLoad() {
// Setup
let testAddress = CommonGenerator.address()
let testGroup = generateRandomGroupModel()
let initialChangeToken: UInt64 = databaseStorage.read {
dut.reloadIfNecessary($0)
return dut.changeToken
}
// Test Add blocked items, persist, reset our local state to force a reload
let changeTokenAfterUpdate: UInt64 = databaseStorage.write {
dut.addBlockedAddress(testAddress)
dut.addBlockedGroup(testGroup)
// Double persist, only the first should be necessary. Dirty bit should be unset.
XCTAssertTrue(dut.isDirty)
XCTAssertTrue(dut.persistIfNecessary($0))
XCTAssertFalse(dut.persistIfNecessary($0))
XCTAssertFalse(dut.isDirty)
return dut.changeToken
}
dut = BlockingManager.State._testing_createEmpty()
// Verify
databaseStorage.read {
dut.reloadIfNecessary($0)
XCTAssertEqual(dut.blockedPhoneNumbers, Set([testAddress.phoneNumber!]))
XCTAssertEqual(dut.blockedAcis, Set([testAddress.aci!]))
XCTAssertEqual(dut.blockedGroupMap[testGroup.groupId], testGroup)
XCTAssertEqual(dut.changeToken, changeTokenAfterUpdate)
XCTAssertNotEqual(dut.changeToken, initialChangeToken)
XCTAssertTrue(dut.needsSync(transaction: $0))
}
}
func testSimulatedSyncMessage() {
databaseStorage.write {
// Setup
dut.reloadIfNecessary($0)
XCTAssertFalse(dut.needsSync(transaction: $0))
dut.addBlockedAddress(CommonGenerator.address())
XCTAssertTrue(dut.persistIfNecessary($0))
// Test
let needsSyncBefore = dut.needsSync(transaction: $0)
BlockingManager.State.setLastSyncedChangeToken(dut.changeToken, transaction: $0)
let needsSyncAfter = dut.needsSync(transaction: $0)
// Verify
XCTAssertTrue(needsSyncBefore)
XCTAssertFalse(needsSyncAfter)
}
}
func testSimulatedRemoteChange() {
// Setup
// We mutate two different instance of a state object. One lives as an ivar on the test class
// the other lives within the scope of this test. Mutations to one should be reflected in the other
dut = BlockingManager.State._testing_createEmpty()
var remoteState = BlockingManager.State._testing_createEmpty()
databaseStorage.read {
dut.reloadIfNecessary($0)
remoteState.reloadIfNecessary($0)
}
let blockedAddresses = generateAddresses(count: 3)
let blockedGroups = generateGroupMap(count: 3)
let removedBlock = blockedAddresses.randomElement()!
// Test #1 Add some items to one state. Ensure it gets reflected in the other state
databaseStorage.write { writeTx in
dut.reloadIfNecessary(writeTx)
blockedAddresses.forEach { dut.addBlockedAddress($0) }
blockedGroups.forEach { dut.addBlockedGroup($0.value) }
XCTAssertTrue(dut.persistIfNecessary(writeTx))
}
databaseStorage.read { readTx in
let oldChangeToken = remoteState.changeToken
remoteState.reloadIfNecessary(readTx)
// Verify #1
XCTAssertNotEqual(oldChangeToken, remoteState.changeToken)
XCTAssertEqual(remoteState.blockedPhoneNumbers, Set(blockedAddresses.compactMap { $0.phoneNumber }))
XCTAssertEqual(remoteState.blockedAcis, Set(blockedAddresses.compactMap { $0.aci }))
XCTAssertEqual(remoteState.blockedGroupMap, blockedGroups)
}
// Test #2 In the opposite direction, remove an item and ensure it gets reflected on the other side
databaseStorage.write { writeTx in
remoteState.reloadIfNecessary(writeTx)
remoteState.removeBlockedAddress(removedBlock)
XCTAssertTrue(remoteState.persistIfNecessary(writeTx))
}
databaseStorage.read { readTx in
let oldChangeToken = dut.changeToken
dut.reloadIfNecessary(readTx)
// Verify #2
XCTAssertNotEqual(oldChangeToken, dut.changeToken)
removedBlock.phoneNumber.map { XCTAssertFalse(dut.blockedPhoneNumbers.contains($0)) }
removedBlock.aci.map { XCTAssertFalse(dut.blockedAcis.contains($0)) }
XCTAssertEqual(dut.blockedGroupMap, blockedGroups)
}
}
// MARK: Helpers
func assertInitalState(_ state: BlockingManager.State) {
XCTAssertEqual(dut.isDirty, false)
XCTAssertEqual(dut.blockedPhoneNumbers.count, 0)
XCTAssertEqual(dut.blockedAcis.count, 0)
XCTAssertEqual(dut.blockedGroupMap.count, 0)
}
func generateAddresses(count: UInt) -> Set<SignalServiceAddress> {
Set((0..<count).map { _ in
let hasPhoneNumber = Int.random(in: 0...2) == 0
let hasAci = !hasPhoneNumber || Bool.random()
return CommonGenerator.address(hasAci: hasAci, hasPhoneNumber: hasPhoneNumber)
})
}
func generateGroupMap(count: UInt) -> [Data: TSGroupModel] {
Dictionary(uniqueKeysWithValues: (0..<count).map { _ in
let fakeGroup = generateRandomGroupModel()
return (fakeGroup.groupId, fakeGroup)
})
}
func generateRandomGroupModel() -> TSGroupModel {
GroupManager.fakeGroupModel(groupId: TSGroupModel.generateRandomV1GroupId())!
}
}