Files
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

281 lines
6.5 KiB
Go

package testing
import (
"bytes"
"context"
"crypto/tls"
"encoding/json"
"fmt"
"github.com/filipowm/terraform-provider-unifi/internal/provider/base"
"github.com/hashicorp/terraform-plugin-log/tflog"
"github.com/hashicorp/terraform-plugin-testing/helper/resource"
"net/http"
"os"
"path/filepath"
"sync"
"testing"
"time"
"github.com/filipowm/go-unifi/unifi"
"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/modules/compose"
)
const (
TestEnvStarting testEnvironmentStatus = iota
TestEnvReady
TestEnvDown
TestEnvUnknown
)
type testEnvironmentStatus int
type TestEnvironment struct {
Client unifi.Client
Endpoint string
Shutdown func()
ctx context.Context
internalClient *http.Client
mutex sync.Mutex
timeout time.Duration
}
type envStatus struct {
Meta struct {
Up bool `json:"up"`
} `json:"meta"`
}
func Run(m *testing.M, callback func(env *TestEnvironment)) int {
if os.Getenv(resource.EnvTfAcc) == "" {
// short circuit non-acceptance test runs
os.Exit(m.Run())
}
env := NewTestEnvironment(5 * time.Minute)
return env.run(m, callback)
}
func NewTestEnvironment(startupTimeout time.Duration) *TestEnvironment {
c := http.Client{Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}}
ctx := context.Background()
return &TestEnvironment{
Endpoint: "https://localhost:8443", // default endpoint, assumed
timeout: startupTimeout,
mutex: sync.Mutex{},
ctx: ctx,
internalClient: &c,
Shutdown: func() {},
}
}
func (te *TestEnvironment) isReady() bool {
if st, _ := te.readStatus(te.ctx); st != TestEnvReady {
return false
}
return true
}
func (te *TestEnvironment) run(m *testing.M, callback func(env *TestEnvironment)) int {
err := te.Start()
defer func() {
te.Shutdown()
}()
if err != nil {
panic(err)
}
err = te.WaitUntilReady()
if err != nil {
panic(err)
}
c, err := te.newTestClient()
if err != nil {
panic(err)
}
te.Client = c
callback(te)
return m.Run()
}
func (te *TestEnvironment) readStatus(ctx context.Context) (testEnvironmentStatus, error) {
req, err := http.NewRequest("GET", fmt.Sprintf("%s/status", te.Endpoint), nil)
if err != nil {
return TestEnvUnknown, err
}
req = req.WithContext(ctx)
r, err := te.internalClient.Do(req)
if err != nil {
return TestEnvDown, err
}
resp := envStatus{}
err = json.NewDecoder(r.Body).Decode(&resp)
if err != nil {
return TestEnvUnknown, err
}
if resp.Meta.Up {
return TestEnvReady, nil
}
return TestEnvStarting, nil
}
func findFileInProject(filename string) (string, error) {
wd, err := os.Getwd()
if err != nil {
return "", err
}
// Walk up the directory tree until we find a file
for {
path := filepath.Join(wd, filename)
if _, err := os.Stat(path); err == nil {
return path, nil
}
if wd == "/" {
break
}
wd = filepath.Dir(wd)
}
return "", fmt.Errorf("file %s not found in project", filename)
}
func (te *TestEnvironment) startDockerController(ctx context.Context) error {
composeFile, err := findFileInProject("docker-compose.yaml")
if err != nil {
return fmt.Errorf("failed to find docker-compose.yaml file: %w", err)
}
dc, err := compose.NewDockerCompose(composeFile)
shutdown := func() {
if dc != nil {
if err := dc.Down(context.Background(), compose.RemoveOrphans(true), compose.RemoveImagesLocal); err != nil {
panic(err)
}
}
}
te.Shutdown = shutdown
if err != nil {
return err
}
if err = dc.WithOsEnv().Up(ctx, compose.Wait(true)); err != nil {
return fmt.Errorf("failed to Start docker-compose. Controller container might be already running or starting: %w", err)
}
container, err := dc.ServiceContainer(ctx, "unifi")
if err != nil {
return err
}
// Dump the container logs on exit.
//
// TODO: Use https://pkg.go.dev/github.com/testcontainers/testcontainers-go#LogConsumer instead.
te.Shutdown = func() {
shutdown()
if os.Getenv("UNIFI_STDOUT") == "" {
return
}
stream, err := container.Logs(ctx)
if err != nil {
fmt.Printf("Failed to get logs from container: %v", err)
return
}
buffer := new(bytes.Buffer)
buffer.ReadFrom(stream)
testcontainers.Logger.Printf("%s", buffer)
}
endpoint, err := container.PortEndpoint(ctx, "8443/tcp", "https")
if err != nil {
return err
}
te.Endpoint = endpoint
return nil
}
func (te *TestEnvironment) WaitUntilReady() error {
te.mutex.Lock()
ctx, cancel := context.WithTimeoutCause(te.ctx, te.timeout, fmt.Errorf("controller was not ready within %s", te.timeout))
defer cancel()
defer te.mutex.Unlock()
if st, _ := te.readStatus(ctx); st == TestEnvDown || st == TestEnvUnknown {
return fmt.Errorf("controller is not starting nor running. Use Start() first to Start the controller")
}
te.waitForController(ctx)
if !te.isReady() {
return fmt.Errorf("controller is not ready within %s", te.timeout)
}
return nil
}
func (te *TestEnvironment) Start() error {
tflog.Error(te.ctx, "Starting test environment")
if te.isReady() {
tflog.Warn(te.ctx, "Environment is already running at "+te.Endpoint)
if te.Client == nil {
c, err := te.newTestClient()
if err != nil {
return err
}
te.Client = c
}
return nil
}
ctx, cancel := context.WithTimeoutCause(te.ctx, te.timeout, fmt.Errorf("controller did not Start within %s", te.timeout))
defer cancel()
err := te.startDockerController(ctx)
if err != nil {
return err
}
tflog.Info(te.ctx, "Environment is starting at "+te.Endpoint)
return nil
}
func (te *TestEnvironment) waitForController(ctx context.Context) {
var wg sync.WaitGroup
wg.Add(1)
go func() {
for {
if st, err := te.readStatus(ctx); err != nil {
return
} else if st == TestEnvReady {
wg.Done()
return
}
time.Sleep(1 * time.Second)
}
}()
wg.Wait()
}
func (te *TestEnvironment) newTestClient() (unifi.Client, error) {
const user = "admin"
const password = "admin"
var err error
if err = os.Setenv("UNIFI_USERNAME", user); err != nil {
return nil, err
}
if err = os.Setenv("UNIFI_PASSWORD", password); err != nil {
return nil, err
}
if err = os.Setenv("UNIFI_INSECURE", "true"); err != nil {
return nil, err
}
if err = os.Setenv("UNIFI_API", te.Endpoint); err != nil {
return nil, err
}
client, err := unifi.NewClient(&unifi.ClientConfig{
URL: te.Endpoint,
User: user,
Password: password,
VerifySSL: false,
RememberMe: true,
ValidationMode: unifi.DisableValidation,
Logger: unifi.NewDefaultLogger(unifi.WarnLevel),
})
return base.NewRetryableUnifiClient(client), err
}