packages feed

keiro-ops-http-0.22.0.0: test/Test/WebSocketSpec.hs

module Test.WebSocketSpec (spec) where

import Control.Monad (forM_, void)
import Data.Aeson (Value (..), object, (.=))
import Data.Aeson qualified as Aeson
import Data.Aeson.KeyMap qualified as KeyMap
import Data.ByteString qualified as Bytes
import Data.ByteString.Char8 qualified as Char8
import Data.ByteString.Lazy qualified as Lazy
import Data.Text (Text)
import Data.Time (UTCTime, addUTCTime, getCurrentTime)
import Data.UUID qualified as UUID
import Effectful (Eff, IOE)
import Effectful.Error.Static (Error)
import Hasql.Pool qualified as Pool
import Hasql.Session qualified as Session
import Keiro.Ops (emptyAppHooks)
import Keiro.Ops.Http hiding (subscribe)
import Keiro.Ops.Projection (projectionGroupStatusJson)
import Keiro.ReadModel (registerReadModel)
import Keiro.ReadModel.Rebuild
import Keiro.Test.Postgres (Fixture, withFreshStore)
import Keiro.Timer qualified as Timer
import Keiro.Workflow (WorkflowId (..), WorkflowJournalEvent (..), WorkflowName (..), appendJournalEntry)
import Kiroku.Store.Connection (KirokuStore (..))
import Kiroku.Store.Effect (Store, runStoreIO)
import Kiroku.Store.Error (StoreError)
import Kiroku.Store.Transaction (runTransaction)
import Kiroku.Store.Types (EventId (..))
import Network.HTTP.Client qualified as HTTP
import Network.HTTP.Types
import Network.Wai (Application, defaultRequest)
import Network.Wai.Test qualified as Wai
import Network.WebSockets qualified as WS
import System.Timeout (timeout)
import Test.Hspec

spec :: Fixture -> Spec
spec fixture = describe "inspection sockets" $ around (withFreshStore fixture) $ do
  it "shares workflow truth, retains a subscription on refusal, and releases each sent frame" $ \store -> do
    now <- getCurrentTime
    void (stored store (appendJournalEntry (WorkflowName "approval") (WorkflowId "one") (StepRecorded "start" Null now)))
    serve store defaultOpsHttpConfig socketConfig {maxViews = 1} $ \handle app port ->
      within $ WS.runClient "127.0.0.1" port "/ws?client=one" $ \one ->
        WS.runClient "127.0.0.1" port "/ws" $ \two -> do
          forM_ [one, two] $ \connection -> send connection (subscribe "workflows") >> receive connection >>= assertType "snapshot"
          feedStats handle `shouldReturn` FeedStats 1 2 True
          -- With two queue slots, five sequential sends detect unreleased
          -- in-flight frames or a writer recursing inside its bracket.
          forM_ [1 .. 5 :: Int] $ \_ -> send one (object ["type" .= ("ping" :: Text)]) >> receive one >>= assertType "pong"
          send one (object ["type" .= ("subscribe" :: Text), "feed" .= ("workflows" :: Text), "workflow_name" .= ("other" :: Text)])
          refused <- receive one
          field "code" refused `shouldBe` Just (String "too_many_views")
          forM_
            [ (object ["type" .= ("subscribe" :: Text), "feed" .= ("unknown" :: Text)], "unknown_feed"),
              (object ["type" .= ("subscribe" :: Text), "feed" .= ("timers" :: Text), "bad_filter" .= True], "invalid_subscription")
            ]
            $ \(frame, code) -> do
              send one frame
              response <- receive one
              field "code" response `shouldBe` Just (String code)
          WS.sendTextData one ("{broken" :: Text)
          malformed <- receive one
          field "code" malformed `shouldBe` Just (String "invalid_frame")

          void (stored store (appendJournalEntry (WorkflowName "approval") (WorkflowId "one") (WorkflowCompleted now)))
          update <- receive one
          receive two `shouldReturn` update
          assertType "update" update
          field "trigger" update `shouldBe` Just (String "notify")
          field "sequence" update `shouldBe` Just (Aeson.toJSON (1 :: Int))
          response <- Wai.runSession (Wai.srequest (Wai.SRequest (Wai.setPath defaultRequest "/wf/inspect/list") "")) app
          field "items" (decode (Wai.simpleBody response)) `shouldBe` field "changed" update
          forM_ [one, two] $ \connection -> do
            send connection (object ["type" .= ("unsubscribe" :: Text), "feed" .= ("workflows" :: Text)])
            receive connection >>= assertType "unsubscribed"
          feedStats handle `shouldReturn` FeedStats 0 2 False
  it "streams timer fallback truth and projection extra-only changes using the poll encoders" $ \store ->
    serve store defaultOpsHttpConfig socketConfig $ \_ _ port -> within $
      WS.runClient "127.0.0.1" port "/ws" $ \connection -> do
        forM_ ["timers", "projection_groups"] $ \feed -> send connection (subscribe feed) >> receive connection >>= assertType "snapshot"
        now <- getCurrentTime
        let timer = Timer.TimerRequest (Timer.TimerId UUID.nil) "manager" "one" (addUTCTime (-60) now) Null
        stored store (runTransaction (Timer.scheduleTimerTx timer))
        added <- receive connection
        field "feed" added `shouldBe` Just (String "timers")
        field "trigger" added `shouldBe` Just (String "timeout")
        Just observed <- pure (field "observed_at" added)
        Aeson.Success stamp <- pure (Aeson.fromJSON observed :: Aeson.Result UTCTime)
        Just inspection <- stored store (Timer.lookupTimerInspection timer.timerId)
        field "changed" added `shouldBe` Just (Aeson.toJSON [Timer.pendingTimerItemJson stamp inspection])
        Just _ <- stored store (Timer.claimDueTimer now)
        stored store (Timer.markTimerFired timer.timerId (EventId UUID.nil)) `shouldReturn` True
        removed <- receive connection
        field "removed" removed `shouldBe` Just (Aeson.toJSON [object ["timer_id" .= UUID.toText UUID.nil]])
        _ <- stored store (registerReadModel "socket-model" 1 "shape")
        projection <- receive connection
        field "feed" projection `shouldBe` Just (String "projection_groups")
        Right page <- stored store (listProjectionGroupStatusesPage (ProjectionStatusPageRequest 100 Nothing))
        field "changed" projection `shouldBe` Just (Aeson.toJSON (map projectionGroupStatusJson page.items))
        void (stored store (appendJournalEntry (WorkflowName "head") (WorkflowId "one") (StepRecorded "head" Null now)))
        headOnly <- receive connection
        field "feed" headOnly `shouldBe` Just (String "projection_groups")
        field "changed" headOnly `shouldBe` Just (Aeson.toJSON ([] :: [Value]))
        field "removed" headOnly `shouldBe` Just (Aeson.toJSON ([] :: [Value]))
        field "visible_store_head" headOnly `shouldSatisfy` (\case Just (Number position) -> position > 0; _ -> False)
  it "checks upgrade origin, capacity and schema policy and closes active sockets" $ \store -> do
    manager <- HTTP.newManager HTTP.defaultManagerSettings
    serve store defaultOpsHttpConfig socketConfig {maxConnections = 1} $ \handle _ port -> do
      forbidden <- upgrade manager port [(hOrigin, "http://evil.test")]
      HTTP.responseStatus forbidden `shouldBe` status403
      let origin = "http://127.0.0.1:" <> show port
      within $ WS.runClientWith "127.0.0.1" port "/ws" WS.defaultConnectionOptions [("Origin", fromStringBytes origin)] $ \connection -> do
        full <- upgrade manager port []
        HTTP.responseStatus full `shouldBe` status503
        closeFeedHandle handle
        receive connection >>= assertType "goodbye"
      feedStats handle `shouldReturn` FeedStats 0 0 False
    result <- Pool.use store.pool (Session.script "ALTER TABLE keiro.keiro_timers ADD COLUMN socket_test_drift text")
    either (fail . show) pure result
    serve store defaultOpsHttpConfig socketConfig $ \_ _ port -> do
      refused <- upgrade manager port []
      HTTP.responseStatus refused `shouldBe` status409
    let allow = defaultOpsHttpConfig {schemaDrift = AllowDrift}
    serve store allow socketConfig $ \_ _ port -> within $ WS.runClient "127.0.0.1" port "/ws" $ \connection -> do
      send connection (subscribe "timers")
      receive connection >>= assertType "snapshot"

