check / check (push) Successful in 58s
POST /api/v1/reports stays unauthenticated but is bounded. Each client address, as the trusted-proxy logic resolves it, may send REPORTS_PER_MINUTE reports a minute (default 60, all at once if it likes), using golang.org/x/time/rate; past that it gets 429 with Retry-After. Buckets that have refilled are dropped once a minute, so idle addresses do not pile up. reportbuf refuses a report that would take the report files past DATA_DIR_MAX_BYTES (default 1 GiB) with ErrFull, answered with 507; the count starts from the files already in DATA_DIR, and reports not yet written count at their uncompressed size. CORS adds nothing unless CORS_ALLOWED_ORIGINS lists origins. A limit that is not a positive number stops the server from starting. Model: opus-5-5
248 lines
5.6 KiB
Go
248 lines
5.6 KiB
Go
package reportbuf_test
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"io/fs"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
|
|
"sneak.berlin/go/netwatch/internal/config"
|
|
"sneak.berlin/go/netwatch/internal/globals"
|
|
"sneak.berlin/go/netwatch/internal/logger"
|
|
"sneak.berlin/go/netwatch/internal/reportbuf"
|
|
|
|
"go.uber.org/fx"
|
|
"go.uber.org/fx/fxtest"
|
|
)
|
|
|
|
// TestFlushOnShutdown proves the flush-on-shutdown path: a
|
|
// report appended after start but before the periodic flush
|
|
// window must reach disk when the fx lifecycle stops. This is
|
|
// the exact case that silent data loss on restart used to
|
|
// destroy.
|
|
func TestFlushOnShutdown(t *testing.T) {
|
|
dir := t.TempDir()
|
|
t.Setenv("DATA_DIR", dir)
|
|
|
|
var buf *reportbuf.Buffer
|
|
|
|
app := fxtest.New(t,
|
|
fx.Provide(
|
|
globals.New,
|
|
logger.New,
|
|
config.New,
|
|
reportbuf.New,
|
|
),
|
|
fx.Populate(&buf),
|
|
)
|
|
|
|
app.RequireStart()
|
|
|
|
err := buf.Append(map[string]string{"probe": "shutdown"})
|
|
if err != nil {
|
|
t.Fatalf("append report: %v", err)
|
|
}
|
|
|
|
// RequireStop runs the reportbuf OnStop hook, which is the
|
|
// only code path that flushes buffered reports on shutdown.
|
|
app.RequireStop()
|
|
|
|
if !hasReportFile(t, dir) {
|
|
t.Fatal("no report file on disk after shutdown; " +
|
|
"the buffered report was lost")
|
|
}
|
|
}
|
|
|
|
// TestFailedFinalFlushFailsStop proves a final flush that cannot
|
|
// write its file makes the stop fail, which makes the process
|
|
// exit non-zero instead of dropping the buffered reports silently.
|
|
func TestFailedFinalFlushFailsStop(t *testing.T) {
|
|
dir := t.TempDir()
|
|
t.Setenv("DATA_DIR", dir)
|
|
|
|
var buf *reportbuf.Buffer
|
|
|
|
app := fxtest.New(t,
|
|
fx.Provide(
|
|
globals.New,
|
|
logger.New,
|
|
config.New,
|
|
reportbuf.New,
|
|
),
|
|
fx.Populate(&buf),
|
|
)
|
|
|
|
app.RequireStart()
|
|
|
|
err := buf.Append(map[string]string{"probe": "shutdown"})
|
|
if err != nil {
|
|
t.Fatalf("append report: %v", err)
|
|
}
|
|
|
|
// Removing the data directory leaves the final flush nowhere to
|
|
// write. A read-only directory would not do: tests run as root
|
|
// in the backend image, and root ignores the read-only bit.
|
|
err = os.RemoveAll(dir)
|
|
if err != nil {
|
|
t.Fatalf("remove data dir: %v", err)
|
|
}
|
|
|
|
err = app.Stop(t.Context())
|
|
if !errors.Is(err, fs.ErrNotExist) {
|
|
t.Fatalf("stop error = %v, want the final flush's error", err)
|
|
}
|
|
}
|
|
|
|
// startBuffer starts a Buffer through fx, as main does, with the
|
|
// DATA_DIR and DATA_DIR_MAX_BYTES the calling test has set.
|
|
func startBuffer(t *testing.T) *reportbuf.Buffer {
|
|
t.Helper()
|
|
|
|
var buf *reportbuf.Buffer
|
|
|
|
app := fxtest.New(t,
|
|
fx.Provide(
|
|
globals.New,
|
|
logger.New,
|
|
config.New,
|
|
reportbuf.New,
|
|
),
|
|
fx.Populate(&buf),
|
|
)
|
|
|
|
app.RequireStart()
|
|
t.Cleanup(app.RequireStop)
|
|
|
|
return buf
|
|
}
|
|
|
|
// lineBytes is what one report takes in the buffer: its JSON and a
|
|
// newline.
|
|
func lineBytes(t *testing.T, report any) int {
|
|
t.Helper()
|
|
|
|
line, err := json.Marshal(report)
|
|
if err != nil {
|
|
t.Fatalf("marshal report: %v", err)
|
|
}
|
|
|
|
return len(line) + 1
|
|
}
|
|
|
|
func TestAppendPastCapIsRefused(t *testing.T) {
|
|
report := map[string]string{"id": "cap"}
|
|
|
|
t.Setenv("DATA_DIR", t.TempDir())
|
|
t.Setenv("DATA_DIR_MAX_BYTES", strconv.Itoa(lineBytes(t, report)))
|
|
|
|
buf := startBuffer(t)
|
|
|
|
err := buf.Append(report)
|
|
if err != nil {
|
|
t.Fatalf("report that fills the cap exactly: %v", err)
|
|
}
|
|
|
|
err = buf.Append(report)
|
|
if !errors.Is(err, reportbuf.ErrFull) {
|
|
t.Fatalf("report past the cap: error = %v, want ErrFull", err)
|
|
}
|
|
}
|
|
|
|
// TestCapCountsReportFilesAlreadyInDataDir starts on a data
|
|
// directory holding a report file from an earlier run, and a file
|
|
// that is not a report, which must not count.
|
|
func TestCapCountsReportFilesAlreadyInDataDir(t *testing.T) {
|
|
const earlierBytes = 100
|
|
|
|
report := map[string]string{"id": "cap"}
|
|
dir := t.TempDir()
|
|
|
|
writeBytes(t, filepath.Join(dir, "reports-2026-01-01T00-00-00.000Z.jsonl.zst"),
|
|
earlierBytes)
|
|
writeBytes(t, filepath.Join(dir, "notes.txt"), 10*earlierBytes)
|
|
|
|
t.Setenv("DATA_DIR", dir)
|
|
t.Setenv("DATA_DIR_MAX_BYTES",
|
|
strconv.Itoa(earlierBytes+lineBytes(t, report)))
|
|
|
|
buf := startBuffer(t)
|
|
|
|
err := buf.Append(report)
|
|
if err != nil {
|
|
t.Fatalf("report that fills the cap exactly: %v", err)
|
|
}
|
|
|
|
err = buf.Append(report)
|
|
if !errors.Is(err, reportbuf.ErrFull) {
|
|
t.Fatalf("report past the cap: error = %v, want ErrFull", err)
|
|
}
|
|
}
|
|
|
|
// TestWrittenReportsCountAtFileSize checks that once reports are
|
|
// written, they count as their compressed file, not their
|
|
// uncompressed size, which frees room under the cap.
|
|
func TestWrittenReportsCountAtFileSize(t *testing.T) {
|
|
// Repetitive, so its file is far smaller than its JSON.
|
|
report := map[string]string{"id": strings.Repeat("a", 1000)}
|
|
size := lineBytes(t, report)
|
|
|
|
t.Setenv("DATA_DIR", t.TempDir())
|
|
// Room for the report twice over only if the first one counts
|
|
// at its file's size by the time the second arrives.
|
|
t.Setenv("DATA_DIR_MAX_BYTES", strconv.Itoa(2*size-1))
|
|
|
|
buf := startBuffer(t)
|
|
|
|
err := buf.Append(report)
|
|
if err != nil {
|
|
t.Fatalf("first report: %v", err)
|
|
}
|
|
|
|
err = buf.Flush()
|
|
if err != nil {
|
|
t.Fatalf("flush: %v", err)
|
|
}
|
|
|
|
err = buf.Append(report)
|
|
if err != nil {
|
|
t.Fatalf("second report, after the first was written: %v", err)
|
|
}
|
|
}
|
|
|
|
func writeBytes(t *testing.T, path string, n int) {
|
|
t.Helper()
|
|
|
|
err := os.WriteFile(path, make([]byte, n), 0o600)
|
|
if err != nil {
|
|
t.Fatalf("write %s: %v", path, err)
|
|
}
|
|
}
|
|
|
|
func hasReportFile(t *testing.T, dir string) bool {
|
|
t.Helper()
|
|
|
|
entries, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
t.Fatalf("read data dir: %v", err)
|
|
}
|
|
|
|
for _, e := range entries {
|
|
if strings.HasSuffix(e.Name(), ".jsonl.zst") {
|
|
info, statErr := e.Info()
|
|
if statErr != nil {
|
|
t.Fatalf("stat %s: %v", e.Name(), statErr)
|
|
}
|
|
|
|
if info.Size() > 0 {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|