settei-yaml-0.1.0.0: test/Settei/YamlTest.hs
module Settei.YamlTest (tests) where
import Data.ByteString qualified as ByteString
import Data.ByteString.Char8 qualified as ByteString8
import Data.Generics.Labels ()
import Data.List.NonEmpty qualified as NonEmpty
import Data.Ratio ((%))
import Data.Text qualified as Text
import Paths_settei_yaml qualified as Paths
import Settei
import Settei.Prelude
import Settei.Yaml
import Test.Tasty (TestTree, testGroup)
import Test.Tasty.HUnit (assertBool, testCase, (@?=))
tests :: TestTree
tests =
testGroup
"Settei.Yaml"
[ testCase "nested mappings become segmented keys with exact locations" $ do
input <- expectSource "service:\n http:\n host: api.internal\n port: 8080\n"
host <- expectCandidate serviceHttpHost input
assertBool "host did not remain text" (candidateValue host == RawText "api.internal")
host ^. to candidateOrigin . #name @?= "application"
host ^? to candidateOrigin . #location . _Just . #path @?= Just "application.yaml"
host ^? to candidateOrigin . #location . _Just . #line @?= Just (Just 3)
host ^? to candidateOrigin . #location . _Just . #column @?= Just (Just 11)
port <- expectCandidate serviceHttpPort input
assertBool "port did not remain numeric" (candidateValue port == RawNumber 8080),
testCase "explicit null remains a present candidate" $ do
input <- expectSource "service:\n optional: null\n"
found <- expectCandidate serviceOptional input
assertBool "explicit null became absence" (candidateValue found == RawNull),
testCase "large integers, decimals, booleans, and arrays retain portable meaning" $ do
input <-
expectSource
"limits:\n huge: 123456789012345678901234567890\n ratio: 1.25\nfeature:\n enabled: true\n names: [one, two]\n"
huge <- expectCandidate hugeNumber input
assertBool
"large integer lost precision"
(candidateValue huge == RawNumber 123456789012345678901234567890)
ratio <- expectCandidate ratioNumber input
assertBool "decimal lost precision" (candidateValue ratio == RawNumber (5 % 4))
enabled <- expectCandidate featureEnabled input
assertBool "boolean was not typed" (candidateValue enabled == RawBool True)
names <- expectCandidate featureNames input
assertBool
"array shape changed"
(candidateValue names == RawArray [RawText "one", RawText "two"]),
testCase "higher YAML overrides leaves and replaces arrays wholesale" $ do
low <- expectNamedSource "low" "service:\n host: old.internal\n port: 7000\n names: [one, two]\n"
high <- expectNamedSource "high" "service:\n port: 9000\n names: [three]\n"
result <-
expectResolution
( resolve
defaultResolveOptions
[low, high]
((,,) <$> required hostSetting <*> required portSetting <*> required namesSetting)
)
result ^. #value @?= ("old.internal", 9000, ["three"])
case result ^. #report . #nodes . at servicePort of
Just node -> do
node ^. #origin . _Just . #name @?= "high"
fmap (^. #name) (node ^. #shadowed) @?= ["low"]
Nothing -> fail "expected resolved YAML port provenance",
testCase "dotted keys fail instead of becoming implicit nesting" $ do
problem <- expectError "service.port: 8080\n"
yamlErrorCategory problem @?= YamlDottedKey
yamlErrorContext problem @?= "$.service.port",
testCase "special floating values fail instead of becoming text" $ do
problem <- expectError "service:\n ratio: !!float .inf\n"
yamlErrorCategory problem @?= YamlInvalidScalar,
testCase "invalid UTF-8 fails without retaining source bytes" $ do
let invalid = ByteString.pack [115, 101, 114, 118, 105, 99, 101, 58, 32, 255]
case decodeYamlSource sourceOptions invalid of
Left errors -> do
let problem = NonEmpty.head errors
assertBool
"unexpected invalid UTF-8 category"
(yamlErrorCategory problem `elem` [YamlSyntaxError, YamlInvalidScalar])
Right _ -> fail "expected invalid UTF-8 to fail",
testCase "mounted Secret metadata remains visible while its setting is redacted" $ do
let reference = kubernetesRef SecretObject (Just "production") "database-config" (Just "config.yaml")
options = fromKubernetesMountedFile reference sourceOptions
input <- expectSourceWith options ("database:\n password: " <> Text.unpack secretSentinel <> "\n")
result <- expectResolution (resolve defaultResolveOptions [input] (required passwordSetting))
let textOutput = renderResolutionText (result ^. #report)
jsonOutput = renderResolutionJson (result ^. #report)
assertBool "secret reached text output" (not (secretSentinel `Text.isInfixOf` textOutput))
assertBool "secret reached JSON output" (not (secretSentinel `Text.isInfixOf` jsonOutput))
assertBool "Secret metadata was omitted" ("database-config" `Text.isInfixOf` textOutput),
testCase "readYamlSource reports IO failures with the requested path" $ do
result <- readYamlSource (yamlSourceOptions "missing") "test/fixtures/does-not-exist.yaml"
case result of
Left errors -> do
let problem = NonEmpty.head errors
yamlErrorCategory problem @?= YamlIoError
yamlErrorPath problem @?= Just "test/fixtures/does-not-exist.yaml"
Right _ -> fail "expected missing YAML file to fail",
testCase "readYamlSource loads a fixture and records its actual path" $ do
fixturePath <- Paths.getDataFileName "test/fixtures/characterization/nested.yaml"
result <- readYamlSource (yamlSourceOptions "fixture") fixturePath
input <- case result of
Left errors -> fail (show errors)
Right sourceValue -> pure sourceValue
host <- expectCandidate serviceHttpHost input
host ^? to candidateOrigin . #location . _Just . #path @?= Just (Text.pack fixturePath),
testCase "syntax errors never echo an earlier secret scalar" $ do
let input =
"database:\n password: "
<> Text.unpack secretSentinel
<> "\nbroken: [one, two\n"
case decodeYamlSource sourceOptions (ByteString8.pack input) of
Left errors ->
assertBool
"secret reached structured YAML errors"
(not (secretSentinel `Text.isInfixOf` Text.pack (show errors)))
Right _ -> fail "expected malformed YAML to fail",
testCase "top-level sequences fail because configuration keys require a mapping" $ do
problem <- expectError "[one, two]\n"
yamlErrorCategory problem @?= YamlTopLevelType
]
sourceOptions :: YamlSourceOptions
sourceOptions = withYamlSourcePath "application.yaml" (yamlSourceOptions "application")
expectSource :: String -> IO Source
expectSource = expectSourceWith sourceOptions
expectNamedSource :: Text -> String -> IO Source
expectNamedSource name =
expectSourceWith (withYamlSourcePath (Text.unpack name <> ".yaml") (yamlSourceOptions name))
expectSourceWith :: YamlSourceOptions -> String -> IO Source
expectSourceWith options input =
case decodeYamlSource options (ByteString8.pack input) of
Left errors -> fail (show errors)
Right value -> pure value
expectError :: String -> IO YamlSourceError
expectError input = case decodeYamlSource sourceOptions (ByteString8.pack input) of
Left errors -> pure (NonEmpty.head errors)
Right _ -> fail "expected YAML decoding to fail"
expectCandidate :: Key -> Source -> IO Candidate
expectCandidate key input = case lookupSource key input of
Right (Just found) -> pure found
_ -> fail "expected YAML candidate"
expectResolution :: Either (NonEmpty ConfigError) a -> IO a
expectResolution = \case
Left _ -> fail "expected YAML resolution to succeed"
Right value -> pure value
hostSetting :: Setting Text
hostSetting = publicSetting serviceHost "Service host" textDecoder
portSetting :: Setting Int
portSetting = publicSetting servicePort "Service port" boundedIntegralDecoder
namesSetting :: Setting [Text]
namesSetting = publicSetting serviceNames "Service names" textListDecoder
passwordSetting :: Setting Text
passwordSetting = secretSetting databasePassword "Database password" textDecoder
textListDecoder :: Decoder [Text]
textListDecoder = decoder $ \key -> \case
RawArray values -> traverse (textElement key) values
_ -> Left (decodeFailure key "an array of text")
textElement :: Key -> RawValue -> Either DecodeFailure Text
textElement key = \case
RawText value -> Right value
_ -> Left (decodeFailure key "an array of text")
serviceHttpHost, serviceHttpPort, serviceHost, servicePort, serviceNames, serviceOptional, hugeNumber, ratioNumber, featureEnabled, featureNames, databasePassword :: Key
serviceHttpHost = validKey "service.http.host"
serviceHttpPort = validKey "service.http.port"
serviceHost = validKey "service.host"
servicePort = validKey "service.port"
serviceNames = validKey "service.names"
serviceOptional = validKey "service.optional"
hugeNumber = validKey "limits.huge"
ratioNumber = validKey "limits.ratio"
featureEnabled = validKey "feature.enabled"
featureNames = validKey "feature.names"
databasePassword = validKey "database.password"
validKey :: Text -> Key
validKey value = either (error . show) id (parseKey value)
secretSentinel :: Text
secretSentinel = "never-render-this-yaml-secret"