slopguard-cli 0.2.0__tar.gz → 0.3.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.
Files changed (50) hide show
  1. {slopguard_cli-0.2.0 → slopguard_cli-0.3.0}/PKG-INFO +8 -7
  2. {slopguard_cli-0.2.0 → slopguard_cli-0.3.0}/README.md +7 -6
  3. {slopguard_cli-0.2.0 → slopguard_cli-0.3.0}/pyproject.toml +1 -1
  4. {slopguard_cli-0.2.0 → slopguard_cli-0.3.0}/slopguard/__init__.py +1 -1
  5. {slopguard_cli-0.2.0 → slopguard_cli-0.3.0}/slopguard/cli.py +4 -179
  6. {slopguard_cli-0.2.0 → slopguard_cli-0.3.0}/slopguard/update.py +7 -13
  7. slopguard_cli-0.3.0/tests/conftest.py +22 -0
  8. {slopguard_cli-0.2.0 → slopguard_cli-0.3.0}/tests/test_cli.py +2 -2
  9. {slopguard_cli-0.2.0 → slopguard_cli-0.3.0}/tests/test_misc.py +2 -2
  10. {slopguard_cli-0.2.0 → slopguard_cli-0.3.0}/tests/test_update.py +8 -20
  11. slopguard_cli-0.2.0/slopguard/saas/__init__.py +0 -26
  12. slopguard_cli-0.2.0/slopguard/saas/client.py +0 -114
  13. slopguard_cli-0.2.0/slopguard/saas/credentials.py +0 -118
  14. slopguard_cli-0.2.0/tests/conftest.py +0 -12
  15. slopguard_cli-0.2.0/tests/test_saas_auth.py +0 -190
  16. slopguard_cli-0.2.0/tests/test_saas_client.py +0 -186
  17. {slopguard_cli-0.2.0 → slopguard_cli-0.3.0}/.gitignore +0 -0
  18. {slopguard_cli-0.2.0 → slopguard_cli-0.3.0}/.ruff.toml +0 -0
  19. {slopguard_cli-0.2.0 → slopguard_cli-0.3.0}/Makefile +0 -0
  20. {slopguard_cli-0.2.0 → slopguard_cli-0.3.0}/scripts/generate_seed_data.py +0 -0
  21. {slopguard_cli-0.2.0 → slopguard_cli-0.3.0}/slopguard/__main__.py +0 -0
  22. {slopguard_cli-0.2.0 → slopguard_cli-0.3.0}/slopguard/config.py +0 -0
  23. {slopguard_cli-0.2.0 → slopguard_cli-0.3.0}/slopguard/data/__init__.py +0 -0
  24. {slopguard_cli-0.2.0 → slopguard_cli-0.3.0}/slopguard/data/hallucinations_seed.json +0 -0
  25. {slopguard_cli-0.2.0 → slopguard_cli-0.3.0}/slopguard/data/popular_packages.json +0 -0
  26. {slopguard_cli-0.2.0 → slopguard_cli-0.3.0}/slopguard/models.py +0 -0
  27. {slopguard_cli-0.2.0 → slopguard_cli-0.3.0}/slopguard/parsers/__init__.py +0 -0
  28. {slopguard_cli-0.2.0 → slopguard_cli-0.3.0}/slopguard/parsers/base.py +0 -0
  29. {slopguard_cli-0.2.0 → slopguard_cli-0.3.0}/slopguard/parsers/npm.py +0 -0
  30. {slopguard_cli-0.2.0 → slopguard_cli-0.3.0}/slopguard/parsers/python.py +0 -0
  31. {slopguard_cli-0.2.0 → slopguard_cli-0.3.0}/slopguard/registry/__init__.py +0 -0
  32. {slopguard_cli-0.2.0 → slopguard_cli-0.3.0}/slopguard/registry/base.py +0 -0
  33. {slopguard_cli-0.2.0 → slopguard_cli-0.3.0}/slopguard/registry/npm.py +0 -0
  34. {slopguard_cli-0.2.0 → slopguard_cli-0.3.0}/slopguard/registry/pypi.py +0 -0
  35. {slopguard_cli-0.2.0 → slopguard_cli-0.3.0}/slopguard/report/__init__.py +0 -0
  36. {slopguard_cli-0.2.0 → slopguard_cli-0.3.0}/slopguard/report/json.py +0 -0
  37. {slopguard_cli-0.2.0 → slopguard_cli-0.3.0}/slopguard/report/terminal.py +0 -0
  38. {slopguard_cli-0.2.0 → slopguard_cli-0.3.0}/slopguard/scoring/__init__.py +0 -0
  39. {slopguard_cli-0.2.0 → slopguard_cli-0.3.0}/slopguard/scoring/engine.py +0 -0
  40. {slopguard_cli-0.2.0 → slopguard_cli-0.3.0}/slopguard/scoring/signals.py +0 -0
  41. {slopguard_cli-0.2.0 → slopguard_cli-0.3.0}/tests/__init__.py +0 -0
  42. {slopguard_cli-0.2.0 → slopguard_cli-0.3.0}/tests/fixtures/.slopguard.yaml +0 -0
  43. {slopguard_cli-0.2.0 → slopguard_cli-0.3.0}/tests/fixtures/package.json +0 -0
  44. {slopguard_cli-0.2.0 → slopguard_cli-0.3.0}/tests/fixtures/pyproject.toml +0 -0
  45. {slopguard_cli-0.2.0 → slopguard_cli-0.3.0}/tests/fixtures/requirements.txt +0 -0
  46. {slopguard_cli-0.2.0 → slopguard_cli-0.3.0}/tests/test_parsers_npm.py +0 -0
  47. {slopguard_cli-0.2.0 → slopguard_cli-0.3.0}/tests/test_parsers_python.py +0 -0
  48. {slopguard_cli-0.2.0 → slopguard_cli-0.3.0}/tests/test_registry_npm.py +0 -0
  49. {slopguard_cli-0.2.0 → slopguard_cli-0.3.0}/tests/test_registry_pypi.py +0 -0
  50. {slopguard_cli-0.2.0 → slopguard_cli-0.3.0}/tests/test_scoring_engine.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: slopguard-cli
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Defend developers and AI coding agents against slopsquatting (hallucinated package names).
5
5
  Project-URL: Homepage, https://github.com/hariomunknownslab/slopguard
6
6
  Project-URL: Repository, https://github.com/hariomunknownslab/slopguard
@@ -167,18 +167,19 @@ scoring:
167
167
  CLI flags override the file. See [`docs/usage.md`](docs/usage.md) for the full
