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

grain.social/grain-android

1 collaborator
0
1
main
780 lines · 26.9 KB Raw History
package social.grain

import android.net.Uri
import androidx.test.ext.junit.runners.AndroidJUnit4
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import social.grain.data.api.DPoP
import social.grain.data.api.getFeed
import social.grain.data.api.XrpcException
import social.grain.data.auth.AuthManager
import social.grain.data.auth.SecureStore
import social.grain.data.auth.StoredAccount
import social.grain.data.auth.TokenStorage
import kotlinx.serialization.json.jsonPrimitive
import okhttp3.HttpUrl.Companion.toHttpUrl
import social.grain.support.FakeAndroidKeyStore
import social.grain.support.STUB_BASE_URL
import social.grain.support.STUB_ISSUER
import social.grain.support.authServerMetadata
import social.grain.support.callback
import social.grain.support.didDocument
import social.grain.support.stubAccountServer
import social.grain.support.stubSignIn
import social.grain.support.StubServer
import social.grain.support.appContext
import social.grain.support.assertFails
import social.grain.support.testJson

/**
 * The session: sign-in, refresh, several accounts at once, and signing out.
 *
 * Every branch here fails the same way on screen — the user finds themselves
 * signed out — so the distinctions have to be made in a test. In particular a
 * refresh rejected with a 4xx must sign the account out, and one that dies on a
 * 5xx must leave the credentials exactly where they are.
 */
@RunWith(AndroidJUnit4::class)
class AuthManagerTest {

    private lateinit var storage: TokenStorage
    private lateinit var server: StubServer
    private lateinit var scope: CoroutineScope

    private val me = "did:plc:me"
    private val other = "did:plc:other"

    @Before
    fun setUp() {
        FakeAndroidKeyStore.install()
        storage = TokenStorage(SecureStore(appContext))
        clearAccounts()
        server = StubServer()
        scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
    }

    @After
    fun tearDown() {
        clearAccounts()
        scope.coroutineContext[kotlinx.coroutines.Job]?.cancel()
        server.shutdown()
    }

    private fun clearAccounts() {
        storage.accounts.forEach { storage.removeAccount(it.did) }
        storage.removeAccount(me)
        storage.removeAccount(other)
        storage.activeDid = null
        storage.clearPendingLogin()
    }

    private fun seed(
        did: String,
        handle: String = "$did.test",
        expiresInMillis: Long = 3_600_000,
        refreshToken: String? = "refresh-$did",
    ) {
        storage.storeDpopKey(did, DPoP.createEphemeral().encodedKeyPair())
        storage.storeTokens(
            did = did,
            accessToken = "access-$did",
            refreshToken = refreshToken,
            handle = handle,
            expiresAtMillis = System.currentTimeMillis() + expiresInMillis,
            scope = null,
        )
        storage.storeServer(did, stubAccountServer())
        storage.upsertAccount(StoredAccount(did, handle, null))
    }

    private fun manager() = AuthManager(storage, server.client(), testJson, scope)

    // MARK: - Restore

    @Test
    fun aFreshInstallStartsSignedOut() {
        val auth = manager()
        assertFalse(auth.state.value.isAuthenticated)
        assertNull(auth.state.value.did)
        assertTrue(auth.state.value.accounts.isEmpty())
    }

    @Test
    fun storedCredentialsAreRestoredOnLaunch() {
        seed(me)
        storage.activeDid = me

        val auth = manager()

        assertTrue(auth.state.value.isAuthenticated)
        assertEquals(me, auth.state.value.did)
        assertEquals("$me.test", auth.state.value.handle)
        assertEquals(1, auth.state.value.accounts.size)
    }

    @Test
    fun anAccountMissingHalfItsCredentialsIsNotResumed() {
        seed(me, refreshToken = null)
        storage.activeDid = me

        val auth = manager()

        assertFalse("A session with no refresh token can't be resumed", auth.state.value.isAuthenticated)
        assertEquals("It is still offered as an account to sign back into", 1, auth.state.value.accounts.size)
    }

    // MARK: - Auth context

