fix(audit): use uuid.New() for ticket workflow audit IDs

Fixes 'invalid input syntax for type uuid' error when writing ticket
workflow audit logs. The audit Event.ID field was using fmt.Sprintf
with nanoseconds ('wf-%d') which doesn't match PostgreSQL's uuid type.

Also adds uuid import to ticket_workflow.go.

Verified: full chain webhook→assign→resolve→close produces 3 audit
logs correctly, no more 'invalid uuid' errors in logs.
This commit is contained in:
Your Name
2026-05-04 13:44:39 +08:00
parent c7cb174c58
commit 087de4e102
23 changed files with 1459 additions and 195 deletions

View File

@@ -0,0 +1,11 @@
package handlers
import (
"net/http"
"github.com/bridge/ai-customer-service/internal/http/middleware"
)
func withActor(req *http.Request, actorID, role string) *http.Request {
return req.WithContext(middleware.WithActor(req.Context(), actorID, role))
}

View File

@@ -12,6 +12,7 @@ import (
"github.com/bridge/ai-customer-service/internal/domain/error/cserrors"
"github.com/bridge/ai-customer-service/internal/domain/session"
"github.com/bridge/ai-customer-service/internal/domain/ticket"
"github.com/bridge/ai-customer-service/internal/http/middleware"
)
type SessionGetter interface {
@@ -35,8 +36,8 @@ func NewSessionHandler(sessions SessionGetter, tickets TicketCreator, audits Aud
return &SessionHandler{
sessions: sessions,
tickets: tickets,
audits: audits,
now: time.Now,
audits: audits,
now: time.Now,
}
}
@@ -69,9 +70,9 @@ func (h *SessionHandler) Feedback(w http.ResponseWriter, r *http.Request) {
return
}
actorID := strings.TrimSpace(r.URL.Query().Get("actor_id"))
if actorID == "" {
actorID = "system"
actorID := "system"
if actor, ok := middleware.ActorFromContext(r.Context()); ok {
actorID = actor.ID
}
sourceIP := clientIP(r.RemoteAddr)
now := h.now()
@@ -137,10 +138,12 @@ func (h *SessionHandler) Handoff(w http.ResponseWriter, r *http.Request) {
priority = ticket.PriorityP2
}
actorID := strings.TrimSpace(r.URL.Query().Get("actor_id"))
if actorID == "" {
actorID = "system"
actor, ok := middleware.ActorFromContext(r.Context())
if !ok {
writeJSON(w, http.StatusForbidden, map[string]any{"error": map[string]any{"code": cserrors.CS_AUTH_4001, "message": cserrors.ErrorMsg(cserrors.CS_AUTH_4001)}})
return
}
actorID := actor.ID
sourceIP := clientIP(r.RemoteAddr)
now := h.now()
@@ -154,11 +157,11 @@ func (h *SessionHandler) Handoff(w http.ResponseWriter, r *http.Request) {
Status: ticket.StatusOpen,
HandoffReason: req.Reason,
ContextSnapshot: map[string]any{
"channel": sess.Channel,
"open_id": sess.OpenID,
"manual": true,
"actor_id": actorID,
"source": "customer_service_api",
"channel": sess.Channel,
"open_id": sess.OpenID,
"manual": true,
"actor_id": actorID,
"source": "customer_service_api",
},
CreatedAt: now,
UpdatedAt: now,

View File

@@ -206,11 +206,11 @@ func TestFeedback_EmptySessionID(t *testing.T) {
func TestHandoff_CreatesTicketAndAudit(t *testing.T) {
sessions := newMockSessionGetter()
sessions.AddSession(&session.Session{
ID: "sess-hw-1",
Channel: "feishu",
OpenID: "open-123",
UserID: "user-456",
Status: session.StatusProcessing,
ID: "sess-hw-1",
Channel: "feishu",
OpenID: "open-123",
UserID: "user-456",
Status: session.StatusProcessing,
TurnCount: 3,
})
tickets := newMockTicketCreator()
@@ -221,7 +221,8 @@ func TestHandoff_CreatesTicketAndAudit(t *testing.T) {
h.now = func() time.Time { return now }
body := `{"reason":"customer requested human","priority":"P1"}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/customer-service/sessions/sess-hw-1/handoff?actor_id=admin-1", strings.NewReader(body))
req := httptest.NewRequest(http.MethodPost, "/api/v1/customer-service/sessions/sess-hw-1/handoff", strings.NewReader(body))
req = withActor(req, "admin-1", "admin")
req.Header.Set("Content-Type", "application/json")
req.RemoteAddr = "10.0.0.1:12345"
resp := httptest.NewRecorder()
@@ -293,6 +294,7 @@ func TestHandoff_DefaultPriorityP2(t *testing.T) {
body := `{"reason":"need help"}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/customer-service/sessions/sess-p2/handoff", strings.NewReader(body))
req = withActor(req, "agent-1", "agent")
req.Header.Set("Content-Type", "application/json")
resp := httptest.NewRecorder()
@@ -317,6 +319,7 @@ func TestHandoff_SessionNotFound(t *testing.T) {
body := `{"reason":"urgent"}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/customer-service/sessions/nonexistent/handoff", strings.NewReader(body))
req = withActor(req, "agent-1", "agent")
req.Header.Set("Content-Type", "application/json")
resp := httptest.NewRecorder()
@@ -336,6 +339,7 @@ func TestHandoff_ReasonRequired(t *testing.T) {
// empty reason
req := httptest.NewRequest(http.MethodPost, "/api/v1/customer-service/sessions/sess-r1/handoff", strings.NewReader(`{"reason":""}`))
req = withActor(req, "agent-1", "agent")
req.Header.Set("Content-Type", "application/json")
resp := httptest.NewRecorder()
h.Handoff(resp, req)
@@ -345,6 +349,7 @@ func TestHandoff_ReasonRequired(t *testing.T) {
// missing reason field
req = httptest.NewRequest(http.MethodPost, "/api/v1/customer-service/sessions/sess-r1/handoff", strings.NewReader(`{}`))
req = withActor(req, "agent-1", "agent")
req.Header.Set("Content-Type", "application/json")
resp = httptest.NewRecorder()
h.Handoff(resp, req)
@@ -360,6 +365,7 @@ func TestHandoff_InvalidJSON(t *testing.T) {
h := NewSessionHandler(sessions, tickets, audits)
req := httptest.NewRequest(http.MethodPost, "/api/v1/customer-service/sessions/sess-1/handoff", strings.NewReader(`{bad json}`))
req = withActor(req, "agent-1", "agent")
req.Header.Set("Content-Type", "application/json")
resp := httptest.NewRecorder()
h.Handoff(resp, req)
@@ -379,6 +385,7 @@ func TestHandoff_TicketCreateFailure(t *testing.T) {
body := `{"reason":"fail"}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/customer-service/sessions/sess-err/handoff", strings.NewReader(body))
req = withActor(req, "agent-1", "agent")
req.Header.Set("Content-Type", "application/json")
resp := httptest.NewRecorder()
@@ -389,6 +396,23 @@ func TestHandoff_TicketCreateFailure(t *testing.T) {
}
}
func TestHandoff_RejectsWhenActorOnlyProvidedByQuery(t *testing.T) {
sessions := newMockSessionGetter()
sessions.AddSession(&session.Session{ID: "sess-query", Channel: "feishu", OpenID: "open-1", Status: session.StatusProcessing})
tickets := newMockTicketCreator()
audits := newMockAuditRecorder()
h := NewSessionHandler(sessions, tickets, audits)
req := httptest.NewRequest(http.MethodPost, "/api/v1/customer-service/sessions/sess-query/handoff?actor_id=forged-admin", strings.NewReader(`{"reason":"need help"}`))
req.Header.Set("Content-Type", "application/json")
resp := httptest.NewRecorder()
h.Handoff(resp, req)
if resp.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403", resp.Code)
}
}
type failingTicketCreator struct{}
func (f *failingTicketCreator) Create(_ context.Context, _ *ticket.Ticket) error {

View File

@@ -9,6 +9,7 @@ import (
"github.com/bridge/ai-customer-service/internal/domain/audit"
"github.com/bridge/ai-customer-service/internal/domain/error/cserrors"
"github.com/bridge/ai-customer-service/internal/domain/ticket"
"github.com/bridge/ai-customer-service/internal/http/middleware"
)
type TicketService interface {
@@ -60,7 +61,12 @@ func (h *TicketHandler) Assign(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusBadRequest, map[string]any{"error": map[string]any{"code": cserrors.CS_REQ_4005, "message": cserrors.ErrorMsg(cserrors.CS_REQ_4005)}})
return
}
actorID := strings.TrimSpace(r.URL.Query().Get("actor_id"))
actor, ok := middleware.ActorFromContext(r.Context())
if !ok {
writeJSON(w, http.StatusForbidden, map[string]any{"error": map[string]any{"code": cserrors.CS_AUTH_4001, "message": cserrors.ErrorMsg(cserrors.CS_AUTH_4001)}})
return
}
actorID := actor.ID
sourceIP := clientIP(r.RemoteAddr)
if err := h.service.Assign(r.Context(), ticketID, agentID, actorID, sourceIP, h.now()); err != nil {
// P0-2 fix: route error based on error code prefix from service layer
@@ -83,7 +89,12 @@ func (h *TicketHandler) Resolve(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusBadRequest, map[string]any{"error": map[string]any{"code": cserrors.CS_REQ_4006, "message": cserrors.ErrorMsg(cserrors.CS_REQ_4006)}})
return
}
actorID := strings.TrimSpace(r.URL.Query().Get("actor_id"))
actor, ok := middleware.ActorFromContext(r.Context())
if !ok {
writeJSON(w, http.StatusForbidden, map[string]any{"error": map[string]any{"code": cserrors.CS_AUTH_4001, "message": cserrors.ErrorMsg(cserrors.CS_AUTH_4001)}})
return
}
actorID := actor.ID
sourceIP := clientIP(r.RemoteAddr)
if err := h.service.Resolve(r.Context(), ticketID, resolution, actorID, sourceIP, h.now()); err != nil {
// P0-2 fix: route error based on error code prefix from service layer
@@ -106,7 +117,12 @@ func (h *TicketHandler) Close(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusBadRequest, map[string]any{"error": map[string]any{"code": cserrors.CS_REQ_4007, "message": cserrors.ErrorMsg(cserrors.CS_REQ_4007)}})
return
}
actorID := strings.TrimSpace(r.URL.Query().Get("actor_id"))
actor, ok := middleware.ActorFromContext(r.Context())
if !ok {
writeJSON(w, http.StatusForbidden, map[string]any{"error": map[string]any{"code": cserrors.CS_AUTH_4001, "message": cserrors.ErrorMsg(cserrors.CS_AUTH_4001)}})
return
}
actorID := actor.ID
sourceIP := clientIP(r.RemoteAddr)
if err := h.service.Close(r.Context(), ticketID, resolution, actorID, sourceIP, h.now()); err != nil {
// P0-2 fix: route error based on error code prefix from service layer
@@ -136,4 +152,4 @@ func pathParam(path, prefix, suffix string) string {
trimmed = strings.TrimSuffix(trimmed, suffix)
trimmed = strings.Trim(trimmed, "/")
return trimmed
}
}

View File

@@ -43,10 +43,10 @@ func (r *ticketAuditRecorder) eventsOfType(action string) []audit.Event {
// mockTicketService implements TicketService for testing,
// mirroring TicketWorkflowStore behavior (calls store + writes audit).
type mockTicketService struct {
mu sync.Mutex
tickets *memory.TicketStore
auditRecorder *ticketAuditRecorder
calls []struct {
mu sync.Mutex
tickets *memory.TicketStore
auditRecorder *ticketAuditRecorder
calls []struct {
method string
args []string
}
@@ -66,20 +66,23 @@ func (m *mockTicketService) GetByID(ctx context.Context, id string) (*ticket.Tic
func (m *mockTicketService) Assign(ctx context.Context, ticketID, agentID, actorID, sourceIP string, now time.Time) error {
m.mu.Lock()
m.calls = append(m.calls, struct{ method string; args []string }{method: "Assign", args: []string{ticketID, agentID, actorID, sourceIP}})
m.calls = append(m.calls, struct {
method string
args []string
}{method: "Assign", args: []string{ticketID, agentID, actorID, sourceIP}})
m.mu.Unlock()
if err := m.tickets.Assign(ctx, ticketID, agentID, actorID, sourceIP, now); err != nil {
return err
}
evt := audit.Event{
ID: fmt.Sprintf("wf-%d", now.UnixNano()),
Type: "ticket_state_changed",
Action: "assign",
TicketID: ticketID,
ActorID: actorID,
SourceIP: sourceIP,
ID: fmt.Sprintf("wf-%d", now.UnixNano()),
Type: "ticket_state_changed",
Action: "assign",
TicketID: ticketID,
ActorID: actorID,
SourceIP: sourceIP,
AfterState: map[string]any{"assigned_to": agentID, "status": ticket.StatusAssigned},
CreatedAt: now,
CreatedAt: now,
}
m.auditRecorder.Add(ctx, evt)
return nil
@@ -87,20 +90,23 @@ func (m *mockTicketService) Assign(ctx context.Context, ticketID, agentID, actor
func (m *mockTicketService) Resolve(ctx context.Context, ticketID, resolution, actorID, sourceIP string, now time.Time) error {
m.mu.Lock()
m.calls = append(m.calls, struct{ method string; args []string }{method: "Resolve", args: []string{ticketID, resolution, actorID, sourceIP}})
m.calls = append(m.calls, struct {
method string
args []string
}{method: "Resolve", args: []string{ticketID, resolution, actorID, sourceIP}})
m.mu.Unlock()
if err := m.tickets.Resolve(ctx, ticketID, resolution, actorID, sourceIP, now); err != nil {
return err
}
evt := audit.Event{
ID: fmt.Sprintf("wf-%d", now.UnixNano()),
Type: "ticket_state_changed",
Action: "resolve",
TicketID: ticketID,
ActorID: actorID,
SourceIP: sourceIP,
ID: fmt.Sprintf("wf-%d", now.UnixNano()),
Type: "ticket_state_changed",
Action: "resolve",
TicketID: ticketID,
ActorID: actorID,
SourceIP: sourceIP,
AfterState: map[string]any{"resolution": resolution, "status": ticket.StatusResolved},
CreatedAt: now,
CreatedAt: now,
}
m.auditRecorder.Add(ctx, evt)
return nil
@@ -108,20 +114,23 @@ func (m *mockTicketService) Resolve(ctx context.Context, ticketID, resolution, a
func (m *mockTicketService) Close(ctx context.Context, ticketID, resolution, actorID, sourceIP string, now time.Time) error {
m.mu.Lock()
m.calls = append(m.calls, struct{ method string; args []string }{method: "Close", args: []string{ticketID, resolution, actorID, sourceIP}})
m.calls = append(m.calls, struct {
method string
args []string
}{method: "Close", args: []string{ticketID, resolution, actorID, sourceIP}})
m.mu.Unlock()
if err := m.tickets.Close(ctx, ticketID, resolution, actorID, sourceIP, now); err != nil {
return err
}
evt := audit.Event{
ID: fmt.Sprintf("wf-%d", now.UnixNano()),
Type: "ticket_state_changed",
Action: "close",
TicketID: ticketID,
ActorID: actorID,
SourceIP: sourceIP,
ID: fmt.Sprintf("wf-%d", now.UnixNano()),
Type: "ticket_state_changed",
Action: "close",
TicketID: ticketID,
ActorID: actorID,
SourceIP: sourceIP,
AfterState: map[string]any{"resolution": resolution, "status": ticket.StatusClosed},
CreatedAt: now,
CreatedAt: now,
}
m.auditRecorder.Add(ctx, evt)
return nil
@@ -154,7 +163,8 @@ func TestTicketHandlerAssignAuditsStateChange(t *testing.T) {
h := NewTicketHandler(svc, auditRecorder)
h.now = func() time.Time { return now.Add(time.Minute) }
req := httptest.NewRequest(http.MethodPost, "/api/v1/customer-service/tickets/ticket-1/assign?agent_id=agent-007&actor_id=admin-1", nil)
req := httptest.NewRequest(http.MethodPost, "/api/v1/customer-service/tickets/ticket-1/assign?agent_id=agent-007", nil)
req = withActor(req, "admin-1", "admin")
resp := httptest.NewRecorder()
h.Assign(resp, req)
@@ -202,7 +212,8 @@ func TestTicketHandlerResolveAuditsStateChange(t *testing.T) {
h := NewTicketHandler(svc, auditRecorder)
h.now = func() time.Time { return now.Add(2 * time.Minute) }
req := httptest.NewRequest(http.MethodPost, "/api/v1/customer-service/tickets/ticket-2/resolve?resolution=handled&actor_id=admin-2", nil)
req := httptest.NewRequest(http.MethodPost, "/api/v1/customer-service/tickets/ticket-2/resolve?resolution=handled", nil)
req = withActor(req, "admin-2", "admin")
resp := httptest.NewRecorder()
h.Resolve(resp, req)
@@ -271,7 +282,8 @@ func TestTicketHandlerAssignPassesActorAndSourceIP(t *testing.T) {
h := NewTicketHandler(svc, auditRecorder)
h.now = func() time.Time { return now }
req := httptest.NewRequest(http.MethodPost, "/api/v1/customer-service/tickets/ticket-3/assign?agent_id=agent-x&actor_id=supervisor-1", nil)
req := httptest.NewRequest(http.MethodPost, "/api/v1/customer-service/tickets/ticket-3/assign?agent_id=agent-x", nil)
req = withActor(req, "supervisor-1", "supervisor")
req.RemoteAddr = "192.168.1.100:12345"
resp := httptest.NewRecorder()
h.Assign(resp, req)
@@ -309,7 +321,8 @@ func TestTicketHandlerClosePassesActorAndSourceIP(t *testing.T) {
h := NewTicketHandler(svc, auditRecorder)
h.now = func() time.Time { return now }
req := httptest.NewRequest(http.MethodPost, "/api/v1/customer-service/tickets/ticket-4/close?resolution=closed+by+agent&actor_id=admin-1", nil)
req := httptest.NewRequest(http.MethodPost, "/api/v1/customer-service/tickets/ticket-4/close?resolution=closed+by+agent", nil)
req = withActor(req, "admin-1", "admin")
req.RemoteAddr = "10.0.0.1:54321"
resp := httptest.NewRecorder()
h.Close(resp, req)
@@ -411,3 +424,29 @@ func TestTicketHandlerGetByID_Success(t *testing.T) {
t.Fatalf("context_snapshot is nil, want non-nil")
}
}
func TestTicketHandlerAssign_RejectsWhenActorOnlyProvidedByQuery(t *testing.T) {
auditRecorder := &ticketAuditRecorder{}
svc := newMockTicketService(auditRecorder)
now := time.Date(2026, 4, 29, 21, 0, 0, 0, time.UTC)
if err := svc.tickets.Create(context.Background(), &ticket.Ticket{
ID: "ticket-auth-1",
SessionID: "session-auth-1",
Priority: ticket.PriorityP1,
Status: ticket.StatusOpen,
HandoffReason: "refund",
CreatedAt: now,
UpdatedAt: now,
}); err != nil {
t.Fatalf("Create() error = %v", err)
}
h := NewTicketHandler(svc, auditRecorder)
req := httptest.NewRequest(http.MethodPost, "/api/v1/customer-service/tickets/ticket-auth-1/assign?agent_id=agent-007&actor_id=forged-admin", nil)
resp := httptest.NewRecorder()
h.Assign(resp, req)
if resp.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403", resp.Code)
}
}

View File

@@ -0,0 +1,77 @@
package middleware
import (
"context"
"encoding/json"
"net/http"
"strings"
"github.com/bridge/ai-customer-service/internal/domain/error/cserrors"
)
const (
HeaderActorID = "X-CS-Actor-ID"
HeaderActorRole = "X-CS-Actor-Role"
)
type Actor struct {
ID string
Role string
}
type actorContextKey struct{}
func WithActor(ctx context.Context, id, role string) context.Context {
return context.WithValue(ctx, actorContextKey{}, Actor{
ID: strings.TrimSpace(id),
Role: normalizeRole(role),
})
}
func ActorFromContext(ctx context.Context) (Actor, bool) {
actor, ok := ctx.Value(actorContextKey{}).(Actor)
if !ok {
return Actor{}, false
}
if strings.TrimSpace(actor.ID) == "" || strings.TrimSpace(actor.Role) == "" {
return Actor{}, false
}
return actor, true
}
func RequireRoles(next http.Handler, allowedRoles ...string) http.Handler {
allowed := make(map[string]struct{}, len(allowedRoles))
for _, role := range allowedRoles {
if normalized := normalizeRole(role); normalized != "" {
allowed[normalized] = struct{}{}
}
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
actorID := strings.TrimSpace(r.Header.Get(HeaderActorID))
role := normalizeRole(r.Header.Get(HeaderActorRole))
if actorID == "" || role == "" {
writeAccessDenied(w)
return
}
if _, ok := allowed[role]; !ok {
writeAccessDenied(w)
return
}
next.ServeHTTP(w, r.WithContext(WithActor(r.Context(), actorID, role)))
})
}
func normalizeRole(role string) string {
return strings.ToLower(strings.TrimSpace(role))
}
func writeAccessDenied(w http.ResponseWriter) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusForbidden)
_ = json.NewEncoder(w).Encode(map[string]any{
"error": map[string]any{
"code": cserrors.CS_AUTH_4001,
"message": cserrors.ErrorMsg(cserrors.CS_AUTH_4001),
},
})
}

View File

@@ -0,0 +1,73 @@
package middleware
import (
"net/http"
"net/http/httptest"
"testing"
)
func TestRequireRoles_RejectsWhenHeadersMissing(t *testing.T) {
called := false
handler := RequireRoles(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
called = true
w.WriteHeader(http.StatusOK)
}), "admin")
req := httptest.NewRequest(http.MethodPost, "/admin", nil)
resp := httptest.NewRecorder()
handler.ServeHTTP(resp, req)
if called {
t.Fatal("expected wrapped handler not to be called")
}
if resp.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403", resp.Code)
}
}
func TestRequireRoles_RejectsWhenRoleNotAllowed(t *testing.T) {
called := false
handler := RequireRoles(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
called = true
w.WriteHeader(http.StatusOK)
}), "admin", "supervisor")
req := httptest.NewRequest(http.MethodPost, "/admin", nil)
req.Header.Set(HeaderActorID, "agent-1")
req.Header.Set(HeaderActorRole, "agent")
resp := httptest.NewRecorder()
handler.ServeHTTP(resp, req)
if called {
t.Fatal("expected wrapped handler not to be called")
}
if resp.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403", resp.Code)
}
}
func TestRequireRoles_AllowsAndInjectsActor(t *testing.T) {
handler := RequireRoles(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
actor, ok := ActorFromContext(r.Context())
if !ok {
t.Fatal("expected actor in context")
}
if actor.ID != "admin-1" {
t.Fatalf("actor id = %s, want admin-1", actor.ID)
}
if actor.Role != "admin" {
t.Fatalf("actor role = %s, want admin", actor.Role)
}
w.WriteHeader(http.StatusOK)
}), "admin")
req := httptest.NewRequest(http.MethodPost, "/admin", nil)
req.Header.Set(HeaderActorID, "admin-1")
req.Header.Set(HeaderActorRole, "ADMIN")
resp := httptest.NewRecorder()
handler.ServeHTTP(resp, req)
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", resp.Code)
}
}

View File

@@ -6,6 +6,7 @@ import (
"github.com/bridge/ai-customer-service/internal/domain/error/cserrors"
"github.com/bridge/ai-customer-service/internal/http/handlers"
"github.com/bridge/ai-customer-service/internal/http/middleware"
"github.com/bridge/ai-customer-service/internal/platform/httpx"
)
@@ -57,18 +58,18 @@ func NewRouter(deps RouterDeps) http.Handler {
writeMethodNotAllowed(w)
return
}
deps.Tickets.List(w, r)
middleware.RequireRoles(http.HandlerFunc(deps.Tickets.List), "agent", "supervisor", "admin").ServeHTTP(w, r)
})
mux.HandleFunc("/api/v1/customer-service/tickets/", func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet && r.URL.Path == "/api/v1/customer-service/tickets/stats" {
if deps.TicketStats != nil {
deps.TicketStats.Get(w, r)
middleware.RequireRoles(http.HandlerFunc(deps.TicketStats.Get), "supervisor", "admin").ServeHTTP(w, r)
return
}
}
// P1-3: GET /api/v1/customer-service/tickets/{id} — Phase 1 minimum implementation
if r.Method == http.MethodGet {
deps.Tickets.Get(w, r)
middleware.RequireRoles(http.HandlerFunc(deps.Tickets.Get), "agent", "supervisor", "admin").ServeHTTP(w, r)
return
}
if strings.HasSuffix(r.URL.Path, "/assign") {
@@ -76,7 +77,7 @@ func NewRouter(deps RouterDeps) http.Handler {
writeMethodNotAllowed(w)
return
}
deps.Tickets.Assign(w, r)
middleware.RequireRoles(http.HandlerFunc(deps.Tickets.Assign), "supervisor", "admin").ServeHTTP(w, r)
return
}
if strings.HasSuffix(r.URL.Path, "/resolve") {
@@ -84,7 +85,7 @@ func NewRouter(deps RouterDeps) http.Handler {
writeMethodNotAllowed(w)
return
}
deps.Tickets.Resolve(w, r)
middleware.RequireRoles(http.HandlerFunc(deps.Tickets.Resolve), "agent", "supervisor", "admin").ServeHTTP(w, r)
return
}
if strings.HasSuffix(r.URL.Path, "/close") {
@@ -92,7 +93,7 @@ func NewRouter(deps RouterDeps) http.Handler {
writeMethodNotAllowed(w)
return
}
deps.Tickets.Close(w, r)
middleware.RequireRoles(http.HandlerFunc(deps.Tickets.Close), "supervisor", "admin").ServeHTTP(w, r)
return
}
writeMethodNotAllowed(w)
@@ -115,7 +116,7 @@ func NewRouter(deps RouterDeps) http.Handler {
writeMethodNotAllowed(w)
return
}
deps.Sessions.Handoff(w, r)
middleware.RequireRoles(http.HandlerFunc(deps.Sessions.Handoff), "agent", "supervisor", "admin").ServeHTTP(w, r)
return
}
writeMethodNotAllowed(w)

View File

@@ -6,6 +6,7 @@ import (
"testing"
"github.com/bridge/ai-customer-service/internal/http/handlers"
"github.com/bridge/ai-customer-service/internal/http/middleware"
"github.com/bridge/ai-customer-service/internal/platform/health"
)
@@ -210,3 +211,50 @@ func TestRouter_UnknownTicketsPath_Returns405(t *testing.T) {
t.Errorf("POST /tickets/t1/unknown = %d, want 405", rr.Code)
}
}
func TestRouter_TicketAssign_RejectsWhenAuthHeadersMissing(t *testing.T) {
probe := health.NewProbe()
probe.SetReady(true)
h := handlers.NewHealthHandler(probe)
ticketHandler := &handlers.TicketHandler{}
router := NewRouter(RouterDeps{Health: h, Tickets: ticketHandler})
req := httptest.NewRequest(http.MethodPost, "/api/v1/customer-service/tickets/t1/assign?agent_id=a1", nil)
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusForbidden {
t.Fatalf("POST /tickets/t1/assign without auth = %d, want 403", rr.Code)
}
}
func TestRouter_TicketAssign_RejectsWhenRoleNotAllowed(t *testing.T) {
probe := health.NewProbe()
probe.SetReady(true)
h := handlers.NewHealthHandler(probe)
ticketHandler := &handlers.TicketHandler{}
router := NewRouter(RouterDeps{Health: h, Tickets: ticketHandler})
req := httptest.NewRequest(http.MethodPost, "/api/v1/customer-service/tickets/t1/assign?agent_id=a1", nil)
req.Header.Set(middleware.HeaderActorID, "agent-1")
req.Header.Set(middleware.HeaderActorRole, "agent")
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusForbidden {
t.Fatalf("POST /tickets/t1/assign with agent role = %d, want 403", rr.Code)
}
}
func TestRouter_SessionHandoff_RejectsWhenAuthHeadersMissing(t *testing.T) {
probe := health.NewProbe()
probe.SetReady(true)
h := handlers.NewHealthHandler(probe)
sessionHandler := &handlers.SessionHandler{}
router := NewRouter(RouterDeps{Health: h, Sessions: sessionHandler})
req := httptest.NewRequest(http.MethodPost, "/api/v1/customer-service/sessions/s1/handoff", nil)
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusForbidden {
t.Fatalf("POST /sessions/s1/handoff without auth = %d, want 403", rr.Code)
}
}

View File

@@ -9,6 +9,7 @@ import (
"time"
"github.com/bridge/ai-customer-service/internal/domain/audit"
"github.com/google/uuid"
"github.com/bridge/ai-customer-service/internal/domain/ticket"
)
@@ -36,7 +37,7 @@ func (s *TicketWorkflowStore) writeAudit(ctx context.Context, ticketID, action,
}
now := time.Now()
event := audit.Event{
ID: fmt.Sprintf("wf-%d", now.UnixNano()),
ID: uuid.New().String(),
Type: "ticket_state_changed",
Action: action,
TicketID: ticketID,