// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. import Foundation import GRDB import Sodium import SessionUtil import SessionUtilitiesKit import Quick import Nimble @testable import SessionSnodeKit @testable import SessionMessagingKit class SessionUtilSpec: QuickSpec { override class func spec() { // MARK: Configuration @TestState var dependencies: TestDependencies! = TestDependencies { dependencies in dependencies.dateNow = Date(timeIntervalSince1970: 1234567890) dependencies.forceSynchronous = true dependencies.setMockableValue(JSONEncoder.OutputFormatting.sortedKeys) // Deterministic ordering } @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: .crypto, in: dependencies) var mockCrypto: MockCrypto! = MockCrypto( initialSetup: { crypto in crypto .when { $0.generate(.ed25519KeyPair(seed: .any, using: .any)) } .thenReturn( KeyPair( publicKey: Data.data( fromHex: "cbd569f56fb13ea95a3f0c05c331cc24139c0090feb412069dc49fab34406ece" )!.bytes, secretKey: Data.data( fromHex: "0123456789abcdef0123456789abcdeffedcba9876543210fedcba9876543210" + "cbd569f56fb13ea95a3f0c05c331cc24139c0090feb412069dc49fab34406ece" )!.bytes ) ) crypto .when { try $0.tryGenerate(.signature(message: .any, secretKey: .any)) } .thenReturn( Authentication.Signature.standard(signature: Array("TestSignature".data(using: .utf8)!)) ) } ) @TestState(singleton: .network, in: dependencies) var mockNetwork: MockNetwork! = MockNetwork( initialSetup: { network in network .when { $0.send(.selectedNetworkRequest(.any, to: .any, timeout: .any, using: .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, using: .any) } .thenReturn(nil) } ) @TestState(defaults: .standard, in: dependencies) var mockUserDefaults: MockUserDefaults! = MockUserDefaults( initialSetup: { userDefaults in userDefaults.when { $0.string(forKey: .any) }.thenReturn(nil) } ) @TestState var createGroupOutput: SessionUtil.CreatedGroupInfo! = { mockStorage.write(using: dependencies) { db in try SessionUtil.createGroup( db, name: "TestGroup", description: nil, displayPictureUrl: nil, displayPictureFilename: nil, displayPictureEncryptionKey: nil, members: [], using: dependencies ) } }() @TestState var mockSwarmCache: Set! = [ Snode( address: "test", port: 0, ed25519PublicKey: TestConstants.edPublicKey, x25519PublicKey: TestConstants.publicKey ), Snode( address: "test", port: 1, ed25519PublicKey: TestConstants.edPublicKey, x25519PublicKey: TestConstants.publicKey ), Snode( address: "test", port: 2, ed25519PublicKey: TestConstants.edPublicKey, x25519PublicKey: TestConstants.publicKey ) ] @TestState(cache: .snodeAPI, in: dependencies) var mockSnodeAPICache: MockSnodeAPICache! = MockSnodeAPICache( initialSetup: { cache in cache.when { $0.clockOffsetMs }.thenReturn(0) cache.when { $0.loadedSwarms }.thenReturn([createGroupOutput.groupSessionId.hexString]) cache.when { $0.swarmCache }.thenReturn([createGroupOutput.groupSessionId.hexString: mockSwarmCache]) } ) @TestState(cache: .sessionUtil, in: dependencies) var mockSessionUtilCache: MockSessionUtilCache! = MockSessionUtilCache( 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(Atomic(.object(conf))) cache.when { $0.config(for: .groupInfo, sessionId: .any) } .thenReturn(Atomic(createGroupOutput.groupState[.groupInfo])) cache.when { $0.config(for: .groupMembers, sessionId: .any) } .thenReturn(Atomic(createGroupOutput.groupState[.groupMembers])) cache.when { $0.config(for: .groupKeys, sessionId: .any) } .thenReturn(Atomic(createGroupOutput.groupState[.groupKeys])) } ) @TestState var userGroupsConfig: SessionUtil.Config! // MARK: - SessionUtil describe("SessionUtil") { // MARK: -- when parsing a community url context("when parsing a community url") { // MARK: ---- handles the example urls correctly it("handles the example urls correctly") { let validUrls: [String] = [ [ "https://sessionopengroup.co/r/main?", "public_key=658d29b91892a2389505596b135e76a53db6e11d613a51dbd3d0816adffb231c" ], [ "https://sessionopengroup.co/main?", "public_key=658d29b91892a2389505596b135e76a53db6e11d613a51dbd3d0816adffb231c" ], [ "http://sessionopengroup.co/r/main?", "public_key=658d29b91892a2389505596b135e76a53db6e11d613a51dbd3d0816adffb231c" ], [ "http://sessionopengroup.co/main?", "public_key=658d29b91892a2389505596b135e76a53db6e11d613a51dbd3d0816adffb231c" ], [ "https://143.198.213.225:443/r/main?", "public_key=658d29b91892a2389505596b135e76a53db6e11d613a51dbd3d0816adffb231c" ], [ "https://143.198.213.225:443/main?", "public_key=658d29b91892a2389505596b135e76a53db6e11d613a51dbd3d0816adffb231c" ], [ "http://143.198.213.255:80/main?", "public_key=658d29b91892a2389505596b135e76a53db6e11d613a51dbd3d0816adffb231c" ], [ "http://143.198.213.255:80/r/main?", "public_key=658d29b91892a2389505596b135e76a53db6e11d613a51dbd3d0816adffb231c" ] ].map { $0.joined() } let processedValues: [(room: String, server: String, publicKey: String)] = validUrls .map { SessionUtil.parseCommunity(url: $0) } .compactMap { $0 } let processedRooms: [String] = processedValues.map { $0.room } let processedServers: [String] = processedValues.map { $0.server } let processedPublicKeys: [String] = processedValues.map { $0.publicKey } let expectedRooms: [String] = [String](repeating: "main", count: 8) let expectedServers: [String] = [ "https://sessionopengroup.co", "https://sessionopengroup.co", "http://sessionopengroup.co", "http://sessionopengroup.co", "https://143.198.213.225", "https://143.198.213.225", "http://143.198.213.255", "http://143.198.213.255" ] let expectedPublicKeys: [String] = [String]( repeating: "658d29b91892a2389505596b135e76a53db6e11d613a51dbd3d0816adffb231c", count: 8 ) expect(processedValues.count).to(equal(validUrls.count)) expect(processedRooms).to(equal(expectedRooms)) expect(processedServers).to(equal(expectedServers)) expect(processedPublicKeys).to(equal(expectedPublicKeys)) } // MARK: ---- handles the r prefix if present it("handles the r prefix if present") { let info = SessionUtil.parseCommunity( url: [ "https://sessionopengroup.co/r/main?", "public_key=658d29b91892a2389505596b135e76a53db6e11d613a51dbd3d0816adffb231c" ].joined() ) expect(info?.room).to(equal("main")) expect(info?.server).to(equal("https://sessionopengroup.co")) expect(info?.publicKey).to(equal("658d29b91892a2389505596b135e76a53db6e11d613a51dbd3d0816adffb231c")) } // MARK: ---- fails if no scheme is provided it("fails if no scheme is provided") { let info = SessionUtil.parseCommunity( url: [ "sessionopengroup.co/r/main?", "public_key=658d29b91892a2389505596b135e76a53db6e11d613a51dbd3d0816adffb231c" ].joined() ) expect(info?.room).to(beNil()) expect(info?.server).to(beNil()) expect(info?.publicKey).to(beNil()) } // MARK: ---- fails if there is no room it("fails if there is no room") { let info = SessionUtil.parseCommunity( url: [ "https://sessionopengroup.co?", "public_key=658d29b91892a2389505596b135e76a53db6e11d613a51dbd3d0816adffb231c" ].joined() ) expect(info?.room).to(beNil()) expect(info?.server).to(beNil()) expect(info?.publicKey).to(beNil()) } // MARK: ---- fails if there is no public key parameter it("fails if there is no public key parameter") { let info = SessionUtil.parseCommunity( url: "https://sessionopengroup.co/r/main" ) expect(info?.room).to(beNil()) expect(info?.server).to(beNil()) expect(info?.publicKey).to(beNil()) } // MARK: ---- fails if the public key parameter is not 64 characters it("fails if the public key parameter is not 64 characters") { let info = SessionUtil.parseCommunity( url: [ "https://sessionopengroup.co/r/main?", "public_key=658d29b91892a2389505596b135e76a53db6e11d613a51dbd3d0816adffb231" ].joined() ) expect(info?.room).to(beNil()) expect(info?.server).to(beNil()) expect(info?.publicKey).to(beNil()) } // MARK: ---- fails if the public key parameter is not a hex string it("fails if the public key parameter is not a hex string") { let info = SessionUtil.parseCommunity( url: [ "https://sessionopengroup.co/r/main?", "public_key=!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!" ].joined() ) expect(info?.room).to(beNil()) expect(info?.server).to(beNil()) expect(info?.publicKey).to(beNil()) } // MARK: ---- maintains the same TLS it("maintains the same TLS") { let server1 = SessionUtil.parseCommunity( url: [ "http://sessionopengroup.co/r/main?", "public_key=658d29b91892a2389505596b135e76a53db6e11d613a51dbd3d0816adffb231c" ].joined() )?.server let server2 = SessionUtil.parseCommunity( url: [ "https://sessionopengroup.co/r/main?", "public_key=658d29b91892a2389505596b135e76a53db6e11d613a51dbd3d0816adffb231c" ].joined() )?.server expect(server1).to(equal("http://sessionopengroup.co")) expect(server2).to(equal("https://sessionopengroup.co")) } // MARK: ---- maintains the same port it("maintains the same port") { let server1 = SessionUtil.parseCommunity( url: [ "https://sessionopengroup.co/r/main?", "public_key=658d29b91892a2389505596b135e76a53db6e11d613a51dbd3d0816adffb231c" ].joined() )?.server let server2 = SessionUtil.parseCommunity( url: [ "https://sessionopengroup.co:1234/r/main?", "public_key=658d29b91892a2389505596b135e76a53db6e11d613a51dbd3d0816adffb231c" ].joined() )?.server expect(server1).to(equal("https://sessionopengroup.co")) expect(server2).to(equal("https://sessionopengroup.co:1234")) } } // MARK: -- when generating a url context("when generating a url") { // MARK: ---- generates the url correctly it("generates the url correctly") { expect(SessionUtil.communityUrlFor(server: "server", roomToken: "room", publicKey: "f8fec9b701000000ffffffff0400008000000000000000000000000000000000")) .to(equal("server/room?public_key=f8fec9b701000000ffffffff0400008000000000000000000000000000000000")) } // MARK: ---- maintains the casing provided it("maintains the casing provided") { expect(SessionUtil.communityUrlFor(server: "SeRVer", roomToken: "RoOM", publicKey: "f8fec9b701000000ffffffff0400008000000000000000000000000000000000")) .to(equal("SeRVer/RoOM?public_key=f8fec9b701000000ffffffff0400008000000000000000000000000000000000")) } } // MARK: -- when creating a group context("when creating a group") { beforeEach { var userGroupsConf: UnsafeMutablePointer! var secretKey: [UInt8] = Array(Data(hex: TestConstants.edSecretKey)) _ = user_groups_init(&userGroupsConf, &secretKey, nil, 0, nil) userGroupsConfig = .object(userGroupsConf) mockSessionUtilCache .when { $0.config(for: .userGroups, sessionId: .any) } .thenReturn(Atomic(userGroupsConfig)) } // MARK: ---- throws when there is no user ed25519 keyPair it("throws when there is no user ed25519 keyPair") { var resultError: Error? = nil mockStorage.write { db in try Identity.filter(id: .ed25519PublicKey).deleteAll(db) try Identity.filter(id: .ed25519SecretKey).deleteAll(db) do { _ = try SessionUtil.createGroup( db, name: "Testname", description: nil, displayPictureUrl: nil, displayPictureFilename: nil, displayPictureEncryptionKey: nil, members: [], using: dependencies ) } catch { resultError = error } } expect(resultError).to(matchError(MessageSenderError.noKeyPair)) } // MARK: ---- throws when it fails to generate a new identity ed25519 keyPair it("throws when it fails to generate a new identity ed25519 keyPair") { var resultError: Error? = nil mockCrypto .when { $0.generate(.ed25519KeyPair(seed: .any, using: .any)) } .thenReturn(nil) mockStorage.write { db in do { _ = try SessionUtil.createGroup( db, name: "Testname", description: nil, displayPictureUrl: nil, displayPictureFilename: nil, displayPictureEncryptionKey: nil, members: [], using: dependencies ) } catch { resultError = error } } expect(resultError).to(matchError(MessageSenderError.noKeyPair)) } // MARK: ---- throws when given an invalid member id it("throws when given an invalid member id") { var resultError: Error? = nil mockStorage.write { db in do { _ = try SessionUtil.createGroup( db, name: "Testname", description: nil, displayPictureUrl: nil, displayPictureFilename: nil, displayPictureEncryptionKey: nil, members: [( id: "123456", profile: Profile( id: "123456", name: "" ) )], using: dependencies ) } catch { resultError = error } } expect(resultError).to(matchError( NSError( domain: "cpp_exception", code: -2, userInfo: [ NSLocalizedDescriptionKey: "Invalid session ID: expected 66 hex digits starting with 05; got 123456" ] ) )) } // MARK: ---- returns the correct identity keyPair it("returns the correct identity keyPair") { createGroupOutput = mockStorage.write(using: dependencies) { db in try SessionUtil.createGroup( db, name: "Testname", description: nil, displayPictureUrl: nil, displayPictureFilename: nil, displayPictureEncryptionKey: nil, members: [], using: dependencies ) } expect(createGroupOutput.identityKeyPair.publicKey.toHexString()) .to(equal("cbd569f56fb13ea95a3f0c05c331cc24139c0090feb412069dc49fab34406ece")) expect(createGroupOutput.identityKeyPair.secretKey.toHexString()) .to(equal( "0123456789abcdef0123456789abcdeffedcba9876543210fedcba9876543210" + "cbd569f56fb13ea95a3f0c05c331cc24139c0090feb412069dc49fab34406ece" )) } // MARK: ---- returns a closed group with the correct data set it("returns a closed group with the correct data set") { createGroupOutput = mockStorage.write(using: dependencies) { db in try SessionUtil.createGroup( db, name: "Testname", description: nil, displayPictureUrl: "TestUrl", displayPictureFilename: "TestFilename", displayPictureEncryptionKey: Data([1, 2, 3]), members: [], using: dependencies ) } expect(createGroupOutput.group.threadId) .to(equal("03cbd569f56fb13ea95a3f0c05c331cc24139c0090feb412069dc49fab34406ece")) expect(createGroupOutput.group.groupIdentityPrivateKey?.toHexString()) .to(equal( "0123456789abcdef0123456789abcdeffedcba9876543210fedcba9876543210" + "cbd569f56fb13ea95a3f0c05c331cc24139c0090feb412069dc49fab34406ece" )) expect(createGroupOutput.group.name).to(equal("Testname")) expect(createGroupOutput.group.displayPictureUrl).to(equal("TestUrl")) expect(createGroupOutput.group.displayPictureFilename).to(equal("TestFilename")) expect(createGroupOutput.group.displayPictureEncryptionKey).to(equal(Data([1, 2, 3]))) expect(createGroupOutput.group.formationTimestamp).to(equal(1234567890)) expect(createGroupOutput.group.invited).to(beFalse()) } // MARK: ---- returns the members setup correctly it("returns the members setup correctly") { createGroupOutput = mockStorage.write(using: dependencies) { db in try SessionUtil.createGroup( db, name: "Testname", description: nil, displayPictureUrl: nil, displayPictureFilename: nil, displayPictureEncryptionKey: nil, members: [( id: "051111111111111111111111111111111111111111111111111111111111111111", profile: Profile( id: "051111111111111111111111111111111111111111111111111111111111111111", name: "TestName", profilePictureUrl: "testUrl", profileEncryptionKey: Data([1, 2, 3]) ) )], using: dependencies ) } expect(createGroupOutput.members.count).to(equal(2)) expect(createGroupOutput.members.map { $0.groupId }) .to(equal([ "03cbd569f56fb13ea95a3f0c05c331cc24139c0090feb412069dc49fab34406ece", "03cbd569f56fb13ea95a3f0c05c331cc24139c0090feb412069dc49fab34406ece", ])) expect(createGroupOutput.members.map { $0.profileId }.asSet()) .to(equal([ "051111111111111111111111111111111111111111111111111111111111111111", "05\(TestConstants.publicKey)" ])) expect(createGroupOutput.members.map { $0.role }.asSet()) .to(equal([ .standard, .admin ])) expect(createGroupOutput.members.map { $0.isHidden }.asSet()) .to(equal([ false, false ])) } // MARK: ---- adds the current user as an admin when not provided it("adds the current user as an admin when not provided") { createGroupOutput = mockStorage.write(using: dependencies) { db in try SessionUtil.createGroup( db, name: "Testname", description: nil, displayPictureUrl: nil, displayPictureFilename: nil, displayPictureEncryptionKey: nil, members: [( id: "051111111111111111111111111111111111111111111111111111111111111111", profile: Profile( id: "051111111111111111111111111111111111111111111111111111111111111111", name: "TestName" ) )], using: dependencies ) } expect(createGroupOutput.members.map { $0.groupId }) .to(contain("03cbd569f56fb13ea95a3f0c05c331cc24139c0090feb412069dc49fab34406ece")) expect(createGroupOutput.members.map { $0.profileId }) .to(contain("05\(TestConstants.publicKey)")) expect(createGroupOutput.members.map { $0.role }).to(contain(.admin)) } // MARK: ---- handles members without profile data correctly it("handles members without profile data correctly") { createGroupOutput = mockStorage.write(using: dependencies) { db in try SessionUtil.createGroup( db, name: "Testname", description: nil, displayPictureUrl: nil, displayPictureFilename: nil, displayPictureEncryptionKey: nil, members: [( id: "051111111111111111111111111111111111111111111111111111111111111111", profile: nil )], using: dependencies ) } expect(createGroupOutput.members.count).to(equal(2)) expect(createGroupOutput.members.map { $0.groupId }) .to(contain("03cbd569f56fb13ea95a3f0c05c331cc24139c0090feb412069dc49fab34406ece")) expect(createGroupOutput.members.map { $0.profileId }) .to(contain("051111111111111111111111111111111111111111111111111111111111111111")) expect(createGroupOutput.members.map { $0.role }).to(contain(.standard)) } // MARK: ---- stores the config states in the cache correctly it("stores the config states in the cache correctly") { createGroupOutput = mockStorage.write(using: dependencies) { db in try SessionUtil.createGroup( db, name: "Testname", description: nil, displayPictureUrl: nil, displayPictureFilename: nil, displayPictureEncryptionKey: nil, members: [( id: "051111111111111111111111111111111111111111111111111111111111111111", profile: nil )], using: dependencies ) } expect(mockSessionUtilCache).to(call(.exactly(times: 3)) { $0.setConfig(for: .any, sessionId: .any, to: .any) }) expect(mockSessionUtilCache) .to(call(matchingParameters: .atLeast(2)) { $0.setConfig( for: .groupInfo, sessionId: SessionId( .group, hex: "cbd569f56fb13ea95a3f0c05c331cc24139c0090feb412069dc49fab34406ece" ), to: .any ) }) expect(mockSessionUtilCache) .to(call(matchingParameters: .atLeast(2)) { $0.setConfig( for: .groupMembers, sessionId: SessionId( .group, hex: "cbd569f56fb13ea95a3f0c05c331cc24139c0090feb412069dc49fab34406ece" ), to: .any ) }) expect(mockSessionUtilCache) .to(call(matchingParameters: .atLeast(2)) { $0.setConfig( for: .groupKeys, sessionId: SessionId( .group, hex: "cbd569f56fb13ea95a3f0c05c331cc24139c0090feb412069dc49fab34406ece" ), to: .any ) }) } } // MARK: -- when saving a created a group context("when saving a created a group") { // MARK: ---- saves config dumps for the stored configs it("saves config dumps for the stored configs") { mockStorage.write(using: dependencies) { db in createGroupOutput = try SessionUtil.createGroup( db, name: "Testname", description: nil, displayPictureUrl: nil, displayPictureFilename: nil, displayPictureEncryptionKey: nil, members: [( id: "051111111111111111111111111111111111111111111111111111111111111111", profile: nil )], using: dependencies ) try SessionUtil.saveCreatedGroup( db, group: createGroupOutput.group, groupState: createGroupOutput.groupState, using: dependencies ) } let result: [ConfigDump]? = mockStorage.read(using: dependencies) { db in try ConfigDump.fetchAll(db) } expect(result?.map { $0.variant }.asSet()) .to(contain([.groupInfo, .groupKeys, .groupMembers])) expect(result?.map { $0.sessionId }.asSet()) .to(contain([ SessionId( .group, hex: "cbd569f56fb13ea95a3f0c05c331cc24139c0090feb412069dc49fab34406ece" ) ])) expect(result?.map { $0.timestampMs }.asSet()) .to(contain([1234567890000])) } // MARK: ---- adds the group to the user groups config it("adds the group to the user groups config") { mockStorage.write(using: dependencies) { db in createGroupOutput = try SessionUtil.createGroup( db, name: "Testname", description: nil, displayPictureUrl: nil, displayPictureFilename: nil, displayPictureEncryptionKey: nil, members: [( id: "051111111111111111111111111111111111111111111111111111111111111111", profile: nil )], using: dependencies ) try SessionUtil.saveCreatedGroup( db, group: createGroupOutput.group, groupState: createGroupOutput.groupState, using: dependencies ) } let result: [ConfigDump]? = mockStorage.read(using: dependencies) { db in try ConfigDump.fetchAll(db) } expect(result?.map { $0.variant }.asSet()).to(contain([.userGroups])) expect(result?.map { $0.timestampMs }.asSet()).to(contain([1234567890000])) } } // MARK: -- when receiving a GROUP_INFO update context("when receiving a GROUP_INFO update") { @TestState var latestGroup: ClosedGroup? @TestState var initialDisappearingConfig: DisappearingMessagesConfiguration? @TestState var latestDisappearingConfig: DisappearingMessagesConfiguration? beforeEach { mockStorage.write(using: dependencies) { db in try SessionThread.fetchOrCreate( db, id: createGroupOutput.group.threadId, variant: .group, shouldBeVisible: true, calledFromConfigHandling: false, using: dependencies ) try createGroupOutput.group.insert(db) try createGroupOutput.members.forEach { try $0.insert(db) } initialDisappearingConfig = try DisappearingMessagesConfiguration .fetchOne(db, id: createGroupOutput.group.threadId) .defaulting( to: DisappearingMessagesConfiguration.defaultWith(createGroupOutput.group.threadId) ) } } // MARK: ---- does nothing if there are no changes it("does nothing if there are no changes") { dependencies.setMockableValue(key: "needsDump", false) mockStorage.write(using: dependencies) { db in try SessionUtil.handleGroupInfoUpdate( db, in: createGroupOutput.groupState[.groupInfo], groupSessionId: SessionId(.group, hex: createGroupOutput.group.threadId), serverTimestampMs: 1234567891000, using: dependencies ) } latestGroup = mockStorage.read(using: dependencies) { db in try ClosedGroup.fetchOne(db, id: createGroupOutput.group.threadId) } expect(createGroupOutput.groupState[.groupInfo]).toNot(beNil()) expect(createGroupOutput.group).to(equal(latestGroup)) } // MARK: ---- throws if the config is invalid it("throws if the config is invalid") { dependencies.setMockableValue(key: "needsDump", true) mockStorage.write(using: dependencies) { db in expect { try SessionUtil.handleGroupInfoUpdate( db, in: .invalid, groupSessionId: SessionId(.group, hex: createGroupOutput.group.threadId), serverTimestampMs: 1234567891000, using: dependencies ) } .to(throwError()) } } // MARK: ---- removes group data if the group is destroyed it("removes group data if the group is destroyed") { createGroupOutput.groupState[.groupInfo]?.conf.map { groups_info_destroy_group($0) } dependencies.setMockableValue(key: "needsDump", true) mockStorage.write(using: dependencies) { db in try SessionUtil.handleGroupInfoUpdate( db, in: createGroupOutput.groupState[.groupInfo], groupSessionId: SessionId(.group, hex: createGroupOutput.group.threadId), serverTimestampMs: 1234567891000, using: dependencies ) } latestGroup = mockStorage.read(using: dependencies) { db in try ClosedGroup.fetchOne(db, id: createGroupOutput.group.threadId) } expect(latestGroup?.authData).to(beNil()) expect(latestGroup?.groupIdentityPrivateKey).to(beNil()) } // MARK: ---- updates the name if it changed it("updates the name if it changed") { createGroupOutput.groupState[.groupInfo]?.conf.map { var updatedName: [CChar] = "UpdatedName".cArray.nullTerminated() groups_info_set_name($0, &updatedName) } dependencies.setMockableValue(key: "needsDump", true) mockStorage.write(using: dependencies) { db in try SessionUtil.handleGroupInfoUpdate( db, in: createGroupOutput.groupState[.groupInfo], groupSessionId: SessionId(.group, hex: createGroupOutput.group.threadId), serverTimestampMs: 1234567891000, using: dependencies ) } latestGroup = mockStorage.read(using: dependencies) { db in try ClosedGroup.fetchOne(db, id: createGroupOutput.group.threadId) } expect(createGroupOutput.group.name).to(equal("TestGroup")) expect(latestGroup?.name).to(equal("UpdatedName")) } // MARK: ---- updates the description if it changed it("updates the description if it changed") { createGroupOutput.groupState[.groupInfo]?.conf.map { var updatedDesc: [CChar] = "UpdatedDesc".cArray.nullTerminated() groups_info_set_description($0, &updatedDesc) } dependencies.setMockableValue(key: "needsDump", true) mockStorage.write(using: dependencies) { db in try SessionUtil.handleGroupInfoUpdate( db, in: createGroupOutput.groupState[.groupInfo], groupSessionId: SessionId(.group, hex: createGroupOutput.group.threadId), serverTimestampMs: 1234567891000, using: dependencies ) } latestGroup = mockStorage.read(using: dependencies) { db in try ClosedGroup.fetchOne(db, id: createGroupOutput.group.threadId) } expect(createGroupOutput.group.groupDescription).to(beNil()) expect(latestGroup?.groupDescription).to(equal("UpdatedDesc")) } // MARK: ---- updates the formation timestamp if it changed it("updates the formation timestamp if it changed") { createGroupOutput.groupState[.groupInfo]?.conf.map { groups_info_set_created($0, 54321) } dependencies.setMockableValue(key: "needsDump", true) mockStorage.write(using: dependencies) { db in try SessionUtil.handleGroupInfoUpdate( db, in: createGroupOutput.groupState[.groupInfo], groupSessionId: SessionId(.group, hex: createGroupOutput.group.threadId), serverTimestampMs: 1234567891000, using: dependencies ) } latestGroup = mockStorage.read(using: dependencies) { db in try ClosedGroup.fetchOne(db, id: createGroupOutput.group.threadId) } expect(createGroupOutput.group.formationTimestamp).to(equal(1234567890)) expect(latestGroup?.formationTimestamp).to(equal(54321)) } // MARK: ---- and the display picture was changed context("and the display picture was changed") { // MARK: ------ removes the display picture it("removes the display picture") { mockStorage.write(using: dependencies) { db in try ClosedGroup .updateAll( db, ClosedGroup.Columns.displayPictureUrl.set(to: "TestUrl"), ClosedGroup.Columns.displayPictureEncryptionKey.set(to: Data([1, 2, 3])), ClosedGroup.Columns.displayPictureFilename.set(to: "TestFilename") ) } dependencies.setMockableValue(key: "needsDump", true) mockStorage.write(using: dependencies) { db in try SessionUtil.handleGroupInfoUpdate( db, in: createGroupOutput.groupState[.groupInfo], groupSessionId: SessionId(.group, hex: createGroupOutput.group.threadId), serverTimestampMs: 1234567891000, using: dependencies ) } latestGroup = mockStorage.read(using: dependencies) { db in try ClosedGroup.fetchOne(db, id: createGroupOutput.group.threadId) } expect(latestGroup?.displayPictureUrl).to(beNil()) expect(latestGroup?.displayPictureEncryptionKey).to(beNil()) expect(latestGroup?.displayPictureFilename).to(beNil()) expect(latestGroup?.lastDisplayPictureUpdate).to(equal(1234567891)) } // MARK: ------ schedules a display picture download job if there is a new one it("schedules a display picture download job if there is a new one") { createGroupOutput.groupState[.groupInfo]?.conf.map { var displayPic: user_profile_pic = user_profile_pic() displayPic.url = "https://www.oxen.io/file/1234".toLibSession() displayPic.key = Data( repeating: 1, count: DisplayPictureManager.aes256KeyByteLength ).toLibSession() groups_info_set_pic($0, displayPic) } dependencies.setMockableValue(key: "needsDump", true) mockStorage.write(using: dependencies) { db in try SessionUtil.handleGroupInfoUpdate( db, in: createGroupOutput.groupState[.groupInfo], groupSessionId: SessionId(.group, hex: createGroupOutput.group.threadId), serverTimestampMs: 1234567891000, using: dependencies ) } expect(mockJobRunner) .to(call(.exactly(times: 1), matchingParameters: .all) { jobRunner in jobRunner.add( .any, job: Job( variant: .displayPictureDownload, behaviour: .runOnce, shouldBlock: false, shouldBeUnique: true, shouldSkipLaunchBecomeActive: false, details: DisplayPictureDownloadJob.Details( target: .group( id: createGroupOutput.group.threadId, url: "https://www.oxen.io/file/1234", encryptionKey: Data( repeating: 1, count: DisplayPictureManager.aes256KeyByteLength ) ), timestamp: 1234567891 ) ), canStartJob: true, using: .any ) }) } } // MARK: ---- updates the disappearing messages config it("updates the disappearing messages config") { createGroupOutput.groupState[.groupInfo]?.conf.map { groups_info_set_expiry_timer($0, 10) } dependencies.setMockableValue(key: "needsDump", true) mockStorage.write(using: dependencies) { db in try SessionUtil.handleGroupInfoUpdate( db, in: createGroupOutput.groupState[.groupInfo], groupSessionId: SessionId(.group, hex: createGroupOutput.group.threadId), serverTimestampMs: 1234567891000, using: dependencies ) } latestDisappearingConfig = mockStorage.read(using: dependencies) { db in try DisappearingMessagesConfiguration.fetchOne(db, id: createGroupOutput.group.threadId) } expect(initialDisappearingConfig?.isEnabled).to(beFalse()) expect(initialDisappearingConfig?.durationSeconds).to(equal(0)) expect(latestDisappearingConfig?.isEnabled).to(beTrue()) expect(latestDisappearingConfig?.durationSeconds).to(equal(10)) } // MARK: ---- containing a deleteBefore timestamp context("containing a deleteBefore timestamp") { @TestState var numInteractions: Int! // MARK: ------ deletes messages before the timestamp it("deletes messages before the timestamp") { mockStorage.write(using: dependencies) { db in try SessionThread.fetchOrCreate( db, id: createGroupOutput.group.threadId, variant: .contact, shouldBeVisible: true, calledFromConfigHandling: false, using: dependencies ) _ = try Interaction( serverHash: "1234", threadId: createGroupOutput.group.threadId, authorId: "4321", variant: .standardIncoming, timestampMs: 100000000 ).inserted(db) } createGroupOutput.groupState[.groupInfo]?.conf.map { groups_info_set_delete_before($0, 123456) } dependencies.setMockableValue(key: "needsDump", true) mockStorage.write(using: dependencies) { db in try SessionUtil.handleGroupInfoUpdate( db, in: createGroupOutput.groupState[.groupInfo], groupSessionId: SessionId(.group, hex: createGroupOutput.group.threadId), serverTimestampMs: 1234567891000, using: dependencies ) } numInteractions = mockStorage.read(using: dependencies) { db in try Interaction.fetchCount(db) } expect(numInteractions).to(equal(0)) } // MARK: ------ does not delete messages after the timestamp it("does not delete messages after the timestamp") { mockStorage.write(using: dependencies) { db in try SessionThread.fetchOrCreate( db, id: createGroupOutput.group.threadId, variant: .contact, shouldBeVisible: true, calledFromConfigHandling: false, using: dependencies ) _ = try Interaction( serverHash: "1234", threadId: createGroupOutput.group.threadId, authorId: "4321", variant: .standardIncoming, timestampMs: 100000000 ).inserted(db) _ = try Interaction( serverHash: "1235", threadId: createGroupOutput.group.threadId, authorId: "4322", variant: .standardIncoming, timestampMs: 200000000 ).inserted(db) } createGroupOutput.groupState[.groupInfo]?.conf.map { groups_info_set_delete_before($0, 123456) } dependencies.setMockableValue(key: "needsDump", true) mockStorage.write(using: dependencies) { db in try SessionUtil.handleGroupInfoUpdate( db, in: createGroupOutput.groupState[.groupInfo], groupSessionId: SessionId(.group, hex: createGroupOutput.group.threadId), serverTimestampMs: 1234567891000, using: dependencies ) } numInteractions = mockStorage.read(using: dependencies) { db in try Interaction.fetchCount(db) } expect(numInteractions).to(equal(1)) } } // MARK: ---- containing a deleteAttachmentsBefore timestamp context("containing a deleteAttachmentsBefore timestamp") { @TestState var numInteractions: Int! // MARK: ------ deletes messages with attachments before the timestamp it("deletes messages with attachments before the timestamp") { mockStorage.write(using: dependencies) { db in try SessionThread.fetchOrCreate( db, id: createGroupOutput.group.threadId, variant: .contact, shouldBeVisible: true, calledFromConfigHandling: false, using: dependencies ) let interaction: Interaction = try Interaction( serverHash: "1234", threadId: createGroupOutput.group.threadId, authorId: "4321", variant: .standardIncoming, timestampMs: 100000000 ).inserted(db) _ = try Attachment( id: "AttachmentId", variant: .standard, contentType: "Test", byteCount: 1234 ).inserted(db) _ = try InteractionAttachment( albumIndex: 1, interactionId: interaction.id!, attachmentId: "AttachmentId" ).inserted(db) } createGroupOutput.groupState[.groupInfo]?.conf.map { groups_info_set_attach_delete_before($0, 123456) } dependencies.setMockableValue(key: "needsDump", true) mockStorage.write(using: dependencies) { db in try SessionUtil.handleGroupInfoUpdate( db, in: createGroupOutput.groupState[.groupInfo], groupSessionId: SessionId(.group, hex: createGroupOutput.group.threadId), serverTimestampMs: 1234567891000, using: dependencies ) } numInteractions = mockStorage.read(using: dependencies) { db in try Interaction.fetchCount(db) } expect(numInteractions).to(equal(0)) } // MARK: ------ schedules a garbage collection job to clean up the attachments it("schedules a garbage collection job to clean up the attachments") { mockStorage.write(using: dependencies) { db in try SessionThread.fetchOrCreate( db, id: createGroupOutput.group.threadId, variant: .contact, shouldBeVisible: true, calledFromConfigHandling: false, using: dependencies ) let interaction: Interaction = try Interaction( serverHash: "1234", threadId: createGroupOutput.group.threadId, authorId: "4321", variant: .standardIncoming, timestampMs: 100000000 ).inserted(db) _ = try Attachment( id: "AttachmentId", variant: .standard, contentType: "Test", byteCount: 1234 ).inserted(db) _ = try InteractionAttachment( albumIndex: 1, interactionId: interaction.id!, attachmentId: "AttachmentId" ).inserted(db) } createGroupOutput.groupState[.groupInfo]?.conf.map { groups_info_set_attach_delete_before($0, 123456) } dependencies.setMockableValue(key: "needsDump", true) mockStorage.write(using: dependencies) { db in try SessionUtil.handleGroupInfoUpdate( db, in: createGroupOutput.groupState[.groupInfo], groupSessionId: SessionId(.group, hex: createGroupOutput.group.threadId), serverTimestampMs: 1234567891000, using: dependencies ) } expect(mockJobRunner) .to(call(.exactly(times: 1), matchingParameters: .all) { jobRunner in jobRunner.add( .any, job: Job( variant: .garbageCollection, behaviour: .runOnce, shouldBlock: false, shouldBeUnique: false, shouldSkipLaunchBecomeActive: false, details: GarbageCollectionJob.Details( typesToCollect: [.orphanedAttachments, .orphanedAttachmentFiles] ) ), canStartJob: true, using: .any ) }) } // MARK: ------ does not delete messages with attachments after the timestamp it("does not delete messages with attachments after the timestamp") { mockStorage.write(using: dependencies) { db in try SessionThread.fetchOrCreate( db, id: createGroupOutput.group.threadId, variant: .contact, shouldBeVisible: true, calledFromConfigHandling: false, using: dependencies ) let interaction1: Interaction = try Interaction( serverHash: "1234", threadId: createGroupOutput.group.threadId, authorId: "4321", variant: .standardIncoming, timestampMs: 100000000 ).inserted(db) let interaction2: Interaction = try Interaction( serverHash: "1235", threadId: createGroupOutput.group.threadId, authorId: "4321", variant: .standardIncoming, timestampMs: 200000000 ).inserted(db) _ = try Attachment( id: "AttachmentId", variant: .standard, contentType: "Test", byteCount: 1234 ).inserted(db) _ = try Attachment( id: "AttachmentId2", variant: .standard, contentType: "Test", byteCount: 1234 ).inserted(db) _ = try InteractionAttachment( albumIndex: 1, interactionId: interaction1.id!, attachmentId: "AttachmentId" ).inserted(db) _ = try InteractionAttachment( albumIndex: 1, interactionId: interaction2.id!, attachmentId: "AttachmentId2" ).inserted(db) } createGroupOutput.groupState[.groupInfo]?.conf.map { groups_info_set_attach_delete_before($0, 123456) } dependencies.setMockableValue(key: "needsDump", true) mockStorage.write(using: dependencies) { db in try SessionUtil.handleGroupInfoUpdate( db, in: createGroupOutput.groupState[.groupInfo], groupSessionId: SessionId(.group, hex: createGroupOutput.group.threadId), serverTimestampMs: 1234567891000, using: dependencies ) } numInteractions = mockStorage.read(using: dependencies) { db in try Interaction.fetchCount(db) } expect(numInteractions).to(equal(1)) } // MARK: ------ does not delete messages before the timestamp that have no attachments it("does not delete messages before the timestamp that have no attachments") { mockStorage.write(using: dependencies) { db in try SessionThread.fetchOrCreate( db, id: createGroupOutput.group.threadId, variant: .contact, shouldBeVisible: true, calledFromConfigHandling: false, using: dependencies ) let interaction1: Interaction = try Interaction( serverHash: "1234", threadId: createGroupOutput.group.threadId, authorId: "4321", variant: .standardIncoming, timestampMs: 100000000 ).inserted(db) _ = try Interaction( serverHash: "1235", threadId: createGroupOutput.group.threadId, authorId: "4321", variant: .standardIncoming, timestampMs: 200000000 ).inserted(db) _ = try Attachment( id: "AttachmentId", variant: .standard, contentType: "Test", byteCount: 1234 ).inserted(db) _ = try InteractionAttachment( albumIndex: 1, interactionId: interaction1.id!, attachmentId: "AttachmentId" ).inserted(db) } createGroupOutput.groupState[.groupInfo]?.conf.map { groups_info_set_attach_delete_before($0, 123456) } dependencies.setMockableValue(key: "needsDump", true) mockStorage.write(using: dependencies) { db in try SessionUtil.handleGroupInfoUpdate( db, in: createGroupOutput.groupState[.groupInfo], groupSessionId: SessionId(.group, hex: createGroupOutput.group.threadId), serverTimestampMs: 1234567891000, using: dependencies ) } numInteractions = mockStorage.read(using: dependencies) { db in try Interaction.fetchCount(db) } expect(numInteractions).to(equal(1)) } } // MARK: ---- deletes from the server after deleting messages before a given timestamp it("deletes from the server after deleting messages before a given timestamp") { mockStorage.write(using: dependencies) { db in try SessionThread.fetchOrCreate( db, id: createGroupOutput.group.threadId, variant: .contact, shouldBeVisible: true, calledFromConfigHandling: false, using: dependencies ) _ = try Interaction( serverHash: "1234", threadId: createGroupOutput.group.threadId, authorId: "4321", variant: .standardIncoming, timestampMs: 100000000 ).inserted(db) } createGroupOutput.groupState[.groupInfo]?.conf.map { groups_info_set_delete_before($0, 123456) } dependencies.setMockableValue(key: "needsDump", true) mockStorage.write(using: dependencies) { db in try SessionUtil.handleGroupInfoUpdate( db, in: createGroupOutput.groupState[.groupInfo], groupSessionId: SessionId(.group, hex: createGroupOutput.group.threadId), serverTimestampMs: 1234567891000, using: dependencies ) } let expectedRequest: URLRequest = try SnodeAPI .preparedDeleteMessages( serverHashes: ["1234"], requireSuccessfulDeletion: false, authMethod: Authentication.groupAdmin( groupSessionId: createGroupOutput.groupSessionId, ed25519SecretKey: createGroupOutput.identityKeyPair.secretKey ), using: dependencies ) .request expect(mockNetwork) .to(call(.exactly(times: 1), matchingParameters: .all) { [dependencies = dependencies!] network in network.send( .selectedNetworkRequest( expectedRequest.httpBody!, to: dependencies.randomElement(mockSwarmCache)!, timeout: HTTP.defaultTimeout, using: .any ) ) }) } // MARK: ---- does not delete from the server if there is no server hash it("does not delete from the server if there is no server hash") { mockStorage.write(using: dependencies) { db in try SessionThread.fetchOrCreate( db, id: createGroupOutput.group.threadId, variant: .contact, shouldBeVisible: true, calledFromConfigHandling: false, using: dependencies ) _ = try Interaction( threadId: createGroupOutput.group.threadId, authorId: "4321", variant: .standardIncoming, timestampMs: 100000000 ).inserted(db) } createGroupOutput.groupState[.groupInfo]?.conf.map { groups_info_set_delete_before($0, 123456) } dependencies.setMockableValue(key: "needsDump", true) mockStorage.write(using: dependencies) { db in try SessionUtil.handleGroupInfoUpdate( db, in: createGroupOutput.groupState[.groupInfo], groupSessionId: SessionId(.group, hex: createGroupOutput.group.threadId), serverTimestampMs: 1234567891000, using: dependencies ) } let numInteractions: Int? = mockStorage.read(using: dependencies) { db in try Interaction.fetchCount(db) } expect(numInteractions).to(equal(0)) expect(mockNetwork) .toNot(call { network in network.send(.selectedNetworkRequest(.any, to: .any, timeout: .any, using: .any)) }) } } } } } // MARK: - Convenience private extension SessionUtil.Config { var conf: UnsafeMutablePointer? { switch self { case .object(let conf): return conf default: return nil } } }