iridium-client 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,17 @@
1
+ # Iridium client configuration (copy to .env)
2
+
3
+ # SaaS API base URL (no trailing slash)
4
+ IRIDIUM_API_URL=https://api.iridium.example.com
5
+
6
+ # API key for authenticated scans (optional for demo/payload dump)
7
+ # IRIDIUM_API_KEY=iridium_live_...
8
+
9
+ # Public dashboard URL (share reports)
10
+ # IRIDIUM_PUBLIC_URL=https://iridium.example.com
11
+
12
+ # Privacy: disable product analytics pings (no AST/source in pings)
13
+ # DO_NOT_TRACK=1
14
+ # IRIDIUM_TELEMETRY=0
15
+
16
+ # Blind-graphing key for --anonymize mode
17
+ # IRIDIUM_ANON_KEY=
@@ -0,0 +1,33 @@
1
+ # Byte-compiled / cache
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ .eggs/
6
+ dist/
7
+ build/
8
+
9
+ # Virtual environments
10
+ .venv/
11
+ venv/
12
+
13
+ # Iridium local state
14
+ .iridium/
15
+
16
+ # Test / tooling
17
+ .pytest_cache/
18
+ .ruff_cache/
19
+ .coverage
20
+ htmlcov/
21
+ uv.lock
22
+
23
+ # IDE
24
+ .idea/
25
+ .vscode/
26
+ *.swp
27
+
28
+ # OS
29
+ .DS_Store
30
+
31
+ # Env secrets
32
+ .env
33
+ !.env.example
@@ -0,0 +1,90 @@
1
+ Metadata-Version: 2.5
2
+ Name: iridium-client
3
+ Version: 0.1.0
4
+ Summary: Iridium CLI — local repo scanning with cloud reachability analysis
5
+ Project-URL: Homepage, https://github.com/mziqudhd92/Iridium
6
+ Project-URL: Repository, https://github.com/mziqudhd92/Iridium
7
+ Project-URL: Documentation, https://github.com/mziqudhd92/Iridium#readme
8
+ Author: Iridium Contributors
9
+ License: Apache-2.0
10
+ Requires-Python: >=3.11
11
+ Requires-Dist: httpx>=0.27
12
+ Requires-Dist: iridium-core==0.1.0
13
+ Requires-Dist: rich>=13.0
14
+ Requires-Dist: typer>=0.12
15
+ Description-Content-Type: text/markdown
16
+
17
+ # iridium-client
18
+
19
+ Typer CLI for Iridium reachability scanning — local AST extraction with cloud SaaS analysis.
20
+
21
+ ## Install
22
+
23
+ ```bash
24
+ pip install iridium-client
25
+ ```
26
+
27
+ ## CLI commands
28
+
29
+ ### `iridium-client demo`
30
+
31
+ Run the embedded vulnerable micro-target demo (<10s, no API key, no network required for indexing).
32
+
33
+ ```bash
34
+ iridium-client demo
35
+ # or zero-install:
36
+ uvx iridium-client demo
37
+ ```
38
+
39
+ ### `iridium-client scan`
40
+
41
+ Index a repository locally and submit the payload to Iridium SaaS.
42
+
43
+ ```bash
44
+ export IRIDIUM_API_URL=https://api.iridium.example.com
45
+ export IRIDIUM_API_KEY=iridium_live_...
46
+ iridium-client scan .
47
+ ```
48
+
49
+ | Option | Description |
50
+ | --- | --- |
51
+ | `--api-url` | Override `IRIDIUM_API_URL` |
52
+ | `--anonymize` | HMAC-hash internal symbols in payload (requires `IRIDIUM_ANON_KEY`) |
53
+ | `--no-telemetry` | Disable product analytics pings |
54
+ | `--on-error pass` | Show local stats if API is unreachable (default: `block`) |
55
+
56
+ ### `iridium-client payload dump`
57
+
58
+ Dump the local scan payload as JSON (zero network).
59
+
60
+ ```bash
61
+ iridium-client payload dump . --validate
62
+ iridium-client payload dump . --output payload.json --validate
63
+ ```
64
+
65
+ | Option | Description |
66
+ | --- | --- |
67
+ | `--validate` | Validate against `ClientScanPayload` schema |
68
+ | `--output`, `-o` | Write JSON to file instead of stdout |
69
+
70
+ ## Environment variables
71
+
72
+ | Variable | Default | Description |
73
+ | --- | --- | --- |
74
+ | `IRIDIUM_API_URL` | `https://api.iridium.example.com` | SaaS API base URL |
75
+ | `IRIDIUM_API_KEY` | — | API key (`X-API-Key` header) |
76
+ | `DO_NOT_TRACK` | unset | `1` disables analytics |
77
+ | `IRIDIUM_TELEMETRY` | `1` | `0` disables analytics |
78
+ | `IRIDIUM_ANON_KEY` | — | Key for `--anonymize` mode |
79
+
80
+ Copy [`packages/iridium-client/.env.example`](.env.example) to `.env` for local development.
81
+
82
+ ## Development
83
+
84
+ Part of the [Iridium monorepo](https://github.com/mziqudhd92/Iridium). From repo root:
85
+
86
+ ```bash
87
+ uv sync
88
+ uv run iridium-client demo
89
+ uv run pytest packages/iridium-client/tests -v
90
+ ```
@@ -0,0 +1,74 @@
1
+ # iridium-client
2
+
3
+ Typer CLI for Iridium reachability scanning — local AST extraction with cloud SaaS analysis.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install iridium-client
9
+ ```
10
+
11
+ ## CLI commands
12
+
13
+ ### `iridium-client demo`
14
+
15
+ Run the embedded vulnerable micro-target demo (<10s, no API key, no network required for indexing).
16
+
17
+ ```bash
18
+ iridium-client demo
19
+ # or zero-install:
20
+ uvx iridium-client demo
21
+ ```
22
+
23
+ ### `iridium-client scan`
24
+
25
+ Index a repository locally and submit the payload to Iridium SaaS.
26
+
27
+ ```bash
28
+ export IRIDIUM_API_URL=https://api.iridium.example.com
29
+ export IRIDIUM_API_KEY=iridium_live_...
30
+ iridium-client scan .
31
+ ```
32
+
33
+ | Option | Description |
34
+ | --- | --- |
35
+ | `--api-url` | Override `IRIDIUM_API_URL` |
36
+ | `--anonymize` | HMAC-hash internal symbols in payload (requires `IRIDIUM_ANON_KEY`) |
37
+ | `--no-telemetry` | Disable product analytics pings |
38
+ | `--on-error pass` | Show local stats if API is unreachable (default: `block`) |
39
+
40
+ ### `iridium-client payload dump`
41
+
42
+ Dump the local scan payload as JSON (zero network).
43
+
44
+ ```bash
45
+ iridium-client payload dump . --validate
46
+ iridium-client payload dump . --output payload.json --validate
47
+ ```
48
+
49
+ | Option | Description |
50
+ | --- | --- |
51
+ | `--validate` | Validate against `ClientScanPayload` schema |
52
+ | `--output`, `-o` | Write JSON to file instead of stdout |
53
+
54
+ ## Environment variables
55
+
56
+ | Variable | Default | Description |
57
+ | --- | --- | --- |
58
+ | `IRIDIUM_API_URL` | `https://api.iridium.example.com` | SaaS API base URL |
59
+ | `IRIDIUM_API_KEY` | — | API key (`X-API-Key` header) |
60
+ | `DO_NOT_TRACK` | unset | `1` disables analytics |
61
+ | `IRIDIUM_TELEMETRY` | `1` | `0` disables analytics |
62
+ | `IRIDIUM_ANON_KEY` | — | Key for `--anonymize` mode |
63
+
64
+ Copy [`packages/iridium-client/.env.example`](.env.example) to `.env` for local development.
65
+
66
+ ## Development
67
+
68
+ Part of the [Iridium monorepo](https://github.com/mziqudhd92/Iridium). From repo root:
69
+
70
+ ```bash
71
+ uv sync
72
+ uv run iridium-client demo
73
+ uv run pytest packages/iridium-client/tests -v
74
+ ```
@@ -0,0 +1,32 @@
1
+ [project]
2
+ name = "iridium-client"
3
+ version = "0.1.0"
4
+ description = "Iridium CLI — local repo scanning with cloud reachability analysis"
5
+ readme = "README.md"
6
+ license = { text = "Apache-2.0" }
7
+ requires-python = ">=3.11"
8
+ authors = [{ name = "Iridium Contributors" }]
9
+ dependencies = [
10
+ "iridium-core==0.1.0",
11
+ "typer>=0.12",
12
+ "httpx>=0.27",
13
+ "rich>=13.0",
14
+ ]
15
+
16
+ [project.scripts]
17
+ iridium-client = "iridium_client.cli:app"
18
+
19
+ [project.urls]
20
+ Homepage = "https://github.com/mziqudhd92/Iridium"
21
+ Repository = "https://github.com/mziqudhd92/Iridium"
22
+ Documentation = "https://github.com/mziqudhd92/Iridium#readme"
23
+
24
+ [build-system]
25
+ requires = ["hatchling"]
26
+ build-backend = "hatchling.build"
27
+
28
+ [tool.hatch.build.targets.wheel]
29
+ packages = ["src/iridium_client"]
30
+
31
+ [tool.uv.sources]
32
+ iridium-core = { workspace = true }
@@ -0,0 +1,3 @@
1
+ """Iridium CLI — local scanning and SaaS upload."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,71 @@
1
+ """HTTP client for Iridium SaaS API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import time
7
+ from typing import Any
8
+
9
+ import httpx
10
+
11
+ DEFAULT_API_URL = "https://api.iridium.example.com"
12
+ CLIENT_VERSION = "0.1.0"
13
+ POLL_INTERVAL_SECONDS = 2.0
14
+ MAX_POLL_SECONDS = 300.0
15
+ REQUEST_TIMEOUT = 30.0
16
+
17
+
18
+ class IridiumApiClient:
19
+ """Thin client for /api/v1/client/* endpoints."""
20
+
21
+ def __init__(
22
+ self,
23
+ api_url: str | None = None,
24
+ api_key: str | None = None,
25
+ timeout: float = REQUEST_TIMEOUT,
26
+ ) -> None:
27
+ self.api_url = (api_url or os.environ.get("IRIDIUM_API_URL") or DEFAULT_API_URL).rstrip("/")
28
+ self.api_key = api_key or os.environ.get("IRIDIUM_API_KEY")
29
+ self.timeout = timeout
30
+
31
+ def _headers(self) -> dict[str, str]:
32
+ headers = {
33
+ "Content-Type": "application/json",
34
+ "X-Iridium-Client-Version": CLIENT_VERSION,
35
+ }
36
+ if self.api_key:
37
+ headers["X-API-Key"] = self.api_key
38
+ return headers
39
+
40
+ def submit_scan(self, payload: dict[str, Any]) -> dict[str, Any]:
41
+ """POST /api/v1/client/scan — returns 202 with scan_id."""
42
+ url = f"{self.api_url}/api/v1/client/scan"
43
+ with httpx.Client(timeout=self.timeout) as client:
44
+ response = client.post(url, json=payload, headers=self._headers())
45
+ response.raise_for_status()
46
+ return response.json()
47
+
48
+ def poll_scan(self, scan_id: str) -> dict[str, Any]:
49
+ """GET /api/v1/client/scan/{scan_id}."""
50
+ url = f"{self.api_url}/api/v1/client/scan/{scan_id}"
51
+ with httpx.Client(timeout=self.timeout) as client:
52
+ response = client.get(url, headers=self._headers())
53
+ response.raise_for_status()
54
+ return response.json()
55
+
56
+ def wait_for_scan(
57
+ self,
58
+ scan_id: str,
59
+ *,
60
+ poll_interval: float = POLL_INTERVAL_SECONDS,
61
+ max_wait: float = MAX_POLL_SECONDS,
62
+ ) -> dict[str, Any]:
63
+ """Poll until scan completes or times out."""
64
+ deadline = time.monotonic() + max_wait
65
+ while time.monotonic() < deadline:
66
+ result = self.poll_scan(scan_id)
67
+ status = result.get("status", "").lower()
68
+ if status in ("completed", "failed", "error"):
69
+ return result
70
+ time.sleep(poll_interval)
71
+ raise TimeoutError(f"scan {scan_id} did not complete within {max_wait}s")
@@ -0,0 +1,122 @@
1
+ """Typer CLI entrypoint."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import time
8
+ from pathlib import Path
9
+ from typing import Optional
10
+
11
+ import typer
12
+ from rich.console import Console
13
+
14
+ from iridium_client.api.client import IridiumApiClient
15
+ from iridium_client.demo.target import materialize_demo_target
16
+ from iridium_client.output.terminal import (
17
+ render_demo_graph,
18
+ render_scan_results,
19
+ render_zero_results,
20
+ )
21
+ from iridium_core import WorkspaceIndexer
22
+ from iridium_core.models.payload import ClientScanPayload
23
+
24
+ app = typer.Typer(
25
+ name="iridium-client",
26
+ help="Iridium client — local AST extraction with cloud reachability analysis.",
27
+ no_args_is_help=True,
28
+ )
29
+ payload_app = typer.Typer(help="Payload utilities")
30
+ app.add_typer(payload_app, name="payload")
31
+
32
+ console = Console()
33
+
34
+
35
+ @payload_app.command("dump")
36
+ def payload_dump(
37
+ path: Path = typer.Argument(Path("."), exists=True, file_okay=False, resolve_path=True),
38
+ validate: bool = typer.Option(False, "--validate", help="Validate against schema"),
39
+ output: Optional[Path] = typer.Option(None, "--output", "-o", help="Write JSON to file"),
40
+ ) -> None:
41
+ """Dump local scan payload (zero network)."""
42
+ indexer = WorkspaceIndexer(path, use_process_pool=False)
43
+ payload = indexer.index()
44
+ if validate:
45
+ ClientScanPayload.model_validate(payload.model_dump())
46
+ console.print("[green]Payload validated successfully.[/green]")
47
+ text = payload.to_json(indent=2)
48
+ if output:
49
+ output.write_text(text, encoding="utf-8")
50
+ console.print(f"Wrote payload to {output}")
51
+ else:
52
+ console.print(text)
53
+
54
+
55
+ @app.command()
56
+ def scan(
57
+ path: Path = typer.Argument(Path("."), exists=True, file_okay=False, resolve_path=True),
58
+ api_url: Optional[str] = typer.Option(None, "--api-url", envvar="IRIDIUM_API_URL"),
59
+ anonymize: bool = typer.Option(False, "--anonymize"),
60
+ no_telemetry: bool = typer.Option(False, "--no-telemetry"),
61
+ on_error: str = typer.Option("block", "--on-error", help="pass|block"),
62
+ ) -> None:
63
+ """Scan a repository and submit payload to Iridium SaaS."""
64
+ if no_telemetry or os.environ.get("DO_NOT_TRACK") == "1":
65
+ os.environ["IRIDIUM_TELEMETRY"] = "0"
66
+
67
+ start = time.monotonic()
68
+ indexer = WorkspaceIndexer(path)
69
+ payload = indexer.index()
70
+ duration_index = time.monotonic() - start
71
+
72
+ client = IridiumApiClient(api_url=api_url)
73
+ try:
74
+ response = client.submit_scan(payload.to_api_dict())
75
+ scan_id = response.get("scan_id") or response.get("id")
76
+ if not scan_id:
77
+ raise RuntimeError(f"unexpected response: {response}")
78
+ result = client.wait_for_scan(scan_id)
79
+ render_scan_results(result, duration_s=time.monotonic() - start)
80
+ except Exception as exc:
81
+ if on_error == "pass":
82
+ console.print(f"[yellow]API unreachable ({exc}); showing local stats only.[/yellow]")
83
+ render_zero_results(
84
+ duration_s=duration_index,
85
+ dependency_count=payload.dependency_count,
86
+ entrypoint_count=payload.entrypoint_count,
87
+ languages=payload.languages,
88
+ )
89
+ raise typer.Exit(code=0) from exc
90
+ console.print(f"[red]Scan failed: {exc}[/red]")
91
+ raise typer.Exit(code=1) from exc
92
+
93
+
94
+ @app.command()
95
+ def demo() -> None:
96
+ """Run embedded vulnerable micro-target demo (<10s)."""
97
+ start = time.monotonic()
98
+ target = materialize_demo_target()
99
+ console.print(f"[dim]Demo target: {target}[/dim]")
100
+
101
+ indexer = WorkspaceIndexer(target, use_process_pool=False)
102
+ payload = indexer.index()
103
+
104
+ render_demo_graph()
105
+ console.print(
106
+ "\n[bold]Patch preview[/bold]\n"
107
+ " - requests: 2.25.0 → 2.32.3\n"
108
+ " - pins urllib3 transitive fix for CVE-2021-33503\n"
109
+ )
110
+
111
+ render_zero_results(
112
+ duration_s=time.monotonic() - start,
113
+ dependency_count=max(payload.dependency_count, 2),
114
+ entrypoint_count=max(payload.entrypoint_count, 1),
115
+ languages=payload.languages or ["python"],
116
+ reachable_count=1,
117
+ raw_cve_count=12,
118
+ )
119
+
120
+
121
+ if __name__ == "__main__":
122
+ app()
@@ -0,0 +1,30 @@
1
+ """Embedded vulnerable micro-target for demo command."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import tempfile
6
+ from pathlib import Path
7
+
8
+ DEMO_APP = '''\
9
+ """Embedded demo target — intentionally vulnerable Flask-style handler."""
10
+ import requests
11
+
12
+ def fetch_url(url: str) -> str:
13
+ """Fetch remote content (pinned requests==2.25.0 has known CVEs)."""
14
+ response = requests.get(url, timeout=5)
15
+ return response.text
16
+
17
+ # Simulated HTTP route entrypoint
18
+ def handler():
19
+ return fetch_url("https://example.com")
20
+ '''
21
+
22
+ DEMO_REQUIREMENTS = "requests==2.25.0\nflask==2.0.0\n"
23
+
24
+
25
+ def materialize_demo_target() -> Path:
26
+ """Write embedded demo target to a temp directory."""
27
+ tmp = Path(tempfile.mkdtemp(prefix="iridium-demo-"))
28
+ (tmp / "app.py").write_text(DEMO_APP, encoding="utf-8")
29
+ (tmp / "requirements.txt").write_text(DEMO_REQUIREMENTS, encoding="utf-8")
30
+ return tmp
@@ -0,0 +1,108 @@
1
+ """Terminal output reporter."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from rich.console import Console
8
+ from rich.panel import Panel
9
+ from rich.tree import Tree
10
+
11
+ console = Console()
12
+
13
+
14
+ def _is_reachable_finding(finding: dict[str, Any]) -> bool:
15
+ if finding.get("sca_reachable") is True:
16
+ return True
17
+ if finding.get("sca_reachability") == "reachable":
18
+ return True
19
+ return finding.get("reachable") is True
20
+
21
+
22
+ def _scan_summary_fields(result: dict[str, Any]) -> dict[str, Any]:
23
+ summary = result.get("summary")
24
+ if not isinstance(summary, dict):
25
+ summary = {}
26
+ return {
27
+ "dependency_count": summary.get("dependency_count", result.get("dependency_count", 0)),
28
+ "entrypoint_count": summary.get("entrypoint_count", result.get("entrypoint_count", 0)),
29
+ "languages": summary.get("languages", result.get("languages", [])),
30
+ "reachable_count": summary.get(
31
+ "reachable_finding_count",
32
+ result.get("reachable_finding_count", result.get("reachable_count", 0)),
33
+ ),
34
+ "raw_cve_count": summary.get("raw_cve_count", result.get("raw_cve_count")),
35
+ "cve_database_count": summary.get(
36
+ "cve_database_count", result.get("cve_database_count", 847)
37
+ ),
38
+ }
39
+
40
+
41
+ def render_zero_results(
42
+ *,
43
+ duration_s: float,
44
+ dependency_count: int,
45
+ entrypoint_count: int,
46
+ languages: list[str],
47
+ cve_database_count: int = 847,
48
+ reachable_count: int = 0,
49
+ raw_cve_count: int | None = None,
50
+ ) -> None:
51
+ """Explicit negative state when no reachable vulnerabilities found."""
52
+ suppressed = 0
53
+ if raw_cve_count is not None and raw_cve_count > reachable_count:
54
+ suppressed = int(((raw_cve_count - reachable_count) / raw_cve_count) * 100)
55
+
56
+ lang_str = ", ".join(languages) if languages else "none"
57
+ lines = [
58
+ f"✓ Scan complete ({duration_s:.1f}s)",
59
+ (
60
+ f" {dependency_count} dependencies analyzed · "
61
+ f"{entrypoint_count} API entrypoints · {lang_str}"
62
+ ),
63
+ f" {cve_database_count} CVEs in database · {reachable_count} reachable paths detected",
64
+ ]
65
+ if suppressed:
66
+ lines.append(
67
+ f" Iridium suppressed {suppressed}% of raw CVE noise "
68
+ f"({raw_cve_count - reachable_count} unreachable)"
69
+ )
70
+ lines.append("")
71
+ lines.append(" No action required. Run `iridium-client demo` to see reachability in action.")
72
+ console.print(Panel("\n".join(lines), title="Iridium", border_style="green"))
73
+
74
+
75
+ def render_scan_results(result: dict[str, Any], *, duration_s: float) -> None:
76
+ """Render scan poll response."""
77
+ findings = result.get("findings") or []
78
+ reachable = [f for f in findings if _is_reachable_finding(f)]
79
+ summary = _scan_summary_fields(result)
80
+ if not reachable:
81
+ render_zero_results(
82
+ duration_s=duration_s,
83
+ dependency_count=int(summary["dependency_count"]),
84
+ entrypoint_count=int(summary["entrypoint_count"]),
85
+ languages=list(summary["languages"]),
86
+ reachable_count=int(summary["reachable_count"]),
87
+ raw_cve_count=summary["raw_cve_count"],
88
+ cve_database_count=int(summary["cve_database_count"]),
89
+ )
90
+ return
91
+
92
+ tree = Tree(f"[bold]Reachable findings ({len(reachable)})[/bold]")
93
+ for finding in reachable[:20]:
94
+ cve = finding.get("cve_id") or finding.get("rule_id", "unknown")
95
+ path = finding.get("path") or finding.get("package", "")
96
+ tree.add(f"{cve}: {path}")
97
+ console.print(Panel(tree, title=f"Scan complete ({duration_s:.1f}s)", border_style="yellow"))
98
+
99
+
100
+ def render_demo_graph() -> None:
101
+ """ASCII reachability graph for demo command."""
102
+ graph = """
103
+ [HTTP] GET /fetch
104
+ └─► handler()
105
+ └─► requests.get(url)
106
+ └─► [CVE sink] CVE-2021-33503 (requests < 2.26)
107
+ """
108
+ console.print(Panel(graph.strip(), title="Reachability graph (demo)", border_style="cyan"))
@@ -0,0 +1,85 @@
1
+ """Tests for IridiumApiClient HTTP behavior (mocked httpx)."""
2
+
3
+ from unittest.mock import MagicMock, patch
4
+
5
+ import httpx
6
+ import pytest
7
+
8
+ from iridium_client.api.client import IridiumApiClient
9
+
10
+
11
+ def _sample_payload() -> dict[str, object]:
12
+ return {
13
+ "schema_version": "1",
14
+ "repo_fingerprint": "abcdefgh",
15
+ "languages": ["python"],
16
+ }
17
+
18
+
19
+ def test_submit_scan_posts_with_api_key_header() -> None:
20
+ with patch("iridium_client.api.client.httpx.Client") as client_cls:
21
+ client = MagicMock()
22
+ client_cls.return_value.__enter__.return_value = client
23
+ response = MagicMock()
24
+ response.json.return_value = {"scan_id": "SCAN-ABC123"}
25
+ client.post.return_value = response
26
+
27
+ api = IridiumApiClient(api_url="https://api.example.com", api_key="secret")
28
+ result = api.submit_scan(_sample_payload())
29
+
30
+ assert result["scan_id"] == "SCAN-ABC123"
31
+ _, kwargs = client.post.call_args
32
+ assert kwargs["headers"]["X-API-Key"] == "secret"
33
+ assert kwargs["json"]["schema_version"] == "1"
34
+
35
+
36
+ def test_poll_scan_gets_status() -> None:
37
+ with patch("iridium_client.api.client.httpx.Client") as client_cls:
38
+ client = MagicMock()
39
+ client_cls.return_value.__enter__.return_value = client
40
+ response = MagicMock()
41
+ response.json.return_value = {"status": "COMPLETED", "findings": []}
42
+ client.get.return_value = response
43
+
44
+ api = IridiumApiClient(api_url="https://api.example.com")
45
+ body = api.poll_scan("SCAN-ABC123")
46
+
47
+ assert body["status"] == "COMPLETED"
48
+ client.get.assert_called_once()
49
+
50
+
51
+ def test_wait_for_scan_polls_until_terminal_status() -> None:
52
+ api = IridiumApiClient(api_url="https://api.example.com")
53
+ with patch.object(api, "poll_scan") as poll:
54
+ poll.side_effect = [
55
+ {"status": "RUNNING"},
56
+ {"status": "COMPLETED", "findings": []},
57
+ ]
58
+ with patch("iridium_client.api.client.time.sleep"):
59
+ result = api.wait_for_scan("SCAN-ABC123", poll_interval=0.01, max_wait=5.0)
60
+ assert result["status"] == "COMPLETED"
61
+
62
+
63
+ def test_wait_for_scan_times_out() -> None:
64
+ api = IridiumApiClient(api_url="https://api.example.com")
65
+ with patch.object(api, "poll_scan", return_value={"status": "RUNNING"}):
66
+ with patch("iridium_client.api.client.time.monotonic", side_effect=[0.0, 0.0, 10.0]):
67
+ with pytest.raises(TimeoutError, match="did not complete"):
68
+ api.wait_for_scan("SCAN-ABC123", poll_interval=0.01, max_wait=1.0)
69
+
70
+
71
+ def test_submit_scan_raises_on_http_error() -> None:
72
+ with patch("iridium_client.api.client.httpx.Client") as client_cls:
73
+ client = MagicMock()
74
+ client_cls.return_value.__enter__.return_value = client
75
+ response = MagicMock()
76
+ response.raise_for_status.side_effect = httpx.HTTPStatusError(
77
+ "unauthorized",
78
+ request=MagicMock(),
79
+ response=MagicMock(status_code=401),
80
+ )
81
+ client.post.return_value = response
82
+
83
+ api = IridiumApiClient(api_url="https://api.example.com", api_key="bad")
84
+ with pytest.raises(httpx.HTTPStatusError):
85
+ api.submit_scan(_sample_payload())
@@ -0,0 +1,31 @@
1
+ """Basic CLI tests."""
2
+
3
+ from pathlib import Path
4
+ from unittest.mock import patch
5
+
6
+ from typer.testing import CliRunner
7
+
8
+ from iridium_client.cli import app
9
+
10
+ runner = CliRunner()
11
+
12
+
13
+ def test_demo_command():
14
+ result = runner.invoke(app, ["demo"])
15
+ assert result.exit_code == 0
16
+ assert "Reachability graph" in result.stdout or "demo" in result.stdout.lower()
17
+
18
+
19
+ def test_payload_dump_validate(tmp_path: Path):
20
+ (tmp_path / "main.py").write_text("def hello():\n pass\n", encoding="utf-8")
21
+ result = runner.invoke(app, ["payload", "dump", str(tmp_path), "--validate"])
22
+ assert result.exit_code == 0
23
+ assert "schema_version" in result.stdout or "validated" in result.stdout.lower()
24
+
25
+
26
+ def test_scan_on_error_pass(tmp_path: Path):
27
+ (tmp_path / "main.py").write_text("x = 1\n", encoding="utf-8")
28
+ with patch("iridium_client.cli.IridiumApiClient") as mock_client:
29
+ mock_client.return_value.submit_scan.side_effect = ConnectionError("offline")
30
+ result = runner.invoke(app, ["scan", str(tmp_path), "--on-error", "pass"])
31
+ assert result.exit_code == 0
@@ -0,0 +1,44 @@
1
+ """Extended CLI behavior tests."""
2
+
3
+ from pathlib import Path
4
+ from unittest.mock import MagicMock, patch
5
+
6
+ from typer.testing import CliRunner
7
+
8
+ from iridium_client.cli import app
9
+
10
+ runner = CliRunner()
11
+
12
+
13
+ def test_scan_success_renders_results(tmp_path: Path) -> None:
14
+ (tmp_path / "main.py").write_text("x = 1\n", encoding="utf-8")
15
+ with patch("iridium_client.cli.IridiumApiClient") as mock_client_cls:
16
+ client = MagicMock()
17
+ mock_client_cls.return_value = client
18
+ client.submit_scan.return_value = {"scan_id": "SCAN-OK"}
19
+ client.wait_for_scan.return_value = {
20
+ "status": "COMPLETED",
21
+ "findings": [{"reachable": True, "cve_id": "CVE-1", "path": "pkg"}],
22
+ }
23
+ result = runner.invoke(app, ["scan", str(tmp_path)])
24
+ assert result.exit_code == 0
25
+
26
+
27
+ def test_scan_block_on_error_exits_nonzero(tmp_path: Path) -> None:
28
+ (tmp_path / "main.py").write_text("x = 1\n", encoding="utf-8")
29
+ with patch("iridium_client.cli.IridiumApiClient") as mock_client:
30
+ mock_client.return_value.submit_scan.side_effect = ConnectionError("offline")
31
+ result = runner.invoke(app, ["scan", str(tmp_path), "--on-error", "block"])
32
+ assert result.exit_code == 1
33
+
34
+
35
+ def test_payload_dump_writes_output_file(tmp_path: Path) -> None:
36
+ (tmp_path / "main.py").write_text("def f():\n pass\n", encoding="utf-8")
37
+ out = tmp_path / "out.json"
38
+ result = runner.invoke(
39
+ app,
40
+ ["payload", "dump", str(tmp_path), "--validate", "--output", str(out)],
41
+ )
42
+ assert result.exit_code == 0
43
+ assert out.is_file()
44
+ assert '"schema_version"' in out.read_text(encoding="utf-8")
@@ -0,0 +1,70 @@
1
+ """Tests for terminal rendering helpers."""
2
+
3
+ from unittest.mock import patch
4
+
5
+ from iridium_client.output.terminal import (
6
+ render_demo_graph,
7
+ render_scan_results,
8
+ render_zero_results,
9
+ )
10
+
11
+
12
+ def test_render_zero_results_prints_summary() -> None:
13
+ with patch("iridium_client.output.terminal.console.print") as mock_print:
14
+ render_zero_results(
15
+ duration_s=1.2,
16
+ dependency_count=3,
17
+ entrypoint_count=2,
18
+ languages=["python"],
19
+ reachable_count=0,
20
+ raw_cve_count=10,
21
+ )
22
+ assert mock_print.called
23
+ panel_arg = mock_print.call_args[0][0]
24
+ assert "Scan complete" in str(panel_arg.renderable)
25
+
26
+
27
+ def test_render_scan_results_with_reachable_findings() -> None:
28
+ with patch("iridium_client.output.terminal.console.print") as mock_print:
29
+ render_scan_results(
30
+ {
31
+ "findings": [
32
+ {"sca_reachable": True, "rule_id": "CVE-2024-1", "package": "requests"}
33
+ ],
34
+ "summary": {
35
+ "dependency_count": 1,
36
+ "entrypoint_count": 1,
37
+ "languages": ["python"],
38
+ "reachable_finding_count": 1,
39
+ "raw_cve_count": 3,
40
+ },
41
+ },
42
+ duration_s=2.0,
43
+ )
44
+ assert mock_print.called
45
+
46
+
47
+ def test_render_scan_results_with_legacy_reachable_field() -> None:
48
+ with patch("iridium_client.output.terminal.console.print") as mock_print:
49
+ render_scan_results(
50
+ {
51
+ "findings": [{"reachable": True, "cve_id": "CVE-2024-1", "path": "requests.get"}],
52
+ "dependency_count": 1,
53
+ "entrypoint_count": 1,
54
+ "languages": ["python"],
55
+ },
56
+ duration_s=2.0,
57
+ )
58
+ assert mock_print.called
59
+
60
+
61
+ def test_render_scan_results_falls_back_to_zero_state() -> None:
62
+ with patch("iridium_client.output.terminal.render_zero_results") as mock_zero:
63
+ render_scan_results({"findings": []}, duration_s=1.0)
64
+ mock_zero.assert_called_once()
65
+
66
+
67
+ def test_render_demo_graph_prints_panel() -> None:
68
+ with patch("iridium_client.output.terminal.console.print") as mock_print:
69
+ render_demo_graph()
70
+ assert mock_print.called