packages feed

bloodhound-1.0.0.0: tests/Test/EventQueryLanguageSpec.hs

{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE QuasiQuotes #-}

module Test.EventQueryLanguageSpec (spec) where

import Data.Aeson
import Data.Aeson.Key (Key)
import Data.Aeson.KeyMap qualified as KM
import Data.ByteString.Lazy.Char8 qualified as LBS
import Data.HashMap.Strict (singleton)
import Data.List (sortOn)
import Data.Text qualified as T
import Data.Text qualified as Text
import Database.Bloodhound.Client.Cluster (BackendType (..))
import Database.Bloodhound.ElasticSearch7.Client qualified as ClientES7
import Database.Bloodhound.ElasticSearch7.Requests qualified as RequestsES7
import Database.Bloodhound.ElasticSearch7.Types qualified as ES7Types
import Database.Bloodhound.ElasticSearch8.Client qualified as ClientES8
import Database.Bloodhound.ElasticSearch8.Requests qualified as RequestsES8
import Database.Bloodhound.ElasticSearch9.Client qualified as ClientES9
import Database.Bloodhound.ElasticSearch9.Requests qualified as RequestsES9
import TestsUtils.Common
import TestsUtils.Import

hasKey :: Key -> LBS.ByteString -> Bool
hasKey k bs = case decode bs of
  Just (Object obj) -> k `KM.member` obj
  _ -> False

-- | Order-insensitive comparison helper for query-parameter lists, since
-- 'withQueries' is order-insensitive and the two param renderers do not
-- guarantee a stable inter-key ordering. Mirrors the helper in
-- "Test.PointInTimeSpec".
sortPairs :: [(Text.Text, Maybe Text.Text)] -> [(Text.Text, Maybe Text.Text)]
sortPairs = sortOn fst

-- | Minimal request value carrying only the required 'eqlQuery' field.
minimalRequest :: ES7Types.EQLRequest
minimalRequest =
  ES7Types.EQLRequest
    { ES7Types.eqlQuery = "process where process.name == \"cmd.exe\"",
      ES7Types.eqlWaitForCompletionTimeout = Nothing,
      ES7Types.eqlKeepOnCompletion = Nothing,
      ES7Types.eqlKeepAlive = Nothing,
      ES7Types.eqlSize = Nothing,
      ES7Types.eqlFetchSize = Nothing,
      ES7Types.eqlFields = Nothing,
      ES7Types.eqlFilter = Nothing,
      ES7Types.eqlResultPosition = Nothing,
      ES7Types.eqlTiebreakerField = Nothing,
      ES7Types.eqlEventCategoryField = Nothing,
      ES7Types.eqlTimestampField = Nothing,
      ES7Types.eqlRuntimeMappings = Nothing,
      ES7Types.eqlAllowPartialSearchResults = Nothing,
      ES7Types.eqlAllowPartialSequenceResults = Nothing,
      ES7Types.eqlCaseSensitive = Nothing
    }

-- | Fully-populated request value with every optional field set.
fullRequest :: ES7Types.EQLRequest
fullRequest =
  ES7Types.EQLRequest
    { ES7Types.eqlQuery = "process where process.name == \"cmd.exe\"",
      ES7Types.eqlWaitForCompletionTimeout = Just "5s",
      ES7Types.eqlKeepOnCompletion = Just True,
      ES7Types.eqlKeepAlive = Just "1m",
      ES7Types.eqlSize = Just 100,
      ES7Types.eqlFetchSize = Just 50,
      ES7Types.eqlFields = Just [toJSON ("@timestamp" :: Text)],
      ES7Types.eqlFilter = Just (Filter (TermQuery (Term "user" "alice") Nothing)),
      ES7Types.eqlResultPosition = Just ES7Types.EQLTail,
      ES7Types.eqlTiebreakerField = Just "tiebreaker",
      ES7Types.eqlEventCategoryField = Just "event.category",
      ES7Types.eqlTimestampField = Just "@timestamp",
      ES7Types.eqlRuntimeMappings =
        Just
          ( RuntimeMappings
              ( singleton
                  "day_of_week"
                  (RuntimeMapping RuntimeTypeKeyword Nothing)
              )
          ),
      ES7Types.eqlAllowPartialSearchResults = Just True,
      ES7Types.eqlAllowPartialSequenceResults = Just False,
      ES7Types.eqlCaseSensitive = Just True
    }

-- | EQL async response body carrying an @id@: the search is still
-- running, so no @hits@/@total@ are present.
sampleAsyncResponseWithId :: LBS.ByteString
sampleAsyncResponseWithId =
  LBS.pack
    "{\"id\":\"FmRleEctRXJtaVNZQ1I2UkJoTDJfQkEAAAAAAAAUX0Q2UkRLNEVBYlhVZ19VUUZmeld3Zw==\",\"is_running\":true,\"timed_out\":false,\"took\":1}"

spec :: Spec
spec = describe "EQL Search API (POST /{index}/_eql/search)" $ do
  describe "EQLRequest JSON" $ do
    it "serializes the required query field" $ do
      let json = encode minimalRequest
      json `shouldSatisfy` hasKey "query"

    it "omits all optional Nothing fields" $ do
      let json = encode minimalRequest
      json `shouldNotSatisfy` hasKey "wait_for_completion_timeout"
      json `shouldNotSatisfy` hasKey "keep_on_completion"
      json `shouldNotSatisfy` hasKey "keep_alive"
      json `shouldNotSatisfy` hasKey "size"
      json `shouldNotSatisfy` hasKey "fetch_size"
      json `shouldNotSatisfy` hasKey "fields"
      json `shouldNotSatisfy` hasKey "filter"
      json `shouldNotSatisfy` hasKey "result_position"
      json `shouldNotSatisfy` hasKey "tiebreaker_field"
      json `shouldNotSatisfy` hasKey "event_category_field"
      json `shouldNotSatisfy` hasKey "timestamp_field"
      json `shouldNotSatisfy` hasKey "runtime_mappings"
      json `shouldNotSatisfy` hasKey "allow_partial_search_results"
      json `shouldNotSatisfy` hasKey "allow_partial_sequence_results"
      json `shouldNotSatisfy` hasKey "case_sensitive"

    it "includes optional fields when set" $ do
      let json = encode fullRequest
      json `shouldSatisfy` hasKey "wait_for_completion_timeout"
      json `shouldSatisfy` hasKey "keep_on_completion"
      json `shouldSatisfy` hasKey "keep_alive"
      json `shouldSatisfy` hasKey "size"
      json `shouldSatisfy` hasKey "fetch_size"
      json `shouldSatisfy` hasKey "fields"
      json `shouldSatisfy` hasKey "filter"
      json `shouldSatisfy` hasKey "result_position"
      json `shouldSatisfy` hasKey "tiebreaker_field"
      json `shouldSatisfy` hasKey "event_category_field"
      json `shouldSatisfy` hasKey "timestamp_field"
      json `shouldSatisfy` hasKey "runtime_mappings"
      json `shouldSatisfy` hasKey "allow_partial_search_results"
      json `shouldSatisfy` hasKey "allow_partial_sequence_results"
      json `shouldSatisfy` hasKey "case_sensitive"

    it "encodes runtime_mappings as a bare object keyed by field name" $ do
      let json = encode fullRequest
      let Just (Object o) = decode json
      -- runtime_mappings renders as an object whose keys are the runtime
      -- field names; here we declared "day_of_week" with type keyword.
      KM.lookup "runtime_mappings" o `shouldSatisfy` isJust
      let Just (Object rm) = KM.lookup "runtime_mappings" o
      KM.lookup "day_of_week" rm `shouldSatisfy` isJust

    it "encodes the new boolean body fields verbatim" $ do
      let json = encode fullRequest
      let Just (Object o) = decode json
      KM.lookup "allow_partial_search_results" o `shouldBe` Just (Bool True)
      KM.lookup "allow_partial_sequence_results" o `shouldBe` Just (Bool False)
      KM.lookup "case_sensitive" o `shouldBe` Just (Bool True)

    it "encodes result_position as a bare string (\"head\"/\"tail\")" $ do
      let json = encode fullRequest
      let Just (Object obj) = decode json
      KM.lookup "result_position" obj `shouldBe` Just "tail"
      let headReq = fullRequest {ES7Types.eqlResultPosition = Just ES7Types.EQLHead}
      let Just (Object objH) = decode (encode headReq)
      KM.lookup "result_position" objH `shouldBe` Just "head"

    it "round-trips a minimal request through ToJSON/FromJSON" $
      decode (encode minimalRequest) `shouldBe` Just minimalRequest

    it "round-trips a fully-populated request through ToJSON/FromJSON" $
      decode (encode fullRequest) `shouldBe` Just fullRequest

  describe "EQLRequest body version gating (EQLRequestBodyES7 / EQLRequestBodyES8)" $ do
    -- The bare 'ToJSON' instance emits the full 8.x shape (every field,
    -- including the three 8.x-only booleans). The two newtype wrappers
    -- give the request builders version-specific wire shapes:
    -- 'EQLRequestBodyES7' drops the 8.x-only keys so the body matches the
    -- 7.17 spec; 'EQLRequestBodyES8' matches the bare instance so ES8/ES9
    -- keep forwarding them. This mirrors the PIT precedent
    -- ('pitOptionsParams' vs 'osPITOptionsParams').

    it "EQLRequestBodyES7 omits the 8.x-only body keys even when set" $ do
      let json = encode (ES7Types.EQLRequestBodyES7 fullRequest)
      json `shouldNotSatisfy` hasKey "allow_partial_search_results"
      json `shouldNotSatisfy` hasKey "allow_partial_sequence_results"
      json `shouldNotSatisfy` hasKey "case_sensitive"

    it "EQLRequestBodyES7 still emits every 7.17-documented body field" $ do
      let json = encode (ES7Types.EQLRequestBodyES7 fullRequest)
      json `shouldSatisfy` hasKey "query"
      json `shouldSatisfy` hasKey "wait_for_completion_timeout"
      json `shouldSatisfy` hasKey "keep_on_completion"
      json `shouldSatisfy` hasKey "keep_alive"
      json `shouldSatisfy` hasKey "size"
      json `shouldSatisfy` hasKey "fetch_size"
      json `shouldSatisfy` hasKey "fields"
      json `shouldSatisfy` hasKey "filter"
      json `shouldSatisfy` hasKey "result_position"
      json `shouldSatisfy` hasKey "tiebreaker_field"
      json `shouldSatisfy` hasKey "event_category_field"
      json `shouldSatisfy` hasKey "timestamp_field"
      json `shouldSatisfy` hasKey "runtime_mappings"

    it "EQLRequestBodyES7 encodes runtime_mappings identically to the bare instance" $ do
      let json = encode (ES7Types.EQLRequestBodyES7 fullRequest)
      let Just (Object o) = decode json
      KM.lookup "runtime_mappings" o `shouldSatisfy` isJust
      let Just (Object rm) = KM.lookup "runtime_mappings" o
      KM.lookup "day_of_week" rm `shouldSatisfy` isJust

    it "EQLRequestBodyES7 on a minimal request is byte-identical to the bare instance" $
      encode (ES7Types.EQLRequestBodyES7 minimalRequest) `shouldBe` encode minimalRequest

    it "EQLRequestBodyES8 emits the 8.x-only body keys verbatim" $ do
      let json = encode (ES7Types.EQLRequestBodyES8 fullRequest)
      let Just (Object o) = decode json
      KM.lookup "allow_partial_search_results" o `shouldBe` Just (Bool True)
      KM.lookup "allow_partial_sequence_results" o `shouldBe` Just (Bool False)
      KM.lookup "case_sensitive" o `shouldBe` Just (Bool True)

    it "EQLRequestBodyES8 is byte-identical to the bare ToJSON instance" $
      encode (ES7Types.EQLRequestBodyES8 fullRequest) `shouldBe` encode fullRequest

  describe "eqlSearchOptionsParams7 (ES7 wire)" $ do
    it "renders nothing for defaultEQLSearchOptions" $
      ES7Types.eqlSearchOptionsParams7 ES7Types.defaultEQLSearchOptions `shouldBe` []

    it "renders allow_no_indices as a boolean string" $
      let opts = ES7Types.defaultEQLSearchOptions {ES7Types.esoAllowNoIndices = Just False}
       in lookup "allow_no_indices" (ES7Types.eqlSearchOptionsParams7 opts) `shouldBe` Just (Just "false")

    it "deliberately omits allow_partial_search_results even when set" $
      let opts = ES7Types.defaultEQLSearchOptions {ES7Types.esoAllowPartialSearchResults = Just True}
       in lookup "allow_partial_search_results" (ES7Types.eqlSearchOptionsParams7 opts) `shouldBe` Nothing

    it "deliberately omits allow_partial_sequence_results even when set" $
      let opts = ES7Types.defaultEQLSearchOptions {ES7Types.esoAllowPartialSequenceResults = Just True}
       in lookup "allow_partial_sequence_results" (ES7Types.eqlSearchOptionsParams7 opts) `shouldBe` Nothing

    it "renders ignore_unavailable as a boolean string" $
      let opts = ES7Types.defaultEQLSearchOptions {ES7Types.esoIgnoreUnavailable = Just True}
       in lookup "ignore_unavailable" (ES7Types.eqlSearchOptionsParams7 opts) `shouldBe` Just (Just "true")

    it "renders ccs_minimize_roundtrips as a boolean string" $
      let opts = ES7Types.defaultEQLSearchOptions {ES7Types.esoCcsMinimizeRoundtrips = Just False}
       in lookup "ccs_minimize_roundtrips" (ES7Types.eqlSearchOptionsParams7 opts) `shouldBe` Just (Just "false")

    it "renders keep_alive verbatim" $
      let opts = ES7Types.defaultEQLSearchOptions {ES7Types.esoKeepAlive = Just "1m"}
       in lookup "keep_alive" (ES7Types.eqlSearchOptionsParams7 opts) `shouldBe` Just (Just "1m")

    it "renders keep_on_completion as a boolean string" $
      let opts = ES7Types.defaultEQLSearchOptions {ES7Types.esoKeepOnCompletion = Just True}
       in lookup "keep_on_completion" (ES7Types.eqlSearchOptionsParams7 opts) `shouldBe` Just (Just "true")

    it "renders wait_for_completion_timeout verbatim" $
      let opts = ES7Types.defaultEQLSearchOptions {ES7Types.esoWaitForCompletionTimeout = Just "5s"}
       in lookup "wait_for_completion_timeout" (ES7Types.eqlSearchOptionsParams7 opts) `shouldBe` Just (Just "5s")

    it "renders expand_wildcards comma-joined" $
      let opts =
            ES7Types.defaultEQLSearchOptions
              { ES7Types.esoExpandWildcards = Just [ExpandWildcardsOpen, ExpandWildcardsClosed]
              }
       in lookup "expand_wildcards" (ES7Types.eqlSearchOptionsParams7 opts)
            `shouldBe` Just (Just "open,closed")

    it "omits Nothing fields entirely (only sets render)" $
      let opts = ES7Types.defaultEQLSearchOptions {ES7Types.esoIgnoreUnavailable = Just True}
       in ES7Types.eqlSearchOptionsParams7 opts `shouldBe` [("ignore_unavailable", Just "true")]

  describe "eqlSearchOptionsParams8 (ES8/ES9 wire)" $ do
    it "renders nothing for defaultEQLSearchOptions" $
      ES7Types.eqlSearchOptionsParams8 ES7Types.defaultEQLSearchOptions `shouldBe` []

    it "renders allow_partial_search_results as a boolean string" $
      let opts = ES7Types.defaultEQLSearchOptions {ES7Types.esoAllowPartialSearchResults = Just True}
       in lookup "allow_partial_search_results" (ES7Types.eqlSearchOptionsParams8 opts) `shouldBe` Just (Just "true")

    it "renders allow_partial_sequence_results as a boolean string" $
      let opts = ES7Types.defaultEQLSearchOptions {ES7Types.esoAllowPartialSequenceResults = Just True}
       in lookup "allow_partial_sequence_results" (ES7Types.eqlSearchOptionsParams8 opts) `shouldBe` Just (Just "true")

    it "emits every parameter that params7 emits (superset)" $
      let opts =
            ES7Types.defaultEQLSearchOptions
              { ES7Types.esoIgnoreUnavailable = Just True,
                ES7Types.esoAllowPartialSearchResults = Just False
              }
       in sortPairs (ES7Types.eqlSearchOptionsParams8 opts)
            `shouldBe` sortPairs
              [ ("ignore_unavailable", Just "true"),
                ("allow_partial_search_results", Just "false")
              ]

    it "renders the 7.17-documented params identically to params7" $ do
      let opts =
            ES7Types.defaultEQLSearchOptions
              { ES7Types.esoAllowNoIndices = Just True,
                ES7Types.esoExpandWildcards = Just [ExpandWildcardsOpen]
              }
      -- params8 is a superset of params7; when the 8.x-only fields are
      -- unset the two renderers must agree byte-for-byte.
      ES7Types.eqlSearchOptionsParams8 opts `shouldBe` ES7Types.eqlSearchOptionsParams7 opts

  describe "EQLResult JSON" $ do
    it "decodes a complete synchronous response with one event" $ do
      let raw =
            LBS.pack
              "{\"is_running\":false,\"timed_out\":false,\"took\":3,\"hits\":{\"total\":{\"value\":1,\"relation\":\"eq\"},\"events\":[{\"_index\":\"logs-1\",\"_id\":\"1\",\"_source\":{\"event\":{\"category\":\"process\"}},\"_version\":1,\"_seq_no\":0,\"_primary_term\":1}]}}"
      let Just (result :: ES7Types.EQLResult Value) = decode raw
      ES7Types.eqlIsRunning result `shouldBe` Just False
      ES7Types.eqlTimedOut result `shouldBe` Just False
      ES7Types.eqlTook result `shouldBe` Just 3
      let Just hits = ES7Types.eqlHits result
      ES7Types.eqlHitsTotal hits
        `shouldBe` Just (ES7Types.EQLTotal 1 ES7Types.EQLTotalEq)
      length (ES7Types.eqlHitsEvents hits) `shouldBe` 1
      let event = head (ES7Types.eqlHitsEvents hits)
      ES7Types.eqlHitIndex event `shouldBe` Just "logs-1"
      ES7Types.eqlHitDocId event `shouldBe` Just "1"
      ES7Types.eqlHitVersion event `shouldBe` Just 1
      ES7Types.eqlHitSeqNo event `shouldBe` Just 0
      ES7Types.eqlHitPrimaryTerm event `shouldBe` Just 1

    it "decodes a still-running async response (no hits)" $ do
      let raw = LBS.pack "{\"is_running\":true,\"timed_out\":false,\"took\":1}"
      let Just (result :: ES7Types.EQLResult Value) = decode raw
      ES7Types.eqlIsRunning result `shouldBe` Just True
      ES7Types.eqlHits result `shouldSatisfy` isNothing
      -- When the search completes synchronously the server omits the
      -- @id@ key entirely; the new 'eqlId' field must decode as Nothing
      -- so existing eqlSearch callers keep their pre-async semantics.
      ES7Types.eqlId result `shouldBe` Nothing

    it "decodes an async-initiation response carrying the search id" $ do
      -- When @wait_for_completion_timeout@ elapses, the server returns
      -- @{"id": ..., "is_running": true}@ so the caller can poll the
      -- deferred results via 'Requests.getEQLSearch'. The id is the
      -- only piece of state that needs to round-trip to the follow-up
      -- GET, so pin its decoder shape here.
      let raw =
            LBS.pack
              "{\"id\":\"FmRleEctMW5TY2utc0MtOFk4AAAAAAAAAA==\",\"is_running\":true,\"timed_out\":false,\"took\":1}"
      let Just (result :: ES7Types.EQLResult Value) = decode raw
      ES7Types.eqlId result
        `shouldBe` Just (ES7Types.EQLSearchId "FmRleEctMW5TY2utc0MtOFk4AAAAAAAAAA==")
      ES7Types.eqlIsRunning result `shouldBe` Just True
      ES7Types.eqlHits result `shouldSatisfy` isNothing

    it "ignores undocumented keys (total/did_timeout/pits from later ES versions)" $ do
      -- ES 7.17 does not document top-level @total@, @did_timeout@, or
      -- @pits@; later ES versions emit them. They must be silently
      -- ignored so the documented fields still decode.
      let raw =
            LBS.pack
              "{\"is_running\":false,\"timed_out\":false,\"took\":2,\"did_timeout\":false,\"total\":{\"value\":1,\"relation\":\"eq\"},\"pits\":[{\"id\":\"x\",\"keep_alive\":\"1m\"}]}"
      let Just (result :: ES7Types.EQLResult Value) = decode raw
      ES7Types.eqlId result `shouldBe` Nothing
      ES7Types.eqlIsRunning result `shouldBe` Just False
      ES7Types.eqlIsPartial result `shouldBe` Nothing
      ES7Types.eqlTimedOut result `shouldBe` Just False
      ES7Types.eqlTook result `shouldBe` Just 2
      ES7Types.eqlHits result `shouldSatisfy` isNothing

  describe "small types JSON" $ do
    it "round-trips EQLTotal through ToJSON/FromJSON" $ do
      let total = ES7Types.EQLTotal 42 ES7Types.EQLTotalEq
      decode (encode total) `shouldBe` Just total
      let totalGte = ES7Types.EQLTotal 100 ES7Types.EQLTotalGte
      decode (encode totalGte) `shouldBe` Just totalGte

    it "round-trips EQLResultPosition through ToJSON/FromJSON" $ do
      decode (encode ES7Types.EQLHead) `shouldBe` Just ES7Types.EQLHead
      decode (encode ES7Types.EQLTail) `shouldBe` Just ES7Types.EQLTail

    it "round-trips EQLSearchId through ToJSON/FromJSON as a bare string" $ do
      let sid = ES7Types.EQLSearchId "FmRleEctMW5TY2utc0MtOFk4AAAAAAAAAA=="
      encode sid `shouldBe` "\"FmRleEctMW5TY2utc0MtOFk4AAAAAAAAAA==\""
      decode "\"FmRleEctMW5TY2utc0MtOFk4AAAAAAAAAA==\"" `shouldBe` Just sid

    it "rejects a non-string EQLSearchId payload" $ do
      decode "{}" `shouldBe` (Nothing :: Maybe ES7Types.EQLSearchId)
      decode "42" `shouldBe` (Nothing :: Maybe ES7Types.EQLSearchId)

    it "rejects unknown result_position strings" $ do
      let raw = LBS.pack "\"sideways\""
      let parsed = decode raw :: Maybe ES7Types.EQLResultPosition
      parsed `shouldBe` Nothing

    it "rejects unknown total relation strings" $ do
      let raw = LBS.pack "{\"value\":1,\"relation\":\"around\"}"
      let parsed = decode raw :: Maybe ES7Types.EQLTotal
      parsed `shouldBe` Nothing

  describe "endpoint shape" $ do
    let index = [qqIndexName|eql-shape-test|]

    it "ES7 POSTs to /{index}/_eql/search with no query string" $ do
      let req = RequestsES7.eqlSearch @Value index minimalRequest
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` [unIndexName index, "_eql", "search"]
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []

    it "ES7 embeds the query field in the body" $ do
      let req = RequestsES7.eqlSearch @Value index minimalRequest
      let body = bhRequestBody req
      body `shouldSatisfy` isJust
      let Just bodyBytes = body
      hasKey "query" bodyBytes `shouldBe` True

    it "ES8 builds the same endpoint shape (own builder, 8.x body)" $ do
      let req8 = RequestsES8.eqlSearch @Value index minimalRequest
      getRawEndpoint (bhRequestEndpoint req8)
        `shouldBe` [unIndexName index, "_eql", "search"]
      getRawEndpointQueries (bhRequestEndpoint req8) `shouldBe` []

    it "ES9 reuses the ES8 request builder (identical endpoint)" $ do
      let req9 = RequestsES9.eqlSearch @Value index minimalRequest
      getRawEndpoint (bhRequestEndpoint req9)
        `shouldBe` [unIndexName index, "_eql", "search"]
      getRawEndpointQueries (bhRequestEndpoint req9) `shouldBe` []

    it "ES7 eqlSearchWith renders no query string for default options" $ do
      let req = RequestsES7.eqlSearchWith @Value index ES7Types.defaultEQLSearchOptions minimalRequest
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` [unIndexName index, "_eql", "search"]
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []

    it "ES7 eqlSearchWith surfaces options as query parameters" $ do
      let opts =
            ES7Types.defaultEQLSearchOptions
              { ES7Types.esoAllowNoIndices = Just True,
                ES7Types.esoExpandWildcards = Just [ExpandWildcardsOpen]
              }
      let req = RequestsES7.eqlSearchWith @Value index opts minimalRequest
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` [unIndexName index, "_eql", "search"]
      getRawEndpointQueries (bhRequestEndpoint req)
        `shouldBe` [("allow_no_indices", Just "true"), ("expand_wildcards", Just "open")]

    it "ES7 eqlSearchWith still carries the body" $ do
      let req = RequestsES7.eqlSearchWith @Value index ES7Types.defaultEQLSearchOptions minimalRequest
      let Just bodyBytes = bhRequestBody req
      hasKey "query" bodyBytes `shouldBe` True

    it "ES7 eqlSearchWith body omits 8.x-only keys even when the request sets them" $ do
      -- The whole point of bloodhound-jbe: the ES7 wire body must not
      -- carry allow_partial_search_results,
      -- allow_partial_sequence_results or case_sensitive, even when the
      -- caller populated them on the EQLRequest.
      let req = RequestsES7.eqlSearchWith @Value index ES7Types.defaultEQLSearchOptions fullRequest
      let Just bodyBytes = bhRequestBody req
      hasKey "query" bodyBytes `shouldBe` True
      hasKey "allow_partial_search_results" bodyBytes `shouldBe` False
      hasKey "allow_partial_sequence_results" bodyBytes `shouldBe` False
      hasKey "case_sensitive" bodyBytes `shouldBe` False
      -- 7.17-documented fields still go through.
      hasKey "runtime_mappings" bodyBytes `shouldBe` True

    it "ES8 eqlSearchWith body emits the 8.x-only keys when the request sets them" $ do
      -- The ES8 builder keeps the 8.x shape, so the three keys survive
      -- on the wire. This pins the complementary behaviour to the ES7
      -- test above and guards against an accidental over-eager drop.
      let req = RequestsES8.eqlSearchWith @Value index ES7Types.defaultEQLSearchOptions fullRequest
      let Just bodyBytes = bhRequestBody req
      hasKey "allow_partial_search_results" bodyBytes `shouldBe` True
      hasKey "allow_partial_sequence_results" bodyBytes `shouldBe` True
      hasKey "case_sensitive" bodyBytes `shouldBe` True

    it "ES8 eqlSearchWith surfaces the 8.x-only URI params (unlike ES7)" $ do
      -- Symmetric gating on the query-string side: ES8 emits
      -- allow_partial_search_results / allow_partial_sequence_results
      -- as URI parameters, ES7 does not.
      let opts =
            ES7Types.defaultEQLSearchOptions
              { ES7Types.esoAllowPartialSearchResults = Just True,
                ES7Types.esoAllowPartialSequenceResults = Just False
              }
      let req7 = RequestsES7.eqlSearchWith @Value index opts minimalRequest
      let req8 = RequestsES8.eqlSearchWith @Value index opts minimalRequest
      sortPairs (getRawEndpointQueries (bhRequestEndpoint req7)) `shouldBe` []
      sortPairs (getRawEndpointQueries (bhRequestEndpoint req8))
        `shouldBe` [ ("allow_partial_search_results", Just "true"),
                     ("allow_partial_sequence_results", Just "false")
                   ]

    it "ES8 eqlSearchWith uses its own builder but the 7.17 params still surface" $ do
      let opts = ES7Types.defaultEQLSearchOptions {ES7Types.esoIgnoreUnavailable = Just True}
      let req8 = RequestsES8.eqlSearchWith @Value index opts minimalRequest
      getRawEndpoint (bhRequestEndpoint req8)
        `shouldBe` [unIndexName index, "_eql", "search"]
      getRawEndpointQueries (bhRequestEndpoint req8)
        `shouldBe` [("ignore_unavailable", Just "true")]

    it "ES9 eqlSearchWith reuses the ES8 request builder (identical query)" $ do
      let opts = ES7Types.defaultEQLSearchOptions {ES7Types.esoCcsMinimizeRoundtrips = Just False}
      let req9 = RequestsES9.eqlSearchWith @Value index opts minimalRequest
      getRawEndpoint (bhRequestEndpoint req9)
        `shouldBe` [unIndexName index, "_eql", "search"]
      getRawEndpointQueries (bhRequestEndpoint req9)
        `shouldBe` [("ccs_minimize_roundtrips", Just "false")]

  describe "GET /_eql/search/{id} endpoint shape" $ do
    -- Async follow-up: once an EQL search returns an 'EQLSearchId',
    -- callers fetch the deferred results via @GET /_eql/search\/{id}@.
    -- The wire path is index-less (the id encodes the originating
    -- indices server-side), which is why the bead title's
    -- @/{index}/_eql/search/{id}@ form would be a 404 -- pin the
    -- index-less shape here.
    let sid = ES7Types.EQLSearchId "FmRleEctMW5TY2utc0MtOFk4AAAAAAAAAA=="

    it "ES7 GETs /_eql/search/{id} with no body; Nothing omits wait_for_completion_timeout" $ do
      let req = RequestsES7.getEQLSearch @Value sid Nothing
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_eql", "search", unEQLSearchId sid]
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []
      bhRequestBody req `shouldBe` Nothing

    it "ES7 renders wait_for_completion_timeout as a query parameter when given" $ do
      let req = RequestsES7.getEQLSearch @Value sid (Just "5s")
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_eql", "search", unEQLSearchId sid]
      getRawEndpointQueries (bhRequestEndpoint req)
        `shouldBe` [("wait_for_completion_timeout", Just "5s")]

    it "ES8 reuses the ES7 request builder (identical endpoint and query)" $ do
      let req8 = RequestsES8.getEQLSearch @Value sid (Just "5s")
      getRawEndpoint (bhRequestEndpoint req8)
        `shouldBe` ["_eql", "search", unEQLSearchId sid]
      getRawEndpointQueries (bhRequestEndpoint req8)
        `shouldBe` [("wait_for_completion_timeout", Just "5s")]

    it "ES9 reuses the ES8 request builder (identical endpoint and query)" $ do
      let req9 = RequestsES9.getEQLSearch @Value sid (Just "5s")
      getRawEndpoint (bhRequestEndpoint req9)
        `shouldBe` ["_eql", "search", unEQLSearchId sid]
      getRawEndpointQueries (bhRequestEndpoint req9)
        `shouldBe` [("wait_for_completion_timeout", Just "5s")]

  describe "GET /_eql/search/status/{id} endpoint shape" $ do
    let sid = ES7Types.EQLSearchId "FmRleEctMW5TY2utc0MtOFk4AAAAAAAAAA=="

    it "ES7 GETs /_eql/search/status/{id} with no body and no query string" $ do
      let req = RequestsES7.getEQLStatus sid
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_eql", "search", "status", unEQLSearchId sid]
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []
      bhRequestBody req `shouldBe` Nothing

    it "ES7 uses a distinct id in the path when given a different EQLSearchId" $ do
      let req = RequestsES7.getEQLStatus (ES7Types.EQLSearchId "other-id")
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_eql", "search", "status", "other-id"]

    it "ES7 keeps the id as a single opaque path segment" $ do
      let req = RequestsES7.getEQLStatus (ES7Types.EQLSearchId "Fm_==_RxD_123")
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_eql", "search", "status", "Fm_==_RxD_123"]

    it "ES8 reuses the ES7 request builder (identical endpoint)" $ do
      let req8 = RequestsES8.getEQLStatus sid
      getRawEndpoint (bhRequestEndpoint req8)
        `shouldBe` ["_eql", "search", "status", unEQLSearchId sid]
      getRawEndpointQueries (bhRequestEndpoint req8) `shouldBe` []
      bhRequestBody req8 `shouldBe` Nothing

    it "ES9 reuses the ES8 request builder (identical endpoint)" $ do
      let req9 = RequestsES9.getEQLStatus sid
      getRawEndpoint (bhRequestEndpoint req9)
        `shouldBe` ["_eql", "search", "status", unEQLSearchId sid]
      getRawEndpointQueries (bhRequestEndpoint req9) `shouldBe` []
      bhRequestBody req9 `shouldBe` Nothing

  describe "getEQLStatus response decode" $ do
    let decodeSid = ES7Types.EQLSearchId "FmRleEctMW5TY2utc0MtOFk4AAAAAAAAAA=="

    it "decodes a completed-search status response" $ do
      let raw =
            LBS.pack
              "{\"id\":\"FmRleEctMW5TY2utc0MtOFk4AAAAAAAAAA==\",\"is_running\":false,\"is_partial\":false,\"expiration_time_in_millis\":1782109853290,\"completion_status\":200}"
      let Just (status :: ES7Types.EQLStatus) = decode raw
      ES7Types.eqlStatusId status `shouldBe` Just decodeSid
      ES7Types.eqlStatusIsRunning status `shouldBe` Just False
      ES7Types.eqlStatusIsPartial status `shouldBe` Just False
      ES7Types.eqlStatusCompletionStatus status `shouldBe` Just 200
      ES7Types.eqlStatusExpirationTimeInMillis status `shouldBe` Just 1782109853290

    it "decodes a running-search status response (no completion_status)" $ do
      let raw =
            LBS.pack
              "{\"id\":\"FmRleEctRXJtaVNZQ1I2UkJoTDJfQkEAAAAAAAAUX0Q2UkRLNEVBYlhVZ19VUUZmeld3Zw==\",\"is_running\":true,\"is_partial\":true}"
      let Just (status :: ES7Types.EQLStatus) = decode raw
      ES7Types.eqlStatusId status
        `shouldBe` Just
          ( ES7Types.EQLSearchId
              "FmRleEctRXJtaVNZQ1I2UkJoTDJfQkEAAAAAAAAUX0Q2UkRLNEVBYlhVZ19VUUZmeld3Zw=="
          )
      ES7Types.eqlStatusIsRunning status `shouldBe` Just True
      ES7Types.eqlStatusIsPartial status `shouldBe` Just True
      ES7Types.eqlStatusCompletionStatus status `shouldBe` Nothing

    it "decodes a running-search status with start_time_in_millis" $ do
      let raw =
            LBS.pack
              "{\"id\":\"abc\",\"is_running\":true,\"is_partial\":true,\"start_time_in_millis\":1782109853000}"
      let Just (status :: ES7Types.EQLStatus) = decode raw
      ES7Types.eqlStatusIsRunning status `shouldBe` Just True
      ES7Types.eqlStatusStartTimeInMillis status `shouldBe` Just 1782109853000

    it "tolerates unknown extra fields" $ do
      let raw =
            LBS.pack
              "{\"id\":\"x\",\"is_running\":false,\"future_field\":\"value\"}"
      let Just (status :: ES7Types.EQLStatus) = decode raw
      ES7Types.eqlStatusId status `shouldBe` Just (ES7Types.EQLSearchId "x")

    it "completion_status is Nothing when omitted" $ do
      let raw = LBS.pack "{\"is_running\":true}"
      let Just (status :: ES7Types.EQLStatus) = decode raw
      ES7Types.eqlStatusCompletionStatus status `shouldBe` Nothing

  describe "EQL sequence-query results" $ do
    -- EQL @sequence ... with ...@ queries return matches under the
    -- @hits.sequences@ array rather than @hits.events@. Without this
    -- field modelled, a successful sequence query polled via
    -- 'Requests.getEQLSearch' would silently look empty.
    it "decodes a sequence query response (events + join_keys)" $ do
      let raw =
            LBS.pack
              "{\"is_running\":false,\"took\":4,\"hits\":{\"total\":{\"value\":1,\"relation\":\"eq\"},\"sequences\":[{\"events\":[{\"_index\":\"logs-1\",\"_id\":\"a\",\"_source\":{\"event\":{\"category\":\"process\"}}},{\"_index\":\"logs-1\",\"_id\":\"b\",\"_source\":{\"event\":{\"category\":\"process\"}}}],\"join_keys\":[\"cmd.exe\"]}]}}"
      let Just (result :: ES7Types.EQLResult Value) = decode raw
      ES7Types.eqlIsPartial result `shouldBe` Nothing
      let Just hits = ES7Types.eqlHits result
      -- Event queries populate 'eqlHitsEvents' (defaults to []), but a
      -- pure-sequence response leaves it empty; the matched events are
      -- nested under each 'EQLSequence'.
      ES7Types.eqlHitsEvents hits `shouldBe` []
      let Just seqs = ES7Types.eqlHitsSequences hits
      length seqs `shouldBe` 1
      let s = head seqs
      length (ES7Types.eqlSequenceEvents s) `shouldBe` 2
      ES7Types.eqlSequenceJoinKeys s `shouldBe` Just [toJSON ("cmd.exe" :: Text)]

    it "eqlHitsSequences is Nothing for a pure-event response" $ do
      let raw =
            LBS.pack
              "{\"hits\":{\"total\":{\"value\":1,\"relation\":\"eq\"},\"events\":[{\"_index\":\"logs-1\",\"_id\":\"a\",\"_source\":{\"event\":{\"category\":\"process\"}}}]}}"
      let Just (result :: ES7Types.EQLResult Value) = decode raw
      let Just hits = ES7Types.eqlHits result
      ES7Types.eqlHitsSequences hits `shouldBe` Nothing
      length (ES7Types.eqlHitsEvents hits) `shouldBe` 1

    it "decodes is_partial=true on a partial-result response" $ do
      let raw =
            LBS.pack
              "{\"id\":\"FmRleEctMW5TY2utc0MtOFk4AAAAAAAAAA==\",\"is_running\":false,\"is_partial\":true,\"took\":1}"
      let Just (result :: ES7Types.EQLResult Value) = decode raw
      ES7Types.eqlIsPartial result `shouldBe` Just True
      -- A partial response leaves hits absent; callers should treat
      -- is_partial=true as terminal rather than retry.
      ES7Types.eqlHits result `shouldSatisfy` isNothing

    it "eqlIsPartial is Nothing when the server omits the key" $ do
      let raw = LBS.pack "{\"is_running\":false,\"took\":3}"
      let Just (result :: ES7Types.EQLResult Value) = decode raw
      ES7Types.eqlIsPartial result `shouldBe` Nothing

  describe "live integration (requires ES7.10+ backend)" $
    backendSpecific [ElasticSearch7, ElasticSearch8, ElasticSearch9] $ do
      it "returns events matching an EQL predicate" $
        withTestEnv $ do
          _ <- insertEqlData
          result <- eqlSearchForBackend ("process where true" :: Text)
          case result of
            Left e ->
              liftIO $
                expectationFailure $
                  "eqlSearch failed: " <> show e
            Right eqlResult -> liftIO $ do
              ES7Types.eqlIsRunning eqlResult
                `shouldSatisfy` (\x -> x == Just False || isNothing x)
              let mbHits = ES7Types.eqlHits eqlResult
              mbHits `shouldSatisfy` isJust
              let Just hits = mbHits
              length (ES7Types.eqlHitsEvents hits)
                `shouldSatisfy` (\n -> n >= 1)
              let total = ES7Types.eqlHitsTotal hits
              total `shouldSatisfy` isJust
              let Just (ES7Types.EQLTotal v _) = total
              v `shouldSatisfy` (>= 1)

  describe "EQLResult id field" $ do
    it "decodes the top-level id when present" $ do
      let Just (result :: ES7Types.EQLResult Value) = decode sampleAsyncResponseWithId
      ES7Types.eqlId result `shouldBe` Just (ES7Types.EQLSearchId "FmRleEctRXJtaVNZQ1I2UkJoTDJfQkEAAAAAAAAUX0Q2UkRLNEVBYlhVZ19VUUZmeld3Zw==")

    it "leaves eqlId at Nothing when the response has no id (synchronous)" $ do
      let raw = LBS.pack "{\"is_running\":false,\"took\":3}"
      let Just (result :: ES7Types.EQLResult Value) = decode raw
      ES7Types.eqlId result `shouldBe` Nothing

    it "tolerates unknown extra fields alongside id" $ do
      let raw = LBS.pack "{\"id\":\"abc\",\"is_running\":true,\"future_field\":\"x\"}"
      let Just (result :: ES7Types.EQLResult Value) = decode raw
      ES7Types.eqlId result `shouldBe` Just (ES7Types.EQLSearchId "abc")

  describe "EQLSearchId JSON" $ do
    it "decodes a bare string" $ do
      decode "\"FmRleEct==\"" `shouldBe` Just (ES7Types.EQLSearchId "FmRleEct==")

    it "encodes back to a bare string" $ do
      encode (ES7Types.EQLSearchId "FmRleEct==") `shouldBe` "\"FmRleEct==\""

    it "round-trips through ToJSON/FromJSON" $ do
      let original = ES7Types.EQLSearchId "Fm_==_RxD_123"
      (decode . encode) original `shouldBe` Just original

  describe "deleteEQLSearch endpoint shape" $ do
    it "ES7 DELETEs /_eql/search/{id} when given an EQLSearchId" $ do
      let req = RequestsES7.deleteEQLSearch (ES7Types.EQLSearchId "FmRleEct")
      getRawEndpoint (bhRequestEndpoint req) `shouldBe` ["_eql", "search", "FmRleEct"]
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []

    it "ES7 uses DELETE and attaches no body" $ do
      let req = RequestsES7.deleteEQLSearch (ES7Types.EQLSearchId "FmRleEct")
      bhRequestMethod req `shouldBe` "DELETE"
      bhRequestBody req `shouldBe` Nothing

    it "ES7 uses a distinct id in the path when given a different EQLSearchId" $ do
      let req = RequestsES7.deleteEQLSearch (ES7Types.EQLSearchId "other-id")
      getRawEndpoint (bhRequestEndpoint req) `shouldBe` ["_eql", "search", "other-id"]

    it "ES7 keeps the id as a single opaque path segment" $ do
      let req = RequestsES7.deleteEQLSearch (ES7Types.EQLSearchId "Fm_==_RxD_123")
      getRawEndpoint (bhRequestEndpoint req) `shouldBe` ["_eql", "search", "Fm_==_RxD_123"]

    it "ES8 reuses the ES7 request builder (identical endpoint)" $ do
      let req8 = RequestsES8.deleteEQLSearch (ES7Types.EQLSearchId "FmRleEct")
      getRawEndpoint (bhRequestEndpoint req8) `shouldBe` ["_eql", "search", "FmRleEct"]
      getRawEndpointQueries (bhRequestEndpoint req8) `shouldBe` []
      bhRequestBody req8 `shouldBe` Nothing

    it "ES9 reuses the ES8 request builder (identical endpoint)" $ do
      let req9 = RequestsES9.deleteEQLSearch (ES7Types.EQLSearchId "FmRleEct")
      getRawEndpoint (bhRequestEndpoint req9) `shouldBe` ["_eql", "search", "FmRleEct"]
      getRawEndpointQueries (bhRequestEndpoint req9) `shouldBe` []
      bhRequestBody req9 `shouldBe` Nothing

  describe "deleteEQLSearch response decode" $ do
    it "decodes {\"acknowledged\": true} as Acknowledged True" $ do
      decode "{\"acknowledged\":true}" `shouldBe` Just (Acknowledged True)

  describe "deleteEQLSearch (live)" $
    backendSpecific [ElasticSearch7, ElasticSearch8, ElasticSearch9] $ do
      it "surfaces a server error for a non-existent EQL search id" $
        withTestEnv $ do
          -- ES rejects unknown ids with 404 and an error body that does
          -- not parse as Acknowledged, which surfaces as an EsError.
          result <-
            tryEsError
              ( performBHRequest $
                  RequestsES7.deleteEQLSearch
                    (ES7Types.EQLSearchId "bloodhound-no-such-eql-search-04f-5-6-4")
              )
          case result of
            Left _err -> pure ()
            Right r ->
              liftIO $
                expectationFailure $
                  "expected EsError for missing EQL search, got: " <> show r

  describe "getEQLStatus (live)" $
    backendSpecific [ElasticSearch7, ElasticSearch8, ElasticSearch9] $ do
      it "returns the status of an async EQL search" $
        withTestEnv $ do
          _ <- insertEqlData
          -- Start an async EQL search: wait_for_completion_timeout="0ms"
          -- forces an immediate return with an id; keep_on_completion=true
          -- ensures the search context survives so status is available.
          let asyncReq =
                minimalRequest
                  { ES7Types.eqlWaitForCompletionTimeout = Just "0ms",
                    ES7Types.eqlKeepOnCompletion = Just True,
                    ES7Types.eqlKeepAlive = Just "1m"
                  }
          searchResult <- eqlSearchAsyncForBackend asyncReq
          case searchResult of
            Left e ->
              liftIO $
                expectationFailure $
                  "async eqlSearch failed: " <> show e
            Right initialResult -> do
              let mSid = ES7Types.eqlId initialResult
              liftIO $ mSid `shouldSatisfy` isJust
              let Just sid = mSid
              statusResult <- getEQLStatusForBackend sid
              case statusResult of
                Left e ->
                  liftIO $
                    expectationFailure $
                      "getEQLStatus failed: " <> show e
                Right status -> liftIO $ do
                  ES7Types.eqlStatusId status `shouldBe` Just sid
                  ES7Types.eqlStatusIsRunning status `shouldSatisfy` isJust
              -- Cleanup: delete the async search context.
              _ <- tryEsError (performBHRequest $ RequestsES7.deleteEQLSearch sid)
              pure ()

-- ---------------------------------------------------------------------------
-- - EQL test fixtures
-- ---------------------------------------------------------------------------

eqlTestIndex :: IndexName
eqlTestIndex = [qqIndexName|bloodhound-tests-eql-1|]

-- | Sample event document. The @timestamp@ and @event.category@ fields
-- are required by EQL; @message@ is included so the decoded @_source@
-- has a recognisable value.
data EqlEvent = EqlEvent
  { eqlEventTimestamp :: Text,
    eqlEventEvent :: EventCategory,
    eqlEventMessage :: Text
  }
  deriving stock (Eq, Show)

newtype EventCategory = EventCategory {eventCategory :: Value}
  deriving newtype (Eq, Show, ToJSON, FromJSON)

instance ToJSON EqlEvent where
  toJSON e =
    object
      [ "@timestamp" .= eqlEventTimestamp e,
        "event" .= eqlEventEvent e,
        "message" .= eqlEventMessage e
      ]

instance FromJSON EqlEvent where
  parseJSON (Object v) =
    EqlEvent
      <$> v .: "@timestamp"
      <*> v .: "event"
      <*> v .: "message"
  parseJSON _ = empty

-- | Mapping declaring @\\@timestamp@ as a date and @event.category@ as a
-- keyword — the two fields EQL needs to order events.
eqlEventMapping :: Value
eqlEventMapping =
  object
    [ "properties"
        .= object
          [ "@timestamp" .= object ["type" .= ("date" :: Text)],
            "event"
              .= object
                [ "properties"
                    .= object
                      ["category" .= object ["type" .= ("keyword" :: Text)]]
                ],
            "message" .= object ["type" .= ("keyword" :: Text)]
          ]
    ]

exampleEqlEventA :: EqlEvent
exampleEqlEventA =
  EqlEvent
    { eqlEventTimestamp = "2020-12-06T21:00:00.000Z",
      eqlEventEvent = EventCategory (object ["category" .= ("process" :: Text)]),
      eqlEventMessage = "process A"
    }

exampleEqlEventB :: EqlEvent
exampleEqlEventB =
  EqlEvent
    { eqlEventTimestamp = "2020-12-06T21:00:01.000Z",
      eqlEventEvent = EventCategory (object ["category" .= ("process" :: Text)]),
      eqlEventMessage = "process B"
    }

deleteEqlExampleIndex :: BH IO (BHResponse StatusDependant Acknowledged, Acknowledged)
deleteEqlExampleIndex =
  performBHRequest $ keepBHResponse $ deleteIndex eqlTestIndex

createEqlExampleIndex :: BH IO (BHResponse StatusDependant Acknowledged, Acknowledged)
createEqlExampleIndex = do
  result <-
    tryPerformBHRequest
      ( keepBHResponse $
          createIndex
            (IndexSettings (ShardCount 1) (ReplicaCount 0) defaultIndexMappingsLimits)
            eqlTestIndex
      )
  case result of
    Left e
      | "already exists" `T.isSuffixOf` errorMessage e ->
          return (error "createEqlExampleIndex: already exists", Acknowledged False)
      | otherwise -> throwEsError e
    Right ack -> return ack

-- | Reset (delete + recreate + map + seed) the EQL test index.
insertEqlData :: BH IO ()
insertEqlData = do
  _ <- tryEsError deleteEqlExampleIndex
  _ <- createEqlExampleIndex
  _ <- performBHRequest $ putMapping @Value eqlTestIndex eqlEventMapping
  _ <- performBHRequest $ indexDocument eqlTestIndex defaultIndexDocumentSettings exampleEqlEventA (DocId "a")
  _ <- performBHRequest $ indexDocument eqlTestIndex defaultIndexDocumentSettings exampleEqlEventB (DocId "b")
  _ <- performBHRequest $ refreshIndex eqlTestIndex
  pure ()

-- | Dispatch to the right eqlSearch client based on the detected backend.
-- The wire format is identical across ES 7.10+, 8.x and 9.x; the dispatch
-- exists only because each version has its own 'WithBackend' constraint.
eqlSearchForBackend :: Text -> BH IO (Either EsError (ES7Types.EQLResult EqlEvent))
eqlSearchForBackend queryText = do
  backend <- liftIO detectBackendType
  let req = minimalRequest {ES7Types.eqlQuery = queryText}
  case backend of
    Just ElasticSearch7 -> ClientES7.eqlSearch eqlTestIndex req
    Just ElasticSearch8 -> ClientES8.eqlSearch eqlTestIndex req
    Just ElasticSearch9 -> ClientES9.eqlSearch eqlTestIndex req
    _ -> ClientES8.eqlSearch eqlTestIndex req

-- | Dispatch 'eqlSearch' with a caller-supplied 'EQLRequest' (used for
-- async-mode searches where @wait_for_completion_timeout@ and
-- @keep_on_completion@ differ from the minimal defaults).
eqlSearchAsyncForBackend :: ES7Types.EQLRequest -> BH IO (Either EsError (ES7Types.EQLResult EqlEvent))
eqlSearchAsyncForBackend req = do
  backend <- liftIO detectBackendType
  case backend of
    Just ElasticSearch7 -> ClientES7.eqlSearch eqlTestIndex req
    Just ElasticSearch8 -> ClientES8.eqlSearch eqlTestIndex req
    Just ElasticSearch9 -> ClientES9.eqlSearch eqlTestIndex req
    _ -> ClientES8.eqlSearch eqlTestIndex req

-- | Dispatch 'getEQLStatus' based on the detected backend. The wire
-- format is identical across ES 7.10+, 8.x and 9.x; the dispatch exists
-- only because each version has its own 'WithBackend' constraint.
getEQLStatusForBackend :: ES7Types.EQLSearchId -> BH IO (Either EsError ES7Types.EQLStatus)
getEQLStatusForBackend sid = do
  backend <- liftIO detectBackendType
  case backend of
    Just ElasticSearch7 -> tryEsError (ClientES7.getEQLStatus sid)
    Just ElasticSearch8 -> tryEsError (ClientES8.getEQLStatus sid)
    Just ElasticSearch9 -> tryEsError (ClientES9.getEQLStatus sid)
    _ -> tryEsError (ClientES8.getEQLStatus sid)