From 7a4b226ecef89bc7863321b865e3c9596765048d Mon Sep 17 00:00:00 2001 From: MegaportPhilipBrowne Date: Wed, 26 Nov 2025 08:53:46 -0800 Subject: [PATCH 1/4] fix: prevent delayed cancellation for mcr and transit vxc --- errors.go | 6 ++++++ mcr.go | 10 +++++++++- mcr_integration_test.go | 28 ++++++++-------------------- vxc.go | 38 ++++++++++++++++++++++++++++++++++++-- vxc_test.go | 35 +++++++++++++++++++++++++++++++---- 5 files changed, 90 insertions(+), 27 deletions(-) diff --git a/errors.go b/errors.go index 951ca96..a998387 100644 --- a/errors.go +++ b/errors.go @@ -83,3 +83,9 @@ var ErrInvalidVXCAEndPartnerConfig = errors.New("invalid vxc a-end partner confi // ErrInvalidVXCBEndPartnerConfig is returned when an invalid VXC B-End partner config is provided var ErrInvalidVXCBEndPartnerConfig = errors.New("invalid vxc b-end partner config") + +// ErrMCRCancelLaterNotAllowed is returned when attempting to schedule MCR deletion for later (only CANCEL_NOW is allowed) +var ErrMCRCancelLaterNotAllowed = errors.New("MCR products do not support scheduled deletion (cancel later). Only immediate deletion (CANCEL_NOW) is allowed per product lifecycle requirements") + +// ErrTransitVXCCancelLaterNotAllowed is returned when attempting to schedule Transit VXC deletion for later (only CANCEL_NOW is allowed) +var ErrTransitVXCCancelLaterNotAllowed = errors.New("Transit VXC (Megaport Internet) does not support scheduled deletion (cancel later). Only immediate deletion (CANCEL_NOW) is allowed per product lifecycle requirements") diff --git a/mcr.go b/mcr.go index 3e3344b..b9771be 100644 --- a/mcr.go +++ b/mcr.go @@ -503,10 +503,18 @@ func (svc *MCRServiceOp) ModifyMCRPrefixFilterList(ctx context.Context, mcrID st } // DeleteMCR deletes an MCR in the Megaport MCR API. +// Note: MCR products only support immediate deletion (CANCEL_NOW). Per API requirements, +// the DeleteNow flag will be automatically set to true. Attempting to schedule deletion +// for later (DeleteNow=false) will return an error. func (svc *MCRServiceOp) DeleteMCR(ctx context.Context, req *DeleteMCRRequest) (*DeleteMCRResponse, error) { + // Enforce MCR lifecycle restriction: only CANCEL_NOW is allowed + if !req.DeleteNow { + return nil, ErrMCRCancelLaterNotAllowed + } + _, err := svc.Client.ProductService.DeleteProduct(ctx, &DeleteProductRequest{ ProductID: req.MCRID, - DeleteNow: req.DeleteNow, + DeleteNow: true, // Always use CANCEL_NOW for MCR SafeDelete: req.SafeDelete, }) if err != nil { diff --git a/mcr_integration_test.go b/mcr_integration_test.go index 347c1b8..15560d5 100644 --- a/mcr_integration_test.go +++ b/mcr_integration_test.go @@ -107,30 +107,18 @@ func (suite *MCRIntegrationTestSuite) TestMCRLifecycle() { } suite.EqualValues(newMCRName, mcr.Name) - // Testing MCR Cancel - logger.InfoContext(ctx, "Scheduling MCR for deletion (30 days).", slog.String("mcr_id", mcrId)) + // Testing MCR Cancel - MCR products do not support cancel later (only CANCEL_NOW) + logger.InfoContext(ctx, "Attempting to schedule MCR for deletion (cancel later) - this should fail.", slog.String("mcr_id", mcrId)) - // This is a soft Delete - softDeleteRes, deleteErr := mcrSvc.DeleteMCR(ctx, &DeleteMCRRequest{ + // Attempt soft delete - this should now fail because MCR only supports CANCEL_NOW + _, deleteErr := mcrSvc.DeleteMCR(ctx, &DeleteMCRRequest{ MCRID: mcrId, DeleteNow: false, }) - if deleteErr != nil { - suite.FailNowf("could not soft delete mcr", "could not soft delete mcr %v", deleteErr) - } - suite.True(softDeleteRes.IsDeleting, true) - - mcrCancelInfo, getErr := mcrSvc.GetMCR(ctx, mcrId) - if getErr != nil { - suite.FailNowf("could not get mcr", "could not get mcr %v", getErr) - } - suite.EqualValues(STATUS_CANCELLED, mcrCancelInfo.ProvisioningStatus) - logger.DebugContext(ctx, "MCR Canceled", slog.String("provisioning_status", mcrCancelInfo.ProvisioningStatus)) - restoreRes, restoreErr := mcrSvc.RestoreMCR(ctx, mcrId) - if restoreErr != nil { - suite.FailNowf("could not restore mcr", "could not restore mcr %v", getErr) - } - suite.True(restoreRes.IsRestored) + // Expect error since MCR does not support cancel later + suite.Error(deleteErr, "expected error when attempting to cancel MCR later") + suite.ErrorIs(deleteErr, ErrMCRCancelLaterNotAllowed, "expected ErrMCRCancelLaterNotAllowed error") + logger.DebugContext(ctx, "MCR cancel later correctly rejected", slog.String("error", deleteErr.Error())) // Testing MCR Delete logger.InfoContext(ctx, "Deleting MCR now.") diff --git a/vxc.go b/vxc.go index 5f37ac0..1f1cc2f 100644 --- a/vxc.go +++ b/vxc.go @@ -286,11 +286,45 @@ func (svc *VXCServiceOp) ValidateVXCOrder(ctx context.Context, req *BuyVXCReques return svc.Client.ProductService.ValidateProductOrder(ctx, buyOrder) } +// isTransitVXC checks if a VXC is a Transit VXC (Megaport Internet) by examining the partner configuration. +// A VXC is considered a Transit VXC if either A-End or B-End has connectType "TRANSIT". +func isTransitVXC(vxc *VXC) bool { + if vxc == nil || vxc.Resources == nil || vxc.Resources.CSPConnection == nil { + return false + } + + for _, csp := range vxc.Resources.CSPConnection.CSPConnection { + if transitCSP, ok := csp.(CSPConnectionTransit); ok && transitCSP.ConnectType == "TRANSIT" { + return true + } + } + return false +} + // DeleteVXC deletes a VXC in the Megaport VXC API. +// Note: Transit VXCs (Megaport Internet) only support immediate deletion (CANCEL_NOW). +// If the VXC is a Transit VXC, the DeleteNow flag will be automatically enforced. func (svc *VXCServiceOp) DeleteVXC(ctx context.Context, id string, req *DeleteVXCRequest) error { - _, err := svc.Client.ProductService.DeleteProduct(ctx, &DeleteProductRequest{ + // Check if this is a Transit VXC that requires immediate deletion + vxc, err := svc.GetVXC(ctx, id) + if err != nil { + return err + } + + // Enforce Transit VXC lifecycle restriction: only CANCEL_NOW is allowed + if isTransitVXC(vxc) && !req.DeleteNow { + return ErrTransitVXCCancelLaterNotAllowed + } + + deleteNow := req.DeleteNow + // Force immediate deletion for Transit VXCs + if isTransitVXC(vxc) { + deleteNow = true + } + + _, err = svc.Client.ProductService.DeleteProduct(ctx, &DeleteProductRequest{ ProductID: id, - DeleteNow: req.DeleteNow, + DeleteNow: deleteNow, }) if err != nil { return err diff --git a/vxc_test.go b/vxc_test.go index 6298781..727b26a 100644 --- a/vxc_test.go +++ b/vxc_test.go @@ -1077,16 +1077,43 @@ func (suite *VXCClientTestSuite) TestDeleteVXC() { DeleteNow: true, } - jblob := `{ + // Mock GetVXC call (needed to check if VXC is Transit) + getVxcBlob := `{ + "message": "Found Product 36b3f68e-2f54-4331-bf94-f8984449365f", + "terms": "This data is subject to the Acceptable Use Policy https://www.megaport.com/legal/acceptable-use-policy", + "data": { + "productId": 1, + "productUid": "36b3f68e-2f54-4331-bf94-f8984449365f", + "productName": "test-vxc", + "productType": "VXC", + "rateLimit": 50, + "provisioningStatus": "LIVE", + "resources": { + "csp_connection": { + "connectType": "AWS", + "resource_name": "csp_connection", + "resource_type": "csp_connection" + } + } + } + }` + + getPath := "/v2/product/" + productUid + suite.mux.HandleFunc(getPath, func(w http.ResponseWriter, r *http.Request) { + suite.testMethod(r, http.MethodGet) + fmt.Fprint(w, getVxcBlob) + }) + + deleteBlob := `{ "message": "Action [CANCEL_NOW Service 36b3f68e-2f54-4331-bf94-f8984449365f] has been done.", "terms": "This data is subject to the Acceptable Use Policy https://www.megaport.com/legal/acceptable-use-policy" }` - path := "/v3/product/" + productUid + "/action/CANCEL_NOW" + deletePath := "/v3/product/" + productUid + "/action/CANCEL_NOW" - suite.mux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) { + suite.mux.HandleFunc(deletePath, func(w http.ResponseWriter, r *http.Request) { suite.testMethod(r, http.MethodPost) - fmt.Fprint(w, jblob) + fmt.Fprint(w, deleteBlob) }) err := vxcSvc.DeleteVXC(ctx, productUid, req) From af00cb64a96615a50d41227b4a7bfdcb5bc960b3 Mon Sep 17 00:00:00 2001 From: MegaportPhilipBrowne Date: Wed, 26 Nov 2025 08:56:10 -0800 Subject: [PATCH 2/4] fix: linter error --- errors.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/errors.go b/errors.go index a998387..91e5187 100644 --- a/errors.go +++ b/errors.go @@ -88,4 +88,4 @@ var ErrInvalidVXCBEndPartnerConfig = errors.New("invalid vxc b-end partner confi var ErrMCRCancelLaterNotAllowed = errors.New("MCR products do not support scheduled deletion (cancel later). Only immediate deletion (CANCEL_NOW) is allowed per product lifecycle requirements") // ErrTransitVXCCancelLaterNotAllowed is returned when attempting to schedule Transit VXC deletion for later (only CANCEL_NOW is allowed) -var ErrTransitVXCCancelLaterNotAllowed = errors.New("Transit VXC (Megaport Internet) does not support scheduled deletion (cancel later). Only immediate deletion (CANCEL_NOW) is allowed per product lifecycle requirements") +var ErrTransitVXCCancelLaterNotAllowed = errors.New("transit VXC (Megaport Internet) does not support scheduled deletion (cancel later). Only immediate deletion (CANCEL_NOW) is allowed per product lifecycle requirements") From 7d4422d8e0e784823d62b83192061e46302dc9f1 Mon Sep 17 00:00:00 2001 From: MegaportPhilipBrowne Date: Wed, 26 Nov 2025 08:58:40 -0800 Subject: [PATCH 3/4] fix: handle redundancy --- vxc.go | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/vxc.go b/vxc.go index 1f1cc2f..db8550a 100644 --- a/vxc.go +++ b/vxc.go @@ -316,15 +316,9 @@ func (svc *VXCServiceOp) DeleteVXC(ctx context.Context, id string, req *DeleteVX return ErrTransitVXCCancelLaterNotAllowed } - deleteNow := req.DeleteNow - // Force immediate deletion for Transit VXCs - if isTransitVXC(vxc) { - deleteNow = true - } - _, err = svc.Client.ProductService.DeleteProduct(ctx, &DeleteProductRequest{ ProductID: id, - DeleteNow: deleteNow, + DeleteNow: req.DeleteNow, }) if err != nil { return err From 71dcb5813a018077e440557a7c079ec8de31b628 Mon Sep 17 00:00:00 2001 From: MegaportPhilipBrowne Date: Wed, 26 Nov 2025 09:00:19 -0800 Subject: [PATCH 4/4] fix: test coverage and better documentation --- vxc.go | 2 +- vxc_test.go | 183 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 184 insertions(+), 1 deletion(-) diff --git a/vxc.go b/vxc.go index db8550a..7de6024 100644 --- a/vxc.go +++ b/vxc.go @@ -303,7 +303,7 @@ func isTransitVXC(vxc *VXC) bool { // DeleteVXC deletes a VXC in the Megaport VXC API. // Note: Transit VXCs (Megaport Internet) only support immediate deletion (CANCEL_NOW). -// If the VXC is a Transit VXC, the DeleteNow flag will be automatically enforced. +// Attempting to schedule deletion (DeleteNow=false) for Transit VXCs will return an error. func (svc *VXCServiceOp) DeleteVXC(ctx context.Context, id string, req *DeleteVXCRequest) error { // Check if this is a Transit VXC that requires immediate deletion vxc, err := svc.GetVXC(ctx, id) diff --git a/vxc_test.go b/vxc_test.go index 727b26a..36ed89f 100644 --- a/vxc_test.go +++ b/vxc_test.go @@ -1121,6 +1121,189 @@ func (suite *VXCClientTestSuite) TestDeleteVXC() { suite.NoError(err) } +// TestDeleteTransitVXCWithCancelLater tests that attempting to schedule deletion (cancel later) for a Transit VXC returns an error +func (suite *VXCClientTestSuite) TestDeleteTransitVXCWithCancelLater() { + ctx := context.Background() + + vxcSvc := suite.client.VXCService + productUid := "36b3f68e-2f54-4331-bf94-f8984449365f" + + req := &DeleteVXCRequest{ + DeleteNow: false, // Attempt to schedule deletion + } + + // Mock GetVXC call returning a Transit VXC + getVxcBlob := `{ + "message": "Found Product 36b3f68e-2f54-4331-bf94-f8984449365f", + "terms": "This data is subject to the Acceptable Use Policy https://www.megaport.com/legal/acceptable-use-policy", + "data": { + "productId": 1, + "productUid": "36b3f68e-2f54-4331-bf94-f8984449365f", + "productName": "test-transit-vxc", + "productType": "VXC", + "rateLimit": 50, + "provisioningStatus": "LIVE", + "resources": { + "csp_connection": { + "connectType": "TRANSIT", + "resource_name": "csp_connection", + "resource_type": "csp_connection", + "customer_ip4_address": "203.0.113.1/30", + "ipv4_gateway_address": "203.0.113.2" + } + } + } + }` + + getPath := "/v2/product/" + productUid + suite.mux.HandleFunc(getPath, func(w http.ResponseWriter, r *http.Request) { + suite.testMethod(r, http.MethodGet) + fmt.Fprint(w, getVxcBlob) + }) + + err := vxcSvc.DeleteVXC(ctx, productUid, req) + + suite.Error(err, "expected error when attempting to cancel Transit VXC later") + suite.ErrorIs(err, ErrTransitVXCCancelLaterNotAllowed, "expected ErrTransitVXCCancelLaterNotAllowed") +} + +// TestDeleteTransitVXCWithDeleteNow tests that immediate deletion of a Transit VXC succeeds +func (suite *VXCClientTestSuite) TestDeleteTransitVXCWithDeleteNow() { + ctx := context.Background() + + vxcSvc := suite.client.VXCService + productUid := "36b3f68e-2f54-4331-bf94-f8984449365f" + + req := &DeleteVXCRequest{ + DeleteNow: true, // Immediate deletion + } + + // Mock GetVXC call returning a Transit VXC + getVxcBlob := `{ + "message": "Found Product 36b3f68e-2f54-4331-bf94-f8984449365f", + "terms": "This data is subject to the Acceptable Use Policy https://www.megaport.com/legal/acceptable-use-policy", + "data": { + "productId": 1, + "productUid": "36b3f68e-2f54-4331-bf94-f8984449365f", + "productName": "test-transit-vxc", + "productType": "VXC", + "rateLimit": 50, + "provisioningStatus": "LIVE", + "resources": { + "csp_connection": { + "connectType": "TRANSIT", + "resource_name": "csp_connection", + "resource_type": "csp_connection", + "customer_ip4_address": "203.0.113.1/30", + "ipv4_gateway_address": "203.0.113.2" + } + } + } + }` + + getPath := "/v2/product/" + productUid + suite.mux.HandleFunc(getPath, func(w http.ResponseWriter, r *http.Request) { + suite.testMethod(r, http.MethodGet) + fmt.Fprint(w, getVxcBlob) + }) + + deleteBlob := `{ + "message": "Action [CANCEL_NOW Service 36b3f68e-2f54-4331-bf94-f8984449365f] has been done.", + "terms": "This data is subject to the Acceptable Use Policy https://www.megaport.com/legal/acceptable-use-policy" + }` + + deletePath := "/v3/product/" + productUid + "/action/CANCEL_NOW" + + suite.mux.HandleFunc(deletePath, func(w http.ResponseWriter, r *http.Request) { + suite.testMethod(r, http.MethodPost) + fmt.Fprint(w, deleteBlob) + }) + + err := vxcSvc.DeleteVXC(ctx, productUid, req) + + suite.NoError(err, "expected no error when deleting Transit VXC with DeleteNow=true") +} + +// TestIsTransitVXC tests the isTransitVXC helper function with various VXC types +func (suite *VXCClientTestSuite) TestIsTransitVXC() { + // Test nil VXC + suite.False(isTransitVXC(nil), "nil VXC should return false") + + // Test VXC with no resources + vxc := &VXC{} + suite.False(isTransitVXC(vxc), "VXC with no resources should return false") + + // Test VXC with no CSP connection + vxc.Resources = &VXCResources{} + suite.False(isTransitVXC(vxc), "VXC with no CSP connection should return false") + + // Test Transit VXC + transitVXC := &VXC{ + Resources: &VXCResources{ + CSPConnection: &CSPConnection{ + CSPConnection: []CSPConnectionConfig{ + CSPConnectionTransit{ + ConnectType: "TRANSIT", + ResourceName: "csp_connection", + ResourceType: "csp_connection", + CustomerIP4Address: "203.0.113.1/30", + IPv4GatewayAddress: "203.0.113.2", + }, + }, + }, + }, + } + suite.True(isTransitVXC(transitVXC), "VXC with TRANSIT connectType should return true") + + // Test AWS VXC (non-Transit) + awsVXC := &VXC{ + Resources: &VXCResources{ + CSPConnection: &CSPConnection{ + CSPConnection: []CSPConnectionConfig{ + CSPConnectionAWS{ + ConnectType: "AWS", + ResourceName: "csp_connection", + ResourceType: "csp_connection", + }, + }, + }, + }, + } + suite.False(isTransitVXC(awsVXC), "VXC with AWS connectType should return false") + + // Test Azure VXC (non-Transit) + azureVXC := &VXC{ + Resources: &VXCResources{ + CSPConnection: &CSPConnection{ + CSPConnection: []CSPConnectionConfig{ + CSPConnectionAzure{ + ConnectType: "AZURE", + ResourceName: "csp_connection", + ResourceType: "csp_connection", + }, + }, + }, + }, + } + suite.False(isTransitVXC(azureVXC), "VXC with AZURE connectType should return false") + + // Test Google VXC (non-Transit) + googleVXC := &VXC{ + Resources: &VXCResources{ + CSPConnection: &CSPConnection{ + CSPConnection: []CSPConnectionConfig{ + CSPConnectionGoogle{ + ConnectType: "GOOGLE", + ResourceName: "csp_connection", + ResourceType: "csp_connection", + }, + }, + }, + }, + } + suite.False(isTransitVXC(googleVXC), "VXC with GOOGLE connectType should return false") +} + // TestDeleteVXC tests to see if the custom unmarshalling works for decommed VXCs. func (suite *VXCClientTestSuite) TestDecomissionedVXCMarshal() { ctx := context.Background()