Repository: google/exposure-notifications-server
Pull request #560 (2020-06-05 21:50:54)

Move to single cache impl.
* Move secret manager cache to internal/cache write through impl
* Start caching HealthAuthoirty configuration in verificaiton code

/fixes 555

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/560#" title="Author self-approved">mikehelmick</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/master/OWNERS)~~ [mikehelmick]

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/docs/.gitignore b/docs/.gitignore
new file mode 100644
index 0000000..eabac97
--- /dev/null
+++ b/docs/.gitignore
@@ -0,0 +1,5 @@
+_site
+.sass-cache
+.jekyll-cache/
+.jekyll-metadata
+.DS_Store
\ No newline at end of file
diff --git a/docs/Gemfile b/docs/Gemfile
new file mode 100644
index 0000000..72c0e38
--- /dev/null
+++ b/docs/Gemfile
@@ -0,0 +1,27 @@
+source "https://rubygems.org"
+
+# Hello! This is where you manage which Jekyll version is used to run.
+# When you want to use a different version, change it below, save the
+# file and run `bundle install`. Run Jekyll with `bundle exec`, like so:
+#
+#     bundle exec jekyll serve
+#
+# This will help ensure the proper Jekyll version is running.
+# Happy Jekylling!
+
+# This is the default theme for new Jekyll sites. You may change this to anything you like.
+# If you want to use GitHub Pages, remove the "gem "jekyll"" above and
+# uncomment the line below. To upgrade, run `bundle update github-pages`.
+gem "github-pages", group: :jekyll_plugins
+
+# If you have any plugins, put them here!
+group :jekyll_plugins do
+
+end
+
+# Windows does not include zoneinfo files, so bundle the tzinfo-data gem
+gem "tzinfo-data", platforms: [:mingw, :mswin, :x64_mingw, :jruby]
+
+# Performance-booster for watching directories on Windows
+gem "wdm", "~> 0.1.0" if Gem.win_platform?
+
diff --git a/docs/_config.yml b/docs/_config.yml
new file mode 100644
index 0000000..f58d78e
--- /dev/null
+++ b/docs/_config.yml
@@ -0,0 +1,3 @@
+title: Exposure Notifications Server
+theme: jekyll-theme-minimal
+  
\ No newline at end of file
diff --git a/docs/_data/navigation.yml b/docs/_data/navigation.yml
new file mode 100644
index 0000000..1708d2b
--- /dev/null
+++ b/docs/_data/navigation.yml
@@ -0,0 +1,21 @@
+# documentation links
+docs:
+    - title: Home
+      url: /
+
+    - title: Getting Started
+      children:
+        - title: How to Deploy
+          url: /deploying
+
+    - title: Design
+      children:
+        - title: Server Deployment Options
+          url: /server_deployment_options   
+        - title: Server Functional Requirements
+          url: /server_functional_requirements   
+        - title: "Verification Protocol Design"
+          url: /design/verification_protocol
+
+   
+    
\ No newline at end of file
diff --git a/docs/_layouts/default.html b/docs/_layouts/default.html
new file mode 100644
index 0000000..80bbda4
--- /dev/null
+++ b/docs/_layouts/default.html
@@ -0,0 +1,53 @@
+<!DOCTYPE html>
+<html lang="{{ site.lang | default: "en-US" }}">
+  <head>
+    <meta charset="UTF-8">
+    <meta http-equiv="X-UA-Compatible" content="IE=edge">
+    <meta name="viewport" content="width=device-width, initial-scale=1">
+
+{% seo %}
+    <link rel="stylesheet" href="{{ "/assets/css/style.css?v=" | append: site.github.build_revision | relative_url }}">
+    <!--[if lt IE 9]>
+    <script src="https://cdnjs.cloudflare.com/ajax/libs/html5shiv/3.7.3/html5shiv.min.js"></script>
+    <![endif]-->
+  </head>
+  <body>
+    <div class="wrapper">
+      <header>
+        <h1><a href="{{ "/" | absolute_url }}">{{ site.title | default: site.github.repository_name }}</a></h1>
+
+        <!-- Render Navigation -->
+        {% for item in site.data.navigation.docs %}
+            {% if item.url %}
+            <h4><a href="{{ item.url }}">{{ item.title }}</a></h4>
+            {% else %}
+            <h4>{{ item.title }}</h4>
+            {% endif %}
+
+            {% for child in item.children %}
+                <h5>&nbsp;&nbsp;&nbsp;&nbsp;<a href="{{ child.url }}">{{ child.title }}</a></h5>
+            {% endfor %}
+        {% endfor %}
+      </header>
+      <section>
+        
+
+      {{ content }}
+
+      </section>
+      <footer>
+      </footer>
+    </div>
+    <script src="{{ "/assets/js/scale.fix.js" | relative_url }}"></script>
+    {% if site.google_analytics %}
+    <script>
+      (function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){
+      (i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date();a=s.createElement(o),
+      m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m)
+      })(window,document,'script','https://www.google-analytics.com/analytics.js','ga');
+      ga('create', '{{ site.google_analytics }}', 'auto');
+      ga('send', 'pageview');
+    </script>
+    {% endif %}
+  </body>
+</html>
\ No newline at end of file
diff --git a/docs/deploying.md b/docs/deploying.md
index c8705fc..78e57ba 100644
--- a/docs/deploying.md
+++ b/docs/deploying.md
@@ -1,3 +1,6 @@
+---
+layout: default
+---
 <!-- TOC depthFrom:2 depthTo:6 orderedList:false updateOnSave:true withLinks:true -->
 
 - [Before you begin](#before-you-begin)
diff --git a/docs/design/verification_protocol.md b/docs/design/verification_protocol.md
index ba1acd7..4362732 100644
--- a/docs/design/verification_protocol.md
+++ b/docs/design/verification_protocol.md
@@ -1,3 +1,6 @@
+---
+layout: default
+---
 # Public Health Authority Diagnosis Verification Protocol
 
 This design covers the exposure notifications server's ability to verify
diff --git a/docs/index.md b/docs/index.md
index 77c8d20..8f532fe 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -1,3 +1,6 @@
+---
+layout: default
+---
 # Exposure Notification Reference Server
 
 [COVID‑19 Exposure Notifications API](https://www.google.com/covid19/exposurenotifications/)
diff --git a/docs/server_deployment_options.md b/docs/server_deployment_options.md
index bb1d997..933f0b2 100644
--- a/docs/server_deployment_options.md
+++ b/docs/server_deployment_options.md
@@ -1,3 +1,6 @@
+---
+layout: default
+---
 # Google Exposure Notification Server
 
 ## Server deployment options
diff --git a/docs/server_functional_requirements.md b/docs/server_functional_requirements.md
index 25f7cb8..acc7397 100644
--- a/docs/server_functional_requirements.md
+++ b/docs/server_functional_requirements.md
@@ -1,3 +1,7 @@
+---
+layout: default
+classes: wide
+---
 # Google Exposure Notification Server
 
 ## Functional requirements
diff --git a/internal/admin/siginfo/form.go b/internal/admin/siginfo/form.go
index 9508291..efeb0c7 100644
--- a/internal/admin/siginfo/form.go
+++ b/internal/admin/siginfo/form.go
@@ -27,8 +27,6 @@ type formData struct {
 	EndTime           string `form:"endtime"`
 	SigningKeyID      string `form:"SigningKeyID"`
 	SigningKeyVersion string `form:"SigningKeyVersion"`
-	AppPackageName    string `form:"AppPackageName"`
-	BundleID          string `form:"BundleID"`
 }
 
 func (f *formData) EndTimestamp() (time.Time, error) {
@@ -42,8 +40,6 @@ func (f *formData) PopulateSigInfo(si *model.SignatureInfo) error {
 	}
 
 	si.SigningKey = f.SigningKey
-	si.AppPackageName = f.AppPackageName
-	si.BundleID = f.BundleID
 	si.SigningKeyVersion = f.SigningKeyVersion
 	si.SigningKeyID = f.SigningKeyID
 	si.EndTimestamp = ts
diff --git a/internal/authorizedapp/database_provider.go b/internal/authorizedapp/database_provider.go
index 185ed1d..752905d 100644
--- a/internal/authorizedapp/database_provider.go
+++ b/internal/authorizedapp/database_provider.go
@@ -122,3 +122,8 @@ func (p *DatabaseProvider) loadAuthorizedAppFromDatabase(ctx context.Context, na
 	}
 	return config, nil
 }
+
+// Add adds a new authorized app to the system.
+func (p *DatabaseProvider) Add(ctx context.Context, app *model.AuthorizedApp) error {
+	return authorizedappdb.New(p.database).InsertAuthorizedApp(ctx, app)
+}
diff --git a/internal/authorizedapp/memory_provider.go b/internal/authorizedapp/memory_provider.go
index cd11d2e..b6d6ff7 100644
--- a/internal/authorizedapp/memory_provider.go
+++ b/internal/authorizedapp/memory_provider.go
@@ -16,7 +16,9 @@ package authorizedapp
 
 import (
 	"context"
+	"fmt"
 	"strings"
+	"sync"
 
 	"github.com/google/exposure-notifications-server/internal/authorizedapp/model"
 )
@@ -27,26 +29,44 @@ var _ Provider = (*MemoryProvider)(nil)
 // MemoryProvider is an Provider that stores values in-memory. It is primarily
 // used for testing.
 type MemoryProvider struct {
-	Data map[string]*model.AuthorizedApp
+	lock sync.RWMutex
+	data map[string]*model.AuthorizedApp
 }
 
-// NewMemoryProvider creates a new Provider that reads from a database.
-func NewMemoryProvider(ctx context.Context, _ *Config) (Provider, error) {
+// NewMemoryProvider creates a new Provider that is in memory.
+func NewMemoryProvider(_ context.Context, _ *Config) (Provider, error) {
 	provider := &MemoryProvider{
-		Data: make(map[string]*model.AuthorizedApp),
+		data: make(map[string]*model.AuthorizedApp),
 	}
-
 	return provider, nil
 }
 
 // AppConfig returns the config for the given app package name.
-func (p *MemoryProvider) AppConfig(ctx context.Context, name string) (*model.AuthorizedApp, error) {
+func (p *MemoryProvider) AppConfig(_ context.Context, name string) (*model.AuthorizedApp, error) {
+	p.lock.RLock()
+	defer p.lock.RUnlock()
+
 	// Match case-insensitivy of the database.
 	name = strings.ToLower(name)
 
-	val, ok := p.Data[name]
+	val, ok := p.data[name]
 	if !ok {
 		return nil, ErrAppNotFound
 	}
 	return val, nil
 }
+
+// Add inserts the app. It returns an error if the app already exists.
+func (p *MemoryProvider) Add(_ context.Context, app *model.AuthorizedApp) error {
+	p.lock.Lock()
+	defer p.lock.Unlock()
+
+	name := strings.ToLower(app.AppPackageName)
+
+	if _, ok := p.data[name]; ok {
+		return fmt.Errorf("%v already exists", name)
+	}
+
+	p.data[name] = app
+	return nil
+}
diff --git a/internal/authorizedapp/provider.go b/internal/authorizedapp/provider.go
index 8706585..dcf1c89 100644
--- a/internal/authorizedapp/provider.go
+++ b/internal/authorizedapp/provider.go
@@ -33,5 +33,8 @@ type Provider interface {
 	//
 	// The name field is case-insensitive. Implementers should adjust accordingly
 	// to handle mixed case. com.MyApp is the same as com.myapp.
-	AppConfig(ctx context.Context, name string) (*model.AuthorizedApp, error)
+	AppConfig(context.Context, string) (*model.AuthorizedApp, error)
+
+	// Add inserts a model into the provider.
+	Add(context.Context, *model.AuthorizedApp) error
 }
diff --git a/internal/cache/cache.go b/internal/cache/cache.go
index 976f951..f1d8819 100644
--- a/internal/cache/cache.go
+++ b/internal/cache/cache.go
@@ -86,7 +86,7 @@ func (c *Cache) WriteThruLookup(name string, primaryLookup Func) (interface{}, e
 	defer c.mu.Unlock()
 	// double check that the value hasn't been set by another goroutine.
 	if val, hit := c.data[name]; hit && !val.expired() {
-		return val, nil
+		return val.object, nil
 	}
 	// Either a miss, or hit w/ expired value.
 
diff --git a/internal/database/database_util.go b/internal/database/database_util.go
index a977ec0..5e9488f 100644
--- a/internal/database/database_util.go
+++ b/internal/database/database_util.go
@@ -39,9 +39,9 @@ import (
 	// imported to register the "postgres" database driver for migrate
 )
 
-// NewTestDatabase creates a new database suitable for use in testing. This
-// should not be used outside of testing, but it is exposed in the main package
-// so it can be shared with other packages.
+// NewTestDatabaseWithConfig creates a new database suitable for use in testing.
+// This should not be used outside of testing, but it is exposed in the main
+// package so it can be shared with other packages.
 //
 // All database tests can be skipped by running `go test -short` or by setting
 // the `SKIP_DATABASE_TESTS` environment variable.
@@ -105,6 +105,10 @@ func NewTestDatabaseWithConfig(tb testing.TB) (*DB, *Config) {
 	q.Add("sslmode", "disable")
 	connURL.RawQuery = q.Encode()
 
+	// Wait for the container to start - we'll retry connections in a loop below,
+	// but there's no point in trying immediately.
+	time.Sleep(1 * time.Second)
+
 	// Establish a connection to the database. Use a Fibonacci backoff instead of
 	// exponential so wait times scale appropriately.
 	var dbpool *pgxpool.Pool
diff --git a/internal/export/database/export.go b/internal/export/database/export.go
index c66bf0b..0e16ba6 100644
--- a/internal/export/database/export.go
+++ b/internal/export/database/export.go
@@ -207,11 +207,11 @@ func (db *ExportDB) AddSignatureInfo(ctx context.Context, si *model.SignatureInf
 		row := tx.QueryRow(ctx, `
 			INSERT INTO
  				SignatureInfo
-				(signing_key, app_package_name, bundle_id, signing_key_version, signing_key_id, thru_timestamp)
+				(signing_key, signing_key_version, signing_key_id, thru_timestamp)
 			VALUES
-				($1, LOWER($2), LOWER($3), $4, $5, $6)
+				($1, $2, $3, $4)
 			RETURNING id
-			`, si.SigningKey, si.AppPackageName, si.BundleID, si.SigningKeyVersion, si.SigningKeyID, thru)
+			`, si.SigningKey, si.SigningKeyVersion, si.SigningKeyID, thru)
 
 		if err := row.Scan(&si.ID); err != nil {
 			return fmt.Errorf("fetching id: %w", err)
@@ -229,11 +229,13 @@ func (db *ExportDB) UpdateSignatureInfo(ctx context.Context, si *model.Signature
 		result, err := tx.Exec(ctx, `
 			UPDATE SignatureInfo
 			SET
-				signing_key = $1, app_package_name = LOWER($2), bundle_id = LOWER($3),
-				signing_key_version = $4, signing_key_id = $5, thru_timestamp = $6
+				signing_key = $1,
+				signing_key_version = $2,
+				signing_key_id = $3,
+				thru_timestamp = $4
 			WHERE
-				id = $7
- 			`, si.SigningKey, si.AppPackageName, si.BundleID, si.SigningKeyVersion, si.SigningKeyID, thru, si.ID)
+				id = $5
+ 			`, si.SigningKey, si.SigningKeyVersion, si.SigningKeyID, thru, si.ID)
 		if err != nil {
 			return fmt.Errorf("updating signatureinfo: %w", err)
 		}
@@ -253,10 +255,10 @@ func (db *ExportDB) ListAllSigntureInfos(ctx context.Context) ([]*model.Signatur
 
 	rows, err := conn.Query(ctx, `
     SELECT
-      id, signing_key, LOWER(app_package_name), LOWER(bundle_id), signing_key_version, signing_key_id, thru_timestamp
+      id, signing_key, signing_key_version, signing_key_id, thru_timestamp
     FROM
       SignatureInfo
-    ORDER BY signing_key_id ASC, signing_key_version ASC, thru_timestamp DESC, LOWER(app_package_name) ASC, LOWER(bundle_id) ASC
+    ORDER BY signing_key_id ASC, signing_key_version ASC, thru_timestamp DESC
   `)
 	if err != nil {
 		return nil, err
@@ -286,7 +288,7 @@ func (db *ExportDB) LookupSignatureInfos(ctx context.Context, ids []int64, valid
 
 	rows, err := conn.Query(ctx, `
     SELECT
-      id, signing_key, LOWER(app_package_name), LOWER(bundle_id), signing_key_version, signing_key_id, thru_timestamp
+      id, signing_key, signing_key_version, signing_key_id, thru_timestamp
     FROM
       SignatureInfo
     WHERE
@@ -321,7 +323,7 @@ func (db *ExportDB) GetSignatureInfo(ctx context.Context, id int64) (*model.Sign
 
 	row := conn.QueryRow(ctx, `
 		SELECT
-			id, signing_key, LOWER(app_package_name), LOWER(bundle_id), signing_key_version, signing_key_id, thru_timestamp
+			id, signing_key, signing_key_version, signing_key_id, thru_timestamp
 		FROM
 			SignatureInfo
 		WHERE
@@ -334,7 +336,7 @@ func (db *ExportDB) GetSignatureInfo(ctx context.Context, id int64) (*model.Sign
 func scanOneSignatureInfo(row pgx.Row) (*model.SignatureInfo, error) {
 	var info model.SignatureInfo
 	var thru *time.Time
-	if err := row.Scan(&info.ID, &info.SigningKey, &info.AppPackageName, &info.BundleID, &info.SigningKeyVersion, &info.SigningKeyID, &thru); err != nil {
+	if err := row.Scan(&info.ID, &info.SigningKey, &info.SigningKeyVersion, &info.SigningKeyID, &thru); err != nil {
 		return nil, err
 	}
 	if thru != nil {
@@ -401,7 +403,7 @@ func (db *ExportDB) AddExportBatches(ctx context.Context, batches []*model.Expor
 }
 
 // LeaseBatch returns a leased ExportBatch for the worker to process. If no work to do, nil will be returned.
-func (db *ExportDB) LeaseBatch(ctx context.Context, ttl time.Duration, now time.Time) (*model.ExportBatch, error) {
+func (db *ExportDB) LeaseBatch(ctx context.Context, ttl time.Duration, batchMaxCloseTime time.Time) (*model.ExportBatch, error) {
 	// Lookup a set of candidate batch IDs.
 	var openBatchIDs []int64
 	err := func() error { // Use a func to allow defer conn.Release() to work.
@@ -427,7 +429,7 @@ func (db *ExportDB) LeaseBatch(ctx context.Context, ttl time.Duration, now time.
 			AND
 				end_timestamp < $3
 			LIMIT 100
-		`, model.ExportBatchOpen, model.ExportBatchPending, now)
+		`, model.ExportBatchOpen, model.ExportBatchPending, batchMaxCloseTime)
 		if err != nil {
 			return err
 		}
@@ -475,7 +477,7 @@ func (db *ExportDB) LeaseBatch(ctx context.Context, ttl time.Duration, now time.
 				return err
 			}
 
-			if status == model.ExportBatchComplete || (expires != nil && status == model.ExportBatchPending && now.Before(*expires)) {
+			if status == model.ExportBatchComplete || (expires != nil && status == model.ExportBatchPending && batchMaxCloseTime.Before(*expires)) {
 				// Something beat us to this batch, it's no longer available.
 				return nil
 			}
@@ -487,7 +489,7 @@ func (db *ExportDB) LeaseBatch(ctx context.Context, ttl time.Duration, now time.
 					status = $1, lease_expires = $2
 				WHERE
 				    batch_id = $3
-				`, model.ExportBatchPending, now.Add(ttl), bid)
+				`, model.ExportBatchPending, batchMaxCloseTime.Add(ttl), bid)
 			if err != nil {
 				return err
 			}
diff --git a/internal/export/exportfile.go b/internal/export/exportfile.go
index b217773..fa1af85 100644
--- a/internal/export/exportfile.go
+++ b/internal/export/exportfile.go
@@ -180,12 +180,6 @@ func marshalContents(eb *model.ExportBatch, exposures []*publishmodel.Exposure,
 
 func createSignatureInfo(si *model.SignatureInfo) *export.SignatureInfo {
 	sigInfo := &export.SignatureInfo{SignatureAlgorithm: proto.String(algorithm)}
-	if si.AppPackageName != "" {
-		sigInfo.AndroidPackage = proto.String(si.AppPackageName)
-	}
-	if si.BundleID != "" {
-		sigInfo.AppBundleId = proto.String(si.BundleID)
-	}
 	if si.SigningKeyVersion != "" {
 		sigInfo.VerificationKeyVersion = proto.String(si.SigningKeyVersion)
 	}
diff --git a/internal/export/model/export_model.go b/internal/export/model/export_model.go
index ba2e01c..337a12a 100644
--- a/internal/export/model/export_model.go
+++ b/internal/export/model/export_model.go
@@ -133,8 +133,6 @@ func (ef *ExportFile) EffectiveInputRegions() []string {
 type SignatureInfo struct {
 	ID                int64     `db:"id"`
 	SigningKey        string    `db:"signing_key"`
-	AppPackageName    string    `db:"app_package_name"`
-	BundleID          string    `db:"bundle_id"`
 	SigningKeyVersion string    `db:"signing_key_version"`
 	SigningKeyID      string    `db:"signing_key_id"`
 	EndTimestamp      time.Time `db:"thru_timestamp"`
diff --git a/internal/export/worker.go b/internal/export/worker.go
index 22663fa..87c1966 100644
--- a/internal/export/worker.go
+++ b/internal/export/worker.go
@@ -60,12 +60,8 @@ func (s *Server) WorkerHandler(w http.ResponseWriter, r *http.Request) {
 			return
 		}
 
-		// Only consider batches that closed a few minutes ago to allow the publish
-		// windows to close properly.
-		minutesAgo := time.Now().Add(-5 * time.Minute)
-
 		// Check for a batch and obtain a lease for it.
-		batch, err := exportDB.LeaseBatch(ctx, s.config.WorkerTimeout, minutesAgo)
+		batch, err := exportDB.LeaseBatch(ctx, s.config.WorkerTimeout, time.Now())
 		if err != nil {
 			logger.Errorf("Failed to lease batch: %v", err)
 			continue
diff --git a/internal/pb/export/export.pb.go b/internal/pb/export/export.pb.go
index 376977b..77823a4 100644
--- a/internal/pb/export/export.pb.go
+++ b/internal/pb/export/export.pb.go
@@ -155,11 +155,6 @@ type SignatureInfo struct {
 	sizeCache     protoimpl.SizeCache
 	unknownFields protoimpl.UnknownFields
 
-	// Apple App Store Application Bundle ID
-	AppBundleId *string `protobuf:"bytes,1,opt,name=app_bundle_id,json=appBundleId" json:"app_bundle_id,omitempty"`
-	// Android App package name
-	// Don’t set this if exports are to be consumed by multiple apps.
-	AndroidPackage *string `protobuf:"bytes,2,opt,name=android_package,json=androidPackage" json:"android_package,omitempty"`
 	// Key version for rollovers
 	// Must be in character class [a-zA-Z0-9_]. E.g., 'v1'
 	VerificationKeyVersion *string `protobuf:"bytes,3,opt,name=verification_key_version,json=verificationKeyVersion" json:"verification_key_version,omitempty"`
@@ -204,20 +199,6 @@ func (*SignatureInfo) Descriptor() ([]byte, []int) {
 	return file_internal_pb_export_export_proto_rawDescGZIP(), []int{1}
 }
 
-func (x *SignatureInfo) GetAppBundleId() string {
-	if x != nil && x.AppBundleId != nil {
-		return *x.AppBundleId
-	}
-	return ""
-}
-
-func (x *SignatureInfo) GetAndroidPackage() string {
-	if x != nil && x.AndroidPackage != nil {
-		return *x.AndroidPackage
-	}
-	return ""
-}
-
 func (x *SignatureInfo) GetVerificationKeyVersion() string {
 	if x != nil && x.VerificationKeyVersion != nil {
 		return *x.VerificationKeyVersion
@@ -463,55 +444,53 @@ var file_internal_pb_export_export_proto_rawDesc = []byte{
 	0x6e, 0x61, 0x74, 0x75, 0x72, 0x65, 0x49, 0x6e, 0x66, 0x6f, 0x73, 0x12, 0x29, 0x0a, 0x04, 0x6b,
 	0x65, 0x79, 0x73, 0x18, 0x07, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x54, 0x65, 0x6d, 0x70,
 	0x6f, 0x72, 0x61, 0x72, 0x79, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x75, 0x72, 0x65, 0x4b, 0x65, 0x79,
-	0x52, 0x04, 0x6b, 0x65, 0x79, 0x73, 0x22, 0xf7, 0x01, 0x0a, 0x0d, 0x53, 0x69, 0x67, 0x6e, 0x61,
-	0x74, 0x75, 0x72, 0x65, 0x49, 0x6e, 0x66, 0x6f, 0x12, 0x22, 0x0a, 0x0d, 0x61, 0x70, 0x70, 0x5f,
-	0x62, 0x75, 0x6e, 0x64, 0x6c, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52,
-	0x0b, 0x61, 0x70, 0x70, 0x42, 0x75, 0x6e, 0x64, 0x6c, 0x65, 0x49, 0x64, 0x12, 0x27, 0x0a, 0x0f,
-	0x61, 0x6e, 0x64, 0x72, 0x6f, 0x69, 0x64, 0x5f, 0x70, 0x61, 0x63, 0x6b, 0x61, 0x67, 0x65, 0x18,
-	0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x61, 0x6e, 0x64, 0x72, 0x6f, 0x69, 0x64, 0x50, 0x61,
-	0x63, 0x6b, 0x61, 0x67, 0x65, 0x12, 0x38, 0x0a, 0x18, 0x76, 0x65, 0x72, 0x69, 0x66, 0x69, 0x63,
-	0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x6b, 0x65, 0x79, 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f,
-	0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x16, 0x76, 0x65, 0x72, 0x69, 0x66, 0x69, 0x63,
-	0x61, 0x74, 0x69, 0x6f, 0x6e, 0x4b, 0x65, 0x79, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12,
-	0x2e, 0x0a, 0x13, 0x76, 0x65, 0x72, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f,
-	0x6b, 0x65, 0x79, 0x5f, 0x69, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x11, 0x76, 0x65,
-	0x72, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x4b, 0x65, 0x79, 0x49, 0x64, 0x12,
-	0x2f, 0x0a, 0x13, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x74, 0x75, 0x72, 0x65, 0x5f, 0x61, 0x6c, 0x67,
-	0x6f, 0x72, 0x69, 0x74, 0x68, 0x6d, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x12, 0x73, 0x69,
-	0x67, 0x6e, 0x61, 0x74, 0x75, 0x72, 0x65, 0x41, 0x6c, 0x67, 0x6f, 0x72, 0x69, 0x74, 0x68, 0x6d,
-	0x22, 0xd8, 0x01, 0x0a, 0x14, 0x54, 0x65, 0x6d, 0x70, 0x6f, 0x72, 0x61, 0x72, 0x79, 0x45, 0x78,
-	0x70, 0x6f, 0x73, 0x75, 0x72, 0x65, 0x4b, 0x65, 0x79, 0x12, 0x19, 0x0a, 0x08, 0x6b, 0x65, 0x79,
-	0x5f, 0x64, 0x61, 0x74, 0x61, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x07, 0x6b, 0x65, 0x79,
-	0x44, 0x61, 0x74, 0x61, 0x12, 0x36, 0x0a, 0x17, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x6d, 0x69, 0x73,
-	0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x72, 0x69, 0x73, 0x6b, 0x5f, 0x6c, 0x65, 0x76, 0x65, 0x6c, 0x18,
-	0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x15, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x6d, 0x69, 0x73, 0x73,
-	0x69, 0x6f, 0x6e, 0x52, 0x69, 0x73, 0x6b, 0x4c, 0x65, 0x76, 0x65, 0x6c, 0x12, 0x41, 0x0a, 0x1d,
-	0x72, 0x6f, 0x6c, 0x6c, 0x69, 0x6e, 0x67, 0x5f, 0x73, 0x74, 0x61, 0x72, 0x74, 0x5f, 0x69, 0x6e,
-	0x74, 0x65, 0x72, 0x76, 0x61, 0x6c, 0x5f, 0x6e, 0x75, 0x6d, 0x62, 0x65, 0x72, 0x18, 0x03, 0x20,
-	0x01, 0x28, 0x05, 0x52, 0x1a, 0x72, 0x6f, 0x6c, 0x6c, 0x69, 0x6e, 0x67, 0x53, 0x74, 0x61, 0x72,
-	0x74, 0x49, 0x6e, 0x74, 0x65, 0x72, 0x76, 0x61, 0x6c, 0x4e, 0x75, 0x6d, 0x62, 0x65, 0x72, 0x12,
-	0x2a, 0x0a, 0x0e, 0x72, 0x6f, 0x6c, 0x6c, 0x69, 0x6e, 0x67, 0x5f, 0x70, 0x65, 0x72, 0x69, 0x6f,
-	0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x05, 0x3a, 0x03, 0x31, 0x34, 0x34, 0x52, 0x0d, 0x72, 0x6f,
-	0x6c, 0x6c, 0x69, 0x6e, 0x67, 0x50, 0x65, 0x72, 0x69, 0x6f, 0x64, 0x22, 0x41, 0x0a, 0x10, 0x54,
-	0x45, 0x4b, 0x53, 0x69, 0x67, 0x6e, 0x61, 0x74, 0x75, 0x72, 0x65, 0x4c, 0x69, 0x73, 0x74, 0x12,
-	0x2d, 0x0a, 0x0a, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x74, 0x75, 0x72, 0x65, 0x73, 0x18, 0x01, 0x20,
-	0x03, 0x28, 0x0b, 0x32, 0x0d, 0x2e, 0x54, 0x45, 0x4b, 0x53, 0x69, 0x67, 0x6e, 0x61, 0x74, 0x75,
-	0x72, 0x65, 0x52, 0x0a, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x74, 0x75, 0x72, 0x65, 0x73, 0x22, 0x9f,
-	0x01, 0x0a, 0x0c, 0x54, 0x45, 0x4b, 0x53, 0x69, 0x67, 0x6e, 0x61, 0x74, 0x75, 0x72, 0x65, 0x12,
-	0x35, 0x0a, 0x0e, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x74, 0x75, 0x72, 0x65, 0x5f, 0x69, 0x6e, 0x66,
-	0x6f, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0e, 0x2e, 0x53, 0x69, 0x67, 0x6e, 0x61, 0x74,
-	0x75, 0x72, 0x65, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x0d, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x74, 0x75,
-	0x72, 0x65, 0x49, 0x6e, 0x66, 0x6f, 0x12, 0x1b, 0x0a, 0x09, 0x62, 0x61, 0x74, 0x63, 0x68, 0x5f,
-	0x6e, 0x75, 0x6d, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x08, 0x62, 0x61, 0x74, 0x63, 0x68,
-	0x4e, 0x75, 0x6d, 0x12, 0x1d, 0x0a, 0x0a, 0x62, 0x61, 0x74, 0x63, 0x68, 0x5f, 0x73, 0x69, 0x7a,
-	0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x05, 0x52, 0x09, 0x62, 0x61, 0x74, 0x63, 0x68, 0x53, 0x69,
-	0x7a, 0x65, 0x12, 0x1c, 0x0a, 0x09, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x74, 0x75, 0x72, 0x65, 0x18,
-	0x04, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x09, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x74, 0x75, 0x72, 0x65,
-	0x42, 0x4b, 0x5a, 0x49, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x67,
-	0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x65, 0x78, 0x70, 0x6f, 0x73, 0x75, 0x72, 0x65, 0x2d, 0x6e,
-	0x6f, 0x74, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x2d, 0x73, 0x65, 0x72,
-	0x76, 0x65, 0x72, 0x2f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2f, 0x70, 0x62, 0x2f,
-	0x65, 0x78, 0x70, 0x6f, 0x72, 0x74, 0x3b, 0x65, 0x78, 0x70, 0x6f, 0x72, 0x74,
+	0x52, 0x04, 0x6b, 0x65, 0x79, 0x73, 0x22, 0xd6, 0x01, 0x0a, 0x0d, 0x53, 0x69, 0x67, 0x6e, 0x61,
+	0x74, 0x75, 0x72, 0x65, 0x49, 0x6e, 0x66, 0x6f, 0x12, 0x38, 0x0a, 0x18, 0x76, 0x65, 0x72, 0x69,
+	0x66, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x6b, 0x65, 0x79, 0x5f, 0x76, 0x65, 0x72,
+	0x73, 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x16, 0x76, 0x65, 0x72, 0x69,
+	0x66, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x4b, 0x65, 0x79, 0x56, 0x65, 0x72, 0x73, 0x69,
+	0x6f, 0x6e, 0x12, 0x2e, 0x0a, 0x13, 0x76, 0x65, 0x72, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x69,
+	0x6f, 0x6e, 0x5f, 0x6b, 0x65, 0x79, 0x5f, 0x69, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52,
+	0x11, 0x76, 0x65, 0x72, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x4b, 0x65, 0x79,
+	0x49, 0x64, 0x12, 0x2f, 0x0a, 0x13, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x74, 0x75, 0x72, 0x65, 0x5f,
+	0x61, 0x6c, 0x67, 0x6f, 0x72, 0x69, 0x74, 0x68, 0x6d, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52,
+	0x12, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x74, 0x75, 0x72, 0x65, 0x41, 0x6c, 0x67, 0x6f, 0x72, 0x69,
+	0x74, 0x68, 0x6d, 0x4a, 0x04, 0x08, 0x01, 0x10, 0x02, 0x4a, 0x04, 0x08, 0x02, 0x10, 0x03, 0x52,
+	0x0d, 0x61, 0x70, 0x70, 0x5f, 0x62, 0x75, 0x6e, 0x64, 0x6c, 0x65, 0x5f, 0x69, 0x64, 0x52, 0x0f,
+	0x61, 0x6e, 0x64, 0x72, 0x6f, 0x69, 0x64, 0x5f, 0x70, 0x61, 0x63, 0x6b, 0x61, 0x67, 0x65, 0x22,
+	0xd8, 0x01, 0x0a, 0x14, 0x54, 0x65, 0x6d, 0x70, 0x6f, 0x72, 0x61, 0x72, 0x79, 0x45, 0x78, 0x70,
+	0x6f, 0x73, 0x75, 0x72, 0x65, 0x4b, 0x65, 0x79, 0x12, 0x19, 0x0a, 0x08, 0x6b, 0x65, 0x79, 0x5f,
+	0x64, 0x61, 0x74, 0x61, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x07, 0x6b, 0x65, 0x79, 0x44,
+	0x61, 0x74, 0x61, 0x12, 0x36, 0x0a, 0x17, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x6d, 0x69, 0x73, 0x73,
+	0x69, 0x6f, 0x6e, 0x5f, 0x72, 0x69, 0x73, 0x6b, 0x5f, 0x6c, 0x65, 0x76, 0x65, 0x6c, 0x18, 0x02,
+	0x20, 0x01, 0x28, 0x05, 0x52, 0x15, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x6d, 0x69, 0x73, 0x73, 0x69,
+	0x6f, 0x6e, 0x52, 0x69, 0x73, 0x6b, 0x4c, 0x65, 0x76, 0x65, 0x6c, 0x12, 0x41, 0x0a, 0x1d, 0x72,
+	0x6f, 0x6c, 0x6c, 0x69, 0x6e, 0x67, 0x5f, 0x73, 0x74, 0x61, 0x72, 0x74, 0x5f, 0x69, 0x6e, 0x74,
+	0x65, 0x72, 0x76, 0x61, 0x6c, 0x5f, 0x6e, 0x75, 0x6d, 0x62, 0x65, 0x72, 0x18, 0x03, 0x20, 0x01,
+	0x28, 0x05, 0x52, 0x1a, 0x72, 0x6f, 0x6c, 0x6c, 0x69, 0x6e, 0x67, 0x53, 0x74, 0x61, 0x72, 0x74,
+	0x49, 0x6e, 0x74, 0x65, 0x72, 0x76, 0x61, 0x6c, 0x4e, 0x75, 0x6d, 0x62, 0x65, 0x72, 0x12, 0x2a,
+	0x0a, 0x0e, 0x72, 0x6f, 0x6c, 0x6c, 0x69, 0x6e, 0x67, 0x5f, 0x70, 0x65, 0x72, 0x69, 0x6f, 0x64,
+	0x18, 0x04, 0x20, 0x01, 0x28, 0x05, 0x3a, 0x03, 0x31, 0x34, 0x34, 0x52, 0x0d, 0x72, 0x6f, 0x6c,
+	0x6c, 0x69, 0x6e, 0x67, 0x50, 0x65, 0x72, 0x69, 0x6f, 0x64, 0x22, 0x41, 0x0a, 0x10, 0x54, 0x45,
+	0x4b, 0x53, 0x69, 0x67, 0x6e, 0x61, 0x74, 0x75, 0x72, 0x65, 0x4c, 0x69, 0x73, 0x74, 0x12, 0x2d,
+	0x0a, 0x0a, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x74, 0x75, 0x72, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03,
+	0x28, 0x0b, 0x32, 0x0d, 0x2e, 0x54, 0x45, 0x4b, 0x53, 0x69, 0x67, 0x6e, 0x61, 0x74, 0x75, 0x72,
+	0x65, 0x52, 0x0a, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x74, 0x75, 0x72, 0x65, 0x73, 0x22, 0x9f, 0x01,
+	0x0a, 0x0c, 0x54, 0x45, 0x4b, 0x53, 0x69, 0x67, 0x6e, 0x61, 0x74, 0x75, 0x72, 0x65, 0x12, 0x35,
+	0x0a, 0x0e, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x74, 0x75, 0x72, 0x65, 0x5f, 0x69, 0x6e, 0x66, 0x6f,
+	0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0e, 0x2e, 0x53, 0x69, 0x67, 0x6e, 0x61, 0x74, 0x75,
+	0x72, 0x65, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x0d, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x74, 0x75, 0x72,
+	0x65, 0x49, 0x6e, 0x66, 0x6f, 0x12, 0x1b, 0x0a, 0x09, 0x62, 0x61, 0x74, 0x63, 0x68, 0x5f, 0x6e,
+	0x75, 0x6d, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x08, 0x62, 0x61, 0x74, 0x63, 0x68, 0x4e,
+	0x75, 0x6d, 0x12, 0x1d, 0x0a, 0x0a, 0x62, 0x61, 0x74, 0x63, 0x68, 0x5f, 0x73, 0x69, 0x7a, 0x65,
+	0x18, 0x03, 0x20, 0x01, 0x28, 0x05, 0x52, 0x09, 0x62, 0x61, 0x74, 0x63, 0x68, 0x53, 0x69, 0x7a,
+	0x65, 0x12, 0x1c, 0x0a, 0x09, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x74, 0x75, 0x72, 0x65, 0x18, 0x04,
+	0x20, 0x01, 0x28, 0x0c, 0x52, 0x09, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x74, 0x75, 0x72, 0x65, 0x42,
+	0x4b, 0x5a, 0x49, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x67, 0x6f,
+	0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x65, 0x78, 0x70, 0x6f, 0x73, 0x75, 0x72, 0x65, 0x2d, 0x6e, 0x6f,
+	0x74, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x2d, 0x73, 0x65, 0x72, 0x76,
+	0x65, 0x72, 0x2f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2f, 0x70, 0x62, 0x2f, 0x65,
+	0x78, 0x70, 0x6f, 0x72, 0x74, 0x3b, 0x65, 0x78, 0x70, 0x6f, 0x72, 0x74,
 }
 
 var (
diff --git a/internal/pb/export/export.proto b/internal/pb/export/export.proto
index 824bd3d..46bcfa2 100644
--- a/internal/pb/export/export.proto
+++ b/internal/pb/export/export.proto
@@ -46,11 +46,10 @@ message TemporaryExposureKeyExport {
 }
 
 message SignatureInfo {
-  // Apple App Store Application Bundle ID
-  optional string app_bundle_id = 1;
-  // Android App package name
-  // Don’t set this if exports are to be consumed by multiple apps.
-  optional string android_package = 2;
+  // No longer need to set these. Old code that still does will be fine
+  // as these were largely noops anyway.
+  reserved 1, 2;
+  reserved "app_bundle_id", "android_package";
   // Key version for rollovers
   // Must be in character class [a-zA-Z0-9_]. E.g., 'v1'
   optional string verification_key_version = 3;
diff --git a/internal/publish/config.go b/internal/publish/config.go
index 9777c27..9cf0257 100644
--- a/internal/publish/config.go
+++ b/internal/publish/config.go
@@ -22,6 +22,7 @@ import (
 	"github.com/google/exposure-notifications-server/internal/database"
 	"github.com/google/exposure-notifications-server/internal/secrets"
 	"github.com/google/exposure-notifications-server/internal/setup"
+	"github.com/google/exposure-notifications-server/internal/verification"
 )
 
 // Compile-time check to assert this config matches requirements.
@@ -35,6 +36,7 @@ type Config struct {
 	AuthorizedApp authorizedapp.Config
 	Database      database.Config
 	SecretManager secrets.Config
+	Verification  verification.Config
 
 	Port               string        `env:"PORT, default=8080"`
 	MinRequestDuration time.Duration `env:"TARGET_REQUEST_DURATION, default=5s"`
diff --git a/internal/publish/publish.go b/internal/publish/publish.go
index ab855cf..ab585ff 100644
--- a/internal/publish/publish.go
+++ b/internal/publish/publish.go
@@ -54,13 +54,18 @@ func NewHandler(ctx context.Context, config *Config, env *serverenv.ServerEnv) (
 	logger.Infof("max interval start age: %v", config.MaxIntervalAge)
 	logger.Infof("truncate window: %v", config.TruncateWindow)
 
+	verifier, err := verification.New(verifydb.New(env.Database()), &config.Verification)
+	if err != nil {
+		return nil, fmt.Errorf("verification.New: %w", err)
+	}
+
 	return &publishHandler{
 		serverenv:             env,
 		transformer:           transformer,
 		config:                config,
 		database:              database.New(env.Database()),
 		authorizedAppProvider: env.AuthorizedAppProvider(),
-		verifier:              verification.New(verifydb.New(env.Database())),
+		verifier:              verifier,
 	}, nil
 }
 
diff --git a/internal/secrets/cacher.go b/internal/secrets/cacher.go
index b5d18f8..80f21c4 100644
--- a/internal/secrets/cacher.go
+++ b/internal/secrets/cacher.go
@@ -17,10 +17,9 @@ package secrets
 import (
 	"context"
 	"fmt"
-	"sync"
 	"time"
 
-	"github.com/google/exposure-notifications-server/internal/logging"
+	"github.com/google/exposure-notifications-server/internal/cache"
 )
 
 // Compile-time check to verify implements interface.
@@ -29,11 +28,8 @@ var _ SecretManager = (*Cacher)(nil)
 // Cacher is a secret manager implementation that wraps another secret manager
 // and caches secret values.
 type Cacher struct {
-	sm  SecretManager
-	ttl time.Duration
-
-	cache      map[string]*cachedItem
-	cacheMutex sync.Mutex
+	sm    SecretManager
+	cache *cache.Cache
 }
 
 type cachedItem struct {
@@ -48,44 +44,38 @@ func NewCacher(ctx context.Context, f SecretManagerFunc, ttl time.Duration) (Sec
 		return nil, fmt.Errorf("cacher: %w", err)
 	}
 
-	return WrapCacher(ctx, sm, ttl), nil
+	return WrapCacher(ctx, sm, ttl)
 }
 
 // WrapCacher wraps an existing SecretManager with caching.
-func WrapCacher(ctx context.Context, sm SecretManager, ttl time.Duration) SecretManager {
+func WrapCacher(ctx context.Context, sm SecretManager, ttl time.Duration) (SecretManager, error) {
+	cache, err := cache.New(ttl)
+	if err != nil {
+		return nil, err
+	}
 	return &Cacher{
 		sm:    sm,
-		ttl:   ttl,
-		cache: make(map[string]*cachedItem),
-	}
+		cache: cache,
+	}, nil
 }
 
 // GetSecretValue implements the SecretManager interface, but caches values and
 // retrieves them from the cache.
 func (sm *Cacher) GetSecretValue(ctx context.Context, name string) (string, error) {
-	logger := logging.FromContext(ctx)
-
-	// Lock
-	sm.cacheMutex.Lock()
-	defer sm.cacheMutex.Unlock()
-
-	// Lookup in cache
-	if i, ok := sm.cache[name]; ok && time.Since(i.cachedAt) < sm.ttl {
-		logger.Debugf("loaded secret %v from cache", name)
-		return i.value, nil
+	lookup := func() (interface{}, error) {
+		// Delegate lookup to parent sm.
+		plaintext, err := sm.sm.GetSecretValue(ctx, name)
+		if err != nil {
+			return "", err
+		}
+		return plaintext, nil
 	}
 
-	// Delegate lookup to parent sm.
-	plaintext, err := sm.sm.GetSecretValue(ctx, name)
+	cacheVal, err := sm.cache.WriteThruLookup(name, lookup)
 	if err != nil {
-		return "", err
-	}
-
-	// Cache value
-	sm.cache[name] = &cachedItem{
-		value:    plaintext,
-		cachedAt: time.Now(),
+		return "", nil
 	}
 
-	return plaintext, nil
+	plaintext := cacheVal.(string)
+	return plaintext, err
 }
diff --git a/internal/server/server.go b/internal/server/server.go
index c79b03f..f19bf8e 100644
--- a/internal/server/server.go
+++ b/internal/server/server.go
@@ -21,6 +21,7 @@ import (
 	"fmt"
 	"net"
 	"net/http"
+	"strconv"
 	"time"
 
 	"github.com/google/exposure-notifications-server/internal/logging"
@@ -31,11 +32,14 @@ import (
 // Server provides a gracefully-stoppable http server implementation. It is safe
 // for concurrent use in goroutines.
 type Server struct {
+	ip       string
+	port     string
 	listener net.Listener
 }
 
 // New creates a new server listening on the provided address that responds to
-// the http.Handler. It starts the listener, but does not start the server.
+// the http.Handler. It starts the listener, but does not start the server. If
+// an empty port is given, the server randombly chooses one.
 func New(port string) (*Server, error) {
 	// Create the net listener first, so the connection ready when we return. This
 	// guarantees that it can accept requests.
@@ -46,6 +50,8 @@ func New(port string) (*Server, error) {
 	}
 
 	return &Server{
+		ip:       listener.Addr().(*net.TCPAddr).IP.String(),
+		port:     strconv.Itoa(listener.Addr().(*net.TCPAddr).Port),
 		listener: listener,
 	}, nil
 }
@@ -138,3 +144,18 @@ func (s *Server) ServeGRPC(ctx context.Context, srv *grpc.Server) error {
 		return nil
 	}
 }
+
+// Addr returns the server's listening address (ip + port).
+func (s *Server) Addr() string {
+	return net.JoinHostPort(s.ip, s.port)
+}
+
+// IP returns the server's listening IP.
+func (s *Server) IP() string {
+	return s.ip
+}
+
+// Port returns the server's listening port.
+func (s *Server) Port() string {
+	return s.port
+}
diff --git a/internal/setup/setup.go b/internal/setup/setup.go
index 7d1826a..60c9e70 100644
--- a/internal/setup/setup.go
+++ b/internal/setup/setup.go
@@ -106,7 +106,10 @@ func SetupWith(ctx context.Context, config interface{}, l envconfig.Lookuper) (*
 
 		// Enable caching, if a TTL was provided.
 		if ttl := smConfig.SecretCacheTTL; ttl > 0 {
-			sm = secrets.WrapCacher(ctx, sm, ttl)
+			sm, err = secrets.WrapCacher(ctx, sm, ttl)
+			if err != nil {
+				return nil, fmt.Errorf("unable to create secret manager cache: %w", err)
+			}
 		}
 
 		// Update the mutators to process secrets.
diff --git a/internal/storage/config.go b/internal/storage/config.go
index 8407b2b..f1e3955 100644
--- a/internal/storage/config.go
+++ b/internal/storage/config.go
@@ -20,8 +20,9 @@ type BlobstoreType string
 const (
 	BlobstoreTypeAWSS3              BlobstoreType = "AWS_S3"
 	BlobstoreTypeAzureBlobStorage   BlobstoreType = "AZURE_BLOB_STORAGE"
-	BlobstoreTypeGoogleCloudStorage BlobstoreType = "GOOGLE_CLOUD_STORAGE"
 	BlobstoreTypeFilesystem         BlobstoreType = "FILESYSTEM"
+	BlobstoreTypeGoogleCloudStorage BlobstoreType = "GOOGLE_CLOUD_STORAGE"
+	BlobstoreTypeMemory             BlobstoreType = "MEMORY"
 	BlobstoreTypeNoop               BlobstoreType = "NOOP"
 )
 
diff --git a/internal/storage/memory.go b/internal/storage/memory.go
new file mode 100644
index 0000000..f98ce00
--- /dev/null
+++ b/internal/storage/memory.go
@@ -0,0 +1,59 @@
+// 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 storage
+
+import (
+	"context"
+	"path"
+	"sync"
+)
+
+// Compile-time check to verify implements interface.
+var _ Blobstore = (*Memory)(nil)
+
+// Memory implements Blobstore and provides the ability write files to
+// memory.
+type Memory struct {
+	lock sync.Mutex
+	data map[string][]byte
+}
+
+// NewMemory creates a Blobstore that writes data in memory.
+func NewMemory(_ context.Context) (Blobstore, error) {
+	return &Memory{
+		data: make(map[string][]byte),
+	}, nil
+}
+
+// CreateObject creates a new object.
+func (s *Memory) CreateObject(_ context.Context, folder, filename string, contents []byte, cacheable bool) error {
+	s.lock.Lock()
+	defer s.lock.Unlock()
+
+	pth := path.Join(folder, filename)
+	s.data[pth] = contents
+	return nil
+}
+
+// DeleteObject deletes an object. It returns nil if the object was deleted or
+// if the object no longer exists.
+func (s *Memory) DeleteObject(_ context.Context, folder, filename string) error {
+	s.lock.Lock()
+	defer s.lock.Unlock()
+
+	pth := path.Join(folder, filename)
+	delete(s.data, pth)
+	return nil
+}
diff --git a/internal/storage/storage.go b/internal/storage/storage.go
index 4b1e07e..7e8f9dc 100644
--- a/internal/storage/storage.go
+++ b/internal/storage/storage.go
@@ -41,6 +41,8 @@ func BlobstoreFor(ctx context.Context, typ BlobstoreType) (Blobstore, error) {
 		return NewGoogleCloudStorage(ctx)
 	case BlobstoreTypeFilesystem:
 		return NewFilesystemStorage(ctx)
+	case BlobstoreTypeMemory:
+		return NewMemory(ctx)
 	case BlobstoreTypeNoop:
 		return NewNoop(ctx)
 	default:
diff --git a/internal/verification/config.go b/internal/verification/config.go
new file mode 100644
index 0000000..3dba770
--- /dev/null
+++ b/internal/verification/config.go
@@ -0,0 +1,23 @@
+// 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 verification
+
+import "time"
+
+// Config represents the available configuration for the public health authority
+// verification piece.
+type Config struct {
+	CacheDuration time.Duration `env:"VERIFICATION_CACHE_DURATION, default=5m"`
+}
diff --git a/internal/verification/phaverify.go b/internal/verification/phaverify.go
index e030333..3205be6 100644
--- a/internal/verification/phaverify.go
+++ b/internal/verification/phaverify.go
@@ -24,7 +24,9 @@ import (
 
 	aamodel "github.com/google/exposure-notifications-server/internal/authorizedapp/model"
 	"github.com/google/exposure-notifications-server/internal/base64util"
+	"github.com/google/exposure-notifications-server/internal/cache"
 	"github.com/google/exposure-notifications-server/internal/verification/database"
+	"github.com/google/exposure-notifications-server/internal/verification/model"
 
 	verifyapi "github.com/google/exposure-notifications-server/pkg/api/v1alpha1"
 	utils "github.com/google/exposure-notifications-server/pkg/verification"
@@ -34,12 +36,17 @@ import (
 
 // Verifier can be used to verify public health authority diagnosis verification certificates.
 type Verifier struct {
-	db *database.HealthAuthorityDB
+	db      *database.HealthAuthorityDB
+	haCache *cache.Cache
 }
 
 // New creates a new verifier, based on this DB handle.
-func New(db *database.HealthAuthorityDB) *Verifier {
-	return &Verifier{db}
+func New(db *database.HealthAuthorityDB, config *Config) (*Verifier, error) {
+	cache, err := cache.New(config.CacheDuration)
+	if err != nil {
+		return nil, err
+	}
+	return &Verifier{db, cache}, nil
 }
 
 // VerifyDiagnosisCertificate accepts a publish request (from which is extracts the JWT),
@@ -62,12 +69,21 @@ func (v *Verifier) VerifyDiagnosisCertificate(ctx context.Context, authApp *aamo
 			return nil, fmt.Errorf("does not contain expected claim set")
 		}
 
-		// Based on issuer, load the key versions.
-		ha, err := v.db.GetHealthAuthority(ctx, claims.Issuer)
+		lookup := func() (interface{}, error) {
+			// Based on issuer, load the key versions.
+			ha, err := v.db.GetHealthAuthority(ctx, claims.Issuer)
+			if err != nil {
+				return nil, fmt.Errorf("error looking up issuer: %v : %w", claims.Issuer, err)
+			}
+			return ha, nil
+		}
+		cacheVal, err := v.haCache.WriteThruLookup(claims.Issuer, lookup)
 		if err != nil {
-			return nil, fmt.Errorf("error looking up issuer: %v : %w", claims.Issuer, err)
+			return nil, err
 		}
 
+		ha := cacheVal.(*model.HealthAuthority)
+
 		// Find a key version.
 		for _, hak := range ha.Keys {
 			// Key version matches and the key is valid based on the current time.
diff --git a/migrations/000030_remove_package_and_bundle.down.sql b/migrations/000030_remove_package_and_bundle.down.sql
new file mode 100644
index 0000000..f8a2a2c
--- /dev/null
+++ b/migrations/000030_remove_package_and_bundle.down.sql
@@ -0,0 +1,21 @@
+-- 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.
+
+BEGIN;
+
+alter table SignatureInfo
+  add column app_package_name VARCHAR(1000) NOT NULL DEFAULT '',
+  add column bundle_id VARCHAR(1000) NOT NULL DEFAULT '';
+
+END;
diff --git a/migrations/000030_remove_package_and_bundle.up.sql b/migrations/000030_remove_package_and_bundle.up.sql
new file mode 100644
index 0000000..de1f27f
--- /dev/null
+++ b/migrations/000030_remove_package_and_bundle.up.sql
@@ -0,0 +1,21 @@
+-- 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.
+
+BEGIN;
+
+alter table SignatureInfo
+  drop column app_package_name,
+  drop column bundle_id;
+
+END;
diff --git a/tools/admin-console/templates/siginfo.html b/tools/admin-console/templates/siginfo.html
index 19ae8a3..6585ac9 100644
--- a/tools/admin-console/templates/siginfo.html
+++ b/tools/admin-console/templates/siginfo.html
@@ -65,24 +65,6 @@ by Apple and Google).</p>
     </div>
   </div>
 
-  <div class="form-group row">
-    <label class="control-label col-sm-3" for="AppPackageName">App Package Name:</label>
-    <div class="col-sm-6">
-      <input type="text" id="AppPackageName" name="AppPackageName" size="50" value="{{.siginfo.AppPackageName}}">
-      <small id="AppPackageNameHelpBlock" class="form-text text-muted">The android App Package Name
-      to put in the SignatureInfo block in exports. <strong>Normally left blank</strong></small>
-    </div>
-  </div>
-
-  <div class="form-group row">
-    <label class="control-label col-sm-3" for="BundleID">BundleID:</label>
-    <div class="col-sm-6">
-      <input type="text" id="BundleID" name="BundleID" size="50" value="{{.siginfo.BundleID}}">
-      <small id="BundleIDHelpBlock" class="form-text text-muted">The iOS BundleID to
-      put in the SignatureInfo block in exports.</small>
-    </div>
-  </div>
-
   <hr/>
   <button type="submit" class="btn btn-outline-primary" name="TODO" value="save">Save Changes</button>
   <a href="/" class="btn btn-outline-secondary">Cancel</a>
diff --git a/tools/export-config/main.go b/tools/export-config/main.go
index f590391..88fce33 100644
--- a/tools/export-config/main.go
+++ b/tools/export-config/main.go
@@ -38,8 +38,6 @@ var (
 	signingKey        = flag.String("signing-key", "", "The KMS resource ID to use for signing batches.")
 	signingKeyID      = flag.String("signing-key-id", "", "The ID of the signing key (for clients).")
 	signingKeyVersion = flag.String("signing-key-version", "", "The version of the signing key (for clients).")
-	appPkgID          = flag.String("app-pkg-id", "", "The App Package ID to put in export headers")
-	bundleID          = flag.String("bundle-id", "", "The BundleID to put in export headers")
 )
 
 func main() {
@@ -90,8 +88,6 @@ func main() {
 
 	si := model.SignatureInfo{
 		SigningKey:        *signingKey,
-		AppPackageName:    *appPkgID,
-		BundleID:          *bundleID,
 		SigningKeyVersion: *signingKeyVersion,
 		SigningKeyID:      *signingKeyID,
 	}


Test patch:
diff --git a/internal/cache/cache_test.go b/internal/cache/cache_test.go
index 9949e0f..3ac3c98 100644
--- a/internal/cache/cache_test.go
+++ b/internal/cache/cache_test.go
@@ -145,17 +145,25 @@ func TestConcurrentReaders(t *testing.T) {
 	lookupCount := 0
 	want := &order{12, 34}
 	lookerUpper := func() (interface{}, error) {
+		// The sleep here, reliably triggers a race condition on multiple entrants attempting
+		// to lookup the cache miss to primary storage. Only one will win!
+		time.Sleep(250 * time.Millisecond)
 		lookupCount++
 		return want, nil
 	}
 
-	done := make(chan bool, 10)
-	for i := 0; i < 10; i++ {
+	parallel := 10
+	done := make(chan bool, parallel)
+	for i := 0; i < parallel; i++ {
 		ver := i
 		go func() {
-			got, err := cache.WriteThruLookup("foo", lookerUpper)
+			gotCache, err := cache.WriteThruLookup("foo", lookerUpper)
 			if err != nil {
-				t.Errorf("routine: %v got unexpected error: %v", ver, err)
+				t.Fatalf("routine: %v got unexpected error: %v", ver, err)
+			}
+			got, ok := gotCache.(*order)
+			if !ok {
+				t.Fatalf("cache item of wrong type")
 			}
 			if diff := cmp.Diff(want, got); diff != "" {
 				t.Fatalf("routine: %v mismatch (-want, +got):\n%s", ver, diff)
@@ -164,11 +172,11 @@ func TestConcurrentReaders(t *testing.T) {
 		}()
 	}
 
-	for i := 0; i < 10; i++ {
+	for i := 0; i < parallel; i++ {
 		select {
 		case <-done:
 			continue
-		case <-time.After(1 * time.Second):
+		case <-time.After(2 * time.Second):
 			t.Fatal("gorountines didn't termine fast enough")
 		}
 	}
diff --git a/internal/integration/integration_test.go b/internal/integration/integration_test.go
new file mode 100644
index 0000000..2411cf4
--- /dev/null
+++ b/internal/integration/integration_test.go
@@ -0,0 +1,195 @@
+// 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 integration
+
+import (
+	"context"
+	"net/http"
+	"testing"
+	"time"
+
+	"github.com/google/exposure-notifications-server/internal/authorizedapp"
+	"github.com/google/exposure-notifications-server/internal/cleanup"
+	"github.com/google/exposure-notifications-server/internal/database"
+	"github.com/google/exposure-notifications-server/internal/export"
+	"github.com/google/exposure-notifications-server/internal/federationin"
+	"github.com/google/exposure-notifications-server/internal/handlers"
+	"github.com/google/exposure-notifications-server/internal/publish"
+	"github.com/google/exposure-notifications-server/internal/secrets"
+	"github.com/google/exposure-notifications-server/internal/server"
+	"github.com/google/exposure-notifications-server/internal/serverenv"
+	"github.com/google/exposure-notifications-server/internal/signing"
+	"github.com/google/exposure-notifications-server/internal/storage"
+)
+
+func testServer(tb testing.TB) (*serverenv.ServerEnv, *http.Client) {
+	tb.Helper()
+
+	ctx := context.Background()
+
+	aa, err := authorizedapp.NewMemoryProvider(ctx, nil)
+	if err != nil {
+		tb.Fatal(err)
+	}
+
+	bs, err := storage.NewMemory(ctx)
+	if err != nil {
+		tb.Fatal(err)
+	}
+
+	db := database.NewTestDatabase(tb)
+
+	km, err := signing.NewNoop(ctx)
+	if err != nil {
+		tb.Fatal(err)
+	}
+
+	sm, err := secrets.NewNoop(ctx)
+	if err != nil {
+		tb.Fatal(err)
+	}
+
+	env := serverenv.New(ctx,
+		serverenv.WithAuthorizedAppProvider(aa),
+		serverenv.WithBlobStorage(bs),
+		serverenv.WithDatabase(db),
+		serverenv.WithKeyManager(km),
+		serverenv.WithSecretManager(sm),
+	)
+	// Note: don't call env.Cleanup() because the database helper closes the
+	// connection for us.
+
+	mux := http.NewServeMux()
+
+	// Cleanup export
+	cleanupExportConfig := &cleanup.Config{
+		Timeout: 10 * time.Minute,
+		TTL:     336 * time.Hour,
+	}
+
+	cleanupExportHandler, err := cleanup.NewExportHandler(cleanupExportConfig, env)
+	if err != nil {
+		tb.Fatal(err)
+	}
+	mux.Handle("/cleanup-export", cleanupExportHandler)
+
+	// Cleanup exposure
+	cleanupExposureConfig := &cleanup.Config{
+		Timeout: 10 * time.Minute,
+		TTL:     336 * time.Hour,
+	}
+
+	cleanupExposureHandler, err := cleanup.NewExposureHandler(cleanupExposureConfig, env)
+	if err != nil {
+		tb.Fatal(err)
+	}
+	mux.Handle("/cleanup-exposure", cleanupExposureHandler)
+
+	// Export
+	exportConfig := &export.Config{
+		CreateTimeout:  10 * time.Second,
+		WorkerTimeout:  10 * time.Second,
+		MinRecords:     1,
+		PaddingRange:   1,
+		MaxRecords:     10000,
+		TruncateWindow: 1 * time.Second,
+		MinWindowAge:   1 * time.Second,
+		TTL:            336 * time.Hour,
+	}
+
+	exportServer, err := export.NewServer(exportConfig, env)
+	if err != nil {
+		tb.Fatal(err)
+	}
+	mux.HandleFunc("/export/create-batches", exportServer.CreateBatchesHandler)
+	mux.HandleFunc("/export/do-work", exportServer.WorkerHandler)
+
+	// Federation
+	federationInConfig := &federationin.Config{
+		Timeout:        10 * time.Minute,
+		TruncateWindow: 1 * time.Hour,
+	}
+
+	mux.Handle("/federation-in", federationin.NewHandler(env, federationInConfig))
+
+	// Federation out
+	// TODO: this is a grpc listener and requires a lot of setup.
+
+	// Publish
+	publishConfig := &publish.Config{
+		MinRequestDuration:      50 * time.Millisecond,
+		MaxKeysOnPublish:        15,
+		MaxIntervalAge:          360 * time.Hour,
+		TruncateWindow:          1 * time.Hour,
+		DebugAPIResponses:       true,
+		DebugReleaseSameDayKeys: true,
+	}
+
+	publishHandler, err := publish.NewHandler(ctx, publishConfig, env)
+	if err != nil {
+		tb.Fatal(err)
+	}
+	mux.HandleFunc("/publish", handlers.WithMinimumLatency(publishConfig.MinRequestDuration, publishHandler))
+
+	srv, err := server.New("")
+	if err != nil {
+		tb.Fatal(err)
+	}
+
+	// Stop the server on cleanup
+	stopCtx, stop := context.WithCancel(ctx)
+	tb.Cleanup(stop)
+
+	// Start the server
+	go func() {
+		if err := srv.ServeHTTPHandler(stopCtx, mux); err != nil {
+			tb.Error(err)
+		}
+	}()
+
+	// Create a client
+	client := testClient(tb, srv)
+
+	return env, client
+}
+
+type prefixRoundTripper struct {
+	addr string
+	rt   http.RoundTripper
+}
+
+func (p *prefixRoundTripper) RoundTrip(r *http.Request) (*http.Response, error) {
+	u := r.URL
+	if u.Scheme == "" {
+		u.Scheme = "http"
+	}
+	if u.Host == "" {
+		u.Host = p.addr
+	}
+
+	return p.rt.RoundTrip(r)
+}
+
+func testClient(tb testing.TB, srv *server.Server) *http.Client {
+	prt := &prefixRoundTripper{
+		addr: srv.Addr(),
+		rt:   http.DefaultTransport,
+	}
+
+	return &http.Client{
+		Timeout:   5 * time.Second,
+		Transport: prt,
+	}
+}
diff --git a/internal/integration/publish_test.go b/internal/integration/publish_test.go
new file mode 100644
index 0000000..4aff6f2
--- /dev/null
+++ b/internal/integration/publish_test.go
@@ -0,0 +1,139 @@
+// 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 integration
+
+import (
+	"bytes"
+	"context"
+	"encoding/json"
+	"net/http"
+	"testing"
+	"time"
+
+	authorizedappmodel "github.com/google/exposure-notifications-server/internal/authorizedapp/model"
+	exportdatabase "github.com/google/exposure-notifications-server/internal/export/database"
+	exportmodel "github.com/google/exposure-notifications-server/internal/export/model"
+	publishdb "github.com/google/exposure-notifications-server/internal/publish/database"
+	publishmodel "github.com/google/exposure-notifications-server/internal/publish/model"
+	"github.com/google/exposure-notifications-server/internal/util"
+	verifyapi "github.com/google/exposure-notifications-server/pkg/api/v1alpha1"
+)
+
+func TestPublish(t *testing.T) {
+	t.Parallel()
+
+	ctx := context.Background()
+	env, client := testServer(t)
+	db := env.Database()
+
+	// Create an authorized app.
+	aa := env.AuthorizedAppProvider()
+	if err := aa.Add(ctx, &authorizedappmodel.AuthorizedApp{
+		AppPackageName: "com.example.app",
+		AllowedRegions: map[string]struct{}{
+			"US": {},
+		},
+		AllowedHealthAuthorityIDs: map[int64]struct{}{
+			12345: {},
+		},
+
+		// TODO: hook up verification, and disable
+		BypassHealthAuthorityVerification: true,
+	}); err != nil {
+		t.Fatal(err)
+	}
+
+	// Create a signature info.
+	si := &exportmodel.SignatureInfo{
+		SigningKey:        "signer",
+		SigningKeyVersion: "v1",
+		SigningKeyID:      "US",
+	}
+	if err := exportdatabase.New(db).AddSignatureInfo(ctx, si); err != nil {
+		t.Fatal(err)
+	}
+
+	// Create an export config.
+	ec := &exportmodel.ExportConfig{
+		BucketName:       "my-bucket",
+		Period:           1 * time.Second,
+		OutputRegion:     "US",
+		From:             time.Now().Add(-2 * time.Second),
+		Thru:             time.Now().Add(1 * time.Hour),
+		SignatureInfoIDs: []int64{si.ID},
+	}
+	if err := exportdatabase.New(db).AddExportConfig(ctx, ec); err != nil {
+		t.Fatal(err)
+	}
+
+	payload := &verifyapi.Publish{
+		Keys:           util.GenerateExposureKeys(3, -1, false),
+		Regions:        []string{"US"},
+		AppPackageName: "com.example.app",
+
+		// TODO: hook up verification
+		VerificationPayload: "TODO",
+	}
+
+	var body bytes.Buffer
+	if err := json.NewEncoder(&body).Encode(payload); err != nil {
+		t.Fatal(err)
+	}
+
+	resp, err := client.Post("/publish", "application/json", &body)
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer resp.Body.Close()
+
+	// Ensure we get a successful response code.
+	if got, want := resp.StatusCode, http.StatusOK; got != want {
+		t.Errorf("expected %v to be %v", got, want)
+	}
+
+	// Look up the exposures in the database.
+	criteria := publishdb.IterateExposuresCriteria{
+		OnlyLocalProvenance: false,
+	}
+
+	var exposures []*publishmodel.Exposure
+	if _, err := publishdb.New(db).IterateExposures(ctx, criteria, func(m *publishmodel.Exposure) error {
+		exposures = append(exposures, m)
+		return nil
+	}); err != nil {
+		t.Fatal(err)
+	}
+
+	if got, want := len(exposures), 3; got != want {
+		t.Errorf("expected %v to be %v: %#v", got, want, exposures)
+	}
+
+	// Create an export.
+	resp, err = client.Get("/export/create-batches")
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer resp.Body.Close()
+
+	resp, err = client.Get("/export/do-work")
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer resp.Body.Close()
+
+	// TODO: verify export has the correct file
+
+	// TODO: verify signature
+}
diff --git a/internal/secrets/cacher_test.go b/internal/secrets/cacher_test.go
index b9c7080..2b25062 100644
--- a/internal/secrets/cacher_test.go
+++ b/internal/secrets/cacher_test.go
@@ -34,7 +34,10 @@ func TestCacher_GetSecretValue(t *testing.T) {
 	ctx := context.Background()
 
 	sm := &testSecretManager{value: "first"}
-	cached := WrapCacher(ctx, sm, 250*time.Millisecond)
+	cached, err := WrapCacher(ctx, sm, 250*time.Millisecond)
+	if err != nil {
+		t.Fatal(err)
+	}
 
 	// Read the value once, which should cache it.
 	if _, err := cached.GetSecretValue(ctx, "secret"); err != nil {
diff --git a/internal/verification/phaverify_test.go b/internal/verification/phaverify_test.go
index 1a236a8..c0b58de 100644
--- a/internal/verification/phaverify_test.go
+++ b/internal/verification/phaverify_test.go
@@ -180,7 +180,10 @@ func TestVerifyCertificate(t *testing.T) {
 			publish.HMACKey = tc.MacKeyAdjustment + hmacKeyB64
 
 			// Actually test the verify code.
-			verifier := New(haDB)
+			verifier, err := New(haDB, &Config{time.Nanosecond})
+			if err != nil {
+				t.Fatal(err)
+			}
 			overrides, err := verifier.VerifyDiagnosisCertificate(ctx, authApp, &publish)
 			if err != nil {
 				if !strings.Contains(err.Error(), tc.Error) {