Files
yattee/YatteeTests/CredentialProbeTests.swift
2026-09-07 07:30:00 +02:00

487 lines
18 KiB
Swift

//
// CredentialProbeTests.swift
// YatteeTests
//
// The two-stage rule: an auth-shaped failure only ever *suspects* rejection,
// and nothing but a deliberate probe may confirm it.
//
import Testing
import Foundation
@testable import Yattee
/// A probe service wired to a scripted transport instead of a server.
///
/// Holds the credential in a mutable box so a test can replace it mid-probe,
/// and can park every probe inside the transport so the window between "the
/// request failed" and "the probe answered" can be inspected — that window is
/// where the whole design lives.
@MainActor
private final class ProbeHarness {
let instances: InstancesManager
let instance: Instance
let accounts: AccountsManager
let account: Account
private(set) var service: CredentialProbeService!
/// What the credential reader reports right now.
var credential: String? = "sid-original"
/// What the next probe concludes.
var verdict: CredentialProbeService.Verdict = .authenticated
/// Per-credential answers, for the tests where two probes overlap and each
/// has to conclude something different.
var verdictsByCredential: [String: CredentialProbeService.Verdict] = [:]
private(set) var transportCalls = 0
private(set) var probedCredentials: [String] = []
/// While true, every probe suspends inside the transport until `release()`.
var holdsProbesOpen = false
private var parked: [(credential: String, continuation: CheckedContinuation<Void, Never>)] = []
init(
type: InstanceType = .invidious,
usesBasicAuth: Bool = false,
cooldown: TimeInterval = 0,
unreachableRetryInterval: TimeInterval = 60
) {
instances = makeTestInstancesManager()
instance = Instance(
type: type,
url: URL(string: "https://probe-\(UUID().uuidString).example.com")!,
usesBasicAuth: usesBasicAuth
)
instances.add(instance)
accounts = AccountsManager(
identityScope: CredentialIdentityScope(fixedScope: "identity-A"),
instancesManager: instances,
defaults: UserDefaults(suiteName: "yattee.tests.\(UUID().uuidString)")!,
unreachableRetryInterval: unreachableRetryInterval
)
account = accounts.addAccount(instanceID: instance.id)
accounts.setStatus(.signedIn, for: account.id)
service = CredentialProbeService(
accountsManager: accounts,
credentialReader: { [weak self] _ in self?.credential },
transport: { [weak self] _, credential in
guard let self else { return .indeterminate }
return await self.runProbe(credential)
},
cooldown: cooldown
)
accounts.onSuspectedCredentialRejection = { [weak self] account in
self?.service.probeSuspectedRejection(for: account)
}
}
/// Instances live in the shared defaults, so each harness has to take its
/// own back out again.
func tearDown() {
instances.remove(instance)
}
var status: AccountStatus {
accounts.status(for: account.id)
}
var probeTask: Task<Void, Never>? {
service.probeTasks[account.id]
}
func record(_ error: Error?) {
accounts.recordServerOutcome(error, for: account)
}
private func runProbe(_ credential: String) async -> CredentialProbeService.Verdict {
transportCalls += 1
probedCredentials.append(credential)
if holdsProbesOpen {
await withCheckedContinuation { parked.append((credential, $0)) }
}
return verdictsByCredential[credential] ?? verdict
}
/// Lets parked probes finish — the one probing `credential`, or every one
/// of them when it is `nil`, which also stops new probes being parked.
func release(credential: String? = nil) {
guard let credential else {
holdsProbesOpen = false
let waiting = parked
parked = []
waiting.forEach { $0.continuation.resume() }
return
}
let matching = parked.filter { $0.credential == credential }
parked.removeAll { $0.credential == credential }
matching.forEach { $0.continuation.resume() }
}
/// Waits until the transport has been entered at least `count` times.
func waitForTransport(count: Int) async throws {
for _ in 0 ..< 200 {
if transportCalls >= count { return }
try await Task.sleep(for: .milliseconds(5))
}
Issue.record("The transport was never entered \(count) time(s)")
}
}
@Suite("Credential probe", .serialized)
@MainActor
struct CredentialProbeTests {
// MARK: - The raw status never decides
@Test("A 403 asks the server again instead of rejecting the login")
func forbiddenNeverRejectsDirectly() async throws {
let harness = ProbeHarness()
defer { harness.tearDown() }
harness.holdsProbesOpen = true
harness.verdict = .rejected
harness.record(APIError.httpError(statusCode: 403, message: "Request must be authenticated"))
// Invidious answers exactly this for a revoked session — and for
// several things that are not one. The failed request may not conclude
// anything by itself.
#expect(harness.status == .signedIn)
try await harness.waitForTransport(count: 1)
#expect(harness.status == .signedIn)
let task = try #require(harness.probeTask)
harness.release()
await task.value
// Only the probe's own answer marks the account.
#expect(harness.status == .credentialRejected)
}
@Test("An unauthorized error goes through the probe as well")
func unauthorizedRoutesThroughTheProbe() async throws {
let harness = ProbeHarness()
defer { harness.tearDown() }
harness.holdsProbesOpen = true
harness.verdict = .rejected
harness.record(APIError.unauthorized)
// A bare 401 used to reject on the spot. A reverse proxy answers it too.
#expect(harness.status == .signedIn)
try await harness.waitForTransport(count: 1)
let task = try #require(harness.probeTask)
harness.release()
await task.value
#expect(harness.status == .credentialRejected)
}
// MARK: - Verdicts
@Test("A confirmed rejection makes the selected context fall back to local")
func confirmedRejectionEngagesTheFallback() async throws {
let harness = ProbeHarness()
defer { harness.tearDown() }
harness.verdict = .rejected
let validator = DataContextValidator(
accountsManager: harness.accounts,
instancesManager: harness.instances,
toastManager: ToastManager()
)
harness.accounts.setContext(.account(harness.account.id))
#expect(validator.validity == .valid)
harness.record(APIError.httpError(statusCode: 403, message: nil))
await harness.probeTask?.value
#expect(harness.status == .credentialRejected)
// The point of the whole exercise: reads stop going to a server that
// will not answer them, without the user's choice being thrown away.
#expect(validator.validity == .temporarilyUnavailable(.credentialRejected))
#expect(validator.effectiveContext == .local)
#expect(!validator.allowsServerMutations)
#expect(harness.accounts.currentContext == .account(harness.account.id))
}
@Test("A probe the server accepts leaves the account signed in")
func acceptedProbeKeepsTheAccountUsable() async throws {
let harness = ProbeHarness()
defer { harness.tearDown() }
harness.verdict = .authenticated
harness.record(APIError.httpError(statusCode: 403, message: nil))
await harness.probeTask?.value
// The credential is fine; the operation that failed was not allowed.
#expect(harness.transportCalls == 1)
#expect(harness.status == .signedIn)
}
@Test("A probe that cannot reach the server is retried, not written off")
func unreachableProbeLiftsItself() async throws {
let harness = ProbeHarness(unreachableRetryInterval: 0.05)
defer { harness.tearDown() }
harness.verdict = .unreachable
harness.record(APIError.httpError(statusCode: 403, message: nil))
await harness.probeTask?.value
#expect(harness.status == .unreachable)
// Nothing about the credential was learned, so the state has to expire
// on its own — the same auto-expiry every unreachable account gets.
try await Task.sleep(for: .seconds(0.5))
#expect(harness.status == .signedIn)
}
// MARK: - What must not be probed
@Test("An instance behind basic auth is never probed")
func basicAuthInstanceIsNotProbed() async throws {
let harness = ProbeHarness(usesBasicAuth: true)
defer { harness.tearDown() }
harness.verdict = .rejected
harness.record(APIError.httpError(statusCode: 401, message: nil))
harness.record(APIError.unauthorized)
try await Task.sleep(for: .milliseconds(50))
// The proxy answers 401 for every request to this instance whoever
// makes it, and would answer the probe identically.
#expect(harness.transportCalls == 0)
#expect(harness.probeTask == nil)
#expect(harness.status == .signedIn)
}
@Test("Errors that are not about authentication start no probe")
func nonAuthErrorsDoNotProbe() async throws {
let harness = ProbeHarness()
defer { harness.tearDown() }
harness.verdict = .rejected
harness.record(APIError.httpError(statusCode: 500, message: "Internal Server Error"))
#expect(harness.probeTask == nil)
harness.record(APIError.decodingError("unexpected shape"))
#expect(harness.probeTask == nil)
try await Task.sleep(for: .milliseconds(50))
#expect(harness.transportCalls == 0)
#expect(harness.status == .signedIn)
}
// MARK: - Staleness and dedup
@Test("A verdict about a credential that has since been replaced is dropped")
func replacedCredentialDropsTheVerdict() async throws {
let harness = ProbeHarness()
defer { harness.tearDown() }
harness.holdsProbesOpen = true
harness.verdict = .rejected
harness.record(APIError.httpError(statusCode: 403, message: nil))
try await harness.waitForTransport(count: 1)
// A replacement arriving over iCloud Keychain — or the user signing in
// again — while the probe was out. The answer describes the old secret.
harness.credential = "sid-replacement"
let task = try #require(harness.probeTask)
harness.release()
await task.value
#expect(harness.probedCredentials == ["sid-original"])
#expect(harness.status == .signedIn)
}
@Test("A dropped verdict never silences the replacement credential's first probe")
func droppedVerdictStartsNoCooldown() async throws {
let harness = ProbeHarness(cooldown: 60)
defer { harness.tearDown() }
harness.holdsProbesOpen = true
harness.verdict = .rejected
harness.record(APIError.httpError(statusCode: 403, message: nil))
try await harness.waitForTransport(count: 1)
harness.credential = "sid-replacement"
let dropped = try #require(harness.probeTask)
harness.release()
await dropped.value
#expect(harness.status == .signedIn)
// The replacement can be rejected too, and nothing has asked about it
// yet — the login the user just fixed is exactly the one whose failure
// has to be found quickly, not a minute later.
harness.record(APIError.httpError(statusCode: 403, message: nil))
let second = try #require(harness.probeTask)
await second.value
#expect(harness.transportCalls == 2)
#expect(harness.probedCredentials == ["sid-original", "sid-replacement"])
#expect(harness.status == .credentialRejected)
}
@Test("Replacing the credential retires the cooldown the old one earned")
func credentialChangeClearsTheCooldown() async throws {
let harness = ProbeHarness(cooldown: 60)
defer { harness.tearDown() }
harness.verdict = .authenticated
harness.record(APIError.httpError(statusCode: 403, message: nil))
await harness.probeTask?.value
#expect(harness.transportCalls == 1)
// Both gates are keyed by account alone and are checked before the
// credential is even read, so the new login would otherwise inherit the
// old one's silence for a full minute.
harness.credential = "sid-replacement"
harness.service.credentialDidChange(for: harness.account.id)
harness.verdict = .rejected
harness.record(APIError.httpError(statusCode: 403, message: nil))
let second = try #require(harness.probeTask)
await second.value
#expect(harness.transportCalls == 2)
#expect(harness.probedCredentials == ["sid-original", "sid-replacement"])
#expect(harness.status == .credentialRejected)
}
@Test("A probe left over from a replaced credential is disowned, not merely stale")
func credentialChangeInvalidatesAProbeInFlight() async throws {
let harness = ProbeHarness(cooldown: 60)
defer { harness.tearDown() }
harness.holdsProbesOpen = true
harness.verdictsByCredential = [
"sid-original": .rejected,
"sid-replacement": .authenticated
]
harness.record(APIError.httpError(statusCode: 403, message: nil))
try await harness.waitForTransport(count: 1)
let stale = try #require(harness.probeTask)
harness.credential = "sid-replacement"
harness.service.credentialDidChange(for: harness.account.id)
// Disowned and cancelled: nobody is going to listen to its answer.
#expect(harness.probeTask == nil)
#expect(stale.isCancelled)
// The replacement's own failure is examined at once rather than waiting
// out a round trip that is about a secret nobody holds any more.
harness.record(APIError.httpError(statusCode: 403, message: nil))
let fresh = try #require(harness.probeTask)
#expect(fresh != stale)
try await harness.waitForTransport(count: 2)
harness.release(credential: "sid-original")
await stale.value
// The late rejection belongs to the old secret and may not be spoken —
// nor may finishing evict the probe that has taken its place.
#expect(harness.status == .signedIn)
#expect(harness.probeTask == fresh)
harness.release(credential: "sid-replacement")
await fresh.value
#expect(harness.transportCalls == 2)
#expect(harness.probedCredentials == ["sid-original", "sid-replacement"])
#expect(harness.status == .signedIn)
#expect(harness.probeTask == nil)
}
@Test("Repeated failures while a probe is in flight cost one round trip")
func concurrentTriggersDedupe() async throws {
let harness = ProbeHarness()
defer { harness.tearDown() }
harness.holdsProbesOpen = true
harness.verdict = .rejected
harness.record(APIError.httpError(statusCode: 403, message: nil))
try await harness.waitForTransport(count: 1)
// A feed refresh produces a burst of these.
harness.record(APIError.httpError(statusCode: 403, message: nil))
harness.record(APIError.unauthorized)
let task = try #require(harness.probeTask)
harness.release()
await task.value
#expect(harness.transportCalls == 1)
#expect(harness.status == .credentialRejected)
}
@Test("A finished probe stands for the cooldown")
func cooldownSuppressesAnImmediateReprobe() async throws {
let harness = ProbeHarness(cooldown: 60)
defer { harness.tearDown() }
harness.verdict = .authenticated
harness.record(APIError.httpError(statusCode: 403, message: nil))
await harness.probeTask?.value
#expect(harness.transportCalls == 1)
// The next failed request arrives moments later and asks the same
// question; the answer cannot have changed.
harness.record(APIError.httpError(statusCode: 403, message: nil))
#expect(harness.probeTask == nil)
try await Task.sleep(for: .milliseconds(50))
#expect(harness.transportCalls == 1)
#expect(harness.status == .signedIn)
}
// MARK: - Classification
@Test("Only the two authentication statuses reject")
func classifyMapsOutcomes() {
#expect(CredentialProbeService.classify(nil) == .authenticated)
#expect(CredentialProbeService.classify(APIError.unauthorized) == .rejected)
#expect(CredentialProbeService.classify(
APIError.httpError(statusCode: 401, message: nil)
) == .rejected)
// The one the shared HTTP mapping leaves untranslated.
#expect(CredentialProbeService.classify(
APIError.httpError(statusCode: 403, message: "Request must be authenticated")
) == .rejected)
#expect(CredentialProbeService.classify(APIError.timeout) == .unreachable)
#expect(CredentialProbeService.classify(APIError.noConnection) == .unreachable)
// A probe that never completed answers nothing.
#expect(CredentialProbeService.classify(APIError.cancelled) == .indeterminate)
#expect(CredentialProbeService.classify(
APIError.httpError(statusCode: 500, message: nil)
) == .indeterminate)
}
// MARK: - Regression
@Test("A successful request still lifts a rejection")
func successLiftsARejection() {
let harness = ProbeHarness()
defer { harness.tearDown() }
harness.accounts.setStatus(.credentialRejected, for: harness.account.id)
harness.record(nil)
#expect(harness.status == .signedIn)
}
}