Files
bitchat/bitchatTests/Services/SecureIdentityStateManagerTests.swift
T
IslamandGitHub 4cfcefcda6 BitFoundation module to centralize shared components (#1089)
* Run local packages’ tests as well on CI

* BitFoundation module to centralize shared components
2026-04-14 14:10:03 -05:00

499 lines
19 KiB
Swift

import Foundation
import XCTest
import BitFoundation
@testable import bitchat
final class SecureIdentityStateManagerTests: XCTestCase {
func test_upsertCryptographicIdentity_withoutClaimedNicknameDoesNotCreateSocialIdentity() async {
let manager = SecureIdentityStateManager(MockKeychain())
let fingerprint = String(repeating: "aa", count: 32)
let peerID = PeerID(str: String(fingerprint.prefix(16)))
manager.upsertCryptographicIdentity(
fingerprint: fingerprint,
noisePublicKey: Data(repeating: 0x11, count: 32),
signingPublicKey: Data(repeating: 0x22, count: 32),
claimedNickname: nil
)
let inserted = await waitUntil {
manager.getCryptoIdentitiesByPeerIDPrefix(peerID).count == 1
}
XCTAssertTrue(inserted)
XCTAssertNil(manager.getSocialIdentity(for: fingerprint))
}
func test_upsertCryptographicIdentity_updatesExistingKeyAndPreservesSigningKey() async {
let manager = SecureIdentityStateManager(MockKeychain())
let fingerprint = String(repeating: "ab", count: 32)
let peerID = PeerID(str: String(fingerprint.prefix(16)))
let originalNoiseKey = Data(repeating: 0x11, count: 32)
let updatedNoiseKey = Data(repeating: 0x33, count: 32)
let signingKey = Data(repeating: 0x22, count: 32)
manager.upsertCryptographicIdentity(
fingerprint: fingerprint,
noisePublicKey: originalNoiseKey,
signingPublicKey: signingKey,
claimedNickname: nil
)
_ = await waitUntil {
manager.getCryptoIdentitiesByPeerIDPrefix(peerID).first?.publicKey == originalNoiseKey
}
manager.upsertCryptographicIdentity(
fingerprint: fingerprint,
noisePublicKey: updatedNoiseKey,
signingPublicKey: nil,
claimedNickname: nil
)
let updated = await waitUntil {
guard let identity = manager.getCryptoIdentitiesByPeerIDPrefix(peerID).first else { return false }
return identity.publicKey == updatedNoiseKey && identity.signingPublicKey == signingKey
}
XCTAssertTrue(updated)
}
func test_upsertCryptographicIdentity_tracksByPeerIDPrefixAndClaimedNickname() async {
let manager = SecureIdentityStateManager(MockKeychain())
let noisePublicKey = Data(repeating: 0x11, count: 32)
let signingPublicKey = Data(repeating: 0x22, count: 32)
let fingerprint = noisePublicKey.sha256Fingerprint()
manager.upsertCryptographicIdentity(
fingerprint: fingerprint,
noisePublicKey: noisePublicKey,
signingPublicKey: signingPublicKey,
claimedNickname: "Alice"
)
let socialIdentityLoaded = await waitUntil {
manager.getSocialIdentity(for: fingerprint)?.claimedNickname == "Alice"
}
XCTAssertTrue(socialIdentityLoaded)
let matches = manager.getCryptoIdentitiesByPeerIDPrefix(PeerID(publicKey: noisePublicKey))
XCTAssertEqual(matches.count, 1)
XCTAssertEqual(matches.first?.fingerprint, fingerprint)
XCTAssertEqual(matches.first?.publicKey, noisePublicKey)
XCTAssertEqual(matches.first?.signingPublicKey, signingPublicKey)
}
func test_setBlocked_clearsFavoriteState() async {
let manager = SecureIdentityStateManager(MockKeychain())
let fingerprint = String(repeating: "ab", count: 32)
manager.setFavorite(fingerprint, isFavorite: true)
let favoriteSet = await waitUntil { manager.isFavorite(fingerprint: fingerprint) }
XCTAssertTrue(favoriteSet)
manager.setBlocked(fingerprint, isBlocked: true)
let blockedSet = await waitUntil { manager.isBlocked(fingerprint: fingerprint) }
XCTAssertTrue(blockedSet)
XCTAssertFalse(manager.isFavorite(fingerprint: fingerprint))
XCTAssertEqual(manager.getSocialIdentity(for: fingerprint)?.claimedNickname, "Unknown")
}
func test_isBlocked_unknownFingerprintReturnsFalse() {
let manager = SecureIdentityStateManager(MockKeychain())
XCTAssertFalse(manager.isBlocked(fingerprint: String(repeating: "ff", count: 32)))
}
func test_setVerified_updatesTrustLevelAndVerifiedSet() async {
let manager = SecureIdentityStateManager(MockKeychain())
let fingerprint = String(repeating: "cd", count: 32)
manager.setFavorite(fingerprint, isFavorite: false)
_ = await waitUntil { manager.getSocialIdentity(for: fingerprint) != nil }
manager.setVerified(fingerprint: fingerprint, verified: true)
let verifiedSet = await waitUntil { manager.isVerified(fingerprint: fingerprint) }
XCTAssertTrue(verifiedSet)
XCTAssertTrue(manager.getVerifiedFingerprints().contains(fingerprint))
XCTAssertEqual(manager.getSocialIdentity(for: fingerprint)?.trustLevel, .verified)
}
func test_forceSave_persistsFavoriteStateAcrossReinit() async {
let keychain = MockKeychain()
let manager = SecureIdentityStateManager(keychain)
let fingerprint = String(repeating: "ef", count: 32)
manager.setFavorite(fingerprint, isFavorite: true)
let favoriteSet = await waitUntil { manager.isFavorite(fingerprint: fingerprint) }
XCTAssertTrue(favoriteSet)
manager.forceSave()
let reloaded = SecureIdentityStateManager(keychain)
XCTAssertTrue(reloaded.isFavorite(fingerprint: fingerprint))
}
func test_updateSocialIdentity_reindexesClaimedNickname() async {
let manager = SecureIdentityStateManager(MockKeychain())
let fingerprint = String(repeating: "34", count: 32)
manager.updateSocialIdentity(
SocialIdentity(
fingerprint: fingerprint,
localPetname: nil,
claimedNickname: "Alice",
trustLevel: .unknown,
isFavorite: false,
isBlocked: false,
notes: nil
)
)
let initialIndexed = await waitUntil {
manager.debugNicknameIndex["Alice"]?.contains(fingerprint) == true
}
XCTAssertTrue(initialIndexed)
manager.updateSocialIdentity(
SocialIdentity(
fingerprint: fingerprint,
localPetname: "Friend",
claimedNickname: "Bob",
trustLevel: .trusted,
isFavorite: true,
isBlocked: false,
notes: "updated"
)
)
let reindexed = await waitUntil {
manager.debugNicknameIndex["Alice"]?.contains(fingerprint) != true &&
manager.debugNicknameIndex["Bob"]?.contains(fingerprint) == true
}
XCTAssertTrue(reindexed)
XCTAssertEqual(manager.getSocialIdentity(for: fingerprint)?.claimedNickname, "Bob")
}
func test_upsertCryptographicIdentity_sameClaimedNicknamePreservesExistingSocialIdentity() async {
let manager = SecureIdentityStateManager(MockKeychain())
let fingerprint = String(repeating: "35", count: 32)
manager.updateSocialIdentity(
SocialIdentity(
fingerprint: fingerprint,
localPetname: "Pal",
claimedNickname: "Alice",
trustLevel: .trusted,
isFavorite: true,
isBlocked: false,
notes: "keep me"
)
)
_ = await waitUntil { manager.getSocialIdentity(for: fingerprint) != nil }
manager.upsertCryptographicIdentity(
fingerprint: fingerprint,
noisePublicKey: Data(repeating: 0x11, count: 32),
signingPublicKey: Data(repeating: 0x22, count: 32),
claimedNickname: "Alice"
)
let inserted = await waitUntil {
manager.getCryptoIdentitiesByPeerIDPrefix(PeerID(str: String(fingerprint.prefix(16)))).count == 1
}
XCTAssertTrue(inserted)
XCTAssertEqual(manager.getSocialIdentity(for: fingerprint)?.localPetname, "Pal")
XCTAssertEqual(manager.getSocialIdentity(for: fingerprint)?.notes, "keep me")
XCTAssertTrue(manager.getSocialIdentity(for: fingerprint)?.isFavorite == true)
}
func test_getFavorites_returnsOnlyFavoritedFingerprints() async {
let manager = SecureIdentityStateManager(MockKeychain())
let favoriteOne = String(repeating: "45", count: 32)
let favoriteTwo = String(repeating: "56", count: 32)
let other = String(repeating: "67", count: 32)
manager.setFavorite(favoriteOne, isFavorite: true)
manager.setFavorite(favoriteTwo, isFavorite: true)
manager.setFavorite(other, isFavorite: false)
let favoritesLoaded = await waitUntil {
manager.getFavorites() == Set([favoriteOne, favoriteTwo])
}
XCTAssertTrue(favoritesLoaded)
}
func test_setFavorite_existingIdentityCanBeClearedWithoutChangingNickname() async {
let manager = SecureIdentityStateManager(MockKeychain())
let fingerprint = String(repeating: "68", count: 32)
manager.updateSocialIdentity(
SocialIdentity(
fingerprint: fingerprint,
localPetname: nil,
claimedNickname: "Alice",
trustLevel: .trusted,
isFavorite: false,
isBlocked: false,
notes: nil
)
)
_ = await waitUntil { manager.getSocialIdentity(for: fingerprint) != nil }
manager.setFavorite(fingerprint, isFavorite: true)
_ = await waitUntil { manager.isFavorite(fingerprint: fingerprint) }
manager.setFavorite(fingerprint, isFavorite: false)
let cleared = await waitUntil {
!manager.isFavorite(fingerprint: fingerprint) &&
manager.getSocialIdentity(for: fingerprint)?.claimedNickname == "Alice" &&
manager.getSocialIdentity(for: fingerprint)?.trustLevel == .trusted
}
XCTAssertTrue(cleared)
}
func test_setBlocked_createsIdentityAndCanLaterUnblock() async {
let manager = SecureIdentityStateManager(MockKeychain())
let fingerprint = String(repeating: "78", count: 32)
manager.setBlocked(fingerprint, isBlocked: true)
let blocked = await waitUntil {
manager.isBlocked(fingerprint: fingerprint)
}
XCTAssertTrue(blocked)
XCTAssertEqual(manager.getSocialIdentity(for: fingerprint)?.claimedNickname, "Unknown")
manager.setBlocked(fingerprint, isBlocked: false)
let unblocked = await waitUntil {
!manager.isBlocked(fingerprint: fingerprint)
}
XCTAssertTrue(unblocked)
}
func test_setVerified_false_downgradesTrustLevelToCasual() async {
let manager = SecureIdentityStateManager(MockKeychain())
let fingerprint = String(repeating: "89", count: 32)
manager.updateSocialIdentity(
SocialIdentity(
fingerprint: fingerprint,
localPetname: nil,
claimedNickname: "Verifier",
trustLevel: .trusted,
isFavorite: false,
isBlocked: false,
notes: nil
)
)
_ = await waitUntil { manager.getSocialIdentity(for: fingerprint) != nil }
manager.setVerified(fingerprint: fingerprint, verified: true)
_ = await waitUntil { manager.isVerified(fingerprint: fingerprint) }
manager.setVerified(fingerprint: fingerprint, verified: false)
let downgraded = await waitUntil {
!manager.isVerified(fingerprint: fingerprint) &&
manager.getSocialIdentity(for: fingerprint)?.trustLevel == .casual
}
XCTAssertTrue(downgraded)
}
func test_ephemeralSessionLifecycle_tracksHandshakeProgressAndLastInteraction() async {
let manager = SecureIdentityStateManager(MockKeychain())
let peerID = PeerID(str: "1234567890abcdef")
let fingerprint = String(repeating: "90", count: 32)
manager.registerEphemeralSession(peerID: peerID, handshakeState: .initiated)
let registered = await waitUntil {
if case .initiated? = manager.debugEphemeralSession(for: peerID)?.handshakeState {
return true
}
return false
}
XCTAssertTrue(registered)
manager.updateHandshakeState(peerID: peerID, state: .inProgress)
let progressed = await waitUntil {
if case .inProgress? = manager.debugEphemeralSession(for: peerID)?.handshakeState {
return true
}
return false
}
XCTAssertTrue(progressed)
manager.updateHandshakeState(peerID: peerID, state: .completed(fingerprint: fingerprint))
let completed = await waitUntil {
if case .completed(let completedFingerprint)? = manager.debugEphemeralSession(for: peerID)?.handshakeState {
return completedFingerprint == fingerprint && manager.debugLastInteraction(for: fingerprint) != nil
}
return false
}
XCTAssertTrue(completed)
manager.removeEphemeralSession(peerID: peerID)
let removed = await waitUntil {
manager.debugEphemeralSession(for: peerID) == nil
}
XCTAssertTrue(removed)
}
func test_setNostrBlocked_normalizesToLowercaseAndPersists() async {
let keychain = MockKeychain()
let manager = SecureIdentityStateManager(keychain)
let pubkey = "ABCDEF1234"
manager.setNostrBlocked(pubkey, isBlocked: true)
let nostrBlocked = await waitUntil {
manager.isNostrBlocked(pubkeyHexLowercased: pubkey.lowercased())
}
XCTAssertTrue(nostrBlocked)
manager.forceSave()
let reloaded = SecureIdentityStateManager(keychain)
XCTAssertEqual(reloaded.getBlockedNostrPubkeys(), Set([pubkey.lowercased()]))
XCTAssertTrue(reloaded.isNostrBlocked(pubkeyHexLowercased: pubkey))
}
func test_setNostrBlocked_falseRemovesExistingKey() async {
let manager = SecureIdentityStateManager(MockKeychain())
let pubkey = "ABCDEF1234"
manager.setNostrBlocked(pubkey, isBlocked: true)
_ = await waitUntil { manager.isNostrBlocked(pubkeyHexLowercased: pubkey) }
manager.setNostrBlocked(pubkey, isBlocked: false)
let cleared = await waitUntil {
!manager.isNostrBlocked(pubkeyHexLowercased: pubkey) &&
manager.getBlockedNostrPubkeys().isEmpty
}
XCTAssertTrue(cleared)
}
func test_corruptPersistedCache_fallsBackToEmptyState() {
let keychain = MockKeychain()
_ = keychain.saveIdentityKey(Data(repeating: 0x01, count: 32), forKey: "identityCacheEncryptionKey")
_ = keychain.saveIdentityKey(Data([0xFF, 0x00, 0xAA]), forKey: "bitchat.identityCache.v2")
let manager = SecureIdentityStateManager(keychain)
XCTAssertTrue(manager.getFavorites().isEmpty)
XCTAssertTrue(manager.getVerifiedFingerprints().isEmpty)
XCTAssertTrue(manager.getBlockedNostrPubkeys().isEmpty)
}
func test_clearAllIdentityData_removesCachedState() async {
let manager = SecureIdentityStateManager(MockKeychain())
let fingerprint = String(repeating: "12", count: 32)
manager.setFavorite(fingerprint, isFavorite: true)
manager.setVerified(fingerprint: fingerprint, verified: true)
manager.setNostrBlocked("ABCD", isBlocked: true)
let primed = await waitUntil {
manager.isFavorite(fingerprint: fingerprint) &&
manager.isVerified(fingerprint: fingerprint)
}
XCTAssertTrue(primed)
manager.clearAllIdentityData()
let cleared = await waitUntil {
!manager.isFavorite(fingerprint: fingerprint) &&
!manager.isVerified(fingerprint: fingerprint) &&
manager.getBlockedNostrPubkeys().isEmpty
}
XCTAssertTrue(cleared)
}
func test_forceSave_withFailingCacheWriteDoesNotPersistCache() async {
let keychain = FailingCacheSaveKeychain()
let manager = SecureIdentityStateManager(keychain)
let fingerprint = String(repeating: "de", count: 32)
manager.setFavorite(fingerprint, isFavorite: true)
let primed = await waitUntil { manager.isFavorite(fingerprint: fingerprint) }
XCTAssertTrue(primed)
manager.forceSave()
let reloaded = SecureIdentityStateManager(keychain)
XCTAssertFalse(reloaded.isFavorite(fingerprint: fingerprint))
}
private func waitUntil(
timeout: TimeInterval = 1.0,
condition: @escaping () -> Bool
) async -> Bool {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if condition() {
return true
}
try? await Task.sleep(nanoseconds: 10_000_000)
}
return condition()
}
}
private final class FailingCacheSaveKeychain: KeychainManagerProtocol {
private var storage: [String: Data] = [:]
private var serviceStorage: [String: [String: Data]] = [:]
func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool {
if key == "bitchat.identityCache.v2" {
return false
}
storage[key] = keyData
return true
}
func getIdentityKey(forKey key: String) -> Data? {
storage[key]
}
func deleteIdentityKey(forKey key: String) -> Bool {
storage.removeValue(forKey: key)
return true
}
func deleteAllKeychainData() -> Bool {
storage.removeAll()
serviceStorage.removeAll()
return true
}
func secureClear(_ data: inout Data) {
data = Data()
}
func secureClear(_ string: inout String) {
string = ""
}
func verifyIdentityKeyExists() -> Bool {
storage["identity_noiseStaticKey"] != nil
}
func getIdentityKeyWithResult(forKey key: String) -> KeychainReadResult {
if let data = storage[key] {
return .success(data)
}
return .itemNotFound
}
func saveIdentityKeyWithResult(_ keyData: Data, forKey key: String) -> KeychainSaveResult {
if saveIdentityKey(keyData, forKey: key) {
return .success
}
return .otherError(OSStatus(-1))
}
func save(key: String, data: Data, service: String, accessible: CFString?) {
if serviceStorage[service] == nil {
serviceStorage[service] = [:]
}
serviceStorage[service]?[key] = data
}
func load(key: String, service: String) -> Data? {
serviceStorage[service]?[key]
}
func delete(key: String, service: String) {
serviceStorage[service]?.removeValue(forKey: key)
}
}