client_test.go 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434
  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. // generateTestPrivateKey generates a PEM-encoded RSA private key for testing.
  227. func generateTestPrivateKey() (string, error) {
  228. privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
  229. if err != nil {
  230. return "", err
  231. }
  232. privateKeyBytes := x509.MarshalPKCS1PrivateKey(privateKey)
  233. privateKeyPEM := pem.EncodeToMemory(&pem.Block{
  234. Type: "RSA PRIVATE KEY",
  235. Bytes: privateKeyBytes,
  236. })
  237. return string(privateKeyPEM), nil
  238. }
  239. func TestAuthWithPrivateKey(t *testing.T) {
  240. // Generate a valid private key for testing
  241. privateKeyPEM, err := generateTestPrivateKey()
  242. require.NoError(t, err)
  243. tests := []struct {
  244. name string
  245. provider *esv1.GithubProvider
  246. secret *corev1.Secret
  247. wantErr bool
  248. wantBaseURL string
  249. wantUploadURL string
  250. checkTransport bool
  251. }{
  252. {
  253. name: "GitHub.com (default)",
  254. provider: &esv1.GithubProvider{
  255. AppID: 1,
  256. InstallationID: 1,
  257. URL: "https://github.com/",
  258. Auth: esv1.GithubAppAuth{
  259. PrivateKey: esmeta.SecretKeySelector{
  260. Name: "test-secret",
  261. Key: "private-key",
  262. },
  263. },
  264. },
  265. secret: &corev1.Secret{
  266. ObjectMeta: metav1.ObjectMeta{
  267. Name: "test-secret",
  268. Namespace: "default",
  269. },
  270. Data: map[string][]byte{
  271. "private-key": []byte(privateKeyPEM),
  272. },
  273. },
  274. wantErr: false,
  275. wantBaseURL: "https://api.github.com/",
  276. checkTransport: false, // For default GitHub, we don't modify transport
  277. },
  278. {
  279. name: "GitHub Enterprise with custom URL",
  280. provider: &esv1.GithubProvider{
  281. AppID: 1,
  282. InstallationID: 1,
  283. URL: "https://github.enterprise.com/",
  284. Auth: esv1.GithubAppAuth{
  285. PrivateKey: esmeta.SecretKeySelector{
  286. Name: "test-secret",
  287. Key: "private-key",
  288. },
  289. },
  290. },
  291. secret: &corev1.Secret{
  292. ObjectMeta: metav1.ObjectMeta{
  293. Name: "test-secret",
  294. Namespace: "default",
  295. },
  296. Data: map[string][]byte{
  297. "private-key": []byte(privateKeyPEM),
  298. },
  299. },
  300. wantErr: false,
  301. wantBaseURL: "https://github.enterprise.com/api/v3/",
  302. wantUploadURL: "https://github.enterprise.com/api/uploads/",
  303. checkTransport: true,
  304. },
  305. {
  306. name: "GitHub Enterprise with separate upload URL",
  307. provider: &esv1.GithubProvider{
  308. AppID: 1,
  309. InstallationID: 1,
  310. URL: "https://github.enterprise.com/api/v3",
  311. UploadURL: "https://uploads.github.enterprise.com/api/v3",
  312. Auth: esv1.GithubAppAuth{
  313. PrivateKey: esmeta.SecretKeySelector{
  314. Name: "test-secret",
  315. Key: "private-key",
  316. },
  317. },
  318. },
  319. secret: &corev1.Secret{
  320. ObjectMeta: metav1.ObjectMeta{
  321. Name: "test-secret",
  322. Namespace: "default",
  323. },
  324. Data: map[string][]byte{
  325. "private-key": []byte(privateKeyPEM),
  326. },
  327. },
  328. wantErr: false,
  329. wantBaseURL: "https://github.enterprise.com/api/v3/",
  330. wantUploadURL: "https://uploads.github.enterprise.com/api/v3/api/uploads/",
  331. checkTransport: true,
  332. },
  333. {
  334. name: "Empty URL (default to github.com)",
  335. provider: &esv1.GithubProvider{
  336. AppID: 1,
  337. InstallationID: 1,
  338. URL: "",
  339. Auth: esv1.GithubAppAuth{
  340. PrivateKey: esmeta.SecretKeySelector{
  341. Name: "test-secret",
  342. Key: "private-key",
  343. },
  344. },
  345. },
  346. secret: &corev1.Secret{
  347. ObjectMeta: metav1.ObjectMeta{
  348. Name: "test-secret",
  349. Namespace: "default",
  350. },
  351. Data: map[string][]byte{
  352. "private-key": []byte(privateKeyPEM),
  353. },
  354. },
  355. wantErr: false,
  356. wantBaseURL: "https://api.github.com/",
  357. checkTransport: false,
  358. },
  359. }
  360. for _, tt := range tests {
  361. t.Run(tt.name, func(t *testing.T) {
  362. // Create fake Kubernetes client with the secret
  363. scheme := runtime.NewScheme()
  364. _ = corev1.AddToScheme(scheme)
  365. fakeClient := fake.NewClientBuilder().
  366. WithScheme(scheme).
  367. WithObjects(tt.secret).
  368. Build()
  369. // Create the GitHub client
  370. client := &Client{
  371. crClient: fakeClient,
  372. provider: tt.provider,
  373. namespace: "default",
  374. storeKind: "SecretStore",
  375. }
  376. // Call AuthWithPrivateKey
  377. ghClient, err := client.AuthWithPrivateKey(context.Background())
  378. if tt.wantErr {
  379. assert.Error(t, err)
  380. return
  381. }
  382. require.NoError(t, err)
  383. require.NotNil(t, ghClient)
  384. // Verify the BaseURL is set correctly
  385. assert.Equal(t, tt.wantBaseURL, ghClient.BaseURL.String())
  386. // If UploadURL is specified, verify it
  387. if tt.wantUploadURL != "" {
  388. assert.Equal(t, tt.wantUploadURL, ghClient.UploadURL.String())
  389. }
  390. // For GitHub Enterprise, verify the transport BaseURL is also set
  391. if tt.checkTransport {
  392. transport := ghClient.Client().Transport
  393. require.NotNil(t, transport)
  394. // Type assert to ghinstallation.Transport
  395. ghTransport, ok := transport.(*ghinstallation.Transport)
  396. require.True(t, ok, "Expected transport to be *ghinstallation.Transport")
  397. // Verify the BaseURL is set on the transport
  398. assert.Equal(t, tt.wantBaseURL, ghTransport.BaseURL,
  399. "Transport BaseURL should match the enterprise URL")
  400. }
  401. })
  402. }
  403. }