github.com/kovansky/hugo@v0.92.3-0.20220224232819-63076e4ff19f/deploy/deploy_test.go (about)

     1  // Copyright 2019 The Hugo Authors. All rights reserved.
     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  // http://www.apache.org/licenses/LICENSE-2.0
     7  //
     8  // Unless required by applicable law or agreed to in writing, software
     9  // distributed under the License is distributed on an "AS IS" BASIS,
    10  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    11  // See the License for the specific language governing permissions and
    12  // limitations under the License.
    13  
    14  // +build !nodeploy
    15  
    16  package deploy
    17  
    18  import (
    19  	"bytes"
    20  	"compress/gzip"
    21  	"context"
    22  	"crypto/md5"
    23  	"fmt"
    24  	"io"
    25  	"io/ioutil"
    26  	"os"
    27  	"path"
    28  	"path/filepath"
    29  	"regexp"
    30  	"sort"
    31  	"testing"
    32  
    33  	"github.com/gohugoio/hugo/media"
    34  	"github.com/google/go-cmp/cmp"
    35  	"github.com/google/go-cmp/cmp/cmpopts"
    36  	"github.com/spf13/afero"
    37  	"gocloud.dev/blob"
    38  	"gocloud.dev/blob/fileblob"
    39  	"gocloud.dev/blob/memblob"
    40  )
    41  
    42  func TestFindDiffs(t *testing.T) {
    43  	hash1 := []byte("hash 1")
    44  	hash2 := []byte("hash 2")
    45  	makeLocal := func(path string, size int64, hash []byte) *localFile {
    46  		return &localFile{NativePath: path, SlashPath: filepath.ToSlash(path), UploadSize: size, md5: hash}
    47  	}
    48  	makeRemote := func(path string, size int64, hash []byte) *blob.ListObject {
    49  		return &blob.ListObject{Key: path, Size: size, MD5: hash}
    50  	}
    51  
    52  	tests := []struct {
    53  		Description string
    54  		Local       []*localFile
    55  		Remote      []*blob.ListObject
    56  		Force       bool
    57  		WantUpdates []*fileToUpload
    58  		WantDeletes []string
    59  	}{
    60  		{
    61  			Description: "empty -> no diffs",
    62  		},
    63  		{
    64  			Description: "local == remote -> no diffs",
    65  			Local: []*localFile{
    66  				makeLocal("aaa", 1, hash1),
    67  				makeLocal("bbb", 2, hash1),
    68  				makeLocal("ccc", 3, hash2),
    69  			},
    70  			Remote: []*blob.ListObject{
    71  				makeRemote("aaa", 1, hash1),
    72  				makeRemote("bbb", 2, hash1),
    73  				makeRemote("ccc", 3, hash2),
    74  			},
    75  		},
    76  		{
    77  			Description: "local w/ separators == remote -> no diffs",
    78  			Local: []*localFile{
    79  				makeLocal(filepath.Join("aaa", "aaa"), 1, hash1),
    80  				makeLocal(filepath.Join("bbb", "bbb"), 2, hash1),
    81  				makeLocal(filepath.Join("ccc", "ccc"), 3, hash2),
    82  			},
    83  			Remote: []*blob.ListObject{
    84  				makeRemote("aaa/aaa", 1, hash1),
    85  				makeRemote("bbb/bbb", 2, hash1),
    86  				makeRemote("ccc/ccc", 3, hash2),
    87  			},
    88  		},
    89  		{
    90  			Description: "local == remote with force flag true -> diffs",
    91  			Local: []*localFile{
    92  				makeLocal("aaa", 1, hash1),
    93  				makeLocal("bbb", 2, hash1),
    94  				makeLocal("ccc", 3, hash2),
    95  			},
    96  			Remote: []*blob.ListObject{
    97  				makeRemote("aaa", 1, hash1),
    98  				makeRemote("bbb", 2, hash1),
    99  				makeRemote("ccc", 3, hash2),
   100  			},
   101  			Force: true,
   102  			WantUpdates: []*fileToUpload{
   103  				{makeLocal("aaa", 1, nil), reasonForce},
   104  				{makeLocal("bbb", 2, nil), reasonForce},
   105  				{makeLocal("ccc", 3, nil), reasonForce},
   106  			},
   107  		},
   108  		{
   109  			Description: "local == remote with route.Force true -> diffs",
   110  			Local: []*localFile{
   111  				{NativePath: "aaa", SlashPath: "aaa", UploadSize: 1, matcher: &matcher{Force: true}, md5: hash1},
   112  				makeLocal("bbb", 2, hash1),
   113  			},
   114  			Remote: []*blob.ListObject{
   115  				makeRemote("aaa", 1, hash1),
   116  				makeRemote("bbb", 2, hash1),
   117  			},
   118  			WantUpdates: []*fileToUpload{
   119  				{makeLocal("aaa", 1, nil), reasonForce},
   120  			},
   121  		},
   122  		{
   123  			Description: "extra local file -> upload",
   124  			Local: []*localFile{
   125  				makeLocal("aaa", 1, hash1),
   126  				makeLocal("bbb", 2, hash2),
   127  			},
   128  			Remote: []*blob.ListObject{
   129  				makeRemote("aaa", 1, hash1),
   130  			},
   131  			WantUpdates: []*fileToUpload{
   132  				{makeLocal("bbb", 2, nil), reasonNotFound},
   133  			},
   134  		},
   135  		{
   136  			Description: "extra remote file -> delete",
   137  			Local: []*localFile{
   138  				makeLocal("aaa", 1, hash1),
   139  			},
   140  			Remote: []*blob.ListObject{
   141  				makeRemote("aaa", 1, hash1),
   142  				makeRemote("bbb", 2, hash2),
   143  			},
   144  			WantDeletes: []string{"bbb"},
   145  		},
   146  		{
   147  			Description: "diffs in size or md5 -> upload",
   148  			Local: []*localFile{
   149  				makeLocal("aaa", 1, hash1),
   150  				makeLocal("bbb", 2, hash1),
   151  				makeLocal("ccc", 1, hash2),
   152  			},
   153  			Remote: []*blob.ListObject{
   154  				makeRemote("aaa", 1, nil),
   155  				makeRemote("bbb", 1, hash1),
   156  				makeRemote("ccc", 1, hash1),
   157  			},
   158  			WantUpdates: []*fileToUpload{
   159  				{makeLocal("aaa", 1, nil), reasonMD5Missing},
   160  				{makeLocal("bbb", 2, nil), reasonSize},
   161  				{makeLocal("ccc", 1, nil), reasonMD5Differs},
   162  			},
   163  		},
   164  		{
   165  			Description: "mix of updates and deletes",
   166  			Local: []*localFile{
   167  				makeLocal("same", 1, hash1),
   168  				makeLocal("updated", 2, hash1),
   169  				makeLocal("updated2", 1, hash2),
   170  				makeLocal("new", 1, hash1),
   171  				makeLocal("new2", 2, hash2),
   172  			},
   173  			Remote: []*blob.ListObject{
   174  				makeRemote("same", 1, hash1),
   175  				makeRemote("updated", 1, hash1),
   176  				makeRemote("updated2", 1, hash1),
   177  				makeRemote("stale", 1, hash1),
   178  				makeRemote("stale2", 1, hash1),
   179  			},
   180  			WantUpdates: []*fileToUpload{
   181  				{makeLocal("new", 1, nil), reasonNotFound},
   182  				{makeLocal("new2", 2, nil), reasonNotFound},
   183  				{makeLocal("updated", 2, nil), reasonSize},
   184  				{makeLocal("updated2", 1, nil), reasonMD5Differs},
   185  			},
   186  			WantDeletes: []string{"stale", "stale2"},
   187  		},
   188  	}
   189  
   190  	for _, tc := range tests {
   191  		t.Run(tc.Description, func(t *testing.T) {
   192  			local := map[string]*localFile{}
   193  			for _, l := range tc.Local {
   194  				local[l.SlashPath] = l
   195  			}
   196  			remote := map[string]*blob.ListObject{}
   197  			for _, r := range tc.Remote {
   198  				remote[r.Key] = r
   199  			}
   200  			gotUpdates, gotDeletes := findDiffs(local, remote, tc.Force)
   201  			gotUpdates = applyOrdering(nil, gotUpdates)[0]
   202  			sort.Slice(gotDeletes, func(i, j int) bool { return gotDeletes[i] < gotDeletes[j] })
   203  			if diff := cmp.Diff(gotUpdates, tc.WantUpdates, cmpopts.IgnoreUnexported(localFile{})); diff != "" {
   204  				t.Errorf("updates differ:\n%s", diff)
   205  			}
   206  			if diff := cmp.Diff(gotDeletes, tc.WantDeletes); diff != "" {
   207  				t.Errorf("deletes differ:\n%s", diff)
   208  			}
   209  		})
   210  	}
   211  }
   212  
   213  func TestWalkLocal(t *testing.T) {
   214  	tests := map[string]struct {
   215  		Given  []string
   216  		Expect []string
   217  	}{
   218  		"Empty": {
   219  			Given:  []string{},
   220  			Expect: []string{},
   221  		},
   222  		"Normal": {
   223  			Given:  []string{"file.txt", "normal_dir/file.txt"},
   224  			Expect: []string{"file.txt", "normal_dir/file.txt"},
   225  		},
   226  		"Hidden": {
   227  			Given:  []string{"file.txt", ".hidden_dir/file.txt", "normal_dir/file.txt"},
   228  			Expect: []string{"file.txt", "normal_dir/file.txt"},
   229  		},
   230  		"Well Known": {
   231  			Given:  []string{"file.txt", ".hidden_dir/file.txt", ".well-known/file.txt"},
   232  			Expect: []string{"file.txt", ".well-known/file.txt"},
   233  		},
   234  	}
   235  
   236  	for desc, tc := range tests {
   237  		t.Run(desc, func(t *testing.T) {
   238  			fs := afero.NewMemMapFs()
   239  			for _, name := range tc.Given {
   240  				dir, _ := path.Split(name)
   241  				if dir != "" {
   242  					if err := fs.MkdirAll(dir, 0755); err != nil {
   243  						t.Fatal(err)
   244  					}
   245  				}
   246  				if fd, err := fs.Create(name); err != nil {
   247  					t.Fatal(err)
   248  				} else {
   249  					fd.Close()
   250  				}
   251  			}
   252  			if got, err := walkLocal(fs, nil, nil, nil, media.DefaultTypes); err != nil {
   253  				t.Fatal(err)
   254  			} else {
   255  				expect := map[string]interface{}{}
   256  				for _, path := range tc.Expect {
   257  					if _, ok := got[path]; !ok {
   258  						t.Errorf("expected %q in results, but was not found", path)
   259  					}
   260  					expect[path] = nil
   261  				}
   262  				for path := range got {
   263  					if _, ok := expect[path]; !ok {
   264  						t.Errorf("got %q in results unexpectedly", path)
   265  					}
   266  				}
   267  			}
   268  		})
   269  	}
   270  }
   271  
   272  func TestLocalFile(t *testing.T) {
   273  	const (
   274  		content = "hello world!"
   275  	)
   276  	contentBytes := []byte(content)
   277  	contentLen := int64(len(contentBytes))
   278  	contentMD5 := md5.Sum(contentBytes)
   279  	var buf bytes.Buffer
   280  	gz := gzip.NewWriter(&buf)
   281  	if _, err := gz.Write(contentBytes); err != nil {
   282  		t.Fatal(err)
   283  	}
   284  	gz.Close()
   285  	gzBytes := buf.Bytes()
   286  	gzLen := int64(len(gzBytes))
   287  	gzMD5 := md5.Sum(gzBytes)
   288  
   289  	tests := []struct {
   290  		Description         string
   291  		Path                string
   292  		Matcher             *matcher
   293  		MediaTypesConfig    []map[string]interface{}
   294  		WantContent         []byte
   295  		WantSize            int64
   296  		WantMD5             []byte
   297  		WantContentType     string // empty string is always OK, since content type detection is OS-specific
   298  		WantCacheControl    string
   299  		WantContentEncoding string
   300  	}{
   301  		{
   302  			Description: "file with no suffix",
   303  			Path:        "foo",
   304  			WantContent: contentBytes,
   305  			WantSize:    contentLen,
   306  			WantMD5:     contentMD5[:],
   307  		},
   308  		{
   309  			Description: "file with .txt suffix",
   310  			Path:        "foo.txt",
   311  			WantContent: contentBytes,
   312  			WantSize:    contentLen,
   313  			WantMD5:     contentMD5[:],
   314  		},
   315  		{
   316  			Description:      "CacheControl from matcher",
   317  			Path:             "foo.txt",
   318  			Matcher:          &matcher{CacheControl: "max-age=630720000"},
   319  			WantContent:      contentBytes,
   320  			WantSize:         contentLen,
   321  			WantMD5:          contentMD5[:],
   322  			WantCacheControl: "max-age=630720000",
   323  		},
   324  		{
   325  			Description:         "ContentEncoding from matcher",
   326  			Path:                "foo.txt",
   327  			Matcher:             &matcher{ContentEncoding: "foobar"},
   328  			WantContent:         contentBytes,
   329  			WantSize:            contentLen,
   330  			WantMD5:             contentMD5[:],
   331  			WantContentEncoding: "foobar",
   332  		},
   333  		{
   334  			Description:     "ContentType from matcher",
   335  			Path:            "foo.txt",
   336  			Matcher:         &matcher{ContentType: "foo/bar"},
   337  			WantContent:     contentBytes,
   338  			WantSize:        contentLen,
   339  			WantMD5:         contentMD5[:],
   340  			WantContentType: "foo/bar",
   341  		},
   342  		{
   343  			Description:         "gzipped content",
   344  			Path:                "foo.txt",
   345  			Matcher:             &matcher{Gzip: true},
   346  			WantContent:         gzBytes,
   347  			WantSize:            gzLen,
   348  			WantMD5:             gzMD5[:],
   349  			WantContentEncoding: "gzip",
   350  		},
   351  		{
   352  			Description: "Custom MediaType",
   353  			Path:        "foo.hugo",
   354  			MediaTypesConfig: []map[string]interface{}{
   355  				{
   356  					"hugo/custom": map[string]interface{}{
   357  						"suffixes": []string{"hugo"},
   358  					},
   359  				},
   360  			},
   361  			WantContent:     contentBytes,
   362  			WantSize:        contentLen,
   363  			WantMD5:         contentMD5[:],
   364  			WantContentType: "hugo/custom",
   365  		},
   366  	}
   367  
   368  	for _, tc := range tests {
   369  		t.Run(tc.Description, func(t *testing.T) {
   370  			fs := new(afero.MemMapFs)
   371  			if err := afero.WriteFile(fs, tc.Path, []byte(content), os.ModePerm); err != nil {
   372  				t.Fatal(err)
   373  			}
   374  			mediaTypes := media.DefaultTypes
   375  			if len(tc.MediaTypesConfig) > 0 {
   376  				mt, err := media.DecodeTypes(tc.MediaTypesConfig...)
   377  				if err != nil {
   378  					t.Fatal(err)
   379  				}
   380  				mediaTypes = mt
   381  			}
   382  			lf, err := newLocalFile(fs, tc.Path, filepath.ToSlash(tc.Path), tc.Matcher, mediaTypes)
   383  			if err != nil {
   384  				t.Fatal(err)
   385  			}
   386  			if got := lf.UploadSize; got != tc.WantSize {
   387  				t.Errorf("got size %d want %d", got, tc.WantSize)
   388  			}
   389  			if got := lf.MD5(); !bytes.Equal(got, tc.WantMD5) {
   390  				t.Errorf("got MD5 %x want %x", got, tc.WantMD5)
   391  			}
   392  			if got := lf.CacheControl(); got != tc.WantCacheControl {
   393  				t.Errorf("got CacheControl %q want %q", got, tc.WantCacheControl)
   394  			}
   395  			if got := lf.ContentEncoding(); got != tc.WantContentEncoding {
   396  				t.Errorf("got ContentEncoding %q want %q", got, tc.WantContentEncoding)
   397  			}
   398  			if tc.WantContentType != "" {
   399  				if got := lf.ContentType(); got != tc.WantContentType {
   400  					t.Errorf("got ContentType %q want %q", got, tc.WantContentType)
   401  				}
   402  			}
   403  			// Verify the reader last to ensure the previous operations don't
   404  			// interfere with it.
   405  			r, err := lf.Reader()
   406  			if err != nil {
   407  				t.Fatal(err)
   408  			}
   409  			gotContent, err := ioutil.ReadAll(r)
   410  			if err != nil {
   411  				t.Fatal(err)
   412  			}
   413  			if !bytes.Equal(gotContent, tc.WantContent) {
   414  				t.Errorf("got content %q want %q", string(gotContent), string(tc.WantContent))
   415  			}
   416  			r.Close()
   417  			// Verify we can read again.
   418  			r, err = lf.Reader()
   419  			if err != nil {
   420  				t.Fatal(err)
   421  			}
   422  			gotContent, err = ioutil.ReadAll(r)
   423  			if err != nil {
   424  				t.Fatal(err)
   425  			}
   426  			r.Close()
   427  			if !bytes.Equal(gotContent, tc.WantContent) {
   428  				t.Errorf("got content %q want %q", string(gotContent), string(tc.WantContent))
   429  			}
   430  		})
   431  	}
   432  }
   433  
   434  func TestOrdering(t *testing.T) {
   435  	tests := []struct {
   436  		Description string
   437  		Uploads     []string
   438  		Ordering    []*regexp.Regexp
   439  		Want        [][]string
   440  	}{
   441  		{
   442  			Description: "empty",
   443  			Want:        [][]string{nil},
   444  		},
   445  		{
   446  			Description: "no ordering",
   447  			Uploads:     []string{"c", "b", "a", "d"},
   448  			Want:        [][]string{{"a", "b", "c", "d"}},
   449  		},
   450  		{
   451  			Description: "one ordering",
   452  			Uploads:     []string{"db", "c", "b", "a", "da"},
   453  			Ordering:    []*regexp.Regexp{regexp.MustCompile("^d")},
   454  			Want:        [][]string{{"da", "db"}, {"a", "b", "c"}},
   455  		},
   456  		{
   457  			Description: "two orderings",
   458  			Uploads:     []string{"db", "c", "b", "a", "da"},
   459  			Ordering: []*regexp.Regexp{
   460  				regexp.MustCompile("^d"),
   461  				regexp.MustCompile("^b"),
   462  			},
   463  			Want: [][]string{{"da", "db"}, {"b"}, {"a", "c"}},
   464  		},
   465  	}
   466  
   467  	for _, tc := range tests {
   468  		t.Run(tc.Description, func(t *testing.T) {
   469  			uploads := make([]*fileToUpload, len(tc.Uploads))
   470  			for i, u := range tc.Uploads {
   471  				uploads[i] = &fileToUpload{Local: &localFile{SlashPath: u}}
   472  			}
   473  			gotUploads := applyOrdering(tc.Ordering, uploads)
   474  			var got [][]string
   475  			for _, subslice := range gotUploads {
   476  				var gotsubslice []string
   477  				for _, u := range subslice {
   478  					gotsubslice = append(gotsubslice, u.Local.SlashPath)
   479  				}
   480  				got = append(got, gotsubslice)
   481  			}
   482  			if diff := cmp.Diff(got, tc.Want); diff != "" {
   483  				t.Error(diff)
   484  			}
   485  		})
   486  	}
   487  }
   488  
   489  type fileData struct {
   490  	Name     string // name of the file
   491  	Contents string // contents of the file
   492  }
   493  
   494  // initLocalFs initializes fs with some test files.
   495  func initLocalFs(ctx context.Context, fs afero.Fs) ([]*fileData, error) {
   496  	// The initial local filesystem.
   497  	local := []*fileData{
   498  		{"aaa", "aaa"},
   499  		{"bbb", "bbb"},
   500  		{"subdir/aaa", "subdir-aaa"},
   501  		{"subdir/nested/aaa", "subdir-nested-aaa"},
   502  		{"subdir2/bbb", "subdir2-bbb"},
   503  	}
   504  	if err := writeFiles(fs, local); err != nil {
   505  		return nil, err
   506  	}
   507  	return local, nil
   508  }
   509  
   510  // fsTest represents an (afero.FS, Go CDK blob.Bucket) against which end-to-end
   511  // tests can be run.
   512  type fsTest struct {
   513  	name   string
   514  	fs     afero.Fs
   515  	bucket *blob.Bucket
   516  }
   517  
   518  // initFsTests initializes a pair of tests for end-to-end test:
   519  // 1. An in-memory afero.Fs paired with an in-memory Go CDK bucket.
   520  // 2. A filesystem-based afero.Fs paired with an filesystem-based Go CDK bucket.
   521  // It returns the pair of tests and a cleanup function.
   522  func initFsTests() ([]*fsTest, func(), error) {
   523  	tmpfsdir, err := ioutil.TempDir("", "fs")
   524  	if err != nil {
   525  		return nil, nil, err
   526  	}
   527  	tmpbucketdir, err := ioutil.TempDir("", "bucket")
   528  	if err != nil {
   529  		return nil, nil, err
   530  	}
   531  
   532  	memfs := afero.NewMemMapFs()
   533  	membucket := memblob.OpenBucket(nil)
   534  
   535  	filefs := afero.NewBasePathFs(afero.NewOsFs(), tmpfsdir)
   536  	filebucket, err := fileblob.OpenBucket(tmpbucketdir, nil)
   537  	if err != nil {
   538  		return nil, nil, err
   539  	}
   540  
   541  	tests := []*fsTest{
   542  		{"mem", memfs, membucket},
   543  		{"file", filefs, filebucket},
   544  	}
   545  	cleanup := func() {
   546  		membucket.Close()
   547  		filebucket.Close()
   548  		os.RemoveAll(tmpfsdir)
   549  		os.RemoveAll(tmpbucketdir)
   550  	}
   551  	return tests, cleanup, nil
   552  }
   553  
   554  // TestEndToEndSync verifies that basic adds, updates, and deletes are working
   555  // correctly.
   556  func TestEndToEndSync(t *testing.T) {
   557  	ctx := context.Background()
   558  	tests, cleanup, err := initFsTests()
   559  	if err != nil {
   560  		t.Fatal(err)
   561  	}
   562  	defer cleanup()
   563  	for _, test := range tests {
   564  		t.Run(test.name, func(t *testing.T) {
   565  			local, err := initLocalFs(ctx, test.fs)
   566  			if err != nil {
   567  				t.Fatal(err)
   568  			}
   569  			deployer := &Deployer{
   570  				localFs:    test.fs,
   571  				maxDeletes: -1,
   572  				bucket:     test.bucket,
   573  				mediaTypes: media.DefaultTypes,
   574  			}
   575  
   576  			// Initial deployment should sync remote with local.
   577  			if err := deployer.Deploy(ctx); err != nil {
   578  				t.Errorf("initial deploy: failed: %v", err)
   579  			}
   580  			wantSummary := deploySummary{NumLocal: 5, NumRemote: 0, NumUploads: 5, NumDeletes: 0}
   581  			if !cmp.Equal(deployer.summary, wantSummary) {
   582  				t.Errorf("initial deploy: got %v, want %v", deployer.summary, wantSummary)
   583  			}
   584  			if diff, err := verifyRemote(ctx, deployer.bucket, local); err != nil {
   585  				t.Errorf("initial deploy: failed to verify remote: %v", err)
   586  			} else if diff != "" {
   587  				t.Errorf("initial deploy: remote snapshot doesn't match expected:\n%v", diff)
   588  			}
   589  
   590  			// A repeat deployment shouldn't change anything.
   591  			if err := deployer.Deploy(ctx); err != nil {
   592  				t.Errorf("no-op deploy: %v", err)
   593  			}
   594  			wantSummary = deploySummary{NumLocal: 5, NumRemote: 5, NumUploads: 0, NumDeletes: 0}
   595  			if !cmp.Equal(deployer.summary, wantSummary) {
   596  				t.Errorf("no-op deploy: got %v, want %v", deployer.summary, wantSummary)
   597  			}
   598  
   599  			// Make some changes to the local filesystem:
   600  			// 1. Modify file [0].
   601  			// 2. Delete file [1].
   602  			// 3. Add a new file (sorted last).
   603  			updatefd := local[0]
   604  			updatefd.Contents = "new contents"
   605  			deletefd := local[1]
   606  			local = append(local[:1], local[2:]...) // removing deleted [1]
   607  			newfd := &fileData{"zzz", "zzz"}
   608  			local = append(local, newfd)
   609  			if err := writeFiles(test.fs, []*fileData{updatefd, newfd}); err != nil {
   610  				t.Fatal(err)
   611  			}
   612  			if err := test.fs.Remove(deletefd.Name); err != nil {
   613  				t.Fatal(err)
   614  			}
   615  
   616  			// A deployment should apply those 3 changes.
   617  			if err := deployer.Deploy(ctx); err != nil {
   618  				t.Errorf("deploy after changes: failed: %v", err)
   619  			}
   620  			wantSummary = deploySummary{NumLocal: 5, NumRemote: 5, NumUploads: 2, NumDeletes: 1}
   621  			if !cmp.Equal(deployer.summary, wantSummary) {
   622  				t.Errorf("deploy after changes: got %v, want %v", deployer.summary, wantSummary)
   623  			}
   624  			if diff, err := verifyRemote(ctx, deployer.bucket, local); err != nil {
   625  				t.Errorf("deploy after changes: failed to verify remote: %v", err)
   626  			} else if diff != "" {
   627  				t.Errorf("deploy after changes: remote snapshot doesn't match expected:\n%v", diff)
   628  			}
   629  
   630  			// Again, a repeat deployment shouldn't change anything.
   631  			if err := deployer.Deploy(ctx); err != nil {
   632  				t.Errorf("no-op deploy: %v", err)
   633  			}
   634  			wantSummary = deploySummary{NumLocal: 5, NumRemote: 5, NumUploads: 0, NumDeletes: 0}
   635  			if !cmp.Equal(deployer.summary, wantSummary) {
   636  				t.Errorf("no-op deploy: got %v, want %v", deployer.summary, wantSummary)
   637  			}
   638  		})
   639  	}
   640  }
   641  
   642  // TestMaxDeletes verifies that the "maxDeletes" flag is working correctly.
   643  func TestMaxDeletes(t *testing.T) {
   644  	ctx := context.Background()
   645  	tests, cleanup, err := initFsTests()
   646  	if err != nil {
   647  		t.Fatal(err)
   648  	}
   649  	defer cleanup()
   650  	for _, test := range tests {
   651  		t.Run(test.name, func(t *testing.T) {
   652  			local, err := initLocalFs(ctx, test.fs)
   653  			if err != nil {
   654  				t.Fatal(err)
   655  			}
   656  			deployer := &Deployer{
   657  				localFs:    test.fs,
   658  				maxDeletes: -1,
   659  				bucket:     test.bucket,
   660  				mediaTypes: media.DefaultTypes,
   661  			}
   662  
   663  			// Sync remote with local.
   664  			if err := deployer.Deploy(ctx); err != nil {
   665  				t.Errorf("initial deploy: failed: %v", err)
   666  			}
   667  			wantSummary := deploySummary{NumLocal: 5, NumRemote: 0, NumUploads: 5, NumDeletes: 0}
   668  			if !cmp.Equal(deployer.summary, wantSummary) {
   669  				t.Errorf("initial deploy: got %v, want %v", deployer.summary, wantSummary)
   670  			}
   671  
   672  			// Delete two files, [1] and [2].
   673  			if err := test.fs.Remove(local[1].Name); err != nil {
   674  				t.Fatal(err)
   675  			}
   676  			if err := test.fs.Remove(local[2].Name); err != nil {
   677  				t.Fatal(err)
   678  			}
   679  
   680  			// A deployment with maxDeletes=0 shouldn't change anything.
   681  			deployer.maxDeletes = 0
   682  			if err := deployer.Deploy(ctx); err != nil {
   683  				t.Errorf("deploy failed: %v", err)
   684  			}
   685  			wantSummary = deploySummary{NumLocal: 3, NumRemote: 5, NumUploads: 0, NumDeletes: 0}
   686  			if !cmp.Equal(deployer.summary, wantSummary) {
   687  				t.Errorf("deploy: got %v, want %v", deployer.summary, wantSummary)
   688  			}
   689  
   690  			// A deployment with maxDeletes=1 shouldn't change anything either.
   691  			deployer.maxDeletes = 1
   692  			if err := deployer.Deploy(ctx); err != nil {
   693  				t.Errorf("deploy failed: %v", err)
   694  			}
   695  			wantSummary = deploySummary{NumLocal: 3, NumRemote: 5, NumUploads: 0, NumDeletes: 0}
   696  			if !cmp.Equal(deployer.summary, wantSummary) {
   697  				t.Errorf("deploy: got %v, want %v", deployer.summary, wantSummary)
   698  			}
   699  
   700  			// A deployment with maxDeletes=2 should make the changes.
   701  			deployer.maxDeletes = 2
   702  			if err := deployer.Deploy(ctx); err != nil {
   703  				t.Errorf("deploy failed: %v", err)
   704  			}
   705  			wantSummary = deploySummary{NumLocal: 3, NumRemote: 5, NumUploads: 0, NumDeletes: 2}
   706  			if !cmp.Equal(deployer.summary, wantSummary) {
   707  				t.Errorf("deploy: got %v, want %v", deployer.summary, wantSummary)
   708  			}
   709  
   710  			// Delete two more files, [0] and [3].
   711  			if err := test.fs.Remove(local[0].Name); err != nil {
   712  				t.Fatal(err)
   713  			}
   714  			if err := test.fs.Remove(local[3].Name); err != nil {
   715  				t.Fatal(err)
   716  			}
   717  
   718  			// A deployment with maxDeletes=-1 should make the changes.
   719  			deployer.maxDeletes = -1
   720  			if err := deployer.Deploy(ctx); err != nil {
   721  				t.Errorf("deploy failed: %v", err)
   722  			}
   723  			wantSummary = deploySummary{NumLocal: 1, NumRemote: 3, NumUploads: 0, NumDeletes: 2}
   724  			if !cmp.Equal(deployer.summary, wantSummary) {
   725  				t.Errorf("deploy: got %v, want %v", deployer.summary, wantSummary)
   726  			}
   727  		})
   728  	}
   729  }
   730  
   731  // TestIncludeExclude verifies that the include/exclude options for targets work.
   732  func TestIncludeExclude(t *testing.T) {
   733  	ctx := context.Background()
   734  	tests := []struct {
   735  		Include string
   736  		Exclude string
   737  		Want    deploySummary
   738  	}{
   739  		{
   740  			Want: deploySummary{NumLocal: 5, NumUploads: 5},
   741  		},
   742  		{
   743  			Include: "**aaa",
   744  			Want:    deploySummary{NumLocal: 3, NumUploads: 3},
   745  		},
   746  		{
   747  			Include: "**bbb",
   748  			Want:    deploySummary{NumLocal: 2, NumUploads: 2},
   749  		},
   750  		{
   751  			Include: "aaa",
   752  			Want:    deploySummary{NumLocal: 1, NumUploads: 1},
   753  		},
   754  		{
   755  			Exclude: "**aaa",
   756  			Want:    deploySummary{NumLocal: 2, NumUploads: 2},
   757  		},
   758  		{
   759  			Exclude: "**bbb",
   760  			Want:    deploySummary{NumLocal: 3, NumUploads: 3},
   761  		},
   762  		{
   763  			Exclude: "aaa",
   764  			Want:    deploySummary{NumLocal: 4, NumUploads: 4},
   765  		},
   766  		{
   767  			Include: "**aaa",
   768  			Exclude: "**nested**",
   769  			Want:    deploySummary{NumLocal: 2, NumUploads: 2},
   770  		},
   771  	}
   772  	for _, test := range tests {
   773  		t.Run(fmt.Sprintf("include %q exclude %q", test.Include, test.Exclude), func(t *testing.T) {
   774  			fsTests, cleanup, err := initFsTests()
   775  			if err != nil {
   776  				t.Fatal(err)
   777  			}
   778  			defer cleanup()
   779  			fsTest := fsTests[1] // just do file-based test
   780  
   781  			_, err = initLocalFs(ctx, fsTest.fs)
   782  			if err != nil {
   783  				t.Fatal(err)
   784  			}
   785  			tgt := &target{
   786  				Include: test.Include,
   787  				Exclude: test.Exclude,
   788  			}
   789  			if err := tgt.parseIncludeExclude(); err != nil {
   790  				t.Error(err)
   791  			}
   792  			deployer := &Deployer{
   793  				localFs:    fsTest.fs,
   794  				maxDeletes: -1,
   795  				bucket:     fsTest.bucket,
   796  				target:     tgt,
   797  				mediaTypes: media.DefaultTypes,
   798  			}
   799  
   800  			// Sync remote with local.
   801  			if err := deployer.Deploy(ctx); err != nil {
   802  				t.Errorf("deploy: failed: %v", err)
   803  			}
   804  			if !cmp.Equal(deployer.summary, test.Want) {
   805  				t.Errorf("deploy: got %v, want %v", deployer.summary, test.Want)
   806  			}
   807  		})
   808  	}
   809  }
   810  
   811  // TestIncludeExcludeRemoteDelete verifies deleted local files that don't match include/exclude patterns
   812  // are not deleted on the remote.
   813  func TestIncludeExcludeRemoteDelete(t *testing.T) {
   814  	ctx := context.Background()
   815  
   816  	tests := []struct {
   817  		Include string
   818  		Exclude string
   819  		Want    deploySummary
   820  	}{
   821  		{
   822  			Want: deploySummary{NumLocal: 3, NumRemote: 5, NumUploads: 0, NumDeletes: 2},
   823  		},
   824  		{
   825  			Include: "**aaa",
   826  			Want:    deploySummary{NumLocal: 2, NumRemote: 3, NumUploads: 0, NumDeletes: 1},
   827  		},
   828  		{
   829  			Include: "subdir/**",
   830  			Want:    deploySummary{NumLocal: 1, NumRemote: 2, NumUploads: 0, NumDeletes: 1},
   831  		},
   832  		{
   833  			Exclude: "**bbb",
   834  			Want:    deploySummary{NumLocal: 2, NumRemote: 3, NumUploads: 0, NumDeletes: 1},
   835  		},
   836  		{
   837  			Exclude: "bbb",
   838  			Want:    deploySummary{NumLocal: 3, NumRemote: 4, NumUploads: 0, NumDeletes: 1},
   839  		},
   840  	}
   841  	for _, test := range tests {
   842  		t.Run(fmt.Sprintf("include %q exclude %q", test.Include, test.Exclude), func(t *testing.T) {
   843  			fsTests, cleanup, err := initFsTests()
   844  			if err != nil {
   845  				t.Fatal(err)
   846  			}
   847  			defer cleanup()
   848  			fsTest := fsTests[1] // just do file-based test
   849  
   850  			local, err := initLocalFs(ctx, fsTest.fs)
   851  			if err != nil {
   852  				t.Fatal(err)
   853  			}
   854  			deployer := &Deployer{
   855  				localFs:    fsTest.fs,
   856  				maxDeletes: -1,
   857  				bucket:     fsTest.bucket,
   858  				mediaTypes: media.DefaultTypes,
   859  			}
   860  
   861  			// Initial sync to get the files on the remote
   862  			if err := deployer.Deploy(ctx); err != nil {
   863  				t.Errorf("deploy: failed: %v", err)
   864  			}
   865  
   866  			// Delete two files, [1] and [2].
   867  			if err := fsTest.fs.Remove(local[1].Name); err != nil {
   868  				t.Fatal(err)
   869  			}
   870  			if err := fsTest.fs.Remove(local[2].Name); err != nil {
   871  				t.Fatal(err)
   872  			}
   873  
   874  			// Second sync
   875  			tgt := &target{
   876  				Include: test.Include,
   877  				Exclude: test.Exclude,
   878  			}
   879  			if err := tgt.parseIncludeExclude(); err != nil {
   880  				t.Error(err)
   881  			}
   882  			deployer.target = tgt
   883  			if err := deployer.Deploy(ctx); err != nil {
   884  				t.Errorf("deploy: failed: %v", err)
   885  			}
   886  
   887  			if !cmp.Equal(deployer.summary, test.Want) {
   888  				t.Errorf("deploy: got %v, want %v", deployer.summary, test.Want)
   889  			}
   890  		})
   891  	}
   892  }
   893  
   894  // TestCompression verifies that gzip compression works correctly.
   895  // In particular, MD5 hashes must be of the compressed content.
   896  func TestCompression(t *testing.T) {
   897  	ctx := context.Background()
   898  
   899  	tests, cleanup, err := initFsTests()
   900  	if err != nil {
   901  		t.Fatal(err)
   902  	}
   903  	defer cleanup()
   904  	for _, test := range tests {
   905  		t.Run(test.name, func(t *testing.T) {
   906  			local, err := initLocalFs(ctx, test.fs)
   907  			if err != nil {
   908  				t.Fatal(err)
   909  			}
   910  			deployer := &Deployer{
   911  				localFs:    test.fs,
   912  				bucket:     test.bucket,
   913  				matchers:   []*matcher{{Pattern: ".*", Gzip: true, re: regexp.MustCompile(".*")}},
   914  				mediaTypes: media.DefaultTypes,
   915  			}
   916  
   917  			// Initial deployment should sync remote with local.
   918  			if err := deployer.Deploy(ctx); err != nil {
   919  				t.Errorf("initial deploy: failed: %v", err)
   920  			}
   921  			wantSummary := deploySummary{NumLocal: 5, NumRemote: 0, NumUploads: 5, NumDeletes: 0}
   922  			if !cmp.Equal(deployer.summary, wantSummary) {
   923  				t.Errorf("initial deploy: got %v, want %v", deployer.summary, wantSummary)
   924  			}
   925  
   926  			// A repeat deployment shouldn't change anything.
   927  			if err := deployer.Deploy(ctx); err != nil {
   928  				t.Errorf("no-op deploy: %v", err)
   929  			}
   930  			wantSummary = deploySummary{NumLocal: 5, NumRemote: 5, NumUploads: 0, NumDeletes: 0}
   931  			if !cmp.Equal(deployer.summary, wantSummary) {
   932  				t.Errorf("no-op deploy: got %v, want %v", deployer.summary, wantSummary)
   933  			}
   934  
   935  			// Make an update to the local filesystem, on [1].
   936  			updatefd := local[1]
   937  			updatefd.Contents = "new contents"
   938  			if err := writeFiles(test.fs, []*fileData{updatefd}); err != nil {
   939  				t.Fatal(err)
   940  			}
   941  
   942  			// A deployment should apply the changes.
   943  			if err := deployer.Deploy(ctx); err != nil {
   944  				t.Errorf("deploy after changes: failed: %v", err)
   945  			}
   946  			wantSummary = deploySummary{NumLocal: 5, NumRemote: 5, NumUploads: 1, NumDeletes: 0}
   947  			if !cmp.Equal(deployer.summary, wantSummary) {
   948  				t.Errorf("deploy after changes: got %v, want %v", deployer.summary, wantSummary)
   949  			}
   950  		})
   951  	}
   952  }
   953  
   954  // TestMatching verifies that matchers match correctly, and that the Force
   955  // attribute for matcher works.
   956  func TestMatching(t *testing.T) {
   957  	ctx := context.Background()
   958  	tests, cleanup, err := initFsTests()
   959  	if err != nil {
   960  		t.Fatal(err)
   961  	}
   962  	defer cleanup()
   963  	for _, test := range tests {
   964  		t.Run(test.name, func(t *testing.T) {
   965  			_, err := initLocalFs(ctx, test.fs)
   966  			if err != nil {
   967  				t.Fatal(err)
   968  			}
   969  			deployer := &Deployer{
   970  				localFs:    test.fs,
   971  				bucket:     test.bucket,
   972  				matchers:   []*matcher{{Pattern: "^subdir/aaa$", Force: true, re: regexp.MustCompile("^subdir/aaa$")}},
   973  				mediaTypes: media.DefaultTypes,
   974  			}
   975  
   976  			// Initial deployment to sync remote with local.
   977  			if err := deployer.Deploy(ctx); err != nil {
   978  				t.Errorf("initial deploy: failed: %v", err)
   979  			}
   980  			wantSummary := deploySummary{NumLocal: 5, NumRemote: 0, NumUploads: 5, NumDeletes: 0}
   981  			if !cmp.Equal(deployer.summary, wantSummary) {
   982  				t.Errorf("initial deploy: got %v, want %v", deployer.summary, wantSummary)
   983  			}
   984  
   985  			// A repeat deployment should upload a single file, the one that matched the Force matcher.
   986  			// Note that matching happens based on the ToSlash form, so this matches
   987  			// even on Windows.
   988  			if err := deployer.Deploy(ctx); err != nil {
   989  				t.Errorf("no-op deploy with single force matcher: %v", err)
   990  			}
   991  			wantSummary = deploySummary{NumLocal: 5, NumRemote: 5, NumUploads: 1, NumDeletes: 0}
   992  			if !cmp.Equal(deployer.summary, wantSummary) {
   993  				t.Errorf("no-op deploy with single force matcher: got %v, want %v", deployer.summary, wantSummary)
   994  			}
   995  
   996  			// Repeat with a matcher that should now match 3 files.
   997  			deployer.matchers = []*matcher{{Pattern: "aaa", Force: true, re: regexp.MustCompile("aaa")}}
   998  			if err := deployer.Deploy(ctx); err != nil {
   999  				t.Errorf("no-op deploy with triple force matcher: %v", err)
  1000  			}
  1001  			wantSummary = deploySummary{NumLocal: 5, NumRemote: 5, NumUploads: 3, NumDeletes: 0}
  1002  			if !cmp.Equal(deployer.summary, wantSummary) {
  1003  				t.Errorf("no-op deploy with triple force matcher: got %v, want %v", deployer.summary, wantSummary)
  1004  			}
  1005  		})
  1006  	}
  1007  }
  1008  
  1009  // writeFiles writes the files in fds to fd.
  1010  func writeFiles(fs afero.Fs, fds []*fileData) error {
  1011  	for _, fd := range fds {
  1012  		dir := path.Dir(fd.Name)
  1013  		if dir != "." {
  1014  			err := fs.MkdirAll(dir, os.ModePerm)
  1015  			if err != nil {
  1016  				return err
  1017  			}
  1018  		}
  1019  		f, err := fs.Create(fd.Name)
  1020  		if err != nil {
  1021  			return err
  1022  		}
  1023  		defer f.Close()
  1024  		_, err = f.WriteString(fd.Contents)
  1025  		if err != nil {
  1026  			return err
  1027  		}
  1028  	}
  1029  	return nil
  1030  }
  1031  
  1032  // verifyRemote that the current contents of bucket matches local.
  1033  // It returns an empty string if the contents matched, and a non-empty string
  1034  // capturing the diff if they didn't.
  1035  func verifyRemote(ctx context.Context, bucket *blob.Bucket, local []*fileData) (string, error) {
  1036  	var cur []*fileData
  1037  	iter := bucket.List(nil)
  1038  	for {
  1039  		obj, err := iter.Next(ctx)
  1040  		if err == io.EOF {
  1041  			break
  1042  		}
  1043  		if err != nil {
  1044  			return "", err
  1045  		}
  1046  		contents, err := bucket.ReadAll(ctx, obj.Key)
  1047  		if err != nil {
  1048  			return "", err
  1049  		}
  1050  		cur = append(cur, &fileData{obj.Key, string(contents)})
  1051  	}
  1052  	if cmp.Equal(cur, local) {
  1053  		return "", nil
  1054  	}
  1055  	diff := "got: \n"
  1056  	for _, f := range cur {
  1057  		diff += fmt.Sprintf("  %s: %s\n", f.Name, f.Contents)
  1058  	}
  1059  	diff += "want: \n"
  1060  	for _, f := range local {
  1061  		diff += fmt.Sprintf("  %s: %s\n", f.Name, f.Contents)
  1062  	}
  1063  	return diff, nil
  1064  }