diff --git a/cmd/enx-redirect/main.go b/cmd/enx-redirect/main.go
index 7a124b669..edf7cd63c 100644
--- a/cmd/enx-redirect/main.go
+++ b/cmd/enx-redirect/main.go
@@ -18,24 +18,18 @@ import (
"context"
"crypto/sha1"
"fmt"
- "net/http"
"os"
"strconv"
+ "github.com/google/exposure-notifications-verification-server/internal/routes"
"github.com/google/exposure-notifications-verification-server/pkg/buildinfo"
"github.com/google/exposure-notifications-verification-server/pkg/cache"
"github.com/google/exposure-notifications-verification-server/pkg/config"
- "github.com/google/exposure-notifications-verification-server/pkg/controller"
- "github.com/google/exposure-notifications-verification-server/pkg/controller/associated"
- "github.com/google/exposure-notifications-verification-server/pkg/controller/middleware"
- "github.com/google/exposure-notifications-verification-server/pkg/controller/redirect"
- "github.com/google/exposure-notifications-verification-server/pkg/render"
"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"
"github.com/sethvargo/go-signalcontext"
)
@@ -77,7 +71,6 @@ func realMain(ctx context.Context) error {
return fmt.Errorf("error initializing observability exporter: %w", err)
}
defer oe.Close()
- ctx, obs := middleware.WithObservability(ctx)
logger.Infow("observability exporter", "config", oeConfig)
// Setup cacher
@@ -97,68 +90,13 @@ func realMain(ctx context.Context) error {
}
defer db.Close()
- // Create the router
- r := mux.NewRouter()
-
- // Common observability context
- r.Use(obs)
-
- // Create the renderer
- h, err := render.New(ctx, cfg.AssetsPath, cfg.DevMode)
+ // Setup routes
+ mux, err := routes.ENXRedirect(ctx, cfg, db, cacher)
if err != nil {
- return fmt.Errorf("failed to create renderer: %w", err)
+ return fmt.Errorf("failed to setup routes: %w", err)
}
- // Request ID injection
- populateRequestID := middleware.PopulateRequestID(h)
- r.Use(populateRequestID)
-
- // Logger injection
- populateLogger := middleware.PopulateLogger(logger)
- r.Use(populateLogger)
-
- // Install common security headers
- r.Use(middleware.SecureHeaders(cfg.DevMode, "html"))
-
- // Enable debug headers
- processDebug := middleware.ProcessDebug()
- r.Use(processDebug)
-
- // iOS and Android include functionality to associate data between web-apps
- // and device apps. Things like handoff between websites and apps, or
- // shared credentials are the common usecases. The redirect server
- // publishes the metadata needed to share data between the two domains to
- // offer a more seemless experience between the website and apps. iOS and
- // Android publish specs as to what this format looks like, and both live
- // under the /.well-known directory on the server.
- //
- // Android Specs:
- // https://developer.android.com/training/app-links/verify-site-associations
- // iOS Specs:
- // https://developer.apple.com/documentation/safariservices/supporting_associated_domains
- { // .well-known directory
- wk := r.PathPrefix("/.well-known").Subrouter()
-
- // Enable the iOS and Android redirect handler.
- assocHandler, err := associated.New(ctx, cfg, db, cacher, h)
- if err != nil {
- return fmt.Errorf("failed to create associated links handler %w", err)
- }
- wk.PathPrefix("/apple-app-site-association").Handler(assocHandler.HandleIos()).Methods("GET")
- wk.PathPrefix("/assetlinks.json").Handler(assocHandler.HandleAndroid()).Methods("GET")
- }
-
- r.Handle("/health", controller.HandleHealthz(ctx, nil, h)).Methods("GET")
-
- redirectController, err := redirect.New(ctx, db, cfg, cacher, h)
- if err != nil {
- return err
- }
- r.PathPrefix("/").Handler(redirectController.HandleIndex()).Methods("GET")
-
- mux := http.NewServeMux()
- mux.Handle("/", r)
-
+ // Run server
srv, err := server.New(cfg.Port)
if err != nil {
return fmt.Errorf("failed to create server: %w", err)
diff --git a/cmd/server/assets/400.html b/cmd/server/assets/400.html
index 33fd167bf..12829330c 100644
--- a/cmd/server/assets/400.html
+++ b/cmd/server/assets/400.html
@@ -7,9 +7,9 @@
- Not found
+ Bad request
- The page you requested does not exist.
+ That request is not valid.
diff --git a/cmd/server/main.go b/cmd/server/main.go
index 1805aacfe..f07688fce 100644
--- a/cmd/server/main.go
+++ b/cmd/server/main.go
@@ -113,11 +113,13 @@ func realMain(ctx context.Context) error {
return fmt.Errorf("failed to create firebase auth provider: %w", err)
}
+ // Setup routes
mux, err := routes.Server(ctx, cfg, db, authProvider, cacher, certificateSigner, limiterStore)
if err != nil {
return fmt.Errorf("failed to setup routes: %w", err)
}
+ // Run server
srv, err := server.New(cfg.Port)
if err != nil {
return fmt.Errorf("failed to create server: %w", err)
diff --git a/go.sum b/go.sum
index 87fc23665..0fcdef143 100644
--- a/go.sum
+++ b/go.sum
@@ -992,6 +992,7 @@ github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHX
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-runewidth v0.0.2/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU=
github.com/mattn/go-runewidth v0.0.3/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU=
+github.com/mattn/go-shellwords v1.0.5 h1:JhhFTIOslh5ZsPrpa3Wdg8bF0WI3b44EMblmU9wIsXc=
github.com/mattn/go-shellwords v1.0.5/go.mod h1:3xCvwCdWdlDJUrvuMn7Wuy9eWs4pE8vqg+NOMyg4B2o=
github.com/mattn/go-sqlite3 v1.9.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
github.com/mattn/go-sqlite3 v1.10.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
diff --git a/internal/routes/enx_redirect.go b/internal/routes/enx_redirect.go
new file mode 100644
index 000000000..36b00e71f
--- /dev/null
+++ b/internal/routes/enx_redirect.go
@@ -0,0 +1,112 @@
+// 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 routes
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+
+ "github.com/google/exposure-notifications-verification-server/pkg/cache"
+ "github.com/google/exposure-notifications-verification-server/pkg/config"
+ "github.com/google/exposure-notifications-verification-server/pkg/controller"
+ "github.com/google/exposure-notifications-verification-server/pkg/controller/associated"
+ "github.com/google/exposure-notifications-verification-server/pkg/controller/middleware"
+ "github.com/google/exposure-notifications-verification-server/pkg/controller/redirect"
+ "github.com/google/exposure-notifications-verification-server/pkg/database"
+ "github.com/google/exposure-notifications-verification-server/pkg/render"
+
+ "github.com/google/exposure-notifications-server/pkg/logging"
+
+ "github.com/gorilla/mux"
+)
+
+// ENXRedirect defines routes for the redirector service for ENX.
+func ENXRedirect(
+ ctx context.Context,
+ cfg *config.RedirectConfig,
+ db *database.Database,
+ cacher cache.Cacher,
+) (http.Handler, error) {
+ // Create the router
+ r := mux.NewRouter()
+
+ // Common observability context
+ ctx, obs := middleware.WithObservability(ctx)
+ r.Use(obs)
+
+ // Create the renderer
+ h, err := render.New(ctx, cfg.AssetsPath, cfg.DevMode)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create renderer: %w", err)
+ }
+
+ // Request ID injection
+ populateRequestID := middleware.PopulateRequestID(h)
+ r.Use(populateRequestID)
+
+ // Logger injection
+ populateLogger := middleware.PopulateLogger(logging.FromContext(ctx))
+ r.Use(populateLogger)
+
+ // Install common security headers
+ r.Use(middleware.SecureHeaders(cfg.DevMode, "html"))
+
+ // Enable debug headers
+ processDebug := middleware.ProcessDebug()
+ r.Use(processDebug)
+
+ // Handle health.
+ r.Handle("/health", controller.HandleHealthz(ctx, nil, h)).Methods("GET")
+
+ // iOS and Android include functionality to associate data between web-apps
+ // and device apps. Things like handoff between websites and apps, or
+ // shared credentials are the common usecases. The redirect server
+ // publishes the metadata needed to share data between the two domains to
+ // offer a more seemless experience between the website and apps. iOS and
+ // Android publish specs as to what this format looks like, and both live
+ // under the /.well-known directory on the server.
+ //
+ // Android Specs:
+ // https://developer.android.com/training/app-links/verify-site-associations
+ // iOS Specs:
+ // https://developer.apple.com/documentation/safariservices/supporting_associated_domains
+ //
+ {
+ wk := r.PathPrefix("/.well-known").Subrouter()
+
+ // Enable the iOS and Android redirect handler.
+ assocController, err := associated.New(ctx, cfg, db, cacher, h)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create associated links controller: %w", err)
+ }
+ wk.PathPrefix("/apple-app-site-association").Handler(assocController.HandleIos()).Methods("GET")
+ wk.PathPrefix("/assetlinks.json").Handler(assocController.HandleAndroid()).Methods("GET")
+ }
+
+ // Handle redirects.
+ redirectController, err := redirect.New(ctx, db, cfg, cacher, h)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create redirect controller: %w", err)
+ }
+ r.PathPrefix("/").Handler(redirectController.HandleIndex()).Methods("GET")
+
+ // Wrap the main router in the mutating middleware method. This cannot be
+ // inserted as middleware because gorilla processes the method before
+ // middleware.
+ mux := http.NewServeMux()
+ mux.Handle("/", middleware.MutateMethod()(r))
+ return mux, nil
+}
diff --git a/pkg/controller/associated/android.go b/pkg/controller/associated/android.go
index 326e72b81..09b0f3ea3 100644
--- a/pkg/controller/associated/android.go
+++ b/pkg/controller/associated/android.go
@@ -33,13 +33,8 @@ type Target struct {
}
// getAndroidData finds all the android data apps.
-func (c *Controller) getAndroidData(region string) ([]AndroidData, error) {
- realm, err := c.db.FindRealmByRegion(region)
- if err != nil {
- return nil, fmt.Errorf("unable to lookup realm: %w", err)
- }
-
- apps, err := c.db.ListActiveApps(realm.ID, database.WithAppOS(database.OSTypeAndroid))
+func (c *Controller) getAndroidData(realmID uint) ([]AndroidData, error) {
+ apps, err := c.db.ListActiveApps(realmID, database.WithAppOS(database.OSTypeAndroid))
if err != nil {
return nil, fmt.Errorf("failed to get android data: %w", err)
}
diff --git a/pkg/controller/associated/android_test.go b/pkg/controller/associated/android_test.go
new file mode 100644
index 000000000..f61321be2
--- /dev/null
+++ b/pkg/controller/associated/android_test.go
@@ -0,0 +1,133 @@
+// 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 associated
+
+import (
+ "reflect"
+ "sort"
+ "testing"
+
+ "github.com/google/exposure-notifications-verification-server/pkg/database"
+)
+
+func TestGetAndroidData(t *testing.T) {
+ t.Parallel()
+
+ t.Run("no_active_apps", func(t *testing.T) {
+ t.Parallel()
+
+ db, _ := testDatabaseInstance.NewDatabase(t, nil)
+
+ realm, err := db.FindRealm(1)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ c := &Controller{db: db}
+ data, err := c.getAndroidData(realm.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if got, want := len(data), 0; got != want {
+ t.Errorf("expected len(data) to be %d, got %d: %v", want, got, data)
+ }
+ })
+
+ t.Run("active_apps", func(t *testing.T) {
+ t.Parallel()
+
+ db, _ := testDatabaseInstance.NewDatabase(t, nil)
+
+ realm, err := db.FindRealm(1)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // Android app1
+ app1 := &database.MobileApp{
+ Name: "app1",
+ RealmID: realm.ID,
+ URL: "https://app1.example.com/",
+ OS: database.OSTypeAndroid,
+ AppID: "com.example.app1",
+ SHA: "AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA",
+ }
+ if err := db.SaveMobileApp(app1, database.SystemTest); err != nil {
+ t.Fatal(err)
+ }
+
+ // Android app2
+ app2 := &database.MobileApp{
+ Name: "app2",
+ RealmID: realm.ID,
+ URL: "https://app2.example.com/",
+ OS: database.OSTypeAndroid,
+ AppID: "com.example.app2",
+ SHA: "BB:BB:BB:BB:BB:BB:BB:BB:BB:BB:BB:BB:BB:BB:BB:BB:BB:BB:BB:BB:BB:BB:BB:BB:BB:BB:BB:BB:BB:BB:BB:BB",
+ }
+ if err := db.SaveMobileApp(app2, database.SystemTest); err != nil {
+ t.Fatal(err)
+ }
+
+ // iOS app3, not Android
+ app3 := &database.MobileApp{
+ Name: "app3",
+ RealmID: realm.ID,
+ URL: "https://app3.example.com/",
+ OS: database.OSTypeIOS,
+ AppID: "com.example.app3",
+ }
+ if err := db.SaveMobileApp(app3, database.SystemTest); err != nil {
+ t.Fatal(err)
+ }
+
+ // Android app4, not in realm
+ app4 := &database.MobileApp{
+ Name: "app4",
+ RealmID: realm.ID + 1, // Not this realm
+ URL: "https://app4.example.com/",
+ OS: database.OSTypeAndroid,
+ AppID: "com.example.app4",
+ SHA: "DD:DD:DD:DD:DD:DD:DD:DD:DD:DD:DD:DD:DD:DD:DD:DD:DD:DD:DD:DD:DD:DD:DD:DD:DD:DD:DD:DD:DD:DD:DD:DD",
+ }
+ if err := db.SaveMobileApp(app4, database.SystemTest); err != nil {
+ t.Fatal(err)
+ }
+
+ c := &Controller{db: db}
+ data, err := c.getAndroidData(realm.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // Ensure only the 2 actual apps that are android and of this realm were
+ // included in the results.
+ if got, want := len(data), 2; got != want {
+ t.Errorf("expected len(data) to be %d, got %d: %v", want, got, data)
+ }
+
+ sort.Slice(data, func(i, j int) bool {
+ return data[i].Target.PackageName < data[j].Target.PackageName
+ })
+
+ if got, want := data[0].Target.Fingerprints, []string{app1.SHA}; !reflect.DeepEqual(got, want) {
+ t.Errorf("expected %q to be %q", got, want)
+ }
+ if got, want := data[1].Target.Fingerprints, []string{app2.SHA}; !reflect.DeepEqual(got, want) {
+ t.Errorf("expected %q to be %q", got, want)
+ }
+ })
+}
diff --git a/pkg/controller/associated/associated_test.go b/pkg/controller/associated/associated_test.go
new file mode 100644
index 000000000..cfe12aef2
--- /dev/null
+++ b/pkg/controller/associated/associated_test.go
@@ -0,0 +1,29 @@
+// 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 associated
+
+import (
+ "testing"
+
+ "github.com/google/exposure-notifications-verification-server/pkg/database"
+)
+
+var testDatabaseInstance *database.TestInstance
+
+func TestMain(m *testing.M) {
+ testDatabaseInstance = database.MustTestInstance()
+ defer testDatabaseInstance.MustClose()
+ m.Run()
+}
diff --git a/pkg/controller/associated/index.go b/pkg/controller/associated/index.go
index ca0db1d30..f344d0461 100644
--- a/pkg/controller/associated/index.go
+++ b/pkg/controller/associated/index.go
@@ -28,7 +28,6 @@ import (
"strings"
"github.com/google/exposure-notifications-server/pkg/logging"
- "github.com/google/exposure-notifications-verification-server/pkg/api"
"github.com/google/exposure-notifications-verification-server/pkg/cache"
"github.com/google/exposure-notifications-verification-server/pkg/config"
"github.com/google/exposure-notifications-verification-server/pkg/controller"
@@ -56,7 +55,6 @@ func (c *Controller) getRegion(r *http.Request) string {
}
func (c *Controller) HandleIos() http.Handler {
- notFound := api.Errorf("not found")
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
@@ -64,34 +62,45 @@ func (c *Controller) HandleIos() http.Handler {
region := c.getRegion(r)
if region == "" {
- c.h.RenderJSON(w, http.StatusNotFound, notFound)
+ c.h.RenderJSON(w, http.StatusNotFound, fmt.Errorf("request is missing region"))
return
}
+ // Lookup the realm with the region code.
+ realm, err := c.db.FindRealmByRegion(region)
+ if err != nil {
+ if database.IsNotFound(err) {
+ c.h.RenderJSON(w, http.StatusNotFound, fmt.Errorf("no realm exists for region %q", region))
+ return
+ }
+
+ controller.InternalError(w, r, c.h, err)
+ return
+ }
+
+ var data *IOSData
cacheKey := &cache.Key{
Namespace: "apps:ios:by_region",
Key: region,
}
- var iosData *IOSData
- if err := c.cacher.Fetch(ctx, cacheKey, &iosData, c.config.AppCacheTTL, func() (interface{}, error) {
+ if err := c.cacher.Fetch(ctx, cacheKey, &data, c.config.AppCacheTTL, func() (interface{}, error) {
logger.Debug("fetching new ios data")
- return c.getIosData(region)
+ return c.getIosData(realm.ID)
}); err != nil {
controller.InternalError(w, r, c.h, err)
return
}
- if iosData == nil {
- c.h.RenderJSON(w, http.StatusNotFound, notFound)
+ if data == nil {
+ c.h.RenderJSON(w, http.StatusNotFound, fmt.Errorf("no apps are registered"))
return
}
- c.h.RenderJSON(w, http.StatusOK, iosData)
+ c.h.RenderJSON(w, http.StatusOK, data)
})
}
func (c *Controller) HandleAndroid() http.Handler {
- notFound := api.Errorf("not found")
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
@@ -99,29 +108,41 @@ func (c *Controller) HandleAndroid() http.Handler {
region := c.getRegion(r)
if region == "" {
- c.h.RenderJSON(w, http.StatusNotFound, notFound)
+ c.h.RenderJSON(w, http.StatusNotFound, fmt.Errorf("request is missing region"))
+ return
+ }
+
+ // Lookup the realm with the region code.
+ realm, err := c.db.FindRealmByRegion(region)
+ if err != nil {
+ if database.IsNotFound(err) {
+ c.h.RenderJSON(w, http.StatusNotFound, fmt.Errorf("no realm exists for region %q", region))
+ return
+ }
+
+ controller.InternalError(w, r, c.h, err)
return
}
+ var data []AndroidData
cacheKey := &cache.Key{
Namespace: "apps:android:by_region",
Key: region,
}
- var androidData []AndroidData
- if err := c.cacher.Fetch(ctx, cacheKey, &androidData, c.config.AppCacheTTL, func() (interface{}, error) {
+ if err := c.cacher.Fetch(ctx, cacheKey, &data, c.config.AppCacheTTL, func() (interface{}, error) {
logger.Debug("fetching new android data")
- return c.getAndroidData(region)
+ return c.getAndroidData(realm.ID)
}); err != nil {
controller.InternalError(w, r, c.h, err)
return
}
- if len(androidData) == 0 {
- c.h.RenderJSON(w, http.StatusNotFound, notFound)
+ if len(data) == 0 {
+ c.h.RenderJSON(w, http.StatusNotFound, fmt.Errorf("no apps are registered"))
return
}
- c.h.RenderJSON(w, http.StatusOK, androidData)
+ c.h.RenderJSON(w, http.StatusOK, data)
})
}
diff --git a/pkg/controller/associated/index_test.go b/pkg/controller/associated/index_test.go
new file mode 100644
index 000000000..d79bf6ff0
--- /dev/null
+++ b/pkg/controller/associated/index_test.go
@@ -0,0 +1,386 @@
+// 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 associated_test
+
+import (
+ "context"
+ "io/ioutil"
+ "net/http"
+ "net/http/httptest"
+ "path/filepath"
+ "testing"
+
+ "github.com/google/exposure-notifications-verification-server/internal/project"
+ "github.com/google/exposure-notifications-verification-server/internal/routes"
+ "github.com/google/exposure-notifications-verification-server/pkg/cache"
+ "github.com/google/exposure-notifications-verification-server/pkg/config"
+ "github.com/google/exposure-notifications-verification-server/pkg/database"
+)
+
+func TestIndex(t *testing.T) {
+ t.Parallel()
+
+ ctx := context.Background()
+
+ // Create cacher.
+ cacher, err := cache.NewInMemory(nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() {
+ if err := cacher.Close(); err != nil {
+ t.Fatal(err)
+ }
+ })
+
+ // Create database.
+ testDatabaseInstance := database.MustTestInstance()
+ t.Cleanup(func() {
+ if err := testDatabaseInstance.Close(); err != nil {
+ t.Fatal(err)
+ }
+ })
+ db, _ := testDatabaseInstance.NewDatabase(t, cacher)
+
+ // Create config.
+ cfg := &config.RedirectConfig{
+ AssetsPath: filepath.Join(project.Root(), "cmd", "enx-redirect", "assets"),
+ DevMode: true,
+ HostnameConfig: map[string]string{
+ "bad": "nope",
+ "empty": "aa",
+ "okay": "bb",
+ },
+ }
+
+ // Set realm to resolve.
+ realm1, err := db.FindRealm(1)
+ if err != nil {
+ t.Fatal(err)
+ }
+ realm1.RegionCode = "aa"
+ if err := db.SaveRealm(realm1, database.SystemTest); err != nil {
+ t.Fatal(err)
+ }
+
+ // Create another realm with apps.
+ realm2 := database.NewRealmWithDefaults("okay")
+ realm2.RegionCode = "bb"
+ if err := db.SaveRealm(realm2, database.SystemTest); err != nil {
+ t.Fatal(err)
+ }
+
+ // Create iOS app
+ iosApp := &database.MobileApp{
+ Name: "app1",
+ RealmID: realm2.ID,
+ URL: "https://app1.example.com/",
+ OS: database.OSTypeIOS,
+ AppID: "com.example.app1",
+ }
+ if err := db.SaveMobileApp(iosApp, database.SystemTest); err != nil {
+ t.Fatal(err)
+ }
+
+ // Create Android app
+ app2 := &database.MobileApp{
+ Name: "app2",
+ RealmID: realm2.ID,
+ URL: "https://app2.example.com/",
+ OS: database.OSTypeAndroid,
+ AppID: "com.example.app2",
+ SHA: "AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA",
+ }
+ if err := db.SaveMobileApp(app2, database.SystemTest); err != nil {
+ t.Fatal(err)
+ }
+
+ // Build routes.
+ mux, err := routes.ENXRedirect(ctx, cfg, db, cacher)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // Start server.
+ srv := httptest.NewServer(mux)
+ t.Cleanup(func() {
+ srv.Close()
+ })
+ client := srv.Client()
+
+ // The .well-known directory is a 404 and not a 500.
+ t.Run("well-known_root_404", func(t *testing.T) {
+ t.Parallel()
+
+ req, err := http.NewRequest("GET", srv.URL+"/.well-known", nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ resp, err := client.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer resp.Body.Close()
+
+ if got, want := resp.StatusCode, 404; got != want {
+ body, err := ioutil.ReadAll(resp.Body)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Errorf("expected %d to be %d: %s", got, want, body)
+ }
+ })
+
+ // Missing region is a 400
+ t.Run("well-known_apple-app-site-association_missing_region", func(t *testing.T) {
+ t.Parallel()
+
+ req, err := http.NewRequest("GET", srv.URL+"/.well-known/apple-app-site-association", nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ resp, err := client.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer resp.Body.Close()
+
+ if got, want := resp.StatusCode, 404; got != want {
+ body, err := ioutil.ReadAll(resp.Body)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Errorf("expected %d to be %d: %s", got, want, body)
+ }
+ })
+
+ // Unregistered region is a 400
+ t.Run("well-known_apple-app-site-association_invalid_region", func(t *testing.T) {
+ t.Parallel()
+
+ req, err := http.NewRequest("GET", srv.URL+"/.well-known/apple-app-site-association", nil)
+ req.Host = "not-real"
+ if err != nil {
+ t.Fatal(err)
+ }
+ resp, err := client.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer resp.Body.Close()
+
+ if got, want := resp.StatusCode, 404; got != want {
+ body, err := ioutil.ReadAll(resp.Body)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Errorf("expected %d to be %d: %s", got, want, body)
+ }
+ })
+
+ // Region in config that maps to a non-existent realm. In this test, "bad" is
+ // in the config and points to a region code that isn't registered.
+ t.Run("well-known_apple-app-site-association_misconfigured", func(t *testing.T) {
+ t.Parallel()
+
+ req, err := http.NewRequest("GET", srv.URL+"/.well-known/apple-app-site-association", nil)
+ req.Host = "bad"
+ if err != nil {
+ t.Fatal(err)
+ }
+ resp, err := client.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer resp.Body.Close()
+
+ if got, want := resp.StatusCode, 404; got != want {
+ body, err := ioutil.ReadAll(resp.Body)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Errorf("expected %d to be %d: %s", got, want, body)
+ }
+ })
+
+ // Valid region with no apps is 404
+ t.Run("well-known_apple-app-site-association_no_data", func(t *testing.T) {
+ t.Parallel()
+
+ req, err := http.NewRequest("GET", srv.URL+"/.well-known/apple-app-site-association", nil)
+ req.Host = "empty"
+ if err != nil {
+ t.Fatal(err)
+ }
+ resp, err := client.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer resp.Body.Close()
+
+ if got, want := resp.StatusCode, 404; got != want {
+ body, err := ioutil.ReadAll(resp.Body)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Errorf("expected %d to be %d: %s", got, want, body)
+ }
+ })
+
+ // Valid region with apps is 200
+ t.Run("well-known_apple-app-site-association_result", func(t *testing.T) {
+ t.Parallel()
+
+ req, err := http.NewRequest("GET", srv.URL+"/.well-known/apple-app-site-association", nil)
+ req.Host = "okay"
+ if err != nil {
+ t.Fatal(err)
+ }
+ resp, err := client.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer resp.Body.Close()
+
+ if got, want := resp.StatusCode, 200; got != want {
+ body, err := ioutil.ReadAll(resp.Body)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Errorf("expected %d to be %d: %s", got, want, body)
+ }
+ })
+
+ // Missing region is a 400
+ t.Run("well-known_assetlinksjson_missing_region", func(t *testing.T) {
+ t.Parallel()
+
+ req, err := http.NewRequest("GET", srv.URL+"/.well-known/assetlinks.json", nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ resp, err := client.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer resp.Body.Close()
+
+ if got, want := resp.StatusCode, 404; got != want {
+ body, err := ioutil.ReadAll(resp.Body)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Errorf("expected %d to be %d: %s", got, want, body)
+ }
+ })
+
+ // Unregistered region is a 400
+ t.Run("well-known_assetlinksjson_invalid_region", func(t *testing.T) {
+ t.Parallel()
+
+ req, err := http.NewRequest("GET", srv.URL+"/.well-known/assetlinks.json", nil)
+ req.Host = "not-real"
+ if err != nil {
+ t.Fatal(err)
+ }
+ resp, err := client.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer resp.Body.Close()
+
+ if got, want := resp.StatusCode, 404; got != want {
+ body, err := ioutil.ReadAll(resp.Body)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Errorf("expected %d to be %d: %s", got, want, body)
+ }
+ })
+
+ // Region in config that maps to a non-existent realm. In this test, "bad" is
+ // in the config and points to a region code that isn't registered.
+ t.Run("well-known_assetlinksjson_misconfigured", func(t *testing.T) {
+ t.Parallel()
+
+ req, err := http.NewRequest("GET", srv.URL+"/.well-known/assetlinks.json", nil)
+ req.Host = "bad"
+ if err != nil {
+ t.Fatal(err)
+ }
+ resp, err := client.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer resp.Body.Close()
+
+ if got, want := resp.StatusCode, 404; got != want {
+ body, err := ioutil.ReadAll(resp.Body)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Errorf("expected %d to be %d: %s", got, want, body)
+ }
+ })
+
+ // Valid region with no apps is 404
+ t.Run("well-known_assetlinksjson_no_data", func(t *testing.T) {
+ t.Parallel()
+
+ req, err := http.NewRequest("GET", srv.URL+"/.well-known/assetlinks.json", nil)
+ req.Host = "empty"
+ if err != nil {
+ t.Fatal(err)
+ }
+ resp, err := client.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer resp.Body.Close()
+
+ if got, want := resp.StatusCode, 404; got != want {
+ body, err := ioutil.ReadAll(resp.Body)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Errorf("expected %d to be %d: %s", got, want, body)
+ }
+ })
+
+ // Valid region with apps is 200
+ t.Run("well-known_assetlinksjson_result", func(t *testing.T) {
+ t.Parallel()
+
+ req, err := http.NewRequest("GET", srv.URL+"/.well-known/assetlinks.json", nil)
+ req.Host = "okay"
+ if err != nil {
+ t.Fatal(err)
+ }
+ resp, err := client.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer resp.Body.Close()
+
+ if got, want := resp.StatusCode, 200; got != want {
+ body, err := ioutil.ReadAll(resp.Body)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Errorf("expected %d to be %d: %s", got, want, body)
+ }
+ })
+
+}
diff --git a/pkg/controller/associated/ios.go b/pkg/controller/associated/ios.go
index 654ac4942..01d007250 100644
--- a/pkg/controller/associated/ios.go
+++ b/pkg/controller/associated/ios.go
@@ -18,8 +18,6 @@ package associated
// https://developer.apple.com/documentation/safariservices/supporting_associated_domains
import (
- "fmt"
-
"github.com/google/exposure-notifications-verification-server/pkg/database"
)
@@ -60,13 +58,8 @@ func (c *Controller) getAppIds(realmID uint) ([]string, error) {
}
// getIosData gets the iOS app data.
-func (c *Controller) getIosData(region string) (*IOSData, error) {
- realm, err := c.db.FindRealmByRegion(region)
- if err != nil {
- return nil, fmt.Errorf("unable to lookup realm: %w", err)
- }
-
- ids, err := c.getAppIds(realm.ID)
+func (c *Controller) getIosData(realmID uint) (*IOSData, error) {
+ ids, err := c.getAppIds(realmID)
if err != nil {
return nil, err
}
diff --git a/pkg/controller/associated/ios_test.go b/pkg/controller/associated/ios_test.go
new file mode 100644
index 000000000..049d42bd8
--- /dev/null
+++ b/pkg/controller/associated/ios_test.go
@@ -0,0 +1,138 @@
+// 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 associated
+
+import (
+ "sort"
+ "testing"
+
+ "github.com/google/exposure-notifications-verification-server/pkg/database"
+)
+
+func TestGetIosData(t *testing.T) {
+ t.Parallel()
+
+ t.Run("no_active_apps", func(t *testing.T) {
+ t.Parallel()
+
+ db, _ := testDatabaseInstance.NewDatabase(t, nil)
+
+ realm, err := db.FindRealm(1)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ c := &Controller{db: db}
+ data, err := c.getIosData(realm.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if data != nil {
+ t.Errorf("expected data to be nil, got %#v", data)
+ }
+ })
+
+ t.Run("active_apps", func(t *testing.T) {
+ t.Parallel()
+
+ db, _ := testDatabaseInstance.NewDatabase(t, nil)
+
+ realm, err := db.FindRealm(1)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // iOS app1
+ app1 := &database.MobileApp{
+ Name: "app1",
+ RealmID: realm.ID,
+ URL: "https://app1.example.com/",
+ OS: database.OSTypeIOS,
+ AppID: "com.example.app1",
+ }
+ if err := db.SaveMobileApp(app1, database.SystemTest); err != nil {
+ t.Fatal(err)
+ }
+
+ // iOS app2
+ app2 := &database.MobileApp{
+ Name: "app2",
+ RealmID: realm.ID,
+ URL: "https://app2.example.com/",
+ OS: database.OSTypeIOS,
+ AppID: "com.example.app2",
+ }
+ if err := db.SaveMobileApp(app2, database.SystemTest); err != nil {
+ t.Fatal(err)
+ }
+
+ // Android app3, not IOS
+ app3 := &database.MobileApp{
+ Name: "app3",
+ RealmID: realm.ID,
+ URL: "https://app3.example.com/",
+ OS: database.OSTypeAndroid,
+ AppID: "com.example.app3",
+ SHA: "AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA",
+ }
+ if err := db.SaveMobileApp(app3, database.SystemTest); err != nil {
+ t.Fatal(err)
+ }
+
+ // iOS app4, not in realm
+ app4 := &database.MobileApp{
+ Name: "app4",
+ RealmID: realm.ID + 1, // Not this realm
+ URL: "https://app4.example.com/",
+ OS: database.OSTypeIOS,
+ AppID: "com.example.app4",
+ }
+ if err := db.SaveMobileApp(app4, database.SystemTest); err != nil {
+ t.Fatal(err)
+ }
+
+ c := &Controller{db: db}
+ data, err := c.getIosData(realm.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // Ensure only the 2 actual apps that are ios and of this realm were
+ // included in the results.
+ details := data.Applinks.Details
+ if got, want := len(details), 2; got != want {
+ t.Errorf("expected len(details) to be %d, got %d: %v", want, got, details)
+ }
+
+ sort.Slice(details, func(i, j int) bool {
+ return details[i].AppID < details[j].AppID
+ })
+
+ if got, want := details[0].AppID, app1.AppID; got != want {
+ t.Errorf("expected %q to be %q", got, want)
+ }
+ if got, want := details[1].AppID, app2.AppID; got != want {
+ t.Errorf("expected %q to be %q", got, want)
+ }
+
+ // Apps should exist, but be empty (Apple requirement)
+ if data.Applinks.Apps == nil {
+ t.Fatalf("Applinks.Apps should not be nil")
+ }
+ if len(data.Applinks.Apps) != 0 {
+ t.Errorf("AppLinks.Apps should be empty: %v", data.Applinks.Apps)
+ }
+ })
+}
diff --git a/pkg/controller/redirect/helpers.go b/pkg/controller/redirect/helpers.go
new file mode 100644
index 000000000..fc7f81133
--- /dev/null
+++ b/pkg/controller/redirect/helpers.go
@@ -0,0 +1,95 @@
+// 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 redirect
+
+import (
+ "net/url"
+ "strings"
+)
+
+// isAndroid determines if a User-Agent is a Android device.
+func isAndroid(userAgent string) bool {
+ return strings.Contains(strings.ToLower(userAgent), "android")
+}
+
+// isIOS determines if a User-Agent is an iOS EN device.
+func isIOS(userAgent string) bool {
+ return strings.Contains(strings.ToLower(userAgent), "iphone")
+}
+
+// decideRedirect selects where to redirect based on several signals.
+func decideRedirect(region, userAgent string, url url.URL, appStoreData AppStoreData) (string, bool) {
+ // Canonicalize path as lowercase.
+ path := strings.ToLower(url.Path)
+
+ // Check for browser type.
+ onAndroid := isAndroid(userAgent)
+ onIOS := isIOS(userAgent)
+
+ // On Android redirect to Play Store if App Link doesn't trigger
+ // and an a link is set up.
+ if onAndroid && appStoreData.AndroidURL != "" && appStoreData.AndroidAppID != "" {
+ return buildIntentURL(path, url.Query(), region, appStoreData.AndroidAppID, appStoreData.AndroidURL), true
+ }
+
+ // On iOS redirect to App Store if App Link doesn't trigger
+ // and an a link is set up.
+ if onIOS && appStoreData.IOSURL != "" {
+ return appStoreData.IOSURL, true
+ }
+
+ if onIOS || onAndroid {
+ return buildEnsURL(path, url.Query(), region), true
+ }
+
+ return "", false
+}
+
+// buildEnsURL returns the ens:// URL for the given path, query, and region.
+func buildEnsURL(path string, query url.Values, region string) string {
+ u := &url.URL{
+ Scheme: "ens",
+ Path: strings.TrimPrefix(path, "/"),
+ }
+ u.RawQuery = query.Encode()
+ q := u.Query()
+ q.Set("r", region)
+ u.RawQuery = q.Encode()
+
+ return u.String()
+}
+
+// buildIntentURL returns the ens:// URL with fallback
+// for the given path, query, and region.
+func buildIntentURL(path string, query url.Values, region, appID, fallback string) string {
+ u := &url.URL{
+ Scheme: "intent",
+ Path: strings.TrimPrefix(path, "/"),
+ }
+ u.RawQuery = query.Encode()
+ q := u.Query()
+ q.Set("r", region)
+ u.RawQuery = q.Encode()
+
+ suffix := "#Intent"
+ suffix += ";scheme=ens"
+ suffix += ";package=" + appID
+ suffix += ";action=android.intent.action.VIEW"
+ suffix += ";category=android.intent.category.BROWSABLE"
+ suffix += ";S.browser_fallback_url=" + url.QueryEscape(fallback)
+ suffix += ";end"
+
+ return u.String() + suffix
+}
diff --git a/pkg/controller/redirect/helpers_test.go b/pkg/controller/redirect/helpers_test.go
new file mode 100644
index 000000000..9edbcdc55
--- /dev/null
+++ b/pkg/controller/redirect/helpers_test.go
@@ -0,0 +1,361 @@
+// 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 redirect
+
+import (
+ "net/url"
+ "testing"
+)
+
+func TestBuildEnsURL(t *testing.T) {
+ t.Parallel()
+
+ cases := []struct {
+ name string
+ path string
+ query url.Values
+ region string
+ exp string
+ }{
+ {
+ name: "leading_slash_region",
+ path: "v",
+ region: "/US-AA",
+ exp: "ens://v?r=%2FUS-AA",
+ },
+ {
+ name: "trailing_slash_region",
+ path: "v",
+ region: "US-AA/",
+ exp: "ens://v?r=US-AA%2F",
+ },
+ {
+ name: "leading_slash_path",
+ path: "/v",
+ region: "US-AA",
+ exp: "ens://v?r=US-AA",
+ },
+ {
+ name: "trailing_slash_path",
+ path: "v/",
+ region: "US-AA",
+ exp: "ens://v/?r=US-AA",
+ },
+ {
+ name: "includes_code",
+ path: "v",
+ query: url.Values{"c": []string{"1234567890abcdef"}},
+ region: "US-AA",
+ exp: "ens://v?c=1234567890abcdef&r=US-AA",
+ },
+ {
+ name: "includes_other",
+ path: "v",
+ query: url.Values{"foo": []string{"bar"}},
+ region: "US-AA",
+ exp: "ens://v?foo=bar&r=US-AA",
+ },
+ {
+ name: "replace_region",
+ path: "v",
+ query: url.Values{"r": []string{"US-XX"}},
+ region: "US-AA",
+ exp: "ens://v?r=US-AA",
+ },
+ {
+ name: "replace_just_region",
+ path: "v",
+ query: url.Values{"c": []string{"12345678"}, "r": []string{"DE"}},
+ region: "US-BB",
+ exp: "ens://v?c=12345678&r=US-BB",
+ },
+ }
+
+ for _, tc := range cases {
+ tc := tc
+
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+
+ got, want := buildEnsURL(tc.path, tc.query, tc.region), tc.exp
+ if got != want {
+ t.Errorf("expected %q to be %q", got, want)
+ }
+ })
+ }
+}
+
+func TestBuildIntentURL(t *testing.T) {
+ t.Parallel()
+
+ expectedSuffix := "#Intent" +
+ ";scheme=ens" +
+ ";package=gov.moosylvania.app" +
+ ";action=android.intent.action.VIEW" +
+ ";category=android.intent.category.BROWSABLE" +
+ ";S.browser_fallback_url=https%3A%2F%2Fplay.google.com%2Fstore%2Fapps%2Fdetails%3Fid%3Dgov.moosylvania.app" +
+ ";end"
+ cases := []struct {
+ name string
+ path string
+ query url.Values
+ region string
+ exp string
+ }{
+ {
+ name: "leading_slash_region",
+ path: "v",
+ region: "/US-AA",
+ exp: "intent://v?r=%2FUS-AA" + expectedSuffix,
+ },
+ {
+ name: "trailing_slash_region",
+ path: "v",
+ region: "US-AA/",
+ exp: "intent://v?r=US-AA%2F" + expectedSuffix,
+ },
+ {
+ name: "leading_slash_path",
+ path: "/v",
+ region: "US-AA",
+ exp: "intent://v?r=US-AA" + expectedSuffix,
+ },
+ {
+ name: "trailing_slash_path",
+ path: "v/",
+ region: "US-AA",
+ exp: "intent://v/?r=US-AA" + expectedSuffix,
+ },
+ {
+ name: "includes_code",
+ path: "v",
+ query: url.Values{"c": []string{"1234567890abcdef"}},
+ region: "US-AA",
+ exp: "intent://v?c=1234567890abcdef&r=US-AA" + expectedSuffix,
+ },
+ {
+ name: "includes_other",
+ path: "v",
+ query: url.Values{"foo": []string{"bar"}},
+ region: "US-AA",
+ exp: "intent://v?foo=bar&r=US-AA" + expectedSuffix,
+ },
+ {
+ name: "replace_region",
+ path: "v",
+ query: url.Values{"r": []string{"US-XX"}},
+ region: "US-AA",
+ exp: "intent://v?r=US-AA" + expectedSuffix,
+ },
+ }
+
+ for _, tc := range cases {
+ tc := tc
+
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+
+ appID := "gov.moosylvania.app"
+ fallback := "https://play.google.com/store/apps/details?id=gov.moosylvania.app"
+ got, want := buildIntentURL(tc.path, tc.query, tc.region, appID, fallback), tc.exp
+ if got != want {
+ t.Errorf("expected %q to be %q", got, want)
+ }
+ })
+ }
+}
+
+func TestAgentDetection(t *testing.T) {
+ t.Parallel()
+
+ cases := []struct {
+ name string
+ userAgent string
+ android bool
+ ios bool
+ }{
+ {
+ name: "android_chrome",
+ userAgent: "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 6P Build/MMB29P) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/47.0.2526.83 Mobile Safari/537.36",
+ android: true,
+ ios: false,
+ },
+ {
+ name: "android_webview",
+ userAgent: "Mozilla/5.0 (Linux; U; Android 2.2.1; en-us; Nexus One Build/FRG83) AppleWebKit/533.1 (KHTML, like Gecko) Version/4.0 Mobile Safari/533.1",
+ android: true,
+ ios: false,
+ },
+ {
+ name: "iphone_safari",
+ userAgent: "Mozilla/5.0 (iPhone; CPU iPhone OS 13_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.0.5 Mobile/15E148 Safari/604.1",
+ android: false,
+ ios: true,
+ },
+ {
+ name: "iphone_safari",
+ userAgent: "Mozilla/5.0 (iPhone; CPU iPhone OS 13_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.0.5 Mobile/15E148 Safari/604.1",
+ android: false,
+ ios: true,
+ },
+ {
+ name: "iphone_chrome",
+ userAgent: "Mozilla/5.0 (iPhone; CPU iPhone OS 10_3 like Mac OS X) AppleWebKit/602.1.50 (KHTML, like Gecko) CriOS/56.0.2924.75 Mobile/14E5239e Safari/602.1",
+ android: false,
+ ios: true,
+ },
+ {
+ name: "windows_chrome",
+ userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/69.0.3497.100 Safari/537.36",
+ android: false,
+ ios: false,
+ },
+ {
+ name: "ipad_safari",
+ userAgent: "Mozilla/5.0 (iPad; CPU OS 11_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/11.0 Mobile/15E148 Safari/604.1",
+ android: false,
+ // For ENX purposes exclude iPad as it's unsupported.
+ ios: false,
+ },
+ }
+
+ for _, tc := range cases {
+ tc := tc
+
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+ onAndroid := isAndroid(tc.userAgent)
+ onIOS := isIOS(tc.userAgent)
+ if onAndroid != tc.android || onIOS != tc.ios {
+ t.Errorf("expected android=%t ios=%t, got android=%t ios=%t", tc.android, tc.ios, onAndroid, onIOS)
+ }
+ })
+ }
+}
+
+func TestDecideRedirect(t *testing.T) {
+ t.Parallel()
+
+ expectedSuffix := "#Intent" +
+ ";scheme=ens" +
+ ";package=gov.moosylvania.app" +
+ ";action=android.intent.action.VIEW" +
+ ";category=android.intent.category.BROWSABLE" +
+ ";S.browser_fallback_url=https%3A%2F%2Fandroid.example.com%2Fstore%2Fmoosylvania" +
+ ";end"
+
+ appLinkBoth := AppStoreData{
+ AndroidURL: "https://android.example.com/store/moosylvania",
+ AndroidAppID: "gov.moosylvania.app",
+ IOSURL: "https://ios.example.com/store/moosylvania",
+ }
+ appLinkOnlyAndroid := AppStoreData{
+ AndroidURL: "https://android.example.com/store/moosylvania",
+ AndroidAppID: "gov.moosylvania.app",
+ IOSURL: "",
+ }
+ appLinkNeither := AppStoreData{
+ AndroidURL: "",
+ AndroidAppID: "",
+ IOSURL: "",
+ }
+
+ userAgentAndroid := "Android"
+ userAgentIOS := "iPhone"
+ userAgentNeither := "Neither"
+
+ relativePinURL := url.URL{
+ Path: "/v",
+ }
+ q := relativePinURL.Query()
+ q.Set("c", "1234567890abcdef")
+ relativePinURL.RawQuery = q.Encode()
+
+ cases := []struct {
+ name string
+ host string
+ url string
+ altURL *url.URL
+ userAgent string
+ appStoreData *AppStoreData
+ expected string
+ }{
+ {
+ name: "moosylvania_android_both",
+ url: "https://moosylvania.gov/v?c=1234567890abcdef",
+ userAgent: userAgentAndroid,
+ appStoreData: &appLinkBoth,
+ expected: "intent://v?c=1234567890abcdef&r=US-MOO" + expectedSuffix,
+ },
+ {
+ name: "moosylvania_android_both_relative",
+ altURL: &relativePinURL,
+ userAgent: userAgentAndroid,
+ appStoreData: &appLinkBoth,
+ expected: "intent://v?c=1234567890abcdef&r=US-MOO" + expectedSuffix,
+ },
+ {
+ name: "moosylvania_android_no_applink",
+ url: "https://moosylvania.gov/v?c=1234567890abcdef",
+ userAgent: userAgentAndroid,
+ appStoreData: &appLinkNeither,
+ expected: "ens://v?c=1234567890abcdef&r=US-MOO",
+ },
+ {
+ name: "moosylvania_ios_no_applink",
+ url: "https://moosylvania.gov/v?c=1234567890abcdef",
+ userAgent: userAgentIOS,
+ appStoreData: &appLinkOnlyAndroid,
+ expected: "ens://v?c=1234567890abcdef&r=US-MOO",
+ },
+ {
+ name: "moosylvania_ios_no_applink",
+ url: "https://moosylvania.gov/v?c=1234567890abcdef",
+ userAgent: userAgentIOS,
+ appStoreData: &appLinkBoth,
+ expected: "https://ios.example.com/store/moosylvania",
+ },
+ {
+ name: "moosylvania_windows",
+ url: "https://moosylvania.gov/v?c=1234567890abcdef",
+ userAgent: userAgentNeither,
+ appStoreData: &appLinkOnlyAndroid,
+ expected: "",
+ },
+ }
+
+ for _, tc := range cases {
+ tc := tc
+
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+ url := tc.altURL
+ if url == nil {
+ otherURL, err := url.Parse(tc.url)
+ if err != nil {
+ t.Errorf("invalid url %s", tc.url)
+ }
+ url = otherURL
+ }
+ result, success := decideRedirect("US-MOO", tc.userAgent, *url, *tc.appStoreData)
+ if tc.expected != result {
+ t.Errorf("expected %s, got %s", tc.expected, result)
+ }
+ if (tc.expected != "") != success {
+ t.Errorf("expected doesn't match success")
+ }
+ })
+ }
+}
diff --git a/pkg/controller/redirect/index.go b/pkg/controller/redirect/index.go
index 864790579..e8c0103ab 100644
--- a/pkg/controller/redirect/index.go
+++ b/pkg/controller/redirect/index.go
@@ -16,7 +16,6 @@ package redirect
import (
"net/http"
- "net/url"
"strings"
"github.com/google/exposure-notifications-server/pkg/logging"
@@ -44,6 +43,11 @@ func (c *Controller) HandleIndex() http.Handler {
break
}
}
+ if hostRegion == "" {
+ controller.NotFound(w, r, c.h)
+ return
+ }
+
realm, err := c.db.FindRealmByRegion(hostRegion)
if err != nil {
if database.IsNotFound(err) {
@@ -56,107 +60,34 @@ func (c *Controller) HandleIndex() http.Handler {
}
// Get App Store Data.
- var appStoreData AppStoreData
+ var data AppStoreData
cacheKey := &cache.Key{
Namespace: "apps:appstoredata:by_region",
Key: hostRegion,
}
- if err := c.cacher.Fetch(ctx, cacheKey, &appStoreData, c.config.AppCacheTTL, func() (interface{}, error) {
+ if err := c.cacher.Fetch(ctx, cacheKey, &data, c.config.AppCacheTTL, func() (interface{}, error) {
logger.Debug("fetching new app store data")
return c.getAppStoreData(realm.ID)
}); err != nil {
+ if database.IsNotFound(err) {
+ controller.NotFound(w, r, c.h)
+ return
+ }
+
controller.InternalError(w, r, c.h, err)
return
}
- if sendto, success := decideRedirect(hostRegion, r.UserAgent(), *r.URL, appStoreData); success {
+ if sendto, success := decideRedirect(hostRegion, r.UserAgent(), *r.URL, data); success {
http.Redirect(w, r, sendto, http.StatusSeeOther)
return
}
- logger.Warnw("not a mobile user agent", "host", r.Host, "userAgent", r.UserAgent())
- m := controller.TemplateMapFromContext(ctx)
- m.Title("Redirecting...")
- m["requestURI"] = (&url.URL{
- Scheme: "https",
- Host: r.Host,
- Path: strings.TrimPrefix(r.URL.RequestURI(), "/"),
- }).String()
- c.h.RenderHTMLStatus(w, http.StatusNotFound, "404", m)
- })
-}
-
-// isAndroid determines if a User-Agent is a Android device.
-func isAndroid(userAgent string) bool {
- return strings.Contains(strings.ToLower(userAgent), "android")
-}
-
-// isIOS determines if a User-Agent is an iOS EN device.
-func isIOS(userAgent string) bool {
- return strings.Contains(strings.ToLower(userAgent), "iphone")
-}
+ logger.Infow("not a mobile user agent",
+ "host", r.Host,
+ "userAgent", r.UserAgent())
-// decideRedirect selects where to redirect based on several signals.
-func decideRedirect(region, userAgent string, url url.URL, appStoreData AppStoreData) (string, bool) {
- // Canonicalize path as lowercase.
- path := strings.ToLower(url.Path)
-
- // Check for browser type.
- onAndroid := isAndroid(userAgent)
- onIOS := isIOS(userAgent)
-
- // On Android redirect to Play Store if App Link doesn't trigger
- // and an a link is set up.
- if onAndroid && appStoreData.AndroidURL != "" && appStoreData.AndroidAppID != "" {
- return buildIntentURL(path, url.Query(), region, appStoreData.AndroidAppID, appStoreData.AndroidURL), true
- }
-
- // On iOS redirect to App Store if App Link doesn't trigger
- // and an a link is set up.
- if onIOS && appStoreData.IOSURL != "" {
- return appStoreData.IOSURL, true
- }
-
- if onIOS || onAndroid {
- return buildEnsURL(path, url.Query(), region), true
- }
-
- return "", false
-}
-
-// buildEnsURL returns the ens:// URL for the given path, query, and region.
-func buildEnsURL(path string, query url.Values, region string) string {
- u := &url.URL{
- Scheme: "ens",
- Path: strings.TrimPrefix(path, "/"),
- }
- u.RawQuery = query.Encode()
- q := u.Query()
- q.Set("r", region)
- u.RawQuery = q.Encode()
-
- return u.String()
-}
-
-// buildIntentURL returns the ens:// URL with fallback
-// for the given path, query, and region.
-func buildIntentURL(path string, query url.Values, region, appID, fallback string) string {
- u := &url.URL{
- Scheme: "intent",
- Path: strings.TrimPrefix(path, "/"),
- }
- u.RawQuery = query.Encode()
- q := u.Query()
- q.Set("r", region)
- u.RawQuery = q.Encode()
-
- suffix := "#Intent"
- suffix += ";scheme=ens"
- suffix += ";package=" + appID
- suffix += ";action=android.intent.action.VIEW"
- suffix += ";category=android.intent.category.BROWSABLE"
- suffix += ";S.browser_fallback_url=" + url.QueryEscape(fallback)
- suffix += ";end"
-
- return u.String() + suffix
+ controller.NotFound(w, r, c.h)
+ return
+ })
}
diff --git a/pkg/controller/redirect/index_test.go b/pkg/controller/redirect/index_test.go
index 9edbcdc55..8a6a6f501 100644
--- a/pkg/controller/redirect/index_test.go
+++ b/pkg/controller/redirect/index_test.go
@@ -12,350 +12,247 @@
// See the License for the specific language governing permissions and
// limitations under the License.
-package redirect
+package redirect_test
import (
- "net/url"
+ "context"
+ "io/ioutil"
+ "net/http"
+ "net/http/httptest"
+ "path/filepath"
"testing"
+
+ "github.com/google/exposure-notifications-verification-server/internal/project"
+ "github.com/google/exposure-notifications-verification-server/internal/routes"
+ "github.com/google/exposure-notifications-verification-server/pkg/cache"
+ "github.com/google/exposure-notifications-verification-server/pkg/config"
+ "github.com/google/exposure-notifications-verification-server/pkg/database"
)
-func TestBuildEnsURL(t *testing.T) {
+func TestIndex(t *testing.T) {
t.Parallel()
- cases := []struct {
- name string
- path string
- query url.Values
- region string
- exp string
- }{
- {
- name: "leading_slash_region",
- path: "v",
- region: "/US-AA",
- exp: "ens://v?r=%2FUS-AA",
- },
- {
- name: "trailing_slash_region",
- path: "v",
- region: "US-AA/",
- exp: "ens://v?r=US-AA%2F",
- },
- {
- name: "leading_slash_path",
- path: "/v",
- region: "US-AA",
- exp: "ens://v?r=US-AA",
- },
- {
- name: "trailing_slash_path",
- path: "v/",
- region: "US-AA",
- exp: "ens://v/?r=US-AA",
- },
- {
- name: "includes_code",
- path: "v",
- query: url.Values{"c": []string{"1234567890abcdef"}},
- region: "US-AA",
- exp: "ens://v?c=1234567890abcdef&r=US-AA",
- },
- {
- name: "includes_other",
- path: "v",
- query: url.Values{"foo": []string{"bar"}},
- region: "US-AA",
- exp: "ens://v?foo=bar&r=US-AA",
- },
- {
- name: "replace_region",
- path: "v",
- query: url.Values{"r": []string{"US-XX"}},
- region: "US-AA",
- exp: "ens://v?r=US-AA",
- },
- {
- name: "replace_just_region",
- path: "v",
- query: url.Values{"c": []string{"12345678"}, "r": []string{"DE"}},
- region: "US-BB",
- exp: "ens://v?c=12345678&r=US-BB",
- },
- }
+ ctx := context.Background()
- for _, tc := range cases {
- tc := tc
+ // Create cacher.
+ cacher, err := cache.NewInMemory(nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() {
+ if err := cacher.Close(); err != nil {
+ t.Fatal(err)
+ }
+ })
- t.Run(tc.name, func(t *testing.T) {
- t.Parallel()
+ // Create database.
+ testDatabaseInstance := database.MustTestInstance()
+ t.Cleanup(func() {
+ if err := testDatabaseInstance.Close(); err != nil {
+ t.Fatal(err)
+ }
+ })
+ db, _ := testDatabaseInstance.NewDatabase(t, cacher)
- got, want := buildEnsURL(tc.path, tc.query, tc.region), tc.exp
- if got != want {
- t.Errorf("expected %q to be %q", got, want)
- }
- })
+ // Create config.
+ cfg := &config.RedirectConfig{
+ AssetsPath: filepath.Join(project.Root(), "cmd", "enx-redirect", "assets"),
+ DevMode: true,
+ HostnameConfig: map[string]string{
+ "bad": "nope",
+ "okay": "bb",
+ },
}
-}
-func TestBuildIntentURL(t *testing.T) {
- t.Parallel()
+ // Set realm to resolve.
+ realm1, err := db.FindRealm(1)
+ if err != nil {
+ t.Fatal(err)
+ }
+ realm1.RegionCode = "aa"
+ if err := db.SaveRealm(realm1, database.SystemTest); err != nil {
+ t.Fatal(err)
+ }
- expectedSuffix := "#Intent" +
- ";scheme=ens" +
- ";package=gov.moosylvania.app" +
- ";action=android.intent.action.VIEW" +
- ";category=android.intent.category.BROWSABLE" +
- ";S.browser_fallback_url=https%3A%2F%2Fplay.google.com%2Fstore%2Fapps%2Fdetails%3Fid%3Dgov.moosylvania.app" +
- ";end"
- cases := []struct {
- name string
- path string
- query url.Values
- region string
- exp string
- }{
- {
- name: "leading_slash_region",
- path: "v",
- region: "/US-AA",
- exp: "intent://v?r=%2FUS-AA" + expectedSuffix,
- },
- {
- name: "trailing_slash_region",
- path: "v",
- region: "US-AA/",
- exp: "intent://v?r=US-AA%2F" + expectedSuffix,
- },
- {
- name: "leading_slash_path",
- path: "/v",
- region: "US-AA",
- exp: "intent://v?r=US-AA" + expectedSuffix,
- },
- {
- name: "trailing_slash_path",
- path: "v/",
- region: "US-AA",
- exp: "intent://v/?r=US-AA" + expectedSuffix,
- },
- {
- name: "includes_code",
- path: "v",
- query: url.Values{"c": []string{"1234567890abcdef"}},
- region: "US-AA",
- exp: "intent://v?c=1234567890abcdef&r=US-AA" + expectedSuffix,
- },
- {
- name: "includes_other",
- path: "v",
- query: url.Values{"foo": []string{"bar"}},
- region: "US-AA",
- exp: "intent://v?foo=bar&r=US-AA" + expectedSuffix,
- },
- {
- name: "replace_region",
- path: "v",
- query: url.Values{"r": []string{"US-XX"}},
- region: "US-AA",
- exp: "intent://v?r=US-AA" + expectedSuffix,
- },
+ // Create another realm with apps.
+ realm2 := database.NewRealmWithDefaults("okay")
+ realm2.RegionCode = "bb"
+ if err := db.SaveRealm(realm2, database.SystemTest); err != nil {
+ t.Fatal(err)
}
- for _, tc := range cases {
- tc := tc
+ // Create iOS app
+ iosApp := &database.MobileApp{
+ Name: "app1",
+ RealmID: realm2.ID,
+ URL: "https://app1.example.com/",
+ OS: database.OSTypeIOS,
+ AppID: "com.example.app1",
+ }
+ if err := db.SaveMobileApp(iosApp, database.SystemTest); err != nil {
+ t.Fatal(err)
+ }
- t.Run(tc.name, func(t *testing.T) {
- t.Parallel()
+ // Create Android app
+ app2 := &database.MobileApp{
+ Name: "app2",
+ RealmID: realm2.ID,
+ URL: "https://app2.example.com/",
+ OS: database.OSTypeAndroid,
+ AppID: "com.example.app2",
+ SHA: "AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA",
+ }
+ if err := db.SaveMobileApp(app2, database.SystemTest); err != nil {
+ t.Fatal(err)
+ }
- appID := "gov.moosylvania.app"
- fallback := "https://play.google.com/store/apps/details?id=gov.moosylvania.app"
- got, want := buildIntentURL(tc.path, tc.query, tc.region, appID, fallback), tc.exp
- if got != want {
- t.Errorf("expected %q to be %q", got, want)
- }
- })
+ // Build routes.
+ mux, err := routes.ENXRedirect(ctx, cfg, db, cacher)
+ if err != nil {
+ t.Fatal(err)
}
-}
-func TestAgentDetection(t *testing.T) {
- t.Parallel()
+ // Start server.
+ srv := httptest.NewServer(mux)
+ t.Cleanup(func() {
+ srv.Close()
+ })
+ client := srv.Client()
- cases := []struct {
- name string
- userAgent string
- android bool
- ios bool
- }{
- {
- name: "android_chrome",
- userAgent: "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 6P Build/MMB29P) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/47.0.2526.83 Mobile Safari/537.36",
- android: true,
- ios: false,
- },
- {
- name: "android_webview",
- userAgent: "Mozilla/5.0 (Linux; U; Android 2.2.1; en-us; Nexus One Build/FRG83) AppleWebKit/533.1 (KHTML, like Gecko) Version/4.0 Mobile Safari/533.1",
- android: true,
- ios: false,
- },
- {
- name: "iphone_safari",
- userAgent: "Mozilla/5.0 (iPhone; CPU iPhone OS 13_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.0.5 Mobile/15E148 Safari/604.1",
- android: false,
- ios: true,
- },
- {
- name: "iphone_safari",
- userAgent: "Mozilla/5.0 (iPhone; CPU iPhone OS 13_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.0.5 Mobile/15E148 Safari/604.1",
- android: false,
- ios: true,
- },
- {
- name: "iphone_chrome",
- userAgent: "Mozilla/5.0 (iPhone; CPU iPhone OS 10_3 like Mac OS X) AppleWebKit/602.1.50 (KHTML, like Gecko) CriOS/56.0.2924.75 Mobile/14E5239e Safari/602.1",
- android: false,
- ios: true,
- },
- {
- name: "windows_chrome",
- userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/69.0.3497.100 Safari/537.36",
- android: false,
- ios: false,
- },
- {
- name: "ipad_safari",
- userAgent: "Mozilla/5.0 (iPad; CPU OS 11_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/11.0 Mobile/15E148 Safari/604.1",
- android: false,
- // For ENX purposes exclude iPad as it's unsupported.
- ios: false,
- },
+ // Don't follow redirects.
+ client.CheckRedirect = func(r *http.Request, via []*http.Request) error {
+ return http.ErrUseLastResponse
}
- for _, tc := range cases {
- tc := tc
+ // No matching region returns a 404
+ t.Run("no_matching_region", func(t *testing.T) {
+ t.Parallel()
- t.Run(tc.name, func(t *testing.T) {
- t.Parallel()
- onAndroid := isAndroid(tc.userAgent)
- onIOS := isIOS(tc.userAgent)
- if onAndroid != tc.android || onIOS != tc.ios {
- t.Errorf("expected android=%t ios=%t, got android=%t ios=%t", tc.android, tc.ios, onAndroid, onIOS)
+ req, err := http.NewRequest("GET", srv.URL, nil)
+ req.Host = "not-real"
+ if err != nil {
+ t.Fatal(err)
+ }
+ resp, err := client.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer resp.Body.Close()
+
+ if got, want := resp.StatusCode, 404; got != want {
+ body, err := ioutil.ReadAll(resp.Body)
+ if err != nil {
+ t.Fatal(err)
}
- })
- }
-}
+ t.Errorf("expected %d to be %d: %s", got, want, body)
+ }
+ })
-func TestDecideRedirect(t *testing.T) {
- t.Parallel()
+ // A matching region that doesn't point to a realm returns 404
+ t.Run("matching_region_no_realm", func(t *testing.T) {
+ t.Parallel()
- expectedSuffix := "#Intent" +
- ";scheme=ens" +
- ";package=gov.moosylvania.app" +
- ";action=android.intent.action.VIEW" +
- ";category=android.intent.category.BROWSABLE" +
- ";S.browser_fallback_url=https%3A%2F%2Fandroid.example.com%2Fstore%2Fmoosylvania" +
- ";end"
-
- appLinkBoth := AppStoreData{
- AndroidURL: "https://android.example.com/store/moosylvania",
- AndroidAppID: "gov.moosylvania.app",
- IOSURL: "https://ios.example.com/store/moosylvania",
- }
- appLinkOnlyAndroid := AppStoreData{
- AndroidURL: "https://android.example.com/store/moosylvania",
- AndroidAppID: "gov.moosylvania.app",
- IOSURL: "",
- }
- appLinkNeither := AppStoreData{
- AndroidURL: "",
- AndroidAppID: "",
- IOSURL: "",
- }
+ req, err := http.NewRequest("GET", srv.URL, nil)
+ req.Host = "bad"
+ if err != nil {
+ t.Fatal(err)
+ }
+ resp, err := client.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer resp.Body.Close()
- userAgentAndroid := "Android"
- userAgentIOS := "iPhone"
- userAgentNeither := "Neither"
+ if got, want := resp.StatusCode, 404; got != want {
+ body, err := ioutil.ReadAll(resp.Body)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Errorf("expected %d to be %d: %s", got, want, body)
+ }
+ })
- relativePinURL := url.URL{
- Path: "/v",
- }
- q := relativePinURL.Query()
- q.Set("c", "1234567890abcdef")
- relativePinURL.RawQuery = q.Encode()
-
- cases := []struct {
- name string
- host string
- url string
- altURL *url.URL
- userAgent string
- appStoreData *AppStoreData
- expected string
- }{
- {
- name: "moosylvania_android_both",
- url: "https://moosylvania.gov/v?c=1234567890abcdef",
- userAgent: userAgentAndroid,
- appStoreData: &appLinkBoth,
- expected: "intent://v?c=1234567890abcdef&r=US-MOO" + expectedSuffix,
- },
- {
- name: "moosylvania_android_both_relative",
- altURL: &relativePinURL,
- userAgent: userAgentAndroid,
- appStoreData: &appLinkBoth,
- expected: "intent://v?c=1234567890abcdef&r=US-MOO" + expectedSuffix,
- },
- {
- name: "moosylvania_android_no_applink",
- url: "https://moosylvania.gov/v?c=1234567890abcdef",
- userAgent: userAgentAndroid,
- appStoreData: &appLinkNeither,
- expected: "ens://v?c=1234567890abcdef&r=US-MOO",
- },
- {
- name: "moosylvania_ios_no_applink",
- url: "https://moosylvania.gov/v?c=1234567890abcdef",
- userAgent: userAgentIOS,
- appStoreData: &appLinkOnlyAndroid,
- expected: "ens://v?c=1234567890abcdef&r=US-MOO",
- },
- {
- name: "moosylvania_ios_no_applink",
- url: "https://moosylvania.gov/v?c=1234567890abcdef",
- userAgent: userAgentIOS,
- appStoreData: &appLinkBoth,
- expected: "https://ios.example.com/store/moosylvania",
- },
- {
- name: "moosylvania_windows",
- url: "https://moosylvania.gov/v?c=1234567890abcdef",
- userAgent: userAgentNeither,
- appStoreData: &appLinkOnlyAndroid,
- expected: "",
- },
- }
+ // Not a mobile user agent returns a 404
+ t.Run("not_mobile_user_agent", func(t *testing.T) {
+ t.Parallel()
- for _, tc := range cases {
- tc := tc
-
- t.Run(tc.name, func(t *testing.T) {
- t.Parallel()
- url := tc.altURL
- if url == nil {
- otherURL, err := url.Parse(tc.url)
- if err != nil {
- t.Errorf("invalid url %s", tc.url)
- }
- url = otherURL
+ req, err := http.NewRequest("GET", srv.URL, nil)
+ req.Host = "okay"
+ req.Header.Set("User-Agent", "bananarama")
+ if err != nil {
+ t.Fatal(err)
+ }
+ resp, err := client.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer resp.Body.Close()
+
+ if got, want := resp.StatusCode, 404; got != want {
+ body, err := ioutil.ReadAll(resp.Body)
+ if err != nil {
+ t.Fatal(err)
}
- result, success := decideRedirect("US-MOO", tc.userAgent, *url, *tc.appStoreData)
- if tc.expected != result {
- t.Errorf("expected %s, got %s", tc.expected, result)
+ t.Errorf("expected %d to be %d: %s", got, want, body)
+ }
+ })
+
+ // Android redirects
+ t.Run("android_redirect", func(t *testing.T) {
+ t.Parallel()
+
+ req, err := http.NewRequest("GET", srv.URL, nil)
+ req.Host = "okay"
+ req.Header.Set("User-Agent", "android")
+ if err != nil {
+ t.Fatal(err)
+ }
+ resp, err := client.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer resp.Body.Close()
+
+ if got, want := resp.StatusCode, 303; got != want {
+ body, err := ioutil.ReadAll(resp.Body)
+ if err != nil {
+ t.Fatal(err)
}
- if (tc.expected != "") != success {
- t.Errorf("expected doesn't match success")
+ t.Errorf("expected %d to be %d: %s", got, want, body)
+ }
+
+ exp := "intent:?r=BB#Intent;scheme=ens;package=com.example.app2;action=android.intent.action.VIEW;category=android.intent.category.BROWSABLE;S.browser_fallback_url=https%3A%2F%2Fapp2.example.com%2F;end"
+ if got, want := resp.Header.Get("Location"), exp; got != want {
+ t.Errorf("expected %q to be %q", got, want)
+ }
+ })
+
+ // iOS redirects
+ t.Run("ios_redirect", func(t *testing.T) {
+ t.Parallel()
+
+ req, err := http.NewRequest("GET", srv.URL, nil)
+ req.Host = "okay"
+ req.Header.Set("User-Agent", "iphone")
+ if err != nil {
+ t.Fatal(err)
+ }
+ resp, err := client.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer resp.Body.Close()
+
+ if got, want := resp.StatusCode, 303; got != want {
+ body, err := ioutil.ReadAll(resp.Body)
+ if err != nil {
+ t.Fatal(err)
}
- })
- }
+ t.Errorf("expected %d to be %d: %s", got, want, body)
+ }
+
+ if got, want := resp.Header.Get("Location"), "https://app1.example.com/"; got != want {
+ t.Errorf("expected %q to be %q", got, want)
+ }
+ })
}
diff --git a/pkg/controller/redirect/redirect.go b/pkg/controller/redirect/redirect.go
index 97a7b9687..ecc600940 100644
--- a/pkg/controller/redirect/redirect.go
+++ b/pkg/controller/redirect/redirect.go
@@ -19,7 +19,6 @@ import (
"context"
"fmt"
- "github.com/google/exposure-notifications-server/pkg/logging"
"github.com/google/exposure-notifications-verification-server/pkg/cache"
"github.com/google/exposure-notifications-verification-server/pkg/config"
"github.com/google/exposure-notifications-verification-server/pkg/render"
@@ -37,13 +36,10 @@ type Controller struct {
// New creates a new redirect controller.
func New(ctx context.Context, db *database.Database, config *config.RedirectConfig, cacher cache.Cacher, h render.Renderer) (*Controller, error) {
- logger := logging.FromContext(ctx).Named("redirect.New")
-
cfgMap, err := config.HostnameToRegion()
if err != nil {
return nil, fmt.Errorf("invalid config: %w", err)
}
- logger.Infow("redirect configuration", "hostnameToRegion", cfgMap)
return &Controller{
config: config,
diff --git a/pkg/database/realm.go b/pkg/database/realm.go
index 7c39a4b15..62b7769a3 100644
--- a/pkg/database/realm.go
+++ b/pkg/database/realm.go
@@ -930,8 +930,11 @@ func (r *Realm) ValidTestType(typ string) bool {
func (db *Database) FindRealmByRegion(region string) (*Realm, error) {
var realm Realm
-
- if err := db.db.Where("region_code = ?", strings.ToUpper(region)).First(&realm).Error; err != nil {
+ if err := db.db.
+ Model(&Realm{}).
+ Where("region_code = ?", strings.ToUpper(region)).
+ First(&realm).
+ Error; err != nil {
return nil, err
}
return &realm, nil
diff --git a/pkg/render/json.go b/pkg/render/json.go
index b23accf07..33ccd7c67 100644
--- a/pkg/render/json.go
+++ b/pkg/render/json.go
@@ -70,6 +70,13 @@ func (r *ProdRenderer) RenderJSON(w http.ResponseWriter, code int, data interfac
return
}
+ // If the provided value was an error, marshall accordingly.
+ if typ, ok := data.(error); ok {
+ data = map[string]string{
+ "error": typ.Error(),
+ }
+ }
+
// Acquire a renderer
b := r.rendererPool.Get().(*bytes.Buffer)
b.Reset()