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.
- {slopguard_cli-0.2.0 → slopguard_cli-0.3.0}/PKG-INFO +8 -7
- {slopguard_cli-0.2.0 → slopguard_cli-0.3.0}/README.md +7 -6
- {slopguard_cli-0.2.0 → slopguard_cli-0.3.0}/pyproject.toml +1 -1
- {slopguard_cli-0.2.0 → slopguard_cli-0.3.0}/slopguard/__init__.py +1 -1
- {slopguard_cli-0.2.0 → slopguard_cli-0.3.0}/slopguard/cli.py +4 -179
- {slopguard_cli-0.2.0 → slopguard_cli-0.3.0}/slopguard/update.py +7 -13
- slopguard_cli-0.3.0/tests/conftest.py +22 -0
- {slopguard_cli-0.2.0 → slopguard_cli-0.3.0}/tests/test_cli.py +2 -2
- {slopguard_cli-0.2.0 → slopguard_cli-0.3.0}/tests/test_misc.py +2 -2
- {slopguard_cli-0.2.0 → slopguard_cli-0.3.0}/tests/test_update.py +8 -20
- slopguard_cli-0.2.0/slopguard/saas/__init__.py +0 -26
- slopguard_cli-0.2.0/slopguard/saas/client.py +0 -114
- slopguard_cli-0.2.0/slopguard/saas/credentials.py +0 -118
- slopguard_cli-0.2.0/tests/conftest.py +0 -12
- slopguard_cli-0.2.0/tests/test_saas_auth.py +0 -190
- slopguard_cli-0.2.0/tests/test_saas_client.py +0 -186
- {slopguard_cli-0.2.0 → slopguard_cli-0.3.0}/.gitignore +0 -0
- {slopguard_cli-0.2.0 → slopguard_cli-0.3.0}/.ruff.toml +0 -0
- {slopguard_cli-0.2.0 → slopguard_cli-0.3.0}/Makefile +0 -0
- {slopguard_cli-0.2.0 → slopguard_cli-0.3.0}/scripts/generate_seed_data.py +0 -0
- {slopguard_cli-0.2.0 → slopguard_cli-0.3.0}/slopguard/__main__.py +0 -0
- {slopguard_cli-0.2.0 → slopguard_cli-0.3.0}/slopguard/config.py +0 -0
- {slopguard_cli-0.2.0 → slopguard_cli-0.3.0}/slopguard/data/__init__.py +0 -0
- {slopguard_cli-0.2.0 → slopguard_cli-0.3.0}/slopguard/data/hallucinations_seed.json +0 -0
- {slopguard_cli-0.2.0 → slopguard_cli-0.3.0}/slopguard/data/popular_packages.json +0 -0
- {slopguard_cli-0.2.0 → slopguard_cli-0.3.0}/slopguard/models.py +0 -0
- {slopguard_cli-0.2.0 → slopguard_cli-0.3.0}/slopguard/parsers/__init__.py +0 -0
- {slopguard_cli-0.2.0 → slopguard_cli-0.3.0}/slopguard/parsers/base.py +0 -0
- {slopguard_cli-0.2.0 → slopguard_cli-0.3.0}/slopguard/parsers/npm.py +0 -0
- {slopguard_cli-0.2.0 → slopguard_cli-0.3.0}/slopguard/parsers/python.py +0 -0
- {slopguard_cli-0.2.0 → slopguard_cli-0.3.0}/slopguard/registry/__init__.py +0 -0
- {slopguard_cli-0.2.0 → slopguard_cli-0.3.0}/slopguard/registry/base.py +0 -0
- {slopguard_cli-0.2.0 → slopguard_cli-0.3.0}/slopguard/registry/npm.py +0 -0
- {slopguard_cli-0.2.0 → slopguard_cli-0.3.0}/slopguard/registry/pypi.py +0 -0
- {slopguard_cli-0.2.0 → slopguard_cli-0.3.0}/slopguard/report/__init__.py +0 -0
- {slopguard_cli-0.2.0 → slopguard_cli-0.3.0}/slopguard/report/json.py +0 -0
- {slopguard_cli-0.2.0 → slopguard_cli-0.3.0}/slopguard/report/terminal.py +0 -0
- {slopguard_cli-0.2.0 → slopguard_cli-0.3.0}/slopguard/scoring/__init__.py +0 -0
- {slopguard_cli-0.2.0 → slopguard_cli-0.3.0}/slopguard/scoring/engine.py +0 -0
- {slopguard_cli-0.2.0 → slopguard_cli-0.3.0}/slopguard/scoring/signals.py +0 -0
- {slopguard_cli-0.2.0 → slopguard_cli-0.3.0}/tests/__init__.py +0 -0
- {slopguard_cli-0.2.0 → slopguard_cli-0.3.0}/tests/fixtures/.slopguard.yaml +0 -0
- {slopguard_cli-0.2.0 → slopguard_cli-0.3.0}/tests/fixtures/package.json +0 -0
- {slopguard_cli-0.2.0 → slopguard_cli-0.3.0}/tests/fixtures/pyproject.toml +0 -0
- {slopguard_cli-0.2.0 → slopguard_cli-0.3.0}/tests/fixtures/requirements.txt +0 -0
- {slopguard_cli-0.2.0 → slopguard_cli-0.3.0}/tests/test_parsers_npm.py +0 -0
- {slopguard_cli-0.2.0 → slopguard_cli-0.3.0}/tests/test_parsers_python.py +0 -0
- {slopguard_cli-0.2.0 → slopguard_cli-0.3.0}/tests/test_registry_npm.py +0 -0
- {slopguard_cli-0.2.0 → slopguard_cli-0.3.0}/tests/test_registry_pypi.py +0 -0
- {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.
|
|
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
|
|
170
|
+
## What it does NOT do
|
|
171
171
|
|
|
172
|
-
- No
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
|
|
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
|
|
132
|
+
## What it does NOT do
|
|
133
133
|
|
|
134
|
-
- No
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
|
|
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.
|
|
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" }
|
|
@@ -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
|
|
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
|
-
|
|
379
|
-
and caches it at
|
|
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
|
|
4
|
-
|
|
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.
|
|
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
|
-
|
|
39
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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("
|
|
44
|
-
respx.get("http://
|
|
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("
|
|
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://
|
|
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://
|
|
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://
|
|
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
|
|
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
|
|
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
|
|
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,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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|