Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions apps/api/internal/handler/project_create_member_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package handler_test

import (
"net/http"
"testing"

"github.com/Devlaner/devlane/api/internal/model"
"github.com/Devlaner/devlane/api/internal/testutil"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
)

// A regular workspace member who creates a project should become a project
// admin, so they can immediately manage the project they just made.
func TestProject_CreatorBecomesProjectAdmin(t *testing.T) {
ts := testutil.NewTestServer(t)
w := testutil.SeedWorld(t, ts.DB)

member := testutil.CreateUser(t, ts.DB)
testutil.AddWorkspaceMember(t, ts.DB, w.Workspace.ID, member.ID, testutil.RoleMember)
session := testutil.LoginAs(t, ts.DB, member)

base := "/api/workspaces/" + w.Workspace.Slug + "/projects/"
rr := ts.POST(base, map[string]any{"name": "Member Project", "identifier": "MBR"}, session)
require.Equal(t, http.StatusCreated, rr.Code, "body=%s", rr.Body.String())
projectID, _ := testutil.MustJSONMap(t, rr)["id"].(string)
require.NotEmpty(t, projectID)

// A project_members row exists for the creator with at least admin role.
var pm model.ProjectMember
require.NoError(t, ts.DB.
Where("project_id = ? AND member_id = ? AND deleted_at IS NULL", projectID, member.ID).
First(&pm).Error)
require.GreaterOrEqual(t, pm.Role, model.RoleAdmin)

// The creator can immediately save project settings (admin-only action).
rr2 := ts.PATCH(base+projectID+"/", map[string]any{"name": "Renamed"}, session)
require.Equal(t, http.StatusOK, rr2.Code, "body=%s", rr2.Body.String())
}

// Guard: creating a project must not add a stray membership for anyone else.
func TestProject_CreateAddsOnlyCreator(t *testing.T) {
ts := testutil.NewTestServer(t)
w := testutil.SeedWorld(t, ts.DB)

base := "/api/workspaces/" + w.Workspace.Slug + "/projects/"
rr := ts.POST(base, map[string]any{"name": "Solo", "identifier": "SOLO"}, w.Session)
require.Equal(t, http.StatusCreated, rr.Code, "body=%s", rr.Body.String())
projectID, _ := testutil.MustJSONMap(t, rr)["id"].(string)

var count int64
require.NoError(t, ts.DB.Model(&model.ProjectMember{}).
Where("project_id = ? AND deleted_at IS NULL", projectID).Count(&count).Error)
require.Equal(t, int64(1), count)

var pm model.ProjectMember
require.NoError(t, ts.DB.
Where("project_id = ? AND deleted_at IS NULL", projectID).First(&pm).Error)
require.NotNil(t, pm.MemberID)
require.Equal(t, w.User.ID, *pm.MemberID)
// Sanity: the created uuid parses.
require.NotEqual(t, uuid.Nil, uuid.MustParse(projectID))
}
5 changes: 4 additions & 1 deletion apps/api/internal/service/project.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,10 @@ func (s *ProjectService) Create(ctx context.Context, workspaceSlug, name, identi
Identifier: identifier,
CreatedByID: &userID,
}
if err := s.ps.Create(ctx, p); err != nil {
// Create the project and the creator's admin membership together, so the
// creator can manage the project they just made even if they're only a
// regular workspace member.
if err := s.ps.CreateWithCreatorMember(ctx, p, userID, model.RoleAdmin); err != nil {
return nil, err
}
return p, nil
Expand Down
18 changes: 18 additions & 0 deletions apps/api/internal/store/project.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,24 @@ func (s *ProjectStore) Create(ctx context.Context, p *model.Project) error {
return s.db.WithContext(ctx).Create(p).Error
}

// CreateWithCreatorMember inserts a project and a project_members row for its
// creator at the given role, in one transaction, so a project never exists
// without its creator being able to manage it.
func (s *ProjectStore) CreateWithCreatorMember(ctx context.Context, p *model.Project, creatorID uuid.UUID, role int16) error {
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Create(p).Error; err != nil {
return err
}
member := &model.ProjectMember{
ProjectID: p.ID,
WorkspaceID: p.WorkspaceID,
MemberID: &creatorID,
Role: role,
}
return tx.Create(member).Error
})
}

func (s *ProjectStore) GetByID(ctx context.Context, id uuid.UUID) (*model.Project, error) {
var p model.Project
err := s.db.WithContext(ctx).Where("id = ? AND deleted_at IS NULL", id).First(&p).Error
Expand Down
Loading