| 1 | """ |
| 2 | test_gui_hardening_unit |
| 3 | ======================= |
| 4 | |
| 5 | Focused checks for GUI/CLI filesystem safety and container parser hardening. |
| 6 | """ |
| 7 | |
| 8 | from __future__ import annotations |
| 9 | |
| 10 | import json |
| 11 | import sys |
| 12 | import tempfile |
| 13 | from pathlib import Path |
| 14 | |
| 15 | ROOT = Path(__file__).resolve().parent.parent |
| 16 | sys.path.insert(0, str(ROOT)) |
| 17 | |
| 18 | try: |
| 19 | import tkinter |
| 20 | except ImportError: |
| 21 | tkinter = None |
| 22 | |
| 23 | import pytest |
| 24 | |
| 25 | pytestmark = pytest.mark.skipif( |
| 26 | tkinter is None, reason="python3-tk not installed; GUI tests skipped" |
| 27 | ) |
| 28 | |
| 29 | if tkinter is not None: |
| 30 | from cli import gui |
| 31 | |
| 32 | from oversight_core import ClassicIdentity, Manifest, Recipient, content_hash, seal |
| 33 | from oversight_core.container import SealedFile |
| 34 | from oversight_core.safe_io import is_private_key_file, validate_output_path |
| 35 | |
| 36 | |
| 37 | def _identity_dict(identity_id: str = "alice") -> dict: |
| 38 | ident = ClassicIdentity.generate() |
| 39 | return { |
| 40 | "id": identity_id, |
| 41 | "x25519_priv": ident.x25519_priv.hex(), |
| 42 | "x25519_pub": ident.x25519_pub.hex(), |
| 43 | "ed25519_priv": ident.ed25519_priv.hex(), |
| 44 | "ed25519_pub": ident.ed25519_pub.hex(), |
| 45 | } |
| 46 | |
| 47 | |
| 48 | def _sealed_blob() -> bytes: |
| 49 | issuer = ClassicIdentity.generate() |
| 50 | recipient = ClassicIdentity.generate() |
| 51 | plaintext = b"hello oversight" |
| 52 | manifest = Manifest.new( |
| 53 | "hello.txt", |
| 54 | content_hash(plaintext), |
| 55 | len(plaintext), |
| 56 | "issuer", |
| 57 | issuer.ed25519_pub.hex(), |
| 58 | Recipient("alice", recipient.x25519_pub.hex(), recipient.ed25519_pub.hex()), |
| 59 | "https://registry.oversightprotocol.dev", |
| 60 | "text/plain", |
| 61 | ) |
| 62 | return seal(plaintext, manifest, issuer.ed25519_priv, recipient.x25519_pub) |
| 63 | |
| 64 | |
| 65 | def test_private_key_outputs_are_blocked(): |
| 66 | with tempfile.TemporaryDirectory() as td: |
| 67 | key_path = Path(td) / "alice.priv.json" |
| 68 | key_path.write_text(json.dumps(_identity_dict()), encoding="utf-8") |
| 69 | assert is_private_key_file(key_path), "fixture should parse as private key" |
| 70 | try: |
| 71 | validate_output_path(key_path) |
| 72 | except ValueError as exc: |
| 73 | assert "private key" in str(exc) |
| 74 | else: |
| 75 | raise AssertionError("private key overwrite was not blocked") |
| 76 | print(" [PASS] private key output targets are hard-blocked") |
| 77 | |
| 78 | |
| 79 | def test_same_path_outputs_are_blocked(): |
| 80 | with tempfile.TemporaryDirectory() as td: |
| 81 | input_path = Path(td) / "source.txt" |
| 82 | input_path.write_text("source", encoding="utf-8") |
| 83 | try: |
| 84 | validate_output_path(input_path, input_paths=[input_path]) |
| 85 | except ValueError as exc: |
| 86 | assert "different" in str(exc) |
| 87 | else: |
| 88 | raise AssertionError("same-path output was not blocked") |
| 89 | print(" [PASS] output paths cannot equal input paths") |
| 90 | |
| 91 | |
| 92 | def test_windows_reserved_names_are_rejected(): |
| 93 | try: |
| 94 | validate_output_path(Path("NUL.priv.json")) |
| 95 | except ValueError as exc: |
| 96 | assert "reserved" in str(exc) |
| 97 | else: |
| 98 | raise AssertionError("Windows reserved output name was not blocked") |
| 99 | print(" [PASS] Windows reserved output names are rejected") |
| 100 | |
| 101 | |
| 102 | def test_gui_key_shape_errors_are_friendly(): |
| 103 | with tempfile.TemporaryDirectory() as td: |
| 104 | pub_path = Path(td) / "alice.pub.json" |
| 105 | pub_path.write_text(json.dumps({"id": "alice", "x25519_pub": "00" * 32}), encoding="utf-8") |
| 106 | try: |
| 107 | gui._read_private_identity(pub_path, "Issuer file") |
| 108 | except ValueError as exc: |
| 109 | assert "public key" in str(exc) and "x25519_priv" in str(exc) |
| 110 | else: |
| 111 | raise AssertionError("public key accepted as private identity") |
| 112 | print(" [PASS] key-shape mistakes get actionable GUI errors") |
| 113 | |
| 114 | |
| 115 | def test_gui_registry_domain_uses_user_url(): |
| 116 | assert gui._registry_domain("https://registry.example.test:8443/api") == "registry.example.test:8443" |
| 117 | print(" [PASS] GUI beacon domain derives from the configured registry URL") |
| 118 | |
| 119 | |
| 120 | def test_container_rejects_suite_id_tamper(): |
| 121 | blob = bytearray(_sealed_blob()) |
| 122 | blob[7] ^= 0x01 |
| 123 | try: |
| 124 | SealedFile.from_bytes(bytes(blob)) |
| 125 | except ValueError as exc: |
| 126 | assert "suite" in str(exc).lower() |
| 127 | else: |
| 128 | raise AssertionError("suite_id tamper was accepted") |
| 129 | print(" [PASS] unauthenticated suite_id tamper is rejected") |
| 130 | |
| 131 | |
| 132 | def test_container_rejects_trailing_bytes(): |
| 133 | try: |
| 134 | SealedFile.from_bytes(_sealed_blob() + b"junk") |
| 135 | except ValueError as exc: |
| 136 | assert "Trailing bytes" in str(exc) |
| 137 | else: |
| 138 | raise AssertionError("trailing bytes were accepted") |
| 139 | print(" [PASS] trailing bytes after ciphertext are rejected") |
| 140 | |
| 141 | |
| 142 | def main(): |
| 143 | print("=" * 60) |
| 144 | print(" GUI/CLI hardening - focused unit tests") |
| 145 | print("=" * 60) |
| 146 | test_private_key_outputs_are_blocked() |
| 147 | test_same_path_outputs_are_blocked() |
| 148 | test_windows_reserved_names_are_rejected() |
| 149 | test_gui_key_shape_errors_are_friendly() |
| 150 | test_gui_registry_domain_uses_user_url() |
| 151 | test_container_rejects_suite_id_tamper() |
| 152 | test_container_rejects_trailing_bytes() |
| 153 | print() |
| 154 | print(" ALL TESTS PASSED - 7/7") |
| 155 | |
| 156 | |
| 157 | if __name__ == "__main__": |
| 158 | if tkinter is None: |
| 159 | print("python3-tk not installed; GUI tests skipped") |
| 160 | else: |
| 161 | main() |