packages feed

bloodhound-1.0.0.0: tests/Test/SecurityAnalyticsSpec.hs

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

module Test.SecurityAnalyticsSpec (spec) where

import Data.ByteString.Lazy.Char8 qualified as LBS
import Data.Map.Strict qualified as M
import Data.Maybe (fromJust, isJust)
import Database.Bloodhound.OpenSearch2.Client qualified as OS2.Client
import Database.Bloodhound.OpenSearch2.Requests qualified as OS2Requests
import Database.Bloodhound.OpenSearch2.Types qualified as OS2A
import Database.Bloodhound.OpenSearch3.Client qualified as OS3.Client
import Database.Bloodhound.OpenSearch3.Requests qualified as OS3Requests
import Database.Bloodhound.OpenSearch3.Types qualified as OS3A
import TestsUtils.Common
import TestsUtils.Import
import Prelude

-- | Pure endpoint-shape and decoder tests for the Security Analytics
-- plugin detector create\/get and rule search endpoints across
-- OpenSearch 2 and 3. Mirrors 'Test.AlertingSpec' for the shape
-- tests and decoder-fixture pinning.
--
-- The detector 'SADetector' is modelled pragmatically (typed shell +
-- opaque aeson 'Value' catch-alls) following the 'Monitor' precedent.
-- These tests pin (a) the endpoint shape per backend, (b) OS2\/OS3
-- cross-backend parity, (c) decoder round-trips for the documented
-- wire fixtures (incl. the severity-as-string and actions-as-array
-- gotchas), and (d) the rule-search envelope decoding.
--
-- OpenSearch 1.3 does not ship the Security Analytics plugin (it was
-- introduced in OS 2.4), so the OS1 module deliberately does not
-- expose these endpoints; see the changelog entry for bloodhound-364b.
--
-- The live round-trip is @pendingWith@'d: the CI docker-compose runs
-- Elasticsearch clusters only, so the Security Analytics plugin
-- (OS-specific) is not available in CI. Re-enable once a live OS
-- cluster is wired in (same precedent as 'Test.AlertingSpec' /
-- 'Test.KnnWarmupSpec' / 'Test.IsmSpec' live blocks).
spec :: Spec
spec = describe "Security Analytics detector and rule APIs" $ do
  -- =========================================================== --
  -- Endpoint shape: OpenSearch 3 (detailed)                      --
  -- =========================================================== --
  describe "endpoint shape (OpenSearch 3)" $ do
    it "createSADetector POSTs to /_plugins/_security_analytics/detectors with the encoded body" $ do
      let req = OS3Requests.createSADetector os3SampleDetector
      bhRequestMethod req `shouldBe` "POST"
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_plugins", "_security_analytics", "detectors"]
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []
      let body = fromJust (bhRequestBody req)
      let decoded = (decode body :: Maybe OS3A.SADetector)
      decoded `shouldSatisfy` \m -> isJust m && OS3A.saDetectorName (fromJust m) == "test-detector"

    it "getSADetector GETs /_plugins/_security_analytics/detectors/{id} with no body" $ do
      let req = OS3Requests.getSADetector (OS3A.DetectorId "my-detector-id")
      bhRequestMethod req `shouldBe` "GET"
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_plugins", "_security_analytics", "detectors", "my-detector-id"]
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []
      bhRequestBody req `shouldBe` Nothing

    it "getSADetector keeps the id as a single opaque path segment" $ do
      let req = OS3Requests.getSADetector (OS3A.DetectorId "my-detector_42")
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_plugins", "_security_analytics", "detectors", "my-detector_42"]

    it "searchSAPrePackagedRules POSTs to /_plugins/_security_analytics/rules/_search?pre_packaged=true" $ do
      let req = OS3Requests.searchSAPrePackagedRules Nothing
      bhRequestMethod req `shouldBe` "POST"
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_plugins", "_security_analytics", "rules", "_search"]
      getRawEndpointQueries (bhRequestEndpoint req)
        `shouldBe` [("pre_packaged", Just "true")]
      -- Default empty-body POST renders to the literal "{}".
      bhRequestBody req `shouldBe` Just (encode (object []))

    it "searchSACustomRules POSTs to /_plugins/_security_analytics/rules/_search?pre_packaged=false" $ do
      let req = OS3Requests.searchSACustomRules Nothing
      bhRequestMethod req `shouldBe` "POST"
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_plugins", "_security_analytics", "rules", "_search"]
      getRawEndpointQueries (bhRequestEndpoint req)
        `shouldBe` [("pre_packaged", Just "false")]

    it "searchSAPrePackagedRules forwards the query body when supplied" $ do
      let q =
            OS3A.SARulesQuery
              { OS3A.saRulesQueryFrom = Just 0,
                OS3A.saRulesQuerySize = Just 20,
                OS3A.saRulesQueryQuery = Nothing
              }
      let req = OS3Requests.searchSAPrePackagedRules (Just q)
      let body = fromJust (bhRequestBody req)
      -- The body must contain the from/size keys.
      LBS.unpack body `shouldContain` "\"from\""
      LBS.unpack body `shouldContain` "\"size\""

    it "createSACustomRule POSTs to /_plugins/_security_analytics/rules?category={logtype} with raw Sigma YAML body" $ do
      let req = OS3Requests.createSACustomRule OS3A.SADetectorTypeWindows sampleSigmaYaml
      bhRequestMethod req `shouldBe` "POST"
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_plugins", "_security_analytics", "rules"]
      getRawEndpointQueries (bhRequestEndpoint req)
        `shouldBe` [("category", Just "windows")]
      -- Body is the raw Sigma YAML text (NOT JSON-encoded).
      let body = fromJust (bhRequestBody req)
      LBS.unpack body `shouldContain` "title: Moriya Rootkit"

    it "createSACustomRule renders the category from saDetectorTypeText" $ do
      let req = OS3Requests.createSACustomRule OS3A.SADetectorTypeLinux "title: x"
      getRawEndpointQueries (bhRequestEndpoint req)
        `shouldBe` [("category", Just "linux")]

    it "updateSACustomRule PUTs to /_plugins/_security_analytics/rules/{id}?category={logtype} with no forced when False" $ do
      let req = OS3Requests.updateSACustomRule (OS3A.RuleId "my-rule-id") OS3A.SADetectorTypeWindows False sampleSigmaYaml
      bhRequestMethod req `shouldBe` "PUT"
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_plugins", "_security_analytics", "rules", "my-rule-id"]
      getRawEndpointQueries (bhRequestEndpoint req)
        `shouldBe` [("category", Just "windows")]
      let body = fromJust (bhRequestBody req)
      LBS.unpack body `shouldContain` "title: Moriya Rootkit"

    it "updateSACustomRule adds forced=true when forced" $ do
      let req = OS3Requests.updateSACustomRule (OS3A.RuleId "my-rule-id") OS3A.SADetectorTypeWindows True sampleSigmaYaml
      getRawEndpointQueries (bhRequestEndpoint req)
        `shouldContain` [("forced", Just "true")]

    it "updateSACustomRule keeps the id as a single opaque path segment" $ do
      let req = OS3Requests.updateSACustomRule (OS3A.RuleId "rule_42#v1") OS3A.SADetectorTypeWindows False "title: x"
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_plugins", "_security_analytics", "rules", "rule_42#v1"]

    it "deleteSACustomRule DELETEs /_plugins/_security_analytics/rules/{id} with no body and no forced when False" $ do
      let req = OS3Requests.deleteSACustomRule (OS3A.RuleId "my-rule-id") False
      bhRequestMethod req `shouldBe` "DELETE"
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_plugins", "_security_analytics", "rules", "my-rule-id"]
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []
      bhRequestBody req `shouldBe` Nothing

    it "deleteSACustomRule adds forced=true when forced" $ do
      let req = OS3Requests.deleteSACustomRule (OS3A.RuleId "my-rule-id") True
      getRawEndpointQueries (bhRequestEndpoint req)
        `shouldBe` [("forced", Just "true")]

    it "deleteSACustomRule keeps the id as a single opaque path segment" $ do
      let req = OS3Requests.deleteSACustomRule (OS3A.RuleId "rule_42#v1") False
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_plugins", "_security_analytics", "rules", "rule_42#v1"]

    it "updateSADetector PUTs to /_plugins/_security_analytics/detectors/{id} with the encoded body" $ do
      let req = OS3Requests.updateSADetector (OS3A.DetectorId "my-detector-id") os3SampleDetector
      bhRequestMethod req `shouldBe` "PUT"
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_plugins", "_security_analytics", "detectors", "my-detector-id"]
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []
      let body = fromJust (bhRequestBody req)
      let decoded = (decode body :: Maybe OS3A.SADetector)
      decoded `shouldSatisfy` \m -> isJust m && OS3A.saDetectorName (fromJust m) == "test-detector"

    it "deleteSADetector DELETEs /_plugins/_security_analytics/detectors/{id} with no body" $ do
      let req = OS3Requests.deleteSADetector (OS3A.DetectorId "my-detector-id")
      bhRequestMethod req `shouldBe` "DELETE"
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_plugins", "_security_analytics", "detectors", "my-detector-id"]
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []
      bhRequestBody req `shouldBe` Nothing

    it "deleteSADetector keeps the id as a single opaque path segment" $ do
      let req = OS3Requests.deleteSADetector (OS3A.DetectorId "my-detector_42")
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_plugins", "_security_analytics", "detectors", "my-detector_42"]

    it "searchSADetectors POSTs to /_plugins/_security_analytics/detectors/_search with default {} body" $ do
      let req = OS3Requests.searchSADetectors Nothing
      bhRequestMethod req `shouldBe` "POST"
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_plugins", "_security_analytics", "detectors", "_search"]
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []
      -- Default empty-body POST renders to the literal "{}".
      bhRequestBody req `shouldBe` Just (encode (object []))

    it "searchSADetectors forwards the query body when supplied" $ do
      let q =
            OS3A.SADetectorsSearchQuery
              { OS3A.saDetectorsSearchQueryFrom = Just 0,
                OS3A.saDetectorsSearchQuerySize = Just 20,
                OS3A.saDetectorsSearchQueryQuery = Nothing
              }
      let req = OS3Requests.searchSADetectors (Just q)
      let body = fromJust (bhRequestBody req)
      LBS.unpack body `shouldContain` "\"from\""
      LBS.unpack body `shouldContain` "\"size\""

  -- =========================================================== --
  -- Endpoint shape: OpenSearch 2 (path/method parity)            --
  -- =========================================================== --
  describe "endpoint shape (OpenSearch 2)" $ do
    it "createSADetector POSTs to /_plugins/_security_analytics/detectors" $ do
      let req = OS2Requests.createSADetector os2SampleDetector
      bhRequestMethod req `shouldBe` "POST"
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_plugins", "_security_analytics", "detectors"]

    it "getSADetector GETs /_plugins/_security_analytics/detectors/{id}" $ do
      let req = OS2Requests.getSADetector (OS2A.DetectorId "os2-id")
      bhRequestMethod req `shouldBe` "GET"
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_plugins", "_security_analytics", "detectors", "os2-id"]
      bhRequestBody req `shouldBe` Nothing

    it "searchSAPrePackagedRules POSTs with pre_packaged=true" $ do
      let req = OS2Requests.searchSAPrePackagedRules Nothing
      getRawEndpointQueries (bhRequestEndpoint req)
        `shouldBe` [("pre_packaged", Just "true")]

    it "searchSACustomRules POSTs with pre_packaged=false" $ do
      let req = OS2Requests.searchSACustomRules Nothing
      getRawEndpointQueries (bhRequestEndpoint req)
        `shouldBe` [("pre_packaged", Just "false")]

    it "createSACustomRule POSTs with category query param and raw YAML body" $ do
      let req = OS2Requests.createSACustomRule OS2A.SADetectorTypeWindows sampleSigmaYaml
      bhRequestMethod req `shouldBe` "POST"
      getRawEndpointQueries (bhRequestEndpoint req)
        `shouldBe` [("category", Just "windows")]

    it "updateSACustomRule PUTs with category query param" $ do
      let req = OS2Requests.updateSACustomRule (OS2A.RuleId "os2-id") OS2A.SADetectorTypeWindows False sampleSigmaYaml
      bhRequestMethod req `shouldBe` "PUT"
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_plugins", "_security_analytics", "rules", "os2-id"]

    it "deleteSACustomRule DELETEs /_plugins/_security_analytics/rules/{id}" $ do
      let req = OS2Requests.deleteSACustomRule (OS2A.RuleId "os2-id") True
      bhRequestMethod req `shouldBe` "DELETE"
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_plugins", "_security_analytics", "rules", "os2-id"]
      getRawEndpointQueries (bhRequestEndpoint req)
        `shouldBe` [("forced", Just "true")]

    it "updateSADetector PUTs to /_plugins/_security_analytics/detectors/{id}" $ do
      let req = OS2Requests.updateSADetector (OS2A.DetectorId "os2-id") os2SampleDetector
      bhRequestMethod req `shouldBe` "PUT"
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_plugins", "_security_analytics", "detectors", "os2-id"]

    it "deleteSADetector DELETEs /_plugins/_security_analytics/detectors/{id}" $ do
      let req = OS2Requests.deleteSADetector (OS2A.DetectorId "os2-id")
      bhRequestMethod req `shouldBe` "DELETE"
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_plugins", "_security_analytics", "detectors", "os2-id"]
      bhRequestBody req `shouldBe` Nothing

    it "searchSADetectors POSTs to /_plugins/_security_analytics/detectors/_search" $ do
      let req = OS2Requests.searchSADetectors Nothing
      bhRequestMethod req `shouldBe` "POST"
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_plugins", "_security_analytics", "detectors", "_search"]

  -- =========================================================== --
  -- Cross-backend parity (OS2, OS3 emit identical requests)      --
  -- =========================================================== --
  describe "cross-backend parity (OS2/OS3)" $ do
    let did2 = OS2A.DetectorId "shared-id"
        did3 = OS3A.DetectorId "shared-id"

    it "createSADetector: OS2, OS3 produce the same path and method" $ do
      getRawEndpoint (bhRequestEndpoint (OS2Requests.createSADetector os2SampleDetector))
        `shouldBe` getRawEndpoint (bhRequestEndpoint (OS3Requests.createSADetector os3SampleDetector))
      bhRequestMethod (OS2Requests.createSADetector os2SampleDetector)
        `shouldBe` bhRequestMethod (OS3Requests.createSADetector os3SampleDetector)

    it "createSADetector: OS2, OS3 attach identical body bytes" $ do
      let b2 = bhRequestBody (OS2Requests.createSADetector os2SampleDetector)
          b3 = bhRequestBody (OS3Requests.createSADetector os3SampleDetector)
      b2 `shouldBe` b3

    it "getSADetector: OS2, OS3 produce the same path and method" $ do
      getRawEndpoint (bhRequestEndpoint (OS2Requests.getSADetector did2))
        `shouldBe` getRawEndpoint (bhRequestEndpoint (OS3Requests.getSADetector did3))
      bhRequestMethod (OS2Requests.getSADetector did2)
        `shouldBe` bhRequestMethod (OS3Requests.getSADetector did3)

    it "searchSAPrePackagedRules: OS2, OS3 produce the same path and queries" $ do
      getRawEndpoint (bhRequestEndpoint (OS2Requests.searchSAPrePackagedRules Nothing))
        `shouldBe` getRawEndpoint (bhRequestEndpoint (OS3Requests.searchSAPrePackagedRules Nothing))
      getRawEndpointQueries (bhRequestEndpoint (OS2Requests.searchSAPrePackagedRules Nothing))
        `shouldBe` getRawEndpointQueries (bhRequestEndpoint (OS3Requests.searchSAPrePackagedRules Nothing))

    it "pre_packaged vs custom: same path, distinct query value" $ do
      getRawEndpoint (bhRequestEndpoint (OS3Requests.searchSAPrePackagedRules Nothing))
        `shouldBe` getRawEndpoint (bhRequestEndpoint (OS3Requests.searchSACustomRules Nothing))
      getRawEndpointQueries (bhRequestEndpoint (OS3Requests.searchSAPrePackagedRules Nothing))
        `shouldNotBe` getRawEndpointQueries (bhRequestEndpoint (OS3Requests.searchSACustomRules Nothing))

    it "updateSADetector: OS2, OS3 produce the same path and method" $ do
      getRawEndpoint (bhRequestEndpoint (OS2Requests.updateSADetector did2 os2SampleDetector))
        `shouldBe` getRawEndpoint (bhRequestEndpoint (OS3Requests.updateSADetector did3 os3SampleDetector))
      bhRequestMethod (OS2Requests.updateSADetector did2 os2SampleDetector)
        `shouldBe` bhRequestMethod (OS3Requests.updateSADetector did3 os3SampleDetector)

    it "updateSADetector: OS2, OS3 attach identical body bytes" $ do
      let b2 = bhRequestBody (OS2Requests.updateSADetector did2 os2SampleDetector)
          b3 = bhRequestBody (OS3Requests.updateSADetector did3 os3SampleDetector)
      b2 `shouldBe` b3

    it "deleteSADetector: OS2, OS3 produce the same path and method" $ do
      getRawEndpoint (bhRequestEndpoint (OS2Requests.deleteSADetector did2))
        `shouldBe` getRawEndpoint (bhRequestEndpoint (OS3Requests.deleteSADetector did3))
      bhRequestMethod (OS2Requests.deleteSADetector did2)
        `shouldBe` bhRequestMethod (OS3Requests.deleteSADetector did3)

    it "searchSADetectors: OS2, OS3 produce the same path and queries" $ do
      getRawEndpoint (bhRequestEndpoint (OS2Requests.searchSADetectors Nothing))
        `shouldBe` getRawEndpoint (bhRequestEndpoint (OS3Requests.searchSADetectors Nothing))
      getRawEndpointQueries (bhRequestEndpoint (OS2Requests.searchSADetectors Nothing))
        `shouldBe` getRawEndpointQueries (bhRequestEndpoint (OS3Requests.searchSADetectors Nothing))

    let rid2 = OS2A.RuleId "shared-rule-id"
        rid3 = OS3A.RuleId "shared-rule-id"

    it "createSACustomRule: OS2, OS3 produce the same path, method, and queries" $ do
      getRawEndpoint (bhRequestEndpoint (OS2Requests.createSACustomRule OS2A.SADetectorTypeWindows sampleSigmaYaml))
        `shouldBe` getRawEndpoint (bhRequestEndpoint (OS3Requests.createSACustomRule OS3A.SADetectorTypeWindows sampleSigmaYaml))
      getRawEndpointQueries (bhRequestEndpoint (OS2Requests.createSACustomRule OS2A.SADetectorTypeWindows sampleSigmaYaml))
        `shouldBe` getRawEndpointQueries (bhRequestEndpoint (OS3Requests.createSACustomRule OS3A.SADetectorTypeWindows sampleSigmaYaml))
      bhRequestMethod (OS2Requests.createSACustomRule OS2A.SADetectorTypeWindows sampleSigmaYaml)
        `shouldBe` bhRequestMethod (OS3Requests.createSACustomRule OS3A.SADetectorTypeWindows sampleSigmaYaml)

    it "createSACustomRule: OS2, OS3 attach identical body bytes" $ do
      let b2 = bhRequestBody (OS2Requests.createSACustomRule OS2A.SADetectorTypeWindows sampleSigmaYaml)
          b3 = bhRequestBody (OS3Requests.createSACustomRule OS3A.SADetectorTypeWindows sampleSigmaYaml)
      b2 `shouldBe` b3

    it "updateSACustomRule: OS2, OS3 produce the same path, method, and queries" $ do
      getRawEndpoint (bhRequestEndpoint (OS2Requests.updateSACustomRule rid2 OS2A.SADetectorTypeWindows True sampleSigmaYaml))
        `shouldBe` getRawEndpoint (bhRequestEndpoint (OS3Requests.updateSACustomRule rid3 OS3A.SADetectorTypeWindows True sampleSigmaYaml))
      getRawEndpointQueries (bhRequestEndpoint (OS2Requests.updateSACustomRule rid2 OS2A.SADetectorTypeWindows True sampleSigmaYaml))
        `shouldBe` getRawEndpointQueries (bhRequestEndpoint (OS3Requests.updateSACustomRule rid3 OS3A.SADetectorTypeWindows True sampleSigmaYaml))

    it "deleteSACustomRule: OS2, OS3 produce the same path and method" $ do
      getRawEndpoint (bhRequestEndpoint (OS2Requests.deleteSACustomRule rid2 False))
        `shouldBe` getRawEndpoint (bhRequestEndpoint (OS3Requests.deleteSACustomRule rid3 False))
      bhRequestMethod (OS2Requests.deleteSACustomRule rid2 False)
        `shouldBe` bhRequestMethod (OS3Requests.deleteSACustomRule rid3 False)

    it "deleteSACustomRule: forced flag produces the same query across backends" $ do
      getRawEndpointQueries (bhRequestEndpoint (OS2Requests.deleteSACustomRule rid2 True))
        `shouldBe` getRawEndpointQueries (bhRequestEndpoint (OS3Requests.deleteSACustomRule rid3 True))

  -- =========================================================== --
  -- Decoder tests (OS3 types — identical instances across OS2-3) --
  -- =========================================================== --
  describe "SADetectorResponse decoding (create fixture)" $ do
    -- Verbatim from the OS docs create-detector response example.
    let fixture =
          LBS.pack
            "{\"_id\":\"dc2VB4QBrbtylUb_Hfa3\",\"_version\":1,\"detector\":{\"name\":\"nbReFCjlfn\",\"detector_type\":\"windows\",\"enabled\":true,\"schedule\":{\"period\":{\"interval\":1,\"unit\":\"MINUTES\"}},\"inputs\":[{\"detector_input\":{\"description\":\"windows detector for security analytics\",\"indices\":[\"windows\"],\"custom_rules\":[{\"id\":\"bc2RB4QBrbtylUb_1Pbm\"}],\"pre_packaged_rules\":[{\"id\":\"06724a9a-52fc-11ed-bdc3-0242ac120002\"}]}}],\"triggers\":[{\"id\":\"8qhrBoQBYK1JzUUDzH-N\",\"name\":\"test-trigger\",\"severity\":\"1\",\"types\":[],\"ids\":[\"06724a9a-52fc-11ed-bdc3-0242ac120002\"],\"sev_levels\":[],\"tags\":[\"attack.defense_evasion\"],\"actions\":[{\"id\":\"hVTLkZYzlA\",\"name\":\"hello_world\",\"destination_id\":\"6r8ZBoQBKW_6dKriacQb\",\"message_template\":{\"source\":\"Trigger: \",\"lang\":\"mustache\"},\"throttle_enabled\":false,\"subject_template\":{\"source\":\"Detector just entered alert status.\",\"lang\":\"mustache\"},\"throttle\":{\"value\":108,\"unit\":\"MINUTES\"}}]}],\"last_update_time\":\"2022-10-24T01:22:03.738379671Z\",\"enabled_time\":\"2022-10-24T01:22:03.738376103Z\"}}"

    it "decodes the documented create-response fixture" $ do
      let Just decoded = decode fixture :: Maybe OS3A.SADetectorResponse
      OS3A.saDetectorResponseId decoded `shouldBe` "dc2VB4QBrbtylUb_Hfa3"
      OS3A.saDetectorResponseVersion decoded `shouldBe` 1
      let det = OS3A.saDetectorResponseDetector decoded
      OS3A.saDetectorName det `shouldBe` "nbReFCjlfn"
      OS3A.saDetectorType det `shouldBe` OS3A.SADetectorTypeWindows
      OS3A.saDetectorEnabled det `shouldBe` Just True
      -- last_update_time / enabled_time round-trip via the detectorOther catch-all
      -- (not typed fields on SADetector).
      length (OS3A.saDetectorInputs det) `shouldBe` 1
      length (OS3A.saDetectorTriggers det) `shouldBe` 1
      let trig = head (OS3A.saDetectorTriggers det)
      OS3A.saTriggerName trig `shouldBe` "test-trigger"
      -- severity is a string-encoded integer on the wire ("1"), NOT a number.
      OS3A.saTriggerSeverity trig `shouldBe` "1"
      OS3A.saTriggerIds trig `shouldBe` ["06724a9a-52fc-11ed-bdc3-0242ac120002"]
      OS3A.saTriggerTags trig `shouldBe` ["attack.defense_evasion"]
      -- actions is an ARRAY on the wire (docs table says Object — wrong).
      length (OS3A.saTriggerActions trig) `shouldBe` 1
      let act = head (OS3A.saTriggerActions trig)
      OS3A.saActionName act `shouldBe` Just "hello_world"
      OS3A.saActionDestinationId act `shouldBe` Just "6r8ZBoQBKW_6dKriacQb"
      OS3A.saActionThrottleEnabled act `shouldBe` Just False
      let Just th = OS3A.saActionThrottle act
      OS3A.saThrottleValue th `shouldBe` 108
      OS3A.saThrottleUnit th `shouldBe` "MINUTES"

  describe "SADetectorResponse decoding (get fixture)" $ do
    -- Verbatim from the OS docs get-detector response example.
    let fixture =
          LBS.pack
            "{\"_id\":\"x-dwFIYBT6_n8WeuQjo4\",\"_version\":1,\"detector\":{\"name\":\"DetectorTest1\",\"detector_type\":\"windows\",\"enabled\":true,\"schedule\":{\"period\":{\"interval\":1,\"unit\":\"MINUTES\"}},\"inputs\":[{\"detector_input\":{\"description\":\"Test and delete\",\"indices\":[\"windows1\"],\"custom_rules\":[],\"pre_packaged_rules\":[{\"id\":\"847def9e-924d-4e90-b7c4-5f581395a2b4\"}]}}],\"last_update_time\":\"2023-02-02T23:22:26.454Z\",\"enabled_time\":\"2023-02-02T23:22:26.454Z\"}}"

    it "decodes the documented get-response fixture (no triggers array)" $ do
      let Just decoded = decode fixture :: Maybe OS3A.SADetectorResponse
      OS3A.saDetectorResponseId decoded `shouldBe` "x-dwFIYBT6_n8WeuQjo4"
      let det = OS3A.saDetectorResponseDetector decoded
      OS3A.saDetectorName det `shouldBe` "DetectorTest1"
      OS3A.saDetectorTriggers det `shouldBe` []
      length (OS3A.saDetectorInputs det) `shouldBe` 1
      let input = head (OS3A.saDetectorInputs det)
      OS3A.saDetectorInputIndices input `shouldBe` ["windows1"]
      length (OS3A.saDetectorInputPrePackagedRules input) `shouldBe` 1

  describe "SADetector round-trip" $ do
    it "round-trips through encode/decode on the typed fields" $ do
      let encoded = encode os3SampleDetector
          Just reDecoded = decode encoded :: Maybe OS3A.SADetector
      OS3A.saDetectorName reDecoded `shouldBe` OS3A.saDetectorName os3SampleDetector
      OS3A.saDetectorType reDecoded `shouldBe` OS3A.saDetectorType os3SampleDetector
      OS3A.saDetectorSchedule reDecoded `shouldBe` OS3A.saDetectorSchedule os3SampleDetector

  describe "SADetectorType discriminator" $ do
    it "decodes each documented detector_type value (lower-case form)" $ do
      "linux" `decodesDetectorTypeTo` OS3A.SADetectorTypeLinux
      "network" `decodesDetectorTypeTo` OS3A.SADetectorTypeNetwork
      "windows" `decodesDetectorTypeTo` OS3A.SADetectorTypeWindows
      "ad_ldap" `decodesDetectorTypeTo` OS3A.SADetectorTypeAdLdap
      "apache_access" `decodesDetectorTypeTo` OS3A.SADetectorTypeApacheAccess
      "cloudtrail" `decodesDetectorTypeTo` OS3A.SADetectorTypeCloudtrail
      "dns" `decodesDetectorTypeTo` OS3A.SADetectorTypeDns
      "s3" `decodesDetectorTypeTo` OS3A.SADetectorTypeS3

    it "normalises upper-case to the canonical constructor" $
      "WINDOWS" `decodesDetectorTypeTo` OS3A.SADetectorTypeWindows

    it "round-trips through saDetectorTypeText (lower-case)" $
      OS3A.saDetectorTypeText OS3A.SADetectorTypeWindows `shouldBe` "windows"

    it "falls through to SADetectorTypeOther for an unknown value" $
      "future_log_type" `decodesDetectorTypeTo` OS3A.SADetectorTypeOther "future_log_type"

  describe "SASchedule decoding" $ do
    it "decodes a period schedule" $ do
      let Just s = decode "{\"period\":{\"interval\":5,\"unit\":\"HOURS\"}}" :: Maybe OS3A.SASchedule
          Just (OS3A.PeriodSASchedule p) = Just s
      OS3A.saSchedulePeriodInterval p `shouldBe` 5
      OS3A.saSchedulePeriodUnit p `shouldBe` "HOURS"

    it "falls through to OtherSASchedule for an unknown schedule kind" $ do
      let Just s = decode "{\"custom\":{\"foo\":1}}" :: Maybe OS3A.SASchedule
          Just (OS3A.OtherSASchedule _) = Just s
      s `shouldBe` OS3A.OtherSASchedule (object ["custom" .= object ["foo" .= (1 :: Int)]])

  describe "SATrigger severity-as-string gotcha" $ do
    it "severity is decoded as a string (not a number) per the wire" $ do
      let Just t = decode "{\"name\":\"x\",\"severity\":\"1\",\"actions\":[]}" :: Maybe OS3A.SATrigger
      OS3A.saTriggerSeverity t `shouldBe` "1"

    it "defaults to \"1\" when severity is absent (matches server-injected default)" $ do
      let Just t = decode "{\"name\":\"x\",\"actions\":[]}" :: Maybe OS3A.SATrigger
      OS3A.saTriggerSeverity t `shouldBe` "1"

  describe "SARulesSearchResponse decoding (pre-packaged fixture)" $ do
    -- Verbatim from the OS docs search pre-packaged rules response example.
    let fixture =
          LBS.pack
            "{\"took\":3,\"timed_out\":false,\"_shards\":{\"total\":1,\"successful\":1,\"skipped\":0,\"failed\":0},\"hits\":{\"total\":{\"value\":1580,\"relation\":\"eq\"},\"max_score\":0.25863406,\"hits\":[{\"_index\":\".opensearch-pre-packaged-rules-config\",\"_id\":\"6KFv1IMBdLpXWBiBelZg\",\"_version\":1,\"_seq_no\":386,\"_primary_term\":1,\"_score\":0.25863406,\"_source\":{\"category\":\"windows\",\"title\":\"Change Outlook Security Setting in Registry\",\"log_source\":\"registry_set\",\"description\":\"Change outlook email security settings\",\"references\":[{\"value\":\"https://github.com/redcanaryco/atomic-red-team/blob/master/atomics/T1137/T1137.md\"}],\"tags\":[{\"value\":\"attack.persistence\"}],\"level\":\"medium\",\"false_positives\":[{\"value\":\"Administrative scripts\"}],\"author\":\"frack113\",\"status\":\"experimental\",\"last_update_time\":\"2021-12-28T00:00:00.000Z\",\"queries\":[{\"value\":\"TargetObject: *\\\\SOFTWARE*\"}],\"rule\":\"title: Change Outlook Security Setting in Registry\\nid: c3cefdf4-6703-4e1c-bad8-bf422fc5015a\\n\"}}]}}"

    it "decodes the documented pre-packaged search fixture" $ do
      let Just decoded = decode fixture :: Maybe OS3A.SARulesSearchResponse
      OS3A.saRulesSearchResponseTook decoded `shouldBe` 3
      OS3A.saRulesSearchResponseTimedOut decoded `shouldBe` False
      OS3A.saRulesShardsTotal (OS3A.saRulesSearchResponseShards decoded) `shouldBe` 1
      OS3A.saRulesShardsSuccessful (OS3A.saRulesSearchResponseShards decoded) `shouldBe` 1
      OS3A.saRulesTotalValue (OS3A.saRulesSearchResponseTotal decoded) `shouldBe` 1580
      OS3A.saRulesTotalRelation (OS3A.saRulesSearchResponseTotal decoded)
        `shouldBe` OS3A.SARulesTotalRelationEq
      OS3A.saRulesSearchResponseMaxScore decoded `shouldBe` Just 0.25863406
      length (OS3A.saRulesSearchResponseHits decoded) `shouldBe` 1
      let hit = head (OS3A.saRulesSearchResponseHits decoded)
      OS3A.saRuleHitId hit `shouldBe` "6KFv1IMBdLpXWBiBelZg"
      OS3A.saRuleHitIndex hit `shouldBe` Just ".opensearch-pre-packaged-rules-config"
      let rule = OS3A.saRuleHitSource hit
      OS3A.saRuleCategory rule `shouldBe` Just "windows"
      OS3A.saRuleTitle rule `shouldBe` Just "Change Outlook Security Setting in Registry"
      OS3A.saRuleLevel rule `shouldBe` Just "medium"
      OS3A.saRuleAuthor rule `shouldBe` Just "frack113"
      length (OS3A.saRuleTags rule) `shouldBe` 1
      length (OS3A.saRuleReferences rule) `shouldBe` 1
      length (OS3A.saRuleQueries rule) `shouldBe` 1

    it "saRulesSearchResponseRules projects the SARule list out" $ do
      let Just decoded = decode fixture :: Maybe OS3A.SARulesSearchResponse
      let rules = OS3A.saRulesSearchResponseRules decoded
      length rules `shouldBe` 1
      OS3A.saRuleTitle (head rules) `shouldBe` Just "Change Outlook Security Setting in Registry"

  describe "SARulesSearchResponse decoding (custom fixture)" $ do
    -- Verbatim from the OS docs search custom rules response example.
    let fixture =
          LBS.pack
            "{\"took\":1,\"timed_out\":false,\"_shards\":{\"total\":1,\"successful\":1,\"skipped\":0,\"failed\":0},\"hits\":{\"total\":{\"value\":1,\"relation\":\"eq\"},\"max_score\":0.2876821,\"hits\":[{\"_index\":\".opensearch-custom-rules-config\",\"_id\":\"ZaFv1IMBdLpXWBiBa1XI\",\"_version\":2,\"_seq_no\":1,\"_primary_term\":1,\"_score\":0.2876821,\"_source\":{\"category\":\"windows\",\"title\":\"Moriya Rooskit\",\"log_source\":\"\",\"description\":\"Detects the use of Moriya rootkit\",\"references\":[{\"value\":\"https://securelist.com/operation-tunnelsnake-and-moriya-rootkit/101831\"}],\"tags\":[{\"value\":\"attack.persistence\"}],\"level\":\"critical\",\"false_positives\":[{\"value\":\"Unknown\"}],\"author\":\"Bhabesh Raj\",\"status\":\"experimental\",\"last_update_time\":\"2021-05-06T00:00:00.000Z\",\"queries\":[{\"value\":\"Provider_Name: \\\"Service Control Manager\\\"\"}],\"rule\":\"title: Moriya Rooskit\\nid: 25b9c01c-350d-4b95-bed1-836d04a4f324\\n\"}}]}}"

    it "decodes the documented custom search fixture" $ do
      let Just decoded = decode fixture :: Maybe OS3A.SARulesSearchResponse
      OS3A.saRulesTotalValue (OS3A.saRulesSearchResponseTotal decoded) `shouldBe` 1
      let rule = OS3A.saRuleHitSource (head (OS3A.saRulesSearchResponseHits decoded))
      OS3A.saRuleTitle rule `shouldBe` Just "Moriya Rooskit"
      OS3A.saRuleLevel rule `shouldBe` Just "critical"
      OS3A.saRuleLogSource rule `shouldBe` Just ""
      length (OS3A.saRuleFalsePositives rule) `shouldBe` 1

  describe "SARulesQuery rendering" $ do
    it "defaultSARulesQuery renders to empty object" $ do
      encode OS3A.defaultSARulesQuery `shouldBe` "{}"

    it "non-default fields are rendered" $ do
      let q =
            OS3A.SARulesQuery
              { OS3A.saRulesQueryFrom = Just 0,
                OS3A.saRulesQuerySize = Just 20,
                OS3A.saRulesQueryQuery = Nothing
              }
      LBS.unpack (encode q) `shouldContain` "\"from\""
      LBS.unpack (encode q) `shouldContain` "\"size\""

  describe "SADetectorsSearchResponse decoding (search fixture)" $ do
    -- A realistic search-detectors response: the standard OpenSearch
    -- search envelope with one detector document in hits.hits[]._source.
    -- The _source is the stored detector (the SADetector body), NOT the
    -- {_id,_version,detector:{...}} GET wrapper.
    let fixture =
          LBS.pack
            "{\"took\":5,\"timed_out\":false,\"_shards\":{\"total\":1,\"successful\":1,\"skipped\":0,\"failed\":0},\"hits\":{\"total\":{\"value\":1,\"relation\":\"eq\"},\"max_score\":1.0,\"hits\":[{\"_index\":\".opendistro-security-analytics-detectors\",\"_id\":\"abc123\",\"_version\":1,\"_seq_no\":0,\"_primary_term\":1,\"_score\":1.0,\"_source\":{\"name\":\"my-detector\",\"detector_type\":\"windows\",\"type\":\"detector\",\"enabled\":true,\"schedule\":{\"period\":{\"interval\":1,\"unit\":\"MINUTES\"}},\"inputs\":[{\"detector_input\":{\"description\":\"test\",\"indices\":[\"windows\"],\"custom_rules\":[],\"pre_packaged_rules\":[{\"id\":\"rule-1\"}]}}],\"triggers\":[],\"last_update_time\":\"2023-01-01T00:00:00.000Z\",\"enabled_time\":\"2023-01-01T00:00:00.000Z\"}}]}}"

    it "decodes the search-detectors envelope" $ do
      let Just decoded = decode fixture :: Maybe OS3A.SADetectorsSearchResponse
      OS3A.saDetectorsSearchResponseTook decoded `shouldBe` 5
      OS3A.saDetectorsSearchResponseTimedOut decoded `shouldBe` False
      OS3A.saDetectorsShardsTotal (OS3A.saDetectorsSearchResponseShards decoded) `shouldBe` 1
      OS3A.saDetectorsShardsSuccessful (OS3A.saDetectorsSearchResponseShards decoded) `shouldBe` 1
      OS3A.saDetectorsTotalValue (OS3A.saDetectorsSearchResponseTotal decoded) `shouldBe` 1
      OS3A.saDetectorsTotalRelation (OS3A.saDetectorsSearchResponseTotal decoded)
        `shouldBe` OS3A.SADetectorsTotalRelationEq
      OS3A.saDetectorsSearchResponseMaxScore decoded `shouldBe` Just 1.0
      length (OS3A.saDetectorsSearchResponseHits decoded) `shouldBe` 1
      let hit = head (OS3A.saDetectorsSearchResponseHits decoded)
      OS3A.saDetectorHitId hit `shouldBe` "abc123"
      OS3A.saDetectorHitIndex hit `shouldBe` Just ".opendistro-security-analytics-detectors"
      OS3A.saDetectorHitSeqNo hit `shouldBe` Just 0
      let det = OS3A.saDetectorHitSource hit
      OS3A.saDetectorName det `shouldBe` "my-detector"
      OS3A.saDetectorType det `shouldBe` OS3A.SADetectorTypeWindows
      OS3A.saDetectorEnabled det `shouldBe` Just True

    it "saDetectorsSearchResponseDetectors projects the SADetector list out" $ do
      let Just decoded = decode fixture :: Maybe OS3A.SADetectorsSearchResponse
      let dets = OS3A.saDetectorsSearchResponseDetectors decoded
      length dets `shouldBe` 1
      OS3A.saDetectorName (head dets) `shouldBe` "my-detector"

    it "tolerates a missing max_score (Nothing)" $ do
      let Just decoded =
            decode
              (LBS.pack "{\"took\":1,\"timed_out\":false,\"_shards\":{\"total\":1,\"successful\":1,\"skipped\":0,\"failed\":0},\"hits\":{\"total\":{\"value\":0,\"relation\":\"eq\"},\"hits\":[]}}") ::
              Maybe OS3A.SADetectorsSearchResponse
      OS3A.saDetectorsSearchResponseMaxScore decoded `shouldBe` Nothing
      OS3A.saDetectorsSearchResponseHits decoded `shouldBe` []

  describe "DeleteSADetectorResponse decoding (delete fixture)" $ do
    -- The delete-detector response is the bare Elasticsearch
    -- delete-document body (no `acknowledged` key), identical in shape
    -- to DeleteMonitorResponse / DeleteDestinationResponse.
    let fixture =
          LBS.pack
            "{\"_index\":\".opendistro-security-analytics-detectors\",\"_id\":\"abc123\",\"_version\":2,\"result\":\"deleted\",\"forced_refresh\":true,\"_shards\":{\"total\":2,\"successful\":2,\"skipped\":0,\"failed\":0},\"_seq_no\":3,\"_primary_term\":1}"

    it "decodes the documented delete-detector fixture (full shape)" $ do
      let Just decoded = decode fixture :: Maybe OS3A.DeleteSADetectorResponse
      OS3A.deleteSADetectorResponseIndex decoded `shouldBe` Just ".opendistro-security-analytics-detectors"
      OS3A.deleteSADetectorResponseId decoded `shouldBe` "abc123"
      OS3A.deleteSADetectorResponseVersion decoded `shouldBe` 2
      OS3A.deleteSADetectorResponseResult decoded `shouldBe` Just "deleted"
      OS3A.deleteSADetectorResponseForcedRefresh decoded `shouldBe` Just True
      let Just sh = OS3A.deleteSADetectorResponseShards decoded
      OS3A.saDetectorsShardsTotal sh `shouldBe` 2
      OS3A.saDetectorsShardsSuccessful sh `shouldBe` 2
      OS3A.deleteSADetectorResponseSeqNo decoded `shouldBe` Just 3
      OS3A.deleteSADetectorResponsePrimaryTerm decoded `shouldBe` Just 1

    it "decodes the minimal {_id,_version} fixture (OS 2.x/3.x shape, live-verified bead bloodhound-z5j)" $ do
      let minimal = LBS.pack "{\"_id\":\"abc123\",\"_version\":1}"
          Just decoded = decode minimal :: Maybe OS3A.DeleteSADetectorResponse
      OS3A.deleteSADetectorResponseId decoded `shouldBe` "abc123"
      OS3A.deleteSADetectorResponseVersion decoded `shouldBe` 1
      OS3A.deleteSADetectorResponseIndex decoded `shouldBe` Nothing
      OS3A.deleteSADetectorResponseResult decoded `shouldBe` Nothing
      OS3A.deleteSADetectorResponseForcedRefresh decoded `shouldBe` Nothing
      OS3A.deleteSADetectorResponseShards decoded `shouldBe` Nothing
      OS3A.deleteSADetectorResponseSeqNo decoded `shouldBe` Nothing
      OS3A.deleteSADetectorResponsePrimaryTerm decoded `shouldBe` Nothing

    it "round-trips through encode/decode" $ do
      let Just decoded = decode fixture :: Maybe OS3A.DeleteSADetectorResponse
      decode (encode decoded) `shouldBe` Just decoded

  describe "SADetectorsSearchQuery rendering" $ do
    it "defaultSADetectorsSearchQuery renders to empty object" $
      encode OS3A.defaultSADetectorsSearchQuery `shouldBe` "{}"

    it "non-default fields are rendered" $ do
      let q =
            OS3A.SADetectorsSearchQuery
              { OS3A.saDetectorsSearchQueryFrom = Just 0,
                OS3A.saDetectorsSearchQuerySize = Just 20,
                OS3A.saDetectorsSearchQueryQuery = Nothing
              }
      LBS.unpack (encode q) `shouldContain` "\"from\""
      LBS.unpack (encode q) `shouldContain` "\"size\""

  describe "SARuleResponse decoding (create fixture)" $ do
    -- Verbatim from the OS docs create-custom-rule response example.
    let fixture =
          LBS.pack
            "{\"_id\":\"M1Rm1IMByX0LvTiGvde2\",\"_version\":1,\"rule\":{\"category\":\"windows\",\"title\":\"Moriya Rootkit\",\"log_source\":\"\",\"description\":\"Detects the use of Moriya rootkit as described in the securelist's Operation TunnelSnake report\",\"tags\":[{\"value\":\"attack.persistence\"},{\"value\":\"attack.privilege_escalation\"},{\"value\":\"attack.t1543.003\"}],\"references\":[{\"value\":\"https://securelist.com/operation-tunnelsnake-and-moriya-rootkit/101831\"}],\"level\":\"critical\",\"false_positives\":[{\"value\":\"Unknown\"}],\"author\":\"Bhabesh Raj\",\"status\":\"experimental\",\"last_update_time\":\"2021-05-06T00:00:00.000Z\",\"rule\":\"title: Moriya Rootkit\\nid: 25b9c01c-350d-4b95-bed1-836d04a4f324\\n\"}}"

    it "decodes the documented create-rule fixture" $ do
      let Just decoded = decode fixture :: Maybe OS3A.SARuleResponse
      OS3A.saRuleResponseId decoded `shouldBe` "M1Rm1IMByX0LvTiGvde2"
      OS3A.saRuleResponseVersion decoded `shouldBe` 1
      let rule = OS3A.saRuleResponseRule decoded
      OS3A.saRuleCategory rule `shouldBe` Just "windows"
      OS3A.saRuleTitle rule `shouldBe` Just "Moriya Rootkit"
      OS3A.saRuleLevel rule `shouldBe` Just "critical"
      OS3A.saRuleAuthor rule `shouldBe` Just "Bhabesh Raj"
      length (OS3A.saRuleTags rule) `shouldBe` 3
      -- The server echoes the original Sigma YAML verbatim in `rule`.
      OS3A.saRuleRule rule `shouldSatisfy` isJust

  -- The SARule round-trip is not perfectly lossless: the underlying
  -- SARule ToJSON instance renders @queries:[]@ on re-encode even when
  -- the original fixture omits the key (mergeIgnoringNulls treats an
  -- empty array as non-null). This is a pre-existing property of the
  -- SARule type, not of the SARuleResponse wrapper; the decode test
  -- above fully validates the FromJSON instance.

  describe "DeleteSARuleResponse decoding (delete fixture)" $ do
    -- The delete-rule response is the bare Elasticsearch delete-document
    -- body (no `acknowledged` key), identical in shape to
    -- DeleteSADetectorResponse.
    let fixture =
          LBS.pack
            "{\"_index\":\".opensearch-custom-rules-config\",\"_id\":\"abc123\",\"_version\":2,\"result\":\"deleted\",\"forced_refresh\":true,\"_shards\":{\"total\":2,\"successful\":2,\"skipped\":0,\"failed\":0},\"_seq_no\":3,\"_primary_term\":1}"

    it "decodes the documented delete-rule fixture" $ do
      let Just decoded = decode fixture :: Maybe OS3A.DeleteSARuleResponse
      OS3A.deleteSARuleResponseIndex decoded `shouldBe` ".opensearch-custom-rules-config"
      OS3A.deleteSARuleResponseId decoded `shouldBe` "abc123"
      OS3A.deleteSARuleResponseVersion decoded `shouldBe` 2
      OS3A.deleteSARuleResponseResult decoded `shouldBe` "deleted"
      OS3A.deleteSARuleResponseForcedRefresh decoded `shouldBe` True
      OS3A.saRulesShardsTotal (OS3A.deleteSARuleResponseShards decoded) `shouldBe` 2
      OS3A.deleteSARuleResponseSeqNo decoded `shouldBe` 3
      OS3A.deleteSARuleResponsePrimaryTerm decoded `shouldBe` 1

    it "round-trips through encode/decode" $ do
      let Just decoded = decode fixture :: Maybe OS3A.DeleteSARuleResponse
      decode (encode decoded) `shouldBe` Just decoded

  -- =========================================================== --
  -- Mappings API (4 endpoints: view / create / get / update)     --
  -- =========================================================== --
  describe "Mappings API endpoint shape (OpenSearch 3)" $ do
    it "getSAMappingsView GETs /_plugins/_security_analytics/mappings/view with the encoded body" $ do
      let req =
            OS3A.SAMappingsViewRequest
              { OS3A.saMappingsViewIndexName = "windows",
                OS3A.saMappingsViewRuleTopic = "windows"
              }
          r = OS3Requests.getSAMappingsView req
      bhRequestMethod r `shouldBe` "GET"
      getRawEndpoint (bhRequestEndpoint r)
        `shouldBe` ["_plugins", "_security_analytics", "mappings", "view"]
      getRawEndpointQueries (bhRequestEndpoint r) `shouldBe` []
      let body = fromJust (bhRequestBody r)
      LBS.unpack body `shouldContain` "\"index_name\""
      LBS.unpack body `shouldContain` "\"rule_topic\""

    it "createSAMappings POSTs to /_plugins/_security_analytics/mappings with the encoded body" $ do
      let req =
            OS3A.SACreateMappingsRequest
              { OS3A.saCreateMappingsIndexName = "windows",
                OS3A.saCreateMappingsRuleTopic = "windows",
                OS3A.saCreateMappingsPartial = Just True,
                OS3A.saCreateMappingsAliasMappings =
                  OS3A.SAMappingsBody
                    { OS3A.saMappingsBodyProperties = mempty,
                      OS3A.saMappingsBodyOther = Null
                    }
              }
          r = OS3Requests.createSAMappings req
      bhRequestMethod r `shouldBe` "POST"
      getRawEndpoint (bhRequestEndpoint r)
        `shouldBe` ["_plugins", "_security_analytics", "mappings"]
      getRawEndpointQueries (bhRequestEndpoint r) `shouldBe` []
      let body = fromJust (bhRequestBody r)
      LBS.unpack body `shouldContain` "\"alias_mappings\""
      LBS.unpack body `shouldContain` "\"partial\""

    it "getSAMappings GETs /_plugins/_security_analytics/mappings?index_name={name} with no body" $ do
      let r = OS3Requests.getSAMappings "windows"
      bhRequestMethod r `shouldBe` "GET"
      getRawEndpoint (bhRequestEndpoint r)
        `shouldBe` ["_plugins", "_security_analytics", "mappings"]
      getRawEndpointQueries (bhRequestEndpoint r)
        `shouldBe` [("index_name", Just "windows")]
      bhRequestBody r `shouldBe` Nothing

    it "updateSAMappings PUTs to /_plugins/_security_analytics/mappings with the encoded body" $ do
      let req =
            OS3A.SAUpdateMappingsRequest
              { OS3A.saUpdateMappingsIndexName = "windows",
                OS3A.saUpdateMappingsField = "CommandLine",
                OS3A.saUpdateMappingsAlias = "windows-event_data-CommandLine"
              }
          r = OS3Requests.updateSAMappings req
      bhRequestMethod r `shouldBe` "PUT"
      getRawEndpoint (bhRequestEndpoint r)
        `shouldBe` ["_plugins", "_security_analytics", "mappings"]
      getRawEndpointQueries (bhRequestEndpoint r) `shouldBe` []
      let body = fromJust (bhRequestBody r)
      LBS.unpack body `shouldContain` "\"field\""
      LBS.unpack body `shouldContain` "\"alias\""

  describe "Mappings API endpoint shape (OpenSearch 2)" $ do
    it "getSAMappingsView GETs .../mappings/view" $ do
      let r = OS2Requests.getSAMappingsView (OS2A.SAMappingsViewRequest "windows" "windows")
      bhRequestMethod r `shouldBe` "GET"
      getRawEndpoint (bhRequestEndpoint r)
        `shouldBe` ["_plugins", "_security_analytics", "mappings", "view"]

    it "getSAMappings GETs with the index_name query" $ do
      let r = OS2Requests.getSAMappings "windows"
      getRawEndpointQueries (bhRequestEndpoint r)
        `shouldBe` [("index_name", Just "windows")]

    it "updateSAMappings PUTs to .../mappings" $ do
      let r = OS2Requests.updateSAMappings (OS2A.SAUpdateMappingsRequest "windows" "CommandLine" "alias")
      bhRequestMethod r `shouldBe` "PUT"

  describe "Mappings API cross-backend parity (OS2/OS3)" $ do
    let viewReq2 = OS2A.SAMappingsViewRequest "windows" "windows"
        viewReq3 = OS3A.SAMappingsViewRequest "windows" "windows"
        updReq2 = OS2A.SAUpdateMappingsRequest "windows" "f" "a"
        updReq3 = OS3A.SAUpdateMappingsRequest "windows" "f" "a"

    it "getSAMappingsView: OS2/OS3 produce the same path, method, and queries" $ do
      getRawEndpoint (bhRequestEndpoint (OS2Requests.getSAMappingsView viewReq2))
        `shouldBe` getRawEndpoint (bhRequestEndpoint (OS3Requests.getSAMappingsView viewReq3))
      bhRequestMethod (OS2Requests.getSAMappingsView viewReq2)
        `shouldBe` bhRequestMethod (OS3Requests.getSAMappingsView viewReq3)
      getRawEndpointQueries (bhRequestEndpoint (OS2Requests.getSAMappingsView viewReq2))
        `shouldBe` getRawEndpointQueries (bhRequestEndpoint (OS3Requests.getSAMappingsView viewReq3))

    it "getSAMappings: OS2/OS3 produce the same path and index_name query" $ do
      getRawEndpoint (bhRequestEndpoint (OS2Requests.getSAMappings "windows"))
        `shouldBe` getRawEndpoint (bhRequestEndpoint (OS3Requests.getSAMappings "windows"))
      getRawEndpointQueries (bhRequestEndpoint (OS2Requests.getSAMappings "windows"))
        `shouldBe` getRawEndpointQueries (bhRequestEndpoint (OS3Requests.getSAMappings "windows"))

    it "updateSAMappings: OS2/OS3 produce the same path and method" $ do
      getRawEndpoint (bhRequestEndpoint (OS2Requests.updateSAMappings updReq2))
        `shouldBe` getRawEndpoint (bhRequestEndpoint (OS3Requests.updateSAMappings updReq3))
      bhRequestMethod (OS2Requests.updateSAMappings updReq2)
        `shouldBe` bhRequestMethod (OS3Requests.updateSAMappings updReq3)

  describe "SAMappingsViewResponse decoding (view fixture)" $ do
    let fixture =
          LBS.pack
            "{\"properties\":{\"windows-event_data-CommandLine\":{\"path\":\"CommandLine\",\"type\":\"alias\"},\"event_uid\":{\"path\":\"EventID\",\"type\":\"alias\"}},\"unmapped_index_fields\":[\"windows-event_data-CommandLine\",\"src_ip\",\"sha1\"]}"

    it "decodes the documented view-response fixture" $ do
      let Just decoded = decode fixture :: Maybe OS3A.SAMappingsViewResponse
      M.size (OS3A.saMappingsViewResponseProperties decoded) `shouldBe` 2
      length (OS3A.saMappingsViewResponseUnmappedIndexFields decoded) `shouldBe` 3
      let Just prop = M.lookup "event_uid" (OS3A.saMappingsViewResponseProperties decoded)
      OS3A.saMappingPropertyPath prop `shouldBe` Just "EventID"
      OS3A.saMappingPropertyType prop `shouldBe` Just "alias"
      let Just prop2 = M.lookup "windows-event_data-CommandLine" (OS3A.saMappingsViewResponseProperties decoded)
      OS3A.saMappingPropertyPath prop2 `shouldBe` Just "CommandLine"

    it "preserves an unknown forward-compat key via the catch-all" $ do
      let lbs = LBS.pack "{\"properties\":{},\"unmapped_index_fields\":[],\"future_key\":42}"
          Just decoded = decode lbs :: Maybe OS3A.SAMappingsViewResponse
      LBS.unpack (encode decoded) `shouldContain` "\"future_key\""

  describe "SAGetMappingsResponse decoding (get fixture)" $ do
    let fixture =
          LBS.pack
            "{\"windows\":{\"mappings\":{\"properties\":{\"windows-event_data-CommandLine\":{\"type\":\"alias\",\"path\":\"CommandLine\"},\"event_uid\":{\"type\":\"alias\",\"path\":\"EventID\"}}}}}"

    it "decodes the documented get-mappings fixture (keyed by index name)" $ do
      let Just decoded = decode fixture :: Maybe OS3A.SAGetMappingsResponse
      M.size decoded `shouldBe` 1
      let Just idx = M.lookup "windows" decoded
          bodyMap = OS3A.saMappingsIndexBodyMappings idx
      M.size (OS3A.saMappingsBodyProperties bodyMap) `shouldBe` 2
      let Just prop = M.lookup "event_uid" (OS3A.saMappingsBodyProperties bodyMap)
      OS3A.saMappingPropertyType prop `shouldBe` Just "alias"
      OS3A.saMappingPropertyPath prop `shouldBe` Just "EventID"

  describe "SAMappings ack decoding" $ do
    it "create/update mappings {\"acknowledged\":true} decodes as Acknowledged True" $ do
      let Just decoded = decode "{\"acknowledged\":true}" :: Maybe Acknowledged
      isAcknowledged decoded `shouldBe` True

  describe "SAMappings request rendering" $ do
    it "SAMappingsViewRequest round-trips through encode/decode" $ do
      let req = OS3A.SAMappingsViewRequest "windows" "windows"
      decode (encode req) `shouldBe` Just req

    it "SAUpdateMappingsRequest round-trips through encode/decode" $ do
      let req = OS3A.SAUpdateMappingsRequest "windows" "CommandLine" "alias"
      decode (encode req) `shouldBe` Just req

    it "SACreateMappingsRequest omits partial when Nothing" $
      let req =
            OS3A.SACreateMappingsRequest
              "windows"
              "windows"
              Nothing
              (OS3A.SAMappingsBody mempty Null)
       in LBS.unpack (encode req) `shouldNotContain` "\"partial\""

    it "SACreateMappingsRequest includes partial when Just" $
      let req =
            OS3A.SACreateMappingsRequest
              "windows"
              "windows"
              (Just True)
              (OS3A.SAMappingsBody mempty Null)
       in LBS.unpack (encode req) `shouldContain` "\"partial\""

    it "SACreateMappingsRequest includes partial as false when Just False" $
      let req =
            OS3A.SACreateMappingsRequest
              "windows"
              "windows"
              (Just False)
              (OS3A.SAMappingsBody mempty Null)
       in LBS.unpack (encode req) `shouldContain` "false"

  -- =========================================================== --
  -- Alerts, Findings, Correlation, Log type APIs                 --
  -- (bloodhound-2cu). Endpoint shape per backend + JSON          --
  -- fixtures + rendering round-trips. Live round-trips stay       --
  -- @pendingWith@ per the CI-docker-compose precedent            --
  -- (bloodhound-dln).                                            --
  -- =========================================================== --
  describe "Alerts/Findings/Correlation/LogType endpoint shape (OpenSearch 3)" $ do
    it "getSAAlertsWith GETs /alerts with the options as queries" $ do
      let opts =
            OS3A.defaultSAGetAlertsOptions
              { OS3A.saGetAlertsOptionsDetectorId = Just "d1",
                OS3A.saGetAlertsOptionsAlertState = Just OS3A.SAAlertStateActive,
                OS3A.saGetAlertsOptionsSize = Just 10
              }
          r = OS3Requests.getSAAlertsWith opts
      bhRequestMethod r `shouldBe` "GET"
      getRawEndpoint (bhRequestEndpoint r)
        `shouldBe` ["_plugins", "_security_analytics", "alerts"]
      getRawEndpointQueries (bhRequestEndpoint r)
        `shouldContain` [("detector_id", Just "d1")]
      getRawEndpointQueries (bhRequestEndpoint r)
        `shouldContain` [("alertState", Just "ACTIVE")]
      getRawEndpointQueries (bhRequestEndpoint r)
        `shouldContain` [("size", Just "10")]
      bhRequestBody r `shouldBe` Nothing

    it "getSAAlertsWith renders no queries when given defaultSAGetAlertsOptions" $ do
      let r = OS3Requests.getSAAlertsWith OS3A.defaultSAGetAlertsOptions
      getRawEndpointQueries (bhRequestEndpoint r) `shouldBe` []

    it "getSAAlerts GETs /alerts?detector_id={id} and is a projected [SAAlert]" $ do
      let r = OS3Requests.getSAAlerts (OS3A.DetectorId "d1")
      bhRequestMethod r `shouldBe` "GET"
      getRawEndpoint (bhRequestEndpoint r)
        `shouldBe` ["_plugins", "_security_analytics", "alerts"]
      getRawEndpointQueries (bhRequestEndpoint r)
        `shouldBe` [("detector_id", Just "d1")]

    it "acknowledgeSADetectorAlerts POSTs /detectors/{id}/_acknowledge/alerts with {\"alerts\":[...]} body" $ do
      let r = OS3Requests.acknowledgeSADetectorAlerts (OS3A.DetectorId "d1") ["a1", "a2"]
      bhRequestMethod r `shouldBe` "POST"
      getRawEndpoint (bhRequestEndpoint r)
        `shouldBe` ["_plugins", "_security_analytics", "detectors", "d1", "_acknowledge", "alerts"]
      getRawEndpointQueries (bhRequestEndpoint r) `shouldBe` []
      let body = fromJust (bhRequestBody r)
      LBS.unpack body `shouldContain` "\"alerts\""
      LBS.unpack body `shouldContain` "\"a1\""
      LBS.unpack body `shouldContain` "\"a2\""

    it "searchSAFindings GETs /findings/_search with the options as queries" $ do
      let opts =
            OS3A.defaultSASearchFindingsOptions
              { OS3A.saSearchFindingsOptionsSeverity = Just OS3A.SASeverityHigh,
                OS3A.saSearchFindingsOptionsDetectionType = Just OS3A.SADetectionTypeRule
              }
          r = OS3Requests.searchSAFindings opts
      bhRequestMethod r `shouldBe` "GET"
      getRawEndpoint (bhRequestEndpoint r)
        `shouldBe` ["_plugins", "_security_analytics", "findings", "_search"]
      getRawEndpointQueries (bhRequestEndpoint r)
        `shouldContain` [("severity", Just "high")]
      getRawEndpointQueries (bhRequestEndpoint r)
        `shouldContain` [("detectionType", Just "rule")]

    it "createSACorrelationRule POSTs /correlation/rules with the encoded body" $ do
      let req =
            OS3A.CreateSACorrelationRuleRequest
              [ OS3A.SACorrelateEntry "vpc_flow" "dstaddr:4.5.6.7" "network",
                OS3A.SACorrelateEntry "windows" "winlog.event_data.X:Y" "windows"
              ]
          r = OS3Requests.createSACorrelationRule req
      bhRequestMethod r `shouldBe` "POST"
      getRawEndpoint (bhRequestEndpoint r)
        `shouldBe` ["_plugins", "_security_analytics", "correlation", "rules"]
      getRawEndpointQueries (bhRequestEndpoint r) `shouldBe` []
      let body = fromJust (bhRequestBody r)
      LBS.unpack body `shouldContain` "\"correlate\""
      LBS.unpack body `shouldContain` "\"vpc_flow\""
      LBS.unpack body `shouldContain` "\"network\""

    it "getSACorrelations GETs /correlations with start/end timestamps" $ do
      let opts =
            OS3A.defaultGetSACorrelationsOptions
              { OS3A.getSACorrelationsOptionsStartTimestamp = Just 1689289210000,
                OS3A.getSACorrelationsOptionsEndTimestamp = Just 1689300010000
              }
          r = OS3Requests.getSACorrelations opts
      bhRequestMethod r `shouldBe` "GET"
      getRawEndpoint (bhRequestEndpoint r)
        `shouldBe` ["_plugins", "_security_analytics", "correlations"]
      getRawEndpointQueries (bhRequestEndpoint r)
        `shouldBe` [ ("start_timestamp", Just "1689289210000"),
                     ("end_timestamp", Just "1689300010000")
                   ]

    it "findSACorrelation GETs /findings/correlate with required+optional params" $ do
      let opts =
            OS3A.defaultFindSACorrelationOptions
              { OS3A.findSACorrelationOptionsFinding = Just "f1",
                OS3A.findSACorrelationOptionsDetectorType = Just "ad_ldap",
                OS3A.findSACorrelationOptionsNearbyFindings = Just 20,
                OS3A.findSACorrelationOptionsTimeWindow = Just "10m"
              }
          r = OS3Requests.findSACorrelation opts
      bhRequestMethod r `shouldBe` "GET"
      getRawEndpoint (bhRequestEndpoint r)
        `shouldBe` ["_plugins", "_security_analytics", "findings", "correlate"]
      getRawEndpointQueries (bhRequestEndpoint r)
        `shouldContain` [("finding", Just "f1")]
      getRawEndpointQueries (bhRequestEndpoint r)
        `shouldContain` [("detector_type", Just "ad_ldap")]
      getRawEndpointQueries (bhRequestEndpoint r)
        `shouldContain` [("nearby_findings", Just "20")]
      getRawEndpointQueries (bhRequestEndpoint r)
        `shouldContain` [("time_window", Just "10m")]

    it "searchSACorrelationAlerts GETs /correlationAlerts with optional filter" $ do
      let opts =
            OS3A.SearchSACorrelationAlertsOptions
              { OS3A.searchSACorrelationAlertsOptionsCorrelationRuleId = Just "rule1"
              }
          r = OS3Requests.searchSACorrelationAlerts opts
      bhRequestMethod r `shouldBe` "GET"
      getRawEndpoint (bhRequestEndpoint r)
        `shouldBe` ["_plugins", "_security_analytics", "correlationAlerts"]
      getRawEndpointQueries (bhRequestEndpoint r)
        `shouldBe` [("correlation_rule_id", Just "rule1")]

    it "acknowledgeSACorrelationAlerts POSTs /_acknowledge/correlationAlerts with {\"alertIds\":[...]} body" $ do
      let r = OS3Requests.acknowledgeSACorrelationAlerts ["ca1", "ca2"]
      bhRequestMethod r `shouldBe` "POST"
      getRawEndpoint (bhRequestEndpoint r)
        `shouldBe` ["_plugins", "_security_analytics", "_acknowledge", "correlationAlerts"]
      getRawEndpointQueries (bhRequestEndpoint r) `shouldBe` []
      let body = fromJust (bhRequestBody r)
      LBS.unpack body `shouldContain` "\"alertIds\""
      LBS.unpack body `shouldContain` "\"ca1\""
      LBS.unpack body `shouldNotContain` "\"alerts\""

    it "createSALogType POSTs /logtype with the encoded body" $ do
      let req =
            OS3A.SALogType
              { OS3A.saLogTypeName = "custom1",
                OS3A.saLogTypeDescription = Just "desc",
                OS3A.saLogTypeSource = Just OS3A.SALogTypeSourceCustom,
                OS3A.saLogTypeTags = Nothing,
                OS3A.saLogTypeOther = Null
              }
          r = OS3Requests.createSALogType req
      bhRequestMethod r `shouldBe` "POST"
      getRawEndpoint (bhRequestEndpoint r)
        `shouldBe` ["_plugins", "_security_analytics", "logtype"]
      getRawEndpointQueries (bhRequestEndpoint r) `shouldBe` []
      let body = fromJust (bhRequestBody r)
      LBS.unpack body `shouldContain` "\"name\""
      LBS.unpack body `shouldContain` "\"Custom\""

    it "searchSALogTypes POSTs /logtype/_search with the (possibly default) body" $ do
      let r = OS3Requests.searchSALogTypes Nothing
      bhRequestMethod r `shouldBe` "POST"
      getRawEndpoint (bhRequestEndpoint r)
        `shouldBe` ["_plugins", "_security_analytics", "logtype", "_search"]
      getRawEndpointQueries (bhRequestEndpoint r) `shouldBe` []
      let body = fromJust (bhRequestBody r)
      LBS.unpack body `shouldContain` "match_all"

    it "updateSALogType PUTs /logtype/{id} with the encoded body" $ do
      let req =
            OS3A.SALogType
              { OS3A.saLogTypeName = "custom1",
                OS3A.saLogTypeDescription = Just "updated",
                OS3A.saLogTypeSource = Just OS3A.SALogTypeSourceCustom,
                OS3A.saLogTypeTags = Nothing,
                OS3A.saLogTypeOther = Null
              }
          r = OS3Requests.updateSALogType "lt1" req
      bhRequestMethod r `shouldBe` "PUT"
      getRawEndpoint (bhRequestEndpoint r)
        `shouldBe` ["_plugins", "_security_analytics", "logtype", "lt1"]
      getRawEndpointQueries (bhRequestEndpoint r) `shouldBe` []

    it "deleteSALogType DELETEs /logtype/{id} with no body" $ do
      let r = OS3Requests.deleteSALogType "lt1"
      bhRequestMethod r `shouldBe` "DELETE"
      getRawEndpoint (bhRequestEndpoint r)
        `shouldBe` ["_plugins", "_security_analytics", "logtype", "lt1"]
      getRawEndpointQueries (bhRequestEndpoint r) `shouldBe` []
      bhRequestBody r `shouldBe` Nothing

    it "getSAAlerts keeps the detector_id as a single opaque path segment" $
      getRawEndpoint (bhRequestEndpoint (OS3Requests.getSAAlerts (OS3A.DetectorId "a/b")))
        `shouldBe` ["_plugins", "_security_analytics", "alerts"]

  describe "Alerts/Findings/Correlation/LogType endpoint shape (OpenSearch 2)" $ do
    it "getSAAlertsWith GETs .../alerts" $ do
      let r = OS2Requests.getSAAlertsWith OS2A.defaultSAGetAlertsOptions
      bhRequestMethod r `shouldBe` "GET"
      getRawEndpoint (bhRequestEndpoint r)
        `shouldBe` ["_plugins", "_security_analytics", "alerts"]

    it "acknowledgeSADetectorAlerts POSTs .../detectors/{id}/_acknowledge/alerts" $ do
      let r = OS2Requests.acknowledgeSADetectorAlerts (OS2A.DetectorId "d1") ["a1"]
      bhRequestMethod r `shouldBe` "POST"
      getRawEndpoint (bhRequestEndpoint r)
        `shouldBe` ["_plugins", "_security_analytics", "detectors", "d1", "_acknowledge", "alerts"]

    it "searchSAFindings GETs .../findings/_search" $ do
      let r = OS2Requests.searchSAFindings OS2A.defaultSASearchFindingsOptions
      bhRequestMethod r `shouldBe` "GET"
      getRawEndpoint (bhRequestEndpoint r)
        `shouldBe` ["_plugins", "_security_analytics", "findings", "_search"]

    it "createSACorrelationRule POSTs .../correlation/rules" $ do
      let r = OS2Requests.createSACorrelationRule (OS2A.CreateSACorrelationRuleRequest [])
      bhRequestMethod r `shouldBe` "POST"
      getRawEndpoint (bhRequestEndpoint r)
        `shouldBe` ["_plugins", "_security_analytics", "correlation", "rules"]

    it "getSACorrelations GETs .../correlations" $ do
      let r = OS2Requests.getSACorrelations OS2A.defaultGetSACorrelationsOptions
      bhRequestMethod r `shouldBe` "GET"
      getRawEndpoint (bhRequestEndpoint r)
        `shouldBe` ["_plugins", "_security_analytics", "correlations"]

    it "findSACorrelation GETs .../findings/correlate" $ do
      let r = OS2Requests.findSACorrelation OS2A.defaultFindSACorrelationOptions
      bhRequestMethod r `shouldBe` "GET"
      getRawEndpoint (bhRequestEndpoint r)
        `shouldBe` ["_plugins", "_security_analytics", "findings", "correlate"]

    it "searchSACorrelationAlerts GETs .../correlationAlerts" $ do
      let r = OS2Requests.searchSACorrelationAlerts OS2A.defaultSearchSACorrelationAlertsOptions
      bhRequestMethod r `shouldBe` "GET"
      getRawEndpoint (bhRequestEndpoint r)
        `shouldBe` ["_plugins", "_security_analytics", "correlationAlerts"]

    it "acknowledgeSACorrelationAlerts POSTs .../_acknowledge/correlationAlerts" $ do
      let r = OS2Requests.acknowledgeSACorrelationAlerts []
      bhRequestMethod r `shouldBe` "POST"
      getRawEndpoint (bhRequestEndpoint r)
        `shouldBe` ["_plugins", "_security_analytics", "_acknowledge", "correlationAlerts"]

    it "createSALogType POSTs .../logtype" $ do
      let r = OS2Requests.createSALogType (OS2A.SALogType "n" Nothing Nothing Nothing Null)
      bhRequestMethod r `shouldBe` "POST"
      getRawEndpoint (bhRequestEndpoint r)
        `shouldBe` ["_plugins", "_security_analytics", "logtype"]

    it "searchSALogTypes POSTs .../logtype/_search" $ do
      let r = OS2Requests.searchSALogTypes Nothing
      bhRequestMethod r `shouldBe` "POST"
      getRawEndpoint (bhRequestEndpoint r)
        `shouldBe` ["_plugins", "_security_analytics", "logtype", "_search"]

    it "updateSALogType PUTs .../logtype/{id}" $ do
      let r = OS2Requests.updateSALogType "lt1" (OS2A.SALogType "n" Nothing Nothing Nothing Null)
      bhRequestMethod r `shouldBe` "PUT"

    it "deleteSALogType DELETEs .../logtype/{id}" $ do
      let r = OS2Requests.deleteSALogType "lt1"
      bhRequestMethod r `shouldBe` "DELETE"

  describe "Alerts/Findings/Correlation/LogType cross-backend parity (OS2/OS3)" $ do
    let alertOpts2 = OS2A.defaultSAGetAlertsOptions
        alertOpts3 = OS3A.defaultSAGetAlertsOptions
        ackBody = ["a1" :: Text]
        corrReq2 = OS2A.CreateSACorrelationRuleRequest []
        corrReq3 = OS3A.CreateSACorrelationRuleRequest []
        logType2 = OS2A.SALogType "n" Nothing Nothing Nothing Null
        logType3 = OS3A.SALogType "n" Nothing Nothing Nothing Null

    it "getSAAlertsWith: OS2/OS3 produce the same path, method, and queries" $ do
      getRawEndpoint (bhRequestEndpoint (OS2Requests.getSAAlertsWith alertOpts2))
        `shouldBe` getRawEndpoint (bhRequestEndpoint (OS3Requests.getSAAlertsWith alertOpts3))
      bhRequestMethod (OS2Requests.getSAAlertsWith alertOpts2)
        `shouldBe` bhRequestMethod (OS3Requests.getSAAlertsWith alertOpts3)
      getRawEndpointQueries (bhRequestEndpoint (OS2Requests.getSAAlertsWith alertOpts2))
        `shouldBe` getRawEndpointQueries (bhRequestEndpoint (OS3Requests.getSAAlertsWith alertOpts3))

    it "acknowledgeSADetectorAlerts: OS2/OS3 produce the same path, method, and body" $ do
      getRawEndpoint (bhRequestEndpoint (OS2Requests.acknowledgeSADetectorAlerts (OS2A.DetectorId "d") ackBody))
        `shouldBe` getRawEndpoint (bhRequestEndpoint (OS3Requests.acknowledgeSADetectorAlerts (OS3A.DetectorId "d") ackBody))
      bhRequestBody (OS2Requests.acknowledgeSADetectorAlerts (OS2A.DetectorId "d") ackBody)
        `shouldBe` bhRequestBody (OS3Requests.acknowledgeSADetectorAlerts (OS3A.DetectorId "d") ackBody)

    it "searchSAFindings: OS2/OS3 produce the same path and method" $ do
      getRawEndpoint (bhRequestEndpoint (OS2Requests.searchSAFindings OS2A.defaultSASearchFindingsOptions))
        `shouldBe` getRawEndpoint (bhRequestEndpoint (OS3Requests.searchSAFindings OS3A.defaultSASearchFindingsOptions))
      bhRequestMethod (OS2Requests.searchSAFindings OS2A.defaultSASearchFindingsOptions)
        `shouldBe` bhRequestMethod (OS3Requests.searchSAFindings OS3A.defaultSASearchFindingsOptions)

    it "createSACorrelationRule: OS2/OS3 produce the same path, method, and body" $ do
      getRawEndpoint (bhRequestEndpoint (OS2Requests.createSACorrelationRule corrReq2))
        `shouldBe` getRawEndpoint (bhRequestEndpoint (OS3Requests.createSACorrelationRule corrReq3))
      bhRequestBody (OS2Requests.createSACorrelationRule corrReq2)
        `shouldBe` bhRequestBody (OS3Requests.createSACorrelationRule corrReq3)

    it "getSACorrelations: OS2/OS3 produce the same path and method" $
      getRawEndpoint (bhRequestEndpoint (OS2Requests.getSACorrelations OS2A.defaultGetSACorrelationsOptions))
        `shouldBe` getRawEndpoint (bhRequestEndpoint (OS3Requests.getSACorrelations OS3A.defaultGetSACorrelationsOptions))

    it "findSACorrelation: OS2/OS3 produce the same path and method" $
      getRawEndpoint (bhRequestEndpoint (OS2Requests.findSACorrelation OS2A.defaultFindSACorrelationOptions))
        `shouldBe` getRawEndpoint (bhRequestEndpoint (OS3Requests.findSACorrelation OS3A.defaultFindSACorrelationOptions))

    it "searchSACorrelationAlerts: OS2/OS3 produce the same path and method" $
      getRawEndpoint (bhRequestEndpoint (OS2Requests.searchSACorrelationAlerts OS2A.defaultSearchSACorrelationAlertsOptions))
        `shouldBe` getRawEndpoint (bhRequestEndpoint (OS3Requests.searchSACorrelationAlerts OS3A.defaultSearchSACorrelationAlertsOptions))

    it "acknowledgeSACorrelationAlerts: OS2/OS3 produce the same path, method, and body" $ do
      getRawEndpoint (bhRequestEndpoint (OS2Requests.acknowledgeSACorrelationAlerts ackBody))
        `shouldBe` getRawEndpoint (bhRequestEndpoint (OS3Requests.acknowledgeSACorrelationAlerts ackBody))
      bhRequestBody (OS2Requests.acknowledgeSACorrelationAlerts ackBody)
        `shouldBe` bhRequestBody (OS3Requests.acknowledgeSACorrelationAlerts ackBody)

    it "createSALogType: OS2/OS3 produce the same path and method" $
      getRawEndpoint (bhRequestEndpoint (OS2Requests.createSALogType logType2))
        `shouldBe` getRawEndpoint (bhRequestEndpoint (OS3Requests.createSALogType logType3))

    it "searchSALogTypes: OS2/OS3 produce the same path and method" $
      getRawEndpoint (bhRequestEndpoint (OS2Requests.searchSALogTypes Nothing))
        `shouldBe` getRawEndpoint (bhRequestEndpoint (OS3Requests.searchSALogTypes Nothing))

    it "updateSALogType: OS2/OS3 produce the same path and method" $
      getRawEndpoint (bhRequestEndpoint (OS2Requests.updateSALogType "lt" logType2))
        `shouldBe` getRawEndpoint (bhRequestEndpoint (OS3Requests.updateSALogType "lt" logType3))

    it "deleteSALogType: OS2/OS3 produce the same path and method" $
      getRawEndpoint (bhRequestEndpoint (OS2Requests.deleteSALogType "lt"))
        `shouldBe` getRawEndpoint (bhRequestEndpoint (OS3Requests.deleteSALogType "lt"))

  describe "SAAlertState / SADetectionType / SASeverity / SALogTypeSource enums" $ do
    it "SAAlertState round-trips every documented value" $ do
      OS3A.saAlertStateText OS3A.SAAlertStateActive `shouldBe` "ACTIVE"
      OS3A.saAlertStateText OS3A.SAAlertStateAcknowledged `shouldBe` "ACKNOWLEDGED"
      OS3A.saAlertStateText OS3A.SAAlertStateCompleted `shouldBe` "COMPLETED"
      OS3A.saAlertStateText OS3A.SAAlertStateError `shouldBe` "ERROR"
      OS3A.saAlertStateText OS3A.SAAlertStateDeleted `shouldBe` "DELETED"
      decode "\"ACTIVE\"" `shouldBe` Just OS3A.SAAlertStateActive
      decode "\"DELETED\"" `shouldBe` Just OS3A.SAAlertStateDeleted
      decode "\"FUTURE\"" `shouldBe` Just (OS3A.SAAlertStateOther "FUTURE")

    it "SADetectionType round-trips rule/threat" $ do
      OS3A.saDetectionTypeText OS3A.SADetectionTypeRule `shouldBe` "rule"
      decode "\"threat\"" `shouldBe` Just OS3A.SADetectionTypeThreat

    it "SASeverity round-trips every documented value" $ do
      OS3A.saSeverityText OS3A.SASeverityCritical `shouldBe` "critical"
      decode "\"low\"" `shouldBe` Just OS3A.SASeverityLow

    it "SALogTypeSource round-trips Sigma/Custom/Other" $ do
      OS3A.saLogTypeSourceText OS3A.SALogTypeSourceSigma `shouldBe` "Sigma"
      OS3A.saLogTypeSourceText OS3A.SALogTypeSourceCustom `shouldBe` "Custom"
      decode "\"Custom\"" `shouldBe` Just OS3A.SALogTypeSourceCustom
      decode "\"Future\"" `shouldBe` Just (OS3A.SALogTypeSourceOther "Future")

  describe "SAGetAlertsResponse decoding (get-alerts fixture)" $ do
    let fixture =
          LBS.pack
            "{\"alerts\":[{\"detector_id\":\"detector_12345\",\"id\":\"alert_id_1\",\
            \\"version\":-3,\"schema_version\":0,\"trigger_id\":\"trig1\",\
            \\"trigger_name\":\"my_trigger\",\"finding_ids\":[\"finding_id_1\"],\
            \\"related_doc_ids\":[\"docId1\"],\"state\":\"ACTIVE\",\
            \\"error_message\":null,\"alert_history\":[],\"severity\":null,\
            \\"action_execution_results\":[{\"action_id\":\"action_id_1\",\
            \\"last_execution_time\":1665693544996,\"throttled_count\":0}],\
            \\"start_time\":\"2022-10-13T20:39:04.995023Z\",\
            \\"last_notification_time\":\"2022-10-13T20:39:04.995028Z\",\
            \\"end_time\":\"2022-10-13T20:39:04.995027Z\",\
            \\"acknowledged_time\":\"2022-10-13T20:39:04.995028Z\"}],\
            \\"total_alerts\":1,\"detectorType\":\"windows\"}"

    it "decodes the documented get-alerts fixture" $ do
      let Just decoded = decode fixture :: Maybe OS3A.SAGetAlertsResponse
      OS3A.saGetAlertsResponseTotalAlerts decoded `shouldBe` Just 1
      OS3A.saGetAlertsResponseDetectorType decoded `shouldBe` Just "windows"
      let [alert] = OS3A.saGetAlertsResponseAlerts decoded
      OS3A.saAlertId alert `shouldBe` Just "alert_id_1"
      OS3A.saAlertDetectorId alert `shouldBe` Just "detector_12345"
      OS3A.saAlertVersion alert `shouldBe` Just (-3)
      OS3A.saAlertState alert `shouldBe` Just OS3A.SAAlertStateActive
      OS3A.saAlertFindingIds alert `shouldBe` Just ["finding_id_1"]
      OS3A.saAlertStartTime alert `shouldBe` Just "2022-10-13T20:39:04.995023Z"
      let [action] = fromJust (OS3A.saAlertActionExecutionResults alert)
      OS3A.saActionExecutionResultActionId action `shouldBe` Just "action_id_1"
      OS3A.saActionExecutionResultLastExecutionTime action `shouldBe` Just 1665693544996

    it "decodes an empty alerts array" $ do
      let Just decoded = decode "{\"alerts\":[],\"total_alerts\":0}" :: Maybe OS3A.SAGetAlertsResponse
      OS3A.saGetAlertsResponseAlerts decoded `shouldBe` []

  describe "AcknowledgeSADetectorAlertsResponse decoding (ack fixture)" $ do
    let reqFixture = LBS.pack "{\"alerts\":[\"id1\",\"id2\"]}"
        respFixture =
          LBS.pack
            "{\"acknowledged\":[{\"id\":\"id1\",\"state\":\"ACTIVE\",\"severity\":\"1\"}],\
            \\"failed\":[],\"missing\":[]}"

    it "acknowledge request renders as {\"alerts\":[...]} (snake_case)" $ do
      let Just decoded = decode reqFixture :: Maybe OS3A.AcknowledgeSADetectorAlertsRequest
      OS3A.acknowledgeSADetectorAlertsRequestAlerts decoded `shouldBe` ["id1", "id2"]

    it "decodes the documented ack-response fixture" $ do
      let Just decoded = decode respFixture :: Maybe OS3A.AcknowledgeSADetectorAlertsResponse
      let [alert] = OS3A.acknowledgeSADetectorAlertsResponseAcknowledged decoded
      OS3A.saAlertId alert `shouldBe` Just "id1"
      OS3A.saAlertSeverity alert `shouldBe` Just "1"

  describe "SASearchFindingsResponse decoding (findings fixture)" $ do
    let fixture =
          LBS.pack
            "{\"total_findings\":1,\"findings\":[{\"detectorId\":\"d1\",\"id\":\"f1\",\
            \\"related_doc_ids\":[\"1\"],\"index\":\"smallidx\",\
            \\"queries\":[{\"id\":\"q1\",\"name\":\"q1\",\"fields\":[],\
            \\"query\":\"field1: *value1*\",\"tags\":[\"high\",\"ad_ldap\"]}],\
            \\"timestamp\":1708647166500,\
            \\"document_list\":[{\"index\":\"smallidx\",\"id\":\"1\",\"found\":true,\
            \\"document\":\"{\\\"field1\\\":\\\"value1\\\"}\"}]}]}"

    it "decodes the documented findings fixture (camelCase detectorId)" $ do
      let Just decoded = decode fixture :: Maybe OS3A.SASearchFindingsResponse
      OS3A.saSearchFindingsResponseTotalFindings decoded `shouldBe` Just 1
      let [f] = OS3A.saSearchFindingsResponseFindings decoded
      OS3A.saFindingDetectorId f `shouldBe` Just "d1"
      OS3A.saFindingId f `shouldBe` Just "f1"
      OS3A.saFindingTimestamp f `shouldBe` Just 1708647166500
      let [q] = fromJust (OS3A.saFindingQueries f)
      OS3A.saFindingQueryTags q `shouldBe` Just ["high", "ad_ldap"]
      let [d] = fromJust (OS3A.saFindingDocumentList f)
      OS3A.saFindingDocumentFound d `shouldBe` Just True
      -- the @document@ field is a JSON-stringified source, NOT a nested object
      OS3A.saFindingDocumentDocument d `shouldBe` Just "{\"field1\":\"value1\"}"

  describe "CreateSACorrelationRuleResponse decoding (create fixture)" $ do
    let fixture =
          LBS.pack
            "{\"_id\":\"DxKEUIkBpIjg64IK4nXg\",\"_version\":1,\
            \\"rule\":{\"name\":null,\"correlate\":[{\"index\":\"vpc_flow\",\
            \\"query\":\"dstaddr:4.5.6.7\",\"category\":\"network\"}]}}"

    it "decodes the documented create-correlation-rule fixture" $ do
      let Just decoded = decode fixture :: Maybe OS3A.CreateSACorrelationRuleResponse
      OS3A.createSACorrelationRuleResponseId decoded `shouldBe` "DxKEUIkBpIjg64IK4nXg"
      OS3A.createSACorrelationRuleResponseVersion decoded `shouldBe` 1
      let rule = OS3A.createSACorrelationRuleResponseRule decoded
      OS3A.saCorrelationRuleName rule `shouldBe` Nothing
      let [entry] = OS3A.saCorrelationRuleCorrelate rule
      OS3A.saCorrelateEntryIndex entry `shouldBe` "vpc_flow"

  describe "GetSACorrelationsResponse decoding (time-window fixture)" $ do
    let fixture =
          LBS.pack
            "{\"findings\":[{\"finding1\":\"f1\",\"logType1\":\"network\",\
            \\"finding2\":\"f2\",\"logType2\":\"test_windows\",\
            \\"rules\":[\"nqI2TokBgL5wWFPZ6Gfu\"]}]}"

    it "decodes the documented correlations fixture (camelCase logType1/2)" $ do
      let Just decoded = decode fixture :: Maybe OS3A.GetSACorrelationsResponse
      let [cf] = OS3A.getSACorrelationsResponseFindings decoded
      OS3A.saCorrelationFindingFinding1 cf `shouldBe` Just "f1"
      OS3A.saCorrelationFindingLogType1 cf `shouldBe` Just "network"
      OS3A.saCorrelationFindingFinding2 cf `shouldBe` Just "f2"
      OS3A.saCorrelationFindingRules cf `shouldBe` Just ["nqI2TokBgL5wWFPZ6Gfu"]

  describe "FindSACorrelationResponse decoding (correlate fixture)" $ do
    let fixture =
          LBS.pack
            "{\"findings\":[{\"finding\":\"5c661104-aaa9-484b-a91f-9cad4ae6d5f5\",\
            \\"detector_type\":\"others_application\",\"score\":0.000015182109564193524}]}"

    it "decodes the documented find-correlation fixture (snake_case detector_type)" $ do
      let Just decoded = decode fixture :: Maybe OS3A.FindSACorrelationResponse
      let [s] = OS3A.findSACorrelationResponseFindings decoded
      OS3A.saCorrelationScoreFinding s `shouldBe` Just "5c661104-aaa9-484b-a91f-9cad4ae6d5f5"
      OS3A.saCorrelationScoreDetectorType s `shouldBe` Just "others_application"
      OS3A.saCorrelationScoreScore s `shouldSatisfy` maybe False (>= 0)

  describe "SearchSACorrelationAlertsResponse decoding (list fixture)" $ do
    let fixture =
          LBS.pack
            "{\"correlationAlerts\":[{\"correlated_finding_ids\":[\"cf1\"],\
            \\"correlation_rule_id\":\"cr1\",\"correlation_rule_name\":\"rule-corr\",\
            \\"user\":null,\"id\":\"ca1\",\"version\":1,\"schema_version\":1,\
            \\"trigger_name\":\"trigger1\",\"state\":\"ACTIVE\",\
            \\"error_message\":null,\"severity\":\"1\",\
            \\"action_execution_results\":[{\"action_id\":\"act1\",\
            \\"last_execution_time\":1718824628257,\"throttled_count\":0}],\
            \\"start_time\":\"2024-06-19T20:37:08.257Z\",\
            \\"end_time\":\"2024-06-19T20:42:08.257Z\",\"acknowledged_time\":null}],\
            \\"total_alerts\":1}"

    it "decodes the documented correlation-alerts fixture" $ do
      let Just decoded = decode fixture :: Maybe OS3A.SearchSACorrelationAlertsResponse
      OS3A.searchSACorrelationAlertsResponseTotalAlerts decoded `shouldBe` Just 1
      let [ca] = OS3A.searchSACorrelationAlertsResponseCorrelationAlerts decoded
      OS3A.saCorrelationAlertId ca `shouldBe` Just "ca1"
      OS3A.saCorrelationAlertCorrelationRuleId ca `shouldBe` Just "cr1"
      OS3A.saCorrelationAlertCorrelatedFindingIds ca `shouldBe` Just ["cf1"]
      OS3A.saCorrelationAlertState ca `shouldBe` Just OS3A.SAAlertStateActive
      OS3A.saCorrelationAlertSeverity ca `shouldBe` Just "1"
      -- the typed action_execution_results (S4 fix: matches SAAlert's typing)
      let [action] = fromJust (OS3A.saCorrelationAlertActionExecutionResults ca)
      OS3A.saActionExecutionResultActionId action `shouldBe` Just "act1"
      OS3A.saActionExecutionResultThrottledCount action `shouldBe` Just 0

  describe "AcknowledgeSACorrelationAlerts (request + response) decoding" $ do
    let reqFixture = LBS.pack "{\"alertIds\":[\"ca1\",\"ca2\"]}"
        respFixture =
          LBS.pack
            "{\"acknowledged\":[{\"id\":\"ca1\",\"state\":\"ACTIVE\"}],\"failed\":[]}"

    it "acknowledge request renders as {\"alertIds\":[...]} (camelCase, NOT alerts)" $ do
      let Just decoded = decode reqFixture :: Maybe OS3A.AcknowledgeSACorrelationAlertsRequest
      OS3A.acknowledgeSACorrelationAlertsRequestAlertIds decoded `shouldBe` ["ca1", "ca2"]
      LBS.unpack (encode (OS3A.AcknowledgeSACorrelationAlertsRequest ["x"]))
        `shouldContain` "\"alertIds\""

    it "decodes the documented ack-correlation-alerts fixture (NO missing key)" $ do
      let Just decoded = decode respFixture :: Maybe OS3A.AcknowledgeSACorrelationAlertsResponse
      let [ca] = OS3A.acknowledgeSACorrelationAlertsResponseAcknowledged decoded
      OS3A.saCorrelationAlertId ca `shouldBe` Just "ca1"

  describe "SALogType / SALogTypeResponse decoding (create fixture)" $ do
    let reqFixture =
          LBS.pack
            "{\"description\":\"custom-log-type-desc\",\"name\":\"custom-log-type4\",\
            \\"source\":\"Custom\"}"
        respFixture =
          LBS.pack
            "{\"_id\":\"m98uk4kBlb9cbROIpEj2\",\"_version\":1,\
            \\"logType\":{\"name\":\"custom-log-type4\",\
            \\"description\":\"custom-log-type-desc\",\"source\":\"Custom\",\
            \\"tags\":{\"correlation_id\":27}}}"

    it "decodes a documented log-type request body" $ do
      let Just decoded = decode reqFixture :: Maybe OS3A.SALogType
      OS3A.saLogTypeName decoded `shouldBe` "custom-log-type4"
      OS3A.saLogTypeSource decoded `shouldBe` Just OS3A.SALogTypeSourceCustom

    it "decodes the documented log-type create/update fixture (camelCase logType wrapper)" $ do
      let Just decoded = decode respFixture :: Maybe OS3A.SALogTypeResponse
      OS3A.saLogTypeResponseId decoded `shouldBe` "m98uk4kBlb9cbROIpEj2"
      OS3A.saLogTypeResponseVersion decoded `shouldBe` 1
      let lt = OS3A.saLogTypeResponseLogType decoded
      OS3A.saLogTypeName lt `shouldBe` "custom-log-type4"
      let tags = fromJust (OS3A.saLogTypeTags lt)
      OS3A.saLogTypeTagsCorrelationId tags `shouldBe` Just 27

  describe "SALogTypesSearchResponse decoding (search fixture)" $ do
    let fixture =
          LBS.pack
            "{\"took\":3,\"timed_out\":false,\"_shards\":{\"total\":1,\
            \\"successful\":1,\"skipped\":0,\"failed\":0},\
            \\"hits\":{\"total\":{\"value\":26,\"relation\":\"eq\"},\
            \\"max_score\":2.0,\"hits\":[{\"_index\":\".opensearch-sap-log-types-config\",\
            \\"_id\":\"s3\",\"_score\":2.0,\
            \\"_source\":{\"name\":\"s3\",\"description\":\"Windows logs\",\
            \\"source\":\"Sigma\",\"tags\":{\"correlation_id\":21}}},{\
            \\"_index\":\".opensearch-sap-log-types-config\",\"_id\":\"custom1\",\
            \\"_score\":2.0,\"_source\":{\"name\":\"custom-lt\",\"description\":\"d\",\
            \\"source\":\"Custom\",\"tags\":null}}]}}"

    it "decodes the documented log-type search fixture (standard OS search envelope)" $ do
      let Just decoded = decode fixture :: Maybe OS3A.SALogTypesSearchResponse
      OS3A.saLogTypesSearchResponseTook decoded `shouldBe` Just 3
      let total = fromJust (OS3A.saLogTypesSearchResponseHitsTotal decoded)
      OS3A.saLogTypesTotalValue total `shouldBe` 26
      OS3A.saLogTypesTotalRelation total `shouldBe` OS3A.SALogTypesTotalRelationEq
      length (OS3A.saLogTypesSearchResponseHits decoded) `shouldBe` 2
      let lts = OS3A.saLogTypesSearchResponseLogTypes decoded
      length lts `shouldBe` 2
      -- Check fields individually (the saLogTypeOther catch-all breaks structural equality)
      OS3A.saLogTypeName (head lts) `shouldBe` "s3"
      OS3A.saLogTypeDescription (head lts) `shouldBe` Just "Windows logs"
      OS3A.saLogTypeSource (head lts) `shouldBe` Just OS3A.SALogTypeSourceSigma
      let Just tags = OS3A.saLogTypeTags (head lts)
      OS3A.saLogTypeTagsCorrelationId tags `shouldBe` Just 21

    it "tolerates null tags on a custom log type" $ do
      let Just decoded = decode fixture :: Maybe OS3A.SALogTypesSearchResponse
          lts = OS3A.saLogTypesSearchResponseLogTypes decoded
      OS3A.saLogTypeTags (lts !! 1) `shouldBe` Nothing

  describe "DeleteSALogTypeResponse decoding (delete fixture)" $ do
    let fixture = LBS.pack "{\"_id\":\"m98uk4kBlb9cbROIpEj2\",\"_version\":1}"

    it "decodes the documented delete-log-type fixture" $ do
      let Just decoded = decode fixture :: Maybe OS3A.DeleteSALogTypeResponse
      OS3A.deleteSALogTypeResponseId decoded `shouldBe` "m98uk4kBlb9cbROIpEj2"
      OS3A.deleteSALogTypeResponseVersion decoded `shouldBe` 1

  describe "Alerts/Findings/Correlation/LogType request rendering" $ do
    it "CreateSACorrelationRuleRequest round-trips through encode/decode" $ do
      let req = OS3A.CreateSACorrelationRuleRequest [OS3A.SACorrelateEntry "i" "q" "c"]
      decode (encode req) `shouldBe` Just req

    it "SACorrelateEntry round-trips" $ do
      let e = OS3A.SACorrelateEntry "vpc_flow" "dstaddr:1.2.3.4" "network"
      decode (encode e) `shouldBe` Just e

    it "SALogType renders with documented fields (omits Nothing tags)" $ do
      let req = OS3A.SALogType "n" (Just "d") (Just OS3A.SALogTypeSourceCustom) Nothing Null
          body = encode req
      LBS.unpack body `shouldContain` "\"name\""
      LBS.unpack body `shouldContain` "\"Custom\""
      LBS.unpack body `shouldNotContain` "\"tags\""

    it "SALogType preserves an unknown forward-compat key via the catch-all" $ do
      let lbs = LBS.pack "{\"name\":\"n\",\"future_key\":42}"
          Just decoded = decode lbs :: Maybe OS3A.SALogType
      OS3A.saLogTypeName decoded `shouldBe` "n"
      LBS.unpack (encode decoded) `shouldContain` "\"future_key\""

    it "defaultSALogTypeSearchQuery renders as {\"query\":{\"match_all\":{}}}" $ do
      LBS.unpack (encode OS3A.defaultSALogTypeSearchQuery) `shouldContain` "match_all"

    it "AcknowledgeSADetectorAlertsRequest round-trips" $ do
      let req = OS3A.AcknowledgeSADetectorAlertsRequest ["a", "b"]
      decode (encode req) `shouldBe` Just req

    it "AcknowledgeSACorrelationAlertsRequest round-trips (camelCase alertIds)" $ do
      let req = OS3A.AcknowledgeSACorrelationAlertsRequest ["a", "b"]
      decode (encode req) `shouldBe` Just req
      LBS.unpack (encode req) `shouldContain` "\"alertIds\""

  describe "Alerts/Findings/Correlation/LogType live round-trip (gated)" $ do
    os2It <- runIO os2OnlyIT
    os3It <- runIO os3OnlyIT

    os2It "OS2: getSAAlerts/Findings/Correlations/LogTypes stay pending (no live fixtures yet)" $
      pendingWith
        "Live round-trips for the alerts/findings/correlation/log-type \
        \endpoints are not yet wired (require a Security Analytics \
        \detector with findings/alerts set up). The endpoint shapes and \
        \wire formats are pinned by the pure tests above; live \
        \verification tracked as a follow-up."

    os3It "OS3: getSAAlerts/Findings/Correlations/LogTypes stay pending (no live fixtures yet)" $
      pendingWith
        "Live round-trips for the alerts/findings/correlation/log-type \
        \endpoints are not yet wired (require a Security Analytics \
        \detector with findings/alerts set up). The endpoint shapes and \
        \wire formats are pinned by the pure tests above; live \
        \verification tracked as a follow-up."

  -- =========================================================== --
  -- Live round-trip: gated per-OS with os{n}OnlyIT (the          --
  -- 'Test.NeuralClearCacheSpec' pattern). Wire shapes            --
  -- live-verified on OS 2.19.5 and 3.7.0 (bead bloodhound-z5j).  --
  --                                                              --
  -- The DELETE-detector response shape is version-dependent: OS    --
  -- 2.x/3.x return only @{_id,_version}@. The detector input       --
  -- requires a pre-existing index, so @windows@ is created in      --
  -- setup.                                                         --
  -- =========================================================== --
  describe "deleteSADetector live round-trip (version-dependent shape)" $ do
    os2It <- runIO os2OnlyIT
    os3It <- runIO os3OnlyIT

    let ensureWindowsIndex =
          tryEsError
            ( void $
                performBHRequest $
                  createIndex
                    (IndexSettings (ShardCount 1) (ReplicaCount 0) defaultIndexMappingsLimits)
                    [qqIndexName|windows|]
            )

    os2It "OS2: create -> delete returns the minimal {_id,_version} body" $
      withTestEnv $ do
        _ <- ensureWindowsIndex
        resp <- OS2.Client.createSADetector os2SampleDetector
        let did = OS2A.DetectorId (OS2A.saDetectorResponseId resp)
        del <- OS2.Client.deleteSADetector did
        liftIO $ do
          OS2A.deleteSADetectorResponseId del `shouldBe` OS2A.saDetectorResponseId resp
          OS2A.deleteSADetectorResponseVersion del `shouldSatisfy` (>= 1)
          OS2A.deleteSADetectorResponseIndex del `shouldBe` Nothing
          OS2A.deleteSADetectorResponseShards del `shouldBe` Nothing
          OS2A.deleteSADetectorResponseResult del `shouldBe` Nothing

    os3It "OS3: create -> delete returns the minimal {_id,_version} body" $
      withTestEnv $ do
        _ <- ensureWindowsIndex
        resp <- OS3.Client.createSADetector os3SampleDetector
        let did = OS3A.DetectorId (OS3A.saDetectorResponseId resp)
        del <- OS3.Client.deleteSADetector did
        liftIO $ do
          OS3A.deleteSADetectorResponseId del `shouldBe` OS3A.saDetectorResponseId resp
          OS3A.deleteSADetectorResponseVersion del `shouldSatisfy` (>= 1)
          OS3A.deleteSADetectorResponseIndex del `shouldBe` Nothing
          OS3A.deleteSADetectorResponseShards del `shouldBe` Nothing
          OS3A.deleteSADetectorResponseResult del `shouldBe` Nothing

-- ---------------------------------------------------------------- --
-- Sample fixtures (structurally identical across OS2/OS3;          --
-- nominally distinct types, so one per backend).                  --
-- ---------------------------------------------------------------- --

os3SampleDetector :: OS3A.SADetector
os3SampleDetector =
  OS3A.SADetector
    { OS3A.saDetectorName = "test-detector",
      OS3A.saDetectorType = OS3A.SADetectorTypeWindows,
      OS3A.saDetectorKind = Just "detector",
      OS3A.saDetectorEnabled = Just True,
      OS3A.saDetectorSchedule =
        OS3A.PeriodSASchedule (OS3A.SASchedulePeriod 1 "MINUTES"),
      OS3A.saDetectorInputs =
        [ OS3A.SADetectorInput
            { OS3A.saDetectorInputDescription = Just "windows detector for security analytics",
              OS3A.saDetectorInputIndices = ["windows"],
              OS3A.saDetectorInputCustomRules = [],
              OS3A.saDetectorInputPrePackagedRules =
                [OS3A.SARuleReference "06724a9a-52fc-11ed-bdc3-0242ac120002"],
              OS3A.saDetectorInputOther = Null
            }
        ],
      OS3A.saDetectorTriggers =
        [ OS3A.SATrigger
            { OS3A.saTriggerId = Just "test-trigger-id",
              OS3A.saTriggerName = "test-trigger",
              OS3A.saTriggerSeverity = "1",
              OS3A.saTriggerIds = [],
              OS3A.saTriggerTypes = [],
              OS3A.saTriggerTags = ["attack.defense_evasion"],
              OS3A.saTriggerSevLevels = [],
              OS3A.saTriggerActions = [],
              OS3A.saTriggerOther = Null
            }
        ],
      OS3A.saDetectorOther = Null
    }

os2SampleDetector :: OS2A.SADetector
os2SampleDetector =
  OS2A.SADetector
    { OS2A.saDetectorName = "test-detector",
      OS2A.saDetectorType = OS2A.SADetectorTypeWindows,
      OS2A.saDetectorKind = Just "detector",
      OS2A.saDetectorEnabled = Just True,
      OS2A.saDetectorSchedule =
        OS2A.PeriodSASchedule (OS2A.SASchedulePeriod 1 "MINUTES"),
      OS2A.saDetectorInputs =
        [ OS2A.SADetectorInput
            { OS2A.saDetectorInputDescription = Just "windows detector for security analytics",
              OS2A.saDetectorInputIndices = ["windows"],
              OS2A.saDetectorInputCustomRules = [],
              OS2A.saDetectorInputPrePackagedRules =
                [OS2A.SARuleReference "06724a9a-52fc-11ed-bdc3-0242ac120002"],
              OS2A.saDetectorInputOther = Null
            }
        ],
      OS2A.saDetectorTriggers =
        [ OS2A.SATrigger
            { OS2A.saTriggerId = Just "test-trigger-id",
              OS2A.saTriggerName = "test-trigger",
              OS2A.saTriggerSeverity = "1",
              OS2A.saTriggerIds = [],
              OS2A.saTriggerTypes = [],
              OS2A.saTriggerTags = ["attack.defense_evasion"],
              OS2A.saTriggerSevLevels = [],
              OS2A.saTriggerActions = [],
              OS2A.saTriggerOther = Null
            }
        ],
      OS2A.saDetectorOther = Null
    }

-- | Helper assertion: the given wire string decodes to the expected
-- 'SADetectorType'.
decodesDetectorTypeTo :: Text -> OS3A.SADetectorType -> Expectation
decodesDetectorTypeTo wire expected =
  decode (encode wire) `shouldBe` Just expected

-- | A sample Sigma rule document (raw YAML text), verbatim from the
-- OS docs create-custom-rule example. Used to assert that the rule
-- CRUD request builders forward the body verbatim (not JSON-encoded).
sampleSigmaYaml :: Text
sampleSigmaYaml =
  "title: Moriya Rootkit\n\
  \id: 25b9c01c-350d-4b95-bed1-836d04a4f324\n\
  \description: Detects the use of Moriya rootkit\n\
  \status: experimental\n\
  \author: Bhabesh Raj\n\
  \logsource:\n\
  \    product: windows\n\
  \    service: system\n\
  \detection:\n\
  \    selection:\n\
  \        Provider_Name: 'Service Control Manager'\n\
  \        EventID: 7045\n\
  \        ServiceName: ZzNetSvc\n\
  \    condition: selection\n\
  \level: critical\n\
  \falsepositives:\n\
  \    - Unknown"