@@ -0,0 +1,752 @@
3+@testable import HyperCreditsMenubar
5+/// The instant every test pretends it is. Injected into the client, so token expiry is
6+/// a decision the test makes rather than a race against the wall clock.
7+private let fixedNow = Date(timeIntervalSince1970: 1_700_000_000)
9+final class ClaudeUsageClientTests: XCTestCase {
11+ override func setUp() {
13+ StubURLProtocol.reset()
16+ override func tearDown() {
17+ StubURLProtocol.reset()
23+ /// A client whose requests are served by `StubURLProtocol` and whose credentials
24+ /// come from memory, so no test here touches the real Keychain, the real
25+ /// `~/.claude/.credentials.json`, or the network. The backoff is short enough that
26+ /// the retry tests run in milliseconds rather than seconds.
27+ private func makeClient(
28+ store: ClaudeCredentialStoring,
29+ retryBaseDelay: Duration = .milliseconds(1)
30+ ) -> ClaudeUsageClient {
31+ let config = URLSessionConfiguration.ephemeral
32+ config.protocolClasses = [StubURLProtocol.self]
33+ return ClaudeUsageClient(
34+ session: URLSession(configuration: config),
36+ retryBaseDelay: retryBaseDelay,
41+ private func credentials(
42+ accessToken: String = "access-1",
43+ refreshToken: String = "refresh-1",
44+ expiresAt: Date = fixedNow.addingTimeInterval(3600),
45+ subscriptionType: String? = "max"
46+ ) -> ClaudeOAuthCredentials {
47+ ClaudeOAuthCredentials(
48+ claudeAiOauth: .init(
49+ accessToken: accessToken,
50+ refreshToken: refreshToken,
51+ expiresAt: Int64(expiresAt.timeIntervalSince1970 * 1000),
52+ scopes: ["user:inference"],
53+ subscriptionType: subscriptionType,
59+ /// Credentials whose access token is already spent, so a fetch has to refresh first.
60+ private func expiredCredentials() -> ClaudeOAuthCredentials {
61+ credentials(expiresAt: fixedNow.addingTimeInterval(-1))
64+ private func usageJSON(fiveHour: Int = 68, sevenDay: Int = 41) -> Data {
67+ "five_hour": {"utilization": \(fiveHour), "resets_at": "2023-11-14T23:53:20.000Z"},
68+ "seven_day": {"utilization": \(sevenDay), "resets_at": "2023-11-20T23:53:20.000Z"}
73+ private func tokenJSON(
74+ accessToken: String = "access-2",
75+ refreshToken: String? = "refresh-2",
76+ expiresIn: Int? = 3600
78+ var fields = [#""access_token": "\#(accessToken)""#]
79+ if let refreshToken { fields.append(#""refresh_token": "\#(refreshToken)""#) }
80+ if let expiresIn { fields.append(#""expires_in": \#(expiresIn)"#) }
81+ return Data("{\(fields.joined(separator: ", "))}".utf8)
84+ private func requests(to url: URL) -> [URLRequest] {
85+ StubURLProtocol.recordedRequests.filter { $0.url == url }
88+ // MARK: - Usage decoding
90+ func testDecodesUsagePayload() throws {
93+ "five_hour": {"utilization": 68.4, "resets_at": "2026-07-11T20:00:00.000Z"},
94+ "seven_day": {"utilization": 41, "resets_at": "2026-07-15T20:00:00.000Z"},
95+ "seven_day_opus": {"utilization": 12, "resets_at": null},
97+ {"percent": 41, "is_active": false, "resets_at": "2026-07-15T20:00:00.000Z"},
98+ {"percent": 68, "is_active": true, "resets_at": "2026-07-11T20:00:00.000Z"}
103+ let usage = try JSONDecoder().decode(ClaudeUsage.self, from: json)
105+ XCTAssertEqual(usage.fiveHour?.utilization, 68.4)
106+ XCTAssertEqual(usage.sevenDay?.utilization, 41)
107+ XCTAssertEqual(usage.sevenDayOpus?.utilization, 12)
108+ XCTAssertNil(usage.sevenDayOpus?.resetsAt)
109+ XCTAssertEqual(usage.limits?.count, 2)
110+ XCTAssertFalse(usage.isEmpty)
113+ func testDecodesEmptyPayload() throws {
114+ let usage = try JSONDecoder().decode(ClaudeUsage.self, from: Data("{}".utf8))
116+ XCTAssertNil(usage.fiveHour)
117+ XCTAssertNil(usage.limits)
118+ XCTAssertTrue(usage.isEmpty, "An account with no usage has nothing worth a section")
121+ func testIgnoresWindowsItDoesNotModel() throws {
122+ // Anthropic adds and retires windows without notice; an unknown one must not
123+ // take the rest of the payload down with it.
126+ "five_hour": {"utilization": 5},
127+ "tangelo": {"utilization": 99},
128+ "iguana_necktie": {"utilization": 99},
129+ "spend": {"percent": 3, "enabled": true}
133+ let usage = try JSONDecoder().decode(ClaudeUsage.self, from: json)
135+ XCTAssertEqual(usage.fiveHour?.utilization, 5)
136+ XCTAssertFalse(usage.isEmpty)
139+ func testMissingUtilizationDefaultsToZero() throws {
140+ let json = Data(#"{"five_hour": {"resets_at": "2026-07-11T20:00:00.000Z"}}"#.utf8)
142+ let usage = try JSONDecoder().decode(ClaudeUsage.self, from: json)
144+ XCTAssertEqual(usage.fiveHour?.utilization, 0)
147+ func testFractionalPercentIsRounded() throws {
148+ // The field is documented as an integer, but decoding it as one would throw on
149+ // a fractional value and lose the whole payload.
150+ let json = Data(#"{"limits": [{"percent": 68.6, "is_active": true}]}"#.utf8)
152+ let usage = try JSONDecoder().decode(ClaudeUsage.self, from: json)
154+ XCTAssertEqual(usage.activeLimit?.percent, 69)
157+ func testActiveLimitIsTheOneTheServerFlags() throws {
160+ {"percent": 41, "is_active": false},
161+ {"percent": 68, "is_active": true}
165+ let usage = try JSONDecoder().decode(ClaudeUsage.self, from: json)
167+ XCTAssertEqual(usage.activeLimit?.percent, 68)
170+ func testActiveLimitIsNilWhenNoneIsFlagged() throws {
171+ let json = Data(#"{"limits": [{"percent": 41}, {"percent": 12}]}"#.utf8)
173+ let usage = try JSONDecoder().decode(ClaudeUsage.self, from: json)
175+ XCTAssertNil(usage.activeLimit)
178+ // MARK: - Reset countdown
180+ func testResetsInFormatsHoursAndMinutes() {
181+ let window = UsageWindow(utilization: 50, resetsAt: iso(fixedNow.addingTimeInterval(3 * 3600 + 20 * 60)))
183+ XCTAssertEqual(window.resetsIn(from: fixedNow), "3h 20m")
186+ func testResetsInFormatsDaysAndHours() {
187+ let window = UsageWindow(utilization: 50, resetsAt: iso(fixedNow.addingTimeInterval(28 * 3600)))
189+ XCTAssertEqual(window.resetsIn(from: fixedNow), "1d 4h")
192+ func testResetsInFormatsMinutesAlone() {
193+ let window = UsageWindow(utilization: 50, resetsAt: iso(fixedNow.addingTimeInterval(12 * 60)))
195+ XCTAssertEqual(window.resetsIn(from: fixedNow), "12m")
198+ func testResetsInIsNilOnceTheWindowHasPassed() {
199+ let window = UsageWindow(utilization: 50, resetsAt: iso(fixedNow.addingTimeInterval(-60)))
201+ XCTAssertNil(window.resetsIn(from: fixedNow))
204+ func testResetsInIsNilWithoutATimestamp() {
205+ XCTAssertNil(UsageWindow(utilization: 0).resetsIn(from: fixedNow))
208+ func testResetsAtParsesTimestampsWithoutFractionalSeconds() {
209+ // Claude's timestamps normally carry fractional seconds; this spelling has to
210+ // parse too, rather than silently costing us the countdown.
211+ let window = UsageWindow(utilization: 50, resetsAt: "2026-07-11T20:00:00Z")
213+ XCTAssertEqual(window.resetsAtDate?.timeIntervalSince1970, 1_783_800_000)
216+ func testResetsAtIsNilForAnUnparseableTimestamp() {
217+ XCTAssertNil(UsageWindow(utilization: 50, resetsAt: "not a date").resetsAtDate)
220+ func testLimitAlsoCountsDown() {
221+ let limit = Limit(percent: 68, resetsAt: iso(fixedNow.addingTimeInterval(90 * 60)), isActive: true)
223+ XCTAssertEqual(limit.resetsIn(from: fixedNow), "1h 30m")
228+ func testFetchSendsTheOAuthHeaders() async throws {
229+ let store = FakeClaudeCredentialStore(credentials: credentials())
230+ StubURLProtocol.enqueue(.status(200, usageJSON()))
232+ let report = try await makeClient(store: store).fetchUsage()
234+ XCTAssertEqual(report.usage.fiveHour?.utilization, 68)
235+ XCTAssertEqual(report.subscriptionType, "max")
237+ let request = try XCTUnwrap(StubURLProtocol.recordedRequests.first)
238+ XCTAssertEqual(request.url, ClaudeUsageClient.usageEndpoint)
239+ XCTAssertEqual(request.value(forHTTPHeaderField: "Authorization"), "Bearer access-1")
240+ XCTAssertEqual(request.value(forHTTPHeaderField: "anthropic-beta"), "oauth-2025-04-20")
241+ XCTAssertEqual(request.value(forHTTPHeaderField: "Accept"), "application/json")
244+ func testMissingCredentialsThrowWithoutTouchingTheNetwork() async {
245+ let store = FakeClaudeCredentialStore(credentials: nil)
248+ _ = try await makeClient(store: store).fetchUsage()
249+ XCTFail("Expected noCredentials error")
250+ } catch ClaudeUsageError.noCredentials {
251+ // expected — the caller reads this as "Claude Code isn't set up here"
253+ XCTFail("Unexpected error: \(error)")
256+ XCTAssertEqual(StubURLProtocol.requestCount, 0)
259+ func testValidTokenIsUsedWithoutRefreshing() async throws {
260+ let store = FakeClaudeCredentialStore(credentials: credentials())
261+ StubURLProtocol.enqueue(.status(200, usageJSON()))
263+ _ = try await makeClient(store: store).fetchUsage()
265+ XCTAssertEqual(StubURLProtocol.requestCount, 1)
266+ XCTAssertTrue(requests(to: ClaudeUsageClient.tokenEndpoint).isEmpty)
267+ XCTAssertTrue(store.savedCredentials.isEmpty, "Nothing was rotated, so nothing to write back")
270+ // MARK: - Token refresh
272+ func testExpiredTokenIsRefreshedBeforeTheUsageRequest() async throws {
273+ let store = FakeClaudeCredentialStore(credentials: expiredCredentials())
274+ StubURLProtocol.enqueue(
275+ .status(200, tokenJSON(accessToken: "access-2")),
276+ .status(200, usageJSON())
279+ let report = try await makeClient(store: store).fetchUsage()
281+ XCTAssertEqual(report.usage.fiveHour?.utilization, 68)
282+ XCTAssertEqual(StubURLProtocol.requestCount, 2)
284+ let recorded = StubURLProtocol.recordedRequests
285+ XCTAssertEqual(recorded[0].url, ClaudeUsageClient.tokenEndpoint)
286+ XCTAssertEqual(recorded[0].httpMethod, "POST")
287+ XCTAssertEqual(recorded[1].url, ClaudeUsageClient.usageEndpoint)
289+ recorded[1].value(forHTTPHeaderField: "Authorization"),
291+ "The usage request has to carry the refreshed token, not the spent one"
295+ func testRefreshRotatesAndPersistsTheCredentials() async throws {
296+ let store = FakeClaudeCredentialStore(credentials: expiredCredentials())
297+ StubURLProtocol.enqueue(
298+ .status(200, tokenJSON(accessToken: "access-2", refreshToken: "refresh-2", expiresIn: 3600)),
299+ .status(200, usageJSON())
302+ _ = try await makeClient(store: store).fetchUsage()
304+ let saved = try XCTUnwrap(store.savedCredentials.last)
305+ let token = saved.credentials.claudeAiOauth
306+ XCTAssertEqual(token.accessToken, "access-2")
310+ "The server rotated the refresh token; failing to store it would sign Claude Code out"
312+ XCTAssertEqual(token.expiresAt, Int64((fixedNow.timeIntervalSince1970 + 3600) * 1000))
313+ XCTAssertEqual(saved.source, .keychain(account: "tester"), "Written back where it was read from")
316+ func testRefreshKeepsTheOldRefreshTokenWhenTheServerDoesNotRotateIt() async throws {
317+ let store = FakeClaudeCredentialStore(credentials: expiredCredentials())
318+ StubURLProtocol.enqueue(
319+ .status(200, tokenJSON(accessToken: "access-2", refreshToken: nil)),
320+ .status(200, usageJSON())
323+ _ = try await makeClient(store: store).fetchUsage()
325+ let saved = try XCTUnwrap(store.savedCredentials.last)
326+ XCTAssertEqual(saved.credentials.claudeAiOauth.refreshToken, "refresh-1")
329+ func testRefreshWithoutExpiresInAssumesEightHours() async throws {
330+ let store = FakeClaudeCredentialStore(credentials: expiredCredentials())
331+ StubURLProtocol.enqueue(
332+ .status(200, tokenJSON(accessToken: "access-2", expiresIn: nil)),
333+ .status(200, usageJSON())
336+ _ = try await makeClient(store: store).fetchUsage()
338+ let saved = try XCTUnwrap(store.savedCredentials.last)
340+ saved.credentials.claudeAiOauth.expiresAt,
341+ Int64((fixedNow.timeIntervalSince1970 + 8 * 3600) * 1000)
345+ func testRefreshSendsTheGrant() async throws {
346+ let store = FakeClaudeCredentialStore(credentials: expiredCredentials())
347+ StubURLProtocol.enqueue(.status(200, tokenJSON()), .status(200, usageJSON()))
349+ _ = try await makeClient(store: store).fetchUsage()
351+ let body = try XCTUnwrap(StubURLProtocol.recordedBodies.first ?? nil)
352+ let grant = try XCTUnwrap(try JSONSerialization.jsonObject(with: body) as? [String: String])
353+ XCTAssertEqual(grant["grant_type"], "refresh_token")
354+ XCTAssertEqual(grant["refresh_token"], "refresh-1")
355+ XCTAssertEqual(grant["client_id"], ClaudeUsageClient.clientID)
356+ XCTAssertEqual(grant["scope"], ClaudeUsageClient.scope)
359+ func testRefreshFallsBackToFormEncoding() async throws {
360+ let store = FakeClaudeCredentialStore(credentials: expiredCredentials())
361+ StubURLProtocol.enqueue(
362+ .status(400, Data()), // the JSON body is rejected
363+ .status(200, tokenJSON(accessToken: "access-2")),
364+ .status(200, usageJSON())
367+ _ = try await makeClient(store: store).fetchUsage()
369+ XCTAssertEqual(StubURLProtocol.requestCount, 3)
370+ let contentTypes = StubURLProtocol.recordedRequests.prefix(2).map {
371+ $0.value(forHTTPHeaderField: "Content-Type")
373+ XCTAssertEqual(contentTypes, ["application/json", "application/x-www-form-urlencoded"])
375+ let form = try XCTUnwrap(StubURLProtocol.recordedBodies[1].flatMap { String(data: $0, encoding: .utf8) })
376+ XCTAssertTrue(form.contains("grant_type=refresh_token"), "Got: \(form)")
377+ XCTAssertTrue(form.contains("refresh_token=refresh-1"), "Got: \(form)")
379+ form.contains("user%3Ainference"),
380+ "The colons in the scope have to be percent-encoded, not sent raw. Got: \(form)"
384+ func testRefreshFailureThrowsTokenRefreshFailed() async {
385+ let store = FakeClaudeCredentialStore(credentials: expiredCredentials())
386+ StubURLProtocol.enqueue(.status(400, Data())) // repeats, so both encodings fail
389+ _ = try await makeClient(store: store).fetchUsage()
390+ XCTFail("Expected tokenRefreshFailed error")
391+ } catch ClaudeUsageError.tokenRefreshFailed {
394+ XCTFail("Unexpected error: \(error)")
397+ // Both encodings tried, and no usage request made with a token we never got.
398+ XCTAssertEqual(StubURLProtocol.requestCount, 2)
399+ XCTAssertTrue(requests(to: ClaudeUsageClient.usageEndpoint).isEmpty)
402+ func testConcurrentFetchesShareASingleRefresh() async throws {
403+ // Two refreshes at once would each spend the refresh token, and whichever
404+ // finished second would be left holding one the server had already retired.
405+ let store = FakeClaudeCredentialStore(credentials: expiredCredentials())
406+ StubURLProtocol.enqueue(.status(200, tokenJSON()), .status(200, usageJSON()))
408+ let client = makeClient(store: store)
409+ async let first = client.fetchUsage()
410+ async let second = client.fetchUsage()
411+ _ = try await (first, second)
413+ XCTAssertEqual(requests(to: ClaudeUsageClient.tokenEndpoint).count, 1)
414+ XCTAssertEqual(requests(to: ClaudeUsageClient.usageEndpoint).count, 2)
415+ XCTAssertEqual(store.savedCredentials.count, 1)
418+ // MARK: - Rejected tokens
420+ func testRejectedTokenIsRefreshedOnceThenRetried() async throws {
421+ // A token can be revoked server-side well before its stated expiry.
422+ let store = FakeClaudeCredentialStore(credentials: credentials())
423+ StubURLProtocol.enqueue(
424+ .status(401, Data()),
425+ .status(200, tokenJSON(accessToken: "access-2")),
426+ .status(200, usageJSON())
429+ let report = try await makeClient(store: store).fetchUsage()
431+ XCTAssertEqual(report.usage.fiveHour?.utilization, 68)
432+ XCTAssertEqual(StubURLProtocol.requestCount, 3)
434+ StubURLProtocol.recordedRequests[2].value(forHTTPHeaderField: "Authorization"),
439+ func testRepeatedRejectionSurfacesInvalidCredentials() async {
440+ let store = FakeClaudeCredentialStore(credentials: credentials())
441+ StubURLProtocol.enqueue(
442+ .status(401, Data()),
443+ .status(200, tokenJSON(accessToken: "access-2")),
444+ .status(401, Data())
448+ _ = try await makeClient(store: store).fetchUsage()
449+ XCTFail("Expected invalidCredentials error")
450+ } catch ClaudeUsageError.invalidCredentials {
453+ XCTFail("Unexpected error: \(error)")
456+ // One refresh, and no attempt at a second: it plainly isn't the token.
457+ XCTAssertEqual(StubURLProtocol.requestCount, 3)
458+ XCTAssertEqual(requests(to: ClaudeUsageClient.tokenEndpoint).count, 1)
461+ func testFailedRefreshAfterRejectionSurfacesTheRejection() async {
462+ // "Couldn't refresh" says less than "the server rejected your token", and both
463+ // point at the same fix, so the rejection is the more useful of the two.
464+ let store = FakeClaudeCredentialStore(credentials: credentials())
465+ StubURLProtocol.enqueue(.status(401, Data()), .status(403, Data()))
468+ _ = try await makeClient(store: store).fetchUsage()
469+ XCTFail("Expected invalidCredentials error")
470+ } catch ClaudeUsageError.invalidCredentials {
473+ XCTFail("Unexpected error: \(error)")
477+ func testForbiddenIsTreatedAsARejectedToken() async {
478+ let store = FakeClaudeCredentialStore(credentials: credentials())
479+ StubURLProtocol.enqueue(.status(403, Data()))
482+ _ = try await makeClient(store: store).fetchUsage()
483+ XCTFail("Expected invalidCredentials error")
484+ } catch ClaudeUsageError.invalidCredentials {
487+ XCTFail("Unexpected error: \(error)")
493+ func testRetriesOnServerErrorThenSucceeds() async throws {
494+ let store = FakeClaudeCredentialStore(credentials: credentials())
495+ StubURLProtocol.enqueue(
496+ .status(500, Data()),
497+ .status(503, Data()),
498+ .status(200, usageJSON())
501+ let report = try await makeClient(store: store).fetchUsage()
503+ XCTAssertEqual(report.usage.fiveHour?.utilization, 68)
504+ XCTAssertEqual(StubURLProtocol.requestCount, 3)
507+ func testRetriesOnRateLimitThenSucceeds() async throws {
508+ let store = FakeClaudeCredentialStore(credentials: credentials())
509+ StubURLProtocol.enqueue(.status(429, Data()), .status(200, usageJSON()))
511+ _ = try await makeClient(store: store).fetchUsage()
513+ XCTAssertEqual(StubURLProtocol.requestCount, 2)
516+ func testRetriesOnNetworkErrorThenSucceeds() async throws {
517+ let store = FakeClaudeCredentialStore(credentials: credentials())
518+ StubURLProtocol.enqueue(.failure(URLError(.timedOut)), .status(200, usageJSON()))
520+ _ = try await makeClient(store: store).fetchUsage()
522+ XCTAssertEqual(StubURLProtocol.requestCount, 2)
525+ func testGivesUpAfterTwoRetries() async {
526+ let store = FakeClaudeCredentialStore(credentials: credentials())
527+ StubURLProtocol.enqueue(.status(500, Data())) // repeats
530+ _ = try await makeClient(store: store).fetchUsage()
531+ XCTFail("Expected requestFailed error")
532+ } catch ClaudeUsageError.requestFailed(let message) {
533+ XCTAssertEqual(message, "HTTP 500")
535+ XCTFail("Unexpected error: \(error)")
538+ // Initial attempt + 2 retries.
539+ XCTAssertEqual(StubURLProtocol.requestCount, 3)
542+ func testDoesNotRetryOnClientError() async {
543+ let store = FakeClaudeCredentialStore(credentials: credentials())
544+ StubURLProtocol.enqueue(.status(404, Data()))
547+ _ = try await makeClient(store: store).fetchUsage()
548+ XCTFail("Expected requestFailed error")
549+ } catch ClaudeUsageError.requestFailed(let message) {
550+ XCTAssertEqual(message, "HTTP 404")
552+ XCTFail("Unexpected error: \(error)")
555+ XCTAssertEqual(StubURLProtocol.requestCount, 1)
558+ func testDoesNotRetryOnDecodingFailure() async {
559+ let store = FakeClaudeCredentialStore(credentials: credentials())
560+ StubURLProtocol.enqueue(.status(200, Data("not json".utf8)))
563+ _ = try await makeClient(store: store).fetchUsage()
564+ XCTFail("Expected decodingFailed error")
565+ } catch ClaudeUsageError.decodingFailed {
568+ XCTFail("Unexpected error: \(error)")
571+ XCTAssertEqual(StubURLProtocol.requestCount, 1)
576+ func testErrorsAreEquatable() {
577+ XCTAssertEqual(ClaudeUsageError.noCredentials, .noCredentials)
578+ XCTAssertNotEqual(ClaudeUsageError.noCredentials, .invalidCredentials)
579+ XCTAssertEqual(ClaudeUsageError.requestFailed("HTTP 500"), .requestFailed("HTTP 500"))
582+ func testClaudeUsageClientConformsToProtocol() {
583+ let checker: ClaudeUsageChecking = ClaudeUsageClient()
584+ // If this compiles, ClaudeUsageClient conforms to ClaudeUsageChecking.
585+ XCTAssertNotNil(checker)
590+ /// An ISO 8601 timestamp with fractional seconds, as Claude's API spells them.
591+ private func iso(_ date: Date) -> String {
592+ let formatter = ISO8601DateFormatter()
593+ formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
594+ return formatter.string(from: date)
598+// MARK: - Credential store
600+/// These drive `ClaudeCodeCredentialStore` against a throwaway file and a Keychain
601+/// service that cannot exist, so `security` always misses and the file fallback is what
602+/// gets exercised. Nothing here can read — let alone write — the real Claude Code
603+/// credentials, which is the whole point: a test that clobbered them would sign the
604+/// user out of their own CLI.
605+final class ClaudeCodeCredentialStoreTests: XCTestCase {
607+ private var directory: URL!
609+ /// A credentials blob carrying two fields we deliberately don't model.
610+ private let credentialsJSON = """
613+ "accessToken": "access-1",
614+ "refreshToken": "refresh-1",
615+ "expiresAt": 4000000000000,
616+ "scopes": ["user:inference", "user:profile"],
617+ "subscriptionType": "max",
618+ "somethingWeDoNotModel": "keep me"
620+ "anotherTopLevelKey": "keep me too"
624+ override func setUpWithError() throws {
625+ try super.setUpWithError()
626+ directory = URL(fileURLWithPath: NSTemporaryDirectory())
627+ .appendingPathComponent("claude-credentials-\(UUID().uuidString)")
628+ try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
631+ override func tearDownWithError() throws {
633+ try? FileManager.default.removeItem(at: directory)
635+ try super.tearDownWithError()
638+ private func makeStore(file: URL) -> ClaudeCodeCredentialStore {
639+ ClaudeCodeCredentialStore(
640+ keychainService: "com.example.tests.absent-\(UUID().uuidString)",
641+ credentialsFile: file
645+ private func writeCredentialsFile() throws -> URL {
646+ let file = directory.appendingPathComponent(".credentials.json")
647+ try Data(credentialsJSON.utf8).write(to: file)
651+ func testReadsCredentialsFromTheFileWhenTheKeychainHasNothing() throws {
652+ let file = try writeCredentialsFile()
654+ let stored = try XCTUnwrap(makeStore(file: file).load())
656+ XCTAssertEqual(stored.credentials.claudeAiOauth.accessToken, "access-1")
657+ XCTAssertEqual(stored.credentials.claudeAiOauth.refreshToken, "refresh-1")
658+ XCTAssertEqual(stored.credentials.claudeAiOauth.subscriptionType, "max")
659+ XCTAssertEqual(stored.source, .file(file))
662+ func testReturnsNilWhenThereIsNeitherAKeychainItemNorAFile() {
663+ let missing = directory.appendingPathComponent("nothing-here.json")
665+ XCTAssertNil(makeStore(file: missing).load())
668+ func testReturnsNilForACorruptedFile() throws {
669+ let file = directory.appendingPathComponent(".credentials.json")
670+ try Data("{ not json".utf8).write(to: file)
672+ XCTAssertNil(makeStore(file: file).load())
675+ func testWriteBackRotatesTheTokensAndKeepsEverythingElse() throws {
676+ let file = try writeCredentialsFile()
677+ let store = makeStore(file: file)
679+ var stored = try XCTUnwrap(store.load())
680+ stored.credentials.claudeAiOauth.accessToken = "access-2"
681+ stored.credentials.claudeAiOauth.refreshToken = "refresh-2"
682+ stored.credentials.claudeAiOauth.expiresAt = 4_100_000_000_000
685+ let written = try XCTUnwrap(
686+ try JSONSerialization.jsonObject(with: Data(contentsOf: file)) as? [String: Any]
688+ let oauth = try XCTUnwrap(written["claudeAiOauth"] as? [String: Any])
690+ XCTAssertEqual(oauth["accessToken"] as? String, "access-2")
691+ XCTAssertEqual(oauth["refreshToken"] as? String, "refresh-2")
692+ XCTAssertEqual((oauth["expiresAt"] as? NSNumber)?.int64Value, 4_100_000_000_000)
694+ // This is the file the user's CLI signs in with. Round-tripping it through our
695+ // own type would drop the fields we don't model, so the write patches the raw
696+ // JSON instead — and these two have to survive it.
697+ XCTAssertEqual(oauth["somethingWeDoNotModel"] as? String, "keep me")
698+ XCTAssertEqual(written["anotherTopLevelKey"] as? String, "keep me too")
699+ XCTAssertEqual(oauth["subscriptionType"] as? String, "max")
702+ func testWriteBackLeavesTheFileReadableOnlyByItsOwner() throws {
703+ let file = try writeCredentialsFile()
704+ let store = makeStore(file: file)
706+ let stored = try XCTUnwrap(store.load())
709+ let attributes = try FileManager.default.attributesOfItem(atPath: file.path)
711+ (attributes[.posixPermissions] as? NSNumber)?.int16Value,
713+ "An atomic write replaces the file, so the mode has to be reapplied"
718+// MARK: - Fake credential store
720+/// An in-memory `ClaudeCredentialStoring`. Never goes near the Keychain or the disk.
721+final class FakeClaudeCredentialStore: ClaudeCredentialStoring {
722+ private let lock = NSLock()
723+ private var current: StoredClaudeCredentials?
724+ private var writes: [StoredClaudeCredentials] = []
727+ credentials: ClaudeOAuthCredentials?,
728+ source: ClaudeCredentialSource = .keychain(account: "tester")
730+ current = credentials.map { StoredClaudeCredentials(credentials: $0, source: source) }
733+ /// Every write-back the client has made, oldest first.
734+ var savedCredentials: [StoredClaudeCredentials] {
736+ defer { lock.unlock() }
740+ func load() -> StoredClaudeCredentials? {
742+ defer { lock.unlock() }
746+ func save(_ stored: StoredClaudeCredentials) {
748+ defer { lock.unlock() }
750+ writes.append(stored)