mirror of
https://github.com/Dvorinka/excalidraw-full.git
synced 2026-06-05 06:32:56 +00:00
feat: full project sync - CI fixes, frontend, workspace API, and all changes
This commit is contained in:
@@ -0,0 +1,370 @@
|
||||
package workspace
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestPermissionMatrix tests the full permission matrix for drawings.
|
||||
// It creates drawings with different visibilities and verifies access
|
||||
// from users with different roles and direct permission grants.
|
||||
func TestPermissionMatrix(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
visibility string
|
||||
grantPerm string // direct grant permission to bob
|
||||
bobRole string // bob's role in alice's team
|
||||
expectGet int // expected HTTP status for GET /drawings/:id
|
||||
expectUpdate int // expected HTTP status for PATCH /drawings/:id
|
||||
expectListTeam int // expected HTTP status for GET /drawings (team-scoped list)
|
||||
}{
|
||||
// public drawings - anyone in the team can view, edit requires role
|
||||
{
|
||||
name: "public_drawing_team_viewer_can_view_not_edit",
|
||||
visibility: "public", grantPerm: "", bobRole: "viewer",
|
||||
expectGet: http.StatusOK, expectUpdate: http.StatusForbidden,
|
||||
expectListTeam: http.StatusOK,
|
||||
},
|
||||
{
|
||||
name: "public_drawing_team_editor_can_view_and_edit",
|
||||
visibility: "public", grantPerm: "", bobRole: "editor",
|
||||
expectGet: http.StatusOK, expectUpdate: http.StatusOK,
|
||||
expectListTeam: http.StatusOK,
|
||||
},
|
||||
// restricted drawings - no team access without explicit grant
|
||||
{
|
||||
name: "restricted_no_grant_team_member_cannot_access",
|
||||
visibility: "restricted", grantPerm: "", bobRole: "editor",
|
||||
expectGet: http.StatusForbidden, expectUpdate: http.StatusForbidden,
|
||||
expectListTeam: http.StatusOK, // team list still shows it if team-scoped
|
||||
},
|
||||
{
|
||||
name: "restricted_with_view_grant_team_viewer_can_view_not_edit",
|
||||
visibility: "restricted", grantPerm: "view", bobRole: "viewer",
|
||||
expectGet: http.StatusOK, expectUpdate: http.StatusForbidden,
|
||||
expectListTeam: http.StatusOK,
|
||||
},
|
||||
{
|
||||
name: "restricted_with_edit_grant_team_viewer_can_view_and_edit",
|
||||
visibility: "restricted", grantPerm: "edit", bobRole: "viewer",
|
||||
expectGet: http.StatusOK, expectUpdate: http.StatusOK,
|
||||
expectListTeam: http.StatusOK,
|
||||
},
|
||||
// private drawings - only owner and explicit grant holders
|
||||
{
|
||||
name: "private_no_grant_team_member_cannot_access",
|
||||
visibility: "private", grantPerm: "", bobRole: "admin",
|
||||
expectGet: http.StatusForbidden, expectUpdate: http.StatusForbidden,
|
||||
expectListTeam: http.StatusOK, // team list may still include private owned by owner
|
||||
},
|
||||
{
|
||||
name: "private_with_view_grant_allows_view_not_edit",
|
||||
visibility: "private", grantPerm: "view", bobRole: "viewer",
|
||||
expectGet: http.StatusOK, expectUpdate: http.StatusForbidden,
|
||||
expectListTeam: http.StatusOK,
|
||||
},
|
||||
{
|
||||
name: "private_with_edit_grant_allows_view_and_edit",
|
||||
visibility: "private", grantPerm: "edit", bobRole: "viewer",
|
||||
expectGet: http.StatusOK, expectUpdate: http.StatusOK,
|
||||
expectListTeam: http.StatusOK,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
api, cleanup := newTestAPI(t)
|
||||
defer cleanup()
|
||||
|
||||
aliceCookie, _, aliceTeam := signup(t, api, "alice@example.com")
|
||||
bobCookie, _, _ := signup(t, api, "bob@example.com")
|
||||
|
||||
// Invite bob to alice's team with specified role
|
||||
if tc.bobRole != "" {
|
||||
inviteRR := doJSON(t, api, http.MethodPost, "/teams/"+aliceTeam.ID+"/invites", map[string]any{
|
||||
"email": "bob@example.com",
|
||||
"role": tc.bobRole,
|
||||
}, aliceCookie)
|
||||
if inviteRR.Code != http.StatusCreated {
|
||||
t.Fatalf("invite status = %d body = %s", inviteRR.Code, inviteRR.Body.String())
|
||||
}
|
||||
// Accept the invite
|
||||
var invitePayload struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
if err := json.Unmarshal(inviteRR.Body.Bytes(), &invitePayload); err != nil {
|
||||
t.Fatalf("invite decode error = %v", err)
|
||||
}
|
||||
acceptRR := doJSON(t, api, http.MethodPost, "/invites/accept", map[string]string{
|
||||
"token": invitePayload.Token,
|
||||
}, bobCookie)
|
||||
if acceptRR.Code != http.StatusOK {
|
||||
t.Fatalf("accept status = %d body = %s", acceptRR.Code, acceptRR.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// Alice creates a drawing with specified visibility
|
||||
createRR := doJSON(t, api, http.MethodPost, "/drawings", map[string]any{
|
||||
"team_id": aliceTeam.ID,
|
||||
"title": tc.name + " drawing",
|
||||
"visibility": tc.visibility,
|
||||
}, aliceCookie)
|
||||
if createRR.Code != http.StatusCreated {
|
||||
t.Fatalf("create drawing status = %d body = %s", createRR.Code, createRR.Body.String())
|
||||
}
|
||||
var drawing Drawing
|
||||
if err := json.Unmarshal(createRR.Body.Bytes(), &drawing); err != nil {
|
||||
t.Fatalf("drawing decode error = %v", err)
|
||||
}
|
||||
|
||||
// Optionally grant bob a direct permission
|
||||
if tc.grantPerm != "" {
|
||||
grantRR := doJSON(t, api, http.MethodPost, "/drawings/"+drawing.ID+"/permissions", map[string]any{
|
||||
"subject_type": "user",
|
||||
"email": "bob@example.com",
|
||||
"permission": tc.grantPerm,
|
||||
}, aliceCookie)
|
||||
if grantRR.Code != http.StatusCreated {
|
||||
t.Fatalf("grant status = %d body = %s", grantRR.Code, grantRR.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// Bob attempts to GET the drawing
|
||||
getRR := doJSON(t, api, http.MethodGet, "/drawings/"+drawing.ID, nil, bobCookie)
|
||||
if getRR.Code != tc.expectGet {
|
||||
t.Errorf("GET status = %d, want %d; body = %s", getRR.Code, tc.expectGet, getRR.Body.String())
|
||||
}
|
||||
|
||||
// Bob attempts to PATCH (update) the drawing
|
||||
updateRR := doJSON(t, api, http.MethodPatch, "/drawings/"+drawing.ID, map[string]any{
|
||||
"title": tc.name + " updated",
|
||||
}, bobCookie)
|
||||
if updateRR.Code != tc.expectUpdate {
|
||||
t.Errorf("PATCH status = %d, want %d; body = %s", updateRR.Code, tc.expectUpdate, updateRR.Body.String())
|
||||
}
|
||||
|
||||
// Bob attempts to list team drawings
|
||||
listRR := doJSON(t, api, http.MethodGet, "/drawings?team_id="+aliceTeam.ID, nil, bobCookie)
|
||||
if listRR.Code != tc.expectListTeam {
|
||||
t.Errorf("LIST status = %d, want %d; body = %s", listRR.Code, tc.expectListTeam, listRR.Body.String())
|
||||
}
|
||||
|
||||
// Verify alice (owner) can still access everything
|
||||
ownerGet := doJSON(t, api, http.MethodGet, "/drawings/"+drawing.ID, nil, aliceCookie)
|
||||
if ownerGet.Code != http.StatusOK {
|
||||
t.Errorf("owner GET status = %d, want %d", ownerGet.Code, http.StatusOK)
|
||||
}
|
||||
ownerUpdate := doJSON(t, api, http.MethodPatch, "/drawings/"+drawing.ID, map[string]any{
|
||||
"title": tc.name + " owner updated",
|
||||
}, aliceCookie)
|
||||
if ownerUpdate.Code != http.StatusOK {
|
||||
t.Errorf("owner PATCH status = %d, want %d", ownerUpdate.Code, http.StatusOK)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminCanManageTeam verifies that team admins can manage team settings,
|
||||
// members, and resources while non-admins cannot.
|
||||
func TestAdminCanManageTeam(t *testing.T) {
|
||||
api, cleanup := newTestAPI(t)
|
||||
defer cleanup()
|
||||
|
||||
aliceCookie, _, aliceTeam := signup(t, api, "alice@example.com")
|
||||
bobCookie, _, _ := signup(t, api, "bob@example.com")
|
||||
charlieCookie, _, _ := signup(t, api, "charlie@example.com")
|
||||
|
||||
// Invite bob as admin, charlie as viewer
|
||||
for _, tc := range []struct{ email, role string }{
|
||||
{"bob@example.com", "admin"},
|
||||
{"charlie@example.com", "viewer"},
|
||||
} {
|
||||
inviteRR := doJSON(t, api, http.MethodPost, "/teams/"+aliceTeam.ID+"/invites", map[string]any{
|
||||
"email": tc.email,
|
||||
"role": tc.role,
|
||||
}, aliceCookie)
|
||||
if inviteRR.Code != http.StatusCreated {
|
||||
t.Fatalf("invite %s status = %d body = %s", tc.email, inviteRR.Code, inviteRR.Body.String())
|
||||
}
|
||||
var invitePayload struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
if err := json.Unmarshal(inviteRR.Body.Bytes(), &invitePayload); err != nil {
|
||||
t.Fatalf("invite decode error = %v", err)
|
||||
}
|
||||
var cookie *http.Cookie
|
||||
if tc.email == "bob@example.com" {
|
||||
cookie = bobCookie
|
||||
} else {
|
||||
cookie = charlieCookie
|
||||
}
|
||||
acceptRR := doJSON(t, api, http.MethodPost, "/invites/accept", map[string]string{
|
||||
"token": invitePayload.Token,
|
||||
}, cookie)
|
||||
if acceptRR.Code != http.StatusOK {
|
||||
t.Fatalf("accept %s status = %d body = %s", tc.email, acceptRR.Code, acceptRR.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// Bob (admin) can update team name
|
||||
updateTeam := doJSON(t, api, http.MethodPatch, "/teams/"+aliceTeam.ID, map[string]any{
|
||||
"name": "Updated by admin",
|
||||
}, bobCookie)
|
||||
if updateTeam.Code != http.StatusOK {
|
||||
t.Errorf("admin update team status = %d, want %d; body = %s", updateTeam.Code, http.StatusOK, updateTeam.Body.String())
|
||||
}
|
||||
|
||||
// Charlie (viewer) cannot update team name
|
||||
charlieUpdate := doJSON(t, api, http.MethodPatch, "/teams/"+aliceTeam.ID, map[string]any{
|
||||
"name": "Updated by viewer",
|
||||
}, charlieCookie)
|
||||
if charlieUpdate.Code != http.StatusForbidden {
|
||||
t.Errorf("viewer update team status = %d, want %d; body = %s", charlieUpdate.Code, http.StatusForbidden, charlieUpdate.Body.String())
|
||||
}
|
||||
|
||||
// Bob (admin) can manage members
|
||||
membersRR := doJSON(t, api, http.MethodGet, "/teams/"+aliceTeam.ID+"/members", nil, bobCookie)
|
||||
if membersRR.Code != http.StatusOK {
|
||||
t.Errorf("admin list members status = %d, want %d", membersRR.Code, http.StatusOK)
|
||||
}
|
||||
|
||||
// Charlie (viewer) can view members
|
||||
charlieMembers := doJSON(t, api, http.MethodGet, "/teams/"+aliceTeam.ID+"/members", nil, charlieCookie)
|
||||
if charlieMembers.Code != http.StatusOK {
|
||||
t.Errorf("viewer list members status = %d, want %d", charlieMembers.Code, http.StatusOK)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNonMemberCannotAccessPrivateTeam verifies that users not in a team
|
||||
// cannot access any team resources regardless of drawing visibility.
|
||||
func TestNonMemberCannotAccessPrivateTeam(t *testing.T) {
|
||||
api, cleanup := newTestAPI(t)
|
||||
defer cleanup()
|
||||
|
||||
aliceCookie, _, aliceTeam := signup(t, api, "alice@example.com")
|
||||
bobCookie, _, _ := signup(t, api, "bob@example.com")
|
||||
|
||||
// Alice creates a public drawing in her team
|
||||
createRR := doJSON(t, api, http.MethodPost, "/drawings", map[string]any{
|
||||
"team_id": aliceTeam.ID,
|
||||
"title": "Public team drawing",
|
||||
"visibility": "public",
|
||||
}, aliceCookie)
|
||||
if createRR.Code != http.StatusCreated {
|
||||
t.Fatalf("create drawing status = %d body = %s", createRR.Code, createRR.Body.String())
|
||||
}
|
||||
var drawing Drawing
|
||||
if err := json.Unmarshal(createRR.Body.Bytes(), &drawing); err != nil {
|
||||
t.Fatalf("drawing decode error = %v", err)
|
||||
}
|
||||
|
||||
// Bob (not in team) cannot access the drawing even though it's public
|
||||
// because "public" in this context means public within the team
|
||||
getRR := doJSON(t, api, http.MethodGet, "/drawings/"+drawing.ID, nil, bobCookie)
|
||||
if getRR.Code != http.StatusForbidden {
|
||||
t.Errorf("non-member GET status = %d, want %d; body = %s", getRR.Code, http.StatusForbidden, getRR.Body.String())
|
||||
}
|
||||
|
||||
// Bob cannot list team drawings
|
||||
listRR := doJSON(t, api, http.MethodGet, "/drawings?team_id="+aliceTeam.ID, nil, bobCookie)
|
||||
if listRR.Code != http.StatusForbidden && listRR.Code != http.StatusOK {
|
||||
// Depending on implementation, non-members might get empty list or forbidden
|
||||
t.Logf("non-member LIST status = %d", listRR.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPermissionInheritance verifies that permissions flow correctly
|
||||
// through the resource hierarchy (team -> project -> folder -> drawing).
|
||||
func TestPermissionInheritance(t *testing.T) {
|
||||
api, cleanup := newTestAPI(t)
|
||||
defer cleanup()
|
||||
|
||||
aliceCookie, _, aliceTeam := signup(t, api, "alice@example.com")
|
||||
bobCookie, _, _ := signup(t, api, "bob@example.com")
|
||||
|
||||
// Invite bob as editor
|
||||
inviteRR := doJSON(t, api, http.MethodPost, "/teams/"+aliceTeam.ID+"/invites", map[string]any{
|
||||
"email": "bob@example.com",
|
||||
"role": "editor",
|
||||
}, aliceCookie)
|
||||
if inviteRR.Code != http.StatusCreated {
|
||||
t.Fatalf("invite status = %d body = %s", inviteRR.Code, inviteRR.Body.String())
|
||||
}
|
||||
var invitePayload struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
if err := json.Unmarshal(inviteRR.Body.Bytes(), &invitePayload); err != nil {
|
||||
t.Fatalf("invite decode error = %v", err)
|
||||
}
|
||||
acceptRR := doJSON(t, api, http.MethodPost, "/invites/accept", map[string]string{
|
||||
"token": invitePayload.Token,
|
||||
}, bobCookie)
|
||||
if acceptRR.Code != http.StatusOK {
|
||||
t.Fatalf("accept status = %d body = %s", acceptRR.Code, acceptRR.Body.String())
|
||||
}
|
||||
|
||||
// Alice creates a project
|
||||
projectRR := doJSON(t, api, http.MethodPost, "/projects", map[string]any{
|
||||
"team_id": aliceTeam.ID,
|
||||
"name": "Test Project",
|
||||
"description": "A test project",
|
||||
}, aliceCookie)
|
||||
if projectRR.Code != http.StatusCreated {
|
||||
t.Fatalf("create project status = %d body = %s", projectRR.Code, projectRR.Body.String())
|
||||
}
|
||||
var project struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if err := json.Unmarshal(projectRR.Body.Bytes(), &project); err != nil {
|
||||
t.Fatalf("project decode error = %v", err)
|
||||
}
|
||||
|
||||
// Alice creates a folder in the project
|
||||
folderRR := doJSON(t, api, http.MethodPost, "/folders", map[string]any{
|
||||
"team_id": aliceTeam.ID,
|
||||
"project_id": project.ID,
|
||||
"name": "Test Folder",
|
||||
}, aliceCookie)
|
||||
if folderRR.Code != http.StatusCreated {
|
||||
t.Fatalf("create folder status = %d body = %s", folderRR.Code, folderRR.Body.String())
|
||||
}
|
||||
var folder struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if err := json.Unmarshal(folderRR.Body.Bytes(), &folder); err != nil {
|
||||
t.Fatalf("folder decode error = %v", err)
|
||||
}
|
||||
|
||||
// Alice creates a drawing in the folder
|
||||
drawingRR := doJSON(t, api, http.MethodPost, "/drawings", map[string]any{
|
||||
"team_id": aliceTeam.ID,
|
||||
"project_id": project.ID,
|
||||
"folder_id": folder.ID,
|
||||
"title": "Nested Drawing",
|
||||
"visibility": "public",
|
||||
}, aliceCookie)
|
||||
if drawingRR.Code != http.StatusCreated {
|
||||
t.Fatalf("create drawing status = %d body = %s", drawingRR.Code, drawingRR.Body.String())
|
||||
}
|
||||
var drawing Drawing
|
||||
if err := json.Unmarshal(drawingRR.Body.Bytes(), &drawing); err != nil {
|
||||
t.Fatalf("drawing decode error = %v", err)
|
||||
}
|
||||
|
||||
// Bob (team editor) should be able to access the nested drawing through team membership
|
||||
getRR := doJSON(t, api, http.MethodGet, "/drawings/"+drawing.ID, nil, bobCookie)
|
||||
if getRR.Code != http.StatusOK {
|
||||
t.Errorf("team editor GET nested drawing status = %d, want %d; body = %s",
|
||||
getRR.Code, http.StatusOK, getRR.Body.String())
|
||||
}
|
||||
|
||||
// Bob should be able to update the drawing
|
||||
updateRR := doJSON(t, api, http.MethodPatch, "/drawings/"+drawing.ID, map[string]any{
|
||||
"title": "Updated by team editor",
|
||||
}, bobCookie)
|
||||
if updateRR.Code != http.StatusOK {
|
||||
t.Errorf("team editor PATCH nested drawing status = %d, want %d; body = %s",
|
||||
updateRR.Code, http.StatusOK, updateRR.Body.String())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user