packages feed

wai-middleware-delegate-0.1.4.0: test/IntegrationTest.hs

{-# LANGUAGE DataKinds #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TypeApplications #-}
{-# LANGUAGE TypeFamilies #-}

module Main where

import Control.Monad (when)
import Data.ByteString (ByteString)
import qualified Data.ByteString.Char8 as C8
import Data.Default (Default (..))
import Data.Foldable (for_)
import Data.Proxy (Proxy (..))
import Data.Text (Text)
import qualified Data.Text as Text
import Data.Text.Encoding (encodeUtf8)
import qualified Network.HTTP.Client as HC
import Network.HTTP.Client.TLS (newTlsManager)
import Network.HTTP.Types (status500, statusCode)
import Network.Wai
  ( Application
  , rawPathInfo
  , responseLBS
  )
import Network.Wai.Handler.Warp (Port, testWithApplication)
import Network.Wai.Middleware.Delegate
  ( ProxySettings (..)
  , delegateToProxy
  )
import System.Environment (lookupEnv)
import System.IO
import Test.Fetch (fetch)
import Test.Hspec
import Test.Hspec.TmpProc
  ( HList (..)
  , HostIpAddress
  , Pinged (..)
  , Proc (..)
  , ProcHandle
  , SvcURI
  , hAddr
  , handleOf
  , startupAll
  , tdescribe
  , terminateAll
  , toPinged
  , (&:)
  )
import qualified Test.Hspec.TmpProc as TmpProc
import Test.HttpReply
import Test.TestRequests
  ( RequestBuilder (..)
  , buildRequest
  , testNotProxiedRequests
  , testOverRedirectedRequests
  , testRequests
  )
import Test.WithExtras
  ( defaultTlsSettings
  , testWithTLSApplication
  )


defaultTestSettings :: ProxySettings
defaultTestSettings = def {proxyHost = "httpbin.org", proxyTimeout = 2}


redirectTestSettings :: ProxySettings
redirectTestSettings = defaultTestSettings {proxyRedirectCount = 2}


tmpHostSettings :: ByteString -> ProxySettings -> ProxySettings
tmpHostSettings tmpHost settings = settings {proxyHost = tmpHost}


main :: IO ()
main = do
  hSetBuffering stdin NoBuffering
  hSetBuffering stdout NoBuffering
  dumpDebug' <- lookupEnv "DEBUG"
  let dumpDebug = maybe False (const True) dumpDebug'
  hspec $ tdescribe "when accessing http-bin in docker" $ do
    -- TODO: reenable when a secure proxy is added to the docker tests
    -- secureProxyTest dumpDebug
    -- secureNotProxiedTest dumpDebug
    insecureRedirectTest dumpDebug
    insecureNotProxiedTest dumpDebug
    insecureProxyTest dumpDebug


defaultTestDelegate :: ProxySettings -> IO Application
defaultTestDelegate s = do
  -- delegate everything but /status/418
  let handleFunnyStatus req = rawPathInfo req /= "/status/418"
      dummyApp _ respond = respond $ responseLBS status500 [] "I should have been proxied"

  manager <- newTlsManager
  return $ delegateToProxy s manager handleFunnyStatus dummyApp


toHost :: HttpBinFixture -> ByteString
toHost fixture = encodeUtf8 $ hAddr $ handleOf @"tmp-http-bin" Proxy fixture


fixtureApp :: HttpBinFixture -> IO Application
fixtureApp fixture = defaultTestDelegate $ tmpHostSettings (toHost fixture) defaultTestSettings


redirectApp :: HttpBinFixture -> IO Application
redirectApp fixture = defaultTestDelegate $ tmpHostSettings (toHost fixture) redirectTestSettings


testWithInsecureProxy' :: ((HttpBinFixture, Port) -> IO a) -> IO a
testWithInsecureProxy' = TmpProc.testWithApplication onlyHttpBin fixtureApp


testWithSecureProxy' :: ((HttpBinFixture, Port) -> IO a) -> IO a
testWithSecureProxy' action = do
  tls <- defaultTlsSettings
  TmpProc.testWithTLSApplication tls onlyHttpBin fixtureApp action


testWithInsecureProxy :: (Port -> IO ()) -> IO ()
testWithInsecureProxy = testWithApplication (defaultTestDelegate defaultTestSettings)


testWithSecureProxy :: (Port -> IO ()) -> IO ()
testWithSecureProxy withPort = do
  tls <- defaultTlsSettings
  testWithTLSApplication tls (defaultTestDelegate defaultTestSettings) withPort


testWithInsecureRedirects' :: ((HttpBinFixture, Port) -> IO ()) -> IO ()
testWithInsecureRedirects' = TmpProc.testWithApplication onlyHttpBin redirectApp


tmpHostBuilder :: HttpBinFixture -> RequestBuilder -> RequestBuilder
tmpHostBuilder fixture builder = builder {rbHost = toHost fixture}


onDirectAndProxy :: (HttpReply -> HttpReply -> IO ()) -> Bool -> Int -> RequestBuilder -> IO ()
onDirectAndProxy f debug testProxyPort builder = do
  let proxiedBuilder = builder {rbHost = "localhost", rbPort = Just testProxyPort}
  directReq <- buildRequest builder
  proxiedReq <- buildRequest proxiedBuilder

  when debug $ do
    putStrLn "---------------"
    putStrLn "Direct Request:"
    putStrLn "---------------"
    print directReq
    putStrLn "----------------"
    putStrLn "Proxied Request:"
    putStrLn "----------------"
    print proxiedReq
  proxied <- fetch proxiedReq
  direct <- fetch directReq
  when debug $ do
    putStrLn "Direct:"
    putStrLn "-------"
    print direct
    putStrLn "Proxied:"
    putStrLn "--------"
    print proxied
  f direct proxied


insecureNotProxiedTest :: Bool -> Spec
insecureNotProxiedTest debug =
  let scheme = "HTTP"
      desc = "Proxy on " ++ scheme ++ " should fail"
      assertNeq = onDirectAndProxy assertHttpRepliesDiffer debug
   in aroundAll testWithInsecureProxy' $ describe desc $ do
        for_ testNotProxiedRequests $ \(title, modifier) -> do
          let shouldNotMatch (f, p) = assertNeq p $ modifier $ tmpHostBuilder f def
          it (scheme ++ " " ++ title) shouldNotMatch


insecureRedirectTest :: Bool -> Spec
insecureRedirectTest debug =
  let scheme = "HTTP"
      desc = "Proxy over " ++ scheme ++ " with too many redirects differs"
      assertNeq = onDirectAndProxy assertHttpRepliesDiffer debug
   in aroundAll testWithInsecureRedirects' $ describe desc $ do
        for_ testOverRedirectedRequests $ \(title, modifier) -> do
          let shouldNotMatch (f, p) = assertNeq p $ modifier $ tmpHostBuilder f def
          it (scheme ++ " " ++ title) shouldNotMatch


insecureProxyTest :: Bool -> Spec
insecureProxyTest debug =
  let scheme = "HTTP"
      desc = "Simple " ++ scheme ++ " proxying:"
      assertEq = onDirectAndProxy assertHttpRepliesAreEq debug
   in aroundAll testWithInsecureProxy' $ describe desc $ do
        for_ testRequests $ \(title, modifier) -> do
          let shouldMatch (f, p) = assertEq p $ modifier $ tmpHostBuilder f def
          it (scheme ++ " " ++ title) shouldMatch


secureNotProxiedTest :: Bool -> Spec
secureNotProxiedTest debug =
  let scheme = "HTTPS"
      desc = "Proxy on " ++ scheme ++ " should fail"
      assertNeq = onDirectAndProxy assertHttpRepliesDiffer debug
      def' = def {rbSecure = True}
   in aroundAll testWithSecureProxy' $ describe desc $ do
        for_ testNotProxiedRequests $ \(title, modifier) -> do
          let shouldNotMatch (f, p) = assertNeq p $ modifier $ tmpHostBuilder f def'
          it (scheme ++ " " ++ title) shouldNotMatch


secureProxyTest :: Bool -> Spec
secureProxyTest debug =
  let scheme = "HTTPS"
      desc = "Simple " ++ scheme ++ " proxying:"
      assertEq = onDirectAndProxy assertHttpRepliesAreEq debug
      def' = def {rbSecure = True}
   in aroundAll testWithSecureProxy' $ describe desc $ do
        for_ testRequests $ \(title, modifier) -> do
          let shouldMatch (f, p) = assertEq p $ modifier $ tmpHostBuilder f def'
          it (scheme ++ " " ++ title) shouldMatch


-- | A data type representing a connection to a HttpBin server.
data HttpBin = HttpBin


type HttpBinFixture = HList '[ProcHandle HttpBin]


onlyHttpBin :: HList '[HttpBin]
onlyHttpBin = HttpBin &: HNil


setupHttpBin :: IO HttpBinFixture
setupHttpBin = startupAll onlyHttpBin


withHttpBin :: SpecWith HttpBinFixture -> Spec
withHttpBin = beforeAll setupHttpBin . afterAll terminateAll


-- | Run HttpBin using tmp-proc.
instance Proc HttpBin where
  type Image HttpBin = "kennethreitz/httpbin"
  type Name HttpBin = "tmp-http-bin"


  uriOf = mkUri'
  runArgs = []
  reset _ = pure ()
  ping = ping'


-- | Make a uri access the http-bin server.
mkUri' :: HostIpAddress -> SvcURI
mkUri' ip = "http://" <> C8.pack (Text.unpack ip) <> "/"


ping' :: ProcHandle a -> IO Pinged
ping' handle = toPinged @HC.HttpException Proxy $ do
  gotStatus <- handleGet handle "/status/200"
  if gotStatus == 200 then pure OK else pure NotOK


-- | Determine the status from a Get on localhost.
handleGet :: ProcHandle a -> Text -> IO Int
handleGet handle urlPath = do
  let theUri = "http://" <> hAddr handle <> "/" <> Text.dropWhile (== '/') urlPath
  manager <- HC.newManager HC.defaultManagerSettings
  getReq <- HC.parseRequest $ Text.unpack theUri
  statusCode . HC.responseStatus <$> HC.httpLbs getReq manager