hprox-0.7.1: test/Network/HProx/RuntimeSpec.hs
-- SPDX-License-Identifier: Apache-2.0
--
-- Copyright (C) 2026 Bin Jin. All Rights Reserved.
{-# LANGUAGE CPP #-}
module Network.HProx.RuntimeSpec
( spec
) where
import Control.Exception (SomeException, displayException, toException)
import Data.Maybe (isJust, isNothing)
import GHC.IO.Exception (IOErrorType(..))
import Network.HTTP2.Client qualified as H2
import Network.TLS qualified as TLS
import Network.Wai
import Network.Wai.Handler.Warp
import Network.Wai.Handler.WarpTLS
import System.IO.Error (mkIOError)
#ifdef QUIC_ENABLED
import Network.HProx.Quic
import Network.QUIC.Internal qualified as Q
import Network.TLS.SessionManager qualified as SM
#endif
#ifdef OS_UNIX
import Network.HProx.Unix
#endif
import Network.HProx.Config
import Network.HProx.Log
import Network.HProx.Route
import Network.HProx.Runtime
import Test.Hspec
spec :: Spec
spec = do
describe "RuntimeConfig" $
it "normalizes log output and sorted reverse routes at the runtime boundary" $
buildRuntimeConfig defaultConfig
{ _log = "stderr"
, _rev =
[ (Nothing, "/", "catch:80")
, (Just "example.com", "/api/", "api:443")
]
} `shouldBe` RuntimeConfig
{ runtimeConfigLogOutput = LogOutputStderr
, runtimeConfigReverseRoutes =
[ ReverseRoute (Just "example.com") "/api" "api:443"
, ReverseRoute Nothing "/" "catch:80"
]
, runtimeConfigReverseRouteTuples =
[ (Just "example.com", "/api/", "api:443")
, (Nothing, "/", "catch:80")
]
}
describe "runtime config validation" $ do
it "rejects direct Config listener ports outside the TCP port range" $ do
validateRuntimeConfig defaultConfig { _port = 0 } `shouldBe` Left "invalid --port: 0 (expected 1..65535)"
validateRuntimeConfig defaultConfig { _port = 65536 } `shouldBe` Left "invalid --port: 65536 (expected 1..65535)"
#ifdef QUIC_ENABLED
it "rejects direct Config QUIC ports outside the UDP port range" $ do
validateRuntimeConfig defaultConfig { _quic = Just 0 } `shouldBe` Left "invalid --quic: 0 (expected 1..65535)"
validateRuntimeConfig defaultConfig { _quic = Just 65536 } `shouldBe` Left "invalid --quic: 65536 (expected 1..65535)"
#endif
describe "Warp runtime settings" $ do
it "captures default bind, port, server name, and no-parse-path settings" $
buildWarpRuntimePlan defaultConfig `shouldBe` WarpRuntimePlan
{ runtimeBindHost = "*6"
, runtimePort = 3000
, runtimeServerName = "hprox"
, runtimeNoParsePath = True
}
it "captures configured bind, port, and server name settings" $
buildWarpRuntimePlan defaultConfig
{ _bind = Just "127.0.0.1"
, _port = 8080
, _name = "custom"
} `shouldBe` WarpRuntimePlan
{ runtimeBindHost = "127.0.0.1"
, runtimePort = 8080
, runtimeServerName = "custom"
, runtimeNoParsePath = True
}
it "uses dynamic SNI credentials and disables reusable TLS sessions" $ do
let settings = buildTlsSettings $ \_ -> return $ TLS.Credentials []
case tlsCredentials settings of
Just (TLS.Credentials credentials) -> credentials `shouldBe` []
Nothing -> expectationFailure "expected explicit empty TLS credentials"
case tlsSessionManager settings of
Nothing -> return ()
Just _ -> expectationFailure "expected disabled TLS session manager"
TLS.Credentials credentials <- TLS.onServerNameIndication (tlsServerHooks settings) Nothing
credentials `shouldBe` []
describe "runner selection" $ do
it "selects the plain Warp runner when no certificates are configured" $
selectRunnerPlan defaultConfig [] `shouldBe` PlainWarpRunner
it "selects the TLS Warp runner when certificates are configured" $
selectRunnerPlan defaultConfig [("example.com", ())] `shouldBe` TlsWarpRunner
#ifdef QUIC_ENABLED
it "selects concurrent QUIC and TLS when QUIC is configured with certificates" $
selectRunnerPlan defaultConfig { _quic = Just 443 } [("example.com", ())]
`shouldBe` QuicAndTlsRunner 443
it "does not select QUIC without configured certificates" $
selectRunnerPlan defaultConfig { _quic = Just 443 } [] `shouldBe` PlainWarpRunner
it "builds safer QUIC defaults" $ do
quicUse0RTT `shouldBe` False
quicAddressPlan Nothing 8443 `shouldBe` [("0.0.0.0", 8443), ("::", 8443)]
quicAddressPlan (Just "127.0.0.1") 8443 `shouldBe` [("127.0.0.1", 8443)]
quicAltSvc 8443 `shouldBe` "h3=\":8443\""
it "builds QUIC TLS config without stale credentials" $ do
sessionManager <- SM.newSessionManager quicSessionManagerConfig
let onSNI host =
if host == Just "example.com"
then return $ TLS.Credentials []
else fail $ "unexpected SNI host: " <> show host
config = buildQuicServerConfig Nothing onSNI sessionManager 8443
SM.dbMaxSize quicSessionManagerConfig `shouldBe` 0
case Q.scCredentials config of
TLS.Credentials credentials -> credentials `shouldBe` []
TLS.Credentials credentials <- TLS.onServerNameIndication (Q.scTlsHooks config) (Just "example.com")
credentials `shouldBe` []
#endif
#ifdef OS_UNIX
describe "privilege drop planning" $ do
it "uses the target user's primary group when no group is configured" $ do
let userEntry = ResolvedUser "alice" 1001 2001
groups =
[ ResolvedGroup "primary" 2001 []
, ResolvedGroup "extra" 2002 ["alice"]
, ResolvedGroup "other" 2003 ["bob"]
]
planPrivilegeDrop (Just userEntry) Nothing groups
`shouldBe` PrivilegeDropPlan
{ privilegeDropUserName = Just "alice"
, privilegeDropUserID = Just 1001
, privilegeDropGroupName = Nothing
, privilegeDropGroupID = Just 2001
, privilegeDropSupplementaryGroups = [2001, 2002]
}
it "uses an explicit primary group for a target user" $ do
let userEntry = ResolvedUser "alice" 1001 2001
groupEntry = ResolvedGroup "service" 3001 []
groups = [ResolvedGroup "extra" 2002 ["alice"]]
planPrivilegeDrop (Just userEntry) (Just groupEntry) groups
`shouldBe` PrivilegeDropPlan
{ privilegeDropUserName = Just "alice"
, privilegeDropUserID = Just 1001
, privilegeDropGroupName = Just "service"
, privilegeDropGroupID = Just 3001
, privilegeDropSupplementaryGroups = [3001, 2002]
}
it "clears supplementary groups for group-only drops" $ do
let groupEntry = ResolvedGroup "service" 3001 ["alice"]
planPrivilegeDrop Nothing (Just groupEntry) [groupEntry]
`shouldBe` PrivilegeDropPlan
{ privilegeDropUserName = Nothing
, privilegeDropUserID = Nothing
, privilegeDropGroupName = Just "service"
, privilegeDropGroupID = Just 3001
, privilegeDropSupplementaryGroups = []
}
#endif
describe "access log filtering" $ do
it "suppresses health-check access logs" $
shouldSuppressAccessLog defaultRequest { rawPathInfo = "/.hprox/health" } `shouldBe` True
it "keeps normal request access logs" $
shouldSuppressAccessLog defaultRequest { rawPathInfo = "/" } `shouldBe` False
describe "runtime exception filtering" $ do
it "suppresses all exception logs above DEBUG level" $
runtimeExceptionToLog INFO displayedException `shouldSatisfy` isNothing
it "keeps ordinary displayable exceptions at DEBUG level" $
runtimeExceptionToLog DEBUG displayedException `shouldSatisfy` isJust
it "unwraps HTTP/2 wrappers before selecting the exception to log" $
fmap displayException (runtimeExceptionToLog DEBUG (toException (H2.BadThingHappen displayedException)))
`shouldBe` Just (displayException displayedException)
it "suppresses EOF IO exceptions" $
runtimeExceptionToLog DEBUG eofException `shouldSatisfy` isNothing
it "suppresses HTTP/2 errors and recursively classified HTTP/2 wrappers" $ do
runtimeExceptionToLog DEBUG (toException H2.ConnectionIsClosed) `shouldSatisfy` isNothing
runtimeExceptionToLog DEBUG (toException (H2.BadThingHappen eofException)) `shouldSatisfy` isNothing
#ifdef QUIC_ENABLED
it "suppresses QUIC errors and recursively classified QUIC wrappers" $ do
runtimeExceptionToLog DEBUG (toException Q.ConnectionIsReset) `shouldSatisfy` isNothing
runtimeExceptionToLog DEBUG (toException (Q.BadThingHappen eofException)) `shouldSatisfy` isNothing
#endif
it "suppresses Warp TLS and peer-close exceptions" $ do
runtimeExceptionToLog DEBUG (toException InsecureConnectionDenied) `shouldSatisfy` isNothing
runtimeExceptionToLog DEBUG (toException ConnectionClosedByPeer) `shouldSatisfy` isNothing
displayedException :: SomeException
displayedException = toException $ userError "display me"
eofException :: SomeException
eofException = toException $ mkIOError EOF "eof" Nothing Nothing