Skip to content

feat(ui,api): howler case functionality#332

Draft
cccs-mdr wants to merge 167 commits into
developfrom
cases
Draft

feat(ui,api): howler case functionality#332
cccs-mdr wants to merge 167 commits into
developfrom
cases

Conversation

@cccs-mdr

Copy link
Copy Markdown
Collaborator

No description provided.

@github-actions

github-actions Bot commented Apr 28, 2026

Copy link
Copy Markdown
Contributor

Coverage Report

Status Category Percentage Covered / Total
🔵 Lines 19.71% 5864 / 29746
🔵 Statements 19.71% 5864 / 29746
🔵 Functions 34.53% 249 / 721
🔵 Branches 74.31% 1221 / 1643
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
ui/src/api/index.ts 75.8% 57.69% 76.92% 75.8% 85-86, 99-100, 134-139, 143-144, 178-179, 191-192, 203-236, 292-293
ui/src/api/search/case.ts 50% 100% 0% 50% 7-8, 11-12
ui/src/api/search/index.ts 100% 100% 100% 100%
ui/src/api/search/facet/hit.ts 100% 50% 100% 100%
ui/src/api/search/facet/index.ts 100% 100% 100% 100%
ui/src/api/socket/index.ts 100% 100% 100% 100%
ui/src/api/socket/viewers.ts 100% 66.66% 100% 100%
ui/src/api/v2/index.ts 100% 100% 100% 100%
ui/src/api/v2/case/index.ts 50% 100% 0% 50% 10-11, 14-15, 18-19, 22-23, 26-27
ui/src/api/v2/case/items.ts 38.88% 100% 0% 38.88% 9-10, 13-14, 17-22, 25-26
ui/src/api/v2/case/rules.ts 38.88% 100% 0% 38.88% 9-14, 17-18, 21-22, 25-26
ui/src/api/v2/search/facet.ts 35.71% 100% 0% 35.71% 7-8, 11-19
ui/src/api/v2/search/index.ts 68% 33.33% 100% 68% 17-18, 21-22, 25-26, 29-30
ui/src/components/app/App.tsx 0% 0% 0% 0% 1-490
ui/src/components/app/hooks/useMatchers.tsx 76.59% 86.66% 100% 76.59% 23-24, 48-49, 73-74, 89-107
ui/src/components/app/hooks/useTitle.tsx 0% 0% 0% 0% 1-81
ui/src/components/app/providers/FavouritesProvider.tsx 0% 0% 0% 0% 1-158
ui/src/components/app/providers/ModalProvider.tsx 23.33% 100% 0% 23.33% 26-56
ui/src/components/app/providers/ParameterProvider.tsx 98.13% 94.87% 83.33% 98.13% 274-275, 396-399
ui/src/components/app/providers/SocketProvider.tsx 8.14% 100% 0% 8.14% 83-358
ui/src/components/app/providers/UserListProvider.tsx 10.9% 100% 0% 10.9% 16-78
ui/src/components/elements/ContextMenu.tsx 94.36% 92% 100% 94.36% 111-116, 146-147
ui/src/components/elements/PluginTypography.tsx 0% 0% 0% 0% 1-36
ui/src/components/elements/UserList.tsx 0% 0% 0% 0% 1-106
ui/src/components/elements/addons/search/phrase/Phrase.tsx 0% 0% 0% 0% 1-175
ui/src/components/elements/case/CaseCard.tsx 0% 0% 0% 0% 1-185
ui/src/components/elements/case/CasePreview.tsx 0% 0% 0% 0% 1-70
ui/src/components/elements/case/StatusIcon.tsx 0% 0% 0% 0% 1-20
ui/src/components/elements/display/ChipPopper.tsx 100% 95.83% 85.71% 100%
ui/src/components/elements/display/HowlerCard.tsx 0% 100% 100% 0% 2-8
ui/src/components/elements/display/Modal.tsx 0% 0% 0% 0% 1-46
ui/src/components/elements/hit/HitActions.tsx 0% 0% 0% 0% 1-293
ui/src/components/elements/hit/HitBanner.tsx 0% 0% 0% 0% 1-336
ui/src/components/elements/hit/HitCard.tsx 0% 0% 0% 0% 1-45
ui/src/components/elements/hit/HitLabels.tsx 0% 0% 0% 0% 1-265
ui/src/components/elements/hit/HitOutline.tsx 0% 0% 0% 0% 1-69
ui/src/components/elements/hit/HitSummary.tsx 0% 0% 0% 0% 1-292
ui/src/components/elements/hit/aggregate/HitGraph.tsx 0% 0% 0% 0% 1-347
ui/src/components/elements/hit/elements/AnalyticLink.tsx 0% 0% 0% 0% 1-54
ui/src/components/elements/hit/elements/Assigned.tsx 98.18% 25% 100% 98.18% 30
ui/src/components/elements/hit/outlines/DefaultOutline.tsx 0% 0% 0% 0% 1-108
ui/src/components/elements/hit/related/RelatedRecords.tsx 0% 0% 0% 0% 1-120
ui/src/components/elements/observable/ObservableCard.tsx 0% 0% 0% 0% 1-33
ui/src/components/elements/observable/ObservablePreview.tsx 0% 0% 0% 0% 1-60
ui/src/components/elements/record/RecordContextMenu.tsx 98.49% 94.66% 88.88% 98.49% 285-286, 311-312
ui/src/components/elements/record/RecordRelated.tsx 0% 0% 0% 0% 1-107
ui/src/components/elements/view/ViewTitle.tsx 0% 0% 0% 0% 1-69
ui/src/components/hooks/useHitActions.tsx 0% 0% 0% 0% 1-277
ui/src/components/hooks/useMyPreferences.tsx 0% 0% 0% 0% 1-344
ui/src/components/hooks/useMySearch.tsx 0% 0% 0% 0% 1-77
ui/src/components/hooks/useMySitemap.tsx 0% 0% 0% 0% 1-252
ui/src/components/hooks/useMyTheme.tsx 89.18% 100% 0% 89.18% 36-39
ui/src/components/hooks/useRelatedRecords.tsx 0% 0% 0% 0% 1-48
ui/src/components/routes/action/edit/ActionEditor.tsx 0% 0% 0% 0% 1-358
ui/src/components/routes/action/view/ActionSearch.tsx 0% 0% 0% 0% 1-251
ui/src/components/routes/advanced/QueryBuilder.tsx 0% 0% 0% 0% 1-601
ui/src/components/routes/advanced/QueryEditor.tsx 0% 100% 100% 0% 2-151
ui/src/components/routes/advanced/historyCompletionProvider.ts 0% 0% 0% 0% 1-61
ui/src/components/routes/analytics/AnalyticDetails.tsx 0% 0% 0% 0% 1-343
ui/src/components/routes/analytics/AnalyticSearch.tsx 0% 0% 0% 0% 1-301
ui/src/components/routes/analytics/widgets/Assessment.tsx 0% 0% 0% 0% 1-71
ui/src/components/routes/analytics/widgets/Escalation.tsx 0% 0% 0% 0% 1-65
ui/src/components/routes/cases/CaseViewer.tsx 92.15% 66.66% 50% 92.15% 18-19, 41-42
ui/src/components/routes/cases/Cases.tsx 0% 0% 0% 0% 1-225
ui/src/components/routes/cases/constants.ts 100% 100% 100% 100%
ui/src/components/routes/cases/detail/AlertPanel.tsx 0% 0% 0% 0% 1-63
ui/src/components/routes/cases/detail/CaseAssets.tsx 98.52% 91.93% 100% 98.52% 136-137
ui/src/components/routes/cases/detail/CaseDashboard.tsx 0% 0% 0% 0% 1-126
ui/src/components/routes/cases/detail/CaseDetails.tsx 0% 0% 0% 0% 1-203
ui/src/components/routes/cases/detail/CaseOverview.tsx 0% 0% 0% 0% 1-98
ui/src/components/routes/cases/detail/CaseRules.tsx 93.65% 66.66% 83.33% 93.65% 43-44, 55-56, 76-77, 86-87
ui/src/components/routes/cases/detail/CaseSidebar.tsx 95.62% 76.92% 100% 95.62% 76-78, 94-95, 114-115, 166
ui/src/components/routes/cases/detail/CaseTask.tsx 0% 0% 0% 0% 1-175
ui/src/components/routes/cases/detail/CaseTimeline.tsx 97.89% 85.96% 100% 97.89% 90-91, 133-134
ui/src/components/routes/cases/detail/CreateRuleDialog.tsx 88.14% 80% 88.88% 88.14% 105-106, 124, 132-142, 150-159, 261-262, 280-281, 303-306
ui/src/components/routes/cases/detail/ItemPage.tsx 0% 0% 0% 0% 1-126
ui/src/components/routes/cases/detail/RelatedCasePanel.tsx 0% 0% 0% 0% 1-64
ui/src/components/routes/cases/detail/TaskPanel.tsx 0% 0% 0% 0% 1-96
ui/src/components/routes/cases/detail/aggregates/CaseAggregate.tsx 0% 0% 0% 0% 1-81
ui/src/components/routes/cases/detail/aggregates/SourceAggregate.tsx 0% 0% 0% 0% 1-43
ui/src/components/routes/cases/detail/assets/Asset.tsx 100% 100% 100% 100%
ui/src/components/routes/cases/detail/sidebar/CaseFolder.tsx 98.6% 77.19% 100% 98.6% 75-76
ui/src/components/routes/cases/detail/sidebar/CaseFolderContextMenu.tsx 100% 87.8% 100% 100%
ui/src/components/routes/cases/detail/sidebar/FolderEntry.tsx 97.52% 95.65% 100% 97.52% 123-125
ui/src/components/routes/cases/detail/sidebar/RootDropZone.tsx 0% 0% 0% 0% 1-45
ui/src/components/routes/cases/detail/sidebar/utils.ts 100% 100% 100% 100%
ui/src/components/routes/cases/hooks/useCase.ts 63.23% 92.85% 100% 63.23% 47-48, 64-88
ui/src/components/routes/cases/modals/AddToCaseModal.tsx 98% 88.46% 100% 98% 49-50
ui/src/components/routes/cases/modals/CaseRecordRow.tsx 100% 100% 100% 100%
ui/src/components/routes/cases/modals/CreateCaseModal.tsx 98.13% 96.29% 100% 98.13% 38-39
ui/src/components/routes/cases/modals/RenameItemModal.tsx 0% 0% 0% 0% 1-94
ui/src/components/routes/cases/modals/ResolveModal.tsx 98.97% 95.12% 100% 98.97% 41-42
ui/src/components/routes/cases/modals/hooks.ts 95.55% 88.88% 100% 95.55% 24-25
ui/src/components/routes/cases/search/CaseAssigneeFilter.tsx 96.49% 88.23% 100% 96.49% 28-29
ui/src/components/routes/cases/search/CaseDateFilter.tsx 100% 100% 100% 100%
ui/src/components/routes/cases/search/CaseStatusFilter.tsx 100% 100% 100% 100%
ui/src/components/routes/dossiers/DossierEditor.tsx 74.89% 64.1% 100% 74.89% 60-61, 72-73, 76-77, 85-87, 90-92, 95-97, 110-112, 117-119, 122-124, 127-129, 132-134, 137-139, 142-144, 147-148, 151-153, 156-158, 161-163, 170-184
ui/src/components/routes/help/ApiDocumentation.tsx 0% 0% 0% 0% 1-217
ui/src/components/routes/help/HitBannerDocumentation.tsx 0% 0% 0% 0% 1-72
ui/src/components/routes/help/HitDocumentation.tsx 0% 0% 0% 0% 1-70
ui/src/components/routes/hits/search/InformationPane.tsx 0% 0% 0% 0% 1-411
ui/src/components/routes/hits/search/LayoutSettings.tsx 0% 0% 0% 0% 1-147
ui/src/components/routes/hits/search/QuerySettings.tsx 100% 100% 100% 100%
ui/src/components/routes/hits/search/SearchPane.tsx 0% 0% 0% 0% 1-215
ui/src/components/routes/hits/search/ViewLink.tsx 100% 93.33% 71.42% 100%
ui/src/components/routes/hits/search/grid/AddColumnModal.tsx 0% 0% 0% 0% 1-89
ui/src/components/routes/hits/search/grid/EnhancedCell.tsx 0% 0% 0% 0% 1-54
ui/src/components/routes/hits/search/grid/HitGrid.tsx 0% 0% 0% 0% 1-324
ui/src/components/routes/hits/search/shared/IndexPicker.tsx 0% 0% 0% 0% 1-42
ui/src/components/routes/hits/view/HitViewer.tsx 0% 0% 0% 0% 1-349
ui/src/components/routes/home/ViewCard.tsx 0% 0% 0% 0% 1-260
ui/src/components/routes/observables/ObservableViewer.tsx 0% 0% 0% 0% 1-37
ui/src/components/routes/overviews/OverviewViewer.tsx 0% 0% 0% 0% 1-387
ui/src/components/routes/views/ViewComposer.tsx 0% 100% 100% 0% 2-351
ui/src/plugins/clue/utils.ts 0% 0% 0% 0% 1-27
ui/src/plugins/clue/components/ClueTypography.tsx 0% 100% 100% 0% 2-58
ui/src/utils/constants.tsx 100% 100% 100% 100%
ui/src/utils/hitFunctions.ts 0% 100% 100% 0% 4-11
ui/src/utils/socketUtils.ts 100% 100% 100% 100%
ui/src/utils/typeUtils.ts 40.74% 100% 33.33% 40.74% 19-28, 31-40
ui/src/utils/viewUtils.ts 0% 100% 100% 0% 3-21
Generated in workflow #860 for commit 15567d3 by the Vitest Coverage Report Action

