spire-http-0.1.0.0: test/Main.hs
{-# LANGUAGE OverloadedStrings #-}
module Main (main) where
import Control.Concurrent (threadDelay)
import Data.ByteString (ByteString)
import qualified Data.ByteString as BS
import Data.IORef
import Data.List (lookup)
import Network.HTTP.Types (status200, status204, status404, status408, methodGet, methodPost, statusCode)
import System.Directory (createDirectoryIfMissing, removeDirectoryRecursive, getTemporaryDirectory)
import System.FilePath ((</>))
import Spire
import Spire.Http
import Http.Core
assert :: String -> Bool -> IO ()
assert label True = putStrLn $ " OK: " ++ label
assert label False = error $ "FAIL: " ++ label
-- | Make a simple test service that returns 200 with "ok".
echoService :: Service IO (Request ByteString) (Response ByteString)
echoService = Service $ \_ -> pure (ok [] "ok")
-- | Make a test request.
mkReq :: ByteString -> ByteString -> IO (Request ByteString)
mkReq method path = do
exts <- emptyExtensions
pure Request
{ requestMethod = method
, requestPathRaw = path
, requestPath = []
, requestQuery = []
, requestHeaders = []
, requestBody = ""
, requestExtensions = exts
}
-- ===================================================================
-- SecureHeaders tests
-- ===================================================================
testSecureHeaders :: IO ()
testSecureHeaders = do
let svc = echoService |> secureHeadersLayer defaultSecureHeaders
req <- mkReq methodGet "/test"
resp <- runService svc req
let hdrs = responseHeaders resp
assert "adds X-Content-Type-Options" (lookup "X-Content-Type-Options" hdrs == Just "nosniff")
assert "adds X-Frame-Options" (lookup "X-Frame-Options" hdrs == Just "DENY")
assert "adds Referrer-Policy" (lookup "Referrer-Policy" hdrs == Just "strict-origin-when-cross-origin")
assert "adds CSP" (lookup "Content-Security-Policy" hdrs == Just "default-src 'self'")
assert "no HSTS by default" (lookup "Strict-Transport-Security" hdrs == Nothing)
-- With HSTS enabled
let cfg = defaultSecureHeaders { strictTransportSecurity = Just "max-age=63072000" }
svc2 = echoService |> secureHeadersLayer cfg
resp2 <- runService svc2 req
let hdrs2 = responseHeaders resp2
assert "HSTS when configured" (lookup "Strict-Transport-Security" hdrs2 == Just "max-age=63072000")
-- ===================================================================
-- RequestId tests
-- ===================================================================
testRequestId :: IO ()
testRequestId = do
ridLayer <- requestIdLayer
let svc = echoService |> ridLayer
req <- mkReq methodGet "/test"
-- First request gets an ID
resp1 <- runService svc req
let hdrs1 = responseHeaders resp1
assert "adds X-Request-Id header" (lookup "X-Request-Id" hdrs1 /= Nothing)
-- Second request gets a different ID
req2 <- mkReq methodGet "/test2"
resp2 <- runService svc req2
let rid1 = lookup "X-Request-Id" (responseHeaders resp1)
rid2 = lookup "X-Request-Id" (responseHeaders resp2)
assert "sequential IDs differ" (rid1 /= rid2)
-- Existing X-Request-Id is preserved
exts <- emptyExtensions
let req3 = Request
{ requestMethod = methodGet
, requestPathRaw = "/test3"
, requestPath = []
, requestQuery = []
, requestHeaders = [("X-Request-Id", "custom-123")]
, requestBody = ""
, requestExtensions = exts
}
resp3 <- runService svc req3
assert "preserves existing ID" (lookup "X-Request-Id" (responseHeaders resp3) == Just "custom-123")
-- RequestId stored in extensions
exts4 <- emptyExtensions
let checkExtSvc = Service $ \req -> do
mRid <- lookupExtension @RequestId (requestExtensions req)
case mRid of
Just (RequestId rid) -> pure (ok [] rid)
Nothing -> pure (ok [] "no-id")
svc4 = checkExtSvc |> ridLayer
req4 = Request
{ requestMethod = methodGet
, requestPathRaw = "/ext"
, requestPath = []
, requestQuery = []
, requestHeaders = []
, requestBody = ""
, requestExtensions = exts4
}
resp4 <- runService svc4 req4
assert "RequestId in extensions" (responseBody resp4 /= "no-id")
-- ===================================================================
-- Trace tests
-- ===================================================================
testTrace :: IO ()
testTrace = do
ref <- newIORef ([] :: [TraceEntry])
let svc = echoService |> traceLayer (\e -> modifyIORef' ref (++ [e]))
req <- mkReq methodPost "/users"
_ <- runService svc req
entries <- readIORef ref
assert "trace captures one entry" (length entries == 1)
let e = head entries
assert "trace: method" (traceMethod e == methodPost)
assert "trace: path" (tracePath e == "/users")
assert "trace: status 200" (traceStatus e == 200)
-- ===================================================================
-- Composition test: all three layers stacked
-- ===================================================================
testComposition :: IO ()
testComposition = do
ref <- newIORef ([] :: [TraceEntry])
ridLayer <- requestIdLayer
let svc = echoService
|> ridLayer
|> traceLayer (\e -> modifyIORef' ref (++ [e]))
|> secureHeadersLayer defaultSecureHeaders
req <- mkReq methodGet "/health"
resp <- runService svc req
let hdrs = responseHeaders resp
assert "composed: has security headers" (lookup "X-Frame-Options" hdrs == Just "DENY")
assert "composed: has request ID" (lookup "X-Request-Id" hdrs /= Nothing)
entries <- readIORef ref
assert "composed: trace fired" (length entries == 1)
-- ===================================================================
-- CORS tests
-- ===================================================================
testCors :: IO ()
testCors = do
let svc = echoService |> corsLayer permissiveCors
-- Preflight OPTIONS -> 204 with CORS headers
exts1 <- emptyExtensions
let optReq = Request
{ requestMethod = "OPTIONS"
, requestPathRaw = "/test"
, requestPath = ["test"]
, requestQuery = []
, requestHeaders = [("origin", "http://example.com")]
, requestBody = ""
, requestExtensions = exts1
}
resp1 <- runService svc optReq
assert "CORS: preflight 204" (statusCode (responseStatus resp1) == 204)
assert "CORS: Allow-Origin on preflight" (lookup "Access-Control-Allow-Origin" (responseHeaders resp1) == Just "*")
assert "CORS: Allow-Methods on preflight" (lookup "Access-Control-Allow-Methods" (responseHeaders resp1) /= Nothing)
-- Normal GET -> 200 with CORS headers added
req2 <- mkReq methodGet "/test"
resp2 <- runService svc req2
assert "CORS: normal 200" (statusCode (responseStatus resp2) == 200)
assert "CORS: Allow-Origin on GET" (lookup "Access-Control-Allow-Origin" (responseHeaders resp2) == Just "*")
-- ===================================================================
-- Compression tests
-- ===================================================================
testCompression :: IO ()
testCompression = do
-- Big body service (above min threshold)
let bigBody = BS.replicate 2000 65 -- 2000 'A's
bigSvc = Service $ \_ -> pure (ok [("Content-Type", "text/plain")] bigBody)
svc = bigSvc |> compressionLayer defaultCompression
-- With Accept-Encoding: gzip
exts1 <- emptyExtensions
let req1 = Request
{ requestMethod = "GET"
, requestPathRaw = "/big"
, requestPath = ["big"]
, requestQuery = []
, requestHeaders = [("accept-encoding", "gzip, deflate")]
, requestBody = ""
, requestExtensions = exts1
}
resp1 <- runService svc req1
assert "compression: Content-Encoding gzip" (lookup "Content-Encoding" (responseHeaders resp1) == Just "gzip")
assert "compression: body smaller" (BS.length (responseBody resp1) < 2000)
-- Without Accept-Encoding -> no compression
req2 <- mkReq methodGet "/big"
resp2 <- runService svc req2
assert "compression: no encoding without header" (lookup "Content-Encoding" (responseHeaders resp2) == Nothing)
assert "compression: body unchanged" (BS.length (responseBody resp2) == 2000)
-- Small body -> skip compression
let smallSvc = echoService |> compressionLayer defaultCompression
exts3 <- emptyExtensions
let req3 = Request
{ requestMethod = "GET"
, requestPathRaw = "/small"
, requestPath = ["small"]
, requestQuery = []
, requestHeaders = [("accept-encoding", "gzip")]
, requestBody = ""
, requestExtensions = exts3
}
resp3 <- runService smallSvc req3
assert "compression: skip small body" (lookup "Content-Encoding" (responseHeaders resp3) == Nothing)
-- ===================================================================
-- Timeout tests
-- ===================================================================
testTimeout :: IO ()
testTimeout = do
-- Fast handler: completes within timeout
let fastSvc :: Service IO (Request ByteString) (Response ByteString)
fastSvc = Service $ \_ -> pure (ok [] "fast")
svc1 = fastSvc |> timeoutLayer 1000000 -- 1 second
req1 <- mkReq methodGet "/fast"
resp1 <- runService svc1 req1
assert "timeout: fast handler succeeds" (statusCode (responseStatus resp1) == 200)
assert "timeout: fast body" (responseBody resp1 == "fast")
-- Slow handler: exceeds timeout
let slowSvc :: Service IO (Request ByteString) (Response ByteString)
slowSvc = Service $ \_ -> do
threadDelay 2000000 -- 2 seconds
pure (ok [] "slow")
svc2 = slowSvc |> timeoutLayer 200000 -- 200ms timeout
req2 <- mkReq methodGet "/slow"
resp2 <- runService svc2 req2
assert "timeout: slow handler -> 408" (statusCode (responseStatus resp2) == 408)
-- ===================================================================
-- Static file serving tests
-- ===================================================================
-- | A 404 inner service for static file tests.
notFoundService :: Service IO (Request ByteString) (Response ByteString)
notFoundService = Service $ \_ -> pure (notFound [] "not found")
testStaticFiles :: IO ()
testStaticFiles = do
-- Set up temp directory with a test file
tmpBase <- getTemporaryDirectory
let tmpDir = tmpBase </> "spire-http-static-test"
createDirectoryIfMissing True tmpDir
BS.writeFile (tmpDir </> "test.txt") "hello static"
BS.writeFile (tmpDir </> "style.css") "body{}"
let cfg = defaultStaticConfig "/static" tmpDir
svc = notFoundService |> staticFilesLayer cfg
-- Serve existing file with correct content-type
req1 <- mkReq methodGet "/static/test.txt"
resp1 <- runService svc req1
assert "static: serves existing file" (statusCode (responseStatus resp1) == 200)
assert "static: correct body" (responseBody resp1 == "hello static")
assert "static: text/plain content-type" (lookup "Content-Type" (responseHeaders resp1) == Just "text/plain")
-- Serve CSS with correct content-type
req2 <- mkReq methodGet "/static/style.css"
resp2 <- runService svc req2
assert "static: CSS content-type" (lookup "Content-Type" (responseHeaders resp2) == Just "text/css")
-- Non-existent file falls through to inner service
req3 <- mkReq methodGet "/static/nonexistent.txt"
resp3 <- runService svc req3
assert "static: nonexistent falls through" (statusCode (responseStatus resp3) == 404)
-- Directory traversal is rejected
req4 <- mkReq methodGet "/static/../etc/passwd"
resp4 <- runService svc req4
assert "static: traversal rejected" (statusCode (responseStatus resp4) == 400)
-- Non-matching prefix falls through
req5 <- mkReq methodGet "/other/test.txt"
resp5 <- runService svc req5
assert "static: non-matching prefix falls through" (statusCode (responseStatus resp5) == 404)
-- Cleanup
removeDirectoryRecursive tmpDir
-- ===================================================================
-- SPA fallback tests
-- ===================================================================
testSpaFallback :: IO ()
testSpaFallback = do
-- Set up temp directory with index.html
tmpBase <- getTemporaryDirectory
let tmpDir = tmpBase </> "spire-http-spa-test"
createDirectoryIfMissing True tmpDir
BS.writeFile (tmpDir </> "index.html") "<html>SPA</html>"
let svc = notFoundService |> spaFallbackLayer tmpDir
-- SPA route (no extension, not /api) -> serve index.html
req1 <- mkReq methodGet "/dashboard"
resp1 <- runService svc req1
assert "spa: fallback serves index.html" (statusCode (responseStatus resp1) == 200)
assert "spa: correct body" (responseBody resp1 == "<html>SPA</html>")
assert "spa: html content-type" (lookup "Content-Type" (responseHeaders resp1) == Just "text/html")
-- File-like path (has extension) -> no fallback
req2 <- mkReq methodGet "/bundle.js"
resp2 <- runService svc req2
assert "spa: file path not intercepted" (statusCode (responseStatus resp2) == 404)
-- API path -> no fallback
req3 <- mkReq methodGet "/api/users"
resp3 <- runService svc req3
assert "spa: API path not intercepted" (statusCode (responseStatus resp3) == 404)
-- Inner service returns 200 -> no fallback
let okSvc = echoService |> spaFallbackLayer tmpDir
req4 <- mkReq methodGet "/dashboard"
resp4 <- runService okSvc req4
assert "spa: 200 passes through" (statusCode (responseStatus resp4) == 200)
assert "spa: 200 body unchanged" (responseBody resp4 == "ok")
-- Cleanup
removeDirectoryRecursive tmpDir
-- ===================================================================
-- Main
-- ===================================================================
main :: IO ()
main = do
putStrLn "spire-http tests:"
putStrLn ""
putStrLn "SecureHeaders:"
testSecureHeaders
putStrLn ""
putStrLn "RequestId:"
testRequestId
putStrLn ""
putStrLn "Trace:"
testTrace
putStrLn ""
putStrLn "Composition:"
testComposition
putStrLn ""
putStrLn "CORS:"
testCors
putStrLn ""
putStrLn "Compression:"
testCompression
putStrLn ""
putStrLn "Timeout:"
testTimeout
putStrLn ""
putStrLn "Static files:"
testStaticFiles
putStrLn ""
putStrLn "SPA fallback:"
testSpaFallback
putStrLn ""
putStrLn "All spire-http tests passed."