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

grain.social/grain-android

1 collaborator
0
1
main
302 lines · 11.1 KB Raw History
package social.grain.support

import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonObject
import okhttp3.HttpUrl
import okhttp3.Interceptor
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Protocol
import okhttp3.Request
import okhttp3.Response
import okhttp3.ResponseBody.Companion.toResponseBody
import okio.Buffer
import social.grain.data.api.AuthContext
import social.grain.data.api.DPoP
import social.grain.data.api.XrpcClient

/** The JSON the app itself is configured with, so decoding matches production. */
val testJson = Json {
    ignoreUnknownKeys = true
    explicitNulls = false
    coerceInputValues = true
}

/** One request the app made, captured for assertions. */
data class StubCall(
    val nsid: String,
    val url: HttpUrl,
    val method: String,
    val body: String,
    val authorization: String?,
    val dpopProof: String?,
    val headers: okhttp3.Headers = okhttp3.Headers.headersOf(),
) {
    /** A form POST's fields, as PAR and the token endpoint take them. */
    val form: Map<String, String>
        get() = body.split('&').filter { it.contains('=') }.associate {
            java.net.URLDecoder.decode(it.substringBefore('='), "UTF-8") to
                java.net.URLDecoder.decode(it.substringAfter('='), "UTF-8")
        }

    /** The DPoP proof's claims. */
    val proofClaims: JsonObject
        get() = testJson.parseToJsonElement(
            String(java.util.Base64.getUrlDecoder().decode(dpopProof!!.split('.')[1])),
        ).jsonObject

    val params: Map<String, String>
        get() = url.queryParameterNames.associateWith { url.queryParameter(it).orEmpty() }

    /** The POST body as an object — every procedure in this app sends one. */
    val json: JsonObject get() = testJson.parseToJsonElement(body).jsonObject
}

private data class StubReply(
    val code: Int,
    val body: String,
    val headers: Map<String, String> = emptyMap(),
)

/**
 * An OkHttp interceptor standing in for the appview.
 *
 * [on] sets the standing answer for an NSID and [then] queues one to follow it,
 * with the last reply repeating once the queue runs dry — which is what lets a
 * test script a 401-then-success retry without having to count how many times
 * the client will actually call. `on` replaces rather than appends so a test
 * can override a default its fixture set up, which is the common case and the
 * one that fails confusingly when it silently queues instead.
 *
 * Nothing reaches the network: the interceptor is the outermost one and always
 * short-circuits.
 */
class StubServer {

    /** Synchronized: OkHttp answers on its own dispatcher threads, and the
     *  assertions run on the instrumentation thread. */
    val calls: MutableList<StubCall> = java.util.Collections.synchronizedList(mutableListOf())

    private val replies = mutableMapOf<String, ArrayDeque<StubReply>>()
    private val handlers = mutableMapOf<String, (StubCall) -> StubReply>()
    private var fallback = StubReply(200, "{}")

    /** What [nsid] answers with, replacing anything registered for it before. */
    fun on(
        nsid: String,
        body: String = "{}",
        code: Int = 200,
        headers: Map<String, String> = emptyMap(),
    ): StubServer = apply {
        synchronized(replies) {
            handlers.remove(nsid)
            replies[nsid] = ArrayDeque(listOf(StubReply(code, body, headers)))
        }
    }

    /** What [nsid] answers with *next*, once the reply before it has been used. */
    fun then(
        nsid: String,
        body: String = "{}",
        code: Int = 200,
        headers: Map<String, String> = emptyMap(),
    ): StubServer = apply {
        synchronized(replies) {
            replies.getOrPut(nsid) { ArrayDeque() }.addLast(StubReply(code, body, headers))
        }
    }

    /**
     * Answer [nsid] from the request itself.
     *
     * For endpoints where the reply depends on an argument — one author's
     * stories rather than another's. A fixed reply would work only as long as
     * the test knows the order the calls go out in, and prefetching means it
     * doesn't.
     */
    fun on(nsid: String, handler: (StubCall) -> String): StubServer = apply {
        synchronized(replies) { handlers[nsid] = { StubReply(200, handler(it)) } }
    }

    /**
     * Answer [nsid] from the request itself, with a status as well as a body,
     * as `(code, body)`: for an endpoint that refuses some arguments.
     */
    fun answer(nsid: String, handler: (StubCall) -> Pair<Int, String>): StubServer = apply {
        synchronized(replies) {
            handlers[nsid] = { call -> handler(call).let { (code, body) -> StubReply(code, body) } }
        }
    }

    /** What anything unregistered answers with. Defaults to an empty object. */
    fun default(body: String, code: Int = 200): StubServer = apply {
        fallback = StubReply(code, body)
    }

    fun callsTo(nsid: String): List<StubCall> =
        synchronized(calls) { calls.filter { it.nsid == nsid } }

    fun lastCall(nsid: String): StubCall = callsTo(nsid).last()

    fun countOf(nsid: String): Int = callsTo(nsid).size

    /**
     * One client per stub, shared by everything wired to it.
     *
     * Each `OkHttpClient` brings its own dispatcher thread pool, and a suite
     * that builds a couple per test leaves hundreds of idle threads behind on
     * a small emulator. [shutdown] takes this one down with the test.
     */
    val http: OkHttpClient by lazy {
        OkHttpClient.Builder().addInterceptor(intercept).build()
    }

    fun client(): OkHttpClient = http

    /** Release the dispatcher's threads. Safe to call more than once. */
    fun shutdown() {
        http.dispatcher.executorService.shutdown()
        http.connectionPool.evictAll()
    }

