From 68115a85793a03a54410c4f3cca66db817a8337f Mon Sep 17 00:00:00 2001 From: Pasin Suriyentrakorn Date: Mon, 16 Jun 2025 08:19:39 -0700 Subject: [PATCH 01/13] Implement Multipeer Replicator Support in iOS (API 1.2.0) * Implemented Multipeer Replicator Support in iOS per API 1.2.0. * Shared the push/pull filter and conflict resolver with the regular replicator * No support for start / stop URLEndpointListener (1.1.0) and dynamic dataset (1.1.1) yet. # Conflicts: # servers/ios/TestServer.xcodeproj/project.pbxproj # servers/ios/TestServer/ContentTypes/TestServerError.swift --- .../ios/TestServer.xcodeproj/project.pbxproj | 24 ++ .../ContentTypes/DocumentReplication.swift | 15 ++ .../MultipeerReplicatorConfiguration.swift | 18 ++ .../MultipeerReplicatorStatus.swift | 19 ++ .../ContentTypes/ReplicatorStatus.swift | 32 +++ .../StartMultipeerReplicatorRequest.swift | 16 ++ .../ContentTypes/TestServerError.swift | 6 + .../GetMultipeerReplicatorStatus.swift | 21 ++ .../Handlers/StartMultipeerReplicator.swift | 27 +++ .../Handlers/StopMultipeerReplicator.swift | 20 ++ .../ios/TestServer/Server/TestServer.swift | 5 +- .../TestServer/Utils/DatabaseManager.swift | 219 ++++++++++++++---- .../ReplicationConflictResolverFactory.swift | 50 ++-- .../Utils/ReplicationFilterFactory.swift | 45 ++-- 14 files changed, 448 insertions(+), 69 deletions(-) create mode 100644 servers/ios/TestServer/ContentTypes/MultipeerReplicatorConfiguration.swift create mode 100644 servers/ios/TestServer/ContentTypes/MultipeerReplicatorStatus.swift create mode 100644 servers/ios/TestServer/ContentTypes/StartMultipeerReplicatorRequest.swift create mode 100644 servers/ios/TestServer/Handlers/GetMultipeerReplicatorStatus.swift create mode 100644 servers/ios/TestServer/Handlers/StartMultipeerReplicator.swift create mode 100644 servers/ios/TestServer/Handlers/StopMultipeerReplicator.swift diff --git a/servers/ios/TestServer.xcodeproj/project.pbxproj b/servers/ios/TestServer.xcodeproj/project.pbxproj index 17a9e8684..2d6e5ceaa 100644 --- a/servers/ios/TestServer.xcodeproj/project.pbxproj +++ b/servers/ios/TestServer.xcodeproj/project.pbxproj @@ -12,6 +12,12 @@ 404619D22CB9A985003835C7 /* NewSessionHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 404619D12CB9A985003835C7 /* NewSessionHandler.swift */; }; 4055CAA02CDC33A400EDD0CB /* IPAddress.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4055CA9F2CDC33A400EDD0CB /* IPAddress.swift */; }; 409F45302DFA7BC900BB7851 /* FileDownloader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 409F452F2DFA7BC900BB7851 /* FileDownloader.swift */; }; + 409F43F82DEA348500BB7851 /* StartMultipeerReplicatorRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 409F43F72DEA348500BB7851 /* StartMultipeerReplicatorRequest.swift */; }; + 409F43FC2DEA373E00BB7851 /* StartMultipeerReplicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 409F43FB2DEA373E00BB7851 /* StartMultipeerReplicator.swift */; }; + 409F44002DEA561A00BB7851 /* MultipeerReplicatorConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 409F43FF2DEA561A00BB7851 /* MultipeerReplicatorConfiguration.swift */; }; + 409F44022DEA823A00BB7851 /* StopMultipeerReplicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 409F44012DEA823A00BB7851 /* StopMultipeerReplicator.swift */; }; + 409F44172DEE187600BB7851 /* GetMultipeerReplicatorStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 409F44162DEE187600BB7851 /* GetMultipeerReplicatorStatus.swift */; }; + 409F442C2DEE228300BB7851 /* MultipeerReplicatorStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 409F442B2DEE228300BB7851 /* MultipeerReplicatorStatus.swift */; }; 40AA34992CAF5D20004F4D08 /* StopReplicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40AA34982CAF5D20004F4D08 /* StopReplicator.swift */; }; 40AA349D2CAF6755004F4D08 /* CollectionSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40AA349C2CAF6755004F4D08 /* CollectionSpec.swift */; }; 40AA34A12CAF6BE4004F4D08 /* ReplicationConflictResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40AA34A02CAF6BE4004F4D08 /* ReplicationConflictResolver.swift */; }; @@ -97,6 +103,12 @@ 404619D12CB9A985003835C7 /* NewSessionHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewSessionHandler.swift; sourceTree = ""; }; 4055CA9F2CDC33A400EDD0CB /* IPAddress.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IPAddress.swift; sourceTree = ""; }; 409F452F2DFA7BC900BB7851 /* FileDownloader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileDownloader.swift; sourceTree = ""; }; + 409F43F72DEA348500BB7851 /* StartMultipeerReplicatorRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartMultipeerReplicatorRequest.swift; sourceTree = ""; }; + 409F43FB2DEA373E00BB7851 /* StartMultipeerReplicator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartMultipeerReplicator.swift; sourceTree = ""; }; + 409F43FF2DEA561A00BB7851 /* MultipeerReplicatorConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultipeerReplicatorConfiguration.swift; sourceTree = ""; }; + 409F44012DEA823A00BB7851 /* StopMultipeerReplicator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StopMultipeerReplicator.swift; sourceTree = ""; }; + 409F44162DEE187600BB7851 /* GetMultipeerReplicatorStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetMultipeerReplicatorStatus.swift; sourceTree = ""; }; + 409F442B2DEE228300BB7851 /* MultipeerReplicatorStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultipeerReplicatorStatus.swift; sourceTree = ""; }; 40A219D62CDC3E790038204C /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 40AA34982CAF5D20004F4D08 /* StopReplicator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StopReplicator.swift; sourceTree = ""; }; 40AA349C2CAF6755004F4D08 /* CollectionSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollectionSpec.swift; sourceTree = ""; }; @@ -231,6 +243,7 @@ children = ( 40BDA7F72AF1C13B008DB256 /* GetAllDocuments.swift */, 40AA34A42CAF94A4004F4D08 /* GetDocument.swift */, + 409F44162DEE187600BB7851 /* GetMultipeerReplicatorStatus.swift */, 40BDA7F62AF1C13B008DB256 /* GetReplicatorStatus.swift */, 40BDA7F02AF1C13B008DB256 /* GetRootHandler.swift */, 40BDA7F32AF1C13B008DB256 /* Handler.swift */, @@ -239,7 +252,9 @@ 40BDA7EE2AF1C13B008DB256 /* ResetHandler.swift */, 40C7A5622CA4AF5C0053ADCB /* RunQuery.swift */, 40BDA7F42AF1C13B008DB256 /* SnapshotDocuments.swift */, + 409F43FB2DEA373E00BB7851 /* StartMultipeerReplicator.swift */, 40BDA7EF2AF1C13B008DB256 /* StartReplicator.swift */, + 409F44012DEA823A00BB7851 /* StopMultipeerReplicator.swift */, 40AA34982CAF5D20004F4D08 /* StopReplicator.swift */, 40BDA7F22AF1C13B008DB256 /* UpdateDatabase.swift */, 40BDA7F12AF1C13B008DB256 /* VerifyDocuments.swift */, @@ -296,6 +311,8 @@ 40BDA8D92AF20AAB008DB256 /* DocumentID.swift */, 40BDA8D22AF20AAB008DB256 /* DocumentReplication.swift */, 40AA34A62CAF9BC8004F4D08 /* GetDocumentRequest.swift */, + 409F43FF2DEA561A00BB7851 /* MultipeerReplicatorConfiguration.swift */, + 409F442B2DEE228300BB7851 /* MultipeerReplicatorStatus.swift */, 404619CF2CB9A761003835C7 /* NewSession.swift */, 40BDA8D32AF20AAB008DB256 /* PerformMaintenanceConfiguration.swift */, 40C7A5662CA4B15B0053ADCB /* QueryResults.swift */, @@ -311,6 +328,7 @@ 40BDA8DC2AF20AAB008DB256 /* ServerInfo.swift */, 40BDA8CF2AF20AAB008DB256 /* SnapshotID.swift */, 40BDA8CC2AF20AAB008DB256 /* SnapshotRequest.swift */, + 409F43F72DEA348500BB7851 /* StartMultipeerReplicatorRequest.swift */, 40BDA8CD2AF20AAB008DB256 /* StartReplicatorRequest.swift */, 40BDA8D72AF20AAB008DB256 /* TestServerError.swift */, 40BDA8DB2AF20AAB008DB256 /* UpdateRequest.swift */, @@ -435,10 +453,13 @@ 40BDA8132AF1C15C008DB256 /* ReplicationFilterFactory.swift in Sources */, 40BDA8EC2AF20AAB008DB256 /* VerifyRequest.swift in Sources */, 40BDA8062AF1C13B008DB256 /* TestServerMiddleware.swift in Sources */, + 409F43F82DEA348500BB7851 /* StartMultipeerReplicatorRequest.swift in Sources */, 40BDA8F22AF20AAB008DB256 /* ServerInfo.swift in Sources */, 40BDA8F42AF20AAB008DB256 /* DatabaseUpdateItem.swift in Sources */, 40BDA8002AF1C13B008DB256 /* Handler.swift in Sources */, + 409F44022DEA823A00BB7851 /* StopMultipeerReplicator.swift in Sources */, 40BDA7FC2AF1C13B008DB256 /* StartReplicator.swift in Sources */, + 409F44172DEE187600BB7851 /* GetMultipeerReplicatorStatus.swift in Sources */, 40BDA7FE2AF1C13B008DB256 /* VerifyDocuments.swift in Sources */, 40BDA7FD2AF1C13B008DB256 /* GetRootHandler.swift in Sources */, 40BDA7FB2AF1C13B008DB256 /* ResetHandler.swift in Sources */, @@ -455,6 +476,7 @@ 40AA34A12CAF6BE4004F4D08 /* ReplicationConflictResolver.swift in Sources */, 40BDA8E72AF20AAB008DB256 /* CollectionDocuments.swift in Sources */, 40C7A5672CA4B15B0053ADCB /* QueryResults.swift in Sources */, + 409F44002DEA561A00BB7851 /* MultipeerReplicatorConfiguration.swift in Sources */, EACC964D2AF3F8B500999C70 /* CBLVersion.swift in Sources */, 40AA34992CAF5D20004F4D08 /* StopReplicator.swift in Sources */, 404619D22CB9A985003835C7 /* NewSessionHandler.swift in Sources */, @@ -477,8 +499,10 @@ 409F45302DFA7BC900BB7851 /* FileDownloader.swift in Sources */, 40BDA8ED2AF20AAB008DB256 /* TestServerError.swift in Sources */, 40BDA8142AF1C15C008DB256 /* KeyPathParser.swift in Sources */, + 409F43FC2DEA373E00BB7851 /* StartMultipeerReplicator.swift in Sources */, 40C7A5652CA4AFB40053ADCB /* RunQueryConfiguration.swift in Sources */, 40C7A5632CA4AF5C0053ADCB /* RunQuery.swift in Sources */, + 409F442C2DEE228300BB7851 /* MultipeerReplicatorStatus.swift in Sources */, 4055CAA02CDC33A400EDD0CB /* IPAddress.swift in Sources */, 40BDA8EE2AF20AAB008DB256 /* ResetConfiguration.swift in Sources */, 40BDA8EB2AF20AAB008DB256 /* ReplicatorConfiguration.swift in Sources */, diff --git a/servers/ios/TestServer/ContentTypes/DocumentReplication.swift b/servers/ios/TestServer/ContentTypes/DocumentReplication.swift index 0127fb580..05fbd5382 100644 --- a/servers/ios/TestServer/ContentTypes/DocumentReplication.swift +++ b/servers/ios/TestServer/ContentTypes/DocumentReplication.swift @@ -6,6 +6,7 @@ // import Vapor +import CouchbaseLiteSwift //DocumentReplication: // type: object @@ -55,3 +56,17 @@ extension ContentTypes { case accessRemoved = "accessRemoved" } } + +extension ContentTypes.DocumentReplication { + init(doc: CouchbaseLiteSwift.ReplicatedDocument, isPush: Bool) { + var flags: [ContentTypes.DocumentReplicationFlags] = [] + if doc.flags.contains(.accessRemoved) { flags.append(.accessRemoved) } + if doc.flags.contains(.deleted) { flags.append(.deleted) } + + self.collection = "\(doc.scope).\(doc.collection)" + self.documentID = doc.id + self.isPush = isPush + self.flags = flags + self.error = doc.error.map(TestServerError.cblError) + } +} diff --git a/servers/ios/TestServer/ContentTypes/MultipeerReplicatorConfiguration.swift b/servers/ios/TestServer/ContentTypes/MultipeerReplicatorConfiguration.swift new file mode 100644 index 000000000..6ceb6d0dc --- /dev/null +++ b/servers/ios/TestServer/ContentTypes/MultipeerReplicatorConfiguration.swift @@ -0,0 +1,18 @@ +// +// MultipeerReplicatorConfiguration.swift +// TestServer +// +// Created by Pasin Suriyentrakorn on 5/30/25. +// + +import Foundation + +import Vapor + +extension ContentTypes { + struct MultipeerReplicatorConfiguration : Content { + let peerGroupID: String + let database: String + let collections: [ReplicationCollection] + } +} diff --git a/servers/ios/TestServer/ContentTypes/MultipeerReplicatorStatus.swift b/servers/ios/TestServer/ContentTypes/MultipeerReplicatorStatus.swift new file mode 100644 index 000000000..39a3596be --- /dev/null +++ b/servers/ios/TestServer/ContentTypes/MultipeerReplicatorStatus.swift @@ -0,0 +1,19 @@ +// +// MultipeerReplicatorStatus.swift +// TestServer +// +// Created by Pasin Suriyentrakorn on 6/2/25. +// + +import Vapor + +extension ContentTypes { + struct PeerReplicatorStatus : Content { + let peerID: String + let status: ReplicatorStatus + } + + struct MultipeerReplicatorStatus : Content { + let replicators: [PeerReplicatorStatus] + } +} diff --git a/servers/ios/TestServer/ContentTypes/ReplicatorStatus.swift b/servers/ios/TestServer/ContentTypes/ReplicatorStatus.swift index 6b5757225..d740b4b5b 100644 --- a/servers/ios/TestServer/ContentTypes/ReplicatorStatus.swift +++ b/servers/ios/TestServer/ContentTypes/ReplicatorStatus.swift @@ -6,6 +6,7 @@ // import Vapor +import CouchbaseLiteSwift extension ContentTypes { enum ReplicatorActivity : String, Codable { @@ -15,10 +16,12 @@ extension ContentTypes { case IDLE = "IDLE" case BUSY = "BUSY" } + struct ReplicatorStatus : Content, CustomStringConvertible { struct Progress : Codable { let completed: Bool } + let activity: ReplicatorActivity let progress: Progress let documents: [DocumentReplication]? @@ -38,3 +41,32 @@ extension ContentTypes { } } } + +extension ContentTypes.ReplicatorActivity { + init(activityLevel: Replicator.ActivityLevel) { + switch activityLevel { + case .busy: + self = .BUSY + case .connecting: + self = .CONNECTING + case .idle: + self = .IDLE + case .offline: + self = .OFFLINE + case .stopped: + self = .STOPPED + @unknown default: + fatalError("Encountered unknown enum value from Replicator.status.activity") + } + } +} + +extension ContentTypes.ReplicatorStatus { + init(status: Replicator.Status, docs: [ContentTypes.DocumentReplication]) { + self.activity = ContentTypes.ReplicatorActivity(activityLevel: status.activity) + self.progress = ContentTypes.ReplicatorStatus.Progress( + completed: status.progress.completed == status.progress.total) + self.documents = docs + self.error = status.error.map(TestServerError.cblError) + } +} diff --git a/servers/ios/TestServer/ContentTypes/StartMultipeerReplicatorRequest.swift b/servers/ios/TestServer/ContentTypes/StartMultipeerReplicatorRequest.swift new file mode 100644 index 000000000..d27e70565 --- /dev/null +++ b/servers/ios/TestServer/ContentTypes/StartMultipeerReplicatorRequest.swift @@ -0,0 +1,16 @@ +// +// StartMultipeerReplicatorRequest.swift +// TestServer +// +// Created by Pasin Suriyentrakorn on 5/30/25. +// + +import Vapor + +extension ContentTypes { + struct StartMultipeerReplicatorRequest : Content { + let peerGroupID: String + let database: String + let collections: [ReplicationCollection] + } +} diff --git a/servers/ios/TestServer/ContentTypes/TestServerError.swift b/servers/ios/TestServer/ContentTypes/TestServerError.swift index c47e1ba9e..60bf8164b 100644 --- a/servers/ios/TestServer/ContentTypes/TestServerError.swift +++ b/servers/ios/TestServer/ContentTypes/TestServerError.swift @@ -51,8 +51,14 @@ struct TestServerError : Error, Codable { if let error = error as NSError? { return TestServerError(domain: .TESTSERVER, code: error.code, message: error.localizedDescription) } + return badRequest(error.localizedDescription) } + + static func cblError(_ error: Error) -> TestServerError { + let err = error as NSError + return TestServerError(domain: .CBL, code: err.code, message: err.localizedDescription) + } static let cblDBNotOpen = TestServerError(domain: .CBL, code: CBLError.notOpen, message: "Database is not open.") static let docNotFoundErr = TestServerError(domain: .TESTSERVER, code: 404, message: "Document not found") diff --git a/servers/ios/TestServer/Handlers/GetMultipeerReplicatorStatus.swift b/servers/ios/TestServer/Handlers/GetMultipeerReplicatorStatus.swift new file mode 100644 index 000000000..ff9aa4131 --- /dev/null +++ b/servers/ios/TestServer/Handlers/GetMultipeerReplicatorStatus.swift @@ -0,0 +1,21 @@ +// +// GetMultipeerReplicatorStatus.swift +// TestServer +// +// Created by Pasin Suriyentrakorn on 6/2/25. +// + +import Foundation + +extension Handlers { + static let getMultipperReplicatorStatus : EndpointHandler = { req throws in + guard let repl = try? req.content.decode(ContentTypes.Replicator.self) else { + throw TestServerError.badRequest("Request body does not match the 'Replicator' schema.") } + + let dbManager = req.application.databaseManager + guard let status = dbManager.multipeerReplicatorStatus(forID: repl.id) else { + throw TestServerError.badRequest("MultipeerReplicator with ID '\(repl.id)' does not exist.") + } + return status + } +} diff --git a/servers/ios/TestServer/Handlers/StartMultipeerReplicator.swift b/servers/ios/TestServer/Handlers/StartMultipeerReplicator.swift new file mode 100644 index 000000000..2d80b5c12 --- /dev/null +++ b/servers/ios/TestServer/Handlers/StartMultipeerReplicator.swift @@ -0,0 +1,27 @@ +// +// StartMultipeerReplicator.swift +// TestServer +// +// Created by Pasin Suriyentrakorn on 5/30/25. +// + +import Foundation + +extension Handlers { + static let startMultipeerReplicator: EndpointHandler = { req throws in + guard let request = try? req.content.decode(ContentTypes.StartMultipeerReplicatorRequest.self) else { + throw TestServerError.badRequest("Request body is not a valid startMultipeerReplicator Request.") + } + + let dbManager = req.application.databaseManager + + var config = ContentTypes.MultipeerReplicatorConfiguration( + peerGroupID: request.peerGroupID, + database: request.database, + collections: request.collections) + + let id = try dbManager.startMultipeerReplicator(config: config) + + return ContentTypes.Replicator(id: id) + } +} diff --git a/servers/ios/TestServer/Handlers/StopMultipeerReplicator.swift b/servers/ios/TestServer/Handlers/StopMultipeerReplicator.swift new file mode 100644 index 000000000..a6ae9c264 --- /dev/null +++ b/servers/ios/TestServer/Handlers/StopMultipeerReplicator.swift @@ -0,0 +1,20 @@ +// +// StopMultipeerReplicator.swift +// TestServer +// +// Created by Pasin Suriyentrakorn on 5/30/25. +// + +import Vapor + +extension Handlers { + static let stopMultipeerReplicator: EndpointHandlerEmptyResponse = { req throws in + guard let requestedReplicator = try? req.content.decode(ContentTypes.Replicator.self) else { + throw TestServerError.badRequest("Request body does not match the 'Replicator' schema.") + } + + let dbManager = req.application.databaseManager + try dbManager.stopMultipeerReplicator(forID: requestedReplicator.id) + return Response(status: .ok) + } +} diff --git a/servers/ios/TestServer/Server/TestServer.swift b/servers/ios/TestServer/Server/TestServer.swift index 3cb0c2cbe..ed4990d6b 100644 --- a/servers/ios/TestServer/Server/TestServer.swift +++ b/servers/ios/TestServer/Server/TestServer.swift @@ -54,7 +54,7 @@ class TestServer : ObservableObject { setupRoutes() } - /// Implement API v1.0.0 + /// Implement API v1.2.0 (No support for start/stop listener - 1.1.0, and dynamic dataset - 1.1.1 yet) private func setupRoutes() { app.get("", use: Handlers.getRoot) app.post("newSession", use: Handlers.newSession) @@ -66,7 +66,10 @@ class TestServer : ObservableObject { app.post("verifyDocuments", use: Handlers.verifyDocuments) app.post("startReplicator", use: Handlers.startReplicator) app.post("getReplicatorStatus", use: Handlers.getReplicatorStatus) + app.post("startMultipeerReplicator", use: Handlers.startMultipeerReplicator) + app.post("stopMultipeerReplicator", use: Handlers.stopMultipeerReplicator) app.post("performMaintenance", use: Handlers.performMaintenance) + app.post("getMultipeerReplicatorStatus", use: Handlers.getMultipperReplicatorStatus) app.post("runQuery", use: Handlers.runQuery) Log.log(level: .debug, message: "Server configured with the following routes: \n\(app.routes.description)") diff --git a/servers/ios/TestServer/Utils/DatabaseManager.swift b/servers/ios/TestServer/Utils/DatabaseManager.swift index c5ed0711c..21322b05f 100644 --- a/servers/ios/TestServer/Utils/DatabaseManager.swift +++ b/servers/ios/TestServer/Utils/DatabaseManager.swift @@ -7,6 +7,7 @@ import CouchbaseLiteSwift import ZipArchive +import CryptoKit class DatabaseManager { private let kDatasetBaseURL = "https://media.githubusercontent.com/media/couchbaselabs/couchbase-lite-tests/refs/heads/main/dataset/server/" @@ -17,8 +18,18 @@ class DatabaseManager { private let datasetVersion: String private var databases : [ String : Database ] = [:] + private var replicators : [ UUID : Replicator ] = [:] - private var replicatorDocuments : [ UUID : [ContentTypes.DocumentReplication] ] = [:] + private var replicatorDocuments : [ UUID : [ ContentTypes.DocumentReplication ] ] = [:] + private var replicatorDocumentsToken : [ UUID : ListenerToken ] = [:] + + private var multipeerReplicators : [ UUID : MultipeerReplicator ] = [:] + private var peerReplicatorStatus : [ UUID : [ PeerID: Replicator.Status ] ] = [:] + private var peerReplicatorStatusToken : [ UUID : ListenerToken ] = [:] + private var peerReplicatorDocuments : [ UUID : [ PeerID: [ ContentTypes.DocumentReplication ] ] ] = [:] + private var peerReplicatorDocumentsToken : [ UUID : ListenerToken ] = [:] + + private let listenerQueue = DispatchQueue(label: "DatabaseManagerListenerQueue") public init(directory: String, datasetVersion: String) { @@ -128,10 +139,10 @@ class DatabaseManager { collConfig.channels = configColl.channels collConfig.documentIDs = configColl.documentIDs if let pullFilter = configColl.pullFilter { - collConfig.pullFilter = try DatabaseManager.getCBLReplicationFilter(from: pullFilter) + collConfig.pullFilter = try DatabaseManager.getCBLReplicationFilter(from: pullFilter).toReplicationFilter() } if let pushFilter = configColl.pushFilter { - collConfig.pushFilter = try DatabaseManager.getCBLReplicationFilter(from: pushFilter) + collConfig.pushFilter = try DatabaseManager.getCBLReplicationFilter(from: pushFilter).toReplicationFilter() } if let resolver = configColl.conflictResolver { collConfig.conflictResolver = try DatabaseManager.getCBLReplicationConflictResolver(from: resolver) @@ -151,9 +162,10 @@ class DatabaseManager { // Whenever a document is replicated, add it to the replicatorDocuments dict if(config.enableDocumentListener) { replicatorDocuments[replicatorID] = [] - replicator.addDocumentReplicationListener({ [weak self] docChange in - guard self != nil - else { return } + + let listenerToken = replicator.addDocumentReplicationListener(withQueue: listenerQueue) { [weak self] docChange in + guard let strongSelf = self else { return } + for doc in docChange.documents { var docFlags: [ContentTypes.DocumentReplicationFlags] = [] @@ -172,7 +184,7 @@ class DatabaseManager { error = TestServerError(domain: .CBL, code: docError.code, message: docError.localizedDescription) } - self!.replicatorDocuments[replicatorID]?.append( + strongSelf.replicatorDocuments[replicatorID]?.append( ContentTypes.DocumentReplication( collection: "\(doc.scope).\(doc.collection)", documentID: doc.id, @@ -181,7 +193,9 @@ class DatabaseManager { error: error) ) } - }) + } + + replicatorDocumentsToken[replicatorID] = listenerToken } replicator.start(reset: reset) @@ -202,47 +216,127 @@ class DatabaseManager { public func replicatorStatus(forID replID: UUID) -> ContentTypes.ReplicatorStatus? { Log.log(level: .debug, message: "Fetching Replicator status for ID \(replID)") - guard let replicator = replicators[replID] - else { - Log.log(level: .debug, message: "Failed to fetch Replicator status, Replicator with ID \(replID) not found.") - return nil + + var status: ContentTypes.ReplicatorStatus? + + listenerQueue.sync { + guard let replicator = replicators[replID] else { + Log.log(level: .debug, message: "Failed to fetch Replicator status, Replicator with ID \(replID) not found.") + return + } + + var docs: [ContentTypes.DocumentReplication] = replicatorDocuments[replID] ?? [] + + replicatorDocuments[replID] = [] // Reset after return per spec + + status = ContentTypes.ReplicatorStatus(status: replicator.status, docs: docs) + Log.log(level: .debug, message: "Succeessfully fetched Replicator status for ID \(replID): \(status!.description)") } - var activity: ContentTypes.ReplicatorActivity = .STOPPED + return status + } + + public func startMultipeerReplicator(config: ContentTypes.MultipeerReplicatorConfiguration) throws -> UUID { + Log.log(level: .debug, message: "Starting Multipeer Replicator") - switch replicator.status.activity { - case .busy: - activity = .BUSY - case .connecting: - activity = .CONNECTING - case .idle: - activity = .IDLE - case .offline: - activity = .OFFLINE - case .stopped: - activity = .STOPPED - @unknown default: - fatalError("Encountered unknown enum value from CBLReplicator.status.activity") + let identity = try DatabaseManager.identity(for: config) + + let authenticator = MultipeerCertificateAuthenticator { peer, certs in + return true } - let progress = ContentTypes.ReplicatorStatus.Progress(completed: replicator.status.progress.completed == replicator.status.progress.total) + var collectionConfigs: [MultipeerCollectionConfiguration] = [] + for replColl in config.collections { + for name in replColl.names { + guard let collection = try self.collection(name, inDB: config.database) else { + Log.log(level: .error, message: "Failed to start Multipeer Replicator as collection '\(name)' does not exist in \(config.database).") + throw TestServerError.badRequest("Collection '\(name)' does not exist in \(config.database).") + } + + var collConfig = MultipeerCollectionConfiguration(collection: collection) + + collConfig.documentIDs = replColl.documentIDs + + if let pullFilter = replColl.pullFilter { + collConfig.pullFilter = try DatabaseManager.getCBLReplicationFilter(from: pullFilter).toMultipeerReplicationFilter() + } + if let pushFilter = replColl.pushFilter { + collConfig.pushFilter = try DatabaseManager.getCBLReplicationFilter(from: pushFilter).toMultipeerReplicationFilter() + } + if let resolver = replColl.conflictResolver { + collConfig.conflictResolver = try DatabaseManager.getCBLReplicationConflictResolver(from: resolver) + } + + collectionConfigs.append(collConfig) + } + } - var error: TestServerError? = nil - if let replError = replicator.status.error as NSError? { - error = TestServerError(domain: .CBL, code: replError.code, message: replError.localizedDescription) + let conf = MultipeerReplicatorConfiguration( + peerGroupID: config.peerGroupID, + identity: identity, + authenticator: authenticator, + collections: collectionConfigs) + + let id = UUID() + + let multipeerReplicator = try MultipeerReplicator(config: conf) + + let listenerToken = multipeerReplicator.addPeerReplicatorStatusListener(on: listenerQueue) { [weak self] status in + guard let strongSelf = self else { return } + strongSelf.peerReplicatorStatus[id, default: [:]][status.peerID] = status.status } - var documents: [ContentTypes.DocumentReplication]? = nil + let docReplToken = multipeerReplicator.addPeerDocumentReplicationListener(on: listenerQueue) { [weak self] docRepl in + guard let strongSelf = self else { return } + var docs: [ContentTypes.DocumentReplication] = [] + docRepl.documents.forEach { doc in + docs.append(ContentTypes.DocumentReplication.init(doc: doc, isPush: !docRepl.incoming)) + } + strongSelf.peerReplicatorDocuments[id, default: [:]][docRepl.peerID, default: []] += docs + } + + multipeerReplicator.start() + + multipeerReplicators[id] = multipeerReplicator + peerReplicatorStatusToken[id] = listenerToken + peerReplicatorDocumentsToken[id] = docReplToken - if let replicatedDocuments = replicatorDocuments[replID] { - documents = replicatedDocuments - // Clear documents that have already been returned, per spec - replicatorDocuments[replID] = [] + return id + } + + public func stopMultipeerReplicator(forID id: UUID) throws { + Log.log(level: .debug, message: "Stop Multipeer Replicator for ID \(id) is requested.") + guard let replicator = multipeerReplicators[id] else { + throw TestServerError.badRequest("Multipeer Replicator ID'\(id)' does not exist.") } + replicator.stop() + Log.log(level: .debug, message: "Stop Multipeer Replicator for ID \(id) is successfully requested.") + } + + public func multipeerReplicatorStatus(forID id: UUID) -> ContentTypes.MultipeerReplicatorStatus? { + Log.log(level: .debug, message: "Getting MultipeerReplicator Status for ID \(id)") - let status = ContentTypes.ReplicatorStatus(activity: activity, progress: progress, documents: documents, error: error) + var status: ContentTypes.MultipeerReplicatorStatus? - Log.log(level: .debug, message: "Succeessfully fetched Replicator status for ID \(replID): \(status.description)") + listenerQueue.sync { + guard let repl = multipeerReplicators[id] else { + Log.log(level: .debug, message: "Failed to get MultipeerReplicator Status, MultipeerReplicator with ID \(id) not found.") + return + } + + var replicators: [ContentTypes.PeerReplicatorStatus] = [] + + if let statuses = peerReplicatorStatus[id] { + statuses.forEach { peerID, status in + let docs = peerReplicatorDocuments[id]?[peerID] ?? [] + let replStatus = ContentTypes.ReplicatorStatus.init(status: status, docs: docs) + replicators.append(ContentTypes.PeerReplicatorStatus(peerID: "\(peerID)", status: replStatus)) + peerReplicatorDocuments[id]?[peerID] = [] // Reset after return per spec + } + } + + status = ContentTypes.MultipeerReplicatorStatus.init(replicators: replicators) + } return status } @@ -384,6 +478,21 @@ class DatabaseManager { try? Database.delete(withName: dbName) } databases.removeAll() + + // Reset all replicators: + replicators.removeAll() + replicatorDocuments.removeAll() + replicatorDocumentsToken.removeAll() + + // Reset all multipeer replicators: + multipeerReplicators.values.forEach { $0.stop() } // Temporary until all close database also stops multipeer replicator + multipeerReplicators.removeAll() + + peerReplicatorStatus.removeAll() + peerReplicatorStatusToken.removeAll() + + peerReplicatorDocuments.removeAll() + peerReplicatorDocumentsToken.removeAll() } private static func getCBLAuthenticator(from auth: ReplicatorAuthenticator) throws -> Authenticator { @@ -397,11 +506,11 @@ class DatabaseManager { } } - private static func getCBLReplicationFilter(from filter: ContentTypes.ReplicationFilter) throws -> ReplicationFilter { + private static func getCBLReplicationFilter(from filter: ContentTypes.ReplicationFilter) throws -> AnyReplicationFilter { return try ReplicationFilterFactory.getFilter(withName: filter.name, params: filter.params) } - private static func getCBLReplicationConflictResolver(from resolver: ContentTypes.ReplicationConflictResolver) throws -> ConflictResolverProtocol { + private static func getCBLReplicationConflictResolver(from resolver: ContentTypes.ReplicationConflictResolver) throws -> ConflictResolver { return try ReplicationConflictResolverFactory.getResolver(withName: resolver.name, params: resolver.params) } @@ -511,5 +620,35 @@ class DatabaseManager { throw TestServerError(domain: .CBL, code: error.code, message: error.localizedDescription) } } + + private static func identity(for config: ContentTypes.MultipeerReplicatorConfiguration) throws -> TLSIdentity { + var allCollections: [String] = [] + for collection in config.collections { + collection.names.forEach { allCollections.append($0) } + } + + let input = "multipeer-\(config.peerGroupID)-\(config.database)-\(allCollections.joined(separator: ","))" + let data = Data(input.utf8) + let digest = Insecure.SHA1.hash(data: data) + let label = digest.map { String(format: "%02x", $0) }.joined() + + if let identity = try TLSIdentity.identity(withLabel: label) { + if identity.expiration > Date().addingTimeInterval(60 * 60 * 1 /* 60 Mins */) { + return identity + } else { + try TLSIdentity.deleteIdentity(withLabel: label) + } + } + + let keyUsages: KeyUsages = [.clientAuth, .serverAuth] + let attrs = [ + certAttrCommonName: "CBLiteTests-\(label)" + ] + let expiration = Date().addingTimeInterval(60 * 60 * 2 /* 120 Mins */) + return try TLSIdentity.createIdentity( + for: keyUsages, + attributes: attrs, + expiration: expiration, + label: label) + } } - diff --git a/servers/ios/TestServer/Utils/ReplicationConflictResolverFactory.swift b/servers/ios/TestServer/Utils/ReplicationConflictResolverFactory.swift index 5d7c753dc..a83f3628d 100644 --- a/servers/ios/TestServer/Utils/ReplicationConflictResolverFactory.swift +++ b/servers/ios/TestServer/Utils/ReplicationConflictResolverFactory.swift @@ -8,29 +8,49 @@ import Foundation import CouchbaseLiteSwift +public protocol AnyConflictResolver { + func resolve(peerID: PeerID?, conflict: Conflict) -> Document? +} + +struct ConflictResolver: ConflictResolverProtocol, MultipeerConflictResolver { + private let resolver: AnyConflictResolver + + init(_ resolver: AnyConflictResolver) { + self.resolver = resolver + } + + func resolve(conflict: Conflict) -> Document? { + return resolver.resolve(peerID: nil, conflict: conflict) + } + + func resolve(peerID: PeerID, conflict: Conflict) -> Document? { + return resolver.resolve(peerID: peerID, conflict: conflict) + } +} + struct ReplicationConflictResolverFactory { - struct LocalWinsResolver : ConflictResolverProtocol { - func resolve(conflict: Conflict) -> Document? { + struct LocalWinsResolver : AnyConflictResolver { + func resolve(peerID: PeerID?, conflict: Conflict) -> Document? { return conflict.localDocument } } - struct RemoteWinsResolver : ConflictResolverProtocol { - func resolve(conflict: Conflict) -> Document? { + struct RemoteWinsResolver : AnyConflictResolver { + func resolve(peerID: PeerID?, conflict: Conflict) -> Document? { return conflict.remoteDocument } } - struct DeleteResolver : ConflictResolverProtocol { - func resolve(conflict: Conflict) -> Document? { + struct DeleteResolver : AnyConflictResolver { + func resolve(peerID: PeerID?, conflict: Conflict) -> Document? { return nil } } - struct MergeResolver : ConflictResolverProtocol { + struct MergeResolver : AnyConflictResolver { let property: String - func resolve(conflict: Conflict) -> Document? { + func resolve(peerID: PeerID?, conflict: Conflict) -> Document? { if conflict.localDocument == nil || conflict.remoteDocument == nil { return nil } @@ -51,25 +71,25 @@ struct ReplicationConflictResolverFactory { case merge = "merge" } - static func getResolver(withName name: String, params: Dictionary? = nil) - throws -> ConflictResolverProtocol { + static func getResolver( + withName name: String, + params: Dictionary? = nil) throws -> ConflictResolver { guard let type = ConflictResolverType(rawValue: name) else { throw TestServerError.badRequest("Could not find conflict resolver with name '\(name)'") } switch type { case .localWins: - return LocalWinsResolver() + return ConflictResolver(LocalWinsResolver()) case .removeWins: - return RemoteWinsResolver() + return ConflictResolver(RemoteWinsResolver()) case .delete: - return DeleteResolver() + return ConflictResolver(DeleteResolver()) case .merge: guard let property = params?["property"]?.value as? String else { throw TestServerError.badRequest("The property parameter is missing for the merge conflict resolver") } - return MergeResolver(property: property) + return ConflictResolver(MergeResolver(property: property)) } } } - diff --git a/servers/ios/TestServer/Utils/ReplicationFilterFactory.swift b/servers/ios/TestServer/Utils/ReplicationFilterFactory.swift index 1ea2dc75c..0ec091011 100644 --- a/servers/ios/TestServer/Utils/ReplicationFilterFactory.swift +++ b/servers/ios/TestServer/Utils/ReplicationFilterFactory.swift @@ -8,24 +8,42 @@ import Foundation import CouchbaseLiteSwift +struct AnyReplicationFilter { + private let block: (PeerID?, Document, DocumentFlags) -> Bool + + init(_ block: @escaping (PeerID?, Document, DocumentFlags) -> Bool) { + self.block = block + } + + func toReplicationFilter() -> ReplicationFilter { + return { doc, flags in self.block(nil, doc, flags) } + } + + func toMultipeerReplicationFilter() -> MultipeerReplicationFilter { + return { peerID, doc, flags in self.block(peerID, doc, flags) } + } +} + struct ReplicationFilterFactory { - static func documentIDs(params: Dictionary?) throws -> ReplicationFilter { - guard let docIDsWrapped = params?["documentIDs"] - else { throw TestServerError.badRequest("Could not find key 'documentIDs' in params.") } + static func documentIDs(params: Dictionary?) throws -> AnyReplicationFilter { + guard let docIDsWrapped = params?["documentIDs"] else { + throw TestServerError.badRequest("Could not find key 'documentIDs' in params.") + } // docIDs should be a dictionary of collection name to array of docID - guard let validDocIDs = docIDsWrapped.value as? [String : [String]] - else { throw TestServerError.badRequest("documentIDs should be a Dict>.") } + guard let validDocIDs = docIDsWrapped.value as? [String : [String]] else { + throw TestServerError.badRequest("documentIDs should be a Dict>.") + } - return { document, _ in + return AnyReplicationFilter({ _, document, _ in return validDocIDs.contains(where: { (_, docIDs) in docIDs.contains(document.id) }) - } + }) } - static func deletedDocumentsOnly() throws -> ReplicationFilter { - return { _, docFlags in + static func deletedDocumentsOnly() throws -> AnyReplicationFilter { + return AnyReplicationFilter({ _, _, docFlags in return docFlags == .deleted - } + }) } private enum availableFilters : String { @@ -33,9 +51,10 @@ struct ReplicationFilterFactory { case deletedDocumentsOnly = "deletedDocumentsOnly" } - static func getFilter(withName name: String, params: Dictionary? = nil) throws -> ReplicationFilter { - guard let filter = availableFilters(rawValue: name) - else { throw TestServerError.badRequest("Could not find filter with name '\(name)'") } + static func getFilter(withName name: String, params: Dictionary? = nil) throws -> AnyReplicationFilter { + guard let filter = availableFilters(rawValue: name) else { + throw TestServerError.badRequest("Could not find filter with name '\(name)'") + } switch filter { case .documentIDs: From 6ef1d1d474a46af619ae3a98c7562f8a4710c297 Mon Sep 17 00:00:00 2001 From: Pasin Suriyentrakorn Date: Wed, 4 Jun 2025 16:03:08 -0700 Subject: [PATCH 02/13] Implement authenticator and identity per API 1.2.1 --- .../ios/TestServer.xcodeproj/project.pbxproj | 8 +++ .../MultipeerReplicatorCAAuthenticator.swift | 20 +++++++ .../MultipeerReplicatorConfiguration.swift | 4 +- .../MultipeerReplicatorIdentity.swift | 20 +++++++ .../ios/TestServer/Server/TestServer.swift | 2 +- .../TestServer/Utils/DatabaseManager.swift | 60 ++++++++++--------- 6 files changed, 83 insertions(+), 31 deletions(-) create mode 100644 servers/ios/TestServer/ContentTypes/MultipeerReplicatorCAAuthenticator.swift create mode 100644 servers/ios/TestServer/ContentTypes/MultipeerReplicatorIdentity.swift diff --git a/servers/ios/TestServer.xcodeproj/project.pbxproj b/servers/ios/TestServer.xcodeproj/project.pbxproj index 2d6e5ceaa..276f2eb87 100644 --- a/servers/ios/TestServer.xcodeproj/project.pbxproj +++ b/servers/ios/TestServer.xcodeproj/project.pbxproj @@ -18,6 +18,8 @@ 409F44022DEA823A00BB7851 /* StopMultipeerReplicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 409F44012DEA823A00BB7851 /* StopMultipeerReplicator.swift */; }; 409F44172DEE187600BB7851 /* GetMultipeerReplicatorStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 409F44162DEE187600BB7851 /* GetMultipeerReplicatorStatus.swift */; }; 409F442C2DEE228300BB7851 /* MultipeerReplicatorStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 409F442B2DEE228300BB7851 /* MultipeerReplicatorStatus.swift */; }; + 409F443D2DF0EB3800BB7851 /* MultipeerReplicatorCAAuthenticator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 409F443C2DF0EB3800BB7851 /* MultipeerReplicatorCAAuthenticator.swift */; }; + 409F443F2DF1016C00BB7851 /* MultipeerReplicatorIdentity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 409F443E2DF1016C00BB7851 /* MultipeerReplicatorIdentity.swift */; }; 40AA34992CAF5D20004F4D08 /* StopReplicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40AA34982CAF5D20004F4D08 /* StopReplicator.swift */; }; 40AA349D2CAF6755004F4D08 /* CollectionSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40AA349C2CAF6755004F4D08 /* CollectionSpec.swift */; }; 40AA34A12CAF6BE4004F4D08 /* ReplicationConflictResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40AA34A02CAF6BE4004F4D08 /* ReplicationConflictResolver.swift */; }; @@ -109,6 +111,8 @@ 409F44012DEA823A00BB7851 /* StopMultipeerReplicator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StopMultipeerReplicator.swift; sourceTree = ""; }; 409F44162DEE187600BB7851 /* GetMultipeerReplicatorStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetMultipeerReplicatorStatus.swift; sourceTree = ""; }; 409F442B2DEE228300BB7851 /* MultipeerReplicatorStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultipeerReplicatorStatus.swift; sourceTree = ""; }; + 409F443C2DF0EB3800BB7851 /* MultipeerReplicatorCAAuthenticator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultipeerReplicatorCAAuthenticator.swift; sourceTree = ""; }; + 409F443E2DF1016C00BB7851 /* MultipeerReplicatorIdentity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultipeerReplicatorIdentity.swift; sourceTree = ""; }; 40A219D62CDC3E790038204C /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 40AA34982CAF5D20004F4D08 /* StopReplicator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StopReplicator.swift; sourceTree = ""; }; 40AA349C2CAF6755004F4D08 /* CollectionSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollectionSpec.swift; sourceTree = ""; }; @@ -311,7 +315,9 @@ 40BDA8D92AF20AAB008DB256 /* DocumentID.swift */, 40BDA8D22AF20AAB008DB256 /* DocumentReplication.swift */, 40AA34A62CAF9BC8004F4D08 /* GetDocumentRequest.swift */, + 409F443C2DF0EB3800BB7851 /* MultipeerReplicatorCAAuthenticator.swift */, 409F43FF2DEA561A00BB7851 /* MultipeerReplicatorConfiguration.swift */, + 409F443E2DF1016C00BB7851 /* MultipeerReplicatorIdentity.swift */, 409F442B2DEE228300BB7851 /* MultipeerReplicatorStatus.swift */, 404619CF2CB9A761003835C7 /* NewSession.swift */, 40BDA8D32AF20AAB008DB256 /* PerformMaintenanceConfiguration.swift */, @@ -454,6 +460,7 @@ 40BDA8EC2AF20AAB008DB256 /* VerifyRequest.swift in Sources */, 40BDA8062AF1C13B008DB256 /* TestServerMiddleware.swift in Sources */, 409F43F82DEA348500BB7851 /* StartMultipeerReplicatorRequest.swift in Sources */, + 409F443D2DF0EB3800BB7851 /* MultipeerReplicatorCAAuthenticator.swift in Sources */, 40BDA8F22AF20AAB008DB256 /* ServerInfo.swift in Sources */, 40BDA8F42AF20AAB008DB256 /* DatabaseUpdateItem.swift in Sources */, 40BDA8002AF1C13B008DB256 /* Handler.swift in Sources */, @@ -484,6 +491,7 @@ 40BDA8022AF1C13B008DB256 /* PerformMaintenance.swift in Sources */, 40BDA8032AF1C13B008DB256 /* GetReplicatorStatus.swift in Sources */, 40BDA8012AF1C13B008DB256 /* SnapshotDocuments.swift in Sources */, + 409F443F2DF1016C00BB7851 /* MultipeerReplicatorIdentity.swift in Sources */, 40BDA8E42AF20AAB008DB256 /* ContentTypes.swift in Sources */, 40BDA8152AF1C15C008DB256 /* AnyCodable.swift in Sources */, 40AA34A32CAF8C4C004F4D08 /* ReplicationConflictResolverFactory.swift in Sources */, diff --git a/servers/ios/TestServer/ContentTypes/MultipeerReplicatorCAAuthenticator.swift b/servers/ios/TestServer/ContentTypes/MultipeerReplicatorCAAuthenticator.swift new file mode 100644 index 000000000..0920e1023 --- /dev/null +++ b/servers/ios/TestServer/ContentTypes/MultipeerReplicatorCAAuthenticator.swift @@ -0,0 +1,20 @@ +// +// MultipeerReplicatorCAAuthenticator.swift +// TestServer +// +// Created by Pasin Suriyentrakorn on 6/4/25. +// + +import Vapor + +extension ContentTypes { + enum MultipeerReplicatorCAAuthenticatorType : String, Codable { + case CACERT = "CA-CERT" + } + + struct MultipeerReplicatorCAAuthenticator : Content { + let type: MultipeerReplicatorCAAuthenticatorType + let certificate: String + } +} + diff --git a/servers/ios/TestServer/ContentTypes/MultipeerReplicatorConfiguration.swift b/servers/ios/TestServer/ContentTypes/MultipeerReplicatorConfiguration.swift index 6ceb6d0dc..ddbfe6f1c 100644 --- a/servers/ios/TestServer/ContentTypes/MultipeerReplicatorConfiguration.swift +++ b/servers/ios/TestServer/ContentTypes/MultipeerReplicatorConfiguration.swift @@ -5,8 +5,6 @@ // Created by Pasin Suriyentrakorn on 5/30/25. // -import Foundation - import Vapor extension ContentTypes { @@ -14,5 +12,7 @@ extension ContentTypes { let peerGroupID: String let database: String let collections: [ReplicationCollection] + let identity: MultipeerReplicatorIdentity + let authenticator: MultipeerReplicatorCAAuthenticator? } } diff --git a/servers/ios/TestServer/ContentTypes/MultipeerReplicatorIdentity.swift b/servers/ios/TestServer/ContentTypes/MultipeerReplicatorIdentity.swift new file mode 100644 index 000000000..7abab4786 --- /dev/null +++ b/servers/ios/TestServer/ContentTypes/MultipeerReplicatorIdentity.swift @@ -0,0 +1,20 @@ +// +// MultipeerReplicatorIdentity.swift +// TestServer +// +// Created by Pasin Suriyentrakorn on 6/4/25. +// + +import Vapor + +extension ContentTypes { + enum IdentityDataEncoding : String, Codable { + case PKCS12 = "PKCS12" + } + + struct MultipeerReplicatorIdentity : Content { + let encoding: IdentityDataEncoding + let data: String + let password: String? + } +} diff --git a/servers/ios/TestServer/Server/TestServer.swift b/servers/ios/TestServer/Server/TestServer.swift index ed4990d6b..fb95e9212 100644 --- a/servers/ios/TestServer/Server/TestServer.swift +++ b/servers/ios/TestServer/Server/TestServer.swift @@ -54,7 +54,7 @@ class TestServer : ObservableObject { setupRoutes() } - /// Implement API v1.2.0 (No support for start/stop listener - 1.1.0, and dynamic dataset - 1.1.1 yet) + /// Implement API v1.2.1 (No support for start/stop listener - 1.1.0, and dynamic dataset - 1.1.1 yet) private func setupRoutes() { app.get("", use: Handlers.getRoot) app.post("newSession", use: Handlers.newSession) diff --git a/servers/ios/TestServer/Utils/DatabaseManager.swift b/servers/ios/TestServer/Utils/DatabaseManager.swift index 21322b05f..7bf125f24 100644 --- a/servers/ios/TestServer/Utils/DatabaseManager.swift +++ b/servers/ios/TestServer/Utils/DatabaseManager.swift @@ -239,11 +239,9 @@ class DatabaseManager { public func startMultipeerReplicator(config: ContentTypes.MultipeerReplicatorConfiguration) throws -> UUID { Log.log(level: .debug, message: "Starting Multipeer Replicator") - let identity = try DatabaseManager.identity(for: config) + let identity = try DatabaseManager.multipeerReplicatorIdentity(for: config) - let authenticator = MultipeerCertificateAuthenticator { peer, certs in - return true - } + let authenticator = try DatabaseManager.multipeerAuthenticator(for: config.authenticator) var collectionConfigs: [MultipeerCollectionConfiguration] = [] for replColl in config.collections { @@ -621,34 +619,40 @@ class DatabaseManager { } } - private static func identity(for config: ContentTypes.MultipeerReplicatorConfiguration) throws -> TLSIdentity { - var allCollections: [String] = [] - for collection in config.collections { - collection.names.forEach { allCollections.append($0) } - } + private static func multipeerReplicatorIdentity(for config: ContentTypes.MultipeerReplicatorConfiguration) throws -> TLSIdentity { + let label = "ios-multipeer-\(config.peerGroupID)" - let input = "multipeer-\(config.peerGroupID)-\(config.database)-\(allCollections.joined(separator: ","))" - let data = Data(input.utf8) - let digest = Insecure.SHA1.hash(data: data) - let label = digest.map { String(format: "%02x", $0) }.joined() + guard let data = Data(base64Encoded: config.identity.data) else { + throw TestServerError.badRequest("Invalid multipeer replictor's identity data") + } - if let identity = try TLSIdentity.identity(withLabel: label) { - if identity.expiration > Date().addingTimeInterval(60 * 60 * 1 /* 60 Mins */) { - return identity - } else { - try TLSIdentity.deleteIdentity(withLabel: label) + try TLSIdentity.deleteIdentity(withLabel: label) + return try TLSIdentity.importIdentity(withData: data, password: config.identity.password, label: label) + } + + private static func multipeerAuthenticator(for config: ContentTypes.MultipeerReplicatorCAAuthenticator?) throws -> MultipeerCertificateAuthenticator { + guard let auth = config else { + return MultipeerCertificateAuthenticator { peer, certs in + return true } } - let keyUsages: KeyUsages = [.clientAuth, .serverAuth] - let attrs = [ - certAttrCommonName: "CBLiteTests-\(label)" - ] - let expiration = Date().addingTimeInterval(60 * 60 * 2 /* 120 Mins */) - return try TLSIdentity.createIdentity( - for: keyUsages, - attributes: attrs, - expiration: expiration, - label: label) + var lines = auth.certificate + .components(separatedBy: .newlines) + .filter { !$0.contains("-----BEGIN CERTIFICATE-----") && + !$0.contains("-----END CERTIFICATE-----") && + !$0.isEmpty } + + let certData = lines.joined() + + guard let derData = Data(base64Encoded: certData) else { + throw TestServerError.badRequest("Failed to convert multipeer authenticator's root certificate from PEM to DER") + } + + guard let rootCert = SecCertificateCreateWithData(nil, derData as CFData) else { + throw TestServerError.badRequest("Invalid multipeer authenticator's root certificate") + } + + return MultipeerCertificateAuthenticator(rootCerts: [rootCert]) } } From 1c859d82c0f95c741f61aa6388e345e17c821f79 Mon Sep 17 00:00:00 2001 From: Pasin Suriyentrakorn Date: Wed, 4 Jun 2025 18:01:30 -0700 Subject: [PATCH 03/13] Fix missing identity and authenticator in StartMultipeerReplicator --- .../ContentTypes/StartMultipeerReplicatorRequest.swift | 2 ++ .../ios/TestServer/Handlers/StartMultipeerReplicator.swift | 4 +++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/servers/ios/TestServer/ContentTypes/StartMultipeerReplicatorRequest.swift b/servers/ios/TestServer/ContentTypes/StartMultipeerReplicatorRequest.swift index d27e70565..dd1eea35f 100644 --- a/servers/ios/TestServer/ContentTypes/StartMultipeerReplicatorRequest.swift +++ b/servers/ios/TestServer/ContentTypes/StartMultipeerReplicatorRequest.swift @@ -12,5 +12,7 @@ extension ContentTypes { let peerGroupID: String let database: String let collections: [ReplicationCollection] + let identity: MultipeerReplicatorIdentity + let authenticator: MultipeerReplicatorCAAuthenticator? } } diff --git a/servers/ios/TestServer/Handlers/StartMultipeerReplicator.swift b/servers/ios/TestServer/Handlers/StartMultipeerReplicator.swift index 2d80b5c12..207eeae05 100644 --- a/servers/ios/TestServer/Handlers/StartMultipeerReplicator.swift +++ b/servers/ios/TestServer/Handlers/StartMultipeerReplicator.swift @@ -18,7 +18,9 @@ extension Handlers { var config = ContentTypes.MultipeerReplicatorConfiguration( peerGroupID: request.peerGroupID, database: request.database, - collections: request.collections) + collections: request.collections, + identity: request.identity, + authenticator: request.authenticator) let id = try dbManager.startMultipeerReplicator(config: config) From 4ed6ed1b3270a31b70ee4fb6195b5cf8c62caf04 Mon Sep 17 00:00:00 2001 From: Pasin Suriyentrakorn Date: Mon, 16 Jun 2025 08:22:17 -0700 Subject: [PATCH 04/13] Update the Multipeer Replicator Handler Code per change on the main branch --- .../ios/TestServer/Handlers/GetMultipeerReplicatorStatus.swift | 2 +- servers/ios/TestServer/Handlers/StartMultipeerReplicator.swift | 2 +- servers/ios/TestServer/Handlers/StopMultipeerReplicator.swift | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/servers/ios/TestServer/Handlers/GetMultipeerReplicatorStatus.swift b/servers/ios/TestServer/Handlers/GetMultipeerReplicatorStatus.swift index ff9aa4131..173178df2 100644 --- a/servers/ios/TestServer/Handlers/GetMultipeerReplicatorStatus.swift +++ b/servers/ios/TestServer/Handlers/GetMultipeerReplicatorStatus.swift @@ -12,7 +12,7 @@ extension Handlers { guard let repl = try? req.content.decode(ContentTypes.Replicator.self) else { throw TestServerError.badRequest("Request body does not match the 'Replicator' schema.") } - let dbManager = req.application.databaseManager + let dbManager = req.databaseManager guard let status = dbManager.multipeerReplicatorStatus(forID: repl.id) else { throw TestServerError.badRequest("MultipeerReplicator with ID '\(repl.id)' does not exist.") } diff --git a/servers/ios/TestServer/Handlers/StartMultipeerReplicator.swift b/servers/ios/TestServer/Handlers/StartMultipeerReplicator.swift index 207eeae05..3f593d5c6 100644 --- a/servers/ios/TestServer/Handlers/StartMultipeerReplicator.swift +++ b/servers/ios/TestServer/Handlers/StartMultipeerReplicator.swift @@ -13,7 +13,7 @@ extension Handlers { throw TestServerError.badRequest("Request body is not a valid startMultipeerReplicator Request.") } - let dbManager = req.application.databaseManager + let dbManager = req.databaseManager var config = ContentTypes.MultipeerReplicatorConfiguration( peerGroupID: request.peerGroupID, diff --git a/servers/ios/TestServer/Handlers/StopMultipeerReplicator.swift b/servers/ios/TestServer/Handlers/StopMultipeerReplicator.swift index a6ae9c264..442db36ca 100644 --- a/servers/ios/TestServer/Handlers/StopMultipeerReplicator.swift +++ b/servers/ios/TestServer/Handlers/StopMultipeerReplicator.swift @@ -13,7 +13,7 @@ extension Handlers { throw TestServerError.badRequest("Request body does not match the 'Replicator' schema.") } - let dbManager = req.application.databaseManager + let dbManager = req.databaseManager try dbManager.stopMultipeerReplicator(forID: requestedReplicator.id) return Response(status: .ok) } From 8b4993052cd37d875ccb6476102c828de30d13fe Mon Sep 17 00:00:00 2001 From: Pasin Suriyentrakorn Date: Mon, 16 Jun 2025 16:13:30 -0700 Subject: [PATCH 05/13] Update to use the lastest iOS API (v.1.8) --- servers/ios/TestServer/Utils/DatabaseManager.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/servers/ios/TestServer/Utils/DatabaseManager.swift b/servers/ios/TestServer/Utils/DatabaseManager.swift index 7bf125f24..38d4631d1 100644 --- a/servers/ios/TestServer/Utils/DatabaseManager.swift +++ b/servers/ios/TestServer/Utils/DatabaseManager.swift @@ -288,7 +288,7 @@ class DatabaseManager { guard let strongSelf = self else { return } var docs: [ContentTypes.DocumentReplication] = [] docRepl.documents.forEach { doc in - docs.append(ContentTypes.DocumentReplication.init(doc: doc, isPush: !docRepl.incoming)) + docs.append(ContentTypes.DocumentReplication.init(doc: doc, isPush: docRepl.isPush)) } strongSelf.peerReplicatorDocuments[id, default: [:]][docRepl.peerID, default: []] += docs } From f0a5da217b04fa8426c066f92ad31f3a9218e661 Mon Sep 17 00:00:00 2001 From: Jim Borden Date: Tue, 17 Jun 2025 09:04:32 +0900 Subject: [PATCH 06/13] Fix and enhance multi client logic Multipeer replication would not start since it was missing some classes. Also it wasn't serializing the startMultipeerReplicator class correctly. Added in download caching so that each step doesn't have to download CBL from latestbuilds on every build. --- client/src/cbltest/v1/requests.py | 35 ++++++++++++++++++- environment/aws/topology_setup/test_server.py | 31 ++++++++++++++++ .../test_server_platforms/c_register.py | 16 +++++++++ .../test_server_platforms/swift_register.py | 5 +++ 4 files changed, 86 insertions(+), 1 deletion(-) diff --git a/client/src/cbltest/v1/requests.py b/client/src/cbltest/v1/requests.py index aec5ad43a..86e008bf8 100644 --- a/client/src/cbltest/v1/requests.py +++ b/client/src/cbltest/v1/requests.py @@ -847,7 +847,7 @@ def to_json(self) -> Any: json = { "peerGroupID": self.__peerGroupID, "database": self.__database, - "collections": self.__collections, + "collections": [c.to_json() for c in self.collections], "identity": { "encoding": "PKCS12", "data": base64.b64encode(self.__identity.pfx_bytes()).decode("utf-8"), @@ -1084,3 +1084,36 @@ def __init__(self, uuid: UUID, payload: PostStopListenerRequestBody): super().__init__( 1, uuid, "stopListener", PostStopListenerRequestBody, payload=payload ) + +class PostStartMultipeerReplicatorRequest(TestServerRequest): + """ + A POST /startMultipeerReplicator request as specified in version 1 of the + `spec `_ + """ + + def __init__(self, uuid: UUID, payload: PostStartMultipeerReplicatorRequestBody): + super().__init__( + 1, uuid, "startMultipeerReplicator", PostStartMultipeerReplicatorRequestBody, payload=payload + ) + +class PostStopMultipeerReplicatorRequest(TestServerRequest): + """ + A POST /startMultipeerReplicator request as specified in version 1 of the + `spec `_ + """ + + def __init__(self, uuid: UUID, payload: PostStopMultipeerReplicatorRequestBody): + super().__init__( + 1, uuid, "stopMultipeerReplicator", PostStopMultipeerReplicatorRequestBody, payload=payload + ) + +class PostGetMultipeerReplicatorStatusRequest(TestServerRequest): + """ + A POST /getMultipeerReplicatorStatus request as specified in version 1 of the + `spec `_ + """ + + def __init__(self, uuid: UUID, payload: PostGetMultipeerReplicatorStatusRequestBody): + super().__init__( + 1, uuid, "getMultipeerReplicatorStatus", PostGetMultipeerReplicatorStatusRequestBody, payload=payload + ) \ No newline at end of file diff --git a/environment/aws/topology_setup/test_server.py b/environment/aws/topology_setup/test_server.py index 041e9de83..f799430e5 100644 --- a/environment/aws/topology_setup/test_server.py +++ b/environment/aws/topology_setup/test_server.py @@ -46,6 +46,7 @@ from abc import ABC, abstractmethod from collections.abc import Callable from pathlib import Path +from typing import Final import click import requests @@ -101,6 +102,7 @@ class TestServer(ABC): """ __registry: dict[str, type[TestServer]] = {} + __version_filename: Final[str] = ".version" def __init__(self, version: str) -> None: self.__version = version @@ -270,5 +272,34 @@ def create_bridge(self, **kwargs) -> PlatformBridge: """ pass + def _check_downloaded(self, location: Path) -> bool: + """ + Check if the C test server package is already downloaded. + + Args: + location (Path): The path to the downloaded package. + + Returns: + bool: True if the package is downloaded, False otherwise. + """ + if (location / self.__version_filename).exists(): + with open(location / self.__version_filename, "r") as f: + saved_version = f.read().strip() + return saved_version == self.version + + return False + + def _mark_downloaded(self, location: Path) -> None: + """ + Mark the C test server package as downloaded. + + Args: + location (Path): The path to the downloaded package. + version (str): The version of the test server. + build (int): The build number of the test server. + """ + with open(location / self.__version_filename, "w") as f: + f.write(self.version) + assert __name__ != "__main__", "This module is not meant to be run directly" diff --git a/environment/aws/topology_setup/test_server_platforms/c_register.py b/environment/aws/topology_setup/test_server_platforms/c_register.py index 8913379d3..b57565299 100644 --- a/environment/aws/topology_setup/test_server_platforms/c_register.py +++ b/environment/aws/topology_setup/test_server_platforms/c_register.py @@ -91,6 +91,11 @@ def _download_cbl(self) -> None: filename = self.cbl_filename(self.version) ext = ".tar.gz" if filename.endswith(".tar.gz") else ".zip" DOWNLOAD_DIR.mkdir(0o755, exist_ok=True) + + if self._check_downloaded(LIB_DIR): + header(f"CBL library {self.version} already downloaded") + return + download_file = DOWNLOAD_DIR / f"framework.{ext}" downloader = CBLLibraryDownloader( "couchbase-lite-c", @@ -103,6 +108,7 @@ def _download_cbl(self) -> None: unzip_directory(download_file, LIB_DIR) else: untar_directory(download_file, LIB_DIR) + self._mark_downloaded(LIB_DIR) def build(self) -> None: """ @@ -188,6 +194,10 @@ def _download_cbl(self): build = int(version_parts[1]) DOWNLOAD_DIR.mkdir(0o755, exist_ok=True) + if self._check_downloaded(IOS_FRAMEWORKS_DIR): + header(f"CBL library {self.version} already downloaded") + return + download_file = DOWNLOAD_DIR / "framework.zip" downloader = CBLLibraryDownloader( "couchbase-lite-c", @@ -208,6 +218,7 @@ def _download_cbl(self): cwd=IOS_VENDOR_DIR / "cmake", check=True, ) + self._mark_downloaded(IOS_FRAMEWORKS_DIR) def build(self): self._download_cbl() @@ -346,6 +357,10 @@ def _download_cbl(self): build = int(version_parts[1]) DOWNLOAD_DIR.mkdir(0o755, exist_ok=True) + if self._check_downloaded(android_lib_dir): + header(f"CBL library {self.version} already downloaded") + return + download_file = DOWNLOAD_DIR / "framework.zip" downloader = CBLLibraryDownloader( "couchbase-lite-c", @@ -357,6 +372,7 @@ def _download_cbl(self): shutil.rmtree(android_lib_dir, ignore_errors=True) android_lib_dir.mkdir(0o755) unzip_directory(download_file, android_lib_dir) + self._mark_downloaded(android_lib_dir) def build(self) -> None: """ diff --git a/environment/aws/topology_setup/test_server_platforms/swift_register.py b/environment/aws/topology_setup/test_server_platforms/swift_register.py index 0c135b246..9285b16b7 100644 --- a/environment/aws/topology_setup/test_server_platforms/swift_register.py +++ b/environment/aws/topology_setup/test_server_platforms/swift_register.py @@ -70,6 +70,10 @@ def _download_cbl(self) -> None: header(f"Downloading CBL library {self.version}") version_parts = self.version.split("-") DOWNLOAD_DIR.mkdir(0o755, exist_ok=True) + if self._check_downloaded(FRAMEWORKS_DIR): + header(f"CBL library {self.version} already downloaded") + return + download_file = DOWNLOAD_DIR / "framework.zip" downloader = CBLLibraryDownloader( "couchbase-lite-ios", @@ -83,6 +87,7 @@ def _download_cbl(self) -> None: ) unzip_directory(download_file, FRAMEWORKS_DIR) download_file.unlink() + self._mark_downloaded(FRAMEWORKS_DIR) @TestServer.register("swift_ios") From baa4a37e7df68970f899e0ad9456a4ba1d558bf7 Mon Sep 17 00:00:00 2001 From: Jim Borden Date: Tue, 17 Jun 2025 13:58:11 +0900 Subject: [PATCH 07/13] Switch to RSA private keys and encrypt with password Add in a simple smoke test for multipeer that doesn't do much yet, since it already finds problems. --- client/smoke_tests/test_multipeer.py | 25 ++++++++++++++++++++++ client/src/cbltest/api/x509_certificate.py | 21 ++++++++++++------ client/src/cbltest/v1/requests.py | 1 + 3 files changed, 41 insertions(+), 6 deletions(-) create mode 100644 client/smoke_tests/test_multipeer.py diff --git a/client/smoke_tests/test_multipeer.py b/client/smoke_tests/test_multipeer.py new file mode 100644 index 000000000..6249b06c9 --- /dev/null +++ b/client/smoke_tests/test_multipeer.py @@ -0,0 +1,25 @@ +import asyncio + +import pytest +from cbltest import CBLPyTest +from cbltest.api.multipeer_replicator import MultipeerReplicator +from cbltest.api.replicator_types import ReplicatorCollectionEntry +from cbltest.globals import CBLPyTestGlobal + + +class TestMultipeerReplicator: + def setup_method(self, method): + # If writing a new test do not forget this step or the test server + # will not be informed about the currently running test + CBLPyTestGlobal.running_test_name = method.__name__ + + @pytest.mark.asyncio(loop_scope="session") + async def test_start_stop_multipeer(self, cblpytest: CBLPyTest) -> None: + dbs = await cblpytest.test_servers[0].create_and_reset_db(["db1"]) + db = dbs[0] + multipeer = MultipeerReplicator( + "com.couchbase.testing", db, [ReplicatorCollectionEntry(["_default._default"])] + ) + await multipeer.start() + await asyncio.sleep(5) + await multipeer.stop() diff --git a/client/src/cbltest/api/x509_certificate.py b/client/src/cbltest/api/x509_certificate.py index 796b065c7..7c3b98cd8 100644 --- a/client/src/cbltest/api/x509_certificate.py +++ b/client/src/cbltest/api/x509_certificate.py @@ -1,8 +1,8 @@ from datetime import datetime, timedelta, timezone from cryptography.hazmat.primitives import hashes -from cryptography.hazmat.primitives.asymmetric import ec -from cryptography.hazmat.primitives.serialization import Encoding, NoEncryption, pkcs12 +from cryptography.hazmat.primitives.asymmetric import ec, rsa +from cryptography.hazmat.primitives.serialization import Encoding, BestAvailableEncryption, pkcs12 from cryptography.x509 import ( BasicConstraints, Certificate, @@ -22,10 +22,11 @@ class CertKeyPair: """ def __init__( - self, certificate: Certificate, private_key: ec.EllipticCurvePrivateKey + self, certificate: Certificate, private_key: ec.EllipticCurvePrivateKey, *, password: str = "couchbase" ): self.certificate = certificate self.private_key = private_key + self.password = password def pfx_bytes(self) -> bytes: """ @@ -36,7 +37,7 @@ def pfx_bytes(self) -> bytes: key=self.private_key, cert=self.certificate, cas=None, - encryption_algorithm=NoEncryption(), + encryption_algorithm=BestAvailableEncryption(self.password.encode('utf-8')), ) return ret_val @@ -49,7 +50,11 @@ def pem_bytes(self) -> bytes: def create_ca_certificate(CN: str) -> CertKeyPair: - private_key = ec.generate_private_key(ec.SECP256R1()) + private_key = rsa.generate_private_key( + public_exponent=65537, + key_size=2048, + ) + #private_key = ec.generate_private_key(ec.SECP256R1()) cn_attribute = Name([NameAttribute(NameOID.COMMON_NAME, CN)]) not_valid_before = datetime.now(timezone.utc) not_valid_after = not_valid_before + timedelta(days=1) @@ -72,7 +77,11 @@ def create_ca_certificate(CN: str) -> CertKeyPair: def create_leaf_certificate( CN: str, *, issuer_data: CertKeyPair | None = None ) -> CertKeyPair: - private_key = ec.generate_private_key(ec.SECP256R1()) + #private_key = ec.generate_private_key(ec.SECP256R1()) + private_key = rsa.generate_private_key( + public_exponent=65537, + key_size=2048, + ) cn_attribute = Name([NameAttribute(NameOID.COMMON_NAME, CN)]) not_valid_before = datetime.now(timezone.utc) not_valid_after = not_valid_before + timedelta(days=1) diff --git a/client/src/cbltest/v1/requests.py b/client/src/cbltest/v1/requests.py index 86e008bf8..8d36e1890 100644 --- a/client/src/cbltest/v1/requests.py +++ b/client/src/cbltest/v1/requests.py @@ -851,6 +851,7 @@ def to_json(self) -> Any: "identity": { "encoding": "PKCS12", "data": base64.b64encode(self.__identity.pfx_bytes()).decode("utf-8"), + "password": self.__identity.password, }, } From b4940f3babadb53be90303fadd369ab88b8ea502 Mon Sep 17 00:00:00 2001 From: Pasin Suriyentrakorn Date: Tue, 17 Jun 2025 15:11:02 -0700 Subject: [PATCH 08/13] Add _couchbaseP2P._tcp to NSBonjourServices in Info.plist --- servers/ios/TestServer/Info.plist | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/servers/ios/TestServer/Info.plist b/servers/ios/TestServer/Info.plist index 3729d0854..a6b6ac805 100644 --- a/servers/ios/TestServer/Info.plist +++ b/servers/ios/TestServer/Info.plist @@ -2,11 +2,12 @@ - NSLocalNetworkUsageDescription - For Advertising Service + NSLocalNetworkUsageDescription + For Advertising Service NSBonjourServices _testserver._tcp + _couchbaseP2P._tcp From e361943224161e58243d81403638e0d1be78fafb Mon Sep 17 00:00:00 2001 From: Jim Borden Date: Wed, 18 Jun 2025 10:05:04 +0900 Subject: [PATCH 09/13] Fix certificate issues with iOS and improve http_log http_log entries are now broken into batches of 100 inside folders to avoid destroying IDEs with gigantic file lists, and they also indicate in the "begin" file which test server index they sent a request to. --- .gitignore | 1 + client/smoke_tests/.gitignore | 4 +++- client/smoke_tests/README.md | 10 ++++++++ client/smoke_tests/config_in.json | 4 ++++ client/smoke_tests/test_multipeer.py | 7 ++++-- client/smoke_tests/topology.example.json | 11 +++++++++ client/src/cbltest/api/x509_certificate.py | 24 ++++++++++++++----- client/src/cbltest/httplog.py | 21 +++++++++++++---- client/src/cbltest/requests.py | 3 ++- client/src/cbltest/v1/requests.py | 27 ++++++++++++++++++---- 10 files changed, 93 insertions(+), 19 deletions(-) create mode 100644 client/smoke_tests/README.md create mode 100644 client/smoke_tests/config_in.json create mode 100644 client/smoke_tests/topology.example.json diff --git a/.gitignore b/.gitignore index 29db21b08..4a673a6ab 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,4 @@ session.log http_log .terraform servers/downloaded +.version \ No newline at end of file diff --git a/client/smoke_tests/.gitignore b/client/smoke_tests/.gitignore index 236d2335b..5c711c135 100644 --- a/client/smoke_tests/.gitignore +++ b/client/smoke_tests/.gitignore @@ -1,2 +1,4 @@ http_log -local_config.json \ No newline at end of file +local_config.json +config.json +topology.json \ No newline at end of file diff --git a/client/smoke_tests/README.md b/client/smoke_tests/README.md new file mode 100644 index 000000000..6cdc349ce --- /dev/null +++ b/client/smoke_tests/README.md @@ -0,0 +1,10 @@ +Running Smoke Tests +=================== + +You can write a config file by copying config_in.json to config.json and adding in a "test-servers" array entry yourself, or alternatively if you want to streamline multiple devices for some reason you can copy topology.example.json to topology.json, edit the various fields and add more entries as you please and then run the following: + +`python ../../environment/aws/start_backend.py --topology ./topology.json --tdk-config-out ./config.json --tdk-config-in ./config_in.json` + +This will read from config_in.json, build and run the services defined in topology.json and write the resulting config to config.json. + +After that it's just standard pytest stuff (e.g. `pytest -v --no-header --config config.json`) \ No newline at end of file diff --git a/client/smoke_tests/config_in.json b/client/smoke_tests/config_in.json new file mode 100644 index 000000000..d0708c42f --- /dev/null +++ b/client/smoke_tests/config_in.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://packages.couchbase.com/couchbase-lite/testserver.schema.json", + "api-version": 1 +} \ No newline at end of file diff --git a/client/smoke_tests/test_multipeer.py b/client/smoke_tests/test_multipeer.py index 6249b06c9..4040dc177 100644 --- a/client/smoke_tests/test_multipeer.py +++ b/client/smoke_tests/test_multipeer.py @@ -18,8 +18,11 @@ async def test_start_stop_multipeer(self, cblpytest: CBLPyTest) -> None: dbs = await cblpytest.test_servers[0].create_and_reset_db(["db1"]) db = dbs[0] multipeer = MultipeerReplicator( - "com.couchbase.testing", db, [ReplicatorCollectionEntry(["_default._default"])] + "couchtest", db, [ReplicatorCollectionEntry(["_default._default"])] ) await multipeer.start() - await asyncio.sleep(5) + await asyncio.sleep(2) + status = await multipeer.get_status() + assert status is not None, "A started multipeer replicator should have a status" + assert len(status.replicators) == 0, "Nothing should be found" await multipeer.stop() diff --git a/client/smoke_tests/topology.example.json b/client/smoke_tests/topology.example.json new file mode 100644 index 000000000..7b2b43ae3 --- /dev/null +++ b/client/smoke_tests/topology.example.json @@ -0,0 +1,11 @@ +{ + "$schema": "../../environment/aws/topology_setup/topology_schema.json", + "test_servers": [ + { + "platform": "dotnet_ios", + "cbl_version": "", + "dataset_version": "", + "location": "" + } + ] +} \ No newline at end of file diff --git a/client/src/cbltest/api/x509_certificate.py b/client/src/cbltest/api/x509_certificate.py index 7c3b98cd8..39c72d89d 100644 --- a/client/src/cbltest/api/x509_certificate.py +++ b/client/src/cbltest/api/x509_certificate.py @@ -1,8 +1,8 @@ from datetime import datetime, timedelta, timezone from cryptography.hazmat.primitives import hashes -from cryptography.hazmat.primitives.asymmetric import ec, rsa -from cryptography.hazmat.primitives.serialization import Encoding, BestAvailableEncryption, pkcs12 +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.hazmat.primitives.serialization import Encoding, PrivateFormat, pkcs12 from cryptography.x509 import ( BasicConstraints, Certificate, @@ -22,7 +22,11 @@ class CertKeyPair: """ def __init__( - self, certificate: Certificate, private_key: ec.EllipticCurvePrivateKey, *, password: str = "couchbase" + self, + certificate: Certificate, + private_key: pkcs12.PKCS12PrivateKeyTypes, + *, + password: str = "couchbase", ): self.certificate = certificate self.private_key = private_key @@ -32,12 +36,22 @@ def pfx_bytes(self) -> bytes: """ Returns the certificate and private key in PFX format. """ + # At least at iOS 16, AES is not supported for PFX encryption, + # so we have to fallback to this. Furthermore SHA256 is not + # supported either so we have to use SHA1. + enc = ( + PrivateFormat.PKCS12.encryption_builder() + .key_cert_algorithm(pkcs12.PBES.PBESv1SHA1And3KeyTripleDESCBC) + .hmac_hash(hashes.SHA1()) + .build(self.password.encode()) + ) + ret_val = pkcs12.serialize_key_and_certificates( name=b"cbltest", key=self.private_key, cert=self.certificate, cas=None, - encryption_algorithm=BestAvailableEncryption(self.password.encode('utf-8')), + encryption_algorithm=enc, ) return ret_val @@ -54,7 +68,6 @@ def create_ca_certificate(CN: str) -> CertKeyPair: public_exponent=65537, key_size=2048, ) - #private_key = ec.generate_private_key(ec.SECP256R1()) cn_attribute = Name([NameAttribute(NameOID.COMMON_NAME, CN)]) not_valid_before = datetime.now(timezone.utc) not_valid_after = not_valid_before + timedelta(days=1) @@ -77,7 +90,6 @@ def create_ca_certificate(CN: str) -> CertKeyPair: def create_leaf_certificate( CN: str, *, issuer_data: CertKeyPair | None = None ) -> CertKeyPair: - #private_key = ec.generate_private_key(ec.SECP256R1()) private_key = rsa.generate_private_key( public_exponent=65537, key_size=2048, diff --git a/client/src/cbltest/httplog.py b/client/src/cbltest/httplog.py index dc47cdc6c..549e54ff2 100644 --- a/client/src/cbltest/httplog.py +++ b/client/src/cbltest/httplog.py @@ -9,27 +9,40 @@ class _HttpLogWriter: __record_path: Path = Path("http_log") __fname_prefix: str + __folder_name: str def __init__(self, num: int): test_name = CBLPyTestGlobal.running_test_name if test_name.startswith("test_"): test_name = test_name[5:] - self.__fname_prefix = f"{num:05d}_{test_name}" + + mod_num = num % 100 + self.__fname_prefix = f"{mod_num:02d}_{test_name}" + self.__folder_name = f"{(num // 10000000) * 10000000:08d}" + + def __get_path(self, suffix: str) -> Path: + (self.__record_path / self.__folder_name).mkdir(parents=True, exist_ok=True) + return ( + self.__record_path + / self.__folder_name + / f"{self.__fname_prefix}_{suffix}.txt" + ) def write_begin(self, header: str, payload: str) -> None: - send_log_path = self.__record_path / f"{self.__fname_prefix}_begin.txt" + (self.__record_path / self.__folder_name).mkdir(parents=True, exist_ok=True) + send_log_path = self.__get_path("begin") with open(send_log_path, "x") as fout: fout.write(header) fout.write("\n\n") fout.write(payload) def write_error(self, msg: str) -> None: - recv_log_path = self.__record_path / f"{self.__fname_prefix}_error.txt" + recv_log_path = self.__get_path("error") with open(recv_log_path, "x") as fout: fout.write(msg) def write_end(self, header: str, payload: str) -> None: - send_log_path = self.__record_path / f"{self.__fname_prefix}_end.txt" + send_log_path = self.__get_path("end") with open(send_log_path, "x") as fout: fout.write(header) fout.write("\n\n") diff --git a/client/src/cbltest/requests.py b/client/src/cbltest/requests.py index deb111e76..dd7c2b0ff 100644 --- a/client/src/cbltest/requests.py +++ b/client/src/cbltest/requests.py @@ -290,8 +290,9 @@ async def send_request( the JSON configuration file)""" writer = get_next_writer() url = self.__server_urls[index] + header = f"{r} @ TS-{index}" writer.write_begin( - str(r), r.payload.serialize() if r.payload is not None else "" + header, r.payload.serialize() if r.payload is not None else "" ) try: diff --git a/client/src/cbltest/v1/requests.py b/client/src/cbltest/v1/requests.py index 8d36e1890..69f5af3d5 100644 --- a/client/src/cbltest/v1/requests.py +++ b/client/src/cbltest/v1/requests.py @@ -1086,6 +1086,7 @@ def __init__(self, uuid: UUID, payload: PostStopListenerRequestBody): 1, uuid, "stopListener", PostStopListenerRequestBody, payload=payload ) + class PostStartMultipeerReplicatorRequest(TestServerRequest): """ A POST /startMultipeerReplicator request as specified in version 1 of the @@ -1094,9 +1095,14 @@ class PostStartMultipeerReplicatorRequest(TestServerRequest): def __init__(self, uuid: UUID, payload: PostStartMultipeerReplicatorRequestBody): super().__init__( - 1, uuid, "startMultipeerReplicator", PostStartMultipeerReplicatorRequestBody, payload=payload + 1, + uuid, + "startMultipeerReplicator", + PostStartMultipeerReplicatorRequestBody, + payload=payload, ) + class PostStopMultipeerReplicatorRequest(TestServerRequest): """ A POST /startMultipeerReplicator request as specified in version 1 of the @@ -1105,16 +1111,27 @@ class PostStopMultipeerReplicatorRequest(TestServerRequest): def __init__(self, uuid: UUID, payload: PostStopMultipeerReplicatorRequestBody): super().__init__( - 1, uuid, "stopMultipeerReplicator", PostStopMultipeerReplicatorRequestBody, payload=payload + 1, + uuid, + "stopMultipeerReplicator", + PostStopMultipeerReplicatorRequestBody, + payload=payload, ) + class PostGetMultipeerReplicatorStatusRequest(TestServerRequest): """ A POST /getMultipeerReplicatorStatus request as specified in version 1 of the `spec `_ """ - def __init__(self, uuid: UUID, payload: PostGetMultipeerReplicatorStatusRequestBody): + def __init__( + self, uuid: UUID, payload: PostGetMultipeerReplicatorStatusRequestBody + ): super().__init__( - 1, uuid, "getMultipeerReplicatorStatus", PostGetMultipeerReplicatorStatusRequestBody, payload=payload - ) \ No newline at end of file + 1, + uuid, + "getMultipeerReplicatorStatus", + PostGetMultipeerReplicatorStatusRequestBody, + payload=payload, + ) From ec7758ad84a83f493dd9c3d2c4fb10dc7ba9aadd Mon Sep 17 00:00:00 2001 From: Jim Borden Date: Thu, 19 Jun 2025 10:17:32 +0900 Subject: [PATCH 10/13] Implemented first multipeer test Added a few needed things to the TDK API to accomplish this --- .../src/cbltest/api/multipeer_replicator.py | 57 ++++++++++++---- client/src/cbltest/api/test_functions.py | 21 ++++++ spec/tests/dev_e2e/010-multipeer.md | 21 ++++++ tests/dev_e2e/test_multipeer.py | 68 +++++++++++++++++++ 4 files changed, 155 insertions(+), 12 deletions(-) create mode 100644 spec/tests/dev_e2e/010-multipeer.md create mode 100644 tests/dev_e2e/test_multipeer.py diff --git a/client/src/cbltest/api/multipeer_replicator.py b/client/src/cbltest/api/multipeer_replicator.py index 3aa4dc9da..983d700b5 100644 --- a/client/src/cbltest/api/multipeer_replicator.py +++ b/client/src/cbltest/api/multipeer_replicator.py @@ -1,10 +1,15 @@ +import asyncio +from datetime import timedelta +from time import time from typing import cast from opentelemetry.trace import get_tracer from cbltest.api.database import Database +from cbltest.api.error import CblTestError, CblTimeoutError from cbltest.api.multipeer_replicator_types import MultipeerReplicatorAuthenticator from cbltest.api.replicator import ReplicatorCollectionEntry +from cbltest.api.replicator_types import ReplicatorActivityLevel from cbltest.api.x509_certificate import CertKeyPair, create_leaf_certificate from cbltest.logging import cbl_error, cbl_trace from cbltest.requests import TestServerRequestType @@ -135,28 +140,56 @@ async def stop(self) -> None: self.__id = "" - async def get_status(self) -> MultipeerReplicatorStatus | None: + async def get_status(self) -> MultipeerReplicatorStatus: """ Gets the status of the multipeer replicator """ with self.__tracer.start_as_current_span("get_multipeer_replicator_status"): if not self.__id: - cbl_error( - "Cannot get status of multipeer replicator, it has not been started" - ) - return None + raise CblTestError("MultipeerReplicator start call has not completed!") req = self.__request_factory.create_request( TestServerRequestType.MULTIPEER_REPLICATOR_STATUS, PostGetMultipeerReplicatorStatusRequestBody(self.__id), ) resp = await self.__request_factory.send_request(self.__index, req) - if resp.error is not None: - cbl_error( - "Failed to get multipeer replicator status (see trace log for details)" - ) - cbl_trace(resp.error.message) - return None - cast_resp = cast(PostGetMultipeerReplicatorStatusResponse, resp) return MultipeerReplicatorStatus(cast_resp.replicators) + + async def wait_for_idle( + self, + interval: timedelta = timedelta(seconds=1), + timeout: timedelta = timedelta(seconds=30), + ) -> MultipeerReplicatorStatus: + """ + Waits for a given timeout, polling at a set interval, until the Replicator changes to a desired state + + :param activity: The activity level to wait for + :param interval: The polling interval (default 1s) + :param timeout: The time limit to wait for the state change (default 30s) + """ + with self.__tracer.start_as_current_span("wait_for"): + assert interval.total_seconds() > 0.0, ( + "Zero interval makes no sense, try again" + ) + assert timeout.total_seconds() >= 1.0, ( + "Timeout too short, must be at least 1 second" + ) + + all_idle = False + start = time() + next_status: MultipeerReplicatorStatus = MultipeerReplicatorStatus([]) + while not all_idle: + elapsed = time() - start + if elapsed > timeout.total_seconds(): + raise CblTimeoutError("Timeout waiting for replicator status") + + next_status = await self.get_status() + all_idle = len(next_status.replicators) > 0 and all( + r.status.activity == ReplicatorActivityLevel.IDLE + for r in next_status.replicators + ) + if not all_idle: + await asyncio.sleep(interval.total_seconds()) + + return next_status diff --git a/client/src/cbltest/api/test_functions.py b/client/src/cbltest/api/test_functions.py index 16f698bae..49a84110b 100644 --- a/client/src/cbltest/api/test_functions.py +++ b/client/src/cbltest/api/test_functions.py @@ -118,6 +118,27 @@ def compare_doc_results( return DocsCompareResult(True) +def compare_doc_results_p2p( + local: list[AllDocumentsEntry], remote: list[AllDocumentsEntry] +) -> DocsCompareResult: + local_dict: dict[str, str] = {entry.id: entry.rev for entry in local} + remote_dict: dict[str, str] = {entry.id: entry.rev for entry in remote} + + for id in local_dict: + if id not in remote_dict: + return DocsCompareResult( + False, f"Doc '{id}' present in {local_dict} but not {remote_dict}" + ) + + if not _compare_revisions(local_dict[id], [remote_dict[id], None]): + return DocsCompareResult( + False, + f"Doc '{id}' mismatched revid (local: {local_dict[id]}, remote: {remote_dict[id]})", + ) + + return DocsCompareResult(True) + + async def compare_local_and_remote( local: Database, remote: SyncGateway, diff --git a/spec/tests/dev_e2e/010-multipeer.md b/spec/tests/dev_e2e/010-multipeer.md new file mode 100644 index 000000000..dbde816ba --- /dev/null +++ b/spec/tests/dev_e2e/010-multipeer.md @@ -0,0 +1,21 @@ +# Test Cases + +## test_medium_mesh_sanity + +### Description + +Test that if populating data on one device in a mesh, it replicates to all others. + +### Steps + +1. Reset local database and load `empty` dataset on all devices +2. Add 10 docs to the database on device 1 + - docID: doc<1-10> + - body: {"random": } +3. Start a multipeer replicator on all devices + - peerGroupID: “com.couchbase.testing” + - identity: anonymous + - authenticator: accept-all (null) + - collections: default collection +4. Wait for idle status on all devices except device 1 +5. Check that all databases on devices other than 1 have identical content to the database on device 1 \ No newline at end of file diff --git a/tests/dev_e2e/test_multipeer.py b/tests/dev_e2e/test_multipeer.py new file mode 100644 index 000000000..0ef9fc99d --- /dev/null +++ b/tests/dev_e2e/test_multipeer.py @@ -0,0 +1,68 @@ +import asyncio +from random import randint + +import pytest +from cbltest import CBLPyTest +from cbltest.api.cbltestclass import CBLTestClass +from cbltest.api.multipeer_replicator import MultipeerReplicator +from cbltest.api.replicator_types import ReplicatorCollectionEntry +from cbltest.api.test_functions import compare_doc_results_p2p + + +@pytest.mark.min_test_servers(2) +class TestMultipeer(CBLTestClass): + @pytest.mark.asyncio(loop_scope="session") + async def test_medium_mesh_sanity(self, cblpytest: CBLPyTest): + self.mark_test_step( + "Reset local database and load `empty` dataset on all devices" + ) + reset_tasks = [ts.create_and_reset_db(["db1"]) for ts in cblpytest.test_servers] + all_devices_dbs = await asyncio.gather(*reset_tasks) + all_dbs = [dbs[0] for dbs in all_devices_dbs] + db1 = all_dbs[0] + + self.mark_test_step(""" + Add 10 docs to the database on device 1 + * docID: doc<1-10> + * body: {"random": } + """) + async with db1.batch_updater() as b: + for i in range(1, 11): + b.upsert_document( + "_default._default", f"doc{i}", [{"random": randint(1, 100000)}] + ) + + self.mark_test_step(""" + Start a multipeer replicator on all devices + * peerGroupID: “com.couchbase.testing” + * identity: anonymous + * authenticator: accept-all (null) + * collections: default collection + """) + multipeer_replicators = [ + MultipeerReplicator( + "couchtest", db, [ReplicatorCollectionEntry(["_default._default"])] + ) + for db in all_dbs + ] + mpstart_tasks = [multipeer.start() for multipeer in multipeer_replicators] + await asyncio.gather(*mpstart_tasks) + + self.mark_test_step("Wait for idle status on all devices except device 1") + for multipeer in multipeer_replicators[1:]: + status = await multipeer.wait_for_idle() + assert all(r.status.replicator_error is None for r in status.replicators), ( + "Multipeer replicator should not have any errors" + ) + + self.mark_test_step( + "Check that all databases on devices other than 1 have identical content to the database on device 1" + ) + all_docs_collection = [ + db.get_all_documents("_default._default") for db in all_dbs + ] + all_docs_results = await asyncio.gather(*all_docs_collection) + for all_docs in all_docs_results[1:]: + assert compare_doc_results_p2p( + all_docs_results[0]["_default._default"], all_docs["_default._default"] + ), "All databases should have the same content" From 30c35259c5129cdbf580cd9fb2e13140f627388e Mon Sep 17 00:00:00 2001 From: Jim Borden Date: Thu, 19 Jun 2025 12:04:11 +0900 Subject: [PATCH 11/13] Add in remaining multipeer dev e2e tests One is currently skipped due to CBL 7100 --- spec/tests/dev_e2e/010-multipeer.md | 47 ++++++++++- tests/dev_e2e/test_multipeer.py | 126 ++++++++++++++++++++++++++++ 2 files changed, 172 insertions(+), 1 deletion(-) diff --git a/spec/tests/dev_e2e/010-multipeer.md b/spec/tests/dev_e2e/010-multipeer.md index dbde816ba..f758bd787 100644 --- a/spec/tests/dev_e2e/010-multipeer.md +++ b/spec/tests/dev_e2e/010-multipeer.md @@ -18,4 +18,49 @@ Test that if populating data on one device in a mesh, it replicates to all other - authenticator: accept-all (null) - collections: default collection 4. Wait for idle status on all devices except device 1 -5. Check that all databases on devices other than 1 have identical content to the database on device 1 \ No newline at end of file +5. Check that all databases on devices other than 1 have identical content to the database on device 1 + +## test_medium_mesh_consistency + +### Description + +Test that if populating data on all devices in a mesh setup, all devices end up with the same data + +### Steps + +1. Reset local database and load `empty` dataset on all devices +2. Add 10 docs to the database on all devices + - docID: doc<1-x> (10 per device) + - body: {"random": } +3. Start a multipeer replicator on all devices + - peerGroupID: “com.couchbase.testing” + - identity: anonymous + - authenticator: accept-all (null) + - collections: default collection +4. Wait for idle status on all devices +5. Check that all device databases have the same content + +## test_rapid_availability_changes + +### Description + +Test that rapidly starting and stopping a multipeer replicator doesn't corrupt it, and +leaves it in an expected state + +### Steps + +`mode` = [start, stop] + +1. Reset local database and load `empty` dataset on two devices +2. Start a multipeer replicator on each device + - peerGroupID: “com.couchbase.testing” + - identity: anonymous + - authenticator: accept-all (null) + - collections: default collection +3. Stop multipeer replicator 2 after 5 seconds +4. Start multipeer replicator 2 after 5 seconds +5. Repeat the above two steps 5 times +6. (If `mode` is stop) stop multipeer replicator 2 after 5 seconds +7. + a. If `mode` is `start`, verify that peer 2 is visible to peer 1 + b. If `mode` is `stop`, verify that peer 2 is not visible to peer 1 diff --git a/tests/dev_e2e/test_multipeer.py b/tests/dev_e2e/test_multipeer.py index 0ef9fc99d..9e8a9aef3 100644 --- a/tests/dev_e2e/test_multipeer.py +++ b/tests/dev_e2e/test_multipeer.py @@ -1,4 +1,5 @@ import asyncio +from enum import Enum from random import randint import pytest @@ -9,6 +10,11 @@ from cbltest.api.test_functions import compare_doc_results_p2p +class RapidChangesMode(Enum): + START = ("start",) + STOP = "stop" + + @pytest.mark.min_test_servers(2) class TestMultipeer(CBLTestClass): @pytest.mark.asyncio(loop_scope="session") @@ -66,3 +72,123 @@ async def test_medium_mesh_sanity(self, cblpytest: CBLPyTest): assert compare_doc_results_p2p( all_docs_results[0]["_default._default"], all_docs["_default._default"] ), "All databases should have the same content" + + await asyncio.gather(*[multipeer.stop() for multipeer in multipeer_replicators]) + + @pytest.mark.asyncio(loop_scope="session") + async def test_medium_mesh_consistency( + self, + cblpytest: CBLPyTest, + ): + self.mark_test_step( + "Reset local database and load `empty` dataset on all devices" + ) + reset_tasks = [ts.create_and_reset_db(["db1"]) for ts in cblpytest.test_servers] + all_devices_dbs = await asyncio.gather(*reset_tasks) + all_dbs = [dbs[0] for dbs in all_devices_dbs] + + self.mark_test_step(""" + Add 10 docs to the database on all devices + * docID: doc<1-x> (10 per device) + * body: {"random": } + """) + doc_num = 1 + for db in all_dbs: + async with db.batch_updater() as b: + for _ in range(10): + b.upsert_document( + "_default._default", + f"doc{doc_num}", + [{"random": randint(1, 100000)}], + ) + doc_num += 1 + + self.mark_test_step(""" + Start a multipeer replicator on all devices + * peerGroupID: “com.couchbase.testing” + * identity: anonymous + * authenticator: accept-all (null) + * collections: default collection + """) + multipeer_replicators = [ + MultipeerReplicator( + "couchtest", db, [ReplicatorCollectionEntry(["_default._default"])] + ) + for db in all_dbs + ] + mpstart_tasks = [multipeer.start() for multipeer in multipeer_replicators] + await asyncio.gather(*mpstart_tasks) + + self.mark_test_step("Wait for idle status on all devices") + for multipeer in multipeer_replicators: + status = await multipeer.wait_for_idle() + assert all(r.status.replicator_error is None for r in status.replicators), ( + "Multipeer replicator should not have any errors" + ) + + self.mark_test_step("Check that all device databases have the same content") + all_docs_collection = [ + db.get_all_documents("_default._default") for db in all_dbs + ] + all_docs_results = await asyncio.gather(*all_docs_collection) + for all_docs in all_docs_results[1:]: + assert compare_doc_results_p2p( + all_docs_results[0]["_default._default"], all_docs["_default._default"] + ), "All databases should have the same content" + + await asyncio.gather(*[multipeer.stop() for multipeer in multipeer_replicators]) + + @pytest.mark.asyncio(loop_scope="session") + @pytest.mark.parametrize("mode", [RapidChangesMode.START, RapidChangesMode.STOP]) + @pytest.mark.skip("CBL-7100") + async def test_rapid_availability_changes( + self, cblpytest: CBLPyTest, mode: RapidChangesMode + ): + self.mark_test_step( + "Reset local database and load `empty` dataset on two devices" + ) + reset_tasks = [ + ts.create_and_reset_db(["db1"]) for ts in cblpytest.test_servers[:2] + ] + all_devices_dbs = await asyncio.gather(*reset_tasks) + all_dbs = [dbs[0] for dbs in all_devices_dbs] + + self.mark_test_step(""" + Start a multipeer replicator on each device + * peerGroupID: “com.couchbase.testing” + * identity: anonymous + * authenticator: accept-all (null) + * collections: default collection + """) + multipeer_replicators = [ + MultipeerReplicator( + "couchtest", db, [ReplicatorCollectionEntry(["_default._default"])] + ) + for db in all_dbs + ] + mpstart_tasks = [multipeer.start() for multipeer in multipeer_replicators] + await asyncio.gather(*mpstart_tasks) + + for _ in range(5): + self.mark_test_step("Stop multipeer replicator 2 after 5 seconds") + await asyncio.sleep(5) + await multipeer_replicators[1].stop() + + self.mark_test_step("Start multipeer replicator 2 after 5 seconds") + await asyncio.sleep(5) + await multipeer_replicators[1].start() + + if mode == RapidChangesMode.STOP: + self.mark_test_step("Stop multipeer replicator 2 after 5 seconds") + await asyncio.sleep(5) + await multipeer_replicators[1].stop() + + self.mark_test_step("Verify that peer 2 is not visible to peer 1") + status = await multipeer_replicators[0].get_status() + assert len(status.replicators) == 0, ( + "Peer 2 should not be visible to peer 1" + ) + else: + self.mark_test_step("Verify that peer 2 is visible to peer 1") + status = await multipeer_replicators[0].get_status() + assert len(status.replicators) == 1, "Peer 2 should be visible to peer 1" From 39b7b79f3da5796fa68629ce74299b712a692e03 Mon Sep 17 00:00:00 2001 From: "G. Blake Meike" Date: Fri, 20 Jun 2025 12:46:35 -0700 Subject: [PATCH 12/13] Lots more code for the multipeer replicator. (#217) Completely untested and several thing (notably the authenticator) probably do not work --- servers/jak/android/README.md | 20 +- servers/jak/android/app/build.gradle | 6 +- .../lite/android/mobiletest/AndroidTestApp.kt | 18 + .../v1/MultipeerReplicatorManager.java | 459 ++++++++++++++++++ .../services/MultipeerReplicatorService.java | 108 +++++ .../lite/mobiletest/PlatformDispatcher.java | 49 ++ .../lite/mobiletest/TestContext.java | 81 ++++ ...{TestContext.java => BaseTestContext.java} | 123 ++--- .../lite/mobiletest/GetDispatcher.java | 2 + .../lite/mobiletest/PostDispatcher.java | 2 + .../endpoints/v1/BaseReplicatorManager.java | 167 +++++++ .../mobiletest/endpoints/v1/GetDocument.java | 6 +- .../endpoints/v1/ReplicatorManager.java | 350 +++++-------- .../services/ReplicatorService.java | 4 +- .../lite/mobiletest/PlatformDispatcher.java | 28 ++ .../lite/mobiletest/TestContext.java | 32 ++ servers/jak/version.txt | 2 +- .../lite/javaws/mobiletest/TestServerApp.java | 7 +- servers/jak/webservice/build.gradle | 1 - 19 files changed, 1152 insertions(+), 313 deletions(-) create mode 100644 servers/jak/android/app/src/main/java/com/couchbase/lite/android/mobiletest/endpoints/v1/MultipeerReplicatorManager.java create mode 100644 servers/jak/android/app/src/main/java/com/couchbase/lite/android/mobiletest/services/MultipeerReplicatorService.java create mode 100644 servers/jak/android/app/src/main/java/com/couchbase/lite/mobiletest/PlatformDispatcher.java create mode 100644 servers/jak/android/app/src/main/java/com/couchbase/lite/mobiletest/TestContext.java rename servers/jak/shared/common/main/java/com/couchbase/lite/mobiletest/{TestContext.java => BaseTestContext.java} (92%) create mode 100644 servers/jak/shared/common/main/java/com/couchbase/lite/mobiletest/endpoints/v1/BaseReplicatorManager.java create mode 100644 servers/jak/shared/jvm/main/java/com/couchbase/lite/mobiletest/PlatformDispatcher.java create mode 100644 servers/jak/shared/jvm/main/java/com/couchbase/lite/mobiletest/TestContext.java diff --git a/servers/jak/android/README.md b/servers/jak/android/README.md index f2e77d1cf..f4ff0c5fb 100644 --- a/servers/jak/android/README.md +++ b/servers/jak/android/README.md @@ -1,14 +1,18 @@ -# This app complies with Couchbase Lite Test Server REST API v1.1,1 +# Android test server +This app complies with Couchbase Lite Test Server REST API v1.2.1 # Running the project -1. Open build.gradle with Android Studio -2. Use MainActivity.kt to launch the app +1. Open build.gradle as a project with Android Studio +2. Select an attached phone or start an emulator +3. Use the arrow in the gutter next to the class declaration in MainActivity.kt to launch the app (or use the Run menu) -Updating CBL API references - -* Go to App level build.gradle and change the "COUCHBASE_LITE_VERSION" variable to update API references +There are two ways of setting the version of CBL Android to be tested: -Note - We are using NanoHttpServer to run a http server. -To change the reference APIs modify the below line in app level build.gradle -==> compile "org.nanohttpd:nanohttpd:2.3.2-SNAPSHOT" <== +- On your local machine, in ~/.gradle/gradle.properties define `cblVersion`. E.g., `cblVersion=3.3.0-33` +- At compile time, define the property `cblVersion` on the command line. E.g., `./gradlew assemble -PcblVersion=...` +Note - This project uses NanoHttpServer to run an http server. That project seems to be moribund. If you ever need to change the version used here, change this line: + implementation "org.nanohttpd:nanohttpd:2.3.2-SNAPSHOT" + +... in app/build.gradle diff --git a/servers/jak/android/app/build.gradle b/servers/jak/android/app/build.gradle index 40bdbca73..c159b282d 100644 --- a/servers/jak/android/app/build.gradle +++ b/servers/jak/android/app/build.gradle @@ -142,9 +142,9 @@ dependencies { implementation "io.insert-koin:koin-core:3.3.3" implementation "io.insert-koin:koin-android:3.3.3" - implementation 'androidx.appcompat:appcompat:1.7.0' - implementation 'androidx.constraintlayout:constraintlayout:2.1.4' - implementation 'androidx.databinding:databinding-runtime:8.5.2' + implementation 'androidx.appcompat:appcompat:1.7.1' + implementation 'androidx.constraintlayout:constraintlayout:2.2.1' + implementation 'androidx.databinding:databinding-runtime:8.10.1' implementation 'com.squareup.moshi:moshi:1.14.0' diff --git a/servers/jak/android/app/src/main/java/com/couchbase/lite/android/mobiletest/AndroidTestApp.kt b/servers/jak/android/app/src/main/java/com/couchbase/lite/android/mobiletest/AndroidTestApp.kt index 9ab963151..9d55675c4 100644 --- a/servers/jak/android/app/src/main/java/com/couchbase/lite/android/mobiletest/AndroidTestApp.kt +++ b/servers/jak/android/app/src/main/java/com/couchbase/lite/android/mobiletest/AndroidTestApp.kt @@ -22,6 +22,7 @@ import com.couchbase.lite.CouchbaseLite import com.couchbase.lite.CouchbaseLiteException import com.couchbase.lite.KeyStoreUtils import com.couchbase.lite.TLSIdentity +import com.couchbase.lite.android.mobiletest.services.MultipeerReplicatorService import com.couchbase.lite.internal.core.CBLVersion import com.couchbase.lite.mobiletest.TestApp import java.io.IOException @@ -30,9 +31,13 @@ import java.security.NoSuchAlgorithmException import java.security.UnrecoverableEntryException import java.security.cert.CertificateException import java.util.* +import java.util.concurrent.atomic.AtomicReference class AndroidTestApp(private val context: Context) : TestApp("Android") { + private val multipeerReplSvc = AtomicReference() + + override fun initCBL() { CouchbaseLite.init(context, true) } @@ -117,4 +122,17 @@ class AndroidTestApp(private val context: Context) : TestApp("Android") { } return TLSIdentity.getIdentity("ClientCertsSelfsigned")!! } + + + fun getMultipeerReplSvc(): MultipeerReplicatorService { + val mgr = multipeerReplSvc.get() + if (mgr == null) { + multipeerReplSvc.compareAndSet(null, MultipeerReplicatorService()) + } + return multipeerReplSvc.get() + } + + fun clearMultipeerReplSvc(): MultipeerReplicatorService? { + return multipeerReplSvc.getAndSet(null) + } } diff --git a/servers/jak/android/app/src/main/java/com/couchbase/lite/android/mobiletest/endpoints/v1/MultipeerReplicatorManager.java b/servers/jak/android/app/src/main/java/com/couchbase/lite/android/mobiletest/endpoints/v1/MultipeerReplicatorManager.java new file mode 100644 index 000000000..fa739e817 --- /dev/null +++ b/servers/jak/android/app/src/main/java/com/couchbase/lite/android/mobiletest/endpoints/v1/MultipeerReplicatorManager.java @@ -0,0 +1,459 @@ +// +// Copyright (c) 2025 Couchbase, Inc All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +package com.couchbase.lite.android.mobiletest.endpoints.v1; + +import android.text.TextUtils; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.security.GeneralSecurityException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.function.Supplier; + +import com.couchbase.lite.Conflict; +import com.couchbase.lite.CouchbaseLiteException; +import com.couchbase.lite.Database; +import com.couchbase.lite.Document; +import com.couchbase.lite.KeyStoreUtils; +import com.couchbase.lite.MultipeerCollectionConfiguration; +import com.couchbase.lite.MultipeerReplicatorConfiguration; +import com.couchbase.lite.MutableArray; +import com.couchbase.lite.MutableDocument; +import com.couchbase.lite.PeerInfo; +import com.couchbase.lite.TLSIdentity; +import com.couchbase.lite.android.mobiletest.services.MultipeerReplicatorService; +import com.couchbase.lite.internal.utils.PlatformUtils; +import com.couchbase.lite.mobiletest.TestContext; +import com.couchbase.lite.mobiletest.endpoints.v1.BaseReplicatorManager; +import com.couchbase.lite.mobiletest.errors.CblApiFailure; +import com.couchbase.lite.mobiletest.errors.ClientError; +import com.couchbase.lite.mobiletest.services.DatabaseService; +import com.couchbase.lite.mobiletest.services.Log; +import com.couchbase.lite.mobiletest.trees.TypedList; +import com.couchbase.lite.mobiletest.trees.TypedMap; +import com.couchbase.lite.mobiletest.util.StringUtils; + + +public class MultipeerReplicatorManager extends BaseReplicatorManager { + private static final String TAG = "MP_REPL_MGR_V1"; + + private static final String KEY_ID = "peerID"; + private static final String KEY_STATUS = "status"; + private static final String KEY_REPLICATORS = "replicators"; + + // Create replicator + private static final String KEY_PEER_GROUP = "peerGroupID"; + private static final String KEY_DATABASE = "database"; + private static final String KEY_COLLECTIONS = "collections"; + private static final String KEY_IDENTITY = "identity"; + private static final String KEY_AUTHENTICATOR = "authenticator"; + + // Identity + private static final String KEY_IDENTITY_ENCODING = "encoding"; + private static final String KEY_IDENTITY_DATA = "data"; + private static final String KEY_IDENTITY_PASSWORD = "password"; + + // Authenticator + private static final String KEY_AUTH_CERT = "certificate"; + private static final String TYPE_CERT_AUTH_TYPE = "CA-CERT"; + + + private interface ConfigurableConflictResolver extends MultipeerCollectionConfiguration.ConflictResolver { + void configure(@Nullable TypedMap config); + } + + private static class LocalWinsResolver implements ConfigurableConflictResolver { + @Override + public void configure(@Nullable TypedMap config) { + // no configuration needed + } + + @Nullable + @Override + public Document resolve(@NonNull PeerInfo.PeerId peerId, @NonNull Conflict conflict) { + return conflict.getLocalDocument(); + } + } + + private static class RemoteWinsResolver implements ConfigurableConflictResolver { + @Override + public void configure(@Nullable TypedMap config) { + // no configuration needed + } + + @Nullable + @Override + public Document resolve(@NonNull PeerInfo.PeerId peerId, @NonNull Conflict conflict) { + return conflict.getRemoteDocument(); + } + } + + private static class DeleteResolver implements ConfigurableConflictResolver { + @Override + public void configure(@Nullable TypedMap config) { + // no configuration needed + } + + @Nullable + @Override + public Document resolve(@NonNull PeerInfo.PeerId peerId, @NonNull Conflict conflict) { return null; } + } + + private static class MergeResolver implements ConfigurableConflictResolver { + private static final String KEY_PROPERTY = "property"; + + private static final Set MERGE_RESOLVER_KEYS; + static { + final Set l = new HashSet<>(); + l.add(KEY_PROPERTY); + MERGE_RESOLVER_KEYS = Collections.unmodifiableSet(l); + } + + + private String docProp; + + @Override + public void configure(@Nullable TypedMap config) { + if (config == null) { throw new ClientError("Merge resolver requires configuration"); } + + config.validate(MERGE_RESOLVER_KEYS); + + final String prop = config.getString(KEY_PROPERTY); + if (prop == null) { throw new ClientError("Merge resolver requires a property name"); } + + docProp = prop; + } + + @Nullable + @Override + public Document resolve(@NonNull PeerInfo.PeerId peerId, @NonNull Conflict conflict) { + final Document localDoc = conflict.getLocalDocument(); + final Document remoteDoc = conflict.getRemoteDocument(); + if ((localDoc == null) || (remoteDoc == null)) { return null; } + + final MutableArray mergedVal = new MutableArray(); + mergedVal.addValue(localDoc.getValue(docProp)); + mergedVal.addValue(remoteDoc.getValue(docProp)); + + final MutableDocument mergedDoc = localDoc.toMutable(); + mergedDoc.setValue(docProp, mergedVal); + + return mergedDoc; + } + } + + private static final Set LEGAL_CREATE_KEYS; + static { + final Set l = new HashSet<>(); + l.add(KEY_DATABASE); + l.add(KEY_COLLECTIONS); + l.add(KEY_PEER_GROUP); + l.add(KEY_IDENTITY); + l.add(KEY_AUTHENTICATOR); + LEGAL_CREATE_KEYS = Collections.unmodifiableSet(l); + } + + private static final Set LEGAL_COLLECTION_KEYS; + static { + final Set l = new HashSet<>(); + l.add(KEY_NAMES); + l.add(KEY_DOCUMENT_IDS); + l.add(KEY_PUSH_FILTER); + l.add(KEY_PULL_FILTER); + l.add(KEY_CONFLICT_RESOLVER); + LEGAL_COLLECTION_KEYS = Collections.unmodifiableSet(l); + } + + private static final Set LEGAL_IDENTITY_KEYS; + static { + final Set l = new HashSet<>(); + l.add(KEY_IDENTITY_ENCODING); + l.add(KEY_IDENTITY_DATA); + l.add(KEY_IDENTITY_PASSWORD); + LEGAL_IDENTITY_KEYS = Collections.unmodifiableSet(l); + } + + private static final Set LEGAL_CERTIFICATE_AUTH_KEYS; + static { + final Set l = new HashSet<>(); + l.add(KEY_AUTH_TYPE); + l.add(KEY_AUTH_CERT); + LEGAL_CERTIFICATE_AUTH_KEYS = Collections.unmodifiableSet(l); + } + + private static final Map> CONFLICT_RESOLVER_FACTORIES; + static { + final Map> m = new HashMap<>(); + m.put("local-wins", LocalWinsResolver::new); + m.put("remote-wins", RemoteWinsResolver::new); + m.put("delete", DeleteResolver::new); + m.put("merge", MergeResolver::new); + CONFLICT_RESOLVER_FACTORIES = Collections.unmodifiableMap(m); + } + + + @NonNull + private final MultipeerReplicatorService replSvc; + + public MultipeerReplicatorManager(@NonNull DatabaseService dbSvc, @NonNull MultipeerReplicatorService replSvc) { + super(dbSvc); + this.replSvc = replSvc; + } + + @NonNull + public Map createRepl(@NonNull TestContext ctxt, @NonNull TypedMap req) { + Log.p(TAG, "starting multipeer replicator"); + req.validate(LEGAL_CREATE_KEYS); + + final Map ret = new HashMap<>(); + ret.put(KEY_REPL_ID, replSvc.startReplicator(ctxt, buildConfig(ctxt, req))); + return ret; + } + + @NonNull + public Map getReplStatus(@NonNull TestContext ctxt, @NonNull TypedMap req) { + req.validate(LEGAL_REPL_ID_KEYS); + + final String replId = req.getString(KEY_REPL_ID); + if (replId == null) { throw new ClientError("Replicator id not specified"); } + + final List> peerInfoList = new ArrayList<>(); + final Set neighbors = replSvc.getNeighbors(ctxt, replId); + for (PeerInfo.PeerId neighbor: neighbors) { + final PeerInfo peerInfo = replSvc.getPeerStatus(ctxt, replId, neighbor); + final Map peerInfoMap = new HashMap<>(); + peerInfoMap.put(KEY_ID, peerInfo.getPeerId().toString()); + peerInfoMap.put(KEY_STATUS, parseReplStatus(peerInfo.getReplicatorStatus(), null)); + peerInfoList.add(peerInfoMap); + } + + final Map resp = new HashMap<>(); + resp.put(KEY_REPLICATORS, peerInfoList); + + return resp; + } + + @NonNull + public Map stopRepl(@NonNull TestContext ctxt, @NonNull TypedMap req) { + Log.p(TAG, "stopping multipeer replicator"); + req.validate(LEGAL_REPL_ID_KEYS); + + final String replId = req.getString(KEY_REPL_ID); + if (replId == null) { throw new ClientError("Multipeer Replicator stop doesn't specify the replicator id"); } + + replSvc.stopReplicator(ctxt, replId); + + return Collections.emptyMap(); + } + + @NonNull + private MultipeerReplicatorConfiguration buildConfig(@NonNull TestContext ctxt, @NonNull TypedMap config) { + final String peerGroup = config.getString(KEY_PEER_GROUP); + if (peerGroup == null) { + throw new ClientError("Multipeer Replicator configuration doesn't specify a peer group ID"); + } + + final MultipeerReplicatorConfiguration.Builder builder = new MultipeerReplicatorConfiguration.Builder(); + builder.setPeerGroupID(peerGroup); + + final String dbName = config.getString(KEY_DATABASE); + if (dbName == null) { throw new ClientError("Multipeer Replicator configuration doesn't specify a database"); } + + final TypedList collections = config.getList(KEY_COLLECTIONS); + if ((collections == null) || collections.isEmpty()) { + throw new ClientError("Multipeer Replicator specifies a null or empty list of collections"); + } + + final Database db = dbSvc.getOpenDb(ctxt, dbName); + + addCollections(db, collections, builder, ctxt); + + final TypedMap identity = config.getMap(KEY_IDENTITY); + if ((identity == null) || (identity.isEmpty())) { + throw new ClientError("Multipeer Replicator specifies a null or empty identity"); + } + addIdentity(identity, builder); + + final TypedMap authSpec = config.getMap(KEY_AUTHENTICATOR); + if (authSpec != null) { addCertificateAuthenticator(authSpec, builder); } + + return builder.build(); + } + + private void addCollections( + @NonNull Database db, + @NonNull TypedList collectionSpecs, + @NonNull MultipeerReplicatorConfiguration.Builder configBuilder, + @NonNull TestContext ctxt) { + for (int i = 0; i < collectionSpecs.size(); i++) { + final TypedMap collectionsSpec = collectionSpecs.getMap(i); + if ((collectionsSpec == null) || collectionsSpec.isEmpty()) { + throw new ClientError("Multipeer Replicator specifies a null or empty collection spec @" + i); + } + collectionsSpec.validate(LEGAL_COLLECTION_KEYS); + + final TypedList collectionNames = collectionsSpec.getList(KEY_NAMES); + if ((collectionNames == null) || (collectionNames.isEmpty())) { + throw new ClientError("Multipeer Replicator specifies a null or empty list of collection names@" + i); + } + + // All of the collections named in the array get the same configuration + for (int j = 0; j < collectionNames.size(); j++) { + final String collectionName = collectionNames.getString(j); + if (StringUtils.isEmpty(collectionName)) { + throw new ClientError("Empty collection name in multipeer collection spec @" + i + "/" + j); + } + final MultipeerCollectionConfiguration.Builder collectionBuilder + = new MultipeerCollectionConfiguration.Builder(dbSvc.getCollection(ctxt, db, collectionName)); + + configBuilder.addCollection(buildCollectionConfig(collectionsSpec, collectionBuilder)); + } + } + } + + @NonNull + private MultipeerCollectionConfiguration buildCollectionConfig( + @NonNull TypedMap spec, + @NonNull MultipeerCollectionConfiguration.Builder builder) { + final List docIds = getList(spec.getList(KEY_DOCUMENT_IDS)); + if (docIds != null) { builder.setDocumentIDs(docIds); } + + final TypedMap pushFilter = spec.getMap(KEY_PUSH_FILTER); + if (pushFilter != null) { builder.setPushFilter(buildReplicatorFilter(pushFilter)); } + + final TypedMap pullFilter = spec.getMap(KEY_PULL_FILTER); + if (pullFilter != null) { builder.setPullFilter(buildReplicatorFilter(pullFilter)); } + + final TypedMap conflictResolver = spec.getMap(KEY_CONFLICT_RESOLVER); + if (conflictResolver != null) { builder.setConflictResolver(buildConflictResolver(conflictResolver)); } + + return builder.build(); + } + + @NonNull + private MultipeerCollectionConfiguration.ReplicationFilter buildReplicatorFilter(@NonNull TypedMap spec) { + final String name = spec.getString(KEY_NAME); + if (name == null) { throw new ClientError("Filter doesn't specify a name"); } + switch (name) { + case FILTER_DOC_ID: + return buildDocIdFilter(spec.getMap(KEY_PARAMS)); + case FILTER_DELETED: + return replSvc.getDeletedDocFilter(); + default: + throw new ClientError("Unrecognized filter name: " + name); + } + } + + @NonNull + private MultipeerCollectionConfiguration.ReplicationFilter buildDocIdFilter(@Nullable TypedMap spec) { + if (spec == null) { throw new ClientError("DocId filter specifies no doc ids"); } + + final TypedMap documentIds = spec.getMap(KEY_DOC_IDS); + if (documentIds == null) { throw new ClientError("DocId filter specifies no doc ids"); } + + final Set permitted = new HashSet<>(); + + final Set collections = documentIds.getKeys(); + for (String collection: collections) { + final TypedList collectionDocs = documentIds.getList(collection); + if (collectionDocs == null) { + throw new ClientError("DocId filter: no doc ids specified for collection " + collection); + } + final int n = collectionDocs.size(); + for (int i = 0; i < n; i++) { permitted.add(collection + "." + collectionDocs.getString(i)); } + } + + return replSvc.getDocIdFilter(permitted); + } + + @NonNull + protected final MultipeerCollectionConfiguration.ConflictResolver buildConflictResolver(@NonNull TypedMap spec) { + final String name = spec.getString(KEY_NAME); + if (name == null) { throw new ClientError("No name specified for the conflict resolver"); } + + final Supplier resolverFactory + = CONFLICT_RESOLVER_FACTORIES.get(name.toLowerCase(Locale.US).trim()); + if (resolverFactory == null) { throw new ClientError("Unrecognized conflict resolver: " + name); } + + final ConfigurableConflictResolver resolver = resolverFactory.get(); + resolver.configure(spec.getMap(KEY_PARAMS)); + + return resolver; + } + + @SuppressWarnings("PMD.ExceptionAsFlowControl") + private void addIdentity(TypedMap identitySpec, MultipeerReplicatorConfiguration.Builder builder) { + identitySpec.validate(LEGAL_IDENTITY_KEYS); + + final byte[] data = PlatformUtils.getDecoder().decodeString(identitySpec.getString(KEY_IDENTITY_DATA)); + if (data == null) { throw new ClientError("Could not decode identity data for Multipeer Replicator"); } + + final String encoding = identitySpec.getString(KEY_IDENTITY_ENCODING); + if (encoding == null) { throw new ClientError("Null encoding for identity in Multipeer Replicator"); } + + final String password = identitySpec.getString(KEY_IDENTITY_PASSWORD); + if (password == null) { throw new ClientError("Null password for identity in Multipeer Replicator"); } + final char[] pwdChars = password.toCharArray(); + + try { + final String keyAlias = "android-multipeer-" + builder.getPeerGroupID(); + + try (InputStream in = new ByteArrayInputStream(data)) { + KeyStoreUtils.importEntry(encoding, in, pwdChars, "cbltest", pwdChars, keyAlias); + } + catch (GeneralSecurityException | IOException e) { + throw new CouchbaseLiteException("Failed to import identity", e); + } + + final TLSIdentity identity = TLSIdentity.getIdentity(keyAlias); + if (identity == null) { + throw new CouchbaseLiteException("Failed to create identity"); + } + + builder.setIdentity(identity); + } + catch (CouchbaseLiteException e) { + throw new CblApiFailure("Could not create identity for Multipeer Replicator", e); + } + } + + private void addCertificateAuthenticator( + @NonNull TypedMap spec, + MultipeerReplicatorConfiguration.Builder builder) { + spec.validate(LEGAL_CERTIFICATE_AUTH_KEYS); + + final String type = spec.getString(TYPE_CERT_AUTH_TYPE); + if (!TYPE_CERT_AUTH_TYPE.equals(type)) { throw new ClientError("Unrecognized certificate type: " + type); } + + final String cert = spec.getString(KEY_AUTH_CERT); + if (TextUtils.isEmpty(cert)) { throw new ClientError("Null or empty certificate"); } + + // decode the CA-CERT string + + //builder.setAuthenticator(new MultipeerCertificateAuthenticator(cert)); + } +} diff --git a/servers/jak/android/app/src/main/java/com/couchbase/lite/android/mobiletest/services/MultipeerReplicatorService.java b/servers/jak/android/app/src/main/java/com/couchbase/lite/android/mobiletest/services/MultipeerReplicatorService.java new file mode 100644 index 000000000..274e7fe97 --- /dev/null +++ b/servers/jak/android/app/src/main/java/com/couchbase/lite/android/mobiletest/services/MultipeerReplicatorService.java @@ -0,0 +1,108 @@ +// +// Copyright (c) 2025 Couchbase, Inc All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +package com.couchbase.lite.android.mobiletest.services; + +import androidx.annotation.NonNull; + +import java.util.EnumSet; +import java.util.Set; +import java.util.UUID; + +import com.couchbase.lite.CouchbaseLiteException; +import com.couchbase.lite.Document; +import com.couchbase.lite.DocumentFlag; +import com.couchbase.lite.MultipeerCollectionConfiguration; +import com.couchbase.lite.MultipeerReplicator; +import com.couchbase.lite.MultipeerReplicatorConfiguration; +import com.couchbase.lite.PeerInfo; +import com.couchbase.lite.mobiletest.TestContext; +import com.couchbase.lite.mobiletest.errors.CblApiFailure; +import com.couchbase.lite.mobiletest.errors.ClientError; +import com.couchbase.lite.mobiletest.services.DatabaseService; + + +public class MultipeerReplicatorService { + private static class DeletedDocFilter implements MultipeerCollectionConfiguration.ReplicationFilter { + @Override + public boolean filtered( + @NonNull PeerInfo.PeerId peerId, + @NonNull Document document, + @NonNull EnumSet flags) { + return flags.contains(DocumentFlag.DELETED); + } + } + + private static class DocIdFilter implements MultipeerCollectionConfiguration.ReplicationFilter { + @NonNull + private final Set permittedDocs; + + DocIdFilter(@NonNull Set docs) { permittedDocs = docs; } + + @Override + public boolean filtered( + @NonNull PeerInfo.PeerId peerId, + @NonNull Document document, + @NonNull EnumSet flags) { + return permittedDocs.contains(DatabaseService.getDocumentFullName(document)); + } + } + + + @NonNull + public String startReplicator(@NonNull TestContext ctxt, @NonNull MultipeerReplicatorConfiguration config) { + final MultipeerReplicator repl; + try { repl = new MultipeerReplicator(config); } + catch (CouchbaseLiteException e) { throw new CblApiFailure("Failed creating multipeer replicator", e); } + + final String replId = UUID.randomUUID().toString(); + ctxt.addMultipeerRepl(replId, repl); + repl.start(); + + return replId; + } + + @NonNull + public Set getNeighbors(@NonNull TestContext ctxt, @NonNull String id) { + final MultipeerReplicator repl = ctxt.getMultipeerRepl(id); + if (repl == null) { throw new ClientError("No such replicator: " + id); } + return repl.getNeighborPeers(); + } + + @NonNull + public PeerInfo getPeerStatus(@NonNull TestContext ctxt, @NonNull String id, @NonNull PeerInfo.PeerId peer) { + final MultipeerReplicator repl = ctxt.getMultipeerRepl(id); + if (repl == null) { throw new ClientError("No such multipeer replicator: " + id); } + return repl.getPeerInfo(peer); + } + + // Unlike a standard replicator, a multipeer replicator cannot be restarted + // When it is stopped we delete it from the context. + public void stopReplicator(TestContext ctxt, @NonNull String id) { + final MultipeerReplicator repl = ctxt.removeMultipeerRepl(id); + if (repl == null) { throw new ClientError("No such multipeer replicator: " + id); } + repl.stop(); + } + + @NonNull + public MultipeerCollectionConfiguration.ReplicationFilter getDeletedDocFilter() { + return new DeletedDocFilter(); + } + + @NonNull + public MultipeerCollectionConfiguration.ReplicationFilter getDocIdFilter(@NonNull Set permitted) { + return new DocIdFilter(permitted); + } +} diff --git a/servers/jak/android/app/src/main/java/com/couchbase/lite/mobiletest/PlatformDispatcher.java b/servers/jak/android/app/src/main/java/com/couchbase/lite/mobiletest/PlatformDispatcher.java new file mode 100644 index 000000000..cc04ca6fd --- /dev/null +++ b/servers/jak/android/app/src/main/java/com/couchbase/lite/mobiletest/PlatformDispatcher.java @@ -0,0 +1,49 @@ +// +// Copyright (c) 2025 Couchbase, Inc All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +package com.couchbase.lite.mobiletest; + +import com.couchbase.lite.android.mobiletest.AndroidTestApp; +import com.couchbase.lite.android.mobiletest.endpoints.v1.MultipeerReplicatorManager; + + +final class PlatformDispatcher { + private PlatformDispatcher() { + // Prevent instantiation + } + + public static void addGetEndpoints(GetDispatcher dispatcher, TestApp app) { + // there are no Android-specific GET endpoints + } + + public static void addPostEndpoints(PostDispatcher dispatcher, TestApp app) { + final AndroidTestApp androidApp = (AndroidTestApp) app; + dispatcher.addEndpoint( + 1, + "/startMultipeerReplicator", + (c, r) -> new MultipeerReplicatorManager(app.getDbSvc(), androidApp.getMultipeerReplSvc()) + .createRepl(app.getTestContext(c), r)); + dispatcher.addEndpoint( + 1, + "/getMultipeerReplicatorStatus", + (c, r) -> new MultipeerReplicatorManager(app.getDbSvc(), androidApp.getMultipeerReplSvc()) + .getReplStatus(app.getTestContext(c), r)); + dispatcher.addEndpoint( + 1, + "/stopMultipeerReplicator", + (c, r) -> new MultipeerReplicatorManager(app.getDbSvc(), androidApp.getMultipeerReplSvc()) + .stopRepl(app.getTestContext(c), r)); + } +} diff --git a/servers/jak/android/app/src/main/java/com/couchbase/lite/mobiletest/TestContext.java b/servers/jak/android/app/src/main/java/com/couchbase/lite/mobiletest/TestContext.java new file mode 100644 index 000000000..f06edb6d5 --- /dev/null +++ b/servers/jak/android/app/src/main/java/com/couchbase/lite/mobiletest/TestContext.java @@ -0,0 +1,81 @@ +// +// Copyright (c) 2022 Couchbase, Inc All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +package com.couchbase.lite.mobiletest; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.util.HashMap; +import java.util.Map; + +import com.couchbase.lite.MultipeerReplicator; +import com.couchbase.lite.android.mobiletest.AndroidTestApp; +import com.couchbase.lite.mobiletest.endpoints.v1.Session; +import com.couchbase.lite.mobiletest.errors.ClientError; + + +public final class TestContext extends BaseTestContext { + + @Nullable + private Map openMultipeerRepls; + + public TestContext(@NonNull TestApp app, @NonNull Session session, @NonNull String testName) { + super(app, session, testName); + } + + @NonNull + @Override + protected TestContext getTestContext() { return this; } + + public void addMultipeerRepl(@NonNull String id, @NonNull MultipeerReplicator multipeerRepl) { + Map multipeerRepls = openMultipeerRepls; + if (multipeerRepls == null) { + multipeerRepls = new HashMap<>(); + openMultipeerRepls = multipeerRepls; + } + if (openMultipeerRepls.containsKey(id)) { + throw new ClientError("Attempt to replace an existing multipeer replicator"); + } + multipeerRepls.put(id, multipeerRepl); + } + + @Nullable + public MultipeerReplicator getMultipeerRepl(@NonNull String id) { + final Map multipeerRepls = openMultipeerRepls; + return (multipeerRepls == null) ? null : multipeerRepls.get(id); + } + + @Nullable + public MultipeerReplicator removeMultipeerRepl(@NonNull String id) { + final Map multipeerRepls = openMultipeerRepls; + return (multipeerRepls == null) ? null : multipeerRepls.remove(id); + } + + public void close(@NonNull TestApp app) { + ((AndroidTestApp) app).clearMultipeerReplSvc(); + stopMultipeerRepls(); + super.close(app); + } + + private void stopMultipeerRepls() { + final Map liveRepls = openMultipeerRepls; + openMultipeerRepls = null; + if (liveRepls == null) { return; } + for (MultipeerReplicator repl: liveRepls.values()) { + if (repl != null) { repl.stop(); } + } + } +} diff --git a/servers/jak/shared/common/main/java/com/couchbase/lite/mobiletest/TestContext.java b/servers/jak/shared/common/main/java/com/couchbase/lite/mobiletest/BaseTestContext.java similarity index 92% rename from servers/jak/shared/common/main/java/com/couchbase/lite/mobiletest/TestContext.java rename to servers/jak/shared/common/main/java/com/couchbase/lite/mobiletest/BaseTestContext.java index bd8ea244f..369076ed3 100644 --- a/servers/jak/shared/common/main/java/com/couchbase/lite/mobiletest/TestContext.java +++ b/servers/jak/shared/common/main/java/com/couchbase/lite/mobiletest/BaseTestContext.java @@ -35,7 +35,7 @@ @SuppressWarnings("PMD.CyclomaticComplexity") -public final class TestContext { +public abstract class BaseTestContext { private static final String TAG = "CONTEXT"; private static final String KEY_COLLECTIONS = "collections"; @@ -69,7 +69,7 @@ public final class TestContext { @Nullable private Map openEndptListeners; - public TestContext(@NonNull TestApp app, @NonNull Session session, @NonNull String testName) { + public BaseTestContext(@NonNull TestApp app, @NonNull Session session, @NonNull String testName) { this.session = session; this.testName = testName; @@ -77,9 +77,9 @@ public TestContext(@NonNull TestApp app, @NonNull Session session, @NonNull Stri if (!dbDir.mkdirs()) { throw new ServerError("Failed creating test db directory: " + dbDir); } this.dbDir = dbDir; - app.getDbSvc().init(this); - app.getReplSvc().init(this); - app.getListenerService().init(this); + app.getDbSvc().init(getTestContext()); + app.getReplSvc().init(getTestContext()); + app.getListenerService().init(getTestContext()); Log.p(TAG, ">>>>> START TEST: " + testName); } @@ -103,18 +103,20 @@ public void close(@NonNull TestApp app) { openSnapshots = null; stopRepls(); - stopEndptListeners(); - closeCollections(); - deleteDbs(); + // all state should be here. + // but just in case... app.clearReplSvc(); app.clearListenerService(); app.clearDbSvc(); } + @NonNull + protected abstract TestContext getTestContext(); + @SuppressFBWarnings("NP_NULL_ON_SOME_PATH") public void addDb(@NonNull String name, @NonNull Database db) { Log.p(TAG, "Adding database to context: " + name); @@ -135,7 +137,7 @@ public void createDbs(@NonNull DatabaseService dbSvc, @Nullable TypedMap databas final TypedMap dbDesc = databases.getMap(dbName); if ((dbDesc == null) || dbDesc.isEmpty()) { - createDb(this, dbSvc, dbName, null); + createDb(dbSvc, dbName, null); continue; } @@ -151,7 +153,7 @@ public void createDbs(@NonNull DatabaseService dbSvc, @Nullable TypedMap databas if (StringUtils.isEmpty(dataset)) { throw new ClientError("No dataset is specified for database " + dbName + " in reset"); } - dbSvc.installDataset(this, dataset, dbName); + dbSvc.installDataset(getTestContext(), dataset, dbName); continue; } @@ -161,7 +163,7 @@ public void createDbs(@NonNull DatabaseService dbSvc, @Nullable TypedMap databas throw new ClientError("Null or empty collections list for database " + dbName + " in reset"); } - createDb(this, dbSvc, dbName, collections); + createDb(dbSvc, dbName, collections); } } @@ -177,6 +179,28 @@ public Database removeDb(@NonNull String name) { return (dbs == null) ? null : dbs.remove(name); } + @NonNull + public String addSnapshot(@NonNull Snapshot snapshot) { + final String snapshotId = UUID.randomUUID().toString(); + Map snapshots = openSnapshots; + if (openSnapshots == null) { + snapshots = new HashMap<>(); + openSnapshots = snapshots; + } + snapshots.put(snapshotId, snapshot); + return snapshotId; + } + + @NonNull + public Snapshot getSnapshot(@NonNull String id) { + final Map snapshots = openSnapshots; + if (snapshots != null) { + final Snapshot snapshot = snapshots.get(id); + if (snapshot != null) { return snapshot; } + } + throw new ClientError("No such snapshot: " + id); + } + @Nullable public Collection getOpenCollection(@NonNull String collFqn) { final Map collections = openCollections; @@ -226,28 +250,6 @@ public DocReplListener getDocReplListener(@NonNull String id) { return (docListeners == null) ? null : docListeners.get(id); } - @NonNull - public String addSnapshot(@NonNull Snapshot snapshot) { - final String snapshotId = UUID.randomUUID().toString(); - Map snapshots = openSnapshots; - if (openSnapshots == null) { - snapshots = new HashMap<>(); - openSnapshots = snapshots; - } - snapshots.put(snapshotId, snapshot); - return snapshotId; - } - - @NonNull - public Snapshot getSnapshot(@NonNull String id) { - final Map shapshots = openSnapshots; - if (shapshots != null) { - final Snapshot snapshot = shapshots.get(id); - if (snapshot != null) { return snapshot; } - } - throw new ClientError("No such snapshot: " + id); - } - public void addEndptListener(@NonNull String id, @NonNull URLEndpointListener listener) { Map endptListeners = openEndptListeners; if (endptListeners == null) { @@ -264,8 +266,7 @@ public URLEndpointListener getEndptListener(@NonNull String id) { return (endptListeners == null) ? null : endptListeners.get(id); } - private static void createDb( - @NonNull TestContext ctxt, + private void createDb( @NonNull DatabaseService svc, @NonNull String dbName, @Nullable TypedList collections) { @@ -277,32 +278,7 @@ private static void createDb( collFqns.add(DatabaseService.parseCollectionFullName(fqn)); } } - svc.installDatabase(ctxt, dbName, collFqns); - } - - private void stopRepls() { - final Map liveRepls = openRepls; - openRepls = null; - if (liveRepls == null) { return; } - for (Replicator repl: liveRepls.values()) { - if (repl != null) { repl.stop(); } - } - } - - private void stopEndptListeners() { - final Map liveEndptListeners = openEndptListeners; - openEndptListeners = null; - if (liveEndptListeners == null) { return; } - for (URLEndpointListener listener: liveEndptListeners.values()) { - if (listener != null) { listener.stop(); } - } - } - - private void closeCollections() { - final Map liveCollections = openCollections; - openCollections = null; - if (liveCollections == null) { return; } - for (Collection collection: liveCollections.values()) { collection.close(); } + svc.installDatabase(getTestContext(), dbName, collFqns); } private void deleteDbs() { @@ -331,4 +307,29 @@ private void deleteDbs() { Log.err(TAG, "Failed deleting db dir on reset: " + liveDbDir); } } + + private void closeCollections() { + final Map liveCollections = openCollections; + openCollections = null; + if (liveCollections == null) { return; } + for (Collection collection: liveCollections.values()) { collection.close(); } + } + + private void stopRepls() { + final Map liveRepls = openRepls; + openRepls = null; + if (liveRepls == null) { return; } + for (Replicator repl: liveRepls.values()) { + if (repl != null) { repl.stop(); } + } + } + + private void stopEndptListeners() { + final Map liveEndptListeners = openEndptListeners; + openEndptListeners = null; + if (liveEndptListeners == null) { return; } + for (URLEndpointListener listener: liveEndptListeners.values()) { + if (listener != null) { listener.stop(); } + } + } } diff --git a/servers/jak/shared/common/main/java/com/couchbase/lite/mobiletest/GetDispatcher.java b/servers/jak/shared/common/main/java/com/couchbase/lite/mobiletest/GetDispatcher.java index d58b78630..fee87b7ac 100644 --- a/servers/jak/shared/common/main/java/com/couchbase/lite/mobiletest/GetDispatcher.java +++ b/servers/jak/shared/common/main/java/com/couchbase/lite/mobiletest/GetDispatcher.java @@ -41,6 +41,8 @@ public GetDispatcher(@NonNull TestApp app) { // build the endpoint table addEndpoint(1, "/", app::getSystemInfo); + + PlatformDispatcher.addGetEndpoints(this, app); } // This method returns a Reply. Be sure to close it! diff --git a/servers/jak/shared/common/main/java/com/couchbase/lite/mobiletest/PostDispatcher.java b/servers/jak/shared/common/main/java/com/couchbase/lite/mobiletest/PostDispatcher.java index 0a6ba8b54..9e8f67e8d 100644 --- a/servers/jak/shared/common/main/java/com/couchbase/lite/mobiletest/PostDispatcher.java +++ b/servers/jak/shared/common/main/java/com/couchbase/lite/mobiletest/PostDispatcher.java @@ -95,6 +95,8 @@ public PostDispatcher(@NonNull TestApp app) { 1, "/performMaintenance", (c, r) -> new PerformMaintenance(app.getDbSvc()).doMaintenance(app.getTestContext(c), r)); + + PlatformDispatcher.addPostEndpoints(this, app); } // This method returns a Reply. Be sure to close it! diff --git a/servers/jak/shared/common/main/java/com/couchbase/lite/mobiletest/endpoints/v1/BaseReplicatorManager.java b/servers/jak/shared/common/main/java/com/couchbase/lite/mobiletest/endpoints/v1/BaseReplicatorManager.java new file mode 100644 index 000000000..e5be87b8b --- /dev/null +++ b/servers/jak/shared/common/main/java/com/couchbase/lite/mobiletest/endpoints/v1/BaseReplicatorManager.java @@ -0,0 +1,167 @@ +// +// Copyright (c) 2025 Couchbase, Inc All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +package com.couchbase.lite.mobiletest.endpoints.v1; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.EnumMap; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import com.couchbase.lite.CouchbaseLiteException; +import com.couchbase.lite.DocumentFlag; +import com.couchbase.lite.DocumentReplication; +import com.couchbase.lite.ReplicatedDocument; +import com.couchbase.lite.ReplicatorProgress; +import com.couchbase.lite.ReplicatorStatus; +import com.couchbase.lite.mobiletest.errors.CblApiFailure; +import com.couchbase.lite.mobiletest.json.ErrorBuilder; +import com.couchbase.lite.mobiletest.services.DatabaseService; +import com.couchbase.lite.mobiletest.trees.TypedList; + + +public class BaseReplicatorManager { + protected static final String KEY_REPL_ID = "id"; + + protected static final String KEY_NAMES = "names"; + protected static final String KEY_CHANNELS = "channels"; + protected static final String KEY_DOCUMENT_IDS = "documentIDs"; + protected static final String KEY_PUSH_FILTER = "pushFilter"; + protected static final String KEY_PULL_FILTER = "pullFilter"; + protected static final String KEY_CONFLICT_RESOLVER = "conflictResolver"; + protected static final String KEY_NAME = "name"; + protected static final String KEY_PARAMS = "params"; + protected static final String FILTER_DELETED = "deletedDocumentsOnly"; + protected static final String FILTER_DOC_ID = "documentIDs"; + protected static final String KEY_DOC_IDS = "documentIDs"; + protected static final String KEY_AUTHENTICATOR = "authenticator"; + protected static final String KEY_AUTH_TYPE = "type"; + protected static final String AUTH_TYPE_BASIC = "basic"; + protected static final String KEY_BASIC_AUTH_USER = "username"; + protected static final String KEY_BASIC_AUTH_PASSWORD = "password"; + protected static final String AUTH_TYPE_SESSION = "session"; + + // Replicator status + protected static final String KEY_REPL_ACTIVITY = "activity"; + protected static final String KEY_REPL_PROGRESS = "progress"; + protected static final String KEY_REPL_DOCS = "documents"; + protected static final String KEY_REPL_DOCS_COMPLETE = "completed"; + protected static final String KEY_REPL_COLLECTION = "collection"; + protected static final String KEY_REPL_DOC_ID = "documentID"; + protected static final String KEY_REPL_PUSH = "isPush"; + protected static final String KEY_REPL_FLAGS = "flags"; + + protected static final String KEY_REPL_ERROR = "error"; + + protected static final Set LEGAL_COLLECTION_KEYS; + static { + final Set l = new HashSet<>(); + l.add(KEY_NAMES); + l.add(KEY_CHANNELS); + l.add(KEY_DOCUMENT_IDS); + l.add(KEY_PUSH_FILTER); + l.add(KEY_PULL_FILTER); + l.add(KEY_CONFLICT_RESOLVER); + LEGAL_COLLECTION_KEYS = Collections.unmodifiableSet(l); + } + + protected static final Set LEGAL_REPL_ID_KEYS; + static { + final Set l = new HashSet<>(); + l.add(KEY_REPL_ID); + LEGAL_REPL_ID_KEYS = Collections.unmodifiableSet(l); + } + + private static final EnumMap DOC_FLAGS; + static { + final EnumMap m = new EnumMap<>(DocumentFlag.class); + m.put(DocumentFlag.DELETED, "DELETED"); + m.put(DocumentFlag.ACCESS_REMOVED, "ACCESSREMOVED"); + DOC_FLAGS = m; + } + + @NonNull + protected final DatabaseService dbSvc; + + public BaseReplicatorManager(@NonNull DatabaseService dbSvc) { this.dbSvc = dbSvc; } + + @NonNull + protected final Map parseReplStatus( + @NonNull ReplicatorStatus replStatus, + @Nullable List docs) { + final Map resp = new HashMap<>(); + + resp.put(KEY_REPL_ACTIVITY, replStatus.getActivityLevel().toString()); + + final CouchbaseLiteException err = replStatus.getError(); + if (err != null) { resp.put(KEY_REPL_ERROR, new ErrorBuilder(new CblApiFailure(err)).build()); } + + final Map progress = new HashMap<>(); + final ReplicatorProgress replProgress = replStatus.getProgress(); + progress.put(KEY_REPL_DOCS_COMPLETE, replProgress.getCompleted() >= replProgress.getTotal()); + resp.put(KEY_REPL_PROGRESS, progress); + + if (docs != null) { + final List> docRepls = getReplicatedDocs(docs); + if (!docRepls.isEmpty()) { resp.put(KEY_REPL_DOCS, docRepls); } + } + return resp; + } + + @NonNull + private List> getReplicatedDocs(@NonNull List replicatedDocs) { + final List> docRepls = new ArrayList<>(); + for (DocumentReplication replicatedDoc: replicatedDocs) { + for (ReplicatedDocument replDoc: replicatedDoc.getDocuments()) { + final Map docRepl = new HashMap<>(); + + docRepl.put(KEY_REPL_COLLECTION, replDoc.getCollectionScope() + "." + replDoc.getCollectionName()); + + docRepl.put(KEY_REPL_DOC_ID, replDoc.getID()); + + docRepl.put(KEY_REPL_PUSH, replicatedDoc.isPush()); + + final List flagList = new ArrayList<>(); + for (DocumentFlag flag: replDoc.getFlags()) { flagList.add(DOC_FLAGS.get(flag)); } + docRepl.put(KEY_REPL_FLAGS, flagList); + + final CouchbaseLiteException err = replDoc.getError(); + if (err != null) { docRepl.put(KEY_REPL_ERROR, new ErrorBuilder(new CblApiFailure(err)).build()); } + + docRepls.add(docRepl); + } + } + return docRepls; + } + + @Nullable + protected final List getList(@Nullable TypedList spec) { + if (spec == null) { return null; } + final int n = spec.size(); + final List list = new ArrayList<>(n); + for (int i = 0; i < n; i++) { + final String item = spec.getString(i); + if (item != null) { list.add(item); } + } + return list; + } +} diff --git a/servers/jak/shared/common/main/java/com/couchbase/lite/mobiletest/endpoints/v1/GetDocument.java b/servers/jak/shared/common/main/java/com/couchbase/lite/mobiletest/endpoints/v1/GetDocument.java index 9a2af3fd4..56c0e81f4 100644 --- a/servers/jak/shared/common/main/java/com/couchbase/lite/mobiletest/endpoints/v1/GetDocument.java +++ b/servers/jak/shared/common/main/java/com/couchbase/lite/mobiletest/endpoints/v1/GetDocument.java @@ -65,12 +65,12 @@ public Map getDocument(@NonNull TestContext ctxt, @NonNull Typed final TypedMap docSpec = req.getMap(KEY_DOCUMENTS); if (docSpec == null) { throw new ClientError("No document specified for getDocument"); } - req.validate(LEGAL_DOC_SPEC_KEYS); + docSpec.validate(LEGAL_DOC_SPEC_KEYS); - final String collectionName = req.getString(KEY_COLLECTION); + final String collectionName = docSpec.getString(KEY_COLLECTION); if (collectionName == null) { throw new ClientError("No collection specified for getDocument"); } - final String docId = req.getString(KEY_DOC_ID); + final String docId = docSpec.getString(KEY_DOC_ID); if (docId == null) { throw new ClientError("No document id specified for getDocument"); } return dbSvc.getDocument(ctxt, dbName, collectionName, docId); diff --git a/servers/jak/shared/common/main/java/com/couchbase/lite/mobiletest/endpoints/v1/ReplicatorManager.java b/servers/jak/shared/common/main/java/com/couchbase/lite/mobiletest/endpoints/v1/ReplicatorManager.java index 3381801c6..eb6e629ea 100644 --- a/servers/jak/shared/common/main/java/com/couchbase/lite/mobiletest/endpoints/v1/ReplicatorManager.java +++ b/servers/jak/shared/common/main/java/com/couchbase/lite/mobiletest/endpoints/v1/ReplicatorManager.java @@ -26,9 +26,7 @@ import java.nio.charset.StandardCharsets; import java.security.cert.CertificateFactory; import java.security.cert.X509Certificate; -import java.util.ArrayList; import java.util.Collections; -import java.util.EnumMap; import java.util.HashMap; import java.util.HashSet; import java.util.List; @@ -43,26 +41,20 @@ import com.couchbase.lite.CollectionConfiguration; import com.couchbase.lite.Conflict; import com.couchbase.lite.ConflictResolver; -import com.couchbase.lite.CouchbaseLiteException; import com.couchbase.lite.Database; import com.couchbase.lite.Document; -import com.couchbase.lite.DocumentFlag; import com.couchbase.lite.DocumentReplication; import com.couchbase.lite.MutableArray; import com.couchbase.lite.MutableDocument; -import com.couchbase.lite.ReplicatedDocument; import com.couchbase.lite.ReplicationFilter; import com.couchbase.lite.Replicator; import com.couchbase.lite.ReplicatorConfiguration; -import com.couchbase.lite.ReplicatorProgress; import com.couchbase.lite.ReplicatorStatus; import com.couchbase.lite.ReplicatorType; import com.couchbase.lite.SessionAuthenticator; import com.couchbase.lite.URLEndpoint; import com.couchbase.lite.mobiletest.TestContext; -import com.couchbase.lite.mobiletest.errors.CblApiFailure; import com.couchbase.lite.mobiletest.errors.ClientError; -import com.couchbase.lite.mobiletest.json.ErrorBuilder; import com.couchbase.lite.mobiletest.services.DatabaseService; import com.couchbase.lite.mobiletest.services.Log; import com.couchbase.lite.mobiletest.services.ReplicatorService; @@ -70,137 +62,106 @@ import com.couchbase.lite.mobiletest.trees.TypedMap; -@SuppressWarnings("PMD.ExcessiveImports") -interface ConfigurableConflictResolver extends ConflictResolver { - void configure(@Nullable TypedMap config); -} +@SuppressWarnings({"PMD.ExcessiveImports", "PMD.CyclomaticComplexity"}) +public class ReplicatorManager extends BaseReplicatorManager { + private static final String TAG = "REPL_MGR_V1"; -class LocalWinsResolver implements ConfigurableConflictResolver { - @Override - public void configure(@Nullable TypedMap config) { - // no configuration needed + // Create replicator + private static final String KEY_RESET = "reset"; + private static final String KEY_CONFIG = "config"; + private static final String KEY_DATABASE = "database"; + private static final String KEY_COLLECTIONS = "collections"; + private static final String KEY_ENDPOINT = "endpoint"; + private static final String KEY_TYPE = "replicatorType"; + private static final String TYPE_PUSH_AND_PULL = "pushandpull"; + private static final String TYPE_PUSH = "push"; + private static final String TYPE_PULL = "pull"; + private static final String KEY_IS_CONTINUOUS = "continuous"; + private static final String KEY_ENABLE_DOC_LISTENER = "enableDocumentListener"; + private static final String KEY_SESSION_AUTH_ID = "sessionID"; + private static final String KEY_SESSION_AUTH_COOKIE = "cookieName"; + private static final String KEY_ENABLE_AUTOPURGE = "enableAutoPurge"; + private static final String KEY_PINNED_CERT = "pinnedServerCert"; + + private interface ConfigurableConflictResolver extends ConflictResolver { + void configure(@Nullable TypedMap config); } - @Nullable - @Override - public Document resolve(@NonNull Conflict conflict) { return conflict.getLocalDocument(); } -} + private static class LocalWinsResolver implements ConfigurableConflictResolver { + @Override + public void configure(@Nullable TypedMap config) { + // no configuration needed + } -class RemoteWinsResolver implements ConfigurableConflictResolver { - @Override - public void configure(@Nullable TypedMap config) { - // no configuration needed + @Nullable + @Override + public Document resolve(@NonNull Conflict conflict) { return conflict.getLocalDocument(); } } - @Nullable - @Override - public Document resolve(@NonNull Conflict conflict) { return conflict.getRemoteDocument(); } -} + private static class RemoteWinsResolver implements ConfigurableConflictResolver { + @Override + public void configure(@Nullable TypedMap config) { + // no configuration needed + } -class DeleteResolver implements ConfigurableConflictResolver { - @Override - public void configure(@Nullable TypedMap config) { - // no configuration needed + @Nullable + @Override + public Document resolve(@NonNull Conflict conflict) { return conflict.getRemoteDocument(); } } - @Nullable - @Override - public Document resolve(@NonNull Conflict conflict) { return null; } -} - -class MergeResolver implements ConfigurableConflictResolver { - private static final String KEY_PROPERTY = "property"; + private static class DeleteResolver implements ConfigurableConflictResolver { + @Override + public void configure(@Nullable TypedMap config) { + // no configuration needed + } - private static final Set MERGE_RESOLVER_KEYS; - static { - final Set l = new HashSet<>(); - l.add(KEY_PROPERTY); - MERGE_RESOLVER_KEYS = Collections.unmodifiableSet(l); + @Nullable + @Override + public Document resolve(@NonNull Conflict conflict) { return null; } } + private static class MergeResolver implements ConfigurableConflictResolver { + private static final String KEY_PROPERTY = "property"; - private String docProp; - - @Override - public void configure(@Nullable TypedMap config) { - if (config == null) { throw new ClientError("Merge resolver requires configuration"); } + private static final Set MERGE_RESOLVER_KEYS; + static { + final Set l = new HashSet<>(); + l.add(KEY_PROPERTY); + MERGE_RESOLVER_KEYS = Collections.unmodifiableSet(l); + } - config.validate(MERGE_RESOLVER_KEYS); - final String prop = config.getString(KEY_PROPERTY); - if (prop == null) { throw new ClientError("Merge resolver requires a property name"); } + private String docProp; - docProp = prop; - } + @Override + public void configure(@Nullable TypedMap config) { + if (config == null) { throw new ClientError("Merge resolver requires configuration"); } - @Nullable - @Override - public Document resolve(@NonNull Conflict conflict) { - final Document localDoc = conflict.getLocalDocument(); - final Document remoteDoc = conflict.getRemoteDocument(); - if ((localDoc == null) || (remoteDoc == null)) { return null; } + config.validate(MERGE_RESOLVER_KEYS); - final MutableArray mergedVal = new MutableArray(); - mergedVal.addValue(localDoc.getValue(docProp)); - mergedVal.addValue(remoteDoc.getValue(docProp)); + final String prop = config.getString(KEY_PROPERTY); + if (prop == null) { throw new ClientError("Merge resolver requires a property name"); } - final MutableDocument mergedDoc = localDoc.toMutable(); - mergedDoc.setValue(docProp, mergedVal); + docProp = prop; + } - return mergedDoc; - } -} + @Nullable + @Override + public Document resolve(@NonNull Conflict conflict) { + final Document localDoc = conflict.getLocalDocument(); + final Document remoteDoc = conflict.getRemoteDocument(); + if ((localDoc == null) || (remoteDoc == null)) { return null; } -@SuppressWarnings("PMD.CyclomaticComplexity") -public class ReplicatorManager { - private static final String TAG = "REPL_MGR_V1"; + final MutableArray mergedVal = new MutableArray(); + mergedVal.addValue(localDoc.getValue(docProp)); + mergedVal.addValue(remoteDoc.getValue(docProp)); - private static final String KEY_REPL_ID = "id"; + final MutableDocument mergedDoc = localDoc.toMutable(); + mergedDoc.setValue(docProp, mergedVal); - // Create replicator - private static final String KEY_RESET = "reset"; - private static final String KEY_CONFIG = "config"; - private static final String KEY_DATABASE = "database"; - private static final String KEY_COLLECTIONS = "collections"; - private static final String KEY_ENDPOINT = "endpoint"; - private static final String KEY_TYPE = "replicatorType"; - private static final String TYPE_PUSH_AND_PULL = "pushandpull"; - private static final String TYPE_PUSH = "push"; - private static final String TYPE_PULL = "pull"; - private static final String KEY_IS_CONTINUOUS = "continuous"; - private static final String KEY_ENABLE_DOC_LISTENER = "enableDocumentListener"; - private static final String KEY_AUTHENTICATOR = "authenticator"; - private static final String KEY_AUTH_TYPE = "type"; - private static final String AUTH_TYPE_BASIC = "basic"; - private static final String KEY_BASIC_AUTH_USER = "username"; - private static final String KEY_BASIC_AUTH_PASSWORD = "password"; - private static final String AUTH_TYPE_SESSION = "session"; - private static final String KEY_SESSION_AUTH_ID = "sessionID"; - private static final String KEY_SESSION_AUTH_COOKIE = "cookieName"; - private static final String KEY_NAMES = "names"; - private static final String KEY_CHANNELS = "channels"; - private static final String KEY_DOCUMENT_IDS = "documentIDs"; - private static final String KEY_PUSH_FILTER = "pushFilter"; - private static final String KEY_PULL_FILTER = "pullFilter"; - private static final String KEY_CONFLICT_RESOLVER = "conflictResolver"; - private static final String KEY_NAME = "name"; - private static final String KEY_PARAMS = "params"; - private static final String FILTER_DELETED = "deletedDocumentsOnly"; - private static final String FILTER_DOC_ID = "documentIDs"; - private static final String KEY_DOC_IDS = "documentIDs"; - private static final String KEY_ENABLE_AUTOPURGE = "enableAutoPurge"; - private static final String KEY_PINNED_CERT = "pinnedServerCert"; - - // Replicator status - private static final String KEY_REPL_ACTIVITY = "activity"; - private static final String KEY_REPL_PROGRESS = "progress"; - private static final String KEY_REPL_DOCS = "documents"; - private static final String KEY_REPL_DOCS_COMPLETE = "completed"; - private static final String KEY_REPL_COLLECTION = "collection"; - private static final String KEY_REPL_DOC_ID = "documentID"; - private static final String KEY_REPL_PUSH = "isPush"; - private static final String KEY_REPL_FLAGS = "flags"; - private static final String KEY_REPL_ERROR = "error"; + return mergedDoc; + } + } private static final Set LEGAL_CREATE_KEYS; static { @@ -237,7 +198,6 @@ public class ReplicatorManager { LEGAL_COLLECTION_KEYS = Collections.unmodifiableSet(l); } - private static final Set LEGAL_BASIC_AUTH_KEYS; static { final Set l = new HashSet<>(); @@ -256,21 +216,6 @@ public class ReplicatorManager { LEGAL_SESSION_AUTH_KEYS = Collections.unmodifiableSet(l); } - private static final EnumMap DOC_FLAGS; - static { - final EnumMap m = new EnumMap<>(DocumentFlag.class); - m.put(DocumentFlag.DELETED, "DELETED"); - m.put(DocumentFlag.ACCESS_REMOVED, "ACCESSREMOVED"); - DOC_FLAGS = m; - } - - private static final Set LEGAL_REPL_ID_KEYS; - static { - final Set l = new HashSet<>(); - l.add(KEY_REPL_ID); - LEGAL_REPL_ID_KEYS = Collections.unmodifiableSet(l); - } - private static final Map> CONFLICT_RESOLVER_FACTORIES; static { final Map> m = new HashMap<>(); @@ -281,13 +226,11 @@ public class ReplicatorManager { CONFLICT_RESOLVER_FACTORIES = Collections.unmodifiableMap(m); } - @NonNull - private final DatabaseService dbSvc; @NonNull private final ReplicatorService replSvc; public ReplicatorManager(@NonNull DatabaseService dbSvc, @NonNull ReplicatorService replSvc) { - this.dbSvc = dbSvc; + super(dbSvc); this.replSvc = replSvc; } @@ -329,25 +272,8 @@ public Map getReplStatus(@NonNull TestContext ctxt, @NonNull Typ final ReplicatorStatus replStatus = replSvc.getReplStatus(ctxt, replId); Log.p(TAG, "Replicator status: " + replStatus); - final Map resp = new HashMap<>(); - - resp.put(KEY_REPL_ACTIVITY, replStatus.getActivityLevel().toString()); - - final CouchbaseLiteException err = replStatus.getError(); - if (err != null) { resp.put(KEY_REPL_ERROR, new ErrorBuilder(new CblApiFailure(err)).build()); } - - final Map progress = new HashMap<>(); - final ReplicatorProgress replProgress = replStatus.getProgress(); - progress.put(KEY_REPL_DOCS_COMPLETE, replProgress.getCompleted() >= replProgress.getTotal()); - resp.put(KEY_REPL_PROGRESS, progress); - final List docs = replSvc.getReplicatedDocs(ctxt, replId); - if (docs != null) { - final List> docRepls = getReplicatedDocs(docs); - if (!docRepls.isEmpty()) { resp.put(KEY_REPL_DOCS, docRepls); } - } - - return resp; + return parseReplStatus(replStatus, docs); } @NonNull @@ -362,32 +288,6 @@ public Map stopRepl(@NonNull TestContext ctxt, @NonNull TypedMap return Collections.emptyMap(); } - @NonNull - private List> getReplicatedDocs(@NonNull List replicatedDocs) { - final List> docRepls = new ArrayList<>(); - for (DocumentReplication replicatedDoc: replicatedDocs) { - for (ReplicatedDocument replDoc: replicatedDoc.getDocuments()) { - final Map docRepl = new HashMap<>(); - - docRepl.put(KEY_REPL_COLLECTION, replDoc.getCollectionScope() + "." + replDoc.getCollectionName()); - - docRepl.put(KEY_REPL_DOC_ID, replDoc.getID()); - - docRepl.put(KEY_REPL_PUSH, replicatedDoc.isPush()); - - final List flagList = new ArrayList<>(); - for (DocumentFlag flag: replDoc.getFlags()) { flagList.add(DOC_FLAGS.get(flag)); } - docRepl.put(KEY_REPL_FLAGS, flagList); - - final CouchbaseLiteException err = replDoc.getError(); - if (err != null) { docRepl.put(KEY_REPL_ERROR, new ErrorBuilder(new CblApiFailure(err)).build()); } - - docRepls.add(docRepl); - } - } - return docRepls; - } - @SuppressWarnings({"deprecation", "PMD.NPathComplexity"}) @NonNull private ReplicatorConfiguration buildConfig(@NonNull TestContext ctxt, @NonNull TypedMap config) { @@ -456,7 +356,7 @@ private ReplicatorConfiguration buildConfig(@NonNull TestContext ctxt, @NonNull replConfig.setAuthenticator(buildSessionAuthenticator(authenticator)); break; default: - throw new ClientError("Unrecognized authenticator type: " + replType); + throw new ClientError("Unrecognized authenticator type: " + authType); } } @@ -506,42 +406,6 @@ private CollectionConfiguration buildCollectionConfig(@NonNull TypedMap spec) { return collectionConfig; } - @NonNull - private BasicAuthenticator buildBasicAuthenticator(@NonNull TypedMap spec) { - spec.validate(LEGAL_BASIC_AUTH_KEYS); - - final String user = spec.getString(KEY_BASIC_AUTH_USER); - if ((user == null) || user.isEmpty()) { throw new ClientError("Basic authenticator doesn't specify a user"); } - - final String pwd = spec.getString(KEY_BASIC_AUTH_PASSWORD); - if ((pwd == null) || pwd.isEmpty()) { throw new ClientError("Basic authenticator doesn't specify a password"); } - - return new BasicAuthenticator(user, pwd.toCharArray()); - } - - @NonNull - private SessionAuthenticator buildSessionAuthenticator(@NonNull TypedMap spec) { - spec.validate(LEGAL_SESSION_AUTH_KEYS); - - final String session = spec.getString(KEY_SESSION_AUTH_ID); - if ((session == null) || session.isEmpty()) { - throw new ClientError("Session authenticator doesn't specify a session id"); - } - return new SessionAuthenticator(session, spec.getString(KEY_SESSION_AUTH_COOKIE)); - } - - @Nullable - private List getList(@Nullable TypedList spec) { - if (spec == null) { return null; } - final int n = spec.size(); - final List list = new ArrayList<>(n); - for (int i = 0; i < n; i++) { - final String item = spec.getString(i); - if (item != null) { list.add(item); } - } - return list; - } - @NonNull private ReplicationFilter buildReplicatorFilter(@NonNull TypedMap spec) { final String name = spec.getString(KEY_NAME); @@ -556,21 +420,6 @@ private ReplicationFilter buildReplicatorFilter(@NonNull TypedMap spec) { } } - @NonNull - private ConflictResolver buildConflictResolver(@NonNull TypedMap spec) { - final String name = spec.getString(KEY_NAME); - if (name == null) { throw new ClientError("No name specified for the conflict resolver"); } - - final Supplier resolverFactory - = CONFLICT_RESOLVER_FACTORIES.get(name.toLowerCase(Locale.US).trim()); - if (resolverFactory == null) { throw new ClientError("Unrecognized conflict resolver: " + name); } - - final ConfigurableConflictResolver resolver = resolverFactory.get(); - resolver.configure(spec.getMap(KEY_PARAMS)); - - return resolver; - } - @NonNull private ReplicationFilter buildDocIdFilter(@Nullable TypedMap spec) { if (spec == null) { throw new ClientError("DocId filter specifies no doc ids"); } @@ -593,6 +442,45 @@ private ReplicationFilter buildDocIdFilter(@Nullable TypedMap spec) { return replSvc.getDocIdFilter(permitted); } + @NonNull + private ConflictResolver buildConflictResolver(@NonNull TypedMap spec) { + final String name = spec.getString(KEY_NAME); + if (name == null) { throw new ClientError("No name specified for the conflict resolver"); } + + final Supplier resolverFactory + = CONFLICT_RESOLVER_FACTORIES.get(name.toLowerCase(Locale.US).trim()); + if (resolverFactory == null) { throw new ClientError("Unrecognized conflict resolver: " + name); } + + final ConfigurableConflictResolver resolver = resolverFactory.get(); + resolver.configure(spec.getMap(KEY_PARAMS)); + + return resolver; + } + + @NonNull + private BasicAuthenticator buildBasicAuthenticator(@NonNull TypedMap spec) { + spec.validate(LEGAL_BASIC_AUTH_KEYS); + + final String user = spec.getString(KEY_BASIC_AUTH_USER); + if ((user == null) || user.isEmpty()) { throw new ClientError("Basic authenticator doesn't specify a user"); } + + final String pwd = spec.getString(KEY_BASIC_AUTH_PASSWORD); + if ((pwd == null) || pwd.isEmpty()) { throw new ClientError("Basic authenticator doesn't specify a password"); } + + return new BasicAuthenticator(user, pwd.toCharArray()); + } + + @NonNull + private SessionAuthenticator buildSessionAuthenticator(@NonNull TypedMap spec) { + spec.validate(LEGAL_SESSION_AUTH_KEYS); + + final String session = spec.getString(KEY_SESSION_AUTH_ID); + if ((session == null) || session.isEmpty()) { + throw new ClientError("Session authenticator doesn't specify a session id"); + } + return new SessionAuthenticator(session, spec.getString(KEY_SESSION_AUTH_COOKIE)); + } + @NonNull private X509Certificate str2X509Cert(@NonNull String certificate) { try (InputStream certStream = new ByteArrayInputStream(certificate.getBytes(StandardCharsets.UTF_8))) { diff --git a/servers/jak/shared/common/main/java/com/couchbase/lite/mobiletest/services/ReplicatorService.java b/servers/jak/shared/common/main/java/com/couchbase/lite/mobiletest/services/ReplicatorService.java index fa46c2bab..1c7416e92 100644 --- a/servers/jak/shared/common/main/java/com/couchbase/lite/mobiletest/services/ReplicatorService.java +++ b/servers/jak/shared/common/main/java/com/couchbase/lite/mobiletest/services/ReplicatorService.java @@ -34,14 +34,14 @@ public class ReplicatorService { - static class DeletedDocFilter implements ReplicationFilter { + private static class DeletedDocFilter implements ReplicationFilter { @Override public boolean filtered(@NonNull Document ignore, @NonNull EnumSet flags) { return flags.contains(DocumentFlag.DELETED); } } - static class DocIdFilter implements ReplicationFilter { + private static class DocIdFilter implements ReplicationFilter { @NonNull private final Set permittedDocs; diff --git a/servers/jak/shared/jvm/main/java/com/couchbase/lite/mobiletest/PlatformDispatcher.java b/servers/jak/shared/jvm/main/java/com/couchbase/lite/mobiletest/PlatformDispatcher.java new file mode 100644 index 000000000..e1fa29616 --- /dev/null +++ b/servers/jak/shared/jvm/main/java/com/couchbase/lite/mobiletest/PlatformDispatcher.java @@ -0,0 +1,28 @@ +// +// Copyright (c) 2025 Couchbase, Inc All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +package com.couchbase.lite.mobiletest; + +final class PlatformDispatcher { + private PlatformDispatcher() { } + + public static void addGetEndpoints(GetDispatcher dispatcher, TestApp app) { + // there are no platform-specific GET endpoints for either of the JVM platforms + } + + public static void addPostEndpoints(PostDispatcher dispatcher, TestApp app) { + // there are no platform-specific POST endpoints for either of the JVM platforms + } +} diff --git a/servers/jak/shared/jvm/main/java/com/couchbase/lite/mobiletest/TestContext.java b/servers/jak/shared/jvm/main/java/com/couchbase/lite/mobiletest/TestContext.java new file mode 100644 index 000000000..854086d57 --- /dev/null +++ b/servers/jak/shared/jvm/main/java/com/couchbase/lite/mobiletest/TestContext.java @@ -0,0 +1,32 @@ + +// +// Copyright (c) 2022 Couchbase, Inc All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +package com.couchbase.lite.mobiletest; + +import androidx.annotation.NonNull; + +import com.couchbase.lite.mobiletest.endpoints.v1.Session; + + +public final class TestContext extends BaseTestContext { + public TestContext(@NonNull TestApp app, @NonNull Session session, @NonNull String testName) { + super(app, session, testName); + } + + @NonNull + @Override + protected TestContext getTestContext() { return this; } +} diff --git a/servers/jak/version.txt b/servers/jak/version.txt index 26aaba0e8..6085e9465 100644 --- a/servers/jak/version.txt +++ b/servers/jak/version.txt @@ -1 +1 @@ -1.2.0 +1.2.1 diff --git a/servers/jak/webservice/app/src/main/java/com/couchbase/lite/javaws/mobiletest/TestServerApp.java b/servers/jak/webservice/app/src/main/java/com/couchbase/lite/javaws/mobiletest/TestServerApp.java index 4ab115bd8..57dd61b52 100644 --- a/servers/jak/webservice/app/src/main/java/com/couchbase/lite/javaws/mobiletest/TestServerApp.java +++ b/servers/jak/webservice/app/src/main/java/com/couchbase/lite/javaws/mobiletest/TestServerApp.java @@ -42,9 +42,6 @@ private enum Method {UNKNOWN, GET, POST} // Servlets are serializable... private static final long serialVersionUID = 42L; - private transient GetDispatcher getDispatcher; - private transient PostDispatcher postDispatcher; - public static void main(String[] args) { // Create a basic Jetty server instance final Server server = new Server(8080); @@ -64,6 +61,10 @@ public static void main(String[] args) { catch (Exception e) { throw new IllegalStateException("Failed starting the server", e); } } + + private transient GetDispatcher getDispatcher; + private transient PostDispatcher postDispatcher; + @SuppressFBWarnings("DM_DEFAULT_ENCODING") @Override public void init() { diff --git a/servers/jak/webservice/build.gradle b/servers/jak/webservice/build.gradle index 06f0c8472..b8bc0661e 100644 --- a/servers/jak/webservice/build.gradle +++ b/servers/jak/webservice/build.gradle @@ -27,7 +27,6 @@ buildscript { dependencies { classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$KOTLIN_VERSION" - classpath("org.gretty:gretty:4.1.5") classpath 'com.github.spotbugs.snom:spotbugs-gradle-plugin:5.0.14' } } From 71c3be92d5125d65a914b7441489917970e94d8c Mon Sep 17 00:00:00 2001 From: "G. Blake Meike" Date: Mon, 23 Jun 2025 14:55:37 -0700 Subject: [PATCH 13/13] Add _revs and _id to /getDocument (#225) --- .../lite/mobiletest/endpoints/v1/GetDocument.java | 12 +++++++++++- .../lite/mobiletest/services/DatabaseService.java | 11 +++++++++-- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/servers/jak/shared/common/main/java/com/couchbase/lite/mobiletest/endpoints/v1/GetDocument.java b/servers/jak/shared/common/main/java/com/couchbase/lite/mobiletest/endpoints/v1/GetDocument.java index 56c0e81f4..062223edf 100644 --- a/servers/jak/shared/common/main/java/com/couchbase/lite/mobiletest/endpoints/v1/GetDocument.java +++ b/servers/jak/shared/common/main/java/com/couchbase/lite/mobiletest/endpoints/v1/GetDocument.java @@ -22,6 +22,7 @@ import java.util.Map; import java.util.Set; +import com.couchbase.lite.Document; import com.couchbase.lite.mobiletest.TestContext; import com.couchbase.lite.mobiletest.errors.ClientError; import com.couchbase.lite.mobiletest.services.DatabaseService; @@ -33,6 +34,9 @@ public class GetDocument { private static final String KEY_DOCUMENTS = "document"; private static final String KEY_COLLECTION = "collection"; private static final String KEY_DOC_ID = "id"; + private static final String KEY_META_DOC_ID = "_id"; + private static final String KEY_META_REV_HISTORY = "_revs"; + private static final Set LEGAL_GET_DOC_KEYS; static { @@ -73,6 +77,12 @@ public Map getDocument(@NonNull TestContext ctxt, @NonNull Typed final String docId = docSpec.getString(KEY_DOC_ID); if (docId == null) { throw new ClientError("No document id specified for getDocument"); } - return dbSvc.getDocument(ctxt, dbName, collectionName, docId); + final Document doc = dbSvc.getDocument(ctxt, dbName, collectionName, docId); + + final Map ret = doc.toMap(); + ret.put(KEY_META_DOC_ID, doc.getId()); + ret.put(KEY_META_REV_HISTORY, dbSvc.getRevisionHistory(doc)); + + return ret; } } diff --git a/servers/jak/shared/common/main/java/com/couchbase/lite/mobiletest/services/DatabaseService.java b/servers/jak/shared/common/main/java/com/couchbase/lite/mobiletest/services/DatabaseService.java index 2e73acf51..5cd4e6e01 100644 --- a/servers/jak/shared/common/main/java/com/couchbase/lite/mobiletest/services/DatabaseService.java +++ b/servers/jak/shared/common/main/java/com/couchbase/lite/mobiletest/services/DatabaseService.java @@ -168,7 +168,7 @@ public Collection getCollection(@NonNull TestContext ctxt, @NonNull Database db, } @NonNull - public Map getDocument( + public Document getDocument( @NonNull TestContext ctxt, @NonNull String dbName, @NonNull String collName, @@ -179,7 +179,14 @@ public Map getDocument( HTTPStatus.NOT_FOUND, "Document not found: " + getDocumentFQN(dbName, collName, docId)); } - return doc.toMap(); + + return doc; + } + + public String getRevisionHistory(Document doc) { + try { return doc.getRevisionHistory(); } + catch (CouchbaseLiteException ignore) { } + return ""; } @NonNull