From 722808afc488d2eef40747f066dee73294ab95dd Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Sat, 10 Oct 2026 04:27:19 +0200 Subject: [PATCH 01/10] feat(insurance): add read-only archera-comparison command Explicit, default-off comparison of an Archera commitment plan using pkg/insurance. Key from ARCHERA_API_KEY only (no flag); org and plan IDs from env or flags. Table or JSON output via cmd.OutOrStdout, exact decimal money, unknown never rendered as 0, vendor strings sanitized, disclosures verbatim. Never purchases and makes no request unless run. Closes #2156 Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 6 + README.md | 2 +- cmd/archera_comparison.go | 113 +++++++++++ cmd/archera_comparison_test.go | 359 +++++++++++++++++++++++++++++++++ cmd/archera_render.go | 338 +++++++++++++++++++++++++++++++ docs/cli/archera-comparison.md | 51 +++++ 6 files changed, 868 insertions(+), 1 deletion(-) create mode 100644 cmd/archera_comparison.go create mode 100644 cmd/archera_comparison_test.go create mode 100644 cmd/archera_render.go create mode 100644 docs/cli/archera-comparison.md diff --git a/CHANGELOG.md b/CHANGELOG.md index e94c2e2b3..87996f8b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). ## [Unreleased] +### Added + +- `archera-comparison` command: a read-only, default-off comparison of an + Archera commitment plan (table or JSON). See `docs/cli/archera-comparison.md` + (#2156). + ### Fixed - Reject NaN and infinite values for `--coverage`, `--target-coverage`, diff --git a/README.md b/README.md index aeecf78a7..0e6249f97 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ make build `make build` creates `./cudly` from `./cmd`. The build does not deploy or configure a cloud account. -Read the [CLI reference](docs/cli/README.md) for commands. See the guides for [cloud setup](docs/cli/cloud-setup.md), [filtering](docs/cli/filtering.md), and [purchase safety](docs/cli/purchase-safety.md). +Read the [CLI reference](docs/cli/README.md) for commands. See the guides for [cloud setup](docs/cli/cloud-setup.md), [filtering](docs/cli/filtering.md), and [purchase safety](docs/cli/purchase-safety.md). `archera-comparison` is an optional, default-off, read-only Archera plan comparison: see [Archera comparison](docs/cli/archera-comparison.md). ## Common workflows diff --git a/cmd/archera_comparison.go b/cmd/archera_comparison.go new file mode 100644 index 000000000..92df84415 --- /dev/null +++ b/cmd/archera_comparison.go @@ -0,0 +1,113 @@ +package main + +import ( + "errors" + "fmt" + "net/http" + "os" + "strings" + + "github.com/LeanerCloud/cloud-commitments-go/pkg/insurance" + "github.com/spf13/cobra" +) + +// Archera settings come from the environment, read only inside RunE. The API +// key has no flag by design. +const ( + archeraEnvAPIKey = "ARCHERA_API_KEY" // #nosec G101 -- environment variable name, not a credential + archeraEnvOrgID = "ARCHERA_ORG_ID" + archeraEnvPlanID = "ARCHERA_PLAN_ID" +) + +// archeraHTTPClient is nil in production, which selects the hardened default +// client inside pkg/insurance. Tests replace it with a fixed-origin transport. +// It never holds the key; the key lives only in RunE locals. +var archeraHTTPClient *http.Client + +var archeraOpts struct { + OrgID string + PlanID string + Format string +} + +var archeraComparisonCmd = &cobra.Command{ + Use: "archera-comparison", + Short: "Show a read-only Archera commitment plan comparison", + Long: `Fetch and print the comparison for one Archera commitment plan. + +This command is read-only: it never purchases, binds or submits anything, and +it is independent of --purchase. It makes no Archera request unless it is run. + +Configuration (default off; there is no flag for the API key): + ARCHERA_API_KEY Archera API key (environment only) + ARCHERA_ORG_ID Archera organization UUID (or --org-id) + ARCHERA_PLAN_ID Archera commitment plan UUID (or --plan-id)`, + Args: cobra.NoArgs, + SilenceUsage: true, + SilenceErrors: true, + RunE: runArcheraComparison, +} + +func init() { + rootCmd.AddCommand(archeraComparisonCmd) + archeraComparisonCmd.Flags().StringVar(&archeraOpts.OrgID, "org-id", "", "Archera organization UUID (default: $"+archeraEnvOrgID+")") + archeraComparisonCmd.Flags().StringVar(&archeraOpts.PlanID, "plan-id", "", "Archera commitment plan UUID (default: $"+archeraEnvPlanID+")") + archeraComparisonCmd.Flags().StringVar(&archeraOpts.Format, "format", "table", "Output format: table or json") +} + +// archeraSetting returns the flag value, else the environment value, trimmed. +func archeraSetting(flagValue, envName string) string { + if v := strings.TrimSpace(flagValue); v != "" { + return v + } + return strings.TrimSpace(os.Getenv(envName)) +} + +func runArcheraComparison(cmd *cobra.Command, _ []string) error { + format := archeraOpts.Format + if format != "table" && format != "json" { + return errors.New("--format must be table or json") + } + key, orgID, planID, err := archeraSettings() + if err != nil { + return err + } + + // Config and client stay local to this call; never store them in a + // package variable or struct. + client, err := insurance.NewClient(insurance.Config{APIKey: key, OrgID: orgID}, archeraHTTPClient) + if err != nil { + return err + } + comparison, err := client.Comparison(cmd.Context(), insurance.ComparisonRequest{PlanID: planID}) + if err != nil { + var httpErr *insurance.HTTPError + if errors.As(err, &httpErr) && httpErr.RetryAfter > 0 { + return fmt.Errorf("%w (retry after %s; not retried automatically)", err, httpErr.RetryAfter) + } + return err + } + dto := buildArcheraDTO(comparison) + if format == "json" { + return renderArcheraJSON(cmd.OutOrStdout(), dto) + } + return renderArcheraTable(cmd.OutOrStdout(), dto) +} + +// archeraSettings reads the key, org ID and plan ID, naming (never echoing) +// the first missing setting. The key is returned to RunE only. +func archeraSettings() (key, orgID, planID string, err error) { + key = strings.TrimSpace(os.Getenv(archeraEnvAPIKey)) + if key == "" { + return "", "", "", fmt.Errorf("%s is not set (read from the environment only)", archeraEnvAPIKey) + } + orgID = archeraSetting(archeraOpts.OrgID, archeraEnvOrgID) + if orgID == "" { + return "", "", "", fmt.Errorf("archera org ID is not set (--org-id or %s)", archeraEnvOrgID) + } + planID = archeraSetting(archeraOpts.PlanID, archeraEnvPlanID) + if planID == "" { + return "", "", "", fmt.Errorf("archera plan ID is not set (--plan-id or %s)", archeraEnvPlanID) + } + return key, orgID, planID, nil +} diff --git a/cmd/archera_comparison_test.go b/cmd/archera_comparison_test.go new file mode 100644 index 000000000..9c68162ad --- /dev/null +++ b/cmd/archera_comparison_test.go @@ -0,0 +1,359 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "math/big" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "sync" + "testing" + "time" + + "github.com/LeanerCloud/cloud-commitments-go/pkg/common" + "github.com/LeanerCloud/cloud-commitments-go/pkg/insurance" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + archeraTestKey = "test-key-SYNTHETIC-0000" + archeraTestOrg = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" + archeraTestPlan = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb" +) + +// Synthetic fixtures shaped like the documented commitment-plans schema. +const archeraFinancialsFixture = `{ + "commitment_cost": {"total": 110, "breakdown": {"cloud_provider_cost": {"total": 100}, "archera_premium": 10.125}}, + "commitment_savings": {"net": null, "gross": 4.5}, + "covered_ondemand_cost": 104.5 +}` + +func archeraOfferFixture(isCurrent bool, offerType, lease, term, payment, name string) string { + return fmt.Sprintf(`{ + "is_current": %t, + "offer_id": "11111111-1111-4111-8111-111111111111", + "offer_org_id": "public", + "offer": {"provider": "aws", "type": %q, "region": "us-east-1", "guaranteed_display_name": %s}, + "lease_menu_item_id": %s, + "selected_amount": 3, + "commitment_type": %q, + "contract_term": %s, + "payment_option": %s, + "discount_rate": 0.3, + "breakeven_days": null, + "commitment_upfront_cost": 1200, + "commitment_financials_monthly_rate": %s, + "delta_vs_current": {"monthly_net_savings": 0, "upfront_cost": 0, "discount_rate": 0, "breakeven_days": null} + }`, isCurrent, offerType, name, lease, offerType, term, payment, archeraFinancialsFixture) +} + +func archeraComparisonFixture() string { + return `{ + "current_totals": {"commitment_financials_monthly_rate": ` + archeraFinancialsFixture + `, "commitment_upfront_cost": 1200}, + "hypothetical_totals": [{ + "contract_term": null, + "payment_option": "no_upfront", + "commitment_financials_monthly_rate": {}, + "commitment_upfront_cost": 0, + "delta_vs_current": {"monthly_net_savings": 7.25, "monthly_commitment_cost": -1, "upfront_cost": -1200}, + "line_items": [{ + "line_item_id": "22222222-2222-4222-8222-222222222222", + "actual_term": "one_year_gris", "actual_payment_option": "no_upfront", + "actual_commitment_type": "aws/AmazonEC2", + "actual_term_reason": "fallback_closest_shorter" + }] + }], + "data": [{ + "line_item_id": "22222222-2222-4222-8222-222222222222", + "current": ` + archeraOfferFixture(true, "aws/AmazonEC2", "null", "null", "null", "null") + `, + "candidates": [` + + archeraOfferFixture(false, "aws/AmazonEC2", `"33333333-3333-4333-8333-333333333333"`, `"one_year_gris"`, `"no_upfront"`, `"Offer\u001b[31m RED"`) + `,` + + archeraOfferFixture(false, "aws/SomethingNew", "null", `"one_year_gris"`, `"no_upfront"`, "null") + `] + }] + }` +} + +type archeraRecorder struct { + mu sync.Mutex + requests []*http.Request + hits map[string]int +} + +// archeraTestTransport asserts the production origin before rewriting the +// request to the httptest server, so only the fixed origin is ever exercised. +type archeraTestTransport struct { + t *testing.T + target *url.URL + rec *archeraRecorder +} + +func (a *archeraTestTransport) RoundTrip(r *http.Request) (*http.Response, error) { + require.Equal(a.t, "https", r.URL.Scheme) + require.Equal(a.t, "api.archera.ai", r.URL.Host) + a.rec.mu.Lock() + a.rec.requests = append(a.rec.requests, r.Clone(context.Background())) + a.rec.mu.Unlock() + rewritten := r.Clone(r.Context()) + rewritten.URL.Scheme, rewritten.URL.Host = a.target.Scheme, a.target.Host + rewritten.Host = a.target.Host + return http.DefaultTransport.RoundTrip(rewritten) +} + +func setupArchera(t *testing.T, handler http.HandlerFunc) *archeraRecorder { + t.Helper() + rec := &archeraRecorder{hits: map[string]int{}} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + rec.mu.Lock() + rec.hits[r.URL.Path]++ + rec.mu.Unlock() + handler(w, r) + })) + t.Cleanup(srv.Close) + target, err := url.Parse(srv.URL) + require.NoError(t, err) + archeraHTTPClient = &http.Client{Transport: &archeraTestTransport{t: t, target: target, rec: rec}} + t.Cleanup(func() { archeraHTTPClient = nil }) + return rec +} + +func okHandler(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(archeraComparisonFixture())) +} + +// resetHelpFlags clears the sticky --help flag cobra leaves set after a help run. +func resetHelpFlags() { + for _, c := range []*cobra.Command{rootCmd, archeraComparisonCmd, configureGCPCmd} { + if f := c.Flags().Lookup("help"); f != nil { + _ = f.Value.Set("false") + f.Changed = false + } + } +} + +func runArchera(t *testing.T, env map[string]string, args ...string) (stdout, stderr string, err error) { + t.Helper() + for _, k := range []string{archeraEnvAPIKey, archeraEnvOrgID, archeraEnvPlanID} { + t.Setenv(k, "") + } + for k, v := range env { + t.Setenv(k, v) + } + resetHelpFlags() + archeraOpts.OrgID, archeraOpts.PlanID, archeraOpts.Format = "", "", "table" + var out, errb bytes.Buffer + rootCmd.SetOut(&out) + rootCmd.SetErr(&errb) + rootCmd.SetArgs(append([]string{"archera-comparison"}, args...)) + t.Cleanup(func() { rootCmd.SetOut(nil); rootCmd.SetErr(nil); rootCmd.SetArgs(nil) }) + err = rootCmd.Execute() + return out.String(), errb.String(), err +} + +func fullEnv() map[string]string { + return map[string]string{archeraEnvAPIKey: archeraTestKey, archeraEnvOrgID: archeraTestOrg, archeraEnvPlanID: archeraTestPlan} +} + +func TestArcheraComparisonDefaultOffMakesNoRequest(t *testing.T) { + rec := setupArchera(t, func(http.ResponseWriter, *http.Request) { t.Error("unexpected Archera request") }) + for _, args := range [][]string{{"--help"}} { + _, _, err := runArchera(t, fullEnv(), args...) + require.NoError(t, err) + } + rootCmd.SetArgs([]string{"configure-gcp", "--help"}) + var out bytes.Buffer + rootCmd.SetOut(&out) + require.NoError(t, rootCmd.Execute()) + resetHelpFlags() + assert.Empty(t, rec.requests) +} + +func TestArcheraComparisonIncompleteConfig(t *testing.T) { + rec := setupArchera(t, func(http.ResponseWriter, *http.Request) { t.Error("unexpected request") }) + cases := []struct { + name string + env map[string]string + wantMsg string + }{ + {"no key", map[string]string{archeraEnvOrgID: archeraTestOrg, archeraEnvPlanID: archeraTestPlan}, archeraEnvAPIKey}, + {"no org", map[string]string{archeraEnvAPIKey: archeraTestKey, archeraEnvPlanID: archeraTestPlan}, "org ID"}, + {"no plan", map[string]string{archeraEnvAPIKey: archeraTestKey, archeraEnvOrgID: archeraTestOrg}, "plan ID"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + out, errOut, err := runArchera(t, tc.env) + require.Error(t, err) + assert.Contains(t, err.Error(), tc.wantMsg) + assert.NotContains(t, out+errOut+err.Error(), archeraTestKey) + }) + } + assert.Empty(t, rec.requests) +} + +func TestArcheraComparisonBadFormat(t *testing.T) { + setupArchera(t, okHandler) + _, _, err := runArchera(t, fullEnv(), "--format", "xml") + require.ErrorContains(t, err, "--format") +} + +func TestArcheraComparisonTable(t *testing.T) { + rec := setupArchera(t, okHandler) + out, errOut, err := runArchera(t, fullEnv()) + require.NoError(t, err) + + require.Len(t, rec.requests, 1) + req := rec.requests[0] + assert.Equal(t, http.MethodGet, req.Method) + assert.Equal(t, "/v1/org/"+archeraTestOrg+"/commitment-plans/"+archeraTestPlan+"/comparison", req.URL.Path) + assert.Empty(t, req.URL.RawQuery) + assert.Equal(t, archeraTestKey, req.Header.Get("x-api-key")) + + assert.Contains(t, out, archeraTitle) + assert.NotContains(t, strings.ToLower(out), "insured quote") + assert.Contains(t, out, "premium 10.125", "premium kept exact") + assert.Contains(t, out, "net savings unknown", "null money is unknown, not 0") + assert.Contains(t, out, "730-hour monthly rate") + assert.Contains(t, out, "one-time") + assert.Contains(t, out, "The API reports no currency") + assert.Contains(t, out, "reason fallback_closest_shorter") + assert.Contains(t, out, "lease attached") + assert.Contains(t, out, "Archera offer name: Offer[31m RED", "control characters stripped, text kept") + assert.NotContains(t, out, "\x1b") + assert.Contains(t, out, "Archera product support: supported (") + assert.Contains(t, out, "Archera product support: unknown") + assert.Contains(t, out, "discount rate 0.3 (0-1 basis)") + assert.Contains(t, out, common.ArcheraNonGatingDisclosure) + assert.Contains(t, out, common.ArcheraSponsorshipDisclosure) + assert.NotContains(t, out, common.ArcheraSignupURL) + assert.Contains(t, out, "not mapped onto CUDly recommendation rows") + assert.NotContains(t, out+errOut, archeraTestKey) +} + +func TestArcheraComparisonJSON(t *testing.T) { + setupArchera(t, okHandler) + out, errOut, err := runArchera(t, fullEnv(), "--format", "json") + require.NoError(t, err) + assert.Empty(t, errOut) + var doc map[string]any + dec := json.NewDecoder(strings.NewReader(out)) + require.NoError(t, dec.Decode(&doc), "stdout must be exactly one JSON document") + assert.False(t, dec.More()) + assert.Nil(t, doc["currency"]) + assert.Equal(t, true, doc["premium_included"]) + assert.Equal(t, common.ArcheraNonGatingDisclosure, doc["non_gating_disclosure"]) + assert.Equal(t, common.ArcheraSponsorshipDisclosure, doc["sponsorship_disclosure"]) + assert.NotContains(t, doc, "disclosures") + assert.NotContains(t, out, "\x1b") + assert.NotContains(t, out, `\u001b`) + assert.NotContains(t, out, archeraTestKey) + + cur := doc["current_totals"].(map[string]any)["monthly_rate_730h"].(map[string]any) + assert.Equal(t, "10.125", cur["premium"]) + assert.Nil(t, cur["net_savings"]) + rows := doc["rows"].([]any) + cand := rows[0].(map[string]any)["candidates"].([]any) + support := cand[0].(map[string]any)["archera_product_support"].(map[string]any) + assert.Equal(t, "supported", support["status"]) + assert.NotEmpty(t, support["source"]) + unknown := cand[1].(map[string]any)["archera_product_support"].(map[string]any) + assert.Equal(t, "unknown", unknown["status"]) + assert.NotContains(t, unknown, "source") +} + +func TestArcheraMoneyExact(t *testing.T) { + for in, want := range map[string]string{"0": "0", "7.25": "7.25", "10.125": "10.125", "1200": "1200", "0.00001": "0.00001"} { + r, ok := new(big.Rat).SetString(in) + require.True(t, ok) + got := archeraMoney(r) + require.NotNil(t, got) + assert.Equal(t, want, *got, in) + } + assert.Nil(t, archeraMoney(nil)) +} + +func TestArcheraSanitize(t *testing.T) { + got := archeraSanitize("a\x1bb\u009bc\nd") + assert.Equal(t, "abcd", got) + long := archeraSanitize(strings.Repeat("é", 300)) + assert.LessOrEqual(t, len(long), archeraMaxFieldSize) +} + +func TestArcheraVendorErrorsAndRedaction(t *testing.T) { + cases := []struct { + name string + status int + header map[string]string + body string + want string + }{ + {"401", 401, nil, `{"detail":"nope"}`, "HTTP 401"}, + {"403 echoing key", 403, nil, `{"detail":"bad key ` + archeraTestKey + `"}`, "HTTP 403"}, + {"429", 429, map[string]string{"Retry-After": "30"}, `{"detail":"slow down"}`, "retry after 30s"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + rec := setupArchera(t, func(w http.ResponseWriter, _ *http.Request) { + for k, v := range tc.header { + w.Header().Set(k, v) + } + w.WriteHeader(tc.status) + _, _ = w.Write([]byte(tc.body)) + }) + out, errOut, err := runArchera(t, fullEnv()) + require.Error(t, err) + assert.Contains(t, err.Error(), tc.want) + assert.NotContains(t, out+errOut+err.Error(), archeraTestKey) + assert.Len(t, rec.requests, 1, "no automatic retry") + }) + } + t.Run("invalid uuid", func(t *testing.T) { + setupArchera(t, okHandler) + env := fullEnv() + env[archeraEnvOrgID] = "not-a-uuid" + out, errOut, err := runArchera(t, env) + require.Error(t, err) + assert.NotContains(t, out+errOut+err.Error(), archeraTestKey) + assert.NotContains(t, err.Error(), "not-a-uuid") + }) +} + +func TestArcheraRedirectNotFollowed(t *testing.T) { + rec := setupArchera(t, func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "/second") { + t.Error("redirect target was requested") + return + } + http.Redirect(w, r, "/second", http.StatusFound) + }) + out, errOut, err := runArchera(t, fullEnv()) + require.Error(t, err) + assert.NotContains(t, out+errOut+err.Error(), archeraTestKey) + assert.Zero(t, rec.hits["/second"]) +} + +func TestArcheraNoKeyFlagAndStructsRedacted(t *testing.T) { + for _, name := range []string{"api-key", "key", "token", "secret", "archera-api-key"} { + assert.Nil(t, archeraComparisonCmd.Flags().Lookup(name), name) + } + check := func(label string) { + comparison, err := insurance.DecodeComparison(strings.NewReader(archeraComparisonFixture()), archeraTestOrg, archeraTestPlan, time.Unix(0, 0)) + require.NoError(t, err) + dto := buildArcheraDTO(comparison) + for _, v := range []any{archeraOpts, &archeraOpts, dto, &dto} { + for _, f := range []string{"%v", "%+v", "%#v", "%s"} { + assert.NotContains(t, fmt.Sprintf(f, v), archeraTestKey, label+" "+f) + } + } + } + setupArchera(t, okHandler) + check("before") + _, _, err := runArchera(t, fullEnv()) + require.NoError(t, err) + check("after") +} diff --git a/cmd/archera_render.go b/cmd/archera_render.go new file mode 100644 index 000000000..ece6a3839 --- /dev/null +++ b/cmd/archera_render.go @@ -0,0 +1,338 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "math/big" + "strings" + "unicode" + "unicode/utf8" + + "github.com/LeanerCloud/cloud-commitments-go/pkg/common" + "github.com/LeanerCloud/cloud-commitments-go/pkg/insurance" +) + +const ( + archeraTitle = "Archera commitment plan comparison (read-only)" + archeraMonthlyBasis = "730-hour monthly rate" + archeraUpfrontBasis = "one-time" + archeraPlanWideNote = "This comparison covers the whole Archera plan. It is not mapped onto CUDly recommendation rows or recommendation CSV output." + archeraCaveat = "A plan comparison is a hypothetical rollup, never a bindable insurance quote, and never a purchase. " + + "An insured target of 100% is a requested target subject to Archera underwriting allowances, not a guarantee. " + + "The API exposes no per-line product support or allowance verdict, so product support and allowance are shown as unknown unless Archera's documentation says otherwise." + archeraPremiumNote = "Totals include the Archera premium. The API reports no currency, so amounts are unlabelled." + archeraMaxFieldSize = 256 + archeraMaxMoneyPrec = 100 +) + +// archeraSanitize strips control characters (ESC, C1 and the rest of +// unicode.IsControl) from a vendor string and caps it at archeraMaxFieldSize +// bytes on a rune boundary, so it is safe to print to a terminal. +func archeraSanitize(s string) string { + var b strings.Builder + for _, r := range s { + if unicode.IsControl(r) { + continue + } + if b.Len()+utf8.RuneLen(r) > archeraMaxFieldSize { + break + } + b.WriteRune(r) + } + return b.String() +} + +func archeraSanitizePtr(s *string) *string { + if s == nil { + return nil + } + v := archeraSanitize(*s) + return &v +} + +// archeraMoney renders an exact decimal using the smallest precision at which +// FloatString round-trips to the same Rat. No float64 and no division. nil is +// unknown and returns nil so JSON gets null. +func archeraMoney(r *big.Rat) *string { + if r == nil { + return nil + } + for p := 0; p <= archeraMaxMoneyPrec; p++ { + s := r.FloatString(p) + if back, ok := new(big.Rat).SetString(s); ok && back.Cmp(r) == 0 { + return &s + } + } + s := r.String() + return &s +} + +type archeraFinancialsDTO struct { + CommitmentCostTotal *string `json:"commitment_cost_total"` + CloudProviderCost *string `json:"cloud_provider_cost"` + Premium *string `json:"premium"` + GrossSavings *string `json:"gross_savings"` + NetSavings *string `json:"net_savings"` + CoveredOnDemandCost *string `json:"covered_on_demand_cost"` +} + +type archeraTotalsDTO struct { + Monthly archeraFinancialsDTO `json:"monthly_rate_730h"` + UpfrontCost *string `json:"upfront_cost_one_time"` +} + +type archeraLineItemDTO struct { + LineItemID string `json:"line_item_id"` + ActualTerm *string `json:"actual_term"` + ActualPaymentOption *string `json:"actual_payment_option"` + ActualCommitmentType *string `json:"actual_commitment_type"` + Reason string `json:"reason"` +} + +type archeraHypotheticalDTO struct { + ContractTerm *string `json:"contract_term"` + PaymentOption string `json:"payment_option"` + Totals archeraTotalsDTO `json:"totals"` + DeltaMonthlyNetSavings *string `json:"delta_monthly_net_savings"` + DeltaMonthlyCommitmentCost *string `json:"delta_monthly_commitment_cost"` + DeltaUpfrontCost *string `json:"delta_upfront_cost"` + LineItems []archeraLineItemDTO `json:"line_items"` +} + +type archeraProductSupportDTO struct { + Status string `json:"status"` + Source string `json:"source,omitempty"` + Evidence string `json:"evidence,omitempty"` +} + +type archeraDeltaDTO struct { + MonthlyNetSavings *string `json:"monthly_net_savings"` + UpfrontCost *string `json:"upfront_cost"` + DiscountRate *string `json:"discount_rate"` + BreakevenDays *string `json:"breakeven_days"` +} + +type archeraOfferDTO struct { + IsCurrent bool `json:"is_current"` + OfferID string `json:"offer_id"` + CommitmentType string `json:"commitment_type"` + Provider string `json:"provider"` + Region *string `json:"region"` + ContractTerm *string `json:"contract_term"` + PaymentOption *string `json:"payment_option"` + LeaseAttached bool `json:"lease_attached"` + ArcheraOfferName *string `json:"archera_offer_name"` + DiscountRate *string `json:"discount_rate"` + BreakevenDays *string `json:"breakeven_days"` + Totals archeraTotalsDTO `json:"totals"` + DeltaVsCurrent archeraDeltaDTO `json:"delta_vs_current"` + ArcheraProductSupport archeraProductSupportDTO `json:"archera_product_support"` +} + +type archeraRowDTO struct { + LineItemID string `json:"line_item_id"` + Current archeraOfferDTO `json:"current"` + Candidates []archeraOfferDTO `json:"candidates"` +} + +// archeraComparisonDTO is the CLI's stable JSON contract. Money values are +// exact decimal strings; null means unknown, never zero. +type archeraComparisonDTO struct { + Title string `json:"title"` + PlanID string `json:"plan_id"` + FetchedAt string `json:"fetched_at"` + Currency *string `json:"currency"` + PremiumIncluded bool `json:"premium_included"` + MonthlyBasis string `json:"monthly_basis"` + UpfrontBasis string `json:"upfront_basis"` + PlanWide string `json:"plan_wide_note"` + Caveat string `json:"caveat"` + Current archeraTotalsDTO `json:"current_totals"` + Hypotheticals []archeraHypotheticalDTO `json:"hypothetical_totals"` + Rows []archeraRowDTO `json:"rows"` + NonGatingDisclosure string `json:"non_gating_disclosure"` + SponsorshipDisclosure string `json:"sponsorship_disclosure"` +} + +func archeraFinancials(f insurance.Financials) archeraFinancialsDTO { + return archeraFinancialsDTO{ + CommitmentCostTotal: archeraMoney(f.CommitmentCostTotal), + CloudProviderCost: archeraMoney(f.CloudProviderCost), + Premium: archeraMoney(f.Premium), + GrossSavings: archeraMoney(f.GrossSavings), + NetSavings: archeraMoney(f.NetSavings), + CoveredOnDemandCost: archeraMoney(f.CoveredOnDemandCost), + } +} + +func archeraTotals(t insurance.Totals) archeraTotalsDTO { + return archeraTotalsDTO{Monthly: archeraFinancials(t.Monthly), UpfrontCost: archeraMoney(t.UpfrontCost)} +} + +func archeraPayment(p *insurance.PaymentOption) *string { + if p == nil { + return nil + } + s := archeraSanitize(string(*p)) + return &s +} + +func archeraOffer(e insurance.OfferEntry) archeraOfferDTO { + support := insurance.AssessProductSupport(e.Provider, e.CommitmentType) + ps := archeraProductSupportDTO{Status: string(support.Status)} + if support.Status == insurance.ProductSupportSupported { + ps.Source, ps.Evidence = archeraSanitize(support.Source), archeraSanitize(support.Evidence) + } + return archeraOfferDTO{ + IsCurrent: e.IsCurrent, + OfferID: archeraSanitize(e.OfferID), + CommitmentType: archeraSanitize(e.CommitmentType), + Provider: archeraSanitize(string(e.Provider)), + Region: archeraSanitizePtr(e.Region), + ContractTerm: archeraSanitizePtr(e.ContractTerm), + PaymentOption: archeraPayment(e.PaymentOption), + LeaseAttached: e.LeaseBacked(), + ArcheraOfferName: archeraSanitizePtr(e.GuaranteedDisplayName), + DiscountRate: archeraMoney(e.DiscountRate), + BreakevenDays: archeraMoney(e.BreakevenDays), + Totals: archeraTotalsDTO{Monthly: archeraFinancials(e.Monthly), UpfrontCost: archeraMoney(e.UpfrontCost)}, + DeltaVsCurrent: archeraDeltaDTO{ + MonthlyNetSavings: archeraMoney(e.Delta.MonthlyNetSavings), + UpfrontCost: archeraMoney(e.Delta.UpfrontCost), + DiscountRate: archeraMoney(e.Delta.DiscountRate), + BreakevenDays: archeraMoney(e.Delta.BreakevenDays), + }, + ArcheraProductSupport: ps, + } +} + +func buildArcheraDTO(c *insurance.Comparison) archeraComparisonDTO { + dto := archeraComparisonDTO{ + Title: archeraTitle, + PlanID: archeraSanitize(c.PlanID), + FetchedAt: c.FetchedAt.UTC().Format("2006-01-02T15:04:05Z"), + Currency: archeraSanitizePtr(c.Currency), + PremiumIncluded: true, + MonthlyBasis: archeraMonthlyBasis, + UpfrontBasis: archeraUpfrontBasis, + PlanWide: archeraPlanWideNote, + Caveat: archeraCaveat, + Current: archeraTotals(c.Current), + Hypotheticals: []archeraHypotheticalDTO{}, + Rows: []archeraRowDTO{}, + NonGatingDisclosure: common.ArcheraNonGatingDisclosure, + SponsorshipDisclosure: common.ArcheraSponsorshipDisclosure, + } + for i := range c.Hypotheticals { + h := &c.Hypotheticals[i] + hd := archeraHypotheticalDTO{ + ContractTerm: archeraSanitizePtr(h.ContractTerm), + PaymentOption: archeraSanitize(string(h.PaymentOption)), + Totals: archeraTotals(h.Totals), + DeltaMonthlyNetSavings: archeraMoney(h.DeltaMonthlyNetSavings), + DeltaMonthlyCommitmentCost: archeraMoney(h.DeltaMonthlyCommitmentCost), + DeltaUpfrontCost: archeraMoney(h.DeltaUpfrontCost), + LineItems: []archeraLineItemDTO{}, + } + for _, li := range h.LineItems { + hd.LineItems = append(hd.LineItems, archeraLineItemDTO{ + LineItemID: archeraSanitize(li.LineItemID), + ActualTerm: archeraSanitizePtr(li.ActualTerm), + ActualPaymentOption: archeraPayment(li.ActualPaymentOption), + ActualCommitmentType: archeraSanitizePtr(li.ActualCommitmentType), + Reason: archeraSanitize(string(li.Reason)), + }) + } + dto.Hypotheticals = append(dto.Hypotheticals, hd) + } + for i := range c.Rows { + r := &c.Rows[i] + rd := archeraRowDTO{LineItemID: archeraSanitize(r.LineItemID), Current: archeraOffer(r.Current), Candidates: []archeraOfferDTO{}} + for j := range r.Candidates { + rd.Candidates = append(rd.Candidates, archeraOffer(r.Candidates[j])) + } + dto.Rows = append(dto.Rows, rd) + } + return dto +} + +func renderArcheraJSON(w io.Writer, dto archeraComparisonDTO) error { + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + return enc.Encode(dto) +} + +func archeraStr(s *string) string { + if s == nil { + return "unknown" + } + return *s +} + +func archeraTotalsLine(t archeraTotalsDTO) string { + m := t.Monthly + return fmt.Sprintf("commitment cost %s, cloud provider cost %s, premium %s, gross savings %s, net savings %s, covered on-demand %s (%s); upfront %s (%s)", + archeraStr(m.CommitmentCostTotal), archeraStr(m.CloudProviderCost), archeraStr(m.Premium), archeraStr(m.GrossSavings), + archeraStr(m.NetSavings), archeraStr(m.CoveredOnDemandCost), archeraMonthlyBasis, archeraStr(t.UpfrontCost), archeraUpfrontBasis) +} + +func renderArcheraTable(w io.Writer, d archeraComparisonDTO) error { + var b strings.Builder + p := func(format string, a ...any) { fmt.Fprintf(&b, format+"\n", a...) } + p("%s", d.Title) + p("Plan: %s Fetched: %s", d.PlanID, d.FetchedAt) + p("%s", archeraPremiumNote) + p("%s", d.PlanWide) + p("%s", d.Caveat) + p("") + p("Current totals: %s", archeraTotalsLine(d.Current)) + for i := range d.Hypotheticals { + h := &d.Hypotheticals[i] + p("") + p("Hypothetical %d: term %s, payment %s", i+1, archeraStr(h.ContractTerm), h.PaymentOption) + p(" totals: %s", archeraTotalsLine(h.Totals)) + p(" delta vs current: monthly net savings %s, monthly commitment cost %s, upfront %s", + archeraStr(h.DeltaMonthlyNetSavings), archeraStr(h.DeltaMonthlyCommitmentCost), archeraStr(h.DeltaUpfrontCost)) + for _, li := range h.LineItems { + p(" line item %s: term %s, payment %s, type %s, reason %s", li.LineItemID, archeraStr(li.ActualTerm), + archeraStr(li.ActualPaymentOption), archeraStr(li.ActualCommitmentType), li.Reason) + } + } + for i := range d.Rows { + r := &d.Rows[i] + p("") + p("Line item %s", r.LineItemID) + writeArcheraOffer(p, "current", r.Current) + for j := range r.Candidates { + writeArcheraOffer(p, "candidate", r.Candidates[j]) + } + } + p("") + p("%s", d.NonGatingDisclosure) + p("%s", d.SponsorshipDisclosure) + _, err := io.WriteString(w, b.String()) + return err +} + +func writeArcheraOffer(p func(string, ...any), kind string, o archeraOfferDTO) { + lease := "" + if o.LeaseAttached { + lease = ", lease attached" + } + p(" %s offer %s: %s %s, region %s, term %s, payment %s%s", kind, o.OfferID, o.Provider, o.CommitmentType, + archeraStr(o.Region), archeraStr(o.ContractTerm), archeraStr(o.PaymentOption), lease) + if o.ArcheraOfferName != nil { + p(" Archera offer name: %s", *o.ArcheraOfferName) + } + p(" discount rate %s (0-1 basis), breakeven days %s", archeraStr(o.DiscountRate), archeraStr(o.BreakevenDays)) + p(" %s", archeraTotalsLine(o.Totals)) + p(" delta vs current: monthly net savings %s, upfront %s, discount rate %s, breakeven days %s", + archeraStr(o.DeltaVsCurrent.MonthlyNetSavings), archeraStr(o.DeltaVsCurrent.UpfrontCost), + archeraStr(o.DeltaVsCurrent.DiscountRate), archeraStr(o.DeltaVsCurrent.BreakevenDays)) + if o.ArcheraProductSupport.Status == string(insurance.ProductSupportSupported) { + p(" Archera product support: supported (%s; %s)", o.ArcheraProductSupport.Source, o.ArcheraProductSupport.Evidence) + } else { + p(" Archera product support: unknown") + } +} diff --git a/docs/cli/archera-comparison.md b/docs/cli/archera-comparison.md new file mode 100644 index 000000000..eb087ff00 --- /dev/null +++ b/docs/cli/archera-comparison.md @@ -0,0 +1,51 @@ +# Archera plan comparison + +`ri-helper archera-comparison` prints a read-only comparison for one Archera +commitment plan. It never purchases, binds or submits anything, it is +independent of `--purchase`, and it makes no Archera request unless you run it. + +## Configuration + +The feature is off until configured. There is no flag for the API key. + +| Setting | Source | Secret | +|---------|--------|--------| +| `ARCHERA_API_KEY` | environment only | yes | +| `ARCHERA_ORG_ID` | `--org-id` or environment | no | +| `ARCHERA_PLAN_ID` | `--plan-id` or environment | no | + +The org and plan IDs are Archera UUIDs, not CUDly recommendation IDs. If a +setting is missing, the command exits with status 1 and names the missing +setting. It never prints a value. + +## Usage + +```bash +export ARCHERA_API_KEY=... # keep out of shell history and CI logs +export ARCHERA_ORG_ID= +export ARCHERA_PLAN_ID= +ri-helper archera-comparison # table +ri-helper archera-comparison --format json # one JSON document on stdout +``` + +Errors go to stderr and the exit status is 1 for every failure. A vendor error +shows the sanitized HTTP status and message, plus `Retry-After` when given +(capped at 24 hours). The command does not retry. + +## Reading the output + +- Totals include the Archera premium. The API reports no currency, so amounts + are unlabelled. +- Monthly figures are 730-hour monthly rates. Upfront figures are one-time and + are never added to monthly figures. +- Unknown values print `unknown` in the table and `null` in JSON, never `0`. +- Money is printed as an exact decimal. `discount_rate` is shown as sent + (0-1 basis). +- The comparison is a hypothetical rollup for the whole plan. It is not a + bindable quote, not a guarantee, and not mapped onto recommendation rows or + CSV output. +- Product support is `supported` only when Archera's documentation lists the + commitment type; otherwise `unknown`. +- Archera sponsors CUDly development. The command prints the same two + disclosures as the post-purchase message, in both formats + (`non_gating_disclosure` and `sponsorship_disclosure` in JSON). From 7082bb8d76259e8e8c576aa41076b8a651f01b58 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Sat, 10 Oct 2026 05:18:27 +0200 Subject: [PATCH 02/10] test(insurance): distinct-value golden fixture for archera-comparison Every money field in the fixture now carries a unique value (hundreds digit is the block, units digit the field) and table and JSON output are compared against golden files, so a swapped field mapping changes the output. One value is a long decimal that float64 cannot hold exactly. The vendor error cases use the real "message" body shape and assert the key is redacted. The credential-flag check matches any flag containing key, token, secret or password. Refs #2156 Co-Authored-By: Claude Sonnet 5.5 --- cmd/archera_comparison_test.go | 128 ++++++------------ cmd/testdata/archera_comparison.json | 1 + cmd/testdata/archera_json.golden | 190 +++++++++++++++++++++++++++ cmd/testdata/archera_table.golden | 38 ++++++ 4 files changed, 271 insertions(+), 86 deletions(-) create mode 100644 cmd/testdata/archera_comparison.json create mode 100644 cmd/testdata/archera_json.golden create mode 100644 cmd/testdata/archera_table.golden diff --git a/cmd/archera_comparison_test.go b/cmd/archera_comparison_test.go index 9c68162ad..ed06efab6 100644 --- a/cmd/archera_comparison_test.go +++ b/cmd/archera_comparison_test.go @@ -9,6 +9,9 @@ import ( "net/http" "net/http/httptest" "net/url" + "os" + "path/filepath" + "regexp" "strings" "sync" "testing" @@ -27,56 +30,19 @@ const ( archeraTestPlan = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb" ) -// Synthetic fixtures shaped like the documented commitment-plans schema. -const archeraFinancialsFixture = `{ - "commitment_cost": {"total": 110, "breakdown": {"cloud_provider_cost": {"total": 100}, "archera_premium": 10.125}}, - "commitment_savings": {"net": null, "gross": 4.5}, - "covered_ondemand_cost": 104.5 -}` - -func archeraOfferFixture(isCurrent bool, offerType, lease, term, payment, name string) string { - return fmt.Sprintf(`{ - "is_current": %t, - "offer_id": "11111111-1111-4111-8111-111111111111", - "offer_org_id": "public", - "offer": {"provider": "aws", "type": %q, "region": "us-east-1", "guaranteed_display_name": %s}, - "lease_menu_item_id": %s, - "selected_amount": 3, - "commitment_type": %q, - "contract_term": %s, - "payment_option": %s, - "discount_rate": 0.3, - "breakeven_days": null, - "commitment_upfront_cost": 1200, - "commitment_financials_monthly_rate": %s, - "delta_vs_current": {"monthly_net_savings": 0, "upfront_cost": 0, "discount_rate": 0, "breakeven_days": null} - }`, isCurrent, offerType, name, lease, offerType, term, payment, archeraFinancialsFixture) -} - +// archeraComparisonFixture is a synthetic document shaped like the documented +// commitment-plans schema. Every money field carries a distinct value: the +// hundreds digit is the block (1 current totals, 2 and 6 hypotheticals, 3 +// current offer, 4 and 5 candidates) and the units digit is the field (1 cost +// total, 2 cloud cost, 3 premium, 4 gross, 5 net, 6 covered on-demand, 7 +// upfront, 8 and 9 deltas), so a swapped field mapping changes the output. The +// current-totals premium is a long decimal that float64 cannot hold exactly. func archeraComparisonFixture() string { - return `{ - "current_totals": {"commitment_financials_monthly_rate": ` + archeraFinancialsFixture + `, "commitment_upfront_cost": 1200}, - "hypothetical_totals": [{ - "contract_term": null, - "payment_option": "no_upfront", - "commitment_financials_monthly_rate": {}, - "commitment_upfront_cost": 0, - "delta_vs_current": {"monthly_net_savings": 7.25, "monthly_commitment_cost": -1, "upfront_cost": -1200}, - "line_items": [{ - "line_item_id": "22222222-2222-4222-8222-222222222222", - "actual_term": "one_year_gris", "actual_payment_option": "no_upfront", - "actual_commitment_type": "aws/AmazonEC2", - "actual_term_reason": "fallback_closest_shorter" - }] - }], - "data": [{ - "line_item_id": "22222222-2222-4222-8222-222222222222", - "current": ` + archeraOfferFixture(true, "aws/AmazonEC2", "null", "null", "null", "null") + `, - "candidates": [` + - archeraOfferFixture(false, "aws/AmazonEC2", `"33333333-3333-4333-8333-333333333333"`, `"one_year_gris"`, `"no_upfront"`, `"Offer\u001b[31m RED"`) + `,` + - archeraOfferFixture(false, "aws/SomethingNew", "null", `"one_year_gris"`, `"no_upfront"`, "null") + `] - }] - }` + b, err := os.ReadFile("testdata/archera_comparison.json") + if err != nil { + panic(err) + } + return string(b) } type archeraRecorder struct { @@ -202,6 +168,22 @@ func TestArcheraComparisonBadFormat(t *testing.T) { require.ErrorContains(t, err, "--format") } +var archeraFetchedRE = regexp.MustCompile(`(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z)`) + +// checkArcheraGolden compares output with testdata/ after masking the +// wall-clock fetch time. ARCHERA_UPDATE_GOLDEN=1 rewrites the file. +func checkArcheraGolden(t *testing.T, name, got string) { + t.Helper() + got = archeraFetchedRE.ReplaceAllString(got, "") + path := filepath.Join("testdata", name) + if os.Getenv("ARCHERA_UPDATE_GOLDEN") == "1" { + require.NoError(t, os.WriteFile(path, []byte(got), 0o600)) + } + want, err := os.ReadFile(path) + require.NoError(t, err) + assert.Equal(t, string(want), got) +} + func TestArcheraComparisonTable(t *testing.T) { rec := setupArchera(t, okHandler) out, errOut, err := runArchera(t, fullEnv()) @@ -214,24 +196,14 @@ func TestArcheraComparisonTable(t *testing.T) { assert.Empty(t, req.URL.RawQuery) assert.Equal(t, archeraTestKey, req.Header.Get("x-api-key")) - assert.Contains(t, out, archeraTitle) + checkArcheraGolden(t, "archera_table.golden", out) + assert.Contains(t, out, "never a bindable insurance quote") + assert.Contains(t, out, "103.1234567890123456789", "long decimal kept exact, not float64") assert.NotContains(t, strings.ToLower(out), "insured quote") - assert.Contains(t, out, "premium 10.125", "premium kept exact") - assert.Contains(t, out, "net savings unknown", "null money is unknown, not 0") - assert.Contains(t, out, "730-hour monthly rate") - assert.Contains(t, out, "one-time") - assert.Contains(t, out, "The API reports no currency") - assert.Contains(t, out, "reason fallback_closest_shorter") - assert.Contains(t, out, "lease attached") - assert.Contains(t, out, "Archera offer name: Offer[31m RED", "control characters stripped, text kept") assert.NotContains(t, out, "\x1b") - assert.Contains(t, out, "Archera product support: supported (") - assert.Contains(t, out, "Archera product support: unknown") - assert.Contains(t, out, "discount rate 0.3 (0-1 basis)") assert.Contains(t, out, common.ArcheraNonGatingDisclosure) assert.Contains(t, out, common.ArcheraSponsorshipDisclosure) assert.NotContains(t, out, common.ArcheraSignupURL) - assert.Contains(t, out, "not mapped onto CUDly recommendation rows") assert.NotContains(t, out+errOut, archeraTestKey) } @@ -240,30 +212,13 @@ func TestArcheraComparisonJSON(t *testing.T) { out, errOut, err := runArchera(t, fullEnv(), "--format", "json") require.NoError(t, err) assert.Empty(t, errOut) - var doc map[string]any dec := json.NewDecoder(strings.NewReader(out)) + var doc map[string]any require.NoError(t, dec.Decode(&doc), "stdout must be exactly one JSON document") assert.False(t, dec.More()) - assert.Nil(t, doc["currency"]) - assert.Equal(t, true, doc["premium_included"]) - assert.Equal(t, common.ArcheraNonGatingDisclosure, doc["non_gating_disclosure"]) - assert.Equal(t, common.ArcheraSponsorshipDisclosure, doc["sponsorship_disclosure"]) assert.NotContains(t, doc, "disclosures") - assert.NotContains(t, out, "\x1b") - assert.NotContains(t, out, `\u001b`) assert.NotContains(t, out, archeraTestKey) - - cur := doc["current_totals"].(map[string]any)["monthly_rate_730h"].(map[string]any) - assert.Equal(t, "10.125", cur["premium"]) - assert.Nil(t, cur["net_savings"]) - rows := doc["rows"].([]any) - cand := rows[0].(map[string]any)["candidates"].([]any) - support := cand[0].(map[string]any)["archera_product_support"].(map[string]any) - assert.Equal(t, "supported", support["status"]) - assert.NotEmpty(t, support["source"]) - unknown := cand[1].(map[string]any)["archera_product_support"].(map[string]any) - assert.Equal(t, "unknown", unknown["status"]) - assert.NotContains(t, unknown, "source") + checkArcheraGolden(t, "archera_json.golden", out) } func TestArcheraMoneyExact(t *testing.T) { @@ -292,9 +247,9 @@ func TestArcheraVendorErrorsAndRedaction(t *testing.T) { body string want string }{ - {"401", 401, nil, `{"detail":"nope"}`, "HTTP 401"}, - {"403 echoing key", 403, nil, `{"detail":"bad key ` + archeraTestKey + `"}`, "HTTP 403"}, - {"429", 429, map[string]string{"Retry-After": "30"}, `{"detail":"slow down"}`, "retry after 30s"}, + {"401", 401, nil, `{"message":"nope"}`, "HTTP 401"}, + {"403 echoing key", 403, nil, `{"message":"bad key ` + archeraTestKey + `"}`, "bad key [redacted]"}, + {"429", 429, map[string]string{"Retry-After": "30"}, `{"message":"slow down"}`, "retry after 30s"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { @@ -338,8 +293,9 @@ func TestArcheraRedirectNotFollowed(t *testing.T) { } func TestArcheraNoKeyFlagAndStructsRedacted(t *testing.T) { - for _, name := range []string{"api-key", "key", "token", "secret", "archera-api-key"} { - assert.Nil(t, archeraComparisonCmd.Flags().Lookup(name), name) + usages := strings.ToLower(archeraComparisonCmd.Flags().FlagUsages()) + for _, bad := range []string{"key", "token", "secret", "password"} { + assert.NotContains(t, usages, bad, "a flag looks like a credential flag") } check := func(label string) { comparison, err := insurance.DecodeComparison(strings.NewReader(archeraComparisonFixture()), archeraTestOrg, archeraTestPlan, time.Unix(0, 0)) diff --git a/cmd/testdata/archera_comparison.json b/cmd/testdata/archera_comparison.json new file mode 100644 index 000000000..a3b4cdfd9 --- /dev/null +++ b/cmd/testdata/archera_comparison.json @@ -0,0 +1 @@ +{"current_totals":{"commitment_financials_monthly_rate":{"commitment_cost":{"total":101,"breakdown":{"cloud_provider_cost":{"total":102},"archera_premium":103.1234567890123456789}},"commitment_savings":{"net":105,"gross":104},"covered_ondemand_cost":106},"commitment_upfront_cost":107},"hypothetical_totals":[{"contract_term":"one_year_gris","payment_option":"no_upfront","commitment_financials_monthly_rate":{"commitment_cost":{"total":201,"breakdown":{"cloud_provider_cost":{"total":202},"archera_premium":203}},"commitment_savings":{"net":205,"gross":204},"covered_ondemand_cost":206},"commitment_upfront_cost":207,"delta_vs_current":{"monthly_net_savings":208,"monthly_commitment_cost":209,"upfront_cost":210},"line_items":[{"line_item_id":"60000000-0000-4000-8000-0000000000b1","actual_term":"six_month_gris","actual_payment_option":"partial_upfront","actual_commitment_type":"aws/AmazonEC2","actual_term_reason":"fallback_closest_shorter"}]},{"contract_term":null,"payment_option":"all_upfront","commitment_financials_monthly_rate":{"commitment_cost":{"total":601,"breakdown":{"cloud_provider_cost":{"total":602},"archera_premium":603}},"commitment_savings":{"net":605,"gross":604},"covered_ondemand_cost":606},"commitment_upfront_cost":607,"delta_vs_current":{"monthly_net_savings":608,"monthly_commitment_cost":609,"upfront_cost":610},"line_items":[{"line_item_id":"60000000-0000-4000-8000-0000000000b2","actual_term":null,"actual_payment_option":null,"actual_commitment_type":null,"actual_term_reason":"no_alternative"}]}],"data":[{"line_item_id":"70000000-0000-4000-8000-0000000000c1","current":{"is_current":true,"offer_id":"30000000-0000-4000-8000-000000000001","offer_org_id":"public","offer":{"provider":"aws","type":"aws/AmazonEC2","region":"us-east-1","guaranteed_display_name":null},"lease_menu_item_id":null,"selected_amount":3,"commitment_type":"aws/AmazonEC2","contract_term":null,"payment_option":null,"discount_rate":0.31,"breakeven_days":31,"commitment_upfront_cost":307,"commitment_financials_monthly_rate":{"commitment_cost":{"total":301,"breakdown":{"cloud_provider_cost":{"total":302},"archera_premium":303}},"commitment_savings":{"net":305,"gross":304},"covered_ondemand_cost":306},"delta_vs_current":{"monthly_net_savings":308,"upfront_cost":309,"discount_rate":0.32,"breakeven_days":32}},"candidates":[{"is_current":false,"offer_id":"40000000-0000-4000-8000-000000000002","offer_org_id":"public","offer":{"provider":"aws","type":"aws/savingsplan/Compute","region":"eu-west-1","guaranteed_display_name":"Name Four"},"lease_menu_item_id":"40000000-0000-4000-8000-0000000000a1","selected_amount":3,"commitment_type":"aws/savingsplan/Compute","contract_term":"one_year_gris","payment_option":"no_upfront","discount_rate":0.41,"breakeven_days":41,"commitment_upfront_cost":407,"commitment_financials_monthly_rate":{"commitment_cost":{"total":401,"breakdown":{"cloud_provider_cost":{"total":402},"archera_premium":403}},"commitment_savings":{"net":405,"gross":404},"covered_ondemand_cost":406},"delta_vs_current":{"monthly_net_savings":408,"upfront_cost":409,"discount_rate":0.42,"breakeven_days":42}},{"is_current":false,"offer_id":"50000000-0000-4000-8000-000000000003","offer_org_id":"public","offer":{"provider":"aws","type":"aws/Unlisted","region":"ap-south-1","guaranteed_display_name":null},"lease_menu_item_id":null,"selected_amount":3,"commitment_type":"aws/Unlisted","contract_term":"six_month_gris","payment_option":"all_upfront","discount_rate":0.51,"breakeven_days":null,"commitment_upfront_cost":507,"commitment_financials_monthly_rate":{"commitment_cost":{"total":501,"breakdown":{"cloud_provider_cost":{"total":502},"archera_premium":503}},"commitment_savings":{"net":null,"gross":504},"covered_ondemand_cost":506},"delta_vs_current":{"monthly_net_savings":508,"upfront_cost":509,"discount_rate":0.52,"breakeven_days":null}}]}]} diff --git a/cmd/testdata/archera_json.golden b/cmd/testdata/archera_json.golden new file mode 100644 index 000000000..fd05ef8ef --- /dev/null +++ b/cmd/testdata/archera_json.golden @@ -0,0 +1,190 @@ +{ + "title": "Archera commitment plan comparison (read-only)", + "plan_id": "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", + "fetched_at": "", + "currency": null, + "premium_included": true, + "monthly_basis": "730-hour monthly rate", + "upfront_basis": "one-time", + "plan_wide_note": "This comparison covers the whole Archera plan. It is not mapped onto CUDly recommendation rows or recommendation CSV output.", + "caveat": "A plan comparison is a hypothetical rollup, never a bindable insurance quote, and never a purchase. An insured target of 100% is a requested target subject to Archera underwriting allowances, not a guarantee. The API exposes no per-line product support or allowance verdict, so product support and allowance are shown as unknown unless Archera's documentation says otherwise.", + "current_totals": { + "monthly_rate_730h": { + "commitment_cost_total": "101", + "cloud_provider_cost": "102", + "premium": "103.1234567890123456789", + "gross_savings": "104", + "net_savings": "105", + "covered_on_demand_cost": "106" + }, + "upfront_cost_one_time": "107" + }, + "hypothetical_totals": [ + { + "contract_term": "one_year_gris", + "payment_option": "no_upfront", + "totals": { + "monthly_rate_730h": { + "commitment_cost_total": "201", + "cloud_provider_cost": "202", + "premium": "203", + "gross_savings": "204", + "net_savings": "205", + "covered_on_demand_cost": "206" + }, + "upfront_cost_one_time": "207" + }, + "delta_monthly_net_savings": "208", + "delta_monthly_commitment_cost": "209", + "delta_upfront_cost": "210", + "line_items": [ + { + "line_item_id": "60000000-0000-4000-8000-0000000000b1", + "actual_term": "six_month_gris", + "actual_payment_option": "partial_upfront", + "actual_commitment_type": "aws/AmazonEC2", + "reason": "fallback_closest_shorter" + } + ] + }, + { + "contract_term": null, + "payment_option": "all_upfront", + "totals": { + "monthly_rate_730h": { + "commitment_cost_total": "601", + "cloud_provider_cost": "602", + "premium": "603", + "gross_savings": "604", + "net_savings": "605", + "covered_on_demand_cost": "606" + }, + "upfront_cost_one_time": "607" + }, + "delta_monthly_net_savings": "608", + "delta_monthly_commitment_cost": "609", + "delta_upfront_cost": "610", + "line_items": [ + { + "line_item_id": "60000000-0000-4000-8000-0000000000b2", + "actual_term": null, + "actual_payment_option": null, + "actual_commitment_type": null, + "reason": "no_alternative" + } + ] + } + ], + "rows": [ + { + "line_item_id": "70000000-0000-4000-8000-0000000000c1", + "current": { + "is_current": true, + "offer_id": "30000000-0000-4000-8000-000000000001", + "commitment_type": "aws/AmazonEC2", + "provider": "aws", + "region": "us-east-1", + "contract_term": null, + "payment_option": null, + "lease_attached": false, + "archera_offer_name": null, + "discount_rate": "0.31", + "breakeven_days": "31", + "totals": { + "monthly_rate_730h": { + "commitment_cost_total": "301", + "cloud_provider_cost": "302", + "premium": "303", + "gross_savings": "304", + "net_savings": "305", + "covered_on_demand_cost": "306" + }, + "upfront_cost_one_time": "307" + }, + "delta_vs_current": { + "monthly_net_savings": "308", + "upfront_cost": "309", + "discount_rate": "0.32", + "breakeven_days": "32" + }, + "archera_product_support": { + "status": "supported", + "source": "https://docs.archera.ai/help-center/getting-started/supported-reservable-services", + "evidence": "AWS Reserved Instances table, service \"EC2\": Guaranteed terms \"30 day, 1 year\". Mapping of API type \"aws/AmazonEC2\" to page service \"EC2\" is inferred from the schema example; not stated by Archera." + } + }, + "candidates": [ + { + "is_current": false, + "offer_id": "40000000-0000-4000-8000-000000000002", + "commitment_type": "aws/savingsplan/Compute", + "provider": "aws", + "region": "eu-west-1", + "contract_term": "one_year_gris", + "payment_option": "no_upfront", + "lease_attached": true, + "archera_offer_name": "Name Four", + "discount_rate": "0.41", + "breakeven_days": "41", + "totals": { + "monthly_rate_730h": { + "commitment_cost_total": "401", + "cloud_provider_cost": "402", + "premium": "403", + "gross_savings": "404", + "net_savings": "405", + "covered_on_demand_cost": "406" + }, + "upfront_cost_one_time": "407" + }, + "delta_vs_current": { + "monthly_net_savings": "408", + "upfront_cost": "409", + "discount_rate": "0.42", + "breakeven_days": "42" + }, + "archera_product_support": { + "status": "supported", + "source": "https://docs.archera.ai/help-center/getting-started/supported-reservable-services", + "evidence": "AWS Savings Plans table, \"Compute Savings Plan\": Guaranteed terms \"30 day, 1 year\". Mapping of API type \"aws/savingsplan/Compute\" to page service \"Compute Savings Plan\" is inferred from the schema example; not stated by Archera." + } + }, + { + "is_current": false, + "offer_id": "50000000-0000-4000-8000-000000000003", + "commitment_type": "aws/Unlisted", + "provider": "aws", + "region": "ap-south-1", + "contract_term": "six_month_gris", + "payment_option": "all_upfront", + "lease_attached": false, + "archera_offer_name": null, + "discount_rate": "0.51", + "breakeven_days": null, + "totals": { + "monthly_rate_730h": { + "commitment_cost_total": "501", + "cloud_provider_cost": "502", + "premium": "503", + "gross_savings": "504", + "net_savings": null, + "covered_on_demand_cost": "506" + }, + "upfront_cost_one_time": "507" + }, + "delta_vs_current": { + "monthly_net_savings": "508", + "upfront_cost": "509", + "discount_rate": "0.52", + "breakeven_days": null + }, + "archera_product_support": { + "status": "unknown" + } + } + ] + } + ], + "non_gating_disclosure": "This is entirely optional. CUDly's purchase and management features work fully without Archera.", + "sponsorship_disclosure": "For full disclosure, Archera sponsors CUDly's Open Source development from a fraction of their insurance premiums." +} diff --git a/cmd/testdata/archera_table.golden b/cmd/testdata/archera_table.golden new file mode 100644 index 000000000..765f6b4df --- /dev/null +++ b/cmd/testdata/archera_table.golden @@ -0,0 +1,38 @@ +Archera commitment plan comparison (read-only) +Plan: bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb Fetched: +Totals include the Archera premium. The API reports no currency, so amounts are unlabelled. +This comparison covers the whole Archera plan. It is not mapped onto CUDly recommendation rows or recommendation CSV output. +A plan comparison is a hypothetical rollup, never a bindable insurance quote, and never a purchase. An insured target of 100% is a requested target subject to Archera underwriting allowances, not a guarantee. The API exposes no per-line product support or allowance verdict, so product support and allowance are shown as unknown unless Archera's documentation says otherwise. + +Current totals: commitment cost 101, cloud provider cost 102, premium 103.1234567890123456789, gross savings 104, net savings 105, covered on-demand 106 (730-hour monthly rate); upfront 107 (one-time) + +Hypothetical 1: term one_year_gris, payment no_upfront + totals: commitment cost 201, cloud provider cost 202, premium 203, gross savings 204, net savings 205, covered on-demand 206 (730-hour monthly rate); upfront 207 (one-time) + delta vs current: monthly net savings 208, monthly commitment cost 209, upfront 210 + line item 60000000-0000-4000-8000-0000000000b1: term six_month_gris, payment partial_upfront, type aws/AmazonEC2, reason fallback_closest_shorter + +Hypothetical 2: term unknown, payment all_upfront + totals: commitment cost 601, cloud provider cost 602, premium 603, gross savings 604, net savings 605, covered on-demand 606 (730-hour monthly rate); upfront 607 (one-time) + delta vs current: monthly net savings 608, monthly commitment cost 609, upfront 610 + line item 60000000-0000-4000-8000-0000000000b2: term unknown, payment unknown, type unknown, reason no_alternative + +Line item 70000000-0000-4000-8000-0000000000c1 + current offer 30000000-0000-4000-8000-000000000001: aws aws/AmazonEC2, region us-east-1, term unknown, payment unknown + discount rate 0.31 (0-1 basis), breakeven days 31 + commitment cost 301, cloud provider cost 302, premium 303, gross savings 304, net savings 305, covered on-demand 306 (730-hour monthly rate); upfront 307 (one-time) + delta vs current: monthly net savings 308, upfront 309, discount rate 0.32, breakeven days 32 + Archera product support: supported (https://docs.archera.ai/help-center/getting-started/supported-reservable-services; AWS Reserved Instances table, service "EC2": Guaranteed terms "30 day, 1 year". Mapping of API type "aws/AmazonEC2" to page service "EC2" is inferred from the schema example; not stated by Archera.) + candidate offer 40000000-0000-4000-8000-000000000002: aws aws/savingsplan/Compute, region eu-west-1, term one_year_gris, payment no_upfront, lease attached + Archera offer name: Name Four + discount rate 0.41 (0-1 basis), breakeven days 41 + commitment cost 401, cloud provider cost 402, premium 403, gross savings 404, net savings 405, covered on-demand 406 (730-hour monthly rate); upfront 407 (one-time) + delta vs current: monthly net savings 408, upfront 409, discount rate 0.42, breakeven days 42 + Archera product support: supported (https://docs.archera.ai/help-center/getting-started/supported-reservable-services; AWS Savings Plans table, "Compute Savings Plan": Guaranteed terms "30 day, 1 year". Mapping of API type "aws/savingsplan/Compute" to page service "Compute Savings Plan" is inferred from the schema example; not stated by Archera.) + candidate offer 50000000-0000-4000-8000-000000000003: aws aws/Unlisted, region ap-south-1, term six_month_gris, payment all_upfront + discount rate 0.51 (0-1 basis), breakeven days unknown + commitment cost 501, cloud provider cost 502, premium 503, gross savings 504, net savings unknown, covered on-demand 506 (730-hour monthly rate); upfront 507 (one-time) + delta vs current: monthly net savings 508, upfront 509, discount rate 0.52, breakeven days unknown + Archera product support: unknown + +This is entirely optional. CUDly's purchase and management features work fully without Archera. +For full disclosure, Archera sponsors CUDly's Open Source development from a fraction of their insurance premiums. From 66b752d9b2b321dc5499d7e1504914b897849e5b Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Sat, 10 Oct 2026 05:47:21 +0200 Subject: [PATCH 03/10] test(insurance): pin sanitization of every vendor string in archera-comparison The fixture now injects ESC and C1 characters (and an over-256-byte name) into the vendor string fields, the goldens pin the sanitized values, and a direct DTO test covers the fields the decoder would reject. The printed fetch time is asserted to be the current instant in UTC with the process zone shifted. Pkg-supplied product support text is no longer sanitized. Refs #2156 Co-Authored-By: Claude Sonnet 5.5 --- cmd/archera_comparison_test.go | 72 +++++++++++++++++++++++++++- cmd/archera_render.go | 2 +- cmd/testdata/archera_comparison.json | 2 +- cmd/testdata/archera_json.golden | 16 +++---- cmd/testdata/archera_table.golden | 12 ++--- 5 files changed, 87 insertions(+), 17 deletions(-) diff --git a/cmd/archera_comparison_test.go b/cmd/archera_comparison_test.go index ed06efab6..32a3ae88a 100644 --- a/cmd/archera_comparison_test.go +++ b/cmd/archera_comparison_test.go @@ -16,6 +16,7 @@ import ( "sync" "testing" "time" + "unicode" "github.com/LeanerCloud/cloud-commitments-go/pkg/common" "github.com/LeanerCloud/cloud-commitments-go/pkg/insurance" @@ -112,6 +113,9 @@ func runArchera(t *testing.T, env map[string]string, args ...string) (stdout, st t.Setenv(k, v) } resetHelpFlags() + savedLocal := time.Local + time.Local = time.FixedZone("archera-test", 5*3600+1800) + t.Cleanup(func() { time.Local = savedLocal }) archeraOpts.OrgID, archeraOpts.PlanID, archeraOpts.Format = "", "", "table" var out, errb bytes.Buffer rootCmd.SetOut(&out) @@ -170,10 +174,39 @@ func TestArcheraComparisonBadFormat(t *testing.T) { var archeraFetchedRE = regexp.MustCompile(`(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z)`) +// assertArcheraFetchedUTC proves the printed fetch time is the current instant +// in UTC. runArchera shifts time.Local, so a local-zone time mislabelled "Z" +// would be hours off. +func assertArcheraFetchedUTC(t *testing.T, got string) { + t.Helper() + stamp := archeraFetchedRE.FindString(got) + require.NotEmpty(t, stamp) + require.True(t, strings.HasSuffix(stamp, "Z")) + parsed, err := time.Parse("2006-01-02T15:04:05Z", stamp) + require.NoError(t, err) + assert.WithinDuration(t, time.Now().UTC(), parsed, 2*time.Minute) +} + +// assertArcheraNoControlChars fails if any control character survives, in raw +// form or as a JSON escape. The fixture injects ESC and C1 characters into the +// vendor string fields. +func assertArcheraNoControlChars(t *testing.T, out string) { + t.Helper() + for _, r := range out { + if r != '\n' && unicode.IsControl(r) { + t.Fatalf("control character %U in output", r) + } + } + for _, esc := range []string{`\u001b`, `\u0085`, `\u009b`} { + assert.NotContains(t, out, esc) + } +} + // checkArcheraGolden compares output with testdata/ after masking the // wall-clock fetch time. ARCHERA_UPDATE_GOLDEN=1 rewrites the file. func checkArcheraGolden(t *testing.T, name, got string) { t.Helper() + assertArcheraFetchedUTC(t, got) got = archeraFetchedRE.ReplaceAllString(got, "") path := filepath.Join("testdata", name) if os.Getenv("ARCHERA_UPDATE_GOLDEN") == "1" { @@ -200,7 +233,7 @@ func TestArcheraComparisonTable(t *testing.T) { assert.Contains(t, out, "never a bindable insurance quote") assert.Contains(t, out, "103.1234567890123456789", "long decimal kept exact, not float64") assert.NotContains(t, strings.ToLower(out), "insured quote") - assert.NotContains(t, out, "\x1b") + assertArcheraNoControlChars(t, out) assert.Contains(t, out, common.ArcheraNonGatingDisclosure) assert.Contains(t, out, common.ArcheraSponsorshipDisclosure) assert.NotContains(t, out, common.ArcheraSignupURL) @@ -218,6 +251,7 @@ func TestArcheraComparisonJSON(t *testing.T) { assert.False(t, dec.More()) assert.NotContains(t, doc, "disclosures") assert.NotContains(t, out, archeraTestKey) + assertArcheraNoControlChars(t, out) checkArcheraGolden(t, "archera_json.golden", out) } @@ -313,3 +347,39 @@ func TestArcheraNoKeyFlagAndStructsRedacted(t *testing.T) { require.NoError(t, err) check("after") } + +// TestArcheraDTOSanitizesEveryVendorString builds the comparison directly, so +// fields the decoder would reject (enums, UUIDs, terms) still carry control +// characters into the DTO. +func TestArcheraDTOSanitizesEveryVendorString(t *testing.T) { + dirty := func(s string) string { return s + "\x1b[31m\u0085\u009b" } + str := func(s string) *string { v := dirty(s); return &v } + pay := insurance.PaymentOption(dirty("no_upfront")) + entry := func(id string) insurance.OfferEntry { + return insurance.OfferEntry{ + OfferID: dirty(id), CommitmentType: dirty("type" + id), Provider: common.ProviderType(dirty("aws")), + ContractTerm: str("term"), PaymentOption: &pay, Region: str("region"), GuaranteedDisplayName: str("name"), + LeaseMenuItemID: str("lease"), + } + } + c := &insurance.Comparison{ + PlanID: dirty("plan"), + Currency: str("usd"), + Hypotheticals: []insurance.Hypothetical{{ + ContractTerm: str("hterm"), PaymentOption: pay, + LineItems: []insurance.HypotheticalLineItem{{ + LineItemID: dirty("li"), ActualTerm: str("aterm"), ActualPaymentOption: &pay, + ActualCommitmentType: str("atype"), Reason: insurance.TermReason(dirty("exact_match")), + }}, + }}, + Rows: []insurance.ComparisonRow{{LineItemID: dirty("row"), Current: entry("c"), Candidates: []insurance.OfferEntry{entry("k")}}}, + } + var buf bytes.Buffer + dto := buildArcheraDTO(c) + require.NoError(t, renderArcheraJSON(&buf, dto)) + assertArcheraNoControlChars(t, buf.String()) + buf.Reset() + require.NoError(t, renderArcheraTable(&buf, dto)) + assertArcheraNoControlChars(t, buf.String()) + assert.Equal(t, "plan[31m", dto.PlanID) +} diff --git a/cmd/archera_render.go b/cmd/archera_render.go index ece6a3839..15587e51d 100644 --- a/cmd/archera_render.go +++ b/cmd/archera_render.go @@ -182,7 +182,7 @@ func archeraOffer(e insurance.OfferEntry) archeraOfferDTO { support := insurance.AssessProductSupport(e.Provider, e.CommitmentType) ps := archeraProductSupportDTO{Status: string(support.Status)} if support.Status == insurance.ProductSupportSupported { - ps.Source, ps.Evidence = archeraSanitize(support.Source), archeraSanitize(support.Evidence) + ps.Source, ps.Evidence = support.Source, support.Evidence } return archeraOfferDTO{ IsCurrent: e.IsCurrent, diff --git a/cmd/testdata/archera_comparison.json b/cmd/testdata/archera_comparison.json index a3b4cdfd9..0d6a6bbd7 100644 --- a/cmd/testdata/archera_comparison.json +++ b/cmd/testdata/archera_comparison.json @@ -1 +1 @@ -{"current_totals":{"commitment_financials_monthly_rate":{"commitment_cost":{"total":101,"breakdown":{"cloud_provider_cost":{"total":102},"archera_premium":103.1234567890123456789}},"commitment_savings":{"net":105,"gross":104},"covered_ondemand_cost":106},"commitment_upfront_cost":107},"hypothetical_totals":[{"contract_term":"one_year_gris","payment_option":"no_upfront","commitment_financials_monthly_rate":{"commitment_cost":{"total":201,"breakdown":{"cloud_provider_cost":{"total":202},"archera_premium":203}},"commitment_savings":{"net":205,"gross":204},"covered_ondemand_cost":206},"commitment_upfront_cost":207,"delta_vs_current":{"monthly_net_savings":208,"monthly_commitment_cost":209,"upfront_cost":210},"line_items":[{"line_item_id":"60000000-0000-4000-8000-0000000000b1","actual_term":"six_month_gris","actual_payment_option":"partial_upfront","actual_commitment_type":"aws/AmazonEC2","actual_term_reason":"fallback_closest_shorter"}]},{"contract_term":null,"payment_option":"all_upfront","commitment_financials_monthly_rate":{"commitment_cost":{"total":601,"breakdown":{"cloud_provider_cost":{"total":602},"archera_premium":603}},"commitment_savings":{"net":605,"gross":604},"covered_ondemand_cost":606},"commitment_upfront_cost":607,"delta_vs_current":{"monthly_net_savings":608,"monthly_commitment_cost":609,"upfront_cost":610},"line_items":[{"line_item_id":"60000000-0000-4000-8000-0000000000b2","actual_term":null,"actual_payment_option":null,"actual_commitment_type":null,"actual_term_reason":"no_alternative"}]}],"data":[{"line_item_id":"70000000-0000-4000-8000-0000000000c1","current":{"is_current":true,"offer_id":"30000000-0000-4000-8000-000000000001","offer_org_id":"public","offer":{"provider":"aws","type":"aws/AmazonEC2","region":"us-east-1","guaranteed_display_name":null},"lease_menu_item_id":null,"selected_amount":3,"commitment_type":"aws/AmazonEC2","contract_term":null,"payment_option":null,"discount_rate":0.31,"breakeven_days":31,"commitment_upfront_cost":307,"commitment_financials_monthly_rate":{"commitment_cost":{"total":301,"breakdown":{"cloud_provider_cost":{"total":302},"archera_premium":303}},"commitment_savings":{"net":305,"gross":304},"covered_ondemand_cost":306},"delta_vs_current":{"monthly_net_savings":308,"upfront_cost":309,"discount_rate":0.32,"breakeven_days":32}},"candidates":[{"is_current":false,"offer_id":"40000000-0000-4000-8000-000000000002","offer_org_id":"public","offer":{"provider":"aws","type":"aws/savingsplan/Compute","region":"eu-west-1","guaranteed_display_name":"Name Four"},"lease_menu_item_id":"40000000-0000-4000-8000-0000000000a1","selected_amount":3,"commitment_type":"aws/savingsplan/Compute","contract_term":"one_year_gris","payment_option":"no_upfront","discount_rate":0.41,"breakeven_days":41,"commitment_upfront_cost":407,"commitment_financials_monthly_rate":{"commitment_cost":{"total":401,"breakdown":{"cloud_provider_cost":{"total":402},"archera_premium":403}},"commitment_savings":{"net":405,"gross":404},"covered_ondemand_cost":406},"delta_vs_current":{"monthly_net_savings":408,"upfront_cost":409,"discount_rate":0.42,"breakeven_days":42}},{"is_current":false,"offer_id":"50000000-0000-4000-8000-000000000003","offer_org_id":"public","offer":{"provider":"aws","type":"aws/Unlisted","region":"ap-south-1","guaranteed_display_name":null},"lease_menu_item_id":null,"selected_amount":3,"commitment_type":"aws/Unlisted","contract_term":"six_month_gris","payment_option":"all_upfront","discount_rate":0.51,"breakeven_days":null,"commitment_upfront_cost":507,"commitment_financials_monthly_rate":{"commitment_cost":{"total":501,"breakdown":{"cloud_provider_cost":{"total":502},"archera_premium":503}},"commitment_savings":{"net":null,"gross":504},"covered_ondemand_cost":506},"delta_vs_current":{"monthly_net_savings":508,"upfront_cost":509,"discount_rate":0.52,"breakeven_days":null}}]}]} +{"current_totals":{"commitment_financials_monthly_rate":{"commitment_cost":{"total":101,"breakdown":{"cloud_provider_cost":{"total":102},"archera_premium":103.1234567890123456789}},"commitment_savings":{"net":105,"gross":104},"covered_ondemand_cost":106},"commitment_upfront_cost":107},"hypothetical_totals":[{"contract_term":"one_year_gris","payment_option":"no_upfront","commitment_financials_monthly_rate":{"commitment_cost":{"total":201,"breakdown":{"cloud_provider_cost":{"total":202},"archera_premium":203}},"commitment_savings":{"net":205,"gross":204},"covered_ondemand_cost":206},"commitment_upfront_cost":207,"delta_vs_current":{"monthly_net_savings":208,"monthly_commitment_cost":209,"upfront_cost":210},"line_items":[{"line_item_id":"60000000-0000-4000-8000-0000000000b1\u001b[31m\u0085","actual_term":"six_month_gris","actual_payment_option":"partial_upfront","actual_commitment_type":"aws/AmazonEC2\u001b[31m\u0085","actual_term_reason":"fallback_closest_shorter"}]},{"contract_term":null,"payment_option":"all_upfront","commitment_financials_monthly_rate":{"commitment_cost":{"total":601,"breakdown":{"cloud_provider_cost":{"total":602},"archera_premium":603}},"commitment_savings":{"net":605,"gross":604},"covered_ondemand_cost":606},"commitment_upfront_cost":607,"delta_vs_current":{"monthly_net_savings":608,"monthly_commitment_cost":609,"upfront_cost":610},"line_items":[{"line_item_id":"60000000-0000-4000-8000-0000000000b2","actual_term":null,"actual_payment_option":null,"actual_commitment_type":null,"actual_term_reason":"no_alternative"}]}],"data":[{"line_item_id":"70000000-0000-4000-8000-0000000000c1\u001b[31m\u0085","current":{"is_current":true,"offer_id":"30000000-0000-4000-8000-000000000001\u001b[31m\u0085","offer_org_id":"public","offer":{"provider":"aws","type":"aws/AmazonEC2","region":"us-east-1\u001b[31m\u0085","guaranteed_display_name":null},"lease_menu_item_id":null,"selected_amount":3,"commitment_type":"aws/AmazonEC2","contract_term":null,"payment_option":null,"discount_rate":0.31,"breakeven_days":31,"commitment_upfront_cost":307,"commitment_financials_monthly_rate":{"commitment_cost":{"total":301,"breakdown":{"cloud_provider_cost":{"total":302},"archera_premium":303}},"commitment_savings":{"net":305,"gross":304},"covered_ondemand_cost":306},"delta_vs_current":{"monthly_net_savings":308,"upfront_cost":309,"discount_rate":0.32,"breakeven_days":32}},"candidates":[{"is_current":false,"offer_id":"40000000-0000-4000-8000-000000000002","offer_org_id":"public","offer":{"provider":"aws","type":"aws/savingsplan/Compute","region":"eu-west-1\u001b[31m\u0085","guaranteed_display_name":"Name\u001b[31m\u0085FourNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN"},"lease_menu_item_id":"40000000-0000-4000-8000-0000000000a1","selected_amount":3,"commitment_type":"aws/savingsplan/Compute","contract_term":"one_year_gris","payment_option":"no_upfront","discount_rate":0.41,"breakeven_days":41,"commitment_upfront_cost":407,"commitment_financials_monthly_rate":{"commitment_cost":{"total":401,"breakdown":{"cloud_provider_cost":{"total":402},"archera_premium":403}},"commitment_savings":{"net":405,"gross":404},"covered_ondemand_cost":406},"delta_vs_current":{"monthly_net_savings":408,"upfront_cost":409,"discount_rate":0.42,"breakeven_days":42}},{"is_current":false,"offer_id":"50000000-0000-4000-8000-000000000003","offer_org_id":"public","offer":{"provider":"aws","type":"aws/Unlisted\u001b[31m\u0085","region":"ap-south-1","guaranteed_display_name":null},"lease_menu_item_id":null,"selected_amount":3,"commitment_type":"aws/Unlisted\u001b[31m\u0085","contract_term":"six_month_gris","payment_option":"all_upfront","discount_rate":0.51,"breakeven_days":null,"commitment_upfront_cost":507,"commitment_financials_monthly_rate":{"commitment_cost":{"total":501,"breakdown":{"cloud_provider_cost":{"total":502},"archera_premium":503}},"commitment_savings":{"net":null,"gross":504},"covered_ondemand_cost":506},"delta_vs_current":{"monthly_net_savings":508,"upfront_cost":509,"discount_rate":0.52,"breakeven_days":null}}]}]} diff --git a/cmd/testdata/archera_json.golden b/cmd/testdata/archera_json.golden index fd05ef8ef..28e8221f6 100644 --- a/cmd/testdata/archera_json.golden +++ b/cmd/testdata/archera_json.golden @@ -39,10 +39,10 @@ "delta_upfront_cost": "210", "line_items": [ { - "line_item_id": "60000000-0000-4000-8000-0000000000b1", + "line_item_id": "60000000-0000-4000-8000-0000000000b1[31m", "actual_term": "six_month_gris", "actual_payment_option": "partial_upfront", - "actual_commitment_type": "aws/AmazonEC2", + "actual_commitment_type": "aws/AmazonEC2[31m", "reason": "fallback_closest_shorter" } ] @@ -77,13 +77,13 @@ ], "rows": [ { - "line_item_id": "70000000-0000-4000-8000-0000000000c1", + "line_item_id": "70000000-0000-4000-8000-0000000000c1[31m", "current": { "is_current": true, - "offer_id": "30000000-0000-4000-8000-000000000001", + "offer_id": "30000000-0000-4000-8000-000000000001[31m", "commitment_type": "aws/AmazonEC2", "provider": "aws", - "region": "us-east-1", + "region": "us-east-1[31m", "contract_term": null, "payment_option": null, "lease_attached": false, @@ -119,11 +119,11 @@ "offer_id": "40000000-0000-4000-8000-000000000002", "commitment_type": "aws/savingsplan/Compute", "provider": "aws", - "region": "eu-west-1", + "region": "eu-west-1[31m", "contract_term": "one_year_gris", "payment_option": "no_upfront", "lease_attached": true, - "archera_offer_name": "Name Four", + "archera_offer_name": "Name[31mFourNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN", "discount_rate": "0.41", "breakeven_days": "41", "totals": { @@ -152,7 +152,7 @@ { "is_current": false, "offer_id": "50000000-0000-4000-8000-000000000003", - "commitment_type": "aws/Unlisted", + "commitment_type": "aws/Unlisted[31m", "provider": "aws", "region": "ap-south-1", "contract_term": "six_month_gris", diff --git a/cmd/testdata/archera_table.golden b/cmd/testdata/archera_table.golden index 765f6b4df..4b83cd6c3 100644 --- a/cmd/testdata/archera_table.golden +++ b/cmd/testdata/archera_table.golden @@ -9,26 +9,26 @@ Current totals: commitment cost 101, cloud provider cost 102, premium 103.123456 Hypothetical 1: term one_year_gris, payment no_upfront totals: commitment cost 201, cloud provider cost 202, premium 203, gross savings 204, net savings 205, covered on-demand 206 (730-hour monthly rate); upfront 207 (one-time) delta vs current: monthly net savings 208, monthly commitment cost 209, upfront 210 - line item 60000000-0000-4000-8000-0000000000b1: term six_month_gris, payment partial_upfront, type aws/AmazonEC2, reason fallback_closest_shorter + line item 60000000-0000-4000-8000-0000000000b1[31m: term six_month_gris, payment partial_upfront, type aws/AmazonEC2[31m, reason fallback_closest_shorter Hypothetical 2: term unknown, payment all_upfront totals: commitment cost 601, cloud provider cost 602, premium 603, gross savings 604, net savings 605, covered on-demand 606 (730-hour monthly rate); upfront 607 (one-time) delta vs current: monthly net savings 608, monthly commitment cost 609, upfront 610 line item 60000000-0000-4000-8000-0000000000b2: term unknown, payment unknown, type unknown, reason no_alternative -Line item 70000000-0000-4000-8000-0000000000c1 - current offer 30000000-0000-4000-8000-000000000001: aws aws/AmazonEC2, region us-east-1, term unknown, payment unknown +Line item 70000000-0000-4000-8000-0000000000c1[31m + current offer 30000000-0000-4000-8000-000000000001[31m: aws aws/AmazonEC2, region us-east-1[31m, term unknown, payment unknown discount rate 0.31 (0-1 basis), breakeven days 31 commitment cost 301, cloud provider cost 302, premium 303, gross savings 304, net savings 305, covered on-demand 306 (730-hour monthly rate); upfront 307 (one-time) delta vs current: monthly net savings 308, upfront 309, discount rate 0.32, breakeven days 32 Archera product support: supported (https://docs.archera.ai/help-center/getting-started/supported-reservable-services; AWS Reserved Instances table, service "EC2": Guaranteed terms "30 day, 1 year". Mapping of API type "aws/AmazonEC2" to page service "EC2" is inferred from the schema example; not stated by Archera.) - candidate offer 40000000-0000-4000-8000-000000000002: aws aws/savingsplan/Compute, region eu-west-1, term one_year_gris, payment no_upfront, lease attached - Archera offer name: Name Four + candidate offer 40000000-0000-4000-8000-000000000002: aws aws/savingsplan/Compute, region eu-west-1[31m, term one_year_gris, payment no_upfront, lease attached + Archera offer name: Name[31mFourNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN discount rate 0.41 (0-1 basis), breakeven days 41 commitment cost 401, cloud provider cost 402, premium 403, gross savings 404, net savings 405, covered on-demand 406 (730-hour monthly rate); upfront 407 (one-time) delta vs current: monthly net savings 408, upfront 409, discount rate 0.42, breakeven days 42 Archera product support: supported (https://docs.archera.ai/help-center/getting-started/supported-reservable-services; AWS Savings Plans table, "Compute Savings Plan": Guaranteed terms "30 day, 1 year". Mapping of API type "aws/savingsplan/Compute" to page service "Compute Savings Plan" is inferred from the schema example; not stated by Archera.) - candidate offer 50000000-0000-4000-8000-000000000003: aws aws/Unlisted, region ap-south-1, term six_month_gris, payment all_upfront + candidate offer 50000000-0000-4000-8000-000000000003: aws aws/Unlisted[31m, region ap-south-1, term six_month_gris, payment all_upfront discount rate 0.51 (0-1 basis), breakeven days unknown commitment cost 501, cloud provider cost 502, premium 503, gross savings 504, net savings unknown, covered on-demand 506 (730-hour monthly rate); upfront 507 (one-time) delta vs current: monthly net savings 508, upfront 509, discount rate 0.52, breakeven days unknown From 8f95325b31d3886445e714016217fc2046450762 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Mon, 12 Oct 2026 13:48:18 +0200 Subject: [PATCH 04/10] fix(insurance): harden archera-comparison tests and error output Set the test zone once in init instead of writing time.Local per test (data race with the httptest goroutines). Capture the real os.Stdout/os.Stderr, assert flag-over-env precedence, our own plan-ID message and the currency mapping, and add a second row and a third hypothetical to the fixture. Strip format and line/paragraph separator characters (bidi overrides) like the platform cleaner. Decode errors from pkg/insurance echo the rejected vendor value, so cap and sanitize all non-HTTP error text; tested with a 5000-byte hostile value in every validated enum field. Refs #2156 Co-Authored-By: Claude Sonnet 5.5 --- cmd/archera_comparison.go | 19 +++-- cmd/archera_comparison_test.go | 101 ++++++++++++++++++++---- cmd/archera_render.go | 15 ++-- cmd/testdata/archera_comparison.json | 2 +- cmd/testdata/archera_json.golden | 112 +++++++++++++++++++++++++++ cmd/testdata/archera_table.golden | 20 +++++ 6 files changed, 245 insertions(+), 24 deletions(-) diff --git a/cmd/archera_comparison.go b/cmd/archera_comparison.go index 92df84415..5bbe771a9 100644 --- a/cmd/archera_comparison.go +++ b/cmd/archera_comparison.go @@ -81,11 +81,7 @@ func runArcheraComparison(cmd *cobra.Command, _ []string) error { } comparison, err := client.Comparison(cmd.Context(), insurance.ComparisonRequest{PlanID: planID}) if err != nil { - var httpErr *insurance.HTTPError - if errors.As(err, &httpErr) && httpErr.RetryAfter > 0 { - return fmt.Errorf("%w (retry after %s; not retried automatically)", err, httpErr.RetryAfter) - } - return err + return archeraSafeError(err) } dto := buildArcheraDTO(comparison) if format == "json" { @@ -111,3 +107,16 @@ func archeraSettings() (key, orgID, planID string, err error) { } return key, orgID, planID, nil } + +// archeraSafeError renders a client error as bounded, terminal-safe text. +// HTTP errors keep their sanitized message and Retry-After. Other errors +// (decode failures echo the rejected vendor value) are stripped of control +// and format characters and capped, because their text is vendor-controlled. +func archeraSafeError(err error) error { + msg := err.Error() + var httpErr *insurance.HTTPError + if errors.As(err, &httpErr) && httpErr.RetryAfter > 0 { + msg = fmt.Sprintf("%s (retry after %s; not retried automatically)", msg, httpErr.RetryAfter) + } + return errors.New(archeraSanitizeN(msg, archeraMaxErrorSize)) +} diff --git a/cmd/archera_comparison_test.go b/cmd/archera_comparison_test.go index 32a3ae88a..4d6df111a 100644 --- a/cmd/archera_comparison_test.go +++ b/cmd/archera_comparison_test.go @@ -5,6 +5,7 @@ import ( "context" "encoding/json" "fmt" + "io" "math/big" "net/http" "net/http/httptest" @@ -113,19 +114,48 @@ func runArchera(t *testing.T, env map[string]string, args ...string) (stdout, st t.Setenv(k, v) } resetHelpFlags() - savedLocal := time.Local - time.Local = time.FixedZone("archera-test", 5*3600+1800) - t.Cleanup(func() { time.Local = savedLocal }) archeraOpts.OrgID, archeraOpts.PlanID, archeraOpts.Format = "", "", "table" var out, errb bytes.Buffer rootCmd.SetOut(&out) rootCmd.SetErr(&errb) rootCmd.SetArgs(append([]string{"archera-comparison"}, args...)) t.Cleanup(func() { rootCmd.SetOut(nil); rootCmd.SetErr(nil); rootCmd.SetArgs(nil) }) - err = rootCmd.Execute() - return out.String(), errb.String(), err + realOut, realErr := captureRealStd(t, func() { err = rootCmd.Execute() }) + // Anything the command wrote to the real process streams is returned with + // the cobra stderr so every caller's key grep covers it. + return out.String(), errb.String() + realOut + realErr, err } +// captureRealStd swaps os.Stdout and os.Stderr for pipes while fn runs, so a +// write that bypasses cobra's buffers is still observed. +func captureRealStd(t *testing.T, fn func()) (stdout, stderr string) { + t.Helper() + savedOut, savedErr := os.Stdout, os.Stderr + outR, outW, err := os.Pipe() + require.NoError(t, err) + errR, errW, err := os.Pipe() + require.NoError(t, err) + var outBuf, errBuf bytes.Buffer + var wg sync.WaitGroup + wg.Add(2) + go func() { defer wg.Done(); _, _ = io.Copy(&outBuf, outR) }() + go func() { defer wg.Done(); _, _ = io.Copy(&errBuf, errR) }() + os.Stdout, os.Stderr = outW, errW + func() { + defer func() { os.Stdout, os.Stderr = savedOut, savedErr }() + fn() + }() + require.NoError(t, outW.Close()) + require.NoError(t, errW.Close()) + wg.Wait() + return outBuf.String(), errBuf.String() +} + +// The test binary runs in a non-UTC zone from the start, so a fetch time +// printed in local time but labeled Z is visible. Set here, before any +// goroutine exists, because time.Local is read concurrently by net/http. +func init() { time.Local = time.FixedZone("archera-test", 5*3600+1800) } + func fullEnv() map[string]string { return map[string]string{archeraEnvAPIKey: archeraTestKey, archeraEnvOrgID: archeraTestOrg, archeraEnvPlanID: archeraTestPlan} } @@ -151,15 +181,15 @@ func TestArcheraComparisonIncompleteConfig(t *testing.T) { env map[string]string wantMsg string }{ - {"no key", map[string]string{archeraEnvOrgID: archeraTestOrg, archeraEnvPlanID: archeraTestPlan}, archeraEnvAPIKey}, - {"no org", map[string]string{archeraEnvAPIKey: archeraTestKey, archeraEnvPlanID: archeraTestPlan}, "org ID"}, - {"no plan", map[string]string{archeraEnvAPIKey: archeraTestKey, archeraEnvOrgID: archeraTestOrg}, "plan ID"}, + {"no key", map[string]string{archeraEnvOrgID: archeraTestOrg, archeraEnvPlanID: archeraTestPlan}, "ARCHERA_API_KEY is not set (read from the environment only)"}, + {"no org", map[string]string{archeraEnvAPIKey: archeraTestKey, archeraEnvPlanID: archeraTestPlan}, "archera org ID is not set (--org-id or ARCHERA_ORG_ID)"}, + {"no plan", map[string]string{archeraEnvAPIKey: archeraTestKey, archeraEnvOrgID: archeraTestOrg}, "archera plan ID is not set (--plan-id or ARCHERA_PLAN_ID)"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { out, errOut, err := runArchera(t, tc.env) require.Error(t, err) - assert.Contains(t, err.Error(), tc.wantMsg) + assert.Equal(t, tc.wantMsg, err.Error()) assert.NotContains(t, out+errOut+err.Error(), archeraTestKey) }) } @@ -175,7 +205,7 @@ func TestArcheraComparisonBadFormat(t *testing.T) { var archeraFetchedRE = regexp.MustCompile(`(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z)`) // assertArcheraFetchedUTC proves the printed fetch time is the current instant -// in UTC. runArchera shifts time.Local, so a local-zone time mislabelled "Z" +// in UTC. runArchera shifts time.Local, so a local-zone time mislabeled "Z" // would be hours off. func assertArcheraFetchedUTC(t *testing.T, got string) { t.Helper() @@ -193,8 +223,8 @@ func assertArcheraFetchedUTC(t *testing.T, got string) { func assertArcheraNoControlChars(t *testing.T, out string) { t.Helper() for _, r := range out { - if r != '\n' && unicode.IsControl(r) { - t.Fatalf("control character %U in output", r) + if r != '\n' && (unicode.IsControl(r) || unicode.Is(unicode.Cf, r) || unicode.Is(unicode.Zl, r) || unicode.Is(unicode.Zp, r)) { + t.Fatalf("control or format character %U in output", r) } } for _, esc := range []string{`\u001b`, `\u0085`, `\u009b`} { @@ -352,7 +382,7 @@ func TestArcheraNoKeyFlagAndStructsRedacted(t *testing.T) { // fields the decoder would reject (enums, UUIDs, terms) still carry control // characters into the DTO. func TestArcheraDTOSanitizesEveryVendorString(t *testing.T) { - dirty := func(s string) string { return s + "\x1b[31m\u0085\u009b" } + dirty := func(s string) string { return s + "\x1b[31m\u0085\u009b\u202e\u200b\u2028\u2029" } str := func(s string) *string { v := dirty(s); return &v } pay := insurance.PaymentOption(dirty("no_upfront")) entry := func(id string) insurance.OfferEntry { @@ -382,4 +412,49 @@ func TestArcheraDTOSanitizesEveryVendorString(t *testing.T) { require.NoError(t, renderArcheraTable(&buf, dto)) assertArcheraNoControlChars(t, buf.String()) assert.Equal(t, "plan[31m", dto.PlanID) + require.NotNil(t, dto.Currency) + assert.Equal(t, "usd[31m", *dto.Currency) + buf.Reset() + require.NoError(t, renderArcheraJSON(&buf, dto)) + assert.Contains(t, buf.String(), `"currency": "usd[31m"`) +} + +func TestArcheraFlagsOverrideEnvironment(t *testing.T) { + rec := setupArchera(t, okHandler) + flagOrg := "cccccccc-cccc-4ccc-8ccc-cccccccccccc" + flagPlan := "dddddddd-dddd-4ddd-8ddd-dddddddddddd" + _, _, err := runArchera(t, fullEnv(), "--org-id", flagOrg, "--plan-id", flagPlan) + require.NoError(t, err) + require.Len(t, rec.requests, 1) + assert.Equal(t, "/v1/org/"+flagOrg+"/commitment-plans/"+flagPlan+"/comparison", rec.requests[0].URL.Path) +} + +// Decode errors from pkg/insurance echo the rejected vendor value, so the +// command must bound and clean them. +func TestArcheraDecodeErrorsAreBoundedAndClean(t *testing.T) { + hostile := strings.Repeat("X", 5000) + `\u001b[31m\u202e` + paths := map[string]string{ + "hypothetical payment_option": `"payment_option":"no_upfront"`, + "hypothetical contract_term": `"contract_term":"one_year_gris"`, + "line item actual_term": `"actual_term":"six_month_gris"`, + "line item actual payment": `"actual_payment_option":"partial_upfront"`, + "line item actual_term_reason": `"actual_term_reason":"fallback_closest_shorter"`, + "offer contract_term": `"contract_term":"six_month_gris"`, + "offer payment_option": `"payment_option":"all_upfront"`, + } + for name, field := range paths { + t.Run(name, func(t *testing.T) { + key := field[:strings.Index(field, `":`)+2] + body := strings.Replace(archeraComparisonFixture(), field, key+`"`+hostile+`"`, 1) + require.NotEqual(t, archeraComparisonFixture(), body) + setupArchera(t, func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(body)) }) + out, errOut, err := runArchera(t, fullEnv()) + require.Error(t, err) + assert.LessOrEqual(t, len(err.Error()), archeraMaxErrorSize) + assert.NotContains(t, err.Error(), strings.Repeat("X", 600)) + assertArcheraNoControlChars(t, err.Error()) + assert.Empty(t, out) + assert.NotContains(t, out+errOut+err.Error(), archeraTestKey) + }) + } } diff --git a/cmd/archera_render.go b/cmd/archera_render.go index 15587e51d..fd5849424 100644 --- a/cmd/archera_render.go +++ b/cmd/archera_render.go @@ -23,19 +23,24 @@ const ( "The API exposes no per-line product support or allowance verdict, so product support and allowance are shown as unknown unless Archera's documentation says otherwise." archeraPremiumNote = "Totals include the Archera premium. The API reports no currency, so amounts are unlabelled." archeraMaxFieldSize = 256 + archeraMaxErrorSize = 512 archeraMaxMoneyPrec = 100 ) // archeraSanitize strips control characters (ESC, C1 and the rest of -// unicode.IsControl) from a vendor string and caps it at archeraMaxFieldSize -// bytes on a rune boundary, so it is safe to print to a terminal. -func archeraSanitize(s string) string { +// unicode.IsControl), format characters (bidi overrides, zero-width) and line +// or paragraph separators from a vendor string and caps it at +// archeraMaxFieldSize bytes on a rune boundary, so it is safe to print to a +// terminal. +func archeraSanitize(s string) string { return archeraSanitizeN(s, archeraMaxFieldSize) } + +func archeraSanitizeN(s string, maxBytes int) string { var b strings.Builder for _, r := range s { - if unicode.IsControl(r) { + if unicode.IsControl(r) || unicode.Is(unicode.Cf, r) || unicode.Is(unicode.Zl, r) || unicode.Is(unicode.Zp, r) { continue } - if b.Len()+utf8.RuneLen(r) > archeraMaxFieldSize { + if b.Len()+utf8.RuneLen(r) > maxBytes { break } b.WriteRune(r) diff --git a/cmd/testdata/archera_comparison.json b/cmd/testdata/archera_comparison.json index 0d6a6bbd7..d6f6fa03d 100644 --- a/cmd/testdata/archera_comparison.json +++ b/cmd/testdata/archera_comparison.json @@ -1 +1 @@ -{"current_totals":{"commitment_financials_monthly_rate":{"commitment_cost":{"total":101,"breakdown":{"cloud_provider_cost":{"total":102},"archera_premium":103.1234567890123456789}},"commitment_savings":{"net":105,"gross":104},"covered_ondemand_cost":106},"commitment_upfront_cost":107},"hypothetical_totals":[{"contract_term":"one_year_gris","payment_option":"no_upfront","commitment_financials_monthly_rate":{"commitment_cost":{"total":201,"breakdown":{"cloud_provider_cost":{"total":202},"archera_premium":203}},"commitment_savings":{"net":205,"gross":204},"covered_ondemand_cost":206},"commitment_upfront_cost":207,"delta_vs_current":{"monthly_net_savings":208,"monthly_commitment_cost":209,"upfront_cost":210},"line_items":[{"line_item_id":"60000000-0000-4000-8000-0000000000b1\u001b[31m\u0085","actual_term":"six_month_gris","actual_payment_option":"partial_upfront","actual_commitment_type":"aws/AmazonEC2\u001b[31m\u0085","actual_term_reason":"fallback_closest_shorter"}]},{"contract_term":null,"payment_option":"all_upfront","commitment_financials_monthly_rate":{"commitment_cost":{"total":601,"breakdown":{"cloud_provider_cost":{"total":602},"archera_premium":603}},"commitment_savings":{"net":605,"gross":604},"covered_ondemand_cost":606},"commitment_upfront_cost":607,"delta_vs_current":{"monthly_net_savings":608,"monthly_commitment_cost":609,"upfront_cost":610},"line_items":[{"line_item_id":"60000000-0000-4000-8000-0000000000b2","actual_term":null,"actual_payment_option":null,"actual_commitment_type":null,"actual_term_reason":"no_alternative"}]}],"data":[{"line_item_id":"70000000-0000-4000-8000-0000000000c1\u001b[31m\u0085","current":{"is_current":true,"offer_id":"30000000-0000-4000-8000-000000000001\u001b[31m\u0085","offer_org_id":"public","offer":{"provider":"aws","type":"aws/AmazonEC2","region":"us-east-1\u001b[31m\u0085","guaranteed_display_name":null},"lease_menu_item_id":null,"selected_amount":3,"commitment_type":"aws/AmazonEC2","contract_term":null,"payment_option":null,"discount_rate":0.31,"breakeven_days":31,"commitment_upfront_cost":307,"commitment_financials_monthly_rate":{"commitment_cost":{"total":301,"breakdown":{"cloud_provider_cost":{"total":302},"archera_premium":303}},"commitment_savings":{"net":305,"gross":304},"covered_ondemand_cost":306},"delta_vs_current":{"monthly_net_savings":308,"upfront_cost":309,"discount_rate":0.32,"breakeven_days":32}},"candidates":[{"is_current":false,"offer_id":"40000000-0000-4000-8000-000000000002","offer_org_id":"public","offer":{"provider":"aws","type":"aws/savingsplan/Compute","region":"eu-west-1\u001b[31m\u0085","guaranteed_display_name":"Name\u001b[31m\u0085FourNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN"},"lease_menu_item_id":"40000000-0000-4000-8000-0000000000a1","selected_amount":3,"commitment_type":"aws/savingsplan/Compute","contract_term":"one_year_gris","payment_option":"no_upfront","discount_rate":0.41,"breakeven_days":41,"commitment_upfront_cost":407,"commitment_financials_monthly_rate":{"commitment_cost":{"total":401,"breakdown":{"cloud_provider_cost":{"total":402},"archera_premium":403}},"commitment_savings":{"net":405,"gross":404},"covered_ondemand_cost":406},"delta_vs_current":{"monthly_net_savings":408,"upfront_cost":409,"discount_rate":0.42,"breakeven_days":42}},{"is_current":false,"offer_id":"50000000-0000-4000-8000-000000000003","offer_org_id":"public","offer":{"provider":"aws","type":"aws/Unlisted\u001b[31m\u0085","region":"ap-south-1","guaranteed_display_name":null},"lease_menu_item_id":null,"selected_amount":3,"commitment_type":"aws/Unlisted\u001b[31m\u0085","contract_term":"six_month_gris","payment_option":"all_upfront","discount_rate":0.51,"breakeven_days":null,"commitment_upfront_cost":507,"commitment_financials_monthly_rate":{"commitment_cost":{"total":501,"breakdown":{"cloud_provider_cost":{"total":502},"archera_premium":503}},"commitment_savings":{"net":null,"gross":504},"covered_ondemand_cost":506},"delta_vs_current":{"monthly_net_savings":508,"upfront_cost":509,"discount_rate":0.52,"breakeven_days":null}}]}]} +{"current_totals":{"commitment_financials_monthly_rate":{"commitment_cost":{"total":101,"breakdown":{"cloud_provider_cost":{"total":102},"archera_premium":103.1234567890123456789}},"commitment_savings":{"net":105,"gross":104},"covered_ondemand_cost":106},"commitment_upfront_cost":107},"hypothetical_totals":[{"contract_term":"one_year_gris","payment_option":"no_upfront","commitment_financials_monthly_rate":{"commitment_cost":{"total":201,"breakdown":{"cloud_provider_cost":{"total":202},"archera_premium":203}},"commitment_savings":{"net":205,"gross":204},"covered_ondemand_cost":206},"commitment_upfront_cost":207,"delta_vs_current":{"monthly_net_savings":208,"monthly_commitment_cost":209,"upfront_cost":210},"line_items":[{"line_item_id":"60000000-0000-4000-8000-0000000000b1\u001b[31m\u0085","actual_term":"six_month_gris","actual_payment_option":"partial_upfront","actual_commitment_type":"aws/AmazonEC2\u001b[31m\u0085","actual_term_reason":"fallback_closest_shorter"},{"line_item_id":"61000000-0000-4000-8000-0000000000b3","actual_term":"one_year_gris","actual_payment_option":"all_upfront","actual_commitment_type":"aws/AmazonRDS","actual_term_reason":"exact_match"}]},{"contract_term":null,"payment_option":"all_upfront","commitment_financials_monthly_rate":{"commitment_cost":{"total":601,"breakdown":{"cloud_provider_cost":{"total":602},"archera_premium":603}},"commitment_savings":{"net":605,"gross":604},"covered_ondemand_cost":606},"commitment_upfront_cost":607,"delta_vs_current":{"monthly_net_savings":608,"monthly_commitment_cost":609,"upfront_cost":610},"line_items":[{"line_item_id":"60000000-0000-4000-8000-0000000000b2","actual_term":null,"actual_payment_option":null,"actual_commitment_type":null,"actual_term_reason":"no_alternative"}]},{"contract_term":"twelve_month_gris","payment_option":"partial_upfront","commitment_financials_monthly_rate":{"commitment_cost":{"total":901,"breakdown":{"cloud_provider_cost":{"total":902},"archera_premium":903}},"commitment_savings":{"net":905,"gross":904},"covered_ondemand_cost":906},"commitment_upfront_cost":907,"delta_vs_current":{"monthly_net_savings":908,"monthly_commitment_cost":909,"upfront_cost":910},"line_items":[{"line_item_id":"90000000-0000-4000-8000-0000000000d1","actual_term":"twelve_month_gris","actual_payment_option":"all_upfront","actual_commitment_type":"aws/AmazonRDS","actual_term_reason":"exact_match"},{"line_item_id":"90000000-0000-4000-8000-0000000000d2","actual_term":"three_month_gris","actual_payment_option":"no_upfront","actual_commitment_type":"aws/AmazonElastiCache","actual_term_reason":"bundle_member"}]}],"data":[{"line_item_id":"70000000-0000-4000-8000-0000000000c1\u001b[31m\u0085","current":{"is_current":true,"offer_id":"30000000-0000-4000-8000-000000000001\u001b[31m\u0085","offer_org_id":"public","offer":{"provider":"aws","type":"aws/AmazonEC2","region":"us-east-1\u001b[31m\u0085","guaranteed_display_name":null},"lease_menu_item_id":null,"selected_amount":3,"commitment_type":"aws/AmazonEC2","contract_term":null,"payment_option":null,"discount_rate":0.31,"breakeven_days":31,"commitment_upfront_cost":307,"commitment_financials_monthly_rate":{"commitment_cost":{"total":301,"breakdown":{"cloud_provider_cost":{"total":302},"archera_premium":303}},"commitment_savings":{"net":305,"gross":304},"covered_ondemand_cost":306},"delta_vs_current":{"monthly_net_savings":308,"upfront_cost":309,"discount_rate":0.32,"breakeven_days":32}},"candidates":[{"is_current":false,"offer_id":"40000000-0000-4000-8000-000000000002","offer_org_id":"public","offer":{"provider":"aws","type":"aws/savingsplan/Compute","region":"eu-west-1\u001b[31m\u0085","guaranteed_display_name":"Name\u001b[31m\u0085FourNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN"},"lease_menu_item_id":"40000000-0000-4000-8000-0000000000a1","selected_amount":3,"commitment_type":"aws/savingsplan/Compute","contract_term":"one_year_gris","payment_option":"no_upfront","discount_rate":0.41,"breakeven_days":41,"commitment_upfront_cost":407,"commitment_financials_monthly_rate":{"commitment_cost":{"total":401,"breakdown":{"cloud_provider_cost":{"total":402},"archera_premium":403}},"commitment_savings":{"net":405,"gross":404},"covered_ondemand_cost":406},"delta_vs_current":{"monthly_net_savings":408,"upfront_cost":409,"discount_rate":0.42,"breakeven_days":42}},{"is_current":false,"offer_id":"50000000-0000-4000-8000-000000000003","offer_org_id":"public","offer":{"provider":"aws","type":"aws/Unlisted\u001b[31m\u0085","region":"ap-south-1","guaranteed_display_name":null},"lease_menu_item_id":null,"selected_amount":3,"commitment_type":"aws/Unlisted\u001b[31m\u0085","contract_term":"six_month_gris","payment_option":"all_upfront","discount_rate":0.51,"breakeven_days":null,"commitment_upfront_cost":507,"commitment_financials_monthly_rate":{"commitment_cost":{"total":501,"breakdown":{"cloud_provider_cost":{"total":502},"archera_premium":503}},"commitment_savings":{"net":null,"gross":504},"covered_ondemand_cost":506},"delta_vs_current":{"monthly_net_savings":508,"upfront_cost":509,"discount_rate":0.52,"breakeven_days":null}}]},{"line_item_id":"71000000-0000-4000-8000-0000000000c2","current":{"is_current":true,"offer_id":"70000000-0000-4000-8000-000000000004","offer_org_id":"public","offer":{"provider":"aws","type":"aws/AmazonRDS","region":"us-west-2","guaranteed_display_name":"Row Two Current"},"lease_menu_item_id":null,"selected_amount":3,"commitment_type":"aws/AmazonRDS","contract_term":null,"payment_option":null,"discount_rate":0.71,"breakeven_days":71,"commitment_upfront_cost":707,"commitment_financials_monthly_rate":{"commitment_cost":{"total":701,"breakdown":{"cloud_provider_cost":{"total":702},"archera_premium":703}},"commitment_savings":{"net":705,"gross":704},"covered_ondemand_cost":706},"delta_vs_current":{"monthly_net_savings":708,"upfront_cost":709,"discount_rate":0.72,"breakeven_days":72}},"candidates":[{"is_current":false,"offer_id":"80000000-0000-4000-8000-000000000005","offer_org_id":"public","offer":{"provider":"aws","type":"aws/AmazonElastiCache","region":"ca-central-1","guaranteed_display_name":null},"lease_menu_item_id":null,"selected_amount":3,"commitment_type":"aws/AmazonElastiCache","contract_term":"three_month_gris","payment_option":"partial_upfront","discount_rate":0.81,"breakeven_days":81,"commitment_upfront_cost":807,"commitment_financials_monthly_rate":{"commitment_cost":{"total":801,"breakdown":{"cloud_provider_cost":{"total":802},"archera_premium":803}},"commitment_savings":{"net":805,"gross":804},"covered_ondemand_cost":806},"delta_vs_current":{"monthly_net_savings":808,"upfront_cost":809,"discount_rate":0.82,"breakeven_days":82}}]}]} diff --git a/cmd/testdata/archera_json.golden b/cmd/testdata/archera_json.golden index 28e8221f6..58658df58 100644 --- a/cmd/testdata/archera_json.golden +++ b/cmd/testdata/archera_json.golden @@ -44,6 +44,13 @@ "actual_payment_option": "partial_upfront", "actual_commitment_type": "aws/AmazonEC2[31m", "reason": "fallback_closest_shorter" + }, + { + "line_item_id": "61000000-0000-4000-8000-0000000000b3", + "actual_term": "one_year_gris", + "actual_payment_option": "all_upfront", + "actual_commitment_type": "aws/AmazonRDS", + "reason": "exact_match" } ] }, @@ -73,6 +80,40 @@ "reason": "no_alternative" } ] + }, + { + "contract_term": "twelve_month_gris", + "payment_option": "partial_upfront", + "totals": { + "monthly_rate_730h": { + "commitment_cost_total": "901", + "cloud_provider_cost": "902", + "premium": "903", + "gross_savings": "904", + "net_savings": "905", + "covered_on_demand_cost": "906" + }, + "upfront_cost_one_time": "907" + }, + "delta_monthly_net_savings": "908", + "delta_monthly_commitment_cost": "909", + "delta_upfront_cost": "910", + "line_items": [ + { + "line_item_id": "90000000-0000-4000-8000-0000000000d1", + "actual_term": "twelve_month_gris", + "actual_payment_option": "all_upfront", + "actual_commitment_type": "aws/AmazonRDS", + "reason": "exact_match" + }, + { + "line_item_id": "90000000-0000-4000-8000-0000000000d2", + "actual_term": "three_month_gris", + "actual_payment_option": "no_upfront", + "actual_commitment_type": "aws/AmazonElastiCache", + "reason": "bundle_member" + } + ] } ], "rows": [ @@ -183,6 +224,77 @@ } } ] + }, + { + "line_item_id": "71000000-0000-4000-8000-0000000000c2", + "current": { + "is_current": true, + "offer_id": "70000000-0000-4000-8000-000000000004", + "commitment_type": "aws/AmazonRDS", + "provider": "aws", + "region": "us-west-2", + "contract_term": null, + "payment_option": null, + "lease_attached": false, + "archera_offer_name": "Row Two Current", + "discount_rate": "0.71", + "breakeven_days": "71", + "totals": { + "monthly_rate_730h": { + "commitment_cost_total": "701", + "cloud_provider_cost": "702", + "premium": "703", + "gross_savings": "704", + "net_savings": "705", + "covered_on_demand_cost": "706" + }, + "upfront_cost_one_time": "707" + }, + "delta_vs_current": { + "monthly_net_savings": "708", + "upfront_cost": "709", + "discount_rate": "0.72", + "breakeven_days": "72" + }, + "archera_product_support": { + "status": "unknown" + } + }, + "candidates": [ + { + "is_current": false, + "offer_id": "80000000-0000-4000-8000-000000000005", + "commitment_type": "aws/AmazonElastiCache", + "provider": "aws", + "region": "ca-central-1", + "contract_term": "three_month_gris", + "payment_option": "partial_upfront", + "lease_attached": false, + "archera_offer_name": null, + "discount_rate": "0.81", + "breakeven_days": "81", + "totals": { + "monthly_rate_730h": { + "commitment_cost_total": "801", + "cloud_provider_cost": "802", + "premium": "803", + "gross_savings": "804", + "net_savings": "805", + "covered_on_demand_cost": "806" + }, + "upfront_cost_one_time": "807" + }, + "delta_vs_current": { + "monthly_net_savings": "808", + "upfront_cost": "809", + "discount_rate": "0.82", + "breakeven_days": "82" + }, + "archera_product_support": { + "status": "unknown" + } + } + ] } ], "non_gating_disclosure": "This is entirely optional. CUDly's purchase and management features work fully without Archera.", diff --git a/cmd/testdata/archera_table.golden b/cmd/testdata/archera_table.golden index 4b83cd6c3..27995b1f8 100644 --- a/cmd/testdata/archera_table.golden +++ b/cmd/testdata/archera_table.golden @@ -10,12 +10,19 @@ Hypothetical 1: term one_year_gris, payment no_upfront totals: commitment cost 201, cloud provider cost 202, premium 203, gross savings 204, net savings 205, covered on-demand 206 (730-hour monthly rate); upfront 207 (one-time) delta vs current: monthly net savings 208, monthly commitment cost 209, upfront 210 line item 60000000-0000-4000-8000-0000000000b1[31m: term six_month_gris, payment partial_upfront, type aws/AmazonEC2[31m, reason fallback_closest_shorter + line item 61000000-0000-4000-8000-0000000000b3: term one_year_gris, payment all_upfront, type aws/AmazonRDS, reason exact_match Hypothetical 2: term unknown, payment all_upfront totals: commitment cost 601, cloud provider cost 602, premium 603, gross savings 604, net savings 605, covered on-demand 606 (730-hour monthly rate); upfront 607 (one-time) delta vs current: monthly net savings 608, monthly commitment cost 609, upfront 610 line item 60000000-0000-4000-8000-0000000000b2: term unknown, payment unknown, type unknown, reason no_alternative +Hypothetical 3: term twelve_month_gris, payment partial_upfront + totals: commitment cost 901, cloud provider cost 902, premium 903, gross savings 904, net savings 905, covered on-demand 906 (730-hour monthly rate); upfront 907 (one-time) + delta vs current: monthly net savings 908, monthly commitment cost 909, upfront 910 + line item 90000000-0000-4000-8000-0000000000d1: term twelve_month_gris, payment all_upfront, type aws/AmazonRDS, reason exact_match + line item 90000000-0000-4000-8000-0000000000d2: term three_month_gris, payment no_upfront, type aws/AmazonElastiCache, reason bundle_member + Line item 70000000-0000-4000-8000-0000000000c1[31m current offer 30000000-0000-4000-8000-000000000001[31m: aws aws/AmazonEC2, region us-east-1[31m, term unknown, payment unknown discount rate 0.31 (0-1 basis), breakeven days 31 @@ -34,5 +41,18 @@ Line item 70000000-0000-4000-8000-0000000000c1[31m delta vs current: monthly net savings 508, upfront 509, discount rate 0.52, breakeven days unknown Archera product support: unknown +Line item 71000000-0000-4000-8000-0000000000c2 + current offer 70000000-0000-4000-8000-000000000004: aws aws/AmazonRDS, region us-west-2, term unknown, payment unknown + Archera offer name: Row Two Current + discount rate 0.71 (0-1 basis), breakeven days 71 + commitment cost 701, cloud provider cost 702, premium 703, gross savings 704, net savings 705, covered on-demand 706 (730-hour monthly rate); upfront 707 (one-time) + delta vs current: monthly net savings 708, upfront 709, discount rate 0.72, breakeven days 72 + Archera product support: unknown + candidate offer 80000000-0000-4000-8000-000000000005: aws aws/AmazonElastiCache, region ca-central-1, term three_month_gris, payment partial_upfront + discount rate 0.81 (0-1 basis), breakeven days 81 + commitment cost 801, cloud provider cost 802, premium 803, gross savings 804, net savings 805, covered on-demand 806 (730-hour monthly rate); upfront 807 (one-time) + delta vs current: monthly net savings 808, upfront 809, discount rate 0.82, breakeven days 82 + Archera product support: unknown + This is entirely optional. CUDly's purchase and management features work fully without Archera. For full disclosure, Archera sponsors CUDly's Open Source development from a fraction of their insurance premiums. From 2d42cf8ac790d6b5384285793d989e2ce4f56bec Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Mon, 12 Oct 2026 14:37:29 +0200 Subject: [PATCH 05/10] fix(insurance): mask obfuscated API key in archera-comparison errors Clean vendor error text first (controls, format and separator characters, and the literal \uXXXX escapes pkg decode errors print with %q), then mask the key, then cap. A key echoed with U+200B between characters used to be restored by the strip after pkg exact-match masking missed it. Tests cover key split by zero-width, control and bidi characters, echoed twice, straddling the cap, as an enum value in a decode error, bidi in an HTTP error with Retry-After, and a log/slog capture proving the key is never logged. Refs #2156 Co-Authored-By: Claude Sonnet 5.5 --- cmd/archera_comparison.go | 23 +++++++++--- cmd/archera_comparison_test.go | 65 ++++++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+), 5 deletions(-) diff --git a/cmd/archera_comparison.go b/cmd/archera_comparison.go index 5bbe771a9..ffd25c8aa 100644 --- a/cmd/archera_comparison.go +++ b/cmd/archera_comparison.go @@ -3,8 +3,10 @@ package main import ( "errors" "fmt" + "math" "net/http" "os" + "regexp" "strings" "github.com/LeanerCloud/cloud-commitments-go/pkg/insurance" @@ -81,7 +83,7 @@ func runArcheraComparison(cmd *cobra.Command, _ []string) error { } comparison, err := client.Comparison(cmd.Context(), insurance.ComparisonRequest{PlanID: planID}) if err != nil { - return archeraSafeError(err) + return archeraSafeError(err, key) } dto := buildArcheraDTO(comparison) if format == "json" { @@ -108,15 +110,26 @@ func archeraSettings() (key, orgID, planID string, err error) { return key, orgID, planID, nil } +var archeraQuotedEscapeRE = regexp.MustCompile(`\\(?:u[0-9a-fA-F]{4}|U[0-9a-fA-F]{8}|x[0-9a-fA-F]{2})`) + // archeraSafeError renders a client error as bounded, terminal-safe text. -// HTTP errors keep their sanitized message and Retry-After. Other errors -// (decode failures echo the rejected vendor value) are stripped of control -// and format characters and capped, because their text is vendor-controlled. -func archeraSafeError(err error) error { +// HTTP errors keep their message and Retry-After. Decode errors echo the +// rejected vendor value. Both are vendor-controlled, so the text is cleaned +// first (controls, format and separator characters removed, which would +// otherwise let an obfuscated key such as one split by U+200B slip past an +// exact-match mask), then the key is masked, then the result is capped. +func archeraSafeError(err error, key string) error { msg := err.Error() var httpErr *insurance.HTTPError if errors.As(err, &httpErr) && httpErr.RetryAfter > 0 { msg = fmt.Sprintf("%s (retry after %s; not retried automatically)", msg, httpErr.RetryAfter) } + // pkg decode errors quote the rejected value with %q, which spells control + // and format characters as literal \uXXXX text; drop those escapes too so + // they cannot split the key. + msg = archeraQuotedEscapeRE.ReplaceAllString(archeraSanitizeN(msg, math.MaxInt), "") + if key != "" { + msg = strings.ReplaceAll(msg, key, "[redacted]") + } return errors.New(archeraSanitizeN(msg, archeraMaxErrorSize)) } diff --git a/cmd/archera_comparison_test.go b/cmd/archera_comparison_test.go index 4d6df111a..1d74464aa 100644 --- a/cmd/archera_comparison_test.go +++ b/cmd/archera_comparison_test.go @@ -6,6 +6,8 @@ import ( "encoding/json" "fmt" "io" + "log" + "log/slog" "math/big" "net/http" "net/http/httptest" @@ -458,3 +460,66 @@ func TestArcheraDecodeErrorsAreBoundedAndClean(t *testing.T) { }) } } + +func TestArcheraErrorsMaskObfuscatedKey(t *testing.T) { + k := archeraTestKey + split := func(sep string) string { return k[:5] + sep + k[5:] } + pad := strings.Repeat("p", archeraMaxErrorSize-len("archera API returned HTTP 403: ")-len(k)/2) + cases := map[string]string{ + "zero-width split": "bad key " + split("\u200b"), + "control split": "bad key " + split("\u0001"), + "bidi split": "bad key " + split("\u202e"), + "echoed twice": k + " and " + split("\u200b"), + "straddles the cap": pad + k, + "bidi isolate+RLO": "x\u202e\u2066y\u200b" + k, + } + for name, msg := range cases { + t.Run("http "+name, func(t *testing.T) { + setupArchera(t, func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Retry-After", "30") + w.WriteHeader(403) + body, _ := json.Marshal(map[string]string{"message": msg}) + _, _ = w.Write(body) + }) + out, errOut, err := runArchera(t, fullEnv()) + require.Error(t, err) + assert.NotContains(t, out+errOut+err.Error(), k) + assertArcheraNoControlChars(t, err.Error()) + assert.LessOrEqual(t, len(err.Error()), archeraMaxErrorSize) + assert.Contains(t, err.Error(), "retry after 30s") + assert.NotContains(t, err.Error(), "\u202e") + }) + } + t.Run("key as enum value in a decode error", func(t *testing.T) { + body := strings.Replace(archeraComparisonFixture(), `"payment_option":"no_upfront"`, `"payment_option":"`+split("\u200b")+`"`, 1) + setupArchera(t, func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(body)) }) + out, errOut, err := runArchera(t, fullEnv()) + require.Error(t, err) + assert.NotContains(t, out+errOut+err.Error(), k) + assert.Contains(t, err.Error(), "[redacted]") + }) + t.Run("plain key as enum value", func(t *testing.T) { + body := strings.Replace(archeraComparisonFixture(), `"payment_option":"no_upfront"`, `"payment_option":"`+k+`"`, 1) + setupArchera(t, func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(body)) }) + _, _, err := runArchera(t, fullEnv()) + require.Error(t, err) + assert.NotContains(t, err.Error(), k) + }) +} + +// A log or slog write of the key would bypass os.Stderr captured per test, so +// route both default loggers into the capture for the whole run. +func TestArcheraRunDoesNotLogKey(t *testing.T) { + var logs bytes.Buffer + savedLog, savedFlags := log.Writer(), log.Flags() + log.SetOutput(&logs) + savedSlog := slog.Default() + slog.SetDefault(slog.New(slog.NewTextHandler(&logs, nil))) + t.Cleanup(func() { log.SetOutput(savedLog); log.SetFlags(savedFlags); slog.SetDefault(savedSlog) }) + setupArchera(t, okHandler) + _, _, err := runArchera(t, fullEnv()) + require.NoError(t, err) + setupArchera(t, func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(403) }) + _, _, _ = runArchera(t, fullEnv()) + assert.NotContains(t, logs.String(), archeraTestKey) +} From 70a511b2b1b4599eec231ba30d93eb71e9cfb02d Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Mon, 12 Oct 2026 16:28:54 +0200 Subject: [PATCH 06/10] fix(insurance): withhold archera error text when the key stays readable Build a skeleton of the cleaned error text (controls, format, combining, enclosing and separator runes, Hangul fillers and invalid bytes dropped) and withhold the whole message when the masked text still contains the key. A key split by variation selectors, CGJ, U+0301, U+20DD, Hangul fillers or invalid UTF-8 no longer reaches the terminal. The check runs on the full text before the 512-byte cap, after masking the trimmed key. Tests cover HTTP, decode, transport and context-wrapped errors, a key prefix sweep at the cap boundary, and withholding at every cap offset. Co-Authored-By: Claude Sonnet 5.5 --- cmd/archera_comparison.go | 52 +++++++++++--- cmd/archera_comparison_test.go | 122 +++++++++++++++++++++++++++++++++ 2 files changed, 165 insertions(+), 9 deletions(-) diff --git a/cmd/archera_comparison.go b/cmd/archera_comparison.go index ffd25c8aa..5260de69e 100644 --- a/cmd/archera_comparison.go +++ b/cmd/archera_comparison.go @@ -8,6 +8,8 @@ import ( "os" "regexp" "strings" + "unicode" + "unicode/utf8" "github.com/LeanerCloud/cloud-commitments-go/pkg/insurance" "github.com/spf13/cobra" @@ -112,24 +114,56 @@ func archeraSettings() (key, orgID, planID string, err error) { var archeraQuotedEscapeRE = regexp.MustCompile(`\\(?:u[0-9a-fA-F]{4}|U[0-9a-fA-F]{8}|x[0-9a-fA-F]{2})`) +// archeraWithheld replaces vendor text in which the key is still readable +// after masking. +const archeraWithheld = "[message withheld: contained the API key]" + // archeraSafeError renders a client error as bounded, terminal-safe text. -// HTTP errors keep their message and Retry-After. Decode errors echo the -// rejected vendor value. Both are vendor-controlled, so the text is cleaned -// first (controls, format and separator characters removed, which would -// otherwise let an obfuscated key such as one split by U+200B slip past an -// exact-match mask), then the key is masked, then the result is capped. +// HTTP errors keep their status and Retry-After. Decode errors echo the +// rejected vendor value. Both are vendor-controlled, so the order is: clean +// the text (controls, format and separator characters removed, which would +// otherwise let a key split by U+200B slip past an exact-match mask), mask the +// trimmed key, check the full text for a key hidden behind characters the +// clean leaves in place (combining marks, variation selectors, fillers, +// invalid bytes) and withhold it if one is found, then cap. func archeraSafeError(err error, key string) error { - msg := err.Error() + key = strings.TrimSpace(key) + msg, suffix := err.Error(), "" var httpErr *insurance.HTTPError - if errors.As(err, &httpErr) && httpErr.RetryAfter > 0 { - msg = fmt.Sprintf("%s (retry after %s; not retried automatically)", msg, httpErr.RetryAfter) + isHTTP := errors.As(err, &httpErr) + if isHTTP && httpErr.RetryAfter > 0 { + suffix = fmt.Sprintf(" (retry after %s; not retried automatically)", httpErr.RetryAfter) } // pkg decode errors quote the rejected value with %q, which spells control // and format characters as literal \uXXXX text; drop those escapes too so // they cannot split the key. - msg = archeraQuotedEscapeRE.ReplaceAllString(archeraSanitizeN(msg, math.MaxInt), "") + msg = archeraQuotedEscapeRE.ReplaceAllString(archeraSanitizeN(msg+suffix, math.MaxInt), "") if key != "" { msg = strings.ReplaceAll(msg, key, "[redacted]") + if strings.Contains(archeraSkeleton(msg), archeraSkeleton(key)) { + msg = archeraWithheld + if isHTTP { + msg = fmt.Sprintf("archera API returned HTTP %d: %s%s", httpErr.StatusCode, archeraWithheld, suffix) + } + } } return errors.New(archeraSanitizeN(msg, archeraMaxErrorSize)) } + +// archeraSkeleton drops every rune that can hide or space out a key: control, +// format, combining (which includes the variation selectors), enclosing and +// separator runes, Hangul fillers and invalid bytes. +func archeraSkeleton(s string) string { + var b strings.Builder + for _, r := range s { + switch r { + case utf8.RuneError, 0x115F, 0x1160, 0x3164, 0xFFA0: + continue + } + if unicode.In(r, unicode.Cc, unicode.Cf, unicode.Mn, unicode.Me, unicode.Zl, unicode.Zp, unicode.Zs) { + continue + } + b.WriteRune(r) + } + return b.String() +} diff --git a/cmd/archera_comparison_test.go b/cmd/archera_comparison_test.go index 1d74464aa..d8666eebf 100644 --- a/cmd/archera_comparison_test.go +++ b/cmd/archera_comparison_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "io" "log" @@ -507,6 +508,127 @@ func TestArcheraErrorsMaskObfuscatedKey(t *testing.T) { }) } +// archeraKeyHidingSeparators split the key with characters that survive the +// strip, so an exact-match mask misses a key the terminal still displays. +var archeraKeyHidingSeparators = map[string]string{ + "variation selector 16": "\ufe0f", + "variation selector 17": "\U000e0100", + "combining grapheme join": "\u034f", + "combining acute": "\u0301", + "enclosing circle": "\u20dd", + "hangul choseong filler": "\u115f", + "hangul jungseong filler": "\u1160", + "hangul filler": "\u3164", + "halfwidth hangul filler": "\uffa0", + "space": " ", + "invalid utf-8": "\xff", +} + +func archeraSplitEverywhere(k, sep string) string { + return strings.Join(strings.Split(k, ""), sep) +} + +type archeraFailingTransport struct{ err error } + +func (f archeraFailingTransport) RoundTrip(*http.Request) (*http.Response, error) { return nil, f.err } + +func TestArcheraErrorsWithholdKeyHiddenBehindSurvivingCharacters(t *testing.T) { + k := archeraTestKey + checkMsg := func(t *testing.T, got string) { + t.Helper() + assert.NotContains(t, got, k) + assert.Contains(t, got, archeraWithheld) + assertArcheraNoControlChars(t, got) + assert.LessOrEqual(t, len(got), archeraMaxErrorSize) + } + for name, sep := range archeraKeyHidingSeparators { + for shape, echo := range map[string]string{ + "split everywhere": archeraSplitEverywhere(k, sep), + "split once": k[:5] + sep + k[5:], + } { + msg := "bad key " + echo + " was rejected" + t.Run("http "+name+" "+shape, func(t *testing.T) { + setupArchera(t, func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Retry-After", "30") + w.WriteHeader(403) + body, _ := json.Marshal(map[string]string{"message": msg}) + _, _ = w.Write(body) + }) + out, errOut, err := runArchera(t, fullEnv()) + require.Error(t, err) + assert.NotContains(t, out+errOut, k) + checkMsg(t, err.Error()) + assert.Contains(t, err.Error(), "HTTP 403") + assert.Contains(t, err.Error(), "retry after 30s") + }) + t.Run("decode "+name+" "+shape, func(t *testing.T) { + body := strings.Replace(archeraComparisonFixture(), `"payment_option":"no_upfront"`, `"payment_option":`+mustJSON(t, echo), 1) + setupArchera(t, func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(body)) }) + out, errOut, err := runArchera(t, fullEnv()) + require.Error(t, err) + assert.NotContains(t, out+errOut, k) + checkMsg(t, err.Error()) + }) + for kind, wrapped := range map[string]error{ + "transport": errors.New(msg), + "deadline": fmt.Errorf("%s: %w", msg, context.DeadlineExceeded), + "canceled": fmt.Errorf("%s: %w", msg, context.Canceled), + } { + t.Run(kind+" "+name+" "+shape, func(t *testing.T) { + setupArchera(t, okHandler) + archeraHTTPClient = &http.Client{Transport: archeraFailingTransport{err: wrapped}} + out, errOut, err := runArchera(t, fullEnv()) + require.Error(t, err) + assert.NotContains(t, out+errOut, k) + checkMsg(t, err.Error()) + }) + } + } + } +} + +func mustJSON(t *testing.T, v string) string { + t.Helper() + b, err := json.Marshal(v) + require.NoError(t, err) + return string(b) +} + +// The mask must run before the cap: a key cut by the cap boundary would +// otherwise survive as a readable prefix. Sweep the boundary and assert no +// 4+ byte prefix of the key remains, for a plain and a strip-split key. +func TestArcheraErrorsLeaveNoKeyPrefixAtCap(t *testing.T) { + k := archeraTestKey + for _, echo := range []string{k, k[:5] + "\u200b" + k[5:], archeraSplitEverywhere(k, "\u200b")} { + for padLen := archeraMaxErrorSize - len(k) - 8; padLen <= archeraMaxErrorSize+2; padLen++ { + err := archeraSafeError(errors.New(strings.Repeat("p", padLen)+echo), k) + got := err.Error() + for n := 4; n <= len(k); n++ { + for start := 0; start+n <= len(k); start++ { + require.NotContains(t, got, k[start:start+n], "pad %d, fragment %q", padLen, k[start:start+n]) + } + } + } + } +} + +// The withhold check must see the full text: a cut through a split key would +// hide it from a check run after the cap while leaving a readable prefix. +func TestArcheraErrorsWithholdSplitKeyAtAnyCapOffset(t *testing.T) { + k := archeraTestKey + for name, sep := range map[string]string{"variation selector": "\ufe0f", "invalid utf-8": "\xff", "combining acute": "\u0301"} { + for padLen := archeraMaxErrorSize - 3*len(k); padLen <= archeraMaxErrorSize+2; padLen++ { + got := archeraSafeError(errors.New(strings.Repeat("p", padLen)+archeraSplitEverywhere(k, sep)), k).Error() + require.Equal(t, archeraWithheld, got, "%s pad %d", name, padLen) + } + } +} + +func TestArcheraErrorsTrimKeyBeforeMasking(t *testing.T) { + got := archeraSafeError(errors.New("bad key "+archeraTestKey[:5]+"\u200b"+archeraTestKey[5:]), " "+archeraTestKey+"\n").Error() + assert.Equal(t, "bad key [redacted]", got) +} + // A log or slog write of the key would bypass os.Stderr captured per test, so // route both default loggers into the capture for the whole run. func TestArcheraRunDoesNotLogKey(t *testing.T) { From bc85e398fac9492bdfb249c2ed31efc42db6f191 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Mon, 12 Oct 2026 17:25:24 +0200 Subject: [PATCH 07/10] fix(insurance): withhold archera errors when any key fragment is readable The pinned pkg cuts HTTP messages at 200 bytes before masking, so a split key surfaced as a readable prefix. Check for any 8+ character key run in the skeleton, and build the skeleton from only the runes in the key so unlisted invisible characters (U+2800, unassigned code points) drop out. Co-Authored-By: Claude Sonnet 5.5 --- cmd/archera_comparison.go | 45 +++++++++----- cmd/archera_comparison_key_test.go | 99 ++++++++++++++++++++++++++++++ 2 files changed, 130 insertions(+), 14 deletions(-) create mode 100644 cmd/archera_comparison_key_test.go diff --git a/cmd/archera_comparison.go b/cmd/archera_comparison.go index 5260de69e..6efda3786 100644 --- a/cmd/archera_comparison.go +++ b/cmd/archera_comparison.go @@ -8,8 +8,6 @@ import ( "os" "regexp" "strings" - "unicode" - "unicode/utf8" "github.com/LeanerCloud/cloud-commitments-go/pkg/insurance" "github.com/spf13/cobra" @@ -140,7 +138,7 @@ func archeraSafeError(err error, key string) error { msg = archeraQuotedEscapeRE.ReplaceAllString(archeraSanitizeN(msg+suffix, math.MaxInt), "") if key != "" { msg = strings.ReplaceAll(msg, key, "[redacted]") - if strings.Contains(archeraSkeleton(msg), archeraSkeleton(key)) { + if archeraKeyReadable(msg, key) { msg = archeraWithheld if isHTTP { msg = fmt.Sprintf("archera API returned HTTP %d: %s%s", httpErr.StatusCode, archeraWithheld, suffix) @@ -150,20 +148,39 @@ func archeraSafeError(err error, key string) error { return errors.New(archeraSanitizeN(msg, archeraMaxErrorSize)) } -// archeraSkeleton drops every rune that can hide or space out a key: control, -// format, combining (which includes the variation selectors), enclosing and -// separator runes, Hangul fillers and invalid bytes. -func archeraSkeleton(s string) string { +// archeraFragmentLen is the longest key fragment that may remain readable in +// vendor text. The pkg cuts HTTP messages at 200 bytes before the CLI sees +// them, so a split key can arrive as a prefix rather than whole. +const archeraFragmentLen = 8 + +// archeraSkeleton keeps only the runes that occur in the key, so any character +// a vendor inserts to hide the key (known or not, visible or not) drops out. +// alphabet is the rune set of the trimmed key. +func archeraSkeleton(s string, alphabet map[rune]bool) string { var b strings.Builder for _, r := range s { - switch r { - case utf8.RuneError, 0x115F, 0x1160, 0x3164, 0xFFA0: - continue - } - if unicode.In(r, unicode.Cc, unicode.Cf, unicode.Mn, unicode.Me, unicode.Zl, unicode.Zp, unicode.Zs) { - continue + if alphabet[r] { + b.WriteRune(r) } - b.WriteRune(r) } return b.String() } + +// archeraKeyReadable reports whether msg shows a run of at least +// min(len(key), archeraFragmentLen) consecutive key characters once every +// non-key character is dropped. +func archeraKeyReadable(msg, key string) bool { + alphabet := make(map[rune]bool, len(key)) + for _, r := range key { + alphabet[r] = true + } + ks := []rune(archeraSkeleton(key, alphabet)) + ms := archeraSkeleton(msg, alphabet) + n := min(len(ks), archeraFragmentLen) + for i := 0; i+n <= len(ks); i++ { + if strings.Contains(ms, string(ks[i:i+n])) { + return true + } + } + return false +} diff --git a/cmd/archera_comparison_key_test.go b/cmd/archera_comparison_key_test.go new file mode 100644 index 000000000..75d5ad1bf --- /dev/null +++ b/cmd/archera_comparison_key_test.go @@ -0,0 +1,99 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +// A 41-character synthetic key. The pinned pkg cuts HTTP error messages at 200 +// bytes before the CLI sees them, and a key this long split by an invisible +// character spans that cut, which the 23-byte archeraTestKey never does. +const archeraLongKey = "PRB-sYnTh3t1c-QZ9x-7Kd2-mW8v-Lp4n-Ht6r-00" + +// archeraInvisibleSeparators are characters a terminal shows as nothing or as +// a blank, including ones outside every Unicode category list (unassigned +// default-ignorables) and U+2800. +var archeraInvisibleSeparators = map[string]string{ + "zwsp": "\u200b", "word joiner": "\u2060", "vs16": "\ufe0f", "vs17": "\U000e0100", + "cgj": "\u034f", "acute": "\u0301", "enclosing circle": "\u20dd", + "hangul choseong filler": "\u115f", "hangul jungseong filler": "\u1160", + "hangul filler": "\u3164", "halfwidth hangul filler": "\uffa0", + "space": " ", "nbsp": "\u00a0", "ideographic space": "\u3000", "replacement char": "\ufffd", + "khmer inherent vowel": "\u17b4", "mongolian vowel separator": "\u180e", + "tag letter": "\U000e0041", "unassigned fff0": "\ufff0", "unassigned 2065": "\u2065", + "unassigned e0002": "\U000e0002", "braille blank": "\u2800", "zwnj": "\u200c", + "soft hyphen": "\u00ad", "lri": "\u2066", "literal escape": `\u200b`, + "invalid utf-8": "\xff", "unassigned fff8": "\ufff8", "unassigned e001f": "\U000e001f", + "unassigned e0003": "\U000e0003", +} + +// archeraReadableKeyFragment reports a run of 10+ key characters readable in +// out once everything but ASCII letters, digits and '-' is ignored. +func archeraReadableKeyFragment(out string) string { + var b strings.Builder + for _, r := range out { + if r == '-' || r >= '0' && r <= '9' || r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' { + b.WriteRune(r) + } + } + s := b.String() + for i := 0; i+10 <= len(archeraLongKey); i++ { + if frag := archeraLongKey[i : i+10]; strings.Contains(s, frag) { + return frag + } + } + return "" +} + +func TestArcheraLongKeyNeverReadableOnAnyPath(t *testing.T) { + require.Len(t, archeraLongKey, 41) + env := map[string]string{archeraEnvAPIKey: " " + archeraLongKey + "\t", archeraEnvOrgID: archeraTestOrg, archeraEnvPlanID: archeraTestPlan} + for name, sep := range archeraInvisibleSeparators { + for shape, echo := range map[string]string{ + "every": archeraSplitEverywhere(archeraLongKey, sep), + "once": archeraLongKey[:7] + sep + archeraLongKey[7:], + } { + check := func(path string, out, errOut string, err error) { + t.Helper() + require.Error(t, err, "%s %s %s", path, name, shape) + got := out + errOut + err.Error() + require.Empty(t, archeraReadableKeyFragment(got), "%s %s %s: %q", path, name, shape, got) + } + // HTTP: sweep pad so the pkg's 200-byte cut lands at every depth of the key. + for pad := 0; pad <= 199; pad += 9 { + msg := "bad key " + strings.Repeat("p", pad) + echo + setupArchera(t, func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Retry-After", "7") + w.WriteHeader(401) + b, _ := json.Marshal(map[string]string{"message": msg}) + _, _ = w.Write(b) + }) + out, errOut, err := runArchera(t, env) + check(fmt.Sprintf("http pad %d", pad), out, errOut, err) + } + // Decode error echoing the rejected enum value. + body := strings.Replace(archeraComparisonFixture(), `"payment_option":"no_upfront"`, `"payment_option":`+mustJSON(t, echo), 1) + setupArchera(t, func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(body)) }) + out, errOut, err := runArchera(t, env) + check("decode", out, errOut, err) + msg := "bad key " + echo + for kind, wrapped := range map[string]error{ + "transport": errors.New(msg), + "deadline": fmt.Errorf("%s: %w", msg, context.DeadlineExceeded), + "canceled": fmt.Errorf("%s: %w", msg, context.Canceled), + } { + setupArchera(t, okHandler) + archeraHTTPClient = &http.Client{Transport: archeraFailingTransport{err: wrapped}} + out, errOut, err := runArchera(t, env) + check(kind, out, errOut, err) + } + } + } +} From f668042bbd6ff025ae3a8485f5d5b1e0fdb9b7dd Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Mon, 12 Oct 2026 17:52:25 +0200 Subject: [PATCH 08/10] fix(insurance): catch key-alphabet separators, case and fullwidth echoes in archera errors Match key windows as a gapped subsequence (gap <= 2) of the folded message skeleton, fold with NFKD and lower case, and clean the key the way the reader sees it. Adds boundary, window-position, gap-bound and negative tests. Co-Authored-By: Claude Sonnet 5.5 --- cmd/archera_comparison.go | 78 +++++++++---- cmd/archera_comparison_key_test.go | 178 ++++++++++++++++++++++------- go.mod | 2 +- 3 files changed, 196 insertions(+), 62 deletions(-) diff --git a/cmd/archera_comparison.go b/cmd/archera_comparison.go index 6efda3786..480e68371 100644 --- a/cmd/archera_comparison.go +++ b/cmd/archera_comparison.go @@ -8,9 +8,11 @@ import ( "os" "regexp" "strings" + "unicode" "github.com/LeanerCloud/cloud-commitments-go/pkg/insurance" "github.com/spf13/cobra" + "golang.org/x/text/unicode/norm" ) // Archera settings come from the environment, read only inside RunE. The API @@ -153,34 +155,68 @@ func archeraSafeError(err error, key string) error { // them, so a split key can arrive as a prefix rather than whole. const archeraFragmentLen = 8 -// archeraSkeleton keeps only the runes that occur in the key, so any character -// a vendor inserts to hide the key (known or not, visible or not) drops out. -// alphabet is the rune set of the trimmed key. -func archeraSkeleton(s string, alphabet map[rune]bool) string { - var b strings.Builder - for _, r := range s { - if alphabet[r] { - b.WriteRune(r) - } - } - return b.String() -} +// archeraMaxGap is how many other runes may sit between two consecutive key +// characters before the text stops reading as the key. It covers a separator +// taken from the key's own alphabet, such as "--" between every character. +const archeraMaxGap = 2 + +// archeraFold maps text to the form a reader would compare: NFKD (fullwidth +// to ASCII, accents split off as droppable marks) then lower case. +func archeraFold(s string) string { return strings.ToLower(norm.NFKD.String(s)) } -// archeraKeyReadable reports whether msg shows a run of at least -// min(len(key), archeraFragmentLen) consecutive key characters once every -// non-key character is dropped. +// archeraKeyReadable reports whether msg shows min(len(key), archeraFragmentLen) +// consecutive key characters, in order, with at most archeraMaxGap other +// characters between neighbours. Both sides are folded, and msg is reduced to +// the runes that occur in the key, so any inserted character class drops out. +// The key's own non-graphic and space runes are ignored, as the reader does. func archeraKeyReadable(msg, key string) bool { - alphabet := make(map[rune]bool, len(key)) - for _, r := range key { - alphabet[r] = true + var ks []rune + alphabet := map[rune]bool{} + for _, r := range archeraFold(key) { + if unicode.IsGraphic(r) && !unicode.IsSpace(r) { + ks = append(ks, r) + alphabet[r] = true + } + } + var ms []rune + for _, r := range archeraFold(msg) { + if alphabet[r] { + ms = append(ms, r) + } } - ks := []rune(archeraSkeleton(key, alphabet)) - ms := archeraSkeleton(msg, alphabet) n := min(len(ks), archeraFragmentLen) for i := 0; i+n <= len(ks); i++ { - if strings.Contains(ms, string(ks[i:i+n])) { + if archeraHasGappedRun(ms, ks[i:i+n]) { return true } } return false } + +// archeraHasGappedRun reports whether run occurs in ms as a subsequence with +// at most archeraMaxGap skipped runes between neighbours. Gap 0 is a plain +// substring match. +func archeraHasGappedRun(ms, run []rune) bool { + var cur []int + for i, r := range ms { + if r == run[0] { + cur = append(cur, i) + } + } + for _, want := range run[1:] { + var next []int + for j, r := range ms { + if r != want { + continue + } + for _, p := range cur { + if j > p && j-p-1 <= archeraMaxGap { + next = append(next, j) + break + } + } + } + cur = next + } + return len(cur) > 0 +} diff --git a/cmd/archera_comparison_key_test.go b/cmd/archera_comparison_key_test.go index 75d5ad1bf..1d66f5ace 100644 --- a/cmd/archera_comparison_key_test.go +++ b/cmd/archera_comparison_key_test.go @@ -9,6 +9,7 @@ import ( "strings" "testing" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -31,69 +32,166 @@ var archeraInvisibleSeparators = map[string]string{ "unassigned e0002": "\U000e0002", "braille blank": "\u2800", "zwnj": "\u200c", "soft hyphen": "\u00ad", "lri": "\u2066", "literal escape": `\u200b`, "invalid utf-8": "\xff", "unassigned fff8": "\ufff8", "unassigned e001f": "\U000e001f", - "unassigned e0003": "\U000e0003", + "unassigned e0003": "\U000e0003", + "triple braille blank": "\u2800\u2800\u2800", "triple unassigned 2065": "\u2065\u2065\u2065", } -// archeraReadableKeyFragment reports a run of 10+ key characters readable in -// out once everything but ASCII letters, digits and '-' is ignored. +// archeraKeyAlphabetSeparators are characters that occur in archeraLongKey +// itself, so an allow-list skeleton keeps them between the key characters. +var archeraKeyAlphabetSeparators = []string{"-", "x", "0", "--"} + +// archeraFullwidth maps printable ASCII to its fullwidth form. +func archeraFullwidth(s string) string { + return strings.Map(func(r rune) rune { + if r >= 0x21 && r <= 0x7e { + return r + 0xfee0 + } + return r + }, s) +} + +// archeraReadableKeyFragment reports a run of 8+ key characters readable in +// out, ignoring case and fullwidth forms and everything but ASCII letters, +// digits and '-'. 8 is the command's own threshold. func archeraReadableKeyFragment(out string) string { var b strings.Builder for _, r := range out { + if r >= 0xff01 && r <= 0xff5e { + r -= 0xfee0 + } if r == '-' || r >= '0' && r <= '9' || r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' { b.WriteRune(r) } } - s := b.String() - for i := 0; i+10 <= len(archeraLongKey); i++ { - if frag := archeraLongKey[i : i+10]; strings.Contains(s, frag) { + s := strings.ToLower(b.String()) + key := strings.ToLower(archeraLongKey) + for i := 0; i+8 <= len(key); i++ { + if frag := key[i : i+8]; strings.Contains(s, frag) { return frag } } return "" } +// forArcheraErrorPaths drives every error path with msg and calls check with +// each result. pads are the padding lengths swept across the pkg's 200-byte +// HTTP cut. +func forArcheraErrorPaths(t *testing.T, echo string, pads []int, check func(path, got string)) { + t.Helper() + env := map[string]string{archeraEnvAPIKey: " " + archeraLongKey + "\t", archeraEnvOrgID: archeraTestOrg, archeraEnvPlanID: archeraTestPlan} + done := func(path string, out, errOut string, err error) { + t.Helper() + require.Error(t, err, path) + check(path, out+errOut+err.Error()) + } + for _, pad := range pads { + msg := "bad key " + strings.Repeat("p", pad) + echo + setupArchera(t, func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Retry-After", "7") + w.WriteHeader(401) + b, _ := json.Marshal(map[string]string{"message": msg}) + _, _ = w.Write(b) + }) + out, errOut, err := runArchera(t, env) + done(fmt.Sprintf("http pad %d", pad), out, errOut, err) + } + body := strings.Replace(archeraComparisonFixture(), `"payment_option":"no_upfront"`, `"payment_option":`+mustJSON(t, echo), 1) + setupArchera(t, func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(body)) }) + out, errOut, err := runArchera(t, env) + done("decode", out, errOut, err) + msg := "bad key " + echo + for kind, wrapped := range map[string]error{ + "transport": errors.New(msg), + "deadline": fmt.Errorf("%s: %w", msg, context.DeadlineExceeded), + "canceled": fmt.Errorf("%s: %w", msg, context.Canceled), + } { + setupArchera(t, okHandler) + archeraHTTPClient = &http.Client{Transport: archeraFailingTransport{err: wrapped}} + out, errOut, err := runArchera(t, env) + done(kind, out, errOut, err) + } +} + func TestArcheraLongKeyNeverReadableOnAnyPath(t *testing.T) { require.Len(t, archeraLongKey, 41) - env := map[string]string{archeraEnvAPIKey: " " + archeraLongKey + "\t", archeraEnvOrgID: archeraTestOrg, archeraEnvPlanID: archeraTestPlan} + var pads []int + for pad := 0; pad <= 199; pad += 9 { + pads = append(pads, pad) + } for name, sep := range archeraInvisibleSeparators { for shape, echo := range map[string]string{ "every": archeraSplitEverywhere(archeraLongKey, sep), "once": archeraLongKey[:7] + sep + archeraLongKey[7:], } { - check := func(path string, out, errOut string, err error) { - t.Helper() - require.Error(t, err, "%s %s %s", path, name, shape) - got := out + errOut + err.Error() + forArcheraErrorPaths(t, echo, pads, func(path, got string) { require.Empty(t, archeraReadableKeyFragment(got), "%s %s %s: %q", path, name, shape, got) - } - // HTTP: sweep pad so the pkg's 200-byte cut lands at every depth of the key. - for pad := 0; pad <= 199; pad += 9 { - msg := "bad key " + strings.Repeat("p", pad) + echo - setupArchera(t, func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Retry-After", "7") - w.WriteHeader(401) - b, _ := json.Marshal(map[string]string{"message": msg}) - _, _ = w.Write(b) - }) - out, errOut, err := runArchera(t, env) - check(fmt.Sprintf("http pad %d", pad), out, errOut, err) - } - // Decode error echoing the rejected enum value. - body := strings.Replace(archeraComparisonFixture(), `"payment_option":"no_upfront"`, `"payment_option":`+mustJSON(t, echo), 1) - setupArchera(t, func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(body)) }) - out, errOut, err := runArchera(t, env) - check("decode", out, errOut, err) - msg := "bad key " + echo - for kind, wrapped := range map[string]error{ - "transport": errors.New(msg), - "deadline": fmt.Errorf("%s: %w", msg, context.DeadlineExceeded), - "canceled": fmt.Errorf("%s: %w", msg, context.Canceled), - } { - setupArchera(t, okHandler) - archeraHTTPClient = &http.Client{Transport: archeraFailingTransport{err: wrapped}} - out, errOut, err := runArchera(t, env) - check(kind, out, errOut, err) - } + }) + } + } +} + +// Case-changed and fullwidth echoes are the key to a reader, and so are keys +// spaced out by characters taken from the key itself. +func TestArcheraLongKeyVisibleDisguisesAreWithheld(t *testing.T) { + echoes := map[string]string{ + "lower": strings.ToLower(archeraLongKey), + "upper": strings.ToUpper(archeraLongKey), + "fullwidth": archeraFullwidth(archeraLongKey), + } + for _, sep := range archeraKeyAlphabetSeparators { + echoes["sep "+sep] = archeraSplitEverywhere(archeraLongKey, sep) + echoes["fullwidth sep "+sep] = archeraSplitEverywhere(archeraFullwidth(archeraLongKey), sep) + } + for name, echo := range echoes { + forArcheraErrorPaths(t, echo, []int{0}, func(path, got string) { + assert.Contains(t, got, archeraWithheld, "%s %s: %q", path, name, got) + }) + } +} + +// Exactly 8 consecutive key characters are withheld, 7 are shown; the first +// and last 8 count as much as any other. +func TestArcheraKeyFragmentBoundary(t *testing.T) { + spaced := func(frag string) error { + return archeraSafeError(errors.New("A: "+archeraSplitEverywhere(frag, "\u2800")), archeraLongKey) + } + k := archeraLongKey + for name, frag := range map[string]string{"middle 8": k[10:18], "first 8": k[:8], "last 8": k[len(k)-8:]} { + assert.Contains(t, spaced(frag).Error(), archeraWithheld, name) + } + for name, frag := range map[string]string{"middle 7": k[10:17], "first 7": k[:7], "last 7": k[len(k)-7:]} { + assert.NotContains(t, spaced(frag).Error(), archeraWithheld, name) + } +} + +// The gap rule is bounded: key characters 2 apart read as the key, 3 apart do +// not, which is what keeps ordinary text out of the net. +func TestArcheraKeyGapBound(t *testing.T) { + two := archeraSafeError(errors.New("A: "+archeraSplitEverywhere(archeraLongKey, "--")), archeraLongKey).Error() + assert.Contains(t, two, archeraWithheld) + three := archeraSafeError(errors.New("A: "+archeraSplitEverywhere(archeraLongKey, "---")), archeraLongKey).Error() + assert.NotContains(t, three, archeraWithheld) +} + +// A key with an inner space or a format character is matched on what a reader +// sees of it. +func TestArcheraKeyWithInnerSpaceOrFormatRune(t *testing.T) { + for name, key := range map[string]string{"inner space": "abcd efgh ijkl", "format rune": "abcdefg\u200bhijklmn"} { + got := archeraSafeError(errors.New("A: abcd\u2800efgh\u2800ijkl and abcdefghijklmn"), key).Error() + assert.Contains(t, got, archeraWithheld, name) + } +} + +// Ordinary vendor text must survive: the gap rule only fires on the key. +func TestArcheraOrdinaryVendorTextIsNotWithheld(t *testing.T) { + msgs := []string{ + "invalid credentials for the account; request id 4821-aa; HTTP 403 forbidden", + "organization not found: check the org id and the plan id", + "hypothetical_totals[0]: payment_option: unsupported value \"partial_upfront_x\"", + } + for _, key := range []string{archeraLongKey, archeraTestKey} { + for _, m := range msgs { + assert.Equal(t, m, archeraSafeError(errors.New(m), key).Error(), key) } } } diff --git a/go.mod b/go.mod index 26b5c834c..2ce6dbe77 100644 --- a/go.mod +++ b/go.mod @@ -69,7 +69,7 @@ require ( golang.org/x/oauth2 v0.36.0 golang.org/x/sync v0.23.0 // indirect golang.org/x/sys v0.48.0 // indirect - golang.org/x/text v0.42.0 // indirect + golang.org/x/text v0.42.0 golang.org/x/time v0.15.0 // indirect google.golang.org/api v0.274.0 google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 // indirect From 023002505e07d9fa455d7356c3946e8b8c3eca05 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Mon, 12 Oct 2026 17:53:26 +0200 Subject: [PATCH 09/10] style(insurance): fix misspelling flagged by lint Co-Authored-By: Claude Sonnet 5.5 --- cmd/archera_comparison.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd/archera_comparison.go b/cmd/archera_comparison.go index 480e68371..6363f0c76 100644 --- a/cmd/archera_comparison.go +++ b/cmd/archera_comparison.go @@ -166,7 +166,7 @@ func archeraFold(s string) string { return strings.ToLower(norm.NFKD.String(s)) // archeraKeyReadable reports whether msg shows min(len(key), archeraFragmentLen) // consecutive key characters, in order, with at most archeraMaxGap other -// characters between neighbours. Both sides are folded, and msg is reduced to +// characters between neighbors. Both sides are folded, and msg is reduced to // the runes that occur in the key, so any inserted character class drops out. // The key's own non-graphic and space runes are ignored, as the reader does. func archeraKeyReadable(msg, key string) bool { @@ -194,7 +194,7 @@ func archeraKeyReadable(msg, key string) bool { } // archeraHasGappedRun reports whether run occurs in ms as a subsequence with -// at most archeraMaxGap skipped runes between neighbours. Gap 0 is a plain +// at most archeraMaxGap skipped runes between neighbors. Gap 0 is a plain // substring match. func archeraHasGappedRun(ms, run []rune) bool { var cur []int From 1194b60ab3abdcfbc882715746f6018ea42e2f72 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Mon, 12 Oct 2026 18:41:35 +0200 Subject: [PATCH 10/10] fix(insurance): bound the archera key matcher and catch repeated separators Make the key-window match linear and limit it to the displayable prefix plus slack, so a hostile 1 MB rejected value no longer takes minutes. Detect a separator of up to 16 key-alphabet runes repeated between every key character. Adds the bounded-time test and the separator cases. Co-Authored-By: Claude Sonnet 5.5 --- cmd/archera_comparison.go | 89 +++++++++++++++++++++++------- cmd/archera_comparison_key_test.go | 46 +++++++++++++-- 2 files changed, 111 insertions(+), 24 deletions(-) diff --git a/cmd/archera_comparison.go b/cmd/archera_comparison.go index 6363f0c76..a70d35627 100644 --- a/cmd/archera_comparison.go +++ b/cmd/archera_comparison.go @@ -7,8 +7,10 @@ import ( "net/http" "os" "regexp" + "slices" "strings" "unicode" + "unicode/utf8" "github.com/LeanerCloud/cloud-commitments-go/pkg/insurance" "github.com/spf13/cobra" @@ -155,20 +157,30 @@ func archeraSafeError(err error, key string) error { // them, so a split key can arrive as a prefix rather than whole. const archeraFragmentLen = 8 -// archeraMaxGap is how many other runes may sit between two consecutive key -// characters before the text stops reading as the key. It covers a separator -// taken from the key's own alphabet, such as "--" between every character. +// archeraMaxGap is how many other runes may sit at one irregular spot between +// two consecutive key characters before the text stops reading as the key. const archeraMaxGap = 2 +// archeraMaxSep is the longest separator, in key-alphabet runes, that may be +// repeated between every pair of key characters (such as "-- -" or "0x0"). +const archeraMaxSep = 16 + +// archeraCheckSlack is how far past the displayed cap the readability check +// looks, so a key cut by the cap is still seen whole. It also bounds the work: +// the check never reads more than archeraMaxErrorSize + archeraCheckSlack bytes. +const archeraCheckSlack = archeraFragmentLen * (archeraMaxSep + 1) * utf8.UTFMax + // archeraFold maps text to the form a reader would compare: NFKD (fullwidth // to ASCII, accents split off as droppable marks) then lower case. func archeraFold(s string) string { return strings.ToLower(norm.NFKD.String(s)) } -// archeraKeyReadable reports whether msg shows min(len(key), archeraFragmentLen) -// consecutive key characters, in order, with at most archeraMaxGap other -// characters between neighbors. Both sides are folded, and msg is reduced to +// archeraKeyReadable reports whether the displayable part of msg shows +// min(len(key), archeraFragmentLen) consecutive key characters, either +// separated by one repeated separator or in order with at most archeraMaxGap +// other characters at each spot. Both sides are folded, and msg is reduced to // the runes that occur in the key, so any inserted character class drops out. // The key's own non-graphic and space runes are ignored, as the reader does. +// Look-alike letters from other scripts are not handled. func archeraKeyReadable(msg, key string) bool { var ks []rune alphabet := map[rune]bool{} @@ -178,45 +190,84 @@ func archeraKeyReadable(msg, key string) bool { alphabet[r] = true } } + if len(ks) == 0 { + return true + } var ms []rune - for _, r := range archeraFold(msg) { + for _, r := range archeraFold(archeraSanitizeN(msg, archeraMaxErrorSize+archeraCheckSlack)) { if alphabet[r] { ms = append(ms, r) } } n := min(len(ks), archeraFragmentLen) for i := 0; i+n <= len(ks); i++ { - if archeraHasGappedRun(ms, ks[i:i+n]) { + run := ks[i : i+n] + if archeraHasRepeatedSeparatorRun(ms, run) || archeraHasGappedRun(ms, run) { return true } } return false } +// archeraHasRepeatedSeparatorRun reports whether ms spells run with the same +// separator (possibly empty, up to archeraMaxSep runes) between every pair of +// neighbors. The separator is read off the first pair. +func archeraHasRepeatedSeparatorRun(ms, run []rune) bool { + if len(run) < 2 { + return false + } + for i, r := range ms { + if r != run[0] { + continue + } + for j := i + 1; j < len(ms) && j-i-1 <= archeraMaxSep; j++ { + if ms[j] == run[1] && spellsWithSeparator(ms[i:], run, ms[i+1:j]) { + return true + } + } + } + return false +} + +func spellsWithSeparator(ms, run, sep []rune) bool { + pos := 0 + for k, r := range run { + if k > 0 { + if pos+len(sep) > len(ms) || !slices.Equal(ms[pos:pos+len(sep)], sep) { + return false + } + pos += len(sep) + } + if pos >= len(ms) || ms[pos] != r { + return false + } + pos++ + } + return true +} + // archeraHasGappedRun reports whether run occurs in ms as a subsequence with // at most archeraMaxGap skipped runes between neighbors. Gap 0 is a plain -// substring match. +// substring match. It is O(len(ms)*len(run)). func archeraHasGappedRun(ms, run []rune) bool { - var cur []int + reach := make([]bool, len(ms)) for i, r := range ms { - if r == run[0] { - cur = append(cur, i) - } + reach[i] = r == run[0] } for _, want := range run[1:] { - var next []int + next := make([]bool, len(ms)) for j, r := range ms { if r != want { continue } - for _, p := range cur { - if j > p && j-p-1 <= archeraMaxGap { - next = append(next, j) + for g := 0; g <= archeraMaxGap && j-1-g >= 0; g++ { + if reach[j-1-g] { + next[j] = true break } } } - cur = next + reach = next } - return len(cur) > 0 + return slices.Contains(reach, true) } diff --git a/cmd/archera_comparison_key_test.go b/cmd/archera_comparison_key_test.go index 1d66f5ace..586af8194 100644 --- a/cmd/archera_comparison_key_test.go +++ b/cmd/archera_comparison_key_test.go @@ -8,6 +8,7 @@ import ( "net/http" "strings" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -38,7 +39,7 @@ var archeraInvisibleSeparators = map[string]string{ // archeraKeyAlphabetSeparators are characters that occur in archeraLongKey // itself, so an allow-list skeleton keeps them between the key characters. -var archeraKeyAlphabetSeparators = []string{"-", "x", "0", "--"} +var archeraKeyAlphabetSeparators = []string{"-", "x", "0", "--", "---", "0x0", "-- -", "-x0-9"} // archeraFullwidth maps printable ASCII to its fullwidth form. func archeraFullwidth(s string) string { @@ -164,15 +165,50 @@ func TestArcheraKeyFragmentBoundary(t *testing.T) { } } -// The gap rule is bounded: key characters 2 apart read as the key, 3 apart do -// not, which is what keeps ordinary text out of the net. +// The irregular-insertion rule is bounded: key characters 2 apart read as the +// key, 3 apart do not. Uses an 8-character key so there is a single window. func TestArcheraKeyGapBound(t *testing.T) { - two := archeraSafeError(errors.New("A: "+archeraSplitEverywhere(archeraLongKey, "--")), archeraLongKey).Error() + const key = "abcdefgh" + two := archeraSafeError(errors.New("A: abc"+"aa"+"defgh"), key).Error() assert.Contains(t, two, archeraWithheld) - three := archeraSafeError(errors.New("A: "+archeraSplitEverywhere(archeraLongKey, "---")), archeraLongKey).Error() + three := archeraSafeError(errors.New("A: abc"+"aaa"+"defgh"), key).Error() assert.NotContains(t, three, archeraWithheld) } +// The matcher is linear and looks only at what could be displayed: a hostile +// 1 MB rejected value must not stall the command. +func TestArcheraHostileLargeDecodeValueIsBounded(t *testing.T) { + const n = 1 << 20 + value := strings.Repeat("p", n/2) + strings.Repeat("r", n/2) + body := strings.Replace(archeraComparisonFixture(), `"payment_option":"no_upfront"`, `"payment_option":`+mustJSON(t, value), 1) + setupArchera(t, func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(body)) }) + env := map[string]string{archeraEnvAPIKey: archeraLongKey, archeraEnvOrgID: archeraTestOrg, archeraEnvPlanID: archeraTestPlan} + start := time.Now() + _, _, err := runArchera(t, env) + elapsed := time.Since(start) + require.Error(t, err) + assert.LessOrEqual(t, len(err.Error()), archeraMaxErrorSize) + assert.Less(t, elapsed, time.Second) +} + +// Only text that could be displayed (plus a small slack for a key cut by the +// cap) is checked: a key echoed far beyond the cap changes nothing. +func TestArcheraKeyBeyondCheckWindowIsIgnored(t *testing.T) { + far := strings.Repeat("A", archeraMaxErrorSize+archeraCheckSlack+64) + archeraSplitEverywhere(archeraLongKey, "\u2800") + got := archeraSafeError(errors.New(far), archeraLongKey).Error() + assert.NotContains(t, got, archeraWithheld) + assert.Equal(t, strings.Repeat("A", archeraMaxErrorSize), got) +} + +// A key spaced by the longest separator and cut by the cap is still withheld, +// so the look-ahead slack covers a whole window of such a key. +func TestArcheraKeySpacedByLongestSeparatorAtCapIsWithheld(t *testing.T) { + sep := strings.Repeat("-", archeraMaxSep) + msg := strings.Repeat("A", archeraMaxErrorSize-4) + archeraSplitEverywhere(archeraLongKey, sep) + got := archeraSafeError(errors.New(msg), archeraLongKey).Error() + assert.Equal(t, archeraWithheld, got) +} + // A key with an inner space or a format character is matched on what a reader // sees of it. func TestArcheraKeyWithInnerSpaceOrFormatRune(t *testing.T) {