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.
This commit is contained in:
@@ -0,0 +1,443 @@
|
||||
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)")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user