packages feed

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