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,774 @@
|
||||
"""Tests for Reyna CLI.app bundle builder — deterministic layout, Xcode-owned signing, fail-closed."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import plistlib
|
||||
import stat
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _mock_proc(rc=0, stdout="", stderr=""):
|
||||
from types import SimpleNamespace
|
||||
|
||||
return SimpleNamespace(returncode=rc, stdout=stdout, stderr=stderr)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Bundle layout & deterministic plist
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_app_bundle_paths_deterministic_repo_local():
|
||||
from reyna_cli import app_bundle as ab
|
||||
|
||||
assert ab.BUNDLE_IDENTIFIER == "com.reyna.cli.privacy-host"
|
||||
assert ab.APP_BUNDLE_NAME == "Reyna CLI.app"
|
||||
assert ab.APP_EXECUTABLE_NAME == "ReynaCLIHost"
|
||||
|
||||
bundle = ab.app_bundle_path()
|
||||
assert "dist" in str(bundle)
|
||||
assert "Reyna CLI.app" in str(bundle)
|
||||
assert bundle.name == "Reyna CLI.app"
|
||||
assert bundle.parent.name == "dist"
|
||||
assert bundle.parent.parent.name == "ReynaCLIHost"
|
||||
|
||||
|
||||
def test_build_app_bundle_info_plist_deterministic():
|
||||
from reyna_cli import app_bundle as ab
|
||||
|
||||
d1 = ab.build_app_bundle_info_plist_dict()
|
||||
d2 = ab.build_app_bundle_info_plist_dict()
|
||||
|
||||
assert d1 == d2, "plist dict must be deterministic"
|
||||
assert d1["CFBundleIdentifier"] == "com.reyna.cli.privacy-host"
|
||||
assert d1["CFBundleExecutable"] == "ReynaCLIHost"
|
||||
assert d1["CFBundleName"] == "Reyna CLI"
|
||||
assert d1["CFBundleDisplayName"] == "Reyna CLI"
|
||||
assert d1["CFBundlePackageType"] == "APPL"
|
||||
assert "NSCalendarsFullAccessUsageDescription" in d1
|
||||
assert "NSContactsUsageDescription" in d1
|
||||
assert "NSRemindersFullAccessUsageDescription" in d1
|
||||
assert "NSAppleEventsUsageDescription" not in d1, "Notes deferred — AppleEvents must be forbidden"
|
||||
forbidden = [
|
||||
"NSRemindersUsageDescription",
|
||||
"NSAppleMusicUsageDescription",
|
||||
"NSNotesUsageDescription",
|
||||
"NSMailUsageDescription",
|
||||
"NSAppleEventsUsageDescription",
|
||||
]
|
||||
for k in forbidden:
|
||||
assert k not in d1, f"forbidden key {k} present"
|
||||
|
||||
data = plistlib.dumps(d1, sort_keys=True)
|
||||
loaded = plistlib.loads(data)
|
||||
assert loaded == d1
|
||||
|
||||
|
||||
def test_app_bundle_info_plist_allows_write_only_optional_but_not_forbidden():
|
||||
from reyna_cli import app_bundle as ab
|
||||
|
||||
d = ab.build_app_bundle_info_plist_dict()
|
||||
assert "NSCalendarsFullAccessUsageDescription" in d
|
||||
assert "NSContactsUsageDescription" in d
|
||||
assert "NSRemindersFullAccessUsageDescription" in d
|
||||
assert "NSAppleEventsUsageDescription" not in d, "AppleEvents forbidden — Notes deferred"
|
||||
for key in d.keys():
|
||||
if "UsageDescription" in key and key.startswith("NS"):
|
||||
assert key in {
|
||||
"NSCalendarsFullAccessUsageDescription",
|
||||
"NSCalendarsWriteOnlyAccessUsageDescription",
|
||||
"NSCalendarsUsageDescription",
|
||||
"NSContactsUsageDescription",
|
||||
"NSRemindersFullAccessUsageDescription",
|
||||
}, f"unexpected usage description {key}"
|
||||
|
||||
|
||||
def test_app_bundle_info_plist_no_contacts_reminders_notes_mail():
|
||||
from reyna_cli import app_bundle as ab
|
||||
|
||||
d = ab.build_app_bundle_info_plist_dict()
|
||||
# Only Calendar, Contacts, Reminders allowed. Notes/Mail/AppleEvents must be rejected (Notes deferred)
|
||||
for bad in ["Notes", "Mail", "AppleEvents"]:
|
||||
for k in d.keys():
|
||||
if bad.lower() in k.lower() and "UsageDescription" in k:
|
||||
raise AssertionError(f"bundle plist contains forbidden domain {bad} via {k}")
|
||||
for k in d.keys():
|
||||
low = k.lower()
|
||||
if "notesusage" in low or "mailusage" in low or "appleeventsusage" in low:
|
||||
raise AssertionError(f"forbidden domain key {k}")
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Xcode-owned signing – no manual codesign --sign
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_build_xcodebuild_command_static_contract():
|
||||
from reyna_cli import app_bundle as ab
|
||||
import tempfile
|
||||
|
||||
repo_root = Path(tempfile.mkdtemp()) / "repo"
|
||||
(repo_root / "native" / "ReynaCLIHost" / "ReynaCLIHost.xcodeproj").mkdir(parents=True)
|
||||
(repo_root / "native" / "ReynaCLIHost" / "ReynaCLIHost").mkdir(parents=True, exist_ok=True)
|
||||
(repo_root / "native" / "ReynaCLIHost" / "ReynaCLIHost" / "Info.plist").write_text("<plist></plist>")
|
||||
derived = repo_root / "custom" / "DerivedData"
|
||||
|
||||
cmd = ab.build_xcodebuild_command(repo_root=repo_root, derived_data_path=derived, disable_code_signing=False)
|
||||
assert isinstance(cmd, list)
|
||||
assert cmd[0] == "xcodebuild"
|
||||
# No shell
|
||||
assert all(isinstance(x, str) for x in cmd)
|
||||
# Must reference project, scheme, configuration, derivedDataPath, build verb
|
||||
assert "-project" in cmd
|
||||
proj_idx = cmd.index("-project")
|
||||
assert str(repo_root / "native" / "ReynaCLIHost" / "ReynaCLIHost.xcodeproj") == cmd[proj_idx + 1]
|
||||
assert "-scheme" in cmd
|
||||
assert "Reyna CLI" in cmd
|
||||
assert "-target" not in cmd, "must not use -target when using -derivedDataPath (RC 64)"
|
||||
assert "-configuration" in cmd
|
||||
assert "Release" in cmd
|
||||
assert "-derivedDataPath" in cmd
|
||||
dd_idx = cmd.index("-derivedDataPath")
|
||||
assert cmd[dd_idx + 1] == str(derived)
|
||||
assert "build" in cmd
|
||||
# Must NOT contain manual codesign signing command
|
||||
assert "--sign" not in cmd
|
||||
assert "CODE_SIGNING_ALLOWED=NO" not in cmd
|
||||
|
||||
cmd_unsigned = ab.build_xcodebuild_command(
|
||||
repo_root=repo_root, derived_data_path=derived, disable_code_signing=True
|
||||
)
|
||||
assert "CODE_SIGNING_ALLOWED=NO" in cmd_unsigned
|
||||
assert "--sign" not in cmd_unsigned
|
||||
|
||||
|
||||
def test_build_xcodebuild_command_no_manual_codesign_in_source():
|
||||
from reyna_cli import app_bundle as ab
|
||||
|
||||
src = Path(ab.__file__).read_text()
|
||||
# Source must NOT contain codesign --sign manual invocation (Xcode owns signing)
|
||||
# Allow comments about codesign --verify but not --sign as command construction
|
||||
lines = [l for l in src.splitlines() if "codesign" in l.lower() and "--sign" in l]
|
||||
# Only allowed if inside comment about not doing manual sign, not as actual command list
|
||||
for line in lines:
|
||||
stripped = line.strip()
|
||||
if stripped.startswith("#") or stripped.startswith('"""') or stripped.startswith("'''"):
|
||||
continue
|
||||
# If we ever build ["codesign", "--force", ... "--sign"] that would be violation
|
||||
# Our module only uses codesign --verify and -dv for validation
|
||||
if '"codesign"' in line or "'codesign'" in line or '["codesign"' in line:
|
||||
assert "--sign" not in line or "verify" in line.lower(), f"manual codesign --sign found: {line}"
|
||||
# Strong check: no list containing both codesign and --sign for forced signing
|
||||
assert '["codesign", "--force"' not in src, "manual codesign --sign forbidden; Xcode owns signing"
|
||||
assert src.count('"--sign"') == 0 or 'codesign' not in src.split('"--sign"')[0][-200:].lower() or True
|
||||
# Final guard: validate no manual signing identity handling that invokes codesign --sign
|
||||
# Searching for pattern codesign.*--sign in code (not in comments) — we already checked above
|
||||
|
||||
|
||||
def test_app_bundle_no_manual_codesign_sign_invocation():
|
||||
from reyna_cli import app_bundle as ab
|
||||
|
||||
src = Path(ab.__file__).read_text()
|
||||
# Must not have ["codesign", "--force", "--options", "runtime", "--timestamp", "--sign", identity
|
||||
# The old builder used this; new builder must not
|
||||
assert "codesign" in src.lower() # verify still allowed
|
||||
# Ensure we never build a codesign --sign command array
|
||||
forbidden_snippets = [
|
||||
'"--force",\n "--options",\n "runtime"',
|
||||
'sign_cmd = [',
|
||||
]
|
||||
for snippet in forbidden_snippets:
|
||||
if snippet in src:
|
||||
# If present, ensure it's not constructing --sign command
|
||||
ctx = src[src.index(snippet) - 200 : src.index(snippet) + 400] if snippet in src else ""
|
||||
assert "--sign" not in ctx or "verify" in ctx.lower(), f"found manual sign cmd near {snippet}: {ctx[:500]}"
|
||||
|
||||
|
||||
def test_build_app_bundle_uses_xcodebuild_and_copies_product(tmp_path):
|
||||
from reyna_cli import app_bundle as ab
|
||||
|
||||
repo_root = tmp_path / "repo"
|
||||
pkg_dir = repo_root / "native" / "ReynaCLIHost"
|
||||
pkg_dir.mkdir(parents=True)
|
||||
# Create required xcodeproj dir and info plist source
|
||||
(pkg_dir / "ReynaCLIHost.xcodeproj").mkdir(parents=True)
|
||||
(pkg_dir / "ReynaCLIHost.xcodeproj" / "project.pbxproj").write_text("// dummy")
|
||||
(pkg_dir / "ReynaCLIHost").mkdir(parents=True, exist_ok=True)
|
||||
(pkg_dir / "ReynaCLIHost" / "Info.plist").write_text('<?xml version="1.0"?><plist><dict></dict></plist>')
|
||||
|
||||
derived = repo_root / "native" / "ReynaCLIHost" / "build" / "DerivedData"
|
||||
built_app = derived / "Build" / "Products" / "Release" / "Reyna CLI.app"
|
||||
(built_app / "Contents" / "MacOS").mkdir(parents=True)
|
||||
(built_app / "Contents" / "MacOS" / "ReynaCLIHost").write_bytes(b"fakebinarycontent")
|
||||
with open(built_app / "Contents" / "Info.plist", "wb") as f:
|
||||
plistlib.dump(ab.build_app_bundle_info_plist_dict(), f)
|
||||
|
||||
calls = []
|
||||
|
||||
class Proc:
|
||||
def __init__(self, rc=0, stdout="", stderr=""):
|
||||
self.returncode = rc
|
||||
self.stdout = stdout
|
||||
self.stderr = stderr
|
||||
|
||||
def runner(args, cwd=None, **kwargs):
|
||||
assert isinstance(args, list), "must use arg array"
|
||||
calls.append(list(args))
|
||||
if args and args[0] == "xcodebuild":
|
||||
# simulate successful build - ensure product already exists
|
||||
return Proc(rc=0, stdout="BUILD SUCCEEDED", stderr="")
|
||||
if args[:3] == ["codesign", "--verify", "--deep"]:
|
||||
return Proc(rc=0)
|
||||
if args[:2] == ["codesign", "-dv"]:
|
||||
return Proc(rc=0, stdout="", stderr="TeamIdentifier=TEAM123\nAuthority=Apple Development: Foo (TEAM123)\n")
|
||||
return Proc(rc=0)
|
||||
|
||||
result = ab.build_app_bundle(repo_root=repo_root, runner=runner, disable_code_signing=False)
|
||||
|
||||
assert result["ok"] is True
|
||||
assert "xcodebuild" in str(result.get("build_command", [])).lower() or any(c[0] == "xcodebuild" for c in calls)
|
||||
# Ensure xcodebuild invocation used safe arg array with project/scheme/derivedDataPath
|
||||
xb_calls = [c for c in calls if c and c[0] == "xcodebuild"]
|
||||
assert len(xb_calls) == 1
|
||||
xb = xb_calls[0]
|
||||
assert "-project" in xb
|
||||
assert "-scheme" in xb
|
||||
assert "-target" not in xb
|
||||
assert "Reyna CLI" in xb
|
||||
assert "-derivedDataPath" in xb
|
||||
assert "build" in xb
|
||||
assert "--sign" not in xb
|
||||
# No manual codesign --sign
|
||||
sign_calls = [c for c in calls if c[0] == "codesign" and "--sign" in c]
|
||||
assert len(sign_calls) == 0, f"manual codesign --sign must not occur, got {sign_calls}"
|
||||
|
||||
# Product copied to dist
|
||||
dist_app = repo_root / "native" / "ReynaCLIHost" / "dist" / "Reyna CLI.app"
|
||||
assert dist_app.exists()
|
||||
assert (dist_app / "Contents" / "MacOS" / "ReynaCLIHost").exists()
|
||||
|
||||
|
||||
def test_build_app_bundle_fails_if_xcodebuild_fails(tmp_path):
|
||||
from reyna_cli import app_bundle as ab
|
||||
|
||||
repo_root = tmp_path / "repo"
|
||||
pkg_dir = repo_root / "native" / "ReynaCLIHost"
|
||||
(pkg_dir / "ReynaCLIHost.xcodeproj").mkdir(parents=True)
|
||||
(pkg_dir / "ReynaCLIHost").mkdir(parents=True, exist_ok=True)
|
||||
(pkg_dir / "ReynaCLIHost" / "Info.plist").write_text("<plist></plist>")
|
||||
|
||||
def runner(args, cwd=None, **kwargs):
|
||||
if args and args[0] == "xcodebuild":
|
||||
return _mock_proc(rc=1, stderr="BUILD FAILED")
|
||||
return _mock_proc(rc=0)
|
||||
|
||||
res = ab.build_app_bundle(repo_root=repo_root, runner=runner)
|
||||
assert res["ok"] is False
|
||||
assert "xcodebuild" in res["error"].lower()
|
||||
|
||||
|
||||
def test_build_app_bundle_fails_if_product_missing_after_build(tmp_path):
|
||||
from reyna_cli import app_bundle as ab
|
||||
|
||||
repo_root = tmp_path / "repo"
|
||||
pkg_dir = repo_root / "native" / "ReynaCLIHost"
|
||||
(pkg_dir / "ReynaCLIHost.xcodeproj").mkdir(parents=True)
|
||||
(pkg_dir / "ReynaCLIHost").mkdir(parents=True, exist_ok=True)
|
||||
(pkg_dir / "ReynaCLIHost" / "Info.plist").write_text("<plist></plist>")
|
||||
|
||||
def runner(args, cwd=None, **kwargs):
|
||||
if args and args[0] == "xcodebuild":
|
||||
return _mock_proc(rc=0)
|
||||
return _mock_proc(rc=0)
|
||||
|
||||
res = ab.build_app_bundle(repo_root=repo_root, runner=runner)
|
||||
assert res["ok"] is False
|
||||
assert "product not found" in res["error"].lower()
|
||||
|
||||
|
||||
def test_build_app_bundle_unsigned_mode_allows_validation_failure(tmp_path):
|
||||
from reyna_cli import app_bundle as ab
|
||||
|
||||
repo_root = tmp_path / "repo"
|
||||
pkg_dir = repo_root / "native" / "ReynaCLIHost"
|
||||
(pkg_dir / "ReynaCLIHost.xcodeproj").mkdir(parents=True)
|
||||
(pkg_dir / "ReynaCLIHost").mkdir(parents=True, exist_ok=True)
|
||||
(pkg_dir / "ReynaCLIHost" / "Info.plist").write_text("<plist></plist>")
|
||||
derived = repo_root / "native" / "ReynaCLIHost" / "build" / "DerivedData"
|
||||
built_app = derived / "Build" / "Products" / "Release" / "Reyna CLI.app"
|
||||
(built_app / "Contents" / "MacOS").mkdir(parents=True)
|
||||
(built_app / "Contents" / "MacOS" / "ReynaCLIHost").write_bytes(b"bin")
|
||||
with open(built_app / "Contents" / "Info.plist", "wb") as f:
|
||||
plistlib.dump(ab.build_app_bundle_info_plist_dict(), f)
|
||||
|
||||
def runner(args, cwd=None, **kwargs):
|
||||
if args and args[0] == "xcodebuild":
|
||||
return _mock_proc(rc=0)
|
||||
if args[:3] == ["codesign", "--verify", "--deep"]:
|
||||
return _mock_proc(rc=1, stderr="code object is not signed at all")
|
||||
if args[:2] == ["codesign", "-dv"]:
|
||||
return _mock_proc(rc=0, stderr="Signature=adhoc\nTeamIdentifier=not set\n")
|
||||
return _mock_proc(rc=0)
|
||||
|
||||
res = ab.build_app_bundle(repo_root=repo_root, runner=runner, disable_code_signing=True)
|
||||
# Unsigned build should succeed even if validation fails (for testing)
|
||||
assert res["ok"] is True
|
||||
assert res.get("unsigned_build") is True
|
||||
# But validation indicates not verified
|
||||
assert res["validation"]["signature_verified"] is False
|
||||
|
||||
|
||||
def test_build_app_bundle_validation_detects_ad_hoc_signed(tmp_path):
|
||||
from reyna_cli import app_bundle as ab
|
||||
|
||||
repo_root = tmp_path / "repo"
|
||||
pkg_dir = repo_root / "native" / "ReynaCLIHost"
|
||||
(pkg_dir / "ReynaCLIHost.xcodeproj").mkdir(parents=True)
|
||||
(pkg_dir / "ReynaCLIHost").mkdir(parents=True, exist_ok=True)
|
||||
(pkg_dir / "ReynaCLIHost" / "Info.plist").write_text("<plist></plist>")
|
||||
derived = repo_root / "native" / "ReynaCLIHost" / "build" / "DerivedData"
|
||||
built_app = derived / "Build" / "Products" / "Release" / "Reyna CLI.app"
|
||||
(built_app / "Contents" / "MacOS").mkdir(parents=True)
|
||||
(built_app / "Contents" / "MacOS" / "ReynaCLIHost").write_bytes(b"bin")
|
||||
with open(built_app / "Contents" / "Info.plist", "wb") as f:
|
||||
plistlib.dump(ab.build_app_bundle_info_plist_dict(), f)
|
||||
|
||||
def runner(args, cwd=None, **kwargs):
|
||||
if args and args[0] == "xcodebuild":
|
||||
return _mock_proc(rc=0)
|
||||
if args[:3] == ["codesign", "--verify", "--deep"]:
|
||||
return _mock_proc(rc=0)
|
||||
if args[:2] == ["codesign", "-dv"]:
|
||||
return _mock_proc(rc=0, stdout="", stderr="Signature=adhoc\nTeamIdentifier=not set\n")
|
||||
return _mock_proc(rc=0)
|
||||
|
||||
res = ab.build_app_bundle(repo_root=repo_root, runner=runner, disable_code_signing=False)
|
||||
assert res["ok"] is False
|
||||
assert "ad-hoc" in (res.get("error", "") + str(res.get("validation", {}))).lower()
|
||||
|
||||
|
||||
def test_build_app_bundle_verify_failure_fail_closed(tmp_path):
|
||||
from reyna_cli import app_bundle as ab
|
||||
|
||||
repo_root = tmp_path / "repo"
|
||||
pkg_dir = repo_root / "native" / "ReynaCLIHost"
|
||||
(pkg_dir / "ReynaCLIHost.xcodeproj").mkdir(parents=True)
|
||||
(pkg_dir / "ReynaCLIHost").mkdir(parents=True, exist_ok=True)
|
||||
(pkg_dir / "ReynaCLIHost" / "Info.plist").write_text("<plist></plist>")
|
||||
derived = repo_root / "native" / "ReynaCLIHost" / "build" / "DerivedData"
|
||||
built_app = derived / "Build" / "Products" / "Release" / "Reyna CLI.app"
|
||||
(built_app / "Contents" / "MacOS").mkdir(parents=True)
|
||||
(built_app / "Contents" / "MacOS" / "ReynaCLIHost").write_bytes(b"bin")
|
||||
with open(built_app / "Contents" / "Info.plist", "wb") as f:
|
||||
plistlib.dump(ab.build_app_bundle_info_plist_dict(), f)
|
||||
|
||||
def runner(args, cwd=None, **kwargs):
|
||||
if args and args[0] == "xcodebuild":
|
||||
return _mock_proc(rc=0)
|
||||
if args[:3] == ["codesign", "--verify", "--deep"]:
|
||||
return _mock_proc(rc=1, stderr="code failed to satisfy")
|
||||
if args[:2] == ["codesign", "-dv"]:
|
||||
return _mock_proc(rc=0, stderr="TeamIdentifier=TEAM123\n")
|
||||
return _mock_proc(rc=0)
|
||||
|
||||
res = ab.build_app_bundle(repo_root=repo_root, runner=runner, disable_code_signing=False)
|
||||
assert res["ok"] is False
|
||||
assert "verify" in res["error"].lower() or "validation" in res["error"].lower()
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Validation – same as before, plus xcodeproj contract
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_validate_app_bundle_layout_and_signature(tmp_path):
|
||||
from reyna_cli import app_bundle as ab
|
||||
|
||||
repo_root = tmp_path / "repo"
|
||||
bundle = repo_root / "native" / "ReynaCLIHost" / "dist" / "Reyna CLI.app"
|
||||
contents = bundle / "Contents"
|
||||
macos = contents / "MacOS"
|
||||
macos.mkdir(parents=True)
|
||||
exe = macos / "ReynaCLIHost"
|
||||
exe.write_bytes(b"binary")
|
||||
exe.chmod(0o755)
|
||||
|
||||
plist_dict = ab.build_app_bundle_info_plist_dict()
|
||||
with open(contents / "Info.plist", "wb") as f:
|
||||
plistlib.dump(plist_dict, f)
|
||||
|
||||
def runner_ok(args, cwd=None, **kwargs):
|
||||
if args[:3] == ["codesign", "--verify", "--deep"]:
|
||||
return _mock_proc(rc=0)
|
||||
if args[:2] == ["codesign", "-dv"]:
|
||||
return _mock_proc(rc=0, stderr="Executable=...\nIdentifier=com.reyna.cli.privacy-host\nFormat=app bundle\nAuthority=Apple Development: Foo (TEAM123)\nTeamIdentifier=TEAM123\n")
|
||||
return _mock_proc(rc=0)
|
||||
|
||||
res = ab.validate_app_bundle(repo_root=repo_root, runner=runner_ok)
|
||||
assert res["ok"] is True
|
||||
assert res["bundle_exists"] is True
|
||||
assert res["executable_exists"] is True
|
||||
assert res["info_plist_exists"] is True
|
||||
assert res["bundle_identifier"] == "com.reyna.cli.privacy-host"
|
||||
assert res["bundle_identifier_matches"] is True
|
||||
assert res["signature_verified"] is True
|
||||
assert res["is_ad_hoc"] is False
|
||||
|
||||
|
||||
def test_validate_app_bundle_accepts_only_calendar_usage(tmp_path):
|
||||
from reyna_cli import app_bundle as ab
|
||||
|
||||
repo_root = tmp_path / "repo"
|
||||
bundle = repo_root / "native" / "ReynaCLIHost" / "dist" / "Reyna CLI.app"
|
||||
contents = bundle / "Contents"
|
||||
macos = contents / "MacOS"
|
||||
macos.mkdir(parents=True)
|
||||
(macos / "ReynaCLIHost").write_bytes(b"x")
|
||||
plist_dict = ab.build_app_bundle_info_plist_dict()
|
||||
plist_dict["NSRemindersUsageDescription"] = "Should not be allowed"
|
||||
with open(contents / "Info.plist", "wb") as f:
|
||||
plistlib.dump(plist_dict, f)
|
||||
|
||||
def runner(args, cwd=None, **kwargs):
|
||||
if args[:3] == ["codesign", "--verify", "--deep"]:
|
||||
return _mock_proc(rc=0)
|
||||
if args[:2] == ["codesign", "-dv"]:
|
||||
return _mock_proc(rc=0, stderr="TeamIdentifier=TEAM123\n")
|
||||
return _mock_proc(rc=0)
|
||||
|
||||
res = ab.validate_app_bundle(repo_root=repo_root, runner=runner)
|
||||
assert res["ok"] is False
|
||||
assert any("reminders" in e.lower() or "forbidden" in e.lower() or "unexpected" in e.lower() for e in res["errors"])
|
||||
|
||||
|
||||
def test_validate_app_bundle_fails_if_ad_hoc(tmp_path):
|
||||
from reyna_cli import app_bundle as ab
|
||||
|
||||
repo_root = tmp_path / "repo"
|
||||
bundle = repo_root / "native" / "ReynaCLIHost" / "dist" / "Reyna CLI.app"
|
||||
contents = bundle / "Contents"
|
||||
macos = contents / "MacOS"
|
||||
macos.mkdir(parents=True)
|
||||
(macos / "ReynaCLIHost").write_bytes(b"x")
|
||||
with open(contents / "Info.plist", "wb") as f:
|
||||
plistlib.dump(ab.build_app_bundle_info_plist_dict(), f)
|
||||
|
||||
def runner(args, cwd=None, **kwargs):
|
||||
if args[:3] == ["codesign", "--verify", "--deep"]:
|
||||
return _mock_proc(rc=0)
|
||||
if args[:2] == ["codesign", "-dv"]:
|
||||
return _mock_proc(rc=0, stderr="Signature=adhoc\nTeamIdentifier=not set\n")
|
||||
return _mock_proc(rc=0)
|
||||
|
||||
res = ab.validate_app_bundle(repo_root=repo_root, runner=runner)
|
||||
assert res["ok"] is False
|
||||
assert res["is_ad_hoc"] is True
|
||||
assert res["signature_verified"] is False
|
||||
|
||||
|
||||
def test_validate_app_bundle_fails_if_verify_fails(tmp_path):
|
||||
from reyna_cli import app_bundle as ab
|
||||
|
||||
repo_root = tmp_path / "repo"
|
||||
bundle = repo_root / "native" / "ReynaCLIHost" / "dist" / "Reyna CLI.app"
|
||||
(bundle / "Contents" / "MacOS").mkdir(parents=True)
|
||||
(bundle / "Contents" / "MacOS" / "ReynaCLIHost").write_bytes(b"x")
|
||||
with open(bundle / "Contents" / "Info.plist", "wb") as f:
|
||||
plistlib.dump(ab.build_app_bundle_info_plist_dict(), f)
|
||||
|
||||
def runner(args, cwd=None, **kwargs):
|
||||
if args[:3] == ["codesign", "--verify", "--deep"]:
|
||||
return _mock_proc(rc=1, stderr="main executable failed strict validation")
|
||||
if args[:2] == ["codesign", "-dv"]:
|
||||
return _mock_proc(rc=0, stderr="TeamIdentifier=TEAM123\n")
|
||||
return _mock_proc(rc=0)
|
||||
|
||||
res = ab.validate_app_bundle(repo_root=repo_root, runner=runner)
|
||||
assert res["ok"] is False
|
||||
assert res["signature_verified"] is False
|
||||
|
||||
|
||||
def test_xcodeproject_static_contract(tmp_path):
|
||||
"""Verify xcodeproject and Info.plist static contract exist."""
|
||||
from reyna_cli import app_bundle as ab
|
||||
|
||||
repo_root = Path(ab.__file__).resolve().parents[2]
|
||||
proj = repo_root / "native" / "ReynaCLIHost" / "ReynaCLIHost.xcodeproj" / "project.pbxproj"
|
||||
assert proj.exists(), f"xcodeproj missing at {proj}"
|
||||
src = proj.read_text()
|
||||
assert "com.reyna.cli.privacy-host" in src
|
||||
assert "Reyna CLI" in src
|
||||
assert "CODE_SIGN_STYLE = Automatic" in src
|
||||
|
||||
info_src = repo_root / "native" / "ReynaCLIHost" / "ReynaCLIHost" / "Info.plist"
|
||||
assert info_src.exists()
|
||||
with open(info_src, "rb") as f:
|
||||
d = plistlib.load(f)
|
||||
assert d["CFBundleIdentifier"] == "com.reyna.cli.privacy-host"
|
||||
assert d["CFBundleExecutable"] == "ReynaCLIHost"
|
||||
|
||||
|
||||
def test_build_app_bundle_no_secret_logging(tmp_path):
|
||||
"""Result must not contain secret identity in clear."""
|
||||
from reyna_cli import app_bundle as ab
|
||||
|
||||
repo_root = tmp_path / "repo"
|
||||
pkg_dir = repo_root / "native" / "ReynaCLIHost"
|
||||
(pkg_dir / "ReynaCLIHost.xcodeproj").mkdir(parents=True)
|
||||
(pkg_dir / "ReynaCLIHost").mkdir(parents=True, exist_ok=True)
|
||||
(pkg_dir / "ReynaCLIHost" / "Info.plist").write_text("<plist></plist>")
|
||||
derived = repo_root / "native" / "ReynaCLIHost" / "build" / "DerivedData"
|
||||
built_app = derived / "Build" / "Products" / "Release" / "Reyna CLI.app"
|
||||
(built_app / "Contents" / "MacOS").mkdir(parents=True)
|
||||
(built_app / "Contents" / "MacOS" / "ReynaCLIHost").write_bytes(b"bin")
|
||||
with open(built_app / "Contents" / "Info.plist", "wb") as f:
|
||||
plistlib.dump(ab.build_app_bundle_info_plist_dict(), f)
|
||||
|
||||
def runner(args, cwd=None, **kwargs):
|
||||
if args and args[0] == "xcodebuild":
|
||||
return _mock_proc(rc=0)
|
||||
if args[:3] == ["codesign", "--verify", "--deep"]:
|
||||
return _mock_proc(rc=0)
|
||||
if args[:2] == ["codesign", "-dv"]:
|
||||
return _mock_proc(rc=0, stderr="TeamIdentifier=TEAM123\n")
|
||||
return _mock_proc(rc=0)
|
||||
|
||||
secret_identity = "Apple Development: Very Secret Name (TEAM999)"
|
||||
res = ab.build_app_bundle(signing_identity=secret_identity, repo_root=repo_root, runner=runner)
|
||||
assert res["ok"] is True
|
||||
res_str = str(res)
|
||||
# secret team must not leak (Automatic Signing means we don't use identity at all)
|
||||
assert "TEAM999" not in res_str
|
||||
assert secret_identity not in res_str
|
||||
|
||||
|
||||
def test_lifecycle_refuses_invalid_unverified_bundle(tmp_path):
|
||||
from reyna_cli import privacy_host as ph_mod
|
||||
from reyna_cli import app_bundle as ab
|
||||
|
||||
repo_root = tmp_path / "repo"
|
||||
repo_root.mkdir()
|
||||
(repo_root / "native" / "ReynaCLIHost" / "ReynaCLIHost.xcodeproj").mkdir(parents=True)
|
||||
(repo_root / "native" / "ReynaCLIHost" / "ReynaCLIHost").mkdir(parents=True, exist_ok=True)
|
||||
(repo_root / "native" / "ReynaCLIHost" / "ReynaCLIHost" / "Info.plist").write_text("<plist></plist>")
|
||||
derived = repo_root / "native" / "ReynaCLIHost" / "build" / "DerivedData"
|
||||
built_app = derived / "Build" / "Products" / "Release" / "Reyna CLI.app"
|
||||
(built_app / "Contents" / "MacOS").mkdir(parents=True)
|
||||
(built_app / "Contents" / "MacOS" / "ReynaCLIHost").write_bytes(b"bin")
|
||||
with open(built_app / "Contents" / "Info.plist", "wb") as f:
|
||||
plistlib.dump(ab.build_app_bundle_info_plist_dict(), f)
|
||||
|
||||
sock = tmp_path / "priv" / "reyna-cli.sock"
|
||||
plist = tmp_path / "LaunchAgents" / "com.reyna.cli.privacy-host.plist"
|
||||
logd = tmp_path / "Logs"
|
||||
|
||||
def fake_runner(args, cwd=None, **kwargs):
|
||||
if args and args[0] == "xcodebuild":
|
||||
return _mock_proc(rc=0)
|
||||
if args[:3] == ["codesign", "--verify", "--deep"]:
|
||||
return _mock_proc(rc=1, stderr="verify failed")
|
||||
if args[:2] == ["codesign", "-dv"]:
|
||||
return _mock_proc(rc=0, stderr="TeamIdentifier=TEAM123\n")
|
||||
if args[0] == "launchctl":
|
||||
return _mock_proc(rc=0)
|
||||
return _mock_proc(rc=0)
|
||||
|
||||
result = ph_mod.install_privacy_host_service(
|
||||
runner=fake_runner, uid=501, repo_root=repo_root, socket_path=sock, plist_path=plist, log_dir=logd, signing_identity="Test ID"
|
||||
)
|
||||
assert result["ok"] is False
|
||||
assert "verify" in result["error"].lower() or "validation" in result["error"].lower()
|
||||
|
||||
|
||||
def test_lifecycle_start_refuses_unverified_bundle(tmp_path):
|
||||
from reyna_cli import privacy_host as ph_mod
|
||||
|
||||
repo_root = tmp_path / "repo"
|
||||
repo_root.mkdir()
|
||||
(repo_root / "native" / "ReynaCLIHost").mkdir(parents=True)
|
||||
plist = tmp_path / "LaunchAgents" / "com.reyna.cli.privacy-host.plist"
|
||||
plist.parent.mkdir(parents=True)
|
||||
import plistlib as _plist
|
||||
|
||||
plist.write_bytes(_plist.dumps({"Label": ph_mod.PRIVACY_HOST_LABEL}))
|
||||
|
||||
def fake_runner(args, cwd=None, **kwargs):
|
||||
if args[:3] == ["codesign", "--verify", "--deep"]:
|
||||
return _mock_proc(rc=1, stderr="verify failed")
|
||||
if args[:2] == ["codesign", "-dv"]:
|
||||
return _mock_proc(rc=0, stderr="Signature=adhoc\nTeamIdentifier=not set\n")
|
||||
return _mock_proc(rc=0)
|
||||
|
||||
bundle = repo_root / "native" / "ReynaCLIHost" / "dist" / "Reyna CLI.app"
|
||||
(bundle / "Contents" / "MacOS").mkdir(parents=True)
|
||||
(bundle / "Contents" / "MacOS" / "ReynaCLIHost").write_bytes(b"x")
|
||||
with open(bundle / "Contents" / "Info.plist", "wb") as f:
|
||||
from reyna_cli import app_bundle as ab
|
||||
|
||||
_plist.dump(ab.build_app_bundle_info_plist_dict(), f)
|
||||
|
||||
res = ph_mod.start_privacy_host_service(runner=fake_runner, uid=501, plist_path=plist, repo_root=repo_root)
|
||||
assert res["ok"] is False
|
||||
assert "bundle" in res["error"].lower()
|
||||
|
||||
|
||||
def test_status_outputs_app_bundle_path_and_verification_state(tmp_path):
|
||||
from reyna_cli import privacy_host as ph_mod, app_bundle as ab
|
||||
|
||||
repo_root = tmp_path / "repo"
|
||||
(repo_root / "native" / "ReynaCLIHost").mkdir(parents=True)
|
||||
bundle = repo_root / "native" / "ReynaCLIHost" / "dist" / "Reyna CLI.app"
|
||||
(bundle / "Contents" / "MacOS").mkdir(parents=True)
|
||||
(bundle / "Contents" / "MacOS" / "ReynaCLIHost").write_bytes(b"x")
|
||||
with open(bundle / "Contents" / "Info.plist", "wb") as f:
|
||||
import plistlib
|
||||
|
||||
plistlib.dump(ab.build_app_bundle_info_plist_dict(), f)
|
||||
|
||||
sock = tmp_path / "sock" / "reyna-cli.sock"
|
||||
plist_path = tmp_path / "LaunchAgents" / "com.reyna.cli.privacy-host.plist"
|
||||
plist_path.parent.mkdir(parents=True)
|
||||
import plistlib
|
||||
|
||||
plist_path.write_bytes(plistlib.dumps({"Label": ph_mod.PRIVACY_HOST_LABEL}))
|
||||
|
||||
class Proc:
|
||||
returncode = 0
|
||||
stdout = "pid = 1234\n"
|
||||
stderr = ""
|
||||
|
||||
def runner(args, cwd=None, **kwargs):
|
||||
if isinstance(args, list) and args and args[0] == "codesign" and "--verify" in args:
|
||||
return Proc()
|
||||
if isinstance(args, list) and args[:2] == ["codesign", "-dv"]:
|
||||
|
||||
class P:
|
||||
returncode = 0
|
||||
stdout = ""
|
||||
stderr = "TeamIdentifier=TEAM123\nAuthority=Apple Development: Foo (TEAM123)\n"
|
||||
|
||||
return P()
|
||||
if isinstance(args, list) and args[0] == "launchctl":
|
||||
return Proc()
|
||||
return Proc()
|
||||
|
||||
status = ph_mod.privacy_host_service_status(
|
||||
runner=runner, uid=501, plist_path_override=plist_path, socket_path_override=sock, repo_root_override=repo_root
|
||||
)
|
||||
assert status["ok"] is True
|
||||
assert "app_bundle_path" in status
|
||||
assert "bundle_identifier" in status
|
||||
assert status["bundle_identifier"] == "com.reyna.cli.privacy-host"
|
||||
assert status["bundle_identifier_expected"] == "com.reyna.cli.privacy-host"
|
||||
assert "signature_verified" in status
|
||||
assert "bundle_exists" in status
|
||||
assert status["app_bundle_path_expected"] == str(bundle)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Contacts migration readiness – Xcode linkage + deterministic nil date
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_xcode_contacts_source_files_and_framework_linked():
|
||||
from reyna_cli import app_bundle as ab
|
||||
|
||||
repo_root = Path(ab.__file__).resolve().parents[2]
|
||||
proj = repo_root / "native" / "ReynaCLIHost" / "ReynaCLIHost.xcodeproj" / "project.pbxproj"
|
||||
assert proj.exists()
|
||||
src = proj.read_text()
|
||||
|
||||
# Contacts source files must be in project
|
||||
assert "ContactsProvider.swift" in src, "ContactsProvider.swift missing from pbxproj"
|
||||
assert "ContactsAuthorizationProvider.swift" in src, "ContactsAuthorizationProvider.swift missing"
|
||||
|
||||
# Must be in Sources build phase
|
||||
assert "ContactsProvider.swift in Sources" in src
|
||||
assert "ContactsAuthorizationProvider.swift in Sources" in src
|
||||
|
||||
# Must be in ReynaCLIHostCore group
|
||||
# Find the core group and check its children include both
|
||||
assert "ReynaCLIHostCore" in src
|
||||
|
||||
# Framework must be linked
|
||||
assert "Contacts.framework" in src, "Contacts.framework missing from pbxproj"
|
||||
assert "Contacts.framework in Frameworks" in src, "Contacts.framework not in Frameworks phase"
|
||||
|
||||
# Also EventKit still present
|
||||
assert "EventKit.framework" in src
|
||||
|
||||
|
||||
def test_package_swift_links_contacts_framework():
|
||||
from reyna_cli import app_bundle as ab
|
||||
|
||||
repo_root = Path(ab.__file__).resolve().parents[2]
|
||||
pkg = repo_root / "native" / "ReynaCLIHost" / "Package.swift"
|
||||
assert pkg.exists()
|
||||
content = pkg.read_text()
|
||||
assert "Contacts" in content
|
||||
assert 'linkedFramework("Contacts")' in content
|
||||
# Should still link EventKit
|
||||
assert 'linkedFramework("EventKit")' in content
|
||||
|
||||
|
||||
def test_contacts_provider_nil_modification_date_deterministic():
|
||||
from reyna_cli import app_bundle as ab
|
||||
|
||||
repo_root = Path(ab.__file__).resolve().parents[2]
|
||||
provider = repo_root / "native" / "ReynaCLIHost" / "Sources" / "ReynaCLIHostCore" / "ContactsProvider.swift"
|
||||
assert provider.exists()
|
||||
src = provider.read_text()
|
||||
# Must not use iso.string(from: Date()) as fallback – nondeterministic
|
||||
# Count occurrences that use current Date as fallback
|
||||
assert "iso.string(from: Date())" not in src, "ContactsProvider must not fallback to current Date() – nondeterministic"
|
||||
# Ensure the replacement is deterministic (empty string)
|
||||
# Both search and read providers should have deterministic fallback
|
||||
assert '?? ""' in src or 'modifiedStr = ""' in src or "= \"\"" in src
|
||||
|
||||
|
||||
def test_xcode_info_plist_and_generated_plist_alignment_calendar_plus_contacts():
|
||||
from reyna_cli import app_bundle as ab
|
||||
import plistlib
|
||||
|
||||
repo_root = Path(ab.__file__).resolve().parents[2]
|
||||
xcode_plist = repo_root / "native" / "ReynaCLIHost" / "ReynaCLIHost" / "Info.plist"
|
||||
assert xcode_plist.exists()
|
||||
with open(xcode_plist, "rb") as f:
|
||||
xcode_d = plistlib.load(f)
|
||||
|
||||
gen_d = ab.build_app_bundle_info_plist_dict()
|
||||
|
||||
# Both must have Calendar, Contacts, Reminders usage descriptions
|
||||
for key in ["NSCalendarsFullAccessUsageDescription", "NSContactsUsageDescription", "NSRemindersFullAccessUsageDescription"]:
|
||||
assert key in xcode_d, f"Xcode Info.plist missing {key}"
|
||||
assert key in gen_d, f"generated plist missing {key}"
|
||||
|
||||
# Bundle identity alignment
|
||||
assert xcode_d["CFBundleIdentifier"] == gen_d["CFBundleIdentifier"] == "com.reyna.cli.privacy-host"
|
||||
assert xcode_d["CFBundleExecutable"] == gen_d["CFBundleExecutable"] == "ReynaCLIHost"
|
||||
|
||||
# Security: no Notes/Mail/AppleEvents in either (Notes deferred)
|
||||
for d, label in [(xcode_d, "Xcode"), (gen_d, "generated")]:
|
||||
for bad in ["Notes", "Mail", "AppleEvents"]:
|
||||
for k in d.keys():
|
||||
if bad.lower() in k.lower() and "UsageDescription" in k:
|
||||
raise AssertionError(f"{label} plist contains forbidden domain {bad} via {k}")
|
||||
for k in d.keys():
|
||||
low = k.lower()
|
||||
if "notesusage" in low or "mailusage" in low or "appleeventsusage" in low:
|
||||
raise AssertionError(f"{label} plist contains forbidden domain via {k}")
|
||||
|
||||
# Allowed set only: Calendar, Contacts, Reminders (Notes deferred, no AppleEvents)
|
||||
allowed = {
|
||||
"NSCalendarsFullAccessUsageDescription",
|
||||
"NSCalendarsWriteOnlyAccessUsageDescription",
|
||||
"NSCalendarsUsageDescription",
|
||||
"NSContactsUsageDescription",
|
||||
"NSRemindersFullAccessUsageDescription",
|
||||
}
|
||||
for d, label in [(xcode_d, "Xcode"), (gen_d, "generated")]:
|
||||
for k in d.keys():
|
||||
if k.startswith("NS") and "UsageDescription" in k:
|
||||
assert k in allowed, f"{label} plist has unexpected usage key {k}"
|
||||
@@ -0,0 +1,104 @@
|
||||
"""Tests for explicit calendar-authorize operation – TDD fakes only, no live service."""
|
||||
import json
|
||||
import pytest
|
||||
from typer.testing import CliRunner
|
||||
from reyna_cli.cli import app
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
|
||||
def test_privacy_client_direct_op_uses_explicit_operation(monkeypatch):
|
||||
from reyna_cli import privacy_host as ph_mod
|
||||
|
||||
captured = {}
|
||||
|
||||
class FakeClient:
|
||||
def __init__(self, timeout):
|
||||
captured["timeout"] = timeout
|
||||
|
||||
def call(self, op, args):
|
||||
captured["op"] = op
|
||||
captured["args"] = args
|
||||
return {"id": "x", "ok": True, "result": {"protocol_version": "1.0.0", "operation": "calendar.request_full_access", "status": "authorized"}}
|
||||
|
||||
monkeypatch.setattr(ph_mod, "PrivacyClient", FakeClient)
|
||||
result = ph_mod.native_calendar_request_full_access()
|
||||
assert captured["op"] == "calendar.request_full_access"
|
||||
assert captured["args"] == {}
|
||||
assert captured["timeout"] == 35
|
||||
assert result["ok"] is True
|
||||
assert result["source"] == "native_privacy_host"
|
||||
assert result["result"]["status"] == "authorized"
|
||||
|
||||
|
||||
def test_calendar_authorize_cli_no_generic_fallback(monkeypatch):
|
||||
from reyna_cli import privacy_host as ph_mod
|
||||
|
||||
calls = {"count": 0}
|
||||
|
||||
def fake_native():
|
||||
calls["count"] += 1
|
||||
return {"ok": True, "source": "native_privacy_host", "result": {"protocol_version": "1.0.0", "operation": "calendar.request_full_access", "status": "authorized"}}
|
||||
|
||||
monkeypatch.setattr("reyna_cli.privacy_host.native_calendar_request_full_access", fake_native)
|
||||
|
||||
# Ensure src does not use generic call / MCP fallback
|
||||
src = ph_mod.__file__
|
||||
import pathlib
|
||||
text = pathlib.Path(src).read_text()
|
||||
# The new function must call PrivacyClient directly with explicit op and not use generic 'call' helper referencing arbitrary operation arg
|
||||
# CLI command must import the explicit function, not PrivacyClient directly (checked via cli source)
|
||||
cli_text = pathlib.Path("src/reyna_cli/cli.py").read_text() if pathlib.Path("src/reyna_cli/cli.py").exists() else pathlib.Path(__file__).parents[1].joinpath("src/reyna_cli/cli.py").read_text()
|
||||
|
||||
res = runner.invoke(app, ["privacy-host", "calendar-authorize", "--json"])
|
||||
assert res.exit_code == 0, res.stdout + res.stderr
|
||||
payload = json.loads(res.stdout)
|
||||
assert payload["ok"] is True
|
||||
assert payload["result"]["status"] == "authorized"
|
||||
assert calls["count"] == 1
|
||||
|
||||
|
||||
def test_calendar_authorize_cli_help_mentions_prompt():
|
||||
res = runner.invoke(app, ["privacy-host", "calendar-authorize", "--help"])
|
||||
assert res.exit_code == 0
|
||||
out = res.stdout.lower()
|
||||
# Help must make prompting clear
|
||||
assert "calendar" in out
|
||||
assert "permission" in out or "prompt" in out or "privacy" in out
|
||||
|
||||
|
||||
def test_calendar_authorize_failures_surface(monkeypatch):
|
||||
from reyna_cli.privacy_client import PrivacyClientError
|
||||
|
||||
def fake_fail():
|
||||
raise PrivacyClientError("privacy RPC returned ok=false: {'code': 'permission_denied'}")
|
||||
|
||||
monkeypatch.setattr("reyna_cli.privacy_host.native_calendar_request_full_access", fake_fail)
|
||||
|
||||
res = runner.invoke(app, ["privacy-host", "calendar-authorize", "--json"])
|
||||
assert res.exit_code != 0
|
||||
# payload should have ok:false
|
||||
payload = json.loads(res.stdout)
|
||||
assert payload["ok"] is False
|
||||
|
||||
|
||||
def test_native_calendar_request_full_access_no_mcp_import():
|
||||
from reyna_cli import privacy_host as ph_mod
|
||||
import pathlib
|
||||
src = pathlib.Path(ph_mod.__file__).read_text()
|
||||
# Ensure new function does not import MCP fallback
|
||||
# We locate function definition region
|
||||
# Simple guard: whole module still must not reference MCP fallback helpers
|
||||
assert "call_macmini_tool" not in src
|
||||
assert "macmini_client" not in src
|
||||
# The specific new function should exist
|
||||
assert "def native_calendar_request_full_access" in src
|
||||
assert "calendar.request_full_access" in src
|
||||
|
||||
|
||||
def test_privacy_host_cli_has_calendar_authorize():
|
||||
res = runner.invoke(app, ["privacy-host", "--help"])
|
||||
assert res.exit_code == 0
|
||||
assert "calendar-authorize" in res.stdout
|
||||
# Ensure no generic 'call' command exposed
|
||||
assert "call" not in res.stdout.lower() or "calendar-authorize" in res.stdout
|
||||
@@ -0,0 +1,294 @@
|
||||
"""Tests for calendar event list/create native wrappers and CLI routing – TDD, no live calls."""
|
||||
|
||||
from pathlib import Path
|
||||
import json
|
||||
import pytest
|
||||
from typer.testing import CliRunner
|
||||
from reyna_cli.cli import app
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
|
||||
def test_native_calendar_events_list_success(monkeypatch):
|
||||
from reyna_cli import privacy_host as ph_mod
|
||||
|
||||
captured = {}
|
||||
|
||||
class FakeClient:
|
||||
def call(self, op, args):
|
||||
captured["op"] = op
|
||||
captured["args"] = args
|
||||
return {"id": "abc", "ok": True, "result": {"protocol_version": "1.0.0", "operation": "calendar.events.list", "events": []}}
|
||||
|
||||
monkeypatch.setattr(ph_mod, "PrivacyClient", FakeClient)
|
||||
|
||||
result = ph_mod.native_calendar_events_list(start="2026-01-01T00:00:00Z", end="2026-01-02T00:00:00Z", calendar="Home", limit=25)
|
||||
|
||||
assert captured["op"] == "calendar.events.list"
|
||||
assert captured["args"]["start"] == "2026-01-01T00:00:00Z"
|
||||
assert captured["args"]["end"] == "2026-01-02T00:00:00Z"
|
||||
assert captured["args"]["calendar"] == "Home"
|
||||
assert captured["args"]["limit"] == 25
|
||||
assert result["ok"] is True
|
||||
assert result["source"] == "native_privacy_host"
|
||||
|
||||
|
||||
def test_native_calendar_events_list_with_calendar_id(monkeypatch):
|
||||
from reyna_cli import privacy_host as ph_mod
|
||||
|
||||
captured = {}
|
||||
|
||||
class FakeClient:
|
||||
def call(self, op, args):
|
||||
captured["args"] = args
|
||||
return {"id": "abc", "ok": True, "result": {"events": []}}
|
||||
|
||||
monkeypatch.setattr(ph_mod, "PrivacyClient", FakeClient)
|
||||
|
||||
ph_mod.native_calendar_events_list(start="2026-01-01T00:00:00Z", end="2026-01-02T00:00:00Z", calendar_id="stable-id-123", limit=50)
|
||||
|
||||
assert captured["args"]["calendar_id"] == "stable-id-123"
|
||||
assert "calendar" not in captured["args"] or captured["args"].get("calendar") is None
|
||||
|
||||
|
||||
def test_native_calendar_events_list_failure_surfaces(monkeypatch):
|
||||
from reyna_cli import privacy_host as ph_mod
|
||||
from reyna_cli.privacy_client import PrivacyClientError
|
||||
|
||||
class FakeFail:
|
||||
def call(self, op, args):
|
||||
raise PrivacyClientError("privacy RPC returned ok=false: permission_required")
|
||||
|
||||
monkeypatch.setattr(ph_mod, "PrivacyClient", FakeFail)
|
||||
|
||||
with pytest.raises(PrivacyClientError):
|
||||
ph_mod.native_calendar_events_list(start="2026-01-01T00:00:00Z", end="2026-01-02T00:00:00Z")
|
||||
|
||||
|
||||
def test_native_calendar_event_create_success(monkeypatch):
|
||||
from reyna_cli import privacy_host as ph_mod
|
||||
|
||||
captured = {}
|
||||
|
||||
class FakeClient:
|
||||
def call(self, op, args):
|
||||
captured["op"] = op
|
||||
captured["args"] = args
|
||||
return {"id": "c", "ok": True, "result": {"protocol_version": "1.0.0", "operation": "calendar.event.create", "event": {"id": "new"}}}
|
||||
|
||||
monkeypatch.setattr(ph_mod, "PrivacyClient", FakeClient)
|
||||
|
||||
result = ph_mod.native_calendar_event_create(
|
||||
title="Meeting",
|
||||
start="2026-01-01T10:00:00Z",
|
||||
end="2026-01-01T11:00:00Z",
|
||||
all_day=False,
|
||||
notes="bring docs",
|
||||
location="Room 1",
|
||||
calendar_id="cal-id-1",
|
||||
calendar=None,
|
||||
)
|
||||
|
||||
assert captured["op"] == "calendar.event.create"
|
||||
assert captured["args"]["title"] == "Meeting"
|
||||
assert captured["args"]["start"] == "2026-01-01T10:00:00Z"
|
||||
assert captured["args"]["end"] == "2026-01-01T11:00:00Z"
|
||||
assert captured["args"]["all_day"] is False
|
||||
assert captured["args"]["notes"] == "bring docs"
|
||||
assert captured["args"]["calendar_id"] == "cal-id-1"
|
||||
assert result["ok"] is True
|
||||
|
||||
|
||||
def test_native_calendar_event_create_with_calendar_title(monkeypatch):
|
||||
from reyna_cli import privacy_host as ph_mod
|
||||
|
||||
captured = {}
|
||||
|
||||
class FakeClient:
|
||||
def call(self, op, args):
|
||||
captured["args"] = args
|
||||
return {"id": "c", "ok": True, "result": {"event": {"id": "new"}}}
|
||||
|
||||
monkeypatch.setattr(ph_mod, "PrivacyClient", FakeClient)
|
||||
|
||||
ph_mod.native_calendar_event_create(title="T", start="2026-01-01T10:00:00Z", end="2026-01-01T11:00:00Z", calendar="Home")
|
||||
|
||||
assert captured["args"]["calendar"] == "Home"
|
||||
assert "calendar_id" not in captured["args"]
|
||||
|
||||
|
||||
def test_no_mcp_in_new_wrappers():
|
||||
from reyna_cli import privacy_host as ph_mod
|
||||
|
||||
src = Path(ph_mod.__file__).read_text()
|
||||
# wrappers must still not contain MCP fallback
|
||||
assert "call_macmini_tool" not in src
|
||||
assert "MCPClient" not in src
|
||||
|
||||
|
||||
def test_cli_events_uses_native_wrapper(monkeypatch):
|
||||
calls = {"count": 0, "args": None}
|
||||
|
||||
def fake_events(start, end, calendar_id=None, calendar=None, limit=50):
|
||||
calls["count"] += 1
|
||||
calls["args"] = {"start": start, "end": end, "calendar_id": calendar_id, "calendar": calendar, "limit": limit}
|
||||
return {"ok": True, "source": "native_privacy_host", "result": {"events": []}}
|
||||
|
||||
monkeypatch.setattr("reyna_cli.privacy_host.native_calendar_events_list", fake_events)
|
||||
|
||||
result = runner.invoke(app, ["macmini", "calendar", "events", "2026-01-01T00:00:00Z", "2026-01-02T00:00:00Z", "--json"])
|
||||
assert result.exit_code == 0, result.stdout + result.stderr
|
||||
payload = json.loads(result.stdout)
|
||||
assert payload["ok"] is True
|
||||
assert calls["count"] == 1
|
||||
assert calls["args"]["start"] == "2026-01-01T00:00:00Z"
|
||||
|
||||
|
||||
def test_cli_events_with_calendar_title(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
def fake_events(start, end, calendar_id=None, calendar=None, limit=50):
|
||||
captured["calendar"] = calendar
|
||||
captured["calendar_id"] = calendar_id
|
||||
return {"ok": True, "source": "native_privacy_host", "result": {"events": []}}
|
||||
|
||||
monkeypatch.setattr("reyna_cli.privacy_host.native_calendar_events_list", fake_events)
|
||||
|
||||
result = runner.invoke(app, ["macmini", "calendar", "events", "2026-01-01T00:00:00Z", "2026-01-02T00:00:00Z", "--calendar", "Home", "--json"])
|
||||
assert result.exit_code == 0, result.stdout + result.stderr
|
||||
assert captured["calendar"] == "Home"
|
||||
assert captured["calendar_id"] is None
|
||||
|
||||
|
||||
def test_cli_events_with_calendar_index_resolves_once(monkeypatch):
|
||||
# Track how many times native_calendar_list is called – must be exactly once for index compat
|
||||
list_calls = {"count": 0}
|
||||
events_calls = {"args": None}
|
||||
|
||||
def fake_list():
|
||||
list_calls["count"] += 1
|
||||
return {
|
||||
"ok": True,
|
||||
"source": "native_privacy_host",
|
||||
"result": {
|
||||
"protocol_version": "1.0.0",
|
||||
"operation": "calendar.list",
|
||||
"calendars": [
|
||||
{"id": "id-0", "title": "Home", "source": "iCloud", "type": "caldav"},
|
||||
{"id": "id-1", "title": "Work", "source": "iCloud", "type": "caldav"},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
def fake_events(start, end, calendar_id=None, calendar=None, limit=50):
|
||||
events_calls["args"] = {"calendar_id": calendar_id, "calendar": calendar}
|
||||
return {"ok": True, "source": "native_privacy_host", "result": {"events": []}}
|
||||
|
||||
monkeypatch.setattr("reyna_cli.privacy_host.native_calendar_list", fake_list)
|
||||
monkeypatch.setattr("reyna_cli.privacy_host.native_calendar_events_list", fake_events)
|
||||
|
||||
result = runner.invoke(app, ["macmini", "calendar", "events", "2026-01-01T00:00:00Z", "2026-01-02T00:00:00Z", "--calendar-index", "1", "--json"])
|
||||
assert result.exit_code == 0, result.stdout + result.stderr
|
||||
assert list_calls["count"] == 1, "must resolve calendar list exactly once"
|
||||
assert events_calls["args"]["calendar_id"] == "id-1"
|
||||
assert events_calls["args"]["calendar"] is None
|
||||
|
||||
|
||||
def test_cli_events_invalid_calendar_index_returns_error(monkeypatch):
|
||||
def fake_list():
|
||||
return {
|
||||
"ok": True,
|
||||
"source": "native_privacy_host",
|
||||
"result": {
|
||||
"calendars": [
|
||||
{"id": "id-0", "title": "Home", "source": "iCloud", "type": "caldav"},
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
monkeypatch.setattr("reyna_cli.privacy_host.native_calendar_list", fake_list)
|
||||
monkeypatch.setattr("reyna_cli.privacy_host.native_calendar_events_list", lambda **kwargs: {"ok": True, "source": "native", "result": {}})
|
||||
|
||||
result = runner.invoke(app, ["macmini", "calendar", "events", "2026-01-01T00:00:00Z", "2026-01-02T00:00:00Z", "--calendar-index", "5", "--json"])
|
||||
assert result.exit_code != 0
|
||||
# fail() with json_output should emit ok:false payload
|
||||
assert "out of range" in result.stdout or "out of range" in result.stderr or "ok" in result.stdout.lower()
|
||||
|
||||
|
||||
def test_cli_create_uses_native_wrapper(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
def fake_create(title, start, end, all_day=False, notes=None, location=None, calendar_id=None, calendar=None):
|
||||
captured["title"] = title
|
||||
captured["calendar"] = calendar
|
||||
captured["calendar_id"] = calendar_id
|
||||
captured["notes"] = notes
|
||||
return {"ok": True, "source": "native_privacy_host", "result": {"event": {"id": "new"}}}
|
||||
|
||||
monkeypatch.setattr("reyna_cli.privacy_host.native_calendar_event_create", fake_create)
|
||||
|
||||
result = runner.invoke(app, ["macmini", "calendar", "create", "Meeting", "2026-01-01T10:00:00Z", "2026-01-01T11:00:00Z", "--calendar", "Home", "--notes", "bring docs", "--json"])
|
||||
assert result.exit_code == 0, result.stdout + result.stderr
|
||||
assert captured["title"] == "Meeting"
|
||||
assert captured["calendar"] == "Home"
|
||||
assert captured["notes"] == "bring docs"
|
||||
|
||||
|
||||
def test_cli_create_with_calendar_index(monkeypatch):
|
||||
list_calls = {"count": 0}
|
||||
create_calls = {}
|
||||
|
||||
def fake_list():
|
||||
list_calls["count"] += 1
|
||||
return {
|
||||
"ok": True,
|
||||
"source": "native_privacy_host",
|
||||
"result": {"calendars": [{"id": "id-xyz", "title": "Home", "source": "iCloud", "type": "caldav"}]},
|
||||
}
|
||||
|
||||
def fake_create(title, start, end, all_day=False, notes=None, location=None, calendar_id=None, calendar=None):
|
||||
create_calls["calendar_id"] = calendar_id
|
||||
create_calls["calendar"] = calendar
|
||||
return {"ok": True, "source": "native_privacy_host", "result": {"event": {"id": "new"}}}
|
||||
|
||||
monkeypatch.setattr("reyna_cli.privacy_host.native_calendar_list", fake_list)
|
||||
monkeypatch.setattr("reyna_cli.privacy_host.native_calendar_event_create", fake_create)
|
||||
|
||||
result = runner.invoke(app, ["macmini", "calendar", "create", "T", "2026-01-01T10:00:00Z", "2026-01-01T11:00:00Z", "--calendar-index", "0", "--json"])
|
||||
assert result.exit_code == 0, result.stdout + result.stderr
|
||||
assert list_calls["count"] == 1
|
||||
assert create_calls["calendar_id"] == "id-xyz"
|
||||
assert create_calls["calendar"] is None
|
||||
|
||||
|
||||
def test_cli_create_no_default_calendar_first_arbitrary(monkeypatch):
|
||||
# When no calendar specified, wrapper should receive None for both and then host will reject (no default)
|
||||
captured = {}
|
||||
|
||||
def fake_create(title, start, end, all_day=False, notes=None, location=None, calendar_id=None, calendar=None):
|
||||
captured["calendar_id"] = calendar_id
|
||||
captured["calendar"] = calendar
|
||||
return {"ok": True, "source": "native_privacy_host", "result": {"event": {"id": "new"}}}
|
||||
|
||||
monkeypatch.setattr("reyna_cli.privacy_host.native_calendar_event_create", fake_create)
|
||||
|
||||
result = runner.invoke(app, ["macmini", "calendar", "create", "Title", "2026-01-01T10:00:00Z", "2026-01-01T11:00:00Z", "--json"])
|
||||
assert result.exit_code == 0, result.stdout + result.stderr
|
||||
assert captured["calendar_id"] is None
|
||||
assert captured["calendar"] is None
|
||||
|
||||
|
||||
def test_cli_events_no_mcp_tool_call(monkeypatch):
|
||||
# Prove no call_macmini_tool present in file after change
|
||||
from reyna_cli import cli as cli_mod
|
||||
|
||||
src = Path(cli_mod.__file__).read_text()
|
||||
# Find events function section – must not contain call_macmini_tool for calendar_list_events
|
||||
# Overall file still may have call_macmini_tool for contacts etc, but our two commands must not use it
|
||||
# So check that native wrappers are used
|
||||
assert "native_calendar_events_list" in src
|
||||
assert "native_calendar_event_create" in src
|
||||
# Ensure the old patterns are gone from those specific functions by checking surrounding lines
|
||||
# Simpler: assert the literal string call_macmini_tool("calendar_list_events" not present
|
||||
assert 'calendar_list_events' not in src or 'call_macmini_tool(\"calendar_list_events\"' not in src
|
||||
assert 'calendar_create_event' not in src or 'call_macmini_tool(\"calendar_create_event\"' not in src
|
||||
@@ -0,0 +1,167 @@
|
||||
"""Tests for contacts native wrappers and CLI – TDD fakes only, no live Contacts access."""
|
||||
from pathlib import Path
|
||||
import json
|
||||
import pytest
|
||||
from typer.testing import CliRunner
|
||||
from reyna_cli.cli import app
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
|
||||
def test_native_contacts_search_success(monkeypatch):
|
||||
from reyna_cli import privacy_host as ph_mod
|
||||
|
||||
captured = {}
|
||||
|
||||
class FakeClient:
|
||||
def call(self, op, args):
|
||||
captured["op"] = op
|
||||
captured["args"] = args
|
||||
return {"id": "abc", "ok": True, "result": {"protocol_version": "1.0.0", "operation": "contacts.search", "contacts": [{"id": "1", "name": "Alice", "organization": "OrgA", "modifiedAt": "2026-01-01T00:00:00Z"}]}}
|
||||
|
||||
monkeypatch.setattr(ph_mod, "PrivacyClient", FakeClient)
|
||||
|
||||
result = ph_mod.native_contacts_search(query="alice", limit=20)
|
||||
|
||||
assert captured["op"] == "contacts.search"
|
||||
assert captured["args"]["query"] == "alice"
|
||||
assert captured["args"]["limit"] == 20
|
||||
assert result["ok"] is True
|
||||
assert result["source"] == "native_privacy_host"
|
||||
assert len(result["result"]["contacts"]) == 1
|
||||
|
||||
|
||||
def test_native_contacts_search_no_query(monkeypatch):
|
||||
from reyna_cli import privacy_host as ph_mod
|
||||
|
||||
captured = {}
|
||||
|
||||
class FakeClient:
|
||||
def call(self, op, args):
|
||||
captured["args"] = args
|
||||
return {"id": "x", "ok": True, "result": {"contacts": []}}
|
||||
|
||||
monkeypatch.setattr(ph_mod, "PrivacyClient", FakeClient)
|
||||
ph_mod.native_contacts_search(query=None, limit=10)
|
||||
assert "query" not in captured["args"]
|
||||
|
||||
|
||||
def test_native_contacts_read_success(monkeypatch):
|
||||
from reyna_cli import privacy_host as ph_mod
|
||||
|
||||
captured = {}
|
||||
|
||||
class FakeClient:
|
||||
def call(self, op, args):
|
||||
captured["op"] = op
|
||||
captured["args"] = args
|
||||
return {"id": "a", "ok": True, "result": {"protocol_version": "1.0.0", "operation": "contacts.read", "contact": {"id": "1", "name": "Alice"}}}
|
||||
|
||||
monkeypatch.setattr(ph_mod, "PrivacyClient", FakeClient)
|
||||
result = ph_mod.native_contacts_read(contact_id="1")
|
||||
assert captured["op"] == "contacts.read"
|
||||
assert captured["args"]["id"] == "1"
|
||||
assert result["ok"] is True
|
||||
|
||||
|
||||
def test_native_contacts_create_success(monkeypatch):
|
||||
from reyna_cli import privacy_host as ph_mod
|
||||
|
||||
captured = {}
|
||||
|
||||
class FakeClient:
|
||||
def call(self, op, args):
|
||||
captured["op"] = op
|
||||
captured["args"] = args
|
||||
return {"id": "b", "ok": True, "result": {"protocol_version": "1.0.0", "operation": "contacts.create", "created_contact": {"id": "new", "name": "Alice", "organization": ""}}}
|
||||
|
||||
monkeypatch.setattr(ph_mod, "PrivacyClient", FakeClient)
|
||||
result = ph_mod.native_contacts_create(first_name="Alice", last_name="Smith", email="alice@example.com")
|
||||
assert captured["op"] == "contacts.create"
|
||||
assert captured["args"]["firstName"] == "Alice"
|
||||
assert captured["args"]["lastName"] == "Smith"
|
||||
assert captured["args"]["email"]["value"] == "alice@example.com"
|
||||
assert result["ok"] is True
|
||||
|
||||
|
||||
def test_native_contacts_request_access_explicit_op(monkeypatch):
|
||||
from reyna_cli import privacy_host as ph_mod
|
||||
|
||||
captured = {}
|
||||
|
||||
class FakeClient:
|
||||
def __init__(self, timeout):
|
||||
captured["timeout"] = timeout
|
||||
|
||||
def call(self, op, args):
|
||||
captured["op"] = op
|
||||
captured["args"] = args
|
||||
return {"id": "x", "ok": True, "result": {"protocol_version": "1.0.0", "operation": "contacts.request_access", "status": "authorized"}}
|
||||
|
||||
monkeypatch.setattr(ph_mod, "PrivacyClient", FakeClient)
|
||||
result = ph_mod.native_contacts_request_access()
|
||||
assert captured["op"] == "contacts.request_access"
|
||||
assert captured["args"] == {}
|
||||
assert captured["timeout"] == 35
|
||||
assert result["result"]["status"] == "authorized"
|
||||
|
||||
|
||||
def test_native_contacts_no_mcp_import():
|
||||
from reyna_cli import privacy_host as ph_mod
|
||||
import pathlib
|
||||
src = pathlib.Path(ph_mod.__file__).read_text()
|
||||
assert "call_macmini_tool" not in src
|
||||
assert "macmini_client" not in src
|
||||
assert "def native_contacts_request_access" in src
|
||||
assert "contacts.request_access" in src
|
||||
assert "def native_contacts_search" in src
|
||||
assert "def native_contacts_read" in src
|
||||
assert "def native_contacts_create" in src
|
||||
|
||||
|
||||
def test_cli_contacts_search_uses_native(monkeypatch):
|
||||
def fake_search(query=None, limit=20):
|
||||
return {"ok": True, "source": "native_privacy_host", "result": {"contacts": [{"id": "1", "name": "Alice", "organization": "", "modifiedAt": "2026-01-01T00:00:00Z"}]}}
|
||||
|
||||
monkeypatch.setattr("reyna_cli.privacy_host.native_contacts_search", fake_search)
|
||||
res = runner.invoke(app, ["macmini", "contacts", "search", "--query", "alice", "--json"])
|
||||
assert res.exit_code == 0, res.stdout + res.stderr
|
||||
payload = json.loads(res.stdout)
|
||||
assert payload["ok"] is True
|
||||
|
||||
|
||||
def test_cli_contacts_read_uses_native(monkeypatch):
|
||||
def fake_read(contact_id):
|
||||
return {"ok": True, "source": "native_privacy_host", "result": {"contact": {"id": contact_id, "name": "Alice", "firstName": "Alice", "lastName": "", "organization": "", "jobTitle": "", "emails": [], "phones": [], "modifiedAt": "2026-01-01T00:00:00Z"}}}
|
||||
|
||||
monkeypatch.setattr("reyna_cli.privacy_host.native_contacts_read", fake_read)
|
||||
res = runner.invoke(app, ["macmini", "contacts", "read", "abc-123", "--json"])
|
||||
assert res.exit_code == 0, res.stdout + res.stderr
|
||||
payload = json.loads(res.stdout)
|
||||
assert payload["ok"] is True
|
||||
|
||||
|
||||
def test_cli_contacts_create_uses_native(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
def fake_create(first_name=None, last_name=None, organization=None, job_title=None, note=None, email=None, phone=None):
|
||||
captured["first_name"] = first_name
|
||||
captured["email"] = email
|
||||
return {"ok": True, "source": "native_privacy_host", "result": {"created_contact": {"id": "new", "name": "Alice", "organization": ""}}}
|
||||
|
||||
monkeypatch.setattr("reyna_cli.privacy_host.native_contacts_create", fake_create)
|
||||
res = runner.invoke(app, ["macmini", "contacts", "create", "--first-name", "Alice", "--email", "alice@example.com", "--json"])
|
||||
assert res.exit_code == 0, res.stdout + res.stderr
|
||||
assert captured["first_name"] == "Alice"
|
||||
assert captured["email"] == "alice@example.com"
|
||||
|
||||
|
||||
def test_cli_contacts_no_mcp_tool_call_remaining():
|
||||
from reyna_cli import cli as cli_mod
|
||||
src = Path(cli_mod.__file__).read_text()
|
||||
assert "native_contacts_search" in src
|
||||
assert "native_contacts_read" in src
|
||||
assert "native_contacts_create" in src
|
||||
assert 'call_macmini_tool("contacts_search"' not in src
|
||||
assert 'call_macmini_tool("contacts_read"' not in src
|
||||
assert 'call_macmini_tool("contacts_create"' not in src
|
||||
@@ -0,0 +1,268 @@
|
||||
"""Tests for Notes deferred status — no Notes integration may exist.
|
||||
|
||||
Assert:
|
||||
- no `notes.` protocol ops
|
||||
- no native_notes wrappers
|
||||
- no CLI Notes authorization/subcommands
|
||||
- AppleEvents usage key forbidden and absent from plist/app policy
|
||||
- no AppKit link/import
|
||||
- docs correctly say deferred + legacy untouched
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import plistlib
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
REPO = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def test_no_notes_protocol_ops_in_privacy_contract():
|
||||
from reyna_cli.privacy_contract import ALLOWED_OPERATIONS, _COMMAND_TO_OPERATION
|
||||
|
||||
# No operation may start with notes.
|
||||
for op in ALLOWED_OPERATIONS.keys():
|
||||
assert not op.startswith("notes."), f"forbidden notes op {op} still present — Notes is deferred"
|
||||
|
||||
# Explicit strings must not appear
|
||||
forbidden_ops = {"notes.list", "notes.read", "notes.create", "notes.request_access"}
|
||||
for fo in forbidden_ops:
|
||||
assert fo not in ALLOWED_OPERATIONS, f"forbidden {fo} present"
|
||||
|
||||
for cmd, op in _COMMAND_TO_OPERATION.items():
|
||||
assert not op.startswith("notes."), f"command {cmd} maps to forbidden notes op {op}"
|
||||
assert "notes" not in cmd.lower() or "notes" in cmd.lower() and False is False # allow key detection via op only
|
||||
# also forbid notes_* commands mapping
|
||||
assert not cmd.startswith("notes_"), f"forbidden notes command {cmd}"
|
||||
|
||||
|
||||
def test_no_native_notes_wrappers():
|
||||
src = (REPO / "src" / "reyna_cli" / "privacy_host.py").read_text()
|
||||
assert "native_notes" not in src, "native_notes wrappers must be removed — Notes deferred"
|
||||
assert "NotesProvider" not in src
|
||||
assert "NotesAuthorization" not in src
|
||||
# generic notes field as parameter name for calendar/reminders is allowed,
|
||||
# but operation names notes.list etc are forbidden — already checked by string search above
|
||||
# Ensure no wrapper defs remain
|
||||
for name in ["native_notes_list", "native_notes_read", "native_notes_create", "native_notes_request_access"]:
|
||||
assert name not in src
|
||||
|
||||
|
||||
def test_no_cli_notes_subcommands():
|
||||
cli_src = (REPO / "src" / "reyna_cli" / "cli.py").read_text()
|
||||
# macmini notes subcommands must be gone
|
||||
assert "macmini_notes" not in cli_src, "macmini_notes commands must be removed"
|
||||
# CLI must not register a notes typer under macmini
|
||||
# Check that macmini help mentions notes is gone — we test via source: no notes app registration
|
||||
# Allow word 'notes' as parameter name (calendar notes, reminder notes) — but not as subcommand registration
|
||||
# So forbid 'notes' app creation for macmini
|
||||
# Look for macmini_notes typed list/read/create defs
|
||||
assert "def macmini_notes" not in cli_src
|
||||
# Privacy-host notes-authorize must be gone
|
||||
assert "notes-authorize" not in cli_src
|
||||
assert "notes_authorize" not in cli_src
|
||||
# native_notes usage in cli must be gone
|
||||
assert "native_notes" not in cli_src
|
||||
# Ensure NotesProvider strings absent
|
||||
assert "NotesProvider" not in cli_src
|
||||
|
||||
|
||||
def test_appleevents_usage_forbidden():
|
||||
from reyna_cli import app_bundle as ab
|
||||
|
||||
d = ab.build_app_bundle_info_plist_dict()
|
||||
assert "NSAppleEventsUsageDescription" not in d, "AppleEvents usage must be forbidden — Notes deferred"
|
||||
# Also check Xcode source plist
|
||||
xcode_plist = REPO / "native" / "ReynaCLIHost" / "ReynaCLIHost" / "Info.plist"
|
||||
assert xcode_plist.exists()
|
||||
with open(xcode_plist, "rb") as f:
|
||||
xd = plistlib.load(f)
|
||||
assert "NSAppleEventsUsageDescription" not in xd, "Xcode Info.plist must not contain AppleEvents"
|
||||
|
||||
# App bundle policy: forbidden key must be flagged
|
||||
# Validate that forbidden set includes only Calendar, Contacts, Reminders
|
||||
allowed = {
|
||||
"NSCalendarsFullAccessUsageDescription",
|
||||
"NSCalendarsWriteOnlyAccessUsageDescription",
|
||||
"NSCalendarsUsageDescription",
|
||||
"NSContactsUsageDescription",
|
||||
"NSRemindersFullAccessUsageDescription",
|
||||
}
|
||||
for k in d.keys():
|
||||
if k.startswith("NS") and "UsageDescription" in k:
|
||||
assert k in allowed, f"unexpected usage key {k} — only calendar/contacts/reminders allowed (Notes deferred)"
|
||||
|
||||
|
||||
def test_app_bundle_validator_forbids_appleevents():
|
||||
from reyna_cli import app_bundle as ab
|
||||
import tempfile
|
||||
|
||||
repo_root = Path(tempfile.mkdtemp()) / "repo"
|
||||
bundle = repo_root / "native" / "ReynaCLIHost" / "dist" / "Reyna CLI.app"
|
||||
contents = bundle / "Contents"
|
||||
macos = contents / "MacOS"
|
||||
macos.mkdir(parents=True)
|
||||
exe = macos / "ReynaCLIHost"
|
||||
exe.write_bytes(b"binary")
|
||||
exe.chmod(0o755)
|
||||
|
||||
class Proc:
|
||||
returncode = 0
|
||||
stdout = ""
|
||||
stderr = "TeamIdentifier=TEAM123\nAuthority=Apple Development: Foo (TEAM123)\n"
|
||||
|
||||
def runner_ok(args, cwd=None, **kwargs):
|
||||
return Proc()
|
||||
|
||||
good = ab.build_app_bundle_info_plist_dict()
|
||||
with open(contents / "Info.plist", "wb") as f:
|
||||
plistlib.dump(good, f)
|
||||
res_ok = ab.validate_app_bundle(repo_root=repo_root, runner=runner_ok)
|
||||
assert res_ok["ok"] is True, f"should accept plist without AppleEvents: {res_ok['errors']}"
|
||||
|
||||
bad = dict(good)
|
||||
bad["NSAppleEventsUsageDescription"] = "Allow automation"
|
||||
with open(contents / "Info.plist", "wb") as f:
|
||||
plistlib.dump(bad, f)
|
||||
res_bad = ab.validate_app_bundle(repo_root=repo_root, runner=runner_ok)
|
||||
assert res_bad["ok"] is False
|
||||
assert any("appleevents" in e.lower() or "unexpected" in e.lower() or "forbidden" in e.lower() for e in res_bad["errors"])
|
||||
|
||||
|
||||
def test_no_appkit_link_or_import():
|
||||
# AppMain and AppEntry must not import AppKit, must not contain AppleEvents
|
||||
app_main = (REPO / "native" / "ReynaCLIHost" / "ReynaCLIHost" / "AppMain.swift").read_text()
|
||||
app_entry = (REPO / "native" / "ReynaCLIHost" / "Sources" / "ReynaCLIHostCore" / "AppEntry.swift").read_text()
|
||||
for txt, label in [(app_main, "AppMain"), (app_entry, "AppEntry")]:
|
||||
assert "AppKit" not in txt, f"{label} must not import AppKit — Notes deferred, headless"
|
||||
assert "NSAppleScript" not in txt
|
||||
assert "NSAppleEvent" not in txt
|
||||
assert "AppleEvents" not in txt
|
||||
|
||||
# Package.swift must not link AppKit
|
||||
pkg = (REPO / "native" / "ReynaCLIHost" / "Package.swift").read_text()
|
||||
assert "AppKit" not in pkg
|
||||
# pbxproj must not contain AppKit, NotesProvider, NotesAuthorization, ForegroundApp, AppleEventsUsageDescription
|
||||
pbx = (REPO / "native" / "ReynaCLIHost" / "ReynaCLIHost.xcodeproj" / "project.pbxproj").read_text()
|
||||
assert "AppKit.framework" not in pbx
|
||||
assert "NotesProvider.swift" not in pbx, "NotesProvider must not be in Xcode project — deferred"
|
||||
assert "NotesAuthorizationProvider.swift" not in pbx
|
||||
assert "ForegroundApp" not in pbx
|
||||
assert "ForegroundAuthorization" not in pbx
|
||||
assert "NSAppleEventsUsageDescription" not in pbx
|
||||
|
||||
# Protocol.swift must not reference notes operations
|
||||
proto = (REPO / "native" / "ReynaCLIHost" / "Sources" / "ReynaCLIHostCore" / "Protocol.swift").read_text()
|
||||
assert "notes.list" not in proto.lower()
|
||||
assert "notes.read" not in proto.lower()
|
||||
assert "notes.create" not in proto.lower()
|
||||
assert "notes.request_access" not in proto.lower()
|
||||
# Generic notes text field for calendar/reminders is allowed, but operation "notes." must not exist
|
||||
# Check for NotesProvider types
|
||||
assert "NotesProvider" not in proto
|
||||
assert "NotesAuthorization" not in proto
|
||||
assert "NoteListItem" not in proto and "NoteDetailItem" not in proto, "Notes data models must be removed"
|
||||
|
||||
|
||||
def test_no_notes_in_swift_sources():
|
||||
core_dir = REPO / "native" / "ReynaCLIHost" / "Sources" / "ReynaCLIHostCore"
|
||||
for p in core_dir.glob("*.swift"):
|
||||
txt = p.read_text()
|
||||
low = txt.lower()
|
||||
# forbid notes. ops
|
||||
assert "notes.list" not in low
|
||||
assert "notes.read" not in low
|
||||
assert "notes.create" not in low
|
||||
assert "notes.request_access" not in low
|
||||
# forbid NSAppleEvents and Notes-type providers
|
||||
assert "nsappleeventsusagedescription" not in low
|
||||
assert "NotesProvider" not in txt
|
||||
assert "NotesAuthorization" not in txt
|
||||
|
||||
|
||||
def test_swift_tests_deleted():
|
||||
tests_dir = REPO / "native" / "ReynaCLIHost" / "Tests" / "ReynaCLIHostTests"
|
||||
forbidden = ["NotesAuthorizationTests.swift", "NotesOperationsTests.swift", "ForegroundAuthorizationTests.swift"]
|
||||
for name in forbidden:
|
||||
assert not (tests_dir / name).exists(), f"{name} must be deleted"
|
||||
|
||||
|
||||
def test_python_notes_tests_deleted():
|
||||
assert not (REPO / "tests" / "test_foreground_notes_ls_authorize.py").exists()
|
||||
assert not (REPO / "tests" / "test_notes_authorization_boundary.py").exists()
|
||||
|
||||
|
||||
def test_docs_deferred_legacy_untouched():
|
||||
matrix_path = REPO / "docs" / "remaining-coverage-matrix.md"
|
||||
assert matrix_path.exists()
|
||||
txt = matrix_path.read_text()
|
||||
low = txt.lower()
|
||||
# Must say Notes deferred
|
||||
assert "notes" in low
|
||||
assert "deferred" in low, "docs must say Notes deferred"
|
||||
# Must say legacy untouched
|
||||
assert "legacy" in low
|
||||
assert "untouched" in low, "docs must say legacy untouched"
|
||||
# Must not say Notes belongs inside host as active work — should be in deferred section
|
||||
# Ensure no active plan to add NotesProvider now
|
||||
# Allow legacy mention but not as A TODO — check that if Notes is mentioned as A belongs, it's qualified as deferred
|
||||
# Simplest: ensure the doc contains explicit deferred banner
|
||||
assert "deferred" in txt, "doc must contain deferred word"
|
||||
# Ensure no forbidden old instructions about adding NotesProvider as immediate work without deferred qualifier
|
||||
# The deferred table should list Notes as deferred, not as Done A
|
||||
# We'll just ensure the word deferred appears near Notes line
|
||||
for line in txt.splitlines():
|
||||
if "notes" in line.lower() and ("list/read/create" in line.lower() or "notes.js" in line.lower()):
|
||||
# in that row, must mention deferred or C or out of scope
|
||||
assert "deferred" in line.lower() or "untouched" in txt.lower(), f"Notes row must mention deferred: {line}"
|
||||
|
||||
|
||||
def test_no_foreground_notes_references():
|
||||
# Search all source/test tree for forbidden patterns — but allow generic param named notes (calendar/reminder text)
|
||||
forbidden_exact = [
|
||||
"NotesProvider",
|
||||
"NotesAuthorization",
|
||||
"ForegroundApp",
|
||||
"foreground-notes",
|
||||
"NSAppleEventsUsageDescription",
|
||||
"notes.request_access",
|
||||
"native_notes",
|
||||
]
|
||||
exclude_dirs = {".venv", "__pycache__", ".git", "build", ".build", "DerivedData", "dist"}
|
||||
for pattern in forbidden_exact:
|
||||
for path in REPO.rglob("*"):
|
||||
if not path.is_file():
|
||||
continue
|
||||
# skip excluded
|
||||
if any(part in exclude_dirs for part in path.parts):
|
||||
continue
|
||||
# skip backup
|
||||
if "backup" in path.parts:
|
||||
continue
|
||||
# Only check relevant extensions
|
||||
if path.suffix not in {".py", ".swift", ".plist", ".md", ".pbxproj", ".toml", ".yaml", ".yml"}:
|
||||
# also check .xcodeproj is dir, pbxproj covered
|
||||
if path.name != "project.pbxproj":
|
||||
continue
|
||||
# Skip this test file itself if pattern is mentioned in test strings — we need to allow self-reference check for patterns inside this file?
|
||||
# For this file we will skip self to avoid false positive on literal search
|
||||
if path.name == "test_notes_deferred.py" or "tests" in path.parts:
|
||||
continue
|
||||
# Skip docs remaining-coverage — allowed to mention pattern but must also say deferred; we already validated
|
||||
# However per task: eliminate only actual Notes integration references (do not remove generic calendar/reminder text fields named notes)
|
||||
# For forbidden patterns search, we strictly forbid integration references in source/test, not docs describing deferred
|
||||
if "docs/" in str(path) and pattern == "NSAppleEventsUsageDescription":
|
||||
continue
|
||||
if path.name == "app_bundle.py" and pattern == "NSAppleEventsUsageDescription":
|
||||
continue
|
||||
try:
|
||||
txt = path.read_text(errors="ignore")
|
||||
except Exception:
|
||||
continue
|
||||
if pattern in txt:
|
||||
# Allow generic reminder/calendar 'notes' param already excluded by exact list above — so any hit is real integration
|
||||
# But also allow mention in backup
|
||||
if "test_notes_deferred" in str(path):
|
||||
continue
|
||||
raise AssertionError(f"forbidden pattern '{pattern}' found in {path}")
|
||||
@@ -0,0 +1,540 @@
|
||||
"""TDD for secure explicit prebuilt signed app install bridge — no DerivedData scan, no secret leak."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import plistlib
|
||||
import stat
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from reyna_cli.cli import app
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
|
||||
def _mock_proc(rc=0, stdout="", stderr=""):
|
||||
from types import SimpleNamespace
|
||||
|
||||
return SimpleNamespace(returncode=rc, stdout=stdout, stderr=stderr)
|
||||
|
||||
|
||||
def _make_fake_app_bundle(base: Path, bundle_name: str = "Reyna CLI.app", bundle_id: str = "com.reyna.cli.privacy-host"):
|
||||
"""Create a minimal .app bundle directory with Info.plist and executable."""
|
||||
from reyna_cli import app_bundle as ab
|
||||
|
||||
bundle = base / bundle_name
|
||||
contents = bundle / "Contents"
|
||||
macos = contents / "MacOS"
|
||||
macos.mkdir(parents=True, exist_ok=True)
|
||||
exe = macos / "ReynaCLIHost"
|
||||
exe.write_bytes(b"fakebinarycontent")
|
||||
exe.chmod(0o755)
|
||||
plist_dict = ab.build_app_bundle_info_plist_dict()
|
||||
# allow custom bundle_id for negative tests
|
||||
if bundle_id != plist_dict.get("CFBundleIdentifier"):
|
||||
plist_dict = dict(plist_dict)
|
||||
plist_dict["CFBundleIdentifier"] = bundle_id
|
||||
with open(contents / "Info.plist", "wb") as f:
|
||||
plistlib.dump(plist_dict, f)
|
||||
return bundle
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# app_bundle layer
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_validate_app_bundle_at_path_success(tmp_path):
|
||||
from reyna_cli import app_bundle as ab
|
||||
|
||||
src_root = tmp_path / "src"
|
||||
src_root.mkdir()
|
||||
bundle = _make_fake_app_bundle(src_root)
|
||||
|
||||
def run_ok(args, cwd=None, **kwargs):
|
||||
if args[:3] == ["codesign", "--verify", "--deep"]:
|
||||
return _mock_proc(rc=0)
|
||||
if args[:2] == ["codesign", "-dv"]:
|
||||
return _mock_proc(rc=0, stderr="TeamIdentifier=TEAM123\nAuthority=Apple Development: Foo (TEAM123)\n")
|
||||
return _mock_proc(rc=0)
|
||||
|
||||
res = ab.validate_app_bundle_at_path(bundle, runner=run_ok)
|
||||
assert res["ok"] is True
|
||||
assert res["signature_verified"] is True
|
||||
assert res["is_ad_hoc"] is False
|
||||
assert res["bundle_identifier_matches"] is True
|
||||
|
||||
|
||||
def test_validate_app_bundle_at_path_rejects_relative(tmp_path):
|
||||
from reyna_cli import app_bundle as ab
|
||||
|
||||
rel = Path("relative/Reyna CLI.app")
|
||||
res = ab.validate_app_bundle_at_path(rel)
|
||||
assert res["ok"] is False
|
||||
assert any("absolute" in e.lower() for e in res["errors"])
|
||||
|
||||
|
||||
def test_validate_app_bundle_at_path_rejects_wrong_suffix(tmp_path):
|
||||
from reyna_cli import app_bundle as ab
|
||||
|
||||
p = tmp_path / "notapp"
|
||||
p.mkdir()
|
||||
res = ab.validate_app_bundle_at_path(p)
|
||||
assert res["ok"] is False
|
||||
assert any(".app" in e.lower() for e in res["errors"])
|
||||
|
||||
|
||||
def test_validate_app_bundle_at_path_rejects_symlink(tmp_path):
|
||||
from reyna_cli import app_bundle as ab
|
||||
|
||||
real_root = tmp_path / "real"
|
||||
real_root.mkdir()
|
||||
bundle = _make_fake_app_bundle(real_root)
|
||||
link = tmp_path / "Reyna CLI.app"
|
||||
link.symlink_to(bundle)
|
||||
|
||||
res = ab.validate_app_bundle_at_path(link)
|
||||
assert res["ok"] is False
|
||||
assert any("symlink" in e.lower() for e in res["errors"])
|
||||
|
||||
|
||||
def test_validate_app_bundle_at_path_rejects_ad_hoc(tmp_path):
|
||||
from reyna_cli import app_bundle as ab
|
||||
|
||||
src_root = tmp_path / "src"
|
||||
src_root.mkdir()
|
||||
bundle = _make_fake_app_bundle(src_root)
|
||||
|
||||
def run_adhoc(args, cwd=None, **kwargs):
|
||||
if args[:3] == ["codesign", "--verify", "--deep"]:
|
||||
return _mock_proc(rc=0)
|
||||
if args[:2] == ["codesign", "-dv"]:
|
||||
return _mock_proc(rc=0, stderr="Signature=adhoc\nTeamIdentifier=not set\n")
|
||||
return _mock_proc(rc=0)
|
||||
|
||||
res = ab.validate_app_bundle_at_path(bundle, runner=run_adhoc)
|
||||
assert res["ok"] is False
|
||||
assert res["is_ad_hoc"] is True
|
||||
|
||||
|
||||
def test_install_prebuilt_app_bundle_success_no_xcodebuild(tmp_path):
|
||||
from reyna_cli import app_bundle as ab
|
||||
|
||||
src_root = tmp_path / "gui-build"
|
||||
src_root.mkdir()
|
||||
bundle = _make_fake_app_bundle(src_root)
|
||||
|
||||
repo_root = tmp_path / "repo"
|
||||
repo_root.mkdir()
|
||||
|
||||
calls = []
|
||||
|
||||
def runner(args, cwd=None, **kwargs):
|
||||
assert isinstance(args, list)
|
||||
calls.append(list(args))
|
||||
if args and args[0] == "xcodebuild":
|
||||
raise AssertionError("xcodebuild must NOT be called on prebuilt path")
|
||||
if args[:3] == ["codesign", "--verify", "--deep"]:
|
||||
return _mock_proc(rc=0)
|
||||
if args[:2] == ["codesign", "-dv"]:
|
||||
return _mock_proc(rc=0, stderr="TeamIdentifier=TEAM123\n")
|
||||
return _mock_proc(rc=0)
|
||||
|
||||
res = ab.install_prebuilt_app_bundle(source_bundle_path=bundle, repo_root=repo_root, runner=runner)
|
||||
assert res["ok"] is True
|
||||
assert res["action"] == "install_prebuilt_app_bundle"
|
||||
assert "signature_verified" in res
|
||||
# Must have copied to dist
|
||||
dist_bundle = repo_root / "native" / "ReynaCLIHost" / "dist" / "Reyna CLI.app"
|
||||
assert dist_bundle.exists()
|
||||
assert (dist_bundle / "Contents" / "MacOS" / "ReynaCLIHost").exists()
|
||||
# No xcodebuild calls
|
||||
assert all(c[0] != "xcodebuild" for c in calls)
|
||||
# Must have called codesign validation for source and copy (at least 2 verifies)
|
||||
verify_calls = [c for c in calls if c[:3] == ["codesign", "--verify", "--deep"]]
|
||||
assert len(verify_calls) >= 2
|
||||
|
||||
|
||||
def test_install_prebuilt_app_bundle_invalid_prevents_copy(tmp_path, monkeypatch):
|
||||
from reyna_cli import app_bundle as ab
|
||||
|
||||
src_root = tmp_path / "gui-build"
|
||||
src_root.mkdir()
|
||||
bundle = _make_fake_app_bundle(src_root)
|
||||
|
||||
repo_root = tmp_path / "repo"
|
||||
repo_root.mkdir()
|
||||
|
||||
def runner_bad(args, cwd=None, **kwargs):
|
||||
if args[:3] == ["codesign", "--verify", "--deep"]:
|
||||
return _mock_proc(rc=1, stderr="verify fail")
|
||||
if args[:2] == ["codesign", "-dv"]:
|
||||
return _mock_proc(rc=0, stderr="TeamIdentifier=not set\n")
|
||||
return _mock_proc(rc=0)
|
||||
|
||||
# Patch copy to detect if called
|
||||
copied = {"called": False}
|
||||
original_copy = ab._copy_app_bundle_atomic
|
||||
|
||||
def tracking_copy(src, dst):
|
||||
copied["called"] = True
|
||||
return original_copy(src, dst)
|
||||
|
||||
monkeypatch.setattr(ab, "_copy_app_bundle_atomic", tracking_copy)
|
||||
|
||||
res = ab.install_prebuilt_app_bundle(source_bundle_path=bundle, repo_root=repo_root, runner=runner_bad)
|
||||
assert res["ok"] is False
|
||||
assert copied["called"] is False, "must not copy if source validation fails"
|
||||
dist_bundle = repo_root / "native" / "ReynaCLIHost" / "dist" / "Reyna CLI.app"
|
||||
assert not dist_bundle.exists()
|
||||
|
||||
|
||||
def test_install_prebuilt_app_bundle_rejects_symlink_and_relative(tmp_path):
|
||||
from reyna_cli import app_bundle as ab
|
||||
|
||||
real_root = tmp_path / "real"
|
||||
real_root.mkdir()
|
||||
bundle = _make_fake_app_bundle(real_root)
|
||||
link = tmp_path / "Reyna CLI.app"
|
||||
link.symlink_to(bundle)
|
||||
|
||||
repo_root = tmp_path / "repo"
|
||||
repo_root.mkdir()
|
||||
|
||||
def runner(args, cwd=None, **kwargs):
|
||||
return _mock_proc(rc=0)
|
||||
|
||||
res_link = ab.install_prebuilt_app_bundle(source_bundle_path=link, repo_root=repo_root, runner=runner)
|
||||
assert res_link["ok"] is False
|
||||
assert "symlink" in res_link["error"].lower()
|
||||
|
||||
# Relative
|
||||
rel = Path("relative/Reyna CLI.app")
|
||||
res_rel = ab.install_prebuilt_app_bundle(source_bundle_path=rel, repo_root=repo_root, runner=runner)
|
||||
assert res_rel["ok"] is False
|
||||
assert "absolute" in res_rel["error"].lower()
|
||||
|
||||
# Wrong suffix
|
||||
wrong = tmp_path / "wrong.appstuff"
|
||||
wrong.mkdir()
|
||||
res_suffix = ab.install_prebuilt_app_bundle(source_bundle_path=wrong, repo_root=repo_root, runner=runner)
|
||||
assert res_suffix["ok"] is False
|
||||
assert ".app" in res_suffix["error"].lower()
|
||||
|
||||
|
||||
def test_install_prebuilt_no_secret_leak_in_result(tmp_path):
|
||||
from reyna_cli import app_bundle as ab
|
||||
|
||||
src_root = tmp_path / "src"
|
||||
src_root.mkdir()
|
||||
bundle = _make_fake_app_bundle(src_root)
|
||||
|
||||
repo_root = tmp_path / "repo"
|
||||
repo_root.mkdir()
|
||||
|
||||
secret_team = "TEAM_SUPERSECRET123"
|
||||
|
||||
def runner(args, cwd=None, **kwargs):
|
||||
if args[:3] == ["codesign", "--verify", "--deep"]:
|
||||
return _mock_proc(rc=0, stdout=f"Authority=Secret {secret_team}")
|
||||
if args[:2] == ["codesign", "-dv"]:
|
||||
return _mock_proc(rc=0, stderr=f"TeamIdentifier={secret_team}\nAuthority=Apple Development: Foo ({secret_team})\n")
|
||||
return _mock_proc(rc=0)
|
||||
|
||||
res = ab.install_prebuilt_app_bundle(source_bundle_path=bundle, repo_root=repo_root, runner=runner)
|
||||
assert res["ok"] is True
|
||||
# Stringified result must not contain raw team identifier output (we filter generically)
|
||||
# Our runner purposely returns secret in stderr, but result should not echo stderr verbatim
|
||||
import json
|
||||
|
||||
res_str = json.dumps(res)
|
||||
# The implementation stores no raw codesign output, only booleans
|
||||
assert "Authority=Apple" not in res_str
|
||||
assert secret_team not in res_str or res.get("validation", {}).get("signature_verified") is True and secret_team not in str(res.get("validation", {}).get("errors", []))
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# privacy_host layer — prebuilt route
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_privacy_host_install_prebuilt_success_no_xcodebuild(tmp_path):
|
||||
from reyna_cli import privacy_host as ph_mod, app_bundle as ab_mod
|
||||
|
||||
src_root = tmp_path / "gui"
|
||||
src_root.mkdir()
|
||||
src_bundle = _make_fake_app_bundle(src_root)
|
||||
|
||||
repo_root = tmp_path / "repo"
|
||||
repo_root.mkdir()
|
||||
sock = tmp_path / "priv" / "reyna-cli.sock"
|
||||
plist = tmp_path / "LaunchAgents" / "com.reyna.cli.privacy-host.plist"
|
||||
logd = tmp_path / "Logs"
|
||||
|
||||
calls = []
|
||||
|
||||
def fake_runner(args, cwd=None, **kwargs):
|
||||
assert isinstance(args, list)
|
||||
calls.append(list(args))
|
||||
if args and args[0] == "xcodebuild":
|
||||
raise AssertionError("xcodebuild must not be called when prebuilt path supplied")
|
||||
if args[:3] == ["codesign", "--verify", "--deep"]:
|
||||
return _mock_proc(rc=0)
|
||||
if args[:2] == ["codesign", "-dv"]:
|
||||
return _mock_proc(rc=0, stderr="TeamIdentifier=TEAM123\n")
|
||||
return _mock_proc(rc=0)
|
||||
|
||||
result = ph_mod.install_privacy_host_service(
|
||||
runner=fake_runner,
|
||||
uid=501,
|
||||
repo_root=repo_root,
|
||||
socket_path=sock,
|
||||
plist_path=plist,
|
||||
log_dir=logd,
|
||||
prebuilt_app_bundle_path=src_bundle,
|
||||
)
|
||||
|
||||
assert result["ok"] is True
|
||||
assert plist.exists()
|
||||
# No xcodebuild
|
||||
assert all(c[0] != "xcodebuild" for c in calls)
|
||||
# Must have bootout+bootstrap
|
||||
assert ["launchctl", "bootout", "gui/501/com.reyna.cli.privacy-host"] in calls
|
||||
assert ["launchctl", "bootstrap", "gui/501", str(plist)] in calls
|
||||
# Dist bundle exists
|
||||
dist_bundle = repo_root / "native" / "ReynaCLIHost" / "dist" / "Reyna CLI.app"
|
||||
assert dist_bundle.exists()
|
||||
|
||||
|
||||
def test_privacy_host_install_prebuilt_invalid_prevents_plist_and_launchctl(tmp_path):
|
||||
from reyna_cli import privacy_host as ph_mod
|
||||
|
||||
src_root = tmp_path / "gui"
|
||||
src_root.mkdir()
|
||||
src_bundle = _make_fake_app_bundle(src_root)
|
||||
|
||||
repo_root = tmp_path / "repo"
|
||||
repo_root.mkdir()
|
||||
sock = tmp_path / "priv" / "reyna-cli.sock"
|
||||
plist = tmp_path / "LaunchAgents" / "com.reyna.cli.privacy-host.plist"
|
||||
logd = tmp_path / "Logs"
|
||||
|
||||
calls = []
|
||||
|
||||
def fake_runner(args, cwd=None, **kwargs):
|
||||
calls.append(list(args))
|
||||
if args[:3] == ["codesign", "--verify", "--deep"]:
|
||||
return _mock_proc(rc=1, stderr="fail")
|
||||
if args[:2] == ["codesign", "-dv"]:
|
||||
return _mock_proc(rc=0, stderr="TeamIdentifier=not set\nSignature=adhoc\n")
|
||||
return _mock_proc(rc=0)
|
||||
|
||||
result = ph_mod.install_privacy_host_service(
|
||||
runner=fake_runner,
|
||||
uid=501,
|
||||
repo_root=repo_root,
|
||||
socket_path=sock,
|
||||
plist_path=plist,
|
||||
log_dir=logd,
|
||||
prebuilt_app_bundle_path=src_bundle,
|
||||
)
|
||||
|
||||
assert result["ok"] is False
|
||||
assert not plist.exists(), "plist must not be written if prebuilt validation fails"
|
||||
# No launchctl should have been called
|
||||
launch_calls = [c for c in calls if c and c[0] == "launchctl"]
|
||||
assert len(launch_calls) == 0, f"launchctl must not be called on invalid source, got {launch_calls}"
|
||||
|
||||
|
||||
def test_privacy_host_install_prebuilt_rejects_symlink_and_relative(tmp_path):
|
||||
from reyna_cli import privacy_host as ph_mod
|
||||
|
||||
real_root = tmp_path / "real"
|
||||
real_root.mkdir()
|
||||
real_bundle = _make_fake_app_bundle(real_root)
|
||||
|
||||
repo_root = tmp_path / "repo"
|
||||
repo_root.mkdir()
|
||||
plist = tmp_path / "LaunchAgents" / "com.reyna.cli.privacy-host.plist"
|
||||
|
||||
link = tmp_path / "Reyna CLI.app"
|
||||
link.symlink_to(real_bundle)
|
||||
|
||||
def runner(args, cwd=None, **kwargs):
|
||||
return _mock_proc(rc=0)
|
||||
|
||||
res_link = ph_mod.install_privacy_host_service(
|
||||
runner=runner,
|
||||
uid=501,
|
||||
repo_root=repo_root,
|
||||
plist_path=plist,
|
||||
prebuilt_app_bundle_path=link,
|
||||
)
|
||||
assert res_link["ok"] is False
|
||||
assert "symlink" in res_link["error"].lower()
|
||||
|
||||
# Relative
|
||||
rel = Path("relative/Reyna CLI.app")
|
||||
res_rel = ph_mod.install_privacy_host_service(
|
||||
runner=runner,
|
||||
uid=501,
|
||||
repo_root=repo_root,
|
||||
plist_path=plist,
|
||||
prebuilt_app_bundle_path=rel,
|
||||
)
|
||||
assert res_rel["ok"] is False
|
||||
assert "absolute" in res_rel["error"].lower()
|
||||
|
||||
|
||||
def test_privacy_host_install_default_still_calls_xcodebuild(tmp_path):
|
||||
from reyna_cli import privacy_host as ph_mod, app_bundle as ab_mod
|
||||
|
||||
repo_root = tmp_path / "repo"
|
||||
repo_root.mkdir()
|
||||
(repo_root / "native" / "ReynaCLIHost" / "ReynaCLIHost.xcodeproj").mkdir(parents=True)
|
||||
(repo_root / "native" / "ReynaCLIHost" / "ReynaCLIHost").mkdir(parents=True, exist_ok=True)
|
||||
(repo_root / "native" / "ReynaCLIHost" / "ReynaCLIHost" / "Info.plist").write_text("<plist></plist>")
|
||||
|
||||
derived = repo_root / "native" / "ReynaCLIHost" / "build" / "DerivedData"
|
||||
built_app = derived / "Build" / "Products" / "Release" / "Reyna CLI.app"
|
||||
(built_app / "Contents" / "MacOS").mkdir(parents=True)
|
||||
(built_app / "Contents" / "MacOS" / "ReynaCLIHost").write_bytes(b"bin")
|
||||
with open(built_app / "Contents" / "Info.plist", "wb") as f:
|
||||
plistlib.dump(ab_mod.build_app_bundle_info_plist_dict(), f)
|
||||
|
||||
sock = tmp_path / "priv" / "reyna-cli.sock"
|
||||
plist = tmp_path / "LaunchAgents" / "com.reyna.cli.privacy-host.plist"
|
||||
logd = tmp_path / "Logs"
|
||||
|
||||
calls = []
|
||||
|
||||
def fake_runner(args, cwd=None, **kwargs):
|
||||
calls.append(list(args))
|
||||
if args and args[0] == "xcodebuild":
|
||||
return _mock_proc(rc=0)
|
||||
if args[:3] == ["codesign", "--verify", "--deep"]:
|
||||
return _mock_proc(rc=0)
|
||||
if args[:2] == ["codesign", "-dv"]:
|
||||
return _mock_proc(rc=0, stderr="TeamIdentifier=TEAM123\n")
|
||||
return _mock_proc(rc=0)
|
||||
|
||||
result = ph_mod.install_privacy_host_service(
|
||||
runner=fake_runner,
|
||||
uid=501,
|
||||
repo_root=repo_root,
|
||||
socket_path=sock,
|
||||
plist_path=plist,
|
||||
log_dir=logd,
|
||||
signing_identity="Test ID",
|
||||
prebuilt_app_bundle_path=None,
|
||||
)
|
||||
assert result["ok"] is True
|
||||
xcode_calls = [c for c in calls if c and c[0] == "xcodebuild"]
|
||||
assert len(xcode_calls) >= 1, "default install must still call xcodebuild"
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# CLI wiring
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_cli_install_has_app_bundle_option():
|
||||
res = runner.invoke(app, ["privacy-host", "install", "--help"])
|
||||
assert res.exit_code == 0
|
||||
out = res.stdout.lower()
|
||||
assert "app-bundle" in out
|
||||
|
||||
|
||||
def test_cli_install_app_bundle_prebuilt_success_mocked(tmp_path, monkeypatch):
|
||||
from reyna_cli import privacy_host as ph_mod
|
||||
|
||||
src_root = tmp_path / "gui"
|
||||
src_root.mkdir()
|
||||
src_bundle = _make_fake_app_bundle(src_root)
|
||||
|
||||
captured = {}
|
||||
|
||||
def fake_install(prebuilt_app_bundle_path=None, **kwargs):
|
||||
captured["prebuilt"] = prebuilt_app_bundle_path
|
||||
assert prebuilt_app_bundle_path is not None
|
||||
assert Path(prebuilt_app_bundle_path).is_absolute()
|
||||
assert str(prebuilt_app_bundle_path).endswith(".app")
|
||||
return {"ok": True, "action": "install", "app_bundle_path": str(prebuilt_app_bundle_path)}
|
||||
|
||||
monkeypatch.setattr(ph_mod, "install_privacy_host_service", lambda **kw: fake_install(**kw))
|
||||
|
||||
res = runner.invoke(app, ["privacy-host", "install", "--app-bundle", str(src_bundle), "--json"])
|
||||
assert res.exit_code == 0, res.stdout + res.stderr
|
||||
assert captured["prebuilt"] == src_bundle
|
||||
|
||||
|
||||
def test_cli_install_app_bundle_rejects_relative_and_symlink(tmp_path, monkeypatch):
|
||||
from reyna_cli import privacy_host as ph_mod
|
||||
|
||||
# Should fail at CLI layer before calling service, for relative
|
||||
def should_not_be_called(**kwargs):
|
||||
raise AssertionError("service must not be called when CLI rejects path")
|
||||
|
||||
monkeypatch.setattr(ph_mod, "install_privacy_host_service", lambda **kw: should_not_be_called(**kw))
|
||||
|
||||
# Relative
|
||||
res_rel = runner.invoke(app, ["privacy-host", "install", "--app-bundle", "relative/Reyna CLI.app", "--json"])
|
||||
assert res_rel.exit_code != 0
|
||||
assert "absolute" in res_rel.stdout.lower()
|
||||
|
||||
# Symlink
|
||||
real_root = tmp_path / "real"
|
||||
real_root.mkdir()
|
||||
real_bundle = _make_fake_app_bundle(real_root)
|
||||
link = tmp_path / "Reyna CLI.app"
|
||||
link.symlink_to(real_bundle)
|
||||
|
||||
res_link = runner.invoke(app, ["privacy-host", "install", "--app-bundle", str(link), "--json"])
|
||||
assert res_link.exit_code != 0
|
||||
assert "symlink" in res_link.stdout.lower()
|
||||
|
||||
|
||||
def test_cli_install_default_no_app_bundle_calls_build(tmp_path, monkeypatch):
|
||||
from reyna_cli import privacy_host as ph_mod
|
||||
|
||||
captured = {}
|
||||
|
||||
def fake_install(prebuilt_app_bundle_path=None, **kwargs):
|
||||
captured["prebuilt"] = prebuilt_app_bundle_path
|
||||
return {"ok": True, "action": "install", "app_bundle_path": "/fake/dist/Reyna CLI.app"}
|
||||
|
||||
monkeypatch.setattr(ph_mod, "install_privacy_host_service", lambda **kw: fake_install(**kw))
|
||||
|
||||
res = runner.invoke(app, ["privacy-host", "install", "--json"])
|
||||
assert res.exit_code == 0, res.stdout + res.stderr
|
||||
assert captured["prebuilt"] is None, "default should not pass prebuilt path"
|
||||
|
||||
|
||||
def test_no_deriveddata_scan_in_prebuilt_code():
|
||||
from pathlib import Path
|
||||
|
||||
src_app_bundle = Path("/Users/adolforeyna/Projects/reyna-cli/src/reyna_cli/app_bundle.py").read_text()
|
||||
src_privacy_host = Path("/Users/adolforeyna/Projects/reyna-cli/src/reyna_cli/privacy_host.py").read_text()
|
||||
# Prebuilt functions must not scan DerivedData automatically
|
||||
# They should not listdir or glob DerivedData without explicit path
|
||||
# Check that install_prebuilt_app_bundle does not reference DerivedData path discovery
|
||||
assert "install_prebuilt_app_bundle" in src_app_bundle
|
||||
# Ensure function body does not contain DerivedData scan (like os.walk or glob of DerivedData)
|
||||
# Simple heuristic: function definition area should not contain "DerivedData" search
|
||||
import re
|
||||
|
||||
# Extract install_prebuilt function
|
||||
m = re.search(r"def install_prebuilt_app_bundle.*?^def ", src_app_bundle, flags=re.DOTALL | re.MULTILINE)
|
||||
if m:
|
||||
func_text = m.group(0)
|
||||
# Should not contain "DerivedData" except maybe in comments about NOT using it
|
||||
# Allow at most trivial mention, but not listdir/glob
|
||||
assert "glob" not in func_text.lower() or "deriveddata" not in func_text.lower()
|
||||
assert "os.scandir" not in func_text.lower()
|
||||
assert "os.walk" not in func_text.lower()
|
||||
# privacy_host prebuilt path should not call build_app_bundle
|
||||
# It should have conditional: if prebuilt_app_bundle_path is not None -> install_prebuilt, else build
|
||||
assert "prebuilt_app_bundle_path" in src_privacy_host
|
||||
@@ -0,0 +1,244 @@
|
||||
"""Tests for PrivacyClient — TDD with real Unix socket servers, no socket mocks."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
from reyna_cli.privacy_client import (
|
||||
PrivacyClient,
|
||||
PrivacyClientError,
|
||||
default_socket_path,
|
||||
)
|
||||
|
||||
|
||||
def test_default_socket_path():
|
||||
p = default_socket_path()
|
||||
# must be Path and match ~/Library/Application Support/reyna-cli/privacy/reyna-cli.sock
|
||||
expected = Path.home() / "Library/Application Support/reyna-cli/privacy/reyna-cli.sock"
|
||||
# Also accept expanded: Path.home() / "Library/Application Support/reyna-cli/privacy/reyna-cli.sock"
|
||||
# Compare string representation or Path equality
|
||||
assert isinstance(p, Path)
|
||||
assert p == expected
|
||||
assert str(p).endswith("Library/Application Support/reyna-cli/privacy/reyna-cli.sock")
|
||||
|
||||
|
||||
# --- helpers for real socket server ---
|
||||
|
||||
class OneShotServer:
|
||||
"""Simple real Unix socket server that handles one connection with a custom handler."""
|
||||
|
||||
def __init__(self, handler):
|
||||
self.handler = handler
|
||||
self.tmpdir = tempfile.TemporaryDirectory()
|
||||
self.sock_path = Path(self.tmpdir.name) / "test.sock"
|
||||
self._thread = None
|
||||
self._ready = threading.Event()
|
||||
self._done = threading.Event()
|
||||
self.exception = None
|
||||
|
||||
def start(self):
|
||||
def run():
|
||||
srv = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
try:
|
||||
srv.bind(str(self.sock_path))
|
||||
srv.listen(1)
|
||||
self._ready.set()
|
||||
srv.settimeout(5)
|
||||
try:
|
||||
conn, _ = srv.accept()
|
||||
except socket.timeout:
|
||||
return
|
||||
try:
|
||||
self.handler(conn)
|
||||
except Exception as e:
|
||||
self.exception = e
|
||||
finally:
|
||||
try:
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
srv.close()
|
||||
self._done.set()
|
||||
|
||||
self._thread = threading.Thread(target=run, daemon=True)
|
||||
self._thread.start()
|
||||
assert self._ready.wait(timeout=3), "server failed to start"
|
||||
return self
|
||||
|
||||
def stop(self):
|
||||
self._done.wait(timeout=3)
|
||||
if self._thread:
|
||||
self._thread.join(timeout=1)
|
||||
self.tmpdir.cleanup()
|
||||
if self.exception:
|
||||
raise self.exception
|
||||
|
||||
def __enter__(self):
|
||||
return self.start()
|
||||
|
||||
def __exit__(self, *args):
|
||||
self.stop()
|
||||
|
||||
|
||||
def read_one_line(conn: socket.socket, timeout=2) -> dict:
|
||||
conn.settimeout(timeout)
|
||||
buf = b""
|
||||
while b"\n" not in buf:
|
||||
chunk = conn.recv(4096)
|
||||
if not chunk:
|
||||
break
|
||||
buf += chunk
|
||||
line = buf.split(b"\n")[0]
|
||||
return json.loads(line.decode("utf-8"))
|
||||
|
||||
|
||||
def test_call_happy_path_sends_one_json_line_and_validates():
|
||||
received = {}
|
||||
|
||||
def handler(conn):
|
||||
# read exactly one json line
|
||||
conn.settimeout(2)
|
||||
data = b""
|
||||
while not data.endswith(b"\n"):
|
||||
chunk = conn.recv(4096)
|
||||
if not chunk:
|
||||
break
|
||||
data += chunk
|
||||
# ensure only one line sent (count newline)
|
||||
if data.count(b"\n") > 1:
|
||||
raise AssertionError("client sent more than one line")
|
||||
assert data.endswith(b"\n")
|
||||
obj = json.loads(data.decode())
|
||||
received.update(obj)
|
||||
assert "id" in obj and isinstance(obj["id"], str) and obj["id"]
|
||||
assert obj["operation"] == "service.health"
|
||||
assert obj["arguments"] == {"x": 1}
|
||||
# echo back with same id
|
||||
resp = {"id": obj["id"], "ok": True, "result": {"status": "ok"}}
|
||||
conn.sendall((json.dumps(resp) + "\n").encode())
|
||||
|
||||
with OneShotServer(handler) as srv:
|
||||
client = PrivacyClient(socket_path=srv.sock_path, timeout=2)
|
||||
resp = client.call("service.health", {"x": 1})
|
||||
|
||||
assert resp["ok"] is True
|
||||
assert resp["result"]["status"] == "ok"
|
||||
assert received["id"]
|
||||
# id uniqueness check - call again should be different
|
||||
second_id = {}
|
||||
|
||||
def handler2(conn):
|
||||
obj = read_one_line(conn)
|
||||
second_id["id"] = obj["id"]
|
||||
resp = {"id": obj["id"], "ok": True, "result": {}}
|
||||
conn.sendall((json.dumps(resp) + "\n").encode())
|
||||
|
||||
with OneShotServer(handler2) as srv:
|
||||
client = PrivacyClient(socket_path=srv.sock_path, timeout=2)
|
||||
client.call("service.health", {})
|
||||
|
||||
assert received["id"] != second_id["id"]
|
||||
|
||||
|
||||
def test_call_missing_socket_raises():
|
||||
tmp = Path(tempfile.gettempdir()) / f"nonexistent-{uuid.uuid4().hex}.sock"
|
||||
client = PrivacyClient(socket_path=tmp, timeout=1)
|
||||
with pytest.raises(PrivacyClientError, match="(?i)socket|missing|not found|connect|no such"):
|
||||
client.call("service.health", {})
|
||||
|
||||
|
||||
def test_call_timeout_raises():
|
||||
def handler(conn):
|
||||
# never respond, just sleep longer than client timeout
|
||||
time.sleep(3)
|
||||
|
||||
with OneShotServer(handler) as srv:
|
||||
client = PrivacyClient(socket_path=srv.sock_path, timeout=0.3)
|
||||
with pytest.raises(PrivacyClientError, match="(?i)timeout|timed out"):
|
||||
client.call("service.health", {})
|
||||
|
||||
|
||||
def test_call_malformed_json_response_raises():
|
||||
def handler(conn):
|
||||
_ = read_one_line(conn)
|
||||
conn.sendall(b"not-json\n")
|
||||
|
||||
with OneShotServer(handler) as srv:
|
||||
client = PrivacyClient(socket_path=srv.sock_path, timeout=2)
|
||||
with pytest.raises(PrivacyClientError, match="(?i)malformed|invalid|json"):
|
||||
client.call("service.health", {})
|
||||
|
||||
|
||||
def test_call_mismatched_id_raises():
|
||||
def handler(conn):
|
||||
obj = read_one_line(conn)
|
||||
resp = {"id": "different-" + obj["id"], "ok": True, "result": {}}
|
||||
conn.sendall((json.dumps(resp) + "\n").encode())
|
||||
|
||||
with OneShotServer(handler) as srv:
|
||||
client = PrivacyClient(socket_path=srv.sock_path, timeout=2)
|
||||
with pytest.raises(PrivacyClientError, match="(?i)mismatch|id"):
|
||||
client.call("service.health", {})
|
||||
|
||||
|
||||
def test_call_ok_false_raises():
|
||||
def handler(conn):
|
||||
obj = read_one_line(conn)
|
||||
resp = {"id": obj["id"], "ok": False, "error": "forbidden"}
|
||||
conn.sendall((json.dumps(resp) + "\n").encode())
|
||||
|
||||
with OneShotServer(handler) as srv:
|
||||
client = PrivacyClient(socket_path=srv.sock_path, timeout=2)
|
||||
with pytest.raises(PrivacyClientError, match="(?i)forbidden|ok.*false|false"):
|
||||
client.call("service.health", {})
|
||||
|
||||
|
||||
def test_call_payload_too_large_before_connect():
|
||||
# 64 KiB limit
|
||||
large_arg = "x" * (70 * 1024)
|
||||
# Use a non-existent socket path; should fail on size check BEFORE attempting connect
|
||||
# So we can tell it didn't try to connect if error mentions size
|
||||
tmp = Path(tempfile.gettempdir()) / f"nonexistent-{uuid.uuid4().hex}.sock"
|
||||
client = PrivacyClient(socket_path=tmp, timeout=1)
|
||||
with pytest.raises(PrivacyClientError, match="(?i)64|size|large|payload|KiB"):
|
||||
client.call("service.health", {"big": large_arg})
|
||||
|
||||
# Also test just over limit with real server not needed - ensure no socket file created attempt is made
|
||||
# To be sure it didn't connect, we use a server and check that handler was NOT called
|
||||
called = {"yes": False}
|
||||
|
||||
def handler(conn):
|
||||
called["yes"] = True
|
||||
obj = read_one_line(conn)
|
||||
resp = {"id": obj["id"], "ok": True}
|
||||
conn.sendall((json.dumps(resp) + "\n").encode())
|
||||
|
||||
with OneShotServer(handler) as srv:
|
||||
client = PrivacyClient(socket_path=srv.sock_path, timeout=2)
|
||||
with pytest.raises(PrivacyClientError, match="(?i)size|large|payload|64"):
|
||||
client.call("op", {"big": large_arg})
|
||||
# give short time for any unwanted connection
|
||||
time.sleep(0.2)
|
||||
assert not called["yes"], "should not have connected when payload too large"
|
||||
|
||||
|
||||
def test_call_no_arguments_defaults_to_empty():
|
||||
def handler(conn):
|
||||
obj = read_one_line(conn)
|
||||
assert obj["arguments"] == {}
|
||||
resp = {"id": obj["id"], "ok": True, "result": "empty-ok"}
|
||||
conn.sendall((json.dumps(resp) + "\n").encode())
|
||||
|
||||
with OneShotServer(handler) as srv:
|
||||
client = PrivacyClient(socket_path=srv.sock_path, timeout=2)
|
||||
resp = client.call("calendar.list")
|
||||
assert resp["result"] == "empty-ok"
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Foundation slice: privacy contract — RED phase (should fail until module exists)."""
|
||||
|
||||
def test_allowlist_registry_includes_required_operations():
|
||||
from reyna_cli.privacy_contract import ALLOWED_OPERATIONS
|
||||
|
||||
assert "service.health" in ALLOWED_OPERATIONS
|
||||
assert "calendar.list" in ALLOWED_OPERATIONS
|
||||
|
||||
|
||||
def test_command_to_operation_mapping():
|
||||
from reyna_cli.privacy_contract import command_to_operation
|
||||
|
||||
assert command_to_operation("calendar_list_calendars") == "calendar.list"
|
||||
|
||||
|
||||
def test_scrub_privacy_result_redacts_sensitive_keys_case_insensitive_recursive():
|
||||
from reyna_cli.privacy_contract import scrub_privacy_result
|
||||
|
||||
payload = {
|
||||
"ok": True,
|
||||
"token": "should-redact",
|
||||
"nested": {
|
||||
"Password": "secret123",
|
||||
"safe": "keep-me",
|
||||
"deep": [{"SECRET": "hide", "value": 1}, {"Api_Key": "abc", "x": "y"}],
|
||||
},
|
||||
"Authorization": "Bearer xyz",
|
||||
"api_key": "key123",
|
||||
"normal": "visible",
|
||||
}
|
||||
scrubbed = scrub_privacy_result(payload)
|
||||
|
||||
assert scrubbed["token"] == "[REDACTED]"
|
||||
assert scrubbed["nested"]["Password"] == "[REDACTED]"
|
||||
assert scrubbed["nested"]["safe"] == "keep-me"
|
||||
assert scrubbed["nested"]["deep"][0]["SECRET"] == "[REDACTED]"
|
||||
assert scrubbed["nested"]["deep"][0]["value"] == 1
|
||||
assert scrubbed["nested"]["deep"][1]["Api_Key"] == "[REDACTED]"
|
||||
assert scrubbed["Authorization"] == "[REDACTED]"
|
||||
assert scrubbed["api_key"] == "[REDACTED]"
|
||||
assert scrubbed["normal"] == "visible"
|
||||
# original unchanged (no mutation)
|
||||
assert payload["token"] == "should-redact"
|
||||
|
||||
|
||||
def test_scrub_privacy_result_preserves_non_sensitive_and_handles_lists():
|
||||
from reyna_cli.privacy_contract import scrub_privacy_result
|
||||
|
||||
data = {"ok": True, "value": {"calendar": "Home"}}
|
||||
assert scrub_privacy_result(data) == {"ok": True, "value": {"calendar": "Home"}}
|
||||
|
||||
data2 = [{"token": "a"}, {"safe": "b"}]
|
||||
assert scrub_privacy_result(data2) == [{"token": "[REDACTED]"}, {"safe": "b"}]
|
||||
|
||||
|
||||
def test_scrub_exact_key_match_only():
|
||||
"""Only exactly token/password/secret/api_key/authorization should be redacted."""
|
||||
from reyna_cli.privacy_contract import scrub_privacy_result
|
||||
|
||||
payload = {
|
||||
"my_token": "should-not-redact",
|
||||
"tokenizer": "keep",
|
||||
"passwords": "keep",
|
||||
"api_key_id": "keep",
|
||||
"secret": "redact",
|
||||
}
|
||||
scrubbed = scrub_privacy_result(payload)
|
||||
assert scrubbed["my_token"] == "should-not-redact"
|
||||
assert scrubbed["tokenizer"] == "keep"
|
||||
assert scrubbed["passwords"] == "keep"
|
||||
assert scrubbed["api_key_id"] == "keep"
|
||||
assert scrubbed["secret"] == "[REDACTED]"
|
||||
@@ -0,0 +1,974 @@
|
||||
"""Tests for privacy_host wrapper — native calendar.list no fallback, status deterministic."""
|
||||
|
||||
from pathlib import Path
|
||||
import os
|
||||
import stat
|
||||
import plistlib
|
||||
import pytest
|
||||
from typer.testing import CliRunner
|
||||
from reyna_cli.cli import app
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
def test_native_calendar_list_success(monkeypatch):
|
||||
from reyna_cli import privacy_host as ph_mod
|
||||
|
||||
captured = {}
|
||||
|
||||
class FakeClient:
|
||||
def call(self, op, args):
|
||||
captured["op"] = op
|
||||
captured["args"] = args
|
||||
return {"id": "abc", "ok": True, "result": [{"title": "Work"}]}
|
||||
|
||||
monkeypatch.setattr(ph_mod, "PrivacyClient", FakeClient)
|
||||
|
||||
result = ph_mod.native_calendar_list()
|
||||
|
||||
assert captured["op"] == "calendar.list"
|
||||
assert captured["args"] == {}
|
||||
assert result["ok"] is True
|
||||
assert result["source"] == "native_privacy_host"
|
||||
assert result["result"] == [{"title": "Work"}]
|
||||
|
||||
|
||||
def test_native_calendar_list_failure_surfaces(monkeypatch):
|
||||
from reyna_cli import privacy_host as ph_mod
|
||||
from reyna_cli.privacy_client import PrivacyClientError
|
||||
|
||||
class FakeFail:
|
||||
def call(self, op, args):
|
||||
raise PrivacyClientError("privacy socket not found at /tmp/x")
|
||||
|
||||
monkeypatch.setattr(ph_mod, "PrivacyClient", FakeFail)
|
||||
|
||||
with pytest.raises(PrivacyClientError):
|
||||
ph_mod.native_calendar_list()
|
||||
|
||||
src = Path(ph_mod.__file__).read_text()
|
||||
assert "call_macmini_tool" not in src
|
||||
assert "MCPClient" not in src
|
||||
assert "macmini_client" not in src
|
||||
|
||||
|
||||
def test_no_mcp_import_in_privacy_host():
|
||||
from reyna_cli import privacy_host as ph_mod
|
||||
|
||||
src = Path(ph_mod.__file__).read_text()
|
||||
# Must not reference MCP fallback helpers
|
||||
assert "call_macmini_tool" not in src
|
||||
assert "macmini_client" not in src
|
||||
assert "MCPClient" not in src
|
||||
# Must not import mcp module
|
||||
assert "from reyna_cli.mcp import" not in src
|
||||
assert "import mcp" not in src.lower()
|
||||
|
||||
|
||||
def test_native_calendar_list_rejects_success_without_result(monkeypatch):
|
||||
from reyna_cli import privacy_host as ph_mod
|
||||
|
||||
class IncompleteClient:
|
||||
def call(self, op, args):
|
||||
return {"id": "abc", "ok": True}
|
||||
|
||||
monkeypatch.setattr(ph_mod, "PrivacyClient", IncompleteClient)
|
||||
with pytest.raises(RuntimeError, match="missing result"):
|
||||
ph_mod.native_calendar_list()
|
||||
|
||||
def test_cli_calendars_uses_native_wrapper(monkeypatch):
|
||||
"""Prove `macmini calendar calendars` routes through native wrapper."""
|
||||
calls = {"count": 0}
|
||||
|
||||
def fake_native():
|
||||
calls["count"] += 1
|
||||
return {"ok": True, "source": "native_privacy_host", "result": [{"id": "1"}]}
|
||||
|
||||
monkeypatch.setattr("reyna_cli.privacy_host.native_calendar_list", fake_native)
|
||||
|
||||
result = runner.invoke(app, ["macmini", "calendar", "calendars", "--json"])
|
||||
assert result.exit_code == 0, result.stdout + result.stderr
|
||||
import json
|
||||
|
||||
payload = json.loads(result.stdout)
|
||||
assert payload["ok"] is True
|
||||
assert payload["source"] == "native_privacy_host"
|
||||
assert calls["count"] == 1
|
||||
|
||||
|
||||
def test_cli_calendars_json_flag_propagates(monkeypatch):
|
||||
def fake_native():
|
||||
return {"ok": True, "source": "native_privacy_host", "result": []}
|
||||
|
||||
monkeypatch.setattr("reyna_cli.privacy_host.native_calendar_list", fake_native)
|
||||
|
||||
result = runner.invoke(app, ["macmini", "calendar", "calendars", "--json"])
|
||||
assert result.exit_code == 0
|
||||
|
||||
|
||||
def test_cli_calendars_no_fallback_on_native_failure(monkeypatch):
|
||||
from reyna_cli.privacy_client import PrivacyClientError
|
||||
|
||||
def fake_native_fail():
|
||||
raise PrivacyClientError("privacy socket not found")
|
||||
|
||||
monkeypatch.setattr("reyna_cli.privacy_host.native_calendar_list", fake_native_fail)
|
||||
|
||||
result = runner.invoke(app, ["macmini", "calendar", "calendars", "--json"])
|
||||
# fail() triggers Exit 1 with ok:false payload
|
||||
assert result.exit_code != 0
|
||||
|
||||
|
||||
def test_privacy_host_status_no_filesystem_creation(monkeypatch, tmp_path):
|
||||
"""status must not create/start, and must report deterministic paths."""
|
||||
fake_sock_parent = tmp_path / "reyna-privacy-status-test"
|
||||
fake_sock = fake_sock_parent / "reyna-cli.sock"
|
||||
assert not fake_sock_parent.exists()
|
||||
|
||||
from reyna_cli import privacy_host as ph_mod
|
||||
|
||||
monkeypatch.setattr(ph_mod, "default_socket_path", lambda: fake_sock)
|
||||
|
||||
result = runner.invoke(app, ["privacy-host", "status", "--json"])
|
||||
assert result.exit_code == 0, result.stdout + result.stderr
|
||||
import json
|
||||
|
||||
payload = json.loads(result.stdout)
|
||||
assert payload["ok"] is True
|
||||
assert "socket_path" in payload
|
||||
assert "socket_exists" in payload
|
||||
assert payload["socket_exists"] is False
|
||||
assert not fake_sock_parent.exists()
|
||||
assert "build_path" in payload or "socket_dir" in payload
|
||||
assert str(fake_sock) in payload["socket_path"]
|
||||
|
||||
|
||||
def test_no_generic_arbitrary_operation_cli():
|
||||
"""Ensure we didn't expose a generic arbitrary operation CLI yet."""
|
||||
result = runner.invoke(app, ["privacy-host", "--help"])
|
||||
assert result.exit_code == 0
|
||||
out = result.stdout.lower()
|
||||
assert "status" in out
|
||||
assert "call" not in out
|
||||
|
||||
result2 = runner.invoke(app, ["macmini", "call", "--help"])
|
||||
assert result2.exit_code == 0
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# New managed-lifecycle tests — strict TDD, mocked I/O only
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
def test_plist_deterministic_secure_contents(tmp_path):
|
||||
from reyna_cli import privacy_host as ph_mod
|
||||
|
||||
repo_root = tmp_path / "repo"
|
||||
repo_root.mkdir()
|
||||
sock = tmp_path / "sock" / "reyna-cli.sock"
|
||||
logd = tmp_path / "logs"
|
||||
|
||||
plist = ph_mod.build_privacy_host_plist(repo_root=repo_root, socket_path=sock, log_dir=logd)
|
||||
|
||||
# Label
|
||||
assert plist["Label"] == "com.reyna.cli.privacy-host"
|
||||
# No TCP args/ports
|
||||
prog = plist["ProgramArguments"]
|
||||
assert isinstance(prog, list) and len(prog) == 3
|
||||
# Binary path deterministic per task spec – now stable signed .app bundle for TCC identity
|
||||
expected_bundle_exe = repo_root / "native" / "ReynaCLIHost" / "dist" / "Reyna CLI.app" / "Contents" / "MacOS" / "ReynaCLIHost"
|
||||
assert str(prog[0]) == str(expected_bundle_exe)
|
||||
assert prog[1] == "--socket"
|
||||
assert prog[2] == str(sock)
|
||||
combined = " ".join(prog).lower()
|
||||
assert "--port" not in combined
|
||||
assert "tcp" not in combined
|
||||
assert "0.0.0.0" not in combined
|
||||
assert "127.0.0.1" not in combined
|
||||
|
||||
# Required LaunchAgent keys
|
||||
assert plist["RunAtLoad"] is True
|
||||
assert plist["KeepAlive"] is True
|
||||
assert plist["ProcessType"] == "Interactive"
|
||||
assert plist["WorkingDirectory"] == str(repo_root)
|
||||
assert plist["StandardOutPath"] == str(logd / "privacy-host.out.log")
|
||||
assert plist["StandardErrorPath"] == str(logd / "privacy-host.error.log")
|
||||
# No env/secrets
|
||||
assert "EnvironmentVariables" not in plist
|
||||
assert "Environment" not in plist
|
||||
|
||||
# Also check plistlib serializable
|
||||
data = plistlib.dumps(plist)
|
||||
loaded = plistlib.loads(data)
|
||||
assert loaded == plist
|
||||
|
||||
|
||||
def test_plist_contains_only_unix_socket_invocation():
|
||||
from reyna_cli import privacy_host as ph_mod
|
||||
|
||||
plist = ph_mod.build_privacy_host_plist()
|
||||
prog_str = " ".join(plist["ProgramArguments"])
|
||||
# Must contain --socket
|
||||
assert "--socket" in prog_str
|
||||
# Must NOT contain TCP indicators
|
||||
forbidden = ["--port", "--host", "tcp://", "0.0.0.0", "127.0.0.1", ":8080", ":3000"]
|
||||
lower = prog_str.lower()
|
||||
for token in forbidden:
|
||||
assert token.lower() not in lower, f"forbidden token {token} in {prog_str}"
|
||||
# Source must also not contain TCP ports in file itself (extra hardening)
|
||||
src = Path(ph_mod.__file__).read_text()
|
||||
# We allow portion about TCP check in tests/comments but not in plist builder path that would inject TCP
|
||||
# Instead ensure builder uses only socket arg
|
||||
assert "ProgramArguments" in src
|
||||
|
||||
|
||||
def test_build_release_command_exact_arg_array_no_shell():
|
||||
from reyna_cli import privacy_host as ph_mod
|
||||
|
||||
cmd = ph_mod.build_release_command()
|
||||
# Now expects xcodebuild safe arg array (Xcode owns signing)
|
||||
assert isinstance(cmd, list)
|
||||
assert all(isinstance(x, str) for x in cmd)
|
||||
assert cmd[0] == "xcodebuild"
|
||||
assert "-project" in cmd
|
||||
assert "-scheme" in cmd
|
||||
assert "-target" not in cmd, "must use -scheme for valid derivedDataPath builds"
|
||||
assert "Reyna CLI" in cmd
|
||||
assert "-configuration" in cmd
|
||||
assert "Release" in cmd
|
||||
assert "-derivedDataPath" in cmd
|
||||
assert "build" in cmd
|
||||
assert "--sign" not in cmd
|
||||
# Must be list, not string, no shell
|
||||
src = Path(ph_mod.__file__).read_text()
|
||||
assert "shell=True" not in src
|
||||
assert "shell=\"" not in src
|
||||
# No manual codesign --sign construction in module
|
||||
assert '["codesign", "--force"' not in src
|
||||
|
||||
|
||||
def test_build_release_command_no_params():
|
||||
from reyna_cli import privacy_host as ph_mod
|
||||
import inspect
|
||||
sig = inspect.signature(ph_mod.build_release_command)
|
||||
assert len(sig.parameters) == 0, f"should have no params, got {list(sig.parameters)}"
|
||||
|
||||
|
||||
def test_status_reports_plist_and_socket_and_pid_from_runner_only(tmp_path):
|
||||
from reyna_cli import privacy_host as ph_mod
|
||||
from reyna_cli import app_bundle as ab_mod
|
||||
|
||||
fake_repo = tmp_path / "repo"
|
||||
fake_repo.mkdir()
|
||||
bundle = fake_repo / "native" / "ReynaCLIHost" / "dist" / "Reyna CLI.app"
|
||||
(bundle / "Contents" / "MacOS").mkdir(parents=True)
|
||||
(bundle / "Contents" / "MacOS" / "ReynaCLIHost").write_bytes(b"x")
|
||||
import plistlib as _pl
|
||||
with open(bundle / "Contents" / "Info.plist", "wb") as f:
|
||||
_pl.dump(ab_mod.build_app_bundle_info_plist_dict(), f)
|
||||
fake_sock = tmp_path / "sock" / "reyna-cli.sock"
|
||||
fake_sock.parent.mkdir()
|
||||
fake_sock.write_text("dummy")
|
||||
|
||||
fake_plist = tmp_path / "LaunchAgents" / "com.reyna.cli.privacy-host.plist"
|
||||
fake_plist.parent.mkdir()
|
||||
fake_plist.write_bytes(plistlib.dumps({"Label": ph_mod.PRIVACY_HOST_LABEL}))
|
||||
|
||||
calls = []
|
||||
|
||||
class Proc:
|
||||
returncode = 0
|
||||
stdout = " pid = 12345\n state = running\n"
|
||||
stderr = ""
|
||||
|
||||
def fake_runner(args, **kwargs):
|
||||
calls.append(list(args))
|
||||
assert isinstance(args, list), "must be arg array, not shell string"
|
||||
if args and args[0] == "codesign":
|
||||
if "--verify" in args:
|
||||
class P:
|
||||
returncode = 0
|
||||
stdout = ""
|
||||
stderr = ""
|
||||
return P()
|
||||
if len(args) > 1 and args[1] == "-dv":
|
||||
class P:
|
||||
returncode = 0
|
||||
stdout = ""
|
||||
stderr = "TeamIdentifier=TEAM123\n"
|
||||
return P()
|
||||
return Proc()
|
||||
assert args[0] == "launchctl"
|
||||
assert args[1] == "print"
|
||||
assert args[2].startswith("gui/")
|
||||
assert ph_mod.PRIVACY_HOST_LABEL in args[2]
|
||||
return Proc()
|
||||
|
||||
status = ph_mod.privacy_host_service_status(
|
||||
runner=fake_runner,
|
||||
uid=501,
|
||||
plist_path_override=fake_plist,
|
||||
socket_path_override=fake_sock,
|
||||
repo_root_override=fake_repo,
|
||||
)
|
||||
|
||||
assert status["ok"] is True
|
||||
assert status["plist_path"] == str(fake_plist)
|
||||
assert status["plist_exists"] is True
|
||||
assert status["socket_path"] == str(fake_sock)
|
||||
assert status["socket_exists"] is True
|
||||
assert status["socket_type"] in ("file", "socket", "dir", "other")
|
||||
assert status["pid"] == 12345
|
||||
assert status["active"] is True
|
||||
launch_calls = [c for c in calls if c[0] == "launchctl"]
|
||||
assert len(launch_calls) == 1
|
||||
assert launch_calls[0][0] == "launchctl"
|
||||
assert status.get("bundle_exists") is True
|
||||
assert status.get("signature_verified") is True
|
||||
assert (fake_repo / "native" / "ReynaCLIHost" / "dist").exists()
|
||||
|
||||
|
||||
def test_status_failure_no_throw_structured(tmp_path):
|
||||
from reyna_cli import privacy_host as ph_mod
|
||||
|
||||
def failing_runner(args, **kwargs):
|
||||
raise RuntimeError("launchctl not found")
|
||||
|
||||
status = ph_mod.privacy_host_service_status(
|
||||
runner=failing_runner,
|
||||
uid=501,
|
||||
plist_path_override=tmp_path / "nonexist.plist",
|
||||
socket_path_override=tmp_path / "sock.sock",
|
||||
repo_root_override=tmp_path / "repo",
|
||||
)
|
||||
# Must not throw, must return structured
|
||||
assert status["ok"] is True # ok still True but with errors
|
||||
assert "errors" in status
|
||||
assert any("launchctl" in e for e in status["errors"])
|
||||
assert status["pid"] is None
|
||||
|
||||
|
||||
def test_status_no_filesystem_creation_with_mock_runner(tmp_path, monkeypatch):
|
||||
from reyna_cli import privacy_host as ph_mod
|
||||
|
||||
sock_parent = tmp_path / "no-create-parent"
|
||||
sock = sock_parent / "reyna-cli.sock"
|
||||
assert not sock_parent.exists()
|
||||
|
||||
class Proc:
|
||||
returncode = 1
|
||||
stdout = ""
|
||||
stderr = "No such file"
|
||||
|
||||
def fake_runner(args, **kwargs):
|
||||
# Ensure no swift
|
||||
assert "swift" not in args[0]
|
||||
return Proc()
|
||||
|
||||
# monkeypatch _plist_path etc via overrides, not global
|
||||
plist_path = tmp_path / "LaunchAgents" / "com.reyna.cli.privacy-host.plist"
|
||||
# Do not create plist_parent to prove status doesn't create it
|
||||
assert not plist_path.parent.exists()
|
||||
|
||||
status = ph_mod.privacy_host_service_status(
|
||||
runner=fake_runner,
|
||||
uid=501,
|
||||
plist_path_override=plist_path,
|
||||
socket_path_override=sock,
|
||||
repo_root_override=tmp_path / "repo-root-no-create",
|
||||
)
|
||||
|
||||
assert not sock_parent.exists()
|
||||
assert not plist_path.parent.exists()
|
||||
assert status["socket_exists"] is False
|
||||
assert status["plist_exists"] is False
|
||||
|
||||
|
||||
def test_status_exact_launchctl_arg_array():
|
||||
from reyna_cli import privacy_host as ph_mod
|
||||
|
||||
captured = []
|
||||
|
||||
class Proc:
|
||||
returncode = 0
|
||||
stdout = "pid = 999\n"
|
||||
stderr = ""
|
||||
|
||||
def runner(args, **kwargs):
|
||||
captured.append(list(args))
|
||||
if args and args[0] == "codesign":
|
||||
# fake validation phase before launchctl
|
||||
if "--verify" in args:
|
||||
class P:
|
||||
returncode = 0
|
||||
stdout = ""
|
||||
stderr = ""
|
||||
return P()
|
||||
if len(args) > 1 and args[1] == "-dv":
|
||||
class P:
|
||||
returncode = 0
|
||||
stdout = ""
|
||||
stderr = "TeamIdentifier=TEAM123\n"
|
||||
return P()
|
||||
return Proc()
|
||||
|
||||
ph_mod.privacy_host_service_status(runner=runner, uid=123, plist_path_override=Path("/tmp/a.plist"), socket_path_override=Path("/tmp/b.sock"))
|
||||
|
||||
# launchctl print must exist (may not be first due to codesign validation)
|
||||
launch_calls = [c for c in captured if c and c[0] == "launchctl"]
|
||||
assert len(launch_calls) >= 1
|
||||
assert ["launchctl", "print", "gui/123/com.reyna.cli.privacy-host"] in launch_calls
|
||||
# ensure arg array, no shell, no manual --sign
|
||||
for c in captured:
|
||||
assert isinstance(c, list)
|
||||
assert "--sign" not in c or c[0] != "codesign"
|
||||
|
||||
|
||||
def test_install_exact_command_arg_arrays_and_modes(tmp_path, monkeypatch):
|
||||
from reyna_cli import privacy_host as ph_mod
|
||||
from reyna_cli import app_bundle as ab_mod
|
||||
|
||||
repo_root = tmp_path / "repo"
|
||||
repo_root.mkdir()
|
||||
(repo_root / "native" / "ReynaCLIHost").mkdir(parents=True)
|
||||
# Required xcodeproj and Info.plist source for build_app_bundle check
|
||||
(repo_root / "native" / "ReynaCLIHost" / "ReynaCLIHost.xcodeproj").mkdir(parents=True)
|
||||
(repo_root / "native" / "ReynaCLIHost" / "ReynaCLIHost.xcodeproj" / "project.pbxproj").write_text("// dummy")
|
||||
(repo_root / "native" / "ReynaCLIHost" / "ReynaCLIHost").mkdir(parents=True, exist_ok=True)
|
||||
(repo_root / "native" / "ReynaCLIHost" / "ReynaCLIHost" / "Info.plist").write_text("<plist></plist>")
|
||||
|
||||
# Expected derived-data product location that build_app_bundle runner will copy
|
||||
derived = repo_root / "native" / "ReynaCLIHost" / "build" / "DerivedData"
|
||||
built_app = derived / "Build" / "Products" / "Release" / "Reyna CLI.app"
|
||||
(built_app / "Contents" / "MacOS").mkdir(parents=True)
|
||||
(built_app / "Contents" / "MacOS" / "ReynaCLIHost").write_bytes(b"fakebinary")
|
||||
with open(built_app / "Contents" / "Info.plist", "wb") as f:
|
||||
plistlib.dump(ab_mod.build_app_bundle_info_plist_dict(), f)
|
||||
|
||||
sock = tmp_path / "priv" / "reyna-cli.sock"
|
||||
plist = tmp_path / "LaunchAgents" / "com.reyna.cli.privacy-host.plist"
|
||||
logd = tmp_path / "Logs" / "reyna-cli"
|
||||
|
||||
calls = []
|
||||
|
||||
class Proc:
|
||||
def __init__(self, rc=0, stdout="ok", stderr=""):
|
||||
self.returncode = rc
|
||||
self.stdout = stdout
|
||||
self.stderr = stderr
|
||||
|
||||
def fake_runner(args, cwd=None, **kwargs):
|
||||
assert isinstance(args, list), "must use arg list, no shell"
|
||||
calls.append({"args": list(args), "cwd": str(cwd) if cwd else None})
|
||||
if args and args[0] == "xcodebuild":
|
||||
# Simulate build succeeded; product already exists at derived path
|
||||
return Proc(rc=0, stdout="BUILD SUCCEEDED", stderr="")
|
||||
if args and args[0] == "codesign" and "--sign" in args:
|
||||
# Should NOT happen in new flow – Xcode owns signing; fail if called
|
||||
raise AssertionError(f"manual codesign --sign must not occur, got {args}")
|
||||
if len(args) >= 3 and args[:3] == ["codesign", "--verify", "--deep"]:
|
||||
return Proc(rc=0, stdout="", stderr="")
|
||||
if len(args) >= 2 and args[:2] == ["codesign", "-dv"]:
|
||||
return Proc(rc=0, stdout="", stderr="TeamIdentifier=TEAM123\nAuthority=Apple Development: Foo (TEAM123)\n")
|
||||
return Proc(rc=0)
|
||||
|
||||
chmod_calls = []
|
||||
orig_chmod = os.chmod
|
||||
|
||||
def fake_chmod(p, mode, *args, **kwargs):
|
||||
chmod_calls.append((str(p), mode))
|
||||
try:
|
||||
orig_chmod(p, mode, *args, **kwargs)
|
||||
except TypeError:
|
||||
try:
|
||||
orig_chmod(p, mode)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
monkeypatch.setattr(os, "chmod", fake_chmod)
|
||||
|
||||
result = ph_mod.install_privacy_host_service(
|
||||
runner=fake_runner,
|
||||
uid=501,
|
||||
repo_root=repo_root,
|
||||
socket_path=sock,
|
||||
plist_path=plist,
|
||||
log_dir=logd,
|
||||
signing_identity="Test Identity (TEAM123)",
|
||||
)
|
||||
|
||||
assert result["ok"] is True
|
||||
# Must be xcodebuild, not swift build
|
||||
xcode_calls = [c for c in calls if c["args"] and c["args"][0] == "xcodebuild"]
|
||||
assert len(xcode_calls) >= 1
|
||||
xb = xcode_calls[0]["args"]
|
||||
assert "-project" in xb
|
||||
assert "-scheme" in xb
|
||||
assert "-target" not in xb
|
||||
assert "Reyna CLI" in xb
|
||||
assert "-configuration" in xb
|
||||
assert "Release" in xb
|
||||
assert "-derivedDataPath" in xb
|
||||
assert "build" in xb
|
||||
# No swift build
|
||||
swift_calls = [c for c in calls if c["args"][:2] == ["swift", "build"]]
|
||||
assert len(swift_calls) == 0, f"swift build must not be used, got {swift_calls}"
|
||||
# No manual codesign --sign
|
||||
sign_calls = [c for c in calls if c["args"][0] == "codesign" and "--sign" in c["args"]]
|
||||
assert len(sign_calls) == 0, f"manual codesign --sign forbidden, got {sign_calls}"
|
||||
|
||||
bootouts = [c for c in calls if c["args"][:2] == ["launchctl", "bootout"]]
|
||||
bootstraps = [c for c in calls if c["args"][:2] == ["launchctl", "bootstrap"]]
|
||||
assert len(bootouts) == 1
|
||||
assert bootouts[0]["args"] == ["launchctl", "bootout", "gui/501/com.reyna.cli.privacy-host"]
|
||||
assert len(bootstraps) == 1
|
||||
assert bootstraps[0]["args"] == ["launchctl", "bootstrap", "gui/501", str(plist)]
|
||||
|
||||
assert sock.parent.exists()
|
||||
assert logd.exists()
|
||||
modes_0700 = [c for c in chmod_calls if c[1] == 0o700]
|
||||
assert len(modes_0700) >= 2
|
||||
assert plist.exists()
|
||||
modes_0600 = [c for c in chmod_calls if c[1] == 0o600 and str(plist) in c[0]]
|
||||
assert len(modes_0600) >= 1
|
||||
|
||||
loaded = plistlib.loads(plist.read_bytes())
|
||||
prog = loaded["ProgramArguments"]
|
||||
assert prog[0] == str(repo_root / "native" / "ReynaCLIHost" / "dist" / "Reyna CLI.app" / "Contents" / "MacOS" / "ReynaCLIHost")
|
||||
assert prog[1] == "--socket"
|
||||
assert prog[2] == str(sock)
|
||||
assert "app_bundle_path" in result
|
||||
assert result["bundle_identifier"] == "com.reyna.cli.privacy-host"
|
||||
assert result.get("signature_verified") is True
|
||||
|
||||
|
||||
def test_start_stop_use_bootstrap_bootout_kickstart_testable(tmp_path):
|
||||
from reyna_cli import privacy_host as ph_mod
|
||||
from reyna_cli import app_bundle as ab_mod
|
||||
|
||||
plist = tmp_path / "LaunchAgents" / "com.reyna.cli.privacy-host.plist"
|
||||
plist.parent.mkdir(parents=True)
|
||||
plist.write_bytes(plistlib.dumps({"Label": ph_mod.PRIVACY_HOST_LABEL}))
|
||||
repo_root = tmp_path / "repo"
|
||||
bundle = repo_root / "native" / "ReynaCLIHost" / "dist" / "Reyna CLI.app"
|
||||
(bundle / "Contents" / "MacOS").mkdir(parents=True)
|
||||
(bundle / "Contents" / "MacOS" / "ReynaCLIHost").write_bytes(b"x")
|
||||
with open(bundle / "Contents" / "Info.plist", "wb") as f:
|
||||
import plistlib as _pl
|
||||
_pl.dump(ab_mod.build_app_bundle_info_plist_dict(), f)
|
||||
|
||||
calls = []
|
||||
|
||||
class Proc:
|
||||
def __init__(self, rc=0, stdout="", stderr=""):
|
||||
self.returncode = rc
|
||||
self.stdout = stdout
|
||||
self.stderr = stderr
|
||||
|
||||
def start_runner(args, **kwargs):
|
||||
assert isinstance(args, list)
|
||||
calls.append(list(args))
|
||||
if args and args[0] == "codesign":
|
||||
if "--verify" in args:
|
||||
return Proc(rc=0)
|
||||
if len(args) > 1 and args[1] == "-dv":
|
||||
return Proc(rc=0, stdout="", stderr="TeamIdentifier=TEAM123\n")
|
||||
return Proc(rc=0)
|
||||
return Proc(rc=0)
|
||||
|
||||
res_start = ph_mod.start_privacy_host_service(runner=start_runner, uid=502, plist_path=plist, repo_root=repo_root)
|
||||
assert res_start["ok"] is True
|
||||
assert ["launchctl", "kickstart", "-k", "gui/502/com.reyna.cli.privacy-host"] in calls
|
||||
|
||||
calls.clear()
|
||||
|
||||
def stop_runner(args, **kwargs):
|
||||
assert isinstance(args, list)
|
||||
calls.append(list(args))
|
||||
return Proc(rc=0)
|
||||
|
||||
res_stop = ph_mod.stop_privacy_host_service(runner=stop_runner, uid=502, plist_path=plist)
|
||||
assert res_stop["ok"] is True
|
||||
assert calls[0] == ["launchctl", "bootout", "gui/502/com.reyna.cli.privacy-host"]
|
||||
|
||||
|
||||
def test_start_idempotence_kickstart_fail_bootstrap_fail_then_kickstart_retry(tmp_path):
|
||||
from reyna_cli import privacy_host as ph_mod
|
||||
from reyna_cli import app_bundle as ab_mod
|
||||
|
||||
plist = tmp_path / "LaunchAgents" / "com.reyna.cli.privacy-host.plist"
|
||||
plist.parent.mkdir(parents=True)
|
||||
plist.write_bytes(plistlib.dumps({"Label": ph_mod.PRIVACY_HOST_LABEL}))
|
||||
repo_root = tmp_path / "repo"
|
||||
bundle = repo_root / "native" / "ReynaCLIHost" / "dist" / "Reyna CLI.app"
|
||||
(bundle / "Contents" / "MacOS").mkdir(parents=True)
|
||||
(bundle / "Contents" / "MacOS" / "ReynaCLIHost").write_bytes(b"x")
|
||||
with open(bundle / "Contents" / "Info.plist", "wb") as f:
|
||||
import plistlib as _pl
|
||||
_pl.dump(ab_mod.build_app_bundle_info_plist_dict(), f)
|
||||
|
||||
calls = []
|
||||
|
||||
class Proc:
|
||||
def __init__(self, rc, out="", err=""):
|
||||
self.returncode = rc
|
||||
self.stdout = out
|
||||
self.stderr = err
|
||||
|
||||
seq = [
|
||||
Proc(1, "", "kickstart failed"),
|
||||
Proc(1, "", "already loaded"),
|
||||
Proc(0, "", ""),
|
||||
]
|
||||
idx = {"i": 0}
|
||||
|
||||
def runner(args, **kwargs):
|
||||
# validation phase codesign calls first
|
||||
if args and args[0] == "codesign":
|
||||
if "--verify" in args:
|
||||
class P:
|
||||
returncode = 0
|
||||
stdout = ""
|
||||
stderr = ""
|
||||
return P()
|
||||
if len(args) > 1 and args[1] == "-dv":
|
||||
class P:
|
||||
returncode = 0
|
||||
stdout = ""
|
||||
stderr = "TeamIdentifier=TEAM123\n"
|
||||
return P()
|
||||
return Proc(0, "", "")
|
||||
calls.append(list(args))
|
||||
r = seq[idx["i"]]
|
||||
idx["i"] += 1
|
||||
return r
|
||||
|
||||
result = ph_mod.start_privacy_host_service(runner=runner, uid=501, plist_path=plist, repo_root=repo_root)
|
||||
assert result["ok"] is True
|
||||
assert len(calls) == 3
|
||||
assert calls[0] == ["launchctl", "kickstart", "-k", "gui/501/com.reyna.cli.privacy-host"]
|
||||
assert calls[1] == ["launchctl", "bootstrap", "gui/501", str(plist)]
|
||||
assert calls[2] == ["launchctl", "kickstart", "-k", "gui/501/com.reyna.cli.privacy-host"]
|
||||
|
||||
|
||||
def test_start_does_not_treat_arbitrary_bootstrap_failure_as_success(tmp_path):
|
||||
from reyna_cli import privacy_host as ph_mod
|
||||
from reyna_cli import app_bundle as ab_mod
|
||||
|
||||
plist = tmp_path / "LaunchAgents" / "com.reyna.cli.privacy-host.plist"
|
||||
plist.parent.mkdir(parents=True)
|
||||
plist.write_bytes(plistlib.dumps({"Label": ph_mod.PRIVACY_HOST_LABEL}))
|
||||
repo_root = tmp_path / "repo"
|
||||
bundle = repo_root / "native" / "ReynaCLIHost" / "dist" / "Reyna CLI.app"
|
||||
(bundle / "Contents" / "MacOS").mkdir(parents=True)
|
||||
(bundle / "Contents" / "MacOS" / "ReynaCLIHost").write_bytes(b"x")
|
||||
with open(bundle / "Contents" / "Info.plist", "wb") as f:
|
||||
import plistlib as _pl
|
||||
_pl.dump(ab_mod.build_app_bundle_info_plist_dict(), f)
|
||||
|
||||
class Proc:
|
||||
def __init__(self, rc):
|
||||
self.returncode = rc
|
||||
self.stdout = ""
|
||||
self.stderr = "some other failure"
|
||||
|
||||
seq = [Proc(1), Proc(1), Proc(1)]
|
||||
idx = {"i": 0}
|
||||
|
||||
def runner(args, **kwargs):
|
||||
if args and args[0] == "codesign":
|
||||
if "--verify" in args:
|
||||
class P:
|
||||
returncode = 0
|
||||
stdout = ""
|
||||
stderr = ""
|
||||
return P()
|
||||
if len(args) > 1 and args[1] == "-dv":
|
||||
class P:
|
||||
returncode = 0
|
||||
stdout = ""
|
||||
stderr = "TeamIdentifier=TEAM123\n"
|
||||
return P()
|
||||
return Proc(1)
|
||||
r = seq[idx["i"]]
|
||||
idx["i"] += 1
|
||||
return r
|
||||
|
||||
result = ph_mod.start_privacy_host_service(runner=runner, uid=501, plist_path=plist, repo_root=repo_root)
|
||||
# All three fail -> ok False
|
||||
assert result["ok"] is False
|
||||
|
||||
|
||||
def test_uninstall_removes_only_exact_plist(tmp_path, monkeypatch):
|
||||
from reyna_cli import privacy_host as ph_mod
|
||||
|
||||
# Real expected plist path is ~/Library/LaunchAgents/com.reyna.cli.privacy-host.plist
|
||||
# For safety test, we will monkeypatch _plist_path to return our tmp plist
|
||||
real_expected = tmp_path / "Library" / "LaunchAgents" / "com.reyna.cli.privacy-host.plist"
|
||||
real_expected.parent.mkdir(parents=True)
|
||||
real_expected.write_bytes(plistlib.dumps({"Label": ph_mod.PRIVACY_HOST_LABEL}))
|
||||
|
||||
# Create a fake socket somewhere else that must NOT be removed
|
||||
fake_sock = tmp_path / "sockdir" / "reyna-cli.sock"
|
||||
fake_sock.parent.mkdir()
|
||||
fake_sock.write_text("keep me")
|
||||
|
||||
calls = []
|
||||
|
||||
class Proc:
|
||||
returncode = 0
|
||||
stdout = ""
|
||||
stderr = ""
|
||||
|
||||
def fake_runner(args, **kwargs):
|
||||
assert isinstance(args, list)
|
||||
calls.append(list(args))
|
||||
return Proc()
|
||||
|
||||
monkeypatch.setattr(ph_mod, "_plist_path", lambda: real_expected)
|
||||
|
||||
result = ph_mod.uninstall_privacy_host_service(runner=fake_runner, uid=503, plist_path=real_expected)
|
||||
|
||||
assert result["ok"] is True
|
||||
assert result["removed"] is True
|
||||
assert not real_expected.exists()
|
||||
assert fake_sock.exists(), "uninstall must never remove socket arbitrary paths"
|
||||
# Must have called bootout
|
||||
assert ["launchctl", "bootout", "gui/503/com.reyna.cli.privacy-host"] in calls
|
||||
|
||||
# Attempt to remove non-exact plist should be refused
|
||||
other_plist = tmp_path / "other.plist"
|
||||
other_plist.write_text("evil")
|
||||
|
||||
result2 = ph_mod.uninstall_privacy_host_service(runner=fake_runner, uid=503, plist_path=other_plist)
|
||||
assert result2["ok"] is False
|
||||
assert "refusing" in result2["error"].lower()
|
||||
assert other_plist.exists(), "non-exact plist must not be removed"
|
||||
|
||||
|
||||
def test_cli_has_managed_lifecycle_commands():
|
||||
result = runner.invoke(app, ["privacy-host", "--help"])
|
||||
assert result.exit_code == 0
|
||||
out = result.stdout.lower()
|
||||
# Must have all lifecycle commands
|
||||
for cmd in ["status", "install", "start", "stop", "uninstall"]:
|
||||
assert cmd in out, f"{cmd} missing from help: {out}"
|
||||
# Still no generic call
|
||||
assert "call" not in out
|
||||
|
||||
|
||||
def test_cli_install_start_stop_uninstall_with_mocked_helpers(monkeypatch):
|
||||
from reyna_cli import privacy_host as ph_mod
|
||||
|
||||
# Mock helpers to avoid real I/O
|
||||
def fake_install():
|
||||
return {"ok": True, "action": "install", "results": []}
|
||||
|
||||
def fake_start():
|
||||
return {"ok": True, "action": "start", "results": []}
|
||||
|
||||
def fake_stop():
|
||||
return {"ok": True, "action": "stop", "results": []}
|
||||
|
||||
def fake_uninstall():
|
||||
return {"ok": True, "action": "uninstall", "removed": True, "results": []}
|
||||
|
||||
monkeypatch.setattr(ph_mod, "install_privacy_host_service", lambda *a, **k: fake_install())
|
||||
monkeypatch.setattr(ph_mod, "start_privacy_host_service", lambda *a, **k: fake_start())
|
||||
monkeypatch.setattr(ph_mod, "stop_privacy_host_service", lambda *a, **k: fake_stop())
|
||||
monkeypatch.setattr(ph_mod, "uninstall_privacy_host_service", lambda *a, **k: fake_uninstall())
|
||||
|
||||
for cmd in ["install", "start", "stop", "uninstall"]:
|
||||
res = runner.invoke(app, ["privacy-host", cmd, "--json"])
|
||||
assert res.exit_code == 0, f"{cmd} failed: {res.stdout} {res.stderr}"
|
||||
import json
|
||||
|
||||
payload = json.loads(res.stdout)
|
||||
assert payload["ok"] is True
|
||||
|
||||
|
||||
def test_no_live_launchctl_swift_in_module_source():
|
||||
from reyna_cli import privacy_host as ph_mod
|
||||
|
||||
src = Path(ph_mod.__file__).read_text()
|
||||
# Ensure no direct subprocess.run with shell string that would invoke live commands at import time
|
||||
# The module should not execute swift or launchctl on import — check top-level calls
|
||||
# We already tested shell=True absent, now ensure no top-level launchctl/bootstrap call outside functions
|
||||
lines = src.splitlines()
|
||||
# Look for launchctl or swift outside function defs — simple heuristic: any line at column 0 invoking runner?
|
||||
# For this slice, we just ensure module import doesn't trigger side effects by importing again
|
||||
import importlib
|
||||
|
||||
importlib.reload(ph_mod) # should not throw or run launchctl
|
||||
# If reload succeeded without side-effect error, pass
|
||||
assert True
|
||||
|
||||
|
||||
def test_status_payload_includes_new_fields_but_preserves_legacy(monkeypatch, tmp_path):
|
||||
from reyna_cli import privacy_host as ph_mod
|
||||
|
||||
sock = tmp_path / "legacy" / "reyna-cli.sock"
|
||||
# Do not create parent to prove no creation
|
||||
|
||||
monkeypatch.setattr(ph_mod, "default_socket_path", lambda: sock)
|
||||
|
||||
class Proc:
|
||||
returncode = 1
|
||||
stdout = ""
|
||||
stderr = ""
|
||||
|
||||
def fake_runner(args, **kwargs):
|
||||
return Proc()
|
||||
|
||||
payload = ph_mod.privacy_host_status_payload(runner=fake_runner, uid=501)
|
||||
|
||||
# Legacy fields preserved
|
||||
assert "socket_path" in payload
|
||||
assert "socket_dir" in payload
|
||||
assert "build_path" in payload
|
||||
assert "socket_exists" in payload
|
||||
assert payload["socket_exists"] is False
|
||||
# New fields present
|
||||
assert "plist_path" in payload
|
||||
assert "plist_exists" in payload
|
||||
assert "binary_path" in payload
|
||||
assert "pid" in payload
|
||||
assert "active" in payload
|
||||
# No creation
|
||||
assert not sock.parent.exists()
|
||||
|
||||
|
||||
# --- Additional hardening tests ---
|
||||
|
||||
def test_write_plist_atomic_no_world_readable_window(tmp_path):
|
||||
from reyna_cli import privacy_host as ph_mod
|
||||
import inspect
|
||||
|
||||
src = inspect.getsource(ph_mod._write_plist_0600)
|
||||
assert "os.open" in src
|
||||
assert "O_CREAT" in src
|
||||
assert "O_EXCL" in src
|
||||
assert "os.replace" in src
|
||||
assert "fsync" in src
|
||||
|
||||
# Functional: permissions 0600
|
||||
plist_path = tmp_path / "LaunchAgents" / "com.reyna.cli.privacy-host.plist"
|
||||
plist_path.parent.mkdir(parents=True)
|
||||
# Ensure parent is 0700 as implementation will enforce
|
||||
os.chmod(plist_path.parent, 0o700)
|
||||
ph_mod._write_plist_0600({"Label": "test", "ProgramArguments": ["/bin/true"]}, plist_path)
|
||||
st = plist_path.lstat()
|
||||
assert stat.S_IMODE(st.st_mode) == 0o600
|
||||
# No temp files left
|
||||
leftovers = list(plist_path.parent.glob("*.tmp.*"))
|
||||
assert len(leftovers) == 0, f"temp files left: {leftovers}"
|
||||
|
||||
|
||||
def test_ensure_dir_0700_rejects_symlink(tmp_path):
|
||||
from reyna_cli import privacy_host as ph_mod
|
||||
|
||||
real = tmp_path / "real"
|
||||
real.mkdir()
|
||||
link = tmp_path / "linkdir"
|
||||
link.symlink_to(real)
|
||||
with pytest.raises((ValueError, PermissionError)):
|
||||
ph_mod._ensure_dir_0700(link)
|
||||
|
||||
|
||||
def test_ensure_dir_0700_enforces_0700(tmp_path):
|
||||
from reyna_cli import privacy_host as ph_mod
|
||||
|
||||
d = tmp_path / "a" / "b" / "c"
|
||||
ph_mod._ensure_dir_0700(d)
|
||||
assert d.exists()
|
||||
assert stat.S_IMODE(d.lstat().st_mode) == 0o700
|
||||
assert stat.S_IMODE(d.parent.lstat().st_mode) == 0o700 or True # parent also 0700 via parents creation may be checked
|
||||
|
||||
# Re-call should still ensure 0700
|
||||
os.chmod(d, 0o755)
|
||||
ph_mod._ensure_dir_0700(d)
|
||||
assert stat.S_IMODE(d.lstat().st_mode) == 0o700
|
||||
|
||||
|
||||
def test_ensure_dir_0700_rejects_non_directory(tmp_path):
|
||||
from reyna_cli import privacy_host as ph_mod
|
||||
|
||||
f = tmp_path / "file.txt"
|
||||
f.write_text("hi")
|
||||
with pytest.raises((ValueError, PermissionError)):
|
||||
ph_mod._ensure_dir_0700(f)
|
||||
|
||||
|
||||
def test_write_plist_refuses_symlink_parent(tmp_path):
|
||||
from reyna_cli import privacy_host as ph_mod
|
||||
|
||||
real_parent = tmp_path / "real"
|
||||
real_parent.mkdir()
|
||||
link_parent = tmp_path / "linkparent"
|
||||
link_parent.symlink_to(real_parent)
|
||||
plist_path = link_parent / "com.reyna.cli.privacy-host.plist"
|
||||
with pytest.raises((ValueError, PermissionError)):
|
||||
ph_mod._write_plist_0600({"Label": "test"}, plist_path)
|
||||
|
||||
|
||||
def test_uid_tightening_returns_structured_error_not_throw(tmp_path):
|
||||
from reyna_cli import privacy_host as ph_mod
|
||||
|
||||
plist = tmp_path / "com.reyna.cli.privacy-host.plist"
|
||||
plist.write_bytes(plistlib.dumps({"Label": ph_mod.PRIVACY_HOST_LABEL}))
|
||||
|
||||
for bad_uid in ["not-int", "", " ", "1.5", True, -1, "abc"]:
|
||||
res = ph_mod.start_privacy_host_service(uid=bad_uid, plist_path=plist, runner=lambda *a, **k: None)
|
||||
assert isinstance(res, dict), f"should return dict for {bad_uid}"
|
||||
assert res.get("ok") is False, f"should be False for {bad_uid}: {res}"
|
||||
assert "error" in res or "invalid uid" in str(res).lower()
|
||||
|
||||
# Status also structured
|
||||
res_status = ph_mod.privacy_host_service_status(uid="bad-uid", plist_path_override=plist, socket_path_override=Path("/tmp/x.sock"))
|
||||
assert isinstance(res_status, dict)
|
||||
assert res_status.get("ok") is False
|
||||
assert "invalid uid" in str(res_status).lower()
|
||||
|
||||
|
||||
def test_uid_valid_int_string_coerced(tmp_path):
|
||||
from reyna_cli import privacy_host as ph_mod
|
||||
from reyna_cli import app_bundle as ab_mod
|
||||
|
||||
plist = tmp_path / "com.reyna.cli.privacy-host.plist"
|
||||
plist.write_bytes(plistlib.dumps({"Label": ph_mod.PRIVACY_HOST_LABEL}))
|
||||
repo_root = tmp_path / "repo"
|
||||
bundle = repo_root / "native" / "ReynaCLIHost" / "dist" / "Reyna CLI.app"
|
||||
(bundle / "Contents" / "MacOS").mkdir(parents=True)
|
||||
(bundle / "Contents" / "MacOS" / "ReynaCLIHost").write_bytes(b"x")
|
||||
with open(bundle / "Contents" / "Info.plist", "wb") as f:
|
||||
import plistlib as _pl
|
||||
_pl.dump(ab_mod.build_app_bundle_info_plist_dict(), f)
|
||||
|
||||
calls = []
|
||||
|
||||
class Proc:
|
||||
returncode = 0
|
||||
stdout = ""
|
||||
stderr = ""
|
||||
|
||||
def runner(args, **kwargs):
|
||||
if args and args[0] == "codesign":
|
||||
if "--verify" in args:
|
||||
class P:
|
||||
returncode = 0
|
||||
stdout = ""
|
||||
stderr = ""
|
||||
return P()
|
||||
if len(args) > 1 and args[1] == "-dv":
|
||||
class P:
|
||||
returncode = 0
|
||||
stdout = ""
|
||||
stderr = "TeamIdentifier=TEAM123\n"
|
||||
return P()
|
||||
return Proc()
|
||||
calls.append(args)
|
||||
return Proc()
|
||||
|
||||
res = ph_mod.start_privacy_host_service(uid="501", plist_path=plist, runner=runner, repo_root=repo_root)
|
||||
assert res["ok"] is True
|
||||
assert calls[0] == ["launchctl", "kickstart", "-k", "gui/501/com.reyna.cli.privacy-host"]
|
||||
@@ -0,0 +1,267 @@
|
||||
"""Tests for remaining coverage migration — fully consolidated no-Notes CLI.
|
||||
|
||||
This file replaces the old Notes-native tests. Validates:
|
||||
- privacy contract contains only calendar/contacts/reminders/system/speech/apple_llm (no notes)
|
||||
- coverage matrix exists and says Notes deferred + legacy untouched
|
||||
- system info still works via native host
|
||||
- local-services direct wrappers offline safe (no Notes)
|
||||
- docs mention Notes deferred
|
||||
"""
|
||||
|
||||
import json
|
||||
import pytest
|
||||
from typer.testing import CliRunner
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from reyna_cli.cli import app
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
# ─── Coverage matrix existence ───────────────────────────────────────────
|
||||
|
||||
def test_coverage_matrix_exists():
|
||||
from pathlib import Path
|
||||
p = Path(__file__).parents[1] / "docs" / "remaining-coverage-matrix.md"
|
||||
assert p.exists(), f"matrix doc missing at {p}"
|
||||
content = p.read_text()
|
||||
# Must mention Notes deferred
|
||||
assert "Notes" in content
|
||||
assert "deferred" in content.lower(), "matrix must say Notes deferred"
|
||||
assert "legacy" in content.lower()
|
||||
assert "untouched" in content.lower()
|
||||
|
||||
|
||||
# ─── Privacy contract — no notes, deferred ────────────────────────────────
|
||||
|
||||
def test_privacy_contract_no_notes_ops():
|
||||
from reyna_cli.privacy_contract import ALLOWED_OPERATIONS, _COMMAND_TO_OPERATION
|
||||
|
||||
for op in ALLOWED_OPERATIONS.keys():
|
||||
assert not op.startswith("notes."), f"forbidden notes op {op} present — Notes deferred"
|
||||
|
||||
forbidden = {"notes.list", "notes.read", "notes.create", "notes.request_access"}
|
||||
for fo in forbidden:
|
||||
assert fo not in ALLOWED_OPERATIONS
|
||||
|
||||
for cmd, op in _COMMAND_TO_OPERATION.items():
|
||||
assert not op.startswith("notes.")
|
||||
assert not cmd.startswith("notes_")
|
||||
|
||||
# Should still contain calendar/contacts/reminders/system/speech
|
||||
assert "calendar.list" in ALLOWED_OPERATIONS
|
||||
assert "contacts.search" in ALLOWED_OPERATIONS
|
||||
assert "reminders.lists" in ALLOWED_OPERATIONS
|
||||
assert "system.get_info" in ALLOWED_OPERATIONS
|
||||
assert "apple_llm.check" in ALLOWED_OPERATIONS
|
||||
|
||||
|
||||
def test_privacy_contract_mapping():
|
||||
from reyna_cli.privacy_contract import command_to_operation
|
||||
|
||||
assert command_to_operation("system_get_info") == "system.get_info"
|
||||
assert command_to_operation("apple_llm_check") == "apple_llm.check"
|
||||
|
||||
|
||||
# ─── System info wrappers still work ─────────────────────────────────────
|
||||
|
||||
def test_native_system_get_info_wrapper(monkeypatch):
|
||||
from reyna_cli import privacy_host as ph_mod
|
||||
|
||||
class FakeClient:
|
||||
def call(self, op, args):
|
||||
assert op == "system.get_info"
|
||||
return {"id": "x", "ok": True, "result": {"system_info": {"macos_version": "26.0"}}}
|
||||
|
||||
monkeypatch.setattr(ph_mod, "PrivacyClient", FakeClient)
|
||||
res = ph_mod.native_system_get_info()
|
||||
assert res["ok"] is True
|
||||
|
||||
|
||||
def test_cli_system_info_uses_native(monkeypatch):
|
||||
def fake_native():
|
||||
return {"ok": True, "source": "native_privacy_host", "result": {"system_info": {"macos_version": "15.0"}}}
|
||||
|
||||
monkeypatch.setattr("reyna_cli.privacy_host.native_system_get_info", fake_native)
|
||||
result = runner.invoke(app, ["macmini", "system-info", "--json"])
|
||||
assert result.exit_code == 0
|
||||
payload = json.loads(result.stdout)
|
||||
assert payload["ok"] is True
|
||||
|
||||
|
||||
# ─── No Notes CLI ─────────────────────────────────────────────────────────
|
||||
|
||||
def test_cli_macmini_no_notes_subcommand():
|
||||
result = runner.invoke(app, ["macmini", "--help"])
|
||||
assert result.exit_code == 0
|
||||
assert "notes" not in result.stdout.lower(), f"macmini must not list notes — Notes deferred, got: {result.stdout}"
|
||||
|
||||
|
||||
def test_cli_privacy_host_no_notes_authorize():
|
||||
result = runner.invoke(app, ["privacy-host", "--help"])
|
||||
assert result.exit_code == 0
|
||||
# notes-authorize must be gone
|
||||
assert "notes" not in result.stdout.lower()
|
||||
|
||||
|
||||
# ─── Local services direct wrappers offline safe ───────────────────────────
|
||||
|
||||
def test_speech_direct_config_offline():
|
||||
from reyna_cli.local_services_direct import SpeechDirectClient
|
||||
|
||||
c = SpeechDirectClient().config_status()
|
||||
assert "say_available" in c
|
||||
assert "source" in c
|
||||
assert c["source"] == "direct"
|
||||
assert isinstance(c.get("say_path"), str)
|
||||
|
||||
|
||||
def test_speech_direct_validate_args():
|
||||
from reyna_cli.local_services_direct import SpeechDirectClient
|
||||
|
||||
cli = SpeechDirectClient()
|
||||
ok = cli.synthesize_args("hello", voice="Alex", rate=200)
|
||||
assert ok["text"] == "hello"
|
||||
with pytest.raises(ValueError):
|
||||
cli.synthesize_args("", voice="Alex")
|
||||
with pytest.raises(ValueError):
|
||||
cli.synthesize_args("hi", rate=10)
|
||||
|
||||
|
||||
def test_kokoro_config_offline_no_network():
|
||||
from reyna_cli.local_services_direct import KokoroDirectClient
|
||||
|
||||
c = KokoroDirectClient(url="http://127.0.0.1:7332").config_status()
|
||||
assert c["url"] == "http://127.0.0.1:7332"
|
||||
assert c["source"] == "direct"
|
||||
assert "note" in c
|
||||
v = KokoroDirectClient().validate_synthesize("hello world")
|
||||
assert v["offline_validation"] is True
|
||||
with pytest.raises(ValueError):
|
||||
KokoroDirectClient().validate_synthesize("")
|
||||
|
||||
|
||||
def test_kokoro_no_secret_exposure(monkeypatch):
|
||||
from reyna_cli.local_services_direct import KokoroDirectClient
|
||||
|
||||
monkeypatch.setenv("KSAY_URL", "http://127.0.0.1:7332")
|
||||
c = KokoroDirectClient().config_status()
|
||||
for k in c:
|
||||
assert "token" not in k.lower() or "password" not in str(c[k]).lower()
|
||||
|
||||
|
||||
def test_voicebox_config_offline():
|
||||
from reyna_cli.local_services_direct import VoiceboxDirectClient
|
||||
|
||||
c = VoiceboxDirectClient().config_status()
|
||||
assert "url" in c
|
||||
assert c["source"] == "direct"
|
||||
assert "known_profiles" in c
|
||||
v = VoiceboxDirectClient().validate_generate("hello", profile="Aiden")
|
||||
assert v["offline_validation"] is True
|
||||
|
||||
|
||||
def test_apple_llm_config_offline():
|
||||
from reyna_cli.local_services_direct import AppleLLMDirectClient
|
||||
|
||||
c = AppleLLMDirectClient().config_status()
|
||||
assert "swift_available" in c or "swiftc_available" in c
|
||||
assert c["source"] == "direct"
|
||||
v = AppleLLMDirectClient().validate_polish("hello world", mode="line")
|
||||
assert v["offline_validation"] is True
|
||||
|
||||
|
||||
def test_image_config_offline_no_key_exposure(monkeypatch):
|
||||
from reyna_cli.local_services_direct import ImageDirectClient
|
||||
|
||||
monkeypatch.delenv("GEMINI_API_KEY", raising=False)
|
||||
c = ImageDirectClient().config_status()
|
||||
assert c["source"] == "direct"
|
||||
assert "gemini_api_key_configured" in c
|
||||
assert c["gemini_api_key_configured"] is False
|
||||
assert "GEMINI_API_KEY" not in json.dumps(c)
|
||||
|
||||
monkeypatch.setenv("GEMINI_API_KEY", "secret123")
|
||||
c2 = ImageDirectClient().config_status()
|
||||
assert c2["gemini_api_key_configured"] is True
|
||||
assert "secret123" not in json.dumps(c2)
|
||||
|
||||
|
||||
def test_system_direct_offline():
|
||||
from reyna_cli.local_services_direct import SystemDirectClient
|
||||
|
||||
c = SystemDirectClient().config_status()
|
||||
assert c["requires_tcc"] is False
|
||||
info = SystemDirectClient().get_info_offline()
|
||||
assert "macos_version" in info
|
||||
|
||||
|
||||
# ─── CLI local-services commands offline ──────────────────────────────────
|
||||
|
||||
def test_cli_local_services_speech_config():
|
||||
result = runner.invoke(app, ["local-services", "speech", "config", "--json"])
|
||||
assert result.exit_code == 0
|
||||
payload = json.loads(result.stdout)
|
||||
assert payload["ok"] is True
|
||||
assert payload["source"] == "direct"
|
||||
|
||||
|
||||
def test_cli_local_services_kokoro_config():
|
||||
result = runner.invoke(app, ["local-services", "kokoro", "config", "--json"])
|
||||
assert result.exit_code == 0
|
||||
payload = json.loads(result.stdout)
|
||||
assert payload["result"]["source"] == "direct"
|
||||
|
||||
|
||||
def test_cli_local_services_voicebox_config():
|
||||
result = runner.invoke(app, ["local-services", "voicebox", "config", "--json"])
|
||||
assert result.exit_code == 0
|
||||
|
||||
|
||||
def test_cli_local_services_apple_llm_config():
|
||||
result = runner.invoke(app, ["local-services", "apple-llm", "config", "--json"])
|
||||
assert result.exit_code == 0
|
||||
|
||||
|
||||
def test_cli_local_services_image_config():
|
||||
result = runner.invoke(app, ["local-services", "image", "config", "--json"])
|
||||
assert result.exit_code == 0
|
||||
payload = json.loads(result.stdout)
|
||||
assert payload["ok"] is True
|
||||
|
||||
|
||||
def test_cli_local_services_system_info():
|
||||
result = runner.invoke(app, ["local-services", "system", "info", "--json"])
|
||||
assert result.exit_code == 0
|
||||
|
||||
|
||||
def test_no_mcp_imports_in_direct_wrappers():
|
||||
from pathlib import Path
|
||||
|
||||
p = Path(__file__).parents[1] / "src" / "reyna_cli" / "local_services_direct.py"
|
||||
src = p.read_text()
|
||||
assert "MCPClient" not in src
|
||||
assert "call_macmini_tool" not in src
|
||||
assert "macmini_client" not in src
|
||||
assert "httpx.Client" not in src
|
||||
assert "requests.get" not in src
|
||||
|
||||
|
||||
def test_direct_wrappers_no_credential_exposure():
|
||||
from pathlib import Path
|
||||
|
||||
src = (Path(__file__).parents[1] / "src" / "reyna_cli" / "local_services_direct.py").read_text()
|
||||
assert "DECO_PASSWORD" not in src
|
||||
from reyna_cli.local_services_direct import KokoroDirectClient, VoiceboxDirectClient, AppleLLMDirectClient
|
||||
|
||||
for c in [KokoroDirectClient().config_status(), VoiceboxDirectClient().config_status(), AppleLLMDirectClient().config_status()]:
|
||||
for k, v in c.items():
|
||||
if isinstance(v, str):
|
||||
assert len(v) < 5000
|
||||
|
||||
|
||||
def test_no_notes_wrappers_in_privacy_host():
|
||||
from pathlib import Path
|
||||
|
||||
src = (Path(__file__).parents[1] / "src" / "reyna_cli" / "privacy_host.py").read_text()
|
||||
assert "native_notes" not in src
|
||||
assert "NotesProvider" not in src
|
||||
@@ -0,0 +1,279 @@
|
||||
"""Tests for reminders native wrappers and CLI – TDD fakes only, no live Reminders access."""
|
||||
from pathlib import Path
|
||||
import json
|
||||
import pytest
|
||||
from typer.testing import CliRunner
|
||||
from reyna_cli.cli import app
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
|
||||
def test_native_reminders_request_full_access_explicit_op(monkeypatch):
|
||||
from reyna_cli import privacy_host as ph_mod
|
||||
|
||||
captured = {}
|
||||
|
||||
class FakeClient:
|
||||
def __init__(self, timeout):
|
||||
captured["timeout"] = timeout
|
||||
|
||||
def call(self, op, args):
|
||||
captured["op"] = op
|
||||
captured["args"] = args
|
||||
return {"id": "x", "ok": True, "result": {"protocol_version": "1.0.0", "operation": "reminders.request_full_access", "status": "authorized"}}
|
||||
|
||||
monkeypatch.setattr(ph_mod, "PrivacyClient", FakeClient)
|
||||
result = ph_mod.native_reminders_request_full_access()
|
||||
assert captured["op"] == "reminders.request_full_access"
|
||||
assert captured["args"] == {}
|
||||
assert captured["timeout"] == 35
|
||||
assert result["ok"] is True
|
||||
assert result["result"]["status"] == "authorized"
|
||||
|
||||
|
||||
def test_native_reminders_lists_direct_op(monkeypatch):
|
||||
from reyna_cli import privacy_host as ph_mod
|
||||
|
||||
captured = {}
|
||||
|
||||
class FakeClient:
|
||||
def call(self, op, args):
|
||||
captured["op"] = op
|
||||
captured["args"] = args
|
||||
return {"id": "1", "ok": True, "result": {"protocol_version": "1.0.0", "operation": "reminders.lists", "reminder_lists": [{"id": "a", "title": "Groceries", "source": "iCloud", "type": "caldav"}]}}
|
||||
|
||||
monkeypatch.setattr(ph_mod, "PrivacyClient", FakeClient)
|
||||
result = ph_mod.native_reminders_lists()
|
||||
assert captured["op"] == "reminders.lists"
|
||||
assert captured["args"] == {}
|
||||
assert result["ok"] is True
|
||||
assert result["result"]["reminder_lists"][0]["title"] == "Groceries"
|
||||
|
||||
|
||||
def test_native_reminders_list_success(monkeypatch):
|
||||
from reyna_cli import privacy_host as ph_mod
|
||||
|
||||
captured = {}
|
||||
|
||||
class FakeClient:
|
||||
def call(self, op, args):
|
||||
captured["op"] = op
|
||||
captured["args"] = args
|
||||
return {"id": "2", "ok": True, "result": {"reminders": [{"id": "r1", "list_id": "a", "list_title": "Groceries", "title": "Milk", "completed": False, "due": None, "priority": 0}]}}
|
||||
|
||||
monkeypatch.setattr(ph_mod, "PrivacyClient", FakeClient)
|
||||
result = ph_mod.native_reminders_list(list_name="Groceries", limit=25)
|
||||
assert captured["op"] == "reminders.list"
|
||||
assert captured["args"]["list"] == "Groceries"
|
||||
assert captured["args"]["limit"] == 25
|
||||
assert result["result"]["reminders"][0]["title"] == "Milk"
|
||||
|
||||
|
||||
def test_native_reminders_list_with_id_and_completed_filter(monkeypatch):
|
||||
from reyna_cli import privacy_host as ph_mod
|
||||
|
||||
captured = {}
|
||||
|
||||
class FakeClient:
|
||||
def call(self, op, args):
|
||||
captured["args"] = args
|
||||
return {"id": "2", "ok": True, "result": {"reminders": []}}
|
||||
|
||||
monkeypatch.setattr(ph_mod, "PrivacyClient", FakeClient)
|
||||
ph_mod.native_reminders_list(list_id="stable-id", completed=True, limit=50)
|
||||
assert captured["args"]["list_id"] == "stable-id"
|
||||
assert captured["args"]["completed"] is True
|
||||
assert captured["args"]["limit"] == 50
|
||||
|
||||
|
||||
def test_native_reminders_create_success_with_list_title(monkeypatch):
|
||||
from reyna_cli import privacy_host as ph_mod
|
||||
|
||||
captured = {}
|
||||
|
||||
class FakeClient:
|
||||
def call(self, op, args):
|
||||
captured["op"] = op
|
||||
captured["args"] = args
|
||||
return {"id": "3", "ok": True, "result": {"created_reminder": {"id": "new", "list_id": "a", "list_title": "Groceries", "title": "Buy eggs"}}}
|
||||
|
||||
monkeypatch.setattr(ph_mod, "PrivacyClient", FakeClient)
|
||||
result = ph_mod.native_reminders_create(title="Buy eggs", list_name="Groceries", notes="organic", due="2026-08-10T10:00:00Z", priority=1)
|
||||
assert captured["op"] == "reminders.create"
|
||||
assert captured["args"]["title"] == "Buy eggs"
|
||||
assert captured["args"]["list"] == "Groceries"
|
||||
assert captured["args"]["notes"] == "organic"
|
||||
assert captured["args"]["due"] == "2026-08-10T10:00:00Z"
|
||||
assert captured["args"]["priority"] == 1
|
||||
assert result["ok"] is True
|
||||
|
||||
|
||||
def test_native_reminders_create_requires_list():
|
||||
from reyna_cli import privacy_host as ph_mod
|
||||
|
||||
with pytest.raises(ValueError, match="list must be specified"):
|
||||
ph_mod.native_reminders_create(title="No list")
|
||||
|
||||
|
||||
def test_native_reminders_create_validates_title():
|
||||
from reyna_cli import privacy_host as ph_mod
|
||||
|
||||
with pytest.raises(ValueError, match="title must be nonempty"):
|
||||
ph_mod.native_reminders_create(title="", list_name="X")
|
||||
|
||||
with pytest.raises(ValueError, match="title exceeds"):
|
||||
ph_mod.native_reminders_create(title="A" * 1025, list_name="X")
|
||||
|
||||
|
||||
def test_native_reminders_create_validates_limit():
|
||||
from reyna_cli import privacy_host as ph_mod
|
||||
|
||||
with pytest.raises(ValueError, match="limit"):
|
||||
ph_mod.native_reminders_list(limit=0)
|
||||
|
||||
with pytest.raises(ValueError, match="limit"):
|
||||
ph_mod.native_reminders_list(limit=999)
|
||||
|
||||
|
||||
def test_native_reminders_no_mcp_import():
|
||||
from reyna_cli import privacy_host as ph_mod
|
||||
import pathlib
|
||||
src = pathlib.Path(ph_mod.__file__).read_text()
|
||||
assert "call_macmini_tool" not in src
|
||||
assert "macmini_client" not in src
|
||||
assert "def native_reminders_request_full_access" in src
|
||||
assert "reminders.request_full_access" in src
|
||||
assert "def native_reminders_lists" in src
|
||||
assert "def native_reminders_list" in src
|
||||
assert "def native_reminders_create" in src
|
||||
|
||||
|
||||
def test_cli_reminders_lists_uses_native(monkeypatch):
|
||||
def fake_lists():
|
||||
return {"ok": True, "source": "native_privacy_host", "result": {"reminder_lists": [{"id": "a", "title": "Groceries", "source": "iCloud", "type": "caldav"}]}}
|
||||
|
||||
monkeypatch.setattr("reyna_cli.privacy_host.native_reminders_lists", fake_lists)
|
||||
res = runner.invoke(app, ["macmini", "reminders", "lists", "--json"])
|
||||
assert res.exit_code == 0, res.stdout + res.stderr
|
||||
payload = json.loads(res.stdout)
|
||||
assert payload["ok"] is True
|
||||
assert payload["result"]["reminder_lists"][0]["title"] == "Groceries"
|
||||
|
||||
|
||||
def test_cli_reminders_list_uses_native(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
def fake_list(list_id=None, list_name=None, completed=None, limit=50):
|
||||
captured["list_id"] = list_id
|
||||
captured["list_name"] = list_name
|
||||
captured["completed"] = completed
|
||||
captured["limit"] = limit
|
||||
return {"ok": True, "source": "native_privacy_host", "result": {"reminders": []}}
|
||||
|
||||
monkeypatch.setattr("reyna_cli.privacy_host.native_reminders_list", fake_list)
|
||||
res = runner.invoke(app, ["macmini", "reminders", "list", "--list", "Groceries", "--limit", "10", "--json"])
|
||||
assert res.exit_code == 0, res.stdout + res.stderr
|
||||
assert captured["list_name"] == "Groceries"
|
||||
assert captured["limit"] == 10
|
||||
|
||||
|
||||
def test_cli_reminders_list_with_completed_flags(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
def fake_list(list_id=None, list_name=None, completed=None, limit=25):
|
||||
captured["completed"] = completed
|
||||
return {"ok": True, "source": "native_privacy_host", "result": {"reminders": []}}
|
||||
|
||||
monkeypatch.setattr("reyna_cli.privacy_host.native_reminders_list", fake_list)
|
||||
res = runner.invoke(app, ["macmini", "reminders", "list", "--list", "Groceries", "--completed", "--json"])
|
||||
assert res.exit_code == 0, res.stdout + res.stderr
|
||||
assert captured["completed"] is True
|
||||
|
||||
res2 = runner.invoke(app, ["macmini", "reminders", "list", "--list", "Groceries", "--incomplete", "--json"])
|
||||
assert res2.exit_code == 0, res2.stdout + res2.stderr
|
||||
# --incomplete should set completed=False
|
||||
assert captured["completed"] is False
|
||||
|
||||
|
||||
def test_cli_reminders_create_requires_list():
|
||||
res = runner.invoke(app, ["macmini", "reminders", "create", "Buy milk", "--json"])
|
||||
assert res.exit_code != 0
|
||||
|
||||
|
||||
def test_cli_reminders_create_with_title_and_list(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
def fake_create(title, list_id=None, list_name=None, notes=None, due=None, priority=None):
|
||||
captured["title"] = title
|
||||
captured["list_name"] = list_name
|
||||
captured["list_id"] = list_id
|
||||
captured["notes"] = notes
|
||||
captured["due"] = due
|
||||
captured["priority"] = priority
|
||||
return {"ok": True, "source": "native_privacy_host", "result": {"created_reminder": {"id": "new", "list_id": "a", "list_title": "Groceries", "title": title}}}
|
||||
|
||||
monkeypatch.setattr("reyna_cli.privacy_host.native_reminders_create", fake_create)
|
||||
res = runner.invoke(app, ["macmini", "reminders", "create", "Buy milk", "--list", "Groceries", "--notes", "2% please", "--json"])
|
||||
assert res.exit_code == 0, res.stdout + res.stderr
|
||||
assert captured["title"] == "Buy milk"
|
||||
assert captured["list_name"] == "Groceries"
|
||||
assert captured["notes"] == "2% please"
|
||||
|
||||
|
||||
def test_cli_reminders_create_with_list_id_and_due_priority(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
def fake_create(title, list_id=None, list_name=None, notes=None, due=None, priority=None):
|
||||
captured["list_id"] = list_id
|
||||
captured["due"] = due
|
||||
captured["priority"] = priority
|
||||
return {"ok": True, "source": "native_privacy_host", "result": {"created_reminder": {"id": "new", "list_id": list_id or "", "list_title": "", "title": title}}}
|
||||
|
||||
monkeypatch.setattr("reyna_cli.privacy_host.native_reminders_create", fake_create)
|
||||
res = runner.invoke(app, ["macmini", "reminders", "create", "Task", "--list-id", "abc-123", "--due", "2026-08-10T10:00:00Z", "--priority", "1", "--json"])
|
||||
assert res.exit_code == 0, res.stdout + res.stderr
|
||||
assert captured["list_id"] == "abc-123"
|
||||
assert captured["due"] == "2026-08-10T10:00:00Z"
|
||||
assert captured["priority"] == 1
|
||||
|
||||
|
||||
def test_cli_reminders_no_mcp_tool_call_remaining():
|
||||
from reyna_cli import cli as cli_mod
|
||||
src = Path(cli_mod.__file__).read_text()
|
||||
assert "native_reminders_lists" in src
|
||||
assert "native_reminders_list" in src
|
||||
assert "native_reminders_create" in src
|
||||
assert 'call_macmini_tool("reminders_list_lists"' not in src
|
||||
assert 'call_macmini_tool("reminders_list"' not in src
|
||||
assert 'call_macmini_tool("reminders_create"' not in src
|
||||
|
||||
|
||||
def test_privacy_host_cli_has_reminders_authorize():
|
||||
res = runner.invoke(app, ["privacy-host", "--help"])
|
||||
assert res.exit_code == 0
|
||||
assert "reminders-authorize" in res.stdout
|
||||
|
||||
|
||||
def test_privacy_host_reminders_authorize_cli_help_mentions_prompt():
|
||||
res = runner.invoke(app, ["privacy-host", "reminders-authorize", "--help"])
|
||||
assert res.exit_code == 0
|
||||
out = res.stdout.lower()
|
||||
assert "reminders" in out
|
||||
assert "permission" in out or "prompt" in out or "privacy" in out
|
||||
|
||||
|
||||
def test_reminders_authorize_cli_no_generic_fallback(monkeypatch):
|
||||
calls = {"count": 0}
|
||||
|
||||
def fake_native():
|
||||
calls["count"] += 1
|
||||
return {"ok": True, "source": "native_privacy_host", "result": {"protocol_version": "1.0.0", "operation": "reminders.request_full_access", "status": "authorized"}}
|
||||
|
||||
monkeypatch.setattr("reyna_cli.privacy_host.native_reminders_request_full_access", fake_native)
|
||||
|
||||
res = runner.invoke(app, ["privacy-host", "reminders-authorize", "--json"])
|
||||
assert res.exit_code == 0, res.stdout + res.stderr
|
||||
payload = json.loads(res.stdout)
|
||||
assert payload["ok"] is True
|
||||
assert payload["result"]["status"] == "authorized"
|
||||
assert calls["count"] == 1
|
||||
+1
-1
@@ -217,7 +217,7 @@ def test_macmini_has_subcommands():
|
||||
assert result.exit_code == 0
|
||||
assert "calendar" in result.stdout
|
||||
assert "contacts" in result.stdout
|
||||
assert "notes" in result.stdout
|
||||
assert "notes" not in result.stdout, "Notes deferred — macmini must not list notes"
|
||||
assert "reminders" in result.stdout
|
||||
assert "deco" in result.stdout
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
"""TDD: builder must use -scheme and derivedDataPath, not -target incompatible form."""
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
|
||||
def test_builder_uses_scheme_and_derived_data_path():
|
||||
from reyna_cli import app_bundle as ab
|
||||
repo_root = Path(tempfile.mkdtemp()) / "repo"
|
||||
(repo_root / "native" / "ReynaCLIHost" / "ReynaCLIHost.xcodeproj").mkdir(parents=True)
|
||||
(repo_root / "native" / "ReynaCLIHost" / "ReynaCLIHost").mkdir(parents=True, exist_ok=True)
|
||||
(repo_root / "native" / "ReynaCLIHost" / "ReynaCLIHost" / "Info.plist").write_text("<plist></plist>")
|
||||
derived = repo_root / "custom" / "DerivedData"
|
||||
|
||||
cmd = ab.build_xcodebuild_command(repo_root=repo_root, derived_data_path=derived, disable_code_signing=True)
|
||||
|
||||
# Must use -scheme with valid scheme name
|
||||
assert "-scheme" in cmd, f"expected -scheme in {cmd}, got {cmd}"
|
||||
scheme_idx = cmd.index("-scheme")
|
||||
assert cmd[scheme_idx + 1] == "Reyna CLI", f"scheme name mismatch {cmd}"
|
||||
|
||||
# Must have derivedDataPath
|
||||
assert "-derivedDataPath" in cmd
|
||||
dd_idx = cmd.index("-derivedDataPath")
|
||||
assert cmd[dd_idx + 1] == str(derived)
|
||||
|
||||
# Must NOT use -target with -derivedDataPath (invalid, RC 64)
|
||||
assert "-target" not in cmd, f"must not use -target when using -derivedDataPath, got {cmd}"
|
||||
|
||||
# Must still contain configuration Release and build verb
|
||||
assert "-configuration" in cmd
|
||||
assert "Release" in cmd
|
||||
assert "build" in cmd
|
||||
assert "CODE_SIGNING_ALLOWED=NO" in cmd
|
||||
|
||||
def test_builder_unsigned_variant_still_uses_scheme():
|
||||
from reyna_cli import app_bundle as ab
|
||||
repo_root = Path(tempfile.mkdtemp()) / "repo"
|
||||
(repo_root / "native" / "ReynaCLIHost" / "ReynaCLIHost.xcodeproj").mkdir(parents=True)
|
||||
(repo_root / "native" / "ReynaCLIHost" / "ReynaCLIHost").mkdir(parents=True, exist_ok=True)
|
||||
(repo_root / "native" / "ReynaCLIHost" / "ReynaCLIHost" / "Info.plist").write_text("<plist></plist>")
|
||||
derived = repo_root / "custom" / "DerivedData"
|
||||
|
||||
cmd_signed = ab.build_xcodebuild_command(repo_root=repo_root, derived_data_path=derived, disable_code_signing=False)
|
||||
assert "-scheme" in cmd_signed
|
||||
assert "-target" not in cmd_signed
|
||||
@@ -0,0 +1,151 @@
|
||||
"""TDD: SystemInfoProvider must be linked in Xcode project and compile full protocol.
|
||||
|
||||
RED: before fix, project.pbxproj lacks SystemInfoProvider.swift -> should fail.
|
||||
GREEN: after adding fileRef, group, and Sources entries, passes.
|
||||
|
||||
Also regression: unsigned Xcode shared scheme Release build must succeed.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
import plistlib
|
||||
import subprocess
|
||||
import json
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
PBX = REPO_ROOT / "native" / "ReynaCLIHost" / "ReynaCLIHost.xcodeproj" / "project.pbxproj"
|
||||
SYSTEM_PROVIDER_SWIFT = REPO_ROOT / "native" / "ReynaCLIHost" / "Sources" / "ReynaCLIHostCore" / "SystemInfoProvider.swift"
|
||||
PROTOCOL_SWIFT = REPO_ROOT / "native" / "ReynaCLIHost" / "Sources" / "ReynaCLIHostCore" / "Protocol.swift"
|
||||
|
||||
|
||||
def _read_pbx() -> str:
|
||||
assert PBX.exists(), f"project.pbxproj missing at {PBX}"
|
||||
return PBX.read_text()
|
||||
|
||||
|
||||
def test_system_info_provider_file_exists():
|
||||
assert SYSTEM_PROVIDER_SWIFT.exists(), f"SystemInfoProvider.swift missing at {SYSTEM_PROVIDER_SWIFT}"
|
||||
src = SYSTEM_PROVIDER_SWIFT.read_text()
|
||||
assert "SystemInfoItem" in src
|
||||
assert "SpeechApiStatusItem" in src
|
||||
assert "ProductionSystemInfoProvider" in src
|
||||
# Conformance required by ResultPayload (Codable, Equatable, Sendable)
|
||||
assert "Codable, Equatable, Sendable" in src or ("Codable" in src and "Equatable" in src and "Sendable" in src)
|
||||
|
||||
|
||||
def test_system_info_result_types_are_codable_equatable_sendable():
|
||||
src = SYSTEM_PROVIDER_SWIFT.read_text()
|
||||
# Both data models must be Codable, Equatable, Sendable
|
||||
assert "struct SystemInfoItem: Codable, Equatable, Sendable" in src
|
||||
assert "struct SpeechApiStatusItem: Codable, Equatable, Sendable" in src
|
||||
# No secret leak: fields must be config/status only
|
||||
forbidden = ["password", "token", "api_key", "secret", "bundle_seed", "keychain", "credential"]
|
||||
lower = src.lower()
|
||||
for word in forbidden:
|
||||
# Allow if in comment about not leaking? But our file should not contain at all except maybe password_configured? Check we don't have password in this file
|
||||
# For system info, none of these should appear
|
||||
assert word not in lower or word == "token" and False, f"SystemInfoProvider must not contain sensitive field {word}" # noqa
|
||||
# Actually check explicitly: file should not contain password/token/api_key
|
||||
assert "password" not in lower
|
||||
assert "api_key" not in lower
|
||||
assert "secret" not in lower
|
||||
|
||||
|
||||
def test_xcode_pbx_contains_system_info_provider_ref_and_buildfile_and_group_and_sources():
|
||||
pbx = _read_pbx()
|
||||
# File ref
|
||||
assert "SystemInfoProvider.swift" in pbx, "SystemInfoProvider.swift missing from pbxproj file refs"
|
||||
# Build file entry
|
||||
assert "SystemInfoProvider.swift in Sources" in pbx, "SystemInfoProvider.swift missing from Sources build phase"
|
||||
# Core group must contain it (ReynaCLIHostCore group children includes SystemInfoProvider)
|
||||
# Look for group section
|
||||
assert "ReynaCLIHostCore" in pbx
|
||||
# The pbx structure: core group lists all swift files; we already check presence but also ensure PBXBuildFile entry exists
|
||||
assert "PBXBuildFile" in pbx
|
||||
assert "SystemInfoProvider.swift" in pbx.split("/* Begin PBXFileReference section */")[1].split("/* End PBXFileReference section */")[0] or "SystemInfoProvider.swift" in pbx
|
||||
|
||||
|
||||
def test_protocol_references_system_info_types_match_provider():
|
||||
proto = PROTOCOL_SWIFT.read_text()
|
||||
# Protocol must reference system.get_info, system.speech_api_status, apple_llm.check
|
||||
assert "system.get_info" in proto
|
||||
assert "system.speech_api_status" in proto
|
||||
assert "apple_llm.check" in proto
|
||||
# ResultPayload must have system_info and speech_api_status
|
||||
assert "system_info" in proto
|
||||
assert "speech_api_status" in proto
|
||||
assert "SystemInfoItem" in proto
|
||||
assert "SpeechApiStatusItem" in proto
|
||||
# Ensure apple_llm.check does NOT require private frameworks - it should be status ok only
|
||||
# Find its case
|
||||
assert 'case "apple_llm.check"' in proto
|
||||
|
||||
|
||||
def test_xcode_project_protocol_version_result_payload_extended_still_codable():
|
||||
# Simulate Codable check via swiftc compilation of Protocol.swift + SystemInfoProvider.swift alone
|
||||
# More importantly, ensure ResultPayload includes only expected ops and remains Codable
|
||||
proto = PROTOCOL_SWIFT.read_text()
|
||||
# Ensure ResultPayload init includes system_info and speech_api_status params
|
||||
assert "system_info: SystemInfoItem? = nil" in proto
|
||||
assert "speech_api_status: SpeechApiStatusItem? = nil" in proto
|
||||
|
||||
|
||||
def test_xcode_references_no_duplicate_or_missing_system_file_ref_ids():
|
||||
pbx = _read_pbx()
|
||||
# Count occurrences
|
||||
assert pbx.count("SystemInfoProvider.swift") >= 3, "Expected at least fileRef + buildFile + group entries"
|
||||
# Ensure IDs are present (B.. for file ref, C.. for build file)
|
||||
assert "B00000000000000000000015" in pbx or "SystemInfoProvider.swift\" = {isa = PBXFileReference" in pbx
|
||||
|
||||
|
||||
def test_apple_llm_check_belongs_in_native_app_and_uses_no_private_frameworks():
|
||||
proto = PROTOCOL_SWIFT.read_text()
|
||||
system_src = SYSTEM_PROVIDER_SWIFT.read_text()
|
||||
# apple_llm.check must NOT import FoundationModels private or unsupported
|
||||
assert "FoundationModels" not in proto
|
||||
assert "FoundationModels" not in system_src or "framework" not in system_src.lower() or True # allowed in comment but not import
|
||||
# Must NOT import Speech private only, etc. SystemInfoProvider should only use Foundation/Darwin
|
||||
assert "import Foundation" in system_src
|
||||
# Ensure apple_llm.check path returns status ok, failclosed if error
|
||||
# Extract the case block
|
||||
idx = proto.find('case "apple_llm.check"')
|
||||
assert idx != -1
|
||||
block = proto[idx: idx + 600]
|
||||
assert "status" in block
|
||||
assert "ok" in block
|
||||
# No force unwrap of private framework symbols
|
||||
assert "SystemLanguageModel" not in proto
|
||||
assert "ANE" not in proto or "ANE" in proto and ("conclusion" in proto.lower() or True) # ANE only in comments or SystemInfoProvider's framework string is allowed elsewhere but not in Protocol.swift operation?
|
||||
# Actually Protocol.swift should not reference ANE 3B classes directly
|
||||
assert "LanguageModelSession" not in proto
|
||||
|
||||
|
||||
def test_system_info_privacy_no_sensitive_config_leak():
|
||||
"""System info must only expose config/status, no sensitive system config like passwords."""
|
||||
src = SYSTEM_PROVIDER_SWIFT.read_text()
|
||||
# Allowed fields per task: config/status commands only, no secret system config
|
||||
# Check struct fields are whitelisted
|
||||
allowed_system_fields = {"macos_version", "build", "uname", "hw_model", "cpu_brand", "is_macos_26_plus", "speech_analyzer_expected"}
|
||||
allowed_speech_fields = {"system", "swift_availability", "conclusion"}
|
||||
# Extract struct definitions
|
||||
# Simple check: ensure struct contains only allowed fields (parse lines)
|
||||
system_block = src[src.find("struct SystemInfoItem"): src.find("struct SystemInfoItem") + 600]
|
||||
for forbidden in ["password", "token", "secret", "keychain", "home_directory", "user_home", "env"]:
|
||||
assert forbidden not in system_block.lower(), f"forbidden field {forbidden} in SystemInfoItem"
|
||||
|
||||
|
||||
def test_xcode_unsigned_release_build_smoke():
|
||||
"""Regression: unsigned Xcode shared scheme Release build must succeed (full protocol)."""
|
||||
from reyna_cli import app_bundle as ab
|
||||
repo_root = REPO_ROOT
|
||||
# Build with CODE_SIGNING_ALLOWED=NO, like in test_app_bundle_unsigned
|
||||
derived = repo_root / "native" / "ReynaCLIHost" / "build" / "DerivedData"
|
||||
# Run xcodebuild command via app_bundle helper to ensure shared scheme
|
||||
cmd = ab.build_xcodebuild_command(repo_root=repo_root, derived_data_path=derived, disable_code_signing=True)
|
||||
# Ensure our new file is not causing compile failure in the command list sense
|
||||
assert "SystemInfoProvider" not in " ".join(cmd) # command doesn't need to mention file, but xcode project does
|
||||
# Actually run xcodebuild
|
||||
proc = subprocess.run(cmd, cwd=str(repo_root), capture_output=True, text=True, timeout=180)
|
||||
assert proc.returncode == 0, f"xcodebuild unsigned Release failed: {proc.stdout[-2000:]} {proc.stderr[-2000:]}"
|
||||
# Verify product exists
|
||||
built_app = derived / "Build" / "Products" / "Release" / "Reyna CLI.app" / "Contents" / "MacOS" / "ReynaCLIHost"
|
||||
assert built_app.exists(), f"built product missing at {built_app}"
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Static contract: project must not force ad-hoc CODE_SIGN_IDENTITY when using Automatic Signing."""
|
||||
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
PBX = REPO_ROOT / "native" / "ReynaCLIHost" / "ReynaCLIHost.xcodeproj" / "project.pbxproj"
|
||||
|
||||
|
||||
def _read_pbx() -> str:
|
||||
assert PBX.exists(), f"project.pbxproj missing at {PBX}"
|
||||
return PBX.read_text()
|
||||
|
||||
|
||||
def test_no_forced_adhoc_code_sign_identity():
|
||||
src = _read_pbx()
|
||||
# Fail if any CODE_SIGN_IDENTITY variant is forced to "-" or ad-hoc
|
||||
# Covers CODE_SIGN_IDENTITY and CODE_SIGN_IDENTITY[sdk=...]
|
||||
pattern = re.compile(r'CODE_SIGN_IDENTITY.*?=\s*"?-"?\s*;', re.IGNORECASE)
|
||||
matches = pattern.findall(src)
|
||||
assert not matches, f"found forced ad-hoc CODE_SIGN_IDENTITY: {matches} in {PBX}"
|
||||
|
||||
# Also explicitly check literal '"-"'
|
||||
assert '"CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"' not in src
|
||||
assert 'CODE_SIGN_IDENTITY = "-"' not in src
|
||||
assert 'CODE_SIGN_IDENTITY = -' not in src
|
||||
|
||||
|
||||
def test_automatic_signing_not_paired_with_forced_identity():
|
||||
src = _read_pbx()
|
||||
# If project uses CODE_SIGN_STYLE = Automatic, it must not also force CODE_SIGN_IDENTITY to ad-hoc
|
||||
assert "CODE_SIGN_STYLE = Automatic" in src, "expected CODE_SIGN_STYLE=Automatic for durable identity"
|
||||
# Scan buildSettings blocks containing Automatic - simplistic but effective
|
||||
# Any occurrence of CODE_SIGN_IDENTITY with "-" while Automatic present is violation
|
||||
has_adhoc = bool(re.search(r'CODE_SIGN_IDENTITY.*=\s*"?-"?\s*;', src))
|
||||
has_auto = "CODE_SIGN_STYLE = Automatic" in src
|
||||
assert not (has_auto and has_adhoc), (
|
||||
"Automatic Signing paired with forced CODE_SIGN_IDENTITY=\"-\" defeats team signing; "
|
||||
"remove forced identity so Xcode can use selected team"
|
||||
)
|
||||
|
||||
|
||||
def test_static_config_bundle_and_signing_style():
|
||||
src = _read_pbx()
|
||||
assert "com.reyna.cli.privacy-host" in src, "bundle ID must remain fixed"
|
||||
assert "CODE_SIGN_STYLE = Automatic" in src
|
||||
# Must not contain literal manual style when we expect automatic
|
||||
# Ensure bundle id still present and no ad-hoc marker left
|
||||
assert '"-" ' not in src or 'CODE_SIGN_IDENTITY' not in src.split('"-"')[0][-100:] # sanity
|
||||
# Double-check no CODE_SIGN_IDENTITY forced at all (allow absence)
|
||||
assert 'CODE_SIGN_IDENTITY[sdk=' not in src or '"-"' not in src, "ad-hoc identity marker still present"
|
||||
Reference in New Issue
Block a user