From 34f8c280080dfbd80030752ba1ba036aead34662 Mon Sep 17 00:00:00 2001 From: Lukas von Allmen Date: Wed, 9 Sep 2026 15:23:31 +0200 Subject: [PATCH 1/4] Extends the router client with the missing update functionality --- router.go | 10 +- router_test.go | 20 + test/integration/cloudscale_test.go | 2 +- test/integration/helper_test.go | 2 + test/integration/routers_integration_test.go | 374 +++++++++++++++---- 5 files changed, 330 insertions(+), 78 deletions(-) diff --git a/router.go b/router.go index 9adbfdf..7824fa3 100644 --- a/router.go +++ b/router.go @@ -43,14 +43,18 @@ type RouterCreateRequest struct { InternetGateway bool `json:"internet_gateway"` } -// RouterUpdateRequest is not implemented yet because the API is not implemented yet -type RouterUpdateRequest struct{} +type RouterUpdateRequest struct { + ZonalResourceRequest + TaggedResourceRequest + Name string `json:"name,omitempty"` + InternetGateway bool `json:"internet_gateway,omitempty"` +} type RouterService interface { GenericCreateService[Router, RouterCreateRequest] GenericGetService[Router] GenericListService[Router] - // GenericUpdateService[Router, RouterUpdateRequest] + GenericUpdateService[Router, RouterUpdateRequest] GenericDeleteService[Router] GenericWaitForService[Router] // CreateInterface creates a new interface attached to this router diff --git a/router_test.go b/router_test.go index bd92eb3..0532560 100644 --- a/router_test.go +++ b/router_test.go @@ -1,6 +1,7 @@ package cloudscale import ( + "context" "encoding/json" "fmt" "net/http" @@ -48,6 +49,25 @@ func TestRouters_Create(t *testing.T) { } } +func TestRouters_Update(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/v1/routers/cfde831a-4e87-4a75-960f-89b0148aa2cc", func(w http.ResponseWriter, r *http.Request) { + testHTTPMethod(t, r, http.MethodPatch) + }) + + routerID := "cfde831a-4e87-4a75-960f-89b0148aa2cc" + + req := &RouterUpdateRequest{ + Name: "new-router-name", + } + err := client.Routers.Update(context.TODO(), routerID, req) + if err != nil { + t.Errorf("ObjectsUser.Update returned error: %v", err) + } +} + func TestRouters_Get(t *testing.T) { setup() defer teardown() diff --git a/test/integration/cloudscale_test.go b/test/integration/cloudscale_test.go index 48b4c60..f5a01eb 100644 --- a/test/integration/cloudscale_test.go +++ b/test/integration/cloudscale_test.go @@ -260,7 +260,7 @@ func DeleteRemainingRouters() bool { } err = client.Routers.Delete(context.Background(), router.UUID) if err != nil { - log.Fatalf("Routers.Delete returned error %s\n", err) + log.Fatalf("Routers.Delete returned error: %s\n", err) } } } diff --git a/test/integration/helper_test.go b/test/integration/helper_test.go index a0f6e04..521520e 100644 --- a/test/integration/helper_test.go +++ b/test/integration/helper_test.go @@ -52,6 +52,8 @@ func assertEqual(t *testing.T, expected any, actual any) { } // waitForDeleted calls existsFunc in a backoff loop until exists is false. +// +//lint:ignore U1000 - Ignore unused function, since it might become handy again func waitForDeleted(ctx context.Context, existsFunc func() (exists bool, err error)) error { options := []backoff.RetryOption{ backoff.WithBackOff(backoff.NewConstantBackOff(2 * time.Second)), diff --git a/test/integration/routers_integration_test.go b/test/integration/routers_integration_test.go index de6dce9..345a0a4 100644 --- a/test/integration/routers_integration_test.go +++ b/test/integration/routers_integration_test.go @@ -3,7 +3,9 @@ package integration import ( + "context" "errors" + "fmt" "net/http" "testing" "time" @@ -11,51 +13,94 @@ import ( "github.com/cloudscale-ch/cloudscale-go-sdk/v10" ) -func TestIntegrationRouter_CR_D(t *testing.T) { - t.Parallel() +func checkIfRouterIsDeleted(ctx context.Context, router *cloudscale.Router) error { + r, err := client.Routers.Get(ctx, router.UUID) + if err != nil { + if cerr, ok := errors.AsType[*cloudscale.ErrorResponse](err); ok && cerr.StatusCode == http.StatusNotFound { + // The router cannot be found, which means it was successfully deleted + return nil + } + // A different API error code was returned + return err + } + + return fmt.Errorf("router %q still exists with status %q", r.UUID, r.Status) +} + +func checkIfRouterInterfaceIsDeleted(ctx context.Context, router *cloudscale.Router, routerInterface *cloudscale.RouterInterface) error { + r, err := client.Routers.Get(ctx, router.UUID) + if err != nil { + return err + } + for _, i := range r.Interfaces { + if i.UUID == routerInterface.UUID { + return fmt.Errorf("interface %q still attached to router %q", routerInterface.UUID, router.UUID) + } + } + // The router interface is not in the list, which means it was successfully detached + return nil +} + +// newTestRouter creates a router for use in a single test and registers a +// cleanup that deletes it (and waits for the deletion to complete) once the +// test finishes. +func newTestRouter(t *testing.T) *cloudscale.Router { + t.Helper() createRouterRequest := &cloudscale.RouterCreateRequest{ - Name: testRunPrefix, - InternetGateway: true, + Name: "offline-router", + InternetGateway: false, ZonalResourceRequest: cloudscale.ZonalResourceRequest{Zone: testZone}, } - expected, err := client.Routers.Create(t.Context(), createRouterRequest) + router, err := client.Routers.Create(t.Context(), createRouterRequest) if err != nil { t.Fatalf("Routers.Create returned error %s", err) } - router, err := client.Routers.Get(t.Context(), expected.UUID) - if err != nil { - t.Fatalf("Routers.Get returned error %s", err) - } + t.Cleanup(func() { + router, err := client.Routers.Get(context.Background(), router.UUID) + if err != nil { + t.Errorf("Routers.Get returned error: %v", err) + return + } - if uuid := router.UUID; uuid != expected.UUID { - t.Errorf("Router.UUID got=%s\nwant=%s", uuid, expected.UUID) - } + for _, routerInterface := range router.Interfaces { + err = client.Routers.DeleteInterface(context.Background(), router.UUID, routerInterface.UUID) + if err != nil { + t.Errorf("Routers.DeleteInterface returned error: %v", err) + return + } - if h := time.Since(router.CreatedAt).Hours(); !(-1 < h && h < 1) { - t.Errorf("router.CreatedAt outside of expected range. got=%v", router.CreatedAt) - } + err = checkIfRouterInterfaceIsDeleted(context.Background(), router, &routerInterface) + if err != nil { + t.Errorf("check if router interface is deleted failed: %v", err) + return + } + } - if !router.InternetGateway { - t.Errorf("router.InternetGateway got=%v\nwant=%v", router.InternetGateway, true) - } + err = client.Routers.Delete(context.Background(), router.UUID) + if err != nil { + t.Errorf("Routers.Delete returned error: %s", err) + return + } - if _, err := client.Routers.WaitFor(t.Context(), router.UUID, cloudscale.RouterIsActive); err != nil { - t.Errorf("router not in active state: %v", err) - } + err = checkIfRouterIsDeleted(context.Background(), router) + if err != nil { + t.Errorf("check if router is deleted failed: %v", err) + return + } + }) - routers, err := client.Routers.List(t.Context()) - if err != nil { - t.Fatalf("Routers.List returned error %s\n", err) - } + return router +} - if numRouters := len(routers); numRouters < 1 { - t.Errorf("Routers.List got=%d\nwant>=%d\n", numRouters, 1) - } +// newTestNetworkWithSubnet creates a network and a subnet within it for use +// in a single test and registers a cleanup that deletes the network once the +// test finishes. +func newTestNetworkWithSubnet(t *testing.T) (*cloudscale.Network, *cloudscale.Subnet) { + t.Helper() - // Set up a network with a subnet so we can attach an interface to the router. createNetworkRequest := &cloudscale.NetworkCreateRequest{ Name: testRunPrefix, AutoCreateIPV4Subnet: new(false), @@ -63,17 +108,186 @@ func TestIntegrationRouter_CR_D(t *testing.T) { } network, err := client.Networks.Create(t.Context(), createNetworkRequest) if err != nil { - t.Fatalf("Networks.Create returned error %s", err) + t.Fatalf("Networks.Create returned error: %s", err) } + t.Cleanup(func() { + if err := client.Networks.Delete(context.Background(), network.UUID); err != nil { + t.Errorf("Networks.Delete returned error: %s", err) + } + }) + createSubnetRequest := &cloudscale.SubnetCreateRequest{ Network: network.UUID, CIDR: "192.168.99.0/24", } subnet, err := client.Subnets.Create(t.Context(), createSubnetRequest) if err != nil { - t.Fatalf("Subnets.Create returned error %s", err) + t.Fatalf("Subnets.Create returned error: %s", err) + } + + return network, subnet +} + +func TestIntegrationRouter_Create(t *testing.T) { + t.Parallel() + + createRouterRequest := &cloudscale.RouterCreateRequest{ + Name: "offline-router", + InternetGateway: false, + ZonalResourceRequest: cloudscale.ZonalResourceRequest{Zone: testZone}, + } + + router, err := client.Routers.Create(t.Context(), createRouterRequest) + if err != nil { + t.Fatalf("Routers.Create returned error: %s", err) + } + t.Cleanup(func() { + err := client.Routers.Delete(context.Background(), router.UUID) + if err != nil { + t.Errorf("Routers.Delete returned error: %s", err) + return + } + + err = checkIfRouterIsDeleted(context.Background(), router) + if err != nil { + t.Errorf("check if router is deleted failed: %v", err) + } + }) + + if router.Name != createRouterRequest.Name { + t.Errorf("Router.Name got=%s\nwant=%s", router.Name, createRouterRequest.Name) + } + + if router.InternetGateway != createRouterRequest.InternetGateway { + t.Errorf("Router.InternetGateway got=%t\nwant=%t", router.InternetGateway, createRouterRequest.InternetGateway) + } + + // The number of InternetGatewayAddresses should be 0, since InternetGateway is initially set to false + if numInternetGatewayAddresses := len(router.InternetGatewayAddresses); numInternetGatewayAddresses != 0 { + t.Errorf("Number of InternetGatewayAddresses got=%d\nwant=%d", numInternetGatewayAddresses, 0) + } +} + +func TestIntegrationRouter_Update_Name(t *testing.T) { + t.Parallel() + + router := newTestRouter(t) + + updateNameRouterRequest := &cloudscale.RouterUpdateRequest{ + Name: "router.example.com", + } + + err := client.Routers.Update(t.Context(), router.UUID, updateNameRouterRequest) + if err != nil { + t.Fatalf("Routers.Update returned error: %s", err) + } + + updated, err := client.Routers.Get(t.Context(), router.UUID) + if err != nil { + t.Fatalf("Routers.Get returned error: %s", err) + } + + if updateNameRouterRequest.Name != updated.Name { + t.Errorf("router.Name got=%s\nwant=%s", updated.Name, updateNameRouterRequest.Name) + } +} + +func TestIntegrationRouter_Update_InternetGateway(t *testing.T) { + t.Parallel() + + router := newTestRouter(t) + + // Give the router a valid FQDN name first, so we can assert on the + // reverse pointer of the resulting InternetGatewayAddresses. + updateNameRouterRequest := &cloudscale.RouterUpdateRequest{ + Name: "router.example.com", + } + if err := client.Routers.Update(t.Context(), router.UUID, updateNameRouterRequest); err != nil { + t.Fatalf("Routers.Update returned error: %s", err) + } + + updateInternetGatewayRouterRequest := &cloudscale.RouterUpdateRequest{ + InternetGateway: true, + } + + err := client.Routers.Update(t.Context(), router.UUID, updateInternetGatewayRouterRequest) + if err != nil { + t.Fatalf("Routers.Update returned error: %s", err) + } + + updated, err := client.Routers.Get(t.Context(), router.UUID) + if err != nil { + t.Fatalf("Routers.Get returned error: %s", err) + } + + if updateInternetGatewayRouterRequest.InternetGateway != updated.InternetGateway { + t.Errorf("router.InternetGateway got=%t\nwant=%t", updated.InternetGateway, updateInternetGatewayRouterRequest.InternetGateway) + } + + // The number of InternetGatewayAddresses should be 1, since InternetGateway was updated to true. + // This assertion must be updated after the API supports IPv6 for private networks. + if numInternetGatewayAddresses := len(updated.InternetGatewayAddresses); numInternetGatewayAddresses != 1 { + t.Errorf("Number of InternetGatewayAddresses got=%d\nwant=%d", numInternetGatewayAddresses, 1) + } + + // If InternetGateway is true and the Name is a valid FQDN, all InternetGatewayAddresses should have this FQDN as a reverse pointer. + for index, address := range updated.InternetGatewayAddresses { + if reversePTR := *address.ReversePTR; reversePTR != updateNameRouterRequest.Name { + t.Errorf("Router.InternetGatewayAddresses[%d].ReversePTR got=%s\nwant=%s", index, reversePTR, updateNameRouterRequest.Name) + } + } +} + +func TestIntegrationRouter_Get(t *testing.T) { + t.Parallel() + + router := newTestRouter(t) + + got, err := client.Routers.Get(t.Context(), router.UUID) + if err != nil { + t.Fatalf("Routers.Get returned error: %s", err) + } + + if uuid := got.UUID; uuid != router.UUID { + t.Errorf("Router.UUID got=%s\nwant=%s", uuid, router.UUID) + } + + if h := time.Since(got.CreatedAt).Hours(); !(-1 < h && h < 1) { + t.Errorf("router.CreatedAt outside of expected range. got=%v", got.CreatedAt) + } +} + +func TestIntegrationRouter_List(t *testing.T) { + t.Parallel() + + _ = newTestRouter(t) + + routers, err := client.Routers.List(t.Context()) + if err != nil { + t.Fatalf("Routers.List returned error: %s\n", err) + } + + if numRouters := len(routers); numRouters < 1 { + t.Errorf("Routers.List got=%d\nwant>=%d\n", numRouters, 1) + } +} + +func TestIntegrationRouter_WaitFor(t *testing.T) { + t.Parallel() + + router := newTestRouter(t) + + if _, err := client.Routers.WaitFor(t.Context(), router.UUID, cloudscale.RouterIsActive); err != nil { + t.Errorf("router not in active state: %v", err) } +} + +func TestIntegrationRouter_AttachInterface(t *testing.T) { + t.Parallel() + + router := newTestRouter(t) + network, subnet := newTestNetworkWithSubnet(t) createInterfaceRequest := cloudscale.CreateInterfaceRequest{ Network: network.UUID, @@ -84,75 +298,87 @@ func TestIntegrationRouter_CR_D(t *testing.T) { }, }, } - iface, err := client.Routers.CreateInterface(t.Context(), router.UUID, createInterfaceRequest) + routerInterface, err := client.Routers.CreateInterface(t.Context(), router.UUID, createInterfaceRequest) if err != nil { - t.Fatalf("Routers.CreateInterface returned error %s", err) + t.Fatalf("Routers.CreateInterface returned error: %s", err) } + t.Cleanup(func() { + if err := client.Routers.DeleteInterface(context.Background(), router.UUID, routerInterface.UUID); err != nil { + t.Errorf("Routers.DeleteInterface returned error: %s", err) + } + }) - if iface.UUID == "" { + if routerInterface.UUID == "" { t.Error("Routers.CreateInterface returned interface without UUID") } - if networkUUID := iface.Network.UUID; networkUUID != network.UUID { + if networkUUID := routerInterface.Network.UUID; networkUUID != network.UUID { t.Errorf("interface.Network.UUID got=%s\nwant=%s", networkUUID, network.UUID) } - if numAddresses := len(iface.Addresses); numAddresses != 1 { + if numAddresses := len(routerInterface.Addresses); numAddresses != 1 { t.Fatalf("interface Addresses got=%d\nwant=%d", numAddresses, 1) } - if subnetUUID := iface.Addresses[0].Subnet.UUID; subnetUUID != subnet.UUID { + if subnetUUID := routerInterface.Addresses[0].Subnet.UUID; subnetUUID != subnet.UUID { t.Errorf("interface.Addresses[0].Subnet.UUID got=%s\nwant=%s", subnetUUID, subnet.UUID) } - if addr := iface.Addresses[0].Address; addr != "192.168.99.10" { + if addr := routerInterface.Addresses[0].Address; addr != "192.168.99.10" { t.Errorf("interface.Addresses[0].Address got=%s\nwant=%s", addr, "192.168.99.10") } +} + +func TestIntegrationRouter_DetachInterface(t *testing.T) { + t.Parallel() + + router := newTestRouter(t) + network, subnet := newTestNetworkWithSubnet(t) - // Clean up: delete interface - if err := client.Routers.DeleteInterface(t.Context(), router.UUID, iface.UUID); err != nil { + createInterfaceRequest := cloudscale.CreateInterfaceRequest{ + Network: network.UUID, + Addresses: []cloudscale.CreateAddressRequest{ + { + Subnet: subnet.UUID, + Address: "192.168.99.10", + }, + }, + } + routerInterface, err := client.Routers.CreateInterface(t.Context(), router.UUID, createInterfaceRequest) + if err != nil { + t.Fatalf("Routers.CreateInterface returned error: %s", err) + } + + if err := client.Routers.DeleteInterface(t.Context(), router.UUID, routerInterface.UUID); err != nil { t.Errorf("Routers.DeleteInterface returned error: %s", err) } - // Verify the interface is actually gone before deleting the router: the router's - // Interfaces list must no longer contain it. - err = waitForDeleted(t.Context(), func() (exists bool, err error) { - r, err := client.Routers.Get(t.Context(), router.UUID) - if err != nil { - return true, err - } - for _, i := range r.Interfaces { - if i.UUID == iface.UUID { - t.Logf("interface %q still attached to router %q", iface.UUID, router.UUID) - return true, nil - } - } - return false, nil - }) + // Verify the interface is actually gone: the router's Interfaces list + // must no longer contain it. + err = checkIfRouterInterfaceIsDeleted(t.Context(), router, routerInterface) if err != nil { - t.Errorf("waiting for interface delete failed: %v", err) + t.Errorf("check if router interface is deleted failed: %v", err) } +} - // Clean up: delete router - err = client.Routers.Delete(t.Context(), router.UUID) +func TestIntegrationRouter_Delete(t *testing.T) { + t.Parallel() + + // Cannot use newTestRouter here, since it registers a cleanup function which also tries to delete the router + createRouterRequest := &cloudscale.RouterCreateRequest{ + Name: "offline-router", + InternetGateway: false, + ZonalResourceRequest: cloudscale.ZonalResourceRequest{Zone: testZone}, + } + + router, err := client.Routers.Create(t.Context(), createRouterRequest) if err != nil { - t.Fatalf("Routers.Delete returned error %s", err) + t.Fatalf("Routers.Create returned error: %s", err) } - err = waitForDeleted(t.Context(), func() (exists bool, err error) { - r, err := client.Routers.Get(t.Context(), router.UUID) - if err != nil { - if cerr, ok := errors.AsType[*cloudscale.ErrorResponse](err); ok && cerr.StatusCode == http.StatusNotFound { - return false, nil - } - return true, err - } - t.Logf("router %q still exists with status %q", r.UUID, r.Status) - return true, nil - }) + err = client.Routers.Delete(t.Context(), router.UUID) if err != nil { - t.Errorf("waiting for router delete failed: %v", err) + t.Fatalf("Routers.Delete returned error: %s", err) } - // Clean up: delete network - err = client.Networks.Delete(t.Context(), network.UUID) + err = checkIfRouterIsDeleted(t.Context(), router) if err != nil { - t.Fatalf("Networks.Delete returned error %s", err) + t.Errorf("check if router is deleted failed: %v", err) } } From 14af50980d4bdceb0839863c00f03e33050695e4 Mon Sep 17 00:00:00 2001 From: Lukas von Allmen Date: Fri, 11 Sep 2026 10:54:20 +0200 Subject: [PATCH 2/4] Refactors router integration test --- .gitignore | 1 + test/integration/helper_test.go | 26 -- test/integration/routers_integration_test.go | 376 ++++++------------- 3 files changed, 115 insertions(+), 288 deletions(-) diff --git a/.gitignore b/.gitignore index dcefd75..6a93cee 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ .idea/ +.env /vendor/ bin/ diff --git a/test/integration/helper_test.go b/test/integration/helper_test.go index 521520e..d654426 100644 --- a/test/integration/helper_test.go +++ b/test/integration/helper_test.go @@ -4,13 +4,9 @@ package integration import ( "context" - "errors" "math/rand" "reflect" "testing" - "time" - - "github.com/cenkalti/backoff/v5" "github.com/cloudscale-ch/cloudscale-go-sdk/v10" ) @@ -50,25 +46,3 @@ func assertEqual(t *testing.T, expected any, actual any) { t.Errorf("Assertion failed:\nexpected: %#v\n actual: %#v", expected, actual) } } - -// waitForDeleted calls existsFunc in a backoff loop until exists is false. -// -//lint:ignore U1000 - Ignore unused function, since it might become handy again -func waitForDeleted(ctx context.Context, existsFunc func() (exists bool, err error)) error { - options := []backoff.RetryOption{ - backoff.WithBackOff(backoff.NewConstantBackOff(2 * time.Second)), - backoff.WithMaxElapsedTime(5 * time.Minute), - } - - _, err := backoff.Retry(ctx, func() (struct{}, error) { - exists, err := existsFunc() - if !exists { - return struct{}{}, nil - } - if err == nil { - return struct{}{}, errors.New("resource not deleted yet") - } - return struct{}{}, err - }, options...) - return err -} diff --git a/test/integration/routers_integration_test.go b/test/integration/routers_integration_test.go index 345a0a4..c1abc54 100644 --- a/test/integration/routers_integration_test.go +++ b/test/integration/routers_integration_test.go @@ -3,294 +3,156 @@ package integration import ( - "context" - "errors" "fmt" - "net/http" "testing" "time" "github.com/cloudscale-ch/cloudscale-go-sdk/v10" ) -func checkIfRouterIsDeleted(ctx context.Context, router *cloudscale.Router) error { - r, err := client.Routers.Get(ctx, router.UUID) - if err != nil { - if cerr, ok := errors.AsType[*cloudscale.ErrorResponse](err); ok && cerr.StatusCode == http.StatusNotFound { - // The router cannot be found, which means it was successfully deleted - return nil - } - // A different API error code was returned - return err - } - - return fmt.Errorf("router %q still exists with status %q", r.UUID, r.Status) -} - -func checkIfRouterInterfaceIsDeleted(ctx context.Context, router *cloudscale.Router, routerInterface *cloudscale.RouterInterface) error { - r, err := client.Routers.Get(ctx, router.UUID) - if err != nil { - return err - } - for _, i := range r.Interfaces { - if i.UUID == routerInterface.UUID { - return fmt.Errorf("interface %q still attached to router %q", routerInterface.UUID, router.UUID) - } - } - // The router interface is not in the list, which means it was successfully detached - return nil -} - -// newTestRouter creates a router for use in a single test and registers a -// cleanup that deletes it (and waits for the deletion to complete) once the -// test finishes. -func newTestRouter(t *testing.T) *cloudscale.Router { +func testCreateRouter(t *testing.T) cloudscale.Router { t.Helper() createRouterRequest := &cloudscale.RouterCreateRequest{ - Name: "offline-router", + Name: fmt.Sprintf("%s-%s", testRunPrefix, "offline-router"), InternetGateway: false, ZonalResourceRequest: cloudscale.ZonalResourceRequest{Zone: testZone}, } - router, err := client.Routers.Create(t.Context(), createRouterRequest) + initialRouter, err := client.Routers.Create(t.Context(), createRouterRequest) if err != nil { t.Fatalf("Routers.Create returned error %s", err) } - t.Cleanup(func() { - router, err := client.Routers.Get(context.Background(), router.UUID) - if err != nil { - t.Errorf("Routers.Get returned error: %v", err) - return - } - - for _, routerInterface := range router.Interfaces { - err = client.Routers.DeleteInterface(context.Background(), router.UUID, routerInterface.UUID) - if err != nil { - t.Errorf("Routers.DeleteInterface returned error: %v", err) - return - } - - err = checkIfRouterInterfaceIsDeleted(context.Background(), router, &routerInterface) - if err != nil { - t.Errorf("check if router interface is deleted failed: %v", err) - return - } - } - - err = client.Routers.Delete(context.Background(), router.UUID) - if err != nil { - t.Errorf("Routers.Delete returned error: %s", err) - return - } - - err = checkIfRouterIsDeleted(context.Background(), router) - if err != nil { - t.Errorf("check if router is deleted failed: %v", err) - return - } - }) - - return router -} - -// newTestNetworkWithSubnet creates a network and a subnet within it for use -// in a single test and registers a cleanup that deletes the network once the -// test finishes. -func newTestNetworkWithSubnet(t *testing.T) (*cloudscale.Network, *cloudscale.Subnet) { - t.Helper() - - createNetworkRequest := &cloudscale.NetworkCreateRequest{ - Name: testRunPrefix, - AutoCreateIPV4Subnet: new(false), - ZonalResourceRequest: cloudscale.ZonalResourceRequest{Zone: testZone}, - } - network, err := client.Networks.Create(t.Context(), createNetworkRequest) - if err != nil { - t.Fatalf("Networks.Create returned error: %s", err) + if initialRouter.Name != createRouterRequest.Name { + t.Errorf("Router.Name got=%s\nwant=%s", initialRouter.Name, createRouterRequest.Name) } - t.Cleanup(func() { - if err := client.Networks.Delete(context.Background(), network.UUID); err != nil { - t.Errorf("Networks.Delete returned error: %s", err) - } - }) - - createSubnetRequest := &cloudscale.SubnetCreateRequest{ - Network: network.UUID, - CIDR: "192.168.99.0/24", - } - subnet, err := client.Subnets.Create(t.Context(), createSubnetRequest) - if err != nil { - t.Fatalf("Subnets.Create returned error: %s", err) - } - - return network, subnet -} - -func TestIntegrationRouter_Create(t *testing.T) { - t.Parallel() - - createRouterRequest := &cloudscale.RouterCreateRequest{ - Name: "offline-router", - InternetGateway: false, - ZonalResourceRequest: cloudscale.ZonalResourceRequest{Zone: testZone}, - } - - router, err := client.Routers.Create(t.Context(), createRouterRequest) - if err != nil { - t.Fatalf("Routers.Create returned error: %s", err) - } - t.Cleanup(func() { - err := client.Routers.Delete(context.Background(), router.UUID) - if err != nil { - t.Errorf("Routers.Delete returned error: %s", err) - return - } - - err = checkIfRouterIsDeleted(context.Background(), router) - if err != nil { - t.Errorf("check if router is deleted failed: %v", err) - } - }) - - if router.Name != createRouterRequest.Name { - t.Errorf("Router.Name got=%s\nwant=%s", router.Name, createRouterRequest.Name) - } - - if router.InternetGateway != createRouterRequest.InternetGateway { - t.Errorf("Router.InternetGateway got=%t\nwant=%t", router.InternetGateway, createRouterRequest.InternetGateway) + if initialRouter.InternetGateway != createRouterRequest.InternetGateway { + t.Errorf("Router.InternetGateway got=%t\nwant=%t", initialRouter.InternetGateway, createRouterRequest.InternetGateway) } // The number of InternetGatewayAddresses should be 0, since InternetGateway is initially set to false - if numInternetGatewayAddresses := len(router.InternetGatewayAddresses); numInternetGatewayAddresses != 0 { + if numInternetGatewayAddresses := len(initialRouter.InternetGatewayAddresses); numInternetGatewayAddresses != 0 { t.Errorf("Number of InternetGatewayAddresses got=%d\nwant=%d", numInternetGatewayAddresses, 0) } -} - -func TestIntegrationRouter_Update_Name(t *testing.T) { - t.Parallel() - - router := newTestRouter(t) - updateNameRouterRequest := &cloudscale.RouterUpdateRequest{ - Name: "router.example.com", - } - - err := client.Routers.Update(t.Context(), router.UUID, updateNameRouterRequest) - if err != nil { - t.Fatalf("Routers.Update returned error: %s", err) + if _, err := client.Routers.WaitFor(t.Context(), initialRouter.UUID, cloudscale.RouterIsActive); err != nil { + t.Errorf("router not in active state: %s", err) } - updated, err := client.Routers.Get(t.Context(), router.UUID) - if err != nil { - t.Fatalf("Routers.Get returned error: %s", err) + if h := time.Since(initialRouter.CreatedAt).Hours(); !(-1 < h && h < 1) { + t.Errorf("router.CreatedAt outside of expected range. got=%s", initialRouter.CreatedAt) } - if updateNameRouterRequest.Name != updated.Name { - t.Errorf("router.Name got=%s\nwant=%s", updated.Name, updateNameRouterRequest.Name) - } + return *initialRouter } -func TestIntegrationRouter_Update_InternetGateway(t *testing.T) { - t.Parallel() - - router := newTestRouter(t) +func testUpdateRouter(t *testing.T, initialRouter cloudscale.Router) cloudscale.Router { + t.Helper() - // Give the router a valid FQDN name first, so we can assert on the - // reverse pointer of the resulting InternetGatewayAddresses. updateNameRouterRequest := &cloudscale.RouterUpdateRequest{ - Name: "router.example.com", + Name: fmt.Sprintf("%s.%s", testRunPrefix, "router.example.com"), } - if err := client.Routers.Update(t.Context(), router.UUID, updateNameRouterRequest); err != nil { - t.Fatalf("Routers.Update returned error: %s", err) + + err := client.Routers.Update(t.Context(), initialRouter.UUID, updateNameRouterRequest) + if err != nil { + t.Fatalf("Routers.Update returned error %s", err) } updateInternetGatewayRouterRequest := &cloudscale.RouterUpdateRequest{ InternetGateway: true, } - err := client.Routers.Update(t.Context(), router.UUID, updateInternetGatewayRouterRequest) + err = client.Routers.Update(t.Context(), initialRouter.UUID, updateInternetGatewayRouterRequest) if err != nil { - t.Fatalf("Routers.Update returned error: %s", err) + t.Fatalf("Routers.Update returned error %s", err) } - updated, err := client.Routers.Get(t.Context(), router.UUID) + updatedRouter, err := client.Routers.Get(t.Context(), initialRouter.UUID) if err != nil { - t.Fatalf("Routers.Get returned error: %s", err) + t.Fatalf("Routers.Get returned error %s", err) } - if updateInternetGatewayRouterRequest.InternetGateway != updated.InternetGateway { - t.Errorf("router.InternetGateway got=%t\nwant=%t", updated.InternetGateway, updateInternetGatewayRouterRequest.InternetGateway) + if updatedRouter.Name != updateNameRouterRequest.Name { + t.Errorf("router.Name got=%s\nwant=%s", updatedRouter.Name, updateNameRouterRequest.Name) + } + if updatedRouter.InternetGateway != updateInternetGatewayRouterRequest.InternetGateway { + t.Errorf("router.InternetGateway got=%t\nwant=%t", updatedRouter.InternetGateway, updateInternetGatewayRouterRequest.InternetGateway) + } + if uuid := updatedRouter.UUID; uuid != initialRouter.UUID { + t.Errorf("Router.UUID got=%s\nwant=%s", uuid, initialRouter.UUID) } - // The number of InternetGatewayAddresses should be 1, since InternetGateway was updated to true. // This assertion must be updated after the API supports IPv6 for private networks. - if numInternetGatewayAddresses := len(updated.InternetGatewayAddresses); numInternetGatewayAddresses != 1 { + if numInternetGatewayAddresses := len(updatedRouter.InternetGatewayAddresses); numInternetGatewayAddresses != 1 { t.Errorf("Number of InternetGatewayAddresses got=%d\nwant=%d", numInternetGatewayAddresses, 1) } // If InternetGateway is true and the Name is a valid FQDN, all InternetGatewayAddresses should have this FQDN as a reverse pointer. - for index, address := range updated.InternetGatewayAddresses { - if reversePTR := *address.ReversePTR; reversePTR != updateNameRouterRequest.Name { + for index, address := range updatedRouter.InternetGatewayAddresses { + reversePTR := "" + if address.ReversePTR != nil { + reversePTR = *address.ReversePTR + } + + if reversePTR != updateNameRouterRequest.Name { t.Errorf("Router.InternetGatewayAddresses[%d].ReversePTR got=%s\nwant=%s", index, reversePTR, updateNameRouterRequest.Name) } } -} -func TestIntegrationRouter_Get(t *testing.T) { - t.Parallel() + return *updatedRouter +} - router := newTestRouter(t) +func testListRouters(t *testing.T) { + t.Helper() - got, err := client.Routers.Get(t.Context(), router.UUID) + routers, err := client.Routers.List(t.Context()) if err != nil { - t.Fatalf("Routers.Get returned error: %s", err) - } - - if uuid := got.UUID; uuid != router.UUID { - t.Errorf("Router.UUID got=%s\nwant=%s", uuid, router.UUID) + t.Fatalf("Routers.List returned error %s\n", err) } - if h := time.Since(got.CreatedAt).Hours(); !(-1 < h && h < 1) { - t.Errorf("router.CreatedAt outside of expected range. got=%v", got.CreatedAt) + if numRouters := len(routers); numRouters < 1 { + t.Errorf("Routers.List got=%d\nwant>=%d\n", numRouters, 1) } } -func TestIntegrationRouter_List(t *testing.T) { - t.Parallel() - - _ = newTestRouter(t) +func createNetwork(t *testing.T) cloudscale.Network { + t.Helper() - routers, err := client.Routers.List(t.Context()) + createNetworkRequest := &cloudscale.NetworkCreateRequest{ + Name: testRunPrefix, + AutoCreateIPV4Subnet: new(false), + ZonalResourceRequest: cloudscale.ZonalResourceRequest{Zone: testZone}, + } + network, err := client.Networks.Create(t.Context(), createNetworkRequest) if err != nil { - t.Fatalf("Routers.List returned error: %s\n", err) + t.Fatalf("Networks.Create returned error %s", err) } - if numRouters := len(routers); numRouters < 1 { - t.Errorf("Routers.List got=%d\nwant>=%d\n", numRouters, 1) - } + return *network } -func TestIntegrationRouter_WaitFor(t *testing.T) { - t.Parallel() - - router := newTestRouter(t) +func createSubnet(t *testing.T, network cloudscale.Network) cloudscale.Subnet { + t.Helper() - if _, err := client.Routers.WaitFor(t.Context(), router.UUID, cloudscale.RouterIsActive); err != nil { - t.Errorf("router not in active state: %v", err) + createSubnetRequest := &cloudscale.SubnetCreateRequest{ + Network: network.UUID, + CIDR: "192.168.99.0/24", + } + subnet, err := client.Subnets.Create(t.Context(), createSubnetRequest) + if err != nil { + t.Fatalf("Subnets.Create returned error %s", err) } -} -func TestIntegrationRouter_AttachInterface(t *testing.T) { - t.Parallel() + return *subnet +} - router := newTestRouter(t) - network, subnet := newTestNetworkWithSubnet(t) +func testCreateRouterInterface(t *testing.T, router cloudscale.Router, subnet cloudscale.Subnet) cloudscale.RouterInterface { + t.Helper() - createInterfaceRequest := cloudscale.CreateInterfaceRequest{ - Network: network.UUID, + createRouterInterfaceRequest := cloudscale.CreateInterfaceRequest{ + Network: subnet.Network.UUID, Addresses: []cloudscale.CreateAddressRequest{ { Subnet: subnet.UUID, @@ -298,24 +160,20 @@ func TestIntegrationRouter_AttachInterface(t *testing.T) { }, }, } - routerInterface, err := client.Routers.CreateInterface(t.Context(), router.UUID, createInterfaceRequest) + + routerInterface, err := client.Routers.CreateInterface(t.Context(), router.UUID, createRouterInterfaceRequest) if err != nil { - t.Fatalf("Routers.CreateInterface returned error: %s", err) + t.Fatalf("Routers.CreateInterface returned error %s", err) } - t.Cleanup(func() { - if err := client.Routers.DeleteInterface(context.Background(), router.UUID, routerInterface.UUID); err != nil { - t.Errorf("Routers.DeleteInterface returned error: %s", err) - } - }) if routerInterface.UUID == "" { t.Error("Routers.CreateInterface returned interface without UUID") } - if networkUUID := routerInterface.Network.UUID; networkUUID != network.UUID { - t.Errorf("interface.Network.UUID got=%s\nwant=%s", networkUUID, network.UUID) + if networkUUID := routerInterface.Network.UUID; networkUUID != subnet.Network.UUID { + t.Errorf("interface.Network.UUID got=%s\nwant=%s", networkUUID, subnet.Network.UUID) } if numAddresses := len(routerInterface.Addresses); numAddresses != 1 { - t.Fatalf("interface Addresses got=%d\nwant=%d", numAddresses, 1) + t.Errorf("interface Addresses got=%d\nwant=%d", numAddresses, 1) } if subnetUUID := routerInterface.Addresses[0].Subnet.UUID; subnetUUID != subnet.UUID { t.Errorf("interface.Addresses[0].Subnet.UUID got=%s\nwant=%s", subnetUUID, subnet.UUID) @@ -323,62 +181,56 @@ func TestIntegrationRouter_AttachInterface(t *testing.T) { if addr := routerInterface.Addresses[0].Address; addr != "192.168.99.10" { t.Errorf("interface.Addresses[0].Address got=%s\nwant=%s", addr, "192.168.99.10") } -} -func TestIntegrationRouter_DetachInterface(t *testing.T) { - t.Parallel() + return *routerInterface +} - router := newTestRouter(t) - network, subnet := newTestNetworkWithSubnet(t) +func testDeleteRouterInterface(t *testing.T, router cloudscale.Router, routerInterface cloudscale.RouterInterface) { + t.Helper() - createInterfaceRequest := cloudscale.CreateInterfaceRequest{ - Network: network.UUID, - Addresses: []cloudscale.CreateAddressRequest{ - { - Subnet: subnet.UUID, - Address: "192.168.99.10", - }, - }, - } - routerInterface, err := client.Routers.CreateInterface(t.Context(), router.UUID, createInterfaceRequest) + err := client.Routers.DeleteInterface(t.Context(), router.UUID, routerInterface.UUID) if err != nil { - t.Fatalf("Routers.CreateInterface returned error: %s", err) + t.Errorf("Routers.DeleteInterface returned error: %s", err) } +} - if err := client.Routers.DeleteInterface(t.Context(), router.UUID, routerInterface.UUID); err != nil { - t.Errorf("Routers.DeleteInterface returned error: %s", err) +func testDeleteRouter(t *testing.T, router cloudscale.Router) { + t.Helper() + + err := client.Routers.Delete(t.Context(), router.UUID) + if err != nil { + t.Fatalf("Routers.Delete returned error %s", err) } +} - // Verify the interface is actually gone: the router's Interfaces list - // must no longer contain it. - err = checkIfRouterInterfaceIsDeleted(t.Context(), router, routerInterface) +func deleteNetwork(t *testing.T, network cloudscale.Network) { + t.Helper() + + err := client.Networks.Delete(t.Context(), network.UUID) if err != nil { - t.Errorf("check if router interface is deleted failed: %v", err) + t.Fatalf("Networks.Delete returned error %s", err) } } -func TestIntegrationRouter_Delete(t *testing.T) { +func TestIntegrationRouter_CRUD(t *testing.T) { t.Parallel() - // Cannot use newTestRouter here, since it registers a cleanup function which also tries to delete the router - createRouterRequest := &cloudscale.RouterCreateRequest{ - Name: "offline-router", - InternetGateway: false, - ZonalResourceRequest: cloudscale.ZonalResourceRequest{Zone: testZone}, - } + router := testCreateRouter(t) + router = testUpdateRouter(t, router) + testListRouters(t) - router, err := client.Routers.Create(t.Context(), createRouterRequest) - if err != nil { - t.Fatalf("Routers.Create returned error: %s", err) - } + // Set up a network with a subnet so we can attach an interface to the router. + network := createNetwork(t) + subnet := createSubnet(t, network) - err = client.Routers.Delete(t.Context(), router.UUID) - if err != nil { - t.Fatalf("Routers.Delete returned error: %s", err) - } + routerInterface := testCreateRouterInterface(t, router, subnet) - err = checkIfRouterIsDeleted(t.Context(), router) - if err != nil { - t.Errorf("check if router is deleted failed: %v", err) - } + // Clean up and test router interface deletion + testDeleteRouterInterface(t, router, routerInterface) + + // Clean up and test router deletion + testDeleteRouter(t, router) + + // Clean up: delete network (automatically deletes the subnet too) + deleteNetwork(t, network) } From 37b41911f007bf223cbf6f1e0570103350c41bb4 Mon Sep 17 00:00:00 2001 From: Lukas von Allmen Date: Fri, 11 Sep 2026 14:24:41 +0200 Subject: [PATCH 3/4] Extends TestRouters_Update with json data assertion --- router_test.go | 29 ++++++++++++++++++++++------- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/router_test.go b/router_test.go index 0532560..a9080a7 100644 --- a/router_test.go +++ b/router_test.go @@ -53,18 +53,33 @@ func TestRouters_Update(t *testing.T) { setup() defer teardown() + routerID := "cfde831a-4e87-4a75-960f-89b0148aa2cc" + newRouterName := "new-router-name" + updateRouterRequest := &RouterUpdateRequest{ + Name: newRouterName, + } + mux.HandleFunc("/v1/routers/cfde831a-4e87-4a75-960f-89b0148aa2cc", func(w http.ResponseWriter, r *http.Request) { testHTTPMethod(t, r, http.MethodPatch) - }) - routerID := "cfde831a-4e87-4a75-960f-89b0148aa2cc" + expected := map[string]any{ + "name": newRouterName, + } - req := &RouterUpdateRequest{ - Name: "new-router-name", - } - err := client.Routers.Update(context.TODO(), routerID, req) + var v map[string]any + err := json.NewDecoder(r.Body).Decode(&v) + if err != nil { + t.Fatalf("decode json: %v", err) + } + + if !reflect.DeepEqual(v, expected) { + t.Errorf("Request body = %#v, expected %#v", v, expected) + } + }) + + err := client.Routers.Update(context.TODO(), routerID, updateRouterRequest) if err != nil { - t.Errorf("ObjectsUser.Update returned error: %v", err) + t.Errorf("Routers.Update returned error: %v", err) } } From 8a84a91ecbd8bdfb018f3974fb6cebe4ccdc706e Mon Sep 17 00:00:00 2001 From: Lukas von Allmen Date: Wed, 16 Sep 2026 17:24:39 +0200 Subject: [PATCH 4/4] Changes the type of RouterUpdateRequest.InternetGateway to pointer bool --- router.go | 6 ++- test/integration/routers_integration_test.go | 54 ++++++++++---------- 2 files changed, 31 insertions(+), 29 deletions(-) diff --git a/router.go b/router.go index 7824fa3..e029325 100644 --- a/router.go +++ b/router.go @@ -46,8 +46,10 @@ type RouterCreateRequest struct { type RouterUpdateRequest struct { ZonalResourceRequest TaggedResourceRequest - Name string `json:"name,omitempty"` - InternetGateway bool `json:"internet_gateway,omitempty"` + Name string `json:"name,omitempty"` + // bool + omitempty: If we don't use a pointer to bool here, the value `false` causes the attribute to be omitted. + // This means setting the `internet_gateway` to false would cause an empty request body and thus a 400 Bad Request. + InternetGateway *bool `json:"internet_gateway,omitempty"` } type RouterService interface { diff --git a/test/integration/routers_integration_test.go b/test/integration/routers_integration_test.go index c1abc54..f1df020 100644 --- a/test/integration/routers_integration_test.go +++ b/test/integration/routers_integration_test.go @@ -14,8 +14,8 @@ func testCreateRouter(t *testing.T) cloudscale.Router { t.Helper() createRouterRequest := &cloudscale.RouterCreateRequest{ - Name: fmt.Sprintf("%s-%s", testRunPrefix, "offline-router"), - InternetGateway: false, + Name: fmt.Sprintf("%s-%s", testRunPrefix, "router.example.com"), + InternetGateway: true, ZonalResourceRequest: cloudscale.ZonalResourceRequest{Zone: testZone}, } @@ -32,11 +32,6 @@ func testCreateRouter(t *testing.T) cloudscale.Router { t.Errorf("Router.InternetGateway got=%t\nwant=%t", initialRouter.InternetGateway, createRouterRequest.InternetGateway) } - // The number of InternetGatewayAddresses should be 0, since InternetGateway is initially set to false - if numInternetGatewayAddresses := len(initialRouter.InternetGatewayAddresses); numInternetGatewayAddresses != 0 { - t.Errorf("Number of InternetGatewayAddresses got=%d\nwant=%d", numInternetGatewayAddresses, 0) - } - if _, err := client.Routers.WaitFor(t.Context(), initialRouter.UUID, cloudscale.RouterIsActive); err != nil { t.Errorf("router not in active state: %s", err) } @@ -45,6 +40,24 @@ func testCreateRouter(t *testing.T) cloudscale.Router { t.Errorf("router.CreatedAt outside of expected range. got=%s", initialRouter.CreatedAt) } + // The number of InternetGatewayAddresses should be 1, since InternetGateway was updated to true. + // This assertion must be updated after the API supports IPv6 for private networks. + if numInternetGatewayAddresses := len(initialRouter.InternetGatewayAddresses); numInternetGatewayAddresses != 1 { + t.Errorf("Number of InternetGatewayAddresses got=%d\nwant=%d", numInternetGatewayAddresses, 1) + } + + // If InternetGateway is true and the Name is a valid FQDN, all InternetGatewayAddresses should have this FQDN as a reverse pointer. + for index, address := range initialRouter.InternetGatewayAddresses { + reversePTR := "" + if address.ReversePTR != nil { + reversePTR = *address.ReversePTR + } + + if reversePTR != initialRouter.Name { + t.Errorf("Router.InternetGatewayAddresses[%d].ReversePTR got=%s\nwant=%s", index, reversePTR, initialRouter.Name) + } + } + return *initialRouter } @@ -52,7 +65,7 @@ func testUpdateRouter(t *testing.T, initialRouter cloudscale.Router) cloudscale. t.Helper() updateNameRouterRequest := &cloudscale.RouterUpdateRequest{ - Name: fmt.Sprintf("%s.%s", testRunPrefix, "router.example.com"), + Name: fmt.Sprintf("%s.%s", testRunPrefix, "offline-router"), } err := client.Routers.Update(t.Context(), initialRouter.UUID, updateNameRouterRequest) @@ -61,7 +74,7 @@ func testUpdateRouter(t *testing.T, initialRouter cloudscale.Router) cloudscale. } updateInternetGatewayRouterRequest := &cloudscale.RouterUpdateRequest{ - InternetGateway: true, + InternetGateway: new(false), } err = client.Routers.Update(t.Context(), initialRouter.UUID, updateInternetGatewayRouterRequest) @@ -77,28 +90,15 @@ func testUpdateRouter(t *testing.T, initialRouter cloudscale.Router) cloudscale. if updatedRouter.Name != updateNameRouterRequest.Name { t.Errorf("router.Name got=%s\nwant=%s", updatedRouter.Name, updateNameRouterRequest.Name) } - if updatedRouter.InternetGateway != updateInternetGatewayRouterRequest.InternetGateway { - t.Errorf("router.InternetGateway got=%t\nwant=%t", updatedRouter.InternetGateway, updateInternetGatewayRouterRequest.InternetGateway) + if updatedRouter.InternetGateway != *updateInternetGatewayRouterRequest.InternetGateway { + t.Errorf("router.InternetGateway got=%t\nwant=%t", updatedRouter.InternetGateway, *updateInternetGatewayRouterRequest.InternetGateway) } if uuid := updatedRouter.UUID; uuid != initialRouter.UUID { t.Errorf("Router.UUID got=%s\nwant=%s", uuid, initialRouter.UUID) } - // The number of InternetGatewayAddresses should be 1, since InternetGateway was updated to true. - // This assertion must be updated after the API supports IPv6 for private networks. - if numInternetGatewayAddresses := len(updatedRouter.InternetGatewayAddresses); numInternetGatewayAddresses != 1 { - t.Errorf("Number of InternetGatewayAddresses got=%d\nwant=%d", numInternetGatewayAddresses, 1) - } - - // If InternetGateway is true and the Name is a valid FQDN, all InternetGatewayAddresses should have this FQDN as a reverse pointer. - for index, address := range updatedRouter.InternetGatewayAddresses { - reversePTR := "" - if address.ReversePTR != nil { - reversePTR = *address.ReversePTR - } - - if reversePTR != updateNameRouterRequest.Name { - t.Errorf("Router.InternetGatewayAddresses[%d].ReversePTR got=%s\nwant=%s", index, reversePTR, updateNameRouterRequest.Name) - } + // The number of InternetGatewayAddresses should be 0, since InternetGateway is set to false + if numInternetGatewayAddresses := len(updatedRouter.InternetGatewayAddresses); numInternetGatewayAddresses != 0 { + t.Errorf("Number of InternetGatewayAddresses got=%d\nwant=%d", numInternetGatewayAddresses, 0) } return *updatedRouter