Skip to content

Commit

Permalink
Test that we can access values of invalid headers
Browse files Browse the repository at this point in the history
  • Loading branch information
edsko committed Jul 26, 2024
1 parent a2133e9 commit 4d26880
Show file tree
Hide file tree
Showing 5 changed files with 175 additions and 24 deletions.
1 change: 1 addition & 0 deletions grapesy.cabal
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,7 @@ test-suite test-grapesy
, text >= 1.2 && < 2.2
, tls >= 1.7 && < 2.2
, tree-diff >= 0.3 && < 0.4
, utf8-string >= 1.0 && < 1.1

executable demo-client
import:
Expand Down
4 changes: 4 additions & 0 deletions src/Network/GRPC/Common.hs
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,10 @@ module Network.GRPC.Common (
, Session.PeerException(..)
, SomeProtocolException(..)
, UnexpectedMetadata(..)
, InvalidHeaders(..)
, InvalidHeader(..)
, invalidHeaders
, HandledSynthesized

-- ** User errors
, Session.SendAfterFinal(..)
Expand Down
1 change: 1 addition & 0 deletions src/Network/GRPC/Spec.hs
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,7 @@ module Network.GRPC.Spec (
, mapSynthesized
, throwSynthesized
-- ** Use
, invalidHeaders
, prettyInvalidHeaders
, statusInvalidHeaders
-- * Common infrastructure to all headers
Expand Down
15 changes: 14 additions & 1 deletion src/Network/GRPC/Spec/Headers/Invalid.hs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ module Network.GRPC.Spec.Headers.Invalid (
, mapSynthesizedM
, throwSynthesized
-- * Utility
, invalidHeaders
, prettyInvalidHeaders
, statusInvalidHeaders
) where
Expand All @@ -29,7 +30,7 @@ import Data.ByteString.UTF8 qualified as BS.UTF8
import Data.CaseInsensitive qualified as CI
import Data.Foldable (asum)
import Data.Functor.Identity
import Data.Maybe (fromMaybe)
import Data.Maybe (fromMaybe, mapMaybe)
import Network.HTTP.Types qualified as HTTP

import Network.GRPC.Spec.Status
Expand Down Expand Up @@ -193,6 +194,18 @@ throwSynthesized throw =
Utility
-------------------------------------------------------------------------------}

-- | Extract all invalid headers
invalidHeaders :: InvalidHeaders e -> [HTTP.Header]
invalidHeaders = \invalid ->
case dropSynthesized invalid of
InvalidHeaders es -> mapMaybe aux es
where
aux :: InvalidHeader HandledSynthesized -> Maybe HTTP.Header
aux (InvalidHeader _status hdr _) = Just hdr
aux MissingHeader{} = Nothing
aux UnexpectedHeader{} = Nothing
aux (InvalidHeaderSynthesize e _) = handledSynthesized e

prettyInvalidHeaders :: InvalidHeaders HandledSynthesized -> ByteString.Builder
prettyInvalidHeaders = mconcat . map go . getInvalidHeaders
where
Expand Down
178 changes: 155 additions & 23 deletions test-grapesy/Test/Sanity/BrokenDeployments.hs
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
{-# LANGUAGE OverloadedStrings #-}
-- Intentionally /NOT/ enabling OverloadedStrings.
-- This forces us to be precise about encoding issues.

module Test.Sanity.BrokenDeployments (tests) where

import Control.Concurrent
import Control.Concurrent.Async
import Control.Exception
import Data.ByteString.Builder qualified as ByteString (Builder)
import Data.ByteString qualified as BS.Strict
import Data.ByteString qualified as Strict (ByteString)
import Data.ByteString.Builder qualified as BS.Builder
import Data.ByteString.Char8 qualified as BS.Strict.Char8
import Data.ByteString.UTF8 qualified as BS.Strict.UTF8
import Data.String (fromString)
import Data.Text qualified as Text
import Network.HTTP.Types qualified as HTTP
import Network.HTTP2.Server qualified as HTTP2
Expand Down Expand Up @@ -41,6 +47,11 @@ tests = testGroup "Test.Sanity.BrokenDeployments" [
, testCase "statusMessage" test_omitStatusMessage
, testCase "allTrailers" test_omitAllTrailers
]
, testGroup "Invalid" [
testCase "statusMessage" test_invalidStatusMessage
, testCase "requestMetadata" test_invalidRequestMetadata
, testCase "trailerMetadata" test_invalidTrailerMetadata
]
]

{-------------------------------------------------------------------------------
Expand Down Expand Up @@ -81,17 +92,20 @@ test_statusNon200Body = respondWith response $ \addr -> do
Left err
| grpcError err == GrpcInternal
, Just msg <- grpcErrorMessage err
, "Server supplied custom error" `Text.isInfixOf` msg ->
, Text.pack "Server supplied custom error" `Text.isInfixOf` msg ->
return ()
_otherwise ->
assertFailure $ "Unexpected response: " ++ show mResp
where
response :: Response
response = def {
responseStatus = HTTP.badRequest400
, responseBody = "Server supplied custom error"
, responseBody = BS.Strict.Char8.pack customError
}

customError :: String
customError = "Server supplied custom error"

{-------------------------------------------------------------------------------
Content-type
-------------------------------------------------------------------------------}
Expand All @@ -111,7 +125,9 @@ test_invalidContentType response = respondWith response $ \addr -> do

test_nonGrpcContentTypeRegular :: Assertion
test_nonGrpcContentTypeRegular = test_invalidContentType def {
responseHeaders = [ ("content-type", "someInvalidContentType") ]
responseHeaders = [
asciiHeader "content-type" "someInvalidContentType"
]
}

test_missingContentTypeRegular :: Assertion
Expand All @@ -121,14 +137,17 @@ test_missingContentTypeRegular = test_invalidContentType def {

test_nonGrpcContentTypeTrailersOnly :: Assertion
test_nonGrpcContentTypeTrailersOnly = test_invalidContentType def {
responseHeaders = [ ("grpc-status", "0")
, ("content-type", "someInvalidContentType")
]
responseHeaders = [
asciiHeader "grpc-status" "0"
, asciiHeader "content-type" "someInvalidContentType"
]
}

test_missingContentTypeTrailersOnly :: Assertion
test_missingContentTypeTrailersOnly = test_invalidContentType def {
responseHeaders = [ ("grpc-status", "0") ]
responseHeaders = [
asciiHeader "grpc-status" "0"
]
}

{-------------------------------------------------------------------------------
Expand All @@ -146,16 +165,15 @@ test_omitStatus = respondWith response $ \addr -> do
case mResp of
Left err
| grpcError err == GrpcUnknown
, Just msg <- grpcErrorMessage err
, "grpc-status" `Text.isInfixOf` msg ->
, grpcMessageContains err "grpc-status" ->
return ()
_otherwise ->
assertFailure $ "Unexpected response: " ++ show mResp
where
response :: Response
response = def {
responseTrailers = [
("grpc-message", "Message but no status")
asciiHeader "grpc-message" "Message but no status"
]
}

Expand All @@ -176,7 +194,7 @@ test_omitStatusMessage = respondWith response $ \addr -> do
response :: Response
response = def {
responseTrailers = [
("grpc-status", "0")
asciiHeader "grpc-status" "0"
]
}

Expand All @@ -191,8 +209,7 @@ test_omitAllTrailers = respondWith response $ \addr -> do
case mResp of
Left err
| grpcError err == GrpcUnknown
, Just msg <- grpcErrorMessage err
, "closed without trailers" `Text.isInfixOf` msg ->
, grpcMessageContains err "closed without trailers" ->
return ()
_otherwise ->
assertFailure $ "Unexpected response: " ++ show mResp
Expand All @@ -202,28 +219,128 @@ test_omitAllTrailers = respondWith response $ \addr -> do
responseTrailers = []
}

{-------------------------------------------------------------------------------
Invalid headers
The gRPC spec mandates that we /MUST NOT/ throw away invalid headers. This
is done as a matter of default for all headers in grapesy, except the ones
that it really needs to operate. To access these invalid values, users do
however need to use the low-level API.
-------------------------------------------------------------------------------}

test_invalidStatusMessage :: Assertion
test_invalidStatusMessage = respondWith response $ \addr -> do
mResp :: StreamElem
Client.ProperTrailers'
(InboundMeta, Proto PongMessage) <-
Client.withConnection connParams (Client.ServerInsecure addr) $ \conn ->
Client.withRPC conn def (Proxy @Ping) $ \call -> do
Client.sendFinalInput call defMessage
Client.recvOutputWithMeta call
case mResp of
NoMoreElems trailers
| Left invalid <- Client.properTrailersGrpcMessage trailers
, [ (_, headerValue) ] <- invalidHeaders invalid
, headerValue == BS.Strict.Char8.pack someInvalidMessage
->
return ()
_otherwise ->
assertFailure $ "Unexpected response: " ++ show mResp
where
response :: Response
response = def {
responseTrailers = [
asciiHeader "grpc-status" "13" -- 'GrpcInternal'
, asciiHeader "grpc-message" someInvalidMessage
]
}

someInvalidMessage :: String
someInvalidMessage = "This is invalid: %X"

test_invalidRequestMetadata :: Assertion
test_invalidRequestMetadata = respondWith response $ \addr -> do
mResp :: Either
(Client.TrailersOnly' HandledSynthesized)
(Client.ResponseHeaders' HandledSynthesized) <-
Client.withConnection connParams' (Client.ServerInsecure addr) $ \conn ->
Client.withRPC conn def (Proxy @Ping) $ \call -> do
Client.recvInitialResponse call
case mResp of
Right headers
| Left invalid <- Client.responseUnrecognized headers
, [ (_, headerValue) ] <- invalidHeaders invalid
, headerValue == BS.Strict.UTF8.fromString someInvalidMetadata
->
return ()
_otherwise ->
assertFailure $ "Unexpected response: " ++ show mResp
where
-- In this case we do /NOT/ want to verify all headers
-- (the whole point is that we can access the invalid header value)
connParams' :: Client.ConnParams
connParams' = def { Client.connVerifyHeaders = False }

response :: Response
response = def {
responseHeaders = [
asciiHeader "content-type" "application/grpc"
, utf8Header "some-custom-header" someInvalidMetadata
]
}

someInvalidMetadata :: String
someInvalidMetadata = "This is invalid: 你好"

test_invalidTrailerMetadata :: Assertion
test_invalidTrailerMetadata = respondWith response $ \addr -> do
mResp :: StreamElem
Client.ProperTrailers'
(InboundMeta, Proto PongMessage) <-
Client.withConnection connParams (Client.ServerInsecure addr) $ \conn ->
Client.withRPC conn def (Proxy @Ping) $ \call -> do
Client.sendFinalInput call defMessage
Client.recvOutputWithMeta call
case mResp of
NoMoreElems trailers
| Left invalid <- Client.properTrailersUnrecognized trailers
, [ (_, headerValue) ] <- invalidHeaders invalid
, headerValue == BS.Strict.UTF8.fromString someInvalidMetadata
->
return ()
_otherwise ->
assertFailure $ "Unexpected response: " ++ show mResp
where
response :: Response
response = def {
responseTrailers = [
asciiHeader "grpc-status" "0"
, utf8Header "some-custom-trailer" someInvalidMetadata
]
}

someInvalidMetadata :: String
someInvalidMetadata = "This is invalid: 你好"

{-------------------------------------------------------------------------------
Test server
This allows us to simulate broken /servers/.
TODO: <https://github.com/well-typed/grapesy/issues/22>
We should also simulate broken /clients/.
-------------------------------------------------------------------------------}

data Response = Response {
responseStatus :: HTTP.Status
, responseHeaders :: [HTTP.Header]
, responseBody :: ByteString.Builder
, responseBody :: Strict.ByteString
, responseTrailers :: [HTTP.Header]
}

instance Default Response where
def = Response {
responseStatus = HTTP.ok200
, responseHeaders = [ ("content-type", "application/grpc") ]
, responseBody = mempty
, responseTrailers = [ ("grpc-status", "0") ]
, responseHeaders = [ asciiHeader "content-type" "application/grpc" ]
, responseBody = BS.Strict.empty
, responseTrailers = [ asciiHeader "grpc-status" "0" ]
}

-- | Server that responds with the given 'Response', independent of the request
Expand All @@ -237,7 +354,7 @@ respondWith response = withTestServer $ \_req _aux respond ->
HTTP2.responseBuilder
(responseStatus response)
(responseHeaders response)
(responseBody response)
(BS.Builder.byteString $ responseBody response)

trailersMaker :: HTTP2.TrailersMaker
trailersMaker Nothing = return $ HTTP2.Trailers (responseTrailers response)
Expand Down Expand Up @@ -290,3 +407,18 @@ connParams = def {
Client.connVerifyHeaders = True
}

-- | Header with ASCII value
--
-- (Header /names/ are always ASCII.)
asciiHeader :: String -> String -> HTTP.Header
asciiHeader name value = (fromString name, BS.Strict.Char8.pack value)

-- | Header with UTF-8 encoded value
utf8Header :: String -> String -> HTTP.Header
utf8Header name value = (fromString name, BS.Strict.UTF8.fromString value)

grpcMessageContains :: GrpcException -> String -> Bool
grpcMessageContains GrpcException{grpcErrorMessage} str =
case grpcErrorMessage of
Just msg -> Text.pack str `Text.isInfixOf` msg
Nothing -> False

0 comments on commit 4d26880

Please sign in to comment.