diff --git a/InterlinedList/Models/IdentityHealth.swift b/InterlinedList/Models/IdentityHealth.swift index aca4ab3..63b05f1 100644 --- a/InterlinedList/Models/IdentityHealth.swift +++ b/InterlinedList/Models/IdentityHealth.swift @@ -41,8 +41,13 @@ enum ProviderStatusOutcome: Equatable { /// Per-identity health, as far as the status routes can honestly report it. enum IdentityHealth: Equatable { case connected + /// Stronger than `connected`: the stored credential was exercised against the + /// provider and came back working (`POST /api/user/identities/verify`). No + /// `/status` route can produce this — none of them touches the token. + case verified /// The provider positively contradicted a listed identity — the row it should - /// have is gone, so reconnecting is the repair. + /// have is gone, or the stored credential was rejected — so reconnecting is + /// the repair. case needsReconnect(reason: String) /// Either the check failed, or the route can't speak to this identity at all. /// Never rendered as "disconnected". @@ -54,6 +59,47 @@ enum IdentityHealth: Equatable { } } +/// What `POST /api/user/identities/verify` answered for one identity. +/// +/// Both cases are *answers*: the route loaded the stored credential and reported +/// on it. A check that could not be made throws instead of landing here, because +/// it proves nothing about the credential either way. +enum IdentityVerification: Equatable { + /// `200 {"success":true}` — the credential still works upstream (and the + /// backend stamped `lastVerifiedAt`). + case verified + case needsReconnect(IdentityVerificationFailure) +} + +/// The two ways the verify route says "this connection is unusable". +enum IdentityVerificationFailure: Equatable { + /// `400` — the provider rejected the stored credential (expired or revoked), + /// or there is no token stored to check at all. + case credentialRejected + /// `404` — the account no longer has an identity row for this provider. + case identityMissing +} + +extension IdentityHealth { + init(verification: IdentityVerification, providerName: String) { + switch verification { + case .verified: + self = .verified + case .needsReconnect(.credentialRejected): + self = .needsReconnect(reason: "\(providerName) rejected the saved sign-in. Reconnect to keep posting.") + case .needsReconnect(.identityMissing): + self = .needsReconnect(reason: "\(providerName) is no longer connected to this account.") + } + } + + /// The check itself failed — no route, no session, no answer. Deliberately not + /// `needsReconnect`: a dead check says nothing about the credential, and + /// sending a user to re-authorize a working account is worse than saying nothing. + static func uncheckable(providerName: String) -> IdentityHealth { + .unknown(reason: "Couldn't run the check just now. Your \(providerName) connection is unchanged.") + } +} + /// The five status outcomes gathered for one pass over `LinkedIdentitiesView`. /// /// Every field defaults to `failed` so a snapshot that was never filled in reads diff --git a/InterlinedList/Services/APIClient+Identities.swift b/InterlinedList/Services/APIClient+Identities.swift index df98f27..17984cc 100644 --- a/InterlinedList/Services/APIClient+Identities.swift +++ b/InterlinedList/Services/APIClient+Identities.swift @@ -26,8 +26,11 @@ import Foundation /// The backend *does* track real per-identity health (`LinkedIdentity.needsReconnect`, /// flagged by `lib/twitter/token-refresh.ts` on a permanent auth failure, and /// already surfaced by `getLinkedIdentitiesForUser`), but `GET /api/user/identities` -/// does not select it. Until it does, a token revoked upstream cannot be seen -/// from the app — see `IdentityHealth` for how far these routes let us go. +/// does not select it — so a revoked token is invisible to every route in this +/// file. The one route that does exercise the stored credential is +/// `POST /api/user/identities/verify` (`APIClient.verifyIdentity(provider:)`); it +/// calls the third-party provider, so it runs only when the user asks for it. +/// See `IdentityHealth` for how far these routes let us go without it. extension APIClient { /// `GET /api/auth/github/status` — public. `clientId` is not a secret (the diff --git a/InterlinedList/Services/APIClient.swift b/InterlinedList/Services/APIClient.swift index 1fb82b6..16c2aa2 100644 --- a/InterlinedList/Services/APIClient.swift +++ b/InterlinedList/Services/APIClient.swift @@ -190,10 +190,47 @@ final class APIClient { return allowed }() - func verifyIdentity(provider: String, providerId: String) async throws { - struct Body: Encodable { let provider: String; let providerId: String } - struct Response: Decodable { let ok: Bool? } - let _: Response = try await postCamel("/api/user/identities/verify", body: Body(provider: provider, providerId: providerId)) + /// Carries the verify route's two "credential is unusable" statuses out of the + /// transport, which can only signal by throwing, back to the outcome value + /// `verifyIdentity` returns. + private enum IdentityVerificationRejection: Error { + case credentialRejected + case identityMissing + } + + /// `POST /api/user/identities/verify` — the only route that proves a linked + /// identity's stored credential still works. It loads the credential and + /// exercises it upstream: Bluesky by restoring the DPoP-bound AT Protocol + /// session, every other provider by calling its "who am I" endpoint. On success + /// the backend stamps `lastVerifiedAt`. + /// + /// This is emphatically **not** what the five `/status` routes report. Those + /// answer either "the server holds OAuth app credentials for this provider" + /// (LinkedIn/Twitter/GitHub) or "a row exists in this user's identity table" + /// (Bluesky/Mastodon) — both stay cheerfully `configured: true` after a token is + /// revoked upstream. See `APIClient+Identities` for the full breakdown. + /// + /// The route reads only `body.provider`; the `providerId` it also accepts is + /// never looked at, so it isn't sent. Each call hits the third-party provider, + /// so this is a user-initiated check, never an on-appear sweep. + func verifyIdentity(provider: String) async throws -> IdentityVerification { + struct Body: Encodable { let provider: String } + struct Response: Decodable { let success: Bool? } + do { + let _: Response = try await postCamel( + "/api/user/identities/verify", + body: Body(provider: provider), + mappingStatuses: [ + 400: IdentityVerificationRejection.credentialRejected, + 404: IdentityVerificationRejection.identityMissing, + ] + ) + return .verified + } catch IdentityVerificationRejection.credentialRejected { + return .needsReconnect(.credentialRejected) + } catch IdentityVerificationRejection.identityMissing { + return .needsReconnect(.identityMissing) + } } // MARK: - OAuth configuration status diff --git a/InterlinedList/Services/APIClientTransport.swift b/InterlinedList/Services/APIClientTransport.swift index 02e8678..637afb7 100644 --- a/InterlinedList/Services/APIClientTransport.swift +++ b/InterlinedList/Services/APIClientTransport.swift @@ -97,6 +97,24 @@ extension APIClient { return try await perform(request) } + /// POST (camelCase body) that maps selected non-2xx statuses to caller-supplied + /// errors *before* the generic body-driven mapping runs — the write-side twin of + /// `get(_:mappingStatuses:)`. For routes where a 4xx is an **answer** rather than + /// a fault: identity verification replies `400` for "this credential is dead", + /// which `checkResponse` would otherwise flatten into the same `.server(…)` a + /// 500 produces, making "your token expired" indistinguishable from "the check + /// couldn't run". + func postCamel(_ path: String, body: B, mappingStatuses statusErrors: [Int: Error]) async throws -> T { + var request = try jsonRequest(path, method: "POST") + request.httpBody = try camelCaseEncoder.encode(body) + let (data, response) = try await session.data(for: request) + if let status = (response as? HTTPURLResponse)?.statusCode, let mapped = statusErrors[status] { + throw mapped + } + try checkResponse(data: data, response: response) + return try decoder.decode(T.self, from: data) + } + func putCamel(_ path: String, body: B) async throws -> T { var request = try jsonRequest(path, method: "PUT") request.httpBody = try camelCaseEncoder.encode(body) diff --git a/InterlinedList/Views/LinkedIdentitiesView.swift b/InterlinedList/Views/LinkedIdentitiesView.swift index a869c63..1d586c3 100644 --- a/InterlinedList/Views/LinkedIdentitiesView.swift +++ b/InterlinedList/Views/LinkedIdentitiesView.swift @@ -30,6 +30,10 @@ struct LinkedIdentitiesView: View { /// Set when the Mastodon prompt is repairing an existing row rather than /// linking a fresh one, so the reconnect follow-up can re-check that row. @State private var reconnectingIdentity: APIClient.LinkedIdentity? + /// Verify-route results keyed by identity id, kept apart from `statusSnapshot` + /// because only these exercised the stored credential. + @State private var verifiedHealth: [String: IdentityHealth] = [:] + @State private var checkingIdentityIDs: Set = [] var body: some View { List { @@ -56,7 +60,7 @@ struct LinkedIdentitiesView: View { Text("Connected accounts") } footer: { if !isLoading && !identities.isEmpty { - Text("A connection shown as unknown couldn't be checked — it hasn't been disconnected.") + Text("A connection shown as unknown couldn't be checked — it hasn't been disconnected. Check connection asks the provider whether your saved sign-in still works.") .font(.ilMono(12)) } } @@ -118,7 +122,9 @@ struct LinkedIdentitiesView: View { @ViewBuilder private func identityRow(_ identity: APIClient.LinkedIdentity) -> some View { - let health = statusSnapshot?.health(for: identity) + // A verify result outranks the snapshot: it exercised the credential + // itself, which no `/status` route does. + let health = verifiedHealth[identity.id] ?? statusSnapshot?.health(for: identity) HStack(spacing: 12) { Image(systemName: OAuthProvider(rawValue: identity.providerType)?.systemImageName ?? "link") .frame(width: 24) @@ -132,14 +138,17 @@ struct LinkedIdentitiesView: View { .foregroundStyle(.secondary) } healthLabel(health, for: identity) - if health?.isStale == true { - Button("Reconnect") { - startReconnect(identity) + HStack(spacing: 8) { + checkConnectionButton(identity) + if health?.isStale == true { + Button("Reconnect") { + startReconnect(identity) + } + .buttonStyle(.bordered) + .font(.ilBody(14)) + .disabled(linkInFlight) + .accessibilityLabel("Reconnect \(displayName(for: identity.provider))") } - .buttonStyle(.bordered) - .font(.ilBody(14)) - .disabled(linkInFlight) - .accessibilityLabel("Reconnect \(displayName(for: identity.provider))") } } Spacer() @@ -152,6 +161,25 @@ struct LinkedIdentitiesView: View { } } + @ViewBuilder + private func checkConnectionButton(_ identity: APIClient.LinkedIdentity) -> some View { + let name = displayName(for: identity.provider) + let isChecking = checkingIdentityIDs.contains(identity.id) + Button { + Task { await checkConnection(identity) } + } label: { + if isChecking { + ProgressView().controlSize(.small) + } else { + Text("Check connection") + } + } + .buttonStyle(.bordered) + .font(.ilBody(14)) + .disabled(isChecking) + .accessibilityLabel(isChecking ? "Checking \(name) connection" : "Check \(name) connection") + } + @ViewBuilder private func healthLabel(_ health: IdentityHealth?, for identity: APIClient.LinkedIdentity) -> some View { let name = displayName(for: identity.provider) @@ -166,6 +194,11 @@ struct LinkedIdentitiesView: View { .font(.ilMono(12)) .foregroundStyle(.green) .accessibilityLabel("\(name) connected") + case .verified: + Label("Sign-in verified", systemImage: "checkmark.seal.fill") + .font(.ilMono(12)) + .foregroundStyle(.green) + .accessibilityLabel("\(name) sign-in verified") case .needsReconnect(let reason): VStack(alignment: .leading, spacing: 2) { Label("Needs reconnect", systemImage: "exclamationmark.triangle.fill") @@ -194,6 +227,7 @@ struct LinkedIdentitiesView: View { private func load() async { errorMessage = nil isLoading = true + verifiedHealth = [:] do { identities = try await APIClient.shared.linkedIdentities() isLoading = false @@ -268,6 +302,24 @@ struct LinkedIdentitiesView: View { } } + /// Manual and per-identity on purpose: the backend calls the third-party + /// provider for every one of these, so a sweep on appear would bill five + /// remote round-trips to a screen the user only opened to read. + private func checkConnection(_ identity: APIClient.LinkedIdentity) async { + let name = displayName(for: identity.provider) + checkingIdentityIDs.insert(identity.id) + defer { checkingIdentityIDs.remove(identity.id) } + do { + let verification = try await APIClient.shared.verifyIdentity(provider: identity.provider) + verifiedHealth[identity.id] = IdentityHealth(verification: verification, providerName: name) + } catch APIError.status(401) { + verifiedHealth[identity.id] = .uncheckable(providerName: name) + authState.handleUnauthorized() + } catch { + verifiedHealth[identity.id] = .uncheckable(providerName: name) + } + } + private func unlink(_ identity: APIClient.LinkedIdentity) async { errorMessage = nil pendingUnlink = nil diff --git a/InterlinedListTests/APIClientTests/APIClientIdentitiesTests.swift b/InterlinedListTests/APIClientTests/APIClientIdentitiesTests.swift index 01b22bf..7c92d32 100644 --- a/InterlinedListTests/APIClientTests/APIClientIdentitiesTests.swift +++ b/InterlinedListTests/APIClientTests/APIClientIdentitiesTests.swift @@ -143,17 +143,79 @@ final class APIClientIdentitiesTests: XCTestCase { // MARK: verifyIdentity func test_verifyIdentity_sendsCorrectPath() async throws { - session.stub(json: #"{"ok":true}"#) - try await sut.verifyIdentity(provider: "bluesky", providerId: "did:plc:abc") + session.stub(json: #"{"success":true}"#) + _ = try await sut.verifyIdentity(provider: "bluesky") XCTAssertEqual(session.lastRequest?.url?.path, "/api/user/identities/verify") XCTAssertEqual(session.lastRequest?.httpMethod, "POST") } func test_verifyIdentity_bodyUsesCamelCase() async throws { - session.stub(json: #"{"ok":true}"#) - try await sut.verifyIdentity(provider: "bluesky", providerId: "did:plc:abc") + session.stub(json: #"{"success":true}"#) + _ = try await sut.verifyIdentity(provider: "mastodon:techhub.social") let body = String(data: session.lastRequest?.httpBody ?? Data(), encoding: .utf8) ?? "" - XCTAssertTrue(body.contains("\"providerId\":\"did:plc:abc\""), "Got: \(body)") + XCTAssertTrue(body.contains("\"provider\":\"mastodon:techhub.social\""), "Got: \(body)") + } + + /// The route reads `body.provider` only — `providerId` was accepted and ignored. + func test_verifyIdentity_bodyOmitsProviderId() async throws { + session.stub(json: #"{"success":true}"#) + _ = try await sut.verifyIdentity(provider: "bluesky") + let body = String(data: session.lastRequest?.httpBody ?? Data(), encoding: .utf8) ?? "" + XCTAssertFalse(body.contains("providerId"), "Got: \(body)") + } + + func test_verifyIdentity_success_returnsVerified() async throws { + session.stub(json: #"{"success":true}"#) + let verification = try await sut.verifyIdentity(provider: "github") + XCTAssertEqual(verification, .verified) + } + + /// 400 is the route's "loaded the credential, the provider rejected it" answer + /// (also "No token to verify") — an outcome, not a transport fault. + func test_verifyIdentity_400_returnsCredentialRejected() async throws { + session.stub(json: #"{"error":"Verification failed - token may be expired","code":"bad_request"}"#, statusCode: 400) + let verification = try await sut.verifyIdentity(provider: "bluesky") + XCTAssertEqual(verification, .needsReconnect(.credentialRejected)) + } + + func test_verifyIdentity_404_returnsIdentityMissing() async throws { + session.stub(json: #"{"error":"Identity not found","code":"not_found"}"#, statusCode: 404) + let verification = try await sut.verifyIdentity(provider: "twitter") + XCTAssertEqual(verification, .needsReconnect(.identityMissing)) + } + + func test_verifyIdentity_401_throwsStatusError() async throws { + session.stub(json: #"{"error":"Unauthorized","code":"unauthorized"}"#, statusCode: 401) + do { + _ = try await sut.verifyIdentity(provider: "bluesky") + XCTFail("Expected throw") + } catch APIError.status(let code) { + XCTAssertEqual(code, 401) + } + } + + /// A 5xx must not be mistaken for a dead token: the check never ran. + func test_verifyIdentity_500_throwsRatherThanReportingNeedsReconnect() async throws { + session.stub(json: #"{"error":"Internal server error","code":"internal_error"}"#, statusCode: 500) + do { + let verification = try await sut.verifyIdentity(provider: "bluesky") + XCTFail("Expected throw, got \(verification)") + } catch APIError.server(let message) { + XCTAssertEqual(message, "Internal server error") + } + } + + /// The route couldn't be reached at all — also distinct from "token is bad". + func test_verifyIdentity_transportFailure_throwsRatherThanReportingNeedsReconnect() async throws { + let failing = FailingURLSession(error: URLError(.notConnectedToInternet)) + let client = APIClient(session: failing) + client.setBearerToken("tok") + do { + let verification = try await client.verifyIdentity(provider: "bluesky") + XCTFail("Expected throw, got \(verification)") + } catch let error as URLError { + XCTAssertEqual(error.code, .notConnectedToInternet) + } } // MARK: provider filtering (exercising the logic ComposeView applies to the identities list) @@ -204,3 +266,18 @@ final class APIClientIdentitiesTests: XCTestCase { XCTAssertFalse(hasTwitter) } } + +/// `MockURLSession` can only answer with a status; this one fails the way a +/// dropped connection does, which is a different thing for the verify route to +/// report than any status the server could send. +private final class FailingURLSession: URLSessionProtocol { + private let error: Error + + init(error: Error) { + self.error = error + } + + func data(for request: URLRequest) async throws -> (Data, URLResponse) { + throw error + } +} diff --git a/InterlinedListTests/ModelTests/IdentityHealthTests.swift b/InterlinedListTests/ModelTests/IdentityHealthTests.swift index ac507d2..0c8a24c 100644 --- a/InterlinedListTests/ModelTests/IdentityHealthTests.swift +++ b/InterlinedListTests/ModelTests/IdentityHealthTests.swift @@ -157,4 +157,57 @@ final class IdentityHealthTests: XCTestCase { XCTAssertEqual(OAuthProvider.bluesky.statusKind, .userIdentityRow) XCTAssertEqual(OAuthProvider.mastodon.statusKind, .userIdentityRow) } + + // MARK: verify-route outcome → row health + + func test_health_fromVerified_isVerifiedAndNotStale() { + let health = IdentityHealth(verification: .verified, providerName: "Bluesky") + XCTAssertEqual(health, .verified) + XCTAssertFalse(health.isStale) + } + + /// `verified` outranks `connected` and must stay its own case: a status route + /// can say "connected" about a credential nobody has ever exercised. + func test_health_verified_isNotTheSameAsConnected() { + XCTAssertNotEqual(IdentityHealth(verification: .verified, providerName: "Bluesky"), .connected) + } + + func test_health_fromCredentialRejected_isStaleAndNamesProvider() { + let health = IdentityHealth(verification: .needsReconnect(.credentialRejected), providerName: "Bluesky") + XCTAssertTrue(health.isStale, "a rejected credential must offer reconnect") + guard case .needsReconnect(let reason) = health else { + return XCTFail("expected needsReconnect, got \(health)") + } + XCTAssertTrue(reason.contains("Bluesky"), "Got: \(reason)") + } + + func test_health_fromIdentityMissing_isStaleAndNamesProvider() { + let health = IdentityHealth(verification: .needsReconnect(.identityMissing), providerName: "Mastodon") + XCTAssertTrue(health.isStale) + guard case .needsReconnect(let reason) = health else { + return XCTFail("expected needsReconnect, got \(health)") + } + XCTAssertTrue(reason.contains("Mastodon"), "Got: \(reason)") + } + + /// The two failures must not read alike: one is "reconnect", the other is + /// "nothing changed". + func test_health_rejectedAndMissing_readDifferently() { + XCTAssertNotEqual( + IdentityHealth(verification: .needsReconnect(.credentialRejected), providerName: "Bluesky"), + IdentityHealth(verification: .needsReconnect(.identityMissing), providerName: "Bluesky") + ) + } + + /// A check that never ran proves nothing — it must never send the user off to + /// re-authorize a healthy account. + func test_uncheckable_isUnknownNotStale() { + let health = IdentityHealth.uncheckable(providerName: "Bluesky") + XCTAssertFalse(health.isStale) + guard case .unknown(let reason) = health else { + return XCTFail("expected unknown, got \(health)") + } + XCTAssertTrue(reason.contains("unchanged"), "Got: \(reason)") + XCTAssertNotEqual(health, IdentityHealth(verification: .needsReconnect(.credentialRejected), providerName: "Bluesky")) + } }