packages feed

warp-s2n-tls-0.1.0.0: test/TestUtils.hs

{-# LANGUAGE NumericUnderscores #-}

module TestUtils (
    withTestServer,
    makeSecureRequest,
    testCertPath,
    testKeyPath,
    loadTestCert,
    loadTestKey,
) where

-- import Control.Concurrent (threadDelay)
-- import Control.Concurrent.Async (race, withAsync)
-- import Control.Concurrent.MVar (newEmptyMVar, putMVar, takeMVar)

import Control.Applicative
import Control.Exception (SomeException, try)
import Data.ByteString (ByteString)
import Data.ByteString qualified as BS
import Data.ByteString.Lazy qualified as LBS
import Network.Connection qualified as Conn
import Network.HTTP.Types (status200)
import Network.Socket
import Network.Wai (Application, responseLBS)
import Network.Wai.Handler.Warp (Port, defaultSettings, setBeforeMainLoop)
import Network.Wai.Handler.WarpS2N (S2nTls, TLSSettings, runTLSSocketLib)
import UnliftIO.Async
import UnliftIO.Concurrent
import UnliftIO.STM

-- | Path to test server certificate
testCertPath :: FilePath
testCertPath = "test/certs/server.pem"

-- | Path to test server key
testKeyPath :: FilePath
testKeyPath = "test/certs/server-key.pem"

-- | Load test certificate as ByteString
loadTestCert :: IO ByteString
loadTestCert = BS.readFile testCertPath

-- | Load test key as ByteString
loadTestKey :: IO ByteString
loadTestKey = BS.readFile testKeyPath

-- | Simple echo application for testing
echoApp :: Application
echoApp _req respond = respond $ responseLBS status200 [] "blarg!"

-- | Run a test server and execute an action with its port.
withTestServer :: S2nTls -> TLSSettings -> (Port -> IO a) -> IO a
withTestServer tls tlsSet action = do
    -- Create and bind socket
    sock <- socket AF_INET Stream defaultProtocol
    setSocketOption sock ReuseAddr 1
    bind sock (SockAddrInet 0 (tupleToHostAddress (127, 0, 0, 1)))
    listen sock 5
    port <- fromIntegral <$> socketPort sock

    serverReady <- newEmptyTMVarIO
    let warpSet = setBeforeMainLoop (atomically $ putTMVar serverReady ()) defaultSettings

    -- Run server in background, execute action, then cleanup
    withAsync (runTLSSocketLib tls tlsSet warpSet sock echoApp) $ \as -> do
        startupResult <-
            atomically $
                (Left <$> waitCatchSTM as)
                    <|> (Right <$> takeTMVar serverReady)
        case startupResult of
            Right () -> pure ()
            Left e -> do
                close sock
                error $ "Server failed to start: " <> show e

        -- Small additional delay for server to be fully ready
        threadDelay 50_000

        -- Run the test action
        result <- action port

        -- Cleanup
        close sock
        threadDelay 100_000 -- 100ms cleanup delay
        pure result

-- | Make a secure HTTPS request to localhost, returning response body.
makeSecureRequest :: Port -> IO (Either String LBS.ByteString)
makeSecureRequest port = do
    result <- try @SomeException $ do
        -- Connect with TLS
        ctx <- Conn.initConnectionContext
        conn <-
            Conn.connectTo
                ctx
                Conn.ConnectionParams
                    { Conn.connectionHostname = "localhost"
                    , Conn.connectionPort = fromIntegral port
                    , Conn.connectionUseSecure =
                        Just
                            Conn.TLSSettingsSimple
                                { Conn.settingDisableCertificateValidation = True
                                , Conn.settingDisableSession = False
                                , Conn.settingUseServerName = True
                                }
                    , Conn.connectionUseSocks = Nothing
                    }

        -- Send HTTP request
        Conn.connectionPut conn "GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n"

        -- Read response with timeout
        response <- readAllWithTimeout conn 5_000_000
        Conn.connectionClose conn
        pure $ LBS.fromStrict response

    pure $ case result of
        Left e -> Left (show e)
        Right bs -> Right bs

-- | Read all data from connection with a timeout
readAllWithTimeout :: Conn.Connection -> Int -> IO ByteString
readAllWithTimeout conn timeoutMicros = do
    result <- race (threadDelay timeoutMicros) (readAll conn)
    case result of
        Left () -> pure BS.empty -- Timeout, return what we have
        Right bs -> pure bs
  where
    readAll c = do
        chunk <- Conn.connectionGetChunk c
        if BS.null chunk
            then pure BS.empty
            else do
                rest <- readAll c
                pure (chunk <> rest)