packages feed

seihou-cli-0.16.0.0: test/Seihou/CLI/InstallClosureSpec.hs

module Seihou.CLI.InstallClosureSpec (tests) where

import Data.Either (isLeft)
import Data.Text qualified as T
import Seihou.CLI.InstallClosure (installClosureInto, publishStagedClosure, stageClosuresInto)
import Seihou.CLI.InstallShared (installModuleDirInto)
import System.Directory
import System.FilePath ((</>))
import System.IO.Temp (withSystemTempDirectory)
import Test.Hspec
import Test.Tasty
import Test.Tasty.Hspec (testSpec)

tests :: IO TestTree
tests = testSpec "Seihou.CLI.InstallClosure" $ do
  it "acquires same-registry recipe bases, materializes their source, and repairs a missing base" $
    withSystemTempDirectory "closure-recipes" $ \tmp -> do
      let repo = tmp </> "repo"
          cache = tmp </> "cache"
          source = "https://example.com/registry.git"
      moduleDir repo "member"
      recipeDir repo "base" "[\"member\"]" ""
      recipeDir repo "child" "[] : List Text" ", extends = Some {recipe = \"base\"}"
      registry repo ["member"] ["base", "child"]
      installClosureInto cache False repo source [("child", repo </> "child")] `shouldReturn` Right ()
      doesFileExist (cache </> "base/recipe.dhall") `shouldReturn` True
      normalized <- readFile (cache </> "child/recipe.dhall")
      T.pack normalized `shouldSatisfy` T.isInfixOf source
      removeDirectoryRecursive (cache </> "base")
      installClosureInto cache False repo source [("child", repo </> "child")] `shouldReturn` Right ()
      doesFileExist (cache </> "base/recipe.dhall") `shouldReturn` True

  it "acquires the judgements a module references, by source, and the same registry's own" $
    withSystemTempDirectory "closure-judgements" $ \tmp -> do
      let repo = tmp </> "repo"
          cache = tmp </> "cache"
          source = "https://example.com/registry.git"
      judgementDir repo "guard"
      judgementDir repo "local-guard"
      -- One reference with a source, one without that this registry provides,
      -- and one without that only the bundled catalogue provides; a false
      -- when does not stop acquisition.
      moduleWithJudgements repo "member" ["{ name = \"guard\", source = Some " <> show source <> ", when = Some \"false\" }", "{ name = \"local-guard\", source = None Text, when = None Text }", "{ name = \"security-review\", source = None Text, when = None Text }"]
      registryWith repo ["member"] ["guard", "local-guard"]
      installClosureInto cache False repo source [("member", repo </> "member")] `shouldReturn` Right ()
      doesFileExist (cache </> "guard/judgement.dhall") `shouldReturn` True
      doesFileExist (cache </> "local-guard/judgement.dhall") `shouldReturn` True
      doesDirectoryExist (cache </> "security-review") `shouldReturn` False

  it "refuses a referenced judgement its source does not provide, before publishing" $
    withSystemTempDirectory "closure-judgement-missing" $ \tmp -> do
      let repo = tmp </> "repo"
          cache = tmp </> "cache"
          source = "https://example.com/registry.git"
      moduleWithJudgements repo "member" ["{ name = \"guard\", source = Some " <> show source <> ", when = None Text }"]
      moduleDir repo "guard"
      registryWith repo ["member", "guard"] []
      result <- installClosureInto cache False repo source [("member", repo </> "member")]
      either (T.isInfixOf "guard not found as a judgement") (const False) result `shouldBe` True
      doesDirectoryExist cache `shouldReturn` False

  it "normalizes read-only descriptors and payload imports without changing source permissions" $
    withSystemTempDirectory "closure-read-only" $ \tmp -> do
      let repo = tmp </> "repo"
          cache = tmp </> "cache"
          descriptor = repo </> "member/module.dhall"
          payload = repo </> "member/files/value.dhall"
      moduleDir repo "member"
      createDirectoryIfMissing True (repo </> "member/files")
      writeFile payload "let value = \"hello\" in value"
      mapM_ (\file -> getPermissions file >>= setPermissions file . setOwnerWritable False) [descriptor, payload]
      before <- mapM getPermissions [descriptor, payload]
      installClosureInto cache False repo "https://example.com/registry.git" [("member", repo </> "member")] `shouldReturn` Right ()
      mapM getPermissions [descriptor, payload] `shouldReturn` before
      normalized <- readFile (cache </> "member/files/value.dhall")
      T.strip (T.pack normalized) `shouldBe` "\"hello\""

  it "rejects wrong-kind bases, range violations, cycles and invalid merged overrides before publishing" $
    withSystemTempDirectory "closure-recipe-refusals" $ \tmp -> do
      let repo = tmp </> "repo"
          cache = tmp </> "cache"
          source = "https://example.com/registry.git"
      moduleDir repo "member"
      recipeDir repo "base" "[\"member\"]" ""
      registry repo ["member"] ["base", "child"]
      mapM_
        ( \extension -> do
            recipeDir repo "child" "[] : List Text" extension
            result <- installClosureInto cache False repo source [("child", repo </> "child")]
            result `shouldSatisfy` isLeft
            doesDirectoryExist cache `shouldReturn` False
        )
        [ ", extends = Some {recipe = \"member\"}",
          ", extends = Some {recipe = \"base\", versionRange = Some {minimum = \"2\", before = \"3\"}}",
          ", extends = Some {recipe = \"child\"}",
          ", extends = Some {recipe = \"base\"}, moduleOverrides = [{module = \"missing\", vars = [{name = \"x\", value = \"new\"}]}]"
        ]

  it "rejects indirect recipe cycles and recipe names used as module edges" $
    withSystemTempDirectory "closure-recipe-kinds" $ \tmp -> do
      let repo = tmp </> "repo"
          cache = tmp </> "cache"
          source = "https://example.com/registry.git"
      moduleDir repo "member"
      recipeDir repo "base" "[] : List Text" ", extends = Some {recipe = \"child\"}"
      recipeDir repo "child" "[] : List Text" ", extends = Some {recipe = \"base\"}"
      registry repo ["member"] ["base", "child"]
      cycleResult <- installClosureInto cache False repo source [("child", repo </> "child")]
      cycleResult `shouldSatisfy` isLeft
      doesDirectoryExist cache `shouldReturn` False
      recipeDir repo "base" "[\"member\"]" ""
      recipeDir repo "child" "[\"base\"]" ""
      wrongKind <- installClosureInto cache False repo source [("child", repo </> "child")]
      wrongKind `shouldSatisfy` isLeft
      doesDirectoryExist cache `shouldReturn` False

  it "refuses an escaping selected path without creating a cache" $
    withSystemTempDirectory "closure" $ \tmp -> do
      let repo = tmp </> "repo"
          cache = tmp </> "cache"
      createDirectory repo
      result <- installClosureInto cache False repo "https://example.com/registry" [("escape", tmp)]
      result `shouldSatisfy` isLeft
      doesDirectoryExist cache `shouldReturn` False

  it "preflights all roots before replacing any existing artifact" $
    withSystemTempDirectory "closure" $ \tmp -> do
      let repo = tmp </> "repo"
          cache = tmp </> "cache"
      moduleDir repo "good"
      _ <- installModuleDirInto cache False (repo </> "good") "good" "https://example.com/registry" Nothing Nothing []
      writeFile (cache </> "good" </> "marker") "original"
      createDirectory (repo </> "bad")
      writeFile (repo </> "bad" </> "recipe.dhall") "this is not Dhall"
      result <- installClosureInto cache False repo "https://example.com/registry" [("good", repo </> "good"), ("bad", repo </> "bad")]
      result `shouldSatisfy` isLeft
      readFile (cache </> "good" </> "marker") `shouldReturn` "original"
      doesDirectoryExist (cache </> "bad") `shouldReturn` False

  it "restores an earlier replacement when a later destination cannot be written" $
    withSystemTempDirectory "closure" $ \tmp -> do
      let repo = tmp </> "repo"
          cache = tmp </> "cache"
      moduleDir repo "good"
      moduleDir repo "blocked"
      _ <- installModuleDirInto cache False (repo </> "good") "good" "https://example.com/registry" Nothing Nothing []
      writeFile (cache </> "good" </> "marker") "original"
      writeFile (cache </> "blocked") "occupied file"
      result <- installClosureInto cache False repo "https://example.com/registry" [("good", repo </> "good"), ("blocked", repo </> "blocked")]
      result `shouldSatisfy` isLeft
      readFile (cache </> "good" </> "marker") `shouldReturn` "original"
      readFile (cache </> "blocked") `shouldReturn` "occupied file"

  it "restores every cache entry if publishing a staged recipe refresh fails" $
    withSystemTempDirectory "closure-upgrade-rollback" $ \tmp -> do
      let repo = tmp </> "repo"
          staged = tmp </> "staged"
          cache = tmp </> "cache"
          source = "https://example.com/registry.git"
      moduleDir repo "first"
      moduleDir repo "second"
      installClosureInto cache False repo source [("first", repo </> "first"), ("second", repo </> "second")] `shouldReturn` Right ()
      writeFile (cache </> "first/marker") "original"
      result <- stageClosuresInto False staged False [(repo, source, [("first", repo </> "first"), ("second", repo </> "second")])]
      case result of
        Left err -> expectationFailure (T.unpack err)
        Right artifacts -> do
          removeDirectoryRecursive (staged </> "second")
          published <- publishStagedClosure cache artifacts
          published `shouldSatisfy` isLeft
          readFile (cache </> "first/marker") `shouldReturn` "original"
          doesFileExist (cache </> "second/module.dhall") `shouldReturn` True

