feat(web-ai): support qwen image search safely

This commit is contained in:
2026-07-19 22:58:20 +08:00
parent a20ca50b87
commit 61bc7191d1
12 changed files with 472 additions and 43 deletions
@@ -10,6 +10,7 @@ public final class AihrCommunityPolicy {
private static final Set<String> MODERATOR_ROLES = Set.of(
"supervisor", "manager", "project_manager", "admin", "superadmin", "hr_operator"
);
private static final Set<String> WEB_SEARCH_PROVIDER_ROLES = Set.of("admin", "superadmin", "hr_operator");
private AihrCommunityPolicy() {
}
@@ -32,6 +33,15 @@ public final class AihrCommunityPolicy {
.anyMatch(MODERATOR_ROLES::contains);
}
public static boolean canManageWebSearchProviders(AihrKnowledgePrincipal principal) {
if (principal == null) {
return false;
}
return principal.roles().stream()
.map(role -> role == null ? "" : role.trim().toLowerCase(Locale.ROOT))
.anyMatch(WEB_SEARCH_PROVIDER_ROLES::contains);
}
public static String moderatedStatus(String decision) {
String normalized = decision == null ? "" : decision.trim().toLowerCase(Locale.ROOT);
return switch (normalized) {
@@ -6,11 +6,15 @@ import org.dromara.aihr.webai.AihrWebAiDto.QueryRequest;
import org.dromara.aihr.webai.AihrWebAiDto.QueryResponse;
import org.dromara.aihr.webai.AihrWebAiDto.CapabilitiesResponse;
import org.dromara.common.core.domain.R;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
@RestController
@RequiredArgsConstructor
@@ -29,4 +33,11 @@ public class AihrWebAiController {
public R<QueryResponse> query(@RequestBody QueryRequest request) {
return R.ok(webAiService.query(request));
}
@PostMapping(value = "/query-image", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public R<QueryResponse> queryImage(@RequestPart("file") MultipartFile file,
@RequestParam("question") String question,
@RequestParam("consent") Boolean consent) {
return R.ok(webAiService.queryImage(new QueryRequest(question, consent), file));
}
}
@@ -21,6 +21,7 @@ public final class AihrWebAiDto {
}
public record CapabilitiesResponse(boolean enabled,
boolean supportsImage,
String provider,
boolean requiresConsent,
int maxQuestionLength,
@@ -0,0 +1,148 @@
package org.dromara.aihr.webai;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ReadListener;
import jakarta.servlet.ServletException;
import jakarta.servlet.ServletInputStream;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletRequestWrapper;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
/** Limits this high-cost multipart route before Spring can spool the upload to disk. */
@Component
@Order(Ordered.HIGHEST_PRECEDENCE + 20)
public class AihrWebAiImageUploadLimitFilter extends OncePerRequestFilter {
static final long MAX_FILE_BYTES = 4L * 1024 * 1024;
private static final long MAX_REQUEST_BYTES = MAX_FILE_BYTES + 64L * 1024;
private static final String ROUTE = "/api/aihr/web-ai/query-image";
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
return !"POST".equalsIgnoreCase(request.getMethod()) || !request.getRequestURI().endsWith(ROUTE);
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
if (request.getContentLengthLong() > MAX_REQUEST_BYTES) {
tooLarge(response);
return;
}
try {
filterChain.doFilter(new LimitedRequest(request, MAX_REQUEST_BYTES), response);
} catch (ServletException | RuntimeException error) {
if (causedByLimit(error)) {
tooLarge(response);
return;
}
throw error;
} catch (IOException error) {
if (causedByLimit(error)) {
tooLarge(response);
return;
}
throw error;
}
}
private static boolean causedByLimit(Throwable error) {
Throwable current = error;
while (current != null) {
if (current instanceof RequestTooLargeException) {
return true;
}
current = current.getCause();
}
return false;
}
private static void tooLarge(HttpServletResponse response) throws IOException {
if (response.isCommitted()) {
return;
}
response.resetBuffer();
response.setStatus(HttpServletResponse.SC_REQUEST_ENTITY_TOO_LARGE);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"code\":413,\"msg\":\"图片不能超过 4MB\"}");
}
private static final class LimitedRequest extends HttpServletRequestWrapper {
private final long maxBytes;
private LimitedRequest(HttpServletRequest request, long maxBytes) {
super(request);
this.maxBytes = maxBytes;
}
@Override
public ServletInputStream getInputStream() throws IOException {
return new LimitedInputStream(super.getInputStream(), maxBytes);
}
@Override
public BufferedReader getReader() throws IOException {
String charset = getCharacterEncoding();
return new BufferedReader(new InputStreamReader(getInputStream(), charset == null ? StandardCharsets.UTF_8 : java.nio.charset.Charset.forName(charset)));
}
}
private static final class LimitedInputStream extends ServletInputStream {
private final ServletInputStream delegate;
private final long maxBytes;
private long bytesRead;
private LimitedInputStream(ServletInputStream delegate, long maxBytes) {
this.delegate = delegate;
this.maxBytes = maxBytes;
}
@Override
public int read() throws IOException {
int value = delegate.read();
if (value >= 0) count(1);
return value;
}
@Override
public int read(byte[] bytes, int offset, int length) throws IOException {
int read = delegate.read(bytes, offset, length);
if (read > 0) count(read);
return read;
}
@Override
public boolean isFinished() {
return delegate.isFinished();
}
@Override
public boolean isReady() {
return delegate.isReady();
}
@Override
public void setReadListener(ReadListener readListener) {
delegate.setReadListener(readListener);
}
private void count(int amount) throws RequestTooLargeException {
bytesRead += amount;
if (bytesRead > maxBytes) {
throw new RequestTooLargeException();
}
}
}
private static final class RequestTooLargeException extends IOException {
}
}
@@ -7,8 +7,12 @@ import org.dromara.aihr.knowledge.service.AihrKnowledgePrincipalResolver;
import org.dromara.aihr.webai.AihrWebAiDto.QueryRequest;
import org.dromara.aihr.webai.AihrWebAiDto.QueryResponse;
import org.dromara.aihr.webai.AihrWebAiDto.CapabilitiesResponse;
import org.dromara.common.core.exception.ServiceException;
import org.dromara.common.redis.utils.RedisUtils;
import org.redisson.api.RateType;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import java.util.List;
@@ -25,6 +29,14 @@ public class AihrWebAiService {
private volatile boolean auditTableReady;
public QueryResponse query(QueryRequest request) {
return query(request, null);
}
public QueryResponse queryImage(QueryRequest request, MultipartFile image) {
return query(request, image);
}
private QueryResponse query(QueryRequest request, MultipartFile image) {
AihrKnowledgePrincipal principal = principalResolver.current();
String rawQuestion = normalizeQuestion(request == null ? null : request.question());
String question = AihrWebPrivacy.forExternalSearch(rawQuestion);
@@ -32,33 +44,50 @@ public class AihrWebAiService {
AihrWebSearchProviderService.RuntimeProvider runtime = searchClient.runtime(principal);
String provider = runtime == null ? clean(properties.getProvider(), "tavily") : runtime.name();
if (request == null || !Boolean.TRUE.equals(request.consent())) {
audit(principal, question, provider, "CONSENT_REQUIRED", 0);
audit(principal, question, provider, auditStatus(image, "CONSENT_REQUIRED"), 0);
return new QueryResponse("web", "CONSENT_REQUIRED", "", List.of(), provider,
"请确认同意将脱敏后的问题发送到外部检索服务", redacted, true);
}
if (runtime == null) {
audit(principal, question, provider, "DISABLED", 0);
audit(principal, question, provider, auditStatus(image, "DISABLED"), 0);
return new QueryResponse("web", "DISABLED", "", List.of(), provider,
"全网检索默认关闭,请由管理员配置合规的外部检索服务后启用", redacted, true);
}
if (image != null && !"qwen".equalsIgnoreCase(runtime.code())) {
audit(principal, question, provider, "IMAGE_UNSUPPORTED", 0);
return new QueryResponse("web", "IMAGE_UNSUPPORTED", "", List.of(), provider,
"当前全网检索提供方不支持图片识图,请切换至千问联网检索", redacted, true);
}
if (image != null) {
enforceImageRateLimit(principal);
}
AihrWebSearchClient.ImageInput imageInput;
try {
AihrWebSearchClient.SearchResult result = searchClient.search(runtime, question);
imageInput = image == null ? null : searchClient.imageInput(image);
} catch (RuntimeException error) {
audit(principal, question, provider, "IMAGE_REJECTED", 0);
throw error;
}
try {
AihrWebSearchClient.SearchResult result = imageInput == null
? searchClient.search(runtime, question)
: searchClient.searchWithImage(runtime, question, imageInput);
if (result.sources().isEmpty()) {
audit(principal, question, provider, "NO_SOURCES", 0);
audit(principal, question, provider, auditStatus(image, "NO_SOURCES"), 0);
return new QueryResponse("web", "NO_SOURCES", "", List.of(), provider,
"没有找到可公开核验的来源,本次不生成答案", redacted, true);
}
if (result.answer() == null || result.answer().isBlank()) {
audit(principal, question, provider, "NO_ANSWER", result.sources().size());
audit(principal, question, provider, auditStatus(image, "NO_ANSWER"), result.sources().size());
return new QueryResponse("web", "NO_ANSWER", "", result.sources(), provider,
"已找到公开来源,但外部服务未返回可核验答案;请直接查看来源", redacted, true);
}
audit(principal, question, provider, "OK", result.sources().size());
audit(principal, question, provider, auditStatus(image, "OK"), result.sources().size());
return new QueryResponse("web", "OK", result.answer(), result.sources(), provider,
"回答来自全网公开资料,不代表企业内部 SOP;请核对下方来源", redacted, true);
} catch (RuntimeException error) {
log.warn("web AI query failed provider={} (details hidden)", provider);
audit(principal, question, provider, "FAILED", 0);
audit(principal, question, provider, auditStatus(image, "FAILED"), 0);
return new QueryResponse("web", "FAILED", "", List.of(), provider,
"全网检索暂时不可用,本次未生成答案", redacted, true);
}
@@ -67,7 +96,7 @@ public class AihrWebAiService {
public CapabilitiesResponse capabilities() {
AihrKnowledgePrincipal principal = principalResolver.current();
AihrWebSearchProviderService.RuntimeProvider runtime = searchClient.runtime(principal);
return new CapabilitiesResponse(runtime != null,
return new CapabilitiesResponse(runtime != null, runtime != null && "qwen".equalsIgnoreCase(runtime.code()),
runtime == null ? clean(properties.getProvider(), "tavily") : runtime.name(), true, 500,
runtime == null
? "全网检索未配置,企业内部问师傅不受影响"
@@ -134,4 +163,21 @@ public class AihrWebAiService {
String provider = clean(value, "unknown");
return provider.length() <= 40 ? provider : provider.substring(0, 40);
}
private static String auditStatus(MultipartFile image, String status) {
return image == null ? status : "IMAGE_" + status;
}
private static void enforceImageRateLimit(AihrKnowledgePrincipal principal) {
try {
String key = "web-ai:image:" + principal.tenantId() + ":" + principal.userId();
if (RedisUtils.rateLimiter(key, RateType.OVERALL, 6, 60) < 0) {
throw new ServiceException("图片联网识图请求过于频繁,请稍后重试", 429);
}
} catch (ServiceException error) {
throw error;
} catch (RuntimeException error) {
throw new ServiceException("图片联网识图限流服务暂不可用", 503);
}
}
}
@@ -8,8 +8,17 @@ import org.dromara.aihr.knowledge.domain.AihrKnowledgePrincipal;
import org.dromara.aihr.webai.AihrWebSearchProviderService.RuntimeProvider;
import org.dromara.common.core.exception.ServiceException;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import javax.imageio.ImageIO;
import java.awt.Color;
import java.awt.Graphics2D;
import java.awt.RenderingHints;
import java.awt.image.BufferedImage;
import java.io.BufferedReader;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.StringReader;
import java.net.Inet4Address;
import java.net.Inet6Address;
@@ -20,6 +29,7 @@ import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Base64;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
@@ -34,6 +44,9 @@ public class AihrWebSearchClient {
private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(5);
private static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(12);
private static final Duration QWEN_REQUEST_TIMEOUT = Duration.ofSeconds(40);
private static final long QWEN_IMAGE_MAX_BYTES = 4L * 1024 * 1024;
private static final long QWEN_IMAGE_MAX_PIXELS = 12_000_000L;
private static final int QWEN_IMAGE_MAX_EDGE = 2048;
private static final Pattern PUBLIC_URL = Pattern.compile("https?://[^\\s\\]\\)\\\"'<>]+");
private final AihrWebAiProperties properties;
@@ -51,7 +64,7 @@ public class AihrWebSearchClient {
SearchResult search(RuntimeProvider runtime, String question) {
String endpoint = clean(runtime.endpoint());
String apiKey = clean(runtime.apiKey());
if (!isPublicHttpsUrl(endpoint)) {
if (!AihrWebSearchProviderService.isAllowedProviderEndpoint(runtime.code(), endpoint)) {
throw new ServiceException("全网检索地址未配置或不安全");
}
if (apiKey.isBlank()) {
@@ -67,6 +80,21 @@ public class AihrWebSearchClient {
return searchTavily(runtime, question);
}
SearchResult searchWithImage(RuntimeProvider runtime, String question, ImageInput image) {
String endpoint = clean(runtime.endpoint());
String apiKey = clean(runtime.apiKey());
if (!AihrWebSearchProviderService.isAllowedProviderEndpoint(runtime.code(), endpoint)) {
throw new ServiceException("全网检索地址未配置或不安全");
}
if (apiKey.isBlank()) {
throw new ServiceException("全网检索密钥未配置");
}
if (!"qwen".equalsIgnoreCase(clean(runtime.code()))) {
throw new ServiceException("当前全网检索提供方不支持图片识图");
}
return searchQwen(runtime, question, image);
}
private SearchResult searchTavily(RuntimeProvider runtime, String question) {
try {
ObjectNode body = objectMapper.createObjectNode();
@@ -101,6 +129,10 @@ public class AihrWebSearchClient {
}
private SearchResult searchQwen(RuntimeProvider runtime, String question) {
return searchQwen(runtime, question, null);
}
private SearchResult searchQwen(RuntimeProvider runtime, String question, ImageInput image) {
String agentId = clean(runtime.agentId());
String agentVersion = clean(runtime.agentVersion());
if (agentId.isBlank() || agentVersion.isBlank()) {
@@ -111,7 +143,15 @@ public class AihrWebSearchClient {
ObjectNode input = body.putObject("input");
ObjectNode message = input.putArray("messages").addObject();
message.put("role", "user");
message.put("content", question);
if (image == null) {
message.put("content", question);
} else {
var content = message.putArray("content");
content.addObject().put("type", "image_url")
.putObject("image_url")
.put("url", "data:" + image.mimeType() + ";base64," + Base64.getEncoder().encodeToString(image.bytes()));
content.addObject().put("type", "text").put("text", question);
}
ObjectNode options = body.putObject("parameters").putObject("agent_options");
options.put("agent_id", agentId);
options.put("agent_version", agentVersion);
@@ -162,6 +202,49 @@ public class AihrWebSearchClient {
clean(properties.getEndpoint()), clean(properties.getApiKey()), "", "", true, true, null);
}
static ImageInput imageInput(MultipartFile file) {
if (file == null || file.isEmpty()) {
throw new ServiceException("请选择图片");
}
if (file.getSize() > QWEN_IMAGE_MAX_BYTES) {
throw new ServiceException("图片不能超过 4MB");
}
try {
BufferedImage source = ImageIO.read(new ByteArrayInputStream(file.getBytes()));
if (source == null || source.getWidth() <= 0 || source.getHeight() <= 0) {
throw new ServiceException("仅支持 JPG、PNG 或 BMP 图片");
}
if ((long) source.getWidth() * source.getHeight() > QWEN_IMAGE_MAX_PIXELS) {
throw new ServiceException("图片像素不能超过1200万");
}
BufferedImage normalized = normalizeImage(source);
ByteArrayOutputStream output = new ByteArrayOutputStream();
if (!ImageIO.write(normalized, "jpg", output) || output.size() > QWEN_IMAGE_MAX_BYTES) {
throw new ServiceException("图片内容过于复杂,请压缩后重试");
}
return new ImageInput(output.toByteArray(), "image/jpeg");
} catch (IOException error) {
throw new ServiceException("图片读取失败,请重新选择");
}
}
private static BufferedImage normalizeImage(BufferedImage source) {
double scale = Math.min(1d, QWEN_IMAGE_MAX_EDGE / (double) Math.max(source.getWidth(), source.getHeight()));
int width = Math.max(1, (int) Math.round(source.getWidth() * scale));
int height = Math.max(1, (int) Math.round(source.getHeight() * scale));
BufferedImage target = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
Graphics2D graphics = target.createGraphics();
try {
graphics.setColor(Color.WHITE);
graphics.fillRect(0, 0, width, height);
graphics.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BICUBIC);
graphics.drawImage(source, 0, 0, width, height, null);
return target;
} finally {
graphics.dispose();
}
}
static SearchResult parseTavilyResponse(ObjectMapper mapper, String body, int limit) throws Exception {
JsonNode root = mapper.readTree(body == null ? "{}" : body);
List<WebSource> sources = new ArrayList<>();
@@ -374,4 +457,7 @@ public class AihrWebSearchClient {
public record SearchResult(String answer, List<WebSource> sources) {
}
record ImageInput(byte[] bytes, String mimeType) {
}
}
@@ -70,8 +70,8 @@ public class AihrWebSearchProviderAdminController {
private AihrKnowledgePrincipal requireModerator() {
AihrKnowledgePrincipal principal = principalResolver.current();
if (!AihrCommunityPolicy.canModerate(principal)) {
throw new ServiceException("仅主管或管理员可管理全网检索提供方", 403);
if (!AihrCommunityPolicy.canManageWebSearchProviders(principal)) {
throw new ServiceException("仅系统管理员可管理全网检索提供方", 403);
}
return principal;
}
@@ -12,6 +12,7 @@ import org.springframework.transaction.annotation.Transactional;
import java.sql.PreparedStatement;
import java.sql.Statement;
import java.net.URI;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Locale;
@@ -88,7 +89,7 @@ public class AihrWebSearchProviderService {
ensureTable();
if (enabled) {
RuntimeProvider selected = provider(principal, id);
if (!AihrWebSearchClient.isPublicHttpsUrl(selected.endpoint()) || !hasText(selected.apiKey())
if (!isAllowedProviderEndpoint(selected.code(), selected.endpoint()) || !hasText(selected.apiKey())
|| ("qwen".equals(selected.code())
&& (!hasText(selected.agentId()) || !hasText(selected.agentVersion())))) {
throw new IllegalArgumentException("请先配置安全的公开接口和访问密钥");
@@ -185,8 +186,8 @@ public class AihrWebSearchProviderService {
if (!Set.of("tavily", "qwen").contains(code)) {
throw new IllegalArgumentException("当前仅支持 Tavily 或千问联网检索");
}
if (!AihrWebSearchClient.isPublicHttpsUrl(endpoint)) {
throw new IllegalArgumentException("检索地址必须是可公开访问的 HTTPS 地址");
if (!isAllowedProviderEndpoint(code, endpoint)) {
throw new IllegalArgumentException("检索地址与提供方不匹配或不受信任");
}
String apiKey = clean(request.apiKey());
if (!idRequired && apiKey.isBlank()) {
@@ -242,6 +243,29 @@ public class AihrWebSearchProviderService {
return value != null && !value.isBlank();
}
static boolean isAllowedProviderEndpoint(String providerCode, String endpoint) {
String code = clean(providerCode).toLowerCase(Locale.ROOT);
if (!AihrWebSearchClient.isPublicHttpsUrl(endpoint)) {
return false;
}
try {
URI uri = URI.create(endpoint.trim());
String host = uri.getHost() == null ? "" : uri.getHost().toLowerCase(Locale.ROOT);
String path = uri.getPath() == null ? "" : uri.getPath();
return switch (code) {
case "qwen" -> "dashscope.aliyuncs.com".equals(host)
&& "/api/v2/apps/web-search-agent/chat/completions".equals(path)
&& uri.getQuery() == null;
case "tavily" -> "api.tavily.com".equals(host)
&& "/search".equals(path)
&& uri.getQuery() == null;
default -> false;
};
} catch (IllegalArgumentException error) {
return false;
}
}
private static String clean(String value) {
return value == null ? "" : value.trim();
}
@@ -13,7 +13,11 @@ import org.dromara.common.core.exception.ServiceException;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.mock.web.MockMultipartFile;
import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDateTime;
@@ -214,6 +218,34 @@ class AihrWebAiSecurityTest {
assertTrue(safe.contains("已脱敏"));
}
@Test
@Tag("dev")
void qwenImageInputReencodesOnlyDecodableImages() throws Exception {
BufferedImage source = new BufferedImage(1, 1, BufferedImage.TYPE_INT_ARGB);
ByteArrayOutputStream output = new ByteArrayOutputStream();
ImageIO.write(source, "png", output);
var png = new MockMultipartFile("file", "photo.png", "image/png", output.toByteArray());
AihrWebSearchClient.ImageInput image = AihrWebSearchClient.imageInput(png);
assertEquals("image/jpeg", image.mimeType());
assertTrue(image.bytes().length > 3);
assertEquals((byte) 0xff, image.bytes()[0]);
assertEquals((byte) 0xd8, image.bytes()[1]);
assertThrows(ServiceException.class, () -> AihrWebSearchClient.imageInput(
new MockMultipartFile("file", "not-an-image.jpg", "image/jpeg",
new byte[]{(byte) 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a})
));
}
@Test
@Tag("dev")
void providersCannotSendSecretsToArbitraryPublicHosts() {
assertTrue(AihrWebSearchProviderService.isAllowedProviderEndpoint("qwen",
"https://dashscope.aliyuncs.com/api/v2/apps/web-search-agent/chat/completions"));
assertFalse(AihrWebSearchProviderService.isAllowedProviderEndpoint("qwen", "https://example.com/collect"));
assertFalse(AihrWebSearchProviderService.isAllowedProviderEndpoint("tavily", "https://api.tavily.com/other"));
}
@Test
@Tag("dev")
void databaseProviderSecretUsesAuthenticatedEncryption() {