keter-2.3.0: test/Keter/Proxy/MiddlewareSpec.hs
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE NumericUnderscores #-}
{-# LANGUAGE TypeApplications #-}
module Keter.Proxy.MiddlewareSpec (tests) where
import Control.Concurrent (forkIO, threadDelay)
import Control.Exception (try)
import Control.Lens ((&), (.~), (^.))
import Control.Monad (void)
import Control.Monad.Logger
import Control.Monad.Reader
import Data.Aeson (eitherDecode)
import Data.ByteString (ByteString)
import qualified Data.ByteString.Lazy.Char8 as LBS
import Keter.Config.Middleware (MiddlewareConfig, processMiddlewareIO)
import Keter.Config.V10
import Keter.Context
import Keter.Proxy
import Network.HTTP.Conduit (Manager)
import qualified Network.HTTP.Conduit as HTTP
import qualified Network.TLS as TLS (Credentials)
import Network.HTTP.Types.Status (ok200, statusCode)
import qualified Network.Wai as Wai
import qualified Network.Wai.Handler.Warp as Warp
import qualified Network.Wreq as Wreq
import Test.Tasty
import Test.Tasty.HUnit
tests :: TestTree
tests = testGroup "Keter.Proxy integration (middleware smoke tests)"
[ testCase "Rate-limit via PAPort (FixedWindow, ip-from-header=False)" caseRateLimitFixedWindow
, testCase "ip-from-header=True uses X-Forwarded-For" caseIpFromHeaderTrue
, testCase "Healthcheck bypasses middleware" caseHealthcheckBypass
, testCase "Proxy exception body on backend down" caseProxyExceptionBody
]
-- Helpers
startBackend :: Int -> IO ()
startBackend port = void . forkIO $
Warp.run port $ \req respond -> do
-- Drain body so HEAD/POST sequences don't leave unread input
void $ Wai.strictRequestBody req
respond $ Wai.responseLBS ok200 [] "ok"
runProxyOn :: Manager -> ProxySettings -> Int -> IO ()
runProxyOn _ settings listenPort = void . forkIO $
flip runReaderT settings $
flip runLoggingT (\_ _ _ _ -> pure ()) $ -- silence logs in tests
runKeterM $
reverseProxy (LPInsecure "*" listenPort)
mkSettings
:: Manager
-> (ByteString -> IO (Maybe (ProxyAction, TLS.Credentials))) -- Updated signature
-> Bool -- ip-from-header
-> Maybe ByteString -- healthcheck-path
-> ByteString -- proxy-exception body
-> IO ProxySettings
mkSettings manager hostLookup useHeader mHealth exBody = do
pure $ MkProxySettings
{ psHostLookup = hostLookup
, psManager = manager
, psTrustForwardedFor = \_ -> True
, psUnknownHost = const ""
, psMissingHost = ""
, psProxyException = exBody
, psIpFromHeader = useHeader
, psConnectionTimeBound = 5 * 60 * 1000
, psHealthcheckPath = mHealth
}
decodeMids :: LBS.ByteString -> [MiddlewareConfig]
decodeMids lbs =
case eitherDecode lbs of
Right xs -> xs
Left e -> error ("Failed to decode middleware config: " <> e)
-- Test cases
-- 1) Rate-limit via PAPort (FixedWindow)
caseRateLimitFixedWindow :: IO ()
caseRateLimitFixedWindow = do
let backendPort = 6791
proxyPort = 6790
midsJSON = LBS.pack $ concat
[ "[{ \"rate-limiter\": {"
, " \"zone_by\":\"default\","
, " \"throttles\":[{"
, " \"name\":\"ip\","
, " \"limit\":2,"
, " \"period\":60,"
, " \"algorithm\":\"FixedWindow\","
, " \"identifier_by\":\"ip\""
, " }]"
, "}}]"
]
mids = decodeMids midsJSON
_ <- forkIO $ startBackend backendPort
manager <- HTTP.newManager HTTP.tlsManagerSettings
-- Compile middleware at setup time (like App.hs does)
compiledMW <- processMiddlewareIO mids
let host = "rl.test"
let hostLookup :: ByteString -> IO (Maybe (ProxyAction, TLS.Credentials))
hostLookup _ =
-- PAPort now contains compiled middleware
return $ Just ((PAPort backendPort compiledMW Nothing, False), mempty)
settings <- mkSettings manager hostLookup False Nothing "proxy error"
runProxyOn manager settings proxyPort
threadDelay 200_000
let base = "http://127.0.0.1:" <> show proxyPort <> "/"
req = Wreq.defaults & Wreq.header "Host" .~ [host]
r1 <- Wreq.getWith req base
r2 <- Wreq.getWith req base
-- For the third request, we expect a 429, so we need to handle the exception
r3Result <- try $ Wreq.getWith req base
r1 ^. Wreq.responseStatus . Wreq.statusCode @?= 200
r2 ^. Wreq.responseStatus . Wreq.statusCode @?= 200
case r3Result of
Left (HTTP.HttpExceptionRequest _ (HTTP.StatusCodeException resp _)) ->
statusCode (HTTP.responseStatus resp) @?= 429
Left e -> assertFailure $ "Unexpected exception: " <> show e
Right r3 -> r3 ^. Wreq.responseStatus . Wreq.statusCode @?= 429
-- 2) ip-from-header=True: X-Forwarded-For identifies client
caseIpFromHeaderTrue :: IO ()
caseIpFromHeaderTrue = do
let backendPort = 6793
proxyPort = 6792
midsJSON = LBS.pack $ concat
[ "[{ \"rate-limiter\": {"
, " \"zone_by\":\"default\","
, " \"throttles\":[{"
, " \"name\":\"ip\","
, " \"limit\":1,"
, " \"period\":60,"
, " \"algorithm\":\"FixedWindow\","
, " \"identifier_by\":\"ip\""
, " }]"
, "}}]"
]
mids = decodeMids midsJSON
_ <- forkIO $ startBackend backendPort
manager <- HTTP.newManager HTTP.tlsManagerSettings
compiledMW <- processMiddlewareIO mids
let host = "xff.test"
let hostLookup _ = return $ Just ((PAPort backendPort compiledMW Nothing, False), mempty)
settings <- mkSettings manager hostLookup True Nothing "proxy error"
runProxyOn manager settings proxyPort
threadDelay 200_000
let base = "http://127.0.0.1:" <> show proxyPort <> "/"
req xff =
Wreq.defaults
& Wreq.header "Host" .~ [host]
& Wreq.header "X-Forwarded-For" .~ [xff]
r1 <- Wreq.getWith (req "1.1.1.1") base
-- For the second request, we expect a 429, so we need to handle the exception
r2Result <- try $ Wreq.getWith (req "1.1.1.1") base
r3 <- Wreq.getWith (req "2.2.2.2") base
r1 ^. Wreq.responseStatus . Wreq.statusCode @?= 200
case r2Result of
Left (HTTP.HttpExceptionRequest _ (HTTP.StatusCodeException resp _)) ->
statusCode (HTTP.responseStatus resp) @?= 429
Left e -> assertFailure $ "Unexpected exception: " <> show e
Right r2 -> r2 ^. Wreq.responseStatus . Wreq.statusCode @?= 429
r3 ^. Wreq.responseStatus . Wreq.statusCode @?= 200
-- 3) Healthcheck bypass
caseHealthcheckBypass :: IO ()
caseHealthcheckBypass = do
let backendPort = 6795
proxyPort = 6794
_ <- forkIO $ startBackend backendPort
manager <- HTTP.newManager HTTP.tlsManagerSettings
-- No middleware for this test - use id middleware
idMW <- processMiddlewareIO []
let host = "hc.test"
let hostLookup _ = return $ Just ((PAPort backendPort idMW Nothing, False), mempty)
settings <- mkSettings manager hostLookup False (Just "/keter-health") "proxy error"
runProxyOn manager settings proxyPort
threadDelay 200_000
let base = "http://127.0.0.1:" <> show proxyPort <> "/keter-health"
req = Wreq.defaults & Wreq.header "Host" .~ [host]
r <- Wreq.getWith req base
r ^. Wreq.responseStatus . Wreq.statusCode @?= 200
-- 4) Proxy exception body when backend is down
caseProxyExceptionBody :: IO ()
caseProxyExceptionBody = do
let proxyPort = 6796
manager <- HTTP.newManager HTTP.tlsManagerSettings
-- No middleware for this test
idMW <- processMiddlewareIO []
let host = "down.test"
exBody = "my branded proxy error"
let hostLookup _ = return $ Just ((PAPort 59999 idMW Nothing, False), mempty) -- no backend here
settings <- mkSettings manager hostLookup False Nothing exBody
runProxyOn manager settings proxyPort
threadDelay 200_000
let base = "http://127.0.0.1:" <> show proxyPort <> "/"
req = Wreq.defaults & Wreq.header "Host" .~ [host]
-- We expect a 502, so we need to handle the exception
rResult <- try $ Wreq.getWith req base
case rResult of
Left (HTTP.HttpExceptionRequest _ (HTTP.StatusCodeException resp _)) ->
statusCode (HTTP.responseStatus resp) @?= 502
Left e -> assertFailure $ "Unexpected exception: " <> show e
Right r -> r ^. Wreq.responseStatus . Wreq.statusCode @?= 502