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

grain.social/grain-ios

1 collaborator
0
1
main
151 lines · 6.3 KB Raw History
@testable import Grain
import XCTest

/// Storage-level guarantees the account switcher rests on: two accounts never
/// read each other's credentials or cached content, and signing one out leaves
/// the other intact.
///
/// Every test uses synthetic DIDs — the test host shares its Keychain and
/// UserDefaults with the app installed on the simulator.
@MainActor
final class AccountSwitchingTests: GrainTestCase {
    private let alice = "did:plc:switchtest-alice"
    private let bob = "did:plc:switchtest-bob"

    override func tearDown() {
        TokenStorage.removeAccount(alice)
        TokenStorage.removeAccount(bob)
        AccountScopedStorage.purge(did: alice)
        AccountScopedStorage.purge(did: bob)
        super.tearDown()
    }

    private func signIn(_ did: String, handle: String, token: String) {
        TokenStorage.storeTokens(
            did: did,
            accessToken: token,
            refreshToken: "\(token)-refresh",
            handle: handle,
            expiresAt: Date().addingTimeInterval(3600),
            scope: "atproto"
        )
        TokenStorage.upsertAccount(StoredAccount(did: did, handle: handle, avatar: nil))
    }

    func testCredentialsAreIsolatedPerAccount() {
        signIn(alice, handle: "alice.test", token: "alice-token")
        signIn(bob, handle: "bob.test", token: "bob-token")

        XCTAssertEqual(TokenStorage.accessToken(for: alice), "alice-token")
        XCTAssertEqual(TokenStorage.accessToken(for: bob), "bob-token")
        XCTAssertEqual(TokenStorage.handle(for: alice), "alice.test")
        XCTAssertEqual(TokenStorage.handle(for: bob), "bob.test")
    }

    func testSigningOutOneAccountLeavesTheOtherSignedIn() {
        signIn(alice, handle: "alice.test", token: "alice-token")
        signIn(bob, handle: "bob.test", token: "bob-token")

        TokenStorage.removeAccount(alice)

        XCTAssertFalse(TokenStorage.hasCredentials(for: alice))
        XCTAssertTrue(TokenStorage.hasCredentials(for: bob))
        XCTAssertFalse(TokenStorage.accounts.contains { $0.did == alice })
        XCTAssertTrue(TokenStorage.accounts.contains { $0.did == bob })
    }

    func testUpsertKeepsDetailsAnEmptyTokenResponseWouldWipe() {
        TokenStorage.upsertAccount(StoredAccount(did: alice, handle: "alice.test", avatar: "https://cdn/alice.jpg"))
        // A refresh knows the DID but carries no profile details.
        TokenStorage.upsertAccount(StoredAccount(did: alice, handle: nil, avatar: nil))

        let stored = TokenStorage.accounts.first { $0.did == alice }
        XCTAssertEqual(stored?.handle, "alice.test")
        XCTAssertEqual(stored?.avatar, "https://cdn/alice.jpg")
    }

    func testDPoPKeysAreDistinctPerAccountAndSurviveTheOtherSigningOut() throws {
        let aliceKey = try DPoP.loadOrCreate(for: alice)
        let bobKey = try DPoP.loadOrCreate(for: bob)
        defer {
            try? DPoP.clearKey(for: alice)
            try? DPoP.clearKey(for: bob)
        }

        XCTAssertNotEqual(aliceKey.thumbprint, bobKey.thumbprint)
        // Reloading must return the same key: the access token is bound to it.
        XCTAssertEqual(try DPoP.loadOrCreate(for: alice).thumbprint, aliceKey.thumbprint)

        try DPoP.clearKey(for: alice)
        XCTAssertEqual(try DPoP.loadOrCreate(for: bob).thumbprint, bobKey.thumbprint)
    }

    func testScopedDefaultsKeysDoNotCollide() {
        XCTAssertEqual(AccountScopedStorage.key("viewedStoryUris", did: alice), "viewedStoryUris::\(alice)")
        XCTAssertNotEqual(
            AccountScopedStorage.key("viewedStoryUris", did: alice),
            AccountScopedStorage.key("viewedStoryUris", did: bob)
        )
        // No account (signed out) falls back to the bare key.
        XCTAssertEqual(AccountScopedStorage.key("viewedStoryUris", did: nil), "viewedStoryUris")
    }

    func testViewedStoriesAreTrackedPerAccount() {
        let storage = ViewedStoryStorage(did: alice)
        storage.markViewed(uri: "at://story-1", authorDid: "did:plc:author", createdAt: "2026-08-16T00:00:00Z")
        XCTAssertTrue(storage.isViewed(uri: "at://story-1"))

        storage.switchAccount(did: bob)
        XCTAssertFalse(storage.isViewed(uri: "at://story-1"), "Bob shouldn't inherit Alice's watch history")

        storage.switchAccount(did: alice)
        XCTAssertTrue(storage.isViewed(uri: "at://story-1"), "Switching back restores Alice's history")
    }

    func testPurgeRemovesOnlyTheSignedOutAccountsViewedStories() {
        let storage = ViewedStoryStorage(did: alice)
        storage.markViewed(uri: "at://alice-story", authorDid: "did:plc:author", createdAt: "2026-08-16T00:00:00Z")
        storage.switchAccount(did: bob)
        storage.markViewed(uri: "at://bob-story", authorDid: "did:plc:author", createdAt: "2026-08-16T00:00:00Z")
        storage.switchAccount(did: alice)

        AccountScopedStorage.purge(did: alice)

        XCTAssertFalse(ViewedStoryStorage(did: alice).isViewed(uri: "at://alice-story"))
        XCTAssertTrue(ViewedStoryStorage(did: bob).isViewed(uri: "at://bob-story"))
    }

    func testUploadCenterOnlySurfacesDraftsOwnedByTheActiveAccount() {
        let root = URL(fileURLWithPath: NSTemporaryDirectory())
            .appendingPathComponent("switchtest-drafts-\(UUID().uuidString)", isDirectory: true)
        defer { try? FileManager.default.removeItem(at: root) }
        let store = GalleryDraftStore(root: root)
        store.save(draft(repo: alice, title: "Alice's gallery"))
        store.save(draft(repo: bob, title: "Bob's gallery"))

        let center = GalleryUploadCenter(store: store)
        center.accountChanged(to: alice)
        XCTAssertEqual(center.pending.map(\.title), ["Alice's gallery"])

        center.accountChanged(to: bob)
        XCTAssertEqual(center.pending.map(\.title), ["Bob's gallery"])
    }

    private func draft(repo: String, title: String) -> GalleryDraft {
        GalleryDraft(
            repo: repo,
            title: title,
            description: "",
            labels: [],
            location: nil,
            includeExif: false,
            postToBluesky: false,
            // Relative, not a literal: the store sweeps drafts older than a
            // week, so a hardcoded date turns this into a time bomb.
            createdAt: DateFormatting.nowISO(),
            galleryRkey: TID.next(),
            blueskyRkey: TID.next(),
            photos: []
        )
    }
}