    private val intercept = Interceptor { chain ->
        val request = chain.request()
        val nsid = request.url.pathSegments.last()
        calls.add(StubCall(
            nsid = nsid,
            url = request.url,
            method = request.method,
            body = request.body?.let { body ->
                Buffer().also { body.writeTo(it) }.readUtf8()
            }.orEmpty(),
            authorization = request.header("Authorization"),
            dpopProof = request.header("DPoP"),
            headers = request.headers,
        ))
        val call = calls.last()
        val reply = synchronized(replies) {
            val queue = replies[nsid]
            val handler = handlers[nsid]
            when {
                handler != null -> handler(call)
                queue == null || queue.isEmpty() -> fallback
                queue.size == 1 -> queue.first()
                else -> queue.removeFirst()
            }
        }
        reply.toResponse(request)
    }

    private fun StubReply.toResponse(request: Request): Response =
        Response.Builder()
            .request(request)
            .protocol(Protocol.HTTP_1_1)
            .code(code)
            .message(if (code in 200..299) "OK" else "Error")
            .body(body.toResponseBody("application/json".toMediaType()))
            .apply { headers.forEach { (name, value) -> header(name, value) } }
            .build()
}

const val STUB_BASE_URL = "https://stub.grain.test"

/** The authorization server every stubbed account signs in at. */
const val STUB_ISSUER = "https://auth.stub.test"

/** What a signed-in account's PDS and authorization server are on record as. */
fun stubAccountServer(pds: String = STUB_BASE_URL): social.grain.data.auth.AccountServer =
    social.grain.data.auth.AccountServer(
        pds = pds,
        issuer = STUB_ISSUER,
        tokenEndpoint = "$STUB_ISSUER/oauth/token",
        revocationEndpoint = "$STUB_ISSUER/oauth/revoke",
    )

/**
 * Answer the discovery a sign-in runs: [handle]'s `/.well-known/atproto-did`,
 * the DID document at plc.directory, the PDS's protected-resource metadata and
 * the authorization server's own, and PAR.
 *
 * The stub answers by last path segment whatever the host, so the PDS here is
 * [STUB_BASE_URL] and the authorization server [issuer].
 */
fun StubServer.stubSignIn(
    did: String = "did:plc:me",
    handle: String = "me.grain.social",
    pds: String = STUB_BASE_URL,
    issuer: String = STUB_ISSUER,
): StubServer = apply {
    on("atproto-did", did)
    on(did, didDocument(did, handle, pds))
    on("oauth-protected-resource", """{"resource":"$pds","authorization_servers":["$issuer"]}""")
    on("oauth-authorization-server", authServerMetadata(issuer))
    on("par", """{"request_uri":"urn:ietf:params:oauth:request_uri:abc","expires_in":60}""")
}

fun didDocument(did: String, handle: String, pds: String): String =
    """{"id":"$did","alsoKnownAs":["at://$handle"],"service":[""" +
        """{"id":"#atproto_pds","type":"AtprotoPersonalDataServer","serviceEndpoint":"$pds"}]}"""

fun authServerMetadata(issuer: String): String =
    """{"issuer":"$issuer",""" +
        """"pushed_authorization_request_endpoint":"$issuer/oauth/par",""" +
        """"authorization_endpoint":"$issuer/oauth/authorize",""" +
        """"token_endpoint":"$issuer/oauth/token",""" +
        """"revocation_endpoint":"$issuer/oauth/revoke"}"""

/** The redirect the browser comes back with, for the sign-in PAR last pushed. */
fun StubServer.callback(
    code: String? = "abc123",
    state: String? = lastCall("par").form["state"],
    iss: String? = STUB_ISSUER,
    extra: String = "",
): android.net.Uri {
    val query = listOfNotNull(
        code?.let { "code=$it" },
        state?.let { "state=$it" },
        iss?.let { "iss=" + java.net.URLEncoder.encode(it, "UTF-8") },
        extra.takeIf { it.isNotEmpty() },
    ).joinToString("&")
    return android.net.Uri.parse("social.grain:/oauth/callback?$query")
}

/** An [XrpcClient] talking to [server] instead of the network. */
fun StubServer.xrpc(onUnauthorized: (suspend () -> AuthContext?)? = null): XrpcClient =
    XrpcClient(
        baseUrl = STUB_BASE_URL,
        http = client(),
        json = testJson,
        onUnauthorized = onUnauthorized,
    )

/**
 * A real signing context.
 *
 * The key is generated rather than faked because [DPoP.createProof] is on the
 * path of every authenticated call — a stub would leave the proof header, and
 * the ES256 signing behind it, untested.
 */
fun testAuth(
    accessToken: String = "test-access-token",
    did: String = "did:plc:me",
    pdsUrl: String? = STUB_BASE_URL,
): AuthContext =
    AuthContext(accessToken = accessToken, dpop = DPoP.createEphemeral(), did = did, pdsUrl = pdsUrl)

/**
 * Assert that [block] fails with [T], from inside a suspending test.
 *
 * JUnit's own `assertThrows` takes a blocking lambda, so every suspending
 * expectation would otherwise have to be wrapped in `runBlocking` inside a
 * `runTest` — which works, but blocks the test scheduler for real time.
 */
inline fun <reified T : Throwable> assertFails(block: () -> Unit): T {
    try {
        block()
    } catch (thrown: Throwable) {
        if (thrown is T) return thrown
        throw AssertionError("Expected ${T::class.java.simpleName} but got $thrown", thrown)
    }
    throw AssertionError("Expected ${T::class.java.simpleName}; nothing was thrown")
}