diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..46e3d71 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,51 @@ +name: Release + +on: + push: + tags: ['v*'] + +permissions: + contents: write + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +jobs: + release: + name: Build & Publish Release + runs-on: macos-15 + steps: + - uses: actions/checkout@v5 + + - name: Select Xcode + run: sudo xcode-select -s /Applications/Xcode_26.2.app + + - name: Build universal binary (arm64 + x86_64) + run: swift build -c release --arch arm64 --arch x86_64 + + - name: Package binaries + id: pkg + run: | + BIN_DIR=$(swift build -c release --arch arm64 --arch x86_64 --show-bin-path) + file "$BIN_DIR/asc-mcp" + + mkdir -p stage && cp "$BIN_DIR/asc-mcp" stage/asc-mcp + ARM="asc-mcp-${GITHUB_REF_NAME}-darwin-arm64.tar.gz" + X64="asc-mcp-${GITHUB_REF_NAME}-darwin-x86_64.tar.gz" + tar -czf "$ARM" -C stage asc-mcp + cp "$ARM" "$X64" + shasum -a 256 "$ARM" > "${ARM}.sha256" + shasum -a 256 "$X64" > "${X64}.sha256" + echo "arm=$ARM" >> "$GITHUB_OUTPUT" + echo "x64=$X64" >> "$GITHUB_OUTPUT" + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + generate_release_notes: true + files: | + ${{ steps.pkg.outputs.arm }} + ${{ steps.pkg.outputs.arm }}.sha256 + ${{ steps.pkg.outputs.x64 }} + ${{ steps.pkg.outputs.x64 }}.sha256 diff --git a/CLAUDE.md b/CLAUDE.md index 9e005ec..62ff4af 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -41,8 +41,9 @@ Three config methods (checked in priority order): 4. `ASC_COMPANY_1_KEY_ID`, `ASC_COMPANY_2_KEY_ID`... (multi-company env vars) 5. `ASC_KEY_ID` + `ASC_ISSUER_ID` + `ASC_PRIVATE_KEY_PATH` (single company env vars) -Each company needs: `keyID`, `issuerID`, `privateKeyPath` (path to `.p8` file). +Each company needs: `keyID`, `privateKeyPath` (or `privateKeyContent`); `issuerID` is required for Team Keys and omitted for Individual Keys. +**Individual API Keys**: Omit `ASC_ISSUER_ID` / `ASC_COMPANY_N_ISSUER_ID` (env) or `issuer_id` (JSON) to configure an Individual API Key. Such companies have `Company.issuerID == nil` and `Company.isIndividualKey == true`. JWTService emits `sub: "user"` instead of `iss` per Apple's spec. Individual keys cannot access Provisioning, Sales/Finance, or notaryTool endpoints — calls will fail with 403 at the API level (no pre-validation). ## Architecture ### Core Components diff --git a/README.md b/README.md index 5157515..b61cea6 100644 --- a/README.md +++ b/README.md @@ -204,6 +204,54 @@ The server resolves configuration in this order: 5. `ASC_COMPANY_1_KEY_ID` ... (multi-company env vars) 6. `ASC_KEY_ID` + `ASC_ISSUER_ID` (single-company env vars) +### Individual API Keys + +App Store Connect supports two types of API keys: + +- **Team Keys** — created under *Users and Access → Integrations → Team Keys* (requires Admin role). Access scoped to the team with the key's assigned role. +- **Individual Keys** — created under *user profile → Individual API Key* (available to any team member). Access scoped to the user's own role. + +Individual Keys use a different JWT payload per [Apple's specification](https://developer.apple.com/documentation/appstoreconnectapi/generating-tokens-for-api-requests): they send `sub: "user"` instead of `iss: `. This server handles the difference transparently — just omit the issuer ID in your configuration. + +#### Configure via environment variables + +```bash +# Single Individual Key (no ASC_ISSUER_ID) +export ASC_KEY_ID=ABC123DEF4 +export ASC_PRIVATE_KEY_PATH=/path/to/AuthKey_ABC123DEF4.p8 + +# Multi-company: Team + Individual +export ASC_COMPANY_1_KEY_ID=TEAM_KEY_ID +export ASC_COMPANY_1_ISSUER_ID=57246542-xxxx-xxxx-xxxx-xxxxxxxxxxxx +export ASC_COMPANY_1_KEY_PATH=/path/to/TeamKey.p8 + +export ASC_COMPANY_2_KEY_ID=INDIVIDUAL_KEY_ID +# No ASC_COMPANY_2_ISSUER_ID → treated as Individual Key +export ASC_COMPANY_2_KEY_PATH=/path/to/IndividualKey.p8 +``` + +#### Configure via `companies.json` + +Simply omit the `issuer_id` field: + +```json +{ + "id": "my-individual", + "name": "My Personal Account", + "key_id": "ABC123DEF4", + "key_path": "/path/to/AuthKey_ABC123DEF4.p8" +} +``` + +#### Limitations of Individual Keys + +Per Apple's documentation, Individual Keys **cannot** access: + +- Provisioning endpoints (Bundle IDs, Certificates, Profiles, Devices) → `provisioning_*` tools +- Sales and Finance reports → `analytics_*` tools that fetch sales/finance data +- `notaryTool` endpoints + +Calls to these endpoints with an Individual Key will fail with HTTP 403 from Apple. Use a Team Key for workflows that require them. This server does not pre-validate — errors surface from the App Store Connect API directly. ### 3. MCP Host Configuration
diff --git a/Sources/asc-mcp/EntryPoint.swift b/Sources/asc-mcp/EntryPoint.swift index 9010baf..bc9b50f 100644 --- a/Sources/asc-mcp/EntryPoint.swift +++ b/Sources/asc-mcp/EntryPoint.swift @@ -8,12 +8,16 @@ struct ASCMCPApp { if CommandLine.arguments.contains("--test") { print("Test mode activated", to: &standardError) - if CommandLine.arguments.contains("--test-metadata") { - try await testAppMetadata() - } else if CommandLine.arguments.contains("--test-switch") { - try await testCompanySwitching() - } else { - try await testCompanySwitching() + do { + if CommandLine.arguments.contains("--test-metadata") { + try await testAppMetadata() + } else if CommandLine.arguments.contains("--test-switch") { + try await testCompanySwitching() + } else { + try await testCompanySwitching() + } + } catch { + print("\n⚠️ Test mode exited early: \(error.localizedDescription)", to: &standardError) } return } diff --git a/Sources/asc-mcp/Models/Company.swift b/Sources/asc-mcp/Models/Company.swift index 602418a..9c47632 100644 --- a/Sources/asc-mcp/Models/Company.swift +++ b/Sources/asc-mcp/Models/Company.swift @@ -5,13 +5,17 @@ public struct Company: Codable, Sendable, Identifiable, Equatable { public let id: String public let name: String public let keyID: String - public let issuerID: String + public let issuerID: String? public let privateKeyPath: String /// Private key content (PEM string). If set, takes priority over `privateKeyPath`. public let privateKeyContent: String? /// Vendor number for sales/financial reports (found in ASC Sales and Trends) public let vendorNumber: String? + /// True if this company uses an Individual API Key (no issuer ID). + /// Individual keys cannot access Provisioning, Sales/Finance, or notarytool endpoints. + public var isIndividualKey: Bool { issuerID == nil } + enum CodingKeys: String, CodingKey { case id case name @@ -27,7 +31,7 @@ public struct Company: Codable, Sendable, Identifiable, Equatable { id = try container.decode(String.self, forKey: .id) name = try container.decode(String.self, forKey: .name) keyID = try container.decode(String.self, forKey: .keyID) - issuerID = try container.decode(String.self, forKey: .issuerID) + issuerID = try container.decodeIfPresent(String.self, forKey: .issuerID) privateKeyPath = try container.decodeIfPresent(String.self, forKey: .privateKeyPath) ?? "" privateKeyContent = try container.decodeIfPresent(String.self, forKey: .privateKeyContent) vendorNumber = try container.decodeIfPresent(String.self, forKey: .vendorNumber) @@ -35,7 +39,7 @@ public struct Company: Codable, Sendable, Identifiable, Equatable { public init( id: String, name: String, - keyID: String, issuerID: String, + keyID: String, issuerID: String? = nil, privateKeyPath: String = "", privateKeyContent: String? = nil, vendorNumber: String? = nil @@ -48,6 +52,17 @@ public struct Company: Codable, Sendable, Identifiable, Equatable { self.privateKeyContent = privateKeyContent self.vendorNumber = vendorNumber } + + public func encode(to encoder: Encoder) throws { + var c = encoder.container(keyedBy: CodingKeys.self) + try c.encode(id, forKey: .id) + try c.encode(name, forKey: .name) + try c.encode(keyID, forKey: .keyID) + try c.encodeIfPresent(issuerID, forKey: .issuerID) + try c.encode(privateKeyPath, forKey: .privateKeyPath) + try c.encodeIfPresent(privateKeyContent, forKey: .privateKeyContent) + try c.encodeIfPresent(vendorNumber, forKey: .vendorNumber) + } } /// Container for all companies diff --git a/Sources/asc-mcp/Services/CompaniesManager.swift b/Sources/asc-mcp/Services/CompaniesManager.swift index 9a64251..ef0a67d 100644 --- a/Sources/asc-mcp/Services/CompaniesManager.swift +++ b/Sources/asc-mcp/Services/CompaniesManager.swift @@ -61,9 +61,9 @@ public actor CompaniesManager { } print("You can configure asc-mcp using:", to: &standardError) print(" 1. Environment variables (single company):", to: &standardError) - print(" ASC_KEY_ID, ASC_ISSUER_ID, ASC_PRIVATE_KEY_PATH (or ASC_PRIVATE_KEY)", to: &standardError) + print(" ASC_KEY_ID, ASC_ISSUER_ID (optional for Individual API Keys), ASC_PRIVATE_KEY_PATH (or ASC_PRIVATE_KEY)", to: &standardError) print(" 2. Environment variables (multiple companies):", to: &standardError) - print(" ASC_COMPANY_1_KEY_ID, ASC_COMPANY_1_ISSUER_ID, ASC_COMPANY_1_KEY_PATH, ...", to: &standardError) + print(" ASC_COMPANY_1_KEY_ID, ASC_COMPANY_1_ISSUER_ID (optional for Individual API Keys), ASC_COMPANY_1_KEY_PATH, ...", to: &standardError) print(" 3. Config file: --companies /path/to/companies.json", to: &standardError) print(" 4. Environment: ASC_MCP_COMPANIES=/path/to/companies.json", to: &standardError) print(" 5. Default path: ~/.config/asc-mcp/companies.json", to: &standardError) @@ -107,14 +107,14 @@ public actor CompaniesManager { /// 1. Multi-company: ASC_COMPANY_1_KEY_ID, ASC_COMPANY_2_KEY_ID, ... /// 2. Single-company: ASC_KEY_ID, ASC_ISSUER_ID /// - Returns: CompaniesConfig if env vars found, nil otherwise - private static func loadFromEnvironment() -> CompaniesConfig? { - let env = ProcessInfo.processInfo.environment - + static func loadFromEnvironment( + env: [String: String] = ProcessInfo.processInfo.environment + ) -> CompaniesConfig? { // Multi-company mode: ASC_COMPANY_N_KEY_ID var companies: [Company] = [] var index = 1 - while let keyID = env["ASC_COMPANY_\(index)_KEY_ID"], - let issuerID = env["ASC_COMPANY_\(index)_ISSUER_ID"] { + while let keyID = env["ASC_COMPANY_\(index)_KEY_ID"] { + let issuerID = env["ASC_COMPANY_\(index)_ISSUER_ID"] let keyPath = env["ASC_COMPANY_\(index)_KEY_PATH"] ?? "" let keyContent = env["ASC_COMPANY_\(index)_KEY"] let name = env["ASC_COMPANY_\(index)_NAME"] ?? "Company \(index)" @@ -138,9 +138,9 @@ public actor CompaniesManager { return CompaniesConfig(companies: companies) } - // Single-company mode: ASC_KEY_ID, ASC_ISSUER_ID - guard let keyID = env["ASC_KEY_ID"], - let issuerID = env["ASC_ISSUER_ID"] else { return nil } + // Single-company mode: ASC_KEY_ID, optional ASC_ISSUER_ID + guard let keyID = env["ASC_KEY_ID"] else { return nil } + let issuerID = env["ASC_ISSUER_ID"] let keyPath = env["ASC_PRIVATE_KEY_PATH"] ?? "" let keyContent = env["ASC_PRIVATE_KEY"] guard !keyPath.isEmpty || keyContent != nil else { return nil } @@ -209,4 +209,3 @@ public enum CompanyError: LocalizedError, Sendable { } } } - diff --git a/Sources/asc-mcp/Services/JWTService.swift b/Sources/asc-mcp/Services/JWTService.swift index bf99144..4b08238 100644 --- a/Sources/asc-mcp/Services/JWTService.swift +++ b/Sources/asc-mcp/Services/JWTService.swift @@ -15,6 +15,7 @@ private struct DecodedJWTPayload: Decodable { let exp: Int // Unix timestamp let iat: Int? let iss: String? + let sub: String? } /// JWT token service for App Store Connect API @@ -140,12 +141,17 @@ public actor JWTService { ) // Payload - let payload = JWTPayload( - iss: company.issuerID, - iat: Int(now.timeIntervalSince1970), - exp: Int(expiration.timeIntervalSince1970), - aud: "appstoreconnect-v1" - ) + let iat = Int(now.timeIntervalSince1970) + let exp = Int(expiration.timeIntervalSince1970) + let payload: JWTPayload + if let issuerID = company.issuerID { + // Team Key: use iss claim + payload = JWTPayload(iss: issuerID, sub: nil, iat: iat, exp: exp, aud: "appstoreconnect-v1") + } else { + // Individual Key: use sub="user" per Apple spec + // https://developer.apple.com/documentation/appstoreconnectapi/generating-tokens-for-api-requests + payload = JWTPayload(iss: nil, sub: "user", iat: iat, exp: exp, aud: "appstoreconnect-v1") + } // Encode header and payload as Base64URL let headerData = try JSONEncoder().encode(header) @@ -215,10 +221,22 @@ private struct JWTHeader: Codable { } private struct JWTPayload: Codable { - let iss: String + let iss: String? + let sub: String? let iat: Int let exp: Int let aud: String + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encodeIfPresent(iss, forKey: .iss) + try container.encodeIfPresent(sub, forKey: .sub) + try container.encode(iat, forKey: .iat) + try container.encode(exp, forKey: .exp) + try container.encode(aud, forKey: .aud) + } + + private enum CodingKeys: String, CodingKey { case iss, sub, iat, exp, aud } } // MARK: - Base64URL Extension diff --git a/Sources/asc-mcp/Tests/TestCompanySwitching.swift b/Sources/asc-mcp/Tests/TestCompanySwitching.swift index 848ad48..63189be 100644 --- a/Sources/asc-mcp/Tests/TestCompanySwitching.swift +++ b/Sources/asc-mcp/Tests/TestCompanySwitching.swift @@ -6,7 +6,13 @@ public func testCompanySwitching() async throws { print("\n🧪 TEST: Company switching\n", to: &standardError) // 1. Create workers using factory method - let companiesManager = try CompaniesManager() + let companiesManager: CompaniesManager + do { + companiesManager = try CompaniesManager() + } catch { + print("⚠️ Skipping company switching test: \(error.localizedDescription)", to: &standardError) + return + } let companiesWorker = CompaniesWorker(manager: companiesManager) let _ = try await WorkerManager.createForProduction(companiesWorker: companiesWorker) @@ -26,11 +32,15 @@ public func testCompanySwitching() async throws { let company1 = companies[0] print("✅ Active company: \(company1.name)", to: &standardError) print(" Key ID: \(company1.keyID)", to: &standardError) - print(" Issuer ID: \(company1.issuerID)", to: &standardError) + if let issuerID = company1.issuerID { + print(" Key Type: Team Key (Issuer: ****\(issuerID.suffix(4)))", to: &standardError) + } else { + print(" Key Type: Individual Key", to: &standardError) + } // Create AuthWorker for first company let jwtService1 = try JWTService(company: company1) - let authWorker1 = AuthWorker(jwtService: jwtService1) + _ = AuthWorker(jwtService: jwtService1) print("✅ AuthWorker created for company 1", to: &standardError) // Test app listing for first company @@ -45,7 +55,7 @@ public func testCompanySwitching() async throws { ) let result1 = try await appsWorker1.listApps(listParams1) - if case .text(let text) = result1.content.first { + if case .text(text: let text, annotations: _, _meta: _) = result1.content.first { print(text, to: &standardError) } @@ -56,11 +66,15 @@ public func testCompanySwitching() async throws { let company2 = try await companiesWorker.manager.getCurrentCompany() print("✅ Active company: \(company2.name)", to: &standardError) print(" Key ID: \(company2.keyID)", to: &standardError) - print(" Issuer ID: \(company2.issuerID)", to: &standardError) + if let issuerID = company2.issuerID { + print(" Key Type: Team Key (Issuer: ****\(issuerID.suffix(4)))", to: &standardError) + } else { + print(" Key Type: Individual Key", to: &standardError) + } // Create AuthWorker for second company let jwtService2 = try JWTService(company: company2) - let authWorker2 = AuthWorker(jwtService: jwtService2) + _ = AuthWorker(jwtService: jwtService2) print("✅ AuthWorker created for company 2", to: &standardError) // Verify configurations are different @@ -81,7 +95,7 @@ public func testCompanySwitching() async throws { ) let result2 = try await appsWorker2.listApps(listParams2) - if case .text(let text) = result2.content.first { + if case .text(text: let text, annotations: _, _meta: _) = result2.content.first { print(text, to: &standardError) } @@ -89,7 +103,7 @@ public func testCompanySwitching() async throws { print("\n🔄 TEST 5: Test switching via WorkerManager", to: &standardError) // Simulate company_switch call via WorkerManager - let switchParams = CallTool.Parameters( + _ = CallTool.Parameters( name: "company_switch", arguments: ["company_id": .string(companies[0].id)] ) @@ -103,4 +117,3 @@ public func testCompanySwitching() async throws { print("\n✅ ALL TESTS COMPLETED", to: &standardError) } - diff --git a/Sources/asc-mcp/Workers/CompaniesWorker/CompaniesWorker+Handlers.swift b/Sources/asc-mcp/Workers/CompaniesWorker/CompaniesWorker+Handlers.swift index 3f7e076..b472934 100644 --- a/Sources/asc-mcp/Workers/CompaniesWorker/CompaniesWorker+Handlers.swift +++ b/Sources/asc-mcp/Workers/CompaniesWorker/CompaniesWorker+Handlers.swift @@ -9,6 +9,15 @@ extension CompaniesWorker { guard value.count > visibleSuffix else { return value } return "****" + value.suffix(visibleSuffix) } + + /// Human-readable description of the API key type for display. + private func keyTypeDescription(_ company: Company) -> String { + if let issuerID = company.issuerID { + return "Team Key (Issuer: \(masked(issuerID)))" + } else { + return "Individual Key" + } + } /// Lists all available companies configured in the MCP server /// - Returns: Formatted list of companies with their IDs, names, and active status @@ -19,7 +28,7 @@ extension CompaniesWorker { if companies.isEmpty { return CallTool.Result(content: [ - .text("Error: No companies found. Please configure companies.json file.") + .text(text: "Error: No companies found. Please configure companies.json file.", annotations: nil, _meta: nil) ]) } @@ -32,8 +41,7 @@ extension CompaniesWorker { result += "\(index + 1). **\(company.name)**\(status)\n" result += " • ID: `\(company.id)`\n" result += " • Key ID: \(company.keyID)\n" - - + result += " • Type: \(keyTypeDescription(company))\n" result += "\n" } @@ -45,7 +53,7 @@ extension CompaniesWorker { result += "Warning: No company selected. Use `company_switch` to select one.\n" } - return CallTool.Result(content: [.text(result)]) + return CallTool.Result(content: [.text(text: result, annotations: nil, _meta: nil)]) } /// Switches to a different company for all subsequent API operations @@ -56,7 +64,7 @@ extension CompaniesWorker { let companyValue = arguments["company"], let companyIdOrName = companyValue.stringValue else { return CallTool.Result( - content: [.text("Error: Required parameter 'company' (ID or name)")], + content: [.text(text: "Error: Required parameter 'company' (ID or name)", annotations: nil, _meta: nil)], isError: true ) } @@ -70,16 +78,16 @@ extension CompaniesWorker { **\(company.name)** • ID: `\(company.id)` • Key ID: \(company.keyID) - • Issuer ID: \(masked(company.issuerID)) + • Type: \(keyTypeDescription(company)) All subsequent API calls will use this company's credentials. """ - return CallTool.Result(content: [.text(result)]) + return CallTool.Result(content: [.text(text: result, annotations: nil, _meta: nil)]) } catch { return CallTool.Result( - content: [.text("Error: Error switching company: \(error.localizedDescription)")], + content: [.text(text: "Error: Error switching company: \(error.localizedDescription)", annotations: nil, _meta: nil)], isError: true ) } @@ -91,7 +99,7 @@ extension CompaniesWorker { func getCurrentCompany(_ params: CallTool.Parameters) async throws -> CallTool.Result { guard let company = try? await manager.getCurrentCompany() else { return CallTool.Result(content: [ - .text("Warning: No company currently selected.\n\nUse `company_list` to see available companies and `company_switch` to select one.") + .text(text: "Warning: No company currently selected.\n\nUse `company_list` to see available companies and `company_switch` to select one.", annotations: nil, _meta: nil) ]) } @@ -102,9 +110,9 @@ extension CompaniesWorker { • ID: `\(company.id)` • NAME: \(company.name) • Key ID: \(company.keyID) - • Issuer ID: \(masked(company.issuerID)) + • Type: \(keyTypeDescription(company)) """ - return CallTool.Result(content: [.text(result)]) + return CallTool.Result(content: [.text(text: result, annotations: nil, _meta: nil)]) } } diff --git a/Sources/asc-mcp/Workers/MainWorker/WorkerManager.swift b/Sources/asc-mcp/Workers/MainWorker/WorkerManager.swift index 43c0970..cf0c9c4 100644 --- a/Sources/asc-mcp/Workers/MainWorker/WorkerManager.swift +++ b/Sources/asc-mcp/Workers/MainWorker/WorkerManager.swift @@ -28,7 +28,12 @@ public actor WorkerDependencies: Sendable { print("🔄 Reinitializing workers for company: \(company.name)", to: &standardError) print(" Key ID: \(company.keyID)", to: &standardError) - print(" Issuer ID: \(company.issuerID)", to: &standardError) + if let issuerID = company.issuerID { + print(" Key Type: Team Key", to: &standardError) + print(" Issuer ID: \(issuerID)", to: &standardError) + } else { + print(" Key Type: Individual Key", to: &standardError) + } self.jwtService = try JWTService(company: company) @@ -464,13 +469,13 @@ public actor WorkerManager { } return CallTool.Result( - content: [.text("Error: Unknown tool: \(params.name)")], + content: [.text(text: "Error: Unknown tool: \(params.name)", annotations: nil, _meta: nil)], isError: true ) } catch { // Catch all errors and return them as Result return CallTool.Result( - content: [.text("Error: \(error.localizedDescription)")], + content: [.text(text: "Error: \(error.localizedDescription)", annotations: nil, _meta: nil)], isError: true ) } @@ -520,7 +525,7 @@ public actor WorkerManager { /// Returns error result for disabled worker private nonisolated func disabledWorkerResult(_ workerName: String) -> CallTool.Result { CallTool.Result( - content: [.text("Error: Worker '\(workerName)' is disabled. Enable it with --workers \(workerName)")], + content: [.text(text: "Error: Worker '\(workerName)' is disabled. Enable it with --workers \(workerName)", annotations: nil, _meta: nil)], isError: true ) } @@ -672,4 +677,3 @@ public actor WorkerManager { return await reviewAttachmentsWorker.getTools() } } - diff --git a/Tests/ASCMCPTests/Fixtures/company_individual.json b/Tests/ASCMCPTests/Fixtures/company_individual.json new file mode 100644 index 0000000..c88057d --- /dev/null +++ b/Tests/ASCMCPTests/Fixtures/company_individual.json @@ -0,0 +1,6 @@ +{ + "id": "comp-individual", + "name": "Test Corp (Individual)", + "key_id": "TESTKEY999", + "key_path": "/tmp/test.p8" +} diff --git a/Tests/ASCMCPTests/Helpers/TestHelpers.swift b/Tests/ASCMCPTests/Helpers/TestHelpers.swift index 131dd5b..54b4b93 100644 --- a/Tests/ASCMCPTests/Helpers/TestHelpers.swift +++ b/Tests/ASCMCPTests/Helpers/TestHelpers.swift @@ -17,7 +17,7 @@ enum TestFactory { id: String = "test-company", name: String = "Test Company", keyID: String = "TEST_KEY_ID", - issuerID: String = "TEST_ISSUER_ID" + issuerID: String? = "TEST_ISSUER_ID" ) -> Company { Company( id: id, @@ -28,6 +28,17 @@ enum TestFactory { ) } + /// Create a test Company with an Individual API Key + static func makeIndividualCompany( + id: String = "test-individual", + name: String = "Test Individual", + keyID: String = "TESTKEY999", + privateKeyPath: String = "/tmp/test.p8" + ) -> Company { + Company(id: id, name: name, keyID: keyID, issuerID: nil, + privateKeyPath: privateKeyPath) + } + /// Create a JWTService with in-memory key (no file access) static func makeJWTService(company: Company? = nil) throws -> JWTService { try JWTService(company: company ?? makeCompany()) diff --git a/Tests/ASCMCPTests/Models/CompaniesConfigModelTests.swift b/Tests/ASCMCPTests/Models/CompaniesConfigModelTests.swift index cfed8fc..f8bb06e 100644 --- a/Tests/ASCMCPTests/Models/CompaniesConfigModelTests.swift +++ b/Tests/ASCMCPTests/Models/CompaniesConfigModelTests.swift @@ -52,6 +52,22 @@ struct CompaniesConfigModelTests { #expect(config.companies[1].name == "B") } + @Test func decodeConfigWithMixedKeyTypes() throws { + let json = """ + { + "companies": [ + {"id":"team","name":"Team","key_id":"k1","issuer_id":"i1"}, + {"id":"individual","name":"Individual","key_id":"k2"} + ] + } + """.data(using: .utf8)! + let config = try JSONDecoder().decode(CompaniesConfig.self, from: json) + #expect(config.companies.count == 2) + #expect(config.companies[0].isIndividualKey == false) + #expect(config.companies[1].isIndividualKey == true) + #expect(config.companies[1].issuerID == nil) + } + @Test func decodeEmptyCompanies() throws { let json = """ {"companies":[]} diff --git a/Tests/ASCMCPTests/Models/CompanyModelTests.swift b/Tests/ASCMCPTests/Models/CompanyModelTests.swift index 8459a11..33f6c6e 100644 --- a/Tests/ASCMCPTests/Models/CompanyModelTests.swift +++ b/Tests/ASCMCPTests/Models/CompanyModelTests.swift @@ -18,6 +18,24 @@ struct CompanyModelTests { #expect(company.vendorNumber == nil) } + @Test func decodeWithoutIssuerID() throws { + let json = """ + {"id":"c1","name":"Corp","key_id":"K1","key_path":"/tmp/k.p8"} + """.data(using: .utf8)! + let company = try JSONDecoder().decode(Company.self, from: json) + #expect(company.issuerID == nil) + #expect(company.isIndividualKey == true) + } + + @Test func decodeWithIssuerID_isIndividualKeyFalse() throws { + let json = """ + {"id":"c1","name":"Corp","key_id":"K1","issuer_id":"I1"} + """.data(using: .utf8)! + let company = try JSONDecoder().decode(Company.self, from: json) + #expect(company.issuerID == "I1") + #expect(company.isIndividualKey == false) + } + @Test func decodeWithVendorNumber() throws { let json = """ {"id":"c1","name":"Corp","key_id":"K1","issuer_id":"I1","vendor_number":"87654321"} @@ -47,6 +65,11 @@ struct CompanyModelTests { #expect(company.vendorNumber == "12345") } + @Test func memberwiseInit_individualKey() { + let company = Company(id: "x", name: "X", keyID: "k", issuerID: nil) + #expect(company.isIndividualKey == true) + } + @Test func memberwiseInitDefaults() { let company = Company(id: "x", name: "X", keyID: "k", issuerID: "i") #expect(company.privateKeyPath == "") @@ -86,6 +109,28 @@ struct CompanyModelTests { #expect(decoded.privateKeyPath == "/path/key.p8") } + @Test func encodeIndividualKey_omitsIssuerID() throws { + let original = Company(id: "rt", name: "RT", keyID: "k", issuerID: nil, privateKeyPath: "/path/key.p8") + let data = try JSONEncoder().encode(original) + let json = String(decoding: data, as: UTF8.self) + #expect(json.contains("issuer_id") == false) + #expect(json.contains("\"issuer_id\":null") == false) + } + + @Test func roundtripIndividualKey() throws { + let original = Company(id: "rt", name: "RT", keyID: "k", issuerID: nil, privateKeyPath: "/path/key.p8") + let data = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode(Company.self, from: data) + #expect(decoded.issuerID == nil) + #expect(decoded.isIndividualKey == true) + } + + @Test func decodeIndividualFromFixture() throws { + let company = try decodeFixture("company_individual", as: Company.self) + #expect(company.issuerID == nil) + #expect(company.isIndividualKey == true) + } + @Test func decodeMissingRequiredField() { let json = """ {"id":"c1","key_id":"K1","issuer_id":"I1"} diff --git a/Tests/ASCMCPTests/Services/CompaniesManagerEnvTests.swift b/Tests/ASCMCPTests/Services/CompaniesManagerEnvTests.swift new file mode 100644 index 0000000..b6b5c29 --- /dev/null +++ b/Tests/ASCMCPTests/Services/CompaniesManagerEnvTests.swift @@ -0,0 +1,73 @@ +import Testing +@testable import asc_mcp + +@Suite("CompaniesManager Environment Loading Tests") +struct CompaniesManagerEnvTests { + @Test("Single team key loads with issuer ID") + func loadFromEnvironment_singleTeamKey() { + let env = [ + "ASC_KEY_ID": "TESTKEY", + "ASC_ISSUER_ID": "TESTISSUER", + "ASC_PRIVATE_KEY_PATH": "/tmp/key.p8" + ] + + let config = CompaniesManager.loadFromEnvironment(env: env) + + #expect(config?.companies.count == 1) + #expect(config?.companies.first?.issuerID != nil) + #expect(config?.companies.first?.isIndividualKey == false) + } + + @Test("Single individual key loads without issuer ID") + func loadFromEnvironment_singleIndividualKey() { + let env = [ + "ASC_KEY_ID": "TESTKEY", + "ASC_PRIVATE_KEY_PATH": "/tmp/key.p8" + ] + + let config = CompaniesManager.loadFromEnvironment(env: env) + + #expect(config?.companies.count == 1) + #expect(config?.companies.first?.issuerID == nil) + #expect(config?.companies.first?.isIndividualKey == true) + } + + @Test("Multi-company env loads team and individual keys") + func loadFromEnvironment_multiCompanyMixed() { + let env = [ + "ASC_COMPANY_1_KEY_ID": "TEAMKEY", + "ASC_COMPANY_1_ISSUER_ID": "TEAMISSUER", + "ASC_COMPANY_1_KEY_PATH": "/tmp/team.p8", + "ASC_COMPANY_2_KEY_ID": "INDIVKEY", + "ASC_COMPANY_2_KEY_PATH": "/tmp/individual.p8" + ] + + let config = CompaniesManager.loadFromEnvironment(env: env) + + #expect(config?.companies.count == 2) + #expect(config?.companies.first?.isIndividualKey == false) + #expect(config?.companies.dropFirst().first?.isIndividualKey == true) + } + + @Test("Missing key ID returns nil") + func loadFromEnvironment_missingKeyIDReturnsNil() { + let config = CompaniesManager.loadFromEnvironment(env: [:]) + + #expect(config == nil) + } + + @Test("Individual key loads from key content") + func loadFromEnvironment_individualKeyWithKeyContent() { + let env = [ + "ASC_KEY_ID": "TESTKEY", + "ASC_PRIVATE_KEY": "-----BEGIN PRIVATE KEY-----\nTEST\n-----END PRIVATE KEY-----" + ] + + let config = CompaniesManager.loadFromEnvironment(env: env) + + #expect(config?.companies.count == 1) + #expect(config?.companies.first?.isIndividualKey == true) + #expect(config?.companies.first?.privateKeyContent != nil) + #expect(config?.companies.first?.privateKeyPath == "") + } +} diff --git a/Tests/ASCMCPTests/Services/JWTServiceTests.swift b/Tests/ASCMCPTests/Services/JWTServiceTests.swift index f988153..cefd071 100644 --- a/Tests/ASCMCPTests/Services/JWTServiceTests.swift +++ b/Tests/ASCMCPTests/Services/JWTServiceTests.swift @@ -70,6 +70,83 @@ struct JWTServiceTests { #expect(payload["iat"] is Int) } + @Test("Individual key payload has sub user and no iss") + func individualKey_payloadHasSubUser_noIss() async throws { + let individual = TestFactory.makeIndividualCompany() + let company = Company( + id: individual.id, + name: individual.name, + keyID: individual.keyID, + issuerID: individual.issuerID, + privateKeyPath: individual.privateKeyPath, + privateKeyContent: TestFactory.testPEM + ) + let service = try JWTService(company: company) + let token = try await service.getToken() + + let payload = try decodeJWTPayload(token) + let rawPayload = try decodePayloadRawString(token) + + #expect(payload["sub"] as? String == "user") + #expect(rawPayload.contains("\"iss\"") == false) + #expect(payload["aud"] as? String == "appstoreconnect-v1") + } + + @Test("Individual key header remains unchanged") + func individualKey_headerUnchanged() async throws { + let individual = TestFactory.makeIndividualCompany() + let company = Company( + id: individual.id, + name: individual.name, + keyID: individual.keyID, + issuerID: individual.issuerID, + privateKeyPath: individual.privateKeyPath, + privateKeyContent: TestFactory.testPEM + ) + let service = try JWTService(company: company) + let token = try await service.getToken() + + let header = try decodeJWTHeader(token) + + #expect(header["alg"] as? String == "ES256") + #expect(header["typ"] as? String == "JWT") + #expect(header["kid"] as? String == company.keyID) + } + + @Test("Individual key token duration is 1200 seconds") + func individualKey_duration1200() async throws { + let individual = TestFactory.makeIndividualCompany() + let company = Company( + id: individual.id, + name: individual.name, + keyID: individual.keyID, + issuerID: individual.issuerID, + privateKeyPath: individual.privateKeyPath, + privateKeyContent: TestFactory.testPEM + ) + let service = try JWTService(company: company) + let token = try await service.getToken() + + let payload = try decodeJWTPayload(token) + + let exp = try #require(payload["exp"] as? Int) + let iat = try #require(payload["iat"] as? Int) + #expect(exp - iat == 1200) + } + + @Test("Team key payload has iss and no sub") + func teamKey_payloadHasIss_noSub() async throws { + let company = makeCompany() + let service = try JWTService(company: company) + let token = try await service.getToken() + + let payload = try decodeJWTPayload(token) + let rawPayload = try decodePayloadRawString(token) + + #expect(payload["iss"] as? String == company.issuerID) + #expect(rawPayload.contains("\"sub\"") == false) + } + @Test("Token expires in exactly 20 minutes (1200 seconds)") func tokenExpiration() async throws { let service = try JWTService(company: makeCompany()) @@ -169,6 +246,37 @@ struct JWTServiceTests { ) return payload } + + private func decodeJWTHeader(_ token: String) throws -> [String: Any] { + let parts = token.split(separator: ".") + guard parts.count == 3 else { + throw TestError(message: "Invalid JWT format") + } + var headerBase64 = String(parts[0]) + .replacingOccurrences(of: "-", with: "+") + .replacingOccurrences(of: "_", with: "/") + while headerBase64.count % 4 != 0 { headerBase64 += "=" } + + let headerData = try #require(Data(base64Encoded: headerBase64)) + let header = try #require( + try JSONSerialization.jsonObject(with: headerData) as? [String: Any] + ) + return header + } + + private func decodePayloadRawString(_ token: String) throws -> String { + let parts = token.split(separator: ".") + guard parts.count == 3 else { + throw TestError(message: "Invalid JWT format") + } + var payloadBase64 = String(parts[1]) + .replacingOccurrences(of: "-", with: "+") + .replacingOccurrences(of: "_", with: "/") + while payloadBase64.count % 4 != 0 { payloadBase64 += "=" } + + let payloadData = try #require(Data(base64Encoded: payloadBase64)) + return try #require(String(data: payloadData, encoding: .utf8)) + } } private struct TestError: Error { diff --git a/companies.example.json b/companies.example.json index f19aa94..7c5dd8f 100644 --- a/companies.example.json +++ b/companies.example.json @@ -15,6 +15,12 @@ "issuer_id": "ANOTHER_ISSUER_ID", "key_path": "/path/to/another/AuthKey.p8", "vendor_number": "YOUR_VENDOR_NUMBER" + }, + { + "id": "company3", + "name": "Your Individual Account (no issuer_id)", + "key_id": "YOUR_INDIVIDUAL_KEY_ID", + "key_path": "/path/to/your/IndividualKey.p8" } ] }