Some checks failed
continuous-integration/drone Build is failing
A publisher names the feed in hub.url (spec §7.1). This read hub.topic, which belongs to subscribe and unsubscribe, so every compliant publisher sent a notification the hub could not resolve: it logged "could not request topic :" and returned 200 with an empty body. Nothing was ever delivered, and the status said the opposite. hub.topic is still read, but only when hub.url is missing. The rest is the same class of bug — a response that reported success for work that did not happen: - a publish with no topic is 400, a topic that does not answer 2xx is 502, and a subscriber is told nothing about either; - the fetched body is bounded, its response closed, and a callback is retried once with the outcome logged instead of assuming the first POST landed; - unsubscribe wrote 200 and then an error body into the same response; it now answers once; - the challenge comes from crypto/rand and must be echoed exactly, since a substring match let any page that mentioned it pass verification; - pq.Error implements error on the pointer, so the value assertion never compiled; the statement now type-asserts the pointer; - the database password can come from HUB_DB_PASSWORD instead of only being baked into the binary. The build moves to go.mod and golang:1.22, because io.Discard does not exist in the 1.12 image the pipeline was pinned to.
216 lines
6.2 KiB
Go
216 lines
6.2 KiB
Go
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, "<rss>fresh</rss>")
|
|
}))
|
|
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"] != "<rss>fresh</rss>" {
|
|
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")
|
|
}
|
|
}
|