chore: import upstream snapshot with attribution
MCP Bash Server CI / Test MCP Bash Server (dev) (push) Has been cancelled
MCP Bash Server CI / Test MCP Bash Server (release) (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:11:39 +08:00
commit c8cebdfeee
4654 changed files with 626756 additions and 0 deletions
+74
View File
@@ -0,0 +1,74 @@
# HertzBeat 日志 MCP
该 MCP 服务基于 GreptimeDB 查询 HertzBeat 自身运行日志。使用前需要启用 GreptimeDB 日志写入。
## 结构化只读查询
服务只暴露 `query_logs`,不再接收或执行调用方提供的 SQL。服务端生成的查询固定为:
```sql
SELECT timestamp, severity_text, body
FROM hzb_logs
WHERE <>
ORDER BY timestamp DESC
LIMIT <1-100>
```
`query_logs` 支持以下可选参数:
| 参数 | 类型 | 说明 |
| --- | --- | --- |
| `severity` | 字符串 | `TRACE``DEBUG``INFO``WARN``ERROR``FATAL` |
| `keyword` | 字符串 | 日志正文关键词,最长 256 个字符 |
| `startTime` | 整数 | 开始时间,Unix 毫秒时间戳 |
| `endTime` | 整数 | 结束时间,Unix 毫秒时间戳 |
| `limit` | 整数 | 返回条数,默认为 20,范围为 1~100 |
调用示例:
```json
{
"severity": "ERROR",
"keyword": "connection refused",
"startTime": 1783785600000,
"endTime": 1783872000000,
"limit": 20
}
```
当前 `hzb_logs` 表没有 `monitorId` 字段,因此本接口不提供无效的监控 ID 过滤。如果后续需要该能力,应先在 OpenTelemetry 日志写入链中定义并提取统一的监控 ID 字段。
## GreptimeDB 账号
服务支持通过 `greptime.username``greptime.password` 发送 HTTP Basic Authentication。用户名和密码必须同时配置;建议配合 HTTPS 或可信内网使用。
项目当前 Docker Compose 使用 GreptimeDB `v0.14.3`,该版本只提供身份认证,不能限制用户为只读权限。因此当前真正生效的安全边界是“删除原始 SQL参数并固定生成单条 `SELECT`”。使用 GreptimeDB 1.0 及以上版本时,应为该 MCP 配置独立的 `ro`/`readonly` 账号。
## Claude Desktop 集成(stdio
```json
{
"mcpServers": {
"hertzbeat-mcp": {
"command": "java",
"args": [
"-Dspring.ai.mcp.server.stdio=true",
"-Dspring.main.web-application-type=none",
"-Dlogging.pattern.console=",
"-jar",
"${PATH}/hertzbeat-mcp-2.0-SNAPSHOT.jar"
],
"env": {
"GREPTIME_URL": "http://${IP}:4000",
"GREPTIME_DATABASE": "public",
"GREPTIME_USERNAME": "${READ_ONLY_USERNAME}",
"GREPTIME_PASSWORD": "${READ_ONLY_PASSWORD}"
}
}
}
}
```
不启用 GreptimeDB 认证时,可以省略 `GREPTIME_USERNAME``GREPTIME_PASSWORD`;不能只配置其中一个。
旧的 `getHertzbeatLog({"querySql": "..."})` Tool 已直接移除,不保留兼容入口,以免继续暴露任意 SQL执行能力。
+99
View File
@@ -0,0 +1,99 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
~ 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.
-->
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.apache.hertzbeat</groupId>
<artifactId>hertzbeat</artifactId>
<version>2.0-SNAPSHOT</version>
</parent>
<artifactId>hertzbeat-mcp</artifactId>
<properties>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<spring-ai.version>1.0.0-M6</spring-ai.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
<version>3.4.2</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-bom</artifactId>
<version>${spring-ai.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-mcp-server-webflux-spring-boot-starter</artifactId>
<version>${spring-ai.version}</version>
</dependency>
<!-- json path parser-->
<dependency>
<groupId>com.jayway.jsonpath</groupId>
<artifactId>json-path</artifactId>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-web</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<executions>
<execution>
<goals>
<goal>repackage</goal>
</goals>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>${maven-compiler-plugin.version}</version>
<configuration>
<release>${java.version}</release>
<compilerArgs>
<compilerArg>-parameters</compilerArg>
</compilerArgs>
</configuration>
</plugin>
</plugins>
</build>
</project>
@@ -0,0 +1,44 @@
/*
* 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.
*/
package org.apache.hertzbeat.mcp.server;
import org.apache.hertzbeat.mcp.server.service.LogService;
import org.springframework.ai.tool.ToolCallbackProvider;
import org.springframework.ai.tool.method.MethodToolCallbackProvider;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
/**
* MCP Server Application
*/
@SpringBootApplication
public class McpServerApplication {
public static void main(String[] args) {
SpringApplication.run(McpServerApplication.class, args);
}
@Bean
public ToolCallbackProvider tools(
LogService logService) {
return MethodToolCallbackProvider.builder()
.toolObjects(logService)
.build();
}
}
@@ -0,0 +1,299 @@
/*
* 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.
*/
package org.apache.hertzbeat.mcp.server.service;
import com.jayway.jsonpath.JsonPath;
import com.jayway.jsonpath.ReadContext;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Service;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.client.RestClient;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
/**
* Log query service.
*/
@Service
@Slf4j
public class LogService {
private static final String BASE_QUERY = "SELECT timestamp, severity_text, body FROM hzb_logs";
private static final String TIMESTAMP_COLUMN = "timestamp";
private static final String SEVERITY_TEXT_COLUMN = "severity_text";
private static final String BODY_COLUMN = "body";
private static final int DEFAULT_LIMIT = 20;
private static final int MAX_LIMIT = 100;
private static final int MAX_KEYWORD_LENGTH = 256;
private static final long NANOS_PER_MILLISECOND = 1_000_000L;
private static final Set<String> SUPPORTED_SEVERITIES =
Set.of("TRACE", "DEBUG", "INFO", "WARN", "ERROR", "FATAL");
private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
private final RestClient restClient;
private final String database;
/**
* Creates a log query service.
*
* @param greptimeUrl GreptimeDB URL
* @param database GreptimeDB database name
* @param username GreptimeDB username
* @param password GreptimeDB password
*/
@Autowired
public LogService(@Value("${greptime.url}") String greptimeUrl,
@Value("${greptime.database:public}") String database,
@Value("${greptime.username:}") String username,
@Value("${greptime.password:}") String password) {
this(RestClient.builder(), greptimeUrl, database, username, password);
}
LogService(RestClient.Builder restClientBuilder, String greptimeUrl, String database,
String username, String password) {
boolean hasUsername = username != null && !username.isBlank();
boolean hasPassword = password != null && !password.isBlank();
if (hasUsername != hasPassword) {
throw new IllegalArgumentException("GreptimeDB username and password must be configured together");
}
RestClient.Builder builder = restClientBuilder
.baseUrl(greptimeUrl)
.defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)
.defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_FORM_URLENCODED_VALUE);
if (hasUsername) {
builder.defaultHeaders(headers -> headers.setBasicAuth(username, password));
}
this.restClient = builder.build();
this.database = database == null || database.isBlank() ? "public" : database;
}
/**
* Queries HertzBeat system logs using structured filters.
*
* @param severity log severity
* @param keyword log body keyword
* @param startTime start time as a Unix timestamp in milliseconds
* @param endTime end time as a Unix timestamp in milliseconds
* @param limit maximum number of records to return
* @return formatted log query results
*/
@Tool(name = "query_logs", description = "Query HertzBeat system logs with structured read-only filters")
public String queryLogs(
@ToolParam(required = false,
description = "Log severity; supported values: TRACE, DEBUG, INFO, WARN, ERROR, FATAL")
String severity,
@ToolParam(required = false, description = "Keyword to search in the log body; maximum 256 characters")
String keyword,
@ToolParam(required = false, description = "Start time as a Unix timestamp in milliseconds")
Long startTime,
@ToolParam(required = false, description = "End time as a Unix timestamp in milliseconds")
Long endTime,
@ToolParam(required = false,
description = "Maximum number of records to return; defaults to 20 and cannot exceed 100")
Integer limit) {
try {
String response = executeQuery(buildQuery(severity, keyword, startTime, endTime, limit));
return formatQueryResults(response);
} catch (IllegalArgumentException e) {
return "Invalid query parameters: " + e.getMessage();
} catch (Exception e) {
log.error("Failed to query logs", e);
return "Failed to query logs";
}
}
static String buildQuery(String severity, String keyword, Long startTime, Long endTime, Integer limit) {
if (startTime != null && startTime < 0) {
throw new IllegalArgumentException("Start time must not be negative");
}
if (endTime != null && endTime < 0) {
throw new IllegalArgumentException("End time must not be negative");
}
if (startTime != null && endTime != null && startTime > endTime) {
throw new IllegalArgumentException("Start time must not be later than end time");
}
int queryLimit = limit == null ? DEFAULT_LIMIT : limit;
if (queryLimit < 1 || queryLimit > MAX_LIMIT) {
throw new IllegalArgumentException("Limit must be between 1 and 100");
}
List<String> conditions = new ArrayList<>(4);
if (startTime != null) {
conditions.add("timestamp >= " + toNanoseconds(startTime, "Start time"));
}
if (endTime != null) {
conditions.add("timestamp <= " + toNanoseconds(endTime, "End time"));
}
String normalizedSeverity = normalizeSeverity(severity);
if (normalizedSeverity != null) {
conditions.add("severity_text = '" + normalizedSeverity + "'");
}
if (keyword != null && !keyword.isBlank()) {
String normalizedKeyword = keyword.strip();
if (normalizedKeyword.length() > MAX_KEYWORD_LENGTH) {
throw new IllegalArgumentException("Log keyword must not exceed 256 characters");
}
conditions.add("matches_term(body, '" + escapeSqlLiteral(normalizedKeyword) + "')");
}
StringBuilder query = new StringBuilder(BASE_QUERY);
if (!conditions.isEmpty()) {
query.append(" WHERE ").append(String.join(" AND ", conditions));
}
return query.append(" ORDER BY timestamp DESC LIMIT ").append(queryLimit).toString();
}
private static String normalizeSeverity(String severity) {
if (severity == null || severity.isBlank()) {
return null;
}
String normalizedSeverity = severity.strip().toUpperCase(Locale.ROOT);
if (!SUPPORTED_SEVERITIES.contains(normalizedSeverity)) {
throw new IllegalArgumentException("Unsupported log severity");
}
return normalizedSeverity;
}
private static long toNanoseconds(long epochMilliseconds, String fieldName) {
try {
return Math.multiplyExact(epochMilliseconds, NANOS_PER_MILLISECOND);
} catch (ArithmeticException e) {
throw new IllegalArgumentException(fieldName + " is out of the supported range", e);
}
}
private static String escapeSqlLiteral(String value) {
return value.replace("'", "''");
}
private String executeQuery(String sql) {
MultiValueMap<String, String> formData = new LinkedMultiValueMap<>();
formData.add("sql", sql);
log.debug("Executing structured log query");
return restClient.post()
.uri(uriBuilder -> uriBuilder.path("/v1/sql").queryParam("db", database).build())
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.body(formData)
.retrieve()
.body(String.class);
}
private String formatQueryResults(String response) {
ReadContext ctx = JsonPath.parse(response);
List<Map<String, Object>> columnSchemas = ctx.read("$.output[0].records.schema.column_schemas");
List<List<Object>> rows = ctx.read("$.output[0].records.rows");
int totalRows = ctx.read("$.output[0].records.total_rows");
ColumnIndices indices = findColumnIndices(columnSchemas);
StringBuilder result = new StringBuilder()
.append("Query Results:\n\n")
.append("Log Time\t\t\tLog Level\tLog Content\n")
.append("----------------------------------------------------\n");
if (rows != null && !rows.isEmpty()) {
formatRows(rows, indices, result);
result.append("\nTotal ").append(totalRows).append(" records");
} else {
result.append("No data");
}
return result.toString();
}
private record ColumnIndices(int timestamp, int severityText, int body) {}
private ColumnIndices findColumnIndices(List<Map<String, Object>> columnSchemas) {
int timestampIndex = -1;
int severityTextIndex = -1;
int bodyIndex = -1;
for (int i = 0; i < columnSchemas.size(); i++) {
String columnName = (String) columnSchemas.get(i).get("name");
switch (columnName) {
case TIMESTAMP_COLUMN -> timestampIndex = i;
case SEVERITY_TEXT_COLUMN -> severityTextIndex = i;
case BODY_COLUMN -> bodyIndex = i;
default -> {
// Ignore other columns
}
}
}
return new ColumnIndices(timestampIndex, severityTextIndex, bodyIndex);
}
private void formatRows(List<List<Object>> rows, ColumnIndices indices, StringBuilder result) {
for (List<Object> row : rows) {
appendTimestamp(row, indices.timestamp(), result);
appendSeverity(row, indices.severityText(), result);
appendBody(row, indices.body(), result);
result.append("\n");
}
}
private void appendTimestamp(List<Object> row, int index, StringBuilder result) {
if (index >= 0 && index < row.size()) {
Object value = row.get(index);
if (value instanceof Number) {
long timestamp = ((Number) value).longValue();
LocalDateTime dateTime = LocalDateTime.ofInstant(
Instant.ofEpochMilli(timestamp / 1_000_000),
ZoneId.systemDefault());
result.append(DATE_FORMATTER.format(dateTime)).append("\t");
return;
}
}
result.append("Unknown time\t");
}
private void appendSeverity(List<Object> row, int index, StringBuilder result) {
if (index >= 0 && index < row.size()) {
result.append(row.get(index)).append("\t");
} else {
result.append("Unknown\t");
}
}
private void appendBody(List<Object> row, int index, StringBuilder result) {
if (index >= 0 && index < row.size()) {
result.append(row.get(index));
} else {
result.append("No content");
}
}
}
@@ -0,0 +1,28 @@
# 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.
spring:
main:
banner-mode: off
ai:
mcp:
server:
name: hertzbeat-log-analysis-server
version: 0.1
#
#logging:
# file:
# name:
@@ -0,0 +1,201 @@
/*
* 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.
*/
package org.apache.hertzbeat.mcp.server.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.content;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.header;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.method;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
import static org.springframework.test.web.client.response.MockRestResponseCreators.withServerError;
import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.Map;
import org.junit.jupiter.api.Test;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.ai.tool.method.MethodToolCallbackProvider;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.client.RestClient;
import org.springframework.test.web.client.MockRestServiceServer;
/**
* Security tests for {@link LogService} queries.
*/
class LogServiceTest {
private static final String BASE_QUERY = "SELECT timestamp, severity_text, body FROM hzb_logs";
@Test
void shouldBuildDefaultReadOnlyQuery() {
assertThat(LogService.buildQuery(null, null, null, null, null))
.isEqualTo(BASE_QUERY + " ORDER BY timestamp DESC LIMIT 20");
}
@Test
void shouldExposeOnlyStructuredToolParameters() {
LogService service = new LogService(
RestClient.builder(), "http://localhost:4000", "public", "", "");
ToolCallback[] callbacks = MethodToolCallbackProvider.builder().toolObjects(service).build().getToolCallbacks();
assertThat(callbacks).singleElement().satisfies(callback -> {
assertThat(callback.getToolDefinition().name()).isEqualTo("query_logs");
assertThat(callback.getToolDefinition().inputSchema())
.contains("severity", "keyword", "startTime", "endTime", "limit")
.doesNotContain("querySql");
});
}
@Test
void shouldBuildQueryFromValidatedFilters() {
String query = LogService.buildQuery(
" error ", " x'); DELETE FROM hzb_logs; -- ", 1L, 2L, 10);
assertThat(query).isEqualTo(BASE_QUERY
+ " WHERE timestamp >= 1000000"
+ " AND timestamp <= 2000000"
+ " AND severity_text = 'ERROR'"
+ " AND matches_term(body, 'x''); DELETE FROM hzb_logs; --')"
+ " ORDER BY timestamp DESC LIMIT 10");
}
@Test
void shouldRejectInvalidFilters() {
assertThatIllegalArgumentException()
.isThrownBy(() -> LogService.buildQuery("NOTICE", null, null, null, null));
assertThatIllegalArgumentException()
.isThrownBy(() -> LogService.buildQuery(null, null, -1L, null, null));
assertThatIllegalArgumentException()
.isThrownBy(() -> LogService.buildQuery(null, null, null, -1L, null));
assertThatIllegalArgumentException()
.isThrownBy(() -> LogService.buildQuery(null, null, 2L, 1L, null));
assertThatIllegalArgumentException()
.isThrownBy(() -> LogService.buildQuery(null, null, Long.MAX_VALUE, null, null));
assertThatIllegalArgumentException()
.isThrownBy(() -> LogService.buildQuery(null, "x".repeat(257), null, null, null));
assertThatIllegalArgumentException()
.isThrownBy(() -> LogService.buildQuery(null, null, null, null, 0));
assertThatIllegalArgumentException()
.isThrownBy(() -> LogService.buildQuery(null, null, null, null, 101));
}
@Test
void shouldSendGeneratedSelectWithBasicAuthentication() {
RestClient.Builder restClientBuilder = RestClient.builder();
MockRestServiceServer server = MockRestServiceServer.bindTo(restClientBuilder).build();
LogService service = new LogService(
restClientBuilder, "http://localhost:4000", "observability", "reader", "secret");
String query = BASE_QUERY + " ORDER BY timestamp DESC LIMIT 20";
MultiValueMap<String, String> expectedForm = new LinkedMultiValueMap<>();
expectedForm.setAll(Map.of("sql", query));
String credentials = Base64.getEncoder()
.encodeToString("reader:secret".getBytes(StandardCharsets.UTF_8));
server.expect(requestTo("http://localhost:4000/v1/sql?db=observability"))
.andExpect(method(HttpMethod.POST))
.andExpect(header(HttpHeaders.AUTHORIZATION, "Basic " + credentials))
.andExpect(content().formData(expectedForm))
.andRespond(withSuccess(emptyResult(), MediaType.APPLICATION_JSON));
assertThat(service.queryLogs(null, null, null, null, null)).contains("No data");
server.verify();
}
@Test
void shouldFormatReturnedLogs() {
RestClient.Builder restClientBuilder = RestClient.builder();
MockRestServiceServer server = MockRestServiceServer.bindTo(restClientBuilder).build();
LogService service = new LogService(restClientBuilder, "http://localhost:4000", "public", "", "");
server.expect(requestTo("http://localhost:4000/v1/sql?db=public"))
.andRespond(withSuccess(resultWithOneLog(), MediaType.APPLICATION_JSON));
assertThat(service.queryLogs("ERROR", "failure", null, null, 1))
.contains("ERROR", "failure", "Total 1 records");
server.verify();
}
@Test
void shouldReturnSafeErrorMessages() {
RestClient.Builder restClientBuilder = RestClient.builder();
MockRestServiceServer server = MockRestServiceServer.bindTo(restClientBuilder).build();
LogService service = new LogService(restClientBuilder, "http://localhost:4000", "public", "", "");
assertThat(service.queryLogs("NOTICE", null, null, null, null))
.isEqualTo("Invalid query parameters: Unsupported log severity");
server.expect(requestTo("http://localhost:4000/v1/sql?db=public"))
.andRespond(withServerError());
assertThat(service.queryLogs(null, null, null, null, null)).isEqualTo("Failed to query logs");
server.verify();
}
@Test
void shouldRequireCompleteCredentials() {
assertThatIllegalArgumentException().isThrownBy(() -> new LogService(
RestClient.builder(), "http://localhost:4000", "public", "reader", ""));
assertThatIllegalArgumentException().isThrownBy(() -> new LogService(
RestClient.builder(), "http://localhost:4000", "public", "", "secret"));
}
@Test
void shouldCreateWithProductionConstructor() {
assertThat(new LogService("http://localhost:4000", "public", "", "")).isNotNull();
}
private static String emptyResult() {
return """
{
"output": [{
"records": {
"schema": {"column_schemas": [
{"name": "timestamp"},
{"name": "severity_text"},
{"name": "body"}
]},
"rows": [],
"total_rows": 0
}
}]
}
""";
}
private static String resultWithOneLog() {
return """
{
"output": [{
"records": {
"schema": {"column_schemas": [
{"name": "timestamp"},
{"name": "severity_text"},
{"name": "body"}
]},
"rows": [[0, "ERROR", "failure"]],
"total_rows": 1
}
}]
}
""";
}
}