diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c69731e..2054aea 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -7,16 +7,9 @@ on: push: { branches: [ main ] } jobs: - lint: - runs-on: ubuntu-latest - container: swift:noble - steps: - - name: Check out PassKit - uses: actions/checkout@v4 - - name: Run format lint check - run: swift format lint --strict --recursive --parallel . - unit-tests: uses: vapor/ci/.github/workflows/run-unit-tests.yml@main + with: + with_linting: true secrets: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} diff --git a/.swift-format b/.swift-format index 360ca2c..47901d1 100644 --- a/.swift-format +++ b/.swift-format @@ -11,7 +11,7 @@ "lineBreakBeforeControlFlowKeywords": false, "lineBreakBeforeEachArgument": false, "lineBreakBeforeEachGenericRequirement": false, - "lineLength": 100, + "lineLength": 140, "maximumBlankLines": 1, "multiElementCollectionTrailingCommas": true, "noAssignmentInExpressions": { diff --git a/Package.swift b/Package.swift index cdefd8f..4ce018b 100644 --- a/Package.swift +++ b/Package.swift @@ -11,13 +11,13 @@ let package = Package( .library(name: "Orders", targets: ["Orders"]), ], dependencies: [ - .package(url: "https://github.com/vapor/vapor.git", from: "4.105.2"), - .package(url: "https://github.com/vapor/fluent.git", from: "4.11.0"), + .package(url: "https://github.com/vapor/vapor.git", from: "4.106.0"), + .package(url: "https://github.com/vapor/fluent.git", from: "4.12.0"), .package(url: "https://github.com/vapor/apns.git", from: "4.2.0"), .package(url: "https://github.com/vapor-community/Zip.git", from: "2.2.3"), .package(url: "https://github.com/apple/swift-certificates.git", from: "1.5.0"), // used in tests - .package(url: "https://github.com/vapor/fluent-sqlite-driver.git", from: "4.7.4"), + .package(url: "https://github.com/vapor/fluent-sqlite-driver.git", from: "4.8.0"), ], targets: [ .target( diff --git a/README.md b/README.md index 6f978d7..4ec7ae8 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ - Swift 5.10+ + Swift 6.0+
diff --git a/Sources/Orders/OrdersService.swift b/Sources/Orders/OrdersService.swift index 3cc897f..2577513 100644 --- a/Sources/Orders/OrdersService.swift +++ b/Sources/Orders/OrdersService.swift @@ -10,8 +10,7 @@ import Vapor /// The main class that handles Wallet orders. public final class OrdersService: Sendable { - private let service: - OrdersServiceCustom + private let service: OrdersServiceCustom /// Initializes the service and registers all the routes required for Apple Wallet to work. /// diff --git a/Tests/OrdersTests/SecretMiddleware.swift b/Sources/PassKit/Testing/SecretMiddleware.swift similarity index 52% rename from Tests/OrdersTests/SecretMiddleware.swift rename to Sources/PassKit/Testing/SecretMiddleware.swift index fef1940..ec9d64f 100644 --- a/Tests/OrdersTests/SecretMiddleware.swift +++ b/Sources/PassKit/Testing/SecretMiddleware.swift @@ -1,11 +1,13 @@ import Vapor -struct SecretMiddleware: AsyncMiddleware { +package struct SecretMiddleware: AsyncMiddleware { let secret: String - func respond( - to request: Request, chainingTo next: any AsyncResponder - ) async throws -> Response { + package init(secret: String) { + self.secret = secret + } + + package func respond(to request: Request, chainingTo next: any AsyncResponder) async throws -> Response { guard request.headers.first(name: "X-Secret") == secret else { throw Abort(.unauthorized, reason: "Incorrect X-Secret header.") } diff --git a/Sources/PassKit/Testing/isLoggingConfigured.swift b/Sources/PassKit/Testing/isLoggingConfigured.swift new file mode 100644 index 0000000..ba27a1f --- /dev/null +++ b/Sources/PassKit/Testing/isLoggingConfigured.swift @@ -0,0 +1,10 @@ +import Vapor + +package let isLoggingConfigured: Bool = { + LoggingSystem.bootstrap { label in + var handler = StreamLogHandler.standardOutput(label: label) + handler.logLevel = .debug + return handler + } + return true +}() diff --git a/Sources/Passes/PassesService.swift b/Sources/Passes/PassesService.swift index 6151d48..4fa593a 100644 --- a/Sources/Passes/PassesService.swift +++ b/Sources/Passes/PassesService.swift @@ -72,8 +72,7 @@ public final class PassesService: Sendable { /// - passes: The passes to include in the bundle. /// - db: The `Database` to use. /// - Returns: The bundle of passes as `Data`. - public func generatePassesContent(for passes: [Pass], on db: any Database) async throws -> Data - { + public func generatePassesContent(for passes: [Pass], on db: any Database) async throws -> Data { try await service.generatePassesContent(for: passes, on: db) } diff --git a/Tests/OrdersTests/EncryptedOrdersTests.swift b/Tests/OrdersTests/EncryptedOrdersTests.swift index 6e39f4c..368b7dc 100644 --- a/Tests/OrdersTests/EncryptedOrdersTests.swift +++ b/Tests/OrdersTests/EncryptedOrdersTests.swift @@ -1,116 +1,92 @@ -import Fluent -import FluentSQLiteDriver +import FluentKit import PassKit +import Testing import XCTVapor import Zip @testable import Orders -final class EncryptedOrdersTests: XCTestCase { +@Suite("Orders Tests with Encrypted PEM Key") +struct EncryptedOrdersTests { 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 + @Test("Order Generation") + func orderGeneration() async throws { + try await withApp(delegate: delegate) { app, ordersService in + let orderData = OrderData(title: "Test Order") + try await orderData.create(on: app.db) + let order = try await orderData.$order.get(on: app.db) + let data = try await ordersService.generateOrderContent(for: order, on: app.db) + let orderURL = FileManager.default.temporaryDirectory.appendingPathComponent("test.order") + try data.write(to: orderURL) + let orderFolder = try Zip.quickUnzipFile(orderURL) + + #expect(FileManager.default.fileExists(atPath: orderFolder.path.appending("/signature"))) + + let passJSONData = try String(contentsOfFile: orderFolder.path.appending("/order.json")).data(using: .utf8) + let passJSON = try JSONSerialization.jsonObject(with: passJSONData!) as! [String: Any] + #expect(passJSON["authenticationToken"] as? String == order.authenticationToken) + let orderID = try order.requireID().uuidString + #expect(passJSON["orderIdentifier"] as? String == orderID) + + let manifestJSONData = try String(contentsOfFile: orderFolder.path.appending("/manifest.json")).data(using: .utf8) + let manifestJSON = try JSONSerialization.jsonObject(with: manifestJSONData!) as! [String: Any] + let iconData = try Data(contentsOf: orderFolder.appendingPathComponent("/icon.png")) + let iconHash = Array(SHA256.hash(data: iconData)).hex + #expect(manifestJSON["icon.png"] as? String == iconHash) + } } - 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 {} + @Test("APNS Client") + func apnsClient() async throws { + try await withApp(delegate: delegate) { app, ordersService in + #expect(app.apns.client(.init(string: "orders")) != nil) + + let orderData = OrderData(title: "Test Order") + try await orderData.create(on: app.db) + let order = try await orderData._$order.get(on: app.db) + + try await ordersService.sendPushNotificationsForOrder(id: order.requireID(), of: order.orderTypeIdentifier, on: app.db) + + let deviceLibraryIdentifier = "abcdefg" + let pushToken = "1234567890" + + try await app.test( + .POST, + "\(ordersURI)push/\(order.orderTypeIdentifier)/\(order.requireID())", + headers: ["X-Secret": "foo"], + afterResponse: { res async throws in + #expect(res.status == .noContent) + } + ) + + try await app.test( + .POST, + "\(ordersURI)devices/\(deviceLibraryIdentifier)/registrations/\(order.orderTypeIdentifier)/\(order.requireID())", + headers: ["Authorization": "AppleOrder \(order.authenticationToken)"], + beforeRequest: { req async throws in + try req.content.encode(RegistrationDTO(pushToken: pushToken)) + }, + afterResponse: { res async throws in + #expect(res.status == .created) + } + ) + + try await app.test( + .POST, + "\(ordersURI)push/\(order.orderTypeIdentifier)/\(order.requireID())", + headers: ["X-Secret": "foo"], + afterResponse: { res async throws in + #expect(res.status == .internalServerError) + } + ) + + // Test `OrderDataMiddleware` update method + orderData.title = "Test Order 2" + do { + try await orderData.update(on: app.db) + } catch {} + } } } diff --git a/Tests/OrdersTests/OrdersTests.swift b/Tests/OrdersTests/OrdersTests.swift index 9833474..c7d1557 100644 --- a/Tests/OrdersTests/OrdersTests.swift +++ b/Tests/OrdersTests/OrdersTests.swift @@ -1,421 +1,409 @@ -import Fluent -import FluentSQLiteDriver +import FluentKit import PassKit +import Testing import XCTVapor import Zip @testable import Orders -final class OrdersTests: XCTestCase { +@Suite("Orders Tests") +struct OrdersTests { 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) - XCTAssertNotNil(manifestJSON["pet_store_logo.png"]) + @Test("Order Generation") + func orderGeneration() async throws { + try await withApp(delegate: delegate) { app, ordersService in + let orderData = OrderData(title: "Test Order") + try await orderData.create(on: app.db) + let order = try await orderData.$order.get(on: app.db) + let data = try await ordersService.generateOrderContent(for: order, on: app.db) + let orderURL = FileManager.default.temporaryDirectory.appendingPathComponent("test.order") + try data.write(to: orderURL) + let orderFolder = try Zip.quickUnzipFile(orderURL) + + #expect(FileManager.default.fileExists(atPath: orderFolder.path.appending("/signature"))) + + let passJSONData = try String(contentsOfFile: orderFolder.path.appending("/order.json")).data(using: .utf8) + let passJSON = try JSONSerialization.jsonObject(with: passJSONData!) as! [String: Any] + #expect(passJSON["authenticationToken"] as? String == order.authenticationToken) + let orderID = try order.requireID().uuidString + #expect(passJSON["orderIdentifier"] as? String == orderID) + + let manifestJSONData = try String(contentsOfFile: orderFolder.path.appending("/manifest.json")).data(using: .utf8) + let manifestJSON = try JSONSerialization.jsonObject(with: manifestJSONData!) as! [String: Any] + let iconData = try Data(contentsOf: orderFolder.appendingPathComponent("/icon.png")) + let iconHash = Array(SHA256.hash(data: iconData)).hex + #expect(manifestJSON["icon.png"] as? String == iconHash) + #expect(manifestJSON["pet_store_logo.png"] != nil) + } } - // 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) - } - ) + @Test("Getting Order from Apple Wallet API") + func getOrderFromAPI() async throws { + try await withApp(delegate: delegate) { app, ordersService in + let orderData = OrderData(title: "Test Order") + try await orderData.create(on: app.db) + let order = try await orderData.$order.get(on: app.db) + + 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 + #expect(res.status == .ok) + #expect(res.body != nil) + #expect(res.headers.contentType?.description == "application/vnd.apple.order") + #expect(res.headers.lastModified != nil) + } + ) + + // 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 + #expect(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 + #expect(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 + #expect(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 + #expect(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" - - try await app.test( - .GET, - "\(ordersURI)devices/\(deviceLibraryIdentifier)/registrations/\(order.orderTypeIdentifier)?ordersModifiedSince=0", - afterResponse: { res async throws in - XCTAssertEqual(res.status, .noContent) - } - ) - - try await app.test( - .DELETE, - "\(ordersURI)devices/\(deviceLibraryIdentifier)/registrations/\(order.orderTypeIdentifier)/\(order.requireID())", - headers: ["Authorization": "AppleOrder \(order.authenticationToken)"], - afterResponse: { res async throws in - XCTAssertEqual(res.status, .notFound) - } - ) - - // Test registration without authentication token - try await app.test( - .POST, - "\(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) - } - ) - - // Test registration of a non-existing order - try await app.test( - .POST, - "\(ordersURI)devices/\(deviceLibraryIdentifier)/registrations/\("order.com.example.NotFound")/\(UUID().uuidString)", - headers: ["Authorization": "AppleOrder \(order.authenticationToken)"], - beforeRequest: { req async throws in - try req.content.encode(RegistrationDTO(pushToken: pushToken)) - }, - afterResponse: { res async throws in - XCTAssertEqual(res.status, .notFound) - } - ) - - // Test call without DTO - try await app.test( - .POST, - "\(ordersURI)devices/\(deviceLibraryIdentifier)/registrations/\(order.orderTypeIdentifier)/\(order.requireID())", - headers: ["Authorization": "AppleOrder \(order.authenticationToken)"], - afterResponse: { res async throws in - XCTAssertEqual(res.status, .badRequest) - } - ) - - // Test call with invalid UUID - try await app.test( - .POST, - "\(ordersURI)devices/\(deviceLibraryIdentifier)/registrations/\(order.orderTypeIdentifier)/\("not-a-uuid")", - headers: ["Authorization": "AppleOrder \(order.authenticationToken)"], - beforeRequest: { req async throws in - try req.content.encode(RegistrationDTO(pushToken: pushToken)) - }, - afterResponse: { res async throws in - XCTAssertEqual(res.status, .badRequest) - } - ) - - try await app.test( - .POST, - "\(ordersURI)devices/\(deviceLibraryIdentifier)/registrations/\(order.orderTypeIdentifier)/\(order.requireID())", - 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) - } - ) - - // Test call with invalid UUID - try await app.test( - .GET, - "\(ordersURI)push/\(order.orderTypeIdentifier)/\("not-a-uuid")", - headers: ["X-Secret": "foo"], - afterResponse: { res async throws in - XCTAssertEqual(res.status, .badRequest) - } - ) - - // Test call with invalid UUID - try await app.test( - .DELETE, - "\(ordersURI)devices/\(deviceLibraryIdentifier)/registrations/\(order.orderTypeIdentifier)/\("not-a-uuid")", - headers: ["Authorization": "AppleOrder \(order.authenticationToken)"], - afterResponse: { res async throws in - XCTAssertEqual(res.status, .badRequest) - } - ) - - try await app.test( - .DELETE, - "\(ordersURI)devices/\(deviceLibraryIdentifier)/registrations/\(order.orderTypeIdentifier)/\(order.requireID())", - headers: ["Authorization": "AppleOrder \(order.authenticationToken)"], - afterResponse: { res async throws in - XCTAssertEqual(res.status, .ok) - } - ) + @Test("Device Registration API") + func apiDeviceRegistration() async throws { + try await withApp(delegate: delegate) { app, ordersService in + let orderData = OrderData(title: "Test Order") + try await orderData.create(on: app.db) + let order = try await orderData.$order.get(on: app.db) + let deviceLibraryIdentifier = "abcdefg" + let pushToken = "1234567890" + + try await app.test( + .GET, + "\(ordersURI)devices/\(deviceLibraryIdentifier)/registrations/\(order.orderTypeIdentifier)?ordersModifiedSince=0", + afterResponse: { res async throws in + #expect(res.status == .noContent) + } + ) + + try await app.test( + .DELETE, + "\(ordersURI)devices/\(deviceLibraryIdentifier)/registrations/\(order.orderTypeIdentifier)/\(order.requireID())", + headers: ["Authorization": "AppleOrder \(order.authenticationToken)"], + afterResponse: { res async throws in + #expect(res.status == .notFound) + } + ) + + // 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 + #expect(res.status == .unauthorized) + } + ) + + // Test registration of a non-existing order + try await app.test( + .POST, + "\(ordersURI)devices/\(deviceLibraryIdentifier)/registrations/\("order.com.example.NotFound")/\(UUID().uuidString)", + headers: ["Authorization": "AppleOrder \(order.authenticationToken)"], + beforeRequest: { req async throws in + try req.content.encode(RegistrationDTO(pushToken: pushToken)) + }, + afterResponse: { res async throws in + #expect(res.status == .notFound) + } + ) + + // Test call without DTO + try await app.test( + .POST, + "\(ordersURI)devices/\(deviceLibraryIdentifier)/registrations/\(order.orderTypeIdentifier)/\(order.requireID())", + headers: ["Authorization": "AppleOrder \(order.authenticationToken)"], + afterResponse: { res async throws in + #expect(res.status == .badRequest) + } + ) + + // Test call with invalid UUID + try await app.test( + .POST, + "\(ordersURI)devices/\(deviceLibraryIdentifier)/registrations/\(order.orderTypeIdentifier)/\("not-a-uuid")", + headers: ["Authorization": "AppleOrder \(order.authenticationToken)"], + beforeRequest: { req async throws in + try req.content.encode(RegistrationDTO(pushToken: pushToken)) + }, + afterResponse: { res async throws in + #expect(res.status == .badRequest) + } + ) + + try await app.test( + .POST, + "\(ordersURI)devices/\(deviceLibraryIdentifier)/registrations/\(order.orderTypeIdentifier)/\(order.requireID())", + headers: ["Authorization": "AppleOrder \(order.authenticationToken)"], + beforeRequest: { req async throws in + try req.content.encode(RegistrationDTO(pushToken: pushToken)) + }, + afterResponse: { res async throws in + #expect(res.status == .created) + } + ) + + // 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 + #expect(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) + #expect(orders.orderIdentifiers.count == 1) + let orderID = try order.requireID() + #expect(orders.orderIdentifiers[0] == orderID.uuidString) + #expect(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) + #expect(pushTokens.count == 1) + #expect(pushTokens[0] == pushToken) + } + ) + + // Test call with invalid UUID + try await app.test( + .GET, + "\(ordersURI)push/\(order.orderTypeIdentifier)/\("not-a-uuid")", + headers: ["X-Secret": "foo"], + afterResponse: { res async throws in + #expect(res.status == .badRequest) + } + ) + + // Test call with invalid UUID + try await app.test( + .DELETE, + "\(ordersURI)devices/\(deviceLibraryIdentifier)/registrations/\(order.orderTypeIdentifier)/\("not-a-uuid")", + headers: ["Authorization": "AppleOrder \(order.authenticationToken)"], + afterResponse: { res async throws in + #expect(res.status == .badRequest) + } + ) + + try await app.test( + .DELETE, + "\(ordersURI)devices/\(deviceLibraryIdentifier)/registrations/\(order.orderTypeIdentifier)/\(order.requireID())", + headers: ["Authorization": "AppleOrder \(order.authenticationToken)"], + afterResponse: { res async throws in + #expect(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) - } - ) + @Test("Error Logging") + func errorLog() async throws { + try await withApp(delegate: delegate) { app, ordersService in + 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 + #expect(res.status == .ok) + } + ) + + let logs = try await OrdersErrorLog.query(on: app.db).all() + #expect(logs.count == 2) + #expect(logs[0].message == log1) + #expect(logs[1]._$message.value == log2) + + // Test call with no DTO + try await app.test( + .POST, + "\(ordersURI)log", + afterResponse: { res async throws in + #expect(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 + #expect(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 call with invalid UUID - try await app.test( - .POST, - "\(ordersURI)push/\(order.orderTypeIdentifier)/\("not-a-uuid")", - headers: ["X-Secret": "foo"], - afterResponse: { res async throws in - XCTAssertEqual(res.status, .badRequest) + @Test("APNS Client") + func apnsClient() async throws { + try await withApp(delegate: delegate) { app, ordersService in + #expect(app.apns.client(.init(string: "orders")) != nil) + + let orderData = OrderData(title: "Test Order") + try await orderData.create(on: app.db) + let order = try await orderData._$order.get(on: app.db) + + try await ordersService.sendPushNotificationsForOrder(id: order.requireID(), of: order.orderTypeIdentifier, on: app.db) + + let deviceLibraryIdentifier = "abcdefg" + let pushToken = "1234567890" + + // Test call with incorrect secret + try await app.test( + .POST, + "\(ordersURI)push/\(order.orderTypeIdentifier)/\(order.requireID())", + headers: ["X-Secret": "bar"], + afterResponse: { res async throws in + #expect(res.status == .unauthorized) + } + ) + + try await app.test( + .POST, + "\(ordersURI)push/\(order.orderTypeIdentifier)/\(order.requireID())", + headers: ["X-Secret": "foo"], + afterResponse: { res async throws in + #expect(res.status == .noContent) + } + ) + + try await app.test( + .POST, + "\(ordersURI)devices/\(deviceLibraryIdentifier)/registrations/\(order.orderTypeIdentifier)/\(order.requireID())", + headers: ["Authorization": "AppleOrder \(order.authenticationToken)"], + beforeRequest: { req async throws in + try req.content.encode(RegistrationDTO(pushToken: pushToken)) + }, + afterResponse: { res async throws in + #expect(res.status == .created) + } + ) + + try await app.test( + .POST, + "\(ordersURI)push/\(order.orderTypeIdentifier)/\(order.requireID())", + headers: ["X-Secret": "foo"], + afterResponse: { res async throws in + #expect(res.status == .internalServerError) + } + ) + + // Test call with invalid UUID + try await app.test( + .POST, + "\(ordersURI)push/\(order.orderTypeIdentifier)/\("not-a-uuid")", + headers: ["X-Secret": "foo"], + afterResponse: { res async throws in + #expect(res.status == .badRequest) + } + ) + + // Test `OrderDataMiddleware` update method + orderData.title = "Test Order 2" + do { + try await orderData.update(on: app.db) + } catch let error as HTTPClientError { + #expect(error.self == .remoteConnectionClosed) } - ) - - // 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)") + @Test("OrdersError") + func ordersError() { + #expect(OrdersError.templateNotDirectory.description == "OrdersError(errorType: templateNotDirectory)") + #expect(OrdersError.pemCertificateMissing.description == "OrdersError(errorType: pemCertificateMissing)") + #expect(OrdersError.pemPrivateKeyMissing.description == "OrdersError(errorType: pemPrivateKeyMissing)") + #expect(OrdersError.opensslBinaryMissing.description == "OrdersError(errorType: opensslBinaryMissing)") } - func testDefaultDelegate() { + @Test("Default OrdersDelegate Properties") + func defaultDelegate() { 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: ""))) + #expect(delegate.wwdrCertificate == "WWDR.pem") + #expect(delegate.pemCertificate == "ordercertificate.pem") + #expect(delegate.pemPrivateKey == "orderkey.pem") + #expect(delegate.pemPrivateKeyPassword == nil) + #expect(delegate.sslBinary == URL(fileURLWithPath: "/usr/bin/openssl")) + #expect(!delegate.generateSignatureFile(in: URL(fileURLWithPath: ""))) } } @@ -424,9 +412,7 @@ final class DefaultOrdersDelegate: OrdersDelegate { 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 { + func encode(order: O, db: any Database, encoder: JSONEncoder) async throws -> Data { Data() } } diff --git a/Tests/OrdersTests/withApp.swift b/Tests/OrdersTests/withApp.swift new file mode 100644 index 0000000..ab5df1d --- /dev/null +++ b/Tests/OrdersTests/withApp.swift @@ -0,0 +1,38 @@ +import FluentKit +import FluentSQLiteDriver +import Orders +import PassKit +import Testing +import Vapor +import Zip + +func withApp( + delegate: some OrdersDelegate, + _ body: (Application, OrdersService) async throws -> Void +) async throws { + let app = try await Application.make(.testing) + + try #require(isLoggingConfigured) + + app.databases.use(.sqlite(.memory), as: .sqlite) + + OrdersService.register(migrations: app.migrations) + app.migrations.add(CreateOrderData()) + let passesService = try OrdersService( + app: app, + delegate: delegate, + pushRoutesMiddleware: SecretMiddleware(secret: "foo"), + logger: app.logger + ) + + app.databases.middleware.use(OrderDataMiddleware(service: passesService), on: .sqlite) + + try await app.autoMigrate() + + Zip.addCustomFileExtension("order") + + try await body(app, passesService) + + try await app.autoRevert() + try await app.asyncShutdown() +} diff --git a/Tests/PassesTests/EncryptedPassesTests.swift b/Tests/PassesTests/EncryptedPassesTests.swift index 971f850..f533c6e 100644 --- a/Tests/PassesTests/EncryptedPassesTests.swift +++ b/Tests/PassesTests/EncryptedPassesTests.swift @@ -1,177 +1,139 @@ -import Fluent -import FluentSQLiteDriver import PassKit +import Testing import XCTVapor import Zip @testable import Passes -final class EncryptedPassesTests: XCTestCase { +@Suite("Passes Tests with Encrypted PEM Key") +struct EncryptedPassesTests { 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 + @Test("Pass Generation") + func passGeneration() async throws { + try await withApp(delegate: delegate) { app, passesService in + let passData = PassData(title: "Test Pass") + try await passData.create(on: app.db) + let pass = try await passData.$pass.get(on: app.db) + let data = try await passesService.generatePassContent(for: pass, on: app.db) + let passURL = FileManager.default.temporaryDirectory.appendingPathComponent("test.pkpass") + try data.write(to: passURL) + let passFolder = try Zip.quickUnzipFile(passURL) + + #expect(FileManager.default.fileExists(atPath: passFolder.path.appending("/signature"))) + + let passJSONData = try String(contentsOfFile: passFolder.path.appending("/pass.json")).data(using: .utf8) + let passJSON = try JSONSerialization.jsonObject(with: passJSONData!) as! [String: Any] + #expect(passJSON["authenticationToken"] as? String == pass.authenticationToken) + let passID = try pass.requireID().uuidString + #expect(passJSON["serialNumber"] as? String == passID) + #expect(passJSON["description"] as? String == passData.title) + + let manifestJSONData = try String(contentsOfFile: passFolder.path.appending("/manifest.json")).data(using: .utf8) + let manifestJSON = try JSONSerialization.jsonObject(with: manifestJSONData!) as! [String: Any] + let iconData = try Data(contentsOf: passFolder.appendingPathComponent("/icon.png")) + let iconHash = Array(Insecure.SHA1.hash(data: iconData)).hex + #expect(manifestJSON["icon.png"] as? String == iconHash) + } } - 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) - } + @Test("Personalizable Pass Apple Wallet API") + func personalizationAPI() async throws { + try await withApp(delegate: delegate) { app, passesService in + let passData = PassData(title: "Personalize") + try await passData.create(on: app.db) + let pass = try await passData.$pass.get(on: app.db) + let personalizationDict = PersonalizationDictionaryDTO( + personalizationToken: "1234567890", + requiredPersonalizationInfo: .init( + emailAddress: "test@example.com", + familyName: "Doe", + fullName: "John Doe", + givenName: "John", + isoCountryCode: "US", + phoneNumber: "1234567890", + postalCode: "12345" + ) + ) - 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 + #expect(res.status == .ok) + #expect(res.body != nil) + #expect(res.headers.contentType?.description == "application/octet-stream") + } ) - ) - - 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) + + let personalizationQuery = try await UserPersonalization.query(on: app.db).all() + #expect(personalizationQuery.count == 1) + let passPersonalizationID = try await Pass.query(on: app.db).first()?._$userPersonalization.get(on: app.db)?.requireID() + #expect(personalizationQuery[0]._$id.value == passPersonalizationID) + #expect(personalizationQuery[0]._$emailAddress.value == personalizationDict.requiredPersonalizationInfo.emailAddress) + #expect(personalizationQuery[0]._$familyName.value == personalizationDict.requiredPersonalizationInfo.familyName) + #expect(personalizationQuery[0]._$fullName.value == personalizationDict.requiredPersonalizationInfo.fullName) + #expect(personalizationQuery[0]._$givenName.value == personalizationDict.requiredPersonalizationInfo.givenName) + #expect(personalizationQuery[0]._$isoCountryCode.value == personalizationDict.requiredPersonalizationInfo.isoCountryCode) + #expect(personalizationQuery[0]._$phoneNumber.value == personalizationDict.requiredPersonalizationInfo.phoneNumber) + #expect(personalizationQuery[0]._$postalCode.value == personalizationDict.requiredPersonalizationInfo.postalCode) + } } - 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 {} + @Test("APNS Client") + func apnsClient() async throws { + try await withApp(delegate: delegate) { app, passesService in + #expect(app.apns.client(.init(string: "passes")) != nil) + + let passData = PassData(title: "Test Pass") + try await passData.create(on: app.db) + let pass = try await passData._$pass.get(on: app.db) + + try await passesService.sendPushNotificationsForPass(id: pass.requireID(), of: pass.passTypeIdentifier, on: app.db) + + let deviceLibraryIdentifier = "abcdefg" + let pushToken = "1234567890" + + try await app.test( + .POST, + "\(passesURI)push/\(pass.passTypeIdentifier)/\(pass.requireID())", + headers: ["X-Secret": "foo"], + afterResponse: { res async throws in + #expect(res.status == .noContent) + } + ) + + try await app.test( + .POST, + "\(passesURI)devices/\(deviceLibraryIdentifier)/registrations/\(pass.passTypeIdentifier)/\(pass.requireID())", + headers: ["Authorization": "ApplePass \(pass.authenticationToken)"], + beforeRequest: { req async throws in + try req.content.encode(RegistrationDTO(pushToken: pushToken)) + }, + afterResponse: { res async throws in + #expect(res.status == .created) + } + ) + + try await app.test( + .POST, + "\(passesURI)push/\(pass.passTypeIdentifier)/\(pass.requireID())", + headers: ["X-Secret": "foo"], + afterResponse: { res async throws in + #expect(res.status == .internalServerError) + } + ) + + // Test `PassDataMiddleware` update method + passData.title = "Test Pass 2" + do { + try await passData.update(on: app.db) + } catch {} + } } } diff --git a/Tests/PassesTests/PassesTests.swift b/Tests/PassesTests/PassesTests.swift index 6720847..7742d25 100644 --- a/Tests/PassesTests/PassesTests.swift +++ b/Tests/PassesTests/PassesTests.swift @@ -1,570 +1,543 @@ -import Fluent -import FluentSQLiteDriver +import FluentKit import PassKit +import Testing import XCTVapor import Zip @testable import Passes -final class PassesTests: XCTestCase { +@Suite("Passes Tests") +struct PassesTests { 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") + + @Test("Pass Generation") + func passGeneration() async throws { + try await withApp(delegate: delegate) { app, passesService in + let passData = PassData(title: "Test Pass") + try await passData.create(on: app.db) + let pass = try await passData.$pass.get(on: app.db) + let data = try await passesService.generatePassContent(for: pass, on: app.db) + let passURL = FileManager.default.temporaryDirectory.appendingPathComponent("test.pkpass") + try data.write(to: passURL) + let passFolder = try Zip.quickUnzipFile(passURL) + + #expect(FileManager.default.fileExists(atPath: passFolder.path.appending("/signature"))) + + let passJSONData = try String(contentsOfFile: passFolder.path.appending("/pass.json")).data(using: .utf8) + let passJSON = try JSONSerialization.jsonObject(with: passJSONData!) as! [String: Any] + #expect(passJSON["authenticationToken"] as? String == pass.authenticationToken) + let passID = try pass.requireID().uuidString + #expect(passJSON["serialNumber"] as? String == passID) + #expect(passJSON["description"] as? String == passData.title) + + let manifestJSONData = try String(contentsOfFile: passFolder.path.appending("/manifest.json")).data(using: .utf8) + let manifestJSON = try JSONSerialization.jsonObject(with: manifestJSONData!) as! [String: Any] + let iconData = try Data(contentsOf: passFolder.appendingPathComponent("/icon.png")) + let iconHash = Array(Insecure.SHA1.hash(data: iconData)).hex + #expect(manifestJSON["icon.png"] as? String == iconHash) + #expect(manifestJSON["logo.png"] != nil) + #expect(manifestJSON["personalizationLogo.png"] != nil) + } } - override func tearDown() async throws { - try await app.autoRevert() - try await self.app.asyncShutdown() - self.app = nil + @Test("Generating Multiple Passes") + func passesGeneration() async throws { + try await withApp(delegate: delegate) { app, passesService in + let passData1 = PassData(title: "Test Pass 1") + try await passData1.create(on: app.db) + let pass1 = try await passData1.$pass.get(on: app.db) + + 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) + #expect(data != nil) + + do { + let data = try await passesService.generatePassesContent(for: [pass1], on: app.db) + Issue.record("Expected error, got \(data)") + } catch let error as PassesError { + #expect(error == .invalidNumberOfPasses) + } + } } - 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) - XCTAssertNotNil(manifestJSON["logo.png"]) - XCTAssertNotNil(manifestJSON["personalizationLogo.png"]) + @Test("Personalizable Passes") + func personalization() async throws { + try await withApp(delegate: delegate) { app, passesService in + let passData = PassData(title: "Personalize") + try await passData.create(on: app.db) + let pass = try await passData.$pass.get(on: app.db) + let 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] + #expect(passJSON["authenticationToken"] as? String == pass.authenticationToken) + let passID = try pass.requireID().uuidString + #expect(passJSON["serialNumber"] as? String == passID) + #expect(passJSON["description"] as? String == passData.title) + + let personalizationJSONData = try String(contentsOfFile: passFolder.path.appending("/personalization.json")).data(using: .utf8) + let personalizationJSON = try JSONSerialization.jsonObject(with: personalizationJSONData!) as! [String: Any] + #expect(personalizationJSON["description"] as? String == "Hello, World!") + + let 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 + #expect(manifestJSON["personalizationLogo.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) + @Test("Getting Pass from Apple Wallet API") + func getPassFromAPI() async throws { + try await withApp(delegate: delegate) { app, passesService in + let passData = PassData(title: "Test Pass") + try await passData.create(on: app.db) + let pass = try await passData.$pass.get(on: app.db) + + 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 + #expect(res.status == .ok) + #expect(res.body != nil) + #expect(res.headers.contentType?.description == "application/vnd.apple.pkpass") + #expect(res.headers.lastModified != nil) + } + ) - let passData2 = PassData(title: "Test Pass 2") - try await passData2.create(on: app.db) - let pass2 = try await passData2._$pass.get(on: app.db) + // 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 + #expect(res.status == .unauthorized) + } + ) - let data = try await passesService.generatePassesContent(for: [pass1, pass2], on: app.db) - XCTAssertNotNil(data) + // 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 + #expect(res.status == .notModified) + } + ) - do { - let data = try await passesService.generatePassesContent(for: [pass1], on: app.db) - XCTFail("Expected error, got \(data)") - } catch let error as PassesError { - XCTAssertEqual(error, .invalidNumberOfPasses) + // 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 + #expect(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 + #expect(res.status == .notFound) + } + ) } } - 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) - } + @Test("Personalizable Pass Apple Wallet API") + func personalizationAPI() async throws { + try await withApp(delegate: delegate) { app, passesService in + let passData = PassData(title: "Personalize") + try await passData.create(on: app.db) + let pass = try await passData.$pass.get(on: app.db) + let personalizationDict = PersonalizationDictionaryDTO( + personalizationToken: "1234567890", + requiredPersonalizationInfo: .init( + emailAddress: "test@example.com", + familyName: "Doe", + fullName: "John Doe", + givenName: "John", + isoCountryCode: "US", + phoneNumber: "1234567890", + postalCode: "12345" + ) + ) - // 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) - } - ) - } + 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 + #expect(res.status == .ok) + #expect(res.body != nil) + #expect(res.headers.contentType?.description == "application/octet-stream") + } + ) - 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" + let personalizationQuery = try await UserPersonalization.query(on: app.db).all() + #expect(personalizationQuery.count == 1) + let passPersonalizationID = try await Pass.query(on: app.db).first()?._$userPersonalization.get(on: app.db)?.requireID() + #expect(personalizationQuery[0]._$id.value == passPersonalizationID) + #expect(personalizationQuery[0]._$emailAddress.value == personalizationDict.requiredPersonalizationInfo.emailAddress) + #expect(personalizationQuery[0]._$familyName.value == personalizationDict.requiredPersonalizationInfo.familyName) + #expect(personalizationQuery[0]._$fullName.value == personalizationDict.requiredPersonalizationInfo.fullName) + #expect(personalizationQuery[0]._$givenName.value == personalizationDict.requiredPersonalizationInfo.givenName) + #expect(personalizationQuery[0]._$isoCountryCode.value == personalizationDict.requiredPersonalizationInfo.isoCountryCode) + #expect(personalizationQuery[0]._$phoneNumber.value == personalizationDict.requiredPersonalizationInfo.phoneNumber) + #expect(personalizationQuery[0]._$postalCode.value == personalizationDict.requiredPersonalizationInfo.postalCode) + + // Test 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 + #expect(res.status == .badRequest) + } ) - ) - - 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) - } - ) + + // 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 + #expect(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" - - try await app.test( - .GET, - "\(passesURI)devices/\(deviceLibraryIdentifier)/registrations/\(pass.passTypeIdentifier)?passesUpdatedSince=0", - afterResponse: { res async throws in - XCTAssertEqual(res.status, .noContent) - } - ) - - try await app.test( - .DELETE, - "\(passesURI)devices/\(deviceLibraryIdentifier)/registrations/\(pass.passTypeIdentifier)/\(pass.requireID())", - headers: ["Authorization": "ApplePass \(pass.authenticationToken)"], - afterResponse: { res async throws in - XCTAssertEqual(res.status, .notFound) - } - ) - - // Test registration without authentication token - try await app.test( - .POST, - "\(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) - } - ) - - // Test registration of a non-existing pass - try await app.test( - .POST, - "\(passesURI)devices/\(deviceLibraryIdentifier)/registrations/\("pass.com.example.NotFound")/\(UUID().uuidString)", - headers: ["Authorization": "ApplePass \(pass.authenticationToken)"], - beforeRequest: { req async throws in - try req.content.encode(RegistrationDTO(pushToken: pushToken)) - }, - afterResponse: { res async throws in - XCTAssertEqual(res.status, .notFound) - } - ) - - // Test call without DTO - try await app.test( - .POST, - "\(passesURI)devices/\(deviceLibraryIdentifier)/registrations/\(pass.passTypeIdentifier)/\(pass.requireID())", - headers: ["Authorization": "ApplePass \(pass.authenticationToken)"], - afterResponse: { res async throws in - XCTAssertEqual(res.status, .badRequest) - } - ) - - // Test call with invalid UUID - try await app.test( - .POST, - "\(passesURI)devices/\(deviceLibraryIdentifier)/registrations/\(pass.passTypeIdentifier)/\("not-a-uuid")", - headers: ["Authorization": "ApplePass \(pass.authenticationToken)"], - beforeRequest: { req async throws in - try req.content.encode(RegistrationDTO(pushToken: pushToken)) - }, - afterResponse: { res async throws in - XCTAssertEqual(res.status, .badRequest) - } - ) - - try await app.test( - .POST, - "\(passesURI)devices/\(deviceLibraryIdentifier)/registrations/\(pass.passTypeIdentifier)/\(pass.requireID())", - 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) - } - ) - - // Test call with invalid UUID - try await app.test( - .GET, - "\(passesURI)push/\(pass.passTypeIdentifier)/\("not-a-uuid")", - headers: ["X-Secret": "foo"], - afterResponse: { res async throws in - XCTAssertEqual(res.status, .badRequest) - } - ) - - // Test call with invalid UUID - try await app.test( - .DELETE, - "\(passesURI)devices/\(deviceLibraryIdentifier)/registrations/\(pass.passTypeIdentifier)/\("not-a-uuid")", - headers: ["Authorization": "ApplePass \(pass.authenticationToken)"], - afterResponse: { res async throws in - XCTAssertEqual(res.status, .badRequest) - } - ) - - try await app.test( - .DELETE, - "\(passesURI)devices/\(deviceLibraryIdentifier)/registrations/\(pass.passTypeIdentifier)/\(pass.requireID())", - headers: ["Authorization": "ApplePass \(pass.authenticationToken)"], - afterResponse: { res async throws in - XCTAssertEqual(res.status, .ok) - } - ) + @Test("Device Registration API") + func apiDeviceRegistration() async throws { + try await withApp(delegate: delegate) { app, passesService in + let passData = PassData(title: "Test Pass") + try await passData.create(on: app.db) + let pass = try await passData.$pass.get(on: app.db) + let deviceLibraryIdentifier = "abcdefg" + let pushToken = "1234567890" + + try await app.test( + .GET, + "\(passesURI)devices/\(deviceLibraryIdentifier)/registrations/\(pass.passTypeIdentifier)?passesUpdatedSince=0", + afterResponse: { res async throws in + #expect(res.status == .noContent) + } + ) + + try await app.test( + .DELETE, + "\(passesURI)devices/\(deviceLibraryIdentifier)/registrations/\(pass.passTypeIdentifier)/\(pass.requireID())", + headers: ["Authorization": "ApplePass \(pass.authenticationToken)"], + afterResponse: { res async throws in + #expect(res.status == .notFound) + } + ) + + // 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 + #expect(res.status == .unauthorized) + } + ) + + // Test registration of a non-existing pass + try await app.test( + .POST, + "\(passesURI)devices/\(deviceLibraryIdentifier)/registrations/\("pass.com.example.NotFound")/\(UUID().uuidString)", + headers: ["Authorization": "ApplePass \(pass.authenticationToken)"], + beforeRequest: { req async throws in + try req.content.encode(RegistrationDTO(pushToken: pushToken)) + }, + afterResponse: { res async throws in + #expect(res.status == .notFound) + } + ) + + // Test call without DTO + try await app.test( + .POST, + "\(passesURI)devices/\(deviceLibraryIdentifier)/registrations/\(pass.passTypeIdentifier)/\(pass.requireID())", + headers: ["Authorization": "ApplePass \(pass.authenticationToken)"], + afterResponse: { res async throws in + #expect(res.status == .badRequest) + } + ) + + // Test call with invalid UUID + try await app.test( + .POST, + "\(passesURI)devices/\(deviceLibraryIdentifier)/registrations/\(pass.passTypeIdentifier)/\("not-a-uuid")", + headers: ["Authorization": "ApplePass \(pass.authenticationToken)"], + beforeRequest: { req async throws in + try req.content.encode(RegistrationDTO(pushToken: pushToken)) + }, + afterResponse: { res async throws in + #expect(res.status == .badRequest) + } + ) + + try await app.test( + .POST, + "\(passesURI)devices/\(deviceLibraryIdentifier)/registrations/\(pass.passTypeIdentifier)/\(pass.requireID())", + headers: ["Authorization": "ApplePass \(pass.authenticationToken)"], + beforeRequest: { req async throws in + try req.content.encode(RegistrationDTO(pushToken: pushToken)) + }, + afterResponse: { res async throws in + #expect(res.status == .created) + } + ) + + // 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 + #expect(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) + #expect(passes.serialNumbers.count == 1) + let passID = try pass.requireID() + #expect(passes.serialNumbers[0] == passID.uuidString) + #expect(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) + #expect(pushTokens.count == 1) + #expect(pushTokens[0] == pushToken) + } + ) + + // Test call with invalid UUID + try await app.test( + .GET, + "\(passesURI)push/\(pass.passTypeIdentifier)/\("not-a-uuid")", + headers: ["X-Secret": "foo"], + afterResponse: { res async throws in + #expect(res.status == .badRequest) + } + ) + + // Test call with invalid UUID + try await app.test( + .DELETE, + "\(passesURI)devices/\(deviceLibraryIdentifier)/registrations/\(pass.passTypeIdentifier)/\("not-a-uuid")", + headers: ["Authorization": "ApplePass \(pass.authenticationToken)"], + afterResponse: { res async throws in + #expect(res.status == .badRequest) + } + ) + + try await app.test( + .DELETE, + "\(passesURI)devices/\(deviceLibraryIdentifier)/registrations/\(pass.passTypeIdentifier)/\(pass.requireID())", + headers: ["Authorization": "ApplePass \(pass.authenticationToken)"], + afterResponse: { res async throws in + #expect(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) - } - ) + @Test("Error Logging") + func errorLog() async throws { + try await withApp(delegate: delegate) { app, passesService in + 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 + #expect(res.status == .ok) + } + ) + + let logs = try await PassesErrorLog.query(on: app.db).all() + #expect(logs.count == 2) + #expect(logs[0].message == log1) + #expect(logs[1]._$message.value == log2) + + // Test call with no DTO + try await app.test( + .POST, + "\(passesURI)log", + afterResponse: { res async throws in + #expect(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 + #expect(res.status == .badRequest) + } + ) + } } - func testAPNSClient() async throws { - XCTAssertNotNil(app.apns.client(.init(string: "passes"))) + @Test("APNS Client") + func apnsClient() async throws { + try await withApp(delegate: delegate) { app, passesService in + #expect(app.apns.client(.init(string: "passes")) != nil) - let passData = PassData(title: "Test Pass") - try await passData.create(on: app.db) - let pass = try await passData._$pass.get(on: app.db) + let passData = PassData(title: "Test Pass") + try await passData.create(on: app.db) + let pass = try await passData._$pass.get(on: app.db) - try await passesService.sendPushNotificationsForPass( - id: pass.requireID(), of: pass.passTypeIdentifier, on: app.db) + try await passesService.sendPushNotificationsForPass(id: pass.requireID(), of: pass.passTypeIdentifier, on: app.db) - let deviceLibraryIdentifier = "abcdefg" - let pushToken = "1234567890" + 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 call with invalid UUID - try await app.test( - .POST, - "\(passesURI)push/\(pass.passTypeIdentifier)/\("not-a-uuid")", - headers: ["X-Secret": "foo"], - afterResponse: { res async throws in - XCTAssertEqual(res.status, .badRequest) + // Test call with incorrect secret + try await app.test( + .POST, + "\(passesURI)push/\(pass.passTypeIdentifier)/\(pass.requireID())", + headers: ["X-Secret": "bar"], + afterResponse: { res async throws in + #expect(res.status == .unauthorized) + } + ) + + try await app.test( + .POST, + "\(passesURI)push/\(pass.passTypeIdentifier)/\(pass.requireID())", + headers: ["X-Secret": "foo"], + afterResponse: { res async throws in + #expect(res.status == .noContent) + } + ) + + try await app.test( + .POST, + "\(passesURI)devices/\(deviceLibraryIdentifier)/registrations/\(pass.passTypeIdentifier)/\(pass.requireID())", + headers: ["Authorization": "ApplePass \(pass.authenticationToken)"], + beforeRequest: { req async throws in + try req.content.encode(RegistrationDTO(pushToken: pushToken)) + }, + afterResponse: { res async throws in + #expect(res.status == .created) + } + ) + + try await app.test( + .POST, + "\(passesURI)push/\(pass.passTypeIdentifier)/\(pass.requireID())", + headers: ["X-Secret": "foo"], + afterResponse: { res async throws in + #expect(res.status == .internalServerError) + } + ) + + // Test call with invalid UUID + try await app.test( + .POST, + "\(passesURI)push/\(pass.passTypeIdentifier)/\("not-a-uuid")", + headers: ["X-Secret": "foo"], + afterResponse: { res async throws in + #expect(res.status == .badRequest) + } + ) + + // Test `PassDataMiddleware` update method + passData.title = "Test Pass 2" + do { + try await passData.update(on: app.db) + } catch let error as HTTPClientError { + #expect(error.self == .remoteConnectionClosed) } - ) - - // 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)") + @Test("PassesError") + func passesError() { + #expect(PassesError.templateNotDirectory.description == "PassesError(errorType: templateNotDirectory)") + #expect(PassesError.pemCertificateMissing.description == "PassesError(errorType: pemCertificateMissing)") + #expect(PassesError.pemPrivateKeyMissing.description == "PassesError(errorType: pemPrivateKeyMissing)") + #expect(PassesError.opensslBinaryMissing.description == "PassesError(errorType: opensslBinaryMissing)") + #expect(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) + @Test("Default PassesDelegate Properties") + func defaultDelegate() async throws { + let defaultDelegate = DefaultPassesDelegate() + #expect(defaultDelegate.wwdrCertificate == "WWDR.pem") + #expect(defaultDelegate.pemCertificate == "passcertificate.pem") + #expect(defaultDelegate.pemPrivateKey == "passkey.pem") + #expect(defaultDelegate.pemPrivateKeyPassword == nil) + #expect(defaultDelegate.sslBinary == URL(fileURLWithPath: "/usr/bin/openssl")) + #expect(!defaultDelegate.generateSignatureFile(in: URL(fileURLWithPath: ""))) + + try await withApp(delegate: delegate) { app, passesService in + let passData = PassData(title: "Test Pass") + try await passData.create(on: app.db) + let pass = try await passData.$pass.get(on: app.db) + let data = try await defaultDelegate.encodePersonalization(for: pass, db: app.db, encoder: JSONEncoder()) + #expect(data == nil) + } } } @@ -573,9 +546,7 @@ final class DefaultPassesDelegate: PassesDelegate { 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 { + 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 deleted file mode 100644 index fef1940..0000000 --- a/Tests/PassesTests/SecretMiddleware.swift +++ /dev/null @@ -1,14 +0,0 @@ -import Vapor - -struct SecretMiddleware: AsyncMiddleware { - let secret: String - - 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/withApp.swift b/Tests/PassesTests/withApp.swift new file mode 100644 index 0000000..6ff4a1c --- /dev/null +++ b/Tests/PassesTests/withApp.swift @@ -0,0 +1,38 @@ +import FluentKit +import FluentSQLiteDriver +import PassKit +import Passes +import Testing +import Vapor +import Zip + +func withApp( + delegate: some PassesDelegate, + _ body: (Application, PassesService) async throws -> Void +) async throws { + let app = try await Application.make(.testing) + + try #require(isLoggingConfigured) + + app.databases.use(.sqlite(.memory), as: .sqlite) + + PassesService.register(migrations: app.migrations) + app.migrations.add(CreatePassData()) + let passesService = try PassesService( + app: app, + delegate: delegate, + pushRoutesMiddleware: SecretMiddleware(secret: "foo"), + logger: app.logger + ) + + app.databases.middleware.use(PassDataMiddleware(service: passesService), on: .sqlite) + + try await app.autoMigrate() + + Zip.addCustomFileExtension("pkpass") + + try await body(app, passesService) + + try await app.autoRevert() + try await app.asyncShutdown() +}