bloodhound-1.0.0.0: tests/Test/NotificationsSpec.hs
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE TypeApplications #-}
module Test.NotificationsSpec (spec) where
import Control.Concurrent (threadDelay)
import Control.Monad.Catch (bracket_)
import Data.ByteString.Char8 qualified as BS
import Data.ByteString.Lazy.Char8 qualified as LBS
import Data.List qualified as L
import Data.Map.Strict qualified as Map
import Data.Text qualified as T
import Data.Time.Clock.POSIX (getPOSIXTime)
import Database.Bloodhound.OpenSearch2.Client qualified as OS2Client
import Database.Bloodhound.OpenSearch2.Requests qualified as OS2Requests
import Database.Bloodhound.OpenSearch2.Types qualified as OS2Types
import Database.Bloodhound.OpenSearch3.Client qualified as OS3Client
import Database.Bloodhound.OpenSearch3.Requests qualified as OS3Requests
import Database.Bloodhound.OpenSearch3.Types
import TestsUtils.Common
import TestsUtils.Import
import Prelude
-- ---------------------------------------------------------------------------
-- Sample bodies, drawn verbatim from the OS Notifications plugin docs
-- (<https://docs.opensearch.org/latest/observing-your-data/notifications/api/>).
-- ---------------------------------------------------------------------------
-- | The canonical POST body for a Slack channel — exactly the shape
-- documented under "Create notification configuration".
sampleSlackConfigJson :: LBS.ByteString
sampleSlackConfigJson =
"{\
\ \"name\": \"Sample Slack Channel\",\
\ \"description\": \"This is a Slack channel\",\
\ \"config_type\": \"slack\",\
\ \"is_enabled\": true,\
\ \"slack\": {\"url\": \"https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX\"}\
\}"
-- | An SNS config with the optional @role_arn@ omitted — exercises the
-- 'Maybe' field on 'SnsTransport'.
sampleSnsConfigNoRoleJson :: LBS.ByteString
sampleSnsConfigNoRoleJson =
"{\
\ \"name\": \"SNS no role\",\
\ \"config_type\": \"sns\",\
\ \"sns\": {\"topic_arn\": \"arn:aws:sns:us-east-1:123:topic\"}\
\}"
-- | The POST request body wrapping a Slack config with a caller-supplied
-- @config_id@.
sampleCreateRequestJson :: LBS.ByteString
sampleCreateRequestJson =
"{\
\ \"config_id\": \"sample-id\",\
\ \"config\": {\
\ \"name\": \"Sample Slack Channel\",\
\ \"description\": \"This is a Slack channel\",\
\ \"config_type\": \"slack\",\
\ \"is_enabled\": true,\
\ \"slack\": {\"url\": \"https://sample-slack-webhook\"}\
\ }\
\}"
-- | The create response body — a single-field object echoing @config_id@.
sampleCreateResponseJson :: LBS.ByteString
sampleCreateResponseJson = "{\"config_id\":\"abc-123\"}"
spec :: Spec
spec = describe "Notifications plugin" $ do
-- =======================================================================
-- NotificationConfigType
-- =======================================================================
describe "NotificationConfigType JSON" $ do
let cases =
[ (NotificationConfigTypeSlack, "slack"),
(NotificationConfigTypeChime, "chime"),
(NotificationConfigTypeWebhook, "webhook"),
(NotificationConfigTypeMicrosoftTeams, "microsoft_teams"),
(NotificationConfigTypeSns, "sns"),
(NotificationConfigTypeSesAccount, "ses_account"),
(NotificationConfigTypeSmtpAccount, "smtp_account"),
(NotificationConfigTypeEmailGroup, "email_group"),
(NotificationConfigTypeEmail, "email")
]
forM_ cases $ \(t, wire) -> do
it ("encodes " <> show wire <> " to the documented wire string") $ do
encode t `shouldBe` "\"" <> LBS.fromStrict wire <> "\""
it ("decodes the documented wire string " <> show wire) $ do
decode ("\"" <> LBS.fromStrict wire <> "\"" :: LBS.ByteString)
`shouldBe` Just t
it "rejects an unknown config_type" $ do
decode "\"slack_v2\"" `shouldBe` (Nothing :: Maybe NotificationConfigType)
it "rejects a non-string config_type" $ do
decode "42" `shouldBe` (Nothing :: Maybe NotificationConfigType)
it "round-trips through ToJSON/FromJSON" $
property $ \(t :: NotificationConfigType) ->
(decode . encode) t === Just t
-- =======================================================================
-- SlackTransport / ChimeTransport / WebhookTransport / MicrosoftTeams
-- =======================================================================
describe "SlackTransport JSON" $ do
it "round-trips a url" $ do
let t = SlackTransport "https://hooks.slack.com/abc"
(decode . encode) t `shouldBe` Just t
it "encodes to a single-field url object" $
encode (SlackTransport "https://x")
`shouldBe` "{\"url\":\"https://x\"}"
it "requires the url field" $
decode "{}" `shouldBe` (Nothing :: Maybe SlackTransport)
describe "ChimeTransport JSON" $ do
it "round-trips a url" $ do
let t = ChimeTransport "https://chime.webhook"
(decode . encode) t `shouldBe` Just t
it "encodes to a single-field url object" $
encode (ChimeTransport "https://y")
`shouldBe` "{\"url\":\"https://y\"}"
describe "WebhookTransport JSON" $ do
it "round-trips a url" $ do
let t = WebhookTransport "https://custom.example/hook"
(decode . encode) t `shouldBe` Just t
describe "MicrosoftTeamsTransport JSON" $ do
it "round-trips a url" $ do
let t = MicrosoftTeamsTransport "https://teams.webhook"
(decode . encode) t `shouldBe` Just t
it "encodes under the microsoft_teams wire key when wrapped" $ do
let wrapped = NotificationTransportMicrosoftTeams (MicrosoftTeamsTransport "https://teams.webhook")
encode wrapped `shouldBe` "{\"microsoft_teams\":{\"url\":\"https://teams.webhook\"}}"
-- =======================================================================
-- SnsTransport (exercises Maybe field)
-- =======================================================================
describe "SnsTransport JSON" $ do
it "round-trips with role_arn populated" $ do
let t = SnsTransport "arn:aws:sns:..." (Just "arn:aws:iam::role")
(decode . encode) t `shouldBe` Just t
it "round-trips with role_arn omitted" $ do
let t = SnsTransport "arn:aws:sns:..." Nothing
(decode . encode) t `shouldBe` Just t
it "omits role_arn when Nothing (wire shape)" $
encode (SnsTransport "arn:aws:sns:..." Nothing)
`shouldBe` "{\"topic_arn\":\"arn:aws:sns:...\"}"
it "requires topic_arn" $
decode "{\"role_arn\":\"x\"}" `shouldBe` (Nothing :: Maybe SnsTransport)
-- =======================================================================
-- SesAccountTransport / SmtpAccountTransport
-- =======================================================================
describe "SesAccountTransport JSON" $ do
it "round-trips the documented shape" $ do
let t =
SesAccountTransport
{ sesAccountTransportRegion = "us-east-1",
sesAccountTransportRoleArn = "arn:aws:iam::012345678912:role/x",
sesAccountTransportFromAddress = "test@email.com"
}
(decode . encode) t `shouldBe` Just t
it "requires region, role_arn, from_address" $
decode "{\"region\":\"us-east-1\"}" `shouldBe` (Nothing :: Maybe SesAccountTransport)
describe "SmtpAccountTransport JSON" $ do
it "round-trips the documented shape" $ do
let t =
SmtpAccountTransport
{ smtpAccountTransportHost = "smtp.example.com",
smtpAccountTransportPort = 587,
smtpAccountTransportMethod = SmtpMethodStartTls,
smtpAccountTransportFromAddress = "test@email.com"
}
(decode . encode) t `shouldBe` Just t
it "round-trips each SmtpMethod variant" $
mapM_
( \m -> do
let t = SmtpAccountTransport "h" 25 m "f@e.com"
(decode . encode) t `shouldBe` Just t
)
[SmtpMethodNone, SmtpMethodSsl, SmtpMethodStartTls]
it "rejects an unknown method" $
( decode "{\"host\":\"h\",\"port\":25,\"method\":\"bogus\",\"from_address\":\"f@e.com\"}" ::
Maybe SmtpAccountTransport
)
`shouldBe` Nothing
-- =======================================================================
-- EmailRecipient / EmailGroupTransport / EmailTransport
-- =======================================================================
describe "EmailRecipient JSON" $ do
it "round-trips an object {recipient: ...}" $ do
let r = EmailRecipient "alice@example.com"
(decode . encode) r `shouldBe` Just r
it "encodes to {\"recipient\":\"...\"}" $
encode (EmailRecipient "x@y.com") `shouldBe` "{\"recipient\":\"x@y.com\"}"
it "accepts the bare-string variant shown in docs (decode-only)" $
decode "\"alice@example.com\"" `shouldBe` Just (EmailRecipient "alice@example.com")
it "OS2: accepts the bare-string variant shown in docs (decode-only)" $
decode "\"alice@example.com\"" `shouldBe` Just (OS2Types.EmailRecipient "alice@example.com")
describe "EmailGroupTransport JSON" $ do
it "round-trips a non-empty recipient list" $ do
let t =
EmailGroupTransport
{ emailGroupTransportRecipientList =
EmailRecipient "a@b.com" :| [EmailRecipient "c@d.com"]
}
(decode . encode) t `shouldBe` Just t
it "requires a non-empty recipient_list" $
decode "{\"recipient_list\":[]}" `shouldBe` (Nothing :: Maybe EmailGroupTransport)
describe "EmailTransport JSON" $ do
it "round-trips with email_group_id_list populated" $ do
let t =
EmailTransport
{ emailTransportEmailAccountId = "smtp-1",
emailTransportRecipientList = EmailRecipient "x@y.com" :| [],
emailTransportEmailGroupIdList = Just ["g1", "g2"]
}
(decode . encode) t `shouldBe` Just t
it "round-trips with email_group_id_list omitted" $ do
let t =
EmailTransport
{ emailTransportEmailAccountId = "smtp-1",
emailTransportRecipientList = EmailRecipient "x@y.com" :| [],
emailTransportEmailGroupIdList = Nothing
}
(decode . encode) t `shouldBe` Just t
it "omits email_group_id_list when Nothing (wire shape)" $ do
let bs =
LBS.toStrict . encode $
EmailTransport
{ emailTransportEmailAccountId = "smtp-1",
emailTransportRecipientList = EmailRecipient "x@y.com" :| [],
emailTransportEmailGroupIdList = Nothing
}
bs `shouldSatisfy` BS.isInfixOf "\"email_account_id\""
-- Assert absence: a buggy encoder that emitted `email_group_id_list`
-- for `Nothing` would silently pass without this negative check.
bs `shouldSatisfy` not . BS.isInfixOf "email_group_id_list"
-- =======================================================================
-- NotificationTransport sum
-- =======================================================================
describe "NotificationTransport JSON" $ do
it "encodes Slack variant under the slack wire key" $ do
let t = NotificationTransportSlack (SlackTransport "https://x")
encode t `shouldBe` "{\"slack\":{\"url\":\"https://x\"}}"
it "encodes Sns variant under the sns wire key" $ do
let t = NotificationTransportSns (SnsTransport "arn:sns" Nothing)
encode t `shouldBe` "{\"sns\":{\"topic_arn\":\"arn:sns\"}}"
it "encodes SmtpAccount variant under the smtp_account wire key" $ do
let t =
NotificationTransportSmtpAccount
( SmtpAccountTransport
{ smtpAccountTransportHost = "h",
smtpAccountTransportPort = 25,
smtpAccountTransportMethod = SmtpMethodStartTls,
smtpAccountTransportFromAddress = "f@e.com"
}
)
bs = LBS.toStrict (encode t)
-- Aeson 2.x sorts object keys alphabetically on encode; assert each
-- substring is present rather than the exact byte ordering.
bs `shouldSatisfy` BS.isInfixOf "\"smtp_account\":"
bs `shouldSatisfy` BS.isInfixOf "\"host\":\"h\""
bs `shouldSatisfy` BS.isInfixOf "\"port\":25"
bs `shouldSatisfy` BS.isInfixOf "\"method\":\"start_tls\""
bs `shouldSatisfy` BS.isInfixOf "\"from_address\":\"f@e.com\""
it "encodes EmailGroup variant under the email_group wire key" $ do
let t =
NotificationTransportEmailGroup
( EmailGroupTransport
{ emailGroupTransportRecipientList = EmailRecipient "a@b" :| []
}
)
encode t
`shouldBe` "{\"email_group\":{\"recipient_list\":[{\"recipient\":\"a@b\"}]}}"
it "decodes a single-variant transport body" $
decode "{\"chime\":{\"url\":\"https://c\"}}"
`shouldBe` Just (NotificationTransportChime (ChimeTransport "https://c"))
it "rejects an empty transport object" $
decode "{}" `shouldBe` (Nothing :: Maybe NotificationTransport)
it "rejects an unknown transport object" $
decode "{\"unknown_transport\":{\"x\":1}}"
`shouldBe` (Nothing :: Maybe NotificationTransport)
it "rejects a multi-transport object (server would reject anyway)" $
-- Both 'slack' and 'chime' keys are present. The decoder detects
-- multiple known transport keys and parse-fails rather than
-- silently picking the first.
decode "{\"slack\":{\"url\":\"a\"},\"chime\":{\"url\":\"b\"}}"
`shouldBe` (Nothing :: Maybe NotificationTransport)
-- =======================================================================
-- transportConfigType invariant
-- =======================================================================
describe "transportConfigType" $ do
it "recovers NotificationConfigTypeSlack from a Slack transport" $
transportConfigType (NotificationTransportSlack (SlackTransport "x"))
`shouldBe` NotificationConfigTypeSlack
it "recovers NotificationConfigTypeMicrosoftTeams from a Teams transport" $
transportConfigType
(NotificationTransportMicrosoftTeams (MicrosoftTeamsTransport "x"))
`shouldBe` NotificationConfigTypeMicrosoftTeams
it "recovers NotificationConfigTypeEmailGroup from an EmailGroup transport" $
transportConfigType
( NotificationTransportEmailGroup
(EmailGroupTransport (EmailRecipient "x" :| []))
)
`shouldBe` NotificationConfigTypeEmailGroup
-- Parametric coverage across all 9 variants: prevents a future
-- copy-paste error in transportConfigType from going undetected.
let allTransports :: [(NotificationConfigType, NotificationTransport)]
allTransports =
[ (NotificationConfigTypeSlack, NotificationTransportSlack (SlackTransport "u")),
(NotificationConfigTypeChime, NotificationTransportChime (ChimeTransport "u")),
( NotificationConfigTypeWebhook,
NotificationTransportWebhook (WebhookTransport "u")
),
( NotificationConfigTypeMicrosoftTeams,
NotificationTransportMicrosoftTeams (MicrosoftTeamsTransport "u")
),
( NotificationConfigTypeSns,
NotificationTransportSns (SnsTransport "arn" Nothing)
),
( NotificationConfigTypeSesAccount,
NotificationTransportSesAccount
(SesAccountTransport "us-east-1" "arn:role" "from@x.com")
),
( NotificationConfigTypeSmtpAccount,
NotificationTransportSmtpAccount
(SmtpAccountTransport "smtp.x" 587 SmtpMethodStartTls "from@x.com")
),
( NotificationConfigTypeEmailGroup,
NotificationTransportEmailGroup
(EmailGroupTransport (EmailRecipient "a@b" :| []))
),
( NotificationConfigTypeEmail,
NotificationTransportEmail
( EmailTransport
{ emailTransportEmailAccountId = "smtp-1",
emailTransportRecipientList = EmailRecipient "x@y" :| [],
emailTransportEmailGroupIdList = Nothing
}
)
)
]
forM_ allTransports $ \(expectedType, transport) ->
it ("transportConfigType returns " <> show expectedType <> " for its variant") $
transportConfigType transport `shouldBe` expectedType
-- =======================================================================
-- NotificationConfig
-- =======================================================================
describe "NotificationConfig JSON" $ do
let slackConfig =
NotificationConfig
{ notificationConfigName = "Sample Slack Channel",
notificationConfigDescription = Just "This is a Slack channel",
notificationConfigIsEnabled = Just True,
notificationConfigTransport =
NotificationTransportSlack (SlackTransport "https://sample-slack-webhook")
}
it "decodes the documented Slack sample body" $ do
let Just decoded = decode sampleSlackConfigJson :: Maybe NotificationConfig
notificationConfigName decoded `shouldBe` "Sample Slack Channel"
notificationConfigDescription decoded `shouldBe` Just "This is a Slack channel"
notificationConfigIsEnabled decoded `shouldBe` Just True
it "round-trips the documented Slack sample body" $ do
let Just decoded = decode sampleSlackConfigJson :: Maybe NotificationConfig
(decode . encode) decoded `shouldBe` Just decoded
it "encodes config_type derived from the transport variant" $ do
let bs = encode slackConfig
LBS.toStrict bs `shouldSatisfy` BS.isInfixOf "\"config_type\":\"slack\""
it "encodes exactly one transport key matching config_type" $ do
let bs = LBS.toStrict (encode slackConfig)
BS.isInfixOf "\"slack\":" bs `shouldBe` True
BS.isInfixOf "\"sns\":" bs `shouldBe` False
BS.isInfixOf "\"email\":" bs `shouldBe` False
it "decodes an SNS body without role_arn (optional field)" $ do
let Just (decoded :: NotificationConfig) = decode sampleSnsConfigNoRoleJson
case notificationConfigTransport decoded of
NotificationTransportSns sns -> snsTransportRoleArn sns `shouldBe` Nothing
_ -> expectationFailure "expected SNS transport"
it "round-trips with all optional fields as Nothing" $ do
let cfg =
NotificationConfig
{ notificationConfigName = "n",
notificationConfigDescription = Nothing,
notificationConfigIsEnabled = Nothing,
notificationConfigTransport =
NotificationTransportSlack (SlackTransport "u")
}
(decode . encode) cfg `shouldBe` Just cfg
it "fails when config_type disagrees with the transport key" $ do
-- Body claims slack but only provides a chime block. The decoder
-- looks up the slack key on a slack config_type; finding nothing
-- parse-fails.
let bad =
"{\
\ \"name\": \"x\",\
\ \"config_type\": \"slack\",\
\ \"chime\": {\"url\": \"https://c\"}\
\}"
decode bad `shouldBe` (Nothing :: Maybe NotificationConfig)
it "fails when the transport key is absent entirely" $ do
let bad =
"{\
\ \"name\": \"x\",\
\ \"config_type\": \"slack\"\
\}"
decode bad `shouldBe` (Nothing :: Maybe NotificationConfig)
-- Parametric coverage: round-trip a NotificationConfig built from
-- each of the 9 transport variants. This exercises both ToJSON
-- (transport key + derived config_type) and FromJSON (config_type
-- dispatch + transport-key lookup) for every variant — catches
-- typos in either direction.
let mkConfig t =
NotificationConfig
{ notificationConfigName = "n",
notificationConfigDescription = Nothing,
notificationConfigIsEnabled = Nothing,
notificationConfigTransport = t
}
variants =
[ ( "slack",
NotificationTransportSlack (SlackTransport "u"),
"\"config_type\":\"slack\"",
"\"slack\":{\"url\":\"u\"}"
),
( "chime",
NotificationTransportChime (ChimeTransport "u"),
"\"config_type\":\"chime\"",
"\"chime\":{\"url\":\"u\"}"
),
( "webhook",
NotificationTransportWebhook (WebhookTransport "u"),
"\"config_type\":\"webhook\"",
"\"webhook\":{\"url\":\"u\"}"
),
( "microsoft_teams",
NotificationTransportMicrosoftTeams (MicrosoftTeamsTransport "u"),
"\"config_type\":\"microsoft_teams\"",
"\"microsoft_teams\":{\"url\":\"u\"}"
),
( "sns",
NotificationTransportSns (SnsTransport "arn" Nothing),
"\"config_type\":\"sns\"",
"\"sns\":{\"topic_arn\":\"arn\"}"
),
( "ses_account",
NotificationTransportSesAccount
(SesAccountTransport "us-east-1" "arn:role" "from@x.com"),
"\"config_type\":\"ses_account\"",
"\"ses_account\":"
),
( "smtp_account",
NotificationTransportSmtpAccount
(SmtpAccountTransport "smtp.x" 587 SmtpMethodStartTls "from@x.com"),
"\"config_type\":\"smtp_account\"",
"\"smtp_account\":"
),
( "email_group",
NotificationTransportEmailGroup
(EmailGroupTransport (EmailRecipient "a@b" :| [])),
"\"config_type\":\"email_group\"",
"\"email_group\":"
),
( "email",
NotificationTransportEmail
( EmailTransport
{ emailTransportEmailAccountId = "smtp-1",
emailTransportRecipientList = EmailRecipient "x@y" :| [],
emailTransportEmailGroupIdList = Nothing
}
),
"\"config_type\":\"email\"",
"\"email\":"
)
]
forM_ variants $ \(label, transport, expectedTypeSubstr, expectedTransportSubstr) -> do
it ("round-trips a NotificationConfig with " <> label <> " transport") $ do
let cfg = mkConfig transport
(decode . encode) cfg `shouldBe` Just cfg
it ("encodes " <> label <> " config_type derived from the transport") $ do
let bs = LBS.toStrict (encode (mkConfig transport))
bs `shouldSatisfy` BS.isInfixOf expectedTypeSubstr
it ("encodes " <> label <> " under the matching transport key") $ do
let bs = LBS.toStrict (encode (mkConfig transport))
bs `shouldSatisfy` BS.isInfixOf expectedTransportSubstr
-- =======================================================================
-- CreateNotificationConfigRequest
-- =======================================================================
describe "CreateNotificationConfigRequest JSON" $ do
let slackConfig =
NotificationConfig
{ notificationConfigName = "Sample Slack Channel",
notificationConfigDescription = Just "desc",
notificationConfigIsEnabled = Just True,
notificationConfigTransport =
NotificationTransportSlack (SlackTransport "https://x")
}
it "round-trips with a caller-supplied config_id" $ do
let req = CreateNotificationConfigRequest (Just "sample-id") slackConfig
(decode . encode) req `shouldBe` Just req
it "round-trips without a config_id (server will assign)" $ do
let req = CreateNotificationConfigRequest Nothing slackConfig
(decode . encode) req `shouldBe` Just req
it "encodes both config_id and config when both are present" $ do
let req = CreateNotificationConfigRequest (Just "abc") slackConfig
bs = LBS.toStrict (encode req)
BS.isInfixOf "\"config_id\":\"abc\"" bs `shouldBe` True
BS.isInfixOf "\"config\":" bs `shouldBe` True
it "omits config_id when Nothing" $ do
let req = CreateNotificationConfigRequest Nothing slackConfig
bs = LBS.toStrict (encode req)
BS.isInfixOf "config_id" bs `shouldBe` False
it "decodes the documented sample body" $ do
let Just (decoded :: CreateNotificationConfigRequest) = decode sampleCreateRequestJson
createNotificationConfigRequestId decoded `shouldBe` Just "sample-id"
it "ignores the docs' alternative 'id' field name (config_id is the documented one)" $ do
-- The plugin docs inconsistently show "id" in one example instead of
-- the documented "config_id". We read only "config_id" and ignore
-- the alternative, so the body parses to a request with
-- config_id=Nothing rather than a wire-shape error. This is the
-- silent-ignore path; an explicit parse-fail would require extra
-- work for no real safety gain since the server is the ultimate
-- authority on what it accepts.
let body =
"{\
\ \"id\": \"sample-id\",\
\ \"config\": {\
\ \"name\": \"x\",\
\ \"config_type\": \"slack\",\
\ \"slack\": {\"url\": \"https://x\"}\
\ }\
\}"
Just (decoded :: CreateNotificationConfigRequest) = decode body
createNotificationConfigRequestId decoded `shouldBe` Nothing
-- =======================================================================
-- CreateNotificationConfigResponse
-- =======================================================================
describe "CreateNotificationConfigResponse JSON" $ do
it "decodes the documented {config_id:...} body" $ do
let Just (decoded :: CreateNotificationConfigResponse) = decode sampleCreateResponseJson
createNotificationConfigResponseConfigId decoded `shouldBe` "abc-123"
it "round-trips" $ do
let r = CreateNotificationConfigResponse "xyz"
(decode . encode) r `shouldBe` Just r
it "fails when config_id is missing" $
decode "{}" `shouldBe` (Nothing :: Maybe CreateNotificationConfigResponse)
-- =======================================================================
-- UpdateNotificationConfigRequest (PUT /_plugins/_notifications/configs/{id})
-- =======================================================================
describe "UpdateNotificationConfigRequest JSON" $ do
let slackConfig =
NotificationConfig
{ notificationConfigName = "Sample Slack Channel",
notificationConfigDescription = Just "desc",
notificationConfigIsEnabled = Just True,
notificationConfigTransport =
NotificationTransportSlack (SlackTransport "https://x")
}
it "round-trips a wrapped config" $ do
let req = UpdateNotificationConfigRequest slackConfig
(decode . encode) req `shouldBe` Just req
it "encodes only the config key (no top-level config_id)" $ do
let req = UpdateNotificationConfigRequest slackConfig
bs = LBS.toStrict (encode req)
-- The update body identity is the URL path, not a body field;
-- a buggy encoder that emitted config_id would mislead callers
-- into thinking the update is idempotent on the body alone.
BS.isInfixOf "\"config\":" bs `shouldBe` True
BS.isInfixOf "\"config_id\"" bs `shouldBe` False
it "decodes the documented PUT body verbatim" $ do
let body =
"{\
\ \"config\": {\
\ \"name\": \"Slack Channel\",\
\ \"description\": \"This is an updated channel configuration\",\
\ \"config_type\": \"slack\",\
\ \"is_enabled\": true,\
\ \"slack\": {\"url\": \"https://hooks.slack.com/sample-url\"}\
\ }\
\}"
Just (decoded :: UpdateNotificationConfigRequest) = decode body
notificationConfigName (updateNotificationConfigRequestConfig decoded)
`shouldBe` "Slack Channel"
it "fails when the config key is absent" $
decode "{}" `shouldBe` (Nothing :: Maybe UpdateNotificationConfigRequest)
-- =======================================================================
-- Endpoint shape
-- =======================================================================
describe "createNotificationConfig endpoint shape (OpenSearch 3)" $ do
let cfg =
NotificationConfig
{ notificationConfigName = "n",
notificationConfigDescription = Nothing,
notificationConfigIsEnabled = Nothing,
notificationConfigTransport = NotificationTransportSlack (SlackTransport "u")
}
it "POSTs to /_plugins/_notifications/configs" $ do
let req = OS3Requests.createNotificationConfig cfg
getRawEndpoint (bhRequestEndpoint req)
`shouldBe` ["_plugins", "_notifications", "configs"]
getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []
it "uses the POST method" $ do
let req = OS3Requests.createNotificationConfig cfg
bhRequestMethod req `shouldBe` "POST"
it "attaches a non-empty body" $ do
let req = OS3Requests.createNotificationConfig cfg
bhRequestBody req `shouldSatisfy` isJust
it "forwards the config_id when supplied via With" $ do
let req = OS3Requests.createNotificationConfigWith (Just "my-id") cfg
Just body = bhRequestBody req
LBS.toStrict body `shouldSatisfy` BS.isInfixOf "\"config_id\":\"my-id\""
it "omits config_id from the body when the plain variant is used" $ do
let req = OS3Requests.createNotificationConfig cfg
Just body = bhRequestBody req
LBS.toStrict body `shouldSatisfy` not . BS.isInfixOf "config_id"
-- =======================================================================
-- Cross-backend parity: OS2 and OS3 must agree on path/method/body.
-- (Notifications plugin does not exist on OS1.)
-- =======================================================================
describe "cross-backend parity (OS2 vs OS3)" $ do
let mkOS2 :: OS2Types.NotificationConfig
mkOS2 =
OS2Types.NotificationConfig
{ OS2Types.notificationConfigName = "n",
OS2Types.notificationConfigDescription = Nothing,
OS2Types.notificationConfigIsEnabled = Nothing,
OS2Types.notificationConfigTransport =
OS2Types.NotificationTransportSlack (OS2Types.SlackTransport "u")
}
mkOS3 :: NotificationConfig
mkOS3 =
NotificationConfig
{ notificationConfigName = "n",
notificationConfigDescription = Nothing,
notificationConfigIsEnabled = Nothing,
notificationConfigTransport =
NotificationTransportSlack (SlackTransport "u")
}
it "createNotificationConfig emits identical paths across OS2/OS3" $ do
let p2 = getRawEndpoint (bhRequestEndpoint (OS2Requests.createNotificationConfig mkOS2))
p3 = getRawEndpoint (bhRequestEndpoint (OS3Requests.createNotificationConfig mkOS3))
p2 `shouldBe` ["_plugins", "_notifications", "configs"]
p3 `shouldBe` p2
it "createNotificationConfig emits identical methods across OS2/OS3" $ do
let methods =
[ bhRequestMethod (OS2Requests.createNotificationConfig mkOS2),
bhRequestMethod (OS3Requests.createNotificationConfig mkOS3)
]
length (L.nub methods) `shouldBe` 1
head methods `shouldBe` "POST"
it "createNotificationConfig emits identical bodies across OS2/OS3" $ do
let bodies =
catMaybes
[ bhRequestBody (OS2Requests.createNotificationConfig mkOS2),
bhRequestBody (OS3Requests.createNotificationConfig mkOS3)
]
length bodies `shouldBe` 2
length (L.nub (L.map LBS.toStrict bodies)) `shouldBe` 1
it "createNotificationConfigWith emits identical bodies across OS2/OS3" $ do
let bodies =
catMaybes
[ bhRequestBody (OS2Requests.createNotificationConfigWith (Just "id") mkOS2),
bhRequestBody (OS3Requests.createNotificationConfigWith (Just "id") mkOS3)
]
length bodies `shouldBe` 2
length (L.nub (L.map LBS.toStrict bodies)) `shouldBe` 1
-- =======================================================================
-- DeleteNotificationConfigResponse (DELETE /_plugins/_notifications/configs/{id})
-- =======================================================================
describe "DeleteNotificationConfigResponse JSON" $ do
it "decodes the documented single-id OK response" $ do
let decoded =
decode
"{\"delete_response_list\":{\"sample-id\":\"OK\"}}" ::
Maybe DeleteNotificationConfigResponse
deleteResponseList <$> decoded `shouldBe` Just (Map.singleton "sample-id" "OK")
it "encodes back to the documented wire shape" $ do
let resp =
DeleteNotificationConfigResponse
{ deleteResponseList = Map.singleton "sample-id" "OK"
}
encode resp
`shouldBe` "{\"delete_response_list\":{\"sample-id\":\"OK\"}}"
it "round-trips through ToJSON/FromJSON" $
property $ \(resp :: DeleteNotificationConfigResponse) ->
(decode . encode) resp === Just resp
it "decodes an empty delete_response_list" $ do
let decoded = decode "{\"delete_response_list\":{}}" :: Maybe DeleteNotificationConfigResponse
deleteResponseList <$> decoded `shouldBe` Just Map.empty
describe "deleteResponseStatus" $ do
it "returns the status for a present config_id" $ do
let resp =
DeleteNotificationConfigResponse
{ deleteResponseList = Map.fromList [("a", "OK"), ("b", "FAIL")]
}
deleteResponseStatus "a" resp `shouldBe` Just "OK"
deleteResponseStatus "b" resp `shouldBe` Just "FAIL"
it "returns Nothing for an absent config_id" $ do
let resp = DeleteNotificationConfigResponse {deleteResponseList = Map.singleton "a" "OK"}
deleteResponseStatus "nope" resp `shouldBe` Nothing
describe "deleteResponseAllOK" $ do
it "is True when every entry is OK" $ do
let resp =
DeleteNotificationConfigResponse
{ deleteResponseList = Map.fromList [("a", "OK"), ("b", "OK")]
}
deleteResponseAllOK resp `shouldBe` True
it "is False when any entry is not OK" $ do
let resp =
DeleteNotificationConfigResponse
{ deleteResponseList = Map.fromList [("a", "OK"), ("b", "FAIL")]
}
deleteResponseAllOK resp `shouldBe` False
it "is True for the empty response" $
deleteResponseAllOK (DeleteNotificationConfigResponse Map.empty) `shouldBe` True
-- =======================================================================
-- deleteNotificationConfig endpoint shape (pure, no live backend).
-- Mirrors the create-endpoint shape tests above; asserts the DELETE
-- method, the config_id-bearing path, no body, and OS2/OS3 parity.
-- =======================================================================
describe "deleteNotificationConfig endpoint shape (OpenSearch 3)" $ do
it "DELETEs /_plugins/_notifications/configs/{config_id}" $ do
let req = OS3Requests.deleteNotificationConfig "abc-123"
bhRequestMethod req `shouldBe` "DELETE"
getRawEndpoint (bhRequestEndpoint req)
`shouldBe` ["_plugins", "_notifications", "configs", "abc-123"]
getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []
it "uses the config_id verbatim as the final path segment" $ do
let req = OS3Requests.deleteNotificationConfig "weird/id with spaces"
getRawEndpoint (bhRequestEndpoint req)
`shouldBe` ["_plugins", "_notifications", "configs", "weird/id with spaces"]
it "carries no body" $
bhRequestBody (OS3Requests.deleteNotificationConfig "x") `shouldBe` Nothing
describe "deleteNotificationConfig endpoint shape (OpenSearch 2)" $ do
it "DELETEs /_plugins/_notifications/configs/{config_id}" $ do
let req = OS2Requests.deleteNotificationConfig "abc-123"
bhRequestMethod req `shouldBe` "DELETE"
getRawEndpoint (bhRequestEndpoint req)
`shouldBe` ["_plugins", "_notifications", "configs", "abc-123"]
getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []
it "carries no body" $
bhRequestBody (OS2Requests.deleteNotificationConfig "x") `shouldBe` Nothing
describe "deleteNotificationConfig cross-backend parity (OS2 vs OS3)" $ do
let cfgId = "shared-id"
it "emits identical paths across OS2/OS3" $ do
let p2 = getRawEndpoint (bhRequestEndpoint (OS2Requests.deleteNotificationConfig cfgId))
p3 = getRawEndpoint (bhRequestEndpoint (OS3Requests.deleteNotificationConfig cfgId))
p2 `shouldBe` ["_plugins", "_notifications", "configs", cfgId]
p3 `shouldBe` p2
it "emits identical methods across OS2/OS3" $ do
let m2 = bhRequestMethod (OS2Requests.deleteNotificationConfig cfgId)
m3 = bhRequestMethod (OS3Requests.deleteNotificationConfig cfgId)
m2 `shouldBe` "DELETE"
m3 `shouldBe` m2
it "emits identical (absent) bodies across OS2/OS3" $ do
let b2 = bhRequestBody (OS2Requests.deleteNotificationConfig cfgId)
b3 = bhRequestBody (OS3Requests.deleteNotificationConfig cfgId)
b2 `shouldBe` Nothing
b3 `shouldBe` b2
-- =======================================================================
-- getNotificationConfigs / NotificationConfigEntry / GetNotificationConfigsResponse
-- (GET /_plugins/_notifications/configs)
-- =======================================================================
describe "NotificationConfigEntry JSON" $ do
it "decodes the documented sample entry verbatim" $ do
let entryJson =
LBS.pack
"{\
\ \"config_id\": \"sample-id\",\
\ \"last_updated_time_ms\": 1652760532774,\
\ \"created_time_ms\": 1652760532774,\
\ \"config\": {\
\ \"name\": \"Sample Slack Channel\",\
\ \"description\": \"This is a Slack channel\",\
\ \"config_type\": \"slack\",\
\ \"is_enabled\": true,\
\ \"slack\": {\"url\": \"https://sample-slack-webhook\"}\
\ }\
\}"
let Just decoded = decode entryJson :: Maybe NotificationConfigEntry
notificationConfigEntryConfigId decoded `shouldBe` "sample-id"
notificationConfigEntryCreatedTimeMs decoded `shouldBe` 1652760532774
notificationConfigEntryLastUpdatedTimeMs decoded `shouldBe` 1652760532774
notificationConfigName (notificationConfigEntryConfig decoded)
`shouldBe` "Sample Slack Channel"
it "round-trips through ToJSON/FromJSON" $ do
let entry =
NotificationConfigEntry
{ notificationConfigEntryConfigId = "abc",
notificationConfigEntryCreatedTimeMs = 1700000000000,
notificationConfigEntryLastUpdatedTimeMs = 1700000000001,
notificationConfigEntryConfig =
NotificationConfig
{ notificationConfigName = "n",
notificationConfigDescription = Nothing,
notificationConfigIsEnabled = Nothing,
notificationConfigTransport =
NotificationTransportSlack (SlackTransport "u")
}
}
(decode . encode) entry `shouldBe` Just (entry :: NotificationConfigEntry)
describe "GetNotificationConfigsResponse JSON" $ do
it "decodes the documented empty-list response" $ do
let Just decoded = decode "{\"start_index\":0,\"total_hits\":0,\"total_hit_relation\":\"eq\",\"config_list\":[]}" :: Maybe GetNotificationConfigsResponse
getNotificationConfigsResponseStartIndex decoded `shouldBe` 0
getNotificationConfigsResponseTotalHits decoded `shouldBe` 0
getNotificationConfigsResponseTotalHitRelation decoded `shouldBe` NotificationTotalRelationEq
getNotificationConfigsResponseConfigList decoded `shouldBe` []
it "decodes gte relation" $ do
let Just decoded = decode "{\"start_index\":0,\"total_hits\":5,\"total_hit_relation\":\"gte\",\"config_list\":[]}" :: Maybe GetNotificationConfigsResponse
getNotificationConfigsResponseTotalHitRelation decoded `shouldBe` NotificationTotalRelationGte
it "round-trips an unknown relation value via Other" $ do
let resp =
GetNotificationConfigsResponse
{ getNotificationConfigsResponseStartIndex = 0,
getNotificationConfigsResponseTotalHits = 0,
getNotificationConfigsResponseTotalHitRelation = NotificationTotalRelationOther "future",
getNotificationConfigsResponseConfigList = []
}
(decode . encode) resp `shouldBe` Just (resp :: GetNotificationConfigsResponse)
it "round-trips through ToJSON/FromJSON" $ do
let resp =
GetNotificationConfigsResponse
{ getNotificationConfigsResponseStartIndex = 0,
getNotificationConfigsResponseTotalHits = 1,
getNotificationConfigsResponseTotalHitRelation = NotificationTotalRelationEq,
getNotificationConfigsResponseConfigList = []
}
(decode . encode) resp `shouldBe` Just (resp :: GetNotificationConfigsResponse)
describe "notificationListOptionsParams" $ do
it "defaultNotificationListOptions renders no params" $
notificationListOptionsParams defaultNotificationListOptions `shouldBe` []
it "renders from_index and max_items as decimal strings" $ do
let opts =
defaultNotificationListOptions
{ notificationListOptionsFromIndex = Just 10,
notificationListOptionsMaxItems = Just 25
}
notificationListOptionsParams opts
`shouldBe` [("from_index", Just "10"), ("max_items", Just "25")]
it "renders sort_order via notificationSortOrderText" $ do
let opts =
defaultNotificationListOptions
{ notificationListOptionsSortOrder = Just NotificationSortOrderDesc
}
notificationListOptionsParams opts
`shouldBe` [("sort_order", Just "desc")]
it "renders config_type via notificationConfigTypeText" $ do
let opts =
defaultNotificationListOptions
{ notificationListOptionsConfigType = Just NotificationConfigTypeChime
}
notificationListOptionsParams opts
`shouldBe` [("config_type", Just "chime")]
it "renders config_id verbatim" $ do
let opts = defaultNotificationListOptions {notificationListOptionsConfigId = Just "abc"}
notificationListOptionsParams opts `shouldBe` [("config_id", Just "abc")]
it "renders config_id_list as a comma-separated string" $ do
let opts =
defaultNotificationListOptions
{ notificationListOptionsConfigIdList = Just ["a", "b", "c"]
}
notificationListOptionsParams opts
`shouldBe` [("config_id_list", Just "a,b,c")]
it "renders is_enabled as lowercase true/false" $ do
let trueOpts = defaultNotificationListOptions {notificationListOptionsIsEnabled = Just True}
falseOpts = defaultNotificationListOptions {notificationListOptionsIsEnabled = Just False}
notificationListOptionsParams trueOpts `shouldBe` [("is_enabled", Just "true")]
notificationListOptionsParams falseOpts `shouldBe` [("is_enabled", Just "false")]
it "omits Nothing fields rather than rendering empty values" $
notificationListOptionsParams defaultNotificationListOptions {notificationListOptionsSortField = Just "name"}
`shouldBe` [("sort_field", Just "name")]
describe "getNotificationConfigs endpoint shape (OpenSearch 3)" $ do
it "GETs /_plugins/_notifications/configs with no query by default" $ do
let req = OS3Requests.getNotificationConfigs
bhRequestMethod req `shouldBe` "GET"
getRawEndpoint (bhRequestEndpoint req)
`shouldBe` ["_plugins", "_notifications", "configs"]
getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []
it "forwards options as query parameters via the With variant" $ do
let opts = defaultNotificationListOptions {notificationListOptionsMaxItems = Just 5}
req = OS3Requests.getNotificationConfigsWith opts
getRawEndpointQueries (bhRequestEndpoint req)
`shouldBe` [("max_items", Just "5")]
it "carries no body" $
bhRequestBody OS3Requests.getNotificationConfigs `shouldBe` Nothing
describe "getNotificationConfigs endpoint shape (OpenSearch 2)" $ do
it "GETs /_plugins/_notifications/configs with no query by default" $ do
let req = OS2Requests.getNotificationConfigs
bhRequestMethod req `shouldBe` "GET"
getRawEndpoint (bhRequestEndpoint req)
`shouldBe` ["_plugins", "_notifications", "configs"]
getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []
it "forwards options as query parameters via the With variant" $ do
let opts =
OS2Types.defaultNotificationListOptions
{ OS2Types.notificationListOptionsConfigType = Just OS2Types.NotificationConfigTypeSns
}
req = OS2Requests.getNotificationConfigsWith opts
getRawEndpointQueries (bhRequestEndpoint req)
`shouldBe` [("config_type", Just "sns")]
describe "getNotificationConfigs cross-backend parity (OS2 vs OS3)" $ do
it "emits identical paths and queries for the default options" $ do
let r2 = OS2Requests.getNotificationConfigs
r3 = OS3Requests.getNotificationConfigs
getRawEndpoint (bhRequestEndpoint r2) `shouldBe` getRawEndpoint (bhRequestEndpoint r3)
getRawEndpointQueries (bhRequestEndpoint r2) `shouldBe` getRawEndpointQueries (bhRequestEndpoint r3)
bhRequestMethod r2 `shouldBe` bhRequestMethod r3
it "emits identical queries for a populated options record" $ do
let opts2 =
OS2Types.defaultNotificationListOptions
{ OS2Types.notificationListOptionsFromIndex = Just 1,
OS2Types.notificationListOptionsMaxItems = Just 50,
OS2Types.notificationListOptionsSortOrder = Just OS2Types.NotificationSortOrderAsc,
OS2Types.notificationListOptionsConfigType = Just OS2Types.NotificationConfigTypeEmail
}
opts3 =
defaultNotificationListOptions
{ notificationListOptionsFromIndex = Just 1,
notificationListOptionsMaxItems = Just 50,
notificationListOptionsSortOrder = Just NotificationSortOrderAsc,
notificationListOptionsConfigType = Just NotificationConfigTypeEmail
}
r2 = OS2Requests.getNotificationConfigsWith opts2
r3 = OS3Requests.getNotificationConfigsWith opts3
getRawEndpointQueries (bhRequestEndpoint r2)
`shouldBe` getRawEndpointQueries (bhRequestEndpoint r3)
-- =======================================================================
-- getNotificationChannels / Channel / GetNotificationChannelsResponse
-- (GET /_plugins/_notifications/channels)
-- =======================================================================
describe "Channel JSON" $ do
it "decodes the documented sample channel verbatim" $ do
let Just decoded =
decode
"{\
\ \"config_id\": \"sample-id\",\
\ \"name\": \"Sample Slack Channel\",\
\ \"description\": \"This is a Slack channel\",\
\ \"config_type\": \"slack\",\
\ \"is_enabled\": true\
\}" ::
Maybe Channel
channelConfigId decoded `shouldBe` "sample-id"
channelName decoded `shouldBe` "Sample Slack Channel"
channelDescription decoded `shouldBe` Just "This is a Slack channel"
channelConfigType decoded `shouldBe` NotificationConfigTypeSlack
channelIsEnabled decoded `shouldBe` True
it "decodes a channel with an omitted description" $ do
let Just decoded =
decode
"{\
\ \"config_id\": \"x\",\
\ \"name\": \"n\",\
\ \"config_type\": \"chime\",\
\ \"is_enabled\": false\
\}" ::
Maybe Channel
channelDescription decoded `shouldBe` Nothing
channelConfigType decoded `shouldBe` NotificationConfigTypeChime
channelIsEnabled decoded `shouldBe` False
it "round-trips through ToJSON/FromJSON" $ do
let ch =
Channel
{ channelConfigId = "abc",
channelName = "n",
channelDescription = Just "d",
channelConfigType = NotificationConfigTypeWebhook,
channelIsEnabled = True
}
(decode . encode) ch `shouldBe` Just (ch :: Channel)
it "omits the description field on encode when Nothing" $ do
let ch =
Channel
{ channelConfigId = "abc",
channelName = "n",
channelDescription = Nothing,
channelConfigType = NotificationConfigTypeWebhook,
channelIsEnabled = True
}
encode ch `shouldSatisfy` not . BS.isInfixOf "\"description\"" . LBS.toStrict
describe "GetNotificationChannelsResponse JSON" $ do
it "decodes the documented empty-list response" $ do
let Just decoded =
decode
"{\"start_index\":0,\"total_hits\":0,\"total_hit_relation\":\"eq\",\"channel_list\":[]}" ::
Maybe GetNotificationChannelsResponse
getNotificationChannelsResponseStartIndex decoded `shouldBe` 0
getNotificationChannelsResponseTotalHits decoded `shouldBe` 0
getNotificationChannelsResponseTotalHitRelation decoded `shouldBe` NotificationTotalRelationEq
getNotificationChannelsResponseChannelList decoded `shouldBe` []
it "decodes the documented two-channel response" $ do
let Just decoded =
decode
"{\
\ \"start_index\": 0,\
\ \"total_hits\": 2,\
\ \"total_hit_relation\": \"eq\",\
\ \"channel_list\": [\
\ {\
\ \"config_id\": \"sample-id\",\
\ \"name\": \"Sample Slack Channel\",\
\ \"description\": \"This is a Slack channel\",\
\ \"config_type\": \"slack\",\
\ \"is_enabled\": true\
\ },\
\ {\
\ \"config_id\": \"sample-id2\",\
\ \"name\": \"Test chime channel\",\
\ \"description\": \"A test chime channel\",\
\ \"config_type\": \"chime\",\
\ \"is_enabled\": true\
\ }\
\ ]\
\}" ::
Maybe GetNotificationChannelsResponse
let channels = getNotificationChannelsResponseChannelList decoded
length channels `shouldBe` 2
getNotificationChannelsResponseTotalHits decoded `shouldBe` 2
-- Per-field assertions on each channel so a regression in
-- individual field parsing surfaces here, not just a count
-- mismatch.
let [c1, c2] = channels
channelConfigId c1 `shouldBe` "sample-id"
channelName c1 `shouldBe` "Sample Slack Channel"
channelDescription c1 `shouldBe` Just "This is a Slack channel"
channelConfigType c1 `shouldBe` NotificationConfigTypeSlack
channelIsEnabled c1 `shouldBe` True
channelConfigId c2 `shouldBe` "sample-id2"
channelName c2 `shouldBe` "Test chime channel"
channelConfigType c2 `shouldBe` NotificationConfigTypeChime
channelIsEnabled c2 `shouldBe` True
describe "getNotificationChannels endpoint shape (OpenSearch 3)" $ do
it "GETs /_plugins/_notifications/channels with no query by default" $ do
let req = OS3Requests.getNotificationChannels
bhRequestMethod req `shouldBe` "GET"
getRawEndpoint (bhRequestEndpoint req)
`shouldBe` ["_plugins", "_notifications", "channels"]
getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []
it "forwards options as query parameters via the With variant" $ do
let opts = defaultNotificationListOptions {notificationListOptionsMaxItems = Just 3}
req = OS3Requests.getNotificationChannelsWith opts
getRawEndpointQueries (bhRequestEndpoint req)
`shouldBe` [("max_items", Just "3")]
it "carries no body" $
bhRequestBody OS3Requests.getNotificationChannels `shouldBe` Nothing
describe "getNotificationChannels endpoint shape (OpenSearch 2)" $ do
it "GETs /_plugins/_notifications/channels with no query by default" $ do
let req = OS2Requests.getNotificationChannels
bhRequestMethod req `shouldBe` "GET"
getRawEndpoint (bhRequestEndpoint req)
`shouldBe` ["_plugins", "_notifications", "channels"]
getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []
it "forwards options as query parameters via the With variant" $ do
let opts =
OS2Types.defaultNotificationListOptions
{ OS2Types.notificationListOptionsConfigType = Just OS2Types.NotificationConfigTypeSesAccount
}
req = OS2Requests.getNotificationChannelsWith opts
getRawEndpointQueries (bhRequestEndpoint req)
`shouldBe` [("config_type", Just "ses_account")]
describe "getNotificationChannels cross-backend parity (OS2 vs OS3)" $ do
it "emits identical paths and queries for the default options" $ do
let r2 = OS2Requests.getNotificationChannels
r3 = OS3Requests.getNotificationChannels
getRawEndpoint (bhRequestEndpoint r2) `shouldBe` getRawEndpoint (bhRequestEndpoint r3)
getRawEndpointQueries (bhRequestEndpoint r2) `shouldBe` getRawEndpointQueries (bhRequestEndpoint r3)
bhRequestMethod r2 `shouldBe` bhRequestMethod r3
it "emits identical queries for a populated options record" $ do
let opts2 =
OS2Types.defaultNotificationListOptions
{ OS2Types.notificationListOptionsSortOrder = Just OS2Types.NotificationSortOrderDesc,
OS2Types.notificationListOptionsSortField = Just "name"
}
opts3 =
defaultNotificationListOptions
{ notificationListOptionsSortOrder = Just NotificationSortOrderDesc,
notificationListOptionsSortField = Just "name"
}
r2 = OS2Requests.getNotificationChannelsWith opts2
r3 = OS3Requests.getNotificationChannelsWith opts3
getRawEndpointQueries (bhRequestEndpoint r2)
`shouldBe` getRawEndpointQueries (bhRequestEndpoint r3)
-- =======================================================================
-- NotificationFeaturesResponse (GET /_plugins/_notifications/features)
-- =======================================================================
describe "NotificationFeaturesResponse JSON" $ do
it "decodes the documented features body verbatim" $ do
let body =
"{\
\ \"allowed_config_type_list\": [\
\ \"slack\",\
\ \"chime\",\
\ \"webhook\",\
\ \"email\",\
\ \"sns\",\
\ \"ses_account\",\
\ \"smtp_account\",\
\ \"email_group\"\
\ ],\
\ \"plugin_features\": {\
\ \"tooltip_support\": \"true\"\
\ }\
\}"
Just (decoded :: NotificationFeaturesResponse) = decode body
notificationFeaturesResponseAllowedConfigTypeList decoded
`shouldBe` [ NotificationConfigTypeSlack,
NotificationConfigTypeChime,
NotificationConfigTypeWebhook,
NotificationConfigTypeEmail,
NotificationConfigTypeSns,
NotificationConfigTypeSesAccount,
NotificationConfigTypeSmtpAccount,
NotificationConfigTypeEmailGroup
]
notificationFeaturesResponsePluginFeatures decoded
`shouldBe` Map.singleton "tooltip_support" "true"
it "decodes when plugin_features is absent (defensive .!=)" $ do
-- The docs always show plugin_features, but the server may omit
-- it on a stripped-down build; we decode such a body to the empty
-- map rather than parse-failing.
let body =
"{\"allowed_config_type_list\": [\"slack\"]}"
Just (decoded :: NotificationFeaturesResponse) = decode body
notificationFeaturesResponsePluginFeatures decoded `shouldBe` Map.empty
it "fails when allowed_config_type_list is absent" $
decode "{}" `shouldBe` (Nothing :: Maybe NotificationFeaturesResponse)
it "fails on an unknown config_type inside the list" $ do
-- Reuses the NotificationConfigType fail-loud policy: a future
-- plugin release adding a tenth config_type surfaces as a
-- deliberate decode error rather than a silent drop.
let body =
"{\"allowed_config_type_list\": [\"slack\", \"future_transport\"]}"
decode body `shouldBe` (Nothing :: Maybe NotificationFeaturesResponse)
it "round-trips through ToJSON/FromJSON" $
property $ \(resp :: NotificationFeaturesResponse) ->
(decode . encode) resp === Just resp
it "round-trips an empty allowed_config_type_list (no omitNulls stripping)" $ do
-- Pins the choice of `object` (not `omitNulls`) for the
-- ToJSON instance: a response type must round-trip every value
-- it can construct, including the empty list. omitNulls would
-- strip the empty Array, breaking the round-trip; the
-- QuickCheck property above does not catch this because the
-- Arbitrary instance uses listOf1 (non-empty), so this explicit
-- case closes the gap.
let resp =
NotificationFeaturesResponse
{ notificationFeaturesResponseAllowedConfigTypeList = [],
notificationFeaturesResponsePluginFeatures = Map.empty
}
(decode . encode) resp `shouldBe` Just resp
-- =======================================================================
-- TestNotificationResponse + sub-types
-- (GET /_plugins/_notifications/feature/test/{config_id})
-- =======================================================================
describe "TestNotificationDeliveryStatus JSON" $ do
it "round-trips the documented shape" $ do
let s = TestNotificationDeliveryStatus "200" "<html>ok</html>"
(decode . encode) s `shouldBe` Just s
it "requires status_code and status_text" $
decode "{\"status_code\":\"200\"}"
`shouldBe` (Nothing :: Maybe TestNotificationDeliveryStatus)
describe "TestNotificationStatus JSON" $ do
it "decodes a slack status entry verbatim" $ do
let body =
"{\
\ \"config_id\": \"abc\",\
\ \"config_type\": \"slack\",\
\ \"config_name\": \"sample-id\",\
\ \"email_recipient_status\": [],\
\ \"delivery_status\": {\
\ \"status_code\": \"200\",\
\ \"status_text\": \"delivered\"\
\ }\
\}"
Just (decoded :: TestNotificationStatus) = decode body
testNotificationStatusConfigId decoded `shouldBe` "abc"
testNotificationStatusConfigType decoded `shouldBe` NotificationConfigTypeSlack
testNotificationStatusConfigName decoded `shouldBe` "sample-id"
testNotificationStatusEmailRecipientStatus decoded `shouldBe` []
testNotificationDeliveryStatusCode (testNotificationStatusDeliveryStatus decoded)
`shouldBe` "200"
it "decodes when email_recipient_status is absent (defensive .!=)" $ do
-- The docs only show email_recipient_status as the empty list;
-- a non-email channel (slack/chime/...) has no recipients and
-- the server may omit the field entirely.
let body =
"{\
\ \"config_id\": \"x\",\
\ \"config_type\": \"chime\",\
\ \"config_name\": \"n\",\
\ \"delivery_status\": {\"status_code\": \"200\", \"status_text\": \"\"}\
\}"
Just (decoded :: TestNotificationStatus) = decode body
testNotificationStatusEmailRecipientStatus decoded `shouldBe` []
it "fails when config_type is unknown" $ do
let body =
"{\
\ \"config_id\": \"x\",\
\ \"config_type\": \"future\",\
\ \"config_name\": \"n\",\
\ \"delivery_status\": {\"status_code\": \"200\", \"status_text\": \"\"}\
\}"
decode body `shouldBe` (Nothing :: Maybe TestNotificationStatus)
it "round-trips" $
property $ \(s :: TestNotificationStatus) ->
(decode . encode) s === Just s
describe "TestNotificationEventSource JSON" $ do
it "decodes the documented event_source verbatim" $ do
let body =
"{\
\ \"title\": \"Test Message Title-0Jnlh4ABa4TCWn5C5H2G\",\
\ \"reference_id\": \"0Jnlh4ABa4TCWn5C5H2G\",\
\ \"severity\": \"info\",\
\ \"tags\": []\
\}"
Just (decoded :: TestNotificationEventSource) = decode body
testNotificationEventSourceTitle decoded `shouldBe` "Test Message Title-0Jnlh4ABa4TCWn5C5H2G"
testNotificationEventSourceSeverity decoded `shouldBe` "info"
testNotificationEventSourceTags decoded `shouldBe` []
it "decodes when tags is absent (defensive .!= [])" $ do
let body =
"{\"title\":\"t\",\"reference_id\":\"r\",\"severity\":\"info\"}"
Just (decoded :: TestNotificationEventSource) = decode body
testNotificationEventSourceTags decoded `shouldBe` []
it "round-trips" $
property $ \(e :: TestNotificationEventSource) ->
(decode . encode) e === Just e
describe "TestNotificationResponse JSON" $ do
it "decodes the documented send-test body verbatim" $ do
let body =
"{\
\ \"event_source\": {\
\ \"title\": \"Test Message Title-0Jnlh4ABa4TCWn5C5H2G\",\
\ \"reference_id\": \"0Jnlh4ABa4TCWn5C5H2G\",\
\ \"severity\": \"info\",\
\ \"tags\": []\
\ },\
\ \"status_list\": [\
\ {\
\ \"config_id\": \"0Jnlh4ABa4TCWn5C5H2G\",\
\ \"config_type\": \"slack\",\
\ \"config_name\": \"sample-id\",\
\ \"email_recipient_status\": [],\
\ \"delivery_status\": {\
\ \"status_code\": \"200\",\
\ \"status_text\": \"delivered\"\
\ }\
\ }\
\ ]\
\}"
Just (decoded :: TestNotificationResponse) = decode body
testNotificationEventSourceSeverity (testNotificationResponseEventSource decoded)
`shouldBe` "info"
length (testNotificationResponseStatusList decoded) `shouldBe` 1
it "round-trips" $
property $ \(r :: TestNotificationResponse) ->
(decode . encode) r === Just r
-- =======================================================================
-- getNotificationConfig endpoint shape (GET configs/{config_id})
-- =======================================================================
describe "getNotificationConfig endpoint shape (OpenSearch 3)" $ do
it "GETs /_plugins/_notifications/configs/{config_id}" $ do
let req = OS3Requests.getNotificationConfig "abc-123"
bhRequestMethod req `shouldBe` "GET"
getRawEndpoint (bhRequestEndpoint req)
`shouldBe` ["_plugins", "_notifications", "configs", "abc-123"]
getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []
it "carries no body" $
bhRequestBody (OS3Requests.getNotificationConfig "x") `shouldBe` Nothing
describe "getNotificationConfig endpoint shape (OpenSearch 2)" $ do
it "GETs /_plugins/_notifications/configs/{config_id}" $ do
let req = OS2Requests.getNotificationConfig "abc-123"
bhRequestMethod req `shouldBe` "GET"
getRawEndpoint (bhRequestEndpoint req)
`shouldBe` ["_plugins", "_notifications", "configs", "abc-123"]
describe "getNotificationConfig cross-backend parity (OS2 vs OS3)" $ do
it "emits identical paths/methods/queries for the same id" $ do
let r2 = OS2Requests.getNotificationConfig "shared-id"
r3 = OS3Requests.getNotificationConfig "shared-id"
getRawEndpoint (bhRequestEndpoint r2) `shouldBe` getRawEndpoint (bhRequestEndpoint r3)
bhRequestMethod r2 `shouldBe` bhRequestMethod r3
getRawEndpointQueries (bhRequestEndpoint r2) `shouldBe` getRawEndpointQueries (bhRequestEndpoint r3)
-- =======================================================================
-- updateNotificationConfig endpoint shape (PUT configs/{config_id})
-- =======================================================================
describe "updateNotificationConfig endpoint shape (OpenSearch 3)" $ do
let cfg =
NotificationConfig
{ notificationConfigName = "n",
notificationConfigDescription = Nothing,
notificationConfigIsEnabled = Nothing,
notificationConfigTransport = NotificationTransportSlack (SlackTransport "u")
}
it "PUTs to /_plugins/_notifications/configs/{config_id}" $ do
let req = OS3Requests.updateNotificationConfig "abc-123" cfg
bhRequestMethod req `shouldBe` "PUT"
getRawEndpoint (bhRequestEndpoint req)
`shouldBe` ["_plugins", "_notifications", "configs", "abc-123"]
getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []
it "wraps the config under the \"config\" key (not flat like create)" $ do
let req = OS3Requests.updateNotificationConfig "id" cfg
body = maybe (error "updateNotificationConfig must carry a body") id (bhRequestBody req)
bs = LBS.toStrict body
BS.isInfixOf "\"config\":" bs `shouldBe` True
-- A create-shaped body would carry a top-level config_id; the
-- update endpoint must NOT (identity is the URL path segment).
BS.isInfixOf "\"config_id\"" bs `shouldBe` False
it "attaches a non-empty body" $
bhRequestBody (OS3Requests.updateNotificationConfig "x" cfg) `shouldSatisfy` isJust
describe "updateNotificationConfig endpoint shape (OpenSearch 2)" $ do
it "PUTs to /_plugins/_notifications/configs/{config_id}" $ do
let cfg =
OS2Types.NotificationConfig
{ OS2Types.notificationConfigName = "n",
OS2Types.notificationConfigDescription = Nothing,
OS2Types.notificationConfigIsEnabled = Nothing,
OS2Types.notificationConfigTransport =
OS2Types.NotificationTransportSlack (OS2Types.SlackTransport "u")
}
req = OS2Requests.updateNotificationConfig "abc-123" cfg
bhRequestMethod req `shouldBe` "PUT"
getRawEndpoint (bhRequestEndpoint req)
`shouldBe` ["_plugins", "_notifications", "configs", "abc-123"]
describe "updateNotificationConfig cross-backend parity (OS2 vs OS3)" $ do
it "emits identical paths/methods/bodies for the same id+config" $ do
let mkOS2 =
OS2Types.NotificationConfig
{ OS2Types.notificationConfigName = "n",
OS2Types.notificationConfigDescription = Nothing,
OS2Types.notificationConfigIsEnabled = Nothing,
OS2Types.notificationConfigTransport =
OS2Types.NotificationTransportSlack (OS2Types.SlackTransport "u")
}
mkOS3 =
NotificationConfig
{ notificationConfigName = "n",
notificationConfigDescription = Nothing,
notificationConfigIsEnabled = Nothing,
notificationConfigTransport = NotificationTransportSlack (SlackTransport "u")
}
r2 = OS2Requests.updateNotificationConfig "id" mkOS2
r3 = OS3Requests.updateNotificationConfig "id" mkOS3
getRawEndpoint (bhRequestEndpoint r2) `shouldBe` getRawEndpoint (bhRequestEndpoint r3)
bhRequestMethod r2 `shouldBe` bhRequestMethod r3
let bodies =
catMaybes
[ bhRequestBody r2,
bhRequestBody r3
]
length bodies `shouldBe` 2
length (L.nub (L.map LBS.toStrict bodies)) `shouldBe` 1
-- =======================================================================
-- deleteNotificationConfigs endpoint shape
-- (DELETE configs/?config_id_list=id1,id2,...)
-- =======================================================================
describe "deleteNotificationConfigs endpoint shape (OpenSearch 3)" $ do
it "DELETEs /_plugins/_notifications/configs with a config_id_list query" $ do
let req = OS3Requests.deleteNotificationConfigs ("a" :| ["b", "c"])
bhRequestMethod req `shouldBe` "DELETE"
getRawEndpoint (bhRequestEndpoint req)
`shouldBe` ["_plugins", "_notifications", "configs"]
getRawEndpointQueries (bhRequestEndpoint req)
`shouldBe` [("config_id_list", Just "a,b,c")]
it "serializes a single-id NonEmpty without trailing comma" $ do
let req = OS3Requests.deleteNotificationConfigs ("only" :| [])
getRawEndpointQueries (bhRequestEndpoint req)
`shouldBe` [("config_id_list", Just "only")]
it "carries no body" $
bhRequestBody (OS3Requests.deleteNotificationConfigs ("a" :| [])) `shouldBe` Nothing
describe "deleteNotificationConfigs endpoint shape (OpenSearch 2)" $ do
it "DELETEs /_plugins/_notifications/configs with a config_id_list query" $ do
let req = OS2Requests.deleteNotificationConfigs ("a" :| ["b"])
bhRequestMethod req `shouldBe` "DELETE"
getRawEndpoint (bhRequestEndpoint req)
`shouldBe` ["_plugins", "_notifications", "configs"]
getRawEndpointQueries (bhRequestEndpoint req)
`shouldBe` [("config_id_list", Just "a,b")]
describe "deleteNotificationConfigs cross-backend parity (OS2 vs OS3)" $ do
it "emits identical paths/methods/queries for the same id list" $ do
let ids = "x" :| ["y", "z"]
r2 = OS2Requests.deleteNotificationConfigs ids
r3 = OS3Requests.deleteNotificationConfigs ids
getRawEndpoint (bhRequestEndpoint r2) `shouldBe` getRawEndpoint (bhRequestEndpoint r3)
bhRequestMethod r2 `shouldBe` bhRequestMethod r3
getRawEndpointQueries (bhRequestEndpoint r2) `shouldBe` getRawEndpointQueries (bhRequestEndpoint r3)
-- =======================================================================
-- getNotificationFeatures endpoint shape
-- (GET /_plugins/_notifications/features)
-- =======================================================================
describe "getNotificationFeatures endpoint shape (OpenSearch 3)" $ do
it "GETs /_plugins/_notifications/features with no query" $ do
let req = OS3Requests.getNotificationFeatures
bhRequestMethod req `shouldBe` "GET"
getRawEndpoint (bhRequestEndpoint req)
`shouldBe` ["_plugins", "_notifications", "features"]
getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []
it "carries no body" $
bhRequestBody OS3Requests.getNotificationFeatures `shouldBe` Nothing
describe "getNotificationFeatures endpoint shape (OpenSearch 2)" $ do
it "GETs /_plugins/_notifications/features with no query" $ do
let req = OS2Requests.getNotificationFeatures
bhRequestMethod req `shouldBe` "GET"
getRawEndpoint (bhRequestEndpoint req)
`shouldBe` ["_plugins", "_notifications", "features"]
describe "getNotificationFeatures cross-backend parity (OS2 vs OS3)" $ do
it "emits identical paths/methods/queries" $ do
let r2 = OS2Requests.getNotificationFeatures
r3 = OS3Requests.getNotificationFeatures
getRawEndpoint (bhRequestEndpoint r2) `shouldBe` getRawEndpoint (bhRequestEndpoint r3)
bhRequestMethod r2 `shouldBe` bhRequestMethod r3
getRawEndpointQueries (bhRequestEndpoint r2) `shouldBe` getRawEndpointQueries (bhRequestEndpoint r3)
-- =======================================================================
-- sendTestNotification endpoint shape
-- (GET /_plugins/_notifications/feature/test/{config_id})
-- =======================================================================
describe "sendTestNotification endpoint shape (OpenSearch 3)" $ do
it "GETs /_plugins/_notifications/feature/test/{config_id}" $ do
let req = OS3Requests.sendTestNotification "abc-123"
bhRequestMethod req `shouldBe` "GET"
getRawEndpoint (bhRequestEndpoint req)
`shouldBe` ["_plugins", "_notifications", "feature", "test", "abc-123"]
getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []
it "carries no body" $
bhRequestBody (OS3Requests.sendTestNotification "x") `shouldBe` Nothing
describe "sendTestNotification endpoint shape (OpenSearch 2)" $ do
it "GETs /_plugins/_notifications/feature/test/{config_id}" $ do
let req = OS2Requests.sendTestNotification "abc-123"
bhRequestMethod req `shouldBe` "GET"
getRawEndpoint (bhRequestEndpoint req)
`shouldBe` ["_plugins", "_notifications", "feature", "test", "abc-123"]
describe "sendTestNotification cross-backend parity (OS2 vs OS3)" $ do
it "emits identical paths/methods/queries for the same id" $ do
let r2 = OS2Requests.sendTestNotification "shared-id"
r3 = OS3Requests.sendTestNotification "shared-id"
getRawEndpoint (bhRequestEndpoint r2) `shouldBe` getRawEndpoint (bhRequestEndpoint r3)
bhRequestMethod r2 `shouldBe` bhRequestMethod r3
getRawEndpointQueries (bhRequestEndpoint r2) `shouldBe` getRawEndpointQueries (bhRequestEndpoint r3)
-- =======================================================================
-- Live integration: create -> list configs -> list channels -> delete
-- round-trip, gated to OpenSearch 2 and 3 (the Notifications plugin
-- shipped in OS 2.0; no OS1 twin). Each backend has its own Client
-- module (twin types), so we write one test per backend, gated via
-- os2OnlyIT / os3OnlyIT. A timestamp-suffixed config_id avoids
-- collisions across concurrent or failed runs; bracket_ guarantees
-- the delete runs even on assertion failure.
--
-- The Notifications plugin persists configs to a system index
-- (@.opendistro-notifications-config@) whose default refresh interval
-- (~1s on OS2, shorter on OS3) means a freshly-created config is not
-- visible to GET immediately. 'waitFor' polls the listing until the
-- new config appears or a bounded timeout (5s) elapses.
-- =======================================================================
describe "Notifications plugin live round-trip" $ do
let webhookConfigOS3 =
NotificationConfig
{ notificationConfigName = "bloodhound test webhook",
notificationConfigDescription = Just "live round-trip fixture",
notificationConfigIsEnabled = Just True,
notificationConfigTransport =
NotificationTransportWebhook (WebhookTransport "https://example.invalid/hook")
}
webhookConfigOS2 =
OS2Types.NotificationConfig
{ OS2Types.notificationConfigName = "bloodhound test webhook",
OS2Types.notificationConfigDescription = Just "live round-trip fixture",
OS2Types.notificationConfigIsEnabled = Just True,
OS2Types.notificationConfigTransport =
OS2Types.NotificationTransportWebhook (OS2Types.WebhookTransport "https://example.invalid/hook")
}
os3It <- runIO os3OnlyIT
os3It "create -> list configs -> list channels -> delete (OpenSearch 3)" $
withTestEnv $ do
suffix <- liftIO $ show @Int . floor <$> getPOSIXTime
let configId = T.pack ("bloodhound_test_notif_os3_" <> suffix)
bracket_
( do
createResp <- OS3Client.createNotificationConfigWith (Just configId) webhookConfigOS3
liftIO $
case createResp of
Left e -> expectationFailure ("Setup createNotificationConfigWith failed: " <> show e)
Right _ -> pure ()
)
(void $ OS3Client.deleteNotificationConfig configId)
$ do
configsResult <-
waitFor $
do
configsResp <- OS3Client.getNotificationConfigs
pure $ case configsResp of
Right configs ->
Right (configId `elem` map notificationConfigEntryConfigId configs)
Left e -> Left e
liftIO $
case configsResult of
Right True -> pure ()
Right False ->
expectationFailure ("config_id " <> T.unpack configId <> " not found in getNotificationConfigs after polling")
Left e ->
expectationFailure ("getNotificationConfigs failed during polling: " <> show e)
channelsResult <-
waitFor $
do
channelsResp <- OS3Client.getNotificationChannels
pure $ case channelsResp of
Right channels ->
Right (configId `elem` map channelConfigId channels)
Left e -> Left e
liftIO $
case channelsResult of
Right True -> pure ()
Right False ->
expectationFailure ("config_id " <> T.unpack configId <> " not found in getNotificationChannels after polling")
Left e ->
expectationFailure ("getNotificationChannels failed during polling: " <> show e)
-- getNotificationConfig (single): the per-id GET must
-- surface the same entry the list-all just found.
getConfigResult <-
waitFor $
do
resp <- OS3Client.getNotificationConfig configId
pure $ case resp of
Right (Just entry)
| notificationConfigEntryConfigId entry == configId -> Right True
| otherwise -> Right False
Right Nothing -> Right False
Left e -> Left e
liftIO $
case getConfigResult of
Right True -> pure ()
Right False ->
expectationFailure ("getNotificationConfig did not return the config_id " <> T.unpack configId)
Left e ->
expectationFailure ("getNotificationConfig failed: " <> show e)
-- updateNotificationConfig: rename, then verify the new
-- name round-trips through getNotificationConfig.
let updatedConfig =
webhookConfigOS3
{ notificationConfigName = "bloodhound test webhook (renamed)"
}
updateResp <- OS3Client.updateNotificationConfig configId updatedConfig
liftIO $
case updateResp of
Left e ->
expectationFailure ("updateNotificationConfig failed: " <> show e)
Right _ -> pure ()
updatedNameResult <-
waitFor $
do
resp <- OS3Client.getNotificationConfig configId
pure $ case resp of
Right (Just entry) ->
Right (notificationConfigName (notificationConfigEntryConfig entry) == "bloodhound test webhook (renamed)")
Right Nothing -> Right False
Left e -> Left e
liftIO $
case updatedNameResult of
Right True -> pure ()
Right False ->
expectationFailure ("updateNotificationConfig rename did not round-trip through getNotificationConfig for " <> T.unpack configId)
Left e ->
expectationFailure ("getNotificationConfig after update failed: " <> show e)
-- getNotificationFeatures: capability discovery. The test
-- cluster accepts webhook configs (we just created one), so
-- NotificationConfigTypeWebhook must be in the allowed list.
featuresResp <- OS3Client.getNotificationFeatures
liftIO $
case featuresResp of
Left e ->
expectationFailure ("getNotificationFeatures failed: " <> show e)
Right feats ->
NotificationConfigTypeWebhook
`elem` notificationFeaturesResponseAllowedConfigTypeList feats
`shouldBe` True
-- sendTestNotification is deliberately NOT exercised here:
-- it triggers a real delivery attempt to the config's
-- transport (a POST to https://example.invalid/hook for
-- our fixture), which would fail with a DNS error and
-- pollute CI logs without proving anything the pure
-- endpoint-shape tests don't already cover.
os2It <- runIO os2OnlyIT
os2It "create -> list configs -> list channels -> delete (OpenSearch 2)" $
withTestEnv $ do
suffix <- liftIO $ show @Int . floor <$> getPOSIXTime
let configId = T.pack ("bloodhound_test_notif_os2_" <> suffix)
bracket_
( do
createResp <- OS2Client.createNotificationConfigWith (Just configId) webhookConfigOS2
liftIO $
case createResp of
Left e -> expectationFailure ("Setup createNotificationConfigWith failed: " <> show e)
Right _ -> pure ()
)
(void $ OS2Client.deleteNotificationConfig configId)
$ do
configsResult <-
waitFor $
do
configsResp <- OS2Client.getNotificationConfigs
pure $ case configsResp of
Right configs ->
Right (configId `elem` map OS2Types.notificationConfigEntryConfigId configs)
Left e -> Left e
liftIO $
case configsResult of
Right True -> pure ()
Right False ->
expectationFailure ("config_id " <> T.unpack configId <> " not found in getNotificationConfigs after polling")
Left e ->
expectationFailure ("getNotificationConfigs failed during polling: " <> show e)
channelsResult <-
waitFor $
do
channelsResp <- OS2Client.getNotificationChannels
pure $ case channelsResp of
Right channels ->
Right (configId `elem` map OS2Types.channelConfigId channels)
Left e -> Left e
liftIO $
case channelsResult of
Right True -> pure ()
Right False ->
expectationFailure ("config_id " <> T.unpack configId <> " not found in getNotificationChannels after polling")
Left e ->
expectationFailure ("getNotificationChannels failed during polling: " <> show e)
-- getNotificationConfig (single): the per-id GET must
-- surface the same entry the list-all just found.
getConfigResult <-
waitFor $
do
resp <- OS2Client.getNotificationConfig configId
pure $ case resp of
Right (Just entry)
| OS2Types.notificationConfigEntryConfigId entry == configId -> Right True
| otherwise -> Right False
Right Nothing -> Right False
Left e -> Left e
liftIO $
case getConfigResult of
Right True -> pure ()
Right False ->
expectationFailure ("getNotificationConfig did not return the config_id " <> T.unpack configId)
Left e ->
expectationFailure ("getNotificationConfig failed: " <> show e)
-- updateNotificationConfig: rename, then verify the new
-- name round-trips through getNotificationConfig.
let updatedConfig =
webhookConfigOS2
{ OS2Types.notificationConfigName = "bloodhound test webhook (renamed)"
}
updateResp <- OS2Client.updateNotificationConfig configId updatedConfig
liftIO $
case updateResp of
Left e ->
expectationFailure ("updateNotificationConfig failed: " <> show e)
Right _ -> pure ()
updatedNameResult <-
waitFor $
do
resp <- OS2Client.getNotificationConfig configId
pure $ case resp of
Right (Just entry) ->
Right
( OS2Types.notificationConfigName
(OS2Types.notificationConfigEntryConfig entry)
== "bloodhound test webhook (renamed)"
)
Right Nothing -> Right False
Left e -> Left e
liftIO $
case updatedNameResult of
Right True -> pure ()
Right False ->
expectationFailure ("updateNotificationConfig rename did not round-trip through getNotificationConfig for " <> T.unpack configId)
Left e ->
expectationFailure ("getNotificationConfig after update failed: " <> show e)
-- getNotificationFeatures: capability discovery. The test
-- cluster accepts webhook configs (we just created one), so
-- NotificationConfigTypeWebhook must be in the allowed list.
featuresResp <- OS2Client.getNotificationFeatures
liftIO $
case featuresResp of
Left e ->
expectationFailure ("getNotificationFeatures failed: " <> show e)
Right feats ->
OS2Types.NotificationConfigTypeWebhook
`elem` OS2Types.notificationFeaturesResponseAllowedConfigTypeList feats
`shouldBe` True
-- | Poll a query at 0.5s intervals until it returns @'Right' 'True'@ or
-- 10 attempts (5s total) elapse. Returns the last result so the caller
-- can fail with the actual server error rather than a misleading
-- \"not found\": a @'Left' e@ surfaces the 'EsError', a @'Right'
-- 'False'@ means the polling exhausted without the predicate ever
-- holding. Used to absorb the Notifications plugin's ~1-2s system-index
-- refresh delay on OS2.
waitFor :: (MonadIO m) => m (Either EsError Bool) -> m (Either EsError Bool)
waitFor check = go (0 :: Int)
where
go n = do
result <- check
case result of
Right True -> pure result
_
| n >= 9 -> pure result
| otherwise -> do
liftIO $ threadDelay 500000
go (n + 1)
-- QuickCheck instances for NotificationConfigType roundtrip property.
-- We don't derive Arbitrary generically because the enum is closed; we
-- pick from the explicit list.
instance Arbitrary NotificationConfigType where
arbitrary =
elements
[ NotificationConfigTypeSlack,
NotificationConfigTypeChime,
NotificationConfigTypeWebhook,
NotificationConfigTypeMicrosoftTeams,
NotificationConfigTypeSns,
NotificationConfigTypeSesAccount,
NotificationConfigTypeSmtpAccount,
NotificationConfigTypeEmailGroup,
NotificationConfigTypeEmail
]
shrink _ = []
-- Drives the DeleteNotificationConfigResponse round-trip property above.
-- Keys are short ASCII strings to keep the JSON readable on failure; the
-- plugin only documents @"OK"@ as a status value but the wire shape
-- permits any string, so the generator emits arbitrary status text to
-- exercise the parser's openness.
instance Arbitrary DeleteNotificationConfigResponse where
arbitrary =
DeleteNotificationConfigResponse . Map.fromList
<$> listOf
( (,)
<$> (T.pack <$> listOf1 (elements ['a' .. 'z']))
<*> (T.pack <$> listOf1 (elements ("OKFAILabc" :: String)))
)
shrink (DeleteNotificationConfigResponse m) =
[ DeleteNotificationConfigResponse (Map.fromList smaller)
| smaller <- shrinkList (const []) (Map.toList m),
not (null smaller)
]
-- Drives the NotificationFeaturesResponse round-trip property. The
-- allowed_config_type_list is a non-empty subset of the 9-variant enum
-- (the docs example lists 8; real clusters vary), so we pick a random
-- non-empty sublist rather than a fixed one. The plugin_features map
-- reuses the DeleteNotificationConfigResponse hand-rolled Map pattern
-- (short ASCII keys/values keep failure output readable).
instance Arbitrary NotificationFeaturesResponse where
arbitrary = do
types <- listOf1 arbitrary
feats <-
Map.fromList
<$> listOf
( (,)
<$> (T.pack <$> listOf1 (elements ['a' .. 'z']))
<*> (T.pack <$> listOf1 (elements ("truefalse01" :: String)))
)
pure
NotificationFeaturesResponse
{ notificationFeaturesResponseAllowedConfigTypeList = types,
notificationFeaturesResponsePluginFeatures = feats
}
shrink (NotificationFeaturesResponse types feats) =
[ NotificationFeaturesResponse types' feats
| types' <- shrinkList (const []) types,
not (null types')
]
++ [ NotificationFeaturesResponse types (Map.fromList smaller)
| smaller <- shrinkList (const []) (Map.toList feats)
]
-- Drives the TestNotificationDeliveryStatus round-trip property.
-- status_code is typed as Text (the plugin emits "200" not 200); we
-- emit short numeric-looking strings to mirror the wire shape.
instance Arbitrary TestNotificationDeliveryStatus where
arbitrary =
TestNotificationDeliveryStatus
<$> (T.pack <$> listOf1 (elements ("0123456789" :: String)))
<*> (T.pack <$> listOf (elements ('a' : ['A' .. 'Z'] ++ "<>/= ")))
-- Drives the TestNotificationStatus round-trip property. The
-- email_recipient_status field is opaque [Value]; the docs only ever
-- show it empty and the per-recipient object shape is under-documented,
-- so we generate the empty list rather than hand-roll a Value generator
-- (which would pull in quickcheck-instances for an Arbitrary Value).
-- No @shrink@ override: the property's job is to flag a round-trip
-- failure, and the inline Text generators (@listOf1 ...@) are simple
-- enough that a failure surfaces clearly without counterexample
-- minimisation. The non-empty @[Value]@ case is therefore unverified
-- by the property — the field is typed @[Value]@ for forward
-- compatibility but the parser's behaviour on a populated list is not
-- exercised here.
instance Arbitrary TestNotificationStatus where
arbitrary =
TestNotificationStatus
<$> (T.pack <$> listOf1 (elements ('a' : ['0' .. '9'])))
<*> arbitrary
<*> (T.pack <$> listOf1 (elements ['a' .. 'z']))
<*> pure []
<*> arbitrary
-- Drives the TestNotificationEventSource round-trip property. severity
-- is typed as Text (the plugin emits "info"/"warning"/"critical" but
-- the enum is not fully documented); we emit short alphabetic strings.
instance Arbitrary TestNotificationEventSource where
arbitrary =
TestNotificationEventSource
<$> (T.pack <$> listOf1 (elements ['a' .. 'z']))
<*> (T.pack <$> listOf1 (elements ('a' : ['0' .. '9'])))
<*> (T.pack <$> listOf1 (elements ['a' .. 'z']))
<*> listOf (T.pack <$> listOf1 (elements ['a' .. 'z']))
-- Drives the TestNotificationResponse round-trip property. Composes
-- the event_source and a short status_list (capped at 2 entries to
-- keep the property fast under 100 samples + shrinks).
instance Arbitrary TestNotificationResponse where
arbitrary = do
src <- arbitrary
n <- choose (0, 2)
statuses <- replicateM n arbitrary
pure
TestNotificationResponse
{ testNotificationResponseEventSource = src,
testNotificationResponseStatusList = statuses
}
shrink (TestNotificationResponse src statuses) =
[ TestNotificationResponse src smaller
| smaller <- shrinkList (const []) statuses
]