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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
572
backend/internal/handlers/document_test.go
Normal file
572
backend/internal/handlers/document_test.go
Normal 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")
|
||||
}
|
||||
95
backend/internal/handlers/errors.go
Normal file
95
backend/internal/handlers/errors.go
Normal 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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
672
backend/internal/handlers/share_test.go
Normal file
672
backend/internal/handlers/share_test.go
Normal 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")
|
||||
}
|
||||
129
backend/internal/handlers/suite_test.go
Normal file
129
backend/internal/handlers/suite_test.go
Normal 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())
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
220
backend/internal/store/testutil.go
Normal file
220
backend/internal/store/testutil.go
Normal file
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user