Files
pulse/internal/api/middleware_tenant_additional_test.go
T
rcourtman 0f4c831fab Reject expired trials in hosted subscription gate
isHostedSubscriptionValid was treating any tenant with a non-nil
TrialEndsAt as a valid bounded trial, never comparing the timestamp to
the current time. A tenant whose trial ended in the past but whose
stored subscription state was still "trial" (e.g. lease refresh failed
or never ran) retained full hosted Cloud access indefinitely.

The validity rule now requires both that a trial end timestamp exists
AND that time.Now() is before it. Refactored the switch to make the
trial branch read explicitly: nil evaluator -> deny, nil end -> deny,
past end -> deny, otherwise allow.

Added TestTenantMiddleware_HostedMode_ExpiredTrial_Blocked as the
regression test (mirrors the existing BoundedTrial_Allowed +
UnboundedTrial_Blocked pair with a past-dated trial end).
2026-05-21 12:33:10 +01:00

416 lines
13 KiB
Go

package api
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
pkglicensing "github.com/rcourtman/pulse-go-rewrite/pkg/licensing"
)
func TestNewTenantMiddlewareWithConfig(t *testing.T) {
persistence := config.NewMultiTenantPersistence(t.TempDir())
checker := stubAuthorizationChecker{}
mw := NewTenantMiddlewareWithConfig(TenantMiddlewareConfig{
Persistence: persistence,
AuthChecker: checker,
})
if mw.persistence != persistence {
t.Fatalf("expected persistence to be set")
}
if mw.authChecker == nil {
t.Fatalf("expected auth checker to be set")
}
}
type stubAuthorizationChecker struct{}
func (stubAuthorizationChecker) TokenCanAccessOrg(*config.APITokenRecord, string) bool {
return true
}
func (stubAuthorizationChecker) UserCanAccessOrg(string, string) bool {
return true
}
func (stubAuthorizationChecker) CheckAccess(*config.APITokenRecord, string, string) AuthorizationResult {
return AuthorizationResult{Allowed: true}
}
func TestWriteJSONError(t *testing.T) {
rec := httptest.NewRecorder()
writeJSONError(rec, http.StatusBadRequest, "bad", "message")
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", rec.Code)
}
var payload map[string]string
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode response: %v", err)
}
if payload["error"] != "bad" || payload["message"] != "message" {
t.Fatalf("unexpected payload: %+v", payload)
}
}
func TestTenantMiddleware_OrgExtraction(t *testing.T) {
mw := NewTenantMiddleware(nil)
handler := mw.Middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
orgID := GetOrgID(r.Context())
org := GetOrganization(r.Context())
if orgID == "" || org == nil || org.ID != orgID {
t.Fatalf("unexpected org context: %q %+v", orgID, org)
}
}))
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set("X-Pulse-Org-ID", "header-org")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
req = httptest.NewRequest(http.MethodGet, "/", nil)
req.AddCookie(&http.Cookie{Name: "pulse_org_id", Value: "cookie-org"})
rec = httptest.NewRecorder()
handler.ServeHTTP(rec, req)
req = httptest.NewRequest(http.MethodGet, "/", nil)
rec = httptest.NewRecorder()
handler.ServeHTTP(rec, req)
}
func TestTenantMiddleware_InvalidOrg(t *testing.T) {
persistence := config.NewMultiTenantPersistence(t.TempDir())
mw := NewTenantMiddleware(persistence)
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set("X-Pulse-Org-ID", "../bad")
rec := httptest.NewRecorder()
mw.Middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatalf("next handler should not be called")
})).ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", rec.Code)
}
}
func TestTenantMiddleware_MultiTenantDisabled(t *testing.T) {
orig := IsMultiTenantEnabled()
SetMultiTenantEnabled(false)
t.Cleanup(func() { SetMultiTenantEnabled(orig) })
mw := NewTenantMiddleware(nil)
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set("X-Pulse-Org-ID", "tenant-1")
rec := httptest.NewRecorder()
mw.Middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatalf("next handler should not be called")
})).ServeHTTP(rec, req)
if rec.Code != http.StatusNotImplemented {
t.Fatalf("expected 501, got %d", rec.Code)
}
}
func TestTenantMiddleware_MultiTenantLicenseRequired(t *testing.T) {
orig := IsMultiTenantEnabled()
SetMultiTenantEnabled(true)
t.Cleanup(func() { SetMultiTenantEnabled(orig) })
mw := NewTenantMiddleware(nil)
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set("X-Pulse-Org-ID", "tenant-2")
rec := httptest.NewRecorder()
mw.Middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatalf("next handler should not be called")
})).ServeHTTP(rec, req)
if rec.Code != http.StatusPaymentRequired {
t.Fatalf("expected 402, got %d", rec.Code)
}
}
// hostedTestSource implements licensing.EntitlementSource for testing hosted subscription gating.
type hostedTestSource struct {
capabilities []string
subscriptionState pkglicensing.SubscriptionState
trialStartedAt *int64
trialEndsAt *int64
}
func (s hostedTestSource) Capabilities() []string { return s.capabilities }
func (s hostedTestSource) Limits() map[string]int64 { return nil }
func (s hostedTestSource) MetersEnabled() []string { return nil }
func (s hostedTestSource) PlanVersion() string { return "cloud_trial" }
func (s hostedTestSource) SubscriptionState() pkglicensing.SubscriptionState {
return s.subscriptionState
}
func (s hostedTestSource) TrialStartedAt() *int64 { return s.trialStartedAt }
func (s hostedTestSource) TrialEndsAt() *int64 { return s.trialEndsAt }
func (s hostedTestSource) OverflowGrantedAt() *int64 { return nil }
// setupHostedLicenseProvider configures a LicenseServiceProvider backed by an evaluator
// with the given subscription state and trial timestamps (no JWT license).
func setupHostedLicenseProvider(t *testing.T, subState pkglicensing.SubscriptionState, trialEndsAt *int64) {
t.Helper()
now := time.Now().Unix()
source := hostedTestSource{
capabilities: pkglicensing.DeriveCapabilitiesFromTier(pkglicensing.TierCloud, nil),
subscriptionState: subState,
trialStartedAt: &now,
trialEndsAt: trialEndsAt,
}
eval := pkglicensing.NewEvaluator(source)
svc := pkglicensing.NewService()
svc.SetEvaluator(eval)
SetLicenseServiceProvider(&staticLicenseProvider{service: svc})
t.Cleanup(func() { SetLicenseServiceProvider(nil) })
}
func TestTenantMiddleware_HostedMode_ActiveSubscription_Allowed(t *testing.T) {
setupHostedLicenseProvider(t, pkglicensing.SubStateActive, nil)
mw := NewTenantMiddlewareWithConfig(TenantMiddlewareConfig{
HostedMode: true,
})
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set("X-Pulse-Org-ID", "cloud-tenant-1")
rec := httptest.NewRecorder()
called := false
mw.Middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
called = true
w.WriteHeader(http.StatusOK)
})).ServeHTTP(rec, req)
if !called {
t.Fatalf("handler should have been called for active hosted subscription")
}
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rec.Code)
}
}
func TestTenantMiddleware_HostedMode_BoundedTrial_Allowed(t *testing.T) {
trialEnd := time.Now().Add(14 * 24 * time.Hour).Unix()
setupHostedLicenseProvider(t, pkglicensing.SubStateTrial, &trialEnd)
mw := NewTenantMiddlewareWithConfig(TenantMiddlewareConfig{
HostedMode: true,
})
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set("X-Pulse-Org-ID", "cloud-trial-tenant")
rec := httptest.NewRecorder()
called := false
mw.Middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
called = true
w.WriteHeader(http.StatusOK)
})).ServeHTTP(rec, req)
if !called {
t.Fatalf("handler should have been called for bounded trial")
}
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rec.Code)
}
}
func TestTenantMiddleware_HostedMode_UnboundedTrial_Blocked(t *testing.T) {
// Trial with no TrialEndsAt should be blocked to prevent infinite free Cloud.
setupHostedLicenseProvider(t, pkglicensing.SubStateTrial, nil)
mw := NewTenantMiddlewareWithConfig(TenantMiddlewareConfig{
HostedMode: true,
})
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set("X-Pulse-Org-ID", "cloud-unbounded-trial")
rec := httptest.NewRecorder()
mw.Middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatalf("handler should NOT be called for unbounded trial")
})).ServeHTTP(rec, req)
if rec.Code != http.StatusPaymentRequired {
t.Fatalf("expected 402, got %d", rec.Code)
}
var payload map[string]interface{}
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode error response: %v", err)
}
if payload["error"] != "subscription_required" {
t.Fatalf("expected subscription_required error code, got %q", payload["error"])
}
}
// TestTenantMiddleware_HostedMode_ExpiredTrial_Blocked is the regression test
// for a fix where isHostedSubscriptionValid only checked that TrialEndsAt was
// non-nil, not that the trial had not yet expired. A tenant whose trial ended
// in the past but whose stored subscription state was still "trial" with a
// non-nil TrialEndsAt would otherwise retain full Cloud access indefinitely.
func TestTenantMiddleware_HostedMode_ExpiredTrial_Blocked(t *testing.T) {
trialEnd := time.Now().Add(-24 * time.Hour).Unix() // ended yesterday
setupHostedLicenseProvider(t, pkglicensing.SubStateTrial, &trialEnd)
mw := NewTenantMiddlewareWithConfig(TenantMiddlewareConfig{
HostedMode: true,
})
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set("X-Pulse-Org-ID", "cloud-expired-trial")
rec := httptest.NewRecorder()
mw.Middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatalf("handler should NOT be called for expired trial")
})).ServeHTTP(rec, req)
if rec.Code != http.StatusPaymentRequired {
t.Fatalf("expected 402 for expired trial, got %d", rec.Code)
}
}
func TestTenantMiddleware_HostedMode_ExpiredSubscription_Blocked(t *testing.T) {
setupHostedLicenseProvider(t, pkglicensing.SubStateExpired, nil)
mw := NewTenantMiddlewareWithConfig(TenantMiddlewareConfig{
HostedMode: true,
})
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set("X-Pulse-Org-ID", "cloud-expired-tenant")
rec := httptest.NewRecorder()
mw.Middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatalf("handler should NOT be called for expired subscription")
})).ServeHTTP(rec, req)
if rec.Code != http.StatusPaymentRequired {
t.Fatalf("expected 402, got %d", rec.Code)
}
}
func TestTenantMiddleware_HostedMode_BypassesMultiTenantFlag(t *testing.T) {
// Hosted mode should NOT require PULSE_MULTI_TENANT_ENABLED=true.
// Even with the flag disabled, hosted tenants with active subscriptions should work.
orig := IsMultiTenantEnabled()
SetMultiTenantEnabled(false)
t.Cleanup(func() { SetMultiTenantEnabled(orig) })
setupHostedLicenseProvider(t, pkglicensing.SubStateActive, nil)
mw := NewTenantMiddlewareWithConfig(TenantMiddlewareConfig{
HostedMode: true,
})
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set("X-Pulse-Org-ID", "cloud-no-flag-tenant")
rec := httptest.NewRecorder()
called := false
mw.Middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
called = true
w.WriteHeader(http.StatusOK)
})).ServeHTTP(rec, req)
if !called {
t.Fatalf("handler should have been called — hosted mode bypasses multi-tenant flag")
}
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rec.Code)
}
}
func TestTenantMiddleware_HostedMode_DefaultOrg_AlwaysAllowed(t *testing.T) {
// Default org should still be allowed in hosted mode even without any subscription.
SetLicenseServiceProvider(nil)
t.Cleanup(func() { SetLicenseServiceProvider(nil) })
mw := NewTenantMiddlewareWithConfig(TenantMiddlewareConfig{
HostedMode: true,
})
req := httptest.NewRequest(http.MethodGet, "/", nil)
// No X-Pulse-Org-ID header → defaults to "default"
rec := httptest.NewRecorder()
called := false
mw.Middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
called = true
if got := GetOrgID(r.Context()); got != "default" {
t.Fatalf("expected default org, got %q", got)
}
w.WriteHeader(http.StatusOK)
})).ServeHTTP(rec, req)
if !called {
t.Fatalf("handler should have been called for default org")
}
}
func TestWebSocketChecker_HostedMode_ActiveSubscription_Allowed(t *testing.T) {
setupHostedLicenseProvider(t, pkglicensing.SubStateActive, nil)
checker := NewMultiTenantChecker(true) // hosted mode
result := checker.CheckMultiTenant(context.Background(), "cloud-ws-tenant")
if !result.Allowed {
t.Fatalf("expected allowed for active hosted subscription, got reason: %s", result.Reason)
}
if !result.FeatureEnabled {
t.Fatalf("expected feature_enabled=true in hosted mode")
}
}
func TestWebSocketChecker_HostedMode_ExpiredSubscription_Blocked(t *testing.T) {
setupHostedLicenseProvider(t, pkglicensing.SubStateExpired, nil)
checker := NewMultiTenantChecker(true) // hosted mode
result := checker.CheckMultiTenant(context.Background(), "cloud-ws-expired")
if result.Allowed {
t.Fatalf("expected blocked for expired hosted subscription")
}
if result.Reason == "" {
t.Fatalf("expected non-empty reason for blocked subscription")
}
}
func TestWebSocketChecker_SelfHosted_StillRequiresEnterprise(t *testing.T) {
// Self-hosted mode should NOT be affected by hosted mode changes.
orig := IsMultiTenantEnabled()
SetMultiTenantEnabled(true)
t.Cleanup(func() { SetMultiTenantEnabled(orig) })
// No enterprise license → should be blocked
SetLicenseServiceProvider(nil)
t.Cleanup(func() { SetLicenseServiceProvider(nil) })
checker := NewMultiTenantChecker(false) // self-hosted mode
result := checker.CheckMultiTenant(context.Background(), "self-hosted-tenant")
if result.Allowed {
t.Fatalf("expected blocked for self-hosted tenant without Enterprise license")
}
if !result.FeatureEnabled {
t.Fatalf("expected feature_enabled=true (flag is on)")
}
if result.Licensed {
t.Fatalf("expected licensed=false without Enterprise")
}
}