client_test.go 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568
  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. "sigs.k8s.io/controller-runtime/pkg/client/fake"
  43. esv1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1"
  44. esv1alpha1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1alpha1"
  45. esmeta "github.com/external-secrets/external-secrets/apis/meta/v1"
  46. )
  47. type getSecretFn func(ctx context.Context, ref esv1.PushSecretRemoteRef) (*github.Secret, *github.Response, error)
  48. func withGetSecretFn(secret *github.Secret, response *github.Response, err error) getSecretFn {
  49. return func(_ context.Context, _ esv1.PushSecretRemoteRef) (*github.Secret, *github.Response, error) {
  50. return secret, response, err
  51. }
  52. }
  53. type getPublicKeyFn func(ctx context.Context) (*github.PublicKey, *github.Response, error)
  54. func withGetPublicKeyFn(key *github.PublicKey, response *github.Response, err error) getPublicKeyFn {
  55. return func(_ context.Context) (*github.PublicKey, *github.Response, error) {
  56. return key, response, err
  57. }
  58. }
  59. type createOrUpdateSecretFn func(ctx context.Context, encryptedSecret *github.EncryptedSecret) (*github.Response, error)
  60. func withCreateOrUpdateSecretFn(response *github.Response, err error) createOrUpdateSecretFn {
  61. return func(_ context.Context, _ *github.EncryptedSecret) (*github.Response, error) {
  62. return response, err
  63. }
  64. }
  65. func TestSecretExists(t *testing.T) {
  66. type testCase struct {
  67. name string
  68. prov *esv1.GithubProvider
  69. remoteRef esv1.PushSecretData
  70. getSecretFn getSecretFn
  71. wantErr error
  72. exists bool
  73. }
  74. tests := []testCase{
  75. {
  76. name: "getSecret fail",
  77. getSecretFn: withGetSecretFn(nil, nil, errors.New("boom")),
  78. exists: false,
  79. wantErr: errors.New("error fetching secret"),
  80. },
  81. {
  82. name: "no secret",
  83. getSecretFn: withGetSecretFn(nil, nil, nil),
  84. exists: false,
  85. },
  86. {
  87. name: "with secret",
  88. getSecretFn: withGetSecretFn(&github.Secret{}, nil, nil),
  89. exists: true,
  90. },
  91. }
  92. for _, test := range tests {
  93. t.Run(test.name, func(t *testing.T) {
  94. g := Client{
  95. provider: test.prov,
  96. }
  97. g.getSecretFn = test.getSecretFn
  98. ok, err := g.SecretExists(context.TODO(), test.remoteRef)
  99. assert.Equal(t, test.exists, ok)
  100. if test.wantErr == nil {
  101. assert.NoError(t, err)
  102. } else {
  103. assert.ErrorContains(t, err, test.wantErr.Error())
  104. }
  105. })
  106. }
  107. }
  108. func TestPushSecret(t *testing.T) {
  109. type testCase struct {
  110. name string
  111. prov *esv1.GithubProvider
  112. secret *corev1.Secret
  113. remoteRef esv1.PushSecretData
  114. getSecretFn getSecretFn
  115. getPublicKeyFn getPublicKeyFn
  116. createOrUpdateFn createOrUpdateSecretFn
  117. wantErr error
  118. }
  119. tests := []testCase{
  120. {
  121. name: "failGetSecretFn",
  122. getSecretFn: withGetSecretFn(nil, nil, errors.New("boom")),
  123. wantErr: errors.New("error fetching secret"),
  124. },
  125. {
  126. name: "failGetPublicKey",
  127. getSecretFn: withGetSecretFn(&github.Secret{
  128. Name: "foo",
  129. }, nil, nil),
  130. getPublicKeyFn: withGetPublicKeyFn(nil, nil, errors.New("boom")),
  131. wantErr: errors.New("error fetching public key"),
  132. },
  133. {
  134. name: "failDecodeKey",
  135. getSecretFn: withGetSecretFn(&github.Secret{
  136. Name: "foo",
  137. }, nil, nil),
  138. getPublicKeyFn: withGetPublicKeyFn(&github.PublicKey{
  139. Key: new("broken"),
  140. KeyID: new("123"),
  141. }, nil, nil),
  142. wantErr: errors.New("unable to decode public key"),
  143. },
  144. {
  145. name: "failSecretData",
  146. getSecretFn: withGetSecretFn(&github.Secret{
  147. Name: "foo",
  148. }, nil, nil),
  149. getPublicKeyFn: withGetPublicKeyFn(&github.PublicKey{
  150. Key: new("Cg=="),
  151. KeyID: new("123"),
  152. }, nil, nil),
  153. secret: &corev1.Secret{
  154. Data: map[string][]byte{
  155. "foo": []byte("bar"),
  156. },
  157. },
  158. remoteRef: esv1alpha1.PushSecretData{
  159. Match: esv1alpha1.PushSecretMatch{
  160. SecretKey: "bar",
  161. },
  162. },
  163. wantErr: errors.New("not found in secret"),
  164. },
  165. {
  166. name: "failSecretData",
  167. getSecretFn: withGetSecretFn(&github.Secret{
  168. Name: "foo",
  169. }, nil, nil),
  170. getPublicKeyFn: withGetPublicKeyFn(&github.PublicKey{
  171. Key: new("Zm9vYmFyCg=="),
  172. KeyID: new("123"),
  173. }, nil, nil),
  174. secret: &corev1.Secret{
  175. Data: map[string][]byte{
  176. "foo": []byte("bingg"),
  177. },
  178. },
  179. remoteRef: esv1alpha1.PushSecretData{
  180. Match: esv1alpha1.PushSecretMatch{
  181. SecretKey: "foo",
  182. },
  183. },
  184. createOrUpdateFn: withCreateOrUpdateSecretFn(nil, errors.New("boom")),
  185. wantErr: errors.New("failed to create secret"),
  186. },
  187. {
  188. name: "Success",
  189. getSecretFn: withGetSecretFn(&github.Secret{
  190. Name: "foo",
  191. }, nil, nil),
  192. getPublicKeyFn: withGetPublicKeyFn(&github.PublicKey{
  193. Key: new("Zm9vYmFyCg=="),
  194. KeyID: new("123"),
  195. }, nil, nil),
  196. secret: &corev1.Secret{
  197. Data: map[string][]byte{
  198. "foo": []byte("bingg"),
  199. },
  200. },
  201. remoteRef: esv1alpha1.PushSecretData{
  202. Match: esv1alpha1.PushSecretMatch{
  203. SecretKey: "foo",
  204. },
  205. },
  206. createOrUpdateFn: withCreateOrUpdateSecretFn(nil, nil),
  207. },
  208. }
  209. for _, test := range tests {
  210. t.Run(test.name, func(t *testing.T) {
  211. g := Client{
  212. provider: test.prov,
  213. }
  214. g.getSecretFn = test.getSecretFn
  215. g.getPublicKeyFn = test.getPublicKeyFn
  216. g.createOrUpdateFn = test.createOrUpdateFn
  217. err := g.PushSecret(context.TODO(), test.secret, test.remoteRef)
  218. if test.wantErr == nil {
  219. assert.NoError(t, err)
  220. } else {
  221. assert.ErrorContains(t, err, test.wantErr.Error())
  222. }
  223. })
  224. }
  225. }
  226. func TestPushSecretSelectedRepos(t *testing.T) {
  227. validKey := withGetPublicKeyFn(&github.PublicKey{
  228. Key: new("Zm9vYmFyCg=="),
  229. KeyID: new("123"),
  230. }, nil, nil)
  231. secret := &corev1.Secret{Data: map[string][]byte{"foo": []byte("bingg")}}
  232. ref := esv1alpha1.PushSecretData{
  233. Match: esv1alpha1.PushSecretMatch{SecretKey: "foo"},
  234. }
  235. t.Run("selected visibility preserves existing repositories", func(t *testing.T) {
  236. var pushed *github.EncryptedSecret
  237. g := Client{provider: &esv1.GithubProvider{}}
  238. g.getSecretFn = withGetSecretFn(&github.Secret{Name: "foo", Visibility: "selected"}, nil, nil)
  239. g.getPublicKeyFn = validKey
  240. g.listSelectedReposFn = func(_ context.Context, _ string) (github.SelectedRepoIDs, error) {
  241. return github.SelectedRepoIDs{1, 2, 3}, nil
  242. }
  243. g.createOrUpdateFn = func(_ context.Context, es *github.EncryptedSecret) (*github.Response, error) {
  244. pushed = es
  245. return nil, nil
  246. }
  247. require.NoError(t, g.PushSecret(context.TODO(), secret, ref))
  248. require.NotNil(t, pushed)
  249. assert.Equal(t, "selected", pushed.Visibility)
  250. assert.Equal(t, github.SelectedRepoIDs{1, 2, 3}, pushed.SelectedRepositoryIDs)
  251. })
  252. t.Run("list selected repos error is propagated", func(t *testing.T) {
  253. g := Client{provider: &esv1.GithubProvider{}}
  254. g.getSecretFn = withGetSecretFn(&github.Secret{Name: "foo", Visibility: "selected"}, nil, nil)
  255. g.getPublicKeyFn = validKey
  256. g.listSelectedReposFn = func(_ context.Context, _ string) (github.SelectedRepoIDs, error) {
  257. return nil, errors.New("boom")
  258. }
  259. g.createOrUpdateFn = withCreateOrUpdateSecretFn(nil, nil)
  260. err := g.PushSecret(context.TODO(), secret, ref)
  261. assert.ErrorContains(t, err, "failed to list selected repositories")
  262. })
  263. t.Run("non-selected visibility does not set repositories", func(t *testing.T) {
  264. var pushed *github.EncryptedSecret
  265. called := false
  266. g := Client{provider: &esv1.GithubProvider{}}
  267. g.getSecretFn = withGetSecretFn(&github.Secret{Name: "foo", Visibility: "all"}, nil, nil)
  268. g.getPublicKeyFn = validKey
  269. g.listSelectedReposFn = func(_ context.Context, _ string) (github.SelectedRepoIDs, error) {
  270. called = true
  271. return github.SelectedRepoIDs{9}, nil
  272. }
  273. g.createOrUpdateFn = func(_ context.Context, es *github.EncryptedSecret) (*github.Response, error) {
  274. pushed = es
  275. return nil, nil
  276. }
  277. require.NoError(t, g.PushSecret(context.TODO(), secret, ref))
  278. require.NotNil(t, pushed)
  279. assert.False(t, called)
  280. assert.Equal(t, "all", pushed.Visibility)
  281. assert.Nil(t, pushed.SelectedRepositoryIDs)
  282. })
  283. }
  284. func TestResolveOrgSecretVisibility(t *testing.T) {
  285. ptr := func(s string) *string { return &s }
  286. tests := []struct {
  287. name string
  288. nilProvider bool
  289. providerViz string
  290. existing *github.Secret
  291. want string
  292. }{
  293. {
  294. name: "nil provider, no existing secret — defaults to all",
  295. nilProvider: true,
  296. existing: nil,
  297. want: "all",
  298. },
  299. {
  300. name: "nil provider, existing secret has private — preserves private",
  301. nilProvider: true,
  302. existing: &github.Secret{Visibility: *ptr("private")},
  303. want: "private",
  304. },
  305. {
  306. name: "provider unset, no existing secret — defaults to all",
  307. providerViz: "",
  308. existing: nil,
  309. want: "all",
  310. },
  311. {
  312. name: "provider unset, existing secret has all — preserves all",
  313. providerViz: "",
  314. existing: &github.Secret{Visibility: *ptr("all")},
  315. want: "all",
  316. },
  317. {
  318. name: "provider unset, existing secret has private — preserves private",
  319. providerViz: "",
  320. existing: &github.Secret{Visibility: *ptr("private")},
  321. want: "private",
  322. },
  323. {
  324. name: "provider set to private, no existing secret",
  325. providerViz: "private",
  326. existing: nil,
  327. want: "private",
  328. },
  329. {
  330. name: "provider set to private, existing secret has all — provider wins",
  331. providerViz: "private",
  332. existing: &github.Secret{Visibility: *ptr("all")},
  333. want: "private",
  334. },
  335. {
  336. name: "provider set to all, existing secret has private — provider wins",
  337. providerViz: "all",
  338. existing: &github.Secret{Visibility: *ptr("private")},
  339. want: "all",
  340. },
  341. }
  342. for _, tt := range tests {
  343. t.Run(tt.name, func(t *testing.T) {
  344. g := &Client{}
  345. if !tt.nilProvider {
  346. g.provider = &esv1.GithubProvider{
  347. OrgSecretVisibility: tt.providerViz,
  348. }
  349. }
  350. got := g.resolveOrgSecretVisibility(tt.existing)
  351. assert.Equal(t, tt.want, got)
  352. })
  353. }
  354. }
  355. // generateTestPrivateKey generates a PEM-encoded RSA private key for testing.
  356. func generateTestPrivateKey() (string, error) {
  357. privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
  358. if err != nil {
  359. return "", err
  360. }
  361. privateKeyBytes := x509.MarshalPKCS1PrivateKey(privateKey)
  362. privateKeyPEM := pem.EncodeToMemory(&pem.Block{
  363. Type: "RSA PRIVATE KEY",
  364. Bytes: privateKeyBytes,
  365. })
  366. return string(privateKeyPEM), nil
  367. }
  368. func TestAuthWithPrivateKey(t *testing.T) {
  369. // Generate a valid private key for testing
  370. privateKeyPEM, err := generateTestPrivateKey()
  371. require.NoError(t, err)
  372. tests := []struct {
  373. name string
  374. provider *esv1.GithubProvider
  375. secret *corev1.Secret
  376. wantErr bool
  377. wantBaseURL string
  378. wantUploadURL string
  379. checkTransport bool
  380. }{
  381. {
  382. name: "GitHub.com (default)",
  383. provider: &esv1.GithubProvider{
  384. AppID: 1,
  385. InstallationID: 1,
  386. URL: "https://github.com/",
  387. Auth: esv1.GithubAppAuth{
  388. PrivateKey: esmeta.SecretKeySelector{
  389. Name: "test-secret",
  390. Key: "private-key",
  391. },
  392. },
  393. },
  394. secret: &corev1.Secret{
  395. ObjectMeta: metav1.ObjectMeta{
  396. Name: "test-secret",
  397. Namespace: "default",
  398. },
  399. Data: map[string][]byte{
  400. "private-key": []byte(privateKeyPEM),
  401. },
  402. },
  403. wantErr: false,
  404. wantBaseURL: "https://api.github.com/",
  405. checkTransport: false, // For default GitHub, we don't modify transport
  406. },
  407. {
  408. name: "GitHub Enterprise with custom URL",
  409. provider: &esv1.GithubProvider{
  410. AppID: 1,
  411. InstallationID: 1,
  412. URL: "https://github.enterprise.com/",
  413. Auth: esv1.GithubAppAuth{
  414. PrivateKey: esmeta.SecretKeySelector{
  415. Name: "test-secret",
  416. Key: "private-key",
  417. },
  418. },
  419. },
  420. secret: &corev1.Secret{
  421. ObjectMeta: metav1.ObjectMeta{
  422. Name: "test-secret",
  423. Namespace: "default",
  424. },
  425. Data: map[string][]byte{
  426. "private-key": []byte(privateKeyPEM),
  427. },
  428. },
  429. wantErr: false,
  430. wantBaseURL: "https://github.enterprise.com/api/v3/",
  431. wantUploadURL: "https://github.enterprise.com/api/uploads/",
  432. checkTransport: true,
  433. },
  434. {
  435. name: "GitHub Enterprise with separate upload URL",
  436. provider: &esv1.GithubProvider{
  437. AppID: 1,
  438. InstallationID: 1,
  439. URL: "https://github.enterprise.com/api/v3",
  440. UploadURL: "https://uploads.github.enterprise.com/api/v3",
  441. Auth: esv1.GithubAppAuth{
  442. PrivateKey: esmeta.SecretKeySelector{
  443. Name: "test-secret",
  444. Key: "private-key",
  445. },
  446. },
  447. },
  448. secret: &corev1.Secret{
  449. ObjectMeta: metav1.ObjectMeta{
  450. Name: "test-secret",
  451. Namespace: "default",
  452. },
  453. Data: map[string][]byte{
  454. "private-key": []byte(privateKeyPEM),
  455. },
  456. },
  457. wantErr: false,
  458. wantBaseURL: "https://github.enterprise.com/api/v3/",
  459. wantUploadURL: "https://uploads.github.enterprise.com/api/v3/api/uploads/",
  460. checkTransport: true,
  461. },
  462. {
  463. name: "Empty URL (default to github.com)",
  464. provider: &esv1.GithubProvider{
  465. AppID: 1,
  466. InstallationID: 1,
  467. URL: "",
  468. Auth: esv1.GithubAppAuth{
  469. PrivateKey: esmeta.SecretKeySelector{
  470. Name: "test-secret",
  471. Key: "private-key",
  472. },
  473. },
  474. },
  475. secret: &corev1.Secret{
  476. ObjectMeta: metav1.ObjectMeta{
  477. Name: "test-secret",
  478. Namespace: "default",
  479. },
  480. Data: map[string][]byte{
  481. "private-key": []byte(privateKeyPEM),
  482. },
  483. },
  484. wantErr: false,
  485. wantBaseURL: "https://api.github.com/",
  486. checkTransport: false,
  487. },
  488. }
  489. for _, tt := range tests {
  490. t.Run(tt.name, func(t *testing.T) {
  491. // Create fake Kubernetes client with the secret
  492. scheme := runtime.NewScheme()
  493. _ = corev1.AddToScheme(scheme)
  494. fakeClient := fake.NewClientBuilder().
  495. WithScheme(scheme).
  496. WithObjects(tt.secret).
  497. Build()
  498. // Create the GitHub client
  499. client := &Client{
  500. crClient: fakeClient,
  501. provider: tt.provider,
  502. namespace: "default",
  503. storeKind: "SecretStore",
  504. }
  505. // Call AuthWithPrivateKey
  506. ghClient, err := client.AuthWithPrivateKey(context.Background())
  507. if tt.wantErr {
  508. assert.Error(t, err)
  509. return
  510. }
  511. require.NoError(t, err)
  512. require.NotNil(t, ghClient)
  513. // Verify the BaseURL is set correctly
  514. assert.Equal(t, tt.wantBaseURL, ghClient.BaseURL.String())
  515. // If UploadURL is specified, verify it
  516. if tt.wantUploadURL != "" {
  517. assert.Equal(t, tt.wantUploadURL, ghClient.UploadURL.String())
  518. }
  519. // For GitHub Enterprise, verify the transport BaseURL is also set
  520. if tt.checkTransport {
  521. transport := ghClient.Client().Transport
  522. require.NotNil(t, transport)
  523. // Type assert to ghinstallation.Transport
  524. ghTransport, ok := transport.(*ghinstallation.Transport)
  525. require.True(t, ok, "Expected transport to be *ghinstallation.Transport")
  526. // Verify the BaseURL is set on the transport
  527. assert.Equal(t, tt.wantBaseURL, ghTransport.BaseURL,
  528. "Transport BaseURL should match the enterprise URL")
  529. }
  530. })
  531. }
  532. }