@github-actions

Copy link
Copy Markdown
Contributor

Static Badge

Howler Evidence Plugin - Coverage Results

Static Badge Static Badge

Diff Coverage

Diff: origin/main...HEAD, staged and unstaged changes

  • plugins/evidence/evidence/odm/models/evidence.py (100%)

Summary

  • Total: 1 line
  • Missing: 0 lines
  • Coverage: 100%

Full Coverage Report

Expand
Name                              Stmts   Miss Branch BrPart  Cover
-------------------------------------------------------------------
evidence/__init__.py                  0      0      0      0   100%
evidence/config.py                   11     11      0      0     0%
evidence/odm/hit.py                  13      2      0      0    85%
evidence/odm/models/evidence.py      66      0      0      0   100%
-------------------------------------------------------------------
TOTAL                                90     13      0      0    86%

@github-actions

Copy link
Copy Markdown
Contributor

Static Badge

Howler Sentinel Plugin - Coverage Results

Static Badge Static Badge

Diff Coverage

Diff: origin/main...HEAD, staged and unstaged changes

  • plugins/sentinel/sentinel/actions/update_defender_xdr_alert.py (100%)
  • plugins/sentinel/sentinel/routes/ingest.py (89.5%): Missing lines 261-262,283-284

Summary

  • Total: 39 lines
  • Missing: 4 lines
  • Coverage: 89%
