Notifications: - Warn once when a counter reaches warn_percent of its limit, through separate warn_*_url endpoints. - Write ntf/ markers so a lost Redis reply cannot re-send a breach. - Retry only when the request never left or the web app answered 429/5xx. Fixes: - Retry transactions Redis rejected; treat pool timeouts as definite. - Install the signal handler before startup work. - Stop logging the login suffix; redact notify URL credentials. - Reject corrupt or empty CDBs, truncated hash tables included. - Require a Sentinel password, refuse example placeholders, and default admin_addr to localhost. - Normalise /counters domains, count on the arrival day, log rejections, hide internals in errors and exit 1 on a failed drain. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
133 lines
3.8 KiB
Go
133 lines
3.8 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net"
|
|
"testing"
|
|
"time"
|
|
|
|
"migadu_counters/internal/config"
|
|
)
|
|
|
|
func TestMarkerKey(t *testing.T) {
|
|
got := MarkerKey("A", "x@example.com", "2026-10-13", "o", MarkerLimit)
|
|
if want := "ntf/A/x@example.com/d/2026-10-13/o/limit"; got != want {
|
|
t.Errorf("MarkerKey = %q, want %q", got, want)
|
|
}
|
|
if got := MarkerKey("O", "7", "2026-10-13", "i", MarkerWarn); got != "ntf/O/7/d/2026-10-13/i/warn" {
|
|
t.Errorf("MarkerKey = %q", got)
|
|
}
|
|
}
|
|
|
|
func TestMarkAndClaimNotified(t *testing.T) {
|
|
s, w, _ := testStore(t)
|
|
ctx := context.Background()
|
|
if err := s.MarkNotified(ctx, "ntf/a"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if ttl := w.TTL("ntf/a"); ttl != MarkerTTL {
|
|
t.Errorf("TTL = %v, want %v", ttl, MarkerTTL)
|
|
}
|
|
// Marking again is not an error: a reset counter may cross again.
|
|
if err := s.MarkNotified(ctx, "ntf/a"); err != nil {
|
|
t.Errorf("second mark: %v", err)
|
|
}
|
|
ok, err := s.ClaimNotified(ctx, "ntf/a")
|
|
if err != nil || ok {
|
|
t.Errorf("claim of a marked key = %v, %v; want false", ok, err)
|
|
}
|
|
ok, err = s.ClaimNotified(ctx, "ntf/b")
|
|
if err != nil || !ok {
|
|
t.Errorf("first claim = %v, %v; want true", ok, err)
|
|
}
|
|
if ttl := w.TTL("ntf/b"); ttl != MarkerTTL {
|
|
t.Errorf("claim TTL = %v, want %v", ttl, MarkerTTL)
|
|
}
|
|
ok, err = s.ClaimNotified(ctx, "ntf/b")
|
|
if err != nil || ok {
|
|
t.Errorf("second claim = %v, %v; want false", ok, err)
|
|
}
|
|
}
|
|
|
|
func TestClaimNotified_RedisDown(t *testing.T) {
|
|
s, w, _ := testStore(t)
|
|
w.Close()
|
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
|
defer cancel()
|
|
if ok, err := s.ClaimNotified(ctx, "ntf/a"); err == nil || ok {
|
|
t.Errorf("claim with redis down = %v, %v; want an error and false", ok, err)
|
|
}
|
|
}
|
|
|
|
// No connection could be had from the pool: no command was ever sent.
|
|
func TestOutcomeUnknown_PoolTimeoutIsDefinite(t *testing.T) {
|
|
if OutcomeUnknown(errors.New("redis incr: redis: connection pool timeout")) {
|
|
t.Error("a pool timeout never reached Redis and must not be an unknown outcome")
|
|
}
|
|
}
|
|
|
|
func TestOutcomeUnknown_NotAppliedIsDefinite(t *testing.T) {
|
|
if OutcomeUnknown(ErrNotApplied) {
|
|
t.Error("ErrNotApplied must not be an unknown outcome")
|
|
}
|
|
}
|
|
|
|
// Transaction-level rejection: every command failed, nothing was applied.
|
|
func TestIncr_RejectedTransactionIsNotApplied(t *testing.T) {
|
|
s, w, _ := testStore(t)
|
|
w.SetError("READONLY You can't write against a read only replica.")
|
|
defer w.SetError("")
|
|
_, err := s.Incr(context.Background(), []string{"a", "b"})
|
|
if !errors.Is(err, ErrNotApplied) {
|
|
t.Fatalf("err = %v, want ErrNotApplied", err)
|
|
}
|
|
}
|
|
|
|
// All INCRs failing on non-numbers is still per-key: the EXPIREs succeeded and
|
|
// EXEC ran, so it must not be mistaken for a rejected transaction.
|
|
func TestIncr_AllKeysNonNumericIsPerKey(t *testing.T) {
|
|
s, w, _ := testStore(t)
|
|
w.Set("a", "x")
|
|
w.Set("b", "y")
|
|
res, err := s.Incr(context.Background(), []string{"a", "b"})
|
|
if err != nil {
|
|
t.Fatalf("per-key failures must not fail the transaction: %v", err)
|
|
}
|
|
if res[0].Err == nil || res[1].Err == nil {
|
|
t.Errorf("results = %+v", res)
|
|
}
|
|
}
|
|
|
|
// A context deadline must bound socket time, not only the configured timeout.
|
|
func TestPing_HonoursContextDeadline(t *testing.T) {
|
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer ln.Close()
|
|
go func() { // accepts and never answers
|
|
for {
|
|
c, err := ln.Accept()
|
|
if err != nil {
|
|
return
|
|
}
|
|
defer c.Close()
|
|
}
|
|
}()
|
|
s, err := New(config.RedisConfig{WriteAddr: ln.Addr().String(), PoolSize: 2, TimeoutMS: 3000})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer s.Close()
|
|
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
|
defer cancel()
|
|
start := time.Now()
|
|
if err := s.Ping(ctx); err == nil {
|
|
t.Fatal("expected a timeout")
|
|
}
|
|
if d := time.Since(start); d > time.Second {
|
|
t.Errorf("Ping took %v with a 100ms deadline", d)
|
|
}
|
|
}
|