168
168
  reference.
169
169
 
170
- ## What it does NOT do (v0.1)
170
+ ## What it does NOT do
171
171
 
172
- - No live LLM probing the hallucination database is a static seed for v0.1.
173
- - No SaaS dashboard, no auth, no billing, no telemetry to any remote server.
174
- - No tarpit registry, no defensive package registration.
172
+ - No dashboard, no auth, no accounts, no billing, no telemetry. The
173
+ CLI is fully offline-capable; `slopguard update` is the only
174
+ outbound call beyond the npm + PyPI registry probes, and it just
175
+ fetches a static JSON file from GitHub Pages.
176
+ - No defensive package registration / tarpit.
175
177
  - No Cursor / Claude Code / Copilot IDE plugins.
176
178
  - No support for crates.io, pkg.go.dev, Maven Central, RubyGems, NuGet —
177
179
  Python and JavaScript only.
178
180
  - No license scanning, no CVE matching, no SBOM generation.
179
- - No remote configuration, no SaaS API client.
180
181
 
181
- The full v0.2+ roadmap is tracked in the build spec, section 14.
182
+ Everything is MIT, free forever. Fork it.
182
183
 
183
184
  ## Privacy & trust
184
185
 
@@ -129,18 +129,19 @@ scoring:
129
129
  CLI flags override the file. See [`docs/usage.md`](docs/usage.md) for the full
130
130
  reference.
131
131
 
132
- ## What it does NOT do (v0.1)
132
+ ## What it does NOT do
133
133
 
134
- - No live LLM probing the hallucination database is a static seed for v0.1.
135
- - No SaaS dashboard, no auth, no billing, no telemetry to any remote server.
136
- - No tarpit registry, no defensive package registration.
134
+ - No dashboard, no auth, no accounts, no billing, no telemetry. The
135
+ CLI is fully offline-capable; `slopguard update` is the only
136
+ outbound call beyond the npm + PyPI registry probes, and it just
137
+ fetches a static JSON file from GitHub Pages.
138
+ - No defensive package registration / tarpit.
137
139
  - No Cursor / Claude Code / Copilot IDE plugins.
138
140
  - No support for crates.io, pkg.go.dev, Maven Central, RubyGems, NuGet —
139
141
  Python and JavaScript only.
140
142
  - No license scanning, no CVE matching, no SBOM generation.
141
- - No remote configuration, no SaaS API client.
142
143
 
143
- The full v0.2+ roadmap is tracked in the build spec, section 14.
144
+ Everything is MIT, free forever. Fork it.
144
145
 
145
146
  ## Privacy & trust
146
147
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "slopguard-cli"
7
- version = "0.2.0"
7
+ version = "0.3.0"
8
8
  description = "Defend developers and AI coding agents against slopsquatting (hallucinated package names)."
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -2,6 +2,6 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- __version__ = "0.2.0"
5
+ __version__ = "0.3.0"
6
6
 
7
7
  __all__ = ["__version__"]
@@ -270,13 +270,6 @@ def scan_cmd(
270
270
  int | None, typer.Option("--concurrency", help="Maximum concurrent registry probes.")
271
271
  ] = None,
272
272
  verbose: Annotated[bool, typer.Option("--verbose", "-v", help="Show debug logs.")] = False,
273
- upload: Annotated[
274
- bool,
275
- typer.Option(
276
- "--upload",
277
- help="Upload the scan to the SlopGuard SaaS after scanning. Requires `slopguard login`.",
278
- ),
279
- ] = False,
280
273
  ) -> None:
281
274
  """Scan a project for slopsquatted / hallucinated dependencies."""
282
275
  if verbose:
