Skip to content

Commit ae28229

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

File tree

1 file changed

+290
-0
lines changed

1 file changed

+290
-0
lines changed
Lines changed: 290 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,290 @@
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+
// Tiny retry policy to keep tests fast and deterministic
122+
private struct TinyRetryPolicy: RetryPolicy {
123+
var numberOfRetries: Int { 0 }
124+
var delayMillis: Int { 1 }
125+
var delayFactor: Int { 1 }
126+
}
127+
128+
private func createAuthConfig(
129+
clientId: String? = nil,
130+
clientUniqueKey: String? = nil,
131+
scopes: Set<String> = .init(),
132+
secret: String? = nil
133+
) {
134+
authConfig = AuthConfig(
135+
clientId: clientId ?? testClientId,
136+
clientUniqueKey: clientUniqueKey ?? testClientUniqueKey,
137+
clientSecret: secret,
138+
credentialsKey: "edgecases.credentialsKey",
139+
scopes: scopes,
140+
enableCertificatePinning: false
141+
)
142+
}
143+
144+
private func makeCredentials(isExpired: Bool, userId: String? = "userId", token: String = "token") -> Credentials {
145+
let expiry = isExpired
146+
? Date().addingTimeInterval(TimeInterval(-5 * 60))
147+
: Date().addingTimeInterval(TimeInterval(5 * 60))
148+
return .init(
149+
clientId: testClientId,
150+
requestedScopes: .init(),
151+
clientUniqueKey: testClientUniqueKey,
152+
grantedScopes: .init(),
153+
userId: userId,
154+
expires: expiry,
155+
token: token
156+
)
157+
}
158+
159+
func testRateLimit429_NoLogout_NoDowngrade_WhenNoStoredTokens() async throws {
160+
// given
161+
createAuthConfig()
162+
let tokensStore = FailingFakeTokensStore(credentialsKey: authConfig.credentialsKey, mode: .none)
163+
let tokenRepo = TokenRepository(
164+
authConfig: authConfig,
165+
tokensStore: tokensStore,
166+
tokenService: FakeTokenService(throwableToThrow: NetworkError(code: "429")),
167+
defaultBackoffPolicy: TinyRetryPolicy(),
168+
upgradeBackoffPolicy: UpgradeTokenRetryPolicy(),
169+
logger: nil
170+
)
171+
172+
// when
173+
let result = try await tokenRepo.getCredentials(apiErrorSubStatus: nil)
174+
175+
// then: no logout/downgrade applies (already logged out); repository returns basic credentials
176+
switch result {
177+
case let .success(creds):
178+
XCTAssertNil(creds.userId)
179+
XCTAssertNil(tokensStore.tokensList.last?.refreshToken)
180+
case .failure:
181+
XCTFail("Expected success with basic credentials when no tokens are stored")
182+
}
183+
}
184+
185+
func testConcurrent_5xx_Coalesced_NoDowngrade() async throws {
186+
// given: expired token present
187+
createAuthConfig()
188+
let stored = Tokens(credentials: makeCredentials(isExpired: true), refreshToken: "rt")
189+
let tokensStore = FailingFakeTokensStore(credentialsKey: authConfig.credentialsKey, mode: .none)
190+
try tokensStore.saveTokens(tokens: stored)
191+
192+
// Service throws 503 repeatedly. With coalescing, only one refresh operation runs
193+
// and will perform the expected number of retry attempts.
194+
let many503 = Array(repeating: NetworkError(code: "503"), count: 10)
195+
let service = SequencedFakeTokenService(refreshThrowables: many503)
196+
let tokenRepo = TokenRepository(
197+
authConfig: authConfig,
198+
tokensStore: tokensStore,
199+
tokenService: service,
200+
defaultBackoffPolicy: TinyRetryPolicy(),
201+
upgradeBackoffPolicy: UpgradeTokenRetryPolicy(),
202+
logger: nil
203+
)
204+
205+
// when: two concurrent requests
206+
async let r1 = tokenRepo.getCredentials(apiErrorSubStatus: nil)
207+
async let r2 = tokenRepo.getCredentials(apiErrorSubStatus: nil)
208+
let result1 = try await r1
209+
let result2 = try await r2
210+
211+
// then: coalesced into a single refresh operation with retries
212+
let expectedRetries = TinyRetryPolicy().numberOfRetries + 1
213+
XCTAssertEqual(service.calls.filter { $0 == .refresh }.count, expectedRetries, "Coalescing should ensure a single refresh operation with retries")
214+
XCTAssertEqual(result1.successData, stored.credentials)
215+
XCTAssertEqual(result2.successData, stored.credentials)
216+
}
217+
218+
func testLoadTokensFailure_CurrentlyThrows() async throws {
219+
// given
220+
createAuthConfig()
221+
let tokensStore = FailingFakeTokensStore(credentialsKey: authConfig.credentialsKey, mode: .load)
222+
let tokenRepo = TokenRepository(
223+
authConfig: authConfig,
224+
tokensStore: tokensStore,
225+
tokenService: FakeTokenService(),
226+
defaultBackoffPolicy: TinyRetryPolicy(),
227+
upgradeBackoffPolicy: UpgradeTokenRetryPolicy(),
228+
logger: nil
229+
)
230+
231+
// when/then: currently throws
232+
do {
233+
_ = try await tokenRepo.getCredentials(apiErrorSubStatus: nil)
234+
XCTFail("Expected throw on load failure")
235+
} catch {
236+
// ok
237+
}
238+
}
239+
240+
func testSaveTokensFailure_CurrentlyThrows() async throws {
241+
// given: expired token present, refresh succeeds but save fails
242+
createAuthConfig()
243+
let tokensStore = FailingFakeTokensStore(credentialsKey: authConfig.credentialsKey, mode: .none)
244+
try tokensStore.saveTokens(tokens: Tokens(credentials: makeCredentials(isExpired: true), refreshToken: "rt"))
245+
tokensStore.mode = .save
246+
let tokenRepo = TokenRepository(
247+
authConfig: authConfig,
248+
tokensStore: tokensStore,
249+
tokenService: FakeTokenService(),
250+
defaultBackoffPolicy: TinyRetryPolicy(),
251+
upgradeBackoffPolicy: UpgradeTokenRetryPolicy(),
252+
logger: nil
253+
)
254+
255+
// when/then: currently throws on save
256+
do {
257+
_ = try await tokenRepo.getCredentials(apiErrorSubStatus: nil)
258+
XCTFail("Expected throw on save failure")
259+
} catch {
260+
// ok
261+
}
262+
}
263+
264+
func testTransientCooldown_CurrentBehavior_NoCooldown() async throws {
265+
// given: expired stored token; service always returns 503
266+
createAuthConfig()
267+
let tokensStore = FailingFakeTokensStore(credentialsKey: authConfig.credentialsKey, mode: .none)
268+
try tokensStore.saveTokens(tokens: Tokens(credentials: makeCredentials(isExpired: true), refreshToken: "rt"))
269+
let service = FakeTokenService(throwableToThrow: NetworkError(code: "503"))
270+
let tokenRepo = TokenRepository(
271+
authConfig: authConfig,
272+
tokensStore: tokensStore,
273+
tokenService: service,
274+
defaultBackoffPolicy: TinyRetryPolicy(),
275+
upgradeBackoffPolicy: UpgradeTokenRetryPolicy(),
276+
logger: nil
277+
)
278+
279+
// first attempt
280+
_ = try await tokenRepo.getCredentials(apiErrorSubStatus: nil)
281+
let firstAttempts = service.calls.filter { $0 == .refresh }.count
282+
283+
// immediate second attempt without cooldown in current code
284+
_ = try await tokenRepo.getCredentials(apiErrorSubStatus: nil)
285+
let secondAttempts = service.calls.filter { $0 == .refresh }.count
286+
287+
// then: currently more attempts after immediate retry (no cooldown yet)
288+
XCTAssertTrue(secondAttempts > firstAttempts, "Current behavior performs another refresh immediately; consider adding cooldown in future")
289+
}
290+
}

0 commit comments

Comments
 (0)