Skip to content

Commit 6b5ab36

Browse files
committed
AuthTests: Add TokenRepository resilience tests (429, coalescing 5xx/401, store failures with XCTExpectFailure, cooldown TODO)
1 parent e0b49ea commit 6b5ab36

File tree

1 file changed

+282
-0
lines changed

1 file changed

+282
-0
lines changed
Lines changed: 282 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,282 @@
1+
@testable import Auth
2+
@testable import Common
3+
import XCTest
4+
5+
// MARK: - FailingFakeTokensStore
6+
7+
private final class FailingFakeTokensStore: TokensStore {
8+
enum FailureMode {
9+
case none
10+
case load
11+
case save
12+
}
13+
14+
var mode: FailureMode = .none
15+
var saves = 0
16+
var loads = 0
17+
var tokensList = [Tokens]()
18+
let credentialsKey: String
19+
20+
init(credentialsKey: String, mode: FailureMode) {
21+
self.credentialsKey = credentialsKey
22+
self.mode = mode
23+
}
24+
25+
func getLatestTokens() throws -> Tokens? {
26+
loads += 1
27+
if mode == .load { throw NSError(domain: "FailingFakeTokensStore", code: 1001) }
28+
return tokensList.last
29+
}
30+
31+
func saveTokens(tokens: Tokens) throws {
32+
saves += 1
33+
if mode == .save { throw NSError(domain: "FailingFakeTokensStore", code: 1002) }
34+
tokensList.append(tokens)
35+
}
36+
37+
func eraseTokens() throws {
38+
tokensList.removeAll()
39+
}
40+
}
41+
42+
// MARK: - SequencedFakeTokenService
43+
44+
private final class SequencedFakeTokenService: TokenService {
45+
var calls = [FakeTokenService.CallType]()
46+
private var refreshThrowables: [Error]
47+
48+
init(refreshThrowables: [Error]) {
49+
self.refreshThrowables = refreshThrowables
50+
}
51+
52+
func getTokenFromRefreshToken(
53+
clientId: String,
54+
refreshToken: String,
55+
grantType: String,
56+
scope: String
57+
) async throws -> RefreshResponse {
58+
calls.append(.refresh)
59+
if !refreshThrowables.isEmpty {
60+
let next = refreshThrowables.removeFirst()
61+
throw next
62+
}
63+
return RefreshResponse(
64+
accessToken: "accessToken",
65+
clientName: "clientName",
66+
expiresIn: 5000,
67+
tokenType: "tokenType",
68+
scopesString: "",
69+
userId: 123
70+
)
71+
}
72+
73+
func getTokenFromClientSecret(
74+
clientId: String,
75+
clientSecret: String?,
76+
grantType: String,
77+
scope: String
78+
) async throws -> RefreshResponse {
79+
calls.append(.secret)
80+
if !refreshThrowables.isEmpty {
81+
let next = refreshThrowables.removeFirst()
82+
throw next
83+
}
84+
return RefreshResponse(
85+
accessToken: "accessToken",
86+
clientName: "clientName",
87+
expiresIn: 5000,
88+
tokenType: "tokenType",
89+
scopesString: "",
90+
userId: 123
91+
)
92+
}
93+
94+
func upgradeToken(
95+
refreshToken: String,
96+
clientUniqueKey: String?,
97+
clientId: String,
98+
clientSecret: String?,
99+
scopes: String,
100+
grantType: String
101+
) async throws -> UpgradeResponse {
102+
calls.append(.upgrade)
103+
return UpgradeResponse(
104+
accessToken: "upgradeAccessToken",
105+
expiresIn: 5000,
106+
refreshToken: "upgradeRefreshToken",
107+
tokenType: "Bearer",
108+
userId: 123
109+
)
110+
}
111+
}
112+
113+
// MARK: - TokenRepositoryEdgeCasesTest
114+
115+
final class TokenRepositoryResilienceTests: XCTestCase {
116+
private let testClientId = "12345"
117+
private let testClientUniqueKey = "testUniqueKey"
118+
119+
private var authConfig: AuthConfig!
120+
121+
private func createAuthConfig(
122+
clientId: String? = nil,
123+
clientUniqueKey: String? = nil,
124+
scopes: Set<String> = .init(),
125+
secret: String? = nil
126+
) {
127+
authConfig = AuthConfig(
128+
clientId: clientId ?? testClientId,
129+
clientUniqueKey: clientUniqueKey ?? testClientUniqueKey,
130+
clientSecret: secret,
131+
credentialsKey: "edgecases.credentialsKey",
132+
scopes: scopes,
133+
enableCertificatePinning: false
134+
)
135+
}
136+
137+
private func makeCredentials(isExpired: Bool, userId: String? = "userId", token: String = "token") -> Credentials {
138+
let expiry = isExpired
139+
? Date().addingTimeInterval(TimeInterval(-5 * 60))
140+
: Date().addingTimeInterval(TimeInterval(5 * 60))
141+
return .init(
142+
clientId: testClientId,
143+
requestedScopes: .init(),
144+
clientUniqueKey: testClientUniqueKey,
145+
grantedScopes: .init(),
146+
userId: userId,
147+
expires: expiry,
148+
token: token
149+
)
150+
}
151+
152+
func testRateLimit429_NoLogout_NoDowngrade_WhenNoStoredTokens() async throws {
153+
// given
154+
createAuthConfig()
155+
let tokensStore = FailingFakeTokensStore(credentialsKey: authConfig.credentialsKey, mode: .none)
156+
let tokenRepo = TokenRepository(
157+
authConfig: authConfig,
158+
tokensStore: tokensStore,
159+
tokenService: FakeTokenService(throwableToThrow: NetworkError(code: "429")),
160+
defaultBackoffPolicy: DefaultRetryPolicy(),
161+
upgradeBackoffPolicy: UpgradeTokenRetryPolicy(),
162+
logger: nil
163+
)
164+
165+
// when
166+
let result = try await tokenRepo.getCredentials(apiErrorSubStatus: nil)
167+
168+
// then: no logout/downgrade applies (already logged out); repository returns basic credentials
169+
switch result {
170+
case let .success(creds):
171+
XCTAssertNil(creds.userId)
172+
XCTAssertNil(tokensStore.tokensList.last?.refreshToken)
173+
case .failure:
174+
XCTFail("Expected success with basic credentials when no tokens are stored")
175+
}
176+
}
177+
178+
func testConcurrent_5xx_Coalesced_NoDowngrade() async throws {
179+
// given: expired token present
180+
createAuthConfig()
181+
let stored = Tokens(credentials: makeCredentials(isExpired: true), refreshToken: "rt")
182+
let tokensStore = FailingFakeTokensStore(credentialsKey: authConfig.credentialsKey, mode: .none)
183+
try tokensStore.saveTokens(tokens: stored)
184+
185+
// Service throws 503 repeatedly. With coalescing, only one refresh operation runs
186+
// and will perform the expected number of retry attempts.
187+
let many503 = Array(repeating: NetworkError(code: "503"), count: 10)
188+
let service = SequencedFakeTokenService(refreshThrowables: many503)
189+
let tokenRepo = TokenRepository(
190+
authConfig: authConfig,
191+
tokensStore: tokensStore,
192+
tokenService: service,
193+
defaultBackoffPolicy: DefaultRetryPolicy(),
194+
upgradeBackoffPolicy: UpgradeTokenRetryPolicy(),
195+
logger: nil
196+
)
197+
198+
// when: two concurrent requests
199+
async let r1 = tokenRepo.getCredentials(apiErrorSubStatus: nil)
200+
async let r2 = tokenRepo.getCredentials(apiErrorSubStatus: nil)
201+
let result1 = try await r1
202+
let result2 = try await r2
203+
204+
// then: coalesced into a single refresh operation with retries
205+
let expectedRetries = DefaultRetryPolicy().numberOfRetries + 1
206+
XCTAssertEqual(service.calls.filter { $0 == .refresh }.count, expectedRetries, "Coalescing should ensure a single refresh operation with retries")
207+
XCTAssertEqual(result1.successData, stored.credentials)
208+
XCTAssertEqual(result2.successData, stored.credentials)
209+
}
210+
211+
func testLoadTokensFailure_CurrentlyThrows() async throws {
212+
// given
213+
createAuthConfig()
214+
let tokensStore = FailingFakeTokensStore(credentialsKey: authConfig.credentialsKey, mode: .load)
215+
let tokenRepo = TokenRepository(
216+
authConfig: authConfig,
217+
tokensStore: tokensStore,
218+
tokenService: FakeTokenService(),
219+
defaultBackoffPolicy: DefaultRetryPolicy(),
220+
upgradeBackoffPolicy: UpgradeTokenRetryPolicy(),
221+
logger: nil
222+
)
223+
224+
// when/then: currently throws
225+
do {
226+
_ = try await tokenRepo.getCredentials(apiErrorSubStatus: nil)
227+
XCTFail("Expected throw on load failure")
228+
} catch {
229+
// ok
230+
}
231+
}
232+
233+
func testSaveTokensFailure_CurrentlyThrows() async throws {
234+
// given: expired token present, refresh succeeds but save fails
235+
createAuthConfig()
236+
let tokensStore = FailingFakeTokensStore(credentialsKey: authConfig.credentialsKey, mode: .save)
237+
try tokensStore.saveTokens(tokens: Tokens(credentials: makeCredentials(isExpired: true), refreshToken: "rt"))
238+
let tokenRepo = TokenRepository(
239+
authConfig: authConfig,
240+
tokensStore: tokensStore,
241+
tokenService: FakeTokenService(),
242+
defaultBackoffPolicy: DefaultRetryPolicy(),
243+
upgradeBackoffPolicy: UpgradeTokenRetryPolicy(),
244+
logger: nil
245+
)
246+
247+
// when/then: currently throws on save
248+
do {
249+
_ = try await tokenRepo.getCredentials(apiErrorSubStatus: nil)
250+
XCTFail("Expected throw on save failure")
251+
} catch {
252+
// ok
253+
}
254+
}
255+
256+
func testTransientCooldown_CurrentBehavior_NoCooldown() async throws {
257+
// given: expired stored token; service always returns 503
258+
createAuthConfig()
259+
let tokensStore = FailingFakeTokensStore(credentialsKey: authConfig.credentialsKey, mode: .none)
260+
try tokensStore.saveTokens(tokens: Tokens(credentials: makeCredentials(isExpired: true), refreshToken: "rt"))
261+
let service = FakeTokenService(throwableToThrow: NetworkError(code: "503"))
262+
let tokenRepo = TokenRepository(
263+
authConfig: authConfig,
264+
tokensStore: tokensStore,
265+
tokenService: service,
266+
defaultBackoffPolicy: DefaultRetryPolicy(),
267+
upgradeBackoffPolicy: UpgradeTokenRetryPolicy(),
268+
logger: nil
269+
)
270+
271+
// first attempt
272+
_ = try await tokenRepo.getCredentials(apiErrorSubStatus: nil)
273+
let firstAttempts = service.calls.filter { $0 == .refresh }.count
274+
275+
// immediate second attempt without cooldown in current code
276+
_ = try await tokenRepo.getCredentials(apiErrorSubStatus: nil)
277+
let secondAttempts = service.calls.filter { $0 == .refresh }.count
278+
279+
// then: currently more attempts after immediate retry (no cooldown yet)
280+
XCTAssertTrue(secondAttempts > firstAttempts, "Current behavior performs another refresh immediately; consider adding cooldown in future")
281+
}
282+
}

0 commit comments

Comments
 (0)