shibuya-metrics-0.11.0.0: test/Shibuya/Metrics/ControlSpec.hs
module Shibuya.Metrics.ControlSpec (spec) where
import Control.Concurrent.STM
import Control.Exception (bracket)
import Control.Monad (forM_)
import Data.Aeson (Value (..), decode, eitherDecode, object, (.=))
import Data.Aeson.Key qualified as Key
import Data.Aeson.KeyMap qualified as KM
import Data.ByteString (ByteString)
import Data.ByteString.Lazy qualified as LBS
import Data.Text qualified as Text
import Effectful (liftIO, runEff)
import Network.HTTP.Client qualified as HTTP
import Network.HTTP.Types
import Network.Wai (Application, requestHeaders, requestMethod)
import Network.Wai.Handler.Warp qualified as Warp
import Network.Wai.Test
import Network.WebSockets qualified as WS
import Shibuya qualified as Public
import Shibuya.Adapter (Adapter (..))
import Shibuya.Core.Metrics
import Shibuya.Internal.Runner.Master
import Shibuya.Internal.Runner.Pause
import Shibuya.Metrics.Config
import Shibuya.Metrics.Server (combinedApp)
import Shibuya.Metrics.TestSupport (fixedTime, registerIdleProcessor, withMaster)
import Shibuya.Metrics.Types (ServerMessage (..))
import Shibuya.Metrics.WebSocket (newWebSocketState)
import Streamly.Data.Stream qualified as Stream
import System.Timeout (timeout)
import Test.Hspec
spec :: Spec
spec = do
around withMaster $ describe "gated control routes" $ do
it "discovers both gate positions and refuses disabled mutations" $ \master -> do
_ <- controllableFixture master
forM_ [False, True] $ \enabled -> do
app <- application defaultConfig {enableControl = enabled} master
response <- send app methodGet "/control" []
response.simpleStatus `shouldBe` status200
decode response.simpleBody `shouldBe` Just (object ["enabled" .= enabled, "operations" .= (["pause", "resume"] :: [String])])
app <- application defaultConfig master
forM_ ["pause", "resume"] $ \op -> do
response <- send app methodPost ("/control/processors/orders/" <> op) [("Origin", "https://forbidden.example")]
assertCode response status403 "control_disabled"
isProcessorPausedIO master (ProcessorId "orders") `shouldReturn` Just False
it "makes repeated pause and resume idempotent with an unchanged pause timestamp" $ \master -> do
_ <- controllableFixture master
app <- application defaultConfig {enableControl = True} master
first <- send app methodPost "/control/processors/orders/pause" []
second <- send app methodPost "/control/processors/orders/pause" []
first.simpleStatus `shouldBe` status200
first.simpleBody `shouldBe` second.simpleBody
field "state" first `shouldBe` Just (String "paused")
field "pausedAt" first `shouldSatisfy` maybe False (const True)
isProcessorPausedIO master (ProcessorId "orders") `shouldReturn` Just True
forM_ [1, 2 :: Int] $ \_ -> do
response <- send app methodPost "/control/processors/orders/resume" []
decode response.simpleBody `shouldBe` Just (object ["processor" .= ("orders" :: String), "state" .= ("running" :: String)])
isProcessorPausedIO master (ProcessorId "orders") `shouldReturn` Just False
it "maps unknown, uncontrollable, terminal and draining outcomes" $ \master -> do
(_, pause) <- controllableFixture master
_ <- registerIdleProcessor master (ProcessorId "fixture")
app <- application defaultConfig {enableControl = True} master
send app methodPost "/control/processors/missing/pause" [] >>= \r -> assertCode r status404 "processor_not_found"
send app methodPost "/control/processors/fixture/pause" [] >>= \r -> assertCode r status409 "processor_not_controllable"
runEff $ drainAndResume master
send app methodPost "/control/processors/orders/pause" [] >>= \r -> assertCode r status409 "processor_draining"
isPaused pause `shouldReturn` False
markProcessorFailedIO master (ProcessorId "orders") "failure" Nothing
runEff $ unregisterProcessor master (ProcessorId "orders")
send app methodPost "/control/processors/orders/resume" [] >>= \r -> assertCode r status409 "processor_terminal"
it "does not confuse cold Failed observation with terminal lifecycle" $ \master -> do
(h, _) <- controllableFixture master
finishProcessing h (Left "transient")
app <- application defaultConfig {enableControl = True} master
response <- send app methodPost "/control/processors/orders/pause" []
response.simpleStatus `shouldBe` status200
isProcessorPausedIO master (ProcessorId "orders") `shouldReturn` Just True
it "checks methods, unknown paths and the JSON enable flag" $ \master -> do
app <- application defaultConfig {enableControl = True} master
forM_ [(methodDelete, "/control", "GET"), (methodGet, "/control/processors/orders/pause", "POST")] $ \(method, path, allowed) -> do
response <- send app method path []
assertCode response status405 "method_not_allowed"
lookup "Allow" response.simpleHeaders `shouldBe` Just allowed
send app methodGet "/control/other" [] >>= \r -> assertCode r status404 "not_found"
hidden <- application defaultConfig {enableJSON = False, enableControl = True} master
send hidden methodPost "/control/processors/orders/pause" [] >>= \r -> assertCode r status404 "not_found"
send hidden methodGet "/control" [] >>= \r -> assertCode r status404 "not_found"
it "refuses forbidden and repeated Origins before a bodyless or simple POST can mutate" $ \master -> do
_ <- controllableFixture master
forM_ [[], ["https://ops.example.com"]] $ \origins -> do
app <- application defaultConfig {enableControl = True, corsAllowedOrigins = origins} master
forM_ [[("Origin", "https://evil.example")], [("Origin", "null")], [("Origin", "")], [("Origin", "https://ops.example.com"), ("Origin", "https://ops.example.com")]] $ \headers ->
forM_ [[], [("Content-Type", "text/plain")]] $ \content -> do
response <- send app methodPost "/control/processors/orders/pause" (headers ++ content)
assertCode response status403 "origin_not_allowed"
isProcessorPausedIO master (ProcessorId "orders") `shouldReturn` Just False
app <- application defaultConfig {enableControl = True, corsAllowedOrigins = ["https://ops.example.com"]} master
response <- send app methodPost "/control/processors/orders/pause" [("Origin", "https://ops.example.com")]
response.simpleStatus `shouldBe` status200
isProcessorPausedIO master (ProcessorId "orders") `shouldReturn` Just True
it "serves the allowed-origin action preflight" $ \master -> do
app <- application defaultConfig {enableControl = True, corsAllowedOrigins = ["https://ops.example.com"]} master
response <- send app methodOptions "/control/processors/orders/pause" [("Origin", "https://ops.example.com"), ("Access-Control-Request-Method", "POST")]
response.simpleStatus `shouldBe` status204
lookup "Access-Control-Allow-Origin" response.simpleHeaders `shouldBe` Just "https://ops.example.com"
lookup "Access-Control-Allow-Methods" response.simpleHeaders `shouldBe` Just "GET, POST, OPTIONS"
describe "real HTTP and WebSocket control" $
it "pauses admission, drains an admitted message, observes paused state, resumes and shuts down" $ do
polls <- newTQueueIO
releases <- newTQueueIO
started <- newTQueueIO
handlerGate <- newEmptyTMVarIO
finalized <- newTVarIO (0 :: Int)
stopped <- newTVarIO False
let acquire = runEff $ Public.runTracingNoop $ do
let source =
Stream.unfoldrM
( \n -> do
liftIO $ atomically $ writeTQueue polls n
next <-
liftIO $
atomically $
(readTVar stopped >>= check >> pure Nothing) `orElse` (Just <$> readTQueue releases)
pure $ fmap (\() -> (Public.mkIngested (Public.mkEnvelope (Public.MessageId "live") ()) (Public.AckHandle (\_ -> liftIO $ atomically $ modifyTVar' finalized (+ 1))), n + 1)) next
)
(1 :: Int)
adapter = Adapter "live" source (liftIO $ atomically $ writeTVar stopped True)
handler _ = do
liftIO $ atomically $ writeTQueue started ()
liftIO $ atomically $ takeTMVar handlerGate
pure Public.AckOk
result <- Public.runApp Public.defaultAppConfig [(ProcessorId "orders", Public.mkProcessor adapter handler)]
either (liftIO . ioError . userError . show) pure result
release app = runEff $ Public.runTracingNoop $ Public.stopAppGracefully Public.defaultShutdownConfig app
bracket acquire release $ \app -> do
bounded (atomically $ readTQueue polls) `shouldReturn` 1
let master = Public.getAppMaster app
server <- application defaultConfig {enableControl = True, wsPushIntervalUs = 10_000} master
Warp.testWithApplication (pure server) $ \port -> do
manager <- HTTP.newManager HTTP.defaultManagerSettings
WS.runClient "127.0.0.1" port "/ws" $ \connection -> do
_ <- bounded $ WS.receiveData @LBS.ByteString connection
let post operation = HTTP.httpLbs (HTTP.parseRequest_ ("http://127.0.0.1:" <> show port <> "/control/processors/orders/" <> operation)) {HTTP.method = methodPost} manager
pause <- post "pause"
HTTP.responseStatus pause `shouldBe` status200
atomically $ writeTQueue releases ()
bounded $ atomically $ readTQueue started
observePaused connection
atomically $ putTMVar handlerGate ()
bounded $ atomically $ readTVar finalized >>= check . (== 1)
timeout 100_000 (atomically $ readTQueue polls) `shouldReturn` Nothing
metric <- HTTP.httpLbs (HTTP.parseRequest_ ("http://127.0.0.1:" <> show port <> "/metrics/orders")) manager
case eitherDecode (HTTP.responseBody metric) of
Right m -> (m :: ProcessorMetrics).state `shouldSatisfy` (\case Paused {} -> True; _ -> False)
Left err -> expectationFailure err
resumed <- post "resume"
HTTP.responseStatus resumed `shouldBe` status200
bounded (atomically $ readTQueue polls) `shouldReturn` 2
atomically $ writeTQueue releases ()
bounded $ atomically $ readTQueue started
atomically $ putTMVar handlerGate ()
bounded $ atomically $ readTVar finalized >>= check . (== 2)
pure ()
application :: MetricsServerConfig -> Master -> IO Application
application config master = do
state <- newWebSocketState config.wsMaxConnections
pure $ combinedApp config master state []
controllableFixture :: Master -> IO (MetricsHandle, PauseHandle)
controllableFixture master = do
h <- newMetricsHandle fixedTime
pause <- newPauseHandle
runEff $ registerProcessorWithPause master (ProcessorId "orders") h (Just pause)
pure (h, pause)
send :: Application -> Method -> ByteString -> RequestHeaders -> IO SResponse
send app method path headers = runSession (request $ setPath defaultRequest {requestMethod = method, requestHeaders = headers} path) app
field :: Key.Key -> SResponse -> Maybe Value
field key response = case decode response.simpleBody of
Just (Object values) -> KM.lookup key values
_ -> Nothing
assertCode :: SResponse -> Status -> String -> Expectation
assertCode response status code = do
response.simpleStatus `shouldBe` status
field "code" response `shouldBe` Just (String (Text.pack code))
observePaused :: WS.Connection -> IO ()
observePaused connection = bounded loop
where
loop = do
bytes <- WS.receiveData connection
case eitherDecode bytes of
Right (ProcessorUpdate _ metrics) | Paused {} <- metrics.state -> pure ()
Right (_ :: ServerMessage) -> loop
Left err -> expectationFailure err
bounded :: IO a -> IO a
bounded action = timeout 2_000_000 action >>= maybe (expectationFailure "timed out" >> error "timeout") pure