package social.grain import androidx.test.ext.junit.runners.AndroidJUnit4 import kotlinx.coroutines.test.runTest import kotlinx.serialization.json.buildJsonObject 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("dev.hatk.getFeed", """{"items":[],"cursor":"next"}""") assertEquals("next", server.xrpc().getFeed(feed = "recent").cursor) } @Test fun anAuthenticatedCallCarriesADpopProofAndTheToken() = runTest { server.on("dev.hatk.getFeed", """{"items":[]}""") server.xrpc().getFeed(feed = "recent", auth = testAuth("abc123")) val call = server.lastCall("dev.hatk.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("dev.hatk.getFeed", """{"items":[]}""") server.xrpc().getFeed(feed = "recent") val call = server.lastCall("dev.hatk.getFeed") assertNull(call.authorization) assertNull(call.dpopProof) } @Test fun aNonceChallengeIsReplayedOnceWithTheNonce() = runTest { server .on( "dev.hatk.getFeed", """{"error":"use_dpop_nonce"}""", code = 401, headers = mapOf("DPoP-Nonce" to "nonce-1"), ) .then("dev.hatk.getFeed", """{"items":[]}""") server.xrpc().getFeed(feed = "recent", auth = testAuth()) assertEquals(2, server.countOf("dev.hatk.getFeed")) } /** hatk answers the first proof of a session with a 400, not a 401. */ @Test fun aNonceOnA400IsAlsoAReplayRatherThanAFailure() = runTest { server .on( "dev.hatk.getFeed", """{"error":"use_dpop_nonce"}""", code = 400, headers = mapOf("DPoP-Nonce" to "nonce-1"), ) .then("dev.hatk.getFeed", """{"items":[]}""") server.xrpc().getFeed(feed = "recent", auth = testAuth()) assertEquals(2, server.countOf("dev.hatk.getFeed")) } @Test fun aServerThatKeepsAskingForANonceEventuallyFails() = runTest { server.on( "dev.hatk.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("dev.hatk.getFeed", """{"error":"expired"}""", code = 401) .then("dev.hatk.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("dev.hatk.getFeed")) assertEquals("DPoP fresh-token", server.lastCall("dev.hatk.getFeed").authorization) } @Test fun a401WithNoRefresherAvailableIsReportedAsUnauthorized() = runTest { server.on("dev.hatk.getFeed", """{"error":"expired"}""", code = 401) assertFails { server.xrpc().getFeed(feed = "recent", auth = testAuth()) } assertEquals(1, server.countOf("dev.hatk.getFeed")) } @Test fun aRefreshThatThrowsLeavesTheOriginalFailureIntact() = runTest { server.on("dev.hatk.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("dev.hatk.getFeed", """{"error":"expired"}""", code = 401) val client = server.xrpc { null as AuthContext? } assertFails { client.getFeed(feed = "recent", auth = testAuth()) } assertEquals(1, server.countOf("dev.hatk.getFeed")) } @Test fun aRefreshedRequestThat401sAgainGivesUp() = runTest { server.on("dev.hatk.getFeed", """{"error":"expired"}""", code = 401) val client = server.xrpc { testAuth("still-bad") } assertFails { client.getFeed(feed = "recent", auth = testAuth()) } assertEquals(2, server.countOf("dev.hatk.getFeed")) } @Test fun anHttpErrorCarriesItsStatusAndBody() = runTest { server.on("dev.hatk.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("dev.hatk.getFeed", """{"items":"not-a-list"}""") val error = assertFails { server.xrpc().getFeed(feed = "recent") } assertNotNull(error.reason) } @Test fun aProcedureWithNoUsefulBodyStillPostsItsInput() = runTest { server.xrpc().procedureVoid( "dev.hatk.putPreference", buildJsonObject { put("key", "includeExif") }, testAuth(), ) val call = server.lastCall("dev.hatk.putPreference") assertEquals("POST", call.method) assertEquals( "includeExif", call.json["key"]!!.let { (it as kotlinx.serialization.json.JsonPrimitive).content }, ) } @Test fun uploadingABlobPostsTheRawBytesUnderTheirOwnMimeType() = runTest { server.on( "dev.hatk.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("dev.hatk.uploadBlob").method) } /** The `htu` claim must not carry the query string, or the PDS rejects it. */ @Test fun theProofIsSignedOverTheUrlWithoutItsQuery() = runTest { server.on("dev.hatk.getFeed", """{"items":[]}""") server.xrpc().getFeed(feed = "recent", auth = testAuth()) val payload = server.lastCall("dev.hatk.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\\/dev.hatk.getFeed\"")) assertTrue(decoded.contains("\"htm\":\"GET\"")) assertTrue("The token hash binds the proof to the token", decoded.contains("\"ath\"")) } }