plugins/sentinel/sentinel/routes/ingest.py

Lines 257-266

  257                             item_type="hit",
  258                             item_value=child_id,
  259                             item_path=child_label,
  260                         )
! 261                     except Exception:
! 262                         logger.exception("Failed to add child hit %s to case", child_id)
  263 
  264             datastore().case.commit()
  265 
  266         datastore().hit.commit()

Lines 279-285

  279             "organization": bundle_hit["organization"]["name"],
  280         }
  281         return created(response_body)
  282     except HowlerException as e:
! 283         logger.exception("Failed to create incident")
! 284         return internal_error(err=f"Failed to create incident: {str(e)}")

Full Coverage Report

Expand
Name                                            Stmts   Miss Branch BrPart  Cover
---------------------------------------------------------------------------------
sentinel/__init__.py                                0      0      0      0   100%
sentinel/actions/azure_emit_hash.py                34     22     10      0    27%
sentinel/actions/send_to_sentinel.py               47     14     12      3    68%
sentinel/actions/update_defender_xdr_alert.py      54     18     14      5    63%
sentinel/config.py                                 27      0      0      0   100%
sentinel/mapping/sentinel_incident.py              91     18     32     12    76%
sentinel/mapping/xdr_alert.py                     113     23     48     18    73%
sentinel/mapping/xdr_alert_evidence.py            157     78     34      2    42%
sentinel/odm/__init__.py                            0      0      0      0   100%
sentinel/odm/hit.py                                 8      0      0      0   100%
sentinel/odm/models/sentinel.py                     5      0      0      0   100%
sentinel/routes/__init__.py                         0      0      0      0   100%
sentinel/routes/ingest.py                         124     18     38     13    81%
sentinel/utils/tenant_utils.py                     25      0      8      0   100%
---------------------------------------------------------------------------------
TOTAL                                             685    191    196     53    67%

@github-actions

github-actions Bot commented Apr 28, 2026

Copy link
Copy Markdown
Contributor

Static Badge

Howler API - Coverage Results

Static Badge Static Badge

Diff Coverage

