Repository: google/exposure-notifications-server
Pull request #1210 (2020-12-04 04:45:29)

Add unit tests for mirror service
Fixes https://github.com/google/exposure-notifications-server/issues/1073

**Release Note**

<!-- Enter your extended release note in the below block. If the PR requires
additional action from users switching to the new release, include the string
"action required". If no release note is required, write "NONE". -->

```release-note
Add unit tests for mirror service
```

google-oss-robot: [APPROVALNOTIFIER] This PR is **APPROVED**

This pull-request has been approved by: *<a href="https://github.com/google/exposure-notifications-server/pull/1210#" title="Author self-approved">sethvargo</a>*

The full list of commands accepted by this bot can be found [here](https://go.k8s.io/bot-commands?repo=google%2Fexposure-notifications-server).

The pull request process is described [here](https://git.k8s.io/community/contributors/guide/owners.md#the-code-review-process)

<details >
Needs approval from an approver in each of these files:

- ~~[OWNERS](https://github.com/google/exposure-notifications-server/blob/main/OWNERS)~~ [sethvargo]

Approvers can indicate their approval by writing `/approve` in a comment
Approvers can cancel approval by writing `/approve cancel` in a comment
</details>
<!-- META={"approvers":[]} -->
sethvargo: /assign @mikehelmick 

Please expand all diffs and review carefully. I'm concerned about the logic around ExportRoot and FilenameRoot handling.
sethvargo: @mikehelmick updated, PTAL
google-oss-robot: [APPROVALNOTIFIER] This PR is **APPROVED**

This pull-request has been approved by: *<a href="https://github.com/google/exposure-notifications-server/pull/1210#pullrequestreview-545101172" title="Approved">mikehelmick</a>*, *<a href="https://github.com/google/exposure-notifications-server/pull/1210#" title="Author self-approved">sethvargo</a>*

The full list of commands accepted by this bot can be found [here](https://go.k8s.io/bot-commands?repo=google%2Fexposure-notifications-server).

The pull request process is described [here](https://git.k8s.io/community/contributors/guide/owners.md#the-code-review-process)

<details >
Needs approval from an approver in each of these files:

- ~~[OWNERS](https://github.com/google/exposure-notifications-server/blob/main/OWNERS)~~ [mikehelmick,sethvargo]

Approvers can indicate their approval by writing `/approve` in a comment
Approvers can cancel approval by writing `/approve cancel` in a comment
</details>
<!-- META={"approvers":[]} -->

Patch:
diff --git a/internal/admin/mirrors/form.go b/internal/admin/mirrors/form.go
index 6eb18f5..0565cfa 100644
--- a/internal/admin/mirrors/form.go
+++ b/internal/admin/mirrors/form.go
@@ -23,9 +23,9 @@ type formData struct {
 	Action string `form:"Action" binding:"required"`
 
 	IndexFile          string `form:"IndexFile" binding:"required"`
-	ExportRoot         string `form:"ExportRoot" binding:"required"`
+	ExportRoot         string `form:"ExportRoot"`
 	CloudStorageBucket string `form:"CloudStorageBucket" binding:"required"`
-	FilenameRoot       string `form:"FilenameRoot" binding:"required"`
+	FilenameRoot       string `form:"FilenameRoot"`
 	FilenameRewrite    string `form:"FilenameRewrite"`
 }
 
diff --git a/internal/mirror/config.go b/internal/mirror/config.go
index b90715a..cadb2aa 100644
--- a/internal/mirror/config.go
+++ b/internal/mirror/config.go
@@ -41,8 +41,16 @@ type Config struct {
 	MaxIndexBytes int64 `env:"MAX_INDEX_BYTES, default=1048576"`
 	MaxZipBytes   int64 `env:"MAX_ZIP_BYTES, default=20971520"`
 
-	IndexFileDownloadTimeout  time.Duration `env:"INDEX_FILE_DOWNLOAD_TIMEOUT, default=30s"`
-	ExportFileDownloadTimeout time.Duration `env:"EXPORT_FILE_DOWNLOAD_TIMEOUT, default=2m"`
+	// IndexFileDownloadTimeout is the amount of time to allow to download the
+	// entire index file.
+	IndexFileDownloadTimeout time.Duration `env:"INDEX_FILE_DOWNLOAD_TIMEOUT, default=30s"`
+
+	// ExportFileDownloadTimeout, ExportFileDeleteTimeout, and
+	// ExportFileUploadTimeout are the maximum amount of time to wait when
+	// downloading, deleting, and uploading an export file, respectively.
+	ExportFileDownloadTimeout time.Duration `env:"EXPORT_FILE_DOWNLOAD_TIMEOUT, default=1m"`
+	ExportFileDeleteTimeout   time.Duration `env:"EXPORT_FILE_DELETE_TIMEOUT, default=10s"`
+	ExportFileUploadTimeout   time.Duration `env:"EXPORT_FILE_UPLOAD_TIMEOUT, default=1m"`
 
 	MaxRuntime         time.Duration `env:"MAX_RUNTIME, default=14m"`
 	MirrorLockDuration time.Duration `env:"MIRROR_LOCK_DURATION, default=15m"`
diff --git a/internal/mirror/database/mirror.go b/internal/mirror/database/mirror.go
index a8047f7..8cae74f 100644
--- a/internal/mirror/database/mirror.go
+++ b/internal/mirror/database/mirror.go
@@ -180,8 +180,14 @@ func scanOneMirror(row pgx.Row) (*model.Mirror, error) {
 }
 
 type SyncFile struct {
+	// RemoteFile is the ONLY the final filename (last part after the slash) with
+	// extension. It does not include the URL, protocol or root information as
+	// that is built from the parent mirror record.
 	RemoteFile string
-	LocalFile  string
+
+	// LocalFile is blank unless a rewrite rule was provided. It is also just the
+	// filename (no URL or protocol information).
+	LocalFile string
 }
 
 // SaveFiles makes the list of filenames passed in the only files that are saved on that mirrorID.
diff --git a/internal/mirror/file_status.go b/internal/mirror/file_status.go
new file mode 100644
index 0000000..9d16600
--- /dev/null
+++ b/internal/mirror/file_status.go
@@ -0,0 +1,45 @@
+// Copyright 2020 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package mirror
+
+import (
+	"sort"
+
+	"github.com/google/exposure-notifications-server/internal/mirror/model"
+)
+
+type FileStatus struct {
+	Order         int
+	MirrorFile    *model.MirrorFile
+	DownloadPath  string
+	Filename      string
+	LocalFilename string
+	Failed        bool
+	Saved         bool
+}
+
+func (f *FileStatus) needsDelete() bool {
+	return f.DownloadPath == ""
+}
+
+func (f *FileStatus) needsDownload() bool {
+	return f.MirrorFile == nil
+}
+
+func sortFileStatus(fs []*FileStatus) {
+	sort.Slice(fs, func(i, j int) bool {
+		return fs[i].Order < fs[j].Order
+	})
+}
diff --git a/internal/mirror/mirror.go b/internal/mirror/mirror.go
index ed383fd..6edd8e5 100644
--- a/internal/mirror/mirror.go
+++ b/internal/mirror/mirror.go
@@ -15,71 +15,37 @@
 package mirror
 
 import (
+	"bufio"
+	"bytes"
 	"context"
+	"encoding/json"
 	"errors"
 	"fmt"
-	"io/ioutil"
+	"io"
 	"net/http"
 	"sort"
 	"strings"
 	"time"
 
 	"github.com/google/exposure-notifications-server/internal/database"
-	mirrordb "github.com/google/exposure-notifications-server/internal/mirror/database"
+	mirrordatabase "github.com/google/exposure-notifications-server/internal/mirror/database"
 	"github.com/google/exposure-notifications-server/internal/mirror/model"
 	"github.com/google/exposure-notifications-server/internal/storage"
 	"github.com/google/exposure-notifications-server/pkg/logging"
+	"github.com/hashicorp/go-multierror"
 	"go.opencensus.io/trace"
 )
 
 const mirrorLockPrefix = "mirror-lock"
 
-type FileStatus struct {
-	Order         int
-	MirrorFile    *model.MirrorFile
-	DownloadPath  string
-	Filename      string
-	LocalFilename string
-	Failed        bool
-	Saved         bool
+type MirrorResponse struct {
+	Mirrors []*MirrorStatus `json:"mirrors"`
 }
 
-func (f *FileStatus) needsDelete() bool {
-	return f.DownloadPath == ""
-}
-
-func (f *FileStatus) needsDownload() bool {
-	return f.MirrorFile == nil
-}
-
-func sortFileStatus(fs []*FileStatus) {
-	sort.Slice(fs, func(i, j int) bool {
-		return fs[i].Order < fs[j].Order
-	})
-}
-
-func (s *Server) downloadIndex(w http.ResponseWriter, mirror *model.Mirror) ([]string, error) {
-	client := &http.Client{
-		Timeout: s.config.IndexFileDownloadTimeout,
-	}
-	resp, err := client.Get(mirror.IndexFile)
-	if err != nil {
-		return nil, fmt.Errorf("error downloading index file: %w", err)
-	}
-	defer resp.Body.Close()
-	resp.Body = http.MaxBytesReader(w, resp.Body, s.config.MaxIndexBytes)
-	bytes, err := ioutil.ReadAll(resp.Body)
-	if err != nil {
-		return nil, fmt.Errorf("error reading index file: %w", err)
-	}
-	zipNames := strings.Split(string(bytes), "\n")
-	currentFiles := make([]string, 0, len(zipNames))
-	for _, zipFile := range zipNames {
-		fullZipFile := fmt.Sprintf("%s%s", mirror.ExportRoot, strings.TrimSpace(zipFile))
-		currentFiles = append(currentFiles, fullZipFile)
-	}
-
-	return currentFiles, nil
+type MirrorStatus struct {
+	ID        int64    `json:"id"`
+	Processed bool     `json:"processed"`
+	Errors    []string `json:"errors,omitempty"`
 }
 
 func (s *Server) handleMirror(ctx context.Context) http.HandlerFunc {
@@ -99,9 +65,9 @@ func (s *Server) handleMirror(ctx context.Context) http.HandlerFunc {
 			runtime = runtime - (30 * time.Second)
 		}
 		deadline := time.Now().Add(runtime)
-		logger.Infow("mirrow will run until", "deadline", deadline)
+		logger.Infow("mirror will run until", "deadline", deadline)
 
-		// get all possible mirror candidates
+		// Get all possible mirror candidates.
 		mirrors, err := s.mirrorDB.Mirrors(ctx)
 		if err != nil {
 			logger.Errorw("unable to list mirrors", "error", err)
@@ -109,127 +75,178 @@ func (s *Server) handleMirror(ctx context.Context) http.HandlerFunc {
 			return
 		}
 
+		// Start building a response for all mirrors.
+		var resp MirrorResponse
+		var hasError bool
+
 		for _, mirror := range mirrors {
+			// Build the initial status
+			status := &MirrorStatus{ID: mirror.ID}
+
+			// If the deadline has passed, do not attempt to process additional
+			// mirrors. Note that exceeding the deadline is NOT considered an error.
 			if deadlinePassed(deadline) {
-				logger.Warnw("mirror timed out before processing all configurations")
-				break
+				status.Errors = []string{"deadline exceeded before processing"}
+				resp.Mirrors = append(resp.Mirrors, status)
+				continue
 			}
 
-			if err := s.processMirror(ctx, w, deadline, mirror); err != nil {
-				logger.Errorw("unable to process mirror", "error", err)
-				w.WriteHeader(http.StatusInternalServerError)
-				return
+			// Process the mirror.
+			if err := s.processMirror(ctx, deadline, mirror); err != nil {
+				hasError = true
+
+				status.Processed = true
+				status.Errors = []string{err.Error()}
+
+				// If the response was a multierror, extract into the list.
+				var merr *multierror.Error
+				if errors.As(err, &merr) {
+					errMsgs := make([]string, len(merr.Errors))
+					for i, v := range merr.Errors {
+						errMsgs[i] = v.Error()
+					}
+					status.Errors = errMsgs
+				}
+
+				resp.Mirrors = append(resp.Mirrors, status)
+				continue
 			}
+
+			// If we got this far, the job completed successfully.
+			status.Processed = true
+		}
+
+		// Marshal response.
+		b, err := json.Marshal(resp)
+		if err != nil {
+			http.Error(w, err.Error(), http.StatusInternalServerError)
+			return
 		}
 
 		status := http.StatusOK
+		if hasError {
+			status = http.StatusInternalServerError
+		}
 		w.WriteHeader(status)
-		w.Write([]byte(http.StatusText(status)))
+		fmt.Fprint(w, string(b))
 	}
 }
 
-func (s *Server) processMirror(ctx context.Context, w http.ResponseWriter, deadline time.Time, mirror *model.Mirror) error {
-	logger := logging.FromContext(ctx)
+// processMirror processes all files in the mirror, deleting files that have
+// been removed and downloading the new index and exports.
+//
+// An ambitious engineer might say "wow, we should really parallelize the
+// download and upload steps". Please don't. The files need to be processed and
+// uploaded in the order, in case a device is in the middle of downloading
+// things. Some devices uses the timestamps and ordering of the files, and
+// uploading in parallel wouldn't guarantee upload order. Thus, a client could
+// skip files and that would be bad.
+func (s *Server) processMirror(ctx context.Context, deadline time.Time, mirror *model.Mirror) (retErr error) {
+	logger := logging.FromContext(ctx).Named("mirror.processMirror").
+		With("mirror_id", mirror.ID)
+	blobstore := s.env.Blobstore()
 
 	lockID := fmt.Sprintf("%s-%d", mirrorLockPrefix, mirror.ID)
 	unlock, err := s.db.Lock(ctx, lockID, s.config.MaxRuntime)
 	if err != nil {
 		if errors.Is(err, database.ErrAlreadyLocked) {
-			logger.Infow("mirror already locked, skipping", "mirrorID", mirror.ID)
+			logger.Infow("mirror already locked, skipping")
 			return nil
 		}
-		return fmt.Errorf("unable to lock mirror: %w", err)
+		return fmt.Errorf("failed to lock mirror: %w", err)
 	}
 	defer func() {
+		logger.Debugw("finished processing mirror", "index", mirror.IndexFile)
+
 		if err := unlock(); err != nil {
-			logger.Errorw("failed to unlock: %v", err)
+			retErr = fmt.Errorf("failed to unlock mirror: %w, original error: %s", err, retErr)
 		}
 	}()
 
-	logger.Info("starting mirror", "mirrorID", mirror.ID, "index", mirror.IndexFile)
+	// If we got this far, we have the lock, start processing.
+	logger.Infow("starting mirror", "index", mirror.IndexFile)
 
-	// have the lock
+	// Get the list of known mirror files.
 	knownFiles, err := s.mirrorDB.ListFiles(ctx, mirror.ID)
 	if err != nil {
-		logger.Errorw("unable to list mirror files", "error", err)
-		return nil
+		return fmt.Errorf("failed to list mirror files: %w", err)
 	}
 
-	// download the index
-	// curFiles contains the fullly qualified download link for the files.
-	curFiles, err := s.downloadIndex(w, mirror)
+	// Download the index, which will return the fully qualified download links
+	// for the files.
+	indexFiles, err := s.downloadIndex(mirror)
 	if err != nil {
-		logger.Errorw("unable to download index.txt - skipping", "mirrorID", mirror.ID, "error", err)
-		return nil
+		return fmt.Errorf("failed to download index: %w", err)
 	}
 
-	// figure out the actions
-	actions := make(map[string]*FileStatus, len(curFiles))
-	for _, kf := range knownFiles {
-		actions[kf.Filename] = &FileStatus{
-			MirrorFile: kf,
-		}
-	}
-	for i, curFile := range curFiles {
-		lastIdx := strings.LastIndex(curFile, "/")
-		fileName := curFile[lastIdx+1:]
-		if cur, ok := actions[fileName]; ok {
-			cur.DownloadPath = curFile
-			cur.Filename = fileName
-			cur.LocalFilename = fileName
-			if cur.MirrorFile.LocalFilename != nil {
-				cur.LocalFilename = *cur.MirrorFile.LocalFilename
-			}
-			cur.Order = i
-		} else {
-			actions[fileName] = &FileStatus{
-				Order:        i,
-				Filename:     fileName,
-				DownloadPath: curFile,
-			}
-		}
-	}
+	// Build a running list of all errors. In many cases, processing continues on
+	// error.
+	var merr *multierror.Error
 
-	// save for convenience.
-	blobstore := s.env.Blobstore()
+	// Figure out actions to take by diffing the known files and current files.
+	actions := computeActions(knownFiles, indexFiles)
 
-	// delete any files we no longer have
+	// Delete any stale export files. These are files that we previously mirrored,
+	// but are no longer in the index.
 	for filename, status := range actions {
 		if status.needsDelete() {
-			logger.Infow("deleting stale export file", "file", filename)
-
-			delFilename := filename
-			if status.MirrorFile != nil && status.MirrorFile.LocalFilename != nil {
-				delFilename = *status.MirrorFile.LocalFilename
-			}
-
-			bsCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
-			defer cancel()
-			// If there's a race and the file was already deleted, no error is returned.
-			if err := blobstore.DeleteObject(bsCtx, mirror.CloudStorageBucket, delFilename); err != nil {
-				logger.Errorw("unable to delete file", "bucket", mirror.CloudStorageBucket, "upstrteamFile", filename, "mirroredFiled", delFilename, "error", err)
-				return nil
-			}
-			// Remove from map, so will be deleted from db.
-			delete(actions, filename)
+			// Use a function here so defers run sooner.
+			func() {
+				localFilename := filename
+				if status.MirrorFile != nil && status.MirrorFile.LocalFilename != nil {
+					// TODO(sethvargo): empty string "" check was not here before
+					val := *status.MirrorFile.LocalFilename
+					if val != "" {
+						localFilename = val
+					}
+				}
+
+				logger.Debugw("deleting stale export file",
+					"upstream_file", filename,
+					"local_file", localFilename,
+					"bucket", mirror.CloudStorageBucket)
+
+				ctx, cancel := context.WithTimeout(ctx, s.config.ExportFileDeleteTimeout)
+				defer cancel()
+
+				parent := mirror.CloudStorageBucket
+				pth := urlJoin(mirror.FilenameRoot, localFilename)
+				if err := blobstore.DeleteObject(ctx, parent, pth); err != nil {
+					merr = multierror.Append(merr, fmt.Errorf("failed to delete stale export file %s: %w", urlJoin(parent, pth), err))
+					return
+				}
+
+				// Remove from map, so will be deleted from the database later. Note we
+				// only do this on successful deletion. The item is not removed from the
+				// map if deletion fails.
+				delete(actions, filename)
+			}()
 		}
 	}
 
-	// download any files we don't have
+	// Start building a list of all objects - we'll use this to save back to the
+	// database later.
 	indexObjects := make([]*FileStatus, 0, len(actions))
-	// quickly bypass anything that doesn't need to be downloaded.
-	// This ensures that if we timeout, files we previously knew about remain saved.
+
+	// Bypass files that we already know about, since they do not need to be
+	// downloaded. This ensures that if we timeout, files we previously knew about
+	// are still saved.
 	for _, status := range actions {
 		if !status.needsDownload() {
 			status.Saved = true
 			indexObjects = append(indexObjects, status)
 		}
 	}
-	logger.Infow("mirror state", "mirrorID", mirror.ID, "total", len(actions), "existing", len(indexObjects), "remaining", len(actions)-len(indexObjects))
 
-	// Convert actions to an array, so we can process files in order.
-	// This is necessary if files are being renamed and using timestamps, and if clients are depending on
-	// monotonically increaing timestamps.
+	// Download any files we don't have.
+	logger.Debugw("mirror state",
+		"total", len(actions),
+		"existing", len(indexObjects),
+		"remaining", len(actions)-len(indexObjects))
+
+	// Convert actions to an array, so we can process files in order. This is
+	// necessary if files are being renamed and using timestamps, and if clients
+	// are depending on monotonically increasing timestamps.
 	remainingWork := make([]*FileStatus, 0, len(actions))
 	for _, status := range actions {
 		if status.needsDownload() {
@@ -238,94 +255,212 @@ func (s *Server) processMirror(ctx context.Context, w http.ResponseWriter, deadl
 	}
 	sortFileStatus(remainingWork)
 
-	// go through again and process downloads.
+	// Process downloads.
 	for _, status := range remainingWork {
-		filename := status.Filename
 		if deadlinePassed(deadline) {
-			logger.Warnw("mirror didn't complete processing before timeout", "mirrorID", mirror.ID)
+			logger.Warnw("mirror did not complete processing before timeout")
 			break
 		}
 
-		logger.Infow("mirroring export file", "mirrorID", mirror.ID, "file", filename)
-
-		client := &http.Client{
-			Timeout: s.config.ExportFileDownloadTimeout,
-		}
-		resp, err := client.Get(status.DownloadPath)
-		if err != nil {
-			logger.Errorw("unable to download file", "filename", filename, "error", err)
-			status.Failed = true
-			return nil
-		}
+		filename := status.Filename
+		logger.Debugw("downloading export file",
+			"file", filename,
+			"download_path", status.DownloadPath)
 
-		defer resp.Body.Close()
-		resp.Body = http.MaxBytesReader(w, resp.Body, s.config.MaxZipBytes)
-		bytes, err := ioutil.ReadAll(resp.Body)
+		b, err := downloadFile(status.DownloadPath, s.config.ExportFileDownloadTimeout, s.config.MaxZipBytes)
 		if err != nil {
-			logger.Errorw("unable to read file", "filename", filename, "error", err)
+			merr = multierror.Append(merr, fmt.Errorf("failed to download export file %s: %w", filename, err))
 			status.Failed = true
-			return nil
+			continue
 		}
 
-		// See if we need to rewrite the filename
+		// See if we need to rewrite the filename.
 		writeFilename, err := mirror.RewriteFilename(filename)
 		if err != nil {
-			logger.Errorw("unable to rewrite filename", "filename", filename, "error", err)
+			merr = multierror.Append(merr, fmt.Errorf("failed to rewrite filename %s: %w", filename, err))
 			status.Failed = true
-			return nil
+			continue
 		}
 		status.LocalFilename = writeFilename
 
-		// Write this file to the blob store.
-		bsCtx, cancel := context.WithTimeout(ctx, time.Minute)
-		defer cancel()
-		objName := fmt.Sprintf("%s/%s", mirror.FilenameRoot, writeFilename)
-		if err := blobstore.CreateObject(bsCtx, mirror.CloudStorageBucket, objName, bytes, true, storage.ContentTypeZip); err != nil {
-			logger.Errorw("unable to write file to blobstore", "filename", filename, "error", err)
+		// Write this file to the blobstore - use a function so defers run when
+		// expected.
+		if err := func() error {
+			ctx, cancel := context.WithTimeout(ctx, s.config.ExportFileUploadTimeout)
+			defer cancel()
+
+			objName := urlJoin(mirror.FilenameRoot, writeFilename)
+			return blobstore.CreateObject(ctx, mirror.CloudStorageBucket, objName, b, true, storage.ContentTypeZip)
+		}(); err != nil {
+			merr = multierror.Append(merr, fmt.Errorf("failed to write %s to blobstore: %w", filename, err))
 			status.Failed = true
-			return nil
+			continue
 		}
-		logger.Infow("wrote mirrored archive", "filename", filename, "localFilename", writeFilename)
 
 		status.Saved = true
+		logger.Debugw("successfully saved mirrored archive",
+			"upstream_file", filename,
+			"local_file", writeFilename)
+
 		indexObjects = append(indexObjects, status)
 	}
 
-	// Write new index file
+	// Build and write a new index file.
 	sort.Slice(indexObjects, func(i, j int) bool {
 		return indexObjects[i].Order < indexObjects[j].Order
 	})
-	logger.Infof("index %+v", indexObjects)
-	syncFilenames := make([]*mirrordb.SyncFile, 0, len(indexObjects))
+
+	syncFilenames := make([]*mirrordatabase.SyncFile, 0, len(indexObjects))
 	filenames := make([]string, 0, len(indexObjects))
 	for _, obj := range indexObjects {
 		// Only persist state of the files we got to.
 		if obj.Saved {
-			syncFilenames = append(syncFilenames,
-				&mirrordb.SyncFile{
-					RemoteFile: obj.Filename,
-					LocalFile:  obj.LocalFilename,
-				})
-			filenames = append(filenames, fmt.Sprintf("%s/%s", mirror.FilenameRoot, obj.LocalFilename))
+			syncFilenames = append(syncFilenames, &mirrordatabase.SyncFile{
+				RemoteFile: obj.Filename,
+				LocalFile:  obj.LocalFilename,
+			})
+			filenames = append(filenames, urlJoin(mirror.FilenameRoot, obj.LocalFilename))
 		}
 	}
-	data := []byte(strings.Join(filenames, "\n"))
-	indexName := fmt.Sprintf("%s/index.txt", mirror.FilenameRoot)
+
 	bsCtx, cancel := context.WithTimeout(ctx, time.Minute)
 	defer cancel()
-	if err := blobstore.CreateObject(bsCtx, mirror.CloudStorageBucket, indexName, data, true, storage.ContentTypeTextPlain); err != nil {
-		logger.Errorw("unable to write index to blobstore", "filename", "index.txt", "error", err)
-		return nil
+
+	parent := mirror.CloudStorageBucket
+	pth := urlJoin(mirror.FilenameRoot, "index.txt")
+	data := []byte(strings.Join(filenames, "\n"))
+	if err := blobstore.CreateObject(bsCtx, parent, pth, data, true, storage.ContentTypeTextPlain); err != nil {
+		merr = multierror.Append(merr, fmt.Errorf("failed to write index.txt to blobstore: %w", err))
 	}
 
-	// sync state to DB.
+	// Save state to database.
 	if err := s.mirrorDB.SaveFiles(ctx, mirror.ID, syncFilenames); err != nil {
-		logger.Errorw("error saving index state to database", "mirrorID", mirror.ID, "error", err)
+		merr = multierror.Append(merr, fmt.Errorf("failed to save index state to database: %w", err))
+	}
+
+	retErr = merr.ErrorOrNil()
+	return
+}
+
+// computeActions computes the actions to take based on the provided known files
+// and files in the index.
+func computeActions(knownFiles []*model.MirrorFile, indexFiles []string) map[string]*FileStatus {
+	actions := make(map[string]*FileStatus, len(indexFiles))
+	for _, kf := range knownFiles {
+		actions[kf.Filename] = &FileStatus{
+			MirrorFile: kf,
+		}
+	}
+
+	for i, indexFile := range indexFiles {
+		lastIdx := strings.LastIndex(indexFile, "/")
+		fileName := indexFile[lastIdx+1:]
+
+		// Start ordering a 1 because a value of 0 is also the default value.
+		order := i + 1
+
+		if cur, ok := actions[fileName]; ok {
+			cur.DownloadPath = indexFile
+			cur.Filename = fileName
+			cur.LocalFilename = fileName
+			if cur.MirrorFile.LocalFilename != nil {
+				cur.LocalFilename = *cur.MirrorFile.LocalFilename
+			}
+			cur.Order = order
+		} else {
+			actions[fileName] = &FileStatus{
+				Order:        order,
+				Filename:     fileName,
+				DownloadPath: indexFile,
+			}
+		}
+	}
+
+	return actions
+}
+
+// downloadFile downloads the file from the given URL u up to maxBytes. If the
+// URL does not return a 200, an error is returned. If the process takes longer
+// than the provided timeout, an error is returned. If more bytes remain after
+// maxBytes, an error is returned. Otherwise, the raw bytes are returned.
+func downloadFile(u string, timeout time.Duration, maxBytes int64) ([]byte, error) {
+	client := &http.Client{Timeout: timeout}
+
+	// Start the download.
+	resp, err := client.Get(u)
+	if err != nil {
+		return nil, fmt.Errorf("failed to download %s: %w", u, err)
+	}
+	defer resp.Body.Close()
+
+	// Ensure a 200 response.
+	if code := resp.StatusCode; code != http.StatusOK {
+		return nil, fmt.Errorf("failed to download %s: status %d", u, code)
+	}
+
+	// Create the limited reader.
+	var b bytes.Buffer
+	r := &io.LimitedReader{R: resp.Body, N: maxBytes}
+	if _, err := io.Copy(&b, r); err != nil {
+		return nil, fmt.Errorf("failed to download %s: %w", u, err)
+	}
+	if r.N == 0 {
+		// Check if there's more data to be read and return an error if so.
+		if _, err := r.R.Read(make([]byte, 1)); err != io.EOF {
+			return nil, fmt.Errorf("failed to read %s: response exceeds %d bytes", u, maxBytes)
+		}
+	}
+
+	// Return the bytes.
+	return b.Bytes(), nil
+}
+
+// downloadIndex downloads the index files and returns the list of entries of
+// each line. An index file has the format:
+//
+//   us/1605818705-1605819005-00001.zip
+//   us/1605818705-1605819005-00002.zip
+//   us/1605818705-1605819005-00003.zip
+//   ...
+//
+// The values are returned in the order in which they appear in the file, joined
+// with the configured mirror ExportRoot.
+func (s *Server) downloadIndex(mirror *model.Mirror) ([]string, error) {
+	b, err := downloadFile(mirror.IndexFile, s.config.IndexFileDownloadTimeout, s.config.MaxIndexBytes)
+	if err != nil {
+		return nil, err
+	}
+
+	currentFiles := make([]string, 0, 64)
+	scanner := bufio.NewScanner(bytes.NewReader(b))
+	for scanner.Scan() {
+		fullName := urlJoin(mirror.ExportRoot, scanner.Text())
+		currentFiles = append(currentFiles, fullName)
+	}
+	if err := scanner.Err(); err != nil {
+		return nil, fmt.Errorf("failed to scan index file: %w", err)
 	}
-	logger.Infow("finished mirror", "mirrorID", mirror.ID, "index", mirror.IndexFile)
-	return nil
+
+	return currentFiles, nil
+}
+
+// urlJoin joins a root path with the extra path, attempting to clean leading
+// and trailing slashes.
+func urlJoin(root, extra string) string {
+	// We can't use path.Join here because it strips URLs (for example, "http://"
+	// becomes "http:/").
+	root = strings.TrimRight(root, "/")
+	extra = strings.TrimLeft(extra, "/")
+	return strings.TrimLeft(root+"/"+extra, "/")
 }
 
 func deadlinePassed(deadline time.Time) bool {
 	return time.Now().After(deadline)
 }
+
+func stringPtr(s string) *string {
+	if s == "" {
+		return nil
+	}
+	return &s
+}
diff --git a/internal/mirror/model/mirror.go b/internal/mirror/model/mirror.go
index a48ca1e..1f32427 100644
--- a/internal/mirror/model/mirror.go
+++ b/internal/mirror/model/mirror.go
@@ -26,6 +26,9 @@ import (
 const (
 	TimestampRW = "[timestamp]"
 	UUID        = "[uuid]"
+
+	// Test is used for testing only.
+	Test = "[test]"
 )
 
 // Mirror represents an individual mirror configuration.
@@ -75,9 +78,12 @@ func (m *Mirror) RewriteFilename(fName string) (string, error) {
 		}
 		fName = strings.Replace(fName, UUID, strings.ToUpper(newID.String()), 1)
 	}
+	for strings.Contains(fName, Test) {
+		fName = strings.Replace(fName, Test, "TEST", 1)
+	}
 
 	if fName == *m.FilenameRewrite {
-		return "", fmt.Errorf("mirror filename rewrite pattern contains not replacement patterns")
+		return "", fmt.Errorf("mirror filename rewrite pattern contains no replacement patterns")
 	}
 
 	return fName, nil
diff --git a/internal/mirror/server.go b/internal/mirror/server.go
index f843f1d..6b9aaf1 100644
--- a/internal/mirror/server.go
+++ b/internal/mirror/server.go
@@ -40,6 +40,9 @@ func NewServer(config *Config, env *serverenv.ServerEnv) (*Server, error) {
 	if env.Database() == nil {
 		return nil, fmt.Errorf("missing database in server environment")
 	}
+	if env.Blobstore() == nil {
+		return nil, fmt.Errorf("missing blobstore in server environment")
+	}
 
 	db := env.Database()
 	mdb := mirrordb.New(db)


Test patch:
diff --git a/internal/mirror/file_status_test.go b/internal/mirror/file_status_test.go
new file mode 100644
index 0000000..44c759b
--- /dev/null
+++ b/internal/mirror/file_status_test.go
@@ -0,0 +1,88 @@
+// Copyright 2020 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package mirror
+
+import (
+	"fmt"
+	"testing"
+
+	"github.com/google/exposure-notifications-server/internal/mirror/model"
+)
+
+func TestFileStatus_NeedsDelete(t *testing.T) {
+	t.Parallel()
+
+	cases := []struct {
+		downloadPath string
+		exp          bool
+	}{
+		{
+			downloadPath: "",
+			exp:          true,
+		},
+		{
+			downloadPath: "banana",
+			exp:          false,
+		},
+		{
+			downloadPath: "💰",
+			exp:          false,
+		},
+	}
+
+	for _, tc := range cases {
+		tc := tc
+
+		t.Run(fmt.Sprintf("download_path_%s", tc.downloadPath), func(t *testing.T) {
+			t.Parallel()
+
+			fs := &FileStatus{DownloadPath: tc.downloadPath}
+			if got, want := fs.needsDelete(), tc.exp; got != want {
+				t.Errorf("expected %t to be %t", got, want)
+			}
+		})
+	}
+}
+
+func TestFileStatus_NeedsDownload(t *testing.T) {
+	t.Parallel()
+
+	cases := []struct {
+		mirrorFile *model.MirrorFile
+		exp        bool
+	}{
+		{
+			mirrorFile: nil,
+			exp:        true,
+		},
+		{
+			mirrorFile: &model.MirrorFile{},
+			exp:        false,
+		},
+	}
+
+	for _, tc := range cases {
+		tc := tc
+
+		t.Run(fmt.Sprintf("mirror_file_%t", tc.mirrorFile != nil), func(t *testing.T) {
+			t.Parallel()
+
+			fs := &FileStatus{MirrorFile: tc.mirrorFile}
+			if got, want := fs.needsDownload(), tc.exp; got != want {
+				t.Errorf("expected %t to be %t", got, want)
+			}
+		})
+	}
+}
diff --git a/internal/mirror/mirror_test.go b/internal/mirror/mirror_test.go
new file mode 100644
index 0000000..7a900e1
--- /dev/null
+++ b/internal/mirror/mirror_test.go
@@ -0,0 +1,585 @@
+// Copyright 2020 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package mirror
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"net/http"
+	"net/http/httptest"
+	"net/url"
+	"sort"
+	"strings"
+	"testing"
+	"time"
+
+	"github.com/google/exposure-notifications-server/internal/database"
+	mirrordatabase "github.com/google/exposure-notifications-server/internal/mirror/database"
+	mirrormodel "github.com/google/exposure-notifications-server/internal/mirror/model"
+	"github.com/google/exposure-notifications-server/internal/serverenv"
+	"github.com/google/exposure-notifications-server/internal/storage"
+	"github.com/google/go-cmp/cmp"
+	"github.com/sethvargo/go-envconfig"
+)
+
+var testDatabaseInstance *database.TestInstance
+
+func TestMain(m *testing.M) {
+	testDatabaseInstance = database.MustTestInstance()
+	defer testDatabaseInstance.MustClose()
+	m.Run()
+}
+
+func TestServer_ProcessMirror(t *testing.T) {
+	t.Parallel()
+
+	ctx := context.Background()
+	zipFileContents := "data data data"
+
+	cases := []struct {
+		name        string
+		indexFiles  []string
+		mirrorFiles []*mirrordatabase.SyncFile
+
+		// Note: ID, IndexFile are populated by the test.
+		mirror *mirrormodel.Mirror
+
+		// expBlobstoreDeleted is a list of files in the blobstore to expect to be
+		// deleted. The first item is the bucket name, the second is the object
+		// name.
+		expBlobstoreDeleted [][2]string
+
+		// expBlobstoreAdded is the list of files in the in the blobstore that
+		// should exist after the run has completed. The first item is the bucket
+		// name, the second is the object name.
+		expBlobstoreAdded [][2]string
+	}{
+		{
+			name: "empty",
+			indexFiles: []string{
+				"1605818705-1605819005-00001.zip",
+				"1605818705-1605819005-00002.zip",
+
+				// Not currently in local copy, should be mirrored.
+				"1605819705-1605820005-00001.zip",
+			},
+			mirrorFiles: []*mirrordatabase.SyncFile{
+				{RemoteFile: "1605818705-1605819005-00001.zip"},
+				{RemoteFile: "1605818705-1605819005-00002.zip"},
+
+				// Old files that are no longer in the index (see indexFiles above)
+				{RemoteFile: "1605818705-1605819005-00003.zip"},
+				{RemoteFile: "1605818705-1605819005-00004.zip"},
+				{RemoteFile: "1605818705-1605819005-00005.zip"},
+			},
+			mirror: &mirrormodel.Mirror{
+				ExportRoot: "",
+
+				CloudStorageBucket: "",
+				FilenameRoot:       "",
+			},
+			expBlobstoreDeleted: [][2]string{
+				{"", "1605818705-1605819005-00003.zip"},
+				{"", "1605818705-1605819005-00004.zip"},
+				{"", "1605818705-1605819005-00005.zip"},
+			},
+			expBlobstoreAdded: [][2]string{
+				{"", "1605819705-1605820005-00001.zip"},
+			},
+		},
+		{
+			name: "configured",
+			indexFiles: []string{
+				"us/1605818705-1605819005-00001.zip",
+				"us/1605818705-1605819005-00002.zip",
+
+				// Not currently in local copy, should be mirrored.
+				"us/1605819705-1605820005-00001.zip",
+			},
+			mirrorFiles: []*mirrordatabase.SyncFile{
+				{RemoteFile: "1605818705-1605819005-00001.zip"},
+				{RemoteFile: "1605818705-1605819005-00002.zip"},
+
+				// Old files that are no longer in the index (see indexFiles above)
+				{RemoteFile: "1605818705-1605819005-00003.zip"},
+				{RemoteFile: "1605818705-1605819005-00004.zip"},
+				{RemoteFile: "1605818705-1605819005-00005.zip"},
+			},
+			mirror: &mirrormodel.Mirror{
+				ExportRoot: "us",
+
+				CloudStorageBucket: "bucket",
+				FilenameRoot:       "my/other/path",
+			},
+			expBlobstoreDeleted: [][2]string{
+				{"bucket", "my/other/path/1605818705-1605819005-00003.zip"},
+				{"bucket", "my/other/path/1605818705-1605819005-00004.zip"},
+				{"bucket", "my/other/path/1605818705-1605819005-00005.zip"},
+			},
+			expBlobstoreAdded: [][2]string{
+				{"bucket", "my/other/path/1605819705-1605820005-00001.zip"},
+			},
+		},
+		{
+			name: "rewrite",
+			indexFiles: []string{
+				"1605818705-1605819005-00001.zip",
+				"1605818705-1605819005-00002.zip",
+			},
+			mirrorFiles: []*mirrordatabase.SyncFile{},
+			mirror: &mirrormodel.Mirror{
+				// This isn't super great, but we can't really test UUID or timestamp
+				// behavior predictably. This will just rewrite all the files to the
+				// same name, but we can at least assert that the rewrite happened.
+				FilenameRewrite: stringPtr("xx-[test].zip"),
+			},
+			expBlobstoreAdded: [][2]string{
+				{"", "xx-TEST.zip"},
+			},
+		},
+	}
+
+	for _, tc := range cases {
+		tc := tc
+
+		t.Run(tc.name, func(t *testing.T) {
+			t.Parallel()
+
+			testDB, _ := testDatabaseInstance.NewDatabase(t)
+			mirrorDB := mirrordatabase.New(testDB)
+			testBlobstore, err := storage.NewMemory(ctx)
+			if err != nil {
+				t.Fatal(err)
+			}
+
+			env := serverenv.New(ctx,
+				serverenv.WithDatabase(testDB),
+				serverenv.WithBlobStorage(testBlobstore),
+			)
+
+			var config Config
+			if err := envconfig.ProcessWith(ctx, &config, envconfig.MapLookuper(nil)); err != nil {
+				t.Fatal(err)
+			}
+
+			s, err := NewServer(&config, env)
+			if err != nil {
+				t.Fatal(err)
+			}
+
+			// Grab a handle to the mirror.
+			mirror := tc.mirror
+
+			// Create a test in-memory server.
+			mux := http.NewServeMux()
+
+			indexPath := urlJoin(mirror.ExportRoot, "index.txt")
+			mux.HandleFunc("/"+indexPath, func(w http.ResponseWriter, r *http.Request) {
+				for _, v := range tc.indexFiles {
+					fmt.Fprintln(w, v)
+				}
+			})
+			for _, indexFile := range tc.indexFiles {
+				indexFilePath := urlJoin(mirror.ExportRoot, indexFile)
+				mux.HandleFunc("/"+indexFilePath, func(w http.ResponseWriter, r *http.Request) {
+					w.Header().Set("Content-Type", "application/zip")
+					fmt.Fprint(w, zipFileContents)
+				})
+			}
+
+			ts := httptest.NewServer(mux)
+			t.Cleanup(ts.Close)
+
+			// Set up mirror and save to the database.
+			mirror.IndexFile = urlJoin(ts.URL, indexPath)
+			mirror.ExportRoot = urlJoin(ts.URL, mirror.ExportRoot)
+			if err := mirrorDB.AddMirror(ctx, tc.mirror); err != nil {
+				t.Fatal(err)
+			}
+			if err := mirrorDB.SaveFiles(ctx, mirror.ID, tc.mirrorFiles); err != nil {
+				t.Fatal(err)
+			}
+
+			// Save any files into the blobstore.
+			for _, f := range tc.mirrorFiles {
+				name := f.RemoteFile
+				if f.LocalFile != "" {
+					name = f.LocalFile
+				}
+				pth := urlJoin(mirror.FilenameRoot, name)
+				if err := testBlobstore.CreateObject(ctx, mirror.CloudStorageBucket, pth, nil, true, storage.ContentTypeZip); err != nil {
+					t.Fatal(err)
+				}
+			}
+
+			deadline := time.Now().Add(60 * time.Second)
+			if err := s.processMirror(ctx, deadline, mirror); err != nil {
+				t.Fatal(err)
+			}
+
+			// Verify that expected files were deleted from the blobstore.
+			for _, exp := range tc.expBlobstoreDeleted {
+				if _, err := testBlobstore.GetObject(ctx, exp[0], exp[1]); !errors.Is(err, storage.ErrNotFound) {
+					t.Errorf("%s: expected %v, got %v", urlJoin(exp[0], exp[1]), storage.ErrNotFound, err)
+				}
+			}
+
+			// Verify that expected files were added to the blobstore.
+			for _, exp := range tc.expBlobstoreAdded {
+				b, err := testBlobstore.GetObject(ctx, exp[0], exp[1])
+				if err != nil {
+					t.Errorf("%s: %s", urlJoin(exp[0], exp[1]), err)
+				}
+				if got, want := string(b), zipFileContents; got != want {
+					t.Errorf("expected %q to be %q", got, want)
+				}
+			}
+		})
+	}
+}
+
+func TestServer_DownloadIndex(t *testing.T) {
+	t.Parallel()
+
+	ctx := context.Background()
+	emptyDB := &database.DB{}
+	testBlobstore, err := storage.NewNoop(ctx)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	env := serverenv.New(ctx,
+		serverenv.WithDatabase(emptyDB),
+		serverenv.WithBlobStorage(testBlobstore),
+	)
+
+	// Client honors the provided timeout.
+	t.Run("timeout", func(t *testing.T) {
+		t.Parallel()
+
+		c := &Config{IndexFileDownloadTimeout: 1 * time.Nanosecond}
+		s, err := NewServer(c, env)
+		if err != nil {
+			t.Fatal(err)
+		}
+
+		ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+			time.Sleep(250 * time.Millisecond)
+			fmt.Fprint(w, "abc-123-456.zip")
+		}))
+		t.Cleanup(ts.Close)
+
+		_, err = s.downloadIndex(&mirrormodel.Mirror{
+			IndexFile: ts.URL,
+		})
+
+		var terr *url.Error
+		if errors.As(err, &terr) {
+			if !terr.Timeout() {
+				t.Errorf("expected %#v to be a timeout error", terr)
+			}
+		} else {
+			t.Fatalf("expected url.Error, got %#v", err)
+		}
+	})
+
+	// Client returns an error on non-200.
+	t.Run("non_200", func(t *testing.T) {
+		t.Parallel()
+
+		c := &Config{}
+		s, err := NewServer(c, env)
+		if err != nil {
+			t.Fatal(err)
+		}
+
+		ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+			w.WriteHeader(404)
+		}))
+		t.Cleanup(ts.Close)
+
+		_, err = s.downloadIndex(&mirrormodel.Mirror{
+			IndexFile: ts.URL,
+		})
+		if err == nil {
+			t.Fatal("expected error")
+		}
+		if got, want := err.Error(), "failed to download"; !strings.Contains(got, want) {
+			t.Errorf("expected %q to contain %q", got, want)
+		}
+	})
+
+	// Client only reads up to configured bytes limit
+	t.Run("max_bytes", func(t *testing.T) {
+		t.Parallel()
+
+		c := &Config{MaxIndexBytes: 1}
+		s, err := NewServer(c, env)
+		if err != nil {
+			t.Fatal(err)
+		}
+
+		ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+			fmt.Fprint(w, "abc-123-456.zip")
+			fmt.Fprint(w, "abc-123-456.zip")
+			fmt.Fprint(w, "abc-123-456.zip")
+		}))
+		t.Cleanup(ts.Close)
+
+		_, err = s.downloadIndex(&mirrormodel.Mirror{
+			IndexFile: ts.URL,
+		})
+		if got, want := err.Error(), "response exceeds 1 bytes"; !strings.Contains(got, want) {
+			t.Errorf("expected %q to contain %q", got, want)
+		}
+	})
+
+	// Client handles empty index file
+	t.Run("empty", func(t *testing.T) {
+		t.Parallel()
+
+		c := &Config{}
+		s, err := NewServer(c, env)
+		if err != nil {
+			t.Fatal(err)
+		}
+
+		ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
+		t.Cleanup(ts.Close)
+
+		results, err := s.downloadIndex(&mirrormodel.Mirror{
+			IndexFile: ts.URL,
+		})
+		if err != nil {
+			t.Fatal(err)
+		}
+
+		if len(results) != 0 {
+			t.Errorf("expected results to be empty, got %#v", results)
+		}
+	})
+
+	// Client handles multiple files
+	t.Run("multifile", func(t *testing.T) {
+		t.Parallel()
+
+		c := &Config{
+			MaxIndexBytes: 8196,
+		}
+		s, err := NewServer(c, env)
+		if err != nil {
+			t.Fatal(err)
+		}
+
+		ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+			now := int(time.Now().UTC().Second())
+
+			for i := 1; i <= 50; i++ {
+				for j := 1; j <= 5; j++ {
+					fmt.Fprintf(w, "us/%d-%d-%05d.zip\n", now+(i*1000), now+(i*2000), j)
+				}
+			}
+		}))
+		t.Cleanup(ts.Close)
+
+		results, err := s.downloadIndex(&mirrormodel.Mirror{
+			IndexFile: ts.URL,
+		})
+		if err != nil {
+			t.Fatal(err)
+		}
+
+		if got, want := len(results), 250; got != want {
+			t.Errorf("got %d results, expected %d", got, want)
+		}
+	})
+
+	// Client handles leading and trailing slashes
+	t.Run("slashes", func(t *testing.T) {
+		t.Parallel()
+
+		c := &Config{
+			MaxIndexBytes: 8196,
+		}
+		s, err := NewServer(c, env)
+		if err != nil {
+			t.Fatal(err)
+		}
+
+		ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+			fmt.Fprintf(w, "us/1-2-00001.zip\n")
+			fmt.Fprintf(w, "/us/2-3-00008.zip\n")
+		}))
+		t.Cleanup(ts.Close)
+
+		for _, root := range []string{
+			"https://example.com/path",
+			"https://example.com/path/",
+		} {
+			results, err := s.downloadIndex(&mirrormodel.Mirror{
+				IndexFile:  ts.URL,
+				ExportRoot: root,
+			})
+			if err != nil {
+				t.Fatal(err)
+			}
+
+			if got, want := len(results), 2; got != want {
+				t.Errorf("got %d results, expected %d", got, want)
+			}
+			sort.Strings(results)
+
+			if got, want := results[0], "https://example.com/path/us/1-2-00001.zip"; got != want {
+				t.Errorf("expected %q to be %q", got, want)
+			}
+			if got, want := results[1], "https://example.com/path/us/2-3-00008.zip"; got != want {
+				t.Errorf("expected %q to be %q", got, want)
+			}
+		}
+	})
+}
+
+func TestServer_ComputeActions(t *testing.T) {
+	t.Parallel()
+
+	cases := []struct {
+		name       string
+		knownFiles []*mirrormodel.MirrorFile
+		indexFiles []string
+		exp        map[string]*FileStatus
+	}{
+		{
+			name:       "nil",
+			knownFiles: nil,
+			indexFiles: nil,
+			exp:        map[string]*FileStatus{},
+		},
+		{
+			name:       "empty",
+			knownFiles: []*mirrormodel.MirrorFile{},
+			indexFiles: []string{},
+			exp:        map[string]*FileStatus{},
+		},
+		{
+			name:       "all_index",
+			knownFiles: []*mirrormodel.MirrorFile{},
+			indexFiles: []string{
+				"us/1605818705-1605819005-00001.zip",
+				"us/1605818705-1605819005-00002.zip",
+			},
+			exp: map[string]*FileStatus{
+				"1605818705-1605819005-00001.zip": {
+					Order:        1,
+					DownloadPath: "us/1605818705-1605819005-00001.zip",
+					Filename:     "1605818705-1605819005-00001.zip",
+				},
+				"1605818705-1605819005-00002.zip": {
+					Order:        2,
+					DownloadPath: "us/1605818705-1605819005-00002.zip",
+					Filename:     "1605818705-1605819005-00002.zip",
+				},
+			},
+		},
+		{
+			name: "all_known",
+			knownFiles: []*mirrormodel.MirrorFile{
+				{
+					MirrorID:      1,
+					Filename:      "1605818705-1605819005-00001.zip",
+					LocalFilename: stringPtr("1605818705-1605819005-00001.zip"),
+				},
+				{
+					MirrorID:      1,
+					Filename:      "1605818705-1605819005-00002.zip",
+					LocalFilename: stringPtr("1605818705-1605819005-00002.zip"),
+				},
+			},
+			indexFiles: []string{},
+			exp: map[string]*FileStatus{
+				"1605818705-1605819005-00001.zip": {
+					MirrorFile: &mirrormodel.MirrorFile{
+						MirrorID:      1,
+						Filename:      "1605818705-1605819005-00001.zip",
+						LocalFilename: stringPtr("1605818705-1605819005-00001.zip"),
+					},
+				},
+				"1605818705-1605819005-00002.zip": {
+					MirrorFile: &mirrormodel.MirrorFile{
+						MirrorID:      1,
+						Filename:      "1605818705-1605819005-00002.zip",
+						LocalFilename: stringPtr("1605818705-1605819005-00002.zip"),
+					},
+				},
+			},
+		},
+		{
+			name: "intersect",
+			knownFiles: []*mirrormodel.MirrorFile{
+				{
+					MirrorID:      1,
+					Filename:      "1605818705-1605819005-00001.zip",
+					LocalFilename: stringPtr("1605818705-1605819005-00001.zip"),
+				},
+				{
+					MirrorID:      1,
+					Filename:      "1605818705-1605819005-00002.zip",
+					LocalFilename: stringPtr("1605818705-1605819005-00002.zip"),
+				},
+			},
+			indexFiles: []string{
+				"ca/1605818705-1605819005-00001.zip",
+				"us/1605818705-1605819005-00002.zip",
+			},
+			exp: map[string]*FileStatus{
+				"1605818705-1605819005-00001.zip": {
+					Order:         1,
+					DownloadPath:  "ca/1605818705-1605819005-00001.zip",
+					Filename:      "1605818705-1605819005-00001.zip",
+					LocalFilename: "1605818705-1605819005-00001.zip",
+					MirrorFile: &mirrormodel.MirrorFile{
+						MirrorID:      1,
+						Filename:      "1605818705-1605819005-00001.zip",
+						LocalFilename: stringPtr("1605818705-1605819005-00001.zip"),
+					},
+				},
+				"1605818705-1605819005-00002.zip": {
+					Order:         2,
+					DownloadPath:  "us/1605818705-1605819005-00002.zip",
+					Filename:      "1605818705-1605819005-00002.zip",
+					LocalFilename: "1605818705-1605819005-00002.zip",
+					MirrorFile: &mirrormodel.MirrorFile{
+						MirrorID:      1,
+						Filename:      "1605818705-1605819005-00002.zip",
+						LocalFilename: stringPtr("1605818705-1605819005-00002.zip"),
+					},
+				},
+			},
+		},
+	}
+
+	for _, tc := range cases {
+		tc := tc
+
+		t.Run(tc.name, func(t *testing.T) {
+			t.Parallel()
+
+			actions := computeActions(tc.knownFiles, tc.indexFiles)
+			if diff := cmp.Diff(tc.exp, actions); diff != "" {
+				t.Fatalf("mismatch (-want, +got):\n%s", diff)
+			}
+		})
+	}
+}
diff --git a/internal/mirror/model/mirror_test.go b/internal/mirror/model/mirror_test.go
index 2c2657f..26888ec 100644
--- a/internal/mirror/model/mirror_test.go
+++ b/internal/mirror/model/mirror_test.go
@@ -20,6 +20,8 @@ import (
 )
 
 func TestRewriteFilenameNoOp(t *testing.T) {
+	t.Parallel()
+
 	m := Mirror{}
 
 	want := "afile.zip"
@@ -31,6 +33,8 @@ func TestRewriteFilenameNoOp(t *testing.T) {
 }
 
 func TestRewriteFilenameTimestamps(t *testing.T) {
+	t.Parallel()
+
 	pattern := "[timestamp]-[timestamp]-00001.zip"
 	m := Mirror{
 		FilenameRewrite: &pattern,
@@ -52,6 +56,8 @@ func TestRewriteFilenameTimestamps(t *testing.T) {
 }
 
 func TestRewriteUUID(t *testing.T) {
+	t.Parallel()
+
 	pattern := "[uuid]-00001.zip"
 	m := Mirror{
 		FilenameRewrite: &pattern,
diff --git a/internal/mirror/server_test.go b/internal/mirror/server_test.go
new file mode 100644
index 0000000..ad329af
--- /dev/null
+++ b/internal/mirror/server_test.go
@@ -0,0 +1,79 @@
+// Copyright 2020 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package mirror
+
+import (
+	"context"
+	"fmt"
+	"strings"
+	"testing"
+
+	"github.com/google/exposure-notifications-server/internal/database"
+	"github.com/google/exposure-notifications-server/internal/serverenv"
+	"github.com/google/exposure-notifications-server/internal/storage"
+)
+
+func TestNewServer(t *testing.T) {
+	t.Parallel()
+
+	emptyDB := &database.DB{}
+	emptyBlobstore := &storage.Noop{}
+	ctx := context.Background()
+
+	testCases := []struct {
+		name string
+		env  *serverenv.ServerEnv
+		err  error
+	}{
+		{
+			name: "nil_database",
+			env:  serverenv.New(ctx),
+			err:  fmt.Errorf("missing database in server environment"),
+		},
+		{
+			name: "nil_blobstore",
+			env:  serverenv.New(ctx, serverenv.WithDatabase(emptyDB)),
+			err:  fmt.Errorf("missing blobstore in server environment"),
+		},
+		{
+			name: "fully_specified",
+			env: serverenv.New(ctx,
+				serverenv.WithDatabase(emptyDB),
+				serverenv.WithBlobStorage(emptyBlobstore)),
+			err: nil,
+		},
+	}
+
+	for _, tc := range testCases {
+		tc := tc
+
+		t.Run(tc.name, func(t *testing.T) {
+			t.Parallel()
+
+			got, err := NewServer(&Config{}, tc.env)
+			if tc.err != nil {
+				if got, want := err.Error(), tc.err.Error(); !strings.Contains(got, want) {
+					t.Fatalf("expected %q to be %q", got, want)
+				}
+			} else if err != nil {
+				t.Fatal(err)
+			} else {
+				if got, want := got.env, tc.env; got != want {
+					t.Fatalf("expected %#v to be %#v", got, want)
+				}
+			}
+		})
+	}
+}