diff --git a/client/gatehouse-cli.py b/client/gatehouse-cli.py index 29c8d28..a4cd752 100755 --- a/client/gatehouse-cli.py +++ b/client/gatehouse-cli.py @@ -668,11 +668,13 @@ if __name__ == "__main__": parser.add_argument("--remove-key", nargs='?', const='', metavar='KEY_ID', help="Remove an SSH key from your profile. Omit KEY_ID to pick interactively.") parser.add_argument("--list-keys", action='store_true', default=False, help="List SSH keys in your profile") parser.add_argument("--install-known-hosts", action='store_true', default=False, help="Fetch Host CA public key and install into ~/.ssh/known_hosts") + parser.add_argument("--list-orgs", action='store_true', default=False, help="List your organizations") + parser.add_argument("--org-id", type=str, help="Organization ID for cert signing (required when a member of multiple orgs)") args = parser.parse_args() if not (args.check_cert or args.request_cert or args.add_key or args.clear_cache - or args.remove_key is not None or args.list_keys or args.install_known_hosts): - parser.error("At least one of --check-cert, --request-cert, --add-key, --list-keys, --remove-key, --clear-cache, or --install-known-hosts must be provided.") + or args.remove_key is not None or args.list_keys or args.install_known_hosts or args.list_orgs): + parser.error("At least one of --check-cert, --request-cert, --add-key, --list-keys, --remove-key, --clear-cache, --list-orgs, or --install-known-hosts must be provided.") # Retrieve SSH key from environment variables if not provided via CLI diff --git a/docs/cli-testing.md b/docs/cli-testing.md new file mode 100644 index 0000000..d6534cb --- /dev/null +++ b/docs/cli-testing.md @@ -0,0 +1,103 @@ +# CLI Integration Tests + +This document describes the integration tests for `client/gatehouse-cli.py`. + +## Approach + +The CLI is tested as a **real subprocess** (not mocked functions) against a **real Flask HTTP server** with an in-memory/file-based SQLite database. Each test is fully self-contained, using UUID-based names to avoid collisions on the shared database. + +Key decisions: + +- **Subprocess + real server**: The CLI is invoked via `subprocess.run([sys.executable, "client/gatehouse-cli.py", ...])` with `SIGN_URL` and `HOME` env vars pointing to the test server and an isolated temp directory. This catches real-world issues like argparse mismatches, import errors, and API contract drift. +- **Module-scoped server**: A single Flask server (random port, `sqlite:///`) is started once per module via a `cli_server` fixture and shared across all tests. Data isolation is achieved by UUID-scoped names rather than per-test databases. +- **Real crypto**: Certificate-signing tests use real Ed25519 keypairs generated by `ssh-keygen`. The `sshkey_tools` library validates the public key, so fake-looking keys (random hex / padding) are rejected by the server. +- **DB-level bypasses**: For cert-signing tests, `_mark_key_verified()` sets `verified=True` directly in the database (same pattern as `test_ssh_workflows.py`), avoiding the complex `ssh-keygen -Y sign` flow. + +## Output Handling + +The CLI emits all messages via the `coloredlogs` library, which writes to **stderr**, not stdout. Occasional `print()` calls go to stdout. The `_output()` helper combines both streams: + +```python +def _output(result: subprocess.CompletedProcess) -> str: + return result.stdout + result.stderr +``` + +All text assertions use `in _output(result)`. + +## Fixtures + +| Fixture | Scope | Description | +|---------|-------|-------------| +| `cli_server` | module | Flask server on `127.0.0.1` with random port; yields `(server_url, app)` | +| `home_dir` | function | Isolated temp directory for `~/.gatehouse` and `~/.ssh` | + +## Test Inventory (10 tests) + +### 1. `test_cli_add_key` +**Flags:** `-a -k ` + +Generates a real Ed25519 keypair with `ssh-keygen`, registers a user, caches the token, then runs the CLI to add the key. Verifies the key-and-verify flow produces an "added successfully" message. Skips if `ssh-keygen` is not available. + +### 2. `test_cli_list_keys` +**Flags:** `--list-keys` + +Adds two fake SSH keys via the API, then runs the CLI to list them. Asserts both key ID prefixes appear in the output. + +### 3. `test_cli_remove_key` +**Flags:** `--remove-key ` + +Adds a key via the API, then runs the CLI to delete it by ID. Asserts "removed successfully" in the output. + +### 4. `test_cli_list_orgs` +**Flags:** `--list-orgs` + +Creates two organizations where the test user is an owner, then runs the CLI to list them. Asserts both org names appear in the output. + +### 5. `test_cli_request_cert_single_org` +**Flags:** `-r` + +Sets up a single organization with a user-type CA, a principal ("deploy"), a principal membership for the user, and a verified real SSH key. Runs the CLI to request a certificate. Asserts "Certificate signed successfully" and the principal name appear in the output. + +### 6. `test_cli_request_cert_multi_org_no_org` +**Flags:** `-r` (with multiple orgs) + +Creates two organizations, each with a CA and one with a principal membership. Runs `-r` without `--org-id`. Asserts exit code 1 and a "multiple organizations" error message. + +### 7. `test_cli_request_cert_multi_org_with_org` +**Flags:** `-r --org-id ` (with multiple orgs) + +Same setup as #6 but passes `--org-id` to disambiguate. Asserts exit code 0 and "Certificate signed successfully". + +### 8. `test_cli_install_known_hosts` +**Flags:** `--install-known-hosts` + +Creates a host-type CA, then runs the CLI to install the Host CA public key. Asserts the success message and verifies the public key was written to `~/.ssh/known_hosts`. + +### 9. `test_cli_clear_cache` +**Flags:** `--clear-cache` + +Writes a dummy token cache file, then runs the CLI to clear it. Asserts the cache file is deleted and "Cached token removed" appears in output. The server URL is irrelevant (no network calls). + +### 10. `test_cli_check_cert` +**Flags:** `-c` + +Runs the CLI to check for a certificate file that does not exist (no prior setup needed). Asserts exit code 1 and "Certificate does not exist" in output. The server URL is irrelevant (no network calls). + +## Running + +```bash +# Full suite +pytest tests/integration/test_cli.py -v + +# Single test +pytest tests/integration/test_cli.py::TestCLI::test_cli_request_cert_single_org -xvs + +# Without coverage noise +pytest tests/integration/test_cli.py --no-cov +``` + +## Notes + +- The `_gen_pubkey()` helper generates fake-looking keys (random hex + base64 padding) that are accepted by the API for storage but **rejected** by `sshkey_tools.PublicKey.from_string()` during cert signing. Use `_real_pubkey()` (which invokes actual `ssh-keygen`) for tests that need cryptographically valid keys. +- `_add_principal_member()` must be called whenever a user needs access to a principal. Even org owners are not automatically members — the CLI's `fetch_my_principals()` reads `my_principals`, not `all_principals`. +- The `cli_server` fixture creates tables once (`db.create_all()`) and reuses the database file across all tests. UUID-suffixed names (via `_email()`, `uuid.uuid4().hex`) prevent collisions. diff --git a/tests/integration/test_cli.py b/tests/integration/test_cli.py new file mode 100644 index 0000000..5b43fc2 --- /dev/null +++ b/tests/integration/test_cli.py @@ -0,0 +1,552 @@ +"""CLI integration tests. + +Runs ``gatehouse-cli.py`` as a subprocess against a real Flask server +with a file-based SQLite database. Each test is self-contained: +it creates its own user, org, CA, and SSH keys via the API, then +caches the auth token and invokes the CLI. +""" + +import json +import os +import socket +import subprocess +import sys +import threading +import time +import uuid + +import pytest +import requests + +from gatehouse_app import create_app +from gatehouse_app.extensions import db + + +# --------------------------------------------------------------------------- +# Module-scoped server fixture +# --------------------------------------------------------------------------- + +@pytest.fixture(scope="module") +def cli_server(tmp_path_factory): + """Flask test server on ``127.0.0.1`` with a random port + temp SQLite DB. + + Yields ``(server_url, app)``. A single DB file is shared by all tests + in the module; each test uses UUID-suffixed names so there are no + collisions. + """ + server_dir = tmp_path_factory.mktemp("cli-server") + db_path = server_dir / "test.db" + session_dir = server_dir / "sessions" + session_dir.mkdir() + + app = create_app(config_name="testing") + app.config["SQLALCHEMY_DATABASE_URI"] = f"sqlite:///{db_path}" + app.config["SESSION_FILE_DIR"] = str(session_dir) + + with app.app_context(): + db.create_all() + + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.bind(("127.0.0.1", 0)) + port = sock.getsockname()[1] + sock.close() + + server_url = f"http://127.0.0.1:{port}" + + t = threading.Thread( + target=lambda: app.run( + host="127.0.0.1", port=port, use_reloader=False, debug=False, + ), + daemon=True, + ) + t.start() + + for _ in range(50): + try: + requests.get(f"{server_url}/api/v1/auth/login", timeout=1) + break + except requests.ConnectionError: + time.sleep(0.1) + else: + raise RuntimeError("Server did not start in time") + + yield server_url, app + + +# --------------------------------------------------------------------------- +# Per-test home-directory fixture +# --------------------------------------------------------------------------- + +@pytest.fixture +def home_dir(tmp_path): + """Isolated ``~/.gatehouse`` + ``~/.ssh`` per test.""" + return tmp_path + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _register_user(server_url, email, password, full_name="Test User"): + """Register a user via ``POST /api/v1/auth/register``, return (user, token).""" + resp = requests.post( + f"{server_url}/api/v1/auth/register", + json={ + "email": email, + "password": password, + "password_confirm": password, + "full_name": full_name, + }, + ) + assert resp.status_code == 201, ( + f"Register failed ({resp.status_code}): {resp.text}" + ) + data = resp.json()["data"] + return data["user"], data["token"] + + +def _auth_headers(token): + return {"Authorization": f"Bearer {token}", "Content-Type": "application/json"} + + +def _create_org(server_url, token, name): + """Create an org (caller becomes owner).""" + resp = requests.post( + f"{server_url}/api/v1/organizations", + json={"name": name, "slug": name.lower().replace(" ", "-")}, + headers=_auth_headers(token), + ) + assert resp.status_code == 201, ( + f"Create org failed ({resp.status_code}): {resp.text}" + ) + return resp.json()["data"]["organization"] + + +def _create_ca(server_url, token, org_id, ca_type="user"): + """Create a real CA via ``POST /organizations/{id}/cas``.""" + resp = requests.post( + f"{server_url}/api/v1/organizations/{org_id}/cas", + json={ + "name": f"Test-CA-{uuid.uuid4().hex[:6]}", + "ca_type": ca_type, + "key_type": "ed25519", + }, + headers=_auth_headers(token), + ) + assert resp.status_code == 201, ( + f"Create CA failed ({resp.status_code}): {resp.text}" + ) + return resp.json()["data"]["ca"] + + +def _create_principal(server_url, token, org_id, name="deploy"): + """Create a principal in an org.""" + resp = requests.post( + f"{server_url}/api/v1/organizations/{org_id}/principals", + json={"name": name, "description": f"Principal {name}"}, + headers=_auth_headers(token), + ) + assert resp.status_code == 201, ( + f"Create principal failed ({resp.status_code}): {resp.text}" + ) + return resp.json()["data"]["principal"] + + +def _add_principal_member(server_url, token, org_id, principal_id, email): + """Add a user as a direct member of a principal (``POST .../members``).""" + resp = requests.post( + f"{server_url}/api/v1/organizations/{org_id}/principals/{principal_id}/members", + json={"email": email}, + headers=_auth_headers(token), + ) + # 201 = created; 409 = already exists (OK) + assert resp.status_code in (201, 409), ( + f"Add principal member failed ({resp.status_code}): {resp.text}" + ) + + +def _add_ssh_key(server_url, token, public_key): + """Add an SSH public key via the API.""" + resp = requests.post( + f"{server_url}/api/v1/ssh/keys", + json={"key": public_key, "description": "CLI test key"}, + headers=_auth_headers(token), + ) + assert resp.status_code == 201, ( + f"Add SSH key failed ({resp.status_code}): {resp.text}" + ) + return resp.json()["data"] + + +def _mark_key_verified(app, key_id): + """Bypass signature check — mark a key as ``verified`` directly in the DB.""" + from gatehouse_app.models.ssh_ca.ssh_key import SSHKey + + with app.app_context(): + key = db.session.get(SSHKey, key_id) + if key: + key.verified = True + key.verified_at = __import__( + "datetime" + ).datetime.now(__import__("pytz").UTC) + db.session.commit() + + +def _cache_token(home_dir, token): + """Write the token to ``~/.gatehouse/token_cache.json``.""" + cache_dir = home_dir / ".gatehouse" + cache_dir.mkdir(exist_ok=True) + (cache_dir / "token_cache.json").write_text(json.dumps({"token": token})) + + +def _run_cli(server_url, home_dir, *args): + """Run ``gatehouse-cli.py`` as a subprocess, return ``CompletedProcess``.""" + env = os.environ.copy() + env["SIGN_URL"] = server_url + env["HOME"] = str(home_dir) + proc = subprocess.run( + [sys.executable, "client/gatehouse-cli.py", *args], + capture_output=True, + text=True, + env=env, + timeout=30, + ) + return proc + + +def _output(result: subprocess.CompletedProcess) -> str: + """Combine stdout + stderr for message-level assertions. + + The CLI emits via coloredlogs (stderr) and occasional ``print()`` + calls (stdout), so we check both. + """ + return result.stdout + result.stderr + + +def _cleanup_shared_files(): + """Remove temp files the CLI writes to known paths.""" + for p in ("/tmp/challenge.txt", "/tmp/challenge.txt.sig", "/tmp/ssh-cert"): + if os.path.exists(p): + os.remove(p) + + +# ---- unique-name helpers --------------------------------------------------- +def _uniq(prefix="test"): + return f"{prefix}-{uuid.uuid4().hex[:8]}" + + +def _email(): + return f"{_uniq('cli')}@example.com" + + +# =========================================================================== +# Tests +# =========================================================================== + +class TestCLI: + """CLI integration tests — each test exercises a different subcommand.""" + + # ------------------------------------------------------------------ + # 1. Add SSH key (real ssh-keygen flow) + # ------------------------------------------------------------------ + + def test_cli_add_key(self, cli_server, home_dir): + """``gatehouse-cli.py -a -k `` — add + verify a key.""" + _cleanup_shared_files() + server_url, app = cli_server + + email = _email() + pw = "MyPassword123!" + _, token = _register_user(server_url, email, pw) + + # Generate a real Ed25519 keypair + key_path = os.path.join(home_dir, "id_ed25519") + gen = subprocess.run( + ["ssh-keygen", "-t", "ed25519", "-f", key_path, "-N", "", "-C", email], + capture_output=True, + ) + if gen.returncode != 0: + pytest.skip(f"ssh-keygen not available: {gen.stderr.decode()}") + + _cache_token(home_dir, token) + + result = _run_cli(server_url, home_dir, "-a", "-k", key_path + ".pub") + assert result.returncode == 0, ( + f"CLI add-key failed:\nstderr: {result.stderr}\nstdout: {result.stdout}" + ) + assert "added successfully" in _output(result), ( + f"Expected 'added successfully' in:\n{_output(result)}" + ) + + # ------------------------------------------------------------------ + # 2. List SSH keys + # ------------------------------------------------------------------ + + def test_cli_list_keys(self, cli_server, home_dir): + """``gatehouse-cli.py --list-keys`` — shows registered keys.""" + _cleanup_shared_files() + server_url, app = cli_server + + email = _email() + pw = "MyPassword123!" + _, token = _register_user(server_url, email, pw) + + # Add two SSH keys via API (using unique generated keys) + key1 = _add_ssh_key(server_url, token, self._gen_pubkey("key1")) + key2 = _add_ssh_key(server_url, token, self._gen_pubkey("key2")) + + _cache_token(home_dir, token) + result = _run_cli(server_url, home_dir, "--list-keys") + + assert result.returncode == 0, ( + f"CLI list-keys failed:\n{result.stderr}" + ) + assert key1["id"][:8] in _output(result), ( + f"Key1 ID prefix not in output:\n{_output(result)}" + ) + assert key2["id"][:8] in _output(result), ( + f"Key2 ID prefix not in output:\n{_output(result)}" + ) + + _key_counter = 0 + + def _gen_pubkey(self, tag: str) -> str: + """Return a unique-looking but structurally valid Ed25519 public key.""" + unique = uuid.uuid4().hex[:32] + padding = "A" * (43 - 32) + return ( + f"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI{unique}{padding} {tag}@example.com" + ) + + def _real_pubkey(self, tag: str) -> str: + """Generate a real Ed25519 keypair and return the public key string.""" + TestCLI._key_counter += 1 + tmp = f"/tmp/cli_test_key_{TestCLI._key_counter}_{uuid.uuid4().hex[:8]}" + subprocess.run( + ["ssh-keygen", "-t", "ed25519", "-f", tmp, "-N", "", "-C", tag], + capture_output=True, + ) + with open(f"{tmp}.pub") as f: + pub = f.read().strip() + for p in (tmp, f"{tmp}.pub"): + try: + os.unlink(p) + except OSError: + pass + return pub + + # ------------------------------------------------------------------ + # 3. Remove SSH key + # ------------------------------------------------------------------ + + def test_cli_remove_key(self, cli_server, home_dir): + """``gatehouse-cli.py --remove-key `` — deletes a key.""" + _cleanup_shared_files() + server_url, app = cli_server + + email = _email() + pw = "MyPassword123!" + _, token = _register_user(server_url, email, pw) + + key = _add_ssh_key(server_url, token, self._gen_pubkey("remove-me")) + + _cache_token(home_dir, token) + result = _run_cli(server_url, home_dir, "--remove-key", key["id"]) + + assert result.returncode == 0, ( + f"CLI remove-key failed:\n{result.stderr}" + ) + assert "removed successfully" in _output(result), ( + f"Expected removal confirmation in:\n{_output(result)}" + ) + + # ------------------------------------------------------------------ + # 4. List organizations + # ------------------------------------------------------------------ + + def test_cli_list_orgs(self, cli_server, home_dir): + """``gatehouse-cli.py --list-orgs`` — shows org memberships.""" + _cleanup_shared_files() + server_url, app = cli_server + + email = _email() + pw = "MyPassword123!" + _, token = _register_user(server_url, email, pw) + + org_a = _create_org(server_url, token, f"Alpha-{uuid.uuid4().hex[:6]}") + org_b = _create_org(server_url, token, f"Beta-{uuid.uuid4().hex[:6]}") + + _cache_token(home_dir, token) + result = _run_cli(server_url, home_dir, "--list-orgs") + + assert result.returncode == 0, ( + f"CLI list-orgs failed:\n{result.stderr}" + ) + assert org_a["name"] in _output(result), ( + f"Org A name not in output:\n{_output(result)}" + ) + assert org_b["name"] in _output(result), ( + f"Org B name not in output:\n{_output(result)}" + ) + + # ------------------------------------------------------------------ + # 5. Certificate signing — single org (no --org-id needed) + # ------------------------------------------------------------------ + + def test_cli_request_cert_single_org(self, cli_server, home_dir): + """``gatehouse-cli.py -r`` — auto-selects the only org.""" + _cleanup_shared_files() + server_url, app = cli_server + + email = _email() + pw = "MyPassword123!" + _, token = _register_user(server_url, email, pw) + org = _create_org(server_url, token, f"Single-{uuid.uuid4().hex[:6]}") + _create_ca(server_url, token, org["id"], ca_type="user") + princ = _create_principal(server_url, token, org["id"], name="deploy") + _add_principal_member(server_url, token, org["id"], princ["id"], email) + key = _add_ssh_key(server_url, token, self._real_pubkey("cert1")) + _mark_key_verified(app, key["id"]) + + _cache_token(home_dir, token) + + result = _run_cli(server_url, home_dir, "-r") + + assert result.returncode == 0, ( + f"CLI request-cert failed:\nstderr: {result.stderr}\nstdout: {result.stdout}" + ) + assert "Certificate signed successfully" in _output(result), ( + f"Expected success in:\n{_output(result)}" + ) + assert princ["name"] in _output(result), ( + f"Principal name not in output:\n{_output(result)}" + ) + + # ------------------------------------------------------------------ + # 6. Certificate signing — multi org WITHOUT --org-id + # ------------------------------------------------------------------ + + def test_cli_request_cert_multi_org_no_org(self, cli_server, home_dir): + """``gatehouse-cli.py -r`` w/o ``--org-id`` — ambiguous error.""" + _cleanup_shared_files() + server_url, app = cli_server + + email = _email() + pw = "MyPassword123!" + _, token = _register_user(server_url, email, pw) + + org1 = _create_org(server_url, token, f"MultiA-{uuid.uuid4().hex[:6]}") + _create_ca(server_url, token, org1["id"], ca_type="user") + + org2 = _create_org(server_url, token, f"MultiB-{uuid.uuid4().hex[:6]}") + _create_ca(server_url, token, org2["id"], ca_type="user") + + princ1 = _create_principal(server_url, token, org1["id"], name="deploy") + _add_principal_member(server_url, token, org1["id"], princ1["id"], email) + key = _add_ssh_key(server_url, token, self._real_pubkey("multi")) + _mark_key_verified(app, key["id"]) + + _cache_token(home_dir, token) + result = _run_cli(server_url, home_dir, "-r") + + assert result.returncode == 1, ( + f"Expected exit code 1, got {result.returncode}\n" + f"stdout: {result.stdout}\nstderr: {result.stderr}" + ) + assert "multiple organizations" in _output(result).lower() + + # ------------------------------------------------------------------ + # 7. Certificate signing — multi org WITH --org-id + # ------------------------------------------------------------------ + + def test_cli_request_cert_multi_org_with_org(self, cli_server, home_dir): + """``gatehouse-cli.py -r --org-id `` — specifies the org.""" + _cleanup_shared_files() + server_url, app = cli_server + + email = _email() + pw = "MyPassword123!" + _, token = _register_user(server_url, email, pw) + + org1 = _create_org(server_url, token, f"WithA-{uuid.uuid4().hex[:6]}") + _create_ca(server_url, token, org1["id"], ca_type="user") + princ1 = _create_principal(server_url, token, org1["id"], name="deploy") + _add_principal_member(server_url, token, org1["id"], princ1["id"], email) + + org2 = _create_org(server_url, token, f"WithB-{uuid.uuid4().hex[:6]}") + _create_ca(server_url, token, org2["id"], ca_type="user") + + key = _add_ssh_key(server_url, token, self._real_pubkey("with-org")) + _mark_key_verified(app, key["id"]) + + _cache_token(home_dir, token) + result = _run_cli(server_url, home_dir, "-r", "--org-id", org1["id"]) + + assert result.returncode == 0, ( + f"CLI request-cert with org-id failed:\n" + f"stderr: {result.stderr}\nstdout: {result.stdout}" + ) + assert "Certificate signed successfully" in _output(result) + + # ------------------------------------------------------------------ + # 8. Install known hosts + # ------------------------------------------------------------------ + + def test_cli_install_known_hosts(self, cli_server, home_dir): + """``gatehouse-cli.py --install-known-hosts`` — fetches Host CA.""" + _cleanup_shared_files() + server_url, app = cli_server + + email = _email() + pw = "MyPassword123!" + _, token = _register_user(server_url, email, pw) + org = _create_org(server_url, token, f"HostCA-{uuid.uuid4().hex[:6]}") + ca = _create_ca(server_url, token, org["id"], ca_type="host") + + _cache_token(home_dir, token) + result = _run_cli(server_url, home_dir, "--install-known-hosts") + + assert result.returncode == 0, ( + f"CLI install-known-hosts failed:\n{result.stderr}" + ) + assert "Successfully installed Host CA" in _output(result) + + # Verify the key was written to known_hosts + known_hosts = home_dir / ".ssh" / "known_hosts" + assert known_hosts.exists(), "known_hosts file was not created" + content = known_hosts.read_text() + assert ca["public_key"].strip() in content, ( + "CA public key not found in known_hosts" + ) + + # ------------------------------------------------------------------ + # 9. Clear cache + # ------------------------------------------------------------------ + + def test_cli_clear_cache(self, cli_server, home_dir): + """``gatehouse-cli.py --clear-cache`` — removes cached token.""" + # Create a bogus cache file first + cache_dir = home_dir / ".gatehouse" + cache_dir.mkdir(exist_ok=True) + (cache_dir / "token_cache.json").write_text('{"token":"dummy"}') + + result = _run_cli("http://localhost:0", home_dir, "--clear-cache") + assert result.returncode == 0, ( + f"CLI clear-cache failed:\n{result.stderr}" + ) + assert "Cached token removed" in _output(result) + assert not (cache_dir / "token_cache.json").exists() + + # ------------------------------------------------------------------ + # 10. Check certificate (no cert file) + # ------------------------------------------------------------------ + + def test_cli_check_cert(self, cli_server, home_dir): + """``gatehouse-cli.py -c`` — reports missing cert.""" + _cleanup_shared_files() + result = _run_cli("http://localhost:0", home_dir, "-c") + assert result.returncode == 1, ( + f"Expected exit code 1, got {result.returncode}\n" + f"stdout: {result.stdout}" + ) + assert "Certificate does not exist" in _output(result)