1// MLSGroupManagerTests.swift2// SophaxChatCoreTests3//4// Unit tests for MLSGroupManager — exercises the full mls-rs Rust layer5// via the UniFFI Swift bindings in-process.6//7// Scenarios:8// 1. Two-party group: Alice creates, Bob joins via Welcome → bidirectional encrypt/decrypt9// 2. Three-party group: Carol is added → all three can communicate10// 3. Removal: Bob is removed → Bob can no longer decrypt new messages1112import Testing13import Foundation14import CryptoKit15@testable import SophaxChatCore1617// MARK: - Helpers1819/// Deterministic 32-byte signing key seed from a label.20private func signingKey(label: String)-> Data {21 let hash = SHA256.hash(data: Data(label.utf8))22 return Data(hash)23}2425/// Fresh MLSGroupManager backed by an in-memory KeychainManager substitute.26private func makeManager(peerID: String) throws -> MLSGroupManager {27 let keychain = KeychainManager(service:"com.sophax.test.\(UUID().uuidString)")28 return try MLSGroupManager(29 peerID: peerID,30 signingKeyBytes: signingKey(label: peerID),31 keychain: keychain32)33}3435// MARK: - Tests3637@Suite("MLSGroupManager")38struct MLSGroupManagerTests {3940// MARK: Two-party: Alice creates, Bob joins4142@Test("Two-party group: bidirectional encrypt/decrypt")43 func twoPartyBidirectional() async throws {44 let alice = try makeManager(peerID:"alice")45 let bob = try makeManager(peerID:"bob")4647 let groupID = UUID().uuidString4849// Bob generates a KeyPackage so Alice can invite him50 let bobKP = try await bob.generateKeyPackage()5152// Alice creates the group with Bob53 let createOut = try await alice.createGroup(groupID: groupID, memberKeyPackages:[bobKP])54#expect(createOut.welcomes.count == 1)5556 let bobWelcome = createOut.welcomes[0]57#expect(bobWelcome.peerId =="bob")5859// Bob processes the Welcome (ratchetTreeBytes is always present from mls-rs)60 try await bob.processWelcome(bobWelcome.welcomeBytes, ratchetTree: bobWelcome.ratchetTreeBytes)61 try await bob.commitWelcomeState(groupID: groupID)6263// Alice → Bob64 let msg1 = Data("Hello Bob!".utf8)65 let ct1 = try await alice.encrypt(groupID: groupID, plaintext: msg1)66 let pt1 = try await bob.decrypt(groupID: groupID, ciphertext: ct1)67#expect(pt1 == msg1)6869// Bob → Alice70 let msg2 = Data("Hello Alice!".utf8)71 let ct2 = try await bob.encrypt(groupID: groupID, plaintext: msg2)72 let pt2 = try await alice.decrypt(groupID: groupID, ciphertext: ct2)73#expect(pt2 == msg2)7475// Multiple messages in sequence76 for i in 0..<5 {77 let m = Data("msg-\(i)".utf8)78 let c = try await alice.encrypt(groupID: groupID, plaintext: m)79 let d = try await bob.decrypt(groupID: groupID, ciphertext: c)80#expect(d == m)81}82}8384// MARK: Three-party: Carol is added8586@Test("Three-party group: add member and communicate")87 func threePartyAdd() async throws {88 let alice = try makeManager(peerID:"alice")89 let bob = try makeManager(peerID:"bob")90 let carol = try makeManager(peerID:"carol")9192 let groupID = UUID().uuidString9394// Alice creates group with Bob95 let bobKP = try await bob.generateKeyPackage()96 let createOut = try await alice.createGroup(groupID: groupID, memberKeyPackages:[bobKP])97 let bobWelcome = createOut.welcomes[0]98 try await bob.processWelcome(bobWelcome.welcomeBytes, ratchetTree: bobWelcome.ratchetTreeBytes)99 try await bob.commitWelcomeState(groupID: groupID)100101// Alice adds Carol (coordinator role)102 let carolKP = try await carol.generateKeyPackage()103 let addOut = try await alice.addMember(groupID: groupID, keyPackage: carolKP)104105// Bob processes the Commit from Alice106 let commitResult = try await bob.processCommit(groupID: groupID, commitBytes: addOut.commitBytes)107#expect(commitResult.addedPeerIds.contains("carol"))108109// Carol processes her Welcome (welcomeBytes is non-nil for AddMember commits)110 guard let carolWelcomeBytes = addOut.welcomeBytes else {111 Issue.record("Expected welcomeBytes in AddMember CommitOutput")112 return113}114 try await carol.processWelcome(carolWelcomeBytes, ratchetTree: nil)115 try await carol.commitWelcomeState(groupID: groupID)116117// All three can now communicate118 let msgA = Data("from alice".utf8)119 let ctA = try await alice.encrypt(groupID: groupID, plaintext: msgA)120#expect(try await bob.decrypt(groupID: groupID, ciphertext: ctA)== msgA)121#expect(try await carol.decrypt(groupID: groupID, ciphertext: ctA)== msgA)122123 let msgC = Data("from carol".utf8)124 let ctC = try await carol.encrypt(groupID: groupID, plaintext: msgC)125#expect(try await alice.decrypt(groupID: groupID, ciphertext: ctC)== msgC)126#expect(try await bob.decrypt(groupID: groupID, ciphertext: ctC)== msgC)127}128129// MARK: Removal: Bob removed, can no longer decrypt130131@Test("Remove member: removed peer cannot decrypt new messages")132 func removeMember() async throws {133 let alice = try makeManager(peerID:"alice")134 let bob = try makeManager(peerID:"bob")135136 let groupID = UUID().uuidString137138 let bobKP = try await bob.generateKeyPackage()139 let createOut = try await alice.createGroup(groupID: groupID, memberKeyPackages:[bobKP])140 let bobWelcome = createOut.welcomes[0]141 try await bob.processWelcome(bobWelcome.welcomeBytes, ratchetTree: bobWelcome.ratchetTreeBytes)142 try await bob.commitWelcomeState(groupID: groupID)143144// Pre-removal: Bob can decrypt Alice145 let preMsgA = Data("pre-removal".utf8)146 let preCtA = try await alice.encrypt(groupID: groupID, plaintext: preMsgA)147#expect(try await bob.decrypt(groupID: groupID, ciphertext: preCtA)== preMsgA)148149// Alice removes Bob (coordinator)150 let removeOut = try await alice.removeMember(groupID: groupID, peerID:"bob")151#expect(removeOut.newEpoch > 0)152153// Post-removal: Bob's state is stale — decrypting a new message should throw154 let postMsgA = Data("post-removal secret".utf8)155 let postCtA = try await alice.encrypt(groupID: groupID, plaintext: postMsgA)156 await #expect(throws:(any Error).self){157 try await bob.decrypt(groupID: groupID, ciphertext: postCtA)158}159}160161// MARK: Epoch increments on commit162163@Test("Epoch increments after each commit")164 func epochIncrements() async throws {165 let alice = try makeManager(peerID:"alice")166 let bob = try makeManager(peerID:"bob")167168 let groupID = UUID().uuidString169 let bobKP = try await bob.generateKeyPackage()170 let createOut = try await alice.createGroup(groupID: groupID, memberKeyPackages:[bobKP])171 let bobWelcome = createOut.welcomes[0]172 try await bob.processWelcome(bobWelcome.welcomeBytes, ratchetTree: bobWelcome.ratchetTreeBytes)173 try await bob.commitWelcomeState(groupID: groupID)174175// Remove Bob — epoch should increment176 let removeOut = try await alice.removeMember(groupID: groupID, peerID:"bob")177#expect(removeOut.newEpoch >= 1)178}179180// MARK: KeyPackage generation181182@Test("generateKeyPackage returns non-empty data")183 func generateKeyPackage() async throws {184 let alice = try makeManager(peerID:"alice")185 let kp = try await alice.generateKeyPackage()186#expect(!kp.isEmpty)187}188189// MARK: hasGroup / deleteGroupState190191@Test("hasGroup and deleteGroupState lifecycle")192 func groupLifecycle() async throws {193 let alice = try makeManager(peerID:"alice")194 let bob = try makeManager(peerID:"bob")195196 let groupID = UUID().uuidString197#expect(await alice.hasGroup(groupID: groupID)== false)198199 let bobKP = try await bob.generateKeyPackage()200 _ = try await alice.createGroup(groupID: groupID, memberKeyPackages:[bobKP])201#expect(await alice.hasGroup(groupID: groupID)== true)202203 try await alice.deleteGroupState(groupID: groupID)204#expect(await alice.hasGroup(groupID: groupID)== false)205}206}