Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
943e689
initial attempt
jaegeral Jan 6, 2026
e52f26d
additional stuff
jaegeral Jan 6, 2026
bf05f88
1. Resolved the Unarchive Deadlock:
jaegeral Jan 6, 2026
45d8bea
api_client fix
jaegeral Jan 6, 2026
fd6f625
linter
jaegeral Jan 6, 2026
9a57b43
permission check
jaegeral Jan 6, 2026
d59f5bf
Merge branch 'master' into 2026-01-06_3654
jaegeral Jan 6, 2026
e9e4f25
permission issues
jaegeral Jan 6, 2026
4693bda
fix test
jaegeral Jan 6, 2026
018e128
lint
jaegeral Jan 6, 2026
db3d945
update handling of missing OS indexes
jaegeral Jan 6, 2026
a839042
fix errors
jaegeral Jan 6, 2026
0f66802
better handling of notfound indexes
jaegeral Jan 6, 2026
ca23b39
unarchive a failed sketch
jaegeral Jan 6, 2026
75e2573
docstrings
jaegeral Jan 6, 2026
5b5132a
Merge branch 'master' into 2026-01-06_3654
jkppr Jan 6, 2026
cd82aff
Update end_to_end_tests/archive_test.py
jaegeral Jan 6, 2026
838a5f8
feedback round 1
jaegeral Jan 6, 2026
99b1755
more tests
jaegeral Jan 6, 2026
bfa4e04
test_delete_shared_index_timeline_safety
jaegeral Jan 6, 2026
17ba6e9
fix
jaegeral Jan 6, 2026
3117841
fix comment
jaegeral Jan 7, 2026
8c9f5be
Merge branch 'master' into 2026-01-06_3654
jaegeral Jan 7, 2026
3cec0b1
fix some lint and refactor
jaegeral Jan 7, 2026
2890e17
make the pagination test more robust and do not matter the order
jaegeral Jan 7, 2026
940383d
Merge branch 'master' into 2026-01-06_3654
jkppr Jan 8, 2026
0bbe0bf
fix
jaegeral Jan 8, 2026
33ee312
linter
jkppr Jan 8, 2026
f5aabcd
Merge branch 'master' into 2026-01-06_3654
jkppr Jan 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions end_to_end_tests/client_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -451,6 +451,16 @@ def test_delete_archived_sketch(self):
str(context.exception),
)

# Unarchive
sketch.unarchive()
self.assertions.assertEqual(sketch.status, "ready")

# Now delete
# allow the admin user to read, write and delete the sketch
sketch.add_to_acl(user_list=["admin"], permissions=["read", "write", "delete"])
admin_sketch_instance = self.admin_api.get_sketch(sketch.id)
admin_sketch_instance.delete(force_delete=True)

