packages feed

katip-logzio-0.1.2.0: test/Katip/Tests/Scribes/LogzIO/HTTPS.hs

{-# LANGUAGE OverloadedStrings #-}

module Katip.Tests.Scribes.LogzIO.HTTPS
  ( tests,
  )
where

-------------------------------------------------------------------------------
import Control.Concurrent.Async
import Control.Concurrent.STM
import Control.Concurrent.STM.TSem
import Control.Exception.Safe
import Control.Monad
import Control.Monad.IO.Class
import Data.Aeson as A
import qualified Data.ByteString.Builder as BB
import qualified Data.ByteString.Lazy as LBS
import qualified Data.ByteString.Lazy.Char8 as LBS8
import qualified Data.HashMap.Strict as HM
import Data.Int
import Data.Scientific
import Data.Text (Text)
import Data.Time
import Data.Time.Clock.POSIX
import qualified Data.Vector as V
import Hedgehog as HH
import qualified Hedgehog.Gen as Gen
import qualified Hedgehog.Range as Range
import Katip
import Katip.Core
-------------------------------------------------------------------------------
import Katip.Scribes.LogzIO.HTTPS
import Language.Haskell.TH (Loc (..))
import qualified Network.HTTP.Types.Status as HTypes
import Network.HostName
import qualified Network.Wai.Handler.Warp as Warp
import System.Posix
import System.Timeout
import Test.Tasty
import Test.Tasty.HUnit
import Test.Tasty.Hedgehog
import URI.ByteString
import qualified Web.Scotty as Scotty

-------------------------------------------------------------------------------

tests :: TestTree
tests =
  testGroup
    "Katip.Scribes.LogzIO.HTTP"
    [ bufferItemTests,
      renderLineTruncatedTests,
      scribeTests
    ]

-------------------------------------------------------------------------------
-- We bind to a system port so we use a lock on these tests to avoid interleaved, concurrent requests
scribeTests :: TestTree
scribeTests = withLock $ \mkSem ->
  testGroup
    "scribe"
    [ testCase "writes well-formed log items as lines to the given API" $ do
        sem <- mkSem
        withServer sem $ \receivedMessages -> do
          withScribe (\cfg -> cfg {logzIOScribeConfiguration_bufferTimeout = 1}) $ \scribe -> do
            (liPush scribe) (genericLog "a message")

            requestLog <- timeoutFailure (20 * second) (atomically (readTQueue receivedMessages))
            requestLog_token requestLog @?= testAPIToken
            let logs = requestLog_logs requestLog
            length logs @?= 1
            let firstLog = head logs
            receivedLog_message firstLog @?= "a message",
      testCase "respects the items limit" $ do
        sem <- mkSem
        withServer sem $ \receivedMessages -> do
          withScribe (\cfg -> cfg {logzIOScribeConfiguration_bufferItems = 1, logzIOScribeConfiguration_bufferTimeout = 1}) $ \scribe -> do
            (liPush scribe) (genericLog "message 1")
            (liPush scribe) (genericLog "message 2")

            log1 <- timeoutFailure (20 * second) (atomically (readTQueue receivedMessages))
            requestLog_token log1 @?= testAPIToken
            let logs1 = requestLog_logs log1
            length logs1 @?= 1
            let firstLog1 = head logs1
            receivedLog_message firstLog1 @?= "message 1"

            log2 <- timeoutFailure (20 * second) (atomically (readTQueue receivedMessages))
            let logs2 = requestLog_logs log2
            length logs2 @?= 1
            let firstLog2 = head logs2
            receivedLog_message firstLog2 @?= "message 2",
      testCase "respects the time limit" $ do
        sem <- mkSem
        withServer sem $ \receivedMessages -> do
          withScribe (\cfg -> cfg {logzIOScribeConfiguration_bufferItems = 2, logzIOScribeConfiguration_bufferTimeout = 1}) $ \scribe -> do
            (liPush scribe) (genericLog "a message")
            log1 <- timeoutFailure (20 * second) (atomically (readTQueue receivedMessages))
            requestLog_token log1 @?= testAPIToken
            let logs1 = requestLog_logs log1
            length logs1 @?= 1
            let firstLog1 = head logs1
            receivedLog_message firstLog1 @?= "a message",
      testCase "reports errors" $ do
        sem <- mkSem
        let badToken = APIToken "badtoken"
        errorSignal <- newEmptyTMVarIO
        withServer sem $ \_receivedMessages -> do
          withScribe (\cfg -> cfg {logzIOScribeConfiguration_bufferItems = 1, logzIOScribeConfiguration_bufferTimeout = 1, logzIOScribeConfiguration_token = badToken, logzIOScribeConfiguration_onError = void . atomically . tryPutTMVar errorSignal}) $ \scribe -> do
            (liPush scribe) (genericLog "a message")
            loggingError <- timeoutFailure (20 * second) (atomically (takeTMVar errorSignal))
            case loggingError of
              BadToken -> pure ()
              _ -> assertFailure ("Expected BadToken error but got " <> show loggingError),
      testCase "flushes all queued logs on close" $ do
        sem <- mkSem
        withServer sem $ \receivedMessages -> do
          withScribe (\cfg -> cfg {logzIOScribeConfiguration_bufferItems = 100, logzIOScribeConfiguration_bufferTimeout = 100}) $ \scribe -> do
            (liPush scribe) (genericLog "message 1")
            (liPush scribe) (genericLog "message 2")
          messages <- atomically (flushTQueue receivedMessages)
          let allLogs = receivedLog_message <$> mconcat (requestLog_logs <$> messages)
          allLogs @?= ["message 1", "message 2"]
    ]
  where
    second = 1000000
    timeoutFailure waitTime f = do
      res <- timeout waitTime f
      case res of
        Nothing -> assertFailure ("Operation timed out after " <> show waitTime <> "us")
        Just x -> pure x
    withLock = withResource (atomically (newTSem 1)) (const (return ()))
    withServer sem f = bracket_ (atomically (waitTSem sem)) (atomically (signalTSem sem)) $ do
      receivedMessages <- atomically newTQueue
      withAsync (startServer receivedMessages) $ \_async -> f receivedMessages
    testAPIToken = "bigSECRET"
    genericLog message =
      Item
        { _itemApp = "katip-logzio",
          _itemEnv = "test",
          _itemSeverity = InfoS,
          _itemThread = ThreadIdText "42",
          _itemHost = "localhost",
          _itemProcess = 111,
          _itemPayload = (),
          _itemMessage = message,
          _itemTime = mkUTCTime 2019 1 2 3 4 5,
          _itemNamespace = "test",
          _itemLoc = Nothing
        }
    startServer receivedMessages = Scotty.scottyOpts serverOpts $ do
      Scotty.post "/" $ do
        token <- APIToken <$> Scotty.param "token"
        if token == testAPIToken
          then do
            bodyLines <- LBS8.lines <$> Scotty.body
            receivedLogs <- mapM (either error pure . A.eitherDecode) bodyLines
            let requestLog =
                  RequestLog
                    { requestLog_token = token,
                      requestLog_logs = receivedLogs
                    }
            liftIO (atomically (writeTQueue receivedMessages requestLog))
          else Scotty.status HTypes.unauthorized401
    serverOpts =
      Scotty.Options
        { Scotty.verbose = 0,
          Scotty.settings = Warp.setPort 1337 Warp.defaultSettings
        }
    withScribe modConfig =
      bracket (mkLogzIOScribe (modConfig scribeConfig) (const (pure True)) V3) scribeFinalizer
    scribeConfig =
      LogzIOScribeConfiguration
        { logzIOScribeConfiguration_bufferItems = 5,
          logzIOScribeConfiguration_bufferTimeout = 10,
          logzIOScribeConfiguration_scheme = HTTP,
          logzIOScribeConfiguration_host = Host "127.0.0.1",
          logzIOScribeConfiguration_port = Port 1337,
          logzIOScribeConfiguration_token = testAPIToken,
          logzIOScribeConfiguration_retry = defaultRetryPolicy,
          logzIOScribeConfiguration_onError = const (pure ())
        }

data RequestLog = RequestLog
  { requestLog_token :: APIToken,
    requestLog_logs :: [ReceivedLog]
  }

-- | Simplified log that only parses the fields we want to assert on
data ReceivedLog = ReceivedLog
  { receivedLog_message :: Text
  }

instance FromJSON ReceivedLog where
  parseJSON = withObject "ReceivedLog" $ \o -> do
    message <- o .: "message"
    pure $
      ReceivedLog
        { receivedLog_message = message
        }

-------------------------------------------------------------------------------
bufferItemTests :: TestTree
bufferItemTests =
  testGroup
    "bufferItem"
    [ testProperty "0 or negative maximum items always flushes immediately" $
        property $ do
          item <- forAllWith showSimplePayloadItem genSimplePayloadItem
          verbosity <- forAll genVerbosity
          maxItems <- forAll (Gen.int (Range.linear minBound 0))
          bulkBuffer <- forAllWith inspectBulkBuffer genBulkBuffer
          let logAction = bufferItem' reducedMaxPayloadBytes reducedMaxLogLineLength maxItems verbosity item bulkBuffer
          assertFlushNow logAction,
      testProperty "never buffers too many items" $
        property $ do
          item <- forAllWith showSimplePayloadItem genSimplePayloadItem
          verbosity <- forAll genVerbosity
          maxItems <- forAll (Gen.int (Range.linear 0 100))
          bulkBuffer <- forAllWith inspectBulkBuffer genBulkBuffer
          let logAction = bufferItem' reducedMaxPayloadBytes reducedMaxLogLineLength maxItems verbosity item bulkBuffer
          case logAction of
            Buffered newBuffer -> HH.assert (bulkBuffer_itemCount newBuffer <= maxItems)
            _ -> pure (),
      testProperty "never produces too many bytes" $
        property $ do
          item <- forAllWith showSimplePayloadItem genSimplePayloadItem
          verbosity <- forAll genVerbosity
          maxItems <- forAll (Gen.int (Range.linear 0 100))
          bulkBuffer <- forAllWith inspectBulkBuffer genBulkBuffer
          let logAction = bufferItem' reducedMaxPayloadBytes reducedMaxLogLineLength maxItems verbosity item bulkBuffer
          case logAction of
            Buffered newBuffer -> HH.assert (bulkBuffer_bytesUsed newBuffer <= reducedMaxPayloadBytes)
            _ -> pure (),
      testProperty "never flushes more than 1 item" $
        property $ do
          item <- forAllWith showSimplePayloadItem genSimplePayloadItem
          verbosity <- forAll genVerbosity
          maxItems <- forAll (Gen.int (Range.linear minBound 0))
          bulkBuffer <- forAllWith inspectBulkBuffer genBulkBuffer
          let logAction = bufferItem' reducedMaxPayloadBytes reducedMaxLogLineLength maxItems verbosity item bulkBuffer
          case logAction of
            FlushNow _ newBuffer -> bulkBuffer_itemCount newBuffer === 1
            _ -> pure (),
      testProperty "flushes when the buffer is exactly at the limit" $
        property $ do
          maxItems <- forAll (Gen.int (Range.linear 1 20))
          item <- forAllWith showSimplePayloadItem genSimplePayloadItem
          bulkBuffer <- forAllWith inspectBulkBuffer ((\b -> b {bulkBuffer_itemCount = maxItems - 1}) <$> genBulkBuffer)
          verbosity <- forAll genVerbosity
          let logAction = bufferItem' maxBound maxBound maxItems verbosity item bulkBuffer
          case logAction of
            FlushNow _ _ -> pure ()
            Buffered _ -> fail "Expected a FlushNow but got a Buffered"
    ]
  where
    assertFlushNow (FlushNow _ _) = pure ()
    assertFlushNow (Buffered _) = fail ("Expceted FlushNow but got a Buffered")
    inspectBulkBuffer (BulkBuffer used _ itemCount) =
      "BulkBuffer (" <> show used <> ") ... " <> show itemCount

-------------------------------------------------------------------------------

-- | Reduced max log line length for more space and time efficient tests
reducedMaxLogLineLength :: Bytes
reducedMaxLogLineLength = Bytes (bytes maxLogLineLength `div` limitScalingFactor)

reducedMaxPayloadBytes :: Bytes
reducedMaxPayloadBytes = Bytes (bytes maxPayloadBytes `div` limitScalingFactor)

limitScalingFactor :: Int64
limitScalingFactor = 10

-------------------------------------------------------------------------------
showSimplePayloadItem :: Item SimpleLogPayload -> String
showSimplePayloadItem item =
  show (item {_itemPayload = [(k, toJSON v) | (k, AnyLogPayload v) <- unSimpleLogPayload (_itemPayload item)]})

-------------------------------------------------------------------------------
renderLineTruncatedTests :: TestTree
renderLineTruncatedTests =
  testGroup
    "renderLineTruncated"
    [ testProperty "always produces an accurate byte count" $
        property $ do
          item <- forAllWith showSimplePayloadItem genSimplePayloadItem
          verbosity <- forAll genVerbosity
          let (builder, Bytes byteCount) = renderLineTruncated' reducedMaxLogLineLength verbosity item
          builderLength builder === byteCount,
      testProperty "always produces a payload smaller than the line limit" $
        property $ do
          let tinyMaxLineLength = 1024 -- set a really low one for speed
          let lowerBound = fromIntegral (bytes tinyMaxLineLength - 100)
          let upperBound = fromIntegral (bytes tinyMaxLineLength + 100)
          bigMessage <- forAll (genLogStr' (Range.linear lowerBound upperBound))
          item <- (\item -> item {_itemMessage = bigMessage}) <$> forAllWith showSimplePayloadItem genSimplePayloadItem
          verbosity <- forAll genVerbosity
          let (_, byteCount) = renderLineTruncated' tinyMaxLineLength verbosity item
          HH.assert (byteCount <= reducedMaxLogLineLength)
    ]

-------------------------------------------------------------------------------
builderLength :: BB.Builder -> Int64
builderLength = LBS.length . BB.toLazyByteString

-------------------------------------------------------------------------------
genVerbosity :: Gen Verbosity
genVerbosity = Gen.enumBounded

-------------------------------------------------------------------------------
genBulkBuffer :: Gen BulkBuffer
genBulkBuffer = do
  bytesUsed <- genBytes
  -- generating a large bytestring string with hedghog seems quite slow, instead we'll generate a smaller string and replicate and merge it
  payloadMultiplier <- Gen.int (Range.linear 1 100)
  payloadSegment <- genBuilder (Range.linear 0 (round (toRational (bytes reducedMaxPayloadBytes) * 0.05)))
  let payload = mconcat (replicate payloadMultiplier payloadSegment)
  itemCount <- Gen.int (Range.linear 0 maxBound)
  pure $
    BulkBuffer
      { bulkBuffer_bytesUsed = bytesUsed,
        bulkBuffer_payload = payload,
        bulkBuffer_itemCount = itemCount
      }

-------------------------------------------------------------------------------
genBytes :: Gen Bytes
genBytes = Bytes <$> Gen.int64 (Range.linear 0 maxBound)

-------------------------------------------------------------------------------
genBuilder :: Range Int -> Gen BB.Builder
genBuilder size = BB.byteString <$> Gen.bytes size

-------------------------------------------------------------------------------
genSimplePayloadItem :: Gen (Item SimpleLogPayload)
genSimplePayloadItem = do
  app <- genNamespace
  env <- genEnvironment
  severity <- genSeverity
  thread <- genThreadIdText
  host <- genHostName
  process <- genProcessID
  payload <- genSimpleLogPayload
  message <- genLogStr
  time <- genUTCTime
  namespace <- genNamespace
  loc <- Gen.maybe genLoc
  pure $
    Item
      { _itemApp = app,
        _itemEnv = env,
        _itemSeverity = severity,
        _itemThread = thread,
        _itemHost = host,
        _itemProcess = process,
        _itemPayload = payload,
        _itemMessage = message,
        _itemTime = time,
        _itemNamespace = namespace,
        _itemLoc = loc
      }

genNamespace :: Gen Namespace
genNamespace = Namespace <$> Gen.list (Range.linear 0 20) genSmallText

genEnvironment :: Gen Environment
genEnvironment = Environment <$> genSmallText

genSeverity :: Gen Severity
genSeverity = Gen.enumBounded

genThreadIdText :: Gen ThreadIdText
genThreadIdText = ThreadIdText <$> genSmallText

genHostName :: Gen HostName
genHostName = genSmallString

genProcessID :: Gen ProcessID
genProcessID = Gen.enumBounded

genLogStr :: Gen LogStr
genLogStr = genLogStr' (Range.linear 0 1024)

genLogStr' :: Range Int -> Gen LogStr
genLogStr' sizeRange = ls <$> Gen.text sizeRange Gen.unicode

genUTCTime :: Gen UTCTime
genUTCTime =
  posixSecondsToUTCTime . fromIntegral
    <$> Gen.int (Range.linear 0 maxBound)

genLoc :: Gen Loc
genLoc =
  Loc
    <$> genSmallString
    <*> genSmallString
    <*> genSmallString
    <*> genCharPos
    <*> genCharPos
  where
    genCharPos =
      (,)
        <$> Gen.int (Range.linear 0 maxBound)
        <*> Gen.int (Range.linear 0 maxBound)

genSimpleLogPayload :: Gen SimpleLogPayload
genSimpleLogPayload =
  SimpleLogPayload
    <$> Gen.list (Range.linear 0 20) genPair
  where
    genPair =
      (,)
        <$> genSmallText
        <*> genAnyLogPayload

genAnyLogPayload :: Gen AnyLogPayload
genAnyLogPayload =
  AnyLogPayload
    <$> genValue

genValue :: Gen Value
genValue = Gen.recursive Gen.choice nonRecursiveGens recursiveGens
  where
    nonRecursiveGens =
      [ pure A.Null,
        A.String <$> genSmallText,
        A.Number <$> genScientific,
        A.Bool <$> Gen.bool
      ]
    recursiveGens =
      [ A.Object <$> genObject,
        A.Array <$> genArray
      ]
    genObject = HM.fromList <$> Gen.list (Range.linear 0 20) genPair
      where
        genPair = (,) <$> genSmallText <*> genValue
    genArray =
      V.fromList
        <$> Gen.list (Range.linear 0 20) genValue

genScientific :: Gen Scientific
genScientific =
  scientific
    <$> Gen.integral (Range.linear (-999999) 999999)
    <*> Gen.int (Range.linear (-999) (999))

genSmallText :: Gen Text
genSmallText = Gen.text (Range.linear 0 128) Gen.unicode

genSmallString :: Gen String
genSmallString = Gen.string (Range.linear 0 128) Gen.unicode

-------------------------------------------------------------------------------
mkUTCTime :: Integer -> Int -> Int -> DiffTime -> DiffTime -> DiffTime -> UTCTime
mkUTCTime y m d hr mn s = UTCTime day dt
  where
    day = mkDay y m d
    dt = s + 60 * mn + 60 * 60 * hr

-------------------------------------------------------------------------------
mkDay :: Integer -> Int -> Int -> Day
mkDay y m d = day
  where
    Just day = fromGregorianValid y m d