packages feed

shibuya-metrics-0.11.0.0: test/Shibuya/Metrics/CorsSpec.hs

module Shibuya.Metrics.CorsSpec (spec) where

import Control.Exception (IOException)
import Control.Monad (forM_)
import Data.Aeson (decode, object, (.=))
import Data.ByteString (ByteString)
import Data.Char (toLower)
import Data.List (isInfixOf)
import Data.Text (Text)
import Network.HTTP.Types (RequestHeaders, hContentType, status200, status204, status403, status404, status503)
import Network.Wai (Application, requestHeaders, requestMethod, responseLBS)
import Network.Wai.Test (SResponse (..), defaultRequest, request, runSession, setPath)
import Shibuya.App (Master)
import Shibuya.Core.Metrics (ProcessorId (..))
import Shibuya.Metrics.Config (MetricsServerConfig (..), defaultConfig)
import Shibuya.Metrics.Cors (corsMiddleware, isAllowedOrigin)
import Shibuya.Metrics.Server (combinedApp, startMetricsServer)
import Shibuya.Metrics.TestSupport (registerFailedProcessor, registerIdleProcessor, withMaster)
import Shibuya.Metrics.WebSocket (newWebSocketState)
import Test.Hspec (Spec, around, describe, it, shouldBe, shouldSatisfy, shouldThrow)

