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..701f72d 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 (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/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 4b8ca6a..6a740d7 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,38 +18,12 @@ 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 // 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) @@ -102,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) } @@ -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/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 new file mode 100644 index 0000000..354570d --- /dev/null +++ b/tools/ri_upfront.go @@ -0,0 +1,122 @@ +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. +// +// 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 quote, nil +} + +// 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/upfront_audit_test.go b/tools/upfront_audit_test.go new file mode 100644 index 0000000..23190d6 --- /dev/null +++ b/tools/upfront_audit_test.go @@ -0,0 +1,193 @@ +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 + scenarioQuoteRejected +) + +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 == scenarioQuoteRejected { + api.fixedPrice, api.standardPrice = 0, 0 + } + 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 + } + if s == scenarioQuoteRejected { + fake.offering = upfrontOffering(0, "USD") + } + 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 + } + if s == scenarioQuoteRejected { + fake.offering = upfrontOffering(0, "USD") + } + 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 +} + +// 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. 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) + 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"]) + if wantMsg != "" { + assert.Contains(t, got["error_message"], wantMsg) + } + 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") +} + +// Not parallel: t.Setenv pins the audit log and Redshift's budget state. +func TestUpfrontAuditRecords(t *testing.T) { + for name, run := range upfrontAuditTools() { + t.Run(name+"/successful preview writes one record", func(t *testing.T) { + path := pinAuditEnv(t) + purchases, err := run(t, scenarioPreviewOK) + require.NoError(t, err) + assert.Zero(t, purchases) + require.Len(t, auditRecords(t, path), 1) + }) + + t.Run(name+"/failed preview pricing writes one error record", func(t *testing.T) { + requireErrorPreviewMatchesSuccess(t, run, scenarioPreviewPricingFails, errPricingDown.Error()) + }) + + t.Run(name+"/quote rejected writes one error record", func(t *testing.T) { + requireErrorPreviewMatchesSuccess(t, run, scenarioQuoteRejected, "") + }) + + t.Run(name+"/real purchase writes one record", func(t *testing.T) { + path := pinAuditEnv(t) + purchases, err := run(t, scenarioRealPurchase) + require.NoError(t, err) + assert.Equal(t, 1, purchases) + 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 := pinAuditEnv(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") + }) + } +}