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

338 lines
19 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 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")
}
}