9fd04b0ce4
Add the owner-only AF_UNIX Reyna CLI privacy host, strict signed-app installation, and typed native routing for Calendar, Contacts, and Reminders.\n\nAdd bounded system-status paths and config-only direct local-service wrappers. Preserve MacMiniMCP pending explicit cutover approval.\n\nApple Notes is intentionally deferred: no native Notes operations, Apple Events declaration, or Automation helper are included; legacy Notes handling remains untouched.
176 lines
8.9 KiB
Swift
176 lines
8.9 KiB
Swift
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")
|
||
}
|
||
}
|
||
}
|