// ===----------------------------------------------------------------------=== // // This source file is part of the ASConnectService open source project // // Copyright (c) 2026 Röck+Cöde VoF. and the ASConnectService project authors // Licensed under Apache license v2.0 // // See LICENSE for license information // See CONTRIBUTORS for the list of ASConnectService project authors // // SPDX-License-Identifier: Apache-2.0 // // ===----------------------------------------------------------------------=== #if canImport(CryptoKit) import CryptoKit import Foundation import Testing // A non-testable import ensures that the provider can be created from outside the module. import ASConnectService @Suite("APIKeyTokenProvider") struct APIKeyTokenProviderTests { // MARK: Properties private let key = P256.Signing.PrivateKey() private let clock = TestClock() // MARK: Tests @Test("Signs a token for a team key") func signsTeamKeyToken() async throws { let token = try await makeProvider(issuerID: .Sample.issuerID).token() let (header, payload) = try decode(token) #expect(header == ["alg": "ES256", "kid": String.Sample.keyID, "typ": "JWT"]) #expect(payload["iss"] as? String == .Sample.issuerID) #expect(payload["sub"] == nil) #expect(payload["aud"] as? String == "appstoreconnect-v1") #expect(payload["iat"] as? Int == Int(clock.date.timeIntervalSince1970)) #expect(payload["exp"] as? Int == Int(clock.date.timeIntervalSince1970) + 20 * 60) } @Test("Signs a token for an individual key") func signsIndividualKeyToken() async throws { let token = try await makeProvider(issuerID: nil).token() let (_, payload) = try decode(token) #expect(payload["iss"] == nil) #expect(payload["sub"] as? String == "user") } @Test("Signs the token with the private key") func signsWithPrivateKey() async throws { let token = try await makeProvider(issuerID: .Sample.issuerID).token() let parts = token.split(separator: ".").map(String.init) let signature = try P256.Signing.ECDSASignature(rawRepresentation: try #require(Data(base64URL: parts[2]))) #expect(key.publicKey.isValidSignature(signature, for: Data("\(parts[0]).\(parts[1])".utf8))) } @Test("Returns the current token until it is about to expire") func returnsCurrentToken() async throws { let provider = try makeProvider(issuerID: .Sample.issuerID) let token = try await provider.token() clock.advance(by: 18 * 60) #expect(try await provider.token() == token) } @Test("Signs a new token when the current one is about to expire") func signsNewToken() async throws { let provider = try makeProvider(issuerID: .Sample.issuerID) let token = try await provider.token() clock.advance(by: 19 * 60 + 1) #expect(try await provider.token() != token) } @Test("Throws when the private key is not valid") func throwsWithInvalidPrivateKey() { #expect(throws: (any Error).self) { try APIKeyTokenProvider( issuerID: .Sample.issuerID, keyID: .Sample.keyID, privateKey: "not-a-private-key" ) } } } // MARK: - Helpers private extension APIKeyTokenProviderTests { func makeProvider(issuerID: String?) throws -> APIKeyTokenProvider { try APIKeyTokenProvider( issuerID: issuerID, keyID: .Sample.keyID, privateKey: key.pemRepresentation, now: { [clock] in clock.date } ) } func decode(_ token: String) throws -> (header: [String: String], payload: [String: Any]) { let parts = token.split(separator: ".").map(String.init) try #require(parts.count == 3) let header = try JSONDecoder().decode([String: String].self, from: try #require(Data(base64URL: parts[0]))) let payloadData = try #require(Data(base64URL: parts[1])) let payload = try #require(try JSONSerialization.jsonObject(with: payloadData) as? [String: Any]) return (header, payload) } } /// A clock whose date only changes when it is advanced. private final class TestClock: @unchecked Sendable { private(set) var date = Date(timeIntervalSince1970: 1_800_000_000) func advance(by interval: TimeInterval) { date = date.addingTimeInterval(interval) } } // MARK: - Data+Base64URL private extension Data { init?(base64URL: String) { var base64 = base64URL .replacingOccurrences(of: "-", with: "+") .replacingOccurrences(of: "_", with: "/") base64 += String(repeating: "=", count: (4 - base64.count % 4) % 4) self.init(base64Encoded: base64) } } // MARK: - String+Samples private extension String { enum Sample { static let issuerID: String = "57246542-96fe-1a63-e053-0824d011072a" static let keyID: String = "2X9R4HXF34" } } #endif