    @Test
    fun anAuthContextIsBuiltFromTheStoredTokenAndKey() = runBlocking {
        seed(me)
        storage.activeDid = me

        val context = manager().authContext()

        assertNotNull(context)
        assertEquals("access-$me", context!!.accessToken)
        assertEquals(3, context.dpop.createProof("GET", "https://x/y").split('.').size)
    }

    @Test
    fun thereIsNoAuthContextWhenNobodyIsSignedIn() = runBlocking {
        assertNull(manager().authContext())
    }

    @Test
    fun aTokenAboutToExpireIsRefreshedBeforeItIsUsed() = runBlocking {
        seed(me, expiresInMillis = 10_000)
        storage.activeDid = me
        server.on("token", """{"access_token":"fresh","expires_in":3600,"sub":"$me"}""")

        val context = manager().authContext()

        assertEquals("fresh", context!!.accessToken)
        assertEquals(1, server.countOf("token"))
    }

    @Test
    fun aTokenWithPlentyOfLifeLeftIsNotRefreshed() = runBlocking {
        seed(me)
        storage.activeDid = me

        manager().authContext()

        assertEquals(0, server.countOf("token"))
    }

    // MARK: - Refresh

    @Test
    fun arefreshStoresTheNewTokenAndKeepsTheOldRefreshTokenWhenNoneComesBack() = runBlocking {
        seed(me)
        storage.activeDid = me
        server.on("token", """{"access_token":"fresh","expires_in":3600}""")

        manager().refresh()

        assertEquals("fresh", storage.accessToken(me))
        assertEquals(
            "A response with no refresh token must not delete the one we hold",
            "refresh-$me",
            storage.refreshToken(me),
        )
    }

    @Test
    fun concurrentRefreshesAreCoalescedIntoOneRequest() = runBlocking {
        seed(me)
        storage.activeDid = me
        server.on("token", """{"access_token":"fresh","expires_in":3600}""")
        val auth = manager()

        coroutineScope {
            listOf(
                async { auth.refresh() },
                async { auth.refresh() },
                async { auth.refresh() },
            ).forEach { it.await() }
        }

        assertEquals(1, server.countOf("token"))
    }

    @Test
    fun aRefreshGoesToTheAccountsOwnTokenEndpointWithItsKey() = runBlocking {
        seed(me)
        storage.activeDid = me
        server.on("token", """{"access_token":"fresh","expires_in":3600}""")

        manager().refresh()

        val call = server.lastCall("token")
        assertEquals("auth.stub.test", call.url.host)
        assertEquals("refresh_token", call.form["grant_type"])
        assertEquals("refresh-$me", call.form["refresh_token"])
        assertEquals(AuthManager.CLIENT_ID, call.form["client_id"])
        assertEquals("$STUB_ISSUER/oauth/token", call.proofClaims["htu"]!!.jsonPrimitive.content)
    }

    @Test
    fun aRefreshRejectedWithA4xxSignsTheAccountOut() = runBlocking {
        seed(me)
        storage.activeDid = me
        server.on("token", """{"error":"invalid_grant"}""", code = 400)
        val auth = manager()

        assertFails<XrpcException.Http> { auth.refresh() }

        assertFalse(auth.state.value.isAuthenticated)
        assertNull(storage.accessToken(me))
    }

    @Test
    fun aRefreshThatDiesOnA5xxLeavesTheCredentialsAlone() = runBlocking {
        seed(me)
        storage.activeDid = me
        server.on("token", """{"error":"oops"}""", code = 503)
        val auth = manager()

        assertFails<XrpcException.Http> { auth.refresh() }

        assertTrue("A bad minute on the server is not a dead grant", auth.state.value.isAuthenticated)
        assertEquals("access-$me", storage.accessToken(me))
    }

    @Test
    fun refreshingWithNoSessionIsUnauthorized() = runBlocking<Unit> {
        assertFails<XrpcException.Unauthorized> { manager().refresh() }
    }

    // MARK: - Sign-in

