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")
}
}