[PM-13607] Implement SDK client-managed state repository registration (#1738)

Co-authored-by: Matt Czech <matt@livefront.com>
This commit is contained in:
Federico Maccaroni 2025-07-17 10:57:28 -03:00 committed by GitHub
parent ee379aa6d2
commit ae99fe362d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 402 additions and 23 deletions

View File

@ -8,6 +8,7 @@ class MockPlatformClientService: PlatformClientService {
var fingerprintResult: Result<String, Error> = .success("a-fingerprint-phrase-string-placeholder")
var featureFlags: [String: Bool] = [:]
var loadFlagsError: Error?
var stateMock = MockStateClient()
var userFingerprintCalled = false
func fido2() -> ClientFido2Service {
@ -25,6 +26,10 @@ class MockPlatformClientService: PlatformClientService {
featureFlags = flags
}
func state() -> StateClientProtocol {
stateMock
}
func userFingerprint(material fingerprintMaterial: String) throws -> String {
fingerprintMaterialString = fingerprintMaterial
userFingerprintCalled = true

View File

@ -0,0 +1,11 @@
import BitwardenSdk
@testable import AuthenticatorShared
final class MockStateClient: StateClientProtocol {
var registerCipherRepositoryReceivedStore: CipherRepository?
func registerCipherRepository(store: CipherRepository) {
registerCipherRepositoryReceivedStore = store
}
}

View File

@ -17,6 +17,9 @@ protocol PlatformClientService: AnyObject {
/// - Parameter flags: Flags to load.
func loadFlags(_ flags: [String: Bool]) throws
/// Returns an object to handle state.
func state() -> StateClientProtocol
/// Fingerprint using logged in user's public key
/// - Parameter material: Fingerprint material to use
/// - Returns: User fingerprint
@ -38,6 +41,10 @@ extension PlatformClient: PlatformClientService {
try loadFlags(flags: flags)
}
func state() -> StateClientProtocol {
state() as StateClient
}
func userFingerprint(material fingerprintMaterial: String) throws -> String {
try userFingerprint(fingerprintMaterial: fingerprintMaterial)
}

View File

@ -92,6 +92,7 @@ class MockClientCiphers: CiphersClientProtocol {
}
var decryptFido2CredentialsResult = [BitwardenSdk.Fido2CredentialView]()
var decryptListWithFailuresResult: DecryptCipherListResult?
var encryptCipherResult: Result<EncryptionContext, Error>?
var encryptError: Error?
var encryptedCiphers = [CipherView]()
@ -115,6 +116,13 @@ class MockClientCiphers: CiphersClientProtocol {
ciphers.map(CipherListView.init)
}
func decryptListWithFailures(ciphers: [Cipher]) -> DecryptCipherListResult {
decryptListWithFailuresResult ?? DecryptCipherListResult(
successes: ciphers.map(CipherListView.init),
failures: []
)
}
func encrypt(cipherView: CipherView) throws -> BitwardenSdk.EncryptionContext {
encryptedCiphers.append(cipherView)
if let encryptError {

View File

@ -123,7 +123,7 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/bitwarden/sdk-swift",
"state" : {
"revision" : "0c3baf9d372cd941146616a3d842b78c96a1170f"
"revision" : "33a7bb7334aeea402256633cbbffe7fb03501e40"
}
},
{

View File

@ -8,6 +8,7 @@ class MockPlatformClientService: PlatformClientService {
var fingerprintResult: Result<String, Error> = .success("a-fingerprint-phrase-string-placeholder")
var featureFlags: [String: Bool] = [:]
var loadFlagsError: Error?
var stateMock = MockStateClient()
var userFingerprintCalled = false
func fido2() -> ClientFido2Service {
@ -25,6 +26,10 @@ class MockPlatformClientService: PlatformClientService {
featureFlags = flags
}
func state() -> StateClientProtocol {
stateMock
}
func userFingerprint(material fingerprintMaterial: String) throws -> String {
fingerprintMaterialString = fingerprintMaterial
userFingerprintCalled = true

View File

@ -0,0 +1,11 @@
import BitwardenSdk
@testable import BitwardenShared
final class MockStateClient: StateClientProtocol {
var registerCipherRepositoryReceivedStore: CipherRepository?
func registerCipherRepository(store: CipherRepository) {
registerCipherRepositoryReceivedStore = store
}
}

View File

@ -17,7 +17,9 @@ class ClientBuilderTests: BitwardenTestCase {
errorReporter = MockErrorReporter()
mockPlatform = MockPlatformClientService()
subject = DefaultClientBuilder(errorReporter: errorReporter)
subject = DefaultClientBuilder(
errorReporter: errorReporter
)
}
override func tearDown() {
@ -30,11 +32,11 @@ class ClientBuilderTests: BitwardenTestCase {
// MARK: Tests
/// `buildClient()` creates a client and loads feature flags.
/// `buildClient(for:)` creates a client and loads feature flags.
func test_buildClient() {
let client = subject.buildClient()
let builtClient = subject.buildClient()
XCTAssertNotNil(client)
XCTAssertNotNil(builtClient)
XCTAssertNotNil(mockPlatform.featureFlags)
}
}

View File

@ -144,6 +144,9 @@ actor DefaultClientService: ClientService {
/// The service used by the application to report non-fatal errors.
private let errorReporter: ErrorReporter
/// The factory to create SDK repositories.
private let sdkRepositoryFactory: SdkRepositoryFactory
/// Basic client behavior settings.
private let settings: ClientSettings?
@ -161,6 +164,7 @@ actor DefaultClientService: ClientService {
/// - clientBuilder: A helper object that builds a Bitwarden SDK `Client`.
/// - configService: The service to get server-specified configuration.
/// - errorReporter: The service used by the application to report non-fatal errors.
/// - sdkRepositoryFactory: The factory to create SDK repositories.
/// - settings: The settings to apply to the client. Defaults to `nil`.
/// - stateService: The service used by the application to manage account state.
///
@ -168,12 +172,14 @@ actor DefaultClientService: ClientService {
clientBuilder: ClientBuilder,
configService: ConfigService,
errorReporter: ErrorReporter,
sdkRepositoryFactory: SdkRepositoryFactory,
settings: ClientSettings? = nil,
stateService: StateService
) {
self.clientBuilder = clientBuilder
self.configService = configService
self.errorReporter = errorReporter
self.sdkRepositoryFactory = sdkRepositoryFactory
self.settings = settings
self.stateService = stateService
@ -254,9 +260,7 @@ actor DefaultClientService: ClientService {
// If not, create one, map it to the user, then return it.
let newClient = await createAndMapClient(for: userId)
// Get the current config and load the flags.
let config = await configService.getConfig()
await loadFlags(config, for: newClient)
await configureNewClient(newClient, for: userId)
return newClient
}
@ -275,6 +279,20 @@ actor DefaultClientService: ClientService {
return clientBuilder.buildClient()
}
}
/// Configures a new SDK client.
/// - Parameters:
/// - client: The SDK client to configure.
/// - userId: The user ID the SDK client instance belongs to.
func configureNewClient(_ client: BitwardenSdkClient, for userId: String) async {
client.platform().state().registerCipherRepository(
store: sdkRepositoryFactory.makeCipherRepository(userId: userId)
)
// Get the current config and load the flags.
let config = await configService.getConfig()
await loadFlags(config, for: client)
}
/// Creates a new client and maps it to an ID.
///
@ -315,7 +333,6 @@ actor DefaultClientService: ClientService {
///
protocol ClientBuilder {
/// Creates a `BitwardenSdkClient`.
///
/// - Returns: A new `BitwardenSdkClient`.
///
func buildClient() -> BitwardenSdkClient
@ -341,8 +358,10 @@ class DefaultClientBuilder: ClientBuilder {
/// - Parameters:
/// - errorReporter: The service used by the application to report non-fatal errors.
/// - settings: The settings applied to the client.
///
init(errorReporter: ErrorReporter, settings: ClientSettings? = nil) {
init(
errorReporter: ErrorReporter,
settings: ClientSettings? = nil
) {
self.errorReporter = errorReporter
self.settings = settings
}

View File

@ -12,6 +12,7 @@ final class ClientServiceTests: BitwardenTestCase { // swiftlint:disable:this ty
var clientBuilder: MockClientBuilder!
var configService: MockConfigService!
var errorReporter: MockErrorReporter!
var sdkRepositoryFactory: MockSdkRepositoryFactory!
var stateService: MockStateService!
var subject: DefaultClientService!
var vaultTimeoutService: MockVaultTimeoutService!
@ -24,11 +25,14 @@ final class ClientServiceTests: BitwardenTestCase { // swiftlint:disable:this ty
clientBuilder = MockClientBuilder()
configService = MockConfigService()
errorReporter = MockErrorReporter()
sdkRepositoryFactory = MockSdkRepositoryFactory()
sdkRepositoryFactory.makeCipherRepositoryUserIdReturnValue = MockSdkCipherRepository()
stateService = MockStateService()
subject = DefaultClientService(
clientBuilder: clientBuilder,
configService: configService,
errorReporter: errorReporter,
sdkRepositoryFactory: sdkRepositoryFactory,
stateService: stateService
)
vaultTimeoutService = MockVaultTimeoutService()
@ -40,6 +44,7 @@ final class ClientServiceTests: BitwardenTestCase { // swiftlint:disable:this ty
clientBuilder = nil
configService = nil
errorReporter = nil
sdkRepositoryFactory = nil
stateService = nil
subject = nil
vaultTimeoutService = nil
@ -215,6 +220,17 @@ final class ClientServiceTests: BitwardenTestCase { // swiftlint:disable:this ty
)
}
/// `client(for:)` registers the SDK cipher repository.
func test_client_registersCipherRepository() async throws {
stateService.activeAccount = .fixture(profile: .fixture(userId: "1"))
let auth = try await subject.auth()
let client = try XCTUnwrap(clientBuilder.clients.first)
XCTAssertIdentical(auth, client.authClient)
XCTAssertTrue(sdkRepositoryFactory.makeCipherRepositoryUserIdCalled)
XCTAssertNotNil(client.platformClient.stateMock.registerCipherRepositoryReceivedStore)
}
/// `configPublisher` loads flags into the SDK.
@MainActor
func test_configPublisher_loadFlags() async throws {

View File

@ -17,6 +17,9 @@ protocol PlatformClientService: AnyObject {
/// - Parameter flags: Flags to load.
func loadFlags(_ flags: [String: Bool]) throws
/// Returns an object to handle state.
func state() -> StateClientProtocol
/// Fingerprint using logged in user's public key
/// - Parameter material: Fingerprint material to use
/// - Returns: User fingerprint
@ -38,6 +41,10 @@ extension PlatformClient: PlatformClientService {
try loadFlags(flags: flags)
}
func state() -> StateClientProtocol {
state() as StateClient
}
func userFingerprint(material fingerprintMaterial: String) throws -> String {
try userFingerprint(fingerprintMaterial: fingerprintMaterial)
}

View File

@ -455,11 +455,24 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le
timeProvider: timeProvider
)
let clientBuilder = DefaultClientBuilder(errorReporter: errorReporter)
let cipherService = DefaultCipherService(
cipherAPIService: apiService,
cipherDataStore: dataStore,
fileAPIService: apiService,
stateService: stateService
)
let clientBuilder = DefaultClientBuilder(
errorReporter: errorReporter
)
let clientService = DefaultClientService(
clientBuilder: clientBuilder,
configService: configService,
errorReporter: errorReporter,
sdkRepositoryFactory: DefaultSdkRepositoryFactory(
cipherDataStore: dataStore,
errorReporter: errorReporter
),
stateService: stateService
)
@ -495,13 +508,6 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le
stateService: stateService
)
let cipherService = DefaultCipherService(
cipherAPIService: apiService,
cipherDataStore: dataStore,
fileAPIService: apiService,
stateService: stateService
)
let eventService = DefaultEventService(
cipherService: cipherService,
errorReporter: errorReporter,

View File

@ -0,0 +1,51 @@
import BitwardenKit
import BitwardenSdk
/// `CipherRepository` implementation to be used on SDK client-managed state.
final class SdkCipherRepository: BitwardenSdk.CipherRepository {
/// The data store for managing the persisted ciphers for the user.
let cipherDataStore: CipherDataStore
/// The service used by the application to report non-fatal errors.
let errorReporter: ErrorReporter
/// The user ID of the SDK instance this repository belongs to.
let userId: String
/// Initializes a `SdkCipherRepository`.
/// - Parameters:
/// - cipherDataStore: The data store for managing the persisted ciphers for the user.
/// - errorReporter: The service used by the application to report non-fatal errors.
/// - userId: The user ID of the SDK instance this repository belongs to
init(
cipherDataStore: CipherDataStore,
errorReporter: ErrorReporter,
userId: String
) {
self.cipherDataStore = cipherDataStore
self.errorReporter = errorReporter
self.userId = userId
}
func get(id: String) async throws -> BitwardenSdk.Cipher? {
try await cipherDataStore.fetchCipher(withId: id, userId: userId)
}
func has(id: String) async throws -> Bool {
let cipher = try await cipherDataStore.fetchCipher(withId: id, userId: userId)
return cipher != nil
}
func list() async throws -> [BitwardenSdk.Cipher] {
try await cipherDataStore.fetchAllCiphers(userId: userId)
}
func remove(id: String) async throws {
try await cipherDataStore.deleteCipher(id: id, userId: userId)
}
func set(id: String, value: BitwardenSdk.Cipher) async throws {
guard id == value.id else {
throw BitwardenError.dataError("CipherRepository: Trying to update a cipher with mismatch IDs")
}
try await cipherDataStore.upsertCipher(value, userId: userId)
}
}

View File

@ -0,0 +1,101 @@
import BitwardenKitMocks
import TestHelpers
import XCTest
@testable import BitwardenShared
// MARK: - SdkCipherRepositoryTests
class SdkCipherRepositoryTests: BitwardenTestCase {
// MARK: Properties
var cipherDataStore: MockCipherDataStore!
var errorReporter: MockErrorReporter!
var subject: SdkCipherRepository!
let expectedUserId = "1"
// MARK: Setup & Teardown
override func setUp() {
super.setUp()
cipherDataStore = MockCipherDataStore()
errorReporter = MockErrorReporter()
subject = SdkCipherRepository(
cipherDataStore: cipherDataStore,
errorReporter: errorReporter,
userId: expectedUserId
)
}
override func tearDown() {
super.tearDown()
cipherDataStore = nil
errorReporter = nil
subject = nil
}
// MARK: Tests
/// `get(id:)` fetches the cipher by its ID.
func test_get() async throws {
cipherDataStore.fetchCipherResult = .fixture(id: "1")
let cipher = try await subject.get(id: "1")
XCTAssertNotNil(cipher)
XCTAssertEqual(cipher?.id, "1")
XCTAssertEqual(cipherDataStore.fetchCipherUserId, expectedUserId)
}
/// `has(id:)` returns whether there is a cipher with the ID.
func test_has() async throws {
cipherDataStore.fetchCipherResult = .fixture(id: "1")
let hasCipher1 = try await subject.has(id: "1")
XCTAssertTrue(hasCipher1)
XCTAssertEqual(cipherDataStore.fetchCipherUserId, expectedUserId)
cipherDataStore.fetchCipherResult = nil
let hasCipher2 = try await subject.has(id: "2")
XCTAssertFalse(hasCipher2)
}
/// `list()` returns a list of ciphers.
func test_list() async throws {
cipherDataStore.fetchAllCiphersResult = .success([.fixture(id: "1"), .fixture(id: "2")])
let ciphers = try await subject.list()
XCTAssertEqual(ciphers.map(\.id), ["1", "2"])
XCTAssertEqual(cipherDataStore.fetchAllCiphersUserId, expectedUserId)
}
/// `remove(id:)` deletes the cipher from local storage by ID.
func test_remove() async throws {
try await subject.remove(id: "1")
XCTAssertEqual(cipherDataStore.deleteCipherId, "1")
XCTAssertEqual(cipherDataStore.deleteCipherUserId, expectedUserId)
}
/// `set(id:value:)` updates the cipher with local storage.
func test_set() async throws {
try await subject.set(id: "1", value: .fixture(id: "1"))
XCTAssertEqual(cipherDataStore.upsertCipherValue?.id, "1")
XCTAssertEqual(cipherDataStore.upsertCipherUserId, expectedUserId)
}
/// `set(id:value:)` doesn't update the cipher with local storage when the ID being passed
/// doesn't match the ID of the `value` and throws an error.
func test_set_nonMatchingIds() async throws {
do {
try await subject.set(id: "1", value: .fixture(id: "5"))
} catch {
XCTAssertEqual(
(error as NSError).userInfo["ErrorMessage"] as? String,
"CipherRepository: Trying to update a cipher with mismatch IDs"
)
}
XCTAssertNil(cipherDataStore.upsertCipherValue)
}
}

View File

@ -0,0 +1,42 @@
import BitwardenKit
import BitwardenSdk
/// A factory to create SDK repositories.
protocol SdkRepositoryFactory { // sourcery: AutoMockable
/// Makes a `BitwardenSdk.CipherRepository` for the given `userId`.
/// - Parameter userId: The user ID to use in the repository which belongs to the SDK instance
/// the repository will be registered in.
/// - Returns: The repository for the given `userId`.
func makeCipherRepository(userId: String) -> BitwardenSdk.CipherRepository
}
/// Default implementation of `SdkRepositoryFactory`.
struct DefaultSdkRepositoryFactory: SdkRepositoryFactory {
// MARK: Properties
/// The data store for managing the persisted ciphers for the user.
private let cipherDataStore: CipherDataStore
/// The service used by the application to report non-fatal errors.
private let errorReporter: ErrorReporter
// MARK: Init
/// Initializes a `DefaultSdkRepositoryFactory`.
/// - Parameters:
/// - cipherDataStore: The data store for managing the persisted ciphers for the user.
/// - errorReporter: The service used by the application to report non-fatal errors.
init(cipherDataStore: CipherDataStore, errorReporter: ErrorReporter) {
self.cipherDataStore = cipherDataStore
self.errorReporter = errorReporter
}
// MARK: Methods
func makeCipherRepository(userId: String) -> BitwardenSdk.CipherRepository {
SdkCipherRepository(
cipherDataStore: cipherDataStore,
errorReporter: errorReporter,
userId: userId
)
}
}

View File

@ -0,0 +1,40 @@
import BitwardenKitMocks
import XCTest
@testable import BitwardenShared
// MARK: - SdkRepositoryFactoryTests
class SdkRepositoryFactoryTests: BitwardenTestCase {
// MARK: Properties
var cipherDataStore: MockCipherDataStore!
var errorReporter: MockErrorReporter!
var subject: SdkRepositoryFactory!
// MARK: Setup & Teardown
override func setUp() {
super.setUp()
cipherDataStore = MockCipherDataStore()
errorReporter = MockErrorReporter()
subject = DefaultSdkRepositoryFactory(cipherDataStore: cipherDataStore, errorReporter: errorReporter)
}
override func tearDown() {
super.tearDown()
cipherDataStore = nil
errorReporter = nil
subject = nil
}
// MARK: Tests
/// `makeCipherRepository(userId:)` makes a cipher repository for the given user ID.
func test_makeCipherRepository() {
let repository = subject.makeCipherRepository(userId: "1")
XCTAssertTrue(repository is SdkCipherRepository)
}
}

View File

@ -0,0 +1,38 @@
import BitwardenKitMocks
import BitwardenSdk
@testable import BitwardenShared
final class MockSdkCipherRepository: BitwardenSdk.CipherRepository {
var getResult: Result<BitwardenSdk.Cipher, Error> = .success(.fixture())
var hasResult: Result<Bool, Error> = .success(true)
var listResult: Result<[BitwardenSdk.Cipher], Error> = .success([])
var removeResult: Result<Void, Error> = .success(())
var removeReceivedId: String?
var setReceivedId: String?
var setReceivedCipher: Cipher?
var setResult: Result<Void, Error> = .success(())
func get(id: String) async throws -> BitwardenSdk.Cipher? {
try getResult.get()
}
func has(id: String) async throws -> Bool {
try hasResult.get()
}
func list() async throws -> [BitwardenSdk.Cipher] {
try listResult.get()
}
func remove(id: String) async throws {
removeReceivedId = id
try removeResult.get()
}
func set(id: String, value: BitwardenSdk.Cipher) async throws {
setReceivedId = id
setReceivedCipher = value
try setResult.get()
}
}

View File

@ -17,6 +17,7 @@ class MockCipherDataStore: CipherDataStore {
var fetchCipherId: String?
var fetchCipherResult: Cipher?
var fetchCipherUserId: String?
var cipherSubjectByUserId: [String: CurrentValueSubject<[Cipher], Error>] = [:]
@ -45,8 +46,9 @@ class MockCipherDataStore: CipherDataStore {
return try fetchAllCiphersResult.get()
}
func fetchCipher(withId id: String, userId _: String) async -> Cipher? {
func fetchCipher(withId id: String, userId: String) async -> Cipher? {
fetchCipherId = id
fetchCipherUserId = userId
return fetchCipherResult
}

View File

@ -107,6 +107,7 @@ class MockClientCiphers: CiphersClientProtocol {
var decryptFido2CredentialsResult = [BitwardenSdk.Fido2CredentialView]()
var decryptListError: Error?
var decryptListWithFailuresResult: DecryptCipherListResult?
var decryptListErrorWhenCiphers: (([Cipher]) -> Error?)?
var decryptListReceivedCiphersInvocations: [[Cipher]] = []
var encryptCipherResult: Result<EncryptionContext, Error>?
@ -139,6 +140,13 @@ class MockClientCiphers: CiphersClientProtocol {
return ciphers.map(CipherListView.init)
}
func decryptListWithFailures(ciphers: [Cipher]) -> DecryptCipherListResult {
decryptListWithFailuresResult ?? DecryptCipherListResult(
successes: ciphers.map(CipherListView.init),
failures: []
)
}
func encrypt(cipherView: CipherView) throws -> EncryptionContext {
encryptedCiphers.append(cipherView)
if let encryptError {

View File

@ -23,7 +23,7 @@ include:
packages:
BitwardenSdk:
url: https://github.com/bitwarden/sdk-swift
revision: 0c3baf9d372cd941146616a3d842b78c96a1170f
revision: 33a7bb7334aeea402256633cbbffe7fb03501e40
branch: unstable
Firebase:
url: https://github.com/firebase/firebase-ios-sdk

View File

@ -23,7 +23,7 @@ include:
packages:
BitwardenSdk:
url: https://github.com/bitwarden/sdk-swift
revision: 0c3baf9d372cd941146616a3d842b78c96a1170f
revision: 33a7bb7334aeea402256633cbbffe7fb03501e40
branch: unstable
Firebase:
url: https://github.com/firebase/firebase-ios-sdk

View File

@ -24,7 +24,7 @@ include:
packages:
BitwardenSdk:
url: https://github.com/bitwarden/sdk-swift
revision: 0c3baf9d372cd941146616a3d842b78c96a1170f
revision: 33a7bb7334aeea402256633cbbffe7fb03501e40
branch: unstable
Firebase:
url: https://github.com/firebase/firebase-ios-sdk