Files
terraform-provider-unifi/internal/provider/base/features_test.go
Mateusz Filipowicz ca21f79083 feat: support Guest Access settings with resource_setting_guest_access (#61)
* feat: support Guest Access settings with `resource_setting_guest_access`

* feat: add support for redirect after authentication in guest access settings

* feat: add support for Facebook authentication in guest access settings

* feat: add support for Google authentication in guest access settings

* feat: add support for RADIUS authentication in guest access settings

* feat: add support for Wechat authentication in guest access settings

* feat: add support for Facebook Wifi authentication in guest access settings

* feat: add support for restricted DNS servers

* feat: add support for guest portal UI customization

* feat: add support for restricted subnet in guest portal

* feat: retry client action on HTTP 401, but first attempt relogging in

* require controllr version 7.4 for several portal customization attributes

* enable acceptance tests workflow concurrency
2025-03-17 14:53:28 +01:00

657 lines
19 KiB
Go

package base
import (
"context"
"errors"
"sync"
"testing"
"github.com/filipowm/go-unifi/unifi"
"github.com/hashicorp/terraform-plugin-framework/attr"
"github.com/hashicorp/terraform-plugin-framework/diag"
"github.com/hashicorp/terraform-plugin-framework/path"
"github.com/hashicorp/terraform-plugin-framework/types"
"github.com/stretchr/testify/assert"
)
// MockUnifiClient provides a minimal implementation of unifi.Client for testing
type MockUnifiClient struct {
unifi.Client // Embed the interface to satisfy all methods (they'll panic if called)
featuresFunc func(ctx context.Context, site string) ([]unifi.DescribedFeature, error)
}
// ListFeatures implements the only unifi.Client method we care about for testing
func (m *MockUnifiClient) ListFeatures(ctx context.Context, site string) ([]unifi.DescribedFeature, error) {
if m.featuresFunc != nil {
return m.featuresFunc(ctx, site)
}
return nil, errors.New("ListFeatures not implemented")
}
// TestFeaturesIsEnabled tests the IsEnabled method of Features
func TestFeaturesIsEnabled(t *testing.T) {
tests := []struct {
name string
features Features
feature string
expected bool
}{
{
name: "feature is enabled",
features: Features{"feature1": featureEnabled, "feature2": featureDisabled},
feature: "feature1",
expected: true,
},
{
name: "feature is disabled",
features: Features{"feature1": featureEnabled, "feature2": featureDisabled},
feature: "feature2",
expected: false,
},
{
name: "feature does not exist",
features: Features{"feature1": featureEnabled},
feature: "feature2",
expected: false,
},
{
name: "empty features map",
features: Features{},
feature: "feature1",
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := tt.features.IsEnabled(tt.feature)
assert.Equal(t, tt.expected, result)
})
}
}
// TestFeaturesIsDisabled tests the IsDisabled method of Features
func TestFeaturesIsDisabled(t *testing.T) {
tests := []struct {
name string
features Features
feature string
expected bool
}{
{
name: "feature is enabled",
features: Features{"feature1": featureEnabled, "feature2": featureDisabled},
feature: "feature1",
expected: false,
},
{
name: "feature is disabled",
features: Features{"feature1": featureEnabled, "feature2": featureDisabled},
feature: "feature2",
expected: true,
},
{
name: "feature does not exist",
features: Features{"feature1": featureEnabled},
feature: "feature2",
expected: false,
},
{
name: "empty features map",
features: Features{},
feature: "feature1",
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := tt.features.IsDisabled(tt.feature)
assert.Equal(t, tt.expected, result)
})
}
}
// TestFeaturesIsUnavailable tests the IsUnavailable method of Features
func TestFeaturesIsUnavailable(t *testing.T) {
tests := []struct {
name string
features Features
feature string
expected bool
}{
{
name: "feature is enabled",
features: Features{"feature1": featureEnabled, "feature2": featureDisabled},
feature: "feature2",
expected: false,
},
{
name: "feature is disabled",
features: Features{"feature1": featureEnabled},
feature: "feature2",
expected: true,
},
{
name: "feature does not exist",
features: Features{},
feature: "feature2",
expected: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := tt.features.IsUnavailable(tt.feature)
assert.Equal(t, tt.expected, result)
})
}
}
func newTestClient(mock *MockUnifiClient) *Client {
return &Client{
Client: mock,
Site: "default",
}
}
// TestNewFeatureValidator tests the NewFeatureValidator function
func TestNewFeatureValidator(t *testing.T) {
mockUnifiClient := &MockUnifiClient{
featuresFunc: func(ctx context.Context, site string) ([]unifi.DescribedFeature, error) {
return []unifi.DescribedFeature{}, nil
},
}
client := newTestClient(mockUnifiClient)
validator := NewFeatureValidator(client)
assert.NotNil(t, validator, "Validator should not be nil")
featureValidator, ok := validator.(*featureEnabledValidator)
assert.True(t, ok, "Validator should be of type *featureEnabledValidator")
assert.Equal(t, client, featureValidator.client, "Client should be set correctly")
assert.NotNil(t, featureValidator.cache, "Cache should be initialized")
}
// TestGetFeatures tests the getFeatures method of featureEnabledValidator
func TestGetFeatures(t *testing.T) {
tests := []struct {
name string
setup func() *MockUnifiClient
site string
expected Features
}{
{
name: "successfully get features",
setup: func() *MockUnifiClient {
return &MockUnifiClient{
featuresFunc: func(ctx context.Context, site string) ([]unifi.DescribedFeature, error) {
return []unifi.DescribedFeature{
{Name: "feature1", FeatureExists: true},
{Name: "feature2", FeatureExists: false},
}, nil
},
}
},
site: "site1",
expected: Features{
"feature1": featureEnabled,
"feature2": featureDisabled,
},
},
{
name: "error getting features",
setup: func() *MockUnifiClient {
return &MockUnifiClient{
featuresFunc: func(ctx context.Context, site string) ([]unifi.DescribedFeature, error) {
return nil, errors.New("error listing features")
},
}
},
site: "site2",
expected: Features{}, // Now returns empty Features instead of nil
},
{
name: "no features returned",
setup: func() *MockUnifiClient {
return &MockUnifiClient{
featuresFunc: func(ctx context.Context, site string) ([]unifi.DescribedFeature, error) {
return []unifi.DescribedFeature{}, nil
},
}
},
site: "site3",
expected: Features{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockUnifiClient := tt.setup()
client := newTestClient(mockUnifiClient)
validator := &featureEnabledValidator{
client: client,
cache: make(map[string]Features),
lock: sync.Mutex{},
}
result := validator.getFeatures(context.Background(), tt.site)
assert.Equal(t, tt.expected, result)
// Test caching - if we call again, we should get the cached result
if tt.expected != nil {
// Replace the test client with one that fails
client.Client = &MockUnifiClient{
featuresFunc: func(ctx context.Context, site string) ([]unifi.DescribedFeature, error) {
return nil, errors.New("should not be called")
},
}
result = validator.getFeatures(context.Background(), tt.site)
assert.Equal(t, tt.expected, result)
}
})
}
}
// TestGetFeaturesConcurrent tests the concurrency safety of getFeatures
func TestGetFeaturesConcurrent(t *testing.T) {
callCount := 0
mockUnifiClient := &MockUnifiClient{
featuresFunc: func(ctx context.Context, site string) ([]unifi.DescribedFeature, error) {
callCount++
return []unifi.DescribedFeature{
{Name: "feature1", FeatureExists: true},
{Name: "feature2", FeatureExists: false},
}, nil
},
}
client := newTestClient(mockUnifiClient)
validator := &featureEnabledValidator{
client: client,
cache: make(map[string]Features),
lock: sync.Mutex{},
}
var wg sync.WaitGroup
// Launch 10 concurrent goroutines to call getFeatures
for i := 0; i < 10; i++ {
wg.Add(1)
go func() {
defer wg.Done()
features := validator.getFeatures(context.Background(), "site1")
assert.NotNil(t, features)
assert.True(t, features.IsEnabled("feature1"))
assert.False(t, features.IsEnabled("feature2"))
}()
}
wg.Wait()
// Verify ListFeatures was called exactly once
assert.Equal(t, 1, callCount, "ListFeatures should be called exactly once")
}
// TestRequireFeatures tests the requireFeatures method of featureEnabledValidator
func TestRequireFeatures(t *testing.T) {
tests := []struct {
name string
features Features
site string
attrPath *path.Path
requiredFeatures []string
expectedHasErrors bool
}{
{
name: "all features enabled",
features: Features{"feature1": featureEnabled, "feature2": featureEnabled},
site: "site1",
attrPath: nil,
requiredFeatures: []string{"feature1", "feature2"},
expectedHasErrors: false,
},
{
name: "one feature disabled",
features: Features{"feature1": featureEnabled, "feature2": featureDisabled},
site: "site1",
attrPath: nil,
requiredFeatures: []string{"feature1", "feature2"},
expectedHasErrors: true,
},
{
name: "all features disabled",
features: Features{"feature1": featureDisabled, "feature2": featureDisabled},
site: "site1",
attrPath: nil,
requiredFeatures: []string{"feature1", "feature2"},
expectedHasErrors: true,
},
{
name: "empty required features",
features: Features{"feature1": featureEnabled, "feature2": featureEnabled},
site: "site1",
attrPath: nil,
requiredFeatures: []string{},
expectedHasErrors: false,
},
{
name: "nil required features",
features: Features{"feature1": featureEnabled, "feature2": featureEnabled},
site: "site1",
attrPath: nil,
requiredFeatures: nil,
expectedHasErrors: false,
},
{
name: "with attribute path",
features: Features{"feature1": featureDisabled},
site: "site1",
attrPath: &path.Path{},
requiredFeatures: []string{"feature1"},
expectedHasErrors: true,
},
{
name: "feature not in map",
features: Features{"feature1": featureEnabled},
site: "site1",
attrPath: nil,
requiredFeatures: []string{"feature2"},
expectedHasErrors: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockUnifiClient := &MockUnifiClient{}
client := newTestClient(mockUnifiClient)
validator := &featureEnabledValidator{
client: client,
cache: map[string]Features{tt.site: tt.features},
lock: sync.Mutex{},
}
diags := validator.requireFeatures(context.Background(), tt.site, tt.attrPath, tt.requiredFeatures...)
assert.Equal(t, tt.expectedHasErrors, diags.HasError())
if tt.expectedHasErrors {
// Verify error message contains appropriate information
assert.Contains(t, diags[0].Detail(), "Features", "Error detail should mention 'Features'")
if tt.attrPath != nil {
assert.Contains(t, diags[0].Detail(), "is not supported", "Error should mention path is not supported")
}
}
})
}
}
// TestRequireFeaturesEnabledForPath tests the RequireFeaturesEnabledForPath method
func TestRequireFeaturesEnabledForPath(t *testing.T) {
tests := []struct {
name string
setupClient func() *MockUnifiClient
attrValue attr.Value
attrPath path.Path
requiredFeatures []string
configError bool
expectedHasErrors bool
}{
{
name: "attribute not set",
setupClient: func() *MockUnifiClient {
return &MockUnifiClient{}
},
attrValue: types.StringNull(),
attrPath: path.Root("test"),
requiredFeatures: []string{"feature1"},
configError: false,
expectedHasErrors: false,
},
{
name: "error getting attribute",
setupClient: func() *MockUnifiClient {
return &MockUnifiClient{}
},
attrValue: types.StringNull(),
attrPath: path.Root("test"),
requiredFeatures: []string{"feature1"},
configError: true,
expectedHasErrors: true,
},
{
name: "attribute set, feature enabled",
setupClient: func() *MockUnifiClient {
return &MockUnifiClient{
featuresFunc: func(ctx context.Context, site string) ([]unifi.DescribedFeature, error) {
return []unifi.DescribedFeature{
{Name: "feature1", FeatureExists: true},
}, nil
},
}
},
attrValue: types.StringValue("test"),
attrPath: path.Root("test"),
requiredFeatures: []string{"feature1"},
configError: false,
expectedHasErrors: false,
},
{
name: "attribute set, feature disabled",
setupClient: func() *MockUnifiClient {
return &MockUnifiClient{
featuresFunc: func(ctx context.Context, site string) ([]unifi.DescribedFeature, error) {
return []unifi.DescribedFeature{
{Name: "feature1", FeatureExists: false},
}, nil
},
}
},
attrValue: types.StringValue("test"),
attrPath: path.Root("test"),
requiredFeatures: []string{"feature1"},
configError: false,
expectedHasErrors: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockUnifiClient := tt.setupClient()
client := newTestClient(mockUnifiClient)
// Create a wrapper FeatureValidator that provides a minimal implementation
// of RequireFeaturesEnabledForPath without needing a real tfsdk.Config
validator := &testFeatureValidator{
base: NewFeatureValidator(client),
attrValue: tt.attrValue,
configErr: tt.configError,
}
diags := validator.TestRequireFeaturesEnabledForPath(context.Background(), "site1", tt.attrPath, tt.requiredFeatures...)
assert.Equal(t, tt.expectedHasErrors, diags.HasError())
})
}
}
// TestRequireFeaturesEnabled tests the RequireFeaturesEnabled method
func TestRequireFeaturesEnabled(t *testing.T) {
tests := []struct {
name string
setupClient func() *MockUnifiClient
requiredFeatures []string
expectedHasErrors bool
}{
{
name: "feature enabled",
setupClient: func() *MockUnifiClient {
return &MockUnifiClient{
featuresFunc: func(ctx context.Context, site string) ([]unifi.DescribedFeature, error) {
return []unifi.DescribedFeature{
{Name: "feature1", FeatureExists: true},
}, nil
},
}
},
requiredFeatures: []string{"feature1"},
expectedHasErrors: false,
},
{
name: "feature disabled",
setupClient: func() *MockUnifiClient {
return &MockUnifiClient{
featuresFunc: func(ctx context.Context, site string) ([]unifi.DescribedFeature, error) {
return []unifi.DescribedFeature{
{Name: "feature1", FeatureExists: false},
}, nil
},
}
},
requiredFeatures: []string{"feature1"},
expectedHasErrors: true,
},
{
name: "feature error",
setupClient: func() *MockUnifiClient {
return &MockUnifiClient{
featuresFunc: func(ctx context.Context, site string) ([]unifi.DescribedFeature, error) {
return nil, errors.New("error listing features")
},
}
},
requiredFeatures: []string{"feature1"},
expectedHasErrors: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockUnifiClient := tt.setupClient()
client := newTestClient(mockUnifiClient)
validator := NewFeatureValidator(client)
diags := validator.RequireFeaturesEnabled(context.Background(), "site1", tt.requiredFeatures...)
assert.Equal(t, tt.expectedHasErrors, diags.HasError())
})
}
}
// TestIsDefined is used in RequireFeaturesEnabledForPath to check if a value is defined
func TestIsDefined(t *testing.T) {
tests := []struct {
name string
value attr.Value
expected bool
}{
{
name: "null",
value: types.StringNull(),
expected: false,
},
{
name: "unknown",
value: types.StringUnknown(),
expected: false,
},
{
name: "null list",
value: types.ListNull(types.StringType),
expected: false,
},
{
name: "empty list",
value: types.ListValueMust(types.StringType, []attr.Value{}),
expected: true,
},
{
name: "defined value",
value: types.StringValue("test"),
expected: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.expected, IsDefined(tt.value))
})
}
}
// TestFeatureValidatorCache specifically tests the caching behavior of the FeatureValidator
// It verifies that multiple calls with the same site only result in one API call
func TestFeatureValidatorCache(t *testing.T) {
// Create a mock client with a counter for API calls
callCount := 0
mockUnifiClient := &MockUnifiClient{
featuresFunc: func(ctx context.Context, site string) ([]unifi.DescribedFeature, error) {
callCount++
return []unifi.DescribedFeature{
{Name: "feature1", FeatureExists: true},
{Name: "feature2", FeatureExists: false},
}, nil
},
}
client := newTestClient(mockUnifiClient)
validator := NewFeatureValidator(client)
// First call to check features should trigger an API call
diags1 := validator.RequireFeaturesEnabled(context.Background(), "site1", "feature1")
assert.Equal(t, 1, callCount, "First call should trigger an API call")
assert.False(t, diags1.HasError(), "Feature1 should be enabled")
// Second call with the same site should use the cache
diags2 := validator.RequireFeaturesEnabled(context.Background(), "site1", "feature2")
assert.Equal(t, 1, callCount, "Second call should use cached data")
assert.True(t, diags2.HasError(), "Feature2 should be disabled")
// Call with a different site should trigger another API call
diags3 := validator.RequireFeaturesEnabled(context.Background(), "site2", "feature1")
assert.Equal(t, 2, callCount, "Call with different site should trigger an API call")
assert.False(t, diags3.HasError(), "Feature1 should be enabled")
// Multiple calls using the same site should still use the cache
for i := 0; i < 5; i++ {
validator.RequireFeaturesEnabled(context.Background(), "site1", "feature1")
}
assert.Equal(t, 2, callCount, "Multiple calls with same site should use cached data")
}
// testFeatureValidator wraps a real FeatureValidator but has a special method for testing
// that doesn't require a real tfsdk.Config
type testFeatureValidator struct {
base FeatureValidator
attrValue attr.Value
configErr bool
}
// TestRequireFeaturesEnabledForPath is a test-specific version that doesn't need a real tfsdk.Config
func (v *testFeatureValidator) TestRequireFeaturesEnabledForPath(ctx context.Context, site string,
attrPath path.Path, features ...string) diag.Diagnostics {
diags := diag.Diagnostics{}
// This simulates what happens in RequireFeaturesEnabledForPath without needing a real Config
if v.configErr {
diags.AddError("Error", "Error getting attribute")
return diags
}
if !IsDefined(v.attrValue) {
return diags
}
// Call the underlying validator's RequireFeaturesEnabled
fv, ok := v.base.(*featureEnabledValidator)
if !ok {
diags.AddError("Error", "Invalid validator type")
return diags
}
diags.Append(fv.requireFeatures(ctx, site, &attrPath, features...)...)
return diags
}