Files
Adolfo Reyna 9fd04b0ce4 feat(privacy-host): add signed native calendar contacts and reminders host
Add the owner-only AF_UNIX Reyna CLI privacy host, strict signed-app installation, and typed native routing for Calendar, Contacts, and Reminders.\n\nAdd bounded system-status paths and config-only direct local-service wrappers. Preserve MacMiniMCP pending explicit cutover approval.\n\nApple Notes is intentionally deferred: no native Notes operations, Apple Events declaration, or Automation helper are included; legacy Notes handling remains untouched.
2026-08-03 20:27:54 -04:00

176 lines
8.9 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import XCTest
@testable import ReynaCLIHostCore
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")
}
}
}