9fd04b0ce4
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.
191 lines
10 KiB
Swift
191 lines
10 KiB
Swift
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)")
|
||
}
|
||
}
|