packages feed

hs-hath-1.1.1: test/Integration.hs

{-# LANGUAGE DataKinds #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE QuasiQuotes #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TypeApplications #-}
{-# LANGUAGE TypeOperators #-}

-- | Integration tests for hs-hath full lifecycle
module Integration ( integrationSpecs ) where

import           Control.Concurrent       ( ThreadId, forkIO, killThread, threadDelay )
import           Control.Concurrent.MVar  ( MVar, newEmptyMVar, putMVar, tryTakeMVar )
import           Control.Concurrent.STM   ( TVar, newTVarIO )
import           Control.Exception        ( bracket, catch, SomeException )

import qualified Data.Aeson               as A
import qualified Data.Aeson               as A
import qualified Data.ByteString          as BS
import qualified Data.ByteString.Char8    as BSC
import qualified Data.ByteString.Lazy     as LBS
import qualified Data.HashMap.Strict      as HM
import qualified Data.Map.Strict          as Map
import           Data.String.Interpolate  ( i )
import qualified Data.Text                as T
import           Data.Text.Encoding       ( decodeUtf8, encodeUtf8 )
import           Data.Time.Clock.System   ( SystemTime(systemSeconds), getSystemTime )
import           Data.X509                ( CertificateChain(..), PrivKey )
import           Data.X509.File           ( readSignedObject, readKeyFile )

import           Storage.Database         ( initializeDB )
import           Database.SQLite.Simple   ( withConnection )

import           Utils                    ( hash )

import           MockRPC

import           Network.HTTP.Client      ( defaultManagerSettings
                                          , httpLbs
                                          , method
                                          , newManager
                                          , parseRequest
                                          , redirectCount
                                          , responseBody
                                          , responseStatus
                                          )
import           Network.HTTP.Types       ( status200, status301, status403, status404, status429 )
import qualified Network.Wai.Handler.Warp as Warp

import           Relude                   hiding ( Reader
                                                 , decodeUtf8
                                                 , encodeUtf8
                                                 , newEmptyMVar
                                                 , newTVarIO
                                                 , runReader
                                                 )

import           Server                   ( CacheRunner(..)

                                          , ServerAction(..)

                                          , makeApplication

                                          , startServer

                                          , IPMap

                                          , KeystampMap

                                          , GalleryTask(..)

                                          )



import           Storage.Database         ( runCache )



import           Stats                    ( newStatsEnv )



import           System.Environment       ( setEnv )



import           Test.Hspec



import           UnliftIO                 ( TChan



                                          , newTChanIO



                                          , tryReadTChan



                                          , writeTChan



                                          )







import           Types                    ( CacheBackend(..)


                                          , ClientConfig(..)
                                          , HathSettings(..)
                                          , defaultHathSettings
                                          )

-- | Test configuration
testConfig :: Int -> ClientConfig
testConfig port
  = ClientConfig
  { clientId     = "test-client-001"
  , key          = "test-secret-key-12345"
  , version      = "1.0.0-test"
  , proxy        = Nothing
  , downloadDir  = "/tmp/hs-hath-test"
  , cachePath    = "/tmp/hs-hath-test-" <> show port <> ".db"
  , cacheBackend = CacheBackendSQLite
  , r2Config     = Nothing
  }

withTestServer
  :: MockRPCConfig
  -> ClientConfig
  -> HathSettings
  -> (Int -> IO a)
  -> IO a
withTestServer mockCfg clientCfg settings action = withMockRPC mockCfg $ do
  setEnv "HATH_RPC_HOST" ("localhost:" <> show (mockPort mockCfg))
  serverChan <- newTChanIO
  ipMap <- newTVarIO HM.empty
  ksVar <- newTVarIO HM.empty
  galleryHandler <- newTChanIO
  ssVar <- newTVarIO settings
  statsEnv <- newStatsEnv
  withConnection ":memory:" $ \conn -> do
    initializeDB conn
    let cacheRunner = CacheRunner { runCacheWith = runCache conn }
        app = makeApplication clientCfg ssVar serverChan galleryHandler ipMap ksVar False False cacheRunner statsEnv
    Warp.testWithApplication (pure app) action

-- | Helper to load test certificates
loadTestCertificates :: IO (CertificateChain, [PrivKey])
loadTestCertificates = do
  certs <- readSignedObject "test-cert.pem"
  keys <- readKeyFile "test-key.pem"
  return (CertificateChain certs, keys)

-- | Test with startServer (includes full initialization)
withStartServer
  :: MockRPCConfig
  -> ClientConfig
  -> HathSettings
  -> (Int -> TChan ServerAction -> IO a)
  -> IO a
withStartServer mockCfg clientCfg settings action = withMockRPC mockCfg $ do
  setEnv "HATH_RPC_HOST" ("localhost:" <> show (mockPort mockCfg))
  (certChain, keys) <- loadTestCertificates
  case keys of
    [] -> error "No private key found in test-key.pem"
    (privKey:_) -> do
      serverChan <- newTChanIO
      let certs = (certChain, privKey)
          port = clientPort settings
      
      -- Start server in background thread (skipVerify = True for tests)
      bracket
        (forkIO $ startServer clientCfg settings certs serverChan True False)
        killThread
        (\_ -> do
          -- Wait a bit for server to initialize
          threadDelay 1000000  -- 1 second
          -- Run the test action
          result <- action port serverChan
          -- Shutdown server gracefully
          atomically $ writeTChan serverChan GracefulShutdown
          threadDelay 500000  -- Wait for shutdown
          return result
        )

-- | Integration specs
integrationSpecs :: Spec
integrationSpecs = do
  describe "Full Server Lifecycle" $ do
    it "serves requested file by fetching from upstream (MockRPC)" $ do
      let mockPortNum = 19801
          mockContent = "TEST_FILE_CONTENT_123"
          mockConfig  = defaultMockConfig { mockPort = mockPortNum, mockFileContent = mockContent }
          clientCfg   = testConfig 0
          fileURIStr  = hash @Text mockContent <> "-123-1024-768-txt"

      withTestServer mockConfig clientCfg defaultHathSettings $ \serverPort -> do
        currentTime <- getSystemTime
        let ts = systemSeconds currentTime
            k  = key clientCfg
            challengeInput = encodeUtf8 [i|#{ts}-#{fileURIStr}-#{k}-hotlinkthis|]
            challenge = BS.take 10 (hash @ByteString challengeInput)
            keystampText = [i|#{ts}-#{decodeUtf8 challenge}|] :: Text
            opts = [i|keystamp=#{keystampText};fileindex=1;xres=1024|] :: Text
            url = [i|http://localhost:#{serverPort}/h/#{fileURIStr}/#{opts}/testfile.txt|]

        manager <- newManager defaultManagerSettings
        request <- parseRequest url
        response <- httpLbs request manager

        responseStatus response `shouldBe` status200
        LBS.toStrict (responseBody response) `shouldBe` mockContent
        let parsedOpts = Map.fromList $ do
              t <- T.splitOn ";" opts
              let (optKey, v) = T.breakOn "=" t
              pure (encodeUtf8 optKey, encodeUtf8 (T.drop 1 v))
        Map.lookup "keystamp" parsedOpts `shouldSatisfy` isJust

    it "rejects requests with invalid keystamp" $ do
      let mockPortNum = 19802
          mockContent = "TEST_FILE_CONTENT_123"
          mockConfig  = defaultMockConfig { mockPort = mockPortNum, mockFileContent = mockContent }
          clientCfg   = testConfig 1
          fileURIStr  = hash @Text mockContent <> "-123-1024-768-txt"

      withTestServer mockConfig clientCfg defaultHathSettings $ \serverPort -> do
        currentTime <- getSystemTime
        let ts = systemSeconds currentTime
            invalidKeystampText = "invalid" :: Text
            opts = [i|keystamp=#{invalidKeystampText};fileindex=1;xres=1024|] :: Text
            url = [i|http://localhost:#{serverPort}/h/#{fileURIStr}/#{opts}/testfile.txt|]

        manager <- newManager defaultManagerSettings
        request <- parseRequest url
        response <- httpLbs request manager

        responseStatus response `shouldBe` status403
        responseBody response `shouldSatisfy` const True

    it "applies rate limiting with 429 after bursts" $ do
      let mockPortNum = 19803
          mockContent = "RL_TEST_CONTENT"
          mockConfig  = defaultMockConfig { mockPort = mockPortNum, mockFileContent = mockContent }
          clientCfg   = testConfig 2
          fileURIStr  = hash @Text mockContent <> "-123-1024-768-txt"

      withTestServer mockConfig clientCfg defaultHathSettings { throttleBytes = 1 } $ \serverPort -> do
        now <- getSystemTime
        let ts = systemSeconds now
            k  = key clientCfg
            goodKeystamp =
              let inner = encodeUtf8 [i|#{ts}-#{fileURIStr}-#{k}-hotlinkthis|]
              in [i|#{ts}-#{decodeUtf8 (BS.take 10 (hash @ByteString inner))}|] :: Text
            opts = [i|keystamp=#{goodKeystamp};fileindex=1;xres=1024|] :: Text
            url = [i|http://localhost:#{serverPort}/h/#{fileURIStr}/#{opts}/testfile.txt|]

        manager <- newManager defaultManagerSettings
        req0 <- parseRequest url
        let burstRequests = replicate 7 req0

        responses <- forM burstRequests $ \req -> httpLbs req manager
        let statuses = responseStatus <$> responses
        length (filter (== status429) statuses) `shouldSatisfy` (>= 2)

    it "returns stats snapshot with non-negative counters" $ do
      let mockPortNum = 19804
          mockConfig  = defaultMockConfig { mockPort = mockPortNum }
          clientCfg   = testConfig 3

      withTestServer mockConfig clientCfg defaultHathSettings $ \serverPort -> do
        manager <- newManager defaultManagerSettings
        req <- parseRequest [i|http://localhost:#{serverPort}/metrics|]
        resp <- httpLbs req manager
        responseStatus resp `shouldBe` status200
        LBS.toStrict (responseBody resp) `shouldSatisfy` (\body -> "hath_cache_sent_total" `BS.isInfixOf` body)

    it "returns 404 for unknown raw routes" $ do
      let mockPortNum = 19805
          mockConfig  = defaultMockConfig { mockPort = mockPortNum }
          clientCfg   = testConfig 4

      withTestServer mockConfig clientCfg defaultHathSettings $ \serverPort -> do
        manager <- newManager defaultManagerSettings
        req <- parseRequest [i|http://localhost:#{serverPort}/unknown-path|]
        responseStatus <$> httpLbs req manager `shouldReturn` status404

    it "serves /robots.txt with disallow all" $ do
      let mockPortNum = 19806
          mockConfig  = defaultMockConfig { mockPort = mockPortNum }
          clientCfg   = testConfig 5

      withTestServer mockConfig clientCfg defaultHathSettings $ \serverPort -> do
        manager <- newManager defaultManagerSettings
        req <- parseRequest [i|http://localhost:#{serverPort}/robots.txt|]
        resp <- httpLbs req manager
        responseStatus resp `shouldBe` status200
        LBS.toStrict (responseBody resp) `shouldSatisfy` (\body -> "Disallow" `BS.isInfixOf` body)

    it "redirects /favicon.ico to e-hentai.org" $ do
      let mockPortNum = 19807
          mockConfig  = defaultMockConfig { mockPort = mockPortNum }
          clientCfg   = testConfig 6

      withTestServer mockConfig clientCfg defaultHathSettings $ \serverPort -> do
        manager <- newManager defaultManagerSettings
        req0 <- parseRequest [i|http://localhost:#{serverPort}/favicon.ico|]
        let req = req0 { redirectCount = 0 }
        resp <- httpLbs req manager
        responseStatus resp `shouldBe` status301

    it "handles /servercmd/still_alive" $ do
      let mockPortNum = 19808
          mockConfig  = defaultMockConfig { mockPort = mockPortNum }
          clientCfg   = testConfig 7

      withTestServer mockConfig clientCfg defaultHathSettings $ \serverPort -> do
        now <- getSystemTime
        let ts = systemSeconds now
        manager <- newManager defaultManagerSettings
        req <- parseRequest [i|http://localhost:#{serverPort}/servercmd/still_alive/additional/#{ts}/key|]
        resp <- httpLbs req manager
        responseStatus resp `shouldBe` status200
        LBS.toStrict (responseBody resp) `shouldSatisfy` (\body -> "FANTASTIC" `BS.isInfixOf` body)

    it "handles /servercmd/speed_test" $ do
      let mockPortNum = 19809
          mockConfig  = defaultMockConfig { mockPort = mockPortNum }
          clientCfg   = testConfig 8

      withTestServer mockConfig clientCfg defaultHathSettings $ \serverPort -> do
        now <- getSystemTime
        let ts = systemSeconds now
        manager <- newManager defaultManagerSettings
        req <- parseRequest [i|http://localhost:#{serverPort}/servercmd/speed_test/testsize=1000/#{ts}/key|]
        resp <- httpLbs req manager
        responseStatus resp `shouldBe` status200

    it "handles /t speed test endpoint with valid key" $ do
      let mockPortNum = 19810
          mockConfig  = defaultMockConfig { mockPort = mockPortNum }
          clientCfg   = testConfig 9
          testSize = 1000 :: Int

      withTestServer mockConfig clientCfg defaultHathSettings $ \serverPort -> do
        now <- getSystemTime
        let ts = systemSeconds now
            cid = clientId clientCfg
            k = key clientCfg
            challengeBS = encodeUtf8 $ T.pack $ "hentai@home-speedtest-" <> show testSize <> "-" <> show ts <> "-" <> T.unpack cid <> "-" <> T.unpack k
            testKeyText = hash @Text challengeBS
            urlStr = "http://localhost:" <> show serverPort <> "/t/" <> show testSize <> "/" <> show ts <> "/" <> T.unpack testKeyText <> "/nothing"
        manager <- newManager defaultManagerSettings
        req <- parseRequest urlStr
        resp <- httpLbs req manager
        responseStatus resp `shouldBe` status200

    it "rejects /t speed test with invalid time drift" $ do
      let mockPortNum = 19811
          mockConfig  = defaultMockConfig { mockPort = mockPortNum }
          clientCfg   = testConfig 10
          testSize = 1000 :: Int

      withTestServer mockConfig clientCfg defaultHathSettings $ \serverPort -> do
        let ts = 1000000 :: Int64
            testKeyStr = "invalid-key" :: String
            url = [i|http://localhost:#{serverPort}/t/#{testSize}/#{ts}/#{testKeyStr}/nothing|] :: String
        manager <- newManager defaultManagerSettings
        req <- parseRequest url
        resp <- httpLbs req manager
        responseStatus resp `shouldBe` status403

    it "handles HEAD request with 200" $ do
      let mockPortNum = 19812
          mockConfig  = defaultMockConfig { mockPort = mockPortNum }
          clientCfg   = testConfig 11

      withTestServer mockConfig clientCfg defaultHathSettings $ \serverPort -> do
        manager <- newManager defaultManagerSettings
        req0 <- parseRequest [i|http://localhost:#{serverPort}/anything|]
        let req = req0 { method = "HEAD" }
        resp <- httpLbs req manager
        responseStatus resp `shouldBe` status200
        responseBody resp `shouldBe` ""

    it "handles /servercmd/refresh_settings" $ do
      let mockPortNum = 19813
          mockConfig  = defaultMockConfig { mockPort = mockPortNum }
          clientCfg   = testConfig 12

      withTestServer mockConfig clientCfg defaultHathSettings $ \serverPort -> do
        now <- getSystemTime
        let ts = systemSeconds now
        manager <- newManager defaultManagerSettings
        req <- parseRequest [i|http://localhost:#{serverPort}/servercmd/refresh_settings/additional/#{ts}/key|]
        resp <- httpLbs req manager
        responseStatus resp `shouldBe` status200

    it "handles /servercmd/refresh_certs" $ do
      let mockPortNum = 19814
          mockConfig  = defaultMockConfig { mockPort = mockPortNum }
          clientCfg   = testConfig 13

      withTestServer mockConfig clientCfg defaultHathSettings $ \serverPort -> do
        now <- getSystemTime
        let ts = systemSeconds now
        manager <- newManager defaultManagerSettings
        req <- parseRequest [i|http://localhost:#{serverPort}/servercmd/refresh_certs/additional/#{ts}/key|]
        resp <- httpLbs req manager
        responseStatus resp `shouldBe` status200

    it "handles /servercmd/threaded_proxy_test with missing params" $ do
      let mockPortNum = 19815
          mockConfig  = defaultMockConfig { mockPort = mockPortNum }
          clientCfg   = testConfig 14

      withTestServer mockConfig clientCfg defaultHathSettings $ \serverPort -> do
        now <- getSystemTime
        let ts = systemSeconds now
        manager <- newManager defaultManagerSettings
        req <- parseRequest [i|http://localhost:#{serverPort}/servercmd/threaded_proxy_test/additional/#{ts}/key|]
        resp <- httpLbs req manager
        responseStatus resp `shouldBe` status403

    it "serves file with jpg extension and correct mime type" $ do
      let mockPortNum = 19816
          mockContent = "FAKE_JPG_CONTENT"
          mockConfig  = defaultMockConfig { mockPort = mockPortNum, mockFileContent = mockContent }
          clientCfg   = testConfig 15
          fileURIStr  = hash @Text mockContent <> "-123-1024-768-jpg"

      withTestServer mockConfig clientCfg defaultHathSettings $ \serverPort -> do
        currentTime <- getSystemTime
        let ts = systemSeconds currentTime
            k  = key clientCfg
            challengeInput = encodeUtf8 [i|#{ts}-#{fileURIStr}-#{k}-hotlinkthis|]
            challenge = BS.take 10 (hash @ByteString challengeInput)
            keystampText = [i|#{ts}-#{decodeUtf8 challenge}|] :: Text
            opts = [i|keystamp=#{keystampText};fileindex=1;xres=1024|] :: Text
            url = [i|http://localhost:#{serverPort}/h/#{fileURIStr}/#{opts}/test.jpg|]

        manager <- newManager defaultManagerSettings
        request <- parseRequest url
        response <- httpLbs request manager

        responseStatus response `shouldBe` status200
        LBS.toStrict (responseBody response) `shouldBe` mockContent

    it "serves file with png extension" $ do
      let mockPortNum = 19817
          mockContent = "FAKE_PNG_CONTENT"
          mockConfig  = defaultMockConfig { mockPort = mockPortNum, mockFileContent = mockContent }
          clientCfg   = testConfig 16
          fileURIStr  = hash @Text mockContent <> "-123-1024-768-png"

      withTestServer mockConfig clientCfg defaultHathSettings $ \serverPort -> do
        currentTime <- getSystemTime
        let ts = systemSeconds currentTime
            k  = key clientCfg
            challengeInput = encodeUtf8 [i|#{ts}-#{fileURIStr}-#{k}-hotlinkthis|]
            challenge = BS.take 10 (hash @ByteString challengeInput)
            keystampText = [i|#{ts}-#{decodeUtf8 challenge}|] :: Text
            opts = [i|keystamp=#{keystampText};fileindex=1;xres=1024|] :: Text
            url = [i|http://localhost:#{serverPort}/h/#{fileURIStr}/#{opts}/test.png|]

        manager <- newManager defaultManagerSettings
        request <- parseRequest url
        response <- httpLbs request manager

        responseStatus response `shouldBe` status200

    it "serves file with gif extension" $ do
      let mockPortNum = 19818
          mockContent = "FAKE_GIF_CONTENT"
          mockConfig  = defaultMockConfig { mockPort = mockPortNum, mockFileContent = mockContent }
          clientCfg   = testConfig 17
          fileURIStr  = hash @Text mockContent <> "-123-1024-768-gif"

      withTestServer mockConfig clientCfg defaultHathSettings $ \serverPort -> do
        currentTime <- getSystemTime
        let ts = systemSeconds currentTime
            k  = key clientCfg
            challengeInput = encodeUtf8 [i|#{ts}-#{fileURIStr}-#{k}-hotlinkthis|]
            challenge = BS.take 10 (hash @ByteString challengeInput)
            keystampText = [i|#{ts}-#{decodeUtf8 challenge}|] :: Text
            opts = [i|keystamp=#{keystampText};fileindex=1;xres=1024|] :: Text
            url = [i|http://localhost:#{serverPort}/h/#{fileURIStr}/#{opts}/test.gif|]

        manager <- newManager defaultManagerSettings
        request <- parseRequest url
        response <- httpLbs request manager

        responseStatus response `shouldBe` status200

    it "serves file with mp4 extension" $ do
      let mockPortNum = 19819
          mockContent = "FAKE_MP4_CONTENT"
          mockConfig  = defaultMockConfig { mockPort = mockPortNum, mockFileContent = mockContent }
          clientCfg   = testConfig 18
          fileURIStr  = hash @Text mockContent <> "-123-1024-768-mp4"

      withTestServer mockConfig clientCfg defaultHathSettings $ \serverPort -> do
        currentTime <- getSystemTime
        let ts = systemSeconds currentTime
            k  = key clientCfg
            challengeInput = encodeUtf8 [i|#{ts}-#{fileURIStr}-#{k}-hotlinkthis|]
            challenge = BS.take 10 (hash @ByteString challengeInput)
            keystampText = [i|#{ts}-#{decodeUtf8 challenge}|] :: Text
            opts = [i|keystamp=#{keystampText};fileindex=1;xres=1024|] :: Text
            url = [i|http://localhost:#{serverPort}/h/#{fileURIStr}/#{opts}/test.mp4|]

        manager <- newManager defaultManagerSettings
        request <- parseRequest url
        response <- httpLbs request manager

        responseStatus response `shouldBe` status200

    it "handles /servercmd/start_downloader" $ do
      let mockPortNum = 19820
          mockConfig  = defaultMockConfig { mockPort = mockPortNum }
          clientCfg   = testConfig 19

      withTestServer mockConfig clientCfg defaultHathSettings $ \serverPort -> do
        now <- getSystemTime
        let ts = systemSeconds now
        manager <- newManager defaultManagerSettings
        req <- parseRequest [i|http://localhost:#{serverPort}/servercmd/start_downloader/additional/#{ts}/key|]
        resp <- httpLbs req manager
        responseStatus resp `shouldBe` status200

    it "serves file with webm extension" $ do
      let mockPortNum = 19821
          mockContent = "FAKE_WEBM_CONTENT"
          mockConfig  = defaultMockConfig { mockPort = mockPortNum, mockFileContent = mockContent }
          clientCfg   = testConfig 20
          fileURIStr  = hash @Text mockContent <> "-123-1024-768-wbm"

      withTestServer mockConfig clientCfg defaultHathSettings $ \serverPort -> do
        currentTime <- getSystemTime
        let ts = systemSeconds currentTime
            k  = key clientCfg
            challengeInput = encodeUtf8 [i|#{ts}-#{fileURIStr}-#{k}-hotlinkthis|]
            challenge = BS.take 10 (hash @ByteString challengeInput)
            keystampText = [i|#{ts}-#{decodeUtf8 challenge}|] :: Text
            opts = [i|keystamp=#{keystampText};fileindex=1;xres=1024|] :: Text
            url = [i|http://localhost:#{serverPort}/h/#{fileURIStr}/#{opts}/test.webm|]

        manager <- newManager defaultManagerSettings
        request <- parseRequest url
        response <- httpLbs request manager

        responseStatus response `shouldBe` status200

    it "serves file with webp extension" $ do
      let mockPortNum = 19822
          mockContent = "FAKE_WEBP_CONTENT"
          mockConfig  = defaultMockConfig { mockPort = mockPortNum, mockFileContent = mockContent }
          clientCfg   = testConfig 21
          fileURIStr  = hash @Text mockContent <> "-123-1024-768-wbp"

      withTestServer mockConfig clientCfg defaultHathSettings $ \serverPort -> do
        currentTime <- getSystemTime
        let ts = systemSeconds currentTime
            k  = key clientCfg
            challengeInput = encodeUtf8 [i|#{ts}-#{fileURIStr}-#{k}-hotlinkthis|]
            challenge = BS.take 10 (hash @ByteString challengeInput)
            keystampText = [i|#{ts}-#{decodeUtf8 challenge}|] :: Text
            opts = [i|keystamp=#{keystampText};fileindex=1;xres=1024|] :: Text
            url = [i|http://localhost:#{serverPort}/h/#{fileURIStr}/#{opts}/test.webp|]

        manager <- newManager defaultManagerSettings
        request <- parseRequest url
        response <- httpLbs request manager

        responseStatus response `shouldBe` status200

    it "serves file with avif extension" $ do
      let mockPortNum = 19823
          mockContent = "FAKE_AVIF_CONTENT"
          mockConfig  = defaultMockConfig { mockPort = mockPortNum, mockFileContent = mockContent }
          clientCfg   = testConfig 22
          fileURIStr  = hash @Text mockContent <> "-123-1024-768-avf"

      withTestServer mockConfig clientCfg defaultHathSettings $ \serverPort -> do
        currentTime <- getSystemTime
        let ts = systemSeconds currentTime
            k  = key clientCfg
            challengeInput = encodeUtf8 [i|#{ts}-#{fileURIStr}-#{k}-hotlinkthis|]
            challenge = BS.take 10 (hash @ByteString challengeInput)
            keystampText = [i|#{ts}-#{decodeUtf8 challenge}|] :: Text
            opts = [i|keystamp=#{keystampText};fileindex=1;xres=1024|] :: Text
            url = [i|http://localhost:#{serverPort}/h/#{fileURIStr}/#{opts}/test.avif|]

        manager <- newManager defaultManagerSettings
        request <- parseRequest url
        response <- httpLbs request manager

        responseStatus response `shouldBe` status200

    it "serves file with jxl extension" $ do
      let mockPortNum = 19824
          mockContent = "FAKE_JXL_CONTENT"
          mockConfig  = defaultMockConfig { mockPort = mockPortNum, mockFileContent = mockContent }
          clientCfg   = testConfig 23
          fileURIStr  = hash @Text mockContent <> "-123-1024-768-jxl"

      withTestServer mockConfig clientCfg defaultHathSettings $ \serverPort -> do
        currentTime <- getSystemTime
        let ts = systemSeconds currentTime
            k  = key clientCfg
            challengeInput = encodeUtf8 [i|#{ts}-#{fileURIStr}-#{k}-hotlinkthis|]
            challenge = BS.take 10 (hash @ByteString challengeInput)
            keystampText = [i|#{ts}-#{decodeUtf8 challenge}|] :: Text
            opts = [i|keystamp=#{keystampText};fileindex=1;xres=1024|] :: Text
            url = [i|http://localhost:#{serverPort}/h/#{fileURIStr}/#{opts}/test.jxl|]

        manager <- newManager defaultManagerSettings
        request <- parseRequest url
        response <- httpLbs request manager

        responseStatus response `shouldBe` status200

    it "rejects resource request with time drift at boundary (601 seconds)" $ do
      let mockPortNum = 19825
          mockContent = "TIMEDRIFT_TEST"
          mockConfig  = defaultMockConfig { mockPort = mockPortNum, mockFileContent = mockContent }
          clientCfg   = testConfig 24
          fileURIStr  = hash @Text mockContent <> "-123-1024-768-txt"

      withTestServer mockConfig clientCfg defaultHathSettings $ \serverPort -> do
        currentTime <- getSystemTime
        let ts = systemSeconds currentTime - 601
            k  = key clientCfg
            challengeInput = encodeUtf8 [i|#{ts}-#{fileURIStr}-#{k}-hotlinkthis|]
            challenge = BS.take 10 (hash @ByteString challengeInput)
            keystampText = [i|#{ts}-#{decodeUtf8 challenge}|] :: Text
            opts = [i|keystamp=#{keystampText};fileindex=1;xres=1024|] :: Text
            url = [i|http://localhost:#{serverPort}/h/#{fileURIStr}/#{opts}/testfile.txt|]

        manager <- newManager defaultManagerSettings
        request <- parseRequest url
        response <- httpLbs request manager

        responseStatus response `shouldBe` status403
        LBS.toStrict (responseBody response) `shouldSatisfy` (\body -> "time" `BS.isInfixOf` body)

    it "serves resource from cache when already stored" $ do
      let mockPortNum = 19826
          mockContent = "CACHED_CONTENT_TEST"
          mockConfig  = defaultMockConfig { mockPort = mockPortNum, mockFileContent = mockContent }
          clientCfg   = testConfig 25
          fileURIStr  = hash @Text mockContent <> "-123-1024-768-txt"

      withTestServer mockConfig clientCfg defaultHathSettings $ \serverPort -> do
        currentTime <- getSystemTime
        let ts = systemSeconds currentTime
            k  = key clientCfg
            challengeInput = encodeUtf8 [i|#{ts}-#{fileURIStr}-#{k}-hotlinkthis|]
            challenge = BS.take 10 (hash @ByteString challengeInput)
            keystampText = [i|#{ts}-#{decodeUtf8 challenge}|] :: Text
            opts = [i|keystamp=#{keystampText};fileindex=1;xres=1024|] :: Text
            url = [i|http://localhost:#{serverPort}/h/#{fileURIStr}/#{opts}/testfile.txt|]

        manager <- newManager defaultManagerSettings
        request <- parseRequest url
        
        -- First request fetches from upstream
        response1 <- httpLbs request manager
        responseStatus response1 `shouldBe` status200
        
        -- Second request should hit cache
        response2 <- httpLbs request manager
        responseStatus response2 `shouldBe` status200
        LBS.toStrict (responseBody response2) `shouldBe` mockContent

    it "rejects resource request with missing fileindex option" $ do
      let mockPortNum = 19827
          mockContent = "MISSING_PARAM_TEST"
          mockConfig  = defaultMockConfig { mockPort = mockPortNum, mockFileContent = mockContent }
          clientCfg   = testConfig 26
          fileURIStr  = hash @Text mockContent <> "-123-1024-768-txt"

      withTestServer mockConfig clientCfg defaultHathSettings $ \serverPort -> do
        currentTime <- getSystemTime
        let ts = systemSeconds currentTime
            k  = key clientCfg
            challengeInput = encodeUtf8 [i|#{ts}-#{fileURIStr}-#{k}-hotlinkthis|]
            challenge = BS.take 10 (hash @ByteString challengeInput)
            keystampText = [i|#{ts}-#{decodeUtf8 challenge}|] :: Text
            opts = [i|keystamp=#{keystampText};xres=1024|] :: Text
            url = [i|http://localhost:#{serverPort}/h/#{fileURIStr}/#{opts}/testfile.txt|]

        manager <- newManager defaultManagerSettings
        request <- parseRequest url
        response <- httpLbs request manager

        responseStatus response `shouldBe` status404

    it "returns 404 when resource not found in cache and no params" $ do
      let mockPortNum = 19828
          mockContent = "NOT_FOUND_TEST"
          mockConfig  = defaultMockConfig { mockPort = mockPortNum, mockFileContent = mockContent }
          clientCfg   = testConfig 27
          fileURIStr  = hash @Text mockContent <> "-123-1024-768-txt"

      withTestServer mockConfig clientCfg defaultHathSettings $ \serverPort -> do
        currentTime <- getSystemTime
        let ts = systemSeconds currentTime
            k  = key clientCfg
            challengeInput = encodeUtf8 [i|#{ts}-#{fileURIStr}-#{k}-hotlinkthis|]
            challenge = BS.take 10 (hash @ByteString challengeInput)
            keystampText = [i|#{ts}-#{decodeUtf8 challenge}|] :: Text
            opts = [i|keystamp=#{keystampText}|] :: Text
            url = [i|http://localhost:#{serverPort}/h/#{fileURIStr}/#{opts}/testfile.txt|]

        manager <- newManager defaultManagerSettings
        request <- parseRequest url
        response <- httpLbs request manager

        responseStatus response `shouldBe` status404

    it "serves resource with default octet-stream mime type for unknown extension" $ do
      let mockPortNum = 19829
          mockContent = "UNKNOWN_EXT_CONTENT"
          mockConfig  = defaultMockConfig { mockPort = mockPortNum, mockFileContent = mockContent }
          clientCfg   = testConfig 28
          fileURIStr  = hash @Text mockContent <> "-123-1024-768-xyz"

      withTestServer mockConfig clientCfg defaultHathSettings $ \serverPort -> do
        currentTime <- getSystemTime
        let ts = systemSeconds currentTime
            k  = key clientCfg
            challengeInput = encodeUtf8 [i|#{ts}-#{fileURIStr}-#{k}-hotlinkthis|]
            challenge = BS.take 10 (hash @ByteString challengeInput)
            keystampText = [i|#{ts}-#{decodeUtf8 challenge}|] :: Text
            opts = [i|keystamp=#{keystampText};fileindex=1;xres=1024|] :: Text
            url = [i|http://localhost:#{serverPort}/h/#{fileURIStr}/#{opts}/test.unknown|]

        manager <- newManager defaultManagerSettings
        request <- parseRequest url
        response <- httpLbs request manager

        responseStatus response `shouldBe` status200
        LBS.toStrict (responseBody response) `shouldBe` mockContent

    it "accepts resource request with future timestamp within drift limit" $ do
      let mockPortNum = 19830
          mockContent = "FUTURE_TIME_TEST"
          mockConfig  = defaultMockConfig { mockPort = mockPortNum, mockFileContent = mockContent }
          clientCfg   = testConfig 29
          fileURIStr  = hash @Text mockContent <> "-123-1024-768-txt"

      withTestServer mockConfig clientCfg defaultHathSettings $ \serverPort -> do
        currentTime <- getSystemTime
        let ts = systemSeconds currentTime + 500
            k  = key clientCfg
            challengeInput = encodeUtf8 [i|#{ts}-#{fileURIStr}-#{k}-hotlinkthis|]
            challenge = BS.take 10 (hash @ByteString challengeInput)
            keystampText = [i|#{ts}-#{decodeUtf8 challenge}|] :: Text
            opts = [i|keystamp=#{keystampText};fileindex=1;xres=1024|] :: Text
            url = [i|http://localhost:#{serverPort}/h/#{fileURIStr}/#{opts}/testfile.txt|]

        manager <- newManager defaultManagerSettings
        request <- parseRequest url
        response <- httpLbs request manager

        responseStatus response `shouldBe` status200

    it "handles POST request to unknown route with 404" $ do
      let mockPortNum = 19831
          mockConfig  = defaultMockConfig { mockPort = mockPortNum }
          clientCfg   = testConfig 30

      withTestServer mockConfig clientCfg defaultHathSettings $ \serverPort -> do
        manager <- newManager defaultManagerSettings
        req0 <- parseRequest [i|http://localhost:#{serverPort}/unknown-post|]
        let req = req0 { method = "POST" }
        resp <- httpLbs req manager
        responseStatus resp `shouldBe` status404

    it "handles GET request to unknown route with 404" $ do
      let mockPortNum = 19832
          mockConfig  = defaultMockConfig { mockPort = mockPortNum }
          clientCfg   = testConfig 31

      withTestServer mockConfig clientCfg defaultHathSettings $ \serverPort -> do
        manager <- newManager defaultManagerSettings
        req <- parseRequest [i|http://localhost:#{serverPort}/random/path/here|]
        resp <- httpLbs req manager
        responseStatus resp `shouldBe` status404

  -- Tests using startServer to cover initialization code
  describe "Server Initialization with startServer" $ do
    it "starts server successfully and serves requests" $ do
      let mockPortNum = 19900
          mockContent = "INIT_TEST_CONTENT"
          mockConfig  = defaultMockConfig { mockPort = mockPortNum, mockFileContent = mockContent }
          clientCfg   = testConfig 100
          settings = defaultHathSettings { clientPort = 9900 }
          fileURIStr  = hash @Text mockContent <> "-123-1024-768-txt"

      withStartServer mockConfig clientCfg settings $ \serverPort _chan -> do
        currentTime <- getSystemTime
        let ts = systemSeconds currentTime
            k  = key clientCfg
            challengeInput = encodeUtf8 [i|#{ts}-#{fileURIStr}-#{k}-hotlinkthis|]
            challenge = BS.take 10 (hash @ByteString challengeInput)
            keystampText = [i|#{ts}-#{decodeUtf8 challenge}|] :: Text
            opts = [i|keystamp=#{keystampText};fileindex=1;xres=1024|] :: Text

        -- Use TLS-aware manager to connect to HTTPS server
        manager <- newManager defaultManagerSettings
        -- Try HTTP first (onInsecure = AllowInsecure means HTTP should work)
        request <- parseRequest $ [i|http://localhost:#{serverPort}/h/#{fileURIStr}/#{opts}/testfile.txt|]
        response <- httpLbs request manager

        responseStatus response `shouldBe` status200

    it "initializes database and fetches blacklist on startup" $ do
      let mockPortNum = 19901
          mockConfig  = defaultMockConfig { mockPort = mockPortNum }
          clientCfg   = testConfig 101
          settings = defaultHathSettings { clientPort = 9901 }

      withStartServer mockConfig clientCfg settings $ \_serverPort _chan -> do
        -- The test passes if startServer successfully:
        -- 1. Calls fetchBlacklist (line 477 in Server.hs)
        -- 2. Initializes database (line 479)
        -- 3. Sets up tictok timers (line 480)
        -- 4. Starts the server loop
        threadDelay 2000000  -- Wait 2 seconds for initialization
        -- If we got here without exceptions, initialization succeeded
        return ()

    it "fetches blacklist via tictok timer" $ do
      let mockPortNum = 19903
          mockConfig  = defaultMockConfig { mockPort = mockPortNum }
          clientCfg   = testConfig 103
          settings = defaultHathSettings { clientPort = 9903 }

      withStartServer mockConfig clientCfg settings $ \_serverPort _chan -> do
        -- tictok sets up three timers:
        -- 1. IP map cleanup (every 1 minute)
        -- 2. Heartbeat stillAlive (every 1 minute)
        -- 3. Blacklist fetch (every 36 minutes)
        -- Wait a bit to ensure timers are set up
        threadDelay 3000000  -- 3 seconds
        -- The RPC mock will receive heartbeat calls
        return ()