feat(privacy-host): add signed native calendar contacts and reminders host
Add the owner-only AF_UNIX Reyna CLI privacy host, strict signed-app installation, and typed native routing for Calendar, Contacts, and Reminders.\n\nAdd bounded system-status paths and config-only direct local-service wrappers. Preserve MacMiniMCP pending explicit cutover approval.\n\nApple Notes is intentionally deferred: no native Notes operations, Apple Events declaration, or Automation helper are included; legacy Notes handling remains untouched.
This commit is contained in:
@@ -0,0 +1,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")
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user