    @Test
    fun beginningALoginDiscoversTheServerAndRunsParThere() = runBlocking {
        server.stubSignIn()

        val url = manager().beginLogin("@me.grain.social")

        assertTrue(url.startsWith("$STUB_ISSUER/oauth/authorize?"))
        assertTrue(url.contains("request_uri=urn"))
        assertEquals(AuthManager.CLIENT_ID, url.toHttpUrl().queryParameter("client_id"))
        assertEquals("me.grain.social", server.lastCall("atproto-did").url.host)
        assertEquals("plc.directory", server.lastCall(me).url.host)
        val par = server.lastCall("par")
        assertEquals("auth.stub.test", par.url.host)
        val form = par.form
        assertEquals("S256", form["code_challenge_method"])
        assertEquals("code", form["response_type"])
        assertEquals("me.grain.social", form["login_hint"])
        assertEquals("https://grain.social/oauth/app-client-metadata.json", form["client_id"])
        assertEquals("social.grain:/oauth/callback", form["redirect_uri"])
        assertEquals(AuthManager.REQUIRED_SCOPES.joinToString(" "), form["scope"])
        assertTrue(form["state"]!!.isNotEmpty())
        assertEquals("POST", par.proofClaims["htm"]!!.jsonPrimitive.content)
    }

    @Test
    fun aHandleWithNoWellKnownFallsBackToTheBlueskyResolver() = runBlocking {
        server.stubSignIn()
            .on("atproto-did", "not found", code = 404)
            .on("com.atproto.identity.resolveHandle", """{"did":"$me"}""")

        manager().beginLogin("me.grain.social")

        assertEquals("public.api.bsky.app", server.lastCall("com.atproto.identity.resolveHandle").url.host)
        assertEquals(1, server.countOf("par"))
    }

    @Test
    fun aDidTypedDirectlyIsNotResolvedAsAHandle() = runBlocking {
        server.stubSignIn()

        manager().beginLogin(me)

        assertEquals(0, server.countOf("atproto-did"))
        assertEquals("The handle comes from the DID document", "me.grain.social", server.lastCall("par").form["login_hint"])
    }

    @Test
    fun aDidWebDocumentIsFetchedFromItsHost() = runBlocking {
        val web = "did:web:alice.example.test"
        server.stubSignIn()
            .on("did.json", didDocument(web, "alice.example.test", STUB_BASE_URL))

        manager().beginLogin(web)

        assertEquals("alice.example.test", server.lastCall("did.json").url.host)
    }

    @Test
    fun aHandleNobodyResolvesSaysSo() = runBlocking {
        server.stubSignIn()
            .on("atproto-did", "", code = 404)
            .on("com.atproto.identity.resolveHandle", """{"error":"InvalidRequest"}""", code = 400)

        val error = assertFails<XrpcException.AuthorizationFailed> { manager().beginLogin("nobody.test") }

        assertTrue(error.message!!.contains("nobody.test"))
        assertEquals(0, server.countOf("par"))
    }

    @Test
    fun anAuthorizationServerThatNamesAnotherIssuerIsRefused() = runBlocking {
        server.stubSignIn()
            .on("oauth-authorization-server", authServerMetadata("https://impostor.test"))

        assertFails<XrpcException.AuthorizationFailed> { manager().beginLogin("me.grain.social") }
        assertEquals(0, server.countOf("par"))
    }

    @Test
    fun anEmptyHandleIsRefusedBeforeAnythingIsAsked() = runBlocking {
        assertFails<XrpcException.AuthorizationFailed> { manager().beginLogin("  ") }
        assertTrue(server.calls.isEmpty())
    }

    /** The authorization server answers the first proof of a session with a 400. */
    @Test
    fun parReplaysOnceWithTheNonceTheServerAsksFor() = runBlocking {
        server.stubSignIn()
            .on(
                "par",
                """{"error":"use_dpop_nonce"}""",
                code = 400,
                headers = mapOf("DPoP-Nonce" to "n1"),
            )
            .then("par", """{"request_uri":"urn:abc"}""")

        manager().beginLogin("me.grain.social")

        assertEquals(2, server.countOf("par"))
        assertEquals("n1", server.lastCall("par").proofClaims["nonce"]!!.jsonPrimitive.content)
    }

