diff --git a/server/src/main/java/com/rocketmq/studio/settings/DataSourceTestDTO.java b/server/src/main/java/com/rocketmq/studio/settings/DataSourceTestDTO.java index b17b8dce..728b1f5b 100644 --- a/server/src/main/java/com/rocketmq/studio/settings/DataSourceTestDTO.java +++ b/server/src/main/java/com/rocketmq/studio/settings/DataSourceTestDTO.java @@ -16,6 +16,7 @@ */ package com.rocketmq.studio.settings; +import jakarta.validation.constraints.NotBlank; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -26,7 +27,12 @@ @NoArgsConstructor @AllArgsConstructor public class DataSourceTestDTO { + @NotBlank(message = "url is required") private String url; + @NotBlank(message = "type is required") private String type; private String auth; + private String username; + private String password; + private String bearerToken; } diff --git a/server/src/main/java/com/rocketmq/studio/settings/DataSourceVO.java b/server/src/main/java/com/rocketmq/studio/settings/DataSourceVO.java index c2d1164a..45a14d0c 100644 --- a/server/src/main/java/com/rocketmq/studio/settings/DataSourceVO.java +++ b/server/src/main/java/com/rocketmq/studio/settings/DataSourceVO.java @@ -16,6 +16,7 @@ */ package com.rocketmq.studio.settings; +import jakarta.validation.constraints.NotBlank; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -27,8 +28,11 @@ @AllArgsConstructor public class DataSourceVO { private String key; + @NotBlank(message = "name is required") private String name; + @NotBlank(message = "type is required") private String type; + @NotBlank(message = "url is required") private String url; private String auth; private String status; diff --git a/server/src/main/java/com/rocketmq/studio/settings/SettingsController.java b/server/src/main/java/com/rocketmq/studio/settings/SettingsController.java index a84923de..594487ed 100644 --- a/server/src/main/java/com/rocketmq/studio/settings/SettingsController.java +++ b/server/src/main/java/com/rocketmq/studio/settings/SettingsController.java @@ -52,12 +52,12 @@ public Result> listDataSources() { } @PostMapping("/datasources/create") - public Result createDataSource(@RequestBody DataSourceVO dataSource) { + public Result createDataSource(@Valid @RequestBody DataSourceVO dataSource) { return Result.ok(settingsService.createDataSource(dataSource)); } @PostMapping("/datasources/update") - public Result updateDataSource(@RequestBody DataSourceVO dataSource) { + public Result updateDataSource(@Valid @RequestBody DataSourceVO dataSource) { return Result.ok(settingsService.updateDataSource(dataSource)); } @@ -68,7 +68,7 @@ public Result deleteDataSource(@RequestParam String key) { } @PostMapping("/datasources/test") - public Result testDataSource(@RequestBody DataSourceTestDTO request) { + public Result testDataSource(@Valid @RequestBody DataSourceTestDTO request) { return Result.ok(settingsService.testDataSource(request)); } } diff --git a/server/src/main/java/com/rocketmq/studio/settings/SettingsService.java b/server/src/main/java/com/rocketmq/studio/settings/SettingsService.java index 2c2db875..53643c9e 100644 --- a/server/src/main/java/com/rocketmq/studio/settings/SettingsService.java +++ b/server/src/main/java/com/rocketmq/studio/settings/SettingsService.java @@ -16,20 +16,55 @@ */ package com.rocketmq.studio.settings; -import lombok.RequiredArgsConstructor; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.client.SimpleClientHttpRequestFactory; import org.springframework.stereotype.Service; import org.springframework.util.StringUtils; - +import org.springframework.web.client.ResourceAccessException; +import org.springframework.web.client.RestClient; +import org.springframework.web.client.RestClientException; +import org.springframework.web.client.RestClientResponseException; +import org.springframework.web.util.UriComponentsBuilder; + +import java.io.IOException; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.SocketTimeoutException; +import java.time.Duration; import java.util.List; +import java.util.Set; import java.util.UUID; @Slf4j @Service -@RequiredArgsConstructor public class SettingsService { + private static final Set PROMETHEUS_COMPATIBLE_TYPES = Set.of( + "prometheus", "victoriametrics", "thanos", "mimir"); + private static final String PROMETHEUS_TEST_QUERY = "up"; + private static final String AUTH_NONE = "none"; + private static final String AUTH_BASIC = "basic auth"; + private static final String AUTH_BEARER = "bearer token"; + private static final Duration DATA_SOURCE_TEST_CONNECT_TIMEOUT = Duration.ofSeconds(3); + private static final Duration DATA_SOURCE_TEST_READ_TIMEOUT = Duration.ofSeconds(5); + private final SettingsRepository settingsRepository; + private final RestClient restClient; + private final ObjectMapper objectMapper; + + public SettingsService(SettingsRepository settingsRepository, RestClient.Builder restClientBuilder, + ObjectMapper objectMapper) { + SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory(); + requestFactory.setConnectTimeout(DATA_SOURCE_TEST_CONNECT_TIMEOUT); + requestFactory.setReadTimeout(DATA_SOURCE_TEST_READ_TIMEOUT); + this.settingsRepository = settingsRepository; + this.restClient = restClientBuilder.requestFactory(requestFactory).build(); + this.objectMapper = objectMapper; + } public GeneralSettingsVO getGeneralSettings() { @@ -77,11 +112,136 @@ public void deleteDataSource(String key) { public DataSourceTestResultVO testDataSource(DataSourceTestDTO request) { - log.info("Testing data source connection: url={}, type={}", request.getUrl(), request.getType()); - // Stub: always return success for now + log.info("Testing data source connection: type={}", request == null ? null : request.getType()); + if (request == null) { + return failed("Data source test request is required"); + } + if (!isPrometheusCompatible(request.getType())) { + return failed("Unsupported data source type: " + request.getType()); + } + + try { + JsonNode response = restClient.get() + .uri(prometheusQueryUri(request.getUrl())) + .accept(MediaType.APPLICATION_JSON) + .headers(headers -> applyAuthentication(headers, request)) + .retrieve() + .body(JsonNode.class); + return prometheusSuccess(response); + } catch (IllegalArgumentException | URISyntaxException exception) { + return failed(exception.getMessage()); + } catch (RestClientResponseException exception) { + return failed(prometheusErrorMessage(exception)); + } catch (ResourceAccessException exception) { + if (hasCause(exception, SocketTimeoutException.class)) { + return failed("Prometheus connection timed out"); + } + return failed("Failed to connect to Prometheus"); + } catch (RestClientException exception) { + if (hasCause(exception, SocketTimeoutException.class)) { + return failed("Prometheus connection timed out"); + } + return failed("Prometheus connection failed"); + } + } + + private boolean isPrometheusCompatible(String type) { + return StringUtils.hasText(type) + && PROMETHEUS_COMPATIBLE_TYPES.contains(type.replaceAll("\\s+", "").toLowerCase()); + } + + private void applyAuthentication(HttpHeaders headers, DataSourceTestDTO request) { + String auth = normalizeAuth(request.getAuth()); + if (AUTH_NONE.equals(auth)) { + return; + } + if (AUTH_BASIC.equals(auth)) { + if (!StringUtils.hasText(request.getUsername()) || !StringUtils.hasText(request.getPassword())) { + throw new IllegalArgumentException("Basic authentication requires username and password"); + } + headers.setBasicAuth(request.getUsername().trim(), request.getPassword()); + return; + } + if (AUTH_BEARER.equals(auth)) { + if (!StringUtils.hasText(request.getBearerToken())) { + throw new IllegalArgumentException("Bearer authentication requires token"); + } + headers.setBearerAuth(request.getBearerToken().trim()); + return; + } + throw new IllegalArgumentException("Unsupported data source authentication: " + request.getAuth()); + } + + private String normalizeAuth(String auth) { + if (!StringUtils.hasText(auth)) { + return AUTH_NONE; + } + return auth.trim().replaceAll("\\s+", " ").toLowerCase(); + } + + private URI prometheusQueryUri(String baseUrl) throws URISyntaxException { + if (!StringUtils.hasText(baseUrl)) { + throw new IllegalArgumentException("Data source URL is required"); + } + String normalized = baseUrl.strip(); + while (normalized.endsWith("/")) { + normalized = normalized.substring(0, normalized.length() - 1); + } + + URI baseUri = new URI(normalized); + if (!"http".equalsIgnoreCase(baseUri.getScheme()) && !"https".equalsIgnoreCase(baseUri.getScheme())) { + throw new IllegalArgumentException("Data source URL must start with http:// or https://"); + } + return UriComponentsBuilder.fromUriString(normalized + "/api/v1/query") + .queryParam("query", PROMETHEUS_TEST_QUERY) + .build() + .toUri(); + } + + private DataSourceTestResultVO prometheusSuccess(JsonNode response) { + if (response != null && "success".equals(response.path("status").asText())) { + return DataSourceTestResultVO.builder() + .success(true) + .message("Connection successful") + .build(); + } + return failed(prometheusBodyError(response)); + } + + private String prometheusErrorMessage(RestClientResponseException exception) { + try { + return prometheusBodyError(objectMapper.readTree(exception.getResponseBodyAsString())); + } catch (IOException ignored) { + return "Prometheus query failed"; + } + } + + private String prometheusBodyError(JsonNode response) { + String errorType = response == null ? "" : response.path("errorType").asText(); + String error = response == null ? "" : response.path("error").asText(); + if (StringUtils.hasText(error)) { + return StringUtils.hasText(errorType) + ? "Prometheus query failed (" + errorType + "): " + error + : "Prometheus query failed: " + error; + } + return "Prometheus query failed"; + } + + private DataSourceTestResultVO failed(String message) { return DataSourceTestResultVO.builder() - .success(true) - .message("Connection successful") + .success(false) + .message(message) .build(); } + + private boolean hasCause(Throwable throwable, Class causeType) { + Throwable current = throwable; + while (current != null) { + if (causeType.isInstance(current)) { + return true; + } + current = current.getCause(); + } + return false; + } } diff --git a/server/src/test/java/com/rocketmq/studio/settings/SettingsControllerTest.java b/server/src/test/java/com/rocketmq/studio/settings/SettingsControllerTest.java index f82bb1ad..1a90e821 100644 --- a/server/src/test/java/com/rocketmq/studio/settings/SettingsControllerTest.java +++ b/server/src/test/java/com/rocketmq/studio/settings/SettingsControllerTest.java @@ -203,6 +203,23 @@ void createDataSourceShouldReturnCreatedSource() throws Exception { .andExpect(jsonPath("$.data.status", is("connected"))); } + @Test + void createDataSourceShouldRejectMissingUrl() throws Exception { + mockMvc.perform(post("/api/settings/datasources/create") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "name": "New DS", + "type": "prometheus" + } + """)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code", is(400))) + .andExpect(jsonPath("$.message", is("url is required"))); + + verifyNoInteractions(settingsService); + } + @Test void updateDataSourceShouldReturnUpdatedSource() throws Exception { DataSourceVO input = DataSourceVO.builder().key("ds-1").name("Updated DS").type("rocketmq") @@ -218,6 +235,24 @@ void updateDataSourceShouldReturnUpdatedSource() throws Exception { .andExpect(jsonPath("$.data.name", is("Updated DS"))); } + @Test + void updateDataSourceShouldRejectMissingName() throws Exception { + mockMvc.perform(post("/api/settings/datasources/update") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "key": "ds-1", + "type": "rocketmq", + "url": "updated:9876" + } + """)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code", is(400))) + .andExpect(jsonPath("$.message", is("name is required"))); + + verifyNoInteractions(settingsService); + } + @Test void deleteDataSourceShouldReturnSuccess() throws Exception { doNothing().when(settingsService).deleteDataSource("ds-1"); @@ -250,4 +285,20 @@ void testDataSourceShouldReturnTestResult() throws Exception { .andExpect(jsonPath("$.data.success", is(true))) .andExpect(jsonPath("$.data.message", is("Connection successful"))); } + + @Test + void testDataSourceShouldRejectMissingType() throws Exception { + mockMvc.perform(post("/api/settings/datasources/test") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "url": "localhost:9876" + } + """)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code", is(400))) + .andExpect(jsonPath("$.message", is("type is required"))); + + verifyNoInteractions(settingsService); + } } diff --git a/server/src/test/java/com/rocketmq/studio/settings/SettingsServiceTest.java b/server/src/test/java/com/rocketmq/studio/settings/SettingsServiceTest.java index 024a9a75..6328958e 100644 --- a/server/src/test/java/com/rocketmq/studio/settings/SettingsServiceTest.java +++ b/server/src/test/java/com/rocketmq/studio/settings/SettingsServiceTest.java @@ -16,15 +16,25 @@ */ package com.rocketmq.studio.settings; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.web.client.RestClient; +import java.io.IOException; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; import java.util.Arrays; +import java.util.Base64; import java.util.Collections; import java.util.List; +import java.util.concurrent.atomic.AtomicReference; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; @@ -38,9 +48,24 @@ class SettingsServiceTest { @Mock private SettingsRepository settingsRepository; - @InjectMocks private SettingsService settingsService; + private HttpServer prometheusServer; + private String prometheusBaseUrl; + + @BeforeEach + void setUp() throws IOException { + settingsService = new SettingsService(settingsRepository, RestClient.builder(), new ObjectMapper()); + prometheusServer = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + prometheusBaseUrl = "http://127.0.0.1:" + prometheusServer.getAddress().getPort(); + prometheusServer.start(); + } + + @AfterEach + void tearDown() { + prometheusServer.stop(0); + } + @Test void getGeneralSettingsShouldReturnCurrentSettings() { GeneralSettingsVO settings = GeneralSettingsVO.builder() @@ -207,29 +232,139 @@ void deleteDataSourceShouldDelegateToRepository() { } @Test - void testConnectionShouldReturnSuccess() { + void testConnectionShouldQueryPrometheusEndpoint() { + AtomicReference requestPath = new AtomicReference<>(); + AtomicReference requestQuery = new AtomicReference<>(); + prometheusServer.createContext("/api/v1/query", exchange -> { + requestPath.set(exchange.getRequestURI().getPath()); + requestQuery.set(exchange.getRequestURI().getRawQuery()); + respond(exchange, 200, "{\"status\":\"success\",\"data\":{\"resultType\":\"vector\",\"result\":[]}}"); + }); DataSourceTestDTO request = DataSourceTestDTO.builder() - .url("localhost:9876") - .type("rocketmq") + .url(prometheusBaseUrl) + .type("Prometheus") .build(); DataSourceTestResultVO result = settingsService.testDataSource(request); assertThat(result.isSuccess()).isTrue(); assertThat(result.getMessage()).isEqualTo("Connection successful"); + assertThat(requestPath.get()).isEqualTo("/api/v1/query"); + assertThat(requestQuery.get()).isEqualTo("query=up"); } @Test - void testConnectionShouldReturnSuccessForAnyInput() { + void testConnectionShouldApplyBasicAuthentication() { + AtomicReference authorization = new AtomicReference<>(); + prometheusServer.createContext("/api/v1/query", exchange -> { + authorization.set(exchange.getRequestHeaders().getFirst("Authorization")); + respond(exchange, 200, "{\"status\":\"success\",\"data\":{\"resultType\":\"vector\",\"result\":[]}}"); + }); DataSourceTestDTO request = DataSourceTestDTO.builder() - .url("invalid-host:9999") - .type("unknown") - .auth("bad-auth") + .url(prometheusBaseUrl) + .type("Prometheus") + .auth("Basic Auth") + .username("prom") + .password("secret") .build(); DataSourceTestResultVO result = settingsService.testDataSource(request); assertThat(result.isSuccess()).isTrue(); - assertThat(result.getMessage()).isEqualTo("Connection successful"); + assertThat(authorization.get()).isEqualTo("Basic " + + Base64.getEncoder().encodeToString("prom:secret".getBytes(StandardCharsets.UTF_8))); + } + + @Test + void testConnectionShouldApplyBearerAuthentication() { + AtomicReference authorization = new AtomicReference<>(); + prometheusServer.createContext("/api/v1/query", exchange -> { + authorization.set(exchange.getRequestHeaders().getFirst("Authorization")); + respond(exchange, 200, "{\"status\":\"success\",\"data\":{\"resultType\":\"vector\",\"result\":[]}}"); + }); + DataSourceTestDTO request = DataSourceTestDTO.builder() + .url(prometheusBaseUrl) + .type("Prometheus") + .auth("Bearer Token") + .bearerToken("token-1") + .build(); + + DataSourceTestResultVO result = settingsService.testDataSource(request); + + assertThat(result.isSuccess()).isTrue(); + assertThat(authorization.get()).isEqualTo("Bearer token-1"); + } + + @Test + void testConnectionShouldRejectIncompleteBasicAuthentication() { + DataSourceTestResultVO result = settingsService.testDataSource(DataSourceTestDTO.builder() + .url(prometheusBaseUrl) + .type("Prometheus") + .auth("Basic Auth") + .username("prom") + .build()); + + assertThat(result.isSuccess()).isFalse(); + assertThat(result.getMessage()).isEqualTo( + "Basic authentication requires username and password"); + } + + @Test + void testConnectionShouldRejectMissingBearerToken() { + DataSourceTestResultVO result = settingsService.testDataSource(DataSourceTestDTO.builder() + .url(prometheusBaseUrl) + .type("Prometheus") + .auth("Bearer Token") + .build()); + + assertThat(result.isSuccess()).isFalse(); + assertThat(result.getMessage()).isEqualTo("Bearer authentication requires token"); + } + + @Test + void testConnectionShouldReturnPrometheusErrorDetails() { + prometheusServer.createContext("/api/v1/query", exchange -> respond(exchange, 422, + "{\"status\":\"error\",\"errorType\":\"bad_data\",\"error\":\"invalid query\"}")); + DataSourceTestDTO request = DataSourceTestDTO.builder() + .url(prometheusBaseUrl) + .type("VictoriaMetrics") + .build(); + + DataSourceTestResultVO result = settingsService.testDataSource(request); + + assertThat(result.isSuccess()).isFalse(); + assertThat(result.getMessage()).isEqualTo( + "Prometheus query failed (bad_data): invalid query"); + } + + @Test + void testConnectionShouldRejectInvalidUrl() { + DataSourceTestResultVO result = settingsService.testDataSource(DataSourceTestDTO.builder() + .url("ftp://example.com") + .type("Prometheus") + .build()); + + assertThat(result.isSuccess()).isFalse(); + assertThat(result.getMessage()).isEqualTo( + "Data source URL must start with http:// or https://"); + } + + @Test + void testConnectionShouldRejectUnsupportedType() { + DataSourceTestResultVO result = settingsService.testDataSource(DataSourceTestDTO.builder() + .url(prometheusBaseUrl) + .type("rocketmq") + .build()); + + assertThat(result.isSuccess()).isFalse(); + assertThat(result.getMessage()).isEqualTo("Unsupported data source type: rocketmq"); + } + + private void respond(HttpExchange exchange, int statusCode, String body) throws IOException { + byte[] response = body.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().set("Content-Type", "application/json"); + exchange.sendResponseHeaders(statusCode, response.length); + exchange.getResponseBody().write(response); + exchange.close(); } } diff --git a/web/src/api/settings.ts b/web/src/api/settings.ts index 19829f70..5de9b4b5 100644 --- a/web/src/api/settings.ts +++ b/web/src/api/settings.ts @@ -42,6 +42,9 @@ export interface DataSource { type: string; url: string; auth: string; + username?: string; + password?: string; + bearerToken?: string; status: string; } @@ -78,7 +81,14 @@ export async function deleteDataSource(key: string) { await client.post('/settings/datasources/delete', undefined, { params: { key } }); } -export async function testDataSource(data: { type: string; url: string; auth?: string }) { +export async function testDataSource(data: { + type: string; + url: string; + auth?: string; + username?: string; + password?: string; + bearerToken?: string; +}) { const res = await client.post<{ data: { success: boolean; message: string } }>( '/settings/datasources/test', data, diff --git a/web/src/pages/settings/__tests__/DataSourceTab.test.tsx b/web/src/pages/settings/__tests__/DataSourceTab.test.tsx new file mode 100644 index 00000000..2f177e02 --- /dev/null +++ b/web/src/pages/settings/__tests__/DataSourceTab.test.tsx @@ -0,0 +1,214 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { render, screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { App } from 'antd'; +import type { DataSource } from '../../../api/settings'; +import { createDataSource, listDataSources, testDataSource } from '../../../api/settings'; +import { DataSourceTab } from '../index'; + +vi.mock('../../../api/settings', () => ({ + createDataSource: vi.fn(), + deleteDataSource: vi.fn(), + getGeneralSettings: vi.fn(), + listDataSources: vi.fn(), + saveGeneralSettings: vi.fn(), + testDataSource: vi.fn(), + updateDataSource: vi.fn(), +})); + +const sources: DataSource[] = [ + { + key: 'prom-prod', + name: 'Prometheus prod', + type: 'Prometheus', + url: 'http://prometheus:9090', + auth: 'None', + status: 'healthy', + }, + { + key: 'thanos-dr', + name: 'Thanos DR', + type: 'Thanos', + url: 'http://thanos:10902', + auth: 'Bearer Token', + status: 'healthy', + }, +]; + +beforeAll(() => { + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); +}); + +describe('DataSourceTab', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(listDataSources).mockResolvedValue(sources); + }); + + it('shows connection test loading only on the clicked row', async () => { + let resolveTest: (value: { success: boolean; message: string }) => void = () => undefined; + vi.mocked(testDataSource).mockReturnValue( + new Promise((resolve) => { + resolveTest = resolve; + }), + ); + + const user = userEvent.setup(); + render( + + + , + ); + + await screen.findByText('Prometheus prod'); + const buttons = screen.getAllByRole('button', { name: /测试连接/ }); + await user.click(buttons[0]); + + await waitFor(() => { + expect(buttons[0]).toHaveClass('ant-btn-loading'); + expect(buttons[1]).not.toHaveClass('ant-btn-loading'); + }); + + resolveTest({ success: true, message: 'ok' }); + }); + + it('submits basic auth credentials when testing from the modal', async () => { + vi.mocked(testDataSource).mockResolvedValue({ success: true, message: 'ok' }); + + const user = userEvent.setup(); + render( + + + , + ); + + await screen.findByText('Prometheus prod'); + await user.click(screen.getByRole('button', { name: /添加数据源/ })); + await selectAntdOption(user, '类型', 'Prometheus'); + await user.type(screen.getByLabelText('URL'), 'http://prometheus:9090'); + await selectAntdOption(user, '认证方式', 'Basic Auth'); + await user.type(screen.getByLabelText('用户名'), 'prom'); + await user.type(screen.getByLabelText('密码'), 'secret'); + + const testButtons = screen.getAllByRole('button', { name: /测试连接/ }); + await user.click(testButtons[testButtons.length - 1]); + + await waitFor(() => { + expect(testDataSource).toHaveBeenCalledWith({ + type: 'Prometheus', + url: 'http://prometheus:9090', + auth: 'Basic Auth', + username: 'prom', + password: 'secret', + }); + }); + }); + + it('submits bearer token when testing from the modal', async () => { + vi.mocked(testDataSource).mockResolvedValue({ success: true, message: 'ok' }); + + const user = userEvent.setup(); + render( + + + , + ); + + await screen.findByText('Prometheus prod'); + await user.click(screen.getByRole('button', { name: /添加数据源/ })); + await selectAntdOption(user, '类型', 'Thanos'); + await user.type(screen.getByLabelText('URL'), 'http://thanos:10902'); + await selectAntdOption(user, '认证方式', 'Bearer Token'); + await user.type(screen.getByLabelText('Bearer Token'), 'token-1'); + + const testButtons = screen.getAllByRole('button', { name: /测试连接/ }); + await user.click(testButtons[testButtons.length - 1]); + + await waitFor(() => { + expect(testDataSource).toHaveBeenCalledWith({ + type: 'Thanos', + url: 'http://thanos:10902', + auth: 'Bearer Token', + bearerToken: 'token-1', + }); + }); + }); + + it('does not persist modal-only credentials when creating a data source', async () => { + vi.mocked(createDataSource).mockResolvedValue({ + key: 'prom-secure', + name: 'Prometheus secure', + type: 'Prometheus', + url: 'http://prometheus:9090', + auth: 'Basic Auth', + status: 'healthy', + }); + + const user = userEvent.setup(); + render( + + + , + ); + + await screen.findByText('Prometheus prod'); + await user.click(screen.getByRole('button', { name: /添加数据源/ })); + await user.type(screen.getByLabelText('名称'), 'Prometheus secure'); + await selectAntdOption(user, '类型', 'Prometheus'); + await user.type(screen.getByLabelText('URL'), 'http://prometheus:9090'); + await selectAntdOption(user, '认证方式', 'Basic Auth'); + await user.type(screen.getByLabelText('用户名'), 'prom'); + await user.type(screen.getByLabelText('密码'), 'secret'); + + await user.click(screen.getByRole('button', { name: 'OK' })); + + await waitFor(() => { + expect(createDataSource).toHaveBeenCalledWith({ + name: 'Prometheus secure', + type: 'Prometheus', + url: 'http://prometheus:9090', + auth: 'Basic Auth', + }); + }); + }); +}); + +async function selectAntdOption(user: ReturnType, label: string, option: string) { + await user.click(screen.getByLabelText(label)); + const popupId = label === '类型' ? 'type_list' : 'auth_list'; + const popup = await waitFor(() => { + const element = document.getElementById(popupId); + if (!element) throw new Error(`Missing popup ${popupId}`); + return element; + }); + await user.click(within(popup).getByRole('option', { name: option })); +} diff --git a/web/src/pages/settings/index.tsx b/web/src/pages/settings/index.tsx index 9344eea0..47080e4d 100644 --- a/web/src/pages/settings/index.tsx +++ b/web/src/pages/settings/index.tsx @@ -70,6 +70,25 @@ const typeTagColor: Record = { Thanos: 'purple', }; +type DataSourceFormValues = Partial; + +const secretFieldNames = ['username', 'password', 'bearerToken'] as const; +const authNeedsSecret = (auth?: string) => auth === 'Basic Auth' || auth === 'Bearer Token'; + +const testFieldNames = (auth?: string) => { + if (auth === 'Basic Auth') return ['type', 'url', 'auth', 'username', 'password']; + if (auth === 'Bearer Token') return ['type', 'url', 'auth', 'bearerToken']; + return ['type', 'url', 'auth']; +}; + +const withoutSecrets = (values: DataSourceFormValues): Partial => { + const sanitized = { ...values }; + secretFieldNames.forEach((field) => { + delete sanitized[field]; + }); + return sanitized; +}; + // ─── General Settings Tab ─────────────────────────────────────────────────── const GeneralSettingsTab = () => { @@ -237,13 +256,14 @@ const GeneralSettingsTab = () => { // ─── Data Source Tab ──────────────────────────────────────────────────────── -const DataSourceTab = () => { +export const DataSourceTab = () => { const [dataSources, setDataSources] = useState([]); const [loading, setLoading] = useState(true); const [modalOpen, setModalOpen] = useState(false); const [editingDataSource, setEditingDataSource] = useState(null); const [dsForm] = Form.useForm(); - const [testing, setTesting] = useState(false); + const authValue = Form.useWatch('auth', dsForm); + const [testingKey, setTestingKey] = useState(null); const [submitting, setSubmitting] = useState(false); useEffect(() => { @@ -264,8 +284,15 @@ const DataSourceTab = () => { }; }, []); - const handleTestConnection = async (data: Pick) => { - setTesting(true); + const handleTestConnection = async ( + data: Pick & Partial, + key: string, + ) => { + if (key !== 'modal' && authNeedsSecret(data.auth)) { + message.warning('认证数据源请编辑后输入凭据再测试连接'); + return; + } + setTestingKey(key); try { const result = await testDataSource(data); if (result.success) message.success(result.message); @@ -273,7 +300,7 @@ const DataSourceTab = () => { } catch { message.error('连接测试失败,请稍后重试'); } finally { - setTesting(false); + setTestingKey(null); } }; @@ -293,10 +320,11 @@ const DataSourceTab = () => { const handleSubmit = async () => { try { const values = await dsForm.validateFields(); + const dataSourceValues = withoutSecrets(values); setSubmitting(true); const saved = editingDataSource - ? await updateDataSource({ ...editingDataSource, ...values }) - : await createDataSource(values); + ? await updateDataSource({ ...editingDataSource, ...dataSourceValues }) + : await createDataSource(dataSourceValues); setDataSources((previous) => editingDataSource ? previous.map((dataSource) => (dataSource.key === saved.key ? saved : dataSource)) @@ -347,8 +375,10 @@ const DataSourceTab = () => { type="link" size="small" icon={} - loading={testing} - onClick={() => void handleTestConnection(record)} + loading={testingKey === record.key} + disabled={authNeedsSecret(record.auth)} + title={authNeedsSecret(record.auth) ? '认证数据源请编辑后输入凭据再测试连接' : undefined} + onClick={() => void handleTestConnection(record, record.key)} > 测试连接 @@ -419,6 +449,7 @@ const DataSourceTab = () => { > { + dsForm.setFieldsValue({ username: undefined, password: undefined, bearerToken: undefined }); + }} options={[ { value: 'None', label: 'None' }, { value: 'Basic Auth', label: 'Basic Auth' }, @@ -445,13 +480,42 @@ const DataSourceTab = () => { /> + {authValue === 'Basic Auth' && ( + <> + + + + + + + + )} + + {authValue === 'Bearer Token' && ( + + + + )} +