packages feed

bloodhound-1.0.0.0: tests/Test/SecurityPrivilegesSpec.hs

{-# LANGUAGE OverloadedStrings #-}

module Test.SecurityPrivilegesSpec (spec) where

import Data.Aeson
import Data.Aeson.KeyMap qualified as KM
import Data.List.NonEmpty (NonEmpty (..))
import Data.List.NonEmpty qualified as NE
import TestsUtils.Import
import Prelude

spec :: Spec
spec = do
  describe "FieldSecurity" $ do
    it "round-trips grant + except arrays" $ do
      let fs =
            FieldSecurity
              { fsGrant = Just (FieldName "a" :| [FieldName "b"]),
                fsExcept = Just (FieldName "secret" :| [])
              }
      let Just decoded = decode (encode fs) :: Maybe FieldSecurity
      (NE.toList <$> fsGrant decoded) `shouldBe` Just [FieldName "a", FieldName "b"]
      (NE.toList <$> fsExcept decoded) `shouldBe` Just [FieldName "secret"]

    it "decodes a bare-string grant as a singleton list" $ do
      let Just fs = decode "{\"grant\":\"email\"}" :: Maybe FieldSecurity
      (NE.toList <$> fsGrant fs) `shouldBe` Just [FieldName "email"]
      fsExcept fs `shouldBe` Nothing

    it "treats an empty grant array as Nothing" $ do
      let Just fs = decode "{\"grant\":[]}" :: Maybe FieldSecurity
      fsGrant fs `shouldBe` Nothing

  describe "IndexPrivilege" $ do
    it "round-trips a full entry with field_security, query and extras" $ do
      let bytes =
            "{\
            \  \"names\": [\"logs-*\", \"metrics-*\"],\
            \  \"privileges\": [\"read\", \"view_index_metadata\"],\
            \  \"field_security\": {\"grant\": [\"a\"], \"except\": [\"b\"]},\
            \  \"query\": {\"term\": {\"active\": true}},\
            \  \"description\": \"custom note\"\
            \}"
      let Just decoded = decode bytes :: Maybe IndexPrivilege
      NE.toList (ipNames decoded)
        `shouldBe` [IndexPattern "logs-*", IndexPattern "metrics-*"]
      NE.toList (ipPrivileges decoded)
        `shouldBe` ["read", "view_index_metadata"]
      ipFieldSecurity decoded `shouldSatisfy` isJust
      ipQuery decoded `shouldSatisfy` isJust
      -- The unknown @description@ survives in extras.
      KM.lookup "description" (ipExtras decoded) `shouldSatisfy` isJust
      -- Round-trip is stable.
      let Just rt = decode (encode decoded) :: Maybe IndexPrivilege
      NE.toList (ipNames rt) `shouldBe` [IndexPattern "logs-*", IndexPattern "metrics-*"]

    it "decodes a bare-string names field" $ do
      let Just decoded = decode "{\"names\":\"logs\",\"privileges\":[\"read\"]}" :: Maybe IndexPrivilege
      NE.toList (ipNames decoded) `shouldBe` [IndexPattern "logs"]

    it "rejects an empty names array" $ do
      decode "{\"names\":[],\"privileges\":[\"read\"]}" `shouldBe` (Nothing :: Maybe IndexPrivilege)

    it "rejects names missing entirely" $ do
      decode "{\"privileges\":[\"read\"]}" `shouldBe` (Nothing :: Maybe IndexPrivilege)

  describe "ApplicationPrivilege" $ do
    it "round-trips application + privileges + resources + extras" $ do
      let bytes =
            "{\
            \  \"application\": \"myapp\",\
            \  \"privileges\": [\"read\", \"write\"],\
            \  \"resources\": [\"index:1\", \"*\"],\
            \  \"note\": \"extra\"\
            \}"
      let Just decoded = decode bytes :: Maybe ApplicationPrivilege
      apvApplication decoded `shouldBe` "myapp"
      apvPrivileges decoded `shouldBe` ["read", "write"]
      apvResources decoded `shouldBe` ["index:1", "*"]
      KM.lookup "note" (apvExtras decoded) `shouldSatisfy` isJust

    it "tolerates an absent resources field" $ do
      let Just decoded =
            decode
              "{\"application\":\"myapp\",\"privileges\":[\"read\"]}" ::
              Maybe ApplicationPrivilege
      apvResources decoded `shouldBe` []

    it "decodes a bare-string privileges field" $ do
      let Just decoded =
            decode
              "{\"application\":\"myapp\",\"privileges\":\"read\",\"resources\":[\"*\"]}" ::
              Maybe ApplicationPrivilege
      apvPrivileges decoded `shouldBe` ["read"]

  describe "ActionName" $ do
    it "round-trips through JSON and IsString" $ do
      let Just decoded = decode "\"data:read/*\"" :: Maybe ActionName
      decoded `shouldBe` "data:read/*"
      encode decoded `shouldBe` "\"data:read/*\""

  describe "ApplicationPrivilegeDefinition" $ do
    it "round-trips actions + metadata + extras" $ do
      let bytes =
            "{\
            \  \"actions\": [\"data:read/*\", \"action:login\"],\
            \  \"metadata\": {\"description\": \"Read access\"},\
            \  \"comment\": \"extra\"\
            \}"
          Just decoded = decode bytes :: Maybe ApplicationPrivilegeDefinition
      NE.toList (apdActions decoded) `shouldBe` ["data:read/*", "action:login"]
      apdMetadata decoded `shouldSatisfy` isJust
      KM.lookup "comment" (apdExtras decoded) `shouldSatisfy` isJust
      let Just rt = decode (encode decoded) :: Maybe ApplicationPrivilegeDefinition
      NE.toList (apdActions rt) `shouldBe` ["data:read/*", "action:login"]
      KM.lookup "comment" (apdExtras rt) `shouldSatisfy` isJust

    it "decodes a bare-string action as a singleton" $ do
      let Just decoded = decode "{\"actions\":\"action:login\"}" :: Maybe ApplicationPrivilegeDefinition
      NE.toList (apdActions decoded) `shouldBe` ["action:login"]

    it "rejects an empty actions array" $ do
      decode "{\"actions\":[]}" `shouldBe` (Nothing :: Maybe ApplicationPrivilegeDefinition)

    it "rejects actions missing entirely" $ do
      decode "{\"metadata\":{}}" `shouldBe` (Nothing :: Maybe ApplicationPrivilegeDefinition)

  describe "ApplicationPrivilegeInfo" $ do
    it "decodes the GET leaf with application, name, actions, metadata" $ do
      let bytes =
            "{\
            \  \"application\": \"myapp\",\
            \  \"name\": \"read\",\
            \  \"actions\": [\"data:read/*\", \"action:login\"],\
            \  \"metadata\": {\"description\": \"Read access to myapp\"}\
            \}"
          Just decoded = decode bytes :: Maybe ApplicationPrivilegeInfo
      apiApplication decoded `shouldBe` "myapp"
      apiName decoded `shouldBe` "read"
      apiActions decoded `shouldBe` ["data:read/*", "action:login"]
      apiMetadata decoded `shouldSatisfy` isJust

    it "tolerates absent actions (defaults to [])" $ do
      let Just decoded = decode "{\"application\":\"myapp\",\"name\":\"read\"}" :: Maybe ApplicationPrivilegeInfo
      apiActions decoded `shouldBe` []

    it "carries unknown fields in extras" $ do
      let Just decoded = decode "{\"application\":\"myapp\",\"name\":\"read\",\"actions\":[\"*\"],\"note\":\"x\"}" :: Maybe ApplicationPrivilegeInfo
      KM.lookup "note" (apiExtras decoded) `shouldSatisfy` isJust

  describe "PrivilegesListResponse" $ do
    it "decodes the two-level {app:{name:leaf}} envelope" $ do
      let bytes =
            "{\"myapp\":{\"read\":{\"application\":\"myapp\",\"name\":\"read\",\"actions\":[\"data:read/*\"]}}}"
          Just decoded = decode bytes :: Maybe PrivilegesListResponse
      case privilegesListEntries decoded of
        [(ApplicationName app, [(ApplicationPrivilegeName priv, info)])] -> do
          app `shouldBe` "myapp"
          priv `shouldBe` "read"
          apiApplication info `shouldBe` "myapp"
          apiActions info `shouldBe` ["data:read/*"]
        other -> expectationFailure $ "unexpected entries: " <> show other

    it "round-trips a multi-app, multi-privilege envelope" $ do
      let bytes =
            "{\
            \  \"app01\": {\
            \    \"read\": {\"application\": \"app01\", \"name\": \"read\", \"actions\": [\"*\"]},\
            \    \"write\": {\"application\": \"app01\", \"name\": \"write\", \"actions\": [\"data:write/*\"]}\
            \  },\
            \  \"app02\": {\
            \    \"all\": {\"application\": \"app02\", \"name\": \"all\", \"actions\": [\"*\"]}\
            \  }\
            \}"
          Just decoded = decode bytes :: Maybe PrivilegesListResponse
          Just rt = decode (encode decoded) :: Maybe PrivilegesListResponse
      length (privilegesListEntries decoded) `shouldBe` 2
      privilegesListEntries rt `shouldBe` privilegesListEntries decoded

  describe "PrivilegeCreatedResponse" $ do
    it "decodes {app:{name:{created:true}}} to created True" $ do
      let Just decoded = decode "{\"myapp\":{\"read\":{\"created\":true}}}" :: Maybe PrivilegeCreatedResponse
      pcCreated decoded `shouldBe` True

    it "decodes created:false on update" $ do
      let Just decoded = decode "{\"myapp\":{\"read\":{\"created\":false}}}" :: Maybe PrivilegeCreatedResponse
      pcCreated decoded `shouldBe` False

    it "re-decodes after encode to the same value" $ do
      let Just decoded = decode "{\"myapp\":{\"read\":{\"created\":true}}}" :: Maybe PrivilegeCreatedResponse
          Just rt = decode (encode decoded) :: Maybe PrivilegeCreatedResponse
      rt `shouldBe` decoded

  describe "PrivilegeDeletedResponse" $ do
    it "decodes {app:{name:{found:true}}}" $ do
      let Just decoded = decode "{\"myapp\":{\"read\":{\"found\":true}}}" :: Maybe PrivilegeDeletedResponse
      pdFound decoded `shouldBe` Just True

    it "decodes found:false when the privilege was absent" $ do
      let Just decoded = decode "{\"myapp\":{\"read\":{\"found\":false}}}" :: Maybe PrivilegeDeletedResponse
      pdFound decoded `shouldBe` Just False

    it "re-decodes after encode to the same value (bare-leaf fallback)" $ do
      let Just decoded = decode "{\"myapp\":{\"read\":{\"found\":true}}}" :: Maybe PrivilegeDeletedResponse
          Just rt = decode (encode decoded) :: Maybe PrivilegeDeletedResponse
      rt `shouldBe` decoded

  describe "ClearPrivilegeCacheResponse" $ do
    it "decodes an empty object" $ do
      let Just decoded = decode "{}" :: Maybe ClearPrivilegeCacheResponse
      cpcBody decoded `shouldBe` KM.empty

    it "round-trips an arbitrary object body" $ do
      let bytes = "{\"cleared\":[\"myapp\"]}"
          Just decoded = decode bytes :: Maybe ClearPrivilegeCacheResponse
          Just rt = decode (encode decoded) :: Maybe ClearPrivilegeCacheResponse
      rt `shouldBe` decoded