    @Test
    fun aParThatFailsOutrightIsReportedAsAnHttpError() = runBlocking<Unit> {
        server.stubSignIn().on("par", """{"error":"nope"}""", code = 500)

        assertFails<XrpcException.Http> { manager().beginLogin("me.grain.social") }
    }

    @Test
    fun theCallbackExchangesTheCodeAndStartsTheSession() = runBlocking {
        server.stubSignIn().on(
            "token",
            """{"access_token":"a1","refresh_token":"r1","expires_in":3600,"sub":"$me","scope":"atproto"}""",
        )
        val auth = manager()
        auth.beginLogin("me.grain.social")

        auth.completeLogin(server.callback())

        assertTrue(auth.state.value.isAuthenticated)
        assertEquals(me, auth.state.value.did)
        assertEquals("me.grain.social", auth.state.value.handle)
        assertEquals(me, storage.activeDid)
        assertEquals(stubAccountServer(), storage.server(me))
        assertEquals("atproto", storage.scope(me))
        val token = server.lastCall("token")
        assertEquals("auth.stub.test", token.url.host)
        assertTrue(token.form["code_verifier"]!!.isNotEmpty())
        assertEquals("abc123", token.form["code"])
        assertEquals(AuthManager.CLIENT_ID, token.form["client_id"])
        assertNull("The pending sign-in is spent", storage.pendingLogin())

        val context = auth.authContext()!!
        assertEquals(STUB_BASE_URL, context.pdsUrl)
        assertEquals(me, context.did)
    }

    @Test
    fun aSignInSurvivesTheProcessBeingRecreatedWhileTheBrowserIsOpen() = runBlocking {
        server.stubSignIn().on("token", """{"access_token":"a1","refresh_token":"r1","sub":"$me"}""")
        manager().beginLogin("me.grain.social")

        val recreated = manager()
        recreated.completeLogin(server.callback())

        assertTrue(recreated.state.value.isAuthenticated)
        assertEquals(me, recreated.state.value.did)
    }

    @Test
    fun aTokenForAnotherAccountThanTheOneNamedIsRefused() = runBlocking {
        server.stubSignIn().on("token", """{"access_token":"a1","refresh_token":"r1","sub":"$other"}""")
        val auth = manager()
        auth.beginLogin("me.grain.social")

        assertFails<XrpcException.AuthorizationFailed> { auth.completeLogin(server.callback()) }

        assertFalse(auth.state.value.isAuthenticated)
        assertNull(storage.accessToken(other))
    }

    @Test
    fun aCallbackWithTheWrongStateIsRefused() = runBlocking {
        server.stubSignIn()
        val auth = manager()
        auth.beginLogin("me.grain.social")

        assertFails<XrpcException.AuthorizationFailed> { auth.completeLogin(server.callback(state = "forged")) }
        assertEquals(0, server.countOf("token"))
    }

    @Test
    fun aCallbackFromAnotherIssuerIsRefused() = runBlocking {
        server.stubSignIn()
        val auth = manager()
        auth.beginLogin("me.grain.social")

        assertFails<XrpcException.AuthorizationFailed> {
            auth.completeLogin(server.callback(iss = "https://impostor.test"))
        }
        assertEquals(0, server.countOf("token"))
    }

    @Test
    fun aCallbackWithNoIssuerIsStillAccepted() = runBlocking {
        server.stubSignIn().on("token", """{"access_token":"a1","refresh_token":"r1","sub":"$me"}""")
        val auth = manager()
        auth.beginLogin("me.grain.social")

        auth.completeLogin(server.callback(iss = null))

        assertTrue(auth.state.value.isAuthenticated)
    }

    @Test
    fun aDeniedAuthorizationIsACancellationRatherThanAFailure() = runBlocking {
        server.stubSignIn()
        val auth = manager()
        auth.beginLogin("me.grain.social")

        assertFails<XrpcException.AuthorizationDenied> {
            auth.completeLogin(server.callback(code = null, extra = "error=access_denied"))
        }
        assertFalse(auth.state.value.isAuthenticated)
    }

