packages feed

hprox-0.7.0: 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 GHC.IO.Exception            (IOErrorType(..))
import Network.HTTP2.Client        qualified as H2
import Network.Wai
import Network.Wai.Handler.Warp
import Network.Wai.Handler.WarpTLS
import System.IO.Error             (mkIOError)

#ifdef QUIC_ENABLED
import Network.HProx.Platform.Quic
import Network.QUIC.Internal       qualified as Q
#endif
#ifdef OS_UNIX
import Network.HProx.Platform.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
          }

  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\""
#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 "DoH wrapping decision" $ do
    it "wraps the application only when a DoH resolver is configured" $ do
      shouldWrapDNSOverHTTPS defaultConfig `shouldBe` False
      shouldWrapDNSOverHTTPS defaultConfig { _doh = Just "127.0.0.1:53" } `shouldBe` True

  describe "startup side-effect order" $
    it "documents the current run startup order" $
      startupOrder `shouldBe`
        [ InitializeLogger
        , LogStartup
        , ReadCertificates
        , CreateTlsSessionManager
        , BuildSettingsAndRunner
        , LoadProxyAuth
        , CreateHttpManager
        , BuildProxyApplication
        , LogRuntimeConfig
        , StartRunner
        ]
  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" $
      shouldIgnoreRuntimeException INFO displayedException `shouldBe` True

    it "keeps ordinary displayable exceptions at DEBUG level" $
      shouldIgnoreRuntimeException DEBUG displayedException `shouldBe` False

    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" $
      shouldIgnoreRuntimeException DEBUG eofException `shouldBe` True

    it "suppresses HTTP/2 errors and recursively classified HTTP/2 wrappers" $ do
      shouldIgnoreRuntimeException DEBUG (toException H2.ConnectionIsClosed) `shouldBe` True
      shouldIgnoreRuntimeException DEBUG (toException (H2.BadThingHappen eofException)) `shouldBe` True

#ifdef QUIC_ENABLED
    it "suppresses QUIC errors and recursively classified QUIC wrappers" $ do
      shouldIgnoreRuntimeException DEBUG (toException Q.ConnectionIsReset) `shouldBe` True
      shouldIgnoreRuntimeException DEBUG (toException (Q.BadThingHappen eofException)) `shouldBe` True
#endif

    it "suppresses Warp TLS and peer-close exceptions" $ do
      shouldIgnoreRuntimeException DEBUG (toException InsecureConnectionDenied) `shouldBe` True
      shouldIgnoreRuntimeException DEBUG (toException ConnectionClosedByPeer) `shouldBe` True

displayedException :: SomeException
displayedException = toException $ userError "display me"

eofException :: SomeException
eofException = toException $ mkIOError EOF "eof" Nothing Nothing