Something went wrong. Try again.
This repository has no description
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234package social.grain
import androidx.test.ext.junit.runners.AndroidJUnit4import kotlinx.coroutines.test.runTestimport kotlinx.serialization.json.buildJsonObjectimport kotlinx.serialization.json.putimport org.junit.Assert.assertEqualsimport org.junit.Assert.assertNotNullimport org.junit.Assert.assertNullimport org.junit.Assert.assertTrueimport org.junit.Testimport org.junit.runner.RunWithimport social.grain.data.api.AuthContextimport social.grain.data.api.XrpcExceptionimport social.grain.data.api.getFeedimport social.grain.support.StubServerimport social.grain.support.assertFailsimport social.grain.support.testAuthimport 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<XrpcException.DpopNonceRequired> { 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<XrpcException.Unauthorized> { 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<XrpcException.Unauthorized> { 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<XrpcException.Unauthorized> { 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<XrpcException.Unauthorized> { 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<XrpcException.Http> { 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<XrpcException.Decoding> { 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\"")) }}