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

grain.social/grain-ios

1 collaborator
0
1
main
222 lines · 9.0 KB Raw History
@testable import Grain
import XCTest

private struct Echo: Codable, Equatable {
    let value: String
}

private struct Input: Encodable {
    let key: String
}

/// The shape of a signed-in call: it goes to the account's PDS, proves
/// possession of the token's key per request, carries the nonce each origin
/// asks for, and asks the PDS to proxy Grain's own methods to the AppView.
final class PDSCallTests: GrainTestCase {
    private let baseURL = URL(string: "https://test.local")!

    override func tearDown() {
        MockURLProtocol.handler = nil
        super.tearDown()
    }

    private func makeClient() -> XRPCClient {
        XRPCClient(baseURL: baseURL, session: MockURLProtocol.mockSession())
    }

    private func respond(body: String = #"{"value": "ok"}"#, record: (@Sendable (URLRequest) -> Void)? = nil) {
        MockURLProtocol.handler = { request in
            record?(request)
            let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!
            return (Data(body.utf8), response)
        }
    }

    private func proofClaims(of proof: String?) throws -> [String: Any] {
        let parts = try XCTUnwrap(proof).split(separator: ".")
        XCTAssertEqual(parts.count, 3)
        var payload = String(parts[1]).replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
        while payload.count % 4 != 0 {
            payload += "="
        }
        let data = try XCTUnwrap(Data(base64Encoded: payload))
        return try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any])
    }

    /// A signed-in call goes to the account's PDS, not the AppView, and asks
    /// the PDS to proxy it to Grain's AppView.
    func testASignedInGrainCallGoesToThePDSThroughTheProxy() async throws {
        let dpop = DPoP(privateKey: .init())
        let pds = try XCTUnwrap(URL(string: "https://pds.example"))
        let auth = AuthContext(accessToken: "token", dpop: dpop, did: "did:plc:me", pds: pds)
        let seen = Recorder()
        respond { seen.record($0) }

        _ = try await makeClient().query("social.grain.feed.getFeed", params: ["feed": "recent"], auth: auth, as: Echo.self)

        XCTAssertEqual(seen.url?.host, "pds.example")
        XCTAssertEqual(seen.url?.path, "/xrpc/social.grain.feed.getFeed")
        XCTAssertEqual(seen.headers?["atproto-proxy"], "\(AuthManager.appviewDID)#grain_appview")
        XCTAssertEqual(seen.headers?["Authorization"], "DPoP token")
    }

    /// `com.atproto.*` is the PDS's own: no proxy.
    func testARepoCallIsNotProxied() async throws {
        let auth = try AuthContext(accessToken: "token", dpop: DPoP(privateKey: .init()), did: "did:plc:me", pds: XCTUnwrap(URL(string: "https://pds.example")))
        let seen = Recorder()
        respond(body: #"{"uri": "at://did:plc:me/social.grain.favorite/1", "cid": "c"}"#) { seen.record($0) }

        _ = try await makeClient().procedure("com.atproto.repo.createRecord", input: Input(key: "k"), auth: auth, as: CreateRecordResponse.self)

        XCTAssertEqual(seen.url?.host, "pds.example")
        XCTAssertNil(seen.headers?["atproto-proxy"])
    }

    /// Signed out, a call goes straight to the AppView with no credentials.
    func testASignedOutCallGoesToTheAppViewBare() async throws {
        let seen = Recorder()
        respond { seen.record($0) }

        _ = try await makeClient().query("social.grain.feed.getFeed", as: Echo.self)

        XCTAssertEqual(seen.url?.host, "test.local")
        XCTAssertNil(seen.headers?["Authorization"])
        XCTAssertNil(seen.headers?["DPoP"])
        XCTAssertNil(seen.headers?["atproto-proxy"])
    }

    /// The proof names the method, the URL without its query, and the token
    /// it is presented with.
    func testTheProofBindsMethodURLAndToken() async throws {
        let auth = try AuthContext(accessToken: "token", dpop: DPoP(privateKey: .init()), did: "did:plc:me", pds: XCTUnwrap(URL(string: "https://proof.example")))
        let seen = Recorder()
        respond { seen.record($0) }

        _ = try await makeClient().query("social.grain.feed.getFeed", params: ["limit": "3"], auth: auth, as: Echo.self)

        let claims = try proofClaims(of: seen.headers?["DPoP"])
        XCTAssertEqual(claims["htm"] as? String, "GET")
        XCTAssertEqual(claims["htu"] as? String, "https://proof.example/xrpc/social.grain.feed.getFeed")
        XCTAssertEqual(claims["ath"] as? String, OAuthClient.challenge(for: "token"))
    }

    /// The retry after a nonce refusal carries the nonce, and later calls to
    /// the same origin start with it.
    func testTheNonceIsSentAgainAndKeptForTheOrigin() async throws {
        let pds = try XCTUnwrap(URL(string: "https://nonce-\(UUID().uuidString.lowercased()).example"))
        let auth = AuthContext(accessToken: "token", dpop: DPoP(privateKey: .init()), did: "did:plc:me", pds: pds)
        let proofs = ProofLog()
        MockURLProtocol.handler = { request in
            let attempt = proofs.record(request.value(forHTTPHeaderField: "DPoP"))
            let refuse = attempt == 1
            let response = HTTPURLResponse(
                url: request.url!, statusCode: refuse ? 401 : 200, httpVersion: nil,
                headerFields: refuse
                    ? ["DPoP-Nonce": "fresh", "WWW-Authenticate": #"DPoP error="use_dpop_nonce""#]
                    : ["DPoP-Nonce": "fresh"]
            )!
            return (Data(#"{"value": "ok"}"#.utf8), response)
        }

        _ = try await makeClient().query("social.grain.feed.getFeed", auth: auth, as: Echo.self)
        _ = try await makeClient().query("social.grain.feed.getFeed", auth: auth, as: Echo.self)

        let all = proofs.all
        XCTAssertEqual(all.count, 3)
        XCTAssertNil(try proofClaims(of: all[0])["nonce"])
        XCTAssertEqual(try proofClaims(of: all[1])["nonce"] as? String, "fresh")
        XCTAssertEqual(try proofClaims(of: all[2])["nonce"] as? String, "fresh", "the second call starts with the kept nonce")
    }

    /// A PDS sends `DPoP-Nonce` on every answer, a 401 for an expired token
    /// included. That 401 is a refresh, not a nonce retry.
    func testAnExpiredTokenWithANonceHeaderRefreshes() async throws {
        let auth = try AuthContext(accessToken: "old", dpop: DPoP(privateKey: .init()), did: "did:plc:me", pds: XCTUnwrap(URL(string: "https://pds.example")))
        let attempts = Counter()
        MockURLProtocol.handler = { request in
            let first = attempts.next() == 1
            let response = HTTPURLResponse(
                url: request.url!, statusCode: first ? 401 : 200, httpVersion: nil,
                headerFields: first
                    ? ["DPoP-Nonce": "n", "WWW-Authenticate": #"DPoP error="invalid_token", error_description="exp""#]
                    : nil
            )!
            return (Data(#"{"value": "ok"}"#.utf8), response)
        }
        let refreshed = Counter()
        let client = XRPCClient(baseURL: baseURL, session: MockURLProtocol.mockSession()) { () -> AuthContext? in
            refreshed.next()
            return AuthContext(accessToken: "new", dpop: auth.dpop, did: auth.did, pds: auth.pds)
        }

        _ = try await client.query("social.grain.feed.getFeed", auth: auth, as: Echo.self)

        XCTAssertEqual(refreshed.count, 1)
        XCTAssertEqual(attempts.count, 2)
    }

    /// A 400 that merely carries a nonce header is an ordinary bad request.
    func testABadRequestCarryingANonceHeaderIsNotRetried() async throws {
        let auth = try AuthContext(accessToken: "t", dpop: DPoP(privateKey: .init()), did: "did:plc:me", pds: XCTUnwrap(URL(string: "https://pds.example")))
        let attempts = Counter()
        MockURLProtocol.handler = { request in
            attempts.next()
            let response = HTTPURLResponse(url: request.url!, statusCode: 400, httpVersion: nil, headerFields: ["DPoP-Nonce": "n"])!
            return (Data(#"{"error": "InvalidRequest"}"#.utf8), response)
        }

        _ = try? await makeClient().query("social.grain.feed.getFeed", auth: auth, as: Echo.self)

        XCTAssertEqual(attempts.count, 1)
    }
}

private final class Recorder: @unchecked Sendable {
    private let lock = NSLock()
    private var request: URLRequest?

    func record(_ request: URLRequest) {
        lock.withLock { self.request = request }
    }

    var url: URL? {
        lock.withLock { request?.url }
    }

    var headers: [String: String]? {
        lock.withLock { request?.allHTTPHeaderFields }
    }
}

private final class ProofLog: @unchecked Sendable {
    private let lock = NSLock()
    private var proofs: [String?] = []

    func record(_ proof: String?) -> Int {
        lock.withLock {
            proofs.append(proof)
            return proofs.count
        }
    }

    var all: [String?] {
        lock.withLock { proofs }
    }
}

private final class Counter: @unchecked Sendable {
    private let lock = NSLock()
    private var value = 0

    @discardableResult
    func next() -> Int {
        lock.withLock {
            value += 1
            return value
        }
    }

    var count: Int {
        lock.withLock { value }
    }
}