packages feed

snap-server-1.1.2.1: test/Test/Blackbox.hs

{-# LANGUAGE BangPatterns        #-}
{-# LANGUAGE CPP                 #-}
{-# LANGUAGE OverloadedStrings   #-}
{-# LANGUAGE RankNTypes          #-}
{-# LANGUAGE ScopedTypeVariables #-}

module Test.Blackbox
  ( tests
  , haTests
  , ssltests
  , startTestServers
  ) where

--------------------------------------------------------------------------------
import           Control.Applicative                  ((<$>))
import           Control.Arrow                        (first)
import           Control.Concurrent                   (MVar, ThreadId, forkIO, forkIOWithUnmask, killThread, newEmptyMVar, putMVar, takeMVar, threadDelay, tryPutMVar)
import           Control.Exception                    (bracket, bracketOnError, finally, mask_)
import           Control.Monad                        (forM_, forever, void, when)
import qualified Data.ByteString.Base16               as B16
import           Data.ByteString.Builder              (byteString)
import           Data.ByteString.Char8                (ByteString)
import qualified Data.ByteString.Char8                as S
import qualified Data.ByteString.Lazy.Char8           as L
import           Data.CaseInsensitive                 (CI)
import qualified Data.CaseInsensitive                 as CI
import           Data.List                            (sort)
import           Data.Monoid                          (Monoid (mconcat, mempty))
import qualified Network.Http.Client                  as HTTP
import qualified Network.Http.Types                   as HTTP
import qualified Network.Socket                       as N
import qualified Network.Socket.ByteString            as NB
import           Prelude                              (Bool (..), Eq (..), IO, Int, Maybe (..), Show (..), String, concat, concatMap, const, dropWhile, elem, flip, fromIntegral, fst, head, id, map, mapM_, maybe, min, not, null, otherwise, putStrLn, replicate, return, reverse, uncurry, ($), ($!), (*), (++), (.), (^))
import qualified Prelude
------------------------------------------------------------------------------
#ifdef OPENSSL
import qualified OpenSSL.Session                      as SSL
#endif
import qualified System.IO.Streams                    as Streams
import           System.Timeout                       (timeout)
import           Test.Framework                       (Test, TestOptions' (topt_maximum_generated_tests), plusTestOptions)
import           Test.Framework.Providers.HUnit       (testCase)
import           Test.Framework.Providers.QuickCheck2 (testProperty)
import           Test.HUnit                           hiding (Test, path)
import           Test.QuickCheck                      (Arbitrary (arbitrary))
import           Test.QuickCheck.Monadic              (forAllM, monadicIO)
import qualified Test.QuickCheck.Monadic              as QC
import qualified Test.QuickCheck.Property             as QC
------------------------------------------------------------------------------
import           Snap.Internal.Debug                  (debug)
import           Snap.Internal.Http.Server.Session    (httpAcceptLoop, snapToServerHandler)
import qualified Snap.Internal.Http.Server.Socket     as Sock
import qualified Snap.Internal.Http.Server.TLS        as TLS
import qualified Snap.Internal.Http.Server.Types      as Types
import           Snap.Test.Common                     (ditchHeaders, eatException, expectExceptionBeforeTimeout, recvAll, timeoutIn, withSock)
import           Test.Common.Rot13                    (rot13)
import           Test.Common.TestHandler              (testHandler)


------------------------------------------------------------------------------
tests :: Int -> [Test]
tests port = map (\f -> f False port "") testFunctions

ssltests :: Maybe Int -> [Test]
ssltests = maybe [] httpsTests
    where httpsTests port = map (\f -> f True port sslname) testFunctions
          sslname = "ssl/"

haTests :: Int -> [Test]
haTests port = [ testHaProxy port
               , testHaProxyLocal port
               , testHaProxyFileServe port
               ]

testFunctions :: [Bool -> Int -> String -> Test]
testFunctions = [ testPong
-- FIXME: waiting on http-enumerator patch for HEAD behaviour
--                , testHeadPong
                , testEcho
                , testRot13
                , testSlowLoris
                , testBlockingRead
                , testBigResponse
                , testPartial
                , testFileUpload
                , testTimeoutTickle
                , testHasDateHeader
                , testServerHeader
                , testFileServe
                , testTimelyRedirect
                , testChunkedHead
                ]

------------------------------------------------------------------------------
startServer :: Types.ServerConfig hookState
            -> IO a
            -> (a -> N.Socket)
            -> (a -> Types.AcceptFunc)
            -> IO (ThreadId, Int, MVar ())
startServer config bind projSock afunc =
    bracketOnError bind (N.close . projSock) forkServer
  where
    forkServer a = do
        mv <- newEmptyMVar
        port <- fromIntegral <$> N.socketPort (projSock a)
        tid <- forkIO $
               eatException $
               (httpAcceptLoop (snapToServerHandler testHandler)
                               config
                               (afunc a)
                  `finally` putMVar mv ())
        return (tid, port, mv)


------------------------------------------------------------------------------
-- | Returns the thread the server is running in as well as the port it is
-- listening on.
data TestServerType = NormalTest | ProxyTest | SSLTest
  deriving (Show)

startTestSocketServer :: TestServerType -> IO (ThreadId, Int, MVar ())
startTestSocketServer serverType = do
  putStrLn $ "Blackbox: starting " ++ show serverType ++ " server"
  case serverType of
    NormalTest -> startServer emptyServerConfig bindSock id Sock.httpAcceptFunc
    ProxyTest  -> startServer emptyServerConfig bindSock id Sock.haProxyAcceptFunc
    SSLTest    -> startServer emptyServerConfig bindSSL fst
                              (uncurry TLS.httpsAcceptFunc)
  where
#if MIN_VERSION_network(2,7,0)
    anyport = N.defaultPort
#else
    anyport = N.aNY_PORT
#endif

    bindSSL = do
        sockCtx <- TLS.bindHttps "127.0.0.1"
                                 (fromIntegral anyport)
                                 "test/cert.pem"
                                 False
                                 "test/key.pem"
#ifdef OPENSSL
        -- Set client code not to verify
        HTTP.modifyContextSSL $ \ctx -> do
            SSL.contextSetVerificationMode ctx SSL.VerifyNone
            return ctx
#endif
        return sockCtx

    bindSock = Sock.bindSocket "127.0.0.1" (fromIntegral anyport)

    logAccess !_ !_ !_             = return ()
    logError !_                    = return ()
    onStart !_                     = return ()
    onParse !_ !_                  = return ()
    onUserHandlerFinished !_ !_ !_ = return ()
    onDataFinished !_ !_ !_        = return ()
    onExceptionHook !_ !_          = return ()
    onEscape !_                    = return ()

    emptyServerConfig = Types.ServerConfig logAccess
                                           logError
                                           onStart
                                           onParse
                                           onUserHandlerFinished
                                           onDataFinished
                                           onExceptionHook
                                           onEscape
                                           "localhost"
                                           6
                                           False
                                           1


------------------------------------------------------------------------------
waitabit :: IO ()
waitabit = threadDelay $ 2*seconds


------------------------------------------------------------------------------
seconds :: Int
seconds = (10::Int) ^ (6::Int)


------------------------------------------------------------------------------
fetch :: ByteString -> IO ByteString
fetch url = HTTP.get url HTTP.concatHandler'


------------------------------------------------------------------------------
fetchWithHeaders :: ByteString
                 -> IO (ByteString, [(CI ByteString, ByteString)])
fetchWithHeaders url = HTTP.get url h
  where
    h resp is = do
        let hdrs = map (first CI.mk) $ HTTP.retrieveHeaders $ HTTP.getHeaders resp
        body <- HTTP.concatHandler' resp is
        return (body, hdrs)


------------------------------------------------------------------------------
slowTestOptions :: Bool -> TestOptions' Maybe
slowTestOptions ssl =
    if ssl
      then mempty { topt_maximum_generated_tests = Just 75 }
      else mempty { topt_maximum_generated_tests = Just 300 }


------------------------------------------------------------------------------
-- FIXME: waiting on http-enumerator patch for HEAD behaviour
-- headPong :: Bool -> Int -> IO ByteString
-- headPong ssl port = do
--     let uri = (if ssl then "https" else "http")
--               ++ "://127.0.0.1:" ++ show port ++ "/echo"

--     req0 <- HTTP.parseUrl uri

--     let req = req0 { HTTP.method = "HEAD" }
--     rsp <- HTTP.httpLbs req
--     return $ S.concat $ L.toChunks $ HTTP.responseBody rsp


------------------------------------------------------------------------------
-- FIXME: waiting on http-enumerator patch for HEAD behaviour
-- testHeadPong :: Bool -> Int -> String -> Test
-- testHeadPong ssl port name = testCase (name ++ "blackbox/pong/HEAD") $ do
--     doc <- headPong ssl port
--     assertEqual "pong HEAD response" "" doc


------------------------------------------------------------------------------
-- TODO: doesn't work w/ ssl
testBlockingRead :: Bool -> Int -> String -> Test
testBlockingRead ssl port name =
    testCase (name ++ "blackbox/testBlockingRead") $
             if ssl then return () else runIt

  where
    runIt = withSock port $ \sock -> do
        m <- timeout (60*seconds) $ go sock
        maybe (assertFailure "timeout")
              (const $ return ())
              m

    go sock = do
        NB.sendAll sock "GET /"
        waitabit
        NB.sendAll sock "pong HTTP/1.1\r\n"
        NB.sendAll sock "Host: 127.0.0.1\r\n"
        NB.sendAll sock "Content-Length: 0\r\n"
        NB.sendAll sock "Connection: close\r\n\r\n"

        resp <- recvAll sock

        let s = head $ ditchHeaders $ S.lines resp

        assertEqual "pong response" "PONG" s


------------------------------------------------------------------------------
-- TODO: this one doesn't work w/ SSL
testSlowLoris :: Bool -> Int -> String -> Test
testSlowLoris ssl port name = testCase (name ++ "blackbox/slowloris") $
                              if ssl then return () else withSock port go

  where
    go sock = do
        NB.sendAll sock "POST /echo HTTP/1.1\r\n"
        NB.sendAll sock "Host: 127.0.0.1\r\n"
        NB.sendAll sock "Content-Length: 2500000\r\n"
        NB.sendAll sock "Connection: close\r\n\r\n"

        b <- expectExceptionBeforeTimeout (loris sock) 30

        assertBool "didn't catch slow loris attack" b

    loris sock = forever $ do
        NB.sendAll sock "."
        waitabit


------------------------------------------------------------------------------
testRot13 :: Bool -> Int -> String -> Test
testRot13 ssl port name =
    plusTestOptions (slowTestOptions ssl) $
    testProperty (name ++ "blackbox/rot13") $
    monadicIO $ forAllM arbitrary prop
  where
    prop txt = do
        let uri = (if ssl then "https" else "http")
                  ++ "://127.0.0.1:" ++ show port ++ "/rot13"

        doc <- QC.run $ HTTP.post (S.pack uri) "text/plain"
                                  (Streams.write (Just $ byteString txt))
                                  HTTP.concatHandler'
        QC.assert $ txt == rot13 doc


------------------------------------------------------------------------------
doPong :: Bool -> Int -> IO ByteString
doPong ssl port = do
    debug "getting URI"
    let !uri = (if ssl then "https" else "http")
               ++ "://127.0.0.1:" ++ show port ++ "/pong"
    debug $ "URI is: '" ++ uri ++ "', calling simpleHttp"

    rsp <- fetch $ S.pack uri

    debug $ "response was " ++ show rsp
    return rsp


------------------------------------------------------------------------------
testPong :: Bool -> Int -> String -> Test
testPong ssl port name = testCase (name ++ "blackbox/pong") $ do
    doc <- doPong ssl port
    assertEqual "pong response" "PONG" doc


------------------------------------------------------------------------------
testHasDateHeader :: Bool -> Int -> String -> Test
testHasDateHeader ssl port name = testCase (name ++ "blackbox/hasDateHdr") $ do
    let !url = (if ssl then "https" else "http") ++ "://127.0.0.1:" ++ show port
                   ++ "/pong"
    (rsp, hdrs) <- fetchWithHeaders $ S.pack url

    let hasDate = "date" `elem` map fst hdrs
    when (not hasDate) $ do
        putStrLn "server not sending dates:"
        forM_ hdrs $ \(k,v) -> S.putStrLn $ S.concat [CI.original k, ": ", v]
    assertBool "has date" hasDate
    assertEqual "pong response" "PONG" rsp


------------------------------------------------------------------------------
testChunkedHead :: Bool -> Int -> String -> Test
testChunkedHead ssl port name = testCase (name ++ "blackbox/chunkedHead") $
                                if ssl then return () else withSock port go
  where
    go sock = do
        NB.sendAll sock $ S.concat [ "HEAD /chunked HTTP/1.1\r\n"
                                   , "Host: localhost\r\n"
                                   , "\r\n"
                                   ]
        s <- NB.recv sock 4096
        assertBool (concat [ "no body: received '"
                           , S.unpack s
                           , "'" ]) $ isOK s

    split x l | S.null x  = reverse l
              | otherwise = let (a, b) = S.break (== '\r') x
                                b'     = S.drop 2 b
                            in split b' (a : l)

    isOK s = let lns  = split s []
                 lns' = Prelude.drop 1 $ dropWhile (not . S.null) lns
             in null lns'


------------------------------------------------------------------------------
-- TODO: no ssl here
-- test server's ability to trap/recover from IO errors
testPartial :: Bool -> Int -> String -> Test
testPartial ssl port name =
    testCase (name ++ "blackbox/testPartial") $
    if ssl then return () else runIt

  where
    runIt = do
        m <- timeout (60*seconds) go
        maybe (assertFailure "timeout")
              (const $ return ())
              m

    go = do
        withSock port $ \sock ->
            NB.sendAll sock "GET /pong HTTP/1.1\r\n"

        doc <- doPong ssl port
        assertEqual "pong response" "PONG" doc


------------------------------------------------------------------------------
-- TODO: no ssl here
-- test server's ability to trap/recover from IO errors
testTimelyRedirect :: Bool -> Int -> String -> Test
testTimelyRedirect ssl port name =
    testCase (name ++ "blackbox/testTimelyRedirect") $
    if ssl then return () else runIt

  where
    runIt = do
        m <- timeout (5*seconds) go
        maybe (assertFailure "timeout")
              (const $ return ())
              m

    go = do
        withSock port $ \sock -> do
            NB.sendAll sock $ S.concat [ "GET /redirect HTTP/1.1\r\n"
                                      , "Host: localhost\r\n\r\n" ]
            resp <- NB.recv sock 100000
            assertBool "wasn't code 302" $ S.isInfixOf "302" resp
            assertBool "didn't have content length" $
                S.isInfixOf "content-length: 0" resp


------------------------------------------------------------------------------
-- TODO: no ssl
testBigResponse :: Bool -> Int -> String -> Test
testBigResponse ssl port name =
    testCase (name ++ "blackbox/testBigResponse") $
    if ssl then return () else runIt
  where
    runIt = withSock port $ \sock -> do
        m <- timeout (120*seconds) $ go sock
        maybe (assertFailure "timeout")
              (const $ return ())
              m

    go sock = do
        NB.sendAll sock "GET /bigresponse HTTP/1.1\r\n"
        NB.sendAll sock "Host: 127.0.0.1\r\n"
        NB.sendAll sock "Content-Length: 0\r\n"
        NB.sendAll sock "Connection: close\r\n\r\n"

        let body = S.replicate 4000000 '.'
        resp <- recvAll sock

        let s = head $ ditchHeaders $ S.lines resp

        assertBool "big response" $ body == s


------------------------------------------------------------------------------
testHaProxy :: Int -> Test
testHaProxy port = testCase "blackbox/haProxy" runIt

  where
    runIt = withSock port $ \sock -> do
        m <- timeout (120*seconds) $ go sock
        maybe (assertFailure "timeout")
              (const $ return ())
              m

    go sock = do
        NB.sendAll sock $ S.concat
              [ "PROXY TCP4 1.2.3.4 5.6.7.8 1234 5678\r\n"
              , "GET /remoteAddrPort HTTP/1.1\r\n"
              , "Host: 127.0.0.1\r\n"
              , "Content-Length: 0\r\n"
              , "Connection: close\r\n\r\n"
              ]

        resp <- recvAll sock

        let s = head $ ditchHeaders $ S.lines resp

        when (s /= "1.2.3.4:1234") $ S.putStrLn s
        assertEqual "haproxy response" "1.2.3.4:1234" s


------------------------------------------------------------------------------
testHaProxyFileServe :: Int -> Test
testHaProxyFileServe port = testCase "blackbox/haProxyFileServe" runIt
  where
    runIt = withSock port $ \sock -> do
        m <- timeout (120*seconds) $ go sock
        maybe (assertFailure "timeout")
              (const $ return ())
              m

    go sock = do
        NB.sendAll sock $ S.concat
              [ "PROXY UNKNOWN\r\n"
              , "GET /fileserve/hello.txt HTTP/1.1\r\n"
              , "Host: 127.0.0.1\r\n"
              , "Content-Length: 0\r\n"
              , "Connection: close\r\n\r\n"
              ]

        resp <- recvAll sock

        let s = head $ ditchHeaders $ S.lines resp

        assertEqual "haproxy response" "hello world" s


------------------------------------------------------------------------------
testHaProxyLocal :: Int -> Test
testHaProxyLocal port = testCase "blackbox/haProxyLocal" runIt

  where
#if MIN_VERSION_network(2,7,0)
    anyport = N.defaultPort
#else
    anyport = N.aNY_PORT
#endif

    remoteAddrServer :: N.Socket
                     -> MVar (Maybe String)
                     -> (forall a . IO a -> IO a)
                     -> IO ()
    remoteAddrServer ssock mvar restore =
      timeoutIn 10 $
      flip finally (tryPutMVar mvar Nothing) $
      bracket (restore $ N.accept ssock)
              (eatException . N.close . fst)
              (\(_, peer) -> putMVar mvar $! Just $! show peer)

    slurp p input = timeoutIn 10 $ withSock p
                      $ \sock -> do NB.sendAll sock input
                                    recvAll sock

    determineSourceInterfaceAddr =
        timeoutIn 10 $
        bracket
          (Sock.bindSocket "127.0.0.1" (fromIntegral anyport))
          (eatException . N.close)
          (\ssock -> do
             mv      <- newEmptyMVar
             svrPort <- fromIntegral <$> N.socketPort ssock
             bracket (mask_ $ forkIOWithUnmask $ remoteAddrServer ssock mv)
                     (eatException . killThread)
                     (const $ do void $ slurp svrPort ""
                                 (Just s) <- takeMVar mv
                                 return $! fst $ S.breakEnd (==':') $ S.pack s))

    runIt = do
        saddr <- determineSourceInterfaceAddr
        resp <- slurp port $ S.concat
                  [ "PROXY UNKNOWN\r\n"
                  , "GET /remoteAddrPort HTTP/1.1\r\n"
                  , "Host: 127.0.0.1\r\n"
                  , "Content-Length: 0\r\n"
                  , "Connection: close\r\n\r\n"
                  ]

        let s = head $ ditchHeaders $ S.lines resp

        when (not $ S.isPrefixOf saddr s) $ S.putStrLn s
        assertBool "haproxy response" $ S.isPrefixOf saddr s


------------------------------------------------------------------------------
-- This test checks two things:
--
-- 1. that the timeout tickling logic works
-- 2. that "flush" is passed along through a gzip operation.
testTimeoutTickle :: Bool -> Int -> String -> Test
testTimeoutTickle ssl port name =
    testCase (name ++ "blackbox/timeout/tickle") $ do
        let uri = (if ssl then "https" else "http")
                  ++ "://127.0.0.1:" ++ show port ++ "/timeout/tickle"
        doc <- fetch $ S.pack uri
        let expected = S.concat $ replicate 10 ".\n"
        assertEqual "response equal" expected doc


------------------------------------------------------------------------------
testFileServe :: Bool -> Int -> String -> Test
testFileServe ssl port name =
    testCase (name ++ "blackbox/fileserve") $ do
        let uri = (if ssl then "https" else "http")
                  ++ "://127.0.0.1:" ++ show port ++ "/fileserve/hello.txt"
        doc <- fetch $ S.pack uri
        let expected = "hello world\n"
        assertEqual "response equal" expected doc


------------------------------------------------------------------------------
testFileUpload :: Bool -> Int -> String -> Test
testFileUpload ssl port name =
    plusTestOptions (slowTestOptions ssl) $
    testProperty (name ++ "blackbox/upload") $
    QC.mapSize (if ssl then min 100 else min 300) $
    monadicIO $
    forAllM arbitrary prop
  where
    boundary = "boundary-jdsklfjdsalkfjadlskfjldskjfldskjfdsfjdsklfldksajfl"

    prefix = [ "--"
             , boundary
             , "\r\n"
             , "content-disposition: form-data; name=\"submit\"\r\n"
             , "\r\nSubmit\r\n" ]

    body kvps = L.concat $ prefix ++ concatMap part kvps ++ suffix
      where
        part (k,v) = [ "--"
                     , boundary
                     , "\r\ncontent-disposition: attachment; filename=\""
                     , k
                     , "\"\r\nContent-Type: text/plain\r\n\r\n"
                     , v
                     , "\r\n" ]

    suffix = [ "--", boundary, "--\r\n" ]

    hdrs = [ ("Content-type", S.concat $ [ "multipart/form-data; boundary=" ]
                                         ++ L.toChunks boundary) ]

    b16 (k,v) = (ne $ e k, e v)
      where
        ne s = if L.null s then "file" else s
        e s = L.fromChunks [ B16.encode $ S.concat $ L.toChunks s ]

    response kvps = L.concat $ [ "Param:\n"
                               , "submit\n"
                               , "Value:\n"
                               , "Submit\n\n" ] ++ concatMap responseKVP kvps

    responseKVP (k,v) = [ "File:\n"
                        , k
                        , "\nValue:\n"
                        , v
                        , "\n\n" ]

    prop kvps' = do
        let kvps = sort $ map b16 kvps'

        let uri = S.pack $ concat [ if ssl then "https" else "http"
                                  , "://127.0.0.1:"
                                  , show port
                                  , "/upload/handle" ]

        let txt = response kvps
        doc0 <- QC.run
                  $ HTTP.withConnection (HTTP.establishConnection uri)
                  $ \conn -> do
            req <- HTTP.buildRequest $ do
                HTTP.http HTTP.POST uri
                mapM_ (uncurry HTTP.setHeader) hdrs

            HTTP.sendRequest conn req (Streams.write $ Just
                                                     $ mconcat
                                                     $ map byteString
                                                     $ L.toChunks
                                                     $ body kvps)
            HTTP.receiveResponse conn HTTP.concatHandler'

        let doc = L.fromChunks [doc0]
        when (txt /= doc) $ QC.run $ do
                     L.putStrLn "expected:"
                     L.putStrLn "----------------------------------------"
                     L.putStrLn txt
                     L.putStrLn "----------------------------------------"
                     L.putStrLn "\ngot:"
                     L.putStrLn "----------------------------------------"
                     L.putStrLn doc
                     L.putStrLn "----------------------------------------"

        QC.assert $ txt == doc


------------------------------------------------------------------------------
testEcho :: Bool -> Int -> String -> Test
testEcho ssl port name =
    plusTestOptions (slowTestOptions ssl) $
    testProperty (name ++ "blackbox/echo") $
    QC.mapSize (if ssl then min 100 else min 300) $
    monadicIO $ forAllM arbitrary prop
  where
    prop txt = do
        let uri = (if ssl then "https" else "http")
                  ++ "://127.0.0.1:" ++ show port ++ "/echo"

        doc <- QC.run $ HTTP.post (S.pack uri) "text/plain"
                                  (Streams.write (Just $ byteString txt))
                                  HTTP.concatHandler'
        QC.assert $ txt == doc


------------------------------------------------------------------------------
testServerHeader :: Bool -> Int -> String -> Test
testServerHeader ssl port name =
    testCase (name ++ "blackbox/server-header") $ do
        let uri = (if ssl then "https" else "http")
                  ++ "://127.0.0.1:" ++ show port ++ "/server-header"
        HTTP.get (S.pack uri) $ \resp _ -> do
            let serverHeader = HTTP.getHeader resp "server"
            assertEqual "server header" (Just "foo") serverHeader


------------------------------------------------------------------------------
startTestServers :: IO ((ThreadId, Int, MVar ()),
                        (ThreadId, Int, MVar ()),
                        Maybe (ThreadId, Int, MVar ()))
startTestServers = do
    x <- startTestSocketServer NormalTest
    y <- startTestSocketServer ProxyTest
#ifdef OPENSSL
    z <- startTestSocketServer SSLTest
    return (x, y, Just z)
#else
    return (x, y, Nothing)
#endif