moduleDir :: FilePath -> String -> IO ()
moduleDir root name = do
  schema <- canonicalizePath "../schema/Module.dhall"
  let dir = root </> name
  createDirectoryIfMissing True dir
  writeFile (dir </> "module.dhall") ("let Module = " <> schema <> " in Module::{ name = \"" <> name <> "\", version = Some \"1.0.0\" }")

moduleWithJudgements :: FilePath -> String -> [String] -> IO ()
moduleWithJudgements root name refs = do
  schema <- canonicalizePath "../schema/Module.dhall"
  let dir = root </> name
  createDirectoryIfMissing True dir
  writeFile (dir </> "module.dhall") ("let Module = " <> schema <> " in Module::{ name = \"" <> name <> "\", version = Some \"1.0.0\", judgements = [" <> concat (zipWith (\i r -> (if i == (0 :: Int) then "" else ",") <> r) [0 ..] refs) <> "] }")

judgementDir :: FilePath -> String -> IO ()
judgementDir root name = do
  schema <- canonicalizePath "../schema/package.dhall"
  let dir = root </> name
  createDirectoryIfMissing True dir
  writeFile (dir </> "judgement.dhall") ("let S = " <> schema <> " in S.Judgement::{ name = \"" <> name <> "\", checks = [ S.Check::{ name = \"concern\", question = \"Is the concern present?\" } ] }")

