diff --git a/backend/go.mod b/backend/go.mod index 8c1f659..e0f3e91 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -5,11 +5,13 @@ go 1.25.3 require ( github.com/gin-contrib/cors v1.7.6 github.com/gin-gonic/gin v1.11.0 + github.com/go-playground/validator/v10 v10.27.0 github.com/golang-jwt/jwt/v5 v5.3.0 github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.3 github.com/joho/godotenv v1.5.1 github.com/lib/pq v1.10.9 + github.com/stretchr/testify v1.11.1 golang.org/x/oauth2 v0.34.0 ) @@ -18,20 +20,22 @@ require ( github.com/bytedance/sonic v1.14.0 // indirect github.com/bytedance/sonic/loader v0.3.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect github.com/gabriel-vasile/mimetype v1.4.9 // indirect github.com/gin-contrib/sse v1.1.0 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect - github.com/go-playground/validator/v10 v10.27.0 // indirect github.com/goccy/go-json v0.10.5 // indirect github.com/goccy/go-yaml v1.18.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/kr/text v0.2.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect github.com/quic-go/qpack v0.5.1 // indirect github.com/quic-go/quic-go v0.54.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect @@ -46,4 +50,5 @@ require ( golang.org/x/text v0.27.0 // indirect golang.org/x/tools v0.34.0 // indirect google.golang.org/protobuf v1.36.9 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/backend/go.sum b/backend/go.sum index a6d3ed9..41d12c1 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -6,6 +6,7 @@ github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZw github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -44,6 +45,10 @@ github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnr github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= @@ -63,6 +68,8 @@ github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI= github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg= github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg= github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY= +github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8= +github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= @@ -100,6 +107,8 @@ golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw= google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/backend/internal/auth/middleware.go b/backend/internal/auth/middleware.go index 00a740c..e5d076f 100644 --- a/backend/internal/auth/middleware.go +++ b/backend/internal/auth/middleware.go @@ -1,7 +1,6 @@ package auth import ( - "context" "fmt" "net/http" "strings" @@ -158,8 +157,9 @@ func GetUserFromContext(c *gin.Context) *uuid.UUID { } // ValidateToken validates a JWT token and returns user ID, name, and avatar URL from JWT claims +// STATELESS: No database lookup - relies entirely on JWT signature and expiration func (m *AuthMiddleware) ValidateToken(tokenString string) (*uuid.UUID, string, string, error) { - // Parse and validate JWT + // Parse and validate JWT signature and expiration claims, err := ValidateJWT(tokenString, m.jwtSecret) if err != nil { return nil, "", "", fmt.Errorf("invalid token: %w", err) @@ -171,23 +171,12 @@ func (m *AuthMiddleware) ValidateToken(tokenString string) (*uuid.UUID, string, return nil, "", "", fmt.Errorf("invalid user ID in token: %w", err) } - // Get session from database by token (for revocation capability) - session, err := m.store.GetSessionByToken(context.Background(), tokenString) - if err != nil { - return nil, "", "", fmt.Errorf("session not found: %w", err) - } - - // Verify session UserID matches JWT Subject - if session.UserID != userID { - return nil, "", "", fmt.Errorf("session ID mismatch") - } - // Extract avatar URL from claims (handle nil gracefully) avatarURL := "" if claims.AvatarURL != nil { avatarURL = *claims.AvatarURL } - // Return user data from JWT claims - no DB query needed! + // Return user data from JWT claims - ZERO database queries! return &userID, claims.Name, avatarURL, nil } diff --git a/backend/internal/handlers/auth.go b/backend/internal/handlers/auth.go index a4a1243..7e4078c 100644 --- a/backend/internal/handlers/auth.go +++ b/backend/internal/handlers/auth.go @@ -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{ diff --git a/backend/internal/handlers/document.go b/backend/internal/handlers/document.go index ba52f29..23fe907 100644 --- a/backend/internal/handlers/document.go +++ b/backend/internal/handlers/document.go @@ -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 } diff --git a/backend/internal/handlers/document_test.go b/backend/internal/handlers/document_test.go new file mode 100644 index 0000000..229c2f9 --- /dev/null +++ b/backend/internal/handlers/document_test.go @@ -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") +} diff --git a/backend/internal/handlers/errors.go b/backend/internal/handlers/errors.go new file mode 100644 index 0000000..1dd67c1 --- /dev/null +++ b/backend/internal/handlers/errors.go @@ -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) +} diff --git a/backend/internal/handlers/share.go b/backend/internal/handlers/share.go index 2534923..576f0ee 100644 --- a/backend/internal/handlers/share.go +++ b/backend/internal/handlers/share.go @@ -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) } \ No newline at end of file diff --git a/backend/internal/handlers/share_test.go b/backend/internal/handlers/share_test.go new file mode 100644 index 0000000..d44b676 --- /dev/null +++ b/backend/internal/handlers/share_test.go @@ -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") +} diff --git a/backend/internal/handlers/suite_test.go b/backend/internal/handlers/suite_test.go new file mode 100644 index 0000000..383c775 --- /dev/null +++ b/backend/internal/handlers/suite_test.go @@ -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()) +} diff --git a/backend/internal/handlers/websocket.go b/backend/internal/handlers/websocket.go index 07cbf39..4865bca 100644 --- a/backend/internal/handlers/websocket.go +++ b/backend/internal/handlers/websocket.go @@ -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 } } diff --git a/backend/internal/store/postgres.go b/backend/internal/store/postgres.go index 1fc712e..0ce95bc 100644 --- a/backend/internal/store/postgres.go +++ b/backend/internal/store/postgres.go @@ -34,7 +34,7 @@ type Store interface { CleanupExpiredSessions(ctx context.Context) error // Share operations - CreateDocumentShare(ctx context.Context, documentID, userID uuid.UUID, permission string, createdBy *uuid.UUID) (*models.DocumentShare, error) + CreateDocumentShare(ctx context.Context, documentID, userID uuid.UUID, permission string, createdBy *uuid.UUID) (*models.DocumentShare, bool, error) ListDocumentShares(ctx context.Context, documentID uuid.UUID) ([]models.DocumentShareWithUser, error) DeleteDocumentShare(ctx context.Context, documentID, userID uuid.UUID) error CanViewDocument(ctx context.Context, documentID, userID uuid.UUID) (bool, error) @@ -105,7 +105,7 @@ func (s *PostgresStore) CreateDocument(name string, docType models.DocumentType) doc := &models.Document{} query := ` - SELECT id, name, type, yjs_state, created_at, updated_at + SELECT id, name, type, yjs_state, owner_id, is_public, created_at, updated_at FROM documents WHERE id = $1 ` @@ -115,6 +115,8 @@ func (s *PostgresStore) CreateDocument(name string, docType models.DocumentType) &doc.Name, &doc.Type, &doc.YjsState, + &doc.OwnerID, + &doc.Is_Public, &doc.CreatedAt, &doc.UpdatedAt, ) @@ -261,7 +263,7 @@ func (s *PostgresStore) CreateDocumentWithOwner(name string, docType models.Docu // ListUserDocuments lists documents owned by or shared with a user func (s *PostgresStore) ListUserDocuments(ctx context.Context, userID uuid.UUID) ([]models.Document, error) { query := ` - SELECT DISTINCT d.id, d.name, d.type, d.owner_id, d.created_at, d.updated_at + SELECT DISTINCT d.id, d.name, d.type, d.owner_id, d.is_public, d.created_at, d.updated_at FROM documents d LEFT JOIN document_shares ds ON d.id = ds.document_id WHERE d.owner_id = $1 OR ds.user_id = $1 @@ -277,7 +279,8 @@ func (s *PostgresStore) ListUserDocuments(ctx context.Context, userID uuid.UUID) var documents []models.Document for rows.Next() { var doc models.Document - err := rows.Scan(&doc.ID, &doc.Name, &doc.Type, &doc.OwnerID, &doc.CreatedAt, &doc.UpdatedAt) + // Note: yjs_state is NOT included in list to avoid performance issues + err := rows.Scan(&doc.ID, &doc.Name, &doc.Type, &doc.OwnerID, &doc.Is_Public, &doc.CreatedAt, &doc.UpdatedAt) if err != nil { return nil, fmt.Errorf("failed to scan document: %w", err) } diff --git a/backend/internal/store/share.go b/backend/internal/store/share.go index 9477870..780a180 100644 --- a/backend/internal/store/share.go +++ b/backend/internal/store/share.go @@ -11,8 +11,15 @@ import ( "github.com/google/uuid" ) -// CreateDocumentShare creates a new share -func (s *PostgresStore) CreateDocumentShare(ctx context.Context, documentID, userID uuid.UUID, permission string, createdBy *uuid.UUID) (*models.DocumentShare, error) { +// CreateDocumentShare creates a new share or updates existing one +// Returns the share and a boolean indicating if it was newly created (true) or updated (false) +func (s *PostgresStore) CreateDocumentShare(ctx context.Context, documentID, userID uuid.UUID, permission string, createdBy *uuid.UUID) (*models.DocumentShare, bool, error) { + // First check if share already exists + var existingID uuid.UUID + checkQuery := `SELECT id FROM document_shares WHERE document_id = $1 AND user_id = $2` + err := s.db.QueryRowContext(ctx, checkQuery, documentID, userID).Scan(&existingID) + isNewShare := err != nil // If error (not found), it's a new share + query := ` INSERT INTO document_shares (document_id, user_id, permission, created_by) VALUES ($1, $2, $3, $4) @@ -21,15 +28,15 @@ func (s *PostgresStore) CreateDocumentShare(ctx context.Context, documentID, use ` var share models.DocumentShare - err := s.db.QueryRowContext(ctx, query, documentID, userID, permission, createdBy).Scan( + err = s.db.QueryRowContext(ctx, query, documentID, userID, permission, createdBy).Scan( &share.ID, &share.DocumentID, &share.UserID, &share.Permission, &share.CreatedAt, &share.CreatedBy, ) if err != nil { - return nil, err + return nil, false, err } - return &share, nil + return &share, isNewShare, nil } // ListDocumentShares lists all shares for a document diff --git a/backend/internal/store/testutil.go b/backend/internal/store/testutil.go new file mode 100644 index 0000000..eb1cb83 --- /dev/null +++ b/backend/internal/store/testutil.go @@ -0,0 +1,220 @@ +package store + +import ( + "context" + "database/sql" + "fmt" + "os" + "path/filepath" + "testing" + "time" + + "github.com/M1ngdaXie/realtime-collab/internal/models" + "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" +) + +// TestData holds UUIDs for seeded test data +type TestData struct { + // Users + AliceID uuid.UUID + BobID uuid.UUID + CharlieID uuid.UUID + + // Documents + AlicePrivateDoc uuid.UUID // Alice's private editor document + AlicePublicDoc uuid.UUID // Alice's public kanban document with share token + BobSharedView uuid.UUID // Bob's document shared with Alice (view) + BobSharedEdit uuid.UUID // Bob's document shared with Alice (edit) + CharlieDoc uuid.UUID // Charlie's private document + + // Share token for AlicePublicDoc + PublicShareToken string +} + +// SetupTestDB creates a test database and runs all migrations +func SetupTestDB(t *testing.T) (*PostgresStore, func()) { + t.Helper() + + // Use environment variable or default + dbURL := os.Getenv("TEST_DATABASE_URL") + if dbURL == "" { + dbURL = "postgres://collab:collab123@localhost:5432/postgres?sslmode=disable" + } + + // Connect to postgres database (not test db yet) + masterDB, err := sql.Open("postgres", dbURL) + if err != nil { + t.Fatalf("Failed to connect to postgres: %v", err) + } + defer masterDB.Close() + + // Drop and recreate test database + testDBName := "collaboration_test" + _, err = masterDB.Exec(fmt.Sprintf("DROP DATABASE IF EXISTS %s", testDBName)) + if err != nil { + t.Fatalf("Failed to drop test database: %v", err) + } + + _, err = masterDB.Exec(fmt.Sprintf("CREATE DATABASE %s", testDBName)) + if err != nil { + t.Fatalf("Failed to create test database: %v", err) + } + + // Connect to test database + testDBURL := fmt.Sprintf("postgres://collab:collab123@localhost:5432/%s?sslmode=disable", testDBName) + store, err := NewPostgresStore(testDBURL) + if err != nil { + t.Fatalf("Failed to connect to test database: %v", err) + } + + // Run migrations + scriptsDir := filepath.Join("..", "..", "scripts") + migrations := []string{ + "init.sql", + "001_add_users_and_sessions.sql", + "002_add_document_shares.sql", + "003_add_public_sharing.sql", + } + + for _, migration := range migrations { + migrationPath := filepath.Join(scriptsDir, migration) + content, err := os.ReadFile(migrationPath) + if err != nil { + t.Fatalf("Failed to read migration %s: %v", migration, err) + } + + _, err = store.db.Exec(string(content)) + if err != nil { + t.Fatalf("Failed to execute migration %s: %v", migration, err) + } + } + + // Cleanup function + cleanup := func() { + store.Close() + masterDB, _ := sql.Open("postgres", dbURL) + if masterDB != nil { + masterDB.Exec(fmt.Sprintf("DROP DATABASE IF EXISTS %s", testDBName)) + masterDB.Close() + } + } + + return store, cleanup +} + +// TruncateAllTables removes all data for test isolation +func TruncateAllTables(ctx context.Context, store *PostgresStore) error { + tables := []string{ + "document_updates", + "document_shares", + "sessions", + "documents", + "users", + } + + for _, table := range tables { + _, err := store.db.ExecContext(ctx, fmt.Sprintf("TRUNCATE TABLE %s CASCADE", table)) + if err != nil { + return fmt.Errorf("failed to truncate %s: %w", table, err) + } + } + + return nil +} + +// SeedTestData creates common test fixtures +func SeedTestData(ctx context.Context, store *PostgresStore) (*TestData, error) { + data := &TestData{} + + // Create 3 test users + alice, err := store.UpsertUser(ctx, "google", "alice123", "alice@test.com", "Alice Wonderland", nil) + if err != nil { + return nil, fmt.Errorf("failed to create alice: %w", err) + } + data.AliceID = alice.ID + + bob, err := store.UpsertUser(ctx, "github", "bob456", "bob@test.com", "Bob Builder", nil) + if err != nil { + return nil, fmt.Errorf("failed to create bob: %w", err) + } + data.BobID = bob.ID + + charlie, err := store.UpsertUser(ctx, "google", "charlie789", "charlie@test.com", "Charlie Chaplin", nil) + if err != nil { + return nil, fmt.Errorf("failed to create charlie: %w", err) + } + data.CharlieID = charlie.ID + + // Create documents + // 1. Alice's private editor document + doc1, err := store.CreateDocumentWithOwner("Alice Private Doc", models.DocumentTypeEditor, &alice.ID) + if err != nil { + return nil, fmt.Errorf("failed to create alice private doc: %w", err) + } + data.AlicePrivateDoc = doc1.ID + + // 2. Alice's public kanban document + doc2, err := store.CreateDocumentWithOwner("Alice Public Kanban", models.DocumentTypeKanban, &alice.ID) + if err != nil { + return nil, fmt.Errorf("failed to create alice public doc: %w", err) + } + data.AlicePublicDoc = doc2.ID + + // Generate share token for Alice's public doc + token, err := store.GenerateShareToken(ctx, doc2.ID, "view") + if err != nil { + return nil, fmt.Errorf("failed to generate share token: %w", err) + } + data.PublicShareToken = token + + // 3. Bob's document shared with Alice (view permission) + doc3, err := store.CreateDocumentWithOwner("Bob Shared View", models.DocumentTypeEditor, &bob.ID) + if err != nil { + return nil, fmt.Errorf("failed to create bob shared view doc: %w", err) + } + data.BobSharedView = doc3.ID + _, _, err = store.CreateDocumentShare(ctx, doc3.ID, alice.ID, "view", &bob.ID) + if err != nil { + return nil, fmt.Errorf("failed to create view share: %w", err) + } + + // 4. Bob's document shared with Alice (edit permission) + doc4, err := store.CreateDocumentWithOwner("Bob Shared Edit", models.DocumentTypeEditor, &bob.ID) + if err != nil { + return nil, fmt.Errorf("failed to create bob shared edit doc: %w", err) + } + data.BobSharedEdit = doc4.ID + _, _, err = store.CreateDocumentShare(ctx, doc4.ID, alice.ID, "edit", &bob.ID) + if err != nil { + return nil, fmt.Errorf("failed to create edit share: %w", err) + } + + // 5. Charlie's private document + doc5, err := store.CreateDocumentWithOwner("Charlie Private", models.DocumentTypeEditor, &charlie.ID) + if err != nil { + return nil, fmt.Errorf("failed to create charlie doc: %w", err) + } + data.CharlieDoc = doc5.ID + + return data, nil +} + +// GenerateTestJWT creates a valid JWT for testing +func GenerateTestJWT(userID uuid.UUID, secret string) (string, error) { + expiresIn := 1 * time.Hour + + claims := jwt.MapClaims{ + "sub": userID.String(), // Subject claim for user ID (matches auth.ValidateJWT) + "exp": time.Now().Add(expiresIn).Unix(), + "iat": time.Now().Unix(), + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + tokenString, err := token.SignedString([]byte(secret)) + if err != nil { + return "", fmt.Errorf("failed to sign token: %w", err) + } + + return tokenString, nil +} diff --git a/backend/scripts/003_add_public_sharing.sql b/backend/scripts/003_add_public_sharing.sql new file mode 100644 index 0000000..4d24114 --- /dev/null +++ b/backend/scripts/003_add_public_sharing.sql @@ -0,0 +1,20 @@ +-- Migration: Add public sharing support via share tokens +-- Dependencies: Run after 002_add_document_shares.sql +-- Purpose: Add share_token and is_public columns used by share link feature + +-- Add columns for public sharing +ALTER TABLE documents ADD COLUMN IF NOT EXISTS share_token VARCHAR(255); +ALTER TABLE documents ADD COLUMN IF NOT EXISTS is_public BOOLEAN DEFAULT false NOT NULL; + +-- Create indexes for performance +CREATE INDEX IF NOT EXISTS idx_documents_share_token ON documents(share_token) WHERE share_token IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_documents_is_public ON documents(is_public) WHERE is_public = true; + +-- Constraint: public documents must have a token +-- This ensures data integrity - a document can't be public without a share token +ALTER TABLE documents ADD CONSTRAINT check_public_has_token + CHECK (is_public = false OR (is_public = true AND share_token IS NOT NULL)); + +-- Documentation +COMMENT ON COLUMN documents.share_token IS 'Public share token for link-based access (base64-encoded random string, 32 bytes)'; +COMMENT ON COLUMN documents.is_public IS 'Whether document is publicly accessible via share link';