mirror of
https://github.com/binwiederhier/ntfy.git
synced 2026-09-15 06:52:46 -04:00
354 lines
12 KiB
Go
354 lines
12 KiB
Go
package server
|
|
|
|
import (
|
|
"database/sql"
|
|
"net"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/netip"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/require"
|
|
"heckel.io/ntfy/v2/cluster"
|
|
dbtest "heckel.io/ntfy/v2/db/test"
|
|
"heckel.io/ntfy/v2/model"
|
|
"heckel.io/ntfy/v2/user"
|
|
)
|
|
|
|
// fakeCluster records relayed messages and topic announcements so tests can assert that every
|
|
// publish path passes through the cluster exactly once, and that subscription hooks fire.
|
|
type fakeCluster struct {
|
|
mu sync.Mutex
|
|
messages []*model.Message
|
|
announced []string
|
|
notLeader bool
|
|
notHealthy bool
|
|
}
|
|
|
|
func (b *fakeCluster) ForwardMessage(m *model.Message) error {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
b.messages = append(b.messages, m)
|
|
return nil
|
|
}
|
|
|
|
func (b *fakeCluster) ServeHTTP(_ http.ResponseWriter, _ *http.Request) {}
|
|
|
|
func (b *fakeCluster) BroadcastState(state *cluster.State) {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
b.announced = append(b.announced, state.AddedTopics...)
|
|
}
|
|
|
|
func (b *fakeCluster) Healthy() bool {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
return !b.notHealthy
|
|
}
|
|
|
|
func (b *fakeCluster) setHealthy(healthy bool) {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
b.notHealthy = !healthy
|
|
}
|
|
|
|
func (b *fakeCluster) IsLeader() bool {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
return !b.notLeader
|
|
}
|
|
|
|
func (b *fakeCluster) setLeader(leader bool) {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
b.notLeader = !leader
|
|
}
|
|
|
|
func (b *fakeCluster) Close() error { return nil }
|
|
|
|
func (b *fakeCluster) Messages() []*model.Message {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
return append([]*model.Message{}, b.messages...)
|
|
}
|
|
|
|
func (b *fakeCluster) Announced() []string {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
return append([]string{}, b.announced...)
|
|
}
|
|
|
|
func TestServer_Cluster_PublishForwardsOnce(t *testing.T) {
|
|
s := newTestServer(t, newTestConfig(t, ""))
|
|
b := &fakeCluster{}
|
|
s.cluster = b
|
|
response := request(t, s, "PUT", "/mytopic", "hi there", nil)
|
|
require.Equal(t, 200, response.Code)
|
|
messages := b.Messages()
|
|
require.Len(t, messages, 1)
|
|
require.Equal(t, "mytopic", messages[0].Topic)
|
|
require.Equal(t, "hi there", messages[0].Message)
|
|
}
|
|
|
|
func TestServer_Cluster_SyncEventForwards(t *testing.T) {
|
|
// Account sync events are delivered via the user's st_... sync topic; without relaying
|
|
// them, cross-device account sync silently breaks when a user's devices land on different
|
|
// cluster nodes.
|
|
s := newTestServer(t, newTestConfig(t, ""))
|
|
b := &fakeCluster{}
|
|
s.cluster = b
|
|
u := &user.User{ID: "u_abc", Name: "phil", SyncTopic: "st_1234"}
|
|
v := s.visitor(netip.MustParseAddr("1.2.3.4"), nil)
|
|
require.Nil(t, s.publishSyncEventForUser(v, u))
|
|
messages := b.Messages()
|
|
require.Len(t, messages, 1)
|
|
require.Equal(t, "st_1234", messages[0].Topic)
|
|
}
|
|
|
|
func TestServer_Cluster_DeliverNotOnPublicHandler(t *testing.T) {
|
|
// The fan-out endpoint lives only on the dedicated cluster listener; the public handler must
|
|
// not serve it, even with cluster mode on and a valid secret.
|
|
schemaDSN := dbtest.CreateTestPostgresSchema(t)
|
|
conf := newTestConfig(t, schemaDSN)
|
|
conf.ClusterNodeID = "node-a"
|
|
conf.ClusterListen = "127.0.0.1:1" // Enables clustering; not bound since Run() is not called
|
|
conf.ClusterSecret = "s3cret"
|
|
conf.ClusterAdvertiseURL = "http://127.0.0.1:1"
|
|
s := newTestServer(t, conf)
|
|
topics, err := s.topicsFromIDs(nil, "mytopic")
|
|
require.Nil(t, err)
|
|
var mu sync.Mutex
|
|
var received []*model.Message
|
|
topics[0].Subscribe(func(_ *visitor, m *model.Message) error {
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
received = append(received, m)
|
|
return nil
|
|
}, "", func() {})
|
|
// A valid fan-out request against the PUBLIC handler must not deliver
|
|
response := request(t, s, "POST", "/v1/internal/message",
|
|
`{"message":{"id":"x1","time":1,"event":"message","topic":"mytopic","message":"sneaky"}}`,
|
|
map[string]string{"X-Cluster-Secret": "s3cret", "X-Cluster-Origin": "node-b"})
|
|
require.Equal(t, 404, response.Code)
|
|
time.Sleep(250 * time.Millisecond) // Delivery is async; give a wrong implementation time to fail
|
|
mu.Lock()
|
|
require.Empty(t, received)
|
|
mu.Unlock()
|
|
// The same request against the cluster listener handler DOES deliver
|
|
rr := httptest.NewRecorder()
|
|
req, err := http.NewRequest("POST", "/v1/internal/message",
|
|
strings.NewReader(`{"message":{"id":"x2","time":1,"event":"message","topic":"mytopic","message":"legit"}}`))
|
|
require.Nil(t, err)
|
|
req.Header.Set("X-Cluster-Secret", "s3cret")
|
|
req.Header.Set("X-Cluster-Origin", "node-b")
|
|
s.clusterHandler().ServeHTTP(rr, req)
|
|
require.Equal(t, 200, rr.Code)
|
|
waitFor(t, func() bool {
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
return len(received) == 1
|
|
})
|
|
}
|
|
|
|
func TestServer_Cluster_EndToEnd(t *testing.T) {
|
|
// Two full servers sharing one Postgres schema: a message published to node A over HTTP must
|
|
// reach a subscriber connected to node B, via the node registry and the fan-out endpoint.
|
|
schemaDSN := dbtest.CreateTestPostgresSchema(t)
|
|
// Node B: create the listener first so its advertise URL is known before the server exists
|
|
listenerB, err := net.Listen("tcp", "127.0.0.1:0")
|
|
require.Nil(t, err)
|
|
confB := newTestConfig(t, schemaDSN)
|
|
confB.ClusterNodeID = "node-b"
|
|
confB.ClusterListen = listenerB.Addr().String() // Enables clustering; the test serves it below
|
|
confB.ClusterSecret = "s3cret"
|
|
confB.ClusterAdvertiseURL = "http://" + listenerB.Addr().String()
|
|
sB := newTestServer(t, confB)
|
|
srvB := &http.Server{Handler: sB.clusterHandler()}
|
|
go srvB.Serve(listenerB)
|
|
defer srvB.Close()
|
|
// Node A: publish-only in this test, so its advertise URL is never called
|
|
confA := newTestConfig(t, schemaDSN)
|
|
confA.ClusterNodeID = "node-a"
|
|
confA.ClusterListen = "127.0.0.1:1" // Enables clustering; not bound since Run() is not called
|
|
confA.ClusterSecret = "s3cret"
|
|
confA.ClusterAdvertiseURL = "http://127.0.0.1:1"
|
|
sA := newTestServer(t, confA)
|
|
// Subscribe on node B
|
|
topics, err := sB.topicsFromIDs(nil, "mytopic")
|
|
require.Nil(t, err)
|
|
var mu sync.Mutex
|
|
var received []*model.Message
|
|
topics[0].Subscribe(func(_ *visitor, m *model.Message) error {
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
received = append(received, m)
|
|
return nil
|
|
}, "", func() {})
|
|
// Publish on node A
|
|
response := request(t, sA, "PUT", "/mytopic", "hello cluster", nil)
|
|
require.Equal(t, 200, response.Code)
|
|
waitFor(t, func() bool {
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
return len(received) == 1
|
|
})
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
require.Equal(t, "hello cluster", received[0].Message)
|
|
}
|
|
|
|
func TestServer_Cluster_DeliverFromBus(t *testing.T) {
|
|
// deliverFromBus is the receive side of the broadcaster: a message that originated on a peer
|
|
// node must reach this node's local subscribers, but must NOT be re-broadcast (loop) nor
|
|
// re-trigger origin-only side effects.
|
|
s := newTestServer(t, newTestConfig(t, ""))
|
|
b := &fakeCluster{}
|
|
s.cluster = b
|
|
topics, err := s.topicsFromIDs(nil, "mytopic")
|
|
require.Nil(t, err)
|
|
var mu sync.Mutex
|
|
var received []*model.Message
|
|
topics[0].Subscribe(func(_ *visitor, m *model.Message) error {
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
received = append(received, m)
|
|
return nil
|
|
}, "", func() {})
|
|
m := model.NewDefaultMessage("mytopic", "from peer")
|
|
m.Sender = netip.MustParseAddr("5.6.7.8")
|
|
s.deliverFromBus(m)
|
|
waitFor(t, func() bool {
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
return len(received) == 1
|
|
})
|
|
require.Empty(t, b.Messages()) // Peer messages are never re-relayed
|
|
}
|
|
|
|
func TestServer_Cluster_FirstSubscriberAnnounces(t *testing.T) {
|
|
// A topic gaining its FIRST subscriber is announced to peers exactly once, so publishers on
|
|
// other nodes stop skipping this node for it without waiting for the next state push.
|
|
s := newTestServer(t, newTestConfig(t, ""))
|
|
b := &fakeCluster{}
|
|
s.cluster = b
|
|
topics, err := s.topicsFromIDs(nil, "mytopic")
|
|
require.Nil(t, err)
|
|
subscriber := func(_ *visitor, _ *model.Message) error { return nil }
|
|
topics[0].Subscribe(subscriber, "", func() {})
|
|
waitFor(t, func() bool {
|
|
return len(b.Announced()) == 1 && b.Announced()[0] == "mytopic"
|
|
})
|
|
// A second subscriber does not re-announce
|
|
topics[0].Subscribe(subscriber, "", func() {})
|
|
time.Sleep(250 * time.Millisecond)
|
|
require.Len(t, b.Announced(), 1)
|
|
}
|
|
|
|
func TestServer_Cluster_ManagerPrunesOnlyOnLeader(t *testing.T) {
|
|
c := newTestConfig(t, "")
|
|
s := newTestServer(t, c)
|
|
cl := &fakeCluster{notLeader: true}
|
|
s.cluster = cl
|
|
|
|
// Publish and expire a message
|
|
rr := request(t, s, "POST", "/mytopic", "hi", nil)
|
|
require.Equal(t, 200, rr.Code)
|
|
m := toMessage(t, rr.Body.String())
|
|
require.Nil(t, s.messageCache.ExpireMessages("mytopic"))
|
|
|
|
// A non-leader node leaves shared-database pruning to the leader
|
|
s.execManager()
|
|
_, err := s.messageCache.Message(m.ID)
|
|
require.Nil(t, err)
|
|
|
|
// Once this node is the leader, the same run prunes
|
|
cl.setLeader(true)
|
|
s.execManager()
|
|
_, err = s.messageCache.Message(m.ID)
|
|
require.Equal(t, model.ErrMessageNotFound, err)
|
|
}
|
|
|
|
func TestServer_Cluster_StatsResetOnlyOnLeader(t *testing.T) {
|
|
c := newTestConfigWithAuthFile(t, "")
|
|
s := newTestServer(t, c)
|
|
cl := &fakeCluster{notLeader: true}
|
|
s.cluster = cl
|
|
|
|
// An anonymous visitor with an in-memory message count
|
|
v := newVisitor(c, s.messageCache, s.userManager, netip.MustParseAddr("1.2.3.4"), nil)
|
|
require.True(t, v.MessageAllowed())
|
|
s.mu.Lock()
|
|
s.visitors["ip:1.2.3.4"] = v
|
|
s.mu.Unlock()
|
|
require.Equal(t, int64(1), v.Stats().Messages)
|
|
|
|
// A user with persisted stats in the (shared) user database
|
|
require.Nil(t, s.userManager.AddUser("phil", "phil1234", user.RoleUser, false))
|
|
authDB, err := sql.Open("sqlite3", c.AuthFile)
|
|
require.Nil(t, err)
|
|
defer authDB.Close()
|
|
_, err = authDB.Exec(`UPDATE user SET stats_messages = 5 WHERE user = 'phil'`)
|
|
require.Nil(t, err)
|
|
|
|
// A non-leader node resets its own in-memory visitor stats, but leaves the user database
|
|
// to the leader
|
|
s.resetStats()
|
|
require.Equal(t, int64(0), v.Stats().Messages)
|
|
u, err := s.userManager.User("phil")
|
|
require.Nil(t, err)
|
|
require.Equal(t, int64(5), u.Stats.Messages)
|
|
|
|
// The leader resets the user database too
|
|
cl.setLeader(true)
|
|
s.resetStats()
|
|
u, err = s.userManager.User("phil")
|
|
require.Nil(t, err)
|
|
require.Equal(t, int64(0), u.Stats.Messages)
|
|
}
|
|
|
|
func TestServer_Cluster_FirebaseKeepaliverOnlyOnLeader(t *testing.T) {
|
|
// Every FCM keepalive wakes all subscribed phones, so only the leader may send them;
|
|
// N nodes sending N keepalives would multiply the battery cost for every user
|
|
c := newTestConfig(t, "")
|
|
c.FirebaseKeepaliveInterval = 20 * time.Millisecond
|
|
s := newTestServer(t, c)
|
|
sender := newTestFirebaseSender(100)
|
|
s.firebaseClient = newFirebaseClient(sender, &testAuther{Allow: true})
|
|
cl := &fakeCluster{notLeader: true}
|
|
s.cluster = cl
|
|
s.closeChan = make(chan bool) // Closed by Stop() in the test cleanup
|
|
go s.runFirebaseKeepaliver()
|
|
|
|
// A non-leader node stays silent
|
|
time.Sleep(150 * time.Millisecond)
|
|
require.Empty(t, sender.Messages())
|
|
|
|
// The leader sends keepalives
|
|
cl.setLeader(true)
|
|
waitFor(t, func() bool { return len(sender.Messages()) > 0 })
|
|
}
|
|
|
|
func TestServer_Cluster_HealthReflectsCluster(t *testing.T) {
|
|
// A node whose registry heartbeat went stale no longer receives forwarded messages, so
|
|
// health checks must pull it from rotation (the fail-open policy lives in the checker)
|
|
s := newTestServer(t, newTestConfig(t, ""))
|
|
cl := &fakeCluster{}
|
|
s.cluster = cl
|
|
rr := request(t, s, "GET", "/v1/health", "", nil)
|
|
require.Equal(t, 200, rr.Code)
|
|
require.Contains(t, rr.Body.String(), `"healthy":true`)
|
|
cl.setHealthy(false)
|
|
rr = request(t, s, "GET", "/v1/health", "", nil)
|
|
require.Equal(t, 503, rr.Code)
|
|
require.Contains(t, rr.Body.String(), `"healthy":false`)
|
|
// The cluster listener's health endpoint reflects the same state
|
|
rr2 := httptest.NewRecorder()
|
|
req, err := http.NewRequest("GET", "/v1/health", nil)
|
|
require.Nil(t, err)
|
|
s.clusterHandler().ServeHTTP(rr2, req)
|
|
require.Equal(t, 503, rr2.Code)
|
|
}
|