From f6cb6456775bb4ca2fd76c9eaadcc96bd8174114 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Wed, 4 Nov 2026 20:10:48 +0100 Subject: [PATCH 1/7] refactor(tools): move service-agnostic upfront helpers to ri_upfront.go Pure move out of aws_ec2_ri_upfront.go so the RDS and simple RI tools share a file that is not named after EC2. The EC2 wrappers stay put. No behavior change. Refs #129 Co-Authored-By: Claude Opus 5.5 --- tools/aws_ec2_ri_upfront.go | 103 --------------------------------- tools/ri_upfront.go | 111 ++++++++++++++++++++++++++++++++++++ 2 files changed, 111 insertions(+), 103 deletions(-) create mode 100644 tools/ri_upfront.go diff --git a/tools/aws_ec2_ri_upfront.go b/tools/aws_ec2_ri_upfront.go index 4b8ca6a..3dc6693 100644 --- a/tools/aws_ec2_ri_upfront.go +++ b/tools/aws_ec2_ri_upfront.go @@ -4,52 +4,10 @@ import ( "context" "errors" "fmt" - "math" - "strings" "github.com/LeanerCloud/cloud-commitments-go/pkg/common" ) -// approvedCurrency is the only currency the approved-upfront guard -// (cloud-commitments-go#611) can compare an offering against. -const approvedCurrency = "USD" - -// upfrontQuote is what a partial- or all-upfront RI dry run reports: the -// offering's total upfront (FixedPrice x count) and its currency. The caller -// copies both into approved_upfront/approved_currency on the confirming call, -// and the purchase is refused if the offering it would buy then costs more -// than that figure plus go#611's tolerance. -type upfrontQuote struct { - currency string - total float64 -} - -// requiresApprovedUpfront reports whether a confirmed purchase with this -// payment option must carry an approved upfront. Everything except -// no-upfront does, so a payment option added later fails closed (it needs a -// figure) instead of silently skipping the guard. -func requiresApprovedUpfront(paymentOption string) bool { - return paymentOption != string(PaymentOptionNoUpfront) -} - -// validateApprovedFigures checks approved_upfront and approved_currency in -// isolation: when present, the upfront must be a finite amount above zero -// (go#611 skips its check for zero or less, so accepting one would buy -// unguarded) and the currency must be exactly USD. service only words the error. -func validateApprovedFigures(upfront *float64, currency, service string) error { - if currency != "" && currency != approvedCurrency { - return fmt.Errorf("invalid approved_currency %q: must be %s, the only currency the %s price check supports", - currency, approvedCurrency, service) - } - if upfront == nil { - return nil - } - if v := *upfront; math.IsNaN(v) || math.IsInf(v, 0) || v <= 0 { - return fmt.Errorf("invalid approved_upfront %v: must be the positive amount from the dry run's approved_upfront", v) - } - return nil -} - // applyApprovedUpfront validates the approved-upfront arguments and, for a // confirmed real purchase of a partial- or all-upfront RI, copies them into // rec.CommitmentCost/CommitmentCurrency so the provider's go#611 guard @@ -60,32 +18,6 @@ func applyApprovedUpfront(rec *common.Recommendation, args ec2RIPurchaseArgs, co return applyApprovedFigures(rec, args.ApprovedUpfront, args.ApprovedCurrency, "EC2", confirmedPurchase) } -// applyApprovedFigures is applyApprovedUpfront for any service. -func applyApprovedFigures(rec *common.Recommendation, upfront *float64, currency, service string, confirmedPurchase bool) error { - if err := validateApprovedFigures(upfront, currency, service); err != nil { - return err - } - supplied := upfront != nil || currency != "" - if !requiresApprovedUpfront(rec.PaymentOption) { - if supplied { - return fmt.Errorf("approved_upfront and approved_currency apply only to %s and %s purchases; omit them for payment_option=%s", - PaymentOptionPartialUpfront, PaymentOptionAllUpfront, rec.PaymentOption) - } - return nil - } - if !confirmedPurchase { - return nil - } - if upfront == nil || currency == "" { - return fmt.Errorf("refusing real purchase: a payment_option=%s purchase needs approved_upfront and approved_currency; "+ - "run this call with dry_run=true first and copy approved_upfront and approved_currency from its response into this call", - rec.PaymentOption) - } - rec.CommitmentCost = *upfront - rec.CommitmentCurrency = currency - return nil -} - // previewUpfront prices the offering for a partial- or all-upfront dry run. // It returns nil (and contacts nothing) for a real purchase or a no-upfront // dry run. The lookup is read-only (DescribeReservedInstancesOfferings) and @@ -110,42 +42,7 @@ func (t *awsEC2RIPurchaseTool) previewUpfront(ctx context.Context, args ec2RIPur }) } -// previewUpfrontQuote is previewUpfront for any service: price resolves the -// client and fetches the offering, and must word its own errors. -func previewUpfrontQuote(ctx context.Context, rec common.Recommendation, dryRun bool, service string, price func(context.Context) (*common.OfferingDetails, error)) (*upfrontQuote, error) { - if !dryRun || !requiresApprovedUpfront(rec.PaymentOption) { - return nil, nil - } - od, err := price(ctx) - if err != nil { - return nil, err - } - return quoteUpfront(od, rec, service) -} - // quoteFromOffering is quoteUpfront for EC2. func quoteFromOffering(od *common.OfferingDetails, rec common.Recommendation) (*upfrontQuote, error) { return quoteUpfront(od, rec, "EC2") } - -// quoteUpfront turns the per-reservation offering price into the -// total upfront for rec.Count, rounded to cents. An upfront offering with -// no positive upfront, or one not priced in USD, cannot produce a figure -// the confirming call could pass, so the dry run fails instead. -func quoteUpfront(od *common.OfferingDetails, rec common.Recommendation, service string) (*upfrontQuote, error) { - if od == nil { - return nil, fmt.Errorf("dry run: the provider returned no %s offering details", service) - } - unit := od.UpfrontCost - if math.IsNaN(unit) || math.IsInf(unit, 0) || unit <= 0 { - return nil, fmt.Errorf("dry run: the %s %s offering reports upfront %v; expected a positive amount", rec.PaymentOption, service, unit) - } - if cur := strings.TrimSpace(od.Currency); cur != approvedCurrency { - return nil, fmt.Errorf("dry run: the %s offering is priced in %q; only %s purchases can be approved", service, od.Currency, approvedCurrency) - } - total := math.Round(unit*float64(rec.Count)*100) / 100 - if math.IsInf(total, 0) || total <= 0 { - return nil, fmt.Errorf("dry run: the %s offering's total upfront %v is not a usable amount", service, total) - } - return &upfrontQuote{total: total, currency: approvedCurrency}, nil -} diff --git a/tools/ri_upfront.go b/tools/ri_upfront.go new file mode 100644 index 0000000..07c838a --- /dev/null +++ b/tools/ri_upfront.go @@ -0,0 +1,111 @@ +package tools + +import ( + "context" + "fmt" + "math" + "strings" + + "github.com/LeanerCloud/cloud-commitments-go/pkg/common" +) + +// approvedCurrency is the only currency the approved-upfront guard +// (cloud-commitments-go#611) can compare an offering against. +const approvedCurrency = "USD" + +// upfrontQuote is what a partial- or all-upfront RI dry run reports: the +// offering's total upfront (FixedPrice x count) and its currency. The caller +// copies both into approved_upfront/approved_currency on the confirming call, +// and the purchase is refused if the offering it would buy then costs more +// than that figure plus go#611's tolerance. +type upfrontQuote struct { + currency string + total float64 +} + +// requiresApprovedUpfront reports whether a confirmed purchase with this +// payment option must carry an approved upfront. Everything except +// no-upfront does, so a payment option added later fails closed (it needs a +// figure) instead of silently skipping the guard. +func requiresApprovedUpfront(paymentOption string) bool { + return paymentOption != string(PaymentOptionNoUpfront) +} + +// validateApprovedFigures checks approved_upfront and approved_currency in +// isolation: when present, the upfront must be a finite amount above zero +// (go#611 skips its check for zero or less, so accepting one would buy +// unguarded) and the currency must be exactly USD. service only words the error. +func validateApprovedFigures(upfront *float64, currency, service string) error { + if currency != "" && currency != approvedCurrency { + return fmt.Errorf("invalid approved_currency %q: must be %s, the only currency the %s price check supports", + currency, approvedCurrency, service) + } + if upfront == nil { + return nil + } + if v := *upfront; math.IsNaN(v) || math.IsInf(v, 0) || v <= 0 { + return fmt.Errorf("invalid approved_upfront %v: must be the positive amount from the dry run's approved_upfront", v) + } + return nil +} + +// applyApprovedFigures is applyApprovedUpfront for any service. +func applyApprovedFigures(rec *common.Recommendation, upfront *float64, currency, service string, confirmedPurchase bool) error { + if err := validateApprovedFigures(upfront, currency, service); err != nil { + return err + } + supplied := upfront != nil || currency != "" + if !requiresApprovedUpfront(rec.PaymentOption) { + if supplied { + return fmt.Errorf("approved_upfront and approved_currency apply only to %s and %s purchases; omit them for payment_option=%s", + PaymentOptionPartialUpfront, PaymentOptionAllUpfront, rec.PaymentOption) + } + return nil + } + if !confirmedPurchase { + return nil + } + if upfront == nil || currency == "" { + return fmt.Errorf("refusing real purchase: a payment_option=%s purchase needs approved_upfront and approved_currency; "+ + "run this call with dry_run=true first and copy approved_upfront and approved_currency from its response into this call", + rec.PaymentOption) + } + rec.CommitmentCost = *upfront + rec.CommitmentCurrency = currency + return nil +} + +// previewUpfrontQuote is previewUpfront for any service: price resolves the +// client and fetches the offering, and must word its own errors. +func previewUpfrontQuote(ctx context.Context, rec common.Recommendation, dryRun bool, service string, price func(context.Context) (*common.OfferingDetails, error)) (*upfrontQuote, error) { + if !dryRun || !requiresApprovedUpfront(rec.PaymentOption) { + return nil, nil + } + od, err := price(ctx) + if err != nil { + return nil, err + } + return quoteUpfront(od, rec, service) +} + +// quoteUpfront turns the per-reservation offering price into the +// total upfront for rec.Count, rounded to cents. An upfront offering with +// no positive upfront, or one not priced in USD, cannot produce a figure +// the confirming call could pass, so the dry run fails instead. +func quoteUpfront(od *common.OfferingDetails, rec common.Recommendation, service string) (*upfrontQuote, error) { + if od == nil { + return nil, fmt.Errorf("dry run: the provider returned no %s offering details", service) + } + unit := od.UpfrontCost + if math.IsNaN(unit) || math.IsInf(unit, 0) || unit <= 0 { + return nil, fmt.Errorf("dry run: the %s %s offering reports upfront %v; expected a positive amount", rec.PaymentOption, service, unit) + } + if cur := strings.TrimSpace(od.Currency); cur != approvedCurrency { + return nil, fmt.Errorf("dry run: the %s offering is priced in %q; only %s purchases can be approved", service, od.Currency, approvedCurrency) + } + total := math.Round(unit*float64(rec.Count)*100) / 100 + if math.IsInf(total, 0) || total <= 0 { + return nil, fmt.Errorf("dry run: the %s offering's total upfront %v is not a usable amount", service, total) + } + return &upfrontQuote{total: total, currency: approvedCurrency}, nil +} From 72f5d6b62e6288f1342edfb2c711a41eb16fdb97 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Wed, 4 Nov 2026 20:15:25 +0100 Subject: [PATCH 2/7] fix(tools): audit a dry run that cannot price the upfront offering A partial- or all-upfront dry run whose offering lookup (or quote) failed returned from the handler before ExecutePurchase, so no audit record was written, contradicting the README promise that previews are recorded. The EC2, RDS, OpenSearch, Redshift and MemoryDB handlers now build the PurchaseRequest once, before the upfront helpers, and previewUpfrontQuote records the failed preview as status error, dry_run true. A missing approved figure on a confirmed purchase stays an unaudited argument error (README Audit log). Refs #129 Co-Authored-By: Claude Opus 5.5 --- tools/aws_ec2_ri.go | 27 +++--- tools/aws_ec2_ri_upfront.go | 6 +- tools/aws_rds_ri.go | 29 +++---- tools/aws_simple_ri.go | 29 +++---- tools/ri_upfront.go | 17 +++- tools/upfront_audit_test.go | 160 ++++++++++++++++++++++++++++++++++++ 6 files changed, 221 insertions(+), 47 deletions(-) create mode 100644 tools/upfront_audit_test.go diff --git a/tools/aws_ec2_ri.go b/tools/aws_ec2_ri.go index bef4240..6e6852b 100644 --- a/tools/aws_ec2_ri.go +++ b/tools/aws_ec2_ri.go @@ -112,23 +112,12 @@ func (t *awsEC2RIPurchaseTool) handle(ctx context.Context, _ *mcp.CallToolReques if err != nil { return nil, PurchaseResponse{}, err } - // A confirmed upfront purchase carries the dry run's figure into - // rec.CommitmentCost so the provider's go#611 price guard runs. - err = applyApprovedUpfront(&rec, args, !dryRun && confirm) - if err != nil { - return nil, PurchaseResponse{}, err - } - quote, err := t.previewUpfront(ctx, args, region, rec, string(offeringClass), dryRun) - if err != nil { - return nil, PurchaseResponse{}, err - } // The audit record has no offering-class field, so record it in the raw // request payload alongside the other purchase parameters. if rec.RawRecommendation, err = json.Marshal(map[string]string{"offering_class": string(offeringClass)}); err != nil { return nil, PurchaseResponse{}, fmt.Errorf("encode offering_class for audit: %w", err) } - - resp, err := ExecutePurchase(ctx, PurchaseRequest{ + req := PurchaseRequest{ Region: region, Recommendation: rec, DryRun: dryRun, @@ -137,7 +126,19 @@ func (t *awsEC2RIPurchaseTool) handle(ctx context.Context, _ *mcp.CallToolReques Nonce: args.IdempotencyNonce, OfferingClass: string(offeringClass), CredentialScope: requestCredentialScope(args.AWSProfile, dryRun, "AWS_PROFILE"), - }) + } + // A confirmed upfront purchase carries the dry run's figure into + // the recommendation so the provider's go#611 price guard runs. + err = applyApprovedUpfront(&req.Recommendation, args, !dryRun && confirm) + if err != nil { + return nil, PurchaseResponse{}, err + } + quote, err := t.previewUpfront(ctx, args, region, &req, string(offeringClass)) + if err != nil { + return nil, PurchaseResponse{}, err + } + + resp, err := ExecutePurchase(ctx, req) if err != nil { return nil, PurchaseResponse{}, err } diff --git a/tools/aws_ec2_ri_upfront.go b/tools/aws_ec2_ri_upfront.go index 3dc6693..6a740d7 100644 --- a/tools/aws_ec2_ri_upfront.go +++ b/tools/aws_ec2_ri_upfront.go @@ -22,8 +22,8 @@ func applyApprovedUpfront(rec *common.Recommendation, args ec2RIPurchaseArgs, co // It returns nil (and contacts nothing) for a real purchase or a no-upfront // dry run. The lookup is read-only (DescribeReservedInstancesOfferings) and // needs credentials, but not the real-purchase opt-in or the spend caps. -func (t *awsEC2RIPurchaseTool) previewUpfront(ctx context.Context, args ec2RIPurchaseArgs, region string, rec common.Recommendation, offeringClass string, dryRun bool) (*upfrontQuote, error) { - return previewUpfrontQuote(ctx, rec, dryRun, "EC2", func(ctx context.Context) (*common.OfferingDetails, error) { +func (t *awsEC2RIPurchaseTool) previewUpfront(ctx context.Context, args ec2RIPurchaseArgs, region string, req *PurchaseRequest, offeringClass string) (*upfrontQuote, error) { + return previewUpfrontQuote(ctx, req, "EC2", func(ctx context.Context) (*common.OfferingDetails, error) { client, err := t.resolveClient(args, region)(ctx) if err != nil { return nil, fmt.Errorf("dry run: resolve the AWS EC2 client to price the offering: %w", err) @@ -34,7 +34,7 @@ func (t *awsEC2RIPurchaseTool) previewUpfront(ctx context.Context, args ec2RIPur if !ok { return nil, errors.New("dry run: the EC2 client cannot price an offering class (GetOfferingDetailsForClass missing, cloud-commitments-go#574)") } - od, err := pricer.GetOfferingDetailsForClass(ctx, rec, offeringClass) + od, err := pricer.GetOfferingDetailsForClass(ctx, req.Recommendation, offeringClass) if err != nil { return nil, fmt.Errorf("dry run: cannot fetch the EC2 offering's upfront price: %w", err) } diff --git a/tools/aws_rds_ri.go b/tools/aws_rds_ri.go index 666965f..128e9cd 100644 --- a/tools/aws_rds_ri.go +++ b/tools/aws_rds_ri.go @@ -99,19 +99,28 @@ func (t *awsRDSRIPurchaseTool) handle(ctx context.Context, _ *mcp.CallToolReques return nil, PurchaseResponse{}, err } + resolve := t.resolveClient(args, region) + req := PurchaseRequest{ + Region: region, + Recommendation: rec, + DryRun: dryRun, + Confirm: confirm, + ResolveClient: resolve, + Nonce: args.IdempotencyNonce, + CredentialScope: requestCredentialScope(args.AWSProfile, dryRun, "AWS_PROFILE"), + } // A confirmed upfront purchase carries the dry run's figure into - // rec.CommitmentCost so the provider's go#611 price guard runs. - err = applyApprovedFigures(&rec, args.ApprovedUpfront, args.ApprovedCurrency, "RDS", !dryRun && confirm) + // the recommendation so the provider's go#611 price guard runs. + err = applyApprovedFigures(&req.Recommendation, args.ApprovedUpfront, args.ApprovedCurrency, "RDS", !dryRun && confirm) if err != nil { return nil, PurchaseResponse{}, err } - resolve := t.resolveClient(args, region) - quote, err := previewUpfrontQuote(ctx, rec, dryRun, "RDS", func(ctx context.Context) (*common.OfferingDetails, error) { + quote, err := previewUpfrontQuote(ctx, &req, "RDS", func(ctx context.Context) (*common.OfferingDetails, error) { client, resolveErr := resolve(ctx) if resolveErr != nil { return nil, fmt.Errorf("dry run: resolve the AWS RDS client to price the offering: %w", resolveErr) } - od, priceErr := client.GetOfferingDetails(ctx, rec) + od, priceErr := client.GetOfferingDetails(ctx, req.Recommendation) if priceErr != nil { return nil, fmt.Errorf("dry run: cannot fetch the RDS offering's upfront price: %w", priceErr) } @@ -121,15 +130,7 @@ func (t *awsRDSRIPurchaseTool) handle(ctx context.Context, _ *mcp.CallToolReques return nil, PurchaseResponse{}, err } - resp, err := ExecutePurchase(ctx, PurchaseRequest{ - Region: region, - Recommendation: rec, - DryRun: dryRun, - Confirm: confirm, - ResolveClient: resolve, - Nonce: args.IdempotencyNonce, - CredentialScope: requestCredentialScope(args.AWSProfile, dryRun, "AWS_PROFILE"), - }) + resp, err := ExecutePurchase(ctx, req) if err != nil { return nil, PurchaseResponse{}, err } diff --git a/tools/aws_simple_ri.go b/tools/aws_simple_ri.go index 79dee7c..01e1e90 100644 --- a/tools/aws_simple_ri.go +++ b/tools/aws_simple_ri.go @@ -158,20 +158,29 @@ func (t *simpleAWSRIPurchaseTool) handle(ctx context.Context, _ *mcp.CallToolReq return nil, PurchaseResponse{}, err } + resolve := t.resolveClient(args, region) + req := PurchaseRequest{ + Region: region, + Recommendation: rec, + DryRun: dryRun, + Confirm: confirm, + ResolveClient: resolve, + Nonce: args.IdempotencyNonce, + CredentialScope: requestCredentialScope(args.AWSProfile, dryRun, "AWS_PROFILE"), + } // A confirmed upfront purchase carries the dry run's figure into - // rec.CommitmentCost so the provider's go#611 price guard runs. + // the recommendation so the provider's go#611 price guard runs. label := t.spec.displayName - err = applyApprovedFigures(&rec, args.ApprovedUpfront, args.ApprovedCurrency, label, !dryRun && confirm) + err = applyApprovedFigures(&req.Recommendation, args.ApprovedUpfront, args.ApprovedCurrency, label, !dryRun && confirm) if err != nil { return nil, PurchaseResponse{}, err } - resolve := t.resolveClient(args, region) - quote, err := previewUpfrontQuote(ctx, rec, dryRun, label, func(ctx context.Context) (*common.OfferingDetails, error) { + quote, err := previewUpfrontQuote(ctx, &req, label, func(ctx context.Context) (*common.OfferingDetails, error) { client, resolveErr := resolve(ctx) if resolveErr != nil { return nil, fmt.Errorf("dry run: resolve the AWS %s client to price the offering: %w", label, resolveErr) } - od, priceErr := client.GetOfferingDetails(ctx, rec) + od, priceErr := client.GetOfferingDetails(ctx, req.Recommendation) if priceErr != nil { return nil, fmt.Errorf("dry run: cannot fetch the %s offering's upfront price: %w", label, priceErr) } @@ -181,15 +190,7 @@ func (t *simpleAWSRIPurchaseTool) handle(ctx context.Context, _ *mcp.CallToolReq return nil, PurchaseResponse{}, err } - resp, err := ExecutePurchase(ctx, PurchaseRequest{ - Region: region, - Recommendation: rec, - DryRun: dryRun, - Confirm: confirm, - ResolveClient: resolve, - Nonce: args.IdempotencyNonce, - CredentialScope: requestCredentialScope(args.AWSProfile, dryRun, "AWS_PROFILE"), - }) + resp, err := ExecutePurchase(ctx, req) if err != nil { return nil, PurchaseResponse{}, err } diff --git a/tools/ri_upfront.go b/tools/ri_upfront.go index 07c838a..354570d 100644 --- a/tools/ri_upfront.go +++ b/tools/ri_upfront.go @@ -77,15 +77,26 @@ func applyApprovedFigures(rec *common.Recommendation, upfront *float64, currency // previewUpfrontQuote is previewUpfront for any service: price resolves the // client and fetches the offering, and must word its own errors. -func previewUpfrontQuote(ctx context.Context, rec common.Recommendation, dryRun bool, service string, price func(context.Context) (*common.OfferingDetails, error)) (*upfrontQuote, error) { - if !dryRun || !requiresApprovedUpfront(rec.PaymentOption) { +// +// A dry run that cannot price the offering (the lookup fails or yields no +// usable USD figure) returns before ExecutePurchase +// would audit it, so it is recorded here as an error preview; the caller +// still gets the error. A real purchase writes nothing here. +func previewUpfrontQuote(ctx context.Context, req *PurchaseRequest, service string, price func(context.Context) (*common.OfferingDetails, error)) (*upfrontQuote, error) { + rec := req.Recommendation + if !req.DryRun || !requiresApprovedUpfront(rec.PaymentOption) { return nil, nil } od, err := price(ctx) + var quote *upfrontQuote + if err == nil { + quote, err = quoteUpfront(od, rec, service) + } if err != nil { + recordPurchaseAudit(rec, req.CredentialScope, common.PurchaseResult{DryRun: true, Error: err}, auditStatusError, true) return nil, err } - return quoteUpfront(od, rec, service) + return quote, nil } // quoteUpfront turns the per-reservation offering price into the diff --git a/tools/upfront_audit_test.go b/tools/upfront_audit_test.go new file mode 100644 index 0000000..b844496 --- /dev/null +++ b/tools/upfront_audit_test.go @@ -0,0 +1,160 @@ +package tools + +import ( + "context" + "encoding/json" + "errors" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/LeanerCloud/cloud-commitments-go/pkg/common" +) + +// upfrontAuditRun is one handler invocation for a given scenario: the error +// it returned and how many provider purchase calls it made. +type upfrontAuditRun func(t *testing.T, s upfrontAuditScenario) (purchases int, err error) + +type upfrontAuditScenario int + +const ( + scenarioPreviewPricingFails upfrontAuditScenario = iota + scenarioPreviewOK + scenarioRealPurchase + scenarioConfirmedMissingFigures +) + +var errPricingDown = errors.New("pricing backend down") + +func upfrontAuditTools() map[string]upfrontAuditRun { + tools := map[string]upfrontAuditRun{ + "ec2": func(t *testing.T, s upfrontAuditScenario) (int, error) { + api := newPricedEC2API(PaymentOptionAllUpfront, 500) + if s == scenarioPreviewPricingFails { + api.describeErr = errPricingDown + } + tool, _ := ec2ToolOverFakeAPI(api) + args := upfrontEC2Args(PaymentOptionAllUpfront, "audit-"+t.Name()) + switch s { + case scenarioRealPurchase: + args = confirmed(args, floatPtr(1500), "USD") + case scenarioConfirmedMissingFigures: + args = confirmed(args, nil, "") + } + _, _, err := tool.handle(context.Background(), nil, args) + return api.purchaseCalls, err + }, + "rds": func(t *testing.T, s upfrontAuditScenario) (int, error) { + fake := &fakeServiceClient{offering: upfrontOffering(500, "USD"), + purchaseResult: common.PurchaseResult{Success: true, CommitmentID: "rds-ri"}} + if s == scenarioPreviewPricingFails { + fake.offeringErr = errPricingDown + } + tool, _ := rdsToolOver(fake) + args := upfrontRDSArgs(PaymentOptionAllUpfront, "audit-"+t.Name()) + switch s { + case scenarioRealPurchase: + args = confirmedRDS(args, floatPtr(1000), "USD") + case scenarioConfirmedMissingFigures: + args = confirmedRDS(args, nil, "") + } + _, _, err := tool.handle(context.Background(), nil, args) + return fake.purchaseCalls, err + }, + } + for product, ctor := range simpleToolConstructors() { + tools[product] = func(t *testing.T, s upfrontAuditScenario) (int, error) { + fake := &fakeServiceClient{offering: upfrontOffering(500, "USD"), + purchaseResult: common.PurchaseResult{Success: true, CommitmentID: "res-1"}} + if s == scenarioPreviewPricingFails { + fake.offeringErr = errPricingDown + } + tool, _ := simpleToolOver(ctor, fake) + args := upfrontSimpleArgs(PaymentOptionAllUpfront, "audit-"+product+t.Name()) + switch s { + case scenarioRealPurchase: + args = confirmedSimple(args, floatPtr(1000), "USD") + case scenarioConfirmedMissingFigures: + args = confirmedSimple(args, nil, "") + } + _, _, err := tool.handle(context.Background(), nil, args) + return fake.purchaseCalls, err + } + } + return tools +} + +func auditRecords(t *testing.T, path string) []map[string]any { + t.Helper() + lines := readAuditLines(t, path) + out := make([]map[string]any, 0, len(lines)) + for _, l := range lines { + var m map[string]any + require.NoError(t, json.Unmarshal([]byte(l), &m)) + out = append(out, m) + } + return out +} + +// Not parallel: t.Setenv pins the audit log and Redshift's budget state. +func TestUpfrontAuditRecords(t *testing.T) { + for name, run := range upfrontAuditTools() { + // previewRecord is the successful-preview record, the baseline the + // failed-pricing record must match except for status and error. + var previewRecord map[string]any + pin := func(t *testing.T) string { + path := filepath.Join(t.TempDir(), "audit.jsonl") + t.Setenv(EnvAuditLog, path) + t.Setenv(EnvBudgetState, filepath.Join(t.TempDir(), "budget.json")) + return path + } + + t.Run(name+"/successful preview writes one record", func(t *testing.T) { + path := pin(t) + purchases, err := run(t, scenarioPreviewOK) + require.NoError(t, err) + assert.Zero(t, purchases) + recs := auditRecords(t, path) + require.Len(t, recs, 1) + previewRecord = recs[0] + }) + + t.Run(name+"/failed preview pricing writes one error record", func(t *testing.T) { + path := pin(t) + purchases, err := run(t, scenarioPreviewPricingFails) + require.Error(t, err) + assert.Zero(t, purchases, "a dry run must never buy") + recs := auditRecords(t, path) + require.Len(t, recs, 1, "README: every purchase attempt, previews included, writes an audit record") + got := recs[0] + assert.Equal(t, auditStatusError, got["status"]) + assert.Equal(t, true, got["dry_run"]) + assert.Contains(t, got["error_message"], errPricingDown.Error()) + if previewRecord != nil { + for _, k := range []string{"timestamp", "status", "error_message"} { + delete(got, k) + delete(previewRecord, k) + } + assert.Equal(t, previewRecord, got, "only status and error_message differ from a successful preview") + } + }) + + t.Run(name+"/real purchase writes one record", func(t *testing.T) { + path := pin(t) + purchases, err := run(t, scenarioRealPurchase) + require.NoError(t, err) + assert.Equal(t, 1, purchases) + require.Len(t, auditRecords(t, path), 1) + }) + + t.Run(name+"/confirmed purchase without figures writes nothing", func(t *testing.T) { + path := pin(t) + purchases, err := run(t, scenarioConfirmedMissingFigures) + require.Error(t, err) + assert.Zero(t, purchases) + assert.NoFileExists(t, path, "README: a missing approved figure is an argument error and writes nothing") + }) + } +} From de6821e79a922b18a7f84478cfff3c29a4c84e03 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Wed, 4 Nov 2026 20:15:33 +0100 Subject: [PATCH 3/7] docs: document the audited failed upfront preview Refs #129 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 3 +++ README.md | 1 + 2 files changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 47a285f..a6d356e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -137,6 +137,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). ### Fixed +- A partial- or all-upfront dry run whose offering cannot be priced now writes an + audit record (`status: "error"`, `dry_run: true`) instead of returning without + one, as the README promises for previews (Refs #129). - A real purchase the provider refuses before any purchase call (for example the EC2 approved-upfront price guard, which wraps `common.ErrRefusedBeforePurchase`) no longer stays counted in diff --git a/README.md b/README.md index da85cbf..b52e441 100644 --- a/README.md +++ b/README.md @@ -191,6 +191,7 @@ Auditing is **on by default**. When auditing is enabled and a write succeeds, ea - **Default path**: `$XDG_STATE_HOME/cudly/mcp-audit.jsonl`, falling back to `~/.local/state/cudly/mcp-audit.jsonl` when `XDG_STATE_HOME` is unset, empty, or relative. - **`CUDLY_MCP_AUDIT_LOG`** overrides the path. Setting it to an **empty or whitespace-only string disables the log entirely** -- that is the explicit opt-out; leaving the variable unset is not the same thing and still uses the default path. - A preview (`dry_run=true`) is recorded with `status: "skipped"`, `dry_run: true` -- it spent nothing, but it was still a decision worth reconstructing later. +- A partial- or all-upfront preview whose offering cannot be priced (the lookup fails, or it reports no usable USD upfront) is recorded `"error"`, `dry_run: true`, with the reason in `error_message`; it writes no `REFUSED` line, and the tool call still returns the error. - When a credential scope is supplied, `credential_scope` records that routing identifier: an AWS profile, Azure subscription, or GCP project. It identifies how CUDly selected the target; it is not a verified provider account ID. Previews may omit it because they do not require a target. - A real purchase that a gate refuses before any provider purchase call is recorded `"refused"`, with the refusal text in `error_message` and the requested provider, service, region, resource, count, term and `credential_scope` on the record. This covers real purchases disabled, the count, hourly, memory, USD and cumulative budget limits, a missing credential scope, a failed credential/client resolution, a missing `ResolveClient` (internal error) and an unpriceable offering. Each refusal writes exactly one record and one `mcp purchase REFUSED` line on stderr. Argument-validation errors (including an upfront EC2, RDS, OpenSearch, Redshift or MemoryDB RI purchase missing `approved_upfront`/`approved_currency`) and an unconfirmed `dry_run=false` call are not refusals and write nothing. A purchase the provider refuses for exceeding `approved_upfront` is recorded like any other provider error after the purchase call (`status: "error"`), and like those it counts against `CUDLY_MCP_BUDGET_USD`. - A real purchase is recorded `"success"` only when the provider reports `Success: true` with no embedded error. A failed provider call, `Success: false`, or a result containing an error is recorded as `"error"`, except a failure that wraps the library's outcome-unknown error (an EC2 or Redshift purchase whose response was lost), which is recorded as `"unknown"`: the request may have reached the cloud and the commitment may exist, so reconcile with `DescribeReservedInstances` / `DescribeReservedNodes` before retrying. The stderr line for it reads `mcp purchase OUTCOME UNKNOWN` instead of `FAILED`. From 544877a1ed4b52ec748a5229853e5570cc34a818 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Wed, 4 Nov 2026 20:17:10 +0100 Subject: [PATCH 4/7] test(tools): cover the audited upfront preview whose quote is rejected Refs #129 Co-Authored-By: Claude Opus 5.5 --- tools/upfront_audit_test.go | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tools/upfront_audit_test.go b/tools/upfront_audit_test.go index b844496..5b3d034 100644 --- a/tools/upfront_audit_test.go +++ b/tools/upfront_audit_test.go @@ -24,6 +24,7 @@ const ( scenarioPreviewOK scenarioRealPurchase scenarioConfirmedMissingFigures + scenarioQuoteRejected ) var errPricingDown = errors.New("pricing backend down") @@ -32,6 +33,9 @@ func upfrontAuditTools() map[string]upfrontAuditRun { tools := map[string]upfrontAuditRun{ "ec2": func(t *testing.T, s upfrontAuditScenario) (int, error) { api := newPricedEC2API(PaymentOptionAllUpfront, 500) + if s == scenarioQuoteRejected { + api.fixedPrice, api.standardPrice = 0, 0 + } if s == scenarioPreviewPricingFails { api.describeErr = errPricingDown } @@ -52,6 +56,9 @@ func upfrontAuditTools() map[string]upfrontAuditRun { if s == scenarioPreviewPricingFails { fake.offeringErr = errPricingDown } + if s == scenarioQuoteRejected { + fake.offering = upfrontOffering(0, "USD") + } tool, _ := rdsToolOver(fake) args := upfrontRDSArgs(PaymentOptionAllUpfront, "audit-"+t.Name()) switch s { @@ -71,6 +78,9 @@ func upfrontAuditTools() map[string]upfrontAuditRun { if s == scenarioPreviewPricingFails { fake.offeringErr = errPricingDown } + if s == scenarioQuoteRejected { + fake.offering = upfrontOffering(0, "USD") + } tool, _ := simpleToolOver(ctor, fake) args := upfrontSimpleArgs(PaymentOptionAllUpfront, "audit-"+product+t.Name()) switch s { @@ -141,6 +151,18 @@ func TestUpfrontAuditRecords(t *testing.T) { } }) + t.Run(name+"/quote rejected writes one error record", func(t *testing.T) { + path := pin(t) + purchases, err := run(t, scenarioQuoteRejected) + require.Error(t, err) + assert.Zero(t, purchases) + recs := auditRecords(t, path) + require.Len(t, recs, 1) + assert.Equal(t, auditStatusError, recs[0]["status"]) + assert.Equal(t, true, recs[0]["dry_run"]) + assert.NotEmpty(t, recs[0]["error_message"]) + }) + t.Run(name+"/real purchase writes one record", func(t *testing.T) { path := pin(t) purchases, err := run(t, scenarioRealPurchase) From 104ce3f9bd3b85e7a081bdf5d88abb7cf852a538 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Wed, 4 Nov 2026 20:24:38 +0100 Subject: [PATCH 5/7] test(tools): make the upfront audit baseline independent per subtest Refs #129 Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- tools/upfront_audit_test.go | 95 ++++++++++++++++++++----------------- 2 files changed, 53 insertions(+), 44 deletions(-) diff --git a/README.md b/README.md index b52e441..0cbb101 100644 --- a/README.md +++ b/README.md @@ -191,7 +191,7 @@ Auditing is **on by default**. When auditing is enabled and a write succeeds, ea - **Default path**: `$XDG_STATE_HOME/cudly/mcp-audit.jsonl`, falling back to `~/.local/state/cudly/mcp-audit.jsonl` when `XDG_STATE_HOME` is unset, empty, or relative. - **`CUDLY_MCP_AUDIT_LOG`** overrides the path. Setting it to an **empty or whitespace-only string disables the log entirely** -- that is the explicit opt-out; leaving the variable unset is not the same thing and still uses the default path. - A preview (`dry_run=true`) is recorded with `status: "skipped"`, `dry_run: true` -- it spent nothing, but it was still a decision worth reconstructing later. -- A partial- or all-upfront preview whose offering cannot be priced (the lookup fails, or it reports no usable USD upfront) is recorded `"error"`, `dry_run: true`, with the reason in `error_message`; it writes no `REFUSED` line, and the tool call still returns the error. +- A partial- or all-upfront preview whose offering cannot be priced (the lookup fails, or it reports no usable USD upfront) is recorded `"error"`, `dry_run: true`, with the provider's error text in `error_message`, as for other provider errors; it writes no `REFUSED` line, and the tool call still returns the error. - When a credential scope is supplied, `credential_scope` records that routing identifier: an AWS profile, Azure subscription, or GCP project. It identifies how CUDly selected the target; it is not a verified provider account ID. Previews may omit it because they do not require a target. - A real purchase that a gate refuses before any provider purchase call is recorded `"refused"`, with the refusal text in `error_message` and the requested provider, service, region, resource, count, term and `credential_scope` on the record. This covers real purchases disabled, the count, hourly, memory, USD and cumulative budget limits, a missing credential scope, a failed credential/client resolution, a missing `ResolveClient` (internal error) and an unpriceable offering. Each refusal writes exactly one record and one `mcp purchase REFUSED` line on stderr. Argument-validation errors (including an upfront EC2, RDS, OpenSearch, Redshift or MemoryDB RI purchase missing `approved_upfront`/`approved_currency`) and an unconfirmed `dry_run=false` call are not refusals and write nothing. A purchase the provider refuses for exceeding `approved_upfront` is recorded like any other provider error after the purchase call (`status: "error"`), and like those it counts against `CUDLY_MCP_BUDGET_USD`. - A real purchase is recorded `"success"` only when the provider reports `Success: true` with no embedded error. A failed provider call, `Success: false`, or a result containing an error is recorded as `"error"`, except a failure that wraps the library's outcome-unknown error (an EC2 or Redshift purchase whose response was lost), which is recorded as `"unknown"`: the request may have reached the cloud and the commitment may exist, so reconcile with `DescribeReservedInstances` / `DescribeReservedNodes` before retrying. The stderr line for it reads `mcp purchase OUTCOME UNKNOWN` instead of `FAILED`. diff --git a/tools/upfront_audit_test.go b/tools/upfront_audit_test.go index 5b3d034..93b825c 100644 --- a/tools/upfront_audit_test.go +++ b/tools/upfront_audit_test.go @@ -108,71 +108,80 @@ func auditRecords(t *testing.T, path string) []map[string]any { return out } +// pinAuditEnv points the audit log and Redshift's budget state at fresh temp +// files and returns the audit path. Not for parallel tests (t.Setenv). +func pinAuditEnv(t *testing.T) string { + t.Helper() + path := filepath.Join(t.TempDir(), "audit.jsonl") + t.Setenv(EnvAuditLog, path) + t.Setenv(EnvBudgetState, filepath.Join(t.TempDir(), "budget.json")) + return path +} + +// requireErrorPreviewMatchesSuccess runs the successful preview as a baseline +// in its own audit file, then the failing scenario in another, and requires +// exactly one error record that differs from the baseline only in timestamp, +// status and error_message. +func requireErrorPreviewMatchesSuccess(t *testing.T, run upfrontAuditRun, failing upfrontAuditScenario) map[string]any { + t.Helper() + basePath := pinAuditEnv(t) + purchases, err := run(t, scenarioPreviewOK) + require.NoError(t, err) + assert.Zero(t, purchases) + baseline := auditRecords(t, basePath) + require.Len(t, baseline, 1) + + path := pinAuditEnv(t) + purchases, err = run(t, failing) + require.Error(t, err) + assert.Zero(t, purchases, "a dry run must never buy") + recs := auditRecords(t, path) + require.Len(t, recs, 1, "README: every purchase attempt, previews included, writes an audit record") + got := recs[0] + assert.Equal(t, auditStatusError, got["status"]) + assert.Equal(t, true, got["dry_run"]) + assert.NotEmpty(t, got["error_message"]) + want := baseline[0] + for _, k := range []string{"timestamp", "status", "error_message"} { + delete(got, k) + delete(want, k) + } + assert.Equal(t, want, got, "only status and error_message differ from a successful preview") + return recs[0] +} + // Not parallel: t.Setenv pins the audit log and Redshift's budget state. func TestUpfrontAuditRecords(t *testing.T) { for name, run := range upfrontAuditTools() { - // previewRecord is the successful-preview record, the baseline the - // failed-pricing record must match except for status and error. - var previewRecord map[string]any - pin := func(t *testing.T) string { - path := filepath.Join(t.TempDir(), "audit.jsonl") - t.Setenv(EnvAuditLog, path) - t.Setenv(EnvBudgetState, filepath.Join(t.TempDir(), "budget.json")) - return path - } - t.Run(name+"/successful preview writes one record", func(t *testing.T) { - path := pin(t) + path := pinAuditEnv(t) purchases, err := run(t, scenarioPreviewOK) require.NoError(t, err) assert.Zero(t, purchases) - recs := auditRecords(t, path) - require.Len(t, recs, 1) - previewRecord = recs[0] + require.Len(t, auditRecords(t, path), 1) }) t.Run(name+"/failed preview pricing writes one error record", func(t *testing.T) { - path := pin(t) - purchases, err := run(t, scenarioPreviewPricingFails) - require.Error(t, err) - assert.Zero(t, purchases, "a dry run must never buy") - recs := auditRecords(t, path) - require.Len(t, recs, 1, "README: every purchase attempt, previews included, writes an audit record") - got := recs[0] - assert.Equal(t, auditStatusError, got["status"]) - assert.Equal(t, true, got["dry_run"]) - assert.Contains(t, got["error_message"], errPricingDown.Error()) - if previewRecord != nil { - for _, k := range []string{"timestamp", "status", "error_message"} { - delete(got, k) - delete(previewRecord, k) - } - assert.Equal(t, previewRecord, got, "only status and error_message differ from a successful preview") - } + requireErrorPreviewMatchesSuccess(t, run, scenarioPreviewPricingFails) }) t.Run(name+"/quote rejected writes one error record", func(t *testing.T) { - path := pin(t) - purchases, err := run(t, scenarioQuoteRejected) - require.Error(t, err) - assert.Zero(t, purchases) - recs := auditRecords(t, path) - require.Len(t, recs, 1) - assert.Equal(t, auditStatusError, recs[0]["status"]) - assert.Equal(t, true, recs[0]["dry_run"]) - assert.NotEmpty(t, recs[0]["error_message"]) + requireErrorPreviewMatchesSuccess(t, run, scenarioQuoteRejected) }) t.Run(name+"/real purchase writes one record", func(t *testing.T) { - path := pin(t) + path := pinAuditEnv(t) purchases, err := run(t, scenarioRealPurchase) require.NoError(t, err) assert.Equal(t, 1, purchases) - require.Len(t, auditRecords(t, path), 1) + recs := auditRecords(t, path) + require.Len(t, recs, 1) + assert.Equal(t, auditStatusSuccess, recs[0]["status"]) + assert.Equal(t, false, recs[0]["dry_run"]) }) t.Run(name+"/confirmed purchase without figures writes nothing", func(t *testing.T) { - path := pin(t) + path := pinAuditEnv(t) purchases, err := run(t, scenarioConfirmedMissingFigures) require.Error(t, err) assert.Zero(t, purchases) From 9fcf81ab1a321f8ee4890d46c8f73bbc1a44a48e Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Wed, 4 Nov 2026 20:25:25 +0100 Subject: [PATCH 6/7] test(tools): drop unused return from the audit baseline helper Refs #129 Co-Authored-By: Claude Opus 5.5 --- tools/upfront_audit_test.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tools/upfront_audit_test.go b/tools/upfront_audit_test.go index 93b825c..8d4b253 100644 --- a/tools/upfront_audit_test.go +++ b/tools/upfront_audit_test.go @@ -122,7 +122,7 @@ func pinAuditEnv(t *testing.T) string { // in its own audit file, then the failing scenario in another, and requires // exactly one error record that differs from the baseline only in timestamp, // status and error_message. -func requireErrorPreviewMatchesSuccess(t *testing.T, run upfrontAuditRun, failing upfrontAuditScenario) map[string]any { +func requireErrorPreviewMatchesSuccess(t *testing.T, run upfrontAuditRun, failing upfrontAuditScenario) { t.Helper() basePath := pinAuditEnv(t) purchases, err := run(t, scenarioPreviewOK) @@ -147,7 +147,6 @@ func requireErrorPreviewMatchesSuccess(t *testing.T, run upfrontAuditRun, failin delete(want, k) } assert.Equal(t, want, got, "only status and error_message differ from a successful preview") - return recs[0] } // Not parallel: t.Setenv pins the audit log and Redshift's budget state. From abacf17ac66887024b8555cf648ae00651159e44 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Wed, 4 Nov 2026 20:30:56 +0100 Subject: [PATCH 7/7] test(tools): assert the failed-pricing audit message; reword README Refs #129 Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- tools/upfront_audit_test.go | 11 +++++++---- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 0cbb101..701f72d 100644 --- a/README.md +++ b/README.md @@ -191,7 +191,7 @@ Auditing is **on by default**. When auditing is enabled and a write succeeds, ea - **Default path**: `$XDG_STATE_HOME/cudly/mcp-audit.jsonl`, falling back to `~/.local/state/cudly/mcp-audit.jsonl` when `XDG_STATE_HOME` is unset, empty, or relative. - **`CUDLY_MCP_AUDIT_LOG`** overrides the path. Setting it to an **empty or whitespace-only string disables the log entirely** -- that is the explicit opt-out; leaving the variable unset is not the same thing and still uses the default path. - A preview (`dry_run=true`) is recorded with `status: "skipped"`, `dry_run: true` -- it spent nothing, but it was still a decision worth reconstructing later. -- A partial- or all-upfront preview whose offering cannot be priced (the lookup fails, or it reports no usable USD upfront) is recorded `"error"`, `dry_run: true`, with the provider's error text in `error_message`, as for other provider errors; it writes no `REFUSED` line, and the tool call still returns the error. +- A partial- or all-upfront preview whose offering cannot be priced (the lookup fails, or it reports no usable USD upfront) is recorded `"error"`, `dry_run: true`, with the reason (including any provider error text) in `error_message`, as for other provider errors; it writes no `REFUSED` line, and the tool call still returns the error. - When a credential scope is supplied, `credential_scope` records that routing identifier: an AWS profile, Azure subscription, or GCP project. It identifies how CUDly selected the target; it is not a verified provider account ID. Previews may omit it because they do not require a target. - A real purchase that a gate refuses before any provider purchase call is recorded `"refused"`, with the refusal text in `error_message` and the requested provider, service, region, resource, count, term and `credential_scope` on the record. This covers real purchases disabled, the count, hourly, memory, USD and cumulative budget limits, a missing credential scope, a failed credential/client resolution, a missing `ResolveClient` (internal error) and an unpriceable offering. Each refusal writes exactly one record and one `mcp purchase REFUSED` line on stderr. Argument-validation errors (including an upfront EC2, RDS, OpenSearch, Redshift or MemoryDB RI purchase missing `approved_upfront`/`approved_currency`) and an unconfirmed `dry_run=false` call are not refusals and write nothing. A purchase the provider refuses for exceeding `approved_upfront` is recorded like any other provider error after the purchase call (`status: "error"`), and like those it counts against `CUDLY_MCP_BUDGET_USD`. - A real purchase is recorded `"success"` only when the provider reports `Success: true` with no embedded error. A failed provider call, `Success: false`, or a result containing an error is recorded as `"error"`, except a failure that wraps the library's outcome-unknown error (an EC2 or Redshift purchase whose response was lost), which is recorded as `"unknown"`: the request may have reached the cloud and the commitment may exist, so reconcile with `DescribeReservedInstances` / `DescribeReservedNodes` before retrying. The stderr line for it reads `mcp purchase OUTCOME UNKNOWN` instead of `FAILED`. diff --git a/tools/upfront_audit_test.go b/tools/upfront_audit_test.go index 8d4b253..23190d6 100644 --- a/tools/upfront_audit_test.go +++ b/tools/upfront_audit_test.go @@ -121,8 +121,8 @@ func pinAuditEnv(t *testing.T) string { // requireErrorPreviewMatchesSuccess runs the successful preview as a baseline // in its own audit file, then the failing scenario in another, and requires // exactly one error record that differs from the baseline only in timestamp, -// status and error_message. -func requireErrorPreviewMatchesSuccess(t *testing.T, run upfrontAuditRun, failing upfrontAuditScenario) { +// status and error_message. A non-empty wantMsg must appear in error_message. +func requireErrorPreviewMatchesSuccess(t *testing.T, run upfrontAuditRun, failing upfrontAuditScenario, wantMsg string) { t.Helper() basePath := pinAuditEnv(t) purchases, err := run(t, scenarioPreviewOK) @@ -141,6 +141,9 @@ func requireErrorPreviewMatchesSuccess(t *testing.T, run upfrontAuditRun, failin assert.Equal(t, auditStatusError, got["status"]) assert.Equal(t, true, got["dry_run"]) assert.NotEmpty(t, got["error_message"]) + if wantMsg != "" { + assert.Contains(t, got["error_message"], wantMsg) + } want := baseline[0] for _, k := range []string{"timestamp", "status", "error_message"} { delete(got, k) @@ -161,11 +164,11 @@ func TestUpfrontAuditRecords(t *testing.T) { }) t.Run(name+"/failed preview pricing writes one error record", func(t *testing.T) { - requireErrorPreviewMatchesSuccess(t, run, scenarioPreviewPricingFails) + requireErrorPreviewMatchesSuccess(t, run, scenarioPreviewPricingFails, errPricingDown.Error()) }) t.Run(name+"/quote rejected writes one error record", func(t *testing.T) { - requireErrorPreviewMatchesSuccess(t, run, scenarioQuoteRejected) + requireErrorPreviewMatchesSuccess(t, run, scenarioQuoteRejected, "") }) t.Run(name+"/real purchase writes one record", func(t *testing.T) {