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() {