diff --git a/limacharlie/commands/case_cmd.py b/limacharlie/commands/case_cmd.py index a806ecd..a24cd7e 100644 --- a/limacharlie/commands/case_cmd.py +++ b/limacharlie/commands/case_cmd.py @@ -72,7 +72,7 @@ lifecycle including who made each change and when. Examples: - limacharlie case get --id 42 + limacharlie case get --case-number 42 """ _EXPLAIN_UPDATE = """\ @@ -96,10 +96,10 @@ closed -> in_progress (reopen) Examples: - limacharlie case update --id 42 --status in_progress - limacharlie case update --id 42 --severity high - limacharlie case update --id 42 --assignees alice@example.com - limacharlie case update --id 42 --status resolved \\ + limacharlie case update --case-number 42 --status in_progress + limacharlie case update --case-number 42 --severity high + limacharlie case update --case-number 42 --assignees alice@example.com + limacharlie case update --case-number 42 --status resolved \\ --classification true_positive \\ --conclusion "Contained via network isolation" """ @@ -120,10 +120,10 @@ Provide content via --content, --input-file, or stdin. Examples: - limacharlie case add-note --id 42 --content "Initial triage complete" - limacharlie case add-note --id 42 --type analysis \\ + limacharlie case add-note --case-number 42 --content "Initial triage complete" + limacharlie case add-note --case-number 42 --type analysis \\ --content "Confirmed C2 beacon to 10.0.0.1" - echo "Handoff notes" | limacharlie case add-note --id 42 --type handoff + echo "Handoff notes" | limacharlie case add-note --case-number 42 --type handoff """ _EXPLAIN_BULK_UPDATE = """\ @@ -397,9 +397,9 @@ are skipped. Examples: - limacharlie case export --id 42 - limacharlie case export --id 42 --output json > case.json - limacharlie case export --id 42 --with-data ./case-export + limacharlie case export --case-number 42 + limacharlie case export --case-number 42 --output json > case.json + limacharlie case export --case-number 42 --with-data ./case-export """ _EXPLAIN_CREATE = """\ @@ -463,8 +463,8 @@ replaced with the provided set. Use --tag/-t for each tag value. Examples: - limacharlie case tag set --id 42 --tag phishing - limacharlie case tag set --id 42 -t phishing -t urgent + limacharlie case tag set --case-number 42 --tag phishing + limacharlie case tag set --case-number 42 -t phishing -t urgent """ _EXPLAIN_TAG_ADD = """\ @@ -472,8 +472,8 @@ Duplicate tags are automatically deduplicated. Examples: - limacharlie case tag add --id 42 --tag new-tag - limacharlie case tag add --id 42 -t phishing -t urgent + limacharlie case tag add --case-number 42 --tag new-tag + limacharlie case tag add --case-number 42 -t phishing -t urgent """ _EXPLAIN_TAG_REMOVE = """\ @@ -481,8 +481,8 @@ case are silently ignored. Examples: - limacharlie case tag remove --id 42 --tag old-tag - limacharlie case tag remove --id 42 -t phishing -t urgent + limacharlie case tag remove --case-number 42 --tag old-tag + limacharlie case tag remove --case-number 42 -t phishing -t urgent """ register_explain("case.tag.set", _EXPLAIN_TAG_SET) @@ -647,13 +647,13 @@ def list_cases(ctx, status, severity, classification, assignee, search, # --------------------------------------------------------------------------- @group.command() -@click.option("--id", "case_number", required=True, type=int, help="Case number.") +@click.option("--case-number", "case_number", required=True, type=int, help="Case number.") @pass_context def get(ctx, case_number) -> None: """Get a case with its event timeline. Example: - limacharlie case get --id 42 + limacharlie case get --case-number 42 """ t = _get_cases(ctx) data = t.get_case(case_number) @@ -665,7 +665,7 @@ def get(ctx, case_number) -> None: # --------------------------------------------------------------------------- @group.command() -@click.option("--id", "case_number", required=True, type=int, help="Case number.") +@click.option("--case-number", "case_number", required=True, type=int, help="Case number.") @click.option("--with-data", "output_dir", default=None, type=click.Path(), help="Export with full data to a directory.") @pass_context @@ -678,8 +678,8 @@ def export(ctx, case_number, output_dir) -> None: artifact binaries. Examples: - limacharlie case export --id 42 - limacharlie case export --id 42 --with-data ./out + limacharlie case export --case-number 42 + limacharlie case export --case-number 42 --with-data ./out """ t = _get_cases(ctx) data = t.export_case(case_number) @@ -780,7 +780,7 @@ def _export_with_data(ctx: click.Context, t: Cases, data: dict[str, Any], # --------------------------------------------------------------------------- @group.command() -@click.option("--id", "case_number", required=True, type=int, help="Case number.") +@click.option("--case-number", "case_number", required=True, type=int, help="Case number.") @click.option("--status", default=None, type=_STATUS_CHOICES, help="New status.") @click.option("--severity", default=None, type=_SEVERITY_CHOICES, help="Case severity (critical, high, medium, low, info).") @click.option("--assignees", multiple=True, help="Assignee email (repeatable for multiple assignees).") @@ -796,12 +796,12 @@ def update(ctx, case_number, status, severity, assignees, classification, Only provided fields are changed. Examples: - limacharlie case update --id 42 --status in_progress - limacharlie case update --id 42 --severity high - limacharlie case update --id 42 --assignees alice@example.com - limacharlie case update --id 42 --status resolved \\ + limacharlie case update --case-number 42 --status in_progress + limacharlie case update --case-number 42 --severity high + limacharlie case update --case-number 42 --assignees alice@example.com + limacharlie case update --case-number 42 --status resolved \\ --classification true_positive - limacharlie case update --id 42 --tag phishing --tag urgent + limacharlie case update --case-number 42 --tag phishing --tag urgent """ fields = { "status": status, @@ -829,7 +829,7 @@ def update(ctx, case_number, status, severity, assignees, classification, # --------------------------------------------------------------------------- @group.command("add-note") -@click.option("--id", "case_number", required=True, type=int, help="Case number.") +@click.option("--case-number", "case_number", required=True, type=int, help="Case number.") @click.option("--content", default=None, help="Note content.") @click.option("--input-file", default=None, type=click.Path(exists=True), help="Read note content from file.") @click.option("--type", "note_type", default=None, type=_NOTE_TYPE_CHOICES, @@ -841,10 +841,10 @@ def add_note(ctx, case_number, content, input_file, note_type) -> None: Provide content via --content, --input-file, or stdin. Examples: - limacharlie case add-note --id 42 --content "Triage complete" - limacharlie case add-note --id 42 --type analysis \\ + limacharlie case add-note --case-number 42 --content "Triage complete" + limacharlie case add-note --case-number 42 --type analysis \\ --content "Confirmed C2 beacon" - echo "notes" | limacharlie case add-note --id 42 + echo "notes" | limacharlie case add-note --case-number 42 """ if content: text = content @@ -1423,15 +1423,15 @@ def tag_group() -> None: @tag_group.command("set") -@click.option("--id", "case_number", required=True, type=int, help="Case number.") +@click.option("--case-number", "case_number", required=True, type=int, help="Case number.") @click.option("--tag", "-t", "tags", multiple=True, required=True, help="Tag value (repeatable).") @pass_context def tag_set(ctx, case_number, tags) -> None: """Replace all tags on a case. Examples: - limacharlie case tag set --id 42 --tag phishing - limacharlie case tag set --id 42 -t phishing -t urgent + limacharlie case tag set --case-number 42 --tag phishing + limacharlie case tag set --case-number 42 -t phishing -t urgent """ t = _get_cases(ctx) data = t.update_case(case_number, tags=list(tags)) @@ -1439,15 +1439,15 @@ def tag_set(ctx, case_number, tags) -> None: @tag_group.command("add") -@click.option("--id", "case_number", required=True, type=int, help="Case number.") +@click.option("--case-number", "case_number", required=True, type=int, help="Case number.") @click.option("--tag", "-t", "tags", multiple=True, required=True, help="Tag value to add (repeatable).") @pass_context def tag_add(ctx, case_number, tags) -> None: """Add tags to a case (merged with existing). Examples: - limacharlie case tag add --id 42 --tag new-tag - limacharlie case tag add --id 42 -t phishing -t urgent + limacharlie case tag add --case-number 42 --tag new-tag + limacharlie case tag add --case-number 42 -t phishing -t urgent """ tk = _get_cases(ctx) current = tk.get_case(case_number) @@ -1463,15 +1463,15 @@ def tag_add(ctx, case_number, tags) -> None: @tag_group.command("remove") -@click.option("--id", "case_number", required=True, type=int, help="Case number.") +@click.option("--case-number", "case_number", required=True, type=int, help="Case number.") @click.option("--tag", "-t", "tags", multiple=True, required=True, help="Tag value to remove (repeatable).") @pass_context def tag_remove(ctx, case_number, tags) -> None: """Remove tags from a case. Examples: - limacharlie case tag remove --id 42 --tag old-tag - limacharlie case tag remove --id 42 -t phishing -t urgent + limacharlie case tag remove --case-number 42 --tag old-tag + limacharlie case tag remove --case-number 42 -t phishing -t urgent """ tk = _get_cases(ctx) current = tk.get_case(case_number) diff --git a/limacharlie/sdk/cases.py b/limacharlie/sdk/cases.py index 2cfd740..9487aa9 100644 --- a/limacharlie/sdk/cases.py +++ b/limacharlie/sdk/cases.py @@ -59,9 +59,11 @@ def create_case( """ data: dict[str, Any] = {} if detection is not None: - # The extension schema declares detection as type "json", - # which the platform validates as a JSON string (not a dict). - data["detection"] = json.dumps(detection) + # Pass the detection dict directly — the gzdata encoding + # already JSON-serializes the full data dict, so json.dumps + # here would double-encode it into a string that the LC + # backend drops (schema type "json" expects an object). + data["detection"] = detection if severity is not None: data["severity"] = severity if summary is not None: diff --git a/tests/unit/test_cli_case.py b/tests/unit/test_cli_case.py index 8b67d8b..64aa14c 100644 --- a/tests/unit/test_cli_case.py +++ b/tests/unit/test_cli_case.py @@ -118,7 +118,7 @@ def test_create_with_detection(self): ["case", "create", "--detection", self._SAMPLE_DETECTION, "--summary", "Triage detection"], mock_t_cls, - return_value={"created": 1, "case_id": "tid-new"}, + return_value={"created": 1, "case_number": 1}, ) assert result.exit_code == 0 mock_t.create_case.assert_called_once_with( @@ -136,7 +136,7 @@ def test_create_with_severity_override(self): "--severity", "critical", "--summary", "Critical lateral movement"], mock_t_cls, - return_value={"created": 1, "case_id": "tid-new"}, + return_value={"created": 1, "case_number": 1}, ) assert result.exit_code == 0 mock_t.create_case.assert_called_once_with( @@ -151,7 +151,7 @@ def test_create_without_detection(self): result, mock_t = _invoke( ["case", "create", "--summary", "Manual investigation"], mock_t_cls, - return_value={"created": 1, "case_id": "tid-new"}, + return_value={"created": 1, "case_number": 1}, ) assert result.exit_code == 0 mock_t.create_case.assert_called_once_with( @@ -167,7 +167,7 @@ def test_create_without_detection_with_severity(self): ["case", "create", "--severity", "medium", "--summary", "Medium severity case"], mock_t_cls, - return_value={"created": 1, "case_id": "tid-new"}, + return_value={"created": 1, "case_number": 1}, ) assert result.exit_code == 0 mock_t.create_case.assert_called_once_with( @@ -182,7 +182,7 @@ def test_create_with_summary(self): result, mock_t = _invoke( ["case", "create", "--summary", "Lateral movement detected"], mock_t_cls, - return_value={"created": 1, "case_id": "tid-new"}, + return_value={"created": 1, "case_number": 1}, ) assert result.exit_code == 0 mock_t.create_case.assert_called_once_with( @@ -290,7 +290,7 @@ def test_get_by_id(self): p1, p2, p3 = _patch_cases() with p1, p2, p3 as mock_t_cls: result, mock_t = _invoke( - ["case", "get", "--id", "42"], + ["case", "get", "--case-number", "42"], mock_t_cls, return_value={"case": {"case_id": "tid-1"}, "events": []}, ) @@ -321,7 +321,7 @@ def test_export_by_id(self): "artifacts": {"artifacts": []}, } result, mock_t = _invoke( - ["case", "export", "--id", "42"], + ["case", "export", "--case-number", "42"], mock_t_cls, return_value=export_data, ) @@ -365,7 +365,7 @@ def test_export_with_data_creates_directory(self, tmp_path): runner = CliRunner() result = runner.invoke( - cli, ["--output", "json", "case", "export", "--id", "42", + cli, ["--output", "json", "case", "export", "--case-number", "42", "--with-data", out_dir], ) assert result.exit_code == 0, result.output @@ -415,7 +415,7 @@ def test_export_with_data_skips_on_fetch_error(self, tmp_path): runner = CliRunner() result = runner.invoke( - cli, ["--output", "json", "case", "export", "--id", "42", + cli, ["--output", "json", "case", "export", "--case-number", "42", "--with-data", out_dir], ) assert result.exit_code == 0 @@ -443,7 +443,7 @@ def test_export_with_data_quiet_mode(self, tmp_path): runner = CliRunner() result = runner.invoke( - cli, ["--quiet", "case", "export", "--id", "42", + cli, ["--quiet", "case", "export", "--case-number", "42", "--with-data", out_dir], ) assert result.exit_code == 0 @@ -483,7 +483,7 @@ def test_export_with_data_artifact_export_url(self, tmp_path): runner = CliRunner() result = runner.invoke( - cli, ["--output", "json", "case", "export", "--id", "42", + cli, ["--output", "json", "case", "export", "--case-number", "42", "--with-data", out_dir], ) assert result.exit_code == 0 @@ -514,7 +514,7 @@ def test_export_with_data_telemetry_fetch_error(self, tmp_path): runner = CliRunner() result = runner.invoke( - cli, ["--output", "json", "case", "export", "--id", "42", + cli, ["--output", "json", "case", "export", "--case-number", "42", "--with-data", out_dir], ) assert result.exit_code == 0 @@ -544,7 +544,7 @@ def test_export_with_data_artifact_fetch_error(self, tmp_path): runner = CliRunner() result = runner.invoke( - cli, ["--output", "json", "case", "export", "--id", "42", + cli, ["--output", "json", "case", "export", "--case-number", "42", "--with-data", out_dir], ) assert result.exit_code == 0 @@ -571,7 +571,7 @@ def test_export_with_data_skips_empty_ids(self, tmp_path): runner = CliRunner() result = runner.invoke( - cli, ["--output", "json", "case", "export", "--id", "42", + cli, ["--output", "json", "case", "export", "--case-number", "42", "--with-data", out_dir], ) assert result.exit_code == 0 @@ -589,7 +589,7 @@ def test_update_status(self): p1, p2, p3 = _patch_cases() with p1, p2, p3 as mock_t_cls: result, mock_t = _invoke( - ["case", "update", "--id", "42", "--status", "in_progress"], + ["case", "update", "--case-number", "42", "--status", "in_progress"], mock_t_cls, return_value={"case": {}}, ) @@ -600,7 +600,7 @@ def test_update_multiple_fields(self): p1, p2, p3 = _patch_cases() with p1, p2, p3 as mock_t_cls: result, mock_t = _invoke( - ["case", "update", "--id", "42", + ["case", "update", "--case-number", "42", "--status", "resolved", "--classification", "true_positive", "--assignees", "bob@example.com"], @@ -619,7 +619,7 @@ def test_update_no_fields_error(self): p1, p2, p3 = _patch_cases() with p1, p2, p3 as mock_t_cls: result, mock_t = _invoke( - ["case", "update", "--id", "42"], + ["case", "update", "--case-number", "42"], mock_t_cls, ) assert result.exit_code != 0 @@ -627,7 +627,7 @@ def test_update_no_fields_error(self): def test_update_invalid_status_rejected(self): runner = CliRunner() - result = runner.invoke(cli, ["case", "update", "--id", "1", "--status", "invalid"]) + result = runner.invoke(cli, ["case", "update", "--case-number", "1", "--status", "invalid"]) assert result.exit_code != 0 @@ -641,7 +641,7 @@ def test_add_note_with_content(self): p1, p2, p3 = _patch_cases() with p1, p2, p3 as mock_t_cls: result, mock_t = _invoke( - ["case", "add-note", "--id", "42", "--content", "Triage complete"], + ["case", "add-note", "--case-number", "42", "--content", "Triage complete"], mock_t_cls, return_value={}, ) @@ -652,7 +652,7 @@ def test_add_note_with_type(self): p1, p2, p3 = _patch_cases() with p1, p2, p3 as mock_t_cls: result, mock_t = _invoke( - ["case", "add-note", "--id", "42", "--content", "Analysis", "--type", "analysis"], + ["case", "add-note", "--case-number", "42", "--content", "Analysis", "--type", "analysis"], mock_t_cls, return_value={}, ) @@ -668,7 +668,7 @@ def test_add_note_from_stdin(self): runner = CliRunner() result = runner.invoke( cli, - ["--output", "json", "case", "add-note", "--id", "42"], + ["--output", "json", "case", "add-note", "--case-number", "42"], input="Piped content\n", ) assert result.exit_code == 0 @@ -677,7 +677,7 @@ def test_add_note_from_stdin(self): def test_add_note_invalid_type_rejected(self): runner = CliRunner() result = runner.invoke(cli, [ - "case", "add-note", "--id", "1", "--content", "x", "--type", "invalid", + "case", "add-note", "--case-number", "1", "--content", "x", "--type", "invalid", ]) assert result.exit_code != 0 @@ -1349,7 +1349,7 @@ def test_update_with_tags(self): p1, p2, p3 = _patch_cases() with p1, p2, p3 as mock_t_cls: result, mock_t = _invoke( - ["case", "update", "--id", "1", "--tag", "phishing", "--tag", "urgent"], + ["case", "update", "--case-number", "1", "--tag", "phishing", "--tag", "urgent"], mock_t_cls, return_value={"case": {}}, ) @@ -1362,7 +1362,7 @@ def test_update_with_tags_and_status(self): p1, p2, p3 = _patch_cases() with p1, p2, p3 as mock_t_cls: result, mock_t = _invoke( - ["case", "update", "--id", "1", "--status", "in_progress", + ["case", "update", "--case-number", "1", "--status", "in_progress", "--tag", "phishing"], mock_t_cls, return_value={"case": {}}, @@ -1390,7 +1390,7 @@ def test_tag_set(self): p1, p2, p3 = _patch_cases() with p1, p2, p3 as mock_t_cls: result, mock_t = _invoke( - ["case", "tag", "set", "--id", "1", "--tag", "phishing"], + ["case", "tag", "set", "--case-number", "1", "--tag", "phishing"], mock_t_cls, return_value={"case": {}}, ) @@ -1401,7 +1401,7 @@ def test_tag_set_multiple(self): p1, p2, p3 = _patch_cases() with p1, p2, p3 as mock_t_cls: result, mock_t = _invoke( - ["case", "tag", "set", "--id", "1", "-t", "phishing", "-t", "urgent"], + ["case", "tag", "set", "--case-number", "1", "-t", "phishing", "-t", "urgent"], mock_t_cls, return_value={"case": {}}, ) @@ -1420,7 +1420,7 @@ def test_tag_add_merges_with_existing(self): runner = CliRunner() result = runner.invoke( cli, - ["--output", "json", "case", "tag", "add", "--id", "1", "--tag", "new-tag"], + ["--output", "json", "case", "tag", "add", "--case-number", "1", "--tag", "new-tag"], ) assert result.exit_code == 0 mock_t.get_case.assert_called_once_with(1) @@ -1438,7 +1438,7 @@ def test_tag_add_deduplicates(self): runner = CliRunner() result = runner.invoke( cli, - ["--output", "json", "case", "tag", "add", "--id", "1", "--tag", "phishing"], + ["--output", "json", "case", "tag", "add", "--case-number", "1", "--tag", "phishing"], ) assert result.exit_code == 0 mock_t.update_case.assert_called_once_with(1, tags=["phishing"]) @@ -1455,7 +1455,7 @@ def test_tag_add_with_no_existing_tags(self): runner = CliRunner() result = runner.invoke( cli, - ["--output", "json", "case", "tag", "add", "--id", "1", "--tag", "new-tag"], + ["--output", "json", "case", "tag", "add", "--case-number", "1", "--tag", "new-tag"], ) assert result.exit_code == 0 mock_t.update_case.assert_called_once_with(1, tags=["new-tag"]) @@ -1472,7 +1472,7 @@ def test_tag_remove(self): runner = CliRunner() result = runner.invoke( cli, - ["--output", "json", "case", "tag", "remove", "--id", "1", "--tag", "old-tag"], + ["--output", "json", "case", "tag", "remove", "--case-number", "1", "--tag", "old-tag"], ) assert result.exit_code == 0 mock_t.get_case.assert_called_once_with(1) @@ -1490,7 +1490,7 @@ def test_tag_remove_nonexistent_tag(self): runner = CliRunner() result = runner.invoke( cli, - ["--output", "json", "case", "tag", "remove", "--id", "1", "--tag", "no-such-tag"], + ["--output", "json", "case", "tag", "remove", "--case-number", "1", "--tag", "no-such-tag"], ) assert result.exit_code == 0 mock_t.update_case.assert_called_once_with(1, tags=["keep-tag"]) @@ -1508,7 +1508,7 @@ def test_tag_add_case_insensitive_dedup(self): runner = CliRunner() result = runner.invoke( cli, - ["--output", "json", "case", "tag", "add", "--id", "1", "--tag", "PHISHING"], + ["--output", "json", "case", "tag", "add", "--case-number", "1", "--tag", "PHISHING"], ) assert result.exit_code == 0 # The existing 'phishing' wins; no duplicate added. @@ -1527,7 +1527,7 @@ def test_tag_remove_case_insensitive(self): runner = CliRunner() result = runner.invoke( cli, - ["--output", "json", "case", "tag", "remove", "--id", "1", "--tag", "PHISHING"], + ["--output", "json", "case", "tag", "remove", "--case-number", "1", "--tag", "PHISHING"], ) assert result.exit_code == 0 mock_t.update_case.assert_called_once_with(1, tags=["keep-tag"]) @@ -1539,5 +1539,5 @@ def test_tag_set_requires_id(self): def test_tag_set_requires_tag(self): runner = CliRunner() - result = runner.invoke(cli, ["case", "tag", "set", "--id", "1"]) + result = runner.invoke(cli, ["case", "tag", "set", "--case-number", "1"]) assert result.exit_code != 0 diff --git a/tests/unit/test_sdk_cases.py b/tests/unit/test_sdk_cases.py index b3571a3..01aa717 100644 --- a/tests/unit/test_sdk_cases.py +++ b/tests/unit/test_sdk_cases.py @@ -79,14 +79,14 @@ def test_calls_extension_request(self, cases, mock_org): with patch("limacharlie.sdk.cases.Extensions") as MockExt: mock_ext = MagicMock() MockExt.return_value = mock_ext - mock_ext.request.return_value = {"created": 1, "case_id": "tid-new"} + mock_ext.request.return_value = {"created": 1, "case_number": 1} result = cases.create_case(self._SAMPLE_DETECTION) MockExt.assert_called_once_with(mock_org) mock_ext.request.assert_called_once_with( "ext-cases", "create_case", - data={"detection": json.dumps(self._SAMPLE_DETECTION)}, + data={"detection": self._SAMPLE_DETECTION}, ) - assert result["case_id"] == "tid-new" + assert result["case_number"] == 1 def test_all_optional_fields(self, cases, mock_org): with patch("limacharlie.sdk.cases.Extensions") as MockExt: @@ -100,7 +100,7 @@ def test_all_optional_fields(self, cases, mock_org): ) call_data = mock_ext.request.call_args[1]["data"] assert call_data == { - "detection": json.dumps(self._SAMPLE_DETECTION), + "detection": self._SAMPLE_DETECTION, "severity": "high", "summary": "Test summary", } @@ -144,7 +144,7 @@ def test_with_summary(self, cases, mock_org): ) call_data = mock_ext.request.call_args[1]["data"] assert call_data == { - "detection": json.dumps(self._SAMPLE_DETECTION), + "detection": self._SAMPLE_DETECTION, "severity": "high", "summary": "Lateral movement detected", }