From c6a675276cfac7169ed6a8f58b0b680105976986 Mon Sep 17 00:00:00 2001 From: Khaleel Al-Adhami Date: Mon, 8 Jun 2026 18:47:20 -0700 Subject: [PATCH] feat: add `reflex cloud scan` security review command Adds a Reflex-aware security review CLI command. Zips the app source (pruning build/dependency dirs), uploads it via a presigned URL, submits it for review, polls to completion, and renders findings by severity. Supports --json output and a --fail-on severity gate for CI. ENG-9640 --- .../reflex-hosting-cli/news/6632.feature.md | 1 + .../src/reflex_cli/utils/hosting.py | 129 ++++++++ .../src/reflex_cli/v2/deployments.py | 5 + .../src/reflex_cli/v2/scan.py | 262 ++++++++++++++++ tests/units/reflex_cli/utils/test_hosting.py | 123 ++++++++ tests/units/reflex_cli/v2/test_scan.py | 296 ++++++++++++++++++ 6 files changed, 816 insertions(+) create mode 100644 packages/reflex-hosting-cli/news/6632.feature.md create mode 100644 packages/reflex-hosting-cli/src/reflex_cli/v2/scan.py create mode 100644 tests/units/reflex_cli/v2/test_scan.py diff --git a/packages/reflex-hosting-cli/news/6632.feature.md b/packages/reflex-hosting-cli/news/6632.feature.md new file mode 100644 index 00000000000..57d5d15e160 --- /dev/null +++ b/packages/reflex-hosting-cli/news/6632.feature.md @@ -0,0 +1 @@ +Added `reflex cloud scan`, which uploads your app source for a Reflex-aware security review and reports security and logic flaws. Supports `--json` output and a `--fail-on` severity gate for CI. diff --git a/packages/reflex-hosting-cli/src/reflex_cli/utils/hosting.py b/packages/reflex-hosting-cli/src/reflex_cli/utils/hosting.py index ef8db9f4ba1..d87db2a84f2 100644 --- a/packages/reflex-hosting-cli/src/reflex_cli/utils/hosting.py +++ b/packages/reflex-hosting-cli/src/reflex_cli/utils/hosting.py @@ -1456,6 +1456,135 @@ def create_deployment( return response.json() +class SecurityReviewError(ResponseError): + """Raised when a security review request fails.""" + + +_SECURITY_REVIEW_PREFIX = "/api/v1/agents/security-review" + + +def _security_review_detail(response: Any) -> str: + """Extract a human-readable ``detail`` from a failed review response. + + Args: + response: The error response from the security review API. + + Returns: + The server-provided detail, or a generic fallback if the body is not + a JSON object with a ``detail`` field. + + """ + try: + return str(response.json()["detail"]) + except (ValueError, TypeError, KeyError): + return "internal server error" + + +def submit_security_review(zip_bytes: bytes, client: AuthenticatedClient) -> str: + """Submit a zipped app for security review. + + Uploads the archive straight to object storage via a presigned URL, then + submits the stored object for review. + + Args: + zip_bytes: The zipped app source to review. + client: The authenticated client. + + Returns: + The id of the submitted job, to be polled with ``get_security_review``. + + Raises: + NotAuthenticatedError: If the token is not valid. + SecurityReviewError: If any step of the submission fails. + + """ + import httpx + + if not isinstance(client, AuthenticatedClient): + raise NotAuthenticatedError("not authenticated") + + auth = authorization_header(client.token) + + # 1. Ask the API for a presigned URL to upload the archive directly. + upload_url_response = httpx.post( + urljoin( + constants.Hosting.HOSTING_SERVICE, + f"{_SECURITY_REVIEW_PREFIX}/jobs/upload-url", + ), + json={"content_length": len(zip_bytes), "content_type": "application/zip"}, + headers=auth, + timeout=constants.Hosting.TIMEOUT, + ) + try: + upload_url_response.raise_for_status() + except httpx.HTTPStatusError as ex: + raise SecurityReviewError(_security_review_detail(ex.response)) from ex + upload = upload_url_response.json() + + # 2. Upload the bytes to storage. The presigned URL pins the content length + # and type, so send the returned headers verbatim and let httpx derive + # Content-Length from the body — setting it manually breaks the signature. + put_response = httpx.put( + upload["url"], + content=zip_bytes, + headers=upload.get("headers", {}), + timeout=120, + ) + try: + put_response.raise_for_status() + except httpx.HTTPStatusError as ex: + raise SecurityReviewError("failed to upload app source for review") from ex + + # 3. Submit the uploaded object for review. + response = httpx.post( + urljoin(constants.Hosting.HOSTING_SERVICE, f"{_SECURITY_REVIEW_PREFIX}/jobs"), + json={"key": upload["key"]}, + headers=auth, + timeout=constants.Hosting.TIMEOUT, + ) + try: + response.raise_for_status() + except httpx.HTTPStatusError as ex: + raise SecurityReviewError(_security_review_detail(ex.response)) from ex + return response.json()["job_id"] + + +def get_security_review(job_id: str, client: AuthenticatedClient) -> dict[str, Any]: + """Poll a previously submitted security review job. + + Args: + job_id: The id returned by ``submit_security_review``. + client: The authenticated client. + + Returns: + The job status payload: ``status`` is one of ``pending``, ``complete`` + or ``error``; ``result`` holds the review once ``complete``. + + Raises: + NotAuthenticatedError: If the token is not valid. + SecurityReviewError: If the server returns an error. + + """ + import httpx + + if not isinstance(client, AuthenticatedClient): + raise NotAuthenticatedError("not authenticated") + + response = httpx.get( + urljoin( + constants.Hosting.HOSTING_SERVICE, + f"{_SECURITY_REVIEW_PREFIX}/jobs/{job_id}", + ), + headers=authorization_header(client.token), + timeout=constants.Hosting.TIMEOUT, + ) + try: + response.raise_for_status() + except httpx.HTTPStatusError as ex: + raise SecurityReviewError(_security_review_detail(ex.response)) from ex + return response.json() + + def stop_app(app_id: str, client: AuthenticatedClient): """Stop a running application. diff --git a/packages/reflex-hosting-cli/src/reflex_cli/v2/deployments.py b/packages/reflex-hosting-cli/src/reflex_cli/v2/deployments.py index 37c680fd7ca..93b1d6644a6 100644 --- a/packages/reflex-hosting-cli/src/reflex_cli/v2/deployments.py +++ b/packages/reflex-hosting-cli/src/reflex_cli/v2/deployments.py @@ -14,6 +14,7 @@ from reflex_cli.v2.apps import apps_cli from reflex_cli.v2.gcp import deploy_command as gcp_deploy_command from reflex_cli.v2.project import project_cli +from reflex_cli.v2.scan import scan_command from reflex_cli.v2.secrets import secrets_cli from reflex_cli.v2.vmtypes_regions import vm_types_regions_cli @@ -69,6 +70,10 @@ def hosting_cli(ctx: click.Context) -> None: gcp_deploy_command, name="deploy", ) +hosting_cli.add_command( + scan_command, + name="scan", +) for name, command in vm_types_regions_cli.commands.items(): # Add the command to the hosting CLI hosting_cli.add_command(command, name=name) diff --git a/packages/reflex-hosting-cli/src/reflex_cli/v2/scan.py b/packages/reflex-hosting-cli/src/reflex_cli/v2/scan.py new file mode 100644 index 00000000000..6ff2610218e --- /dev/null +++ b/packages/reflex-hosting-cli/src/reflex_cli/v2/scan.py @@ -0,0 +1,262 @@ +"""The `reflex cloud scan` command: a Reflex-aware security review.""" + +from __future__ import annotations + +import io +import json +import os +import time +import zipfile +from pathlib import Path +from typing import Any + +import click + +from reflex_cli import constants +from reflex_cli.utils import console +from reflex_cli.utils.exceptions import NotAuthenticatedError + +# Directory names whose contents are dependencies or build artifacts, never +# app source. Mirrors the server-side code-map loader so the upload stays +# small instead of shipping bytes the reviewer will discard. +_SKIP_DIRS = frozenset({ + ".git", + ".mypy_cache", + ".next", + ".pytest_cache", + ".ruff_cache", + ".venv", + ".web", + "__pycache__", + "build", + "dist", + "node_modules", + "venv", +}) + +# The reviewer skips files larger than this, so there is no point uploading +# them. Matches the server's per-file cap. +_MAX_FILE_BYTES = 1_000_000 +# The submission endpoint rejects archives above this size. +_MAX_ZIP_BYTES = 50 * 1024 * 1024 + +_POLL_INTERVAL_SECONDS = 3.0 +_POLL_TIMEOUT_SECONDS = 600.0 + +# Severities ordered from most to least serious, for sorting and gating. +_SEVERITY_ORDER = ("critical", "high", "medium", "low") +_SEVERITY_RANK = {severity: rank for rank, severity in enumerate(_SEVERITY_ORDER)} +# Rich styles for the severity badge — a bold, padded, reverse-video label so +# findings stand out at a glance. +_SEVERITY_STYLE = { + "critical": "bold white on red", + "high": "bold red", + "medium": "bold yellow", + "low": "bold cyan", +} + + +def _zip_app_source(directory: Path) -> bytes: + """Zip the app source under ``directory`` for security review. + + Args: + directory: The app root to scan. + + Returns: + The zipped source as bytes. + + Raises: + Exit: If no reviewable files are found or the archive is too large. + + """ + buffer = io.BytesIO() + file_count = 0 + with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as archive: + for root, dirs, files in os.walk(directory): + # Prune skip dirs in place so os.walk never descends into them — + # node_modules/.web alone hold tens of thousands of files. + dirs[:] = [name for name in dirs if name not in _SKIP_DIRS] + root_path = Path(root) + for name in files: + path = root_path / name + if not path.is_file(): + continue + if path.stat().st_size > _MAX_FILE_BYTES: + continue + archive.write(path, path.relative_to(directory).as_posix()) + file_count += 1 + + if not file_count: + console.error(f"No reviewable source files found in {directory}.") + raise click.exceptions.Exit(1) + + data = buffer.getvalue() + if len(data) > _MAX_ZIP_BYTES: + console.error( + f"Project source is too large to scan (over {_MAX_ZIP_BYTES // (1024 * 1024)} MB). " + "Remove large files or move them into an ignored directory." + ) + raise click.exceptions.Exit(1) + return data + + +def _print_violations(result: dict[str, Any]) -> None: + """Render a security review result to the console. + + Args: + result: The ``SecurityReviewResult`` payload from the server. + + """ + from rich.markup import escape + + # Disable Rich's auto-highlighter throughout so only the explicit styles + # below apply — otherwise it recolours line numbers, paths and punctuation. + violations = result.get("violations", []) + summary = result.get("summary") + if summary: + console.print(f"[bold]Summary:[/bold] {escape(summary)}", highlight=False) + + if not violations: + console.success("No issues found.") + return + + console.rule("[bold]Security review findings[/bold]") + for violation in sorted( + violations, + key=lambda item: _SEVERITY_RANK.get( + item.get("severity", "low"), len(_SEVERITY_ORDER) + ), + ): + severity = violation.get("severity", "low") + style = _SEVERITY_STYLE.get(severity, "bold") + location = violation.get("file_path", "?") + if violation.get("line") is not None: + location = f"{location}:{violation['line']}" + console.print( + f"[{style}] {escape(severity.upper())} [/] " + f"[bold cyan]{escape(violation.get('rule_id', ''))}[/bold cyan] " + f"[dim]({escape(violation.get('category', ''))})[/dim] " + f"[dim underline]{escape(location)}[/dim underline]", + highlight=False, + ) + console.print(f" {escape(violation.get('message', ''))}", highlight=False) + if recommendation := violation.get("recommendation"): + console.print( + f" [green]Fix:[/green] {escape(recommendation)}", highlight=False + ) + console.print("") + + # Colour the tally by the worst severity present (there is at least one). + worst = min( + _SEVERITY_RANK.get(v.get("severity", "low"), len(_SEVERITY_ORDER) - 1) + for v in violations + ) + count_style = _SEVERITY_STYLE[_SEVERITY_ORDER[worst]] + console.print( + f"[{count_style}] Found {len(violations)} issue(s). [/]", highlight=False + ) + + +@click.command(name="scan") +@click.argument( + "directory", + required=False, + default=".", + type=click.Path(exists=True, file_okay=False, path_type=Path), +) +@click.option("--token", help="The authentication token.") +@click.option( + "--fail-on", + type=click.Choice([*_SEVERITY_ORDER, "none"]), + default="low", + help="Exit non-zero if a violation at or above this severity is found. " + "Use 'none' to always exit 0.", +) +@click.option( + "--loglevel", + type=click.Choice([level.value for level in constants.LogLevel]), + default=constants.LogLevel.INFO.value, + help="The log level to use.", +) +@click.option( + "--json/--no-json", + "-j", + "as_json", + is_flag=True, + help="Whether to output the result in JSON format.", +) +@click.option( + "--interactive/--no-interactive", + "-i", + is_flag=True, + default=True, + help="Whether to use interactive mode.", +) +def scan_command( + directory: Path, + token: str | None, + fail_on: str, + loglevel: str, + as_json: bool, + interactive: bool, +): + """Run a Reflex-aware security review over your app source. + + Uploads the app source under DIRECTORY (the current directory by default) + and reports security and logic flaws specific to Reflex apps. + """ + from reflex_cli.utils import hosting + + console.set_log_level(loglevel) + + try: + authenticated_client = hosting.get_authenticated_client( + token=token, interactive=interactive + ) + + zip_bytes = _zip_app_source(directory) + + with console.status("Uploading app source..."): + job_id = hosting.submit_security_review( + zip_bytes=zip_bytes, client=authenticated_client + ) + + deadline = time.monotonic() + _POLL_TIMEOUT_SECONDS + payload: dict[str, Any] = {} + with console.status("Scanning..."): + while True: + payload = hosting.get_security_review( + job_id=job_id, client=authenticated_client + ) + if payload.get("status") != "pending": + break + if time.monotonic() >= deadline: + console.error("Security review timed out. Try again later.") + raise click.exceptions.Exit(1) + time.sleep(_POLL_INTERVAL_SECONDS) + except NotAuthenticatedError as err: + console.error("You are not authenticated. Run `reflex login` to authenticate.") + raise click.exceptions.Exit(1) from err + except hosting.SecurityReviewError as err: + console.error(f"Security review failed: {err}") + raise click.exceptions.Exit(1) from err + + if payload.get("status") == "error": + console.error(f"Security review failed: {payload.get('error')}") + raise click.exceptions.Exit(1) + + result = payload.get("result") or {} + + if as_json: + console.print(json.dumps(result)) + else: + _print_violations(result) + + if fail_on != "none": + threshold = _SEVERITY_RANK[fail_on] + if any( + _SEVERITY_RANK.get(violation.get("severity", "low"), len(_SEVERITY_ORDER)) + <= threshold + for violation in result.get("violations", []) + ): + raise click.exceptions.Exit(1) diff --git a/tests/units/reflex_cli/utils/test_hosting.py b/tests/units/reflex_cli/utils/test_hosting.py index 488391dd1d6..d6223f3c40a 100644 --- a/tests/units/reflex_cli/utils/test_hosting.py +++ b/tests/units/reflex_cli/utils/test_hosting.py @@ -4,20 +4,27 @@ from unittest.mock import mock_open import click +import httpx import pytest from pytest_mock import MockerFixture, MockFixture from reflex_cli.utils.hosting import ( + AuthenticatedClient, ScaleParams, ScaleType, + SecurityReviewError, authenticated_token, delete_token_from_config, get_authenticated_client, get_existing_access_token, + get_security_review, get_selected_project, normalize_project_id, save_token_to_config, + submit_security_review, ) +_CLIENT = AuthenticatedClient(token="fake-token", validated_data={}) + @pytest.mark.parametrize( "config_content, expected_token", @@ -215,3 +222,119 @@ def test_get_selected_project_normalizes_empty_to_none( ) def test_normalize_project_id(value: object, expected: str | None): assert normalize_project_id(value) == expected + + +def _ok(mocker: MockerFixture, payload: dict | None = None): + """Build a mock 2xx response returning ``payload`` from ``.json()``.""" + response = mocker.Mock() + response.raise_for_status.return_value = None + response.json.return_value = payload or {} + return response + + +def _error(mocker: MockerFixture, status_code: int, detail: str): + """Build a mock response whose ``raise_for_status`` raises with ``detail``.""" + response = mocker.Mock() + response.status_code = status_code + response.json.return_value = {"detail": detail} + response.raise_for_status.side_effect = httpx.HTTPStatusError( + "error", request=mocker.Mock(), response=response + ) + return response + + +def test_submit_security_review_uploads_then_submits(mocker: MockerFixture): + """The three-step flow requests a URL, PUTs the bytes, then submits the key.""" + upload_url = _ok( + mocker, + { + "key": "staging/security-review/u/abc.zip", + "url": "https://bucket.s3/abc?sig=1", + "headers": {"Content-Type": "application/zip"}, + }, + ) + submit = _ok(mocker, {"job_id": "job-1"}) + mock_post = mocker.patch("httpx.post", side_effect=[upload_url, submit]) + mock_put = mocker.patch("httpx.put", return_value=_ok(mocker)) + + assert submit_security_review(b"zip-bytes", _CLIENT) == "job-1" + + # Presigned URL requested with the exact content length. + assert ( + mock_post + .call_args_list[0] + .args[0] + .endswith("/api/v1/agents/security-review/jobs/upload-url") + ) + assert mock_post.call_args_list[0].kwargs["json"] == { + "content_length": len(b"zip-bytes"), + "content_type": "application/zip", + } + # Bytes PUT straight to storage with the returned headers, no auth header. + assert mock_put.call_args.args[0] == "https://bucket.s3/abc?sig=1" + assert mock_put.call_args.kwargs["content"] == b"zip-bytes" + assert mock_put.call_args.kwargs["headers"] == {"Content-Type": "application/zip"} + assert "X-API-TOKEN" not in mock_put.call_args.kwargs["headers"] + # Job submitted by key, not by raw bytes. + assert ( + mock_post + .call_args_list[1] + .args[0] + .endswith("/api/v1/agents/security-review/jobs") + ) + assert mock_post.call_args_list[1].kwargs["json"] == { + "key": "staging/security-review/u/abc.zip" + } + + +def test_submit_security_review_surfaces_server_detail(mocker: MockerFixture): + """A 403 on the URL request surfaces the server's detail verbatim.""" + mocker.patch( + "httpx.post", + return_value=_error(mocker, 403, "This feature requires the Enterprise tier."), + ) + + with pytest.raises( + SecurityReviewError, match="This feature requires the Enterprise tier" + ): + submit_security_review(b"zip-bytes", _CLIENT) + + +def test_submit_security_review_upload_failure(mocker: MockerFixture): + """A storage PUT failure is reported and the job is never submitted.""" + upload_url = _ok(mocker, {"key": "k", "url": "https://bucket.s3/k", "headers": {}}) + mock_post = mocker.patch("httpx.post", side_effect=[upload_url]) + mocker.patch("httpx.put", return_value=_error(mocker, 403, "signature expired")) + + with pytest.raises(SecurityReviewError, match="failed to upload app source"): + submit_security_review(b"zip-bytes", _CLIENT) + + # Only the upload-url call happened; the job submit was never reached. + assert mock_post.call_count == 1 + + +def test_submit_security_review_submit_failure(mocker: MockerFixture): + """A 404 (object not uploaded / not owned) on submit surfaces the detail.""" + upload_url = _ok(mocker, {"key": "k", "url": "https://bucket.s3/k", "headers": {}}) + submit = _error(mocker, 404, "Job not found.") + mocker.patch("httpx.post", side_effect=[upload_url, submit]) + mocker.patch("httpx.put", return_value=_ok(mocker)) + + with pytest.raises(SecurityReviewError, match="Job not found"): + submit_security_review(b"zip-bytes", _CLIENT) + + +def test_get_security_review_returns_payload(mocker: MockerFixture): + """Polling returns the parsed job status payload.""" + response = mocker.Mock() + response.raise_for_status.return_value = None + response.json.return_value = {"job_id": "job-1", "status": "pending"} + mock_get = mocker.patch("httpx.get", return_value=response) + + assert get_security_review("job-1", _CLIENT) == { + "job_id": "job-1", + "status": "pending", + } + assert mock_get.call_args.args[0].endswith( + "/api/v1/agents/security-review/jobs/job-1" + ) diff --git a/tests/units/reflex_cli/v2/test_scan.py b/tests/units/reflex_cli/v2/test_scan.py new file mode 100644 index 00000000000..28777712469 --- /dev/null +++ b/tests/units/reflex_cli/v2/test_scan.py @@ -0,0 +1,296 @@ +from __future__ import annotations + +import io +import json +import zipfile +from pathlib import Path + +from click.testing import CliRunner +from pytest_mock import MockFixture +from reflex_cli.utils import hosting +from reflex_cli.utils.exceptions import NotAuthenticatedError +from reflex_cli.v2.deployments import hosting_cli +from typer.main import Typer, get_command + +hosting_cli = ( + get_command(hosting_cli) if isinstance(hosting_cli, Typer) else hosting_cli +) + +runner = CliRunner() + +_CLIENT = hosting.AuthenticatedClient(token="fake-token", validated_data={"foo": "bar"}) + +_RESULT = { + "summary": "Looks mostly fine.", + "violations": [ + { + "rule_id": "exposed-setter", + "category": "security", + "file_path": "app/state.py", + "line": 12, + "severity": "high", + "snippet": "self.is_admin = value", + "message": "Client can flip auth.", + "recommendation": "Validate server-side.", + } + ], +} + + +def _write_app(directory: Path) -> None: + """Write a minimal reviewable file plus a skipped directory.""" + (directory / "app.py").write_text("import reflex as rx\n") + skipped = directory / ".web" / "nested" + skipped.mkdir(parents=True) + (skipped / "bundle.js").write_text("// build artifact\n") + + +def _mock_auth(mocker: MockFixture) -> None: + mocker.patch( + "reflex_cli.utils.hosting.get_authenticated_client", return_value=_CLIENT + ) + + +def test_scan_success_with_violations(mocker: MockFixture, tmp_path: Path): + """A completed review with findings prints them and exits non-zero by default.""" + _write_app(tmp_path) + _mock_auth(mocker) + mock_submit = mocker.patch( + "reflex_cli.utils.hosting.submit_security_review", return_value="job123" + ) + mock_get = mocker.patch( + "reflex_cli.utils.hosting.get_security_review", + return_value={"job_id": "job123", "status": "complete", "result": _RESULT}, + ) + + result = runner.invoke(hosting_cli, ["scan", str(tmp_path)]) + + assert result.exit_code == 1, result.output + mock_submit.assert_called_once() + assert mock_submit.call_args.kwargs["client"] == _CLIENT + mock_get.assert_called_once_with(job_id="job123", client=_CLIENT) + + +def test_scan_zip_excludes_build_dirs(mocker: MockFixture, tmp_path: Path): + """The uploaded archive contains source but not skipped build directories.""" + _write_app(tmp_path) + _mock_auth(mocker) + mock_submit = mocker.patch( + "reflex_cli.utils.hosting.submit_security_review", return_value="job123" + ) + mocker.patch( + "reflex_cli.utils.hosting.get_security_review", + return_value={"job_id": "job123", "status": "complete", "result": _RESULT}, + ) + + runner.invoke(hosting_cli, ["scan", str(tmp_path)]) + + zip_bytes = mock_submit.call_args.kwargs["zip_bytes"] + with zipfile.ZipFile(io.BytesIO(zip_bytes)) as archive: + names = archive.namelist() + assert "app.py" in names + assert not any(".web" in name for name in names) + + +def test_scan_no_files(mocker: MockFixture, tmp_path: Path): + """An empty project errors out without contacting the server.""" + _mock_auth(mocker) + mock_submit = mocker.patch("reflex_cli.utils.hosting.submit_security_review") + mock_error = mocker.patch("reflex_cli.utils.console.error") + + result = runner.invoke(hosting_cli, ["scan", str(tmp_path)]) + + assert result.exit_code == 1 + mock_submit.assert_not_called() + mock_error.assert_called_once() + + +def test_scan_renders_findings_with_markup_chars(mocker: MockFixture, tmp_path: Path): + """Findings whose text contains brackets render without crashing Rich.""" + _write_app(tmp_path) + _mock_auth(mocker) + mocker.patch( + "reflex_cli.utils.hosting.submit_security_review", return_value="job123" + ) + bracketed = { + "summary": "Check [this] and config[key].", + "violations": [ + { + "rule_id": "exposed-setter", + "category": "security", + "file_path": "app/state[0].py", + "line": 3, + "severity": "critical", + "snippet": "x = data[key]", + "message": "Indexing data[key] is unsafe.", + "recommendation": "Use data.get([key]).", + } + ], + } + mocker.patch( + "reflex_cli.utils.hosting.get_security_review", + return_value={"job_id": "job123", "status": "complete", "result": bracketed}, + ) + + result = runner.invoke(hosting_cli, ["scan", str(tmp_path), "--fail-on", "none"]) + + assert result.exit_code == 0, result.output + assert result.exception is None + + +def test_scan_clean_exits_zero(mocker: MockFixture, tmp_path: Path): + """A review with no violations exits zero.""" + _write_app(tmp_path) + _mock_auth(mocker) + mocker.patch( + "reflex_cli.utils.hosting.submit_security_review", return_value="job123" + ) + mocker.patch( + "reflex_cli.utils.hosting.get_security_review", + return_value={ + "job_id": "job123", + "status": "complete", + "result": {"summary": "All good.", "violations": []}, + }, + ) + + result = runner.invoke(hosting_cli, ["scan", str(tmp_path)]) + + assert result.exit_code == 0, result.output + + +def test_scan_fail_on_none_exits_zero(mocker: MockFixture, tmp_path: Path): + """With --fail-on none, findings are reported but the command exits zero.""" + _write_app(tmp_path) + _mock_auth(mocker) + mocker.patch( + "reflex_cli.utils.hosting.submit_security_review", return_value="job123" + ) + mocker.patch( + "reflex_cli.utils.hosting.get_security_review", + return_value={"job_id": "job123", "status": "complete", "result": _RESULT}, + ) + + result = runner.invoke(hosting_cli, ["scan", str(tmp_path), "--fail-on", "none"]) + + assert result.exit_code == 0, result.output + + +def test_scan_fail_on_critical_ignores_high(mocker: MockFixture, tmp_path: Path): + """--fail-on critical does not trip on a high-severity finding.""" + _write_app(tmp_path) + _mock_auth(mocker) + mocker.patch( + "reflex_cli.utils.hosting.submit_security_review", return_value="job123" + ) + mocker.patch( + "reflex_cli.utils.hosting.get_security_review", + return_value={"job_id": "job123", "status": "complete", "result": _RESULT}, + ) + + result = runner.invoke( + hosting_cli, ["scan", str(tmp_path), "--fail-on", "critical"] + ) + + assert result.exit_code == 0, result.output + + +def test_scan_json_output(mocker: MockFixture, tmp_path: Path): + """--json prints the raw result payload.""" + _write_app(tmp_path) + _mock_auth(mocker) + mocker.patch( + "reflex_cli.utils.hosting.submit_security_review", return_value="job123" + ) + mocker.patch( + "reflex_cli.utils.hosting.get_security_review", + return_value={"job_id": "job123", "status": "complete", "result": _RESULT}, + ) + mock_print = mocker.patch("reflex_cli.utils.console.print") + + result = runner.invoke( + hosting_cli, ["scan", str(tmp_path), "--json", "--fail-on", "none"] + ) + + assert result.exit_code == 0, result.output + mock_print.assert_called_once_with(json.dumps(_RESULT)) + + +def test_scan_polls_until_complete(mocker: MockFixture, tmp_path: Path): + """Pending statuses are polled until the review finishes.""" + _write_app(tmp_path) + _mock_auth(mocker) + mocker.patch( + "reflex_cli.utils.hosting.submit_security_review", return_value="job123" + ) + mock_get = mocker.patch( + "reflex_cli.utils.hosting.get_security_review", + side_effect=[ + {"job_id": "job123", "status": "pending"}, + {"job_id": "job123", "status": "complete", "result": _RESULT}, + ], + ) + mocker.patch("reflex_cli.v2.scan.time.sleep") + + result = runner.invoke(hosting_cli, ["scan", str(tmp_path), "--fail-on", "none"]) + + assert result.exit_code == 0, result.output + assert mock_get.call_count == 2 + + +def test_scan_server_error(mocker: MockFixture, tmp_path: Path): + """An errored job surfaces the server error and exits non-zero.""" + _write_app(tmp_path) + _mock_auth(mocker) + mocker.patch( + "reflex_cli.utils.hosting.submit_security_review", return_value="job123" + ) + mocker.patch( + "reflex_cli.utils.hosting.get_security_review", + return_value={ + "job_id": "job123", + "status": "error", + "error": "Security review failed.", + }, + ) + mock_error = mocker.patch("reflex_cli.utils.console.error") + + result = runner.invoke(hosting_cli, ["scan", str(tmp_path)]) + + assert result.exit_code == 1 + mock_error.assert_called_once_with( + "Security review failed: Security review failed." + ) + + +def test_scan_submit_error(mocker: MockFixture, tmp_path: Path): + """A SecurityReviewError on submit surfaces the server detail verbatim.""" + _write_app(tmp_path) + _mock_auth(mocker) + mocker.patch( + "reflex_cli.utils.hosting.submit_security_review", + side_effect=hosting.SecurityReviewError("server says no"), + ) + mock_error = mocker.patch("reflex_cli.utils.console.error") + + result = runner.invoke(hosting_cli, ["scan", str(tmp_path)]) + + assert result.exit_code == 1 + mock_error.assert_called_once_with("Security review failed: server says no") + + +def test_scan_not_authenticated(mocker: MockFixture, tmp_path: Path): + """An unauthenticated client produces the standard login prompt.""" + _write_app(tmp_path) + mocker.patch( + "reflex_cli.utils.hosting.get_authenticated_client", + side_effect=NotAuthenticatedError("not authenticated"), + ) + mock_error = mocker.patch("reflex_cli.utils.console.error") + + result = runner.invoke(hosting_cli, ["scan", str(tmp_path)]) + + assert result.exit_code == 1 + mock_error.assert_called_once_with( + "You are not authenticated. Run `reflex login` to authenticate." + )