package social.grain import androidx.test.ext.junit.runners.AndroidJUnit4 import kotlinx.coroutines.test.runTest import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.put import org.junit.Assert.assertEquals import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test import org.junit.runner.RunWith import social.grain.data.api.AuthContext import social.grain.data.api.XrpcException import social.grain.data.api.getFeed import social.grain.support.StubServer import social.grain.support.assertFails import social.grain.support.testAuth import social.grain.support.xrpc /** * The two replays folded into every XRPC call. * * Both are invisible when they work and indistinguishable from a broken session * when they don't: a dropped nonce retry reads as "signed out" on the very * first authenticated request of a launch, and a missing token refresh reads as * "signed out" an hour in. Neither shows up in a screen test, because both need * the server to answer twice. */ @RunWith(AndroidJUnit4::class) class XrpcClientTest { private val server = StubServer() @Test fun aSuccessfulQueryDecodesTheBody() = runTest { server.on("social.grain.feed.getFeed", """{"items":[],"cursor":"next"}""") assertEquals("next", server.xrpc().getFeed(feed = "recent").cursor) } @Test fun anAuthenticatedCallCarriesADpopProofAndTheToken() = runTest { server.on("social.grain.feed.getFeed", """{"items":[]}""") server.xrpc().getFeed(feed = "recent", auth = testAuth("abc123")) val call = server.lastCall("social.grain.feed.getFeed") assertEquals("DPoP abc123", call.authorization) // header.payload.signature — the proof is a real signed JWT. assertEquals(3, call.dpopProof!!.split('.').size) } @Test fun anUnauthenticatedCallSendsNoProofAtAll() = runTest { server.on("social.grain.feed.getFeed", """{"items":[]}""") server.xrpc().getFeed(feed = "recent") val call = server.lastCall("social.grain.feed.getFeed") assertNull(call.authorization) assertNull(call.dpopProof) } @Test fun aNonceChallengeIsReplayedOnceWithTheNonce() = runTest { server .on( "social.grain.feed.getFeed", """{"error":"use_dpop_nonce"}""", code = 401, headers = mapOf("DPoP-Nonce" to "nonce-1"), ) .then("social.grain.feed.getFeed", """{"items":[]}""") server.xrpc().getFeed(feed = "recent", auth = testAuth()) assertEquals(2, server.countOf("social.grain.feed.getFeed")) } /** An authorization server refuses a proof with no nonce with a 400, not a 401. */ @Test fun aNonceOnA400IsAlsoAReplayRatherThanAFailure() = runTest { server .on( "social.grain.feed.getFeed", """{"error":"use_dpop_nonce"}""", code = 400, headers = mapOf("DPoP-Nonce" to "nonce-1"), ) .then("social.grain.feed.getFeed", """{"items":[]}""") server.xrpc().getFeed(feed = "recent", auth = testAuth()) assertEquals(2, server.countOf("social.grain.feed.getFeed")) } @Test fun aServerThatKeepsAskingForANonceEventuallyFails() = runTest { server.on( "social.grain.feed.getFeed", """{"error":"use_dpop_nonce"}""", code = 401, headers = mapOf("DPoP-Nonce" to "nonce-1"), ) assertFails { server.xrpc().getFeed(feed = "recent", auth = testAuth()) } } @Test fun aBare401RefreshesTheTokenAndRetriesOnce() = runTest { server .on("social.grain.feed.getFeed", """{"error":"expired"}""", code = 401) .then("social.grain.feed.getFeed", """{"items":[]}""") var refreshes = 0 val refreshed = testAuth("fresh-token") val client = server.xrpc { refreshes += 1 refreshed } client.getFeed(feed = "recent", auth = testAuth("stale-token")) assertEquals(1, refreshes) assertEquals(2, server.countOf("social.grain.feed.getFeed")) assertEquals("DPoP fresh-token", server.lastCall("social.grain.feed.getFeed").authorization) } @Test fun a401WithNoRefresherAvailableIsReportedAsUnauthorized() = runTest { server.on("social.grain.feed.getFeed", """{"error":"expired"}""", code = 401) assertFails { server.xrpc().getFeed(feed = "recent", auth = testAuth()) } assertEquals(1, server.countOf("social.grain.feed.getFeed")) } @Test fun aRefreshThatThrowsLeavesTheOriginalFailureIntact() = runTest { server.on("social.grain.feed.getFeed", """{"error":"expired"}""", code = 401) val client = server.xrpc { error("refresh exploded") } assertFails { client.getFeed(feed = "recent", auth = testAuth()) } } @Test fun aRefreshThatFindsNoSessionDoesNotRetry() = runTest { server.on("social.grain.feed.getFeed", """{"error":"expired"}""", code = 401) val client = server.xrpc { null as AuthContext? } assertFails { client.getFeed(feed = "recent", auth = testAuth()) } assertEquals(1, server.countOf("social.grain.feed.getFeed")) } @Test fun aRefreshedRequestThat401sAgainGivesUp() = runTest { server.on("social.grain.feed.getFeed", """{"error":"expired"}""", code = 401) val client = server.xrpc { testAuth("still-bad") } assertFails { client.getFeed(feed = "recent", auth = testAuth()) } assertEquals(2, server.countOf("social.grain.feed.getFeed")) } @Test fun anHttpErrorCarriesItsStatusAndBody() = runTest { server.on("social.grain.feed.getFeed", """{"error":"NotFound"}""", code = 404) val error = assertFails { server.xrpc().getFeed(feed = "recent") } assertEquals(404, error.statusCode) assertTrue(error.body!!.contains("NotFound")) } @Test fun aBodyThatIsNotTheExpectedShapeBecomesADecodingError() = runTest { server.on("social.grain.feed.getFeed", """{"items":"not-a-list"}""") val error = assertFails { server.xrpc().getFeed(feed = "recent") } assertNotNull(error.reason) } @Test fun aProcedureWithNoUsefulBodyStillPostsItsInput() = runTest { server.xrpc().procedureVoid( "social.grain.notification.updateSeen", buildJsonObject { put("seenAt", "2026-01-01T00:00:00.000Z") }, testAuth(), ) val call = server.lastCall("social.grain.notification.updateSeen") assertEquals("POST", call.method) assertEquals( "2026-01-01T00:00:00.000Z", call.json["seenAt"]!!.let { (it as kotlinx.serialization.json.JsonPrimitive).content }, ) } @Test fun uploadingABlobPostsTheRawBytesUnderTheirOwnMimeType() = runTest { server.on( "com.atproto.repo.uploadBlob", """{"blob":{"${'$'}type":"blob","ref":{"${'$'}link":"bafy1"},""" + """"mimeType":"image/jpeg","size":4}}""", ) val response = server.xrpc().uploadBlob( byteArrayOf(1, 2, 3, 4), "image/jpeg", testAuth(), ) assertEquals("bafy1", response.blob.ref?.link) assertEquals("POST", server.lastCall("com.atproto.repo.uploadBlob").method) } /** The `htu` claim must not carry the query string, or the PDS rejects it. */ @Test fun theProofIsSignedOverTheUrlWithoutItsQuery() = runTest { server.on("social.grain.feed.getFeed", """{"items":[]}""") server.xrpc().getFeed(feed = "recent", auth = testAuth()) val payload = server.lastCall("social.grain.feed.getFeed").dpopProof!!.split('.')[1] val decoded = String( android.util.Base64.decode( payload, android.util.Base64.URL_SAFE or android.util.Base64.NO_PADDING, ), ) assertTrue(decoded.contains("\"htu\":\"https:\\/\\/stub.grain.test\\/xrpc\\/social.grain.feed.getFeed\"")) assertTrue(decoded.contains("\"htm\":\"GET\"")) assertTrue("The token hash binds the proof to the token", decoded.contains("\"ath\"")) } // MARK: - Through the PDS @Test fun aSignedInCallGoesToThePdsAndIsProxiedToTheAppview() = runTest { server.on("social.grain.feed.getFeed", """{"items":[]}""") server.xrpc().getFeed(feed = "recent", auth = testAuth(pdsUrl = "https://pds.example.test")) val call = server.lastCall("social.grain.feed.getFeed") assertEquals("pds.example.test", call.url.host) assertEquals("did:web:stub.grain.test#grain_appview", call.headers["atproto-proxy"]) assertEquals("https://pds.example.test/xrpc/social.grain.feed.getFeed", call.proofClaims["htu"]!!.jsonPrimitive.content) } @Test fun aSignedOutCallGoesStraightToTheAppviewWithNoProxy() = runTest { server.on("social.grain.feed.getFeed", """{"items":[]}""") server.xrpc().getFeed(feed = "recent") val call = server.lastCall("social.grain.feed.getFeed") assertEquals("stub.grain.test", call.url.host) assertNull(call.headers["atproto-proxy"]) } @Test fun aBlobUploadIsThePdsOwnCallAndIsNotProxied() = runTest { server.on("com.atproto.repo.uploadBlob", """{"blob":{"ref":{"${'$'}link":"bafy"}}}""") server.xrpc().uploadBlob(byteArrayOf(1), "image/jpeg", testAuth()) assertNull(server.lastCall("com.atproto.repo.uploadBlob").headers["atproto-proxy"]) } @Test fun theTokenHashInTheProofIsTheTokensSha256() = runTest { server.on("social.grain.feed.getFeed", """{"items":[]}""") server.xrpc().getFeed(feed = "recent", auth = testAuth("abc123")) val expected = java.util.Base64.getUrlEncoder().withoutPadding().encodeToString( java.security.MessageDigest.getInstance("SHA-256").digest("abc123".toByteArray()), ) assertEquals(expected, server.lastCall("social.grain.feed.getFeed").proofClaims["ath"]!!.jsonPrimitive.content) } @Test fun aNonceNamedInWwwAuthenticateIsReplayedWithThatNonce() = runTest { server .on( "social.grain.feed.getFeed", "", code = 401, headers = mapOf( "DPoP-Nonce" to "nonce-7", "WWW-Authenticate" to """DPoP error="use_dpop_nonce"""", ), ) .then("social.grain.feed.getFeed", """{"items":[]}""") server.xrpc().getFeed(feed = "recent", auth = testAuth()) val calls = server.callsTo("social.grain.feed.getFeed") assertEquals(2, calls.size) assertNull(calls[0].proofClaims["nonce"]) assertEquals("nonce-7", calls[1].proofClaims["nonce"]!!.jsonPrimitive.content) } @Test fun aNonceIsKeptForItsOriginAndSentFromTheStart() = runTest { server.on("social.grain.feed.getFeed", """{"items":[]}""", headers = mapOf("DPoP-Nonce" to "nonce-9")) val client = server.xrpc() client.getFeed(feed = "recent", auth = testAuth()) client.getFeed(feed = "recent", auth = testAuth()) client.getFeed(feed = "recent", auth = testAuth(pdsUrl = "https://other-pds.test")) val calls = server.callsTo("social.grain.feed.getFeed") assertEquals("nonce-9", calls[1].proofClaims["nonce"]!!.jsonPrimitive.content) assertNull("Another origin has nonces of its own", calls[2].proofClaims["nonce"]) } /** A PDS sends a nonce on every response, a token it has rejected included. */ @Test fun a401WithANonceButNoNonceErrorRefreshesTheToken() = runTest { server .on( "social.grain.feed.getFeed", """{"error":"InvalidToken"}""", code = 401, headers = mapOf("DPoP-Nonce" to "n", "WWW-Authenticate" to """DPoP error="invalid_token""""), ) .then("social.grain.feed.getFeed", """{"items":[]}""") var refreshes = 0 val client = server.xrpc { refreshes += 1; testAuth("fresh") } client.getFeed(feed = "recent", auth = testAuth("stale")) assertEquals(1, refreshes) assertEquals("DPoP fresh", server.lastCall("social.grain.feed.getFeed").authorization) } }