Files
Termind/FrameworkTests/MCPAuthorizationTests.swift

273 lines
14 KiB
Swift

// SPDX-FileCopyrightText: 2026 Kaede Akino
// SPDX-License-Identifier: GPL-3.0-only
import AgentKit
import Foundation
import Observation
import SwiftData
import Synchronization
import TermindDomain
import TermindPersistence
import Testing
@testable import TermindApplication
@testable import TermindRemoteInfrastructure
@Suite(.timeLimit(.minutes(1))) @MainActor
struct MCPAuthorizationTests {
private func grant(_ server: MCPServer, token: String = "fixture-token") -> MCPOAuthCredential {
.init(
resource: server.url, issuer: "https://issuer.example", configuration: server.oauth ?? .init(),
clientID: "client", tokenEndpoint: URL(string: "https://issuer.example/token")!,
accessToken: token, refreshToken: "fixture-refresh")
}
@Test func draftsDiscardNewGrantsAndCommitOnlyWithConfiguration() throws {
let server = MCPServer(name: "Example", url: "https://resource.example/mcp")
let backing = InMemoryMCPAuthorizationStore()
let model = MCPAuthorizationModel(storage: backing)
model.synchronize([server])
let cancelled = try model.draft(for: server)
try cancelled.save([grant(server)], for: server.id)
try cancelled.saveRefreshed([grant(server, token: "draft-rotation")], for: server.id)
#expect(backing.credentials(for: server.id).isEmpty)
cancelled.discard()
#expect(backing.credentials(for: server.id).isEmpty)
let draft = try model.draft(for: server)
defer { draft.discard() }
try draft.save([grant(server)], for: server.id)
enum SaveFailure: Error { case failed }
#expect(throws: SaveFailure.failed) {
try draft.commit(server: server) { throw SaveFailure.failed }
}
#expect(backing.credentials(for: server.id).isEmpty)
try draft.commit(server: server) { model.synchronize([server]) }
#expect(backing.credentials(for: server.id) == [grant(server)])
#expect(model.isSignedIn(server))
}
@Test func stagedGrantChangesPublishLoginStatusForTheEditor() throws {
let server = MCPServer(name: "Example", url: "https://resource.example/mcp")
let model = MCPAuthorizationModel(storage: InMemoryMCPAuthorizationStore())
let draft = try model.draft(for: server)
defer { draft.discard() }
let changed = Mutex(false)
withObservationTracking {
#expect(!draft.isSignedIn)
} onChange: {
changed.withLock { $0 = true }
}
try draft.save([grant(server)], for: server.id)
#expect(changed.withLock { $0 })
#expect(draft.isSignedIn)
}
@Test func originAudienceGrantsRemainBoundToTheSavedMCPEndpoint() throws {
var server = MCPServer(name: "Example", url: "https://resource.example/mcp")
let backing = InMemoryMCPAuthorizationStore()
let model = MCPAuthorizationModel(storage: backing)
model.synchronize([server])
var value = grant(server)
value.authorizationResource = "https://resource.example/"
let draft = try model.draft(for: server)
defer { draft.discard() }
try draft.save([value], for: server.id)
try draft.commit(server: server) { model.synchronize([server]) }
#expect(model.isSignedIn(server))
#expect(backing.credentials(for: server.id).first?.authorizationResource == "https://resource.example/")
server.url = "https://resource.example/another-mcp"
#expect(!model.isSignedIn(server))
model.synchronize([server])
#expect(backing.credentials(for: server.id).isEmpty)
}
@Test func refreshRotationPersistsBeforeDraftSaveAndSurvivesDiscard() throws {
let server = MCPServer(name: "Example", url: "https://resource.example/mcp")
let backing = InMemoryMCPAuthorizationStore()
backing.setCredentials([grant(server)], for: server.id)
let model = MCPAuthorizationModel(storage: backing)
model.synchronize([server])
let draft = try model.draft(for: server)
var refreshed = grant(server, token: "rotated")
refreshed.refreshToken = "rotated-refresh"
try draft.saveRefreshed([refreshed], for: server.id)
draft.discard()
#expect(backing.credentials(for: server.id) == [refreshed])
}
@Test func changingIssuerClearsDraftSecretWithoutChangingSavedLogin() throws {
var server = MCPServer(name: "Example", url: "https://resource.example/mcp")
server.oauth = .init(issuer: "https://issuer.example", clientID: "client")
let backing = InMemoryMCPAuthorizationStore()
backing.setCredentials([grant(server)], for: server.id)
backing.setClientSecret("fixture-client-secret", for: server)
let model = MCPAuthorizationModel(storage: backing)
model.synchronize([server])
let draft = try model.draft(for: server)
defer { draft.discard() }
var changed = server
changed.oauth?.issuer = "https://different.example"
draft.update(changed)
#expect(draft.secret.isEmpty)
#expect(try draft.clientSecret(for: changed) == nil)
#expect(!draft.isSignedIn)
#expect(backing.clientSecret(for: server) == "fixture-client-secret")
#expect(throws: CancellationError.self) { try draft.save([grant(server)], for: server.id) }
try draft.commit(server: changed) { model.synchronize([changed]) }
#expect(backing.credentials(for: server.id).isEmpty)
#expect(backing.clientSecret(for: changed) == nil)
}
@Test func configurationChangesAndDeletionInvalidateDeviceGrants() throws {
var server = MCPServer(name: "Example", url: "https://resource.example/mcp")
let backing = InMemoryMCPAuthorizationStore()
let model = MCPAuthorizationModel(storage: backing)
model.synchronize([server])
backing.setCredentials([grant(server)], for: server.id)
server.url = "https://another.example/mcp"
model.synchronize([server])
#expect(backing.credentials(for: server.id).isEmpty)
backing.setCredentials([grant(server)], for: server.id)
model.synchronize([])
#expect(backing.credentials(for: server.id).isEmpty)
#expect(!model.isSignedIn(server))
}
@Test func retiredToolConfigurationCannotStartAnotherLogin() async throws {
let server = MCPServer(name: "Example", url: "https://resource.example/mcp")
let model = MCPAuthorizationModel(storage: InMemoryMCPAuthorizationStore())
model.synchronize([server])
var changed = server
changed.oauth = .init(issuer: "https://another.example")
model.synchronize([changed])
await #expect(throws: CancellationError.self) {
try await model.token(for: server, challenge: nil, rejectedToken: nil, interaction: nil)
}
model.synchronize([])
await #expect(throws: CancellationError.self) {
try await model.token(for: changed, challenge: nil, rejectedToken: nil, interaction: nil)
}
}
@Test func oldSyncPayloadDefaultsToDiscoveryAndNewPayloadContainsNoGrant() throws {
var server = MCPServer(name: "Example", url: "https://resource.example/mcp")
var object = try #require(
JSONSerialization.jsonObject(with: JSONEncoder().encode(SyncedMCPServer(server, sortIndex: 0)))
as? [String: Any])
object.removeValue(forKey: "oauth")
let decoded = try JSONDecoder().decode(
SyncedMCPServer.self, from: JSONSerialization.data(withJSONObject: object))
#expect(decoded.value.oauth == nil)
server.oauth = .init(issuer: "https://issuer.example", clientID: "public-id")
let data = try JSONEncoder().encode(SyncedMCPServer(server, sortIndex: 0))
#expect(try JSONDecoder().decode(SyncedMCPServer.self, from: data).value.oauth == server.oauth)
let wire = String(decoding: data, as: UTF8.self)
#expect(!wire.contains("accessToken") && !wire.contains("refreshToken") && !wire.contains("clientSecret"))
}
@Test func version42MigratesSidecarAndReopensTwice() throws {
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: directory) }
let url = directory.appendingPathComponent("Termind.store")
var server = MCPServer(name: "Saved", url: "https://resource.example/mcp")
func writeOldStore() throws {
let schema = Schema(versionedSchema: TermindSchemaV42.self)
let container = try ModelContainer(
for: schema,
configurations: [ModelConfiguration("Termind", schema: schema, url: url, cloudKitDatabase: .none)])
container.mainContext.insert(PersistentMCPServer(server, sortIndex: 0))
try container.mainContext.save()
}
try writeOldStore()
func migrate() throws {
let container = try TermindPersistence.makeContainer(url: url)
let store = AIPersistence(context: container.mainContext)
var snapshot = try store.load()
#expect(snapshot.mcpServers == [server])
server.oauth = .init(issuer: "https://issuer.example", clientID: "client")
snapshot.mcpServers = [server]
try store.save(snapshot)
}
try migrate()
for _ in 0..<2 {
let container = try TermindPersistence.makeContainer(url: url)
#expect(try AIPersistence(context: container.mainContext).load().mcpServers == [server])
#expect(try container.mainContext.fetchCount(FetchDescriptor<PersistentMCPOAuthConfiguration>()) == 1)
}
}
@Test func keychainUsesOnlyDeviceScopeAndSeparatesServersAndClientBindings() throws {
let secrets = OAuthRecordingSecrets()
let keychain = MCPKeychainCredentialStore(keychain: secrets)
let server = MCPServer(name: "Example", url: "https://resource.example/mcp")
let other = MCPServer(name: "Other", url: server.url)
try keychain.setCredentials([grant(server)], for: server.id)
try keychain.setClientSecret("fixture-secret", for: server)
#expect(try keychain.credentials(for: server.id) == [grant(server)])
#expect(try keychain.credentials(for: other.id).isEmpty)
var changed = server
changed.oauth = .init(issuer: "https://another.example", clientID: "another")
#expect(try keychain.clientSecret(for: changed) == nil)
try keychain.remove(serverID: server.id)
#expect(try keychain.credentials(for: server.id).isEmpty)
#expect(try keychain.clientSecret(for: server) == nil)
#expect(
secrets.calls.withLock { $0.allSatisfy { $0.0.hasPrefix("termind.mcp.oauth.") && $0.1 == .thisDevice } })
}
@Test func authorizationQueueResumesAndCancellationDropsLateCallbacks() async throws {
let conversation = ConversationModel()
conversation.handle(.runState(.running))
func request() -> MCPAuthorizationRequest {
.init(
serverID: UUID(), serverName: "Example", issuer: URL(string: "https://issuer.example")!,
scopes: ["read"],
url: URL(string: "https://issuer.example/authorize?state=fixture-state")!,
redirectURI: MCPAuthorizationModel.redirectURI)
}
let first = request()
let pending = Task { try await conversation.requestMCPAuthorization(first) }
for _ in 0..<100 where conversation.pendingMCPAuthorizations.isEmpty { await Task.yield() }
#expect(conversation.runState == .waitingForUser)
#expect(conversation.notificationInteraction?.kind == .mcpAuthorization)
let callback = MCPAuthorizationModel.redirectURI.appending(queryItems: [
URLQueryItem(name: "code", value: "fixture-code")
])
conversation.respondToMCPAuthorization(first.id, result: .success(callback))
#expect(try await pending.value == callback)
let second = request()
let cancelled = Task { try await conversation.requestMCPAuthorization(second) }
for _ in 0..<100 where conversation.pendingMCPAuthorizations.isEmpty { await Task.yield() }
cancelled.cancel()
await #expect(throws: CancellationError.self) { try await cancelled.value }
conversation.respondToMCPAuthorization(second.id, result: .success(callback))
let stopped = Task { try await conversation.requestMCPAuthorization(request()) }
for _ in 0..<100 where conversation.pendingMCPAuthorizations.isEmpty { await Task.yield() }
conversation.stop()
await #expect(throws: CancellationError.self) { try await stopped.value }
#expect(conversation.pendingMCPAuthorizations.isEmpty)
#expect(conversation.transcript.isEmpty)
#expect(conversation.messages.isEmpty)
}
}
private nonisolated final class OAuthRecordingSecrets: SecretStoring, Sendable {
let calls = Mutex<[(String, PasswordStorage)]>([])
private let values = InMemorySecretStore()
func string(for account: String, storage: PasswordStorage) throws -> String? {
calls.withLock { $0.append((account, storage)) }
return try values.string(for: account, storage: storage)
}
func setString(_ value: String?, for account: String, storage: PasswordStorage) throws {
calls.withLock { $0.append((account, storage)) }
try values.setString(value, for: account, storage: storage)
}
func keyCredential(for account: String, storage: KeyStorage) throws -> StoredSSHKeyCredential? { nil }
func setKeyCredential(_ value: StoredSSHKeyCredential?, for account: String, storage: KeyStorage) throws {}
func removePassword(_ account: String, storage: PasswordStorage) throws {
try setString(nil, for: account, storage: storage)
}
func removeKeyCredential(_ account: String, storage: KeyStorage) throws {}
}