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:
Adolfo Reyna
2026-08-03 20:27:54 -04:00
parent 6e2117188e
commit 9fd04b0ce4
56 changed files with 14239 additions and 50 deletions
@@ -0,0 +1,337 @@
import XCTest
@testable import ReynaCLIHostCore
import Foundation
final class ContactsOperationsTests: XCTestCase {
// MARK: - Fake providers
struct FakeSearch: ContactsSearchProviding {
var contacts: [ContactListItem]
var shouldThrow: ContactsProviderError? = nil
func searchContacts(query: String?, limit: Int) throws -> [ContactListItem] {
if let e = shouldThrow { throw e }
var filtered = contacts
if let q = query, !q.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
let lower = q.lowercased()
filtered = filtered.filter { ($0.name + "\n" + $0.organization).lowercased().contains(lower) }
}
filtered.sort { $0.name < $1.name }
if filtered.count > limit { filtered = Array(filtered.prefix(limit)) }
return filtered
}
}
struct FakeRead: ContactsReadProviding {
var contact: ContactDetailItem?
var shouldThrow: ContactsProviderError? = nil
func readContact(id: String) throws -> ContactDetailItem {
if let e = shouldThrow { throw e }
guard let c = contact, c.id == id else {
throw ContactsProviderError.notFound("Contact not found: \(id)")
}
return c
}
}
struct FakeCreate: ContactsCreateProviding {
var result: ContactCreateResult
var shouldThrow: ContactsProviderError? = nil
func createContact(firstName: String?, lastName: String?, organization: String?, jobTitle: String?, note: String?, email: ContactEmailLabelValue?, phone: ContactPhoneLabelValue?) throws -> ContactCreateResult {
if let e = shouldThrow { throw e }
return result
}
}
struct EmptyCalendarList: CalendarListProviding {
func listCalendars() throws -> [CalendarListItem] { [] }
}
struct EmptyEvents: CalendarEventsListProviding {
func listEvents(start: Date, end: Date, calendarId: String?, calendarTitle: String?, limit: Int) throws -> [CalendarEventItem] { [] }
}
struct EmptyCreate: CalendarEventCreateProviding {
func createEvent(title: String, start: Date, end: Date, allDay: Bool, notes: String?, location: String?, calendarId: String?, calendarTitle: String?) throws -> CalendarEventItem {
CalendarEventItem(id: "x", title: "t", start: "2026-01-01T10:00:00Z", end: "2026-01-01T11:00:00Z", all_day: false, calendar_id: "c", calendar_title: "Home", notes: nil, location: nil)
}
}
struct EmptyContactsAuth: ContactsAuthorizationProviding {
func authorizationStatus() -> ContactsAuthorizationStatus { .authorized }
func requestAccess() throws -> Bool { false }
}
func mockCalAuth() -> MockCalendarAuth { MockCalendarAuth(status: .authorized) }
struct MockCalendarAuth: CalendarAuthorizationProviding {
var status: CalendarAuthorizationStatus
func authorizationStatus() -> CalendarAuthorizationStatus { status }
func requestFullAccess() throws -> Bool { false }
}
// Helpers to build dispatch for contacts only
func dispatchContacts(request: Request, search: ContactsSearchProviding = FakeSearch(contacts: []), read: ContactsReadProviding = FakeRead(), create: ContactsCreateProviding = FakeCreate(result: ContactCreateResult(id: "id", name: "Name", organization: "")), auth: ContactsAuthorizationProviding = EmptyContactsAuth()) -> Response {
return dispatch(request: request, calendarProvider: EmptyCalendarList(), eventsProvider: EmptyEvents(), createProvider: EmptyCreate(), authProvider: mockCalAuth(), contactsAuthProvider: auth, contactsSearchProvider: search, contactsReadProvider: read, contactsCreateProvider: create)
}
func sampleContactList() -> [ContactListItem] {
[
ContactListItem(id: "3", name: "Charlie", organization: "OrgC", modifiedAt: "2026-01-01T00:00:00Z"),
ContactListItem(id: "1", name: "Alice", organization: "OrgA", modifiedAt: "2026-01-01T00:00:00Z"),
ContactListItem(id: "2", name: "Bob", organization: "OrgB", modifiedAt: "2026-01-01T00:00:00Z"),
]
}
func sampleContactDetail() -> ContactDetailItem {
ContactDetailItem(id: "1", name: "Alice Smith", firstName: "Alice", lastName: "Smith", organization: "OrgA", jobTitle: "Engineer", emails: [ContactEmailLabelValue(label: "work", value: "alice@example.com")], phones: [ContactPhoneLabelValue(label: "mobile", value: "123")], modifiedAt: "2026-01-01T00:00:00Z")
}
// MARK: - Search tests
func testSearchMissingArgsReturnsAllWithDefaultLimit() {
let req = Request(id: "s1", operation: "contacts.search", arguments: .object([:]))
let resp = dispatchContacts(request: req, search: FakeSearch(contacts: sampleContactList()))
XCTAssertTrue(resp.ok)
XCTAssertEqual(resp.result?.contacts?.count, 3)
}
func testSearchLimitBoundedLow() {
let req = Request(id: "s2", operation: "contacts.search", arguments: .object(["limit": .number(0)]))
let resp = dispatchContacts(request: req)
XCTAssertFalse(resp.ok)
XCTAssertEqual(resp.error?.code, "invalid_request")
}
func testSearchLimitBoundedHigh() {
let req = Request(id: "s3", operation: "contacts.search", arguments: .object(["limit": .number(101)]))
let resp = dispatchContacts(request: req)
XCTAssertFalse(resp.ok)
XCTAssertEqual(resp.error?.code, "invalid_request")
}
func testSearchQueryFiltersDeterministically() {
let req = Request(id: "s4", operation: "contacts.search", arguments: .object(["query": .string("ali"), "limit": .number(10)]))
let resp = dispatchContacts(request: req, search: FakeSearch(contacts: sampleContactList()))
XCTAssertTrue(resp.ok)
XCTAssertEqual(resp.result?.contacts?.count, 1)
XCTAssertEqual(resp.result?.contacts?.first?.name, "Alice")
}
func testSearchSortedByNameOrgId() {
let req = Request(id: "s5", operation: "contacts.search", arguments: .object(["limit": .number(10)]))
let resp = dispatchContacts(request: req, search: FakeSearch(contacts: sampleContactList()))
XCTAssertTrue(resp.ok)
let ids = resp.result?.contacts?.map { $0.id }
XCTAssertEqual(ids, ["1","2","3"])
}
func testSearchPermissionRequired() {
let req = Request(id: "s6", operation: "contacts.search", arguments: .object([:]))
let resp = dispatchContacts(request: req, search: FakeSearch(contacts: [], shouldThrow: .permissionRequired))
XCTAssertFalse(resp.ok)
XCTAssertEqual(resp.error?.code, "permission_required")
XCTAssertNil(resp.result)
}
func testSearchContactsNeverPrompts() throws {
// Ensure Contacts search providers never call request-access APIs – static source check
let fm = FileManager.default
var cur = URL(fileURLWithPath: fm.currentDirectoryPath)
var providerURL: URL? = nil
for _ in 0..<8 {
let cand = cur.appendingPathComponent("Sources/ReynaCLIHostCore/ContactsProvider.swift")
if fm.fileExists(atPath: cand.path) { providerURL = cand; break }
let cand2 = cur.appendingPathComponent("native/ReynaCLIHost/Sources/ReynaCLIHostCore/ContactsProvider.swift")
if fm.fileExists(atPath: cand2.path) { providerURL = cand2; break }
cur = cur.deletingLastPathComponent()
}
guard let url = providerURL, let content = try? String(contentsOf: url) else {
XCTFail("ContactsProvider.swift not found"); return
}
// Protocol declarations like `func requestAccess()` are allowed; actual prompt calls use `requestAccess(for:`
// or call on CNContactStore. We forbid `requestAccess(for:` in this file.
XCTAssertFalse(content.contains("requestAccess(for:"), "Contacts search/read/create must not call requestAccess(for:) – only auth provider should")
XCTAssertFalse(content.contains("requestFullAccess"), "Contacts provider must not call calendar request")
}
// MARK: - Read tests
func testReadMissingIdInvalidRequest() {
let req = Request(id: "r1", operation: "contacts.read", arguments: .object([:]))
let resp = dispatchContacts(request: req)
XCTAssertFalse(resp.ok)
XCTAssertEqual(resp.error?.code, "invalid_request")
}
func testReadSuccessMinimalFields() {
let detail = sampleContactDetail()
let req = Request(id: "r2", operation: "contacts.read", arguments: .object(["id": .string("1")]))
let resp = dispatchContacts(request: req, read: FakeRead(contact: detail))
XCTAssertTrue(resp.ok)
XCTAssertEqual(resp.result?.contact?.id, "1")
XCTAssertEqual(resp.result?.contact?.firstName, "Alice")
XCTAssertEqual(resp.result?.contact?.emails.first?.value, "alice@example.com")
}
func testReadPermissionRequired() {
let req = Request(id: "r3", operation: "contacts.read", arguments: .object(["id": .string("1")]))
let resp = dispatchContacts(request: req, read: FakeRead(shouldThrow: .permissionRequired))
XCTAssertFalse(resp.ok)
XCTAssertEqual(resp.error?.code, "permission_required")
}
func testReadNotFoundMapsToInvalid() {
let req = Request(id: "r4", operation: "contacts.read", arguments: .object(["id": .string("nope")]))
let resp = dispatchContacts(request: req, read: FakeRead(contact: sampleContactDetail()))
XCTAssertFalse(resp.ok)
XCTAssertEqual(resp.error?.code, "invalid_request")
}
// MARK: - Create tests
func testCreateMissingNameFieldsInvalid() {
let req = Request(id: "c1", operation: "contacts.create", arguments: .object([:]))
let resp = dispatchContacts(request: req)
XCTAssertFalse(resp.ok)
XCTAssertEqual(resp.error?.code, "invalid_request")
}
func testCreateSuccessReturnsMetadataOnly() {
let result = ContactCreateResult(id: "new-id", name: "Alice Smith", organization: "OrgA")
let req = Request(id: "c2", operation: "contacts.create", arguments: .object(["firstName": .string("Alice"), "lastName": .string("Smith")]))
let resp = dispatchContacts(request: req, create: FakeCreate(result: result))
XCTAssertTrue(resp.ok)
XCTAssertEqual(resp.result?.created_contact?.id, "new-id")
XCTAssertEqual(resp.result?.created_contact?.name, "Alice Smith")
// Ensure no excessive fields leaked
let encoded = try! JSONEncoder().encode(resp)
let obj = try! JSONSerialization.jsonObject(with: encoded) as! [String: Any]
let resultObj = obj["result"] as! [String: Any]
XCTAssertNotNil(resultObj["created_contact"])
XCTAssertNil(resultObj["contact"], "create must not output full contact detail")
XCTAssertNil(resultObj["contacts"])
}
func testCreateWithEmailPhoneObjects() {
let result = ContactCreateResult(id: "nid", name: "Bob", organization: "")
let req = Request(id: "c3", operation: "contacts.create", arguments: .object([
"firstName": .string("Bob"),
"email": .object(["label": .string("work"), "value": .string("bob@example.com")]),
"phone": .object(["label": .string("mobile"), "value": .string("+1555")])
]))
let resp = dispatchContacts(request: req, create: FakeCreate(result: result))
XCTAssertTrue(resp.ok)
XCTAssertEqual(resp.result?.created_contact?.name, "Bob")
}
func testCreatePermissionRequired() {
let req = Request(id: "c4", operation: "contacts.create", arguments: .object(["firstName": .string("Bob")]))
let resp = dispatchContacts(request: req, create: FakeCreate(result: ContactCreateResult(id: "x", name: "x", organization: ""), shouldThrow: .permissionRequired))
XCTAssertFalse(resp.ok)
XCTAssertEqual(resp.error?.code, "permission_required")
}
func testSearchDoesNotTriggerCreate() {
final class CountingCreate: ContactsCreateProviding, @unchecked Sendable {
var count = 0
func createContact(firstName: String?, lastName: String?, organization: String?, jobTitle: String?, note: String?, email: ContactEmailLabelValue?, phone: ContactPhoneLabelValue?) throws -> ContactCreateResult {
count += 1
return ContactCreateResult(id: "x", name: "x", organization: "")
}
}
let counter = CountingCreate()
let req = Request(id: "iso", operation: "contacts.search", arguments: .object(["limit": .number(5)]))
let resp = dispatch(request: req, calendarProvider: EmptyCalendarList(), eventsProvider: EmptyEvents(), createProvider: EmptyCreate(), authProvider: mockCalAuth(), contactsAuthProvider: EmptyContactsAuth(), contactsSearchProvider: FakeSearch(contacts: []), contactsReadProvider: FakeRead(), contactsCreateProvider: counter)
XCTAssertTrue(resp.ok)
XCTAssertEqual(counter.count, 0, "search must not trigger create")
}
// MARK: - Regression: contacts.search production crash (CNPropertyNotFetchedException)
func testSearchProductionDoesNotUseCNContactFormatter() throws {
let fm = FileManager.default
var cur = URL(fileURLWithPath: fm.currentDirectoryPath)
var providerURL: URL? = nil
for _ in 0..<8 {
let cand = cur.appendingPathComponent("Sources/ReynaCLIHostCore/ContactsProvider.swift")
if fm.fileExists(atPath: cand.path) { providerURL = cand; break }
let cand2 = cur.appendingPathComponent("native/ReynaCLIHost/Sources/ReynaCLIHostCore/ContactsProvider.swift")
if fm.fileExists(atPath: cand2.path) { providerURL = cand2; break }
cur = cur.deletingLastPathComponent()
}
guard let url = providerURL, let content = try? String(contentsOf: url) else {
XCTFail("ContactsProvider.swift not found"); return
}
XCTAssertFalse(content.contains("CNContactFormatter.string("), "Production ContactsProvider must not call CNContactFormatter.string - use only fetched keys to avoid ObjC exception on middleName etc.")
XCTAssertFalse(content.contains("CNContactMiddleNameKey"), "Do not add middleName to keysToFetch - fix is to avoid formatter, not fetch more")
XCTAssertFalse(content.contains("CNContactNamePrefixKey"), "Avoid extra keys to satisfy formatter")
XCTAssertFalse(content.contains("CNContactNameSuffixKey"), "Avoid extra keys to satisfy formatter")
XCTAssertFalse(content.contains("CNContactNicknameKey"), "Avoid extra keys to satisfy formatter")
}
func testSearchProductionKeysToFetchWhitelist() throws {
let fm = FileManager.default
var cur = URL(fileURLWithPath: fm.currentDirectoryPath)
var providerURL: URL? = nil
for _ in 0..<8 {
let cand = cur.appendingPathComponent("Sources/ReynaCLIHostCore/ContactsProvider.swift")
if fm.fileExists(atPath: cand.path) { providerURL = cand; break }
let cand2 = cur.appendingPathComponent("native/ReynaCLIHost/Sources/ReynaCLIHostCore/ContactsProvider.swift")
if fm.fileExists(atPath: cand2.path) { providerURL = cand2; break }
cur = cur.deletingLastPathComponent()
}
guard let url = providerURL, let content = try? String(contentsOf: url) else {
XCTFail("ContactsProvider.swift not found"); return
}
let lines = content.components(separatedBy: "\n")
guard let searchIdx = lines.firstIndex(where: { $0.contains("struct ContactsSearchProvider") }) else {
XCTFail("ContactsSearchProvider not found"); return
}
let searchSlice = lines[searchIdx..<min(searchIdx+30, lines.count)].joined(separator: "\n")
XCTAssertTrue(searchSlice.contains("CNContactIdentifierKey"), "search should fetch identifier")
XCTAssertTrue(searchSlice.contains("CNContactGivenNameKey"), "search should fetch givenName")
XCTAssertTrue(searchSlice.contains("CNContactFamilyNameKey"), "search should fetch familyName")
XCTAssertTrue(searchSlice.contains("CNContactOrganizationNameKey"), "search should fetch org for filter")
XCTAssertFalse(searchSlice.contains("CNContactEmailAddressesKey"), "search must not fetch emails")
XCTAssertFalse(searchSlice.contains("CNContactPhoneNumbersKey"), "search must not fetch phones")
XCTAssertFalse(searchSlice.contains("CNContactMiddleNameKey"), "search must not fetch middleName")
}
func testSearchNonmatchingQueryProducesEmptyResultDeterministically() {
func displayNameFromFetchedParts(givenName: String, familyName: String) -> String {
let combined = "\(givenName) \(familyName)".trimmingCharacters(in: .whitespacesAndNewlines)
return combined.components(separatedBy: .whitespaces).filter { !$0.isEmpty }.joined(separator: " ")
}
let contacts = [
ContactListItem(id: "1", name: displayNameFromFetchedParts(givenName: "Alice", familyName: "Smith"), organization: "OrgA", modifiedAt: ""),
ContactListItem(id: "2", name: displayNameFromFetchedParts(givenName: "Bob", familyName: "Jones"), organization: "OrgB", modifiedAt: ""),
]
let fake = FakeSearch(contacts: contacts)
let syntheticQuery = "zzzz_synthetic_nonmatch_9f3a7c2e"
let filtered = try! fake.searchContacts(query: syntheticQuery, limit: 20)
XCTAssertEqual(filtered.count, 0, "Synthetic nonmatching query should yield empty result, not crash")
let req = Request(id: "s-nm", operation: "contacts.search", arguments: .object(["query": .string(syntheticQuery), "limit": .number(20)]))
let resp = dispatchContacts(request: req, search: fake)
XCTAssertTrue(resp.ok, "Nonmatching search must succeed with ok:true")
XCTAssertEqual(resp.result?.contacts?.count, 0, "Nonmatching search must return empty list")
}
func testReadProductionDoesNotUseCNContactFormatter() throws {
let fm = FileManager.default
var cur = URL(fileURLWithPath: fm.currentDirectoryPath)
var providerURL: URL? = nil
for _ in 0..<8 {
let cand = cur.appendingPathComponent("Sources/ReynaCLIHostCore/ContactsProvider.swift")
if fm.fileExists(atPath: cand.path) { providerURL = cand; break }
let cand2 = cur.appendingPathComponent("native/ReynaCLIHost/Sources/ReynaCLIHostCore/ContactsProvider.swift")
if fm.fileExists(atPath: cand2.path) { providerURL = cand2; break }
cur = cur.deletingLastPathComponent()
}
guard let url = providerURL, let content = try? String(contentsOf: url) else {
XCTFail("ContactsProvider.swift not found"); return
}
XCTAssertFalse(content.contains("CNContactFormatter.string("), "Read must not use CNContactFormatter either")
}
}