@@ -311,59 +304,9 @@ def scan_cmd(
311
304
  else:
312
305
  render_terminal_report(report, duration_seconds=duration)
313
306
 
314
- if upload:
315
- _upload_report(report, duration_ms=int(duration * 1000))
316
-
317
307
  raise typer.Exit(code=report.exit_code)
318
308
 
319
309
 
320
- def _upload_report(report: ScanReport, *, duration_ms: int) -> None:
321
- """Persist a completed ScanReport to the SaaS via POST /v1/scans."""
322
- from slopguard.saas import load_credentials
323
- from slopguard.saas.client import ApiError, list_projects, post_scan
324
-
325
- creds = load_credentials()
326
- if creds is None:
327
- raise _error_exit("upload requires `slopguard login` first.")
328
- if creds.organization_id is None:
329
- raise _error_exit(
330
- "credentials missing organization_id; run `slopguard logout` then `slopguard login` again."
331
- )
332
-
333
- try:
334
- projects = list_projects(creds.api_url, creds.token, creds.organization_id)
335
- except ApiError as exc:
336
- raise _error_exit(f"could not list projects: {exc.detail} (status {exc.status})") from exc
337
- if not projects:
338
- raise _error_exit("no project on this organization; visit the dashboard and create one.")
339
- project_id = projects[0]["id"]
340
-
341
- payload: dict[str, object] = {
342
- "project_id": project_id,
343
- "report": report.model_dump(mode="json"),
344
- "source": _detect_runner(),
345
- "duration_ms": duration_ms,
346
- }
347
- try:
348
- created = post_scan(creds.api_url, creds.token, payload)
349
- except ApiError as exc:
350
- raise _error_exit(f"upload failed: {exc.detail} (status {exc.status})") from exc
351
-
352
- Console().print(
353
- f"[green]Uploaded[/green] to {creds.api_url}/app/{creds.org_slug}/scans/{created['id']}"
354
- )
355
-
356
-
357
- def _detect_runner() -> str:
358
- import os
359
-
360
- if os.environ.get("GITHUB_ACTIONS") == "true":
361
- return "ci"
362
- if os.environ.get("CI") == "true":
363
- return "ci"
364
- return "cli"
365
-
366
-
367
310
  @app.command("version")
368
311
  def version_cmd() -> None:
369
312
  """Print the SlopGuard version and exit 0."""
@@ -372,131 +315,13 @@ def version_cmd() -> None:
372
315
 
373
316
  @app.command("update")
374
317
  def update_cmd() -> None:
375
- """Refresh the local hallucination DB from the SlopGuard SaaS feed.
318
+ """Refresh the local hallucination DB from the public GitHub Pages feed.
376
319
 
377
320
  No auth required. Downloads the public registry from
378
- ``$SLOPGUARD_API_URL/v1/hallucinations`` (default: the SlopGuard SaaS)
379
- and caches it at ``~/.cache/slopguard/hallucinations_db.json`` for the
380
- next scan to read.
321
+ ``https://hariomunknownslab.github.io/slopguard/db.json`` (overridable
322
+ via ``SLOPGUARD_DB_URL``) and caches it at
323
+ ``~/.cache/slopguard/hallucinations_db.json`` for the next scan to read.
381
324
  """
382
325
  from slopguard.update import run
383
326
 
384
327
  raise typer.Exit(code=run())
385
-
386
-
387
- # --- SaaS auth subcommands -------------------------------------------------
388
- #
389
- # Free, unauthenticated scanning never touches these — the imports below are
390
- # deferred so the CLI keeps starting fast and offline.
391
-
392
-
393
- def _resolve_api_url(flag_value: str | None) -> str:
394
- """API URL resolution order: --api-url > $SLOPGUARD_API_URL > default."""
395
- import os
396
-
397
- from slopguard.saas.client import DEFAULT_API_URL
398
-
399
- return flag_value or os.environ.get("SLOPGUARD_API_URL") or DEFAULT_API_URL
400
-
401
-
402
- @app.command("login")
403
- def login_cmd(
404
- token: Annotated[
405
- str | None,
406
- typer.Option(
407
- "--token",
408
- help="API token. If omitted, you'll be prompted (paste hidden).",
409
- ),
410
- ] = None,
411
- api_url: Annotated[
412
- str | None,
413
- typer.Option(
414
- "--api-url",
415
- help="SlopGuard API base URL. Defaults to $SLOPGUARD_API_URL or production.",
416
- ),
417
- ] = None,
418
- ) -> None:
419
- """Authenticate the CLI with a SlopGuard API token.
420
-
421
- Mint a token in the dashboard at *<api>/app/<org>/tokens* and paste it
422
- here. The token is stored at ``~/.config/slopguard/credentials.toml``
423
- with mode 0600.
424
- """
425
- from slopguard.saas import Credentials, save_credentials
426
- from slopguard.saas.client import ApiError, get_me
427
-
428
- api = _resolve_api_url(api_url)
429
- plaintext = token or typer.prompt("API token (input hidden)", hide_input=True)
430
- if not plaintext.startswith(("sg_live_", "sg_test_")):
431
- raise _error_exit(
432
- "token must start with sg_live_ or sg_test_; mint one at "
433
- f"{api.rstrip('/')}/app/<org-slug>/tokens"
434
- )
435
-
436
- try:
437
- me = get_me(api, plaintext)
438
- except ApiError as exc:
439
- raise _error_exit(
440
- f"could not verify token against {api}: {exc.detail} (status {exc.status})"
441
- ) from exc
442
-
443
- save_credentials(
444
- Credentials(
445
- api_url=api,
446
- token=plaintext,
447
- org_slug=me.active_org.slug,
448
- organization_id=me.active_org.id,
449
- user_email=me.email,
450
- )
451
- )
452
-
453
- console = Console()
454
- console.print(
455
- f"[green]Authenticated.[/green] Org: [bold]{me.active_org.name}[/bold] "
456
- f"([cyan]{me.active_org.slug}[/cyan], plan: {me.active_org.plan})"
457
- )
458
- console.print("Credentials saved to ~/.config/slopguard/credentials.toml (mode 600).")
459
-
460
-
461
- @app.command("whoami")
462
- def whoami_cmd() -> None:
463
- """Print the active org context for the saved credentials."""
464
- from slopguard.saas import load_credentials
465
- from slopguard.saas.client import ApiError, get_me
466
-
467
- creds = load_credentials()
468
- if creds is None:
469
- raise _error_exit(
470
- "not authenticated. Run `slopguard login` first.",
471
- )
472
-
473
- try:
474
- me = get_me(creds.api_url, creds.token)
475
- except ApiError as exc:
476
- if exc.status in (401, 403):
477
- raise _error_exit(
478
- "saved token rejected. Run `slopguard logout` then `slopguard login` again."
479
- ) from exc
480
- raise _error_exit(f"API call failed: {exc.detail} (status {exc.status})") from exc
481
-
482
- console = Console()
483
- console.print(f"[bold]Email:[/bold] {me.email or '—'}")
484
- console.print(
485
- f"[bold]Active org:[/bold] {me.active_org.name} ([cyan]{me.active_org.slug}[/cyan])"
486
- )
487
- console.print(f"[bold]Role:[/bold] {me.active_org.role}")
488
- console.print(f"[bold]Plan:[/bold] {me.active_org.plan}")
489
- console.print(f"[bold]API:[/bold] {creds.api_url}")
490
-
491
-
492
- @app.command("logout")
493
- def logout_cmd() -> None:
494
- """Forget the saved credentials."""
495
- from slopguard.saas import delete_credentials
496
-
497
- removed = delete_credentials()
498
- console = Console()
499
- if removed:
500
- console.print("[green]Logged out.[/green] Credentials file removed.")
501
- else:
502
- console.print("[yellow]Not logged in.[/yellow] No credentials file to remove.")
@@ -1,7 +1,7 @@
1
1
  """``slopguard update`` — refresh the local hallucination DB.
2
2
 
3
- Fetches the curated DB from the SlopGuard GitHub Pages site (no API,
4
- no account, no auth) and writes a seed-shaped file to
3
+ Fetches the curated DB from the public GitHub Pages site (no API, no
4
+ account, no auth) and writes a seed-shaped file to
5
5
  ``~/.cache/slopguard/hallucinations_db.json``. The next ``slopguard
6
6
  scan`` prefers this cached file over the bundled seed (see
7
7
  ``slopguard.data.load_hallucination_db``).
@@ -13,9 +13,7 @@ Updated daily by the probe-cron GitHub Action; each entry goes through
13
13
  PR review before publication (see probe-data/README.md in the repo).
14
14
 
15
15
  Override the URL with ``SLOPGUARD_DB_URL`` to fetch from a fork or
16
- mirror. The legacy ``SLOPGUARD_API_URL`` env var still works for the
17
- 0.1.x SaaS endpoint shape — used as a fallback for backward
18
- compatibility.
16
+ self-hosted mirror.
19
17
  """
20
18
 
21
19
  from __future__ import annotations
@@ -35,18 +33,14 @@ CACHE_PATH = Path.home() / ".cache" / "slopguard" / "hallucinations_db.json"
35
33
  def _db_url() -> str:
36
34
  """Where to fetch the published DB.
37
35
 
