-
Notifications
You must be signed in to change notification settings - Fork 41
feat: migrate one conversation to MLS - WPB-27447 #5049
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from 5 commits
e05dc0f
a8d506b
6210bc5
ebbc5bc
e586a96
594f2b7
40d7994
61a4e4e
6ef18e8
39586f9
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,191 @@ | ||
| // | ||
| // Wire | ||
| // Copyright (C) 2026 Wire Swiss GmbH | ||
| // | ||
| // This program is free software: you can redistribute it and/or modify | ||
| // it under the terms of the GNU General Public License as published by | ||
| // the Free Software Foundation, either version 3 of the License, or | ||
| // (at your option) any later version. | ||
| // | ||
| // This program is distributed in the hope that it will be useful, | ||
| // but WITHOUT ANY WARRANTY; without even the implied warranty of | ||
| // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | ||
| // GNU General Public License for more details. | ||
| // | ||
| // You should have received a copy of the GNU General Public License | ||
| // along with this program. If not, see http://www.gnu.org/licenses/. | ||
| // | ||
|
|
||
| import CoreData | ||
| import Foundation | ||
|
|
||
| public protocol MigrateConversationToMLSUseCaseProtocol { | ||
|
|
||
| /// Migrates one team group conversation from Proteus or mixed mode to MLS. | ||
| /// | ||
| /// Calling this method for an MLS conversation succeeds without performing any work. | ||
| func invoke( | ||
| conversationID: QualifiedID, | ||
| syncContext: NSManagedObjectContext | ||
| ) async throws | ||
|
|
||
| } | ||
|
|
||
| public final class MigrateConversationToMLSUseCase: MigrateConversationToMLSUseCaseProtocol { | ||
|
|
||
| public enum Failure: Error, Equatable { | ||
| case conversationNotFound | ||
| case unsupportedConversation | ||
| case missingMLSService | ||
| case missingMLSGroupID | ||
| } | ||
|
|
||
| private let actionsProvider: any MLSActionsProviderProtocol | ||
|
|
||
| public convenience init() { | ||
| self.init(actionsProvider: MLSActionsProvider()) | ||
| } | ||
|
|
||
| init(actionsProvider: any MLSActionsProviderProtocol) { | ||
| self.actionsProvider = actionsProvider | ||
| } | ||
|
|
||
| public func invoke( | ||
| conversationID: QualifiedID, | ||
| syncContext: NSManagedObjectContext | ||
| ) async throws { | ||
| let isSyncContext = await syncContext.perform { syncContext.zm_isSyncContext } | ||
| precondition(isSyncContext, "use case should only be accessed on the sync context") | ||
|
|
||
| switch try await messageProtocol(for: conversationID, in: syncContext) { | ||
| case .mls: | ||
| return | ||
|
|
||
| case .mixed: | ||
| try await finaliseMigration(conversationID: conversationID, syncContext: syncContext) | ||
|
|
||
| case .proteus: | ||
| try await startMigration(conversationID: conversationID, syncContext: syncContext) | ||
| try await finaliseMigration(conversationID: conversationID, syncContext: syncContext) | ||
| } | ||
| } | ||
|
|
||
| private func messageProtocol( | ||
| for conversationID: QualifiedID, | ||
| in context: NSManagedObjectContext | ||
| ) async throws -> MessageProtocol { | ||
| try await context.perform { | ||
| guard let conversation = ZMConversation.fetch( | ||
| with: conversationID.uuid, | ||
| domain: conversationID.domain, | ||
| in: context | ||
| ) else { | ||
| throw Failure.conversationNotFound | ||
| } | ||
|
|
||
| let selfUser = ZMUser.selfUser(in: context) | ||
| guard conversation.conversationType == .group, | ||
| let selfTeamIdentifier = selfUser.teamIdentifier, | ||
| conversation.teamRemoteIdentifier == selfTeamIdentifier | ||
| else { | ||
| throw Failure.unsupportedConversation | ||
| } | ||
|
|
||
| return conversation.messageProtocol | ||
| } | ||
| } | ||
|
|
||
| private func startMigration( | ||
| conversationID: QualifiedID, | ||
| syncContext: NSManagedObjectContext | ||
| ) async throws { | ||
| try await updateConversationProtocol( | ||
| conversationID: conversationID, | ||
| to: .mixed, | ||
| syncContext: syncContext | ||
| ) | ||
|
|
||
| let (mlsService, groupID, members) = try await syncContext.perform { | ||
| guard let conversation = ZMConversation.fetch( | ||
| with: conversationID.uuid, | ||
| domain: conversationID.domain, | ||
| in: syncContext | ||
| ) else { | ||
| throw Failure.conversationNotFound | ||
| } | ||
|
|
||
| guard let mlsService = syncContext.mlsService else { | ||
| throw Failure.missingMLSService | ||
| } | ||
|
|
||
| guard let groupID = conversation.mlsGroupID else { | ||
| throw Failure.missingMLSGroupID | ||
| } | ||
|
|
||
| let members = conversation.localParticipants.map { | ||
| MLSUser(from: $0, localDomain: mlsService.localDomain) | ||
| } | ||
|
|
||
| return (mlsService, groupID, members) | ||
| } | ||
|
|
||
| _ = try await mlsService.establishGroup( | ||
| for: groupID, | ||
| with: members, | ||
| removalKeys: nil | ||
| ) | ||
| } | ||
|
|
||
| private func finaliseMigration( | ||
| conversationID: QualifiedID, | ||
| syncContext: NSManagedObjectContext | ||
| ) async throws { | ||
| let (mlsService, groupID) = try await syncContext.perform { | ||
| guard let conversation = ZMConversation.fetch( | ||
| with: conversationID.uuid, | ||
| domain: conversationID.domain, | ||
| in: syncContext | ||
| ) else { | ||
| throw Failure.conversationNotFound | ||
| } | ||
|
|
||
| guard let mlsService = syncContext.mlsService else { | ||
| throw Failure.missingMLSService | ||
| } | ||
|
|
||
| guard let groupID = conversation.mlsGroupID else { | ||
| throw Failure.missingMLSGroupID | ||
| } | ||
|
|
||
| return (mlsService, groupID) | ||
| } | ||
|
|
||
| if try await !mlsService.conversationExists(groupID: groupID) { | ||
| try await mlsService.joinGroup(with: groupID) | ||
| } | ||
|
|
||
| try await updateConversationProtocol( | ||
| conversationID: conversationID, | ||
| to: .mls, | ||
| syncContext: syncContext | ||
| ) | ||
| } | ||
|
|
||
| private func updateConversationProtocol( | ||
| conversationID: QualifiedID, | ||
| to messageProtocol: MessageProtocol, | ||
| syncContext: NSManagedObjectContext | ||
| ) async throws { | ||
| try await actionsProvider.updateConversationProtocol( | ||
| qualifiedID: conversationID, | ||
| messageProtocol: messageProtocol, | ||
| context: syncContext.notificationContext | ||
| ) | ||
|
|
||
| try await actionsProvider.syncConversation( | ||
| qualifiedID: conversationID, | ||
| context: syncContext.notificationContext | ||
| ) | ||
| } | ||
|
|
||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,193 @@ | ||
| // | ||
| // Wire | ||
| // Copyright (C) 2026 Wire Swiss GmbH | ||
| // | ||
| // This program is free software: you can redistribute it and/or modify | ||
| // it under the terms of the GNU General Public License as published by | ||
| // the Free Software Foundation, either version 3 of the License, or | ||
| // (at your option) any later version. | ||
| // | ||
| // This program is distributed in the hope that it will be useful, | ||
| // but WITHOUT ANY WARRANTY; without even the implied warranty of | ||
| // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | ||
| // GNU General Public License for more details. | ||
| // | ||
| // You should have received a copy of the GNU General Public License | ||
| // along with this program. If not, see http://www.gnu.org/licenses/. | ||
| // | ||
|
|
||
| import XCTest | ||
| @testable import WireDataModel | ||
| @testable import WireDataModelSupport | ||
|
|
||
| final class MigrateConversationToMLSUseCaseTests: ZMBaseManagedObjectTest { | ||
|
|
||
| private enum TestFailure: Error, Equatable { | ||
| case groupEstablishment | ||
| } | ||
|
|
||
| private var sut: MigrateConversationToMLSUseCase! | ||
| private var actionsProvider: MockMLSActionsProviderProtocol! | ||
| private var mlsService: MockMLSServiceInterface! | ||
|
|
||
| override func setUp() { | ||
| super.setUp() | ||
|
|
||
| actionsProvider = MockMLSActionsProviderProtocol() | ||
| mlsService = MockMLSServiceInterface() | ||
| mlsService.underlyingLocalDomain = "example.com" | ||
| mlsService.conversationExistsGroupID_MockValue = true | ||
| mlsService.joinGroupWith_MockMethod = { _ in } | ||
| mlsService.establishGroupForWithRemovalKeys_MockValue = .MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519 | ||
|
|
||
| actionsProvider.updateConversationProtocolQualifiedIDMessageProtocolContext_MockMethod = { _, _, _ in } | ||
| actionsProvider.syncConversationQualifiedIDContext_MockMethod = { _, _ in } | ||
|
|
||
| syncMOC.performAndWait { | ||
| syncMOC.mlsService = mlsService | ||
| } | ||
|
|
||
| sut = MigrateConversationToMLSUseCase(actionsProvider: actionsProvider) | ||
| } | ||
|
|
||
| override func tearDown() { | ||
| sut = nil | ||
| actionsProvider = nil | ||
| mlsService = nil | ||
| super.tearDown() | ||
| } | ||
|
|
||
| func testInvoke_MLSConversation_DoesNothing() async throws { | ||
| let conversationID = await createConversation(messageProtocol: .mls) | ||
|
|
||
| try await sut.invoke(conversationID: conversationID, syncContext: syncMOC) | ||
|
|
||
| XCTAssertTrue(actionsProvider.updateConversationProtocolQualifiedIDMessageProtocolContext_Invocations.isEmpty) | ||
| XCTAssertTrue(actionsProvider.syncConversationQualifiedIDContext_Invocations.isEmpty) | ||
| XCTAssertTrue(mlsService.establishGroupForWithRemovalKeys_Invocations.isEmpty) | ||
| XCTAssertTrue(mlsService.conversationExistsGroupID_Invocations.isEmpty) | ||
| } | ||
|
|
||
| func testInvoke_MixedConversation_FinalisesToMLS() async throws { | ||
| let groupID = MLSGroupID.random() | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Semgrep identified an issue, but thinks it may be safe to ignore. Why this might be safe to ignore:
To resolve this comment: 🔧 No guidance has been designated for this issue. Fix according to your organization's approved methods. 💬 Ignore this findingReply with Semgrep commands to ignore this finding.
Alternatively, triage in Semgrep AppSec Platform to ignore the finding created by ios_insecure_random_no_generator. You can view more details about this finding in the Semgrep AppSec Platform.
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. /fp this is used in a test, safe to ignore |
||
| let conversationID = await createConversation(messageProtocol: .mixed, groupID: groupID) | ||
|
|
||
| try await sut.invoke(conversationID: conversationID, syncContext: syncMOC) | ||
|
|
||
| XCTAssertEqual(mlsService.conversationExistsGroupID_Invocations, [groupID]) | ||
| XCTAssertTrue(mlsService.joinGroupWith_Invocations.isEmpty) | ||
| XCTAssertEqual(updatedProtocols, [.mls]) | ||
| XCTAssertEqual(actionsProvider.syncConversationQualifiedIDContext_Invocations.count, 1) | ||
| } | ||
|
|
||
| func testInvoke_MixedConversation_JoinsMissingGroupBeforeFinalising() async throws { | ||
| let groupID = MLSGroupID.random() | ||
|
netbe marked this conversation as resolved.
|
||
| let conversationID = await createConversation(messageProtocol: .mixed, groupID: groupID) | ||
| mlsService.conversationExistsGroupID_MockValue = false | ||
|
|
||
| try await sut.invoke(conversationID: conversationID, syncContext: syncMOC) | ||
|
|
||
| XCTAssertEqual(mlsService.joinGroupWith_Invocations, [groupID]) | ||
| XCTAssertEqual(updatedProtocols, [.mls]) | ||
| } | ||
|
|
||
| func testInvoke_ProteusConversation_MigratesAndFinalisesToMLS() async throws { | ||
| let groupID = MLSGroupID.random() | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Semgrep identified an issue, but thinks it may be safe to ignore. Why this might be safe to ignore:
To resolve this comment: 🔧 No guidance has been designated for this issue. Fix according to your organization's approved methods. 💬 Ignore this findingReply with Semgrep commands to ignore this finding.
Alternatively, triage in Semgrep AppSec Platform to ignore the finding created by ios_insecure_random_no_generator. You can view more details about this finding in the Semgrep AppSec Platform.
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. /fp this is used in a test, safe to ignore |
||
| let conversationID = await createConversation(messageProtocol: .proteus, groupID: groupID) | ||
|
|
||
| try await sut.invoke(conversationID: conversationID, syncContext: syncMOC) | ||
|
|
||
| XCTAssertEqual(updatedProtocols, [.mixed, .mls]) | ||
| XCTAssertEqual(actionsProvider.syncConversationQualifiedIDContext_Invocations.count, 2) | ||
| XCTAssertEqual(mlsService.establishGroupForWithRemovalKeys_Invocations.count, 1) | ||
| XCTAssertEqual(mlsService.establishGroupForWithRemovalKeys_Invocations.first?.groupID, groupID) | ||
| XCTAssertEqual(mlsService.conversationExistsGroupID_Invocations, [groupID]) | ||
| } | ||
|
|
||
| func testInvoke_ProteusConversation_WhenMigrationFails_DoesNotFinalise() async throws { | ||
| let conversationID = await createConversation(messageProtocol: .proteus) | ||
| mlsService.establishGroupForWithRemovalKeys_MockError = TestFailure.groupEstablishment | ||
|
|
||
| await XCTAssertThrowsErrorAsync( | ||
| TestFailure.groupEstablishment, | ||
| when: { | ||
| try await self.sut.invoke( | ||
| conversationID: conversationID, | ||
| syncContext: self.syncMOC | ||
| ) | ||
| } | ||
| ) | ||
|
|
||
| XCTAssertEqual(updatedProtocols, [.mixed]) | ||
| XCTAssertEqual(actionsProvider.syncConversationQualifiedIDContext_Invocations.count, 1) | ||
| XCTAssertTrue(mlsService.conversationExistsGroupID_Invocations.isEmpty) | ||
| } | ||
|
|
||
| func testInvoke_OneOnOneConversation_ThrowsUnsupportedConversation() async throws { | ||
| let conversationID = await createConversation(messageProtocol: .proteus, conversationType: .oneOnOne) | ||
|
|
||
| await XCTAssertThrowsErrorAsync( | ||
| MigrateConversationToMLSUseCase.Failure.unsupportedConversation, | ||
| when: { | ||
| try await self.sut.invoke( | ||
| conversationID: conversationID, | ||
| syncContext: self.syncMOC | ||
| ) | ||
| } | ||
| ) | ||
| } | ||
|
|
||
| func testInvoke_NonTeamGroupConversation_ThrowsUnsupportedConversation() async throws { | ||
| let conversationID = await createConversation(messageProtocol: .proteus, belongsToTeam: false) | ||
|
|
||
| await XCTAssertThrowsErrorAsync( | ||
| MigrateConversationToMLSUseCase.Failure.unsupportedConversation, | ||
| when: { | ||
| try await self.sut.invoke( | ||
| conversationID: conversationID, | ||
| syncContext: self.syncMOC | ||
| ) | ||
| } | ||
| ) | ||
| } | ||
|
|
||
| func testInvoke_MissingConversation_ThrowsConversationNotFound() async throws { | ||
| await XCTAssertThrowsErrorAsync( | ||
| MigrateConversationToMLSUseCase.Failure.conversationNotFound, | ||
| when: { | ||
| try await self.sut.invoke( | ||
| conversationID: .init(uuid: .create(), domain: "example.com"), | ||
| syncContext: self.syncMOC | ||
| ) | ||
| } | ||
| ) | ||
| } | ||
|
|
||
| private var updatedProtocols: [MessageProtocol] { | ||
| actionsProvider.updateConversationProtocolQualifiedIDMessageProtocolContext_Invocations.map(\.messageProtocol) | ||
| } | ||
|
|
||
| private func createConversation( | ||
| messageProtocol: MessageProtocol, | ||
| conversationType: ZMConversationType = .group, | ||
| groupID: MLSGroupID = .random(), | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Semgrep identified an issue, but thinks it may be safe to ignore. Why this might be safe to ignore:
To resolve this comment: 🔧 No guidance has been designated for this issue. Fix according to your organization's approved methods. 💬 Ignore this findingReply with Semgrep commands to ignore this finding.
Alternatively, triage in Semgrep AppSec Platform to ignore the finding created by ios_insecure_random_no_generator. You can view more details about this finding in the Semgrep AppSec Platform.
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. /fp this is used in a test, safe to ignore |
||
| belongsToTeam: Bool = true | ||
| ) async -> QualifiedID { | ||
| await syncMOC.perform { | ||
| let selfUser = ZMUser.selfUser(in: self.syncMOC) | ||
| selfUser.teamIdentifier = belongsToTeam ? .create() : nil | ||
| selfUser.domain = "example.com" | ||
|
|
||
| let conversation = ZMConversation.insertNewObject(in: self.syncMOC) | ||
| conversation.remoteIdentifier = .create() | ||
| conversation.domain = "example.com" | ||
| conversation.teamRemoteIdentifier = selfUser.teamIdentifier | ||
| conversation.conversationType = conversationType | ||
| conversation.messageProtocol = messageProtocol | ||
| conversation.mlsGroupID = groupID | ||
|
|
||
| return conversation.qualifiedID! | ||
| } | ||
| } | ||
|
|
||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.