github.com/anchore/syft@v1.38.2/syft/source/snapsource/snapcraft_api_test.go (about)

     1  package snapsource
     2  
     3  import (
     4  	"encoding/json"
     5  	"net/http"
     6  	"net/http/httptest"
     7  	"testing"
     8  
     9  	"github.com/stretchr/testify/assert"
    10  	"github.com/stretchr/testify/require"
    11  )
    12  
    13  func TestSnapcraftClient_CheckSnapExists(t *testing.T) {
    14  	tests := []struct {
    15  		name           string
    16  		snapName       string
    17  		mockResponse   snapFindResponse
    18  		statusCode     int
    19  		expectedExists bool
    20  		expectedSnapID string
    21  		expectError    require.ErrorAssertionFunc
    22  		errorContains  string
    23  	}{
    24  		{
    25  			name:       "snap exists",
    26  			snapName:   "jp-ledger",
    27  			statusCode: http.StatusOK,
    28  			mockResponse: snapFindResponse{
    29  				Results: []struct {
    30  					Name   string   `json:"name"`
    31  					SnapID string   `json:"snap-id"`
    32  					Snap   struct{} `json:"snap"`
    33  				}{
    34  					{
    35  						Name:   "jp-ledger",
    36  						SnapID: "jyDlMmifyQhSWGPM9fnKc1HSD7E6c47e",
    37  						Snap:   struct{}{},
    38  					},
    39  				},
    40  			},
    41  			expectedExists: true,
    42  			expectedSnapID: "jyDlMmifyQhSWGPM9fnKc1HSD7E6c47e",
    43  			expectError:    require.NoError,
    44  		},
    45  		{
    46  			name:       "snap does not exist",
    47  			snapName:   "nonexistent-snap",
    48  			statusCode: http.StatusOK,
    49  			mockResponse: snapFindResponse{
    50  				Results: []struct {
    51  					Name   string   `json:"name"`
    52  					SnapID string   `json:"snap-id"`
    53  					Snap   struct{} `json:"snap"`
    54  				}{},
    55  			},
    56  			expectedExists: false,
    57  			expectedSnapID: "",
    58  			expectError:    require.NoError,
    59  		},
    60  		{
    61  			name:       "multiple results - exact match found",
    62  			snapName:   "test-snap",
    63  			statusCode: http.StatusOK,
    64  			mockResponse: snapFindResponse{
    65  				Results: []struct {
    66  					Name   string   `json:"name"`
    67  					SnapID string   `json:"snap-id"`
    68  					Snap   struct{} `json:"snap"`
    69  				}{
    70  					{
    71  						Name:   "test-snap-extra",
    72  						SnapID: "wrong-id",
    73  						Snap:   struct{}{},
    74  					},
    75  					{
    76  						Name:   "test-snap",
    77  						SnapID: "correct-id",
    78  						Snap:   struct{}{},
    79  					},
    80  				},
    81  			},
    82  			expectedExists: true,
    83  			expectedSnapID: "correct-id",
    84  			expectError:    require.NoError,
    85  		},
    86  		{
    87  			name:          "find API returns 404",
    88  			snapName:      "test",
    89  			statusCode:    http.StatusNotFound,
    90  			expectError:   require.Error,
    91  			errorContains: "find API request failed with status code 404",
    92  		},
    93  	}
    94  
    95  	for _, tt := range tests {
    96  		t.Run(tt.name, func(t *testing.T) {
    97  			findServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    98  				assert.Equal(t, defaultSeries, r.Header.Get("Snap-Device-Series"))
    99  				assert.Equal(t, tt.snapName, r.URL.Query().Get("name-startswith"))
   100  
   101  				w.WriteHeader(tt.statusCode)
   102  				if tt.statusCode == http.StatusOK {
   103  					responseBytes, err := json.Marshal(tt.mockResponse)
   104  					require.NoError(t, err)
   105  					w.Write(responseBytes)
   106  				}
   107  			}))
   108  			defer findServer.Close()
   109  
   110  			client := &snapcraftClient{
   111  				FindAPIURL: findServer.URL,
   112  				HTTPClient: &http.Client{},
   113  			}
   114  
   115  			exists, snapID, err := client.CheckSnapExists(tt.snapName)
   116  			tt.expectError(t, err)
   117  			if err != nil && tt.errorContains != "" {
   118  				assert.Contains(t, err.Error(), tt.errorContains)
   119  				return
   120  			}
   121  
   122  			assert.Equal(t, tt.expectedExists, exists)
   123  			assert.Equal(t, tt.expectedSnapID, snapID)
   124  		})
   125  	}
   126  }
   127  
   128  func TestSnapcraftClient_GetSnapDownloadURL(t *testing.T) {
   129  	tests := []struct {
   130  		name           string
   131  		snapID         snapIdentity
   132  		infoResponse   snapcraftInfo
   133  		infoStatusCode int
   134  		findResponse   *snapFindResponse
   135  		findStatusCode int
   136  		expectedURL    string
   137  		expectError    require.ErrorAssertionFunc
   138  		errorContains  string
   139  	}{
   140  		{
   141  			name: "successful download URL retrieval",
   142  			snapID: snapIdentity{
   143  				Name:         "etcd",
   144  				Channel:      "stable",
   145  				Architecture: "amd64",
   146  			},
   147  			infoStatusCode: http.StatusOK,
   148  			infoResponse: snapcraftInfo{
   149  				ChannelMap: []snapChannelMapEntry{
   150  					{
   151  						Channel: snapChannel{
   152  							Architecture: "amd64",
   153  							Name:         "stable",
   154  						},
   155  						Download: snapDownload{
   156  							URL: "https://api.snapcraft.io/api/v1/snaps/download/etcd_123.snap",
   157  						},
   158  					},
   159  				},
   160  			},
   161  			expectedURL: "https://api.snapcraft.io/api/v1/snaps/download/etcd_123.snap",
   162  			expectError: require.NoError,
   163  		},
   164  		{
   165  			name: "region-locked snap - exists but unavailable",
   166  			snapID: snapIdentity{
   167  				Name:         "jp-ledger",
   168  				Channel:      "stable",
   169  				Architecture: "amd64",
   170  			},
   171  			infoStatusCode: http.StatusNotFound,
   172  			findStatusCode: http.StatusOK,
   173  			findResponse: &snapFindResponse{
   174  				Results: []struct {
   175  					Name   string   `json:"name"`
   176  					SnapID string   `json:"snap-id"`
   177  					Snap   struct{} `json:"snap"`
   178  				}{
   179  					{
   180  						Name:   "jp-ledger",
   181  						SnapID: "jyDlMmifyQhSWGPM9fnKc1HSD7E6c47e",
   182  						Snap:   struct{}{},
   183  					},
   184  				},
   185  			},
   186  			expectError:   require.Error,
   187  			errorContains: "found snap 'jp-ledger' (id=jyDlMmifyQhSWGPM9fnKc1HSD7E6c47e) but it is unavailable for download",
   188  		},
   189  		{
   190  			name: "snap truly does not exist",
   191  			snapID: snapIdentity{
   192  				Name:         "nonexistent",
   193  				Channel:      "stable",
   194  				Architecture: "amd64",
   195  			},
   196  			infoStatusCode: http.StatusNotFound,
   197  			findStatusCode: http.StatusOK,
   198  			findResponse: &snapFindResponse{
   199  				Results: []struct {
   200  					Name   string   `json:"name"`
   201  					SnapID string   `json:"snap-id"`
   202  					Snap   struct{} `json:"snap"`
   203  				}{},
   204  			},
   205  			expectError:   require.Error,
   206  			errorContains: "no snap found with name 'nonexistent'",
   207  		},
   208  		{
   209  			name: "multiple architectures - find correct one",
   210  			snapID: snapIdentity{
   211  				Name:         "mysql",
   212  				Channel:      "stable",
   213  				Architecture: "arm64",
   214  			},
   215  			infoStatusCode: http.StatusOK,
   216  			infoResponse: snapcraftInfo{
   217  				ChannelMap: []snapChannelMapEntry{
   218  					{
   219  						Channel: snapChannel{
   220  							Architecture: "amd64",
   221  							Name:         "stable",
   222  						},
   223  						Download: snapDownload{
   224  							URL: "https://api.snapcraft.io/api/v1/snaps/download/mysql_amd64.snap",
   225  						},
   226  					},
   227  					{
   228  						Channel: snapChannel{
   229  							Architecture: "arm64",
   230  							Name:         "stable",
   231  						},
   232  						Download: snapDownload{
   233  							URL: "https://api.snapcraft.io/api/v1/snaps/download/mysql_arm64.snap",
   234  						},
   235  					},
   236  				},
   237  			},
   238  			expectedURL: "https://api.snapcraft.io/api/v1/snaps/download/mysql_arm64.snap",
   239  			expectError: require.NoError,
   240  		},
   241  		{
   242  			name: "snap not found - no matching architecture",
   243  			snapID: snapIdentity{
   244  				Name:         "etcd",
   245  				Channel:      "stable",
   246  				Architecture: "s390x",
   247  			},
   248  			infoStatusCode: http.StatusOK,
   249  			infoResponse: snapcraftInfo{
   250  				ChannelMap: []snapChannelMapEntry{
   251  					{
   252  						Channel: snapChannel{
   253  							Architecture: "amd64",
   254  							Name:         "stable",
   255  						},
   256  						Download: snapDownload{
   257  							URL: "https://api.snapcraft.io/api/v1/snaps/download/etcd_123.snap",
   258  						},
   259  					},
   260  				},
   261  			},
   262  			expectError:   require.Error,
   263  			errorContains: "no matching snap found",
   264  		},
   265  		{
   266  			name: "API returns 500",
   267  			snapID: snapIdentity{
   268  				Name:         "etcd",
   269  				Channel:      "stable",
   270  				Architecture: "amd64",
   271  			},
   272  			infoStatusCode: http.StatusInternalServerError,
   273  			expectError:    require.Error,
   274  			errorContains:  "API request failed with status code 500",
   275  		},
   276  		{
   277  			name: "find API fails when checking 404",
   278  			snapID: snapIdentity{
   279  				Name:         "test-snap",
   280  				Channel:      "stable",
   281  				Architecture: "amd64",
   282  			},
   283  			infoStatusCode: http.StatusNotFound,
   284  			findStatusCode: http.StatusInternalServerError,
   285  			expectError:    require.Error,
   286  			errorContains:  "failed to check if snap exists",
   287  		},
   288  	}
   289  
   290  	for _, tt := range tests {
   291  		t.Run(tt.name, func(t *testing.T) {
   292  			if tt.expectError == nil {
   293  				tt.expectError = require.NoError
   294  			}
   295  
   296  			infoServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
   297  				assert.Equal(t, defaultSeries, r.Header.Get("Snap-Device-Series"))
   298  
   299  				expectedPath := "/" + tt.snapID.Name
   300  				assert.Equal(t, expectedPath, r.URL.Path)
   301  
   302  				w.WriteHeader(tt.infoStatusCode)
   303  
   304  				if tt.infoStatusCode == http.StatusOK {
   305  					responseBytes, err := json.Marshal(tt.infoResponse)
   306  					require.NoError(t, err)
   307  					w.Write(responseBytes)
   308  				}
   309  			}))
   310  			defer infoServer.Close()
   311  
   312  			var findServer *httptest.Server
   313  			if tt.findResponse != nil || tt.findStatusCode != 0 {
   314  				findServer = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
   315  					assert.Equal(t, defaultSeries, r.Header.Get("Snap-Device-Series"))
   316  					assert.Equal(t, tt.snapID.Name, r.URL.Query().Get("name-startswith"))
   317  
   318  					statusCode := tt.findStatusCode
   319  					if statusCode == 0 {
   320  						statusCode = http.StatusOK
   321  					}
   322  					w.WriteHeader(statusCode)
   323  
   324  					if tt.findResponse != nil && statusCode == http.StatusOK {
   325  						responseBytes, err := json.Marshal(tt.findResponse)
   326  						require.NoError(t, err)
   327  						w.Write(responseBytes)
   328  					}
   329  				}))
   330  				defer findServer.Close()
   331  			}
   332  
   333  			client := &snapcraftClient{
   334  				InfoAPIURL: infoServer.URL + "/",
   335  				HTTPClient: &http.Client{},
   336  			}
   337  			if findServer != nil {
   338  				client.FindAPIURL = findServer.URL
   339  			}
   340  
   341  			url, err := client.GetSnapDownloadURL(tt.snapID)
   342  			tt.expectError(t, err)
   343  			if err != nil {
   344  				if tt.errorContains != "" {
   345  					assert.Contains(t, err.Error(), tt.errorContains)
   346  				}
   347  				return
   348  			}
   349  			assert.Equal(t, tt.expectedURL, url)
   350  		})
   351  	}
   352  }
   353  
   354  func TestSnapcraftClient_GetSnapDownloadURL_InvalidJSON(t *testing.T) {
   355  	infoServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
   356  		w.WriteHeader(http.StatusOK)
   357  		w.Write([]byte("invalid json"))
   358  	}))
   359  	defer infoServer.Close()
   360  
   361  	client := &snapcraftClient{
   362  		InfoAPIURL: infoServer.URL + "/",
   363  		HTTPClient: &http.Client{},
   364  	}
   365  
   366  	snapID := snapIdentity{
   367  		Name:         "etcd",
   368  		Channel:      "stable",
   369  		Architecture: "amd64",
   370  	}
   371  
   372  	_, err := client.GetSnapDownloadURL(snapID)
   373  	assert.Error(t, err)
   374  	assert.Contains(t, err.Error(), "failed to parse JSON response")
   375  }
   376  
   377  func TestNewSnapcraftClient(t *testing.T) {
   378  	client := newSnapcraftClient()
   379  
   380  	assert.Equal(t, "https://api.snapcraft.io/v2/snaps/info/", client.InfoAPIURL)
   381  	assert.Equal(t, "https://api.snapcraft.io/v2/snaps/find", client.FindAPIURL)
   382  	assert.NotNil(t, client.HTTPClient)
   383  }