// Copyright (c) 2026 Advanced Micro Devices, Inc. // SPDX-License-Identifier: MIT // // Unit tests for the plugin config resolver. The resolver merges a plugin's // declared `config_schema` with the user-supplied config: applying defaults, // validating types, enforcing required args, and passing unknown keys through. // These tests exercise the contract directly, without dlopen()ing a plugin. #include "rocjitsu/vm/plugins/plugin_config_resolver.h" #include #include #include #include namespace { using rocjitsu::plugin_detail::flexbuffer_from_json; using rocjitsu::plugin_detail::is_valid_plugin_name; using rocjitsu::plugin_detail::resolve_config_json; using rocjitsu::plugin_detail::type_matches; // Resolve helper: returns the resolved JSON via out-param and the bool result. bool resolve(const char *schema, const std::string &user, std::string &out) { return resolve_config_json("test_plugin", schema, user, out); } TEST(PluginConfigResolver, NoSchemaPassesUserConfigThrough) { std::string out; EXPECT_TRUE(resolve(nullptr, R"({"a": 1, "b": "x"})", out)); EXPECT_NE(out.find("\"a\": 1"), std::string::npos); EXPECT_NE(out.find("\"b\": \"x\""), std::string::npos); } TEST(PluginConfigResolver, EmptySchemaWithEmptyUserConfigYieldsEmptyObject) { std::string out; EXPECT_TRUE(resolve("{}", "", out)); // An empty object may be rendered with insignificant whitespace. out.erase(std::remove_if(out.begin(), out.end(), [](unsigned char c) { return std::isspace(c); }), out.end()); EXPECT_EQ(out, "{}"); } TEST(PluginConfigResolver, AppliesDefaultsForMissingArgs) { const char *schema = R"({ "level": { "type": "number", "default": 3 }, "label": { "type": "string", "default": "info" } })"; std::string out; EXPECT_TRUE(resolve(schema, "", out)); EXPECT_NE(out.find("\"level\": 3"), std::string::npos); EXPECT_NE(out.find("\"label\": \"info\""), std::string::npos); } TEST(PluginConfigResolver, ProvidedValueOverridesDefault) { const char *schema = R"({ "level": { "type": "number", "default": 3 } })"; std::string out; EXPECT_TRUE(resolve(schema, R"({"level": 7})", out)); EXPECT_NE(out.find("\"level\": 7"), std::string::npos); EXPECT_EQ(out.find("\"level\": 3"), std::string::npos); } TEST(PluginConfigResolver, MissingRequiredArgFails) { // An arg with a type but no default is required. const char *schema = R"({ "path": { "type": "string" } })"; std::string out; EXPECT_FALSE(resolve(schema, "", out)); } TEST(PluginConfigResolver, RequiredArgProvidedSucceeds) { const char *schema = R"({ "path": { "type": "string" } })"; std::string out; EXPECT_TRUE(resolve(schema, R"({"path": "/tmp/log"})", out)); EXPECT_NE(out.find("\"path\": \"/tmp/log\""), std::string::npos); } TEST(PluginConfigResolver, WrongTypeFails) { const char *schema = R"({ "level": { "type": "number", "default": 3 } })"; std::string out; EXPECT_FALSE(resolve(schema, R"({"level": "not-a-number"})", out)); } TEST(PluginConfigResolver, WrongDefaultTypeFails) { const char *schema = R"({ "level": { "type": "number", "default": "not-a-number" } })"; std::string out; EXPECT_FALSE(resolve(schema, "", out)); } TEST(PluginConfigResolver, SchemaEntryMissingTypeFails) { std::string out; EXPECT_FALSE(resolve(R"({ "level": { "default": 3 } })", R"({"level": 7})", out)); } TEST(PluginConfigResolver, SchemaEntryWithNonStringTypeFails) { std::string out; EXPECT_FALSE(resolve(R"({ "level": { "type": 42 } })", R"({"level": 7})", out)); } TEST(PluginConfigResolver, NonObjectSchemaEntryFails) { std::string out; EXPECT_FALSE(resolve(R"({ "level": "number" })", R"({"level": 7})", out)); } TEST(PluginConfigResolver, UnknownKeyPassedThrough) { const char *schema = R"({ "level": { "type": "number", "default": 3 } })"; std::string out; EXPECT_TRUE(resolve(schema, R"({"extra": "kept"})", out)); EXPECT_NE(out.find("\"level\": 3"), std::string::npos); EXPECT_NE(out.find("\"extra\": \"kept\""), std::string::npos); } TEST(PluginConfigResolver, InvalidSchemaIsIgnored) { std::string out; EXPECT_TRUE(resolve("{ this is not json", R"({"a": 1})", out)); EXPECT_NE(out.find("\"a\": 1"), std::string::npos); } TEST(PluginConfigResolver, InvalidUserJsonFails) { const char *schema = R"({ "level": { "type": "number", "default": 3 } })"; std::string out; EXPECT_FALSE(resolve(schema, "{ not json", out)); } TEST(PluginConfigResolver, TypeMatchesValidatesAgainstValueKinds) { flexbuffers::Builder fbb; ASSERT_TRUE(flexbuffer_from_json(R"({"s": "hello", "n": 42, "f": 1.5, "b": true})", fbb)); auto root = flexbuffers::GetRoot(fbb.GetBuffer()).AsMap(); EXPECT_TRUE(type_matches("string", root["s"])); EXPECT_FALSE(type_matches("number", root["s"])); EXPECT_TRUE(type_matches("number", root["n"])); EXPECT_TRUE(type_matches("number", root["f"])); EXPECT_FALSE(type_matches("string", root["n"])); EXPECT_TRUE(type_matches("boolean", root["b"])); EXPECT_TRUE(type_matches("bool", root["b"])); EXPECT_FALSE(type_matches("number", root["b"])); // Unknown schema type names never match. EXPECT_FALSE(type_matches("widget", root["s"])); } // is_valid_plugin_name guards the config key before it is interpolated into a // `librocjitsu_plugin_.so` filename and handed to dlopen. A name that // contains a path separator or other unexpected character could be turned into // a pathname (e.g. `../evil`), so only letters, digits, `_`, and `-` are // allowed. TEST(PluginName, RejectsPathLikeAndEmptyNames) { EXPECT_FALSE(is_valid_plugin_name("")); EXPECT_FALSE(is_valid_plugin_name("../evil")); EXPECT_FALSE(is_valid_plugin_name("/abs/path")); EXPECT_FALSE(is_valid_plugin_name("a/b")); EXPECT_FALSE(is_valid_plugin_name("a.b")); EXPECT_FALSE(is_valid_plugin_name("has space")); EXPECT_FALSE(is_valid_plugin_name("bad$char")); } TEST(PluginName, AcceptsSafeNames) { EXPECT_TRUE(is_valid_plugin_name("race")); EXPECT_TRUE(is_valid_plugin_name("logging")); EXPECT_TRUE(is_valid_plugin_name("my-plugin_2")); EXPECT_TRUE(is_valid_plugin_name("ABC123")); } } // namespace