spec :: Spec
spec = around withMaster $ do
  describe "cross-origin HTTP policy" $ do
    it "answers allowed preflights with an empty 204 and explicit policy" $ \master -> do
      app <- appFor master
      forM_ ["GET", "POST", "OPTIONS"] $ \method -> do
        response <- send app "OPTIONS" "/metrics" (preflight method)
        response.simpleStatus `shouldBe` status204
        response.simpleBody `shouldBe` ""
        lookup "Access-Control-Allow-Origin" response.simpleHeaders `shouldBe` Just origin
        lookup "Access-Control-Allow-Methods" response.simpleHeaders `shouldBe` Just "GET, POST, OPTIONS"
        lookup "Access-Control-Allow-Headers" response.simpleHeaders `shouldBe` Just "Content-Type"
        lookup "Access-Control-Max-Age" response.simpleHeaders `shouldBe` Just "600"
        lookup "Vary" response.simpleHeaders `shouldBe` Just "Origin, Access-Control-Request-Method, Access-Control-Request-Headers"
        lookup "Access-Control-Allow-Credentials" response.simpleHeaders `shouldBe` Nothing

    it "adds headers to every allowed read route, including 404s" $ \master -> do
      _ <- registerIdleProcessor master (ProcessorId "known")
      app <- appFor master
      forM_ ["/metrics", "/metrics/known", "/metrics/prometheus", "/health", "/health/live", "/health/ready", "/metrics/missing", "/ws", "/unknown"] $ \path -> do
        plain <- send app "GET" path []
        response <- send app "GET" path [("Origin", origin)]
        response.simpleStatus `shouldBe` plain.simpleStatus
        response.simpleBody `shouldBe` plain.simpleBody
        lookup "Access-Control-Allow-Origin" response.simpleHeaders `shouldBe` Just origin
        lookup "Vary" response.simpleHeaders `shouldBe` Just "Origin"
        lookup "Access-Control-Allow-Credentials" response.simpleHeaders `shouldBe` Nothing
      missing <- send app "GET" "/metrics/missing" [("Origin", origin)]
      missing.simpleStatus `shouldBe` status404
      decode missing.simpleBody
        `shouldBe` Just
          ( object
              ["error" .= ("Processor not found" :: Text), "code" .= ("processor_not_found" :: Text), "processor" .= ("missing" :: Text)]
          )

    it "adds allowed-origin headers to unhealthy 503 responses" $ \master -> do
      _ <- registerFailedProcessor master (ProcessorId "failed")
      app <- appFor master
      response <- send app "GET" "/health/ready" [("Origin", origin)]
      response.simpleStatus `shouldBe` status503
      lookup "Access-Control-Allow-Origin" response.simpleHeaders `shouldBe` Just origin

    it "refuses disallowed preflights but preserves ordinary read responses" $ \master -> do
      app <- appFor master
      response <- send app "OPTIONS" "/metrics" [("Origin", "https://evil.example.com"), ("Access-Control-Request-Method", "GET")]
      response.simpleStatus `shouldBe` status403
      decode response.simpleBody `shouldBe` Just (object ["error" .= ("Origin not allowed" :: Text), "code" .= ("origin_not_allowed" :: Text)])
      lookup hContentType response.simpleHeaders `shouldBe` Just "application/json"
      noAccessHeaders response
      lookup "Vary" response.simpleHeaders `shouldBe` Just "Origin, Access-Control-Request-Method, Access-Control-Request-Headers"
      plain <- send app "GET" "/metrics" []
      refused <- send app "GET" "/metrics" [("Origin", "https://evil.example.com")]
      refused.simpleStatus `shouldBe` status200
      refused.simpleBody `shouldBe` plain.simpleBody
      noAccessHeaders refused
      lookup "Vary" refused.simpleHeaders `shouldBe` Just "Origin"
      lookup "Vary" plain.simpleHeaders `shouldBe` Just "Origin"

    it "validates and echoes requested header tokens" $ \master -> do
      app <- appFor master
      response <- send app "OPTIONS" "/metrics" (preflight "GET" <> [("Access-Control-Request-Headers", "x-trace-id, Content-Type")])
      response.simpleStatus `shouldBe` status204
      lookup "Access-Control-Allow-Headers" response.simpleHeaders `shouldBe` Just "x-trace-id, Content-Type"
      forM_ ["", "bad header", "x-trace-id,", "x-trace-id\r\nInjected: yes", "*"] $ \headers -> do
        invalid <- send app "OPTIONS" "/metrics" (preflight "GET" <> [("Access-Control-Request-Headers", headers)])
        invalid.simpleStatus `shouldBe` status403
        noAccessHeaders invalid
      repeatedEmpty <-
        send
          app
          "OPTIONS"
          "/metrics"
          (preflight "GET" <> [("Access-Control-Request-Headers", ""), ("Access-Control-Request-Headers", "x-trace-id")])
      repeatedEmpty.simpleStatus `shouldBe` status403
      noAccessHeaders repeatedEmpty

    it "rejects forbidden, malformed, and repeated requested methods" $ \master -> do
      app <- appFor master
      forM_ ["DELETE", "get", "GET, POST", ""] $ \method -> do
        response <- send app "OPTIONS" "/metrics" (preflight method)
        response.simpleStatus `shouldBe` status403
        decode response.simpleBody `shouldBe` Just (object ["error" .= ("CORS method not allowed" :: Text), "code" .= ("cors_method_not_allowed" :: Text)])
        noAccessHeaders response
      repeated <- send app "OPTIONS" "/metrics" (preflight "GET" <> [("Access-Control-Request-Method", "POST")])
      repeated.simpleStatus `shouldBe` status403

    it "fails closed for malformed or multiple Origin values" $ \master -> do
      app <- appFor master
      forM_ [[("Origin", "null")], [("Origin", "")], [("Origin", "https://ops.example.com https://evil.example.com")], [("Origin", "https://ops.example.com,https://evil.example.com")], [("Origin", origin <> "/")], [("Origin", origin), ("origin", origin)]] $ \origins -> do
        response <- send app "OPTIONS" "/metrics" (origins <> [("Access-Control-Request-Method", "GET")])
        response.simpleStatus `shouldBe` status403
        noAccessHeaders response
        ordinary <- send app "GET" "/metrics" origins
        noAccessHeaders ordinary

    it "routes plain OPTIONS normally when preflight headers are absent" $ \master -> do
      app <- appFor master
      response <- send app "OPTIONS" "/unknown" [("Origin", origin)]
      response.simpleStatus `shouldBe` status404
      methodOnly <- send app "OPTIONS" "/unknown" [("Access-Control-Request-Method", "GET")]
      methodOnly.simpleStatus `shouldBe` status404

    it "merges Vary tokens and replaces any stale CORS policy headers" $ \_ -> do
      let inner _ respond =
            respond $
              responseLBS
                status200
                [("Vary", "Accept-Encoding, origin"), ("vary", "Accept-Language"), ("Access-Control-Allow-Origin", "*"), ("Access-Control-Allow-Credentials", "true")]
                "body"
          app = corsMiddleware ["https://ops.example.com"] inner
      forM_ [[], [("Origin", origin)], [("Origin", "https://evil.example.com")]] $ \headers -> do
        response <- send app "GET" "/" headers
        lookup "Vary" response.simpleHeaders `shouldBe` Just "Accept-Encoding, origin, Accept-Language"
        length (filter ((== "Vary") . fst) response.simpleHeaders) `shouldBe` 1
        lookup "Access-Control-Allow-Credentials" response.simpleHeaders `shouldBe` Nothing
      allowed <- send app "GET" "/" [("Origin", origin)]
      filter ((== "Access-Control-Allow-Origin") . fst) allowed.simpleHeaders `shouldBe` [("Access-Control-Allow-Origin", origin)]
      refused <- send app "GET" "/" [("Origin", "https://evil.example.com")]
      noAccessHeaders refused
      wildcard <- send (corsMiddleware ["https://ops.example.com"] (\_ respond -> respond $ responseLBS status200 [("Vary", "*")] "body")) "GET" "/" []
      lookup "Vary" wildcard.simpleHeaders `shouldBe` Just "*"

    it "compares origin bytes exactly without normalizing or prefix matching" $ \_ -> do
      let allowed = isAllowedOrigin ["https://ops.example.com"]
      allowed origin `shouldBe` True
      forM_ ["https://OPS.example.com", "https://ops.example.com:443", "https://ops.example.com.evil", "https://ops.example.com/"] $ \other -> allowed other `shouldBe` False

    it "rejects invalid configured origins before opening a listener" $ \master ->
      forM_ ["*", "", "https://ops.example.com/", "ops.example.com", "1https://ops.example.com", "://ops.example.com", "https://", "https://ops.example.com?x=1", "https://ops.example.com#x", "https://ops. example.com", "https://ops.example.com\t"] $ \invalid ->
        startMetricsServer defaultConfig {corsAllowedOrigins = [invalid]} master
          `shouldThrow` (\err -> "corsAllowedOrigins" `isInfixOf` show (err :: IOException))

origin :: ByteString
origin = "https://ops.example.com"

preflight :: ByteString -> RequestHeaders
preflight method = [("Origin", origin), ("Access-Control-Request-Method", method)]

send :: Application -> ByteString -> ByteString -> RequestHeaders -> IO SResponse
send app method path headers =
  runSession
    (request $ setPath defaultRequest {requestMethod = method, requestHeaders = headers} path)
    app

appFor :: Master -> IO Application
appFor master = do
  wsState <- newWebSocketState defaultConfig.wsMaxConnections
  pure $ combinedApp defaultConfig {corsAllowedOrigins = ["https://ops.example.com"]} master wsState []

noAccessHeaders :: SResponse -> IO ()
noAccessHeaders response =
  response.simpleHeaders
    `shouldSatisfy` (not . any (\(name, _) -> "access-control-" `isInfixOf` map toLower (show name)))