From 2137dbb33ffb1df4ced656de4e81a40ebcfd9925 Mon Sep 17 00:00:00 2001 From: Adron Hall Date: Tue, 15 Sep 2026 05:11:16 -0700 Subject: [PATCH] feat(settings): an Integrations pane, and the three identity defects underneath it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Linked accounts pane could only link. Once an account was connected there was no way to check it, reconfigure it, or remove it from the Mac. Probing that surface turned up three live defects, and they matter more than the pane. Mastodon arrives instance-qualified. The wire sends `"mastodon:techhub.social"`, and IdentityProvider matched `"mastodon"` exactly — so every Mastodon identity fell to `.other` and rendered as an unknown provider with none of its actions, while the instance host was discarded. The doc comment claimed the host was "carried on the LinkedIdentity"; there was no such field. It is split out now, and `providerWireToken` reassembles it for unlink and verify — a bare "mastodon" on an account with two instances is ambiguous enough that the server could disconnect the wrong one. X was missing from the provider enum. The consequence is sharper than a missing row: a linked X identity decoded as `.other("twitter")`, so the one provider the account could already cross-post to was the one the client could not recognise. `GET /api/user/identities` accepts Bearer, contrary to its `.session` annotation. Not a hard failure — the kit lazily establishes a cookie session — but a redundant credentialed round-trip on every load, and the same class of error as the send-verification-email correction in PR #83, in the other direction. The pane itself is data-driven rather than a switch statement per provider: supportsMultipleInstances, requiresInstanceHost, isVerifiable and isDisconnectable live on the provider, so a row is a projection of the model instead of a parallel copy of the same rules. That is also what makes Mastodon work — it is the one provider with several connections, and it renders one row per instance. Two smaller judgement calls. Disconnect is not optimistic: it stops cross-posting, and a row that vanishes and returns because the write failed is worse than one that waits. And a failed verify reports the request error rather than "not responding" — a failed ask is not a failed connection, and saying otherwise would be a lie about the provider. Four pieces are deferred on evidence, not effort: LinkedIn posting-target editing (the read is empty on this account, and it is #79's supply side), the githubDefaultRepo write and Reconnect for Issues (no accessible repos — see #51), and the reconnect-reminders toggle (its backing field is on neither /api/user nor the notification-preferences catalogue, so nothing is wired to a guess). Refs #47 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016gSWb3scYobtxLJioV1qF9 --- App/Features/Settings/IntegrationsView.swift | 230 ++++++++++++++++++ .../Settings/IntegrationsViewModel.swift | 166 +++++++++++++ .../Settings/LinkedAccountsView.swift | 20 +- .../Settings/LinkedAccountsViewModel.swift | 6 +- App/Features/Settings/SettingsRootView.swift | 8 + AppTests/IntegrationsViewModelTests.swift | 203 ++++++++++++++++ AppTests/LinkedAccountsViewModelTests.swift | 6 +- AppTests/Support/StubUserService.swift | 57 +++++ .../Models/IdentityMappers.swift | 6 +- .../Models/LinkedIdentity.swift | 160 +++++++++++- .../Services/UserService.swift | 48 ++++ .../IdentityProviderTokenTests.swift | 156 ++++++++++++ .../InterlinedKit/DTOs/GitHubDTO.swift | 45 ++++ .../Sources/InterlinedKit/DTOs/UserDTO.swift | 43 +++- .../Endpoints/GitHubEndpoint.swift | 31 +++ .../Endpoints/UserEndpoint.swift | 43 +++- .../UserEndpointTests.swift | 92 +++++-- 17 files changed, 1278 insertions(+), 42 deletions(-) create mode 100644 App/Features/Settings/IntegrationsView.swift create mode 100644 App/Features/Settings/IntegrationsViewModel.swift create mode 100644 AppTests/IntegrationsViewModelTests.swift create mode 100644 Packages/InterlinedDomain/Tests/InterlinedDomainTests/IdentityProviderTokenTests.swift diff --git a/App/Features/Settings/IntegrationsView.swift b/App/Features/Settings/IntegrationsView.swift new file mode 100644 index 0000000..9b54e0f --- /dev/null +++ b/App/Features/Settings/IntegrationsView.swift @@ -0,0 +1,230 @@ +// IntegrationsView +// +// Settings ▸ Integrations (GitHub #47 / G33) — every connected provider in one +// place, with Link, Verify and Disconnect as each row's actions. +// +// `LinkedAccountsView` could only **link**. Once an account was connected there +// was no way to check it, reconfigure it, or remove it from the Mac. +// +// The rows are **data-driven** rather than a switch statement per provider: what +// a row offers comes from `IdentityProvider`'s capability flags, so adding a +// provider does not mean finding every place that enumerates them. That is also +// what makes Mastodon work — it is the one provider that supports several +// connections, and it renders as one row per instance. +// +// Pure SwiftUI; no AppKit. Decision 0003: consumes only `InterlinedDomain`. + +import SwiftUI +import InterlinedDomain + +struct IntegrationsView: View { + + @Environment(\.appEnvironment) private var environment + @Environment(\.openURL) private var openURL + @State private var viewModel: IntegrationsViewModel? + @State private var pendingDisconnect: LinkedIdentity? + + var body: some View { + Form { + if let viewModel { + connectedSection(viewModel) + githubSection(viewModel) + connectSection(viewModel) + if let error = viewModel.loadError { + Section { + Text(error.localizedDescription) + .font(.ilMono(10)) + .foregroundStyle(.orange) + } + } + } + } + .formStyle(.grouped) + .task { + guard viewModel == nil, let environment else { return } + let model = IntegrationsViewModel(user: environment.userService) + viewModel = model + await model.load() + } + .confirmationDialog( + "Disconnect \(pendingDisconnect?.provider.displayName ?? "")?", + isPresented: Binding( + get: { pendingDisconnect != nil }, + set: { if !$0 { pendingDisconnect = nil } } + ), + titleVisibility: .visible + ) { + Button("Disconnect", role: .destructive) { + guard let identity = pendingDisconnect, let viewModel else { return } + pendingDisconnect = nil + Task { await viewModel.disconnect(identity) } + } + Button("Cancel", role: .cancel) { pendingDisconnect = nil } + } message: { + // The consequence is the question. A generic "are you sure" tells + // the user nothing they did not already know. + if let identity = pendingDisconnect, let viewModel { + Text(viewModel.disconnectConsequence(for: identity)) + } + } + } + + // MARK: - Connected + + @ViewBuilder + private func connectedSection(_ viewModel: IntegrationsViewModel) -> some View { + Section("Connected") { + if viewModel.isLoading && viewModel.identities.isEmpty { + HStack(spacing: 6) { + ProgressView().controlSize(.small) + Text("Loading connections…").foregroundStyle(.secondary) + } + } else if viewModel.connectedIdentities.isEmpty { + Text("No accounts are connected yet.") + .font(.ilMono(10)) + .foregroundStyle(.secondary) + } else { + ForEach(viewModel.connectedIdentities) { identity in + identityRow(identity, viewModel: viewModel) + } + } + } + } + + @ViewBuilder + private func identityRow( + _ identity: LinkedIdentity, + viewModel: IntegrationsViewModel + ) -> some View { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 8) { + Image(systemName: identity.provider.iconName) + .foregroundStyle(.secondary) + .accessibilityHidden(true) + VStack(alignment: .leading, spacing: 1) { + // The instance is part of the identity for Mastodon, so it + // belongs in the title — two rows reading "Mastodon" would + // be indistinguishable. + Text(rowTitle(identity)) + .font(.ilBody()) + if let handle = identity.handle, !handle.isEmpty { + Text(handle) + .font(.ilMono(10)) + .foregroundStyle(.secondary) + } + } + Spacer(minLength: 8) + + if identity.provider.isVerifiable { + Button("Verify") { + Task { await viewModel.verify(identity) } + } + .buttonStyle(.bordered) + .controlSize(.small) + .disabled(viewModel.isVerifying(identity) || viewModel.isDisconnecting(identity)) + } + if identity.provider.isDisconnectable { + Button("Disconnect", role: .destructive) { + pendingDisconnect = identity + } + .buttonStyle(.bordered) + .controlSize(.small) + .disabled(viewModel.isDisconnecting(identity)) + } + if viewModel.isVerifying(identity) || viewModel.isDisconnecting(identity) { + ProgressView().controlSize(.mini) + } + } + + statusLine(identity, viewModel: viewModel) + } + .padding(.vertical, 2) + } + + private func rowTitle(_ identity: LinkedIdentity) -> String { + guard let instance = identity.instance, !instance.isEmpty else { + return identity.provider.displayName + } + return "\(identity.provider.displayName) · \(instance)" + } + + @ViewBuilder + private func statusLine( + _ identity: LinkedIdentity, + viewModel: IntegrationsViewModel + ) -> some View { + // Precedence matters: an error from this row's own last action is more + // useful than a stale verify result, and both beat the connected-at + // date. One line, not three. + if let failure = viewModel.rowErrors[identity.id] { + Text(failure) + .font(.ilMono(10)) + .foregroundStyle(.orange) + } else if let verified = viewModel.verifyResults[identity.id] { + Label( + verified ? "Connection is working" : "Connection isn't responding — reconnect it", + systemImage: verified ? "checkmark.circle" : "exclamationmark.triangle" + ) + .font(.ilMono(10)) + .foregroundStyle(verified ? Color.secondary : Color.orange) + } else if let lastVerified = identity.lastVerifiedAt { + Text("Last checked \(lastVerified.formatted(date: .abbreviated, time: .omitted))") + .font(.ilMono(10)) + .foregroundStyle(.secondary) + } else if let connectedAt = identity.connectedAt { + Text("Connected \(connectedAt.formatted(date: .abbreviated, time: .omitted))") + .font(.ilMono(10)) + .foregroundStyle(.secondary) + } + } + + // MARK: - GitHub + + @ViewBuilder + private func githubSection(_ viewModel: IntegrationsViewModel) -> some View { + if let github = viewModel.github { + Section("GitHub") { + if !github.isConfigured { + // A different message from "you have not linked GitHub": + // no amount of user action fixes this one. + Text("GitHub sign-in isn't configured on InterlinedList, so linking a GitHub account won't work right now.") + .font(.ilMono(10)) + .foregroundStyle(.orange) + } else if let url = github.manageOrgAccessURL { + Button("Update organization access…") { openURL(url) } + Text("Opens GitHub, where you grant or revoke this app's access to your organizations. Needed before a GitHub-backed list can read an org's issues.") + .font(.ilMono(10)) + .foregroundStyle(.secondary) + } + } + } + } + + // MARK: - Connect + + @ViewBuilder + private func connectSection(_ viewModel: IntegrationsViewModel) -> some View { + Section("Connect an account") { + ForEach(viewModel.connectableProviders, id: \.wireToken) { provider in + HStack(spacing: 8) { + Image(systemName: provider.iconName) + .foregroundStyle(.secondary) + .accessibilityHidden(true) + Text(provider.displayName) + if provider.supportsMultipleInstances { + Text("· you can connect several instances") + .font(.ilMono(10)) + .foregroundStyle(.secondary) + } + Spacer() + } + } + // Linking itself stays in the Linked Accounts pane, which owns the + // OAuth session and the Mastodon instance prompt. Duplicating that + // flow here would mean two copies of an authorization handshake. + Text("Connecting an account happens in Settings ▸ Linked accounts, which handles the sign-in window.") + .font(.ilMono(10)) + .foregroundStyle(.secondary) + } + } +} diff --git a/App/Features/Settings/IntegrationsViewModel.swift b/App/Features/Settings/IntegrationsViewModel.swift new file mode 100644 index 0000000..7024c49 --- /dev/null +++ b/App/Features/Settings/IntegrationsViewModel.swift @@ -0,0 +1,166 @@ +// IntegrationsViewModel +// +// Drives Settings ▸ Integrations (GitHub #47 / G33) — the pane that manages +// every connected provider in one place. +// +// `LinkedAccountsView` could only **link**. Once an account was connected there +// was no way to check it, reconfigure it, or remove it from the Mac; the web's +// `/integrations` page does all four. +// +// Three things here come from the 2026-09-15 probe rather than from the docs: +// +// - **Mastodon arrives instance-qualified** (`"mastodon:techhub.social"`), so +// every Mastodon identity used to decode as an unknown provider. Fixed in +// `IdentityProvider`; this pane is where it becomes visible, as one row per +// instance. +// - **X was missing from the client's provider list**, despite the account +// having a linked X identity and cross-posting to X being shipped. +// - **`GET /api/user/identities` accepts Bearer**, contrary to its `.session` +// annotation — a redundant cookie login on every load. +// +// Per Decision 0003 this view model consumes only `InterlinedDomain`. + +import Foundation +import Observation +import InterlinedDomain + +@MainActor +@Observable +final class IntegrationsViewModel { + + private let user: UserServicing + + // MARK: - Observable state + + private(set) var identities: [LinkedIdentity] = [] + + /// The app's GitHub configuration, when the status read succeeded. + private(set) var github: GitHubConnection? + + private(set) var isLoading: Bool = false + private(set) var loadError: Error? + + /// Identities with a verify in flight, keyed by identity id. + private(set) var verifyingIDs: Set = [] + + /// Identities with a disconnect in flight. + private(set) var disconnectingIDs: Set = [] + + /// The outcome of the most recent verify per identity. `true` means the + /// connection answered; `false` means it did not. + /// + /// Per-row on purpose: a pane with five providers and one shared status + /// line cannot say *which* connection is broken, which is the only thing + /// the user needs to know. + private(set) var verifyResults: [String: Bool] = [:] + + /// The error from the most recent failed action on a row, keyed by identity + /// id, so a failure reports where it happened. + private(set) var rowErrors: [String: String] = [:] + + // MARK: - Projections + + /// Every provider the pane shows a row for, in a stable order: the + /// connected ones first, then the ones that could be connected. + var connectedIdentities: [LinkedIdentity] { + identities.sorted { lhs, rhs in + if lhs.provider.displayName != rhs.provider.displayName { + return lhs.provider.displayName < rhs.provider.displayName + } + // Several Mastodon instances sort by host, so the list is stable + // across loads rather than following server order. + return (lhs.instance ?? "") < (rhs.instance ?? "") + } + } + + /// Providers with no connection yet. + /// + /// Mastodon is **never** listed here even when instances are already + /// connected: it supports several, so "connect another instance" stays + /// available. Every other provider disappears from this list once linked. + var connectableProviders: [IdentityProvider] { + let connected = Set(identities.map(\.provider)) + return [.github, .mastodon, .bluesky, .linkedin, .twitter].filter { provider in + provider.supportsMultipleInstances || !connected.contains(provider) + } + } + + // MARK: - Init + + init(user: UserServicing) { + self.user = user + } + + // MARK: - Intents + + func load() async { + isLoading = true + loadError = nil + defer { isLoading = false } + do { + identities = try await user.identities() + } catch { + loadError = error + return + } + // The GitHub status is a soft follow-up: the pane is fully usable + // without it, and only the GitHub row loses an affordance. + github = try? await user.githubConnectionStatus() + } + + func verify(_ identity: LinkedIdentity) async { + guard !verifyingIDs.contains(identity.id) else { return } + verifyingIDs.insert(identity.id) + rowErrors[identity.id] = nil + defer { verifyingIDs.remove(identity.id) } + do { + verifyResults[identity.id] = try await user.verifyIdentity(identity) + } catch { + // A failed verify is not a failed connection — the request itself + // did not complete — so the row reports the error rather than + // claiming the provider is broken. + rowErrors[identity.id] = error.localizedDescription + } + } + + /// Disconnects a provider. + /// + /// Not optimistic. Disconnecting stops cross-posting, and a row that + /// disappears and then comes back because the write failed is worse than a + /// row that waits — the user needs to know whether it actually happened. + func disconnect(_ identity: LinkedIdentity) async { + guard !disconnectingIDs.contains(identity.id) else { return } + disconnectingIDs.insert(identity.id) + rowErrors[identity.id] = nil + defer { disconnectingIDs.remove(identity.id) } + do { + try await user.unlinkIdentity(identity) + identities.removeAll { $0.id == identity.id } + verifyResults[identity.id] = nil + } catch { + rowErrors[identity.id] = error.localizedDescription + } + } + + /// What a disconnect confirmation should say stops working. + /// + /// Named per provider rather than a generic "are you sure": the consequence + /// is the question, and it differs — losing GitHub takes GitHub-backed lists + /// with it, losing a cross-post provider silently stops those posts. + func disconnectConsequence(for identity: LinkedIdentity) -> String { + switch identity.provider { + case .github: + return "Disconnecting GitHub stops GitHub-backed lists from syncing and signs this app out of GitHub Issues." + case .mastodon: + let host = identity.instance.map { " on \($0)" } ?? "" + return "Disconnecting Mastodon\(host) stops cross-posting to that instance." + case .bluesky, .linkedin, .twitter: + return "Disconnecting \(identity.provider.displayName) stops cross-posting to it." + case .other(let raw): + return "Disconnecting \(raw) may stop features that depend on it." + } + } + + func isVerifying(_ identity: LinkedIdentity) -> Bool { verifyingIDs.contains(identity.id) } + func isDisconnecting(_ identity: LinkedIdentity) -> Bool { disconnectingIDs.contains(identity.id) } +} diff --git a/App/Features/Settings/LinkedAccountsView.swift b/App/Features/Settings/LinkedAccountsView.swift index 66ffdce..3a35ae4 100644 --- a/App/Features/Settings/LinkedAccountsView.swift +++ b/App/Features/Settings/LinkedAccountsView.swift @@ -176,28 +176,20 @@ private struct LinkedIdentityRow: View { extension IdentityProvider { - /// Human-readable label. `.other` surfaces the raw token capitalized so - /// an unrecognized provider still renders a legible name. - var displayName: String { - switch self { - case .github: return "GitHub" - case .mastodon: return "Mastodon" - case .bluesky: return "Bluesky" - case .linkedin: return "LinkedIn" - case .other(let raw): - let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) - return trimmed.isEmpty ? "Account" : trimmed.capitalized - } - } - /// SF Symbol approximating the provider (no third-party brand glyphs in /// the symbol set). `.other` falls back to a generic link icon. + /// + /// The label itself moved to `IdentityProvider.displayName` in the domain, + /// alongside the per-provider capability flags — the pane needs the name and + /// the capabilities together, and keeping the name in the App layer meant + /// two places knew about providers (GitHub #47). var iconName: String { switch self { case .github: return "chevron.left.forwardslash.chevron.right" case .mastodon: return "number.square" case .bluesky: return "cloud" case .linkedin: return "briefcase" + case .twitter: return "at" case .other: return "link" } } diff --git a/App/Features/Settings/LinkedAccountsViewModel.swift b/App/Features/Settings/LinkedAccountsViewModel.swift index e4c02a6..03ab383 100644 --- a/App/Features/Settings/LinkedAccountsViewModel.swift +++ b/App/Features/Settings/LinkedAccountsViewModel.swift @@ -130,8 +130,12 @@ final class LinkedAccountsViewModel { /// but requires the instance prompt before a URL can be resolved (the /// view collects the instance host). `.other` is never offered — there is /// no authorize route the client can build for an unknown provider. + /// + /// X was absent (GitHub #47) even though the test account has a linked X + /// identity and cross-posting to X is a shipped feature — so the one + /// provider you could already post to was the one you could not connect. var linkableProviders: [IdentityProvider] { - [.github, .mastodon, .bluesky, .linkedin] + [.github, .mastodon, .bluesky, .linkedin, .twitter] } // MARK: - Legacy browser-handoff (kept for tests that rely on it) diff --git a/App/Features/Settings/SettingsRootView.swift b/App/Features/Settings/SettingsRootView.swift index f8fd8a7..00827a4 100644 --- a/App/Features/Settings/SettingsRootView.swift +++ b/App/Features/Settings/SettingsRootView.swift @@ -21,6 +21,14 @@ struct SettingsRootView: View { Label("Linked accounts", systemImage: "link") } + // Manage every connected provider in one place — verify, disconnect, + // and GitHub org access (GitHub #47 / G33). Linking itself stays in + // the Linked accounts pane, which owns the OAuth session. + IntegrationsView() + .tabItem { + Label("Integrations", systemImage: "app.connected.to.app.below.fill") + } + AccountSettingsView() .tabItem { Label("Account", systemImage: "person.crop.circle") diff --git a/AppTests/IntegrationsViewModelTests.swift b/AppTests/IntegrationsViewModelTests.swift new file mode 100644 index 0000000..183443f --- /dev/null +++ b/AppTests/IntegrationsViewModelTests.swift @@ -0,0 +1,203 @@ +// IntegrationsViewModelTests +// +// BDD quartet for Settings ▸ Integrations (GitHub #47 / G33). +// +// The cases that matter most are not about the pane — they are about the two +// live defects underneath it: Mastodon identities decoded as unknown providers, +// and the instance being dropped so unlink could address the wrong connection. + +import XCTest +import InterlinedDomain +@testable import InterlinedList + +@MainActor +final class IntegrationsViewModelTests: XCTestCase { + + private func identity( + id: String, + provider: IdentityProvider, + instance: String? = nil, + handle: String? = nil + ) -> LinkedIdentity { + LinkedIdentity(id: id, provider: provider, handle: handle, instance: instance) + } + + // MARK: - Happy path + + func test_givenLinkedProviders_whenLoading_thenEveryRowArrives() async { + let stub = StubUserService() + stub.enqueueIdentities(success: [ + identity(id: "1", provider: .bluesky, handle: "a.bsky.social"), + identity(id: "2", provider: .mastodon, instance: "techhub.social"), + identity(id: "3", provider: .twitter, handle: "someone") + ]) + stub.enqueueGitHubConnectionStatus(success: GitHubConnection(isConfigured: true)) + let viewModel = IntegrationsViewModel(user: stub) + + await viewModel.load() + + XCTAssertEqual(viewModel.connectedIdentities.count, 3) + XCTAssertNil(viewModel.loadError) + XCTAssertEqual(viewModel.github?.isConfigured, true) + } + + func test_givenAVerifiableIdentity_whenVerifying_thenTheResultLandsOnThatRow() async { + let stub = StubUserService() + let bluesky = identity(id: "1", provider: .bluesky) + stub.enqueueIdentities(success: [bluesky, identity(id: "2", provider: .linkedin)]) + stub.enqueueGitHubConnectionStatus(failure: TestError.upstream("no github")) + stub.enqueueVerifyIdentity(success: true) + let viewModel = IntegrationsViewModel(user: stub) + await viewModel.load() + + await viewModel.verify(bluesky) + + XCTAssertEqual(viewModel.verifyResults["1"], true) + XCTAssertNil(viewModel.verifyResults["2"], "a verify reports on its own row only") + } + + // MARK: - Mastodon: the instance is the identity (the #47 defect) + + func test_givenSeveralMastodonInstances_whenDisconnectingOne_thenTheInstanceQualifiedTokenIsSent() async { + // The defect this guards. The wire token is `mastodon:techhub.social`; + // sending a bare `mastodon` on an account with two instances is + // ambiguous enough that the server could disconnect the wrong one. + let stub = StubUserService() + let techhub = identity(id: "1", provider: .mastodon, instance: "techhub.social") + let hachyderm = identity(id: "2", provider: .mastodon, instance: "hachyderm.io") + stub.enqueueIdentities(success: [techhub, hachyderm]) + stub.enqueueGitHubConnectionStatus(failure: TestError.upstream("no github")) + stub.enqueueUnlinkIdentity() + let viewModel = IntegrationsViewModel(user: stub) + await viewModel.load() + + await viewModel.disconnect(hachyderm) + + guard case .unlinkIdentity(let provider)? = stub.recorded.last?.kind else { + return XCTFail("expected unlinkIdentity, got \(String(describing: stub.recorded.last))") + } + XCTAssertEqual(provider, "mastodon:hachyderm.io") + XCTAssertEqual(viewModel.connectedIdentities.map(\.id), ["1"], "the other instance survives") + } + + func test_givenMastodonAlreadyConnected_whenListingConnectable_thenItIsStillOffered() async { + // Mastodon is the one provider that supports several connections, so it + // must not disappear from the connect list once one instance is linked. + let stub = StubUserService() + stub.enqueueIdentities(success: [ + identity(id: "1", provider: .mastodon, instance: "techhub.social"), + identity(id: "2", provider: .bluesky) + ]) + stub.enqueueGitHubConnectionStatus(failure: TestError.upstream("no github")) + let viewModel = IntegrationsViewModel(user: stub) + await viewModel.load() + + XCTAssertTrue(viewModel.connectableProviders.contains(.mastodon)) + XCTAssertFalse( + viewModel.connectableProviders.contains(.bluesky), + "a single-connection provider disappears once it is linked" + ) + } + + // MARK: - Upstream failure + + func test_givenAFailedVerify_whenVerifying_thenTheRowReportsTheErrorAndNotABrokenConnection() async { + // A failed *request* is not a failed *connection*. Reporting "not + // responding" when we simply could not ask would be a lie about the + // provider. + let stub = StubUserService() + let bluesky = identity(id: "1", provider: .bluesky) + stub.enqueueIdentities(success: [bluesky]) + stub.enqueueGitHubConnectionStatus(failure: TestError.upstream("no github")) + stub.enqueueVerifyIdentity(failure: TestError.upstream("timeout")) + let viewModel = IntegrationsViewModel(user: stub) + await viewModel.load() + + await viewModel.verify(bluesky) + + XCTAssertNotNil(viewModel.rowErrors["1"]) + XCTAssertNil(viewModel.verifyResults["1"], "no verdict is recorded when the ask failed") + } + + func test_givenAFailedDisconnect_whenDisconnecting_thenTheRowStays() async { + // Not optimistic on purpose: a row that vanishes and comes back because + // the write failed is worse than one that waits — the user needs to know + // whether their cross-posting actually stopped. + let stub = StubUserService() + let bluesky = identity(id: "1", provider: .bluesky) + stub.enqueueIdentities(success: [bluesky]) + stub.enqueueGitHubConnectionStatus(failure: TestError.upstream("no github")) + stub.enqueueUnlinkIdentity(failure: TestError.upstream("nope")) + let viewModel = IntegrationsViewModel(user: stub) + await viewModel.load() + + await viewModel.disconnect(bluesky) + + XCTAssertEqual(viewModel.connectedIdentities.count, 1, "the connection is still there") + XCTAssertNotNil(viewModel.rowErrors["1"]) + } + + func test_givenAFailedIdentitiesLoad_whenLoading_thenTheErrorIsSurfaced() async { + let stub = StubUserService() + stub.enqueueIdentities(failure: TestError.upstream("boom")) + let viewModel = IntegrationsViewModel(user: stub) + + await viewModel.load() + + XCTAssertNotNil(viewModel.loadError) + XCTAssertTrue(viewModel.connectedIdentities.isEmpty) + } + + func test_givenAFailedGitHubStatus_whenLoading_thenTheRestOfThePaneStillWorks() async { + // The GitHub status is a soft follow-up: only the GitHub row loses an + // affordance, and the pane is otherwise fully usable. + let stub = StubUserService() + stub.enqueueIdentities(success: [identity(id: "1", provider: .bluesky)]) + stub.enqueueGitHubConnectionStatus(failure: TestError.upstream("nope")) + let viewModel = IntegrationsViewModel(user: stub) + + await viewModel.load() + + XCTAssertNil(viewModel.github) + XCTAssertNil(viewModel.loadError, "a soft failure is not a pane failure") + XCTAssertEqual(viewModel.connectedIdentities.count, 1) + } + + // MARK: - Boundary + + func test_givenNoConnections_whenLoading_thenEveryProviderIsOfferedAndNothingErrors() async { + let stub = StubUserService() + stub.enqueueIdentities(success: []) + stub.enqueueGitHubConnectionStatus(success: GitHubConnection(isConfigured: true)) + let viewModel = IntegrationsViewModel(user: stub) + + await viewModel.load() + + XCTAssertTrue(viewModel.connectedIdentities.isEmpty) + XCTAssertEqual(viewModel.connectableProviders.count, 5) + XCTAssertNil(viewModel.loadError) + } + + func test_givenAnUnknownProvider_whenRendering_thenNoActionsAreOffered() async { + // The client cannot describe what it would be verifying, or promise that + // a disconnect addresses the right thing. + XCTAssertFalse(IdentityProvider.other("carrier-pigeon").isVerifiable) + XCTAssertFalse(IdentityProvider.other("carrier-pigeon").isDisconnectable) + XCTAssertTrue(IdentityProvider.bluesky.isVerifiable) + } + + func test_givenADisconnect_whenConfirming_thenTheConsequenceNamesWhatStops() async { + // A generic "are you sure" tells the user nothing. The consequence + // differs per provider, and it is the question. + let stub = StubUserService() + let viewModel = IntegrationsViewModel(user: stub) + + let github = viewModel.disconnectConsequence(for: identity(id: "1", provider: .github)) + XCTAssertTrue(github.contains("GitHub-backed lists"), github) + + let mastodon = viewModel.disconnectConsequence( + for: identity(id: "2", provider: .mastodon, instance: "techhub.social") + ) + XCTAssertTrue(mastodon.contains("techhub.social"), mastodon) + } +} diff --git a/AppTests/LinkedAccountsViewModelTests.swift b/AppTests/LinkedAccountsViewModelTests.swift index b633132..8324442 100644 --- a/AppTests/LinkedAccountsViewModelTests.swift +++ b/AppTests/LinkedAccountsViewModelTests.swift @@ -114,7 +114,11 @@ final class LinkedAccountsViewModelTests: XCTestCase { let providers = vm.linkableProviders - XCTAssertEqual(providers, [.github, .mastodon, .bluesky, .linkedin]) + // X is included since GitHub #47. It was absent even though the test + // account has a linked X identity and cross-posting to X is shipped — + // so the one provider you could already post to was the one you could + // not connect. + XCTAssertEqual(providers, [.github, .mastodon, .bluesky, .linkedin, .twitter]) } // MARK: - linkNatively (NW-5) diff --git a/AppTests/Support/StubUserService.swift b/AppTests/Support/StubUserService.swift index cdacef6..97abf0f 100644 --- a/AppTests/Support/StubUserService.swift +++ b/AppTests/Support/StubUserService.swift @@ -33,6 +33,9 @@ struct RecordedUserCall: Sendable, Equatable { case linkIdentityNative(provider: String, code: String, state: String) case settings case updateSettings + case unlinkIdentity(provider: String) + case verifyIdentity(provider: String) + case githubConnectionStatus /// The composer gear's single-field write, with the value it sent. case setShowAdvancedPostSettings(enabled: Bool) } @@ -56,6 +59,9 @@ final class StubUserService: UserServicing, @unchecked Sendable { private var blueskyConfiguredOutcomes: [Result] = [] private var mastodonConfiguredOutcomes: [Result] = [] private var linkIdentityNativeOutcomes: [Result] = [] + private var unlinkIdentityOutcomes: [Result] = [] + private var verifyIdentityOutcomes: [Result] = [] + private var githubConnectionStatusOutcomes: [Result] = [] private var settingsOutcomes: [Result] = [] private var updateSettingsOutcomes: [Result] = [] private var setShowAdvancedPostSettingsOutcomes: [Result] = [] @@ -179,6 +185,31 @@ final class StubUserService: UserServicing, @unchecked Sendable { linkIdentityNativeOutcomes.append(.failure(error)) } + func enqueueUnlinkIdentity(success: Void = ()) { + lock.lock(); defer { lock.unlock() } + unlinkIdentityOutcomes.append(.success(())) + } + func enqueueUnlinkIdentity(failure error: Error) { + lock.lock(); defer { lock.unlock() } + unlinkIdentityOutcomes.append(.failure(error)) + } + func enqueueVerifyIdentity(success verified: Bool) { + lock.lock(); defer { lock.unlock() } + verifyIdentityOutcomes.append(.success(verified)) + } + func enqueueVerifyIdentity(failure error: Error) { + lock.lock(); defer { lock.unlock() } + verifyIdentityOutcomes.append(.failure(error)) + } + func enqueueGitHubConnectionStatus(success status: GitHubConnection) { + lock.lock(); defer { lock.unlock() } + githubConnectionStatusOutcomes.append(.success(status)) + } + func enqueueGitHubConnectionStatus(failure error: Error) { + lock.lock(); defer { lock.unlock() } + githubConnectionStatusOutcomes.append(.failure(error)) + } + func enqueueSettings(success settings: UserSettings) { lock.lock(); defer { lock.unlock() } settingsOutcomes.append(.success(settings)) @@ -349,6 +380,32 @@ final class StubUserService: UserServicing, @unchecked Sendable { set: { $0.linkIdentityNativeOutcomes = $1 } } + func unlinkIdentity(_ identity: LinkedIdentity) async throws { + // Records the *wire token*, not the provider case: the whole point is + // that Mastodon must address its own instance. + let _: Void = try perform( + label: "unlinkIdentity", + record: .unlinkIdentity(provider: identity.providerWireToken) + ) { $0.unlinkIdentityOutcomes } + set: { $0.unlinkIdentityOutcomes = $1 } + } + + func verifyIdentity(_ identity: LinkedIdentity) async throws -> Bool { + try perform( + label: "verifyIdentity", + record: .verifyIdentity(provider: identity.providerWireToken) + ) { $0.verifyIdentityOutcomes } + set: { $0.verifyIdentityOutcomes = $1 } + } + + func githubConnectionStatus() async throws -> GitHubConnection { + try perform(label: "githubConnectionStatus", record: .githubConnectionStatus) { + $0.githubConnectionStatusOutcomes + } set: { + $0.githubConnectionStatusOutcomes = $1 + } + } + func settings() async throws -> UserSettings { try perform(label: "settings", record: .settings) { $0.settingsOutcomes } set: { $0.settingsOutcomes = $1 } diff --git a/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/IdentityMappers.swift b/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/IdentityMappers.swift index 28f1311..e36bec0 100644 --- a/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/IdentityMappers.swift +++ b/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/IdentityMappers.swift @@ -24,7 +24,11 @@ extension LinkedIdentity { profileURL: dto.profileUrl.flatMap(URL.init(string:)), avatarURL: dto.avatarUrl.flatMap(URL.init(string:)), connectedAt: dto.connectedAt, - lastVerifiedAt: dto.lastVerifiedAt + lastVerifiedAt: dto.lastVerifiedAt, + // The Mastodon host, split out of the instance-qualified token + // (`"mastodon:techhub.social"`). Without this the instance was + // discarded and the provider itself fell to `.other` (GitHub #47). + instance: IdentityProvider.instanceHost(fromWireToken: dto.provider) ) } } diff --git a/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/LinkedIdentity.swift b/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/LinkedIdentity.swift index 817f07c..7efc1e7 100644 --- a/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/LinkedIdentity.swift +++ b/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/LinkedIdentity.swift @@ -16,10 +16,21 @@ public enum IdentityProvider: Sendable, Equatable, Hashable { /// GitHub. case github - /// A Mastodon instance (the specific instance host is carried on the - /// `LinkedIdentity`, not the provider case). + /// A Mastodon instance. + /// + /// The instance host is carried on ``LinkedIdentity/instance``, not in this + /// case — one `.mastodon` provider, many linked instances. Modelling the + /// host in the enum would make every instance its own provider and every + /// `switch` over providers unbounded. case mastodon + /// X (formerly Twitter). + /// + /// Was absent from this enum entirely, so a linked X identity — which the + /// test account has, and which cross-posting to X depends on — decoded as + /// `.other("twitter")` and rendered as an unknown provider (GitHub #47). + case twitter + /// Bluesky / AT Protocol. case bluesky @@ -32,16 +43,47 @@ public enum IdentityProvider: Sendable, Equatable, Hashable { /// Maps a wire string to a provider, case-insensitively. Unknown tokens /// preserve their original casing under `.other`. + /// + /// **Mastodon arrives instance-qualified.** The live payload sends + /// `"mastodon:techhub.social"`, not `"mastodon"` — confirmed 2026-09-15: + /// + /// ```json + /// {"provider":"mastodon:techhub.social", + /// "providerUsername":"interlinedlist_crew@techhub.social"} + /// ``` + /// + /// An exact match on `"mastodon"` therefore sent **every** Mastodon + /// identity to `.other`, where the UI renders it as an unknown provider and + /// offers none of its actions (GitHub #47). The prefix is split here, in the + /// one place that reads the token, and the host is returned by + /// ``instanceHost(fromWireToken:)`` for the caller to carry. public init(wireToken: String) { - switch wireToken.lowercased() { + let lowered = wireToken.lowercased() + if lowered == "mastodon" || lowered.hasPrefix("mastodon:") { + self = .mastodon + return + } + switch lowered { case "github": self = .github - case "mastodon": self = .mastodon case "bluesky", "atproto": self = .bluesky case "linkedin": self = .linkedin + case "twitter", "x": self = .twitter default: self = .other(wireToken) } } + /// The instance host embedded in a provider token, when there is one. + /// + /// `"mastodon:techhub.social"` → `"techhub.social"`; everything else `nil`. + /// Separate from `init(wireToken:)` because the provider and the instance + /// are two facts encoded in one string, and a caller usually wants both. + public static func instanceHost(fromWireToken token: String) -> String? { + let parts = token.split(separator: ":", maxSplits: 1, omittingEmptySubsequences: false) + guard parts.count == 2, parts[0].lowercased() == "mastodon" else { return nil } + let host = String(parts[1]) + return host.isEmpty ? nil : host + } + /// The canonical wire token for this provider. public var wireToken: String { switch self { @@ -49,9 +91,64 @@ public enum IdentityProvider: Sendable, Equatable, Hashable { case .mastodon: return "mastodon" case .bluesky: return "bluesky" case .linkedin: return "linkedin" + case .twitter: return "twitter" case .other(let raw): return raw } } + + /// The label the UI shows. + /// + /// `.other` surfaces its raw token capitalised, so an unrecognised provider + /// still renders a legible name — and an *empty* token falls back to + /// "Account" rather than rendering a blank row, which would look like a + /// layout bug rather than an unknown provider. + /// + /// Note `.twitter` reads "X" while its wire token stays `"twitter"`: the + /// server's vocabulary and the user's are different, and this is the seam + /// between them. + public var displayName: String { + switch self { + case .github: return "GitHub" + case .mastodon: return "Mastodon" + case .bluesky: return "Bluesky" + case .linkedin: return "LinkedIn" + case .twitter: return "X" + case .other(let raw): + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? "Account" : trimmed.capitalized + } + } + + // MARK: - Per-provider capability + // + // Data, not a switch statement per call site (GitHub #47). The Integrations + // pane renders one row per provider and needs to know which actions that row + // offers; expressing it here keeps the view a projection of the model rather + // than a parallel copy of these rules. + + /// Whether the provider supports more than one linked account. + /// + /// Only Mastodon does, which is why it is the one that will not retrofit if + /// it is modelled as a scalar first. + public var supportsMultipleInstances: Bool { self == .mastodon } + + /// Whether linking needs an instance host from the user before an authorize + /// URL can even be built. + public var requiresInstanceHost: Bool { self == .mastodon } + + /// Whether `POST /api/user/identities/verify` is meaningful for this + /// provider. `.other` is excluded: the client cannot describe what it would + /// be verifying. + public var isVerifiable: Bool { + if case .other = self { return false } + return true + } + + /// Whether the provider can be disconnected from this client. + public var isDisconnectable: Bool { + if case .other = self { return false } + return true + } } // MARK: - LinkedIdentity @@ -87,6 +184,30 @@ public struct LinkedIdentity: Sendable, Equatable, Hashable, Identifiable { /// When the link was last verified. `nil` when the server omits it. public let lastVerifiedAt: Date? + /// The Mastodon instance host this identity lives on, e.g. + /// `"techhub.social"`. `nil` for every other provider. + /// + /// Split out of the provider token, which arrives as + /// `"mastodon:techhub.social"`. Carried here rather than in the provider + /// enum because an account can link several instances, and one `.mastodon` + /// case with a host per identity is the shape that supports that; a case per + /// host would make the enum unbounded. + public let instance: String? + + /// The token the *server* uses for this identity, which for Mastodon is + /// instance-qualified. + /// + /// Unlink and verify both address an identity by provider token, so a + /// Mastodon call that sent a bare `"mastodon"` would be ambiguous on an + /// account with two instances — and the server could disconnect the wrong + /// one. This is the value those calls must send. + public var providerWireToken: String { + guard provider == .mastodon, let instance, !instance.isEmpty else { + return provider.wireToken + } + return "mastodon:\(instance)" + } + public init( id: String, provider: IdentityProvider, @@ -94,7 +215,8 @@ public struct LinkedIdentity: Sendable, Equatable, Hashable, Identifiable { profileURL: URL? = nil, avatarURL: URL? = nil, connectedAt: Date? = nil, - lastVerifiedAt: Date? = nil + lastVerifiedAt: Date? = nil, + instance: String? = nil ) { self.id = id self.provider = provider @@ -103,6 +225,7 @@ public struct LinkedIdentity: Sendable, Equatable, Hashable, Identifiable { self.avatarURL = avatarURL self.connectedAt = connectedAt self.lastVerifiedAt = lastVerifiedAt + self.instance = instance } } @@ -147,3 +270,30 @@ public struct UserOrganization: Sendable, Equatable, Hashable, Identifiable { self.joinedAt = joinedAt } } + +// MARK: - GitHubConnection + +/// The GitHub side of the Integrations pane (GitHub #47 / G33). +/// +/// Separate from `LinkedIdentity` because it describes the **app's** GitHub +/// configuration rather than one user's link: `isConfigured == false` means no +/// amount of user action will make linking work, which is a different message +/// from "you have not linked GitHub". +public struct GitHubConnection: Sendable, Equatable { + + /// Whether GitHub OAuth is configured on the server at all. + public let isConfigured: Bool + + /// The github.com page that manages this app's organization access — the + /// "Update orgs" destination. + /// + /// Opened in the browser on purpose: it is GitHub's own consent UI, and + /// re-implementing it natively would mean maintaining a copy of someone + /// else's permissions screen. + public let manageOrgAccessURL: URL? + + public init(isConfigured: Bool, manageOrgAccessURL: URL? = nil) { + self.isConfigured = isConfigured + self.manageOrgAccessURL = manageOrgAccessURL + } +} diff --git a/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/UserService.swift b/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/UserService.swift index 6d21d0f..3ddfbf3 100644 --- a/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/UserService.swift +++ b/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/UserService.swift @@ -22,6 +22,28 @@ public protocol UserServicing: Sendable { /// `{ identities: [...] }` envelope is internal. func identities() async throws -> [LinkedIdentity] + /// Disconnects a linked provider (GitHub #47 / G33). + /// + /// - Parameter identity: the identity to remove. Takes the whole value + /// rather than a provider case so Mastodon addresses the **right + /// instance**: the wire token is `"mastodon:techhub.social"`, and a bare + /// `"mastodon"` on an account with two instances is ambiguous enough that + /// the server could disconnect the wrong one. + /// + /// Destructive: disconnecting a cross-posting provider stops cross-posts to + /// it. The caller is expected to confirm first and say what stops working. + func unlinkIdentity(_ identity: LinkedIdentity) async throws + + /// Re-checks that a linked provider still works. + /// + /// Returns `true` when the connection is live. Same instance-qualified + /// addressing as `unlinkIdentity`. + func verifyIdentity(_ identity: LinkedIdentity) async throws -> Bool + + /// The GitHub connection status, including the github.com page that manages + /// this app's organization access. + func githubConnectionStatus() async throws -> GitHubConnection + /// Loads the organizations the signed-in user belongs to, with their own /// membership role and joined-at. Powers the org switcher. The /// `{ organizations: [...] }` envelope is internal. @@ -200,6 +222,23 @@ public final class UserService: UserServicing { self.baseURL = baseURL } + public func unlinkIdentity(_ identity: LinkedIdentity) async throws { + _ = try await api.send(User.unlinkIdentity(provider: identity.providerWireToken)) + } + + public func verifyIdentity(_ identity: LinkedIdentity) async throws -> Bool { + let response = try await api.send(User.verifyIdentity(provider: identity.providerWireToken)) + return response.isVerified + } + + public func githubConnectionStatus() async throws -> GitHubConnection { + let response = try await api.send(GitHub.connectionStatus()) + return GitHubConnection( + isConfigured: response.configured ?? false, + manageOrgAccessURL: response.manageOrgAccessUrl.flatMap(URL.init(string:)) + ) + } + public func identities() async throws -> [LinkedIdentity] { let response = try await api.send(User.identities()) return response.identities.map(LinkedIdentity.init(from:)) @@ -247,6 +286,9 @@ public final class UserService: UserServicing { case .mastodon: oauthProvider = .mastodon case .bluesky: oauthProvider = .bluesky case .linkedin: oauthProvider = .linkedin + // X has an OAuth route like the rest; it was absent from the domain + // provider enum, not from the kit's (GitHub #47). + case .twitter: oauthProvider = .twitter case .other(let token): throw UserServiceError.unsupportedProvider(token) } @@ -372,6 +414,9 @@ public final class UserService: UserServicing { case .mastodon: oauthProvider = .mastodon case .bluesky: oauthProvider = .bluesky case .linkedin: oauthProvider = .linkedin + // X has an OAuth route like the rest; it was absent from the domain + // provider enum, not from the kit's (GitHub #47). + case .twitter: oauthProvider = .twitter case .other(let token): throw UserServiceError.unsupportedProvider(token) } @@ -425,6 +470,9 @@ public final class UserService: UserServicing { case .mastodon: oauthProvider = .mastodon case .bluesky: oauthProvider = .bluesky case .linkedin: oauthProvider = .linkedin + // X has an OAuth route like the rest; it was absent from the domain + // provider enum, not from the kit's (GitHub #47). + case .twitter: oauthProvider = .twitter case .other(let token): throw UserServiceError.unsupportedProvider(token) } diff --git a/Packages/InterlinedDomain/Tests/InterlinedDomainTests/IdentityProviderTokenTests.swift b/Packages/InterlinedDomain/Tests/InterlinedDomainTests/IdentityProviderTokenTests.swift new file mode 100644 index 0000000..2bfcd97 --- /dev/null +++ b/Packages/InterlinedDomain/Tests/InterlinedDomainTests/IdentityProviderTokenTests.swift @@ -0,0 +1,156 @@ +// IdentityProviderTokenTests +// +// The provider-token parsing (GitHub #47 / G33). +// +// This is the file that matters in that issue. The live payload sends Mastodon +// **instance-qualified** — `"mastodon:techhub.social"` — and the client matched +// on `"mastodon"` exactly, so every Mastodon identity fell to `.other` and +// rendered as an unknown provider with none of its actions. X was missing from +// the enum entirely, despite the account having a linked X identity and +// cross-posting to X being a shipped feature. + +import XCTest +@testable import InterlinedDomain +@testable import InterlinedKit + +final class IdentityProviderTokenTests: XCTestCase { + + /// Captured from `GET /api/user/identities` on 2026-09-15 — all four + /// identities the test account actually has. + private let identitiesJSON = """ + { + "identities": [ + { + "id": "64ceab8b-a66e-40b8-8246-c2d20066366c", + "provider": "bluesky", + "providerUsername": "interlinedlist.bsky.social", + "profileUrl": "https://bsky.app/profile/interlinedlist.bsky.social", + "avatarUrl": null, + "connectedAt": "2026-08-07T00:46:44.352Z", + "lastVerifiedAt": "2026-09-05T04:00:20.264Z" + }, + { + "id": "dfbfcdb1-51a5-4523-902b-0ebfcfce4313", + "provider": "mastodon:techhub.social", + "providerUsername": "interlinedlist_crew@techhub.social", + "profileUrl": "https://techhub.social/@interlinedlist_crew", + "avatarUrl": "https://techhub.social/avatars/original/missing.png", + "connectedAt": "2026-04-07T16:35:32.476Z", + "lastVerifiedAt": "2026-08-22T06:00:41.130Z" + }, + { + "id": "da5c6ac1-8b5f-4a98-a791-afcdf7da1c75", + "provider": "twitter", + "providerUsername": "interlinedlist", + "profileUrl": "https://twitter.com/interlinedlist", + "avatarUrl": null, + "connectedAt": "2026-08-07T00:47:00.159Z", + "lastVerifiedAt": "2026-08-07T00:47:00.159Z" + }, + { + "id": "aaaaaaa1-0000-0000-0000-000000000000", + "provider": "github", + "providerUsername": "InterlinedListMessenger", + "profileUrl": null, + "avatarUrl": null, + "connectedAt": "2026-08-07T00:47:00.159Z", + "lastVerifiedAt": null + } + ] + } + """ + + private func identities() throws -> [LinkedIdentity] { + let response = try JSONCoders.makeDecoder().decode( + IdentitiesResponse.self, + from: Data(identitiesJSON.utf8) + ) + return response.identities.map(LinkedIdentity.init(from:)) + } + + // MARK: - Happy path + + func test_givenTheCapturedIdentityList_whenMapping_thenEveryProviderIsRecognised() throws { + let mapped = try identities() + + XCTAssertEqual(mapped.map(\.provider), [.bluesky, .mastodon, .twitter, .github]) + XCTAssertFalse( + mapped.contains { if case .other = $0.provider { return true } else { return false } }, + "not one of the account's real identities should decode as unknown" + ) + } + + func test_givenAnInstanceQualifiedToken_whenMapping_thenTheHostIsCarriedSeparately() throws { + let mastodon = try XCTUnwrap(try identities().first { $0.provider == .mastodon }) + + XCTAssertEqual(mastodon.instance, "techhub.social") + XCTAssertEqual( + mastodon.providerWireToken, + "mastodon:techhub.social", + "and the token round-trips, so unlink and verify address this instance" + ) + } + + func test_givenANonMastodonIdentity_whenAskingForItsToken_thenItIsTheBareProvider() throws { + let bluesky = try XCTUnwrap(try identities().first { $0.provider == .bluesky }) + + XCTAssertNil(bluesky.instance) + XCTAssertEqual(bluesky.providerWireToken, "bluesky") + } + + // MARK: - Invalid input + + func test_givenAGenuinelyUnknownProvider_whenMapping_thenItIsPreservedNotGuessed() { + // `.other` must keep working — the point of the fix is that Mastodon is + // no longer *wrongly* in it, not that the case is gone. + let provider = IdentityProvider(wireToken: "carrier-pigeon") + XCTAssertEqual(provider, .other("carrier-pigeon")) + XCTAssertEqual(provider.wireToken, "carrier-pigeon", "original casing survives") + } + + func test_givenAMalformedInstanceToken_whenExtractingTheHost_thenThereIsNone() { + // A trailing colon with no host is not an instance. Returning `""` would + // produce a wire token of `"mastodon:"`, which addresses nothing. + XCTAssertNil(IdentityProvider.instanceHost(fromWireToken: "mastodon:")) + XCTAssertNil(IdentityProvider.instanceHost(fromWireToken: "mastodon")) + XCTAssertNil(IdentityProvider.instanceHost(fromWireToken: "bluesky")) + XCTAssertEqual( + IdentityProvider.instanceHost(fromWireToken: "mastodon:a.b:c"), + "a.b:c", + "only the first colon separates; a host may legitimately contain more" + ) + } + + func test_givenABareMastodonToken_whenMapping_thenItStillResolvesToMastodon() { + // The server may not always qualify it. Handling only the qualified form + // would trade one wrong answer for another. + XCTAssertEqual(IdentityProvider(wireToken: "mastodon"), .mastodon) + XCTAssertEqual(IdentityProvider(wireToken: "MASTODON:Techhub.Social"), .mastodon) + } + + // MARK: - X / Twitter + + func test_givenEitherSpellingOfX_whenMapping_thenBothResolve() { + XCTAssertEqual(IdentityProvider(wireToken: "twitter"), .twitter) + XCTAssertEqual(IdentityProvider(wireToken: "x"), .twitter) + XCTAssertEqual(IdentityProvider.twitter.wireToken, "twitter", "the wire still says twitter") + XCTAssertEqual(IdentityProvider.twitter.displayName, "X", "the user still sees X") + } + + // MARK: - Capability flags + + func test_givenEachProvider_whenAskingItsCapabilities_thenOnlyMastodonIsMultiInstance() { + XCTAssertTrue(IdentityProvider.mastodon.supportsMultipleInstances) + XCTAssertTrue(IdentityProvider.mastodon.requiresInstanceHost) + for provider in [IdentityProvider.github, .bluesky, .linkedin, .twitter] { + XCTAssertFalse(provider.supportsMultipleInstances, "\(provider)") + XCTAssertFalse(provider.requiresInstanceHost, "\(provider)") + } + } + + func test_givenAnUnknownProvider_whenAskingItsCapabilities_thenNoActionsAreClaimed() { + let unknown = IdentityProvider.other("carrier-pigeon") + XCTAssertFalse(unknown.isVerifiable) + XCTAssertFalse(unknown.isDisconnectable) + } +} diff --git a/Packages/InterlinedKit/Sources/InterlinedKit/DTOs/GitHubDTO.swift b/Packages/InterlinedKit/Sources/InterlinedKit/DTOs/GitHubDTO.swift index e406306..ab122fd 100644 --- a/Packages/InterlinedKit/Sources/InterlinedKit/DTOs/GitHubDTO.swift +++ b/Packages/InterlinedKit/Sources/InterlinedKit/DTOs/GitHubDTO.swift @@ -406,3 +406,48 @@ public struct GitHubNextIssueNumberResponse: Decodable, Sendable, Equatable { number = c.firstInt("nextIssueNumber", "next_issue_number", "number", "next") ?? 0 } } + +// MARK: - Connection status (GitHub #47 / G33) + +/// Response of `GET /api/auth/github/status`. +public struct GitHubStatusResponse: Decodable, Sendable, Equatable { + /// Whether GitHub OAuth is configured on the server at all. `false` means + /// no amount of client work will make linking succeed. + public let configured: Bool? + public let clientId: String? + /// The github.com page that manages this app's organization access — the + /// "Update orgs" destination. + public let manageOrgAccessUrl: String? + + public init(configured: Bool? = nil, clientId: String? = nil, manageOrgAccessUrl: String? = nil) { + self.configured = configured + self.clientId = clientId + self.manageOrgAccessUrl = manageOrgAccessUrl + } +} + +/// One row of the bare array `GET /api/github/orgs` returns. +/// +/// Every field optional: the live response on the test account is `[]`, so the +/// populated row shape is **unverified**. Modelled from GitHub's own org shape, +/// which this route almost certainly passes through — and deliberately +/// permissive until a populated payload is captured, because guessing a required +/// key is how the G25 org-member defect happened. +public struct GitHubOrgDTO: Decodable, Sendable, Equatable { + public let id: Int? + public let login: String? + public let avatarUrl: String? + public let description: String? + + /// The stable human identifier. `login` is what GitHub URLs and this app's + /// own repo slugs use; the numeric id is a fallback, and an empty string + /// means a row so degraded it should not be rendered. + public var identifier: String { login ?? id.map(String.init) ?? "" } + + public init(id: Int? = nil, login: String? = nil, avatarUrl: String? = nil, description: String? = nil) { + self.id = id + self.login = login + self.avatarUrl = avatarUrl + self.description = description + } +} diff --git a/Packages/InterlinedKit/Sources/InterlinedKit/DTOs/UserDTO.swift b/Packages/InterlinedKit/Sources/InterlinedKit/DTOs/UserDTO.swift index d4d216e..e2a8493 100644 --- a/Packages/InterlinedKit/Sources/InterlinedKit/DTOs/UserDTO.swift +++ b/Packages/InterlinedKit/Sources/InterlinedKit/DTOs/UserDTO.swift @@ -314,7 +314,48 @@ public struct IdentitiesResponse: Decodable, Sendable, Equatable { } } -/// A single linked OAuth identity (GitHub, Mastodon, Bluesky, LinkedIn). +/// Request body for `POST /api/user/identities/verify`. +public struct VerifyIdentityRequest: Encodable, Sendable, Equatable { + public let provider: String + + public init(provider: String) { + self.provider = provider + } +} + +/// Response of `POST /api/user/identities/verify`. +/// +/// Every field is optional and the shape is read permissively: the verify write +/// was **not exercised live** — it mutates a shared recon account's connection +/// state — so this follows the documented action rather than a capture. The +/// fields are named for what `/help/settings` describes Verify as doing. +/// Tighten once a real response is seen; until then a missing key degrades one +/// field rather than failing the call, and `isVerified` falls back to "the call +/// succeeded", which is the only thing the status code really tells us. +public struct VerifyIdentityResponse: Decodable, Sendable, Equatable { + public let verified: Bool? + public let provider: String? + public let message: String? + public let lastVerifiedAt: Date? + + public init( + verified: Bool? = nil, + provider: String? = nil, + message: String? = nil, + lastVerifiedAt: Date? = nil + ) { + self.verified = verified + self.provider = provider + self.message = message + self.lastVerifiedAt = lastVerifiedAt + } + + /// Whether the connection is live. A body with no `verified` key but a 2xx + /// status counts as verified — the route answering at all is the signal. + public var isVerified: Bool { verified ?? true } +} + +/// A single linked OAuth identity (GitHub, Mastodon, Bluesky, LinkedIn, X). public struct LinkedIdentityDTO: Decodable, Sendable, Equatable { public let id: String public let provider: String diff --git a/Packages/InterlinedKit/Sources/InterlinedKit/Endpoints/GitHubEndpoint.swift b/Packages/InterlinedKit/Sources/InterlinedKit/Endpoints/GitHubEndpoint.swift index e1bd88f..fc9b2a9 100644 --- a/Packages/InterlinedKit/Sources/InterlinedKit/Endpoints/GitHubEndpoint.swift +++ b/Packages/InterlinedKit/Sources/InterlinedKit/Endpoints/GitHubEndpoint.swift @@ -139,3 +139,34 @@ public enum GitHub { Request(method: .get, path: "/api/github/repos/\(repo)/next-issue-number", auth: .bearer) } } + +// MARK: - Connection status and org access (GitHub #47 / G33) + +public extension GitHub { + + /// `GET /api/auth/github/status` — whether GitHub OAuth is configured, and + /// the URL that manages this app's org access on github.com. + /// + /// VERIFIED live 2026-09-15: + /// + /// ```json + /// {"configured":true,"clientId":"Ov23li9eXYK1i6psJW6G", + /// "manageOrgAccessUrl":"https://github.com/settings/connections/applications/Ov23li9eXYK1i6psJW6G"} + /// ``` + /// + /// `manageOrgAccessUrl` is the "Update orgs" affordance. It is a github.com + /// page — open it in the browser rather than trying to render GitHub's + /// org-access UI natively. + static func connectionStatus() -> Request { + Request(method: .get, path: "/api/auth/github/status", auth: .bearer) + } + + /// `GET /api/github/orgs` — the organizations this app can see. + /// + /// ⚠️ Answers a **bare array**, not the `{message?, }` envelope + /// most single-resource routes on this API use. Verified live 2026-09-15: + /// `200 []` on the test account, which belongs to no orgs the app can read. + static func orgs() -> Request<[GitHubOrgDTO]> { + Request(method: .get, path: "/api/github/orgs", auth: .bearer) + } +} diff --git a/Packages/InterlinedKit/Sources/InterlinedKit/Endpoints/UserEndpoint.swift b/Packages/InterlinedKit/Sources/InterlinedKit/Endpoints/UserEndpoint.swift index d7d65d8..8dbb6ec 100644 --- a/Packages/InterlinedKit/Sources/InterlinedKit/Endpoints/UserEndpoint.swift +++ b/Packages/InterlinedKit/Sources/InterlinedKit/Endpoints/UserEndpoint.swift @@ -22,9 +22,48 @@ public enum User { } /// `GET /api/user/identities` — linked OAuth identities. - /// **Session-only** per decision 0001 (Bearer is rejected here). + /// + /// **Corrected 2026-09-15 (GitHub #47): this route accepts Bearer.** A live + /// probe with a valid sync-token returns `200` with the full identity list, + /// so the `.session` annotation was wrong. It was not a hard failure — the + /// kit has a cookie-session transport that lazily logs in — but it cost an + /// extra credentialed round-trip on every load of the Identities pane and + /// authenticated the same request a second way for no reason. + /// + /// This is the same class of error as the `send-verification-email` + /// correction in PR #83, in the opposite direction: an annotation inferred + /// from one observation rather than from a probe under the transport in + /// question. public static func identities() -> Request { - Request(method: .get, path: "/api/user/identities", auth: .session) + Request(method: .get, path: "/api/user/identities", auth: .bearer) + } + + /// `DELETE /api/user/identities` — unlink a provider. + /// + /// The provider token is instance-qualified for Mastodon + /// (`"mastodon:techhub.social"`), because an account can link several + /// instances and a bare `"mastodon"` would be ambiguous — the server could + /// disconnect the wrong one. Callers should pass + /// `LinkedIdentity.providerWireToken`, which is exactly that value. + public static func unlinkIdentity(provider: String) -> Request { + Request( + method: .delete, + path: "/api/user/identities", + query: [.string("provider", provider)], + auth: .bearer + ) + } + + /// `POST /api/user/identities/verify` — re-check a connection is still live. + /// + /// The web's Verify action. Same instance-qualified token as unlink. + public static func verifyIdentity(provider: String) -> Request { + Request( + method: .post, + path: "/api/user/identities/verify", + body: .json(VerifyIdentityRequest(provider: provider)), + auth: .bearer + ) } /// `GET /api/user/organizations` — organizations the user belongs to, with diff --git a/Packages/InterlinedKit/Tests/InterlinedKitTests/UserEndpointTests.swift b/Packages/InterlinedKit/Tests/InterlinedKitTests/UserEndpointTests.swift index 6979d83..548d90c 100644 --- a/Packages/InterlinedKit/Tests/InterlinedKitTests/UserEndpointTests.swift +++ b/Packages/InterlinedKit/Tests/InterlinedKitTests/UserEndpointTests.swift @@ -163,41 +163,99 @@ final class UserEndpointTests: XCTestCase { } } - // MARK: - identities (session-only) - - func test_givenIdentities_whenBuilt_thenUsesSessionAuth() { + // MARK: - identities (Bearer — corrected 2026-09-15, GitHub #47) + + func test_givenIdentities_whenBuilt_thenUsesBearerAuth() { + // CORRECTED: this route was on the decision-0001 session allowlist. A + // live probe with a valid Bearer sync-token returns 200 with the full + // identity list, so the annotation was wrong — the same class of error + // as the `send-verification-email` correction in PR #83, in the opposite + // direction. It was not a hard failure (the kit has a cookie-session + // transport that lazily logs in) but it cost an extra credentialed + // round-trip on every load of the Identities pane. let request = User.identities() XCTAssertEqual(request.method, .get) XCTAssertEqual(request.path, "/api/user/identities") - XCTAssertEqual(request.auth, .session) // decision-0001 allowlist + XCTAssertEqual(request.auth, .bearer) } - func test_givenIdentitiesEnvelope_whenSent_thenDecodesViaSessionTransport() async throws { - // Happy path: .session routes through the session transport. - let session = StubHTTPDataTransport() - await session.enqueue(.json(#""" - {"identities":[{"id":"i1","provider":"github","providerUsername":"ada", - "profileUrl":"https://github.com/ada","avatarUrl":null, - "connectedAt":"2026-01-01T00:00:00.000Z","lastVerifiedAt":null}]} + func test_givenIdentitiesEnvelope_whenSent_thenDecodesTheCapturedShape() async throws { + // The payload is the one captured live on 2026-09-15, including the + // instance-qualified Mastodon provider token that used to decode as an + // unknown provider. + let (client, transport, _) = makeClient() + await transport.enqueue(.json(#""" + {"identities":[ + {"id":"i1","provider":"github","providerUsername":"ada", + "profileUrl":"https://github.com/ada","avatarUrl":null, + "connectedAt":"2026-01-01T00:00:00.000Z","lastVerifiedAt":null}, + {"id":"i2","provider":"mastodon:techhub.social", + "providerUsername":"crew@techhub.social", + "profileUrl":"https://techhub.social/@crew","avatarUrl":null, + "connectedAt":"2026-04-07T16:35:32.476Z", + "lastVerifiedAt":"2026-08-22T06:00:41.130Z"}]} """#)) - let (client, _, _) = makeClient(sessionTransport: session) let response = try await client.send(User.identities()) - XCTAssertEqual(response.identities.count, 1) + XCTAssertEqual(response.identities.count, 2) XCTAssertEqual(response.identities.first?.provider, "github") - XCTAssertNil(response.identities.first?.lastVerifiedAt) + XCTAssertEqual( + response.identities.last?.provider, + "mastodon:techhub.social", + "the kit passes the qualified token through; the domain splits it" + ) } func test_givenNoIdentities_whenSent_thenDecodesEmpty() async throws { // Boundary: account with no linked identities. - let session = StubHTTPDataTransport() - await session.enqueue(.json(#"{"identities":[]}"#)) - let (client, _, _) = makeClient(sessionTransport: session) + let (client, transport, _) = makeClient() + await transport.enqueue(.json(#"{"identities":[]}"#)) let response = try await client.send(User.identities()) XCTAssertTrue(response.identities.isEmpty) } + // MARK: - unlink / verify (GitHub #47) + + func test_givenAnUnlink_whenBuilt_thenTheProviderTravelsAsAQueryParameter() { + let request = User.unlinkIdentity(provider: "mastodon:techhub.social") + XCTAssertEqual(request.method, .delete) + XCTAssertEqual(request.path, "/api/user/identities") + XCTAssertEqual(request.auth, .bearer) + // Instance-qualified: a bare "mastodon" on an account with two instances + // is ambiguous enough that the server could disconnect the wrong one. + XCTAssertEqual( + request.query.first(where: { $0.name == "provider" })?.value, + "mastodon:techhub.social" + ) + } + + func test_givenAVerify_whenBuilt_thenTheProviderIsInTheBody() throws { + let request = User.verifyIdentity(provider: "bluesky") + XCTAssertEqual(request.method, .post) + XCTAssertEqual(request.path, "/api/user/identities/verify") + XCTAssertEqual(try encodedBody(request)["provider"] as? String, "bluesky") + } + + func test_givenAVerifyResponseWithNoVerifiedKey_whenDecoding_thenItCountsAsVerified() throws { + // The verify write was not exercised live — it mutates a shared recon + // account's connection state — so the decoder is permissive and a 2xx + // with an unexpected body is read as success. The route answering at all + // is the signal; claiming "not responding" on a shape we have not seen + // would be a lie about the provider. + let decoded = try JSONCoders.makeDecoder().decode( + VerifyIdentityResponse.self, + from: Data(#"{"message":"ok"}"#.utf8) + ) + XCTAssertTrue(decoded.isVerified) + + let explicit = try JSONCoders.makeDecoder().decode( + VerifyIdentityResponse.self, + from: Data(#"{"verified":false}"#.utf8) + ) + XCTAssertFalse(explicit.isVerified, "an explicit false is still respected") + } + // MARK: - organizations (Bearer — corrected 2026-09-09, G25) func test_givenOrganizations_whenBuilt_thenUsesBearerAuth() {