统一 SuperAgent 查询接口鉴权契约

This commit is contained in:
andy
2026-07-08 10:10:00 +08:00
parent 652c5c10c5
commit fb82386fdb
10 changed files with 832 additions and 72 deletions

View File

@@ -12,8 +12,13 @@ import cn.nianxx.thhotel.ThHotelApplication;
import cn.nianxx.thhotel.platform.message.common.request.CaptureSourceMessageCommand;
import cn.nianxx.thhotel.platform.message.common.result.SourceMessageCaptureResult;
import cn.nianxx.thhotel.platform.message.service.SourceMessageCaptureService;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Instant;
import java.util.HexFormat;
import java.util.List;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
@@ -22,13 +27,25 @@ import org.springframework.http.MediaType;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
@SpringBootTest(classes = ThHotelApplication.class)
@SpringBootTest(
classes = ThHotelApplication.class,
properties = {
"superagent.task-result.hmac-secret=test-superagent-secret",
"superagent.task-result.clock-skew-seconds=300",
"superagent.task-result.nonce-ttl-seconds=600",
"superagent.task-result.max-body-bytes=12000"
})
@AutoConfigureMockMvc
@ActiveProfiles("test")
class ReservationAiQueryControllerTest {
private static final String HOTEL_ID = "HOTEL-TEST";
private static final String CASE_CONTEXT_ENDPOINT = "/api/ai-query/v1/case-context";
private static final String OBJECT_DETAIL_ENDPOINT = "/api/ai-query/v1/object-detail";
private static final String CLIENT_ID = "superagent-test-client";
private static final String SECRET = "test-superagent-secret";
@Autowired
private MockMvc mockMvc;
@@ -46,11 +63,7 @@ class ReservationAiQueryControllerTest {
insertTransition(920000000000000201L, source.inboxId(), 1, "GRP-AIQUERY-001");
insertTask(920000000000000301L, 920000000000000101L, source.inboxId(), 920000000000000201L, "PENDING_CONFIRM");
mockMvc.perform(post("/api/ai-query/v1/case-context")
.contentType(MediaType.APPLICATION_JSON)
.header("X-Request-Id", "req-ai-query-case-001")
.header("X-AI-Trace-Id", "trace-ai-query-case-001")
.content("""
String body = """
{
"hotel_id": "HOTEL-TEST",
"source_message_id": "%s",
@@ -59,7 +72,10 @@ class ReservationAiQueryControllerTest {
"target_key_source": "body_current",
"body_thread_used_only_as_evidence": false
}
""".formatted(source.inboxId())))
""".formatted(source.inboxId());
mockMvc.perform(signedPost(CASE_CONTEXT_ENDPOINT, body, "nonce-ai-query-case-001", "req-ai-query-case-001")
.header("X-AI-Trace-Id", "trace-ai-query-case-001"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.request_id").value("req-ai-query-case-001"))
@@ -85,17 +101,16 @@ class ReservationAiQueryControllerTest {
void shouldReturnSuccessfulEmptyCaseContextWhenNoObjectMatches() throws Exception {
SourceMessageCaptureResult source = captureSourceMessage("mail-ai-query-empty-001");
mockMvc.perform(post("/api/ai-query/v1/case-context")
.contentType(MediaType.APPLICATION_JSON)
.header("X-Request-Id", "req-ai-query-empty-001")
.content("""
String body = """
{
"hotel_id": "HOTEL-TEST",
"source_message_id": "%s",
"source_event_index": 1,
"group_code": "GRP-AIQUERY-NOT-FOUND"
}
""".formatted(source.inboxId())))
""".formatted(source.inboxId());
mockMvc.perform(signedPost(CASE_CONTEXT_ENDPOINT, body, "nonce-ai-query-empty-001", "req-ai-query-empty-001"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.matched_order_records.length()").value(0))
@@ -110,17 +125,17 @@ class ReservationAiQueryControllerTest {
void shouldNotAllowCreationWhenOnlyUnsupportedReservationNoProvided() throws Exception {
SourceMessageCaptureResult source = captureSourceMessage("mail-ai-query-reservation-no-001");
mockMvc.perform(post("/api/ai-query/v1/case-context")
.contentType(MediaType.APPLICATION_JSON)
.header("X-Request-Id", "req-ai-query-reservation-no-001")
.content("""
String body = """
{
"hotel_id": "HOTEL-TEST",
"source_message_id": "%s",
"source_event_index": 1,
"reservation_no": "RESV-AIQUERY-001"
}
""".formatted(source.inboxId())))
""".formatted(source.inboxId());
mockMvc.perform(signedPost(CASE_CONTEXT_ENDPOINT, body, "nonce-ai-query-reservation-no-001",
"req-ai-query-reservation-no-001"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.matched_order_records.length()").value(0))
@@ -131,22 +146,104 @@ class ReservationAiQueryControllerTest {
.value("UNSUPPORTED_RESERVATION_NO_QUERY"));
}
@Test
void shouldAllowGlobalCaseContextQueryWithoutSourceMessageAndEventIndex() throws Exception {
SourceMessageCaptureResult source = captureSourceMessage("mail-ai-query-global-001");
insertActiveGroupOrder(920000000000000501L, source.inboxId(), "GRP-AIQUERY-GLOBAL-001");
String body = """
{
"hotel_id": "HOTEL-TEST",
"group_code": "GRP-AIQUERY-GLOBAL-001"
}
""";
mockMvc.perform(signedPost(CASE_CONTEXT_ENDPOINT, body, "nonce-ai-query-global-001",
"req-ai-query-global-001"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.matched_order_records[0].object_id").value("ORDER:920000000000000501"))
.andExpect(jsonPath("$.data.target_object_validation.status").value("single"));
}
@Test
void shouldRejectCaseContextWhenHmacSignatureInvalid() throws Exception {
String body = """
{
"hotel_id": "HOTEL-TEST",
"source_event_index": 1,
"group_code": "GRP-AIQUERY-HMAC-INVALID"
}
""";
mockMvc.perform(post(CASE_CONTEXT_ENDPOINT)
.contentType(MediaType.APPLICATION_JSON)
.header("X-TH-Hotel-Request-Id", "req-ai-query-hmac-invalid")
.header("X-TH-Hotel-SuperAgent-Client-Id", CLIENT_ID)
.header("X-TH-Hotel-SuperAgent-Timestamp", Instant.now().toString())
.header("X-TH-Hotel-SuperAgent-Nonce", "nonce-ai-query-hmac-invalid")
.header("X-TH-Hotel-SuperAgent-Signature", "sha256=invalid")
.content(body))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.success").value(false))
.andExpect(jsonPath("$.error.code").value("AUTH_SIGNATURE_INVALID"))
.andExpect(content().string(not(containsString(SECRET))));
}
@Test
void shouldReturnUnifiedErrorWhenCaseContextJsonInvalid() throws Exception {
String body = "{\"hotel_id\":";
mockMvc.perform(signedPost(CASE_CONTEXT_ENDPOINT, body, "nonce-ai-query-invalid-json",
"req-ai-query-invalid-json"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.success").value(false))
.andExpect(jsonPath("$.request_id").value("req-ai-query-invalid-json"))
.andExpect(jsonPath("$.error.code").value("REQUEST_BODY_INVALID"))
.andExpect(content().string(not(containsString(SECRET))));
}
@Test
void shouldReturnUnifiedErrorWhenCaseContextContentTypeUnsupported() throws Exception {
String body = """
{
"hotel_id": "HOTEL-TEST",
"group_code": "GRP-AIQUERY-CONTENT-TYPE"
}
""";
String timestamp = Instant.now().toString();
mockMvc.perform(post(CASE_CONTEXT_ENDPOINT)
.contentType(MediaType.TEXT_PLAIN)
.header("X-TH-Hotel-Request-Id", "req-ai-query-content-type")
.header("X-TH-Hotel-SuperAgent-Client-Id", CLIENT_ID)
.header("X-TH-Hotel-SuperAgent-Timestamp", timestamp)
.header("X-TH-Hotel-SuperAgent-Nonce", "nonce-ai-query-content-type")
.header("X-TH-Hotel-SuperAgent-Signature",
signature(CASE_CONTEXT_ENDPOINT, body, "nonce-ai-query-content-type", timestamp))
.content(body))
.andExpect(status().isUnsupportedMediaType())
.andExpect(jsonPath("$.success").value(false))
.andExpect(jsonPath("$.request_id").value("req-ai-query-content-type"))
.andExpect(jsonPath("$.error.code").value("REQUEST_CONTENT_TYPE_UNSUPPORTED"))
.andExpect(content().string(not(containsString(SECRET))));
}
@Test
void shouldReturnObjectDetailWithNullableOperaProjectionWarning() throws Exception {
SourceMessageCaptureResult source = captureSourceMessage("mail-ai-query-detail-001");
insertActiveGroupOrder(920000000000000401L, source.inboxId(), "GRP-AIQUERY-DETAIL-001");
mockMvc.perform(post("/api/ai-query/v1/object-detail")
.contentType(MediaType.APPLICATION_JSON)
.header("X-Request-Id", "req-ai-query-detail-001")
.header("X-AI-Trace-Id", "trace-ai-query-detail-001")
.content("""
String body = """
{
"hotel_id": "HOTEL-TEST",
"object_id": "ORDER:920000000000000401",
"object_type": "group_block"
}
"""))
""";
mockMvc.perform(signedPost(OBJECT_DETAIL_ENDPOINT, body, "nonce-ai-query-detail-001",
"req-ai-query-detail-001")
.header("X-AI-Trace-Id", "trace-ai-query-detail-001"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.object_id").value("ORDER:920000000000000401"))
@@ -162,6 +259,31 @@ class ReservationAiQueryControllerTest {
.andExpect(jsonPath("$.data.hard_validation_warnings[0].code").value("OPERA_PROJECTION_UNAVAILABLE"));
}
@Test
void shouldRejectObjectDetailWhenHmacSignatureInvalid() throws Exception {
String body = """
{
"hotel_id": "HOTEL-TEST",
"object_id": "ORDER:920000000000000401",
"object_type": "group_block"
}
""";
mockMvc.perform(post(OBJECT_DETAIL_ENDPOINT)
.contentType(MediaType.APPLICATION_JSON)
.header("X-TH-Hotel-Request-Id", "req-ai-query-detail-hmac-invalid")
.header("X-TH-Hotel-SuperAgent-Client-Id", CLIENT_ID)
.header("X-TH-Hotel-SuperAgent-Timestamp", Instant.now().toString())
.header("X-TH-Hotel-SuperAgent-Nonce", "nonce-ai-query-detail-hmac-invalid")
.header("X-TH-Hotel-SuperAgent-Signature", "sha256=invalid")
.content(body))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.success").value(false))
.andExpect(jsonPath("$.request_id").value("req-ai-query-detail-hmac-invalid"))
.andExpect(jsonPath("$.error.code").value("AUTH_SIGNATURE_INVALID"))
.andExpect(content().string(not(containsString(SECRET))));
}
private SourceMessageCaptureResult captureSourceMessage(String externalMessageId) {
return captureService.capture(new CaptureSourceMessageCommand(
HOTEL_ID,
@@ -224,4 +346,28 @@ class ReservationAiQueryControllerTest {
CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
""", taskId, HOTEL_ID, orderId, sourceMessageId, transitionId, taskStatus);
}
private MockHttpServletRequestBuilder signedPost(String endpoint, String body, String nonce, String requestId) throws Exception {
String timestamp = Instant.now().toString();
return post(endpoint)
.contentType(MediaType.APPLICATION_JSON)
.header("X-TH-Hotel-Request-Id", requestId)
.header("X-TH-Hotel-SuperAgent-Client-Id", CLIENT_ID)
.header("X-TH-Hotel-SuperAgent-Timestamp", timestamp)
.header("X-TH-Hotel-SuperAgent-Nonce", nonce)
.header("X-TH-Hotel-SuperAgent-Signature", signature(endpoint, body, nonce, timestamp))
.content(body);
}
private String signature(String endpoint, String body, String nonce, String timestamp) throws Exception {
String canonical = "POST\n" + endpoint + "\n" + timestamp + "\n" + nonce + "\n" + CLIENT_ID + "\n" + sha256(body);
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(SECRET.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
return "sha256=" + HexFormat.of().formatHex(mac.doFinal(canonical.getBytes(StandardCharsets.UTF_8)));
}
private String sha256(String body) throws Exception {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
return HexFormat.of().formatHex(digest.digest(body.getBytes(StandardCharsets.UTF_8)));
}
}