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
]