.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 0000000000000000000000000000000000000000..e08e7dfd63f9511fb54653fb73805f63ced37430
GIT binary patch
literal 1723
zcmeAS@N?(olHy`uVBq!ia0vp^vLMXC1|-8Kr}G0T#^NA%Cx&(BWL^R}EvXTnX}-P;
zT0k}j11qBt12aeo5Hc`IF|dN!3=Ce3(r|VVqXtwB69YqgCIbspO%#v@0S_Ps>W0$H
z3m6e5E?|PImR-P%V1u;jI#z>R%UR$NS8?tx|+mh4$+(B?3=AyUo-U3d9=u1xyfb1#CH|eOwJ?^PyzHXcGC}dC;0+5+
zyB4y}7kQMO=z4_R#WP1+L&Q-tLR|07vE#>jm39AyV0A2%8ozpFq0bKn2p^674^?(Yp>obRx?|M8K*x(bfp
zM^>)znW)WV<5H`%^`eTn>$l#J7Bw%=Rlh{U#B@Vm(LaY7~jp(xaZ3T
z%UJf6jm*w9!o`y=fBvbUpyluU`clg#=Wn0?xK8QPVpNr!DzWzG3+HylM+#1Fo=()B
zWbuPf
r#NIT9ECsCe|)#^
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
HoqPWWk?Qmp
literal 0
HcmV?d00001
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 0000000000000000000000000000000000000000..e08e7dfd63f9511fb54653fb73805f63ced37430
GIT binary patch
literal 1723
zcmeAS@N?(olHy`uVBq!ia0vp^vLMXC1|-8Kr}G0T#^NA%Cx&(BWL^R}EvXTnX}-P;
zT0k}j11qBt12aeo5Hc`IF|dN!3=Ce3(r|VVqXtwB69YqgCIbspO%#v@0S_Ps>W0$H
z3m6e5E?|PImR-P%V1u;jI#z>R%UR$NS8?tx|+mh4$+(B?3=AyUo-U3d9=u1xyfb1#CH|eOwJ?^PyzHXcGC}dC;0+5+
zyB4y}7kQMO=z4_R#WP1+L&Q-tLR|07vE#>jm39AyV0A2%8ozpFq0bKn2p^674^?(Yp>obRx?|M8K*x(bfp
zM^>)znW)WV<5H`%^`eTn>$l#J7Bw%=Rlh{U#B@Vm(LaY7~jp(xaZ3T
z%UJf6jm*w9!o`y=fBvbUpyluU`clg#=Wn0?xK8QPVpNr!DzWzG3+HylM+#1Fo=()B
zWbuPf
r#NIT9ECsCe|)#^
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
literal 0
HcmV?d00001
diff --git a/Tests/PassesTests/Templates/personalizationLogo.png b/Tests/PassesTests/Templates/personalizationLogo.png
new file mode 100644
index 0000000000000000000000000000000000000000..0b96c3171cfb1804309c8658cf385b2cc245acfc
GIT binary patch
literal 3593
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
literal 0
HcmV?d00001
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
+ )
+ }
+}