@@ -2,8 +2,8 @@ import XCTest
22import Foundation
33@testable import UsageMenubar
44
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.
5+/// The instant the countdown tests reckon from, so "3h 20m" is a fact rather than a race
6+/// against the wall clock.
77private let fixedNow = Date(timeIntervalSince1970: 1_700_000_000)
88
99final class ClaudeUsageClientTests: XCTestCase {
@@ -33,22 +33,17 @@ final class ClaudeUsageClientTests: XCTestCase {
3333 return ClaudeUsageClient(
3434 session: URLSession(configuration: config),
3535 store: store,
36− retryBaseDelay: retryBaseDelay,
37− now: { fixedNow }
36+ retryBaseDelay: retryBaseDelay
3837 )
3938 }
4039
4140 private func credentials(
4241 accessToken: String = "access-1",
43− refreshToken: String = "refresh-1",
44− expiresAt: Date = fixedNow.addingTimeInterval(3600),
4542 subscriptionType: String? = "max"
4643 ) -> ClaudeOAuthCredentials {
4744 ClaudeOAuthCredentials(
4845 claudeAiOauth: .init(
4946 accessToken: accessToken,
50− refreshToken: refreshToken,
51− expiresAt: Int64(expiresAt.timeIntervalSince1970 * 1000),
5247 scopes: ["user:inference"],
5348 subscriptionType: subscriptionType,
5449 rateLimitTier: nil
@@ -56,11 +51,6 @@ final class ClaudeUsageClientTests: XCTestCase {
5651 )
5752 }
5853
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))
62− }
63−
6454 private func usageJSON(fiveHour: Int = 68, sevenDay: Int = 41) -> Data {
6555 Data("""
6656 {
@@ -70,21 +60,6 @@ final class ClaudeUsageClientTests: XCTestCase {
7060 """.utf8)
7161 }
7262
73− private func tokenJSON(
74− accessToken: String = "access-2",
75− refreshToken: String? = "refresh-2",
76− expiresIn: Int? = 3600
77− ) -> Data {
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)
82− }
83−
84− private func requests(to url: URL) -> [URLRequest] {
85− StubURLProtocol.recordedRequests.filter { $0.url == url }
86− }
87−
8863 // MARK: - Usage decoding
8964
9065 func testDecodesUsagePayload() throws {
@@ -256,193 +231,44 @@ final class ClaudeUsageClientTests: XCTestCase {
256231 XCTAssertEqual(StubURLProtocol.requestCount, 0)
257232 }
258233
259− func testValidTokenIsUsedWithoutRefreshing() async throws {
234+ func testTheStoredTokenIsSpentExactlyAsItIs() async throws {
260235 let store = FakeClaudeCredentialStore(credentials: credentials())
261236 StubURLProtocol.enqueue(.status(200, usageJSON()))
262237
263238 _ = try await makeClient(store: store).fetchUsage()
264239
240+ // One request, to the usage endpoint. No token endpoint, no grant, no rotation:
241+ // refreshing behind the CLI's back is what signed the user out to begin with.
265242 XCTAssertEqual(StubURLProtocol.requestCount, 1)
266− XCTAssertTrue(requests(to: ClaudeUsageClient.tokenEndpoint).isEmpty)
267− XCTAssertTrue(store.savedCredentials.isEmpty, "Nothing was rotated, so nothing to write back")
268− }
269−
270− // MARK: - Token refresh
271−
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())
277− )
278−
279− let report = try await makeClient(store: store).fetchUsage()
280−
281− XCTAssertEqual(report.usage.fiveHour?.utilization, 68)
282− XCTAssertEqual(StubURLProtocol.requestCount, 2)
283−
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)
288− XCTAssertEqual(
289− recorded[1].value(forHTTPHeaderField: "Authorization"),
290− "Bearer access-2",
291− "The usage request has to carry the refreshed token, not the spent one"
292− )
293− }
294−
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())
300− )
301−
302− _ = try await makeClient(store: store).fetchUsage()
303−
304− let saved = try XCTUnwrap(store.savedCredentials.last)
305− let token = saved.credentials.claudeAiOauth
306− XCTAssertEqual(token.accessToken, "access-2")
307− XCTAssertEqual(
308− token.refreshToken,
309− "refresh-2",
310− "The server rotated the refresh token; failing to store it would sign Claude Code out"
311− )
312− XCTAssertEqual(token.expiresAt, Int64((fixedNow.timeIntervalSince1970 + 3600) * 1000))
313− XCTAssertEqual(saved.source, .keychain(account: "tester"), "Written back where it was read from")
243+ XCTAssertEqual(StubURLProtocol.recordedRequests[0].url, ClaudeUsageClient.usageEndpoint)
244+ XCTAssertEqual(StubURLProtocol.recordedRequests[0].httpMethod, "GET")
314245 }
315246
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())
321− )
322−
323− _ = try await makeClient(store: store).fetchUsage()
324−
325− let saved = try XCTUnwrap(store.savedCredentials.last)
326− XCTAssertEqual(saved.credentials.claudeAiOauth.refreshToken, "refresh-1")
327− }
328−
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())
334− )
335−
336− _ = try await makeClient(store: store).fetchUsage()
337−
338− let saved = try XCTUnwrap(store.savedCredentials.last)
339− XCTAssertEqual(
340− saved.credentials.claudeAiOauth.expiresAt,
341− Int64((fixedNow.timeIntervalSince1970 + 8 * 3600) * 1000)
342− )
343− }
344−
345− func testRefreshSendsTheGrant() async throws {
346− let store = FakeClaudeCredentialStore(credentials: expiredCredentials())
347− StubURLProtocol.enqueue(.status(200, tokenJSON()), .status(200, usageJSON()))
348−
349− _ = try await makeClient(store: store).fetchUsage()
350−
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)
357− }
358−
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())
365− )
366−
367− _ = try await makeClient(store: store).fetchUsage()
368−
369− XCTAssertEqual(StubURLProtocol.requestCount, 3)
370− let contentTypes = StubURLProtocol.recordedRequests.prefix(2).map {
371− $0.value(forHTTPHeaderField: "Content-Type")
372− }
373− XCTAssertEqual(contentTypes, ["application/json", "application/x-www-form-urlencoded"])
374−
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)")
378− XCTAssertTrue(
379− form.contains("user%3Ainference"),
380− "The colons in the scope have to be percent-encoded, not sent raw. Got: \(form)"
381− )
382− }
383−
384− func testRefreshFailureThrowsTokenRefreshFailed() async {
385− let store = FakeClaudeCredentialStore(credentials: expiredCredentials())
386− StubURLProtocol.enqueue(.status(400, Data())) // repeats, so both encodings fail
387−
388− do {
389− _ = try await makeClient(store: store).fetchUsage()
390− XCTFail("Expected tokenRefreshFailed error")
391− } catch ClaudeUsageError.tokenRefreshFailed {
392− // expected
393− } catch {
394− XCTFail("Unexpected error: \(error)")
395− }
396−
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)
400− }
401−
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()))
247+ func testCredentialsAreReReadOnEveryFetch() async throws {
248+ // The CLI refreshes on its own schedule and we never do, so the only way to see
249+ // a new token is to go back to the store each time. Caching one would strand the
250+ // app on a token the CLI has long since replaced.
251+ let store = FakeClaudeCredentialStore(credentials: credentials(accessToken: "access-1"))
252+ StubURLProtocol.enqueue(.status(200, usageJSON()), .status(200, usageJSON()))
407253
408254 let client = makeClient(store: store)
409− async let first = client.fetchUsage()
410− async let second = client.fetchUsage()
411− _ = try await (first, second)
255+ _ = try await client.fetchUsage()
412256
413− XCTAssertEqual(requests(to: ClaudeUsageClient.tokenEndpoint).count, 1)
414− XCTAssertEqual(requests(to: ClaudeUsageClient.usageEndpoint).count, 2)
415− XCTAssertEqual(store.savedCredentials.count, 1)
257+ store.replace(with: credentials(accessToken: "access-2"))
258+ _ = try await client.fetchUsage()
259+
260+ XCTAssertEqual(store.loadCount, 2)
261+ let sent = StubURLProtocol.recordedRequests.map { $0.value(forHTTPHeaderField: "Authorization") }
262+ XCTAssertEqual(sent, ["Bearer access-1", "Bearer access-2"])
416263 }
417264
418265 // MARK: - Rejected tokens
419266
420− func testRejectedTokenIsRefreshedOnceThenRetried() async throws {
421− // A token can be revoked server-side well before its stated expiry.
267+ func testRejectedTokenSurfacesInvalidCredentialsWithoutRefreshing() async {
268+ // A spent or revoked token is the user's to fix by running `claude`. The app
269+ // attempting the refresh itself is the bug this whole client is shaped around.
422270 let store = FakeClaudeCredentialStore(credentials: credentials())
423− StubURLProtocol.enqueue(
424− .status(401, Data()),
425− .status(200, tokenJSON(accessToken: "access-2")),
426− .status(200, usageJSON())
427− )
428−
429− let report = try await makeClient(store: store).fetchUsage()
430−
431− XCTAssertEqual(report.usage.fiveHour?.utilization, 68)
432− XCTAssertEqual(StubURLProtocol.requestCount, 3)
433− XCTAssertEqual(
434− StubURLProtocol.recordedRequests[2].value(forHTTPHeaderField: "Authorization"),
435− "Bearer access-2"
436− )
437− }
438−
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())
445− )
271+ StubURLProtocol.enqueue(.status(401, Data()))
446272
447273 do {
448274 _ = try await makeClient(store: store).fetchUsage()
@@ -453,25 +279,11 @@ final class ClaudeUsageClientTests: XCTestCase {
453279 XCTFail("Unexpected error: \(error)")
454280 }
455281
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)
459− }
460−
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()))
466−
467− do {
468− _ = try await makeClient(store: store).fetchUsage()
469− XCTFail("Expected invalidCredentials error")
470− } catch ClaudeUsageError.invalidCredentials {
471− // expected
472− } catch {
473− XCTFail("Unexpected error: \(error)")
474− }
282+ XCTAssertEqual(
283+ StubURLProtocol.requestCount,
284+ 1,
285+ "A 401 must end the fetch — no refresh, and no retry of the request either"
286+ )
475287 }
476288
477289 func testForbiddenIsTreatedAsARejectedToken() async {
@@ -486,6 +298,15 @@ final class ClaudeUsageClientTests: XCTestCase {
486298 } catch {
487299 XCTFail("Unexpected error: \(error)")
488300 }
301+
302+ XCTAssertEqual(StubURLProtocol.requestCount, 1)
303+ }
304+
305+ func testTheInvalidCredentialsMessagePointsAtTheCLI() {
306+ // The app can't mint a token, so the only useful thing it can say is where one
307+ // comes from.
308+ let message = ClaudeUsageError.invalidCredentials.errorDescription
309+ XCTAssertEqual(message, "Claude sign-in expired. Run `claude` in a terminal to sign in again.")
489310 }
490311
491312 // MARK: - Retries
@@ -606,7 +427,8 @@ final class ClaudeCodeCredentialStoreTests: XCTestCase {
606427
607428 private var directory: URL!
608429
609− /// A credentials blob carrying two fields we deliberately don't model.
430+ /// A credentials blob as Claude Code actually writes it: the two token fields we no
431+ /// longer model, and two more we never did.
610432 private let credentialsJSON = """
611433 {
612434 "claudeAiOauth": {
@@ -651,12 +473,11 @@ final class ClaudeCodeCredentialStoreTests: XCTestCase {
651473 func testReadsCredentialsFromTheFileWhenTheKeychainHasNothing() throws {
652474 let file = try writeCredentialsFile()
653475
654− let stored = try XCTUnwrap(makeStore(file: file).load())
476+ let credentials = try XCTUnwrap(makeStore(file: file).load())
655477
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))
478+ XCTAssertEqual(credentials.claudeAiOauth.accessToken, "access-1")
479+ XCTAssertEqual(credentials.claudeAiOauth.subscriptionType, "max")
480+ XCTAssertEqual(credentials.claudeAiOauth.scopes, ["user:inference", "user:profile"])
660481 }
661482
662483 func testReturnsNilWhenThereIsNeitherAKeychainItemNorAFile() {
@@ -672,81 +493,65 @@ final class ClaudeCodeCredentialStoreTests: XCTestCase {
672493 XCTAssertNil(makeStore(file: file).load())
673494 }
674495
675− func testWriteBackRotatesTheTokensAndKeepsEverythingElse() throws {
676− let file = try writeCredentialsFile()
677− let store = makeStore(file: file)
678−
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
683− store.save(stored)
684−
685− let written = try XCTUnwrap(
686− try JSONSerialization.jsonObject(with: Data(contentsOf: file)) as? [String: Any]
687− )
688− let oauth = try XCTUnwrap(written["claudeAiOauth"] as? [String: Any])
496+ func testABlobWithoutTheFieldsWeDroppedStillLoads() throws {
497+ // `refreshToken` and `expiresAt` are no longer modelled, so their absence must
498+ // not fail the decode — nor must their presence, above.
499+ let file = directory.appendingPathComponent(".credentials.json")
500+ try Data(#"{"claudeAiOauth": {"accessToken": "access-1"}}"#.utf8).write(to: file)
689501
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)
502+ let credentials = try XCTUnwrap(makeStore(file: file).load())
693503
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")
504+ XCTAssertEqual(credentials.claudeAiOauth.accessToken, "access-1")
505+ XCTAssertNil(credentials.claudeAiOauth.subscriptionType)
700506 }
701507
702− func testWriteBackLeavesTheFileReadableOnlyByItsOwner() throws {
508+ func testLoadingLeavesTheCredentialsFileByteForByteUntouched() throws {
509+ // The regression guard for the bug this store was built around: the app used to
510+ // write a rotated token back here, racing the CLI's own refresh and signing the
511+ // user out of their terminal. It must now be a reader and nothing else.
703512 let file = try writeCredentialsFile()
704− let store = makeStore(file: file)
513+ let before = try Data(contentsOf: file)
705514
706− let stored = try XCTUnwrap(store.load())
707− store.save(stored)
515+ _ = makeStore(file: file).load()
708516
709− let attributes = try FileManager.default.attributesOfItem(atPath: file.path)
710− XCTAssertEqual(
711− (attributes[.posixPermissions] as? NSNumber)?.int16Value,
712− 0o600,
713− "An atomic write replaces the file, so the mode has to be reapplied"
714− )
517+ XCTAssertEqual(try Data(contentsOf: file), before)
715518 }
716519}
717520
718521// MARK: - Fake credential store
719522
720523/// An in-memory `ClaudeCredentialStoring`. Never goes near the Keychain or the disk.
524+///
525+/// It cannot write, because the protocol no longer can: the CLI is the only thing that
526+/// may rotate these tokens. `replace(with:)` is the test standing in for the CLI having
527+/// done exactly that behind the app's back.
721528final class FakeClaudeCredentialStore: ClaudeCredentialStoring {
722529 private let lock = NSLock()
723− private var current: StoredClaudeCredentials?
724− private var writes: [StoredClaudeCredentials] = []
530+ private var current: ClaudeOAuthCredentials?
531+ private var loads = 0
725532
726− init(
727− credentials: ClaudeOAuthCredentials?,
728− source: ClaudeCredentialSource = .keychain(account: "tester")
729− ) {
730− current = credentials.map { StoredClaudeCredentials(credentials: $0, source: source) }
533+ init(credentials: ClaudeOAuthCredentials?) {
534+ current = credentials
731535 }
732536
733− /// Every write-back the client has made, oldest first.
734− var savedCredentials: [StoredClaudeCredentials] {
537+ /// How many times the client has gone back to the store — it should be once per fetch.
538+ var loadCount: Int {
735539 lock.lock()
736540 defer { lock.unlock() }
737− return writes
541+ return loads
738542 }
739543
740− func load() -> StoredClaudeCredentials? {
544+ /// Stands in for the CLI refreshing its own token between two fetches.
545+ func replace(with credentials: ClaudeOAuthCredentials?) {
741546 lock.lock()
742547 defer { lock.unlock() }
743− return current
548+ current = credentials
744549 }
745550
746− func save(_ stored: StoredClaudeCredentials) {
551+ func load() -> ClaudeOAuthCredentials? {
747552 lock.lock()
748553 defer { lock.unlock() }
749− current = stored
750− writes.append(stored)
554+ loads += 1
555+ return current
751556 }
752557}