Added input validation to the Authentication middleware (#5)

This PR contains the work done to improve the existing `AuthMiddleware` type to provide input validations with the `SecureValidationRule` validation rule and also, by generating the authentication information at initialization time.

Reviewed-on: #5
Co-authored-by: Javier Cicchelli <javier@rock-n-code.com>
Co-committed-by: Javier Cicchelli <javier@rock-n-code.com>
This commit was merged in pull request #5.
This commit is contained in:
2025-10-12 19:33:45 +00:00
committed by Javier Cicchelli
parent a1a649838c
commit 24d703b967
10 changed files with 638 additions and 98 deletions
@@ -18,4 +18,10 @@ enum InputValidationError: Error {
case inputIsEmpty case inputIsEmpty
/// An input is nil. /// An input is nil.
case inputIsNil case inputIsNil
/// An input does not comply with the consumer key requirements.
case inputNotConsumerKey
/// An input does not comply with the consumer secret requirements.
case inputNotConsumerSecret
/// An input does not comply with the user token requirements.
case inputNotUserToken
} }
@@ -26,13 +26,8 @@ extension String {
static let token = "token" static let token = "token"
} }
/// A namespaces assigned for the formats of string values. /// A namespaces assigned for the formats of string values.
enum Format { enum Format {}
/// A format for the consumer authentication header.
static let authConsumer = "Discogs \(String.Parameter.key)=%@, \(String.Parameter.secret)=%@" /// A namespaces assigned for the formats of regular expression patterns.
/// A format for the user authentication header. enum Pattern {}
static let authUser = "Discogs \(String.Parameter.token)=%@"
/// A format for the user agent header.
static let userAgent = "%@/%@ +%@"
}
} }
@@ -29,6 +29,17 @@ struct NotEmptyValidationRule: InputValidationRule {
} }
// MARK: - Definitions
extension InputValidationRule where Self == NotEmptyValidationRule {
// MARK: Constants
/// A validation rule that checks whether an input is empty or not.
static var notEmpty: Self { .init() }
}
// MARK: - Helpers // MARK: - Helpers
private extension NotEmptyValidationRule { private extension NotEmptyValidationRule {
@@ -54,10 +65,3 @@ private extension NotEmptyValidationRule {
} }
} }
// MARK: - Constants
extension InputValidationRule where Self == NotEmptyValidationRule {
/// A validation rule that checks whether an input is empty or not.
static var notEmpty: Self { .init() }
}
@@ -29,6 +29,17 @@ struct NotNilValidationRule: InputValidationRule {
} }
// MARK: - Definitions
extension InputValidationRule where Self == NotNilValidationRule {
// MARK: Constants
/// A validation rule that checks whether an input is nil or not.
static var notNil: Self { .init() }
}
// MARK: - Helpers // MARK: - Helpers
private extension NotNilValidationRule { private extension NotNilValidationRule {
@@ -43,7 +54,7 @@ private extension NotNilValidationRule {
/// - Returns: A flag that indicates whether a given input has been validated or not. /// - Returns: A flag that indicates whether a given input has been validated or not.
/// - Throws: An error of type ``InputValidatorError`` in case the validation failed. /// - Throws: An error of type ``InputValidatorError`` in case the validation failed.
func validate(input: String?) throws -> Bool { func validate(input: String?) throws -> Bool {
guard let input else { guard input != nil else {
throw InputValidationError.inputIsNil throw InputValidationError.inputIsNil
} }
@@ -51,10 +62,3 @@ private extension NotNilValidationRule {
} }
} }
// MARK: - Constants
extension InputValidationRule where Self == NotNilValidationRule {
/// A validation rule that checks whether an input is nil or not.
static var notNil: Self { .init() }
}
@@ -0,0 +1,135 @@
// ===----------------------------------------------------------------------===
//
// This source file is part of the DiscogsService open source project
//
// Copyright (c) 2025 Röck+Cöde VoF. and the DiscogsService project authors
// Licensed under Apache license v2.0
//
// See LICENSE for license information
// See CONTRIBUTORS for the list of DiscogsService project authors
//
// SPDX-License-Identifier: Apache-2.0
//
// ===----------------------------------------------------------------------===
import Foundation
/// A validation rule type that checks whether an input is secure or not.
struct SecureValidationRule: InputValidationRule {
// MARK: Properties
/// A representation of the available security input types.
private let inputType: SecurityInput
// MARK: Initializers
/// Initializes this validation rule.
/// - Parameter inputType: A representation of the available security input types.
init(inputType: SecurityInput) {
self.inputType = inputType
}
// MARK: Functions
#if swift(>=6.0)
func validate(_ input: String?) throws(InputValidationError) -> Bool {
try validate(input: input)
}
#else
func validate(_ input: String?) throws -> Bool {
try validate(input: input)
}
#endif
}
// MARK: - Definitions
extension InputValidationRule where Self == SecureValidationRule {
// MARK: Functions
/// A validation rule that checks whether an input is secure or not.
/// - Parameter securityInput: A representation of the security input type to validate
/// - Returns: A validation rule that has been configured and it is ready to use.
static func secure(_ securityInput: SecurityInput) -> Self {
.init(inputType: securityInput)
}
}
// MARK: - Enumerations
/// A representation of all the possible security input types, based on their respective character length expectations.
enum SecurityInput: Int {
/// A consumer key is 20 characters long.
case consumerKey = 20
/// A consumer key is 32 characters long.
case consumerSecret = 32
/// A consumer key is 40 characters long.
case userToken = 40
}
// MARK: - Helpers
private extension SecureValidationRule {
// MARK: Functions
/// Checks if a given input is valid,
/// - Parameter input: An input to validate.
/// - Returns: A flag that indicates whether a given input is valid or not.
func isValid(_ input: String) -> Bool {
let regexPattern = String(format: .Pattern.securityInput, inputType.rawValue)
do {
if #available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 6.0, *) {
let securityInput = try Regex(regexPattern)
let matches = input.matches(of: securityInput)
return !matches.isEmpty
} else {
let securityInput = try NSRegularExpression(pattern: regexPattern)
let matches = securityInput.matches(
in: input,
range: .init(location: 0, length: input.count)
)
return !matches.isEmpty
}
} catch {
return false
}
}
/// Validates a given input.
///
/// > note: This helper function would not be necessary when support for *Swift 5.10* is discontinued.
///
/// - Parameter input: An input to be validated.
/// - Returns: A flag that indicates whether a given input has been validated or not.
/// - Throws: An error of type ``InputValidatorError`` in case the validation failed.
func validate(input: String?) throws -> Bool {
guard let input else {
return false
}
guard isValid(input) else {
switch inputType {
case .consumerKey: throw InputValidationError.inputNotConsumerKey
case .consumerSecret: throw InputValidationError.inputNotConsumerSecret
case .userToken: throw InputValidationError.inputNotUserToken
}
}
return true
}
}
// MARK: - Constants
private extension String.Pattern {
/// A regular expression pattern to match the security inputs against.
static let securityInput = "^([a-z]|[A-Z]){%d}$"
}
@@ -13,7 +13,7 @@
// ===----------------------------------------------------------------------=== // ===----------------------------------------------------------------------===
/// A representation of the available transport options to send credentials in authenticated requests. /// A representation of the available transport options to send credentials in authenticated requests.
public enum AuthTransport: Sendable { public enum AuthTransport: CaseIterable, Sendable {
/// Authentication credential are sent in a request as an `Authentication` header. /// Authentication credential are sent in a request as an `Authentication` header.
/// ///
/// This means that the header will be added to any existing header in a request, like this: /// This means that the header will be added to any existing header in a request, like this:
@@ -19,23 +19,24 @@ import protocol OpenAPIRuntime.ClientMiddleware
import struct Foundation.URL import struct Foundation.URL
import struct Foundation.URLComponents import struct Foundation.URLComponents
import struct Foundation.URLQueryItem import struct Foundation.URLQueryItem
import struct HTTPTypes.HTTPField
import struct HTTPTypes.HTTPFields import struct HTTPTypes.HTTPFields
import struct HTTPTypes.HTTPRequest import struct HTTPTypes.HTTPRequest
import struct HTTPTypes.HTTPResponse import struct HTTPTypes.HTTPResponse
/// A middleware that attaches any defined authentication credentials into the requests for the service. /// A middleware that attaches any defined authentication credentials into the requests to the service.
/// ///
/// Please refer to the [Discogs documentation](https://www.discogs.com/developers#page:authentication) for further information. /// Please refer to the [Discogs documentation](https://www.discogs.com/developers#page:authentication) for further information.
public struct AuthMiddleware { public struct AuthMiddleware {
// MARK: Properties // MARK: Properties
/// A representation of an authentication method to use to authenticate requests. /// A header field that contains the authentication information.
private let method: AuthMethod let authField: HTTPField?
/// A representation of a transport option to send credentials in requests.
private let transport: AuthTransport
/// A list of query items that contains the authentication information.
let authItems: [URLQueryItem]?
// MARK: Initializers // MARK: Initializers
/// Initializes this middleware. /// Initializes this middleware.
@@ -45,9 +46,59 @@ public struct AuthMiddleware {
public init( public init(
method: AuthMethod = .none, method: AuthMethod = .none,
transport: AuthTransport transport: AuthTransport
) { ) throws {
self.method = method switch method {
self.transport = transport case let .consumer(key, secret):
let validateKey = ValidateInputUseCase(rules: .notNil, .notEmpty, .secure(.consumerKey))
let validateSecret = ValidateInputUseCase(rules: .notNil, .notEmpty, .secure(.consumerSecret))
try validateKey(key)
try validateSecret(secret)
self.authField = switch transport {
case .onQuery: nil
case .onHeader: .init(
name: .authorization,
value: .init(format: .Format.authConsumer, key, secret)
)}
self.authItems = switch transport {
case .onHeader: nil
case .onQuery: [
.init(name: .Parameter.key, value: key),
.init(name: .Parameter.secret, value: secret)
]}
case let .user(token):
let validateToken = ValidateInputUseCase(rules: .notNil, .notEmpty, .secure(.userToken))
try validateToken(token)
self.authField = switch transport {
case .onQuery: nil
case .onHeader: .init(
name: .authorization,
value: .init(format: .Format.authUser, token)
)}
self.authItems = switch transport {
case .onHeader: nil
case .onQuery: [
.init(name: .Parameter.token, value: token)
]
}
case .none:
self.authField = nil
self.authItems = nil
}
}
// MARK: Computed
/// A flag that indicates whether the middleware should authenticate the intercepted request or not.
var shouldAuthenticate: Bool {
authField != nil || authItems != nil
} }
} }
@@ -65,29 +116,17 @@ extension AuthMiddleware: ClientMiddleware {
operationID: String, operationID: String,
next: @Sendable (HTTPRequest, HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?) next: @Sendable (HTTPRequest, HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?)
) async throws -> (HTTPResponse, HTTPBody?) { ) async throws -> (HTTPResponse, HTTPBody?) {
guard method != .none else { guard shouldAuthenticate else {
return try await next(request, body, baseURL) return try await next(request, body, baseURL)
} }
let headerFields = if transport == .onHeader {
authenticateHeader(request.headerFields)
} else {
request.headerFields
}
let path = if transport == .onQuery {
authenticatePath(request.path)
} else {
request.path
}
return try await next( return try await next(
.init( .init(
method: request.method, method: request.method,
scheme: request.scheme, scheme: request.scheme,
authority: request.authority, authority: request.authority,
path: path, path: authenticatePath(request.path),
headerFields: headerFields headerFields: authenticateHeader(request.headerFields)
), ),
body, body,
baseURL baseURL
@@ -104,21 +143,14 @@ private extension AuthMiddleware {
/// Adds an authorization header to the existing header fields. /// Adds an authorization header to the existing header fields.
/// - Parameter fields: A set of header fields to update. /// - Parameter fields: A set of header fields to update.
/// - Returns: An updated set of header fields. /// - Returns: An updated set of header fields including the authorization header.
func authenticateHeader(_ fields: HTTPFields) -> HTTPFields { func authenticateHeader(_ fields: HTTPFields) -> HTTPFields {
var fields = fields var fields = fields
let authorization: String = switch method { if let authField {
case let .consumer(key, secret): .init(format: .Format.authConsumer, key, secret) fields.append(authField)
case let .user(token): .init(format: .Format.authUser, token)
default: .empty
} }
fields.append(.init(
name: .authorization,
value: authorization
))
return fields return fields
} }
@@ -127,23 +159,13 @@ private extension AuthMiddleware {
/// - Returns: An updated request path including the authentication parameters. /// - Returns: An updated request path including the authentication parameters.
func authenticatePath(_ path: String?) -> String? { func authenticatePath(_ path: String?) -> String? {
guard guard
let authItems,
let path, let path,
var urlComponents = URLComponents(string: path) var urlComponents = URLComponents(string: path)
else { else {
return path return path
} }
let authItems: [URLQueryItem] = switch method {
case let .consumer(key, secret): [
.init(name: .Parameter.key, value: key),
.init(name: .Parameter.secret, value: secret)
]
case let .user(token): [
.init(name: .Parameter.token, value: token)
]
default: []
}
var queryItems = urlComponents.queryItems ?? [] var queryItems = urlComponents.queryItems ?? []
queryItems.append(contentsOf: authItems) queryItems.append(contentsOf: authItems)
@@ -158,3 +180,12 @@ private extension AuthMiddleware {
} }
} }
// MARK: - Constants
private extension String.Format {
/// A format for the consumer authentication header.
static let authConsumer = "Discogs \(String.Parameter.key)=%@, \(String.Parameter.secret)=%@"
/// A format for the user authentication header.
static let authUser = "Discogs \(String.Parameter.token)=%@"
}
@@ -16,33 +16,147 @@ import Testing
@testable import DiscogsService @testable import DiscogsService
@Suite("Validate Input Use Cases") @Suite("Validate Input Use Cases", .tags(.useCase))
struct ValidateInputUseCaseTests { struct ValidateInputUseCaseTests {
// MARK: Functions // MARK: Functions
#if swift(>=6.2) #if swift(>=6.2)
@Test(arguments: zip( @Test(arguments: zip(
Input.inputsToValidate, Input.inputsNotEmpty,
Output.inputsToValidate Output.inputsNotEmpty
)) func `validate`( )) func `validates not empty`(
input: String,
expects error: InputValidationError?
) async throws {
try assertValidate(
rule: .notEmpty,
input: input,
expects: error
)
}
@Test(arguments: zip(
Input.inputsNotNil,
Output.inputsNotNil
)) func `validate not nil`(
input: String?, input: String?,
expects error: InputValidationError? expects error: InputValidationError?
) async throws { ) async throws {
try assertValidate( try assertValidate(
rule: .notNil,
input: input,
expects: error
)
}
@Test(arguments: zip(
Input.inputsSecureConsumerKey,
Output.inputsSecureConsumerKey
)) func `validate secure (consumer key)`(
input: String?,
expects error: InputValidationError?
) async throws {
try assertValidate(
rule: .secure(.consumerKey),
input: input,
expects: error
)
}
@Test(arguments: zip(
Input.inputsSecureConsumerSecret,
Output.inputsSecureConsumerSecret
)) func `validate secure (consumer secret)`(
input: String?,
expects error: InputValidationError?
) async throws {
try assertValidate(
rule: .secure(.consumerSecret),
input: input,
expects: error
)
}
@Test(arguments: zip(
Input.inputsSecureUserToken,
Output.inputsSecureUserToken
)) func `validate secure (user token)`(
input: String?,
expects error: InputValidationError?
) async throws {
try assertValidate(
rule: .secure(.userToken),
input: input, input: input,
expects: error expects: error
) )
} }
#else #else
@Test("validate", arguments: zip( @Test("validate not empty", arguments: zip(
Input.inputsToValidate, Input.inputsNotEmpty,
Output.inputsToValidate Output.inputsNotEmpty
)) func validate( )) func validateNotEmpty(
input: String,
expects error: InputValidationError?
) async throws {
try assertValidate(
rule: .notEmpty,
input: input,
expects: error
)
}
@Test("validate not nil", arguments: zip(
Input.inputsNotNil,
Output.inputsNotNil
)) func validateNotNil(
input: String?, input: String?,
expects error: InputValidationError? expects error: InputValidationError?
) async throws { ) async throws {
try assertValidate( try assertValidate(
rule: .notNil,
input: input,
expects: error
)
}
@Test("validate secure (consumer key)", arguments: zip(
Input.inputsSecureConsumerKey,
Output.inputsSecureConsumerKey
)) func validateSecureConsumerKey(
input: String?,
expects error: InputValidationError?
) async throws {
try assertValidate(
rule: .secure(.consumerKey),
input: input,
expects: error
)
}
@Test("validate secure (consumer secret)", arguments: zip(
Input.inputsSecureConsumerSecret,
Output.inputsSecureConsumerSecret
)) func validateSecureConsumerSecret(
input: String?,
expects error: InputValidationError?
) async throws {
try assertValidate(
rule: .secure(.consumerSecret),
input: input,
expects: error
)
}
@Test("validate secure (user token)", arguments: zip(
Input.inputsSecureUserToken,
Output.inputsSecureUserToken
)) func validateSecureUserToken(
input: String?,
expects error: InputValidationError?
) async throws {
try assertValidate(
rule: .secure(.userToken),
input: input, input: input,
expects: error expects: error
) )
@@ -59,15 +173,17 @@ private extension ValidateInputUseCaseTests {
/// Asserts an input validation of a ``ValidateInputUseCase`` use case. /// Asserts an input validation of a ``ValidateInputUseCase`` use case.
/// - Parameters: /// - Parameters:
/// - rule: A validation rule to test.
/// - input: An input to validate, if any. /// - input: An input to validate, if any.
/// - error: An expected error, if any. /// - error: An expected error, if any.
/// - Throws: An error of type ``InputValidationError`` in case of an unexpected test case scenario. /// - Throws: An error of type ``InputValidationError`` in case of an unexpected test case scenario.
func assertValidate( func assertValidate(
rule: InputValidationRule,
input: String?, input: String?,
expects error: InputValidationError? expects error: InputValidationError?
) throws { ) throws {
// GIVEN // GIVEN
let validate = ValidateInputUseCase(rules: .notNil, .notEmpty) let validate = ValidateInputUseCase(rules: rule)
// WHEN // WHEN
// THEN // THEN
@@ -87,11 +203,27 @@ private extension ValidateInputUseCaseTests {
// MARK: - Constants // MARK: - Constants
private extension Input { private extension Input {
/// A list of inputs to validate against a set of validation rules. /// A list of inputs to validate against the not empty validation rule.
static let inputsToValidate: [String?] = [nil, .empty, "SomeInput"] static let inputsNotEmpty: [String] = ["Something", .empty]
/// A list of inputs to validate against the not nil validation rule.
static let inputsNotNil: [String?] = [.empty, nil]
/// A list of inputs to validate against the secure (consumer key) validation rule.
static let inputsSecureConsumerKey: [String] = ["aAbBcCdDeEfFgGhHiIjJ", "aAbBcCdDeEfFgGhH", "aAbBcCdDeEfFgGhHiIjJkK", "a4bBcCdDe3fFg6hH1Ij7"]
/// A list of inputs to validate against the secure (consumer secret) validation rule.
static let inputsSecureConsumerSecret: [String] = ["aAbBcCdDeEfFgGhHiIjJkKlLmMnNoOpP", "aAbBcCdDeEfFgGhHiIjJkKlLmMnNoO", "aAbBcCdDeEfFgGhHiIjJkKlLmMnNoOpPqQ", "a4bBcCdDe3fFg6hH1IjJkK1LmMnNo0p9"]
/// A list of inputs to validate against the secure (user token) validation rule.
static let inputsSecureUserToken: [String] = ["aAbBcCdDeEfFgGhHiIjJkKlLmMnNoOpPqQrRsStT", "aAbBcCdDeEfFgGhHiIjJkKlLmMnNoOpPqQrRsS", "aAbBcCdDeEfFgGhHiIjJkKlLmMnNoOpPqQrRsStTuU", "a4bBcCdDe3fFg6hH1IjJkK1LmMnNo0p9qQrRs5t7"]
} }
private extension Output { private extension Output {
/// A list of expected input validation errors to be thrown (if necessary). /// A list of expected input validation errors to be thrown after validating inputs against the not empty validation rule.
static let inputsToValidate: [InputValidationError?] = [.inputIsNil, .inputIsEmpty, nil] static let inputsNotEmpty: [InputValidationError?] = [nil, .inputIsEmpty]
/// A list of expected input validation errors to be thrown after validating inputs against the not nil validation rule.
static let inputsNotNil: [InputValidationError?] = [nil, .inputIsNil]
/// A list of expected input validation errors to be thrown after validating inputs against the secure (consumer key) validation rule.
static let inputsSecureConsumerKey: [InputValidationError?] = [nil, .inputNotConsumerKey, .inputNotConsumerKey, .inputNotConsumerKey]
/// A list of expected input validation errors to be thrown after validating inputs against the secure (consumer secret) validation rule.
static let inputsSecureConsumerSecret: [InputValidationError?] = [nil, .inputNotConsumerSecret, .inputNotConsumerSecret, .inputNotConsumerSecret]
/// A list of expected input validation errors to be thrown after validating inputs against the secure (user token) validation rule.
static let inputsSecureUserToken: [InputValidationError?] = [nil, .inputNotUserToken, .inputNotUserToken, .inputNotUserToken]
} }
@@ -14,6 +14,8 @@
import struct Foundation.URL import struct Foundation.URL
import struct Foundation.URLComponents import struct Foundation.URLComponents
import struct Foundation.URLQueryItem
import struct HTTPTypes.HTTPField
import struct HTTPTypes.HTTPFields import struct HTTPTypes.HTTPFields
import struct HTTPTypes.HTTPRequest import struct HTTPTypes.HTTPRequest
import struct HTTPTypes.HTTPResponse import struct HTTPTypes.HTTPResponse
@@ -25,6 +27,92 @@ import Testing
@Suite("Auth Middleware", .tags(.middleware)) @Suite("Auth Middleware", .tags(.middleware))
struct AuthMiddlewareTests { struct AuthMiddlewareTests {
// MARK: Initializers tests
#if swift(>=6.2)
@Test(arguments: Input.authMethods)
func `initialize`(
_ authMethod: AuthMethod
) async throws {
try assertInit(
authMethod: authMethod,
authTransport: try randomTransport
)
}
@Test(arguments: zip(
Input.authMethodsThrows,
Output.authMethodsThrows
))
func `initialize throws`(
_ authMethod: AuthMethod,
expects error: InputValidationError?
) async throws {
try assertInitThrows(
authMethod: authMethod,
authTransport: try randomTransport,
expects: error
)
}
#else
@Test("initialize", arguments: Input.authMethods)
func initialize(
_ authMethod: AuthMethod
) throws {
try assertInit(
authMethod: authMethod,
authTransport: try randomTransport
)
}
@Test("initialize throws", arguments: zip(
Input.authMethodsThrows,
Output.authMethodsThrows
))
func initializeThrows(
_ authMethod: AuthMethod,
expects error: InputValidationError?
) throws {
assertInitThrows(
authMethod: authMethod,
authTransport: try randomTransport,
expects: error
)
}
#endif
// MARK: Properties tests
#if swift(>=6.2)
@Test(arguments: zip(
Input.authMethods,
Output.authMethodsShouldAuthenticate
))
func `should authenticate`(
_ authMethod: AuthMethod,
expects flag: Bool
) throws {
try assertShouldAuthenticate(
authMethod: authMethod,
authTransport: try randomTransport,
expects: flag
)
}
#else
@Test("should authenticate", arguments: zip(
Input.authMethods,
Output.authMethodsShouldAuthenticate
))
func shouldAuthenticate(
_ authMethod: AuthMethod,
expects flag: Bool
) throws {
try assertShouldAuthenticate(
authMethod: authMethod,
authTransport: try randomTransport,
expects: flag
)
}
#endif
// MARK: Functions tests // MARK: Functions tests
#if swift(>=6.2) #if swift(>=6.2)
@@ -134,12 +222,94 @@ private extension AuthMiddlewareTests {
// MARK: Functions // MARK: Functions
/// Asserts the initialization of the middleware, especially the assignment of its properties.
/// - Parameters:
/// - authMethod: A representation of an authentication method.
/// - authTransport: A representation of an authentication transport.
/// - Throws: an error of type ``InputValidationError`` in case of an unexpected error occurs while running test cases.
func assertInit(
authMethod: AuthMethod,
authTransport: AuthTransport,
) throws {
// GIVEN
// WHEN
let middleware = try AuthMiddleware(
method: authMethod,
transport: authTransport
)
// THEN
switch (authMethod, authTransport) {
case let (.consumer(key, secret), .onHeader):
#expect(middleware.authItems == nil)
#expect(middleware.authField == .init(
name: .authorization,
value: "Discogs \(String.Parameter.key)=\(key), \(String.Parameter.secret)=\(secret)"
))
case let (.consumer(key, secret), .onQuery):
#expect(middleware.authField == nil)
#expect(middleware.authItems == [
.init(name: .Parameter.key, value: key),
.init(name: .Parameter.secret, value: secret)
])
case let (.user(token), .onHeader):
#expect(middleware.authItems == nil)
#expect(middleware.authField == .init(
name: .authorization,
value: "Discogs \(String.Parameter.token)=\(token)"
))
case let (.user(token), .onQuery):
#expect(middleware.authField == nil)
#expect(middleware.authItems == [
.init(name: .Parameter.token, value: token)
])
case (.none, _):
#expect(middleware.authField == nil)
#expect(middleware.authItems == nil)
}
}
/// Asserts the error throwing (if justified) during the initialization of the middleware.
/// - Parameters:
/// - authMethod: A representation of an authentication method.
/// - authTransport: A representation of an authentication transport.
/// - error: An expected error of type ``InputValidationError`` during the initialization of the middleware.
func assertInitThrows(
authMethod: AuthMethod,
authTransport: AuthTransport,
expects error: InputValidationError?
) {
// GIVEN
// WHEN
// THEN
if let error {
#expect(throws: error) {
try AuthMiddleware(
method: authMethod,
transport: authTransport
)
}
} else {
#expect(throws: Never.self) {
try AuthMiddleware(
method: authMethod,
transport: authTransport
)
}
}
}
/// Asserts the interception of a request to add its authentication. /// Asserts the interception of a request to add its authentication.
/// - Parameters: /// - Parameters:
/// - authMethod: A representation of an authentication method. /// - authMethod: A representation of an authentication method.
/// - authTransport: A representation of an authentication transport. /// - authTransport: A representation of an authentication transport.
/// - path: A URI path for a request. /// - path: A URI path for a request.
/// - headerFields: A set of header fields for a request. /// - headerFields: A set of header fields for a request.
/// - Throws:An error in case of an unexpected issue encountered while running a test case.
func assertIntercept( func assertIntercept(
authMethod: AuthMethod, authMethod: AuthMethod,
authTransport: AuthTransport, authTransport: AuthTransport,
@@ -147,7 +317,7 @@ private extension AuthMiddlewareTests {
headerFields: HTTPFields = [:], headerFields: HTTPFields = [:],
) async throws { ) async throws {
// GIVEN // GIVEN
let middleware = AuthMiddleware( let middleware = try AuthMiddleware(
method: authMethod, method: authMethod,
transport: authTransport transport: authTransport
) )
@@ -166,22 +336,24 @@ private extension AuthMiddlewareTests {
) { request, _, _ in ) { request, _, _ in
// THEN // THEN
switch (authMethod, authTransport) { switch (authMethod, authTransport) {
case let (.consumer(key, secret), .onHeader): case (.consumer, .onHeader):
#expect(request.path == path) #expect(request.path == path)
#expect(request.headerFields != headerFields) #expect(request.headerFields != headerFields)
#expect(request.headerFields[.authorization] == "Discogs key=\(key), secret=\(secret)") #expect(request.headerFields.contains(where: { $0.name == .authorization }))
case (.consumer, .onQuery): case (.consumer, .onQuery):
#expect(request.path != path)
try assertAuthInPath(request.path, authMethod)
#expect(request.headerFields == headerFields) #expect(request.headerFields == headerFields)
case let (.user(token), .onHeader): try assertAuthInPath(request.path, authMethod)
case (.user, .onHeader):
#expect(request.path == path) #expect(request.path == path)
#expect(request.headerFields != headerFields) #expect(request.headerFields != headerFields)
#expect(request.headerFields[.authorization] == "Discogs token=\(token)") #expect(request.headerFields.contains(where: { $0.name == .authorization }))
case (.user, .onQuery): case (.user, .onQuery):
#expect(request.path != path)
try assertAuthInPath(request.path, authMethod)
#expect(request.headerFields == headerFields) #expect(request.headerFields == headerFields)
try assertAuthInPath(request.path, authMethod)
case (.none, _): case (.none, _):
#expect(request.path == path) #expect(request.path == path)
#expect(request.headerFields == headerFields) #expect(request.headerFields == headerFields)
@@ -194,10 +366,33 @@ private extension AuthMiddlewareTests {
} }
} }
/// Asserts the value of `shouldAuthenticate` flag after an initialization of a middleware.
/// - Parameters:
/// - authMethod: A representation of an authentication method.
/// - authTransport: A representation of an authentication transport.
/// - flag: An expected flag that indicates whether the middleware should authenticate its requests or not.
/// - Throws: An error of type ``InputValidationError`` in case of an unexpected issue occurs while running test cases.
func assertShouldAuthenticate(
authMethod: AuthMethod,
authTransport: AuthTransport,
expects flag: Bool
) throws {
// GIVEN
// WHEN
let middleware = try AuthMiddleware(
method: authMethod,
transport: authTransport
)
// THEN
#expect(middleware.shouldAuthenticate == flag)
}
/// Asserts a request path to contain authentication parameters in its query. /// Asserts a request path to contain authentication parameters in its query.
/// - Parameters: /// - Parameters:
/// - path: A request path /// - path: A request path
/// - authMethod: A representation of an authentication method. /// - authMethod: A representation of an authentication method.
/// - Throws:An error in case of an unexpected issue encountered while unwrapping the optionals.
func assertAuthInPath( func assertAuthInPath(
_ path: String?, _ path: String?,
_ authMethod: AuthMethod _ authMethod: AuthMethod
@@ -222,6 +417,19 @@ private extension AuthMiddlewareTests {
// MARK: - Helpers // MARK: - Helpers
private extension AuthMiddlewareTests {
// MARK: Properties
/// Provides a random authentication transport representation.
var randomTransport: AuthTransport {
get throws {
try #require(AuthTransport.allCases.randomElement())
}
}
}
private extension HTTPRequest { private extension HTTPRequest {
// MARK: Initializers // MARK: Initializers
@@ -250,12 +458,34 @@ private extension HTTPRequest {
// MARK: - Constants // MARK: - Constants
private extension Input { private extension Input {
/// A list of authentication methods for a request. /// A list of authentication methods to use in most of the test cases.
static let authMethods: [AuthMethod] = [ static let authMethods: [AuthMethod] = [
.consumer(key: "SomeKey", secret: "SomeSecret"), .consumer(key: "aAbBcCdDeEfFgGhHiIjJ", secret: "aAbBcCdDeEfFgGhHiIjJkKlLmMnNoOpP"),
.user(token: "SomeToken"), .user(token: "aAbBcCdDeEfFgGhHiIjJkKlLmMnNoOpPqQrRsStT"),
.none .none
] ]
/// A list of authentication methods to tests for the initialization throw test cases.
static let authMethodsThrows: [AuthMethod] = authMethods + [
.consumer(key: .empty, secret: "aAbBcCdDeEfFgGhHiIjJkKlLmMnNoOpP"),
.consumer(key: "aAbBcCdDeEfFgGhHiI", secret: "aAbBcCdDeEfFgGhHiIjJkKlLmMnNoOpP"),
.consumer(key: "aAbBcCdDeEfFgGhHiIjJkK", secret: "aAbBcCdDeEfFgGhHiIjJkKlLmMnNoOpP"),
.consumer(key: "a4bBcCdDe3fFg6hH1Ij7", secret: "aAbBcCdDeEfFgGhHiIjJkKlLmMnNoOpP"),
.consumer(key: "aAbBcCdDeEfFgGhHiIjJ", secret: .empty),
.consumer(key: "aAbBcCdDeEfFgGhHiIjJ", secret: "aAbBcCdDeEfFgGhHiIjJkKlLmMnNoO"),
.consumer(key: "aAbBcCdDeEfFgGhHiIjJ", secret: "aAbBcCdDeEfFgGhHiIjJkKlLmMnNoOpPqQ"),
.consumer(key: "aAbBcCdDeEfFgGhHiIjJ", secret: "a4bBcCdDe3fFg6hH1IjJkK1LmMnNo0p9"),
.user(token: .empty),
.user(token: "aAbBcCdDeEfFgGhHiIjJkKlLmMnNoOpPqQrRsS"),
.user(token: "aAbBcCdDeEfFgGhHiIjJkKlLmMnNoOpPqQrRsStTuU"),
.user(token: "a4bBcCdDe3fFg6hH1IjJkK1LmMnNo0p9qQrRs5t7"),
]
}
private extension Output {
/// A list of expected input validation errors (if thrown) coming from the initialization throw test cases.
static let authMethodsThrows: [InputValidationError?] = [nil, nil, nil, .inputIsEmpty, .inputNotConsumerKey, .inputNotConsumerKey, .inputNotConsumerKey, .inputIsEmpty, .inputNotConsumerSecret, .inputNotConsumerSecret, .inputNotConsumerSecret, .inputIsEmpty, .inputNotUserToken, .inputNotUserToken, .inputNotUserToken]
/// A list of expected boolean flags coming from the should authenticate test cases.
static let authMethodsShouldAuthenticate: [Bool] = [true, true, false]
} }
private extension String { private extension String {
@@ -21,4 +21,7 @@ extension Tag {
/// A tag that indicates tests for a middleware type. /// A tag that indicates tests for a middleware type.
@Tag static var middleware: Self @Tag static var middleware: Self
/// A tag that indicates tests for a use case type.
@Tag static var useCase: Self
} }