From e8688fed4b2b752ab3ec3bd724d0a6eceb7ef7f7 Mon Sep 17 00:00:00 2001 From: Philipp Heckel Date: Fri, 10 Dec 2021 22:57:01 -0500 Subject: [PATCH] Lots more tests --- config/config.go | 9 ++ server/cache_sqlite.go | 2 +- server/server.go | 25 ++--- server/server_test.go | 244 +++++++++++++++++++++++++++-------------- util/time.go | 2 +- util/time_test.go | 2 +- 6 files changed, 186 insertions(+), 98 deletions(-) diff --git a/config/config.go b/config/config.go index 7fcea453..9e1640a8 100644 --- a/config/config.go +++ b/config/config.go @@ -12,6 +12,9 @@ const ( DefaultKeepaliveInterval = 30 * time.Second DefaultManagerInterval = time.Minute DefaultAtSenderInterval = 10 * time.Second + DefaultMinDelay = 10 * time.Second + DefaultMaxDelay = 3 * 24 * time.Hour + DefaultMessageLimit = 512 ) // Defines all the limits @@ -37,6 +40,9 @@ type Config struct { KeepaliveInterval time.Duration ManagerInterval time.Duration AtSenderInterval time.Duration + MessageLimit int + MinDelay time.Duration + MaxDelay time.Duration GlobalTopicLimit int VisitorRequestLimitBurst int VisitorRequestLimitReplenish time.Duration @@ -56,6 +62,9 @@ func New(listenHTTP string) *Config { CacheDuration: DefaultCacheDuration, KeepaliveInterval: DefaultKeepaliveInterval, ManagerInterval: DefaultManagerInterval, + MessageLimit: DefaultMessageLimit, + MinDelay: DefaultMinDelay, + MaxDelay: DefaultMaxDelay, AtSenderInterval: DefaultAtSenderInterval, GlobalTopicLimit: DefaultGlobalTopicLimit, VisitorRequestLimitBurst: DefaultVisitorRequestLimitBurst, diff --git a/server/cache_sqlite.go b/server/cache_sqlite.go index c6813d5c..82d09073 100644 --- a/server/cache_sqlite.go +++ b/server/cache_sqlite.go @@ -28,7 +28,7 @@ const ( COMMIT; ` insertMessageQuery = `INSERT INTO messages (id, time, topic, message, title, priority, tags, published) VALUES (?, ?, ?, ?, ?, ?, ?, ?)` - pruneMessagesQuery = `DELETE FROM messages WHERE time < ?` + pruneMessagesQuery = `DELETE FROM messages WHERE time < ? AND published = 1` selectMessagesSinceTimeQuery = ` SELECT id, time, topic, message, title, priority, tags FROM messages diff --git a/server/server.go b/server/server.go index fa7574c2..b5bd31ee 100644 --- a/server/server.go +++ b/server/server.go @@ -71,11 +71,6 @@ var ( sinceNoMessages = sinceTime(time.Unix(1, 0)) ) -const ( - messageLimit = 512 - minDelay = 10 * time.Second -) - var ( topicRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}$`) // Regex must match JS & Android app! jsonRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/json$`) @@ -181,7 +176,7 @@ func (s *Server) Run() error { ticker := time.NewTicker(s.config.ManagerInterval) for { <-ticker.C - s.updateStatsAndExpire() + s.updateStatsAndPrune() } }() go func() { @@ -280,7 +275,7 @@ func (s *Server) handlePublish(w http.ResponseWriter, r *http.Request, _ *visito if err != nil { return err } - reader := io.LimitReader(r.Body, messageLimit) + reader := io.LimitReader(r.Body, int64(s.config.MessageLimit)) b, err := io.ReadAll(reader) if err != nil { return err @@ -289,7 +284,7 @@ func (s *Server) handlePublish(w http.ResponseWriter, r *http.Request, _ *visito if m.Message == "" { return errHTTPBadRequest } - cache, firebase, err := parseHeaders(r.Header, m) + cache, firebase, err := s.parseHeaders(r.Header, m) if err != nil { return err } @@ -321,7 +316,7 @@ func (s *Server) handlePublish(w http.ResponseWriter, r *http.Request, _ *visito return nil } -func parseHeaders(header http.Header, m *message) (cache bool, firebase bool, err error) { +func (s *Server) parseHeaders(header http.Header, m *message) (cache bool, firebase bool, err error) { cache = readHeader(header, "x-cache", "cache") != "no" firebase = readHeader(header, "x-firebase", "firebase") != "no" m.Title = readHeader(header, "x-title", "title", "ti", "t") @@ -349,7 +344,7 @@ func parseHeaders(header http.Header, m *message) (cache bool, firebase bool, er m.Tags = append(m.Tags, strings.TrimSpace(s)) } } - whenStr := readHeader(header, "x-at", "at", "x-in", "in") + whenStr := readHeader(header, "x-at", "at", "x-in", "in", "x-delay", "delay") if whenStr != "" { if !cache { return false, false, errHTTPBadRequest @@ -357,7 +352,9 @@ func parseHeaders(header http.Header, m *message) (cache bool, firebase bool, er at, err := util.ParseFutureTime(whenStr, time.Now()) if err != nil { return false, false, errHTTPBadRequest - } else if at.Unix() < time.Now().Add(minDelay).Unix() { + } else if at.Unix() < time.Now().Add(s.config.MinDelay).Unix() { + return false, false, errHTTPBadRequest + } else if at.Unix() > time.Now().Add(s.config.MaxDelay).Unix() { return false, false, errHTTPBadRequest } m.Time = at.Unix() @@ -548,7 +545,7 @@ func (s *Server) topicsFromIDs(ids ...string) ([]*topic, error) { return topics, nil } -func (s *Server) updateStatsAndExpire() { +func (s *Server) updateStatsAndPrune() { s.mu.Lock() defer s.mu.Unlock() @@ -559,13 +556,13 @@ func (s *Server) updateStatsAndExpire() { } } - // Prune cache + // Prune message cache olderThan := time.Now().Add(-1 * s.config.CacheDuration) if err := s.cache.Prune(olderThan); err != nil { log.Printf("error pruning cache: %s", err.Error()) } - // Prune old messages, remove subscriptions without subscribers + // Prune old topics, remove subscriptions without subscribers var subscribers, messages int for _, t := range s.topics { subs := t.Subscribers() diff --git a/server/server_test.go b/server/server_test.go index 1513cb90..cbe24a5f 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -4,7 +4,7 @@ import ( "bufio" "context" "encoding/json" - "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "heckel.io/ntfy/config" "net/http" "net/http/httptest" @@ -19,33 +19,33 @@ func TestServer_PublishAndPoll(t *testing.T) { response1 := request(t, s, "PUT", "/mytopic", "my first message", nil) msg1 := toMessage(t, response1.Body.String()) - assert.NotEmpty(t, msg1.ID) - assert.Equal(t, "my first message", msg1.Message) + require.NotEmpty(t, msg1.ID) + require.Equal(t, "my first message", msg1.Message) response2 := request(t, s, "PUT", "/mytopic", "my second\n\nmessage", nil) msg2 := toMessage(t, response2.Body.String()) - assert.NotEqual(t, msg1.ID, msg2.ID) - assert.NotEmpty(t, msg2.ID) - assert.Equal(t, "my second\n\nmessage", msg2.Message) + require.NotEqual(t, msg1.ID, msg2.ID) + require.NotEmpty(t, msg2.ID) + require.Equal(t, "my second\n\nmessage", msg2.Message) response := request(t, s, "GET", "/mytopic/json?poll=1", "", nil) messages := toMessages(t, response.Body.String()) - assert.Equal(t, 2, len(messages)) - assert.Equal(t, "my first message", messages[0].Message) - assert.Equal(t, "my second\n\nmessage", messages[1].Message) + require.Equal(t, 2, len(messages)) + require.Equal(t, "my first message", messages[0].Message) + require.Equal(t, "my second\n\nmessage", messages[1].Message) response = request(t, s, "GET", "/mytopic/sse?poll=1", "", nil) lines := strings.Split(strings.TrimSpace(response.Body.String()), "\n") - assert.Equal(t, 3, len(lines)) - assert.Equal(t, "my first message", toMessage(t, strings.TrimPrefix(lines[0], "data: ")).Message) - assert.Equal(t, "", lines[1]) - assert.Equal(t, "my second\n\nmessage", toMessage(t, strings.TrimPrefix(lines[2], "data: ")).Message) + require.Equal(t, 3, len(lines)) + require.Equal(t, "my first message", toMessage(t, strings.TrimPrefix(lines[0], "data: ")).Message) + require.Equal(t, "", lines[1]) + require.Equal(t, "my second\n\nmessage", toMessage(t, strings.TrimPrefix(lines[2], "data: ")).Message) response = request(t, s, "GET", "/mytopic/raw?poll=1", "", nil) lines = strings.Split(strings.TrimSpace(response.Body.String()), "\n") - assert.Equal(t, 2, len(lines)) - assert.Equal(t, "my first message", lines[0]) - assert.Equal(t, "my second message", lines[1]) // \n -> " " + require.Equal(t, 2, len(lines)) + require.Equal(t, "my first message", lines[0]) + require.Equal(t, "my second message", lines[1]) // \n -> " " } func TestServer_SubscribeOpenAndKeepalive(t *testing.T) { @@ -69,21 +69,21 @@ func TestServer_SubscribeOpenAndKeepalive(t *testing.T) { <-doneChan messages := toMessages(t, rr.Body.String()) - assert.Equal(t, 2, len(messages)) + require.Equal(t, 2, len(messages)) - assert.Equal(t, openEvent, messages[0].Event) - assert.Equal(t, "mytopic", messages[0].Topic) - assert.Equal(t, "", messages[0].Message) - assert.Equal(t, "", messages[0].Title) - assert.Equal(t, 0, messages[0].Priority) - assert.Nil(t, messages[0].Tags) + require.Equal(t, openEvent, messages[0].Event) + require.Equal(t, "mytopic", messages[0].Topic) + require.Equal(t, "", messages[0].Message) + require.Equal(t, "", messages[0].Title) + require.Equal(t, 0, messages[0].Priority) + require.Nil(t, messages[0].Tags) - assert.Equal(t, keepaliveEvent, messages[1].Event) - assert.Equal(t, "mytopic", messages[1].Topic) - assert.Equal(t, "", messages[1].Message) - assert.Equal(t, "", messages[1].Title) - assert.Equal(t, 0, messages[1].Priority) - assert.Nil(t, messages[1].Tags) + require.Equal(t, keepaliveEvent, messages[1].Event) + require.Equal(t, "mytopic", messages[1].Topic) + require.Equal(t, "", messages[1].Message) + require.Equal(t, "", messages[1].Title) + require.Equal(t, 0, messages[1].Priority) + require.Nil(t, messages[1].Tags) } func TestServer_PublishAndSubscribe(t *testing.T) { @@ -93,63 +93,79 @@ func TestServer_PublishAndSubscribe(t *testing.T) { subscribeCancel := subscribe(t, s, "/mytopic/json", subscribeRR) publishFirstRR := request(t, s, "PUT", "/mytopic", "my first message", nil) - assert.Equal(t, 200, publishFirstRR.Code) + require.Equal(t, 200, publishFirstRR.Code) publishSecondRR := request(t, s, "PUT", "/mytopic", "my other message", map[string]string{ "Title": " This is a title ", "X-Tags": "tag1,tag 2, tag3", "p": "1", }) - assert.Equal(t, 200, publishSecondRR.Code) + require.Equal(t, 200, publishSecondRR.Code) subscribeCancel() messages := toMessages(t, subscribeRR.Body.String()) - assert.Equal(t, 3, len(messages)) - assert.Equal(t, openEvent, messages[0].Event) + require.Equal(t, 3, len(messages)) + require.Equal(t, openEvent, messages[0].Event) - assert.Equal(t, messageEvent, messages[1].Event) - assert.Equal(t, "mytopic", messages[1].Topic) - assert.Equal(t, "my first message", messages[1].Message) - assert.Equal(t, "", messages[1].Title) - assert.Equal(t, 0, messages[1].Priority) - assert.Nil(t, messages[1].Tags) + require.Equal(t, messageEvent, messages[1].Event) + require.Equal(t, "mytopic", messages[1].Topic) + require.Equal(t, "my first message", messages[1].Message) + require.Equal(t, "", messages[1].Title) + require.Equal(t, 0, messages[1].Priority) + require.Nil(t, messages[1].Tags) - assert.Equal(t, messageEvent, messages[2].Event) - assert.Equal(t, "mytopic", messages[2].Topic) - assert.Equal(t, "my other message", messages[2].Message) - assert.Equal(t, "This is a title", messages[2].Title) - assert.Equal(t, 1, messages[2].Priority) - assert.Equal(t, []string{"tag1", "tag 2", "tag3"}, messages[2].Tags) + require.Equal(t, messageEvent, messages[2].Event) + require.Equal(t, "mytopic", messages[2].Topic) + require.Equal(t, "my other message", messages[2].Message) + require.Equal(t, "This is a title", messages[2].Title) + require.Equal(t, 1, messages[2].Priority) + require.Equal(t, []string{"tag1", "tag 2", "tag3"}, messages[2].Tags) } func TestServer_StaticSites(t *testing.T) { s := newTestServer(t, newTestConfig(t)) rr := request(t, s, "GET", "/", "", nil) - assert.Equal(t, 200, rr.Code) - assert.Contains(t, rr.Body.String(), "") + require.Equal(t, 200, rr.Code) + require.Contains(t, rr.Body.String(), "") rr = request(t, s, "HEAD", "/", "", nil) - assert.Equal(t, 200, rr.Code) + require.Equal(t, 200, rr.Code) rr = request(t, s, "GET", "/does-not-exist.txt", "", nil) - assert.Equal(t, 404, rr.Code) + require.Equal(t, 404, rr.Code) rr = request(t, s, "GET", "/mytopic", "", nil) - assert.Equal(t, 200, rr.Code) - assert.Contains(t, rr.Body.String(), ``) + require.Equal(t, 200, rr.Code) + require.Contains(t, rr.Body.String(), ``) rr = request(t, s, "GET", "/static/css/app.css", "", nil) - assert.Equal(t, 200, rr.Code) - assert.Contains(t, rr.Body.String(), `html, body {`) + require.Equal(t, 200, rr.Code) + require.Contains(t, rr.Body.String(), `html, body {`) rr = request(t, s, "GET", "/docs", "", nil) - assert.Equal(t, 301, rr.Code) + require.Equal(t, 301, rr.Code) rr = request(t, s, "GET", "/docs/", "", nil) - assert.Equal(t, 200, rr.Code) - assert.Contains(t, rr.Body.String(), `Made with ❤️ by Philipp C. Heckel`) - assert.Contains(t, rr.Body.String(), ``) + require.Equal(t, 200, rr.Code) + require.Contains(t, rr.Body.String(), `Made with ❤️ by Philipp C. Heckel`) + require.Contains(t, rr.Body.String(), ``) +} + +func TestServer_PublishLargeMessage(t *testing.T) { + s := newTestServer(t, newTestConfig(t)) + + body := strings.Repeat("this is a large message", 1000) + truncated := body[0:512] + response := request(t, s, "PUT", "/mytopic", body, nil) + msg := toMessage(t, response.Body.String()) + require.NotEmpty(t, msg.ID) + require.Equal(t, truncated, msg.Message) + + response = request(t, s, "GET", "/mytopic/json?poll=1", "", nil) + messages := toMessages(t, response.Body.String()) + require.Equal(t, 1, len(messages)) + require.Equal(t, truncated, messages[0].Message) } func TestServer_PublishNoCache(t *testing.T) { @@ -159,12 +175,78 @@ func TestServer_PublishNoCache(t *testing.T) { "Cache": "no", }) msg := toMessage(t, response.Body.String()) - assert.NotEmpty(t, msg.ID) - assert.Equal(t, "this message is not cached", msg.Message) + require.NotEmpty(t, msg.ID) + require.Equal(t, "this message is not cached", msg.Message) response = request(t, s, "GET", "/mytopic/json?poll=1", "", nil) messages := toMessages(t, response.Body.String()) - assert.Empty(t, messages) + require.Empty(t, messages) +} +func TestServer_PublishAt(t *testing.T) { + c := newTestConfig(t) + c.MinDelay = time.Second + c.AtSenderInterval = 100 * time.Millisecond + s := newTestServer(t, c) + + response := request(t, s, "PUT", "/mytopic", "a message", map[string]string{ + "In": "1s", + }) + require.Equal(t, 200, response.Code) + + response = request(t, s, "GET", "/mytopic/json?poll=1", "", nil) + messages := toMessages(t, response.Body.String()) + require.Equal(t, 0, len(messages)) + + time.Sleep(time.Second) + require.Nil(t, s.sendDelayedMessages()) + + response = request(t, s, "GET", "/mytopic/json?poll=1", "", nil) + messages = toMessages(t, response.Body.String()) + require.Equal(t, 1, len(messages)) + require.Equal(t, "a message", messages[0].Message) +} + +func TestServer_PublishAtWithCacheError(t *testing.T) { + s := newTestServer(t, newTestConfig(t)) + + response := request(t, s, "PUT", "/mytopic", "a message", map[string]string{ + "Cache": "no", + "In": "30 min", + }) + require.Equal(t, 400, response.Code) +} + +func TestServer_PublishAtTooShortDelay(t *testing.T) { + s := newTestServer(t, newTestConfig(t)) + + response := request(t, s, "PUT", "/mytopic", "a message", map[string]string{ + "In": "1s", + }) + require.Equal(t, 400, response.Code) +} + +func TestServer_PublishAtTooLongDelay(t *testing.T) { + s := newTestServer(t, newTestConfig(t)) + + response := request(t, s, "PUT", "/mytopic", "a message", map[string]string{ + "In": "99999999h", + }) + require.Equal(t, 400, response.Code) +} + +func TestServer_PublishAtAndPrune(t *testing.T) { + s := newTestServer(t, newTestConfig(t)) + + response := request(t, s, "PUT", "/mytopic", "a message", map[string]string{ + "In": "1h", + }) + require.Equal(t, 200, response.Code) + s.updateStatsAndPrune() // Fire pruning + + response = request(t, s, "GET", "/mytopic/json?poll=1&scheduled=1", "", nil) + messages := toMessages(t, response.Body.String()) + require.Equal(t, 1, len(messages)) // Not affected by pruning + require.Equal(t, "a message", messages[0].Message) } func TestServer_PublishAndMultiPoll(t *testing.T) { @@ -172,29 +254,29 @@ func TestServer_PublishAndMultiPoll(t *testing.T) { response := request(t, s, "PUT", "/mytopic1", "message 1", nil) msg := toMessage(t, response.Body.String()) - assert.NotEmpty(t, msg.ID) - assert.Equal(t, "mytopic1", msg.Topic) - assert.Equal(t, "message 1", msg.Message) + require.NotEmpty(t, msg.ID) + require.Equal(t, "mytopic1", msg.Topic) + require.Equal(t, "message 1", msg.Message) response = request(t, s, "PUT", "/mytopic2", "message 2", nil) msg = toMessage(t, response.Body.String()) - assert.NotEmpty(t, msg.ID) - assert.Equal(t, "mytopic2", msg.Topic) - assert.Equal(t, "message 2", msg.Message) + require.NotEmpty(t, msg.ID) + require.Equal(t, "mytopic2", msg.Topic) + require.Equal(t, "message 2", msg.Message) response = request(t, s, "GET", "/mytopic1/json?poll=1", "", nil) messages := toMessages(t, response.Body.String()) - assert.Equal(t, 1, len(messages)) - assert.Equal(t, "mytopic1", messages[0].Topic) - assert.Equal(t, "message 1", messages[0].Message) + require.Equal(t, 1, len(messages)) + require.Equal(t, "mytopic1", messages[0].Topic) + require.Equal(t, "message 1", messages[0].Message) response = request(t, s, "GET", "/mytopic1,mytopic2/json?poll=1", "", nil) messages = toMessages(t, response.Body.String()) - assert.Equal(t, 2, len(messages)) - assert.Equal(t, "mytopic1", messages[0].Topic) - assert.Equal(t, "message 1", messages[0].Message) - assert.Equal(t, "mytopic2", messages[1].Topic) - assert.Equal(t, "message 2", messages[1].Message) + require.Equal(t, 2, len(messages)) + require.Equal(t, "mytopic1", messages[0].Topic) + require.Equal(t, "message 1", messages[0].Message) + require.Equal(t, "mytopic2", messages[1].Topic) + require.Equal(t, "message 2", messages[1].Message) } func TestServer_PublishWithNopCache(t *testing.T) { @@ -206,18 +288,18 @@ func TestServer_PublishWithNopCache(t *testing.T) { subscribeCancel := subscribe(t, s, "/mytopic/json", subscribeRR) publishRR := request(t, s, "PUT", "/mytopic", "my first message", nil) - assert.Equal(t, 200, publishRR.Code) + require.Equal(t, 200, publishRR.Code) subscribeCancel() messages := toMessages(t, subscribeRR.Body.String()) - assert.Equal(t, 2, len(messages)) - assert.Equal(t, openEvent, messages[0].Event) - assert.Equal(t, messageEvent, messages[1].Event) - assert.Equal(t, "my first message", messages[1].Message) + require.Equal(t, 2, len(messages)) + require.Equal(t, openEvent, messages[0].Event) + require.Equal(t, messageEvent, messages[1].Event) + require.Equal(t, "my first message", messages[1].Message) response := request(t, s, "GET", "/mytopic/json?poll=1", "", nil) messages = toMessages(t, response.Body.String()) - assert.Empty(t, messages) + require.Empty(t, messages) } func newTestConfig(t *testing.T) *config.Config { @@ -278,6 +360,6 @@ func toMessages(t *testing.T, s string) []*message { func toMessage(t *testing.T, s string) *message { var m message - assert.Nil(t, json.NewDecoder(strings.NewReader(s)).Decode(&m)) + require.Nil(t, json.NewDecoder(strings.NewReader(s)).Decode(&m)) return &m } diff --git a/util/time.go b/util/time.go index cd8fa9fd..ed68b766 100644 --- a/util/time.go +++ b/util/time.go @@ -73,7 +73,7 @@ func parseUnixTime(s string, now time.Time) (time.Time, error) { } else if int64(t) < now.Unix() { return time.Time{}, errUnparsableTime } - return time.Unix(int64(t), 0), nil + return time.Unix(int64(t), 0).UTC(), nil } func parseNaturalTime(s string, now time.Time) (time.Time, error) { diff --git a/util/time_test.go b/util/time_test.go index 5575a626..9cab5046 100644 --- a/util/time_test.go +++ b/util/time_test.go @@ -56,5 +56,5 @@ func TestParseFutureTime_1day(t *testing.T) { func TestParseFutureTime_UnixTime(t *testing.T) { d, err := ParseFutureTime("1639183911", base) require.Nil(t, err) - require.Equal(t, time.Date(2021, 12, 10, 19, 51, 51, 0, time.Local), d) + require.Equal(t, time.Date(2021, 12, 11, 0, 51, 51, 0, time.UTC), d) }