Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 47 additions & 1 deletion InterlinedList/Models/IdentityHealth.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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".
Expand All @@ -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
Expand Down
7 changes: 5 additions & 2 deletions InterlinedList/Services/APIClient+Identities.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
45 changes: 41 additions & 4 deletions InterlinedList/Services/APIClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 18 additions & 0 deletions InterlinedList/Services/APIClientTransport.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<T: Decodable, B: Encodable>(_ 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<T: Decodable, B: Encodable>(_ path: String, body: B) async throws -> T {
var request = try jsonRequest(path, method: "PUT")
request.httpBody = try camelCaseEncoder.encode(body)
Expand Down
70 changes: 61 additions & 9 deletions InterlinedList/Views/LinkedIdentitiesView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> = []

var body: some View {
List {
Expand All @@ -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))
}
}
Expand Down Expand Up @@ -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)
Expand All @@ -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()
Expand All @@ -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)
Expand All @@ -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")
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
87 changes: 82 additions & 5 deletions InterlinedListTests/APIClientTests/APIClientIdentitiesTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
}
}
Loading