packages feed

settei-0.3.0.0: test/Settei/ValidateTest.hs

module Settei.ValidateTest (tests) where

import Data.Generics.Labels ()
import Data.List.NonEmpty qualified as NonEmpty
import Data.Text qualified as Text
import Data.Text.IO qualified as TextIO
import Settei
import Settei.Prelude
import Test.Tasty (TestTree, testGroup)
import Test.Tasty.HUnit (assertBool, testCase, (@?=))

tests :: TestTree
tests =
  testGroup
    "Settei.Validate"
    [ testCase "empty collection preserves the typed answer" $ do
        let result = validate (collection []) (resolved [])
        assertBool "answer changed" (result ^. #answer == resolved [] ^. #answer)
        validationOutcomes (result ^. #validation) @?= [],
      testCase "valid configuration passes all rules without changing values" $ do
        let base = resolved []; result = validate workerValidators base
        assertBool "answer changed" (result ^. #answer == base ^. #answer)
        statuses result @?= replicate 5 InvariantPassed
        result ^. #report @?= base ^. #report
        result ^. #warnings @?= base ^. #warnings,
      testCase "independent failures accumulate in declaration and violation order" $ do
        problems <- failures failingResult
        fmap (^. #rule) problems @?= fmap InvariantRule ["pool-capacity", "visibility-timeout", "provider-account", "provider-account"]
        fmap (^. #keys) problems @?= [validKeys ["workers.jobPoolSize", "workers.concurrency"], validKeys ["workers.visibilityTimeoutSeconds", "workers.concurrency"], validKeys ["email.accounts", "email.credentials"], validKeys ["email.accounts", "email.credentials"]]
        fmap (^. #detail) problems @?= [Nothing, Nothing, Just "account alpha", Just "account beta"]
        statuses failingResult @?= [InvariantFailed 1, InvariantFailed 1, InvariantPassed, InvariantPassed, InvariantFailed 2],
      testCase "failed prerequisite skips an unforced validator function" $ do
        let rules = take 3 hubRules <> [requires [InvariantRule "replica"] (invariant (InvariantRule "replica-pool-share") (validKeys ["workers.jobPoolSize", "workers.replica.count"]) "pool covers replicas" (error "skipped function forced"))]
            result = validate (collection rules) (resolved [("workers.replica.index", RawText "2")])
        statuses result @?= [InvariantPassed, InvariantPassed, InvariantFailed 1, InvariantSkipped (InvariantRule "replica" :| [])],
      testCase "requires composes and blocked prerequisites preserve order transitively" $ do
        let rule name = check (InvariantRule name) (validKeys ["workers.concurrency"]) "safe" (const False)
            rules = [rule "a", requires [InvariantRule "a"] (rule "b"), requires [InvariantRule "a", InvariantRule "b"] (requires [InvariantRule "b", InvariantRule "a"] (invariant (InvariantRule "c") (validKeys ["workers.concurrency"]) "safe" (error "blocked")))]
            report = validate (collection rules) (resolved []) ^. #validation
        fmap (^. #status) (validationOutcomes report) @?= [InvariantFailed 1, InvariantSkipped (InvariantRule "a" :| []), InvariantSkipped (InvariantRule "b" :| [InvariantRule "a"])]
        fmap (^. #prerequisites) (validationOutcomes report) @?= [[], [InvariantRule "a"], [InvariantRule "b", InvariantRule "a"]]
        assertBool "skipped JSON absent" ("\"blockedBy\":[\"b\",\"a\"]" `Text.isInfixOf` renderValidatedResolutionJson (ResolutionReport mempty []) report)
        assertBool "skipped text absent" ("[skipped after b, a]" `Text.isInfixOf` renderValidationText report),
      testCase "failed resolution retains errors report warnings and never runs functions" $ do
        let base = resolve defaultResolveOptions [fixture (filter ((/= "workers.concurrency") . fst) validPairs)] hubConfig
            declarations = collection [requires (entry ^. #prerequisites) (invariant (entry ^. #rule) (entry ^. #keys) (entry ^. #expectation) (error "failed-resolution function forced")) | entry <- validationOutcomes (describeValidators workerValidators)]
            result = validate declarations base
        assertBool "errors changed" (result ^. #answer == base ^. #answer)
        case result ^. #answer of
          Left (MissingRequired _ :| []) -> pure ()
          _ -> fail "expected original missing error"
        result ^. #report @?= base ^. #report
        result ^. #warnings @?= base ^. #warnings
        statuses result @?= replicate 5 InvariantNotRun,
      testCase "role collections select shared rules" $ do
        let base = resolved [("workers.jobPoolSize", RawText "2")]
        assertBool "server rejected" (case validate serverValidators base ^. #answer of Right _ -> True; _ -> False)
        statuses (validate workerValidators base) @?= [InvariantFailed 1, InvariantPassed, InvariantPassed, InvariantPassed, InvariantPassed],
      testCase "describe and direct execution are source free" $ do
        let report = describeValidators workerValidators
        fmap (^. #status) (validationOutcomes report) @?= replicate 5 InvariantNotRun
        fmap (^. #prerequisites) (validationOutcomes report) @?= [[], [], [], [InvariantRule "replica"], []]
        case resolved [] ^. #answer of
          Left _ -> fail "fixture failed"
          Right value -> do
            let (directReport, problems) = runValidators workerValidators value
            directReport @?= validate workerValidators (resolved []) ^. #validation
            problems @?= [],
      testCase "construction accumulates every offender in defined order" $ do
        let rule name ks = invariant (InvariantRule name) (validKeys ks) "safe" (error "construction forced function")
            declarations = [rule "" [], rule "white space" ["nothing.here"], rule "line\nbreak" ["workers.concurrency"], requires [InvariantRule "later"] (rule "early" ["workers.concurrency"]), requires [InvariantRule "self"] (rule "self" ["workers.concurrency"]), rule "later" ["workers.concurrency"], rule "later" ["workers.concurrency"], rule "later" ["workers.concurrency"]]
            expected = [InvalidInvariantRule (InvariantRule ""), EmptyInvariantKeys (InvariantRule ""), InvalidInvariantRule (InvariantRule "white space"), UndeclaredInvariantKey (InvariantRule "white space") (validKey "nothing.here"), InvalidInvariantRule (InvariantRule "line\nbreak"), UnknownPrerequisite (InvariantRule "early") (InvariantRule "later"), UnknownPrerequisite (InvariantRule "self") (InvariantRule "self"), DuplicateInvariantRule (InvariantRule "later")]
        case validators hubConfig declarations of
          Right _ -> fail "invalid declarations accepted"
          Left errors -> do
            NonEmpty.toList errors @?= expected
            length (Text.lines (renderValidatorErrorsText errors)) @?= length expected
            mapM_ (\problem -> assertBool "empty error" (not (Text.null (renderValidatorErrorText problem)))) expected
            assertBool "newline not escaped" ("line\\nbreak" `Text.isInfixOf` renderValidatorErrorsText errors),
      testCase "cycles precede metadata errors and suppress key-membership checks" $
        case validators cyclicA [check (InvariantRule "") [validKey "nothing.here"] "safe" (const True)] of
          Left errors -> do
            NonEmpty.toList errors @?= [CyclicDeclaration (DefaultCycleProblem (RuleName "cycle-a" :| [RuleName "cycle-b", RuleName "cycle-a"])), InvalidInvariantRule (InvariantRule "")]
            assertBool "cycle path missing" ("cycle-a -> cycle-b -> cycle-a" `Text.isInfixOf` renderValidatorErrorsText errors)
          Right _ -> fail "cycle accepted",
      testCase "optional keys are allowed without assuming presence" $ do
        let config = optional (publicSetting (validKey "maybe.value") "optional" textDecoder)
        case validators config [check (InvariantRule "optional-value") [validKey "maybe.value"] "may be absent" (const True)] of
          Left errors -> fail (Text.unpack (renderValidatorErrorsText errors))
          Right rules -> case validate rules (resolve defaultResolveOptions [] config) ^. #answer of
            Right Nothing -> pure ()
            _ -> fail "optional changed",
      testCase "conditional keys can be referenced on an unselected path" $ do
        let config = whenEq (pure False) True (required (publicSetting (validKey "branch.value") "conditional" textDecoder))
        case validators config [check (InvariantRule "branch-value") [validKey "branch.value"] "may be unselected" (== Nothing)] of
          Left errors -> fail (Text.unpack (renderValidatorErrorsText errors))
          Right rules -> statuses (validate rules (resolve defaultResolveOptions [] config)) @?= [InvariantPassed],
      testCase "direct failing execution agrees with validated resolution" $ do
        case resolved overrides ^. #answer of
          Left _ -> fail "fixture did not resolve"
          Right value -> do
            let (report, problems) = runValidators workerValidators value
            expected <- failures failingResult
            problems @?= expected
            report @?= failingResult ^. #validation,
      testCase "detail is retained verbatim and escaped by JSON" $ do
        let detail = "public \"account\" \\ label"
            result = validate (collection [invariant (InvariantRule "label") (validKeys ["email.accounts"]) "has credential" (const [violationWithDetail detail])]) (resolved [])
        problems <- failures result
        fmap (^. #detail) problems @?= [Just detail]
        errors <- errorList result
        assertBool "detail absent in text" (detail `Text.isInfixOf` renderErrorsText errors)
        assertBool "detail absent in JSON" ("public \\\"account\\\" \\\\ label" `Text.isInfixOf` renderErrorsJson errors),
      testCase "all retained structures and renderers omit raw and escaped secrets" $ do
        problems <- failures failingResult
        errors <- errorList failingResult
        let report = failingResult ^. #report; validation = failingResult ^. #validation
        mapM_ assertSafe [Text.pack (show problems), Text.pack (show errors), Text.pack (show report), Text.pack (show validation), Text.pack (show (validationOutcomes validation)), renderErrorsText errors, renderErrorsJson errors, renderValidationText validation, renderResolutionText report, renderResolutionJson report, renderValidatedResolutionJson report validation]
        assertBool "scan missed escaped leak" (hasSecret (Text.pack (show secretSentinel))),
      testCase "rendering is deterministic and preserves rule order independently of key order" $ do
        let output = renderValidatedResolutionJson (failingResult ^. #report) (failingResult ^. #validation)
            reordered = validate workerValidators (resolve defaultResolveOptions [fixture (reverse (overrides <> filter (\(name, _) -> name `notElem` fmap fst overrides) validPairs))] hubConfig)
        renderValidatedResolutionJson (reordered ^. #report) (reordered ^. #validation) @?= output
        renderValidatedResolutionJson (failingResult ^. #report) (failingResult ^. #validation) @?= output,
      testCase "validation text golden" $ golden "validation.txt" (renderValidationText (failingResult ^. #validation)),
      testCase "validated resolution JSON golden" $ golden "validation.json" (renderValidatedResolutionJson (failingResult ^. #report) (failingResult ^. #validation)),
      testCase "invariant error text golden" $ errorList failingResult >>= golden "invariant-errors.txt" . renderErrorsText,
      testCase "invariant error JSON golden" $ errorList failingResult >>= golden "invariant-errors.json" . renderErrorsJson
    ]

-- Intentionally no Show instance on either secret-bearing type.
newtype Credentials = Credentials [Text] deriving stock (Generic, Eq)

data HubConfig = HubConfig
  { environment :: !Text,
    pool :: !Int,
    jobPool :: !Int,
    concurrency :: !Int,
    visibility :: !Int,
    replicaIndex :: !Int,
    replicaCount :: !Int,
    accounts :: ![Text],
    databaseUrl :: !Text,
    credentials :: !Credentials
  }
  deriving stock (Generic, Eq)

hubConfig :: Config HubConfig
hubConfig =
  HubConfig
    <$> required (publicSetting (validKey "runtime.environment") "environment" (enumDecoder [("development", "development"), ("production", "production")]))
    <*> number "database.poolSize"
    <*> number "workers.jobPoolSize"
    <*> number "workers.concurrency"
    <*> number "workers.visibilityTimeoutSeconds"
    <*> number "workers.replica.index"
    <*> number "workers.replica.count"
    <*> required (publicSetting (validKey "email.accounts") "accounts" (listDecoder textDecoder))
    <*> required (secretSetting (validKey "database.url") "database" textDecoder)
    <*> required (secretSetting (validKey "email.credentials") "credentials" (Credentials <$> listDecoder textDecoder))
  where
    number name = required (publicSetting (validKey name) name boundedIntegralDecoder)

hubRules :: [Validator HubConfig]
hubRules =
  [ check (InvariantRule "pool-capacity") (validKeys ["workers.jobPoolSize", "workers.concurrency"]) "pool covers concurrency" (\c -> c ^. #jobPool >= c ^. #concurrency),
    check (InvariantRule "visibility-timeout") (validKeys ["workers.visibilityTimeoutSeconds", "workers.concurrency"]) "timeout covers twice concurrency" (\c -> toInteger (c ^. #visibility) >= 2 * toInteger (c ^. #concurrency)),
    check (InvariantRule "replica") (validKeys ["workers.replica.index", "workers.replica.count"]) "index is within replica count" (\c -> c ^. #replicaIndex >= 0 && c ^. #replicaIndex < c ^. #replicaCount),
    requires [InvariantRule "replica"] (check (InvariantRule "replica-pool-share") (validKeys ["workers.jobPoolSize", "workers.replica.count"]) "pool covers replicas" (\c -> c ^. #jobPool >= c ^. #replicaCount)),
    invariant (InvariantRule "provider-account") (validKeys ["email.accounts", "email.credentials"]) "every account has a credential" (\c -> let Credentials entries = c ^. #credentials; names = fmap (Text.takeWhile (/= ':')) entries in [violationWithDetail ("account " <> name) | name <- c ^. #accounts, name `notElem` names])
  ]

workerValidators, serverValidators :: Validators HubConfig
workerValidators = collection hubRules
serverValidators = collection [last hubRules]

collection :: [Validator HubConfig] -> Validators HubConfig
collection rules = either (error . Text.unpack . renderValidatorErrorsText) id (validators hubConfig rules)

validPairs :: [(Text, RawValue)]
validPairs = [("runtime.environment", RawText "production"), ("database.poolSize", RawText "10"), ("workers.jobPoolSize", RawText "8"), ("workers.concurrency", RawText "4"), ("workers.visibilityTimeoutSeconds", RawText "30"), ("workers.replica.index", RawText "0"), ("workers.replica.count", RawText "2"), ("email.accounts", RawArray [RawText "alpha", RawText "beta"]), ("database.url", RawText secretSentinel), ("email.credentials", RawArray [RawText ("alpha:" <> secretSentinel), RawText ("beta:" <> secretSentinel)])]

overrides :: [(Text, RawValue)]
overrides = [("workers.jobPoolSize", RawText "2"), ("workers.visibilityTimeoutSeconds", RawText "1"), ("email.credentials", RawArray [RawText ("other:" <> secretSentinel)])]

resolved :: [(Text, RawValue)] -> ResolveResult HubConfig
resolved changes = resolve defaultResolveOptions [fixture (changes <> filter (\(name, _) -> name `notElem` fmap fst changes) validPairs)] hubConfig

fixture :: [(Text, RawValue)] -> Source
fixture pairs = either (const (error "invalid fixture pairs")) id (sourceFromPairs "fixture" EnvironmentSource [(validKey name, value) | (name, value) <- pairs])

failingResult :: ValidatedResult HubConfig
failingResult = validate workerValidators (resolved overrides)

statuses :: ValidatedResult a -> [InvariantStatus]
statuses result = fmap (^. #status) (validationOutcomes (result ^. #validation))

errorList :: ValidatedResult a -> IO (NonEmpty ConfigError)
errorList result = case result ^. #answer of Left errors -> pure errors; Right _ -> fail "expected rejection"

failures :: ValidatedResult a -> IO [InvariantProblem]
failures result = do
  errors <- errorList result
  let problems = [problem | InvariantError problem <- NonEmpty.toList errors]
  length problems @?= length errors
  pure problems

validKey :: Text -> Key
validKey = either (error . show) id . parseKey

validKeys :: [Text] -> [Key]
validKeys = fmap validKey

secretSentinel :: Text
secretSentinel = "SETTEI_VALIDATION_SECRET_29\"\\\n秘密"

hasSecret :: Text -> Bool
hasSecret output = any (`Text.isInfixOf` output) [secretSentinel, "SETTEI_VALIDATION_SECRET_29"]

assertSafe :: Text -> IO ()
assertSafe output = assertBool "secret retained in diagnostic" (not (hasSecret output))

golden :: FilePath -> Text -> IO ()
golden name actual = do
  expected <- TextIO.readFile ("test/golden/" <> name)
  Text.stripEnd actual @?= Text.stripEnd expected

cyclicA, cyclicB :: Config Text
cyclicA = withDefault (publicSetting (validKey "cycle.a") "A" textDecoder) (derivedDefault (RuleName "cycle-a") "A from B" cyclicB id)
cyclicB = withDefault (publicSetting (validKey "cycle.b") "B" textDecoder) (derivedDefault (RuleName "cycle-b") "B from A" cyclicA id)