Repository: google/exposure-notifications-server
Pull request #1480 (2021-03-22 20:55:20)

Refactor cleanup to use server patterns and middleware
I must have missed this package when doing the previous refactors. This will improve our observability and logging visibility for the cleanup workers.

**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
Refactor cleanup jobs to use server patterns and middleware.
```


Patch:
diff --git a/cmd/cleanup-export/main.go b/cmd/cleanup-export/main.go
index a6c085a..9ea7745 100644
--- a/cmd/cleanup-export/main.go
+++ b/cmd/cleanup-export/main.go
@@ -25,9 +25,7 @@ import (
 	"github.com/google/exposure-notifications-server/internal/cleanup"
 	"github.com/google/exposure-notifications-server/internal/setup"
 	"github.com/google/exposure-notifications-server/pkg/logging"
-	_ "github.com/google/exposure-notifications-server/pkg/observability"
 	"github.com/google/exposure-notifications-server/pkg/server"
-	"github.com/gorilla/mux"
 )
 
 func main() {
@@ -64,20 +62,16 @@ func realMain(ctx context.Context) error {
 	}
 	defer env.Close(ctx)
 
-	handler, err := cleanup.NewExportHandler(&config, env)
+	cleanupExportServer, err := cleanup.NewExportServer(&config, env)
 	if err != nil {
-		return fmt.Errorf("cleanup.NewExportHandler: %w", err)
+		return fmt.Errorf("cleaup.NewExportServer: %w", err)
 	}
 
-	r := mux.NewRouter()
-	r.Handle("/", handler)
-	r.Handle("/health", server.HandleHealthz(env.Database()))
-
 	srv, err := server.New(config.Port)
 	if err != nil {
 		return fmt.Errorf("server.New: %w", err)
 	}
-	logger.Infof("listening on :%s", config.Port)
+	logger.Info("listening on: ", config.Port)
 
-	return srv.ServeHTTPHandler(ctx, r)
+	return srv.ServeHTTPHandler(ctx, cleanupExportServer.Routes(ctx))
 }
diff --git a/cmd/cleanup-exposure/main.go b/cmd/cleanup-exposure/main.go
index b7d188f..ee5df50 100644
--- a/cmd/cleanup-exposure/main.go
+++ b/cmd/cleanup-exposure/main.go
@@ -27,7 +27,6 @@ import (
 	"github.com/google/exposure-notifications-server/pkg/logging"
 	_ "github.com/google/exposure-notifications-server/pkg/observability"
 	"github.com/google/exposure-notifications-server/pkg/server"
-	"github.com/gorilla/mux"
 )
 
 func main() {
@@ -64,20 +63,16 @@ func realMain(ctx context.Context) error {
 	}
 	defer env.Close(ctx)
 
-	handler, err := cleanup.NewExposureHandler(&config, env)
+	cleanupExposureServer, err := cleanup.NewExposureServer(&config, env)
 	if err != nil {
-		return fmt.Errorf("cleanup.NewExposureHandler: %w", err)
+		return fmt.Errorf("cleaup.NewExposureServer: %w", err)
 	}
 
-	r := mux.NewRouter()
-	r.Handle("/", handler)
-	r.Handle("/health", server.HandleHealthz(env.Database()))
-
 	srv, err := server.New(config.Port)
 	if err != nil {
 		return fmt.Errorf("server.New: %w", err)
 	}
-	logger.Infof("listening on :%s", config.Port)
+	logger.Info("listening on: ", config.Port)
 
-	return srv.ServeHTTPHandler(ctx, r)
+	return srv.ServeHTTPHandler(ctx, cleanupExposureServer.Routes(ctx))
 }
diff --git a/internal/cleanup/cleanup.go b/internal/cleanup/cleanup.go
index b855742..c62209a 100644
--- a/internal/cleanup/cleanup.go
+++ b/internal/cleanup/cleanup.go
@@ -16,157 +16,17 @@
 package cleanup
 
 import (
-	"context"
 	"encoding/json"
 	"errors"
 	"fmt"
 	"net/http"
 	"time"
 
-	"go.opencensus.io/stats"
-
-	"github.com/google/exposure-notifications-server/internal/export/database"
-	publishdb "github.com/google/exposure-notifications-server/internal/publish/database"
 	"github.com/hashicorp/go-multierror"
-
-	"github.com/google/exposure-notifications-server/internal/serverenv"
-	"github.com/google/exposure-notifications-server/internal/storage"
-	"github.com/google/exposure-notifications-server/pkg/logging"
 )
 
 const minTTL = 10 * 24 * time.Hour
 
-// NewExposureHandler creates a http.Handler for deleting exposure keys
-// from the database.
-func NewExposureHandler(config *Config, env *serverenv.ServerEnv) (http.Handler, error) {
-	if env.Database() == nil {
-		return nil, fmt.Errorf("missing database in server environment")
-	}
-
-	return &exposureCleanupHandler{
-		config:   config,
-		env:      env,
-		database: publishdb.New(env.Database()),
-	}, nil
-}
-
-type exposureCleanupHandler struct {
-	config   *Config
-	env      *serverenv.ServerEnv
-	database *publishdb.PublishDB
-}
-
-func (h *exposureCleanupHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
-	ctx := r.Context()
-	logger := logging.FromContext(ctx).Named("cleanup.exposure")
-
-	cutoff, err := cutoffDate(h.config.TTL, h.config.DebugOverrideCleanupMinDuration)
-	if err != nil {
-		logger.Errorw("failed to calculate cutoff date", "error", err)
-		respondWithError(w, http.StatusInternalServerError, err)
-		return
-	}
-
-	// Construct a multi-error. If one of the purges fails, we still want to
-	// attempt the other purges.
-	var merr *multierror.Error
-
-	// Exposures
-	func() {
-		ctx, cancel := context.WithTimeout(ctx, h.config.Timeout)
-		defer cancel()
-
-		if count, err := h.database.DeleteExposuresBefore(ctx, cutoff); err != nil {
-			merr = multierror.Append(merr, fmt.Errorf("failed to delete exposures: %w", err))
-		} else {
-			logger.Infow("purged exposures", "count", count)
-		}
-	}()
-
-	// Stats
-	func() {
-		ctx, cancel := context.WithTimeout(ctx, h.config.Timeout)
-		defer cancel()
-
-		if count, err := h.database.DeleteStatsBefore(ctx, cutoff); err != nil {
-			merr = multierror.Append(merr, fmt.Errorf("failed to delete stats: %w", err))
-		} else {
-			logger.Infow("purged statistics", "count", count)
-		}
-	}()
-
-	if merr != nil {
-		logger.Errorw("failed to cleanup exposures", "errors", merr.WrappedErrors())
-		respondWithError(w, http.StatusInternalServerError, merr)
-		return
-	}
-
-	stats.Record(ctx, mExposureSuccess.M(1))
-	respond(w, http.StatusOK)
-}
-
-// NewExportHandler creates a http.Handler that manages deletion of
-// old export files that are no longer needed by clients for download.
-func NewExportHandler(config *Config, env *serverenv.ServerEnv) (http.Handler, 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")
-	}
-
-	return &exportCleanupHandler{
-		config:    config,
-		env:       env,
-		database:  database.New(env.Database()),
-		blobstore: env.Blobstore(),
-	}, nil
-}
-
-type exportCleanupHandler struct {
-	config    *Config
-	env       *serverenv.ServerEnv
-	database  *database.ExportDB
-	blobstore storage.Blobstore
-}
-
-func (h *exportCleanupHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
-	ctx := r.Context()
-	logger := logging.FromContext(ctx).Named("cleanup.export")
-
-	cutoff, err := cutoffDate(h.config.TTL, h.config.DebugOverrideCleanupMinDuration)
-	if err != nil {
-		logger.Errorw("failed to calculate cutoff date", "error", err)
-		respondWithError(w, http.StatusInternalServerError, err)
-		return
-	}
-
-	// Construct a multi-error. If one of the purges fails, we still want to
-	// attempt the other purges.
-	var merr *multierror.Error
-
-	// Files
-	func() {
-		ctx, cancel := context.WithTimeout(ctx, h.config.Timeout)
-		defer cancel()
-
-		if count, err := h.database.DeleteFilesBefore(ctx, cutoff, h.blobstore); err != nil {
-			merr = multierror.Append(merr, fmt.Errorf("failed to delete files: %w", err))
-		} else {
-			logger.Infow("purged files", "count", count)
-		}
-	}()
-
-	if merr != nil {
-		logger.Errorw("failed to cleanup exports", "errors", merr.WrappedErrors())
-		respondWithError(w, http.StatusInternalServerError, merr)
-		return
-	}
-
-	stats.Record(ctx, mExportSuccess.M(1))
-	respond(w, http.StatusOK)
-}
-
 func cutoffDate(d time.Duration, override bool) (time.Time, error) {
 	if d >= minTTL || override {
 		return time.Now().UTC().Add(-d), nil
diff --git a/internal/cleanup/cleanup_export.go b/internal/cleanup/cleanup_export.go
new file mode 100644
index 0000000..1a12aa1
--- /dev/null
+++ b/internal/cleanup/cleanup_export.go
@@ -0,0 +1,112 @@
+// Copyright 2021 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 cleanup
+
+import (
+	"context"
+	"fmt"
+	"net/http"
+
+	"github.com/google/exposure-notifications-server/internal/export/database"
+	"github.com/google/exposure-notifications-server/internal/middleware"
+	"github.com/google/exposure-notifications-server/internal/serverenv"
+	"github.com/google/exposure-notifications-server/internal/storage"
+	"github.com/google/exposure-notifications-server/pkg/logging"
+	"github.com/google/exposure-notifications-server/pkg/server"
+	"github.com/gorilla/mux"
+	"github.com/hashicorp/go-multierror"
+	"go.opencensus.io/stats"
+)
+
+type ExportServer struct {
+	config    *Config
+	env       *serverenv.ServerEnv
+	database  *database.ExportDB
+	blobstore storage.Blobstore
+}
+
+// NewExportServer creates a server that manages deletion of old export files
+// that are no longer needed by clients for download.
+func NewExportServer(cfg *Config, env *serverenv.ServerEnv) (*ExportServer, 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")
+	}
+
+	return &ExportServer{
+		config:    cfg,
+		env:       env,
+		database:  database.New(env.Database()),
+		blobstore: env.Blobstore(),
+	}, nil
+}
+
+// Routes defines and returns the routes for the export server.
+func (s *ExportServer) Routes(ctx context.Context) *mux.Router {
+	logger := logging.FromContext(ctx).Named("mirror")
+
+	r := mux.NewRouter()
+	r.Use(middleware.Recovery())
+	r.Use(middleware.PopulateRequestID())
+	r.Use(middleware.PopulateObservability())
+	r.Use(middleware.PopulateLogger(logger))
+
+	r.Handle("/health", server.HandleHealthz(s.env.Database()))
+	r.Handle("/", s.handleCleanup())
+
+	return r
+}
+
+// handleCleanup handles export cleanup.
+func (s *ExportServer) handleCleanup() http.Handler {
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		ctx := r.Context()
+		logger := logging.FromContext(ctx).Named("cleanup.export")
+
+		cutoff, err := cutoffDate(s.config.TTL, s.config.DebugOverrideCleanupMinDuration)
+		if err != nil {
+			logger.Errorw("failed to calculate cutoff date", "error", err)
+			respondWithError(w, http.StatusInternalServerError, err)
+			return
+		}
+
+		// Construct a multi-error. If one of the purges fails, we still want to
+		// attempt the other purges.
+		var merr *multierror.Error
+
+		// Files
+		func() {
+			ctx, cancel := context.WithTimeout(ctx, s.config.Timeout)
+			defer cancel()
+
+			if count, err := s.database.DeleteFilesBefore(ctx, cutoff, s.blobstore); err != nil {
+				merr = multierror.Append(merr, fmt.Errorf("failed to delete files: %w", err))
+			} else {
+				logger.Infow("purged files", "count", count)
+			}
+		}()
+
+		if merr != nil {
+			logger.Errorw("failed to cleanup exports", "errors", merr.WrappedErrors())
+			respondWithError(w, http.StatusInternalServerError, merr)
+			return
+		}
+
+		stats.Record(ctx, mExportSuccess.M(1))
+		respond(w, http.StatusOK)
+	})
+}
diff --git a/internal/cleanup/cleanup_exposure.go b/internal/cleanup/cleanup_exposure.go
new file mode 100644
index 0000000..f4a3f98
--- /dev/null
+++ b/internal/cleanup/cleanup_exposure.go
@@ -0,0 +1,118 @@
+// Copyright 2021 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 cleanup
+
+import (
+	"context"
+	"fmt"
+	"net/http"
+
+	"github.com/google/exposure-notifications-server/internal/middleware"
+	"github.com/google/exposure-notifications-server/internal/publish/database"
+	"github.com/google/exposure-notifications-server/internal/serverenv"
+	"github.com/google/exposure-notifications-server/pkg/logging"
+	"github.com/google/exposure-notifications-server/pkg/server"
+	"github.com/gorilla/mux"
+	"github.com/hashicorp/go-multierror"
+	"go.opencensus.io/stats"
+)
+
+type ExposureServer struct {
+	config   *Config
+	env      *serverenv.ServerEnv
+	database *database.PublishDB
+}
+
+// NewExposureServer creates a http.Handler for deleting exposure keys
+// from the database.
+func NewExposureServer(cfg *Config, env *serverenv.ServerEnv) (*ExposureServer, error) {
+	if env.Database() == nil {
+		return nil, fmt.Errorf("missing database in server environment")
+	}
+
+	return &ExposureServer{
+		config:   cfg,
+		env:      env,
+		database: database.New(env.Database()),
+	}, nil
+}
+
+// Routes defines and returns the routes for the export server.
+func (s *ExposureServer) Routes(ctx context.Context) *mux.Router {
+	logger := logging.FromContext(ctx).Named("mirror")
+
+	r := mux.NewRouter()
+	r.Use(middleware.Recovery())
+	r.Use(middleware.PopulateRequestID())
+	r.Use(middleware.PopulateObservability())
+	r.Use(middleware.PopulateLogger(logger))
+
+	r.Handle("/health", server.HandleHealthz(s.env.Database()))
+	r.Handle("/", s.handleCleanup())
+
+	return r
+}
+
+// handleCleanup handles export cleanup.
+func (s *ExposureServer) handleCleanup() http.Handler {
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		ctx := r.Context()
+		logger := logging.FromContext(ctx).Named("cleanup.exposure")
+
+		cutoff, err := cutoffDate(s.config.TTL, s.config.DebugOverrideCleanupMinDuration)
+		if err != nil {
+			logger.Errorw("failed to calculate cutoff date", "error", err)
+			respondWithError(w, http.StatusInternalServerError, err)
+			return
+		}
+
+		// Construct a multi-error. If one of the purges fails, we still want to
+		// attempt the other purges.
+		var merr *multierror.Error
+
+		// Exposures
+		func() {
+			ctx, cancel := context.WithTimeout(ctx, s.config.Timeout)
+			defer cancel()
+
+			if count, err := s.database.DeleteExposuresBefore(ctx, cutoff); err != nil {
+				merr = multierror.Append(merr, fmt.Errorf("failed to delete exposures: %w", err))
+			} else {
+				logger.Infow("purged exposures", "count", count)
+			}
+		}()
+
+		// Stats
+		func() {
+			ctx, cancel := context.WithTimeout(ctx, s.config.Timeout)
+			defer cancel()
+
+			if count, err := s.database.DeleteStatsBefore(ctx, cutoff); err != nil {
+				merr = multierror.Append(merr, fmt.Errorf("failed to delete stats: %w", err))
+			} else {
+				logger.Infow("purged statistics", "count", count)
+			}
+		}()
+
+		if merr != nil {
+			logger.Errorw("failed to cleanup exposures", "errors", merr.WrappedErrors())
+			respondWithError(w, http.StatusInternalServerError, merr)
+			return
+		}
+
+		stats.Record(ctx, mExposureSuccess.M(1))
+		respond(w, http.StatusOK)
+	})
+}


Test patch:
diff --git a/internal/cleanup/cleanup_export_test.go b/internal/cleanup/cleanup_export_test.go
new file mode 100644
index 0000000..cbed361
--- /dev/null
+++ b/internal/cleanup/cleanup_export_test.go
@@ -0,0 +1,116 @@
+// Copyright 2021 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 cleanup
+
+import (
+	"fmt"
+	"net/http"
+	"net/http/httptest"
+	"testing"
+	"time"
+
+	"github.com/google/exposure-notifications-server/internal/project"
+	"github.com/google/exposure-notifications-server/internal/serverenv"
+	"github.com/google/exposure-notifications-server/internal/storage"
+)
+
+func TestNewExportServer(t *testing.T) {
+	t.Parallel()
+
+	ctx := project.TestContext(t)
+	testDB, _ := testDatabaseInstance.NewDatabase(t)
+	noopBlobstore, _ := storage.NewMemory(ctx, &storage.Config{})
+
+	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(testDB)),
+			err:  fmt.Errorf("missing blobstore in server environment"),
+		},
+		{
+			name: "fully_specified",
+			env:  serverenv.New(ctx, serverenv.WithBlobStorage(noopBlobstore), serverenv.WithDatabase(testDB)),
+			err:  nil,
+		},
+	}
+
+	for _, tc := range testCases {
+		tc := tc
+
+		t.Run(tc.name, func(t *testing.T) {
+			t.Parallel()
+
+			got, err := NewExportServer(&Config{}, tc.env)
+			if tc.err != nil {
+				if err.Error() != tc.err.Error() {
+					t.Fatalf("got %+v: want %v", err, tc.err)
+				}
+			} else if err != nil {
+				t.Fatalf("got unexpected error: %v", err)
+			} else {
+				if got.env != tc.env {
+					t.Fatalf("got %+v: want %v", got.env, tc.env)
+				}
+			}
+		})
+	}
+}
+
+func TestExportHandler_ServeHTTP(t *testing.T) {
+	t.Parallel()
+
+	ctx := project.TestContext(t)
+	testDB, _ := testDatabaseInstance.NewDatabase(t)
+
+	bs, err := storage.NewMemory(ctx, &storage.Config{
+		Type: "MEMORY",
+	})
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	env := serverenv.New(ctx,
+		serverenv.WithDatabase(testDB),
+		serverenv.WithBlobStorage(bs))
+
+	r, err := http.NewRequestWithContext(ctx, http.MethodGet, "/", nil)
+	if err != nil {
+		t.Fatal(err)
+	}
+	w := httptest.NewRecorder()
+
+	server, err := NewExportServer(&Config{
+		Timeout: 5 * time.Second,
+		TTL:     336 * time.Hour,
+	}, env)
+	if err != nil {
+		t.Fatal(err)
+	}
+	handler := server.Routes(ctx)
+	handler.ServeHTTP(w, r)
+
+	if got, want := w.Code, http.StatusOK; got != want {
+		t.Errorf("expected %d to be %d: %s", got, want, w.Body.String())
+	}
+}
diff --git a/internal/cleanup/cleanup_exposure_test.go b/internal/cleanup/cleanup_exposure_test.go
new file mode 100644
index 0000000..ef01eae
--- /dev/null
+++ b/internal/cleanup/cleanup_exposure_test.go
@@ -0,0 +1,99 @@
+// Copyright 2021 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 cleanup
+
+import (
+	"fmt"
+	"net/http"
+	"net/http/httptest"
+	"testing"
+	"time"
+
+	"github.com/google/exposure-notifications-server/internal/project"
+	"github.com/google/exposure-notifications-server/internal/serverenv"
+)
+
+func TestNewExposureServer(t *testing.T) {
+	t.Parallel()
+
+	ctx := project.TestContext(t)
+	testDB, _ := testDatabaseInstance.NewDatabase(t)
+
+	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: "Fully Specified",
+			env:  serverenv.New(ctx, serverenv.WithDatabase(testDB)),
+			err:  nil,
+		},
+	}
+
+	for _, tc := range testCases {
+		tc := tc
+
+		t.Run(tc.name, func(t *testing.T) {
+			t.Parallel()
+
+			got, err := NewExposureServer(&Config{}, tc.env)
+			if tc.err != nil {
+				if err.Error() != tc.err.Error() {
+					t.Fatalf("got '%+v': want '%v'", err, tc.err)
+				}
+			} else if err != nil {
+				t.Fatalf("got unexpected error: %v", err)
+			} else {
+				if got.env != tc.env {
+					t.Fatalf("got %+v: want %v", got.env, tc.env)
+				}
+			}
+		})
+	}
+}
+
+func TestExposureHandler_ServeHTTP(t *testing.T) {
+	t.Parallel()
+
+	ctx := project.TestContext(t)
+	testDB, _ := testDatabaseInstance.NewDatabase(t)
+	env := serverenv.New(ctx, serverenv.WithDatabase(testDB))
+
+	r, err := http.NewRequestWithContext(ctx, http.MethodGet, "/", nil)
+	if err != nil {
+		t.Fatal(err)
+	}
+	w := httptest.NewRecorder()
+
+	server, err := NewExposureServer(&Config{
+		Timeout: 5 * time.Second,
+		TTL:     336 * time.Hour,
+	}, env)
+	if err != nil {
+		t.Fatal(err)
+	}
+	handler := server.Routes(ctx)
+	handler.ServeHTTP(w, r)
+
+	if got, want := w.Code, http.StatusOK; got != want {
+		t.Errorf("expected %d to be %d: %s", got, want, w.Body.String())
+	}
+}
diff --git a/internal/cleanup/cleanup_test.go b/internal/cleanup/cleanup_test.go
index d7123dd..d33ec8b 100644
--- a/internal/cleanup/cleanup_test.go
+++ b/internal/cleanup/cleanup_test.go
@@ -15,15 +15,9 @@
 package cleanup
 
 import (
-	"fmt"
-	"net/http"
-	"net/http/httptest"
 	"testing"
 	"time"
 
-	"github.com/google/exposure-notifications-server/internal/project"
-	"github.com/google/exposure-notifications-server/internal/serverenv"
-	"github.com/google/exposure-notifications-server/internal/storage"
 	"github.com/google/exposure-notifications-server/pkg/database"
 )
 
@@ -35,172 +29,6 @@ func TestMain(m *testing.M) {
 	m.Run()
 }
 
-func TestNewExposureHandler(t *testing.T) {
-	t.Parallel()
-
-	ctx := project.TestContext(t)
-	testDB, _ := testDatabaseInstance.NewDatabase(t)
-
-	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: "Fully Specified",
-			env:  serverenv.New(ctx, serverenv.WithDatabase(testDB)),
-			err:  nil,
-		},
-	}
-
-	for _, tc := range testCases {
-		tc := tc
-
-		t.Run(tc.name, func(t *testing.T) {
-			t.Parallel()
-
-			got, err := NewExposureHandler(&Config{}, tc.env)
-			if tc.err != nil {
-				if err.Error() != tc.err.Error() {
-					t.Fatalf("got '%+v': want '%v'", err, tc.err)
-				}
-			} else if err != nil {
-				t.Fatalf("got unexpected error: %v", err)
-			} else {
-				handler, ok := got.(*exposureCleanupHandler)
-				if !ok {
-					t.Fatalf("exposureCleanupHandler does not satisfy http.Handler interface")
-				} else if handler.env != tc.env {
-					t.Fatalf("got %+v: want %v", handler.env, tc.env)
-				}
-			}
-		})
-	}
-}
-
-func TestExposureHandler_ServeHTTP(t *testing.T) {
-	t.Parallel()
-
-	ctx := project.TestContext(t)
-	testDB, _ := testDatabaseInstance.NewDatabase(t)
-	env := serverenv.New(ctx, serverenv.WithDatabase(testDB))
-
-	r, err := http.NewRequestWithContext(ctx, http.MethodGet, "/", nil)
-	if err != nil {
-		t.Fatal(err)
-	}
-	w := httptest.NewRecorder()
-
-	handler, err := NewExposureHandler(&Config{
-		Timeout: 5 * time.Second,
-		TTL:     336 * time.Hour,
-	}, env)
-	if err != nil {
-		t.Fatal(err)
-	}
-	handler.ServeHTTP(w, r)
-
-	if got, want := w.Code, http.StatusOK; got != want {
-		t.Errorf("expected %d to be %d: %s", got, want, w.Body.String())
-	}
-}
-
-func TestNewExportHandler(t *testing.T) {
-	t.Parallel()
-
-	ctx := project.TestContext(t)
-	testDB, _ := testDatabaseInstance.NewDatabase(t)
-	noopBlobstore, _ := storage.NewMemory(ctx, &storage.Config{})
-
-	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(testDB)),
-			err:  fmt.Errorf("missing blobstore in server environment"),
-		},
-		{
-			name: "fully_specified",
-			env:  serverenv.New(ctx, serverenv.WithBlobStorage(noopBlobstore), serverenv.WithDatabase(testDB)),
-			err:  nil,
-		},
-	}
-
-	for _, tc := range testCases {
-		tc := tc
-
-		t.Run(tc.name, func(t *testing.T) {
-			t.Parallel()
-
-			got, err := NewExportHandler(&Config{}, tc.env)
-			if tc.err != nil {
-				if err.Error() != tc.err.Error() {
-					t.Fatalf("got %+v: want %v", err, tc.err)
-				}
-			} else if err != nil {
-				t.Fatalf("got unexpected error: %v", err)
-			} else {
-				handler, ok := got.(*exportCleanupHandler)
-				if !ok {
-					t.Fatal("exportCleanupHandler does not satisfy http.Handler interface")
-				} else if handler.env != tc.env {
-					t.Fatalf("got %+v: want %v", handler.env, tc.env)
-				}
-			}
-		})
-	}
-}
-
-func TestExportHandler_ServeHTTP(t *testing.T) {
-	t.Parallel()
-
-	ctx := project.TestContext(t)
-	testDB, _ := testDatabaseInstance.NewDatabase(t)
-
-	bs, err := storage.NewMemory(ctx, &storage.Config{
-		Type: "MEMORY",
-	})
-	if err != nil {
-		t.Fatal(err)
-	}
-
-	env := serverenv.New(ctx,
-		serverenv.WithDatabase(testDB),
-		serverenv.WithBlobStorage(bs))
-
-	r, err := http.NewRequestWithContext(ctx, http.MethodGet, "/", nil)
-	if err != nil {
-		t.Fatal(err)
-	}
-	w := httptest.NewRecorder()
-
-	handler, err := NewExportHandler(&Config{
-		Timeout: 5 * time.Second,
-		TTL:     336 * time.Hour,
-	}, env)
-	if err != nil {
-		t.Fatal(err)
-	}
-	handler.ServeHTTP(w, r)
-
-	if got, want := w.Code, http.StatusOK; got != want {
-		t.Errorf("expected %d to be %d: %s", got, want, w.Body.String())
-	}
-}
-
 func TestCutoffDate(t *testing.T) {
 	t.Parallel()
 
diff --git a/pkg/enkstest/server.go b/pkg/enkstest/server.go
index 7ac672a..e413301 100644
--- a/pkg/enkstest/server.go
+++ b/pkg/enkstest/server.go
@@ -113,11 +113,11 @@ func NewServer(tb testing.TB, testDatabaseInstance *database.TestInstance) *Serv
 		TTL:     336 * time.Hour,
 	}
 	processDefaults(tb, cleanupExportConfig)
-	cleanupExportHandler, err := cleanup.NewExportHandler(cleanupExportConfig, env)
+	cleanupExportServer, err := cleanup.NewExportServer(cleanupExportConfig, env)
 	if err != nil {
 		tb.Fatal(err)
 	}
-	r.Handle("/cleanup-export", cleanupExportHandler)
+	r.PathPrefix("/cleanup-export/").Handler(http.StripPrefix("/cleanup-export", cleanupExportServer.Routes(ctx)))
 
 	// cleanup-exposure
 	cleanupExposureConfig := &cleanup.Config{
@@ -125,11 +125,11 @@ func NewServer(tb testing.TB, testDatabaseInstance *database.TestInstance) *Serv
 		TTL:     336 * time.Hour,
 	}
 	processDefaults(tb, cleanupExposureConfig)
-	cleanupExposureHandler, err := cleanup.NewExposureHandler(cleanupExposureConfig, env)
+	cleanupExposureServer, err := cleanup.NewExposureServer(cleanupExposureConfig, env)
 	if err != nil {
 		tb.Fatal(err)
 	}
-	r.Handle("/cleanup-exposure", cleanupExposureHandler)
+	r.PathPrefix("/cleanup-exposure/").Handler(http.StripPrefix("/cleanup-exposure", cleanupExposureServer.Routes(ctx)))
 
 	// export
 	exportConfig := &export.Config{