Files
reyna-cli/native/ReynaCLIHost/Tests/ReynaCLIHostTests/RemindersAuthorizationTests.swift
T
Adolfo Reyna 9fd04b0ce4 feat(privacy-host): add signed native calendar contacts and reminders host
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.
2026-08-03 20:27:54 -04:00

444 lines
20 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)")
}
}