// Copyright © 2024 Rangeproof Pty Ltd. All rights reserved. import Foundation import GRDB import SessionUtil import SessionUtilitiesKit import Quick import Nimble @testable import SessionSnodeKit @testable import SessionMessagingKit class LibSessionGroupMembersSpec: QuickSpec { override class func spec() { // MARK: Configuration @TestState var dependencies: TestDependencies! = TestDependencies { dependencies in dependencies.dateNow = Date(timeIntervalSince1970: 1234567890) dependencies.forceSynchronous = true } @TestState(cache: .general, in: dependencies) var mockGeneralCache: MockGeneralCache! = MockGeneralCache( initialSetup: { cache in cache.when { $0.sessionId }.thenReturn(SessionId(.standard, hex: TestConstants.publicKey)) } ) @TestState(singleton: .storage, in: dependencies) var mockStorage: Storage! = SynchronousStorage( customWriter: try! DatabaseQueue(), migrationTargets: [ SNUtilitiesKit.self, SNMessagingKit.self ], using: dependencies, initialData: { db in try Identity(variant: .x25519PublicKey, data: Data(hex: TestConstants.publicKey)).insert(db) try Identity(variant: .x25519PrivateKey, data: Data(hex: TestConstants.privateKey)).insert(db) try Identity(variant: .ed25519PublicKey, data: Data(hex: TestConstants.edPublicKey)).insert(db) try Identity(variant: .ed25519SecretKey, data: Data(hex: TestConstants.edSecretKey)).insert(db) } ) @TestState(singleton: .network, in: dependencies) var mockNetwork: MockNetwork! = MockNetwork( initialSetup: { network in network .when { $0.send(.any, to: .any, requestTimeout: .any, requestAndPathBuildTimeout: .any) } .thenReturn(MockNetwork.response(data: Data([1, 2, 3]))) } ) @TestState(singleton: .jobRunner, in: dependencies) var mockJobRunner: MockJobRunner! = MockJobRunner( initialSetup: { jobRunner in jobRunner .when { $0.add(.any, job: .any, dependantJob: .any, canStartJob: .any) } .thenReturn(nil) jobRunner .when { $0.upsert(.any, job: .any, canStartJob: .any) } .thenReturn(nil) jobRunner .when { $0.jobInfoFor(jobs: .any, state: .any, variant: .any) } .thenReturn([:]) } ) @TestState var createGroupOutput: LibSession.CreatedGroupInfo! = { mockStorage.write { db in try LibSession.createGroup( db, name: "TestGroup", description: nil, displayPictureUrl: nil, displayPictureFilename: nil, displayPictureEncryptionKey: nil, members: [], using: dependencies ) } }() @TestState(cache: .libSession, in: dependencies) var mockLibSessionCache: MockLibSessionCache! = MockLibSessionCache( initialSetup: { cache in var conf: UnsafeMutablePointer! var secretKey: [UInt8] = Array(Data(hex: TestConstants.edSecretKey)) _ = user_groups_init(&conf, &secretKey, nil, 0, nil) cache.when { $0.setConfig(for: .any, sessionId: .any, to: .any) }.thenReturn(()) cache.when { $0.config(for: .userGroups, sessionId: .any) } .thenReturn(.userGroups(conf)) cache.when { $0.config(for: .groupInfo, sessionId: .any) } .thenReturn(createGroupOutput.groupState[.groupInfo]) cache.when { $0.config(for: .groupMembers, sessionId: .any) } .thenReturn(createGroupOutput.groupState[.groupMembers]) cache.when { $0.config(for: .groupKeys, sessionId: .any) } .thenReturn(createGroupOutput.groupState[.groupKeys]) cache.when { try $0.performAndPushChange(.any, for: .any, sessionId: .any, change: { _ in }) }.thenReturn(nil) cache.when { $0.pinnedPriority(.any, threadId: .any, threadVariant: .any) } .thenReturn(LibSession.defaultNewThreadPriority) cache.when { $0.disappearingMessagesConfig(threadId: .any, threadVariant: .any) } .thenReturn(nil) cache.when { $0.isAdmin(groupSessionId: .any) }.thenReturn(true) } ) // MARK: - LibSessionGroupMembers describe("LibSessionGroupMembers") { // MARK: -- when handling a GROUP_MEMBERS update context("when handling a GROUP_MEMBERS update") { @TestState var latestGroup: ClosedGroup? beforeEach { mockStorage.write { db in try SessionThread.upsert( db, id: createGroupOutput.group.threadId, variant: .group, values: SessionThread.TargetValues( creationDateTimestamp: .setTo(1234567890), shouldBeVisible: .setTo(true) ), using: dependencies ) try createGroupOutput.group.insert(db) try createGroupOutput.members.forEach { try $0.insert(db) } } mockLibSessionCache.when { $0.configNeedsDump(.any) }.thenReturn(true) createGroupOutput.groupState[.groupMembers]?.conf.map { var cMemberId: [CChar] = "05\(TestConstants.publicKey)".cString(using: .utf8)! var member: config_group_member = config_group_member() expect(groups_members_get_or_construct($0, &member, &cMemberId)).to(beTrue()) member.admin = true member.invited = 0 member.promoted = 0 groups_members_set($0, &member) } } // MARK: ---- does nothing if there are no changes it("does nothing if there are no changes") { mockLibSessionCache.when { $0.configNeedsDump(.any) }.thenReturn(false) mockStorage.write { db in try mockLibSessionCache.handleGroupMembersUpdate( db, in: createGroupOutput.groupState[.groupMembers], groupSessionId: SessionId(.group, hex: createGroupOutput.group.threadId), serverTimestampMs: 1234567891000 ) } latestGroup = mockStorage.read { db in try ClosedGroup.fetchOne(db, id: createGroupOutput.group.threadId) } expect(createGroupOutput.groupState[.groupMembers]).toNot(beNil()) expect(createGroupOutput.group).to(equal(latestGroup)) } // MARK: ---- throws if the config is invalid it("throws if the config is invalid") { mockStorage.write { db in expect { try mockLibSessionCache.handleGroupMembersUpdate( db, in: createGroupOutput.groupState[.groupInfo]!, groupSessionId: createGroupOutput.groupSessionId, serverTimestampMs: 1234567891000 ) } .to(throwError()) } } // MARK: ---- updates a standard member entry to an accepted admin it("updates a standard member entry to an accepted admin") { mockStorage.write { db in try GroupMember( groupId: createGroupOutput.groupSessionId.hexString, profileId: "05\(TestConstants.publicKey)", role: .standard, roleStatus: .accepted, isHidden: false ).upsert(db) } mockStorage.write { db in try mockLibSessionCache.handleGroupMembersUpdate( db, in: createGroupOutput.groupState[.groupMembers], groupSessionId: createGroupOutput.groupSessionId, serverTimestampMs: 1234567891000 ) } let members: [GroupMember]? = mockStorage.read { db in try GroupMember.fetchAll(db) } expect(members?.count).to(equal(1)) expect(members?.first?.role).to(equal(.admin)) expect(members?.first?.roleStatus).to(equal(.accepted)) } // MARK: ---- updates a failed admin entry to an accepted admin it("updates a failed admin entry to an accepted admin") { mockStorage.write { db in try GroupMember( groupId: createGroupOutput.groupSessionId.hexString, profileId: "05\(TestConstants.publicKey)", role: .admin, roleStatus: .failed, isHidden: false ).upsert(db) } mockStorage.write { db in try mockLibSessionCache.handleGroupMembersUpdate( db, in: createGroupOutput.groupState[.groupMembers], groupSessionId: createGroupOutput.groupSessionId, serverTimestampMs: 1234567891000 ) } let members: [GroupMember]? = mockStorage.read { db in try GroupMember.fetchAll(db) } expect(members?.count).to(equal(1)) expect(members?.first?.role).to(equal(.admin)) expect(members?.first?.roleStatus).to(equal(.accepted)) } // MARK: ---- updates a pending admin entry to an accepted admin it("updates a pending admin entry to an accepted admin") { mockStorage.write { db in try GroupMember( groupId: createGroupOutput.groupSessionId.hexString, profileId: "05\(TestConstants.publicKey)", role: .admin, roleStatus: .pending, isHidden: false ).upsert(db) } mockStorage.write { db in try mockLibSessionCache.handleGroupMembersUpdate( db, in: createGroupOutput.groupState[.groupMembers], groupSessionId: createGroupOutput.groupSessionId, serverTimestampMs: 1234567891000 ) } let members: [GroupMember]? = mockStorage.read { db in try GroupMember.fetchAll(db) } expect(members?.count).to(equal(1)) expect(members?.first?.role).to(equal(.admin)) expect(members?.first?.roleStatus).to(equal(.accepted)) } } } } } // MARK: - Convenience private extension LibSession.Config { var conf: UnsafeMutablePointer? { switch self { case .userProfile(let conf), .contacts(let conf), .convoInfoVolatile(let conf), .userGroups(let conf), .groupInfo(let conf), .groupMembers(let conf): return conf default: return nil } } }