Compare commits

...
5 Commits
Author SHA1 Message Date
popertots ac104e19c5 fix: remove rate limiter dev stub, consolidate tests under all build tags
CI / Docker compose check (push) Successful in 13s
CI / Env docs check (push) Successful in 14s
CI / Nginx config check (push) Successful in 15s
CI / Frontend major deps (push) Successful in 25s
CI / Frontend deps check (push) Successful in 26s
CI / Secrets scan (push) Successful in 35s
CI / Go build (push) Successful in 42s
CI / Frontend build (push) Successful in 45s
CI / Knip (push) Successful in 23s
CI / Frontend a11y check (push) Successful in 1m13s
CI / go mod tidy (push) Successful in 37s
CI / Go vet (prod) (push) Successful in 2m5s
CI / Go vet (dev) (push) Successful in 2m16s
CI / Frontend QC (audit) (push) Successful in 45s
CI / Go vulnerabilities (push) Successful in 1m29s
CI / Staticcheck (prod) (push) Successful in 3m3s
CI / Staticcheck (dev) (push) Successful in 3m46s
CI / Frontend QC (typecheck) (push) Successful in 2m1s
CI / golangci-lint (push) Successful in 4m19s
CI / Security scan (prod) (push) Successful in 4m23s
CI / Security scan (dev) (push) Successful in 4m35s
CI / Frontend QC (lint) (push) Successful in 1m51s
CI / Svelte strict check (push) Successful in 1m21s
CI / Tests (prod) (push) Successful in 3m39s
CI / Tests (dev) (push) Failing after 3m50s
CI / Race (prod) (push) Successful in 7m4s
CI / Race (dev) (push) Failing after 7m12s
2026-07-11 14:41:58 +01:00
popertots 9b235a1298 fix: add error logging to 3 non-aggregate nolint:errcheck sites (duration lookup, deposit-paid scans) 2026-07-11 14:41:06 +01:00
popertots d410dce0e0 fix: gift card friend purchase notifies admin, frontend no longer falsely claims email sent 2026-07-11 14:39:54 +01:00
popertots 9f9f0eed98 fix: add error logging to discount campaign payment/times_redeemed execs 2026-07-11 14:37:47 +01:00
popertots 7a51bcc49a fix: JTI revocation returns error, LogoutHandler returns 500 on failure 2026-07-11 14:35:34 +01:00
15 changed files with 101 additions and 343 deletions
+10 -10
View File
@@ -38,35 +38,35 @@ func generateJTI() (string, error) {
b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]), nil
}
// RevokeJTI adds a JTI to the revoked set in PostgreSQL
func RevokeJTI(ctx context.Context, jti string, expiresAt time.Time) {
// RevokeJTI adds a JTI to the revoked set in PostgreSQL.
// Returns an error if the operation fails.
func RevokeJTI(ctx context.Context, jti string, expiresAt time.Time) error {
if db.Conn == nil {
return
return fmt.Errorf("revoke JTI: db.Conn is nil")
}
tx, err := db.Conn.Begin(ctx)
if err != nil {
log.Printf("WARN: Failed to begin transaction for JTI revocation: %v", err)
return
return fmt.Errorf("revoke JTI: begin transaction: %w", err)
}
defer func() {
if err := tx.Rollback(ctx); err != nil && err.Error() != "tx is closed" {
slog.Error("failed to rollback transaction", "err", err)
}
}()
}()
_, err = tx.Exec(ctx,
`INSERT INTO revoked_jtis (jti, expires_at) VALUES ($1, $2)
ON CONFLICT (jti) DO NOTHING`,
jti, expiresAt)
if err != nil {
// Log but don't fail - this is best effort
log.Printf("WARN: Failed to revoke JTI %s: %v", jti, err)
return
return fmt.Errorf("revoke JTI %s: %w", jti, err)
}
if err := tx.Commit(ctx); err != nil {
log.Printf("WARN: Failed to commit transaction for JTI revocation: %v", err)
return fmt.Errorf("revoke JTI: commit transaction: %w", err)
}
return nil
}
// IsJTIRevoked checks if a JTI is in the revoked set via PostgreSQL.
+16 -6
View File
@@ -165,7 +165,9 @@ func TestVerifyToken_RevokedJTI(t *testing.T) {
t.Fatalf("GenerateToken() failed: %v", err)
}
RevokeJTI(ctx, jti, clock.Now().Add(30*24*time.Hour))
if err := RevokeJTI(ctx, jti, clock.Now().Add(30*24*time.Hour)); err != nil {
t.Fatalf("RevokeJTI() failed: %v", err)
}
_, _, _, err = VerifyToken(token, ctx)
if err == nil {
@@ -225,7 +227,9 @@ func TestRevokeJTI_AddsToSet(t *testing.T) {
t.Fatal("JTI should not be revoked before calling RevokeJTI")
}
RevokeJTI(ctx, jti, clock.Now().Add(30*24*time.Hour))
if err := RevokeJTI(ctx, jti, clock.Now().Add(30*24*time.Hour)); err != nil {
t.Fatalf("RevokeJTI() failed: %v", err)
}
if !IsJTIRevoked(ctx, jti) {
t.Error("expected IsJTIRevoked to return true after RevokeJTI")
@@ -255,7 +259,9 @@ func TestCleanupRevokedJTIs_RemovesExpired(t *testing.T) {
}
// Add with future expiry so IsJTIRevoked sees it
RevokeJTI(ctx, jti, clock.Now().Add(1*time.Hour))
if err := RevokeJTI(ctx, jti, clock.Now().Add(1*time.Hour)); err != nil {
t.Fatalf("RevokeJTI() failed: %v", err)
}
if !IsJTIRevoked(ctx, jti) {
t.Fatal("JTI should be in revoked set after RevokeJTI")
@@ -285,7 +291,9 @@ func TestCleanupRevokedJTIs_KeepsValid(t *testing.T) {
}
// Add with future expiry
RevokeJTI(ctx, jti, clock.Now().Add(30*24*time.Hour))
if err := RevokeJTI(ctx, jti, clock.Now().Add(30*24*time.Hour)); err != nil {
t.Fatalf("RevokeJTI() failed: %v", err)
}
if !IsJTIRevoked(ctx, jti) {
t.Fatal("JTI should be in revoked set before cleanup")
@@ -491,8 +499,10 @@ func TestRevokeJTI_NilConn(t *testing.T) {
db.Conn = nil
t.Cleanup(func() { db.Conn = savedConn })
// Should not panic when db.Conn is nil
RevokeJTI(context.Background(), "test-jti", time.Now())
// Should return an error when db.Conn is nil
if err := RevokeJTI(context.Background(), "test-jti", time.Now()); err == nil {
t.Error("expected error when db.Conn is nil, got nil")
}
}
// TestIsJTIRevoked_NilConn verifies that IsJTIRevoked returns false when db.Conn is nil.
+3 -1
View File
@@ -1731,7 +1731,9 @@ func TestJTI_Revocation_PostgreSQL(t *testing.T) {
t.Fatalf("token should be valid before revocation: %v", err)
}
auth.RevokeJTI(ctx, jti, clock.Now().Add(1*time.Hour))
if err := auth.RevokeJTI(ctx, jti, clock.Now().Add(1*time.Hour)); err != nil {
t.Fatalf("RevokeJTI() failed: %v", err)
}
if !auth.IsJTIRevoked(ctx, jti) {
t.Error("JTI should be revoked after RevokeJTI call")
+9 -2
View File
@@ -485,7 +485,10 @@ func RefreshTokenHandler(w http.ResponseWriter, r *http.Request) {
// Revoke the old token's JTI before issuing a new one (rotation)
if oldJTI != "" {
auth.RevokeJTI(r.Context(), oldJTI, clock.Now().Add(90*24*time.Hour)) // match refresh token lifetime
// Best-effort revocation: log the error but continue with the refresh
if err := auth.RevokeJTI(r.Context(), oldJTI, clock.Now().Add(90*24*time.Hour)); err != nil {
slog.Error("refresh: failed to revoke old JTI", "oldJTI", oldJTI, "err", err)
}
}
// Generate new token
@@ -510,7 +513,11 @@ func LogoutHandler(w http.ResponseWriter, r *http.Request) {
}
// Revoke the JTI — match the access token lifetime (1 hour)
auth.RevokeJTI(r.Context(), jti, clock.Now().Add(1*time.Hour))
if err := auth.RevokeJTI(r.Context(), jti, clock.Now().Add(1*time.Hour)); err != nil {
slog.Error("logout: failed to revoke JTI", "err", err)
http.Error(w, "Failed to revoke token. Please try again.", http.StatusInternalServerError)
return
}
_ = json.NewEncoder(w).Encode(map[string]bool{"success": true})
}
+6 -6
View File
@@ -1998,8 +1998,9 @@ func CreateBookingHandler(w http.ResponseWriter, r *http.Request) {
// Get payment info
var preStartPaid float64
//nolint:errcheck // zero value is acceptable fallback on scan failure
_ = db.Conn.QueryRow(r.Context(), `SELECT COALESCE(SUM(amount), 0) FROM payments WHERE booking_id = $1 AND payment_type IN ('deposit', 'full') AND status = 'completed'`, existingBooking.ID).Scan(&preStartPaid)
if err := db.Conn.QueryRow(r.Context(), `SELECT COALESCE(SUM(amount), 0) FROM payments WHERE booking_id = $1 AND payment_type IN ('deposit', 'full') AND status = 'completed'`, existingBooking.ID).Scan(&preStartPaid); err != nil {
log.Printf("Failed to scan preStartPaid for booking %s: %v", existingBooking.ID, err)
}
populateDepositFields(&existingBooking, existingBooking.DepositRequired, preStartPaid)
w.Header().Set("Content-Type", "application/json")
@@ -3158,11 +3159,10 @@ func ConfirmBookingHandler(w http.ResponseWriter, r *http.Request) {
if dav.Service != nil {
var durationMinutes int
//nolint:errcheck // zero value is acceptable fallback on scan failure
_ = db.Conn.QueryRow(r.Context(), `
if err := db.Conn.QueryRow(r.Context(), `
SELECT total_duration_minutes FROM bookings WHERE id = $1
`, bookingID).Scan(&durationMinutes)
if durationMinutes == 0 {
`, bookingID).Scan(&durationMinutes); err != nil {
log.Printf("ALERT: failed to scan total_duration_minutes for booking %s: %v", bookingID, err)
durationMinutes = 60
}
_ = dav.Service.CreateEvent(1, dav.EventInput{
+3 -2
View File
@@ -413,8 +413,9 @@ func AdminCreateBookingForUserHandler(w http.ResponseWriter, r *http.Request) {
// Get deposit info — deposit_required already fetched above in the main booking query.
var preStartPaid float64
//nolint:errcheck // zero value is acceptable fallback on scan failure (aggregate with COALESCE)
_ = db.Conn.QueryRow(r.Context(), `SELECT COALESCE(SUM(amount), 0) FROM payments WHERE booking_id = $1 AND payment_type IN ('deposit', 'full') AND status = 'completed'`, existingID).Scan(&preStartPaid)
if err := db.Conn.QueryRow(r.Context(), `SELECT COALESCE(SUM(amount), 0) FROM payments WHERE booking_id = $1 AND payment_type IN ('deposit', 'full') AND status = 'completed'`, existingID).Scan(&preStartPaid); err != nil {
log.Printf("Failed to scan preStartPaid for booking %s: %v", existingID, err)
}
populateDepositFields(&existingBooking, existingBooking.DepositRequired, preStartPaid)
w.Header().Set("Content-Type", "application/json")
+8 -1
View File
@@ -1080,7 +1080,14 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) {
}
recipient = userEmail
}
log.Printf("[TODO EMAIL] Send gift card code %s (Value: £%.2f) to %s", cardID, amountPounds, recipient)
// Notify admin about the friend gift card (email delivery not yet implemented — admin must send manually)
if _, err := db.Conn.Exec(ctx, `
INSERT INTO admin_notifications (reason, booking_id, user_id)
VALUES ('gift_card_purchased_for_friend', $1, $2)
`, cardID, userID); err != nil {
log.Printf("ALERT: failed to create admin notification for gift card %s: %v", cardID, err)
}
log.Printf("GIFT CARD FOR FRIEND — code: %s, value: £%.2f, intended for: %s", cardID, amountPounds, recipient)
_, err = db.Conn.Exec(ctx, `
INSERT INTO gift_card_transactions (gift_card_id, transaction_type, amount, reference_type, reference_id, user_id, notes)
+40 -30
View File
@@ -1123,15 +1123,17 @@ func applyEligibleCampaignsAtPayment(ctx context.Context, q db.Querier, bookingI
`, bookingID, userID, campaignID, campaignPercent, bookingTotal, discountAmount); err != nil {
log.Printf("Failed to insert time-based campaign discount: %v", err)
} else {
//nolint:errcheck // exec errors are non-critical; best-effort inserts/updates
_, _ = q.Exec(ctx, `
if _, err := q.Exec(ctx, `
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by)
VALUES ($1, 'partial', 'discount', $2, 'completed', $3)
`, bookingID, discountAmount, userID)
//nolint:errcheck // exec errors are non-critical; best-effort inserts/updates
_, _ = q.Exec(ctx, `
`, bookingID, discountAmount, userID); err != nil {
log.Printf("ALERT: failed to insert discount payment record for campaign %s, booking %s: %v", campaignID, bookingID, err)
}
if _, err := q.Exec(ctx, `
UPDATE discount_campaigns SET times_redeemed = times_redeemed + 1 WHERE id = $1
`, campaignID)
`, campaignID); err != nil {
log.Printf("ALERT: failed to increment times_redeemed for campaign %s, booking %s: %v", campaignID, bookingID, err)
}
}
}
}
@@ -1163,15 +1165,17 @@ func applyEligibleCampaignsAtPayment(ctx context.Context, q db.Querier, bookingI
`, bookingID, userID, milestoneCampaignID, milestonePercent, bookingTotal, discountAmount); err != nil {
log.Printf("Failed to insert per-user milestone discount: %v", err)
} else {
//nolint:errcheck // exec errors are non-critical; best-effort inserts/updates
_, _ = q.Exec(ctx, `
if _, err := q.Exec(ctx, `
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by)
VALUES ($1, 'partial', 'discount', $2, 'completed', $3)
`, bookingID, discountAmount, userID)
//nolint:errcheck // exec errors are non-critical; best-effort inserts/updates
_, _ = q.Exec(ctx, `
`, bookingID, discountAmount, userID); err != nil {
log.Printf("ALERT: failed to insert discount payment record for campaign %s, booking %s: %v", milestoneCampaignID, bookingID, err)
}
if _, err := q.Exec(ctx, `
UPDATE discount_campaigns SET times_redeemed = times_redeemed + 1 WHERE id = $1
`, milestoneCampaignID)
`, milestoneCampaignID); err != nil {
log.Printf("ALERT: failed to increment times_redeemed for campaign %s, booking %s: %v", milestoneCampaignID, bookingID, err)
}
}
}
}
@@ -1227,15 +1231,17 @@ func applyEligibleCampaignsAtPayment(ctx context.Context, q db.Querier, bookingI
`, bookingID, userID, c.id, c.pct, bookingTotal, discountAmount); err != nil {
log.Printf("Failed to insert anniversary discount: %v", err)
} else {
//nolint:errcheck // exec errors are non-critical; best-effort inserts/updates
_, _ = q.Exec(ctx, `
if _, err := q.Exec(ctx, `
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by)
VALUES ($1, 'partial', 'discount', $2, 'completed', $3)
`, bookingID, discountAmount, userID)
//nolint:errcheck // exec errors are non-critical; best-effort inserts/updates
_, _ = q.Exec(ctx, `
`, bookingID, discountAmount, userID); err != nil {
log.Printf("ALERT: failed to insert discount payment record for campaign %s, booking %s: %v", c.id, bookingID, err)
}
if _, err := q.Exec(ctx, `
UPDATE discount_campaigns SET times_redeemed = times_redeemed + 1 WHERE id = $1
`, c.id)
`, c.id); err != nil {
log.Printf("ALERT: failed to increment times_redeemed for campaign %s, booking %s: %v", c.id, bookingID, err)
}
}
break
}
@@ -1278,15 +1284,17 @@ func applyEligibleCampaignsAtPayment(ctx context.Context, q db.Querier, bookingI
`, bookingID, userID, globalCampaignID, globalPercent, bookingTotal, discountAmount); err != nil {
log.Printf("Failed to insert global milestone discount: %v", err)
} else {
//nolint:errcheck // exec errors are non-critical; best-effort inserts/updates
_, _ = q.Exec(ctx, `
if _, err := q.Exec(ctx, `
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by)
VALUES ($1, 'partial', 'discount', $2, 'completed', $3)
`, bookingID, discountAmount, userID)
//nolint:errcheck // exec errors are non-critical; best-effort inserts/updates
_, _ = q.Exec(ctx, `
`, bookingID, discountAmount, userID); err != nil {
log.Printf("ALERT: failed to insert discount payment record for campaign %s, booking %s: %v", globalCampaignID, bookingID, err)
}
if _, err := q.Exec(ctx, `
UPDATE discount_campaigns SET times_redeemed = times_redeemed + 1 WHERE id = $1
`, globalCampaignID)
`, globalCampaignID); err != nil {
log.Printf("ALERT: failed to increment times_redeemed for campaign %s, booking %s: %v", globalCampaignID, bookingID, err)
}
}
}
}
@@ -1310,15 +1318,17 @@ func applyEligibleCampaignsAtPayment(ctx context.Context, q db.Querier, bookingI
INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, milestone_type, discount_percent, original_total, discount_amount)
VALUES ($1, $2, 'referral', $3, NULL, NULL, $4, $5, $6)
`, bookingID, userID, rdID, rdPercent, bookingTotal, discountAmount); err == nil {
//nolint:errcheck // exec errors are non-critical; best-effort inserts/updates
_, _ = q.Exec(ctx, `
if _, err := q.Exec(ctx, `
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by)
VALUES ($1, 'partial', 'discount', $2, 'completed', $3)
`, bookingID, discountAmount, userID)
//nolint:errcheck // exec errors are non-critical; best-effort inserts/updates
_, _ = q.Exec(ctx, `
`, bookingID, discountAmount, userID); err != nil {
log.Printf("ALERT: failed to insert discount payment record for campaign %s, booking %s: %v", rdID, bookingID, err)
}
if _, err := q.Exec(ctx, `
UPDATE referral_discounts SET used = TRUE, used_at = NOW() WHERE id = $1
`, rdID)
`, rdID); err != nil {
log.Printf("ALERT: failed to mark referral discount as used, booking %s: %v", bookingID, err)
}
}
}
}
-2
View File
@@ -1,5 +1,3 @@
//go:build !dev
package mw
import (
-42
View File
@@ -1,42 +0,0 @@
//go:build dev
package mw
import (
"net/http"
"time"
)
func NewRateLimiter(limit int, window time.Duration) *RateLimiter {
rl := &RateLimiter{
requests: make(map[string][]time.Time),
limit: limit,
window: window,
}
registerLimiter(rl)
return rl
}
func (rl *RateLimiter) Allow(key string) bool { return true }
func NewProgressiveRateLimiter() *ProgressiveRateLimiter {
return &ProgressiveRateLimiter{
requests: make(map[string]*ipProgressiveState),
}
}
func (prl *ProgressiveRateLimiter) Check(ip string) int { return 0 }
func ProgressiveRateLimit(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
next.ServeHTTP(w, r)
})
}
func RateLimit(limit int, window time.Duration) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
next.ServeHTTP(w, r)
})
}
}
-231
View File
@@ -1,231 +0,0 @@
//go:build test && dev
package mw
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"crussell/clock"
"github.com/stretchr/testify/assert"
)
// ============================================================
// Dev Stub Behavioral Tests
// ============================================================
// TestDevRateLimiter_AllowAlwaysTrue verifies the dev stub never throttles.
func TestDevRateLimiter_AllowAlwaysTrue(t *testing.T) {
rl := NewRateLimiter(10, time.Minute)
if !rl.Allow("any-key") {
t.Error("expected Allow to return true for dev stub")
}
if !rl.Allow("another-key") {
t.Error("expected Allow to return true for dev stub (different key)")
}
// Even an empty key should pass
if !rl.Allow("") {
t.Error("expected Allow to return true for empty key in dev stub")
}
}
// TestDevProgressiveRateLimiter_CheckAlwaysZero verifies the dev stub never delays.
func TestDevProgressiveRateLimiter_CheckAlwaysZero(t *testing.T) {
prl := NewProgressiveRateLimiter()
for i := 0; i < 200; i++ {
delay := prl.Check("10.0.0.1")
if delay != 0 {
t.Errorf("expected 0 delay for dev stub (request %d), got %d", i+1, delay)
}
}
// Different IP also returns 0
if delay := prl.Check("10.0.0.2"); delay != 0 {
t.Errorf("expected 0 delay for different IP in dev stub, got %d", delay)
}
}
func TestProgressiveRateLimiter_CleanupRemovesStaleEntries(t *testing.T) {
prl := NewProgressiveRateLimiter()
prl.mu.Lock()
prl.requests["stale-ip"] = &ipProgressiveState{
timestamps: []time.Time{clock.Now().Add(-120 * time.Second)},
}
prl.mu.Unlock()
prl.Cleanup()
prl.mu.RLock()
_, exists := prl.requests["stale-ip"]
prl.mu.RUnlock()
if exists {
t.Error("expected stale IP to be cleaned up")
}
}
func TestRateLimiter_Cleanup(t *testing.T) {
rl := NewRateLimiter(10, time.Minute)
rl.mu.Lock()
rl.requests["stale-key"] = []time.Time{clock.Now().Add(-5 * time.Minute)}
rl.requests["fresh-key"] = []time.Time{clock.Now()}
rl.mu.Unlock()
rl.Cleanup()
rl.mu.RLock()
_, staleExists := rl.requests["stale-key"]
_, freshExists := rl.requests["fresh-key"]
rl.mu.RUnlock()
if staleExists {
t.Error("expected stale-key to be removed")
}
if !freshExists {
t.Error("expected fresh-key to be preserved")
}
}
func TestRateLimiter_Cleanup_EmptyMap(t *testing.T) {
rl := NewRateLimiter(10, time.Minute)
rl.mu.Lock()
rl.requests = make(map[string][]time.Time)
rl.mu.Unlock()
rl.Cleanup()
rl.mu.RLock()
count := len(rl.requests)
rl.mu.RUnlock()
if count != 0 {
t.Errorf("expected empty map, got %d entries", count)
}
}
func TestCleanupAllRateLimiters(t *testing.T) {
registeredLimitersMu.Lock()
saved := registeredLimiters
registeredLimitersMu.Unlock()
defer func() {
registeredLimitersMu.Lock()
registeredLimiters = saved
registeredLimitersMu.Unlock()
}()
rl1 := NewRateLimiter(10, time.Minute)
rl2 := NewRateLimiter(20, time.Minute)
rl1.mu.Lock()
rl1.requests["rl1-stale"] = []time.Time{clock.Now().Add(-5 * time.Minute)}
rl1.mu.Unlock()
rl2.mu.Lock()
rl2.requests["rl2-stale"] = []time.Time{clock.Now().Add(-5 * time.Minute)}
rl2.mu.Unlock()
_, err := CleanupAllRateLimiters(context.Background())
if err != nil {
t.Errorf("expected nil error, got %v", err)
}
rl1.mu.RLock()
_, rl1Stale := rl1.requests["rl1-stale"]
rl1.mu.RUnlock()
rl2.mu.RLock()
_, rl2Stale := rl2.requests["rl2-stale"]
rl2.mu.RUnlock()
if rl1Stale {
t.Error("expected rl1-stale to be removed")
}
if rl2Stale {
t.Error("expected rl2-stale to be removed")
}
}
func TestCleanupAllRateLimiters_Empty(t *testing.T) {
registeredLimitersMu.Lock()
saved := registeredLimiters
registeredLimiters = nil
registeredLimitersMu.Unlock()
defer func() {
registeredLimitersMu.Lock()
registeredLimiters = saved
registeredLimitersMu.Unlock()
}()
_, err := CleanupAllRateLimiters(context.Background())
if err != nil {
t.Errorf("expected nil error, got %v", err)
}
}
func TestCleanupProgressiveRateLimiter(t *testing.T) {
globalProgressiveLimiter.mu.Lock()
saved := globalProgressiveLimiter.requests
globalProgressiveLimiter.requests = map[string]*ipProgressiveState{
"global-stale": {timestamps: []time.Time{clock.Now().Add(-120 * time.Second)}},
}
globalProgressiveLimiter.mu.Unlock()
defer func() {
globalProgressiveLimiter.mu.Lock()
globalProgressiveLimiter.requests = saved
globalProgressiveLimiter.mu.Unlock()
}()
_, err := CleanupProgressiveRateLimiter(context.Background())
if err != nil {
t.Errorf("expected nil error, got %v", err)
}
globalProgressiveLimiter.mu.RLock()
_, exists := globalProgressiveLimiter.requests["global-stale"]
globalProgressiveLimiter.mu.RUnlock()
if exists {
t.Error("expected global-stale to be removed")
}
}
// ============================================================
// Dev Stub Middleware Pass-Through Tests
// ============================================================
// TestProgressiveRateLimit_PassThrough verifies the dev stub middleware
// passes through to the next handler without rate limiting.
func TestProgressiveRateLimit_PassThrough(t *testing.T) {
handler := ProgressiveRateLimit(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
}))
req := httptest.NewRequest("GET", "/", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
assert.Equal(t, "ok", w.Body.String())
}
// TestRateLimit_PassThrough verifies the dev stub middleware
// passes through to the next handler without rate limiting.
func TestRateLimit_PassThrough(t *testing.T) {
handler := RateLimit(10, time.Minute)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
}))
req := httptest.NewRequest("GET", "/", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
assert.Equal(t, "ok", w.Body.String())
}
-2
View File
@@ -1,5 +1,3 @@
//go:build test && !dev
package mw
import (
-2
View File
@@ -1,5 +1,3 @@
//go:build test && !dev
package mw
import (
+2 -2
View File
@@ -2296,8 +2296,8 @@
>
{formatCardCode(purchaseResultCode)}
</div>
<p class="text-[10px] text-green-600 italic">
Please save this code! It has been emailed to the recipient.
<p class="text-[10px] text-amber-600 italic font-semibold">
⚠️ Please save this code and send it to your friend — no email was sent.
</p>
{/if}
<Button
+1 -1
View File
@@ -800,7 +800,7 @@ INSERT INTO business_settings (
'https://www.website.co.uk'
);
CREATE TYPE admin_notification_reason AS ENUM ('pending_booking', 'cancelled_booking', 'rescheduled_booking', '1_week_no_pay', '1_month_no_pay', 'affiliate_claim', 'late_cancellation', 'deposit_paid', 'edit_request', 'edit_requested', 'new_booking', 'deposit_not_paid_by_deadline');
CREATE TYPE admin_notification_reason AS ENUM ('pending_booking', 'cancelled_booking', 'rescheduled_booking', '1_week_no_pay', '1_month_no_pay', 'affiliate_claim', 'late_cancellation', 'deposit_paid', 'edit_request', 'edit_requested', 'new_booking', 'deposit_not_paid_by_deadline', 'gift_card_purchased_for_friend');
CREATE TABLE admin_notifications (
id CHAR(12) PRIMARY KEY DEFAULT generate_admin_notifications_id(),