Rewrite tests

This commit is contained in:
binwiederhier
2026-03-21 21:59:59 -04:00
parent b81218953a
commit ad501feab1
3 changed files with 275 additions and 340 deletions
+2 -166
View File
@@ -1,180 +1,16 @@
package attachment
import (
"bytes"
"fmt"
"io"
"os"
"strings"
"testing"
"time"
"github.com/stretchr/testify/require"
"heckel.io/ntfy/v2/util"
)
var (
oneKilobyteArray = make([]byte, 1024)
)
func TestFileStore_Write_Success(t *testing.T) {
dir, c := newTestFileStore(t)
size, err := c.Write("abcdefghijkl", strings.NewReader("normal file"), 0, util.NewFixedLimiter(999))
require.Nil(t, err)
require.Equal(t, int64(11), size)
require.Equal(t, "normal file", readFile(t, dir+"/abcdefghijkl"))
require.Equal(t, int64(11), c.Size())
require.Equal(t, int64(10229), c.Remaining())
}
func TestFileStore_Write_Read_Success(t *testing.T) {
_, c := newTestFileStore(t)
size, err := c.Write("abcdefghijkl", strings.NewReader("hello world"), 0)
require.Nil(t, err)
require.Equal(t, int64(11), size)
reader, readSize, err := c.Read("abcdefghijkl")
require.Nil(t, err)
require.Equal(t, int64(11), readSize)
defer reader.Close()
data, err := io.ReadAll(reader)
require.Nil(t, err)
require.Equal(t, "hello world", string(data))
}
func TestFileStore_Write_Remove_Success(t *testing.T) {
dir, c := newTestFileStore(t) // max = 10k (10240), each = 1k (1024)
for i := 0; i < 10; i++ { // 10x999 = 9990
size, err := c.Write(fmt.Sprintf("abcdefghijk%d", i), bytes.NewReader(make([]byte, 999)), 0)
require.Nil(t, err)
require.Equal(t, int64(999), size)
}
require.Equal(t, int64(9990), c.Size())
require.Equal(t, int64(250), c.Remaining())
require.FileExists(t, dir+"/abcdefghijk1")
require.FileExists(t, dir+"/abcdefghijk5")
require.Nil(t, c.Remove("abcdefghijk1", "abcdefghijk5"))
require.NoFileExists(t, dir+"/abcdefghijk1")
require.NoFileExists(t, dir+"/abcdefghijk5")
require.Equal(t, int64(8*999), c.Size())
require.Equal(t, int64(10240-8*999), c.Remaining())
}
func TestFileStore_Write_FailedTotalSizeLimit(t *testing.T) {
dir, c := newTestFileStore(t)
for i := 0; i < 10; i++ {
size, err := c.Write(fmt.Sprintf("abcdefghijk%d", i), bytes.NewReader(oneKilobyteArray), 0)
require.Nil(t, err)
require.Equal(t, int64(1024), size)
}
_, err := c.Write("abcdefghijkX", bytes.NewReader(oneKilobyteArray), 0)
require.Equal(t, util.ErrLimitReached, err)
require.NoFileExists(t, dir+"/abcdefghijkX")
}
func TestFileStore_Write_FailedAdditionalLimiter(t *testing.T) {
dir, c := newTestFileStore(t)
_, err := c.Write("abcdefghijkl", bytes.NewReader(make([]byte, 1001)), 0, util.NewFixedLimiter(1000))
require.Equal(t, util.ErrLimitReached, err)
require.NoFileExists(t, dir+"/abcdefghijkl")
}
func TestFileStore_Write_UntrustedContentLengthExact(t *testing.T) {
dir, c := newTestFileStore(t)
size, err := c.Write("abcdefghijkl", strings.NewReader("hello world"), 11)
require.Nil(t, err)
require.Equal(t, int64(11), size)
require.Equal(t, "hello world", readFile(t, dir+"/abcdefghijkl"))
}
func TestFileStore_Write_UntrustedContentLengthBodyLonger(t *testing.T) {
dir, c := newTestFileStore(t)
// Body has 11 bytes, but we claim 5 — only first 5 bytes should be stored
size, err := c.Write("abcdefghijkl", strings.NewReader("hello world"), 5)
require.Nil(t, err)
require.Equal(t, int64(5), size)
require.Equal(t, "hello", readFile(t, dir+"/abcdefghijkl"))
}
func TestFileStore_Write_UntrustedContentLengthBodyShorter(t *testing.T) {
dir, c := newTestFileStore(t)
// Body has 5 bytes, but we claim 100 — should fail with content length mismatch
_, err := c.Write("abcdefghijkl", strings.NewReader("hello"), 100)
require.Error(t, err)
require.Contains(t, err.Error(), "content length mismatch")
require.NoFileExists(t, dir+"/abcdefghijkl")
}
func TestFileStore_Read_NotFound(t *testing.T) {
_, c := newTestFileStore(t)
_, _, err := c.Read("abcdefghijkl")
require.Error(t, err)
}
func TestFileStore_Sync(t *testing.T) {
dir, c := newTestFileStore(t)
// Write some files
_, err := c.Write("abcdefghijk0", strings.NewReader("file0"), 0)
require.Nil(t, err)
_, err = c.Write("abcdefghijk1", strings.NewReader("file1"), 0)
require.Nil(t, err)
_, err = c.Write("abcdefghijk2", strings.NewReader("file2"), 0)
require.Nil(t, err)
require.Equal(t, int64(15), c.Size())
// Set the ID provider to only know about file 0 and 2
c.localIDs = func() ([]string, error) {
return []string{"abcdefghijk0", "abcdefghijk2"}, nil
}
// Make file 1's mod time old enough to be cleaned up (> 1 hour)
oldTime := time.Unix(1, 0)
os.Chtimes(dir+"/abcdefghijk1", oldTime, oldTime)
// Run sync
require.Nil(t, c.sync())
// File 1 should be deleted (orphan, old enough)
require.NoFileExists(t, dir+"/abcdefghijk1")
require.FileExists(t, dir+"/abcdefghijk0")
require.FileExists(t, dir+"/abcdefghijk2")
// Size should be updated
require.Equal(t, int64(10), c.Size())
}
func TestFileStore_Sync_SkipsRecentFiles(t *testing.T) {
dir, c := newTestFileStore(t)
// Write a file
_, err := c.Write("abcdefghijk0", strings.NewReader("file0"), 0)
require.Nil(t, err)
// Set the ID provider to return empty (no valid IDs)
c.localIDs = func() ([]string, error) {
return []string{}, nil
}
// File was just created, so it should NOT be deleted (< 1 hour old)
require.Nil(t, c.sync())
require.FileExists(t, dir+"/abcdefghijk0")
}
func newTestFileStore(t *testing.T) (dir string, cache *Store) {
func newTestFileStore(t *testing.T, totalSizeLimit int64) (dir string, cache *Store) {
t.Helper()
dir = t.TempDir()
cache, err := NewFileStore(dir, 10*1024, nil)
cache, err := NewFileStore(dir, totalSizeLimit, nil)
require.Nil(t, err)
t.Cleanup(func() { cache.Close() })
return dir, cache
}
func readFile(t *testing.T, f string) string {
t.Helper()
b, err := os.ReadFile(f)
require.Nil(t, err)
return string(b)
}
+21 -174
View File
@@ -1,7 +1,6 @@
package attachment
import (
"bytes"
"encoding/xml"
"fmt"
"io"
@@ -14,43 +13,12 @@ import (
"github.com/stretchr/testify/require"
"heckel.io/ntfy/v2/s3"
"heckel.io/ntfy/v2/util"
)
// --- Integration tests using a mock S3 server ---
func TestS3Store_WriteReadRemove(t *testing.T) {
server := newMockS3Server()
defer server.Close()
cache := newTestS3Store(t, server, "my-bucket", "pfx", 10*1024)
// Write
size, err := cache.Write("abcdefghijkl", strings.NewReader("hello world"), 0)
require.Nil(t, err)
require.Equal(t, int64(11), size)
require.Equal(t, int64(11), cache.Size())
// Read back
reader, readSize, err := cache.Read("abcdefghijkl")
require.Nil(t, err)
require.Equal(t, int64(11), readSize)
data, err := io.ReadAll(reader)
reader.Close()
require.Nil(t, err)
require.Equal(t, "hello world", string(data))
// Remove
require.Nil(t, cache.Remove("abcdefghijkl"))
require.Equal(t, int64(0), cache.Size())
// Read after remove should fail
_, _, err = cache.Read("abcdefghijkl")
require.Error(t, err)
}
// --- S3-specific tests ---
func TestS3Store_WriteNoPrefix(t *testing.T) {
server := newMockS3Server()
server, _ := newMockS3Server()
defer server.Close()
cache := newTestS3Store(t, server, "my-bucket", "", 10*1024)
@@ -67,131 +35,6 @@ func TestS3Store_WriteNoPrefix(t *testing.T) {
require.Equal(t, "test", string(data))
}
func TestS3Store_WriteTotalSizeLimit(t *testing.T) {
server := newMockS3Server()
defer server.Close()
cache := newTestS3Store(t, server, "my-bucket", "pfx", 100)
// First write fits
_, err := cache.Write("abcdefghijk0", bytes.NewReader(make([]byte, 80)), 0)
require.Nil(t, err)
require.Equal(t, int64(80), cache.Size())
require.Equal(t, int64(20), cache.Remaining())
// Second write exceeds total limit
_, err = cache.Write("abcdefghijk1", bytes.NewReader(make([]byte, 50)), 0)
require.ErrorIs(t, err, util.ErrLimitReached)
}
func TestS3Store_WriteFileSizeLimit(t *testing.T) {
server := newMockS3Server()
defer server.Close()
cache := newTestS3Store(t, server, "my-bucket", "pfx", 10*1024)
_, err := cache.Write("abcdefghijkl", bytes.NewReader(make([]byte, 200)), 0, util.NewFixedLimiter(100))
require.ErrorIs(t, err, util.ErrLimitReached)
}
func TestS3Store_WriteRemoveMultiple(t *testing.T) {
server := newMockS3Server()
defer server.Close()
cache := newTestS3Store(t, server, "my-bucket", "pfx", 10*1024)
for i := 0; i < 5; i++ {
_, err := cache.Write(fmt.Sprintf("abcdefghijk%d", i), bytes.NewReader(make([]byte, 100)), 0)
require.Nil(t, err)
}
require.Equal(t, int64(500), cache.Size())
require.Nil(t, cache.Remove("abcdefghijk1", "abcdefghijk3"))
require.Equal(t, int64(300), cache.Size())
}
func TestS3Store_ReadNotFound(t *testing.T) {
server := newMockS3Server()
defer server.Close()
cache := newTestS3Store(t, server, "my-bucket", "pfx", 10*1024)
_, _, err := cache.Read("abcdefghijkl")
require.Error(t, err)
}
func TestS3Store_InvalidID(t *testing.T) {
server := newMockS3Server()
defer server.Close()
cache := newTestS3Store(t, server, "my-bucket", "pfx", 10*1024)
_, err := cache.Write("bad", strings.NewReader("x"), 0)
require.Equal(t, errInvalidFileID, err)
_, _, err = cache.Read("bad")
require.Equal(t, errInvalidFileID, err)
err = cache.Remove("bad")
require.Equal(t, errInvalidFileID, err)
}
func TestS3Store_Sync(t *testing.T) {
server := newMockS3Server()
defer server.Close()
cache := newTestS3Store(t, server, "my-bucket", "pfx", 10*1024)
// Write some files
_, err := cache.Write("abcdefghijk0", strings.NewReader("file0"), 0)
require.Nil(t, err)
_, err = cache.Write("abcdefghijk1", strings.NewReader("file1"), 0)
require.Nil(t, err)
_, err = cache.Write("abcdefghijk2", strings.NewReader("file2"), 0)
require.Nil(t, err)
require.Equal(t, int64(15), cache.Size())
// Set the ID provider to only know about file 0 and 2
// All mock objects have LastModified set to 2 hours ago, so orphans are eligible for deletion
cache.localIDs = func() ([]string, error) {
return []string{"abcdefghijk0", "abcdefghijk2"}, nil
}
// Run sync
require.Nil(t, cache.sync())
// File 1 should be deleted (orphan)
_, _, err = cache.Read("abcdefghijk1")
require.Error(t, err)
// Size should be updated
require.Equal(t, int64(10), cache.Size())
}
func TestS3Store_Sync_SkipsRecentFiles(t *testing.T) {
mockServer := newMockS3ServerWithModTime(time.Now())
defer mockServer.Close()
cache := newTestS3Store(t, mockServer, "my-bucket", "pfx", 10*1024)
_, err := cache.Write("abcdefghijk0", strings.NewReader("file0"), 0)
require.Nil(t, err)
// Set the ID provider to return empty (no valid IDs)
cache.localIDs = func() ([]string, error) {
return []string{}, nil
}
// File was "just created" (mock returns recent time), so it should NOT be deleted
require.Nil(t, cache.sync())
// File should still exist
reader, _, err := cache.Read("abcdefghijk0")
require.Nil(t, err)
reader.Close()
}
// --- Helpers ---
func newTestS3Store(t *testing.T, server *httptest.Server, bucket, prefix string, totalSizeLimit int64) *Store {
@@ -219,24 +62,26 @@ func newTestS3Store(t *testing.T, server *httptest.Server, bucket, prefix string
// ListObjectsV2. Uses path-style addressing: /{bucket}/{key}. Objects are stored in memory.
type mockS3Server struct {
objects map[string][]byte // full key (bucket/key) -> body
uploads map[string]map[int][]byte // uploadID -> partNumber -> data
nextID int // counter for generating upload IDs
lastModTime time.Time // time to return for LastModified in list responses
mu sync.RWMutex
objects map[string][]byte // full key (bucket/key) -> body
modTimes map[string]time.Time // full key (bucket/key) -> last modified time
uploads map[string]map[int][]byte // uploadID -> partNumber -> data
nextID int // counter for generating upload IDs
mu sync.RWMutex
}
func newMockS3Server() *httptest.Server {
return newMockS3ServerWithModTime(time.Now().Add(-2 * time.Hour))
}
func newMockS3ServerWithModTime(modTime time.Time) *httptest.Server {
func newMockS3Server() (*httptest.Server, *mockS3Server) {
m := &mockS3Server{
objects: make(map[string][]byte),
uploads: make(map[string]map[int][]byte),
lastModTime: modTime,
objects: make(map[string][]byte),
modTimes: make(map[string]time.Time),
uploads: make(map[string]map[int][]byte),
}
return httptest.NewTLSServer(m)
return httptest.NewTLSServer(m), m
}
func (m *mockS3Server) setModTime(path string, t time.Time) {
m.mu.Lock()
m.modTimes[path] = t
m.mu.Unlock()
}
func (m *mockS3Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
@@ -274,6 +119,7 @@ func (m *mockS3Server) handlePut(w http.ResponseWriter, r *http.Request, path st
}
m.mu.Lock()
m.objects[path] = body
m.modTimes[path] = time.Now()
m.mu.Unlock()
w.WriteHeader(http.StatusOK)
}
@@ -333,6 +179,7 @@ func (m *mockS3Server) handleCompleteMultipart(w http.ResponseWriter, r *http.Re
assembled = append(assembled, parts[i]...)
}
m.objects[path] = assembled
m.modTimes[path] = time.Now()
delete(m.uploads, uploadID)
m.mu.Unlock()
@@ -402,7 +249,7 @@ func (m *mockS3Server) handleList(w http.ResponseWriter, r *http.Request, bucket
contents = append(contents, s3ListObject{
Key: objKey,
Size: int64(len(body)),
LastModified: m.lastModTime.Format(time.RFC3339),
LastModified: m.modTimes[key].Format(time.RFC3339),
})
}
}
+252
View File
@@ -0,0 +1,252 @@
package attachment
import (
"bytes"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/stretchr/testify/require"
"heckel.io/ntfy/v2/util"
)
const testSizeLimit = 10 * 1024
func TestStore_WriteReadRemove(t *testing.T) {
forEachBackend(t, testSizeLimit, func(t *testing.T, s *Store, _ func(string)) {
// Write
size, err := s.Write("abcdefghijkl", strings.NewReader("hello world"), 0)
require.Nil(t, err)
require.Equal(t, int64(11), size)
require.Equal(t, int64(11), s.Size())
// Read back
reader, readSize, err := s.Read("abcdefghijkl")
require.Nil(t, err)
require.Equal(t, int64(11), readSize)
data, err := io.ReadAll(reader)
reader.Close()
require.Nil(t, err)
require.Equal(t, "hello world", string(data))
// Remove
require.Nil(t, s.Remove("abcdefghijkl"))
require.Equal(t, int64(0), s.Size())
// Read after remove should fail
_, _, err = s.Read("abcdefghijkl")
require.Error(t, err)
})
}
func TestStore_WriteRemoveMultiple(t *testing.T) {
forEachBackend(t, testSizeLimit, func(t *testing.T, s *Store, _ func(string)) {
for i := 0; i < 5; i++ {
_, err := s.Write(fmt.Sprintf("abcdefghijk%d", i), bytes.NewReader(make([]byte, 100)), 0)
require.Nil(t, err)
}
require.Equal(t, int64(500), s.Size())
require.Nil(t, s.Remove("abcdefghijk1", "abcdefghijk3"))
require.Equal(t, int64(300), s.Size())
// Removed files should not be readable
_, _, err := s.Read("abcdefghijk1")
require.Error(t, err)
_, _, err = s.Read("abcdefghijk3")
require.Error(t, err)
// Remaining files should still be readable
for _, id := range []string{"abcdefghijk0", "abcdefghijk2", "abcdefghijk4"} {
reader, _, err := s.Read(id)
require.Nil(t, err)
reader.Close()
}
})
}
func TestStore_WriteTotalSizeLimit(t *testing.T) {
forEachBackend(t, 100, func(t *testing.T, s *Store, _ func(string)) {
// First write fits
_, err := s.Write("abcdefghijk0", bytes.NewReader(make([]byte, 80)), 0)
require.Nil(t, err)
require.Equal(t, int64(80), s.Size())
require.Equal(t, int64(20), s.Remaining())
// Second write exceeds total limit
_, err = s.Write("abcdefghijk1", bytes.NewReader(make([]byte, 50)), 0)
require.ErrorIs(t, err, util.ErrLimitReached)
})
}
func TestStore_WriteAdditionalLimiter(t *testing.T) {
forEachBackend(t, testSizeLimit, func(t *testing.T, s *Store, _ func(string)) {
_, err := s.Write("abcdefghijkl", bytes.NewReader(make([]byte, 200)), 0, util.NewFixedLimiter(100))
require.ErrorIs(t, err, util.ErrLimitReached)
// File should not be readable (was cleaned up)
_, _, err = s.Read("abcdefghijkl")
require.Error(t, err)
})
}
func TestStore_WriteWithLimiter(t *testing.T) {
forEachBackend(t, testSizeLimit, func(t *testing.T, s *Store, _ func(string)) {
size, err := s.Write("abcdefghijkl", strings.NewReader("normal file"), 0, util.NewFixedLimiter(999))
require.Nil(t, err)
require.Equal(t, int64(11), size)
require.Equal(t, int64(11), s.Size())
})
}
func TestStore_ReadNotFound(t *testing.T) {
forEachBackend(t, testSizeLimit, func(t *testing.T, s *Store, _ func(string)) {
_, _, err := s.Read("abcdefghijkl")
require.Error(t, err)
})
}
func TestStore_InvalidID(t *testing.T) {
forEachBackend(t, testSizeLimit, func(t *testing.T, s *Store, _ func(string)) {
_, err := s.Write("bad", strings.NewReader("x"), 0)
require.Equal(t, errInvalidFileID, err)
_, _, err = s.Read("bad")
require.Equal(t, errInvalidFileID, err)
err = s.Remove("bad")
require.Equal(t, errInvalidFileID, err)
})
}
func TestStore_WriteUntrustedLengthExact(t *testing.T) {
forEachBackend(t, testSizeLimit, func(t *testing.T, s *Store, _ func(string)) {
size, err := s.Write("abcdefghijkl", strings.NewReader("hello world"), 11)
require.Nil(t, err)
require.Equal(t, int64(11), size)
reader, _, err := s.Read("abcdefghijkl")
require.Nil(t, err)
data, err := io.ReadAll(reader)
reader.Close()
require.Nil(t, err)
require.Equal(t, "hello world", string(data))
})
}
func TestStore_WriteUntrustedLengthBodyLonger(t *testing.T) {
forEachBackend(t, testSizeLimit, func(t *testing.T, s *Store, _ func(string)) {
// Body has 11 bytes, but we claim 5 — only first 5 bytes should be stored
size, err := s.Write("abcdefghijkl", strings.NewReader("hello world"), 5)
require.Nil(t, err)
require.Equal(t, int64(5), size)
reader, _, err := s.Read("abcdefghijkl")
require.Nil(t, err)
data, err := io.ReadAll(reader)
reader.Close()
require.Nil(t, err)
require.Equal(t, "hello", string(data))
})
}
func TestStore_WriteUntrustedLengthBodyShorter(t *testing.T) {
forEachBackend(t, testSizeLimit, func(t *testing.T, s *Store, _ func(string)) {
// Body has 5 bytes, but we claim 100 — should fail
_, err := s.Write("abcdefghijkl", strings.NewReader("hello"), 100)
require.Error(t, err)
// File should not be readable (was cleaned up)
_, _, err = s.Read("abcdefghijkl")
require.Error(t, err)
})
}
func TestStore_Sync(t *testing.T) {
forEachBackend(t, testSizeLimit, func(t *testing.T, s *Store, makeOld func(string)) {
// Write some files
_, err := s.Write("abcdefghijk0", strings.NewReader("file0"), 0)
require.Nil(t, err)
_, err = s.Write("abcdefghijk1", strings.NewReader("file1"), 0)
require.Nil(t, err)
_, err = s.Write("abcdefghijk2", strings.NewReader("file2"), 0)
require.Nil(t, err)
require.Equal(t, int64(15), s.Size())
// Set the ID provider to only know about file 0 and 2
s.localIDs = func() ([]string, error) {
return []string{"abcdefghijk0", "abcdefghijk2"}, nil
}
// Make file 1 old enough to be cleaned up
makeOld("abcdefghijk1")
// Run sync
require.Nil(t, s.sync())
// File 1 should be deleted (orphan, old enough)
_, _, err = s.Read("abcdefghijk1")
require.Error(t, err)
// Files 0 and 2 should still be readable
r, _, err := s.Read("abcdefghijk0")
require.Nil(t, err)
r.Close()
r, _, err = s.Read("abcdefghijk2")
require.Nil(t, err)
r.Close()
// Size should be updated
require.Equal(t, int64(10), s.Size())
})
}
func TestStore_Sync_SkipsRecentFiles(t *testing.T) {
forEachBackend(t, testSizeLimit, func(t *testing.T, s *Store, _ func(string)) {
// Write a file
_, err := s.Write("abcdefghijk0", strings.NewReader("file0"), 0)
require.Nil(t, err)
// Set the ID provider to return empty (no valid IDs)
s.localIDs = func() ([]string, error) {
return []string{}, nil
}
// File was just created, so it should NOT be deleted (< 1 hour old)
require.Nil(t, s.sync())
// File should still exist
reader, _, err := s.Read("abcdefghijk0")
require.Nil(t, err)
reader.Close()
})
}
// forEachBackend runs f against both the file and S3 backends. It also provides a makeOld
// callback that makes a specific object's timestamp old enough for orphan cleanup (> 1 hour).
// For the file backend, this uses os.Chtimes; for the S3 backend, it sets the object's
// LastModified time in the mock server. Objects start with recent timestamps by default.
func forEachBackend(t *testing.T, totalSizeLimit int64, f func(t *testing.T, s *Store, makeOld func(string))) {
t.Run("file", func(t *testing.T) {
dir, s := newTestFileStore(t, totalSizeLimit)
makeOld := func(id string) {
oldTime := time.Unix(1, 0)
os.Chtimes(filepath.Join(dir, id), oldTime, oldTime)
}
f(t, s, makeOld)
})
t.Run("s3", func(t *testing.T) {
server, mock := newMockS3Server()
defer server.Close()
s := newTestS3Store(t, server, "my-bucket", "pfx", totalSizeLimit)
makeOld := func(id string) {
mock.setModTime("my-bucket/pfx/"+id, time.Unix(1, 0))
}
f(t, s, makeOld)
})
}