hugo

Fork of github.com/gohugoio/hugo with reverse pagination support

git clone git://git.shimmy1996.com/hugo.git

deploy_test.go (30404B)

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