mirror of
https://github.com/hanzoai/base.git
synced 2026-10-21 03:41:50 +00:00
A backup checkpoints one Base's write-ahead log inside one transaction. A tenant's Base is separate handles with its own log, in neither, so its file was being copied mid-write beside a log copied at another instant — a torn copy rather than a smaller one. The archive names what it leaves out, in the zip's own comment, where every listing tool already prints it. Restore spares exactly what the archive omits. That symmetry is the whole thing: an archive that skips the tenants and a restore that does not spare them deletes every tenant. Both are stated in the package that owns the directory, so core stays ignorant of tenancy. The rate limiter refuses a request it cannot key. A forwarded request arrives with no client address, and a limiter that admits what it cannot count is not one. The way to be counted already exists: a gateway that sets a forwarded header restores the client, through the trusted-proxy settings. Opening a Base holds the registry's lock over the map alone. It had spanned provision, construct, bootstrap and declare, and a read waits behind a waiting writer, so one org's first request stalled every other request in the process — measured at 209ms against a 101ms open. Co-authored-by: Hanzo Dev <[email protected]>
224 lines
5.8 KiB
Go
224 lines
5.8 KiB
Go
package apis_test
|
|
|
|
import (
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/hanzoai/base/apis"
|
|
"github.com/hanzoai/base/core"
|
|
"github.com/hanzoai/base/tests"
|
|
)
|
|
|
|
func TestDefaultRateLimitMiddleware(t *testing.T) {
|
|
app, _ := tests.NewTestApp()
|
|
defer app.Cleanup()
|
|
|
|
app.Settings().RateLimits.Enabled = true
|
|
app.Settings().RateLimits.Rules = []core.RateLimitRule{
|
|
{
|
|
Label: "/rate/",
|
|
MaxRequests: 2,
|
|
Duration: 1,
|
|
},
|
|
{
|
|
Label: "/rate/b",
|
|
MaxRequests: 3,
|
|
Duration: 1,
|
|
},
|
|
{
|
|
Label: "POST /rate/b",
|
|
MaxRequests: 1,
|
|
Duration: 1,
|
|
},
|
|
{
|
|
Label: "/rate/guest",
|
|
MaxRequests: 1,
|
|
Duration: 1,
|
|
Audience: core.RateLimitRuleAudienceGuest,
|
|
},
|
|
{
|
|
Label: "/rate/auth",
|
|
MaxRequests: 1,
|
|
Duration: 1,
|
|
Audience: core.RateLimitRuleAudienceAuth,
|
|
},
|
|
}
|
|
|
|
baseRouter, err := apis.NewRouter(app)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
baseRouter.GET("/norate", func(e *core.RequestEvent) error {
|
|
return e.String(200, "norate")
|
|
}).BindFunc(func(e *core.RequestEvent) error {
|
|
return e.Next()
|
|
})
|
|
baseRouter.GET("/rate/a", func(e *core.RequestEvent) error {
|
|
return e.String(200, "a")
|
|
})
|
|
baseRouter.GET("/rate/b", func(e *core.RequestEvent) error {
|
|
return e.String(200, "b")
|
|
})
|
|
baseRouter.GET("/rate/guest", func(e *core.RequestEvent) error {
|
|
return e.String(200, "guest")
|
|
})
|
|
baseRouter.GET("/rate/auth", func(e *core.RequestEvent) error {
|
|
return e.String(200, "auth")
|
|
})
|
|
|
|
mux, err := baseRouter.BuildMux()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
scenarios := []struct {
|
|
url string
|
|
wait float64 // ms
|
|
authenticated bool
|
|
expectedStatus int
|
|
}{
|
|
{"/norate", 0, false, 200},
|
|
{"/norate", 0, false, 200},
|
|
{"/norate", 0, false, 200},
|
|
{"/norate", 0, false, 200},
|
|
{"/norate", 0, false, 200},
|
|
|
|
{"/rate/a", 0, false, 200},
|
|
{"/rate/a", 1100, false, 200}, // (fixed window check) wait enough to ensure that it can't fit 2 requests in 1s
|
|
{"/rate/a", 1100, false, 200},
|
|
{"/rate/a", 1100, false, 200},
|
|
{"/rate/a", 0, false, 200},
|
|
{"/rate/a", 0, false, 429},
|
|
{"/rate/a", 0, false, 429},
|
|
{"/rate/a", 1200, false, 200},
|
|
{"/rate/a", 0, false, 200},
|
|
{"/rate/a", 0, false, 429},
|
|
|
|
{"/rate/b", 0, false, 200},
|
|
{"/rate/b", 0, false, 200},
|
|
{"/rate/b", 0, false, 200},
|
|
{"/rate/b", 0, false, 429},
|
|
{"/rate/b", 1200, false, 200},
|
|
{"/rate/b", 0, false, 200},
|
|
{"/rate/b", 0, false, 200},
|
|
{"/rate/b", 0, false, 429},
|
|
|
|
// "auth" with guest (should fallback to the /rate/ rule)
|
|
{"/rate/auth", 0, false, 200},
|
|
{"/rate/auth", 0, false, 200},
|
|
{"/rate/auth", 0, false, 429},
|
|
{"/rate/auth", 0, false, 429},
|
|
|
|
// "auth" rule with regular user (should match the /rate/auth rule)
|
|
{"/rate/auth", 0, true, 200},
|
|
{"/rate/auth", 0, true, 429},
|
|
{"/rate/auth", 0, true, 429},
|
|
|
|
// "guest" with guest (should match the /rate/guest rule)
|
|
{"/rate/guest", 0, false, 200},
|
|
{"/rate/guest", 0, false, 429},
|
|
{"/rate/guest", 0, false, 429},
|
|
|
|
// "guest" rule with regular user (should fallback to the /rate/ rule)
|
|
{"/rate/guest", 1200, true, 200},
|
|
{"/rate/guest", 0, true, 200},
|
|
{"/rate/guest", 0, true, 429},
|
|
{"/rate/guest", 0, true, 429},
|
|
}
|
|
|
|
for _, s := range scenarios {
|
|
t.Run(s.url, func(t *testing.T) {
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest("GET", s.url, nil)
|
|
|
|
if s.authenticated {
|
|
auth, err := app.FindAuthRecordByEmail("users", "[email protected]")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
token, err := auth.NewAuthToken()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
req.Header.Add("Authorization", token)
|
|
}
|
|
|
|
if s.wait > 0 {
|
|
time.Sleep(time.Duration(s.wait) * time.Millisecond)
|
|
}
|
|
|
|
mux.ServeHTTP(rec, req)
|
|
|
|
result := rec.Result()
|
|
|
|
if result.StatusCode != s.expectedStatus {
|
|
t.Fatalf("Expected response status %d, got %d", s.expectedStatus, result.StatusCode)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// A limit is per client, so what the limiter does with a request that names no
|
|
// client is the whole of this test. It refuses: a rule that admits whatever it
|
|
// cannot count is not a rule, and the request that produces no address is the
|
|
// one a rule was least able to see coming.
|
|
//
|
|
// A transport that terminates elsewhere and hands the request on carries no
|
|
// socket peer, which is where an address goes missing in practice. The
|
|
// deployment says where to read it instead — Settings.TrustedProxy — and that
|
|
// is the third case here.
|
|
func TestRateLimitNeedsAClientAddress(t *testing.T) {
|
|
app, _ := tests.NewTestApp()
|
|
defer app.Cleanup()
|
|
|
|
app.Settings().RateLimits.Enabled = true
|
|
app.Settings().RateLimits.Rules = []core.RateLimitRule{
|
|
{Label: "/probe", MaxRequests: 100, Duration: 1},
|
|
}
|
|
|
|
router, err := apis.NewRouter(app)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
router.GET("/probe", func(e *core.RequestEvent) error {
|
|
return e.String(http.StatusOK, "probe")
|
|
})
|
|
|
|
mux, err := router.BuildMux()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
probe := func(remote string, forwarded string) int {
|
|
req := httptest.NewRequest(http.MethodGet, "/probe", nil)
|
|
req.RemoteAddr = remote
|
|
if forwarded != "" {
|
|
req.Header.Set("X-Forwarded-For", forwarded)
|
|
}
|
|
rec := httptest.NewRecorder()
|
|
mux.ServeHTTP(rec, req)
|
|
return rec.Code
|
|
}
|
|
|
|
// A socket peer, and a rule the request is nowhere near.
|
|
if code := probe("192.0.2.4:1234", ""); code != http.StatusOK {
|
|
t.Fatalf("a request carrying a client address answered %d, want 200", code)
|
|
}
|
|
|
|
// No socket peer.
|
|
if code := probe("", ""); code != http.StatusTooManyRequests {
|
|
t.Fatalf("a request carrying no client address answered %d, want 429", code)
|
|
}
|
|
|
|
// Still no socket peer, but the deployment names a header to read the
|
|
// client from, so there is a client again.
|
|
app.Settings().TrustedProxy.Headers = []string{"X-Forwarded-For"}
|
|
if code := probe("", "203.0.113.7"); code != http.StatusOK {
|
|
t.Fatalf("a forwarded client address answered %d, want 200", code)
|
|
}
|
|
}
|