mateclaw/mateclaw-server/src/test/java/vip/mate/plugin/PluginManagerUpdateConfigTest.java
倪程伟 53a12ee18d
feat(plugin/settings): search-provider catalog endpoint + grouped settings UI + plugin config form
Adds a read-only GET /api/v1/settings/search-providers catalog (admin-gated, no secrets), grouped collapsible provider cards, and a schema-driven plugin config form. Breaks a SystemSettingService<->PluginManager circular dependency via parameter @Lazy (with a context smoke test), and fixes PluginManager.updateConfig to merge instead of overwrite so omitted/blank secret fields are preserved. Hardens plugin search-provider id validation (reject-not-trim, case-insensitive conflict) and insulates the provider bridge hot path from throwing plugin code.
2026-07-04 12:06:33 +08:00

185 lines
8.1 KiB
Java

package vip.mate.plugin;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import vip.mate.channel.ChannelManager;
import vip.mate.llm.service.ModelProviderService;
import vip.mate.memory.spi.MemoryManager;
import vip.mate.plugin.api.MateClawPlugin;
import vip.mate.plugin.api.PluginContext;
import vip.mate.plugin.api.PluginException;
import vip.mate.plugin.api.PluginManifest;
import vip.mate.plugin.model.PluginEntity;
import vip.mate.plugin.repository.PluginMapper;
import vip.mate.tool.ToolRegistry;
import vip.mate.tool.search.SearchProviderRegistry;
import vip.mate.workspace.core.service.WorkspaceService;
import java.lang.reflect.Field;
import java.net.URL;
import java.net.URLClassLoader;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* PluginManager#updateConfig must MERGE the incoming partial config onto the existing
* stored config, not overwrite it wholesale — the new Plugins.vue config dialog
* intentionally omits unchanged secret fields (it never receives plaintext secrets
* back from the backend to resubmit them), so "omitted" must mean "keep the old
* value", not "delete it".
*/
class PluginManagerUpdateConfigTest {
private static final String PLUGIN_NAME = "plugin-a";
private static final String ORIGINAL_CONFIG_JSON =
"{\"baseUrl\":\"https://example.com\",\"apiKey\":\"secret123\"}";
private final ObjectMapper objectMapper = new ObjectMapper();
private PluginMapper pluginMapper;
private PluginManager manager() {
// Constructor param order MUST match PluginManager's field declaration order
// (Lombok @RequiredArgsConstructor): pluginProperties, pluginMapper, toolRegistry,
// channelManager, memoryManager, modelProviderService, searchProviderRegistry, workspaceService.
pluginMapper = mock(PluginMapper.class);
return new PluginManager(
mock(PluginProperties.class),
pluginMapper,
mock(ToolRegistry.class),
mock(ChannelManager.class),
mock(MemoryManager.class),
mock(ModelProviderService.class),
new SearchProviderRegistry(List.of()),
Optional.<WorkspaceService>empty());
}
private PluginEntity fixtureEntity(String configJson) {
PluginEntity entity = new PluginEntity();
entity.setName(PLUGIN_NAME);
entity.setConfigJson(configJson);
entity.setEnabled(true);
return entity;
}
private LoadedPlugin loadedPluginWithRequiredField(String name, String requiredKey) {
PluginManifest manifest = new PluginManifest();
manifest.setName(name);
manifest.setVersion("1.0.0");
manifest.setType("search");
manifest.setEntrypoint("x.Y");
PluginManifest.ConfigField field = new PluginManifest.ConfigField();
field.setType("string");
field.setRequired(true);
field.setSecret(true);
manifest.setConfig(Map.of(requiredKey, field));
MateClawPlugin plugin = new MateClawPlugin() {
@Override public void onLoad(PluginContext ctx) { }
@Override public void onEnable() { }
@Override public void onDisable() { }
};
return new LoadedPlugin(manifest, plugin,
new URLClassLoader(new URL[0], getClass().getClassLoader()));
}
@SuppressWarnings("unchecked")
private void seedPlugins(PluginManager manager, LoadedPlugin... loaded) throws Exception {
Field f = PluginManager.class.getDeclaredField("plugins");
f.setAccessible(true);
Map<String, LoadedPlugin> map = (Map<String, LoadedPlugin>) f.get(manager);
for (LoadedPlugin l : loaded) {
map.put(l.getManifest().getName(), l);
}
}
@Test
@DisplayName("merges new values over existing config, preserving omitted keys")
void mergesNewValuesOverExistingConfig() throws Exception {
PluginManager manager = manager();
when(pluginMapper.selectOne(any())).thenReturn(fixtureEntity(ORIGINAL_CONFIG_JSON));
manager.updateConfig(PLUGIN_NAME, Map.of("baseUrl", "https://new.example.com"));
ArgumentCaptor<PluginEntity> captor = ArgumentCaptor.forClass(PluginEntity.class);
verify(pluginMapper).updateById(captor.capture());
Map<String, Object> persisted = objectMapper.readValue(captor.getValue().getConfigJson(), Map.class);
assertEquals("https://new.example.com", persisted.get("baseUrl"));
assertEquals("secret123", persisted.get("apiKey"), "omitted secret must be retained, not deleted");
}
@Test
@DisplayName("overwrites a key when explicitly provided, keeping other stored keys untouched")
void overwritesAKeyWhenExplicitlyProvided() throws Exception {
PluginManager manager = manager();
when(pluginMapper.selectOne(any())).thenReturn(fixtureEntity(ORIGINAL_CONFIG_JSON));
manager.updateConfig(PLUGIN_NAME, Map.of("apiKey", "newSecret456"));
ArgumentCaptor<PluginEntity> captor = ArgumentCaptor.forClass(PluginEntity.class);
verify(pluginMapper).updateById(captor.capture());
Map<String, Object> persisted = objectMapper.readValue(captor.getValue().getConfigJson(), Map.class);
assertEquals("newSecret456", persisted.get("apiKey"));
assertEquals("https://example.com", persisted.get("baseUrl"));
}
@Test
@DisplayName("required-field check passes when omitted but already present in stored config")
void requiredFieldCheckPassesWhenOmittedButAlreadyStoredFromBefore() throws Exception {
PluginManager manager = manager();
seedPlugins(manager, loadedPluginWithRequiredField(PLUGIN_NAME, "apiKey"));
when(pluginMapper.selectOne(any())).thenReturn(fixtureEntity(ORIGINAL_CONFIG_JSON));
assertDoesNotThrow(() ->
manager.updateConfig(PLUGIN_NAME, Map.of("baseUrl", "https://only-this-changed.com")));
}
@Test
@DisplayName("required-field check still fails when the field has never been configured")
void requiredFieldCheckStillFailsWhenNeverConfigured() throws Exception {
PluginManager manager = manager();
seedPlugins(manager, loadedPluginWithRequiredField(PLUGIN_NAME, "apiKey"));
when(pluginMapper.selectOne(any())).thenReturn(fixtureEntity("{}"));
assertThrows(PluginException.class, () ->
manager.updateConfig(PLUGIN_NAME, Map.of("baseUrl", "https://only-this-changed.com")));
}
@Test
@DisplayName("running plugin's context sees the new config immediately after save (no restart needed)")
void runningPluginContextIsRefreshedAfterSave() throws Exception {
PluginManager manager = manager();
LoadedPlugin loaded = loadedPluginWithRequiredField(PLUGIN_NAME, "apiKey");
PluginContextImpl context = new PluginContextImpl(
loaded, loaded.getManifest(),
mock(ToolRegistry.class), mock(ChannelManager.class),
mock(MemoryManager.class), mock(ModelProviderService.class),
new SearchProviderRegistry(List.of()),
ORIGINAL_CONFIG_JSON);
loaded.setContext(context);
seedPlugins(manager, loaded);
when(pluginMapper.selectOne(any())).thenReturn(fixtureEntity(ORIGINAL_CONFIG_JSON));
assertEquals("https://example.com", context.getConfig("baseUrl", String.class));
manager.updateConfig(PLUGIN_NAME, Map.of("baseUrl", "https://new.example.com"));
assertEquals("https://new.example.com", context.getConfig("baseUrl", String.class),
"getConfig must serve the saved value without a disable/enable cycle");
assertEquals("secret123", context.getConfig("apiKey", String.class),
"omitted secret must survive the refresh via merge semantics");
}
}