feat(i8-i9): webhook DEAD replay, read-only delivery status, and callback UI

I8: platform proxies replay to webhook; webhook ops token filter and internal
replay endpoint; delivery service supports read/replay flows.

I9: platform GET callback webhook delivery status by inbox id; UI shows
read-only status block and handles load errors without blocking the page.

Also refresh OpenAPI, Runbook notes, test fixtures and YAML; fix Vite dev
axios baseURL so /api uses proxy; improve login error messaging.

Made-with: Cursor
This commit is contained in:
2026-04-07 21:26:44 +08:00
parent 5e051633ec
commit d53ddf32c8
20 changed files with 874 additions and 6 deletions
@@ -1,16 +1,25 @@
package cn.craftlabs.platform.api.callback;
import cn.craftlabs.platform.api.support.JwtTestSupport;
import cn.craftlabs.platform.api.web.dto.CallbackWebhookDeliveryStatusResponse;
import cn.craftlabs.platform.api.webhook.WebhookDeliveryReplayClient;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
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.boot.test.mock.mockito.MockBean;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.transaction.annotation.Transactional;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doNothing;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.nullValue;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
@@ -33,6 +42,9 @@ class CallbackInboxControllerTest {
@Autowired
private ObjectMapper objectMapper;
@MockBean
private WebhookDeliveryReplayClient webhookDeliveryReplayClient;
@Test
void listDetailStatusLinkAndIntegrationCatalog() throws Exception {
String token = JwtTestSupport.obtainBearerToken(mockMvc, objectMapper);
@@ -55,6 +67,8 @@ class CallbackInboxControllerTest {
.andExpect(status().isOk())
.andExpect(jsonPath("$.content.length()").value(2));
when(webhookDeliveryReplayClient.isConfigured()).thenReturn(false);
String ingestBody = minimalIngestJson("msg-inbox-flow-1");
String ingestResp =
mockMvc.perform(
@@ -110,6 +124,7 @@ class CallbackInboxControllerTest {
@Test
void developerCannotAccessCallbackInbox() throws Exception {
when(webhookDeliveryReplayClient.isConfigured()).thenReturn(false);
String token = JwtTestSupport.obtainBearerToken(mockMvc, objectMapper, "dev", "dev");
mockMvc.perform(
get("/api/v1/callback-inbox")
@@ -119,13 +134,85 @@ class CallbackInboxControllerTest {
.andExpect(status().isForbidden());
}
@Test
void replayWebhookDeliveryDelegatesToWebhookClient() throws Exception {
when(webhookDeliveryReplayClient.isConfigured()).thenReturn(true);
doNothing().when(webhookDeliveryReplayClient).replay(anyLong());
String ingestBody = minimalIngestJson("msg-replay-flow", "4242");
String ingestResp =
mockMvc.perform(
post("/internal/v1/callback-events")
.header(INTERNAL_HEADER, INTERNAL_TOKEN)
.contentType(MediaType.APPLICATION_JSON)
.content(ingestBody))
.andExpect(status().isOk())
.andReturn()
.getResponse()
.getContentAsString();
long inboxId = objectMapper.readTree(ingestResp).get("inboxId").asLong();
String token = JwtTestSupport.obtainBearerToken(mockMvc, objectMapper);
mockMvc.perform(
post("/api/v1/callback-inbox/" + inboxId + "/replay-webhook-delivery")
.header("Authorization", "Bearer " + token))
.andExpect(status().isOk())
.andExpect(jsonPath("$.status").value("REQUEUED"))
.andExpect(jsonPath("$.receiptId").value("4242"));
verify(webhookDeliveryReplayClient).replay(4242L);
}
@Test
void getWebhookDeliveryStatusDelegatesToWebhookClient() throws Exception {
when(webhookDeliveryReplayClient.isConfigured()).thenReturn(true);
CallbackWebhookDeliveryStatusResponse wh = new CallbackWebhookDeliveryStatusResponse();
wh.setReceiptId(777L);
wh.setStatus("PENDING");
wh.setAttempts(2);
wh.setLastError("probe");
when(webhookDeliveryReplayClient.fetchDeliveryStatus(eq(777L))).thenReturn(wh);
String ingestBody = minimalIngestJson("msg-webhook-delivery-status", "777");
String ingestResp =
mockMvc.perform(
post("/internal/v1/callback-events")
.header(INTERNAL_HEADER, INTERNAL_TOKEN)
.contentType(MediaType.APPLICATION_JSON)
.content(ingestBody))
.andExpect(status().isOk())
.andReturn()
.getResponse()
.getContentAsString();
long inboxId = objectMapper.readTree(ingestResp).get("inboxId").asLong();
String token = JwtTestSupport.obtainBearerToken(mockMvc, objectMapper);
mockMvc.perform(
get("/api/v1/callback-inbox/" + inboxId + "/webhook-delivery")
.header("Authorization", "Bearer " + token))
.andExpect(status().isOk())
.andExpect(jsonPath("$.receiptId").value(777))
.andExpect(jsonPath("$.status").value("PENDING"))
.andExpect(jsonPath("$.attempts").value(2))
.andExpect(jsonPath("$.lastError").value("probe"));
verify(webhookDeliveryReplayClient).fetchDeliveryStatus(777L);
}
private String minimalIngestJson(String externalMessageId) throws Exception {
return minimalIngestJson(externalMessageId, null);
}
private String minimalIngestJson(String externalMessageId, String webhookReceiptId) throws Exception {
ObjectNode root = objectMapper.createObjectNode();
root.put("schemaVersion", "1.0");
root.put("sourceSystem", "BITANSWER");
root.put("externalMessageId", externalMessageId);
root.put("eventType", "sn:test");
root.set("rawPayload", objectMapper.createObjectNode().put("sn", "SN-X"));
if (webhookReceiptId != null) {
root.put("webhookReceiptId", webhookReceiptId);
}
return objectMapper.writeValueAsString(root);
}
}
@@ -14,3 +14,8 @@ platform:
secret: unit-test-jwt-secret-at-least-32-chars-ok
internal:
token: unit-test-internal-token-for-callback-ingest
craftlabs:
webhook:
base-url: http://127.0.0.1:65520
ops-token: unit-test-webhook-ops-token