packages feed

hurl-workbench-core-0.1.0.0: test/HurlWorkbench/Suite/RunTest.hs

module HurlWorkbench.Suite.RunTest (tests) where

import Control.Concurrent.Async (AsyncCancelled (..), cancel, waitCatch, withAsync)
import Control.Concurrent.STM (TVar, atomically, modifyTVar', newEmptyTMVarIO, newTVarIO, putTMVar, readTVar, readTVarIO, takeTMVar)
import Control.Exception (bracket_, fromException)
import Data.Aeson qualified as Aeson
import Data.Aeson.KeyMap qualified as KeyMap
import Data.ByteString qualified as ByteString
import Data.Generics.Labels ()
import Data.List.NonEmpty qualified as NonEmpty
import Data.Map.Strict qualified as Map
import Data.Set qualified as Set
import Data.Text qualified as Text
import Data.Version (makeVersion)
import HurlWorkbench.Hurl.Format
import HurlWorkbench.Hurl.Run
import HurlWorkbench.Parameter.Resolve
import HurlWorkbench.Prelude
import HurlWorkbench.Run.Batch
import HurlWorkbench.Run.FileRoot
import HurlWorkbench.Suite.Isolation
import HurlWorkbench.Suite.LifecycleTest qualified as LifecycleTest
import HurlWorkbench.Suite.Resolve
import HurlWorkbench.Suite.Run
import HurlWorkbench.Suite.Scenario (ScenarioInputs (..))
import HurlWorkbench.Workspace.Context
import HurlWorkbench.Workspace.Discover (WorkspaceSource (..))
import HurlWorkbench.Workspace.Error (renderWorkspaceError)
import HurlWorkbench.Workspace.Types
import HurlWorkbench.Workspace.Validate
import System.Directory (createDirectoryIfMissing, createDirectoryLink, doesPathExist)
import System.Exit (ExitCode (..))
import System.FilePath ((</>))
import System.IO.Temp (withSystemTempDirectory)
import Test.Tasty (TestTree, testGroup)
import Test.Tasty.HUnit (assertBool, assertFailure, testCase, (@?=))

tests :: TestTree
tests =
  testGroup
    "integration suites"
    [ LifecycleTest.tests,
      testCase "explicit suite roots reach every request and protect refusal-summary inputs" $
        withSystemTempDirectory "suite-file-root" $ \directory -> do
          workspace <- loadValidated "test/fixtures/workspaces/isolation/hurl-workbench.dhall"
          root <- validateFileRoot directory (Just directory) >>= either (assertFailure . show) pure
          requests <- newTVarIO []
          let input = emptyBindingInput {plainOverrides = Map.singleton (ParameterName "baseUrl") (literal "http://fixture.test")}
              request = SuiteRequest (SuiteName "reads") (Just root) Nothing input defaultHurlOptions (SuiteOptions (positive 2) Nothing False False Set.empty Nothing False)
          _ <- runSuite (recordingRunner requests) trueCapabilities workspace request >>= requireSuite
          observed <- readTVarIO requests
          assertBool "all suite cases ran" (length observed > 1)
          for_ observed $ \run -> run ^. #fileRoot @?= root
          let forbidden _ _ = assertFailure "formatter ran before output preflight"
          result <- runSuiteWith forbidden (HurlRunner (const (assertFailure "Hurl ran"))) trueCapabilities workspace (request & #suiteOptions . #reportDirectory .~ Just directory & #suiteOptions . #overwriteReports .~ True)
          case result of
            Left err -> assertBool "root failure" (all (\case SuiteFileRootFailed _ -> True; _ -> False) (err ^. #issues))
            Right _ -> assertFailure "suite accepted overlapping report root"
          doesPathExist (directory </> "reads") >>= (@?= False),
      testCase "parallel unsafe suites are refused before syntax, binding IO, service, or Hurl" $ do
        workspace <- loadValidated "test/fixtures/workspaces/isolation/hurl-workbench.dhall"
        let request name allowed manage = SuiteRequest (SuiteName name) Nothing Nothing emptyBindingInput defaultHurlOptions (SuiteOptions (positive 2) Nothing allowed manage Set.empty Nothing False)
            forbidden _ _ = assertFailure "syntax validator called"
            runner = HurlRunner (const (assertFailure "Hurl started"))
        for_ [False, True] $ \allowed -> for_ [False, True] $ \manage -> do
          for_ ["mixed", "single-write"] $ \name -> do
            result <- runSuiteWith forbidden runner trueCapabilities workspace (request name allowed manage)
            case result of
              Left err -> do
                let unsafe = [scenario | SuiteIsolationFailed (SharedStateParallelUnsafe scenario _) <- toList (err ^. #issues)]
                unsafe @?= if name == "mixed" then ["dangerous-health", "writes/first", "writes/second", "health"] else ["dangerous-health"]
              Right _ -> assertFailure "unsafe suite started"
        for_ [1, 2] $ \jobs ->
          runSuiteWith forbidden runner trueCapabilities workspace (request "independent" True False & #suiteOptions . #jobs .~ positive jobs) >>= \case
            Left err -> toList (err ^. #issues) @?= [SuiteIsolationFailed IndependentStateHandoffRequired]
            Right _ -> assertFailure "bare independent declaration started"
        for_ [("reads", False, SuiteScenarioInputsUnexpected), ("independent", True, SuiteIndependentExternalRequired)] $ \(name, manage, issue) ->
          runSuiteWith forbidden runner trueCapabilities workspace (request name True manage & #scenarioInputs .~ Just (ScenarioInputs [])) >>= \case
            Left err -> toList (err ^. #issues) @?= [issue]
            Right _ -> assertFailure "invalid isolation mode accepted"
        for_ ["service-started", "readiness-started"] $ \marker ->
          doesPathExist ("test/fixtures/workspaces/isolation" </> marker) >>= (@?= False),
      testCase "parallel reads and serial authorized mutations remain supported" $ do
        workspace <- loadValidated "test/fixtures/workspaces/isolation/hurl-workbench.dhall"
        requests <- newTVarIO []
        let request name jobs allowed = SuiteRequest (SuiteName name) Nothing Nothing (emptyBindingInput {plainOverrides = Map.singleton (ParameterName "baseUrl") (literal "http://fixture.test")}) defaultHurlOptions (SuiteOptions (positive jobs) Nothing allowed False Set.empty Nothing False)
        readResult <- runSuite (recordingRunner requests) trueCapabilities workspace (request "reads" 2 False) >>= requireSuite
        readResult ^. #selectedExitCode @?= ExitSuccess
        length <$> readTVarIO requests >>= (@?= 3)
        write <- runSuite (recordingRunner requests) trueCapabilities workspace (request "single-write" 1 True) >>= requireSuite
        write ^. #selectedExitCode @?= ExitSuccess
        runSuite (recordingRunner requests) trueCapabilities workspace (request "single-write" 1 False) >>= \case
          Left err -> assertBool "serial still requires opt-in" (any isUnsafe (err ^. #issues))
          Right _ -> assertFailure "unauthorized mutation started",
      testCase "refusal and setup summaries contain logical cases and stable codes only" $
        withSystemTempDirectory "suite-preflight-summary" $ \directory -> do
          workspace <- loadValidated "test/fixtures/workspaces/isolation/hurl-workbench.dhall"
          let runner = HurlRunner (const (assertFailure "preflight started Hurl"))
              request name root = SuiteRequest (SuiteName name) Nothing Nothing emptyBindingInput defaultHurlOptions (SuiteOptions (positive 2) Nothing True False Set.empty (Just root) False)
          for_ [("mixed", "refused"), ("reads", "setup-failed")] $ \(name, status) -> do
            let root = directory </> name
            runSuiteWith (\_ _ -> pure (Right ())) runner trueCapabilities workspace (request (Text.pack name) root) >>= \case
              Left _ -> pure ()
              Right _ -> assertFailure "preflight unexpectedly succeeded"
            summary <- readJson (root </> name </> "summary.json")
            field "status" summary @?= Aeson.String status
            field "isolation" summary @?= Aeson.String "shared-state"
            let cases = arrayValues (field "cases" summary)
            assertBool "logical cases present" (not (null cases))
            for_ cases $ \logical -> do
              field "status" logical @?= Aeson.String "not-started"
              field "reports" logical @?= Aeson.toJSON ([] :: [Text])
              field "exitCode" logical @?= Aeson.Null
              assertBool "scenario is explicit" (field "scenario" logical /= Aeson.Null),
      testCase "unsafe report destinations and unknown suites preserve existing trees" $
        withSystemTempDirectory "suite-refusal-destination" $ \directory -> do
          workspace <- loadValidated "test/fixtures/workspaces/isolation/hurl-workbench.dhall"
          let outsideDirectory = directory </> "outside"
              link = directory </> "link"
              request name root = SuiteRequest (SuiteName name) Nothing Nothing emptyBindingInput defaultHurlOptions (SuiteOptions (positive 2) Nothing True False Set.empty (Just root) True)
              runner = HurlRunner (const (assertFailure "unexpected execution"))
          createDirectoryIfMissing True outsideDirectory
          ByteString.writeFile (outsideDirectory </> "sentinel") "preserve"
          createDirectoryLink outsideDirectory link
          result <- runSuiteWith (\_ _ -> assertFailure "unexpected format") runner trueCapabilities workspace (request "mixed" (link </> "nested"))
          case result of
            Left err -> assertBool "symlink ancestor rejected" (any (\case SuiteReportPathIsSymbolicLink _ -> True; _ -> False) (err ^. #issues))
            Right _ -> assertFailure "unsafe destination accepted"
          doesPathExist (outsideDirectory </> "nested") >>= (@?= False)
          _ <- runSuite runner trueCapabilities workspace (request "unknown" outsideDirectory)
          ByteString.readFile (outsideDirectory </> "sentinel") >>= (@?= "preserve")
          doesPathExist (outsideDirectory </> "unknown") >>= (@?= False),
      testCase "cancellation retains completed outcomes, joins workers, and propagates even if summary writing fails" $
        for_ [False, True] $ \breakSummary -> withSystemTempDirectory "suite-cancel-summary" $ \directory -> do
          workspace <- loadValidated "test/fixtures/workspaces/isolation/hurl-workbench.dhall"
          started <- newEmptyTMVarIO
          blocked <- newEmptyTMVarIO
          starts <- newTVarIO (0 :: Int)
          active <- newTVarIO (0 :: Int)
          let summaryPath = directory </> "reads" </> "summary.json"
              literal_ = literal "private-plain-sentinel"
              request = SuiteRequest (SuiteName "reads") Nothing Nothing (emptyBindingInput {plainOverrides = Map.singleton (ParameterName "baseUrl") literal_}) defaultHurlOptions (SuiteOptions (positive 1) Nothing False False Set.empty (Just directory) False)
              runner = HurlRunner $ \_ -> do
                ordinal <- atomically $ do
                  modifyTVar' starts (+ 1)
                  count <- readTVar starts
                  pure count
                if ordinal == 1
                  then pure (Right (RunResult ExitSuccess 0.01 (Just (CapturedRunOutput "private-captured-sentinel" "private-secret-sentinel"))))
                  else bracket_
                    (atomically (modifyTVar' active (+ 1)))
                    (atomically (modifyTVar' active (subtract 1)))
                    $ do
                      when breakSummary (createDirectoryIfMissing True summaryPath)
                      atomically (putTMVar started ())
                      _ <- atomically (takeTMVar blocked)
                      assertFailure "cancelled run resumed"
          withAsync (runSuiteWith (\_ _ -> pure (Right ())) runner trueCapabilities workspace request) $ \worker -> do
            atomically (takeTMVar started)
            cancel worker
            waitCatch worker >>= \case
              Left err -> (fromException err :: Maybe AsyncCancelled) @?= Just AsyncCancelled
              Right _ -> assertFailure "cancellation returned a SuiteResult"
          readTVarIO active >>= (@?= 0)
          readTVarIO starts >>= (@?= 2)
          unless breakSummary $ do
            raw <- ByteString.readFile summaryPath
            for_ ["private-plain-sentinel", "private-captured-sentinel", "private-secret-sentinel"] $ \sentinel -> assertBool "summary redacted" (not (sentinel `ByteString.isInfixOf` raw))
            summary <- readJson summaryPath
            field "status" summary @?= Aeson.String "cancelled"
            let cases = arrayValues (field "cases" summary)
            map (field "scenario") cases @?= map Aeson.String ["health-cases/first", "health-cases/second", "health-cases/third"]
            map (field "status") cases @?= map Aeson.String ["passed", "cancelled", "cancelled"],
      testCase "summary failure preserves a managed-service failure status" $
        withSystemTempDirectory "suite-service-summary-failure" $ \directory -> do
          original <- failureWorkspace
          let summaryPath = directory </> "failure" </> "summary.json"
              context = validatedContext original
              sabotage service = service & #command . #executable .~ "python3" & #command . #arguments .~ ["-c", "import os, sys; os.mkdir(sys.argv[1]); raise SystemExit(9)", Text.pack summaryPath]
          workspace <- validateWorkspace (context & #workspace . #services %~ map sabotage) >>= either (assertFailure . show) pure
          requests <- newTVarIO []
          let request = SuiteRequest (SuiteName "failure") Nothing Nothing emptyBindingInput defaultHurlOptions (SuiteOptions (positive 1) Nothing False True Set.empty (Just directory) False)
          result <- runSuite (recordingRunner requests) trueCapabilities workspace request >>= requireSuite
          result ^. #selectedExitCode @?= ExitFailure 4
          assertBool "summary failure retained" (isJust (result ^. #summaryError))
          readTVarIO requests >>= (@?= []),
      testCase "missing managed environment skips Hurl; external service bypasses policy" $ do
        original <- failureWorkspace
        let context = validatedContext original
            services = map (\service -> service & #command . #environmentPolicy .~ Clean ["HURL_WORKBENCH_TEST_ABSENT_972638"]) (validatedWorkspace original ^. #services)
        workspace <- validateWorkspace (context & #workspace . #services .~ services) >>= either (assertFailure . show) pure
        requests <- newTVarIO []
        let request = SuiteRequest (SuiteName "failure") Nothing Nothing emptyBindingInput defaultHurlOptions (SuiteOptions (positive 1) Nothing False True Set.empty Nothing False)
        result <- runSuite (recordingRunner requests) trueCapabilities workspace request
        case result of
          Left err -> assertBool "missing environment rejected in preflight" (any (\case SuiteServiceResolutionFailed _ -> True; _ -> False) (err ^. #issues))
          Right _ -> assertFailure "missing managed environment accepted"
        readTVarIO requests >>= (@?= [])
        external <- runSuite (recordingRunner requests) trueCapabilities workspace (request & #suiteOptions . #manageService .~ False) >>= requireSuite
        external ^. #selectedExitCode @?= ExitSuccess
        length <$> readTVarIO requests >>= (@?= 1),
      testCase "blocks unclassified runs before the managed service or Hurl starts" $
        withSystemTempDirectory "hurl-workbench-suite-gate" $ \directory -> do
          ByteString.writeFile (directory </> "secrets.env") "client_secret=suite-secret\n"
          workspace <- fullWorkspace
          requests <- newTVarIO []
          result <- runSuite (recordingRunner requests) trueCapabilities workspace (fullSuiteRequest directory False True Set.empty Nothing False)
          case result of
            Left err ->
              assertBool
                "preflight identifies the unclassified workflow"
                (any isUnsafe (toList (err ^. #issues)))
            Right _ -> assertFailure "expected the suite safety gate to fail"
          readTVarIO requests >>= (@?= []),
      testCase "flattens runs, isolates reports, and writes a redacted atomic summary" $
        withSystemTempDirectory "hurl-workbench-suite-reports" $ \directory -> do
          ByteString.writeFile (directory </> "secrets.env") "client_secret=suite-secret\n"
          workspace <- fullWorkspace
          requests <- newTVarIO []
          let reportRoot = directory </> "reports"
              request = fullSuiteRequest directory True False (Set.fromList [JUnit, Json]) (Just reportRoot) False
          result <- runSuite (recordingRunner requests) trueCapabilities workspace request >>= requireSuite
          map (view #name) (toList (result ^. #cases))
            @?= ["list-members", "top-properties", "property-page-sizes/small", "property-page-sizes/large"]
          result ^. #serviceOutcome @?= Nothing
          result ^. #selectedExitCode @?= ExitSuccess
          summary <- maybe (assertFailure "missing summary path") pure (result ^. #summaryPath)
          rawSummary <- ByteString.readFile summary
          assertBool "summary omits secret values" (not ("suite-secret" `ByteString.isInfixOf` rawSummary))
          assertBool "summary omits plain runtime values" (not ("suite-client" `ByteString.isInfixOf` rawSummary))
          assertBool "summary names relative report targets" ("property-page-sizes/small/junit.xml" `ByteString.isInfixOf` rawSummary)
          observed <- readTVarIO requests
          length observed @?= 4
          traverse_ assertTestRequest observed
          traverse_ (traverse_ assertReportExists . requestReportPaths) observed
          runSuite (recordingRunner requests) trueCapabilities workspace request >>= \case
            Left err -> assertBool "existing suite report root is rejected" (any isExistingReport (toList (err ^. #issues)))
            Right _ -> assertFailure "expected an existing report error"
          overwritten <- runSuite (recordingRunner requests) trueCapabilities workspace (request & #suiteOptions . #overwriteReports .~ True) >>= requireSuite
          overwritten ^. #selectedExitCode @?= ExitSuccess,
      testCase "turns managed-service failure into exit 4 and prerequisite skips" $ do
        workspace <- failureWorkspace
        requests <- newTVarIO []
        let request =
              SuiteRequest
                { suiteName = SuiteName "failure",
                  fileRoot = Nothing,
                  scenarioInputs = Nothing,
                  bindingInput = emptyBindingInput,
                  hurlOptions = defaultHurlOptions,
                  suiteOptions = SuiteOptions (positive 1) Nothing False True Set.empty Nothing False
                }
        result <- runSuite (recordingRunner requests) trueCapabilities workspace request >>= requireSuite
        result ^. #selectedExitCode @?= ExitFailure 4
        map (view #outcome) (toList (result ^. #cases)) @?= [CaseSkipped BatchPrerequisiteFailed]
        case result ^. #serviceOutcome of
          Just (ServiceFailed _) -> pure ()
          other -> assertFailure ("expected managed-service failure, got " <> show other)
        readTVarIO requests >>= (@?= [])
    ]

fullSuiteRequest :: FilePath -> Bool -> Bool -> Set ReportFormat -> Maybe FilePath -> Bool -> SuiteRequest
fullSuiteRequest directory allowMutating manageService reportFormats reportDirectory overwriteReports =
  SuiteRequest
    { suiteName = SuiteName "smoke",
      fileRoot = Nothing,
      scenarioInputs = Nothing,
      bindingInput =
        emptyBindingInput
          { plainOverrides = Map.singleton (ParameterName "client_id") (literal "suite-client"),
            secretFiles = [directory </> "secrets.env"]
          },
      hurlOptions = defaultHurlOptions,
      suiteOptions =
        SuiteOptions
          { jobs = positive 1,
            failFastOverride = Nothing,
            allowMutating,
            manageService,
            reportFormats,
            reportDirectory,
            overwriteReports
          }
    }

recordingRunner :: TVar [RunRequest] -> HurlRunner
recordingRunner requests = HurlRunner $ \request -> do
  materializeReports request
  atomically (modifyTVar' requests (request :))
  pure (Right (RunResult ExitSuccess 0.01 (Just (CapturedRunOutput "" ""))))

materializeReports :: RunRequest -> IO ()
materializeReports request = traverse_ create (request ^. #reportTargets)
  where
    create = \case
      JUnitReport path -> ByteString.writeFile path "<testsuites/>"
      TapReport path -> ByteString.writeFile path "1..0\n"
      HtmlReport path -> createDirectoryIfMissing True path >> ByteString.writeFile (path </> "index.html") ""
      JsonReport path -> createDirectoryIfMissing True path >> ByteString.writeFile (path </> "report.json") "{}"

requestReportPaths :: RunRequest -> [FilePath]
requestReportPaths request = map path (request ^. #reportTargets)
  where
    path = \case
      JUnitReport value -> value
      TapReport value -> value
      HtmlReport value -> value
      JsonReport value -> value

assertTestRequest :: RunRequest -> IO ()
assertTestRequest request = do
  request ^. #mode @?= TestMode
  length (request ^. #reportTargets) @?= 2

assertReportExists :: FilePath -> IO ()
assertReportExists path = doesPathExist path >>= assertBool ("fake report target was not created: " <> path)

requireSuite :: Either SuitePreflightError SuiteResult -> IO SuiteResult
requireSuite = either (assertFailure . Text.unpack . renderSuitePreflightError) pure

isUnsafe :: SuitePreflightIssue -> Bool
isUnsafe SuiteUnsafeRun {} = True
isUnsafe _ = False

isExistingReport :: SuitePreflightIssue -> Bool
isExistingReport SuiteReportPathExists {} = True
isExistingReport _ = False

literal :: Text -> HurlValueLiteral
literal value = either (error . show) id (mkHurlValueLiteral value)

positive :: Int -> PositiveInt
positive value = either (error . show) id (mkPositiveInt value)

trueCapabilities :: HurlfmtCapabilities
trueCapabilities = HurlfmtCapabilities (HurlfmtExecutable "true") (makeVersion [8, 0, 1])

fullWorkspace :: IO ValidatedWorkspace
fullWorkspace = loadValidated "test/fixtures/workspaces/full/hurl-workbench.dhall"

failureWorkspace :: IO ValidatedWorkspace
failureWorkspace = loadValidated "test/fixtures/workspaces/suite-failure/hurl-workbench.dhall"

loadValidated :: FilePath -> IO ValidatedWorkspace
loadValidated path = do
  loaded <- loadWorkspaceContext (ExplicitWorkspace path)
  context <- either (assertFailure . Text.unpack . renderWorkspaceError) pure loaded
  validateWorkspace context >>= either (assertFailure . Text.unpack . Text.unlines . map renderValidationIssue . toList) pure

readJson :: FilePath -> IO Aeson.Value
readJson path = ByteString.readFile path >>= either assertFailure pure . Aeson.eitherDecodeStrict

field :: Aeson.Key -> Aeson.Value -> Aeson.Value
field key (Aeson.Object object) = fromMaybe Aeson.Null (KeyMap.lookup key object)
field _ _ = Aeson.Null

arrayValues :: Aeson.Value -> [Aeson.Value]
arrayValues (Aeson.Array values) = toList values
arrayValues _ = []