.sendPushNotifications(for: pass, on: db, app: app)
+ public func sendPushNotifications(for pass: Pass, on db: any Database) async throws {
+ try await service.sendPushNotifications(for: pass, on: db)
}
}
diff --git a/Sources/Passes/PassesServiceCustom.swift b/Sources/Passes/PassesServiceCustom.swift
index 5b465d7..a3a140c 100644
--- a/Sources/Passes/PassesServiceCustom.swift
+++ b/Sources/Passes/PassesServiceCustom.swift
@@ -5,87 +5,61 @@
// Created by Francesco Paolo Severino on 29/06/24.
//
-@preconcurrency import Vapor
+import Vapor
import APNS
import VaporAPNS
-@preconcurrency import APNSCore
+import APNSCore
import Fluent
import NIOSSL
import PassKit
+import Zip
+@_spi(CMS) import X509
/// Class to handle ``PassesService``.
///
/// The generics should be passed in this order:
/// - Pass Type
+/// - User Personalization Type
/// - Device Type
/// - Registration Type
/// - Error Log Type
-public final class PassesServiceCustom: Sendable where P == R.PassType, D == R.DeviceType {
- /// The ``PassesDelegate`` to use for pass generation.
- public unowned let delegate: any PassesDelegate
+public final class PassesServiceCustom
: Sendable where P == R.PassType, D == R.DeviceType, U == P.UserPersonalizationType {
private unowned let app: Application
-
- private let v1: any RoutesBuilder
+ private unowned let delegate: any PassesDelegate
private let logger: Logger?
- /// Initializes the service.
+ /// 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.
+ /// - 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, logger: Logger? = nil) {
+ public init(
+ app: Application,
+ delegate: any PassesDelegate,
+ pushRoutesMiddleware: (any Middleware)? = nil,
+ logger: Logger? = nil
+ ) throws {
+ self.app = app
self.delegate = delegate
self.logger = logger
- self.app = app
-
- v1 = app.grouped("api", "passes", "v1")
- }
-
- /// Registers all the routes required for PassKit to work.
- public func registerRoutes() {
- v1.get("devices", ":deviceLibraryIdentifier", "registrations", ":passTypeIdentifier", use: { try await self.passesForDevice(req: $0) })
- v1.post("log", use: { try await self.logError(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) })
- v1auth.delete("devices", ":deviceLibraryIdentifier", "registrations", ":passTypeIdentifier", ":passSerial", use: { try await self.unregisterDevice(req: $0) })
- v1auth.post("passes", ":passTypeIdentifier", ":passSerial", "personalize", use: { try await self.personalizedPass(req: $0) })
- }
-
- /// Registers routes to send push notifications for updated passes
- ///
- /// ### Example ###
- /// ```swift
- /// try passesService.registerPushRoutes(middleware: SecretMiddleware(secret: "foo"))
- /// ```
- ///
- /// - Parameter middleware: The `Middleware` which will control authentication for the routes.
- /// - Throws: An error of type ``PassesError``.
- public func registerPushRoutes(middleware: any Middleware) throws {
- let privateKeyPath = URL(fileURLWithPath: delegate.pemPrivateKey, relativeTo:
- delegate.sslSigningFilesDirectory).unixPath()
+ let privateKeyPath = URL(fileURLWithPath: delegate.pemPrivateKey, relativeTo: delegate.sslSigningFilesDirectory).unixPath()
guard FileManager.default.fileExists(atPath: privateKeyPath) else {
throw PassesError.pemPrivateKeyMissing
}
-
let pemPath = URL(fileURLWithPath: delegate.pemCertificate, relativeTo: delegate.sslSigningFilesDirectory).unixPath()
-
guard FileManager.default.fileExists(atPath: pemPath) else {
throw PassesError.pemCertificateMissing
}
-
- // PassKit *only* works with the production APNs. You can't pass in `.sandbox` here.
let apnsConfig: APNSClientConfiguration
- if let pwd = delegate.pemPrivateKeyPassword {
+ if let password = delegate.pemPrivateKeyPassword {
apnsConfig = APNSClientConfiguration(
authenticationMethod: try .tls(
privateKey: .privateKey(
NIOSSLPrivateKey(file: privateKeyPath, format: .pem) { closure in
- closure(pwd.utf8)
+ closure(password.utf8)
}),
certificateChain: NIOSSLCertificate.fromPEMFile(pemPath).map { .certificate($0) }
),
@@ -94,7 +68,7 @@ public final class PassesServiceCustom
())
+ v1auth.post("devices", ":deviceLibraryIdentifier", "registrations", ":passTypeIdentifier", ":passSerial", use: { try await self.registerDevice(req: $0) })
+ v1auth.get("passes", ":passTypeIdentifier", ":passSerial", use: { try await self.latestVersionOfPass(req: $0) })
+ v1auth.delete("devices", ":deviceLibraryIdentifier", "registrations", ":passTypeIdentifier", ":passSerial", use: { try await self.unregisterDevice(req: $0) })
+
+ 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) })
+ }
}
}
-
+
// MARK: - API Routes
extension PassesServiceCustom {
func registerDevice(req: Request) async throws -> HTTPStatus {
logger?.debug("Called register device")
- guard let serial = req.parameters.get("passSerial", as: UUID.self) else {
- throw Abort(.badRequest)
- }
-
let pushToken: String
do {
- let content = try req.content.decode(RegistrationDTO.self)
- pushToken = content.pushToken
+ pushToken = try req.content.decode(RegistrationDTO.self).pushToken
} catch {
throw Abort(.badRequest)
}
+ guard let serial = req.parameters.get("passSerial", as: UUID.self) else {
+ throw Abort(.badRequest)
+ }
let passTypeIdentifier = req.parameters.get("passTypeIdentifier")!
let deviceLibraryIdentifier = req.parameters.get("deviceLibraryIdentifier")!
-
guard let pass = try await P.query(on: req.db)
.filter(\._$passTypeIdentifier == passTypeIdentifier)
.filter(\._$id == serial)
@@ -148,7 +130,6 @@ extension PassesServiceCustom {
.filter(\._$deviceLibraryIdentifier == deviceLibraryIdentifier)
.filter(\._$pushToken == pushToken)
.first()
-
if let device = device {
return try await Self.createRegistration(device: device, pass: pass, db: req.db)
} else {
@@ -160,29 +141,25 @@ extension PassesServiceCustom {
private static func createRegistration(device: D, pass: P, db: any Database) async throws -> HTTPStatus {
let r = try await R.for(deviceLibraryIdentifier: device.deviceLibraryIdentifier, passTypeIdentifier: pass.passTypeIdentifier, on: db)
- .filter(P.self, \._$id == pass.id!)
+ .filter(P.self, \._$id == pass.requireID())
.first()
- if r != nil {
- // If the registration already exists, docs say to return a 200
- return .ok
- }
-
+ // If the registration already exists, docs say to return 200 OK
+ if r != nil { return .ok }
+
let registration = R()
- registration._$pass.id = pass.id!
- registration._$device.id = device.id!
-
+ registration._$pass.id = try pass.requireID()
+ registration._$device.id = try device.requireID()
try await registration.create(on: db)
return .created
}
func passesForDevice(req: Request) async throws -> PassesForDeviceDTO {
logger?.debug("Called passesForDevice")
-
+
let passTypeIdentifier = req.parameters.get("passTypeIdentifier")!
let deviceLibraryIdentifier = req.parameters.get("deviceLibraryIdentifier")!
var query = R.for(deviceLibraryIdentifier: deviceLibraryIdentifier, passTypeIdentifier: passTypeIdentifier, on: req.db)
-
if let since: TimeInterval = req.query["passesUpdatedSince"] {
let when = Date(timeIntervalSince1970: since)
query = query.filter(P.self, \._$updatedAt > when)
@@ -195,11 +172,9 @@ extension PassesServiceCustom {
var serialNumbers: [String] = []
var maxDate = Date.distantPast
-
- registrations.forEach { r in
+ try registrations.forEach { r in
let pass = r.pass
-
- serialNumbers.append(pass.id!.uuidString)
+ try serialNumbers.append(pass.requireID().uuidString)
if let updatedAt = pass.updatedAt, updatedAt > maxDate {
maxDate = updatedAt
}
@@ -211,12 +186,7 @@ extension PassesServiceCustom {
func latestVersionOfPass(req: Request) async throws -> Response {
logger?.debug("Called latestVersionOfPass")
- guard FileManager.default.fileExists(atPath: delegate.zipBinary.unixPath()) else {
- throw Abort(.internalServerError, suggestedFixes: ["Provide full path to zip command"])
- }
-
var ifModifiedSince: TimeInterval = 0
-
if let header = req.headers[.ifModifiedSince].first, let ims = TimeInterval(header) {
ifModifiedSince = ims
}
@@ -225,7 +195,6 @@ extension PassesServiceCustom {
let id = req.parameters.get("passSerial", as: UUID.self) else {
throw Abort(.badRequest)
}
-
guard let pass = try await P.query(on: req.db)
.filter(\._$id == id)
.filter(\._$passTypeIdentifier == passTypeIdentifier)
@@ -238,28 +207,26 @@ extension PassesServiceCustom {
throw Abort(.notModified)
}
- let data = try await self.generatePassContent(for: pass, on: req.db)
- let body = Response.Body(data: data)
-
var headers = HTTPHeaders()
headers.add(name: .contentType, value: "application/vnd.apple.pkpass")
- headers.add(name: .lastModified, value: String(pass.updatedAt?.timeIntervalSince1970 ?? 0))
+ headers.lastModified = HTTPHeaders.LastModified(pass.updatedAt ?? Date.distantPast)
headers.add(name: .contentTransferEncoding, value: "binary")
-
- return Response(status: .ok, headers: headers, body: body)
+ return try await Response(
+ status: .ok,
+ headers: headers,
+ body: Response.Body(data: self.generatePassContent(for: pass, on: req.db))
+ )
}
func unregisterDevice(req: Request) async throws -> HTTPStatus {
logger?.debug("Called unregisterDevice")
-
- let passTypeIdentifier = req.parameters.get("passTypeIdentifier")!
-
+
guard let passId = req.parameters.get("passSerial", as: UUID.self) else {
throw Abort(.badRequest)
}
-
+ let passTypeIdentifier = req.parameters.get("passTypeIdentifier")!
let deviceLibraryIdentifier = req.parameters.get("deviceLibraryIdentifier")!
-
+
guard let r = try await R.for(deviceLibraryIdentifier: deviceLibraryIdentifier, passTypeIdentifier: passTypeIdentifier, on: req.db)
.filter(P.self, \._$id == passId)
.first()
@@ -272,33 +239,29 @@ extension PassesServiceCustom {
func logError(req: Request) async throws -> HTTPStatus {
logger?.debug("Called logError")
-
+
let body: ErrorLogDTO
-
do {
body = try req.content.decode(ErrorLogDTO.self)
} catch {
throw Abort(.badRequest)
}
-
- guard body.logs.isEmpty == false else {
+
+ guard !body.logs.isEmpty else {
throw Abort(.badRequest)
}
-
+
try await body.logs.map(E.init(message:)).create(on: req.db)
-
return .ok
}
func personalizedPass(req: Request) async throws -> Response {
logger?.debug("Called personalizedPass")
- /*
guard let passTypeIdentifier = req.parameters.get("passTypeIdentifier"),
let id = req.parameters.get("passSerial", as: UUID.self) else {
throw Abort(.badRequest)
}
-
guard let pass = try await P.query(on: req.db)
.filter(\._$id == id)
.filter(\._$passTypeIdentifier == passTypeIdentifier)
@@ -307,37 +270,113 @@ extension PassesServiceCustom {
throw Abort(.notFound)
}
- let personalization = try req.content.decode(PersonalizationDictionaryDTO.self)
- */
+ let userInfo = try req.content.decode(PersonalizationDictionaryDTO.self)
+
+ let userPersonalization = U()
+ userPersonalization.fullName = userInfo.requiredPersonalizationInfo.fullName
+ userPersonalization.givenName = userInfo.requiredPersonalizationInfo.givenName
+ userPersonalization.familyName = userInfo.requiredPersonalizationInfo.familyName
+ userPersonalization.emailAddress = userInfo.requiredPersonalizationInfo.emailAddress
+ userPersonalization.postalCode = userInfo.requiredPersonalizationInfo.postalCode
+ userPersonalization.ISOCountryCode = userInfo.requiredPersonalizationInfo.ISOCountryCode
+ userPersonalization.phoneNumber = userInfo.requiredPersonalizationInfo.phoneNumber
+ try await userPersonalization.create(on: req.db)
+
+ 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)
+ try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
+ defer { _ = try? FileManager.default.removeItem(at: root) }
+
+ guard let token = userInfo.personalizationToken.data(using: .utf8) else {
+ throw Abort(.internalServerError)
+ }
+ let signature: Data
+ if let password = delegate.pemPrivateKeyPassword {
+ let sslBinary = delegate.sslBinary
+ guard FileManager.default.fileExists(atPath: sslBinary.unixPath()) else {
+ throw PassesError.opensslBinaryMissing
+ }
+
+ let tokenURL = root.appendingPathComponent("personalizationToken")
+ try token.write(to: tokenURL)
+
+ let proc = Process()
+ proc.currentDirectoryURL = delegate.sslSigningFilesDirectory
+ proc.executableURL = sslBinary
+ proc.arguments = [
+ "smime", "-binary", "-sign",
+ "-certfile", delegate.wwdrCertificate,
+ "-signer", delegate.pemCertificate,
+ "-inkey", delegate.pemPrivateKey,
+ "-in", tokenURL.unixPath(),
+ "-out", root.appendingPathComponent("signature").unixPath(),
+ "-outform", "DER",
+ "-passin", "pass:\(password)"
+ ]
+ try proc.run()
+ proc.waitUntilExit()
+ signature = try Data(contentsOf: root.appendingPathComponent("signature"))
+ } else {
+ let signatureBytes = try CMS.sign(
+ token,
+ signatureAlgorithm: .sha256WithRSAEncryption,
+ additionalIntermediateCertificates: [
+ Certificate(
+ pemEncoded: String(
+ contentsOf: delegate.sslSigningFilesDirectory
+ .appendingPathComponent(delegate.wwdrCertificate)
+ )
+ )
+ ],
+ certificate: Certificate(
+ pemEncoded: String(
+ contentsOf: delegate.sslSigningFilesDirectory
+ .appendingPathComponent(delegate.pemCertificate)
+ )
+ ),
+ privateKey: .init(
+ pemEncoded: String(
+ contentsOf: delegate.sslSigningFilesDirectory
+ .appendingPathComponent(delegate.pemPrivateKey)
+ )
+ ),
+ signingTime: Date()
+ )
+ signature = Data(signatureBytes)
+ }
- throw Abort(.notImplemented)
+ var headers = HTTPHeaders()
+ headers.add(name: .contentType, value: "application/octet-stream")
+ headers.add(name: .contentTransferEncoding, value: "binary")
+ return Response(status: .ok, headers: headers, body: Response.Body(data: signature))
}
// MARK: - Push Routes
func pushUpdatesForPass(req: Request) async throws -> HTTPStatus {
logger?.debug("Called pushUpdatesForPass")
-
+
guard let id = req.parameters.get("passSerial", as: UUID.self) else {
throw Abort(.badRequest)
}
-
let passTypeIdentifier = req.parameters.get("passTypeIdentifier")!
-
- try await Self.sendPushNotificationsForPass(id: id, of: passTypeIdentifier, on: req.db, app: req.application)
+
+ try await sendPushNotificationsForPass(id: id, of: passTypeIdentifier, on: req.db)
return .noContent
}
func tokensForPassUpdate(req: Request) async throws -> [String] {
logger?.debug("Called tokensForPassUpdate")
-
+
guard let id = req.parameters.get("passSerial", as: UUID.self) else {
throw Abort(.badRequest)
}
-
let passTypeIdentifier = req.parameters.get("passTypeIdentifier")!
-
- let registrations = try await Self.registrationsForPass(id: id, of: passTypeIdentifier, on: req.db)
- return registrations.map { $0.device.pushToken }
+
+ return try await Self.registrationsForPass(id: id, of: passTypeIdentifier, on: req.db)
+ .map { $0.device.pushToken }
}
}
@@ -349,11 +388,14 @@ extension PassesServiceCustom {
/// - id: The `UUID` of the pass to send the notifications for.
/// - passTypeIdentifier: The type identifier of the pass.
/// - db: The `Database` to use.
- /// - app: The `Application` to use.
- public static func sendPushNotificationsForPass(id: UUID, of passTypeIdentifier: String, on db: any Database, app: Application) async throws {
+ public func sendPushNotificationsForPass(id: UUID, of passTypeIdentifier: String, on db: any Database) async throws {
let registrations = try await Self.registrationsForPass(id: id, of: passTypeIdentifier, on: db)
for reg in registrations {
- let backgroundNotification = APNSBackgroundNotification(expiration: .immediately, topic: reg.pass.passTypeIdentifier, payload: EmptyPayload())
+ let backgroundNotification = APNSBackgroundNotification(
+ expiration: .immediately,
+ topic: reg.pass.passTypeIdentifier,
+ payload: EmptyPayload()
+ )
do {
try await app.apns.client(.init(string: "passes")).sendBackgroundNotification(
backgroundNotification,
@@ -371,37 +413,13 @@ extension PassesServiceCustom {
/// - Parameters:
/// - pass: The pass to send the notifications for.
/// - db: The `Database` to use.
- /// - app: The `Application` to use.
- public static func sendPushNotifications(for pass: P, on db: any Database, app: Application) async throws {
- guard let id = pass.id else {
- throw FluentError.idRequired
- }
-
- try await Self.sendPushNotificationsForPass(id: id, of: pass.passTypeIdentifier, on: db, app: app)
- }
-
- /// Sends push notifications for a given pass.
- ///
- /// - Parameters:
- /// - pass: The pass (as the `ParentProperty`) to send the notifications for.
- /// - db: The `Database` to use.
- /// - app: The `Application` to use.
- public static func sendPushNotifications(for pass: ParentProperty, on db: any Database, app: Application) async throws {
- let value: P
-
- if let eagerLoaded = pass.value {
- value = eagerLoaded
- } else {
- value = try await pass.get(on: db)
- }
-
- try await sendPushNotifications(for: value, on: db, app: app)
+ public func sendPushNotifications(for pass: P, on db: any Database) async throws {
+ try await sendPushNotificationsForPass(id: pass.requireID(), of: pass.passTypeIdentifier, on: db)
}
- private static func registrationsForPass(id: UUID, of passTypeIdentifier: String, on db: any Database) async throws -> [R] {
- // This could be done by enforcing the caller to have a Siblings property
- // wrapper, but there's not really any value to forcing that on them when
- // we can just do the query ourselves like this.
+ static func registrationsForPass(id: UUID, of passTypeIdentifier: String, on db: any Database) async throws -> [R] {
+ // This could be done by enforcing the caller to have a Siblings property wrapper,
+ // but there's not really any value to forcing that on them when we can just do the query ourselves like this.
try await R.query(on: db)
.join(parent: \._$pass)
.join(parent: \._$device)
@@ -415,74 +433,76 @@ extension PassesServiceCustom {
// MARK: - pkpass file generation
extension PassesServiceCustom {
- private static func generateManifestFile(using encoder: JSONEncoder, in root: URL) throws {
+ private static func generateManifestFile(using encoder: JSONEncoder, in root: URL) throws -> Data {
var manifest: [String: String] = [:]
-
let paths = try FileManager.default.subpathsOfDirectory(atPath: root.unixPath())
try paths.forEach { relativePath in
let file = URL(fileURLWithPath: relativePath, relativeTo: root)
- guard !file.hasDirectoryPath else {
- return
- }
-
+ guard !file.hasDirectoryPath else { return }
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()
}
-
- let encoded = try encoder.encode(manifest)
- try encoded.write(to: root.appendingPathComponent("manifest.json"))
+ let data = try encoder.encode(manifest)
+ try data.write(to: root.appendingPathComponent("manifest.json"))
+ return data
}
- private func generateSignatureFile(in root: URL) throws {
- if delegate.generateSignatureFile(in: root) {
- // If the caller's delegate generated a file we don't have to do it.
- return
- }
+ private func generateSignatureFile(for manifest: Data, in root: URL) throws {
+ // If the caller's delegate generated a file we don't have to do it.
+ if delegate.generateSignatureFile(in: root) { return }
- let sslBinary = delegate.sslBinary
+ // Swift Crypto doesn't support encrypted PEM private keys, so we have to use OpenSSL for that.
+ if let password = delegate.pemPrivateKeyPassword {
+ let sslBinary = delegate.sslBinary
+ guard FileManager.default.fileExists(atPath: sslBinary.unixPath()) else {
+ throw PassesError.opensslBinaryMissing
+ }
- guard FileManager.default.fileExists(atPath: sslBinary.unixPath()) else {
- throw PassesError.opensslBinaryMissing
+ let proc = Process()
+ proc.currentDirectoryURL = delegate.sslSigningFilesDirectory
+ proc.executableURL = sslBinary
+ proc.arguments = [
+ "smime", "-binary", "-sign",
+ "-certfile", delegate.wwdrCertificate,
+ "-signer", delegate.pemCertificate,
+ "-inkey", delegate.pemPrivateKey,
+ "-in", root.appendingPathComponent("manifest.json").unixPath(),
+ "-out", root.appendingPathComponent("signature").unixPath(),
+ "-outform", "DER",
+ "-passin", "pass:\(password)"
+ ]
+ try proc.run()
+ proc.waitUntilExit()
+ return
}
- let proc = Process()
- proc.currentDirectoryURL = delegate.sslSigningFilesDirectory
- proc.executableURL = sslBinary
-
- proc.arguments = [
- "smime", "-binary", "-sign",
- "-certfile", delegate.wwdrCertificate,
- "-signer", delegate.pemCertificate,
- "-inkey", delegate.pemPrivateKey,
- "-in", root.appendingPathComponent("manifest.json").unixPath(),
- "-out", root.appendingPathComponent("signature").unixPath(),
- "-outform", "DER"
- ]
-
- if let pwd = delegate.pemPrivateKeyPassword {
- proc.arguments!.append(contentsOf: ["-passin", "pass:\(pwd)"])
- }
-
- try proc.run()
-
- proc.waitUntilExit()
- }
-
- private func zip(directory: URL, to: URL) throws {
- let zipBinary = delegate.zipBinary
- guard FileManager.default.fileExists(atPath: zipBinary.unixPath()) else {
- throw PassesError.zipBinaryMissing
- }
-
- let proc = Process()
- proc.currentDirectoryURL = directory
- proc.executableURL = zipBinary
-
- proc.arguments = [ to.unixPath(), "-r", "-q", "." ]
-
- try proc.run()
- proc.waitUntilExit()
+ let signature = try CMS.sign(
+ manifest,
+ signatureAlgorithm: .sha256WithRSAEncryption,
+ additionalIntermediateCertificates: [
+ Certificate(
+ pemEncoded: String(
+ contentsOf: delegate.sslSigningFilesDirectory
+ .appendingPathComponent(delegate.wwdrCertificate)
+ )
+ )
+ ],
+ certificate: Certificate(
+ pemEncoded: String(
+ contentsOf: delegate.sslSigningFilesDirectory
+ .appendingPathComponent(delegate.pemCertificate)
+ )
+ ),
+ privateKey: .init(
+ pemEncoded: String(
+ contentsOf: delegate.sslSigningFilesDirectory
+ .appendingPathComponent(delegate.pemPrivateKey)
+ )
+ ),
+ signingTime: Date()
+ )
+ try Data(signature).write(to: root.appendingPathComponent("signature"))
}
/// Generates the pass content bundle for a given pass.
@@ -492,45 +512,36 @@ 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)
+ 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 zipFile = tmp.appendingPathComponent("\(UUID().uuidString).zip")
- let encoder = JSONEncoder()
+ try FileManager.default.copyItem(at: templateDirectory, to: root)
+ defer { _ = try? FileManager.default.removeItem(at: root) }
- let src = try await delegate.template(for: pass, db: db)
- guard (try? src.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) ?? false else {
- throw PassesError.templateNotDirectory
+ let encoder = JSONEncoder()
+ try await self.delegate.encode(pass: pass, db: db, encoder: encoder)
+ .write(to: root.appendingPathComponent("pass.json"))
+
+ // Pass Personalization
+ if let encodedPersonalization = try await self.delegate.encodePersonalization(for: pass, db: db, encoder: encoder) {
+ try encodedPersonalization.write(to: root.appendingPathComponent("personalization.json"))
+ files.append(URL(fileURLWithPath: "personalization.json", relativeTo: root))
}
- let encoded = try await self.delegate.encode(pass: pass, db: db, encoder: encoder)
-
- do {
- try FileManager.default.copyItem(at: src, to: root)
-
- defer {
- _ = try? FileManager.default.removeItem(at: root)
- }
-
- try encoded.write(to: root.appendingPathComponent("pass.json"))
+ try self.generateSignatureFile(
+ for: Self.generateManifestFile(using: encoder, in: root),
+ in: root
+ )
- // Pass Personalization
- if let encodedPersonalization = try await self.delegate.encodePersonalization(for: pass, db: db, encoder: encoder) {
- try encodedPersonalization.write(to: root.appendingPathComponent("personalization.json"))
- }
-
- try Self.generateManifestFile(using: encoder, in: root)
- try self.generateSignatureFile(in: root)
-
- try self.zip(directory: root, to: zipFile)
-
- defer {
- _ = try? FileManager.default.removeItem(at: zipFile)
- }
-
- return try Data(contentsOf: zipFile)
- } catch {
- throw error
- }
+ files.append(URL(fileURLWithPath: "pass.json", relativeTo: root))
+ files.append(URL(fileURLWithPath: "manifest.json", relativeTo: root))
+ files.append(URL(fileURLWithPath: "signature", relativeTo: root))
+ return try Data(contentsOf: Zip.quickZipFiles(files, fileName: UUID().uuidString))
}
/// Generates a bundle of passes to enable your user to download multiple passes at once.
@@ -550,25 +561,16 @@ extension PassesServiceCustom {
let tmp = FileManager.default.temporaryDirectory
let root = tmp.appendingPathComponent(UUID().uuidString, isDirectory: true)
- let zipFile = tmp.appendingPathComponent("\(UUID().uuidString).zip")
-
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
+ defer { _ = try? FileManager.default.removeItem(at: root) }
+ var files: [URL] = []
for (i, pass) in passes.enumerated() {
+ let name = "pass\(i).pkpass"
try await self.generatePassContent(for: pass, on: db)
- .write(to: root.appendingPathComponent("pass\(i).pkpass"))
- }
-
- defer {
- _ = try? FileManager.default.removeItem(at: root)
+ .write(to: root.appendingPathComponent(name))
+ files.append(URL(fileURLWithPath: name, relativeTo: root))
}
-
- try self.zip(directory: root, to: zipFile)
-
- defer {
- _ = try? FileManager.default.removeItem(at: zipFile)
- }
-
- return try Data(contentsOf: zipFile)
+ return try Data(contentsOf: Zip.quickZipFiles(files, fileName: UUID().uuidString))
}
}
diff --git a/Tests/Certificates/WWDR.pem b/Tests/Certificates/WWDR.pem
new file mode 100644
index 0000000..7202de0
--- /dev/null
+++ b/Tests/Certificates/WWDR.pem
@@ -0,0 +1,26 @@
+-----BEGIN CERTIFICATE-----
+MIIEVTCCAz2gAwIBAgIUE9x3lVJx5T3GMujM/+Uh88zFztIwDQYJKoZIhvcNAQEL
+BQAwYjELMAkGA1UEBhMCVVMxEzARBgNVBAoTCkFwcGxlIEluYy4xJjAkBgNVBAsT
+HUFwcGxlIENlcnRpZmljYXRpb24gQXV0aG9yaXR5MRYwFAYDVQQDEw1BcHBsZSBS
+b290IENBMB4XDTIwMTIxNjE5MzYwNFoXDTMwMTIxMDAwMDAwMFowdTFEMEIGA1UE
+Aww7QXBwbGUgV29ybGR3aWRlIERldmVsb3BlciBSZWxhdGlvbnMgQ2VydGlmaWNh
+dGlvbiBBdXRob3JpdHkxCzAJBgNVBAsMAkc0MRMwEQYDVQQKDApBcHBsZSBJbmMu
+MQswCQYDVQQGEwJVUzCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANAf
+eKp6JzKwRl/nF3bYoJ0OKY6tPTKlxGs3yeRBkWq3eXFdDDQEYHX3rkOPR8SGHgjo
+v9Y5Ui8eZ/xx8YJtPH4GUnadLLzVQ+mxtLxAOnhRXVGhJeG+bJGdayFZGEHVD41t
+QSo5SiHgkJ9OE0/QjJoyuNdqkh4laqQyziIZhQVg3AJK8lrrd3kCfcCXVGySjnYB
+5kaP5eYq+6KwrRitbTOFOCOL6oqW7Z+uZk+jDEAnbZXQYojZQykn/e2kv1MukBVl
+PNkuYmQzHWxq3Y4hqqRfFcYw7V/mjDaSlLfcOQIA+2SM1AyB8j/VNJeHdSbCb64D
+YyEMe9QbsWLFApy9/a8CAwEAAaOB7zCB7DASBgNVHRMBAf8ECDAGAQH/AgEAMB8G
+A1UdIwQYMBaAFCvQaUeUdgn+9GuNLkCm90dNfwheMEQGCCsGAQUFBwEBBDgwNjA0
+BggrBgEFBQcwAYYoaHR0cDovL29jc3AuYXBwbGUuY29tL29jc3AwMy1hcHBsZXJv
+b3RjYTAuBgNVHR8EJzAlMCOgIaAfhh1odHRwOi8vY3JsLmFwcGxlLmNvbS9yb290
+LmNybDAdBgNVHQ4EFgQUW9n6HeeaGgujmXYiUIY+kchbd6gwDgYDVR0PAQH/BAQD
+AgEGMBAGCiqGSIb3Y2QGAgEEAgUAMA0GCSqGSIb3DQEBCwUAA4IBAQA/Vj2e5bbD
+eeZFIGi9v3OLLBKeAuOugCKMBB7DUshwgKj7zqew1UJEggOCTwb8O0kU+9h0UoWv
+p50h5wESA5/NQFjQAde/MoMrU1goPO6cn1R2PWQnxn6NHThNLa6B5rmluJyJlPef
+x4elUWY0GzlxOSTjh2fvpbFoe4zuPfeutnvi0v/fYcZqdUmVIkSoBPyUuAsuORFJ
+EtHlgepZAE9bPFo22noicwkJac3AfOriJP6YRLj477JxPxpd1F1+M02cHSS+APCQ
+A1iZQT0xWmJArzmoUUOSqwSonMJNsUvSq3xKX+udO7xPiEAGE/+QF4oIRynoYpgp
+pU8RBWk6z/Kf
+-----END CERTIFICATE-----
diff --git a/Tests/Certificates/certificate.pem b/Tests/Certificates/certificate.pem
new file mode 100644
index 0000000..c300118
--- /dev/null
+++ b/Tests/Certificates/certificate.pem
@@ -0,0 +1,18 @@
+-----BEGIN CERTIFICATE-----
+MIIC8TCCAdmgAwIBAgICbE8wDQYJKoZIhvcNAQENBQAwGDEWMBQGA1UEAwwNUHVz
+aHlUZXN0Um9vdDAgFw0xNzA0MTcwMDUzMzBaGA8yMTE3MDMyNDAwNTMzMFowHzEd
+MBsGA1UEAwwUY29tLnJlbGF5cmlkZXMucHVzaHkwggEiMA0GCSqGSIb3DQEBAQUA
+A4IBDwAwggEKAoIBAQDHZkZBnDKM4Gt+WZwTc5h2GuT1Di7TfUE8SxDhw5wn3c36
+41/6lnrTj1Sh5tAsed8N2FDrD+Hp9zTkKljDGe8tuDncT1qSrp/UuikgdIAAiCXA
+/vClWPYqZcHAUc9/OcfRiyK5AmJdzz+UbY803ArSPHjz3+Mk6C9tnzBXzG8oJq9o
+EKJhwUYX+7l8+m0omtZXhMCOrbmZ2s69m6hTwHJKdC0mEngdyeiYIsbHaoSwxR7U
+j8wRstdr2xWhPg1fdIVHzudYubJ7M/h95JQFKtwqEevtLUa4BJgi8SKvRX5NnkGE
+QMui1ercRuklVURTeoGDQYENiFnzTyI0J2tw3T+dAgMBAAGjPDA6MAkGA1UdEwQC
+MAAwDgYDVR0PAQH/BAQDAgWgMB0GA1UdJQQWMBQGCCsGAQUFBwMCBggrBgEFBQcD
+ATANBgkqhkiG9w0BAQ0FAAOCAQEAnHHYMvBWglQLOUmNOalCMopmk9yKHM7+Sc9h
+KsTWJW+YohF5zkRhnwUFxW85Pc63rRVA0qyI5zHzRtwYlcZHU57KttJyDGe1rm/0
+ZUqXharurJzyI09jcwRpDY8EGktrGirE1iHDqQTHNDHyS8iMVU6aPCo0xur63G5y
+XzoIVhQXsBuwoU4VKb3n5CrxKEVcmE/nYF/Tk0rTtCrZF7TR3y/oxrp359goJ1b2
+/OjXN4dlqND41SbVTTL0FyXU3ebaS4DALA3pyVa1Rijw7vgEbFabsuMaAbdvlprn
+RwUjsrRVu3Tx7sp/NqmeBLVru5nH/yHStDjSdvQtI2ipNGK/9w==
+-----END CERTIFICATE-----
diff --git a/Tests/Certificates/encryptedcert.pem b/Tests/Certificates/encryptedcert.pem
new file mode 100644
index 0000000..7a9d7cc
--- /dev/null
+++ b/Tests/Certificates/encryptedcert.pem
@@ -0,0 +1,16 @@
+-----BEGIN CERTIFICATE-----
+MIICdDCCAVwCCQCtBOr7dtQS6zANBgkqhkiG9w0BAQsFADB7MQswCQYDVQQGEwJB
+VTETMBEGA1UECAwKU29tZS1TdGF0ZTEhMB8GA1UECgwYSW50ZXJuZXQgV2lkZ2l0
+cyBQdHkgTHRkMRAwDgYDVQQDDAdQYXNza2l0MSIwIAYJKoZIhvcNAQkBFhNub3Jl
+cGx5QGV4YW1wbGUuY29tMB4XDTE5MDEzMTE2NTYzNloXDTI0MDEzMDE2NTYzNlow
+RTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoMGElu
+dGVybmV0IFdpZGdpdHMgUHR5IEx0ZDBcMA0GCSqGSIb3DQEBAQUAA0sAMEgCQQC1
++NQj0QzX5Vu9JMZVntP8i+JYAfOxzeP+MWUL/VaOxGaRp7DSiWAOd8bXDjJZjET0
+4SPZzKvy0a6Suk9aIxCfAgMBAAEwDQYJKoZIhvcNAQELBQADggEBAHd5jKTM4smJ
+b4CoVY2PwYogb+bI4AtpUBV1QlsDrb1xMBHQ6zLf+JhRMya2MqJR+5hDKywrN4bC
+j3LY87ir5aJFFaBMs9h0sCEoQKs0cnksf6Gq2pVJF9G+Aki4UF9r7jxoQwXjbtS3
+m6ptezzKYvMcw5rKKhtZRgDT1uuy5hgOCapZrV1s0byRv3W6IcdzOD3cWZEuxz2D
+AVZCwIvqThqMaAs3Fvs3L3aQsDiOJpZ65gNnBU6j21liMZ3q7txD3eCzuXWMLPI5
+O7C4Sxy+LF4XAfd1/0nmHC2HBgA6CSMgncEzU6PLRR6bXH1daKWlcMAvF+STbLUJ
+79kQMXh2OCs=
+-----END CERTIFICATE-----
diff --git a/Tests/Certificates/encryptedkey.pem b/Tests/Certificates/encryptedkey.pem
new file mode 100644
index 0000000..615dcc0
--- /dev/null
+++ b/Tests/Certificates/encryptedkey.pem
@@ -0,0 +1,11 @@
+-----BEGIN ENCRYPTED PRIVATE KEY-----
+MIIBpjBABgkqhkiG9w0BBQ0wMzAbBgkqhkiG9w0BBQwwDgQIMBNCLGhiuR4CAggA
+MBQGCCqGSIb3DQMHBAjXF3m+2fdMRgSCAWAaGMyNREsNYTuTE0Zf/GIORBQH1Vjc
+pNTvxV0B/YUHfzthOkotQjL8mfbbCWVixEdDE41Rn66WVrVmgFDVIKoGhjsMLGYd
+angmocOnZ77ZYXi0f0/8fZYuQF2dF/zOfsdxyNl2gi4MGbKqt8m9vDcFAWEZsN/r
+5l1QJYNpF4OXKwNg4dnf7Ugo3PMWrVxKzKn+KtUvQd+mdYJ3xBjr1yLjLacbCXh0
+4Kh1kWeV6yyaZswYPPItyAeg4smLdDTEqFI+GHIT7NFQ0GIojIqz2Ug8KWZaMwZs
+iRCYXHDECkC7zqgcxJKRtjDmCJIxfIFcnwJ8DmMf7bpawtowcfM/z7TGSzAUeptA
+bH9rS4Zf5/5Sx/yFRr2esClwli5BJG1uISQBpA0DePhePTiW6LesvAt3YZ3p2BCI
+OE0HwdbAr24Nw7LRCuobRsTKFnBmM+uqtGyJhKE6hC1q4CPjZ09F8njX
+-----END ENCRYPTED PRIVATE KEY-----
diff --git a/Tests/Certificates/key.pem b/Tests/Certificates/key.pem
new file mode 100644
index 0000000..db02c80
--- /dev/null
+++ b/Tests/Certificates/key.pem
@@ -0,0 +1,28 @@
+-----BEGIN PRIVATE KEY-----
+MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQDHZkZBnDKM4Gt+
+WZwTc5h2GuT1Di7TfUE8SxDhw5wn3c3641/6lnrTj1Sh5tAsed8N2FDrD+Hp9zTk
+KljDGe8tuDncT1qSrp/UuikgdIAAiCXA/vClWPYqZcHAUc9/OcfRiyK5AmJdzz+U
+bY803ArSPHjz3+Mk6C9tnzBXzG8oJq9oEKJhwUYX+7l8+m0omtZXhMCOrbmZ2s69
+m6hTwHJKdC0mEngdyeiYIsbHaoSwxR7Uj8wRstdr2xWhPg1fdIVHzudYubJ7M/h9
+5JQFKtwqEevtLUa4BJgi8SKvRX5NnkGEQMui1ercRuklVURTeoGDQYENiFnzTyI0
+J2tw3T+dAgMBAAECggEBAMOsIZWQ6ipEsDe1R+vuq9Z6XeP8nwb7C2FXaKGji0Gz
+78YcCruln7KsHKkkD3UVw0Wa2Q1S8Kbf6A9fXutWL9f1yRHg7Ui0BDSE2ob2zAW5
+lRLnGs+nlSnV4WQQ5EY9NVDz8IcNR+o2znWhbb65kATvQuJO+l/lWWWBqbb+7rW+
+RHy43p7U8cK63nXJy9eHZ7eOgGGUMUX+Yg0g47RGYxlIeSDrtPCXlNuwwAJY7Ecp
+LVltCAyCJEaLVwQpz61PTdmkb9HCvkwiuL6cnjtpoAdXCWX7tV61UNweNkvALIWR
+kMukFFE/H6JlAkcbw4na1KwQ3glWIIB2H/vZyMNdnyECgYEA78VEXo+iAQ6or4bY
+xUQFd/hIibIYMzq8PxDMOmD86G78u5Ho0ytetht5Xk1xmhv402FZCL1LsAEWpCBs
+a9LUwo30A23KaTA7Oy5oo5Md1YJejSNOCR+vs5wAo0SZov5tQaxVMoj3vZZqnJzJ
+3A+XUgYZddIFkn8KJjgU/QVapTMCgYEA1OV1okYF2u5VW90RkVdvQONNcUvlKEL4
+UMSF3WJnORmtUL3Dt8AFt9b7pfz6WtVr0apT5SSIFA1+305PTpjjaw25m1GftL3U
+5QwkmgTKxnPD/YPC6tImp+OUXHmk+iTgmQ9HaBpEplcyjD0EP2LQsIc6qiku/P2n
+OT8ArOkk5+8CgYEA7B98wRL6G8hv3swRVdMy/36HEPNOWcUR9Zl5RlSVO+FxCtca
+Tjt7viM4VuI1aer6FFDd+XlRvDaWMXOs0lKCLEbXczkACK7y5clCSzRqQQVuT9fg
+1aNayKptBlxcYOPmfLJWBLpWH2KuAyV0tT61apWPJTR7QFXTjOfV44cOSXkCgYAH
+CvAxRg+7hlbcixuhqzrK8roFHXWfN1fvlBC5mh/AC9Fn8l8fHQMTadE5VH0TtCu0
+6+WKlwLJZwjjajvFZdlgGTwinzihSgZY7WXoknAC0KGTKWCxU/Jja2vlA0Ep5T5o
+0dCS6QuMVSYe7YXOcv5kWJTgPCyJwfpeMm9bSPsnkQKBgQChy4vU3J6CxGzwuvd/
+011kszao+cHn1DdMTyUhvA/O/paB+BAVktHm+o/i+kOk4OcPjhRqewzZZdf7ie5U
+hUC8kIraXM4aZt69ThQkAIER89wlhxsFXUmGf7ZMXm8f7pvM6/MDaMW3mEsfbL0U
+Y3jy0E30W5s1XCW3gmZ1Vg2xAg==
+-----END PRIVATE KEY-----
diff --git a/Tests/OrdersTests/EncryptedOrdersDelegate.swift b/Tests/OrdersTests/EncryptedOrdersDelegate.swift
new file mode 100644
index 0000000..dd0712a
--- /dev/null
+++ b/Tests/OrdersTests/EncryptedOrdersDelegate.swift
@@ -0,0 +1,36 @@
+import Vapor
+import FluentKit
+import Orders
+
+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
+ )
+ }
+}
\ No newline at end of file
diff --git a/Tests/OrdersTests/EncryptedOrdersTests.swift b/Tests/OrdersTests/EncryptedOrdersTests.swift
new file mode 100644
index 0000000..c004f0c
--- /dev/null
+++ b/Tests/OrdersTests/EncryptedOrdersTests.swift
@@ -0,0 +1,110 @@
+import XCTVapor
+import Fluent
+import FluentSQLiteDriver
+@testable import Orders
+import PassKit
+import Zip
+
+final class EncryptedOrdersTests: XCTestCase {
+ let delegate = EncryptedOrdersDelegate()
+ let ordersURI = "/api/orders/v1/"
+ var ordersService: OrdersService!
+ var app: Application!
+
+ override func setUp() async throws {
+ self.app = try await Application.make(.testing)
+ app.databases.use(.sqlite(.memory), as: .sqlite)
+
+ OrdersService.register(migrations: app.migrations)
+ app.migrations.add(CreateOrderData())
+ ordersService = try OrdersService(
+ app: app,
+ delegate: delegate,
+ pushRoutesMiddleware: SecretMiddleware(secret: "foo"),
+ logger: app.logger
+ )
+ app.databases.middleware.use(OrderDataMiddleware(service: ordersService), on: .sqlite)
+
+ try await app.autoMigrate()
+
+ Zip.addCustomFileExtension("order")
+ }
+
+ override func tearDown() async throws {
+ try await app.autoRevert()
+ try await self.app.asyncShutdown()
+ self.app = nil
+ }
+
+ func testOrderGeneration() async throws {
+ 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)
+
+ XCTAssert(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]
+ XCTAssertEqual(passJSON["authenticationToken"] as? String, order.authenticationToken)
+ try XCTAssertEqual(passJSON["orderIdentifier"] as? String, order.requireID().uuidString)
+
+ let manifestJSONData = try String(contentsOfFile: orderFolder.path.appending("/manifest.json")).data(using: .utf8)
+ let manifestJSON = try JSONSerialization.jsonObject(with: manifestJSONData!) as! [String: Any]
+ let iconData = try Data(contentsOf: orderFolder.appendingPathComponent("/icon.png"))
+ let iconHash = Array(SHA256.hash(data: iconData)).hex
+ XCTAssertEqual(manifestJSON["icon.png"] as? String, iconHash)
+ }
+
+ func testAPNSClient() async throws {
+ XCTAssertNotNil(app.apns.client(.init(string: "orders")))
+
+ 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
+ XCTAssertEqual(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
+ XCTAssertEqual(res.status, .created)
+ }
+ )
+
+ try await app.test(
+ .POST,
+ "\(ordersURI)push/\(order.orderTypeIdentifier)/\(order.requireID())",
+ headers: ["X-Secret": "foo"],
+ afterResponse: { res async throws in
+ XCTAssertEqual(res.status, .internalServerError)
+ }
+ )
+
+ // Test `OrderDataMiddleware` update method
+ orderData.title = "Test Order 2"
+ do {
+ try await orderData.update(on: app.db)
+ } catch {}
+ }
+}
\ No newline at end of file
diff --git a/Tests/OrdersTests/OrderData.swift b/Tests/OrdersTests/OrderData.swift
new file mode 100644
index 0000000..61ebc6e
--- /dev/null
+++ b/Tests/OrdersTests/OrderData.swift
@@ -0,0 +1,126 @@
+import Fluent
+import struct Foundation.UUID
+import Orders
+import Vapor
+
+final class OrderData: OrderDataModel, @unchecked Sendable {
+ static let schema = OrderData.FieldKeys.schemaName
+
+ @ID(key: .id)
+ var id: UUID?
+
+ @Field(key: OrderData.FieldKeys.title)
+ var title: String
+
+ @Parent(key: OrderData.FieldKeys.orderID)
+ var order: Order
+
+ init() { }
+
+ init(id: UUID? = nil, title: String) {
+ 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 {
+ func prepare(on database: any Database) async throws {
+ 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))
+ .create()
+ }
+
+ func revert(on database: any Database) async throws {
+ try await database.schema(OrderData.FieldKeys.schemaName).delete()
+ }
+}
+
+extension OrderData {
+ enum FieldKeys {
+ static let schemaName = "order_data"
+ static let title = FieldKey(stringLiteral: "title")
+ static let orderID = FieldKey(stringLiteral: "order_id")
+ }
+}
+
+struct OrderJSONData: OrderJSON.Properties {
+ let schemaVersion = OrderJSON.SchemaVersion.v1
+ let orderTypeIdentifier = "order.com.example.pet-store"
+ let orderIdentifier: String
+ let orderType = OrderJSON.OrderType.ecommerce
+ let orderNumber = "HM090772020864"
+ let createdAt: String
+ let updatedAt: String
+ let status = OrderJSON.OrderStatus.open
+ let merchant: MerchantData
+ let orderManagementURL = "https://www.example.com/"
+ let authenticationToken: String
+
+ private let webServiceURL = "https://www.example.com/api/orders/"
+
+ struct MerchantData: OrderJSON.Merchant {
+ let merchantIdentifier = "com.example.pet-store"
+ let displayName: String
+ let url = "https://www.example.com/"
+ let logo = "pet_store_logo.png"
+ }
+
+ init(data: OrderData, order: Order) {
+ self.orderIdentifier = order.id!.uuidString
+ self.authenticationToken = order.authenticationToken
+ self.merchant = MerchantData(displayName: data.title)
+ let dateFormatter = ISO8601DateFormatter()
+ dateFormatter.formatOptions = .withInternetDateTime
+ self.createdAt = dateFormatter.string(from: order.createdAt!)
+ self.updatedAt = dateFormatter.string(from: order.updatedAt!)
+ }
+}
+
+struct OrderDataMiddleware: AsyncModelMiddleware {
+ private unowned let service: OrdersService
+
+ init(service: OrdersService) {
+ self.service = service
+ }
+
+ 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())
+ 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 {
+ let order = try await model.$order.get(on: db)
+ order.updatedAt = Date()
+ try await order.save(on: db)
+ try await next.update(model, on: db)
+ try await service.sendPushNotifications(for: order, on: db)
+ }
+}
diff --git a/Tests/OrdersTests/OrdersTests.swift b/Tests/OrdersTests/OrdersTests.swift
index ba5d087..5eb5aae 100644
--- a/Tests/OrdersTests/OrdersTests.swift
+++ b/Tests/OrdersTests/OrdersTests.swift
@@ -1,15 +1,328 @@
import XCTVapor
+import Fluent
+import FluentSQLiteDriver
@testable import Orders
+import PassKit
+import Zip
final class OrdersTests: XCTestCase {
- func testExample() {
- // This is an example of a functional test case.
- // Use XCTAssert and related functions to verify your tests produce the correct
- // results.
- //XCTAssertEqual(OrdersService().text, "Hello, World!")
+ let delegate = TestOrdersDelegate()
+ let ordersURI = "/api/orders/v1/"
+ var ordersService: OrdersService!
+ var app: Application!
+
+ override func setUp() async throws {
+ self.app = try await Application.make(.testing)
+ app.databases.use(.sqlite(.memory), as: .sqlite)
+
+ OrdersService.register(migrations: app.migrations)
+ app.migrations.add(CreateOrderData())
+ ordersService = try OrdersService(
+ app: app,
+ delegate: delegate,
+ pushRoutesMiddleware: SecretMiddleware(secret: "foo"),
+ logger: app.logger
+ )
+ app.databases.middleware.use(OrderDataMiddleware(service: ordersService), on: .sqlite)
+
+ try await app.autoMigrate()
+
+ Zip.addCustomFileExtension("order")
+ }
+
+ override func tearDown() async throws {
+ try await app.autoRevert()
+ try await self.app.asyncShutdown()
+ self.app = nil
+ }
+
+ func testOrderGeneration() async throws {
+ 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)
+
+ XCTAssert(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]
+ XCTAssertEqual(passJSON["authenticationToken"] as? String, order.authenticationToken)
+ try XCTAssertEqual(passJSON["orderIdentifier"] as? String, order.requireID().uuidString)
+
+ let manifestJSONData = try String(contentsOfFile: orderFolder.path.appending("/manifest.json")).data(using: .utf8)
+ let manifestJSON = try JSONSerialization.jsonObject(with: manifestJSONData!) as! [String: Any]
+ let iconData = try Data(contentsOf: orderFolder.appendingPathComponent("/icon.png"))
+ let iconHash = Array(SHA256.hash(data: iconData)).hex
+ XCTAssertEqual(manifestJSON["icon.png"] as? String, iconHash)
}
- static var allTests = [
- ("testExample", testExample),
- ]
+ // Tests the API Apple Wallet calls to get orders
+ func testGetOrderFromAPI() async throws {
+ 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())",
+ headers: [
+ "Authorization": "AppleOrder \(order.authenticationToken)",
+ "If-Modified-Since": "0"
+ ],
+ afterResponse: { res async throws in
+ XCTAssertEqual(res.status, .ok)
+ XCTAssertNotNil(res.body)
+ XCTAssertEqual(res.headers.contentType?.description, "application/vnd.apple.order")
+ XCTAssertNotNil(res.headers.lastModified)
+ }
+ )
+
+ // Test call with invalid authentication token
+ try await app.test(
+ .GET,
+ "\(ordersURI)orders/\(order.orderTypeIdentifier)/\(order.requireID())",
+ headers: [
+ "Authorization": "AppleOrder invalidToken",
+ "If-Modified-Since": "0"
+ ],
+ afterResponse: { res async throws in
+ XCTAssertEqual(res.status, .unauthorized)
+ }
+ )
+
+ // Test distant future `If-Modified-Since` date
+ try await app.test(
+ .GET,
+ "\(ordersURI)orders/\(order.orderTypeIdentifier)/\(order.requireID())",
+ headers: [
+ "Authorization": "AppleOrder \(order.authenticationToken)",
+ "If-Modified-Since": "2147483647"
+ ],
+ afterResponse: { res async throws in
+ XCTAssertEqual(res.status, .notModified)
+ }
+ )
+
+ // Test call with invalid order ID
+ try await app.test(
+ .GET,
+ "\(ordersURI)orders/\(order.orderTypeIdentifier)/invalidID",
+ headers: [
+ "Authorization": "AppleOrder \(order.authenticationToken)",
+ "If-Modified-Since": "0"
+ ],
+ afterResponse: { res async throws in
+ XCTAssertEqual(res.status, .badRequest)
+ }
+ )
+
+ // Test call with invalid order type identifier
+ try await app.test(
+ .GET,
+ "\(ordersURI)orders/order.com.example.InvalidType/\(order.requireID())",
+ headers: [
+ "Authorization": "AppleOrder \(order.authenticationToken)",
+ "If-Modified-Since": "0"
+ ],
+ afterResponse: { res async throws in
+ XCTAssertEqual(res.status, .notFound)
+ }
+ )
+ }
+
+ func testAPIDeviceRegistration() async throws {
+ let orderData = OrderData(title: "Test Order")
+ try await orderData.create(on: app.db)
+ let order = try await orderData.$order.get(on: app.db)
+ let deviceLibraryIdentifier = "abcdefg"
+ let pushToken = "1234567890"
+
+ // Test registration without authentication token
+ try await app.test(
+ .POST,
+ "\(ordersURI)devices/\(deviceLibraryIdentifier)/registrations/\(order.orderTypeIdentifier)/\(order.requireID())",
+ beforeRequest: { req async throws in
+ try req.content.encode(RegistrationDTO(pushToken: pushToken))
+ },
+ afterResponse: { res async throws in
+ XCTAssertEqual(res.status, .unauthorized)
+ }
+ )
+
+ 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
+ XCTAssertEqual(res.status, .created)
+ }
+ )
+
+ // Test registration of an already registered device
+ 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
+ XCTAssertEqual(res.status, .ok)
+ }
+ )
+
+ try await app.test(
+ .GET,
+ "\(ordersURI)devices/\(deviceLibraryIdentifier)/registrations/\(order.orderTypeIdentifier)?ordersModifiedSince=0",
+ afterResponse: { res async throws in
+ let orders = try res.content.decode(OrdersForDeviceDTO.self)
+ XCTAssertEqual(orders.orderIdentifiers.count, 1)
+ let orderID = try order.requireID()
+ XCTAssertEqual(orders.orderIdentifiers[0], orderID.uuidString)
+ XCTAssertEqual(orders.lastModified, String(order.updatedAt!.timeIntervalSince1970))
+ }
+ )
+
+ try await app.test(
+ .GET,
+ "\(ordersURI)push/\(order.orderTypeIdentifier)/\(order.requireID())",
+ headers: ["X-Secret": "foo"],
+ afterResponse: { res async throws in
+ let pushTokens = try res.content.decode([String].self)
+ XCTAssertEqual(pushTokens.count, 1)
+ XCTAssertEqual(pushTokens[0], pushToken)
+ }
+ )
+
+ try await app.test(
+ .DELETE,
+ "\(ordersURI)devices/\(deviceLibraryIdentifier)/registrations/\(order.orderTypeIdentifier)/\(order.requireID())",
+ headers: ["Authorization": "AppleOrder \(order.authenticationToken)"],
+ afterResponse: { res async throws in
+ XCTAssertEqual(res.status, .ok)
+ }
+ )
+ }
+
+ func testErrorLog() async throws {
+ let log1 = "Error 1"
+ let log2 = "Error 2"
+
+ try await app.test(
+ .POST,
+ "\(ordersURI)log",
+ beforeRequest: { req async throws in
+ try req.content.encode(ErrorLogDTO(logs: [log1, log2]))
+ },
+ afterResponse: { res async throws in
+ XCTAssertEqual(res.status, .ok)
+ }
+ )
+
+ let logs = try await OrdersErrorLog.query(on: app.db).all()
+ XCTAssertEqual(logs.count, 2)
+ XCTAssertEqual(logs[0].message, log1)
+ XCTAssertEqual(logs[1]._$message.value, log2)
+
+ // Test call with no DTO
+ try await app.test(
+ .POST,
+ "\(ordersURI)log",
+ afterResponse: { res async throws in
+ XCTAssertEqual(res.status, .badRequest)
+ }
+ )
+
+ // Test call with empty logs
+ try await app.test(
+ .POST,
+ "\(ordersURI)log",
+ beforeRequest: { req async throws in
+ try req.content.encode(ErrorLogDTO(logs: []))
+ },
+ afterResponse: { res async throws in
+ XCTAssertEqual(res.status, .badRequest)
+ }
+ )
+ }
+
+ func testAPNSClient() async throws {
+ XCTAssertNotNil(app.apns.client(.init(string: "orders")))
+
+ 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
+ XCTAssertEqual(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
+ XCTAssertEqual(res.status, .created)
+ }
+ )
+
+ try await app.test(
+ .POST,
+ "\(ordersURI)push/\(order.orderTypeIdentifier)/\(order.requireID())",
+ headers: ["X-Secret": "foo"],
+ afterResponse: { res async throws in
+ XCTAssertEqual(res.status, .internalServerError)
+ }
+ )
+
+ // Test `OrderDataMiddleware` update method
+ orderData.title = "Test Order 2"
+ do {
+ try await orderData.update(on: app.db)
+ } catch let error as HTTPClientError {
+ XCTAssertEqual(error.self, .remoteConnectionClosed)
+ }
+ }
+
+ func testOrdersError() {
+ XCTAssertEqual(OrdersError.templateNotDirectory.description, "OrdersError(errorType: templateNotDirectory)")
+ XCTAssertEqual(OrdersError.pemCertificateMissing.description, "OrdersError(errorType: pemCertificateMissing)")
+ XCTAssertEqual(OrdersError.pemPrivateKeyMissing.description, "OrdersError(errorType: pemPrivateKeyMissing)")
+ XCTAssertEqual(OrdersError.opensslBinaryMissing.description, "OrdersError(errorType: opensslBinaryMissing)")
+ }
+
+ func testDefaultDelegate() {
+ let delegate = DefaultOrdersDelegate()
+ XCTAssertEqual(delegate.wwdrCertificate, "WWDR.pem")
+ XCTAssertEqual(delegate.pemCertificate, "ordercertificate.pem")
+ XCTAssertEqual(delegate.pemPrivateKey, "orderkey.pem")
+ XCTAssertNil(delegate.pemPrivateKeyPassword)
+ XCTAssertEqual(delegate.sslBinary, URL(fileURLWithPath: "/usr/bin/openssl"))
+ XCTAssertFalse(delegate.generateSignatureFile(in: URL(fileURLWithPath: "")))
+ }
+}
+
+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() }
}
diff --git a/Tests/OrdersTests/SecretMiddleware.swift b/Tests/OrdersTests/SecretMiddleware.swift
new file mode 100644
index 0000000..7dadd9e
--- /dev/null
+++ b/Tests/OrdersTests/SecretMiddleware.swift
@@ -0,0 +1,16 @@
+import Vapor
+
+struct SecretMiddleware: AsyncMiddleware {
+ let secret: String
+
+ init(secret: String) {
+ self.secret = secret
+ }
+
+ func respond(to request: Request, chainingTo next: any AsyncResponder) async throws -> Response {
+ guard request.headers.first(name: "X-Secret") == secret else {
+ throw Abort(.unauthorized, reason: "Incorrect X-Secret header.")
+ }
+ return try await next.respond(to: request)
+ }
+}
diff --git a/Tests/OrdersTests/Templates/icon.png b/Tests/OrdersTests/Templates/icon.png
new file mode 100644
index 0000000..e08e7df
Binary files /dev/null and b/Tests/OrdersTests/Templates/icon.png differ
diff --git a/Tests/OrdersTests/Templates/pet_store_logo.png b/Tests/OrdersTests/Templates/pet_store_logo.png
new file mode 100644
index 0000000..b0a0dc1
Binary files /dev/null and b/Tests/OrdersTests/Templates/pet_store_logo.png differ
diff --git a/Tests/OrdersTests/TestOrdersDelegate.swift b/Tests/OrdersTests/TestOrdersDelegate.swift
new file mode 100644
index 0000000..a636cc9
--- /dev/null
+++ b/Tests/OrdersTests/TestOrdersDelegate.swift
@@ -0,0 +1,34 @@
+import Vapor
+import FluentKit
+import Orders
+
+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 {
+ 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/PassesTests/EncryptedPassesDelegate.swift b/Tests/PassesTests/EncryptedPassesDelegate.swift
new file mode 100644
index 0000000..e73cecd
--- /dev/null
+++ b/Tests/PassesTests/EncryptedPassesDelegate.swift
@@ -0,0 +1,55 @@
+import Vapor
+import FluentKit
+import Passes
+
+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
+ )
+ }
+}
\ No newline at end of file
diff --git a/Tests/PassesTests/EncryptedPassesTests.swift b/Tests/PassesTests/EncryptedPassesTests.swift
new file mode 100644
index 0000000..e2e473d
--- /dev/null
+++ b/Tests/PassesTests/EncryptedPassesTests.swift
@@ -0,0 +1,157 @@
+import XCTVapor
+import Fluent
+import FluentSQLiteDriver
+@testable import Passes
+import PassKit
+import Zip
+
+final class EncryptedPassesTests: XCTestCase {
+ let delegate = EncryptedPassesDelegate()
+ let passesURI = "/api/passes/v1/"
+ var passesService: PassesService!
+ var app: Application!
+
+ override func setUp() async throws {
+ self.app = try await Application.make(.testing)
+ app.databases.use(.sqlite(.memory), as: .sqlite)
+
+ PassesService.register(migrations: app.migrations)
+ app.migrations.add(CreatePassData())
+ passesService = try PassesService(
+ 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")
+ }
+
+ override func tearDown() async throws {
+ try await app.autoRevert()
+ try await self.app.asyncShutdown()
+ self.app = nil
+ }
+
+ func testPassGeneration() async throws {
+ 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)
+
+ XCTAssert(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]
+ XCTAssertEqual(passJSON["authenticationToken"] as? String, pass.authenticationToken)
+ try XCTAssertEqual(passJSON["serialNumber"] as? String, pass.requireID().uuidString)
+ XCTAssertEqual(passJSON["description"] as? String, passData.title)
+
+ let manifestJSONData = try String(contentsOfFile: passFolder.path.appending("/manifest.json")).data(using: .utf8)
+ let manifestJSON = try JSONSerialization.jsonObject(with: manifestJSONData!) as! [String: Any]
+ let iconData = try Data(contentsOf: passFolder.appendingPathComponent("/icon.png"))
+ let iconHash = Array(Insecure.SHA1.hash(data: iconData)).hex
+ XCTAssertEqual(manifestJSON["icon.png"] as? String, iconHash)
+ }
+
+ func testPersonalizationAPI() async throws {
+ 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
+ XCTAssertEqual(res.status, .ok)
+ XCTAssertNotNil(res.body)
+ XCTAssertEqual(res.headers.contentType?.description, "application/octet-stream")
+ }
+ )
+
+ let personalizationQuery = try await UserPersonalization.query(on: app.db).all()
+ XCTAssertEqual(personalizationQuery.count, 1)
+ let passPersonalizationID = try await Pass.query(on: app.db).first()?
+ ._$userPersonalization.get(on: app.db)?
+ .requireID()
+ XCTAssertEqual(personalizationQuery[0]._$id.value, passPersonalizationID)
+ XCTAssertEqual(personalizationQuery[0]._$emailAddress.value, personalizationDict.requiredPersonalizationInfo.emailAddress)
+ XCTAssertEqual(personalizationQuery[0]._$familyName.value, personalizationDict.requiredPersonalizationInfo.familyName)
+ XCTAssertEqual(personalizationQuery[0]._$fullName.value, personalizationDict.requiredPersonalizationInfo.fullName)
+ XCTAssertEqual(personalizationQuery[0]._$givenName.value, personalizationDict.requiredPersonalizationInfo.givenName)
+ XCTAssertEqual(personalizationQuery[0]._$ISOCountryCode.value, personalizationDict.requiredPersonalizationInfo.ISOCountryCode)
+ XCTAssertEqual(personalizationQuery[0]._$phoneNumber.value, personalizationDict.requiredPersonalizationInfo.phoneNumber)
+ XCTAssertEqual(personalizationQuery[0]._$postalCode.value, personalizationDict.requiredPersonalizationInfo.postalCode)
+ }
+
+ func testAPNSClient() async throws {
+ XCTAssertNotNil(app.apns.client(.init(string: "passes")))
+
+ 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
+ XCTAssertEqual(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
+ XCTAssertEqual(res.status, .created)
+ }
+ )
+
+ try await app.test(
+ .POST,
+ "\(passesURI)push/\(pass.passTypeIdentifier)/\(pass.requireID())",
+ headers: ["X-Secret": "foo"],
+ afterResponse: { res async throws in
+ XCTAssertEqual(res.status, .internalServerError)
+ }
+ )
+
+ // Test `PassDataMiddleware` update method
+ passData.title = "Test Pass 2"
+ do {
+ try await passData.update(on: app.db)
+ } catch {}
+ }
+}
\ No newline at end of file
diff --git a/Tests/PassesTests/PassData.swift b/Tests/PassesTests/PassData.swift
new file mode 100644
index 0000000..b25b05e
--- /dev/null
+++ b/Tests/PassesTests/PassData.swift
@@ -0,0 +1,157 @@
+import Fluent
+import struct Foundation.UUID
+import Passes
+import Vapor
+
+final class PassData: PassDataModel, @unchecked Sendable {
+ static let schema = PassData.FieldKeys.schemaName
+
+ @ID(key: .id)
+ var id: UUID?
+
+ @Field(key: PassData.FieldKeys.title)
+ var title: String
+
+ @Parent(key: PassData.FieldKeys.passID)
+ var pass: Pass
+
+ init() { }
+
+ init(id: UUID? = nil, title: String) {
+ 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 {
+ func prepare(on database: any Database) async throws {
+ 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))
+ .create()
+ }
+
+ func revert(on database: any Database) async throws {
+ try await database.schema(PassData.FieldKeys.schemaName).delete()
+ }
+}
+
+extension PassData {
+ enum FieldKeys {
+ static let schemaName = "pass_data"
+ static let title = FieldKey(stringLiteral: "title")
+ static let passID = FieldKey(stringLiteral: "pass_id")
+ }
+}
+
+struct PassJSONData: PassJSON.Properties {
+ let description: String
+ let formatVersion = PassJSON.FormatVersion.v1
+ let organizationName = "vapor-community"
+ let passTypeIdentifier = "pass.com.vapor-community.PassKit"
+ let serialNumber: String
+ let teamIdentifier = "K6512ZA2S5"
+
+ private let webServiceURL = "https://www.example.com/api/passes/"
+ private 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 {
+ let format = PassJSON.BarcodeFormat.qr
+ let message: String
+ let messageEncoding = "iso-8859-1"
+ }
+
+ let boardingPass = Boarding(transitType: .air)
+ struct Boarding: PassJSON.BoardingPass {
+ let transitType: PassJSON.TransitType
+ let headerFields: [PassField]
+ let primaryFields: [PassField]
+ let secondaryFields: [PassField]
+ let auxiliaryFields: [PassField]
+ let backFields: [PassField]
+
+ struct PassField: PassJSON.PassFieldContent {
+ let key: String
+ let label: String
+ let value: String
+ }
+
+ init(transitType: PassJSON.TransitType) {
+ self.headerFields = [.init(key: "header", label: "Header", value: "Header")]
+ self.primaryFields = [.init(key: "primary", label: "Primary", value: "Primary")]
+ self.secondaryFields = [.init(key: "secondary", label: "Secondary", value: "Secondary")]
+ self.auxiliaryFields = [.init(key: "auxiliary", label: "Auxiliary", value: "Auxiliary")]
+ self.backFields = [.init(key: "back", label: "Back", value: "Back")]
+ self.transitType = transitType
+ }
+ }
+
+ init(data: PassData, pass: Pass) {
+ self.description = data.title
+ self.serialNumber = pass.id!.uuidString
+ self.authenticationToken = pass.authenticationToken
+ }
+}
+
+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
+
+ init(service: PassesService) {
+ self.service = service
+ }
+
+ 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())
+ 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 {
+ let pass = try await model.$pass.get(on: db)
+ pass.updatedAt = Date()
+ try await pass.save(on: db)
+ try await next.update(model, on: db)
+ try await service.sendPushNotifications(for: pass, on: db)
+ }
+}
diff --git a/Tests/PassesTests/PassesTests.swift b/Tests/PassesTests/PassesTests.swift
index 6e30f98..b1d7067 100644
--- a/Tests/PassesTests/PassesTests.swift
+++ b/Tests/PassesTests/PassesTests.swift
@@ -1,15 +1,444 @@
import XCTVapor
+import Fluent
+import FluentSQLiteDriver
@testable import Passes
+import PassKit
+import Zip
final class PassesTests: XCTestCase {
- func testExample() {
- // This is an example of a functional test case.
- // Use XCTAssert and related functions to verify your tests produce the correct
- // results.
- //XCTAssertEqual(PassesService().text, "Hello, World!")
+ let delegate = TestPassesDelegate()
+ let passesURI = "/api/passes/v1/"
+ var passesService: PassesService!
+ var app: Application!
+
+ override func setUp() async throws {
+ self.app = try await Application.make(.testing)
+ app.databases.use(.sqlite(.memory), as: .sqlite)
+
+ PassesService.register(migrations: app.migrations)
+ app.migrations.add(CreatePassData())
+ passesService = try PassesService(
+ 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")
+ }
+
+ override func tearDown() async throws {
+ try await app.autoRevert()
+ try await self.app.asyncShutdown()
+ self.app = nil
+ }
+
+ func testPassGeneration() async throws {
+ 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)
+
+ XCTAssert(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]
+ XCTAssertEqual(passJSON["authenticationToken"] as? String, pass.authenticationToken)
+ try XCTAssertEqual(passJSON["serialNumber"] as? String, pass.requireID().uuidString)
+ XCTAssertEqual(passJSON["description"] as? String, passData.title)
+
+ let manifestJSONData = try String(contentsOfFile: passFolder.path.appending("/manifest.json")).data(using: .utf8)
+ let manifestJSON = try JSONSerialization.jsonObject(with: manifestJSONData!) as! [String: Any]
+ let iconData = try Data(contentsOf: passFolder.appendingPathComponent("/icon.png"))
+ let iconHash = Array(Insecure.SHA1.hash(data: iconData)).hex
+ XCTAssertEqual(manifestJSON["icon.png"] as? String, iconHash)
+ }
+
+ func testPassesGeneration() async throws {
+ let passData1 = PassData(title: "Test Pass 1")
+ try await passData1.create(on: app.db)
+ let pass1 = try await passData1.$pass.get(on: app.db)
+
+ let passData2 = PassData(title: "Test Pass 2")
+ try await passData2.create(on: app.db)
+ let pass2 = try await passData2._$pass.get(on: app.db)
+
+ let data = try await passesService.generatePassesContent(for: [pass1, pass2], on: app.db)
+ XCTAssertNotNil(data)
+ }
+
+ func testPersonalization() async throws {
+ 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")
+ try data.write(to: passURL)
+ let passFolder = try Zip.quickUnzipFile(passURL)
+
+ let passJSONData = try String(contentsOfFile: passFolder.path.appending("/pass.json")).data(using: .utf8)
+ let passJSON = try JSONSerialization.jsonObject(with: passJSONData!) as! [String: Any]
+ XCTAssertEqual(passJSON["authenticationToken"] as? String, pass.authenticationToken)
+ try XCTAssertEqual(passJSON["serialNumber"] as? String, pass.requireID().uuidString)
+ XCTAssertEqual(passJSON["description"] as? String, passData.title)
+
+ let personalizationJSONData = try String(contentsOfFile: passFolder.path.appending("/personalization.json")).data(using: .utf8)
+ let personalizationJSON = try JSONSerialization.jsonObject(with: personalizationJSONData!) as! [String: Any]
+ XCTAssertEqual(personalizationJSON["description"] as? String, "Hello, World!")
+
+ let manifestJSONData = try String(contentsOfFile: passFolder.path.appending("/manifest.json")).data(using: .utf8)
+ let manifestJSON = try JSONSerialization.jsonObject(with: manifestJSONData!) as! [String: Any]
+ let iconData = try Data(contentsOf: passFolder.appendingPathComponent("/personalizationLogo.png"))
+ let iconHash = Array(Insecure.SHA1.hash(data: iconData)).hex
+ XCTAssertEqual(manifestJSON["personalizationLogo.png"] as? String, iconHash)
+ }
+
+ // Tests the API Apple Wallet calls to get passes
+ func testGetPassFromAPI() async throws {
+ 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())",
+ headers: [
+ "Authorization": "ApplePass \(pass.authenticationToken)",
+ "If-Modified-Since": "0"
+ ],
+ afterResponse: { res async throws in
+ XCTAssertEqual(res.status, .ok)
+ XCTAssertNotNil(res.body)
+ XCTAssertEqual(res.headers.contentType?.description, "application/vnd.apple.pkpass")
+ XCTAssertNotNil(res.headers.lastModified)
+ }
+ )
+
+ // Test call with invalid authentication token
+ try await app.test(
+ .GET,
+ "\(passesURI)passes/\(pass.passTypeIdentifier)/\(pass.requireID())",
+ headers: [
+ "Authorization": "ApplePass invalid-token",
+ "If-Modified-Since": "0"
+ ],
+ afterResponse: { res async throws in
+ XCTAssertEqual(res.status, .unauthorized)
+ }
+ )
+
+ // Test distant future `If-Modified-Since` date
+ try await app.test(
+ .GET,
+ "\(passesURI)passes/\(pass.passTypeIdentifier)/\(pass.requireID())",
+ headers: [
+ "Authorization": "ApplePass \(pass.authenticationToken)",
+ "If-Modified-Since": "2147483647"
+ ],
+ afterResponse: { res async throws in
+ XCTAssertEqual(res.status, .notModified)
+ }
+ )
+
+ // Test call with invalid pass ID
+ try await app.test(
+ .GET,
+ "\(passesURI)passes/\(pass.passTypeIdentifier)/invalid-uuid",
+ headers: [
+ "Authorization": "ApplePass \(pass.authenticationToken)",
+ "If-Modified-Since": "0"
+ ],
+ afterResponse: { res async throws in
+ XCTAssertEqual(res.status, .badRequest)
+ }
+ )
+
+ // Test call with invalid pass type identifier
+ try await app.test(
+ .GET,
+ "\(passesURI)passes/pass.com.example.InvalidType/\(pass.requireID())",
+ headers: [
+ "Authorization": "ApplePass \(pass.authenticationToken)",
+ "If-Modified-Since": "0"
+ ],
+ afterResponse: { res async throws in
+ XCTAssertEqual(res.status, .notFound)
+ }
+ )
+ }
+
+ func testPersonalizationAPI() async throws {
+ 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",
+ beforeRequest: { req async throws in
+ try req.content.encode(personalizationDict)
+ },
+ afterResponse: { res async throws in
+ XCTAssertEqual(res.status, .ok)
+ XCTAssertNotNil(res.body)
+ XCTAssertEqual(res.headers.contentType?.description, "application/octet-stream")
+ }
+ )
+
+ let personalizationQuery = try await UserPersonalization.query(on: app.db).all()
+ XCTAssertEqual(personalizationQuery.count, 1)
+ let passPersonalizationID = try await Pass.query(on: app.db).first()?
+ ._$userPersonalization.get(on: app.db)?
+ .requireID()
+ XCTAssertEqual(personalizationQuery[0]._$id.value, passPersonalizationID)
+ XCTAssertEqual(personalizationQuery[0]._$emailAddress.value, personalizationDict.requiredPersonalizationInfo.emailAddress)
+ XCTAssertEqual(personalizationQuery[0]._$familyName.value, personalizationDict.requiredPersonalizationInfo.familyName)
+ XCTAssertEqual(personalizationQuery[0]._$fullName.value, personalizationDict.requiredPersonalizationInfo.fullName)
+ XCTAssertEqual(personalizationQuery[0]._$givenName.value, personalizationDict.requiredPersonalizationInfo.givenName)
+ XCTAssertEqual(personalizationQuery[0]._$ISOCountryCode.value, personalizationDict.requiredPersonalizationInfo.ISOCountryCode)
+ XCTAssertEqual(personalizationQuery[0]._$phoneNumber.value, personalizationDict.requiredPersonalizationInfo.phoneNumber)
+ XCTAssertEqual(personalizationQuery[0]._$postalCode.value, personalizationDict.requiredPersonalizationInfo.postalCode)
+
+ // Test call with invalid pass ID
+ try await app.test(
+ .POST,
+ "\(passesURI)passes/\(pass.passTypeIdentifier)/invalid-uuid/personalize",
+ beforeRequest: { req async throws in
+ try req.content.encode(personalizationDict)
+ },
+ afterResponse: { res async throws in
+ XCTAssertEqual(res.status, .badRequest)
+ }
+ )
+
+ // Test call with invalid pass type identifier
+ try await app.test(
+ .POST,
+ "\(passesURI)passes/pass.com.example.InvalidType/\(pass.requireID())/personalize",
+ beforeRequest: { req async throws in
+ try req.content.encode(personalizationDict)
+ },
+ afterResponse: { res async throws in
+ XCTAssertEqual(res.status, .notFound)
+ }
+ )
+ }
+
+ func testAPIDeviceRegistration() async throws {
+ let passData = PassData(title: "Test Pass")
+ try await passData.create(on: app.db)
+ let pass = try await passData.$pass.get(on: app.db)
+ let deviceLibraryIdentifier = "abcdefg"
+ let pushToken = "1234567890"
+
+ // Test registration without authentication token
+ try await app.test(
+ .POST,
+ "\(passesURI)devices/\(deviceLibraryIdentifier)/registrations/\(pass.passTypeIdentifier)/\(pass.requireID())",
+ beforeRequest: { req async throws in
+ try req.content.encode(RegistrationDTO(pushToken: pushToken))
+ },
+ afterResponse: { res async throws in
+ XCTAssertEqual(res.status, .unauthorized)
+ }
+ )
+
+ 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
+ XCTAssertEqual(res.status, .created)
+ }
+ )
+
+ // Test registration of an already registered device
+ 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
+ XCTAssertEqual(res.status, .ok)
+ }
+ )
+
+ try await app.test(
+ .GET,
+ "\(passesURI)devices/\(deviceLibraryIdentifier)/registrations/\(pass.passTypeIdentifier)?passesUpdatedSince=0",
+ afterResponse: { res async throws in
+ let passes = try res.content.decode(PassesForDeviceDTO.self)
+ XCTAssertEqual(passes.serialNumbers.count, 1)
+ let passID = try pass.requireID()
+ XCTAssertEqual(passes.serialNumbers[0], passID.uuidString)
+ XCTAssertEqual(passes.lastUpdated, String(pass.updatedAt!.timeIntervalSince1970))
+ }
+ )
+
+ try await app.test(
+ .GET,
+ "\(passesURI)push/\(pass.passTypeIdentifier)/\(pass.requireID())",
+ headers: ["X-Secret": "foo"],
+ afterResponse: { res async throws in
+ let pushTokens = try res.content.decode([String].self)
+ XCTAssertEqual(pushTokens.count, 1)
+ XCTAssertEqual(pushTokens[0], pushToken)
+ }
+ )
+
+ try await app.test(
+ .DELETE,
+ "\(passesURI)devices/\(deviceLibraryIdentifier)/registrations/\(pass.passTypeIdentifier)/\(pass.requireID())",
+ headers: ["Authorization": "ApplePass \(pass.authenticationToken)"],
+ afterResponse: { res async throws in
+ XCTAssertEqual(res.status, .ok)
+ }
+ )
+ }
+
+ func testErrorLog() async throws {
+ let log1 = "Error 1"
+ let log2 = "Error 2"
+
+ try await app.test(
+ .POST,
+ "\(passesURI)log",
+ beforeRequest: { req async throws in
+ try req.content.encode(ErrorLogDTO(logs: [log1, log2]))
+ },
+ afterResponse: { res async throws in
+ XCTAssertEqual(res.status, .ok)
+ }
+ )
+
+ let logs = try await PassesErrorLog.query(on: app.db).all()
+ XCTAssertEqual(logs.count, 2)
+ XCTAssertEqual(logs[0].message, log1)
+ XCTAssertEqual(logs[1]._$message.value, log2)
+
+ // Test call with no DTO
+ try await app.test(
+ .POST,
+ "\(passesURI)log",
+ afterResponse: { res async throws in
+ XCTAssertEqual(res.status, .badRequest)
+ }
+ )
+
+ // Test call with empty logs
+ try await app.test(
+ .POST,
+ "\(passesURI)log",
+ beforeRequest: { req async throws in
+ try req.content.encode(ErrorLogDTO(logs: []))
+ },
+ afterResponse: { res async throws in
+ XCTAssertEqual(res.status, .badRequest)
+ }
+ )
}
- static var allTests = [
- ("testExample", testExample),
- ]
+ func testAPNSClient() async throws {
+ XCTAssertNotNil(app.apns.client(.init(string: "passes")))
+
+ 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
+ XCTAssertEqual(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
+ XCTAssertEqual(res.status, .created)
+ }
+ )
+
+ try await app.test(
+ .POST,
+ "\(passesURI)push/\(pass.passTypeIdentifier)/\(pass.requireID())",
+ headers: ["X-Secret": "foo"],
+ afterResponse: { res async throws in
+ XCTAssertEqual(res.status, .internalServerError)
+ }
+ )
+
+ // Test `PassDataMiddleware` update method
+ passData.title = "Test Pass 2"
+ do {
+ try await passData.update(on: app.db)
+ } catch let error as HTTPClientError {
+ XCTAssertEqual(error.self, .remoteConnectionClosed)
+ }
+ }
+
+ func testPassesError() {
+ XCTAssertEqual(PassesError.templateNotDirectory.description, "PassesError(errorType: templateNotDirectory)")
+ XCTAssertEqual(PassesError.pemCertificateMissing.description, "PassesError(errorType: pemCertificateMissing)")
+ XCTAssertEqual(PassesError.pemPrivateKeyMissing.description, "PassesError(errorType: pemPrivateKeyMissing)")
+ XCTAssertEqual(PassesError.opensslBinaryMissing.description, "PassesError(errorType: opensslBinaryMissing)")
+ XCTAssertEqual(PassesError.invalidNumberOfPasses.description, "PassesError(errorType: invalidNumberOfPasses)")
+ }
+
+ func testDefaultDelegate() async throws {
+ let delegate = DefaultPassesDelegate()
+ XCTAssertEqual(delegate.wwdrCertificate, "WWDR.pem")
+ XCTAssertEqual(delegate.pemCertificate, "passcertificate.pem")
+ XCTAssertEqual(delegate.pemPrivateKey, "passkey.pem")
+ XCTAssertNil(delegate.pemPrivateKeyPassword)
+ XCTAssertEqual(delegate.sslBinary, URL(fileURLWithPath: "/usr/bin/openssl"))
+ XCTAssertFalse(delegate.generateSignatureFile(in: URL(fileURLWithPath: "")))
+
+ let passData = PassData(title: "Test Pass")
+ try await passData.create(on: app.db)
+ let pass = try await passData.$pass.get(on: app.db)
+ let data = try await delegate.encodePersonalization(for: pass, db: app.db, encoder: JSONEncoder())
+ XCTAssertNil(data)
+ }
+}
+
+final class DefaultPassesDelegate: PassesDelegate {
+ let sslSigningFilesDirectory = URL(fileURLWithPath: "", isDirectory: true)
+ func template(for pass: P, db: any Database) async throws -> URL { URL(fileURLWithPath: "") }
+ func encode(pass: P, db: any Database, encoder: JSONEncoder) async throws -> Data { Data() }
}
diff --git a/Tests/PassesTests/SecretMiddleware.swift b/Tests/PassesTests/SecretMiddleware.swift
new file mode 100644
index 0000000..7dadd9e
--- /dev/null
+++ b/Tests/PassesTests/SecretMiddleware.swift
@@ -0,0 +1,16 @@
+import Vapor
+
+struct SecretMiddleware: AsyncMiddleware {
+ let secret: String
+
+ init(secret: String) {
+ self.secret = secret
+ }
+
+ func respond(to request: Request, chainingTo next: any AsyncResponder) async throws -> Response {
+ guard request.headers.first(name: "X-Secret") == secret else {
+ throw Abort(.unauthorized, reason: "Incorrect X-Secret header.")
+ }
+ return try await next.respond(to: request)
+ }
+}
diff --git a/Tests/PassesTests/Templates/icon.png b/Tests/PassesTests/Templates/icon.png
new file mode 100644
index 0000000..e08e7df
Binary files /dev/null and b/Tests/PassesTests/Templates/icon.png differ
diff --git a/Tests/PassesTests/Templates/logo.png b/Tests/PassesTests/Templates/logo.png
new file mode 100644
index 0000000..0b96c31
Binary files /dev/null and b/Tests/PassesTests/Templates/logo.png differ
diff --git a/Tests/PassesTests/Templates/personalizationLogo.png b/Tests/PassesTests/Templates/personalizationLogo.png
new file mode 100644
index 0000000..0b96c31
Binary files /dev/null and b/Tests/PassesTests/Templates/personalizationLogo.png differ
diff --git a/Tests/PassesTests/TestPassesDelegate.swift b/Tests/PassesTests/TestPassesDelegate.swift
new file mode 100644
index 0000000..51c3429
--- /dev/null
+++ b/Tests/PassesTests/TestPassesDelegate.swift
@@ -0,0 +1,53 @@
+import Vapor
+import FluentKit
+import Passes
+
+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 {
+ 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
+ )
+ }
+}