Feature(licensing): anonymous trial mode + server-side quota enforcement
This commit is contained in:
@@ -253,23 +253,102 @@ func TestQuotaEnforcement(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestAuthRejectsMissingBearer: no token → 401, not 200 / not 500. Belt
|
||||
// and braces — the auth check sits in front of /sign and /heartbeat.
|
||||
func TestAuthRejectsMissingBearer(t *testing.T) {
|
||||
// TestAnonymousTrialSignsAndCaps: no Authorization header → anonymous trial
|
||||
// branch. /sign returns 200 with a real signature up to FreeMaxDevices, then
|
||||
// 403 once the per-IP cap is reached. Replaces the older "missing bearer →
|
||||
// 401" test now that anonymous trial is a first-class mode.
|
||||
func TestAnonymousTrialSignsAndCaps(t *testing.T) {
|
||||
priv := testKey(t)
|
||||
master := mustLocal(t, "master-auth-test-xxxxxxx")
|
||||
master := mustLocal(t, "master-trial-test-xxxxxx")
|
||||
ls := NewLicenseServer(master, &priv.PublicKey, time.Minute, silentLogger{})
|
||||
ts := httptest.NewServer(ls.Handler())
|
||||
defer ts.Close()
|
||||
|
||||
body := strings.NewReader(`{"client_id":"x","start_time":"y"}`)
|
||||
resp, err := http.Post(ts.URL+"/license/sign", "application/json", body)
|
||||
call := func(clientID string) (int, string) {
|
||||
body := strings.NewReader(fmt.Sprintf(
|
||||
`{"client_id":%q,"start_time":"2026-01-01T00:00:00Z"}`, clientID))
|
||||
resp, err := http.Post(ts.URL+"/license/sign", "application/json", body)
|
||||
if err != nil {
|
||||
t.Fatalf("Post: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var sr signResponse
|
||||
_ = json.NewDecoder(resp.Body).Decode(&sr)
|
||||
if sr.Signature != "" {
|
||||
return resp.StatusCode, sr.Signature
|
||||
}
|
||||
return resp.StatusCode, sr.Error
|
||||
}
|
||||
|
||||
// First FreeMaxDevices distinct clientIDs get real signatures.
|
||||
for i := range FreeMaxDevices {
|
||||
code, sig := call(fmt.Sprintf("trial-dev-%d", i))
|
||||
if code != http.StatusOK {
|
||||
t.Errorf("dev-%d expected 200, got %d (%q)", i, code, sig)
|
||||
}
|
||||
if sig == "" {
|
||||
t.Errorf("dev-%d signature unexpectedly empty", i)
|
||||
}
|
||||
}
|
||||
// Cap+1 → 403 quota exceeded.
|
||||
code, msg := call("trial-dev-overflow")
|
||||
if code != http.StatusForbidden {
|
||||
t.Errorf("overflow expected 403, got %d (%q)", code, msg)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAnonymousTrialIPRateLimit: anonymous /sign is capped at
|
||||
// anonRatePerWindow requests per minute per source IP. Hitting the cap
|
||||
// returns 429 with Retry-After.
|
||||
func TestAnonymousTrialIPRateLimit(t *testing.T) {
|
||||
priv := testKey(t)
|
||||
master := mustLocal(t, "master-rate-test-xxxxxxx")
|
||||
ls := NewLicenseServer(master, &priv.PublicKey, time.Minute, silentLogger{})
|
||||
ts := httptest.NewServer(ls.Handler())
|
||||
defer ts.Close()
|
||||
|
||||
// Reuse the same clientID so quota does NOT also reject — we want to
|
||||
// isolate the rate limiter. quotaTracker.Reserve treats a repeat clientID
|
||||
// as a refresh (always accepted), so all the 200s here are the same slot.
|
||||
hit := func() int {
|
||||
body := strings.NewReader(`{"client_id":"rate-dev","start_time":"t"}`)
|
||||
resp, err := http.Post(ts.URL+"/license/sign", "application/json", body)
|
||||
if err != nil {
|
||||
t.Fatalf("Post: %v", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
return resp.StatusCode
|
||||
}
|
||||
|
||||
for i := range anonRatePerWindow {
|
||||
if code := hit(); code != http.StatusOK {
|
||||
t.Fatalf("req %d expected 200, got %d", i, code)
|
||||
}
|
||||
}
|
||||
if code := hit(); code != http.StatusTooManyRequests {
|
||||
t.Errorf("expected 429 after %d requests, got %d", anonRatePerWindow, code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAuthRejectsBadBearer: invalid JWT still returns 401 (we did NOT widen
|
||||
// the auth surface; only "no Authorization header at all" enters trial).
|
||||
func TestAuthRejectsBadBearer(t *testing.T) {
|
||||
priv := testKey(t)
|
||||
master := mustLocal(t, "master-bad-bearer-xxxxxx")
|
||||
ls := NewLicenseServer(master, &priv.PublicKey, time.Minute, silentLogger{})
|
||||
ts := httptest.NewServer(ls.Handler())
|
||||
defer ts.Close()
|
||||
|
||||
req, _ := http.NewRequest("POST", ts.URL+"/license/sign",
|
||||
strings.NewReader(`{"client_id":"x","start_time":"y"}`))
|
||||
req.Header.Set("Authorization", "Bearer not.a.real.jwt")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Post: %v", err)
|
||||
t.Fatalf("Do: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Errorf("expected 401, got %d", resp.StatusCode)
|
||||
t.Errorf("expected 401 for malformed bearer, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user