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.
349 lines
16 KiB
Swift
349 lines
16 KiB
Swift
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)
|
||
}
|
||
}
|