1// +build !integration
2
3package azure
4
5import (
6	"encoding/base64"
7	"errors"
8	"fmt"
9	"net/http"
10	"net/url"
11	"testing"
12	"time"
13
14	"github.com/sirupsen/logrus/hooks/test"
15	"github.com/stretchr/testify/assert"
16	"github.com/stretchr/testify/require"
17
18	"gitlab.com/gitlab-org/gitlab-runner/common"
19)
20
21var (
22	accountName    = "azuretest"
23	accountKey     = base64.StdEncoding.EncodeToString([]byte("12345"))
24	containerName  = "test"
25	objectName     = "key"
26	storageDomain  = "example.com"
27	defaultTimeout = 1 * time.Hour
28)
29
30func defaultAzureCache() *common.CacheConfig {
31	return &common.CacheConfig{
32		Type: "azure",
33		Azure: &common.CacheAzureConfig{
34			CacheAzureCredentials: common.CacheAzureCredentials{
35				AccountName: accountName,
36				AccountKey:  accountKey,
37			},
38			ContainerName: containerName,
39			StorageDomain: storageDomain,
40		},
41	}
42}
43
44type adapterOperationInvalidConfigTestCase struct {
45	provideAzureConfig bool
46
47	errorOnCredentialsResolverInitialization bool
48	credentialsResolverResolveError          bool
49
50	accountName        string
51	accountKey         string
52	containerName      string
53	expectedErrorMsg   string
54	expectedGoCloudURL string
55}
56
57func prepareMockedCredentialsResolverInitializer(tc adapterOperationInvalidConfigTestCase) func() {
58	oldCredentialsResolverInitializer := credentialsResolverInitializer
59	credentialsResolverInitializer = func(config *common.CacheAzureConfig) (*defaultCredentialsResolver, error) {
60		if tc.errorOnCredentialsResolverInitialization {
61			return nil, errors.New("test error")
62		}
63
64		return newDefaultCredentialsResolver(config)
65	}
66
67	return func() {
68		credentialsResolverInitializer = oldCredentialsResolverInitializer
69	}
70}
71
72func prepareMockedCredentialsResolverForInvalidConfig(adapter *azureAdapter, tc adapterOperationInvalidConfigTestCase) {
73	cr := &mockCredentialsResolver{}
74
75	resolveCall := cr.On("Resolve")
76	if tc.credentialsResolverResolveError {
77		resolveCall.Return(fmt.Errorf("test error"))
78	} else {
79		resolveCall.Return(nil)
80	}
81
82	cr.On("Credentials").Return(&common.CacheAzureCredentials{
83		AccountName: tc.accountName,
84		AccountKey:  tc.accountKey,
85	})
86
87	adapter.credentialsResolver = cr
88}
89
90func testAdapterOperationWithInvalidConfig(
91	t *testing.T,
92	name string,
93	tc adapterOperationInvalidConfigTestCase,
94	adapter *azureAdapter,
95	operation func() *url.URL,
96) {
97	t.Run(name, func(t *testing.T) {
98		prepareMockedCredentialsResolverForInvalidConfig(adapter, tc)
99		hook := test.NewGlobal()
100
101		u := operation()
102		assert.Nil(t, u)
103
104		message, err := hook.LastEntry().String()
105		require.NoError(t, err)
106		assert.Contains(t, message, tc.expectedErrorMsg)
107	})
108}
109
110func testGoCloudURLWithInvalidConfig(
111	t *testing.T,
112	name string,
113	tc adapterOperationInvalidConfigTestCase,
114	adapter *azureAdapter,
115	operation func() *url.URL,
116) {
117	t.Run(name, func(t *testing.T) {
118		prepareMockedCredentialsResolverForInvalidConfig(adapter, tc)
119
120		u := operation()
121
122		if u != nil {
123			assert.Equal(t, tc.expectedGoCloudURL, u.String())
124		} else {
125			assert.Empty(t, tc.expectedGoCloudURL)
126		}
127	})
128}
129
130func testUploadEnvWithInvalidConfig(
131	t *testing.T,
132	name string,
133	tc adapterOperationInvalidConfigTestCase,
134	adapter *azureAdapter,
135	operation func() map[string]string,
136) {
137	t.Run(name, func(t *testing.T) {
138		prepareMockedCredentialsResolverForInvalidConfig(adapter, tc)
139
140		u := operation()
141		assert.Empty(t, u)
142	})
143}
144
145func TestAdapterOperation_InvalidConfig(t *testing.T) {
146	tests := map[string]adapterOperationInvalidConfigTestCase{
147		"no-azure-config": {
148			containerName:    containerName,
149			expectedErrorMsg: "Missing Azure configuration",
150		},
151		"error-on-credentials-resolver-initialization": {
152			provideAzureConfig:                       true,
153			errorOnCredentialsResolverInitialization: true,
154		},
155		"credentials-resolver-resolve-error": {
156			provideAzureConfig:              true,
157			credentialsResolverResolveError: true,
158			containerName:                   containerName,
159			expectedErrorMsg:                `error resolving Azure credentials" error="test error"`,
160			expectedGoCloudURL:              "azblob://test/key",
161		},
162		"no-credentials": {
163			provideAzureConfig: true,
164			containerName:      containerName,
165			expectedErrorMsg:   "error generating Azure pre-signed URL\" error=\"missing Azure storage account name\"",
166			expectedGoCloudURL: "azblob://test/key",
167		},
168		"no-account-name": {
169			provideAzureConfig: true,
170			accountKey:         accountKey,
171			containerName:      containerName,
172			expectedErrorMsg:   "error generating Azure pre-signed URL\" error=\"missing Azure storage account name\"",
173			expectedGoCloudURL: "azblob://test/key",
174		},
175		"no-account-key": {
176			provideAzureConfig: true,
177			accountName:        accountName,
178			containerName:      containerName,
179			expectedErrorMsg:   "error generating Azure pre-signed URL\" error=\"missing Azure storage account key\"",
180			expectedGoCloudURL: "azblob://test/key",
181		},
182		"invalid-container-name-and-no-account-key": {
183			provideAzureConfig: true,
184			accountName:        accountName,
185			containerName:      "\x00",
186			expectedErrorMsg:   "error generating Azure pre-signed URL\" error=\"missing Azure storage account key\"",
187		},
188		"container-not-specified": {
189			provideAzureConfig: true,
190			accountName:        "access-id",
191			accountKey:         accountKey,
192			expectedErrorMsg:   "ContainerName can't be empty",
193		},
194	}
195
196	for name, tc := range tests {
197		t.Run(name, func(t *testing.T) {
198			cleanupCredentialsResolverInitializerMock := prepareMockedCredentialsResolverInitializer(tc)
199			defer cleanupCredentialsResolverInitializerMock()
200
201			config := defaultAzureCache()
202			config.Azure.ContainerName = tc.containerName
203			if !tc.provideAzureConfig {
204				config.Azure = nil
205			}
206
207			a, err := New(config, defaultTimeout, objectName)
208			if !tc.provideAzureConfig {
209				assert.Nil(t, a)
210				assert.EqualError(t, err, "missing Azure configuration")
211				return
212			}
213
214			if tc.errorOnCredentialsResolverInitialization {
215				assert.Nil(t, a)
216				assert.EqualError(t, err, "error while initializing Azure credentials resolver: test error")
217				return
218			}
219
220			require.NotNil(t, a)
221			assert.NoError(t, err)
222
223			adapter, ok := a.(*azureAdapter)
224			require.True(t, ok, "Adapter should be properly casted to *adapter type")
225
226			testAdapterOperationWithInvalidConfig(t, "GetDownloadURL", tc, adapter, a.GetDownloadURL)
227			testAdapterOperationWithInvalidConfig(t, "GetUploadURL", tc, adapter, a.GetUploadURL)
228			testGoCloudURLWithInvalidConfig(t, "GetGoCloudURL", tc, adapter, a.GetGoCloudURL)
229			testUploadEnvWithInvalidConfig(t, "GetUploadEnv", tc, adapter, a.GetUploadEnv)
230		})
231	}
232}
233
234type adapterOperationTestCase struct {
235	objectName    string
236	returnedURL   string
237	returnedError error
238	expectedError string
239}
240
241func prepareMockedCredentialsResolver(adapter *azureAdapter) func(t *testing.T) {
242	cr := &mockCredentialsResolver{}
243	cr.On("Resolve").Return(nil)
244	cr.On("Credentials").Return(&common.CacheAzureCredentials{
245		AccountName: accountName,
246		AccountKey:  accountKey,
247	})
248
249	adapter.credentialsResolver = cr
250
251	return func(t *testing.T) {
252		cr.AssertExpectations(t)
253	}
254}
255
256func prepareMockedSignedURLGenerator(
257	t *testing.T,
258	tc adapterOperationTestCase,
259	expectedMethod string,
260	adapter *azureAdapter,
261) {
262	adapter.generateSignedURL = func(name string, opts *signedURLOptions) (*url.URL, error) {
263		assert.Equal(t, containerName, opts.ContainerName)
264		assert.Equal(t, accountName, opts.Credentials.AccountName)
265		assert.Equal(t, accountKey, opts.Credentials.AccountKey)
266		assert.Equal(t, expectedMethod, opts.Method)
267
268		u, err := url.Parse(tc.returnedURL)
269		if err != nil {
270			return nil, err
271		}
272
273		return u, tc.returnedError
274	}
275}
276
277func testAdapterOperation(
278	t *testing.T,
279	tc adapterOperationTestCase,
280	name string,
281	expectedMethod string,
282	adapter *azureAdapter,
283	operation func() *url.URL,
284) {
285	t.Run(name, func(t *testing.T) {
286		cleanupCredentialsResolverMock := prepareMockedCredentialsResolver(adapter)
287		defer cleanupCredentialsResolverMock(t)
288
289		prepareMockedSignedURLGenerator(t, tc, expectedMethod, adapter)
290		hook := test.NewGlobal()
291
292		u := operation()
293
294		if tc.expectedError != "" {
295			message, err := hook.LastEntry().String()
296			require.NoError(t, err)
297			assert.Contains(t, message, tc.expectedError)
298			return
299		}
300
301		assert.Empty(t, hook.AllEntries())
302
303		assert.Equal(t, tc.returnedURL, u.String())
304	})
305}
306
307func TestAdapterOperation(t *testing.T) {
308	tests := map[string]adapterOperationTestCase{
309		"error-on-URL-signing": {
310			objectName:    objectName,
311			returnedURL:   "",
312			returnedError: fmt.Errorf("test error"),
313			expectedError: "error generating Azure pre-signed URL\" error=\"test error\"",
314		},
315		"invalid-URL-returned": {
316			objectName:    objectName,
317			returnedURL:   "://test",
318			returnedError: nil,
319			expectedError: "error generating Azure pre-signed URL\" error=\"parse",
320		},
321		"valid-configuration": {
322			objectName:    objectName,
323			returnedURL:   "https://myaccount.blob.core.windows.net/mycontainer/mydirectory/myfile.txt?sig=XYZ&sp=r",
324			returnedError: nil,
325			expectedError: "",
326		},
327		"valid-configuration-with-leading-slash": {
328			objectName:    "/" + objectName,
329			returnedURL:   "https://myaccount.blob.core.windows.net/mycontainer/mydirectory/myfile.txt?sig=XYZ&sp=r",
330			returnedError: nil,
331			expectedError: "",
332		},
333	}
334
335	for name, tc := range tests {
336		t.Run(name, func(t *testing.T) {
337			config := defaultAzureCache()
338
339			a, err := New(config, defaultTimeout, tc.objectName)
340			require.NoError(t, err)
341
342			adapter, ok := a.(*azureAdapter)
343			require.True(t, ok, "Adapter should be properly casted to *adapter type")
344
345			testAdapterOperation(
346				t,
347				tc,
348				"GetDownloadURL",
349				http.MethodGet,
350				adapter,
351				a.GetDownloadURL,
352			)
353			testAdapterOperation(
354				t,
355				tc,
356				"GetUploadURL",
357				http.MethodPut,
358				adapter,
359				a.GetUploadURL,
360			)
361
362			headers := adapter.GetUploadHeaders()
363			require.NotNil(t, headers)
364			assert.Len(t, headers, 2)
365			assert.Equal(t, "application/octet-stream", headers.Get("Content-Type"))
366			assert.Equal(t, "BlockBlob", headers.Get("x-ms-blob-type"))
367
368			u := adapter.GetGoCloudURL()
369			assert.Equal(t, "azblob://test/key", u.String())
370
371			env := adapter.GetUploadEnv()
372			assert.Len(t, env, 3)
373			assert.Equal(t, accountName, env["AZURE_STORAGE_ACCOUNT"])
374			assert.NotEmpty(t, env["AZURE_STORAGE_SAS_TOKEN"])
375			assert.Empty(t, env["AZURE_STORAGE_KEY"])
376			assert.Equal(t, storageDomain, env["AZURE_STORAGE_DOMAIN"])
377		})
378	}
379}
380