packages feed

hprox-0.7.2: 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