收口预约只读接口权限与酒店隔离

This commit is contained in:
andy committed 2026-07-16 11:27:23 +07:00
1 parent 9a2812fe74
commit 5c0a5d21d0
22 files changed
+877 -63

No files matched your search

@@ -149,19 +149,21 @@ class AuthControllerTest {
}
@Test
void shouldKeepExistingBusinessEndpointsCompatibleWhenTokenIsMissingOrInvalid() throws Exception {
void shouldRequireTokenForFrontendReadonlyBusinessEndpoints() throws Exception {
mockMvc.perform(get("/api/reservation/orders")
.param("hotel_id", "HOTEL-TEST")
.param("page_num", "1")
.param("page_size", "1"))
.andExpect(status().isOk());
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.error_code").value("AUTH_TOKEN_REQUIRED"));
mockMvc.perform(get("/api/reservation/orders")
.header("Authorization", "Bearer invalid-token")
.param("hotel_id", "HOTEL-TEST")
.param("page_num", "1")
.param("page_size", "1"))
.andExpect(status().isOk());
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.error_code").value("AUTH_SESSION_INVALID"));
}
private MvcResult login() throws Exception {
@@ -3,6 +3,8 @@ package cn.nianxx.thhotel.platform.message.control;
import static org.hamcrest.Matchers.contains;
import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.not;
import static cn.nianxx.thhotel.support.MockMvcAuthTestSupport.loginToken;
import static cn.nianxx.thhotel.support.MockMvcAuthTestSupport.performAuthorized;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
@@ -25,7 +27,17 @@ import org.springframework.test.web.servlet.MockMvc;
@SpringBootTest(
classes = ThHotelApplication.class,
properties = "source-message.original-read.access-key=test-original-read-key")
properties = {
"source-message.original-read.access-key=test-original-read-key",
"spring.datasource.url=jdbc:h2:mem:source_message_controller_test;MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE",
"auth.bootstrap.admin.username=source-message-admin",
"auth.bootstrap.admin.password=Admin@123456",
"auth.bootstrap.admin.display-name=SourceMessage管理员",
"auth.bootstrap.default-hotel-id=HOTEL-TEST",
"auth.bootstrap.default-hotel-name=测试酒店",
"auth.bootstrap.default-hotel-time-zone=Asia/Bangkok",
"auth.session.ttl-minutes=720"
})
@AutoConfigureMockMvc
@ActiveProfiles("test")
class SourceMessageControllerTest {
@@ -39,6 +51,18 @@ class SourceMessageControllerTest {
@Autowired
private JdbcTemplate jdbcTemplate;
private String adminToken;
/**
* 获取 SourceMessage 摘要接口测试管理员 token。
*/
private String adminToken() throws Exception {
if (adminToken == null) {
adminToken = loginToken(mockMvc, "source-message-admin", "Admin@123456");
}
return adminToken;
}
@Test
void shouldListAndReadSummaryWithoutOriginalContentOrMediaUrls() throws Exception {
SourceMessageCaptureResult result = captureService.capture(command(
@@ -49,7 +73,7 @@ class SourceMessageControllerTest {
"https://media.example.test/private.pdf?token=secret"
));
mockMvc.perform(get("/api/source-messages")
performAuthorized(mockMvc, adminToken(), get("/api/source-messages")
.param("hotelId", "HOTEL-TEST")
.param("externalConversationId", "conversation-api-001")
.param("pageNum", "1")
@@ -66,7 +90,7 @@ class SourceMessageControllerTest {
.andExpect(content().string(not(containsString("token=secret"))))
.andExpect(content().string(not(containsString("payloadJson"))));
mockMvc.perform(get("/api/source-messages/{id}", result.inboxId()))
performAuthorized(mockMvc, adminToken(), get("/api/source-messages/{id}", result.inboxId()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(result.inboxId().toString()))
.andExpect(jsonPath("$.externalMessageId").value("mail-api-001"))
@@ -0,0 +1,48 @@
package cn.nianxx.thhotel.support;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import com.jayway.jsonpath.JsonPath;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import org.springframework.test.web.servlet.ResultActions;
import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
/**
* MockMvc 登录辅助工具。测试仍走真实登录接口,避免绕过后端安全链路。
*/
public final class MockMvcAuthTestSupport {
private MockMvcAuthTestSupport() {
}
/**
* 通过真实登录接口获取 Bearer token。
*/
public static String loginToken(MockMvc mockMvc, String username, String password) throws Exception {
MvcResult result = mockMvc.perform(post("/api/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"username": "%s",
"password": "%s",
"preferred_hotel_id": "HOTEL-TEST"
}
""".formatted(username, password)))
.andExpect(status().isOk())
.andReturn();
return JsonPath.read(result.getResponse().getContentAsString(), "$.access_token");
}
/**
* 使用指定 token 执行已授权请求。
*/
public static ResultActions performAuthorized(
MockMvc mockMvc,
String token,
MockHttpServletRequestBuilder requestBuilder) throws Exception {
return mockMvc.perform(requestBuilder.header("Authorization", "Bearer " + token));
}
}
@@ -0,0 +1,199 @@
package cn.nianxx.thhotel.workflows.reservation.control;
import static cn.nianxx.thhotel.support.MockMvcAuthTestSupport.loginToken;
import static cn.nianxx.thhotel.support.MockMvcAuthTestSupport.performAuthorized;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import cn.nianxx.thhotel.ThHotelApplication;
import cn.nianxx.thhotel.platform.hotel.repository.PlatformHotelRepository;
import cn.nianxx.thhotel.platform.identity.common.enums.PlatformUserStatus;
import cn.nianxx.thhotel.platform.identity.domain.PlatformUserEntity;
import cn.nianxx.thhotel.platform.identity.repository.PlatformIdentityRepository;
import cn.nianxx.thhotel.platform.identity.service.impl.AuthPasswordService;
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.time.Instant;
import java.time.LocalDateTime;
import java.util.List;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
@SpringBootTest(
classes = ThHotelApplication.class,
properties = {
"spring.datasource.url=jdbc:h2:mem:frontend_read_auth_cp1;MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE",
"auth.bootstrap.admin.username=cp1-admin",
"auth.bootstrap.admin.password=Admin@123456",
"auth.bootstrap.admin.display-name=系统管理员",
"auth.bootstrap.default-hotel-id=HOTEL-TEST",
"auth.bootstrap.default-hotel-name=测试酒店",
"auth.bootstrap.default-hotel-time-zone=Asia/Bangkok",
"superagent.task-result.hmac-secret=test-superagent-secret"
})
@AutoConfigureMockMvc
@ActiveProfiles("test")
class FrontendReadAuthorizationControllerTest {
private static final String HOTEL_ID = "HOTEL-TEST";
private static final String OTHER_HOTEL_ID = "HOTEL-OTHER";
@Autowired
private MockMvc mockMvc;
@Autowired
private JdbcTemplate jdbcTemplate;
@Autowired
private SourceMessageCaptureService captureService;
@Autowired
private PlatformIdentityRepository identityRepository;
@Autowired
private PlatformHotelRepository hotelRepository;
@Autowired
private AuthPasswordService passwordService;
@BeforeEach
void ensureNoPermissionUser() {
PlatformUserEntity user = identityRepository.findUserByUsername("cp1-no-permission")
.orElseGet(() -> {
LocalDateTime now = LocalDateTime.now();
PlatformUserEntity created = new PlatformUserEntity();
created.setUsername("cp1-no-permission");
created.setPasswordHash(passwordService.hash("NoPerm@123456"));
created.setDisplayName("无权限用户");
created.setUserStatus(PlatformUserStatus.ACTIVE.name());
created.setSuperAdmin(false);
created.setPasswordChangedAt(now);
created.setCreatedAt(now);
created.setUpdatedAt(now);
identityRepository.insertUser(created);
return created;
});
hotelRepository.ensureUserHotel(user.getId(), HOTEL_ID, true);
}
@Test
void shouldRejectReservationReadWhenTokenMissing() throws Exception {
mockMvc.perform(get("/api/reservation/tasks")
.param("hotel_id", HOTEL_ID))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.error_code").value("AUTH_TOKEN_REQUIRED"));
}
@Test
void shouldRejectReservationReadWhenPermissionMissing() throws Exception {
String token = loginToken(mockMvc, "cp1-no-permission", "NoPerm@123456");
performAuthorized(mockMvc, token, get("/api/reservation/tasks")
.param("hotel_id", HOTEL_ID))
.andExpect(status().isForbidden())
.andExpect(jsonPath("$.error_code").value("FRONTEND_PERMISSION_DENIED"));
}
@Test
void shouldRejectReservationAndSourceMessageDetailAcrossHotels() throws Exception {
String token = loginToken(mockMvc, "cp1-admin", "Admin@123456");
SourceMessageCaptureResult source = captureOtherHotelSourceMessage();
Long orderId = 970000000000000101L;
Long taskId = 970000000000000301L;
insertOtherHotelOrderTask(orderId, taskId, source.inboxId());
performAuthorized(mockMvc, token, get("/api/reservation/orders/{orderId}", orderId))
.andExpect(status().isForbidden())
.andExpect(jsonPath("$.error_code").value("HOTEL_ACCESS_DENIED"));
performAuthorized(mockMvc, token, get("/api/reservation/tasks/{taskId}", taskId))
.andExpect(status().isForbidden())
.andExpect(jsonPath("$.error_code").value("HOTEL_ACCESS_DENIED"));
performAuthorized(mockMvc, token, get("/api/reservation/tasks/{taskId}/audits", taskId))
.andExpect(status().isForbidden())
.andExpect(jsonPath("$.error_code").value("HOTEL_ACCESS_DENIED"));
performAuthorized(mockMvc, token, get("/api/source-messages/{id}", source.inboxId()))
.andExpect(status().isForbidden())
.andExpect(jsonPath("$.error_code").value("HOTEL_ACCESS_DENIED"));
}
@Test
void shouldKeepSuperAgentTaskResultEndpointOutsideFrontendLoginInterceptor() throws Exception {
mockMvc.perform(post("/api/integrations/superagent/task-results")
.contentType(MediaType.APPLICATION_JSON)
.content("{}"))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.error_code").value("AUTH_HEADER_MISSING"));
}
private SourceMessageCaptureResult captureOtherHotelSourceMessage() {
return captureService.capture(new CaptureSourceMessageCommand(
OTHER_HOTEL_ID,
"AGENTBUS",
"EMAIL",
"cp1-other-hotel-mail-001",
"cp1-other-hotel-thread-001",
"frame-cp1-other",
"session-cp1-other",
Instant.parse("2026-07-13T01:00:00Z"),
"guest@example.test",
"Other hotel message",
"Other hotel body",
"<html><body>Other hotel body</body></html>",
"{\"source\":{\"external_message_id\":\"cp1-other-hotel-mail-001\"}}",
"agentbus-outlook-v1",
List.of()));
}
private void insertOtherHotelOrderTask(Long orderId, Long taskId, Long sourceMessageId) {
Long transitionId = taskId - 1;
jdbcTemplate.update("""
INSERT INTO workflow_reservation_order (
id, hotel_id, order_key_type, order_business_key, active_business_key,
temporary_order_code, order_status, order_visibility, business_key_source, display_name,
source_message_id, version, created_at, updated_at, latest_activity_at
)
VALUES (?, ?, 'GROUP_CODE', 'GRP-CP1-OTHER', 'GRP-CP1-OTHER',
'TMP-CP1-OTHER', 'ACTIVE', 'VISIBLE', 'AI_CANDIDATE', 'GRP-CP1-OTHER',
?, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
""", orderId, OTHER_HOTEL_ID, sourceMessageId);
jdbcTemplate.update("""
INSERT INTO workflow_reservation_ai_transition (
id, hotel_id, batch_id, source_message_id, source_event_index, array_index,
execution_order, catalog_code, skill_id, result_type, ai_task_type,
system_task_type, task_card_type, task_subtype, current_or_history,
group_code, item_payload_sha256, item_idempotency_key, blocked_until_parent_completed,
ai_payload_json, case_keys_json, extracted_fields_json, created_at, updated_at
)
VALUES (?, ?, ?, ?, 1, 1, 1, 'S02', 'cp1_skill',
'normal_task', 'New Booking', 'NEW_BOOKING', 'NEW_BOOKING', 'new_group_block',
'current', 'GRP-CP1-OTHER', ?, ?, 0, '{}', '{}', '{}', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
""", transitionId, OTHER_HOTEL_ID, transitionId - 1, sourceMessageId, "1".repeat(64),
transitionId.toString());
jdbcTemplate.update("""
INSERT INTO workflow_reservation_task (
id, hotel_id, order_id, source_message_id, ai_transition_id,
result_type, ai_task_type, system_task_type, task_card_type, task_subtype,
task_status, queue_participation, execution_order, blocked_until_parent_completed,
version, created_at, updated_at
)
VALUES (?, ?, ?, ?, ?, 'normal_task', 'New Booking',
'NEW_BOOKING', 'NEW_BOOKING', 'new_group_block', 'PENDING_CONFIRM',
1, 1, 0, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
""", taskId, OTHER_HOTEL_ID, orderId, sourceMessageId, transitionId);
jdbcTemplate.update("""
INSERT INTO workflow_reservation_task_card (
id, hotel_id, task_id, task_card_type, field_contract_version,
ai_payload_json, created_at, updated_at
)
VALUES (?, ?, ?, 'NEW_BOOKING', 'test', '{}', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
""", taskId + 1, OTHER_HOTEL_ID, taskId);
}
}
@@ -4,6 +4,8 @@ import static org.hamcrest.Matchers.greaterThanOrEqualTo;
import static org.hamcrest.Matchers.hasSize;
import static org.hamcrest.Matchers.not;
import static org.hamcrest.Matchers.blankOrNullString;
import static cn.nianxx.thhotel.support.MockMvcAuthTestSupport.loginToken;
import static cn.nianxx.thhotel.support.MockMvcAuthTestSupport.performAuthorized;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
@@ -24,8 +26,16 @@ import org.springframework.test.web.servlet.MvcResult;
@SpringBootTest(
classes = ThHotelApplication.class,
properties = {
"spring.datasource.url=jdbc:h2:mem:reservation_demo_data_controller_test;MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE",
"reservation.demo-data.enabled=true",
"reservation.demo-data.access-key=test-demo-data-key"
"reservation.demo-data.access-key=test-demo-data-key",
"auth.bootstrap.admin.username=demo-data-admin",
"auth.bootstrap.admin.password=Admin@123456",
"auth.bootstrap.admin.display-name=演示数据管理员",
"auth.bootstrap.default-hotel-id=HOTEL-TEST",
"auth.bootstrap.default-hotel-name=测试酒店",
"auth.bootstrap.default-hotel-time-zone=Asia/Bangkok",
"auth.session.ttl-minutes=720"
})
@AutoConfigureMockMvc
@ActiveProfiles("test")
@@ -37,6 +47,18 @@ class ReservationDemoDataControllerTest {
@Autowired
private MockMvc mockMvc;
private String adminToken;
/**
* 获取演示数据查询阶段使用的管理员 token。
*/
private String adminToken() throws Exception {
if (adminToken == null) {
adminToken = loginToken(mockMvc, "demo-data-admin", "Admin@123456");
}
return adminToken;
}
@Test
void shouldRejectDemoDataSeedWhenAccessKeyMissing() throws Exception {
mockMvc.perform(post("/api/system/reservation/demo-data")
@@ -76,7 +98,7 @@ class ReservationDemoDataControllerTest {
String blockedTaskId = first(response, "$.tasks[?(@.scenario_code=='QUEUE_BLOCKED')].task_id");
String failedTaskId = first(response, "$.tasks[?(@.scenario_code=='OPERA_FAILED')].task_id");
mockMvc.perform(get("/api/reservation/tasks")
performAuthorized(mockMvc, adminToken(), get("/api/reservation/tasks")
.param("hotel_id", HOTEL_ID)
.param("keyword", demoRunId)
.param("page_num", "1")
@@ -84,7 +106,7 @@ class ReservationDemoDataControllerTest {
.andExpect(status().isOk())
.andExpect(jsonPath("$.items", hasSize(greaterThanOrEqualTo(5))));
mockMvc.perform(get("/api/reservation/orders")
performAuthorized(mockMvc, adminToken(), get("/api/reservation/orders")
.param("hotel_id", HOTEL_ID)
.param("keyword", demoRunId)
.param("page_num", "1")
@@ -92,7 +114,7 @@ class ReservationDemoDataControllerTest {
.andExpect(status().isOk())
.andExpect(jsonPath("$.items", hasSize(greaterThanOrEqualTo(5))));
mockMvc.perform(get("/api/reservation/orders/{orderId}", queueOrderId)
performAuthorized(mockMvc, adminToken(), get("/api/reservation/orders/{orderId}", queueOrderId)
.param("hotel_id", HOTEL_ID)
.param("include_tasks", "true")
.param("include_source_summary", "true"))
@@ -100,13 +122,13 @@ class ReservationDemoDataControllerTest {
.andExpect(jsonPath("$.tasks", hasSize(greaterThanOrEqualTo(2))))
.andExpect(jsonPath("$.tasks[1].readonly_reason_code").value("PREVIOUS_TASK_NOT_FINISHED"));
mockMvc.perform(get("/api/reservation/tasks/{taskId}", blockedTaskId))
performAuthorized(mockMvc, adminToken(), get("/api/reservation/tasks/{taskId}", blockedTaskId))
.andExpect(status().isOk())
.andExpect(jsonPath("$.availability.blocked").value(true))
.andExpect(jsonPath("$.availability.read_only").value(true))
.andExpect(jsonPath("$.availability.blocked_by_task_id", not(blankOrNullString())));
mockMvc.perform(get("/api/reservation/tasks/{taskId}", failedTaskId))
performAuthorized(mockMvc, adminToken(), get("/api/reservation/tasks/{taskId}", failedTaskId))
.andExpect(status().isOk())
.andExpect(jsonPath("$.opera_operations[0].operation_status").value("FAILED"))
.andExpect(jsonPath("$.opera_operations[0].attempt_count").value(1));
@@ -10,6 +10,8 @@ import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.reset;
import static org.mockito.Mockito.verify;
import static cn.nianxx.thhotel.support.MockMvcAuthTestSupport.loginToken;
import static cn.nianxx.thhotel.support.MockMvcAuthTestSupport.performAuthorized;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@@ -40,7 +42,18 @@ import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.bean.override.mockito.MockitoSpyBean;
import org.springframework.test.web.servlet.MockMvc;
@SpringBootTest(classes = ThHotelApplication.class)
@SpringBootTest(
classes = ThHotelApplication.class,
properties = {
"spring.datasource.url=jdbc:h2:mem:reservation_frontend_query_test;MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE",
"auth.bootstrap.admin.username=frontend-query-admin",
"auth.bootstrap.admin.password=Admin@123456",
"auth.bootstrap.admin.display-name=前端查询管理员",
"auth.bootstrap.default-hotel-id=HOTEL-TEST",
"auth.bootstrap.default-hotel-name=测试酒店",
"auth.bootstrap.default-hotel-time-zone=Asia/Bangkok",
"auth.session.ttl-minutes=720"
})
@AutoConfigureMockMvc
@ActiveProfiles("test")
class ReservationFrontendQueryControllerTest {
@@ -63,6 +76,18 @@ class ReservationFrontendQueryControllerTest {
@Autowired
private JdbcTemplate jdbcTemplate;
private String adminToken;
/**
* 获取前端查询管理员 token,测试通过真实登录接口覆盖认证链路。
*/
private String adminToken() throws Exception {
if (adminToken == null) {
adminToken = loginToken(mockMvc, "frontend-query-admin", "Admin@123456");
}
return adminToken;
}
@Test
void shouldReturnTaskWorkbenchListWithRealtimeAvailability() throws Exception {
SourceMessageCaptureResult source = captureSourceMessage(
@@ -83,7 +108,7 @@ class ReservationFrontendQueryControllerTest {
reset(sourceMessageQueryService);
reset(workflowRepository);
mockMvc.perform(get("/api/reservation/tasks")
performAuthorized(mockMvc, adminToken(), get("/api/reservation/tasks")
.param("hotel_id", HOTEL_ID)
.param("order_id", orderId.toString())
.param("page_num", "1")
@@ -119,7 +144,7 @@ class ReservationFrontendQueryControllerTest {
.andExpect(jsonPath("$.page.page_size").value(20))
.andExpect(jsonPath("$.page.total").value(2));
mockMvc.perform(get("/api/reservation/tasks")
performAuthorized(mockMvc, adminToken(), get("/api/reservation/tasks")
.param("hotel_id", HOTEL_ID)
.param("keyword", "Frontend Query List Smoke")
.param("page_num", "1")
@@ -152,7 +177,7 @@ class ReservationFrontendQueryControllerTest {
insertTask(endedTaskId, endedOrderId, source.inboxId(), 930000000000001202L, "Cancel Booking",
"CANCEL_BOOKING", "CANCEL_BOOKING", "PENDING_CONFIRM", 1);
mockMvc.perform(get("/api/reservation/tasks")
performAuthorized(mockMvc, adminToken(), get("/api/reservation/tasks")
.param("hotel_id", HOTEL_ID)
.param("order_status", "ENDED")
.param("keyword", "GRP-FRONTEND-TASK-")
@@ -191,7 +216,7 @@ class ReservationFrontendQueryControllerTest {
insertTask(latestTaskId, latestOrderId, latestSource.inboxId(), 930000000000001502L, "Update Booking",
"UPDATE_BOOKING", "UPDATE_BOOKING", "PENDING_CONFIRM", 1);
mockMvc.perform(get("/api/reservation/tasks")
performAuthorized(mockMvc, adminToken(), get("/api/reservation/tasks")
.param("hotel_id", HOTEL_ID)
.param("keyword", "GRP-FRONTEND-TASK-SORT-")
.param("page_num", "1")
@@ -235,7 +260,7 @@ class ReservationFrontendQueryControllerTest {
updateOrderLatestActivityAt(staleButRecentlyUpdatedOrderId, Instant.parse("2026-07-09T00:00:00Z"));
updateOrderLatestActivityAt(latestActivityOrderId, Instant.parse("2026-07-10T09:30:00Z"));
mockMvc.perform(get("/api/reservation/orders")
performAuthorized(mockMvc, adminToken(), get("/api/reservation/orders")
.param("hotel_id", HOTEL_ID)
.param("keyword", "GRP-FRONTEND-ORDER-SORT-")
.param("page_num", "1")
@@ -276,7 +301,7 @@ class ReservationFrontendQueryControllerTest {
"GRP-FRONTEND-VISIBLE-FILTER-ARCHIVED", "ACTIVE");
updateOrderVisibility(archivedOrderId, "ARCHIVED_SYSTEM");
mockMvc.perform(get("/api/reservation/orders")
performAuthorized(mockMvc, adminToken(), get("/api/reservation/orders")
.param("hotel_id", HOTEL_ID)
.param("keyword", "GRP-FRONTEND-VISIBLE-FILTER-")
.param("page_num", "1")
@@ -355,7 +380,7 @@ class ReservationFrontendQueryControllerTest {
"UPDATE_BOOKING", "UPDATE_BOOKING", "PENDING_CONFIRM", 2);
reset(workflowRepository);
mockMvc.perform(get("/api/reservation/orders/{orderId}", orderId)
performAuthorized(mockMvc, adminToken(), get("/api/reservation/orders/{orderId}", orderId)
.param("hotel_id", HOTEL_ID)
.param("include_tasks", "true")
.param("include_source_summary", "true"))
@@ -387,7 +412,7 @@ class ReservationFrontendQueryControllerTest {
.andExpect(jsonPath("$.warnings.length()").value(0));
verify(workflowRepository, never()).findQueueTasksBefore(anyString(), anyLong(), any());
mockMvc.perform(get("/api/reservation/orders/{orderId}", orderId)
performAuthorized(mockMvc, adminToken(), get("/api/reservation/orders/{orderId}", orderId)
.param("hotel_id", HOTEL_ID)
.param("include_tasks", "false"))
.andExpect(status().isOk())
@@ -420,7 +445,7 @@ class ReservationFrontendQueryControllerTest {
insertTask(failedTaskId, activeOrderId, source.inboxId(), 930000000000000803L, "Cancel Booking",
"CANCEL_BOOKING", "CANCEL_BOOKING", "FAILED", 3);
mockMvc.perform(get("/api/reservation/orders")
performAuthorized(mockMvc, adminToken(), get("/api/reservation/orders")
.param("hotel_id", HOTEL_ID)
.param("keyword", "GRP-FRONTEND-ORDERS-")
.param("page_num", "1")
@@ -439,7 +464,7 @@ class ReservationFrontendQueryControllerTest {
.value(contains(0)))
.andExpect(jsonPath("$.page.total").value(2));
mockMvc.perform(get("/api/reservation/orders")
performAuthorized(mockMvc, adminToken(), get("/api/reservation/orders")
.param("hotel_id", HOTEL_ID)
.param("order_status", "ENDED")
.param("keyword", "GRP-FRONTEND-ORDERS-")
@@ -6,6 +6,8 @@ import static org.hamcrest.Matchers.matchesPattern;
import static org.hamcrest.Matchers.not;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static cn.nianxx.thhotel.support.MockMvcAuthTestSupport.loginToken;
import static cn.nianxx.thhotel.support.MockMvcAuthTestSupport.performAuthorized;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
@@ -40,11 +42,19 @@ import org.springframework.test.web.servlet.MvcResult;
@SpringBootTest(
classes = ThHotelApplication.class,
properties = {
"spring.datasource.url=jdbc:h2:mem:superagent_task_result_controller_test;MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE",
"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",
"superagent.task-result.allow-legacy-internal-source-message-id=true"
"superagent.task-result.allow-legacy-internal-source-message-id=true",
"auth.bootstrap.admin.username=superagent-result-admin",
"auth.bootstrap.admin.password=Admin@123456",
"auth.bootstrap.admin.display-name=SuperAgent回归管理员",
"auth.bootstrap.default-hotel-id=HOTEL-TEST",
"auth.bootstrap.default-hotel-name=测试酒店",
"auth.bootstrap.default-hotel-time-zone=Asia/Bangkok",
"auth.session.ttl-minutes=720"
})
@AutoConfigureMockMvc
@ActiveProfiles("test")
@@ -64,6 +74,18 @@ class SuperAgentTaskResultControllerTest {
@Autowired
private JdbcTemplate jdbcTemplate;
private String adminToken;
/**
* 获取 SuperAgent 回调测试里前端只读接口验证使用的管理员 token。
*/
private String adminToken() throws Exception {
if (adminToken == null) {
adminToken = loginToken(mockMvc, "superagent-result-admin", "Admin@123456");
}
return adminToken;
}
@Test
void shouldRejectRequestWithoutHmacHeaders() throws Exception {
String body = minimalBody("1", "New Booking", "normal_task", "new_fit_reservation", """
@@ -522,7 +544,7 @@ class SuperAgentTaskResultControllerTest {
String taskId = com.jayway.jsonpath.JsonPath.read(result.getResponse().getContentAsString(), "$.items[0].task_id");
String orderId = com.jayway.jsonpath.JsonPath.read(result.getResponse().getContentAsString(), "$.items[0].order_id");
mockMvc.perform(get("/api/reservation/tasks/{taskId}", taskId))
performAuthorized(mockMvc, adminToken(), get("/api/reservation/tasks/{taskId}", taskId))
.andExpect(status().isOk())
.andExpect(jsonPath("$.system_task_type").value("SOURCE_MESSAGE_ONLY"))
.andExpect(jsonPath("$.task_card_type").value("SOURCE_MESSAGE_ONLY"))
@@ -560,7 +582,7 @@ class SuperAgentTaskResultControllerTest {
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.error_code").value("TASK_NOT_MANUAL_REVIEW"));
mockMvc.perform(get("/api/reservation/tasks")
performAuthorized(mockMvc, adminToken(), get("/api/reservation/tasks")
.param("hotel_id", "HOTEL-TEST")
.param("task_type", "SOURCE_MESSAGE_ONLY")
.param("keyword", "mail-s000-entry-result-001"))
@@ -572,7 +594,7 @@ class SuperAgentTaskResultControllerTest {
.andExpect(jsonPath("$.items[0].queue_participation").value(false))
.andExpect(jsonPath("$.items[0].can_process").value(false));
mockMvc.perform(get("/api/reservation/orders")
performAuthorized(mockMvc, adminToken(), get("/api/reservation/orders")
.param("hotel_id", "HOTEL-TEST")
.param("keyword", "mail-s000-entry-result-001"))
.andExpect(status().isOk())
@@ -871,7 +893,7 @@ class SuperAgentTaskResultControllerTest {
"$.items[0].task_id"
);
mockMvc.perform(get("/api/reservation/tasks")
performAuthorized(mockMvc, adminToken(), get("/api/reservation/tasks")
.param("hotel_id", "HOTEL-TEST")
.param("task_type", "SOURCE_MESSAGE_ONLY")
.param("keyword", "mail-v3-s99-frontend-visible-001"))
@@ -882,7 +904,7 @@ class SuperAgentTaskResultControllerTest {
.andExpect(jsonPath("$.items[0].route_code").value("S99"))
.andExpect(jsonPath("$.items[0].system_process_category").value("SOURCE_MESSAGE_NOTIFICATION"));
mockMvc.perform(get("/api/reservation/tasks/{taskId}", taskId))
performAuthorized(mockMvc, adminToken(), get("/api/reservation/tasks/{taskId}", taskId))
.andExpect(status().isOk())
.andExpect(jsonPath("$.result_type").value("source_message_review_notification"))
.andExpect(jsonPath("$.ai_task_type").value("S99"))
@@ -1465,7 +1487,7 @@ class SuperAgentTaskResultControllerTest {
"$.items[1].task_id"
);
mockMvc.perform(get("/api/reservation/tasks")
performAuthorized(mockMvc, adminToken(), get("/api/reservation/tasks")
.param("hotel_id", "HOTEL-TEST")
.param("keyword", "mail-v3-frontend-route-blocks-001"))
.andExpect(status().isOk())
@@ -1475,7 +1497,7 @@ class SuperAgentTaskResultControllerTest {
.andExpect(jsonPath("$.items[0].route_code").value("R01_NEW_FIT_RESERVATION_NORMAL"))
.andExpect(jsonPath("$.items[0].system_process_category").value("BUSINESS_TASK"));
mockMvc.perform(get("/api/reservation/orders/{orderId}", orderId)
performAuthorized(mockMvc, adminToken(), get("/api/reservation/orders/{orderId}", orderId)
.param("hotel_id", "HOTEL-TEST")
.param("include_tasks", "true"))
.andExpect(status().isOk())
@@ -1485,7 +1507,7 @@ class SuperAgentTaskResultControllerTest {
.andExpect(jsonPath("$.tasks[0].route_code").value("R01_NEW_FIT_RESERVATION_NORMAL"))
.andExpect(jsonPath("$.tasks[0].system_process_category").value("BUSINESS_TASK"));
mockMvc.perform(get("/api/reservation/tasks/{taskId}", taskId))
performAuthorized(mockMvc, adminToken(), get("/api/reservation/tasks/{taskId}", taskId))
.andExpect(status().isOk())
.andExpect(jsonPath("$.result_type").value("normal_task"))
.andExpect(jsonPath("$.ai_task_type").value("New Booking"))
@@ -1646,7 +1668,7 @@ class SuperAgentTaskResultControllerTest {
String firstTaskId = com.jayway.jsonpath.JsonPath.read(result.getResponse().getContentAsString(), "$.items[0].task_id");
String secondTaskId = com.jayway.jsonpath.JsonPath.read(result.getResponse().getContentAsString(), "$.items[1].task_id");
mockMvc.perform(get("/api/reservation/tasks/{taskId}", secondTaskId))
performAuthorized(mockMvc, adminToken(), get("/api/reservation/tasks/{taskId}", secondTaskId))
.andExpect(status().isOk())
.andExpect(jsonPath("$.task_id").value(secondTaskId))
.andExpect(jsonPath("$.availability.blocked").value(true))
@@ -1662,7 +1684,7 @@ class SuperAgentTaskResultControllerTest {
WHERE id = ?
""", Long.valueOf(firstTaskId));
mockMvc.perform(get("/api/reservation/tasks/{taskId}", secondTaskId))
performAuthorized(mockMvc, adminToken(), get("/api/reservation/tasks/{taskId}", secondTaskId))
.andExpect(status().isOk())
.andExpect(jsonPath("$.availability.blocked").value(true))
.andExpect(jsonPath("$.availability.blocked_by_task_id").value(firstTaskId));
@@ -1673,7 +1695,7 @@ class SuperAgentTaskResultControllerTest {
WHERE id = ?
""", Long.valueOf(firstTaskId));
mockMvc.perform(get("/api/reservation/tasks/{taskId}", secondTaskId))
performAuthorized(mockMvc, adminToken(), get("/api/reservation/tasks/{taskId}", secondTaskId))
.andExpect(status().isOk())
.andExpect(jsonPath("$.availability.blocked").value(true))
.andExpect(jsonPath("$.availability.blocked_by_task_id").value(firstTaskId));
@@ -1684,7 +1706,7 @@ class SuperAgentTaskResultControllerTest {
WHERE id = ?
""", Long.valueOf(firstTaskId));
mockMvc.perform(get("/api/reservation/tasks/{taskId}", secondTaskId))
performAuthorized(mockMvc, adminToken(), get("/api/reservation/tasks/{taskId}", secondTaskId))
.andExpect(status().isOk())
.andExpect(jsonPath("$.availability.blocked").value(false))
.andExpect(jsonPath("$.availability.read_only").value(false))
@@ -1705,7 +1727,7 @@ class SuperAgentTaskResultControllerTest {
.andReturn();
String taskId = com.jayway.jsonpath.JsonPath.read(result.getResponse().getContentAsString(), "$.items[0].task_id");
mockMvc.perform(get("/api/reservation/tasks/{taskId}", taskId))
performAuthorized(mockMvc, adminToken(), get("/api/reservation/tasks/{taskId}", taskId))
.andExpect(status().isOk())
.andExpect(jsonPath("$.task_card_type").value("NEW_BOOKING"))
.andExpect(jsonPath("$.source_subject").value("M002 SuperAgent intake"))
@@ -1820,7 +1842,7 @@ class SuperAgentTaskResultControllerTest {
.andExpect(jsonPath("$.opera_operations[1].operation_sequence").value(2))
.andExpect(jsonPath("$.opera_operations[1].operation_status").value("PENDING"));
mockMvc.perform(get("/api/reservation/tasks/{taskId}", taskId))
performAuthorized(mockMvc, adminToken(), get("/api/reservation/tasks/{taskId}", taskId))
.andExpect(status().isOk())
.andExpect(jsonPath("$.opera_operations.length()").value(2))
.andExpect(jsonPath("$.opera_operations[0].operation_sequence").value(1))
@@ -1938,7 +1960,7 @@ class SuperAgentTaskResultControllerTest {
.andExpect(jsonPath("$.attempts[0].attempt_number").value(1))
.andExpect(jsonPath("$.attempts[0].attempt_status").value("SUCCEEDED"));
mockMvc.perform(get("/api/reservation/tasks/{taskId}", taskId))
performAuthorized(mockMvc, adminToken(), get("/api/reservation/tasks/{taskId}", taskId))
.andExpect(status().isOk())
.andExpect(jsonPath("$.opera_operations[0].operation_id").value(firstOperationId))
.andExpect(jsonPath("$.opera_operations[0].operation_status").value("SUCCEEDED"))
@@ -2065,7 +2087,7 @@ class SuperAgentTaskResultControllerTest {
"""))
.andExpect(status().isOk());
mockMvc.perform(get("/api/reservation/tasks/{taskId}/audits", taskId))
performAuthorized(mockMvc, adminToken(), get("/api/reservation/tasks/{taskId}/audits", taskId))
.andExpect(status().isOk())
.andExpect(jsonPath("$.task_id").value(taskId))
.andExpect(jsonPath("$.items[?(@.action=='TASK_CONFIRM')].action")
@@ -2208,7 +2230,7 @@ class SuperAgentTaskResultControllerTest {
WHERE source_message_id = ?
""", Long.class, source.inboxId());
mockMvc.perform(get("/api/reservation/tasks/{taskId}", taskId))
performAuthorized(mockMvc, adminToken(), get("/api/reservation/tasks/{taskId}", taskId))
.andExpect(status().isOk())
.andExpect(jsonPath("$.result_type").value("manual_review"))
.andExpect(jsonPath("$.review_status").value("PENDING"))
@@ -3,6 +3,8 @@ package cn.nianxx.thhotel.workflows.reservation.control;
import static org.assertj.core.api.Assertions.assertThat;
import static org.hamcrest.Matchers.contains;
import static org.hamcrest.Matchers.containsString;
import static cn.nianxx.thhotel.support.MockMvcAuthTestSupport.loginToken;
import static cn.nianxx.thhotel.support.MockMvcAuthTestSupport.performAuthorized;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
@@ -40,10 +42,18 @@ import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilde
@SpringBootTest(
classes = ThHotelApplication.class,
properties = {
"spring.datasource.url=jdbc:h2:mem:superagent_task_result_p0_fixture_test;MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE",
"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=20000"
"superagent.task-result.max-body-bytes=20000",
"auth.bootstrap.admin.username=p0-fixture-admin",
"auth.bootstrap.admin.password=Admin@123456",
"auth.bootstrap.admin.display-name=P0基线管理员",
"auth.bootstrap.default-hotel-id=HOTEL-TEST",
"auth.bootstrap.default-hotel-name=测试酒店",
"auth.bootstrap.default-hotel-time-zone=Asia/Bangkok",
"auth.session.ttl-minutes=720"
})
@AutoConfigureMockMvc
@ActiveProfiles("test")
@@ -73,6 +83,18 @@ class SuperAgentTaskResultP0FixtureRegressionTest {
@Autowired
private JdbcTemplate jdbcTemplate;
private String adminToken;
/**
* 获取 P0 fixture 回归测试里前端只读接口验证使用的管理员 token。
*/
private String adminToken() throws Exception {
if (adminToken == null) {
adminToken = loginToken(mockMvc, "p0-fixture-admin", "Admin@123456");
}
return adminToken;
}
@Test
void shouldAcceptLegalS10AndS99MainOutcomeFixtures() throws Exception {
JsonNode cases = fixture("main_outcomes.json").path("cases");
@@ -99,7 +121,7 @@ class SuperAgentTaskResultP0FixtureRegressionTest {
String s10TaskId = com.jayway.jsonpath.JsonPath.read(
s10Result.getResponse().getContentAsString(),
"$.items[0].task_id");
mockMvc.perform(get("/api/reservation/tasks/{taskId}", s10TaskId))
performAuthorized(mockMvc, adminToken(), get("/api/reservation/tasks/{taskId}", s10TaskId))
.andExpect(status().isOk())
.andExpect(jsonPath("$.fields.length()").value(0));
@@ -466,7 +488,7 @@ class SuperAgentTaskResultP0FixtureRegressionTest {
createResult.getResponse().getContentAsString(),
"$.items[2].order_id");
mockMvc.perform(get("/api/reservation/tasks/{taskId}", taskId))
performAuthorized(mockMvc, adminToken(), get("/api/reservation/tasks/{taskId}", taskId))
.andExpect(status().isOk())
.andExpect(jsonPath("$.fields[?(@.field_path=='case_keys.group_code')].field_pointer")
.value(contains("/case_keys/group_code")));
@@ -679,7 +701,7 @@ class SuperAgentTaskResultP0FixtureRegressionTest {
result.getResponse().getContentAsString(),
"$.items[0].task_id");
mockMvc.perform(get("/api/reservation/tasks/{taskId}", taskId))
performAuthorized(mockMvc, adminToken(), get("/api/reservation/tasks/{taskId}", taskId))
.andExpect(status().isOk())
.andExpect(jsonPath("$.fields[?(@.field_path=='extracted_fields.room_items.0.room_quantity')].field_pointer")
.value(contains("/extracted_fields/room_items/0/room_quantity")))
@@ -856,7 +878,7 @@ class SuperAgentTaskResultP0FixtureRegressionTest {
result.getResponse().getContentAsString(),
"$.items[0].order_id");
mockMvc.perform(get("/api/reservation/tasks/{taskId}", taskId))
performAuthorized(mockMvc, adminToken(), get("/api/reservation/tasks/{taskId}", taskId))
.andExpect(status().isOk())
.andExpect(jsonPath("$.fields[?(@.field_path=='extracted_fields.room_items.0.pms_room_type_code')].field_pointer")
.value(contains("/extracted_fields/room_items/0/pms_room_type_code")))