fix: 修复工作流公共 API 调用问题
- 限制远程文档仅访问公网地址并校验重定向目标 - 统一访问令牌 401/403 与过期执行状态 404 语义 - 校正节点查询参数和工作流状态文档
This commit is contained in:
@@ -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<String> 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]));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,且一次轮询只读取一次工作流状态。
|
||||
*
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user