bloodhound-1.0.0.0: tests/Test/SecurityApiKeysSpec.hs
{-# LANGUAGE OverloadedStrings #-}
module Test.SecurityApiKeysSpec (spec) where
import Data.Aeson
import Data.Aeson.KeyMap qualified as KM
import Data.ByteString.Lazy.Char8 qualified as L
import TestsUtils.Import
import Prelude
-- | @POST /_security/api_key@ response — the plaintext @api_key@ and the
-- base64 @encoded@ token are present ONLY here.
sampleCreatedBytes :: L.ByteString
sampleCreatedBytes =
"{\
\ \"id\": \"VuaCfGJBC186LdfnLWIw\",\
\ \"name\": \"my-api-key\",\
\ \"api_key\": \"ui2lp2x2ninwsksjdb2ws6g_\",\
\ \"encoded\": \"VnVhQ2ZHSkJDMTg2TGRmbkxXSXc6dWkybHAyeDJuaW53c2tnamIyd3M2Z18=\",\
\ \"metadata\": {\"app\": \"beta\"}\
\}"
-- | One entry from @GET /_security/api_key@ — NO @api_key@ \/ @encoded@.
sampleRetrieveEntryBytes :: L.ByteString
sampleRetrieveEntryBytes =
"{\
\ \"id\": \"VuaCfGJBC186LdfnLWIw\",\
\ \"name\": \"my-api-key\",\
\ \"creation\": 1550761411000,\
\ \"expiration\": 1550800000000,\
\ \"invalidated\": false,\
\ \"username\": \"alice\",\
\ \"realm\": \"reserved\",\
\ \"role_descriptors\": {\
\ \"my-role\": {\
\ \"cluster\": [\"monitor\"],\
\ \"indices\": [{\"names\": [\"*\"], \"privileges\": [\"read\"]}]\
\ }\
\ }\
\}"
-- | @DELETE /_security/api_key@ response with a partial failure.
sampleInvalidateBytes :: L.ByteString
sampleInvalidateBytes =
"{\
\ \"invalidated_api_keys\": [\"id1\"],\
\ \"previously_owned_api_keys\": [],\
\ \"error_count\": 1,\
\ \"error_details\": [\
\ {\"id\": \"id2\", \"type\": \"exception\", \"reason\": \"not found\"}\
\ ]\
\}"
-- | @POST /_security/_query/api_key@ response: @total@ \/ @count@, a
-- page of @api_keys@ (each carrying query-only fields @realm_type@ \/
-- @_sort@ that fall into 'ApiKeyInfo''s extras), plus a top-level
-- @aggregations@ blob that falls into 'qkrExtras'.
sampleQueryBytes :: L.ByteString
sampleQueryBytes =
"{\"total\": 2, \"count\": 1, \"api_keys\": [{\"id\": \"id1\", \"name\": \"n1\", \"realm_type\": \"reserved\", \"_sort\": [\"x\"]}], \"aggregations\": {\"foo\": {}}}"
spec :: Spec
spec = do
describe "ApiKeyCreatedResponse" $ do
it "decodes the plaintext secret + encoded token" $ do
let Just c = decode sampleCreatedBytes :: Maybe ApiKeyCreatedResponse
akcId c `shouldBe` "VuaCfGJBC186LdfnLWIw"
akcName c `shouldBe` "my-api-key"
akcApiKey c `shouldBe` Just "ui2lp2x2ninwsksjdb2ws6g_"
akcEncoded c `shouldSatisfy` isJust
KM.lookup "app" (fromMaybe KM.empty (akcMetadata c)) `shouldSatisfy` isJust
it "tolerates an absent api_key (older ES)" $ do
let Just c = decode "{\"id\":\"i\",\"name\":\"n\"}" :: Maybe ApiKeyCreatedResponse
akcApiKey c `shouldBe` Nothing
describe "ApiKeyInfo" $ do
it "decodes WITHOUT api_key / encoded and embeds role_descriptors" $ do
let Just i = decode sampleRetrieveEntryBytes :: Maybe ApiKeyInfo
akiId i `shouldBe` "VuaCfGJBC186LdfnLWIw"
akiCreation i `shouldBe` Just 1550761411000
akiInvalidated i `shouldBe` Just False
akiRealm i `shouldBe` Just "reserved"
-- role_descriptors reuses the Role type.
case akiRoleDescriptors i of
Just [(RoleName "my-role", role)] ->
rCluster role `shouldBe` ["monitor"]
other -> expectationFailure ("expected one role descriptor, got " <> show other)
it "preserves unknown sibling fields in extras (forward-compat)" $ do
let bytes =
"{\
\ \"id\": \"i\",\
\ \"name\": \"n\",\
\ \"agent\": \"kibana\",\
\ \"creation_by\": \"alice\"\
\}"
let Just i = decode bytes :: Maybe ApiKeyInfo
KM.lookup "agent" (akiExtras i) `shouldSatisfy` isJust
KM.lookup "creation_by" (akiExtras i) `shouldSatisfy` isJust
-- Stable round-trip: extras survive encode . decode.
let Just rt = decode (encode i) :: Maybe ApiKeyInfo
KM.lookup "agent" (akiExtras rt) `shouldSatisfy` isJust
describe "CreateApiKeyRequest" $ do
it "omits role_descriptors when empty" $ do
let req = defaultCreateApiKeyRequest "my-key"
encode req `shouldBe` "{\"name\":\"my-key\"}"
it "encodes role_descriptors keyed by role name and round-trips" $ do
let role = defaultRole {rCluster = ["monitor"]}
req =
(defaultCreateApiKeyRequest "my-key")
{ cakRoleDescriptors = Just [(RoleName "my-role", role)],
cakExpiration = Just "1d"
}
let Just rt = decode (encode req) :: Maybe CreateApiKeyRequest
cakName rt `shouldBe` "my-key"
cakExpiration rt `shouldBe` Just "1d"
case cakRoleDescriptors rt of
Just [(RoleName "my-role", role')] -> rCluster role' `shouldBe` ["monitor"]
other -> expectationFailure ("expected one role descriptor, got " <> show other)
describe "GrantApiKeyRequest" $ do
it "round-trips a password grant" $ do
let req =
GrantApiKeyRequest
{ ggrGrantType = GrantTypePassword,
ggrAccessToken = Nothing,
ggrUsername = Just "alice",
ggrPassword = Just "pw",
ggrApiKey = defaultApiKeySpec "granted",
ggrRunAs = Nothing
}
let Just rt = decode (encode req) :: Maybe GrantApiKeyRequest
ggrGrantType rt `shouldBe` GrantTypePassword
ggrUsername rt `shouldBe` Just "alice"
it "rejects an unknown grant_type" $ do
decode "{\"grant_type\":\"weird\",\"api_key\":{\"name\":\"n\"}}"
`shouldBe` (Nothing :: Maybe GrantApiKeyRequest)
describe "RetrieveApiKeyRequest" $ do
it "encodes only the set filters" $ do
let req = (defaultRetrieveApiKeyRequest) {rakOwner = Just True}
encode req `shouldBe` "{\"owner\":true}"
describe "InvalidateApiKeyResponse" $ do
it "decodes a partial failure" $ do
let Just r = decode sampleInvalidateBytes :: Maybe InvalidateApiKeyResponse
iarInvalidatedApiKeys r `shouldBe` ["id1"]
iarErrorCount r `shouldBe` Just 1
length (iarErrorDetails r) `shouldBe` 1
case iarErrorDetails r of
(e : _) -> iaeReason e `shouldBe` Just "not found"
[] -> expectationFailure "expected error details"
describe "UpdateApiKeyRequest" $ do
it "encodes role_descriptors + metadata and round-trips" $ do
let req =
UpdateApiKeyRequest
{ uakRoleDescriptors =
Just [(RoleName "r", defaultRole {rCluster = ["monitor"]})],
uakMetadata = Just (KM.fromList [("k", "v")])
}
let Just rt = decode (encode req) :: Maybe UpdateApiKeyRequest
case uakRoleDescriptors rt of
Just [(RoleName "r", role)] -> rCluster role `shouldBe` ["monitor"]
other -> expectationFailure ("expected one role descriptor, got " <> show other)
KM.lookup "k" (fromMaybe KM.empty (uakMetadata rt)) `shouldSatisfy` isJust
describe "QueryApiKeyRequest" $ do
it "default encodes to {}" $
encode defaultQueryApiKeyRequest `shouldBe` "{}"
it "encodes query/from/size and round-trips" $ do
let req =
defaultQueryApiKeyRequest
{ qakQuery = Just (object ["match_all" .= object []]),
qakFrom = Just 0,
qakSize = Just 10
}
let Just rt = decode (encode req) :: Maybe QueryApiKeyRequest
qakFrom rt `shouldBe` Just 0
qakSize rt `shouldBe` Just 10
qakQuery rt `shouldSatisfy` isJust
it "decodes aggregations from the long-form 'aggregations' key" $ do
let Just rt = decode "{\"aggregations\":{\"x\":{}}}" :: Maybe QueryApiKeyRequest
qakAggregations rt `shouldSatisfy` isJust
-- round-trips
let Just rt2 = decode (encode rt) :: Maybe QueryApiKeyRequest
qakAggregations rt2 `shouldSatisfy` isJust
describe "QueryApiKeyResponse" $ do
it "decodes total/count, embeds ApiKeyInfo, absorbs response + per-key extras" $ do
let Just r = decode sampleQueryBytes :: Maybe QueryApiKeyResponse
qkrTotal r `shouldBe` Just 2
qkrCount r `shouldBe` Just 1
length (qkrApiKeys r) `shouldBe` 1
case qkrApiKeys r of
(info : _) -> do
akiId info `shouldBe` "id1"
-- query-only sibling fields land in the ApiKeyInfo extras
KM.lookup "realm_type" (akiExtras info) `shouldSatisfy` isJust
KM.lookup "_sort" (akiExtras info) `shouldSatisfy` isJust
[] -> expectationFailure "expected one api_key"
-- top-level unknown fields (e.g. aggregations) land in qkrExtras
KM.lookup "aggregations" (qkrExtras r) `shouldSatisfy` isJust
-- stable round-trip: extras survive encode . decode
let Just rt = decode (encode r) :: Maybe QueryApiKeyResponse
KM.lookup "aggregations" (qkrExtras rt) `shouldSatisfy` isJust