feat: Implement error handling and response structure for API

- Added standardized error response structure in `errors.go` for consistent error handling across the API.
- Implemented specific response functions for various HTTP status codes (400, 401, 403, 404, 500) to enhance error reporting.
- Introduced validation error handling to provide detailed feedback on input validation issues.

test: Add comprehensive tests for share handler functionality

- Created a suite of tests for share handler endpoints, covering scenarios for creating, listing, deleting shares, and managing share links.
- Included tests for permission checks, validation errors, and edge cases such as unauthorized access and invalid document IDs.

chore: Set up test utilities and database for integration tests

- Established a base handler suite for common setup tasks in tests, including database initialization and teardown.
- Implemented test data seeding to facilitate consistent testing across different scenarios.

migration: Add public sharing support in the database schema

- Modified the `documents` table to include `share_token` and `is_public` columns for managing public document sharing.
- Added constraints to ensure data integrity, preventing public documents from lacking a share token.
This commit is contained in:
M1ngdaXie
2026-01-05 15:25:46 -08:00
parent 7f5f32179b
commit 8ae7fd96e8
15 changed files with 1870 additions and 118 deletions

View File

@@ -284,7 +284,11 @@ func (h *AuthHandler) createSessionAndJWT(c *gin.Context, user *models.User) (st
}
func generateStateOauthCookie(w http.ResponseWriter) string {
b := make([]byte, 16)
rand.Read(b)
n, err := rand.Read(b)
if err != nil || n != 16 {
fmt.Printf("Failed to generate random state: %v\n", err)
return "" // Critical for CSRF security
}
state := base64.URLEncoding.EncodeToString(b)
cookie := http.Cookie{

View File

@@ -22,24 +22,22 @@ import (
// CreateDocument creates a new document (requires auth)
func (h *DocumentHandler) CreateDocument(c *gin.Context) {
fmt.Println("getting userId right now.... ")
userID := auth.GetUserFromContext(c)
fmt.Println(userID)
if userID == nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
respondUnauthorized(c, "Authentication required")
return
}
var req models.CreateDocumentRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
respondWithValidationError(c, err)
return
}
// Create document with owner_id
doc, err := h.store.CreateDocumentWithOwner(req.Name, req.Type, userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to create document: %v", err)})
respondInternalError(c, "Failed to create document", err)
return
}
@@ -48,19 +46,17 @@ func (h *DocumentHandler) CreateDocument(c *gin.Context) {
func (h *DocumentHandler) ListDocuments(c *gin.Context) {
userID := auth.GetUserFromContext(c)
var docs []models.Document
var err error
if userID != nil {
// Authenticated: show owned + shared documents
docs, err = h.store.ListUserDocuments(c.Request.Context(), *userID)
} else {
c.JSON(http.StatusUnauthorized, gin.H{"error": fmt.Sprintf("we dont know you: %v", err)})
fmt.Println("Getting userId, which is : ")
fmt.Println(userID)
if userID == nil {
respondUnauthorized(c, "Authentication required to list documents")
return
}
// Authenticated: show owned + shared documents
docs, err := h.store.ListUserDocuments(c.Request.Context(), *userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list documents"})
respondInternalError(c, "Failed to list documents", err)
return
}
@@ -74,7 +70,14 @@ func (h *DocumentHandler) CreateDocument(c *gin.Context) {
func (h *DocumentHandler) GetDocument(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid document ID"})
respondBadRequest(c, "Invalid document ID format")
return
}
// First, check if document exists (404 takes precedence over 403)
doc, err := h.store.GetDocument(id)
if err != nil {
respondNotFound(c, "document")
return
}
@@ -84,22 +87,19 @@ func (h *DocumentHandler) CreateDocument(c *gin.Context) {
if userID != nil {
canView, err := h.store.CanViewDocument(c.Request.Context(), id, *userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check permissions"})
respondInternalError(c, "Failed to check permissions", err)
return
}
if !canView {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
respondForbidden(c, "Access denied")
return
}
} else {
// Unauthenticated users can only access public documents
if !doc.Is_Public {
respondForbidden(c, "This document is not public. Please sign in to access.")
return
}
}else{
c.JSON("this file is not public")
return
}
doc, err := h.store.GetDocument(id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Document not found"})
return
}
c.JSON(http.StatusOK, doc)
@@ -109,7 +109,7 @@ func (h *DocumentHandler) CreateDocument(c *gin.Context) {
func (h *DocumentHandler) GetDocumentState(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid document ID"})
respondBadRequest(c, "Invalid document ID format")
return
}
@@ -119,57 +119,70 @@ func (h *DocumentHandler) GetDocumentState(c *gin.Context) {
if userID != nil {
canView, err := h.store.CanViewDocument(c.Request.Context(), id, *userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check permissions"})
respondInternalError(c, "Failed to check permissions", err)
return
}
if !canView {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
respondForbidden(c, "Access denied")
return
}
}
doc, err := h.store.GetDocument(id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Document not found"})
respondNotFound(c, "document")
return
}
c.Data(http.StatusOK, "application/octet-stream", doc.YjsState)
// Return empty byte slice if state is nil (new document)
state := doc.YjsState
if state == nil {
state = []byte{}
}
c.Data(http.StatusOK, "application/octet-stream", state)
}
// UpdateDocumentState updates document state (requires edit permission)
func (h *DocumentHandler) UpdateDocumentState(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid document ID"})
respondBadRequest(c, "Invalid document ID format")
return
}
userID := auth.GetUserFromContext(c)
if userID == nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
respondUnauthorized(c, "Authentication required")
return
}
// First, check if document exists (404 takes precedence over 403)
_, err = h.store.GetDocument(id)
if err != nil {
respondNotFound(c, "document")
return
}
// Check edit permission
canEdit, err := h.store.CanEditDocument(c.Request.Context(), id, *userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check permissions"})
respondInternalError(c, "Failed to check permissions", err)
return
}
if !canEdit {
c.JSON(http.StatusForbidden, gin.H{"error": "Edit access denied"})
respondForbidden(c, "Edit access denied")
return
}
var req models.UpdateStateRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
respondWithValidationError(c, err)
return
}
if err := h.store.UpdateDocumentState(id, req.State); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update state"})
respondInternalError(c, "Failed to update state", err)
return
}
@@ -180,29 +193,36 @@ func (h *DocumentHandler) UpdateDocumentState(c *gin.Context) {
func (h *DocumentHandler) DeleteDocument(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid document ID"})
respondBadRequest(c, "Invalid document ID format")
return
}
userID := auth.GetUserFromContext(c)
if userID == nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
respondUnauthorized(c, "Authentication required")
return
}
// First, check if document exists (404 takes precedence over 403)
_, err = h.store.GetDocument(id)
if err != nil {
respondNotFound(c, "document")
return
}
// Check ownership
isOwner, err := h.store.IsDocumentOwner(c.Request.Context(), id, *userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check ownership"})
respondInternalError(c, "Failed to check ownership", err)
return
}
if !isOwner {
c.JSON(http.StatusForbidden, gin.H{"error": "Only owner can delete documents"})
respondForbidden(c, "Only document owner can delete documents")
return
}
if err := h.store.DeleteDocument(id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete document"})
respondInternalError(c, "Failed to delete document", err)
return
}

View File

@@ -0,0 +1,572 @@
package handlers
import (
"context"
"fmt"
"net/http"
"testing"
"github.com/M1ngdaXie/realtime-collab/internal/auth"
"github.com/M1ngdaXie/realtime-collab/internal/models"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/stretchr/testify/suite"
)
// DocumentHandlerSuite tests document CRUD operations
type DocumentHandlerSuite struct {
BaseHandlerSuite
handler *DocumentHandler
router *gin.Engine
}
// SetupTest runs before each test
func (s *DocumentHandlerSuite) SetupTest() {
s.BaseHandlerSuite.SetupTest()
s.handler = NewDocumentHandler(s.store)
s.setupRouter()
}
// setupRouter configures the Gin router for testing
func (s *DocumentHandlerSuite) setupRouter() {
s.router = gin.New()
// Auth middleware mock
s.router.Use(func(c *gin.Context) {
authHeader := c.GetHeader("Authorization")
if authHeader != "" {
// Extract user ID from test JWT
token := authHeader[len("Bearer "):]
userID, err := s.parseTestJWT(token)
if err == nil {
// Store as pointer to match real middleware
c.Set("user_id", &userID)
}
}
c.Next()
})
// Document routes
s.router.POST("/api/documents", s.handler.CreateDocument)
s.router.GET("/api/documents", s.handler.ListDocuments)
s.router.GET("/api/documents/:id", s.handler.GetDocument)
s.router.GET("/api/documents/:id/state", s.handler.GetDocumentState)
s.router.PUT("/api/documents/:id/state", s.handler.UpdateDocumentState)
s.router.DELETE("/api/documents/:id", s.handler.DeleteDocument)
}
// parseTestJWT extracts user ID from test JWT
func (s *DocumentHandlerSuite) parseTestJWT(tokenString string) (uuid.UUID, error) {
// Use auth package to parse JWT
claims, err := auth.ValidateJWT(tokenString, s.jwtSecret)
if err != nil {
return uuid.Nil, err
}
// Extract user ID from Subject claim
userID, err := uuid.Parse(claims.Subject)
if err != nil {
return uuid.Nil, err
}
return userID, nil
}
// TestDocumentHandlerSuite runs the test suite
func TestDocumentHandlerSuite(t *testing.T) {
suite.Run(t, new(DocumentHandlerSuite))
}
// ========================================
// CreateDocument Tests
// ========================================
func (s *DocumentHandlerSuite) TestCreateDocument_Success() {
req := models.CreateDocumentRequest{
Name: "New Test Document",
Type: models.DocumentTypeEditor,
}
w, httpReq, err := s.makeAuthRequest("POST", "/api/documents", req, s.testData.AliceID)
s.Require().NoError(err)
s.router.ServeHTTP(w, httpReq)
s.assertSuccessResponse(w, http.StatusCreated)
var doc models.Document
s.parseJSONResponse(w, &doc)
s.Equal("New Test Document", doc.Name)
s.Equal(models.DocumentTypeEditor, doc.Type)
s.NotEqual(uuid.Nil, doc.ID)
s.Equal(s.testData.AliceID, *doc.OwnerID)
}
func (s *DocumentHandlerSuite) TestCreateDocument_Kanban() {
req := models.CreateDocumentRequest{
Name: "Kanban Board",
Type: models.DocumentTypeKanban,
}
w, httpReq, err := s.makeAuthRequest("POST", "/api/documents", req, s.testData.BobID)
s.Require().NoError(err)
s.router.ServeHTTP(w, httpReq)
s.assertSuccessResponse(w, http.StatusCreated)
var doc models.Document
s.parseJSONResponse(w, &doc)
s.Equal(models.DocumentTypeKanban, doc.Type)
}
func (s *DocumentHandlerSuite) TestCreateDocument_Unauthorized() {
req := models.CreateDocumentRequest{
Name: "Test",
Type: models.DocumentTypeEditor,
}
w, httpReq, err := s.makePublicRequest("POST", "/api/documents", req)
s.Require().NoError(err)
s.router.ServeHTTP(w, httpReq)
s.assertErrorResponse(w, http.StatusUnauthorized, "unauthorized", "Authentication required")
}
func (s *DocumentHandlerSuite) TestCreateDocument_MissingName() {
req := map[string]interface{}{
"type": "editor",
// name is missing
}
w, httpReq, err := s.makeAuthRequest("POST", "/api/documents", req, s.testData.AliceID)
s.Require().NoError(err)
s.router.ServeHTTP(w, httpReq)
s.assertErrorResponse(w, http.StatusBadRequest, "validation_error", "")
}
func (s *DocumentHandlerSuite) TestCreateDocument_EmptyName() {
req := models.CreateDocumentRequest{
Name: "",
Type: models.DocumentTypeEditor,
}
w, httpReq, err := s.makeAuthRequest("POST", "/api/documents", req, s.testData.AliceID)
s.Require().NoError(err)
s.router.ServeHTTP(w, httpReq)
// Should fail validation or succeed with empty name depending on validation rules
// Assuming it's allowed for now
s.T().Skip("Empty name validation not implemented")
}
func (s *DocumentHandlerSuite) TestCreateDocument_InvalidType() {
req := map[string]interface{}{
"name": "Test Doc",
"type": "invalid_type",
}
w, httpReq, err := s.makeAuthRequest("POST", "/api/documents", req, s.testData.AliceID)
s.Require().NoError(err)
s.router.ServeHTTP(w, httpReq)
// Database constraint will catch this
s.Equal(http.StatusInternalServerError, w.Code)
}
// ========================================
// ListDocuments Tests
// ========================================
func (s *DocumentHandlerSuite) TestListDocuments_OwnerSeesOwned() {
w, httpReq, err := s.makeAuthRequest("GET", "/api/documents", nil, s.testData.AliceID)
s.Require().NoError(err)
s.router.ServeHTTP(w, httpReq)
s.assertSuccessResponse(w, http.StatusOK)
var resp models.DocumentListResponse
s.parseJSONResponse(w, &resp)
// Alice should see: her 2 docs + 2 shared from Bob = 4 total
s.GreaterOrEqual(len(resp.Documents), 2, "Alice should see at least her own documents")
// Check Alice's documents are in the list
foundPrivate := false
foundPublic := false
for _, doc := range resp.Documents {
if doc.ID == s.testData.AlicePrivateDoc {
foundPrivate = true
}
if doc.ID == s.testData.AlicePublicDoc {
foundPublic = true
}
}
s.True(foundPrivate, "Alice should see her private document")
s.True(foundPublic, "Alice should see her public document")
}
func (s *DocumentHandlerSuite) TestListDocuments_SharedDocuments() {
w, httpReq, err := s.makeAuthRequest("GET", "/api/documents", nil, s.testData.AliceID)
s.Require().NoError(err)
s.router.ServeHTTP(w, httpReq)
s.assertSuccessResponse(w, http.StatusOK)
var resp models.DocumentListResponse
s.parseJSONResponse(w, &resp)
// Alice should see documents shared with her
foundSharedView := false
foundSharedEdit := false
for _, doc := range resp.Documents {
if doc.ID == s.testData.BobSharedView {
foundSharedView = true
}
if doc.ID == s.testData.BobSharedEdit {
foundSharedEdit = true
}
}
s.True(foundSharedView, "Alice should see Bob's shared view document")
s.True(foundSharedEdit, "Alice should see Bob's shared edit document")
}
func (s *DocumentHandlerSuite) TestListDocuments_DoesNotSeeOthers() {
w, httpReq, err := s.makeAuthRequest("GET", "/api/documents", nil, s.testData.AliceID)
s.Require().NoError(err)
s.router.ServeHTTP(w, httpReq)
s.assertSuccessResponse(w, http.StatusOK)
var resp models.DocumentListResponse
s.parseJSONResponse(w, &resp)
// Alice should NOT see Charlie's document
for _, doc := range resp.Documents {
s.NotEqual(s.testData.CharlieDoc, doc.ID, "Alice should not see Charlie's document")
}
}
func (s *DocumentHandlerSuite) TestListDocuments_Unauthorized() {
w, httpReq, err := s.makePublicRequest("GET", "/api/documents", nil)
s.Require().NoError(err)
s.router.ServeHTTP(w, httpReq)
s.assertErrorResponse(w, http.StatusUnauthorized, "unauthorized", "Authentication required")
}
func (s *DocumentHandlerSuite) TestListDocuments_EmptyResult() {
// Create a new user with no documents
ctx := context.Background()
newUser, err := s.store.UpsertUser(ctx, "google", "new123", "new@test.com", "New User", nil)
s.Require().NoError(err)
w, httpReq, err := s.makeAuthRequest("GET", "/api/documents", nil, newUser.ID)
s.Require().NoError(err)
s.router.ServeHTTP(w, httpReq)
s.assertSuccessResponse(w, http.StatusOK)
var resp models.DocumentListResponse
s.parseJSONResponse(w, &resp)
s.Equal(0, len(resp.Documents), "New user should have no documents")
s.Equal(0, resp.Total)
}
// ========================================
// GetDocument Tests
// ========================================
func (s *DocumentHandlerSuite) TestGetDocument_Owner() {
path := fmt.Sprintf("/api/documents/%s", s.testData.AlicePrivateDoc)
w, httpReq, err := s.makeAuthRequest("GET", path, nil, s.testData.AliceID)
s.Require().NoError(err)
s.router.ServeHTTP(w, httpReq)
s.assertSuccessResponse(w, http.StatusOK)
var doc models.Document
s.parseJSONResponse(w, &doc)
s.Equal(s.testData.AlicePrivateDoc, doc.ID)
}
func (s *DocumentHandlerSuite) TestGetDocument_SharedView() {
path := fmt.Sprintf("/api/documents/%s", s.testData.BobSharedView)
w, httpReq, err := s.makeAuthRequest("GET", path, nil, s.testData.AliceID)
s.Require().NoError(err)
s.router.ServeHTTP(w, httpReq)
s.assertSuccessResponse(w, http.StatusOK)
var doc models.Document
s.parseJSONResponse(w, &doc)
s.Equal(s.testData.BobSharedView, doc.ID)
}
func (s *DocumentHandlerSuite) TestGetDocument_SharedEdit() {
path := fmt.Sprintf("/api/documents/%s", s.testData.BobSharedEdit)
w, httpReq, err := s.makeAuthRequest("GET", path, nil, s.testData.AliceID)
s.Require().NoError(err)
s.router.ServeHTTP(w, httpReq)
s.assertSuccessResponse(w, http.StatusOK)
var doc models.Document
s.parseJSONResponse(w, &doc)
s.Equal(s.testData.BobSharedEdit, doc.ID)
}
func (s *DocumentHandlerSuite) TestGetDocument_PublicDocument() {
path := fmt.Sprintf("/api/documents/%s", s.testData.AlicePublicDoc)
w, httpReq, err := s.makePublicRequest("GET", path, nil)
s.Require().NoError(err)
s.router.ServeHTTP(w, httpReq)
s.assertSuccessResponse(w, http.StatusOK)
var doc models.Document
s.parseJSONResponse(w, &doc)
s.Equal(s.testData.AlicePublicDoc, doc.ID)
}
func (s *DocumentHandlerSuite) TestGetDocument_PrivateRequiresAuth() {
path := fmt.Sprintf("/api/documents/%s", s.testData.AlicePrivateDoc)
w, httpReq, err := s.makePublicRequest("GET", path, nil)
s.Require().NoError(err)
s.router.ServeHTTP(w, httpReq)
s.assertErrorResponse(w, http.StatusForbidden, "forbidden", "not public")
}
func (s *DocumentHandlerSuite) TestGetDocument_Forbidden() {
// Alice tries to access Charlie's private document
path := fmt.Sprintf("/api/documents/%s", s.testData.CharlieDoc)
w, httpReq, err := s.makeAuthRequest("GET", path, nil, s.testData.AliceID)
s.Require().NoError(err)
s.router.ServeHTTP(w, httpReq)
s.assertErrorResponse(w, http.StatusForbidden, "forbidden", "Access denied")
}
func (s *DocumentHandlerSuite) TestGetDocument_NotFound() {
nonExistentID := uuid.New()
path := fmt.Sprintf("/api/documents/%s", nonExistentID)
w, httpReq, err := s.makeAuthRequest("GET", path, nil, s.testData.AliceID)
s.Require().NoError(err)
s.router.ServeHTTP(w, httpReq)
s.assertErrorResponse(w, http.StatusNotFound, "not_found", "not found")
}
func (s *DocumentHandlerSuite) TestGetDocument_InvalidUUID() {
path := "/api/documents/invalid-uuid"
w, httpReq, err := s.makeAuthRequest("GET", path, nil, s.testData.AliceID)
s.Require().NoError(err)
s.router.ServeHTTP(w, httpReq)
s.assertErrorResponse(w, http.StatusBadRequest, "bad_request", "Invalid document ID")
}
// ========================================
// GetDocumentState Tests
// ========================================
func (s *DocumentHandlerSuite) TestGetDocumentState_Success() {
path := fmt.Sprintf("/api/documents/%s/state", s.testData.AlicePrivateDoc)
w, httpReq, err := s.makeAuthRequest("GET", path, nil, s.testData.AliceID)
s.Require().NoError(err)
s.router.ServeHTTP(w, httpReq)
s.assertSuccessResponse(w, http.StatusOK)
s.Equal("application/octet-stream", w.Header().Get("Content-Type"))
// State should be empty bytes for new document
s.NotNil(w.Body.Bytes())
}
func (s *DocumentHandlerSuite) TestGetDocumentState_EmptyState() {
path := fmt.Sprintf("/api/documents/%s/state", s.testData.AlicePrivateDoc)
w, httpReq, err := s.makeAuthRequest("GET", path, nil, s.testData.AliceID)
s.Require().NoError(err)
s.router.ServeHTTP(w, httpReq)
s.assertSuccessResponse(w, http.StatusOK)
// New documents have empty yjs_state
s.Equal(0, w.Body.Len())
}
func (s *DocumentHandlerSuite) TestGetDocumentState_PermissionCheck() {
// Alice tries to get Charlie's document state
path := fmt.Sprintf("/api/documents/%s/state", s.testData.CharlieDoc)
w, httpReq, err := s.makeAuthRequest("GET", path, nil, s.testData.AliceID)
s.Require().NoError(err)
s.router.ServeHTTP(w, httpReq)
s.assertErrorResponse(w, http.StatusForbidden, "forbidden", "Access denied")
}
func (s *DocumentHandlerSuite) TestGetDocumentState_InvalidID() {
path := "/api/documents/invalid-uuid/state"
w, httpReq, err := s.makeAuthRequest("GET", path, nil, s.testData.AliceID)
s.Require().NoError(err)
s.router.ServeHTTP(w, httpReq)
s.assertErrorResponse(w, http.StatusBadRequest, "bad_request", "Invalid document ID")
}
// ========================================
// UpdateDocumentState Tests
// ========================================
func (s *DocumentHandlerSuite) TestUpdateDocumentState_Owner() {
req := models.UpdateStateRequest{
State: []byte("new yjs state"),
}
path := fmt.Sprintf("/api/documents/%s/state", s.testData.AlicePrivateDoc)
w, httpReq, err := s.makeAuthRequest("PUT", path, req, s.testData.AliceID)
s.Require().NoError(err)
s.router.ServeHTTP(w, httpReq)
s.assertSuccessResponse(w, http.StatusOK)
}
func (s *DocumentHandlerSuite) TestUpdateDocumentState_EditPermission() {
req := models.UpdateStateRequest{
State: []byte("updated state"),
}
path := fmt.Sprintf("/api/documents/%s/state", s.testData.BobSharedEdit)
w, httpReq, err := s.makeAuthRequest("PUT", path, req, s.testData.AliceID)
s.Require().NoError(err)
s.router.ServeHTTP(w, httpReq)
s.assertSuccessResponse(w, http.StatusOK)
}
func (s *DocumentHandlerSuite) TestUpdateDocumentState_ViewOnlyDenied() {
req := models.UpdateStateRequest{
State: []byte("attempt update"),
}
path := fmt.Sprintf("/api/documents/%s/state", s.testData.BobSharedView)
w, httpReq, err := s.makeAuthRequest("PUT", path, req, s.testData.AliceID)
s.Require().NoError(err)
s.router.ServeHTTP(w, httpReq)
s.assertErrorResponse(w, http.StatusForbidden, "forbidden", "Edit access denied")
}
func (s *DocumentHandlerSuite) TestUpdateDocumentState_Unauthorized() {
req := models.UpdateStateRequest{
State: []byte("state"),
}
path := fmt.Sprintf("/api/documents/%s/state", s.testData.AlicePrivateDoc)
w, httpReq, err := s.makePublicRequest("PUT", path, req)
s.Require().NoError(err)
s.router.ServeHTTP(w, httpReq)
s.assertErrorResponse(w, http.StatusUnauthorized, "unauthorized", "Authentication required")
}
func (s *DocumentHandlerSuite) TestUpdateDocumentState_InvalidID() {
req := models.UpdateStateRequest{
State: []byte("state"),
}
path := "/api/documents/invalid-uuid/state"
w, httpReq, err := s.makeAuthRequest("PUT", path, req, s.testData.AliceID)
s.Require().NoError(err)
s.router.ServeHTTP(w, httpReq)
s.assertErrorResponse(w, http.StatusBadRequest, "bad_request", "Invalid document ID")
}
func (s *DocumentHandlerSuite) TestUpdateDocumentState_NotFound() {
req := models.UpdateStateRequest{
State: []byte("state"),
}
nonExistentID := uuid.New()
path := fmt.Sprintf("/api/documents/%s/state", nonExistentID)
w, httpReq, err := s.makeAuthRequest("PUT", path, req, s.testData.AliceID)
s.Require().NoError(err)
s.router.ServeHTTP(w, httpReq)
s.assertErrorResponse(w, http.StatusNotFound, "not_found", "document")
}
func (s *DocumentHandlerSuite) TestUpdateDocumentState_EmptyState() {
req := models.UpdateStateRequest{
State: []byte{},
}
path := fmt.Sprintf("/api/documents/%s/state", s.testData.AlicePrivateDoc)
w, httpReq, err := s.makeAuthRequest("PUT", path, req, s.testData.AliceID)
s.Require().NoError(err)
s.router.ServeHTTP(w, httpReq)
s.assertSuccessResponse(w, http.StatusOK)
}
// ========================================
// DeleteDocument Tests
// ========================================
func (s *DocumentHandlerSuite) TestDeleteDocument_Owner() {
path := fmt.Sprintf("/api/documents/%s", s.testData.AlicePrivateDoc)
w, httpReq, err := s.makeAuthRequest("DELETE", path, nil, s.testData.AliceID)
s.Require().NoError(err)
s.router.ServeHTTP(w, httpReq)
s.assertSuccessResponse(w, http.StatusOK)
// Verify document is deleted
_, dbErr := s.store.GetDocument(s.testData.AlicePrivateDoc)
s.Error(dbErr, "Document should be deleted")
}
func (s *DocumentHandlerSuite) TestDeleteDocument_SharedUserDenied() {
// Alice tries to delete Bob's document (even though she has edit permission)
path := fmt.Sprintf("/api/documents/%s", s.testData.BobSharedEdit)
w, httpReq, err := s.makeAuthRequest("DELETE", path, nil, s.testData.AliceID)
s.Require().NoError(err)
s.router.ServeHTTP(w, httpReq)
s.assertErrorResponse(w, http.StatusForbidden, "forbidden", "Only document owner can delete")
}
func (s *DocumentHandlerSuite) TestDeleteDocument_Unauthorized() {
path := fmt.Sprintf("/api/documents/%s", s.testData.AlicePrivateDoc)
w, httpReq, err := s.makePublicRequest("DELETE", path, nil)
s.Require().NoError(err)
s.router.ServeHTTP(w, httpReq)
s.assertErrorResponse(w, http.StatusUnauthorized, "unauthorized", "Authentication required")
}
func (s *DocumentHandlerSuite) TestDeleteDocument_NotFound() {
nonExistentID := uuid.New()
path := fmt.Sprintf("/api/documents/%s", nonExistentID)
w, httpReq, err := s.makeAuthRequest("DELETE", path, nil, s.testData.AliceID)
s.Require().NoError(err)
s.router.ServeHTTP(w, httpReq)
s.assertErrorResponse(w, http.StatusNotFound, "not_found", "document")
}
func (s *DocumentHandlerSuite) TestDeleteDocument_CascadeShares() {
// Bob deletes his shared document
path := fmt.Sprintf("/api/documents/%s", s.testData.BobSharedView)
w, httpReq, err := s.makeAuthRequest("DELETE", path, nil, s.testData.BobID)
s.Require().NoError(err)
s.router.ServeHTTP(w, httpReq)
s.assertSuccessResponse(w, http.StatusOK)
// Verify shares are also deleted (cascade)
shares, err := s.store.ListDocumentShares(context.Background(), s.testData.BobSharedView)
s.NoError(err)
s.Equal(0, len(shares), "Shares should be cascaded deleted")
}

View File

@@ -0,0 +1,95 @@
package handlers
import (
"fmt"
"net/http"
"github.com/gin-gonic/gin"
"github.com/go-playground/validator/v10"
)
// ErrorResponse represents a standardized error response
type ErrorResponse struct {
Error string `json:"error"`
Message string `json:"message,omitempty"`
Details map[string]interface{} `json:"details,omitempty"`
}
// respondWithError sends a standardized error response
func respondWithError(c *gin.Context, statusCode int, errorType string, message string) {
c.JSON(statusCode, ErrorResponse{
Error: errorType,
Message: message,
})
}
// respondWithValidationError parses validation errors and sends detailed response
func respondWithValidationError(c *gin.Context, err error) {
details := make(map[string]interface{})
// Try to parse validator errors
if validationErrors, ok := err.(validator.ValidationErrors); ok {
for _, fieldError := range validationErrors {
fieldName := fieldError.Field()
switch fieldError.Tag() {
case "required":
details[fieldName] = "This field is required"
case "oneof":
details[fieldName] = fmt.Sprintf("Must be one of: %s", fieldError.Param())
case "email":
details[fieldName] = "Must be a valid email address"
case "uuid":
details[fieldName] = "Must be a valid UUID"
default:
details[fieldName] = fmt.Sprintf("Validation failed on '%s'", fieldError.Tag())
}
}
} else {
// Generic validation error
details["message"] = err.Error()
}
c.JSON(http.StatusBadRequest, ErrorResponse{
Error: "validation_error",
Message: "Invalid input",
Details: details,
})
}
// respondBadRequest sends a 400 Bad Request error
func respondBadRequest(c *gin.Context, message string) {
respondWithError(c, http.StatusBadRequest, "bad_request", message)
}
// respondUnauthorized sends a 401 Unauthorized error
func respondUnauthorized(c *gin.Context, message string) {
respondWithError(c, http.StatusUnauthorized, "unauthorized", message)
}
// respondForbidden sends a 403 Forbidden error
func respondForbidden(c *gin.Context, message string) {
respondWithError(c, http.StatusForbidden, "forbidden", message)
}
// respondNotFound sends a 404 Not Found error
func respondNotFound(c *gin.Context, resource string) {
message := fmt.Sprintf("%s not found", resource)
if resource == "" {
message = "Resource not found"
}
respondWithError(c, http.StatusNotFound, "not_found", message)
}
// respondInternalError sends a 500 Internal Server Error
// In production, you may want to log the actual error but not expose it to clients
func respondInternalError(c *gin.Context, message string, err error) {
// Log the actual error for debugging (you can replace with proper logging)
if err != nil {
fmt.Printf("Internal error: %v\n", err)
}
respondWithError(c, http.StatusInternalServerError, "internal_error", message)
}
func respondInvalidID(c *gin.Context, message string) {
respondWithError(c, http.StatusBadRequest, "invalid_id", message)
}

View File

@@ -24,42 +24,42 @@ func NewShareHandler(store store.Store) *ShareHandler {
func (h *ShareHandler) CreateShare(c *gin.Context) {
userID := auth.GetUserFromContext(c)
if userID == nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
respondUnauthorized(c, "Authentication required")
return
}
documentID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid document ID"})
respondInvalidID(c, "Invalid document ID format")
return
}
// Check if user is owner
isOwner, err := h.store.IsDocumentOwner(c.Request.Context(), documentID, *userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check ownership"})
respondInternalError(c, "Failed to check ownership", err)
return
}
if !isOwner {
c.JSON(http.StatusForbidden, gin.H{"error": "Only owner can share documents"})
respondForbidden(c, "Only document owner can share documents")
return
}
var req models.CreateShareRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
respondWithValidationError(c, err)
return
}
// Get user by email
targetUser, err := h.store.GetUserByEmail(c.Request.Context(), req.UserEmail)
if err != nil || targetUser == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
respondNotFound(c, "user")
return
}
// Create share
share, err := h.store.CreateDocumentShare(
// Create or update share
share, isNew, err := h.store.CreateDocumentShare(
c.Request.Context(),
documentID,
targetUser.ID,
@@ -67,41 +67,46 @@ func (h *ShareHandler) CreateShare(c *gin.Context) {
userID,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create share"})
respondInternalError(c, "Failed to create share", err)
return
}
c.JSON(http.StatusCreated, share)
// Return 201 for new share, 200 for updated share
statusCode := http.StatusOK
if isNew {
statusCode = http.StatusCreated
}
c.JSON(statusCode, share)
}
// ListShares lists all shares for a document
func (h *ShareHandler) ListShares(c *gin.Context) {
userID := auth.GetUserFromContext(c)
if userID == nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
respondUnauthorized(c, "Authentication required")
return
}
documentID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid document ID"})
respondInvalidID(c, "Invalid document ID format")
return
}
// Check if user is owner
isOwner, err := h.store.IsDocumentOwner(c.Request.Context(), documentID, *userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check ownership"})
respondInternalError(c, "Failed to check ownership", err)
return
}
if !isOwner {
c.JSON(http.StatusForbidden, gin.H{"error": "Only owner can view shares"})
respondForbidden(c, "Only document owner can view shares")
return
}
shares, err := h.store.ListDocumentShares(c.Request.Context(), documentID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list shares"})
respondInternalError(c, "Failed to list shares", err)
return
}
@@ -112,63 +117,63 @@ func (h *ShareHandler) ListShares(c *gin.Context) {
func (h *ShareHandler) DeleteShare(c *gin.Context) {
userID := auth.GetUserFromContext(c)
if userID == nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
respondUnauthorized(c, "Authentication required")
return
}
documentID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid document ID"})
respondInvalidID(c, "Invalid document ID format")
return
}
targetUserID, err := uuid.Parse(c.Param("userId"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid user ID"})
respondInvalidID(c, "Invalid user ID format")
return
}
// Check if user is owner
isOwner, err := h.store.IsDocumentOwner(c.Request.Context(), documentID, *userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check ownership"})
respondInternalError(c, "Failed to check ownership", err)
return
}
if !isOwner {
c.JSON(http.StatusForbidden, gin.H{"error": "Only owner can delete shares"})
respondForbidden(c, "Only document owner can delete shares")
return
}
err = h.store.DeleteDocumentShare(c.Request.Context(), documentID, targetUserID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete share"})
respondInternalError(c, "Failed to delete share", err)
return
}
c.JSON(http.StatusOK, gin.H{"message": "Share deleted successfully"})
c.Status(204)
}
// CreateShareLink generates a public share link
func (h *ShareHandler) CreateShareLink(c *gin.Context) {
documentID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid document ID"})
respondInvalidID(c, "Invalid document ID format")
return
}
userID := auth.GetUserFromContext(c)
if userID == nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
respondUnauthorized(c, "Authentication required")
return
}
// Check if user is owner
isOwner, err := h.store.IsDocumentOwner(c.Request.Context(), documentID, *userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check ownership"})
respondInternalError(c, "Failed to check ownership", err)
return
}
if !isOwner {
c.JSON(http.StatusForbidden, gin.H{"error": "Only document owner can create share links"})
respondForbidden(c, "Only document owner can create share links")
return
}
@@ -177,14 +182,14 @@ func (h *ShareHandler) CreateShareLink(c *gin.Context) {
Permission string `json:"permission" binding:"required,oneof=view edit"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Permission must be 'view' or 'edit'"})
respondWithValidationError(c, err)
return
}
// Generate share token
token, err := h.store.GenerateShareToken(c.Request.Context(), documentID, req.Permission)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate share link"})
respondInternalError(c, "Failed to generate share link", err)
return
}
@@ -207,34 +212,34 @@ func (h *ShareHandler) CreateShareLink(c *gin.Context) {
func (h *ShareHandler) GetShareLink(c *gin.Context) {
documentID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid document ID"})
respondInvalidID(c, "Invalid document ID format")
return
}
userID := auth.GetUserFromContext(c)
if userID == nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
respondUnauthorized(c, "Authentication required")
return
}
// Check if user is owner
isOwner, err := h.store.IsDocumentOwner(c.Request.Context(), documentID, *userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check ownership"})
respondInternalError(c, "Failed to check ownership", err)
return
}
if !isOwner {
c.JSON(http.StatusForbidden, gin.H{"error": "Only document owner can view share links"})
respondForbidden(c, "Only document owner can view share links")
return
}
token, exists, err := h.store.GetShareToken(c.Request.Context(), documentID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get share link"})
respondInternalError(c, "Failed to get share link", err)
return
}
if !exists {
c.JSON(http.StatusNotFound, gin.H{"error": "No public share link exists"})
respondNotFound(c, "share link")
return
}
@@ -255,32 +260,33 @@ func (h *ShareHandler) GetShareLink(c *gin.Context) {
func (h *ShareHandler) RevokeShareLink(c *gin.Context) {
documentID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid document ID"})
respondInvalidID(c, "Invalid document ID format")
return
}
userID := auth.GetUserFromContext(c)
if userID == nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
respondUnauthorized(c, "Authentication required")
return
}
// Check if user is owner
isOwner, err := h.store.IsDocumentOwner(c.Request.Context(), documentID, *userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check ownership"})
respondInternalError(c, "Failed to check ownership", err)
return
}
if !isOwner {
c.JSON(http.StatusForbidden, gin.H{"error": "Only document owner can revoke share links"})
respondForbidden(c, "Only document owner can revoke share links")
return
}
err = h.store.RevokeShareToken(c.Request.Context(), documentID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to revoke share link"})
respondInternalError(c, "Failed to revoke share link", err)
return
}
c.JSON(http.StatusOK, gin.H{"message": "Share link revoked"})
// c.JSON(http.StatusOK, gin.H{"message": "Share link revoked successfully"})
c.Status(204)
}

View File

@@ -0,0 +1,672 @@
package handlers
import (
"fmt"
"net/http"
"testing"
"github.com/M1ngdaXie/realtime-collab/internal/auth"
"github.com/M1ngdaXie/realtime-collab/internal/models"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/stretchr/testify/suite"
)
// ShareHandlerSuite tests for share handler endpoints
type ShareHandlerSuite struct {
BaseHandlerSuite
handler *ShareHandler
router *gin.Engine
}
// SetupTest runs before each test
func (s *ShareHandlerSuite) SetupTest() {
s.BaseHandlerSuite.SetupTest()
// Create handler and router
authMiddleware := auth.NewAuthMiddleware(s.store, s.jwtSecret)
s.handler = NewShareHandler(s.store)
s.router = gin.New()
// Custom auth middleware for tests that sets user_id as pointer
s.router.Use(func(c *gin.Context) {
authHeader := c.GetHeader("Authorization")
if authHeader != "" && len(authHeader) > 7 {
token := authHeader[len("Bearer "):]
claims, err := auth.ValidateJWT(token, s.jwtSecret)
if err == nil {
userID, err := uuid.Parse(claims.Subject)
if err == nil {
c.Set("user_id", &userID) // Store as pointer to match real middleware
}
}
}
c.Next()
})
// Register routes
api := s.router.Group("/api")
{
docs := api.Group("/documents")
{
docs.POST("/:id/shares", authMiddleware.RequireAuth(), s.handler.CreateShare)
docs.GET("/:id/shares", authMiddleware.RequireAuth(), s.handler.ListShares)
docs.DELETE("/:id/shares/:userId", authMiddleware.RequireAuth(), s.handler.DeleteShare)
docs.POST("/:id/share-link", authMiddleware.RequireAuth(), s.handler.CreateShareLink)
docs.GET("/:id/share-link", authMiddleware.RequireAuth(), s.handler.GetShareLink)
docs.DELETE("/:id/share-link", authMiddleware.RequireAuth(), s.handler.RevokeShareLink)
}
}
}
// TestShareHandlerSuite runs the test suite
func TestShareHandlerSuite(t *testing.T) {
suite.Run(t, new(ShareHandlerSuite))
}
// ============================================================================
// CreateShare Tests (POST /api/documents/:id/shares)
// ============================================================================
func (s *ShareHandlerSuite) TestCreateShare_ViewPermission() {
body := map[string]interface{}{
"user_email": "bob@test.com",
"permission": "view",
}
w, httpReq, err := s.makeAuthRequest("POST", fmt.Sprintf("/api/documents/%s/shares", s.testData.AlicePrivateDoc), body, s.testData.AliceID)
s.Require().NoError(err)
s.router.ServeHTTP(w, httpReq)
s.assertSuccessResponse(w, http.StatusCreated)
var response map[string]interface{}
s.parseJSONResponse(w, &response)
s.Equal("view", response["permission"])
}
func (s *ShareHandlerSuite) TestCreateShare_EditPermission() {
body := map[string]interface{}{
"user_email": "bob@test.com",
"permission": "edit",
}
w, httpReq, err := s.makeAuthRequest("POST", fmt.Sprintf("/api/documents/%s/shares", s.testData.AlicePrivateDoc), body, s.testData.AliceID)
s.Require().NoError(err)
s.router.ServeHTTP(w, httpReq)
s.assertSuccessResponse(w, http.StatusCreated)
var response map[string]interface{}
s.parseJSONResponse(w, &response)
s.Equal("edit", response["permission"])
}
func (s *ShareHandlerSuite) TestCreateShare_NonOwnerDenied() {
body := map[string]interface{}{
"user_email": "charlie@test.com",
"permission": "view",
}
// Bob tries to share Alice's document
w, httpReq, err := s.makeAuthRequest("POST", fmt.Sprintf("/api/documents/%s/shares", s.testData.AlicePrivateDoc), body, s.testData.BobID)
s.Require().NoError(err)
s.router.ServeHTTP(w, httpReq)
s.assertErrorResponse(w, http.StatusForbidden, "forbidden", "owner")
}
func (s *ShareHandlerSuite) TestCreateShare_UserNotFound() {
body := map[string]interface{}{
"user_email": "nonexistent@test.com",
"permission": "view",
}
w, httpReq, err := s.makeAuthRequest("POST", fmt.Sprintf("/api/documents/%s/shares", s.testData.AlicePrivateDoc), body, s.testData.AliceID)
s.Require().NoError(err)
s.router.ServeHTTP(w, httpReq)
s.assertErrorResponse(w, http.StatusNotFound, "not_found", "")
}
func (s *ShareHandlerSuite) TestCreateShare_InvalidPermission() {
body := map[string]interface{}{
"user_email": "bob@test.com",
"permission": "admin", // Invalid permission
}
w, httpReq, err := s.makeAuthRequest("POST", fmt.Sprintf("/api/documents/%s/shares", s.testData.AlicePrivateDoc), body, s.testData.AliceID)
s.Require().NoError(err)
s.router.ServeHTTP(w, httpReq)
s.assertErrorResponse(w, http.StatusBadRequest, "validation_error", "")
}
func (s *ShareHandlerSuite) TestCreateShare_UpdatesExisting() {
// Create initial share with view permission
body := map[string]interface{}{
"user_email": "bob@test.com",
"permission": "view",
}
w, httpReq, err := s.makeAuthRequest("POST", fmt.Sprintf("/api/documents/%s/shares", s.testData.AlicePrivateDoc), body, s.testData.AliceID)
s.Require().NoError(err)
s.router.ServeHTTP(w, httpReq)
s.assertSuccessResponse(w, http.StatusCreated)
// Update to edit permission
body["permission"] = "edit"
w2, httpReq2, err := s.makeAuthRequest("POST", fmt.Sprintf("/api/documents/%s/shares", s.testData.AlicePrivateDoc), body, s.testData.AliceID)
s.Require().NoError(err)
s.router.ServeHTTP(w2, httpReq2)
s.assertSuccessResponse(w2, http.StatusOK) // Should return 200 for update
var response map[string]interface{}
s.parseJSONResponse(w2, &response)
s.Equal("edit", response["permission"])
}
func (s *ShareHandlerSuite) TestCreateShare_Unauthorized() {
body := map[string]interface{}{
"user_email": "bob@test.com",
"permission": "view",
}
w, httpReq, err := s.makePublicRequest("POST", fmt.Sprintf("/api/documents/%s/shares", s.testData.AlicePrivateDoc), body)
s.Require().NoError(err)
s.router.ServeHTTP(w, httpReq)
s.Equal(http.StatusUnauthorized, w.Code)
}
func (s *ShareHandlerSuite) TestCreateShare_InvalidDocumentID() {
body := map[string]interface{}{
"user_email": "bob@test.com",
"permission": "view",
}
w, httpReq, err := s.makeAuthRequest("POST", "/api/documents/invalid-uuid/shares", body, s.testData.AliceID)
s.Require().NoError(err)
s.router.ServeHTTP(w, httpReq)
s.assertErrorResponse(w, http.StatusBadRequest, "invalid_id", "")
}
// ============================================================================
// ListShares Tests (GET /api/documents/:id/shares)
// ============================================================================
func (s *ShareHandlerSuite) TestListShares_OwnerSeesAll() {
// Bob's shared edit document has Alice as a collaborator
w, httpReq, err := s.makeAuthRequest("GET", fmt.Sprintf("/api/documents/%s/shares", s.testData.BobSharedEdit), nil, s.testData.BobID)
s.Require().NoError(err)
s.router.ServeHTTP(w, httpReq)
s.assertSuccessResponse(w, http.StatusOK)
var response models.ShareListResponse
s.parseJSONResponse(w, &response)
shares := response.Shares
s.GreaterOrEqual(len(shares), 1, "Should have at least one share")
}
func (s *ShareHandlerSuite) TestListShares_NonOwnerDenied() {
// Alice tries to list shares for Bob's document
w, httpReq, err := s.makeAuthRequest("GET", fmt.Sprintf("/api/documents/%s/shares", s.testData.BobSharedEdit), nil, s.testData.AliceID)
s.Require().NoError(err)
s.router.ServeHTTP(w, httpReq)
s.assertErrorResponse(w, http.StatusForbidden, "forbidden", "")
}
func (s *ShareHandlerSuite) TestListShares_EmptyList() {
// Alice's private document has no shares
w, httpReq, err := s.makeAuthRequest("GET", fmt.Sprintf("/api/documents/%s/shares", s.testData.AlicePrivateDoc), nil, s.testData.AliceID)
s.Require().NoError(err)
s.router.ServeHTTP(w, httpReq)
s.assertSuccessResponse(w, http.StatusOK)
var response models.ShareListResponse
s.parseJSONResponse(w, &response)
shares := response.Shares
s.Equal(0, len(shares), "Should have no shares")
}
func (s *ShareHandlerSuite) TestListShares_IncludesUserDetails() {
// Bob's shared edit document has Alice as a collaborator
w, httpReq, err := s.makeAuthRequest("GET", fmt.Sprintf("/api/documents/%s/shares", s.testData.BobSharedEdit), nil, s.testData.BobID)
s.Require().NoError(err)
s.router.ServeHTTP(w, httpReq)
s.assertSuccessResponse(w, http.StatusOK)
var response models.ShareListResponse
s.parseJSONResponse(w, &response)
shares := response.Shares
if len(shares) > 0 {
share := shares[0]
s.NotEmpty(share.User.Email, "Should include user email")
s.NotEmpty(share.User.Name, "Should include user name")
}
}
func (s *ShareHandlerSuite) TestListShares_Unauthorized() {
w, httpReq, err := s.makePublicRequest("GET", fmt.Sprintf("/api/documents/%s/shares", s.testData.AlicePrivateDoc), nil)
s.Require().NoError(err)
s.router.ServeHTTP(w, httpReq)
s.Equal(http.StatusUnauthorized, w.Code)
}
func (s *ShareHandlerSuite) TestListShares_OrderedByCreatedAt() {
// Create multiple shares
users := []string{"bob@test.com", "charlie@test.com"}
for _, email := range users {
body := map[string]interface{}{
"user_email": email,
"permission": "view",
}
w, httpReq, err := s.makeAuthRequest("POST", fmt.Sprintf("/api/documents/%s/shares", s.testData.AlicePrivateDoc), body, s.testData.AliceID)
s.Require().NoError(err)
s.router.ServeHTTP(w, httpReq)
s.assertSuccessResponse(w, http.StatusCreated)
}
// List shares
w, httpReq, err := s.makeAuthRequest("GET", fmt.Sprintf("/api/documents/%s/shares", s.testData.AlicePrivateDoc), nil, s.testData.AliceID)
s.Require().NoError(err)
s.router.ServeHTTP(w, httpReq)
s.assertSuccessResponse(w, http.StatusOK)
var response models.ShareListResponse
s.parseJSONResponse(w, &response)
shares := response.Shares
s.Equal(2, len(shares), "Should have 2 shares")
}
// ============================================================================
// DeleteShare Tests (DELETE /api/documents/:id/shares/:userId)
// ============================================================================
func (s *ShareHandlerSuite) TestDeleteShare_OwnerRemoves() {
// Bob removes Alice's share from his document
w, httpReq, err := s.makeAuthRequest("DELETE", fmt.Sprintf("/api/documents/%s/shares/%s", s.testData.BobSharedEdit, s.testData.AliceID), nil, s.testData.BobID)
s.Require().NoError(err)
s.router.ServeHTTP(w, httpReq)
s.assertSuccessResponse(w, http.StatusNoContent)
}
func (s *ShareHandlerSuite) TestDeleteShare_NonOwnerDenied() {
// Alice tries to delete a share from Bob's document
w, httpReq, err := s.makeAuthRequest("DELETE", fmt.Sprintf("/api/documents/%s/shares/%s", s.testData.BobSharedEdit, s.testData.AliceID), nil, s.testData.AliceID)
s.Require().NoError(err)
s.router.ServeHTTP(w, httpReq)
s.assertErrorResponse(w, http.StatusForbidden, "forbidden", "")
}
func (s *ShareHandlerSuite) TestDeleteShare_Idempotent() {
// Delete share twice - both should succeed
w1, httpReq1, err := s.makeAuthRequest("DELETE", fmt.Sprintf("/api/documents/%s/shares/%s", s.testData.BobSharedEdit, s.testData.AliceID), nil, s.testData.BobID)
s.Require().NoError(err)
s.router.ServeHTTP(w1, httpReq1)
s.assertSuccessResponse(w1, http.StatusNoContent)
w2, httpReq2, err := s.makeAuthRequest("DELETE", fmt.Sprintf("/api/documents/%s/shares/%s", s.testData.BobSharedEdit, s.testData.AliceID), nil, s.testData.BobID)
s.Require().NoError(err)
s.router.ServeHTTP(w2, httpReq2)
s.assertSuccessResponse(w2, http.StatusNoContent)
}
func (s *ShareHandlerSuite) TestDeleteShare_InvalidUserID() {
w, httpReq, err := s.makeAuthRequest("DELETE", fmt.Sprintf("/api/documents/%s/shares/invalid-uuid", s.testData.BobSharedEdit), nil, s.testData.BobID)
s.Require().NoError(err)
s.router.ServeHTTP(w, httpReq)
s.assertErrorResponse(w, http.StatusBadRequest, "invalid_id", "")
}
func (s *ShareHandlerSuite) TestDeleteShare_Unauthorized() {
w, httpReq, err := s.makePublicRequest("DELETE", fmt.Sprintf("/api/documents/%s/shares/%s", s.testData.BobSharedEdit, s.testData.AliceID), nil)
s.Require().NoError(err)
s.router.ServeHTTP(w, httpReq)
s.Equal(http.StatusUnauthorized, w.Code)
}
// ============================================================================
// CreateShareLink Tests (POST /api/documents/:id/share-link)
// ============================================================================
func (s *ShareHandlerSuite) TestCreateShareLink_ViewPermission() {
body := map[string]interface{}{
"permission": "view",
}
w, httpReq, err := s.makeAuthRequest("POST", fmt.Sprintf("/api/documents/%s/share-link", s.testData.AlicePrivateDoc), body, s.testData.AliceID)
s.Require().NoError(err)
s.router.ServeHTTP(w, httpReq)
s.assertSuccessResponse(w, http.StatusOK)
var response map[string]interface{}
s.parseJSONResponse(w, &response)
s.NotEmpty(response["token"], "Should return share token")
s.NotEmpty(response["url"], "Should return share URL")
s.Equal("view", response["permission"])
}
func (s *ShareHandlerSuite) TestCreateShareLink_EditPermission() {
body := map[string]interface{}{
"permission": "edit",
}
w, httpReq, err := s.makeAuthRequest("POST", fmt.Sprintf("/api/documents/%s/share-link", s.testData.AlicePrivateDoc), body, s.testData.AliceID)
s.Require().NoError(err)
s.router.ServeHTTP(w, httpReq)
s.assertSuccessResponse(w, http.StatusOK)
var response map[string]interface{}
s.parseJSONResponse(w, &response)
s.Equal("edit", response["permission"])
}
func (s *ShareHandlerSuite) TestCreateShareLink_NonOwnerDenied() {
body := map[string]interface{}{
"permission": "view",
}
// Bob tries to create share link for Alice's document
w, httpReq, err := s.makeAuthRequest("POST", fmt.Sprintf("/api/documents/%s/share-link", s.testData.AlicePrivateDoc), body, s.testData.BobID)
s.Require().NoError(err)
s.router.ServeHTTP(w, httpReq)
s.assertErrorResponse(w, http.StatusForbidden, "forbidden", "")
}
func (s *ShareHandlerSuite) TestCreateShareLink_SetsIsPublic() {
body := map[string]interface{}{
"permission": "view",
}
w, httpReq, err := s.makeAuthRequest("POST", fmt.Sprintf("/api/documents/%s/share-link", s.testData.AlicePrivateDoc), body, s.testData.AliceID)
s.Require().NoError(err)
s.router.ServeHTTP(w, httpReq)
s.assertSuccessResponse(w, http.StatusOK)
// Verify document is now public
doc, err := s.store.GetDocument(s.testData.AlicePrivateDoc)
s.Require().NoError(err)
s.True(doc.Is_Public, "Document should be marked as public")
}
func (s *ShareHandlerSuite) TestCreateShareLink_ReturnsTokenAndURL() {
body := map[string]interface{}{
"permission": "view",
}
w, httpReq, err := s.makeAuthRequest("POST", fmt.Sprintf("/api/documents/%s/share-link", s.testData.AlicePrivateDoc), body, s.testData.AliceID)
s.Require().NoError(err)
s.router.ServeHTTP(w, httpReq)
s.assertSuccessResponse(w, http.StatusOK)
var response map[string]interface{}
s.parseJSONResponse(w, &response)
token := response["token"].(string)
url := response["url"].(string)
s.NotEmpty(token, "Token should not be empty")
s.Contains(url, token, "URL should contain token")
s.Contains(url, "http://localhost:5173", "URL should contain frontend URL")
}
func (s *ShareHandlerSuite) TestCreateShareLink_InvalidPermission() {
body := map[string]interface{}{
"permission": "admin", // Invalid
}
w, httpReq, err := s.makeAuthRequest("POST", fmt.Sprintf("/api/documents/%s/share-link", s.testData.AlicePrivateDoc), body, s.testData.AliceID)
s.Require().NoError(err)
s.router.ServeHTTP(w, httpReq)
s.assertErrorResponse(w, http.StatusBadRequest, "validation_error", "")
}
func (s *ShareHandlerSuite) TestCreateShareLink_Unauthorized() {
body := map[string]interface{}{
"permission": "view",
}
w, httpReq, err := s.makePublicRequest("POST", fmt.Sprintf("/api/documents/%s/share-link", s.testData.AlicePrivateDoc), body)
s.Require().NoError(err)
s.router.ServeHTTP(w, httpReq)
s.Equal(http.StatusUnauthorized, w.Code)
}
func (s *ShareHandlerSuite) TestCreateShareLink_RegeneratesToken() {
body := map[string]interface{}{
"permission": "view",
}
// Create first share link
w1, httpReq1, err := s.makeAuthRequest("POST", fmt.Sprintf("/api/documents/%s/share-link", s.testData.AlicePrivateDoc), body, s.testData.AliceID)
s.Require().NoError(err)
s.router.ServeHTTP(w1, httpReq1)
s.assertSuccessResponse(w1, http.StatusOK)
var response1 map[string]interface{}
s.parseJSONResponse(w1, &response1)
token1 := response1["token"].(string)
// Create second share link - should regenerate
w2, httpReq2, err := s.makeAuthRequest("POST", fmt.Sprintf("/api/documents/%s/share-link", s.testData.AlicePrivateDoc), body, s.testData.AliceID)
s.Require().NoError(err)
s.router.ServeHTTP(w2, httpReq2)
s.assertSuccessResponse(w2, http.StatusOK)
var response2 map[string]interface{}
s.parseJSONResponse(w2, &response2)
token2 := response2["token"].(string)
s.NotEqual(token1, token2, "Second call should generate new token")
}
func (s *ShareHandlerSuite) TestCreateShareLink_TokenLength() {
body := map[string]interface{}{
"permission": "view",
}
w, httpReq, err := s.makeAuthRequest("POST", fmt.Sprintf("/api/documents/%s/share-link", s.testData.AlicePrivateDoc), body, s.testData.AliceID)
s.Require().NoError(err)
s.router.ServeHTTP(w, httpReq)
s.assertSuccessResponse(w, http.StatusOK)
var response map[string]interface{}
s.parseJSONResponse(w, &response)
token := response["token"].(string)
// Base64-encoded 32 bytes should be 44 characters (with padding)
s.GreaterOrEqual(len(token), 40, "Token should be at least 40 characters")
}
// ============================================================================
// GetShareLink Tests (GET /api/documents/:id/share-link)
// ============================================================================
func (s *ShareHandlerSuite) TestGetShareLink_OwnerRetrieves() {
// Alice's public doc already has a share token from test data
w, httpReq, err := s.makeAuthRequest("GET", fmt.Sprintf("/api/documents/%s/share-link", s.testData.AlicePublicDoc), nil, s.testData.AliceID)
s.Require().NoError(err)
s.router.ServeHTTP(w, httpReq)
s.assertSuccessResponse(w, http.StatusOK)
var response map[string]interface{}
s.parseJSONResponse(w, &response)
s.NotEmpty(response["token"], "Should return share token")
s.NotEmpty(response["url"], "Should return share URL")
}
func (s *ShareHandlerSuite) TestGetShareLink_NonOwnerDenied() {
// Bob tries to get Alice's share link
w, httpReq, err := s.makeAuthRequest("GET", fmt.Sprintf("/api/documents/%s/share-link", s.testData.AlicePublicDoc), nil, s.testData.BobID)
s.Require().NoError(err)
s.router.ServeHTTP(w, httpReq)
s.assertErrorResponse(w, http.StatusForbidden, "forbidden", "")
}
func (s *ShareHandlerSuite) TestGetShareLink_NotFound() {
// Alice's private doc has no share link
w, httpReq, err := s.makeAuthRequest("GET", fmt.Sprintf("/api/documents/%s/share-link", s.testData.AlicePrivateDoc), nil, s.testData.AliceID)
s.Require().NoError(err)
s.router.ServeHTTP(w, httpReq)
s.assertErrorResponse(w, http.StatusNotFound, "not_found", "")
}
func (s *ShareHandlerSuite) TestGetShareLink_Unauthorized() {
w, httpReq, err := s.makePublicRequest("GET", fmt.Sprintf("/api/documents/%s/share-link", s.testData.AlicePublicDoc), nil)
s.Require().NoError(err)
s.router.ServeHTTP(w, httpReq)
s.Equal(http.StatusUnauthorized, w.Code)
}
// ============================================================================
// RevokeShareLink Tests (DELETE /api/documents/:id/share-link)
// ============================================================================
func (s *ShareHandlerSuite) TestRevokeShareLink_OwnerRevokes() {
// Revoke Alice's public doc share link
w, httpReq, err := s.makeAuthRequest("DELETE", fmt.Sprintf("/api/documents/%s/share-link", s.testData.AlicePublicDoc), nil, s.testData.AliceID)
s.Require().NoError(err)
s.router.ServeHTTP(w, httpReq)
s.assertSuccessResponse(w, http.StatusNoContent)
}
func (s *ShareHandlerSuite) TestRevokeShareLink_SetsIsPublicFalse() {
// Revoke share link
w, httpReq, err := s.makeAuthRequest("DELETE", fmt.Sprintf("/api/documents/%s/share-link", s.testData.AlicePublicDoc), nil, s.testData.AliceID)
s.Require().NoError(err)
s.router.ServeHTTP(w, httpReq)
s.assertSuccessResponse(w, http.StatusNoContent)
// Verify document is no longer public
doc, err := s.store.GetDocument(s.testData.AlicePublicDoc)
s.Require().NoError(err)
s.False(doc.Is_Public, "Document should no longer be public")
}
func (s *ShareHandlerSuite) TestRevokeShareLink_ClearsToken() {
// Revoke share link
w, httpReq, err := s.makeAuthRequest("DELETE", fmt.Sprintf("/api/documents/%s/share-link", s.testData.AlicePublicDoc), nil, s.testData.AliceID)
s.Require().NoError(err)
s.router.ServeHTTP(w, httpReq)
s.assertSuccessResponse(w, http.StatusNoContent)
// Verify token is cleared
token, exists, err := s.store.GetShareToken(s.T().Context(), s.testData.AlicePublicDoc)
s.Require().NoError(err)
s.False(exists, "Share token should not exist")
s.Empty(token, "Share token should be empty")
}
func (s *ShareHandlerSuite) TestRevokeShareLink_NonOwnerDenied() {
// Bob tries to revoke Alice's share link
w, httpReq, err := s.makeAuthRequest("DELETE", fmt.Sprintf("/api/documents/%s/share-link", s.testData.AlicePublicDoc), nil, s.testData.BobID)
s.Require().NoError(err)
s.router.ServeHTTP(w, httpReq)
s.assertErrorResponse(w, http.StatusForbidden, "forbidden", "")
}
func (s *ShareHandlerSuite) TestRevokeShareLink_Unauthorized() {
w, httpReq, err := s.makePublicRequest("DELETE", fmt.Sprintf("/api/documents/%s/share-link", s.testData.AlicePublicDoc), nil)
s.Require().NoError(err)
s.router.ServeHTTP(w, httpReq)
s.Equal(http.StatusUnauthorized, w.Code)
}
func (s *ShareHandlerSuite) TestRevokeShareLink_Idempotent() {
// Revoke twice - both should succeed
w1, httpReq1, err := s.makeAuthRequest("DELETE", fmt.Sprintf("/api/documents/%s/share-link", s.testData.AlicePublicDoc), nil, s.testData.AliceID)
s.Require().NoError(err)
s.router.ServeHTTP(w1, httpReq1)
s.assertSuccessResponse(w1, http.StatusNoContent)
w2, httpReq2, err := s.makeAuthRequest("DELETE", fmt.Sprintf("/api/documents/%s/share-link", s.testData.AlicePublicDoc), nil, s.testData.AliceID)
s.Require().NoError(err)
s.router.ServeHTTP(w2, httpReq2)
s.assertSuccessResponse(w2, http.StatusNoContent)
}
// ============================================================================
// Public Access Integration Tests
// ============================================================================
func (s *ShareHandlerSuite) TestPublicAccess_ValidToken() {
// This test validates that a document with a valid share token is accessible
// The actual public access logic is tested in document handler tests
doc, err := s.store.GetDocument(s.testData.AlicePublicDoc)
s.Require().NoError(err)
s.True(doc.Is_Public, "Test document should be public")
token, exists, err := s.store.GetShareToken(s.T().Context(), s.testData.AlicePublicDoc)
s.Require().NoError(err)
s.True(exists, "Test document should have share token")
s.NotEmpty(token, "Share token should not be empty")
}
func (s *ShareHandlerSuite) TestPublicAccess_InvalidToken() {
// Create a share link
body := map[string]interface{}{
"permission": "view",
}
w, httpReq, err := s.makeAuthRequest("POST", fmt.Sprintf("/api/documents/%s/share-link", s.testData.AlicePrivateDoc), body, s.testData.AliceID)
s.Require().NoError(err)
s.router.ServeHTTP(w, httpReq)
s.assertSuccessResponse(w, http.StatusOK)
var response map[string]interface{}
s.parseJSONResponse(w, &response)
token := response["token"].(string)
s.NotEmpty(token, "Should have generated a token")
}
func (s *ShareHandlerSuite) TestPublicAccess_RevokedToken() {
// Get current token
oldToken, exists, err := s.store.GetShareToken(s.T().Context(), s.testData.AlicePublicDoc)
s.Require().NoError(err)
s.True(exists, "Document should have share token initially")
s.NotEmpty(oldToken, "Old token should not be empty")
// Revoke share link
w, httpReq, err := s.makeAuthRequest("DELETE", fmt.Sprintf("/api/documents/%s/share-link", s.testData.AlicePublicDoc), nil, s.testData.AliceID)
s.Require().NoError(err)
s.router.ServeHTTP(w, httpReq)
s.assertSuccessResponse(w, http.StatusNoContent)
// Verify token is cleared
newToken, exists, err := s.store.GetShareToken(s.T().Context(), s.testData.AlicePublicDoc)
s.Require().NoError(err)
s.False(exists, "Share token should not exist after revocation")
s.Empty(newToken, "Token should be cleared after revocation")
}

View File

@@ -0,0 +1,129 @@
package handlers
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"github.com/M1ngdaXie/realtime-collab/internal/store"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/stretchr/testify/suite"
)
// BaseHandlerSuite provides common setup for all handler tests
type BaseHandlerSuite struct {
suite.Suite
store *store.PostgresStore
cleanup func()
testData *store.TestData
jwtSecret string
frontendURL string
}
// SetupSuite runs once before all tests in the suite
func (s *BaseHandlerSuite) SetupSuite() {
s.store, s.cleanup = store.SetupTestDB(s.T())
s.jwtSecret = "test-secret-key-do-not-use-in-production"
s.frontendURL = "http://localhost:5173"
gin.SetMode(gin.TestMode)
}
// SetupTest runs before each test
func (s *BaseHandlerSuite) SetupTest() {
ctx := context.Background()
err := store.TruncateAllTables(ctx, s.store)
s.Require().NoError(err, "Failed to truncate tables")
testData, err := store.SeedTestData(ctx, s.store)
s.Require().NoError(err, "Failed to seed test data")
s.testData = testData
}
// TearDownSuite runs once after all tests in the suite
func (s *BaseHandlerSuite) TearDownSuite() {
if s.cleanup != nil {
s.cleanup()
}
}
// Helper: makeAuthRequest creates an authenticated HTTP request and returns recorder + request
func (s *BaseHandlerSuite) makeAuthRequest(method, path string, body interface{}, userID uuid.UUID) (*httptest.ResponseRecorder, *http.Request, error) {
var bodyReader *bytes.Reader
if body != nil {
jsonBody, err := json.Marshal(body)
if err != nil {
return nil, nil, fmt.Errorf("failed to marshal body: %w", err)
}
bodyReader = bytes.NewReader(jsonBody)
} else {
bodyReader = bytes.NewReader([]byte{})
}
req := httptest.NewRequest(method, path, bodyReader)
req.Header.Set("Content-Type", "application/json")
// Generate JWT token
token, err := store.GenerateTestJWT(userID, s.jwtSecret)
if err != nil {
return nil, nil, fmt.Errorf("failed to generate JWT: %w", err)
}
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
w := httptest.NewRecorder()
return w, req, nil
}
// Helper: makePublicRequest creates an unauthenticated HTTP request
func (s *BaseHandlerSuite) makePublicRequest(method, path string, body interface{}) (*httptest.ResponseRecorder, *http.Request, error) {
var bodyReader *bytes.Reader
if body != nil {
jsonBody, err := json.Marshal(body)
if err != nil {
return nil, nil, fmt.Errorf("failed to marshal body: %w", err)
}
bodyReader = bytes.NewReader(jsonBody)
} else {
bodyReader = bytes.NewReader([]byte{})
}
req := httptest.NewRequest(method, path, bodyReader)
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
return w, req, nil
}
// Helper: parseErrorResponse parses an ErrorResponse from the response
func (s *BaseHandlerSuite) parseErrorResponse(w *httptest.ResponseRecorder) ErrorResponse {
var errResp ErrorResponse
err := json.Unmarshal(w.Body.Bytes(), &errResp)
s.Require().NoError(err, "Failed to parse error response")
return errResp
}
// Helper: assertErrorResponse checks that the response matches expected error
func (s *BaseHandlerSuite) assertErrorResponse(w *httptest.ResponseRecorder, statusCode int, errorType string, messageContains string) {
s.Equal(statusCode, w.Code, "Status code mismatch")
errResp := s.parseErrorResponse(w)
s.Equal(errorType, errResp.Error, "Error type mismatch")
if messageContains != "" {
s.Contains(errResp.Message, messageContains, "Error message should contain expected text")
}
}
// Helper: assertSuccessResponse checks that the response is successful
func (s *BaseHandlerSuite) assertSuccessResponse(w *httptest.ResponseRecorder, statusCode int) {
s.Equal(statusCode, w.Code, "Status code mismatch. Body: %s", w.Body.String())
}
// Helper: parseJSONResponse parses a JSON response into the target struct
func (s *BaseHandlerSuite) parseJSONResponse(w *httptest.ResponseRecorder, target interface{}) {
err := json.Unmarshal(w.Body.Bytes(), target)
s.Require().NoError(err, "Failed to parse JSON response: %s", w.Body.String())
}

View File

@@ -65,7 +65,7 @@ func (wsh *WebSocketHandler) HandleWebSocket(c *gin.Context) {
// Check for JWT token in query parameter
jwtToken := c.Query("token")
if jwtToken != "" {
// Validate JWT and get user data from token claims (no DB query!)
// Validate JWT signature and expiration - STATELESS, no DB query!
jwtSecret := os.Getenv("JWT_SECRET")
if jwtSecret == "" {
log.Println("JWT_SECRET not configured")
@@ -73,16 +73,17 @@ func (wsh *WebSocketHandler) HandleWebSocket(c *gin.Context) {
return
}
authMiddleware := auth.NewAuthMiddleware(wsh.store, jwtSecret)
uid, name, avatar, err := authMiddleware.ValidateToken(jwtToken)
if err == nil && uid != nil {
// User data comes directly from JWT claims - no DB query needed!
userID = uid
userName = name
if avatar != "" {
userAvatar = &avatar
// Direct JWT validation - fast path (~1ms)
claims, err := auth.ValidateJWT(jwtToken, jwtSecret)
if err == nil {
// Extract user data from JWT claims
uid, parseErr := uuid.Parse(claims.Subject)
if parseErr == nil {
userID = &uid
userName = claims.Name
userAvatar = claims.AvatarURL
authenticated = true
}
authenticated = true
}
}