packages feed

keter-rate-limiting-plugin-0.1.0.0: test/Keter/RateLimiter/NotificationTests.hs

{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE ScopedTypeVariables #-}

{-|
Module      : Keter.RateLimiter.NotificationTests
Description : Unit tests for throttling notification mechanisms in Keter
Copyright   : (c) 2025 Oleksandr Zhabenko
License     : MIT
Maintainer  : oleksandr.zhabenko@yahoo.com
Stability   : experimental
Portability : portable

This module contains tests for the notification infrastructure used in
the Keter rate limiter. It verifies that console-based and no-op notifiers,
along with WAI integration, produce expected log messages or suppress output appropriately.
-}
module Keter.RateLimiter.NotificationTests (tests) where

import Test.Tasty
import Test.Tasty.HUnit
import Data.Text (Text)
import qualified Data.Text as T
import Network.Wai (defaultRequest, requestMethod, rawPathInfo, rawQueryString, remoteHost)
import Network.Socket (SockAddr(..), tupleToHostAddress, tupleToHostAddress6)
import Data.IORef (IORef, newIORef, readIORef, modifyIORef')
import Keter.RateLimiter.Notifications
import Data.Time.Clock (getCurrentTime)
import Data.Time.Format (defaultTimeLocale, formatTime)

-- | Create a test notifier that captures output with real timestamps
createTestNotifier :: IORef [Text] -> Notifier
createTestNotifier outputRef = Notifier
  { notifierName = "test"
  , notifierAction = \throttle act item limit -> do
      now <- getCurrentTime
      let ts = T.pack $ formatTime defaultTimeLocale "%Y-%m-%d %H:%M:%S" now
          parts = [throttle, act, item, T.concat ["(limit: ", T.pack (show limit), ")"]]
          message = T.intercalate " " $ filter (not . T.null) parts
          fullMessage = T.concat [ts, " - ", message]
      modifyIORef' outputRef (fullMessage :)
  }

-- | Create a test WAI notifier that captures output with real timestamps
createTestWAINotifier :: IORef [Text] -> WAINotifier
createTestWAINotifier outputRef throttleName req limit = do
  now <- getCurrentTime
  let timestamp = T.pack $ formatTime defaultTimeLocale "%Y-%m-%d %H:%M:%S" now
      requestInfo = convertWAIRequest req
      message = T.concat [timestamp, " - ", throttleName, " blocked ", 
                         requestInfo, " (limit: ", T.pack (show limit), ")"]
  modifyIORef' outputRef (message :)

-- | Check if a timestamp is valid (format: YYYY-MM-DD HH:MM:SS)
isValidTimestamp :: Text -> Bool
isValidTimestamp ts = 
  T.length ts == 19 && 
  T.all (`T.elem` ("0123456789-: " :: Text)) ts &&
  T.index ts 4 == '-' &&
  T.index ts 7 == '-' &&
  T.index ts 10 == ' ' &&
  T.index ts 13 == ':' &&
  T.index ts 16 == ':'

-- | Parse and validate a log message format: "TIMESTAMP - CONTENT"
parseLogMessage :: Text -> Maybe (Text, Text)
parseLogMessage msg =
  case T.splitOn " - " msg of
    [timestamp, content] | isValidTimestamp timestamp -> Just (timestamp, content)
    _ -> Nothing

-- | Test suite for notification handlers in the rate limiter.
tests :: TestTree
tests = testGroup "Keter.RateLimiter.Notifications Tests"
  [ testCase "Generic notifier format" $ do
      outputRef <- newIORef []
      let notifier = createTestNotifier outputRef
      
      notify notifier "loginAttempts" ("user123" :: Text) 5
      
      output <- readIORef outputRef
      case output of
        [message] -> case parseLogMessage message of
          Just (_, content) -> 
            assertEqual "Generic notifier message format" 
                       "loginAttempts blocked \"user123\" (limit: 5)" 
                       content
          Nothing -> assertFailure $ "Invalid log format: " ++ T.unpack message
        _ -> assertFailure $ "Expected exactly one message, got: " ++ show (length output)

  , testCase "WAI notifier format" $ do
      outputRef <- newIORef []
      let notifier = createTestWAINotifier outputRef
          req = defaultRequest
                  { requestMethod = "GET"
                  , rawPathInfo = "/api/users"
                  , rawQueryString = "?limit=10"
                  , remoteHost = SockAddrInet 8080 (tupleToHostAddress (192,168,1,100))
                  }
      
      notifier "apiRequests" req 50
      
      output <- readIORef outputRef
      case output of
        [message] -> case parseLogMessage message of
          Just (_, content) ->
            assertEqual "WAI notifier message format"
                       "apiRequests blocked GET /api/users?limit=10 from 192.168.1.100:8080 (limit: 50)"
                       content
          Nothing -> assertFailure $ "Invalid log format: " ++ T.unpack message
        _ -> assertFailure $ "Expected exactly one message, got: " ++ show (length output)

  , testCase "waiNotifier adapter format" $ do
      outputRef <- newIORef []
      let baseNotifier = createTestNotifier outputRef
          adapter = waiNotifier baseNotifier
          req = defaultRequest 
                  { requestMethod = "POST"
                  , rawPathInfo = "/login"
                  , rawQueryString = ""
                  , remoteHost = SockAddrInet 443 (tupleToHostAddress (10,0,0,1))
                  }
      
      adapter "auth" req 10
      
      output <- readIORef outputRef
      case output of
        [message] -> case parseLogMessage message of
          Just (_, content) ->
            assertEqual "waiNotifier adapter format"
                       "auth blocked POST /login from 10.0.0.1:443 (limit: 10)"
                       content
          Nothing -> assertFailure $ "Invalid log format: " ++ T.unpack message
        _ -> assertFailure $ "Expected exactly one message, got: " ++ show (length output)

  , testCase "convertWAIRequest IPv4 format" $ do
      let req = defaultRequest
                  { requestMethod = "GET"
                  , rawPathInfo = "/api/users"
                  , rawQueryString = "?limit=10"
                  , remoteHost = SockAddrInet 8080 (tupleToHostAddress (192,168,1,100))
                  }
      assertEqual "IPv4 request conversion" 
                 "GET /api/users?limit=10 from 192.168.1.100:8080"
                 (convertWAIRequest req)

  , testCase "convertWAIRequest IPv6 format" $ do
      let req = defaultRequest 
                  { requestMethod = "POST"
                  , rawPathInfo = "/login"
                  , rawQueryString = ""
                  , remoteHost = SockAddrInet6 443 0 (tupleToHostAddress6 (0x2001, 0x0db8, 0, 0, 0, 0, 0, 1)) 0
                  }
      assertEqual "IPv6 request conversion"
                 "POST /login from 2001:0db8:0000:0000:0000:0000:0000:0001:443"
                 (convertWAIRequest req)

  , testCase "Empty throttle name handling" $ do
      outputRef <- newIORef []
      let notifier = createTestNotifier outputRef
      
      notify notifier "" ("item" :: Text) 100
      
      output <- readIORef outputRef
      case output of
        [message] -> case parseLogMessage message of
          Just (_, content) ->
            assertEqual "Empty throttle name format"
                       "blocked \"item\" (limit: 100)"
                       content
          Nothing -> assertFailure $ "Invalid log format: " ++ T.unpack message
        _ -> assertFailure $ "Expected exactly one message, got: " ++ show (length output)

  , testCase "Empty query string handling" $ do
      let req = defaultRequest 
                  { requestMethod = "DELETE"
                  , rawPathInfo = "/resource/123"
                  , rawQueryString = ""
                  , remoteHost = SockAddrInet 9000 (tupleToHostAddress (127,0,0,1))
                  }
      assertEqual "Empty query string conversion"
                 "DELETE /resource/123 from 127.0.0.1:9000"
                 (convertWAIRequest req)

  , testCase "No-op notifier silence" $ do
      -- Test that no-op notifiers complete without side effects
      notify noopNotifier "test" ("data" :: Text) 1
      noopWAINotifier "test" defaultRequest 1
      -- If we reach here, both completed successfully
      assertBool "No-op notifiers should complete silently" True

  , testCase "Console notifiers exist and are callable" $ do
      -- Test that console notifiers can be referenced without crashing
      -- We don't actually call them to avoid polluting test output
      let _ = consoleNotifier
          _ = consoleWAINotifier
      assertBool "Console notifiers should be accessible" True
  ]