    @Test
    fun anyOtherOauthErrorCarriesItsDescription() = runBlocking {
        server.stubSignIn()
        val auth = manager()
        auth.beginLogin("me.grain.social")

        val error = assertFails<XrpcException.AuthorizationFailed> {
            auth.completeLogin(server.callback(code = null, extra = "error=server_error&error_description=Boom"))
        }
        assertEquals("Boom", error.message)
    }

    @Test
    fun aCallbackWithNoCodeAtAllIsAFailure() = runBlocking<Unit> {
        server.stubSignIn()
        val auth = manager()
        auth.beginLogin("me.grain.social")

        assertFails<XrpcException.AuthorizationFailed> {
            auth.completeLogin(server.callback(code = null))
        }
    }

    @Test
    fun aCallbackWithNoSignInInFlightIsUnauthorized() = runBlocking<Unit> {
        assertFails<XrpcException.Unauthorized> {
            manager().completeLogin(server.callback(state = "s"))
        }
    }

    @Test
    fun theRedirectIsRecognisedByItsSchemeAndPath() {
        assertTrue(AuthManager.isCallback(android.net.Uri.parse("social.grain:/oauth/callback?code=x")))
        assertFalse(AuthManager.isCallback(android.net.Uri.parse("grain://oauth/callback?code=x")))
        assertFalse(AuthManager.isCallback(android.net.Uri.parse("social.grain:/profile/alice")))
    }

    // MARK: - Sign-up

    @Test
    fun creatingAnAccountAsksTheSignUpServerForItsSignUpPage() = runBlocking {
        server.stubSignIn()

        manager().beginLogin(createAccount = true)

        assertEquals("selfhosted.social", server.lastCall("oauth-protected-resource").url.host)
        val form = server.lastCall("par").form
        assertEquals("create", form["prompt"])
        assertNull(form["login_hint"])
        assertEquals(0, server.countOf("atproto-did"))
    }

    @Test
    fun aNewAccountIsAcceptedWhenItsPdsSignsInWhereItWasMade() = runBlocking {
        server.stubSignIn().on("token", """{"access_token":"a1","refresh_token":"r1","sub":"$me"}""")
        val auth = manager()
        auth.beginLogin(createAccount = true)

        auth.completeLogin(server.callback())

        assertTrue(auth.state.value.isAuthenticated)
        assertEquals("me.grain.social", auth.state.value.handle)
        assertEquals(STUB_BASE_URL, storage.server(me)!!.pds)
    }

    @Test
    fun aNewAccountWhosePdsSignsInElsewhereIsRefused() = runBlocking {
        server.stubSignIn().on("token", """{"access_token":"a1","refresh_token":"r1","sub":"$me"}""")
        val auth = manager()
        auth.beginLogin(createAccount = true)
        // From here on the new account's PDS names a different server.
        server
            .on("oauth-protected-resource", """{"authorization_servers":["https://elsewhere.test"]}""")
            .on("oauth-authorization-server", authServerMetadata("https://elsewhere.test"))

        assertFails<XrpcException.AuthorizationFailed> { auth.completeLogin(server.callback()) }
        assertFalse(auth.state.value.isAuthenticated)
        assertNull(storage.accessToken(me))
    }

    // MARK: - Sessions from before

    @Test
    fun aSessionWithNoServerOnRecordIsDroppedAndTheLoginScreenSaysWhy() {
        // A session with tokens and a key but no PDS or issuer on record.
        storage.storeDpopKey(me, DPoP.createEphemeral().encodedKeyPair())
        storage.storeTokens(me, "access", "refresh", "me.test", System.currentTimeMillis() + 3_600_000, null)
        storage.upsertAccount(StoredAccount(me, "me.test", null))
        storage.activeDid = me

        val auth = manager()

        assertFalse(auth.state.value.isAuthenticated)
        assertEquals(AuthManager.UPDATED_REAUTH_REASON, auth.state.value.reauthReason)
        assertTrue(storage.accounts.isEmpty())
        assertNull(storage.accessToken(me))

        val next = manager()
        assertNull("The reason is said once", next.state.value.reauthReason)
    }

