diff --git a/api_client/python/timesketch_api_client/sketch.py b/api_client/python/timesketch_api_client/sketch.py index ad74426d2a..7615ec7434 100644 --- a/api_client/python/timesketch_api_client/sketch.py +++ b/api_client/python/timesketch_api_client/sketch.py @@ -502,13 +502,17 @@ def delete(self, force_delete=False): # Check the return status. If it's not a success (20x), # error_message will raise a RuntimeError. if not error.check_return_status(response, logger): + if response.status_code == definitions.HTTP_STATUS_CODE_NOT_FOUND: + error.error_message( + response, + message=f"Failed to delete sketch {self.id}", + error=error.NotFoundError, + ) error.error_message( response, message=f"Failed to delete sketch {self.id}", - error=RuntimeError, ) - else: - return error.check_return_status(response, logger) + return True def add_to_acl( diff --git a/cli_client/python/timesketch_cli_client/commands/sketch.py b/cli_client/python/timesketch_cli_client/commands/sketch.py index d1f320ed0c..c5a3a85b42 100644 --- a/cli_client/python/timesketch_cli_client/commands/sketch.py +++ b/cli_client/python/timesketch_cli_client/commands/sketch.py @@ -21,6 +21,7 @@ from timesketch_cli_client.commands import attribute as attribute_command from timesketch_api_client import search +from timesketch_api_client.error import NotFoundError @click.group("sketch") @@ -326,31 +327,72 @@ def delete_sketch(ctx: click.Context, force_delete: bool) -> None: force_delete: If true, delete immediately. """ sketch = ctx.obj.sketch - # if sketch is archived, exit - if sketch.is_archived(): + + # Initialize with default values. Preserve cached sketch_name if it exists. + sketch_name = getattr(sketch, "_sketch_name", None) or "" + sketch_desc = "N/A" + sketch_status = "N/A" + sketch_labels = "N/A" + timelines = [] + + try: + is_archived = sketch.is_archived() + except NotFoundError as e: # pylint: disable=unused-variable + click.echo( + f"Warning: Sketch {sketch.id} appears to be soft-deleted or inaccessible." + ) + if not force_delete: + click.echo("If you want to permanently delete it, use --force_delete") + ctx.exit(1) + is_archived = False + + if is_archived: click.echo("Error Sketch is archived") ctx.exit(1) + try: + sketch_name = sketch.name + sketch_desc = sketch.description + sketch_status = sketch.status + sketch_labels = sketch.labels + timelines = sketch.list_timelines() + except NotFoundError as e: # pylint: disable=unused-variable + pass + # Dryrun: if not force_delete: click.echo("Would delete the following things (use --force_delete to execute)") + click.echo( - f"Sketch: {sketch.id} {sketch.name} {sketch.description} {sketch.status} Labels: {sketch.labels}" # pylint: disable=line-too-long + f"Sketch: {sketch.id} {sketch_name} {sketch_desc} {sketch_status} Labels: {sketch_labels}" # pylint: disable=line-too-long ) - for timeline in sketch.list_timelines(): + for timeline in timelines: + timeline_desc = "N/A" + timeline_status = "N/A" + try: + # timeline.description and timeline.status lazy-load from the API. + timeline_desc = timeline.description + timeline_status = timeline.status + except NotFoundError as e: # pylint: disable=unused-variable + pass click.echo( - f" Timeline: {timeline.id} {timeline.name} {timeline.description} {timeline.status}" # pylint: disable=line-too-long + f" Timeline: {timeline.id} {timeline.name} {timeline_desc} {timeline_status}" # pylint: disable=line-too-long ) if force_delete: # --- Check the response for success or error --- try: sketch.delete(force_delete=force_delete) - click.echo(f"Sketch {sketch.id} '{sketch.name}' successfully deleted.") + click.echo(f"Sketch {sketch.id} '{sketch_name}' successfully deleted.") + except NotFoundError: + click.echo( + f"Failed to delete sketch {sketch.id} '{sketch_name}'. Error: Sketch was not found (perhaps already permanently deleted?)." # pylint: disable=line-too-long + ) + ctx.exit(1) except RuntimeError as e: click.echo( - f"Failed to delete sketch {sketch.id} '{sketch.name}'. Error: {e}" + f"Failed to delete sketch {sketch.id} '{sketch_name}'. Error: {e}" ) ctx.exit(1) diff --git a/end_to_end_tests/cli_client_e2e_test.py b/end_to_end_tests/cli_client_e2e_test.py index 715c591873..ca145cfaf8 100644 --- a/end_to_end_tests/cli_client_e2e_test.py +++ b/end_to_end_tests/cli_client_e2e_test.py @@ -215,6 +215,47 @@ def test_cli_integration(self): self.assertions.assertEqual(result.exit_code, 0, f"Failed: {result.output}") self.assertions.assertEqual(result.output.strip(), "42") + def test_cli_sketch_delete_soft_deleted(self): + """Tests that a soft-deleted sketch can be deleted via CLI.""" + # Create a new sketch to be soft-deleted and then hard-deleted + sketch_name = f"cli_soft_delete_test_{uuid.uuid4().hex}" + sketch = self.api.create_sketch(name=sketch_name) + + # We need a timeline to trigger the loop in sketch.py + self.import_timeline("evtx_part.csv", sketch=sketch) + + # Soft delete the sketch + sketch.delete(force_delete=False) + + # Get a fresh instance of the sketch so cached values are cleared + fresh_sketch = self.api.get_sketch(sketch.id) + + # Now try to delete it via CLI (dry-run first, then force) + cli_ctx_obj = E2ECliContextObject( + api_client=self.api, + sketch_instance=fresh_sketch, + output_format="text", + ) + + # Dry-run + result = self.runner.invoke(sketch_group, ["delete"], obj=cli_ctx_obj) + self.assertions.assertEqual( + result.exit_code, + 1, + f"CLI command 'sketch delete' (dry-run) failed to exit with 1 on soft-deleted sketch.\nOutput:\n{result.output}\nException:\n{result.exception}", # pylint: disable=line-too-long + ) + + # Force-delete + result_force = self.runner.invoke( + sketch_group, ["delete", "--force_delete"], obj=cli_ctx_obj + ) + self.assertions.assertEqual( + result_force.exit_code, + 1, + f"CLI command 'sketch delete --force_delete' unexpectedly succeeded on a soft-deleted sketch for a non-admin.\nOutput:\n{result_force.output}\nException:\n{result_force.exception}", # pylint: disable=line-too-long + ) + self.assertions.assertIn("Failed to delete sketch", result_force.output) + # Register the new test class with the test manager manager.EndToEndTestManager.register_test(CliClientE2ETest)