mirror of
https://github.com/hanzoai/gateway.git
synced 2026-10-30 15:04:44 +00:00
599 lines
16 KiB
Go
599 lines
16 KiB
Go
// Copyright © 2026 Hanzo AI. MIT License.
|
|
|
|
//go:build legacy
|
|
// +build legacy
|
|
|
|
// gin-driven: this suite drives the LEGACY Lura edge's transports
|
|
// (legacy_transports.go), which exist only under this tag. The policies it
|
|
// exercises are framework-free values shared with the zip edge, and
|
|
// transport_parity_test.go asserts the two edges answer alike.
|
|
|
|
package gateway
|
|
|
|
import (
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/hanzoai/authz"
|
|
"github.com/hanzoai/authz/edge"
|
|
)
|
|
|
|
func TestExtractBearerToken(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
header string
|
|
expected string
|
|
}{
|
|
{"valid bearer", "Bearer eyJhbGciOi...", "eyJhbGciOi..."},
|
|
{"lowercase bearer", "bearer eyJhbGciOi...", "eyJhbGciOi..."},
|
|
{"no bearer prefix", "Basic dXNlcjpwYXNz", ""},
|
|
{"empty header", "", ""},
|
|
{"bearer only", "Bearer", ""},
|
|
{"bearer with spaces", "Bearer eyJhbGciOi... ", "eyJhbGciOi..."},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
if tt.header != "" {
|
|
req.Header.Set("Authorization", tt.header)
|
|
}
|
|
got := edge.Bearer(req.Header)
|
|
if got != tt.expected {
|
|
t.Errorf("extractBearerToken() = %q, want %q", got, tt.expected)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestExtractTokenFromCookie(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
cookieName string
|
|
value string
|
|
expected string
|
|
}{
|
|
{"iam cookie", "iam_access_token", "tok123", "tok123"},
|
|
{"access_token cookie", "access_token", "tok456", "tok456"},
|
|
{"hanzo_token cookie", "hanzo_token", "tok789", "tok789"},
|
|
{"unknown cookie", "random_cookie", "tokXYZ", ""},
|
|
{"empty cookie", "iam_access_token", "", ""},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
if tt.value != "" {
|
|
req.AddCookie(&http.Cookie{Name: tt.cookieName, Value: tt.value})
|
|
}
|
|
got := edge.Cookie(req.Header)
|
|
if got != tt.expected {
|
|
t.Errorf("extractTokenFromCookie() = %q, want %q", got, tt.expected)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestAuthMiddlewarePublicPaths(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
cfg := AuthConfig{
|
|
Enabled: true,
|
|
JWKSURL: "https://hanzo.id/v1/iam/.well-known/jwks",
|
|
Issuer: "https://hanzo.id",
|
|
PublicPaths: []string{"/healthz", "/.well-known/"},
|
|
PublicHosts: []string{"hanzo.id"},
|
|
RequireAuth: true, // Even with require=true, public paths should pass
|
|
}
|
|
|
|
middleware := NewAuthMiddleware(cfg)
|
|
|
|
tests := []struct {
|
|
name string
|
|
path string
|
|
host string
|
|
expectedStatus int
|
|
}{
|
|
{"health check bypasses auth", "/healthz", "api.hanzo.ai", http.StatusOK},
|
|
{"well-known bypasses auth", "/.well-known/jwks", "api.hanzo.ai", http.StatusOK},
|
|
{"public host bypasses auth", "/v1/anything", "hanzo.id", http.StatusOK},
|
|
{"non-public path requires auth", "/v1/chat/completions", "api.hanzo.ai", http.StatusUnauthorized},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
w := httptest.NewRecorder()
|
|
c, r := gin.CreateTestContext(w)
|
|
|
|
r.Use(middleware)
|
|
r.GET(tt.path, func(c *gin.Context) {
|
|
c.Status(http.StatusOK)
|
|
})
|
|
// Also handle POST for api paths
|
|
r.POST(tt.path, func(c *gin.Context) {
|
|
c.Status(http.StatusOK)
|
|
})
|
|
|
|
c.Request = httptest.NewRequest(http.MethodGet, tt.path, nil)
|
|
c.Request.Host = tt.host
|
|
|
|
r.ServeHTTP(w, c.Request)
|
|
|
|
if w.Code != tt.expectedStatus {
|
|
t.Errorf("status = %d, want %d", w.Code, tt.expectedStatus)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestAuthMiddlewareNoTokenOptional(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
cfg := AuthConfig{
|
|
Enabled: true,
|
|
JWKSURL: "https://hanzo.id/v1/iam/.well-known/jwks",
|
|
Issuer: "https://hanzo.id",
|
|
PublicPaths: []string{},
|
|
PublicHosts: []string{},
|
|
RequireAuth: false, // Don't require auth
|
|
}
|
|
|
|
middleware := NewAuthMiddleware(cfg)
|
|
|
|
w := httptest.NewRecorder()
|
|
_, r := gin.CreateTestContext(w)
|
|
|
|
var gotOrgHeader string
|
|
r.Use(middleware)
|
|
r.GET("/v1/test", func(c *gin.Context) {
|
|
gotOrgHeader = c.Request.Header.Get("X-Org-Id")
|
|
c.Status(http.StatusOK)
|
|
})
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/v1/test", nil)
|
|
req.Host = "api.hanzo.ai"
|
|
|
|
r.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("expected 200 for optional auth without token, got %d", w.Code)
|
|
}
|
|
if gotOrgHeader != "" {
|
|
t.Errorf("expected no X-Org-Id header without token, got %q", gotOrgHeader)
|
|
}
|
|
}
|
|
|
|
func TestIsAPIKey(t *testing.T) {
|
|
tests := []struct {
|
|
token string
|
|
expected bool
|
|
}{
|
|
{"hk-test-placeholder", true},
|
|
{"sk-ant-api03-cPXAHvR", true},
|
|
{"sk-live-abc123", true},
|
|
{"fw_6UVdtest", true},
|
|
{"hz_widget_public", true},
|
|
{"hz_custom_key_123", true},
|
|
{"pk-publishable-key", true},
|
|
{"eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.payload.sig", false},
|
|
{"random-token", false},
|
|
{"", false},
|
|
}
|
|
for _, tt := range tests {
|
|
if got := authz.IsAPIKey(tt.token); got != tt.expected {
|
|
t.Errorf("authz.IsAPIKey(%q) = %v, want %v", tt.token, got, tt.expected)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAuthMiddlewareAPIKeyPassthrough(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
cfg := AuthConfig{
|
|
Enabled: true,
|
|
JWKSURL: "https://hanzo.id/v1/iam/.well-known/jwks",
|
|
Issuer: "https://hanzo.id",
|
|
PublicPaths: []string{},
|
|
PublicHosts: []string{},
|
|
RequireAuth: true,
|
|
}
|
|
|
|
middleware := NewAuthMiddleware(cfg)
|
|
|
|
w := httptest.NewRecorder()
|
|
_, r := gin.CreateTestContext(w)
|
|
|
|
r.Use(middleware)
|
|
r.POST("/v1/chat/completions", func(c *gin.Context) {
|
|
c.Status(http.StatusOK)
|
|
})
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
|
|
req.Host = "api.hanzo.ai"
|
|
req.Header.Set("Authorization", "Bearer sk-test-placeholder")
|
|
|
|
r.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("API key should pass through auth middleware, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestAuthMiddlewareWidgetKeyPassthrough(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
cfg := AuthConfig{
|
|
Enabled: true,
|
|
JWKSURL: "https://hanzo.id/v1/iam/.well-known/jwks",
|
|
Issuer: "https://hanzo.id",
|
|
PublicPaths: []string{},
|
|
PublicHosts: []string{},
|
|
RequireAuth: true,
|
|
}
|
|
|
|
middleware := NewAuthMiddleware(cfg)
|
|
|
|
w := httptest.NewRecorder()
|
|
_, r := gin.CreateTestContext(w)
|
|
|
|
r.Use(middleware)
|
|
r.POST("/v1/chat/completions", func(c *gin.Context) {
|
|
c.Status(http.StatusOK)
|
|
})
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
|
|
req.Host = "api.hanzo.ai"
|
|
req.Header.Set("Authorization", "Bearer hz_widget_public")
|
|
|
|
r.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("widget key should pass through auth middleware, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestDefaultAuthConfig(t *testing.T) {
|
|
cfg := DefaultAuthConfig()
|
|
|
|
if !cfg.Enabled {
|
|
t.Error("Enabled should default to true")
|
|
}
|
|
if cfg.JWKSURL == "" {
|
|
t.Error("JWKSURL should not be empty")
|
|
}
|
|
if cfg.Issuer == "" {
|
|
t.Error("Issuer should not be empty")
|
|
}
|
|
if len(cfg.PublicPaths) == 0 {
|
|
t.Error("PublicPaths should have defaults")
|
|
}
|
|
if len(cfg.PublicHosts) == 0 {
|
|
t.Error("PublicHosts should have defaults")
|
|
}
|
|
if cfg.RequireAuth {
|
|
t.Error("RequireAuth should default to false")
|
|
}
|
|
}
|
|
|
|
func TestAuthMiddlewareDisabled(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
cfg := AuthConfig{
|
|
Enabled: false,
|
|
RequireAuth: true,
|
|
}
|
|
|
|
middleware := NewAuthMiddleware(cfg)
|
|
|
|
w := httptest.NewRecorder()
|
|
_, r := gin.CreateTestContext(w)
|
|
|
|
r.Use(middleware)
|
|
r.GET("/v1/test", func(c *gin.Context) {
|
|
c.Status(http.StatusOK)
|
|
})
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/v1/test", nil)
|
|
req.Host = "api.hanzo.ai"
|
|
req.Header.Set("Authorization", "Bearer not-a-valid-jwt")
|
|
|
|
r.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("disabled auth should pass through all requests, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
// TestStripIdentityHeaders verifies the stripIdentityHeaders function directly.
|
|
func TestStripIdentityHeaders(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
req.Header.Set("X-Org-Id", "attacker-org")
|
|
req.Header.Set("X-User-Id", "attacker-user")
|
|
req.Header.Set("X-User-Email", "[email protected]")
|
|
|
|
edge.Strip(req.Header)
|
|
|
|
for _, h := range []string{"X-Org-Id", "X-User-Id", "X-User-Email"} {
|
|
if v := req.Header.Get(h); v != "" {
|
|
t.Errorf("stripIdentityHeaders did not remove %s, got %q", h, v)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestHeaderInjectionPublicHost verifies that client-supplied X-Identity-* headers
|
|
// are stripped when the request hits a public host bypass path.
|
|
// This is a regression test for CVE: identity header injection.
|
|
func TestHeaderInjectionPublicHost(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
cfg := AuthConfig{
|
|
Enabled: true,
|
|
JWKSURL: "https://hanzo.id/v1/iam/.well-known/jwks",
|
|
Issuer: "https://hanzo.id",
|
|
PublicPaths: []string{},
|
|
PublicHosts: []string{"hanzo.id"},
|
|
RequireAuth: false,
|
|
}
|
|
|
|
middleware := NewAuthMiddleware(cfg)
|
|
|
|
w := httptest.NewRecorder()
|
|
_, r := gin.CreateTestContext(w)
|
|
|
|
var gotOrg, gotUser, gotEmail string
|
|
r.Use(middleware)
|
|
r.GET("/v1/anything", func(c *gin.Context) {
|
|
gotOrg = c.Request.Header.Get("X-Org-Id")
|
|
gotUser = c.Request.Header.Get("X-User-Id")
|
|
gotEmail = c.Request.Header.Get("X-User-Email")
|
|
c.Status(http.StatusOK)
|
|
})
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/v1/anything", nil)
|
|
req.Host = "hanzo.id"
|
|
// Attacker injects identity headers
|
|
req.Header.Set("X-Org-Id", "admin")
|
|
req.Header.Set("X-User-Id", "root")
|
|
req.Header.Set("X-User-Email", "[email protected]")
|
|
|
|
r.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d", w.Code)
|
|
}
|
|
if gotOrg != "" {
|
|
t.Errorf("X-Org-Id should be stripped on public host, got %q", gotOrg)
|
|
}
|
|
if gotUser != "" {
|
|
t.Errorf("X-User-Id should be stripped on public host, got %q", gotUser)
|
|
}
|
|
if gotEmail != "" {
|
|
t.Errorf("X-User-Email should be stripped on public host, got %q", gotEmail)
|
|
}
|
|
}
|
|
|
|
// TestHeaderInjectionPublicPath verifies that client-supplied X-Identity-* headers
|
|
// are stripped when the request hits a public path bypass.
|
|
func TestHeaderInjectionPublicPath(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
cfg := AuthConfig{
|
|
Enabled: true,
|
|
JWKSURL: "https://hanzo.id/v1/iam/.well-known/jwks",
|
|
Issuer: "https://hanzo.id",
|
|
PublicPaths: []string{"/healthz"},
|
|
PublicHosts: []string{},
|
|
RequireAuth: false,
|
|
}
|
|
|
|
middleware := NewAuthMiddleware(cfg)
|
|
|
|
w := httptest.NewRecorder()
|
|
_, r := gin.CreateTestContext(w)
|
|
|
|
var gotOrg string
|
|
r.Use(middleware)
|
|
r.GET("/healthz", func(c *gin.Context) {
|
|
gotOrg = c.Request.Header.Get("X-Org-Id")
|
|
c.Status(http.StatusOK)
|
|
})
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
|
|
req.Host = "api.hanzo.ai"
|
|
req.Header.Set("X-Org-Id", "forged-org")
|
|
|
|
r.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d", w.Code)
|
|
}
|
|
if gotOrg != "" {
|
|
t.Errorf("X-Org-Id should be stripped on public path, got %q", gotOrg)
|
|
}
|
|
}
|
|
|
|
// TestHeaderInjectionNoToken verifies that client-supplied X-Identity-* headers
|
|
// are stripped when no token is present and auth is optional.
|
|
func TestHeaderInjectionNoToken(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
cfg := AuthConfig{
|
|
Enabled: true,
|
|
JWKSURL: "https://hanzo.id/v1/iam/.well-known/jwks",
|
|
Issuer: "https://hanzo.id",
|
|
PublicPaths: []string{},
|
|
PublicHosts: []string{},
|
|
RequireAuth: false,
|
|
}
|
|
|
|
middleware := NewAuthMiddleware(cfg)
|
|
|
|
w := httptest.NewRecorder()
|
|
_, r := gin.CreateTestContext(w)
|
|
|
|
var gotUser string
|
|
r.Use(middleware)
|
|
r.GET("/v1/test", func(c *gin.Context) {
|
|
gotUser = c.Request.Header.Get("X-User-Id")
|
|
c.Status(http.StatusOK)
|
|
})
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/v1/test", nil)
|
|
req.Host = "api.hanzo.ai"
|
|
// No Authorization header, but attacker injects identity
|
|
req.Header.Set("X-User-Id", "forged-admin")
|
|
|
|
r.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d", w.Code)
|
|
}
|
|
if gotUser != "" {
|
|
t.Errorf("X-User-Id should be stripped with no token, got %q", gotUser)
|
|
}
|
|
}
|
|
|
|
// TestHeaderInjectionAPIKey verifies that client-supplied X-Identity-* headers
|
|
// are stripped when an API key bypasses JWT validation.
|
|
func TestHeaderInjectionAPIKey(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
cfg := AuthConfig{
|
|
Enabled: true,
|
|
JWKSURL: "https://hanzo.id/v1/iam/.well-known/jwks",
|
|
Issuer: "https://hanzo.id",
|
|
PublicPaths: []string{},
|
|
PublicHosts: []string{},
|
|
RequireAuth: true,
|
|
}
|
|
|
|
middleware := NewAuthMiddleware(cfg)
|
|
|
|
w := httptest.NewRecorder()
|
|
_, r := gin.CreateTestContext(w)
|
|
|
|
var gotOrg, gotUser string
|
|
r.Use(middleware)
|
|
r.POST("/v1/chat/completions", func(c *gin.Context) {
|
|
gotOrg = c.Request.Header.Get("X-Org-Id")
|
|
gotUser = c.Request.Header.Get("X-User-Id")
|
|
c.Status(http.StatusOK)
|
|
})
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
|
|
req.Host = "api.hanzo.ai"
|
|
req.Header.Set("Authorization", "Bearer sk-ant-api03-cPXAHvR")
|
|
// Attacker also injects identity headers alongside API key
|
|
req.Header.Set("X-Org-Id", "forged-org")
|
|
req.Header.Set("X-User-Id", "forged-admin")
|
|
|
|
r.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d", w.Code)
|
|
}
|
|
if gotOrg != "" {
|
|
t.Errorf("X-Org-Id should be stripped on API key path, got %q", gotOrg)
|
|
}
|
|
if gotUser != "" {
|
|
t.Errorf("X-User-Id should be stripped on API key path, got %q", gotUser)
|
|
}
|
|
}
|
|
|
|
// TestHeaderInjectionDisabledAuth verifies that client-supplied X-Identity-*
|
|
// headers are stripped even when auth is entirely disabled.
|
|
func TestHeaderInjectionDisabledAuth(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
cfg := AuthConfig{
|
|
Enabled: false,
|
|
}
|
|
|
|
middleware := NewAuthMiddleware(cfg)
|
|
|
|
w := httptest.NewRecorder()
|
|
_, r := gin.CreateTestContext(w)
|
|
|
|
var gotOrg, gotUser, gotEmail string
|
|
r.Use(middleware)
|
|
r.GET("/v1/test", func(c *gin.Context) {
|
|
gotOrg = c.Request.Header.Get("X-Org-Id")
|
|
gotUser = c.Request.Header.Get("X-User-Id")
|
|
gotEmail = c.Request.Header.Get("X-User-Email")
|
|
c.Status(http.StatusOK)
|
|
})
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/v1/test", nil)
|
|
req.Host = "api.hanzo.ai"
|
|
req.Header.Set("X-Org-Id", "forged-org")
|
|
req.Header.Set("X-User-Id", "forged-admin")
|
|
req.Header.Set("X-User-Email", "[email protected]")
|
|
|
|
r.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d", w.Code)
|
|
}
|
|
if gotOrg != "" {
|
|
t.Errorf("X-Org-Id should be stripped even when auth disabled, got %q", gotOrg)
|
|
}
|
|
if gotUser != "" {
|
|
t.Errorf("X-User-Id should be stripped even when auth disabled, got %q", gotUser)
|
|
}
|
|
if gotEmail != "" {
|
|
t.Errorf("X-User-Email should be stripped even when auth disabled, got %q", gotEmail)
|
|
}
|
|
}
|
|
|
|
// TestDefaultAuthConfigAudience verifies the default config carries the
|
|
// audience allowlist: the gateway origin plus the known IAM client_ids
|
|
// (IAM stamps user tokens with aud=<client_id>, never the origin).
|
|
func TestDefaultAuthConfigAudience(t *testing.T) {
|
|
cfg := DefaultAuthConfig()
|
|
if len(cfg.Audiences) == 0 {
|
|
t.Fatal("Audiences should default to a non-empty allowlist")
|
|
}
|
|
has := func(want string) bool {
|
|
for _, a := range cfg.Audiences {
|
|
if a == want {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
// The gateway origin and at least the primary app client_id must be
|
|
// accepted, else every normal user JWT (aud=hanzo-app) is rejected.
|
|
for _, want := range []string{"https://api.hanzo.ai", "hanzo-app"} {
|
|
if !has(want) {
|
|
t.Errorf("default Audiences %v missing %q", cfg.Audiences, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBillingCheckerFailClosed(t *testing.T) {
|
|
// Unreachable billing service -> deny (no free), with a non-nil error so
|
|
// the caller can surface a 503 rather than a wrong "insufficient balance".
|
|
checker := newBillingChecker("http://127.0.0.1:1", "test-token") // unreachable port
|
|
ok, err := checker.checkBalance("hanzo/test-user")
|
|
if ok {
|
|
t.Error("billing check must fail-closed (deny) when commerce is unreachable")
|
|
}
|
|
if err == nil {
|
|
t.Error("unreachable commerce should return a non-nil error so the caller emits 503")
|
|
}
|
|
}
|
|
|
|
func TestBillingCheckerNoURL(t *testing.T) {
|
|
// Test with empty billing URL - should pass through
|
|
checker := newBillingChecker("", "")
|
|
ok, err := checker.checkBalance("hanzo/test-user")
|
|
if !ok {
|
|
t.Error("billing check should pass when no URL configured")
|
|
}
|
|
if err != nil {
|
|
t.Errorf("billing check should not error when no URL configured: %v", err)
|
|
}
|
|
}
|