grapesy-1.0.0: test-grapesy/Test/Driver/ClientServer.hs
{-# LANGUAGE CPP #-}
{-# LANGUAGE OverloadedStrings #-}
module Test.Driver.ClientServer (
ClientServerTest(..)
, testClientServer
, propClientServer
-- * Configuration
, ClientServerConfig(..)
, ContentTypeOverride(..)
, TlsSetup(..)
, TlsFail(..)
, TlsOk(..)
-- ** Expected exceptions
, DeliberateException(..)
, isDeliberateException
, isClientDisconnected
, isInvalidRequestHeaders
, isGrpc415
, isGrpc400
, isGrpcCancelled
, isHandshakeFailed
, isServerUnsupportedCompression
, isClientUnsupportedCompression
, isHandlerTerminated
-- * Constructing clients
, TestClient
, simpleTestClient
) where
import Control.Concurrent
import Control.Concurrent.Async
import Control.Concurrent.STM
import Control.Exception (throwIO)
import Control.Monad
import Control.Monad.Catch
import Control.Monad.IO.Class
import Data.Text qualified as Text
import Network.HTTP2.Server qualified as HTTP2.Server
import Network.Socket (PortNumber)
import Network.TLS
import Test.QuickCheck.Monadic qualified as QuickCheck
import Test.Tasty.QuickCheck qualified as QuickCheck
import Network.GRPC.Client qualified as Client
import Network.GRPC.Common
import Network.GRPC.Common.Compression qualified as Compr
import Network.GRPC.Server qualified as Server
import Network.GRPC.Server.Run qualified as Server
import Test.Util.Exception
import Paths_grapesy
{-------------------------------------------------------------------------------
Top-level
-------------------------------------------------------------------------------}
-- | Run client server test, and check for expected failures
testClientServer :: ClientServerTest -> IO ()
testClientServer test =
runTestClientServer test
-- | Turn client server test into property
propClientServer :: IO ClientServerTest -> QuickCheck.Property
propClientServer mkTest =
QuickCheck.monadicIO $ liftIO $ runTestClientServer =<< mkTest
{-------------------------------------------------------------------------------
Configuration
-------------------------------------------------------------------------------}
data ClientServerConfig = ClientServerConfig {
-- | Port number used by the server
--
-- The client will query the server for its port; this makes it possible
-- to use @0@ for 'serverPort', so that the server picks a random
-- available port (this is the default).
serverPort :: PortNumber
-- | Compression algorithms supported by the client
, clientCompr :: Compr.Negotation
-- | Initial compression algorithm used by the client (if any)
, clientInitCompr :: Maybe Compr.Compression
-- | Compression algorithms supported the server
, serverCompr :: Compr.Negotation
-- | TLS setup (if using)
, useTLS :: Maybe TlsSetup
-- | Override content-type used by the client
, clientContentType :: ContentTypeOverride
-- | Override content-type used by the server
, serverContentType :: ContentTypeOverride
-- | Is this exception expected on the client?
, isExpectedClientException :: SomeException -> Bool
-- | Is this exception expected on the server?
, isExpectedServerException :: SomeException -> Bool
}
data ContentTypeOverride =
-- | Use the default content-type
NoOverride
-- | Override with a valid alternative content-type
--
-- It is the responsibility of the test to make sure that this content-type
-- is in fact valid.
| ValidOverride Server.ContentType
-- | Override with an invalid (possibly missing) content-type
| InvalidOverride (Maybe Server.ContentType)
instance Default ClientServerConfig where
def = ClientServerConfig {
serverPort = 0
, clientCompr = def
, clientInitCompr = Nothing
, serverCompr = def
, useTLS = Nothing
, clientContentType = NoOverride
, serverContentType = NoOverride
, isExpectedClientException = const False
, isExpectedServerException = const False
}
{-------------------------------------------------------------------------------
Configuration: TLS
-------------------------------------------------------------------------------}
-- | TLS setup
data TlsSetup = TlsOk TlsOk | TlsFail TlsFail
-- | TLS setup that we expect to work
data TlsOk =
-- | Configure the client so that the server's cert is a known root
--
-- This means that client can validate the server's cert, even though it is
-- self signed.
TlsOkCertAsRoot
-- | Configure the client to not validate the server's cert at all
| TlsOkSkipValidation
-- | TLS setup that should result in an error
data TlsFail =
-- | Don't take any special provisions in the client
--
-- This means that TLS validation will fail, since the server's cert is
-- self signed.
TlsFailValidation
-- | The server is not configured for TLS
| TlsFailUnsupported
{-------------------------------------------------------------------------------
Expected exceptions
Ideally 'isExpectedServerException' and 'isExpectedClientException' should
have identical structure, illustrating that the exceptions are consistent
between the server API and the client API. This ideal isn't /quite/ reached:
* If a server handler throws an exception, the client is informed,
but the reverse is not true (the client simply disconnects).
* TLS exceptions are thrown to the client, but since no handler is ever run,
we don't see these exceptions server-side.
-------------------------------------------------------------------------------}
isDeliberateException :: SomeException -> Bool
isDeliberateException e =
case fromException e of
Just DeliberateException{} -> True
_otherwise -> False
isClientDisconnected :: SomeException -> Bool
isClientDisconnected e =
case fromException e of
Just Server.ClientDisconnected{} -> True
_otherwise -> False
isInvalidRequestHeaders :: SomeException -> Bool
isInvalidRequestHeaders e =
case fromException e of
Just Server.CallSetupInvalidRequestHeaders{} -> True
_otherwise -> False
isGrpc415 :: SomeException -> Bool
isGrpc415 e =
case fromException e of
Just err' | Just msg <- grpcErrorMessage err' -> and [
grpcError err' == GrpcUnknown
, "415" `Text.isInfixOf` msg
]
_otherwise -> False
-- | Client choose unsupported compression
--
-- We respond with 400 Bad Request, which gets turned into GrpcInternal
-- by 'classifyServerResponse'.
isGrpc400 :: SomeException -> Bool
isGrpc400 e =
case fromException e of
Just err' | Just msg <- grpcErrorMessage err' -> and [
grpcError err' == GrpcInternal
, "400" `Text.isInfixOf` msg
]
_otherwise -> False
isGrpcCancelled :: SomeException -> Bool
isGrpcCancelled e =
case fromException e of
Just err' -> grpcError err' == GrpcCancelled
_otherwise -> False
isHandshakeFailed :: SomeException -> Bool
isHandshakeFailed e =
case fromException e of
Just HandshakeFailed{} -> True
_otherwise -> False
isServerUnsupportedCompression :: SomeException -> Bool
isServerUnsupportedCompression e =
case fromException e of
Just Server.CallSetupUnsupportedCompression{} -> True
_otherwise -> False
isClientUnsupportedCompression :: SomeException -> Bool
isClientUnsupportedCompression e =
case fromException e of
Just Client.CallSetupUnsupportedCompression{} -> True
_otherwise -> False
isHandlerTerminated :: SomeException -> Bool
isHandlerTerminated e =
case fromException e of
Just Server.HandlerTerminated{} -> True
_otherwise -> False
{-------------------------------------------------------------------------------
Test failures
-------------------------------------------------------------------------------}
-- | Test failure
--
-- When a test fails, we want to report the /first/ test failure; anything else
-- might result in difficult to debug test cases, because that first test
-- failure (first exception) might have all kinds of hard-to-predict
-- consequences. This is somewhat tricky to achieve in a concurrent test
-- setting; for example, if a server handler throws an exception, this exception
-- will be raised in the client also, but we want a guarantee that we see the
-- /handler/ exception, not the client one. We therefore wrap every client test
-- and every handler in an exception wrapper which, after verifying that the
-- exception was not expected, will write the exception (i.e., the test failure)
-- to a test-wide 'FirstTestFailure'.
--
-- We then run the client in a separate thread, and wait for it to finish, /or/
-- for a test failure to be reported. Doing these two checks independently means
-- that if the client deadlocks because of some test failure somewhere else, we
-- don't wait but instead report the test failure. In the client we throw
-- 'TestFailure' if we do see a test failure; this helps in tests where we run
-- multiple clients, as it signals that there is no point waiting for the other
-- tests to terminate.
--
-- In a similar fashion we then wait for all handlers to terminate also or,
-- again, for some test failure to be reported. (In this case the exception is
-- not rethrown, because handlers should never throw at all.)
--
-- Finally, we check 'FirstTestFailure', report failure if it's set, or test
-- success otherwise.
--
-- Note: if we have multiple independent clients, running independent tests,
-- then we have multiple concurrent test failures, there /is/ no clear notion
-- of a \"first\" test failure. However, in this case which exception we report
-- as \"the\" test failure is not very important; by definition, in this case
-- the one exception cannot be the /cause/ for the other exception (if it was,
-- then one must happen /before/ the other).
data FirstTestFailure =
FirstFailureInClient SomeException
| FirstFailureInServer SomeException
deriving stock (Show)
deriving anyclass (Exception)
data TestFailure = TestFailure
deriving stock (Show)
deriving anyclass (Exception)
-- | Mark test failure
--
-- Does nothing if an earlier test failure has already been marked.
markTestFailure :: TMVar FirstTestFailure -> FirstTestFailure -> IO ()
markTestFailure firstTestFailure err =
void $ atomically $ tryPutTMVar firstTestFailure err
{-------------------------------------------------------------------------------
Server handler lock
-------------------------------------------------------------------------------}
-- | Server handler lock
--
-- Handlers are initiated by calls from clients, but may outlive the connection
-- to the client. Therefore, after we wait for the clients to terminate, we
-- should also wait for all handlers to terminate.
--
-- See 'FirstTestFailure' for discussion of handler exceptions.
newtype ServerHandlerLock = ServerHandlerLock (TVar Int)
newServerHandlerLock :: IO ServerHandlerLock
newServerHandlerLock = ServerHandlerLock <$> newTVarIO 0
waitForHandlerTermination :: ServerHandlerLock -> STM ()
waitForHandlerTermination (ServerHandlerLock lock) = do
activeHandlers <- readTVar lock
when (activeHandlers > 0) retry
topLevelWithHandlerLock ::
ClientServerConfig
-> TMVar FirstTestFailure
-> ServerHandlerLock
-> Server.RequestHandler ()
-> Server.RequestHandler ()
topLevelWithHandlerLock cfg
firstTestFailure
(ServerHandlerLock lock)
handler
unmask =
handler'
where
handler' ::
HTTP2.Server.Request
-> (HTTP2.Server.Response -> IO ())
-> IO ()
handler' req respond = do
markActive
result <- try $ handler unmask req respond
case result of
Right () ->
return ()
Left err | isExpectedServerException cfg err ->
return ()
Left err ->
markTestFailure firstTestFailure (FirstFailureInServer err)
markDone
markActive, markDone :: IO ()
markActive = atomically $ modifyTVar lock (\n -> n + 1)
markDone = atomically $ modifyTVar lock (\n -> n - 1)
{-------------------------------------------------------------------------------
Server
-------------------------------------------------------------------------------}
withTestServer ::
ClientServerConfig
-> TMVar FirstTestFailure
-> ServerHandlerLock
-> [Server.SomeRpcHandler IO]
-> (Server.RunningServer -> IO a)
-> IO a
withTestServer cfg firstTestFailure handlerLock serverHandlers k = do
pubCert <- getDataFileName "grpc-demo.pem"
privKey <- getDataFileName "grpc-demo.key"
let serverConfig :: Server.ServerConfig
serverConfig =
case useTLS cfg of
Nothing -> Server.ServerConfig {
serverInsecure = Just Server.InsecureConfig {
insecureHost = Just "127.0.0.1"
, insecurePort = serverPort cfg
}
, serverSecure = Nothing
}
Just (TlsFail TlsFailUnsupported) -> Server.ServerConfig {
serverInsecure = Just Server.InsecureConfig {
insecureHost = Just "127.0.0.1"
, insecurePort = serverPort cfg
}
, serverSecure = Nothing
}
Just _tlsSetup -> Server.ServerConfig {
serverInsecure = Nothing
, serverSecure = Just $ Server.SecureConfig {
secureHost = "127.0.0.1"
, securePort = serverPort cfg
, securePubCert = pubCert
, secureChainCerts = []
, securePrivKey = privKey
, secureSslKeyLog = SslKeyLogNone
}
}
serverParams :: Server.ServerParams
serverParams = def {
Server.serverCompression =
serverCompr cfg
, Server.serverTopLevel =
topLevelWithHandlerLock cfg firstTestFailure handlerLock
, Server.serverContentType =
case serverContentType cfg of
NoOverride -> Just Server.ContentTypeDefault
ValidOverride ctype -> Just ctype
InvalidOverride ctype -> ctype
, Server.serverVerifyHeaders =
-- We want to check that we can spot invalid headers
-- (and that we don't generate any in the client)
True
}
server <- Server.mkGrpcServer serverParams serverHandlers
Server.forkServer def serverConfig server k
{-------------------------------------------------------------------------------
Client
-------------------------------------------------------------------------------}
type TestClient =
Client.ConnParams
-- ^ Test-appropriate connection parameters
-> Client.Server
-- ^ Test server to connect to
-> (IO () -> IO ())
-- ^ Delimit test scope
--
-- Any test failures will be limited to this scope. Important when
-- running multiple tests.
-> IO ()
simpleTestClient :: (Client.Connection -> IO ()) -> TestClient
simpleTestClient test params testServer delimitTestScope =
Client.withConnection params testServer $ \conn ->
delimitTestScope $ test conn
runTestClient ::
ClientServerConfig
-> TMVar FirstTestFailure
-> PortNumber
-> TestClient
-> IO ()
runTestClient cfg firstTestFailure port clientRun = do
pubCert <- getDataFileName "grpc-demo.pem"
let clientParams :: Client.ConnParams
clientParams = Client.ConnParams {
connCompression = clientCompr cfg
, connInitCompression = clientInitCompr cfg
, connDefaultTimeout = Nothing
, connVerifyHeaders = True
, connHTTP2Settings = defaultHTTP2Settings
-- Content-type
, connContentType =
case clientContentType cfg of
NoOverride -> Just Server.ContentTypeDefault
ValidOverride ctype -> Just ctype
InvalidOverride ctype -> ctype
-- We need a single reconnect, to enable wait-for-ready.
-- This avoids a race condition between the server starting first
-- and the client starting first.
, connReconnectPolicy =
Client.ReconnectAfter def $ do
threadDelay 100_000
return Client.DontReconnect
}
clientServer :: Client.Server
clientServer =
case useTLS cfg of
Just tlsSetup ->
Client.ServerSecure
( case tlsSetup of
TlsOk TlsOkCertAsRoot ->
correctClientSetup
TlsOk TlsOkSkipValidation ->
Client.NoServerValidation
TlsFail TlsFailValidation ->
Client.ValidateServer mempty
TlsFail TlsFailUnsupported ->
correctClientSetup
)
-- We enable key logging in the client and disable it in the
-- server. This avoids the client and server trying to write
-- to the same file.
SslKeyLogFromEnv
clientAuthority
Nothing ->
Client.ServerInsecure
clientAuthority
where
correctClientSetup :: Client.ServerValidation
correctClientSetup =
Client.ValidateServer $
Client.certStoreFromPath pubCert
clientAuthority :: Client.Address
clientAuthority =
case useTLS cfg of
Just _tlsSetup -> Client.Address {
addressHost = "127.0.0.1"
, addressPort = port
, addressAuthority = Nothing
}
Nothing -> Client.Address {
addressHost = "127.0.0.1"
, addressPort = port
, addressAuthority = Nothing
}
delimitTestScope :: IO () -> IO ()
delimitTestScope test = do
result :: Either SomeException () <- try test
case result of
Right () ->
return ()
Left err | isExpectedClientException cfg err ->
return ()
Left err -> do
markTestFailure firstTestFailure (FirstFailureInClient err)
throwIO TestFailure
clientRun clientParams clientServer delimitTestScope
{-------------------------------------------------------------------------------
Main entry point: run server and client together
-------------------------------------------------------------------------------}
data ClientServerTest = ClientServerTest {
config :: ClientServerConfig
, client :: TestClient
, server :: [Server.SomeRpcHandler IO]
}
runTestClientServer :: ClientServerTest -> IO ()
runTestClientServer (ClientServerTest cfg clientRun handlers) = do
-- Setup client and server
firstTestFailure <- newEmptyTMVarIO
serverHandlerLock <- newServerHandlerLock
let server :: (Server.RunningServer -> IO a) -> IO a
server = withTestServer cfg firstTestFailure serverHandlerLock handlers
let client :: PortNumber -> IO ()
client port = runTestClient cfg firstTestFailure port clientRun
-- Run the test
server $ \runningServer -> do
port <- Server.getServerPort runningServer
withAsync (client port) $ \clientThread -> do
let failure = waitForFailure runningServer clientThread firstTestFailure
-- Wait for client to terminate (or test failure)
-- (the 'orElse' is only relevant if a /handler/ throws an exception)
atomically $
(void $ waitCatchSTM clientThread)
`orElse`
(void failure)
-- Wait for handlers to terminate (or test failure)
-- (Note that the server /itself/ normally never terminates)
atomically $
(waitForHandlerTermination serverHandlerLock)
`orElse`
(void failure)
atomically $ do
(failure >>= throwSTM)
`orElse`
return ()
-- | Wait for test failure (retries/blocks if tests have not yet failed)
--
-- /If/ a first test failure has been reported, we prefer to report it. It is
-- however possible that either the server or the client threw an exception
-- /without/ the 'FirstTestFailure' being populated; for example, this can
-- happen if the server fails to start at all, or if there is a bug in the test
-- framework itself.
waitForFailure ::
Server.RunningServer -- ^ Server
-> Async () -- ^ Client
-> TMVar FirstTestFailure -- ^ First test failure
-> STM FirstTestFailure
waitForFailure server client firstTestFailure =
(readTMVar firstTestFailure)
`orElse`
(Server.waitServerSTM server >>= serverAux)
`orElse`
(waitCatchSTM client >>= clientAux)
where
serverAux ::
( Either SomeException ()
, Either SomeException ()
)
-> STM FirstTestFailure
serverAux (Left e, _) = return (FirstFailureInServer e)
serverAux (_, Left e) = return (FirstFailureInServer e)
serverAux _otherwise = throwSTM $ UnexpectedServerTermination
clientAux :: Either SomeException () -> STM FirstTestFailure
clientAux (Left e) = return (FirstFailureInClient e)
clientAux _otherwise = retry
-- | We don't expect the server to shutdown until we kill it
data UnexpectedServerTermination = UnexpectedServerTermination
deriving stock (Show)
deriving anyclass (Exception)