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

grain.social/grain-ios

1 collaborator
0
1
main
199 lines · 8.6 KB Raw History
import CryptoKit
@testable import Grain
import XCTest

/// The grain profile the app makes once an account signs in, from its
/// Bluesky profile or bare, as grain-serve makes one for the web.
final class ProfileSeedTests: GrainTestCase {
    private var log: SeedLog!

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

    private static let avatar = #"{"$type": "blob", "ref": {"$link": "bafk"}, "mimeType": "image/png", "size": 9}"#
    private static let notFound = #"{"error": "RecordNotFound", "message": "Could not locate record"}"#

    private let auth = AuthContext(
        accessToken: "token",
        dpop: DPoP(privateKey: P256.Signing.PrivateKey()),
        did: "did:plc:seed",
        pds: URL(string: "https://pds.test")!
    )

    /// Serve `grain` and `bluesky` as the account's profiles, nil for none,
    /// and answer each write.
    private func makeClient(grain: String?, bluesky: String?, writeStatus: Int = 200) -> XRPCClient {
        let log = SeedLog()
        self.log = log
        MockURLProtocol.handler = { request in
            log.record(request)
            let path = request.url?.path ?? ""
            let collection = URLComponents(url: request.url!, resolvingAgainstBaseURL: false)?
                .queryItems?.first { $0.name == "collection" }?.value
            var status = 200
            var body = "{}"
            if path.hasSuffix("getRecord") {
                let found = collection == ProfileSeed.grain ? grain : bluesky
                if let found {
                    body = #"{"uri": "at://did:plc:seed/\#(collection ?? "")/self", "cid": "bafyold", "value": \#(found)}"#
                } else {
                    (status, body) = (400, Self.notFound)
                }
            } else if path.hasSuffix("createRecord") || path.hasSuffix("putRecord") {
                status = writeStatus
                body = #"{"uri": "at://did:plc:seed/social.grain.actor.profile/self", "cid": "bafynew"}"#
            }
            let response = HTTPURLResponse(url: request.url!, statusCode: status, httpVersion: nil, headerFields: nil)!
            return (Data(body.utf8), response)
        }
        return XRPCClient(baseURL: URL(string: "https://grain.test")!, session: MockURLProtocol.mockSession())
    }

