packages feed

http-tower-hs-0.2.0.0: test/Network/HTTP/Tower/ClientTLSSpec.hs

{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE NumericUnderscores #-}

module Network.HTTP.Tower.ClientTLSSpec (spec) where

import Control.Concurrent (threadDelay)
import Control.Exception (try, SomeException)
import Data.Text (pack)
import qualified Network.HTTP.Client as HTTP
import qualified Network.HTTP.Types as HTTP
import System.Directory (createDirectoryIfMissing)
import System.Process (callCommand, readProcess)
import Test.Hspec

import qualified TestContainers as TC
import TestContainers.Hspec (withContainers)

import Network.HTTP.Tower.Client (newClientWithTLS, runRequest)
import Tower.Error (ServiceError(..))

-- | Generate test certificates: CA, server cert, client cert.
generateCerts :: FilePath -> IO ()
generateCerts dir = do
  createDirectoryIfMissing True dir
  -- CA
  callCommand $ "openssl genrsa -out " <> dir <> "/ca-key.pem 2048 2>/dev/null"
  callCommand $ "openssl req -new -x509 -key " <> dir <> "/ca-key.pem"
    <> " -out " <> dir <> "/ca.pem -days 1 -subj '/CN=Test CA' 2>/dev/null"
  -- Server (signed by CA, with SAN for localhost)
  callCommand $ "openssl genrsa -out " <> dir <> "/server-key.pem 2048 2>/dev/null"
  callCommand $ "openssl req -new -key " <> dir <> "/server-key.pem"
    <> " -out " <> dir <> "/server.csr -subj '/CN=localhost' 2>/dev/null"
  writeFile (dir <> "/san.cnf") $ unlines
    [ "[v3_req]"
    , "subjectAltName = DNS:localhost,IP:127.0.0.1"
    ]
  callCommand $ "openssl x509 -req -in " <> dir <> "/server.csr"
    <> " -CA " <> dir <> "/ca.pem -CAkey " <> dir <> "/ca-key.pem"
    <> " -CAcreateserial -out " <> dir <> "/server.pem -days 1"
    <> " -extensions v3_req -extfile " <> dir <> "/san.cnf 2>/dev/null"
  -- Client (signed by CA)
  callCommand $ "openssl genrsa -out " <> dir <> "/client-key.pem 2048 2>/dev/null"
  callCommand $ "openssl req -new -key " <> dir <> "/client-key.pem"
    <> " -out " <> dir <> "/client.csr -subj '/CN=Test Client' 2>/dev/null"
  callCommand $ "openssl x509 -req -in " <> dir <> "/client.csr"
    <> " -CA " <> dir <> "/ca.pem -CAkey " <> dir <> "/ca-key.pem"
    <> " -CAcreateserial -out " <> dir <> "/client.pem -days 1 2>/dev/null"

nginxConf :: String
nginxConf = unlines
  [ "events { worker_connections 64; }"
  , "http {"
  , "  server {"
  , "    listen 443 ssl;"
  , "    ssl_certificate /certs/server.pem;"
  , "    ssl_certificate_key /certs/server-key.pem;"
  , "    ssl_client_certificate /certs/ca.pem;"
  , "    ssl_verify_client on;"
  , "    location / { return 200 'mTLS OK'; }"
  , "  }"
  , "}"
  ]

dockerAvailable :: IO Bool
dockerAvailable = do
  result <- try (readProcess "docker" ["info"] "") :: IO (Either SomeException String)
  pure $ case result of
    Right _ -> True
    Left _  -> False

spec :: Spec
spec = describe "Client TLS (Docker)" $ beforeAll dockerAvailable $ do

  it "connects with custom CA to system HTTPS" $ \isAvailable -> do
    -- No Docker needed for this one — just test that newClientWithTLS works
    client <- newClientWithTLS Nothing Nothing
    req <- HTTP.parseRequest "https://example.com"
    result <- runRequest client req
    case result of
      Right resp -> HTTP.statusCode (HTTP.responseStatus resp) `shouldBe` 200
      Left err -> expectationFailure $ "Expected success, got: " <> show err

  it "connects with mTLS client cert to nginx" $ \isAvailable -> do
    if not isAvailable
      then pendingWith "Docker not available"
      else do
        let certDir = "/tmp/http-tower-hs-test-certs"
        generateCerts certDir
        writeFile (certDir <> "/nginx.conf") nginxConf

        withContainers (setupNginx certDir) $ \port -> do
          threadDelay 2_000_000

          client <- newClientWithTLS
            (Just (certDir <> "/ca.pem"))
            (Just (certDir <> "/client.pem", certDir <> "/client-key.pem"))
          req <- HTTP.parseRequest $ "https://localhost:" <> show port <> "/"
          result <- runRequest client req
          case result of
            Right resp -> HTTP.statusCode (HTTP.responseStatus resp) `shouldBe` 200
            Left err -> expectationFailure $ "mTLS request failed: " <> show err

  it "fails without client cert when mTLS is required" $ \isAvailable -> do
    if not isAvailable
      then pendingWith "Docker not available"
      else do
        let certDir = "/tmp/http-tower-hs-test-certs"
        generateCerts certDir
        writeFile (certDir <> "/nginx.conf") nginxConf

        withContainers (setupNginx certDir) $ \port -> do
          threadDelay 2_000_000

          client <- newClientWithTLS
            (Just (certDir <> "/ca.pem"))
            Nothing  -- no client cert
          req <- HTTP.parseRequest $ "https://localhost:" <> show port <> "/"
          result <- runRequest client req
          case result of
            Left _ -> pure ()  -- TLS handshake error
            Right resp ->
              -- nginx returns 400 when client cert is missing
              HTTP.statusCode (HTTP.responseStatus resp) `shouldSatisfy` (>= 400)

setupNginx :: FilePath -> TC.TestContainer Int
setupNginx certDir = do
  container <- TC.run $ TC.containerRequest (TC.fromTag "nginx:alpine")
    TC.& TC.setExpose [443]
    TC.& TC.setVolumeMounts
        [ (pack certDir, "/certs")
        , (pack (certDir <> "/nginx.conf"), "/etc/nginx/nginx.conf")
        ]
    TC.& TC.setWaitingFor (TC.waitUntilTimeout 30 (TC.waitUntilMappedPortReachable 443))
  pure (TC.containerPort container 443)