From 70c1ba888dd2730da030c0126cbe90a8de20b8a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Blaise?= Date: Tue, 16 Jul 2024 17:36:58 +0800 Subject: [PATCH] Add token generation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Clément Blaise --- apis/projects/v1alpha1/types.go | 21 ++ .../v1alpha1/zz_generated.deepcopy.go | 42 +++ go.mod | 2 +- ...rojects.argocd.crossplane.io_projects.yaml | 32 +++ pkg/clients/mock/projects/mock.go | 40 +++ pkg/clients/projects/client.go | 4 + pkg/controller/projects/controller.go | 237 ++++++++++++++-- pkg/controller/projects/controller_test.go | 256 ++++++++++++++++++ 8 files changed, 609 insertions(+), 25 deletions(-) diff --git a/apis/projects/v1alpha1/types.go b/apis/projects/v1alpha1/types.go index 3bc5043..1e661fc 100644 --- a/apis/projects/v1alpha1/types.go +++ b/apis/projects/v1alpha1/types.go @@ -113,6 +113,27 @@ type ProjectRole struct { // Groups are a list of OIDC group claims bound to this role // +optional Groups []string `json:"groups,omitempty"` + // Tokens are a list of tokens to generate + // +optional + Tokens []ProjectToken `json:"tokens,omitempty"` +} + +// ProjectToken holds the configuration for a Token +type ProjectToken struct { + // ID is an id for the token + ID string `json:"id"` + // Description is a description for the token + // +optional + Description *string `json:"description,omitempty"` + // Duration before the token will expire. Valid time units are `s`, `m`, `h` and `d` E.g. 12h, 7d. No expiration if not set. + // +optional + ExpiresIn *string `json:"expiresIn,omitempty"` + // Duration to control token regeneration based on token age. Valid time units are `s`, `m`, `h` and `d`. + // +optional + RenewAfter *string `json:"renewAfter,omitempty"` + // Duration to control token regeneration based on remaining token lifetime. Valid time units are `s`, `m`, `h` and `d`. + // +optional + RenewBefore *string `json:"renewBefore,omitempty"` } // JWTToken holds the issuedAt and expiresAt values of a token diff --git a/apis/projects/v1alpha1/zz_generated.deepcopy.go b/apis/projects/v1alpha1/zz_generated.deepcopy.go index e0d4875..32d0383 100644 --- a/apis/projects/v1alpha1/zz_generated.deepcopy.go +++ b/apis/projects/v1alpha1/zz_generated.deepcopy.go @@ -371,6 +371,13 @@ func (in *ProjectRole) DeepCopyInto(out *ProjectRole) { *out = make([]string, len(*in)) copy(*out, *in) } + if in.Tokens != nil { + in, out := &in.Tokens, &out.Tokens + *out = make([]ProjectToken, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ProjectRole. @@ -417,6 +424,41 @@ func (in *ProjectStatus) DeepCopy() *ProjectStatus { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ProjectToken) DeepCopyInto(out *ProjectToken) { + *out = *in + if in.Description != nil { + in, out := &in.Description, &out.Description + *out = new(string) + **out = **in + } + if in.ExpiresIn != nil { + in, out := &in.ExpiresIn, &out.ExpiresIn + *out = new(string) + **out = **in + } + if in.RenewAfter != nil { + in, out := &in.RenewAfter, &out.RenewAfter + *out = new(string) + **out = **in + } + if in.RenewBefore != nil { + in, out := &in.RenewBefore, &out.RenewBefore + *out = new(string) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ProjectToken. +func (in *ProjectToken) DeepCopy() *ProjectToken { + if in == nil { + return nil + } + out := new(ProjectToken) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *SignatureKey) DeepCopyInto(out *SignatureKey) { *out = *in diff --git a/go.mod b/go.mod index 0eb00a4..1a626cc 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ replace github.com/argoproj/gitops-engine v0.7.1-0.20230607163028-425d65e07695 = require ( github.com/argoproj/argo-cd/v2 v2.8.19 github.com/argoproj/gitops-engine v0.7.1-0.20230607163028-425d65e07695 + github.com/argoproj/pkg v0.13.7-0.20230626144333-d56162821bd1 github.com/crossplane/crossplane-runtime v1.16.0 github.com/crossplane/crossplane-tools v0.0.0-20230925130601-628280f8bf79 github.com/golang/mock v1.6.0 @@ -36,7 +37,6 @@ require ( github.com/ProtonMail/go-crypto v0.0.0-20230828082145-3c4c8a2d2371 // indirect github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751 // indirect github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137 // indirect - github.com/argoproj/pkg v0.13.7-0.20230626144333-d56162821bd1 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/blang/semver/v4 v4.0.0 // indirect github.com/bmatcuk/doublestar/v4 v4.6.0 // indirect diff --git a/package/crds/projects.argocd.crossplane.io_projects.yaml b/package/crds/projects.argocd.crossplane.io_projects.yaml index 6962dbb..a34ce9b 100644 --- a/package/crds/projects.argocd.crossplane.io_projects.yaml +++ b/package/crds/projects.argocd.crossplane.io_projects.yaml @@ -315,6 +315,38 @@ spec: items: type: string type: array + tokens: + description: Tokens are a list of tokens to generate + items: + description: ProjectToken holds the configuration for + a Token + properties: + description: + description: Description is a description for the + token + type: string + expiresIn: + description: Duration before the token will expire. + Valid time units are `s`, `m`, `h` and `d` E.g. + 12h, 7d. No expiration if not set. + type: string + id: + description: ID is an id for the token + type: string + renewAfter: + description: Duration to control token regeneration + based on token age. Valid time units are `s`, `m`, + `h` and `d`. + type: string + renewBefore: + description: Duration to control token regeneration + based on remaining token lifetime. Valid time units + are `s`, `m`, `h` and `d`. + type: string + required: + - id + type: object + type: array required: - name type: object diff --git a/pkg/clients/mock/projects/mock.go b/pkg/clients/mock/projects/mock.go index 182ae86..7166f8e 100644 --- a/pkg/clients/mock/projects/mock.go +++ b/pkg/clients/mock/projects/mock.go @@ -57,6 +57,26 @@ func (mr *MockProjectServiceClientMockRecorder) Create(ctx, in interface{}, opts return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Create", reflect.TypeOf((*MockProjectServiceClient)(nil).Create), varargs...) } +// CreateToken mocks base method. +func (m *MockProjectServiceClient) CreateToken(ctx context.Context, in *project.ProjectTokenCreateRequest, opts ...grpc.CallOption) (*project.ProjectTokenResponse, error) { + m.ctrl.T.Helper() + varargs := []interface{}{ctx, in} + for _, a := range opts { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "CreateToken", varargs...) + ret0, _ := ret[0].(*project.ProjectTokenResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// CreateToken indicates an expected call of CreateToken. +func (mr *MockProjectServiceClientMockRecorder) CreateToken(ctx, in interface{}, opts ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]interface{}{ctx, in}, opts...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateToken", reflect.TypeOf((*MockProjectServiceClient)(nil).CreateToken), varargs...) +} + // Delete mocks base method. func (m *MockProjectServiceClient) Delete(ctx context.Context, in *project.ProjectQuery, opts ...grpc.CallOption) (*project.EmptyResponse, error) { m.ctrl.T.Helper() @@ -77,6 +97,26 @@ func (mr *MockProjectServiceClientMockRecorder) Delete(ctx, in interface{}, opts return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Delete", reflect.TypeOf((*MockProjectServiceClient)(nil).Delete), varargs...) } +// DeleteToken mocks base method. +func (m *MockProjectServiceClient) DeleteToken(ctx context.Context, in *project.ProjectTokenDeleteRequest, opts ...grpc.CallOption) (*project.EmptyResponse, error) { + m.ctrl.T.Helper() + varargs := []interface{}{ctx, in} + for _, a := range opts { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "DeleteToken", varargs...) + ret0, _ := ret[0].(*project.EmptyResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// DeleteToken indicates an expected call of DeleteToken. +func (mr *MockProjectServiceClientMockRecorder) DeleteToken(ctx, in interface{}, opts ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]interface{}{ctx, in}, opts...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteToken", reflect.TypeOf((*MockProjectServiceClient)(nil).DeleteToken), varargs...) +} + // Get mocks base method. func (m *MockProjectServiceClient) Get(ctx context.Context, in *project.ProjectQuery, opts ...grpc.CallOption) (*v1alpha1.AppProject, error) { m.ctrl.T.Helper() diff --git a/pkg/clients/projects/client.go b/pkg/clients/projects/client.go index 7a557b2..f509d90 100644 --- a/pkg/clients/projects/client.go +++ b/pkg/clients/projects/client.go @@ -26,6 +26,10 @@ type ProjectServiceClient interface { Update(ctx context.Context, in *project.ProjectUpdateRequest, opts ...grpc.CallOption) (*v1alpha1.AppProject, error) // Delete deletes a project Delete(ctx context.Context, in *project.ProjectQuery, opts ...grpc.CallOption) (*project.EmptyResponse, error) + // CreateToken a new project token + CreateToken(ctx context.Context, in *project.ProjectTokenCreateRequest, opts ...grpc.CallOption) (*project.ProjectTokenResponse, error) + // DeleteToken a new project token + DeleteToken(ctx context.Context, in *project.ProjectTokenDeleteRequest, opts ...grpc.CallOption) (*project.EmptyResponse, error) } // NewProjectServiceClient creates a new API client from a set of config options, or fails fatally if the new client creation fails. diff --git a/pkg/controller/projects/controller.go b/pkg/controller/projects/controller.go index ee4a292..6d12d41 100644 --- a/pkg/controller/projects/controller.go +++ b/pkg/controller/projects/controller.go @@ -18,13 +18,17 @@ package projects import ( "context" + "fmt" + "time" "github.com/argoproj/argo-cd/v2/pkg/apiclient" "github.com/argoproj/argo-cd/v2/pkg/apiclient/project" argocdv1alpha1 "github.com/argoproj/argo-cd/v2/pkg/apis/application/v1alpha1" "github.com/argoproj/argo-cd/v2/util/io" + atime "github.com/argoproj/pkg/time" "github.com/google/go-cmp/cmp" "github.com/pkg/errors" + kerrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/utils/ptr" ctrl "sigs.k8s.io/controller-runtime" @@ -43,12 +47,13 @@ import ( ) const ( - errNotProject = "managed resource is not a Argocd Project custom resource" - errGetFailed = "cannot get Argocd Project" - errKubeUpdateFailed = "cannot update Argocd Project custom resource" - errCreateFailed = "cannot create Argocd Project" - errUpdateFailed = "cannot update Argocd Project" - errDeleteFailed = "cannot delete Argocd Project" + errNotProject = "managed resource is not a Argocd Project custom resource" + errGetFailed = "cannot get Argocd Project" + errKubeUpdateFailed = "cannot update Argocd Project custom resource" + errCreateFailed = "cannot create Argocd Project" + errUpdateFailed = "cannot update Argocd Project" + errDeleteFailed = "cannot delete Argocd Project" + errCreateTokenFailed = "cannot create Argocd Project Token" ) // SetupProject adds a controller that reconciles projects. @@ -144,6 +149,23 @@ func (e *external) Create(ctx context.Context, mg resource.Managed) (managed.Ext return managed.ExternalCreation{}, errors.Wrap(err, errCreateFailed) } + projTokenCreateRequests := generateTokenCreateRequests(resp.Name, cr.Spec.ForProvider.Roles) + tokens := make(map[string][]byte, len(projTokenCreateRequests)) + for key, req := range projTokenCreateRequests { + res, errToken := e.client.CreateToken(ctx, req) + if errToken != nil { + return managed.ExternalCreation{}, errors.Wrap(err, errCreateTokenFailed) + } + tokens[key] = []byte(res.GetToken()) + } + + if len(tokens) > 0 { + errSecret := e.upsertConnectionSecret(ctx, cr, tokens) + if errSecret != nil { + return managed.ExternalCreation{}, errors.Wrap(errSecret, errCreateFailed) + } + } + meta.SetExternalName(cr, resp.Name) return managed.ExternalCreation{}, errors.Wrap(nil, errKubeUpdateFailed) @@ -166,6 +188,26 @@ func (e *external) Update(ctx context.Context, mg resource.Managed) (managed.Ext projUpdateRequest := generateUpdateProjectOptions(cr, proj) _, err = e.client.Update(ctx, projUpdateRequest) + if err != nil { + return managed.ExternalUpdate{}, errors.Wrap(err, errUpdateFailed) + } + projTokenUpdateRequests := generateTokenUpdateRequests(proj.Name, cr.Spec.ForProvider.Roles, cr.Status.AtProvider.JWTTokensByRole) + + tokens := make(map[string][]byte, len(projTokenUpdateRequests)) + for key, req := range projTokenUpdateRequests { + res, errToken := e.client.CreateToken(ctx, req) + if errToken != nil { + return managed.ExternalUpdate{}, errors.Wrap(err, errCreateTokenFailed) + } + tokens[key] = []byte(res.GetToken()) + } + + if len(tokens) > 0 { + err = e.upsertConnectionSecret(ctx, cr, tokens) + if err != nil { + return managed.ExternalUpdate{}, errors.Wrap(err, errCreateFailed) + } + } return managed.ExternalUpdate{}, errors.Wrap(err, errUpdateFailed) } @@ -355,21 +397,10 @@ func generateProjectSpec(p *v1alpha1.ProjectParameters) argocdv1alpha1.AppProjec if p.Roles != nil { projSpec.Roles = make([]argocdv1alpha1.ProjectRole, len(p.Roles)) for i, r := range p.Roles { - - jwtTokens := make([]argocdv1alpha1.JWTToken, len(r.JWTTokens)) - for j, t := range r.JWTTokens { - jwtTokens[j] = argocdv1alpha1.JWTToken{ - IssuedAt: t.IssuedAt, - ExpiresAt: clients.Int64Value(t.ExpiresAt), - ID: clients.StringValue(t.ID), - } - } - projSpec.Roles[i] = argocdv1alpha1.ProjectRole{ Name: r.Name, Description: clients.StringValue(r.Description), Policies: r.Policies, - JWTTokens: jwtTokens, Groups: r.Groups, } } @@ -444,6 +475,83 @@ func generateUpdateProjectOptions(p *v1alpha1.Project, current *argocdv1alpha1.A return o } +func createProjectTokenRequest(name, roleName, tokenID string, description *string, expiresIn int64) *project.ProjectTokenCreateRequest { + request := &project.ProjectTokenCreateRequest{ + Id: tokenID, + Project: name, + Role: roleName, + ExpiresIn: expiresIn, + } + if description != nil { + request.Description = *description + } + return request +} + +func generateTokenCreateRequests(name string, roles []v1alpha1.ProjectRole) map[string]*project.ProjectTokenCreateRequest { + requests := make(map[string]*project.ProjectTokenCreateRequest) + for _, role := range roles { + for _, token := range role.Tokens { + expiresIn, _ := parseDuration(token.ExpiresIn) + request := createProjectTokenRequest(name, role.Name, token.ID, token.Description, expiresIn) + requests[fmt.Sprintf("%s.%s", role.Name, token.ID)] = request + } + } + return requests +} + +func generateTokenUpdateRequests(name string, roles []v1alpha1.ProjectRole, existingTokens map[string]v1alpha1.JWTTokens) map[string]*project.ProjectTokenCreateRequest { + requests := make(map[string]*project.ProjectTokenCreateRequest) + now := time.Now().Unix() + + for _, role := range roles { + for _, token := range role.Tokens { + tokenKey := fmt.Sprintf("%s.%s", role.Name, token.ID) + expiresIn, _ := parseDuration(token.ExpiresIn) + + tokens, exists := existingTokens[role.Name] + if !exists { + requests[tokenKey] = createProjectTokenRequest(name, role.Name, token.ID, token.Description, expiresIn) + continue + } + + var existingToken *v1alpha1.JWTToken + for i, t := range tokens.Items { + if *t.ID == token.ID { + existingToken = &tokens.Items[i] + break + } + } + + if existingToken == nil { + requests[tokenKey] = createProjectTokenRequest(name, role.Name, token.ID, token.Description, expiresIn) + continue + } + + if shouldRenewToken(token, existingToken, now) { + requests[tokenKey] = createProjectTokenRequest(name, role.Name, token.ID, token.Description, expiresIn) + } + } + } + return requests +} + +func shouldRenewToken(token v1alpha1.ProjectToken, existingToken *v1alpha1.JWTToken, now int64) bool { + if token.RenewBefore != nil { + renewBefore, _ := parseDuration(token.RenewBefore) + if *existingToken.ExpiresAt-now < renewBefore { + return true + } + } + if token.RenewAfter != nil { + renewAfter, _ := parseDuration(token.RenewAfter) + if now-existingToken.IssuedAt > renewAfter { + return true + } + } + return false +} + func isProjectUpToDate(p *v1alpha1.ProjectParameters, r *argocdv1alpha1.AppProject) bool { // nolint:gocyclo // checking all parameters can't be reduced switch { case !cmp.Equal(p.SourceRepos, r.Spec.SourceRepos), @@ -475,28 +583,81 @@ func isEqualRoles(p []v1alpha1.ProjectRole, r []argocdv1alpha1.ProjectRole) bool role.Description != nil && *role.Description != r[i].Description, !cmp.Equal(role.Policies, r[i].Policies), !cmp.Equal(role.Groups, r[i].Groups), - !isEqualJWTTokens(role.JWTTokens, r[i].JWTTokens): + !isEqualJWTTokens(role.Tokens, r[i].JWTTokens): return false } } return true } -func isEqualJWTTokens(p []v1alpha1.JWTToken, r []argocdv1alpha1.JWTToken) bool { +func isEqualJWTTokens(p []v1alpha1.ProjectToken, r []argocdv1alpha1.JWTToken) bool { if p == nil && r == nil { return true } if p == nil || r == nil || len(p) != len(r) { return false } - for i, jwtToken := range p { - switch { - case jwtToken.IssuedAt != r[i].IssuedAt, - jwtToken.ExpiresAt != nil && *jwtToken.ExpiresAt != r[i].ExpiresAt, - jwtToken.ID != nil && *jwtToken.ID != r[i].ID: + + now := time.Now().Unix() + + for i, token := range p { + if !isTokenEqual(token, r[i], now) { + return false + } + } + return true +} + +func isTokenEqual(p v1alpha1.ProjectToken, r argocdv1alpha1.JWTToken, now int64) bool { + if p.ID != r.ID { + return false + } + + if p.ExpiresIn == nil || *p.ExpiresIn == "0" { + return true + } + + expiresIn, err := atime.ParseDuration(*p.ExpiresIn) + if err != nil { + return false + } + + if int64(expiresIn.Seconds()) != r.ExpiresAt-r.IssuedAt { + return false + } + + if r.ExpiresAt < now { + return false + } + + if !isRenewalValid(p, r, now) { + return false + } + + return true +} + +func isRenewalValid(p v1alpha1.ProjectToken, r argocdv1alpha1.JWTToken, now int64) bool { + if p.RenewAfter != nil { + renewAfter, err := atime.ParseDuration(*p.RenewAfter) + if err != nil { + return false + } + if now-r.IssuedAt > int64(renewAfter.Seconds()) { + return false + } + } + + if p.RenewBefore != nil { + renewBefore, err := atime.ParseDuration(*p.RenewBefore) + if err != nil { + return false + } + if r.ExpiresAt-now < int64(renewBefore.Seconds()) { return false } } + return true } @@ -587,3 +748,31 @@ func isEqualSyncWindows(p v1alpha1.SyncWindows, r argocdv1alpha1.SyncWindows) bo } return true } + +func parseDuration(durationStr *string) (int64, error) { + if durationStr == nil { + return 0, nil + } + duration, err := atime.ParseDuration(*durationStr) + if err != nil { + return 0, err + } + return int64(duration.Seconds()), nil +} + +func (e *external) upsertConnectionSecret(ctx context.Context, project *v1alpha1.Project, tokens map[string][]byte) error { + secret := resource.ConnectionSecretFor(project, v1alpha1.ProjectGroupVersionKind) + secret.Data = tokens + if secret.Data != nil { + for k := range tokens { + secret.Data[k] = tokens[k] + } + } + if err := e.kube.Create(ctx, secret); err != nil { + if kerrors.IsAlreadyExists(err) { + return errors.Wrapf(e.kube.Update(ctx, secret), "failed to update secret: %s", secret.Name) + } + return errors.Wrapf(err, "failed to create secret: %s", secret.Name) + } + return nil +} diff --git a/pkg/controller/projects/controller_test.go b/pkg/controller/projects/controller_test.go index f2102fd..5acd9f6 100644 --- a/pkg/controller/projects/controller_test.go +++ b/pkg/controller/projects/controller_test.go @@ -19,6 +19,9 @@ package projects import ( "context" "testing" + "time" + + "k8s.io/utils/ptr" "github.com/golang/mock/gomock" "github.com/google/go-cmp/cmp" @@ -709,3 +712,256 @@ func TestDelete(t *testing.T) { }) } } + +func TestIsEqualJWTTokens(t *testing.T) { + now := time.Now().Unix() + tests := []struct { + name string + p []v1alpha1.ProjectToken + r []argocdv1alpha1.JWTToken + want bool + }{ + { + name: "EqualTokens", + p: []v1alpha1.ProjectToken{{ + ID: "token1", + ExpiresIn: ptr.To("1h"), + }}, + r: []argocdv1alpha1.JWTToken{{ + ID: "token1", + IssuedAt: now, + ExpiresAt: now + 3600, + }}, + want: true, + }, + { + name: "DifferentIDs", + p: []v1alpha1.ProjectToken{{ + ID: "token1", + ExpiresIn: ptr.To("1h"), + }}, + r: []argocdv1alpha1.JWTToken{{ + ID: "token2", + IssuedAt: now, + ExpiresAt: now + 3600, + }}, + want: false, + }, + { + name: "DifferentExpiration", + p: []v1alpha1.ProjectToken{{ + ID: "token1", + ExpiresIn: ptr.To("2h"), + }}, + r: []argocdv1alpha1.JWTToken{{ + ID: "token1", + IssuedAt: now, + ExpiresAt: now + 3600, + }}, + want: false, + }, + { + name: "TokenExpired", + p: []v1alpha1.ProjectToken{{ + ID: "token1", + ExpiresIn: ptr.To("1h"), + }}, + r: []argocdv1alpha1.JWTToken{{ + ID: "token1", + IssuedAt: now - 7200, + ExpiresAt: now - 3600, + }}, + want: false, + }, + { + name: "DifferentLengths", + p: []v1alpha1.ProjectToken{{ + ID: "token1", + ExpiresIn: ptr.To("1h"), + }}, + r: []argocdv1alpha1.JWTToken{{ + ID: "token1", + IssuedAt: now, + ExpiresAt: now + 3600, + }, { + ID: "token2", + IssuedAt: now, + ExpiresAt: now + 7200, + }}, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := isEqualJWTTokens(tt.p, tt.r); got != tt.want { + t.Errorf("isEqualJWTTokens() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestGenerateTokenUpdateRequests(t *testing.T) { + now := time.Now().Unix() + tests := []struct { + name string + projectName string + roles []v1alpha1.ProjectRole + existingTokens map[string]v1alpha1.JWTTokens + want map[string]*project.ProjectTokenCreateRequest + }{ + { + name: "NoExistingTokens", + projectName: "test-project", + roles: []v1alpha1.ProjectRole{{ + Name: "role1", + Tokens: []v1alpha1.ProjectToken{{ + ID: "token1", + ExpiresIn: ptr.To("1h"), + Description: ptr.To("Test token"), + }}, + }}, + existingTokens: map[string]v1alpha1.JWTTokens{}, + want: map[string]*project.ProjectTokenCreateRequest{ + "role1.token1": { + Project: "test-project", + Role: "role1", + Description: "Test token", + ExpiresIn: 3600, + Id: "token1", + }, + }, + }, + { + name: "ExistingTokenOK", + projectName: "test-project", + roles: []v1alpha1.ProjectRole{{ + Name: "role1", + Tokens: []v1alpha1.ProjectToken{{ + ID: "token1", + ExpiresIn: ptr.To("1h"), + }}, + }}, + existingTokens: map[string]v1alpha1.JWTTokens{ + "role1": { + Items: []v1alpha1.JWTToken{{ + ID: ptr.To("token1"), + IssuedAt: now - 4000, + ExpiresAt: ptr.To(now + 3600), + }}, + }, + }, + want: map[string]*project.ProjectTokenCreateRequest{}, + }, + { + name: "TokenNeedsRenewalBefore", + projectName: "test-project", + roles: []v1alpha1.ProjectRole{{ + Name: "role1", + Tokens: []v1alpha1.ProjectToken{{ + ID: "token1", + ExpiresIn: ptr.To("1h"), + RenewBefore: ptr.To("30m"), + }}, + }}, + existingTokens: map[string]v1alpha1.JWTTokens{ + "role1": { + Items: []v1alpha1.JWTToken{{ + ID: ptr.To("token1"), + IssuedAt: now - 3000, + ExpiresAt: ptr.To(now + 600), + }}, + }, + }, + want: map[string]*project.ProjectTokenCreateRequest{ + "role1.token1": { + Project: "test-project", + Role: "role1", + ExpiresIn: 3600, + Id: "token1", + }, + }, + }, + { + name: "TokenNeedsRenewalAfter", + projectName: "test-project", + roles: []v1alpha1.ProjectRole{{ + Name: "role1", + Tokens: []v1alpha1.ProjectToken{{ + ID: "token1", + ExpiresIn: ptr.To("1h"), + RenewAfter: ptr.To("30m"), + }}, + }}, + existingTokens: map[string]v1alpha1.JWTTokens{ + "role1": { + Items: []v1alpha1.JWTToken{{ + ID: ptr.To("token1"), + IssuedAt: now - 3000, + ExpiresAt: ptr.To(now + 600), + }}, + }, + }, + want: map[string]*project.ProjectTokenCreateRequest{ + "role1.token1": { + Project: "test-project", + Role: "role1", + ExpiresIn: 3600, + Id: "token1", + }, + }, + }, + { + name: "MultipleTokensOneNeedsRenewal", + projectName: "test-project", + roles: []v1alpha1.ProjectRole{{ + Name: "role1", + Tokens: []v1alpha1.ProjectToken{ + { + ID: "token1", + ExpiresIn: ptr.To("1h"), + RenewBefore: ptr.To("30m"), + }, + { + ID: "token2", + ExpiresIn: ptr.To("1h"), + RenewBefore: ptr.To("30m"), + }, + }, + }}, + existingTokens: map[string]v1alpha1.JWTTokens{ + "role1": { + Items: []v1alpha1.JWTToken{ + { + ID: ptr.To("token1"), + IssuedAt: now - 3000, + ExpiresAt: ptr.To(now + 600), + }, + { + ID: ptr.To("token2"), + IssuedAt: now - 100, + ExpiresAt: ptr.To(now + 3500), + }, + }, + }, + }, + want: map[string]*project.ProjectTokenCreateRequest{ + "role1.token1": { + Project: "test-project", + Role: "role1", + ExpiresIn: 3600, + Id: "token1", + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := generateTokenUpdateRequests(tt.projectName, tt.roles, tt.existingTokens) + if diff := cmp.Diff(tt.want, got); diff != "" { + t.Errorf("generateTokenUpdateRequests() mismatch (-want +got):\n%s", diff) + } + }) + } +}