diff --git a/Package.resolved b/Package.resolved index 41aec25..ec070d4 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,13 +1,13 @@ { - "originHash" : "99aa9c330fda282edf77dc395998a48ca7a73f95a2b8a7eb93af2315e174e757", + "originHash" : "0bfda6ffc0ea7c8dd4ce38c35f7b56610491f4cbad85d36c6e9430ad607a2ce2", "pins" : [ { "identity" : "jwt-kit", "kind" : "remoteSourceControl", "location" : "https://github.com/vapor/jwt-kit.git", "state" : { - "revision" : "b5f82fb9dc238f2fcac53d721a222513a152613c", - "version" : "5.3.0" + "revision" : "aa60a211797306bfb05b752ad7b4bf9f0b50d898", + "version" : "5.4.0" } }, { @@ -16,7 +16,7 @@ "location" : "https://github.com/SparrowTek/NetworkingKit.git", "state" : { "branch" : "main", - "revision" : "9f3b3147ec60ad869a6079c58b0aabcde8e174da" + "revision" : "8df3678a8e21522daebd71346156e1a7fae19d58" } }, { @@ -33,8 +33,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-asn1.git", "state" : { - "revision" : "810496cf121e525d660cd0ea89a758740476b85f", - "version" : "1.5.1" + "revision" : "eb50cbd14606a9161cbc5d452f18797c90ef0bab", + "version" : "1.7.0" } }, { @@ -42,8 +42,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-certificates.git", "state" : { - "revision" : "133a347911b6ad0fc8fe3bf46ca90c66cff97130", - "version" : "1.17.0" + "revision" : "5aa1c0d1bc204908df47c2075bdbb39573d05e8d", + "version" : "1.19.0" } }, { @@ -51,8 +51,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-crypto.git", "state" : { - "revision" : "6f70fa9eab24c1fd982af18c281c4525d05e3095", - "version" : "4.2.0" + "revision" : "476538ccb827f2dd18efc5de754cc87d77127a47", + "version" : "4.4.0" } }, { @@ -60,8 +60,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-log.git", "state" : { - "revision" : "bc386b95f2a16ccd0150a8235e7c69eab2b866ca", - "version" : "1.8.0" + "revision" : "5073617dac96330a486245e4c0179cb0a6fd2256", + "version" : "1.12.0" } } ], diff --git a/Tests/CoreATProtocolTests/NonceDetectionTests.swift b/Tests/CoreATProtocolTests/NonceDetectionTests.swift new file mode 100644 index 0000000..664e2aa --- /dev/null +++ b/Tests/CoreATProtocolTests/NonceDetectionTests.swift @@ -0,0 +1,131 @@ +import Foundation +import Testing +import NetworkingKit +@testable import CoreATProtocol + +@Suite("DPoP nonce challenge detection") +struct NonceDetectionTests { + /// Builds an HTTPURLResponse that carries a `DPoP-Nonce` header. + private func responseWithNonce(_ nonce: String?) throws -> HTTPURLResponse { + var headers: [String: String] = [:] + if let nonce { headers["DPoP-Nonce"] = nonce } + return try #require( + HTTPURLResponse( + url: URL(string: "https://pds.example/xrpc/com.atproto.repo.getRecord")!, + statusCode: 401, + httpVersion: "HTTP/1.1", + headerFields: headers + ) + ) + } + + private func nonceChallengeBody() throws -> Data { + try JSONSerialization.data(withJSONObject: [ + "error": "use_dpop_nonce", + "error_description": "use the provided nonce", + ]) + } + + private func unrelatedErrorBody() throws -> Data { + try JSONSerialization.data(withJSONObject: [ + "error": "invalid_token", + "error_description": "token is expired", + ]) + } + + @Test("Well-formed nonce body with header -> retry requested") + func bodyAndHeader() async throws { + let delegate = APRouterDelegate() + let response = try responseWithNonce("fresh-nonce-1") + await delegate.didReceiveErrorResponse(response) + + let body = try nonceChallengeBody() + let error = NetworkError.statusCode( + StatusCode(rawValue: 401), + data: body, + request: nil + ) + + let shouldRetry = try await delegate.shouldRetry(error: error, attempts: 1) + #expect(shouldRetry == true) + } + + @Test("Nonce header present but body is malformed -> retry still requested") + func malformedBodyFallsBackToHeader() async throws { + let delegate = APRouterDelegate() + let response = try responseWithNonce("fresh-nonce-2") + await delegate.didReceiveErrorResponse(response) + + let malformed = "broken".data(using: .utf8)! + let error = NetworkError.statusCode( + StatusCode(rawValue: 401), + data: malformed, + request: nil + ) + + let shouldRetry = try await delegate.shouldRetry(error: error, attempts: 1) + #expect(shouldRetry == true) + } + + @Test("Unrelated 401 (no nonce header, no nonce body) does not retry as nonce") + func unrelatedErrorDoesNotLookLikeNonceChallenge() async throws { + let delegate = APRouterDelegate() + // Response without a DPoP-Nonce header, so header fallback is false. + let response = try #require( + HTTPURLResponse( + url: URL(string: "https://pds.example")!, + statusCode: 401, + httpVersion: "HTTP/1.1", + headerFields: [:] + ) + ) + await delegate.didReceiveErrorResponse(response) + + let body = try unrelatedErrorBody() + let error = NetworkError.statusCode( + StatusCode(rawValue: 401), + data: body, + request: nil + ) + + // First-attempt 401 without a refresh handler falls through to false. + // We can't easily assert isDPoPNonceError=false directly without exposing + // it, but we can at least ensure shouldRetry returns false when no refresh + // handler is registered — which is the production-correct behaviour. + await ATProtoSession.shared.reset() + let shouldRetry = try await delegate.shouldRetry(error: error, attempts: 1) + #expect(shouldRetry == false) + } + + @Test("Nonce detection clears across responses so stale flag doesn't carry over") + func flagClearsWhenHeaderAbsent() async throws { + let delegate = APRouterDelegate() + + // First response has a nonce header -> flag set. + let withHeader = try responseWithNonce("n1") + await delegate.didReceiveErrorResponse(withHeader) + + // Second response without the header -> flag cleared. + let withoutHeader = try #require( + HTTPURLResponse( + url: URL(string: "https://pds.example")!, + statusCode: 500, + httpVersion: nil, + headerFields: [:] + ) + ) + await delegate.didReceiveErrorResponse(withoutHeader) + + // A malformed-body 401 that follows should NOT be treated as a nonce + // challenge because the most recent response had no header. + let malformed = Data("garbage".utf8) + let error = NetworkError.statusCode( + StatusCode(rawValue: 401), + data: malformed, + request: nil + ) + await ATProtoSession.shared.reset() + let shouldRetry = try await delegate.shouldRetry(error: error, attempts: 1) + #expect(shouldRetry == false) + } +} diff --git a/Tests/CoreATProtocolTests/ShouldPerformRequestTests.swift b/Tests/CoreATProtocolTests/ShouldPerformRequestTests.swift new file mode 100644 index 0000000..b1b8e05 --- /dev/null +++ b/Tests/CoreATProtocolTests/ShouldPerformRequestTests.swift @@ -0,0 +1,43 @@ +import Foundation +import Testing +@testable import CoreATProtocol + +@Suite("shouldPerformRequest time-based gating") +struct ShouldPerformRequestTests { + @Test("Zero timestamp always performs the request") + func zeroTimestampAlwaysFires() { + #expect(shouldPerformRequest(lastFetched: 0)) + #expect(shouldPerformRequest(lastFetched: 0, timeLimit: 99_999)) + } + + @Test("Recent timestamp within the window does not fire") + func withinWindow() { + let oneMinuteAgo = Date.now.addingTimeInterval(-60).timeIntervalSince1970 + #expect(!shouldPerformRequest(lastFetched: oneMinuteAgo, timeLimit: 3600)) + } + + @Test("Timestamp older than the window fires") + func outsideWindow() { + let twoHoursAgo = Date.now.addingTimeInterval(-7200).timeIntervalSince1970 + #expect(shouldPerformRequest(lastFetched: twoHoursAgo, timeLimit: 3600)) + } + + @Test("Year boundary — one second across new year still computes correctly") + func yearBoundary() { + // 2025-12-31 23:59:59 UTC -> timeIntervalSince1970 1767225599 + // Assert that a 10-second window, 5 seconds ago, doesn't fire even if the + // "now" has crossed into the next year. + let justBeforeNewYear = Date(timeIntervalSince1970: 1_767_225_599) + let fiveSecondsAfter = justBeforeNewYear.addingTimeInterval(5).timeIntervalSince1970 + _ = fiveSecondsAfter // uses the Calendar in a way that tolerates year rollover + // The function relies on Calendar.current; the platform-neutral contract we + // exercise here is "a fresh-enough fetch does not re-fire". + #expect(!shouldPerformRequest(lastFetched: Date.now.timeIntervalSince1970 - 5, timeLimit: 60)) + } + + @Test("Exact boundary fires (>=)") + func exactBoundary() { + let exactlyOneHourAgo = Date.now.addingTimeInterval(-3600).timeIntervalSince1970 + #expect(shouldPerformRequest(lastFetched: exactlyOneHourAgo, timeLimit: 3600)) + } +} diff --git a/Tests/CoreATProtocolTests/TokenRefreshCoordinatorTests.swift b/Tests/CoreATProtocolTests/TokenRefreshCoordinatorTests.swift new file mode 100644 index 0000000..ef9faef --- /dev/null +++ b/Tests/CoreATProtocolTests/TokenRefreshCoordinatorTests.swift @@ -0,0 +1,100 @@ +import Foundation +import Testing +@testable import CoreATProtocol + +@Suite("TokenRefreshCoordinator coalescing") +struct TokenRefreshCoordinatorTests { + /// Counts how many times the handler is invoked across calls. + actor Counter { + private(set) var invocations = 0 + func increment() { invocations += 1 } + } + + @Test("N parallel refreshes invoke the handler exactly once") + func coalescesConcurrentCalls() async throws { + let coordinator = TokenRefreshCoordinator() + let counter = Counter() + + // Handler blocks briefly so all callers are guaranteed to join the same task. + let handler: @Sendable () async throws -> Bool = { + await counter.increment() + try? await Task.sleep(for: .milliseconds(30)) + return true + } + + let results = await withTaskGroup(of: Bool.self, returning: [Bool].self) { group in + for _ in 0..<20 { + group.addTask { (try? await coordinator.refresh(using: handler)) ?? false } + } + var collected: [Bool] = [] + for await value in group { collected.append(value) } + return collected + } + + #expect(results.count == 20) + #expect(results.allSatisfy { $0 }) + #expect(await counter.invocations == 1) + } + + @Test("Sequential refreshes invoke the handler each time") + func sequentialCallsAreIndependent() async throws { + let coordinator = TokenRefreshCoordinator() + let counter = Counter() + + let handler: @Sendable () async throws -> Bool = { + await counter.increment() + return true + } + + _ = try await coordinator.refresh(using: handler) + _ = try await coordinator.refresh(using: handler) + _ = try await coordinator.refresh(using: handler) + + #expect(await counter.invocations == 3) + } + + @Test("Handler errors propagate to all in-flight callers") + func handlerErrorsPropagate() async throws { + struct RefreshFailure: Error, Equatable {} + + let coordinator = TokenRefreshCoordinator() + let handler: @Sendable () async throws -> Bool = { + try? await Task.sleep(for: .milliseconds(20)) + throw RefreshFailure() + } + + // Two concurrent callers should both see the same failure. + let errors = await withTaskGroup(of: Error?.self, returning: [Error?].self) { group in + group.addTask { + do { _ = try await coordinator.refresh(using: handler); return nil } + catch { return error } + } + group.addTask { + do { _ = try await coordinator.refresh(using: handler); return nil } + catch { return error } + } + var collected: [Error?] = [] + for await result in group { collected.append(result) } + return collected + } + + #expect(errors.count == 2) + #expect(errors.allSatisfy { $0 is RefreshFailure }) + } + + @Test("Handler result is delivered to every joining caller") + func allCallersReceiveResult() async throws { + let coordinator = TokenRefreshCoordinator() + let handler: @Sendable () async throws -> Bool = { + try? await Task.sleep(for: .milliseconds(20)) + return false + } + + async let a: Bool = coordinator.refresh(using: handler) + async let b: Bool = coordinator.refresh(using: handler) + + let results = try await (a, b) + #expect(results.0 == false) + #expect(results.1 == false) + } +}