    @Test
    fun aSessionWithItsServerOnRecordIsKept() {
        seed(me)
        storage.activeDid = me

        val auth = manager()

        assertTrue(auth.state.value.isAuthenticated)
        assertNull(auth.state.value.reauthReason)
    }

    // MARK: - Several accounts

    @Test
    fun switchingAccountsMovesTheWholeSessionAcross() {
        seed(me)
        seed(other, handle = "other.test")
        storage.activeDid = me
        val auth = manager()

        auth.switchTo(other)

        assertEquals(other, auth.state.value.did)
        assertEquals("other.test", auth.state.value.handle)
        assertEquals(other, storage.activeDid)
    }

    @Test
    fun switchingToAnAccountWithNoCredentialsDoesNothing() {
        seed(me)
        storage.activeDid = me
        val auth = manager()

        auth.switchTo("did:plc:stranger")

        assertEquals(me, auth.state.value.did)
    }

    @Test
    fun signingOutHandsTheSessionToWhicheverAccountIsLeft() {
        seed(me)
        seed(other, handle = "other.test")
        storage.activeDid = me
        val auth = manager()

        auth.signOut()

        assertTrue(auth.state.value.isAuthenticated)
        assertEquals(other, auth.state.value.did)
    }

    @Test
    fun signingOutOfTheLastAccountLandsOnTheLoginScreen() {
        seed(me)
        storage.activeDid = me
        val auth = manager()

        auth.signOut()

        assertFalse(auth.state.value.isAuthenticated)
        assertTrue(auth.state.value.accounts.isEmpty())
        assertNull(storage.accessToken(me))
    }

    @Test
    fun signingOutRevokesTheRefreshTokenAtTheAccountsServer() {
        seed(me)
        storage.activeDid = me
        val auth = manager()

        auth.signOut()

        val deadline = System.currentTimeMillis() + 5_000
        while (server.countOf("revoke") == 0 && System.currentTimeMillis() < deadline) Thread.sleep(20)
        val revoke = server.lastCall("revoke")
        assertEquals("auth.stub.test", revoke.url.host)
        assertEquals("refresh-$me", revoke.form["token"])
        assertEquals(AuthManager.CLIENT_ID, revoke.form["client_id"])
        assertNotNull(revoke.dpopProof)
    }

    @Test
    fun aRevocationThatFailsStillSignsOut() {
        seed(me)
        storage.activeDid = me
        server.on("revoke", """{"error":"server_error"}""", code = 500)
        val auth = manager()

        auth.signOut()

        assertFalse(auth.state.value.isAuthenticated)
        assertNull(storage.accessToken(me))
    }

    @Test
    fun aRefreshRejectedForGoodDoesNotTryToRevoke() = runBlocking {
        seed(me)
        storage.activeDid = me
        server.on("token", """{"error":"invalid_grant"}""", code = 400)

        assertFails<XrpcException.Http> { manager().refresh() }
        Thread.sleep(200)

        assertEquals(0, server.countOf("revoke"))
    }

    @Test
    fun signingOutWithNobodySignedInIsANoOp() {
        val auth = manager()
        auth.signOut()
        assertFalse(auth.state.value.isAuthenticated)
    }

    // MARK: - Profile details

    @Test
    fun theAvatarAndHandleCanBeFilledInAfterTheProfileLands() {
        seed(me)
        storage.activeDid = me
        val auth = manager()

        auth.updateAvatar("https://cdn.grain.test/me.jpg")
        auth.updateHandle("renamed.test")

        assertEquals("https://cdn.grain.test/me.jpg", auth.state.value.avatar)
        assertEquals("renamed.test", auth.state.value.handle)
        assertEquals("https://cdn.grain.test/me.jpg", storage.avatar(me))
        assertEquals("renamed.test", storage.accounts.single().handle)
    }

