182 lines
6.0 KiB
Go
182 lines
6.0 KiB
Go
package billing
|
|
|
|
import (
|
|
"errors"
|
|
"reflect"
|
|
"testing"
|
|
)
|
|
|
|
func TestIsGrantableFeatureKey(t *testing.T) {
|
|
grantable := []string{
|
|
"stores.hub",
|
|
"settings.api_keys",
|
|
"catalog.products",
|
|
"catalog.products.tab_error",
|
|
"marketing.campaigns.send",
|
|
}
|
|
for _, key := range grantable {
|
|
if !IsGrantableFeatureKey(key) {
|
|
t.Errorf("expected %q to be grantable", key)
|
|
}
|
|
}
|
|
notGrantable := []string{
|
|
"shell.navigation", // app chrome
|
|
"capability.ai_credits", // plan metering, not UI access
|
|
"dashboard.overview", // always-on landing page
|
|
"settings.profile", // always-on: own password / sign out
|
|
"catalog.not_a_real_key", // unknown
|
|
"",
|
|
}
|
|
for _, key := range notGrantable {
|
|
if IsGrantableFeatureKey(key) {
|
|
t.Errorf("expected %q to NOT be grantable", key)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSanitizeMemberPermissions(t *testing.T) {
|
|
t.Run("keeps only denials of grantable keys", func(t *testing.T) {
|
|
got, err := SanitizeMemberPermissions(map[string]bool{
|
|
"stores.hub": false,
|
|
"settings.api_keys": false,
|
|
"catalog.products": true, // allow is the default — never stored
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
want := map[string]bool{"stores.hub": false, "settings.api_keys": false}
|
|
if !reflect.DeepEqual(got, want) {
|
|
t.Fatalf("got %v want %v", got, want)
|
|
}
|
|
})
|
|
|
|
t.Run("rejects protected keys instead of silently dropping them", func(t *testing.T) {
|
|
if _, err := SanitizeMemberPermissions(map[string]bool{"shell.navigation": false}); !errors.Is(err, ErrFeatureNotGrantable) {
|
|
t.Fatalf("expected ErrFeatureNotGrantable, got %v", err)
|
|
}
|
|
if _, err := SanitizeMemberPermissions(map[string]bool{"dashboard.overview": false}); !errors.Is(err, ErrFeatureNotGrantable) {
|
|
t.Fatalf("expected ErrFeatureNotGrantable for always-on key, got %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("rejects unknown keys", func(t *testing.T) {
|
|
if _, err := SanitizeMemberPermissions(map[string]bool{"nope.nope": false}); !errors.Is(err, ErrFeatureNotGrantable) {
|
|
t.Fatalf("expected ErrFeatureNotGrantable, got %v", err)
|
|
}
|
|
if _, err := SanitizeMemberPermissions(map[string]bool{"nope.nope": true}); !errors.Is(err, ErrUnknownFeatureKey) {
|
|
t.Fatalf("expected ErrUnknownFeatureKey, got %v", err)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestMemberDeniesFeature(t *testing.T) {
|
|
perms := map[string]bool{"stores.hub": false, "catalog.products": false}
|
|
|
|
if !MemberDeniesFeature(perms, "stores.hub") {
|
|
t.Error("direct denial should apply")
|
|
}
|
|
if !MemberDeniesFeature(perms, "catalog.products.tab_error") {
|
|
t.Error("denying a parent must deny its descendants")
|
|
}
|
|
if MemberDeniesFeature(perms, "catalog.categories") {
|
|
t.Error("sibling keys must stay allowed")
|
|
}
|
|
if MemberDeniesFeature(nil, "stores.hub") {
|
|
t.Error("an empty overlay denies nothing")
|
|
}
|
|
|
|
// Protected keys survive even a hand-edited overlay that names them.
|
|
hostile := map[string]bool{"shell.navigation": false, "dashboard.overview": false, "capability.ai_credits": false}
|
|
for key := range hostile {
|
|
if MemberDeniesFeature(hostile, key) {
|
|
t.Errorf("%q must never be deniable", key)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestApplyMemberPermissions(t *testing.T) {
|
|
features := map[string]bool{
|
|
"stores.hub": true,
|
|
"catalog.products": true,
|
|
"catalog.products.tab_error": true,
|
|
"marketing.campaigns": false, // already off by plan
|
|
"dashboard.overview": true,
|
|
}
|
|
perms := map[string]bool{"stores.hub": false, "catalog.products": false, "marketing.campaigns": false}
|
|
|
|
got, denied := ApplyMemberPermissions(features, perms)
|
|
|
|
for _, key := range []string{"stores.hub", "catalog.products", "catalog.products.tab_error"} {
|
|
if got[key] {
|
|
t.Errorf("expected %q to be denied", key)
|
|
}
|
|
}
|
|
if !got["dashboard.overview"] {
|
|
t.Error("always-on keys must survive the overlay")
|
|
}
|
|
// Already-off plan keys are not reported as member denials — the member did not lose them.
|
|
want := []string{"catalog.products", "catalog.products.tab_error", "stores.hub"}
|
|
if !reflect.DeepEqual(denied, want) {
|
|
t.Fatalf("denied = %v, want %v", denied, want)
|
|
}
|
|
// The input map must not be mutated — it is shared with the company-level cache.
|
|
if !features["stores.hub"] {
|
|
t.Error("ApplyMemberPermissions mutated its input")
|
|
}
|
|
}
|
|
|
|
func TestApplyMemberPermissionsIsNoopWithoutOverlay(t *testing.T) {
|
|
features := map[string]bool{"stores.hub": true}
|
|
got, denied := ApplyMemberPermissions(features, nil)
|
|
if len(denied) != 0 {
|
|
t.Fatalf("expected no denials, got %v", denied)
|
|
}
|
|
if !reflect.DeepEqual(got, features) {
|
|
t.Fatalf("expected the map unchanged, got %v", got)
|
|
}
|
|
}
|
|
|
|
func TestGrantableFeatureKeysExcludeProtectedPrefixes(t *testing.T) {
|
|
keys := GrantableFeatureKeys()
|
|
if len(keys) == 0 {
|
|
t.Fatal("expected a non-empty grantable catalog")
|
|
}
|
|
for _, key := range keys {
|
|
if !IsGrantableFeatureKey(key) {
|
|
t.Errorf("GrantableFeatureKeys returned non-grantable %q", key)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestGrantableParent(t *testing.T) {
|
|
if got := grantableParent("catalog.products.tab_error"); got != "catalog.products" {
|
|
t.Errorf("got %q, want catalog.products", got)
|
|
}
|
|
// dashboard.overview is always-on, so its children have no grantable parent.
|
|
if got := grantableParent("dashboard.stats"); got != "" {
|
|
t.Errorf("got %q, want empty", got)
|
|
}
|
|
if got := grantableParent("stores.hub"); got != "" {
|
|
t.Errorf("got %q, want empty", got)
|
|
}
|
|
}
|
|
|
|
func TestDecodeMemberPermissionsIsDefensive(t *testing.T) {
|
|
// Garbage must not brick the dashboard — it resolves to "unrestricted".
|
|
if got, _ := decodeMemberPermissions([]byte("not json")); got != nil {
|
|
t.Errorf("expected nil for invalid JSON, got %v", got)
|
|
}
|
|
if got, _ := decodeMemberPermissions([]byte(`{}`)); got != nil {
|
|
t.Errorf("expected nil for an empty overlay, got %v", got)
|
|
}
|
|
// Stale / protected keys are dropped at read time too.
|
|
got, err := decodeMemberPermissions([]byte(`{"stores.hub":false,"shell.navigation":false,"gone.key":false,"feeds.list":true}`))
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
want := map[string]bool{"stores.hub": false}
|
|
if !reflect.DeepEqual(got, want) {
|
|
t.Fatalf("got %v, want %v", got, want)
|
|
}
|
|
}
|