Files
OSGAccountServer/src/test/kotlin/com/osglab/account/features/auth/AppleTokenClientTest.kt
T
Rocky 0af35d44f4 Establish secure account and managed AI backend
Provide the production foundation for Apple identity, immutable credits, referrals, integrity checks, managed providers, and hardened Docker deployment.
2026-08-16 14:46:23 +08:00

124 lines
4.5 KiB
Kotlin

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<AppleTokenEndpointException> {
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<AppleTokenEndpointException> {
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<AppleTokenEndpointException> {
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",
)