Diff: origin/main...HEAD, staged and unstaged changes

  • api/howler/actions/add_to_bundle.py (79.2%): Missing lines 57,70,84-85,107
  • api/howler/actions/add_to_case.py (90.0%): Missing lines 85-88
  • api/howler/actions/remove_from_bundle.py (69.2%): Missing lines 57,70,75,83,100,121-122,130
  • api/howler/api/init.py (100%)
  • api/howler/api/socket.py (72.2%): Missing lines 67,100-101,104-105
  • api/howler/api/v1/init.py (100%)
  • api/howler/api/v1/action.py (0.0%): Missing lines 242
  • api/howler/api/v1/analytic.py (86.7%): Missing lines 497,532
  • api/howler/api/v1/hit.py (100%)
  • api/howler/api/v1/tool.py (100%)
  • api/howler/api/v1/user.py (100%)
  • api/howler/api/v1/utils/etag.py (100%)
  • api/howler/api/v2/init.py (100%)
  • api/howler/api/v2/case.py (98.2%): Missing lines 54,57-58
  • api/howler/api/v2/ingest.py (96.7%): Missing lines 174,311-312,370-371
  • api/howler/api/v2/search.py (94.4%): Missing lines 204-206,325,336-338
  • api/howler/app.py (100%)
  • api/howler/common/loader.py (100%)
  • api/howler/cronjobs/correlation.py (88.2%): Missing lines 25-26
  • api/howler/datastore/collection.py (100%)
  • api/howler/datastore/howler_store.py (100%)
  • api/howler/datastore/store.py (100%)
  • api/howler/datastore/types.py (100%)
  • api/howler/helper/discover.py (75.0%): Missing lines 40-41
  • api/howler/helper/hit.py (0.0%): Missing lines 232
  • api/howler/helper/search.py (83.3%): Missing lines 81
  • api/howler/odm/base.py (100%)
  • api/howler/odm/constants.py (100%)
  • api/howler/odm/helper.py (100%)
  • api/howler/odm/mixins.py (96.2%): Missing lines 56
  • api/howler/remote/datatypes/init.py (100%)
  • api/howler/remote/datatypes/queues/comms.py (100%)
  • api/howler/services/bundle_compat_service.py (93.1%): Missing lines 128-129,136,176,191-192,204,226,243
  • api/howler/services/case_service.py (95.5%): Missing lines 52,55,57-58,60,325-328,366,413,459,524,565,636,693
  • api/howler/services/config_service.py (100%)
  • api/howler/services/correlation_service.py (97.1%): Missing lines 124-125
  • api/howler/services/docs_service.py (90.0%): Missing lines 59-60,67
  • api/howler/services/event_service.py (80.5%): Missing lines 51-52,92-95,100-101
  • api/howler/services/hit_service.py (100%)
  • api/howler/services/observable_service.py (98.3%): Missing lines 90
  • api/howler/services/search_service.py (97.8%): Missing lines 214-215
  • api/howler/services/viewer_service.py (100%)
  • api/howler/utils/socket_utils.py (100%)

Summary

  • Total: 1650 lines
  • Missing: 88 lines
  • Coverage: 94%
api/howler/actions/add_to_bundle.py

Lines 53-61

  53         ## Check hit limit against the query before searching
  54         if user:
  55             limit_error = check_hit_limit(query, user, MAX_HITS_BASIC, MAX_HITS_ADVANCED)
  56             if limit_error:
! 57                 return [limit_error]
  58 
  59         matching_hits = ds.hit.search(query, rows=MAX_HITS_ADVANCED)["items"]
  60 
  61         if not matching_hits:

Lines 66-74

  66                     "title": "No Matching Hits",
  67                     "message": "There were no hits matching this query.",
  68                 }
  69             )
! 70             return report
  71 
  72         added = []
  73         skipped = []
  74         for hit in matching_hits:

Lines 80-89

  80                     item_value=hit.howler.id,
  81                     item_path=child_label,
  82                 )
  83                 added.append(hit.howler.id)
! 84             except Exception:
! 85                 skipped.append(hit.howler.id)
  86 
  87         if skipped:
  88             report.append(
  89                 {

Lines 103-111

  103                     "message": "The specified bundle has had all matching hits added.",
  104                 }
  105             )
  106 
! 107     except NotFoundException as e:
  108         report.append(
  109             {
  110                 "query": query,
  111                 "outcome": "error",
api/howler/actions/add_to_case.py

Lines 81-92

  81             )
  82             added.append(hit.howler.id)
  83         except InvalidDataException as e:
  84             skipped.append(f"{hit.howler.id}: {e}")
! 85         except NotFoundException as e:
! 86             skipped.append(f"{hit.howler.id}: {e}")
! 87         except Exception as e:
! 88             skipped.append(f"{hit.howler.id}: {e}")
  89 
  90     if added:
  91         report.append(
  92             {
api/howler/actions/remove_from_bundle.py

Lines 53-61

  53         ## Check hit limit against the query before searching
  54         if user:
  55             limit_error = check_hit_limit(query, user, MAX_HITS_BASIC, MAX_HITS_ADVANCED)
  56             if limit_error:
! 57                 return [limit_error]
  58 
  59         matching_hits = ds.hit.search(query, rows=MAX_HITS_ADVANCED)["items"]
  60 
  61         if not matching_hits:

Lines 66-79

  66                     "title": "No Matching Hits",
  67                     "message": "There were no hits matching this query.",
  68                 }
  69             )
! 70             return report
  71 
  72         ## Get the case to check which hits are actually in it
  73         case = ds.case.get(case_id)
  74         if case is None:
! 75             report.append(
  76                 {
  77                     "query": query,
  78                     "outcome": "error",
  79                     "title": "Case Not Found",

Lines 79-87

  79                     "title": "Case Not Found",
  80                     "message": f"Associated case {case_id} no longer exists.",
  81                 }
  82             )
! 83             return report
  84 
  85         case_item_values = {item.value for item in case.items}
  86         values_to_remove = [h.howler.id for h in matching_hits if h.howler.id in case_item_values]
  87         skipped_ids = [h.howler.id for h in matching_hits if h.howler.id not in case_item_values]

Lines 96-104

   96                 }
   97             )
   98 
   99         if not values_to_remove:
! 100             report.append(
  101                 {
  102                     "query": query,
  103                     "outcome": "skipped",
  104                     "title": "No Matching Hits",

Lines 117-126

  117                 "message": f"Matching hits removed from bundle with id {bundle_id}",
  118             }
  119         )
  120 
! 121     except NotFoundException as e:
! 122         report.append(
  123             {
  124                 "query": query,
  125                 "outcome": "error",
  126                 "title": "Failed to Execute",

Lines 126-134

  126                 "title": "Failed to Execute",
  127                 "message": str(e),
  128             }
  129         )