def test_modify_sketch_name_description(self):
"""Test modifying a sketch's name and description."""
sketch = self.api.create_sketch(
Expand Down
3 changes: 1 addition & 2 deletions timesketch/api/v1/resources/archive.py
Original file line number Diff line number Diff line change
Expand Up @@ -721,8 +721,7 @@ def _unarchive_sketch(self, sketch: Sketch):

general_advice = " You can use 'tsctl find-inconsistent-archives' to find such sketches." # pylint: disable=line-too-long
warning_msg = f"{base_warning_msg}{specific_advice}{general_advice}"
error_details.append(warning_msg)
errors_occurred = True
logger.warning(warning_msg)

if errors_occurred:
logger.error(
Expand Down
199 changes: 198 additions & 1 deletion timesketch/api/v1/resources_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@
from timesketch.models.sketch import InvestigativeQuestion
from timesketch.models.sketch import InvestigativeQuestionApproach
from timesketch.models.sketch import Facet
from timesketch.models.sketch import Timeline
from timesketch.models.sketch import SearchIndex
from timesketch.models import db_session
from timesketch.api.v1.resources import ResourceMixin


Expand Down Expand Up @@ -292,6 +295,153 @@ def test_archive_sketch(self):
self.assert200(response)
self.assertIn("archived", response.json["objects"][0]["status"][0]["status"])

def test_unarchive_sketch_with_failed_timeline(self):
"""Tests that a sketch can be successfully unarchived even if it contains
a timeline in a 'fail' state. This ensures that users can recover
sketches with problematic timelines and fix them (e.g., by deleting
the failed timeline) after unarchiving.
"""
self.login()

# Create sketch to test with
data = {"name": "test_unarchive_fail", "description": "desc"}
response = self.client.post(
"/api/v1/sketches/",
data=json.dumps(data, ensure_ascii=False),
content_type="application/json",
)
created_id = response.json["objects"][0]["id"]

# Create a failed timeline
# We need to manually add a timeline to the sketch and set it to fail
# relying on BaseTest helper or manual DB session
from timesketch.models import db_session
from timesketch.models.sketch import Sketch

sketch = Sketch.get_by_id(created_id)

# Create a dummy search index
searchindex = SearchIndex(
name="failed_index",
description="failed_index",
user=sketch.user,
index_name="failed_index",
)
searchindex.set_status("fail")
db_session.add(searchindex)

timeline = Timeline(
name="failed_timeline",
description="failed_timeline",
sketch=sketch,
user=sketch.user,
searchindex=searchindex,
)
timeline.set_status("fail")
db_session.add(timeline)
db_session.commit()

# Manually archive the sketch (bypass API check for failed timeline
# during archive if any). Actually, archive API might block it too
# if we don't fix archive API. But we are testing UNARCHIVE here.
# So let's force the state.
sketch.set_status("archived")
timeline.set_status("fail")
db_session.commit()

# Try to unarchive
resource_url = f"/api/v1/sketches/{created_id}/archive/"
data = {"action": "unarchive"}
response = self.client.post(
resource_url,
data=json.dumps(data, ensure_ascii=False),
content_type="application/json",
)

# This should now succeed with 200 OK instead of 500
self.assert200(response)

# Verify sketch is ready
sketch = Sketch.get_by_id(created_id)
self.assertEqual(sketch.get_status.status, "ready")

# Verify failed timeline is still failed (or whatever the logic does)
self.assertEqual(timeline.get_status.status, "fail")

def test_unarchive_sketch_with_mixed_states(self):
"""Tests that a sketch can be successfully unarchived even if it contains
both 'fail' and 'processing' timelines. This confirms that the
unarchive operation is robust against various non-ready timeline
states, allowing users to regain access to the sketch and address
each problematic timeline individually.
"""
self.login()

# Create sketch
data = {"name": "test_unarchive_mixed", "description": "desc"}
response = self.client.post(
"/api/v1/sketches/",
data=json.dumps(data, ensure_ascii=False),
content_type="application/json",
)
created_id = response.json["objects"][0]["id"]

from timesketch.models import db_session
from timesketch.models.sketch import Sketch

sketch = Sketch.get_by_id(created_id)

# Create a failed timeline
idx_fail = SearchIndex(name="idx_fail", user=sketch.user, index_name="idx_fail")
idx_fail.set_status("fail")
db_session.add(idx_fail)
tl_fail = Timeline(
name="tl_fail",
sketch=sketch,
user=sketch.user,
searchindex=idx_fail,
)
tl_fail.set_status("fail")
db_session.add(tl_fail)

# Create a processing timeline
idx_proc = SearchIndex(name="idx_proc", user=sketch.user, index_name="idx_proc")
idx_proc.set_status("processing")
db_session.add(idx_proc)
tl_proc = Timeline(
name="tl_proc",
sketch=sketch,
user=sketch.user,
searchindex=idx_proc,
)
tl_proc.set_status("processing")
db_session.add(tl_proc)

# Set sketch to archived to simulate the stuck state
sketch.set_status("archived")
db_session.commit()

# Try to unarchive
resource_url = f"/api/v1/sketches/{created_id}/archive/"
data = {"action": "unarchive"}
response = self.client.post(
resource_url,
data=json.dumps(data, ensure_ascii=False),
content_type="application/json",
)

# Should succeed
self.assert200(response)

# Verify sketch is ready
sketch = Sketch.get_by_id(created_id)
self.assertEqual(sketch.get_status.status, "ready")

# Verify timelines retained their stuck states (so user can see/fix
# them)
self.assertEqual(tl_fail.get_status.status, "fail")
self.assertEqual(tl_proc.get_status.status, "processing")

def test_sketch_delete_not_existant_sketch(self):
"""Authenticated request to delete a sketch that does not exist."""
self.login()
Expand Down Expand Up @@ -376,10 +526,57 @@ def test_attempt_to_delete_archived_sketch(self):
"test_delete_archive_sketch",
)
self.assert200(response)
self.assertIn("archived", response.json["objects"][0]["status"][0]["status"])
status_list = response.json["objects"][0]["status"]
self.assertIn("archived", status_list[0]["status"])

# Soft delete should fail
response = self.client.delete(f"/api/v1/sketches/{created_id}/")
self.assertEqual(HTTP_STATUS_CODE_BAD_REQUEST, response.status_code)
self.assertEqual(
"Unable to delete an archived sketch, first unarchive then delete "
"or use force delete.",
response.json["message"],
)

# Force delete should also fail because the sketch is archived
# We need to give the admin permission to delete the sketch first
from timesketch.models import db_session
from timesketch.models.sketch import Sketch

sketch = Sketch.get_by_id(created_id)
sketch.grant_permission(permission="delete", user=self.useradmin)
db_session.commit()

self.login_admin()
resource_url = f"/api/v1/sketches/{created_id}/?force=true"
response = self.client.delete(resource_url)
self.assertEqual(HTTP_STATUS_CODE_BAD_REQUEST, response.status_code)
self.assertEqual(
"Unable to delete a sketch that is already archived.",
response.json["message"],
)

# Unarchive the sketch (should succeed now even if we had failed
# timelines, though this test sketch has no timelines)
self.login()
resource_url = f"/api/v1/sketches/{created_id}/archive/"
data = {"action": "unarchive"}
response = self.client.post(
resource_url,
data=json.dumps(data, ensure_ascii=False),
content_type="application/json",
)
self.assert200(response)

# Now delete should succeed (soft)
response = self.client.delete(f"/api/v1/sketches/{created_id}/")
self.assertEqual(HTTP_STATUS_CODE_OK, response.status_code)

# Or force delete (admin)
self.login_admin()
resource_url = f"/api/v1/sketches/{created_id}/?force=true"
response = self.client.delete(resource_url)
self.assertEqual(HTTP_STATUS_CODE_OK, response.status_code)


class ViewListResourceTest(BaseTest):
Expand Down
Loading