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,190 @@
|
||||
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)")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user