diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e11674..a4bc2ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,41 @@ # Changelog +## v1.2.0 + +### Added +- `dm ifm` command group + for managing in-flight masking ruleset plans + and running mask operations against the IFM service: + - `dm ifm list` — + list all IFM ruleset plans. + - `dm ifm get ` — + show plan metadata, + or the ruleset YAML with `--yaml`. + - `dm ifm create --name --file ` — + create a plan from a YAML ruleset, + with optional `--enabled/--disabled` and `--log-level`. + - `dm ifm update ` — + update a plan; + pass any of `--file`, `--enabled/--disabled`, `--log-level` + and only those fields are sent. + - `dm ifm delete ` — + delete a plan + (interactive confirm, + or `--yes` to skip). + - `dm ifm mask --data ` — + mask a JSON list of records against a plan, + with `--disable-instance-secret`, + `--run-secret`, + `--log-level`, + `--request-id`, + and `--json/--no-json` (NDJSON) output. + - `dm ifm verify-token` — + verify the current IFM token and list its scopes. + + Authentication reuses your existing `dm` profile credentials + via the SDK's `DataMasqueIfmClient`, + which transparently exchanges admin-server credentials for an IFM JWT. + ## v1.1.0 ### Added diff --git a/README.md b/README.md index 36c07d4..003db54 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ so teams can use production-shaped data in non-production environments without e DataMasque CLI `dm` covers: - connections, rulesets, ruleset libraries, and masking runs +- in-flight masking (IFM) ruleset plans and on-demand mask requests - schema discovery and sensitive-data discovery - users, files, and DataMasque instance administration @@ -166,6 +167,26 @@ dm libraries validate # Re-validate against current dm libraries usage # Show rulesets using it ``` +### In-flight masking + +The IFM service runs alongside the admin server, +reached at `/ifm` via the standard nginx topology. + +```console +dm ifm list # List ruleset plans +dm ifm get # Show plan metadata +dm ifm get --yaml # Print the ruleset YAML +dm ifm create --name myplan --file rules.yaml # Create (server suffixes a random string to the name) +dm ifm create --name myplan --file rules.yaml --disabled --log-level DEBUG +dm ifm update --file rules.yaml # Replace the ruleset YAML +dm ifm update --enabled # Toggle without re-sending the YAML +dm ifm update --log-level INFO +dm ifm delete --yes # Delete a plan +dm ifm mask --data input.json # Mask a JSON list of records +dm ifm mask --data - # Read records from stdin +dm ifm verify-token # Show scopes granted to the current IFM token +``` + ### Masking runs ```console diff --git a/pyproject.toml b/pyproject.toml index 026df31..f4596bb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "datamasque-cli" -version = "1.1.0" +version = "1.2.0" description = "Official command-line interface for the DataMasque data-masking platform." authors = [ { name = "DataMasque Ltd" }, diff --git a/src/datamasque_cli/client.py b/src/datamasque_cli/client.py index cd331bb..73e9c0a 100644 --- a/src/datamasque_cli/client.py +++ b/src/datamasque_cli/client.py @@ -8,9 +8,10 @@ import os -from datamasque.client import DataMasqueClient -from datamasque.client.exceptions import DataMasqueApiError, DataMasqueTransportError +from datamasque.client import DataMasqueClient, DataMasqueIfmClient +from datamasque.client.exceptions import DataMasqueApiError, DataMasqueTransportError, IfmAuthError from datamasque.client.models.dm_instance import DataMasqueInstanceConfig +from datamasque.client.models.ifm import DataMasqueIfmInstanceConfig from datamasque_cli.config import Config, Profile, load_config from datamasque_cli.output import ErrorCode, abort @@ -59,6 +60,33 @@ def _resolve_profile(config: Config, profile_name: str | None) -> Profile: return profile +def _resolve_profile_with_verify(profile_name: str | None) -> tuple[Profile, bool]: + """Resolve the active `Profile` and apply env-var overrides for `verify_ssl`.""" + env_profile = profile_from_env() if profile_name is None else None + if env_profile is not None: + profile = env_profile + else: + config = load_config() + profile = _resolve_profile(config, profile_name) + return profile, _verify_ssl_from_env(default=profile.verify_ssl) + + +def _authenticate_or_abort( + client: DataMasqueClient | DataMasqueIfmClient, + url: str, + *, + verify_ssl: bool, + failure_label: str = "Authentication", + extra_auth_excs: tuple[type[Exception], ...] = (), +) -> None: + try: + client.authenticate() + except DataMasqueTransportError as e: + abort(_format_transport_error(url, e, verify_ssl=verify_ssl), code=ErrorCode.TRANSPORT_ERROR) + except (DataMasqueApiError, *extra_auth_excs) as e: + abort(f"{failure_label} failed: {e}", code=ErrorCode.AUTH_FAILED) + + def get_client(profile_name: str | None = None) -> DataMasqueClient: """Build and authenticate a `DataMasqueClient`. @@ -66,18 +94,11 @@ def get_client(profile_name: str | None = None) -> DataMasqueClient: 1. Environment variables (DATAMASQUE_URL, DATAMASQUE_USERNAME, DATAMASQUE_PASSWORD) 2. Named profile (--profile flag) 3. Active profile from config file - """ - # Env vars take precedence unless a specific profile was requested. - env_profile = profile_from_env() if profile_name is None else None - if env_profile is not None: - profile = env_profile - else: - config = load_config() - profile = _resolve_profile(config, profile_name) - # `DATAMASQUE_VERIFY_SSL` always wins over the stored profile so you can - # flip TLS verification per-call without re-running `dm auth login`. - verify_ssl = _verify_ssl_from_env(default=profile.verify_ssl) + `DATAMASQUE_VERIFY_SSL` always wins over the stored profile so you can + flip TLS verification per-call without re-running `dm auth login`. + """ + profile, verify_ssl = _resolve_profile_with_verify(profile_name) instance_config = DataMasqueInstanceConfig( base_url=profile.url, username=profile.username, @@ -86,14 +107,7 @@ def get_client(profile_name: str | None = None) -> DataMasqueClient: ) client = DataMasqueClient(instance_config) - - try: - client.authenticate() - except DataMasqueTransportError as e: - abort(_format_transport_error(profile.url, e, verify_ssl=verify_ssl), code=ErrorCode.TRANSPORT_ERROR) - except DataMasqueApiError as e: - abort(f"Authentication failed: {e}", code=ErrorCode.AUTH_FAILED) - + _authenticate_or_abort(client, profile.url, verify_ssl=verify_ssl) return client @@ -107,3 +121,30 @@ def _format_transport_error(url: str, error: Exception, *, verify_ssl: bool) -> if verify_ssl and any(term in str(error).lower() for term in _SSL_HINT_TERMS): message += "\nIf this is a self-signed local build, retry with --insecure or set DATAMASQUE_VERIFY_SSL=false." return message + + +def get_ifm_client(profile_name: str | None = None) -> DataMasqueIfmClient: + """Build and authenticate a `DataMasqueIfmClient`. + + Credential resolution order matches `get_client`. + The IFM base URL is derived as `/ifm`, + matching the standard nginx topology that proxies `/ifm/` to the IFM container on the same hostname. + """ + profile, verify_ssl = _resolve_profile_with_verify(profile_name) + instance_config = DataMasqueIfmInstanceConfig( + admin_server_base_url=profile.url, + ifm_base_url=f"{profile.url.rstrip('/')}/ifm", + username=profile.username, + password=profile.password, + verify_ssl=verify_ssl, + ) + + client = DataMasqueIfmClient(instance_config) + _authenticate_or_abort( + client, + profile.url, + verify_ssl=verify_ssl, + failure_label="IFM authentication", + extra_auth_excs=(IfmAuthError,), + ) + return client diff --git a/src/datamasque_cli/commands/ifm.py b/src/datamasque_cli/commands/ifm.py new file mode 100644 index 0000000..1511fc1 --- /dev/null +++ b/src/datamasque_cli/commands/ifm.py @@ -0,0 +1,354 @@ +"""In-flight masking (IFM) commands. + +Wraps `DataMasqueIfmClient` for managing IFM ruleset plans and running mask operations. +The IFM service exposes a separate HTTP API; +the SDK handles JWT auth transparently using the same admin-server credentials as `dm rulesets`. +""" + +from __future__ import annotations + +import json +import sys +from enum import StrEnum +from pathlib import Path +from typing import Any, NoReturn + +import typer +from datamasque.client.exceptions import DataMasqueApiError +from datamasque.client.models.ifm import ( + IfmMaskRequest, + RulesetPlanCreateRequest, + RulesetPlanOptions, + RulesetPlanPartialUpdateRequest, +) + +from datamasque_cli.client import get_ifm_client +from datamasque_cli.output import ErrorCode, abort, print_error, print_json, print_success, render_output + +app = typer.Typer(help="Manage in-flight-masking (IFM) ruleset plans and execute masks.", no_args_is_help=True) + + +# IFM service maps HTTP statuses to the CLI's stable `ErrorCode` taxonomy so +# agents and scripts get the right exit code (see "Exit codes" in `README.md`). +# Anything not listed falls through to `ErrorCode.ERROR` (exit 1). +_STATUS_TO_ERROR_CODE: dict[int, ErrorCode] = { + 400: ErrorCode.INVALID_INPUT, + 404: ErrorCode.NOT_FOUND, + 409: ErrorCode.CONFLICT, + 422: ErrorCode.INVALID_INPUT, +} + + +def _format_pydantic_errors(errors: list[Any]) -> str: + """Flatten FastAPI's `detail` list (Pydantic `e.errors()`) into a readable string. + + Each entry looks like `{"loc": [...], "msg": "...", "type": "..."}`; + we render `field.path: message` per entry, joined with `; `. + Entries that don't match the shape fall back to `str(entry)`. + """ + parts: list[str] = [] + for entry in errors: + if isinstance(entry, dict) and "msg" in entry: + loc = entry.get("loc") or [] + location = ".".join(str(part) for part in loc if part != "body") if isinstance(loc, (list, tuple)) else "" + parts.append(f"{location}: {entry['msg']}" if location else str(entry["msg"])) + else: + parts.append(str(entry)) + return "; ".join(parts) + + +def _server_error_detail(exc: DataMasqueApiError) -> str | None: + """Pull a human-readable error string from the IFM response body, if present. + + The IFM service returns `{"error": "..."}`; + FastAPI validation errors come back as `{"detail": ...}`, + where `detail` is either a string or a list of Pydantic error dicts (422s). + Falls through to `None` if the body is missing or not parseable. + """ + try: + body = exc.response.json() + except (ValueError, AttributeError): + return None + if isinstance(body, dict): + error = body.get("error") + if isinstance(error, str): + return error + if "detail" in body: + detail = body["detail"] + if isinstance(detail, str): + return detail + if isinstance(detail, list): + return _format_pydantic_errors(detail) + return str(detail) + return None + + +def _abort_api_error(prefix: str, exc: DataMasqueApiError) -> NoReturn: + """Map an `DataMasqueApiError` to the right `ErrorCode` and surface the body. + + The default `str(exc)` only includes the HTTP status, + so the actual server message is hidden without this. + """ + status_code = getattr(exc.response, "status_code", None) + code = _STATUS_TO_ERROR_CODE.get(status_code, ErrorCode.ERROR) if isinstance(status_code, int) else ErrorCode.ERROR + detail = _server_error_detail(exc) + message = f"{prefix}: {detail}" if detail else f"{prefix}: {exc}" + abort(message, code=code) + + +class LogLevel(StrEnum): + DEBUG = "DEBUG" + INFO = "INFO" + WARNING = "WARNING" + ERROR = "ERROR" + CRITICAL = "CRITICAL" + + +def _options_from_flags( + enabled: bool | None, + log_level: LogLevel | None, +) -> RulesetPlanOptions | None: + if enabled is None and log_level is None: + return None + return RulesetPlanOptions(enabled=enabled, default_log_level=log_level) + + +def _load_mask_input(data: str) -> list[Any]: + if data == "-": + raw = sys.stdin.read() + else: + try: + raw = Path(data).read_text() + except OSError as exc: + code = ErrorCode.NOT_FOUND if isinstance(exc, FileNotFoundError) else ErrorCode.INVALID_INPUT + abort(f"Could not read mask input file '{data}': {exc.strerror or exc}", code=code) + + try: + parsed = json.loads(raw) + except json.JSONDecodeError as exc: + abort(f"Failed to parse mask input as JSON: {exc}", code=ErrorCode.INVALID_INPUT) + + if not isinstance(parsed, list): + abort("Mask input must be a JSON list (array) of records.", code=ErrorCode.INVALID_INPUT) + return parsed + + +@app.command("list") +def list_plans( + profile: str | None = typer.Option(None, "--profile", "-p", help="Profile to use"), + is_json: bool = typer.Option(False, "--json", help="Output as JSON"), +) -> None: + """List all IFM ruleset plans.""" + client = get_ifm_client(profile) + try: + plans = client.list_ruleset_plans() + except DataMasqueApiError as exc: + _abort_api_error("Failed to list IFM ruleset plans", exc) + + data = [ + { + "name": plan.name, + "serial": plan.serial, + "created": plan.created_time.isoformat(), + "modified": plan.modified_time.isoformat(), + "enabled": plan.options.enabled, + } + for plan in plans + ] + + render_output( + data, + is_json=is_json, + columns=["name", "serial", "created", "modified", "enabled"], + title="IFM ruleset plans", + ) + + +@app.command("get") +def get_plan( + name: str = typer.Argument(help="Ruleset plan name"), + profile: str | None = typer.Option(None, "--profile", "-p", help="Profile to use"), + is_yaml: bool = typer.Option(False, "--yaml", help="Output the ruleset YAML only"), + is_json: bool = typer.Option(False, "--json", help="Output as JSON"), +) -> None: + """Show an IFM ruleset plan's metadata or YAML.""" + client = get_ifm_client(profile) + try: + plan = client.get_ruleset_plan(name) + except DataMasqueApiError as exc: + _abort_api_error(f"Failed to get IFM ruleset plan '{name}'", exc) + + if is_yaml: + if plan.ruleset_yaml is None: + abort(f"IFM ruleset plan '{name}' has no ruleset YAML.") + typer.echo(plan.ruleset_yaml) + return + + data: dict[str, object] = { + "name": plan.name, + "serial": plan.serial, + "created": plan.created_time.isoformat(), + "modified": plan.modified_time.isoformat(), + "enabled": plan.options.enabled, + "default_log_level": plan.options.default_log_level, + "ruleset_yaml": plan.ruleset_yaml, + } + render_output(data, is_json=is_json, title=f"IFM plan: {name}") + + +@app.command("create") +def create_plan( + name: str = typer.Option(..., "--name", help="Ruleset plan name (server may suffix a random string)"), + file: Path = typer.Option(..., "--file", "-f", help="Path to YAML ruleset file", exists=True, readable=True), + enabled: bool | None = typer.Option( + None, + "--enabled/--disabled", + help="Enable or disable the plan immediately. Defaults to the server default.", + ), + log_level: LogLevel | None = typer.Option( + None, + "--log-level", + case_sensitive=False, + help="Default log level.", + ), + profile: str | None = typer.Option(None, "--profile", "-p", help="Profile to use"), +) -> None: + """Create a new IFM ruleset plan from a YAML file.""" + client = get_ifm_client(profile) + request = RulesetPlanCreateRequest( + name=name, + ruleset_yaml=file.read_text(), + options=_options_from_flags(enabled, log_level), + ) + try: + created = client.create_ruleset_plan(request) + except DataMasqueApiError as exc: + _abort_api_error("Failed to create IFM ruleset plan", exc) + + print_success(f"IFM ruleset plan '{created.name}' created (serial {created.serial}).") + if created.url: + typer.echo(created.url) + + +@app.command("update") +def update_plan( + name: str = typer.Argument(help="Existing ruleset plan name"), + file: Path | None = typer.Option( + None, "--file", "-f", help="Path to YAML ruleset file (optional)", exists=True, readable=True + ), + enabled: bool | None = typer.Option(None, "--enabled/--disabled", help="Enable or disable the plan."), + log_level: LogLevel | None = typer.Option(None, "--log-level", case_sensitive=False, help="Default log level."), + profile: str | None = typer.Option(None, "--profile", "-p", help="Profile to use"), +) -> None: + """Update an IFM ruleset plan: only fields you pass are sent.""" + if file is None and enabled is None and log_level is None: + abort( + "Pass at least one of --file, --enabled/--disabled, or --log-level.", + code=ErrorCode.INVALID_INPUT, + ) + + client = get_ifm_client(profile) + request = RulesetPlanPartialUpdateRequest( + ruleset_yaml=file.read_text() if file is not None else None, + options=_options_from_flags(enabled, log_level), + ) + try: + updated = client.patch_ruleset_plan(name, request) + except DataMasqueApiError as exc: + _abort_api_error(f"Failed to update IFM ruleset plan '{name}'", exc) + + print_success(f"IFM ruleset plan '{name}' updated (serial {updated.serial}).") + + +@app.command("delete") +def delete_plan( + name: str = typer.Argument(help="Ruleset plan name to delete"), + profile: str | None = typer.Option(None, "--profile", "-p", help="Profile to use"), + is_confirmed: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation"), +) -> None: + """Delete an IFM ruleset plan.""" + if not is_confirmed: + typer.confirm(f"Delete IFM ruleset plan '{name}'?", abort=True) + + client = get_ifm_client(profile) + try: + client.delete_ruleset_plan(name) + except DataMasqueApiError as exc: + _abort_api_error(f"Failed to delete IFM ruleset plan '{name}'", exc) + + print_success(f"IFM ruleset plan '{name}' deleted.") + + +@app.command("mask") +def mask( + name: str = typer.Argument(help="Ruleset plan name to mask against"), + data: str = typer.Option( + ..., + "--data", + "-d", + help="Path to a JSON file containing a list of records to mask, or '-' to read from stdin.", + ), + disable_instance_secret: bool = typer.Option( + False, "--disable-instance-secret", help="Disable the per-instance secret for this run." + ), + run_secret: str | None = typer.Option(None, "--run-secret", help="Override the run secret for this call."), + log_level: LogLevel | None = typer.Option( + None, "--log-level", case_sensitive=False, help="Override the plan's default log level." + ), + request_id: str | None = typer.Option(None, "--request-id", help="Custom request id (echoed in the response)."), + profile: str | None = typer.Option(None, "--profile", "-p", help="Profile to use"), + is_json: bool = typer.Option( + True, + "--json/--no-json", + help="Output the masked records as a JSON array (default). Use --no-json for NDJSON (one record per line).", + ), +) -> None: + """Run an IFM mask against a list of records.""" + records = _load_mask_input(data) + client = get_ifm_client(profile) + request = IfmMaskRequest( + data=records, + disable_instance_secret=disable_instance_secret or None, + run_secret=run_secret, + log_level=log_level, + request_id=request_id, + ) + + try: + result = client.mask(name, request) + except DataMasqueApiError as exc: + _abort_api_error("Mask request failed", exc) + + if not result.success: + print_error("Mask failed.") + for log in result.logs or []: + print_error(f" [{log.log_level}] {log.timestamp} {log.message}") + raise SystemExit(1) + + if is_json: + print_json(result.data or []) + else: + for record in result.data or []: + typer.echo(json.dumps(record, default=str)) + + +@app.command("verify-token") +def verify_token( + profile: str | None = typer.Option(None, "--profile", "-p", help="Profile to use"), + is_json: bool = typer.Option(False, "--json", help="Output as JSON"), +) -> None: + """Verify the current IFM token and list its scopes.""" + client = get_ifm_client(profile) + try: + info = client.verify_token() + except DataMasqueApiError as exc: + _abort_api_error("Failed to verify IFM token", exc) + if is_json: + print_json({"scopes": info.scopes}) + return + render_output( + [{"scope": scope} for scope in info.scopes], + is_json=False, + columns=["scope"], + title="IFM token scopes", + ) diff --git a/src/datamasque_cli/main.py b/src/datamasque_cli/main.py index 14543ad..8efdbe3 100644 --- a/src/datamasque_cli/main.py +++ b/src/datamasque_cli/main.py @@ -22,6 +22,7 @@ connections, discovery, files, + ifm, ruleset_libraries, rulesets, runs, @@ -47,6 +48,7 @@ app.add_typer(files.app, name="files") app.add_typer(system.app, name="system") app.add_typer(ruleset_libraries.app, name="libraries") +app.add_typer(ifm.app, name="ifm") @app.command() diff --git a/src/datamasque_cli/output.py b/src/datamasque_cli/output.py index 9356ee3..58dafaa 100644 --- a/src/datamasque_cli/output.py +++ b/src/datamasque_cli/output.py @@ -20,6 +20,7 @@ import typer from rich.console import Console from rich.table import Table +from rich.text import Text from rich.theme import Theme _DM_THEME = Theme( @@ -124,6 +125,21 @@ def print_json(data: object) -> None: typer.echo(json.dumps(data, indent=2, default=str)) +def _cell(value: object) -> Text: + """Coerce a cell value into a `Text` so Rich treats it literally. + + Without this, square brackets in YAML inline lists (e.g. `path: [a, b]`) + are parsed by Rich as console markup tags and silently dropped from the + rendered cell. `Text` instances pass through unchanged so callers that + *want* styling (see `style_status`) still work. + """ + if value is None: + return Text("") + if isinstance(value, Text): + return value + return Text(str(value)) + + def print_table( columns: list[str], rows: list[list[Any]], @@ -135,7 +151,7 @@ def print_table( # rather than silently ellipsizing them, so IDs stay copyable in narrow terminals. table.add_column(col, overflow="fold") for row in rows: - table.add_row(*[str(v) if v is not None else "" for v in row]) + table.add_row(*[_cell(v) for v in row]) stdout_console.print(table) @@ -145,7 +161,7 @@ def print_kv(data: dict[str, Any], title: str | None = None) -> None: table.add_column("Key", style="bold") table.add_column("Value", overflow="fold") for key, value in data.items(): - table.add_row(key, str(value) if value is not None else "") + table.add_row(key, _cell(value)) stdout_console.print(table) @@ -171,10 +187,14 @@ def print_info(message: str) -> None: console.print(f"[dim]{message}[/dim]") -def style_status(status: str) -> str: - """Wrap a run status string in the appropriate colour tag.""" - style_name = f"status.{status}" - return f"[{style_name}]{status}[/{style_name}]" +def style_status(status: str) -> Text: + """Wrap a run status string in the appropriate colour tag. + + Returns a `Text` (not a markup string) so it passes through `print_table` + and `print_kv` unchanged. Returning a raw markup string would be re-escaped + by `_cell` and lose its colour. + """ + return Text(status, style=f"status.{status}") def render_output( diff --git a/tests/commands/test_ifm.py b/tests/commands/test_ifm.py new file mode 100644 index 0000000..6081bc1 --- /dev/null +++ b/tests/commands/test_ifm.py @@ -0,0 +1,577 @@ +from __future__ import annotations + +import json +from datetime import UTC, datetime +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +from datamasque.client.exceptions import DataMasqueApiError +from typer.testing import CliRunner + +from datamasque_cli.main import app + +MODULE = "datamasque_cli.commands.ifm" + + +def _options(enabled: bool | None = None, log_level: str | None = None) -> SimpleNamespace: + return SimpleNamespace(enabled=enabled, default_log_level=log_level) + + +def _plan( + name: str = "p1", + serial: int = 1, + *, + yaml: str | None = "tasks: []\n", + options: SimpleNamespace | None = None, + url: str | None = None, +) -> SimpleNamespace: + return SimpleNamespace( + name=name, + serial=serial, + created_time=datetime(2026, 1, 1, tzinfo=UTC), + modified_time=datetime(2026, 1, 2, tzinfo=UTC), + ruleset_yaml=yaml, + options=options or _options(enabled=True), + url=url, + logs=[], + ) + + +@patch(f"{MODULE}.get_ifm_client") +def test_list_renders_plans_as_json(mock_get_client: MagicMock, runner: CliRunner) -> None: + client = MagicMock() + mock_get_client.return_value = client + client.list_ruleset_plans.return_value = [_plan("a", 1), _plan("b", 2)] + + result = runner.invoke(app, ["ifm", "list", "--json"]) + + assert result.exit_code == 0 + rows = json.loads(result.stdout) + assert [r["name"] for r in rows] == ["a", "b"] + assert rows[0]["serial"] == 1 + + +@patch(f"{MODULE}.get_ifm_client") +def test_list_renders_table(mock_get_client: MagicMock, runner: CliRunner) -> None: + client = MagicMock() + mock_get_client.return_value = client + client.list_ruleset_plans.return_value = [_plan("only-plan", 7)] + + result = runner.invoke(app, ["ifm", "list"]) + + assert result.exit_code == 0 + assert "only-plan" in result.stdout + + +@patch(f"{MODULE}.get_ifm_client") +def test_get_yaml_prints_only_yaml(mock_get_client: MagicMock, runner: CliRunner) -> None: + client = MagicMock() + mock_get_client.return_value = client + client.get_ruleset_plan.return_value = _plan("p1", yaml="version: '1.0'\ntasks: []\n") + + result = runner.invoke(app, ["ifm", "get", "p1", "--yaml"]) + + assert result.exit_code == 0 + assert result.stdout.strip() == "version: '1.0'\ntasks: []" + client.get_ruleset_plan.assert_called_once_with("p1") + + +@patch(f"{MODULE}.get_ifm_client") +def test_get_json_dumps_plan(mock_get_client: MagicMock, runner: CliRunner) -> None: + client = MagicMock() + mock_get_client.return_value = client + client.get_ruleset_plan.return_value = _plan("p1", options=_options(enabled=False, log_level="DEBUG")) + + result = runner.invoke(app, ["ifm", "get", "p1", "--json"]) + + assert result.exit_code == 0 + body = json.loads(result.stdout) + assert body["name"] == "p1" + assert body["enabled"] is False + assert body["default_log_level"] == "DEBUG" + + +@patch(f"{MODULE}.get_ifm_client") +def test_create_minimal_omits_options(mock_get_client: MagicMock, runner: CliRunner, tmp_path: Path) -> None: + client = MagicMock() + mock_get_client.return_value = client + client.create_ruleset_plan.return_value = _plan("smoke-abc123", url="http://ifm/ruleset-plans/smoke-abc123/") + + yaml_file = tmp_path / "rs.yaml" + yaml_file.write_text("tasks: []\n") + + result = runner.invoke(app, ["ifm", "create", "--name", "smoke", "--file", str(yaml_file)]) + + assert result.exit_code == 0 + (sent,), _ = client.create_ruleset_plan.call_args + assert sent.name == "smoke" + assert sent.ruleset_yaml == "tasks: []\n" + assert sent.options is None + + +@patch(f"{MODULE}.get_ifm_client") +def test_create_with_options(mock_get_client: MagicMock, runner: CliRunner, tmp_path: Path) -> None: + client = MagicMock() + mock_get_client.return_value = client + client.create_ruleset_plan.return_value = _plan("smoke-abc123") + + yaml_file = tmp_path / "rs.yaml" + yaml_file.write_text("tasks: []\n") + + result = runner.invoke( + app, + ["ifm", "create", "--name", "smoke", "--file", str(yaml_file), "--disabled", "--log-level", "DEBUG"], + ) + + assert result.exit_code == 0 + (sent,), _ = client.create_ruleset_plan.call_args + assert sent.options is not None + assert sent.options.enabled is False + assert sent.options.default_log_level == "DEBUG" + + +@patch(f"{MODULE}.get_ifm_client") +def test_update_with_file_sends_yaml(mock_get_client: MagicMock, runner: CliRunner, tmp_path: Path) -> None: + client = MagicMock() + mock_get_client.return_value = client + client.patch_ruleset_plan.return_value = _plan("p1", serial=2) + + yaml_file = tmp_path / "rs.yaml" + yaml_file.write_text("version: '2.0'\n") + + result = runner.invoke(app, ["ifm", "update", "p1", "--file", str(yaml_file)]) + + assert result.exit_code == 0 + name, sent = client.patch_ruleset_plan.call_args.args + assert name == "p1" + assert sent.ruleset_yaml == "version: '2.0'\n" + + +@patch(f"{MODULE}.get_ifm_client") +def test_update_only_options_omits_yaml(mock_get_client: MagicMock, runner: CliRunner) -> None: + client = MagicMock() + mock_get_client.return_value = client + client.patch_ruleset_plan.return_value = _plan("p1", serial=3) + + result = runner.invoke(app, ["ifm", "update", "p1", "--enabled"]) + + assert result.exit_code == 0 + name, sent = client.patch_ruleset_plan.call_args.args + assert name == "p1" + assert sent.ruleset_yaml is None + body = sent.model_dump(exclude_none=True, mode="json") + assert body == {"options": {"enabled": True}} + + +@patch(f"{MODULE}.get_ifm_client") +def test_update_aborts_when_no_fields_provided(mock_get_client: MagicMock, runner: CliRunner) -> None: + client = MagicMock() + mock_get_client.return_value = client + + result = runner.invoke(app, ["ifm", "update", "p1"]) + + assert result.exit_code == 4 + client.patch_ruleset_plan.assert_not_called() + + +@patch(f"{MODULE}.get_ifm_client") +def test_delete_with_yes_skips_prompt(mock_get_client: MagicMock, runner: CliRunner) -> None: + client = MagicMock() + mock_get_client.return_value = client + + result = runner.invoke(app, ["ifm", "delete", "p1", "--yes"]) + + assert result.exit_code == 0 + client.delete_ruleset_plan.assert_called_once_with("p1") + + +@patch(f"{MODULE}.get_ifm_client") +def test_delete_without_confirmation_aborts(mock_get_client: MagicMock, runner: CliRunner) -> None: + client = MagicMock() + mock_get_client.return_value = client + + result = runner.invoke(app, ["ifm", "delete", "p1"], input="n\n") + + assert result.exit_code != 0 + client.delete_ruleset_plan.assert_not_called() + + +@patch(f"{MODULE}.get_ifm_client") +def test_mask_success_prints_masked_data(mock_get_client: MagicMock, runner: CliRunner, tmp_path: Path) -> None: + client = MagicMock() + mock_get_client.return_value = client + client.mask.return_value = SimpleNamespace( + success=True, + data=[{"id": 1, "email": "***@***.***"}], + logs=[], + ) + + data_file = tmp_path / "in.json" + data_file.write_text(json.dumps([{"id": 1, "email": "a@b.com"}])) + + result = runner.invoke(app, ["ifm", "mask", "p1", "--data", str(data_file)]) + + assert result.exit_code == 0 + assert json.loads(result.stdout) == [{"id": 1, "email": "***@***.***"}] + name, sent = client.mask.call_args.args + assert name == "p1" + assert sent.data == [{"id": 1, "email": "a@b.com"}] + + +@patch(f"{MODULE}.get_ifm_client") +def test_mask_soft_failure_exits_nonzero_and_logs( + mock_get_client: MagicMock, runner: CliRunner, tmp_path: Path +) -> None: + client = MagicMock() + mock_get_client.return_value = client + client.mask.return_value = SimpleNamespace( + success=False, + data=None, + logs=[SimpleNamespace(log_level="error", timestamp="2026-04-20T12:00:00Z", message="bad input")], + ) + + data_file = tmp_path / "in.json" + data_file.write_text("[]") + + result = runner.invoke(app, ["ifm", "mask", "p1", "--data", str(data_file)]) + + assert result.exit_code == 1 + assert "Mask failed." in result.stderr + assert "bad input" in result.stderr + + +@patch(f"{MODULE}.get_ifm_client") +def test_mask_rejects_non_list_input(mock_get_client: MagicMock, runner: CliRunner, tmp_path: Path) -> None: + client = MagicMock() + mock_get_client.return_value = client + + data_file = tmp_path / "in.json" + data_file.write_text(json.dumps({"not": "a list"})) + + result = runner.invoke(app, ["ifm", "mask", "p1", "--data", str(data_file)]) + + assert result.exit_code != 0 + client.mask.assert_not_called() + + +@patch(f"{MODULE}.get_ifm_client") +def test_mask_aborts_when_data_file_missing(mock_get_client: MagicMock, runner: CliRunner, tmp_path: Path) -> None: + client = MagicMock() + mock_get_client.return_value = client + + missing = tmp_path / "does-not-exist.json" + + result = runner.invoke(app, ["ifm", "mask", "p1", "--data", str(missing)]) + + assert result.exit_code != 0 + assert "Could not read mask input file" in result.stderr + assert "Traceback" not in result.stderr + client.mask.assert_not_called() + + +@patch(f"{MODULE}.get_ifm_client") +def test_verify_token_lists_scopes(mock_get_client: MagicMock, runner: CliRunner) -> None: + client = MagicMock() + mock_get_client.return_value = client + client.verify_token.return_value = SimpleNamespace(scopes=["ifm/mask", "ifm/rules:list"]) + + result = runner.invoke(app, ["ifm", "verify-token", "--json"]) + + assert result.exit_code == 0 + assert json.loads(result.stdout) == {"scopes": ["ifm/mask", "ifm/rules:list"]} + + +@patch(f"{MODULE}.get_ifm_client") +def test_verify_token_table_lists_each_scope(mock_get_client: MagicMock, runner: CliRunner) -> None: + client = MagicMock() + mock_get_client.return_value = client + client.verify_token.return_value = SimpleNamespace(scopes=["ifm/mask", "ifm/rules:list"]) + + result = runner.invoke(app, ["ifm", "verify-token"]) + + assert result.exit_code == 0 + assert "ifm/mask" in result.stdout + assert "ifm/rules:list" in result.stdout + + +def _flat(text: str) -> str: + """Collapse whitespace so assertions survive rich's terminal wrapping.""" + return " ".join(text.split()) + + +def _api_error(message: str, *, status_code: int, body: object | str | None = None) -> DataMasqueApiError: + response = MagicMock() + response.status_code = status_code + if isinstance(body, (dict, list)): + response.json.return_value = body + response.text = json.dumps(body) + elif isinstance(body, str): + response.json.side_effect = ValueError("not json") + response.text = body + else: + response.json.side_effect = ValueError("no body") + response.text = "" + return DataMasqueApiError(message, response=response) + + +@patch(f"{MODULE}.get_ifm_client") +def test_list_aborts_on_api_error(mock_get_client: MagicMock, runner: CliRunner) -> None: + client = MagicMock() + mock_get_client.return_value = client + client.list_ruleset_plans.side_effect = _api_error("boom", status_code=500) + + result = runner.invoke(app, ["ifm", "list"]) + + assert result.exit_code == 1 + assert "Failed to list IFM ruleset plans" in result.stderr + + +@patch(f"{MODULE}.get_ifm_client") +def test_get_aborts_on_api_error(mock_get_client: MagicMock, runner: CliRunner) -> None: + client = MagicMock() + mock_get_client.return_value = client + client.get_ruleset_plan.side_effect = _api_error("boom", status_code=500) + + result = runner.invoke(app, ["ifm", "get", "p1"]) + + assert result.exit_code == 1 + assert "Failed to get IFM ruleset plan 'p1'" in result.stderr + + +@patch(f"{MODULE}.get_ifm_client") +def test_verify_token_aborts_on_api_error(mock_get_client: MagicMock, runner: CliRunner) -> None: + client = MagicMock() + mock_get_client.return_value = client + client.verify_token.side_effect = _api_error("boom", status_code=500) + + result = runner.invoke(app, ["ifm", "verify-token"]) + + assert result.exit_code == 1 + assert "Failed to verify IFM token" in result.stderr + + +@patch(f"{MODULE}.get_ifm_client") +def test_get_404_exits_with_not_found_code(mock_get_client: MagicMock, runner: CliRunner) -> None: + client = MagicMock() + mock_get_client.return_value = client + client.get_ruleset_plan.side_effect = _api_error( + "boom", + status_code=404, + body={"error": "Ruleset plan 'p1' not found."}, + ) + + result = runner.invoke(app, ["ifm", "get", "p1"]) + + assert result.exit_code == 3 + assert "Ruleset plan 'p1' not found." in result.stderr + + +@patch(f"{MODULE}.get_ifm_client") +def test_create_400_surfaces_server_error_body(mock_get_client: MagicMock, runner: CliRunner, tmp_path: Path) -> None: + client = MagicMock() + mock_get_client.return_value = client + client.create_ruleset_plan.side_effect = _api_error( + "boom", + status_code=400, + body={"error": "Invalid ruleset YAML: unknown mask type 'from_invalid'."}, + ) + + yaml_file = tmp_path / "rs.yaml" + yaml_file.write_text("tasks: []\n") + + result = runner.invoke(app, ["ifm", "create", "--name", "smoke", "--file", str(yaml_file)]) + + assert result.exit_code == 4 + assert "unknown mask type 'from_invalid'" in _flat(result.stderr) + + +@patch(f"{MODULE}.get_ifm_client") +def test_mask_400_surfaces_server_error_body(mock_get_client: MagicMock, runner: CliRunner, tmp_path: Path) -> None: + client = MagicMock() + mock_get_client.return_value = client + client.mask.side_effect = _api_error( + "boom", + status_code=400, + body={"error": "Invalid masking parameters: Run secret length must be at least 20 characters."}, + ) + + data_file = tmp_path / "in.json" + data_file.write_text("[]") + + result = runner.invoke(app, ["ifm", "mask", "p1", "--data", str(data_file), "--run-secret", "short"]) + + assert result.exit_code == 4 + assert "Run secret length must be at least 20 characters." in _flat(result.stderr) + + +@patch(f"{MODULE}.get_ifm_client") +def test_update_404_exits_with_not_found_code(mock_get_client: MagicMock, runner: CliRunner) -> None: + client = MagicMock() + mock_get_client.return_value = client + client.patch_ruleset_plan.side_effect = _api_error( + "boom", + status_code=404, + body={"error": "Ruleset plan 'p1' not found."}, + ) + + result = runner.invoke(app, ["ifm", "update", "p1", "--enabled"]) + + assert result.exit_code == 3 + assert "Ruleset plan 'p1' not found." in result.stderr + + +@patch(f"{MODULE}.get_ifm_client") +def test_delete_404_exits_with_not_found_code(mock_get_client: MagicMock, runner: CliRunner) -> None: + client = MagicMock() + mock_get_client.return_value = client + client.delete_ruleset_plan.side_effect = _api_error( + "boom", + status_code=404, + body={"error": "Ruleset plan 'p1' not found."}, + ) + + result = runner.invoke(app, ["ifm", "delete", "p1", "--yes"]) + + assert result.exit_code == 3 + assert "Ruleset plan 'p1' not found." in result.stderr + + +@patch(f"{MODULE}.get_ifm_client") +def test_create_409_exits_with_conflict_code(mock_get_client: MagicMock, runner: CliRunner, tmp_path: Path) -> None: + client = MagicMock() + mock_get_client.return_value = client + client.create_ruleset_plan.side_effect = _api_error( + "boom", + status_code=409, + body={"error": "A ruleset plan named 'smoke' already exists."}, + ) + + yaml_file = tmp_path / "rs.yaml" + yaml_file.write_text("tasks: []\n") + + result = runner.invoke(app, ["ifm", "create", "--name", "smoke", "--file", str(yaml_file)]) + + assert result.exit_code == 8 + assert "already exists" in _flat(result.stderr) + + +@patch(f"{MODULE}.get_ifm_client") +def test_get_404_falls_back_when_body_not_json(mock_get_client: MagicMock, runner: CliRunner) -> None: + client = MagicMock() + mock_get_client.return_value = client + client.get_ruleset_plan.side_effect = _api_error("boom", status_code=404, body="not found") + + result = runner.invoke(app, ["ifm", "get", "p1"]) + + assert result.exit_code == 3 + assert "Failed to get IFM ruleset plan 'p1'" in result.stderr + + +@patch(f"{MODULE}.get_ifm_client") +def test_get_extracts_fastapi_detail_field(mock_get_client: MagicMock, runner: CliRunner) -> None: + client = MagicMock() + mock_get_client.return_value = client + client.get_ruleset_plan.side_effect = _api_error( + "boom", + status_code=400, + body={"detail": "validation failed on field 'name'"}, + ) + + result = runner.invoke(app, ["ifm", "get", "p1"]) + + assert result.exit_code == 4 + assert "validation failed on field 'name'" in result.stderr + + +@patch(f"{MODULE}.get_ifm_client") +def test_mask_ndjson_emits_one_record_per_line(mock_get_client: MagicMock, runner: CliRunner, tmp_path: Path) -> None: + client = MagicMock() + mock_get_client.return_value = client + client.mask.return_value = SimpleNamespace( + success=True, + data=[{"id": 1, "email": "***"}, {"id": 2, "email": "***"}], + logs=[], + ) + + data_file = tmp_path / "in.json" + data_file.write_text(json.dumps([{"id": 1}, {"id": 2}])) + + result = runner.invoke(app, ["ifm", "mask", "p1", "--data", str(data_file), "--no-json"]) + + assert result.exit_code == 0 + lines = [line for line in result.stdout.splitlines() if line.strip()] + assert [json.loads(line) for line in lines] == [ + {"id": 1, "email": "***"}, + {"id": 2, "email": "***"}, + ] + + +@patch(f"{MODULE}.get_ifm_client") +def test_mask_ndjson_with_no_data_prints_nothing(mock_get_client: MagicMock, runner: CliRunner, tmp_path: Path) -> None: + client = MagicMock() + mock_get_client.return_value = client + client.mask.return_value = SimpleNamespace(success=True, data=None, logs=[]) + + data_file = tmp_path / "in.json" + data_file.write_text("[]") + + result = runner.invoke(app, ["ifm", "mask", "p1", "--data", str(data_file), "--no-json"]) + + assert result.exit_code == 0 + assert result.stdout.strip() == "" + + +@patch(f"{MODULE}.get_ifm_client") +def test_get_formats_pydantic_422_detail_list(mock_get_client: MagicMock, runner: CliRunner) -> None: + client = MagicMock() + mock_get_client.return_value = client + client.get_ruleset_plan.side_effect = _api_error( + "boom", + status_code=422, + body={ + "detail": [ + {"loc": ["body", "name"], "msg": "field required", "type": "value_error.missing"}, + {"loc": ["body", "options", "log_level"], "msg": "invalid choice", "type": "value_error"}, + ] + }, + ) + + result = runner.invoke(app, ["ifm", "get", "p1"]) + + assert result.exit_code == 4 + flat = _flat(result.stderr) + assert "name: field required" in flat + assert "options.log_level: invalid choice" in flat + + +@patch(f"{MODULE}.get_ifm_client") +def test_mask_emits_empty_array_when_data_none(mock_get_client: MagicMock, runner: CliRunner, tmp_path: Path) -> None: + client = MagicMock() + mock_get_client.return_value = client + client.mask.return_value = SimpleNamespace(success=True, data=None, logs=[]) + + data_file = tmp_path / "in.json" + data_file.write_text("[]") + + result = runner.invoke(app, ["ifm", "mask", "p1", "--data", str(data_file)]) + + assert result.exit_code == 0 + assert json.loads(result.stdout) == [] + + +@patch(f"{MODULE}.get_ifm_client") +def test_create_rejects_invalid_log_level(mock_get_client: MagicMock, runner: CliRunner, tmp_path: Path) -> None: + client = MagicMock() + mock_get_client.return_value = client + + yaml_file = tmp_path / "rs.yaml" + yaml_file.write_text("tasks: []\n") + + result = runner.invoke( + app, + ["ifm", "create", "--name", "smoke", "--file", str(yaml_file), "--log-level", "TRACE"], + ) + + assert result.exit_code != 0 + client.create_ruleset_plan.assert_not_called() diff --git a/tests/test_client_ifm.py b/tests/test_client_ifm.py new file mode 100644 index 0000000..bd5b01b --- /dev/null +++ b/tests/test_client_ifm.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest + +from datamasque_cli.client import get_ifm_client +from datamasque_cli.config import Config, Profile + + +@patch("datamasque_cli.client.DataMasqueIfmInstanceConfig") +@patch("datamasque_cli.client.DataMasqueIfmClient") +@patch("datamasque_cli.client.load_config") +def test_get_ifm_client_passes_derived_ifm_url( + mock_load: MagicMock, + _mock_client_cls: MagicMock, + mock_instance_cfg: MagicMock, +) -> None: + config = Config() + config.set_profile("dev", Profile(url="https://dm.example.com", username="u", password="p")) + mock_load.return_value = config + + get_ifm_client(profile_name="dev") + + _, kwargs = mock_instance_cfg.call_args + assert kwargs["admin_server_base_url"] == "https://dm.example.com" + assert kwargs["ifm_base_url"] == "https://dm.example.com/ifm" + + +@patch("datamasque_cli.client.DataMasqueIfmInstanceConfig") +@patch("datamasque_cli.client.DataMasqueIfmClient") +@patch("datamasque_cli.client.load_config") +def test_get_ifm_client_strips_trailing_slash_on_admin_url( + mock_load: MagicMock, + _mock_client_cls: MagicMock, + mock_instance_cfg: MagicMock, +) -> None: + config = Config() + config.set_profile("dev", Profile(url="https://dm.example.com/", username="u", password="p")) + mock_load.return_value = config + + get_ifm_client(profile_name="dev") + + _, kwargs = mock_instance_cfg.call_args + assert kwargs["ifm_base_url"] == "https://dm.example.com/ifm" + + +@patch("datamasque_cli.client.DataMasqueIfmInstanceConfig") +@patch("datamasque_cli.client.DataMasqueIfmClient") +@patch("datamasque_cli.client.load_config") +def test_get_ifm_client_uses_env_profile( + _mock_load: MagicMock, + _mock_client_cls: MagicMock, + mock_instance_cfg: MagicMock, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("DATAMASQUE_URL", "https://env.example.com") + monkeypatch.setenv("DATAMASQUE_USERNAME", "env_user") + monkeypatch.setenv("DATAMASQUE_PASSWORD", "env_pass") + + get_ifm_client() + + _, kwargs = mock_instance_cfg.call_args + assert kwargs["admin_server_base_url"] == "https://env.example.com" + assert kwargs["ifm_base_url"] == "https://env.example.com/ifm" diff --git a/uv.lock b/uv.lock index a90ee89..d864fa3 100644 --- a/uv.lock +++ b/uv.lock @@ -141,7 +141,7 @@ wheels = [ [[package]] name = "datamasque-cli" -version = "1.1.0" +version = "1.2.0" source = { editable = "." } dependencies = [ { name = "datamasque-python" },