client_test.go 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435
  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. // /*
  14. // Licensed under the Apache License, Version 2.0 (the "License");
  15. // you may not use this file except in compliance with the License.
  16. // You may obtain a copy of the License at
  17. //
  18. // https://www.apache.org/licenses/LICENSE-2.0
  19. //
  20. // Unless required by applicable law or agreed to in writing, software
  21. // distributed under the License is distributed on an "AS IS" BASIS,
  22. // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  23. // See the License for the specific language governing permissions and
  24. // limitations under the License.
  25. // */
  26. package github
  27. import (
  28. "context"
  29. "crypto/rand"
  30. "crypto/rsa"
  31. "crypto/x509"
  32. "encoding/pem"
  33. "errors"
  34. "testing"
  35. "github.com/bradleyfalzon/ghinstallation/v2"
  36. github "github.com/google/go-github/v56/github"
  37. "github.com/stretchr/testify/assert"
  38. "github.com/stretchr/testify/require"
  39. corev1 "k8s.io/api/core/v1"
  40. metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
  41. "k8s.io/apimachinery/pkg/runtime"
  42. "k8s.io/utils/ptr"
  43. "sigs.k8s.io/controller-runtime/pkg/client/fake"
  44. esv1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1"
  45. esv1alpha1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1alpha1"
  46. esmeta "github.com/external-secrets/external-secrets/apis/meta/v1"
  47. )
  48. type getSecretFn func(ctx context.Context, ref esv1.PushSecretRemoteRef) (*github.Secret, *github.Response, error)
  49. func withGetSecretFn(secret *github.Secret, response *github.Response, err error) getSecretFn {
  50. return func(_ context.Context, _ esv1.PushSecretRemoteRef) (*github.Secret, *github.Response, error) {
  51. return secret, response, err
  52. }
  53. }
  54. type getPublicKeyFn func(ctx context.Context) (*github.PublicKey, *github.Response, error)
  55. func withGetPublicKeyFn(key *github.PublicKey, response *github.Response, err error) getPublicKeyFn {
  56. return func(_ context.Context) (*github.PublicKey, *github.Response, error) {
  57. return key, response, err
  58. }
  59. }
  60. type createOrUpdateSecretFn func(ctx context.Context, encryptedSecret *github.EncryptedSecret) (*github.Response, error)
  61. func withCreateOrUpdateSecretFn(response *github.Response, err error) createOrUpdateSecretFn {
  62. return func(_ context.Context, _ *github.EncryptedSecret) (*github.Response, error) {
  63. return response, err
  64. }
  65. }
  66. func TestSecretExists(t *testing.T) {
  67. type testCase struct {
  68. name string
  69. prov *esv1.GithubProvider
  70. remoteRef esv1.PushSecretData
  71. getSecretFn getSecretFn
  72. wantErr error
  73. exists bool
  74. }
  75. tests := []testCase{
  76. {
  77. name: "getSecret fail",
  78. getSecretFn: withGetSecretFn(nil, nil, errors.New("boom")),
  79. exists: false,
  80. wantErr: errors.New("error fetching secret"),
  81. },
  82. {
  83. name: "no secret",
  84. getSecretFn: withGetSecretFn(nil, nil, nil),
  85. exists: false,
  86. },
  87. {
  88. name: "with secret",
  89. getSecretFn: withGetSecretFn(&github.Secret{}, nil, nil),
  90. exists: true,
  91. },
  92. }
  93. for _, test := range tests {
  94. t.Run(test.name, func(t *testing.T) {
  95. g := Client{
  96. provider: test.prov,
  97. }
  98. g.getSecretFn = test.getSecretFn
  99. ok, err := g.SecretExists(context.TODO(), test.remoteRef)
  100. assert.Equal(t, test.exists, ok)
  101. if test.wantErr == nil {
  102. assert.NoError(t, err)
  103. } else {
  104. assert.ErrorContains(t, err, test.wantErr.Error())
  105. }
  106. })
  107. }
  108. }
  109. func TestPushSecret(t *testing.T) {
  110. type testCase struct {
  111. name string
  112. prov *esv1.GithubProvider
  113. secret *corev1.Secret
  114. remoteRef esv1.PushSecretData
  115. getSecretFn getSecretFn
  116. getPublicKeyFn getPublicKeyFn
  117. createOrUpdateFn createOrUpdateSecretFn
  118. wantErr error
  119. }
  120. tests := []testCase{
  121. {
  122. name: "failGetSecretFn",
  123. getSecretFn: withGetSecretFn(nil, nil, errors.New("boom")),
  124. wantErr: errors.New("error fetching secret"),
  125. },
  126. {
  127. name: "failGetPublicKey",
  128. getSecretFn: withGetSecretFn(&github.Secret{
  129. Name: "foo",
  130. }, nil, nil),
  131. getPublicKeyFn: withGetPublicKeyFn(nil, nil, errors.New("boom")),
  132. wantErr: errors.New("error fetching public key"),
  133. },
  134. {
  135. name: "failDecodeKey",
  136. getSecretFn: withGetSecretFn(&github.Secret{
  137. Name: "foo",
  138. }, nil, nil),
  139. getPublicKeyFn: withGetPublicKeyFn(&github.PublicKey{
  140. Key: ptr.To("broken"),
  141. KeyID: ptr.To("123"),
  142. }, nil, nil),
  143. wantErr: errors.New("unable to decode public key"),
  144. },
  145. {
  146. name: "failSecretData",
  147. getSecretFn: withGetSecretFn(&github.Secret{
  148. Name: "foo",
  149. }, nil, nil),
  150. getPublicKeyFn: withGetPublicKeyFn(&github.PublicKey{
  151. Key: ptr.To("Cg=="),
  152. KeyID: ptr.To("123"),
  153. }, nil, nil),
  154. secret: &corev1.Secret{
  155. Data: map[string][]byte{
  156. "foo": []byte("bar"),
  157. },
  158. },
  159. remoteRef: esv1alpha1.PushSecretData{
  160. Match: esv1alpha1.PushSecretMatch{
  161. SecretKey: "bar",
  162. },
  163. },
  164. wantErr: errors.New("not found in secret"),
  165. },
  166. {
  167. name: "failSecretData",
  168. getSecretFn: withGetSecretFn(&github.Secret{
  169. Name: "foo",
  170. }, nil, nil),
  171. getPublicKeyFn: withGetPublicKeyFn(&github.PublicKey{
  172. Key: ptr.To("Zm9vYmFyCg=="),
  173. KeyID: ptr.To("123"),
  174. }, nil, nil),
  175. secret: &corev1.Secret{
  176. Data: map[string][]byte{
  177. "foo": []byte("bingg"),
  178. },
  179. },
  180. remoteRef: esv1alpha1.PushSecretData{
  181. Match: esv1alpha1.PushSecretMatch{
  182. SecretKey: "foo",
  183. },
  184. },
  185. createOrUpdateFn: withCreateOrUpdateSecretFn(nil, errors.New("boom")),
  186. wantErr: errors.New("failed to create secret"),
  187. },
  188. {
  189. name: "Success",
  190. getSecretFn: withGetSecretFn(&github.Secret{
  191. Name: "foo",
  192. }, nil, nil),
  193. getPublicKeyFn: withGetPublicKeyFn(&github.PublicKey{
  194. Key: ptr.To("Zm9vYmFyCg=="),
  195. KeyID: ptr.To("123"),
  196. }, nil, nil),
  197. secret: &corev1.Secret{
  198. Data: map[string][]byte{
  199. "foo": []byte("bingg"),
  200. },
  201. },
  202. remoteRef: esv1alpha1.PushSecretData{
  203. Match: esv1alpha1.PushSecretMatch{
  204. SecretKey: "foo",
  205. },
  206. },
  207. createOrUpdateFn: withCreateOrUpdateSecretFn(nil, nil),
  208. },
  209. }
  210. for _, test := range tests {
  211. t.Run(test.name, func(t *testing.T) {
  212. g := Client{
  213. provider: test.prov,
  214. }
  215. g.getSecretFn = test.getSecretFn
  216. g.getPublicKeyFn = test.getPublicKeyFn
  217. g.createOrUpdateFn = test.createOrUpdateFn
  218. err := g.PushSecret(context.TODO(), test.secret, test.remoteRef)
  219. if test.wantErr == nil {
  220. assert.NoError(t, err)
  221. } else {
  222. assert.ErrorContains(t, err, test.wantErr.Error())
  223. }
  224. })
  225. }
  226. }
  227. // generateTestPrivateKey generates a PEM-encoded RSA private key for testing.
  228. func generateTestPrivateKey() (string, error) {
  229. privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
  230. if err != nil {
  231. return "", err
  232. }
  233. privateKeyBytes := x509.MarshalPKCS1PrivateKey(privateKey)
  234. privateKeyPEM := pem.EncodeToMemory(&pem.Block{
  235. Type: "RSA PRIVATE KEY",
  236. Bytes: privateKeyBytes,
  237. })
  238. return string(privateKeyPEM), nil
  239. }
  240. func TestAuthWithPrivateKey(t *testing.T) {
  241. // Generate a valid private key for testing
  242. privateKeyPEM, err := generateTestPrivateKey()
  243. require.NoError(t, err)
  244. tests := []struct {
  245. name string
  246. provider *esv1.GithubProvider
  247. secret *corev1.Secret
  248. wantErr bool
  249. wantBaseURL string
  250. wantUploadURL string
  251. checkTransport bool
  252. }{
  253. {
  254. name: "GitHub.com (default)",
  255. provider: &esv1.GithubProvider{
  256. AppID: 1,
  257. InstallationID: 1,
  258. URL: "https://github.com/",
  259. Auth: esv1.GithubAppAuth{
  260. PrivateKey: esmeta.SecretKeySelector{
  261. Name: "test-secret",
  262. Key: "private-key",
  263. },
  264. },
  265. },
  266. secret: &corev1.Secret{
  267. ObjectMeta: metav1.ObjectMeta{
  268. Name: "test-secret",
  269. Namespace: "default",
  270. },
  271. Data: map[string][]byte{
  272. "private-key": []byte(privateKeyPEM),
  273. },
  274. },
  275. wantErr: false,
  276. wantBaseURL: "https://api.github.com/",
  277. checkTransport: false, // For default GitHub, we don't modify transport
  278. },
  279. {
  280. name: "GitHub Enterprise with custom URL",
  281. provider: &esv1.GithubProvider{
  282. AppID: 1,
  283. InstallationID: 1,
  284. URL: "https://github.enterprise.com/",
  285. Auth: esv1.GithubAppAuth{
  286. PrivateKey: esmeta.SecretKeySelector{
  287. Name: "test-secret",
  288. Key: "private-key",
  289. },
  290. },
  291. },
  292. secret: &corev1.Secret{
  293. ObjectMeta: metav1.ObjectMeta{
  294. Name: "test-secret",
  295. Namespace: "default",
  296. },
  297. Data: map[string][]byte{
  298. "private-key": []byte(privateKeyPEM),
  299. },
  300. },
  301. wantErr: false,
  302. wantBaseURL: "https://github.enterprise.com/api/v3/",
  303. wantUploadURL: "https://github.enterprise.com/api/uploads/",
  304. checkTransport: true,
  305. },
  306. {
  307. name: "GitHub Enterprise with separate upload URL",
  308. provider: &esv1.GithubProvider{
  309. AppID: 1,
  310. InstallationID: 1,
  311. URL: "https://github.enterprise.com/api/v3",
  312. UploadURL: "https://uploads.github.enterprise.com/api/v3",
  313. Auth: esv1.GithubAppAuth{
  314. PrivateKey: esmeta.SecretKeySelector{
  315. Name: "test-secret",
  316. Key: "private-key",
  317. },
  318. },
  319. },
  320. secret: &corev1.Secret{
  321. ObjectMeta: metav1.ObjectMeta{
  322. Name: "test-secret",
  323. Namespace: "default",
  324. },
  325. Data: map[string][]byte{
  326. "private-key": []byte(privateKeyPEM),
  327. },
  328. },
  329. wantErr: false,
  330. wantBaseURL: "https://github.enterprise.com/api/v3/",
  331. wantUploadURL: "https://uploads.github.enterprise.com/api/v3/api/uploads/",
  332. checkTransport: true,
  333. },
  334. {
  335. name: "Empty URL (default to github.com)",
  336. provider: &esv1.GithubProvider{
  337. AppID: 1,
  338. InstallationID: 1,
  339. URL: "",
  340. Auth: esv1.GithubAppAuth{
  341. PrivateKey: esmeta.SecretKeySelector{
  342. Name: "test-secret",
  343. Key: "private-key",
  344. },
  345. },
  346. },
  347. secret: &corev1.Secret{
  348. ObjectMeta: metav1.ObjectMeta{
  349. Name: "test-secret",
  350. Namespace: "default",
  351. },
  352. Data: map[string][]byte{
  353. "private-key": []byte(privateKeyPEM),
  354. },
  355. },
  356. wantErr: false,
  357. wantBaseURL: "https://api.github.com/",
  358. checkTransport: false,
  359. },
  360. }
  361. for _, tt := range tests {
  362. t.Run(tt.name, func(t *testing.T) {
  363. // Create fake Kubernetes client with the secret
  364. scheme := runtime.NewScheme()
  365. _ = corev1.AddToScheme(scheme)
  366. fakeClient := fake.NewClientBuilder().
  367. WithScheme(scheme).
  368. WithObjects(tt.secret).
  369. Build()
  370. // Create the GitHub client
  371. client := &Client{
  372. crClient: fakeClient,
  373. provider: tt.provider,
  374. namespace: "default",
  375. storeKind: "SecretStore",
  376. }
  377. // Call AuthWithPrivateKey
  378. ghClient, err := client.AuthWithPrivateKey(context.Background())
  379. if tt.wantErr {
  380. assert.Error(t, err)
  381. return
  382. }
  383. require.NoError(t, err)
  384. require.NotNil(t, ghClient)
  385. // Verify the BaseURL is set correctly
  386. assert.Equal(t, tt.wantBaseURL, ghClient.BaseURL.String())
  387. // If UploadURL is specified, verify it
  388. if tt.wantUploadURL != "" {
  389. assert.Equal(t, tt.wantUploadURL, ghClient.UploadURL.String())
  390. }
  391. // For GitHub Enterprise, verify the transport BaseURL is also set
  392. if tt.checkTransport {
  393. transport := ghClient.Client().Transport
  394. require.NotNil(t, transport)
  395. // Type assert to ghinstallation.Transport
  396. ghTransport, ok := transport.(*ghinstallation.Transport)
  397. require.True(t, ok, "Expected transport to be *ghinstallation.Transport")
  398. // Verify the BaseURL is set on the transport
  399. assert.Equal(t, tt.wantBaseURL, ghTransport.BaseURL,
  400. "Transport BaseURL should match the enterprise URL")
  401. }
  402. })
  403. }
  404. }