9fd04b0ce4
Add the owner-only AF_UNIX Reyna CLI privacy host, strict signed-app installation, and typed native routing for Calendar, Contacts, and Reminders.\n\nAdd bounded system-status paths and config-only direct local-service wrappers. Preserve MacMiniMCP pending explicit cutover approval.\n\nApple Notes is intentionally deferred: no native Notes operations, Apple Events declaration, or Automation helper are included; legacy Notes handling remains untouched.
444 lines
20 KiB
Swift
444 lines
20 KiB
Swift
import XCTest
|
||
@testable import ReynaCLIHostCore
|
||
import Foundation
|
||
|
||
// Tests for reminders.request_full_access – TDD, fake providers only
|
||
// Modeled after CalendarAuthorizationTests.swift
|
||
|
||
final class RemindersAuthorizationTests: XCTestCase {
|
||
|
||
// MARK: - Fake auth providers
|
||
|
||
struct AlreadyAuthorizedProvider: RemindersAuthorizationProviding {
|
||
func authorizationStatus() -> RemindersAuthorizationStatus { .authorized }
|
||
func requestFullAccess() throws -> Bool {
|
||
XCTFail("requestFullAccess must not be called when already authorized")
|
||
return false
|
||
}
|
||
}
|
||
|
||
struct NotDeterminedGrantedProvider: RemindersAuthorizationProviding {
|
||
func authorizationStatus() -> RemindersAuthorizationStatus { .notDetermined }
|
||
func requestFullAccess() throws -> Bool { true }
|
||
}
|
||
|
||
struct NotDeterminedDeniedProvider: RemindersAuthorizationProviding {
|
||
func authorizationStatus() -> RemindersAuthorizationStatus { .notDetermined }
|
||
func requestFullAccess() throws -> Bool { false }
|
||
}
|
||
|
||
struct DeniedProvider: RemindersAuthorizationProviding {
|
||
func authorizationStatus() -> RemindersAuthorizationStatus { .denied }
|
||
func requestFullAccess() throws -> Bool { false }
|
||
}
|
||
|
||
struct TimeoutProvider: RemindersAuthorizationProviding {
|
||
func authorizationStatus() -> RemindersAuthorizationStatus { .notDetermined }
|
||
func requestFullAccess() throws -> Bool {
|
||
throw RemindersProviderError.unavailable("reminders authorization timed out")
|
||
}
|
||
}
|
||
|
||
struct ErrorProvider: RemindersAuthorizationProviding {
|
||
func authorizationStatus() -> RemindersAuthorizationStatus { .notDetermined }
|
||
func requestFullAccess() throws -> Bool {
|
||
throw RemindersProviderError.unavailable("disk error")
|
||
}
|
||
}
|
||
|
||
// MARK: - Empty reminders providers for dispatch
|
||
|
||
struct EmptyLists: RemindersListsProviding {
|
||
func listReminderLists() throws -> [ReminderListItem] { [] }
|
||
}
|
||
|
||
struct EmptyList: RemindersListProviding {
|
||
func listReminders(listId: String?, listTitle: String?, completed: Bool?, limit: Int) throws -> [ReminderItem] { [] }
|
||
}
|
||
|
||
struct EmptyCreate: RemindersCreateProviding {
|
||
func createReminder(title: String, listId: String?, listTitle: String?, notes: String?, due: Date?, priority: Int?) throws -> ReminderCreateResult {
|
||
ReminderCreateResult(id: "r1", list_id: "l1", list_title: "t", title: title)
|
||
}
|
||
}
|
||
|
||
struct EmptyCalList: CalendarListProviding { func listCalendars() throws -> [CalendarListItem] { [] } }
|
||
struct EmptyCalEvents: CalendarEventsListProviding {
|
||
func listEvents(start: Date, end: Date, calendarId: String?, calendarTitle: String?, limit: Int) throws -> [CalendarEventItem] { [] }
|
||
}
|
||
struct EmptyCalCreate: CalendarEventCreateProviding {
|
||
func createEvent(title: String, start: Date, end: Date, allDay: Bool, notes: String?, location: String?, calendarId: String?, calendarTitle: String?) throws -> CalendarEventItem {
|
||
CalendarEventItem(id: "e", title: title, start: "2026-01-01T00:00:00Z", end: "2026-01-01T01:00:00Z", all_day: false, calendar_id: "c", calendar_title: "t", notes: nil, location: nil)
|
||
}
|
||
}
|
||
struct MockCalAuth: CalendarAuthorizationProviding {
|
||
var status: CalendarAuthorizationStatus = .authorized
|
||
func authorizationStatus() -> CalendarAuthorizationStatus { status }
|
||
func requestFullAccess() throws -> Bool { false }
|
||
}
|
||
struct EmptyContactsAuth: ContactsAuthorizationProviding {
|
||
func authorizationStatus() -> ContactsAuthorizationStatus { .authorized }
|
||
func requestAccess() throws -> Bool { false }
|
||
}
|
||
struct EmptyContactsSearch: ContactsSearchProviding { func searchContacts(query: String?, limit: Int) throws -> [ContactListItem] { [] } }
|
||
struct EmptyContactsRead: ContactsReadProviding {
|
||
func readContact(id: String) throws -> ContactDetailItem { throw ContactsProviderError.notFound("nf") }
|
||
}
|
||
struct EmptyContactsCreate: ContactsCreateProviding {
|
||
func createContact(firstName: String?, lastName: String?, organization: String?, jobTitle: String?, note: String?, email: ContactEmailLabelValue?, phone: ContactPhoneLabelValue?) throws -> ContactCreateResult {
|
||
throw ContactsProviderError.unavailable("na")
|
||
}
|
||
}
|
||
|
||
private func dispatchRemindersAuth(op: String, id: String, auth: RemindersAuthorizationProviding) -> Response {
|
||
let req = Request(id: id, operation: op, arguments: .object([:]))
|
||
return dispatch(
|
||
request: req,
|
||
calendarProvider: EmptyCalList(),
|
||
eventsProvider: EmptyCalEvents(),
|
||
createProvider: EmptyCalCreate(),
|
||
authProvider: MockCalAuth(),
|
||
contactsAuthProvider: EmptyContactsAuth(),
|
||
contactsSearchProvider: EmptyContactsSearch(),
|
||
contactsReadProvider: EmptyContactsRead(),
|
||
contactsCreateProvider: EmptyContactsCreate(),
|
||
remindersAuthProvider: auth,
|
||
remindersListsProvider: EmptyLists(),
|
||
remindersListProvider: EmptyList(),
|
||
remindersCreateProvider: EmptyCreate()
|
||
)
|
||
}
|
||
|
||
// MARK: - Core auth behavior
|
||
|
||
func testAlreadyAuthorizedReturnsAuthorizedWithoutRequesting() {
|
||
let auth = AlreadyAuthorizedProvider()
|
||
let resp = dispatchRemindersAuth(op: "reminders.request_full_access", id: "ra1", auth: auth)
|
||
XCTAssertTrue(resp.ok)
|
||
XCTAssertEqual(resp.result?.status, "authorized")
|
||
XCTAssertEqual(resp.result?.operation, "reminders.request_full_access")
|
||
XCTAssertEqual(resp.id, "ra1")
|
||
}
|
||
|
||
func testNotDeterminedReachesRequestPath() {
|
||
final class TrackingProvider: RemindersAuthorizationProviding, @unchecked Sendable {
|
||
var didRequest = false
|
||
var status: RemindersAuthorizationStatus = .notDetermined
|
||
func authorizationStatus() -> RemindersAuthorizationStatus { status }
|
||
func requestFullAccess() throws -> Bool {
|
||
didRequest = true
|
||
return true
|
||
}
|
||
}
|
||
let tracking = TrackingProvider()
|
||
let resp = dispatchRemindersAuth(op: "reminders.request_full_access", id: "ra2", auth: tracking)
|
||
XCTAssertTrue(tracking.didRequest, "requestFullAccess must be called when notDetermined")
|
||
XCTAssertTrue(resp.ok)
|
||
}
|
||
|
||
func testGrantedReturnsAuthorizedResult() {
|
||
let auth = NotDeterminedGrantedProvider()
|
||
let resp = dispatchRemindersAuth(op: "reminders.request_full_access", id: "ra3", auth: auth)
|
||
XCTAssertTrue(resp.ok)
|
||
XCTAssertEqual(resp.result?.status, "authorized")
|
||
XCTAssertEqual(resp.result?.protocol_version, PROTOCOL_VERSION)
|
||
XCTAssertNil(resp.error)
|
||
XCTAssertNil(resp.result?.reminders, "must not output reminder content")
|
||
XCTAssertNil(resp.result?.reminder_lists)
|
||
XCTAssertNil(resp.result?.reminder)
|
||
}
|
||
|
||
func testDeniedReturnsPermissionDenied() {
|
||
let auth = NotDeterminedDeniedProvider()
|
||
let resp = dispatchRemindersAuth(op: "reminders.request_full_access", id: "ra4", auth: auth)
|
||
XCTAssertFalse(resp.ok)
|
||
XCTAssertEqual(resp.error?.code, "permission_denied")
|
||
XCTAssertNotNil(resp.error?.message)
|
||
XCTAssertNil(resp.result)
|
||
}
|
||
|
||
func testAlreadyDeniedPathAlsoDenies() {
|
||
let auth = DeniedProvider()
|
||
let resp = dispatchRemindersAuth(op: "reminders.request_full_access", id: "ra5", auth: auth)
|
||
XCTAssertFalse(resp.ok)
|
||
XCTAssertEqual(resp.error?.code, "permission_denied")
|
||
}
|
||
|
||
func testTimeoutReturnsRemindersUnavailable() {
|
||
let auth = TimeoutProvider()
|
||
let resp = dispatchRemindersAuth(op: "reminders.request_full_access", id: "ra6", auth: auth)
|
||
XCTAssertFalse(resp.ok)
|
||
XCTAssertEqual(resp.error?.code, "reminders_unavailable")
|
||
XCTAssertTrue(resp.error?.message.lowercased().contains("timed out") ?? false)
|
||
}
|
||
|
||
func testErrorReturnsRemindersUnavailable() {
|
||
let auth = ErrorProvider()
|
||
let resp = dispatchRemindersAuth(op: "reminders.request_full_access", id: "ra7", auth: auth)
|
||
XCTAssertFalse(resp.ok)
|
||
XCTAssertEqual(resp.error?.code, "reminders_unavailable")
|
||
}
|
||
|
||
func testNoReminderDataInAuthResponses() {
|
||
let authOk = NotDeterminedGrantedProvider()
|
||
let respOk = dispatchRemindersAuth(op: "reminders.request_full_access", id: "ok", auth: authOk)
|
||
XCTAssertNil(respOk.result?.reminders)
|
||
XCTAssertNil(respOk.result?.reminder_lists)
|
||
XCTAssertNil(respOk.result?.reminder)
|
||
XCTAssertNil(respOk.result?.created_reminder)
|
||
XCTAssertNil(respOk.result?.calendars)
|
||
XCTAssertNil(respOk.result?.events)
|
||
|
||
let authDen = NotDeterminedDeniedProvider()
|
||
let respDen = dispatchRemindersAuth(op: "reminders.request_full_access", id: "den", auth: authDen)
|
||
XCTAssertNil(respDen.result)
|
||
}
|
||
|
||
// MARK: - list / create must never prompt
|
||
|
||
func testRemindersListsDoesNotCallAuthRequest() {
|
||
final class SpyLists: RemindersListsProviding, @unchecked Sendable {
|
||
var called = false
|
||
func listReminderLists() throws -> [ReminderListItem] { called = true; return [] }
|
||
}
|
||
final class SpyAuth: RemindersAuthorizationProviding, @unchecked Sendable {
|
||
var didCallStatus = false
|
||
var didCallRequest = false
|
||
func authorizationStatus() -> RemindersAuthorizationStatus { didCallStatus = true; return .authorized }
|
||
func requestFullAccess() throws -> Bool { didCallRequest = true; return false }
|
||
}
|
||
let lists = SpyLists()
|
||
let auth = SpyAuth()
|
||
let req = Request(id: "rl-1", operation: "reminders.lists", arguments: .object([:]))
|
||
let resp = dispatch(
|
||
request: req,
|
||
calendarProvider: EmptyCalList(),
|
||
eventsProvider: EmptyCalEvents(),
|
||
createProvider: EmptyCalCreate(),
|
||
authProvider: MockCalAuth(),
|
||
contactsAuthProvider: EmptyContactsAuth(),
|
||
contactsSearchProvider: EmptyContactsSearch(),
|
||
contactsReadProvider: EmptyContactsRead(),
|
||
contactsCreateProvider: EmptyContactsCreate(),
|
||
remindersAuthProvider: auth,
|
||
remindersListsProvider: lists,
|
||
remindersListProvider: EmptyList(),
|
||
remindersCreateProvider: EmptyCreate()
|
||
)
|
||
XCTAssertTrue(resp.ok)
|
||
XCTAssertFalse(auth.didCallRequest, "reminders.lists must never call requestFullAccess")
|
||
XCTAssertTrue(lists.called)
|
||
}
|
||
|
||
func testRemindersListDoesNotCallAuthRequest() {
|
||
final class SpyList: RemindersListProviding, @unchecked Sendable {
|
||
var called = false
|
||
func listReminders(listId: String?, listTitle: String?, completed: Bool?, limit: Int) throws -> [ReminderItem] { called = true; return [] }
|
||
}
|
||
final class SpyAuth: RemindersAuthorizationProviding, @unchecked Sendable {
|
||
var didCallRequest = false
|
||
func authorizationStatus() -> RemindersAuthorizationStatus { .authorized }
|
||
func requestFullAccess() throws -> Bool { didCallRequest = true; return false }
|
||
}
|
||
let rl = SpyList()
|
||
let auth = SpyAuth()
|
||
let req = Request(id: "r-1", operation: "reminders.list", arguments: .object(["limit": .number(10)]))
|
||
let resp = dispatch(
|
||
request: req,
|
||
calendarProvider: EmptyCalList(),
|
||
eventsProvider: EmptyCalEvents(),
|
||
createProvider: EmptyCalCreate(),
|
||
authProvider: MockCalAuth(),
|
||
contactsAuthProvider: EmptyContactsAuth(),
|
||
contactsSearchProvider: EmptyContactsSearch(),
|
||
contactsReadProvider: EmptyContactsRead(),
|
||
contactsCreateProvider: EmptyContactsCreate(),
|
||
remindersAuthProvider: auth,
|
||
remindersListsProvider: EmptyLists(),
|
||
remindersListProvider: rl,
|
||
remindersCreateProvider: EmptyCreate()
|
||
)
|
||
XCTAssertTrue(resp.ok)
|
||
XCTAssertFalse(auth.didCallRequest, "reminders.list must never trigger authorization request")
|
||
XCTAssertTrue(rl.called)
|
||
}
|
||
|
||
func testRemindersCreateDoesNotCallAuthRequest() {
|
||
final class SpyCreate: RemindersCreateProviding, @unchecked Sendable {
|
||
var called = false
|
||
func createReminder(title: String, listId: String?, listTitle: String?, notes: String?, due: Date?, priority: Int?) throws -> ReminderCreateResult {
|
||
called = true
|
||
return ReminderCreateResult(id: "x", list_id: "l", list_title: "t", title: title)
|
||
}
|
||
}
|
||
final class SpyAuth: RemindersAuthorizationProviding, @unchecked Sendable {
|
||
var didCallRequest = false
|
||
func authorizationStatus() -> RemindersAuthorizationStatus { .authorized }
|
||
func requestFullAccess() throws -> Bool { didCallRequest = true; return false }
|
||
}
|
||
let create = SpyCreate()
|
||
let auth = SpyAuth()
|
||
let req = Request(id: "rc-1", operation: "reminders.create", arguments: .object(["title": .string("Buy milk"), "list": .string("Groceries")]))
|
||
let resp = dispatch(
|
||
request: req,
|
||
calendarProvider: EmptyCalList(),
|
||
eventsProvider: EmptyCalEvents(),
|
||
createProvider: EmptyCalCreate(),
|
||
authProvider: MockCalAuth(),
|
||
contactsAuthProvider: EmptyContactsAuth(),
|
||
contactsSearchProvider: EmptyContactsSearch(),
|
||
contactsReadProvider: EmptyContactsRead(),
|
||
contactsCreateProvider: EmptyContactsCreate(),
|
||
remindersAuthProvider: auth,
|
||
remindersListsProvider: EmptyLists(),
|
||
remindersListProvider: EmptyList(),
|
||
remindersCreateProvider: create
|
||
)
|
||
XCTAssertTrue(resp.ok)
|
||
XCTAssertFalse(auth.didCallRequest, "reminders.create must never trigger authorization request (permission check is via status only in real provider, but here we assert no prompt)")
|
||
XCTAssertTrue(create.called)
|
||
}
|
||
|
||
// MARK: - Bridge tests – deterministic pump without real EventKit
|
||
|
||
final class TestBox<T>: @unchecked Sendable {
|
||
var value: T
|
||
init(_ v: T) { value = v }
|
||
}
|
||
|
||
func testBridgePumpsMainRunLoopAndDeliversMainQueueCallback() throws {
|
||
let exp = expectation(description: "bridge completes")
|
||
let grantedBox = TestBox(false)
|
||
let errorBox = TestBox<Error?>(nil)
|
||
|
||
DispatchQueue.main.async {
|
||
let bridge = RemindersMainRunLoopBridge()
|
||
do {
|
||
grantedBox.value = try bridge.requestAccess(timeout: 2) { completion in
|
||
Timer.scheduledTimer(withTimeInterval: 0.02, repeats: false) { _ in
|
||
completion(true, nil)
|
||
}
|
||
}
|
||
} catch {
|
||
errorBox.value = error
|
||
}
|
||
exp.fulfill()
|
||
}
|
||
|
||
wait(for: [exp], timeout: 5)
|
||
XCTAssertNil(errorBox.value, "bridge must not timeout when it pumps main run loop; got \(String(describing: errorBox.value))")
|
||
XCTAssertTrue(grantedBox.value, "granted should be true after main-queue callback is pumped")
|
||
}
|
||
|
||
func testBridgeHandlesCompletionExactlyOnce() throws {
|
||
let exp = expectation(description: "exactly once")
|
||
let resultBox = TestBox(false)
|
||
DispatchQueue.main.async {
|
||
let bridge = RemindersMainRunLoopBridge()
|
||
do {
|
||
resultBox.value = try bridge.requestAccess(timeout: 1) { completion in
|
||
completion(true, nil)
|
||
completion(false, NSError(domain: "should-be-ignored", code: 1))
|
||
}
|
||
} catch {}
|
||
exp.fulfill()
|
||
}
|
||
wait(for: [exp], timeout: 2)
|
||
XCTAssertTrue(resultBox.value, "First completion should win")
|
||
}
|
||
|
||
func testBridgeThreadSafetyForConcurrentCompletion() throws {
|
||
let exp = expectation(description: "thread-safe")
|
||
let grantedBox = TestBox(false)
|
||
let doneBox = TestBox(false)
|
||
DispatchQueue.main.async {
|
||
let bridge = RemindersMainRunLoopBridge()
|
||
do {
|
||
grantedBox.value = try bridge.requestAccess(timeout: 1) { completion in
|
||
DispatchQueue.global().async { completion(true, nil) }
|
||
DispatchQueue.global().async { completion(false, nil) }
|
||
}
|
||
doneBox.value = true
|
||
} catch {}
|
||
exp.fulfill()
|
||
}
|
||
wait(for: [exp], timeout: 2)
|
||
XCTAssertTrue(doneBox.value, "bridge must complete even with concurrent completions")
|
||
}
|
||
|
||
func testBridgePropagatesError() throws {
|
||
let exp = expectation(description: "error propagation")
|
||
let caughtBox = TestBox(false)
|
||
DispatchQueue.main.async {
|
||
let bridge = RemindersMainRunLoopBridge()
|
||
do {
|
||
_ = try bridge.requestAccess(timeout: 1) { completion in
|
||
completion(false, NSError(domain: "test", code: 2, userInfo: [NSLocalizedDescriptionKey: "fake EK error"]))
|
||
}
|
||
} catch let err as RemindersProviderError {
|
||
if case .unavailable(let msg) = err {
|
||
caughtBox.value = msg.contains("fake EK error")
|
||
}
|
||
} catch {}
|
||
exp.fulfill()
|
||
}
|
||
wait(for: [exp], timeout: 2)
|
||
XCTAssertTrue(caughtBox.value, "Error from EK completion must be wrapped as reminders_unavailable")
|
||
}
|
||
|
||
func testBridgeTimeoutReturnsCorrectError() throws {
|
||
let exp = expectation(description: "timeout")
|
||
let codeBox = TestBox("")
|
||
DispatchQueue.main.async {
|
||
let bridge = RemindersMainRunLoopBridge()
|
||
do {
|
||
_ = try bridge.requestAccess(timeout: 0.15) { _ in }
|
||
XCTFail("Should have thrown")
|
||
} catch let err as RemindersProviderError {
|
||
if case .unavailable(let msg) = err {
|
||
codeBox.value = msg
|
||
}
|
||
} catch {}
|
||
exp.fulfill()
|
||
}
|
||
wait(for: [exp], timeout: 2)
|
||
XCTAssertTrue(codeBox.value.lowercased().contains("timed out"), "Timeout must produce 'reminders authorization timed out' message, got \(codeBox.value)")
|
||
}
|
||
|
||
// MARK: - Production code location check
|
||
|
||
func testOnlyOneFileCallsRequestFullAccessToReminders() throws {
|
||
let fm = FileManager.default
|
||
var cur = URL(fileURLWithPath: fm.currentDirectoryPath)
|
||
var dirs: [URL] = []
|
||
for _ in 0..<10 {
|
||
let cand = cur.appendingPathComponent("native/ReynaCLIHost/Sources/ReynaCLIHostCore")
|
||
if fm.fileExists(atPath: cand.path) { dirs.append(cand); break }
|
||
let cand2 = cur.appendingPathComponent("Sources/ReynaCLIHostCore")
|
||
if fm.fileExists(atPath: cand2.path) { dirs.append(cand2); break }
|
||
let cand3 = cur.appendingPathComponent("native/ReynaCLIHost/Sources/ReynaCLIHost")
|
||
if fm.fileExists(atPath: cand3.path) { dirs.append(cand3) }
|
||
let cand4 = cur.appendingPathComponent("Sources/ReynaCLIHost")
|
||
if fm.fileExists(atPath: cand4.path) { dirs.append(cand4) }
|
||
if !dirs.isEmpty { break }
|
||
cur = cur.deletingLastPathComponent()
|
||
}
|
||
guard !dirs.isEmpty else {
|
||
XCTFail("Could not locate Sources/ReynaCLIHost dir")
|
||
return
|
||
}
|
||
var hits: [String] = []
|
||
for srcDir in dirs {
|
||
let files = (try? fm.contentsOfDirectory(at: srcDir, includingPropertiesForKeys: nil)) ?? []
|
||
for file in files where file.pathExtension == "swift" {
|
||
guard let content = try? String(contentsOf: file) else { continue }
|
||
if content.contains("requestFullAccessToReminders") {
|
||
hits.append(file.lastPathComponent)
|
||
}
|
||
}
|
||
}
|
||
let uniqueSorted = Array(Set(hits)).sorted()
|
||
XCTAssertEqual(uniqueSorted, ["RemindersAuthorizationProvider.swift"], "requestFullAccessToReminders must only appear in RemindersAuthorizationProvider.swift, found in \(uniqueSorted)")
|
||
}
|
||
}
|