From 521c8c4de65b040abdd6054a2f713bdb99bb7055 Mon Sep 17 00:00:00 2001 From: Matt Czech Date: Wed, 8 Jul 2026 12:00:14 -0500 Subject: [PATCH 1/3] [PM-40133] feat: Add Send Controls policy support and AnyCodable array/dictionary decoding --- .../Core/Platform/Utilities/AnyCodable.swift | 30 ++++++++ .../Platform/Utilities/AnyCodableTests.swift | 72 +++++++++++++++++++ .../Platform/Models/Enum/FeatureFlag.swift | 5 ++ .../Core/Vault/Models/Enum/PolicyType.swift | 5 ++ .../Services/PolicyService+SdkMapping.swift | 3 +- 5 files changed, 114 insertions(+), 1 deletion(-) diff --git a/BitwardenKit/Core/Platform/Utilities/AnyCodable.swift b/BitwardenKit/Core/Platform/Utilities/AnyCodable.swift index e54ed8b201..43383cf702 100644 --- a/BitwardenKit/Core/Platform/Utilities/AnyCodable.swift +++ b/BitwardenKit/Core/Platform/Utilities/AnyCodable.swift @@ -3,9 +3,15 @@ /// A custom codable type that can be used to encode/decode a variety of primitive types. /// public enum AnyCodable: Codable, Equatable, Sendable { + /// The wrapped value is an array of `AnyCodable` values. + case array([AnyCodable]) + /// The wrapped value is a bool value. case bool(Bool) + /// The wrapped value is a keyed dictionary of `AnyCodable` values. + case dictionary([String: AnyCodable]) + /// The wrapped value is a double value. case double(Double) @@ -34,6 +40,10 @@ public enum AnyCodable: Codable, Equatable, Sendable { self = .null } else if let stringValue = try? container.decode(String.self) { self = .string(stringValue) + } else if let arrayValue = try? container.decode([AnyCodable].self) { + self = .array(arrayValue) + } else if let dictionaryValue = try? container.decode([String: AnyCodable].self) { + self = .dictionary(dictionaryValue) } else { throw DecodingError.dataCorruptedError( in: container, @@ -48,8 +58,12 @@ public enum AnyCodable: Codable, Equatable, Sendable { var container = encoder.singleValueContainer() switch self { + case let .array(arrayValue): + try container.encode(arrayValue) case let .bool(boolValue): try container.encode(boolValue) + case let .dictionary(dictionaryValue): + try container.encode(dictionaryValue) case let .double(doubleValue): try container.encode(doubleValue) case let .int(intValue): @@ -63,15 +77,29 @@ public enum AnyCodable: Codable, Equatable, Sendable { } public extension AnyCodable { + /// Returns the associated array value if the type is `array`. + var arrayValue: [AnyCodable]? { + guard case let .array(arrayValue) = self else { return nil } + return arrayValue + } + /// Returns the associated bool value if the type is `bool`. var boolValue: Bool? { guard case let .bool(boolValue) = self else { return nil } return boolValue } + /// Returns the associated dictionary value if the type is `dictionary`. + var dictionaryValue: [String: AnyCodable]? { + guard case let .dictionary(dictionaryValue) = self else { return nil } + return dictionaryValue + } + /// Returns the associated double value if the type is `double` or could be converted to `Double`. var doubleValue: Double? { switch self { + case .array, .dictionary: + nil case let .bool(boolValue): boolValue ? 1 : 0 case let .double(doubleValue): @@ -88,6 +116,8 @@ public extension AnyCodable { /// Returns the associated int value if the type is `int` or could be converted to `Int`. var intValue: Int? { switch self { + case .array, .dictionary: + nil case let .bool(boolValue): boolValue ? 1 : 0 case let .double(doubleValue): diff --git a/BitwardenKit/Core/Platform/Utilities/AnyCodableTests.swift b/BitwardenKit/Core/Platform/Utilities/AnyCodableTests.swift index 54edb6fdd8..e7d934d037 100644 --- a/BitwardenKit/Core/Platform/Utilities/AnyCodableTests.swift +++ b/BitwardenKit/Core/Platform/Utilities/AnyCodableTests.swift @@ -5,6 +5,16 @@ import XCTest class AnyCodableTests: BitwardenTestCase { // MARK: Properties + /// `arrayValue` returns the array associated value if the type is an `array`. + func test_arrayValue() { + XCTAssertEqual(AnyCodable.array([.int(0), .int(1)]).arrayValue, [.int(0), .int(1)]) + + XCTAssertNil(AnyCodable.bool(true).arrayValue) + XCTAssertNil(AnyCodable.dictionary(["a": .int(1)]).arrayValue) + XCTAssertNil(AnyCodable.null.arrayValue) + XCTAssertNil(AnyCodable.string("abc").arrayValue) + } + /// `boolValue` returns the bool associated value if the type is a `bool`. func test_boolValue() { XCTAssertEqual(AnyCodable.bool(true).boolValue, true) @@ -15,6 +25,16 @@ class AnyCodableTests: BitwardenTestCase { XCTAssertNil(AnyCodable.string("abc").boolValue) } + /// `dictionaryValue` returns the dictionary associated value if the type is a `dictionary`. + func test_dictionaryValue() { + XCTAssertEqual(AnyCodable.dictionary(["a": .int(1)]).dictionaryValue, ["a": .int(1)]) + + XCTAssertNil(AnyCodable.array([.int(1)]).dictionaryValue) + XCTAssertNil(AnyCodable.bool(true).dictionaryValue) + XCTAssertNil(AnyCodable.null.dictionaryValue) + XCTAssertNil(AnyCodable.string("abc").dictionaryValue) + } + /// `AnyCodable` can be used to decode JSON. func test_decode() throws { let json = """ @@ -50,6 +70,36 @@ class AnyCodableTests: BitwardenTestCase { ) } + /// `AnyCodable` can decode a JSON array of values, e.g. a Send Controls policy's + /// `allowedSendTypes` field. + func test_decode_array() throws { + let json = """ + { + "disableSend": false, + "whoCanAccess": 1, + "allowedDomains": null, + "disableHideEmail": false, + "allowedSendTypes": [0, 1], + "deletionHours": null + } + """ + + let jsonData = try XCTUnwrap(json.data(using: .utf8)) + let dictionary = try JSONDecoder().decode([String: AnyCodable].self, from: jsonData) + + XCTAssertEqual(dictionary["allowedSendTypes"], .array([.int(0), .int(1)])) + } + + /// `AnyCodable` can decode a nested JSON object value. + func test_decode_dictionary() throws { + let json = #"{"nested": {"a": 1, "b": true}}"# + + let jsonData = try XCTUnwrap(json.data(using: .utf8)) + let dictionary = try JSONDecoder().decode([String: AnyCodable].self, from: jsonData) + + XCTAssertEqual(dictionary["nested"], .dictionary(["a": .int(1), "b": .bool(true)])) + } + /// `doubleValue` returns the double associated value if the type is a `double` or could be /// converted to `Double`. func test_doubleValue() { @@ -109,6 +159,28 @@ class AnyCodableTests: BitwardenTestCase { ) } + /// `AnyCodable` can encode a JSON array of values. + func test_encode_array() throws { + let dictionary: [String: AnyCodable] = ["allowedSendTypes": .array([.int(0), .int(1)])] + + let encoder = JSONEncoder() + let jsonData = try encoder.encode(dictionary) + let json = String(data: jsonData, encoding: .utf8) + + XCTAssertEqual(json, #"{"allowedSendTypes":[0,1]}"#) + } + + /// `AnyCodable` can encode a nested JSON object value. + func test_encode_dictionary() throws { + let dictionary: [String: AnyCodable] = ["nested": .dictionary(["a": .int(1)])] + + let encoder = JSONEncoder() + let jsonData = try encoder.encode(dictionary) + let json = String(data: jsonData, encoding: .utf8) + + XCTAssertEqual(json, #"{"nested":{"a":1}}"#) + } + /// `intValue` returns the int associated value if the type is an `int` or could be converted /// to `Int`. func test_intValue() { diff --git a/BitwardenShared/Core/Platform/Models/Enum/FeatureFlag.swift b/BitwardenShared/Core/Platform/Models/Enum/FeatureFlag.swift index 0c225e4552..1d49042ed8 100644 --- a/BitwardenShared/Core/Platform/Models/Enum/FeatureFlag.swift +++ b/BitwardenShared/Core/Platform/Models/Enum/FeatureFlag.swift @@ -59,6 +59,10 @@ extension FeatureFlag: @retroactive CaseIterable { /// Flag to enable/disable Premium upgrade path. static let premiumUpgradePath = FeatureFlag(rawValue: "pm-31697-premium-upgrade-path") + /// Flag to enable/disable the Send Controls policy, which supersedes the `disableSend` and + /// `sendOptions` policies when active. + static let sendControls = FeatureFlag(rawValue: "pm-31885-send-controls") + public static var allCases: [FeatureFlag] { [ .accountEncryptionV2JITPassword, @@ -76,6 +80,7 @@ extension FeatureFlag: @retroactive CaseIterable { .organizationUserNotificationBanner, .policiesInAcceptedState, .premiumUpgradePath, + .sendControls, ] } } diff --git a/BitwardenShared/Core/Vault/Models/Enum/PolicyType.swift b/BitwardenShared/Core/Vault/Models/Enum/PolicyType.swift index 5fdec4a6b3..ade59e4367 100644 --- a/BitwardenShared/Core/Vault/Models/Enum/PolicyType.swift +++ b/BitwardenShared/Core/Vault/Models/Enum/PolicyType.swift @@ -46,6 +46,11 @@ enum PolicyType: Int, Codable { /// Displays a banner notification to organization users. case organizationUserNotification = 20 + /// Restricts Send creation, access, and default settings for members of an organization; + /// supersedes `disableSend` and `sendOptions` when the `pm-31885-send-controls` feature flag + /// is active on the server. + case sendControls = 21 + /// An unknown policy type. case unknown = -1 diff --git a/BitwardenShared/Core/Vault/Services/PolicyService+SdkMapping.swift b/BitwardenShared/Core/Vault/Services/PolicyService+SdkMapping.swift index f29caf3678..8630e09609 100644 --- a/BitwardenShared/Core/Vault/Services/PolicyService+SdkMapping.swift +++ b/BitwardenShared/Core/Vault/Services/PolicyService+SdkMapping.swift @@ -86,6 +86,7 @@ extension BitwardenSdk.PolicyType { case .requireSSO: self = .requireSso case .resetPassword: self = .resetPassword case .restrictItemTypes: self = .restrictedItemTypes + case .sendControls: self = .sendControls case .sendOptions: self = .sendOptions case .twoFactorAuthentication: self = .twoFactorAuthentication // TODO: PM-39144 Add SDK mapping for `organizationUserNotification` PolicyType @@ -113,6 +114,7 @@ extension PolicyType { case .requireSso: self = .requireSSO case .resetPassword: self = .resetPassword case .restrictedItemTypes: self = .restrictItemTypes + case .sendControls: self = .sendControls case .sendOptions: self = .sendOptions case .singleOrg: self = .onlyOrg case .twoFactorAuthentication: self = .twoFactorAuthentication @@ -122,7 +124,6 @@ extension PolicyType { .blockClaimedDomainAccountCreation, .freeFamiliesSponsorship, .organizationUserNotification, - .sendControls, .uriMatchDefaults: self = .unknown } } From d645b60971353fb80139990440e0e01ca38d2934 Mon Sep 17 00:00:00 2001 From: Matt Czech Date: Wed, 8 Jul 2026 12:01:07 -0500 Subject: [PATCH 2/3] [PM-40133] Convert AnyCodableTests to Swift Testing --- .../Platform/Utilities/AnyCodableTests.swift | 203 ++++++++++-------- 1 file changed, 109 insertions(+), 94 deletions(-) diff --git a/BitwardenKit/Core/Platform/Utilities/AnyCodableTests.swift b/BitwardenKit/Core/Platform/Utilities/AnyCodableTests.swift index e7d934d037..e8cdea816a 100644 --- a/BitwardenKit/Core/Platform/Utilities/AnyCodableTests.swift +++ b/BitwardenKit/Core/Platform/Utilities/AnyCodableTests.swift @@ -1,42 +1,14 @@ -import XCTest +import Foundation +import Testing @testable import BitwardenKit -class AnyCodableTests: BitwardenTestCase { - // MARK: Properties - - /// `arrayValue` returns the array associated value if the type is an `array`. - func test_arrayValue() { - XCTAssertEqual(AnyCodable.array([.int(0), .int(1)]).arrayValue, [.int(0), .int(1)]) - - XCTAssertNil(AnyCodable.bool(true).arrayValue) - XCTAssertNil(AnyCodable.dictionary(["a": .int(1)]).arrayValue) - XCTAssertNil(AnyCodable.null.arrayValue) - XCTAssertNil(AnyCodable.string("abc").arrayValue) - } - - /// `boolValue` returns the bool associated value if the type is a `bool`. - func test_boolValue() { - XCTAssertEqual(AnyCodable.bool(true).boolValue, true) - XCTAssertEqual(AnyCodable.bool(false).boolValue, false) - - XCTAssertNil(AnyCodable.int(2).boolValue) - XCTAssertNil(AnyCodable.null.boolValue) - XCTAssertNil(AnyCodable.string("abc").boolValue) - } - - /// `dictionaryValue` returns the dictionary associated value if the type is a `dictionary`. - func test_dictionaryValue() { - XCTAssertEqual(AnyCodable.dictionary(["a": .int(1)]).dictionaryValue, ["a": .int(1)]) - - XCTAssertNil(AnyCodable.array([.int(1)]).dictionaryValue) - XCTAssertNil(AnyCodable.bool(true).dictionaryValue) - XCTAssertNil(AnyCodable.null.dictionaryValue) - XCTAssertNil(AnyCodable.string("abc").dictionaryValue) - } +struct AnyCodableTests { + // MARK: Decode Tests /// `AnyCodable` can be used to decode JSON. - func test_decode() throws { + @Test + func decode() throws { let json = """ { "minComplexity": null, @@ -51,12 +23,11 @@ class AnyCodableTests: BitwardenTestCase { } """ - let jsonData = try XCTUnwrap(json.data(using: .utf8)) + let jsonData = try #require(json.data(using: .utf8)) let dictionary = try JSONDecoder().decode([String: AnyCodable].self, from: jsonData) - XCTAssertEqual( - dictionary, - [ + #expect( + dictionary == [ "minComplexity": AnyCodable.null, "minLength": AnyCodable.int(12), "requireUpper": AnyCodable.bool(false), @@ -72,7 +43,8 @@ class AnyCodableTests: BitwardenTestCase { /// `AnyCodable` can decode a JSON array of values, e.g. a Send Controls policy's /// `allowedSendTypes` field. - func test_decode_array() throws { + @Test + func decode_array() throws { let json = """ { "disableSend": false, @@ -84,46 +56,28 @@ class AnyCodableTests: BitwardenTestCase { } """ - let jsonData = try XCTUnwrap(json.data(using: .utf8)) + let jsonData = try #require(json.data(using: .utf8)) let dictionary = try JSONDecoder().decode([String: AnyCodable].self, from: jsonData) - XCTAssertEqual(dictionary["allowedSendTypes"], .array([.int(0), .int(1)])) + #expect(dictionary["allowedSendTypes"] == .array([.int(0), .int(1)])) } /// `AnyCodable` can decode a nested JSON object value. - func test_decode_dictionary() throws { + @Test + func decode_dictionary() throws { let json = #"{"nested": {"a": 1, "b": true}}"# - let jsonData = try XCTUnwrap(json.data(using: .utf8)) + let jsonData = try #require(json.data(using: .utf8)) let dictionary = try JSONDecoder().decode([String: AnyCodable].self, from: jsonData) - XCTAssertEqual(dictionary["nested"], .dictionary(["a": .int(1), "b": .bool(true)])) + #expect(dictionary["nested"] == .dictionary(["a": .int(1), "b": .bool(true)])) } - /// `doubleValue` returns the double associated value if the type is a `double` or could be - /// converted to `Double`. - func test_doubleValue() { - XCTAssertEqual(AnyCodable.bool(true).doubleValue, 1) - XCTAssertEqual(AnyCodable.bool(false).doubleValue, 0) - - XCTAssertEqual(AnyCodable.double(2).doubleValue, 2) - XCTAssertEqual(AnyCodable.double(3.1).doubleValue, 3.1) - XCTAssertEqual(AnyCodable.double(3.8).doubleValue, 3.8) - XCTAssertEqual(AnyCodable.double(-5.5).doubleValue, -5.5) - - XCTAssertEqual(AnyCodable.int(1).doubleValue, 1) - XCTAssertEqual(AnyCodable.int(5).doubleValue, 5) - XCTAssertEqual(AnyCodable.int(15).doubleValue, 15) - - XCTAssertNil(AnyCodable.null.doubleValue) - - XCTAssertEqual(AnyCodable.string("1").doubleValue, 1) - XCTAssertEqual(AnyCodable.string("1.5").doubleValue, 1.5) - XCTAssertNil(AnyCodable.string("abc").doubleValue) - } + // MARK: Encode Tests /// `AnyCodable` can be used to encode JSON. - func test_encode() throws { + @Test + func encode() throws { let dictionary: [String: AnyCodable] = [ "minComplexity": AnyCodable.null, "minLength": AnyCodable.int(12), @@ -141,9 +95,8 @@ class AnyCodableTests: BitwardenTestCase { let jsonData = try encoder.encode(dictionary) let json = String(data: jsonData, encoding: .utf8) - XCTAssertEqual( - json, - """ + #expect( + json == """ { "enforceOnLogin" : false, "minComplexity" : null, @@ -160,56 +113,118 @@ class AnyCodableTests: BitwardenTestCase { } /// `AnyCodable` can encode a JSON array of values. - func test_encode_array() throws { + @Test + func encode_array() throws { let dictionary: [String: AnyCodable] = ["allowedSendTypes": .array([.int(0), .int(1)])] let encoder = JSONEncoder() let jsonData = try encoder.encode(dictionary) let json = String(data: jsonData, encoding: .utf8) - XCTAssertEqual(json, #"{"allowedSendTypes":[0,1]}"#) + #expect(json == #"{"allowedSendTypes":[0,1]}"#) } /// `AnyCodable` can encode a nested JSON object value. - func test_encode_dictionary() throws { + @Test + func encode_dictionary() throws { let dictionary: [String: AnyCodable] = ["nested": .dictionary(["a": .int(1)])] let encoder = JSONEncoder() let jsonData = try encoder.encode(dictionary) let json = String(data: jsonData, encoding: .utf8) - XCTAssertEqual(json, #"{"nested":{"a":1}}"#) + #expect(json == #"{"nested":{"a":1}}"#) + } + + // MARK: Value Tests + + /// `arrayValue` returns the array associated value if the type is an `array`. + @Test + func arrayValue() { + #expect(AnyCodable.array([.int(0), .int(1)]).arrayValue == [.int(0), .int(1)]) + + #expect(AnyCodable.bool(true).arrayValue == nil) + #expect(AnyCodable.dictionary(["a": .int(1)]).arrayValue == nil) + #expect(AnyCodable.null.arrayValue == nil) + #expect(AnyCodable.string("abc").arrayValue == nil) + } + + /// `boolValue` returns the bool associated value if the type is a `bool`. + @Test + func boolValue() { + #expect(AnyCodable.bool(true).boolValue == true) + #expect(AnyCodable.bool(false).boolValue == false) + + #expect(AnyCodable.int(2).boolValue == nil) + #expect(AnyCodable.null.boolValue == nil) + #expect(AnyCodable.string("abc").boolValue == nil) + } + + /// `dictionaryValue` returns the dictionary associated value if the type is a `dictionary`. + @Test + func dictionaryValue() { + #expect(AnyCodable.dictionary(["a": .int(1)]).dictionaryValue == ["a": .int(1)]) + + #expect(AnyCodable.array([.int(1)]).dictionaryValue == nil) + #expect(AnyCodable.bool(true).dictionaryValue == nil) + #expect(AnyCodable.null.dictionaryValue == nil) + #expect(AnyCodable.string("abc").dictionaryValue == nil) + } + + /// `doubleValue` returns the double associated value if the type is a `double` or could be + /// converted to `Double`. + @Test + func doubleValue() { + #expect(AnyCodable.bool(true).doubleValue == 1) + #expect(AnyCodable.bool(false).doubleValue == 0) + + #expect(AnyCodable.double(2).doubleValue == 2) + #expect(AnyCodable.double(3.1).doubleValue == 3.1) + #expect(AnyCodable.double(3.8).doubleValue == 3.8) + #expect(AnyCodable.double(-5.5).doubleValue == -5.5) + + #expect(AnyCodable.int(1).doubleValue == 1) + #expect(AnyCodable.int(5).doubleValue == 5) + #expect(AnyCodable.int(15).doubleValue == 15) + + #expect(AnyCodable.null.doubleValue == nil) + + #expect(AnyCodable.string("1").doubleValue == 1) + #expect(AnyCodable.string("1.5").doubleValue == 1.5) + #expect(AnyCodable.string("abc").doubleValue == nil) } /// `intValue` returns the int associated value if the type is an `int` or could be converted /// to `Int`. - func test_intValue() { - XCTAssertEqual(AnyCodable.bool(true).intValue, 1) - XCTAssertEqual(AnyCodable.bool(false).intValue, 0) + @Test + func intValue() { + #expect(AnyCodable.bool(true).intValue == 1) + #expect(AnyCodable.bool(false).intValue == 0) - XCTAssertEqual(AnyCodable.double(2).intValue, 2) - XCTAssertEqual(AnyCodable.double(3.1).intValue, 3) - XCTAssertEqual(AnyCodable.double(3.8).intValue, 3) - XCTAssertEqual(AnyCodable.double(-5.5).intValue, -5) + #expect(AnyCodable.double(2).intValue == 2) + #expect(AnyCodable.double(3.1).intValue == 3) + #expect(AnyCodable.double(3.8).intValue == 3) + #expect(AnyCodable.double(-5.5).intValue == -5) - XCTAssertEqual(AnyCodable.int(1).intValue, 1) - XCTAssertEqual(AnyCodable.int(5).intValue, 5) - XCTAssertEqual(AnyCodable.int(15).intValue, 15) + #expect(AnyCodable.int(1).intValue == 1) + #expect(AnyCodable.int(5).intValue == 5) + #expect(AnyCodable.int(15).intValue == 15) - XCTAssertNil(AnyCodable.null.intValue) + #expect(AnyCodable.null.intValue == nil) - XCTAssertEqual(AnyCodable.string("1").intValue, 1) - XCTAssertNil(AnyCodable.string("1.5").intValue) - XCTAssertNil(AnyCodable.string("abc").intValue) + #expect(AnyCodable.string("1").intValue == 1) + #expect(AnyCodable.string("1.5").intValue == nil) + #expect(AnyCodable.string("abc").intValue == nil) } /// `stringValue` returns the string associated value if the type is a `string`. - func test_stringValue() { - XCTAssertEqual(AnyCodable.string("abc").stringValue, "abc") - XCTAssertEqual(AnyCodable.string("example").stringValue, "example") - - XCTAssertNil(AnyCodable.bool(false).stringValue) - XCTAssertNil(AnyCodable.int(2).stringValue) - XCTAssertNil(AnyCodable.null.stringValue) + @Test + func stringValue() { + #expect(AnyCodable.string("abc").stringValue == "abc") + #expect(AnyCodable.string("example").stringValue == "example") + + #expect(AnyCodable.bool(false).stringValue == nil) + #expect(AnyCodable.int(2).stringValue == nil) + #expect(AnyCodable.null.stringValue == nil) } } From 5d839bd80d94d5d6e2db93d3c994cdaa4af3e3f6 Mon Sep 17 00:00:00 2001 From: Matt Czech Date: Wed, 8 Jul 2026 14:22:51 -0500 Subject: [PATCH 3/3] [PM-40133] Update docs --- .../Core/Vault/Services/PolicyService+SdkMapping.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BitwardenShared/Core/Vault/Services/PolicyService+SdkMapping.swift b/BitwardenShared/Core/Vault/Services/PolicyService+SdkMapping.swift index 8630e09609..6536e46ab8 100644 --- a/BitwardenShared/Core/Vault/Services/PolicyService+SdkMapping.swift +++ b/BitwardenShared/Core/Vault/Services/PolicyService+SdkMapping.swift @@ -99,7 +99,7 @@ extension BitwardenSdk.PolicyType { extension PolicyType { /// Converts an SDK `BitwardenSdk.PolicyType` to the iOS `PolicyType`. /// - /// SDK-only cases (e.g., `sendControls`) with no iOS equivalent map to `.unknown`. + /// SDK-only cases with no iOS equivalent map to `.unknown`. /// init(_ sdkType: BitwardenSdk.PolicyType) { switch sdkType {