~ knotless Search/
↑↓ move↵ open⇧↵ all results

grain.social/grain-android

1 collaborator
0
1
main
332 lines · 12.5 KB Raw History
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<XrpcException.DpopNonceRequired> {
            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<XrpcException.Unauthorized> {
            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<XrpcException.Unauthorized> { 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<XrpcException.Unauthorized> { 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<XrpcException.Unauthorized> { 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<XrpcException.Http> { 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<XrpcException.Decoding> { 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)
    }
}