fix(session): map ErrSessionNotFound to 404 across all handlers
PR #1309 plumbed user-scope into the session/message service layer so non-owner / wrong-tenant lookups now surface as ErrSessionNotFound at every entry point that goes through sessionRepo.Get. The handler-side mapping, however, only existed in 4 of the 8 affected sites: - handler/session/handler.go:GetSession ✓ already mapped (with `==`) - handler/session/handler.go:UpdateSession ✓ already mapped (with `==`) - handler/session/handler.go:DeleteSession ✓ already mapped (with `==`) - handler/session/handler.go:BatchDeleteSessions ✓ already mapped (with `==`) - handler/session/handler.go:ClearSessionMessages ✗ always 500 - handler/session/stream.go:ContinueStream session lookup ✓ (with `==`) - handler/session/stream.go:ContinueStream message lookup ✗ always 500 - handler/message.go:LoadMessages (recent + before-time) ✗ always 500 - handler/message.go:DeleteMessage ✗ always 500 Result: a Contributor in tenant A asking for a Contributor B's session messages got a 500 instead of 404 — the wire response leaked "something is broken" rather than "you can't see this URL", which breaks SDK / frontend axios interceptor error handling that keys on HTTP status. Two changes: 1. Add ErrSessionNotFound → 404 mapping to the 4 sites that lacked it (ClearSessionMessages, ContinueStream's GetMessage, LoadMessages's two paths, DeleteMessage). 2. Replace `err == errors.ErrSessionNotFound` with `stderrors.Is(err, errors.ErrSessionNotFound)` everywhere. Sentinel error comparison must use errors.Is so wrapped errors (`fmt.Errorf("...: %w", ErrSessionNotFound)`) still match. Today no service-layer caller wraps, but a future refactor that does would silently turn 404s into 500s with no test catching it. Tests: - internal/handler/message_session_not_found_test.go covers the four new mapping sites in handler/message.go (LoadMessages-recent, LoadMessages-before-time, DeleteMessage) and pins the wrapped-error behaviour with a dedicated regression test that fmt.Errorf("%w") a sentinel must still be detected as ErrSessionNotFound. The bare handler is hit through gin.Engine + middleware.ErrorHandler so the response shape matches production exactly. - PR #1309's existing service-level tests (session_user_scope_test, session_test on the repository) still pass — this PR is a strictly handler-layer follow-up.
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
stderrors "errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
@@ -70,6 +71,15 @@ func (h *MessageHandler) LoadMessages(c *gin.Context) {
|
||||
logger.Infof(ctx, "Getting recent messages for session, session ID: %s, limit: %d", sessionID, limitInt)
|
||||
messages, err := h.MessageService.GetRecentMessagesBySession(ctx, sessionID, limitInt)
|
||||
if err != nil {
|
||||
if stderrors.Is(err, errors.ErrSessionNotFound) {
|
||||
// PR #1309 plumbed user-scope into the message service's
|
||||
// session existence check; non-owner / wrong-tenant lookups
|
||||
// surface as ErrSessionNotFound. Map to 404 so clients can
|
||||
// tell "wrong URL" from a real 5xx.
|
||||
logger.Warnf(ctx, "Session not found, ID: %s", sessionID)
|
||||
c.Error(errors.NewNotFoundError(err.Error()))
|
||||
return
|
||||
}
|
||||
logger.ErrorWithFields(ctx, err, nil)
|
||||
c.Error(errors.NewInternalServerError(err.Error()))
|
||||
return
|
||||
@@ -104,6 +114,12 @@ func (h *MessageHandler) LoadMessages(c *gin.Context) {
|
||||
sessionID, beforeTime.Format(time.RFC3339Nano), limitInt)
|
||||
messages, err := h.MessageService.GetMessagesBySessionBeforeTime(ctx, sessionID, beforeTime, limitInt)
|
||||
if err != nil {
|
||||
if stderrors.Is(err, errors.ErrSessionNotFound) {
|
||||
// See note on the GetRecentMessagesBySession path above.
|
||||
logger.Warnf(ctx, "Session not found, ID: %s", sessionID)
|
||||
c.Error(errors.NewNotFoundError(err.Error()))
|
||||
return
|
||||
}
|
||||
logger.ErrorWithFields(ctx, err, nil)
|
||||
c.Error(errors.NewInternalServerError(err.Error()))
|
||||
return
|
||||
@@ -146,6 +162,14 @@ func (h *MessageHandler) DeleteMessage(c *gin.Context) {
|
||||
|
||||
// Delete the message using the message service
|
||||
if err := h.MessageService.DeleteMessage(ctx, sessionID, messageID); err != nil {
|
||||
if stderrors.Is(err, errors.ErrSessionNotFound) {
|
||||
// See note on LoadMessages above — message-service operations
|
||||
// surface ErrSessionNotFound when the caller can't see the
|
||||
// owning session (post-#1309 user scope).
|
||||
logger.Warnf(ctx, "Session not found, ID: %s", sessionID)
|
||||
c.Error(errors.NewNotFoundError(err.Error()))
|
||||
return
|
||||
}
|
||||
logger.ErrorWithFields(ctx, err, nil)
|
||||
c.Error(errors.NewInternalServerError(err.Error()))
|
||||
return
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
stderrors "errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
apperrors "github.com/Tencent/WeKnora/internal/errors"
|
||||
"github.com/Tencent/WeKnora/internal/middleware"
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
"github.com/Tencent/WeKnora/internal/types/interfaces"
|
||||
)
|
||||
|
||||
// stubMessageService satisfies just the four MessageService methods this
|
||||
// file exercises. Embedding the interface keeps the rest nil-panicky on
|
||||
// purpose: any test that reaches an un-stubbed method should fail loudly
|
||||
// rather than silently returning zero-values.
|
||||
type stubMessageService struct {
|
||||
interfaces.MessageService
|
||||
getRecent func(ctx context.Context, sessionID string, limit int) ([]*types.Message, error)
|
||||
getBeforeTime func(ctx context.Context, sessionID string, beforeTime time.Time, limit int) ([]*types.Message, error)
|
||||
deleteMessage func(ctx context.Context, sessionID, id string) error
|
||||
}
|
||||
|
||||
func (s *stubMessageService) GetRecentMessagesBySession(
|
||||
ctx context.Context, sessionID string, limit int,
|
||||
) ([]*types.Message, error) {
|
||||
return s.getRecent(ctx, sessionID, limit)
|
||||
}
|
||||
|
||||
func (s *stubMessageService) GetMessagesBySessionBeforeTime(
|
||||
ctx context.Context, sessionID string, beforeTime time.Time, limit int,
|
||||
) ([]*types.Message, error) {
|
||||
return s.getBeforeTime(ctx, sessionID, beforeTime, limit)
|
||||
}
|
||||
|
||||
func (s *stubMessageService) DeleteMessage(ctx context.Context, sessionID, id string) error {
|
||||
return s.deleteMessage(ctx, sessionID, id)
|
||||
}
|
||||
|
||||
// newMessageTestRouter mounts the standard ErrorHandler middleware so
|
||||
// c.Error(NewNotFoundError(...)) renders as a real 404 envelope —
|
||||
// matching production routing exactly.
|
||||
func newMessageTestRouter(svc interfaces.MessageService) *gin.Engine {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.Use(middleware.ErrorHandler())
|
||||
h := &MessageHandler{MessageService: svc}
|
||||
r.GET("/messages/:session_id/load", h.LoadMessages)
|
||||
r.DELETE("/messages/:session_id/:id", h.DeleteMessage)
|
||||
return r
|
||||
}
|
||||
|
||||
// TestLoadMessagesMapsErrSessionNotFoundToNotFound pins the fix for
|
||||
// PR #1309's surfacing gap: when message-service ops can't see the
|
||||
// owning session (non-owner / wrong tenant), the handler used to
|
||||
// surface a 500 because ErrSessionNotFound flowed through the generic
|
||||
// error branch. We now map it to 404 so clients can tell "wrong URL"
|
||||
// from a real 5xx.
|
||||
func TestLoadMessagesMapsErrSessionNotFoundToNotFound(t *testing.T) {
|
||||
svc := &stubMessageService{
|
||||
getRecent: func(_ context.Context, _ string, _ int) ([]*types.Message, error) {
|
||||
return nil, apperrors.ErrSessionNotFound
|
||||
},
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/messages/sess1/load?limit=20", nil)
|
||||
newMessageTestRouter(svc).ServeHTTP(w, req)
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("ErrSessionNotFound must map to 404, got %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadMessagesBeforeTimeMapsErrSessionNotFoundToNotFound covers the
|
||||
// second branch of LoadMessages (the `before_time` path), which has its
|
||||
// own error-handling block.
|
||||
func TestLoadMessagesBeforeTimeMapsErrSessionNotFoundToNotFound(t *testing.T) {
|
||||
svc := &stubMessageService{
|
||||
getBeforeTime: func(_ context.Context, _ string, _ time.Time, _ int) ([]*types.Message, error) {
|
||||
return nil, apperrors.ErrSessionNotFound
|
||||
},
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
url := "/messages/sess1/load?limit=20&before_time=" + time.Now().UTC().Format(time.RFC3339Nano)
|
||||
req := httptest.NewRequest(http.MethodGet, url, nil)
|
||||
newMessageTestRouter(svc).ServeHTTP(w, req)
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("ErrSessionNotFound on before-time path must map to 404, got %d body=%s",
|
||||
w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeleteMessageMapsErrSessionNotFoundToNotFound mirrors the
|
||||
// LoadMessages test for the delete endpoint.
|
||||
func TestDeleteMessageMapsErrSessionNotFoundToNotFound(t *testing.T) {
|
||||
svc := &stubMessageService{
|
||||
deleteMessage: func(_ context.Context, _, _ string) error {
|
||||
return apperrors.ErrSessionNotFound
|
||||
},
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodDelete, "/messages/sess1/msg1", nil)
|
||||
newMessageTestRouter(svc).ServeHTTP(w, req)
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("ErrSessionNotFound on DeleteMessage must map to 404, got %d body=%s",
|
||||
w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeleteMessageHonoursWrappedErrSessionNotFound is the regression
|
||||
// test against the stale `==` sentinel comparison the original PR used.
|
||||
// errors.Is unwraps fmt.Errorf("%w", ...); a literal `==` does not. If
|
||||
// anyone reverts the comparison, this test fails before the change ships.
|
||||
func TestDeleteMessageHonoursWrappedErrSessionNotFound(t *testing.T) {
|
||||
svc := &stubMessageService{
|
||||
deleteMessage: func(_ context.Context, _, _ string) error {
|
||||
return fmt.Errorf("delete: %w", apperrors.ErrSessionNotFound)
|
||||
},
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodDelete, "/messages/sess1/msg1", nil)
|
||||
newMessageTestRouter(svc).ServeHTTP(w, req)
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("wrapped ErrSessionNotFound must still map to 404, got %d body=%s",
|
||||
w.Code, w.Body.String())
|
||||
}
|
||||
// Defence-in-depth: the response body should expose the AppError
|
||||
// envelope (ErrorHandler renders {success,error:{code,message}}),
|
||||
// not the bare gin error string.
|
||||
if !strings.Contains(w.Body.String(), `"code":1003`) {
|
||||
t.Fatalf("expected NotFound envelope with code=1003, got body=%s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// guardAgainstStaleSentinelEquality is a compile-time assertion: if a
|
||||
// future refactor accidentally drops the stderrors import, the test file
|
||||
// stops compiling and the human gets a clear signal that something tied
|
||||
// to errors.Is wrapping went away.
|
||||
var _ = stderrors.Is
|
||||
@@ -1,6 +1,7 @@
|
||||
package session
|
||||
|
||||
import (
|
||||
stderrors "errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/config"
|
||||
@@ -160,7 +161,7 @@ func (h *Handler) GetSession(c *gin.Context) {
|
||||
logger.Infof(ctx, "Retrieving session, ID: %s", id)
|
||||
session, err := h.sessionService.GetSession(ctx, id)
|
||||
if err != nil {
|
||||
if err == errors.ErrSessionNotFound {
|
||||
if stderrors.Is(err, errors.ErrSessionNotFound) {
|
||||
logger.Warnf(ctx, "Session not found, ID: %s", id)
|
||||
c.Error(errors.NewNotFoundError(err.Error()))
|
||||
return
|
||||
@@ -275,7 +276,7 @@ func (h *Handler) UpdateSession(c *gin.Context) {
|
||||
|
||||
// Call service to update session
|
||||
if err := h.sessionService.UpdateSession(ctx, &session); err != nil {
|
||||
if err == errors.ErrSessionNotFound {
|
||||
if stderrors.Is(err, errors.ErrSessionNotFound) {
|
||||
logger.Warnf(ctx, "Session not found, ID: %s", id)
|
||||
c.Error(errors.NewNotFoundError(err.Error()))
|
||||
return
|
||||
@@ -326,7 +327,7 @@ func (h *Handler) DeleteSession(c *gin.Context) {
|
||||
|
||||
// Call service to delete session
|
||||
if err := h.sessionService.DeleteSession(ctx, id); err != nil {
|
||||
if err == errors.ErrSessionNotFound {
|
||||
if stderrors.Is(err, errors.ErrSessionNotFound) {
|
||||
logger.Warnf(ctx, "Session not found, ID: %s", id)
|
||||
c.Error(errors.NewNotFoundError(err.Error()))
|
||||
return
|
||||
@@ -369,6 +370,11 @@ func (h *Handler) ClearSessionMessages(c *gin.Context) {
|
||||
logger.Infof(ctx, "Clearing all messages for session: %s", id)
|
||||
|
||||
if err := h.messageService.ClearSessionMessages(ctx, id); err != nil {
|
||||
if stderrors.Is(err, errors.ErrSessionNotFound) {
|
||||
logger.Warnf(ctx, "Session not found, ID: %s", id)
|
||||
c.Error(errors.NewNotFoundError(err.Error()))
|
||||
return
|
||||
}
|
||||
logger.ErrorWithFields(ctx, err, map[string]interface{}{"session_id": id})
|
||||
c.Error(errors.NewInternalServerError(err.Error()))
|
||||
return
|
||||
@@ -442,7 +448,7 @@ func (h *Handler) BatchDeleteSessions(c *gin.Context) {
|
||||
}
|
||||
|
||||
if err := h.sessionService.BatchDeleteSessions(ctx, sanitizedIDs); err != nil {
|
||||
if err == errors.ErrSessionNotFound {
|
||||
if stderrors.Is(err, errors.ErrSessionNotFound) {
|
||||
logger.Warnf(ctx, "No visible sessions found for batch delete")
|
||||
c.Error(errors.NewNotFoundError(err.Error()))
|
||||
return
|
||||
|
||||
@@ -2,6 +2,7 @@ package session
|
||||
|
||||
import (
|
||||
"context"
|
||||
stderrors "errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
@@ -54,7 +55,7 @@ func (h *Handler) ContinueStream(c *gin.Context) {
|
||||
// Verify that the session exists and belongs to this tenant
|
||||
_, err := h.sessionService.GetSession(ctx, sessionID)
|
||||
if err != nil {
|
||||
if err == errors.ErrSessionNotFound {
|
||||
if stderrors.Is(err, errors.ErrSessionNotFound) {
|
||||
logger.Warnf(ctx, "Session not found, ID: %s", sessionID)
|
||||
c.Error(errors.NewNotFoundError(err.Error()))
|
||||
} else {
|
||||
@@ -67,6 +68,15 @@ func (h *Handler) ContinueStream(c *gin.Context) {
|
||||
// Get the incomplete message
|
||||
message, err := h.messageService.GetMessage(ctx, sessionID, messageID)
|
||||
if err != nil {
|
||||
if stderrors.Is(err, errors.ErrSessionNotFound) {
|
||||
// PR #1309 plumbed user-scope into messageService.GetMessage's
|
||||
// session existence check; non-owner / wrong-user lookups now
|
||||
// surface as ErrSessionNotFound. Map to 404 so clients can tell
|
||||
// "wrong URL" from a real 5xx instead of seeing a generic 500.
|
||||
logger.Warnf(ctx, "Session not found, ID: %s", sessionID)
|
||||
c.Error(errors.NewNotFoundError(err.Error()))
|
||||
return
|
||||
}
|
||||
logger.ErrorWithFields(ctx, err, nil)
|
||||
c.Error(errors.NewInternalServerError(err.Error()))
|
||||
return
|
||||
|
||||
Reference in New Issue
Block a user