packages feed

hs-opentelemetry-api-1.0.0.0: test/OpenTelemetry/BaggageSpec.hs

module OpenTelemetry.BaggageSpec where

import qualified Data.ByteString as BS
import qualified Data.ByteString.Builder as B
import qualified Data.ByteString.Char8 as B8
import qualified Data.ByteString.Lazy as L
import Data.Either (isLeft, isRight)
import qualified Data.HashMap.Strict as HashMap
import Data.Maybe (fromJust, isJust)
import qualified Data.Text as T
import OpenTelemetry.Baggage (
  Baggage,
  Element (..),
  InvalidBaggage (..),
  decodeBaggageHeader,
  delete,
  empty,
  encodeBaggageHeader,
  encodeBaggageHeaderB,
  fromHashMap,
  insert,
  insertChecked,
  maxBaggageBytes,
  maxMemberBytes,
  maxMembers,
  mkToken,
  property,
  tokenValue,
  values,
 )
import Test.Hspec


spec :: Spec
spec = describe "Baggage" $ do
  -- Baggage API §Baggage header format: key=value list-members
  -- https://opentelemetry.io/docs/specs/otel/baggage/api/
  it "decodes simple header" $ do
    let baggage = values <$> decodeBaggageHeader "x-api-key=asdf"
    HashMap.mapKeys tokenValue <$> baggage `shouldBe` Right (HashMap.fromList [("x-api-key", Element {value = "asdf", properties = []})])
  -- Baggage API §Baggage header format: percent-encoding in values
  -- https://opentelemetry.io/docs/specs/otel/baggage/api/
  it "decodes percent encoded header" $ do
    let baggage = values <$> decodeBaggageHeader "Authorization=Basic%20asdf"
    HashMap.mapKeys tokenValue <$> baggage `shouldBe` Right (HashMap.fromList [("Authorization", Element {value = "Basic asdf", properties = []})])

  it "encodes and decodes simple baggage (roundtrip)" $ do
    let k = fromJust (mkToken "x-key")
        b0 = insert k (Element {value = "hello", properties = []}) empty
        hdr = encodeBaggageHeader b0
    HashMap.mapKeys tokenValue . values <$> decodeBaggageHeader hdr
      `shouldBe` Right (HashMap.fromList [("x-key", Element {value = "hello", properties = []})])

  it "encodes baggage with multiple entries" $ do
    let k1 = fromJust (mkToken "a")
        k2 = fromJust (mkToken "b")
        b0 =
          insert k1 (Element {value = "1", properties = []}) $
            insert k2 (Element {value = "2", properties = []}) empty
        hdr = encodeBaggageHeader b0
    case HashMap.mapKeys tokenValue . values <$> decodeBaggageHeader hdr of
      Left e -> expectationFailure e
      Right m -> do
        HashMap.lookup "a" m `shouldBe` Just (Element {value = "1", properties = []})
        HashMap.lookup "b" m `shouldBe` Just (Element {value = "2", properties = []})

  it "insert overwrites existing key" $ do
    let k = fromJust (mkToken "k")
        b0 = insert k (Element {value = "first", properties = []}) empty
        b1 = insert k (Element {value = "second", properties = []}) b0
    HashMap.mapKeys tokenValue (values b1)
      `shouldBe` HashMap.fromList [("k", Element {value = "second", properties = []})]

  it "delete removes entry" $ do
    let k = fromJust (mkToken "k")
        b0 = insert k (Element {value = "v", properties = []}) empty
        b1 = delete k b0
    values b1 `shouldBe` HashMap.empty

  it "empty baggage encodes to empty" $ do
    encodeBaggageHeader empty `shouldBe` ""

  -- Baggage API §Baggage header format: properties after semicolon
  -- https://opentelemetry.io/docs/specs/otel/baggage/api/
  it "decodes header with properties" $ do
    let expected =
          HashMap.fromList
            [
              ( "key"
              , Element
                  { value = "value"
                  , properties =
                      [ property (fromJust (mkToken "prop1")) Nothing
                      , property (fromJust (mkToken "prop2")) (Just "val2")
                      ]
                  }
              )
            ]
    HashMap.mapKeys tokenValue . values <$> decodeBaggageHeader "key=value;prop1;prop2=val2"
      `shouldBe` Right expected

  it "decodes header with multiple entries" $ do
    let expected =
          HashMap.fromList
            [ ("k1", Element {value = "v1", properties = []})
            , ("k2", Element {value = "v2", properties = []})
            ]
    HashMap.mapKeys tokenValue . values <$> decodeBaggageHeader "k1=v1,k2=v2"
      `shouldBe` Right expected

  it "rejects empty input" $ do
    decodeBaggageHeader "" `shouldSatisfy` isLeft

  -- Baggage API §Baggage header: W3C baggage limits (8192 bytes, 180 members, 4096 bytes per member)
  -- https://opentelemetry.io/docs/specs/otel/baggage/api/
  describe "W3C Baggage size limits" $ do
    it "decode rejects header exceeding 8192 bytes" $ do
      let longVal = B8.replicate (maxBaggageBytes + 1 - 2) 'x'
          hdr = "k=" <> longVal
      decodeBaggageHeader hdr `shouldSatisfy` isLeft

    it "decode accepts header at exactly 8192 bytes" $ do
      let valLen = maxBaggageBytes - 2
          longVal = B8.replicate valLen 'x'
          hdr = "k=" <> longVal
      BS.length hdr `shouldBe` maxBaggageBytes
      decodeBaggageHeader hdr `shouldSatisfy` isRight

    it "decode rejects more than 180 members" $ do
      let mkEntry i = B8.pack ("k" ++ show (i :: Int) ++ "=v")
          entries = map mkEntry [1 .. maxMembers + 1]
          hdr = BS.intercalate "," entries
      decodeBaggageHeader hdr `shouldSatisfy` isLeft

    it "decode accepts exactly 180 members" $ do
      let mkEntry i = B8.pack ("k" ++ show (i :: Int) ++ "=v")
          entries = map mkEntry [1 .. maxMembers]
          hdr = BS.intercalate "," entries
      case decodeBaggageHeader hdr of
        Left err -> expectationFailure err
        Right b -> HashMap.size (values b) `shouldBe` maxMembers

    it "encode skips member exceeding 4096 bytes" $ do
      let shortKey = fromJust (mkToken "short")
          longKey = fromJust (mkToken "long")
          longVal = T.replicate maxMemberBytes "x"
          b =
            insert shortKey (Element "ok" []) $
              insert longKey (Element longVal []) empty
          hdr = encodeBaggageHeader b
      case decodeBaggageHeader hdr of
        Left err -> expectationFailure err
        Right decoded -> do
          let m = HashMap.mapKeys tokenValue (values decoded)
          HashMap.lookup "short" m `shouldBe` Just (Element "ok" [])
          m `shouldSatisfy` (not . HashMap.member "long")

    it "encode respects 8192 byte total limit" $ do
      let bigBaggage = foldr addEntry empty [1 :: Int .. 200]
          addEntry i b = case mkToken (T.pack ("key" ++ show i)) of
            Nothing -> b
            Just k -> insert k (Element (T.replicate 50 "x") []) b
      BS.length (encodeBaggageHeader bigBaggage) `shouldSatisfy` (<= maxBaggageBytes)

  -- Baggage API §Baggage operations: baggage must enforce header limits
  -- https://opentelemetry.io/docs/specs/otel/baggage/api/
  describe "insertChecked" $ do
    it "accepts normal entries" $ do
      let k = fromJust (mkToken "key1")
      insertChecked k (Element "val" []) empty `shouldSatisfy` isRight

    it "rejects when exceeding 180 members" $ do
      let base = foldr addEntry empty [1 :: Int .. maxMembers]
          addEntry i b = case mkToken (T.pack ("k" ++ show i)) of
            Nothing -> b
            Just k -> insert k (Element "v" []) b
          extra = fromJust (mkToken "overflow")
      insertChecked extra (Element "v" []) base `shouldBe` Left TooManyListMembers

    it "rejects when total serialized size exceeds 8192 bytes" $ do
      let base = foldr addEntry empty [1 :: Int .. 80]
          addEntry i b = case mkToken (T.pack ("k" ++ show i)) of
            Nothing -> b
            Just k -> insert k (Element (T.replicate 100 "x") []) b
          extra = fromJust (mkToken "overflow")
      insertChecked extra (Element (T.replicate 100 "x") []) base `shouldBe` Left BaggageTooLong

    it "allows replacing an existing key without exceeding limits" $ do
      let k = fromJust (mkToken "key")
          b0 = insert k (Element "old" []) empty
      insertChecked k (Element "new" []) b0 `shouldSatisfy` isRight

  describe "fromHashMap" $ do
    it "creates Baggage from a HashMap" $ do
      let k = fromJust (mkToken "hm-key")
          hm = HashMap.singleton k (Element "hm-val" [])
          bag = fromHashMap hm
      HashMap.mapKeys tokenValue (values bag)
        `shouldBe` HashMap.fromList [("hm-key", Element "hm-val" [])]

    it "roundtrips with values" $ do
      let k = fromJust (mkToken "rt")
          bag = insert k (Element "v" []) empty
      fromHashMap (values bag) `shouldBe` bag

  -- Implementation-specific: Builder-based encoding matches strict encoding
  -- https://opentelemetry.io/docs/specs/otel/baggage/api/
  describe "encodeBaggageHeaderB" $ do
    it "produces same output as encodeBaggageHeader" $ do
      let k = fromJust (mkToken "b-key")
          bag = insert k (Element "b-val" []) empty
          strict = encodeBaggageHeader bag
          viaBuilder = L.toStrict (B.toLazyByteString (encodeBaggageHeaderB bag))
      viaBuilder `shouldBe` strict

  -- Baggage API §Token: baggage key token constraints
  -- https://opentelemetry.io/docs/specs/otel/baggage/api/
  describe "mkToken edge cases" $ do
    it "rejects empty string" $ do
      mkToken "" `shouldBe` Nothing

    it "rejects token with spaces" $ do
      mkToken "has space" `shouldBe` Nothing

    it "rejects token exceeding 4096 chars" $ do
      mkToken (T.replicate 4097 "a") `shouldBe` Nothing

    it "accepts token at exactly 4096 chars" $ do
      mkToken (T.replicate 4096 "a") `shouldSatisfy` isJust

    it "rejects control characters" $ do
      mkToken "bad\x00key" `shouldBe` Nothing

  describe "decode error paths" $ do
    it "rejects key with invalid characters" $ do
      decodeBaggageHeader "bad key=val" `shouldSatisfy` isLeft

    it "rejects missing equals sign" $ do
      decodeBaggageHeader "keyonly" `shouldSatisfy` isLeft

    -- Implementation-specific: invalid percent-sequences treated as literal (decoder behavior)
    -- https://opentelemetry.io/docs/specs/otel/baggage/api/
    it "passes through invalid percent encoding literally" $ do
      decodeBaggageHeader "k=%ZZ" `shouldSatisfy` isRight