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

349 lines
16 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
// Tests for calendar.request_full_access – TDD, fake providers only
final class CalendarAuthorizationTests: XCTestCase {
// Fake auth providers
struct AlreadyAuthorizedProvider: CalendarAuthorizationProviding {
var requested = false
func authorizationStatus() -> CalendarAuthorizationStatus { .authorized }
func requestFullAccess() throws -> Bool {
XCTFail("requestFullAccess must not be called when already authorized")
return false
}
}
struct NotDeterminedGrantedProvider: CalendarAuthorizationProviding {
var statusCall = 0
func authorizationStatus() -> CalendarAuthorizationStatus { .notDetermined }
func requestFullAccess() throws -> Bool { true }
}
struct NotDeterminedDeniedProvider: CalendarAuthorizationProviding {
func authorizationStatus() -> CalendarAuthorizationStatus { .notDetermined }
func requestFullAccess() throws -> Bool { false }
}
struct DeniedProvider: CalendarAuthorizationProviding {
func authorizationStatus() -> CalendarAuthorizationStatus { .denied }
func requestFullAccess() throws -> Bool { false }
}
struct TimeoutProvider: CalendarAuthorizationProviding {
func authorizationStatus() -> CalendarAuthorizationStatus { .notDetermined }
func requestFullAccess() throws -> Bool {
throw CalendarProviderError.unavailable("calendar authorization timed out")
}
}
struct ErrorProvider: CalendarAuthorizationProviding {
func authorizationStatus() -> CalendarAuthorizationStatus { .notDetermined }
func requestFullAccess() throws -> Bool {
throw CalendarProviderError.unavailable("disk error")
}
}
struct EmptyListProvider: CalendarListProviding {
func listCalendars() throws -> [CalendarListItem] { [] }
}
// already-full permission returns state authorized without asking
func testAlreadyAuthorizedReturnsAuthorizedWithoutRequesting() {
let auth = AlreadyAuthorizedProvider()
let req = Request(id: "a1", operation: "calendar.request_full_access", arguments: .object([:]))
let resp = dispatch(request: req, calendarProvider: EmptyListProvider(), authProvider: auth)
XCTAssertTrue(resp.ok)
XCTAssertEqual(resp.result?.status, "authorized")
XCTAssertEqual(resp.result?.operation, "calendar.request_full_access")
XCTAssertEqual(resp.id, "a1")
}
// notDetermined reaches request path
func testNotDeterminedReachesRequestPath() {
final class TrackingProvider: CalendarAuthorizationProviding, @unchecked Sendable {
var didRequest = false
var status: CalendarAuthorizationStatus = .notDetermined
func authorizationStatus() -> CalendarAuthorizationStatus { status }
func requestFullAccess() throws -> Bool {
didRequest = true
return true
}
}
let tracking = TrackingProvider()
let req = Request(id: "a2", operation: "calendar.request_full_access", arguments: .object([:]))
let resp = dispatch(request: req, calendarProvider: EmptyListProvider(), authProvider: tracking)
XCTAssertTrue(tracking.didRequest, "requestFullAccess must be called when notDetermined")
XCTAssertTrue(resp.ok)
}
// granted response returns {status:"authorized"}
func testGrantedReturnsAuthorizedResult() {
let auth = NotDeterminedGrantedProvider()
let req = Request(id: "a3", operation: "calendar.request_full_access", arguments: .object([:]))
let resp = dispatch(request: req, calendarProvider: EmptyListProvider(), authProvider: auth)
XCTAssertTrue(resp.ok)
XCTAssertEqual(resp.result?.status, "authorized")
XCTAssertEqual(resp.result?.protocol_version, PROTOCOL_VERSION)
XCTAssertNil(resp.error)
XCTAssertNil(resp.result?.calendars, "must not output calendar content")
}
// denied returns structured permission_denied
func testDeniedReturnsPermissionDenied() {
let auth = NotDeterminedDeniedProvider()
let req = Request(id: "a4", operation: "calendar.request_full_access", arguments: .object([:]))
let resp = dispatch(request: req, calendarProvider: EmptyListProvider(), authProvider: auth)
XCTAssertFalse(resp.ok)
XCTAssertEqual(resp.error?.code, "permission_denied")
XCTAssertNotNil(resp.error?.message)
XCTAssertNil(resp.result)
}
// denied when already denied also permission_denied
func testAlreadyDeniedPathAlsoDenies() {
let auth = DeniedProvider()
let req = Request(id: "a5", operation: "calendar.request_full_access", arguments: .object([:]))
let resp = dispatch(request: req, calendarProvider: EmptyListProvider(), authProvider: auth)
XCTAssertFalse(resp.ok)
XCTAssertEqual(resp.error?.code, "permission_denied")
}
// async timeout/error returns calendar_unavailable
func testTimeoutReturnsCalendarUnavailable() {
let auth = TimeoutProvider()
let req = Request(id: "a6", operation: "calendar.request_full_access", arguments: .object([:]))
let resp = dispatch(request: req, calendarProvider: EmptyListProvider(), authProvider: auth)
XCTAssertFalse(resp.ok)
XCTAssertEqual(resp.error?.code, "calendar_unavailable")
XCTAssertTrue(resp.error?.message.lowercased().contains("timed out") ?? false)
}
func testErrorReturnsCalendarUnavailable() {
let auth = ErrorProvider()
let req = Request(id: "a7", operation: "calendar.request_full_access", arguments: .object([:]))
let resp = dispatch(request: req, calendarProvider: EmptyListProvider(), authProvider: auth)
XCTAssertFalse(resp.ok)
XCTAssertEqual(resp.error?.code, "calendar_unavailable")
}
// event/calendar list cannot call request method (verify list path never requests)
func testCalendarListDoesNotCallAuthRequest() {
final class SpyListProvider: CalendarListProviding, @unchecked Sendable {
var called = false
func listCalendars() throws -> [CalendarListItem] {
called = true
return []
}
}
final class SpyAuthProvider: CalendarAuthorizationProviding, @unchecked Sendable {
var didCallStatus = false
var didCallRequest = false
func authorizationStatus() -> CalendarAuthorizationStatus {
didCallStatus = true
return .authorized
}
func requestFullAccess() throws -> Bool {
didCallRequest = true
return false
}
}
let list = SpyListProvider()
let auth = SpyAuthProvider()
let req = Request(id: "list-1", operation: "calendar.list", arguments: .object([:]))
let resp = dispatch(request: req, calendarProvider: list, authProvider: auth)
XCTAssertTrue(resp.ok)
XCTAssertFalse(auth.didCallRequest, "calendar.list must never call requestFullAccess")
XCTAssertFalse(auth.didCallStatus, "calendar.list must not touch auth provider")
XCTAssertTrue(list.called)
}
// MARK: - Shared holder to satisfy Swift 6 Sendable checks
final class TestBox<T>: @unchecked Sendable {
var value: T
init(_ v: T) { value = v }
}
// MARK: - EventKitMainRunLoopBridge – deterministic pump tests (no real EventKit/TCC)
func testBridgePumpsMainRunLoopAndDeliversMainQueueCallback() throws {
let exp = expectation(description: "bridge completes")
let grantedBox = TestBox(false)
let errorBox = TestBox<Error?>(nil)
DispatchQueue.main.async {
let bridge = EventKitMainRunLoopBridge()
do {
// Deterministic seam: schedule callback onto next main run loop turn via Timer,
// simulating EventKit delivering completion on main run loop.
grantedBox.value = try bridge.requestAccess(timeout: 2) { completion in
// Timer on main run loop – only fires when run loop is pumped
Timer.scheduledTimer(withTimeInterval: 0.02, repeats: false) { _ in
completion(true, nil)
}
}
} catch {
errorBox.value = error
}
exp.fulfill()
}
wait(for: [exp], timeout: 5)
XCTAssertNil(errorBox.value, "bridge must not timeout when it pumps main run loop; got \(String(describing: errorBox.value))")
XCTAssertTrue(grantedBox.value, "granted should be true after main-queue callback is pumped")
}
func testSemaphoreDeadlocksButBridgeDoesNot() throws {
let semExp = expectation(description: "old impl would timeout")
let completedBox = TestBox(false)
DispatchQueue.main.async {
let sem = DispatchSemaphore(value: 0)
Timer.scheduledTimer(withTimeInterval: 0.02, repeats: false) { _ in
completedBox.value = true
sem.signal()
}
let res = sem.wait(timeout: .now() + 0.2)
XCTAssertEqual(res, .timedOut, "Blocking semaphore on main thread must deadlock main-run-loop callback – proving old bug")
XCTAssertFalse(completedBox.value, "Callback must not have run while semaphore blocked main loop")
semExp.fulfill()
}
wait(for: [semExp], timeout: 2)
}
func testBridgeHandlesCompletionExactlyOnce() throws {
let exp = expectation(description: "exactly once")
let resultBox = TestBox(false)
DispatchQueue.main.async {
let bridge = EventKitMainRunLoopBridge()
do {
resultBox.value = try bridge.requestAccess(timeout: 1) { completion in
completion(true, nil)
completion(false, NSError(domain: "should-be-ignored", code: 1))
}
} catch {}
exp.fulfill()
}
wait(for: [exp], timeout: 2)
XCTAssertTrue(resultBox.value, "First completion should win")
}
func testBridgeThreadSafetyForConcurrentCompletion() throws {
let exp = expectation(description: "thread-safe")
let grantedBox = TestBox(false)
let doneBox = TestBox(false)
DispatchQueue.main.async {
let bridge = EventKitMainRunLoopBridge()
do {
grantedBox.value = try bridge.requestAccess(timeout: 1) { completion in
DispatchQueue.global().async { completion(true, nil) }
DispatchQueue.global().async { completion(false, nil) }
}
doneBox.value = true
} catch {}
exp.fulfill()
}
wait(for: [exp], timeout: 2)
XCTAssertTrue(doneBox.value, "bridge must complete even with concurrent completions")
}
func testBridgePropagatesError() throws {
let exp = expectation(description: "error propagation")
let caughtBox = TestBox(false)
DispatchQueue.main.async {
let bridge = EventKitMainRunLoopBridge()
do {
_ = try bridge.requestAccess(timeout: 1) { completion in
completion(false, NSError(domain: "test", code: 2, userInfo: [NSLocalizedDescriptionKey: "fake EK error"]))
}
} catch let err as CalendarProviderError {
if case .unavailable(let msg) = err {
caughtBox.value = msg.contains("fake EK error")
}
} catch {}
exp.fulfill()
}
wait(for: [exp], timeout: 2)
XCTAssertTrue(caughtBox.value, "Error from EK completion must be wrapped as calendar_unavailable")
}
func testBridgeTimeoutReturnsCorrectError() throws {
let exp = expectation(description: "timeout")
let codeBox = TestBox("")
DispatchQueue.main.async {
let bridge = EventKitMainRunLoopBridge()
do {
_ = try bridge.requestAccess(timeout: 0.15) { _ in }
XCTFail("Should have thrown")
} catch let err as CalendarProviderError {
if case .unavailable(let msg) = err {
codeBox.value = msg
}
} catch {}
exp.fulfill()
}
wait(for: [exp], timeout: 2)
XCTAssertTrue(codeBox.value.lowercased().contains("timed out"), "Timeout must produce 'calendar authorization timed out' message, got \(codeBox.value)")
}
// Production code location check: only CalendarAuthorizationProvider.swift calls requestFullAccessToEvents
func testOnlyOneFileCallsRequestFullAccessToEvents() throws {
let fm = FileManager.default
// Walk up to find repo root containing native/ReynaCLIHost/Sources
var cur = URL(fileURLWithPath: fm.currentDirectoryPath)
var dirs: [URL] = []
for _ in 0..<10 {
let cand = cur.appendingPathComponent("native/ReynaCLIHost/Sources/ReynaCLIHost")
if fm.fileExists(atPath: cand.path) {
dirs.append(cand)
}
let candCore = cur.appendingPathComponent("native/ReynaCLIHost/Sources/ReynaCLIHostCore")
if fm.fileExists(atPath: candCore.path) {
dirs.append(candCore)
}
let cand2 = cur.appendingPathComponent("Sources/ReynaCLIHost")
if fm.fileExists(atPath: cand2.path) {
dirs.append(cand2)
}
let candCore2 = cur.appendingPathComponent("Sources/ReynaCLIHostCore")
if fm.fileExists(atPath: candCore2.path) {
dirs.append(candCore2)
}
if !dirs.isEmpty { break }
cur = cur.deletingLastPathComponent()
}
guard !dirs.isEmpty else {
XCTFail("Could not locate Sources/ReynaCLIHost dir")
return
}
var hits: [String] = []
for srcDir in dirs {
let files = (try? fm.contentsOfDirectory(at: srcDir, includingPropertiesForKeys: nil)) ?? []
for file in files where file.pathExtension == "swift" {
guard let content = try? String(contentsOf: file) else { continue }
if content.contains("requestFullAccessToEvents") {
hits.append(file.lastPathComponent)
}
}
}
// Dedupe + sort for stable assertion
let uniqueSorted = Array(Set(hits)).sorted()
XCTAssertEqual(uniqueSorted, ["CalendarAuthorizationProvider.swift"], "requestFullAccessToEvents must only appear in CalendarAuthorizationProvider.swift, found in \(uniqueSorted)")
}
func testNoOutputCalendarContentOnAuthOperations() {
// Both authorized and denied paths must not include calendars
let authOk = NotDeterminedGrantedProvider()
let reqOk = Request(id: "ok", operation: "calendar.request_full_access", arguments: .object([:]))
let respOk = dispatch(request: reqOk, calendarProvider: EmptyListProvider(), authProvider: authOk)
XCTAssertNil(respOk.result?.calendars)
let authDen = NotDeterminedDeniedProvider()
let reqDen = Request(id: "den", operation: "calendar.request_full_access", arguments: .object([:]))
let respDen = dispatch(request: reqDen, calendarProvider: EmptyListProvider(), authProvider: authDen)
// denied has nil result, so no calendars by construction
XCTAssertNil(respDen.result)
}
}