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

150 lines
6.9 KiB
Swift

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"])
}
}