packages feed

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

module Test.ComposeSpec (spec) where

import Control.Monad (forM_, void)
import Data.Aeson (Value (..), encode, object, (.=))
import Data.Aeson qualified as Aeson
import Data.Aeson.KeyMap qualified as KeyMap
import Data.ByteString.Builder qualified as Builder
import Data.ByteString.Lazy qualified as Lazy
import Data.Text (Text)
import Data.Time (getCurrentTime)
import Effectful (Eff, IOE)
import Effectful.Error.Static (Error)
import Keiro.Ops (emptyAppHooks)
import Keiro.Ops.Http
import Keiro.Ops.Http.Application (opsApplicationWithBudget)
import Keiro.Ops.Http.Config qualified as Config
import Keiro.Ops.Http.WebSocket (feedsMiddleware)
import Keiro.Test.Postgres (Fixture, withFreshStore)
import Keiro.Workflow (WorkflowId (..), WorkflowJournalEvent (..), WorkflowName (..), appendJournalEntry)
import Kiroku.Metrics qualified as Metrics
import Kiroku.Store.Connection (KirokuStore)
import Kiroku.Store.Effect (Store, runStoreIO)
import Kiroku.Store.Error (StoreError)
import Network.HTTP.Types
import Network.Wai
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 "Compose" $ do
  it "strips exactly one raw segment and preserves route spelling, queries and streaming bodies" $ do
    let app = composed defaultComposeConfig [MountedSurface keiroPrefix echo]
    forM_
      [ ("/keiro", "/"),
        ("/keiro/", "/"),
        ("/k%65iro/ws?from=3", "/ws"),
        ("/keiro/a%2Fb/%25/%E6%97%A5/", "/a%2Fb/%25/%E6%97%A5/"),
        ("/keiro/x//y/", "/x//y/")
      ]
      $ \(path, relative) -> do
        let req = Wai.setPath defaultRequest path
        Just stripped <- pure (stripSurfacePrefix keiroPrefix req)
        rawPathInfo stripped `shouldBe` relative
        rawQueryString stripped `shouldBe` rawQueryString req
        queryString stripped `shouldBe` queryString req
        response <- get app req
        Wai.simpleStatus response `shouldBe` status200
        Wai.simpleBody response `shouldBe` Lazy.fromStrict relative
    stripSurfacePrefix keiroPrefix ((Wai.setPath defaultRequest "/keiro/one") {rawPathInfo = "/keiro/two"}) `shouldSatisfy` maybe True (const False)
  it "validates mounts, lists them in order, refuses absent surfaces and applies CORS once" $ do
    forM_ ["", "-x", "UPPER", "x/y"] $ \prefix -> mkSurfacePrefix prefix `shouldBe` Left (InvalidPrefix prefix)
    case composeSurfaces defaultComposeConfig [] of Left NoSurfaces -> pure (); _ -> expectationFailure "empty mount accepted"
    case composeSurfaces defaultComposeConfig [MountedSurface keiroPrefix echo, MountedSurface keiroPrefix echo] of
      Left (DuplicatePrefix "keiro") -> pure ()
      _ -> expectationFailure "duplicate mount accepted"
    let app = composed (ComposeConfig ["http://ui.test"]) [MountedSurface keiroPrefix echo, MountedSurface kirokuPrefix existingCors]
    index <- get app (Wai.setPath defaultRequest "/")
    decode (Wai.simpleBody index) `shouldBe` object ["surfaces" .= [object ["prefix" .= ("keiro" :: Text), "path" .= ("/keiro" :: Text)], object ["prefix" .= ("kiroku" :: Text), "path" .= ("/kiroku" :: Text)]]]
    refused <- get app (Wai.setPath defaultRequest "/pgmq/queues")
    Wai.simpleStatus refused `shouldBe` status404
    method <- get app ((Wai.setPath defaultRequest "/") {requestMethod = methodPost})
    lookup hAllow (Wai.simpleHeaders method) `shouldBe` Just "GET"
    forM_ [("/keiro/x", "http://ui.test"), ("/kiroku/x", "http://already.test")] $ \(path, origin) -> do
      response <- get app ((Wai.setPath defaultRequest path) {requestHeaders = [(hOrigin, "http://ui.test")]})
      filter ((== "Access-Control-Allow-Origin") . fst) (Wai.simpleHeaders response) `shouldBe` [("Access-Control-Allow-Origin", origin)]
      lookup "Access-Control-Allow-Credentials" (Wai.simpleHeaders response) `shouldBe` Nothing
    let preflight = (Wai.setPath defaultRequest "/keiro/x") {requestMethod = methodOptions, requestHeaders = [(hOrigin, "http://ui.test"), ("Access-Control-Request-Method", "GET"), ("Access-Control-Request-Headers", "X-Request-Id")]}
    response <- get app preflight
    Wai.simpleStatus response `shouldBe` status204
    lookup "Access-Control-Allow-Headers" (Wai.simpleHeaders response) `shouldBe` Just "X-Request-Id"
    forM_ [composed defaultComposeConfig [MountedSurface keiroPrefix echo], app] $ \candidate -> do
      denied <- get candidate ((Wai.setPath defaultRequest "/keiro/x") {requestHeaders = [(hOrigin, "http://other.test")]})
      lookup "Access-Control-Allow-Origin" (Wai.simpleHeaders denied) `shouldBe` Nothing
  around (withFreshStore fixture) $ it "mounts real Keiro and Kiroku applications and both socket protocols on one port" $ \store -> do
    now <- getCurrentTime
    void (stored store (appendJournalEntry (WorkflowName "compose") (WorkflowId "one") (StepRecorded "start" Null now)))
    let config = defaultOpsHttpConfig {Config.allowedOrigins = ["http://ui.test"], mutations = MutationsEnabled}
        metricsConfig = Metrics.defaultConfig {Metrics.wsPushIntervalUs = 30000000}
    metrics <- Metrics.newKirokuMetrics store
    providers <- Metrics.storeServerProviders metricsConfig metrics store
    let kiroku = Metrics.combinedAppWithProviders metricsConfig metrics [] providers
    withFeedHandle config defaultFeedConfig store $ \handle -> do
      http <- opsApplicationWithBudget config (handleBudget handle) emptyAppHooks store
      let keiro = feedsMiddleware config handle http
          app = composed (ComposeConfig config.allowedOrigins) [MountedSurface keiroPrefix keiro, MountedSurface kirokuPrefix kiroku]
      forM_ [(keiro, "/health", "/keiro/health"), (kiroku, "/capabilities", "/kiroku/capabilities")] $ \(standalone, relative, mounted) -> do
        plain <- get standalone (Wai.setPath defaultRequest relative)
        through <- get app ((Wai.setPath defaultRequest mounted) {requestHeaders = [(hOrigin, "http://ui.test")]})
        Wai.simpleStatus through `shouldBe` Wai.simpleStatus plain
        Wai.simpleBody through `shouldBe` Wai.simpleBody plain
        filter ((== "Access-Control-Allow-Origin") . fst) (Wai.simpleHeaders through) `shouldBe` [("Access-Control-Allow-Origin", "http://ui.test")]
      forM_ [("http://ui.test", status200), ("http://evil.test", status403)] $ \(origin, status) -> do
        let request = (Wai.setPath defaultRequest "/keiro/outbox/maintenance-pass") {requestMethod = methodPost, requestHeaders = [(hOrigin, origin)]}
        response <- Wai.runSession (Wai.srequest (Wai.SRequest request "")) app
        Wai.simpleStatus response `shouldBe` status
      withOpsHttpServer defaultOpsHttpServerConfig {port = 0} app $ \server -> within $ do
        WS.runClient "127.0.0.1" server.serverPort "/kiroku/ws/metrics" $ \connection -> do
          receive connection >>= hasType "snapshot"
          WS.sendTextData connection (encode (object ["type" .= ("ping" :: Text)]))
          receive connection >>= hasType "pong"
        WS.runClient "127.0.0.1" server.serverPort "/keiro/ws?from=3" $ \connection -> do
          WS.sendTextData connection (encode (object ["type" .= ("subscribe" :: Text), "feed" .= ("workflows" :: Text)]))
          receive connection >>= hasType "snapshot"
          void (stored store (appendJournalEntry (WorkflowName "compose") (WorkflowId "one") (WorkflowCompleted now)))
          receive connection >>= hasType "update"
          closeFeedHandle handle
          receive connection >>= hasType "goodbye"
      feedStats handle `shouldReturn` FeedStats 0 0 False

composed :: ComposeConfig -> [MountedSurface] -> Application
composed config = either (error . show) id . composeSurfaces config

echo :: Application
echo req respond = respond (responseStream status200 [] (\write flush -> write (Builder.byteString (rawPathInfo req)) >> flush))

existingCors :: Application
existingCors req respond = echo req (respond . mapResponseHeaders (("Access-Control-Allow-Origin", "http://already.test") :))

get :: Application -> Request -> IO Wai.SResponse
get app req = Wai.runSession (Wai.srequest (Wai.SRequest req "")) app

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

receive :: WS.Connection -> IO Value
receive connection = decode <$> (WS.receiveData connection :: IO Lazy.ByteString)

hasType :: Text -> Value -> Expectation
hasType kind = \case Object fields -> KeyMap.lookup "type" fields `shouldBe` Just (String kind); value -> expectationFailure (show value)

within :: IO a -> IO a
within action = timeout 15000000 action >>= maybe (fail "composed socket deadline") pure

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