From 939492b8ebe775d0e2ba2a518a1e240a52673464 Mon Sep 17 00:00:00 2001 From: Francesco Paolo Severino <96546612+fpseverino@users.noreply.github.com> Date: Sun, 29 Sep 2024 18:55:27 +0200 Subject: [PATCH] Adopt `swift-format` (#12) - Update to `swift-tools-version:6.0` - Adopt `swift-format` - Switch from custom `.unixPath()` to Foundation's `.path` - Add more tests --------- Co-authored-by: Mahdi Bahrami --- .github/workflows/test.yml | 9 + .spi.yml | 3 +- .swift-format | 70 ++++++ Package.swift | 30 ++- Sources/Orders/DTOs/OrderJSON.swift | 22 +- .../Middleware/AppleOrderMiddleware.swift | 14 +- .../Orders/Models/Concrete Models/Order.swift | 4 +- .../Models/Concrete Models/OrdersDevice.swift | 4 +- .../Concrete Models/OrdersErrorLog.swift | 3 +- .../Concrete Models/OrdersRegistration.swift | 10 +- Sources/Orders/Models/OrderDataModel.swift | 7 +- Sources/Orders/Models/OrderModel.swift | 38 +-- .../Models/OrdersRegistrationModel.swift | 24 +- Sources/Orders/OrdersDelegate.swift | 34 +-- Sources/Orders/OrdersError.swift | 14 +- Sources/Orders/OrdersService.swift | 24 +- Sources/Orders/OrdersServiceCustom.swift | 154 ++++++++---- Sources/PassKit/Models/DeviceModel.swift | 27 +- Sources/PassKit/Models/ErrorLogModel.swift | 13 +- Sources/PassKit/URL+Extension.swift | 35 --- Sources/Passes/DTOs/PassJSON.swift | 24 +- Sources/Passes/DTOs/PassesForDeviceDTO.swift | 2 +- .../DTOs/PersonalizationDictionaryDTO.swift | 14 +- Sources/Passes/DTOs/PersonalizationJSON.swift | 2 +- .../Middleware/ApplePassMiddleware.swift | 15 +- .../Passes/Models/Concrete Models/Pass.swift | 11 +- .../Models/Concrete Models/PassesDevice.swift | 4 +- .../Concrete Models/PassesErrorLog.swift | 3 +- .../Concrete Models/PassesRegistration.swift | 10 +- .../Concrete Models/UserPersonalization.swift | 10 +- Sources/Passes/Models/PassDataModel.swift | 7 +- Sources/Passes/Models/PassModel.swift | 45 ++-- .../Models/PassesRegistrationModel.swift | 27 +- .../Models/UserPersonalizationModel.swift | 90 ++++--- Sources/Passes/PassesDelegate.swift | 40 +-- Sources/Passes/PassesError.swift | 30 ++- Sources/Passes/PassesService.swift | 33 ++- Sources/Passes/PassesServiceCustom.swift | 238 +++++++++++------- .../OrdersTests/EncryptedOrdersDelegate.swift | 25 +- Tests/OrdersTests/EncryptedOrdersTests.swift | 24 +- Tests/OrdersTests/OrderData.swift | 30 ++- Tests/OrdersTests/OrdersTests.swift | 142 +++++++++-- Tests/OrdersTests/SecretMiddleware.swift | 8 +- Tests/OrdersTests/Templates/EmptyDir/.gitkeep | 0 Tests/OrdersTests/TestOrdersDelegate.swift | 23 +- .../PassesTests/EncryptedPassesDelegate.swift | 44 ++-- Tests/PassesTests/EncryptedPassesTests.swift | 60 +++-- Tests/PassesTests/PassData.swift | 34 ++- Tests/PassesTests/PassesTests.swift | 211 +++++++++++++--- Tests/PassesTests/SecretMiddleware.swift | 8 +- Tests/PassesTests/Templates/EmptyDir/.gitkeep | 0 Tests/PassesTests/TestPassesDelegate.swift | 42 ++-- 52 files changed, 1175 insertions(+), 620 deletions(-) create mode 100644 .swift-format delete mode 100644 Sources/PassKit/URL+Extension.swift create mode 100644 Tests/OrdersTests/Templates/EmptyDir/.gitkeep create mode 100644 Tests/PassesTests/Templates/EmptyDir/.gitkeep diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3dbf46d..c69731e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -7,6 +7,15 @@ on: push: { branches: [ main ] } jobs: + lint: + runs-on: ubuntu-latest + container: swift:noble + steps: + - name: Check out PassKit + uses: actions/checkout@v4 + - name: Run format lint check + run: swift format lint --strict --recursive --parallel . + unit-tests: uses: vapor/ci/.github/workflows/run-unit-tests.yml@main secrets: diff --git a/.spi.yml b/.spi.yml index ad36f6c..eaf12d0 100644 --- a/.spi.yml +++ b/.spi.yml @@ -1,4 +1,5 @@ version: 1 builder: configs: - - documentation_targets: [PassKit, Passes, Orders] \ No newline at end of file + - documentation_targets: [PassKit, Passes, Orders] + swift_version: 6.0 \ No newline at end of file diff --git a/.swift-format b/.swift-format new file mode 100644 index 0000000..360ca2c --- /dev/null +++ b/.swift-format @@ -0,0 +1,70 @@ +{ + "fileScopedDeclarationPrivacy": { + "accessLevel": "private" + }, + "indentation": { + "spaces": 4 + }, + "indentConditionalCompilationBlocks": true, + "indentSwitchCaseLabels": false, + "lineBreakAroundMultilineExpressionChainComponents": false, + "lineBreakBeforeControlFlowKeywords": false, + "lineBreakBeforeEachArgument": false, + "lineBreakBeforeEachGenericRequirement": false, + "lineLength": 100, + "maximumBlankLines": 1, + "multiElementCollectionTrailingCommas": true, + "noAssignmentInExpressions": { + "allowedFunctions": [ + "XCTAssertNoThrow" + ] + }, + "prioritizeKeepingFunctionOutputTogether": false, + "respectsExistingLineBreaks": true, + "rules": { + "AllPublicDeclarationsHaveDocumentation": false, + "AlwaysUseLiteralForEmptyCollectionInit": false, + "AlwaysUseLowerCamelCase": true, + "AmbiguousTrailingClosureOverload": true, + "BeginDocumentationCommentWithOneLineSummary": false, + "DoNotUseSemicolons": true, + "DontRepeatTypeInStaticProperties": true, + "FileScopedDeclarationPrivacy": true, + "FullyIndirectEnum": true, + "GroupNumericLiterals": true, + "IdentifiersMustBeASCII": true, + "NeverForceUnwrap": false, + "NeverUseForceTry": false, + "NeverUseImplicitlyUnwrappedOptionals": false, + "NoAccessLevelOnExtensionDeclaration": true, + "NoAssignmentInExpressions": true, + "NoBlockComments": true, + "NoCasesWithOnlyFallthrough": true, + "NoEmptyTrailingClosureParentheses": true, + "NoLabelsInCasePatterns": true, + "NoLeadingUnderscores": false, + "NoParensAroundConditions": true, + "NoPlaygroundLiterals": true, + "NoVoidReturnOnFunctionSignature": true, + "OmitExplicitReturns": false, + "OneCasePerLine": true, + "OneVariableDeclarationPerLine": true, + "OnlyOneTrailingClosureArgument": true, + "OrderedImports": true, + "ReplaceForEachWithForLoop": true, + "ReturnVoidInsteadOfEmptyTuple": true, + "TypeNamesShouldBeCapitalized": true, + "UseEarlyExits": false, + "UseExplicitNilCheckInConditions": true, + "UseLetInEveryBoundCaseVariable": true, + "UseShorthandTypeNames": true, + "UseSingleLinePropertyGetter": true, + "UseSynthesizedInitializer": true, + "UseTripleSlashForDocumentationComments": true, + "UseWhereClausesInForLoops": false, + "ValidateDocumentationComments": false + }, + "spacesAroundRangeFormationOperators": false, + "tabWidth": 8, + "version": 1 +} \ No newline at end of file diff --git a/Package.swift b/Package.swift index af782f7..cdefd8f 100644 --- a/Package.swift +++ b/Package.swift @@ -1,20 +1,20 @@ -// swift-tools-version:5.10 +// swift-tools-version:6.0 import PackageDescription let package = Package( name: "PassKit", platforms: [ - .macOS(.v13) + .macOS(.v14) ], products: [ .library(name: "Passes", targets: ["Passes"]), .library(name: "Orders", targets: ["Orders"]), ], dependencies: [ - .package(url: "https://github.com/vapor/vapor.git", from: "4.103.1"), + .package(url: "https://github.com/vapor/vapor.git", from: "4.105.2"), .package(url: "https://github.com/vapor/fluent.git", from: "4.11.0"), .package(url: "https://github.com/vapor/apns.git", from: "4.2.0"), - .package(url: "https://github.com/vapor-community/Zip.git", from: "2.2.0"), + .package(url: "https://github.com/vapor-community/Zip.git", from: "2.2.3"), .package(url: "https://github.com/apple/swift-certificates.git", from: "1.5.0"), // used in tests .package(url: "https://github.com/vapor/fluent-sqlite-driver.git", from: "4.7.4"), @@ -34,14 +34,14 @@ let package = Package( .target( name: "Passes", dependencies: [ - .target(name: "PassKit"), + .target(name: "PassKit") ], swiftSettings: swiftSettings ), .target( name: "Orders", dependencies: [ - .target(name: "PassKit"), + .target(name: "PassKit") ], swiftSettings: swiftSettings ), @@ -54,7 +54,7 @@ let package = Package( .product(name: "FluentSQLiteDriver", package: "fluent-sqlite-driver"), ], resources: [ - .copy("Templates"), + .copy("Templates") ], swiftSettings: swiftSettings ), @@ -67,18 +67,16 @@ let package = Package( .product(name: "FluentSQLiteDriver", package: "fluent-sqlite-driver"), ], resources: [ - .copy("Templates"), + .copy("Templates") ], swiftSettings: swiftSettings ), ] ) -var swiftSettings: [SwiftSetting] { [ - .enableUpcomingFeature("ExistentialAny"), - .enableUpcomingFeature("ConciseMagicFile"), - .enableUpcomingFeature("ForwardTrailingClosures"), - .enableUpcomingFeature("DisableOutwardActorInference"), - .enableUpcomingFeature("StrictConcurrency"), - .enableExperimentalFeature("StrictConcurrency=complete"), -] } +var swiftSettings: [SwiftSetting] { + [ + .enableUpcomingFeature("ExistentialAny"), + .enableUpcomingFeature("FullTypedThrows"), + ] +} diff --git a/Sources/Orders/DTOs/OrderJSON.swift b/Sources/Orders/DTOs/OrderJSON.swift index 33dafd1..23b7afb 100644 --- a/Sources/Orders/DTOs/OrderJSON.swift +++ b/Sources/Orders/DTOs/OrderJSON.swift @@ -13,35 +13,35 @@ public struct OrderJSON { public protocol Properties: Encodable { /// The date and time when the customer created the order, in RFC 3339 format. var createdAt: String { get } - + /// A unique order identifier scoped to your order type identifier. /// /// In combination with the order type identifier, this uniquely identifies an order within the system and isn’t displayed to the user. var orderIdentifier: String { get } - + /// A URL where the customer can manage the order. var orderManagementURL: String { get } - + /// The type of order this bundle represents. /// /// Currently the only supported value is `ecommerce`. var orderType: OrderType { get } - + /// An identifier for the order type associated with the order. /// /// The value must correspond with your signing certificate and isn’t displayed to the user. var orderTypeIdentifier: String { get } - + /// A high-level status of the order, used for display purposes. /// /// The system considers orders with status `completed` or `cancelled` closed. var status: OrderStatus { get } - + /// The version of the schema used for the order. /// /// The current version is `1`. var schemaVersion: SchemaVersion { get } - + /// The date and time when the order was last updated, in RFC 3339 format. /// /// This should equal the `createdAt` time, if the order hasn’t had any updates. @@ -58,10 +58,10 @@ extension OrderJSON { public protocol Merchant: Encodable { /// The localized display name of the merchant. var displayName: String { get } - + /// The Apple Merchant Identifier for this merchant, generated at `developer.apple.com`. var merchantIdentifier: String { get } - + /// The URL for the merchant’s website or landing page. var url: String { get } } @@ -74,10 +74,10 @@ extension OrderJSON { public protocol Barcode: Encodable { /// The format of the barcode. var format: BarcodeFormat { get } - + /// The contents of the barcode. var message: String { get } - + /// The text encoding of the barcode message. /// /// Typically this is `iso-8859-1`, but you may specify an alternative encoding if required. diff --git a/Sources/Orders/Middleware/AppleOrderMiddleware.swift b/Sources/Orders/Middleware/AppleOrderMiddleware.swift index 04d411e..66d8544 100644 --- a/Sources/Orders/Middleware/AppleOrderMiddleware.swift +++ b/Sources/Orders/Middleware/AppleOrderMiddleware.swift @@ -5,15 +5,19 @@ // Created by Francesco Paolo Severino on 30/06/24. // -import Vapor import FluentKit +import Vapor struct AppleOrderMiddleware: AsyncMiddleware { - func respond(to request: Request, chainingTo next: any AsyncResponder) async throws -> Response { - guard let auth = request.headers["Authorization"].first?.replacingOccurrences(of: "AppleOrder ", with: ""), - let _ = try await O.query(on: request.db) + func respond( + to request: Request, chainingTo next: any AsyncResponder + ) async throws -> Response { + guard + let auth = request.headers["Authorization"].first?.replacingOccurrences( + of: "AppleOrder ", with: ""), + (try await O.query(on: request.db) .filter(\._$authenticationToken == auth) - .first() + .first()) != nil else { throw Abort(.unauthorized) } diff --git a/Sources/Orders/Models/Concrete Models/Order.swift b/Sources/Orders/Models/Concrete Models/Order.swift index 9c0352c..2430a2d 100644 --- a/Sources/Orders/Models/Concrete Models/Order.swift +++ b/Sources/Orders/Models/Concrete Models/Order.swift @@ -5,8 +5,8 @@ // Created by Francesco Paolo Severino on 30/06/24. // -import Foundation import FluentKit +import Foundation /// The `Model` that stores Wallet orders. final public class Order: OrderModel, @unchecked Sendable { @@ -35,7 +35,7 @@ final public class Order: OrderModel, @unchecked Sendable { @Field(key: Order.FieldKeys.authenticationToken) public var authenticationToken: String - public required init() { } + public required init() {} public required init(orderTypeIdentifier: String, authenticationToken: String) { self.orderTypeIdentifier = orderTypeIdentifier diff --git a/Sources/Orders/Models/Concrete Models/OrdersDevice.swift b/Sources/Orders/Models/Concrete Models/OrdersDevice.swift index 4895ca2..eab4e47 100644 --- a/Sources/Orders/Models/Concrete Models/OrdersDevice.swift +++ b/Sources/Orders/Models/Concrete Models/OrdersDevice.swift @@ -38,7 +38,9 @@ extension OrdersDevice: AsyncMigration { .field(.id, .int, .identifier(auto: true)) .field(OrdersDevice.FieldKeys.pushToken, .string, .required) .field(OrdersDevice.FieldKeys.deviceLibraryIdentifier, .string, .required) - .unique(on: OrdersDevice.FieldKeys.pushToken, OrdersDevice.FieldKeys.deviceLibraryIdentifier) + .unique( + on: OrdersDevice.FieldKeys.pushToken, OrdersDevice.FieldKeys.deviceLibraryIdentifier + ) .create() } diff --git a/Sources/Orders/Models/Concrete Models/OrdersErrorLog.swift b/Sources/Orders/Models/Concrete Models/OrdersErrorLog.swift index 4af3aa0..d0b7ac6 100644 --- a/Sources/Orders/Models/Concrete Models/OrdersErrorLog.swift +++ b/Sources/Orders/Models/Concrete Models/OrdersErrorLog.swift @@ -5,10 +5,11 @@ // Created by Francesco Paolo Severino on 30/06/24. // -import struct Foundation.Date import FluentKit import PassKit +import struct Foundation.Date + /// The `Model` that stores Wallet orders error logs. final public class OrdersErrorLog: ErrorLogModel, @unchecked Sendable { /// The schema name of the error log model. diff --git a/Sources/Orders/Models/Concrete Models/OrdersRegistration.swift b/Sources/Orders/Models/Concrete Models/OrdersRegistration.swift index 7a43280..ea4ddf3 100644 --- a/Sources/Orders/Models/Concrete Models/OrdersRegistration.swift +++ b/Sources/Orders/Models/Concrete Models/OrdersRegistration.swift @@ -33,8 +33,14 @@ extension OrdersRegistration: AsyncMigration { public func prepare(on database: any Database) async throws { try await database.schema(Self.schema) .field(.id, .int, .identifier(auto: true)) - .field(OrdersRegistration.FieldKeys.deviceID, .int, .required, .references(DeviceType.schema, .id, onDelete: .cascade)) - .field(OrdersRegistration.FieldKeys.orderID, .uuid, .required, .references(OrderType.schema, .id, onDelete: .cascade)) + .field( + OrdersRegistration.FieldKeys.deviceID, .int, .required, + .references(DeviceType.schema, .id, onDelete: .cascade) + ) + .field( + OrdersRegistration.FieldKeys.orderID, .uuid, .required, + .references(OrderType.schema, .id, onDelete: .cascade) + ) .create() } diff --git a/Sources/Orders/Models/OrderDataModel.swift b/Sources/Orders/Models/OrderDataModel.swift index d5a999e..cbb89f1 100644 --- a/Sources/Orders/Models/OrderDataModel.swift +++ b/Sources/Orders/Models/OrderDataModel.swift @@ -15,11 +15,12 @@ public protocol OrderDataModel: Model { var order: OrderType { get set } } -internal extension OrderDataModel { +extension OrderDataModel { var _$order: Parent { guard let mirror = Mirror(reflecting: self).descendant("_order"), - let order = mirror as? Parent else { - fatalError("order property must be declared using @Parent") + let order = mirror as? Parent + else { + fatalError("order property must be declared using @Parent") } return order diff --git a/Sources/Orders/Models/OrderModel.swift b/Sources/Orders/Models/OrderModel.swift index 056307e..a9045fd 100644 --- a/Sources/Orders/Models/OrderModel.swift +++ b/Sources/Orders/Models/OrderModel.swift @@ -5,8 +5,8 @@ // Created by Francesco Paolo Severino on 30/06/24. // -import Foundation import FluentKit +import Foundation /// Represents the `Model` that stores Waller orders. /// @@ -17,7 +17,7 @@ public protocol OrderModel: Model where IDValue == UUID { /// The date and time when the customer created the order. var createdAt: Date? { get set } - + /// The date and time when the order was last updated. var updatedAt: Date? { get set } @@ -25,40 +25,44 @@ public protocol OrderModel: Model where IDValue == UUID { var authenticationToken: String { get set } } -internal extension OrderModel { +extension OrderModel { var _$id: ID { guard let mirror = Mirror(reflecting: self).descendant("_id"), - let id = mirror as? ID else { - fatalError("id property must be declared using @ID") + let id = mirror as? ID + else { + fatalError("id property must be declared using @ID") } - + return id } - + var _$orderTypeIdentifier: Field { guard let mirror = Mirror(reflecting: self).descendant("_orderTypeIdentifier"), - let orderTypeIdentifier = mirror as? Field else { - fatalError("orderTypeIdentifier property must be declared using @Field") + let orderTypeIdentifier = mirror as? Field + else { + fatalError("orderTypeIdentifier property must be declared using @Field") } - + return orderTypeIdentifier } - + var _$updatedAt: Timestamp { guard let mirror = Mirror(reflecting: self).descendant("_updatedAt"), - let updatedAt = mirror as? Timestamp else { - fatalError("updatedAt property must be declared using @Timestamp(on: .update)") + let updatedAt = mirror as? Timestamp + else { + fatalError("updatedAt property must be declared using @Timestamp(on: .update)") } - + return updatedAt } var _$authenticationToken: Field { guard let mirror = Mirror(reflecting: self).descendant("_authenticationToken"), - let authenticationToken = mirror as? Field else { - fatalError("authenticationToken property must be declared using @Field") + let authenticationToken = mirror as? Field + else { + fatalError("authenticationToken property must be declared using @Field") } - + return authenticationToken } } diff --git a/Sources/Orders/Models/OrdersRegistrationModel.swift b/Sources/Orders/Models/OrdersRegistrationModel.swift index a5c96b2..6550627 100644 --- a/Sources/Orders/Models/OrdersRegistrationModel.swift +++ b/Sources/Orders/Models/OrdersRegistrationModel.swift @@ -15,31 +15,35 @@ public protocol OrdersRegistrationModel: Model where IDValue == Int { /// The device for this registration. var device: DeviceType { get set } - + /// The order for this registration. var order: OrderType { get set } } -internal extension OrdersRegistrationModel { +extension OrdersRegistrationModel { var _$device: Parent { guard let mirror = Mirror(reflecting: self).descendant("_device"), - let device = mirror as? Parent else { - fatalError("device property must be declared using @Parent") + let device = mirror as? Parent + else { + fatalError("device property must be declared using @Parent") } - + return device } - + var _$order: Parent { guard let mirror = Mirror(reflecting: self).descendant("_order"), - let order = mirror as? Parent else { - fatalError("order property must be declared using @Parent") + let order = mirror as? Parent + else { + fatalError("order property must be declared using @Parent") } - + return order } - static func `for`(deviceLibraryIdentifier: String, orderTypeIdentifier: String, on db: any Database) -> QueryBuilder { + static func `for`( + deviceLibraryIdentifier: String, orderTypeIdentifier: String, on db: any Database + ) -> QueryBuilder { Self.query(on: db) .join(parent: \._$order) .join(parent: \._$device) diff --git a/Sources/Orders/OrdersDelegate.swift b/Sources/Orders/OrdersDelegate.swift index 9837a91..f0b974b 100644 --- a/Sources/Orders/OrdersDelegate.swift +++ b/Sources/Orders/OrdersDelegate.swift @@ -5,8 +5,8 @@ // Created by Francesco Paolo Severino on 01/07/24. // -import Foundation import FluentKit +import Foundation /// The delegate which is responsible for generating the order files. public protocol OrdersDelegate: AnyObject, Sendable { @@ -48,7 +48,9 @@ public protocol OrdersDelegate: AnyObject, Sendable { /// - Returns: The encoded order JSON data. /// /// > Tip: See the [`Order`](https://developer.apple.com/documentation/walletorders/order) object to understand the keys. - func encode(order: O, db: any Database, encoder: JSONEncoder) async throws -> Data + func encode( + order: O, db: any Database, encoder: JSONEncoder + ) async throws -> Data /// Should return a `URL` which points to the template data for the order. /// @@ -64,7 +66,7 @@ public protocol OrdersDelegate: AnyObject, Sendable { /// /// > Important: Be sure to use the `URL(fileURLWithPath:)` constructor. var sslBinary: URL { get } - + /// The name of Apple's WWDR.pem certificate as contained in `sslSigningFiles` path. /// /// Defaults to `WWDR.pem` @@ -84,28 +86,28 @@ public protocol OrdersDelegate: AnyObject, Sendable { var pemPrivateKeyPassword: String? { get } } -public extension OrdersDelegate { - var wwdrCertificate: String { - get { return "WWDR.pem" } +extension OrdersDelegate { + public var wwdrCertificate: String { + return "WWDR.pem" } - var pemCertificate: String { - get { return "ordercertificate.pem" } + public var pemCertificate: String { + return "ordercertificate.pem" } - var pemPrivateKey: String { - get { return "orderkey.pem" } + public var pemPrivateKey: String { + return "orderkey.pem" } - var pemPrivateKeyPassword: String? { - get { return nil } + public var pemPrivateKeyPassword: String? { + return nil } - var sslBinary: URL { - get { return URL(fileURLWithPath: "/usr/bin/openssl") } + public var sslBinary: URL { + return URL(fileURLWithPath: "/usr/bin/openssl") } - - func generateSignatureFile(in root: URL) -> Bool { + + public func generateSignatureFile(in root: URL) -> Bool { return false } } diff --git a/Sources/Orders/OrdersError.swift b/Sources/Orders/OrdersError.swift index 2e32c0f..3c827b9 100644 --- a/Sources/Orders/OrdersError.swift +++ b/Sources/Orders/OrdersError.swift @@ -15,13 +15,13 @@ public struct OrdersError: Error, Sendable { case pemPrivateKeyMissing case opensslBinaryMissing } - + let base: Base - + private init(_ base: Base) { self.base = base } - + /// The template path is not a directory. public static let templateNotDirectory = Self(.templateNotDirectory) /// The `pemCertificate` file is missing. @@ -36,15 +36,15 @@ public struct OrdersError: Error, Sendable { base.rawValue } } - + private struct Backing: Sendable { fileprivate let errorType: ErrorType - + init(errorType: ErrorType) { self.errorType = errorType } } - + private var backing: Backing /// The type of this error. @@ -53,7 +53,7 @@ public struct OrdersError: Error, Sendable { private init(errorType: ErrorType) { self.backing = .init(errorType: errorType) } - + /// The template path is not a directory. public static let templateNotDirectory = Self(errorType: .templateNotDirectory) diff --git a/Sources/Orders/OrdersService.swift b/Sources/Orders/OrdersService.swift index c91e5e8..3cc897f 100644 --- a/Sources/Orders/OrdersService.swift +++ b/Sources/Orders/OrdersService.swift @@ -5,13 +5,14 @@ // Created by Francesco Paolo Severino on 01/07/24. // -import Vapor import FluentKit +import Vapor /// The main class that handles Wallet orders. public final class OrdersService: Sendable { - private let service: OrdersServiceCustom - + private let service: + OrdersServiceCustom + /// Initializes the service and registers all the routes required for Apple Wallet to work. /// /// - Parameters: @@ -19,8 +20,13 @@ public final class OrdersService: Sendable { /// - delegate: The ``OrdersDelegate`` to use for order generation. /// - pushRoutesMiddleware: The `Middleware` to use for push notification routes. If `nil`, push routes will not be registered. /// - logger: The `Logger` to use. - public init(app: Application, delegate: any OrdersDelegate, pushRoutesMiddleware: (any Middleware)? = nil, logger: Logger? = nil) throws { - service = try .init(app: app, delegate: delegate, pushRoutesMiddleware: pushRoutesMiddleware, logger: logger) + public init( + app: Application, delegate: any OrdersDelegate, + pushRoutesMiddleware: (any Middleware)? = nil, logger: Logger? = nil + ) throws { + service = try .init( + app: app, delegate: delegate, pushRoutesMiddleware: pushRoutesMiddleware, logger: logger + ) } /// Generates the order content bundle for a given order. @@ -49,12 +55,14 @@ public final class OrdersService: Sendable { /// - id: The `UUID` of the order to send the notifications for. /// - orderTypeIdentifier: The type identifier of the order. /// - db: The `Database` to use. - public func sendPushNotificationsForOrder(id: UUID, of orderTypeIdentifier: String, on db: any Database) async throws { + public func sendPushNotificationsForOrder( + id: UUID, of orderTypeIdentifier: String, on db: any Database + ) async throws { try await service.sendPushNotificationsForOrder(id: id, of: orderTypeIdentifier, on: db) } - + /// Sends push notifications for a given order. - /// + /// /// - Parameters: /// - order: The order to send the notifications for. /// - db: The `Database` to use. diff --git a/Sources/Orders/OrdersServiceCustom.swift b/Sources/Orders/OrdersServiceCustom.swift index b8c7a0c..a650869 100644 --- a/Sources/Orders/OrdersServiceCustom.swift +++ b/Sources/Orders/OrdersServiceCustom.swift @@ -5,15 +5,15 @@ // Created by Francesco Paolo Severino on 01/07/24. // -import Vapor import APNS -import VaporAPNS import APNSCore import Fluent import NIOSSL import PassKit -import Zip +import Vapor +import VaporAPNS @_spi(CMS) import X509 +import Zip /// Class to handle ``OrdersService``. /// @@ -22,11 +22,12 @@ import Zip /// - Device Type /// - Registration Type /// - Error Log Type -public final class OrdersServiceCustom: Sendable where O == R.OrderType, D == R.DeviceType { +public final class OrdersServiceCustom: Sendable +where O == R.OrderType, D == R.DeviceType { private unowned let app: Application private unowned let delegate: any OrdersDelegate private let logger: Logger? - + /// Initializes the service and registers all the routes required for Apple Wallet to work. /// /// - Parameters: @@ -43,12 +44,16 @@ public final class OrdersServiceCustom()) - v1auth.post("devices", ":deviceIdentifier", "registrations", ":orderTypeIdentifier", ":orderIdentifier", use: { try await self.registerDevice(req: $0) }) - v1auth.get("orders", ":orderTypeIdentifier", ":orderIdentifier", use: { try await self.latestVersionOfOrder(req: $0) }) - v1auth.delete("devices", ":deviceIdentifier", "registrations", ":orderTypeIdentifier", ":orderIdentifier", use: { try await self.unregisterDevice(req: $0) }) - + v1auth.post( + "devices", ":deviceIdentifier", "registrations", ":orderTypeIdentifier", + ":orderIdentifier", use: { try await self.registerDevice(req: $0) }) + v1auth.get( + "orders", ":orderTypeIdentifier", ":orderIdentifier", + use: { try await self.latestVersionOfOrder(req: $0) }) + v1auth.delete( + "devices", ":deviceIdentifier", "registrations", ":orderTypeIdentifier", + ":orderIdentifier", use: { try await self.unregisterDevice(req: $0) }) + if let pushRoutesMiddleware { let pushAuth = v1.grouped(pushRoutesMiddleware) - pushAuth.post("push", ":orderTypeIdentifier", ":orderIdentifier", use: { try await self.pushUpdatesForOrder(req: $0) }) - pushAuth.get("push", ":orderTypeIdentifier", ":orderIdentifier", use: { try await self.tokensForOrderUpdate(req: $0) }) + pushAuth.post( + "push", ":orderTypeIdentifier", ":orderIdentifier", + use: { try await self.pushUpdatesForOrder(req: $0) }) + pushAuth.get( + "push", ":orderTypeIdentifier", ":orderIdentifier", + use: { try await self.tokensForOrderUpdate(req: $0) }) } } } @@ -103,20 +124,22 @@ public final class OrdersServiceCustom Response { logger?.debug("Called latestVersionOfOrder") - + var ifModifiedSince: TimeInterval = 0 if let header = req.headers[.ifModifiedSince].first, let ims = TimeInterval(header) { ifModifiedSince = ims } guard let orderTypeIdentifier = req.parameters.get("orderTypeIdentifier"), - let id = req.parameters.get("orderIdentifier", as: UUID.self) else { - throw Abort(.badRequest) + let id = req.parameters.get("orderIdentifier", as: UUID.self) + else { + throw Abort(.badRequest) } - guard let order = try await O.query(on: req.db) - .filter(\._$id == id) - .filter(\._$orderTypeIdentifier == orderTypeIdentifier) - .first() + guard + let order = try await O.query(on: req.db) + .filter(\._$id == id) + .filter(\._$orderTypeIdentifier == orderTypeIdentifier) + .first() else { throw Abort(.notFound) } @@ -151,10 +174,11 @@ extension OrdersServiceCustom { } let orderTypeIdentifier = req.parameters.get("orderTypeIdentifier")! let deviceIdentifier = req.parameters.get("deviceIdentifier")! - guard let order = try await O.query(on: req.db) - .filter(\._$id == orderIdentifier) - .filter(\._$orderTypeIdentifier == orderTypeIdentifier) - .first() + guard + let order = try await O.query(on: req.db) + .filter(\._$id == orderIdentifier) + .filter(\._$orderTypeIdentifier == orderTypeIdentifier) + .first() else { throw Abort(.notFound) } @@ -172,10 +196,15 @@ extension OrdersServiceCustom { } } - private static func createRegistration(device: D, order: O, db: any Database) async throws -> HTTPStatus { - let r = try await R.for(deviceLibraryIdentifier: device.deviceLibraryIdentifier, orderTypeIdentifier: order.orderTypeIdentifier, on: db) - .filter(O.self, \._$id == order.requireID()) - .first() + private static func createRegistration( + device: D, order: O, db: any Database + ) async throws -> HTTPStatus { + let r = try await R.for( + deviceLibraryIdentifier: device.deviceLibraryIdentifier, + orderTypeIdentifier: order.orderTypeIdentifier, on: db + ) + .filter(O.self, \._$id == order.requireID()) + .first() // If the registration already exists, docs say to return 200 OK if r != nil { return .ok } @@ -192,7 +221,9 @@ extension OrdersServiceCustom { let orderTypeIdentifier = req.parameters.get("orderTypeIdentifier")! let deviceIdentifier = req.parameters.get("deviceIdentifier")! - var query = R.for(deviceLibraryIdentifier: deviceIdentifier, orderTypeIdentifier: orderTypeIdentifier, on: req.db) + var query = R.for( + deviceLibraryIdentifier: deviceIdentifier, orderTypeIdentifier: orderTypeIdentifier, + on: req.db) if let since: TimeInterval = req.query["ordersModifiedSince"] { let when = Date(timeIntervalSince1970: since) query = query.filter(O.self, \._$updatedAt > when) @@ -205,8 +236,8 @@ extension OrdersServiceCustom { var orderIdentifiers: [String] = [] var maxDate = Date.distantPast - try registrations.forEach { r in - let order = r.order + for registration in registrations { + let order = registration.order try orderIdentifiers.append(order.requireID().uuidString) if let updatedAt = order.updatedAt, updatedAt > maxDate { maxDate = updatedAt @@ -243,7 +274,11 @@ extension OrdersServiceCustom { let orderTypeIdentifier = req.parameters.get("orderTypeIdentifier")! let deviceIdentifier = req.parameters.get("deviceIdentifier")! - guard let r = try await R.for(deviceLibraryIdentifier: deviceIdentifier, orderTypeIdentifier: orderTypeIdentifier, on: req.db) + guard + let r = try await R.for( + deviceLibraryIdentifier: deviceIdentifier, orderTypeIdentifier: orderTypeIdentifier, + on: req.db + ) .filter(O.self, \._$id == orderIdentifier) .first() else { @@ -273,7 +308,7 @@ extension OrdersServiceCustom { throw Abort(.badRequest) } let orderTypeIdentifier = req.parameters.get("orderTypeIdentifier")! - + return try await Self.registrationsForOrder(id: id, of: orderTypeIdentifier, on: req.db) .map { $0.device.pushToken } } @@ -287,8 +322,11 @@ extension OrdersServiceCustom { /// - id: The `UUID` of the order to send the notifications for. /// - orderTypeIdentifier: The type identifier of the order. /// - db: The `Database` to use. - public func sendPushNotificationsForOrder(id: UUID, of orderTypeIdentifier: String, on db: any Database) async throws { - let registrations = try await Self.registrationsForOrder(id: id, of: orderTypeIdentifier, on: db) + public func sendPushNotificationsForOrder( + id: UUID, of orderTypeIdentifier: String, on db: any Database + ) async throws { + let registrations = try await Self.registrationsForOrder( + id: id, of: orderTypeIdentifier, on: db) for reg in registrations { let backgroundNotification = APNSBackgroundNotification( expiration: .immediately, @@ -308,15 +346,18 @@ extension OrdersServiceCustom { } /// Sends push notifications for a given order. - /// + /// /// - Parameters: /// - order: The order to send the notifications for. /// - db: The `Database` to use. public func sendPushNotifications(for order: O, on db: any Database) async throws { - try await sendPushNotificationsForOrder(id: order.requireID(), of: order.orderTypeIdentifier, on: db) + try await sendPushNotificationsForOrder( + id: order.requireID(), of: order.orderTypeIdentifier, on: db) } - static func registrationsForOrder(id: UUID, of orderTypeIdentifier: String, on db: any Database) async throws -> [R] { + static func registrationsForOrder( + id: UUID, of orderTypeIdentifier: String, on db: any Database + ) async throws -> [R] { // This could be done by enforcing the caller to have a Siblings property wrapper, // but there's not really any value to forcing that on them when we can just do the query ourselves like this. try await R.query(on: db) @@ -332,12 +373,14 @@ extension OrdersServiceCustom { // MARK: - order file generation extension OrdersServiceCustom { - private static func generateManifestFile(using encoder: JSONEncoder, in root: URL) throws -> Data { + private static func generateManifestFile( + using encoder: JSONEncoder, in root: URL + ) throws -> Data { var manifest: [String: String] = [:] - let paths = try FileManager.default.subpathsOfDirectory(atPath: root.unixPath()) - try paths.forEach { relativePath in + let paths = try FileManager.default.subpathsOfDirectory(atPath: root.path) + for relativePath in paths { let file = URL(fileURLWithPath: relativePath, relativeTo: root) - guard !file.hasDirectoryPath else { return } + guard !file.hasDirectoryPath else { continue } let data = try Data(contentsOf: file) let hash = SHA256.hash(data: data) manifest[relativePath] = hash.map { "0\(String($0, radix: 16))".suffix(2) }.joined() @@ -354,7 +397,7 @@ extension OrdersServiceCustom { // Swift Crypto doesn't support encrypted PEM private keys, so we have to use OpenSSL for that. if let password = delegate.pemPrivateKeyPassword { let sslBinary = delegate.sslBinary - guard FileManager.default.fileExists(atPath: sslBinary.unixPath()) else { + guard FileManager.default.fileExists(atPath: sslBinary.path) else { throw OrdersError.opensslBinaryMissing } @@ -366,16 +409,16 @@ extension OrdersServiceCustom { "-certfile", delegate.wwdrCertificate, "-signer", delegate.pemCertificate, "-inkey", delegate.pemPrivateKey, - "-in", root.appendingPathComponent("manifest.json").unixPath(), - "-out", root.appendingPathComponent("signature").unixPath(), + "-in", root.appendingPathComponent("manifest.json").path, + "-out", root.appendingPathComponent("signature").path, "-outform", "DER", - "-passin", "pass:\(password)" + "-passin", "pass:\(password)", ] try proc.run() proc.waitUntilExit() return } - + let signature = try CMS.sign( manifest, signatureAlgorithm: .sha256WithRSAEncryption, @@ -412,7 +455,9 @@ extension OrdersServiceCustom { /// - Returns: The generated order content as `Data`. public func generateOrderContent(for order: O, on db: any Database) async throws -> Data { let templateDirectory = try await delegate.template(for: order, db: db) - guard (try? templateDirectory.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) ?? false else { + guard + (try? templateDirectory.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) ?? false + else { throw OrdersError.templateNotDirectory } @@ -424,13 +469,14 @@ extension OrdersServiceCustom { let encoder = JSONEncoder() try await self.delegate.encode(order: order, db: db, encoder: encoder) .write(to: root.appendingPathComponent("order.json")) - + try self.generateSignatureFile( for: Self.generateManifestFile(using: encoder, in: root), in: root ) - var files = try FileManager.default.contentsOfDirectory(at: templateDirectory, includingPropertiesForKeys: nil) + var files = try FileManager.default.contentsOfDirectory( + at: templateDirectory, includingPropertiesForKeys: nil) files.append(URL(fileURLWithPath: "order.json", relativeTo: root)) files.append(URL(fileURLWithPath: "manifest.json", relativeTo: root)) files.append(URL(fileURLWithPath: "signature", relativeTo: root)) diff --git a/Sources/PassKit/Models/DeviceModel.swift b/Sources/PassKit/Models/DeviceModel.swift index 206a980..19ad199 100644 --- a/Sources/PassKit/Models/DeviceModel.swift +++ b/Sources/PassKit/Models/DeviceModel.swift @@ -32,10 +32,10 @@ import FluentKit public protocol DeviceModel: Model where IDValue == Int { /// The push token used for sending updates to the device. var pushToken: String { get set } - + /// The identifier PassKit provides for the device. var deviceLibraryIdentifier: String { get set } - + /// The designated initializer. /// - Parameters: /// - deviceLibraryIdentifier: The device identifier as provided during registration. @@ -43,23 +43,24 @@ public protocol DeviceModel: Model where IDValue == Int { init(deviceLibraryIdentifier: String, pushToken: String) } -package extension DeviceModel { - var _$pushToken: Field { +extension DeviceModel { + package var _$pushToken: Field { guard let mirror = Mirror(reflecting: self).descendant("_pushToken"), - let pushToken = mirror as? Field else { - fatalError("pushToken property must be declared using @Field") + let pushToken = mirror as? Field + else { + fatalError("pushToken property must be declared using @Field") } - + return pushToken } - - var _$deviceLibraryIdentifier: Field { + + package var _$deviceLibraryIdentifier: Field { guard let mirror = Mirror(reflecting: self).descendant("_deviceLibraryIdentifier"), - let deviceLibraryIdentifier = mirror as? Field else { - fatalError("deviceLibraryIdentifier property must be declared using @Field") + let deviceLibraryIdentifier = mirror as? Field + else { + fatalError("deviceLibraryIdentifier property must be declared using @Field") } - + return deviceLibraryIdentifier } } - diff --git a/Sources/PassKit/Models/ErrorLogModel.swift b/Sources/PassKit/Models/ErrorLogModel.swift index 27b21b7..f49de87 100644 --- a/Sources/PassKit/Models/ErrorLogModel.swift +++ b/Sources/PassKit/Models/ErrorLogModel.swift @@ -32,19 +32,20 @@ import FluentKit public protocol ErrorLogModel: Model { /// The error message provided by PassKit. var message: String { get set } - + /// The designated initializer. /// - Parameter message: The error message. init(message: String) } -package extension ErrorLogModel { - var _$message: Field { +extension ErrorLogModel { + package var _$message: Field { guard let mirror = Mirror(reflecting: self).descendant("_message"), - let message = mirror as? Field else { - fatalError("id property must be declared using @ID") + let message = mirror as? Field + else { + fatalError("id property must be declared using @ID") } - + return message } } diff --git a/Sources/PassKit/URL+Extension.swift b/Sources/PassKit/URL+Extension.swift deleted file mode 100644 index 5114164..0000000 --- a/Sources/PassKit/URL+Extension.swift +++ /dev/null @@ -1,35 +0,0 @@ -/// Copyright 2020 Gargoyle Software, LLC -/// -/// Permission is hereby granted, free of charge, to any person obtaining a copy -/// of this software and associated documentation files (the "Software"), to deal -/// in the Software without restriction, including without limitation the rights -/// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -/// copies of the Software, and to permit persons to whom the Software is -/// furnished to do so, subject to the following conditions: -/// -/// The above copyright notice and this permission notice shall be included in -/// all copies or substantial portions of the Software. -/// -/// Notwithstanding the foregoing, you may not use, copy, modify, merge, publish, -/// distribute, sublicense, create a derivative work, and/or sell copies of the -/// Software in any work that is designed, intended, or marketed for pedagogical or -/// instructional purposes related to programming, coding, application development, -/// or information technology. Permission for such use, copying, modification, -/// merger, publication, distribution, sublicensing, creation of derivative works, -/// or sale is expressly withheld. -/// -/// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -/// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -/// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -/// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -/// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -/// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -/// THE SOFTWARE. - -import Foundation - -extension URL { - package func unixPath() -> String { - absoluteString.replacingOccurrences(of: "file://", with: "") - } -} diff --git a/Sources/Passes/DTOs/PassJSON.swift b/Sources/Passes/DTOs/PassJSON.swift index df11686..c93ecb7 100644 --- a/Sources/Passes/DTOs/PassJSON.swift +++ b/Sources/Passes/DTOs/PassJSON.swift @@ -13,25 +13,25 @@ public struct PassJSON { public protocol Properties: Encodable { /// A short description that iOS accessibility technologies use for a pass. var description: String { get } - + /// The version of the file format. /// /// The value must be `1`. var formatVersion: FormatVersion { get } - + /// The name of the organization. var organizationName: String { get } - + /// The pass type identifier that’s registered with Apple. /// /// The value must be the same as the distribution certificate used to sign the pass. var passTypeIdentifier: String { get } - + /// An alphanumeric serial number. /// /// The combination of the serial number and pass type identifier must be unique for each pass. var serialNumber: String { get } - + /// The Team ID for the Apple Developer Program account that registered the pass type identifier. var teamIdentifier: String { get } } @@ -44,7 +44,7 @@ extension PassJSON { public protocol PassFieldContent: Encodable { /// A unique key that identifies a field in the pass; for example, `departure-gate`. var key: String { get } - + /// The value to use for the field; for example, 42. /// /// A date or time value must include a time zone. @@ -76,10 +76,10 @@ extension PassJSON { /// /// The barcode format `PKBarcodeFormatCode128` isn’t supported for watchOS. var format: BarcodeFormat { get } - + /// The message or payload to display as a barcode. var message: String { get } - + /// The IANA character set name of the text encoding to use to convert message /// from a string representation to a data representation that the system renders as a barcode, such as `iso-8859-1`. var messageEncoding: String { get } @@ -93,8 +93,8 @@ extension PassJSON { public protocol Locations: Encodable { /// The latitude, in degrees, of the location. var latitude: Double { get } - - /// (Required) + + /// (Required) var longitude: Double { get } } } @@ -123,7 +123,7 @@ extension PassJSON { /// The value must be `1`. case v1 = 1 } - + /// The type of transit for a boarding pass. public enum TransitType: String, Encodable { case air = "PKTransitTypeAir" @@ -132,7 +132,7 @@ extension PassJSON { case generic = "PKTransitTypeGeneric" case train = "PKTransitTypeTrain" } - + /// The format of the barcode. public enum BarcodeFormat: String, Encodable { case pdf417 = "PKBarcodeFormatPDF417" diff --git a/Sources/Passes/DTOs/PassesForDeviceDTO.swift b/Sources/Passes/DTOs/PassesForDeviceDTO.swift index 8a17e3a..9ba58e2 100644 --- a/Sources/Passes/DTOs/PassesForDeviceDTO.swift +++ b/Sources/Passes/DTOs/PassesForDeviceDTO.swift @@ -31,7 +31,7 @@ import Vapor struct PassesForDeviceDTO: Content { let lastUpdated: String let serialNumbers: [String] - + init(with serialNumbers: [String], maxDate: Date) { lastUpdated = String(maxDate.timeIntervalSince1970) self.serialNumbers = serialNumbers diff --git a/Sources/Passes/DTOs/PersonalizationDictionaryDTO.swift b/Sources/Passes/DTOs/PersonalizationDictionaryDTO.swift index 51465d4..7ce0988 100644 --- a/Sources/Passes/DTOs/PersonalizationDictionaryDTO.swift +++ b/Sources/Passes/DTOs/PersonalizationDictionaryDTO.swift @@ -16,8 +16,18 @@ struct PersonalizationDictionaryDTO: Content { let familyName: String? let fullName: String? let givenName: String? - let ISOCountryCode: String? + let isoCountryCode: String? let phoneNumber: String? let postalCode: String? + + enum CodingKeys: String, CodingKey { + case emailAddress + case familyName + case fullName + case givenName + case isoCountryCode = "ISOCountryCode" + case phoneNumber + case postalCode + } } -} \ No newline at end of file +} diff --git a/Sources/Passes/DTOs/PersonalizationJSON.swift b/Sources/Passes/DTOs/PersonalizationJSON.swift index ac6a301..2738be4 100644 --- a/Sources/Passes/DTOs/PersonalizationJSON.swift +++ b/Sources/Passes/DTOs/PersonalizationJSON.swift @@ -49,4 +49,4 @@ extension PersonalizationJSON { /// `phoneNumber` is submitted in the personalize request. case phoneNumber = "PKPassPersonalizationFieldPhoneNumber" } -} \ No newline at end of file +} diff --git a/Sources/Passes/Middleware/ApplePassMiddleware.swift b/Sources/Passes/Middleware/ApplePassMiddleware.swift index ee498e5..d040e5c 100644 --- a/Sources/Passes/Middleware/ApplePassMiddleware.swift +++ b/Sources/Passes/Middleware/ApplePassMiddleware.swift @@ -26,19 +26,22 @@ /// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN /// THE SOFTWARE. -import Vapor import FluentKit +import Vapor struct ApplePassMiddleware: AsyncMiddleware { - func respond(to request: Request, chainingTo next: any AsyncResponder) async throws -> Response { - guard let auth = request.headers["Authorization"].first?.replacingOccurrences(of: "ApplePass ", with: ""), - let _ = try await P.query(on: request.db) + func respond( + to request: Request, chainingTo next: any AsyncResponder + ) async throws -> Response { + guard + let auth = request.headers["Authorization"].first?.replacingOccurrences( + of: "ApplePass ", with: ""), + (try await P.query(on: request.db) .filter(\._$authenticationToken == auth) - .first() + .first()) != nil else { throw Abort(.unauthorized) } return try await next.respond(to: request) } } - diff --git a/Sources/Passes/Models/Concrete Models/Pass.swift b/Sources/Passes/Models/Concrete Models/Pass.swift index 8382d65..87a3212 100644 --- a/Sources/Passes/Models/Concrete Models/Pass.swift +++ b/Sources/Passes/Models/Concrete Models/Pass.swift @@ -5,8 +5,8 @@ // Created by Francesco Paolo Severino on 29/06/24. // -import Foundation import FluentKit +import Foundation /// The `Model` that stores PassKit passes. /// @@ -39,8 +39,8 @@ final public class Pass: PassModel, @unchecked Sendable { /// The user personalization info. @OptionalParent(key: Pass.FieldKeys.userPersonalizationID) public var userPersonalization: UserPersonalizationType? - - public required init() { } + + public required init() {} public required init(passTypeIdentifier: String, authenticationToken: String) { self.passTypeIdentifier = passTypeIdentifier @@ -55,7 +55,10 @@ extension Pass: AsyncMigration { .field(Pass.FieldKeys.updatedAt, .datetime, .required) .field(Pass.FieldKeys.passTypeIdentifier, .string, .required) .field(Pass.FieldKeys.authenticationToken, .string, .required) - .field(Pass.FieldKeys.userPersonalizationID, .int, .references(UserPersonalizationType.schema, .id)) + .field( + Pass.FieldKeys.userPersonalizationID, .int, + .references(UserPersonalizationType.schema, .id) + ) .unique(on: Pass.FieldKeys.userPersonalizationID) .create() } diff --git a/Sources/Passes/Models/Concrete Models/PassesDevice.swift b/Sources/Passes/Models/Concrete Models/PassesDevice.swift index 6bda9db..c3cd02b 100644 --- a/Sources/Passes/Models/Concrete Models/PassesDevice.swift +++ b/Sources/Passes/Models/Concrete Models/PassesDevice.swift @@ -38,7 +38,9 @@ extension PassesDevice: AsyncMigration { .field(.id, .int, .identifier(auto: true)) .field(PassesDevice.FieldKeys.pushToken, .string, .required) .field(PassesDevice.FieldKeys.deviceLibraryIdentifier, .string, .required) - .unique(on: PassesDevice.FieldKeys.pushToken, PassesDevice.FieldKeys.deviceLibraryIdentifier) + .unique( + on: PassesDevice.FieldKeys.pushToken, PassesDevice.FieldKeys.deviceLibraryIdentifier + ) .create() } diff --git a/Sources/Passes/Models/Concrete Models/PassesErrorLog.swift b/Sources/Passes/Models/Concrete Models/PassesErrorLog.swift index e7d6107..92b8c5f 100644 --- a/Sources/Passes/Models/Concrete Models/PassesErrorLog.swift +++ b/Sources/Passes/Models/Concrete Models/PassesErrorLog.swift @@ -5,10 +5,11 @@ // Created by Francesco Paolo Severino on 29/06/24. // -import struct Foundation.Date import FluentKit import PassKit +import struct Foundation.Date + /// The `Model` that stores PassKit passes error logs. final public class PassesErrorLog: ErrorLogModel, @unchecked Sendable { /// The schema name of the error log model. diff --git a/Sources/Passes/Models/Concrete Models/PassesRegistration.swift b/Sources/Passes/Models/Concrete Models/PassesRegistration.swift index d071184..4ef396f 100644 --- a/Sources/Passes/Models/Concrete Models/PassesRegistration.swift +++ b/Sources/Passes/Models/Concrete Models/PassesRegistration.swift @@ -33,8 +33,14 @@ extension PassesRegistration: AsyncMigration { public func prepare(on database: any Database) async throws { try await database.schema(Self.schema) .field(.id, .int, .identifier(auto: true)) - .field(PassesRegistration.FieldKeys.deviceID, .int, .required, .references(DeviceType.schema, .id, onDelete: .cascade)) - .field(PassesRegistration.FieldKeys.passID, .uuid, .required, .references(PassType.schema, .id, onDelete: .cascade)) + .field( + PassesRegistration.FieldKeys.deviceID, .int, .required, + .references(DeviceType.schema, .id, onDelete: .cascade) + ) + .field( + PassesRegistration.FieldKeys.passID, .uuid, .required, + .references(PassType.schema, .id, onDelete: .cascade) + ) .create() } diff --git a/Sources/Passes/Models/Concrete Models/UserPersonalization.swift b/Sources/Passes/Models/Concrete Models/UserPersonalization.swift index 153ea4f..4439fdb 100644 --- a/Sources/Passes/Models/Concrete Models/UserPersonalization.swift +++ b/Sources/Passes/Models/Concrete Models/UserPersonalization.swift @@ -43,8 +43,8 @@ final public class UserPersonalization: UserPersonalizationModel, @unchecked Sen /// The user’s ISO country code. /// /// This key is only included when the system can deduce the country code. - @OptionalField(key: UserPersonalization.FieldKeys.ISOCountryCode) - public var ISOCountryCode: String? + @OptionalField(key: UserPersonalization.FieldKeys.isoCountryCode) + public var isoCountryCode: String? /// The phone number, as entered by the user. @OptionalField(key: UserPersonalization.FieldKeys.phoneNumber) @@ -62,7 +62,7 @@ extension UserPersonalization: AsyncMigration { .field(UserPersonalization.FieldKeys.familyName, .string) .field(UserPersonalization.FieldKeys.emailAddress, .string) .field(UserPersonalization.FieldKeys.postalCode, .string) - .field(UserPersonalization.FieldKeys.ISOCountryCode, .string) + .field(UserPersonalization.FieldKeys.isoCountryCode, .string) .field(UserPersonalization.FieldKeys.phoneNumber, .string) .create() } @@ -80,7 +80,7 @@ extension UserPersonalization { static let familyName = FieldKey(stringLiteral: "family_name") static let emailAddress = FieldKey(stringLiteral: "email_address") static let postalCode = FieldKey(stringLiteral: "postal_code") - static let ISOCountryCode = FieldKey(stringLiteral: "iso_country_code") + static let isoCountryCode = FieldKey(stringLiteral: "iso_country_code") static let phoneNumber = FieldKey(stringLiteral: "phone_number") } -} \ No newline at end of file +} diff --git a/Sources/Passes/Models/PassDataModel.swift b/Sources/Passes/Models/PassDataModel.swift index abe2425..1c5b96b 100644 --- a/Sources/Passes/Models/PassDataModel.swift +++ b/Sources/Passes/Models/PassDataModel.swift @@ -36,11 +36,12 @@ public protocol PassDataModel: Model { var pass: PassType { get set } } -internal extension PassDataModel { +extension PassDataModel { var _$pass: Parent { guard let mirror = Mirror(reflecting: self).descendant("_pass"), - let pass = mirror as? Parent else { - fatalError("pass property must be declared using @Parent") + let pass = mirror as? Parent + else { + fatalError("pass property must be declared using @Parent") } return pass diff --git a/Sources/Passes/Models/PassModel.swift b/Sources/Passes/Models/PassModel.swift index 4af4f78..ca6dd12 100644 --- a/Sources/Passes/Models/PassModel.swift +++ b/Sources/Passes/Models/PassModel.swift @@ -26,8 +26,8 @@ /// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN /// THE SOFTWARE. -import Foundation import FluentKit +import Foundation /// Represents the `Model` that stores PassKit passes. /// @@ -37,7 +37,7 @@ public protocol PassModel: Model where IDValue == UUID { /// The pass type identifier that’s registered with Apple. var passTypeIdentifier: String { get set } - + /// The last time the pass was modified. var updatedAt: Date? { get set } @@ -54,49 +54,54 @@ public protocol PassModel: Model where IDValue == UUID { init(passTypeIdentifier: String, authenticationToken: String) } -internal extension PassModel { +extension PassModel { var _$id: ID { guard let mirror = Mirror(reflecting: self).descendant("_id"), - let id = mirror as? ID else { - fatalError("id property must be declared using @ID") + let id = mirror as? ID + else { + fatalError("id property must be declared using @ID") } - + return id } - + var _$passTypeIdentifier: Field { guard let mirror = Mirror(reflecting: self).descendant("_passTypeIdentifier"), - let passTypeIdentifier = mirror as? Field else { - fatalError("passTypeIdentifier property must be declared using @Field") + let passTypeIdentifier = mirror as? Field + else { + fatalError("passTypeIdentifier property must be declared using @Field") } - + return passTypeIdentifier } - + var _$updatedAt: Timestamp { guard let mirror = Mirror(reflecting: self).descendant("_updatedAt"), - let updatedAt = mirror as? Timestamp else { - fatalError("updatedAt property must be declared using @Timestamp(on: .update)") + let updatedAt = mirror as? Timestamp + else { + fatalError("updatedAt property must be declared using @Timestamp(on: .update)") } - + return updatedAt } var _$authenticationToken: Field { guard let mirror = Mirror(reflecting: self).descendant("_authenticationToken"), - let authenticationToken = mirror as? Field else { - fatalError("authenticationToken property must be declared using @Field") + let authenticationToken = mirror as? Field + else { + fatalError("authenticationToken property must be declared using @Field") } - + return authenticationToken } var _$userPersonalization: OptionalParent { guard let mirror = Mirror(reflecting: self).descendant("_userPersonalization"), - let userPersonalization = mirror as? OptionalParent else { - fatalError("userPersonalization property must be declared using @OptionalParent") + let userPersonalization = mirror as? OptionalParent + else { + fatalError("userPersonalization property must be declared using @OptionalParent") } - + return userPersonalization } } diff --git a/Sources/Passes/Models/PassesRegistrationModel.swift b/Sources/Passes/Models/PassesRegistrationModel.swift index afe1699..4eb7e39 100644 --- a/Sources/Passes/Models/PassesRegistrationModel.swift +++ b/Sources/Passes/Models/PassesRegistrationModel.swift @@ -36,31 +36,35 @@ public protocol PassesRegistrationModel: Model where IDValue == Int { /// The device for this registration. var device: DeviceType { get set } - + /// The pass for this registration. var pass: PassType { get set } } -internal extension PassesRegistrationModel { +extension PassesRegistrationModel { var _$device: Parent { guard let mirror = Mirror(reflecting: self).descendant("_device"), - let device = mirror as? Parent else { - fatalError("device property must be declared using @Parent") + let device = mirror as? Parent + else { + fatalError("device property must be declared using @Parent") } - + return device } - + var _$pass: Parent { guard let mirror = Mirror(reflecting: self).descendant("_pass"), - let pass = mirror as? Parent else { - fatalError("pass property must be declared using @Parent") + let pass = mirror as? Parent + else { + fatalError("pass property must be declared using @Parent") } - + return pass } - - static func `for`(deviceLibraryIdentifier: String, passTypeIdentifier: String, on db: any Database) -> QueryBuilder { + + static func `for`( + deviceLibraryIdentifier: String, passTypeIdentifier: String, on db: any Database + ) -> QueryBuilder { Self.query(on: db) .join(parent: \._$pass) .join(parent: \._$device) @@ -70,4 +74,3 @@ internal extension PassesRegistrationModel { .filter(DeviceType.self, \._$deviceLibraryIdentifier == deviceLibraryIdentifier) } } - diff --git a/Sources/Passes/Models/UserPersonalizationModel.swift b/Sources/Passes/Models/UserPersonalizationModel.swift index 83dc79d..ccaa357 100644 --- a/Sources/Passes/Models/UserPersonalizationModel.swift +++ b/Sources/Passes/Models/UserPersonalizationModel.swift @@ -11,13 +11,13 @@ import FluentKit public protocol UserPersonalizationModel: Model where IDValue == Int { /// The user’s full name, as entered by the user. var fullName: String? { get set } - + /// The user’s given name, parsed from the full name. /// /// This is the name bestowed upon an individual to differentiate them from other members of a group that share a family name (for example, “John”). /// In some locales, this is also known as a first name or forename. var givenName: String? { get set } - + /// The user’s family name, parsed from the full name. /// /// This is the name bestowed upon an individual to denote membership in a group or family (for example, “Appleseed”). @@ -25,89 +25,97 @@ public protocol UserPersonalizationModel: Model where IDValue == Int { /// The email address, as entered by the user. var emailAddress: String? { get set } - + /// The postal code, as entered by the user. var postalCode: String? { get set } - + /// The user’s ISO country code. /// /// This key is only included when the system can deduce the country code. - var ISOCountryCode: String? { get set } - + var isoCountryCode: String? { get set } + /// The phone number, as entered by the user. var phoneNumber: String? { get set } } -internal extension UserPersonalizationModel { +extension UserPersonalizationModel { var _$id: ID { guard let mirror = Mirror(reflecting: self).descendant("_id"), - let id = mirror as? ID else { - fatalError("id property must be declared using @ID") + let id = mirror as? ID + else { + fatalError("id property must be declared using @ID") } - + return id } - + var _$fullName: OptionalField { guard let mirror = Mirror(reflecting: self).descendant("_fullName"), - let fullName = mirror as? OptionalField else { - fatalError("fullName property must be declared using @OptionalField") + let fullName = mirror as? OptionalField + else { + fatalError("fullName property must be declared using @OptionalField") } - + return fullName } - + var _$givenName: OptionalField { guard let mirror = Mirror(reflecting: self).descendant("_givenName"), - let givenName = mirror as? OptionalField else { - fatalError("givenName property must be declared using @OptionalField") + let givenName = mirror as? OptionalField + else { + fatalError("givenName property must be declared using @OptionalField") } - + return givenName } - + var _$familyName: OptionalField { guard let mirror = Mirror(reflecting: self).descendant("_familyName"), - let familyName = mirror as? OptionalField else { - fatalError("familyName property must be declared using @OptionalField") + let familyName = mirror as? OptionalField + else { + fatalError("familyName property must be declared using @OptionalField") } - + return familyName } - + var _$emailAddress: OptionalField { guard let mirror = Mirror(reflecting: self).descendant("_emailAddress"), - let emailAddress = mirror as? OptionalField else { - fatalError("emailAddress property must be declared using @OptionalField") + let emailAddress = mirror as? OptionalField + else { + fatalError("emailAddress property must be declared using @OptionalField") } - + return emailAddress } - + var _$postalCode: OptionalField { guard let mirror = Mirror(reflecting: self).descendant("_postalCode"), - let postalCode = mirror as? OptionalField else { - fatalError("postalCode property must be declared using @OptionalField") + let postalCode = mirror as? OptionalField + else { + fatalError("postalCode property must be declared using @OptionalField") } - + return postalCode } - - var _$ISOCountryCode: OptionalField { - guard let mirror = Mirror(reflecting: self).descendant("_ISOCountryCode"), - let ISOCountryCode = mirror as? OptionalField else { - fatalError("ISOCountryCode property must be declared using @OptionalField") + + var _$isoCountryCode: OptionalField { + guard let mirror = Mirror(reflecting: self).descendant("_isoCountryCode"), + let isoCountryCode = mirror as? OptionalField + else { + fatalError("isoCountryCode property must be declared using @OptionalField") } - - return ISOCountryCode + + return isoCountryCode } - + var _$phoneNumber: OptionalField { guard let mirror = Mirror(reflecting: self).descendant("_phoneNumber"), - let phoneNumber = mirror as? OptionalField else { - fatalError("phoneNumber property must be declared using @OptionalField") + let phoneNumber = mirror as? OptionalField + else { + fatalError("phoneNumber property must be declared using @OptionalField") } return phoneNumber } -} \ No newline at end of file +} diff --git a/Sources/Passes/PassesDelegate.swift b/Sources/Passes/PassesDelegate.swift index f923642..a571f6f 100644 --- a/Sources/Passes/PassesDelegate.swift +++ b/Sources/Passes/PassesDelegate.swift @@ -26,8 +26,8 @@ /// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN /// THE SOFTWARE. -import Foundation import FluentKit +import Foundation /// The delegate which is responsible for generating the pass files. public protocol PassesDelegate: AnyObject, Sendable { @@ -80,7 +80,7 @@ public protocol PassesDelegate: AnyObject, Sendable { /// you should return a properly formatted personalization JSON file. /// /// If the pass does not require personalization, you should return `nil`. - /// + /// /// The default implementation of this method returns `nil`. /// /// - Parameters: @@ -88,7 +88,9 @@ public protocol PassesDelegate: AnyObject, Sendable { /// - db: The SQL database to query against. /// - encoder: The `JSONEncoder` which you should use. /// - Returns: The encoded personalization JSON data, or `nil` if the pass does not require personalization. - func encodePersonalization(for pass: P, db: any Database, encoder: JSONEncoder) async throws -> Data? + func encodePersonalization( + for pass: P, db: any Database, encoder: JSONEncoder + ) async throws -> Data? /// Should return a `URL` which points to the template data for the pass. /// @@ -104,7 +106,7 @@ public protocol PassesDelegate: AnyObject, Sendable { /// /// > Important: Be sure to use the `URL(fileURLWithPath:)` constructor. var sslBinary: URL { get } - + /// The name of Apple's WWDR.pem certificate as contained in `sslSigningFiles` path. /// /// Defaults to `WWDR.pem` @@ -124,32 +126,34 @@ public protocol PassesDelegate: AnyObject, Sendable { var pemPrivateKeyPassword: String? { get } } -public extension PassesDelegate { - var wwdrCertificate: String { - get { return "WWDR.pem" } +extension PassesDelegate { + public var wwdrCertificate: String { + return "WWDR.pem" } - var pemCertificate: String { - get { return "passcertificate.pem" } + public var pemCertificate: String { + return "passcertificate.pem" } - var pemPrivateKey: String { - get { return "passkey.pem" } + public var pemPrivateKey: String { + return "passkey.pem" } - var pemPrivateKeyPassword: String? { - get { return nil } + public var pemPrivateKeyPassword: String? { + return nil } - var sslBinary: URL { - get { return URL(fileURLWithPath: "/usr/bin/openssl") } + public var sslBinary: URL { + return URL(fileURLWithPath: "/usr/bin/openssl") } - - func generateSignatureFile(in root: URL) -> Bool { + + public func generateSignatureFile(in root: URL) -> Bool { return false } - func encodePersonalization(for pass: P, db: any Database, encoder: JSONEncoder) async throws -> Data? { + public func encodePersonalization( + for pass: P, db: any Database, encoder: JSONEncoder + ) async throws -> Data? { return nil } } diff --git a/Sources/Passes/PassesError.swift b/Sources/Passes/PassesError.swift index 9de4c3c..2bacc68 100644 --- a/Sources/Passes/PassesError.swift +++ b/Sources/Passes/PassesError.swift @@ -6,23 +6,23 @@ // /// Errors that can be thrown by PassKit passes. -public struct PassesError: Error, Sendable { +public struct PassesError: Error, Sendable, Equatable { /// The type of the errors that can be thrown by PassKit passes. - public struct ErrorType: Sendable, Hashable, CustomStringConvertible { - enum Base: String, Sendable { + public struct ErrorType: Sendable, Hashable, CustomStringConvertible, Equatable { + enum Base: String, Sendable, Equatable { case templateNotDirectory case pemCertificateMissing case pemPrivateKeyMissing case opensslBinaryMissing case invalidNumberOfPasses } - + let base: Base - + private init(_ base: Base) { self.base = base } - + /// The template path is not a directory. public static let templateNotDirectory = Self(.templateNotDirectory) /// The `pemCertificate` file is missing. @@ -39,15 +39,19 @@ public struct PassesError: Error, Sendable { base.rawValue } } - - private struct Backing: Sendable { + + private struct Backing: Sendable, Equatable { fileprivate let errorType: ErrorType - + init(errorType: ErrorType) { self.errorType = errorType } + + static func == (lhs: PassesError.Backing, rhs: PassesError.Backing) -> Bool { + lhs.errorType == rhs.errorType + } } - + private var backing: Backing /// The type of this error. @@ -56,7 +60,7 @@ public struct PassesError: Error, Sendable { private init(errorType: ErrorType) { self.backing = .init(errorType: errorType) } - + /// The template path is not a directory. public static let templateNotDirectory = Self(errorType: .templateNotDirectory) @@ -71,6 +75,10 @@ public struct PassesError: Error, Sendable { /// The number of passes to bundle is invalid. public static let invalidNumberOfPasses = Self(errorType: .invalidNumberOfPasses) + + public static func == (lhs: PassesError, rhs: PassesError) -> Bool { + lhs.backing == rhs.backing + } } extension PassesError: CustomStringConvertible { diff --git a/Sources/Passes/PassesService.swift b/Sources/Passes/PassesService.swift index 3578c97..6151d48 100644 --- a/Sources/Passes/PassesService.swift +++ b/Sources/Passes/PassesService.swift @@ -26,13 +26,16 @@ /// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN /// THE SOFTWARE. -import Vapor import FluentKit +import Vapor /// The main class that handles PassKit passes. public final class PassesService: Sendable { - private let service: PassesServiceCustom - + private let service: + PassesServiceCustom< + Pass, UserPersonalization, PassesDevice, PassesRegistration, PassesErrorLog + > + /// Initializes the service and registers all the routes required for PassKit to work. /// /// - Parameters: @@ -40,8 +43,13 @@ public final class PassesService: Sendable { /// - delegate: The ``PassesDelegate`` to use for pass generation. /// - pushRoutesMiddleware: The `Middleware` to use for push notification routes. If `nil`, push routes will not be registered. /// - logger: The `Logger` to use. - public init(app: Application, delegate: any PassesDelegate, pushRoutesMiddleware: (any Middleware)? = nil, logger: Logger? = nil) throws { - service = try .init(app: app, delegate: delegate, pushRoutesMiddleware: pushRoutesMiddleware, logger: logger) + public init( + app: Application, delegate: any PassesDelegate, + pushRoutesMiddleware: (any Middleware)? = nil, logger: Logger? = nil + ) throws { + service = try .init( + app: app, delegate: delegate, pushRoutesMiddleware: pushRoutesMiddleware, logger: logger + ) } /// Generates the pass content bundle for a given pass. @@ -64,10 +72,11 @@ public final class PassesService: Sendable { /// - passes: The passes to include in the bundle. /// - db: The `Database` to use. /// - Returns: The bundle of passes as `Data`. - public func generatePassesContent(for passes: [Pass], on db: any Database) async throws -> Data { + public func generatePassesContent(for passes: [Pass], on db: any Database) async throws -> Data + { try await service.generatePassesContent(for: passes, on: db) } - + /// Adds the migrations for PassKit passes models. /// /// - Parameter migrations: The `Migrations` object to add the migrations to. @@ -78,19 +87,21 @@ public final class PassesService: Sendable { migrations.add(PassesRegistration()) migrations.add(PassesErrorLog()) } - + /// Sends push notifications for a given pass. /// /// - Parameters: /// - id: The `UUID` of the pass to send the notifications for. /// - passTypeIdentifier: The type identifier of the pass. /// - db: The `Database` to use. - public func sendPushNotificationsForPass(id: UUID, of passTypeIdentifier: String, on db: any Database) async throws { + public func sendPushNotificationsForPass( + id: UUID, of passTypeIdentifier: String, on db: any Database + ) async throws { try await service.sendPushNotificationsForPass(id: id, of: passTypeIdentifier, on: db) } - + /// Sends push notifications for a given pass. - /// + /// /// - Parameters: /// - pass: The pass to send the notifications for. /// - db: The `Database` to use. diff --git a/Sources/Passes/PassesServiceCustom.swift b/Sources/Passes/PassesServiceCustom.swift index a3a140c..334a990 100644 --- a/Sources/Passes/PassesServiceCustom.swift +++ b/Sources/Passes/PassesServiceCustom.swift @@ -5,15 +5,15 @@ // Created by Francesco Paolo Severino on 29/06/24. // -import Vapor import APNS -import VaporAPNS import APNSCore import Fluent import NIOSSL import PassKit -import Zip +import Vapor +import VaporAPNS @_spi(CMS) import X509 +import Zip /// Class to handle ``PassesService``. /// @@ -23,11 +23,14 @@ import Zip /// - Device Type /// - Registration Type /// - Error Log Type -public final class PassesServiceCustom: Sendable where P == R.PassType, D == R.DeviceType, U == P.UserPersonalizationType { +public final class PassesServiceCustom< + P, U, D, R: PassesRegistrationModel, E: ErrorLogModel +>: Sendable +where P == R.PassType, D == R.DeviceType, U == P.UserPersonalizationType { private unowned let app: Application private unowned let delegate: any PassesDelegate private let logger: Logger? - + /// Initializes the service and registers all the routes required for PassKit to work. /// /// - Parameters: @@ -45,11 +48,15 @@ public final class PassesServiceCustom()) - v1auth.post("devices", ":deviceLibraryIdentifier", "registrations", ":passTypeIdentifier", ":passSerial", use: { try await self.registerDevice(req: $0) }) - v1auth.get("passes", ":passTypeIdentifier", ":passSerial", use: { try await self.latestVersionOfPass(req: $0) }) - v1auth.delete("devices", ":deviceLibraryIdentifier", "registrations", ":passTypeIdentifier", ":passSerial", use: { try await self.unregisterDevice(req: $0) }) + v1auth.post( + "devices", ":deviceLibraryIdentifier", "registrations", ":passTypeIdentifier", + ":passSerial", use: { try await self.registerDevice(req: $0) }) + v1auth.get( + "passes", ":passTypeIdentifier", ":passSerial", + use: { try await self.latestVersionOfPass(req: $0) }) + v1auth.delete( + "devices", ":deviceLibraryIdentifier", "registrations", ":passTypeIdentifier", + ":passSerial", use: { try await self.unregisterDevice(req: $0) }) if let pushRoutesMiddleware { let pushAuth = v1.grouped(pushRoutesMiddleware) - pushAuth.post("push", ":passTypeIdentifier", ":passSerial", use: {try await self.pushUpdatesForPass(req: $0) }) - pushAuth.get("push", ":passTypeIdentifier", ":passSerial", use: { try await self.tokensForPassUpdate(req: $0) }) + pushAuth.post( + "push", ":passTypeIdentifier", ":passSerial", + use: { try await self.pushUpdatesForPass(req: $0) }) + pushAuth.get( + "push", ":passTypeIdentifier", ":passSerial", + use: { try await self.tokensForPassUpdate(req: $0) }) } } } @@ -105,27 +130,28 @@ public final class PassesServiceCustom HTTPStatus { logger?.debug("Called register device") - + let pushToken: String do { pushToken = try req.content.decode(RegistrationDTO.self).pushToken } catch { throw Abort(.badRequest) } - + guard let serial = req.parameters.get("passSerial", as: UUID.self) else { throw Abort(.badRequest) } let passTypeIdentifier = req.parameters.get("passTypeIdentifier")! let deviceLibraryIdentifier = req.parameters.get("deviceLibraryIdentifier")! - guard let pass = try await P.query(on: req.db) - .filter(\._$passTypeIdentifier == passTypeIdentifier) - .filter(\._$id == serial) - .first() + guard + let pass = try await P.query(on: req.db) + .filter(\._$passTypeIdentifier == passTypeIdentifier) + .filter(\._$id == serial) + .first() else { throw Abort(.notFound) } - + let device = try await D.query(on: req.db) .filter(\._$deviceLibraryIdentifier == deviceLibraryIdentifier) .filter(\._$pushToken == pushToken) @@ -133,16 +159,24 @@ extension PassesServiceCustom { if let device = device { return try await Self.createRegistration(device: device, pass: pass, db: req.db) } else { - let newDevice = D(deviceLibraryIdentifier: deviceLibraryIdentifier, pushToken: pushToken) + let newDevice = D( + deviceLibraryIdentifier: deviceLibraryIdentifier, pushToken: pushToken) try await newDevice.create(on: req.db) return try await Self.createRegistration(device: newDevice, pass: pass, db: req.db) } } - - private static func createRegistration(device: D, pass: P, db: any Database) async throws -> HTTPStatus { - let r = try await R.for(deviceLibraryIdentifier: device.deviceLibraryIdentifier, passTypeIdentifier: pass.passTypeIdentifier, on: db) - .filter(P.self, \._$id == pass.requireID()) - .first() + + private static func createRegistration( + device: D, + pass: P, + db: any Database + ) async throws -> HTTPStatus { + let r = try await R.for( + deviceLibraryIdentifier: device.deviceLibraryIdentifier, + passTypeIdentifier: pass.passTypeIdentifier, on: db + ) + .filter(P.self, \._$id == pass.requireID()) + .first() // If the registration already exists, docs say to return 200 OK if r != nil { return .ok } @@ -152,61 +186,65 @@ extension PassesServiceCustom { try await registration.create(on: db) return .created } - + func passesForDevice(req: Request) async throws -> PassesForDeviceDTO { logger?.debug("Called passesForDevice") let passTypeIdentifier = req.parameters.get("passTypeIdentifier")! let deviceLibraryIdentifier = req.parameters.get("deviceLibraryIdentifier")! - - var query = R.for(deviceLibraryIdentifier: deviceLibraryIdentifier, passTypeIdentifier: passTypeIdentifier, on: req.db) + + var query = R.for( + deviceLibraryIdentifier: deviceLibraryIdentifier, + passTypeIdentifier: passTypeIdentifier, on: req.db) if let since: TimeInterval = req.query["passesUpdatedSince"] { let when = Date(timeIntervalSince1970: since) query = query.filter(P.self, \._$updatedAt > when) } - + let registrations = try await query.all() guard !registrations.isEmpty else { throw Abort(.noContent) } - + var serialNumbers: [String] = [] var maxDate = Date.distantPast - try registrations.forEach { r in - let pass = r.pass + for registration in registrations { + let pass = registration.pass try serialNumbers.append(pass.requireID().uuidString) if let updatedAt = pass.updatedAt, updatedAt > maxDate { maxDate = updatedAt } } - + return PassesForDeviceDTO(with: serialNumbers, maxDate: maxDate) } - + func latestVersionOfPass(req: Request) async throws -> Response { logger?.debug("Called latestVersionOfPass") - + var ifModifiedSince: TimeInterval = 0 if let header = req.headers[.ifModifiedSince].first, let ims = TimeInterval(header) { ifModifiedSince = ims } - + guard let passTypeIdentifier = req.parameters.get("passTypeIdentifier"), - let id = req.parameters.get("passSerial", as: UUID.self) else { - throw Abort(.badRequest) + let id = req.parameters.get("passSerial", as: UUID.self) + else { + throw Abort(.badRequest) } - guard let pass = try await P.query(on: req.db) - .filter(\._$id == id) - .filter(\._$passTypeIdentifier == passTypeIdentifier) - .first() + guard + let pass = try await P.query(on: req.db) + .filter(\._$id == id) + .filter(\._$passTypeIdentifier == passTypeIdentifier) + .first() else { throw Abort(.notFound) } - + guard ifModifiedSince < pass.updatedAt?.timeIntervalSince1970 ?? 0 else { throw Abort(.notModified) } - + var headers = HTTPHeaders() headers.add(name: .contentType, value: "application/vnd.apple.pkpass") headers.lastModified = HTTPHeaders.LastModified(pass.updatedAt ?? Date.distantPast) @@ -217,7 +255,7 @@ extension PassesServiceCustom { body: Response.Body(data: self.generatePassContent(for: pass, on: req.db)) ) } - + func unregisterDevice(req: Request) async throws -> HTTPStatus { logger?.debug("Called unregisterDevice") @@ -227,7 +265,11 @@ extension PassesServiceCustom { let passTypeIdentifier = req.parameters.get("passTypeIdentifier")! let deviceLibraryIdentifier = req.parameters.get("deviceLibraryIdentifier")! - guard let r = try await R.for(deviceLibraryIdentifier: deviceLibraryIdentifier, passTypeIdentifier: passTypeIdentifier, on: req.db) + guard + let r = try await R.for( + deviceLibraryIdentifier: deviceLibraryIdentifier, + passTypeIdentifier: passTypeIdentifier, on: req.db + ) .filter(P.self, \._$id == passId) .first() else { @@ -236,7 +278,7 @@ extension PassesServiceCustom { try await r.delete(on: req.db) return .ok } - + func logError(req: Request) async throws -> HTTPStatus { logger?.debug("Called logError") @@ -259,26 +301,28 @@ extension PassesServiceCustom { logger?.debug("Called personalizedPass") guard let passTypeIdentifier = req.parameters.get("passTypeIdentifier"), - let id = req.parameters.get("passSerial", as: UUID.self) else { - throw Abort(.badRequest) + let id = req.parameters.get("passSerial", as: UUID.self) + else { + throw Abort(.badRequest) } - guard let pass = try await P.query(on: req.db) - .filter(\._$id == id) - .filter(\._$passTypeIdentifier == passTypeIdentifier) - .first() + guard + let pass = try await P.query(on: req.db) + .filter(\._$id == id) + .filter(\._$passTypeIdentifier == passTypeIdentifier) + .first() else { throw Abort(.notFound) } let userInfo = try req.content.decode(PersonalizationDictionaryDTO.self) - + let userPersonalization = U() userPersonalization.fullName = userInfo.requiredPersonalizationInfo.fullName userPersonalization.givenName = userInfo.requiredPersonalizationInfo.givenName userPersonalization.familyName = userInfo.requiredPersonalizationInfo.familyName userPersonalization.emailAddress = userInfo.requiredPersonalizationInfo.emailAddress userPersonalization.postalCode = userInfo.requiredPersonalizationInfo.postalCode - userPersonalization.ISOCountryCode = userInfo.requiredPersonalizationInfo.ISOCountryCode + userPersonalization.isoCountryCode = userInfo.requiredPersonalizationInfo.isoCountryCode userPersonalization.phoneNumber = userInfo.requiredPersonalizationInfo.phoneNumber try await userPersonalization.create(on: req.db) @@ -296,7 +340,7 @@ extension PassesServiceCustom { let signature: Data if let password = delegate.pemPrivateKeyPassword { let sslBinary = delegate.sslBinary - guard FileManager.default.fileExists(atPath: sslBinary.unixPath()) else { + guard FileManager.default.fileExists(atPath: sslBinary.path) else { throw PassesError.opensslBinaryMissing } @@ -311,10 +355,10 @@ extension PassesServiceCustom { "-certfile", delegate.wwdrCertificate, "-signer", delegate.pemCertificate, "-inkey", delegate.pemPrivateKey, - "-in", tokenURL.unixPath(), - "-out", root.appendingPathComponent("signature").unixPath(), + "-in", tokenURL.path, + "-out", root.appendingPathComponent("signature").path, "-outform", "DER", - "-passin", "pass:\(password)" + "-passin", "pass:\(password)", ] try proc.run() proc.waitUntilExit() @@ -353,7 +397,7 @@ extension PassesServiceCustom { headers.add(name: .contentTransferEncoding, value: "binary") return Response(status: .ok, headers: headers, body: Response.Body(data: signature)) } - + // MARK: - Push Routes func pushUpdatesForPass(req: Request) async throws -> HTTPStatus { logger?.debug("Called pushUpdatesForPass") @@ -366,7 +410,7 @@ extension PassesServiceCustom { try await sendPushNotificationsForPass(id: id, of: passTypeIdentifier, on: req.db) return .noContent } - + func tokensForPassUpdate(req: Request) async throws -> [String] { logger?.debug("Called tokensForPassUpdate") @@ -379,7 +423,7 @@ extension PassesServiceCustom { .map { $0.device.pushToken } } } - + // MARK: - Push Notifications extension PassesServiceCustom { /// Sends push notifications for a given pass. @@ -388,8 +432,11 @@ extension PassesServiceCustom { /// - id: The `UUID` of the pass to send the notifications for. /// - passTypeIdentifier: The type identifier of the pass. /// - db: The `Database` to use. - public func sendPushNotificationsForPass(id: UUID, of passTypeIdentifier: String, on db: any Database) async throws { - let registrations = try await Self.registrationsForPass(id: id, of: passTypeIdentifier, on: db) + public func sendPushNotificationsForPass( + id: UUID, of passTypeIdentifier: String, on db: any Database + ) async throws { + let registrations = try await Self.registrationsForPass( + id: id, of: passTypeIdentifier, on: db) for reg in registrations { let backgroundNotification = APNSBackgroundNotification( expiration: .immediately, @@ -409,15 +456,18 @@ extension PassesServiceCustom { } /// Sends push notifications for a given pass. - /// + /// /// - Parameters: /// - pass: The pass to send the notifications for. /// - db: The `Database` to use. public func sendPushNotifications(for pass: P, on db: any Database) async throws { - try await sendPushNotificationsForPass(id: pass.requireID(), of: pass.passTypeIdentifier, on: db) + try await sendPushNotificationsForPass( + id: pass.requireID(), of: pass.passTypeIdentifier, on: db) } - - static func registrationsForPass(id: UUID, of passTypeIdentifier: String, on db: any Database) async throws -> [R] { + + static func registrationsForPass( + id: UUID, of passTypeIdentifier: String, on db: any Database + ) async throws -> [R] { // This could be done by enforcing the caller to have a Siblings property wrapper, // but there's not really any value to forcing that on them when we can just do the query ourselves like this. try await R.query(on: db) @@ -430,15 +480,17 @@ extension PassesServiceCustom { .all() } } - + // MARK: - pkpass file generation extension PassesServiceCustom { - private static func generateManifestFile(using encoder: JSONEncoder, in root: URL) throws -> Data { + private static func generateManifestFile( + using encoder: JSONEncoder, in root: URL + ) throws -> Data { var manifest: [String: String] = [:] - let paths = try FileManager.default.subpathsOfDirectory(atPath: root.unixPath()) - try paths.forEach { relativePath in + let paths = try FileManager.default.subpathsOfDirectory(atPath: root.path) + for relativePath in paths { let file = URL(fileURLWithPath: relativePath, relativeTo: root) - guard !file.hasDirectoryPath else { return } + guard !file.hasDirectoryPath else { continue } let data = try Data(contentsOf: file) let hash = Insecure.SHA1.hash(data: data) manifest[relativePath] = hash.map { "0\(String($0, radix: 16))".suffix(2) }.joined() @@ -447,7 +499,7 @@ extension PassesServiceCustom { try data.write(to: root.appendingPathComponent("manifest.json")) return data } - + private func generateSignatureFile(for manifest: Data, in root: URL) throws { // If the caller's delegate generated a file we don't have to do it. if delegate.generateSignatureFile(in: root) { return } @@ -455,7 +507,7 @@ extension PassesServiceCustom { // Swift Crypto doesn't support encrypted PEM private keys, so we have to use OpenSSL for that. if let password = delegate.pemPrivateKeyPassword { let sslBinary = delegate.sslBinary - guard FileManager.default.fileExists(atPath: sslBinary.unixPath()) else { + guard FileManager.default.fileExists(atPath: sslBinary.path) else { throw PassesError.opensslBinaryMissing } @@ -467,10 +519,10 @@ extension PassesServiceCustom { "-certfile", delegate.wwdrCertificate, "-signer", delegate.pemCertificate, "-inkey", delegate.pemPrivateKey, - "-in", root.appendingPathComponent("manifest.json").unixPath(), - "-out", root.appendingPathComponent("signature").unixPath(), + "-in", root.appendingPathComponent("manifest.json").path, + "-out", root.appendingPathComponent("signature").path, "-outform", "DER", - "-passin", "pass:\(password)" + "-passin", "pass:\(password)", ] try proc.run() proc.waitUntilExit() @@ -504,7 +556,7 @@ extension PassesServiceCustom { ) try Data(signature).write(to: root.appendingPathComponent("signature")) } - + /// Generates the pass content bundle for a given pass. /// /// - Parameters: @@ -513,26 +565,32 @@ extension PassesServiceCustom { /// - Returns: The generated pass content as `Data`. public func generatePassContent(for pass: P, on db: any Database) async throws -> Data { let templateDirectory = try await delegate.template(for: pass, db: db) - guard (try? templateDirectory.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) ?? false else { + guard + (try? templateDirectory.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) ?? false + else { throw PassesError.templateNotDirectory } - var files = try FileManager.default.contentsOfDirectory(at: templateDirectory, includingPropertiesForKeys: nil) + var files = try FileManager.default.contentsOfDirectory( + at: templateDirectory, includingPropertiesForKeys: nil) let tmp = FileManager.default.temporaryDirectory let root = tmp.appendingPathComponent(UUID().uuidString, isDirectory: true) try FileManager.default.copyItem(at: templateDirectory, to: root) defer { _ = try? FileManager.default.removeItem(at: root) } - + let encoder = JSONEncoder() try await self.delegate.encode(pass: pass, db: db, encoder: encoder) .write(to: root.appendingPathComponent("pass.json")) // Pass Personalization - if let encodedPersonalization = try await self.delegate.encodePersonalization(for: pass, db: db, encoder: encoder) { - try encodedPersonalization.write(to: root.appendingPathComponent("personalization.json")) + if let encodedPersonalization = try await self.delegate.encodePersonalization( + for: pass, db: db, encoder: encoder) + { + try encodedPersonalization.write( + to: root.appendingPathComponent("personalization.json")) files.append(URL(fileURLWithPath: "personalization.json", relativeTo: root)) } - + try self.generateSignatureFile( for: Self.generateManifestFile(using: encoder, in: root), in: root @@ -563,7 +621,7 @@ extension PassesServiceCustom { let root = tmp.appendingPathComponent(UUID().uuidString, isDirectory: true) try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) defer { _ = try? FileManager.default.removeItem(at: root) } - + var files: [URL] = [] for (i, pass) in passes.enumerated() { let name = "pass\(i).pkpass" diff --git a/Tests/OrdersTests/EncryptedOrdersDelegate.swift b/Tests/OrdersTests/EncryptedOrdersDelegate.swift index dd0712a..82c192d 100644 --- a/Tests/OrdersTests/EncryptedOrdersDelegate.swift +++ b/Tests/OrdersTests/EncryptedOrdersDelegate.swift @@ -1,6 +1,6 @@ -import Vapor import FluentKit import Orders +import Vapor final class EncryptedOrdersDelegate: OrdersDelegate { let sslSigningFilesDirectory = URL( @@ -12,16 +12,20 @@ final class EncryptedOrdersDelegate: OrdersDelegate { let pemPrivateKey = "encryptedkey.pem" let pemPrivateKeyPassword: String? = "password" - - func encode(order: O, db: any Database, encoder: JSONEncoder) async throws -> Data { - guard let orderData = try await OrderData.query(on: db) - .filter(\.$order.$id == order.requireID()) - .with(\.$order) - .first() + + func encode( + order: O, db: any Database, encoder: JSONEncoder + ) async throws -> Data { + guard + let orderData = try await OrderData.query(on: db) + .filter(\.$order.$id == order.requireID()) + .with(\.$order) + .first() else { throw Abort(.internalServerError) } - guard let data = try? encoder.encode(OrderJSONData(data: orderData, order: orderData.order)) else { + guard let data = try? encoder.encode(OrderJSONData(data: orderData, order: orderData.order)) + else { throw Abort(.internalServerError) } return data @@ -29,8 +33,9 @@ final class EncryptedOrdersDelegate: OrdersDelegate { func template(for: O, db: any Database) async throws -> URL { URL( - fileURLWithPath: "\(FileManager.default.currentDirectoryPath)/Tests/OrdersTests/Templates/", + fileURLWithPath: + "\(FileManager.default.currentDirectoryPath)/Tests/OrdersTests/Templates/", isDirectory: true ) } -} \ No newline at end of file +} diff --git a/Tests/OrdersTests/EncryptedOrdersTests.swift b/Tests/OrdersTests/EncryptedOrdersTests.swift index c004f0c..6e39f4c 100644 --- a/Tests/OrdersTests/EncryptedOrdersTests.swift +++ b/Tests/OrdersTests/EncryptedOrdersTests.swift @@ -1,16 +1,17 @@ -import XCTVapor import Fluent import FluentSQLiteDriver -@testable import Orders import PassKit +import XCTVapor import Zip +@testable import Orders + final class EncryptedOrdersTests: XCTestCase { let delegate = EncryptedOrdersDelegate() let ordersURI = "/api/orders/v1/" var ordersService: OrdersService! var app: Application! - + override func setUp() async throws { self.app = try await Application.make(.testing) app.databases.use(.sqlite(.memory), as: .sqlite) @@ -30,7 +31,7 @@ final class EncryptedOrdersTests: XCTestCase { Zip.addCustomFileExtension("order") } - override func tearDown() async throws { + override func tearDown() async throws { try await app.autoRevert() try await self.app.asyncShutdown() self.app = nil @@ -47,13 +48,17 @@ final class EncryptedOrdersTests: XCTestCase { XCTAssert(FileManager.default.fileExists(atPath: orderFolder.path.appending("/signature"))) - let passJSONData = try String(contentsOfFile: orderFolder.path.appending("/order.json")).data(using: .utf8) + let passJSONData = try String(contentsOfFile: orderFolder.path.appending("/order.json")) + .data(using: .utf8) let passJSON = try JSONSerialization.jsonObject(with: passJSONData!) as! [String: Any] XCTAssertEqual(passJSON["authenticationToken"] as? String, order.authenticationToken) try XCTAssertEqual(passJSON["orderIdentifier"] as? String, order.requireID().uuidString) - let manifestJSONData = try String(contentsOfFile: orderFolder.path.appending("/manifest.json")).data(using: .utf8) - let manifestJSON = try JSONSerialization.jsonObject(with: manifestJSONData!) as! [String: Any] + let manifestJSONData = try String( + contentsOfFile: orderFolder.path.appending("/manifest.json") + ).data(using: .utf8) + let manifestJSON = + try JSONSerialization.jsonObject(with: manifestJSONData!) as! [String: Any] let iconData = try Data(contentsOf: orderFolder.appendingPathComponent("/icon.png")) let iconHash = Array(SHA256.hash(data: iconData)).hex XCTAssertEqual(manifestJSON["icon.png"] as? String, iconHash) @@ -66,7 +71,8 @@ final class EncryptedOrdersTests: XCTestCase { try await orderData.create(on: app.db) let order = try await orderData._$order.get(on: app.db) - try await ordersService.sendPushNotificationsForOrder(id: order.requireID(), of: order.orderTypeIdentifier, on: app.db) + try await ordersService.sendPushNotificationsForOrder( + id: order.requireID(), of: order.orderTypeIdentifier, on: app.db) let deviceLibraryIdentifier = "abcdefg" let pushToken = "1234567890" @@ -107,4 +113,4 @@ final class EncryptedOrdersTests: XCTestCase { try await orderData.update(on: app.db) } catch {} } -} \ No newline at end of file +} diff --git a/Tests/OrdersTests/OrderData.swift b/Tests/OrdersTests/OrderData.swift index 61ebc6e..698c457 100644 --- a/Tests/OrdersTests/OrderData.swift +++ b/Tests/OrdersTests/OrderData.swift @@ -1,27 +1,28 @@ import Fluent -import struct Foundation.UUID import Orders import Vapor +import struct Foundation.UUID + final class OrderData: OrderDataModel, @unchecked Sendable { static let schema = OrderData.FieldKeys.schemaName - + @ID(key: .id) var id: UUID? @Field(key: OrderData.FieldKeys.title) var title: String - + @Parent(key: OrderData.FieldKeys.orderID) var order: Order - init() { } + init() {} init(id: UUID? = nil, title: String) { self.id = id self.title = title } - + func toDTO() -> OrderDataDTO { .init( id: self.id, @@ -33,10 +34,10 @@ final class OrderData: OrderDataModel, @unchecked Sendable { struct OrderDataDTO: Content { var id: UUID? var title: String? - + func toModel() -> OrderData { let model = OrderData() - + model.id = self.id if let title = self.title { model.title = title @@ -50,7 +51,10 @@ struct CreateOrderData: AsyncMigration { try await database.schema(OrderData.FieldKeys.schemaName) .id() .field(OrderData.FieldKeys.title, .string, .required) - .field(OrderData.FieldKeys.orderID, .uuid, .required, .references(Order.schema, .id, onDelete: .cascade)) + .field( + OrderData.FieldKeys.orderID, .uuid, .required, + .references(Order.schema, .id, onDelete: .cascade) + ) .create() } @@ -88,7 +92,7 @@ struct OrderJSONData: OrderJSON.Properties { let url = "https://www.example.com/" let logo = "pet_store_logo.png" } - + init(data: OrderData, order: Order) { self.orderIdentifier = order.id!.uuidString self.authenticationToken = order.authenticationToken @@ -107,7 +111,9 @@ struct OrderDataMiddleware: AsyncModelMiddleware { self.service = service } - func create(model: OrderData, on db: any Database, next: any AnyAsyncModelResponder) async throws { + func create( + model: OrderData, on db: any Database, next: any AnyAsyncModelResponder + ) async throws { let order = Order( orderTypeIdentifier: "order.com.example.pet-store", authenticationToken: Data([UInt8].random(count: 12)).base64EncodedString()) @@ -116,7 +122,9 @@ struct OrderDataMiddleware: AsyncModelMiddleware { try await next.create(model, on: db) } - func update(model: OrderData, on db: any Database, next: any AnyAsyncModelResponder) async throws { + func update( + model: OrderData, on db: any Database, next: any AnyAsyncModelResponder + ) async throws { let order = try await model.$order.get(on: db) order.updatedAt = Date() try await order.save(on: db) diff --git a/Tests/OrdersTests/OrdersTests.swift b/Tests/OrdersTests/OrdersTests.swift index 5eb5aae..9833474 100644 --- a/Tests/OrdersTests/OrdersTests.swift +++ b/Tests/OrdersTests/OrdersTests.swift @@ -1,16 +1,17 @@ -import XCTVapor import Fluent import FluentSQLiteDriver -@testable import Orders import PassKit +import XCTVapor import Zip +@testable import Orders + final class OrdersTests: XCTestCase { let delegate = TestOrdersDelegate() let ordersURI = "/api/orders/v1/" var ordersService: OrdersService! var app: Application! - + override func setUp() async throws { self.app = try await Application.make(.testing) app.databases.use(.sqlite(.memory), as: .sqlite) @@ -30,7 +31,7 @@ final class OrdersTests: XCTestCase { Zip.addCustomFileExtension("order") } - override func tearDown() async throws { + override func tearDown() async throws { try await app.autoRevert() try await self.app.asyncShutdown() self.app = nil @@ -47,16 +48,21 @@ final class OrdersTests: XCTestCase { XCTAssert(FileManager.default.fileExists(atPath: orderFolder.path.appending("/signature"))) - let passJSONData = try String(contentsOfFile: orderFolder.path.appending("/order.json")).data(using: .utf8) + let passJSONData = try String(contentsOfFile: orderFolder.path.appending("/order.json")) + .data(using: .utf8) let passJSON = try JSONSerialization.jsonObject(with: passJSONData!) as! [String: Any] XCTAssertEqual(passJSON["authenticationToken"] as? String, order.authenticationToken) try XCTAssertEqual(passJSON["orderIdentifier"] as? String, order.requireID().uuidString) - let manifestJSONData = try String(contentsOfFile: orderFolder.path.appending("/manifest.json")).data(using: .utf8) - let manifestJSON = try JSONSerialization.jsonObject(with: manifestJSONData!) as! [String: Any] + let manifestJSONData = try String( + contentsOfFile: orderFolder.path.appending("/manifest.json") + ).data(using: .utf8) + let manifestJSON = + try JSONSerialization.jsonObject(with: manifestJSONData!) as! [String: Any] let iconData = try Data(contentsOf: orderFolder.appendingPathComponent("/icon.png")) let iconHash = Array(SHA256.hash(data: iconData)).hex XCTAssertEqual(manifestJSON["icon.png"] as? String, iconHash) + XCTAssertNotNil(manifestJSON["pet_store_logo.png"]) } // Tests the API Apple Wallet calls to get orders @@ -70,7 +76,7 @@ final class OrdersTests: XCTestCase { "\(ordersURI)orders/\(order.orderTypeIdentifier)/\(order.requireID())", headers: [ "Authorization": "AppleOrder \(order.authenticationToken)", - "If-Modified-Since": "0" + "If-Modified-Since": "0", ], afterResponse: { res async throws in XCTAssertEqual(res.status, .ok) @@ -86,7 +92,7 @@ final class OrdersTests: XCTestCase { "\(ordersURI)orders/\(order.orderTypeIdentifier)/\(order.requireID())", headers: [ "Authorization": "AppleOrder invalidToken", - "If-Modified-Since": "0" + "If-Modified-Since": "0", ], afterResponse: { res async throws in XCTAssertEqual(res.status, .unauthorized) @@ -99,7 +105,7 @@ final class OrdersTests: XCTestCase { "\(ordersURI)orders/\(order.orderTypeIdentifier)/\(order.requireID())", headers: [ "Authorization": "AppleOrder \(order.authenticationToken)", - "If-Modified-Since": "2147483647" + "If-Modified-Since": "2147483647", ], afterResponse: { res async throws in XCTAssertEqual(res.status, .notModified) @@ -112,7 +118,7 @@ final class OrdersTests: XCTestCase { "\(ordersURI)orders/\(order.orderTypeIdentifier)/invalidID", headers: [ "Authorization": "AppleOrder \(order.authenticationToken)", - "If-Modified-Since": "0" + "If-Modified-Since": "0", ], afterResponse: { res async throws in XCTAssertEqual(res.status, .badRequest) @@ -125,7 +131,7 @@ final class OrdersTests: XCTestCase { "\(ordersURI)orders/order.com.example.InvalidType/\(order.requireID())", headers: [ "Authorization": "AppleOrder \(order.authenticationToken)", - "If-Modified-Since": "0" + "If-Modified-Since": "0", ], afterResponse: { res async throws in XCTAssertEqual(res.status, .notFound) @@ -140,6 +146,23 @@ final class OrdersTests: XCTestCase { let deviceLibraryIdentifier = "abcdefg" let pushToken = "1234567890" + try await app.test( + .GET, + "\(ordersURI)devices/\(deviceLibraryIdentifier)/registrations/\(order.orderTypeIdentifier)?ordersModifiedSince=0", + afterResponse: { res async throws in + XCTAssertEqual(res.status, .noContent) + } + ) + + try await app.test( + .DELETE, + "\(ordersURI)devices/\(deviceLibraryIdentifier)/registrations/\(order.orderTypeIdentifier)/\(order.requireID())", + headers: ["Authorization": "AppleOrder \(order.authenticationToken)"], + afterResponse: { res async throws in + XCTAssertEqual(res.status, .notFound) + } + ) + // Test registration without authentication token try await app.test( .POST, @@ -152,6 +175,42 @@ final class OrdersTests: XCTestCase { } ) + // Test registration of a non-existing order + try await app.test( + .POST, + "\(ordersURI)devices/\(deviceLibraryIdentifier)/registrations/\("order.com.example.NotFound")/\(UUID().uuidString)", + headers: ["Authorization": "AppleOrder \(order.authenticationToken)"], + beforeRequest: { req async throws in + try req.content.encode(RegistrationDTO(pushToken: pushToken)) + }, + afterResponse: { res async throws in + XCTAssertEqual(res.status, .notFound) + } + ) + + // Test call without DTO + try await app.test( + .POST, + "\(ordersURI)devices/\(deviceLibraryIdentifier)/registrations/\(order.orderTypeIdentifier)/\(order.requireID())", + headers: ["Authorization": "AppleOrder \(order.authenticationToken)"], + afterResponse: { res async throws in + XCTAssertEqual(res.status, .badRequest) + } + ) + + // Test call with invalid UUID + try await app.test( + .POST, + "\(ordersURI)devices/\(deviceLibraryIdentifier)/registrations/\(order.orderTypeIdentifier)/\("not-a-uuid")", + headers: ["Authorization": "AppleOrder \(order.authenticationToken)"], + beforeRequest: { req async throws in + try req.content.encode(RegistrationDTO(pushToken: pushToken)) + }, + afterResponse: { res async throws in + XCTAssertEqual(res.status, .badRequest) + } + ) + try await app.test( .POST, "\(ordersURI)devices/\(deviceLibraryIdentifier)/registrations/\(order.orderTypeIdentifier)/\(order.requireID())", @@ -200,6 +259,26 @@ final class OrdersTests: XCTestCase { } ) + // Test call with invalid UUID + try await app.test( + .GET, + "\(ordersURI)push/\(order.orderTypeIdentifier)/\("not-a-uuid")", + headers: ["X-Secret": "foo"], + afterResponse: { res async throws in + XCTAssertEqual(res.status, .badRequest) + } + ) + + // Test call with invalid UUID + try await app.test( + .DELETE, + "\(ordersURI)devices/\(deviceLibraryIdentifier)/registrations/\(order.orderTypeIdentifier)/\("not-a-uuid")", + headers: ["Authorization": "AppleOrder \(order.authenticationToken)"], + afterResponse: { res async throws in + XCTAssertEqual(res.status, .badRequest) + } + ) + try await app.test( .DELETE, "\(ordersURI)devices/\(deviceLibraryIdentifier)/registrations/\(order.orderTypeIdentifier)/\(order.requireID())", @@ -259,7 +338,8 @@ final class OrdersTests: XCTestCase { try await orderData.create(on: app.db) let order = try await orderData._$order.get(on: app.db) - try await ordersService.sendPushNotificationsForOrder(id: order.requireID(), of: order.orderTypeIdentifier, on: app.db) + try await ordersService.sendPushNotificationsForOrder( + id: order.requireID(), of: order.orderTypeIdentifier, on: app.db) let deviceLibraryIdentifier = "abcdefg" let pushToken = "1234567890" @@ -294,6 +374,16 @@ final class OrdersTests: XCTestCase { } ) + // Test call with invalid UUID + try await app.test( + .POST, + "\(ordersURI)push/\(order.orderTypeIdentifier)/\("not-a-uuid")", + headers: ["X-Secret": "foo"], + afterResponse: { res async throws in + XCTAssertEqual(res.status, .badRequest) + } + ) + // Test `OrderDataMiddleware` update method orderData.title = "Test Order 2" do { @@ -304,10 +394,18 @@ final class OrdersTests: XCTestCase { } func testOrdersError() { - XCTAssertEqual(OrdersError.templateNotDirectory.description, "OrdersError(errorType: templateNotDirectory)") - XCTAssertEqual(OrdersError.pemCertificateMissing.description, "OrdersError(errorType: pemCertificateMissing)") - XCTAssertEqual(OrdersError.pemPrivateKeyMissing.description, "OrdersError(errorType: pemPrivateKeyMissing)") - XCTAssertEqual(OrdersError.opensslBinaryMissing.description, "OrdersError(errorType: opensslBinaryMissing)") + XCTAssertEqual( + OrdersError.templateNotDirectory.description, + "OrdersError(errorType: templateNotDirectory)") + XCTAssertEqual( + OrdersError.pemCertificateMissing.description, + "OrdersError(errorType: pemCertificateMissing)") + XCTAssertEqual( + OrdersError.pemPrivateKeyMissing.description, + "OrdersError(errorType: pemPrivateKeyMissing)") + XCTAssertEqual( + OrdersError.opensslBinaryMissing.description, + "OrdersError(errorType: opensslBinaryMissing)") } func testDefaultDelegate() { @@ -323,6 +421,12 @@ final class OrdersTests: XCTestCase { final class DefaultOrdersDelegate: OrdersDelegate { let sslSigningFilesDirectory = URL(fileURLWithPath: "", isDirectory: true) - func template(for order: O, db: any Database) async throws -> URL { URL(fileURLWithPath: "") } - func encode(order: O, db: any Database, encoder: JSONEncoder) async throws -> Data { Data() } + func template(for order: O, db: any Database) async throws -> URL { + URL(fileURLWithPath: "") + } + func encode( + order: O, db: any Database, encoder: JSONEncoder + ) async throws -> Data { + Data() + } } diff --git a/Tests/OrdersTests/SecretMiddleware.swift b/Tests/OrdersTests/SecretMiddleware.swift index 7dadd9e..fef1940 100644 --- a/Tests/OrdersTests/SecretMiddleware.swift +++ b/Tests/OrdersTests/SecretMiddleware.swift @@ -3,11 +3,9 @@ import Vapor struct SecretMiddleware: AsyncMiddleware { let secret: String - init(secret: String) { - self.secret = secret - } - - func respond(to request: Request, chainingTo next: any AsyncResponder) async throws -> Response { + func respond( + to request: Request, chainingTo next: any AsyncResponder + ) async throws -> Response { guard request.headers.first(name: "X-Secret") == secret else { throw Abort(.unauthorized, reason: "Incorrect X-Secret header.") } diff --git a/Tests/OrdersTests/Templates/EmptyDir/.gitkeep b/Tests/OrdersTests/Templates/EmptyDir/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/Tests/OrdersTests/TestOrdersDelegate.swift b/Tests/OrdersTests/TestOrdersDelegate.swift index a636cc9..ae36748 100644 --- a/Tests/OrdersTests/TestOrdersDelegate.swift +++ b/Tests/OrdersTests/TestOrdersDelegate.swift @@ -1,6 +1,6 @@ -import Vapor import FluentKit import Orders +import Vapor final class TestOrdersDelegate: OrdersDelegate { let sslSigningFilesDirectory = URL( @@ -10,16 +10,20 @@ final class TestOrdersDelegate: OrdersDelegate { let pemCertificate = "certificate.pem" let pemPrivateKey = "key.pem" - - func encode(order: O, db: any Database, encoder: JSONEncoder) async throws -> Data { - guard let orderData = try await OrderData.query(on: db) - .filter(\.$order.$id == order.requireID()) - .with(\.$order) - .first() + + func encode( + order: O, db: any Database, encoder: JSONEncoder + ) async throws -> Data { + guard + let orderData = try await OrderData.query(on: db) + .filter(\.$order.$id == order.requireID()) + .with(\.$order) + .first() else { throw Abort(.internalServerError) } - guard let data = try? encoder.encode(OrderJSONData(data: orderData, order: orderData.order)) else { + guard let data = try? encoder.encode(OrderJSONData(data: orderData, order: orderData.order)) + else { throw Abort(.internalServerError) } return data @@ -27,7 +31,8 @@ final class TestOrdersDelegate: OrdersDelegate { func template(for: O, db: any Database) async throws -> URL { URL( - fileURLWithPath: "\(FileManager.default.currentDirectoryPath)/Tests/OrdersTests/Templates/", + fileURLWithPath: + "\(FileManager.default.currentDirectoryPath)/Tests/OrdersTests/Templates/", isDirectory: true ) } diff --git a/Tests/PassesTests/EncryptedPassesDelegate.swift b/Tests/PassesTests/EncryptedPassesDelegate.swift index e73cecd..5c887e5 100644 --- a/Tests/PassesTests/EncryptedPassesDelegate.swift +++ b/Tests/PassesTests/EncryptedPassesDelegate.swift @@ -1,6 +1,6 @@ -import Vapor import FluentKit import Passes +import Vapor final class EncryptedPassesDelegate: PassesDelegate { let sslSigningFilesDirectory = URL( @@ -13,43 +13,53 @@ final class EncryptedPassesDelegate: PassesDelegate { let pemPrivateKeyPassword: String? = "password" - func encode(pass: P, db: any Database, encoder: JSONEncoder) async throws -> Data { - guard let passData = try await PassData.query(on: db) - .filter(\.$pass.$id == pass.requireID()) - .with(\.$pass) - .first() + func encode( + pass: P, db: any Database, encoder: JSONEncoder + ) async throws -> Data { + guard + let passData = try await PassData.query(on: db) + .filter(\.$pass.$id == pass.requireID()) + .with(\.$pass) + .first() else { throw Abort(.internalServerError) } - guard let data = try? encoder.encode(PassJSONData(data: passData, pass: passData.pass)) else { + guard let data = try? encoder.encode(PassJSONData(data: passData, pass: passData.pass)) + else { throw Abort(.internalServerError) } return data } - - func encodePersonalization(for pass: P, db: any Database, encoder: JSONEncoder) async throws -> Data? { - guard let passData = try await PassData.query(on: db) - .filter(\.$pass.$id == pass.id!) - .with(\.$pass) - .first() + + func encodePersonalization( + for pass: P, db: any Database, encoder: JSONEncoder + ) async throws -> Data? { + guard + let passData = try await PassData.query(on: db) + .filter(\.$pass.$id == pass.id!) + .with(\.$pass) + .first() else { throw Abort(.internalServerError) } if passData.title != "Personalize" { return nil } - + if try await passData.pass.$userPersonalization.get(on: db) == nil { guard let data = try? encoder.encode(PersonalizationJSONData()) else { throw Abort(.internalServerError) } return data - } else { return nil } + } else { + return nil + } } func template(for pass: P, db: any Database) async throws -> URL { URL( - fileURLWithPath: "\(FileManager.default.currentDirectoryPath)/Tests/PassesTests/Templates/", + fileURLWithPath: + "\(FileManager.default.currentDirectoryPath)/Tests/PassesTests/Templates/", isDirectory: true ) } -} \ No newline at end of file +} diff --git a/Tests/PassesTests/EncryptedPassesTests.swift b/Tests/PassesTests/EncryptedPassesTests.swift index e2e473d..971f850 100644 --- a/Tests/PassesTests/EncryptedPassesTests.swift +++ b/Tests/PassesTests/EncryptedPassesTests.swift @@ -1,10 +1,11 @@ -import XCTVapor import Fluent import FluentSQLiteDriver -@testable import Passes import PassKit +import XCTVapor import Zip +@testable import Passes + final class EncryptedPassesTests: XCTestCase { let delegate = EncryptedPassesDelegate() let passesURI = "/api/passes/v1/" @@ -14,7 +15,7 @@ final class EncryptedPassesTests: XCTestCase { override func setUp() async throws { self.app = try await Application.make(.testing) app.databases.use(.sqlite(.memory), as: .sqlite) - + PassesService.register(migrations: app.migrations) app.migrations.add(CreatePassData()) passesService = try PassesService( @@ -30,7 +31,7 @@ final class EncryptedPassesTests: XCTestCase { Zip.addCustomFileExtension("pkpass") } - override func tearDown() async throws { + override func tearDown() async throws { try await app.autoRevert() try await self.app.asyncShutdown() self.app = nil @@ -47,14 +48,18 @@ final class EncryptedPassesTests: XCTestCase { XCTAssert(FileManager.default.fileExists(atPath: passFolder.path.appending("/signature"))) - let passJSONData = try String(contentsOfFile: passFolder.path.appending("/pass.json")).data(using: .utf8) + let passJSONData = try String(contentsOfFile: passFolder.path.appending("/pass.json")).data( + using: .utf8) let passJSON = try JSONSerialization.jsonObject(with: passJSONData!) as! [String: Any] XCTAssertEqual(passJSON["authenticationToken"] as? String, pass.authenticationToken) try XCTAssertEqual(passJSON["serialNumber"] as? String, pass.requireID().uuidString) XCTAssertEqual(passJSON["description"] as? String, passData.title) - let manifestJSONData = try String(contentsOfFile: passFolder.path.appending("/manifest.json")).data(using: .utf8) - let manifestJSON = try JSONSerialization.jsonObject(with: manifestJSONData!) as! [String: Any] + let manifestJSONData = try String( + contentsOfFile: passFolder.path.appending("/manifest.json") + ).data(using: .utf8) + let manifestJSON = + try JSONSerialization.jsonObject(with: manifestJSONData!) as! [String: Any] let iconData = try Data(contentsOf: passFolder.appendingPathComponent("/icon.png")) let iconHash = Array(Insecure.SHA1.hash(data: iconData)).hex XCTAssertEqual(manifestJSON["icon.png"] as? String, iconHash) @@ -71,7 +76,7 @@ final class EncryptedPassesTests: XCTestCase { familyName: "Doe", fullName: "John Doe", givenName: "John", - ISOCountryCode: "US", + isoCountryCode: "US", phoneNumber: "1234567890", postalCode: "12345" ) @@ -97,13 +102,27 @@ final class EncryptedPassesTests: XCTestCase { ._$userPersonalization.get(on: app.db)? .requireID() XCTAssertEqual(personalizationQuery[0]._$id.value, passPersonalizationID) - XCTAssertEqual(personalizationQuery[0]._$emailAddress.value, personalizationDict.requiredPersonalizationInfo.emailAddress) - XCTAssertEqual(personalizationQuery[0]._$familyName.value, personalizationDict.requiredPersonalizationInfo.familyName) - XCTAssertEqual(personalizationQuery[0]._$fullName.value, personalizationDict.requiredPersonalizationInfo.fullName) - XCTAssertEqual(personalizationQuery[0]._$givenName.value, personalizationDict.requiredPersonalizationInfo.givenName) - XCTAssertEqual(personalizationQuery[0]._$ISOCountryCode.value, personalizationDict.requiredPersonalizationInfo.ISOCountryCode) - XCTAssertEqual(personalizationQuery[0]._$phoneNumber.value, personalizationDict.requiredPersonalizationInfo.phoneNumber) - XCTAssertEqual(personalizationQuery[0]._$postalCode.value, personalizationDict.requiredPersonalizationInfo.postalCode) + XCTAssertEqual( + personalizationQuery[0]._$emailAddress.value, + personalizationDict.requiredPersonalizationInfo.emailAddress) + XCTAssertEqual( + personalizationQuery[0]._$familyName.value, + personalizationDict.requiredPersonalizationInfo.familyName) + XCTAssertEqual( + personalizationQuery[0]._$fullName.value, + personalizationDict.requiredPersonalizationInfo.fullName) + XCTAssertEqual( + personalizationQuery[0]._$givenName.value, + personalizationDict.requiredPersonalizationInfo.givenName) + XCTAssertEqual( + personalizationQuery[0]._$isoCountryCode.value, + personalizationDict.requiredPersonalizationInfo.isoCountryCode) + XCTAssertEqual( + personalizationQuery[0]._$phoneNumber.value, + personalizationDict.requiredPersonalizationInfo.phoneNumber) + XCTAssertEqual( + personalizationQuery[0]._$postalCode.value, + personalizationDict.requiredPersonalizationInfo.postalCode) } func testAPNSClient() async throws { @@ -112,8 +131,9 @@ final class EncryptedPassesTests: XCTestCase { let passData = PassData(title: "Test Pass") try await passData.create(on: app.db) let pass = try await passData._$pass.get(on: app.db) - - try await passesService.sendPushNotificationsForPass(id: pass.requireID(), of: pass.passTypeIdentifier, on: app.db) + + try await passesService.sendPushNotificationsForPass( + id: pass.requireID(), of: pass.passTypeIdentifier, on: app.db) let deviceLibraryIdentifier = "abcdefg" let pushToken = "1234567890" @@ -138,7 +158,7 @@ final class EncryptedPassesTests: XCTestCase { XCTAssertEqual(res.status, .created) } ) - + try await app.test( .POST, "\(passesURI)push/\(pass.passTypeIdentifier)/\(pass.requireID())", @@ -147,11 +167,11 @@ final class EncryptedPassesTests: XCTestCase { XCTAssertEqual(res.status, .internalServerError) } ) - + // Test `PassDataMiddleware` update method passData.title = "Test Pass 2" do { try await passData.update(on: app.db) } catch {} } -} \ No newline at end of file +} diff --git a/Tests/PassesTests/PassData.swift b/Tests/PassesTests/PassData.swift index b25b05e..bf96087 100644 --- a/Tests/PassesTests/PassData.swift +++ b/Tests/PassesTests/PassData.swift @@ -1,27 +1,28 @@ import Fluent -import struct Foundation.UUID import Passes import Vapor +import struct Foundation.UUID + final class PassData: PassDataModel, @unchecked Sendable { static let schema = PassData.FieldKeys.schemaName - + @ID(key: .id) var id: UUID? @Field(key: PassData.FieldKeys.title) var title: String - + @Parent(key: PassData.FieldKeys.passID) var pass: Pass - init() { } + init() {} init(id: UUID? = nil, title: String) { self.id = id self.title = title } - + func toDTO() -> PassDataDTO { .init( id: self.id, @@ -33,10 +34,10 @@ final class PassData: PassDataModel, @unchecked Sendable { struct PassDataDTO: Content { var id: UUID? var title: String? - + func toModel() -> PassData { let model = PassData() - + model.id = self.id if let title = self.title { model.title = title @@ -50,7 +51,10 @@ struct CreatePassData: AsyncMigration { try await database.schema(PassData.FieldKeys.schemaName) .id() .field(PassData.FieldKeys.title, .string, .required) - .field(PassData.FieldKeys.passID, .uuid, .required, .references(Pass.schema, .id, onDelete: .cascade)) + .field( + PassData.FieldKeys.passID, .uuid, .required, + .references(Pass.schema, .id, onDelete: .cascade) + ) .create() } @@ -81,14 +85,14 @@ struct PassJSONData: PassJSON.Properties { private let sharingProhibited = true let backgroundColor = "rgb(207, 77, 243)" let foregroundColor = "rgb(255, 255, 255)" - + let barcodes = Barcode(message: "test") struct Barcode: PassJSON.Barcodes { let format = PassJSON.BarcodeFormat.qr let message: String let messageEncoding = "iso-8859-1" } - + let boardingPass = Boarding(transitType: .air) struct Boarding: PassJSON.BoardingPass { let transitType: PassJSON.TransitType @@ -126,7 +130,7 @@ struct PersonalizationJSONData: PersonalizationJSON.Properties { PersonalizationJSON.PersonalizationField.name, PersonalizationJSON.PersonalizationField.postalCode, PersonalizationJSON.PersonalizationField.emailAddress, - PersonalizationJSON.PersonalizationField.phoneNumber + PersonalizationJSON.PersonalizationField.phoneNumber, ] var description = "Hello, World!" } @@ -138,7 +142,9 @@ struct PassDataMiddleware: AsyncModelMiddleware { self.service = service } - func create(model: PassData, on db: any Database, next: any AnyAsyncModelResponder) async throws { + func create( + model: PassData, on db: any Database, next: any AnyAsyncModelResponder + ) async throws { let pass = Pass( passTypeIdentifier: "pass.com.vapor-community.PassKit", authenticationToken: Data([UInt8].random(count: 12)).base64EncodedString()) @@ -147,7 +153,9 @@ struct PassDataMiddleware: AsyncModelMiddleware { try await next.create(model, on: db) } - func update(model: PassData, on db: any Database, next: any AnyAsyncModelResponder) async throws { + func update( + model: PassData, on db: any Database, next: any AnyAsyncModelResponder + ) async throws { let pass = try await model.$pass.get(on: db) pass.updatedAt = Date() try await pass.save(on: db) diff --git a/Tests/PassesTests/PassesTests.swift b/Tests/PassesTests/PassesTests.swift index b1d7067..6720847 100644 --- a/Tests/PassesTests/PassesTests.swift +++ b/Tests/PassesTests/PassesTests.swift @@ -1,20 +1,21 @@ -import XCTVapor import Fluent import FluentSQLiteDriver -@testable import Passes import PassKit +import XCTVapor import Zip +@testable import Passes + final class PassesTests: XCTestCase { let delegate = TestPassesDelegate() let passesURI = "/api/passes/v1/" var passesService: PassesService! var app: Application! - + override func setUp() async throws { self.app = try await Application.make(.testing) app.databases.use(.sqlite(.memory), as: .sqlite) - + PassesService.register(migrations: app.migrations) app.migrations.add(CreatePassData()) passesService = try PassesService( @@ -30,7 +31,7 @@ final class PassesTests: XCTestCase { Zip.addCustomFileExtension("pkpass") } - override func tearDown() async throws { + override func tearDown() async throws { try await app.autoRevert() try await self.app.asyncShutdown() self.app = nil @@ -47,17 +48,23 @@ final class PassesTests: XCTestCase { XCTAssert(FileManager.default.fileExists(atPath: passFolder.path.appending("/signature"))) - let passJSONData = try String(contentsOfFile: passFolder.path.appending("/pass.json")).data(using: .utf8) + let passJSONData = try String(contentsOfFile: passFolder.path.appending("/pass.json")).data( + using: .utf8) let passJSON = try JSONSerialization.jsonObject(with: passJSONData!) as! [String: Any] XCTAssertEqual(passJSON["authenticationToken"] as? String, pass.authenticationToken) try XCTAssertEqual(passJSON["serialNumber"] as? String, pass.requireID().uuidString) XCTAssertEqual(passJSON["description"] as? String, passData.title) - let manifestJSONData = try String(contentsOfFile: passFolder.path.appending("/manifest.json")).data(using: .utf8) - let manifestJSON = try JSONSerialization.jsonObject(with: manifestJSONData!) as! [String: Any] + let manifestJSONData = try String( + contentsOfFile: passFolder.path.appending("/manifest.json") + ).data(using: .utf8) + let manifestJSON = + try JSONSerialization.jsonObject(with: manifestJSONData!) as! [String: Any] let iconData = try Data(contentsOf: passFolder.appendingPathComponent("/icon.png")) let iconHash = Array(Insecure.SHA1.hash(data: iconData)).hex XCTAssertEqual(manifestJSON["icon.png"] as? String, iconHash) + XCTAssertNotNil(manifestJSON["logo.png"]) + XCTAssertNotNil(manifestJSON["personalizationLogo.png"]) } func testPassesGeneration() async throws { @@ -71,6 +78,13 @@ final class PassesTests: XCTestCase { let data = try await passesService.generatePassesContent(for: [pass1, pass2], on: app.db) XCTAssertNotNil(data) + + do { + let data = try await passesService.generatePassesContent(for: [pass1], on: app.db) + XCTFail("Expected error, got \(data)") + } catch let error as PassesError { + XCTAssertEqual(error, .invalidNumberOfPasses) + } } func testPersonalization() async throws { @@ -82,19 +96,27 @@ final class PassesTests: XCTestCase { try data.write(to: passURL) let passFolder = try Zip.quickUnzipFile(passURL) - let passJSONData = try String(contentsOfFile: passFolder.path.appending("/pass.json")).data(using: .utf8) + let passJSONData = try String(contentsOfFile: passFolder.path.appending("/pass.json")).data( + using: .utf8) let passJSON = try JSONSerialization.jsonObject(with: passJSONData!) as! [String: Any] XCTAssertEqual(passJSON["authenticationToken"] as? String, pass.authenticationToken) try XCTAssertEqual(passJSON["serialNumber"] as? String, pass.requireID().uuidString) XCTAssertEqual(passJSON["description"] as? String, passData.title) - let personalizationJSONData = try String(contentsOfFile: passFolder.path.appending("/personalization.json")).data(using: .utf8) - let personalizationJSON = try JSONSerialization.jsonObject(with: personalizationJSONData!) as! [String: Any] + let personalizationJSONData = try String( + contentsOfFile: passFolder.path.appending("/personalization.json") + ).data(using: .utf8) + let personalizationJSON = + try JSONSerialization.jsonObject(with: personalizationJSONData!) as! [String: Any] XCTAssertEqual(personalizationJSON["description"] as? String, "Hello, World!") - let manifestJSONData = try String(contentsOfFile: passFolder.path.appending("/manifest.json")).data(using: .utf8) - let manifestJSON = try JSONSerialization.jsonObject(with: manifestJSONData!) as! [String: Any] - let iconData = try Data(contentsOf: passFolder.appendingPathComponent("/personalizationLogo.png")) + let manifestJSONData = try String( + contentsOfFile: passFolder.path.appending("/manifest.json") + ).data(using: .utf8) + let manifestJSON = + try JSONSerialization.jsonObject(with: manifestJSONData!) as! [String: Any] + let iconData = try Data( + contentsOf: passFolder.appendingPathComponent("/personalizationLogo.png")) let iconHash = Array(Insecure.SHA1.hash(data: iconData)).hex XCTAssertEqual(manifestJSON["personalizationLogo.png"] as? String, iconHash) } @@ -110,7 +132,7 @@ final class PassesTests: XCTestCase { "\(passesURI)passes/\(pass.passTypeIdentifier)/\(pass.requireID())", headers: [ "Authorization": "ApplePass \(pass.authenticationToken)", - "If-Modified-Since": "0" + "If-Modified-Since": "0", ], afterResponse: { res async throws in XCTAssertEqual(res.status, .ok) @@ -126,7 +148,7 @@ final class PassesTests: XCTestCase { "\(passesURI)passes/\(pass.passTypeIdentifier)/\(pass.requireID())", headers: [ "Authorization": "ApplePass invalid-token", - "If-Modified-Since": "0" + "If-Modified-Since": "0", ], afterResponse: { res async throws in XCTAssertEqual(res.status, .unauthorized) @@ -139,7 +161,7 @@ final class PassesTests: XCTestCase { "\(passesURI)passes/\(pass.passTypeIdentifier)/\(pass.requireID())", headers: [ "Authorization": "ApplePass \(pass.authenticationToken)", - "If-Modified-Since": "2147483647" + "If-Modified-Since": "2147483647", ], afterResponse: { res async throws in XCTAssertEqual(res.status, .notModified) @@ -152,7 +174,7 @@ final class PassesTests: XCTestCase { "\(passesURI)passes/\(pass.passTypeIdentifier)/invalid-uuid", headers: [ "Authorization": "ApplePass \(pass.authenticationToken)", - "If-Modified-Since": "0" + "If-Modified-Since": "0", ], afterResponse: { res async throws in XCTAssertEqual(res.status, .badRequest) @@ -165,7 +187,7 @@ final class PassesTests: XCTestCase { "\(passesURI)passes/pass.com.example.InvalidType/\(pass.requireID())", headers: [ "Authorization": "ApplePass \(pass.authenticationToken)", - "If-Modified-Since": "0" + "If-Modified-Since": "0", ], afterResponse: { res async throws in XCTAssertEqual(res.status, .notFound) @@ -184,7 +206,7 @@ final class PassesTests: XCTestCase { familyName: "Doe", fullName: "John Doe", givenName: "John", - ISOCountryCode: "US", + isoCountryCode: "US", phoneNumber: "1234567890", postalCode: "12345" ) @@ -209,13 +231,27 @@ final class PassesTests: XCTestCase { ._$userPersonalization.get(on: app.db)? .requireID() XCTAssertEqual(personalizationQuery[0]._$id.value, passPersonalizationID) - XCTAssertEqual(personalizationQuery[0]._$emailAddress.value, personalizationDict.requiredPersonalizationInfo.emailAddress) - XCTAssertEqual(personalizationQuery[0]._$familyName.value, personalizationDict.requiredPersonalizationInfo.familyName) - XCTAssertEqual(personalizationQuery[0]._$fullName.value, personalizationDict.requiredPersonalizationInfo.fullName) - XCTAssertEqual(personalizationQuery[0]._$givenName.value, personalizationDict.requiredPersonalizationInfo.givenName) - XCTAssertEqual(personalizationQuery[0]._$ISOCountryCode.value, personalizationDict.requiredPersonalizationInfo.ISOCountryCode) - XCTAssertEqual(personalizationQuery[0]._$phoneNumber.value, personalizationDict.requiredPersonalizationInfo.phoneNumber) - XCTAssertEqual(personalizationQuery[0]._$postalCode.value, personalizationDict.requiredPersonalizationInfo.postalCode) + XCTAssertEqual( + personalizationQuery[0]._$emailAddress.value, + personalizationDict.requiredPersonalizationInfo.emailAddress) + XCTAssertEqual( + personalizationQuery[0]._$familyName.value, + personalizationDict.requiredPersonalizationInfo.familyName) + XCTAssertEqual( + personalizationQuery[0]._$fullName.value, + personalizationDict.requiredPersonalizationInfo.fullName) + XCTAssertEqual( + personalizationQuery[0]._$givenName.value, + personalizationDict.requiredPersonalizationInfo.givenName) + XCTAssertEqual( + personalizationQuery[0]._$isoCountryCode.value, + personalizationDict.requiredPersonalizationInfo.isoCountryCode) + XCTAssertEqual( + personalizationQuery[0]._$phoneNumber.value, + personalizationDict.requiredPersonalizationInfo.phoneNumber) + XCTAssertEqual( + personalizationQuery[0]._$postalCode.value, + personalizationDict.requiredPersonalizationInfo.postalCode) // Test call with invalid pass ID try await app.test( @@ -249,6 +285,23 @@ final class PassesTests: XCTestCase { let deviceLibraryIdentifier = "abcdefg" let pushToken = "1234567890" + try await app.test( + .GET, + "\(passesURI)devices/\(deviceLibraryIdentifier)/registrations/\(pass.passTypeIdentifier)?passesUpdatedSince=0", + afterResponse: { res async throws in + XCTAssertEqual(res.status, .noContent) + } + ) + + try await app.test( + .DELETE, + "\(passesURI)devices/\(deviceLibraryIdentifier)/registrations/\(pass.passTypeIdentifier)/\(pass.requireID())", + headers: ["Authorization": "ApplePass \(pass.authenticationToken)"], + afterResponse: { res async throws in + XCTAssertEqual(res.status, .notFound) + } + ) + // Test registration without authentication token try await app.test( .POST, @@ -261,6 +314,42 @@ final class PassesTests: XCTestCase { } ) + // Test registration of a non-existing pass + try await app.test( + .POST, + "\(passesURI)devices/\(deviceLibraryIdentifier)/registrations/\("pass.com.example.NotFound")/\(UUID().uuidString)", + headers: ["Authorization": "ApplePass \(pass.authenticationToken)"], + beforeRequest: { req async throws in + try req.content.encode(RegistrationDTO(pushToken: pushToken)) + }, + afterResponse: { res async throws in + XCTAssertEqual(res.status, .notFound) + } + ) + + // Test call without DTO + try await app.test( + .POST, + "\(passesURI)devices/\(deviceLibraryIdentifier)/registrations/\(pass.passTypeIdentifier)/\(pass.requireID())", + headers: ["Authorization": "ApplePass \(pass.authenticationToken)"], + afterResponse: { res async throws in + XCTAssertEqual(res.status, .badRequest) + } + ) + + // Test call with invalid UUID + try await app.test( + .POST, + "\(passesURI)devices/\(deviceLibraryIdentifier)/registrations/\(pass.passTypeIdentifier)/\("not-a-uuid")", + headers: ["Authorization": "ApplePass \(pass.authenticationToken)"], + beforeRequest: { req async throws in + try req.content.encode(RegistrationDTO(pushToken: pushToken)) + }, + afterResponse: { res async throws in + XCTAssertEqual(res.status, .badRequest) + } + ) + try await app.test( .POST, "\(passesURI)devices/\(deviceLibraryIdentifier)/registrations/\(pass.passTypeIdentifier)/\(pass.requireID())", @@ -309,6 +398,26 @@ final class PassesTests: XCTestCase { } ) + // Test call with invalid UUID + try await app.test( + .GET, + "\(passesURI)push/\(pass.passTypeIdentifier)/\("not-a-uuid")", + headers: ["X-Secret": "foo"], + afterResponse: { res async throws in + XCTAssertEqual(res.status, .badRequest) + } + ) + + // Test call with invalid UUID + try await app.test( + .DELETE, + "\(passesURI)devices/\(deviceLibraryIdentifier)/registrations/\(pass.passTypeIdentifier)/\("not-a-uuid")", + headers: ["Authorization": "ApplePass \(pass.authenticationToken)"], + afterResponse: { res async throws in + XCTAssertEqual(res.status, .badRequest) + } + ) + try await app.test( .DELETE, "\(passesURI)devices/\(deviceLibraryIdentifier)/registrations/\(pass.passTypeIdentifier)/\(pass.requireID())", @@ -368,7 +477,8 @@ final class PassesTests: XCTestCase { try await passData.create(on: app.db) let pass = try await passData._$pass.get(on: app.db) - try await passesService.sendPushNotificationsForPass(id: pass.requireID(), of: pass.passTypeIdentifier, on: app.db) + try await passesService.sendPushNotificationsForPass( + id: pass.requireID(), of: pass.passTypeIdentifier, on: app.db) let deviceLibraryIdentifier = "abcdefg" let pushToken = "1234567890" @@ -403,21 +513,41 @@ final class PassesTests: XCTestCase { } ) + // Test call with invalid UUID + try await app.test( + .POST, + "\(passesURI)push/\(pass.passTypeIdentifier)/\("not-a-uuid")", + headers: ["X-Secret": "foo"], + afterResponse: { res async throws in + XCTAssertEqual(res.status, .badRequest) + } + ) + // Test `PassDataMiddleware` update method passData.title = "Test Pass 2" do { try await passData.update(on: app.db) } catch let error as HTTPClientError { - XCTAssertEqual(error.self, .remoteConnectionClosed) + XCTAssertEqual(error.self, .remoteConnectionClosed) } } func testPassesError() { - XCTAssertEqual(PassesError.templateNotDirectory.description, "PassesError(errorType: templateNotDirectory)") - XCTAssertEqual(PassesError.pemCertificateMissing.description, "PassesError(errorType: pemCertificateMissing)") - XCTAssertEqual(PassesError.pemPrivateKeyMissing.description, "PassesError(errorType: pemPrivateKeyMissing)") - XCTAssertEqual(PassesError.opensslBinaryMissing.description, "PassesError(errorType: opensslBinaryMissing)") - XCTAssertEqual(PassesError.invalidNumberOfPasses.description, "PassesError(errorType: invalidNumberOfPasses)") + XCTAssertEqual( + PassesError.templateNotDirectory.description, + "PassesError(errorType: templateNotDirectory)") + XCTAssertEqual( + PassesError.pemCertificateMissing.description, + "PassesError(errorType: pemCertificateMissing)") + XCTAssertEqual( + PassesError.pemPrivateKeyMissing.description, + "PassesError(errorType: pemPrivateKeyMissing)") + XCTAssertEqual( + PassesError.opensslBinaryMissing.description, + "PassesError(errorType: opensslBinaryMissing)") + XCTAssertEqual( + PassesError.invalidNumberOfPasses.description, + "PassesError(errorType: invalidNumberOfPasses)") } func testDefaultDelegate() async throws { @@ -432,13 +562,20 @@ final class PassesTests: XCTestCase { let passData = PassData(title: "Test Pass") try await passData.create(on: app.db) let pass = try await passData.$pass.get(on: app.db) - let data = try await delegate.encodePersonalization(for: pass, db: app.db, encoder: JSONEncoder()) + let data = try await delegate.encodePersonalization( + for: pass, db: app.db, encoder: JSONEncoder()) XCTAssertNil(data) } } final class DefaultPassesDelegate: PassesDelegate { let sslSigningFilesDirectory = URL(fileURLWithPath: "", isDirectory: true) - func template(for pass: P, db: any Database) async throws -> URL { URL(fileURLWithPath: "") } - func encode(pass: P, db: any Database, encoder: JSONEncoder) async throws -> Data { Data() } + func template(for pass: P, db: any Database) async throws -> URL { + URL(fileURLWithPath: "") + } + func encode( + pass: P, db: any Database, encoder: JSONEncoder + ) async throws -> Data { + Data() + } } diff --git a/Tests/PassesTests/SecretMiddleware.swift b/Tests/PassesTests/SecretMiddleware.swift index 7dadd9e..fef1940 100644 --- a/Tests/PassesTests/SecretMiddleware.swift +++ b/Tests/PassesTests/SecretMiddleware.swift @@ -3,11 +3,9 @@ import Vapor struct SecretMiddleware: AsyncMiddleware { let secret: String - init(secret: String) { - self.secret = secret - } - - func respond(to request: Request, chainingTo next: any AsyncResponder) async throws -> Response { + func respond( + to request: Request, chainingTo next: any AsyncResponder + ) async throws -> Response { guard request.headers.first(name: "X-Secret") == secret else { throw Abort(.unauthorized, reason: "Incorrect X-Secret header.") } diff --git a/Tests/PassesTests/Templates/EmptyDir/.gitkeep b/Tests/PassesTests/Templates/EmptyDir/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/Tests/PassesTests/TestPassesDelegate.swift b/Tests/PassesTests/TestPassesDelegate.swift index 51c3429..4b4fc87 100644 --- a/Tests/PassesTests/TestPassesDelegate.swift +++ b/Tests/PassesTests/TestPassesDelegate.swift @@ -1,6 +1,6 @@ -import Vapor import FluentKit import Passes +import Vapor final class TestPassesDelegate: PassesDelegate { let sslSigningFilesDirectory = URL( @@ -11,42 +11,52 @@ final class TestPassesDelegate: PassesDelegate { let pemCertificate = "certificate.pem" let pemPrivateKey = "key.pem" - func encode(pass: P, db: any Database, encoder: JSONEncoder) async throws -> Data { - guard let passData = try await PassData.query(on: db) - .filter(\.$pass.$id == pass.requireID()) - .with(\.$pass) - .first() + func encode( + pass: P, db: any Database, encoder: JSONEncoder + ) async throws -> Data { + guard + let passData = try await PassData.query(on: db) + .filter(\.$pass.$id == pass.requireID()) + .with(\.$pass) + .first() else { throw Abort(.internalServerError) } - guard let data = try? encoder.encode(PassJSONData(data: passData, pass: passData.pass)) else { + guard let data = try? encoder.encode(PassJSONData(data: passData, pass: passData.pass)) + else { throw Abort(.internalServerError) } return data } - - func encodePersonalization(for pass: P, db: any Database, encoder: JSONEncoder) async throws -> Data? { - guard let passData = try await PassData.query(on: db) - .filter(\.$pass.$id == pass.id!) - .with(\.$pass) - .first() + + func encodePersonalization( + for pass: P, db: any Database, encoder: JSONEncoder + ) async throws -> Data? { + guard + let passData = try await PassData.query(on: db) + .filter(\.$pass.$id == pass.id!) + .with(\.$pass) + .first() else { throw Abort(.internalServerError) } if passData.title != "Personalize" { return nil } - + if try await passData.pass.$userPersonalization.get(on: db) == nil { guard let data = try? encoder.encode(PersonalizationJSONData()) else { throw Abort(.internalServerError) } return data - } else { return nil } + } else { + return nil + } } func template(for pass: P, db: any Database) async throws -> URL { URL( - fileURLWithPath: "\(FileManager.default.currentDirectoryPath)/Tests/PassesTests/Templates/", + fileURLWithPath: + "\(FileManager.default.currentDirectoryPath)/Tests/PassesTests/Templates/", isDirectory: true ) }