-- | A registry with modules and judgements.
registryWith :: FilePath -> [String] -> [String] -> IO ()
registryWith root modules judgements =
  writeFile (root </> "seihou-registry.dhall") $
    "{repoName = \"fixture\", repoDescription = None Text, modules = " <> entries modules <> ", judgements = " <> entries judgements <> "}"
  where
    entries [] = "[] : List {name : Text, path : Text, version : Optional Text, description : Optional Text, tags : List Text}"
    entries names = "[" <> concat (zipWith (\i name -> (if i == (0 :: Int) then "" else ",") <> "{name = " <> show name <> ", path = " <> show name <> ", version = None Text, description = None Text, tags = [] : List Text}") [0 ..] names) <> "]"

recipeDir :: FilePath -> String -> String -> String -> IO ()
recipeDir root name members extension = do
  let dir = root </> name
  createDirectoryIfMissing True dir
  writeFile (dir </> "recipe.dhall") $
    "{ name = "
      <> show name
      <> ", version = Some \"1.0.0\", description = None Text, modules = "
      <> members
      <> ", vars = [] : List {name : Text, type : Text, default : Optional Text, description : Optional Text, required : Bool, validation : Optional Text}"
      <> ", prompts = [] : List {var : Text, text : Text, when : Optional Text, choices : Optional (List Text)}"
      <> extension
      <> "}"

registry :: FilePath -> [String] -> [String] -> IO ()
registry root modules recipes =
  writeFile (root </> "seihou-registry.dhall") $
    "{repoName = \"fixture\", repoDescription = None Text, modules = " <> entries modules <> ", recipes = " <> entries recipes <> "}"
  where
    entries names = "[" <> concat (zipWith (\i name -> (if i == (0 :: Int) then "" else ",") <> "{name = " <> show name <> ", path = " <> show name <> ", version = None Text, description = None Text, tags = [] : List Text}") [0 ..] names) <> "]"