Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 24 additions & 29 deletions internal/api/handler_purchases_revoke.go
Original file line number Diff line number Diff line change
Expand Up @@ -496,8 +496,11 @@ func (h *Handler) calculateAzureRevoke(ctx context.Context, req *events.LambdaFu
}

refundAmount, refundCurrency := extractAzureRefundQuote(calcResp)
if refundAmount == nil || strings.TrimSpace(refundCurrency) == "" {
return nil, NewClientError(422, "Azure returned no refund amount or currency for this reservation; cannot quote a refund, contact Azure Support to request one")
}
return &revokeQuoteResult{
RefundAmount: refundAmount,
RefundAmount: *refundAmount,
RefundCurrency: refundCurrency,
QuotedAt: time.Now().UTC().Format(time.RFC3339),
}, nil
Expand Down Expand Up @@ -575,20 +578,24 @@ func azureRevokeWindowAndIDs(record *config.PurchaseHistoryRecord) (string, stri
}

// extractAzureRefundQuote pulls the refund amount and currency out of a
// CalculateRefund response, guarding every nil pointer in the chain. Returns
// zero values when the response carries no billing-refund amount.
func extractAzureRefundQuote(resp armreservations.CalculateRefundClientPostResponse) (float64, string) { //nolint:gocritic // unnamedResult: return names would conflict with body locals
var refundAmount float64
var refundCurrency string
if resp.Properties != nil && resp.Properties.BillingRefundAmount != nil {
if resp.Properties.BillingRefundAmount.Amount != nil {
refundAmount = *resp.Properties.BillingRefundAmount.Amount
}
if resp.Properties.BillingRefundAmount.CurrencyCode != nil {
refundCurrency = *resp.Properties.BillingRefundAmount.CurrencyCode
}
// CalculateRefund response, guarding every nil pointer in the chain. A missing
// amount is returned as nil and a missing currency as "", never as zero, so
// callers can tell "Azure quoted nothing" from "Azure quoted 0".
func extractAzureRefundQuote(resp armreservations.CalculateRefundClientPostResponse) (*float64, string) { //nolint:gocritic // unnamedResult: return names would conflict with body locals
if resp.Properties == nil || resp.Properties.BillingRefundAmount == nil {
return nil, ""
}
price := resp.Properties.BillingRefundAmount
var amount *float64
if price.Amount != nil {
v := *price.Amount
amount = &v
}
return refundAmount, refundCurrency
var currency string
if price.CurrencyCode != nil {
currency = *price.CurrencyCode
}
return amount, currency
}

// revokeAzurePurchase handles Azure reservation returns via the Azure
Expand Down Expand Up @@ -787,22 +794,10 @@ func (h *Handler) azureCalculateRefund(ctx context.Context, calcClient azureCalc
}

var sessionID string
var calcRefundAmount *float64
var calcRefundCurrency string
if calcResp.Properties != nil {
if calcResp.Properties.SessionID != nil {
sessionID = *calcResp.Properties.SessionID
}
if calcResp.Properties.BillingRefundAmount != nil {
if calcResp.Properties.BillingRefundAmount.Amount != nil {
v := *calcResp.Properties.BillingRefundAmount.Amount
calcRefundAmount = &v
}
if calcResp.Properties.BillingRefundAmount.CurrencyCode != nil {
calcRefundCurrency = *calcResp.Properties.BillingRefundAmount.CurrencyCode
}
}
if calcResp.Properties != nil && calcResp.Properties.SessionID != nil {
sessionID = *calcResp.Properties.SessionID
}
calcRefundAmount, calcRefundCurrency := extractAzureRefundQuote(calcResp)
return sessionID, calcRefundAmount, calcRefundCurrency, nil
}

Expand Down
132 changes: 132 additions & 0 deletions internal/api/handler_purchases_revoke_quote_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
package api

import (
"context"
"encoding/json"
"testing"

"github.com/Azure/azure-sdk-for-go/sdk/azcore"
"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/reservations/armreservations"
"github.com/aws/aws-lambda-go/events"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
)

// Issue #447: the quote endpoint must not fabricate a 0 / empty refund when
// Azure omits the amount or currency; it must fail with a 422 instead.

func quoteResponse(price *armreservations.Price) armreservations.CalculateRefundClientPostResponse {
sessionID := "s1"
return armreservations.CalculateRefundClientPostResponse{
CalculateRefundResponse: armreservations.CalculateRefundResponse{
Properties: &armreservations.RefundResponseProperties{
SessionID: &sessionID,
BillingRefundAmount: price,
},
},
}
}

func quoteHandlerRequest(purchaseID string) *events.LambdaFunctionURLRequest {
return &events.LambdaFunctionURLRequest{
Headers: map[string]string{"authorization": "Bearer tok"},
RequestContext: events.LambdaFunctionURLRequestContext{
HTTP: events.LambdaFunctionURLRequestContextHTTPDescription{
Method: "GET",
Path: "/api/purchases/" + purchaseID + "/revoke/calculate",
},
},
}
}

func TestHandleRequest_RevokeCalculate_AzureQuote(t *testing.T) {
amount := 75.25
currency := "USD"
empty := ""

cases := []struct {
name string
resp armreservations.CalculateRefundClientPostResponse
wantStatus int
wantErr string
}{
{
name: "complete quote",
resp: quoteResponse(&armreservations.Price{Amount: &amount, CurrencyCode: &currency}),
wantStatus: 200,
},
{
name: "zero amount is a real quote",
resp: quoteResponse(&armreservations.Price{Amount: new(float64), CurrencyCode: &currency}),
wantStatus: 200,
},
{
name: "no billing refund amount",
resp: quoteResponse(nil),
wantStatus: 422,
wantErr: "no refund amount or currency",
},
{
name: "no properties",
resp: armreservations.CalculateRefundClientPostResponse{},
wantStatus: 422,
wantErr: "no refund amount or currency",
},
{
name: "amount missing",
resp: quoteResponse(&armreservations.Price{CurrencyCode: &currency}),
wantStatus: 422,
wantErr: "no refund amount or currency",
},
{
name: "currency missing",
resp: quoteResponse(&armreservations.Price{Amount: &amount}),
wantStatus: 422,
wantErr: "no refund amount or currency",
},
{
name: "currency blank",
resp: quoteResponse(&armreservations.Price{Amount: &amount, CurrencyCode: &empty}),
wantStatus: 422,
wantErr: "no refund amount or currency",
},
}

for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
ctx := context.Background()
mockStore := new(MockConfigStore)
mockAuth := new(MockAuthService)
mockAuth.On("ValidateSession", mock.Anything, "tok").Return(revokeAdminSession(), nil)
r := armReservationRecord()
mockStore.On("GetPurchaseHistoryByPurchaseID", mock.Anything, r.PurchaseID).Return(r, nil)

h := &Handler{
config: mockStore,
auth: mockAuth,
azureRevokeFactory: &azureRevokeClientFactory{
newCredential: func() (azcore.TokenCredential, error) { return nil, nil },
newCalculateRefundClient: func(azcore.TokenCredential) (azureCalculateRefundClient, error) {
return &stubCalcRefundClient{resp: tc.resp}, nil
},
newReturnClient: func(azcore.TokenCredential) (azureReturnClient, error) {
return &stubReturnClient{}, nil
},
},
}

resp, err := h.HandleRequest(ctx, quoteHandlerRequest(r.PurchaseID))
require.NoError(t, err)
require.Equal(t, tc.wantStatus, resp.StatusCode, resp.Body)
if tc.wantErr != "" {
assert.Contains(t, resp.Body, tc.wantErr)
assert.NotContains(t, resp.Body, "refund_amount")
return
}
var body map[string]any
require.NoError(t, json.Unmarshal([]byte(resp.Body), &body))
assert.Equal(t, "USD", body["refund_currency"])
})
}
}
Loading