163 lines
5.4 KiB
Go
163 lines
5.4 KiB
Go
package bootstrap
|
|
|
|
import (
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
|
|
"agent-desk/internal/pkg/config"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
)
|
|
|
|
func parseInt(t *testing.T, value string) int {
|
|
t.Helper()
|
|
parsed, err := strconv.Atoi(value)
|
|
if err != nil {
|
|
t.Fatalf("%q is not an integer", value)
|
|
}
|
|
return parsed
|
|
}
|
|
|
|
// setRateLimitTestConfig disables password login so /api/auth/login returns
|
|
// before it reaches the database. The limiter runs ahead of the handler either
|
|
// way, which is what these tests are about.
|
|
func setRateLimitTestConfig(rateLimit config.RateLimitConfig) {
|
|
disabled := false
|
|
config.SetCurrent(&config.Config{
|
|
Server: config.ServerConfig{
|
|
RateLimit: rateLimit,
|
|
CORS: config.CORSConfig{AllowedOrigins: []string{}},
|
|
},
|
|
Auth: config.AuthConfig{PasswordLoginEnabled: &disabled},
|
|
Storage: config.StorageConfig{Local: config.LocalStorageConfig{Root: "storage", BaseURL: "/storage"}},
|
|
})
|
|
}
|
|
|
|
func postJSON(app *gin.Engine, path, clientIP string) *httptest.ResponseRecorder {
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(`{"username":"admin","password":"secret"}`))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.RemoteAddr = clientIP + ":52000"
|
|
app.ServeHTTP(rec, req)
|
|
return rec
|
|
}
|
|
|
|
func TestNewServerRateLimitsThePublicLoginEndpoint(t *testing.T) {
|
|
setRateLimitTestConfig(config.RateLimitConfig{})
|
|
app, err := NewServer()
|
|
if err != nil {
|
|
t.Fatalf("NewServer() error = %v", err)
|
|
}
|
|
|
|
for i := 1; i <= limitLogin; i++ {
|
|
if rec := postJSON(app, "/api/auth/login", "203.0.113.7"); rec.Code == http.StatusTooManyRequests {
|
|
t.Fatalf("request %d was throttled although the login limit is %d per window", i, limitLogin)
|
|
}
|
|
}
|
|
rec := postJSON(app, "/api/auth/login", "203.0.113.7")
|
|
if rec.Code != http.StatusTooManyRequests {
|
|
t.Fatalf("request %d got status %d, want 429", limitLogin+1, rec.Code)
|
|
}
|
|
if rec.Header().Get("Retry-After") == "" {
|
|
t.Error("the 429 carried no Retry-After header")
|
|
}
|
|
|
|
// A second address must keep working, otherwise one attacker could lock the
|
|
// login page for every user behind their own NAT.
|
|
if rec := postJSON(app, "/api/auth/login", "198.51.100.9"); rec.Code == http.StatusTooManyRequests {
|
|
t.Fatal("an unrelated address was throttled by another address's requests")
|
|
}
|
|
}
|
|
|
|
func TestNewServerRateLimitsTheSupportRegistrationEndpoint(t *testing.T) {
|
|
setRateLimitTestConfig(config.RateLimitConfig{})
|
|
app, err := NewServer()
|
|
if err != nil {
|
|
t.Fatalf("NewServer() error = %v", err)
|
|
}
|
|
|
|
for i := 1; i <= limitSupportRegister; i++ {
|
|
postJSON(app, "/api/support/auth/register", "203.0.113.7")
|
|
}
|
|
if rec := postJSON(app, "/api/support/auth/register", "203.0.113.7"); rec.Code != http.StatusTooManyRequests {
|
|
t.Fatalf("registration attempt %d got status %d, want 429", limitSupportRegister+1, rec.Code)
|
|
}
|
|
}
|
|
|
|
// TestNewServerLeavesWebhooksAndPublicReadsUnthrottled guards the exemption that
|
|
// matters most. A channel platform that receives a 429 from its webhook stops
|
|
// retrying and eventually disables the delivery, which takes a whole channel
|
|
// offline - a far worse outcome than the flood the limit was meant to stop.
|
|
func TestNewServerLeavesWebhooksAndPublicReadsUnthrottled(t *testing.T) {
|
|
setRateLimitTestConfig(config.RateLimitConfig{})
|
|
app, err := NewServer()
|
|
if err != nil {
|
|
t.Fatalf("NewServer() error = %v", err)
|
|
}
|
|
|
|
requests := []struct {
|
|
method string
|
|
path string
|
|
}{
|
|
{http.MethodGet, "/api/health"},
|
|
{http.MethodGet, "/api/config"},
|
|
{http.MethodPost, "/api/webhooks/org-sync"},
|
|
{http.MethodGet, "/api/third/telegram/webhook"},
|
|
{http.MethodPost, "/api/third/whatsapp/webhook"},
|
|
}
|
|
for _, r := range requests {
|
|
for i := 0; i < 200; i++ {
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest(r.method, r.path, strings.NewReader(`{}`))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.RemoteAddr = "203.0.113.7:52000"
|
|
app.ServeHTTP(rec, req)
|
|
// Any status is acceptable here except 429: these routes either
|
|
// succeed, fail validation, or error out, but they must never be
|
|
// throttled.
|
|
if rec.Code == http.StatusTooManyRequests {
|
|
t.Fatalf("%s %s returned 429 on request %d; this route must stay unthrottled", r.method, r.path, i+1)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestNewServerRateLimitCanBeDisabled(t *testing.T) {
|
|
disabled := false
|
|
setRateLimitTestConfig(config.RateLimitConfig{Enabled: &disabled})
|
|
app, err := NewServer()
|
|
if err != nil {
|
|
t.Fatalf("NewServer() error = %v", err)
|
|
}
|
|
|
|
for i := 0; i < limitLogin*4; i++ {
|
|
if rec := postJSON(app, "/api/auth/login", "203.0.113.7"); rec.Code == http.StatusTooManyRequests {
|
|
t.Fatalf("request %d was throttled although rate limiting is disabled", i+1)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestNewServerRateLimitWindowIsConfigurable(t *testing.T) {
|
|
setRateLimitTestConfig(config.RateLimitConfig{WindowSeconds: 3600})
|
|
app, err := NewServer()
|
|
if err != nil {
|
|
t.Fatalf("NewServer() error = %v", err)
|
|
}
|
|
|
|
for i := 1; i <= limitLogin; i++ {
|
|
postJSON(app, "/api/auth/login", "203.0.113.7")
|
|
}
|
|
rec := postJSON(app, "/api/auth/login", "203.0.113.7")
|
|
if rec.Code != http.StatusTooManyRequests {
|
|
t.Fatalf("got status %d, want 429", rec.Code)
|
|
}
|
|
if retryAfter := rec.Header().Get("Retry-After"); retryAfter == "" {
|
|
t.Fatal("no Retry-After header")
|
|
} else if seconds := parseInt(t, retryAfter); seconds < 3500 || seconds > 3600 {
|
|
t.Errorf("Retry-After = %s, want roughly the configured 3600 second window", retryAfter)
|
|
}
|
|
}
|