packages feed

pgmq-inspect-0.7.0.0: test/ServerSpec.hs

module ServerSpec (tests) where

import Control.Concurrent.Async
import Control.Concurrent.MVar
import Control.Exception
import Data.Aeson (Value (..))
import Data.IORef
import Data.List (isInfixOf)
import Network.HTTP.Client qualified as Http
import Network.HTTP.Types (statusCode)
import Network.Socket qualified as Socket
import Network.Wai (pathInfo)
import Network.Wai.Handler.Warp qualified as Warp
import Pgmq.Inspect
import Pgmq.Inspect.Lifecycle qualified as Lifecycle
import RouterSpec (errorCodeOf, mockEnv)
import System.IO.Error (isUserError)
import Test.Tasty
import Test.Tasty.HUnit
import TestServer

tests :: TestTree
tests =
  testGroup
    "Server lifecycle and mounting"
    [ testCase "a plain GET /ws is 426" $ withTestServer defaultInspectConfig mockEnv $ \port -> do
        r <- get port "/ws"
        replyStatus r @?= 426
        errorCodeOf r >>= (@?= String "websocket_upgrade_required"),
      testCase "upgrade seam returns JSON 503" $
        withInspectApplicationWith defaultInspectConfig mockEnv rejectingWebSocketApp $ \app ->
          Warp.testWithApplication (pure app) $ \port -> do
            r <- getWith port "GET" upgradeHeaders "/ws"
            replyStatus r @?= 503
            errorCodeOf r >>= (@?= String "websocket_unavailable"),
      testCase "HTTP, encoded queue names and upgrades use stripped pathInfo under a prefix" $
        withInspectApplicationWith defaultInspectConfig mockEnv rejectingWebSocketApp $ \app ->
          Warp.testWithApplication (pure (\req respond -> app req {pathInfo = drop 1 (pathInfo req)} respond)) $ \port -> do
            r <- get port "/pgmq/queues/%6frders"
            replyStatus r @?= 200
            upgrade <- getWith port "GET" upgradeHeaders "/pgmq/ws"
            replyStatus upgrade @?= 503,
      testCase "occupied port fails before continuation" $
        withInspectServer config mockEnv $ \server -> do
          ran <- newIORef False
          result <- try @IOException (withInspectServer config {bindPort = serverPort server} mockEnv (\_ -> writeIORef ran True))
          assertBool "bind failure propagated" (either (const True) (const False) result)
          readIORef ran >>= (@?= False),
      testCase "invalid host fails before continuation" $ do
        result <- try @IOException (withInspectServer config {bindHost = "invalid host !"} mockEnv (const (assertFailure "continued after bad host")))
        assertBool "host failure" (either (const True) (const False) result),
      testCase "invalid port and config are rejected" $
        mapM_
          ( \cfg -> do
              result <- try @IOException (withInspectServer cfg mockEnv (const (assertFailure "continued after invalid configuration")))
              assertBool "invalid configuration failed" (either (const True) (const False) result)
          )
          [config {bindPort = -1}, config {bindPort = 65536}, config {inspect = defaultInspectConfig {maxPageLimit = maxBound}}],
      testCase "normal and exceptional exits join thread and close the actual owned socket" $ do
        saved <- newEmptyMVar
        observedServer (\socket server -> putMVar saved (socket, server))
        (socket, server) <- takeMVar saved
        closedSocket socket
        poll (serverThread server) >>= (\state -> assertBool "server joined" (maybe False (const True) state))
        result <- try @IOException $ observedServer (\owned _ -> putMVar saved (owned, server) >> throwIO (userError "intentional callback failure"))
        case result of
          Left err -> assertBool "callback error propagated" (isUserError err && "intentional callback failure" `isInfixOf` show err)
          Right () -> assertFailure "callback error was lost"
        (failedSocket, _) <- takeMVar saved
        closedSocket failedSocket,
      testCase "cancellation immediately after bind closes the actual startup socket" $ do
        bound <- newEmptyMVar
        withAsync (Lifecycle.withBoundSocket config (\socket -> putMVar bound socket >> threadBlock)) $ \owner -> do
          socket <- takeMVar bound
          cancel owner
          closedSocket socket,
      testCase "cancellation in a running callback joins thread and closes owned socket" $ do
        started <- newEmptyMVar
        withAsync (observedServer (\socket server -> putMVar started (socket, server) >> threadBlock)) $ \owner -> do
          (socket, server) <- takeMVar started
          cancel owner
          closedSocket socket
          poll (serverThread server) >>= (\state -> assertBool "server joined" (maybe False (const True) state)),
      testCase "port 0 honors configured loopback host" $
        withInspectServer config {bindHost = "::1"} mockEnv $ \server -> do
          assertBool "OS assigned port" (serverPort server > 0)
          manager <- Http.newManager Http.defaultManagerSettings
          req <- Http.parseRequest ("http://[::1]:" <> show (serverPort server) <> "/health/live")
          response <- Http.httpLbs req manager
          statusCode (Http.responseStatus response) @?= 200
    ]
  where
    -- These are the same private resource combinators used by withInspectServer.
    observedServer action = Lifecycle.withBoundSocket config $ \socket ->
      withInspectRuntime defaultInspectConfig mockEnv $ \runtime ->
        Lifecycle.withRunningSocket
          config
          socket
          (runtimeApplication runtime)
          (runtimeRequestShutdown runtime >> runtimeAwaitDrained runtime 1000000)
          (\port worker -> action socket (RunningInspectServer port worker))
    closedSocket socket = Socket.withFdSocket socket (\descriptor -> descriptor @?= -1)
    config = defaultInspectServerConfig {bindPort = 0}
    threadBlock = newEmptyMVar >>= takeMVar :: IO ()
    upgradeHeaders = [("Connection", "Upgrade"), ("Upgrade", "websocket"), ("Sec-WebSocket-Key", "dGhlIHNhbXBsZSBub25jZQ=="), ("Sec-WebSocket-Version", "13")]