managarten/services/mana-sync/internal/billing/check_test.go
Till JS ceed8ccd64 feat(mana-sync): per-app billing exemption — Cards bypasses sync gate
mana-sync's billing middleware short-circuited every push/pull with
402 for users without a sync subscription. Cards promises free Sync
in its Phase-1 GUIDELINES, so it shouldn't gate its own users on a
mana-credits subscription it never sells.

Implementation:
  • billing.NewChecker now takes an exemptApps slice. The middleware
    extracts {appId} from the URL path and short-circuits before the
    user lookup if the app is in the set.
  • Configurable via the BILLING_EXEMPT_APPS env var (comma-separated).
  • Set BILLING_EXEMPT_APPS=cards on the mana-sync container so the
    cards.mana.how Sync loop stops 402-ing.
  • Tests cover the exemption + the empty/whitespace edge cases. All
    other apps keep the original behaviour (fail-open if mana-credits
    is unreachable, 402 if it explicitly says inactive).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 15:01:54 +02:00

72 lines
1.9 KiB
Go

package billing
import (
"net/http"
"net/http/httptest"
"testing"
)
type stubValidator struct {
uid string
err error
}
func (s stubValidator) UserIDFromRequest(_ *http.Request) (string, error) {
return s.uid, s.err
}
// Routes for an exempt appID short-circuit before the user lookup
// happens. Asserting via downstream handler reachability + no
// mana-credits round-trip (the checker has no creditsURL configured —
// any fetch would fail).
func TestMiddleware_AppExemption(t *testing.T) {
c := NewChecker("http://invalid.invalid", "stub", []string{"cards"})
// Wire a mux that surfaces {appId} as a path value, like main.go does.
mux := http.NewServeMux()
called := false
mux.Handle("POST /sync/{appId}", c.Middleware(stubValidator{uid: "user-1"})(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
called = true
w.WriteHeader(http.StatusOK)
})))
cases := []struct {
name string
path string
wantStatus int
wantCalled bool
}{
{"exempt app passes without billing check", "/sync/cards", http.StatusOK, true},
{"non-exempt app reaches the (failing) billing check", "/sync/todo", http.StatusOK, true}, // fail-open keeps it open
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
called = false
req := httptest.NewRequest("POST", tc.path, nil)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != tc.wantStatus {
t.Errorf("status=%d want=%d", rec.Code, tc.wantStatus)
}
if called != tc.wantCalled {
t.Errorf("downstream called=%v want=%v", called, tc.wantCalled)
}
})
}
}
func TestIsAppExempt(t *testing.T) {
c := NewChecker("", "", []string{"cards", " ", "todo"})
if !c.IsAppExempt("cards") {
t.Error("expected cards to be exempt")
}
if !c.IsAppExempt("todo") {
t.Error("expected todo to be exempt")
}
if c.IsAppExempt("notes") {
t.Error("notes should not be exempt")
}
if c.IsAppExempt("") {
t.Error("empty appID should never be exempt")
}
}