packages feed

hurl-workbench-core-0.1.0.0: test/HurlWorkbench/Workspace/WorkspaceTest.hs

module HurlWorkbench.Workspace.WorkspaceTest (tests) where

import Data.Generics.Labels ()
import Data.Map.Strict qualified as Map
import Data.Text qualified as Text
import Data.Text.IO qualified as Text.IO
import HurlWorkbench.Prelude
import HurlWorkbench.Workspace.Context
import HurlWorkbench.Workspace.Decode
import HurlWorkbench.Workspace.Discover
import HurlWorkbench.Workspace.Error
import HurlWorkbench.Workspace.Types
import HurlWorkbench.Workspace.Validate
import System.Directory (canonicalizePath, createDirectoryIfMissing, createFileLink)
import System.FilePath ((</>))
import System.IO.Temp (withSystemTempDirectory)
import Test.Tasty (TestTree, testGroup)
import Test.Tasty.HUnit (assertBool, assertFailure, testCase, (@?=))

tests :: TestTree
tests =
  testGroup
    "workspace"
    [ decodingTests,
      discoveryTests,
      validationTests,
      literalTests
    ]

fixture :: FilePath -> FilePath
fixture name = "test/fixtures/workspaces" </> name </> "hurl-workbench.dhall"

--------------------------------------------------------------------------------
-- Decoding

