Files
terraform-provider-netbird/internal/provider/reverse_proxy_service_test.go

1379 lines
42 KiB
Go

package provider
import (
"context"
"reflect"
"testing"
"github.com/hashicorp/terraform-plugin-framework/attr"
"github.com/hashicorp/terraform-plugin-framework/datasource"
"github.com/hashicorp/terraform-plugin-framework/resource"
"github.com/hashicorp/terraform-plugin-framework/types"
"github.com/netbirdio/netbird/shared/management/http/api"
)
func Test_reverseProxyServiceAPIToTerraform(t *testing.T) {
cases := []struct {
name string
service *api.Service
}{
{
name: "basic service with password auth",
service: &api.Service{
Id: "svc1",
Name: "test-service",
Domain: "test.example.com",
Enabled: true,
Targets: []api.ServiceTarget{
{
TargetId: "peer1",
TargetType: api.ServiceTargetTargetTypePeer,
Port: 8080,
Protocol: api.ServiceTargetProtocolHttp,
Enabled: true,
},
},
Auth: api.ServiceAuthConfig{
PasswordAuth: &api.PasswordAuthConfig{
Enabled: true,
Password: "secret",
},
},
},
},
{
name: "service with optional fields and bearer auth",
service: &api.Service{
Id: "svc2",
Name: "advanced-service",
Domain: "app.example.com",
Enabled: false,
PassHostHeader: valPtr(true),
RewriteRedirects: valPtr(true),
Targets: []api.ServiceTarget{
{
TargetId: "res1",
TargetType: api.ServiceTargetTargetType("subnet"),
Port: 443,
Protocol: api.ServiceTargetProtocolHttps,
Enabled: true,
Host: valPtr("backend.internal"),
Path: valPtr("/api"),
},
},
Auth: api.ServiceAuthConfig{
BearerAuth: &api.BearerAuthConfig{
Enabled: true,
DistributionGroups: &[]string{"group1", "group2"},
},
},
},
},
{
name: "service with pin auth and nil optional pointers",
service: &api.Service{
Id: "svc3",
Name: "pin-service",
Domain: "pin.example.com",
Enabled: true,
Targets: []api.ServiceTarget{
{
TargetId: "peer2",
TargetType: api.ServiceTargetTargetTypePeer,
Port: 0,
Protocol: api.ServiceTargetProtocolHttp,
Enabled: false,
},
},
Auth: api.ServiceAuthConfig{
PinAuth: &api.PINAuthConfig{
Enabled: true,
Pin: "1234",
},
},
},
},
{
name: "service with link auth",
service: &api.Service{
Id: "svc4",
Name: "link-service",
Domain: "link.example.com",
Enabled: true,
Targets: []api.ServiceTarget{
{
TargetId: "peer1",
TargetType: api.ServiceTargetTargetTypePeer,
Port: 80,
Protocol: api.ServiceTargetProtocolHttp,
Enabled: true,
},
},
Auth: api.ServiceAuthConfig{
LinkAuth: &api.LinkAuthConfig{
Enabled: true,
},
},
},
},
{
name: "service with target options",
service: &api.Service{
Id: "svc5",
Name: "options-service",
Domain: "opts.example.com",
Enabled: true,
Targets: []api.ServiceTarget{
{
TargetId: "peer1",
TargetType: api.ServiceTargetTargetTypePeer,
Port: 8080,
Protocol: api.ServiceTargetProtocolHttps,
Enabled: true,
Options: &api.ServiceTargetOptions{
SkipTlsVerify: valPtr(true),
RequestTimeout: valPtr("30s"),
PathRewrite: valPtr(api.ServiceTargetOptionsPathRewritePreserve),
CustomHeaders: &map[string]string{"X-Custom": "value"},
},
},
},
Auth: api.ServiceAuthConfig{
PasswordAuth: &api.PasswordAuthConfig{
Enabled: true,
Password: "secret",
},
},
},
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
var out ReverseProxyServiceModel
ctx := context.Background()
outDiag := reverseProxyServiceAPIToTerraform(ctx, c.service, &out)
if outDiag.HasError() {
t.Fatalf("Expected no error diagnostics, found %d errors", outDiag.ErrorsCount())
}
if out.Id.ValueString() != c.service.Id {
t.Errorf("Id mismatch: expected %s, got %s", c.service.Id, out.Id.ValueString())
}
if out.Name.ValueString() != c.service.Name {
t.Errorf("Name mismatch: expected %s, got %s", c.service.Name, out.Name.ValueString())
}
if out.Domain.ValueString() != c.service.Domain {
t.Errorf("Domain mismatch: expected %s, got %s", c.service.Domain, out.Domain.ValueString())
}
if out.Enabled.ValueBool() != c.service.Enabled {
t.Errorf("Enabled mismatch: expected %v, got %v", c.service.Enabled, out.Enabled.ValueBool())
}
expectedPassHost := false
if c.service.PassHostHeader != nil {
expectedPassHost = *c.service.PassHostHeader
}
if out.PassHostHeader.ValueBool() != expectedPassHost {
t.Errorf("PassHostHeader mismatch: expected %v, got %v", expectedPassHost, out.PassHostHeader.ValueBool())
}
expectedRewrite := false
if c.service.RewriteRedirects != nil {
expectedRewrite = *c.service.RewriteRedirects
}
if out.RewriteRedirects.ValueBool() != expectedRewrite {
t.Errorf("RewriteRedirects mismatch: expected %v, got %v", expectedRewrite, out.RewriteRedirects.ValueBool())
}
})
}
}
func Test_reverseProxyServiceRoundtrip(t *testing.T) {
ctx := context.Background()
original := &api.Service{
Id: "svc-rt",
Name: "roundtrip-service",
Domain: "rt.example.com",
Enabled: true,
PassHostHeader: valPtr(true),
RewriteRedirects: valPtr(false),
Targets: []api.ServiceTarget{
{
TargetId: "peer1",
TargetType: api.ServiceTargetTargetTypePeer,
Port: 8080,
Protocol: api.ServiceTargetProtocolHttp,
Enabled: true,
Host: valPtr("10.0.0.1"),
Path: valPtr("/v1"),
},
},
Auth: api.ServiceAuthConfig{
PasswordAuth: &api.PasswordAuthConfig{
Enabled: true,
Password: "pass123",
},
},
}
// API -> Terraform
var model ReverseProxyServiceModel
d := reverseProxyServiceAPIToTerraform(ctx, original, &model)
if d.HasError() {
t.Fatalf("APIToTerraform failed with %d errors", d.ErrorsCount())
}
// Terraform -> API
req, d := reverseProxyServiceTerraformToAPI(ctx, &model)
if d.HasError() {
t.Fatalf("TerraformToAPI failed with %d errors", d.ErrorsCount())
}
if req.Name != original.Name {
t.Errorf("Name mismatch: expected %s, got %s", original.Name, req.Name)
}
if req.Domain != original.Domain {
t.Errorf("Domain mismatch: expected %s, got %s", original.Domain, req.Domain)
}
if req.Enabled != original.Enabled {
t.Errorf("Enabled mismatch: expected %v, got %v", original.Enabled, req.Enabled)
}
if req.PassHostHeader == nil || *req.PassHostHeader != *original.PassHostHeader {
t.Errorf("PassHostHeader mismatch")
}
if req.RewriteRedirects == nil || *req.RewriteRedirects != *original.RewriteRedirects {
t.Errorf("RewriteRedirects mismatch")
}
if req.Targets == nil || len(*req.Targets) != 1 {
t.Fatalf("Expected 1 target, got %v", req.Targets)
}
target := (*req.Targets)[0]
origTarget := original.Targets[0]
if target.TargetId != origTarget.TargetId {
t.Errorf("Target.TargetId mismatch: expected %s, got %s", origTarget.TargetId, target.TargetId)
}
if target.TargetType != origTarget.TargetType {
t.Errorf("Target.TargetType mismatch")
}
if target.Port != origTarget.Port {
t.Errorf("Target.Port mismatch: expected %d, got %d", origTarget.Port, target.Port)
}
if target.Protocol != origTarget.Protocol {
t.Errorf("Target.Protocol mismatch")
}
if target.Host == nil || *target.Host != *origTarget.Host {
t.Errorf("Target.Host mismatch")
}
if target.Path == nil || *target.Path != *origTarget.Path {
t.Errorf("Target.Path mismatch")
}
if req.Auth.PasswordAuth == nil {
t.Fatal("Expected PasswordAuth to be set")
}
if req.Auth.PasswordAuth.Enabled != original.Auth.PasswordAuth.Enabled {
t.Errorf("PasswordAuth.Enabled mismatch")
}
if req.Auth.PasswordAuth.Password != original.Auth.PasswordAuth.Password {
t.Errorf("PasswordAuth.Password mismatch")
}
}
func Test_reverseProxyServiceRoundtrip_withOptions(t *testing.T) {
ctx := context.Background()
preserve := api.ServiceTargetOptionsPathRewritePreserve
original := &api.Service{
Id: "svc-opts-rt",
Name: "options-roundtrip",
Domain: "opts-rt.example.com",
Enabled: true,
Targets: []api.ServiceTarget{
{
TargetId: "peer1",
TargetType: api.ServiceTargetTargetTypePeer,
Port: 8443,
Protocol: api.ServiceTargetProtocolHttps,
Enabled: true,
Options: &api.ServiceTargetOptions{
SkipTlsVerify: valPtr(true),
RequestTimeout: valPtr("45s"),
PathRewrite: &preserve,
CustomHeaders: &map[string]string{"X-Env": "prod"},
},
},
},
Auth: api.ServiceAuthConfig{
PasswordAuth: &api.PasswordAuthConfig{
Enabled: true,
Password: "test",
},
},
}
var model ReverseProxyServiceModel
d := reverseProxyServiceAPIToTerraform(ctx, original, &model)
if d.HasError() {
t.Fatalf("APIToTerraform failed with %d errors", d.ErrorsCount())
}
req, d := reverseProxyServiceTerraformToAPI(ctx, &model)
if d.HasError() {
t.Fatalf("TerraformToAPI failed with %d errors", d.ErrorsCount())
}
if req.Targets == nil || len(*req.Targets) != 1 {
t.Fatalf("Expected 1 target")
}
target := (*req.Targets)[0]
if target.Options == nil {
t.Fatal("Expected Options to be set")
}
if target.Options.SkipTlsVerify == nil || !*target.Options.SkipTlsVerify {
t.Error("SkipTlsVerify should be true")
}
if target.Options.RequestTimeout == nil || *target.Options.RequestTimeout != "45s" {
t.Errorf("RequestTimeout mismatch: got %v", target.Options.RequestTimeout)
}
if target.Options.PathRewrite == nil || *target.Options.PathRewrite != preserve {
t.Error("PathRewrite should be preserve")
}
if target.Options.CustomHeaders == nil || (*target.Options.CustomHeaders)["X-Env"] != "prod" {
t.Error("CustomHeaders mismatch")
}
}
func Test_reverseProxyServiceRoundtrip_l4Options(t *testing.T) {
ctx := context.Background()
original := &api.Service{
Id: "svc-l4-rt",
Name: "l4-roundtrip",
Domain: "l4-rt.example.com",
Enabled: true,
Targets: []api.ServiceTarget{
{
TargetId: "res1",
TargetType: api.ServiceTargetTargetType("subnet"),
Port: 5432,
Protocol: api.ServiceTargetProtocolTcp,
Enabled: true,
Options: &api.ServiceTargetOptions{
ProxyProtocol: valPtr(true),
RequestTimeout: valPtr("60s"),
},
},
{
TargetId: "res2",
TargetType: api.ServiceTargetTargetType("subnet"),
Port: 53,
Protocol: api.ServiceTargetProtocolUdp,
Enabled: true,
Options: &api.ServiceTargetOptions{
SessionIdleTimeout: valPtr("2m"),
},
},
},
Auth: api.ServiceAuthConfig{
PasswordAuth: &api.PasswordAuthConfig{
Enabled: true,
Password: "test",
},
},
}
var model ReverseProxyServiceModel
d := reverseProxyServiceAPIToTerraform(ctx, original, &model)
if d.HasError() {
t.Fatalf("APIToTerraform failed with %d errors", d.ErrorsCount())
}
req, d := reverseProxyServiceTerraformToAPI(ctx, &model)
if d.HasError() {
t.Fatalf("TerraformToAPI failed with %d errors", d.ErrorsCount())
}
if req.Targets == nil || len(*req.Targets) != 2 {
t.Fatalf("Expected 2 targets")
}
tcp := (*req.Targets)[0]
if tcp.Options == nil {
t.Fatal("Expected TCP target options")
}
if tcp.Options.ProxyProtocol == nil || !*tcp.Options.ProxyProtocol {
t.Error("TCP ProxyProtocol should be true")
}
if tcp.Options.RequestTimeout == nil || *tcp.Options.RequestTimeout != "60s" {
t.Error("TCP RequestTimeout mismatch")
}
udp := (*req.Targets)[1]
if udp.Options == nil {
t.Fatal("Expected UDP target options")
}
if udp.Options.SessionIdleTimeout == nil || *udp.Options.SessionIdleTimeout != "2m" {
t.Error("UDP SessionIdleTimeout mismatch")
}
}
func Test_reverseProxyServiceTargetModelTFType(t *testing.T) {
tfType := ReverseProxyServiceTargetModel{}.TFType()
expectedKeys := []string{"target_id", "target_type", "host", "port", "protocol", "path", "enabled", "options"}
for _, key := range expectedKeys {
if _, ok := tfType.AttrTypes[key]; !ok {
t.Errorf("Expected key %s in TFType, not found", key)
}
}
if len(tfType.AttrTypes) != len(expectedKeys) {
t.Errorf("Expected %d keys in TFType, got %d", len(expectedKeys), len(tfType.AttrTypes))
}
}
func Test_reverseProxyServiceAuthModelTFType(t *testing.T) {
tfType := ReverseProxyServiceAuthModel{}.TFType()
expectedKeys := []string{"password_auth", "pin_auth", "bearer_auth", "link_auth", "header_auths"}
for _, key := range expectedKeys {
if _, ok := tfType.AttrTypes[key]; !ok {
t.Errorf("Expected key %s in TFType, not found", key)
}
}
}
func Test_reverseProxyTargetOptionsModelTFType(t *testing.T) {
tfType := ReverseProxyTargetOptionsModel{}.TFType()
expectedKeys := []string{"skip_tls_verify", "request_timeout", "path_rewrite", "custom_headers", "proxy_protocol", "session_idle_timeout"}
for _, key := range expectedKeys {
if _, ok := tfType.AttrTypes[key]; !ok {
t.Errorf("Expected key %s in TFType, not found", key)
}
}
if len(tfType.AttrTypes) != len(expectedKeys) {
t.Errorf("Expected %d keys in TFType, got %d", len(expectedKeys), len(tfType.AttrTypes))
}
}
func Test_reverseProxyServiceAPIToTerraform_nullAuth(t *testing.T) {
ctx := context.Background()
svc := &api.Service{
Id: "svc-null",
Name: "null-auth-service",
Domain: "null.example.com",
Enabled: true,
Targets: []api.ServiceTarget{
{
TargetId: "peer1",
TargetType: api.ServiceTargetTargetTypePeer,
Port: 80,
Protocol: api.ServiceTargetProtocolHttp,
Enabled: true,
},
},
Auth: api.ServiceAuthConfig{},
}
var out ReverseProxyServiceModel
d := reverseProxyServiceAPIToTerraform(ctx, svc, &out)
if d.HasError() {
t.Fatalf("Expected no error diagnostics, found %d errors", d.ErrorsCount())
}
if out.Auth.IsNull() {
t.Error("Auth should not be null")
}
authAttrs := out.Auth.Attributes()
pwObj, ok := authAttrs["password_auth"].(types.Object)
if !ok || !pwObj.IsNull() {
t.Error("password_auth should be null when not set in API")
}
pinObj, ok := authAttrs["pin_auth"].(types.Object)
if !ok || !pinObj.IsNull() {
t.Error("pin_auth should be null when not set in API")
}
bearerObj, ok := authAttrs["bearer_auth"].(types.Object)
if !ok || !bearerObj.IsNull() {
t.Error("bearer_auth should be null when not set in API")
}
linkObj, ok := authAttrs["link_auth"].(types.Object)
if !ok || !linkObj.IsNull() {
t.Error("link_auth should be null when not set in API")
}
}
func Test_reverseProxyServiceAPIToTerraform_nullOptions(t *testing.T) {
ctx := context.Background()
svc := &api.Service{
Id: "svc-no-opts",
Name: "no-options",
Domain: "noopts.example.com",
Enabled: true,
Targets: []api.ServiceTarget{
{
TargetId: "peer1",
TargetType: api.ServiceTargetTargetTypePeer,
Port: 80,
Protocol: api.ServiceTargetProtocolHttp,
Enabled: true,
Options: nil,
},
},
Auth: api.ServiceAuthConfig{},
}
var out ReverseProxyServiceModel
d := reverseProxyServiceAPIToTerraform(ctx, svc, &out)
if d.HasError() {
t.Fatalf("Expected no error diagnostics, found %d errors", d.ErrorsCount())
}
var targets []ReverseProxyServiceTargetModel
d = out.Targets.ElementsAs(ctx, &targets, false)
if d.HasError() {
t.Fatal("Failed to extract targets")
}
if len(targets) != 1 {
t.Fatalf("Expected 1 target, got %d", len(targets))
}
if !targets[0].Options.IsNull() {
t.Error("Options should be null when not set in API")
}
}
func Test_reverseProxyServiceAPIToTerraform_bearerWithNilGroups(t *testing.T) {
ctx := context.Background()
svc := &api.Service{
Id: "svc-bearer-nil",
Name: "bearer-nil-groups",
Domain: "bearer.example.com",
Enabled: true,
Targets: []api.ServiceTarget{
{
TargetId: "peer1",
TargetType: api.ServiceTargetTargetTypePeer,
Port: 80,
Protocol: api.ServiceTargetProtocolHttp,
Enabled: true,
},
},
Auth: api.ServiceAuthConfig{
BearerAuth: &api.BearerAuthConfig{
Enabled: true,
DistributionGroups: nil,
},
},
}
var out ReverseProxyServiceModel
d := reverseProxyServiceAPIToTerraform(ctx, svc, &out)
if d.HasError() {
t.Fatalf("Expected no error diagnostics, found %d errors", d.ErrorsCount())
}
authAttrs := out.Auth.Attributes()
bearerObj, ok := authAttrs["bearer_auth"].(types.Object)
if !ok {
t.Fatal("bearer_auth is not types.Object")
}
if bearerObj.IsNull() {
t.Fatal("bearer_auth should not be null")
}
bearerAttrs := bearerObj.Attributes()
groupsList, ok := bearerAttrs["distribution_groups"].(types.List)
if !ok {
t.Fatal("distribution_groups is not types.List")
}
if !groupsList.IsNull() {
t.Error("distribution_groups should be null when API returns nil")
}
}
func Test_reverseProxyServiceAPIToTerraform_multipleTargets(t *testing.T) {
ctx := context.Background()
svc := &api.Service{
Id: "svc-multi",
Name: "multi-target",
Domain: "multi.example.com",
Enabled: true,
Targets: []api.ServiceTarget{
{
TargetId: "peer1",
TargetType: api.ServiceTargetTargetTypePeer,
Port: 8080,
Protocol: api.ServiceTargetProtocolHttp,
Enabled: true,
},
{
TargetId: "res1",
TargetType: api.ServiceTargetTargetType("subnet"),
Port: 443,
Protocol: api.ServiceTargetProtocolHttps,
Enabled: false,
Host: valPtr("backend.local"),
Path: valPtr("/api/v2"),
},
},
Auth: api.ServiceAuthConfig{
PinAuth: &api.PINAuthConfig{
Enabled: true,
Pin: "9999",
},
},
}
var out ReverseProxyServiceModel
d := reverseProxyServiceAPIToTerraform(ctx, svc, &out)
if d.HasError() {
t.Fatalf("Expected no error diagnostics, found %d errors", d.ErrorsCount())
}
var targets []ReverseProxyServiceTargetModel
d = out.Targets.ElementsAs(ctx, &targets, false)
if d.HasError() {
t.Fatalf("Failed to extract targets: %d errors", d.ErrorsCount())
}
if len(targets) != 2 {
t.Fatalf("Expected 2 targets, got %d", len(targets))
}
if targets[0].TargetId.ValueString() != "peer1" {
t.Errorf("First target ID mismatch: expected peer1, got %s", targets[0].TargetId.ValueString())
}
if targets[1].TargetId.ValueString() != "res1" {
t.Errorf("Second target ID mismatch: expected res1, got %s", targets[1].TargetId.ValueString())
}
if targets[1].Host.ValueString() != "backend.local" {
t.Errorf("Second target host mismatch")
}
if targets[1].Path.ValueString() != "/api/v2" {
t.Errorf("Second target path mismatch")
}
if targets[1].Enabled.ValueBool() != false {
t.Errorf("Second target should be disabled")
}
}
func Test_reverseProxyServiceTerraformToAPI_targets(t *testing.T) {
ctx := context.Background()
targetModels := []ReverseProxyServiceTargetModel{
{
TargetId: types.StringValue("peer1"),
TargetType: types.StringValue("peer"),
Port: types.Int64Value(8080),
Protocol: types.StringValue("http"),
Enabled: types.BoolValue(true),
Host: types.StringNull(),
Path: types.StringNull(),
Options: types.ObjectNull(ReverseProxyTargetOptionsModel{}.TFType().AttrTypes),
},
}
targetsList, d := types.ListValueFrom(ctx, ReverseProxyServiceTargetModel{}.TFType(), targetModels)
if d.HasError() {
t.Fatalf("Failed to build targets list")
}
pwAuth, d := types.ObjectValueFrom(ctx, ReverseProxyPasswordAuthModel{}.TFType().AttrTypes, ReverseProxyPasswordAuthModel{
Enabled: types.BoolValue(false),
Password: types.StringValue(""),
})
if d.HasError() {
t.Fatalf("Failed to build password auth object")
}
authModel := ReverseProxyServiceAuthModel{
PasswordAuth: pwAuth,
PinAuth: types.ObjectNull(ReverseProxyPinAuthModel{}.TFType().AttrTypes),
BearerAuth: types.ObjectNull(ReverseProxyBearerAuthModel{}.TFType().AttrTypes),
LinkAuth: types.ObjectNull(ReverseProxyLinkAuthModel{}.TFType().AttrTypes),
HeaderAuths: types.ListNull(ReverseProxyHeaderAuthModel{}.TFType()),
}
authObj, d := types.ObjectValueFrom(ctx, ReverseProxyServiceAuthModel{}.TFType().AttrTypes, authModel)
if d.HasError() {
t.Fatalf("Failed to build auth object")
}
model := &ReverseProxyServiceModel{
Id: types.StringValue("test-id"),
Name: types.StringValue("test"),
Domain: types.StringValue("test.com"),
Enabled: types.BoolValue(true),
PassHostHeader: types.BoolValue(false),
RewriteRedirects: types.BoolValue(false),
Targets: targetsList,
Auth: authObj,
}
req, d := reverseProxyServiceTerraformToAPI(ctx, model)
if d.HasError() {
t.Fatalf("TerraformToAPI failed with %d errors", d.ErrorsCount())
}
if req.Name != "test" {
t.Errorf("Name mismatch: expected test, got %s", req.Name)
}
if req.Targets == nil || len(*req.Targets) != 1 {
t.Fatalf("Expected 1 target, got %v", req.Targets)
}
if (*req.Targets)[0].TargetId != "peer1" {
t.Errorf("Target ID mismatch")
}
if (*req.Targets)[0].Host != nil {
t.Errorf("Target Host should be nil for null input")
}
if (*req.Targets)[0].Path != nil {
t.Errorf("Target Path should be nil for null input")
}
if (*req.Targets)[0].Options != nil {
t.Errorf("Target Options should be nil for null input")
}
if req.Auth == nil || req.Auth.PasswordAuth == nil {
t.Fatal("Expected PasswordAuth to be set")
}
if req.Auth.PinAuth != nil {
t.Error("Expected PinAuth to be nil")
}
if req.Auth.BearerAuth != nil {
t.Error("Expected BearerAuth to be nil")
}
if req.Auth.LinkAuth != nil {
t.Error("Expected LinkAuth to be nil")
}
}
func Test_reverseProxyServiceSchemaValidation(t *testing.T) {
r := &ReverseProxyService{}
var metaResp resource.MetadataResponse
r.Metadata(context.Background(), resource.MetadataRequest{ProviderTypeName: "netbird"}, &metaResp)
if metaResp.TypeName != "netbird_reverse_proxy_service" {
t.Errorf("Expected type name netbird_reverse_proxy_service, got %s", metaResp.TypeName)
}
var schemaResp resource.SchemaResponse
r.Schema(context.Background(), resource.SchemaRequest{}, &schemaResp)
if schemaResp.Diagnostics.HasError() {
t.Fatalf("Schema returned errors")
}
attrs := schemaResp.Schema.Attributes
requiredAttrs := []string{"name", "domain", "targets", "auth"}
for _, a := range requiredAttrs {
if _, ok := attrs[a]; !ok {
t.Errorf("Expected required attribute %s in schema", a)
}
}
computedAttrs := []string{"id", "proxy_cluster"}
for _, a := range computedAttrs {
if _, ok := attrs[a]; !ok {
t.Errorf("Expected computed attribute %s in schema", a)
}
}
var _ resource.Resource = &ReverseProxyService{}
var _ resource.ResourceWithImportState = &ReverseProxyService{}
}
func Test_reverseProxyServiceTFTypeConsistency(t *testing.T) {
targetType := ReverseProxyServiceTargetModel{}.TFType()
authType := ReverseProxyServiceAuthModel{}.TFType()
pwType := ReverseProxyPasswordAuthModel{}.TFType()
pinType := ReverseProxyPinAuthModel{}.TFType()
bearerType := ReverseProxyBearerAuthModel{}.TFType()
linkType := ReverseProxyLinkAuthModel{}.TFType()
optsType := ReverseProxyTargetOptionsModel{}.TFType()
if !reflect.DeepEqual(authType.AttrTypes["password_auth"], pwType) {
t.Error("Auth password_auth type doesn't match PasswordAuth TFType")
}
if !reflect.DeepEqual(authType.AttrTypes["pin_auth"], pinType) {
t.Error("Auth pin_auth type doesn't match PinAuth TFType")
}
if !reflect.DeepEqual(authType.AttrTypes["bearer_auth"], bearerType) {
t.Error("Auth bearer_auth type doesn't match BearerAuth TFType")
}
if !reflect.DeepEqual(authType.AttrTypes["link_auth"], linkType) {
t.Error("Auth link_auth type doesn't match LinkAuth TFType")
}
if !reflect.DeepEqual(targetType.AttrTypes["options"], optsType) {
t.Error("Target options type doesn't match TargetOptions TFType")
}
if len(targetType.AttrTypes) != 8 {
t.Errorf("Expected 8 target attributes, got %d", len(targetType.AttrTypes))
}
}
func Test_preserveAuthSecrets(t *testing.T) {
ctx := context.Background()
priorAuthModel := ReverseProxyServiceAuthModel{
PasswordAuth: mustObjectValue(ctx, ReverseProxyPasswordAuthModel{}.TFType().AttrTypes, ReverseProxyPasswordAuthModel{
Enabled: types.BoolValue(true),
Password: types.StringValue("real-password"),
}),
PinAuth: mustObjectValue(ctx, ReverseProxyPinAuthModel{}.TFType().AttrTypes, ReverseProxyPinAuthModel{
Enabled: types.BoolValue(true),
Pin: types.StringValue("1234"),
}),
BearerAuth: types.ObjectNull(ReverseProxyBearerAuthModel{}.TFType().AttrTypes),
LinkAuth: types.ObjectNull(ReverseProxyLinkAuthModel{}.TFType().AttrTypes),
HeaderAuths: types.ListNull(ReverseProxyHeaderAuthModel{}.TFType()),
}
priorAuth := mustObjectValue(ctx, ReverseProxyServiceAuthModel{}.TFType().AttrTypes, priorAuthModel)
currentAuthModel := ReverseProxyServiceAuthModel{
PasswordAuth: mustObjectValue(ctx, ReverseProxyPasswordAuthModel{}.TFType().AttrTypes, ReverseProxyPasswordAuthModel{
Enabled: types.BoolValue(true),
Password: types.StringValue(""),
}),
PinAuth: mustObjectValue(ctx, ReverseProxyPinAuthModel{}.TFType().AttrTypes, ReverseProxyPinAuthModel{
Enabled: types.BoolValue(true),
Pin: types.StringValue(""),
}),
BearerAuth: types.ObjectNull(ReverseProxyBearerAuthModel{}.TFType().AttrTypes),
LinkAuth: types.ObjectNull(ReverseProxyLinkAuthModel{}.TFType().AttrTypes),
HeaderAuths: types.ListNull(ReverseProxyHeaderAuthModel{}.TFType()),
}
currentAuth := mustObjectValue(ctx, ReverseProxyServiceAuthModel{}.TFType().AttrTypes, currentAuthModel)
result, d := preserveAuthSecrets(priorAuth, currentAuth)
if d.HasError() {
t.Fatalf("preserveAuthSecrets failed with %d errors", d.ErrorsCount())
}
authAttrs := result.Attributes()
pwObj, ok := authAttrs["password_auth"].(types.Object)
if !ok {
t.Fatal("password_auth is not types.Object")
}
pwAttrs := pwObj.Attributes()
password, ok := pwAttrs["password"].(types.String)
if !ok {
t.Fatal("password is not types.String")
}
if password.ValueString() != "real-password" {
t.Errorf("Expected password to be preserved as 'real-password', got %q", password.ValueString())
}
pinObj, ok := authAttrs["pin_auth"].(types.Object)
if !ok {
t.Fatal("pin_auth is not types.Object")
}
pinAttrs := pinObj.Attributes()
pin, ok := pinAttrs["pin"].(types.String)
if !ok {
t.Fatal("pin is not types.String")
}
if pin.ValueString() != "1234" {
t.Errorf("Expected pin to be preserved as '1234', got %q", pin.ValueString())
}
}
func Test_preserveAuthSecrets_nullPrior(t *testing.T) {
ctx := context.Background()
currentAuthModel := ReverseProxyServiceAuthModel{
PasswordAuth: mustObjectValue(ctx, ReverseProxyPasswordAuthModel{}.TFType().AttrTypes, ReverseProxyPasswordAuthModel{
Enabled: types.BoolValue(true),
Password: types.StringValue("new-password"),
}),
PinAuth: types.ObjectNull(ReverseProxyPinAuthModel{}.TFType().AttrTypes),
BearerAuth: types.ObjectNull(ReverseProxyBearerAuthModel{}.TFType().AttrTypes),
LinkAuth: types.ObjectNull(ReverseProxyLinkAuthModel{}.TFType().AttrTypes),
HeaderAuths: types.ListNull(ReverseProxyHeaderAuthModel{}.TFType()),
}
currentAuth := mustObjectValue(ctx, ReverseProxyServiceAuthModel{}.TFType().AttrTypes, currentAuthModel)
result, d := preserveAuthSecrets(types.ObjectNull(ReverseProxyServiceAuthModel{}.TFType().AttrTypes), currentAuth)
if d.HasError() {
t.Fatalf("preserveAuthSecrets failed with %d errors", d.ErrorsCount())
}
if result.IsNull() {
t.Error("Result should not be null when only prior is null")
}
authAttrs := result.Attributes()
pwObj, ok := authAttrs["password_auth"].(types.Object)
if !ok {
t.Fatal("password_auth is not types.Object")
}
pwAttrs := pwObj.Attributes()
password, ok := pwAttrs["password"].(types.String)
if !ok {
t.Fatal("password is not types.String")
}
if password.ValueString() != "new-password" {
t.Errorf("Expected password to remain 'new-password', got %q", password.ValueString())
}
}
func Test_preserveTargetPlanValues(t *testing.T) {
ctx := context.Background()
planModels := []ReverseProxyServiceTargetModel{
{
TargetId: types.StringValue("peer1"),
TargetType: types.StringValue("peer"),
Port: types.Int64Value(8080),
Protocol: types.StringValue("http"),
Enabled: types.BoolValue(true),
Host: types.StringValue("10.0.0.1"),
Path: types.StringValue("/custom"),
Options: types.ObjectNull(ReverseProxyTargetOptionsModel{}.TFType().AttrTypes),
},
}
planTargets, d := types.ListValueFrom(ctx, ReverseProxyServiceTargetModel{}.TFType(), planModels)
if d.HasError() {
t.Fatal("Failed to build plan targets")
}
apiModels := []ReverseProxyServiceTargetModel{
{
TargetId: types.StringValue("peer1"),
TargetType: types.StringValue("peer"),
Port: types.Int64Value(8080),
Protocol: types.StringValue("http"),
Enabled: types.BoolValue(true),
Host: types.StringValue("100.0.219.86"),
Path: types.StringValue("/"),
Options: types.ObjectNull(ReverseProxyTargetOptionsModel{}.TFType().AttrTypes),
},
}
apiTargets, d := types.ListValueFrom(ctx, ReverseProxyServiceTargetModel{}.TFType(), apiModels)
if d.HasError() {
t.Fatal("Failed to build API targets")
}
result, d := preserveTargetPlanValues(ctx, planTargets, apiTargets)
if d.HasError() {
t.Fatalf("preserveTargetPlanValues failed with %d errors", d.ErrorsCount())
}
var resultModels []ReverseProxyServiceTargetModel
d = result.ElementsAs(ctx, &resultModels, false)
if d.HasError() {
t.Fatal("Failed to extract result targets")
}
if len(resultModels) != 1 {
t.Fatalf("Expected 1 target, got %d", len(resultModels))
}
if resultModels[0].Host.ValueString() != "10.0.0.1" {
t.Errorf("Expected host to be preserved as '10.0.0.1', got %q", resultModels[0].Host.ValueString())
}
if resultModels[0].Path.ValueString() != "/custom" {
t.Errorf("Expected path to be preserved as '/custom', got %q", resultModels[0].Path.ValueString())
}
if resultModels[0].Port.ValueInt64() != 8080 {
t.Errorf("Expected port 8080, got %d", resultModels[0].Port.ValueInt64())
}
}
func Test_preserveTargetPlanValues_nullPlanHost(t *testing.T) {
ctx := context.Background()
planModels := []ReverseProxyServiceTargetModel{
{
TargetId: types.StringValue("peer1"),
TargetType: types.StringValue("peer"),
Port: types.Int64Value(80),
Protocol: types.StringValue("http"),
Enabled: types.BoolValue(true),
Host: types.StringNull(),
Path: types.StringNull(),
Options: types.ObjectNull(ReverseProxyTargetOptionsModel{}.TFType().AttrTypes),
},
}
planTargets, d := types.ListValueFrom(ctx, ReverseProxyServiceTargetModel{}.TFType(), planModels)
if d.HasError() {
t.Fatal("Failed to build plan targets")
}
apiModels := []ReverseProxyServiceTargetModel{
{
TargetId: types.StringValue("peer1"),
TargetType: types.StringValue("peer"),
Port: types.Int64Value(80),
Protocol: types.StringValue("http"),
Enabled: types.BoolValue(true),
Host: types.StringValue("100.0.219.86"),
Path: types.StringValue("/"),
Options: types.ObjectNull(ReverseProxyTargetOptionsModel{}.TFType().AttrTypes),
},
}
apiTargets, d := types.ListValueFrom(ctx, ReverseProxyServiceTargetModel{}.TFType(), apiModels)
if d.HasError() {
t.Fatal("Failed to build API targets")
}
result, d := preserveTargetPlanValues(ctx, planTargets, apiTargets)
if d.HasError() {
t.Fatalf("preserveTargetPlanValues failed with %d errors", d.ErrorsCount())
}
var resultModels []ReverseProxyServiceTargetModel
d = result.ElementsAs(ctx, &resultModels, false)
if d.HasError() {
t.Fatal("Failed to extract result targets")
}
if resultModels[0].Host.ValueString() != "100.0.219.86" {
t.Errorf("Expected API host '100.0.219.86' when plan is null, got %q", resultModels[0].Host.ValueString())
}
if resultModels[0].Path.ValueString() != "/" {
t.Errorf("Expected API path '/' when plan is null, got %q", resultModels[0].Path.ValueString())
}
}
func Test_preserveTargetPlanValues_extraAPITarget(t *testing.T) {
ctx := context.Background()
planModels := []ReverseProxyServiceTargetModel{
{
TargetId: types.StringValue("peer1"),
TargetType: types.StringValue("peer"),
Port: types.Int64Value(80),
Protocol: types.StringValue("http"),
Enabled: types.BoolValue(true),
Host: types.StringValue("10.0.0.1"),
Path: types.StringNull(),
Options: types.ObjectNull(ReverseProxyTargetOptionsModel{}.TFType().AttrTypes),
},
}
planTargets, d := types.ListValueFrom(ctx, ReverseProxyServiceTargetModel{}.TFType(), planModels)
if d.HasError() {
t.Fatal("Failed to build plan targets")
}
apiModels := []ReverseProxyServiceTargetModel{
{
TargetId: types.StringValue("peer1"),
TargetType: types.StringValue("peer"),
Port: types.Int64Value(80),
Protocol: types.StringValue("http"),
Enabled: types.BoolValue(true),
Host: types.StringValue("100.0.219.86"),
Path: types.StringValue("/"),
Options: types.ObjectNull(ReverseProxyTargetOptionsModel{}.TFType().AttrTypes),
},
{
TargetId: types.StringValue("peer2"),
TargetType: types.StringValue("peer"),
Port: types.Int64Value(443),
Protocol: types.StringValue("https"),
Enabled: types.BoolValue(true),
Host: types.StringValue("100.0.34.47"),
Path: types.StringValue("/"),
Options: types.ObjectNull(ReverseProxyTargetOptionsModel{}.TFType().AttrTypes),
},
}
apiTargets, d := types.ListValueFrom(ctx, ReverseProxyServiceTargetModel{}.TFType(), apiModels)
if d.HasError() {
t.Fatal("Failed to build API targets")
}
result, d := preserveTargetPlanValues(ctx, planTargets, apiTargets)
if d.HasError() {
t.Fatalf("preserveTargetPlanValues failed with %d errors", d.ErrorsCount())
}
var resultModels []ReverseProxyServiceTargetModel
d = result.ElementsAs(ctx, &resultModels, false)
if d.HasError() {
t.Fatal("Failed to extract result targets")
}
if len(resultModels) != 2 {
t.Fatalf("Expected 2 targets, got %d", len(resultModels))
}
if resultModels[0].Host.ValueString() != "10.0.0.1" {
t.Errorf("Expected peer1 host preserved as '10.0.0.1', got %q", resultModels[0].Host.ValueString())
}
if resultModels[1].Host.ValueString() != "100.0.34.47" {
t.Errorf("Expected peer2 API host '100.0.34.47', got %q", resultModels[1].Host.ValueString())
}
}
func Test_preserveTargetPlanValues_nullPlan(t *testing.T) {
ctx := context.Background()
apiModels := []ReverseProxyServiceTargetModel{
{
TargetId: types.StringValue("peer1"),
TargetType: types.StringValue("peer"),
Port: types.Int64Value(80),
Protocol: types.StringValue("http"),
Enabled: types.BoolValue(true),
Host: types.StringValue("100.0.219.86"),
Path: types.StringValue("/"),
Options: types.ObjectNull(ReverseProxyTargetOptionsModel{}.TFType().AttrTypes),
},
}
apiTargets, d := types.ListValueFrom(ctx, ReverseProxyServiceTargetModel{}.TFType(), apiModels)
if d.HasError() {
t.Fatal("Failed to build API targets")
}
result, d := preserveTargetPlanValues(ctx, types.ListNull(ReverseProxyServiceTargetModel{}.TFType()), apiTargets)
if d.HasError() {
t.Fatalf("preserveTargetPlanValues failed with %d errors", d.ErrorsCount())
}
var resultModels []ReverseProxyServiceTargetModel
d = result.ElementsAs(ctx, &resultModels, false)
if d.HasError() {
t.Fatal("Failed to extract result targets")
}
if resultModels[0].Host.ValueString() != "100.0.219.86" {
t.Errorf("Expected API host when plan is null, got %q", resultModels[0].Host.ValueString())
}
}
func Test_reverseProxyServiceRoundtrip_bearerAuth(t *testing.T) {
ctx := context.Background()
original := &api.Service{
Id: "svc-bearer-rt",
Name: "bearer-roundtrip",
Domain: "bearer-rt.example.com",
Enabled: true,
Targets: []api.ServiceTarget{
{
TargetId: "peer1",
TargetType: api.ServiceTargetTargetTypePeer,
Port: 8080,
Protocol: api.ServiceTargetProtocolHttp,
Enabled: true,
},
},
Auth: api.ServiceAuthConfig{
BearerAuth: &api.BearerAuthConfig{
Enabled: true,
DistributionGroups: &[]string{"group1", "group2"},
},
},
}
var model ReverseProxyServiceModel
d := reverseProxyServiceAPIToTerraform(ctx, original, &model)
if d.HasError() {
t.Fatalf("APIToTerraform failed with %d errors", d.ErrorsCount())
}
req, d := reverseProxyServiceTerraformToAPI(ctx, &model)
if d.HasError() {
t.Fatalf("TerraformToAPI failed with %d errors", d.ErrorsCount())
}
if req.Auth.BearerAuth == nil {
t.Fatal("Expected BearerAuth to be set")
}
if req.Auth.BearerAuth.Enabled != true {
t.Error("BearerAuth.Enabled should be true")
}
if req.Auth.BearerAuth.DistributionGroups == nil {
t.Fatal("Expected DistributionGroups to be set")
}
groups := *req.Auth.BearerAuth.DistributionGroups
if len(groups) != 2 {
t.Fatalf("Expected 2 distribution groups, got %d", len(groups))
}
if groups[0] != "group1" || groups[1] != "group2" {
t.Errorf("Distribution groups mismatch: got %v", groups)
}
if req.Auth.PasswordAuth != nil {
t.Error("PasswordAuth should be nil")
}
if req.Auth.PinAuth != nil {
t.Error("PinAuth should be nil")
}
}
func Test_reverseProxyServiceDataSourceSchema(t *testing.T) {
d := &ReverseProxyServiceDataSource{}
var metaResp datasource.MetadataResponse
d.Metadata(context.Background(), datasource.MetadataRequest{ProviderTypeName: "netbird"}, &metaResp)
if metaResp.TypeName != "netbird_reverse_proxy_service" {
t.Errorf("Expected type name netbird_reverse_proxy_service, got %s", metaResp.TypeName)
}
var schemaResp datasource.SchemaResponse
d.Schema(context.Background(), datasource.SchemaRequest{}, &schemaResp)
if schemaResp.Diagnostics.HasError() {
t.Fatalf("Schema returned errors")
}
attrs := schemaResp.Schema.Attributes
expectedAttrs := []string{"id", "name", "domain", "enabled", "pass_host_header", "rewrite_redirects", "proxy_cluster", "targets", "auth"}
for _, attr := range expectedAttrs {
if _, ok := attrs[attr]; !ok {
t.Errorf("Expected attribute %s in schema", attr)
}
}
var _ datasource.DataSource = &ReverseProxyServiceDataSource{}
}
func Test_reverseProxyServiceRoundtrip_headerAuth(t *testing.T) {
ctx := context.Background()
original := &api.Service{
Id: "svc-header-rt",
Name: "header-roundtrip",
Domain: "header-rt.example.com",
Enabled: true,
Targets: []api.ServiceTarget{
{
TargetId: "peer1",
TargetType: api.ServiceTargetTargetTypePeer,
Port: 8080,
Protocol: api.ServiceTargetProtocolHttp,
Enabled: true,
},
},
Auth: api.ServiceAuthConfig{
HeaderAuths: &[]api.HeaderAuthConfig{
{Enabled: true, Header: "X-API-Key", Value: "secret-key"},
{Enabled: true, Header: "Authorization", Value: "Bearer token123"},
},
},
}
var model ReverseProxyServiceModel
d := reverseProxyServiceAPIToTerraform(ctx, original, &model)
if d.HasError() {
t.Fatalf("APIToTerraform failed with %d errors", d.ErrorsCount())
}
req, d := reverseProxyServiceTerraformToAPI(ctx, &model)
if d.HasError() {
t.Fatalf("TerraformToAPI failed with %d errors", d.ErrorsCount())
}
if req.Auth.HeaderAuths == nil {
t.Fatal("Expected HeaderAuths to be set")
}
headers := *req.Auth.HeaderAuths
if len(headers) != 2 {
t.Fatalf("Expected 2 header auths, got %d", len(headers))
}
if headers[0].Header != "X-API-Key" || headers[0].Value != "secret-key" {
t.Errorf("First header auth mismatch: got %+v", headers[0])
}
if headers[1].Header != "Authorization" || headers[1].Value != "Bearer token123" {
t.Errorf("Second header auth mismatch: got %+v", headers[1])
}
}
func Test_reverseProxyServiceRoundtrip_accessRestrictions(t *testing.T) {
ctx := context.Background()
original := &api.Service{
Id: "svc-ar-rt",
Name: "access-restrictions-roundtrip",
Domain: "ar-rt.example.com",
Enabled: true,
Targets: []api.ServiceTarget{
{
TargetId: "peer1",
TargetType: api.ServiceTargetTargetTypePeer,
Port: 8080,
Protocol: api.ServiceTargetProtocolHttp,
Enabled: true,
},
},
Auth: api.ServiceAuthConfig{
PasswordAuth: &api.PasswordAuthConfig{
Enabled: true,
Password: "test",
},
},
AccessRestrictions: &api.AccessRestrictions{
AllowedCountries: &[]string{"US", "DE"},
BlockedCidrs: &[]string{"192.168.0.0/16", "10.0.0.0/8"},
},
}
var model ReverseProxyServiceModel
d := reverseProxyServiceAPIToTerraform(ctx, original, &model)
if d.HasError() {
t.Fatalf("APIToTerraform failed with %d errors", d.ErrorsCount())
}
if model.AccessRestrictions.IsNull() {
t.Fatal("AccessRestrictions should not be null")
}
req, d := reverseProxyServiceTerraformToAPI(ctx, &model)
if d.HasError() {
t.Fatalf("TerraformToAPI failed with %d errors", d.ErrorsCount())
}
if req.AccessRestrictions == nil {
t.Fatal("Expected AccessRestrictions to be set")
}
if req.AccessRestrictions.AllowedCountries == nil || len(*req.AccessRestrictions.AllowedCountries) != 2 {
t.Fatalf("Expected 2 allowed countries, got %v", req.AccessRestrictions.AllowedCountries)
}
countries := *req.AccessRestrictions.AllowedCountries
if countries[0] != "US" || countries[1] != "DE" {
t.Errorf("Allowed countries mismatch: got %v", countries)
}
if req.AccessRestrictions.BlockedCidrs == nil || len(*req.AccessRestrictions.BlockedCidrs) != 2 {
t.Fatalf("Expected 2 blocked CIDRs, got %v", req.AccessRestrictions.BlockedCidrs)
}
if req.AccessRestrictions.AllowedCidrs != nil {
t.Error("AllowedCidrs should be nil when not set")
}
if req.AccessRestrictions.BlockedCountries != nil {
t.Error("BlockedCountries should be nil when not set")
}
}
func Test_reverseProxyServiceAPIToTerraform_nullAccessRestrictions(t *testing.T) {
ctx := context.Background()
svc := &api.Service{
Id: "svc-no-ar",
Name: "no-access-restrictions",
Domain: "noar.example.com",
Enabled: true,
Targets: []api.ServiceTarget{
{
TargetId: "peer1",
TargetType: api.ServiceTargetTargetTypePeer,
Port: 80,
Protocol: api.ServiceTargetProtocolHttp,
Enabled: true,
},
},
Auth: api.ServiceAuthConfig{},
}
var out ReverseProxyServiceModel
d := reverseProxyServiceAPIToTerraform(ctx, svc, &out)
if d.HasError() {
t.Fatalf("Expected no error diagnostics, found %d errors", d.ErrorsCount())
}
if !out.AccessRestrictions.IsNull() {
t.Error("AccessRestrictions should be null when not set in API")
}
}
func mustObjectValue(ctx context.Context, attrTypes map[string]attr.Type, val any) types.Object {
obj, d := types.ObjectValueFrom(ctx, attrTypes, val)
if d.HasError() {
panic("failed to create object value in test helper")
}
return obj
}