Repository: google/exposure-notifications-server
Pull request #1471 (2021-03-18 21:27:37)

Add tests for admin health authorities and keys
**Release Note**

<!-- Enter your extended release note in the below block. If the PR requires
additional action from users switching to the new release, include the string
"action required". If no release note is required, write "NONE". -->

```release-note
Add tests for admin health authorities and keys
```


Patch:
diff --git a/internal/admin/handle_health_authority.go b/internal/admin/handle_health_authority.go
index 7acc643..37cf29d 100644
--- a/internal/admin/handle_health_authority.go
+++ b/internal/admin/handle_health_authority.go
@@ -45,7 +45,7 @@ func (s *Server) HandleHealthAuthoritySave() func(c *gin.Context) {
 		healthAuthority := &model.HealthAuthority{}
 		haID, err := strconv.ParseInt(c.Param("id"), 10, 64)
 		if err != nil {
-			ErrorPage(c, "Unable to parse `id` param")
+			ErrorPage(c, fmt.Sprintf("failed to parse %q as int: %v", c.Param("id"), err))
 			return
 		}
 		if haID != 0 {
@@ -68,9 +68,8 @@ func (s *Server) HandleHealthAuthoritySave() func(c *gin.Context) {
 		}
 
 		m.AddSuccess(fmt.Sprintf("Updated Health Authority '%v'", healthAuthority.Issuer))
-		m["ha"] = healthAuthority
-		m["hak"] = &model.HealthAuthorityKey{From: time.Now()} // For create form.
-		c.HTML(http.StatusOK, "healthauthority", m)
+		c.Redirect(http.StatusSeeOther, fmt.Sprintf("/healthauthority/%d", healthAuthority.ID))
+		c.Abort()
 	}
 }
 
@@ -176,7 +175,9 @@ func (s *Server) HandleHealthAuthorityKeys() func(c *gin.Context) {
 			ErrorPage(c, "invalid action")
 			return
 		}
-		c.Redirect(http.StatusFound, fmt.Sprintf("/healthauthority/%v", healthAuthority.ID))
+
+		c.Redirect(http.StatusSeeOther, fmt.Sprintf("/healthauthority/%d", healthAuthority.ID))
+		c.Abort()
 	}
 }
 
