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:
11
internal/http/handlers/auth_test.go
Normal file
11
internal/http/handlers/auth_test.go
Normal 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))
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
77
internal/http/middleware/authz.go
Normal file
77
internal/http/middleware/authz.go
Normal 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),
|
||||
},
|
||||
})
|
||||
}
|
||||
73
internal/http/middleware/authz_test.go
Normal file
73
internal/http/middleware/authz_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user