socketConfig :: FeedConfig
socketConfig = defaultFeedConfig {fallbackInterval = 5, minRefreshInterval = 0.01, readTimeout = 2, queueCapacity = 2}

serve :: KirokuStore -> OpsHttpConfig -> FeedConfig -> (FeedHandle -> Application -> Int -> IO a) -> IO a
serve store config feeds action = withFeedHandle config feeds store $ \handle -> do
  app <- opsApplicationWithFeeds config handle emptyAppHooks store
  withOpsHttpServer defaultOpsHttpServerConfig {port = 0} app (\server -> action handle app server.serverPort)

subscribe :: Text -> Value
subscribe feed = object ["type" .= ("subscribe" :: Text), "feed" .= feed]

send :: WS.Connection -> Value -> IO ()
send connection = WS.sendTextData connection . Aeson.encode

receive :: WS.Connection -> IO Value
receive connection = timeout 7000000 (WS.receiveData connection :: IO Lazy.ByteString) >>= maybe (fail "socket frame deadline exceeded") (pure . decode)

decode :: Lazy.ByteString -> Value
decode = either error id . Aeson.eitherDecode

field :: Aeson.Key -> Value -> Maybe Value
field key = \case Object fields -> KeyMap.lookup key fields; _ -> Nothing

assertType :: Text -> Value -> IO ()
assertType expected value = field "type" value `shouldBe` Just (String expected)

stored :: KirokuStore -> Eff '[Store, Error StoreError, IOE] a -> IO a
stored store action = runStoreIO store action >>= either (fail . show) pure

within :: IO a -> IO a
within action = timeout 25000000 action >>= maybe (fail "socket client deadline exceeded") pure

upgrade :: HTTP.Manager -> Int -> RequestHeaders -> IO (HTTP.Response Lazy.ByteString)
upgrade manager port headers = do
  request <- HTTP.parseRequest ("http://127.0.0.1:" <> show port <> "/ws")
  HTTP.httpLbs request {HTTP.requestHeaders = headers <> [(hConnection, "Upgrade"), (hUpgrade, "websocket"), ("Sec-WebSocket-Version", "13"), ("Sec-WebSocket-Key", "dGhlIHNhbXBsZSBub25jZQ==")]} manager

fromStringBytes :: String -> Bytes.ByteString
fromStringBytes = Char8.pack