@@ -21,6 +21,24 @@ import NIOCore
2121import NIOHTTP1
2222import AsyncHTTPClient
2323
24+ /// Builds a fake (unsigned) provider authentication token in the shape the mock server expects:
25+ /// `base64url(header).base64url(payload).base64url(signature)`. The server never verifies the
26+ /// ES256 signature (it has no public key), so any bytes work there.
27+ private func makeTestJWT( iss: String = " team " , kid: String = " key " , iatOffset: TimeInterval = 0 ) -> String {
28+ func base64url( _ string: String ) -> String {
29+ Data ( string. utf8) . base64EncodedString ( )
30+ . replacingOccurrences ( of: " + " , with: " - " )
31+ . replacingOccurrences ( of: " / " , with: " _ " )
32+ . replacingOccurrences ( of: " = " , with: " " )
33+ }
34+
35+ let header = " { \" alg \" : \" ES256 \" , \" typ \" : \" JWT \" , \" kid \" : \" \( kid) \" } "
36+ let iat = Int ( Date ( ) . timeIntervalSince1970 + iatOffset)
37+ let payload = " { \" iss \" : \" \( iss) \" , \" iat \" : \( iat) } "
38+
39+ return " \( base64url ( header) ) . \( base64url ( payload) ) . \( base64url ( " sig " ) ) "
40+ }
41+
2442final class APNSTestServerValidationTests : XCTestCase {
2543 var server : APNSTestServer !
2644 var httpClient : HTTPClient !
@@ -270,6 +288,7 @@ final class APNSTestServerValidationTests: XCTestCase {
270288 request. headers. add ( name: " apns-topic " , value: " com.example.app " )
271289 request. headers. add ( name: " apns-push-type " , value: " alert " )
272290 request. headers. add ( name: " content-type " , value: " application/json " )
291+ request. headers. add ( name: " authorization " , value: " bearer \( makeTestJWT ( ) ) " )
273292 request. body = . bytes( ByteBuffer ( string: " {} " ) )
274293
275294 let response = try await httpClient. execute ( request, timeout: . seconds( 30 ) )
@@ -285,6 +304,7 @@ final class APNSTestServerValidationTests: XCTestCase {
285304 func testMethodNotAllowed_GET( ) async throws {
286305 var request = HTTPClientRequest ( url: " http://127.0.0.1: \( server. port) /3/device/0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef " )
287306 request. method = . GET
307+ request. headers. add ( name: " authorization " , value: " bearer \( makeTestJWT ( ) ) " )
288308
289309 let response = try await httpClient. execute ( request, timeout: . seconds( 30 ) )
290310 let bodyBuffer = try await response. body. collect ( upTo: 1024 * 1024 )
@@ -297,6 +317,7 @@ final class APNSTestServerValidationTests: XCTestCase {
297317 func testMethodNotAllowed_PUT( ) async throws {
298318 var request = HTTPClientRequest ( url: " http://127.0.0.1: \( server. port) /3/device/0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef " )
299319 request. method = . PUT
320+ request. headers. add ( name: " authorization " , value: " bearer \( makeTestJWT ( ) ) " )
300321
301322 let response = try await httpClient. execute ( request, timeout: . seconds( 30 ) )
302323 let bodyBuffer = try await response. body. collect ( upTo: 1024 * 1024 )
@@ -309,6 +330,7 @@ final class APNSTestServerValidationTests: XCTestCase {
309330 func testMethodNotAllowed_DELETE( ) async throws {
310331 var request = HTTPClientRequest ( url: " http://127.0.0.1: \( server. port) /3/device/0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef " )
311332 request. method = . DELETE
333+ request. headers. add ( name: " authorization " , value: " bearer \( makeTestJWT ( ) ) " )
312334
313335 let response = try await httpClient. execute ( request, timeout: . seconds( 30 ) )
314336 let bodyBuffer = try await response. body. collect ( upTo: 1024 * 1024 )
@@ -348,32 +370,44 @@ final class APNSTestServerValidationTests: XCTestCase {
348370 let bodyBuffer = try await response. body. collect ( upTo: 1024 * 1024 )
349371 let bodyString = bodyBuffer. getString ( at: 0 , length: bodyBuffer. readableBytes) ?? " "
350372
351- // Wrong version falls through to generic NotFound (not /3/...)
373+ // Wrong version falls through to the generic bad-path case (not /3/...)
352374 XCTAssertEqual ( response. status, . notFound)
353- XCTAssertTrue ( bodyString. contains ( " NotFound " ) )
375+ XCTAssertTrue ( bodyString. contains ( " BadPath " ) )
354376 }
355377
356378 // MARK: - Valid Push Types Test
357379
358380 func testValidPushTypes( ) async throws {
359- let validTypes = [ " alert " , " background " , " location " , " voip " , " complication " ,
360- " fileprovider " , " mdm " , " liveactivity " , " pushtotalk " , " widgets " ]
361-
362- for pushType in validTypes {
381+ // Each push type that enforces a topic suffix must be sent with a matching topic.
382+ let topicByPushType : [ String : String ] = [
383+ " alert " : " com.example.app " ,
384+ " background " : " com.example.app " ,
385+ " location " : " com.example.app.location-query " ,
386+ " voip " : " com.example.app.voip " ,
387+ " complication " : " com.example.app.complication " ,
388+ " fileprovider " : " com.example.app.pushkit.fileprovider " ,
389+ " mdm " : " com.example.app " ,
390+ " liveactivity " : " com.example.app.push-type.liveactivity " ,
391+ " pushtotalk " : " com.example.app.voip-ptt " ,
392+ " widgets " : " com.example.app.push-type.widgets " ,
393+ " controls " : " com.example.app.push-type.controls " ,
394+ ]
395+
396+ for (pushType, topic) in topicByPushType {
363397 let response = try await sendRawNotification (
364398 deviceToken: " 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef " ,
365- topic: " com.example.app " ,
399+ topic: topic ,
366400 pushType: pushType
367401 )
368402
369- XCTAssertEqual ( response. status, . ok, " Push type ' \( pushType) ' should be valid " )
403+ XCTAssertEqual ( response. status, . ok, " Push type ' \( pushType) ' should be valid, got: \( response . body ) " )
370404 }
371405 }
372406
373407 // MARK: - Valid Priorities Test
374408
375409 func testValidPriorities( ) async throws {
376- for priority in [ " 5 " , " 10 " ] {
410+ for priority in [ " 1 " , " 5 " , " 10 " ] {
377411 let response = try await sendRawNotification (
378412 deviceToken: " 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef " ,
379413 topic: " com.example.app " ,
@@ -383,6 +417,183 @@ final class APNSTestServerValidationTests: XCTestCase {
383417
384418 XCTAssertEqual ( response. status, . ok, " Priority ' \( priority) ' should be valid " )
385419 }
420+
421+ // `background` pushes must pair with priority 5 — priority 10 is rejected (BadPriority).
422+ let backgroundResponse = try await sendRawNotification (
423+ deviceToken: " 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef " ,
424+ topic: " com.example.app " ,
425+ pushType: " background " ,
426+ priority: " 5 "
427+ )
428+ XCTAssertEqual ( backgroundResponse. status, . ok)
429+ }
430+
431+ // MARK: - Authorization Tests
432+
433+ func testMissingAuthorization_returnsMissingProviderToken( ) async throws {
434+ let response = try await sendRawNotification (
435+ deviceToken: " 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef " ,
436+ topic: " com.example.app " ,
437+ pushType: " alert " ,
438+ authorization: nil
439+ )
440+
441+ XCTAssertEqual ( response. status, . forbidden)
442+ XCTAssertTrue ( response. body. contains ( " MissingProviderToken " ) )
443+ }
444+
445+ func testGarbageAuthorization_returnsInvalidProviderToken( ) async throws {
446+ let response = try await sendRawNotification (
447+ deviceToken: " 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef " ,
448+ topic: " com.example.app " ,
449+ pushType: " alert " ,
450+ authorization: " bearer not-a-real-token "
451+ )
452+
453+ XCTAssertEqual ( response. status, . forbidden)
454+ XCTAssertTrue ( response. body. contains ( " InvalidProviderToken " ) )
455+ }
456+
457+ func testStringIatToken_returnsInvalidProviderToken( ) async throws {
458+ // Pins the library-side fix: `iat` must be a JSON number, not a string (RFC 7519 NumericDate).
459+ func base64url( _ string: String ) -> String {
460+ Data ( string. utf8) . base64EncodedString ( )
461+ . replacingOccurrences ( of: " + " , with: " - " )
462+ . replacingOccurrences ( of: " / " , with: " _ " )
463+ . replacingOccurrences ( of: " = " , with: " " )
464+ }
465+ let header = base64url ( " { \" alg \" : \" ES256 \" , \" typ \" : \" JWT \" , \" kid \" : \" key \" } " )
466+ let payload = base64url ( " { \" iss \" : \" team \" , \" iat \" : \" \( Int ( Date ( ) . timeIntervalSince1970) ) \" } " )
467+ let token = " \( header) . \( payload) . \( base64url ( " sig " ) ) "
468+
469+ let response = try await sendRawNotification (
470+ deviceToken: " 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef " ,
471+ topic: " com.example.app " ,
472+ pushType: " alert " ,
473+ authorization: " bearer \( token) "
474+ )
475+
476+ XCTAssertEqual ( response. status, . forbidden)
477+ XCTAssertTrue ( response. body. contains ( " InvalidProviderToken " ) )
478+ }
479+
480+ func testExpiredIat_returnsExpiredProviderToken( ) async throws {
481+ let response = try await sendRawNotification (
482+ deviceToken: " 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef " ,
483+ topic: " com.example.app " ,
484+ pushType: " alert " ,
485+ authorization: " bearer \( makeTestJWT ( iatOffset: - 3700 ) ) "
486+ )
487+
488+ XCTAssertEqual ( response. status, . forbidden)
489+ XCTAssertTrue ( response. body. contains ( " ExpiredProviderToken " ) )
490+ }
491+
492+ // MARK: - Topic Suffix Enforcement
493+
494+ func testWrongTopicSuffix_voip_returnsBadTopic( ) async throws {
495+ let response = try await sendRawNotification (
496+ deviceToken: " 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef " ,
497+ topic: " com.example.app " , // missing the required `.voip` suffix
498+ pushType: " voip "
499+ )
500+
501+ XCTAssertEqual ( response. status, . badRequest)
502+ XCTAssertTrue ( response. body. contains ( " BadTopic " ) )
503+ }
504+
505+ // MARK: - Background Priority
506+
507+ func testBackgroundWithPriority10_returnsBadPriority( ) async throws {
508+ let response = try await sendRawNotification (
509+ deviceToken: " 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef " ,
510+ topic: " com.example.app " ,
511+ pushType: " background " ,
512+ priority: " 10 "
513+ )
514+
515+ XCTAssertEqual ( response. status, . badRequest)
516+ XCTAssertTrue ( response. body. contains ( " BadPriority " ) )
517+ }
518+
519+ // MARK: - Per-Push-Type Payload Limits
520+
521+ func testVoIPPayload_5000Bytes_accepted( ) async throws {
522+ let overhead = " { \" data \" : \" \" } " . utf8. count
523+ let payload = " { \" data \" : \" " + String( repeating: " x " , count: 5000 - overhead) + " \" } "
524+ XCTAssertEqual ( payload. utf8. count, 5000 )
525+
526+ let response = try await sendRawNotification (
527+ deviceToken: " 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef " ,
528+ topic: " com.example.app.voip " ,
529+ pushType: " voip " ,
530+ body: payload
531+ )
532+
533+ XCTAssertEqual ( response. status, . ok, " got: \( response. body) " )
534+ }
535+
536+ func testVoIPPayload_5200Bytes_rejected( ) async throws {
537+ let overhead = " { \" data \" : \" \" } " . utf8. count
538+ let payload = " { \" data \" : \" " + String( repeating: " x " , count: 5200 - overhead) + " \" } "
539+ XCTAssertEqual ( payload. utf8. count, 5200 )
540+
541+ let response = try await sendRawNotification (
542+ deviceToken: " 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef " ,
543+ topic: " com.example.app.voip " ,
544+ pushType: " voip " ,
545+ body: payload
546+ )
547+
548+ XCTAssertEqual ( response. status, . badRequest)
549+ XCTAssertTrue ( response. body. contains ( " PayloadTooLarge " ) )
550+ }
551+
552+ // MARK: - Malformed apns-id
553+
554+ func testMalformedAPNSID_returnsBadMessageId( ) async throws {
555+ var request = HTTPClientRequest ( url: " http://127.0.0.1: \( server. port) /3/device/0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef " )
556+ request. method = . POST
557+ request. headers. add ( name: " apns-topic " , value: " com.example.app " )
558+ request. headers. add ( name: " apns-push-type " , value: " alert " )
559+ request. headers. add ( name: " apns-id " , value: " not-a-uuid " )
560+ request. headers. add ( name: " authorization " , value: " bearer \( makeTestJWT ( ) ) " )
561+ request. headers. add ( name: " content-type " , value: " application/json " )
562+ request. body = . bytes( ByteBuffer ( string: " {} " ) )
563+
564+ let response = try await httpClient. execute ( request, timeout: . seconds( 30 ) )
565+ let bodyBuffer = try await response. body. collect ( upTo: 1024 * 1024 )
566+ let bodyString = bodyBuffer. getString ( at: 0 , length: bodyBuffer. readableBytes) ?? " "
567+
568+ XCTAssertEqual ( response. status, . badRequest)
569+ XCTAssertTrue ( bodyString. contains ( " BadMessageId " ) )
570+ }
571+
572+ // MARK: - Response Override
573+
574+ func testResponseOverride_forcesStatus( ) async throws {
575+ server. setResponseOverride ( . init( status: 500 , body: " { \" reason \" : \" InternalServerError \" } " ) )
576+
577+ let response = try await sendRawNotification (
578+ deviceToken: " 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef " ,
579+ topic: " com.example.app " ,
580+ pushType: " alert "
581+ )
582+
583+ XCTAssertEqual ( response. status, . internalServerError)
584+ XCTAssertTrue ( response. body. contains ( " InternalServerError " ) )
585+ XCTAssertEqual (
586+ server. getSentNotifications ( ) . count, 0 ,
587+ " An overridden response must not be recorded as a sent notification "
588+ )
589+
590+ // The override is consumed after a single use — the next request goes through normal handling.
591+ let secondResponse = try await sendRawNotification (
592+ deviceToken: " 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef " ,
593+ topic: " com.example.app " ,
594+ pushType: " alert "
595+ )
596+ XCTAssertEqual ( secondResponse. status, . ok)
386597 }
387598
388599 // MARK: - Helper Methods
@@ -394,7 +605,8 @@ final class APNSTestServerValidationTests: XCTestCase {
394605 priority: String ? = nil ,
395606 expiration: String ? = nil ,
396607 collapseID: String ? = nil ,
397- body: String ? = " {} "
608+ body: String ? = " {} " ,
609+ authorization: String ? = " bearer \( makeTestJWT ( ) ) "
398610 ) async throws -> ( status: HTTPResponseStatus , body: String ) {
399611 var request = HTTPClientRequest ( url: " http://127.0.0.1: \( server. port) /3/device/ \( deviceToken) " )
400612 request. method = . POST
@@ -414,6 +626,9 @@ final class APNSTestServerValidationTests: XCTestCase {
414626 if let collapseID = collapseID {
415627 request. headers. add ( name: " apns-collapse-id " , value: collapseID)
416628 }
629+ if let authorization = authorization {
630+ request. headers. add ( name: " authorization " , value: authorization)
631+ }
417632
418633 request. headers. add ( name: " content-type " , value: " application/json " )
419634
0 commit comments