Files
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

191 lines
10 KiB
Swift
Raw Permalink 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
final class ContactsAuthorizationTests: XCTestCase {
struct AlreadyAuthorizedProvider: ContactsAuthorizationProviding {
func authorizationStatus() -> ContactsAuthorizationStatus { .authorized }
func requestAccess() throws -> Bool {
XCTFail("must not call requestAccess when already authorized")
return false
}
}
struct NotDeterminedGranted: ContactsAuthorizationProviding {
func authorizationStatus() -> ContactsAuthorizationStatus { .notDetermined }
func requestAccess() throws -> Bool { true }
}
struct NotDeterminedDenied: ContactsAuthorizationProviding {
func authorizationStatus() -> ContactsAuthorizationStatus { .notDetermined }
func requestAccess() throws -> Bool { false }
}
struct DeniedProvider: ContactsAuthorizationProviding {
func authorizationStatus() -> ContactsAuthorizationStatus { .denied }
func requestAccess() throws -> Bool { false }
}
struct ErrorProvider: ContactsAuthorizationProviding {
func authorizationStatus() -> ContactsAuthorizationStatus { .notDetermined }
func requestAccess() throws -> Bool {
throw ContactsProviderError.unavailable("disk error")
}
}
struct EmptyContactsSearch: ContactsSearchProviding {
func searchContacts(query: String?, limit: Int) throws -> [ContactListItem] { [] }
}
struct EmptyContactsRead: ContactsReadProviding {
func readContact(id: String) throws -> ContactDetailItem {
throw ContactsProviderError.notFound("not found")
}
}
struct EmptyContactsCreate: ContactsCreateProviding {
func createContact(firstName: String?, lastName: String?, organization: String?, jobTitle: String?, note: String?, email: ContactEmailLabelValue?, phone: ContactPhoneLabelValue?) throws -> ContactCreateResult {
throw ContactsProviderError.unavailable("no create")
}
}
struct EmptyCalendarList: CalendarListProviding {
func listCalendars() throws -> [CalendarListItem] { [] }
}
func testAlreadyAuthorizedNoRequest() {
let auth = AlreadyAuthorizedProvider()
let req = Request(id: "c-a1", operation: "contacts.request_access", arguments: .object([:]))
let resp = dispatch(request: req, calendarProvider: EmptyCalendarList(), eventsProvider: FakeEventsProvider(events: []), createProvider: FakeCreateProvider(willReturn: sampleEvent()), authProvider: MockCalAuth(status: .authorized), contactsAuthProvider: auth, contactsSearchProvider: EmptyContactsSearch(), contactsReadProvider: EmptyContactsRead(), contactsCreateProvider: EmptyContactsCreate())
XCTAssertTrue(resp.ok)
XCTAssertEqual(resp.result?.status, "authorized")
XCTAssertNil(resp.result?.contacts, "auth response must not leak contacts")
XCTAssertNil(resp.result?.contact)
}
func testNotDeterminedGrantedReturnsAuthorized() {
let auth = NotDeterminedGranted()
let req = Request(id: "c-a2", operation: "contacts.request_access", arguments: .object([:]))
let resp = dispatch(request: req, calendarProvider: EmptyCalendarList(), eventsProvider: FakeEventsProvider(events: []), createProvider: FakeCreateProvider(willReturn: sampleEvent()), authProvider: MockCalAuth(status: .authorized), contactsAuthProvider: auth, contactsSearchProvider: EmptyContactsSearch(), contactsReadProvider: EmptyContactsRead(), contactsCreateProvider: EmptyContactsCreate())
XCTAssertTrue(resp.ok)
XCTAssertEqual(resp.result?.status, "authorized")
XCTAssertEqual(resp.result?.operation, "contacts.request_access")
}
func testDeniedReturnsPermissionDenied() {
let auth = NotDeterminedDenied()
let req = Request(id: "c-a3", operation: "contacts.request_access", arguments: .object([:]))
let resp = dispatch(request: req, calendarProvider: EmptyCalendarList(), eventsProvider: FakeEventsProvider(events: []), createProvider: FakeCreateProvider(willReturn: sampleEvent()), authProvider: MockCalAuth(status: .authorized), contactsAuthProvider: auth, contactsSearchProvider: EmptyContactsSearch(), contactsReadProvider: EmptyContactsRead(), contactsCreateProvider: EmptyContactsCreate())
XCTAssertFalse(resp.ok)
XCTAssertEqual(resp.error?.code, "permission_denied")
}
func testErrorReturnsContactsUnavailable() {
let auth = ErrorProvider()
let req = Request(id: "c-a4", operation: "contacts.request_access", arguments: .object([:]))
let resp = dispatch(request: req, calendarProvider: EmptyCalendarList(), eventsProvider: FakeEventsProvider(events: []), createProvider: FakeCreateProvider(willReturn: sampleEvent()), authProvider: MockCalAuth(status: .authorized), contactsAuthProvider: auth, contactsSearchProvider: EmptyContactsSearch(), contactsReadProvider: EmptyContactsRead(), contactsCreateProvider: EmptyContactsCreate())
XCTAssertFalse(resp.ok)
XCTAssertEqual(resp.error?.code, "contacts_unavailable")
}
func testContactsSearchDoesNotCallAuthRequest() {
final class SpySearch: ContactsSearchProviding, @unchecked Sendable {
var called = false
func searchContacts(query: String?, limit: Int) throws -> [ContactListItem] {
called = true
return []
}
}
final class SpyAuth: ContactsAuthorizationProviding, @unchecked Sendable {
var didRequest = false
func authorizationStatus() -> ContactsAuthorizationStatus { .authorized }
func requestAccess() throws -> Bool {
didRequest = true
return false
}
}
let search = SpySearch()
let cAuth = SpyAuth()
let req = Request(id: "cs-1", operation: "contacts.search", arguments: .object(["query": .string("john"), "limit": .number(10)]))
let resp = dispatch(request: req, calendarProvider: EmptyCalendarList(), eventsProvider: FakeEventsProvider(events: []), createProvider: FakeCreateProvider(willReturn: sampleEvent()), authProvider: MockCalAuth(status: .authorized), contactsAuthProvider: cAuth, contactsSearchProvider: search, contactsReadProvider: EmptyContactsRead(), contactsCreateProvider: EmptyContactsCreate())
XCTAssertTrue(resp.ok)
XCTAssertFalse(cAuth.didRequest, "contacts.search must never trigger authorization request")
XCTAssertTrue(search.called)
}
// Bridging tests reuse same bridge pattern – verify contacts bridge pumps run loop
func testContactsBridgePumpsMainRunLoop() {
let exp = expectation(description: "contacts bridge")
final class Box: @unchecked Sendable { var granted = false; var error: Error? = nil }
let box = Box()
DispatchQueue.main.async {
let bridge = ContactsMainRunLoopBridge()
do {
box.granted = try bridge.requestAccess(timeout: 2) { completion in
Timer.scheduledTimer(withTimeInterval: 0.02, repeats: false) { _ in
completion(true, nil)
}
}
} catch {
box.error = error
}
exp.fulfill()
}
wait(for: [exp], timeout: 5)
XCTAssertNil(box.error)
XCTAssertTrue(box.granted)
}
// MARK: - Helpers shared
struct FakeEventsProvider: CalendarEventsListProviding {
var events: [CalendarEventItem]
func listEvents(start: Date, end: Date, calendarId: String?, calendarTitle: String?, limit: Int) throws -> [CalendarEventItem] { events }
}
struct FakeCreateProvider: CalendarEventCreateProviding {
var willReturn: CalendarEventItem
func createEvent(title: String, start: Date, end: Date, allDay: Bool, notes: String?, location: String?, calendarId: String?, calendarTitle: String?) throws -> CalendarEventItem { willReturn }
}
func sampleEvent() -> CalendarEventItem {
CalendarEventItem(id: "sample", title: "Sample", start: "2026-01-01T10:00:00Z", end: "2026-01-01T11:00:00Z", all_day: false, calendar_id: "cal1", calendar_title: "Home", notes: nil, location: nil)
}
struct MockCalAuth: CalendarAuthorizationProviding {
var status: CalendarAuthorizationStatus
func authorizationStatus() -> CalendarAuthorizationStatus { status }
func requestFullAccess() throws -> Bool { false }
}
// Isolation: only ContactsAuthorizationProvider.swift should call requestAccess(for: .contacts)
func testOnlyContactsAuthorizationProviderCallsRequestAccessForContacts() throws {
let fm = FileManager.default
var cur = URL(fileURLWithPath: fm.currentDirectoryPath)
var dirs: [URL] = []
for _ in 0..<10 {
let candCore = cur.appendingPathComponent("Sources/ReynaCLIHostCore")
if fm.fileExists(atPath: candCore.path) { dirs.append(candCore); break }
let cand = cur.appendingPathComponent("native/ReynaCLIHost/Sources/ReynaCLIHostCore")
if fm.fileExists(atPath: cand.path) { dirs.append(cand); break }
cur = cur.deletingLastPathComponent()
}
guard let srcDir = dirs.first else {
XCTFail("Could not find ReynaCLIHostCore sources"); return
}
let files = (try? fm.contentsOfDirectory(at: srcDir, includingPropertiesForKeys: nil)) ?? []
var hits: [String] = []
for file in files where file.pathExtension == "swift" {
guard let content = try? String(contentsOf: file) else { continue }
if content.contains("requestAccess(for:") && file.lastPathComponent != "ContactsAuthorizationProvider.swift" {
// Calendar provider calls requestFullAccessToEvents – not contacts
if content.contains(".contacts") || content.contains("CNContact") {
hits.append(file.lastPathComponent)
}
}
}
XCTAssertTrue(hits.isEmpty, "Only ContactsAuthorizationProvider.swift should call requestAccess(for: .contacts), found extras: \(hits)")
}
}