From 41b056b7e33b0ff56d53bf0c231fc0ad54cfc74c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E5=AD=90=E9=BB=98?= <925456043@qq.com> Date: Fri, 31 Jul 2026 11:27:37 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E5=B7=A5=E4=BD=9C?= =?UTF-8?q?=E6=B5=81=E5=85=AC=E5=85=B1=20API=20=E8=B0=83=E7=94=A8=E9=97=AE?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 限制远程文档仅访问公网地址并校验重定向目标 - 统一访问令牌 401/403 与过期执行状态 404 语义 - 校正节点查询参数和工作流状态文档 --- .../interceptor/PublicApiInterceptor.java | 3 +- .../interceptor/PublicApiInterceptorTest.java | 85 ++++ .../support/DocumentInputStreamSupport.java | 409 ++++++++++++++++++ .../support/DocumentSourceLoader.java | 6 +- .../service/TinyFlowService.java | 7 + .../WorkflowApiPermissionServiceImpl.java | 2 +- .../DocumentInputStreamSupportTest.java | 180 ++++++++ .../service/TinyFlowServiceTest.java | 31 ++ .../WorkflowApiPermissionServiceImplTest.java | 77 ++++ .../service/impl/SysApiKeyServiceImpl.java | 10 +- .../SysApiKeyAuthenticationStatusTest.java | 106 +++++ .../src/views/ai/workflow/WorkflowList.vue | 70 ++- 12 files changed, 953 insertions(+), 33 deletions(-) create mode 100644 easyflow-api/easyflow-api-public/src/test/java/tech/easyflow/publicapi/interceptor/PublicApiInterceptorTest.java create mode 100644 easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/document/support/DocumentInputStreamSupport.java create mode 100644 easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/document/support/DocumentInputStreamSupportTest.java create mode 100644 easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/service/impl/WorkflowApiPermissionServiceImplTest.java create mode 100644 easyflow-modules/easyflow-module-system/src/test/java/tech/easyflow/system/service/impl/SysApiKeyAuthenticationStatusTest.java diff --git a/easyflow-api/easyflow-api-public/src/main/java/tech/easyflow/publicapi/interceptor/PublicApiInterceptor.java b/easyflow-api/easyflow-api-public/src/main/java/tech/easyflow/publicapi/interceptor/PublicApiInterceptor.java index 14eab885..24f001c2 100644 --- a/easyflow-api/easyflow-api-public/src/main/java/tech/easyflow/publicapi/interceptor/PublicApiInterceptor.java +++ b/easyflow-api/easyflow-api-public/src/main/java/tech/easyflow/publicapi/interceptor/PublicApiInterceptor.java @@ -25,8 +25,9 @@ public class PublicApiInterceptor implements HandlerInterceptor { String requestURI = request.getRequestURI(); String apiKey = request.getHeader("ApiKey"); - if (apiKey == null || apiKey.isEmpty()) { + if (apiKey == null || apiKey.isBlank()) { Result failed = Result.fail(401, "密钥不正确"); + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); ResponseUtil.renderJson(response, failed); return false; } diff --git a/easyflow-api/easyflow-api-public/src/test/java/tech/easyflow/publicapi/interceptor/PublicApiInterceptorTest.java b/easyflow-api/easyflow-api-public/src/test/java/tech/easyflow/publicapi/interceptor/PublicApiInterceptorTest.java new file mode 100644 index 00000000..53fcd352 --- /dev/null +++ b/easyflow-api/easyflow-api-public/src/test/java/tech/easyflow/publicapi/interceptor/PublicApiInterceptorTest.java @@ -0,0 +1,85 @@ +package tech.easyflow.publicapi.interceptor; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.junit.Assert; +import org.junit.Test; + +import java.io.PrintWriter; +import java.io.StringWriter; +import java.lang.reflect.Proxy; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * {@link PublicApiInterceptor} 鉴权响应测试。 + */ +public class PublicApiInterceptorTest { + + /** + * 验证缺少访问令牌时返回真实 HTTP 401 和统一错误体。 + * + * @throws Exception 拦截器处理失败时抛出 + */ + @Test + public void shouldReturnUnauthorizedWhenApiKeyMissing() throws Exception { + StringWriter body = new StringWriter(); + AtomicInteger status = new AtomicInteger(); + HttpServletRequest request = proxy( + HttpServletRequest.class, + (instance, method, args) -> { + if ("getRequestURI".equals(method.getName())) { + return "/public-api/workflow/runAsync"; + } + if ("getHeader".equals(method.getName())) { + return null; + } + throw new AssertionError( + "测试路径不应调用 HttpServletRequest." + + method.getName()); + }); + HttpServletResponse response = proxy( + HttpServletResponse.class, + (instance, method, args) -> { + if ("setStatus".equals(method.getName())) { + status.set((Integer) args[0]); + return null; + } + if ("setContentType".equals(method.getName())) { + return null; + } + if ("getWriter".equals(method.getName())) { + return new PrintWriter(body); + } + throw new AssertionError( + "测试路径不应调用 HttpServletResponse." + + method.getName()); + }); + + boolean allowed = new PublicApiInterceptor() + .preHandle(request, response, new Object()); + + Assert.assertFalse(allowed); + Assert.assertEquals( + HttpServletResponse.SC_UNAUTHORIZED, + status.get()); + Assert.assertTrue(body.toString().contains("\"errorCode\":401")); + Assert.assertTrue(body.toString().contains("密钥不正确")); + } + + /** + * 创建接口代理。 + * + * @param type 接口类型 + * @param handler 调用处理器 + * @param 接口类型 + * @return 代理实例 + */ + private T proxy( + Class type, + java.lang.reflect.InvocationHandler handler) { + return type.cast(Proxy.newProxyInstance( + type.getClassLoader(), + new Class[]{type}, + handler)); + } +} diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/document/support/DocumentInputStreamSupport.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/document/support/DocumentInputStreamSupport.java new file mode 100644 index 00000000..d2d07b5a --- /dev/null +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/document/support/DocumentInputStreamSupport.java @@ -0,0 +1,409 @@ +package tech.easyflow.ai.document.support; + +import com.easyagents.flow.core.util.OkHttpClientUtil; +import okhttp3.Dns; +import okhttp3.HttpUrl; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; +import okhttp3.ResponseBody; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.FilterInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.InetAddress; +import java.net.Proxy; +import java.net.UnknownHostException; +import java.util.List; + +/** + * 文档输入流的共享连接与实际字节数保护工具。 + */ +public final class DocumentInputStreamSupport { + + private static final Logger LOG = + LoggerFactory.getLogger(DocumentInputStreamSupport.class); + private static final String BLOCKED_REMOTE_ADDRESS_MESSAGE = + "远端文档地址不允许访问非公网目标"; + + private DocumentInputStreamSupport() { + } + + /** + * 使用共享 HTTP 客户端打开远端文档流。 + * + * @param url 远端 URL + * @param maxBytes 最大允许读取字节数;小于等于 0 时不限制 + * @return 关闭时会同步释放 HTTP 响应的输入流 + * @throws IOException 请求失败、非成功状态或响应超过限制时抛出 + */ + public static InputStream openRemote(String url, long maxBytes) throws IOException { + HttpUrl remoteUrl = parseRemoteUrl(url); + Response response = RemoteClientHolder.CLIENT + .newCall(new Request.Builder().url(remoteUrl).get().build()) + .execute(); + return openResponse(response, maxBytes); + } + + /** + * 解析并校验远端文档 URL 的协议。 + * + * @param url 原始 URL + * @return 可用于请求的 HTTP URL + * @throws IOException URL 为空、格式错误或协议不受支持时抛出 + */ + private static HttpUrl parseRemoteUrl(String url) throws IOException { + if (url == null || url.isBlank()) { + throw new IOException("远端文档 URL 不能为空"); + } + HttpUrl remoteUrl = HttpUrl.parse(url); + if (remoteUrl == null + || (!"http".equals(remoteUrl.scheme()) + && !"https".equals(remoteUrl.scheme()))) { + throw new IOException("远端文档仅支持 HTTP 或 HTTPS URL"); + } + return remoteUrl; + } + + /** + * 创建只允许公网目标的远程文档客户端。 + * + *

显式禁用代理,避免代理服务器重新解析目标域名后绕过本机 DNS 校验。 + * OkHttp 的重定向请求会继续使用同一 DNS 实现,因此每个新目标都会重新校验。

+ * + * @param delegateDns 实际执行域名解析的 DNS + * @return 带公网地址约束的 HTTP 客户端 + */ + static OkHttpClient createRemoteClient(Dns delegateDns) { + if (delegateDns == null) { + throw new IllegalArgumentException("DNS 解析器不能为空"); + } + return OkHttpClientUtil.buildDefaultClient() + .newBuilder() + .proxy(Proxy.NO_PROXY) + .dns(new PublicAddressDns(delegateDns)) + .build(); + } + + /** + * 判断地址是否属于允许访问的公网范围。 + * + * @param address 已解析的目标地址 + * @return 公网单播地址返回 true + */ + static boolean isPublicAddress(InetAddress address) { + if (address == null + || address.isAnyLocalAddress() + || address.isLoopbackAddress() + || address.isLinkLocalAddress() + || address.isSiteLocalAddress() + || address.isMulticastAddress()) { + return false; + } + byte[] bytes = address.getAddress(); + if (bytes.length == 4) { + return isPublicIpv4(bytes); + } + if (bytes.length == 16) { + return isPublicIpv6(bytes); + } + return false; + } + + /** + * 判断 IPv4 地址是否为公网单播地址。 + * + * @param bytes IPv4 地址字节 + * @return 公网单播地址返回 true + */ + private static boolean isPublicIpv4(byte[] bytes) { + int first = bytes[0] & 0xff; + int second = bytes[1] & 0xff; + int third = bytes[2] & 0xff; + if (first == 0 || first == 10 || first == 127 || first >= 224) { + return false; + } + if (first == 100 && second >= 64 && second <= 127) { + return false; + } + if (first == 169 && second == 254) { + return false; + } + if (first == 172 && second >= 16 && second <= 31) { + return false; + } + if (first == 192 && second == 168) { + return false; + } + if (first == 192 && second == 0 && (third == 0 || third == 2)) { + return false; + } + if (first == 192 && second == 88 && third == 99) { + return false; + } + if (first == 198 && (second == 18 || second == 19)) { + return false; + } + if (first == 198 && second == 51 && third == 100) { + return false; + } + return !(first == 203 && second == 0 && third == 113); + } + + /** + * 判断 IPv6 地址是否为公网单播地址。 + * + * @param bytes IPv6 地址字节 + * @return 公网单播地址返回 true + */ + private static boolean isPublicIpv6(byte[] bytes) { + int first = bytes[0] & 0xff; + int second = bytes[1] & 0xff; + if ((first & 0xfe) == 0xfc) { + return false; + } + if (first == 0x20 && second == 0x01) { + int third = bytes[2] & 0xff; + int fourth = bytes[3] & 0xff; + if ((third == 0x0d && fourth == 0xb8) + || (third == 0x00 && fourth == 0x00)) { + return false; + } + } + if (first == 0x20 && second == 0x02) { + return false; + } + return !(first == 0x00 + && second == 0x64 + && (bytes[2] & 0xff) == 0xff + && (bytes[3] & 0xff) == 0x9b); + } + + /** + * 将 HTTP 响应转换为按实际读取字节数受限的输入流。 + * + *

响应声明长度仅用于诊断。最终限制以流中实际读到的字节数为准, + * 避免代理或存储服务返回错误 Content-Length 时误判正常文件。

+ * + * @param response HTTP 响应 + * @param maxBytes 最大允许读取字节数;小于等于 0 时不限制 + * @return 关闭时会同步释放 HTTP 响应的输入流 + * @throws IOException 非成功状态或响应体为空时抛出 + */ + static InputStream openResponse(Response response, long maxBytes) + throws IOException { + if (!response.isSuccessful()) { + int status = response.code(); + response.close(); + throw new IOException("Document download failed with HTTP status " + status); + } + ResponseBody body = response.body(); + if (body == null) { + response.close(); + throw new IOException("Document download response body is empty"); + } + long contentLength = body.contentLength(); + if (maxBytes > 0L && contentLength > maxBytes) { + LOG.warn( + "远端文档响应声明长度超过限制,将按实际读取量确认: " + + "declaredBytes={}, maxBytes={}", + contentLength, + maxBytes); + } + return limit(new ResponseInputStream(body.byteStream(), response), maxBytes); + } + + /** + * 为已有输入流增加实际读取字节数限制。 + * + * @param inputStream 原始输入流 + * @param maxBytes 最大允许读取字节数;小于等于 0 时直接返回原始流 + * @return 受限输入流 + */ + public static InputStream limit(InputStream inputStream, long maxBytes) { + if (inputStream == null || maxBytes <= 0L) { + return inputStream; + } + return new LimitedInputStream(inputStream, maxBytes); + } + + /** + * 在实际字节数限制下读取完整内容。 + * + * @param inputStream 文档输入流 + * @param maxBytes 最大允许读取字节数;小于等于 0 时不限制 + * @return 文档字节 + * @throws IOException 读取失败或内容超过限制时抛出 + */ + public static byte[] readBytes(InputStream inputStream, long maxBytes) throws IOException { + try (InputStream limited = limit(inputStream, maxBytes)) { + return limited.readAllBytes(); + } + } + + /** + * 文档实际读取字节数超过保护值异常。 + */ + public static final class SizeLimitExceededException extends IOException { + + private final long maxBytes; + private final long actualBytes; + + /** + * 创建文档大小限制异常。 + * + * @param maxBytes 最大允许字节数 + */ + public SizeLimitExceededException(long maxBytes) { + this(maxBytes, -1L); + } + + /** + * 创建包含实际读取量的文档大小限制异常。 + * + * @param maxBytes 最大允许字节数 + * @param actualBytes 已确认的实际字节数;未知时传入负数 + */ + public SizeLimitExceededException(long maxBytes, long actualBytes) { + super("Document exceeds size limit: max=" + maxBytes + + " bytes, actual=" + actualBytes + " bytes"); + this.maxBytes = maxBytes; + this.actualBytes = actualBytes; + } + + /** + * 获取最大允许字节数。 + * + * @return 最大允许字节数 + */ + public long getMaxBytes() { + return maxBytes; + } + + /** + * 获取已确认的实际读取量。 + * + * @return 实际字节数;未知时为负数 + */ + public long getActualBytes() { + return actualBytes; + } + } + + /** + * 按实际读取量执行限制的输入流。 + */ + private static final class LimitedInputStream extends FilterInputStream { + + private final long maxBytes; + private long consumed; + + private LimitedInputStream(InputStream inputStream, long maxBytes) { + super(inputStream); + this.maxBytes = maxBytes; + } + + @Override + public int read() throws IOException { + int value = super.read(); + if (value >= 0) { + recordRead(1L); + } + return value; + } + + @Override + public int read(byte[] buffer, int offset, int length) throws IOException { + if (length == 0) { + return 0; + } + int allowed = (int) Math.min( + Math.max(0L, maxBytes - consumed + 1L), + (long) length); + if (allowed <= 0) { + throw new SizeLimitExceededException(maxBytes, consumed + 1L); + } + int count = super.read(buffer, offset, allowed); + if (count > 0) { + recordRead(count); + } + return count; + } + + private void recordRead(long count) throws SizeLimitExceededException { + consumed += count; + if (consumed > maxBytes) { + throw new SizeLimitExceededException(maxBytes, consumed); + } + } + } + + /** + * 关闭输入流时一并关闭 OkHttp 响应。 + */ + private static final class ResponseInputStream extends FilterInputStream { + + private final Response response; + + private ResponseInputStream(InputStream inputStream, Response response) { + super(inputStream); + this.response = response; + } + + @Override + public void close() { + response.close(); + } + } + + /** + * 延迟创建远程文档客户端,避免本地流处理路径初始化网络资源。 + */ + private static final class RemoteClientHolder { + + private static final OkHttpClient CLIENT = createRemoteClient(Dns.SYSTEM); + + private RemoteClientHolder() { + } + } + + /** + * 对 DNS 解析结果执行公网地址约束。 + */ + private static final class PublicAddressDns implements Dns { + + private final Dns delegate; + + /** + * 创建安全 DNS 包装器。 + * + * @param delegate 实际 DNS 解析器 + */ + private PublicAddressDns(Dns delegate) { + this.delegate = delegate; + } + + /** + * 解析主机并拒绝任一非公网地址,避免连接回退到私有地址。 + * + * @param hostname 目标主机名 + * @return 全部通过校验的解析地址 + * @throws UnknownHostException 解析失败或包含非公网地址时抛出 + */ + @Override + public List lookup(String hostname) throws UnknownHostException { + List addresses = delegate.lookup(hostname); + if (addresses == null || addresses.isEmpty()) { + throw new UnknownHostException("远端文档域名没有可用地址"); + } + for (InetAddress address : addresses) { + if (!isPublicAddress(address)) { + throw new UnknownHostException(BLOCKED_REMOTE_ADDRESS_MESSAGE); + } + } + return addresses; + } + } +} diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/document/support/DocumentSourceLoader.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/document/support/DocumentSourceLoader.java index 48f2ecb5..3cf8d0c6 100644 --- a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/document/support/DocumentSourceLoader.java +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/document/support/DocumentSourceLoader.java @@ -1,6 +1,5 @@ package tech.easyflow.ai.document.support; -import cn.hutool.http.HttpUtil; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; @@ -85,8 +84,9 @@ public class DocumentSourceLoader { private LoadedDocumentSource loadFromRemoteValue(DocumentSourceRef sourceRef, String remoteUrl) { String fileName = resolveFileName(sourceRef); - try { - byte[] contentBytes = HttpUtil.downloadBytes(remoteUrl); + try (InputStream inputStream = + DocumentInputStreamSupport.openRemote(remoteUrl, 0L)) { + byte[] contentBytes = DocumentInputStreamSupport.readBytes(inputStream, 0L); return buildLoadedSource( fileName, resolveContentType(sourceRef, fileName), diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagentsflow/service/TinyFlowService.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagentsflow/service/TinyFlowService.java index b6784778..7ea0bc5c 100644 --- a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagentsflow/service/TinyFlowService.java +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagentsflow/service/TinyFlowService.java @@ -10,6 +10,7 @@ import com.easyagents.flow.core.chain.runtime.ChainExecutor; import org.springframework.stereotype.Component; import tech.easyflow.ai.easyagentsflow.entity.ChainInfo; import tech.easyflow.ai.easyagentsflow.entity.NodeInfo; +import tech.easyflow.common.web.exceptions.BusinessException; import javax.annotation.Resource; import java.util.List; @@ -40,6 +41,12 @@ public class TinyFlowService { NodeStateRepository nodeStateRepository = chainExecutor.getNodeStateRepository(); ChainState chainState = chainStateRepository.load(executeId); + if (chainState == null) { + throw new BusinessException( + 404, + 404, + "工作流执行状态不存在或已过期"); + } ChainInfo res = getChainInfo(executeId, chainState); if (nodes != null) { diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/WorkflowApiPermissionServiceImpl.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/WorkflowApiPermissionServiceImpl.java index 5a0a98e9..10b6e1a1 100644 --- a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/WorkflowApiPermissionServiceImpl.java +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/WorkflowApiPermissionServiceImpl.java @@ -87,7 +87,7 @@ public class WorkflowApiPermissionServiceImpl implements WorkflowApiPermissionSe .isNull(SysApiKeyResourceMapping::getResourceTargetId) .eq(SysApiKeyResourceMapping::getActionScope, ACTION_SCOPE_INVOKE); if (mappingService.count(wrapper) == 0) { - throw new BusinessException("该apiKey无权限调用工作流 API"); + throw new BusinessException(403, 403, "该apiKey无权限调用工作流 API"); } return sysApiKey; } diff --git a/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/document/support/DocumentInputStreamSupportTest.java b/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/document/support/DocumentInputStreamSupportTest.java new file mode 100644 index 00000000..94ef20d2 --- /dev/null +++ b/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/document/support/DocumentInputStreamSupportTest.java @@ -0,0 +1,180 @@ +package tech.easyflow.ai.document.support; + +import okhttp3.Dns; +import okhttp3.MediaType; +import okhttp3.OkHttpClient; +import okhttp3.Protocol; +import okhttp3.Request; +import okhttp3.Response; +import okhttp3.ResponseBody; +import okio.Buffer; +import okio.BufferedSource; +import org.junit.Assert; +import org.junit.Test; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.InetAddress; +import java.net.Proxy; +import java.net.UnknownHostException; +import java.util.List; + +/** + * {@link DocumentInputStreamSupport} 单元测试。 + */ +public class DocumentInputStreamSupportTest { + + /** + * 验证远程文档客户端禁用代理且保留重定向,并让每个目标经过安全 DNS。 + * + * @throws Exception 地址构造失败时抛出 + */ + @Test + public void shouldRejectPrivateAddressForRedirectAwareClient() + throws Exception { + Dns privateDns = hostname -> List.of( + InetAddress.getByName("127.0.0.1")); + OkHttpClient client = + DocumentInputStreamSupport.createRemoteClient(privateDns); + + Assert.assertEquals(Proxy.NO_PROXY, client.proxy()); + Assert.assertTrue(client.followRedirects()); + try { + client.dns().lookup("redirect-target.example"); + Assert.fail("expected private address rejection"); + } catch (UnknownHostException error) { + Assert.assertTrue(error.getMessage().contains("非公网目标")); + } + } + + /** + * 验证正常公网地址仍可通过安全 DNS。 + * + * @throws Exception 地址构造失败时抛出 + */ + @Test + public void shouldAllowPublicAddress() throws Exception { + InetAddress publicAddress = + InetAddress.getByName("93.184.216.34"); + Dns publicDns = hostname -> List.of(publicAddress); + OkHttpClient client = + DocumentInputStreamSupport.createRemoteClient(publicDns); + + Assert.assertEquals( + List.of(publicAddress), + client.dns().lookup("public.example")); + } + + /** + * 验证常见内网、链路本地和保留地址均被拒绝。 + * + * @throws Exception 地址构造失败时抛出 + */ + @Test + public void shouldRejectNonPublicAddressRanges() throws Exception { + List blockedAddresses = List.of( + "10.0.0.1", + "100.64.0.1", + "127.0.0.1", + "169.254.169.254", + "172.16.0.1", + "192.168.0.1", + "198.18.0.1", + "fd00::1", + "2001:db8::1"); + + for (String value : blockedAddresses) { + Assert.assertFalse( + value, + DocumentInputStreamSupport.isPublicAddress( + InetAddress.getByName(value))); + } + } + + /** + * 验证远端响应声明长度虚高时按实际读取量接受小文件。 + * + * @throws IOException 响应读取失败时抛出 + */ + @Test + public void shouldUseActualBytesWhenDeclaredLengthExceedsLimit() + throws IOException { + long maxBytes = 20L * 1024L * 1024L; + byte[] content = new byte[58 * 1024]; + Buffer source = new Buffer().write(content); + ResponseBody responseBody = new ResponseBody() { + @Override + public MediaType contentType() { + return MediaType.parse( + "application/vnd.openxmlformats-officedocument" + + ".wordprocessingml.document"); + } + + @Override + public long contentLength() { + return maxBytes + 1L; + } + + @Override + public BufferedSource source() { + return source; + } + }; + Response response = new Response.Builder() + .request(new Request.Builder() + .url("http://127.0.0.1/document.docx") + .build()) + .protocol(Protocol.HTTP_1_1) + .code(200) + .message("OK") + .body(responseBody) + .build(); + + try (InputStream inputStream = + DocumentInputStreamSupport.openResponse( + response, + maxBytes)) { + Assert.assertArrayEquals( + content, + DocumentInputStreamSupport.readBytes( + inputStream, + maxBytes)); + } + } + + /** + * 验证真实读取量超过限制时携带实际字节数。 + */ + @Test + public void shouldRejectActualBytesBeyondLimit() { + long maxBytes = 1024L; + byte[] content = new byte[(int) maxBytes + 1]; + + try { + DocumentInputStreamSupport.readBytes( + new ByteArrayInputStream(content), + maxBytes); + Assert.fail("expected SizeLimitExceededException"); + } catch (DocumentInputStreamSupport.SizeLimitExceededException error) { + Assert.assertEquals(maxBytes, error.getMaxBytes()); + Assert.assertEquals(maxBytes + 1L, error.getActualBytes()); + } catch (IOException error) { + Assert.fail("unexpected IOException: " + error.getMessage()); + } + } + + /** + * 验证零长度读取符合 InputStream 约定。 + * + * @throws IOException 读取失败时抛出 + */ + @Test + public void shouldAllowZeroLengthRead() throws IOException { + try (InputStream inputStream = DocumentInputStreamSupport.limit( + new ByteArrayInputStream(new byte[0]), + 1L)) { + Assert.assertEquals(0, inputStream.read(new byte[0])); + } + } +} diff --git a/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/easyagentsflow/service/TinyFlowServiceTest.java b/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/easyagentsflow/service/TinyFlowServiceTest.java index 61128426..740817c7 100644 --- a/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/easyagentsflow/service/TinyFlowServiceTest.java +++ b/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/easyagentsflow/service/TinyFlowServiceTest.java @@ -11,6 +11,7 @@ import org.junit.Assert; import org.junit.Test; import tech.easyflow.ai.easyagentsflow.entity.ChainInfo; import tech.easyflow.ai.easyagentsflow.entity.NodeInfo; +import tech.easyflow.common.web.exceptions.BusinessException; import java.lang.reflect.Field; import java.util.List; @@ -28,6 +29,36 @@ public class TinyFlowServiceTest { private static final String EXECUTE_ID = "execution-1"; private static final String NODE_ID = "node-1"; + /** + * 验证执行状态不存在或已过期时返回稳定的 HTTP 404 业务错误。 + * + * @throws Exception 测试依赖注入失败时抛出 + */ + @Test + public void shouldReturnNotFoundWhenChainStateMissing() + throws Exception { + ChainExecutor chainExecutor = mock(ChainExecutor.class); + ChainStateRepository chainStateRepository = + mock(ChainStateRepository.class); + NodeStateRepository nodeStateRepository = + mock(NodeStateRepository.class); + when(chainExecutor.getChainStateRepository()) + .thenReturn(chainStateRepository); + when(chainExecutor.getNodeStateRepository()) + .thenReturn(nodeStateRepository); + when(chainStateRepository.load(EXECUTE_ID)).thenReturn(null); + TinyFlowService service = service(chainExecutor); + + try { + service.getChainStatus(EXECUTE_ID, null); + Assert.fail("expected BusinessException"); + } catch (BusinessException error) { + Assert.assertEquals(404, error.getHttpStatus()); + Assert.assertEquals(404, error.getErrorCode()); + Assert.assertTrue(error.getMessage().contains("不存在或已过期")); + } + } + /** * 验证尚未启动的节点返回 READY,且一次轮询只读取一次工作流状态。 * diff --git a/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/service/impl/WorkflowApiPermissionServiceImplTest.java b/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/service/impl/WorkflowApiPermissionServiceImplTest.java new file mode 100644 index 00000000..2a63dc8e --- /dev/null +++ b/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/service/impl/WorkflowApiPermissionServiceImplTest.java @@ -0,0 +1,77 @@ +package tech.easyflow.ai.service.impl; + +import com.mybatisflex.core.query.QueryWrapper; +import org.junit.Assert; +import org.junit.Test; +import tech.easyflow.common.web.exceptions.BusinessException; +import tech.easyflow.system.entity.SysApiKey; +import tech.easyflow.system.entity.SysApiKeyResource; +import tech.easyflow.system.service.SysApiKeyResourceMappingService; +import tech.easyflow.system.service.SysApiKeyResourceService; +import tech.easyflow.system.service.SysApiKeyService; + +import java.lang.reflect.Field; +import java.math.BigInteger; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * {@link WorkflowApiPermissionServiceImpl} 权限错误语义测试。 + */ +public class WorkflowApiPermissionServiceImplTest { + + /** + * 验证未开启工作流 API 授权时返回 HTTP 403。 + * + * @throws Exception 测试依赖注入失败时抛出 + */ + @Test + public void shouldReturnForbiddenWhenWorkflowApiPermissionMissing() + throws Exception { + SysApiKeyService apiKeyService = mock(SysApiKeyService.class); + SysApiKeyResourceService resourceService = + mock(SysApiKeyResourceService.class); + SysApiKeyResourceMappingService mappingService = + mock(SysApiKeyResourceMappingService.class); + SysApiKey apiKey = new SysApiKey(); + apiKey.setId(BigInteger.ONE); + SysApiKeyResource resource = new SysApiKeyResource(); + resource.setId(BigInteger.TWO); + when(apiKeyService.getSysApiKey("test-key")).thenReturn(apiKey); + when(resourceService.getOne(any(QueryWrapper.class))) + .thenReturn(resource); + when(mappingService.count(any(QueryWrapper.class))).thenReturn(0L); + WorkflowApiPermissionServiceImpl service = + new WorkflowApiPermissionServiceImpl(); + setField(service, "sysApiKeyService", apiKeyService); + setField(service, "resourceService", resourceService); + setField(service, "mappingService", mappingService); + + try { + service.assertWorkflowApi( + "test-key", + "/public-api/workflow/runAsync"); + Assert.fail("expected BusinessException"); + } catch (BusinessException error) { + Assert.assertEquals(403, error.getHttpStatus()); + Assert.assertEquals(403, error.getErrorCode()); + } + } + + /** + * 注入测试依赖。 + * + * @param target 目标对象 + * @param name 字段名 + * @param value 字段值 + * @throws Exception 反射访问失败时抛出 + */ + private void setField(Object target, String name, Object value) + throws Exception { + Field field = target.getClass().getDeclaredField(name); + field.setAccessible(true); + field.set(target, value); + } +} diff --git a/easyflow-modules/easyflow-module-system/src/main/java/tech/easyflow/system/service/impl/SysApiKeyServiceImpl.java b/easyflow-modules/easyflow-module-system/src/main/java/tech/easyflow/system/service/impl/SysApiKeyServiceImpl.java index ecce5a26..aab62879 100644 --- a/easyflow-modules/easyflow-module-system/src/main/java/tech/easyflow/system/service/impl/SysApiKeyServiceImpl.java +++ b/easyflow-modules/easyflow-module-system/src/main/java/tech/easyflow/system/service/impl/SysApiKeyServiceImpl.java @@ -50,7 +50,7 @@ public class SysApiKeyServiceImpl extends ServiceImpl service.getSysApiKey("missing")); + + Assert.assertEquals(401, error.getHttpStatus()); + Assert.assertEquals(401, error.getErrorCode()); + } + + /** + * 验证状态缺失的 API Key 按禁用处理并返回 HTTP 401。 + */ + @Test + public void shouldReturnUnauthorizedWhenApiKeyStatusMissing() { + SysApiKey apiKey = new SysApiKey(); + TestSysApiKeyService service = new TestSysApiKeyService(apiKey); + + BusinessException error = expectBusinessException( + () -> service.getSysApiKey("status-missing")); + + Assert.assertEquals(401, error.getHttpStatus()); + Assert.assertEquals(401, error.getErrorCode()); + } + + /** + * 验证过期的 API Key 使用 HTTP 401 语义。 + */ + @Test + public void shouldReturnUnauthorizedWhenApiKeyExpired() { + SysApiKey apiKey = new SysApiKey(); + apiKey.setStatus(1); + apiKey.setExpiredAt(new Date(System.currentTimeMillis() - 1_000L)); + TestSysApiKeyService service = new TestSysApiKeyService(apiKey); + + BusinessException error = expectBusinessException( + () -> service.getSysApiKey("expired")); + + Assert.assertEquals(401, error.getHttpStatus()); + Assert.assertEquals(401, error.getErrorCode()); + } + + /** + * 执行调用并返回预期业务异常。 + * + * @param action 待执行调用 + * @return 捕获的业务异常 + */ + private BusinessException expectBusinessException(Runnable action) { + try { + action.run(); + Assert.fail("expected BusinessException"); + return null; + } catch (BusinessException error) { + return error; + } + } + + /** + * 固定返回 API Key 的测试服务。 + */ + private static final class TestSysApiKeyService + extends SysApiKeyServiceImpl { + + private final SysApiKey apiKey; + + /** + * 创建测试服务。 + * + * @param apiKey 查询时返回的 API Key + */ + private TestSysApiKeyService(SysApiKey apiKey) { + this.apiKey = apiKey; + } + + /** + * 返回预设 API Key。 + * + * @param queryWrapper 查询条件 + * @return 预设 API Key + */ + @Override + public SysApiKey getOne(QueryWrapper queryWrapper) { + return apiKey; + } + } +} diff --git a/easyflow-ui-admin/app/src/views/ai/workflow/WorkflowList.vue b/easyflow-ui-admin/app/src/views/ai/workflow/WorkflowList.vue index f7f3de0a..d93f34a3 100644 --- a/easyflow-ui-admin/app/src/views/ai/workflow/WorkflowList.vue +++ b/easyflow-ui-admin/app/src/views/ai/workflow/WorkflowList.vue @@ -649,42 +649,64 @@ const apiDocMarkdown = computed(() => { lines.push(``); lines.push(apiUrlLine('POST', `${baseUrl}/getChainStatus`)); lines.push(``); - lines.push(`查询工作流执行的整体状态与各节点执行详情。工作流为异步执行,建议**轮询**该接口直到状态为终态。`); + lines.push( + `查询工作流执行的整体状态与各节点执行详情。工作流为异步执行,建议**轮询**该接口直到状态为终态。`, + ); lines.push(``); lines.push(`### 请求体`); lines.push(``); lines.push('```json'); - lines.push(JSON.stringify({ executeId: '' }, null, 2)); + lines.push( + JSON.stringify( + { + executeId: '', + nodes: [{ nodeId: '<需要查询的节点 ID>' }], + }, + null, + 2, + ), + ); lines.push('```'); lines.push(``); - lines.push(`> \`nodes\` 参数可选。不传则只返回工作流整体状态;传入节点 ID 数组可额外获取对应节点的执行详情。`); + lines.push( + `> \`nodes\` 参数可选。不传则只返回工作流整体状态;需要节点详情时传入对象数组,每项至少包含 \`nodeId\`。`, + ); lines.push(``); lines.push(`### 响应示例`); lines.push(``); lines.push('```json'); - lines.push(JSON.stringify({ - errorCode: 0, - message: '成功', - data: { - executeId: 'abc5358c-a310-4caa-97ec-455062b2235e', - status: 'FINISHED', - message: null, - result: { output: '工作流执行结果' }, - nodes: {}, - }, - }, null, 2)); + lines.push( + JSON.stringify( + { + errorCode: 0, + message: '成功', + data: { + executeId: 'abc5358c-a310-4caa-97ec-455062b2235e', + status: 20, + message: null, + result: { output: '工作流执行结果' }, + nodes: {}, + }, + }, + null, + 2, + ), + ); lines.push('```'); lines.push(``); lines.push(`### 状态值说明`); lines.push(``); - lines.push(`| 状态 | 说明 |`); - lines.push(`| --- | --- |`); - lines.push(`| READY | 就绪,尚未开始 |`); - lines.push(`| RUNNING | 执行中 |`); - lines.push(`| SUSPEND | 挂起,等待确认节点恢复 |`); - lines.push(`| FINISHED | 执行完成 |`); - lines.push(`| FAILED | 执行失败 |`); - lines.push(`| ERROR | 执行异常 |`); + lines.push(`| 数值 | 状态 | 说明 |`); + lines.push(`| --- | --- | --- |`); + lines.push(`| 0 | READY | 就绪,尚未开始 |`); + lines.push(`| 1 | RUNNING | 执行中 |`); + lines.push(`| 5 | SUSPEND | 挂起,等待确认节点恢复 |`); + lines.push(`| 10 | ERROR | 执行异常,可能仍在重试 |`); + lines.push(`| 20 | SUCCEEDED | 执行成功,终态 |`); + lines.push(`| 21 | FAILED | 执行失败,终态 |`); + lines.push(`| 22 | CANCELLED | 已取消,终态 |`); + lines.push(``); + lines.push(`轮询可在状态值为 \`20\`、\`21\` 或 \`22\` 时结束。`); lines.push(``); // ---- 3. 恢复执行 ---- @@ -694,7 +716,9 @@ const apiDocMarkdown = computed(() => { lines.push(``); lines.push(apiUrlLine('POST', `${baseUrl}/resume`)); lines.push(``); - lines.push(`当工作流包含**确认节点**时,执行到该节点后状态变为 \`SUSPEND\`,需要调用此接口传入确认参数后恢复执行。若工作流不包含确认节点则无需调用。`); + lines.push( + `当工作流包含**确认节点**时,执行到该节点后状态变为 \`5(SUSPEND)\`,需要调用此接口传入确认参数后恢复执行。若工作流不包含确认节点则无需调用。`, + ); lines.push(``); lines.push(`### 请求体`); lines.push(``);