38
- Precedence: ``SLOPGUARD_DB_URL`` > legacy
39
- ``SLOPGUARD_API_URL/v1/hallucinations`` > default GitHub Pages.
36
+ Override the default GitHub Pages URL with ``SLOPGUARD_DB_URL``
37
+ when running against a fork or a self-hosted mirror.
40
38
  """
41
- if explicit := os.environ.get("SLOPGUARD_DB_URL"):
42
- return explicit
43
- if legacy := os.environ.get("SLOPGUARD_API_URL"):
44
- return legacy.rstrip("/") + "/v1/hallucinations"
45
- return DEFAULT_DB_URL
39
+ return os.environ.get("SLOPGUARD_DB_URL") or DEFAULT_DB_URL
46
40
 
47
41
 
48
42
  def _convert(wire: dict[str, Any]) -> dict[str, Any]:
49
- """Map the API wire payload to the local seed schema."""
43
+ """Map the public-feed payload to the local seed schema."""
50
44
  entries: list[dict[str, Any]] = []
51
45
  for row in wire.get("entries", []):
52
46
  entries.append(
@@ -0,0 +1,22 @@
1
+ """Shared pytest fixtures."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import pytest
8
+
9
+ # Force test isolation from the developer's real ~/.cache. If a previous
10
+ # `slopguard update` left an empty/stale cache file, the hallucination DB
11
+ # loader would pick it up and tests asserting on the bundled seed would
12
+ # break. Pointing CACHE_OVERRIDE at /dev/null/missing makes the loader
13
+ # fall back to the seed every time, regardless of host state.
14
+ from slopguard import data as _data
15
+
16
+ _data.CACHE_OVERRIDE = Path("/__slopguard_test_no_cache__")
17
+ _data.load_hallucination_db.cache_clear()
18
+
19
+
20
+ @pytest.fixture
21
+ def fixtures_dir() -> Path:
22
+ return Path(__file__).parent / "fixtures"
@@ -107,7 +107,7 @@ def runner() -> CliRunner:
107
107
  def test_version_subcommand(runner: CliRunner) -> None:
108
108
  result = runner.invoke(app, ["version"])
109
109
  assert result.exit_code == 0
110
- assert result.stdout.strip() == "0.2.0"
110
+ assert result.stdout.strip() == "0.3.0"
111
111
 
112
112
 
113
113
  def test_scan_fixtures_no_network_terminal(runner: CliRunner, fixtures_dir: Path) -> None:
@@ -183,4 +183,4 @@ def test_module_main_runs() -> None:
183
183
  check=False,
184
184
  )
185
185
  assert result.returncode == 0
