go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/config/impl/remote/remote_v2_test.go (about)

     1  // Copyright 2023 The LUCI Authors.
     2  //
     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  //
     7  //      http://www.apache.org/licenses/LICENSE-2.0
     8  //
     9  // Unless required by applicable law or agreed to in writing, software
    10  // distributed under the License is distributed on an "AS IS" BASIS,
    11  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    12  // See the License for the specific language governing permissions and
    13  // limitations under the License.
    14  
    15  package remote
    16  
    17  import (
    18  	"bytes"
    19  	"context"
    20  	"net/http"
    21  	"net/http/httptest"
    22  	"net/url"
    23  	"strings"
    24  	"testing"
    25  
    26  	"github.com/golang/mock/gomock"
    27  	"github.com/klauspost/compress/gzip"
    28  	"google.golang.org/genproto/protobuf/field_mask"
    29  	"google.golang.org/grpc"
    30  	"google.golang.org/grpc/codes"
    31  	grpcGzip "google.golang.org/grpc/encoding/gzip"
    32  	"google.golang.org/grpc/status"
    33  
    34  	"go.chromium.org/luci/common/proto"
    35  	"go.chromium.org/luci/common/retry/transient"
    36  	pb "go.chromium.org/luci/config_service/proto"
    37  
    38  	"go.chromium.org/luci/config"
    39  
    40  	. "github.com/smartystreets/goconvey/convey"
    41  	. "go.chromium.org/luci/common/testing/assertions"
    42  )
    43  
    44  func TestRemoteV2Calls(t *testing.T) {
    45  	t.Parallel()
    46  
    47  	Convey("Remote V2 calls", t, func() {
    48  		ctl := gomock.NewController(t)
    49  		mockClient := pb.NewMockConfigsClient(ctl)
    50  		v2Impl := remoteV2Impl{
    51  			grpcClient: mockClient,
    52  			httpClient: http.DefaultClient,
    53  		}
    54  		ctx := context.Background()
    55  
    56  		Convey("GetConfig", func() {
    57  			Convey("ok - raw content", func() {
    58  				mockClient.EXPECT().GetConfig(gomock.Any(), proto.MatcherEqual(&pb.GetConfigRequest{
    59  					ConfigSet: "projects/project1",
    60  					Path:      "config.cfg",
    61  				}), grpc.UseCompressor(grpcGzip.Name)).Return(&pb.Config{
    62  					ConfigSet: "projects/project1",
    63  					Path:      "config.cfg",
    64  					Content: &pb.Config_RawContent{
    65  						RawContent: []byte("content"),
    66  					},
    67  					Revision:      "revision",
    68  					ContentSha256: "sha256",
    69  					Url:           "url",
    70  				}, nil)
    71  
    72  				cfg, err := v2Impl.GetConfig(ctx, config.Set("projects/project1"), "config.cfg", false)
    73  
    74  				So(err, ShouldBeNil)
    75  				So(cfg, ShouldResemble, &config.Config{
    76  					Meta: config.Meta{
    77  						ConfigSet:   "projects/project1",
    78  						Path:        "config.cfg",
    79  						ContentHash: "sha256",
    80  						Revision:    "revision",
    81  						ViewURL:     "url",
    82  					},
    83  					Content: "content",
    84  				})
    85  			})
    86  
    87  			Convey("ok - signed url", func(c C) {
    88  				signedURLServer := signedURLServer(c, "content")
    89  				defer signedURLServer.Close()
    90  
    91  				mockClient.EXPECT().GetConfig(gomock.Any(), proto.MatcherEqual(&pb.GetConfigRequest{
    92  					ConfigSet: "projects/project1",
    93  					Path:      "config.cfg",
    94  				}), grpc.UseCompressor(grpcGzip.Name)).Return(&pb.Config{
    95  					ConfigSet: "projects/project1",
    96  					Path:      "config.cfg",
    97  					Content: &pb.Config_SignedUrl{
    98  						SignedUrl: signedURLServer.URL,
    99  					},
   100  					Revision:      "revision",
   101  					ContentSha256: "sha256",
   102  					Url:           "url",
   103  				}, nil)
   104  
   105  				cfg, err := v2Impl.GetConfig(ctx, config.Set("projects/project1"), "config.cfg", false)
   106  
   107  				So(err, ShouldBeNil)
   108  				So(cfg, ShouldResemble, &config.Config{
   109  					Meta: config.Meta{
   110  						ConfigSet:   "projects/project1",
   111  						Path:        "config.cfg",
   112  						ContentHash: "sha256",
   113  						Revision:    "revision",
   114  						ViewURL:     "url",
   115  					},
   116  					Content: "content",
   117  				})
   118  			})
   119  
   120  			Convey("ok - meta only", func() {
   121  				mockClient.EXPECT().GetConfig(gomock.Any(), proto.MatcherEqual(&pb.GetConfigRequest{
   122  					ConfigSet: "projects/project1",
   123  					Path:      "config.cfg",
   124  					Fields: &field_mask.FieldMask{
   125  						Paths: []string{"config_set", "path", "content_sha256", "revision", "url"},
   126  					},
   127  				}), grpc.UseCompressor(grpcGzip.Name)).Return(&pb.Config{
   128  					ConfigSet:     "projects/project1",
   129  					Path:          "config.cfg",
   130  					Revision:      "revision",
   131  					ContentSha256: "sha256",
   132  					Url:           "url",
   133  				}, nil)
   134  
   135  				cfg, err := v2Impl.GetConfig(ctx, config.Set("projects/project1"), "config.cfg", true)
   136  
   137  				So(err, ShouldBeNil)
   138  				So(cfg, ShouldResemble, &config.Config{
   139  					Meta: config.Meta{
   140  						ConfigSet:   "projects/project1",
   141  						Path:        "config.cfg",
   142  						ContentHash: "sha256",
   143  						Revision:    "revision",
   144  						ViewURL:     "url",
   145  					},
   146  				})
   147  			})
   148  
   149  			Convey("error - not found", func() {
   150  				mockClient.EXPECT().GetConfig(gomock.Any(), gomock.Any(), grpc.UseCompressor(grpcGzip.Name)).Return(nil, status.Errorf(codes.NotFound, "not found"))
   151  
   152  				cfg, err := v2Impl.GetConfig(ctx, config.Set("projects/project1"), "config.cfg", true)
   153  
   154  				So(cfg, ShouldBeNil)
   155  				So(err, ShouldErrLike, config.ErrNoConfig)
   156  			})
   157  
   158  			Convey("error - other", func() {
   159  				mockClient.EXPECT().GetConfig(gomock.Any(), gomock.Any(), grpc.UseCompressor(grpcGzip.Name)).Return(nil, status.Errorf(codes.Internal, "internal error"))
   160  
   161  				cfg, err := v2Impl.GetConfig(ctx, config.Set("projects/project1"), "config.cfg", true)
   162  
   163  				So(cfg, ShouldBeNil)
   164  				So(err, ShouldHaveGRPCStatus, codes.Internal, "internal error")
   165  				So(transient.Tag.In(err), ShouldBeTrue)
   166  			})
   167  		})
   168  
   169  		Convey("GetProjectConfigs", func() {
   170  
   171  			Convey("ok - meta only", func() {
   172  				mockClient.EXPECT().GetProjectConfigs(gomock.Any(), proto.MatcherEqual(&pb.GetProjectConfigsRequest{
   173  					Path: "config.cfg",
   174  					Fields: &field_mask.FieldMask{
   175  						Paths: []string{"config_set", "path", "content_sha256", "revision", "url"},
   176  					},
   177  				}), grpc.UseCompressor(grpcGzip.Name)).Return(&pb.GetProjectConfigsResponse{
   178  					Configs: []*pb.Config{
   179  						{
   180  							ConfigSet:     "projects/project1",
   181  							Path:          "config.cfg",
   182  							Revision:      "revision",
   183  							ContentSha256: "sha256",
   184  							Url:           "url",
   185  						},
   186  					},
   187  				}, nil)
   188  
   189  				configs, err := v2Impl.GetProjectConfigs(ctx, "config.cfg", true)
   190  				So(err, ShouldBeNil)
   191  				So(configs, ShouldResemble, []config.Config{
   192  					{
   193  						Meta: config.Meta{
   194  							ConfigSet:   "projects/project1",
   195  							Path:        "config.cfg",
   196  							ContentHash: "sha256",
   197  							Revision:    "revision",
   198  							ViewURL:     "url",
   199  						},
   200  					},
   201  				})
   202  			})
   203  
   204  			Convey("ok - raw + signed url", func(c C) {
   205  				signedURLServer := signedURLServer(c, "large content")
   206  				defer signedURLServer.Close()
   207  
   208  				mockClient.EXPECT().GetProjectConfigs(gomock.Any(), proto.MatcherEqual(&pb.GetProjectConfigsRequest{
   209  					Path: "config.cfg",
   210  				}), grpc.UseCompressor(grpcGzip.Name)).Return(&pb.GetProjectConfigsResponse{
   211  					Configs: []*pb.Config{
   212  						{
   213  							ConfigSet:     "projects/project1",
   214  							Path:          "config.cfg",
   215  							Revision:      "revision",
   216  							ContentSha256: "sha256",
   217  							Url:           "url",
   218  							Content: &pb.Config_RawContent{
   219  								RawContent: []byte("small content"),
   220  							},
   221  						},
   222  						{
   223  							ConfigSet:     "projects/project2",
   224  							Path:          "config.cfg",
   225  							Revision:      "revision",
   226  							ContentSha256: "sha256",
   227  							Url:           "url",
   228  							Content: &pb.Config_SignedUrl{
   229  								SignedUrl: signedURLServer.URL,
   230  							},
   231  						},
   232  					},
   233  				}, nil)
   234  
   235  				configs, err := v2Impl.GetProjectConfigs(ctx, "config.cfg", false)
   236  				So(err, ShouldBeNil)
   237  				So(configs, ShouldResemble, []config.Config{
   238  					{
   239  						Meta: config.Meta{
   240  							ConfigSet:   "projects/project1",
   241  							Path:        "config.cfg",
   242  							ContentHash: "sha256",
   243  							Revision:    "revision",
   244  							ViewURL:     "url",
   245  						},
   246  						Content: "small content",
   247  					},
   248  					{
   249  						Meta: config.Meta{
   250  							ConfigSet:   "projects/project2",
   251  							Path:        "config.cfg",
   252  							ContentHash: "sha256",
   253  							Revision:    "revision",
   254  							ViewURL:     "url",
   255  						},
   256  						Content: "large content",
   257  					},
   258  				})
   259  			})
   260  
   261  			Convey("empty response", func() {
   262  				mockClient.EXPECT().GetProjectConfigs(gomock.Any(), proto.MatcherEqual(&pb.GetProjectConfigsRequest{
   263  					Path: "config.cfg",
   264  				}), grpc.UseCompressor(grpcGzip.Name)).Return(&pb.GetProjectConfigsResponse{}, nil)
   265  
   266  				configs, err := v2Impl.GetProjectConfigs(ctx, "config.cfg", false)
   267  				So(err, ShouldBeNil)
   268  				So(configs, ShouldBeEmpty)
   269  			})
   270  
   271  			Convey("rpc error", func() {
   272  				mockClient.EXPECT().GetProjectConfigs(gomock.Any(), proto.MatcherEqual(&pb.GetProjectConfigsRequest{
   273  					Path: "config.cfg",
   274  				}), grpc.UseCompressor(grpcGzip.Name)).Return(nil, status.Errorf(codes.Internal, "config server internal error"))
   275  
   276  				configs, err := v2Impl.GetProjectConfigs(ctx, "config.cfg", false)
   277  				So(configs, ShouldBeNil)
   278  				So(err, ShouldHaveGRPCStatus, codes.Internal, "config server internal error")
   279  				So(transient.Tag.In(err), ShouldBeTrue)
   280  			})
   281  
   282  			Convey("signed url error", func(c C) {
   283  				signedURLSever := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
   284  					if strings.HasSuffix(r.URL.String(), "err") {
   285  						w.WriteHeader(http.StatusInternalServerError)
   286  						_, err := w.Write([]byte("internal error"))
   287  						c.So(err, ShouldBeNil)
   288  						return
   289  					}
   290  					buf := &bytes.Buffer{}
   291  					gw := gzip.NewWriter(buf)
   292  					_, err := gw.Write([]byte("large content"))
   293  					c.So(err, ShouldBeNil)
   294  					c.So(gw.Close(), ShouldBeNil)
   295  					w.Header().Set("Content-Encoding", "gzip")
   296  					_, err = w.Write(buf.Bytes())
   297  					c.So(err, ShouldBeNil)
   298  				}))
   299  				defer signedURLSever.Close()
   300  
   301  				mockClient.EXPECT().GetProjectConfigs(gomock.Any(), proto.MatcherEqual(&pb.GetProjectConfigsRequest{
   302  					Path: "config.cfg",
   303  				}), grpc.UseCompressor(grpcGzip.Name)).Return(&pb.GetProjectConfigsResponse{
   304  					Configs: []*pb.Config{
   305  						{
   306  							ConfigSet: "projects/project1",
   307  							Path:      "config.cfg",
   308  							Content: &pb.Config_SignedUrl{
   309  								SignedUrl: signedURLSever.URL,
   310  							},
   311  						},
   312  						{
   313  							ConfigSet: "projects/project2",
   314  							Path:      "config.cfg",
   315  							Content: &pb.Config_SignedUrl{
   316  								SignedUrl: signedURLSever.URL + "/err",
   317  							},
   318  						},
   319  					},
   320  				}, nil)
   321  
   322  				configs, err := v2Impl.GetProjectConfigs(ctx, "config.cfg", false)
   323  				So(configs, ShouldBeNil)
   324  				So(err, ShouldErrLike, `for file(config.cfg) in config_set(projects/project2): failed to download file, got http response code: 500, body: "internal error"`)
   325  				So(transient.Tag.In(err), ShouldBeTrue)
   326  			})
   327  		})
   328  
   329  		Convey("GetProjects", func() {
   330  			Convey("ok", func() {
   331  				res := &pb.ListConfigSetsResponse{
   332  					ConfigSets: []*pb.ConfigSet{
   333  						{
   334  							Name: "projects/project1",
   335  							Url:  "https://a.googlesource.com/project1",
   336  						},
   337  						{
   338  							Name: "projects/project2",
   339  							Url:  "https://b.googlesource.com/project2",
   340  						},
   341  					},
   342  				}
   343  				mockClient.EXPECT().ListConfigSets(gomock.Any(), proto.MatcherEqual(&pb.ListConfigSetsRequest{
   344  					Domain: pb.ListConfigSetsRequest_PROJECT,
   345  				})).Return(res, nil)
   346  
   347  				projects, err := v2Impl.GetProjects(ctx)
   348  				So(err, ShouldBeNil)
   349  
   350  				url1, err := url.Parse(res.ConfigSets[0].Url)
   351  				So(err, ShouldBeNil)
   352  				url2, err := url.Parse(res.ConfigSets[1].Url)
   353  				So(err, ShouldBeNil)
   354  				So(projects, ShouldResemble, []config.Project{
   355  					{
   356  						ID:       "project1",
   357  						Name:     "project1",
   358  						RepoType: config.GitilesRepo,
   359  						RepoURL:  url1,
   360  					},
   361  					{
   362  						ID:       "project2",
   363  						Name:     "project2",
   364  						RepoType: config.GitilesRepo,
   365  						RepoURL:  url2,
   366  					},
   367  				})
   368  			})
   369  
   370  			Convey("rpc err", func() {
   371  				mockClient.EXPECT().ListConfigSets(gomock.Any(), proto.MatcherEqual(&pb.ListConfigSetsRequest{
   372  					Domain: pb.ListConfigSetsRequest_PROJECT,
   373  				})).Return(nil, status.Errorf(codes.Internal, "server internal error"))
   374  
   375  				projects, err := v2Impl.GetProjects(ctx)
   376  				So(projects, ShouldBeNil)
   377  				So(err, ShouldHaveGRPCStatus, codes.Internal, "server internal error")
   378  				So(transient.Tag.In(err), ShouldBeTrue)
   379  			})
   380  		})
   381  
   382  		Convey("ListFiles", func() {
   383  			Convey("ok", func() {
   384  				mockClient.EXPECT().GetConfigSet(gomock.Any(), proto.MatcherEqual(&pb.GetConfigSetRequest{
   385  					ConfigSet: "projects/project",
   386  					Fields: &field_mask.FieldMask{
   387  						Paths: []string{"configs"},
   388  					},
   389  				})).Return(&pb.ConfigSet{
   390  					Configs: []*pb.Config{
   391  						{Path: "file1"},
   392  						{Path: "file2"},
   393  					},
   394  				}, nil)
   395  
   396  				files, err := v2Impl.ListFiles(ctx, config.Set("projects/project"))
   397  				So(err, ShouldBeNil)
   398  				So(files, ShouldResemble, []string{"file1", "file2"})
   399  			})
   400  
   401  			Convey("rpc err", func() {
   402  				mockClient.EXPECT().GetConfigSet(gomock.Any(), proto.MatcherEqual(&pb.GetConfigSetRequest{
   403  					ConfigSet: "projects/project",
   404  					Fields: &field_mask.FieldMask{
   405  						Paths: []string{"configs"},
   406  					},
   407  				})).Return(nil, status.Errorf(codes.Internal, "server internal error"))
   408  
   409  				files, err := v2Impl.ListFiles(ctx, config.Set("projects/project"))
   410  				So(files, ShouldBeNil)
   411  				So(err, ShouldHaveGRPCStatus, codes.Internal, "server internal error")
   412  				So(transient.Tag.In(err), ShouldBeTrue)
   413  			})
   414  		})
   415  
   416  		Convey("GetConfigs", func() {
   417  			Convey("listing err", func() {
   418  				mockClient.EXPECT().GetConfigSet(gomock.Any(), proto.MatcherEqual(&pb.GetConfigSetRequest{
   419  					ConfigSet: "projects/project",
   420  					Fields: &field_mask.FieldMask{
   421  						Paths: []string{"configs"},
   422  					},
   423  				})).Return(nil, status.Errorf(codes.NotFound, "no config set"))
   424  				files, err := v2Impl.GetConfigs(ctx, "projects/project", nil, false)
   425  				So(files, ShouldBeNil)
   426  				So(err, ShouldEqual, config.ErrNoConfig)
   427  			})
   428  
   429  			Convey("listing ok", func() {
   430  				mockClient.EXPECT().GetConfigSet(gomock.Any(), proto.MatcherEqual(&pb.GetConfigSetRequest{
   431  					ConfigSet: "projects/project",
   432  					Fields: &field_mask.FieldMask{
   433  						Paths: []string{"configs"},
   434  					},
   435  				})).Return(&pb.ConfigSet{
   436  					Configs: []*pb.Config{
   437  						{
   438  							ConfigSet:     "projects/project",
   439  							Path:          "file1",
   440  							ContentSha256: "file1-hash",
   441  							Size:          123,
   442  							Revision:      "rev",
   443  							Url:           "file1-url",
   444  						},
   445  						{
   446  							ConfigSet: "projects/project",
   447  							Path:      "ignored",
   448  							Revision:  "rev",
   449  						},
   450  						{
   451  							ConfigSet:     "projects/project",
   452  							Path:          "file2",
   453  							ContentSha256: "file2-hash",
   454  							Size:          456,
   455  							Revision:      "rev",
   456  							Url:           "file2-url",
   457  						},
   458  					},
   459  				}, nil)
   460  
   461  				filter := func(path string) bool { return path != "ignored" }
   462  
   463  				expectedOutput := func(metaOnly bool) map[string]config.Config {
   464  					content := func(p string) string { return "" }
   465  					if !metaOnly {
   466  						content = func(p string) string { return p + " content" }
   467  					}
   468  					return map[string]config.Config{
   469  						"file1": {
   470  							Meta: config.Meta{
   471  								ConfigSet:   "projects/project",
   472  								Path:        "file1",
   473  								ContentHash: "file1-hash",
   474  								Revision:    "rev",
   475  								ViewURL:     "file1-url",
   476  							},
   477  							Content: content("file1"),
   478  						},
   479  						"file2": {
   480  							Meta: config.Meta{
   481  								ConfigSet:   "projects/project",
   482  								Path:        "file2",
   483  								ContentHash: "file2-hash",
   484  								Revision:    "rev",
   485  								ViewURL:     "file2-url",
   486  							},
   487  							Content: content("file2"),
   488  						},
   489  					}
   490  				}
   491  
   492  				expectGetConfigCall := func(hash string, err error, cfg *pb.Config) {
   493  					if cfg != nil {
   494  						cfg.ConfigSet = "ignore-me"
   495  						cfg.Path = "ignore-me"
   496  						cfg.Revision = "ignore-me"
   497  						cfg.ContentSha256 = "ignore-me"
   498  						cfg.Url = "ignore-me"
   499  					}
   500  					mockClient.EXPECT().GetConfig(gomock.Any(), proto.MatcherEqual(&pb.GetConfigRequest{
   501  						ConfigSet:     "projects/project",
   502  						ContentSha256: hash,
   503  					}), grpc.UseCompressor(grpcGzip.Name)).Return(cfg, err)
   504  				}
   505  
   506  				Convey("meta only", func() {
   507  					files, err := v2Impl.GetConfigs(ctx, "projects/project", filter, true)
   508  					So(err, ShouldBeNil)
   509  					So(files, ShouldResemble, expectedOutput(true))
   510  				})
   511  
   512  				Convey("small bodies", func() {
   513  					expectGetConfigCall("file1-hash", nil, &pb.Config{
   514  						Content: &pb.Config_RawContent{
   515  							RawContent: []byte("file1 content"),
   516  						},
   517  					})
   518  					expectGetConfigCall("file2-hash", nil, &pb.Config{
   519  						Content: &pb.Config_RawContent{
   520  							RawContent: []byte("file2 content"),
   521  						},
   522  					})
   523  
   524  					files, err := v2Impl.GetConfigs(ctx, "projects/project", filter, false)
   525  					So(err, ShouldBeNil)
   526  					So(files, ShouldResemble, expectedOutput(false))
   527  				})
   528  
   529  				Convey("single fetch err", func() {
   530  					expectGetConfigCall("file1-hash", nil, &pb.Config{
   531  						Content: &pb.Config_RawContent{
   532  							RawContent: []byte("file1 content"),
   533  						},
   534  					})
   535  					expectGetConfigCall("file2-hash",
   536  						status.Errorf(codes.Internal, "server internal error"), nil)
   537  
   538  					files, err := v2Impl.GetConfigs(ctx, "projects/project", filter, false)
   539  					So(files, ShouldBeNil)
   540  					So(err, ShouldHaveGRPCStatus, codes.Internal, "server internal error")
   541  					So(transient.Tag.In(err), ShouldBeTrue)
   542  				})
   543  
   544  				Convey("single fetch unexpectedly missing", func() {
   545  					expectGetConfigCall("file1-hash", nil, &pb.Config{
   546  						Content: &pb.Config_RawContent{
   547  							RawContent: []byte("file1 content"),
   548  						},
   549  					})
   550  					expectGetConfigCall("file2-hash",
   551  						status.Errorf(codes.NotFound, "gone but why"), nil)
   552  
   553  					files, err := v2Impl.GetConfigs(ctx, "projects/project", filter, false)
   554  					So(files, ShouldBeNil)
   555  					So(err, ShouldErrLike, "is unexpectedly gone")
   556  					So(transient.Tag.In(err), ShouldBeFalse)
   557  				})
   558  
   559  				Convey("large body - ok", func(c C) {
   560  					signedURLServer := signedURLServer(c, "file2 content")
   561  					defer signedURLServer.Close()
   562  
   563  					expectGetConfigCall("file1-hash", nil, &pb.Config{
   564  						Content: &pb.Config_RawContent{
   565  							RawContent: []byte("file1 content"),
   566  						},
   567  					})
   568  					expectGetConfigCall("file2-hash", nil, &pb.Config{
   569  						Content: &pb.Config_SignedUrl{
   570  							SignedUrl: signedURLServer.URL,
   571  						},
   572  					})
   573  
   574  					files, err := v2Impl.GetConfigs(ctx, "projects/project", filter, false)
   575  					So(err, ShouldBeNil)
   576  					So(files, ShouldResemble, expectedOutput(false))
   577  				})
   578  
   579  				Convey("large body - err", func(c C) {
   580  					signedURLServer := signedURLServer(c, "file2 content")
   581  					defer signedURLServer.Close()
   582  
   583  					expectGetConfigCall("file1-hash", nil, &pb.Config{
   584  						Content: &pb.Config_RawContent{
   585  							RawContent: []byte("file1 content"),
   586  						},
   587  					})
   588  					expectGetConfigCall("file2-hash", nil, &pb.Config{
   589  						Content: &pb.Config_SignedUrl{
   590  							SignedUrl: signedURLServer.URL + "/err",
   591  						},
   592  					})
   593  
   594  					files, err := v2Impl.GetConfigs(ctx, "projects/project", filter, false)
   595  					So(files, ShouldBeNil)
   596  					So(err, ShouldErrLike, `fetching "file2" from signed URL: failed to download file`)
   597  					So(transient.Tag.In(err), ShouldBeTrue)
   598  				})
   599  			})
   600  		})
   601  	})
   602  }
   603  
   604  func signedURLServer(c C, body string) *httptest.Server {
   605  	return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
   606  		if strings.HasSuffix(r.URL.String(), "err") {
   607  			w.WriteHeader(http.StatusInternalServerError)
   608  			_, err := w.Write([]byte(body))
   609  			c.So(err, ShouldBeNil)
   610  			return
   611  		}
   612  		buf := &bytes.Buffer{}
   613  		gw := gzip.NewWriter(buf)
   614  		_, err := gw.Write([]byte(body))
   615  		c.So(err, ShouldBeNil)
   616  		c.So(gw.Close(), ShouldBeNil)
   617  		w.Header().Set("Content-Encoding", "gzip")
   618  		_, err = w.Write(buf.Bytes())
   619  		c.So(err, ShouldBeNil)
   620  	}))
   621  }