! 130     except Exception as e:
  131         report.append(
  132             {
  133                 "query": query,
  134                 "outcome": "error",
api/howler/api/socket.py

Lines 63-71

  63 
  64     Result Example:
  65     ["user1", "user2"]
  66     """
! 67     return ok(viewer_service.get_viewers(entity_id))
  68 
  69 
  70 @tracer.start_as_current_span(f"{__name__}.connect")
  71 @socket_api.route("/connect", websocket=True)  ## type: ignore

Lines 96-109

   96         logger.debug("Sending action: %s", data)
   97         ws.send(ws_response("action", data))
   98 
   99     def send_case(data: dict[str, Any]):
! 100         logger.debug("Sending case update: %s", data.get("case", {}).get("case_id", "unknown"))
! 101         ws.send(ws_response("cases", data))
  102 
  103     def send_viewers_update(data: dict[str, Any]):
! 104         logger.debug("Sending viewers update: %s", data.get("id", "unknown"))
! 105         ws.send(ws_response("viewers_update", data))
  106 
  107     try:
  108         event_service.on("hits", send_hit)
  109         event_service.on("broadcast", send_broadcast)
api/howler/api/v1/action.py

Lines 238-246

  238     execute_req = request.json
  239     if not isinstance(execute_req, dict):
  240         return bad_request(err="Incorrect data structure!")
  241 
! 242     action = datastore().action.get(id)
  243     if not action:
  244         return not_found(err="The specified action does not exist")
  245 
  246     reports: dict[str, list[dict]] = {}
api/howler/api/v1/analytic.py

Lines 493-501

  493     if not existing_analytic:
  494         return not_found(err="This analytic does not exist")
  495 
  496     if not user:
! 497         return forbidden(err="User was not found.")
  498 
  499     try:
  500         user.favourite_analytics.append(id)

Lines 528-536

  528     if not storage.analytic.exists(id):
  529         return not_found(err="This analytic does not exist")
  530 
  531     if not user:
! 532         return forbidden(err="User was not found.")
  533 
  534     try:
  535         user.favourite_analytics = list(filter(lambda f: f != id, user.favourite_analytics))
api/howler/api/v2/case.py

Lines 50-62

  50 
  51     try:
  52         return created(case_service.create_case(case_data, user.uname))
  53     except InvalidDataException as e:
! 54         return bad_request(err=str(e))
  55     except ResourceExists as e:
  56         return bad_request(err=str(e))
! 57     except HowlerException as e:
! 58         return bad_request(err=str(e))
  59 
  60 
  61 @generate_swagger_docs()
  62 @case_api.route("/<id>", methods=["GET"])
api/howler/api/v2/ingest.py

Lines 170-178

  170                 break
  171 
  172             existing = [record_id for record_id in remaining if ds[index].exists(record_id)]
  173             if not existing:
! 174                 continue
  175 
  176             for record_id in existing:
  177                 ds[index].delete(record_id)

Lines 307-316

  307 
  308         new_record, new_version = ds[index].get(id, as_obj=False, version=True)
  309 
  310         return ok(new_record), new_version
! 311     except HowlerValueError as e:
! 312         return bad_request(err=e.message)
  313 
  314 
  315 @generate_swagger_docs()
  316 @ingest_api.route("/<indexes>/update", methods=["PUT"])

Lines 366-372

  366 
  367         return ok({"success": all(ds[index].update_by_query(query, operations) for index in indexes.split(","))})
  368     except (HowlerValueError, KeyError, DataStoreException) as e:
  369         return bad_request(err=str(e))
! 370     except Exception as e:
! 371         return internal_error(err=str(e))
api/howler/api/v2/search.py

Lines 200-210

  200         for explanation in result["explanations"]:
  201             del explanation["index"]
  202 
  203         return ok(result)
! 204     except Exception as e:
! 205         logger.exception("Exception on query explanation")
! 206         return bad_request(err=f"Exception: {e}")
  207 
  208 
  209 @generate_swagger_docs()
  210 @search_api.route("/count/<index>", methods=["GET", "POST"])

Lines 321-329

  321             if collection is None:
  322                 return bad_request(err=f"Not a valid index to search in: {index}")
  323 
  324             if has_access_control(index):
! 325                 params.update({"access_control": user["access_control"]})
  326             for field in fields:
  327                 facet_result.setdefault(field, {})
  328 
  329                 if field not in collection().fields():

Lines 332-339

  332 
  333                 facet_result[field].update(collection().facet(field, **params))
  334 
  335         return ok(facet_result)
! 336     except (SearchException, BadRequestError) as e:
! 337         logger.error("SearchException: %s", str(e), exc_info=True)
! 338         return bad_request(err=f"SearchException: {e}")
api/howler/cronjobs/correlation.py

Lines 21-30

  21     """Start the correlation worker thread if correlation is enabled."""
  22     global _thread
  23 
  24     if not config.system.correlation.enabled:
! 25         logger.info("Correlation worker disabled by configuration")
! 26         return
  27 
  28     if _thread is not None and _thread.is_alive():
  29         logger.debug("Correlation worker thread already running")
  30         return
api/howler/helper/discover.py

Lines 36-45

  36             if resp.ok:
  37                 data = resp.json()
  38                 for app in data["applications"]["application"]:
  39                     try:
! 40                         app_url = app["instance"][0]["hostName"]
! 41                         if "howler" not in app_url:
  42                             apps.append(
  43                                 {
  44                                     "alt": app["instance"][0]["metadata"]["alternateText"],
  45                                     "name": app["name"],
api/howler/helper/hit.py

Lines 228-236

  228             Status.IN_PROGRESS,
  229             Status.OPEN,
  230         ]:
  231             actions.append(odm_helper.update("howler.assignment", "unassigned"))
! 232             actions.append(odm_helper.update("howler.status", Status.OPEN))
  233         else:
  234             raise InvalidDataException("Cannot vote on hit you are assigned to.")
  235 
  236     return actions
api/howler/helper/search.py

Lines 77-85

  77         bool: Does the index have access control
  78     """
  79     if isinstance(index, str):
  80         if "," in index:
! 81             index = index.split(",")
  82         else:
  83             index = [index]
  84 
  85     return any(_index in ACCESS_CONTROLLED_INDICES for _index in index)
api/howler/odm/mixins.py

Lines 52-60

  52                 f"Use '{type(obj).__name__}.store' instead."
  53             )
  54 
  55         if objtype is None:
! 56             raise HowlerRuntimeError("Cannot resolve owner class for 'store' descriptor.")
  57 
  58         index_name = objtype.__name__.lower()
  59         return datastore()[index_name]
api/howler/services/bundle_compat_service.py

Lines 124-133

  124                 item_type="hit",
  125                 item_value=child_id,
  126                 item_path=child_label,
  127             )
