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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -290,3 +290,4 @@ spans*.json
/*user_emails.json
.venv
copilot-*.md
/e2e-tests/
223 changes: 223 additions & 0 deletions cla-backend-go/v2/company/cla_groups_lookup_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
// Copyright The Linux Foundation and each contributor to CommunityBridge.
// SPDX-License-Identifier: MIT

package company

import (
"context"
"errors"
"fmt"
"io"
"net/http"
"strings"
"sync"
"testing"
"time"

"github.com/aws/aws-sdk-go/aws"
"github.com/golang/mock/gomock"
mock_company_repo "github.com/linuxfoundation/easycla/cla-backend-go/company/mocks"
v1Models "github.com/linuxfoundation/easycla/cla-backend-go/gen/v1/models"
v1SignatureParams "github.com/linuxfoundation/easycla/cla-backend-go/gen/v1/restapi/operations/signatures"
mock_project_repo "github.com/linuxfoundation/easycla/cla-backend-go/project/mocks"
"github.com/linuxfoundation/easycla/cla-backend-go/projects_cla_groups"
mock_pcg_repo "github.com/linuxfoundation/easycla/cla-backend-go/projects_cla_groups/mocks"
"github.com/linuxfoundation/easycla/cla-backend-go/signatures"
mock_signature_repo "github.com/linuxfoundation/easycla/cla-backend-go/signatures/mocks"
"github.com/linuxfoundation/easycla/cla-backend-go/token"
mock_user_repo "github.com/linuxfoundation/easycla/cla-backend-go/users/mocks"
v2ProjectService "github.com/linuxfoundation/easycla/cla-backend-go/v2/project-service"
v2ProjectServiceClient "github.com/linuxfoundation/easycla/cla-backend-go/v2/project-service/client/project"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

const (
claGroupsPlatformHost = "platform.company.invalid"
claGroupsAuthHost = "auth.company.invalid"
claGroupsProjectsPath = "/project-service/v1/projects/"
)

type claGroupsTokenBody struct {
io.Reader
once sync.Once
ready chan struct{}
}

func (b *claGroupsTokenBody) Close() error {
b.once.Do(func() { close(b.ready) })
return nil
}

type claGroupsHTTP struct {
t *testing.T
tokenReady chan struct{}
projects map[string]string
}

func (h *claGroupsHTTP) RoundTrip(r *http.Request) (*http.Response, error) {
response := &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"application/json"}},
Request: r,
}
switch {
case r.URL.Host == claGroupsAuthHost && r.URL.Path == "/oauth/token":
response.Body = &claGroupsTokenBody{
Reader: strings.NewReader(`{"access_token":"unit-test-token","token_type":"Bearer","expires_in":3600}`),
ready: h.tokenReady,
}
return response, nil
case r.URL.Host == claGroupsPlatformHost && strings.HasPrefix(r.URL.Path, claGroupsProjectsPath):
body, ok := h.projects[strings.TrimPrefix(r.URL.Path, claGroupsProjectsPath)]
if !ok {
response.StatusCode = http.StatusNotFound
body = `{}`
}
response.Body = io.NopCloser(strings.NewReader(body))
return response, nil
default:
h.t.Errorf("unexpected HTTP request (no network allowed): %s %s", r.Method, r.URL)
return nil, fmt.Errorf("unexpected HTTP request: %s %s", r.Method, r.URL)
}
}

func setupClaGroupsHTTP(t *testing.T, projects map[string]string) {
t.Helper()
transport := &claGroupsHTTP{t: t, tokenReady: make(chan struct{}), projects: projects}
oldTransport, oldClient := http.DefaultTransport, http.DefaultClient
http.DefaultTransport = transport
http.DefaultClient = &http.Client{Transport: transport}
t.Cleanup(func() {
http.DefaultTransport, http.DefaultClient = oldTransport, oldClient
})
token.Init("test-client", "test-secret", "https://"+claGroupsAuthHost+"/oauth/token", "test-audience")
select {
case <-transport.tokenReady:
case <-time.After(5 * time.Second):
t.Fatal("mock token initialization did not complete")
}
v2ProjectService.InitClient("https://" + claGroupsPlatformHost)
}

func newClaGroupsService(t *testing.T, ctrl *gomock.Controller, sigRepo *mock_signature_repo.MockSignatureRepository, projectRepo *mock_project_repo.MockProjectRepository, pcgRepo *mock_pcg_repo.MockRepository) *service {
t.Helper()
svc, ok := NewService(nil, sigRepo, projectRepo, mock_user_repo.NewMockUserRepository(ctrl), mock_company_repo.NewMockIRepository(ctrl), pcgRepo, nil).(*service)
require.True(t, ok)
return svc
}

func TestGetCLAGroupsUnderProjectOrFoundationMappingLookup(t *testing.T) {
setupClaGroupsHTTP(t, map[string]string{
"proj-none": `{"ID":"proj-none","Name":"No CLA Group"}`,
"proj-wrapped": `{"ID":"proj-wrapped","Name":"Wrapped"}`,
"proj-fail": `{"ID":"proj-fail","Name":"Lookup Failure"}`,
"proj-ok": `{"ID":"proj-ok","Name":"Project OK","ProjectType":"Project"}`,
})
lookupErr := errors.New("dynamodb failure")
mapping := &projects_cla_groups.ProjectClaGroup{ClaGroupID: "cg-1", ProjectSFID: "proj-ok", FoundationSFID: "found-1"}

for _, tc := range []struct {
name string
projectSFID string
mappingErr error
wantErr error
wantProjectGone bool
wantGroup bool
}{
{name: "project not associated with a CLA group", projectSFID: "proj-none", mappingErr: projects_cla_groups.ErrProjectNotAssociatedWithClaGroup},
{name: "wrapped not associated", projectSFID: "proj-wrapped", mappingErr: fmt.Errorf("mapping: %w", projects_cla_groups.ErrProjectNotAssociatedWithClaGroup)},
{name: "mapping lookup failure is returned", projectSFID: "proj-fail", mappingErr: lookupErr, wantErr: lookupErr},
{name: "project missing from the project service", projectSFID: "proj-missing", wantProjectGone: true},
{name: "success", projectSFID: "proj-ok", wantGroup: true},
} {
t.Run(tc.name, func(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()

pcgRepo := mock_pcg_repo.NewMockRepository(ctrl)
projectRepo := mock_project_repo.NewMockProjectRepository(ctrl)
switch {
case tc.wantProjectGone:
case tc.wantGroup:
pcgRepo.EXPECT().GetClaGroupIDForProject(gomock.Any(), tc.projectSFID).Return(mapping, nil)
pcgRepo.EXPECT().GetProjectsIdsForClaGroup(gomock.Any(), "cg-1").Return([]*projects_cla_groups.ProjectClaGroup{mapping}, nil)
projectRepo.EXPECT().GetCLAGroupByID(gomock.Any(), "cg-1", DontLoadRepoDetails).Return(&v1Models.ClaGroup{
ProjectID: "cg-1", ProjectName: "CLA Group One", ProjectICLAEnabled: true,
}, nil)
default:
pcgRepo.EXPECT().GetClaGroupIDForProject(gomock.Any(), tc.projectSFID).Return(nil, tc.mappingErr)
}
svc := newClaGroupsService(t, ctrl, mock_signature_repo.NewMockSignatureRepository(ctrl), projectRepo, pcgRepo)

result, err := svc.getCLAGroupsUnderProjectOrFoundation(context.Background(), tc.projectSFID)

switch {
case tc.wantErr != nil:
require.ErrorIs(t, err, tc.wantErr)
assert.Nil(t, result)
case tc.wantProjectGone:
var projectNotFound *v2ProjectServiceClient.GetProjectNotFound
require.ErrorAs(t, err, &projectNotFound)
assert.Nil(t, result)
case tc.wantGroup:
require.NoError(t, err)
require.Len(t, result, 1)
got := result["cg-1"]
require.NotNil(t, got)
assert.Equal(t, "cg-1", got.ClaGroupID)
assert.Equal(t, "CLA Group One", got.ClaGroupName)
assert.Equal(t, "proj-ok", got.ProjectSFID)
assert.Equal(t, "Project OK", got.ProjectName)
assert.Equal(t, "Project", got.ProjectType)
assert.Equal(t, "found-1", got.FoundationSFID)
assert.Equal(t, []string{"proj-ok"}, got.SubProjectIDs)
assert.True(t, got.IclaEnabled)
assert.False(t, got.CclaEnabled)
default:
require.NoError(t, err)
assert.Empty(t, result)
}
})
}
}

func TestGetCompanyProjectActiveCLAsMappingLookup(t *testing.T) {
setupClaGroupsHTTP(t, map[string]string{
"proj-active-none": `{"ID":"proj-active-none","Name":"No CLA Group"}`,
"proj-active-fail": `{"ID":"proj-active-fail","Name":"Lookup Failure"}`,
})
lookupErr := errors.New("dynamodb failure")
cclaParams := v1SignatureParams.GetCompanySignaturesParams{CompanyID: "company-1", SignatureType: aws.String("ccla")}

t.Run("project not associated with a CLA group returns an empty list", func(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()

pcgRepo := mock_pcg_repo.NewMockRepository(ctrl)
pcgRepo.EXPECT().GetClaGroupIDForProject(gomock.Any(), "proj-active-none").Return(nil, projects_cla_groups.ErrProjectNotAssociatedWithClaGroup)
sigRepo := mock_signature_repo.NewMockSignatureRepository(ctrl)
sigRepo.EXPECT().GetCompanySignatures(gomock.Any(), cclaParams, HugePageSize, signatures.DontLoadACLDetails).Return(&v1Models.Signatures{}, nil)
svc := newClaGroupsService(t, ctrl, sigRepo, mock_project_repo.NewMockProjectRepository(ctrl), pcgRepo)

out, err := svc.GetCompanyProjectActiveCLAs(context.Background(), "company-1", "proj-active-none")

require.NoError(t, err)
require.NotNil(t, out)
assert.Empty(t, out.List)
})

t.Run("mapping lookup failure is returned before signatures are read", func(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()

pcgRepo := mock_pcg_repo.NewMockRepository(ctrl)
pcgRepo.EXPECT().GetClaGroupIDForProject(gomock.Any(), "proj-active-fail").Return(nil, lookupErr)
svc := newClaGroupsService(t, ctrl, mock_signature_repo.NewMockSignatureRepository(ctrl), mock_project_repo.NewMockProjectRepository(ctrl), pcgRepo)

out, err := svc.GetCompanyProjectActiveCLAs(context.Background(), "company-1", "proj-active-fail")

require.ErrorIs(t, err, lookupErr)
assert.Nil(t, out)
})
}
5 changes: 4 additions & 1 deletion cla-backend-go/v2/company/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -1221,7 +1221,10 @@ func (s *service) getCLAGroupsUnderProjectOrFoundation(ctx context.Context, proj
projectMapping, perr := s.projectClaGroupsRepo.GetClaGroupIDForProject(ctx, projectSFID)
if perr != nil {
log.WithFields(f).WithError(perr).Warnf("unable to get CLA group IDs for project SFID: %s", projectSFID)
return nil, err
if errors.Is(perr, projects_cla_groups.ErrProjectNotAssociatedWithClaGroup) {
return result, nil
}
return nil, perr
}
// get all projects for that cla group
allProjectMapping, err = s.projectClaGroupsRepo.GetProjectsIdsForClaGroup(ctx, projectMapping.ClaGroupID)
Expand Down
2 changes: 1 addition & 1 deletion cla-backend-go/v2/signatures/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ func Configure(api *operations.EasyclaAPI, claGroupService service.Service, proj
if updateErr != nil || updatedSig == nil {
msg := fmt.Sprintf("unable to update signature approval list using CLA Group ID: %s", params.ClaGroupID)
log.WithFields(f).Warn(msg)
if _, ok := err.(*signatureService.ForbiddenError); ok {
if _, ok := updateErr.(*signatureService.ForbiddenError); ok {
return signatures.NewUpdateApprovalListForbidden().WithXRequestID(reqID).WithPayload(utils.ErrorResponseForbiddenWithError(reqID, msg, updateErr))
}
return signatures.NewUpdateApprovalListBadRequest().WithXRequestID(reqID).WithPayload(utils.ErrorResponseBadRequestWithError(reqID, msg, updateErr))
Expand Down
91 changes: 91 additions & 0 deletions cla-backend-go/v2/signatures/handlers_approval_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
// Copyright The Linux Foundation and each contributor to CommunityBridge.
// SPDX-License-Identifier: MIT

package signatures

import (
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strconv"
"testing"

"github.com/LF-Engineering/lfx-kit/auth"
"github.com/go-openapi/runtime"
"github.com/golang/mock/gomock"
mock_company "github.com/linuxfoundation/easycla/cla-backend-go/company/mocks"
v1Models "github.com/linuxfoundation/easycla/cla-backend-go/gen/v1/models"
"github.com/linuxfoundation/easycla/cla-backend-go/gen/v2/models"
"github.com/linuxfoundation/easycla/cla-backend-go/gen/v2/restapi/operations"
sigOps "github.com/linuxfoundation/easycla/cla-backend-go/gen/v2/restapi/operations/signatures"
mock_project "github.com/linuxfoundation/easycla/cla-backend-go/project/mocks"
signatureService "github.com/linuxfoundation/easycla/cla-backend-go/signatures"
mock_v1_signatures "github.com/linuxfoundation/easycla/cla-backend-go/signatures/mocks"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestUpdateApprovalListResponse(t *testing.T) {
t.Setenv("DISABLE_LOCAL_PERMISSION_CHECKS", "false")

for _, tc := range []struct {
name string
result *v1Models.Signature
err error
status int
}{
{name: "success", result: &v1Models.Signature{SignatureID: "sig-1"}, status: http.StatusOK},
{name: "signature ACL forbidden", err: signatureService.NewForbiddenError("caller is not in the signature ACL"), status: http.StatusForbidden},
{name: "forbidden with result", result: &v1Models.Signature{SignatureID: "sig-1"}, err: signatureService.NewForbiddenError("caller is not in the signature ACL"), status: http.StatusForbidden},
{name: "other service error", err: errors.New("approval update failed"), status: http.StatusBadRequest},
{name: "missing result", status: http.StatusBadRequest},
} {
t.Run(tc.name, func(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()

companyModel := &v1Models.Company{CompanyID: "company-1", CompanyExternalID: "comp-sfid"}
claGroup := &v1Models.ClaGroup{ProjectID: "cla-group-1"}
authUser := &auth.User{
UserName: "manager-user", Email: "manager@example.com",
ACL: auth.ACL{Allowed: true, Scopes: []auth.Scope{{Type: auth.ProjectOrganization, ID: "proj-sfid|comp-sfid"}}},
}
mockCompanyService := mock_company.NewMockIService(ctrl)
mockCompanyService.EXPECT().GetCompany(gomock.Any(), "company-1").Return(companyModel, nil)
mockProjectService := mock_project.NewMockService(ctrl)
mockProjectService.EXPECT().GetCLAGroupsByExternalSFID(gomock.Any(), "proj-sfid").Return(&v1Models.ClaGroups{}, nil)
mockProjectService.EXPECT().GetCLAGroupByID(gomock.Any(), "cla-group-1").Return(claGroup, nil)
mockSignatureService := mock_v1_signatures.NewMockSignatureService(ctrl)
mockSignatureService.EXPECT().UpdateApprovalList(gomock.Any(), authUser, claGroup, companyModel, "cla-group-1",
&v1Models.ApprovalList{AddEmailApprovalList: []string{"dev@example.com"}}, "proj-sfid").Return(tc.result, tc.err)

api := operations.NewEasyclaAPI(nil)
Configure(api, mockProjectService, nil, mockCompanyService, mockSignatureService, nil, nil, nil, nil)
username, email, reqID := authUser.UserName, authUser.Email, testReqID
recorder := httptest.NewRecorder()
api.SignaturesUpdateApprovalListHandler.Handle(sigOps.UpdateApprovalListParams{
HTTPRequest: httptest.NewRequest(http.MethodPut, "/v4/signatures/project/proj-sfid/company/company-1/clagroup/cla-group-1/approval-list", nil),
XUSERNAME: &username, XEMAIL: &email, XREQUESTID: &reqID,
ClaGroupID: "cla-group-1", CompanyID: "company-1", ProjectSFID: "proj-sfid",
Body: &models.ApprovalList{AddEmailApprovalList: []string{"dev@example.com"}},
}, authUser).WriteResponse(recorder, runtime.JSONProducer())

assert.Equal(t, tc.status, recorder.Code, recorder.Body.String())
assert.Equal(t, reqID, recorder.Header().Get("X-Request-Id"))
if tc.status == http.StatusOK {
var payload models.Signature
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &payload))
assert.Equal(t, tc.result.SignatureID, payload.SignatureID)
} else {
var payload models.ErrorResponse
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &payload))
assert.Equal(t, strconv.Itoa(tc.status), payload.Code)
assert.Equal(t, reqID, payload.XRequestID)
if tc.err != nil {
assert.Contains(t, payload.Message, tc.err.Error())
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}
})
}
}
3 changes: 2 additions & 1 deletion utils/dev_acs_role_flip.sh
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
# Caching notes:
# - warden caches successful (user,resource) answers for ~10 minutes; the
# authoritative current state is the rolescopes listing ('status'/'roles').
# - role reads bypass ACS's response cache with X-LFX-CACHE: false.
# - after flipping, pass -H 'Cache-Control: no-cache' on your first API call
# through the gateway to bypass its cached X-ACL for that path.
#
Expand Down Expand Up @@ -145,7 +146,7 @@ get_token() {
echo "$tok"
}

acs_get() { curl -sfS --max-time 20 -H "Authorization: Bearer $TOKEN" "$ACS$1"; }
acs_get() { curl -sfS --max-time 20 -H "Authorization: Bearer $TOKEN" -H "Cache-Control: no-cache" -H "X-LFX-CACHE: false" "$ACS$1"; }
acs_post() { curl -s --max-time 20 -X POST "$ACS$1" -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d "$2" -w "\n%{http_code}"; }
acs_delete() { curl -s --max-time 20 -X DELETE "$ACS$1" -H "Authorization: Bearer $TOKEN" -o /dev/null -w "%{http_code}"; }

Expand Down
Loading