#include #include #include #include #include #include #include #include "fold/config/configuration_profile.hpp" namespace { using fold::config::AssumptionKind; using fold::config::ConfigurationProfileErrorCode; using fold::config::GetenvState; using fold::config::parseConfigurationProfileText; constexpr const char* kValidProfile = R"({ "version": 1, "entrypoints": ["worker", "main"], "assumptions": [ {"kind":"accessor", "selector":"Config::enabled", "value":false}, {"kind":"getenv", "selector":"FEATURE", "state":"unset"}, {"kind":"entry_arg", "entrypoint":"main", "index":0, "value":-7}, {"kind":"global", "selector":"g_ready", "value":true}, {"kind":"getenv", "selector":"LIMIT", "state":"set"} ] })"; bool hasError(const fold::config::ConfigurationProfileParseResult& result, ConfigurationProfileErrorCode code) { for (const auto& error : result.errors) { if (error.code == code) return true; } return false; } struct TempFile { std::filesystem::path path = std::filesystem::temp_directory_path() / ("fold-configuration-profile-" + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count()) + ".json"); explicit TempFile(const std::string& contents) { std::ofstream(path) << contents; } ~TempFile() { std::filesystem::remove(path); } TempFile(const TempFile&) = delete; TempFile& operator=(const TempFile&) = delete; }; } // namespace TEST_CASE("configuration profile parses all v1 assumption kinds and typed values", "[configuration-profile]") { const auto result = parseConfigurationProfileText(kValidProfile); REQUIRE(result); REQUIRE(result.profile->version() == 1); REQUIRE(result.profile->entrypoints() == std::vector{"main", "worker"}); REQUIRE(result.profile->assumptions().size() == 5); bool saw_bool = false; bool saw_int = false; bool saw_unset = false; for (const auto& assumption : result.profile->assumptions()) { REQUIRE(std::regex_match(assumption.symbolId().value(), std::regex("[0-9a-f]{64}"))); if (assumption.value && std::holds_alternative(*assumption.value)) saw_bool = true; if (assumption.value && std::holds_alternative(*assumption.value)) saw_int = true; if (assumption.kind == AssumptionKind::Getenv) { REQUIRE_FALSE(assumption.value.has_value()); if (assumption.getenv_state == GetenvState::Unset) saw_unset = true; } } REQUIRE(saw_bool); REQUIRE(saw_int); REQUIRE(saw_unset); } TEST_CASE("configuration profile rejects root and assumption schema drift", "[configuration-profile][strict]") { const auto missing = parseConfigurationProfileText(R"({"entrypoints":[],"assumptions":[]})"); REQUIRE_FALSE(missing); REQUIRE(hasError(missing, ConfigurationProfileErrorCode::MissingField)); const auto future = parseConfigurationProfileText(R"({"version":2,"entrypoints":[],"assumptions":[]})"); REQUIRE_FALSE(future); REQUIRE(hasError(future, ConfigurationProfileErrorCode::UnsupportedVersion)); const auto unknown = parseConfigurationProfileText( R"({"version":1,"entrypoints":[],"assumptions":[],"extra":true})"); REQUIRE_FALSE(unknown); REQUIRE(hasError(unknown, ConfigurationProfileErrorCode::UnknownField)); const auto nested_unknown = parseConfigurationProfileText( R"({"version":1,"entrypoints":[],"assumptions":[{"kind":"global","selector":"g","value":true,"extra":0}]})"); REQUIRE_FALSE(nested_unknown); REQUIRE(hasError(nested_unknown, ConfigurationProfileErrorCode::UnknownField)); } TEST_CASE("configuration profile performs no value or index coercion", "[configuration-profile][strict]") { const auto string_bool = parseConfigurationProfileText( R"({"version":1,"entrypoints":[],"assumptions":[{"kind":"global","selector":"g","value":"true"}]})"); REQUIRE_FALSE(string_bool); REQUIRE(hasError(string_bool, ConfigurationProfileErrorCode::InvalidType)); const auto float_value = parseConfigurationProfileText( R"({"version":1,"entrypoints":[],"assumptions":[{"kind":"accessor","selector":"f","value":1.0}]})"); REQUIRE_FALSE(float_value); REQUIRE(hasError(float_value, ConfigurationProfileErrorCode::InvalidType)); const auto string_index = parseConfigurationProfileText( R"({"version":1,"entrypoints":["main"],"assumptions":[{"kind":"entry_arg","entrypoint":"main","index":"0","value":1}]})"); REQUIRE_FALSE(string_index); REQUIRE(hasError(string_index, ConfigurationProfileErrorCode::InvalidType)); } TEST_CASE("configuration profile validates selectors, entry arguments, getenv, and duplicates", "[configuration-profile][strict]") { const auto empty_selector = parseConfigurationProfileText( R"({"version":1,"entrypoints":[],"assumptions":[{"kind":"global","selector":" ","value":true}]})"); REQUIRE_FALSE(empty_selector); REQUIRE(hasError(empty_selector, ConfigurationProfileErrorCode::InvalidValue)); const auto unknown_entrypoint = parseConfigurationProfileText( R"({"version":1,"entrypoints":["main"],"assumptions":[{"kind":"entry_arg","entrypoint":"worker","index":0,"value":1}]})"); REQUIRE_FALSE(unknown_entrypoint); REQUIRE(hasError(unknown_entrypoint, ConfigurationProfileErrorCode::UnknownEntrypoint)); const auto negative_index = parseConfigurationProfileText( R"({"version":1,"entrypoints":["main"],"assumptions":[{"kind":"entry_arg","entrypoint":"main","index":-1,"value":1}]})"); REQUIRE_FALSE(negative_index); REQUIRE(hasError(negative_index, ConfigurationProfileErrorCode::InvalidValue)); const auto invalid_getenv = parseConfigurationProfileText( R"({"version":1,"entrypoints":[],"assumptions":[{"kind":"getenv","selector":"X","state":"unset","value":1}]})"); REQUIRE_FALSE(invalid_getenv); REQUIRE(hasError(invalid_getenv, ConfigurationProfileErrorCode::UnknownField)); const auto duplicate_key = parseConfigurationProfileText( R"({"version":1,"version":1,"entrypoints":[],"assumptions":[]})"); REQUIRE_FALSE(duplicate_key); REQUIRE(hasError(duplicate_key, ConfigurationProfileErrorCode::DuplicateField)); const auto duplicate_selector = parseConfigurationProfileText( R"({"version":1,"entrypoints":[],"assumptions":[{"kind":"global","selector":"g","value":true},{"kind":"global","selector":"g","value":false}]})"); REQUIRE_FALSE(duplicate_selector); REQUIRE(hasError(duplicate_selector, ConfigurationProfileErrorCode::DuplicateAssumption)); } TEST_CASE("configuration profile canonical JSON and hash ignore input order and whitespace", "[configuration-profile][canonical]") { const auto first = parseConfigurationProfileText(R"({ "version":1, "entrypoints":["worker","main"], "assumptions":[ {"kind":"global","selector":"z","value":9}, {"kind":"entry_arg","entrypoint":"main","index":0,"value":true} ] })"); const auto second = parseConfigurationProfileText( R"({"assumptions":[{"value":true,"index":0,"entrypoint":"main","kind":"entry_arg"},{"selector":"z","value":9,"kind":"global"}],"entrypoints":["main","worker"],"version":1})"); REQUIRE(first); REQUIRE(second); REQUIRE(first.profile->canonicalJsonText() == second.profile->canonicalJsonText()); REQUIRE(first.profile->canonicalHash() == second.profile->canonicalHash()); REQUIRE(std::regex_match(first.profile->canonicalHash(), std::regex("[0-9a-f]{64}"))); REQUIRE(first.profile->assumptions().size() == second.profile->assumptions().size()); for (std::size_t index = 0; index < first.profile->assumptions().size(); ++index) { REQUIRE(first.profile->assumptions()[index].symbolId() == second.profile->assumptions()[index].symbolId()); } } TEST_CASE("configuration profile parses files and reports bounded structured errors", "[configuration-profile][file]") { TempFile file(kValidProfile); const auto parsed = fold::config::parseConfigurationProfileFile(file.path); REQUIRE(parsed); std::string invalid = R"({"version":1,"entrypoints":[],"assumptions":[])"; const auto syntax_error = parseConfigurationProfileText(invalid); REQUIRE_FALSE(syntax_error); REQUIRE(hasError(syntax_error, ConfigurationProfileErrorCode::JsonSyntax)); REQUIRE(syntax_error.errors.size() <= fold::config::kMaximumConfigurationProfileErrors); REQUIRE_FALSE(syntax_error.errors.front().path.empty()); REQUIRE_FALSE(syntax_error.errors.front().message.empty()); const auto missing_file = fold::config::parseConfigurationProfileFile(file.path.string() + ".missing"); REQUIRE_FALSE(missing_file); REQUIRE(hasError(missing_file, ConfigurationProfileErrorCode::FileRead)); }