client_test.go 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506
  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 TestResolveOrgSecretVisibility(t *testing.T) {
  227. ptr := func(s string) *string { return &s }
  228. tests := []struct {
  229. name string
  230. nilProvider bool
  231. providerViz string
  232. existing *github.Secret
  233. want string
  234. }{
  235. {
  236. name: "nil provider, no existing secret — defaults to all",
  237. nilProvider: true,
  238. existing: nil,
  239. want: "all",
  240. },
  241. {
  242. name: "nil provider, existing secret has private — preserves private",
  243. nilProvider: true,
  244. existing: &github.Secret{Visibility: *ptr("private")},
  245. want: "private",
  246. },
  247. {
  248. name: "provider unset, no existing secret — defaults to all",
  249. providerViz: "",
  250. existing: nil,
  251. want: "all",
  252. },
  253. {
  254. name: "provider unset, existing secret has all — preserves all",
  255. providerViz: "",
  256. existing: &github.Secret{Visibility: *ptr("all")},
  257. want: "all",
  258. },
  259. {
  260. name: "provider unset, existing secret has private — preserves private",
  261. providerViz: "",
  262. existing: &github.Secret{Visibility: *ptr("private")},
  263. want: "private",
  264. },
  265. {
  266. name: "provider set to private, no existing secret",
  267. providerViz: "private",
  268. existing: nil,
  269. want: "private",
  270. },
  271. {
  272. name: "provider set to private, existing secret has all — provider wins",
  273. providerViz: "private",
  274. existing: &github.Secret{Visibility: *ptr("all")},
  275. want: "private",
  276. },
  277. {
  278. name: "provider set to all, existing secret has private — provider wins",
  279. providerViz: "all",
  280. existing: &github.Secret{Visibility: *ptr("private")},
  281. want: "all",
  282. },
  283. }
  284. for _, tt := range tests {
  285. t.Run(tt.name, func(t *testing.T) {
  286. g := &Client{}
  287. if !tt.nilProvider {
  288. g.provider = &esv1.GithubProvider{
  289. OrgSecretVisibility: tt.providerViz,
  290. }
  291. }
  292. got := g.resolveOrgSecretVisibility(tt.existing)
  293. assert.Equal(t, tt.want, got)
  294. })
  295. }
  296. }
  297. // generateTestPrivateKey generates a PEM-encoded RSA private key for testing.
  298. func generateTestPrivateKey() (string, error) {
  299. privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
  300. if err != nil {
  301. return "", err
  302. }
  303. privateKeyBytes := x509.MarshalPKCS1PrivateKey(privateKey)
  304. privateKeyPEM := pem.EncodeToMemory(&pem.Block{
  305. Type: "RSA PRIVATE KEY",
  306. Bytes: privateKeyBytes,
  307. })
  308. return string(privateKeyPEM), nil
  309. }
  310. func TestAuthWithPrivateKey(t *testing.T) {
  311. // Generate a valid private key for testing
  312. privateKeyPEM, err := generateTestPrivateKey()
  313. require.NoError(t, err)
  314. tests := []struct {
  315. name string
  316. provider *esv1.GithubProvider
  317. secret *corev1.Secret
  318. wantErr bool
  319. wantBaseURL string
  320. wantUploadURL string
  321. checkTransport bool
  322. }{
  323. {
  324. name: "GitHub.com (default)",
  325. provider: &esv1.GithubProvider{
  326. AppID: 1,
  327. InstallationID: 1,
  328. URL: "https://github.com/",
  329. Auth: esv1.GithubAppAuth{
  330. PrivateKey: esmeta.SecretKeySelector{
  331. Name: "test-secret",
  332. Key: "private-key",
  333. },
  334. },
  335. },
  336. secret: &corev1.Secret{
  337. ObjectMeta: metav1.ObjectMeta{
  338. Name: "test-secret",
  339. Namespace: "default",
  340. },
  341. Data: map[string][]byte{
  342. "private-key": []byte(privateKeyPEM),
  343. },
  344. },
  345. wantErr: false,
  346. wantBaseURL: "https://api.github.com/",
  347. checkTransport: false, // For default GitHub, we don't modify transport
  348. },
  349. {
  350. name: "GitHub Enterprise with custom URL",
  351. provider: &esv1.GithubProvider{
  352. AppID: 1,
  353. InstallationID: 1,
  354. URL: "https://github.enterprise.com/",
  355. Auth: esv1.GithubAppAuth{
  356. PrivateKey: esmeta.SecretKeySelector{
  357. Name: "test-secret",
  358. Key: "private-key",
  359. },
  360. },
  361. },
  362. secret: &corev1.Secret{
  363. ObjectMeta: metav1.ObjectMeta{
  364. Name: "test-secret",
  365. Namespace: "default",
  366. },
  367. Data: map[string][]byte{
  368. "private-key": []byte(privateKeyPEM),
  369. },
  370. },
  371. wantErr: false,
  372. wantBaseURL: "https://github.enterprise.com/api/v3/",
  373. wantUploadURL: "https://github.enterprise.com/api/uploads/",
  374. checkTransport: true,
  375. },
  376. {
  377. name: "GitHub Enterprise with separate upload URL",
  378. provider: &esv1.GithubProvider{
  379. AppID: 1,
  380. InstallationID: 1,
  381. URL: "https://github.enterprise.com/api/v3",
  382. UploadURL: "https://uploads.github.enterprise.com/api/v3",
  383. Auth: esv1.GithubAppAuth{
  384. PrivateKey: esmeta.SecretKeySelector{
  385. Name: "test-secret",
  386. Key: "private-key",
  387. },
  388. },
  389. },
  390. secret: &corev1.Secret{
  391. ObjectMeta: metav1.ObjectMeta{
  392. Name: "test-secret",
  393. Namespace: "default",
  394. },
  395. Data: map[string][]byte{
  396. "private-key": []byte(privateKeyPEM),
  397. },
  398. },
  399. wantErr: false,
  400. wantBaseURL: "https://github.enterprise.com/api/v3/",
  401. wantUploadURL: "https://uploads.github.enterprise.com/api/v3/api/uploads/",
  402. checkTransport: true,
  403. },
  404. {
  405. name: "Empty URL (default to github.com)",
  406. provider: &esv1.GithubProvider{
  407. AppID: 1,
  408. InstallationID: 1,
  409. URL: "",
  410. Auth: esv1.GithubAppAuth{
  411. PrivateKey: esmeta.SecretKeySelector{
  412. Name: "test-secret",
  413. Key: "private-key",
  414. },
  415. },
  416. },
  417. secret: &corev1.Secret{
  418. ObjectMeta: metav1.ObjectMeta{
  419. Name: "test-secret",
  420. Namespace: "default",
  421. },
  422. Data: map[string][]byte{
  423. "private-key": []byte(privateKeyPEM),
  424. },
  425. },
  426. wantErr: false,
  427. wantBaseURL: "https://api.github.com/",
  428. checkTransport: false,
  429. },
  430. }
  431. for _, tt := range tests {
  432. t.Run(tt.name, func(t *testing.T) {
  433. // Create fake Kubernetes client with the secret
  434. scheme := runtime.NewScheme()
  435. _ = corev1.AddToScheme(scheme)
  436. fakeClient := fake.NewClientBuilder().
  437. WithScheme(scheme).
  438. WithObjects(tt.secret).
  439. Build()
  440. // Create the GitHub client
  441. client := &Client{
  442. crClient: fakeClient,
  443. provider: tt.provider,
  444. namespace: "default",
  445. storeKind: "SecretStore",
  446. }
  447. // Call AuthWithPrivateKey
  448. ghClient, err := client.AuthWithPrivateKey(context.Background())
  449. if tt.wantErr {
  450. assert.Error(t, err)
  451. return
  452. }
  453. require.NoError(t, err)
  454. require.NotNil(t, ghClient)
  455. // Verify the BaseURL is set correctly
  456. assert.Equal(t, tt.wantBaseURL, ghClient.BaseURL.String())
  457. // If UploadURL is specified, verify it
  458. if tt.wantUploadURL != "" {
  459. assert.Equal(t, tt.wantUploadURL, ghClient.UploadURL.String())
  460. }
  461. // For GitHub Enterprise, verify the transport BaseURL is also set
  462. if tt.checkTransport {
  463. transport := ghClient.Client().Transport
  464. require.NotNil(t, transport)
  465. // Type assert to ghinstallation.Transport
  466. ghTransport, ok := transport.(*ghinstallation.Transport)
  467. require.True(t, ok, "Expected transport to be *ghinstallation.Transport")
  468. // Verify the BaseURL is set on the transport
  469. assert.Equal(t, tt.wantBaseURL, ghTransport.BaseURL,
  470. "Transport BaseURL should match the enterprise URL")
  471. }
  472. })
  473. }
  474. }