chore(testing): Add tests for Domain Reconciler (#646)

* chore(mocks): Generate mocks with consistent prefix for .gitattributes

* feat(testing): Create a mock domain client

* feat(testing): Add tests for domain controller

* chore(domain-controller): Use k8s ptr library for comparison of CNAME target
This commit is contained in:
Jonathan Stacks
2025-05-06 14:15:16 -05:00
committed by GitHub
parent 7b82194fb3
commit e93e96ac9c
17 changed files with 1249 additions and 17 deletions
+1 -3
View File
@@ -15,6 +15,4 @@ manifest-bundle.yaml linguist-generated=true
helm/ngrok-operator/values.schema.json linguist-generated=true
# Generated by mockgen
internal/mocks/conn.go linguist-generated=true
internal/mocks/dialer.go linguist-generated=true
internal/mocks/tunnel.go linguist-generated=true
internal/mocks/mock_*.go linguist-generated=true
+2 -1
View File
@@ -29,6 +29,7 @@ import (
"github.com/ngrok/ngrok-api-go/v7"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/utils/ptr"
)
// EDIT THIS FILE! THIS IS SCAFFOLDING FOR YOU TO OWN!
@@ -126,7 +127,7 @@ func (d *Domain) Equal(ngrokDomain *ngrok.ReservedDomain) bool {
d.Status.Region == ngrokDomain.Region &&
d.Status.Domain == ngrokDomain.Domain &&
d.Status.URI == ngrokDomain.URI &&
d.Status.CNAMETarget == ngrokDomain.CNAMETarget &&
ptr.Equal(d.Status.CNAMETarget, ngrokDomain.CNAMETarget) &&
d.Spec.Description == ngrokDomain.Description &&
d.Spec.Metadata == ngrokDomain.Metadata
}
+2
View File
@@ -11,6 +11,7 @@ require (
github.com/ngrok/ngrok-api-go/v7 v7.3.0
github.com/onsi/ginkgo/v2 v2.23.4
github.com/onsi/gomega v1.36.3
github.com/segmentio/ksuid v1.0.4
github.com/spf13/cobra v1.8.1
github.com/stretchr/testify v1.10.0
go.uber.org/mock v0.5.2
@@ -112,6 +113,7 @@ require (
)
tool (
github.com/onsi/ginkgo/v2/ginkgo
go.uber.org/mock/mockgen
sigs.k8s.io/controller-runtime/tools/setup-envtest
sigs.k8s.io/controller-tools/cmd/controller-gen
+2
View File
@@ -475,6 +475,8 @@ github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/securego/gosec v0.0.0-20191002120514-e680875ea14d/go.mod h1:w5+eXa0mYznDkHaMCXA4XYffjlH+cy1oyKbfzJXa2Do=
github.com/segmentio/ksuid v1.0.4 h1:sBo2BdShXjmcugAMwjugoGUdUV0pcxY5mW4xKRn3v4c=
github.com/segmentio/ksuid v1.0.4/go.mod h1:/XUiZBD3kVx5SmUOl55voK5yeAbBNNIed+2O73XgrPE=
github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
@@ -0,0 +1,399 @@
package ingress
import (
"context"
"fmt"
"time"
"github.com/ngrok/ngrok-api-go/v7"
ingressv1alpha1 "github.com/ngrok/ngrok-operator/api/ingress/v1alpha1"
"github.com/ngrok/ngrok-operator/internal/controller"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/util/rand"
"sigs.k8s.io/controller-runtime/pkg/client"
)
var _ = Describe("DomainReconciler", func() {
const (
timeout = 10 * time.Second
duration = 10 * time.Second
interval = 250 * time.Millisecond
NgrokManagedDomainSuffix = "ngrok.app"
CustomDomainSuffix = "custom-domain.xyz"
)
var (
ctx context.Context
namespace string = "default"
)
BeforeEach(func() {
ctx = GinkgoT().Context()
})
AfterEach(func() {
// List all domain CRs in the env test cluster
domains := &ingressv1alpha1.DomainList{}
err := k8sClient.List(ctx, domains)
Expect(err).ToNot(HaveOccurred())
// Delete all domain CRs in the env test cluster
for _, d := range domains.Items {
Expect(k8sClient.Delete(ctx, &d)).To(Succeed())
}
// Eventually, listing all the domain CRs should return an empty list because we
// deleted all of them and the finalizer should have cleaned up the ngrok domains.
Eventually(func(g Gomega) {
domains := &ingressv1alpha1.DomainList{}
err := k8sClient.List(ctx, domains)
g.Expect(err).ToNot(HaveOccurred())
g.Expect(domains.Items).To(BeEmpty())
}, timeout, interval).Should(Succeed())
// Reset the internal state of the domain client between tests
// to ensure that each test starts with a clean slate.
domainClient.Reset()
})
Describe("CreateDomain", func() {
var (
createDomainErr error
domainSuffix string
domainName string
domain *ingressv1alpha1.Domain
)
JustBeforeEach(func() {
domain = &ingressv1alpha1.Domain{
ObjectMeta: metav1.ObjectMeta{
Name: "test-domain",
Namespace: namespace,
},
Spec: ingressv1alpha1.DomainSpec{
Domain: domainName,
},
}
createDomainErr = k8sClient.Create(ctx, domain)
})
When("the domain is a ngrok managed domain", func() {
BeforeEach(func() {
domainSuffix = NgrokManagedDomainSuffix
domainName = fmt.Sprintf("test-domain-%s.%s", rand.String(10), domainSuffix)
})
When("the domain does not exist in ngrok", func() {
It("should create the domain in ngrok", func() {
Expect(createDomainErr).ToNot(HaveOccurred())
Eventually(func(g Gomega) {
foundDomain := &ingressv1alpha1.Domain{}
err := k8sClient.Get(ctx, client.ObjectKeyFromObject(domain), foundDomain)
g.Expect(err).ToNot(HaveOccurred())
g.Expect(foundDomain.Status.ID).To(MatchRegexp("^rd"))
g.Expect(foundDomain.Status.Domain).To(Equal(domainName))
g.Expect(foundDomain.Status.CNAMETarget).To(BeNil())
}, timeout, interval).Should(Succeed())
})
})
When("the domain already exists in ngrok", func() {
var (
existingDomain *ngrok.ReservedDomain
preCreateDomainErr error
)
BeforeEach(func() {
existingDomain, preCreateDomainErr = domainClient.Create(ctx, &ngrok.ReservedDomainCreate{Domain: domainName})
Expect(preCreateDomainErr).ToNot(HaveOccurred())
})
It("should use the existing domain in ngrok", func() {
Expect(createDomainErr).ToNot(HaveOccurred())
Eventually(func(g Gomega) {
foundDomain := &ingressv1alpha1.Domain{}
err := k8sClient.Get(ctx, client.ObjectKeyFromObject(domain), foundDomain)
g.Expect(err).ToNot(HaveOccurred())
g.Expect(foundDomain.Status.ID).To(Equal(existingDomain.ID))
g.Expect(foundDomain.Status.Domain).To(Equal(domainName))
g.Expect(foundDomain.Status.CNAMETarget).To(BeNil())
}, timeout, interval).Should(Succeed())
})
})
})
When("the domain is a custom domain", func() {
BeforeEach(func() {
domainSuffix = CustomDomainSuffix
domainName = fmt.Sprintf("test-domain-%s.%s", rand.String(10), domainSuffix)
})
When("the domain does not exist in ngrok", func() {
It("should create the domain in ngrok", func() {
Expect(createDomainErr).ToNot(HaveOccurred())
Eventually(func(g Gomega) {
foundDomain := &ingressv1alpha1.Domain{}
err := k8sClient.Get(ctx, client.ObjectKeyFromObject(domain), foundDomain)
g.Expect(err).ToNot(HaveOccurred())
g.Expect(foundDomain.Status.ID).To(MatchRegexp("^rd"))
g.Expect(foundDomain.Status.Domain).To(Equal(domainName))
g.Expect(foundDomain.Status.CNAMETarget).ToNot(BeNil())
g.Expect(*foundDomain.Status.CNAMETarget).To(MatchRegexp("\\.ngrok-cname\\.com$"))
}, timeout, interval).Should(Succeed())
})
})
When("the domain already exists in ngrok", func() {
var (
existingDomain *ngrok.ReservedDomain
preCreateDomainErr error
)
BeforeEach(func() {
existingDomain, preCreateDomainErr = domainClient.Create(ctx, &ngrok.ReservedDomainCreate{Domain: domainName})
Expect(preCreateDomainErr).ToNot(HaveOccurred())
GinkgoLogr.Info("Pre-created domain", "domain", existingDomain.Domain, "id", existingDomain.ID)
})
It("should use the existing domain in ngrok", func() {
Expect(createDomainErr).ToNot(HaveOccurred())
Eventually(func(g Gomega) {
foundDomain := &ingressv1alpha1.Domain{}
err := k8sClient.Get(ctx, client.ObjectKeyFromObject(domain), foundDomain)
g.Expect(err).ToNot(HaveOccurred())
g.Expect(foundDomain.Status.ID).To(Equal(existingDomain.ID))
g.Expect(foundDomain.Status.Domain).To(Equal(domainName))
g.Expect(foundDomain.Status.CNAMETarget).ToNot(BeNil())
}, timeout, interval).Should(Succeed())
})
})
})
})
Describe("UpdateDomain", func() {
var (
domainName string
domain *ingressv1alpha1.Domain
objKey client.ObjectKey
)
BeforeEach(func() {
name := fmt.Sprintf("test-domain-%s", rand.String(10))
domainName = fmt.Sprintf("test-domain-%s.%s", rand.String(10), NgrokManagedDomainSuffix)
objKey = client.ObjectKey{
Name: name,
Namespace: namespace,
}
domain = &ingressv1alpha1.Domain{
ObjectMeta: metav1.ObjectMeta{
Name: name,
Namespace: namespace,
},
Spec: ingressv1alpha1.DomainSpec{
Domain: domainName,
},
}
domain.Spec.Metadata = "starting metadata"
domain.Spec.Description = "starting description"
Expect(k8sClient.Create(ctx, domain)).To(Succeed())
})
It("updates the domain metadata", func() {
patch := client.MergeFrom(domain.DeepCopy())
domain.Spec.Metadata = "updated metadata"
Expect(k8sClient.Patch(ctx, domain, patch)).To(Succeed())
Eventually(func(g Gomega) {
d := &ingressv1alpha1.Domain{}
err := k8sClient.Get(ctx, objKey, d)
g.Expect(err).ToNot(HaveOccurred())
g.Expect(d.Spec.Metadata).To(Equal("updated metadata"))
g.Expect(d.Status.ID).ToNot(BeEmpty())
g.Expect(d.Status.Domain).To(Equal(domainName))
}, timeout, interval).Should(Succeed())
})
It("updates the domain description", func() {
patch := client.MergeFrom(domain.DeepCopy())
domain.Spec.Description = "updated description"
Expect(k8sClient.Patch(ctx, domain, patch)).To(Succeed())
Eventually(func(g Gomega) {
d := &ingressv1alpha1.Domain{}
err := k8sClient.Get(ctx, objKey, d)
g.Expect(err).ToNot(HaveOccurred())
g.Expect(d.Spec.Description).To(Equal("updated description"))
g.Expect(d.Status.ID).ToNot(BeEmpty())
g.Expect(d.Status.Domain).To(Equal(domainName))
}, timeout, interval).Should(Succeed())
})
When("the domain was manually deleted in ngrok", func() {
var (
previousID string
)
BeforeEach(func() {
Eventually(func(g Gomega) {
d := &ingressv1alpha1.Domain{}
err := k8sClient.Get(ctx, objKey, d)
g.Expect(err).ToNot(HaveOccurred())
g.Expect(d.Status.ID).ToNot(BeEmpty())
g.Expect(d.Status.Domain).To(Equal(domainName))
previousID = d.Status.ID
g.Expect(domainClient.Delete(ctx, previousID)).To(Succeed())
})
})
It("should create a new domain in ngrok", func() {
// Simulate a manual reconcile by adding an annotation
patch := client.MergeFrom(domain.DeepCopy())
controller.AddAnnotations(domain, map[string]string{
"manual-reconcile": "true",
})
Expect(k8sClient.Patch(ctx, domain, patch)).To(Succeed())
Eventually(func(g Gomega) {
d := &ingressv1alpha1.Domain{}
err := k8sClient.Get(ctx, objKey, d)
g.Expect(err).ToNot(HaveOccurred())
g.Expect(d.Status.ID).ToNot(BeEmpty())
g.Expect(d.Status.ID).ToNot(Equal(previousID))
g.Expect(d.Status.Domain).To(Equal(domainName))
g.Expect(d.Status.CNAMETarget).To(BeNil())
}, timeout, interval).Should(Succeed())
})
})
})
Describe("DeleteDomain", func() {
var (
reclaimPolicy ingressv1alpha1.DomainReclaimPolicy
domain *ingressv1alpha1.Domain
ngrokDomainID string
deleteDomainInNgrokBeforeK8s bool
)
JustBeforeEach(func() {
By("Creating the domain")
domain = &ingressv1alpha1.Domain{
ObjectMeta: metav1.ObjectMeta{
Name: "test-domain",
Namespace: namespace,
},
Spec: ingressv1alpha1.DomainSpec{
ReclaimPolicy: reclaimPolicy,
Domain: fmt.Sprintf("test-domain-%s.%s", rand.String(10), NgrokManagedDomainSuffix),
},
}
Expect(k8sClient.Create(ctx, domain)).To(Succeed())
By("Waiting for the domain to be created in ngrok")
Eventually(func(g Gomega) {
d := &ingressv1alpha1.Domain{}
err := k8sClient.Get(ctx, client.ObjectKeyFromObject(domain), d)
g.Expect(err).ToNot(HaveOccurred())
g.Expect(d.Status.ID).ToNot(BeEmpty())
g.Expect(d.Status.Domain).To(Equal(domain.Spec.Domain))
ngrokDomainID = d.Status.ID
}, timeout, interval).Should(Succeed())
if deleteDomainInNgrokBeforeK8s {
By("Deleting the domain in ngrok")
// Simulate the domain being deleted in ngrok
Expect(domainClient.Delete(ctx, ngrokDomainID)).To(Succeed())
}
By("Deleting the domain CR in k8s")
Expect(k8sClient.Delete(ctx, domain)).To(Succeed())
By("Waiting for the domain to be deleted in kubernetes")
Eventually(func(g Gomega) {
d := &ingressv1alpha1.Domain{}
err := k8sClient.Get(ctx, client.ObjectKeyFromObject(domain), d)
g.Expect(err).To(HaveOccurred())
g.Expect(apierrors.IsNotFound(err)).To(BeTrue())
}, timeout, interval).Should(Succeed())
})
When("The domain exists in ngrok", func() {
When("The reclaim policy is set to delete", func() {
BeforeEach(func() {
reclaimPolicy = ingressv1alpha1.DomainReclaimPolicyDelete
})
It("should delete the domain in ngrok", func() {
rd, err := domainClient.Get(ctx, ngrokDomainID)
Expect(ngrok.IsNotFound(err)).To(BeTrue())
Expect(rd).To(BeNil())
})
})
When("The reclaim policy is set to retain", func() {
BeforeEach(func() {
reclaimPolicy = ingressv1alpha1.DomainReclaimPolicyRetain
})
It("should not delete the domain in ngrok", func() {
rd, err := domainClient.Get(ctx, ngrokDomainID)
Expect(err).ToNot(HaveOccurred())
Expect(rd).ToNot(BeNil())
Expect(rd.ID).To(Equal(ngrokDomainID))
})
})
})
When("The domain does not exist in ngrok", func() {
BeforeEach(func() {
deleteDomainInNgrokBeforeK8s = true
})
When("The reclaim policy is set to delete", func() {
BeforeEach(func() {
reclaimPolicy = ingressv1alpha1.DomainReclaimPolicyDelete
})
It("The domain should be deleted in ngrok", func() {
rd, err := domainClient.Get(ctx, ngrokDomainID)
Expect(ngrok.IsNotFound(err)).To(BeTrue())
Expect(rd).To(BeNil())
})
})
When("The reclaim policy is set to retain", func() {
BeforeEach(func() {
reclaimPolicy = ingressv1alpha1.DomainReclaimPolicyRetain
})
It("The domain is still missing in ngrok", func() {
iter := domainClient.List(&ngrok.Paging{})
for iter.Next(ctx) {
d := iter.Item()
if d.Domain == domain.Spec.Domain {
Fail("Domain should not exist in ngrok")
}
}
Expect(iter.Err()).To(BeNil())
})
})
})
})
})
@@ -1,19 +1,12 @@
package ingress
import (
"testing"
"github.com/ngrok/ngrok-api-go/v7"
ingressv1alpha1 "github.com/ngrok/ngrok-operator/api/ingress/v1alpha1"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestControllers(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "Controllers package Test Suite")
}
var _ = Describe("HTTPSEdgeController", func() {
DescribeTable("isMigratingAuthProviders", func(current *ngrok.HTTPSEdgeRoute, desired *ingressv1alpha1.HTTPSEdgeRouteSpec, expected bool) {
Expect(isMigratingAuthProviders(current, desired)).To(Equal(expected))
+156
View File
@@ -0,0 +1,156 @@
/*
MIT License
Copyright (c) 2025 ngrok, Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
*/
package ingress
import (
"context"
"path/filepath"
"testing"
"github.com/go-logr/logr"
"github.com/ngrok/ngrok-operator/internal/mocks/nmockapi"
"github.com/ngrok/ngrok-operator/internal/testutils"
"github.com/ngrok/ngrok-operator/pkg/managerdriver"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
ingressv1alpha1 "github.com/ngrok/ngrok-operator/api/ingress/v1alpha1"
ngrokv1alpha1 "github.com/ngrok/ngrok-operator/api/ngrok/v1alpha1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/types"
"k8s.io/client-go/kubernetes/scheme"
"k8s.io/client-go/rest"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/envtest"
logf "sigs.k8s.io/controller-runtime/pkg/log"
"sigs.k8s.io/controller-runtime/pkg/log/zap"
"sigs.k8s.io/controller-runtime/pkg/metrics/server"
gatewayv1 "sigs.k8s.io/gateway-api/apis/v1"
gatewayv1alpha2 "sigs.k8s.io/gateway-api/apis/v1alpha2"
// +kubebuilder:scaffold:imports
)
// These tests use Ginkgo (BDD-style Go testing framework). Refer to
// http://onsi.github.io/ginkgo/ to learn more about Ginkgo.
var (
cfg *rest.Config
k8sClient client.Client
testEnv *envtest.Environment
driver *managerdriver.Driver
domainClient *nmockapi.DomainClient
ctx context.Context
cancel context.CancelFunc
)
func TestControllers(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "Controller Suite")
}
var _ = BeforeSuite(func() {
logf.SetLogger(zap.New(zap.WriteTo(GinkgoWriter), zap.UseDevMode(true)))
ctx, cancel = context.WithCancel(GinkgoT().Context())
By("bootstrapping test environment")
operatorAPIs := filepath.Join("..", "..", "..", "helm", "ngrok-operator", "templates", "crds")
gwAPIs := filepath.Join(".", "testdata", "gatewayapi-crds.yaml")
testEnv = &envtest.Environment{
CRDDirectoryPaths: []string{operatorAPIs, gwAPIs},
}
var err error
// cfg is defined in this file globally.
cfg, err = testEnv.Start()
Expect(err).NotTo(HaveOccurred())
Expect(cfg).NotTo(BeNil())
schemeAdders := []func(*runtime.Scheme) error{
scheme.AddToScheme,
ngrokv1alpha1.AddToScheme,
ingressv1alpha1.AddToScheme,
gatewayv1.Install,
gatewayv1alpha2.Install,
}
for _, addFunc := range schemeAdders {
Expect(addFunc(scheme.Scheme)).NotTo(HaveOccurred())
}
// +kubebuilder:scaffold:scheme
k8sClient, err = client.New(cfg, client.Options{Scheme: scheme.Scheme})
Expect(err).NotTo(HaveOccurred())
Expect(k8sClient).NotTo(BeNil())
driver = managerdriver.NewDriver(
logr.New(logr.Discard().GetSink()),
scheme.Scheme,
testutils.DefaultControllerName,
types.NamespacedName{
Name: "test-manager-name",
Namespace: "test-manager-namespace",
},
managerdriver.WithGatewayEnabled(true),
managerdriver.WithSyncAllowConcurrent(true),
)
k8sManager, err := ctrl.NewManager(cfg, ctrl.Options{
Scheme: scheme.Scheme,
Metrics: server.Options{
// Set to 0 to disable the metrics server for tests
BindAddress: "0",
},
})
Expect(err).NotTo(HaveOccurred())
Expect(k8sManager).NotTo(BeNil())
domainClient = nmockapi.NewDomainClient()
err = (&DomainReconciler{
Client: k8sManager.GetClient(),
Log: logf.Log.WithName("controllers").WithName("Domain"),
Recorder: k8sManager.GetEventRecorderFor("domain-controller"),
Scheme: k8sManager.GetScheme(),
DomainsClient: domainClient,
}).SetupWithManager(k8sManager)
Expect(err).NotTo(HaveOccurred())
go func() {
defer GinkgoRecover()
err = k8sManager.Start(ctx)
Expect(err).NotTo(HaveOccurred())
}()
})
var _ = AfterSuite(func() {
By("tearing down the test environment")
cancel()
err := testEnv.Stop()
Expect(err).NotTo(HaveOccurred())
})
+6 -3
View File
@@ -1,7 +1,10 @@
package mocks
//go:generate go tool go.uber.org/mock/mockgen -package mocks -destination conn.go net Conn
// Note: Generate the mock files with names like mock_*.go. This is so that
// the generated files are picked up by the .gitattributes file.
//go:generate go tool go.uber.org/mock/mockgen -package mocks -destination tunnel.go golang.ngrok.com/ngrok Tunnel
//go:generate go tool go.uber.org/mock/mockgen -package mocks -destination mock_conn.go net Conn
//go:generate go tool go.uber.org/mock/mockgen -package mocks -destination dialer.go github.com/ngrok/ngrok-operator/pkg/tunneldriver Dialer
//go:generate go tool go.uber.org/mock/mockgen -package mocks -destination mock_tunnel.go golang.ngrok.com/ngrok Tunnel
//go:generate go tool go.uber.org/mock/mockgen -package mocks -destination mock_dialer.go github.com/ngrok/ngrok-operator/pkg/tunneldriver Dialer
+1 -1
View File
@@ -3,7 +3,7 @@
//
// Generated by this command:
//
// mockgen -package mocks -destination conn.go net Conn
// mockgen -package mocks -destination mock_conn.go net Conn
//
// Package mocks is a generated GoMock package.
+1 -1
View File
@@ -3,7 +3,7 @@
//
// Generated by this command:
//
// mockgen -package mocks -destination dialer.go github.com/ngrok/ngrok-operator/pkg/tunneldriver Dialer
// mockgen -package mocks -destination mock_dialer.go github.com/ngrok/ngrok-operator/pkg/tunneldriver Dialer
//
// Package mocks is a generated GoMock package.
+1 -1
View File
@@ -3,7 +3,7 @@
//
// Generated by this command:
//
// mockgen -package mocks -destination tunnel.go golang.ngrok.com/ngrok Tunnel
// mockgen -package mocks -destination mock_tunnel.go golang.ngrok.com/ngrok Tunnel
//
// Package mocks is a generated GoMock package.
+76
View File
@@ -0,0 +1,76 @@
package nmockapi
import (
context "context"
"fmt"
"maps"
"net/http"
"slices"
"time"
"github.com/ngrok/ngrok-api-go/v7"
"github.com/segmentio/ksuid"
)
type baseClient[T any] struct {
idPrefix string
items map[string]T
}
func newBase[T any](idPrefix string) baseClient[T] {
return baseClient[T]{
items: make(map[string]T),
idPrefix: idPrefix,
}
}
func (m *baseClient[T]) Get(_ context.Context, id string) (T, error) {
item, ok := m.items[id]
if !ok {
return *new(T), m.notFoundErr()
}
return item, nil
}
func (m *baseClient[T]) List(_ *ngrok.Paging) ngrok.Iter[T] {
items := slices.Collect(maps.Values(m.items))
return NewIter(items, nil)
}
func (m *baseClient[T]) Delete(ctx context.Context, id string) error {
_, err := m.Get(ctx, id)
if err != nil {
return err
}
delete(m.items, id)
return nil
}
// Reset clears the items in the client.
// This is useful for resetting the state of the client between tests, without allocating a new client.
func (m *baseClient[T]) Reset() {
m.items = make(map[string]T)
}
func (m *baseClient[T]) newID() string {
return fmt.Sprintf("%s_%s", m.idPrefix, ksuid.New().String())
}
func (m *baseClient[T]) notFoundErr() error {
return &ngrok.Error{
StatusCode: http.StatusNotFound,
}
}
func (m *baseClient[T]) any(predicate func(T) bool) bool {
for _, item := range m.items {
if predicate(item) {
return true
}
}
return false
}
func (m *baseClient[T]) createdAt() string {
return time.Now().Format(time.RFC3339)
}
+105
View File
@@ -0,0 +1,105 @@
package nmockapi
import (
context "context"
"fmt"
"net/http"
"slices"
"strings"
"github.com/ngrok/ngrok-api-go/v7"
"k8s.io/apimachinery/pkg/util/rand"
)
// DomainClient is a mock implementation of the ngrok API client for managing reserved domains. It
// tries to mimic the behavior of the actual ngrok API client, but it is not a complete
// implementation. It is used for testing purposes only and should not be used in production
// environments.
type DomainClient struct {
baseClient[*ngrok.ReservedDomain]
}
func NewDomainClient() *DomainClient {
return &DomainClient{
baseClient: newBase[*ngrok.ReservedDomain](
"rd",
),
}
}
func (m *DomainClient) Create(_ context.Context, item *ngrok.ReservedDomainCreate) (*ngrok.ReservedDomain, error) {
if m.any(func(rd *ngrok.ReservedDomain) bool { return rd.Domain == item.Domain }) {
return nil, &ngrok.Error{
StatusCode: http.StatusConflict,
Msg: fmt.Sprintf("Domain %s already exists", item.Domain),
ErrorCode: "ERR_NGROK_413",
}
}
id := m.newID()
newDomain := &ngrok.ReservedDomain{
ID: id,
CreatedAt: m.createdAt(),
Domain: item.Domain,
Region: item.Region,
URI: fmt.Sprintf("https://mock-api.ngrok.com/reserved_domains/%s", id),
}
if !isNgrokManagedDomain(newDomain) {
cname := fmt.Sprintf("%s.%s.ngrok-cname.com", rand.String(17), rand.String(17))
newDomain.CNAMETarget = &cname
}
m.items[id] = newDomain
return newDomain, nil
}
func (m *DomainClient) Update(ctx context.Context, item *ngrok.ReservedDomainUpdate) (*ngrok.ReservedDomain, error) {
existingItem, err := m.Get(ctx, item.ID)
if err != nil {
return nil, err
}
if item.Description != nil {
existingItem.Description = *item.Description
}
if item.Metadata != nil {
existingItem.Metadata = *item.Metadata
}
if item.CertificateID != nil {
existingItem.Certificate = &ngrok.Ref{
ID: *item.CertificateID,
URI: fmt.Sprintf("https://mock-api.ngrok.com/certificates/%s", *item.CertificateID),
}
}
if item.CertificateManagementPolicy != nil {
existingItem.CertificateManagementPolicy = item.CertificateManagementPolicy
}
m.items[item.ID] = existingItem
return existingItem, nil
}
var (
ngrokManagedDomainSuffixes = []string{
"ngrok.app",
"ngrok.dev",
"ngrok.pizza",
"ngrok-free.app",
"ngrok-free.dev",
"ngrok-free.pizza",
"ngrok.io",
}
)
func isNgrokManagedDomain(domain *ngrok.ReservedDomain) bool {
if domain == nil {
return false
}
return slices.ContainsFunc(ngrokManagedDomainSuffixes, func(suffix string) bool {
return strings.HasSuffix(domain.Domain, suffix)
})
}
@@ -0,0 +1,309 @@
package nmockapi_test
import (
context "context"
"fmt"
"slices"
"github.com/ngrok/ngrok-api-go/v7"
"github.com/ngrok/ngrok-operator/internal/mocks/nmockapi"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"k8s.io/apimachinery/pkg/util/rand"
)
var _ = Describe("DomainClient", func() {
const (
NgrokManagedDomainSuffix = "ngrok.app"
CustomDomainSuffix = "custom-domain.xyz"
)
var (
domainClient *nmockapi.DomainClient
ctx context.Context
)
BeforeEach(func() {
domainClient = nmockapi.NewDomainClient()
ctx = GinkgoT().Context()
})
Describe("Get()", func() {
var (
id string
domain *ngrok.ReservedDomain
err error
)
JustBeforeEach(func() {
domain, err = domainClient.Get(ctx, id)
})
When("the domain exists", func() {
BeforeEach(func() {
domain, err := domainClient.Create(ctx, &ngrok.ReservedDomainCreate{
Domain: "test-domain.ngrok.io",
})
Expect(err).NotTo(HaveOccurred())
id = domain.ID
})
It("should return the domain", func() {
Expect(err).To(BeNil())
Expect(domain.Domain).To(Equal("test-domain.ngrok.io"))
Expect(domain.ID).To(MatchRegexp("^rd_"))
})
})
When("the domain does not exist", func() {
BeforeEach(func() {
id = "non-existing-id"
})
It("should return an ngrok not found error", func() {
Expect(err).To(HaveOccurred())
Expect(ngrok.IsNotFound(err)).To(BeTrue())
})
})
})
Describe("List()", func() {
var (
domains []*ngrok.ReservedDomain
err error
)
JustBeforeEach(func() {
iter := domainClient.List(nil)
domains = make([]*ngrok.ReservedDomain, 0)
for iter.Next(ctx) {
domains = append(domains, iter.Item())
}
err = iter.Err()
})
When("there are no domains", func() {
It("the iterator should return an empty list", func() {
Expect(err).ToNot(HaveOccurred())
Expect(domains).To(BeEmpty())
})
})
When("there are domains", func() {
BeforeEach(func() {
_, createDomain1Err := domainClient.Create(ctx, &ngrok.ReservedDomainCreate{
Domain: "test-domain-1.ngrok.io",
})
Expect(createDomain1Err).ToNot(HaveOccurred())
_, createDomain2Err := domainClient.Create(ctx, &ngrok.ReservedDomainCreate{
Domain: "test-domain-2.ngrok.io",
})
Expect(createDomain2Err).ToNot(HaveOccurred())
})
It("the iterator should return the domains", func() {
Expect(err).ToNot(HaveOccurred())
Expect(domains).To(HaveLen(2))
Expect(slices.ContainsFunc(domains, func(domain *ngrok.ReservedDomain) bool {
return domain.Domain == "test-domain-1.ngrok.io"
})).To(BeTrue())
Expect(slices.ContainsFunc(domains, func(domain *ngrok.ReservedDomain) bool {
return domain.Domain == "test-domain-2.ngrok.io"
})).To(BeTrue())
})
})
})
Describe("Create()", func() {
var (
domain *ngrok.ReservedDomain
err error
domainSuffix string
domainName string
)
JustBeforeEach(func() {
domain, err = domainClient.Create(ctx, &ngrok.ReservedDomainCreate{
Domain: domainName,
})
})
When("the domain is a ngrok managed domain", func() {
BeforeEach(func() {
domainSuffix = NgrokManagedDomainSuffix
domainName = fmt.Sprintf("test-domain-%s.%s", rand.String(10), domainSuffix)
})
It("should create and return the domain", func() {
Expect(err).To(BeNil())
Expect(domain.Domain).To(Equal(domainName))
})
It("should create the domain with an ID", func() {
Expect(domain.ID).ToNot(BeEmpty())
Expect(domain.ID).To(MatchRegexp("^rd_"))
})
It("should create the domain with a timestamp", func() {
Expect(domain.CreatedAt).ToNot(BeEmpty())
})
It("should not create the domain with a CNAMETarget", func() {
Expect(domain.CNAMETarget).To(BeNil())
})
When("the domain is already taken", func() {
BeforeEach(func() {
_, err := domainClient.Create(ctx, &ngrok.ReservedDomainCreate{Domain: domainName})
Expect(err).ToNot(HaveOccurred())
})
It("should return an ngrok already exists error", func() {
Expect(err).To(HaveOccurred())
Expect(ngrok.IsErrorCode(err, 413)).To(BeTrue())
})
})
})
When("the domain is a custom domain", func() {
BeforeEach(func() {
domainSuffix = CustomDomainSuffix
domainName = fmt.Sprintf("test-domain-%s.%s", rand.String(10), domainSuffix)
})
It("should create and return the domain", func() {
Expect(err).To(BeNil())
Expect(domain.Domain).To(Equal(domainName))
})
It("should create the domain with an ID", func() {
Expect(domain.ID).ToNot(BeEmpty())
Expect(domain.ID).To(MatchRegexp("^rd_"))
})
It("should create the domain with a timestamp", func() {
Expect(domain.CreatedAt).ToNot(BeEmpty())
})
It("should create the domain with a CNAMETarget", func() {
Expect(domain.CNAMETarget).ToNot(BeNil())
Expect(*domain.CNAMETarget).To(MatchRegexp("^[a-zA-Z0-9]{17}\\.[a-zA-Z0-9]{17}\\.ngrok-cname\\.com$"))
})
When("the domain is already taken", func() {
BeforeEach(func() {
_, err := domainClient.Create(ctx, &ngrok.ReservedDomainCreate{
Domain: domainName,
})
Expect(err).ToNot(HaveOccurred())
})
It("should return an ngrok already exists error", func() {
Expect(err).To(HaveOccurred())
Expect(ngrok.IsErrorCode(err, 413)).To(BeTrue())
})
})
})
})
Describe("Delete()", func() {
var (
id string
deleteErr error
domain *ngrok.ReservedDomain
getErr error
)
JustBeforeEach(func() {
deleteErr = domainClient.Delete(ctx, id)
domain, getErr = domainClient.Get(ctx, id)
})
When("the domain exists", func() {
BeforeEach(func() {
domain, err := domainClient.Create(ctx, &ngrok.ReservedDomainCreate{
Domain: "test-domain-4.ngrok.io",
})
Expect(err).ToNot(HaveOccurred())
id = domain.ID
})
It("should delete the domain", func() {
Expect(deleteErr).ToNot(HaveOccurred())
Expect(getErr).To(HaveOccurred())
Expect(ngrok.IsNotFound(getErr)).To(BeTrue())
Expect(domain).To(BeNil())
})
})
When("the domain does not exist", func() {
BeforeEach(func() {
id = "non-existing-id"
})
It("Should return an ngrok not found error", func() {
Expect(deleteErr).To(HaveOccurred())
Expect(ngrok.IsNotFound(deleteErr)).To(BeTrue())
})
})
})
Describe("Update()", func() {
var (
createdDomain *ngrok.ReservedDomain
createErr error
domainUpdate *ngrok.ReservedDomainUpdate
updatedDomain *ngrok.ReservedDomain
updateErr error
)
BeforeEach(func() {
createdDomain, createErr = domainClient.Create(ctx, &ngrok.ReservedDomainCreate{
Domain: "test-domain-5.ngrok.io",
})
Expect(createErr).ToNot(HaveOccurred())
})
JustBeforeEach(func() {
updatedDomain, updateErr = domainClient.Update(ctx, domainUpdate)
})
When("the domain exists", func() {
BeforeEach(func() {
domainUpdate = &ngrok.ReservedDomainUpdate{
ID: createdDomain.ID,
Metadata: ngrok.String("new-metadata"),
Description: ngrok.String("new-description"),
}
})
It("should update the domain", func() {
Expect(updateErr).ToNot(HaveOccurred())
Expect(updatedDomain).ToNot(BeNil())
Expect(updatedDomain.ID).To(Equal(createdDomain.ID))
Expect(updatedDomain.Metadata).To(Equal("new-metadata"))
Expect(updatedDomain.Description).To(Equal("new-description"))
})
})
When("the domain does not exist", func() {
BeforeEach(func() {
domainUpdate = &ngrok.ReservedDomainUpdate{
ID: "non-existing-id",
Metadata: ngrok.String("new-metadata"),
Description: ngrok.String("new-description"),
}
})
It("should return a not found error", func() {
Expect(updateErr).To(HaveOccurred())
Expect(ngrok.IsNotFound(updateErr)).To(BeTrue())
})
})
})
})
+41
View File
@@ -0,0 +1,41 @@
package nmockapi
import context "context"
// Iter is a mock iterator that implements the ngrok.Iter[T] interface.
type Iter[T any] struct {
items []T
err error
n int
}
func (m *Iter[T]) Next(_ context.Context) bool {
// If there is an error, stop iteration
if m.err != nil {
return false
}
// Increment the index
m.n++
return m.n < len(m.items) && m.n >= 0
}
func (m *Iter[T]) Item() T {
if m.n >= 0 && m.n < len(m.items) {
return m.items[m.n]
}
return *new(T)
}
func (m *Iter[T]) Err() error {
return m.err
}
func NewIter[T any](items []T, err error) *Iter[T] {
return &Iter[T]{
items: items,
err: err,
n: -1,
}
}
+134
View File
@@ -0,0 +1,134 @@
package nmockapi_test
import (
context "context"
"fmt"
"github.com/ngrok/ngrok-api-go/v7"
"github.com/ngrok/ngrok-operator/internal/mocks/nmockapi"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Iter", func() {
var (
iter ngrok.Iter[string]
iterErr error
items []string
ctx context.Context
)
BeforeEach(func() {
ctx = GinkgoT().Context()
})
JustBeforeEach(func() {
iter = nmockapi.NewIter(items, iterErr)
})
Describe("Item()", func() {
Context("when there is an error", func() {
BeforeEach(func() {
iterErr = fmt.Errorf("ut-oh")
})
It("should return an empty item", func() {
Expect(iter.Item()).To(Equal(""))
})
})
Context("when there is no error", func() {
BeforeEach(func() {
iterErr = nil
items = []string{"a", "b", "c"}
})
It("should return the current item", func() {
iter.Next(ctx)
Expect(iter.Item()).To(Equal("a"))
iter.Next(ctx)
Expect(iter.Item()).To(Equal("b"))
iter.Next(ctx)
Expect(iter.Item()).To(Equal("c"))
})
Context("when called before Next", func() {
It("should return an empty item", func() {
Expect(iter.Item()).To(Equal(""))
})
})
Context("when called after Next returns false", func() {
It("should return an empty item", func() {
for iter.Next(ctx) {
// Iterate until there are no more items
}
Expect(iter.Item()).To(Equal(""))
})
})
})
})
Describe("Next", func() {
Context("when there is an error", func() {
BeforeEach(func() {
iterErr = fmt.Errorf("ut-oh")
})
It("should return false", func() {
Expect(iter.Next(ctx)).To(BeFalse())
})
})
Context("when there is no error", func() {
BeforeEach(func() {
iterErr = nil
})
Context("when there are no items", func() {
BeforeEach(func() {
iter = nmockapi.NewIter([]string{}, nil)
})
It("should return false", func() {
})
})
BeforeEach(func() {
iter = nmockapi.NewIter([]string{"a", "b", "c"}, nil)
})
It("should return true while there are more values", func() {
Expect(iter.Next(ctx)).To(BeTrue())
Expect(iter.Next(ctx)).To(BeTrue())
Expect(iter.Next(ctx)).To(BeTrue())
Expect(iter.Next(ctx)).To(BeFalse())
})
})
})
Describe("Err()", func() {
Context("when there is an error", func() {
BeforeEach(func() {
iterErr = fmt.Errorf("ut-oh")
})
It("should return the error", func() {
Expect(iter.Err()).To(HaveOccurred())
})
})
Context("when there is no error", func() {
BeforeEach(func() {
iterErr = nil
})
It("should return nil", func() {
Expect(iter.Err()).ToNot(HaveOccurred())
})
})
})
})
+13
View File
@@ -0,0 +1,13 @@
package nmockapi_test
import (
"testing"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestNmockAPI(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "nmockapi Suite")
}