@@ -216,11 +217,11 @@ func (f *keyhealthAuthorityFormData) ThruTimestamp() (time.Time, error) {
 func (f *keyhealthAuthorityFormData) PopulateHealthAuthorityKey(hak *model.HealthAuthorityKey) error {
 	fTime, err := f.FromTimestamp()
 	if err != nil {
-		return err
+		return fmt.Errorf("invalid from timestamp: %w", err)
 	}
 	tTime, err := f.ThruTimestamp()
 	if err != nil {
-		return err
+		return fmt.Errorf("invalid thru timestamp: %w", err)
 	}
 	hak.Version = project.TrimSpaceAndNonPrintable(f.Version)
 	hak.From = fTime


Test patch:
diff --git a/internal/admin/admin_test.go b/internal/admin/admin_test.go
index b63dbd1..e4a9f07 100644
--- a/internal/admin/admin_test.go
+++ b/internal/admin/admin_test.go
@@ -170,3 +170,10 @@ func intPtr(i int) *int {
 	}
 	return &i
 }
+
+func stringPtr(s string) *string {
+	if s == "" {
+		return nil
+	}
+	return &s
+}
diff --git a/internal/admin/handle_health_authority_test.go b/internal/admin/handle_health_authority_test.go
new file mode 100644
index 0000000..30ec826
--- /dev/null
+++ b/internal/admin/handle_health_authority_test.go
@@ -0,0 +1,584 @@
+// Copyright 2021 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, softwar
+// 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 admin
+
+import (
+	"context"
+	"fmt"
+	"io"
+	"net/http"
+	"strings"
+	"testing"
+	"time"
+
+	"github.com/google/exposure-notifications-server/internal/project"
+	"github.com/google/exposure-notifications-server/internal/verification/database"
+	"github.com/google/exposure-notifications-server/internal/verification/model"
+	"github.com/google/go-cmp/cmp"
+	"github.com/google/go-cmp/cmp/cmpopts"
+)
+
+func TestRenderHealthAuthority(t *testing.T) {
+	t.Parallel()
+
+	m := TemplateMap{}
+	ha := new(model.HealthAuthority)
+	hak := new(model.HealthAuthorityKey)
+	m["ha"] = ha
+	m["hak"] = hak
+
+	testRenderTemplate(t, "healthauthority", m)
+}
+
+func TestPopulateHealthAuthority(t *testing.T) {
+	t.Parallel()
+
+	cases := []struct {
+		name string
+		form *healthAuthorityFormData
+		exp  *model.HealthAuthority
+		err  string
+	}{
+		{
+			name: "default",
+			form: &healthAuthorityFormData{
+				Issuer:         "test-iss",
+				Audience:       "test-aud",
+				Name:           "test-ha",
+				EnableStatsAPI: true,
+				JwksURI:        "https://foo.bar",
+			},
+			exp: &model.HealthAuthority{
+				Issuer:         "test-iss",
+				Audience:       "test-aud",
+				Name:           "test-ha",
+				EnableStatsAPI: true,
+				JwksURI:        stringPtr("https://foo.bar"),
+			},
+		},
+	}
+
+	for _, tc := range cases {
+		tc := tc
+
+		t.Run(tc.name, func(t *testing.T) {
+			t.Parallel()
+
+			var ha model.HealthAuthority
+			tc.form.PopulateHealthAuthority(&ha)
+
+			opts := cmp.Options{cmpopts.EquateApproxTime(5 * time.Minute)}
+			if diff := cmp.Diff(tc.exp, &ha, opts); diff != "" {
+				t.Errorf("mismatch (-want, +got):\n%s", diff)
+			}
+		})
+	}
+}
+
+func TestPopulateHealthAuthorityKey(t *testing.T) {
+	t.Parallel()
+
+	from := time.Unix(1609579380, 0).UTC()
+	thru := time.Unix(1641119640, 0).UTC()
+
+	cases := []struct {
+		name string
+		form *keyhealthAuthorityFormData
+		exp  *model.HealthAuthorityKey
+		err  string
+	}{
+		{
+			name: "default",
+			form: &keyhealthAuthorityFormData{
+				Version: "123",
+				PEMBlock: `
+-----BEGIN PUBLIC KEY-----
+MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEml59itec9qzwVojreLXdPNRsUWzf
+YHc1cKvIIi6/H56AJS/kZEYQnfDpxrgyGhdAm+pNN2GAJ3XdnQZ1Sk4amg==
+-----END PUBLIC KEY-----
+`,
+				FromDate: "2021-01-02",
+				FromTime: "09:23",
+				ThruDate: "2022-01-02",
+				ThruTime: "10:34",
+			},
+			exp: &model.HealthAuthorityKey{
+				Version: "123",
+				From:    from,
+				Thru:    thru,
+				PublicKeyPEM: `-----BEGIN PUBLIC KEY-----
+MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEml59itec9qzwVojreLXdPNRsUWzf
+YHc1cKvIIi6/H56AJS/kZEYQnfDpxrgyGhdAm+pNN2GAJ3XdnQZ1Sk4amg==
+-----END PUBLIC KEY-----`,
+			},
+		},
+		{
+			name: "bad_from",
+			form: &keyhealthAuthorityFormData{
+				FromDate: "banana",
+				FromTime: "apple",
+			},
+			err: "invalid from time",
+		},
+		{
+			name: "bad_thru",
+			form: &keyhealthAuthorityFormData{
+				ThruDate: "banana",
+				ThruTime: "apple",
+			},
+			err: "invalid thru time",
+		},
+	}
+
+	for _, tc := range cases {
+		tc := tc
+
+		t.Run(tc.name, func(t *testing.T) {
+			t.Parallel()
+
+			var key model.HealthAuthorityKey
+			err := tc.form.PopulateHealthAuthorityKey(&key)
+			if err != nil {
+				if tc.err == "" {
+					t.Fatal(err)
+				}
+				if got, want := err.Error(), tc.err; !strings.Contains(got, want) {
+					t.Errorf("expected %q to contain %q", got, want)
+				}
+			}
+
+			if tc.err == "" {
+				opts := cmp.Options{cmpopts.EquateApproxTime(5 * time.Minute)}
+				if diff := cmp.Diff(tc.exp, &key, opts); diff != "" {
+					t.Errorf("mismatch (-want, +got):\n%s", diff)
+				}
+			}
+		})
+	}
+}
+
+func TestHandleHealthAuthorityShow(t *testing.T) {
+	t.Parallel()
+	ctx := project.TestContext(t)
+
+	env, s := newTestServer(t)
+	db := env.Database()
+	verificationDB := database.New(db)
+
+	healthAuthority := &model.HealthAuthority{
+		Issuer:         "test-iss",
+		Audience:       "test-aud",
+		Name:           "TEST",
+		Keys:           []*model.HealthAuthorityKey{},
+		JwksURI:        stringPtr("https://foo.bar"),
+		EnableStatsAPI: true,
+	}
+	if err := verificationDB.AddHealthAuthority(ctx, healthAuthority); err != nil {
+		t.Fatal(err)
+	}
+
+	cases := []struct {
+		name   string
+		id     string
+		status int
+		want   []string
+	}{
+		{
+			name:   "new",
+			id:     "0",
+			status: 200,
+			want:   []string{"New Verification Key"},
+		},
+		{
+			name:   "existing",
+			id:     fmt.Sprintf("%d", healthAuthority.ID),
+			status: 200,
+			want:   []string{"Edit Verification Key", healthAuthority.Issuer, healthAuthority.Audience},
+		},
+		{
+			name:   "bad_id",
+			id:     "banana",
+			status: 500,
+			want:   []string{"Unable to parse `id` param"},
+		},
+		{
+			name:   "non_existing",
+			id:     "123456",
+			status: 500,
+			want:   []string{"Unable to find requested health authority"},
+		},
+	}
+
+	for _, tc := range cases {
+		tc := tc
+		t.Run(tc.name, func(t *testing.T) {
+			t.Parallel()
+
+			server := newHTTPServer(t, http.MethodGet, "/:id", s.HandleHealthAuthorityShow())
+
+			ctx := context.Background()
+			req, err := http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf("%s/%s", server.URL, tc.id), nil)
+			if err != nil {
+				t.Fatal(err)
+			}
+			client := server.Client()
+
+			resp, err := client.Do(req)
+			if err != nil {
+				t.Fatalf("error making http call: %v", err)
+			}
+			defer resp.Body.Close()
+
+			if got, want := resp.StatusCode, tc.status; got != want {
+				b, err := io.ReadAll(resp.Body)
+				if err != nil {
+					t.Fatal(err)
+				}
+				t.Errorf("expected status %d to be %d; headers: %#v; body: %s", got, want, resp.Header, b)
+			}
+
+			mustFindStrings(t, resp, tc.want...)
+		})
+	}
+}
+
+func TestHandleHealthAuthoritySave(t *testing.T) {
+	t.Parallel()
+	ctx := project.TestContext(t)
+
+	env, s := newTestServer(t)
+	db := env.Database()
+	verificationDB := database.New(db)
+
+	healthAuthority := &model.HealthAuthority{
+		Issuer:         "test-iss",
+		Audience:       "test-aud",
+		Name:           "TEST",
+		Keys:           []*model.HealthAuthorityKey{},
+		JwksURI:        stringPtr("https://foo.bar"),
+		EnableStatsAPI: true,
+	}
+	if err := verificationDB.AddHealthAuthority(ctx, healthAuthority); err != nil {
+		t.Fatal(err)
+	}
+
+	cases := []struct {
+		name   string
+		id     string
+		seed   *model.HealthAuthority
+		form   *healthAuthorityFormData
+		status int
+		want   []string
+	}{
+		// create
+		{
+			name: "create_new",
+			id:   "0",
+			form: &healthAuthorityFormData{
+				Issuer:         "test-iss1",
+				Audience:       "test-aud1",
+				Name:           "test-ha1",
+				EnableStatsAPI: true,
+				JwksURI:        "https://foo.bar",
+			},
+			status: 303,
+		},
+
+		// update
+		{
+			name:   "update_unknown",
+			id:     "123456",
+			form:   &healthAuthorityFormData{},
+			status: 500,
+			want:   []string{"error processing health authority"},
+		},
+		{
+			name:   "update_bad_id",
+			id:     "banana",
+			form:   &healthAuthorityFormData{},
+			status: 500,
+			want:   []string{"failed to parse"},
+		},
+		{
+			name:   "update_request",
+			id:     fmt.Sprintf("%d", healthAuthority.ID),
+			form:   &healthAuthorityFormData{},
+			status: 500,
+			want:   []string{"Error writing health authority"},
+		},
+		{
+			name: "update_existing",
+			id:   fmt.Sprintf("%d", healthAuthority.ID),
+			form: &healthAuthorityFormData{
+				Issuer:         "test-iss2",
+				Audience:       "test-aud2",
+				Name:           "test-ha2",
+				EnableStatsAPI: true,
+				JwksURI:        "https://foo.bar",
+			},
+			status: 303,
+		},
+	}
+
+	for _, tc := range cases {
+		tc := tc
+
+		t.Run(tc.name, func(t *testing.T) {
+			t.Parallel()
+
+			if tc.seed != nil {
+				if err := verificationDB.AddHealthAuthority(ctx, tc.seed); err != nil {
+					t.Fatalf("failed to add health authority: %v", err)
+				}
+			}
+
+			server := newHTTPServer(t, http.MethodPost, "/:id", s.HandleHealthAuthoritySave())
+
+			// URL values
+			form, err := serializeForm(tc.form)
+			if err != nil {
+				t.Fatalf("unable to serialize form: %v", err)
+			}
+
+			ctx := context.Background()
+			req, err := http.NewRequestWithContext(ctx, http.MethodPost, fmt.Sprintf("%s/%s", server.URL, tc.id), strings.NewReader(form.Encode()))
+			if err != nil {
+				t.Fatal(err)
+			}
+			req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
+			client := server.Client()
+			client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
+				return http.ErrUseLastResponse
+			}
+
+			resp, err := client.Do(req)
+			if err != nil {
+				t.Fatalf("error making http call: %v", err)
+			}
+			defer resp.Body.Close()
+
+			if got, want := resp.StatusCode, tc.status; got != want {
+				b, err := io.ReadAll(resp.Body)
+				if err != nil {
+					t.Fatal(err)
+				}
+				t.Errorf("expected status %d to be %d; headers: %#v; body: %s", got, want, resp.Header, b)
+			}
+
+			if len(tc.want) > 0 {
+				mustFindStrings(t, resp, tc.want...)
+			}
+		})
+	}
+}
+
+func TestHandleHealthAuthorityKeys(t *testing.T) {
+	t.Parallel()
+
+	ctx := project.TestContext(t)
+
+	from := time.Unix(1609579380, 0).UTC()
+	thru := time.Unix(1641119640, 0).UTC()
+
+	env, s := newTestServer(t)
+	db := env.Database()
+	verificationDB := database.New(db)
+
+	healthAuthority := &model.HealthAuthority{
+		Issuer:         "test-iss",
+		Audience:       "test-aud",
+		Name:           "TEST",
+		Keys:           []*model.HealthAuthorityKey{},
+		JwksURI:        stringPtr("https://foo.bar"),
+		EnableStatsAPI: true,
+	}
+	if err := verificationDB.AddHealthAuthority(ctx, healthAuthority); err != nil {
+		t.Fatal(err)
+	}
+
+	healthAuthorityKey := &model.HealthAuthorityKey{
+		AuthorityID: healthAuthority.ID,
+		Version:     "123",
+		From:        from,
+		Thru:        thru,
+		PublicKeyPEM: `-----BEGIN PUBLIC KEY-----
+MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEml59itec9qzwVojreLXdPNRsUWzf
+YHc1cKvIIi6/H56AJS/kZEYQnfDpxrgyGhdAm+pNN2GAJ3XdnQZ1Sk4amg==
+-----END PUBLIC KEY-----`,
+	}
+	if err := verificationDB.AddHealthAuthorityKey(ctx, healthAuthority, healthAuthorityKey); err != nil {
+		t.Fatal(err)
+	}
+
+	cases := []struct {
+		name    string
+		action  string
+		id      string
+		version string
+		seed    *model.HealthAuthorityKey
+		form    *keyhealthAuthorityFormData
+		status  int
+		want    []string
+	}{
+		{
+			name:    "invalid_id",
+			action:  "create",
+			id:      "banana",
+			version: "0",
+			status:  500,
+			want:    []string{"Unable to parse `id` param"},
+		},
+		{
+			name:    "not_existing",
+			action:  "create",
+			id:      "123",
+			version: "0",
+			status:  500,
+			want:    []string{"error processing health authority"},
+		},
+
+		// create
+		{
+			name:    "create_new",
+			action:  "create",
+			id:      fmt.Sprintf("%d", healthAuthority.ID),
+			version: "0",
+			form: &keyhealthAuthorityFormData{
+				Version: "key_version2",
+				PEMBlock: `-----BEGIN PUBLIC KEY-----
+MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEml59itec9qzwVojreLXdPNRsUWzf
+YHc1cKvIIi6/H56AJS/kZEYQnfDpxrgyGhdAm+pNN2GAJ3XdnQZ1Sk4amg==
+-----END PUBLIC KEY-----`,
+				FromDate: "2021-01-02",
+				FromTime: "09:23",
+				ThruDate: "2022-01-02",
+				ThruTime: "10:34",
+			},
+			status: 303,
+		},
+		{
+			name:    "create_validation",
+			action:  "create",
+			id:      fmt.Sprintf("%d", healthAuthority.ID),
+			version: "0",
+			form: &keyhealthAuthorityFormData{
+				Version:  "key_version",
+				PEMBlock: "PEMPEMPEM",
+				FromDate: "bad-date",
+				FromTime: "bad-time",
+				ThruDate: "bad-date",
+				ThruTime: "bad-time",
+			},
+			status: 500,
+			want:   []string{"invalid from time"},
+		},
+
+		// revoke
+		{
+			name:    "revoke",
+			action:  "revoke",
+			id:      fmt.Sprintf("%d", healthAuthority.ID),
+			version: healthAuthorityKey.Version,
+			status:  303,
+		},
+		{
+			name:    "revoke_not_exist",
+			action:  "revoke",
+			id:      fmt.Sprintf("%d", healthAuthority.ID),
+			version: "not-a-real-key-to-find",
+			status:  500,
+			want:    []string{"Invalid key specified"},
+		},
+
+		// reinstate
+		{
+			name:    "reinstate",
+			action:  "reinstate",
+			id:      fmt.Sprintf("%d", healthAuthority.ID),
+			version: healthAuthorityKey.Version,
+			status:  303,
+		},
+
+		// activate
+		{
+			name:    "activate",
+			action:  "activate",
+			id:      fmt.Sprintf("%d", healthAuthority.ID),
+			version: healthAuthorityKey.Version,
+			status:  303,
+		},
+
+		// unknown
+		{
+			name:    "invalid_action",
+			action:  "nope",
+			id:      fmt.Sprintf("%d", healthAuthority.ID),
+			version: healthAuthorityKey.Version,
+			status:  500,
+			want:    []string{"invalid action"},
+		},
+	}
+
+	for _, tc := range cases {
+		tc := tc
+
+		t.Run(tc.name, func(t *testing.T) {
+			t.Parallel()
+
+			if tc.seed != nil {
+				if err := verificationDB.AddHealthAuthorityKey(ctx, healthAuthority, tc.seed); err != nil {
+					t.Fatalf("failed to add health authority key: %v", err)
+				}
+			}
+
+			server := newHTTPServer(t, http.MethodPost, "/:id/:action/:version", s.HandleHealthAuthorityKeys())
+
+			// URL values
+			form, err := serializeForm(tc.form)
+			if err != nil {
+				t.Fatalf("unable to serialize form: %v", err)
+			}
+
+			ctx := context.Background()
+			req, err := http.NewRequestWithContext(ctx, http.MethodPost, fmt.Sprintf("%s/%s/%s/%s", server.URL, tc.id, tc.action, tc.version), strings.NewReader(form.Encode()))
+			if err != nil {
+				t.Fatal(err)
+			}
+			req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
+			client := server.Client()
+			client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
+				return http.ErrUseLastResponse
+			}
+
+			resp, err := client.Do(req)
+			if err != nil {
+				t.Fatalf("error making http call: %v", err)
+			}
+			defer resp.Body.Close()
+
+			if got, want := resp.StatusCode, tc.status; got != want {
+				b, err := io.ReadAll(resp.Body)
+				if err != nil {
+					t.Fatal(err)
+				}
+				t.Errorf("expected status %d to be %d; headers: %#v; body: %s", got, want, resp.Header, b)
+			}
+
+			if len(tc.want) > 0 {
+				mustFindStrings(t, resp, tc.want...)
+			}
+		})
+	}
+}