    @Test
    fun profileDetailsForNobodyAreDroppedRatherThanStored() {
        val auth = manager()
        auth.updateAvatar("https://cdn.grain.test/nobody.jpg")
        auth.updateHandle("nobody.test")

        assertNull(auth.state.value.avatar)
        assertTrue(storage.accounts.isEmpty())
    }

    @Test
    fun theReauthReasonCanBeCleared() {
        val auth = manager()
        auth.clearReauthReason()
        assertNull(auth.state.value.reauthReason)
    }

    @Test
    fun theClientItBuildsRefreshesTheTokenOnA401() = runBlocking {
        seed(me)
        storage.activeDid = me
        server
            .on("social.grain.feed.getFeed", """{"error":"expired"}""", code = 401)
            .then("social.grain.feed.getFeed", """{"items":[]}""")
            .on("token", """{"access_token":"fresh","expires_in":3600}""")
        val auth = manager()

        auth.makeClient().getFeed(feed = "recent", auth = auth.authContext())

        assertEquals(2, server.countOf("social.grain.feed.getFeed"))
        assertEquals("DPoP fresh", server.lastCall("social.grain.feed.getFeed").authorization)
        assertEquals("The call goes to the account's PDS", "stub.grain.test", server.lastCall("social.grain.feed.getFeed").url.host)
        assertEquals(AuthManager.APPVIEW_PROXY, server.lastCall("social.grain.feed.getFeed").headers["atproto-proxy"])
    }

    @Test
    fun theRequiredScopeListNamesEveryCollectionTheAppWritesTo() {
        assertTrue(AuthManager.REQUIRED_SCOPES.contains("atproto"))
        assertTrue(AuthManager.REQUIRED_SCOPES.contains("repo:social.grain.gallery"))
        assertTrue(AuthManager.REQUIRED_SCOPES.contains("repo:social.grain.story"))
    }

    @Test
    fun theScopeIsExactlyWhatTheClientMetadataRegisters() {
        assertEquals(
            "atproto blob:image/* repo:social.grain.gallery repo:social.grain.gallery.item " +
                "repo:social.grain.photo repo:social.grain.photo.exif repo:social.grain.actor.profile " +
                "repo:social.grain.graph.follow repo:social.grain.favorite repo:social.grain.comment " +
                "repo:social.grain.story repo:social.grain.graph.block " +
                "repo:app.bsky.feed.post?action=create&action=update " +
                "rpc:*?aud=did:web:grain.social%23grain_appview",
            AuthManager.REQUIRED_SCOPES.joinToString(" "),
        )
    }

    @Test
    fun theClientIdRedirectAndProxyFollowTheServerUrl() {
        assertEquals("https://grain.social/oauth/app-client-metadata.json", AuthManager.CLIENT_ID)
        assertEquals("social.grain:/oauth/callback", AuthManager.REDIRECT_URI)
        assertEquals("did:web:grain.social#grain_appview", AuthManager.APPVIEW_PROXY)
        assertEquals("did:web:localhost%3A3000", social.grain.data.api.ServiceIdentity.serviceDid("http://localhost:3000"))
        assertEquals(
            "rpc:*?aud=did:web:localhost%253A3000%23grain_appview",
            social.grain.data.api.ServiceIdentity.rpcScope("http://localhost:3000"),
        )
        assertEquals(
            "did:web:localhost%3A3000#grain_appview",
            social.grain.data.api.ServiceIdentity.appviewProxy("http://localhost:3000"),
        )
        assertEquals(
            "rpc:*?aud=did:web:grain.social%23grain_appview",
            social.grain.data.api.ServiceIdentity.rpcScope("https://grain.social"),
        )
        assertEquals("dev.tunnel.example", social.grain.data.api.ServiceIdentity.redirectScheme("https://example.tunnel.dev"))
        assertEquals("https://x.test:8443", social.grain.data.api.DpopNonces.origin("https://x.test:8443/a?b"))
        assertEquals("https://x.test", social.grain.data.api.DpopNonces.origin("https://x.test:443/a"))
    }
}