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