1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
@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: []
)
}
}