! 128         except (InvalidDataException, NotFoundException, DataStoreException) as exc:
! 129             logger.warning("Could not add child hit %s to case: %s", child_id, exc)
  130 
  131     datastore().hit.commit()
  132     datastore().case.commit()

Lines 132-140

  132     datastore().case.commit()
  133 
  134     updated_case: Case | None = datastore().case.get(case.case_id)
  135     if updated_case is None:
! 136         raise NotFoundException(f"Case {case.case_id} disappeared after creation")
  137 
  138     return synthesize_bundle_response(updated_case, odm, warnings=warnings)
  139 

Lines 172-180

  172 
  173     ## Check for duplicates and nested bundles before modifying
  174     current_case: Case | None = datastore().case.get(case_id)
  175     if current_case is None:
! 176         raise NotFoundException(f"Case {case_id} not found")
  177 
  178     existing_values = {item.value for item in current_case.items}
  179 
  180     for hit_id in hit_ids:

Lines 187-196

  187 
  188     for hit_id in hit_ids:
  189         child_hit = hit_service.get_hit(hit_id, as_odm=True)
  190         if child_hit is None:
! 191             logger.warning("Hit %s does not exist, skipping", hit_id)
! 192             continue
  193 
  194         child_label = f"hits/{child_hit.howler.analytic} ({hit_id})"
  195         case_service.append_case_item(
  196             case_id,

Lines 200-208

  200         )
  201 
  202     updated_case: Case | None = datastore().case.get(case_id)
  203     if updated_case is None:
! 204         raise NotFoundException(f"Case {case_id} not found")
  205 
  206     return synthesize_bundle_response(updated_case, root_hit)
  207 

Lines 222-230

  222         raise InvalidDataException("The specified hit must be a bundle.")
  223 
  224     case: Case | None = datastore().case.get(case_id)
  225     if case is None:
! 226         raise NotFoundException(f"Case {case_id} not found")
  227 
  228     if hit_ids == ["*"]:
  229         values_to_remove = [item.value for item in case.items if item.value != bundle_id]
  230     else:

Lines 239-247

  239             case_service.remove_case_items(case_id, values_to_remove)
  240 
  241     updated_case: Case | None = datastore().case.get(case_id)
  242     if updated_case is None:
! 243         raise NotFoundException(f"Case {case_id} not found")
  244 
  245     return synthesize_bundle_response(updated_case, root_hit)
  246 
api/howler/services/case_service.py

Lines 48-64

  48     datastore().case.save(case.case_id, case)
  49     CREATED_CASES.inc()
  50 
  51     for item in items:
! 52         append_case_item(case.case_id, item=CaseItem(item))
  53 
  54     if items:
! 55         updated_case = datastore().case.get(case.case_id)
  56 
! 57         if not updated_case:
! 58             raise HowlerValueError("Error occurred when creating case")
  59 
! 60         case = updated_case
  61 
  62     event_service.emit("cases", {"case": case.as_primitives()})
  63 
  64     return case

Lines 321-332

  321         case CaseItemTypes.TABLE:
  322             return append_table(case_id, item)
  323         case CaseItemTypes.LEAD:
  324             return append_lead(case_id, item)
! 325         case CaseItemTypes.REFERENCE:
! 326             return append_reference(case_id, item)
! 327         case _:
! 328             raise InvalidDataException(f"Unsupported item type: {item_type}")
  329 
  330 
  331 def append_hit(case_id: str, item: CaseItem) -> Case:
  332     """Append a hit item to a case and create a back-reference on the hit.

Lines 362-370

  362 
  363     _case.items.append(item)
  364 
  365     if not ds.case.save(_case.case_id, _case):
! 366         raise DataStoreException(f"Failed to save {_case.case_id} with new item {item.value}")
  367 
  368     _add_backreference(hit, _case.case_id)
  369 
  370     _sync_case_metadata(_case.case_id)

Lines 409-417

  409 
  410     _case.items.append(item)
  411 
  412     if not ds.case.save(_case.case_id, _case):
! 413         raise DataStoreException(f"Failed to save {_case.case_id} with new item {item.value}")
  414 
  415     _add_backreference(observable, _case.case_id)
  416     _sync_case_metadata(case_id)

Lines 455-463

  455 
  456     _case.items.append(item)
  457 
  458     if not datastore().case.save(_case.case_id, _case):
! 459         raise DataStoreException(f"Failed to save {_case.case_id} with new item {item.value}")
  460 
  461     event_service.emit("cases", {"case": _case.as_primitives()})
  462 
  463     return _case

Lines 520-528

  520 
  521     _case.items.append(item)
  522 
  523     if not datastore().case.save(_case.case_id, _case):
! 524         raise DataStoreException(f"Failed to save {_case.case_id} with new item {item.value}")
  525 
  526     event_service.emit("cases", {"case": _case.as_primitives()})
  527 
  528     return _case

Lines 561-569

  561     for item in _case.items:
  562         if item.type == CaseItemTypes.HIT and item.value:
  563             hit = ds.hit.get(item.value)
  564             if hit is None:
! 565                 continue
  566 
  567             indicators.update(_collect_indicators_from_related(hit.related))
  568 
  569             if hit.howler.outline:

Lines 632-640

  632     if backing_obj is None:
  633         raise InvalidDataException("Cannot remove back reference on a nonexisting object")
  634 
  635     if not case_id:
! 636         raise InvalidDataException("Missing back reference case_id")
  637 
  638     if case_id in backing_obj.howler.related:
  639         backing_obj.howler.related.remove(case_id)
  640         datastore()[backing_obj.__class__.__name__.lower()].save(backing_obj.howler.id, backing_obj)

Lines 689-697

  689     for item in items_to_remove:
  690         _case.items.remove(item)
  691 
  692     if not ds.case.save(_case.case_id, _case):
! 693         raise DataStoreException("Failed to save case after item removal")
  694 
  695     ## Clean up back-references after the case is safely persisted.
  696     for backing_obj in backing_objs:
  697         remove_backreference(backing_obj, _case.case_id)
api/howler/services/correlation_service.py

Lines 120-129

  120             except InvalidDataException:
  121                 logger.debug("Record %s already exists in case %s, skipping", record_id, case_id)
  122             except NotFoundException:
  123                 logger.warning("Case %s or record %s not found during correlation", case_id, record_id)
! 124             except Exception:
! 125                 logger.exception("Failed to add record %s to case %s", record_id, case_id)
  126 
  127     return added
  128 
api/howler/services/docs_service.py

Lines 55-64

  55 
  56                     if blueprint not in api_blueprints:
  57                         try:
  58                             doc = current_app.blueprints[rule.endpoint[: rule.endpoint.rindex(".")]]._doc  ## type: ignore[attr-defined]
! 59                         except Exception:
! 60                             doc = ""
  61 
  62                         api_blueprints[blueprint] = doc
  63 
  64                     if doc_string:

Lines 63-71

  63 
  64                     if doc_string:
  65                         description = dedent(doc_string)
  66                     else:
! 67                         description = "[INCOMPLETE]\n\nTHIS API HAS NOT BEEN DOCUMENTED YET!"
  68 
  69                     api_id = rule.endpoint.replace(f"api{version}.", "").replace(".", "_")
  70 
  71                     api_list.append(
api/howler/services/event_service.py

Lines 47-56

  47 
  48     for handler in handlers[event_type]:
  49         try:
  50             handler(payload)
! 51         except Exception:
! 52             logger.exception("Error in event handler for %s", event_type)
  53 
  54 
  55 def start_watcher():
  56     """Start the Redis pubsub watcher that routes incoming events to local handlers.