186
- assert result.stdout.strip() == "0.2.0"
186
+ assert result.stdout.strip() == "0.3.0"
@@ -40,8 +40,8 @@ def test_update_writes_cache_when_api_reachable(
40
40
  from slopguard import update
41
41
 
42
42
  monkeypatch.setattr(update, "CACHE_PATH", tmp_path / "h.json")
43
- monkeypatch.setenv("SLOPGUARD_API_URL", "http://api.test")
44
- respx.get("http://api.test/v1/hallucinations").mock(
43
+ monkeypatch.setenv("SLOPGUARD_DB_URL", "http://feed.test/db.json")
44
+ respx.get("http://feed.test/db.json").mock(
45
45
  return_value=httpx.Response(
46
46
  200,
47
47
  json={"generated_at": "2026-05-23T22:00:00Z", "count": 0, "entries": []},
@@ -22,7 +22,7 @@ def cache_at_tmp(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
22
22
 
23
23
  @pytest.fixture
24
24
  def api_at_test(monkeypatch: pytest.MonkeyPatch) -> None:
25
- monkeypatch.setenv("SLOPGUARD_API_URL", "http://api.test")
25
+ monkeypatch.setenv("SLOPGUARD_DB_URL", "http://feed.test/db.json")
26
26
 
27
27
 
28
28
  @respx.mock
@@ -44,9 +44,7 @@ def test_update_writes_cache(
44
44
  }
45
45
  ],
46
46
  }
47
- respx.get("http://api.test/v1/hallucinations").mock(
48
- return_value=httpx.Response(200, json=payload)
49
- )
47
+ respx.get("http://feed.test/db.json").mock(return_value=httpx.Response(200, json=payload))
50
48
 
51
49
  code = update.run()
52
50
  assert code == 0
@@ -67,7 +65,7 @@ def test_update_writes_cache(
67
65
  def test_update_handles_network_failure(
68
66
  cache_at_tmp: Path, api_at_test: None, capsys: pytest.CaptureFixture[str]
69
67
  ) -> None:
70
- respx.get("http://api.test/v1/hallucinations").mock(side_effect=httpx.ConnectError("nope"))
68
+ respx.get("http://feed.test/db.json").mock(side_effect=httpx.ConnectError("nope"))
71
69
  code = update.run()
72
70
  assert code == 1
73
71
  assert not cache_at_tmp.exists()
@@ -78,7 +76,7 @@ def test_update_handles_network_failure(
78
76
  def test_update_rejects_malformed_payload(
79
77
  cache_at_tmp: Path, api_at_test: None, capsys: pytest.CaptureFixture[str]
80
78
  ) -> None:
81
- respx.get("http://api.test/v1/hallucinations").mock(
79
+ respx.get("http://feed.test/db.json").mock(
82
80
  return_value=httpx.Response(200, json={"entries": [{"name": "x"}]})
83
81
  )
84
82
  code = update.run()
@@ -88,34 +86,24 @@ def test_update_rejects_malformed_payload(
88
86
 
89
87
 
90
88
  def test_default_db_url_is_github_pages(monkeypatch: pytest.MonkeyPatch) -> None:
91
- """When neither override env var is set, fetch from GitHub Pages."""
89
+ """When SLOPGUARD_DB_URL is not set, fetch from GitHub Pages."""
92
90
  monkeypatch.delenv("SLOPGUARD_DB_URL", raising=False)
93
- monkeypatch.delenv("SLOPGUARD_API_URL", raising=False)
94
91
  assert update._db_url() == update.DEFAULT_DB_URL
95
92
  assert update.DEFAULT_DB_URL.startswith("https://hariomunknownslab.github.io/")
96
93
 
97
94
 
98
- def test_slopguard_db_url_takes_precedence(monkeypatch: pytest.MonkeyPatch) -> None:
95
+ def test_slopguard_db_url_override(monkeypatch: pytest.MonkeyPatch) -> None:
96
+ """SLOPGUARD_DB_URL overrides the default Pages URL."""
99
97
  monkeypatch.setenv("SLOPGUARD_DB_URL", "https://my.mirror/db.json")
100
- monkeypatch.setenv("SLOPGUARD_API_URL", "https://legacy/api") # should be ignored
101
98
  assert update._db_url() == "https://my.mirror/db.json"
102
99
 
103
100
 
104
- def test_legacy_api_url_still_works(monkeypatch: pytest.MonkeyPatch) -> None:
105
- """0.1.x users with SLOPGUARD_API_URL set keep working — we append
106
- /v1/hallucinations the way the SaaS endpoint expected."""
107
- monkeypatch.delenv("SLOPGUARD_DB_URL", raising=False)
108
- monkeypatch.setenv("SLOPGUARD_API_URL", "https://legacy.example/")
109
- assert update._db_url() == "https://legacy.example/v1/hallucinations"
110
-
111
-
112
101
  @respx.mock
113
102
  def test_update_fetches_from_pages_by_default(
114
103
  cache_at_tmp: Path, monkeypatch: pytest.MonkeyPatch
115
104
  ) -> None:
116
- """End-to-end: no env overrides, hits the Pages URL."""
105
+ """End-to-end: no env override, hits the Pages URL."""
117
106
  monkeypatch.delenv("SLOPGUARD_DB_URL", raising=False)
118
- monkeypatch.delenv("SLOPGUARD_API_URL", raising=False)
119
107
  respx.get(update.DEFAULT_DB_URL).mock(
120
108
  return_value=httpx.Response(
121
109
  200,
@@ -1,26 +0,0 @@
1
- """SaaS integration: credentials store + HTTP client for the SlopGuard API.
2
-
3
- Free, unauthenticated CLI invocations never touch this module. It is only
4
- imported when the user runs ``slopguard login``, ``slopguard whoami``,
5
- ``slopguard logout``, or ``slopguard scan --upload``.
6
- """
7
-
8
- from __future__ import annotations
9
-
10
- from slopguard.saas.credentials import (
11
- DEFAULT_CREDENTIALS_PATH,
12
- Credentials,
13
- CredentialsError,
14
- delete_credentials,
15
- load_credentials,
16
- save_credentials,
17
- )
18
-
19
- __all__ = [
20
- "DEFAULT_CREDENTIALS_PATH",
21
- "Credentials",
22
- "CredentialsError",
23
- "delete_credentials",
24
- "load_credentials",
25
- "save_credentials",
26
- ]
@@ -1,114 +0,0 @@
1
- """Tiny HTTP client for the SlopGuard API."""
2
-
3
- from __future__ import annotations
4
-
5
- from dataclasses import dataclass
6
- from typing import Any
7
-
8
- import httpx
9
-
10
- DEFAULT_API_URL = "https://api.slopguard.io"
11
-
12
-
13
- class ApiError(Exception):
14
- def __init__(self, status: int, detail: str) -> None:
15
- self.status = status
16
- self.detail = detail
17
- super().__init__(f"API {status}: {detail}")
18
-
19
-
20
- @dataclass(frozen=True)
21
- class MeOrg:
22
- id: str
23
- name: str
24
- slug: str
25
- plan: str
26
- role: str
27
-
28
-
29
- @dataclass(frozen=True)
30
- class MeResponse:
31
- user_id: str | None
32
- email: str | None
33
- principal_kind: str
34
- active_org: MeOrg
35
-
36
-
37
- def get_me(api_url: str, token: str, *, timeout: float = 10.0) -> MeResponse:
38
- """Synchronous /v1/me call. Used by login/whoami/--upload."""
39
- try:
40
- response = httpx.get(
41
- f"{api_url.rstrip('/')}/v1/me",
42
- headers={"Authorization": f"Bearer {token}"},
43
- timeout=timeout,
44
- )
45
- except httpx.RequestError as exc:
46
- raise ApiError(0, f"network error: {exc}") from exc
47
-
48
- if response.status_code != 200:
49
- detail = _extract_detail(response)
50
- raise ApiError(response.status_code, detail)
51
- payload = response.json()
52
- active = payload["active_organization"]
53
- return MeResponse(
54
- user_id=payload.get("user_id"),
55
- email=payload.get("email"),
56
- principal_kind=payload["principal_kind"],
57
- active_org=MeOrg(
58
- id=active["id"],
59
- name=active["name"],
60
- slug=active["slug"],
61
- plan=active["plan"],
62
- role=active["role"],
63
- ),
64
- )
65
-
66
-
67
- def post_scan(
68
- api_url: str, token: str, payload: dict[str, Any], *, timeout: float = 30.0
69
- ) -> dict[str, Any]:
70
- """POST /v1/scans. Returns the parsed ScanSummary response."""
71
- try:
72
- response = httpx.post(
73
- f"{api_url.rstrip('/')}/v1/scans",
74
- headers={
75
- "Authorization": f"Bearer {token}",
76
- "Content-Type": "application/json",
77
- },
78
- json=payload,
79
- timeout=timeout,
80
- )
81
- except httpx.RequestError as exc:
82
- raise ApiError(0, f"network error: {exc}") from exc
83
- if response.status_code not in (200, 201):
84
- raise ApiError(response.status_code, _extract_detail(response))
85
- return response.json() # type: ignore[no-any-return]
86
-
87
-
88
- def list_projects(
89
- api_url: str, token: str, organization_id: str, *, timeout: float = 10.0
90
- ) -> list[dict[str, Any]]:
91
- try:
92
- response = httpx.get(
93
- f"{api_url.rstrip('/')}/v1/projects",
94
- params={"organization_id": organization_id},
95
- headers={"Authorization": f"Bearer {token}"},
96
- timeout=timeout,
97
- )
98
- except httpx.RequestError as exc:
99
- raise ApiError(0, f"network error: {exc}") from exc
100
- if response.status_code != 200:
101
- raise ApiError(response.status_code, _extract_detail(response))
102
- return response.json() # type: ignore[no-any-return]
103
-
104
-
105
- def _extract_detail(response: httpx.Response) -> str:
106
- try:
107
- body = response.json()
108
- except ValueError:
109
- body = None
110
- if isinstance(body, dict):
111
- detail = body.get("detail")
112
- if isinstance(detail, str):
113
- return detail
114
- return response.text[:200] or response.reason_phrase
@@ -1,118 +0,0 @@
1
- """Read/write the CLI credentials file.
2
-
3
- Stored at ``~/.config/slopguard/credentials.toml`` with mode 0600 so other
4
- users on the machine cannot read it (spec §6.6).
5
-
6
- Layout::
7
-
8
- [active]
9
- api_url = "https://api.slopguard.io"
10
- token = "sg_live_..."
11
- org_slug = "acme-security"
12
- organization_id = "..."
13
- user_email = "harry@unknownslab.com"
14
-
15
- A future ``slopguard switch`` could let multiple profiles live side-by-
16
- side; for now only ``[active]`` is read or written.
17
- """
18
-
19
- from __future__ import annotations
20
-
21
- import os
22
- import tomllib
23
- from dataclasses import dataclass
24
- from pathlib import Path
25
-
26
- DEFAULT_CREDENTIALS_PATH = Path.home() / ".config" / "slopguard" / "credentials.toml"
27
-
28
-
29
- class CredentialsError(Exception):
30
- """Raised when credentials cannot be read or written."""
31
-
32
-
33
- @dataclass(frozen=True)
34
- class Credentials:
35
- api_url: str
36
- token: str
37
- org_slug: str | None = None
38
- organization_id: str | None = None
39
- user_email: str | None = None
40
-
41
-
42
- def load_credentials(path: Path | None = None) -> Credentials | None:
43
- """Return the active credentials, or None if the file doesn't exist."""
44
- if path is None:
45
- path = DEFAULT_CREDENTIALS_PATH
46
- if not path.exists():
47
- return None
48
- try:
49
- with path.open("rb") as fh:
50
- data = tomllib.load(fh)
51
- except tomllib.TOMLDecodeError as exc:
52
- raise CredentialsError(f"invalid credentials file {path}: {exc}") from exc
53
-
54
- active = data.get("active")
55
- if not isinstance(active, dict):
56
- return None
57
- token = active.get("token")
58
- api_url = active.get("api_url")
59
- if not isinstance(token, str) or not isinstance(api_url, str):
60
- return None
61
- return Credentials(
62
- api_url=api_url,
63
- token=token,
64
- org_slug=active.get("org_slug") if isinstance(active.get("org_slug"), str) else None,
65
- organization_id=(
66
- active.get("organization_id")
67
- if isinstance(active.get("organization_id"), str)
68
- else None
69
- ),
70
- user_email=(
71
- active.get("user_email") if isinstance(active.get("user_email"), str) else None
72
- ),
73
- )
74
-
75
-
76
- def save_credentials(creds: Credentials, path: Path | None = None) -> None:
77
- """Write ``creds`` to the file at ``path``. Creates parent dirs and chmod 0600."""
78
- if path is None:
79
- path = DEFAULT_CREDENTIALS_PATH
80
- path.parent.mkdir(parents=True, exist_ok=True)
81
- body = _render(creds)
82
- # Write to a temp sibling then atomic-rename so a crash mid-write can't
83
- # leave a half-written credentials file.
84
- tmp = path.with_suffix(path.suffix + ".tmp")
85
- tmp.write_text(body, encoding="utf-8")
86
- os.chmod(tmp, 0o600)
87
- tmp.replace(path)
88
-
89
-
90
- def delete_credentials(path: Path | None = None) -> bool:
91
- """Remove the credentials file. Returns True if a file was deleted."""
92
- if path is None:
93
- path = DEFAULT_CREDENTIALS_PATH
94
- if not path.exists():
95
- return False
96
- path.unlink()
97
- return True
98
-
99
-
100
- def _render(creds: Credentials) -> str:
101
- lines = [
102
- "# slopguard CLI credentials — do not share. chmod 600.",
103
- "",
104
- "[active]",
105
- f'api_url = "{_escape(creds.api_url)}"',
106
- f'token = "{_escape(creds.token)}"',
107
- ]
108
- if creds.org_slug:
109
- lines.append(f'org_slug = "{_escape(creds.org_slug)}"')
110
- if creds.organization_id:
111
- lines.append(f'organization_id = "{_escape(creds.organization_id)}"')
112
- if creds.user_email:
113
- lines.append(f'user_email = "{_escape(creds.user_email)}"')
114
- return "\n".join(lines) + "\n"
115
-
116
-
117
- def _escape(value: str) -> str:
118
- return value.replace("\\", "\\\\").replace('"', '\\"')
@@ -1,12 +0,0 @@
1
- """Shared pytest fixtures."""
2
-
3
- from __future__ import annotations
4
-
5
- from pathlib import Path
6
-
7
- import pytest
8
-
9
-
10
- @pytest.fixture
11
- def fixtures_dir() -> Path:
12
- return Path(__file__).parent / "fixtures"
@@ -1,190 +0,0 @@
1
- """Tests for the SaaS auth subcommands: login, whoami, logout."""
2
-
3
- from __future__ import annotations
4
-
5
- import stat
6
- from pathlib import Path
7
-
8
- import pytest
9
- from typer.testing import CliRunner
10
-
11
- from slopguard.cli import app
12
- from slopguard.saas.credentials import (
13
- Credentials,
14
- delete_credentials,
15
- load_credentials,
16
- save_credentials,
17
- )
18
-
19
-
20
- @pytest.fixture
21
- def runner() -> CliRunner:
22
- return CliRunner()
23
-
24
-
25
- @pytest.fixture
26
- def cred_path(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
27
- path = tmp_path / "credentials.toml"
28
- # Redirect the default path used by all three subcommands.
29
- monkeypatch.setattr("slopguard.saas.credentials.DEFAULT_CREDENTIALS_PATH", path)
30
- monkeypatch.setattr("slopguard.saas.DEFAULT_CREDENTIALS_PATH", path)
31
- return path
32
-
33
-
34
- def test_credentials_round_trip(tmp_path: Path) -> None:
35
- path = tmp_path / "creds.toml"
36
- save_credentials(
37
- Credentials(
38
- api_url="http://localhost:8000",
39
- token="sg_live_" + "x" * 24,
40
- org_slug="acme",
41
- organization_id="11111111-1111-1111-1111-111111111111",
42
- user_email="harry@example.com",
43
- ),
44
- path=path,
45
- )
46
- assert path.exists()
47
- mode = stat.S_IMODE(path.stat().st_mode)
48
- assert mode == 0o600, f"expected 0600, got {oct(mode)}"
49
-
50
- loaded = load_credentials(path)
51
- assert loaded is not None
52
- assert loaded.api_url == "http://localhost:8000"
53
- assert loaded.token == "sg_live_" + "x" * 24
54
- assert loaded.org_slug == "acme"
55
-
56
-
57
- def test_load_credentials_missing_returns_none(tmp_path: Path) -> None:
58
- assert load_credentials(tmp_path / "nope.toml") is None
59
-
60
-
61
- def test_delete_credentials(tmp_path: Path) -> None:
62
- path = tmp_path / "creds.toml"
63
- path.write_text('[active]\ntoken="x"\napi_url="y"\n')
64
- assert delete_credentials(path) is True
65
- assert delete_credentials(path) is False
66
-
67
-
68
- def test_login_rejects_malformed_token(runner: CliRunner, cred_path: Path) -> None:
69
- result = runner.invoke(app, ["login", "--token", "not-a-token"])
70
- assert result.exit_code == 2
71
- assert "must start with sg_live_" in result.stdout or "must start with sg_live_" in (
72
- result.stderr or ""
73
- )
74
- assert not cred_path.exists()
75
-
76
-
77
- def test_login_persists_creds_on_success(
78
- runner: CliRunner,
79
- cred_path: Path,
80
- monkeypatch: pytest.MonkeyPatch,
81
- ) -> None:
82
- from slopguard.saas import client as saas_client
83
-
84
- def fake_get_me(api_url: str, token: str, **_: object) -> saas_client.MeResponse:
85
- assert token.startswith("sg_live_")
86
- return saas_client.MeResponse(
87
- user_id="user-1",
88
- email="harry@example.com",
89
- principal_kind="token",
90
- active_org=saas_client.MeOrg(
91
- id="org-1",
92
- name="Acme",
93
- slug="acme",
94
- plan="free",
95
- role="token",
96
- ),
97
- )
98
-
99
- monkeypatch.setattr("slopguard.cli.get_me", None, raising=False)
100
- monkeypatch.setattr("slopguard.saas.client.get_me", fake_get_me)
101
-
102
- result = runner.invoke(
103
- app,
104
- ["login", "--token", "sg_live_" + "x" * 24, "--api-url", "http://localhost:8000"],
105
- )
106
- assert result.exit_code == 0, result.stdout
107
- assert "Authenticated" in result.stdout
108
- creds = load_credentials(cred_path)
109
- assert creds is not None
110
- assert creds.org_slug == "acme"
111
- assert creds.api_url == "http://localhost:8000"
112
-
113
-
114
- def test_whoami_without_creds_exits_2(runner: CliRunner, cred_path: Path) -> None:
115
- result = runner.invoke(app, ["whoami"])
116
- assert result.exit_code == 2
117
-
118
-
119
- def test_whoami_with_valid_creds_prints_org(
120
- runner: CliRunner,
121
- cred_path: Path,
122
- monkeypatch: pytest.MonkeyPatch,
123
- ) -> None:
124
- save_credentials(
125
- Credentials(
126
- api_url="http://localhost:8000",
127
- token="sg_live_" + "x" * 24,
128
- org_slug="acme",
129
- organization_id="org-1",
130
- user_email="harry@example.com",
131
- ),
132
- path=cred_path,
133
- )
134
- from slopguard.saas import client as saas_client
135
-
136
- def fake_get_me(api_url: str, token: str, **_: object) -> saas_client.MeResponse:
137
- return saas_client.MeResponse(
138
- user_id="user-1",
139
- email="harry@example.com",
140
- principal_kind="token",
141
- active_org=saas_client.MeOrg(
142
- id="org-1",
143
- name="Acme",
144
- slug="acme",
145
- plan="free",
146
- role="token",
147
- ),
148
- )
149
-
150
- monkeypatch.setattr("slopguard.saas.client.get_me", fake_get_me)
151
- result = runner.invoke(app, ["whoami"])
152
- assert result.exit_code == 0, result.stdout
153
- assert "Acme" in result.stdout
154
- assert "acme" in result.stdout
155
-
156
-
157
- def test_whoami_rejected_token(
158
- runner: CliRunner, cred_path: Path, monkeypatch: pytest.MonkeyPatch
159
- ) -> None:
160
- save_credentials(
161
- Credentials(api_url="http://localhost:8000", token="sg_live_" + "x" * 24),
162
- path=cred_path,
163
- )
164
- from slopguard.saas import client as saas_client
165
-
166
- def fake_get_me(api_url: str, token: str, **_: object) -> saas_client.MeResponse:
167
- raise saas_client.ApiError(401, "token not found")
168
-
169
- monkeypatch.setattr("slopguard.saas.client.get_me", fake_get_me)
170
- result = runner.invoke(app, ["whoami"])
171
- assert result.exit_code == 2
172
- assert "logout" in result.stdout or "logout" in (result.stderr or "")
173
-
174
-
175
- def test_logout_removes_creds(runner: CliRunner, cred_path: Path) -> None:
176
- save_credentials(
177
- Credentials(api_url="http://localhost:8000", token="sg_live_" + "x" * 24),
178
- path=cred_path,
179
- )
180
- assert cred_path.exists()
181
- result = runner.invoke(app, ["logout"])
182
- assert result.exit_code == 0
183
- assert "Logged out" in result.stdout
184
- assert not cred_path.exists()
185
-
186
-
187
- def test_logout_when_not_logged_in(runner: CliRunner, cred_path: Path) -> None:
188
- result = runner.invoke(app, ["logout"])
189
- assert result.exit_code == 0
190
- assert "Not logged in" in result.stdout
@@ -1,186 +0,0 @@
1
- """Tests for the SlopGuard API HTTP client used by login/whoami/--upload."""
2
-
3
- from __future__ import annotations
4
-
5
- import httpx
6
- import pytest
7
- import respx
8
-
9
- from slopguard.saas.client import (
10
- DEFAULT_API_URL,
11
- ApiError,
12
- MeOrg,
13
- MeResponse,
14
- _extract_detail,
15
- get_me,
16
- list_projects,
17
- post_scan,
18
- )
19
-
20
- ME_RESPONSE = {
21
- "user_id": "user-1",
22
- "email": "harry@example.com",
23
- "clerk_id": None,
24
- "principal_kind": "token",
25
- "active_organization": {
26
- "id": "org-1",
27
- "name": "Acme Security",
28
- "slug": "acme",
29
- "plan": "starter",
30
- "role": "owner",
31
- },
32
- "organizations": [
33
- {
34
- "id": "org-1",
35
- "name": "Acme Security",
36
- "slug": "acme",
37
- "plan": "starter",
38
- "role": "owner",
39
- }
40
- ],
41
- }
42
-
43
-
44
- def test_default_api_url_is_production() -> None:
45
- assert DEFAULT_API_URL.startswith("https://")
46
-
47
-
48
- def test_api_error_message_includes_status_and_detail() -> None:
49
- err = ApiError(401, "token revoked")
50
- assert "401" in str(err)
51
- assert "token revoked" in str(err)
52
-
53
-
54
- @respx.mock
55
- def test_get_me_returns_parsed_response() -> None:
56
- respx.get("http://api.test/v1/me").mock(return_value=httpx.Response(200, json=ME_RESPONSE))
57
- me = get_me("http://api.test", "sg_live_" + "x" * 24)
58
- assert isinstance(me, MeResponse)
59
- assert isinstance(me.active_org, MeOrg)
60
- assert me.email == "harry@example.com"
61
- assert me.active_org.slug == "acme"
62
- assert me.active_org.role == "owner"
63
-
64
-
65
- @respx.mock
66
- def test_get_me_trims_trailing_slash() -> None:
67
- respx.get("http://api.test/v1/me").mock(return_value=httpx.Response(200, json=ME_RESPONSE))
68
- # Verify that trailing slash on api_url doesn't double up
69
- me = get_me("http://api.test/", "sg_live_" + "x" * 24)
70
- assert me.active_org.slug == "acme"
71
-
72
-
73
- @respx.mock
74
- def test_get_me_raises_api_error_on_401() -> None:
75
- respx.get("http://api.test/v1/me").mock(
76
- return_value=httpx.Response(401, json={"detail": "token revoked"})
77
- )
78
- with pytest.raises(ApiError) as exc_info:
79
- get_me("http://api.test", "sg_live_" + "x" * 24)
80
- assert exc_info.value.status == 401
81
- assert "token revoked" in exc_info.value.detail
82
-
83
-
84
- @respx.mock
85
- def test_get_me_raises_on_network_error() -> None:
86
- respx.get("http://api.test/v1/me").mock(side_effect=httpx.ConnectError("refused"))
87
- with pytest.raises(ApiError) as exc_info:
88
- get_me("http://api.test", "sg_live_" + "x" * 24)
89
- assert exc_info.value.status == 0
90
- assert "network error" in exc_info.value.detail
91
-
92
-
93
- @respx.mock
94
- def test_get_me_handles_non_json_detail() -> None:
95
- respx.get("http://api.test/v1/me").mock(
96
- return_value=httpx.Response(500, text="internal whoops")
97
- )
98
- with pytest.raises(ApiError) as exc_info:
99
- get_me("http://api.test", "sg_live_" + "x" * 24)
100
- assert exc_info.value.status == 500
101
- assert "internal whoops" in exc_info.value.detail
102
-
103
-
104
- @respx.mock
105
- def test_post_scan_returns_parsed_summary() -> None:
106
- payload = {
107
- "project_id": "00000000-0000-0000-0000-000000000001",
108
- "report": {"slopguard_version": "0.1.1"},
109
- "source": "cli",
110
- }
111
- respx.post("http://api.test/v1/scans").mock(
112
- return_value=httpx.Response(201, json={"id": "scan-1", "summary_total": 14})
113
- )
114
- result = post_scan("http://api.test", "sg_live_" + "x" * 24, payload)
115
- assert result["id"] == "scan-1"
116
- assert result["summary_total"] == 14
117
-
118
-
119
- @respx.mock
120
- def test_post_scan_raises_on_404() -> None:
121
- respx.post("http://api.test/v1/scans").mock(
122
- return_value=httpx.Response(404, json={"detail": "project not found"})
123
- )
124
- with pytest.raises(ApiError) as exc_info:
125
- post_scan("http://api.test", "sg_live_" + "x" * 24, {"project_id": "x", "report": {}})
126
- assert exc_info.value.status == 404
127
-
128
-
129
- @respx.mock
130
- def test_post_scan_network_error_is_zero_status() -> None:
131
- respx.post("http://api.test/v1/scans").mock(side_effect=httpx.TimeoutException("slow"))
132
- with pytest.raises(ApiError) as exc_info:
133
- post_scan("http://api.test", "sg_live_" + "x" * 24, {"project_id": "x", "report": {}})
134
- assert exc_info.value.status == 0
135
-
136
-
137
- @respx.mock
138
- def test_list_projects_returns_array() -> None:
139
- rows = [
140
- {"id": "p1", "name": "Default", "organization_id": "org-1"},
141
- ]
142
- respx.get("http://api.test/v1/projects").mock(return_value=httpx.Response(200, json=rows))
143
- result = list_projects("http://api.test", "sg_live_" + "x" * 24, "org-1")
144
- assert result == rows
145
-
146
-
147
- @respx.mock
148
- def test_list_projects_passes_organization_id_param() -> None:
149
- route = respx.get("http://api.test/v1/projects").mock(return_value=httpx.Response(200, json=[]))
150
- list_projects("http://api.test", "sg_live_" + "x" * 24, "org-zzz")
151
- assert route.calls.last.request.url.params.get("organization_id") == "org-zzz"
152
-
153
-
154
- @respx.mock
155
- def test_list_projects_raises_on_error() -> None:
156
- respx.get("http://api.test/v1/projects").mock(
157
- return_value=httpx.Response(403, json={"detail": "forbidden"})
158
- )
159
- with pytest.raises(ApiError) as exc_info:
160
- list_projects("http://api.test", "sg_live_" + "x" * 24, "org-1")
161
- assert exc_info.value.status == 403
162
-
163
-
164
- @respx.mock
165
- def test_list_projects_network_error_is_zero_status() -> None:
166
- respx.get("http://api.test/v1/projects").mock(side_effect=httpx.ConnectError("nope"))
167
- with pytest.raises(ApiError) as exc_info:
168
- list_projects("http://api.test", "sg_live_" + "x" * 24, "org-1")
169
- assert exc_info.value.status == 0
170
-
171
-
172
- def test_extract_detail_prefers_json_detail() -> None:
173
- resp = httpx.Response(400, json={"detail": "bad slug"})
174
- assert _extract_detail(resp) == "bad slug"
175
-
176
-
177
- def test_extract_detail_falls_back_to_text() -> None:
178
- resp = httpx.Response(500, text="kaboom")
179
- assert "kaboom" in _extract_detail(resp)
180
-
181
-
182
- def test_extract_detail_handles_non_string_detail() -> None:
183
- resp = httpx.Response(400, json={"detail": ["a", "b"]})
184
- # detail isn't a string -> falls back to body text
185
- out = _extract_detail(resp)
186
- assert isinstance(out, str)
File without changes
File without changes
File without changes