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:
Adolfo Reyna
2026-08-03 20:27:54 -04:00
parent 6e2117188e
commit 9fd04b0ce4
56 changed files with 14239 additions and 50 deletions
@@ -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)
}
}
@@ -0,0 +1,419 @@
import XCTest
@testable import ReynaCLIHostCore
import Foundation
// TDD for calendar.events.list and calendar.event.create – fake providers only, no live EventKit
final class CalendarEventsTests: XCTestCase {
// MARK: - Helper types
struct FakeCalendarListForSelection: CalendarListProviding {
var calendars: [CalendarListItem]
func listCalendars() throws -> [CalendarListItem] { calendars }
}
struct FakeEventsProvider: CalendarEventsListProviding {
var events: [CalendarEventItem]
var shouldThrow: CalendarProviderError? = nil
func listEvents(start: Date, end: Date, calendarId: String?, calendarTitle: String?, limit: Int) throws -> [CalendarEventItem] {
if let err = shouldThrow { throw err }
var filtered = events
if let cid = calendarId {
filtered = filtered.filter { $0.calendar_id == cid }
} else if let ctitle = calendarTitle {
filtered = filtered.filter { $0.calendar_title == ctitle }
}
return filtered
}
}
struct SelectingEventsProvider: CalendarEventsListProviding {
var availableCalendars: [CalendarListItem]
var events: [CalendarEventItem]
func listEvents(start: Date, end: Date, calendarId: String?, calendarTitle: String?, limit: Int) throws -> [CalendarEventItem] {
if let cid = calendarId {
guard availableCalendars.contains(where: { $0.id == cid }) else {
throw CalendarProviderError.invalidRequest("Calendar not found: \(cid)")
}
return events.filter { $0.calendar_id == cid }.prefix(limit).map { $0 }
}
if let title = calendarTitle {
let matched = availableCalendars.filter { $0.title == title }
if matched.isEmpty {
throw CalendarProviderError.invalidRequest("Calendar not found: \(title)")
}
if matched.count > 1 {
throw CalendarProviderError.invalidRequest("Ambiguous calendar title: \(title) matches \(matched.count) calendars")
}
return events.filter { $0.calendar_title == title }.prefix(limit).map { $0 }
}
let sorted = events.sorted { $0.start < $1.start }
return Array(sorted.prefix(limit))
}
}
struct FakeCreateProvider: CalendarEventCreateProviding {
var shouldThrow: CalendarProviderError? = nil
var willReturn: CalendarEventItem
func createEvent(title: String, start: Date, end: Date, allDay: Bool, notes: String?, location: String?, calendarId: String?, calendarTitle: String?) throws -> CalendarEventItem {
if let err = shouldThrow { throw err }
return willReturn
}
}
struct SelectingCreateProvider: CalendarEventCreateProviding {
var availableCalendars: [CalendarListItem]
var writableIds: Set<String>
var willReturn: CalendarEventItem
func createEvent(title: String, start: Date, end: Date, allDay: Bool, notes: String?, location: String?, calendarId: String?, calendarTitle: String?) throws -> CalendarEventItem {
if let cid = calendarId {
guard let cal = availableCalendars.first(where: { $0.id == cid }) else {
throw CalendarProviderError.invalidRequest("Calendar not found: \(cid)")
}
guard writableIds.contains(cal.id) else {
throw CalendarProviderError.invalidRequest("Calendar is read-only: \(cal.title)")
}
return willReturn
}
if let t = calendarTitle {
let matched = availableCalendars.filter { $0.title == t }
if matched.isEmpty { throw CalendarProviderError.invalidRequest("Calendar not found: \(t)") }
if matched.count > 1 { throw CalendarProviderError.invalidRequest("Ambiguous calendar title: \(t) matches \(matched.count) calendars") }
guard writableIds.contains(matched[0].id) else {
throw CalendarProviderError.invalidRequest("Calendar is read-only: \(matched[0].title)")
}
return willReturn
}
throw CalendarProviderError.invalidRequest("Calendar must be specified by id or exact unique title")
}
}
struct DeniedEventsProvider: CalendarEventsListProviding {
func listEvents(start: Date, end: Date, calendarId: String?, calendarTitle: String?, limit: Int) throws -> [CalendarEventItem] {
throw CalendarProviderError.permissionRequired
}
}
struct FailEventsProvider: CalendarEventsListProviding {
func listEvents(start: Date, end: Date, calendarId: String?, calendarTitle: String?, limit: Int) throws -> [CalendarEventItem] {
throw CalendarProviderError.unavailable("disk fail")
}
}
struct DeniedCreateProvider: CalendarEventCreateProviding {
func createEvent(title: String, start: Date, end: Date, allDay: Bool, notes: String?, location: String?, calendarId: String?, calendarTitle: String?) throws -> CalendarEventItem {
throw CalendarProviderError.permissionRequired
}
}
struct MockAuthProvider: CalendarAuthorizationProviding {
var status: CalendarAuthorizationStatus
func authorizationStatus() -> CalendarAuthorizationStatus { status }
func requestFullAccess() throws -> Bool { false }
}
final class CountingCreate: CalendarEventCreateProviding, @unchecked Sendable {
var count = 0
func createEvent(title: String, start: Date, end: Date, allDay: Bool, notes: String?, location: String?, calendarId: String?, calendarTitle: String?) throws -> CalendarEventItem {
count += 1
return 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)
}
}
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)
}
// MARK: - List tests
func testEventsListMissingStartReturnsInvalidRequest() {
let req = Request(id: "e1", operation: "calendar.events.list", arguments: .object(["end": .string("2026-01-02T00:00:00Z")]))
let resp = dispatch(request: req, calendarProvider: FakeCalendarListForSelection(calendars: []), eventsProvider: FakeEventsProvider(events: []), createProvider: FakeCreateProvider(willReturn: sampleEvent()), authProvider: MockAuthProvider(status: .authorized))
XCTAssertFalse(resp.ok)
XCTAssertEqual(resp.error?.code, "invalid_request")
}
func testEventsListInvalidISODate() {
let args: JSONValue = .object(["start": .string("not-a-date"), "end": .string("2026-01-02T00:00:00Z")])
let req = Request(id: "e2", operation: "calendar.events.list", arguments: args)
let resp = dispatch(request: req, calendarProvider: FakeCalendarListForSelection(calendars: []), eventsProvider: FakeEventsProvider(events: []), createProvider: FakeCreateProvider(willReturn: sampleEvent()), authProvider: MockAuthProvider(status: .authorized))
XCTAssertFalse(resp.ok)
XCTAssertEqual(resp.error?.code, "invalid_request")
}
func testEventsListStartAfterEnd() {
let args: JSONValue = .object(["start": .string("2026-01-03T00:00:00Z"), "end": .string("2026-01-02T00:00:00Z")])
let req = Request(id: "e3", operation: "calendar.events.list", arguments: args)
let resp = dispatch(request: req, calendarProvider: FakeCalendarListForSelection(calendars: []), eventsProvider: FakeEventsProvider(events: []), createProvider: FakeCreateProvider(willReturn: sampleEvent()), authProvider: MockAuthProvider(status: .authorized))
XCTAssertFalse(resp.ok)
XCTAssertEqual(resp.error?.code, "invalid_request")
}
func testEventsListLimitBounded() {
let argsLow: JSONValue = .object(["start": .string("2026-01-01T00:00:00Z"), "end": .string("2026-01-02T00:00:00Z"), "limit": .number(0)])
let reqLow = Request(id: "e4", operation: "calendar.events.list", arguments: argsLow)
let respLow = dispatch(request: reqLow, calendarProvider: FakeCalendarListForSelection(calendars: []), eventsProvider: FakeEventsProvider(events: []), createProvider: FakeCreateProvider(willReturn: sampleEvent()), authProvider: MockAuthProvider(status: .authorized))
XCTAssertFalse(respLow.ok)
XCTAssertEqual(respLow.error?.code, "invalid_request")
let argsHigh: JSONValue = .object(["start": .string("2026-01-01T00:00:00Z"), "end": .string("2026-01-02T00:00:00Z"), "limit": .number(251)])
let reqHigh = Request(id: "e5", operation: "calendar.events.list", arguments: argsHigh)
let respHigh = dispatch(request: reqHigh, calendarProvider: FakeCalendarListForSelection(calendars: []), eventsProvider: FakeEventsProvider(events: []), createProvider: FakeCreateProvider(willReturn: sampleEvent()), authProvider: MockAuthProvider(status: .authorized))
XCTAssertFalse(respHigh.ok)
XCTAssertEqual(respHigh.error?.code, "invalid_request")
}
func testEventsListSuccessSortedAndMinimalFields() {
let ev1 = CalendarEventItem(id: "2", title: "B", start: "2026-01-01T11:00:00Z", end: "2026-01-01T12:00:00Z", all_day: false, calendar_id: "cal1", calendar_title: "Home", notes: "n2", location: "loc2")
let ev2 = CalendarEventItem(id: "1", title: "A", 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)
let provider = FakeEventsProvider(events: [ev1, ev2])
let args: JSONValue = .object(["start": .string("2026-01-01T00:00:00Z"), "end": .string("2026-01-02T00:00:00Z"), "limit": .number(10)])
let req = Request(id: "e6", operation: "calendar.events.list", arguments: args)
let resp = dispatch(request: req, calendarProvider: FakeCalendarListForSelection(calendars: []), eventsProvider: provider, createProvider: FakeCreateProvider(willReturn: sampleEvent()), authProvider: MockAuthProvider(status: .authorized))
XCTAssertTrue(resp.ok)
XCTAssertEqual(resp.result?.events?.count, 2)
XCTAssertEqual(resp.result?.events?.first?.id, "1")
let encoded = try! JSONEncoder().encode(resp)
let obj = try! JSONSerialization.jsonObject(with: encoded) as! [String: Any]
let result = obj["result"] as! [String: Any]
let events = result["events"] as! [[String: Any]]
for ev in events {
XCTAssertNotNil(ev["id"])
XCTAssertNotNil(ev["title"])
XCTAssertNotNil(ev["start"])
XCTAssertNotNil(ev["end"])
XCTAssertNotNil(ev["calendar_id"])
XCTAssertNotNil(ev["calendar_title"])
let allowed = Set(["id","title","start","end","all_day","calendar_id","calendar_title","notes","location"])
XCTAssertTrue(Set(ev.keys).isSubset(of: allowed), "Unexpected keys: \(ev.keys)")
}
}
func testEventsListCalendarIdWinsOverTitle() {
let calendars = [
CalendarListItem(id: "id1", title: "Home", source: "iCloud", type: "caldav"),
CalendarListItem(id: "id2", title: "Home", source: "Local", type: "local")
]
let ev1 = CalendarEventItem(id: "e1", title: "T", start: "2026-01-01T10:00:00Z", end: "2026-01-01T11:00:00Z", all_day: false, calendar_id: "id1", calendar_title: "Home", notes: nil, location: nil)
let ev2 = CalendarEventItem(id: "e2", title: "T", start: "2026-01-01T11:00:00Z", end: "2026-01-01T12:00:00Z", all_day: false, calendar_id: "id2", calendar_title: "Home", notes: nil, location: nil)
let provider = SelectingEventsProvider(availableCalendars: calendars, events: [ev1, ev2])
let args: JSONValue = .object([
"start": .string("2026-01-01T00:00:00Z"),
"end": .string("2026-01-02T00:00:00Z"),
"calendar_id": .string("id1"),
"calendar": .string("Home"),
"limit": .number(10)
])
let req = Request(id: "e7", operation: "calendar.events.list", arguments: args)
let resp = dispatch(request: req, calendarProvider: FakeCalendarListForSelection(calendars: calendars), eventsProvider: provider, createProvider: FakeCreateProvider(willReturn: sampleEvent()), authProvider: MockAuthProvider(status: .authorized))
XCTAssertTrue(resp.ok)
XCTAssertEqual(resp.result?.events?.count, 1)
XCTAssertEqual(resp.result?.events?.first?.calendar_id, "id1")
}
func testEventsListUnknownCalendarFailsDeterministic() {
let calendars = [CalendarListItem(id: "id1", title: "Home", source: "iCloud", type: "caldav")]
let provider = SelectingEventsProvider(availableCalendars: calendars, events: [])
let args: JSONValue = .object([
"start": .string("2026-01-01T00:00:00Z"),
"end": .string("2026-01-02T00:00:00Z"),
"calendar": .string("Work")
])
let req = Request(id: "e8", operation: "calendar.events.list", arguments: args)
let resp = dispatch(request: req, calendarProvider: FakeCalendarListForSelection(calendars: calendars), eventsProvider: provider, createProvider: FakeCreateProvider(willReturn: sampleEvent()), authProvider: MockAuthProvider(status: .authorized))
XCTAssertFalse(resp.ok)
XCTAssertEqual(resp.error?.code, "invalid_request")
}
func testEventsListAmbiguousTitleFails() {
let calendars = [
CalendarListItem(id: "id1", title: "Home", source: "iCloud", type: "caldav"),
CalendarListItem(id: "id2", title: "Home", source: "Local", type: "local")
]
let provider = SelectingEventsProvider(availableCalendars: calendars, events: [])
let args: JSONValue = .object([
"start": .string("2026-01-01T00:00:00Z"),
"end": .string("2026-01-02T00:00:00Z"),
"calendar": .string("Home")
])
let req = Request(id: "e9", operation: "calendar.events.list", arguments: args)
let resp = dispatch(request: req, calendarProvider: FakeCalendarListForSelection(calendars: calendars), eventsProvider: provider, createProvider: FakeCreateProvider(willReturn: sampleEvent()), authProvider: MockAuthProvider(status: .authorized))
XCTAssertFalse(resp.ok)
XCTAssertEqual(resp.error?.code, "invalid_request")
XCTAssertTrue(resp.error?.message.contains("Ambiguous") ?? false)
}
func testEventsListPermissionRequired() {
let args: JSONValue = .object([
"start": .string("2026-01-01T00:00:00Z"),
"end": .string("2026-01-02T00:00:00Z")
])
let req = Request(id: "e10", operation: "calendar.events.list", arguments: args)
let resp = dispatch(request: req, calendarProvider: FakeCalendarListForSelection(calendars: []), eventsProvider: DeniedEventsProvider(), createProvider: FakeCreateProvider(willReturn: sampleEvent()), authProvider: MockAuthProvider(status: .authorized))
XCTAssertFalse(resp.ok)
XCTAssertEqual(resp.error?.code, "permission_required")
}
func testEventsListProviderFailureMapsToUnavailable() {
let args: JSONValue = .object([
"start": .string("2026-01-01T00:00:00Z"),
"end": .string("2026-01-02T00:00:00Z")
])
let req = Request(id: "e11", operation: "calendar.events.list", arguments: args)
let resp = dispatch(request: req, calendarProvider: FakeCalendarListForSelection(calendars: []), eventsProvider: FailEventsProvider(), createProvider: FakeCreateProvider(willReturn: sampleEvent()), authProvider: MockAuthProvider(status: .authorized))
XCTAssertFalse(resp.ok)
XCTAssertEqual(resp.error?.code, "calendar_unavailable")
}
// MARK: - Create tests
func testCreateMissingTitleInvalid() {
let args: JSONValue = .object([
"start": .string("2026-01-01T10:00:00Z"),
"end": .string("2026-01-01T11:00:00Z"),
"calendar_id": .string("id1")
])
let req = Request(id: "c1", operation: "calendar.event.create", arguments: args)
let resp = dispatch(request: req, calendarProvider: FakeCalendarListForSelection(calendars: []), eventsProvider: FakeEventsProvider(events: []), createProvider: FakeCreateProvider(willReturn: sampleEvent()), authProvider: MockAuthProvider(status: .authorized))
XCTAssertFalse(resp.ok)
XCTAssertEqual(resp.error?.code, "invalid_request")
}
func testCreateStartAfterEndInvalid() {
let args: JSONValue = .object([
"title": .string("Meeting"),
"start": .string("2026-01-01T12:00:00Z"),
"end": .string("2026-01-01T11:00:00Z"),
"calendar_id": .string("id1")
])
let req = Request(id: "c2", operation: "calendar.event.create", arguments: args)
let resp = dispatch(request: req, calendarProvider: FakeCalendarListForSelection(calendars: []), eventsProvider: FakeEventsProvider(events: []), createProvider: FakeCreateProvider(willReturn: sampleEvent()), authProvider: MockAuthProvider(status: .authorized))
XCTAssertFalse(resp.ok)
XCTAssertEqual(resp.error?.code, "invalid_request")
}
func testCreateInvalidISO() {
let args: JSONValue = .object([
"title": .string("Meeting"),
"start": .string("bad-date"),
"end": .string("2026-01-01T11:00:00Z"),
"calendar_id": .string("id1")
])
let req = Request(id: "c3", operation: "calendar.event.create", arguments: args)
let resp = dispatch(request: req, calendarProvider: FakeCalendarListForSelection(calendars: []), eventsProvider: FakeEventsProvider(events: []), createProvider: FakeCreateProvider(willReturn: sampleEvent()), authProvider: MockAuthProvider(status: .authorized))
XCTAssertFalse(resp.ok)
XCTAssertEqual(resp.error?.code, "invalid_request")
}
func testCreateNoCalendarSpecifiedFailsNoDefault() {
let args: JSONValue = .object([
"title": .string("Meeting"),
"start": .string("2026-01-01T10:00:00Z"),
"end": .string("2026-01-01T11:00:00Z")
])
let req = Request(id: "c4", operation: "calendar.event.create", arguments: args)
let calendars = [CalendarListItem(id: "id1", title: "Home", source: "iCloud", type: "caldav")]
let provider = SelectingCreateProvider(availableCalendars: calendars, writableIds: ["id1"], willReturn: sampleEvent())
let resp = dispatch(request: req, calendarProvider: FakeCalendarListForSelection(calendars: calendars), eventsProvider: FakeEventsProvider(events: []), createProvider: provider, authProvider: MockAuthProvider(status: .authorized))
XCTAssertFalse(resp.ok)
XCTAssertEqual(resp.error?.code, "invalid_request")
XCTAssertTrue(resp.error?.message.contains("must be specified") ?? false)
}
func testCreateUnknownCalendarFails() {
let args: JSONValue = .object([
"title": .string("Meeting"),
"start": .string("2026-01-01T10:00:00Z"),
"end": .string("2026-01-01T11:00:00Z"),
"calendar": .string("Nonexistent")
])
let req = Request(id: "c5", operation: "calendar.event.create", arguments: args)
let calendars = [CalendarListItem(id: "id1", title: "Home", source: "iCloud", type: "caldav")]
let provider = SelectingCreateProvider(availableCalendars: calendars, writableIds: ["id1"], willReturn: sampleEvent())
let resp = dispatch(request: req, calendarProvider: FakeCalendarListForSelection(calendars: calendars), eventsProvider: FakeEventsProvider(events: []), createProvider: provider, authProvider: MockAuthProvider(status: .authorized))
XCTAssertFalse(resp.ok)
XCTAssertEqual(resp.error?.code, "invalid_request")
}
func testCreateAmbiguousTitleFails() {
let args: JSONValue = .object([
"title": .string("Meeting"),
"start": .string("2026-01-01T10:00:00Z"),
"end": .string("2026-01-01T11:00:00Z"),
"calendar": .string("Home")
])
let req = Request(id: "c6", operation: "calendar.event.create", arguments: args)
let calendars = [
CalendarListItem(id: "id1", title: "Home", source: "iCloud", type: "caldav"),
CalendarListItem(id: "id2", title: "Home", source: "Local", type: "local")
]
let provider = SelectingCreateProvider(availableCalendars: calendars, writableIds: ["id1","id2"], willReturn: sampleEvent())
let resp = dispatch(request: req, calendarProvider: FakeCalendarListForSelection(calendars: calendars), eventsProvider: FakeEventsProvider(events: []), createProvider: provider, authProvider: MockAuthProvider(status: .authorized))
XCTAssertFalse(resp.ok)
XCTAssertEqual(resp.error?.code, "invalid_request")
}
func testCreateReadOnlyCalendarFails() {
let args: JSONValue = .object([
"title": .string("Meeting"),
"start": .string("2026-01-01T10:00:00Z"),
"end": .string("2026-01-01T11:00:00Z"),
"calendar_id": .string("id1")
])
let req = Request(id: "c7", operation: "calendar.event.create", arguments: args)
let calendars = [CalendarListItem(id: "id1", title: "Birthdays", source: "iCloud", type: "birthday")]
let provider = SelectingCreateProvider(availableCalendars: calendars, writableIds: [], willReturn: sampleEvent())
let resp = dispatch(request: req, calendarProvider: FakeCalendarListForSelection(calendars: calendars), eventsProvider: FakeEventsProvider(events: []), createProvider: provider, authProvider: MockAuthProvider(status: .authorized))
XCTAssertFalse(resp.ok)
XCTAssertEqual(resp.error?.code, "invalid_request")
}
func testCreateSuccessReturnsMetadata() {
let args: JSONValue = .object([
"title": .string("Meeting"),
"start": .string("2026-01-01T10:00:00Z"),
"end": .string("2026-01-01T11:00:00Z"),
"calendar_id": .string("id1"),
"notes": .string("bring docs"),
"location": .string("Room 1")
])
let req = Request(id: "c8", operation: "calendar.event.create", arguments: args)
let created = CalendarEventItem(id: "new-id", title: "Meeting", start: "2026-01-01T10:00:00Z", end: "2026-01-01T11:00:00Z", all_day: false, calendar_id: "id1", calendar_title: "Home", notes: "bring docs", location: "Room 1")
let provider = FakeCreateProvider(willReturn: created)
let resp = dispatch(request: req, calendarProvider: FakeCalendarListForSelection(calendars: []), eventsProvider: FakeEventsProvider(events: []), createProvider: provider, authProvider: MockAuthProvider(status: .authorized))
XCTAssertTrue(resp.ok)
XCTAssertEqual(resp.result?.event?.id, "new-id")
XCTAssertEqual(resp.result?.event?.calendar_id, "id1")
XCTAssertEqual(resp.result?.operation, "calendar.event.create")
}
func testCreatePermissionRequired() {
let args: JSONValue = .object([
"title": .string("Meeting"),
"start": .string("2026-01-01T10:00:00Z"),
"end": .string("2026-01-01T11:00:00Z"),
"calendar_id": .string("id1")
])
let req = Request(id: "c9", operation: "calendar.event.create", arguments: args)
let resp = dispatch(request: req, calendarProvider: FakeCalendarListForSelection(calendars: []), eventsProvider: FakeEventsProvider(events: []), createProvider: DeniedCreateProvider(), authProvider: MockAuthProvider(status: .authorized))
XCTAssertFalse(resp.ok)
XCTAssertEqual(resp.error?.code, "permission_required")
}
func testListDoesNotTriggerCreate() {
let args: JSONValue = .object([
"start": .string("2026-01-01T00:00:00Z"),
"end": .string("2026-01-02T00:00:00Z")
])
let req = Request(id: "iso", operation: "calendar.events.list", arguments: args)
let counter = CountingCreate()
let resp = dispatch(request: req, calendarProvider: FakeCalendarListForSelection(calendars: []), eventsProvider: FakeEventsProvider(events: []), createProvider: counter, authProvider: MockAuthProvider(status: .authorized))
XCTAssertTrue(resp.ok)
XCTAssertEqual(counter.count, 0, "list must not trigger create")
}
}
@@ -0,0 +1,175 @@
import XCTest
@testable import ReynaCLIHostCore
import Foundation
// RED tests first: TDD for calendar.list migration
final class CalendarListTests: XCTestCase {
// MARK: - JSONValue safe representation
func testJSONValueRoundTripsObjectArrayPrimitive() throws {
// Expect JSONValue type to support object/array/string/bool/number/null
let json = """
{"id":"1","operation":"calendar.list","arguments":{"filter":"home","limit":2,"nested":{"a":1},"arr":[1,2,null,true],"flag":false}}
"""
let data = json.data(using: .utf8)!
let req = try JSONDecoder().decode(Request.self, from: data)
// arguments should not be empty struct; should retain values
// We test via encoding back and check presence
let encoded = try JSONEncoder().encode(req)
let obj = try JSONSerialization.jsonObject(with: encoded) as! [String: Any]
let args = obj["arguments"] as! [String: Any]
XCTAssertEqual(args["filter"] as? String, "home")
XCTAssertNotNil(args["nested"])
XCTAssertNotNil(args["arr"])
}
func testJSONValueCodableEquatableSendable() throws {
// Verify JSONValue conforms to Codable, Equatable, Sendable (compile-time)
let v1: JSONValue = .object(["a": .number(1)])
let v2: JSONValue = .object(["a": .number(1)])
XCTAssertEqual(v1, v2)
// Codable roundtrip
let data = try JSONEncoder().encode(v1)
let decoded = try JSONDecoder().decode(JSONValue.self, from: data)
XCTAssertEqual(decoded, v1)
}
// MARK: - CalendarListProvider injection & sorting
// Fake provider for tests
struct FakeSuccessProvider: CalendarListProviding {
let calendars: [CalendarListItem]
func listCalendars() throws -> [CalendarListItem] { calendars }
}
func testCalendarListSuccessAndDeterministicSort() throws {
// Unsorted input should be returned sorted by source/title/id
let unsorted = [
CalendarListItem(id: "c", title: "B", source: "iCloud", type: "caldav"),
CalendarListItem(id: "a", title: "A", source: "Local", type: "local"),
CalendarListItem(id: "b", title: "A", source: "iCloud", type: "caldav"),
CalendarListItem(id: "aa", title: "A", source: "iCloud", type: "caldav"),
]
let provider = FakeSuccessProvider(calendars: unsorted)
let req = Request(id: "id-1", operation: "calendar.list", arguments: .object([:]))
let resp = dispatch(request: req, calendarProvider: provider)
XCTAssertTrue(resp.ok)
XCTAssertEqual(resp.id, "id-1")
// The new response/result model should still carry protocol_version/operation/status and calendars
// We decode result payload to check calendars order
// ResultPayload should support generic calendars? We'll check via JSON
let encoded = try JSONEncoder().encode(resp)
let obj = try JSONSerialization.jsonObject(with: encoded) as! [String: Any]
let result = obj["result"] as! [String: Any]
XCTAssertEqual(result["protocol_version"] as? String, PROTOCOL_VERSION)
XCTAssertEqual(result["operation"] as? String, "calendar.list")
XCTAssertEqual(result["status"] as? String, "ok")
let cals = result["calendars"] as! [[String: Any]]
// Deterministic sort: by source, then title, then id (lexicographic, case-sensitive)
// 'L' (76) < 'i' (105) so Local < iCloud
// Expected order: (Local,A,a), (iCloud,A,aa), (iCloud,A,b), (iCloud,B,c)
XCTAssertEqual(cals[0]["id"] as? String, "a")
XCTAssertEqual(cals[1]["id"] as? String, "aa")
XCTAssertEqual(cals[2]["id"] as? String, "b")
XCTAssertEqual(cals[3]["id"] as? String, "c")
// Ensure only allowed fields
for cal in cals {
XCTAssertEqual(Set(cal.keys), Set(["id","title","source","type"]))
}
}
func testCalendarListPermissionRequired() throws {
struct DeniedProvider: CalendarListProviding {
func listCalendars() throws -> [CalendarListItem] {
throw CalendarProviderError.permissionRequired
}
}
let req = Request(id: "perm-1", operation: "calendar.list", arguments: .object([:]))
let resp = dispatch(request: req, calendarProvider: DeniedProvider())
XCTAssertFalse(resp.ok)
XCTAssertEqual(resp.error?.code, "permission_required")
XCTAssertNil(resp.result, "permission_required must not leak calendar content")
}
func testCalendarListProviderFailure() throws {
struct FailProvider: CalendarListProviding {
func listCalendars() throws -> [CalendarListItem] {
throw CalendarProviderError.unavailable("disk error")
}
}
let req = Request(id: "fail-1", operation: "calendar.list", arguments: .object([:]))
let resp = dispatch(request: req, calendarProvider: FailProvider())
XCTAssertFalse(resp.ok)
XCTAssertEqual(resp.error?.code, "calendar_unavailable")
}
func testCalendarListRequestJSONDecodeWithArgumentsObject() throws {
let json = """
{"id":"json-1","operation":"calendar.list","arguments":{"foo":"bar","num":42}}
"""
let data = json.data(using: .utf8)!
let req = try JSONDecoder().decode(Request.self, from: data)
XCTAssertEqual(req.id, "json-1")
// arguments should be parsed and not throw
let provider = FakeSuccessProvider(calendars: [])
let resp = dispatch(request: req, calendarProvider: provider)
XCTAssertTrue(resp.ok, "calendar.list with args object should succeed")
}
func testServiceHealthStillWorksAfterMigration() throws {
let req = Request(id: "health-1", operation: "service.health", arguments: .object([:]))
let resp = dispatch(request: req, calendarProvider: FakeSuccessProvider(calendars: []))
XCTAssertTrue(resp.ok)
XCTAssertEqual(resp.result?.protocol_version, PROTOCOL_VERSION)
XCTAssertEqual(resp.result?.operation, "service.health")
XCTAssertEqual(resp.result?.status, "ok")
}
// MARK: - EventKit provider must be read-only (static check)
func testProductionEventKitProviderNeverRequestsAccess() throws {
// Read the EventKit provider source and ensure it never calls prompt-triggering APIs
// Note: createEvent legitimately mutates (save) – allowed only inside createEvent method.
let fm = FileManager.default
var cur = URL(fileURLWithPath: fm.currentDirectoryPath)
var providerURL: URL? = nil
for _ in 0..<8 {
let cand = cur.appendingPathComponent("Sources/ReynaCLIHost/CalendarProvider.swift")
if fm.fileExists(atPath: cand.path) { providerURL = cand; break }
let candCore = cur.appendingPathComponent("Sources/ReynaCLIHostCore/CalendarProvider.swift")
if fm.fileExists(atPath: candCore.path) { providerURL = candCore; break }
let cand2 = cur.appendingPathComponent("native/ReynaCLIHost/Sources/ReynaCLIHost/CalendarProvider.swift")
if fm.fileExists(atPath: cand2.path) { providerURL = cand2; break }
let cand2Core = cur.appendingPathComponent("native/ReynaCLIHost/Sources/ReynaCLIHostCore/CalendarProvider.swift")
if fm.fileExists(atPath: cand2Core.path) { providerURL = cand2Core; break }
cur = cur.deletingLastPathComponent()
}
guard let url = providerURL, let content = try? String(contentsOf: url) else {
XCTFail("CalendarProvider.swift not found for read-only safety check")
return
}
let lines = content.components(separatedBy: .newlines)
for line in lines {
let l = line.lowercased()
if l.contains("ekeventstore") && l.contains(".request") {
XCTFail("Production provider must not call request methods on EKEventStore: \(line)")
}
}
XCTAssertTrue(content.contains("authorizationStatus"), "Should check authorizationStatus")
// Ensure no remove mutation anywhere
let lower = content.lowercased()
XCTAssertFalse(lower.contains("remove(") && lower.contains("ekevent"), "Should not remove EKEvent")
// No AppleScript
XCTAssertFalse(lower.contains("nsapplescript") || lower.contains("appleevent"), "Should not use AppleScript")
// If save exists, it must be inside createEvent func (mutation allowed only there)
if lower.contains("save(") {
// crude check: ensure save appears after func createEvent
let parts = content.components(separatedBy: "func createEvent")
XCTAssertEqual(parts.count, 2, "save should only appear in createEvent, found multiple or none")
let beforeCreate = parts[0].lowercased()
XCTAssertFalse(beforeCreate.contains("save(") && beforeCreate.contains("ekevent"), "save(EKEvent) must not appear outside createEvent")
}
}
}
@@ -0,0 +1,190 @@
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)")
}
}
@@ -0,0 +1,337 @@
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")
}
}
@@ -0,0 +1,149 @@
import XCTest
import Foundation
/// Executable-level integration tests invoking the compiled ReynaCLIHost binary.
/// These prove persistent-pipe and malformed-request behavior.
final class HostIntegrationTests: XCTestCase {
// MARK: - Helpers
func hostExecutableURL() throws -> URL {
let fm = FileManager.default
// When `swift test` runs, cwd is package root. But be robust.
// Portable candidates only: package-relative .build locations for common triples.
let candidates: [String] = [
".build/debug/ReynaCLIHost",
".build/arm64-apple-macosx/debug/ReynaCLIHost",
".build/x86_64-apple-macosx/debug/ReynaCLIHost",
]
let cwd = fm.currentDirectoryPath
var tried: [String] = []
for c in candidates {
let url = URL(fileURLWithPath: cwd).appendingPathComponent(c)
tried.append(url.path)
if fm.isExecutableFile(atPath: url.path) {
return url
}
}
// Try surrounding .build directories walked upward from cwd
var parent = URL(fileURLWithPath: cwd)
for _ in 0..<6 {
let p1 = parent.appendingPathComponent(".build/debug/ReynaCLIHost")
tried.append(p1.path)
if fm.isExecutableFile(atPath: p1.path) { return p1 }
let p2 = parent.appendingPathComponent(".build/arm64-apple-macosx/debug/ReynaCLIHost")
tried.append(p2.path)
if fm.isExecutableFile(atPath: p2.path) { return p2 }
parent = parent.deletingLastPathComponent()
}
throw NSError(domain: "HostIntegrationTests", code: 1, userInfo: [NSLocalizedDescriptionKey: "ReynaCLIHost binary not found. Tried:\n" + tried.joined(separator: "\n")])
}
/// Run host with given stdin string, return stdout lines (non-empty trimmed) after process exits.
func runHost(input: String, timeout: TimeInterval = 5) throws -> [String] {
let exe = try hostExecutableURL()
let process = Process()
process.executableURL = exe
let stdinPipe = Pipe()
let stdoutPipe = Pipe()
let stderrPipe = Pipe()
process.standardInput = stdinPipe
process.standardOutput = stdoutPipe
process.standardError = stderrPipe
try process.run()
// Write input then close
if let data = input.data(using: .utf8) {
stdinPipe.fileHandleForWriting.write(data)
}
stdinPipe.fileHandleForWriting.closeFile()
// Wait with timeout
let deadline = Date().addingTimeInterval(timeout)
while process.isRunning && Date() < deadline {
usleep(100_000) // 0.1s
}
if process.isRunning {
process.terminate()
throw NSError(domain: "HostIntegrationTests", code: 2, userInfo: [NSLocalizedDescriptionKey: "Host process timed out after \(timeout)s. stderr: \(String(data: stderrPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "")"])
}
let outData = stdoutPipe.fileHandleForReading.readDataToEndOfFile()
let outStr = String(data: outData, encoding: .utf8) ?? ""
// Split by newline, keep non-empty raw lines but preserve for debugging
let lines = outStr.split(separator: "\n", omittingEmptySubsequences: false).map { String($0) }.filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
return lines
}
func decodeResponse(_ line: String) throws -> [String: Any] {
guard let data = line.data(using: .utf8),
let obj = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
throw NSError(domain: "HostIntegrationTests", code: 3, userInfo: [NSLocalizedDescriptionKey: "Line is not valid JSON: \(line)"])
}
return obj
}
// MARK: - Tests
func testPersistentPipeHandlesTwoHealthRequests() throws {
// Two well-formed health requests on persistent stdin must yield two responses.
let req1 = #"{"id":"1","operation":"service.health","arguments":{}}"#
let req2 = #"{"id":"2","operation":"service.health","arguments":{}}"#
let input = req1 + "\n" + req2 + "\n"
let lines = try runHost(input: input)
XCTAssertEqual(lines.count, 2, "Expected 2 responses for 2 requests, got \(lines.count). Output: \(lines)")
let resp1 = try decodeResponse(lines[0])
XCTAssertEqual(resp1["id"] as? String, "1")
XCTAssertEqual(resp1["ok"] as? Bool, true)
let resp2 = try decodeResponse(lines[1])
XCTAssertEqual(resp2["id"] as? String, "2")
XCTAssertEqual(resp2["ok"] as? Bool, true)
}
func testMalformedJsonProducesInvalidRequestResponseWithoutId() throws {
// Malformed nonempty JSON must produce a response with ok:false, error.code invalid_request, id = ""
let bad = "not json at all"
let input = bad + "\n"
let lines = try runHost(input: input)
XCTAssertEqual(lines.count, 1, "Malformed JSON should produce one error response, got \(lines.count). Output: \(lines)")
let resp = try decodeResponse(lines[0])
XCTAssertEqual(resp["ok"] as? Bool, false, "Malformed JSON should be ok:false")
XCTAssertEqual(resp["id"] as? String, "", "When id cannot be recovered, id should be empty string")
if let err = resp["error"] as? [String: Any] {
XCTAssertEqual(err["code"] as? String, "invalid_request")
} else {
XCTFail("Missing error object in response: \(resp)")
}
}
func testMalformedJsonPreservesIdWhenPossible() throws {
// When malformed JSON still contains an id field, preserve it.
let bad = #"{"id":"keep-me","operation":}"# // invalid JSON but id extractable
let input = bad + "\n"
let lines = try runHost(input: input)
XCTAssertEqual(lines.count, 1, "Expected 1 error response for malformed JSON with id, got \(lines)")
let resp = try decodeResponse(lines[0])
XCTAssertEqual(resp["ok"] as? Bool, false)
XCTAssertEqual(resp["id"] as? String, "keep-me", "Should preserve id when recoverable")
if let err = resp["error"] as? [String: Any] {
XCTAssertEqual(err["code"] as? String, "invalid_request")
} else {
XCTFail("Missing error object")
}
}
func testEmptyLinesAreIgnored() throws {
// Empty lines should not produce responses or break subsequent messages.
let req1 = #"{"id":"a","operation":"service.health","arguments":{}}"#
let req2 = #"{"id":"b","operation":"service.health","arguments":{}}"#
let input = "\n" + req1 + "\n\n\n" + req2 + "\n\n"
let lines = try runHost(input: input)
XCTAssertEqual(lines.count, 2, "Empty lines should be ignored, expected 2 responses got \(lines.count): \(lines)")
let ids = try lines.map { try decodeResponse($0)["id"] as? String }
XCTAssertEqual(ids, ["a", "b"])
}
}
@@ -0,0 +1,126 @@
import XCTest
import Foundation
import Darwin
@testable import ReynaCLIHostCore
final class LStatFailClosedTests: XCTestCase {
private func dir(uid: uid_t, mode: mode_t, symlink: Bool = false) -> LStatInfo {
return LStatInfo(uid: uid, mode: mode_t(mode), isSymlink: symlink, isDir: !symlink, exists: true)
}
private func file(uid: uid_t, mode: mode_t) -> LStatInfo {
// regular file: not symlink, not dir
return LStatInfo(uid: uid, mode: mode_t(mode), isSymlink: false, isDir: false, exists: true)
}
private func currentUID() -> uid_t { getuid() }
// RED: EACCES and ELOOP must not be treated as missing (fail-closed)
func testNonENOENTProviderFailureRejectsWithCode22() {
let uid = currentUID()
let socketPath = "/tmp/rhfail/reyna.sock"
// Map only root trusted; but intermediate component will fail with EACCES
var mapPresent: [String: LStatInfo] = [
"/": dir(uid: 0, mode: 0o40755),
"/private": dir(uid: 0, mode: 0o40755),
"/private/tmp": dir(uid: 0, mode: 0o41777),
"/tmp": dir(uid: 0, mode: 0o120777, symlink: true)
]
let provider: LStatResultProvider = { p in
if p == "/tmp/rhfail" {
return .failed(errnoCode: EACCES)
}
if let v = mapPresent[p] { return .present(v) }
return .absent
}
XCTAssertThrowsError(try validateParentChainPureResultProvider(socketPath: socketPath, currentUID: uid, provider: provider)) { err in
XCTAssertEqual((err as NSError).code, 22, "EACCES must surface as lstat failure code 22, not absent")
}
let providerLoop: LStatResultProvider = { p in
if p == "/tmp/rhfail" { return .failed(errnoCode: ELOOP) }
if let v = mapPresent[p] { return .present(v) }
return .absent
}
XCTAssertThrowsError(try validateParentChainPureResultProvider(socketPath: socketPath, currentUID: uid, provider: providerLoop)) { err in
XCTAssertEqual((err as NSError).code, 22, "ELOOP must surface as code 22, not treated as ENOENT")
}
}
func testEAccesAnywhereInChainRejects() {
let uid = currentUID()
let socketPath = "/Users/\(NSUserName())/Library/reyna.sock"
let provider: LStatResultProvider = { p in
if p == "/Users" { return .failed(errnoCode: EACCES) }
return .absent
}
XCTAssertThrowsError(try validateParentChainPureResultProvider(socketPath: socketPath, currentUID: uid, provider: provider))
}
// RED: regular file at /tmp or /var must be rejected (old ensureParentDirectories had bug where it skipped directory check for those)
func testRegularFileAtTmpMustReject() throws {
let uid = currentUID()
let socketPath = "/tmp/reyna.sock"
// /tmp exists as regular file (not dir, not symlink)
var map: [String: LStatInfo] = [
"/": dir(uid: 0, mode: 0o40755),
"/tmp": file(uid: 0, mode: 0o100644) // regular file
]
let provider: LStatResultProvider = { p in
if let v = map[p] { return .present(v) }
return .absent
}
XCTAssertThrowsError(try validateParentChainPureResultProvider(socketPath: socketPath, currentUID: uid, provider: provider), "Regular file at /tmp must reject (not directory)")
// Also test direct single-component validator
XCTAssertThrowsError(try validateSingleLStatInfoOrThrow(path: "/tmp", info: map["/tmp"]!, currentUID: uid))
XCTAssertThrowsError(try validateSingleLStatInfoOrThrow(path: "/var", info: file(uid: 0, mode: 0o100644), currentUID: uid))
}
func testRegularFileAtIntermediateTrustedAliasMustReject() {
let uid = currentUID()
// /private/var exists as file
var map: [String: LStatInfo] = [
"/": dir(uid: 0, mode: 0o40755),
"/private": dir(uid: 0, mode: 0o40755),
"/private/var": file(uid: 0, mode: 0o100644)
]
let provider: LStatResultProvider = { p in
if let v = map[p] { return .present(v) }
return .absent
}
XCTAssertThrowsError(try validateParentChainPureResultProvider(socketPath: "/private/var/tmp/x/reyna.sock", currentUID: uid, provider: provider))
}
func testLiveProviderDoesNotSwallowNonENOENT() {
// liveLStatProvider legacy should now fail-closed sentinel, not nil
// Simulate by calling wrapper directly: we can't easily force EACCES without real FS,
// but we can assert that failed case in result provider is distinct from absent
let absent = LStatResult.absent
let failed = LStatResult.failed(errnoCode: EACCES)
switch absent {
case .absent: break
default: XCTFail()
}
switch failed {
case .failed(let c): XCTAssertEqual(c, EACCES)
default: XCTFail()
}
// legacy provider should return non-nil sentinel for failed case (so caller doesn't treat as missing)
// We test sentinel is non-nil and will be rejected by validator
// The new liveLStatResultProvider is tested via chain above
}
func testTmpSymlinkStillAllowed() throws {
let uid = currentUID()
let map: [String: LStatInfo] = [
"/": dir(uid: 0, mode: 0o40755),
"/private": dir(uid: 0, mode: 0o40755),
"/private/tmp": dir(uid: 0, mode: 0o41777),
"/tmp": dir(uid: 0, mode: 0o120777, symlink: true),
"/tmp/rh-test": dir(uid: uid, mode: 0o40700)
]
let provider: LStatResultProvider = { p in
if let v = map[p] { return .present(v) }
return .absent
}
XCTAssertNoThrow(try validateParentChainPureResultProvider(socketPath: "/tmp/rh-test/reyna.sock", currentUID: uid, provider: provider))
}
}
@@ -0,0 +1,63 @@
import XCTest
@testable import ReynaCLIHostCore
final class ProtocolTests: XCTestCase {
func testServiceHealthReturnsOKWithSameIdAndProtocolVersion() throws {
let req = Request(id: "x", operation: "service.health", arguments: Args())
let resp = dispatch(request: req)
XCTAssertEqual(resp.id, "x", "response must echo same id")
XCTAssertTrue(resp.ok, "service.health should be ok:true")
XCTAssertNotNil(resp.result, "result must be present on success")
XCTAssertEqual(resp.result?.operation, "service.health")
XCTAssertFalse(resp.result?.protocol_version.isEmpty ?? true, "protocol_version must be nonempty")
XCTAssertNil(resp.error, "error must be nil on success")
}
func testServiceHealthDecodedFromJSON() throws {
let json = #"{"id":"abc-123","operation":"service.health","arguments":{}}"#
let data = json.data(using: .utf8)!
let decoder = JSONDecoder()
let req = try decoder.decode(Request.self, from: data)
let resp = dispatch(request: req)
XCTAssertEqual(resp.id, "abc-123")
XCTAssertTrue(resp.ok)
XCTAssertEqual(resp.result?.operation, "service.health")
XCTAssertFalse(resp.result?.protocol_version.isEmpty ?? true)
}
func testUnknownOperationReturnsError() throws {
let req = Request(id: "y", operation: "does.not.exist", arguments: Args())
let resp = dispatch(request: req)
XCTAssertEqual(resp.id, "y")
XCTAssertFalse(resp.ok, "unknown operation must return ok:false")
XCTAssertNil(resp.result, "result must be nil on failure")
XCTAssertNotNil(resp.error)
XCTAssertEqual(resp.error?.code, "unknown_operation")
}
func testUnknownOperationJSONRoundTrip() throws {
let json = #"{"id":"1","operation":"foo.bar","arguments":{}}"#
let req = try JSONDecoder().decode(Request.self, from: json.data(using: .utf8)!)
let resp = dispatch(request: req)
let encoded = try JSONEncoder().encode(resp)
let decoded = try JSONDecoder().decode(Response.self, from: encoded)
XCTAssertEqual(decoded.id, "1")
XCTAssertFalse(decoded.ok)
XCTAssertEqual(decoded.error?.code, "unknown_operation")
}
func testDispatchIsDeterministic() throws {
let req = Request(id: "same", operation: "service.health", arguments: Args())
let r1 = dispatch(request: req)
let r2 = dispatch(request: req)
XCTAssertEqual(r1.id, r2.id)
XCTAssertEqual(r1.ok, r2.ok)
XCTAssertEqual(r1.result?.protocol_version, r2.result?.protocol_version)
}
}
@@ -0,0 +1,443 @@
import XCTest
@testable import ReynaCLIHostCore
import Foundation
// Tests for reminders.request_full_access – TDD, fake providers only
// Modeled after CalendarAuthorizationTests.swift
final class RemindersAuthorizationTests: XCTestCase {
// MARK: - Fake auth providers
struct AlreadyAuthorizedProvider: RemindersAuthorizationProviding {
func authorizationStatus() -> RemindersAuthorizationStatus { .authorized }
func requestFullAccess() throws -> Bool {
XCTFail("requestFullAccess must not be called when already authorized")
return false
}
}
struct NotDeterminedGrantedProvider: RemindersAuthorizationProviding {
func authorizationStatus() -> RemindersAuthorizationStatus { .notDetermined }
func requestFullAccess() throws -> Bool { true }
}
struct NotDeterminedDeniedProvider: RemindersAuthorizationProviding {
func authorizationStatus() -> RemindersAuthorizationStatus { .notDetermined }
func requestFullAccess() throws -> Bool { false }
}
struct DeniedProvider: RemindersAuthorizationProviding {
func authorizationStatus() -> RemindersAuthorizationStatus { .denied }
func requestFullAccess() throws -> Bool { false }
}
struct TimeoutProvider: RemindersAuthorizationProviding {
func authorizationStatus() -> RemindersAuthorizationStatus { .notDetermined }
func requestFullAccess() throws -> Bool {
throw RemindersProviderError.unavailable("reminders authorization timed out")
}
}
struct ErrorProvider: RemindersAuthorizationProviding {
func authorizationStatus() -> RemindersAuthorizationStatus { .notDetermined }
func requestFullAccess() throws -> Bool {
throw RemindersProviderError.unavailable("disk error")
}
}
// MARK: - Empty reminders providers for dispatch
struct EmptyLists: RemindersListsProviding {
func listReminderLists() throws -> [ReminderListItem] { [] }
}
struct EmptyList: RemindersListProviding {
func listReminders(listId: String?, listTitle: String?, completed: Bool?, limit: Int) throws -> [ReminderItem] { [] }
}
struct EmptyCreate: RemindersCreateProviding {
func createReminder(title: String, listId: String?, listTitle: String?, notes: String?, due: Date?, priority: Int?) throws -> ReminderCreateResult {
ReminderCreateResult(id: "r1", list_id: "l1", list_title: "t", title: title)
}
}
struct EmptyCalList: CalendarListProviding { func listCalendars() throws -> [CalendarListItem] { [] } }
struct EmptyCalEvents: CalendarEventsListProviding {
func listEvents(start: Date, end: Date, calendarId: String?, calendarTitle: String?, limit: Int) throws -> [CalendarEventItem] { [] }
}
struct EmptyCalCreate: CalendarEventCreateProviding {
func createEvent(title: String, start: Date, end: Date, allDay: Bool, notes: String?, location: String?, calendarId: String?, calendarTitle: String?) throws -> CalendarEventItem {
CalendarEventItem(id: "e", title: title, start: "2026-01-01T00:00:00Z", end: "2026-01-01T01:00:00Z", all_day: false, calendar_id: "c", calendar_title: "t", notes: nil, location: nil)
}
}
struct MockCalAuth: CalendarAuthorizationProviding {
var status: CalendarAuthorizationStatus = .authorized
func authorizationStatus() -> CalendarAuthorizationStatus { status }
func requestFullAccess() throws -> Bool { false }
}
struct EmptyContactsAuth: ContactsAuthorizationProviding {
func authorizationStatus() -> ContactsAuthorizationStatus { .authorized }
func requestAccess() throws -> Bool { false }
}
struct EmptyContactsSearch: ContactsSearchProviding { func searchContacts(query: String?, limit: Int) throws -> [ContactListItem] { [] } }
struct EmptyContactsRead: ContactsReadProviding {
func readContact(id: String) throws -> ContactDetailItem { throw ContactsProviderError.notFound("nf") }
}
struct EmptyContactsCreate: ContactsCreateProviding {
func createContact(firstName: String?, lastName: String?, organization: String?, jobTitle: String?, note: String?, email: ContactEmailLabelValue?, phone: ContactPhoneLabelValue?) throws -> ContactCreateResult {
throw ContactsProviderError.unavailable("na")
}
}
private func dispatchRemindersAuth(op: String, id: String, auth: RemindersAuthorizationProviding) -> Response {
let req = Request(id: id, operation: op, arguments: .object([:]))
return dispatch(
request: req,
calendarProvider: EmptyCalList(),
eventsProvider: EmptyCalEvents(),
createProvider: EmptyCalCreate(),
authProvider: MockCalAuth(),
contactsAuthProvider: EmptyContactsAuth(),
contactsSearchProvider: EmptyContactsSearch(),
contactsReadProvider: EmptyContactsRead(),
contactsCreateProvider: EmptyContactsCreate(),
remindersAuthProvider: auth,
remindersListsProvider: EmptyLists(),
remindersListProvider: EmptyList(),
remindersCreateProvider: EmptyCreate()
)
}
// MARK: - Core auth behavior
func testAlreadyAuthorizedReturnsAuthorizedWithoutRequesting() {
let auth = AlreadyAuthorizedProvider()
let resp = dispatchRemindersAuth(op: "reminders.request_full_access", id: "ra1", auth: auth)
XCTAssertTrue(resp.ok)
XCTAssertEqual(resp.result?.status, "authorized")
XCTAssertEqual(resp.result?.operation, "reminders.request_full_access")
XCTAssertEqual(resp.id, "ra1")
}
func testNotDeterminedReachesRequestPath() {
final class TrackingProvider: RemindersAuthorizationProviding, @unchecked Sendable {
var didRequest = false
var status: RemindersAuthorizationStatus = .notDetermined
func authorizationStatus() -> RemindersAuthorizationStatus { status }
func requestFullAccess() throws -> Bool {
didRequest = true
return true
}
}
let tracking = TrackingProvider()
let resp = dispatchRemindersAuth(op: "reminders.request_full_access", id: "ra2", auth: tracking)
XCTAssertTrue(tracking.didRequest, "requestFullAccess must be called when notDetermined")
XCTAssertTrue(resp.ok)
}
func testGrantedReturnsAuthorizedResult() {
let auth = NotDeterminedGrantedProvider()
let resp = dispatchRemindersAuth(op: "reminders.request_full_access", id: "ra3", auth: auth)
XCTAssertTrue(resp.ok)
XCTAssertEqual(resp.result?.status, "authorized")
XCTAssertEqual(resp.result?.protocol_version, PROTOCOL_VERSION)
XCTAssertNil(resp.error)
XCTAssertNil(resp.result?.reminders, "must not output reminder content")
XCTAssertNil(resp.result?.reminder_lists)
XCTAssertNil(resp.result?.reminder)
}
func testDeniedReturnsPermissionDenied() {
let auth = NotDeterminedDeniedProvider()
let resp = dispatchRemindersAuth(op: "reminders.request_full_access", id: "ra4", auth: auth)
XCTAssertFalse(resp.ok)
XCTAssertEqual(resp.error?.code, "permission_denied")
XCTAssertNotNil(resp.error?.message)
XCTAssertNil(resp.result)
}
func testAlreadyDeniedPathAlsoDenies() {
let auth = DeniedProvider()
let resp = dispatchRemindersAuth(op: "reminders.request_full_access", id: "ra5", auth: auth)
XCTAssertFalse(resp.ok)
XCTAssertEqual(resp.error?.code, "permission_denied")
}
func testTimeoutReturnsRemindersUnavailable() {
let auth = TimeoutProvider()
let resp = dispatchRemindersAuth(op: "reminders.request_full_access", id: "ra6", auth: auth)
XCTAssertFalse(resp.ok)
XCTAssertEqual(resp.error?.code, "reminders_unavailable")
XCTAssertTrue(resp.error?.message.lowercased().contains("timed out") ?? false)
}
func testErrorReturnsRemindersUnavailable() {
let auth = ErrorProvider()
let resp = dispatchRemindersAuth(op: "reminders.request_full_access", id: "ra7", auth: auth)
XCTAssertFalse(resp.ok)
XCTAssertEqual(resp.error?.code, "reminders_unavailable")
}
func testNoReminderDataInAuthResponses() {
let authOk = NotDeterminedGrantedProvider()
let respOk = dispatchRemindersAuth(op: "reminders.request_full_access", id: "ok", auth: authOk)
XCTAssertNil(respOk.result?.reminders)
XCTAssertNil(respOk.result?.reminder_lists)
XCTAssertNil(respOk.result?.reminder)
XCTAssertNil(respOk.result?.created_reminder)
XCTAssertNil(respOk.result?.calendars)
XCTAssertNil(respOk.result?.events)
let authDen = NotDeterminedDeniedProvider()
let respDen = dispatchRemindersAuth(op: "reminders.request_full_access", id: "den", auth: authDen)
XCTAssertNil(respDen.result)
}
// MARK: - list / create must never prompt
func testRemindersListsDoesNotCallAuthRequest() {
final class SpyLists: RemindersListsProviding, @unchecked Sendable {
var called = false
func listReminderLists() throws -> [ReminderListItem] { called = true; return [] }
}
final class SpyAuth: RemindersAuthorizationProviding, @unchecked Sendable {
var didCallStatus = false
var didCallRequest = false
func authorizationStatus() -> RemindersAuthorizationStatus { didCallStatus = true; return .authorized }
func requestFullAccess() throws -> Bool { didCallRequest = true; return false }
}
let lists = SpyLists()
let auth = SpyAuth()
let req = Request(id: "rl-1", operation: "reminders.lists", arguments: .object([:]))
let resp = dispatch(
request: req,
calendarProvider: EmptyCalList(),
eventsProvider: EmptyCalEvents(),
createProvider: EmptyCalCreate(),
authProvider: MockCalAuth(),
contactsAuthProvider: EmptyContactsAuth(),
contactsSearchProvider: EmptyContactsSearch(),
contactsReadProvider: EmptyContactsRead(),
contactsCreateProvider: EmptyContactsCreate(),
remindersAuthProvider: auth,
remindersListsProvider: lists,
remindersListProvider: EmptyList(),
remindersCreateProvider: EmptyCreate()
)
XCTAssertTrue(resp.ok)
XCTAssertFalse(auth.didCallRequest, "reminders.lists must never call requestFullAccess")
XCTAssertTrue(lists.called)
}
func testRemindersListDoesNotCallAuthRequest() {
final class SpyList: RemindersListProviding, @unchecked Sendable {
var called = false
func listReminders(listId: String?, listTitle: String?, completed: Bool?, limit: Int) throws -> [ReminderItem] { called = true; return [] }
}
final class SpyAuth: RemindersAuthorizationProviding, @unchecked Sendable {
var didCallRequest = false
func authorizationStatus() -> RemindersAuthorizationStatus { .authorized }
func requestFullAccess() throws -> Bool { didCallRequest = true; return false }
}
let rl = SpyList()
let auth = SpyAuth()
let req = Request(id: "r-1", operation: "reminders.list", arguments: .object(["limit": .number(10)]))
let resp = dispatch(
request: req,
calendarProvider: EmptyCalList(),
eventsProvider: EmptyCalEvents(),
createProvider: EmptyCalCreate(),
authProvider: MockCalAuth(),
contactsAuthProvider: EmptyContactsAuth(),
contactsSearchProvider: EmptyContactsSearch(),
contactsReadProvider: EmptyContactsRead(),
contactsCreateProvider: EmptyContactsCreate(),
remindersAuthProvider: auth,
remindersListsProvider: EmptyLists(),
remindersListProvider: rl,
remindersCreateProvider: EmptyCreate()
)
XCTAssertTrue(resp.ok)
XCTAssertFalse(auth.didCallRequest, "reminders.list must never trigger authorization request")
XCTAssertTrue(rl.called)
}
func testRemindersCreateDoesNotCallAuthRequest() {
final class SpyCreate: RemindersCreateProviding, @unchecked Sendable {
var called = false
func createReminder(title: String, listId: String?, listTitle: String?, notes: String?, due: Date?, priority: Int?) throws -> ReminderCreateResult {
called = true
return ReminderCreateResult(id: "x", list_id: "l", list_title: "t", title: title)
}
}
final class SpyAuth: RemindersAuthorizationProviding, @unchecked Sendable {
var didCallRequest = false
func authorizationStatus() -> RemindersAuthorizationStatus { .authorized }
func requestFullAccess() throws -> Bool { didCallRequest = true; return false }
}
let create = SpyCreate()
let auth = SpyAuth()
let req = Request(id: "rc-1", operation: "reminders.create", arguments: .object(["title": .string("Buy milk"), "list": .string("Groceries")]))
let resp = dispatch(
request: req,
calendarProvider: EmptyCalList(),
eventsProvider: EmptyCalEvents(),
createProvider: EmptyCalCreate(),
authProvider: MockCalAuth(),
contactsAuthProvider: EmptyContactsAuth(),
contactsSearchProvider: EmptyContactsSearch(),
contactsReadProvider: EmptyContactsRead(),
contactsCreateProvider: EmptyContactsCreate(),
remindersAuthProvider: auth,
remindersListsProvider: EmptyLists(),
remindersListProvider: EmptyList(),
remindersCreateProvider: create
)
XCTAssertTrue(resp.ok)
XCTAssertFalse(auth.didCallRequest, "reminders.create must never trigger authorization request (permission check is via status only in real provider, but here we assert no prompt)")
XCTAssertTrue(create.called)
}
// MARK: - Bridge tests – deterministic pump without real EventKit
final class TestBox<T>: @unchecked Sendable {
var value: T
init(_ v: T) { value = v }
}
func testBridgePumpsMainRunLoopAndDeliversMainQueueCallback() throws {
let exp = expectation(description: "bridge completes")
let grantedBox = TestBox(false)
let errorBox = TestBox<Error?>(nil)
DispatchQueue.main.async {
let bridge = RemindersMainRunLoopBridge()
do {
grantedBox.value = try bridge.requestAccess(timeout: 2) { completion in
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 testBridgeHandlesCompletionExactlyOnce() throws {
let exp = expectation(description: "exactly once")
let resultBox = TestBox(false)
DispatchQueue.main.async {
let bridge = RemindersMainRunLoopBridge()
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 = RemindersMainRunLoopBridge()
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 = RemindersMainRunLoopBridge()
do {
_ = try bridge.requestAccess(timeout: 1) { completion in
completion(false, NSError(domain: "test", code: 2, userInfo: [NSLocalizedDescriptionKey: "fake EK error"]))
}
} catch let err as RemindersProviderError {
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 reminders_unavailable")
}
func testBridgeTimeoutReturnsCorrectError() throws {
let exp = expectation(description: "timeout")
let codeBox = TestBox("")
DispatchQueue.main.async {
let bridge = RemindersMainRunLoopBridge()
do {
_ = try bridge.requestAccess(timeout: 0.15) { _ in }
XCTFail("Should have thrown")
} catch let err as RemindersProviderError {
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 'reminders authorization timed out' message, got \(codeBox.value)")
}
// MARK: - Production code location check
func testOnlyOneFileCallsRequestFullAccessToReminders() throws {
let fm = FileManager.default
var cur = URL(fileURLWithPath: fm.currentDirectoryPath)
var dirs: [URL] = []
for _ in 0..<10 {
let cand = cur.appendingPathComponent("native/ReynaCLIHost/Sources/ReynaCLIHostCore")
if fm.fileExists(atPath: cand.path) { dirs.append(cand); break }
let cand2 = cur.appendingPathComponent("Sources/ReynaCLIHostCore")
if fm.fileExists(atPath: cand2.path) { dirs.append(cand2); break }
let cand3 = cur.appendingPathComponent("native/ReynaCLIHost/Sources/ReynaCLIHost")
if fm.fileExists(atPath: cand3.path) { dirs.append(cand3) }
let cand4 = cur.appendingPathComponent("Sources/ReynaCLIHost")
if fm.fileExists(atPath: cand4.path) { dirs.append(cand4) }
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("requestFullAccessToReminders") {
hits.append(file.lastPathComponent)
}
}
}
let uniqueSorted = Array(Set(hits)).sorted()
XCTAssertEqual(uniqueSorted, ["RemindersAuthorizationProvider.swift"], "requestFullAccessToReminders must only appear in RemindersAuthorizationProvider.swift, found in \(uniqueSorted)")
}
}
@@ -0,0 +1,481 @@
import XCTest
import Foundation
import Darwin
// Mirrors the pure auth decision we expect to exist in SocketServer.swift after fix.
func referenceIsPeerAuthorized(peerUID: uid_t, currentUID: uid_t) -> Bool {
return peerUID == currentUID
}
final class SecurityHardeningTests: XCTestCase {
func hostExecutableURL() throws -> URL {
let fm = FileManager.default
let candidates = [
".build/debug/ReynaCLIHost",
".build/arm64-apple-macosx/debug/ReynaCLIHost",
".build/x86_64-apple-macosx/debug/ReynaCLIHost",
]
let cwd = fm.currentDirectoryPath
var tried: [String] = []
for c in candidates {
let url = URL(fileURLWithPath: cwd).appendingPathComponent(c)
tried.append(url.path)
if fm.isExecutableFile(atPath: url.path) { return url }
}
var parent = URL(fileURLWithPath: cwd)
for _ in 0..<6 {
let p1 = parent.appendingPathComponent(".build/debug/ReynaCLIHost")
tried.append(p1.path)
if fm.isExecutableFile(atPath: p1.path) { return p1 }
parent = parent.deletingLastPathComponent()
}
throw NSError(domain: "SecurityHardeningTests", code: 1, userInfo: [NSLocalizedDescriptionKey: "binary not found Tried:\n"+tried.joined(separator: "\n")])
}
func makeShortUniqueDirChecked() throws -> URL {
let fm = FileManager.default
for _ in 0..<20 {
let hex = String(format: "%08x", UInt32.random(in: 0...UInt32.max))
let url = URL(fileURLWithPath: "/tmp/rh-\(hex)")
if !fm.fileExists(atPath: url.path) { return url }
}
return URL(fileURLWithPath: "/tmp/rh-\(String(format: "%08x", UInt32.random(in: 0...UInt32.max)))")
}
final class HostProcess {
let process: Process
let socketPath: String
let tempDir: URL
init(process: Process, socketPath: String, tempDir: URL) {
self.process = process; self.socketPath = socketPath; self.tempDir = tempDir
}
func terminate() {
if process.isRunning { process.terminate() }
let deadline = Date().addingTimeInterval(2)
while process.isRunning && Date() < deadline { usleep(100_000) }
if process.isRunning { process.interrupt() }
}
deinit { terminate() }
}
func startSocketHost(socketPath: String, tempDir: URL) throws -> HostProcess {
let exe = try hostExecutableURL()
let process = Process()
process.executableURL = exe
process.arguments = ["--socket", socketPath]
let stderr = Pipe()
process.standardError = stderr
process.standardOutput = Pipe()
process.standardInput = Pipe()
try process.run()
let fm = FileManager.default
let deadline = Date().addingTimeInterval(5)
while Date() < deadline {
if fm.fileExists(atPath: socketPath) { break }
if !process.isRunning {
let data = stderr.fileHandleForReading.readDataToEndOfFile()
let s = String(data: data, encoding: .utf8) ?? ""
throw NSError(domain: "SecurityHardeningTests", code: 2, userInfo: [NSLocalizedDescriptionKey: "Host exited early. stderr: \(s)"])
}
usleep(100_000)
}
if !fm.fileExists(atPath: socketPath) {
process.terminate()
let data = stderr.fileHandleForReading.readDataToEndOfFile()
let s = String(data: data, encoding: .utf8) ?? ""
throw NSError(domain: "SecurityHardeningTests", code: 3, userInfo: [NSLocalizedDescriptionKey: "Socket not created at \(socketPath). stderr: \(s)"])
}
return HostProcess(process: process, socketPath: socketPath, tempDir: tempDir)
}
func socketRequestResponse(socketPath: String, requestLine: String, timeout: TimeInterval = 3) throws -> String {
let fd = socket(AF_UNIX, SOCK_STREAM, 0)
guard fd >= 0 else { throw NSError(domain: "SecurityHardeningTests", code: 10, userInfo: nil) }
defer { close(fd) }
var addr = sockaddr_un()
addr.sun_family = sa_family_t(AF_UNIX)
memset(&addr.sun_path, 0, MemoryLayout.size(ofValue: addr.sun_path))
_ = socketPath.withCString { cStr in
withUnsafeMutablePointer(to: &addr.sun_path) { dstPtr in
dstPtr.withMemoryRebound(to: CChar.self, capacity: 104) { charPtr in
strncpy(charPtr, cStr, 103)
}
}
}
let addrLen = socklen_t(MemoryLayout<sockaddr_un>.size)
let cr = withUnsafePointer(to: &addr) { ptr in
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { saddr in
connect(fd, saddr, addrLen)
}
}
guard cr == 0 else { throw NSError(domain: "SecurityHardeningTests", code: 11, userInfo: [NSLocalizedDescriptionKey: "connect failed: \(String(cString: strerror(errno)))"]) }
let toSend = requestLine.hasSuffix("\n") ? requestLine : requestLine + "\n"
guard let data = toSend.data(using: .utf8) else { throw NSError(domain: "SecurityHardeningTests", code: 12, userInfo: nil) }
var sent = 0
while sent < data.count {
let n = data.withUnsafeBytes { raw in send(fd, raw.baseAddress!.advanced(by: sent), data.count - sent, 0) }
if n <= 0 { throw NSError(domain: "SecurityHardeningTests", code: 13, userInfo: nil) }
sent += n
}
var responseData = Data()
var buffer = [UInt8](repeating: 0, count: 4096)
let start = Date()
while true {
if Date().timeIntervalSince(start) > timeout {
throw NSError(domain: "SecurityHardeningTests", code: 14, userInfo: [NSLocalizedDescriptionKey: "read timeout"])
}
var pfd = pollfd(fd: fd, events: Int16(POLLIN), revents: 0)
let pr = poll(&pfd, 1, 200)
if pr < 0 { if errno == EINTR { continue }; throw NSError(domain: "SecurityHardeningTests", code: 15, userInfo: nil) }
if pr == 0 { continue }
let r = recv(fd, &buffer, buffer.count, 0)
if r < 0 { if errno == EINTR { continue }; throw NSError(domain: "SecurityHardeningTests", code: 16, userInfo: nil) }
if r == 0 { break }
responseData.append(contentsOf: buffer[0..<r])
if let str = String(data: responseData, encoding: .utf8), str.contains("\n") { break }
}
guard let respString = String(data: responseData, encoding: .utf8) else { throw NSError(domain: "SecurityHardeningTests", code: 17, userInfo: nil) }
let first = respString.split(separator: "\n", omittingEmptySubsequences: false).first.map { String($0) } ?? respString
return first.trimmingCharacters(in: .whitespacesAndNewlines)
}
// MARK: - 1) peer UID pure decision
func testPeerAuthorizationPureDecision() {
let me = getuid()
let foreign: uid_t = (me == 0) ? 1 : 0
XCTAssertTrue(referenceIsPeerAuthorized(peerUID: me, currentUID: me), "own UID should be authorized")
XCTAssertFalse(referenceIsPeerAuthorized(peerUID: foreign, currentUID: me), "foreign UID should be rejected")
}
func testHostPeerAuthorizationFunctionExists() throws {
// If implementation exposes isPeerAuthorized, test it indirectly by exercising server.
// We assert current process connecting is allowed (same UID) – existing health test proves this.
// For this TDD RED, we also attempt to check source contains getpeereid.
let fm = FileManager.default
let srcURL = URL(fileURLWithPath: fm.currentDirectoryPath).appendingPathComponent("Sources/ReynaCLIHostCore/SocketServer.swift")
// Walk up
var found: URL? = nil
var cur = URL(fileURLWithPath: fm.currentDirectoryPath)
for _ in 0..<8 {
let cand = cur.appendingPathComponent("Sources/ReynaCLIHostCore/SocketServer.swift")
if fm.fileExists(atPath: cand.path) { found = cand; break }
let candOld = cur.appendingPathComponent("Sources/ReynaCLIHost/SocketServer.swift")
if fm.fileExists(atPath: candOld.path) { found = candOld; break }
let cand2 = cur.appendingPathComponent("native/ReynaCLIHost/Sources/ReynaCLIHostCore/SocketServer.swift")
if fm.fileExists(atPath: cand2.path) { found = cand2; break }
let cand2Old = cur.appendingPathComponent("native/ReynaCLIHost/Sources/ReynaCLIHost/SocketServer.swift")
if fm.fileExists(atPath: cand2Old.path) { found = cand2Old; break }
cur = cur.deletingLastPathComponent()
}
let url = found ?? srcURL
guard let content = try? String(contentsOf: url) else {
XCTFail("Could not read SocketServer.swift at \(url.path)")
return
}
XCTAssertTrue(content.contains("getpeereid") || content.contains("getpeerid"), "SocketServer.swift must call getpeereid for peer UID check")
}
// MARK: - 2) signal-handler safety
func testSignalHandlerNoUnsafeGlobals() throws {
let fm = FileManager.default
var cur = URL(fileURLWithPath: fm.currentDirectoryPath)
var srcPath: URL? = nil
for _ in 0..<8 {
let cand = cur.appendingPathComponent("Sources/ReynaCLIHostCore/SocketServer.swift")
if fm.fileExists(atPath: cand.path) { srcPath = cand; break }
let candOld = cur.appendingPathComponent("Sources/ReynaCLIHost/SocketServer.swift")
if fm.fileExists(atPath: candOld.path) { srcPath = candOld; break }
let cand2 = cur.appendingPathComponent("native/ReynaCLIHost/Sources/ReynaCLIHostCore/SocketServer.swift")
if fm.fileExists(atPath: cand2.path) { srcPath = cand2; break }
let cand2Old = cur.appendingPathComponent("native/ReynaCLIHost/Sources/ReynaCLIHost/SocketServer.swift")
if fm.fileExists(atPath: cand2Old.path) { srcPath = cand2Old; break }
cur = cur.deletingLastPathComponent()
}
guard let url = srcPath, let content = try? String(contentsOf: url) else {
XCTFail("Cannot find SocketServer.swift for signal safety check")
return
}
// No Swift mutable global storing path
XCTAssertFalse(content.contains("gSocketPathCStr"), "Should not have Swift mutable global gSocketPathCStr")
XCTAssertFalse(content.contains("nonisolated(unsafe)"), "Should not have nonisolated(unsafe) global for signal handling")
// No unsafeBitCast to sig_t
XCTAssertFalse(content.contains("unsafeBitCast") && content.contains("sig_t"), "Should not use unsafeBitCast to sig_t")
// Signal handler itself should not be Swift using stat/lstat
// Check that reynaSocketSignalHandler Swift func with lstat/stat is gone
// Allow C file to handle signals; here check that Swift file doesn't define reynaSocketSignalHandler with lstat
// This part will pass when we move handler to C target.
// Also check Package.swift contains C target
var pkgURL: URL? = nil
cur = URL(fileURLWithPath: fm.currentDirectoryPath)
for _ in 0..<8 {
let cand = cur.appendingPathComponent("Package.swift")
if fm.fileExists(atPath: cand.path) { pkgURL = cand; break }
cur = cur.deletingLastPathComponent()
}
if let purl = pkgURL, (try? String(contentsOf: purl)) != nil {
// Should contain C target for signal support OR no signal unsafe patterns above already covers
// Not failing if C target missing yet, but signal safety tests still need to show RED via earlier checks
}
}
// MARK: - 3) path validation
func testSocketPathRejectsDotDotComponents() throws {
// Host should refuse paths containing .. or . components
let fm = FileManager.default
let unique = try makeShortUniqueDirChecked()
try fm.createDirectory(at: unique, withIntermediateDirectories: true, attributes: [.posixPermissions: 0o700])
defer { try? fm.removeItem(at: unique) }
let dotDotPath = unique.appendingPathComponent("../evil.sock").path
// This contains .. – should be rejected, process exits non-zero quickly
let exe = try hostExecutableURL()
let proc = Process()
proc.executableURL = exe
proc.arguments = ["--socket", dotDotPath]
proc.standardError = Pipe()
proc.standardOutput = Pipe()
try proc.run()
let deadline = Date().addingTimeInterval(2)
while proc.isRunning && Date() < deadline { usleep(100_000) }
if proc.isRunning {
proc.terminate()
XCTFail("Host should reject path containing .. and exit, but kept running for \(dotDotPath)")
} else {
XCTAssertNotEqual(proc.terminationStatus, 0, "Should exit non-zero for .. path")
}
// Also test ./ component
let dotPath = unique.appendingPathComponent("./evil.sock").path
let proc2 = Process()
proc2.executableURL = exe
proc2.arguments = ["--socket", dotPath]
proc2.standardError = Pipe()
proc2.standardOutput = Pipe()
try proc2.run()
let deadline2 = Date().addingTimeInterval(2)
while proc2.isRunning && Date() < deadline2 { usleep(100_000) }
if proc2.isRunning {
proc2.terminate()
XCTFail("Host should reject path containing . and exit")
} else {
XCTAssertNotEqual(proc2.terminationStatus, 0, "Should exit non-zero for . path")
}
}
func testSocketParentRejectsSymlink() throws {
let fm = FileManager.default
let unique = try makeShortUniqueDirChecked()
try fm.createDirectory(at: unique, withIntermediateDirectories: true, attributes: [.posixPermissions: 0o700])
defer { try? fm.removeItem(at: unique) }
let real = unique.appendingPathComponent("real")
try fm.createDirectory(at: real, withIntermediateDirectories: false, attributes: [.posixPermissions: 0o700])
let link = unique.appendingPathComponent("linkdir")
try fm.createSymbolicLink(at: link, withDestinationURL: real)
let sockPath = link.appendingPathComponent("reyna.sock").path
let exe = try hostExecutableURL()
let proc = Process()
proc.executableURL = exe
proc.arguments = ["--socket", sockPath]
proc.standardError = Pipe()
proc.standardOutput = Pipe()
try proc.run()
let deadline = Date().addingTimeInterval(2)
while proc.isRunning && Date() < deadline { usleep(100_000) }
if proc.isRunning {
proc.terminate()
XCTFail("Host should reject symlink parent and exit")
} else {
XCTAssertNotEqual(proc.terminationStatus, 0, "Should exit non-zero when parent is symlink")
}
}
func testSocketParentRejectsWorldWritable() throws {
let fm = FileManager.default
let unique = try makeShortUniqueDirChecked()
try fm.createDirectory(at: unique, withIntermediateDirectories: true, attributes: [.posixPermissions: 0o700])
defer { try? fm.removeItem(at: unique) }
// Make parent world-writable 0777 but owned by us – should be rejected
chmod(unique.path, 0o777)
let sockPath = unique.appendingPathComponent("reyna.sock").path
let exe = try hostExecutableURL()
let proc = Process()
proc.executableURL = exe
proc.arguments = ["--socket", sockPath]
proc.standardError = Pipe()
proc.standardOutput = Pipe()
try proc.run()
let deadline = Date().addingTimeInterval(2)
while proc.isRunning && Date() < deadline { usleep(100_000) }
if proc.isRunning {
proc.terminate()
XCTFail("Host should reject world-writable dedicated parent")
} else {
XCTAssertNotEqual(proc.terminationStatus, 0, "Should exit non-zero for world-writable parent")
}
}
// MARK: - 4) recv timeout / slow client
func testSlowClientDoesNotBlockHealthClient() throws {
let fm = FileManager.default
let unique = try makeShortUniqueDirChecked()
try fm.createDirectory(at: unique, withIntermediateDirectories: true, attributes: [.posixPermissions: 0o700])
defer { try? fm.removeItem(at: unique) }
let sockPath = unique.appendingPathComponent("reyna.sock").path
let host = try startSocketHost(socketPath: sockPath, tempDir: unique)
defer { host.terminate() }
// Open slow client that connects and sends partial without newline and keeps open
let slowFd = socket(AF_UNIX, SOCK_STREAM, 0)
XCTAssertTrue(slowFd >= 0)
var addr = sockaddr_un()
addr.sun_family = sa_family_t(AF_UNIX)
memset(&addr.sun_path, 0, MemoryLayout.size(ofValue: addr.sun_path))
_ = sockPath.withCString { cStr in
withUnsafeMutablePointer(to: &addr.sun_path) { dst in
dst.withMemoryRebound(to: CChar.self, capacity: 104) { p in strncpy(p, cStr, 103) }
}
}
let len = socklen_t(MemoryLayout<sockaddr_un>.size)
let cr = withUnsafePointer(to: &addr) { ptr in
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { saddr in connect(slowFd, saddr, len) }
}
XCTAssertEqual(cr, 0, "slow client connect should succeed")
// Send incomplete data (no newline)
let partial = "{\"id\":\"slow\",\"operation\":\"service.health\""
_ = partial.withCString { cStr in send(slowFd, cStr, strlen(cStr), 0) }
// Give server a moment to be blocked in recv if vulnerable
usleep(300_000)
// Now try health client – should succeed within timeout + small margin, not blocked forever.
// Server should have a recv timeout ~5s, so this health client should succeed in < (timeout+2)s
let start = Date()
let req = #"{"id":"fast","operation":"service.health","arguments":{}}"#
var gotResponse = false
var lastError: Error? = nil
for _ in 0..<3 {
do {
let respLine = try socketRequestResponse(socketPath: sockPath, requestLine: req, timeout: 6)
if let data = respLine.data(using: .utf8),
let obj = try JSONSerialization.jsonObject(with: data) as? [String: Any],
obj["id"] as? String == "fast",
obj["ok"] as? Bool == true {
gotResponse = true
break
}
} catch {
lastError = error
usleep(200_000)
}
}
let elapsed = Date().timeIntervalSince(start)
close(slowFd)
XCTAssertTrue(gotResponse, "Fast client should succeed despite slow client; lastError: \(String(describing: lastError)) elapsed: \(elapsed)s")
XCTAssertLessThan(elapsed, 8, "Slow client should not block health client beyond timeout; elapsed \(elapsed)s")
}
// MARK: - 5) oversized-line boundary
func testOversizedLineWithNewlineInSameChunk() throws {
// This tests the bug where buf+chunk > limit and newline in most recent chunk is ignored.
// Build a valid JSON line exactly 500 bytes, then newline, then extra garbage in same TCP chunk.
// The server must accept the first line (<=64KiB) even if same recv includes bytes after newline.
let fm = FileManager.default
let unique = try makeShortUniqueDirChecked()
try fm.createDirectory(at: unique, withIntermediateDirectories: true, attributes: [.posixPermissions: 0o700])
defer { try? fm.removeItem(at: unique) }
let sockPath = unique.appendingPathComponent("reyna.sock").path
let host = try startSocketHost(socketPath: sockPath, tempDir: unique)
defer { host.terminate() }
let fd = socket(AF_UNIX, SOCK_STREAM, 0)
XCTAssertTrue(fd >= 0)
defer { close(fd) }
var addr = sockaddr_un()
addr.sun_family = sa_family_t(AF_UNIX)
memset(&addr.sun_path, 0, MemoryLayout.size(ofValue: addr.sun_path))
_ = sockPath.withCString { cStr in
withUnsafeMutablePointer(to: &addr.sun_path) { dst in
dst.withMemoryRebound(to: CChar.self, capacity: 104) { p in strncpy(p, cStr, 103) }
}
}
let addrLen = socklen_t(MemoryLayout<sockaddr_un>.size)
let cr = withUnsafePointer(to: &addr) { ptr in
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { saddr in connect(fd, saddr, addrLen) }
}
XCTAssertEqual(cr, 0)
// Build payload: first line is valid health request (<64KiB) + "\n" + extra bytes that would make total > limit if counted, but second line invalid.
// Actually to trigger bug, we need first line <=64KiB, but the chunk that contains newline also contains extra bytes making total > limit? The bug checks buf.count+n > limit before looking at newline in new chunk.
// So simulate by sending one large send that is exactly 64KiB + extra.
// We'll send a health request (~50 bytes) + "\n" + 70KiB of 'X's in ONE send call. The server reads up to 4096 at a time, but could still get newline in first recv.
// Better: send health request + "\n" + large extra, and ensure server still returns ok for first line, not payload_too_large.
let healthReq = #"{"id":"line-ok","operation":"service.health","arguments":{}}"#
let extra = String(repeating: "X", count: 70*1024)
let combined = healthReq + "\n" + extra
guard let data = combined.data(using: .utf8) else { XCTFail("encode fail"); return }
// Ignore SIGPIPE in this process to avoid signal 13 when server closes early
signal(SIGPIPE, SIG_IGN)
var sent = 0
while sent < data.count {
let n = data.withUnsafeBytes { ptr in send(fd, ptr.baseAddress!.advanced(by: sent), data.count - sent, 0) }
if n <= 0 {
if errno == EPIPE || errno == ECONNRESET { break }
break
}
sent += n
}
var responseData = Data()
var buf = [UInt8](repeating: 0, count: 4096)
let start = Date()
while true {
if Date().timeIntervalSince(start) > 3 { break }
var pfd = pollfd(fd: fd, events: Int16(POLLIN), revents: 0)
let pr = poll(&pfd, 1, 200)
if pr <= 0 { continue }
let r = recv(fd, &buf, buf.count, 0)
if r <= 0 { break }
responseData.append(contentsOf: buf[0..<r])
if let s = String(data: responseData, encoding: .utf8), s.contains("\n") { break }
}
guard let respStr = String(data: responseData, encoding: .utf8) else {
XCTFail("No utf8 response")
return
}
let firstLine = respStr.split(separator: "\n").first.map { String($0) } ?? respStr
guard let d = firstLine.data(using: .utf8),
let obj = try? JSONSerialization.jsonObject(with: d) as? [String: Any] else {
XCTFail("Response not JSON: \(firstLine)")
return
}
XCTAssertEqual(obj["id"] as? String, "line-ok", "Should preserve id of first line")
XCTAssertEqual(obj["ok"] as? Bool, true, "First line <=64KiB should be accepted even when same chunk has extra bytes after newline, got: \(obj)")
}
func testOversizedFirstLineStillRejected() throws {
let fm = FileManager.default
let unique = try makeShortUniqueDirChecked()
try fm.createDirectory(at: unique, withIntermediateDirectories: true, attributes: [.posixPermissions: 0o700])
defer { try? fm.removeItem(at: unique) }
let sockPath = unique.appendingPathComponent("reyna.sock").path
let host = try startSocketHost(socketPath: sockPath, tempDir: unique)
defer { host.terminate() }
let largeString = String(repeating: "A", count: 70*1024)
let req = #"{"id":"big","operation":"service.health","arguments":{},"data":"\#(largeString)"}"#
XCTAssertTrue(req.utf8.count > 65536)
let respLine = try socketRequestResponse(socketPath: sockPath, requestLine: req)
guard let data = respLine.data(using: .utf8),
let obj = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
XCTFail("Not JSON: \(respLine)"); return
}
XCTAssertEqual(obj["ok"] as? Bool, false)
let code = (obj["error"] as? [String: Any])?["code"] as? String ?? ""
XCTAssertTrue(code.contains("too_large") || code.contains("payload") || code.contains("invalid"), "Expected too_large code, got \(code)")
}
}
@@ -0,0 +1,332 @@
import XCTest
import Foundation
import Darwin
@testable import ReynaCLIHostCore
final class SocketPathValidationTests: XCTestCase {
// Helpers to build fake LStatInfo
private func dir(uid: uid_t, mode: mode_t, symlink: Bool = false) -> LStatInfo {
return LStatInfo(uid: uid, mode: mode_t(mode), isSymlink: symlink, isDir: !symlink, exists: true)
}
private func currentUID() -> uid_t { getuid() }
// Real default socket path: $HOME/Library/Application Support/reyna-cli/privacy/reyna-cli.sock
// Actual Mac modes from bug report:
// home /Users/adolforeyna = 0750, ~/Library = 0700, ~/Library/Application Support = 0700, privacy = 0700
// Tier 2 allows 0750 for intermediates, tier 3 requires 0700 for dedicated runtime parent.
func testRealDefaultHomeSocketPathValidationAccepts() throws {
let uid = currentUID()
let home = NSHomeDirectory() // /Users/adolforeyna
let socketPath = (home as NSString).appendingPathComponent("Library/Application Support/reyna-cli/privacy/reyna-cli.sock")
var map: [String: LStatInfo] = [:]
map["/"] = dir(uid: 0, mode: 0o40755)
map["/Users"] = dir(uid: 0, mode: 0o40755)
// helper to insert chain
func insertChain(upTo target: String, defaultMode: mode_t, overrides: [String: mode_t] = [:]) {
let url = URL(fileURLWithPath: target)
var cur = ""
for comp in url.pathComponents {
if comp == "/" { cur = "/"; continue }
if cur == "/" { cur = "/" + comp } else if cur.isEmpty { cur = comp } else { cur = cur + "/" + comp }
if map[cur] != nil { continue }
if cur == "/" || cur == "/Users" { continue }
let m = overrides[cur] ?? defaultMode
map[cur] = dir(uid: uid, mode: m)
}
}
// Home itself 0750 per actual system
map[home] = dir(uid: uid, mode: 0o40750)
// Library and subdirs 0700 except home already set
// Build full parent chain to privacy
let parentOfSocket = URL(fileURLWithPath: socketPath).deletingLastPathComponent().path
// For parent chain: home is 0750 override, others 0700
var chainCur = ""
for comp in URL(fileURLWithPath: parentOfSocket).pathComponents {
if comp == "/" { chainCur = "/"; continue }
if chainCur == "/" { chainCur = "/" + comp } else if chainCur.isEmpty { chainCur = comp } else { chainCur = chainCur + "/" + comp }
if map[chainCur] != nil { continue }
if chainCur == "/" || chainCur == "/Users" { continue }
if chainCur == home { continue } // already 0750
map[chainCur] = dir(uid: uid, mode: 0o40700)
}
let provider: LStatProvider = { path in map[path] }
XCTAssertNoThrow(try validateParentChainPure(socketPath: socketPath, currentUID: uid, provider: provider),
"Real default home with 0750 home and 0700 Library/.../privacy must validate")
XCTAssertTrue(platformTrustedRootPaths.contains("/Users"))
XCTAssertTrue(platformTrustedRootPaths.contains("/"))
}
func testRealDefaultHomeSocketPathValidationAcceptsWithHome0755() throws {
// Also allow 0755 for home (some configs) – tier 2 should still allow as no write
let uid = currentUID()
let home = NSHomeDirectory()
let socketPath = (home as NSString).appendingPathComponent("Library/Application Support/reyna-cli/privacy/reyna-cli.sock")
var map: [String: LStatInfo] = [
"/": dir(uid: 0, mode: 0o40755),
"/Users": dir(uid: 0, mode: 0o40755)
]
map[home] = dir(uid: uid, mode: 0o40755)
let parentPath = URL(fileURLWithPath: socketPath).deletingLastPathComponent().path
var cur = ""
for comp in URL(fileURLWithPath: parentPath).pathComponents {
if comp == "/" { cur = "/"; continue }
if cur == "/" { cur = "/" + comp } else if cur.isEmpty { cur = comp } else { cur = cur + "/" + comp }
if map[cur] != nil { continue }
if cur == "/" || cur == "/Users" { continue }
map[cur] = dir(uid: uid, mode: 0o40700)
}
let provider: LStatProvider = { map[$0] }
XCTAssertNoThrow(try validateParentChainPure(socketPath: socketPath, currentUID: uid, provider: provider),
"Home 0755 should also be allowed (no write)")
}
// Safe vs unsafe ancestor decision with fake stats
func testTrustedRootMustBeRootOwned() {
let uid = currentUID()
let socketPath = "/Users/\(NSUserName())/Library/reyna.sock"
var map: [String: LStatInfo] = [
"/": dir(uid: 0, mode: 0o40755),
"/Users": dir(uid: uid, mode: 0o40755), // wrong: owned by current user, should fail - /Users must be uid 0
"/Users/\(NSUserName())": dir(uid: uid, mode: 0o40700)
]
let provider: LStatProvider = { map[$0] }
XCTAssertThrowsError(try validateParentChainPure(socketPath: socketPath, currentUID: uid, provider: provider)) { err in
let msg = (err as NSError).localizedDescription
XCTAssertTrue(msg.contains("/Users") || msg.contains("current uid") || msg.contains("not owned"))
}
}
func testTrustedRootMustNotBeWorldWritable() {
let uid = currentUID()
let socketPath = "/Users/\(NSUserName())/Library/reyna.sock"
var map: [String: LStatInfo] = [
"/": dir(uid: 0, mode: 0o40755),
"/Users": dir(uid: 0, mode: 0o40777), // world writable unsafe
"/Users/\(NSUserName())": dir(uid: uid, mode: 0o40700)
]
let provider: LStatProvider = { map[$0] }
XCTAssertThrowsError(try validateParentChainPure(socketPath: socketPath, currentUID: uid, provider: provider)) { err in
XCTAssertTrue((err as NSError).code == 25 || (err as NSError).localizedDescription.contains("permissions"))
}
}
func testRejectsArbitraryRootOwnedIntermediatePath() {
// E.g. /tmp/root_owned_dir owned by root should be REJECTED because not in allowlist
let uid = currentUID()
let socketPath = "/tmp/root_owned_dir/reyna.sock"
var map: [String: LStatInfo] = [
"/": dir(uid: 0, mode: 0o40755),
// /tmp is symlink-allowed platform path
"/tmp": dir(uid: 0, mode: 0o120777, symlink: true), // symlink allowed
"/tmp/root_owned_dir": dir(uid: 0, mode: 0o40700) // root owned but not allowlisted
]
let provider: LStatProvider = { map[$0] }
XCTAssertThrowsError(try validateParentChainPure(socketPath: socketPath, currentUID: uid, provider: provider),
"Arbitrary root-owned path /tmp/root_owned_dir must be rejected – only explicit allowlist trusted")
}
func testRejectsHomeNotOwnedByCurrentUID() {
let uid = currentUID()
let socketPath = "/Users/\(NSUserName())/Library/reyna.sock"
var map: [String: LStatInfo] = [
"/": dir(uid: 0, mode: 0o40755),
"/Users": dir(uid: 0, mode: 0o40755),
"/Users/\(NSUserName())": dir(uid: 0, mode: 0o40700) // wrong owner
]
let provider: LStatProvider = { map[$0] }
XCTAssertThrowsError(try validateParentChainPure(socketPath: socketPath, currentUID: uid, provider: provider))
}
func testAllowsHome0750ForIntermediateButRejectsWritable() {
// Tier 2: intermediate ancestors allow 0750/0755, reject writable bits
let uid = currentUID()
let socketPath = "/Users/\(NSUserName())/Library/Application Support/reyna-cli/privacy/reyna.sock"
// 0750 for home should be accepted (intermediate)
var mapAllow: [String: LStatInfo] = [
"/": dir(uid: 0, mode: 0o40755),
"/Users": dir(uid: 0, mode: 0o40755),
"/Users/\(NSUserName())": dir(uid: uid, mode: 0o40750),
"/Users/\(NSUserName())/Library": dir(uid: uid, mode: 0o40700),
"/Users/\(NSUserName())/Library/Application Support": dir(uid: uid, mode: 0o40700),
"/Users/\(NSUserName())/Library/Application Support/reyna-cli": dir(uid: uid, mode: 0o40700),
"/Users/\(NSUserName())/Library/Application Support/reyna-cli/privacy": dir(uid: uid, mode: 0o40700)
]
let providerAllow: LStatProvider = { mapAllow[$0] }
XCTAssertNoThrow(try validateParentChainPure(socketPath: socketPath, currentUID: uid, provider: providerAllow),
"HOME 0750 as intermediate must be allowed (no write bits)")
// 0770 (group writable) for home must be rejected
var mapReject: [String: LStatInfo] = [
"/": dir(uid: 0, mode: 0o40755),
"/Users": dir(uid: 0, mode: 0o40755),
"/Users/\(NSUserName())": dir(uid: uid, mode: 0o40770)
]
let socketPath2 = "/Users/\(NSUserName())/Library/reyna.sock"
let providerReject: LStatProvider = { mapReject[$0] }
XCTAssertThrowsError(try validateParentChainPure(socketPath: socketPath2, currentUID: uid, provider: providerReject),
"HOME 0770 (group writable) must be rejected even for intermediate")
// 0777 world writable must be rejected
var mapReject2: [String: LStatInfo] = [
"/": dir(uid: 0, mode: 0o40755),
"/Users": dir(uid: 0, mode: 0o40755),
"/Users/\(NSUserName())": dir(uid: uid, mode: 0o40777)
]
let providerReject2: LStatProvider = { mapReject2[$0] }
XCTAssertThrowsError(try validateParentChainPure(socketPath: socketPath2, currentUID: uid, provider: providerReject2),
"HOME 0777 must be rejected")
}
func testRejectsDedicatedRuntimeParentWith0750or0755() {
let uid = currentUID()
let home = NSHomeDirectory()
let socketPath = (home as NSString).appendingPathComponent("Library/Application Support/reyna-cli/privacy/reyna-cli.sock")
// privacy dir 0750 must be rejected (tier 3 requires 0700)
var map0750: [String: LStatInfo] = [
"/": dir(uid: 0, mode: 0o40755),
"/Users": dir(uid: 0, mode: 0o40755),
]
// build chain but make privacy 0750
var cur = ""
for comp in URL(fileURLWithPath: URL(fileURLWithPath: socketPath).deletingLastPathComponent().path).pathComponents {
if comp == "/" { cur = "/"; continue }
if cur == "/" { cur = "/" + comp } else if cur.isEmpty { cur = comp } else { cur = cur + "/" + comp }
if map0750[cur] != nil { continue }
if cur == "/" || cur == "/Users" { continue }
if cur.hasSuffix("/privacy") {
map0750[cur] = dir(uid: uid, mode: 0o40750)
} else if cur == home {
map0750[cur] = dir(uid: uid, mode: 0o40750) // home 0750 allowed
} else {
map0750[cur] = dir(uid: uid, mode: 0o40700)
}
}
let provider0750: LStatProvider = { map0750[$0] }
XCTAssertThrowsError(try validateParentChainPure(socketPath: socketPath, currentUID: uid, provider: provider0750),
"Dedicated runtime parent privacy with 0750 must be rejected – requires 0700")
// 0755 also rejected
var map0755 = map0750
let privacyPath = URL(fileURLWithPath: socketPath).deletingLastPathComponent().path
map0755[privacyPath] = dir(uid: uid, mode: 0o40755)
let provider0755: LStatProvider = { map0755[$0] }
XCTAssertThrowsError(try validateParentChainPure(socketPath: socketPath, currentUID: uid, provider: provider0755),
"Dedicated runtime parent privacy with 0755 must be rejected")
}
func testAllowsIntermediate0755ButRequiresPrivacy0700() {
let uid = currentUID()
let home = NSHomeDirectory()
let socketPath = (home as NSString).appendingPathComponent("Library/Application Support/reyna-cli/privacy/reyna-cli.sock")
var map: [String: LStatInfo] = [
"/": dir(uid: 0, mode: 0o40755),
"/Users": dir(uid: 0, mode: 0o40755),
]
var cur = ""
for comp in URL(fileURLWithPath: URL(fileURLWithPath: socketPath).deletingLastPathComponent().path).pathComponents {
if comp == "/" { cur = "/"; continue }
if cur == "/" { cur = "/" + comp } else if cur.isEmpty { cur = comp } else { cur = cur + "/" + comp }
if map[cur] != nil { continue }
if cur == "/" || cur == "/Users" { continue }
if cur == home {
map[cur] = dir(uid: uid, mode: 0o40755) // home 0755 allowed as intermediate
} else if cur.hasSuffix("/privacy") == false {
// intermediate Library etc can be 0750/0755
map[cur] = dir(uid: uid, mode: 0o40750)
} else {
map[cur] = dir(uid: uid, mode: 0o40700) // privacy must be 0700
}
}
let provider: LStatProvider = { map[$0] }
XCTAssertNoThrow(try validateParentChainPure(socketPath: socketPath, currentUID: uid, provider: provider),
"Intermediate 0750/0755 allowed, privacy 0700 must validate")
}
func testRejectsSymlinkInUserChain() {
let uid = currentUID()
let socketPath = "/Users/\(NSUserName())/Library/reyna.sock"
var map: [String: LStatInfo] = [
"/": dir(uid: 0, mode: 0o40755),
"/Users": dir(uid: 0, mode: 0o40755),
"/Users/\(NSUserName())": dir(uid: uid, mode: 0o40700),
"/Users/\(NSUserName())/Library": dir(uid: uid, mode: 0o120777, symlink: true)
]
let provider: LStatProvider = { map[$0] }
XCTAssertThrowsError(try validateParentChainPure(socketPath: socketPath, currentUID: uid, provider: provider),
"Symlink in user chain must be rejected")
}
func testAllowsPlatformSymlinksTmpVar() {
let uid = currentUID()
let socketPath = "/tmp/rh-test/reyna.sock"
var map: [String: LStatInfo] = [
"/": dir(uid: 0, mode: 0o40755),
"/private": dir(uid: 0, mode: 0o40755),
"/private/tmp": dir(uid: 0, mode: 0o41777), // /private/tmp typically 1777
"/tmp": dir(uid: 0, mode: 0o120777, symlink: true), // allowed symlink
"/tmp/rh-test": dir(uid: uid, mode: 0o40700)
]
let provider: LStatProvider = { map[$0] }
XCTAssertNoThrow(try validateParentChainPure(socketPath: socketPath, currentUID: uid, provider: provider))
}
func testRejectsDotDot() {
let uid = currentUID()
let socketPath = "/tmp/rh/../evil.sock"
var map: [String: LStatInfo] = [:]
let provider: LStatProvider = { map[$0] }
XCTAssertThrowsError(try validateParentChainPure(socketPath: socketPath, currentUID: uid, provider: provider))
}
func testNoBroadGlobalShortcutOnlyAllowlist() {
// Ensure implementation does not accept every root-owned path.
// For path /opt/rootdir where /opt is root-owned (simulating arbitrary root path), it must be rejected unless explicitly allowlisted.
let uid = currentUID()
let socketPath = "/opt/rootdir/reyna.sock"
var map: [String: LStatInfo] = [
"/": dir(uid: 0, mode: 0o40755),
"/opt": dir(uid: 0, mode: 0o40755), // root owned, not allowlisted
"/opt/rootdir": dir(uid: uid, mode: 0o40700)
]
let provider: LStatProvider = { map[$0] }
XCTAssertThrowsError(try validateParentChainPure(socketPath: socketPath, currentUID: uid, provider: provider),
"Must not accept arbitrary root-owned /opt")
}
func testAllowsPrivateVarChain() {
// macOS: /var -> /private/var, /private/var/tmp etc are platform trusted
let uid = currentUID()
let socketPath = "/private/var/tmp/rh-test/reyna.sock"
var map: [String: LStatInfo] = [
"/": dir(uid: 0, mode: 0o40755),
"/private": dir(uid: 0, mode: 0o40755),
"/private/var": dir(uid: 0, mode: 0o40755),
"/private/var/tmp": dir(uid: 0, mode: 0o41777),
"/private/var/tmp/rh-test": dir(uid: uid, mode: 0o40700)
]
let provider: LStatProvider = { map[$0] }
XCTAssertNoThrow(try validateParentChainPure(socketPath: socketPath, currentUID: uid, provider: provider))
}
func testRejectsGroupWritableIntermediate() {
let uid = currentUID()
let socketPath = "/Users/\(NSUserName())/Library/Application Support/reyna.sock"
var map: [String: LStatInfo] = [
"/": dir(uid: 0, mode: 0o40755),
"/Users": dir(uid: 0, mode: 0o40755),
"/Users/\(NSUserName())": dir(uid: uid, mode: 0o40755),
"/Users/\(NSUserName())/Library": dir(uid: uid, mode: 0o40770),
"/Users/\(NSUserName())/Library/Application Support": dir(uid: uid, mode: 0o40700)
]
let provider: LStatProvider = { map[$0] }
XCTAssertThrowsError(try validateParentChainPure(socketPath: socketPath, currentUID: uid, provider: provider),
"Intermediate Library 0770 group writable must be rejected")
}
}
@@ -0,0 +1,373 @@
import XCTest
import Foundation
/// TDD tests for Unix-domain-socket server mode (--socket <path>).
/// These start the compiled executable in a temp directory using real AF_UNIX sockets.
final class SocketServerTests: XCTestCase {
// MARK: - Helpers
func hostExecutableURL() throws -> URL {
let fm = FileManager.default
// Portable candidates only: package-relative .build locations for common triples.
let candidates: [String] = [
".build/debug/ReynaCLIHost",
".build/arm64-apple-macosx/debug/ReynaCLIHost",
".build/x86_64-apple-macosx/debug/ReynaCLIHost",
]
let cwd = fm.currentDirectoryPath
var tried: [String] = []
for c in candidates {
let url = URL(fileURLWithPath: cwd).appendingPathComponent(c)
tried.append(url.path)
if fm.isExecutableFile(atPath: url.path) { return url }
}
var parent = URL(fileURLWithPath: cwd)
for _ in 0..<6 {
let p1 = parent.appendingPathComponent(".build/debug/ReynaCLIHost")
tried.append(p1.path)
if fm.isExecutableFile(atPath: p1.path) { return p1 }
let p2 = parent.appendingPathComponent(".build/arm64-apple-macosx/debug/ReynaCLIHost")
tried.append(p2.path)
if fm.isExecutableFile(atPath: p2.path) { return p2 }
parent = parent.deletingLastPathComponent()
}
throw NSError(domain: "SocketServerTests", code: 1, userInfo: [NSLocalizedDescriptionKey: "ReynaCLIHost binary not found. Tried:\n" + tried.joined(separator: "\n")])
}
/// Short unique /tmp path to stay under sockaddr_un.sun_path 104-byte limit.
/// e.g. /tmp/rh-a1b2c3d4
func makeShortUniqueDirChecked() throws -> URL {
let fm = FileManager.default
for _ in 0..<20 {
let hex = String(format: "%08x", UInt32.random(in: 0...UInt32.max))
let url = URL(fileURLWithPath: "/tmp/rh-\(hex)")
if !fm.fileExists(atPath: url.path) {
return url
}
}
return URL(fileURLWithPath: "/tmp/rh-\(String(format: "%08x", UInt32.random(in: 0...UInt32.max)))")
}
final class HostProcess {
let process: Process
let socketPath: String
let tempDir: URL
init(process: Process, socketPath: String, tempDir: URL) {
self.process = process
self.socketPath = socketPath
self.tempDir = tempDir
}
func terminate() {
if process.isRunning { process.terminate() }
// Give time to cleanup
let deadline = Date().addingTimeInterval(2)
while process.isRunning && Date() < deadline { usleep(100_000) }
if process.isRunning { process.interrupt() }
}
deinit { terminate() }
}
func startSocketHost(socketPath: String, tempDir: URL) throws -> HostProcess {
let exe = try hostExecutableURL()
let process = Process()
process.executableURL = exe
process.arguments = ["--socket", socketPath]
let stderr = Pipe()
process.standardError = stderr
process.standardOutput = Pipe()
process.standardInput = Pipe() // keep open, not used
try process.run()
// Wait for socket to appear (max 5s)
let fm = FileManager.default
let deadline = Date().addingTimeInterval(5)
while Date() < deadline {
if fm.fileExists(atPath: socketPath) { break }
if !process.isRunning {
let data = stderr.fileHandleForReading.readDataToEndOfFile()
let s = String(data: data, encoding: .utf8) ?? ""
throw NSError(domain: "SocketServerTests", code: 2, userInfo: [NSLocalizedDescriptionKey: "Host exited early. stderr: \(s)"])
}
// Do not call FileHandle.availableData here: it blocks while a healthy,
// silent child keeps stderr open. Poll the socket path instead.
usleep(100_000)
}
if !fm.fileExists(atPath: socketPath) {
process.terminate()
let data = stderr.fileHandleForReading.readDataToEndOfFile()
let s = String(data: data, encoding: .utf8) ?? ""
throw NSError(domain: "SocketServerTests", code: 3, userInfo: [NSLocalizedDescriptionKey: "Socket not created at \(socketPath) after timeout. stderr: \(s)"])
}
return HostProcess(process: process, socketPath: socketPath, tempDir: tempDir)
}
// Low-level socket client: connect, send line, read one line response with timeout
func socketRequestResponse(socketPath: String, requestLine: String, timeout: TimeInterval = 3) throws -> String {
let fd = socket(AF_UNIX, SOCK_STREAM, 0)
guard fd >= 0 else { throw NSError(domain: "SocketServerTests", code: 10, userInfo: [NSLocalizedDescriptionKey: "socket() failed: \(String(cString: strerror(errno)))"]) }
defer { close(fd) }
var addr = sockaddr_un()
addr.sun_family = sa_family_t(AF_UNIX)
let pathBytes = socketPath.utf8
guard pathBytes.count < MemoryLayout.size(ofValue: addr.sun_path) else {
throw NSError(domain: "SocketServerTests", code: 11, userInfo: [NSLocalizedDescriptionKey: "Socket path too long"])
}
memset(&addr.sun_path, 0, MemoryLayout.size(ofValue: addr.sun_path))
_ = socketPath.withCString { cStr in
withUnsafeMutablePointer(to: &addr.sun_path) { dstPtr in
dstPtr.withMemoryRebound(to: CChar.self, capacity: 104) { charPtr in
strncpy(charPtr, cStr, 103)
}
}
}
let addrLen = socklen_t(MemoryLayout<sockaddr_un>.size)
let connectResult = withUnsafePointer(to: &addr) { ptr in
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { saddr in
connect(fd, saddr, addrLen)
}
}
guard connectResult == 0 else {
throw NSError(domain: "SocketServerTests", code: 12, userInfo: [NSLocalizedDescriptionKey: "connect() failed to \(socketPath): \(String(cString: strerror(errno)))"])
}
let toSend = requestLine.hasSuffix("\n") ? requestLine : requestLine + "\n"
guard let data = toSend.data(using: .utf8) else { throw NSError(domain: "SocketServerTests", code: 13, userInfo: [NSLocalizedDescriptionKey: "UTF8 encode fail"]) }
var sent = 0
while sent < data.count {
let n = data.withUnsafeBytes { rawBuf in
send(fd, rawBuf.baseAddress!.advanced(by: sent), data.count - sent, 0)
}
if n <= 0 { throw NSError(domain: "SocketServerTests", code: 14, userInfo: [NSLocalizedDescriptionKey: "send() failed: \(String(cString: strerror(errno)))"]) }
sent += n
}
var responseData = Data()
var buffer = [UInt8](repeating: 0, count: 4096)
let start = Date()
while true {
if Date().timeIntervalSince(start) > timeout {
let partial = String(data: responseData, encoding: .utf8) ?? "<binary>"
throw NSError(domain: "SocketServerTests", code: 15, userInfo: [NSLocalizedDescriptionKey: "socket read timeout, partial: \(partial)"])
}
var pfd = pollfd(fd: fd, events: Int16(POLLIN), revents: 0)
let pr = poll(&pfd, 1, 200) // 200ms
if pr < 0 {
if errno == EINTR { continue }
throw NSError(domain: "SocketServerTests", code: 16, userInfo: [NSLocalizedDescriptionKey: "poll failed: \(String(cString: strerror(errno)))"])
}
if pr == 0 { continue }
let r = recv(fd, &buffer, buffer.count, 0)
if r < 0 {
if errno == EINTR { continue }
throw NSError(domain: "SocketServerTests", code: 17, userInfo: [NSLocalizedDescriptionKey: "recv failed: \(String(cString: strerror(errno)))"])
}
if r == 0 { break }
responseData.append(contentsOf: buffer[0..<r])
if let str = String(data: responseData, encoding: .utf8), str.contains("\n") {
break
}
}
guard let respString = String(data: responseData, encoding: .utf8) else {
throw NSError(domain: "SocketServerTests", code: 18, userInfo: [NSLocalizedDescriptionKey: "response not utf8"])
}
let firstLine = respString.split(separator: "\n", omittingEmptySubsequences: false).first.map { String($0) } ?? respString
return firstLine.trimmingCharacters(in: .whitespacesAndNewlines)
}
func decode(_ line: String) throws -> [String: Any] {
guard let data = line.data(using: .utf8),
let obj = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
throw NSError(domain: "SocketServerTests", code: 20, userInfo: [NSLocalizedDescriptionKey: "Not JSON: \(line)"])
}
return obj
}
// MARK: - Tests (TDD RED first)
func testSocketHealthRequest() throws {
// Prove that --socket mode health request works via real Unix socket.
let fm = FileManager.default
let unique = try makeShortUniqueDirChecked()
try fm.createDirectory(at: unique, withIntermediateDirectories: true, attributes: [.posixPermissions: 0o700])
defer { try? fm.removeItem(at: unique) }
let sockPath = unique.appendingPathComponent("reyna.sock").path
let host = try startSocketHost(socketPath: sockPath, tempDir: unique)
defer { host.terminate(); try? fm.removeItem(atPath: sockPath) }
let req = #"{"id":"sock-1","operation":"service.health","arguments":{}}"#
let respLine = try socketRequestResponse(socketPath: sockPath, requestLine: req)
let resp = try decode(respLine)
XCTAssertEqual(resp["id"] as? String, "sock-1")
XCTAssertEqual(resp["ok"] as? Bool, true)
if let result = resp["result"] as? [String: Any] {
XCTAssertEqual(result["operation"] as? String, "service.health")
XCTAssertFalse((result["protocol_version"] as? String ?? "").isEmpty)
} else {
XCTFail("Missing result: \(resp)")
}
}
func testSocketPermissionsAndParentCreation() throws {
// Prove socket file mode 0600 and parent dir 0700, and auto-create parent.
let fm = FileManager.default
let unique = try makeShortUniqueDirChecked()
// Do NOT create unique; let child subdir also not exist, testing parent creation
let nestedParent = unique.appendingPathComponent("a/b/c")
let sockPath = nestedParent.appendingPathComponent("reyna.sock").path
// Ensure base exists for cleanup tracking but not nested
try fm.createDirectory(at: unique, withIntermediateDirectories: true, attributes: [.posixPermissions: 0o700])
defer { try? fm.removeItem(at: unique) }
let host = try startSocketHost(socketPath: sockPath, tempDir: unique)
defer { host.terminate() }
var isDir: ObjCBool = false
XCTAssertTrue(fm.fileExists(atPath: nestedParent.path, isDirectory: &isDir))
XCTAssertTrue(isDir.boolValue)
let attrs = try fm.attributesOfItem(atPath: nestedParent.path)
if let posix = attrs[.posixPermissions] as? NSNumber {
let perms = posix.uint16Value & 0o777
XCTAssertEqual(perms, 0o700, "Parent dir should be 0700, got \(String(perms, radix: 8))")
} else {
XCTFail("Could not get posixPermissions for parent")
}
// Check socket file mode 0600 and type socket
let sockAttrs = try fm.attributesOfItem(atPath: sockPath)
if let posix = sockAttrs[.posixPermissions] as? NSNumber {
let perms = posix.uint16Value & 0o777
XCTAssertEqual(perms, 0o600, "Socket file should be 0600, got \(String(perms, radix: 8))")
} else {
XCTFail("Could not get posixPermissions for socket")
}
// Verify it's a socket using lstat mode check
var st = stat()
XCTAssertEqual(lstat(sockPath, &st), 0, "lstat should succeed")
XCTAssertTrue((st.st_mode & S_IFMT) == S_IFSOCK, "File should be a socket")
// Also ensure owned by current uid
XCTAssertEqual(st.st_uid, getuid(), "Socket should be owned by current uid")
}
func testSocketMalformedRequest() throws {
let fm = FileManager.default
let unique = try makeShortUniqueDirChecked()
try fm.createDirectory(at: unique, withIntermediateDirectories: true, attributes: [.posixPermissions: 0o700])
defer { try? fm.removeItem(at: unique) }
let sockPath = unique.appendingPathComponent("reyna.sock").path
let host = try startSocketHost(socketPath: sockPath, tempDir: unique)
defer { host.terminate() }
// Send malformed JSON
let respLine = try socketRequestResponse(socketPath: sockPath, requestLine: "not json at all")
let resp = try decode(respLine)
XCTAssertEqual(resp["ok"] as? Bool, false)
if let err = resp["error"] as? [String: Any] {
XCTAssertEqual(err["code"] as? String, "invalid_request")
} else {
XCTFail("Missing error, got \(resp)")
}
// id should be empty when unrecoverable
XCTAssertEqual(resp["id"] as? String, "")
// Send malformed but with id field to test preservation
let respLine2 = try socketRequestResponse(socketPath: sockPath, requestLine: #"{"id":"keep-me","operation":}"#)
let resp2 = try decode(respLine2)
XCTAssertEqual(resp2["ok"] as? Bool, false)
XCTAssertEqual(resp2["id"] as? String, "keep-me")
if let err = resp2["error"] as? [String: Any] {
XCTAssertEqual(err["code"] as? String, "invalid_request")
} else {
XCTFail("Missing error for second malformed")
}
}
func testSocketOversizedRequestBeyond64KiB() throws {
let fm = FileManager.default
let unique = try makeShortUniqueDirChecked()
try fm.createDirectory(at: unique, withIntermediateDirectories: true, attributes: [.posixPermissions: 0o700])
defer { try? fm.removeItem(at: unique) }
let sockPath = unique.appendingPathComponent("reyna.sock").path
let host = try startSocketHost(socketPath: sockPath, tempDir: unique)
defer { host.terminate() }
// Create payload > 64KiB
let largeString = String(repeating: "A", count: 70*1024)
let req = #"{"id":"big","operation":"service.health","arguments":{},"data":"\#(largeString)"}"#
// Must be > 65536 bytes
XCTAssertTrue(req.utf8.count > 65536)
let respLine = try socketRequestResponse(socketPath: sockPath, requestLine: req)
let resp = try decode(respLine)
XCTAssertEqual(resp["ok"] as? Bool, false, "Oversized should be rejected")
if let err = resp["error"] as? [String: Any] {
let code = err["code"] as? String ?? ""
XCTAssertTrue(code == "invalid_request" || code == "payload_too_large" || code == "request_too_large" || code.contains("too_large") || code.contains("invalid"), "Unexpected error code for oversized: \(code)")
} else {
XCTFail("Missing error for oversized: \(resp)")
}
}
func testSocketCleanupOnTermination() throws {
let fm = FileManager.default
let unique = try makeShortUniqueDirChecked()
try fm.createDirectory(at: unique, withIntermediateDirectories: true, attributes: [.posixPermissions: 0o700])
defer { try? fm.removeItem(at: unique) }
let sockPath = unique.appendingPathComponent("reyna.sock").path
var maybeHost: HostProcess? = try startSocketHost(socketPath: sockPath, tempDir: unique)
XCTAssertTrue(fm.fileExists(atPath: sockPath), "Socket should exist while host running")
maybeHost?.terminate()
maybeHost = nil
// Wait a bit for cleanup
let deadline = Date().addingTimeInterval(3)
while fm.fileExists(atPath: sockPath) && Date() < deadline { usleep(100_000) }
XCTAssertFalse(fm.fileExists(atPath: sockPath), "Socket file should be removed on SIGTERM cleanup")
}
func testSocketRefusesNonSocketExistingFile() throws {
// If path exists and is regular file owned by uid, should refuse (not unlink unsafe)
let fm = FileManager.default
let unique = try makeShortUniqueDirChecked()
try fm.createDirectory(at: unique, withIntermediateDirectories: true, attributes: [.posixPermissions: 0o700])
defer { try? fm.removeItem(at: unique) }
let sockPath = unique.appendingPathComponent("reyna.sock").path
// Create regular file there
fm.createFile(atPath: sockPath, contents: Data("hello".utf8))
defer { try? fm.removeItem(atPath: sockPath) }
// Try start - should fail quickly (exit)
let exe = try hostExecutableURL()
let process = Process()
process.executableURL = exe
process.arguments = ["--socket", sockPath]
let stderr = Pipe()
process.standardError = stderr
process.standardOutput = Pipe()
try process.run()
let deadline = Date().addingTimeInterval(3)
while process.isRunning && Date() < deadline { usleep(100_000) }
// Process should have exited with error, not be running and not have created socket replacing file
var isSocket = false
var st = stat()
if lstat(sockPath, &st) == 0 {
isSocket = (st.st_mode & S_IFMT) == S_IFSOCK
}
XCTAssertFalse(isSocket, "Should not have replaced regular file with socket")
// If process still running, terminate and fail
if process.isRunning {
process.terminate()
XCTFail("Host should refuse to overwrite regular file and exit, but it is still running")
} else {
// Should exit non-zero
XCTAssertNotEqual(process.terminationStatus, 0, "Should exit non-zero when refusing non-socket file")
}
}
}