go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/config_service/internal/importer/importer_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 importer
    16  
    17  import (
    18  	"archive/tar"
    19  	"bytes"
    20  	"context"
    21  	"crypto/sha256"
    22  	"encoding/hex"
    23  	"fmt"
    24  	"math/rand"
    25  	"net/http"
    26  	"net/http/httptest"
    27  	"sort"
    28  	"strings"
    29  	"testing"
    30  
    31  	"cloud.google.com/go/storage"
    32  	"github.com/golang/mock/gomock"
    33  	"github.com/julienschmidt/httprouter"
    34  	"github.com/klauspost/compress/gzip"
    35  	"google.golang.org/protobuf/proto"
    36  	"google.golang.org/protobuf/types/known/timestamppb"
    37  
    38  	"go.chromium.org/luci/auth/identity"
    39  	"go.chromium.org/luci/common/clock"
    40  	"go.chromium.org/luci/common/errors"
    41  	"go.chromium.org/luci/common/gcloud/gs"
    42  	protoutil "go.chromium.org/luci/common/proto"
    43  	cfgcommonpb "go.chromium.org/luci/common/proto/config"
    44  	"go.chromium.org/luci/common/proto/git"
    45  	gitilespb "go.chromium.org/luci/common/proto/gitiles"
    46  	"go.chromium.org/luci/common/proto/gitiles/mock_gitiles"
    47  	"go.chromium.org/luci/common/tsmon"
    48  	"go.chromium.org/luci/config"
    49  	"go.chromium.org/luci/gae/service/datastore"
    50  	"go.chromium.org/luci/server/auth"
    51  	"go.chromium.org/luci/server/auth/authtest"
    52  	"go.chromium.org/luci/server/router"
    53  	"go.chromium.org/luci/server/tq"
    54  	"go.chromium.org/luci/server/tq/tqtesting"
    55  
    56  	"go.chromium.org/luci/config_service/internal/clients"
    57  	"go.chromium.org/luci/config_service/internal/common"
    58  	"go.chromium.org/luci/config_service/internal/metrics"
    59  	"go.chromium.org/luci/config_service/internal/model"
    60  	"go.chromium.org/luci/config_service/internal/settings"
    61  	"go.chromium.org/luci/config_service/internal/taskpb"
    62  	"go.chromium.org/luci/config_service/internal/validation"
    63  	"go.chromium.org/luci/config_service/testutil"
    64  
    65  	. "github.com/smartystreets/goconvey/convey"
    66  	. "go.chromium.org/luci/common/testing/assertions"
    67  )
    68  
    69  func TestImportAllConfigs(t *testing.T) {
    70  	t.Parallel()
    71  
    72  	Convey("import all configs", t, func() {
    73  		ctx := testutil.SetupContext()
    74  		disp := &tq.Dispatcher{}
    75  		ctx, sch := tq.TestingContext(ctx, disp)
    76  		ctx = settings.WithGlobalConfigLoc(ctx, &cfgcommonpb.GitilesLocation{
    77  			Repo: "https://a.googlesource.com/infradata/config",
    78  			Ref:  "refs/heads/main",
    79  			Path: "dev-configs",
    80  		})
    81  
    82  		ctl := gomock.NewController(t)
    83  		defer ctl.Finish()
    84  		mockClient := mock_gitiles.NewMockGitilesClient(ctl)
    85  		ctx = context.WithValue(ctx, &clients.MockGitilesClientKey, mockClient)
    86  		importer := &Importer{}
    87  		importer.registerTQTask(disp)
    88  
    89  		Convey("ok", func() {
    90  			mockClient.EXPECT().ListFiles(gomock.Any(), protoutil.MatcherEqual(
    91  				&gitilespb.ListFilesRequest{
    92  					Project:    "infradata/config",
    93  					Committish: "refs/heads/main",
    94  					Path:       "dev-configs",
    95  				},
    96  			)).Return(&gitilespb.ListFilesResponse{
    97  				Files: []*git.File{
    98  					{
    99  						Id:   "hash1",
   100  						Path: "service1",
   101  						Type: git.File_TREE,
   102  					},
   103  					{
   104  						Id:   "hash2",
   105  						Path: "service2",
   106  						Type: git.File_TREE,
   107  					},
   108  					{
   109  						Id:   "hash3",
   110  						Path: "file1",
   111  						Type: git.File_BLOB,
   112  					},
   113  				},
   114  			}, nil)
   115  
   116  			Convey("projects.cfg File entity not exist", func() {
   117  				err := importAllConfigs(ctx, disp)
   118  				So(err, ShouldBeNil)
   119  				cfgSetsInQueue := getCfgSetsInTaskQueue(sch)
   120  				So(cfgSetsInQueue, ShouldResemble, []string{"services/service1", "services/service2"})
   121  			})
   122  
   123  			Convey("projects.cfg File entity exist", func() {
   124  				testutil.InjectSelfConfigs(ctx, map[string]proto.Message{
   125  					"projects.cfg": &cfgcommonpb.ProjectsCfg{
   126  						Projects: []*cfgcommonpb.Project{
   127  							{Id: "proj1"},
   128  						},
   129  					},
   130  				})
   131  				err := importAllConfigs(ctx, disp)
   132  				So(err, ShouldBeNil)
   133  				cfgSetsInQueue := getCfgSetsInTaskQueue(sch)
   134  				So(cfgSetsInQueue, ShouldResemble, []string{"projects/proj1", "services/service1", "services/service2"})
   135  			})
   136  
   137  			Convey("delete stale config set", func() {
   138  				stale := &model.ConfigSet{ID: config.MustServiceSet("stale")}
   139  				So(datastore.Put(ctx, stale), ShouldBeNil)
   140  				err := importAllConfigs(ctx, disp)
   141  				So(err, ShouldBeNil)
   142  				So(datastore.Get(ctx, stale), ShouldEqual, datastore.ErrNoSuchEntity)
   143  			})
   144  		})
   145  
   146  		Convey("error", func() {
   147  			Convey("gitiles", func() {
   148  				mockClient.EXPECT().ListFiles(gomock.Any(), gomock.Any()).Return(nil, errors.New("gitiles error"))
   149  				err := importAllConfigs(ctx, disp)
   150  				So(err, ShouldErrLike, "failed to load service config sets: failed to call Gitiles to list files: gitiles error")
   151  			})
   152  
   153  			Convey("bad projects.cfg content", func() {
   154  				mockClient.EXPECT().ListFiles(gomock.Any(), gomock.Any()).Return(&gitilespb.ListFilesResponse{}, nil)
   155  				testutil.InjectSelfConfigs(ctx, map[string]proto.Message{
   156  					"projects.cfg": &cfgcommonpb.ServicesCfg{
   157  						Services: []*cfgcommonpb.Service{
   158  							{Id: "my-service"},
   159  						}}, // bad type
   160  				})
   161  				So(importAllConfigs(ctx, disp), ShouldErrLike, `failed to load project config sets: failed to unmarshal file "projects.cfg": proto`)
   162  			})
   163  		})
   164  	})
   165  }
   166  
   167  type mockValidator struct {
   168  	result *cfgcommonpb.ValidationResult
   169  	err    error
   170  }
   171  
   172  func (mv *mockValidator) Validate(context.Context, config.Set, []validation.File) (*cfgcommonpb.ValidationResult, error) {
   173  	return mv.result, mv.err
   174  }
   175  
   176  func TestImportConfigSet(t *testing.T) {
   177  	t.Parallel()
   178  
   179  	Convey("import single ConfigSet", t, func() {
   180  		ctx := testutil.SetupContext()
   181  		ctx = settings.WithGlobalConfigLoc(ctx, &cfgcommonpb.GitilesLocation{
   182  			Repo: "https://a.googlesource.com/infradata/config",
   183  			Ref:  "refs/heads/main",
   184  			Path: "dev-configs",
   185  		})
   186  		ctx, _ = tsmon.WithDummyInMemory(ctx)
   187  		ctl := gomock.NewController(t)
   188  		mockGtClient := mock_gitiles.NewMockGitilesClient(ctl)
   189  		ctx = context.WithValue(ctx, &clients.MockGitilesClientKey, mockGtClient)
   190  		mockGsClient := clients.NewMockGsClient(ctl)
   191  		ctx = clients.WithGsClient(ctx, mockGsClient)
   192  		const testGSBucket = "test-bucket"
   193  		cs := config.MustServiceSet("my-service")
   194  		latestCommit := &git.Commit{
   195  			Id: "latest revision",
   196  			Committer: &git.Commit_User{
   197  				Name:  "user",
   198  				Email: "user@gmail.com",
   199  				Time:  timestamppb.New(datastore.RoundTime(clock.Now(ctx).UTC())),
   200  			},
   201  			Author: &git.Commit_User{
   202  				Email: "author@gmail.com",
   203  			},
   204  		}
   205  		expectedLatestRevInfo := model.RevisionInfo{
   206  			ID:             latestCommit.Id,
   207  			CommitTime:     latestCommit.Committer.Time.AsTime(),
   208  			CommitterEmail: latestCommit.Committer.Email,
   209  			AuthorEmail:    latestCommit.Author.Email,
   210  		}
   211  		mockValidator := &mockValidator{}
   212  		importer := &Importer{
   213  			Validator: mockValidator,
   214  			GSBucket:  testGSBucket,
   215  		}
   216  
   217  		Convey("happy path", func() {
   218  			Convey("success import", func() {
   219  				mockGtClient.EXPECT().Log(gomock.Any(), protoutil.MatcherEqual(
   220  					&gitilespb.LogRequest{
   221  						Project:    "infradata/config",
   222  						Committish: "refs/heads/main",
   223  						Path:       "dev-configs/myservice",
   224  						PageSize:   1,
   225  					},
   226  				)).Return(&gitilespb.LogResponse{
   227  					Log: []*git.Commit{latestCommit},
   228  				}, nil)
   229  				tarGzContent, err := buildTarGz(map[string]any{"file1": "file1 content", "sub_dir/file2": "file2 content", "sub_dir/": "", "empty_file": ""})
   230  				So(err, ShouldBeNil)
   231  				mockGtClient.EXPECT().Archive(gomock.Any(), protoutil.MatcherEqual(
   232  					&gitilespb.ArchiveRequest{
   233  						Project: "infradata/config",
   234  						Ref:     latestCommit.Id,
   235  						Path:    "dev-configs/myservice",
   236  						Format:  gitilespb.ArchiveRequest_GZIP,
   237  					},
   238  				)).Return(&gitilespb.ArchiveResponse{
   239  					Contents: tarGzContent,
   240  				}, nil)
   241  				var recordedGSPaths []gs.Path
   242  				mockGsClient.EXPECT().UploadIfMissing(
   243  					gomock.Any(), gomock.Eq(testGSBucket),
   244  					gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes().DoAndReturn(
   245  					func(ctx context.Context, bucket, object string, data []byte, attrsModifyFn func(*storage.ObjectAttrs)) (bool, error) {
   246  						recordedGSPaths = append(recordedGSPaths, gs.MakePath(bucket, object))
   247  						return true, nil
   248  					},
   249  				)
   250  
   251  				err = importer.ImportConfigSet(ctx, config.MustServiceSet("myservice"))
   252  				So(err, ShouldBeNil)
   253  				cfgSet := &model.ConfigSet{
   254  					ID: config.MustServiceSet("myservice"),
   255  				}
   256  				attempt := &model.ImportAttempt{
   257  					ConfigSet: datastore.KeyForObj(ctx, cfgSet),
   258  				}
   259  				revKey := datastore.MakeKey(ctx, model.ConfigSetKind, "services/myservice", model.RevisionKind, latestCommit.Id)
   260  				var files []*model.File
   261  				So(datastore.Get(ctx, cfgSet, attempt), ShouldBeNil)
   262  				So(datastore.GetAll(ctx, datastore.NewQuery(model.FileKind).Ancestor(revKey), &files), ShouldBeNil)
   263  
   264  				So(cfgSet.Location, ShouldResembleProto, &cfgcommonpb.Location{
   265  					Location: &cfgcommonpb.Location_GitilesLocation{
   266  						GitilesLocation: &cfgcommonpb.GitilesLocation{
   267  							Repo: "https://a.googlesource.com/infradata/config",
   268  							Ref:  "refs/heads/main",
   269  							Path: "dev-configs/myservice",
   270  						},
   271  					},
   272  				})
   273  				So(cfgSet.LatestRevision.Location, ShouldResembleProto, &cfgcommonpb.Location{
   274  					Location: &cfgcommonpb.Location_GitilesLocation{
   275  						GitilesLocation: &cfgcommonpb.GitilesLocation{
   276  							Repo: "https://a.googlesource.com/infradata/config",
   277  							Ref:  latestCommit.Id,
   278  							Path: "dev-configs/myservice",
   279  						},
   280  					},
   281  				})
   282  				// Drop the `Location` as model ConfigSet has to use ShouldResemble which
   283  				// will not work if it contains proto. Same for other tests.
   284  				cfgSet.Location = nil
   285  				cfgSet.LatestRevision.Location = nil
   286  				So(cfgSet, ShouldResemble, &model.ConfigSet{
   287  					ID:             "services/myservice",
   288  					LatestRevision: expectedLatestRevInfo,
   289  					Version:        model.CurrentCfgSetVersion,
   290  				})
   291  
   292  				So(files, ShouldHaveLength, 3)
   293  				sort.Slice(files, func(i, j int) bool {
   294  					return strings.Compare(files[i].Path, files[j].Path) < 0
   295  				})
   296  				So(files[0].Location, ShouldResembleProto, &cfgcommonpb.Location{
   297  					Location: &cfgcommonpb.Location_GitilesLocation{
   298  						GitilesLocation: &cfgcommonpb.GitilesLocation{
   299  							Repo: "https://a.googlesource.com/infradata/config",
   300  							Ref:  latestCommit.Id,
   301  							Path: "dev-configs/myservice/empty_file",
   302  						},
   303  					},
   304  				})
   305  				files[0].Location = nil
   306  				expectedSha256, expectedContent0, err := hashAndCompressConfig(bytes.NewBuffer([]byte("")))
   307  				So(err, ShouldBeNil)
   308  				So(files[0], ShouldResemble, &model.File{
   309  					Path:          "empty_file",
   310  					Revision:      revKey,
   311  					CreateTime:    datastore.RoundTime(clock.Now(ctx).UTC()),
   312  					Content:       expectedContent0,
   313  					ContentSHA256: expectedSha256,
   314  					GcsURI:        gs.MakePath(testGSBucket, fmt.Sprintf("%s/sha256/%s", common.GSProdCfgFolder, expectedSha256)),
   315  				})
   316  				So(files[1].Location, ShouldResembleProto, &cfgcommonpb.Location{
   317  					Location: &cfgcommonpb.Location_GitilesLocation{
   318  						GitilesLocation: &cfgcommonpb.GitilesLocation{
   319  							Repo: "https://a.googlesource.com/infradata/config",
   320  							Ref:  latestCommit.Id,
   321  							Path: "dev-configs/myservice/file1",
   322  						},
   323  					},
   324  				})
   325  				files[1].Location = nil
   326  				expectedSha256, expectedContent1, err := hashAndCompressConfig(bytes.NewBuffer([]byte("file1 content")))
   327  				So(err, ShouldBeNil)
   328  				So(files[1], ShouldResemble, &model.File{
   329  					Path:          "file1",
   330  					Revision:      revKey,
   331  					CreateTime:    datastore.RoundTime(clock.Now(ctx).UTC()),
   332  					Content:       expectedContent1,
   333  					ContentSHA256: expectedSha256,
   334  					Size:          int64(len("file1 content")),
   335  					GcsURI:        gs.MakePath(testGSBucket, fmt.Sprintf("%s/sha256/%s", common.GSProdCfgFolder, expectedSha256)),
   336  				})
   337  				So(files[2].Location, ShouldResembleProto, &cfgcommonpb.Location{
   338  					Location: &cfgcommonpb.Location_GitilesLocation{
   339  						GitilesLocation: &cfgcommonpb.GitilesLocation{
   340  							Repo: "https://a.googlesource.com/infradata/config",
   341  							Ref:  latestCommit.Id,
   342  							Path: "dev-configs/myservice/sub_dir/file2",
   343  						},
   344  					},
   345  				})
   346  				files[2].Location = nil
   347  				expectedSha256, expectedContent2, err := hashAndCompressConfig(bytes.NewBuffer([]byte("file2 content")))
   348  				So(err, ShouldBeNil)
   349  				So(files[2], ShouldResemble, &model.File{
   350  					Path:          "sub_dir/file2",
   351  					Revision:      revKey,
   352  					CreateTime:    datastore.RoundTime(clock.Now(ctx).UTC()),
   353  					Content:       expectedContent2,
   354  					ContentSHA256: expectedSha256,
   355  					Size:          int64(len("file2 content")),
   356  					GcsURI:        gs.MakePath(testGSBucket, fmt.Sprintf("%s/sha256/%s", common.GSProdCfgFolder, expectedSha256)),
   357  				})
   358  
   359  				So(recordedGSPaths, ShouldHaveLength, len(files))
   360  				sort.Slice(recordedGSPaths, func(i, j int) bool {
   361  					return strings.Compare(string(recordedGSPaths[i]), string(recordedGSPaths[j])) < 0
   362  				})
   363  				expectedGSPaths := make([]gs.Path, len(files))
   364  				for i, f := range files {
   365  					expectedGSPaths[i] = f.GcsURI
   366  				}
   367  				sort.Slice(expectedGSPaths, func(i, j int) bool {
   368  					return strings.Compare(string(expectedGSPaths[i]), string(expectedGSPaths[j])) < 0
   369  				})
   370  				So(recordedGSPaths, ShouldResemble, expectedGSPaths)
   371  
   372  				So(attempt.Revision.Location, ShouldResembleProto, &cfgcommonpb.Location{
   373  					Location: &cfgcommonpb.Location_GitilesLocation{
   374  						GitilesLocation: &cfgcommonpb.GitilesLocation{
   375  							Repo: "https://a.googlesource.com/infradata/config",
   376  							Ref:  latestCommit.Id,
   377  							Path: "dev-configs/myservice",
   378  						},
   379  					},
   380  				})
   381  				attempt.Revision.Location = nil
   382  				So(attempt, ShouldResemble, &model.ImportAttempt{
   383  					ConfigSet: datastore.KeyForObj(ctx, cfgSet),
   384  					Revision:  expectedLatestRevInfo,
   385  					Success:   true,
   386  					Message:   "Imported",
   387  				})
   388  			})
   389  
   390  			Convey("same git revision", func() {
   391  				loc := &cfgcommonpb.Location{
   392  					Location: &cfgcommonpb.Location_GitilesLocation{
   393  						GitilesLocation: &cfgcommonpb.GitilesLocation{
   394  							Repo: "https://a.googlesource.com/infradata/config",
   395  							Ref:  "refs/heads/main",
   396  							Path: "dev-configs/myservice",
   397  						},
   398  					},
   399  				}
   400  				cfgSetBeforeImport := &model.ConfigSet{
   401  					ID:             config.MustServiceSet("myservice"),
   402  					Location:       loc,
   403  					LatestRevision: model.RevisionInfo{ID: latestCommit.Id},
   404  				}
   405  
   406  				So(datastore.Put(ctx, cfgSetBeforeImport), ShouldBeNil)
   407  				mockGtClient.EXPECT().Log(gomock.Any(), gomock.Any()).Return(&gitilespb.LogResponse{
   408  					Log: []*git.Commit{latestCommit},
   409  				}, nil)
   410  
   411  				Convey("last attempt succeeded", func() {
   412  					lastAttempt := &model.ImportAttempt{
   413  						ConfigSet: datastore.KeyForObj(ctx, cfgSetBeforeImport),
   414  						Success:   true,
   415  						Message:   "imported",
   416  					}
   417  					So(datastore.Put(ctx, cfgSetBeforeImport, lastAttempt), ShouldBeNil)
   418  
   419  					err := importer.ImportConfigSet(ctx, config.MustServiceSet("myservice"))
   420  
   421  					So(err, ShouldBeNil)
   422  					cfgSetAfterImport := &model.ConfigSet{
   423  						ID: config.MustServiceSet("myservice"),
   424  					}
   425  					So(datastore.Get(ctx, cfgSetAfterImport), ShouldBeNil)
   426  					So(cfgSetAfterImport.Location, ShouldResembleProto, loc)
   427  					cfgSetAfterImport.Location = nil
   428  					cfgSetBeforeImport.Location = nil
   429  					So(cfgSetAfterImport, ShouldResemble, cfgSetBeforeImport)
   430  					attempt := &model.ImportAttempt{ConfigSet: datastore.KeyForObj(ctx, cfgSetAfterImport)}
   431  					So(datastore.Get(ctx, attempt), ShouldBeNil)
   432  					So(attempt.Revision.Location, ShouldResembleProto, &cfgcommonpb.Location{
   433  						Location: &cfgcommonpb.Location_GitilesLocation{
   434  							GitilesLocation: &cfgcommonpb.GitilesLocation{
   435  								Repo: "https://a.googlesource.com/infradata/config",
   436  								Ref:  latestCommit.Id,
   437  								Path: "dev-configs/myservice",
   438  							},
   439  						},
   440  					})
   441  					attempt.Revision.Location = nil
   442  					So(attempt, ShouldResemble, &model.ImportAttempt{
   443  						ConfigSet: datastore.KeyForObj(ctx, cfgSetAfterImport),
   444  						Success:   true,
   445  						Message:   "Up-to-date",
   446  						Revision: model.RevisionInfo{
   447  							ID:             latestCommit.Id,
   448  							CommitTime:     latestCommit.Committer.Time.AsTime(),
   449  							CommitterEmail: latestCommit.Committer.Email,
   450  							AuthorEmail:    latestCommit.Author.Email,
   451  						},
   452  					})
   453  				})
   454  
   455  				Convey("last attempt succeeded but with validation msg", func() {
   456  					lastAttempt := &model.ImportAttempt{
   457  						ConfigSet: datastore.KeyForObj(ctx, cfgSetBeforeImport),
   458  						Success:   true,
   459  						Message:   "imported with warnings",
   460  						ValidationResult: &cfgcommonpb.ValidationResult{
   461  							Messages: []*cfgcommonpb.ValidationResult_Message{
   462  								{
   463  									Path:     "foo.cfg",
   464  									Severity: cfgcommonpb.ValidationResult_WARNING,
   465  									Text:     "there is a warning",
   466  								},
   467  							},
   468  						},
   469  					}
   470  					So(datastore.Put(ctx, lastAttempt), ShouldBeNil)
   471  
   472  					tarGzContent, err := buildTarGz(map[string]any{"foo.cfg": "content"})
   473  					So(err, ShouldBeNil)
   474  					mockGtClient.EXPECT().Archive(gomock.Any(), gomock.Any()).Return(&gitilespb.ArchiveResponse{
   475  						Contents: tarGzContent,
   476  					}, nil)
   477  					mockGsClient.EXPECT().UploadIfMissing(
   478  						gomock.Any(), gomock.Eq(testGSBucket),
   479  						gomock.Any(), gomock.Any(), gomock.Any()).Return(true, nil)
   480  					mockValidator.result = lastAttempt.ValidationResult
   481  
   482  					err = importer.ImportConfigSet(ctx, config.MustServiceSet("myservice"))
   483  
   484  					So(err, ShouldBeNil)
   485  					currentAttempt := &model.ImportAttempt{ConfigSet: datastore.KeyForObj(ctx, cfgSetBeforeImport)}
   486  					So(datastore.Get(ctx, currentAttempt), ShouldBeNil)
   487  					So(currentAttempt.ValidationResult, ShouldResembleProto, lastAttempt.ValidationResult)
   488  					So(currentAttempt.Success, ShouldBeTrue)
   489  				})
   490  
   491  				Convey("last attempt not succeeded", func() {
   492  					lastAttempt := &model.ImportAttempt{
   493  						ConfigSet: datastore.KeyForObj(ctx, cfgSetBeforeImport),
   494  						Success:   false,
   495  						Message:   "transient gitilies error",
   496  					}
   497  					So(datastore.Put(ctx, lastAttempt), ShouldBeNil)
   498  
   499  					tarGzContent, err := buildTarGz(map[string]any{"foo.cfg": "content"})
   500  					So(err, ShouldBeNil)
   501  					mockGtClient.EXPECT().Archive(gomock.Any(), gomock.Any()).Return(&gitilespb.ArchiveResponse{
   502  						Contents: tarGzContent,
   503  					}, nil)
   504  					mockGsClient.EXPECT().UploadIfMissing(
   505  						gomock.Any(), gomock.Eq(testGSBucket),
   506  						gomock.Any(), gomock.Any(), gomock.Any()).Return(true, nil)
   507  
   508  					err = importer.ImportConfigSet(ctx, config.MustServiceSet("myservice"))
   509  
   510  					So(err, ShouldBeNil)
   511  					currentAttempt := &model.ImportAttempt{ConfigSet: datastore.KeyForObj(ctx, cfgSetBeforeImport)}
   512  					So(datastore.Get(ctx, currentAttempt), ShouldBeNil)
   513  					So(currentAttempt.Success, ShouldBeTrue)
   514  				})
   515  			})
   516  
   517  			Convey("empty archive", func() {
   518  				mockGtClient.EXPECT().Log(gomock.Any(), protoutil.MatcherEqual(
   519  					&gitilespb.LogRequest{
   520  						Project:    "infradata/config",
   521  						Committish: "refs/heads/main",
   522  						Path:       "dev-configs/myservice",
   523  						PageSize:   1,
   524  					},
   525  				)).Return(&gitilespb.LogResponse{
   526  					Log: []*git.Commit{latestCommit},
   527  				}, nil)
   528  				mockGtClient.EXPECT().Archive(gomock.Any(), gomock.Any()).Return(&gitilespb.ArchiveResponse{}, nil)
   529  
   530  				err := importer.ImportConfigSet(ctx, config.MustServiceSet("myservice"))
   531  
   532  				So(err, ShouldBeNil)
   533  				cfgSet := &model.ConfigSet{
   534  					ID: config.MustServiceSet("myservice"),
   535  				}
   536  				attempt := &model.ImportAttempt{
   537  					ConfigSet: datastore.KeyForObj(ctx, cfgSet),
   538  				}
   539  				revKey := datastore.MakeKey(ctx, model.ConfigSetKind, "services/myservice", model.RevisionKind, latestCommit.Id)
   540  				So(datastore.Get(ctx, cfgSet, attempt), ShouldBeNil)
   541  				var files []*model.File
   542  				So(datastore.Run(ctx, datastore.NewQuery(model.FileKind).Ancestor(revKey), func(f *model.File) {
   543  					files = append(files, f)
   544  				}), ShouldBeNil)
   545  				So(files, ShouldHaveLength, 0)
   546  
   547  				So(cfgSet.Location, ShouldResembleProto, &cfgcommonpb.Location{
   548  					Location: &cfgcommonpb.Location_GitilesLocation{
   549  						GitilesLocation: &cfgcommonpb.GitilesLocation{
   550  							Repo: "https://a.googlesource.com/infradata/config",
   551  							Ref:  "refs/heads/main",
   552  							Path: "dev-configs/myservice",
   553  						},
   554  					},
   555  				})
   556  				So(cfgSet.LatestRevision.Location, ShouldResembleProto, &cfgcommonpb.Location{
   557  					Location: &cfgcommonpb.Location_GitilesLocation{
   558  						GitilesLocation: &cfgcommonpb.GitilesLocation{
   559  							Repo: "https://a.googlesource.com/infradata/config",
   560  							Ref:  latestCommit.Id,
   561  							Path: "dev-configs/myservice",
   562  						},
   563  					},
   564  				})
   565  
   566  				cfgSet.Location = nil
   567  				cfgSet.LatestRevision.Location = nil
   568  				So(cfgSet, ShouldResemble, &model.ConfigSet{
   569  					ID:             "services/myservice",
   570  					LatestRevision: expectedLatestRevInfo,
   571  					Version:        model.CurrentCfgSetVersion,
   572  				})
   573  
   574  				So(attempt.Success, ShouldBeTrue)
   575  				So(attempt.Message, ShouldEqual, "No Configs. Imported as empty")
   576  			})
   577  
   578  			Convey("config set location change", func() {
   579  				cfgSetBeforeImport := &model.ConfigSet{
   580  					ID: config.MustServiceSet("myservice"),
   581  					Location: &cfgcommonpb.Location{
   582  						Location: &cfgcommonpb.Location_GitilesLocation{
   583  							GitilesLocation: &cfgcommonpb.GitilesLocation{
   584  								Repo: "https://a.googlesource.com/infradata/config",
   585  								Ref:  "stale",
   586  								Path: "dev-configs/myservice",
   587  							},
   588  						},
   589  					},
   590  					LatestRevision: model.RevisionInfo{ID: latestCommit.Id},
   591  				}
   592  				So(datastore.Put(ctx, cfgSetBeforeImport), ShouldBeNil)
   593  				mockGtClient.EXPECT().Log(gomock.Any(), protoutil.MatcherEqual(
   594  					&gitilespb.LogRequest{
   595  						Project:    "infradata/config",
   596  						Committish: "refs/heads/main",
   597  						Path:       "dev-configs/myservice",
   598  						PageSize:   1,
   599  					},
   600  				)).Return(&gitilespb.LogResponse{
   601  					Log: []*git.Commit{latestCommit},
   602  				}, nil)
   603  				mockGtClient.EXPECT().Archive(gomock.Any(), gomock.Any()).Return(&gitilespb.ArchiveResponse{}, nil)
   604  
   605  				err := importer.ImportConfigSet(ctx, config.MustServiceSet("myservice"))
   606  
   607  				So(err, ShouldBeNil)
   608  				cfgSetAfterImport := &model.ConfigSet{
   609  					ID: config.MustServiceSet("myservice"),
   610  				}
   611  				So(datastore.Get(ctx, cfgSetAfterImport), ShouldBeNil)
   612  				So(cfgSetAfterImport.Location.GetGitilesLocation().Ref, ShouldNotEqual, cfgSetBeforeImport.Location.GetGitilesLocation().Ref)
   613  				So(cfgSetAfterImport.Location, ShouldResembleProto, &cfgcommonpb.Location{
   614  					Location: &cfgcommonpb.Location_GitilesLocation{
   615  						GitilesLocation: &cfgcommonpb.GitilesLocation{
   616  							Repo: "https://a.googlesource.com/infradata/config",
   617  							Ref:  "refs/heads/main",
   618  							Path: "dev-configs/myservice",
   619  						},
   620  					},
   621  				})
   622  			})
   623  
   624  			Convey("no logs", func() {
   625  				mockGtClient.EXPECT().Log(gomock.Any(), gomock.Any()).Return(&gitilespb.LogResponse{
   626  					Log: []*git.Commit{},
   627  				}, nil)
   628  				err := importer.ImportConfigSet(ctx, config.MustServiceSet("myservice"))
   629  				So(err, ShouldBeNil)
   630  				attempt := &model.ImportAttempt{
   631  					ConfigSet: datastore.MakeKey(ctx, model.ConfigSetKind, "services/myservice"),
   632  				}
   633  				So(datastore.Get(ctx, attempt), ShouldBeNil)
   634  				So(attempt.Success, ShouldBeTrue)
   635  				So(attempt.Message, ShouldContainSubstring, "no commit logs")
   636  			})
   637  
   638  			Convey("validate", func() {
   639  				mockGtClient.EXPECT().Log(gomock.Any(), gomock.Any()).Return(&gitilespb.LogResponse{
   640  					Log: []*git.Commit{latestCommit},
   641  				}, nil)
   642  
   643  				tarGzContent, err := buildTarGz(map[string]any{"foo.cfg": "content"})
   644  				So(err, ShouldBeNil)
   645  				mockGtClient.EXPECT().Archive(gomock.Any(), gomock.Any()).Return(&gitilespb.ArchiveResponse{
   646  					Contents: tarGzContent,
   647  				}, nil)
   648  				mockGsClient.EXPECT().UploadIfMissing(
   649  					gomock.Any(), gomock.Eq(testGSBucket),
   650  					gomock.Any(), gomock.Any(), gomock.Any()).Return(true, nil)
   651  
   652  				Convey("has warning", func() {
   653  					mockValidator.result = &cfgcommonpb.ValidationResult{
   654  						Messages: []*cfgcommonpb.ValidationResult_Message{
   655  							{
   656  								Path:     "foo.cfg",
   657  								Severity: cfgcommonpb.ValidationResult_WARNING,
   658  								Text:     "this is a warning",
   659  							},
   660  						},
   661  					}
   662  					err = importer.ImportConfigSet(ctx, cs)
   663  					So(err, ShouldBeNil)
   664  					revKey := datastore.MakeKey(ctx, model.ConfigSetKind, string(cs), model.RevisionKind, latestCommit.Id)
   665  					var files []*model.File
   666  					So(datastore.GetAll(ctx, datastore.NewQuery(model.FileKind).Ancestor(revKey), &files), ShouldBeNil)
   667  					So(files, ShouldHaveLength, 1)
   668  					So(files[0].Path, ShouldEqual, "foo.cfg")
   669  					attempt := &model.ImportAttempt{
   670  						ConfigSet: datastore.MakeKey(ctx, model.ConfigSetKind, string(cs)),
   671  					}
   672  					So(datastore.Get(ctx, attempt), ShouldBeNil)
   673  					So(attempt.Success, ShouldBeTrue)
   674  					So(attempt.Revision.ID, ShouldEqual, latestCommit.Id)
   675  					So(attempt.Message, ShouldEqual, "Imported with warnings")
   676  					So(attempt.ValidationResult, ShouldResembleProto, mockValidator.result)
   677  				})
   678  				Convey("has error", func() {
   679  					mockValidator.result = &cfgcommonpb.ValidationResult{
   680  						Messages: []*cfgcommonpb.ValidationResult_Message{
   681  							{
   682  								Path:     "foo.cfg",
   683  								Severity: cfgcommonpb.ValidationResult_ERROR,
   684  								Text:     "this is an error",
   685  							},
   686  						},
   687  					}
   688  
   689  					Convey("author email is google", func() {
   690  						latestCommit.Author = &git.Commit_User{
   691  							Name:  "author",
   692  							Email: "author@google.com",
   693  						}
   694  
   695  						err = importer.ImportConfigSet(ctx, cs)
   696  						So(err, ShouldBeNil)
   697  
   698  						So(metrics.RejectedCfgImportCounter.Get(ctx, string(cs), latestCommit.Id, "author"), ShouldEqual, 1)
   699  					})
   700  
   701  					Convey("author email is non-google", func() {
   702  						latestCommit.Author = &git.Commit_User{
   703  							Name:  "author",
   704  							Email: "author@chrmoium.org",
   705  						}
   706  
   707  						err = importer.ImportConfigSet(ctx, cs)
   708  						So(err, ShouldBeNil)
   709  
   710  						So(metrics.RejectedCfgImportCounter.Get(ctx, string(cs), latestCommit.Id, ""), ShouldEqual, 1)
   711  					})
   712  
   713  					revKey := datastore.MakeKey(ctx, model.ConfigSetKind, string(cs), model.RevisionKind, latestCommit.Id)
   714  					var files []*model.File
   715  					So(datastore.GetAll(ctx, datastore.NewQuery(model.FileKind).Ancestor(revKey), &files), ShouldBeNil)
   716  					So(files, ShouldBeEmpty)
   717  					attempt := &model.ImportAttempt{
   718  						ConfigSet: datastore.MakeKey(ctx, model.ConfigSetKind, string(cs)),
   719  					}
   720  					So(datastore.Get(ctx, attempt), ShouldBeNil)
   721  					So(attempt.Success, ShouldBeFalse)
   722  					So(attempt.Message, ShouldEqual, "Invalid config")
   723  					So(attempt.Revision.ID, ShouldEqual, latestCommit.Id)
   724  					So(attempt.ValidationResult, ShouldResembleProto, mockValidator.result)
   725  				})
   726  			})
   727  
   728  			Convey("large config", func() {
   729  				mockGtClient.EXPECT().Log(gomock.Any(), gomock.Any()).Return(&gitilespb.LogResponse{
   730  					Log: []*git.Commit{latestCommit},
   731  				}, nil)
   732  				// Construct incompressible data which is larger than compressedContentLimit.
   733  				incompressible := make([]byte, compressedContentLimit+1024*1024)
   734  				_, err := rand.New(rand.NewSource(1234)).Read(incompressible)
   735  				So(err, ShouldBeNil)
   736  				compressed, err := gzipCompress(incompressible)
   737  				So(err, ShouldBeNil)
   738  				So(len(compressed) > compressedContentLimit, ShouldBeTrue)
   739  
   740  				tarGzContent, err := buildTarGz(map[string]any{"large": incompressible})
   741  				So(err, ShouldBeNil)
   742  				expectedSha256 := sha256.Sum256(incompressible)
   743  				expectedGsFileName := "configs/sha256/" + hex.EncodeToString(expectedSha256[:])
   744  				mockGtClient.EXPECT().Archive(gomock.Any(), gomock.Any()).Return(&gitilespb.ArchiveResponse{
   745  					Contents: tarGzContent,
   746  				}, nil)
   747  				mockGsClient.EXPECT().UploadIfMissing(
   748  					gomock.Any(), gomock.Eq(testGSBucket),
   749  					gomock.Eq(expectedGsFileName), gomock.Any(), gomock.Any()).Return(true, nil)
   750  
   751  				err = importer.ImportConfigSet(ctx, config.MustServiceSet("myservice"))
   752  
   753  				So(err, ShouldBeNil)
   754  				revKey := datastore.MakeKey(ctx, model.ConfigSetKind, "services/myservice", model.RevisionKind, latestCommit.Id)
   755  				var files []*model.File
   756  				So(datastore.GetAll(ctx, datastore.NewQuery(model.FileKind).Ancestor(revKey), &files), ShouldBeNil)
   757  				So(files, ShouldHaveLength, 1)
   758  				file := files[0]
   759  				So(file.Path, ShouldEqual, "large")
   760  				So(file.Content, ShouldBeNil)
   761  				So(file.GcsURI, ShouldEqual, gs.MakePath(testGSBucket, expectedGsFileName))
   762  			})
   763  		})
   764  
   765  		Convey("unhappy path", func() {
   766  			Convey("bad config set format", func() {
   767  				err := importer.ImportConfigSet(ctx, config.Set("bad"))
   768  				So(err, ShouldErrLike, "Invalid config set")
   769  			})
   770  
   771  			Convey("project doesn't exist ", func() {
   772  				testutil.InjectSelfConfigs(ctx, map[string]proto.Message{
   773  					common.ProjRegistryFilePath: &cfgcommonpb.ProjectsCfg{
   774  						Projects: []*cfgcommonpb.Project{
   775  							{
   776  								Id: "proj",
   777  								Location: &cfgcommonpb.Project_GitilesLocation{
   778  									GitilesLocation: &cfgcommonpb.GitilesLocation{
   779  										Repo: "https://chromium.googlesource.com/infra/infra",
   780  										Ref:  "refs/heads/main",
   781  										Path: "generated",
   782  									},
   783  								},
   784  							},
   785  						},
   786  					},
   787  				})
   788  				err := importer.ImportConfigSet(ctx, config.MustProjectSet("unknown_proj"))
   789  				So(err, ShouldErrLike, `project "unknown_proj" not exist or has no gitiles location`)
   790  				So(ErrFatalTag.In(err), ShouldBeTrue)
   791  			})
   792  
   793  			Convey("no project gitiles location", func() {
   794  				testutil.InjectSelfConfigs(ctx, map[string]proto.Message{
   795  					common.ProjRegistryFilePath: &cfgcommonpb.ProjectsCfg{
   796  						Projects: []*cfgcommonpb.Project{
   797  							{Id: "proj"},
   798  						},
   799  					},
   800  				})
   801  				err := importer.ImportConfigSet(ctx, config.MustProjectSet("proj"))
   802  				So(err, ShouldErrLike, `project "proj" not exist or has no gitiles location`)
   803  				So(ErrFatalTag.In(err), ShouldBeTrue)
   804  			})
   805  
   806  			Convey("cannot fetch logs", func() {
   807  				mockGtClient.EXPECT().Log(gomock.Any(), gomock.Any()).Return(nil, errors.New("gitiles internal errors"))
   808  				err := importer.ImportConfigSet(ctx, config.MustServiceSet("myservice"))
   809  				So(err, ShouldErrLike, "cannot fetch logs", "gitiles internal errors")
   810  				attempt := &model.ImportAttempt{
   811  					ConfigSet: datastore.MakeKey(ctx, model.ConfigSetKind, "services/myservice"),
   812  				}
   813  				So(datastore.Get(ctx, attempt), ShouldBeNil)
   814  				So(attempt.Success, ShouldBeFalse)
   815  				So(attempt.Message, ShouldContainSubstring, "cannot fetch logs")
   816  			})
   817  
   818  			Convey("bad tar file", func() {
   819  				loc := &cfgcommonpb.Location{
   820  					Location: &cfgcommonpb.Location_GitilesLocation{
   821  						GitilesLocation: &cfgcommonpb.GitilesLocation{
   822  							Repo: "https://a.googlesource.com/infradata/config",
   823  							Ref:  "refs/heads/main",
   824  							Path: "dev-configs/myservice",
   825  						},
   826  					},
   827  				}
   828  				cfgSetBeforeImport := &model.ConfigSet{
   829  					ID:             config.MustServiceSet("myservice"),
   830  					Location:       loc,
   831  					LatestRevision: model.RevisionInfo{ID: "old revision"},
   832  				}
   833  				So(datastore.Put(ctx, cfgSetBeforeImport), ShouldBeNil)
   834  				mockGtClient.EXPECT().Log(gomock.Any(), gomock.Any()).Return(&gitilespb.LogResponse{
   835  					Log: []*git.Commit{latestCommit},
   836  				}, nil)
   837  				mockGtClient.EXPECT().Archive(gomock.Any(), gomock.Any()).Return(&gitilespb.ArchiveResponse{
   838  					Contents: []byte("invalid .tar.gz content"),
   839  				}, nil)
   840  
   841  				err := importer.ImportConfigSet(ctx, config.MustServiceSet("myservice"))
   842  
   843  				So(err, ShouldErrLike, "Failed to import services/myservice revision latest revision")
   844  				cfgSetAfterImport := &model.ConfigSet{
   845  					ID: config.MustServiceSet("myservice"),
   846  				}
   847  				attempt := &model.ImportAttempt{
   848  					ConfigSet: datastore.KeyForObj(ctx, cfgSetAfterImport),
   849  				}
   850  				So(datastore.Get(ctx, cfgSetAfterImport, attempt), ShouldBeNil)
   851  
   852  				So(cfgSetAfterImport.Location, ShouldResembleProto, cfgSetBeforeImport.Location)
   853  				So(cfgSetAfterImport.LatestRevision.ID, ShouldEqual, cfgSetBeforeImport.LatestRevision.ID)
   854  
   855  				So(attempt.Success, ShouldBeFalse)
   856  				So(attempt.Message, ShouldContainSubstring, "Failed to import services/myservice revision latest revision")
   857  			})
   858  
   859  			Convey("failed to upload to GCS", func() {
   860  				mockGtClient.EXPECT().Log(gomock.Any(), gomock.Any()).Return(&gitilespb.LogResponse{
   861  					Log: []*git.Commit{latestCommit},
   862  				}, nil)
   863  				// Construct incompressible data which is larger than compressedContentLimit.
   864  				incompressible := make([]byte, compressedContentLimit+1024*1024)
   865  				_, err := rand.New(rand.NewSource(1234)).Read(incompressible)
   866  				So(err, ShouldBeNil)
   867  				compressed, err := gzipCompress(incompressible)
   868  				So(err, ShouldBeNil)
   869  				So(len(compressed) > compressedContentLimit, ShouldBeTrue)
   870  				tarGzContent, err := buildTarGz(map[string]any{"large": incompressible})
   871  				So(err, ShouldBeNil)
   872  				expectedSha256 := sha256.Sum256(incompressible)
   873  				expectedGsFileName := "configs/sha256/" + hex.EncodeToString(expectedSha256[:])
   874  				mockGtClient.EXPECT().Archive(gomock.Any(), gomock.Any()).Return(&gitilespb.ArchiveResponse{
   875  					Contents: tarGzContent,
   876  				}, nil)
   877  				mockGsClient.EXPECT().UploadIfMissing(
   878  					gomock.Any(), gomock.Eq(testGSBucket),
   879  					gomock.Eq(expectedGsFileName), gomock.Any(), gomock.Any()).Return(false, errors.New("GCS internal error"))
   880  
   881  				err = importer.ImportConfigSet(ctx, config.MustServiceSet("myservice"))
   882  
   883  				So(err, ShouldErrLike, "failed to upload file", "GCS internal error")
   884  				revKey := datastore.MakeKey(ctx, model.ConfigSetKind, "services/myservice", model.RevisionKind, latestCommit.Id)
   885  				var files []*model.File
   886  				So(datastore.GetAll(ctx, datastore.NewQuery(model.FileKind).Ancestor(revKey), &files), ShouldBeNil)
   887  				So(files, ShouldHaveLength, 0)
   888  
   889  				attempt := &model.ImportAttempt{
   890  					ConfigSet: datastore.MakeKey(ctx, model.ConfigSetKind, "services/myservice"),
   891  				}
   892  				So(datastore.Get(ctx, attempt), ShouldBeNil)
   893  				So(attempt.Success, ShouldBeFalse)
   894  				So(attempt.Message, ShouldContainSubstring, "failed to upload file")
   895  			})
   896  
   897  			Convey("validation error", func() {
   898  				mockGtClient.EXPECT().Log(gomock.Any(), gomock.Any()).Return(&gitilespb.LogResponse{
   899  					Log: []*git.Commit{latestCommit},
   900  				}, nil)
   901  
   902  				tarGzContent, err := buildTarGz(map[string]any{"foo.cfg": "content"})
   903  				So(err, ShouldBeNil)
   904  				mockGtClient.EXPECT().Archive(gomock.Any(), gomock.Any()).Return(&gitilespb.ArchiveResponse{
   905  					Contents: tarGzContent,
   906  				}, nil)
   907  				mockGsClient.EXPECT().UploadIfMissing(
   908  					gomock.Any(), gomock.Eq(testGSBucket),
   909  					gomock.Any(), gomock.Any(), gomock.Any()).Return(true, nil)
   910  
   911  				mockValidator.err = errors.New("something went wrong during validation")
   912  				err = importer.ImportConfigSet(ctx, cs)
   913  				So(err, ShouldErrLike, "something went wrong during validation")
   914  			})
   915  		})
   916  	})
   917  }
   918  
   919  func TestReImport(t *testing.T) {
   920  	t.Parallel()
   921  
   922  	Convey("Reimport", t, func() {
   923  		rsp := httptest.NewRecorder()
   924  		rctx := &router.Context{
   925  			Writer: rsp,
   926  		}
   927  
   928  		mockValidator := &mockValidator{}
   929  		importer := &Importer{
   930  			Validator: mockValidator,
   931  		}
   932  		ctx := testutil.SetupContext()
   933  		userID := identity.Identity("user:user@example.com")
   934  		fakeAuthDB := authtest.NewFakeDB()
   935  		testutil.InjectSelfConfigs(ctx, map[string]proto.Message{
   936  			common.ACLRegistryFilePath: &cfgcommonpb.AclCfg{
   937  				ServiceAccessGroup:   "service-access-group",
   938  				ServiceReimportGroup: "service-reimport-group",
   939  			},
   940  		})
   941  		fakeAuthDB.AddMocks(
   942  			authtest.MockMembership(userID, "service-access-group"),
   943  			authtest.MockMembership(userID, "service-reimport-group"),
   944  		)
   945  		ctx = auth.WithState(ctx, &authtest.FakeState{
   946  			Identity: userID,
   947  			FakeDB:   fakeAuthDB,
   948  		})
   949  
   950  		Convey("no ConfigSet param", func() {
   951  			rctx.Request = (&http.Request{}).WithContext(ctx)
   952  			importer.Reimport(rctx)
   953  			So(rsp.Code, ShouldEqual, http.StatusBadRequest)
   954  			So(rsp.Body.String(), ShouldContainSubstring, "config set is not specified")
   955  		})
   956  
   957  		Convey("invalid config set", func() {
   958  			rctx.Request = (&http.Request{}).WithContext(ctx)
   959  			rctx.Params = httprouter.Params{
   960  				{Key: "ConfigSet", Value: "badCfgSet"},
   961  			}
   962  			importer.Reimport(rctx)
   963  			So(rsp.Code, ShouldEqual, http.StatusBadRequest)
   964  			So(rsp.Body.String(), ShouldContainSubstring, `invalid config set: unknown domain "badCfgSet" for config set "badCfgSet"`)
   965  		})
   966  
   967  		Convey("no permission", func() {
   968  			ctx = auth.WithState(ctx, &authtest.FakeState{
   969  				Identity: identity.Identity("user:random@example.com"),
   970  				FakeDB:   authtest.NewFakeDB(),
   971  			})
   972  			rctx.Request = (&http.Request{}).WithContext(ctx)
   973  			rctx.Params = httprouter.Params{
   974  				{Key: "ConfigSet", Value: "services/myservice"},
   975  			}
   976  			importer.Reimport(rctx)
   977  			So(rsp.Code, ShouldEqual, http.StatusForbidden)
   978  			So(rsp.Body.String(), ShouldContainSubstring, `"user:random@example.com" is not allowed to reimport services/myservice`)
   979  		})
   980  
   981  		Convey("config set not found", func() {
   982  			rctx.Request = (&http.Request{}).WithContext(ctx)
   983  			rctx.Params = httprouter.Params{
   984  				{Key: "ConfigSet", Value: "services/myservice"},
   985  			}
   986  			importer.Reimport(rctx)
   987  			So(rsp.Code, ShouldEqual, http.StatusNotFound)
   988  			So(rsp.Body.String(), ShouldContainSubstring, `"services/myservice" is not found`)
   989  		})
   990  
   991  		Convey("ok", func() {
   992  			ctx = settings.WithGlobalConfigLoc(ctx, &cfgcommonpb.GitilesLocation{
   993  				Repo: "https://a.googlesource.com/infradata/config",
   994  				Ref:  "refs/heads/main",
   995  				Path: "dev-configs",
   996  			})
   997  			testutil.InjectConfigSet(ctx, "services/myservice", nil)
   998  			ctl := gomock.NewController(t)
   999  			mockGtClient := mock_gitiles.NewMockGitilesClient(ctl)
  1000  			ctx = context.WithValue(ctx, &clients.MockGitilesClientKey, mockGtClient)
  1001  			mockGtClient.EXPECT().Log(gomock.Any(), gomock.Any()).Return(&gitilespb.LogResponse{}, nil)
  1002  
  1003  			rctx.Request = (&http.Request{}).WithContext(ctx)
  1004  			rctx.Params = httprouter.Params{
  1005  				{Key: "ConfigSet", Value: "services/myservice"},
  1006  			}
  1007  			importer.Reimport(rctx)
  1008  
  1009  			So(rsp.Code, ShouldEqual, http.StatusOK)
  1010  		})
  1011  
  1012  		Convey("internal error", func() {
  1013  			ctx = settings.WithGlobalConfigLoc(ctx, &cfgcommonpb.GitilesLocation{
  1014  				Repo: "https://a.googlesource.com/infradata/config",
  1015  				Ref:  "refs/heads/main",
  1016  				Path: "dev-configs",
  1017  			})
  1018  			testutil.InjectConfigSet(ctx, "services/myservice", nil)
  1019  			ctl := gomock.NewController(t)
  1020  			mockGtClient := mock_gitiles.NewMockGitilesClient(ctl)
  1021  			ctx = context.WithValue(ctx, &clients.MockGitilesClientKey, mockGtClient)
  1022  			mockGtClient.EXPECT().Log(gomock.Any(), gomock.Any()).Return(nil, errors.New("gitiles internal error"))
  1023  
  1024  			rctx.Request = (&http.Request{}).WithContext(ctx)
  1025  			rctx.Params = httprouter.Params{
  1026  				{Key: "ConfigSet", Value: "services/myservice"},
  1027  			}
  1028  			importer.Reimport(rctx)
  1029  
  1030  			So(rsp.Code, ShouldEqual, http.StatusInternalServerError)
  1031  			So(rsp.Body.String(), ShouldContainSubstring, `error when reimporting "services/myservice"`)
  1032  		})
  1033  	})
  1034  }
  1035  
  1036  func buildTarGz(raw map[string]any) ([]byte, error) {
  1037  	var buf bytes.Buffer
  1038  	gzw := gzip.NewWriter(&buf)
  1039  	tw := tar.NewWriter(gzw)
  1040  	for name, body := range raw {
  1041  		hdr := &tar.Header{
  1042  			Name:     name,
  1043  			Typeflag: tar.TypeReg,
  1044  		}
  1045  		if strings.HasSuffix(name, "/") {
  1046  			hdr.Typeflag = tar.TypeDir
  1047  		}
  1048  
  1049  		var bodyBytes []byte
  1050  		switch v := body.(type) {
  1051  		case string:
  1052  			bodyBytes = []byte(body.(string))
  1053  		case []byte:
  1054  			bodyBytes = body.([]byte)
  1055  		default:
  1056  			return nil, errors.Reason("unsupported type %T for %s", v, name).Err()
  1057  		}
  1058  		hdr.Size = int64(len(bodyBytes))
  1059  		if err := tw.WriteHeader(hdr); err != nil {
  1060  			return nil, err
  1061  		}
  1062  		if _, err := tw.Write(bodyBytes); err != nil {
  1063  			return nil, err
  1064  		}
  1065  	}
  1066  	if err := tw.Flush(); err != nil {
  1067  		return nil, err
  1068  	}
  1069  	if err := tw.Close(); err != nil {
  1070  		return nil, err
  1071  	}
  1072  	if err := gzw.Flush(); err != nil {
  1073  		return nil, err
  1074  	}
  1075  	if err := gzw.Close(); err != nil {
  1076  		return nil, err
  1077  	}
  1078  	return buf.Bytes(), nil
  1079  }
  1080  
  1081  func getCfgSetsInTaskQueue(sch *tqtesting.Scheduler) []string {
  1082  	cfgSets := make([]string, len(sch.Tasks()))
  1083  	for i, task := range sch.Tasks() {
  1084  		cfgSets[i] = task.Payload.(*taskpb.ImportConfigs).ConfigSet
  1085  	}
  1086  	sort.Slice(cfgSets, func(i, j int) bool { return cfgSets[i] < cfgSets[j] })
  1087  	return cfgSets
  1088  }
  1089  
  1090  func gzipCompress(b []byte) ([]byte, error) {
  1091  	buf := &bytes.Buffer{}
  1092  	gw := gzip.NewWriter(buf)
  1093  	if _, err := gw.Write(b); err != nil {
  1094  		return nil, err
  1095  	}
  1096  	if err := gw.Close(); err != nil {
  1097  		return nil, err
  1098  	}
  1099  	return buf.Bytes(), nil
  1100  }