mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-11-02 22:16:58 +08:00
fix(channels): reject unsupported webhook channels before persistence (#654)
This commit is contained in:
parent
a1f1a6f841
commit
adc350207e
@ -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"
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 应用启动完成后自动加载并启动所有已启用的渠道。
|
* 应用启动完成后自动加载并启动所有已启用的渠道。
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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());
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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">
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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()
|
||||||
|
}
|
||||||
|
})
|
||||||
@ -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',
|
||||||
])
|
])
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user