gitlab_test.go 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337
  1. /*
  2. Copyright © The ESO Authors
  3. Licensed under the Apache License, Version 2.0 (the "License");
  4. you may not use this file except in compliance with the License.
  5. You may obtain a copy of the License at
  6. https://www.apache.org/licenses/LICENSE-2.0
  7. Unless required by applicable law or agreed to in writing, software
  8. distributed under the License is distributed on an "AS IS" BASIS,
  9. WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  10. See the License for the specific language governing permissions and
  11. limitations under the License.
  12. */
  13. package gitlab
  14. import (
  15. "context"
  16. "encoding/json"
  17. "fmt"
  18. "net/http"
  19. "net/http/httptest"
  20. "testing"
  21. "github.com/stretchr/testify/assert"
  22. "github.com/stretchr/testify/require"
  23. corev1 "k8s.io/api/core/v1"
  24. apiextensions "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
  25. metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
  26. "sigs.k8s.io/controller-runtime/pkg/client"
  27. clientfake "sigs.k8s.io/controller-runtime/pkg/client/fake"
  28. genv1alpha1 "github.com/external-secrets/external-secrets/apis/generators/v1alpha1"
  29. )
  30. const (
  31. testNamespace = "foo"
  32. testSecret = "gitlab-token"
  33. testKey = "token"
  34. )
  35. func newKube() client.Client {
  36. return clientfake.NewClientBuilder().WithObjects(&corev1.Secret{
  37. ObjectMeta: metav1.ObjectMeta{
  38. Name: testSecret,
  39. Namespace: testNamespace,
  40. },
  41. Data: map[string][]byte{
  42. testKey: []byte("glpat-secret-access-token"),
  43. },
  44. }).Build()
  45. }
  46. // captured records the create requests the mock GitLab server received.
  47. type captured struct {
  48. method string
  49. path string
  50. privateTok string
  51. contentType string
  52. body map[string]any
  53. }
  54. func newServer(t *testing.T, status int, response []byte, sink *captured) *httptest.Server {
  55. return httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
  56. sink.method = req.Method
  57. // EscapedPath() preserves the on-the-wire encoding (e.g. group%2Fproject),
  58. // whereas req.URL.Path would be decoded back and hide double/non-encoding.
  59. sink.path = req.URL.EscapedPath()
  60. sink.privateTok = req.Header.Get("PRIVATE-TOKEN")
  61. sink.contentType = req.Header.Get("Content-Type")
  62. if req.Body != nil && req.Method == http.MethodPost {
  63. _ = json.NewDecoder(req.Body).Decode(&sink.body)
  64. }
  65. rw.WriteHeader(status)
  66. _, _ = rw.Write(response)
  67. }))
  68. }
  69. func specJSON(t *testing.T, url, projectID, groupID string) *apiextensions.JSON {
  70. t.Helper()
  71. target := ""
  72. if projectID != "" {
  73. target = fmt.Sprintf(" projectID: %q\n", projectID)
  74. }
  75. if groupID != "" {
  76. target += fmt.Sprintf(" groupID: %q\n", groupID)
  77. }
  78. raw := fmt.Sprintf(`apiVersion: generators.external-secrets.io/v1alpha1
  79. kind: GitlabDeployToken
  80. spec:
  81. url: %q
  82. %s name: "eso-token"
  83. scopes:
  84. - read_repository
  85. - read_registry
  86. username: "custom-user"
  87. auth:
  88. token:
  89. secretRef:
  90. name: %q
  91. key: %q
  92. `, url, target, testSecret, testKey)
  93. return &apiextensions.JSON{Raw: []byte(raw)}
  94. }
  95. func TestGenerate(t *testing.T) {
  96. createResp := []byte(`{
  97. "id": 42,
  98. "name": "eso-token",
  99. "username": "custom-user",
  100. "expires_at": null,
  101. "token": "gitlab-deploy-token-value",
  102. "revoked": false,
  103. "expired": false,
  104. "scopes": ["read_repository", "read_registry"]
  105. }`)
  106. t.Run("nil spec", func(t *testing.T) {
  107. g := &Generator{}
  108. _, _, err := g.generate(context.Background(), nil, newKube(), testNamespace)
  109. require.Error(t, err)
  110. })
  111. t.Run("project deploy token", func(t *testing.T) {
  112. sink := &captured{}
  113. srv := newServer(t, http.StatusCreated, createResp, sink)
  114. defer srv.Close()
  115. g := &Generator{httpClient: srv.Client()}
  116. got, state, err := g.generate(context.Background(), specJSON(t, srv.URL, "group/project", ""), newKube(), testNamespace)
  117. require.NoError(t, err)
  118. assert.Equal(t, http.MethodPost, sink.method)
  119. // Unescaped path input is URL-escaped on the wire.
  120. assert.Equal(t, "/api/v4/projects/group%2Fproject/deploy_tokens", sink.path)
  121. assert.Equal(t, "glpat-secret-access-token", sink.privateTok)
  122. assert.Equal(t, "application/json", sink.contentType)
  123. assert.Equal(t, "eso-token", sink.body["name"])
  124. assert.Equal(t, "custom-user", sink.body["username"])
  125. assert.ElementsMatch(t, []any{"read_repository", "read_registry"}, sink.body["scopes"])
  126. assert.Equal(t, map[string][]byte{
  127. "username": []byte("custom-user"),
  128. "token": []byte("gitlab-deploy-token-value"),
  129. }, got)
  130. require.NotNil(t, state)
  131. var st deployTokenState
  132. require.NoError(t, json.Unmarshal(state.Raw, &st))
  133. assert.Equal(t, 42, st.TokenID)
  134. assert.Equal(t, "group/project", st.ProjectID)
  135. })
  136. t.Run("group deploy token", func(t *testing.T) {
  137. sink := &captured{}
  138. srv := newServer(t, http.StatusCreated, createResp, sink)
  139. defer srv.Close()
  140. g := &Generator{httpClient: srv.Client()}
  141. _, state, err := g.generate(context.Background(), specJSON(t, srv.URL, "", "42"), newKube(), testNamespace)
  142. require.NoError(t, err)
  143. assert.Equal(t, "/api/v4/groups/42/deploy_tokens", sink.path)
  144. var st deployTokenState
  145. require.NoError(t, json.Unmarshal(state.Raw, &st))
  146. assert.Equal(t, "42", st.GroupID)
  147. })
  148. t.Run("error when both project and group set", func(t *testing.T) {
  149. g := &Generator{}
  150. _, _, err := g.generate(context.Background(), specJSON(t, "https://gitlab.com", "1", "2"), newKube(), testNamespace)
  151. require.ErrorContains(t, err, "exactly one of projectID or groupID")
  152. })
  153. t.Run("error on non-2xx response", func(t *testing.T) {
  154. sink := &captured{}
  155. srv := newServer(t, http.StatusForbidden, []byte(`{"message":"403 Forbidden"}`), sink)
  156. defer srv.Close()
  157. g := &Generator{httpClient: srv.Client()}
  158. _, _, err := g.generate(context.Background(), specJSON(t, srv.URL, "1", ""), newKube(), testNamespace)
  159. require.ErrorContains(t, err, "403 Forbidden")
  160. })
  161. t.Run("error when token missing in secret", func(t *testing.T) {
  162. kube := clientfake.NewClientBuilder().WithObjects(&corev1.Secret{
  163. ObjectMeta: metav1.ObjectMeta{Name: testSecret, Namespace: testNamespace},
  164. Data: map[string][]byte{},
  165. }).Build()
  166. g := &Generator{}
  167. _, _, err := g.generate(context.Background(), specJSON(t, "https://gitlab.com", "1", ""), kube, testNamespace)
  168. require.ErrorContains(t, err, "cannot find secret data for key")
  169. })
  170. t.Run("expires_at is normalized to RFC3339 UTC", func(t *testing.T) {
  171. // GitLab's deploy-token API documents an ISO8601 expiry; RFC3339 is a
  172. // profile of ISO8601 and the generator always emits a UTC datetime,
  173. // regardless of the offset the user supplied. username is left unset
  174. // here so this also covers "expiresAt set, other optional field unset".
  175. cases := []struct{ in, want string }{
  176. {"2025-12-31T23:59:59Z", "2025-12-31T23:59:59Z"},
  177. {"2030-01-02T03:04:05Z", "2030-01-02T03:04:05Z"},
  178. {"2025-06-30T12:00:00+02:00", "2025-06-30T10:00:00Z"},
  179. }
  180. for _, tc := range cases {
  181. sink := &captured{}
  182. srv := newServer(t, http.StatusCreated, createResp, sink)
  183. raw := fmt.Sprintf(`apiVersion: generators.external-secrets.io/v1alpha1
  184. kind: GitlabDeployToken
  185. spec:
  186. url: %q
  187. projectID: "1"
  188. name: "eso-token"
  189. scopes:
  190. - read_repository
  191. expiresAt: %q
  192. auth:
  193. token:
  194. secretRef:
  195. name: %q
  196. key: %q
  197. `, srv.URL, tc.in, testSecret, testKey)
  198. g := &Generator{httpClient: srv.Client()}
  199. _, _, err := g.generate(context.Background(), &apiextensions.JSON{Raw: []byte(raw)}, newKube(), testNamespace)
  200. require.NoError(t, err)
  201. assert.Equal(t, tc.want, sink.body["expires_at"])
  202. srv.Close()
  203. }
  204. })
  205. t.Run("optional fields omitted are not sent", func(t *testing.T) {
  206. sink := &captured{}
  207. srv := newServer(t, http.StatusCreated, createResp, sink)
  208. defer srv.Close()
  209. raw := fmt.Sprintf(`apiVersion: generators.external-secrets.io/v1alpha1
  210. kind: GitlabDeployToken
  211. spec:
  212. url: %q
  213. projectID: "1"
  214. name: "eso-token"
  215. scopes:
  216. - read_repository
  217. auth:
  218. token:
  219. secretRef:
  220. name: %q
  221. key: %q
  222. `, srv.URL, testSecret, testKey)
  223. g := &Generator{httpClient: srv.Client()}
  224. _, _, err := g.generate(context.Background(), &apiextensions.JSON{Raw: []byte(raw)}, newKube(), testNamespace)
  225. require.NoError(t, err)
  226. _, hasUser := sink.body["username"]
  227. assert.False(t, hasUser, "username must be omitted from the request when unset")
  228. _, hasExp := sink.body["expires_at"]
  229. assert.False(t, hasExp, "expires_at must be omitted from the request when unset")
  230. })
  231. }
  232. func TestCleanup(t *testing.T) {
  233. t.Run("nil state is a no-op", func(t *testing.T) {
  234. g := &Generator{}
  235. require.NoError(t, g.cleanup(context.Background(), specJSON(t, "https://gitlab.com", "1", ""), nil, newKube(), testNamespace))
  236. })
  237. t.Run("revokes project deploy token", func(t *testing.T) {
  238. sink := &captured{}
  239. srv := newServer(t, http.StatusNoContent, nil, sink)
  240. defer srv.Close()
  241. state := &apiextensions.JSON{Raw: []byte(`{"url":"` + srv.URL + `","projectID":"1","tokenID":42}`)}
  242. g := &Generator{httpClient: srv.Client()}
  243. require.NoError(t, g.cleanup(context.Background(), specJSON(t, srv.URL, "1", ""), state, newKube(), testNamespace))
  244. assert.Equal(t, http.MethodDelete, sink.method)
  245. assert.Equal(t, "/api/v4/projects/1/deploy_tokens/42", sink.path)
  246. assert.Equal(t, "glpat-secret-access-token", sink.privateTok)
  247. })
  248. t.Run("idempotent on 404", func(t *testing.T) {
  249. sink := &captured{}
  250. srv := newServer(t, http.StatusNotFound, []byte(`{"message":"404 Not Found"}`), sink)
  251. defer srv.Close()
  252. state := &apiextensions.JSON{Raw: []byte(`{"url":"` + srv.URL + `","groupID":"7","tokenID":99}`)}
  253. g := &Generator{httpClient: srv.Client()}
  254. require.NoError(t, g.cleanup(context.Background(), specJSON(t, srv.URL, "", "7"), state, newKube(), testNamespace))
  255. assert.Equal(t, "/api/v4/groups/7/deploy_tokens/99", sink.path)
  256. })
  257. t.Run("error on 500", func(t *testing.T) {
  258. sink := &captured{}
  259. srv := newServer(t, http.StatusInternalServerError, []byte(`{"message":"boom"}`), sink)
  260. defer srv.Close()
  261. state := &apiextensions.JSON{Raw: []byte(`{"url":"` + srv.URL + `","projectID":"1","tokenID":42}`)}
  262. g := &Generator{httpClient: srv.Client()}
  263. require.ErrorContains(t, g.cleanup(context.Background(), specJSON(t, srv.URL, "1", ""), state, newKube(), testNamespace), "boom")
  264. })
  265. }
  266. func TestDeployTokensURL(t *testing.T) {
  267. tests := []struct {
  268. name string
  269. spec genv1alpha1.GitlabDeployTokenSpec
  270. want string
  271. wantErr bool
  272. }{
  273. {name: "project default url", spec: genv1alpha1.GitlabDeployTokenSpec{ProjectID: "10"}, want: "https://gitlab.com/api/v4/projects/10/deploy_tokens"},
  274. {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"},
  275. {name: "path project encoded", spec: genv1alpha1.GitlabDeployTokenSpec{ProjectID: "grp/proj"}, want: "https://gitlab.com/api/v4/projects/grp%2Fproj/deploy_tokens"},
  276. {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"},
  277. {
  278. name: "multiple trailing slashes trimmed",
  279. spec: genv1alpha1.GitlabDeployTokenSpec{URL: "https://gl.example.com///", GroupID: "5"},
  280. want: "https://gl.example.com/api/v4/groups/5/deploy_tokens",
  281. },
  282. {name: "both set", spec: genv1alpha1.GitlabDeployTokenSpec{ProjectID: "1", GroupID: "2"}, wantErr: true},
  283. {name: "neither set", spec: genv1alpha1.GitlabDeployTokenSpec{}, wantErr: true},
  284. }
  285. for _, tt := range tests {
  286. t.Run(tt.name, func(t *testing.T) {
  287. got, err := deployTokensURL(&tt.spec)
  288. if tt.wantErr {
  289. require.Error(t, err)
  290. return
  291. }
  292. require.NoError(t, err)
  293. assert.Equal(t, tt.want, got)
  294. })
  295. }
  296. }