    func testANamedGrainProfileIsLeftAsItIs() async throws {
        let client = makeClient(grain: #"{"displayName": "Already"}"#, bluesky: #"{"displayName": "Alice"}"#)

        let outcome = try await ProfileSeed.seed(client: client, auth: auth)

        XCTAssertEqual(outcome, .named)
        XCTAssertNil(log.write)
    }

    /// No Bluesky profile and no grain profile: a bare one is made at
    /// `self`, its `createdAt` alone, so grain-serve tracks the account.
    func testWithNeitherProfileABareOneIsMadeAtSelf() async throws {
        let client = makeClient(grain: nil, bluesky: nil)

        let outcome = try await ProfileSeed.seed(client: client, auth: auth)

        XCTAssertEqual(outcome, .made)
        let write = try XCTUnwrap(log.write)
        XCTAssertTrue(write.path.hasSuffix("com.atproto.repo.createRecord"))
        XCTAssertEqual(write.body["rkey"] as? String, "self")
        XCTAssertEqual(write.body["collection"] as? String, "social.grain.actor.profile")
        let record = try XCTUnwrap(write.body["record"] as? [String: Any])
        XCTAssertEqual(Set(record.keys), ["$type", "createdAt"])
        XCTAssertEqual(record["$type"] as? String, "social.grain.actor.profile")
        XCTAssertNotNil(DateFormatting.parse(record["createdAt"] as? String ?? ""))
        XCTAssertNotNil(log.indexProxy)
    }

    /// No Bluesky profile and an unnamed grain profile: it is left as it is.
    func testWithNoBlueskyProfileAnUnnamedGrainProfileIsLeft() async throws {
        let client = makeClient(grain: #"{"createdAt": "2025-01-01T00:00:00.000Z"}"#, bluesky: nil)

        let outcome = try await ProfileSeed.seed(client: client, auth: auth)

        XCTAssertEqual(outcome, .noBluesky)
        XCTAssertNil(log.write)
    }

    /// No grain profile: one is made at `self`, from the Bluesky one, the
    /// avatar's blob as it names it, then indexed through the proxy.
    func testWithNoGrainProfileOneIsMadeAtSelf() async throws {
        let client = makeClient(grain: nil, bluesky: #"{"displayName": "Alice", "description": "Photos", "avatar": \#(Self.avatar), "banner": \#(Self.avatar)}"#)

        let outcome = try await ProfileSeed.seed(client: client, auth: auth)

        XCTAssertEqual(outcome, .made)
        let write = try XCTUnwrap(log.write)
        XCTAssertTrue(write.path.hasSuffix("com.atproto.repo.createRecord"))
        XCTAssertEqual(write.body["rkey"] as? String, "self")
        XCTAssertEqual(write.body["collection"] as? String, "social.grain.actor.profile")
        let record = try XCTUnwrap(write.body["record"] as? [String: Any])
        XCTAssertEqual(record["$type"] as? String, "social.grain.actor.profile")
        XCTAssertEqual(record["displayName"] as? String, "Alice")
        XCTAssertEqual(record["description"] as? String, "Photos")
        XCTAssertEqual((record["avatar"] as? [String: Any])?["ref"] as? [String: String], ["$link": "bafk"])
        XCTAssertNil(record["banner"])
        XCTAssertNotNil(DateFormatting.parse(record["createdAt"] as? String ?? ""))
        XCTAssertEqual(log.indexProxy, "\(AuthManager.appviewDID)#grain_appview")
    }

    /// A grain profile with no name is put over, swapped on the CID read,
    /// keeping when it says it was made.
    func testAnUnnamedGrainProfileIsPutOverOnItsCID() async throws {
        let client = makeClient(
            grain: #"{"createdAt": "2025-01-01T00:00:00.000Z", "description": "old"}"#,
            bluesky: #"{"displayName": "Alice", "description": ""}"#
        )

        let outcome = try await ProfileSeed.seed(client: client, auth: auth)

        XCTAssertEqual(outcome, .put)
        let write = try XCTUnwrap(log.write)
        XCTAssertTrue(write.path.hasSuffix("com.atproto.repo.putRecord"))
        XCTAssertEqual(write.body["swapRecord"] as? String, "bafyold")
        XCTAssertEqual(write.body["rkey"] as? String, "self")
        let record = try XCTUnwrap(write.body["record"] as? [String: Any])
        XCTAssertEqual(record["createdAt"] as? String, "2025-01-01T00:00:00.000Z")
        XCTAssertEqual(record["displayName"] as? String, "Alice")
        XCTAssertNil(record["description"], "an empty description is left out")
        XCTAssertNotNil(log.indexProxy)
    }

    /// The background run swallows a failure, so a sign-in never fails by it.
    func testAFailedWriteNeverEscapesTheRun() async {
        let client = makeClient(grain: nil, bluesky: #"{"displayName": "Alice"}"#, writeStatus: 500)

        await ProfileSeed.run(client: client, auth: auth)

        XCTAssertNotNil(log.write)
    }

    func testOnlyRecordNotFoundCountsAsNoRecord() {
        XCTAssertTrue(ProfileSeed.isNotFound(Data(Self.notFound.utf8)))
        XCTAssertFalse(ProfileSeed.isNotFound(Data(#"{"error": "InternalServerError"}"#.utf8)))
        XCTAssertFalse(ProfileSeed.isNotFound(nil))
    }
}

/// The requests the mock saw, from outside the main actor.
private final class SeedLog: @unchecked Sendable {
    struct Write {
        let path: String
        let body: [String: Any]
    }

    private let lock = NSLock()
    private var writes: [Write] = []
    private var proxies: [String?] = []

    func record(_ request: URLRequest) {
        let path = request.url?.path ?? ""
        lock.withLock {
            if path.hasSuffix("createRecord") || path.hasSuffix("putRecord") {
                writes.append(Write(path: path, body: Self.json(request) ?? [:]))
            } else if path.hasSuffix("indexRecords") {
                proxies.append(request.value(forHTTPHeaderField: "atproto-proxy"))
            }
        }
    }

    var write: Write? {
        lock.withLock { writes.last }
    }

    var indexProxy: String? {
        lock.withLock { proxies.last ?? nil }
    }

    private static func json(_ request: URLRequest) -> [String: Any]? {
        var data = request.httpBody
        if data == nil, let stream = request.httpBodyStream {
            stream.open()
            defer { stream.close() }
            var collected = Data()
            let buffer = UnsafeMutablePointer<UInt8>.allocate(capacity: 4096)
            defer { buffer.deallocate() }
            while stream.hasBytesAvailable {
                let read = stream.read(buffer, maxLength: 4096)
                guard read > 0 else { break }
                collected.append(buffer, count: read)
            }
            data = collected
        }
        return data.flatMap { try? JSONSerialization.jsonObject(with: $0) as? [String: Any] }
    }
}