- Added Redis Streams operations to the message bus interface and implementation. - Introduced StreamCheckpoint model to track last processed stream entry per document. - Implemented UpsertStreamCheckpoint and GetStreamCheckpoint methods in the Postgres store. - Created document_update_history table for storing update payloads for recovery and replay. - Developed update persist worker to handle Redis Stream updates and persist them to Postgres. - Enhanced Docker Compose configuration for Redis with persistence. - Updated frontend API to support fetching document state with optional share token. - Added connection stability monitoring in the Yjs document hook.
575 lines
18 KiB
Go
575 lines
18 KiB
Go
package handlers
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/http"
|
|
"testing"
|
|
|
|
"github.com/M1ngdaXie/realtime-collab/internal/auth"
|
|
"github.com/M1ngdaXie/realtime-collab/internal/messagebus"
|
|
"github.com/M1ngdaXie/realtime-collab/internal/models"
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/google/uuid"
|
|
"github.com/stretchr/testify/suite"
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
// 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, messagebus.NewLocalMessageBus(), "test-server", zap.NewNop())
|
|
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")
|
|
}
|