Lines 88-105

   88 
   89     if DEBUG and not _watcher_started:
   90         ## In debug/single-process mode without a watcher, call handlers
   91         ## directly for immediate feedback.
!  92         if event in handlers:
!  93             logger.debug("event:%s - emitting data (in-process)", event)
!  94             for handler in handlers[event]:
!  95                 handler(data)
   96 
   97     ## Always publish to Redis so other pods receive the event.
   98     try:
   99         _get_sender().send(event, {"__event__": event, "__payload__": data})
! 100     except Exception:
! 101         logger.exception("Failed to publish event %s to Redis", event)
  102 
  103 
  104 def on(event: str, handler: Callable):
  105     """Add a new listener to the specified event
api/howler/services/observable_service.py

Lines 86-94

  86     odm_flatten = odm.flat_fields(show_compound=True)
  87     unused_keys = extra_keys(Observable, data)
  88 
  89     if unused_keys and not ignore_extra_values:
! 90         raise HowlerValueError(f"Observable was created with invalid parameters: {', '.join(unused_keys)}")
  91     deprecated_keys = set(key for key in odm_flatten.keys() & data.keys() if odm_flatten[key].deprecated)
  92 
  93     warnings = [f"{key} is not currently used by howler." for key in unused_keys]
  94     warnings.extend(
api/howler/services/search_service.py

Lines 210-219

  210 
  211     if next_deep_paging_id is not None and len(response["items"]) < rows:
  212         try:
  213             client.clear_scroll(scroll_id=next_deep_paging_id)
! 214         except elasticsearch.exceptions.NotFoundError:
! 215             pass
  216         next_deep_paging_id = None
  217 
  218     if next_deep_paging_id is not None:
  219         response["next_deep_paging_id"] = next_deep_paging_id

Full Coverage Report

Expand
Name                                            Stmts   Miss Branch BrPart  Cover
---------------------------------------------------------------------------------
howler/__init__.py                                  0      0      0      0   100%
howler/actions/__init__.py                         83      2     36      3    96%
howler/actions/add_label.py                        30      2      6      0    94%
howler/actions/add_to_bundle.py                    50     10     16      5    77%
howler/actions/add_to_case.py                      40      4     12      0    92%
howler/actions/change_field.py                     18      2      2      0    90%
howler/actions/demote.py                           35      2      8      0    95%
howler/actions/example_plugin.py                   13      8      2      0    33%
howler/actions/prioritization.py                   24      2      2      0    92%
howler/actions/promote.py                          34      2      8      0    95%
howler/actions/remove_from_bundle.py               51     12     16      6    73%
howler/actions/remove_label.py                     30      2      6      0    94%
howler/actions/transition.py                       66      4     24      4    91%
howler/api/__init__.py                            122     48     26      2    57%
howler/api/base.py                                 36      0     12      0   100%
howler/api/socket.py                               83     14     16      6    80%
howler/api/v1/__init__.py                          12      0      0      0   100%
howler/api/v1/action.py                           128     40     32      6    64%
howler/api/v1/analytic.py                         260     79     84     29    63%
howler/api/v1/auth.py                             164     25     56     16    80%
howler/api/v1/clue.py                              47     14     12      0    66%
howler/api/v1/configs.py                           14      0      0      0   100%
howler/api/v1/dossier.py                           90     27     10      4    67%
howler/api/v1/help.py                              13      0      0      0   100%
howler/api/v1/hit.py                              415     57    124     38    82%
howler/api/v1/notebook.py                          33      5      6      1    85%
howler/api/v1/overview.py                          77     11     18      7    81%
howler/api/v1/search.py                           309     82     90     17    71%
howler/api/v1/template.py                          86     13     26      9    80%
howler/api/v1/tool.py                             118      6     48      5    92%
howler/api/v1/user.py                             175     56     58     23    61%
howler/api/v1/utils/__init__.py                     0      0      0      0   100%
howler/api/v1/utils/etag.py                        30      0     14      0   100%
howler/api/v1/view.py                             129     27     36     13    76%
howler/api/v2/__init__.py                          12      0      0      0   100%
howler/api/v2/case.py                             165      3     32      0    98%
howler/api/v2/ingest.py                           153      5     44      2    96%
howler/api/v2/search.py                           124      7     30      3    94%
howler/app.py                                     139     15     36     12    83%
howler/common/__init__.py                           0      0      0      0   100%
howler/common/classification.py                   595     77    338     72    83%
howler/common/exceptions.py                        73      8      2      0    89%
howler/common/loader.py                            85     24     38     10    66%
howler/common/logging/__init__.py                 141     83     64      7    36%
howler/common/logging/audit.py                     38      5      8      4    80%
howler/common/logging/format.py                    14      2      0      0    86%
howler/common/net.py                               48      2     22      2    94%
howler/common/net_static.py                         1      0      0      0   100%
howler/common/random_user.py                       10      0      2      0   100%
howler/common/swagger.py                           48     12     18      4    70%
howler/config.py                                   28      0      0      0   100%
howler/cronjobs/__init__.py                        19      2      4      0    91%
howler/cronjobs/correlation.py                     17      2      4      1    86%
howler/cronjobs/retention.py                       30     12      6      1    58%
howler/cronjobs/view_cleanup.py                    47      6     22      4    83%
howler/datastore/__init__.py                        0      0      0      0   100%
howler/datastore/bulk.py                           53     53     26      0     0%
howler/datastore/collection.py                   1080    265    504     99    72%
howler/datastore/constants.py                       7      0      0      0   100%
howler/datastore/exceptions.py                     23      1      0      0    96%
howler/datastore/howler_store.py                   83     10     14      1    87%
howler/datastore/operations.py                     45      5     16      5    84%
howler/datastore/schemas.py                         4      4      0      0     0%
howler/datastore/store.py                         147     35     32      8    72%
howler/datastore/support/__init__.py                0      0      0      0   100%
howler/datastore/support/build.py                 101     26     68     10    70%
howler/datastore/support/schemas.py                 4      0      0      0   100%
howler/datastore/types.py                           9      0      0      0   100%
howler/error.py                                    52     31     12      0    33%
howler/healthz.py                                  15      5      2      0    59%
howler/helper/__init__.py                           0      0      0      0   100%
howler/helper/azure.py                             20      0      8      0   100%
howler/helper/discover.py                          32     17     12      3    41%
howler/helper/hit.py                               63     16     30     12    68%
howler/helper/oauth.py                            155     68    100     19    52%
howler/helper/search.py                            24      2      6      2    87%
howler/helper/workflow.py                          47      3     22      3    91%
howler/odm/__init__.py                              1      0      0      0   100%
howler/odm/base.py                                902    164    408     48    78%
howler/odm/constants.py                             8      0      0      0   100%
howler/odm/helper.py                              208     31     76      5    78%
howler/odm/howler_enum.py                          13      0      0      0   100%
howler/odm/mixins.py                               26      1      4      1    93%
howler/odm/randomizer.py                          219     44    120     18    79%
howler/plugins/__init__.py                         17      0      4      0   100%
howler/plugins/config.py                           78      0     40      0   100%
howler/remote/__init__.py                           0      0      0      0   100%
howler/remote/datatypes/__init__.py                84      2     32      2    97%
howler/remote/datatypes/counters.py                47      5     16      0    92%
howler/remote/datatypes/events.py                  35      0      6      2    95%
howler/remote/datatypes/hash.py                   114     17     22      7    82%
howler/remote/datatypes/lock.py                    19      0      2      0   100%
howler/remote/datatypes/queues/__init__.py          0      0      0      0   100%
howler/remote/datatypes/queues/comms.py            47     10     14      3    75%
howler/remote/datatypes/queues/multi.py            22      3      8      3    80%
howler/remote/datatypes/queues/named.py            66      7     24      7    84%
howler/remote/datatypes/queues/priority.py        107     20     38     12    75%
howler/remote/datatypes/set.py                     74     12      8      2    80%
howler/remote/datatypes/user_quota_tracker.py      28     10      4      0    56%
howler/security/__init__.py                       128     19     40      9    83%
howler/security/socket.py                          53     10     12      5    74%
howler/security/utils.py                           81     37     36      2    48%
howler/services/__init__.py                         0      0      0      0   100%
howler/services/action_service.py                  60      7     34      5    85%
howler/services/analytic_service.py                61     13     20      3    78%
howler/services/auth_service.py                   140     13     52     12    87%
howler/services/bundle_compat_service.py          130      9     58     10    90%
howler/services/case_service.py                   354     16    186     19    93%
howler/services/config_service.py                  50      8      8      2    83%
howler/services/correlation_service.py             68      2     18      0    98%
howler/services/docs_service.py                    30      3     14      2    89%
howler/services/dossier_service.py                 80      6     40      2    90%
howler/services/event_service.py                   63     10     22      3    80%
howler/services/hit_service.py                    228      6     92      6    96%
howler/services/jwt_service.py                     71     20     18      3    74%
howler/services/lucene_service.py                 157     15     48      8    89%
howler/services/notebook_service.py                58      3     18      3    92%
howler/services/observable_service.py              58      1     20      1    97%
howler/services/overview_service.py                21      5      6      2    74%
howler/services/search_service.py                  93      2     44      0    99%
howler/services/template_service.py                22      5      6      2    75%
howler/services/user_service.py                   150     32     66     17    72%
howler/services/viewer_service.py                  20      0      0      0   100%
howler/telemetry.py                                33     28      6      0    13%
howler/utils/__init__.py                            0      0      0      0   100%
howler/utils/annotations.py                         0      0      0      0   100%
howler/utils/chunk.py                               8      0      2      0   100%
howler/utils/compat.py                             11      6      2      1    46%
howler/utils/constants.py                           3      0      0      0   100%
howler/utils/dict_utils.py                        123     12     86     11    87%
howler/utils/isotime.py                             8      1      2      1    80%
howler/utils/list_utils.py                          7      0      4      0   100%
howler/utils/lucene.py                             52      1     18      1    97%
howler/utils/path.py                               16     16      2      0     0%
howler/utils/socket_utils.py                       17      0     10      1    96%
howler/utils/str_utils.py                         115     21     38      4    80%
howler/utils/uid.py                                20      2      4      2    83%
---------------------------------------------------------------------------------
TOTAL                                           11270   2017   4186    725    79%

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants