settei-0.2.0.0: test/Settei/ResolveTest.hs
{-# LANGUAGE ImportQualifiedPost #-}
module Settei.ResolveTest (tests) where
import Control.Selective (select)
import Data.Generics.Labels ()
import Data.List (permutations)
import Data.List.NonEmpty qualified as NonEmpty
import Data.Map.Strict qualified as Map
import Data.Text qualified as Text
import Settei
import Settei.Internal.Schema (mergeSensitivity)
import Settei.Prelude
import Test.Tasty (TestTree, testGroup)
import Test.Tasty.HUnit (testCase, (@?=))
tests :: TestTree
tests =
testGroup
"Settei.Resolve"
[ testCase "rightmost source wins per leaf" $ do
let result = resolve defaultResolveOptions layeredSources serviceConfig
value <- expectAnswer result
value @?= ("file.example", 443)
let nodes = result ^. #report . #nodes
case nodes ^. at serviceHost of
Just node -> do
fmap (^. #name) (node ^. #origin) @?= Just "file"
fmap (^. #name) (node ^. #shadowed) @?= ["built-in"]
Nothing -> fail "expected host report node"
case nodes ^. at servicePort of
Just node -> do
fmap (^. #name) (node ^. #origin) @?= Just "environment"
fmap (^. #name) (node ^. #shadowed) @?= ["built-in"]
Nothing -> fail "expected port report node",
testCase "reversing source order predictably reverses precedence" $ do
let result = resolve defaultResolveOptions (reverse layeredSources) serviceConfig
value <- expectAnswer result
value @?= ("built-in.example", 8080),
testCase "shadowed origins are ordered from highest to lowest precedence" $ do
let result = resolve defaultResolveOptions (fmap snd lawSources) (required portSetting)
_ <- expectAnswer result
case result ^. #report . #nodes . at servicePort of
Just node -> fmap (^. #name) (node ^. #shadowed) @?= ["two", "one"]
Nothing -> fail "expected the port resolution node",
testCase "rightmost-wins law holds for every three-source ordering" $
mapM_ checkOrdering (permutations lawSources),
testCase "malformed winner does not fall back" $ do
let lower = treeSource "lower" (RawNumber 8080) Map.empty
higher = treeSource "higher" (RawText "not-a-port") Map.empty
case (resolve defaultResolveOptions [lower, higher] (required portSetting)) ^. #answer of
Left errors -> case NonEmpty.toList errors of
[DecodeError problem] -> do
problem ^. #key @?= servicePort
problem ^. #origin . #name @?= "higher"
_ -> fail "expected one decode error"
Right _ -> fail "expected the malformed winner to fail",
testCase "arrays replace rather than concatenate" $ do
let lower = arraySource "lower" ["one", "two"]
higher = arraySource "higher" ["three"]
let result = resolve defaultResolveOptions [lower, higher] (required hostsSetting)
value <- expectAnswer result
value @?= ["three"],
testCase "unknown keys warn by default and can be promoted" $ do
let values =
RawObject
( Map.fromList
[ ("known", RawText "yes"),
("typo", RawText "unused")
]
)
input = source "document" (FileSource "memory") values
declaration = required knownSetting
let result = resolve defaultResolveOptions [input] declaration
_ <- expectAnswer result
length (result ^. #warnings) @?= 1
case result ^. #warnings of
[UnknownKeyWarning problem] -> problem ^. #key @?= typoKey
_ -> fail "expected one unknown-key warning"
let strictOptions = ResolveOptions {unknownKeyPolicy = RejectUnknownKeys}
case (resolve strictOptions [input] declaration) ^. #answer of
Left errors -> case NonEmpty.toList errors of
[UnknownKeyError problem] -> problem ^. #key @?= typoKey
_ -> fail "expected one strict unknown-key error"
Right _ -> fail "strict mode should reject the unknown key",
testCase "independent applicative errors accumulate in declaration order" $ do
case (resolve defaultResolveOptions [] serviceConfig) ^. #answer of
Left errors ->
fmap errorKey (NonEmpty.toList errors)
@?= [serviceHost, servicePort]
Right _ -> fail "expected both required settings to be missing",
testCase "unselected selective branch is traced without errors" $ do
let development = environmentSource "development"
let result = resolve defaultResolveOptions [development] productionPassword
value <- expectAnswer result
value @?= Nothing
case result ^. #report . #nodes . at databasePassword of
Just node -> node ^. #outcome @?= NotSelected
Nothing -> fail "expected a not-selected password node"
case result ^. #report . #branches of
[branch] -> branch ^. #selected @?= False
_ -> fail "expected one selective branch trace",
testCase "selected selective branch reports its missing requirement" $ do
case (resolve defaultResolveOptions [environmentSource "production"] productionPassword) ^. #answer of
Left errors -> fmap errorKey (NonEmpty.toList errors) @?= [databasePassword]
Right _ -> fail "production should require a password",
testCase "whenEq reports an unselected branch in development" $ do
let result =
resolve defaultResolveOptions [environmentSource "development"] sugaredProductionPassword
value <- expectAnswer result
value @?= Nothing
case result ^. #report . #nodes . at databasePassword of
Just node -> node ^. #outcome @?= NotSelected
Nothing -> fail "expected a not-selected password node"
case result ^. #report . #branches of
[branch] -> do
branch ^. #dependencies @?= [runtimeEnvironment]
branch ^. #settings @?= [databasePassword]
branch ^. #selected @?= False
_ -> fail "expected one selective branch trace",
testCase "whenEq reports a selected missing branch in production" $ do
let result =
resolve defaultResolveOptions [environmentSource "production"] sugaredProductionPassword
case result ^. #answer of
Left errors -> fmap errorKey (NonEmpty.toList errors) @?= [databasePassword]
Right _ -> fail "production should require a password"
case result ^. #report . #nodes . at databasePassword of
Just node -> node ^. #outcome @?= MissingValue
Nothing -> fail "expected a missing password node"
case result ^. #report . #branches of
[branch] -> do
branch ^. #dependencies @?= [runtimeEnvironment]
branch ^. #settings @?= [databasePassword]
branch ^. #selected @?= True
_ -> fail "expected one selective branch trace",
testCase "fallbackTo suppresses the legacy key when the primary is present" $ do
let result =
resolve
defaultResolveOptions
[serviceTextSource "primary" "endpoint" "new.example"]
endpointWithFallback
value <- expectAnswer result
value @?= "new.example"
case result ^. #report . #nodes . at serviceUrl of
Just node -> node ^. #outcome @?= NotSelected
Nothing -> fail "expected a not-selected legacy URL node"
case result ^. #report . #branches of
[branch] -> do
branch ^. #dependencies @?= [serviceEndpoint]
branch ^. #settings @?= [serviceUrl]
branch ^. #selected @?= False
_ -> fail "expected one fallback branch trace",
testCase "fallbackTo evaluates the legacy key when the primary is absent" $ do
let result =
resolve
defaultResolveOptions
[serviceTextSource "legacy" "url" "old.example"]
endpointWithFallback
value <- expectAnswer result
value @?= "old.example"
case result ^. #report . #branches of
[branch] -> do
branch ^. #dependencies @?= [serviceEndpoint]
branch ^. #settings @?= [serviceUrl]
branch ^. #selected @?= True
_ -> fail "expected one fallback branch trace",
testCase "publicShowSetting renders a typed default" $
expectDefaultDisplay
"8080"
(publicShowSetting servicePort "Service port" boundedIntegralDecoder),
testCase "a rendererless public setting keeps the derived marker" $
expectDefaultDisplay
"<derived>"
(publicSetting servicePort "Service port" boundedIntegralDecoder),
testCase "withRenderer displays a public typed default" $
expectDefaultDisplay
"8080"
( withRenderer
(Text.pack . show)
(publicSetting servicePort "Service port" boundedIntegralDecoder)
),
testCase "withRenderer cannot expose a secret typed default" $
expectDefaultDisplay
"<redacted>"
( withRenderer
(Text.pack . show)
(secretSetting servicePort "Service port" boundedIntegralDecoder)
),
testCase "failure report retains evaluated provenance" $ do
let input = treeSource "only-port" (RawNumber 8080) Map.empty
result = resolve defaultResolveOptions [input] serviceConfig
case result ^. #answer of
Left errors -> fmap errorKey (NonEmpty.toList errors) @?= [serviceHost]
Right _ -> fail "expected the missing host to fail"
case result ^. #report . #nodes . at servicePort of
Just node -> do
node ^. #outcome @?= Resolved (reportedValue Public (RawNumber 8080))
fmap (^. #name) (node ^. #origin) @?= Just "only-port"
Nothing -> fail "expected the evaluated port node"
case result ^. #report . #nodes . at serviceHost of
Just node -> node ^. #outcome @?= MissingValue
Nothing -> fail "expected the missing host node",
testCase "decode failure keeps the rejected candidate's provenance" $ do
let lower = treeSource "lower" (RawNumber 8080) Map.empty
higher = treeSource "higher" (RawText "not-a-port") Map.empty
result = resolve defaultResolveOptions [lower, higher] (required portSetting)
case result ^. #answer of
Left errors -> case NonEmpty.toList errors of
[DecodeError problem] -> problem ^. #origin . #name @?= "higher"
_ -> fail "expected one decode error"
Right _ -> fail "expected the malformed winner to fail"
case result ^. #report . #nodes . at servicePort of
Just node -> do
node ^. #outcome @?= Resolved (reportedValue Public (RawText "not-a-port"))
fmap (^. #name) (node ^. #origin) @?= Just "higher"
fmap (^. #name) (node ^. #shadowed) @?= ["lower"]
Nothing -> fail "expected the rejected winner's report node",
testCase "failure report completes not-selected settings" $ do
let declaration = (,) <$> productionPassword <*> required knownSetting
result = resolve defaultResolveOptions [environmentSource "development"] declaration
case result ^. #answer of
Left errors -> fmap errorKey (NonEmpty.toList errors) @?= [knownKey]
Right _ -> fail "expected the unrelated required setting to fail"
case result ^. #report . #nodes . at databasePassword of
Just node -> node ^. #outcome @?= NotSelected
Nothing -> fail "expected the unselected password node"
case result ^. #report . #branches of
[branch] -> branch ^. #selected @?= False
_ -> fail "expected one unselected branch trace",
testCase "structural conflicts are rejected even under an unselected branch" $ do
let development = environmentSource "development"
conflicting =
source
"conflicting"
(FileSource "memory")
(RawObject (Map.singleton "database" (RawText "not-an-object")))
case (resolve defaultResolveOptions [development, conflicting] productionPassword) ^. #answer of
Left errors -> case NonEmpty.toList errors of
[StructuralConflict structuralError] ->
structuralError ^. #key @?= databasePassword
_ -> fail "expected one structural conflict"
Right _ -> fail "source shape validation should fail",
testCase "structural exit still reports schema and warnings" $ do
let malformed =
source
"malformed"
(FileSource "memory")
( RawObject
( Map.fromList
[ ("database", RawText "not-an-object"),
("typo", RawText "unused")
]
)
)
result =
resolve
defaultResolveOptions
[environmentSource "development", malformed]
productionPassword
case result ^. #answer of
Left errors -> case NonEmpty.toList errors of
[StructuralConflict structuralError] ->
structuralError ^. #key @?= databasePassword
_ -> fail "expected one structural conflict"
Right _ -> fail "source shape validation should fail"
fmap (^. #key) (reportNodes (result ^. #report))
@?= [databasePassword, runtimeEnvironment]
fmap (^. #outcome) (reportNodes (result ^. #report))
@?= [NotSelected, NotSelected]
fmap (^. #origin) (reportNodes (result ^. #report)) @?= [Nothing, Nothing]
result ^. #report . #branches @?= []
any
( \case
UnknownKeyWarning problem -> problem ^. #key == typoKey
)
(result ^. #warnings)
@?= True,
testCase "warnings accompany failures and strict mode promotes them" $ do
let input =
source
"document"
(FileSource "memory")
(RawObject (Map.singleton "typo" (RawText "unused")))
warned = resolve defaultResolveOptions [input] (required knownSetting)
case warned ^. #answer of
Left errors -> fmap errorKey (NonEmpty.toList errors) @?= [knownKey]
Right _ -> fail "expected the missing known setting to fail"
case warned ^. #warnings of
[UnknownKeyWarning problem] -> problem ^. #key @?= typoKey
_ -> fail "expected one warning alongside the failure"
let strictOptions = ResolveOptions {unknownKeyPolicy = RejectUnknownKeys}
rejected = resolve strictOptions [input] (required knownSetting)
case rejected ^. #answer of
Left errors -> fmap errorKey (NonEmpty.toList errors) @?= [knownKey, typoKey]
Right _ -> fail "strict mode should reject the unknown key"
rejected ^. #warnings @?= [],
testCase "conflicting sensitivity declarations fail with a structured error" $ do
assertSensitivityConflict secretThenPublic
assertSensitivityConflict publicThenSecret,
testCase "sensitivity conflicts include unselected selective branches" $
assertSensitivityConflict selectiveSensitivityConflict,
testCase "sensitivity conflicts include default dependencies" $
assertSensitivityConflict defaultSensitivityConflict,
testCase "effective sensitivity is secret-dominant at the schema seam" $ do
mergeSensitivity Public Public @?= Public
mergeSensitivity Public Secret @?= Secret
mergeSensitivity Secret Public @?= Secret
mergeSensitivity Secret Secret @?= Secret
schemaSettingSensitivity (schemaEntry databasePassword (describe secretThenPublic))
@?= Secret
schemaSettingSensitivity (schemaEntry databasePassword (describe publicThenSecret))
@?= Secret,
testCase "redactReportedValue collapses every retained representation" $
mapM_
(\value -> renderReportedValue (redactReportedValue value) @?= "<redacted>")
[ visibleReportedValue "x",
derivedReportedValue Public,
reportedValue Secret (RawText "x")
]
]
assertSensitivityConflict :: Config a -> IO ()
assertSensitivityConflict declaration = do
let result = resolve defaultResolveOptions [passwordSource] declaration
case result ^. #answer of
Left errors -> case NonEmpty.toList errors of
[SensitivityConflict problem] -> problem ^. #key @?= databasePassword
_ -> fail "expected one sensitivity conflict"
Right _ -> fail "mixed-sensitivity declaration should fail"
case result ^. #report . #nodes . at databasePassword of
Just node -> do
node ^. #sensitivity @?= Secret
node ^. #outcome @?= NotSelected
node ^. #origin @?= Nothing
Nothing -> fail "expected a schema-only conflict report node"
schemaEntry :: Key -> Schema -> SchemaSetting
schemaEntry key schema =
case filter ((== key) . schemaSettingKey) (schemaPossible schema) of
[entry] -> entry
entries -> error ("expected one schema entry, found " <> show (length entries))
checkOrdering :: [(Int, Source)] -> IO ()
checkOrdering orderedSources = do
let result = resolve defaultResolveOptions (fmap snd orderedSources) (required portSetting)
value <- expectAnswer result
value @?= fst (last orderedSources)
lawSources :: [(Int, Source)]
lawSources =
[ (1001, treeSource "one" (RawNumber 1001) Map.empty),
(2002, treeSource "two" (RawNumber 2002) Map.empty),
(3003, treeSource "three" (RawNumber 3003) Map.empty)
]
expectAnswer :: ResolveResult a -> IO a
expectAnswer result = case result ^. #answer of
Left errors -> fail ("expected successful resolution: " <> show errors)
Right value -> pure value
expectDefaultDisplay :: Text -> Setting Int -> IO ()
expectDefaultDisplay expected settingSpec = do
let declaration =
withDefault
settingSpec
(constantDefault (RuleName "built-in-port") "Built-in service port" 8080)
result = resolve defaultResolveOptions [] declaration
value <- expectAnswer result
value @?= 8080
case result ^. #report . #nodes . at servicePort of
Just node -> case node ^. #outcome of
Resolved reported -> renderReportedValue reported @?= expected
_ -> fail "expected a resolved default value"
Nothing -> fail "expected a derived service port node"
errorKey :: ConfigError -> Key
errorKey = \case
MissingRequired problem -> problem ^. #key
DecodeError problem -> problem ^. #key
StructuralConflict problem -> problem ^. #key
UnknownKeyError problem -> problem ^. #key
DefaultError problem -> problem ^. #key
DefaultCycle _ -> error "a cycle has no single setting key"
SensitivityConflict problem -> problem ^. #key
serviceConfig :: Config (Text, Int)
serviceConfig = (,) <$> required hostSetting <*> required portSetting
productionPassword :: Config (Maybe Text)
productionPassword = select selector branch
where
selector =
(\environment -> if environment == "production" then Left () else Right Nothing)
<$> required environmentSetting
branch = (\password _ -> Just password) <$> required passwordSetting
sugaredProductionPassword :: Config (Maybe Text)
sugaredProductionPassword =
whenEq (required environmentSetting) "production" (required passwordSetting)
endpointWithFallback :: Config Text
endpointWithFallback =
optional endpointSetting `fallbackTo` required urlSetting
secretThenPublic :: Config (Text, Text)
secretThenPublic =
(,) <$> required passwordSetting <*> required publicPasswordSetting
publicThenSecret :: Config (Text, Text)
publicThenSecret =
(,) <$> required publicPasswordSetting <*> required passwordSetting
selectiveSensitivityConflict :: Config Text
selectiveSensitivityConflict =
select
((\password -> Right password :: Either () Text) <$> required passwordSetting)
((\publicValue () -> publicValue) <$> required publicPasswordSetting)
defaultSensitivityConflict :: Config Text
defaultSensitivityConflict =
withDefault
passwordSetting
( derivedDefault
(RuleName "password-from-public-duplicate")
"Exercise a sensitivity conflict in a default dependency"
(required publicPasswordSetting)
id
)
hostSetting :: Setting Text
hostSetting = publicSetting serviceHost "Service host" textDecoder
portSetting :: Setting Int
portSetting = publicSetting servicePort "Service port" boundedIntegralDecoder
hostsSetting :: Setting [Text]
hostsSetting = publicSetting serviceHosts "Service hosts" textArrayDecoder
knownSetting :: Setting Text
knownSetting = publicSetting knownKey "Known value" textDecoder
environmentSetting :: Setting Text
environmentSetting = publicSetting runtimeEnvironment "Runtime environment" textDecoder
passwordSetting :: Setting Text
passwordSetting = secretSetting databasePassword "Database password" textDecoder
endpointSetting :: Setting Text
endpointSetting = publicSetting serviceEndpoint "Service endpoint" textDecoder
urlSetting :: Setting Text
urlSetting = publicSetting serviceUrl "Legacy service URL" textDecoder
publicPasswordSetting :: Setting Text
publicPasswordSetting = publicSetting databasePassword "Metrics password label" textDecoder
passwordSource :: Source
passwordSource =
source
"password"
(CustomSource "test")
( RawObject
( Map.singleton
"database"
(RawObject (Map.singleton "password" (RawText "sensitive-value")))
)
)
textArrayDecoder :: Decoder [Text]
textArrayDecoder = decoder $ \key -> \case
RawArray values -> traverse (decodeOne key) values
_ -> Left (decodeFailure key "array of text")
where
decodeOne _ (RawText value) = Right value
decodeOne key _ = Left (decodeFailure key "array of text")
layeredSources :: [Source]
layeredSources =
[ treeSource "built-in" (RawNumber 8080) (Map.singleton "host" (RawText "built-in.example")),
source
"file"
(FileSource "memory")
(RawObject (Map.singleton "service" (RawObject (Map.singleton "host" (RawText "file.example"))))),
treeSource "environment" (RawNumber 443) Map.empty
]
treeSource :: Text -> RawValue -> Map Text RawValue -> Source
treeSource name portValue extraServiceValues =
source
name
(CustomSource "test")
( RawObject
( Map.singleton
"service"
(RawObject (extraServiceValues & at "port" ?~ portValue))
)
)
arraySource :: Text -> [Text] -> Source
arraySource name values =
source
name
(CustomSource "test")
( RawObject
( Map.singleton
"service"
(RawObject (Map.singleton "hosts" (RawArray (fmap RawText values))))
)
)
environmentSource :: Text -> Source
environmentSource value =
source
"environment"
EnvironmentSource
( RawObject
( Map.singleton
"runtime"
(RawObject (Map.singleton "environment" (RawText value)))
)
)
serviceTextSource :: Text -> Text -> Text -> Source
serviceTextSource name field value =
source
name
(CustomSource "test")
( RawObject
( Map.singleton
"service"
(RawObject (Map.singleton field (RawText value)))
)
)
serviceHost :: Key
serviceHost = validKey "service.host"
servicePort :: Key
servicePort = validKey "service.port"
serviceHosts :: Key
serviceHosts = validKey "service.hosts"
serviceEndpoint :: Key
serviceEndpoint = validKey "service.endpoint"
serviceUrl :: Key
serviceUrl = validKey "service.url"
runtimeEnvironment :: Key
runtimeEnvironment = validKey "runtime.environment"
databasePassword :: Key
databasePassword = validKey "database.password"
knownKey :: Key
knownKey = validKey "known"
typoKey :: Key
typoKey = validKey "typo"
validKey :: Text -> Key
validKey value = either (error . show) id (parseKey value)