diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2054aea..dbddd19 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -11,5 +11,6 @@ jobs: uses: vapor/ci/.github/workflows/run-unit-tests.yml@main with: with_linting: true + test_filter: --no-parallel secrets: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} diff --git a/Package.swift b/Package.swift index 4ce018b..2a5323a 100644 --- a/Package.swift +++ b/Package.swift @@ -11,11 +11,11 @@ let package = Package( .library(name: "Orders", targets: ["Orders"]), ], dependencies: [ - .package(url: "https://github.com/vapor/vapor.git", from: "4.106.0"), + .package(url: "https://github.com/vapor/vapor.git", from: "4.106.1"), .package(url: "https://github.com/vapor/fluent.git", from: "4.12.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.3"), - .package(url: "https://github.com/apple/swift-certificates.git", from: "1.5.0"), + .package(url: "https://github.com/apple/swift-certificates.git", from: "1.6.1"), // used in tests .package(url: "https://github.com/vapor/fluent-sqlite-driver.git", from: "4.8.0"), ], diff --git a/Sources/Orders/Models/Concrete Models/Order.swift b/Sources/Orders/Models/Concrete Models/Order.swift index 2430a2d..8cf29ef 100644 --- a/Sources/Orders/Models/Concrete Models/Order.swift +++ b/Sources/Orders/Models/Concrete Models/Order.swift @@ -28,8 +28,8 @@ final public class Order: OrderModel, @unchecked Sendable { public var updatedAt: Date? /// An identifier for the order type associated with the order. - @Field(key: Order.FieldKeys.orderTypeIdentifier) - public var orderTypeIdentifier: String + @Field(key: Order.FieldKeys.typeIdentifier) + public var typeIdentifier: String /// The authentication token supplied to your web service. @Field(key: Order.FieldKeys.authenticationToken) @@ -37,8 +37,8 @@ final public class Order: OrderModel, @unchecked Sendable { public required init() {} - public required init(orderTypeIdentifier: String, authenticationToken: String) { - self.orderTypeIdentifier = orderTypeIdentifier + public required init(typeIdentifier: String, authenticationToken: String) { + self.typeIdentifier = typeIdentifier self.authenticationToken = authenticationToken } } @@ -49,7 +49,7 @@ extension Order: AsyncMigration { .id() .field(Order.FieldKeys.createdAt, .datetime, .required) .field(Order.FieldKeys.updatedAt, .datetime, .required) - .field(Order.FieldKeys.orderTypeIdentifier, .string, .required) + .field(Order.FieldKeys.typeIdentifier, .string, .required) .field(Order.FieldKeys.authenticationToken, .string, .required) .create() } @@ -64,7 +64,7 @@ extension Order { static let schemaName = "orders" static let createdAt = FieldKey(stringLiteral: "created_at") static let updatedAt = FieldKey(stringLiteral: "updated_at") - static let orderTypeIdentifier = FieldKey(stringLiteral: "order_type_identifier") + static let typeIdentifier = FieldKey(stringLiteral: "type_identifier") static let authenticationToken = FieldKey(stringLiteral: "authentication_token") } } diff --git a/Sources/Orders/Models/Concrete Models/OrdersDevice.swift b/Sources/Orders/Models/Concrete Models/OrdersDevice.swift index eab4e47..b739e94 100644 --- a/Sources/Orders/Models/Concrete Models/OrdersDevice.swift +++ b/Sources/Orders/Models/Concrete Models/OrdersDevice.swift @@ -21,11 +21,11 @@ final public class OrdersDevice: DeviceModel, @unchecked Sendable { public var pushToken: String /// The identifier Apple Wallet provides for the device. - @Field(key: OrdersDevice.FieldKeys.deviceLibraryIdentifier) - public var deviceLibraryIdentifier: String + @Field(key: OrdersDevice.FieldKeys.libraryIdentifier) + public var libraryIdentifier: String - public init(deviceLibraryIdentifier: String, pushToken: String) { - self.deviceLibraryIdentifier = deviceLibraryIdentifier + public init(libraryIdentifier: String, pushToken: String) { + self.libraryIdentifier = libraryIdentifier self.pushToken = pushToken } @@ -37,10 +37,8 @@ extension OrdersDevice: AsyncMigration { try await database.schema(Self.schema) .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 - ) + .field(OrdersDevice.FieldKeys.libraryIdentifier, .string, .required) + .unique(on: OrdersDevice.FieldKeys.pushToken, OrdersDevice.FieldKeys.libraryIdentifier) .create() } @@ -53,6 +51,6 @@ extension OrdersDevice { enum FieldKeys { static let schemaName = "orders_devices" static let pushToken = FieldKey(stringLiteral: "push_token") - static let deviceLibraryIdentifier = FieldKey(stringLiteral: "device_library_identifier") + static let libraryIdentifier = FieldKey(stringLiteral: "library_identifier") } } diff --git a/Sources/Orders/Models/OrderModel.swift b/Sources/Orders/Models/OrderModel.swift index a9045fd..92cbd4f 100644 --- a/Sources/Orders/Models/OrderModel.swift +++ b/Sources/Orders/Models/OrderModel.swift @@ -13,7 +13,7 @@ import Foundation /// Uses a UUID so people can't easily guess order IDs. public protocol OrderModel: Model where IDValue == UUID { /// An identifier for the order type associated with the order. - var orderTypeIdentifier: String { get set } + var typeIdentifier: String { get set } /// The date and time when the customer created the order. var createdAt: Date? { get set } @@ -36,14 +36,14 @@ extension OrderModel { return id } - var _$orderTypeIdentifier: Field { - guard let mirror = Mirror(reflecting: self).descendant("_orderTypeIdentifier"), - let orderTypeIdentifier = mirror as? Field + var _$typeIdentifier: Field { + guard let mirror = Mirror(reflecting: self).descendant("_typeIdentifier"), + let typeIdentifier = mirror as? Field else { - fatalError("orderTypeIdentifier property must be declared using @Field") + fatalError("typeIdentifier property must be declared using @Field") } - return orderTypeIdentifier + return typeIdentifier } var _$updatedAt: Timestamp { diff --git a/Sources/Orders/Models/OrdersRegistrationModel.swift b/Sources/Orders/Models/OrdersRegistrationModel.swift index 6550627..22a8708 100644 --- a/Sources/Orders/Models/OrdersRegistrationModel.swift +++ b/Sources/Orders/Models/OrdersRegistrationModel.swift @@ -41,15 +41,13 @@ extension OrdersRegistrationModel { return order } - static func `for`( - deviceLibraryIdentifier: String, orderTypeIdentifier: String, on db: any Database - ) -> QueryBuilder { + static func `for`(deviceLibraryIdentifier: String, typeIdentifier: String, on db: any Database) -> QueryBuilder { Self.query(on: db) .join(parent: \._$order) .join(parent: \._$device) .with(\._$order) .with(\._$device) - .filter(OrderType.self, \._$orderTypeIdentifier == orderTypeIdentifier) - .filter(DeviceType.self, \._$deviceLibraryIdentifier == deviceLibraryIdentifier) + .filter(OrderType.self, \._$typeIdentifier == typeIdentifier) + .filter(DeviceType.self, \._$libraryIdentifier == deviceLibraryIdentifier) } } diff --git a/Sources/Orders/Orders.docc/GettingStarted.md b/Sources/Orders/Orders.docc/GettingStarted.md index ce70e86..9870283 100644 --- a/Sources/Orders/Orders.docc/GettingStarted.md +++ b/Sources/Orders/Orders.docc/GettingStarted.md @@ -111,12 +111,6 @@ struct OrderJSONData: OrderJSON.Properties { ### Implement the Delegate Create a delegate class that implements ``OrdersDelegate``. -In the ``OrdersDelegate/sslSigningFilesDirectory`` you specify there must be the `WWDR.pem`, `ordercertificate.pem` and `orderkey.pem` files. -If they are named like that you're good to go, otherwise you have to specify the custom name. - -> Tip: Obtaining the three certificates files could be a bit tricky. You could get some guidance from [this guide](https://github.com/alexandercerutti/passkit-generator/wiki/Generating-Certificates) and [this video](https://www.youtube.com/watch?v=rJZdPoXHtzI). Those guides are for Wallet passes, but the process is similar for Wallet orders. - -There are other fields available which have reasonable default values. See ``OrdersDelegate``'s documentation. Because the files for your order's template and the method of encoding might vary by order type, you'll be provided the ``Order`` for those methods. In the ``OrdersDelegate/encode(order:db:encoder:)`` method, you'll want to encode a `struct` that conforms to ``OrderJSON``. @@ -127,12 +121,8 @@ import Fluent import Orders final class OrderDelegate: OrdersDelegate { - let sslSigningFilesDirectory = URL(fileURLWithPath: "Certificates/Orders/", isDirectory: true) - - let pemPrivateKeyPassword: String? = Environment.get("ORDERS_PEM_PRIVATE_KEY_PASSWORD")! - func encode(order: O, db: Database, encoder: JSONEncoder) async throws -> Data { - // The specific OrderData class you use here may vary based on the `order.orderTypeIdentifier` + // The specific OrderData class you use here may vary based on the `order.typeIdentifier` // if you have multiple different types of orders, and thus multiple types of order data. guard let orderData = try await OrderData.query(on: db) .filter(\.$order.$id == order.requireID()) @@ -146,19 +136,21 @@ final class OrderDelegate: OrdersDelegate { return data } - func template(for order: O, db: Database) async throws -> URL { + func template(for order: O, db: Database) async throws -> String { // The location might vary depending on the type of order. - URL(fileURLWithPath: "Templates/Orders/", isDirectory: true) + "Templates/Orders/" } } ``` -> Important: If you have an encrypted PEM private key, you **must** explicitly declare ``OrdersDelegate/pemPrivateKeyPassword`` as a `String?` or Swift will ignore it as it'll think it's a `String` instead. - ### Initialize the Service Next, initialize the ``OrdersService`` inside the `configure.swift` file. This will implement all of the routes that Apple Wallet expects to exist on your server. +In the `signingFilesDirectory` you specify there must be the `WWDR.pem`, `certificate.pem` and `key.pem` files. +If they are named like that you're good to go, otherwise you have to specify the custom name. + +> Tip: Obtaining the three certificates files could be a bit tricky. You could get some guidance from [this guide](https://github.com/alexandercerutti/passkit-generator/wiki/Generating-Certificates) and [this video](https://www.youtube.com/watch?v=rJZdPoXHtzI). Those guides are for Wallet passes, but the process is similar for Wallet orders. ```swift import Fluent @@ -169,7 +161,11 @@ let orderDelegate = OrderDelegate() public func configure(_ app: Application) async throws { ... - let ordersService = try OrdersService(app: app, delegate: orderDelegate) + let ordersService = try OrdersService( + app: app, + delegate: orderDelegate, + signingFilesDirectory: "Certificates/Orders/" + ) } ``` @@ -199,7 +195,16 @@ let orderDelegate = OrderDelegate() public func configure(_ app: Application) async throws { ... - let ordersService = try OrdersServiceCustom(app: app, delegate: orderDelegate) + let ordersService = try OrdersServiceCustom< + MyOrderType, + MyDeviceType, + MyOrdersRegistrationType, + MyErrorLogType + >( + app: app, + delegate: orderDelegate, + signingFilesDirectory: "Certificates/Orders/" + ) } ``` @@ -234,7 +239,7 @@ struct OrderDataMiddleware: AsyncModelMiddleware { // Create the `Order` and add it to the `OrderData` automatically at creation func create(model: OrderData, on db: Database, next: AnyAsyncModelResponder) async throws { let order = Order( - orderTypeIdentifier: Environment.get("ORDER_TYPE_IDENTIFIER")!, + typeIdentifier: Environment.get("ORDER_TYPE_IDENTIFIER")!, authenticationToken: Data([UInt8].random(count: 12)).base64EncodedString()) try await order.save(on: db) model.$order.id = try order.requireID() diff --git a/Sources/Orders/OrdersDelegate.swift b/Sources/Orders/OrdersDelegate.swift index f0b974b..dc40cdc 100644 --- a/Sources/Orders/OrdersDelegate.swift +++ b/Sources/Orders/OrdersDelegate.swift @@ -10,9 +10,10 @@ import Foundation /// The delegate which is responsible for generating the order files. public protocol OrdersDelegate: AnyObject, Sendable { - /// Should return a `URL` which points to the template data for the order. + /// Should return a URL path which points to the template data for the order. /// - /// The URL should point to a directory containing all the images and localizations for the generated `.order` archive but should *not* contain any of these items: + /// The path should point to a directory containing all the images and localizations for the generated `.order` archive + /// but should *not* contain any of these items: /// - `manifest.json` /// - `order.json` /// - `signature` @@ -21,10 +22,8 @@ public protocol OrdersDelegate: AnyObject, Sendable { /// - order: The order data from the SQL server. /// - db: The SQL database to query against. /// - /// - Returns: A `URL` which points to the template data for the order. - /// - /// > Important: Be sure to use the `URL(fileURLWithPath:isDirectory:)` constructor. - func template(for order: O, db: any Database) async throws -> URL + /// - Returns: A URL path which points to the template data for the order. + func template(for order: O, db: any Database) async throws -> String /// Generates the SSL `signature` file. /// @@ -51,62 +50,9 @@ public protocol OrdersDelegate: AnyObject, Sendable { 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. - /// - /// The URL should point to a directory containing the files specified by these keys: - /// - `wwdrCertificate` - /// - `pemCertificate` - /// - `pemPrivateKey` - /// - /// > Important: Be sure to use the `URL(fileURLWithPath:isDirectory:)` initializer! - var sslSigningFilesDirectory: URL { get } - - /// The location of the `openssl` command as a file URL. - /// - /// > 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` - var wwdrCertificate: String { get } - - /// The name of the PEM Certificate for signing the order as contained in `sslSigningFiles` path. - /// - /// Defaults to `ordercertificate.pem` - var pemCertificate: String { get } - - /// The name of the PEM Certificate's private key for signing the order as contained in `sslSigningFiles` path. - /// - /// Defaults to `orderkey.pem` - var pemPrivateKey: String { get } - - /// The password to the private key file. - var pemPrivateKeyPassword: String? { get } } extension OrdersDelegate { - public var wwdrCertificate: String { - return "WWDR.pem" - } - - public var pemCertificate: String { - return "ordercertificate.pem" - } - - public var pemPrivateKey: String { - return "orderkey.pem" - } - - public var pemPrivateKeyPassword: String? { - return nil - } - - public var sslBinary: URL { - return URL(fileURLWithPath: "/usr/bin/openssl") - } - public func generateSignatureFile(in root: URL) -> Bool { return false } diff --git a/Sources/Orders/OrdersService.swift b/Sources/Orders/OrdersService.swift index 2577513..0a9d1e0 100644 --- a/Sources/Orders/OrdersService.swift +++ b/Sources/Orders/OrdersService.swift @@ -17,14 +17,37 @@ public final class OrdersService: Sendable { /// - Parameters: /// - app: The `Vapor.Application` to use in route handlers and APNs. /// - delegate: The ``OrdersDelegate`` to use for order generation. + /// - signingFilesDirectory: The path of the directory where the signing files (`wwdrCertificate`, `pemCertificate`, `pemPrivateKey`) are located. + /// - wwdrCertificate: The name of Apple's WWDR.pem certificate as contained in `signingFilesDirectory` path. Defaults to `WWDR.pem`. + /// - pemCertificate: The name of the PEM Certificate for signing the pass as contained in `signingFilesDirectory` path. Defaults to `certificate.pem`. + /// - pemPrivateKey: The name of the PEM Certificate's private key for signing the pass as contained in `signingFilesDirectory` path. Defaults to `key.pem`. + /// - pemPrivateKeyPassword: The password to the private key file. If the key is not encrypted it must be `nil`. Defaults to `nil`. + /// - sslBinary: The location of the `openssl` command as a file path. /// - 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 + app: Application, + delegate: any OrdersDelegate, + signingFilesDirectory: String, + wwdrCertificate: String = "WWDR.pem", + pemCertificate: String = "certificate.pem", + pemPrivateKey: String = "key.pem", + pemPrivateKeyPassword: String? = nil, + sslBinary: String = "/usr/bin/openssl", + pushRoutesMiddleware: (any Middleware)? = nil, + logger: Logger? = nil ) throws { - service = try .init( - app: app, delegate: delegate, pushRoutesMiddleware: pushRoutesMiddleware, logger: logger + self.service = try .init( + app: app, + delegate: delegate, + signingFilesDirectory: signingFilesDirectory, + wwdrCertificate: wwdrCertificate, + pemCertificate: pemCertificate, + pemPrivateKey: pemPrivateKey, + pemPrivateKeyPassword: pemPrivateKeyPassword, + sslBinary: sslBinary, + pushRoutesMiddleware: pushRoutesMiddleware, + logger: logger ) } @@ -52,12 +75,10 @@ public final class OrdersService: Sendable { /// /// - Parameters: /// - id: The `UUID` of the order to send the notifications for. - /// - orderTypeIdentifier: The type identifier of the order. + /// - typeIdentifier: 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 { - try await service.sendPushNotificationsForOrder(id: id, of: orderTypeIdentifier, on: db) + public func sendPushNotificationsForOrder(id: UUID, of typeIdentifier: String, on db: any Database) async throws { + try await service.sendPushNotificationsForOrder(id: id, of: typeIdentifier, on: db) } /// Sends push notifications for a given order. diff --git a/Sources/Orders/OrdersServiceCustom.swift b/Sources/Orders/OrdersServiceCustom.swift index a650869..4f741f7 100644 --- a/Sources/Orders/OrdersServiceCustom.swift +++ b/Sources/Orders/OrdersServiceCustom.swift @@ -26,44 +26,65 @@ 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) }) + "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) }) + "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) }) } } } // MARK: - API Routes extension OrdersServiceCustom { - func latestVersionOfOrder(req: Request) async throws -> Response { + fileprivate func latestVersionOfOrder(req: Request) async throws -> Response { logger?.debug("Called latestVersionOfOrder") var ifModifiedSince: TimeInterval = 0 @@ -138,7 +153,7 @@ extension OrdersServiceCustom { guard let order = try await O.query(on: req.db) .filter(\._$id == id) - .filter(\._$orderTypeIdentifier == orderTypeIdentifier) + .filter(\._$typeIdentifier == orderTypeIdentifier) .first() else { throw Abort(.notFound) @@ -159,7 +174,7 @@ extension OrdersServiceCustom { ) } - func registerDevice(req: Request) async throws -> HTTPStatus { + fileprivate func registerDevice(req: Request) async throws -> HTTPStatus { logger?.debug("Called register device") let pushToken: String @@ -177,20 +192,20 @@ extension OrdersServiceCustom { guard let order = try await O.query(on: req.db) .filter(\._$id == orderIdentifier) - .filter(\._$orderTypeIdentifier == orderTypeIdentifier) + .filter(\._$typeIdentifier == orderTypeIdentifier) .first() else { throw Abort(.notFound) } let device = try await D.query(on: req.db) - .filter(\._$deviceLibraryIdentifier == deviceIdentifier) + .filter(\._$libraryIdentifier == deviceIdentifier) .filter(\._$pushToken == pushToken) .first() if let device = device { return try await Self.createRegistration(device: device, order: order, db: req.db) } else { - let newDevice = D(deviceLibraryIdentifier: deviceIdentifier, pushToken: pushToken) + let newDevice = D(libraryIdentifier: deviceIdentifier, pushToken: pushToken) try await newDevice.create(on: req.db) return try await Self.createRegistration(device: newDevice, order: order, db: req.db) } @@ -200,8 +215,9 @@ extension OrdersServiceCustom { device: D, order: O, db: any Database ) async throws -> HTTPStatus { let r = try await R.for( - deviceLibraryIdentifier: device.deviceLibraryIdentifier, - orderTypeIdentifier: order.orderTypeIdentifier, on: db + deviceLibraryIdentifier: device.libraryIdentifier, + typeIdentifier: order.typeIdentifier, + on: db ) .filter(O.self, \._$id == order.requireID()) .first() @@ -215,15 +231,17 @@ extension OrdersServiceCustom { return .created } - func ordersForDevice(req: Request) async throws -> OrdersForDeviceDTO { + fileprivate func ordersForDevice(req: Request) async throws -> OrdersForDeviceDTO { logger?.debug("Called ordersForDevice") let orderTypeIdentifier = req.parameters.get("orderTypeIdentifier")! let deviceIdentifier = req.parameters.get("deviceIdentifier")! var query = R.for( - deviceLibraryIdentifier: deviceIdentifier, orderTypeIdentifier: orderTypeIdentifier, - on: req.db) + deviceLibraryIdentifier: deviceIdentifier, + typeIdentifier: orderTypeIdentifier, + on: req.db + ) if let since: TimeInterval = req.query["ordersModifiedSince"] { let when = Date(timeIntervalSince1970: since) query = query.filter(O.self, \._$updatedAt > when) @@ -247,7 +265,7 @@ extension OrdersServiceCustom { return OrdersForDeviceDTO(with: orderIdentifiers, maxDate: maxDate) } - func logError(req: Request) async throws -> HTTPStatus { + fileprivate func logError(req: Request) async throws -> HTTPStatus { logger?.debug("Called logError") let body: ErrorLogDTO @@ -265,7 +283,7 @@ extension OrdersServiceCustom { return .ok } - func unregisterDevice(req: Request) async throws -> HTTPStatus { + fileprivate func unregisterDevice(req: Request) async throws -> HTTPStatus { logger?.debug("Called unregisterDevice") guard let orderIdentifier = req.parameters.get("orderIdentifier", as: UUID.self) else { @@ -276,7 +294,8 @@ extension OrdersServiceCustom { guard let r = try await R.for( - deviceLibraryIdentifier: deviceIdentifier, orderTypeIdentifier: orderTypeIdentifier, + deviceLibraryIdentifier: deviceIdentifier, + typeIdentifier: orderTypeIdentifier, on: req.db ) .filter(O.self, \._$id == orderIdentifier) @@ -289,7 +308,7 @@ extension OrdersServiceCustom { } // MARK: - Push Routes - func pushUpdatesForOrder(req: Request) async throws -> HTTPStatus { + fileprivate func pushUpdatesForOrder(req: Request) async throws -> HTTPStatus { logger?.debug("Called pushUpdatesForOrder") guard let id = req.parameters.get("orderIdentifier", as: UUID.self) else { @@ -301,7 +320,7 @@ extension OrdersServiceCustom { return .noContent } - func tokensForOrderUpdate(req: Request) async throws -> [String] { + fileprivate func tokensForOrderUpdate(req: Request) async throws -> [String] { logger?.debug("Called tokensForOrderUpdate") guard let id = req.parameters.get("orderIdentifier", as: UUID.self) else { @@ -309,8 +328,7 @@ extension OrdersServiceCustom { } let orderTypeIdentifier = req.parameters.get("orderTypeIdentifier")! - return try await Self.registrationsForOrder(id: id, of: orderTypeIdentifier, on: req.db) - .map { $0.device.pushToken } + return try await Self.registrationsForOrder(id: id, of: orderTypeIdentifier, on: req.db).map { $0.device.pushToken } } } @@ -320,17 +338,14 @@ extension OrdersServiceCustom { /// /// - Parameters: /// - id: The `UUID` of the order to send the notifications for. - /// - orderTypeIdentifier: The type identifier of the order. + /// - typeIdentifier: 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 typeIdentifier: String, on db: any Database) async throws { + let registrations = try await Self.registrationsForOrder(id: id, of: typeIdentifier, on: db) for reg in registrations { let backgroundNotification = APNSBackgroundNotification( expiration: .immediately, - topic: reg.order.orderTypeIdentifier, + topic: reg.order.typeIdentifier, payload: EmptyPayload() ) do { @@ -351,13 +366,10 @@ extension OrdersServiceCustom { /// - 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.typeIdentifier, on: db) } - static func registrationsForOrder( - id: UUID, of orderTypeIdentifier: String, on db: any Database - ) async throws -> [R] { + private static func registrationsForOrder(id: UUID, of typeIdentifier: 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) @@ -365,7 +377,7 @@ extension OrdersServiceCustom { .join(parent: \._$device) .with(\._$order) .with(\._$device) - .filter(O.self, \._$orderTypeIdentifier == orderTypeIdentifier) + .filter(O.self, \._$typeIdentifier == typeIdentifier) .filter(O.self, \._$id == id) .all() } @@ -373,18 +385,16 @@ 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.path) for relativePath in paths { let file = URL(fileURLWithPath: relativePath, relativeTo: root) 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() + manifest[relativePath] = try SHA256.hash(data: Data(contentsOf: file)).hex } + // Write the manifest file to the root directory + // and return the data for using it in signing. let data = try encoder.encode(manifest) try data.write(to: root.appendingPathComponent("manifest.json")) return data @@ -395,20 +405,20 @@ extension OrdersServiceCustom { if delegate.generateSignatureFile(in: root) { return } // 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 + if let password = self.pemPrivateKeyPassword { + let sslBinary = self.sslBinary guard FileManager.default.fileExists(atPath: sslBinary.path) else { throw OrdersError.opensslBinaryMissing } let proc = Process() - proc.currentDirectoryURL = delegate.sslSigningFilesDirectory + proc.currentDirectoryURL = self.signingFilesDirectory proc.executableURL = sslBinary proc.arguments = [ "smime", "-binary", "-sign", - "-certfile", delegate.wwdrCertificate, - "-signer", delegate.pemCertificate, - "-inkey", delegate.pemPrivateKey, + "-certfile", self.wwdrCertificate, + "-signer", self.pemCertificate, + "-inkey", self.pemPrivateKey, "-in", root.appendingPathComponent("manifest.json").path, "-out", root.appendingPathComponent("signature").path, "-outform", "DER", @@ -425,21 +435,21 @@ extension OrdersServiceCustom { additionalIntermediateCertificates: [ Certificate( pemEncoded: String( - contentsOf: delegate.sslSigningFilesDirectory - .appendingPathComponent(delegate.wwdrCertificate) + contentsOf: self.signingFilesDirectory + .appendingPathComponent(self.wwdrCertificate) ) ) ], certificate: Certificate( pemEncoded: String( - contentsOf: delegate.sslSigningFilesDirectory - .appendingPathComponent(delegate.pemCertificate) + contentsOf: self.signingFilesDirectory + .appendingPathComponent(self.pemCertificate) ) ), privateKey: .init( pemEncoded: String( - contentsOf: delegate.sslSigningFilesDirectory - .appendingPathComponent(delegate.pemPrivateKey) + contentsOf: self.signingFilesDirectory + .appendingPathComponent(self.pemPrivateKey) ) ), signingTime: Date() @@ -454,29 +464,23 @@ extension OrdersServiceCustom { /// - db: The `Database` to use. /// - 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) + let templateDirectory = try await URL(fileURLWithPath: delegate.template(for: order, db: db), isDirectory: true) guard (try? templateDirectory.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) ?? false else { throw OrdersError.templateNotDirectory } - let tmp = FileManager.default.temporaryDirectory - let root = tmp.appendingPathComponent(UUID().uuidString, isDirectory: true) + let root = FileManager.default.temporaryDirectory.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(order: order, db: db, encoder: encoder) + try await self.delegate.encode(order: order, db: db, encoder: self.encoder) .write(to: root.appendingPathComponent("order.json")) - try self.generateSignatureFile( - for: Self.generateManifestFile(using: encoder, in: root), - in: root - ) + try self.generateSignatureFile(for: Self.generateManifestFile(using: self.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 19ad199..6062737 100644 --- a/Sources/PassKit/Models/DeviceModel.swift +++ b/Sources/PassKit/Models/DeviceModel.swift @@ -34,13 +34,13 @@ public protocol DeviceModel: Model where IDValue == Int { var pushToken: String { get set } /// The identifier PassKit provides for the device. - var deviceLibraryIdentifier: String { get set } + var libraryIdentifier: String { get set } /// The designated initializer. /// - Parameters: - /// - deviceLibraryIdentifier: The device identifier as provided during registration. + /// - libraryIdentifier: The device identifier as provided during registration. /// - pushToken: The push token to use when sending updates via push notifications. - init(deviceLibraryIdentifier: String, pushToken: String) + init(libraryIdentifier: String, pushToken: String) } extension DeviceModel { @@ -54,13 +54,13 @@ extension DeviceModel { return pushToken } - package var _$deviceLibraryIdentifier: Field { - guard let mirror = Mirror(reflecting: self).descendant("_deviceLibraryIdentifier"), - let deviceLibraryIdentifier = mirror as? Field + package var _$libraryIdentifier: Field { + guard let mirror = Mirror(reflecting: self).descendant("_libraryIdentifier"), + let libraryIdentifier = mirror as? Field else { - fatalError("deviceLibraryIdentifier property must be declared using @Field") + fatalError("libraryIdentifier property must be declared using @Field") } - return deviceLibraryIdentifier + return libraryIdentifier } } diff --git a/Sources/Passes/DTOs/PersonalizationJSON.swift b/Sources/Passes/DTOs/PersonalizationJSON.swift index 2738be4..08fd2a5 100644 --- a/Sources/Passes/DTOs/PersonalizationJSON.swift +++ b/Sources/Passes/DTOs/PersonalizationJSON.swift @@ -1,34 +1,48 @@ -// -// PersonalizationJSON.swift -// PassKit -// -// Created by Francesco Paolo Severino on 04/07/24. -// - /// The structure of a `personalization.json` file. /// /// This file specifies the personal information requested by the signup form. /// It also contains a description of the program and (optionally) the program’s terms and conditions. -public struct PersonalizationJSON { - /// A protocol that defines the structure of a `personalization.json` file. +/// +/// > Tip: See the [documentation](https://developer.apple.com/library/archive/documentation/UserExperience/Conceptual/PassKit_PG/PassPersonalization.html#//apple_ref/doc/uid/TP40012195-CH12-SW2) to understand the keys. +public struct PersonalizationJSON: Codable, Sendable { + /// The contents of this array define the data requested from the user. /// - /// > Tip: See the [documentation](https://developer.apple.com/library/archive/documentation/UserExperience/Conceptual/PassKit_PG/PassPersonalization.html#//apple_ref/doc/uid/TP40012195-CH12-SW2) to understand the keys. - public protocol Properties: Encodable { - /// The contents of this array define the data requested from the user. - /// - /// The signup form’s fields are generated based on these keys. - var requiredPersonalizationFields: [PersonalizationField] { get } + /// The signup form’s fields are generated based on these keys. + var requiredPersonalizationFields: [PersonalizationField] - /// A brief description of the program. - /// - /// This is displayed on the signup sheet, under the personalization logo. - var description: String { get } + /// A brief description of the program. + /// + /// This is displayed on the signup sheet, under the personalization logo. + var description: String + + /// A description of the program’s terms and conditions. + /// + /// This string can contain HTML link tags to external content. + /// + /// If present, this information is displayed after the user enters their personal information and taps the Next button. + /// The user then has the option to agree to the terms, or to cancel out of the signup process. + var termsAndConditions: String? + + /// Initializes a new ``PersonalizationJSON`` instance. + /// + /// - Parameters: + /// - requiredPersonalizationFields: An array of ``PersonalizationField`` values that define the data requested to the user. + /// - description: A brief description of the program. + /// - termsAndConditions: A description of the program’s terms and conditions. + public init( + requiredPersonalizationFields: [PersonalizationField], + description: String, + termsAndConditions: String? = nil + ) { + self.requiredPersonalizationFields = requiredPersonalizationFields + self.description = description + self.termsAndConditions = termsAndConditions } } extension PersonalizationJSON { /// Personal information requested by the signup form. - public enum PersonalizationField: String, Encodable { + public enum PersonalizationField: String, Codable, Sendable { /// Prompts the user for their name. /// /// `fullName`, `givenName`, and `familyName` are submitted in the personalize request. diff --git a/Sources/Passes/Models/Concrete Models/Pass.swift b/Sources/Passes/Models/Concrete Models/Pass.swift index 87a3212..e723208 100644 --- a/Sources/Passes/Models/Concrete Models/Pass.swift +++ b/Sources/Passes/Models/Concrete Models/Pass.swift @@ -29,8 +29,8 @@ final public class Pass: PassModel, @unchecked Sendable { public var updatedAt: Date? /// The pass type identifier that’s registered with Apple. - @Field(key: Pass.FieldKeys.passTypeIdentifier) - public var passTypeIdentifier: String + @Field(key: Pass.FieldKeys.typeIdentifier) + public var typeIdentifier: String /// The authentication token to use with the web service in the `webServiceURL` key. @Field(key: Pass.FieldKeys.authenticationToken) @@ -42,8 +42,8 @@ final public class Pass: PassModel, @unchecked Sendable { public required init() {} - public required init(passTypeIdentifier: String, authenticationToken: String) { - self.passTypeIdentifier = passTypeIdentifier + public required init(typeIdentifier: String, authenticationToken: String) { + self.typeIdentifier = typeIdentifier self.authenticationToken = authenticationToken } } @@ -53,7 +53,7 @@ extension Pass: AsyncMigration { try await database.schema(Self.schema) .id() .field(Pass.FieldKeys.updatedAt, .datetime, .required) - .field(Pass.FieldKeys.passTypeIdentifier, .string, .required) + .field(Pass.FieldKeys.typeIdentifier, .string, .required) .field(Pass.FieldKeys.authenticationToken, .string, .required) .field( Pass.FieldKeys.userPersonalizationID, .int, @@ -72,7 +72,7 @@ extension Pass { enum FieldKeys { static let schemaName = "passes" static let updatedAt = FieldKey(stringLiteral: "updated_at") - static let passTypeIdentifier = FieldKey(stringLiteral: "pass_type_identifier") + static let typeIdentifier = FieldKey(stringLiteral: "type_identifier") static let authenticationToken = FieldKey(stringLiteral: "authentication_token") static let userPersonalizationID = FieldKey(stringLiteral: "user_personalization_id") } diff --git a/Sources/Passes/Models/Concrete Models/PassesDevice.swift b/Sources/Passes/Models/Concrete Models/PassesDevice.swift index c3cd02b..593b62f 100644 --- a/Sources/Passes/Models/Concrete Models/PassesDevice.swift +++ b/Sources/Passes/Models/Concrete Models/PassesDevice.swift @@ -21,11 +21,11 @@ final public class PassesDevice: DeviceModel, @unchecked Sendable { public var pushToken: String /// The identifier PassKit provides for the device. - @Field(key: PassesDevice.FieldKeys.deviceLibraryIdentifier) - public var deviceLibraryIdentifier: String + @Field(key: PassesDevice.FieldKeys.libraryIdentifier) + public var libraryIdentifier: String - public init(deviceLibraryIdentifier: String, pushToken: String) { - self.deviceLibraryIdentifier = deviceLibraryIdentifier + public init(libraryIdentifier: String, pushToken: String) { + self.libraryIdentifier = libraryIdentifier self.pushToken = pushToken } @@ -37,10 +37,8 @@ extension PassesDevice: AsyncMigration { try await database.schema(Self.schema) .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 - ) + .field(PassesDevice.FieldKeys.libraryIdentifier, .string, .required) + .unique(on: PassesDevice.FieldKeys.pushToken, PassesDevice.FieldKeys.libraryIdentifier) .create() } @@ -53,6 +51,6 @@ extension PassesDevice { enum FieldKeys { static let schemaName = "passes_devices" static let pushToken = FieldKey(stringLiteral: "push_token") - static let deviceLibraryIdentifier = FieldKey(stringLiteral: "device_library_identifier") + static let libraryIdentifier = FieldKey(stringLiteral: "library_identifier") } } diff --git a/Sources/Passes/Models/PassModel.swift b/Sources/Passes/Models/PassModel.swift index ca6dd12..e4fe6a8 100644 --- a/Sources/Passes/Models/PassModel.swift +++ b/Sources/Passes/Models/PassModel.swift @@ -36,7 +36,7 @@ public protocol PassModel: Model where IDValue == UUID { associatedtype UserPersonalizationType: UserPersonalizationModel /// The pass type identifier that’s registered with Apple. - var passTypeIdentifier: String { get set } + var typeIdentifier: String { get set } /// The last time the pass was modified. var updatedAt: Date? { get set } @@ -49,9 +49,9 @@ public protocol PassModel: Model where IDValue == UUID { /// The designated initializer. /// - Parameters: - /// - passTypeIdentifier: The pass type identifier that’s registered with Apple. + /// - typeIdentifier: The pass type identifier that’s registered with Apple. /// - authenticationToken: The authentication token to use with the web service in the `webServiceURL` key. - init(passTypeIdentifier: String, authenticationToken: String) + init(typeIdentifier: String, authenticationToken: String) } extension PassModel { @@ -65,14 +65,14 @@ extension PassModel { return id } - var _$passTypeIdentifier: Field { - guard let mirror = Mirror(reflecting: self).descendant("_passTypeIdentifier"), - let passTypeIdentifier = mirror as? Field + var _$typeIdentifier: Field { + guard let mirror = Mirror(reflecting: self).descendant("_typeIdentifier"), + let typeIdentifier = mirror as? Field else { - fatalError("passTypeIdentifier property must be declared using @Field") + fatalError("typeIdentifier property must be declared using @Field") } - return passTypeIdentifier + return typeIdentifier } var _$updatedAt: Timestamp { diff --git a/Sources/Passes/Models/PassesRegistrationModel.swift b/Sources/Passes/Models/PassesRegistrationModel.swift index 4eb7e39..c23accc 100644 --- a/Sources/Passes/Models/PassesRegistrationModel.swift +++ b/Sources/Passes/Models/PassesRegistrationModel.swift @@ -62,15 +62,13 @@ extension PassesRegistrationModel { return pass } - static func `for`( - deviceLibraryIdentifier: String, passTypeIdentifier: String, on db: any Database - ) -> QueryBuilder { + static func `for`(deviceLibraryIdentifier: String, typeIdentifier: String, on db: any Database) -> QueryBuilder { Self.query(on: db) .join(parent: \._$pass) .join(parent: \._$device) .with(\._$pass) .with(\._$device) - .filter(PassType.self, \._$passTypeIdentifier == passTypeIdentifier) - .filter(DeviceType.self, \._$deviceLibraryIdentifier == deviceLibraryIdentifier) + .filter(PassType.self, \._$typeIdentifier == typeIdentifier) + .filter(DeviceType.self, \._$libraryIdentifier == deviceLibraryIdentifier) } } diff --git a/Sources/Passes/Passes.docc/GettingStarted.md b/Sources/Passes/Passes.docc/GettingStarted.md index fba044d..f7c679e 100644 --- a/Sources/Passes/Passes.docc/GettingStarted.md +++ b/Sources/Passes/Passes.docc/GettingStarted.md @@ -135,12 +135,6 @@ struct PassJSONData: PassJSON.Properties { ### Implement the Delegate Create a delegate class that implements ``PassesDelegate``. -In the ``PassesDelegate/sslSigningFilesDirectory`` you specify there must be the `WWDR.pem`, `passcertificate.pem` and `passkey.pem` files. -If they are named like that you're good to go, otherwise you have to specify the custom name. - -> Tip: Obtaining the three certificates files could be a bit tricky. You could get some guidance from [this guide](https://github.com/alexandercerutti/passkit-generator/wiki/Generating-Certificates) and [this video](https://www.youtube.com/watch?v=rJZdPoXHtzI). - -There are other fields available which have reasonable default values. See ``PassesDelegate``'s documentation. Because the files for your pass' template and the method of encoding might vary by pass type, you'll be provided the ``Pass`` for those methods. In the ``PassesDelegate/encode(pass:db:encoder:)`` method, you'll want to encode a `struct` that conforms to ``PassJSON``. @@ -151,12 +145,8 @@ import Fluent import Passes final class PassDelegate: PassesDelegate { - let sslSigningFilesDirectory = URL(fileURLWithPath: "Certificates/Passes/", isDirectory: true) - - let pemPrivateKeyPassword: String? = Environment.get("PASSES_PEM_PRIVATE_KEY_PASSWORD")! - func encode(pass: P, db: Database, encoder: JSONEncoder) async throws -> Data { - // The specific PassData class you use here may vary based on the `pass.passTypeIdentifier` + // The specific PassData class you use here may vary based on the `pass.typeIdentifier` // if you have multiple different types of passes, and thus multiple types of pass data. guard let passData = try await PassData.query(on: db) .filter(\.$pass.$id == pass.requireID()) @@ -170,19 +160,21 @@ final class PassDelegate: PassesDelegate { return data } - func template(for pass: P, db: Database) async throws -> URL { + func template(for pass: P, db: Database) async throws -> String { // The location might vary depending on the type of pass. - URL(fileURLWithPath: "Templates/Passes/", isDirectory: true) + "Templates/Passes/" } } ``` -> Important: If you have an encrypted PEM private key, you **must** explicitly declare ``PassesDelegate/pemPrivateKeyPassword`` as a `String?` or Swift will ignore it as it'll think it's a `String` instead. - ### Initialize the Service Next, initialize the ``PassesService`` inside the `configure.swift` file. This will implement all of the routes that Apple Wallet expects to exist on your server. +In the `signingFilesDirectory` you specify there must be the `WWDR.pem`, `certificate.pem` and `key.pem` files. +If they are named like that you're good to go, otherwise you have to specify the custom name. + +> Tip: Obtaining the three certificates files could be a bit tricky. You could get some guidance from [this guide](https://github.com/alexandercerutti/passkit-generator/wiki/Generating-Certificates) and [this video](https://www.youtube.com/watch?v=rJZdPoXHtzI). ```swift import Fluent @@ -193,7 +185,11 @@ let passDelegate = PassDelegate() public func configure(_ app: Application) async throws { ... - let passesService = try PassesService(app: app, delegate: passDelegate) + let passesService = try PassesService( + app: app, + delegate: passDelegate, + signingFilesDirectory: "Certificates/Passes/" + ) } ``` @@ -223,7 +219,17 @@ let passDelegate = PassDelegate() public func configure(_ app: Application) async throws { ... - let passesService = try PassesServiceCustom(app: app, delegate: passDelegate) + let passesService = try PassesServiceCustom< + MyPassType, + MyUserPersonalizationType, + MyDeviceType, + MyPassesRegistrationType, + MyErrorLogType + >( + app: app, + delegate: passDelegate, + signingFilesDirectory: "Certificates/Passes/" + ) } ``` @@ -258,7 +264,7 @@ struct PassDataMiddleware: AsyncModelMiddleware { // Create the `Pass` and add it to the `PassData` automatically at creation func create(model: PassData, on db: Database, next: AnyAsyncModelResponder) async throws { let pass = Pass( - passTypeIdentifier: Environment.get("PASS_TYPE_IDENTIFIER")!, + typeIdentifier: Environment.get("PASS_TYPE_IDENTIFIER")!, authenticationToken: Data([UInt8].random(count: 12)).base64EncodedString()) try await pass.save(on: db) model.$pass.id = try pass.requireID() diff --git a/Sources/Passes/Passes.docc/Personalization.md b/Sources/Passes/Passes.docc/Personalization.md index 32054c6..c828d2c 100644 --- a/Sources/Passes/Passes.docc/Personalization.md +++ b/Sources/Passes/Passes.docc/Personalization.md @@ -12,42 +12,19 @@ Pass Personalization lets you create passes, referred to as personalizable passe Personalizable passes can be distributed like any other pass. For information on personalizable passes, see the [Wallet Developer Guide](https://developer.apple.com/library/archive/documentation/UserExperience/Conceptual/PassKit_PG/PassPersonalization.html#//apple_ref/doc/uid/TP40012195-CH12-SW2) and [Return a Personalized Pass](https://developer.apple.com/documentation/walletpasses/return_a_personalized_pass). -### Model the personalization.json contents +### Implement the Delegate + +You'll have to make a few changes to ``PassesDelegate`` to support personalizable passes. A personalizable pass is just a standard pass package with the following additional files: - A `personalization.json` file. - A `personalizationLogo@XX.png` file. -Create a `struct` that implements ``PersonalizationJSON/Properties`` which will contain all the fields for the generated `personalization.json` file. -Create an initializer that takes your custom pass data, the ``Pass`` and everything else you may need. +Implement the ``PassesDelegate/personalizationJSON(for:db:)`` method, which gives you the ``Pass`` to encode. +If the pass requires personalization, and if it was not already personalized, create the ``PersonalizationJSON`` struct, which will contain all the fields for the generated `personalization.json` file, and return it, otherwise return `nil`. -```swift -import Passes - -struct PersonalizationJSONData: PersonalizationJSON.Properties { - var requiredPersonalizationFields = [ - PersonalizationJSON.PersonalizationField.name, - PersonalizationJSON.PersonalizationField.postalCode, - PersonalizationJSON.PersonalizationField.emailAddress, - PersonalizationJSON.PersonalizationField.phoneNumber - ] - var description: String - - init(data: PassData, pass: Pass) { - self.description = data.title - } -} -``` - -### Implement the Delegate - -You'll have to make a few changes to ``PassesDelegate`` to support personalizable passes. - -Implement the ``PassesDelegate/encodePersonalization(for:db:encoder:)`` method, which gives you the ``Pass`` to encode. -If the pass requires personalization, and if it was not already personalized, encode the ``PersonalizationJSON`` and return it, otherwise return `nil`. - -In the ``PassesDelegate/template(for:db:)`` method, you have to return two different directory URLs, depending on whether the pass has to be personalized or not. If it does, the directory must contain the `personalizationLogo@XX.png` file. +In the ``PassesDelegate/template(for:db:)`` method, you have to return two different directory paths, depending on whether the pass has to be personalized or not. If it does, the directory must contain the `personalizationLogo@XX.png` file. Finally, you have to implement the ``PassesDelegate/encode(pass:db:encoder:)`` method as usual, but remember to use in the ``PassJSON`` initializer the user info that will be saved inside ``Pass/userPersonalization`` after the pass has been personalized. @@ -57,10 +34,6 @@ import Fluent import Passes final class PassDelegate: PassesDelegate { - let sslSigningFilesDirectory = URL(fileURLWithPath: "Certificates/Passes/", isDirectory: true) - - let pemPrivateKeyPassword: String? = Environment.get("PASSES_PEM_PRIVATE_KEY_PASSWORD")! - func encode(pass: P, db: Database, encoder: JSONEncoder) async throws -> Data { // Here encode the pass JSON data as usual. guard let passData = try await PassData.query(on: db) @@ -75,7 +48,7 @@ final class PassDelegate: PassesDelegate { return data } - func encodePersonalization(for pass: P, db: any Database, encoder: JSONEncoder) async throws -> Data? { + func personalizationJSON(for pass: P, db: any Database) async throws -> PersonalizationJSON? { guard let passData = try await PassData.query(on: db) .filter(\.$pass.$id == pass.requireID()) .with(\.$pass) @@ -85,18 +58,18 @@ final class PassDelegate: PassesDelegate { } if try await passData.pass.$userPersonalization.get(on: db) == nil { - // If the pass requires personalization, encode the personalization JSON data. - guard let data = try? encoder.encode(PersonalizationJSONData(data: passData, pass: pass)) else { - throw Abort(.internalServerError) - } - return data + // If the pass requires personalization, create the personalization JSON struct. + return PersonalizationJSON( + requiredPersonalizationFields: [.name, .postalCode, .emailAddress, .phoneNumber], + description: "Hello, World!" + ) } else { // Otherwise, return `nil`. return nil } } - func template(for pass: P, db: Database) async throws -> URL { + func template(for pass: P, db: Database) async throws -> String { guard let passData = try await PassData.query(on: db) .filter(\.$pass.$id == pass.requireID()) .first() @@ -105,12 +78,12 @@ final class PassDelegate: PassesDelegate { } if passData.requiresPersonalization { - // If the pass requires personalization, return the URL to the personalization template, + // If the pass requires personalization, return the URL path to the personalization template, // which must contain the `personalizationLogo@XX.png` file. - return URL(fileURLWithPath: "Templates/Passes/Personalization/", isDirectory: true) + return "Templates/Passes/Personalization/" } else { - // Otherwise, return the URL to the standard pass template. - return URL(fileURLWithPath: "Templates/Passes/Standard/", isDirectory: true) + // Otherwise, return the URL path to the standard pass template. + return "Templates/Passes/Standard/" } } } @@ -118,7 +91,7 @@ final class PassDelegate: PassesDelegate { ### Implement the Web Service -After implementing the JSON `struct` and the delegate, there is nothing else you have to do. +After implementing the delegate methods, there is nothing else you have to do. Initializing the ``PassesService`` will automatically set up the endpoints that Apple Wallet expects to exist on your server to handle pass personalization. @@ -130,10 +103,10 @@ Wallet will then send the user personal information to your server, which will b Immediately after that, Wallet will request the updated pass. This updated pass will contain the user personalization data that was previously saved inside the ``Pass/userPersonalization`` field. -> Important: This updated and personalized pass **must not** contain the `personalization.json` file, so make sure that the ``PassesDelegate/encodePersonalization(for:db:encoder:)`` method returns `nil` when the pass has already been personalized. +> Important: This updated and personalized pass **must not** contain the `personalization.json` file, so make sure that the ``PassesDelegate/personalizationJSON(for:db:)`` method returns `nil` when the pass has already been personalized. ## Topics ### Delegate Method -- ``PassesDelegate/encodePersonalization(for:db:encoder:)`` +- ``PassesDelegate/personalizationJSON(for:db:)`` diff --git a/Sources/Passes/PassesDelegate.swift b/Sources/Passes/PassesDelegate.swift index a571f6f..ca3464b 100644 --- a/Sources/Passes/PassesDelegate.swift +++ b/Sources/Passes/PassesDelegate.swift @@ -31,9 +31,10 @@ import Foundation /// The delegate which is responsible for generating the pass files. public protocol PassesDelegate: AnyObject, Sendable { - /// Should return a `URL` which points to the template data for the pass. + /// Should return a URL path which points to the template data for the pass. /// - /// The URL should point to a directory containing all the images and localizations for the generated `.pkpass` archive but should *not* contain any of these items: + /// The path should point to a directory containing all the images and localizations for the generated `.pkpass` archive + /// but should *not* contain any of these items: /// - `manifest.json` /// - `pass.json` /// - `personalization.json` @@ -43,10 +44,8 @@ public protocol PassesDelegate: AnyObject, Sendable { /// - pass: The pass data from the SQL server. /// - db: The SQL database to query against. /// - /// - Returns: A `URL` which points to the template data for the pass. - /// - /// > Important: Be sure to use the `URL(fileURLWithPath:isDirectory:)` constructor. - func template(for pass: P, db: any Database) async throws -> URL + /// - Returns: A URL path which points to the template data for the pass. + func template(for pass: P, db: any Database) async throws -> String /// Generates the SSL `signature` file. /// @@ -72,12 +71,12 @@ public protocol PassesDelegate: AnyObject, Sendable { /// > Tip: See the [`Pass`](https://developer.apple.com/documentation/walletpasses/pass) object to understand the keys. func encode(pass: P, db: any Database, encoder: JSONEncoder) async throws -> Data - /// Encode the personalization JSON file. + /// Create the personalization JSON struct. /// - /// This method of the ``PassesDelegate`` should generate the entire personalization JSON file. + /// This method of the ``PassesDelegate`` should generate the entire personalization JSON struct. /// You are provided with the pass data from the SQL database and, /// if the pass in question requires personalization, - /// you should return a properly formatted personalization JSON file. + /// you should return a ``PersonalizationJSON``. /// /// If the pass does not require personalization, you should return `nil`. /// @@ -86,74 +85,16 @@ public protocol PassesDelegate: AnyObject, Sendable { /// - Parameters: /// - pass: The pass data from the SQL server. /// - 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? - - /// Should return a `URL` which points to the template data for the pass. - /// - /// The URL should point to a directory containing the files specified by these keys: - /// - `wwdrCertificate` - /// - `pemCertificate` - /// - `pemPrivateKey` - /// - /// > Important: Be sure to use the `URL(fileURLWithPath:isDirectory:)` initializer! - var sslSigningFilesDirectory: URL { get } - - /// The location of the `openssl` command as a file URL. - /// - /// > 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` - var wwdrCertificate: String { get } - - /// The name of the PEM Certificate for signing the pass as contained in `sslSigningFiles` path. - /// - /// Defaults to `passcertificate.pem` - var pemCertificate: String { get } - - /// The name of the PEM Certificate's private key for signing the pass as contained in `sslSigningFiles` path. - /// - /// Defaults to `passkey.pem` - var pemPrivateKey: String { get } - - /// The password to the private key file. - var pemPrivateKeyPassword: String? { get } + /// - Returns: A ``PersonalizationJSON`` or `nil` if the pass does not require personalization. + func personalizationJSON(for pass: P, db: any Database) async throws -> PersonalizationJSON? } extension PassesDelegate { - public var wwdrCertificate: String { - return "WWDR.pem" - } - - public var pemCertificate: String { - return "passcertificate.pem" - } - - public var pemPrivateKey: String { - return "passkey.pem" - } - - public var pemPrivateKeyPassword: String? { - return nil - } - - public var sslBinary: URL { - return URL(fileURLWithPath: "/usr/bin/openssl") - } - public func generateSignatureFile(in root: URL) -> Bool { return false } - public func encodePersonalization( - for pass: P, db: any Database, encoder: JSONEncoder - ) async throws -> Data? { + public func personalizationJSON(for pass: P, db: any Database) async throws -> PersonalizationJSON? { return nil } } diff --git a/Sources/Passes/PassesService.swift b/Sources/Passes/PassesService.swift index 4fa593a..572f07f 100644 --- a/Sources/Passes/PassesService.swift +++ b/Sources/Passes/PassesService.swift @@ -31,24 +31,44 @@ import Vapor /// The main class that handles PassKit passes. public final class PassesService: Sendable { - private let service: - PassesServiceCustom< - Pass, UserPersonalization, PassesDevice, PassesRegistration, PassesErrorLog - > + private let service: PassesServiceCustom /// Initializes the service and registers all the routes required for PassKit to work. /// /// - Parameters: /// - app: The `Vapor.Application` to use in route handlers and APNs. /// - delegate: The ``PassesDelegate`` to use for pass generation. + /// - signingFilesDirectory: The path of the directory where the signing files (`wwdrCertificate`, `pemCertificate`, `pemPrivateKey`) are located. + /// - wwdrCertificate: The name of Apple's WWDR.pem certificate as contained in `signingFilesDirectory` path. Defaults to `WWDR.pem`. + /// - pemCertificate: The name of the PEM Certificate for signing the pass as contained in `signingFilesDirectory` path. Defaults to `certificate.pem`. + /// - pemPrivateKey: The name of the PEM Certificate's private key for signing the pass as contained in `signingFilesDirectory` path. Defaults to `key.pem`. + /// - pemPrivateKeyPassword: The password to the private key file. If the key is not encrypted it must be `nil`. Defaults to `nil`. + /// - sslBinary: The location of the `openssl` command as a file path. /// - 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 + app: Application, + delegate: any PassesDelegate, + signingFilesDirectory: String, + wwdrCertificate: String = "WWDR.pem", + pemCertificate: String = "certificate.pem", + pemPrivateKey: String = "key.pem", + pemPrivateKeyPassword: String? = nil, + sslBinary: String = "/usr/bin/openssl", + pushRoutesMiddleware: (any Middleware)? = nil, + logger: Logger? = nil ) throws { - service = try .init( - app: app, delegate: delegate, pushRoutesMiddleware: pushRoutesMiddleware, logger: logger + self.service = try .init( + app: app, + delegate: delegate, + signingFilesDirectory: signingFilesDirectory, + wwdrCertificate: wwdrCertificate, + pemCertificate: pemCertificate, + pemPrivateKey: pemPrivateKey, + pemPrivateKeyPassword: pemPrivateKeyPassword, + sslBinary: sslBinary, + pushRoutesMiddleware: pushRoutesMiddleware, + logger: logger ) } @@ -91,12 +111,10 @@ public final class PassesService: Sendable { /// /// - Parameters: /// - id: The `UUID` of the pass to send the notifications for. - /// - passTypeIdentifier: The type identifier of the pass. + /// - typeIdentifier: 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 { - try await service.sendPushNotificationsForPass(id: id, of: passTypeIdentifier, on: db) + public func sendPushNotificationsForPass(id: UUID, of typeIdentifier: String, on db: any Database) async throws { + try await service.sendPushNotificationsForPass(id: id, of: typeIdentifier, on: db) } /// Sends push notifications for a given pass. diff --git a/Sources/Passes/PassesServiceCustom.swift b/Sources/Passes/PassesServiceCustom.swift index 334a990..56db552 100644 --- a/Sources/Passes/PassesServiceCustom.swift +++ b/Sources/Passes/PassesServiceCustom.swift @@ -29,44 +29,65 @@ public final class PassesServiceCustom< where P == R.PassType, D == R.DeviceType, U == P.UserPersonalizationType { private unowned let app: Application private unowned let delegate: any PassesDelegate + private let signingFilesDirectory: URL + private let wwdrCertificate: String + private let pemCertificate: String + private let pemPrivateKey: String + private let pemPrivateKeyPassword: String? + private let sslBinary: URL private let logger: Logger? + private let encoder = JSONEncoder() /// Initializes the service and registers all the routes required for PassKit to work. /// /// - Parameters: /// - app: The `Vapor.Application` to use in route handlers and APNs. /// - delegate: The ``PassesDelegate`` to use for pass generation. + /// - signingFilesDirectory: The path of the directory where the signing files (`wwdrCertificate`, `pemCertificate`, `pemPrivateKey`) are located. + /// - wwdrCertificate: The name of Apple's WWDR.pem certificate as contained in `signingFilesDirectory` path. Defaults to `WWDR.pem`. + /// - pemCertificate: The name of the PEM Certificate for signing the pass as contained in `signingFilesDirectory` path. Defaults to `certificate.pem`. + /// - pemPrivateKey: The name of the PEM Certificate's private key for signing the pass as contained in `signingFilesDirectory` path. Defaults to `key.pem`. + /// - pemPrivateKeyPassword: The password to the private key file. If the key is not encrypted it must be `nil`. Defaults to `nil`. + /// - sslBinary: The location of the `openssl` command as a file path. /// - 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, + signingFilesDirectory: String, + wwdrCertificate: String = "WWDR.pem", + pemCertificate: String = "certificate.pem", + pemPrivateKey: String = "key.pem", + pemPrivateKeyPassword: String? = nil, + sslBinary: String = "/usr/bin/openssl", pushRoutesMiddleware: (any Middleware)? = nil, logger: Logger? = nil ) throws { self.app = app self.delegate = delegate + self.signingFilesDirectory = URL(fileURLWithPath: signingFilesDirectory, isDirectory: true) + self.wwdrCertificate = wwdrCertificate + self.pemCertificate = pemCertificate + self.pemPrivateKey = pemPrivateKey + self.pemPrivateKeyPassword = pemPrivateKeyPassword + self.sslBinary = URL(fileURLWithPath: sslBinary) self.logger = logger - let privateKeyPath = URL( - fileURLWithPath: delegate.pemPrivateKey, relativeTo: delegate.sslSigningFilesDirectory - ).path + let privateKeyPath = URL(fileURLWithPath: pemPrivateKey, relativeTo: self.signingFilesDirectory).path guard FileManager.default.fileExists(atPath: privateKeyPath) else { throw PassesError.pemPrivateKeyMissing } - let pemPath = URL( - fileURLWithPath: delegate.pemCertificate, relativeTo: delegate.sslSigningFilesDirectory - ).path + let pemPath = URL(fileURLWithPath: pemCertificate, relativeTo: self.signingFilesDirectory).path guard FileManager.default.fileExists(atPath: pemPath) else { throw PassesError.pemCertificateMissing } let apnsConfig: APNSClientConfiguration - if let password = delegate.pemPrivateKeyPassword { + if let password = pemPrivateKeyPassword { apnsConfig = APNSClientConfiguration( authenticationMethod: try .tls( privateKey: .privateKey( - NIOSSLPrivateKey(file: privateKeyPath, format: .pem) { closure in - closure(password.utf8) + NIOSSLPrivateKey(file: privateKeyPath, format: .pem) { passphraseCallback in + passphraseCallback(password.utf8) }), certificateChain: NIOSSLCertificate.fromPEMFile(pemPath).map { .certificate($0) @@ -89,7 +110,7 @@ where P == R.PassType, D == R.DeviceType, U == P.UserPersonalizationType { apnsConfig, eventLoopGroupProvider: .shared(app.eventLoopGroup), responseDecoder: JSONDecoder(), - requestEncoder: JSONEncoder(), + requestEncoder: self.encoder, as: .init(string: "passes"), isDefault: false ) @@ -97,38 +118,33 @@ where P == R.PassType, D == R.DeviceType, U == P.UserPersonalizationType { let v1 = app.grouped("api", "passes", "v1") v1.get( "devices", ":deviceLibraryIdentifier", "registrations", ":passTypeIdentifier", - use: { try await self.passesForDevice(req: $0) }) + use: { try await self.passesForDevice(req: $0) } + ) v1.post("log", use: { try await self.logError(req: $0) }) - v1.post( - "passes", ":passTypeIdentifier", ":passSerial", "personalize", - use: { try await self.personalizedPass(req: $0) }) + v1.post("passes", ":passTypeIdentifier", ":passSerial", "personalize", use: { try await self.personalizedPass(req: $0) }) let v1auth = v1.grouped(ApplePassMiddleware

()) 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) }) + "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) }) + "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) }) } } } // MARK: - API Routes extension PassesServiceCustom { - func registerDevice(req: Request) async throws -> HTTPStatus { + fileprivate func registerDevice(req: Request) async throws -> HTTPStatus { logger?.debug("Called register device") let pushToken: String @@ -145,7 +161,7 @@ extension PassesServiceCustom { let deviceLibraryIdentifier = req.parameters.get("deviceLibraryIdentifier")! guard let pass = try await P.query(on: req.db) - .filter(\._$passTypeIdentifier == passTypeIdentifier) + .filter(\._$typeIdentifier == passTypeIdentifier) .filter(\._$id == serial) .first() else { @@ -153,27 +169,23 @@ extension PassesServiceCustom { } let device = try await D.query(on: req.db) - .filter(\._$deviceLibraryIdentifier == deviceLibraryIdentifier) + .filter(\._$libraryIdentifier == deviceLibraryIdentifier) .filter(\._$pushToken == pushToken) .first() 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(libraryIdentifier: 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 { + 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 + deviceLibraryIdentifier: device.libraryIdentifier, + typeIdentifier: pass.typeIdentifier, + on: db ) .filter(P.self, \._$id == pass.requireID()) .first() @@ -187,7 +199,7 @@ extension PassesServiceCustom { return .created } - func passesForDevice(req: Request) async throws -> PassesForDeviceDTO { + fileprivate func passesForDevice(req: Request) async throws -> PassesForDeviceDTO { logger?.debug("Called passesForDevice") let passTypeIdentifier = req.parameters.get("passTypeIdentifier")! @@ -195,7 +207,9 @@ extension PassesServiceCustom { var query = R.for( deviceLibraryIdentifier: deviceLibraryIdentifier, - passTypeIdentifier: passTypeIdentifier, on: req.db) + typeIdentifier: passTypeIdentifier, + on: req.db + ) if let since: TimeInterval = req.query["passesUpdatedSince"] { let when = Date(timeIntervalSince1970: since) query = query.filter(P.self, \._$updatedAt > when) @@ -219,7 +233,7 @@ extension PassesServiceCustom { return PassesForDeviceDTO(with: serialNumbers, maxDate: maxDate) } - func latestVersionOfPass(req: Request) async throws -> Response { + fileprivate func latestVersionOfPass(req: Request) async throws -> Response { logger?.debug("Called latestVersionOfPass") var ifModifiedSince: TimeInterval = 0 @@ -235,7 +249,7 @@ extension PassesServiceCustom { guard let pass = try await P.query(on: req.db) .filter(\._$id == id) - .filter(\._$passTypeIdentifier == passTypeIdentifier) + .filter(\._$typeIdentifier == passTypeIdentifier) .first() else { throw Abort(.notFound) @@ -256,7 +270,7 @@ extension PassesServiceCustom { ) } - func unregisterDevice(req: Request) async throws -> HTTPStatus { + fileprivate func unregisterDevice(req: Request) async throws -> HTTPStatus { logger?.debug("Called unregisterDevice") guard let passId = req.parameters.get("passSerial", as: UUID.self) else { @@ -268,7 +282,8 @@ extension PassesServiceCustom { guard let r = try await R.for( deviceLibraryIdentifier: deviceLibraryIdentifier, - passTypeIdentifier: passTypeIdentifier, on: req.db + typeIdentifier: passTypeIdentifier, + on: req.db ) .filter(P.self, \._$id == passId) .first() @@ -279,7 +294,7 @@ extension PassesServiceCustom { return .ok } - func logError(req: Request) async throws -> HTTPStatus { + fileprivate func logError(req: Request) async throws -> HTTPStatus { logger?.debug("Called logError") let body: ErrorLogDTO @@ -297,7 +312,7 @@ extension PassesServiceCustom { return .ok } - func personalizedPass(req: Request) async throws -> Response { + fileprivate func personalizedPass(req: Request) async throws -> Response { logger?.debug("Called personalizedPass") guard let passTypeIdentifier = req.parameters.get("passTypeIdentifier"), @@ -308,7 +323,7 @@ extension PassesServiceCustom { guard let pass = try await P.query(on: req.db) .filter(\._$id == id) - .filter(\._$passTypeIdentifier == passTypeIdentifier) + .filter(\._$typeIdentifier == passTypeIdentifier) .first() else { throw Abort(.notFound) @@ -329,8 +344,7 @@ extension PassesServiceCustom { pass._$userPersonalization.id = try userPersonalization.requireID() try await pass.update(on: req.db) - let tmp = FileManager.default.temporaryDirectory - let root = tmp.appendingPathComponent(UUID().uuidString, isDirectory: true) + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) defer { _ = try? FileManager.default.removeItem(at: root) } @@ -338,8 +352,8 @@ extension PassesServiceCustom { throw Abort(.internalServerError) } let signature: Data - if let password = delegate.pemPrivateKeyPassword { - let sslBinary = delegate.sslBinary + if let password = self.pemPrivateKeyPassword { + let sslBinary: URL = self.sslBinary guard FileManager.default.fileExists(atPath: sslBinary.path) else { throw PassesError.opensslBinaryMissing } @@ -348,13 +362,13 @@ extension PassesServiceCustom { try token.write(to: tokenURL) let proc = Process() - proc.currentDirectoryURL = delegate.sslSigningFilesDirectory + proc.currentDirectoryURL = self.signingFilesDirectory proc.executableURL = sslBinary proc.arguments = [ "smime", "-binary", "-sign", - "-certfile", delegate.wwdrCertificate, - "-signer", delegate.pemCertificate, - "-inkey", delegate.pemPrivateKey, + "-certfile", self.wwdrCertificate, + "-signer", self.pemCertificate, + "-inkey", self.pemPrivateKey, "-in", tokenURL.path, "-out", root.appendingPathComponent("signature").path, "-outform", "DER", @@ -370,21 +384,21 @@ extension PassesServiceCustom { additionalIntermediateCertificates: [ Certificate( pemEncoded: String( - contentsOf: delegate.sslSigningFilesDirectory - .appendingPathComponent(delegate.wwdrCertificate) + contentsOf: self.signingFilesDirectory + .appendingPathComponent(self.wwdrCertificate) ) ) ], certificate: Certificate( pemEncoded: String( - contentsOf: delegate.sslSigningFilesDirectory - .appendingPathComponent(delegate.pemCertificate) + contentsOf: self.signingFilesDirectory + .appendingPathComponent(self.pemCertificate) ) ), privateKey: .init( pemEncoded: String( - contentsOf: delegate.sslSigningFilesDirectory - .appendingPathComponent(delegate.pemPrivateKey) + contentsOf: self.signingFilesDirectory + .appendingPathComponent(self.pemPrivateKey) ) ), signingTime: Date() @@ -399,7 +413,7 @@ extension PassesServiceCustom { } // MARK: - Push Routes - func pushUpdatesForPass(req: Request) async throws -> HTTPStatus { + fileprivate func pushUpdatesForPass(req: Request) async throws -> HTTPStatus { logger?.debug("Called pushUpdatesForPass") guard let id = req.parameters.get("passSerial", as: UUID.self) else { @@ -411,7 +425,7 @@ extension PassesServiceCustom { return .noContent } - func tokensForPassUpdate(req: Request) async throws -> [String] { + fileprivate func tokensForPassUpdate(req: Request) async throws -> [String] { logger?.debug("Called tokensForPassUpdate") guard let id = req.parameters.get("passSerial", as: UUID.self) else { @@ -419,8 +433,7 @@ extension PassesServiceCustom { } let passTypeIdentifier = req.parameters.get("passTypeIdentifier")! - return try await Self.registrationsForPass(id: id, of: passTypeIdentifier, on: req.db) - .map { $0.device.pushToken } + return try await Self.registrationsForPass(id: id, of: passTypeIdentifier, on: req.db).map { $0.device.pushToken } } } @@ -430,17 +443,14 @@ extension PassesServiceCustom { /// /// - Parameters: /// - id: The `UUID` of the pass to send the notifications for. - /// - passTypeIdentifier: The type identifier of the pass. + /// - typeIdentifier: 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 typeIdentifier: String, on db: any Database) async throws { + let registrations = try await Self.registrationsForPass(id: id, of: typeIdentifier, on: db) for reg in registrations { let backgroundNotification = APNSBackgroundNotification( expiration: .immediately, - topic: reg.pass.passTypeIdentifier, + topic: reg.pass.typeIdentifier, payload: EmptyPayload() ) do { @@ -461,13 +471,10 @@ extension PassesServiceCustom { /// - 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.typeIdentifier, on: db) } - static func registrationsForPass( - id: UUID, of passTypeIdentifier: String, on db: any Database - ) async throws -> [R] { + private static func registrationsForPass(id: UUID, of typeIdentifier: 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) @@ -475,7 +482,7 @@ extension PassesServiceCustom { .join(parent: \._$device) .with(\._$pass) .with(\._$device) - .filter(P.self, \._$passTypeIdentifier == passTypeIdentifier) + .filter(P.self, \._$typeIdentifier == typeIdentifier) .filter(P.self, \._$id == id) .all() } @@ -483,18 +490,16 @@ extension PassesServiceCustom { // 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.path) for relativePath in paths { let file = URL(fileURLWithPath: relativePath, relativeTo: root) 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() + manifest[relativePath] = try Insecure.SHA1.hash(data: Data(contentsOf: file)).hex } + // Write the manifest file to the root directory + // and return the data for using it in signing. let data = try encoder.encode(manifest) try data.write(to: root.appendingPathComponent("manifest.json")) return data @@ -505,20 +510,20 @@ extension PassesServiceCustom { if delegate.generateSignatureFile(in: root) { return } // 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 + if let password = self.pemPrivateKeyPassword { + let sslBinary = self.sslBinary guard FileManager.default.fileExists(atPath: sslBinary.path) else { throw PassesError.opensslBinaryMissing } let proc = Process() - proc.currentDirectoryURL = delegate.sslSigningFilesDirectory + proc.currentDirectoryURL = self.signingFilesDirectory proc.executableURL = sslBinary proc.arguments = [ "smime", "-binary", "-sign", - "-certfile", delegate.wwdrCertificate, - "-signer", delegate.pemCertificate, - "-inkey", delegate.pemPrivateKey, + "-certfile", self.wwdrCertificate, + "-signer", self.pemCertificate, + "-inkey", self.pemPrivateKey, "-in", root.appendingPathComponent("manifest.json").path, "-out", root.appendingPathComponent("signature").path, "-outform", "DER", @@ -535,21 +540,21 @@ extension PassesServiceCustom { additionalIntermediateCertificates: [ Certificate( pemEncoded: String( - contentsOf: delegate.sslSigningFilesDirectory - .appendingPathComponent(delegate.wwdrCertificate) + contentsOf: self.signingFilesDirectory + .appendingPathComponent(self.wwdrCertificate) ) ) ], certificate: Certificate( pemEncoded: String( - contentsOf: delegate.sslSigningFilesDirectory - .appendingPathComponent(delegate.pemCertificate) + contentsOf: self.signingFilesDirectory + .appendingPathComponent(self.pemCertificate) ) ), privateKey: .init( pemEncoded: String( - contentsOf: delegate.sslSigningFilesDirectory - .appendingPathComponent(delegate.pemPrivateKey) + contentsOf: self.signingFilesDirectory + .appendingPathComponent(self.pemPrivateKey) ) ), signingTime: Date() @@ -564,37 +569,29 @@ extension PassesServiceCustom { /// - db: The `Database` to use. /// - 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) + let templateDirectory = try await URL(fileURLWithPath: delegate.template(for: pass, db: db), isDirectory: true) guard (try? templateDirectory.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) ?? false else { throw PassesError.templateNotDirectory } - var files = try FileManager.default.contentsOfDirectory( - at: templateDirectory, includingPropertiesForKeys: nil) - let tmp = FileManager.default.temporaryDirectory - let root = tmp.appendingPathComponent(UUID().uuidString, isDirectory: true) + let root = FileManager.default.temporaryDirectory.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) + try await self.delegate.encode(pass: pass, db: db, encoder: self.encoder) .write(to: root.appendingPathComponent("pass.json")) + var files = try FileManager.default.contentsOfDirectory(at: templateDirectory, includingPropertiesForKeys: nil) + // 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 personalizationJSON = try await self.delegate.personalizationJSON(for: pass, db: db) { + try self.encoder.encode(personalizationJSON).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 - ) + try self.generateSignatureFile(for: Self.generateManifestFile(using: self.encoder, in: root), in: root) files.append(URL(fileURLWithPath: "pass.json", relativeTo: root)) files.append(URL(fileURLWithPath: "manifest.json", relativeTo: root)) @@ -617,8 +614,7 @@ extension PassesServiceCustom { throw PassesError.invalidNumberOfPasses } - let tmp = FileManager.default.temporaryDirectory - let root = tmp.appendingPathComponent(UUID().uuidString, isDirectory: true) + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) defer { _ = try? FileManager.default.removeItem(at: root) } diff --git a/Tests/OrdersTests/EncryptedOrdersDelegate.swift b/Tests/OrdersTests/EncryptedOrdersDelegate.swift deleted file mode 100644 index 82c192d..0000000 --- a/Tests/OrdersTests/EncryptedOrdersDelegate.swift +++ /dev/null @@ -1,41 +0,0 @@ -import FluentKit -import Orders -import Vapor - -final class EncryptedOrdersDelegate: OrdersDelegate { - let sslSigningFilesDirectory = URL( - fileURLWithPath: "\(FileManager.default.currentDirectoryPath)/Tests/Certificates/", - isDirectory: true - ) - - let pemCertificate = "encryptedcert.pem" - 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() - else { - throw Abort(.internalServerError) - } - guard let data = try? encoder.encode(OrderJSONData(data: orderData, order: orderData.order)) - else { - throw Abort(.internalServerError) - } - return data - } - - func template(for: O, db: any Database) async throws -> URL { - URL( - fileURLWithPath: - "\(FileManager.default.currentDirectoryPath)/Tests/OrdersTests/Templates/", - isDirectory: true - ) - } -} diff --git a/Tests/OrdersTests/EncryptedOrdersTests.swift b/Tests/OrdersTests/EncryptedOrdersTests.swift deleted file mode 100644 index 368b7dc..0000000 --- a/Tests/OrdersTests/EncryptedOrdersTests.swift +++ /dev/null @@ -1,92 +0,0 @@ -import FluentKit -import PassKit -import Testing -import XCTVapor -import Zip - -@testable import Orders - -@Suite("Orders Tests with Encrypted PEM Key") -struct EncryptedOrdersTests { - let delegate = EncryptedOrdersDelegate() - let ordersURI = "/api/orders/v1/" - - @Test("Order Generation") - func orderGeneration() async throws { - try await withApp(delegate: delegate) { app, ordersService in - let orderData = OrderData(title: "Test Order") - try await orderData.create(on: app.db) - let order = try await orderData.$order.get(on: app.db) - let data = try await ordersService.generateOrderContent(for: order, on: app.db) - let orderURL = FileManager.default.temporaryDirectory.appendingPathComponent("test.order") - try data.write(to: orderURL) - let orderFolder = try Zip.quickUnzipFile(orderURL) - - #expect(FileManager.default.fileExists(atPath: orderFolder.path.appending("/signature"))) - - let passJSONData = try String(contentsOfFile: orderFolder.path.appending("/order.json")).data(using: .utf8) - let passJSON = try JSONSerialization.jsonObject(with: passJSONData!) as! [String: Any] - #expect(passJSON["authenticationToken"] as? String == order.authenticationToken) - let orderID = try order.requireID().uuidString - #expect(passJSON["orderIdentifier"] as? String == orderID) - - 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 - #expect(manifestJSON["icon.png"] as? String == iconHash) - } - } - - @Test("APNS Client") - func apnsClient() async throws { - try await withApp(delegate: delegate) { app, ordersService in - #expect(app.apns.client(.init(string: "orders")) != nil) - - let orderData = OrderData(title: "Test Order") - 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) - - let deviceLibraryIdentifier = "abcdefg" - let pushToken = "1234567890" - - try await app.test( - .POST, - "\(ordersURI)push/\(order.orderTypeIdentifier)/\(order.requireID())", - headers: ["X-Secret": "foo"], - afterResponse: { res async throws in - #expect(res.status == .noContent) - } - ) - - try await app.test( - .POST, - "\(ordersURI)devices/\(deviceLibraryIdentifier)/registrations/\(order.orderTypeIdentifier)/\(order.requireID())", - headers: ["Authorization": "AppleOrder \(order.authenticationToken)"], - beforeRequest: { req async throws in - try req.content.encode(RegistrationDTO(pushToken: pushToken)) - }, - afterResponse: { res async throws in - #expect(res.status == .created) - } - ) - - try await app.test( - .POST, - "\(ordersURI)push/\(order.orderTypeIdentifier)/\(order.requireID())", - headers: ["X-Secret": "foo"], - afterResponse: { res async throws in - #expect(res.status == .internalServerError) - } - ) - - // Test `OrderDataMiddleware` update method - orderData.title = "Test Order 2" - do { - try await orderData.update(on: app.db) - } catch {} - } - } -} diff --git a/Tests/OrdersTests/OrderData.swift b/Tests/OrdersTests/OrderData.swift index 698c457..627ecd3 100644 --- a/Tests/OrdersTests/OrderData.swift +++ b/Tests/OrdersTests/OrderData.swift @@ -22,28 +22,6 @@ final class OrderData: OrderDataModel, @unchecked Sendable { self.id = id self.title = title } - - func toDTO() -> OrderDataDTO { - .init( - id: self.id, - title: self.$title.value - ) - } -} - -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 - } - return model - } } struct CreateOrderData: AsyncMigration { @@ -51,10 +29,7 @@ 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() } @@ -71,7 +46,11 @@ extension OrderData { } } -struct OrderJSONData: OrderJSON.Properties { +extension OrderJSON.SchemaVersion: Decodable {} +extension OrderJSON.OrderType: Decodable {} +extension OrderJSON.OrderStatus: Decodable {} + +struct OrderJSONData: OrderJSON.Properties, Decodable { let schemaVersion = OrderJSON.SchemaVersion.v1 let orderTypeIdentifier = "order.com.example.pet-store" let orderIdentifier: String @@ -86,11 +65,23 @@ struct OrderJSONData: OrderJSON.Properties { private let webServiceURL = "https://www.example.com/api/orders/" - struct MerchantData: OrderJSON.Merchant { + enum CodingKeys: String, CodingKey { + case schemaVersion + case orderTypeIdentifier, orderIdentifier, orderType, orderNumber + case createdAt, updatedAt + case status, merchant + case orderManagementURL, authenticationToken, webServiceURL + } + + struct MerchantData: OrderJSON.Merchant, Decodable { let merchantIdentifier = "com.example.pet-store" let displayName: String let url = "https://www.example.com/" let logo = "pet_store_logo.png" + + enum CodingKeys: String, CodingKey { + case merchantIdentifier, displayName, url, logo + } } init(data: OrderData, order: Order) { @@ -111,20 +102,17 @@ 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()) + typeIdentifier: "order.com.example.pet-store", + authenticationToken: Data([UInt8].random(count: 12)).base64EncodedString() + ) try await order.save(on: db) model.$order.id = try order.requireID() 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 c7d1557..b5afb84 100644 --- a/Tests/OrdersTests/OrdersTests.swift +++ b/Tests/OrdersTests/OrdersTests.swift @@ -6,49 +6,50 @@ import Zip @testable import Orders -@Suite("Orders Tests") +@Suite("Orders Tests", .serialized) struct OrdersTests { - let delegate = TestOrdersDelegate() let ordersURI = "/api/orders/v1/" + let decoder = JSONDecoder() - @Test("Order Generation") - func orderGeneration() async throws { - try await withApp(delegate: delegate) { app, ordersService in + @Test("Order Generation", arguments: [true, false]) + func orderGeneration(useEncryptedKey: Bool) async throws { + try await withApp(useEncryptedKey: useEncryptedKey) { app, ordersService in let orderData = OrderData(title: "Test Order") try await orderData.create(on: app.db) let order = try await orderData.$order.get(on: app.db) let data = try await ordersService.generateOrderContent(for: order, on: app.db) - let orderURL = FileManager.default.temporaryDirectory.appendingPathComponent("test.order") + let orderURL = FileManager.default.temporaryDirectory.appendingPathComponent("\(UUID().uuidString).order") try data.write(to: orderURL) - let orderFolder = try Zip.quickUnzipFile(orderURL) + let orderFolder = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + try Zip.unzipFile(orderURL, destination: orderFolder) #expect(FileManager.default.fileExists(atPath: orderFolder.path.appending("/signature"))) - let passJSONData = try String(contentsOfFile: orderFolder.path.appending("/order.json")).data(using: .utf8) - let passJSON = try JSONSerialization.jsonObject(with: passJSONData!) as! [String: Any] - #expect(passJSON["authenticationToken"] as? String == order.authenticationToken) + #expect(FileManager.default.fileExists(atPath: orderFolder.path.appending("/order.json"))) + let orderJSONData = try String(contentsOfFile: orderFolder.path.appending("/order.json")).data(using: .utf8) + let orderJSON = try decoder.decode(OrderJSONData.self, from: orderJSONData!) + #expect(orderJSON.authenticationToken == order.authenticationToken) let orderID = try order.requireID().uuidString - #expect(passJSON["orderIdentifier"] as? String == orderID) + #expect(orderJSON.orderIdentifier == orderID) let manifestJSONData = try String(contentsOfFile: orderFolder.path.appending("/manifest.json")).data(using: .utf8) - let manifestJSON = try JSONSerialization.jsonObject(with: manifestJSONData!) as! [String: Any] + let manifestJSON = try decoder.decode([String: String].self, from: manifestJSONData!) let iconData = try Data(contentsOf: orderFolder.appendingPathComponent("/icon.png")) - let iconHash = Array(SHA256.hash(data: iconData)).hex - #expect(manifestJSON["icon.png"] as? String == iconHash) + #expect(manifestJSON["icon.png"] == SHA256.hash(data: iconData).hex) #expect(manifestJSON["pet_store_logo.png"] != nil) } } @Test("Getting Order from Apple Wallet API") func getOrderFromAPI() async throws { - try await withApp(delegate: delegate) { app, ordersService in + try await withApp { app, ordersService in let orderData = OrderData(title: "Test Order") try await orderData.create(on: app.db) let order = try await orderData.$order.get(on: app.db) try await app.test( .GET, - "\(ordersURI)orders/\(order.orderTypeIdentifier)/\(order.requireID())", + "\(ordersURI)orders/\(order.typeIdentifier)/\(order.requireID())", headers: [ "Authorization": "AppleOrder \(order.authenticationToken)", "If-Modified-Since": "0", @@ -64,7 +65,7 @@ struct OrdersTests { // Test call with invalid authentication token try await app.test( .GET, - "\(ordersURI)orders/\(order.orderTypeIdentifier)/\(order.requireID())", + "\(ordersURI)orders/\(order.typeIdentifier)/\(order.requireID())", headers: [ "Authorization": "AppleOrder invalidToken", "If-Modified-Since": "0", @@ -77,7 +78,7 @@ struct OrdersTests { // Test distant future `If-Modified-Since` date try await app.test( .GET, - "\(ordersURI)orders/\(order.orderTypeIdentifier)/\(order.requireID())", + "\(ordersURI)orders/\(order.typeIdentifier)/\(order.requireID())", headers: [ "Authorization": "AppleOrder \(order.authenticationToken)", "If-Modified-Since": "2147483647", @@ -90,7 +91,7 @@ struct OrdersTests { // Test call with invalid order ID try await app.test( .GET, - "\(ordersURI)orders/\(order.orderTypeIdentifier)/invalidID", + "\(ordersURI)orders/\(order.typeIdentifier)/invalidID", headers: [ "Authorization": "AppleOrder \(order.authenticationToken)", "If-Modified-Since": "0", @@ -117,7 +118,7 @@ struct OrdersTests { @Test("Device Registration API") func apiDeviceRegistration() async throws { - try await withApp(delegate: delegate) { app, ordersService in + try await withApp { app, ordersService in let orderData = OrderData(title: "Test Order") try await orderData.create(on: app.db) let order = try await orderData.$order.get(on: app.db) @@ -126,7 +127,7 @@ struct OrdersTests { try await app.test( .GET, - "\(ordersURI)devices/\(deviceLibraryIdentifier)/registrations/\(order.orderTypeIdentifier)?ordersModifiedSince=0", + "\(ordersURI)devices/\(deviceLibraryIdentifier)/registrations/\(order.typeIdentifier)?ordersModifiedSince=0", afterResponse: { res async throws in #expect(res.status == .noContent) } @@ -134,7 +135,7 @@ struct OrdersTests { try await app.test( .DELETE, - "\(ordersURI)devices/\(deviceLibraryIdentifier)/registrations/\(order.orderTypeIdentifier)/\(order.requireID())", + "\(ordersURI)devices/\(deviceLibraryIdentifier)/registrations/\(order.typeIdentifier)/\(order.requireID())", headers: ["Authorization": "AppleOrder \(order.authenticationToken)"], afterResponse: { res async throws in #expect(res.status == .notFound) @@ -144,7 +145,7 @@ struct OrdersTests { // Test registration without authentication token try await app.test( .POST, - "\(ordersURI)devices/\(deviceLibraryIdentifier)/registrations/\(order.orderTypeIdentifier)/\(order.requireID())", + "\(ordersURI)devices/\(deviceLibraryIdentifier)/registrations/\(order.typeIdentifier)/\(order.requireID())", beforeRequest: { req async throws in try req.content.encode(RegistrationDTO(pushToken: pushToken)) }, @@ -169,7 +170,7 @@ struct OrdersTests { // Test call without DTO try await app.test( .POST, - "\(ordersURI)devices/\(deviceLibraryIdentifier)/registrations/\(order.orderTypeIdentifier)/\(order.requireID())", + "\(ordersURI)devices/\(deviceLibraryIdentifier)/registrations/\(order.typeIdentifier)/\(order.requireID())", headers: ["Authorization": "AppleOrder \(order.authenticationToken)"], afterResponse: { res async throws in #expect(res.status == .badRequest) @@ -179,7 +180,7 @@ struct OrdersTests { // Test call with invalid UUID try await app.test( .POST, - "\(ordersURI)devices/\(deviceLibraryIdentifier)/registrations/\(order.orderTypeIdentifier)/\("not-a-uuid")", + "\(ordersURI)devices/\(deviceLibraryIdentifier)/registrations/\(order.typeIdentifier)/\("not-a-uuid")", headers: ["Authorization": "AppleOrder \(order.authenticationToken)"], beforeRequest: { req async throws in try req.content.encode(RegistrationDTO(pushToken: pushToken)) @@ -191,7 +192,7 @@ struct OrdersTests { try await app.test( .POST, - "\(ordersURI)devices/\(deviceLibraryIdentifier)/registrations/\(order.orderTypeIdentifier)/\(order.requireID())", + "\(ordersURI)devices/\(deviceLibraryIdentifier)/registrations/\(order.typeIdentifier)/\(order.requireID())", headers: ["Authorization": "AppleOrder \(order.authenticationToken)"], beforeRequest: { req async throws in try req.content.encode(RegistrationDTO(pushToken: pushToken)) @@ -204,7 +205,7 @@ struct OrdersTests { // Test registration of an already registered device try await app.test( .POST, - "\(ordersURI)devices/\(deviceLibraryIdentifier)/registrations/\(order.orderTypeIdentifier)/\(order.requireID())", + "\(ordersURI)devices/\(deviceLibraryIdentifier)/registrations/\(order.typeIdentifier)/\(order.requireID())", headers: ["Authorization": "AppleOrder \(order.authenticationToken)"], beforeRequest: { req async throws in try req.content.encode(RegistrationDTO(pushToken: pushToken)) @@ -216,7 +217,7 @@ struct OrdersTests { try await app.test( .GET, - "\(ordersURI)devices/\(deviceLibraryIdentifier)/registrations/\(order.orderTypeIdentifier)?ordersModifiedSince=0", + "\(ordersURI)devices/\(deviceLibraryIdentifier)/registrations/\(order.typeIdentifier)?ordersModifiedSince=0", afterResponse: { res async throws in let orders = try res.content.decode(OrdersForDeviceDTO.self) #expect(orders.orderIdentifiers.count == 1) @@ -228,7 +229,7 @@ struct OrdersTests { try await app.test( .GET, - "\(ordersURI)push/\(order.orderTypeIdentifier)/\(order.requireID())", + "\(ordersURI)push/\(order.typeIdentifier)/\(order.requireID())", headers: ["X-Secret": "foo"], afterResponse: { res async throws in let pushTokens = try res.content.decode([String].self) @@ -240,7 +241,7 @@ struct OrdersTests { // Test call with invalid UUID try await app.test( .GET, - "\(ordersURI)push/\(order.orderTypeIdentifier)/\("not-a-uuid")", + "\(ordersURI)push/\(order.typeIdentifier)/\("not-a-uuid")", headers: ["X-Secret": "foo"], afterResponse: { res async throws in #expect(res.status == .badRequest) @@ -250,7 +251,7 @@ struct OrdersTests { // Test call with invalid UUID try await app.test( .DELETE, - "\(ordersURI)devices/\(deviceLibraryIdentifier)/registrations/\(order.orderTypeIdentifier)/\("not-a-uuid")", + "\(ordersURI)devices/\(deviceLibraryIdentifier)/registrations/\(order.typeIdentifier)/\("not-a-uuid")", headers: ["Authorization": "AppleOrder \(order.authenticationToken)"], afterResponse: { res async throws in #expect(res.status == .badRequest) @@ -259,7 +260,7 @@ struct OrdersTests { try await app.test( .DELETE, - "\(ordersURI)devices/\(deviceLibraryIdentifier)/registrations/\(order.orderTypeIdentifier)/\(order.requireID())", + "\(ordersURI)devices/\(deviceLibraryIdentifier)/registrations/\(order.typeIdentifier)/\(order.requireID())", headers: ["Authorization": "AppleOrder \(order.authenticationToken)"], afterResponse: { res async throws in #expect(res.status == .ok) @@ -270,7 +271,7 @@ struct OrdersTests { @Test("Error Logging") func errorLog() async throws { - try await withApp(delegate: delegate) { app, ordersService in + try await withApp { app, ordersService in let log1 = "Error 1" let log2 = "Error 2" @@ -313,16 +314,16 @@ struct OrdersTests { } } - @Test("APNS Client") - func apnsClient() async throws { - try await withApp(delegate: delegate) { app, ordersService in + @Test("APNS Client", arguments: [true, false]) + func apnsClient(useEncryptedKey: Bool) async throws { + try await withApp(useEncryptedKey: useEncryptedKey) { app, ordersService in #expect(app.apns.client(.init(string: "orders")) != nil) let orderData = OrderData(title: "Test Order") 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.typeIdentifier, on: app.db) let deviceLibraryIdentifier = "abcdefg" let pushToken = "1234567890" @@ -330,7 +331,7 @@ struct OrdersTests { // Test call with incorrect secret try await app.test( .POST, - "\(ordersURI)push/\(order.orderTypeIdentifier)/\(order.requireID())", + "\(ordersURI)push/\(order.typeIdentifier)/\(order.requireID())", headers: ["X-Secret": "bar"], afterResponse: { res async throws in #expect(res.status == .unauthorized) @@ -339,7 +340,7 @@ struct OrdersTests { try await app.test( .POST, - "\(ordersURI)push/\(order.orderTypeIdentifier)/\(order.requireID())", + "\(ordersURI)push/\(order.typeIdentifier)/\(order.requireID())", headers: ["X-Secret": "foo"], afterResponse: { res async throws in #expect(res.status == .noContent) @@ -348,7 +349,7 @@ struct OrdersTests { try await app.test( .POST, - "\(ordersURI)devices/\(deviceLibraryIdentifier)/registrations/\(order.orderTypeIdentifier)/\(order.requireID())", + "\(ordersURI)devices/\(deviceLibraryIdentifier)/registrations/\(order.typeIdentifier)/\(order.requireID())", headers: ["Authorization": "AppleOrder \(order.authenticationToken)"], beforeRequest: { req async throws in try req.content.encode(RegistrationDTO(pushToken: pushToken)) @@ -360,7 +361,7 @@ struct OrdersTests { try await app.test( .POST, - "\(ordersURI)push/\(order.orderTypeIdentifier)/\(order.requireID())", + "\(ordersURI)push/\(order.typeIdentifier)/\(order.requireID())", headers: ["X-Secret": "foo"], afterResponse: { res async throws in #expect(res.status == .internalServerError) @@ -370,19 +371,21 @@ struct OrdersTests { // Test call with invalid UUID try await app.test( .POST, - "\(ordersURI)push/\(order.orderTypeIdentifier)/\("not-a-uuid")", + "\(ordersURI)push/\(order.typeIdentifier)/\("not-a-uuid")", headers: ["X-Secret": "foo"], afterResponse: { res async throws in #expect(res.status == .badRequest) } ) - // Test `OrderDataMiddleware` update method - orderData.title = "Test Order 2" - do { - try await orderData.update(on: app.db) - } catch let error as HTTPClientError { - #expect(error.self == .remoteConnectionClosed) + if !useEncryptedKey { + // Test `OrderDataMiddleware` update method + orderData.title = "Test Order 2" + do { + try await orderData.update(on: app.db) + } catch let error as HTTPClientError { + #expect(error.self == .remoteConnectionClosed) + } } } } @@ -397,22 +400,11 @@ struct OrdersTests { @Test("Default OrdersDelegate Properties") func defaultDelegate() { - let delegate = DefaultOrdersDelegate() - #expect(delegate.wwdrCertificate == "WWDR.pem") - #expect(delegate.pemCertificate == "ordercertificate.pem") - #expect(delegate.pemPrivateKey == "orderkey.pem") - #expect(delegate.pemPrivateKeyPassword == nil) - #expect(delegate.sslBinary == URL(fileURLWithPath: "/usr/bin/openssl")) - #expect(!delegate.generateSignatureFile(in: URL(fileURLWithPath: ""))) - } -} + final class DefaultOrdersDelegate: OrdersDelegate { + func template(for order: O, db: any Database) async throws -> String { "" } + func encode(order: O, db: any Database, encoder: JSONEncoder) async throws -> Data { Data() } + } -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() + #expect(!DefaultOrdersDelegate().generateSignatureFile(in: URL(fileURLWithPath: ""))) } } diff --git a/Tests/OrdersTests/TestOrdersDelegate.swift b/Tests/OrdersTests/TestOrdersDelegate.swift index ae36748..b5b45bb 100644 --- a/Tests/OrdersTests/TestOrdersDelegate.swift +++ b/Tests/OrdersTests/TestOrdersDelegate.swift @@ -3,17 +3,7 @@ import Orders import Vapor final class TestOrdersDelegate: OrdersDelegate { - let sslSigningFilesDirectory = URL( - fileURLWithPath: "\(FileManager.default.currentDirectoryPath)/Tests/Certificates/", - isDirectory: true - ) - - let pemCertificate = "certificate.pem" - let pemPrivateKey = "key.pem" - - func encode( - order: O, db: any Database, encoder: JSONEncoder - ) async throws -> Data { + 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()) @@ -22,18 +12,13 @@ final class TestOrdersDelegate: OrdersDelegate { 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 } - func template(for: O, db: any Database) async throws -> URL { - URL( - fileURLWithPath: - "\(FileManager.default.currentDirectoryPath)/Tests/OrdersTests/Templates/", - isDirectory: true - ) + func template(for: O, db: any Database) async throws -> String { + "\(FileManager.default.currentDirectoryPath)/Tests/OrdersTests/Templates/" } } diff --git a/Tests/OrdersTests/withApp.swift b/Tests/OrdersTests/withApp.swift index ab5df1d..5075c87 100644 --- a/Tests/OrdersTests/withApp.swift +++ b/Tests/OrdersTests/withApp.swift @@ -7,32 +7,39 @@ import Vapor import Zip func withApp( - delegate: some OrdersDelegate, + useEncryptedKey: Bool = false, _ body: (Application, OrdersService) async throws -> Void ) async throws { let app = try await Application.make(.testing) - - try #require(isLoggingConfigured) - - app.databases.use(.sqlite(.memory), as: .sqlite) - - OrdersService.register(migrations: app.migrations) - app.migrations.add(CreateOrderData()) - let passesService = try OrdersService( - app: app, - delegate: delegate, - pushRoutesMiddleware: SecretMiddleware(secret: "foo"), - logger: app.logger - ) - - app.databases.middleware.use(OrderDataMiddleware(service: passesService), on: .sqlite) - - try await app.autoMigrate() - - Zip.addCustomFileExtension("order") - - try await body(app, passesService) - - try await app.autoRevert() + do { + try #require(isLoggingConfigured) + + app.databases.use(.sqlite(.memory), as: .sqlite) + OrdersService.register(migrations: app.migrations) + app.migrations.add(CreateOrderData()) + try await app.autoMigrate() + + let delegate = TestOrdersDelegate() + let ordersService = try OrdersService( + app: app, + delegate: delegate, + signingFilesDirectory: "\(FileManager.default.currentDirectoryPath)/Tests/Certificates/", + pemCertificate: useEncryptedKey ? "encryptedcert.pem" : "certificate.pem", + pemPrivateKey: useEncryptedKey ? "encryptedkey.pem" : "key.pem", + pemPrivateKeyPassword: useEncryptedKey ? "password" : nil, + pushRoutesMiddleware: SecretMiddleware(secret: "foo"), + logger: app.logger + ) + app.databases.middleware.use(OrderDataMiddleware(service: ordersService), on: .sqlite) + + Zip.addCustomFileExtension("order") + + try await body(app, ordersService) + + try await app.autoRevert() + } catch { + try await app.asyncShutdown() + throw error + } try await app.asyncShutdown() } diff --git a/Tests/PassesTests/EncryptedPassesDelegate.swift b/Tests/PassesTests/EncryptedPassesDelegate.swift deleted file mode 100644 index 5c887e5..0000000 --- a/Tests/PassesTests/EncryptedPassesDelegate.swift +++ /dev/null @@ -1,65 +0,0 @@ -import FluentKit -import Passes -import Vapor - -final class EncryptedPassesDelegate: PassesDelegate { - let sslSigningFilesDirectory = URL( - fileURLWithPath: "\(FileManager.default.currentDirectoryPath)/Tests/Certificates/", - isDirectory: true - ) - - let pemCertificate = "encryptedcert.pem" - let pemPrivateKey = "encryptedkey.pem" - - 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() - else { - throw Abort(.internalServerError) - } - 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() - 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 - } - } - - func template(for pass: P, db: any Database) async throws -> URL { - URL( - fileURLWithPath: - "\(FileManager.default.currentDirectoryPath)/Tests/PassesTests/Templates/", - isDirectory: true - ) - } -} diff --git a/Tests/PassesTests/EncryptedPassesTests.swift b/Tests/PassesTests/EncryptedPassesTests.swift deleted file mode 100644 index f533c6e..0000000 --- a/Tests/PassesTests/EncryptedPassesTests.swift +++ /dev/null @@ -1,139 +0,0 @@ -import PassKit -import Testing -import XCTVapor -import Zip - -@testable import Passes - -@Suite("Passes Tests with Encrypted PEM Key") -struct EncryptedPassesTests { - let delegate = EncryptedPassesDelegate() - let passesURI = "/api/passes/v1/" - - @Test("Pass Generation") - func passGeneration() async throws { - try await withApp(delegate: delegate) { app, passesService in - 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 passesService.generatePassContent(for: pass, on: app.db) - let passURL = FileManager.default.temporaryDirectory.appendingPathComponent("test.pkpass") - try data.write(to: passURL) - let passFolder = try Zip.quickUnzipFile(passURL) - - #expect(FileManager.default.fileExists(atPath: passFolder.path.appending("/signature"))) - - let passJSONData = try String(contentsOfFile: passFolder.path.appending("/pass.json")).data(using: .utf8) - let passJSON = try JSONSerialization.jsonObject(with: passJSONData!) as! [String: Any] - #expect(passJSON["authenticationToken"] as? String == pass.authenticationToken) - let passID = try pass.requireID().uuidString - #expect(passJSON["serialNumber"] as? String == passID) - #expect(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 iconData = try Data(contentsOf: passFolder.appendingPathComponent("/icon.png")) - let iconHash = Array(Insecure.SHA1.hash(data: iconData)).hex - #expect(manifestJSON["icon.png"] as? String == iconHash) - } - } - - @Test("Personalizable Pass Apple Wallet API") - func personalizationAPI() async throws { - try await withApp(delegate: delegate) { app, passesService in - let passData = PassData(title: "Personalize") - try await passData.create(on: app.db) - let pass = try await passData.$pass.get(on: app.db) - let personalizationDict = PersonalizationDictionaryDTO( - personalizationToken: "1234567890", - requiredPersonalizationInfo: .init( - emailAddress: "test@example.com", - familyName: "Doe", - fullName: "John Doe", - givenName: "John", - isoCountryCode: "US", - phoneNumber: "1234567890", - postalCode: "12345" - ) - ) - - try await app.test( - .POST, - "\(passesURI)passes/\(pass.passTypeIdentifier)/\(pass.requireID())/personalize", - headers: ["Authorization": "ApplePass \(pass.authenticationToken)"], - beforeRequest: { req async throws in - try req.content.encode(personalizationDict) - }, - afterResponse: { res async throws in - #expect(res.status == .ok) - #expect(res.body != nil) - #expect(res.headers.contentType?.description == "application/octet-stream") - } - ) - - let personalizationQuery = try await UserPersonalization.query(on: app.db).all() - #expect(personalizationQuery.count == 1) - let passPersonalizationID = try await Pass.query(on: app.db).first()?._$userPersonalization.get(on: app.db)?.requireID() - #expect(personalizationQuery[0]._$id.value == passPersonalizationID) - #expect(personalizationQuery[0]._$emailAddress.value == personalizationDict.requiredPersonalizationInfo.emailAddress) - #expect(personalizationQuery[0]._$familyName.value == personalizationDict.requiredPersonalizationInfo.familyName) - #expect(personalizationQuery[0]._$fullName.value == personalizationDict.requiredPersonalizationInfo.fullName) - #expect(personalizationQuery[0]._$givenName.value == personalizationDict.requiredPersonalizationInfo.givenName) - #expect(personalizationQuery[0]._$isoCountryCode.value == personalizationDict.requiredPersonalizationInfo.isoCountryCode) - #expect(personalizationQuery[0]._$phoneNumber.value == personalizationDict.requiredPersonalizationInfo.phoneNumber) - #expect(personalizationQuery[0]._$postalCode.value == personalizationDict.requiredPersonalizationInfo.postalCode) - } - } - - @Test("APNS Client") - func apnsClient() async throws { - try await withApp(delegate: delegate) { app, passesService in - #expect(app.apns.client(.init(string: "passes")) != nil) - - 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) - - let deviceLibraryIdentifier = "abcdefg" - let pushToken = "1234567890" - - try await app.test( - .POST, - "\(passesURI)push/\(pass.passTypeIdentifier)/\(pass.requireID())", - headers: ["X-Secret": "foo"], - afterResponse: { res async throws in - #expect(res.status == .noContent) - } - ) - - try await app.test( - .POST, - "\(passesURI)devices/\(deviceLibraryIdentifier)/registrations/\(pass.passTypeIdentifier)/\(pass.requireID())", - headers: ["Authorization": "ApplePass \(pass.authenticationToken)"], - beforeRequest: { req async throws in - try req.content.encode(RegistrationDTO(pushToken: pushToken)) - }, - afterResponse: { res async throws in - #expect(res.status == .created) - } - ) - - try await app.test( - .POST, - "\(passesURI)push/\(pass.passTypeIdentifier)/\(pass.requireID())", - headers: ["X-Secret": "foo"], - afterResponse: { res async throws in - #expect(res.status == .internalServerError) - } - ) - - // Test `PassDataMiddleware` update method - passData.title = "Test Pass 2" - do { - try await passData.update(on: app.db) - } catch {} - } - } -} diff --git a/Tests/PassesTests/PassData.swift b/Tests/PassesTests/PassData.swift index bf96087..c5388c4 100644 --- a/Tests/PassesTests/PassData.swift +++ b/Tests/PassesTests/PassData.swift @@ -22,28 +22,6 @@ final class PassData: PassDataModel, @unchecked Sendable { self.id = id self.title = title } - - func toDTO() -> PassDataDTO { - .init( - id: self.id, - title: self.$title.value - ) - } -} - -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 - } - return model - } } struct CreatePassData: AsyncMigration { @@ -51,10 +29,7 @@ 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() } @@ -71,7 +46,11 @@ extension PassData { } } -struct PassJSONData: PassJSON.Properties { +extension PassJSON.FormatVersion: Decodable {} +extension PassJSON.BarcodeFormat: Decodable {} +extension PassJSON.TransitType: Decodable {} + +struct PassJSONData: PassJSON.Properties, Decodable { let description: String let formatVersion = PassJSON.FormatVersion.v1 let organizationName = "vapor-community" @@ -80,21 +59,25 @@ struct PassJSONData: PassJSON.Properties { let teamIdentifier = "K6512ZA2S5" private let webServiceURL = "https://www.example.com/api/passes/" - private let authenticationToken: String + let authenticationToken: String private let logoText = "Vapor Community" 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 { + struct Barcode: PassJSON.Barcodes, Decodable { let format = PassJSON.BarcodeFormat.qr let message: String let messageEncoding = "iso-8859-1" + + enum CodingKeys: String, CodingKey { + case format, message, messageEncoding + } } let boardingPass = Boarding(transitType: .air) - struct Boarding: PassJSON.BoardingPass { + struct Boarding: PassJSON.BoardingPass, Decodable { let transitType: PassJSON.TransitType let headerFields: [PassField] let primaryFields: [PassField] @@ -102,7 +85,7 @@ struct PassJSONData: PassJSON.Properties { let auxiliaryFields: [PassField] let backFields: [PassField] - struct PassField: PassJSON.PassFieldContent { + struct PassField: PassJSON.PassFieldContent, Decodable { let key: String let label: String let value: String @@ -118,6 +101,15 @@ struct PassJSONData: PassJSON.Properties { } } + enum CodingKeys: String, CodingKey { + case description + case formatVersion + case organizationName, passTypeIdentifier, serialNumber, teamIdentifier + case webServiceURL, authenticationToken + case logoText, sharingProhibited, backgroundColor, foregroundColor + case barcodes, boardingPass + } + init(data: PassData, pass: Pass) { self.description = data.title self.serialNumber = pass.id!.uuidString @@ -125,16 +117,6 @@ struct PassJSONData: PassJSON.Properties { } } -struct PersonalizationJSONData: PersonalizationJSON.Properties { - var requiredPersonalizationFields = [ - PersonalizationJSON.PersonalizationField.name, - PersonalizationJSON.PersonalizationField.postalCode, - PersonalizationJSON.PersonalizationField.emailAddress, - PersonalizationJSON.PersonalizationField.phoneNumber, - ] - var description = "Hello, World!" -} - struct PassDataMiddleware: AsyncModelMiddleware { private unowned let service: PassesService @@ -142,20 +124,17 @@ 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()) + typeIdentifier: "pass.com.vapor-community.PassKit", + authenticationToken: Data([UInt8].random(count: 12)).base64EncodedString() + ) try await pass.save(on: db) model.$pass.id = try pass.requireID() 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 7742d25..5bccc26 100644 --- a/Tests/PassesTests/PassesTests.swift +++ b/Tests/PassesTests/PassesTests.swift @@ -6,36 +6,37 @@ import Zip @testable import Passes -@Suite("Passes Tests") +@Suite("Passes Tests", .serialized) struct PassesTests { - let delegate = TestPassesDelegate() let passesURI = "/api/passes/v1/" + let decoder = JSONDecoder() - @Test("Pass Generation") - func passGeneration() async throws { - try await withApp(delegate: delegate) { app, passesService in + @Test("Pass Generation", arguments: [true, false]) + func passGeneration(useEncryptedKey: Bool) async throws { + try await withApp(useEncryptedKey: useEncryptedKey) { app, passesService in 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 passesService.generatePassContent(for: pass, on: app.db) - let passURL = FileManager.default.temporaryDirectory.appendingPathComponent("test.pkpass") + let passURL = FileManager.default.temporaryDirectory.appendingPathComponent("\(UUID().uuidString).pkpass") try data.write(to: passURL) - let passFolder = try Zip.quickUnzipFile(passURL) + let passFolder = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + try Zip.unzipFile(passURL, destination: passFolder) #expect(FileManager.default.fileExists(atPath: passFolder.path.appending("/signature"))) + #expect(FileManager.default.fileExists(atPath: passFolder.path.appending("/pass.json"))) let passJSONData = try String(contentsOfFile: passFolder.path.appending("/pass.json")).data(using: .utf8) - let passJSON = try JSONSerialization.jsonObject(with: passJSONData!) as! [String: Any] - #expect(passJSON["authenticationToken"] as? String == pass.authenticationToken) + let passJSON = try decoder.decode(PassJSONData.self, from: passJSONData!) + #expect(passJSON.authenticationToken == pass.authenticationToken) let passID = try pass.requireID().uuidString - #expect(passJSON["serialNumber"] as? String == passID) - #expect(passJSON["description"] as? String == passData.title) + #expect(passJSON.serialNumber == passID) + #expect(passJSON.description == 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 manifestJSON = try decoder.decode([String: String].self, from: manifestJSONData!) let iconData = try Data(contentsOf: passFolder.appendingPathComponent("/icon.png")) - let iconHash = Array(Insecure.SHA1.hash(data: iconData)).hex - #expect(manifestJSON["icon.png"] as? String == iconHash) + #expect(manifestJSON["icon.png"] == Insecure.SHA1.hash(data: iconData).hex) #expect(manifestJSON["logo.png"] != nil) #expect(manifestJSON["personalizationLogo.png"] != nil) } @@ -43,7 +44,7 @@ struct PassesTests { @Test("Generating Multiple Passes") func passesGeneration() async throws { - try await withApp(delegate: delegate) { app, passesService in + try await withApp { app, passesService in let passData1 = PassData(title: "Test Pass 1") try await passData1.create(on: app.db) let pass1 = try await passData1.$pass.get(on: app.db) @@ -66,44 +67,47 @@ struct PassesTests { @Test("Personalizable Passes") func personalization() async throws { - try await withApp(delegate: delegate) { app, passesService in + try await withApp { app, passesService in let passData = PassData(title: "Personalize") try await passData.create(on: app.db) let pass = try await passData.$pass.get(on: app.db) let data = try await passesService.generatePassContent(for: pass, on: app.db) - let passURL = FileManager.default.temporaryDirectory.appendingPathComponent("test.pkpass") + let passURL = FileManager.default.temporaryDirectory.appendingPathComponent("\(UUID().uuidString).pkpass") try data.write(to: passURL) - let passFolder = try Zip.quickUnzipFile(passURL) + let passFolder = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + try Zip.unzipFile(passURL, destination: passFolder) + #expect(FileManager.default.fileExists(atPath: passFolder.path.appending("/signature"))) + + #expect(FileManager.default.fileExists(atPath: passFolder.path.appending("/pass.json"))) let passJSONData = try String(contentsOfFile: passFolder.path.appending("/pass.json")).data(using: .utf8) - let passJSON = try JSONSerialization.jsonObject(with: passJSONData!) as! [String: Any] - #expect(passJSON["authenticationToken"] as? String == pass.authenticationToken) + let passJSON = try decoder.decode(PassJSONData.self, from: passJSONData!) + #expect(passJSON.authenticationToken == pass.authenticationToken) let passID = try pass.requireID().uuidString - #expect(passJSON["serialNumber"] as? String == passID) - #expect(passJSON["description"] as? String == passData.title) + #expect(passJSON.serialNumber == passID) + #expect(passJSON.description == 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] - #expect(personalizationJSON["description"] as? String == "Hello, World!") + let personalizationJSON = try decoder.decode(PersonalizationJSON.self, from: personalizationJSONData!) + #expect(personalizationJSON.description == "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 manifestJSON = try decoder.decode([String: String].self, from: manifestJSONData!) let iconData = try Data(contentsOf: passFolder.appendingPathComponent("/personalizationLogo.png")) - let iconHash = Array(Insecure.SHA1.hash(data: iconData)).hex - #expect(manifestJSON["personalizationLogo.png"] as? String == iconHash) + #expect(manifestJSON["personalizationLogo.png"] == Insecure.SHA1.hash(data: iconData).hex) } } @Test("Getting Pass from Apple Wallet API") func getPassFromAPI() async throws { - try await withApp(delegate: delegate) { app, passesService in + try await withApp { app, passesService in 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 app.test( .GET, - "\(passesURI)passes/\(pass.passTypeIdentifier)/\(pass.requireID())", + "\(passesURI)passes/\(pass.typeIdentifier)/\(pass.requireID())", headers: [ "Authorization": "ApplePass \(pass.authenticationToken)", "If-Modified-Since": "0", @@ -119,7 +123,7 @@ struct PassesTests { // Test call with invalid authentication token try await app.test( .GET, - "\(passesURI)passes/\(pass.passTypeIdentifier)/\(pass.requireID())", + "\(passesURI)passes/\(pass.typeIdentifier)/\(pass.requireID())", headers: [ "Authorization": "ApplePass invalid-token", "If-Modified-Since": "0", @@ -132,7 +136,7 @@ struct PassesTests { // Test distant future `If-Modified-Since` date try await app.test( .GET, - "\(passesURI)passes/\(pass.passTypeIdentifier)/\(pass.requireID())", + "\(passesURI)passes/\(pass.typeIdentifier)/\(pass.requireID())", headers: [ "Authorization": "ApplePass \(pass.authenticationToken)", "If-Modified-Since": "2147483647", @@ -145,7 +149,7 @@ struct PassesTests { // Test call with invalid pass ID try await app.test( .GET, - "\(passesURI)passes/\(pass.passTypeIdentifier)/invalid-uuid", + "\(passesURI)passes/\(pass.typeIdentifier)/invalid-uuid", headers: [ "Authorization": "ApplePass \(pass.authenticationToken)", "If-Modified-Since": "0", @@ -170,9 +174,9 @@ struct PassesTests { } } - @Test("Personalizable Pass Apple Wallet API") - func personalizationAPI() async throws { - try await withApp(delegate: delegate) { app, passesService in + @Test("Personalizable Pass Apple Wallet API", arguments: [true, false]) + func personalizationAPI(useEncryptedKey: Bool) async throws { + try await withApp(useEncryptedKey: useEncryptedKey) { app, passesService in let passData = PassData(title: "Personalize") try await passData.create(on: app.db) let pass = try await passData.$pass.get(on: app.db) @@ -191,7 +195,7 @@ struct PassesTests { try await app.test( .POST, - "\(passesURI)passes/\(pass.passTypeIdentifier)/\(pass.requireID())/personalize", + "\(passesURI)passes/\(pass.typeIdentifier)/\(pass.requireID())/personalize", beforeRequest: { req async throws in try req.content.encode(personalizationDict) }, @@ -217,7 +221,7 @@ struct PassesTests { // Test call with invalid pass ID try await app.test( .POST, - "\(passesURI)passes/\(pass.passTypeIdentifier)/invalid-uuid/personalize", + "\(passesURI)passes/\(pass.typeIdentifier)/invalid-uuid/personalize", beforeRequest: { req async throws in try req.content.encode(personalizationDict) }, @@ -242,7 +246,7 @@ struct PassesTests { @Test("Device Registration API") func apiDeviceRegistration() async throws { - try await withApp(delegate: delegate) { app, passesService in + try await withApp { app, passesService in let passData = PassData(title: "Test Pass") try await passData.create(on: app.db) let pass = try await passData.$pass.get(on: app.db) @@ -251,7 +255,7 @@ struct PassesTests { try await app.test( .GET, - "\(passesURI)devices/\(deviceLibraryIdentifier)/registrations/\(pass.passTypeIdentifier)?passesUpdatedSince=0", + "\(passesURI)devices/\(deviceLibraryIdentifier)/registrations/\(pass.typeIdentifier)?passesUpdatedSince=0", afterResponse: { res async throws in #expect(res.status == .noContent) } @@ -259,7 +263,7 @@ struct PassesTests { try await app.test( .DELETE, - "\(passesURI)devices/\(deviceLibraryIdentifier)/registrations/\(pass.passTypeIdentifier)/\(pass.requireID())", + "\(passesURI)devices/\(deviceLibraryIdentifier)/registrations/\(pass.typeIdentifier)/\(pass.requireID())", headers: ["Authorization": "ApplePass \(pass.authenticationToken)"], afterResponse: { res async throws in #expect(res.status == .notFound) @@ -269,7 +273,7 @@ struct PassesTests { // Test registration without authentication token try await app.test( .POST, - "\(passesURI)devices/\(deviceLibraryIdentifier)/registrations/\(pass.passTypeIdentifier)/\(pass.requireID())", + "\(passesURI)devices/\(deviceLibraryIdentifier)/registrations/\(pass.typeIdentifier)/\(pass.requireID())", beforeRequest: { req async throws in try req.content.encode(RegistrationDTO(pushToken: pushToken)) }, @@ -294,7 +298,7 @@ struct PassesTests { // Test call without DTO try await app.test( .POST, - "\(passesURI)devices/\(deviceLibraryIdentifier)/registrations/\(pass.passTypeIdentifier)/\(pass.requireID())", + "\(passesURI)devices/\(deviceLibraryIdentifier)/registrations/\(pass.typeIdentifier)/\(pass.requireID())", headers: ["Authorization": "ApplePass \(pass.authenticationToken)"], afterResponse: { res async throws in #expect(res.status == .badRequest) @@ -304,7 +308,7 @@ struct PassesTests { // Test call with invalid UUID try await app.test( .POST, - "\(passesURI)devices/\(deviceLibraryIdentifier)/registrations/\(pass.passTypeIdentifier)/\("not-a-uuid")", + "\(passesURI)devices/\(deviceLibraryIdentifier)/registrations/\(pass.typeIdentifier)/\("not-a-uuid")", headers: ["Authorization": "ApplePass \(pass.authenticationToken)"], beforeRequest: { req async throws in try req.content.encode(RegistrationDTO(pushToken: pushToken)) @@ -316,7 +320,7 @@ struct PassesTests { try await app.test( .POST, - "\(passesURI)devices/\(deviceLibraryIdentifier)/registrations/\(pass.passTypeIdentifier)/\(pass.requireID())", + "\(passesURI)devices/\(deviceLibraryIdentifier)/registrations/\(pass.typeIdentifier)/\(pass.requireID())", headers: ["Authorization": "ApplePass \(pass.authenticationToken)"], beforeRequest: { req async throws in try req.content.encode(RegistrationDTO(pushToken: pushToken)) @@ -329,7 +333,7 @@ struct PassesTests { // Test registration of an already registered device try await app.test( .POST, - "\(passesURI)devices/\(deviceLibraryIdentifier)/registrations/\(pass.passTypeIdentifier)/\(pass.requireID())", + "\(passesURI)devices/\(deviceLibraryIdentifier)/registrations/\(pass.typeIdentifier)/\(pass.requireID())", headers: ["Authorization": "ApplePass \(pass.authenticationToken)"], beforeRequest: { req async throws in try req.content.encode(RegistrationDTO(pushToken: pushToken)) @@ -341,7 +345,7 @@ struct PassesTests { try await app.test( .GET, - "\(passesURI)devices/\(deviceLibraryIdentifier)/registrations/\(pass.passTypeIdentifier)?passesUpdatedSince=0", + "\(passesURI)devices/\(deviceLibraryIdentifier)/registrations/\(pass.typeIdentifier)?passesUpdatedSince=0", afterResponse: { res async throws in let passes = try res.content.decode(PassesForDeviceDTO.self) #expect(passes.serialNumbers.count == 1) @@ -353,7 +357,7 @@ struct PassesTests { try await app.test( .GET, - "\(passesURI)push/\(pass.passTypeIdentifier)/\(pass.requireID())", + "\(passesURI)push/\(pass.typeIdentifier)/\(pass.requireID())", headers: ["X-Secret": "foo"], afterResponse: { res async throws in let pushTokens = try res.content.decode([String].self) @@ -365,7 +369,7 @@ struct PassesTests { // Test call with invalid UUID try await app.test( .GET, - "\(passesURI)push/\(pass.passTypeIdentifier)/\("not-a-uuid")", + "\(passesURI)push/\(pass.typeIdentifier)/\("not-a-uuid")", headers: ["X-Secret": "foo"], afterResponse: { res async throws in #expect(res.status == .badRequest) @@ -375,7 +379,7 @@ struct PassesTests { // Test call with invalid UUID try await app.test( .DELETE, - "\(passesURI)devices/\(deviceLibraryIdentifier)/registrations/\(pass.passTypeIdentifier)/\("not-a-uuid")", + "\(passesURI)devices/\(deviceLibraryIdentifier)/registrations/\(pass.typeIdentifier)/\("not-a-uuid")", headers: ["Authorization": "ApplePass \(pass.authenticationToken)"], afterResponse: { res async throws in #expect(res.status == .badRequest) @@ -384,7 +388,7 @@ struct PassesTests { try await app.test( .DELETE, - "\(passesURI)devices/\(deviceLibraryIdentifier)/registrations/\(pass.passTypeIdentifier)/\(pass.requireID())", + "\(passesURI)devices/\(deviceLibraryIdentifier)/registrations/\(pass.typeIdentifier)/\(pass.requireID())", headers: ["Authorization": "ApplePass \(pass.authenticationToken)"], afterResponse: { res async throws in #expect(res.status == .ok) @@ -395,7 +399,7 @@ struct PassesTests { @Test("Error Logging") func errorLog() async throws { - try await withApp(delegate: delegate) { app, passesService in + try await withApp { app, passesService in let log1 = "Error 1" let log2 = "Error 2" @@ -438,16 +442,16 @@ struct PassesTests { } } - @Test("APNS Client") - func apnsClient() async throws { - try await withApp(delegate: delegate) { app, passesService in + @Test("APNS Client", arguments: [true, false]) + func apnsClient(useEncryptedKey: Bool) async throws { + try await withApp(useEncryptedKey: useEncryptedKey) { app, passesService in #expect(app.apns.client(.init(string: "passes")) != nil) 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.typeIdentifier, on: app.db) let deviceLibraryIdentifier = "abcdefg" let pushToken = "1234567890" @@ -455,7 +459,7 @@ struct PassesTests { // Test call with incorrect secret try await app.test( .POST, - "\(passesURI)push/\(pass.passTypeIdentifier)/\(pass.requireID())", + "\(passesURI)push/\(pass.typeIdentifier)/\(pass.requireID())", headers: ["X-Secret": "bar"], afterResponse: { res async throws in #expect(res.status == .unauthorized) @@ -464,7 +468,7 @@ struct PassesTests { try await app.test( .POST, - "\(passesURI)push/\(pass.passTypeIdentifier)/\(pass.requireID())", + "\(passesURI)push/\(pass.typeIdentifier)/\(pass.requireID())", headers: ["X-Secret": "foo"], afterResponse: { res async throws in #expect(res.status == .noContent) @@ -473,7 +477,7 @@ struct PassesTests { try await app.test( .POST, - "\(passesURI)devices/\(deviceLibraryIdentifier)/registrations/\(pass.passTypeIdentifier)/\(pass.requireID())", + "\(passesURI)devices/\(deviceLibraryIdentifier)/registrations/\(pass.typeIdentifier)/\(pass.requireID())", headers: ["Authorization": "ApplePass \(pass.authenticationToken)"], beforeRequest: { req async throws in try req.content.encode(RegistrationDTO(pushToken: pushToken)) @@ -485,7 +489,7 @@ struct PassesTests { try await app.test( .POST, - "\(passesURI)push/\(pass.passTypeIdentifier)/\(pass.requireID())", + "\(passesURI)push/\(pass.typeIdentifier)/\(pass.requireID())", headers: ["X-Secret": "foo"], afterResponse: { res async throws in #expect(res.status == .internalServerError) @@ -495,19 +499,21 @@ struct PassesTests { // Test call with invalid UUID try await app.test( .POST, - "\(passesURI)push/\(pass.passTypeIdentifier)/\("not-a-uuid")", + "\(passesURI)push/\(pass.typeIdentifier)/\("not-a-uuid")", headers: ["X-Secret": "foo"], afterResponse: { res async throws in #expect(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 { - #expect(error.self == .remoteConnectionClosed) + if !useEncryptedKey { + // Test `PassDataMiddleware` update method + passData.title = "Test Pass 2" + do { + try await passData.update(on: app.db) + } catch let error as HTTPClientError { + #expect(error.self == .remoteConnectionClosed) + } } } } @@ -523,30 +529,20 @@ struct PassesTests { @Test("Default PassesDelegate Properties") func defaultDelegate() async throws { + final class DefaultPassesDelegate: PassesDelegate { + func template(for pass: P, db: any Database) async throws -> String { "" } + func encode(pass: P, db: any Database, encoder: JSONEncoder) async throws -> Data { Data() } + } + let defaultDelegate = DefaultPassesDelegate() - #expect(defaultDelegate.wwdrCertificate == "WWDR.pem") - #expect(defaultDelegate.pemCertificate == "passcertificate.pem") - #expect(defaultDelegate.pemPrivateKey == "passkey.pem") - #expect(defaultDelegate.pemPrivateKeyPassword == nil) - #expect(defaultDelegate.sslBinary == URL(fileURLWithPath: "/usr/bin/openssl")) #expect(!defaultDelegate.generateSignatureFile(in: URL(fileURLWithPath: ""))) - try await withApp(delegate: delegate) { app, passesService in + try await withApp { app, passesService in 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 defaultDelegate.encodePersonalization(for: pass, db: app.db, encoder: JSONEncoder()) + let data = try await defaultDelegate.personalizationJSON(for: pass, db: app.db) #expect(data == nil) } } } - -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() - } -} diff --git a/Tests/PassesTests/TestPassesDelegate.swift b/Tests/PassesTests/TestPassesDelegate.swift index 4b4fc87..c323c8d 100644 --- a/Tests/PassesTests/TestPassesDelegate.swift +++ b/Tests/PassesTests/TestPassesDelegate.swift @@ -3,17 +3,7 @@ import Passes import Vapor final class TestPassesDelegate: PassesDelegate { - let sslSigningFilesDirectory = URL( - fileURLWithPath: "\(FileManager.default.currentDirectoryPath)/Tests/Certificates/", - isDirectory: true - ) - - let pemCertificate = "certificate.pem" - let pemPrivateKey = "key.pem" - - func encode( - pass: P, db: any Database, encoder: JSONEncoder - ) async throws -> Data { + 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()) @@ -22,16 +12,13 @@ final class TestPassesDelegate: PassesDelegate { 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? { + func personalizationJSON(for pass: P, db: any Database) async throws -> PersonalizationJSON? { guard let passData = try await PassData.query(on: db) .filter(\.$pass.$id == pass.id!) @@ -44,20 +31,16 @@ final class TestPassesDelegate: PassesDelegate { 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 + return PersonalizationJSON( + requiredPersonalizationFields: [.name, .postalCode, .emailAddress, .phoneNumber], + description: "Hello, World!" + ) } else { return nil } } - func template(for pass: P, db: any Database) async throws -> URL { - URL( - fileURLWithPath: - "\(FileManager.default.currentDirectoryPath)/Tests/PassesTests/Templates/", - isDirectory: true - ) + func template(for pass: P, db: any Database) async throws -> String { + "\(FileManager.default.currentDirectoryPath)/Tests/PassesTests/Templates/" } } diff --git a/Tests/PassesTests/withApp.swift b/Tests/PassesTests/withApp.swift index 6ff4a1c..52a051f 100644 --- a/Tests/PassesTests/withApp.swift +++ b/Tests/PassesTests/withApp.swift @@ -7,32 +7,39 @@ import Vapor import Zip func withApp( - delegate: some PassesDelegate, + useEncryptedKey: Bool = false, _ body: (Application, PassesService) async throws -> Void ) async throws { let app = try await Application.make(.testing) - - try #require(isLoggingConfigured) - - app.databases.use(.sqlite(.memory), as: .sqlite) - - PassesService.register(migrations: app.migrations) - app.migrations.add(CreatePassData()) - let passesService = try PassesService( - app: app, - delegate: delegate, - pushRoutesMiddleware: SecretMiddleware(secret: "foo"), - logger: app.logger - ) - - app.databases.middleware.use(PassDataMiddleware(service: passesService), on: .sqlite) - - try await app.autoMigrate() - - Zip.addCustomFileExtension("pkpass") - - try await body(app, passesService) - - try await app.autoRevert() + do { + try #require(isLoggingConfigured) + + app.databases.use(.sqlite(.memory), as: .sqlite) + PassesService.register(migrations: app.migrations) + app.migrations.add(CreatePassData()) + try await app.autoMigrate() + + let delegate = TestPassesDelegate() + let passesService = try PassesService( + app: app, + delegate: delegate, + signingFilesDirectory: "\(FileManager.default.currentDirectoryPath)/Tests/Certificates/", + pemCertificate: useEncryptedKey ? "encryptedcert.pem" : "certificate.pem", + pemPrivateKey: useEncryptedKey ? "encryptedkey.pem" : "key.pem", + pemPrivateKeyPassword: useEncryptedKey ? "password" : nil, + pushRoutesMiddleware: SecretMiddleware(secret: "foo"), + logger: app.logger + ) + app.databases.middleware.use(PassDataMiddleware(service: passesService), on: .sqlite) + + Zip.addCustomFileExtension("pkpass") + + try await body(app, passesService) + + try await app.autoRevert() + } catch { + try await app.asyncShutdown() + throw error + } try await app.asyncShutdown() }