fix(channels): reject unsupported webhook channels before persistence (#654)

This commit is contained in:
mateaix 2026-10-03 17:38:42 +08:00
parent a1f1a6f841
commit adc350207e
10 changed files with 166 additions and 6 deletions

View File

@ -233,9 +233,7 @@ public class ChannelManager {
private static final long FOLLOWER_RETRY_INTERVAL_SECONDS = 30L; private static final long FOLLOWER_RETRY_INTERVAL_SECONDS = 30L;
/** 支持的渠道类型 */ /** 支持的渠道类型 */
private static final Set<String> SUPPORTED_TYPES = Set.of( private static final Set<String> SUPPORTED_TYPES = ChannelTypes.SUPPORTED;
"web", "dingtalk", "feishu", "telegram", "discord", "wecom", "qq", "weixin", "slack", "webchat"
);
/** /**
* 应用启动完成后自动加载并启动所有已启用的渠道。 * 应用启动完成后自动加载并启动所有已启用的渠道。

View File

@ -0,0 +1,19 @@
package vip.mate.channel;
import java.util.Set;
import vip.mate.exception.MateClawException;
/** Channel types with a runtime adapter. Validate before persisting configuration. */
public final class ChannelTypes {
public static final Set<String> SUPPORTED = Set.of(
"web", "dingtalk", "feishu", "telegram", "discord", "wecom", "qq", "weixin", "slack", "webchat");
private ChannelTypes() {}
public static void requireSupported(String type) {
if (type == null || !SUPPORTED.contains(type)) {
throw new MateClawException("err.channel.type_unsupported", 400,
"Unsupported channel type: " + type);
}
}
}

View File

@ -8,6 +8,7 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.ObjectProvider; import org.springframework.beans.factory.ObjectProvider;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import vip.mate.channel.feishu.FeishuClientFactory; import vip.mate.channel.feishu.FeishuClientFactory;
import vip.mate.channel.ChannelTypes;
import vip.mate.channel.model.ChannelEntity; import vip.mate.channel.model.ChannelEntity;
import vip.mate.channel.repository.ChannelMapper; import vip.mate.channel.repository.ChannelMapper;
import vip.mate.exception.MateClawException; import vip.mate.exception.MateClawException;
@ -117,6 +118,7 @@ public class ChannelService {
if (channel.getChannelType() == null || channel.getChannelType().isBlank()) { if (channel.getChannelType() == null || channel.getChannelType().isBlank()) {
throw new MateClawException("err.channel.type_required", "渠道类型不能为空"); throw new MateClawException("err.channel.type_required", "渠道类型不能为空");
} }
ChannelTypes.requireSupported(channel.getChannelType());
if (channel.getEnabled() == null) { if (channel.getEnabled() == null) {
channel.setEnabled(false); channel.setEnabled(false);
} }
@ -133,6 +135,11 @@ public class ChannelService {
*/ */
public ChannelEntity updateChannel(ChannelEntity channel) { public ChannelEntity updateChannel(ChannelEntity channel) {
ChannelEntity existing = getChannel(channel.getId()); ChannelEntity existing = getChannel(channel.getId());
if (channel.getChannelType() != null) {
ChannelTypes.requireSupported(channel.getChannelType());
} else if (Boolean.TRUE.equals(channel.getEnabled())) {
ChannelTypes.requireSupported(existing.getChannelType());
}
if ("webchat".equals(channel.getChannelType())) { if ("webchat".equals(channel.getChannelType())) {
channel.setConfigJson(enrichWebChatConfig(channel.getConfigJson(), existing.getConfigJson())); channel.setConfigJson(enrichWebChatConfig(channel.getConfigJson(), existing.getConfigJson()));
} }
@ -157,6 +164,9 @@ public class ChannelService {
*/ */
public ChannelEntity toggleChannel(Long id, boolean enabled) { public ChannelEntity toggleChannel(Long id, boolean enabled) {
ChannelEntity channel = getChannel(id); ChannelEntity channel = getChannel(id);
if (enabled) {
ChannelTypes.requireSupported(channel.getChannelType());
}
channel.setEnabled(enabled); channel.setEnabled(enabled);
channelMapper.updateById(channel); channelMapper.updateById(channel);
invalidateChannelCaches(id, channel.getChannelType()); invalidateChannelCaches(id, channel.getChannelType());

View File

@ -338,3 +338,5 @@ tool.send_file.success={0} \u5df2\u53d1\u9001\uff1a[{0}]({1})\uff08\u94fe\u63a5
err.settings.storage_root_invalid=\u5b58\u50a8\u8def\u5f84\u65e0\u6548 err.settings.storage_root_invalid=\u5b58\u50a8\u8def\u5f84\u65e0\u6548
err.settings.storage_root_not_absolute=\u5b58\u50a8\u8def\u5f84\u5fc5\u987b\u4e3a\u7edd\u5bf9\u8def\u5f84 err.settings.storage_root_not_absolute=\u5b58\u50a8\u8def\u5f84\u5fc5\u987b\u4e3a\u7edd\u5bf9\u8def\u5f84
err.settings.storage_root_create_failed=\u65e0\u6cd5\u521b\u5efa\u5b58\u50a8\u76ee\u5f55\uff0c\u8bf7\u68c0\u67e5\u8def\u5f84\u4e0e\u5199\u5165\u6743\u9650 err.settings.storage_root_create_failed=\u65e0\u6cd5\u521b\u5efa\u5b58\u50a8\u76ee\u5f55\uff0c\u8bf7\u68c0\u67e5\u8def\u5f84\u4e0e\u5199\u5165\u6743\u9650
err.channel.type_unsupported=\u4e0d\u652f\u6301\u6b64\u6e20\u9053\u7c7b\u578b\uff0c\u8bf7\u4f7f\u7528\u53d7\u652f\u6301\u7684\u6e20\u9053\uff1b\u901a\u7528 HTTP \u63a5\u5165\u8bf7\u9009\u62e9 Web / API \u63a5\u5165\u3002

View File

@ -345,3 +345,5 @@ tool.send_file.success={0} sent: [{0}]({1}) (link valid for 10 minutes).\nIMPORT
err.settings.storage_root_invalid=Invalid storage path err.settings.storage_root_invalid=Invalid storage path
err.settings.storage_root_not_absolute=Storage path must be an absolute path err.settings.storage_root_not_absolute=Storage path must be an absolute path
err.settings.storage_root_create_failed=Cannot create the storage directory; check the path and write permission err.settings.storage_root_create_failed=Cannot create the storage directory; check the path and write permission
err.channel.type_unsupported=Unsupported channel type. For generic HTTP access, use Web / API access.

View File

@ -0,0 +1,108 @@
package vip.mate.channel.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.ObjectProvider;
import vip.mate.channel.feishu.FeishuClientFactory;
import vip.mate.channel.model.ChannelEntity;
import vip.mate.channel.repository.ChannelMapper;
import vip.mate.channel.tool.ChannelToolService;
import vip.mate.exception.MateClawException;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class ChannelTypeValidationTest {
@Mock ChannelMapper mapper;
@Mock ObjectProvider<FeishuClientFactory> feishu;
@Mock ObjectProvider<ChannelToolService> tools;
private ChannelService service;
@BeforeEach
void setUp() {
service = new ChannelService(mapper, new ObjectMapper(), feishu, tools);
}
private ChannelEntity channel(String type) {
ChannelEntity channel = new ChannelEntity();
channel.setName("Issue 654 regression");
channel.setChannelType(type);
channel.setEnabled(true);
return channel;
}
@ParameterizedTest
@ValueSource(strings = {"webhook", "unknown", "WEB", " web "})
void rejectsUnsupportedCreateBeforeInsert(String type) {
MateClawException error = assertThrows(MateClawException.class,
() -> service.createChannel(channel(type)));
assertEquals(400, error.getCode());
assertEquals("err.channel.type_unsupported", error.getMsgKey());
verifyNoInteractions(mapper);
}
@Test
void disabledWebhookCannotBypassValidation() {
ChannelEntity channel = channel("webhook");
channel.setEnabled(false);
assertThrows(MateClawException.class, () -> service.createChannel(channel));
verifyNoInteractions(mapper);
}
@ParameterizedTest
@ValueSource(strings = {"web", "dingtalk", "feishu", "telegram", "discord", "wecom", "qq", "weixin", "slack", "webchat"})
void supportedChannelsCanStillBeCreated(String type) {
ChannelEntity channel = channel(type);
assertSame(channel, service.createChannel(channel));
verify(mapper).insert(channel);
if ("webchat".equals(type)) {
assertTrue(channel.getConfigJson().contains("api_key"));
}
}
@Test
void rejectsChangingExistingChannelToWebhookBeforeUpdate() {
when(mapper.selectById(1L)).thenReturn(channel("web"));
ChannelEntity update = channel("webhook");
update.setId(1L);
assertThrows(MateClawException.class, () -> service.updateChannel(update));
verify(mapper, never()).updateById(any(ChannelEntity.class));
}
@Test
void rejectsEnablingLegacyWebhookBeforeUpdate() {
ChannelEntity legacy = channel("webhook");
legacy.setEnabled(false);
when(mapper.selectById(1L)).thenReturn(legacy);
assertThrows(MateClawException.class, () -> service.toggleChannel(1L, true));
assertFalse(legacy.getEnabled());
verify(mapper, never()).updateById(any(ChannelEntity.class));
}
@Test
void partialUpdateCannotEnableLegacyWebhook() {
when(mapper.selectById(1L)).thenReturn(channel("webhook"));
ChannelEntity update = new ChannelEntity();
update.setId(1L);
update.setEnabled(true);
assertThrows(MateClawException.class, () -> service.updateChannel(update));
verify(mapper, never()).updateById(any(ChannelEntity.class));
}
@Test
void legacyWebhookCanStillBeDisabledAndDeleted() {
ChannelEntity legacy = channel("webhook");
when(mapper.selectById(1L)).thenReturn(legacy);
assertFalse(service.toggleChannel(1L, false).getEnabled());
service.deleteChannel(1L);
verify(mapper).updateById(legacy);
verify(mapper).deleteById(1L);
}
}

View File

@ -30,7 +30,6 @@
<option value="qq">{{ t('channels.types.qq') }}</option> <option value="qq">{{ t('channels.types.qq') }}</option>
<option value="slack">{{ t('channels.types.slack') }}</option> <option value="slack">{{ t('channels.types.slack') }}</option>
<option value="webchat">{{ t('channels.types.webchat') }}</option> <option value="webchat">{{ t('channels.types.webchat') }}</option>
<option value="webhook">{{ t('channels.types.webhook') }}</option>
</select> </select>
</div> </div>
<div class="form-group full-width"> <div class="form-group full-width">

View File

@ -57,7 +57,7 @@ const { t } = useI18n()
const groups = [ const groups = [
{ key: 'im', types: ['telegram', 'discord', 'slack', 'qq'] }, { key: 'im', types: ['telegram', 'discord', 'slack', 'qq'] },
{ key: 'enterprise', types: ['wecom', 'weixin', 'feishu', 'dingtalk'] }, { key: 'enterprise', types: ['wecom', 'weixin', 'feishu', 'dingtalk'] },
{ key: 'web', types: ['web', 'webchat', 'webhook'] }, { key: 'web', types: ['web', 'webchat'] },
] ]
function pick(type: string) { function pick(type: string) {

View File

@ -0,0 +1,22 @@
import { createApp } from 'vue'
import { createI18n } from 'vue-i18n'
import { expect, it, vi } from 'vitest'
import ChannelTypePicker from '../ChannelTypePicker.vue'
it('offers supported HTTP access without the unimplemented webhook channel', () => {
const host = document.createElement('div')
const pick = vi.fn()
const app = createApp(ChannelTypePicker, { modelValue: true, onPick: pick })
app.use(createI18n({ legacy: false, locale: 'en', missingWarn: false, fallbackWarn: false, messages: { en: {} } }))
app.mount(host)
try {
expect(host.querySelector('img[alt="webhook"]')).toBeNull()
expect(host.querySelector('img[alt="web"]')).not.toBeNull()
const webchat = host.querySelector('img[alt="webchat"]')
expect(webchat).not.toBeNull()
webchat!.closest('button')!.click()
expect(pick).toHaveBeenCalledWith('webchat')
} finally {
app.unmount()
}
})

View File

@ -204,7 +204,7 @@ const wizardType = ref<string>('')
// existing channels (where 3-step would feel like ceremony). // existing channels (where 3-step would feel like ceremony).
const WIZARD_TYPES = new Set([ const WIZARD_TYPES = new Set([
'telegram', 'discord', 'slack', 'qq', 'telegram', 'discord', 'slack', 'qq',
'web', 'webchat', 'webhook', 'web', 'webchat',
'wecom', 'weixin', 'dingtalk', 'feishu', 'wecom', 'weixin', 'dingtalk', 'feishu',
]) ])