package main import ( "fmt" "io" "net/http" "net/http/httptest" "net/url" "strings" "sync" "testing" "time" "p83.nl/go/websub-hub/cmd/hubserver/storage" ) type fakeStore struct { mu sync.Mutex subs map[string][]storage.Subscriber } func newFakeStore() *fakeStore { return &fakeStore{subs: map[string][]storage.Subscriber{}} } func (f *fakeStore) Subscribe(topic string, sub storage.Subscriber) error { f.mu.Lock() defer f.mu.Unlock() f.subs[topic] = append(f.subs[topic], sub) return nil } func (f *fakeStore) Unsubscribe(topic, callback string) error { f.mu.Lock() defer f.mu.Unlock() var keep []storage.Subscriber for _, s := range f.subs[topic] { if s.Callback != callback { keep = append(keep, s) } } f.subs[topic] = keep return nil } func (f *fakeStore) Subscribers(topic string) ([]storage.Subscriber, error) { f.mu.Lock() defer f.mu.Unlock() return append([]storage.Subscriber(nil), f.subs[topic]...), nil } func (f *fakeStore) RemoveExpiredSubscribers() error { return nil } func (f *fakeStore) Close() error { return nil } // recordings holds the delivery POSTs a fake callback received. type recordings struct { mu sync.Mutex posts []map[string]string } func (r *recordings) add(h http.Header, body string) { r.mu.Lock() defer r.mu.Unlock() r.posts = append(r.posts, map[string]string{"link": h.Get("Link"), "body": body}) } func (r *recordings) got() []map[string]string { r.mu.Lock() defer r.mu.Unlock() return append([]map[string]string(nil), r.posts...) } func newHandler(t *testing.T) (*subscriptionHandler, *fakeStore) { t.Helper() store := newFakeStore() return &subscriptionHandler{ store: store, baseURL: "https://hub.test/", client: &http.Client{Timeout: 5 * time.Second}, }, store } func post(t *testing.T, h *subscriptionHandler, form url.Values) *httptest.ResponseRecorder { t.Helper() req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") rec := httptest.NewRecorder() h.ServeHTTP(rec, req) return rec } // A publish names the feed in hub.url (spec ยง7.1) and the hub delivers the // topic's body to every subscriber. func TestPublishUsesHubURL(t *testing.T) { h, store := newHandler(t) topic := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/rss+xml") fmt.Fprint(w, "fresh") })) defer topic.Close() var rec recordings callback := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { body, _ := io.ReadAll(r.Body) rec.add(r.Header, string(body)) w.WriteHeader(http.StatusNoContent) })) defer callback.Close() if err := store.Subscribe(topic.URL, storage.Subscriber{Callback: callback.URL, LeaseSeconds: 3600, Secret: "s"}); err != nil { t.Fatal(err) } res := post(t, h, url.Values{"hub.mode": {"publish"}, "hub.url": {topic.URL}}) if res.Code != http.StatusOK { t.Fatalf("publish = %d %s", res.Code, res.Body.String()) } posts := rec.got() if len(posts) != 1 { t.Fatalf("callback saw %d posts, want 1", len(posts)) } if posts[0]["body"] != "fresh" { t.Fatalf("body = %q", posts[0]["body"]) } if !strings.Contains(posts[0]["link"], `rel=hub`) || !strings.Contains(posts[0]["link"], `rel=self`) { t.Fatalf("Link header = %q", posts[0]["link"]) } } // An older publisher names the topic hub.topic; that must still publish. func TestPublishFallsBackToHubTopic(t *testing.T) { h, store := newHandler(t) topic := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, "feed") })) defer topic.Close() var rec recordings callback := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { rec.add(r.Header, "") w.WriteHeader(http.StatusNoContent) })) defer callback.Close() _ = store.Subscribe(topic.URL, storage.Subscriber{Callback: callback.URL, LeaseSeconds: 3600}) res := post(t, h, url.Values{"hub.mode": {"publish"}, "hub.topic": {topic.URL}}) if res.Code != http.StatusOK { t.Fatalf("publish = %d", res.Code) } if len(rec.got()) != 1 { t.Fatalf("callback saw %d posts, want 1", len(rec.got())) } } // A publish with neither name is refused instead of answering 200 and doing // nothing, which is how this endpoint used to lose every notification. func TestPublishWithoutTopicIsRefused(t *testing.T) { h, _ := newHandler(t) res := post(t, h, url.Values{"hub.mode": {"publish"}}) if res.Code != http.StatusBadRequest { t.Fatalf("publish = %d, want 400", res.Code) } if !strings.Contains(res.Body.String(), "hub.url") { t.Fatalf("body = %q", res.Body.String()) } } // A topic that answers an error has no feed to deliver. func TestPublishSkipsFailedTopic(t *testing.T) { h, store := newHandler(t) topic := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.NotFound(w, r) })) defer topic.Close() var rec recordings callback := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { rec.add(r.Header, "") w.WriteHeader(http.StatusNoContent) })) defer callback.Close() _ = store.Subscribe(topic.URL, storage.Subscriber{Callback: callback.URL, LeaseSeconds: 3600}) res := post(t, h, url.Values{"hub.mode": {"publish"}, "hub.url": {topic.URL}}) if res.Code != http.StatusBadGateway { t.Fatalf("publish = %d, want 502", res.Code) } if len(rec.got()) != 0 { t.Fatalf("callback received an error page: %v", rec.got()) } } // Verification requires the subscriber to echo the challenge, not to mention // it somewhere on a page. func TestValidateURLRequiresExactChallenge(t *testing.T) { challenge := "abc123" var reply string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, reply) })) defer srv.Close() reply = " " + challenge + "\n" if !validateURL(srv.URL, "subscribe", "https://example.test/feed.xml", challenge) { t.Fatal("exact echo rejected") } reply = "thanks for subscribing, the challenge was " + challenge + " but this is a page" if validateURL(srv.URL, "subscribe", "https://example.test/feed.xml", challenge) { t.Fatal("substring match accepted") } }