decodingTests :: TestTree
decodingTests =
  testGroup
    "decoding"
    [ testCase "frozen old service suites normalize additive fields and run unions" $ do
        ws <- decodeOrFail (fixture "old-service-suite")
        map suiteServices (ws ^. #suites) @?= [[ServiceName "old"]]
        map (view #setup) (ws ^. #suites) @?= [[]]
        map (view #commandTimeoutSeconds) (ws ^. #suites) @?= [60],
      testCase "frozen populated old suites default to shared state" $ do
        old <- decodeOrFail (fixture "old-suite")
        shared <- decodeOrFail (fixture "shared-suite")
        old @?= shared
        map (view #isolation) (old ^. #suites) @?= [SharedState]
        independent <- decodeOrFail (fixture "independent-suite")
        independent @?= (shared & #suites .~ map (\suite -> suite & #isolation .~ IndependentState) (shared ^. #suites)),
      testCase "invalid isolation values fail typed decode" $
        withSystemTempDirectory "hurl-isolation" $ \dir -> do
          schema <- canonicalizePath schemaPackage
          for_ ["42", "< Wrong >.Wrong"] $ \policy -> do
            let manifest = dir </> manifestFileName
            Text.IO.writeFile manifest ("let S = " <> Text.pack schema <> " in S.Workspace::{ schemaVersion = 1, suites = [{ name = \"bad\", runs = [] : List S.RunReference, service = None Text, failFast = False, description = None Text, isolation = " <> policy <> " }] }")
            decodeWorkspaceFile manifest >>= \case
              Left DhallFailure {} -> pure ()
              other -> assertFailure ("expected invalid policy, got " <> show other),
      policyDecodingTests,
      testCase "frozen old service and readiness records inherit" $
        for_ ["old-command", "old-http"] $ \name -> do
          ws <- decodeOrFail (fixture name)
          map (view (#command . #environmentPolicy)) (ws ^. #services) @?= [Inherit],
      testCase "completion-based and fully specified manifests decode identically" $ do
        completed <- decodeOrFail (fixture "minimal")
        explicit <- decodeOrFail (fixture "minimal-explicit")
        completed @?= explicit
        completed ^. #schemaVersion @?= 1
        map (view #defaultValue) (completed ^. #parameters)
          @?= [Just (literal "https://api.example.test")],
      testCase "a manifest may import sibling Dhall modules" $ do
        ws <- decodeOrFail (fixture "full")
        map (unParameterName . view #name) (ws ^. #parameters)
          @?= ["auth_url", "base_url", "client_id", "client_secret", "top"],
      testCase "an unsupported schema version is reported before typed decoding" $ do
        path <- canonicalizePath (fixture "schema-version")
        result <- decodeWorkspaceFile path
        result @?= Left (UnsupportedSchemaVersion path 2 1),
      testCase "a Dhall type error is reported with the manifest path" $
        withSystemTempDirectory "hurl-workbench" $ \dir -> do
          let manifest = dir </> manifestFileName
          Text.IO.writeFile manifest "{ schemaVersion = 1, fragments = 42 }"
          result <- decodeWorkspaceFile manifest
          case result of
            Left (DhallFailure path message) -> do
              path @?= manifest
              assertBool "mentions the mismatch" ("doesn't match annotation" `Text.isInfixOf` message)
              assertBool "contains no ANSI escapes" (not ("\ESC" `Text.isInfixOf` message))
            other -> assertFailure ("expected a Dhall failure, got " <> show other)
    ]

decodeOrFail :: FilePath -> IO Workspace
decodeOrFail path =
  decodeWorkspaceFile path >>= \case
    Right ws -> pure ws
    Left err -> assertFailure (Text.unpack (renderWorkspaceError err))

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

--------------------------------------------------------------------------------
-- Discovery

discoveryTests :: TestTree
discoveryTests =
  testGroup
    "discovery"
    [ testCase "finds the manifest from a nested child directory" $
        withSystemTempDirectory "hurl-workbench" $ \dir -> do
          writeManifest dir []
          let nested = dir </> "a" </> "b" </> "c"
          createDirectoryIfMissing True nested
          result <- discoverWorkspace Nothing nested
          result @?= Right (DiscoveredWorkspace (dir </> manifestFileName)),
      testCase "an explicit manifest wins over discovery" $
        withSystemTempDirectory "hurl-workbench" $ \dir -> do
          writeManifest dir []
          let other = dir </> "other"
          createDirectoryIfMissing True other
          writeManifest other []
          result <- discoverWorkspace (Just ("other" </> manifestFileName)) dir
          result @?= Right (ExplicitWorkspace (other </> manifestFileName)),
      testCase "a missing explicit manifest is an error, even when discovery would succeed" $
        withSystemTempDirectory "hurl-workbench" $ \dir -> do
          writeManifest dir []
          result <- discoverWorkspace (Just "missing.dhall") dir
          result @?= Left (ManifestNotFound (dir </> "missing.dhall")),
      testCase "discovery stops at the filesystem root" $
        withSystemTempDirectory "hurl-workbench" $ \dir -> do
          result <- discoverWorkspace Nothing dir
          result @?= Left (NoWorkspaceFound dir)
    ]

-- | Write a manifest with the given @(name, path)@ fragments, importing the
--   repository schema by absolute path.
writeManifest :: FilePath -> [(Text, Text)] -> IO ()
writeManifest dir fragmentList = do
  schema <- canonicalizePath schemaPackage
  writeManifestWithSchema schema dir fragmentList

-- | The repository schema, relative to the package directory in which
--   @cabal test@ runs.
schemaPackage :: FilePath
schemaPackage = "schema/package.dhall"

--------------------------------------------------------------------------------
-- Validation

validationTests :: TestTree
validationTests =
  testGroup
    "validation"
    [ testCase "independent suites cannot declare a shared service" $ do
        original <- validFixture "full"
        let context = validatedContext original
            suites = map (\suite -> suite & #isolation .~ IndependentState) (validatedWorkspace original ^. #suites)
        validateWorkspace (context & #workspace . #suites .~ suites) >>= \case
          Left issues -> assertBool "service rejected" (any (Text.isInfixOf "cannot declare a shared managed service" . view #message) issues)
          Right _ -> assertFailure "expected invalid independent managed suite",
      testCase "a complete workspace validates and offers typed lookups" $ do
        vw <- validFixture "full"
        Map.keys (validatedWorkflows vw) @?= [WorkflowName "list-members", WorkflowName "list-properties"]
        let fragmentsOf name = maybe [] (view #fragments) (lookupWorkflow (WorkflowName name) vw)
        -- One OAuth fragment is shared by both resource workflows.
        take 1 (fragmentsOf "list-properties") @?= [FragmentName "oauth"]
        take 1 (fragmentsOf "list-members") @?= [FragmentName "oauth"]
        fmap (view #safety) (lookupRecipe (RecipeName "top-properties") vw) @?= Just ReadOnly
        fmap (length . view #cases) (lookupMatrix (MatrixName "property-page-sizes") vw) @?= Just 2
        fmap (view #service) (lookupSuite (SuiteName "smoke") vw) @?= Just (Just (ServiceName "api"))
        root <- canonicalizePath "test/fixtures/workspaces/full"
        validatedRoot vw @?= WorkspaceRoot root
        lookupFragmentFile (FragmentName "oauth") vw @?= Just (root </> "hurl/oauth.hurl"),
      testCase "every unresolved reference is reported together, in stable order" $ do
        issues <- invalidFixture "missing-reference"
        issues
          @?= [ ValidationIssue "workflows/empty/fragments" "workflow must list at least one fragment",
                ValidationIssue "workflows/flow/fragments" "unknown fragment \"missing-fragment\"",
                ValidationIssue "workflows/flow/parameters" "unknown parameter \"missing_parameter\"",
                ValidationIssue "recipes/orphan/workflow" "unknown workflow \"missing-workflow\"",
                ValidationIssue "matrices/grid/recipe" "unknown recipe \"missing-recipe\"",
                ValidationIssue "matrices/grid/cases" "matrix must have at least one case",
                ValidationIssue "suites/all/runs" "unknown matrix \"missing-matrix\"",
                ValidationIssue "suites/all/service" "unknown service \"missing-service\""
              ]
        again <- invalidFixture "missing-reference"
        again @?= issues,
      testCase "duplicate and malformed names are reported" $ do
        issues <- invalidFixture "duplicate"
        map (view #location) issues @?= ["fragments/a", "workflows/9-bad name", "workflows/flow"]
        assertMentions issues "duplicate fragment name declared 2 times"
        assertMentions issues "duplicate workflow name declared 2 times",
      testCase "unsafe fragment paths are rejected" $ do
        issues <- invalidFixture "escaping-path"
        issues
          @?= [ ValidationIssue "fragments/absolute/path" "path \"/etc/hosts\" must be relative to the workspace root",
                ValidationIssue "fragments/missing/path" "path \"hurl/missing.hurl\" does not exist as a regular file",
                ValidationIssue "fragments/not-hurl/path" "fragment path \"outside-notes.txt\" must end in .hurl",
                ValidationIssue "fragments/parent/path" "path \"../minimal/hurl/health.hurl\" escapes the workspace root"
              ],
      testCase "a symlink that leaves the workspace is rejected" $
        withSystemTempDirectory "hurl-workbench" $ \dir -> do
          let workspaceDir = dir </> "workspace"
          createDirectoryIfMissing True (workspaceDir </> "hurl")
          Text.IO.writeFile (dir </> "outside.hurl") "GET https://example.test\n"
          Text.IO.writeFile (workspaceDir </> "hurl" </> "inside.hurl") "GET https://example.test\n"
          createFileLink (dir </> "outside.hurl") (workspaceDir </> "hurl" </> "escape.hurl")
          createFileLink (workspaceDir </> "hurl" </> "inside.hurl") (workspaceDir </> "hurl" </> "alias.hurl")
          writeManifest workspaceDir [("escape", "hurl/escape.hurl"), ("alias", "hurl/alias.hurl")]
          issues <- invalidManifest (workspaceDir </> manifestFileName)
          issues @?= [ValidationIssue "fragments/escape/path" "path \"hurl/escape.hurl\" escapes the workspace root"],
      testCase "a secret with a committed default is rejected" $ do
        issues <- invalidFixture "secret-default"
        issues @?= [ValidationIssue "parameters/api_key" "secret parameters must not have a committed defaultValue"],
      testCase "values that Hurl variable files cannot carry are rejected with their location" $ do
        issues <- invalidFixture "invalid-value"
        map (view #location) issues
          @?= ["parameters/multiline/defaultValue", "parameters/newUuid", "parameters/padded/defaultValue"]
    ]
  where
    assertMentions issues needle =
      assertBool
        ("expected an issue mentioning " <> Text.unpack needle)
        (any (Text.isInfixOf needle . view #message) issues)

writeManifestWithSchema :: FilePath -> FilePath -> [(Text, Text)] -> IO ()
writeManifestWithSchema schema dir fragmentList =
  Text.IO.writeFile (dir </> manifestFileName) $
    Text.unlines
      [ "let Schema = " <> Text.pack schema,
        "in Schema.Workspace::{ schemaVersion = 1, fragments = ["
          <> Text.intercalate ", " [fragmentLiteral name path | (name, path) <- fragmentList]
          <> "] : List Schema.Fragment.Type }"
      ]
  where
    fragmentLiteral name path = "Schema.Fragment::{ name = \"" <> name <> "\", path = \"" <> path <> "\" }"

loadManifest :: FilePath -> IO (Either (NonEmpty ValidationIssue) ValidatedWorkspace)
loadManifest manifest = do
  context <-
    discoverWorkspace (Just manifest) "." >>= \case
      Left err -> assertFailure (Text.unpack (renderWorkspaceError err))
      Right source ->
        loadWorkspaceContext source >>= \case
          Left err -> assertFailure (Text.unpack (renderWorkspaceError err))
          Right ctx -> pure ctx
  validateWorkspace context

validFixture :: FilePath -> IO ValidatedWorkspace
validFixture name =
  loadManifest (fixture name) >>= \case
    Right vw -> pure vw
    Left issues -> assertFailure (Text.unpack (Text.unlines (map renderValidationIssue (toList issues))))

invalidFixture :: FilePath -> IO [ValidationIssue]
invalidFixture = invalidManifest . fixture

invalidManifest :: FilePath -> IO [ValidationIssue]
invalidManifest manifest =
  loadManifest manifest >>= \case
    Right _ -> assertFailure "expected validation to fail"
    Left issues -> pure (toList issues)

--------------------------------------------------------------------------------
-- Hurl value literals

literalTests :: TestTree
literalTests =
  testGroup
    "HurlValueLiteral"
    [ testCase "accepts values Hurl reads back unchanged" $
        for_ ["", "true", "null", "42", "3.5", "a=b", "has inner space", "\"quoted\""] $ \value ->
          fmap hurlValueLiteralText (mkHurlValueLiteral value) @?= Right value,
      testCase "rejects line breaks and NUL" $
        for_ ["a\nb", "a\rb", "a\0b"] $ \value ->
          mkHurlValueLiteral value @?= Left ContainsLineBreakOrNul,
      testCase "rejects surrounding whitespace that Hurl trims" $
        for_ [" a", "a ", "\ta", "a\x2028", "\x85"] $ \value ->
          mkHurlValueLiteral value @?= Left SurroundingWhitespace
    ]

policyDecodingTests :: TestTree
policyDecodingTests =
  testGroup
    "command policies"
    [ testCase "default, explicit inheritance, and empty clean policies" $
        withSystemTempDirectory "policy-decode" $ \dir -> do
          for_ [("", Inherit), (", environmentPolicy = Schema.EnvironmentPolicy.Inherit", Inherit), (", environmentPolicy = Schema.EnvironmentPolicy.Clean ([] : List Text)", Clean [])] $ \(field, expected) -> do
            writePolicyManifest dir field
            ws <- decodeOrFail (dir </> manifestFileName)
            map (view (#command . #environmentPolicy)) (ws ^. #services) @?= [expected],
      testCase "malformed policy types fail decoding" $
        withSystemTempDirectory "policy-malformed" $ \dir -> do
          for_ ["42", "< Inherit | Clean : Text >.Clean \"PATH\""] $ \value -> do
            writePolicyManifest dir (", environmentPolicy = " <> value)
            decodeWorkspaceFile (dir </> manifestFileName) >>= \case
              Left (DhallFailure _ _) -> pure ()
              other -> assertFailure (show other),
      testCase "invalid and duplicate inherited names reject deterministically in both commands" $
        withSystemTempDirectory "policy-validation" $ \dir -> do
          writePolicyManifest dir ", environmentPolicy = Schema.EnvironmentPolicy.Clean [ \"\", \"9BAD\", \"A=B\", \"A B\", \"PATH\", \"PATH\" ]"
          loaded <- loadWorkspaceContext (ExplicitWorkspace (dir </> manifestFileName)) >>= either (assertFailure . show) pure
          let extraNames = Clean ["A\NULB", "A\nB", "A\tB"]
              withExtra = loaded & #workspace . #services %~ map (\service -> service & #command . #environmentPolicy .~ extraNames)
          validateWorkspace withExtra >>= \case
            Left issues -> assertBool "NUL and whitespace rejected" (length (toList issues) >= 3)
            Right _ -> assertFailure "expected invalid control characters"
          first <- validateWorkspace loaded
          second <- validateWorkspace loaded
          case (first, second) of
            (Left issues, Left again) -> do
              issues @?= again
              length (toList issues) @?= 10
              assertBool "readiness locations present" (any (Text.isInfixOf "/readiness/command/environmentPolicy" . view #location) (toList issues))
            _ -> assertFailure "expected static policy errors"
    ]

writePolicyManifest :: FilePath -> Text -> IO ()
writePolicyManifest dir policyField = do
  schema <- canonicalizePath schemaPackage
  Text.IO.writeFile (dir </> manifestFileName) $
    "let Schema = " <> Text.pack schema <> " let command = Schema.CommandSpec::{ executable = \"/usr/bin/true\" " <> policyField <> " } in Schema.Workspace::{ schemaVersion = 1, services = [ Schema.Service::{ name = \"policy\", command = command, readiness = Schema.Readiness.Command Schema.CommandReadiness::{ command = command } } ] }"