| new file mode 100644 |
| index 0000000..bdb0fab |
| --- /dev/null |
| +++ b/http_test.go |
| @@ -0,0 +1,190 @@ |
| +package aviso_test |
| + |
| +import ( |
| + "context" |
| + "crypto/ecdh" |
| + "crypto/rand" |
| + "encoding/base64" |
| + "encoding/json" |
| + "net/http" |
| + "net/http/httptest" |
| + "strings" |
| + "testing" |
| + |
| + "amadan.net/rastrillo/rastrillo/sessions" |
| + |
| + "amadan.net/rastrillo/aviso" |
| +) |
| + |
| +func keys() map[string]string { |
| + k, _ := ecdh.P256().GenerateKey(rand.Reader) |
| + auth := make([]byte, 16) |
| + _, _ = rand.Read(auth) |
| + return map[string]string{ |
| + "p256dh": base64.RawURLEncoding.EncodeToString(k.PublicKey().Bytes()), |
| + "auth": base64.RawURLEncoding.EncodeToString(auth), |
| + } |
| +} |
| + |
| +func subscribeBody(s *aviso.Service, endpoint string) string { |
| + b, _ := json.Marshal(map[string]any{ |
| + "subscription": map[string]any{"endpoint": endpoint, "keys": keys()}, |
| + "publicKey": s.PublicKeyString(), |
| + }) |
| + return string(b) |
| +} |
| + |
| +func post(t *testing.T, h http.HandlerFunc, body, subject string, sameOrigin bool) *httptest.ResponseRecorder { |
| + t.Helper() |
| + r := httptest.NewRequest(http.MethodPost, "/aviso/subscribe", strings.NewReader(body)) |
| + r.Header.Set("Content-Type", "application/json") |
| + if sameOrigin { |
| + r.Header.Set("Sec-Fetch-Site", "same-origin") |
| + } else { |
| + r.Header.Set("Sec-Fetch-Site", "cross-site") |
| + } |
| + if subject != "" { |
| + r = sessions.WithSession(r, sessions.Session{Subject: subject}) |
| + } |
| + w := httptest.NewRecorder() |
| + h(w, r) |
| + return w |
| +} |
| + |
| +func TestPublicKey(t *testing.T) { |
| + s := newService(t) |
| + w := httptest.NewRecorder() |
| + s.PublicKey(w, httptest.NewRequest(http.MethodGet, "/aviso/public-key", nil)) |
| + var got struct{ PublicKey string } |
| + if err := json.NewDecoder(w.Body).Decode(&got); err != nil || got.PublicKey != s.PublicKeyString() { |
| + t.Fatalf("status %d body %s", w.Code, w.Body) |
| + } |
| + if w.Header().Get("Cache-Control") != "no-cache" { |
| + t.Fatal("public key cacheable") |
| + } |
| + w = httptest.NewRecorder() |
| + s.PublicKey(w, httptest.NewRequest(http.MethodPost, "/aviso/public-key", nil)) |
| + if w.Code != http.StatusMethodNotAllowed { |
| + t.Fatalf("POST: %d", w.Code) |
| + } |
| +} |
| + |
| +func TestSubscribeGating(t *testing.T) { |
| + s := newService(t) |
| + ctx := context.Background() |
| + body := subscribeBody(s, "https://push.example/e1") |
| + if w := post(t, s.Subscribe, body, "", true); w.Code != http.StatusUnauthorized { |
| + t.Errorf("no session: %d", w.Code) |
| + } |
| + if w := post(t, s.Subscribe, body, "alice", false); w.Code != http.StatusForbidden { |
| + t.Errorf("cross-site: %d", w.Code) |
| + } |
| + if rows, _ := s.List(ctx, "alice"); len(rows) != 0 { |
| + t.Fatal("a refused request stored a row") |
| + } |
| + if w := post(t, s.Subscribe, body, "alice", true); w.Code != http.StatusNoContent { |
| + t.Errorf("good: %d %s", w.Code, w.Body) |
| + } |
| + if rows, _ := s.List(ctx, "alice"); len(rows) != 1 { |
| + t.Fatal("row not stored") |
| + } |
| + if w := post(t, s.Subscribe, body, "bob", true); w.Code != http.StatusConflict { |
| + t.Errorf("cross-owner: %d", w.Code) |
| + } |
| + wrongKey := strings.Replace(body, s.PublicKeyString(), "BOTHER", 1) |
| + if w := post(t, s.Subscribe, wrongKey, "alice", true); w.Code != http.StatusConflict { |
| + t.Errorf("wrong key: %d", w.Code) |
| + } |
| + if w := post(t, s.Subscribe, subscribeBody(s, "http://push.example/e1"), "alice", true); w.Code != http.StatusBadRequest { |
| + t.Errorf("http endpoint: %d", w.Code) |
| + } |
| + if w := post(t, s.Subscribe, subscribeBody(s, "https://10.0.0.1/e1"), "alice", true); w.Code != http.StatusBadRequest { |
| + t.Errorf("private endpoint: %d", w.Code) |
| + } |
| + noKeys := strings.Replace(body, `"p256dh"`, `"p256dhx"`, 1) |
| + if w := post(t, s.Subscribe, noKeys, "alice", true); w.Code != http.StatusBadRequest { |
| + t.Errorf("missing keys: %d", w.Code) |
| + } |
| + if w := post(t, s.Subscribe, "{not json", "alice", true); w.Code != http.StatusBadRequest { |
| + t.Errorf("bad json: %d", w.Code) |
| + } |
| + huge := `{"subscription":{"endpoint":"https://push.example/` + strings.Repeat("x", 8193) + `"}}` |
| + if w := post(t, s.Subscribe, huge, "alice", true); w.Code != http.StatusRequestEntityTooLarge { |
| + t.Errorf("oversize body: %d", w.Code) |
| + } |
| + r := httptest.NewRequest(http.MethodGet, "/aviso/subscribe", nil) |
| + w := httptest.NewRecorder() |
| + s.Subscribe(w, r) |
| + if w.Code != http.StatusMethodNotAllowed { |
| + t.Errorf("GET: %d", w.Code) |
| + } |
| +} |
| + |
| +// Real browsers' toJSON() carries expirationTime; a strict decoder |
| +// would refuse every genuine subscription. |
| +func TestSubscribeToleratesBrowserFields(t *testing.T) { |
| + s := newService(t) |
| + b, _ := json.Marshal(map[string]any{ |
| + "subscription": map[string]any{"endpoint": "https://push.example/e1", "expirationTime": nil, "keys": keys()}, |
| + "publicKey": s.PublicKeyString(), |
| + }) |
| + if w := post(t, s.Subscribe, string(b), "alice", true); w.Code != http.StatusNoContent { |
| + t.Fatalf("%d %s", w.Code, w.Body) |
| + } |
| +} |
| + |
| +func TestSubscribeHonoursPreviousEndpoint(t *testing.T) { |
| + s := newService(t) |
| + _ = post(t, s.Subscribe, subscribeBody(s, "https://push.example/old"), "alice", true) |
| + b, _ := json.Marshal(map[string]any{ |
| + "subscription": map[string]any{"endpoint": "https://push.example/new", "keys": keys()}, |
| + "publicKey": s.PublicKeyString(), |
| + "previousEndpoint": "https://push.example/old", |
| + }) |
| + if w := post(t, s.Subscribe, string(b), "alice", true); w.Code != http.StatusNoContent { |
| + t.Fatalf("%d %s", w.Code, w.Body) |
| + } |
| + rows, _ := s.List(context.Background(), "alice") |
| + if len(rows) != 1 || rows[0].Endpoint != "https://push.example/new" { |
| + t.Fatalf("rows: %+v", rows) |
| + } |
| + b, _ = json.Marshal(map[string]any{ |
| + "subscription": map[string]any{"endpoint": "https://push.example/new2", "keys": keys()}, |
| + "publicKey": s.PublicKeyString(), |
| + "previousEndpoint": "http://push.example/new", |
| + }) |
| + if w := post(t, s.Subscribe, string(b), "alice", true); w.Code != http.StatusBadRequest { |
| + t.Fatalf("bad previousEndpoint accepted: %d", w.Code) |
| + } |
| +} |
| + |
| +func TestUnsubscribe(t *testing.T) { |
| + s := newService(t) |
| + ctx := context.Background() |
| + _ = post(t, s.Subscribe, subscribeBody(s, "https://push.example/e1"), "alice", true) |
| + body := `{"endpoint":"https://push.example/e1"}` |
| + if w := post(t, s.Unsubscribe, body, "", true); w.Code != http.StatusUnauthorized { |
| + t.Errorf("no session: %d", w.Code) |
| + } |
| + if w := post(t, s.Unsubscribe, body, "alice", false); w.Code != http.StatusForbidden { |
| + t.Errorf("cross-site: %d", w.Code) |
| + } |
| + if w := post(t, s.Unsubscribe, body, "bob", true); w.Code != http.StatusNoContent { |
| + t.Errorf("other subject: %d", w.Code) |
| + } |
| + if rows, _ := s.List(ctx, "alice"); len(rows) != 1 { |
| + t.Fatal("bob's unsubscribe removed alice's row") |
| + } |
| + if w := post(t, s.Unsubscribe, `{}`, "alice", true); w.Code != http.StatusBadRequest { |
| + t.Errorf("missing endpoint: %d", w.Code) |
| + } |
| + if w := post(t, s.Unsubscribe, body, "alice", true); w.Code != http.StatusNoContent { |
| + t.Errorf("own: %d", w.Code) |
| + } |
| + if rows, _ := s.List(ctx, "alice"); len(rows) != 0 { |
| + t.Fatal("own unsubscribe did nothing") |
| + } |
| + if w := post(t, s.Unsubscribe, body, "alice", true); w.Code != http.StatusNoContent { |
| + t.Errorf("repeat: %d", w.Code) |
| + } |
| +} |