/* Copyright © The ESO Authors Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package gitlab import ( "context" "encoding/json" "fmt" "net/http" "net/http/httptest" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" corev1 "k8s.io/api/core/v1" apiextensions "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "sigs.k8s.io/controller-runtime/pkg/client" clientfake "sigs.k8s.io/controller-runtime/pkg/client/fake" genv1alpha1 "github.com/external-secrets/external-secrets/apis/generators/v1alpha1" ) const ( testNamespace = "foo" testSecret = "gitlab-token" testKey = "token" ) func newKube() client.Client { return clientfake.NewClientBuilder().WithObjects(&corev1.Secret{ ObjectMeta: metav1.ObjectMeta{ Name: testSecret, Namespace: testNamespace, }, Data: map[string][]byte{ testKey: []byte("glpat-secret-access-token"), }, }).Build() } // captured records the create requests the mock GitLab server received. type captured struct { method string path string privateTok string contentType string body map[string]any } func newServer(t *testing.T, status int, response []byte, sink *captured) *httptest.Server { return httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { sink.method = req.Method // EscapedPath() preserves the on-the-wire encoding (e.g. group%2Fproject), // whereas req.URL.Path would be decoded back and hide double/non-encoding. sink.path = req.URL.EscapedPath() sink.privateTok = req.Header.Get("PRIVATE-TOKEN") sink.contentType = req.Header.Get("Content-Type") if req.Body != nil && req.Method == http.MethodPost { _ = json.NewDecoder(req.Body).Decode(&sink.body) } rw.WriteHeader(status) _, _ = rw.Write(response) })) } func specJSON(t *testing.T, url, projectID, groupID string) *apiextensions.JSON { t.Helper() target := "" if projectID != "" { target = fmt.Sprintf(" projectID: %q\n", projectID) } if groupID != "" { target += fmt.Sprintf(" groupID: %q\n", groupID) } raw := fmt.Sprintf(`apiVersion: generators.external-secrets.io/v1alpha1 kind: GitlabDeployToken spec: url: %q %s name: "eso-token" scopes: - read_repository - read_registry username: "custom-user" auth: token: secretRef: name: %q key: %q `, url, target, testSecret, testKey) return &apiextensions.JSON{Raw: []byte(raw)} } func TestGenerate(t *testing.T) { createResp := []byte(`{ "id": 42, "name": "eso-token", "username": "custom-user", "expires_at": null, "token": "gitlab-deploy-token-value", "revoked": false, "expired": false, "scopes": ["read_repository", "read_registry"] }`) t.Run("nil spec", func(t *testing.T) { g := &Generator{} _, _, err := g.generate(context.Background(), nil, newKube(), testNamespace) require.Error(t, err) }) t.Run("project deploy token", func(t *testing.T) { sink := &captured{} srv := newServer(t, http.StatusCreated, createResp, sink) defer srv.Close() g := &Generator{httpClient: srv.Client()} got, state, err := g.generate(context.Background(), specJSON(t, srv.URL, "group/project", ""), newKube(), testNamespace) require.NoError(t, err) assert.Equal(t, http.MethodPost, sink.method) // Unescaped path input is URL-escaped on the wire. assert.Equal(t, "/api/v4/projects/group%2Fproject/deploy_tokens", sink.path) assert.Equal(t, "glpat-secret-access-token", sink.privateTok) assert.Equal(t, "application/json", sink.contentType) assert.Equal(t, "eso-token", sink.body["name"]) assert.Equal(t, "custom-user", sink.body["username"]) assert.ElementsMatch(t, []any{"read_repository", "read_registry"}, sink.body["scopes"]) assert.Equal(t, map[string][]byte{ "username": []byte("custom-user"), "token": []byte("gitlab-deploy-token-value"), }, got) require.NotNil(t, state) var st deployTokenState require.NoError(t, json.Unmarshal(state.Raw, &st)) assert.Equal(t, 42, st.TokenID) assert.Equal(t, "group/project", st.ProjectID) }) t.Run("group deploy token", func(t *testing.T) { sink := &captured{} srv := newServer(t, http.StatusCreated, createResp, sink) defer srv.Close() g := &Generator{httpClient: srv.Client()} _, state, err := g.generate(context.Background(), specJSON(t, srv.URL, "", "42"), newKube(), testNamespace) require.NoError(t, err) assert.Equal(t, "/api/v4/groups/42/deploy_tokens", sink.path) var st deployTokenState require.NoError(t, json.Unmarshal(state.Raw, &st)) assert.Equal(t, "42", st.GroupID) }) t.Run("error when both project and group set", func(t *testing.T) { g := &Generator{} _, _, err := g.generate(context.Background(), specJSON(t, "https://gitlab.com", "1", "2"), newKube(), testNamespace) require.ErrorContains(t, err, "exactly one of projectID or groupID") }) t.Run("error on non-2xx response", func(t *testing.T) { sink := &captured{} srv := newServer(t, http.StatusForbidden, []byte(`{"message":"403 Forbidden"}`), sink) defer srv.Close() g := &Generator{httpClient: srv.Client()} _, _, err := g.generate(context.Background(), specJSON(t, srv.URL, "1", ""), newKube(), testNamespace) require.ErrorContains(t, err, "403 Forbidden") }) t.Run("error when token missing in secret", func(t *testing.T) { kube := clientfake.NewClientBuilder().WithObjects(&corev1.Secret{ ObjectMeta: metav1.ObjectMeta{Name: testSecret, Namespace: testNamespace}, Data: map[string][]byte{}, }).Build() g := &Generator{} _, _, err := g.generate(context.Background(), specJSON(t, "https://gitlab.com", "1", ""), kube, testNamespace) require.ErrorContains(t, err, "cannot find secret data for key") }) t.Run("expires_at is normalized to RFC3339 UTC", func(t *testing.T) { // GitLab's deploy-token API documents an ISO8601 expiry; RFC3339 is a // profile of ISO8601 and the generator always emits a UTC datetime, // regardless of the offset the user supplied. username is left unset // here so this also covers "expiresAt set, other optional field unset". cases := []struct{ in, want string }{ {"2025-12-31T23:59:59Z", "2025-12-31T23:59:59Z"}, {"2030-01-02T03:04:05Z", "2030-01-02T03:04:05Z"}, {"2025-06-30T12:00:00+02:00", "2025-06-30T10:00:00Z"}, } for _, tc := range cases { sink := &captured{} srv := newServer(t, http.StatusCreated, createResp, sink) raw := fmt.Sprintf(`apiVersion: generators.external-secrets.io/v1alpha1 kind: GitlabDeployToken spec: url: %q projectID: "1" name: "eso-token" scopes: - read_repository expiresAt: %q auth: token: secretRef: name: %q key: %q `, srv.URL, tc.in, testSecret, testKey) g := &Generator{httpClient: srv.Client()} _, _, err := g.generate(context.Background(), &apiextensions.JSON{Raw: []byte(raw)}, newKube(), testNamespace) require.NoError(t, err) assert.Equal(t, tc.want, sink.body["expires_at"]) srv.Close() } }) t.Run("optional fields omitted are not sent", func(t *testing.T) { sink := &captured{} srv := newServer(t, http.StatusCreated, createResp, sink) defer srv.Close() raw := fmt.Sprintf(`apiVersion: generators.external-secrets.io/v1alpha1 kind: GitlabDeployToken spec: url: %q projectID: "1" name: "eso-token" scopes: - read_repository auth: token: secretRef: name: %q key: %q `, srv.URL, testSecret, testKey) g := &Generator{httpClient: srv.Client()} _, _, err := g.generate(context.Background(), &apiextensions.JSON{Raw: []byte(raw)}, newKube(), testNamespace) require.NoError(t, err) _, hasUser := sink.body["username"] assert.False(t, hasUser, "username must be omitted from the request when unset") _, hasExp := sink.body["expires_at"] assert.False(t, hasExp, "expires_at must be omitted from the request when unset") }) } func TestCleanup(t *testing.T) { t.Run("nil state is a no-op", func(t *testing.T) { g := &Generator{} require.NoError(t, g.cleanup(context.Background(), specJSON(t, "https://gitlab.com", "1", ""), nil, newKube(), testNamespace)) }) t.Run("revokes project deploy token", func(t *testing.T) { sink := &captured{} srv := newServer(t, http.StatusNoContent, nil, sink) defer srv.Close() state := &apiextensions.JSON{Raw: []byte(`{"url":"` + srv.URL + `","projectID":"1","tokenID":42}`)} g := &Generator{httpClient: srv.Client()} require.NoError(t, g.cleanup(context.Background(), specJSON(t, srv.URL, "1", ""), state, newKube(), testNamespace)) assert.Equal(t, http.MethodDelete, sink.method) assert.Equal(t, "/api/v4/projects/1/deploy_tokens/42", sink.path) assert.Equal(t, "glpat-secret-access-token", sink.privateTok) }) t.Run("idempotent on 404", func(t *testing.T) { sink := &captured{} srv := newServer(t, http.StatusNotFound, []byte(`{"message":"404 Not Found"}`), sink) defer srv.Close() state := &apiextensions.JSON{Raw: []byte(`{"url":"` + srv.URL + `","groupID":"7","tokenID":99}`)} g := &Generator{httpClient: srv.Client()} require.NoError(t, g.cleanup(context.Background(), specJSON(t, srv.URL, "", "7"), state, newKube(), testNamespace)) assert.Equal(t, "/api/v4/groups/7/deploy_tokens/99", sink.path) }) t.Run("error on 500", func(t *testing.T) { sink := &captured{} srv := newServer(t, http.StatusInternalServerError, []byte(`{"message":"boom"}`), sink) defer srv.Close() state := &apiextensions.JSON{Raw: []byte(`{"url":"` + srv.URL + `","projectID":"1","tokenID":42}`)} g := &Generator{httpClient: srv.Client()} require.ErrorContains(t, g.cleanup(context.Background(), specJSON(t, srv.URL, "1", ""), state, newKube(), testNamespace), "boom") }) } func TestDeployTokensURL(t *testing.T) { tests := []struct { name string spec genv1alpha1.GitlabDeployTokenSpec want string wantErr bool }{ {name: "project default url", spec: genv1alpha1.GitlabDeployTokenSpec{ProjectID: "10"}, want: "https://gitlab.com/api/v4/projects/10/deploy_tokens"}, {name: "group custom url", spec: genv1alpha1.GitlabDeployTokenSpec{URL: "https://gl.example.com", GroupID: "5"}, want: "https://gl.example.com/api/v4/groups/5/deploy_tokens"}, {name: "path project encoded", spec: genv1alpha1.GitlabDeployTokenSpec{ProjectID: "grp/proj"}, want: "https://gitlab.com/api/v4/projects/grp%2Fproj/deploy_tokens"}, {name: "trailing slash trimmed", spec: genv1alpha1.GitlabDeployTokenSpec{URL: "https://gl.example.com/", ProjectID: "10"}, want: "https://gl.example.com/api/v4/projects/10/deploy_tokens"}, { name: "multiple trailing slashes trimmed", spec: genv1alpha1.GitlabDeployTokenSpec{URL: "https://gl.example.com///", GroupID: "5"}, want: "https://gl.example.com/api/v4/groups/5/deploy_tokens", }, {name: "both set", spec: genv1alpha1.GitlabDeployTokenSpec{ProjectID: "1", GroupID: "2"}, wantErr: true}, {name: "neither set", spec: genv1alpha1.GitlabDeployTokenSpec{}, wantErr: true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, err := deployTokensURL(&tt.spec) if tt.wantErr { require.Error(t, err) return } require.NoError(t, err) assert.Equal(t, tt.want, got) }) } }