576 lines
22 KiB
Go
576 lines
22 KiB
Go
package tools
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
|
)
|
|
|
|
func TestISphereSendMessagePreviewPlansAndAuditsWithoutRawContent(t *testing.T) {
|
|
audit := &fakeSendMessageAuditSink{}
|
|
idempotency := newFakeSendMessageIdempotencyStore()
|
|
session, cleanup := connectToolsTestSession(t, func(server *mcp.Server) {
|
|
RegisterISphereSendMessageToolWithState(server, audit, idempotency)
|
|
})
|
|
defer cleanup()
|
|
|
|
content := "hello preview"
|
|
contentHash := sha256HexForSendMessageTest(content)
|
|
callResult, err := session.CallTool(context.Background(), &mcp.CallToolParams{
|
|
Name: ToolNameSendMessage,
|
|
Arguments: map[string]any{
|
|
"target_type": "direct",
|
|
"target_id": "alice@imopenfire1-lanzhou",
|
|
"content_text": content,
|
|
"content_sha256": contentHash,
|
|
"idempotency_key": "idem-preview-1",
|
|
"execution_mode": "preview",
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("call %s: %v", ToolNameSendMessage, err)
|
|
}
|
|
if callResult.IsError {
|
|
t.Fatalf("call result is error: %+v", callResult)
|
|
}
|
|
|
|
var decoded struct {
|
|
OK bool `json:"ok"`
|
|
SendStatus string `json:"send_status"`
|
|
ExecutionMode string `json:"execution_mode"`
|
|
Connector string `json:"connector"`
|
|
ConnectorStage string `json:"connector_stage"`
|
|
Target map[string]any `json:"target"`
|
|
Content map[string]any `json:"content"`
|
|
SideEffects map[string]any `json:"side_effects"`
|
|
Audit map[string]any `json:"audit"`
|
|
}
|
|
payload, err := json.Marshal(callResult.StructuredContent)
|
|
if err != nil {
|
|
t.Fatalf("marshal structured content: %v", err)
|
|
}
|
|
if err := json.Unmarshal(payload, &decoded); err != nil {
|
|
t.Fatalf("decode structured content %s: %v", payload, err)
|
|
}
|
|
if !decoded.OK || decoded.SendStatus != "planned" || decoded.ExecutionMode != "preview" {
|
|
t.Fatalf("unexpected preview status: %s", payload)
|
|
}
|
|
if decoded.Connector != "implatform-sidecar" || decoded.ConnectorStage != "preview" {
|
|
t.Fatalf("unexpected connector fields: %s", payload)
|
|
}
|
|
if decoded.Target["target_type"] != "direct" || decoded.Target["target_id"] != "alice@imopenfire1-lanzhou" || decoded.Target["target_ref"] != "contact:alice@imopenfire1-lanzhou" {
|
|
t.Fatalf("unexpected target: %#v", decoded.Target)
|
|
}
|
|
if decoded.Content["content_sha256"] != contentHash || decoded.Content["content_length"] != float64(len(content)) {
|
|
t.Fatalf("unexpected content metadata: %#v", decoded.Content)
|
|
}
|
|
if _, hasRawBody := decoded.Content["content_text"]; hasRawBody {
|
|
t.Fatalf("preview response leaked raw content body: %s", payload)
|
|
}
|
|
if decoded.SideEffects["sent_message"] != false || decoded.SideEffects["clicked_ui"] != false || decoded.SideEffects["captured_network"] != false {
|
|
t.Fatalf("side effect flags not all false: %#v", decoded.SideEffects)
|
|
}
|
|
if decoded.Audit["result"] != "planned" || decoded.Audit["tool"] != ToolNameSendMessage {
|
|
t.Fatalf("unexpected audit response: %#v", decoded.Audit)
|
|
}
|
|
if len(audit.events) != 1 {
|
|
t.Fatalf("audit events = %+v, want one", audit.events)
|
|
}
|
|
event := audit.events[0]
|
|
if event.ContentSHA256 != contentHash || event.TargetRef != "contact:alice@imopenfire1-lanzhou" || event.Result != "planned" {
|
|
t.Fatalf("unexpected audit event: %+v", event)
|
|
}
|
|
if event.ContentText != "" || event.IdempotencyKey != "" {
|
|
t.Fatalf("audit event leaked raw content or idempotency key: %+v", event)
|
|
}
|
|
}
|
|
|
|
func TestISphereSendMessageProductionIsBlockedWithoutSideEffects(t *testing.T) {
|
|
audit := &fakeSendMessageAuditSink{}
|
|
idempotency := newFakeSendMessageIdempotencyStore()
|
|
session, cleanup := connectToolsTestSession(t, func(server *mcp.Server) {
|
|
RegisterISphereSendMessageToolWithState(server, audit, idempotency)
|
|
})
|
|
defer cleanup()
|
|
|
|
contentHash := sha256HexForSendMessageTest("blocked send")
|
|
callResult, err := session.CallTool(context.Background(), &mcp.CallToolParams{
|
|
Name: ToolNameSendMessage,
|
|
Arguments: map[string]any{
|
|
"target_type": "group",
|
|
"target_id": "project-room@conference.imopenfire1-lanzhou",
|
|
"content_text": "blocked send",
|
|
"content_sha256": contentHash,
|
|
"idempotency_key": "idem-production-1",
|
|
"execution_mode": "production",
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("call %s production: %v", ToolNameSendMessage, err)
|
|
}
|
|
if callResult.IsError {
|
|
t.Fatalf("production blocked result should be structured, got error: %+v", callResult)
|
|
}
|
|
|
|
var decoded struct {
|
|
OK bool `json:"ok"`
|
|
SendStatus string `json:"send_status"`
|
|
BlockedReason string `json:"blocked_reason"`
|
|
Target map[string]any `json:"target"`
|
|
SideEffects map[string]any `json:"side_effects"`
|
|
Audit map[string]any `json:"audit"`
|
|
}
|
|
payload, _ := json.Marshal(callResult.StructuredContent)
|
|
if err := json.Unmarshal(payload, &decoded); err != nil {
|
|
t.Fatalf("decode structured content %s: %v", payload, err)
|
|
}
|
|
if decoded.OK || decoded.SendStatus != "blocked" || decoded.BlockedReason == "" {
|
|
t.Fatalf("production was not clearly blocked: %s", payload)
|
|
}
|
|
if decoded.Target["target_ref"] != "group:project-room@conference.imopenfire1-lanzhou" {
|
|
t.Fatalf("unexpected target: %#v", decoded.Target)
|
|
}
|
|
if decoded.SideEffects["sent_message"] != false || decoded.SideEffects["uploaded_file"] != false || decoded.SideEffects["typed_text"] != false {
|
|
t.Fatalf("side effect flags not all false: %#v", decoded.SideEffects)
|
|
}
|
|
if decoded.Audit["result"] != "blocked" {
|
|
t.Fatalf("unexpected audit response: %#v", decoded.Audit)
|
|
}
|
|
if len(audit.events) != 1 || audit.events[0].Result != "blocked" {
|
|
t.Fatalf("audit events = %+v, want one blocked event", audit.events)
|
|
}
|
|
}
|
|
|
|
func TestISphereSendMessageFakeConnectorAcceptedAudit(t *testing.T) {
|
|
audit := &fakeSendMessageAuditSink{}
|
|
idempotency := newFakeSendMessageIdempotencyStore()
|
|
connector := &fakeSendMessageConnector{
|
|
result: SendMessageConnectorResult{
|
|
Accepted: true,
|
|
Status: "accepted",
|
|
AckRef: "fake-ack-1",
|
|
ConnectorMode: "fake",
|
|
},
|
|
}
|
|
session, cleanup := connectToolsTestSession(t, func(server *mcp.Server) {
|
|
RegisterISphereSendMessageToolWithStateAndConnector(server, audit, idempotency, connector)
|
|
})
|
|
defer cleanup()
|
|
|
|
content := "fake connector accepted"
|
|
contentHash := sha256HexForSendMessageTest(content)
|
|
callResult, err := session.CallTool(context.Background(), &mcp.CallToolParams{
|
|
Name: ToolNameSendMessage,
|
|
Arguments: map[string]any{
|
|
"target_type": "direct",
|
|
"target_id": "alice@imopenfire1-lanzhou",
|
|
"content_text": content,
|
|
"content_sha256": contentHash,
|
|
"idempotency_key": "idem-fake-accepted-1",
|
|
"execution_mode": "production",
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("call %s fake connector accepted: %v", ToolNameSendMessage, err)
|
|
}
|
|
if callResult.IsError {
|
|
t.Fatalf("fake connector accepted result should be structured, got error: %+v", callResult)
|
|
}
|
|
|
|
var decoded struct {
|
|
OK bool `json:"ok"`
|
|
SendStatus string `json:"send_status"`
|
|
ConnectorMode string `json:"connector_mode"`
|
|
ProductionSendEnabled bool `json:"production_send_enabled"`
|
|
SideEffects map[string]any `json:"side_effects"`
|
|
Audit map[string]any `json:"audit"`
|
|
}
|
|
payload, _ := json.Marshal(callResult.StructuredContent)
|
|
if err := json.Unmarshal(payload, &decoded); err != nil {
|
|
t.Fatalf("decode fake accepted payload %s: %v", payload, err)
|
|
}
|
|
if !decoded.OK || decoded.SendStatus != "accepted" || decoded.ConnectorMode != "fake" {
|
|
t.Fatalf("unexpected fake accepted response: %s", payload)
|
|
}
|
|
if decoded.ProductionSendEnabled {
|
|
t.Fatalf("fake connector must not flip default production_send_enabled: %s", payload)
|
|
}
|
|
if decoded.SideEffects["sent_message"] != false || decoded.SideEffects["clicked_ui"] != false || decoded.SideEffects["captured_network"] != false {
|
|
t.Fatalf("fake connector should not report real side effects: %#v", decoded.SideEffects)
|
|
}
|
|
if decoded.Audit["ack_ref"] != "fake-ack-1" || decoded.Audit["result"] != "accepted" {
|
|
t.Fatalf("unexpected fake accepted audit: %#v", decoded.Audit)
|
|
}
|
|
if strings.Contains(string(payload), content) || strings.Contains(string(payload), "idem-fake-accepted-1") {
|
|
t.Fatalf("fake accepted payload leaked raw content or idempotency key: %s", payload)
|
|
}
|
|
if connector.calls != 1 {
|
|
t.Fatalf("fake connector calls = %d, want 1", connector.calls)
|
|
}
|
|
if len(audit.events) != 1 {
|
|
t.Fatalf("audit events = %+v, want one", audit.events)
|
|
}
|
|
event := audit.events[0]
|
|
if event.Result != "accepted" || event.AckRef != "fake-ack-1" || event.ConnectorMode != "fake" {
|
|
t.Fatalf("unexpected audit event: %+v", event)
|
|
}
|
|
if event.ContentText != "" || event.IdempotencyKey != "" {
|
|
t.Fatalf("audit event leaked raw content or idempotency key: %+v", event)
|
|
}
|
|
}
|
|
|
|
func TestISphereSendMessageFakeConnectorFailureAudit(t *testing.T) {
|
|
audit := &fakeSendMessageAuditSink{}
|
|
idempotency := newFakeSendMessageIdempotencyStore()
|
|
connector := &fakeSendMessageConnector{
|
|
result: SendMessageConnectorResult{
|
|
Accepted: false,
|
|
Status: "failed",
|
|
ErrorCode: "fake_rejected",
|
|
ErrorMessage: "fake connector rejected the request",
|
|
ConnectorMode: "fake",
|
|
},
|
|
}
|
|
session, cleanup := connectToolsTestSession(t, func(server *mcp.Server) {
|
|
RegisterISphereSendMessageToolWithStateAndConnector(server, audit, idempotency, connector)
|
|
})
|
|
defer cleanup()
|
|
|
|
content := "fake connector failed"
|
|
contentHash := sha256HexForSendMessageTest(content)
|
|
callResult, err := session.CallTool(context.Background(), &mcp.CallToolParams{
|
|
Name: ToolNameSendMessage,
|
|
Arguments: map[string]any{
|
|
"target_type": "group",
|
|
"target_id": "project-room@conference.imopenfire1-lanzhou",
|
|
"content_text": content,
|
|
"content_sha256": contentHash,
|
|
"idempotency_key": "idem-fake-failed-1",
|
|
"execution_mode": "production",
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("call %s fake connector failure: %v", ToolNameSendMessage, err)
|
|
}
|
|
if callResult.IsError {
|
|
t.Fatalf("fake connector failure should be structured, got error: %+v", callResult)
|
|
}
|
|
|
|
var decoded struct {
|
|
OK bool `json:"ok"`
|
|
SendStatus string `json:"send_status"`
|
|
ErrorCode string `json:"error_code"`
|
|
SideEffects map[string]any `json:"side_effects"`
|
|
Audit map[string]any `json:"audit"`
|
|
}
|
|
payload, _ := json.Marshal(callResult.StructuredContent)
|
|
if err := json.Unmarshal(payload, &decoded); err != nil {
|
|
t.Fatalf("decode fake failure payload %s: %v", payload, err)
|
|
}
|
|
if decoded.OK || decoded.SendStatus != "failed" || decoded.ErrorCode != "fake_rejected" {
|
|
t.Fatalf("unexpected fake failure response: %s", payload)
|
|
}
|
|
if decoded.SideEffects["sent_message"] != false || decoded.SideEffects["typed_text"] != false {
|
|
t.Fatalf("fake failure should not report real side effects: %#v", decoded.SideEffects)
|
|
}
|
|
if decoded.Audit["error_code"] != "fake_rejected" || decoded.Audit["result"] != "failed" {
|
|
t.Fatalf("unexpected fake failure audit: %#v", decoded.Audit)
|
|
}
|
|
if strings.Contains(string(payload), content) || strings.Contains(string(payload), "idem-fake-failed-1") {
|
|
t.Fatalf("fake failure payload leaked raw content or idempotency key: %s", payload)
|
|
}
|
|
if len(audit.events) != 1 {
|
|
t.Fatalf("audit events = %+v, want one", audit.events)
|
|
}
|
|
event := audit.events[0]
|
|
if event.Result != "failed" || event.ErrorCode != "fake_rejected" || event.ConnectorMode != "fake" {
|
|
t.Fatalf("unexpected audit event: %+v", event)
|
|
}
|
|
}
|
|
|
|
func TestISphereSendMessageRejectsContentHashMismatch(t *testing.T) {
|
|
session, cleanup := connectToolsTestSession(t, func(server *mcp.Server) {
|
|
RegisterISphereSendMessageToolWithState(server, &fakeSendMessageAuditSink{}, newFakeSendMessageIdempotencyStore())
|
|
})
|
|
defer cleanup()
|
|
|
|
callResult, err := session.CallTool(context.Background(), &mcp.CallToolParams{
|
|
Name: ToolNameSendMessage,
|
|
Arguments: map[string]any{
|
|
"target_type": "direct",
|
|
"target_id": "alice@imopenfire1-lanzhou",
|
|
"content_text": "hash mismatch",
|
|
"content_sha256": sha256HexForSendMessageTest("different body"),
|
|
"idempotency_key": "idem-hash-mismatch",
|
|
"execution_mode": "preview",
|
|
},
|
|
})
|
|
if err == nil && !callResult.IsError {
|
|
t.Fatalf("hash mismatch was accepted")
|
|
}
|
|
}
|
|
|
|
func TestISphereSendMessageAuditRedactionIsStable(t *testing.T) {
|
|
auditPath := t.TempDir() + "\\send-audit.jsonl"
|
|
audit := fileSendMessageAuditSink{path: auditPath}
|
|
session, cleanup := connectToolsTestSession(t, func(server *mcp.Server) {
|
|
RegisterISphereSendMessageToolWithState(server, audit, newFakeSendMessageIdempotencyStore())
|
|
})
|
|
defer cleanup()
|
|
|
|
content := "raw body must not be stored in audit"
|
|
idempotencyKey := "raw-idempotency-key-must-not-be-stored"
|
|
contentHash := sha256HexForSendMessageTest(content)
|
|
callResult, err := session.CallTool(context.Background(), &mcp.CallToolParams{
|
|
Name: ToolNameSendMessage,
|
|
Arguments: map[string]any{
|
|
"target_type": "direct",
|
|
"target_id": "alice@imopenfire1-lanzhou",
|
|
"content_text": content,
|
|
"content_sha256": contentHash,
|
|
"idempotency_key": idempotencyKey,
|
|
"execution_mode": "preview",
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("call %s: %v", ToolNameSendMessage, err)
|
|
}
|
|
if callResult.IsError {
|
|
t.Fatalf("preview should not return error: %+v", callResult)
|
|
}
|
|
payload, err := os.ReadFile(auditPath)
|
|
if err != nil {
|
|
t.Fatalf("read audit file: %v", err)
|
|
}
|
|
if strings.Contains(string(payload), content) || strings.Contains(string(payload), idempotencyKey) {
|
|
t.Fatalf("audit file leaked raw content or idempotency key: %s", payload)
|
|
}
|
|
if !strings.Contains(string(payload), contentHash) {
|
|
t.Fatalf("audit file missing content hash: %s", payload)
|
|
}
|
|
}
|
|
|
|
func TestISphereSendMessageDetectsDuplicateIdempotencyKey(t *testing.T) {
|
|
audit := &fakeSendMessageAuditSink{}
|
|
idempotency := newFakeSendMessageIdempotencyStore()
|
|
session, cleanup := connectToolsTestSession(t, func(server *mcp.Server) {
|
|
RegisterISphereSendMessageToolWithState(server, audit, idempotency)
|
|
})
|
|
defer cleanup()
|
|
|
|
content := "duplicate preview"
|
|
contentHash := sha256HexForSendMessageTest(content)
|
|
args := map[string]any{
|
|
"target_type": "direct",
|
|
"target_id": "alice@imopenfire1-lanzhou",
|
|
"content_text": content,
|
|
"content_sha256": contentHash,
|
|
"idempotency_key": "idem-duplicate-1",
|
|
"execution_mode": "preview",
|
|
}
|
|
if _, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: ToolNameSendMessage, Arguments: args}); err != nil {
|
|
t.Fatalf("first call: %v", err)
|
|
}
|
|
second, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: ToolNameSendMessage, Arguments: args})
|
|
if err != nil {
|
|
t.Fatalf("second call: %v", err)
|
|
}
|
|
if second.IsError {
|
|
t.Fatalf("duplicate should be structured preview metadata, got error: %+v", second)
|
|
}
|
|
|
|
var decoded struct {
|
|
OK bool `json:"ok"`
|
|
SendStatus string `json:"send_status"`
|
|
Idempotency map[string]any `json:"idempotency"`
|
|
SideEffects map[string]any `json:"side_effects"`
|
|
}
|
|
payload, _ := json.Marshal(second.StructuredContent)
|
|
if err := json.Unmarshal(payload, &decoded); err != nil {
|
|
t.Fatalf("decode duplicate payload %s: %v", payload, err)
|
|
}
|
|
if !decoded.OK || decoded.SendStatus != "planned" {
|
|
t.Fatalf("duplicate preview should remain planned/no-send: %s", payload)
|
|
}
|
|
if decoded.Idempotency["duplicate_detected"] != true || decoded.Idempotency["conflict_detected"] != false {
|
|
t.Fatalf("duplicate idempotency fields mismatch: %#v", decoded.Idempotency)
|
|
}
|
|
if decoded.Idempotency["first_audit_ref"] == "" {
|
|
t.Fatalf("duplicate response missing first_audit_ref: %#v", decoded.Idempotency)
|
|
}
|
|
if decoded.SideEffects["sent_message"] != false || decoded.SideEffects["clicked_ui"] != false {
|
|
t.Fatalf("duplicate preview should not have side effects: %#v", decoded.SideEffects)
|
|
}
|
|
if len(audit.events) != 2 || audit.events[1].Result != "planned" {
|
|
t.Fatalf("audit events = %+v, want two planned events", audit.events)
|
|
}
|
|
}
|
|
|
|
func TestISphereSendMessageBlocksIdempotencyKeyConflict(t *testing.T) {
|
|
audit := &fakeSendMessageAuditSink{}
|
|
idempotency := newFakeSendMessageIdempotencyStore()
|
|
session, cleanup := connectToolsTestSession(t, func(server *mcp.Server) {
|
|
RegisterISphereSendMessageToolWithState(server, audit, idempotency)
|
|
})
|
|
defer cleanup()
|
|
|
|
firstContent := "first body"
|
|
firstHash := sha256HexForSendMessageTest(firstContent)
|
|
_, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: ToolNameSendMessage, Arguments: map[string]any{
|
|
"target_type": "direct",
|
|
"target_id": "alice@imopenfire1-lanzhou",
|
|
"content_text": firstContent,
|
|
"content_sha256": firstHash,
|
|
"idempotency_key": "idem-conflict-1",
|
|
"execution_mode": "preview",
|
|
}})
|
|
if err != nil {
|
|
t.Fatalf("first call: %v", err)
|
|
}
|
|
|
|
secondContent := "different body"
|
|
secondHash := sha256HexForSendMessageTest(secondContent)
|
|
second, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: ToolNameSendMessage, Arguments: map[string]any{
|
|
"target_type": "direct",
|
|
"target_id": "alice@imopenfire1-lanzhou",
|
|
"content_text": secondContent,
|
|
"content_sha256": secondHash,
|
|
"idempotency_key": "idem-conflict-1",
|
|
"execution_mode": "preview",
|
|
}})
|
|
if err != nil {
|
|
t.Fatalf("conflict call: %v", err)
|
|
}
|
|
if second.IsError {
|
|
t.Fatalf("idempotency conflict should be structured blocked metadata, got error: %+v", second)
|
|
}
|
|
|
|
var decoded struct {
|
|
OK bool `json:"ok"`
|
|
SendStatus string `json:"send_status"`
|
|
BlockedReason string `json:"blocked_reason"`
|
|
ConnectorStage string `json:"connector_stage"`
|
|
Idempotency map[string]any `json:"idempotency"`
|
|
SideEffects map[string]any `json:"side_effects"`
|
|
}
|
|
payload, _ := json.Marshal(second.StructuredContent)
|
|
if err := json.Unmarshal(payload, &decoded); err != nil {
|
|
t.Fatalf("decode conflict payload %s: %v", payload, err)
|
|
}
|
|
if decoded.OK || decoded.SendStatus != "blocked" || decoded.ConnectorStage != "blocked" {
|
|
t.Fatalf("conflict should be blocked: %s", payload)
|
|
}
|
|
if !strings.Contains(strings.ToLower(decoded.BlockedReason), "idempotency") {
|
|
t.Fatalf("blocked reason should explain idempotency conflict: %q", decoded.BlockedReason)
|
|
}
|
|
if decoded.Idempotency["duplicate_detected"] != false || decoded.Idempotency["conflict_detected"] != true {
|
|
t.Fatalf("conflict idempotency fields mismatch: %#v", decoded.Idempotency)
|
|
}
|
|
if decoded.SideEffects["sent_message"] != false || decoded.SideEffects["typed_text"] != false {
|
|
t.Fatalf("conflict should not have side effects: %#v", decoded.SideEffects)
|
|
}
|
|
if len(audit.events) != 2 || audit.events[1].Result != "blocked" {
|
|
t.Fatalf("audit events = %+v, want second blocked event", audit.events)
|
|
}
|
|
}
|
|
|
|
func TestFileSendMessageIdempotencyStoreDetectsDuplicateAndConflict(t *testing.T) {
|
|
store := fileSendMessageIdempotencyStore{path: t.TempDir() + "\\send-idempotency.jsonl"}
|
|
first := SendMessageIdempotencyRecord{
|
|
Tool: ToolNameSendMessage,
|
|
TargetRef: "contact:alice@imopenfire1-lanzhou",
|
|
ContentSHA256: sha256HexForSendMessageTest("first body"),
|
|
IdempotencyKeySHA256: sha256HexForSendMessageTest("idem-file-store"),
|
|
AuditRef: "send-message:first",
|
|
StartedAt: "2026-07-10T00:00:00Z",
|
|
}
|
|
firstDecision, err := store.ReserveSendMessageIdempotency(context.Background(), first)
|
|
if err != nil {
|
|
t.Fatalf("reserve first: %v", err)
|
|
}
|
|
if firstDecision.Duplicate || firstDecision.Conflict {
|
|
t.Fatalf("first decision = %+v, want fresh", firstDecision)
|
|
}
|
|
|
|
duplicateDecision, err := store.ReserveSendMessageIdempotency(context.Background(), first)
|
|
if err != nil {
|
|
t.Fatalf("reserve duplicate: %v", err)
|
|
}
|
|
if !duplicateDecision.Duplicate || duplicateDecision.Conflict || duplicateDecision.FirstAuditRef != first.AuditRef {
|
|
t.Fatalf("duplicate decision = %+v", duplicateDecision)
|
|
}
|
|
|
|
conflict := first
|
|
conflict.ContentSHA256 = sha256HexForSendMessageTest("changed body")
|
|
conflict.AuditRef = "send-message:conflict"
|
|
conflictDecision, err := store.ReserveSendMessageIdempotency(context.Background(), conflict)
|
|
if err != nil {
|
|
t.Fatalf("reserve conflict: %v", err)
|
|
}
|
|
if !conflictDecision.Conflict || conflictDecision.Duplicate {
|
|
t.Fatalf("conflict decision = %+v", conflictDecision)
|
|
}
|
|
if conflictDecision.FirstAuditRef != first.AuditRef {
|
|
t.Fatalf("conflict first audit ref = %q, want %q", conflictDecision.FirstAuditRef, first.AuditRef)
|
|
}
|
|
}
|
|
|
|
type fakeSendMessageAuditSink struct {
|
|
events []SendMessageAuditEvent
|
|
}
|
|
|
|
func (f *fakeSendMessageAuditSink) AppendSendMessageAudit(_ context.Context, event SendMessageAuditEvent) error {
|
|
f.events = append(f.events, event)
|
|
return nil
|
|
}
|
|
|
|
type fakeSendMessageIdempotencyStore struct {
|
|
records []SendMessageIdempotencyRecord
|
|
}
|
|
|
|
func newFakeSendMessageIdempotencyStore() *fakeSendMessageIdempotencyStore {
|
|
return &fakeSendMessageIdempotencyStore{}
|
|
}
|
|
|
|
func (f *fakeSendMessageIdempotencyStore) ReserveSendMessageIdempotency(_ context.Context, record SendMessageIdempotencyRecord) (SendMessageIdempotencyDecision, error) {
|
|
for _, existing := range f.records {
|
|
if existing.IdempotencyKeySHA256 != record.IdempotencyKeySHA256 {
|
|
continue
|
|
}
|
|
decision := SendMessageIdempotencyDecision{
|
|
FirstAuditRef: existing.AuditRef,
|
|
FirstStartedAt: existing.StartedAt,
|
|
}
|
|
if existing.TargetRef == record.TargetRef && existing.ContentSHA256 == record.ContentSHA256 {
|
|
decision.Duplicate = true
|
|
return decision, nil
|
|
}
|
|
decision.Conflict = true
|
|
return decision, nil
|
|
}
|
|
f.records = append(f.records, record)
|
|
return SendMessageIdempotencyDecision{}, nil
|
|
}
|
|
|
|
type fakeSendMessageConnector struct {
|
|
result SendMessageConnectorResult
|
|
err error
|
|
calls int
|
|
}
|
|
|
|
func (f *fakeSendMessageConnector) ExecuteSendMessage(_ context.Context, _ SendMessageConnectorRequest) (SendMessageConnectorResult, error) {
|
|
f.calls++
|
|
return f.result, f.err
|
|
}
|
|
|
|
func sha256HexForSendMessageTest(value string) string {
|
|
sum := sha256.Sum256([]byte(value))
|
|
return hex.EncodeToString(sum[:])
|
|
}
|