package com.osglab.account.features.auth import com.osglab.account.config.AppleConfig import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe import io.ktor.client.HttpClient import io.ktor.client.engine.mock.MockEngine import io.ktor.client.engine.mock.respond import io.ktor.client.plugins.HttpTimeout import io.ktor.client.request.forms.FormDataContent import io.ktor.http.HttpHeaders import io.ktor.http.HttpStatusCode import io.ktor.http.headersOf import java.net.SocketTimeoutException class AppleTokenClientTest : FunSpec({ test("authorization code exchange uses the replaceable HTTP boundary") { val engine = MockEngine { request -> request.url.toString() shouldBe "https://appleid.apple.com/auth/token" val form = (request.body as FormDataContent).formData form["client_id"] shouldBe "com.example.ios" form["client_secret"] shouldBe "signed-client-secret" form["code"] shouldBe "one-time-code" form["grant_type"] shouldBe "authorization_code" respond( content = """{"refresh_token":"refresh","id_token":"identity"}""", status = HttpStatusCode.OK, headers = headersOf(HttpHeaders.ContentType, "application/json"), ) } val client = HttpClient(engine) { install(HttpTimeout) } val apple = HttpAppleTokenClient( client, appleTokenClientConfig(), AppleClientSecretSigner { "signed-client-secret" }, ) apple.exchangeAuthorizationCode("one-time-code") shouldBe AppleTokenExchange("refresh", "identity") client.close() } test("provider timeout is mapped to a retryable failure") { val engine = MockEngine { throw SocketTimeoutException("simulated provider timeout") } val client = HttpClient(engine) { install(HttpTimeout) } val apple = HttpAppleTokenClient( httpClient = client, config = appleTokenClientConfig(), clientSecretProvider = AppleClientSecretSigner { "signed-client-secret" }, requestTimeoutMillis = 10, ) shouldThrow { apple.exchangeAuthorizationCode("one-time-code") }.retryable shouldBe true client.close() } test("authorization code exchange rejects a response without a refresh token") { val engine = MockEngine { respond( content = """{"id_token":"identity"}""", status = HttpStatusCode.OK, headers = headersOf(HttpHeaders.ContentType, "application/json"), ) } val client = HttpClient(engine) { install(HttpTimeout) } val apple = HttpAppleTokenClient( client, appleTokenClientConfig(), AppleClientSecretSigner { "signed-client-secret" }, ) shouldThrow { apple.exchangeAuthorizationCode("one-time-code") }.retryable shouldBe false client.close() } test("rate limiting is retryable without reflecting the provider response") { val engine = MockEngine { respond( content = "upstream detail that must not be reflected", status = HttpStatusCode.TooManyRequests, ) } val client = HttpClient(engine) { install(HttpTimeout) } val apple = HttpAppleTokenClient( client, appleTokenClientConfig(), AppleClientSecretSigner { "signed-client-secret" }, ) val failure = shouldThrow { apple.exchangeAuthorizationCode("one-time-code") } failure.retryable shouldBe true failure.message shouldBe "Apple rejected the authorization code" client.close() } test("token exchange values are redacted from string rendering") { AppleTokenExchange("refresh-secret", "identity-secret").toString() shouldBe "AppleTokenExchange(refreshToken=[REDACTED], identityToken=[REDACTED])" } }) private fun appleTokenClientConfig() = AppleConfig( teamId = "TEAM", keyId = "KEY", clientId = "com.example.ios", privateKeyPem = "unused-by-test-signer", jwksUrl = "https://appleid.apple.com/auth/keys", tokenUrl = "https://appleid.apple.com/auth/token", revokeUrl = "https://appleid.apple.com/auth/revoke", )