From cd16c3db4fa7805c1116472735a4297646859c26 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Fri, 9 Oct 2026 16:25:21 +0200 Subject: [PATCH 1/8] feat(insurance): add default-off Archera settings, lazy client and status endpoint Adds internal/archera (ARCHERA_ORG_ID, ARCHERA_PLAN_ID, ARCHERA_API_KEY_SECRET; key resolved lazily through the secret resolver, mutex-guarded so a failed resolve is retried) and GET /api/insurance/status, gated on view:recommendations plus an unrestricted account scope (scoped sessions get 404). Status reports setting presence by name only and makes no outbound call. The comparison endpoint follows in a separate PR. Refs #785 #786 Co-Authored-By: Claude Sonnet 5.5 --- internal/api/handler.go | 2 + internal/api/handler_insurance.go | 46 +++++++ internal/api/handler_insurance_test.go | 133 +++++++++++++++++++ internal/api/router.go | 5 + internal/api/types.go | 1 + internal/archera/provider.go | 145 ++++++++++++++++++++ internal/archera/provider_test.go | 177 +++++++++++++++++++++++++ internal/server/app.go | 8 ++ 8 files changed, 517 insertions(+) create mode 100644 internal/api/handler_insurance.go create mode 100644 internal/api/handler_insurance_test.go create mode 100644 internal/archera/provider.go create mode 100644 internal/archera/provider_test.go diff --git a/internal/api/handler.go b/internal/api/handler.go index aca78168e..0800c58ef 100644 --- a/internal/api/handler.go +++ b/internal/api/handler.go @@ -42,6 +42,7 @@ type Handler struct { analyticsCollector AnalyticsCollectorInterface // Optional: snapshot collector analyticsSnapshots AnalyticsSnapshotStoreInterface // Optional: savings-snapshot time-series store signer oidc.Signer // Optional: OIDC issuer signer (backed by cloud KMS) + insurance InsuranceProvider // Optional: Archera comparison (nil = off) issuerURL string // Canonical OIDC issuer URL (falls back to dashboardURL / request domain) awsCfgOnce sync.Once // guards one-time loading of the base AWS config @@ -185,6 +186,7 @@ func NewHandler(cfg HandlerConfig) *Handler { analyticsSnapshots: cfg.AnalyticsSnapshots, signer: cfg.OIDCSigner, issuerURL: cfg.OIDCIssuerURL, + insurance: cfg.Insurance, commitmentOpts: cfg.CommitmentOpts, encryptionKeySource: cfg.EncryptionKeySource, } diff --git a/internal/api/handler_insurance.go b/internal/api/handler_insurance.go new file mode 100644 index 000000000..eaad790dc --- /dev/null +++ b/internal/api/handler_insurance.go @@ -0,0 +1,46 @@ +package api + +import ( + "context" + + "github.com/aws/aws-lambda-go/events" + + "github.com/LeanerCloud/cloud-commitments-platform/internal/archera" +) + +// InsuranceProvider is the Archera comparison surface. nil means not wired. +type InsuranceProvider interface { + Status() archera.Status +} + +// getInsuranceStatus reports whether the Archera comparison settings are +// present (names only, no values, no outbound call, no secret read). +// +// Gate: view:recommendations plus an unrestricted account scope. The Archera +// plan is deployment-global and cannot be attributed to one cloud account, so +// a scoped session gets 404 (same non-leaky shape as requireAccountAccess). +func (h *Handler) getInsuranceStatus(ctx context.Context, req *events.LambdaFunctionURLRequest) (*archera.Status, error) { + if err := h.requireInsuranceAccess(ctx, req); err != nil { + return nil, err + } + status := archera.Settings{}.Status() + if h.insurance != nil { + status = h.insurance.Status() + } + return &status, nil +} + +func (h *Handler) requireInsuranceAccess(ctx context.Context, req *events.LambdaFunctionURLRequest) error { + session, err := h.requirePermission(ctx, req, "view", "recommendations") + if err != nil { + return err + } + scope, err := h.getAccountScope(ctx, session) + if err != nil { + return err + } + if !scope.AllowsAll() { + return errNotFound + } + return nil +} diff --git a/internal/api/handler_insurance_test.go b/internal/api/handler_insurance_test.go new file mode 100644 index 000000000..b5f8338fd --- /dev/null +++ b/internal/api/handler_insurance_test.go @@ -0,0 +1,133 @@ +package api + +import ( + "context" + "fmt" + "net/http" + "testing" + + "github.com/aws/aws-lambda-go/events" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + "github.com/LeanerCloud/cloud-commitments-platform/internal/archera" + "github.com/LeanerCloud/cloud-commitments-platform/internal/secrets" +) + +const ( + insTestKey = "synthetic-archera-key-0123456789" + insTestOrg = "11111111-1111-4111-8111-111111111111" + insTestPlan = "44444444-4444-4444-8444-444444444444" + insTestRef = "SYNTHETIC_ARCHERA_KEY_REF" +) + +type panicTransport struct{ t *testing.T } + +func (p panicTransport) RoundTrip(*http.Request) (*http.Response, error) { + p.t.Fatal("status must make no outbound request") + return nil, nil +} + +func insuranceProvider(t *testing.T, s archera.Settings) *archera.Provider { + return archera.NewProvider(s, nil, &http.Client{Transport: panicTransport{t}}) +} + +func sessionPrincipalCtx(userID string) context.Context { + return contextWithPrincipal(context.Background(), &Principal{ + Kind: PrincipalSession, Session: &Session{UserID: userID}, UserID: userID, + }) +} + +func TestInsuranceStatus_AdminAPIKey_ReportsPresenceOnly(t *testing.T) { + full := archera.Settings{OrgID: insTestOrg, PlanID: insTestPlan, KeySecretRef: insTestRef} + for name, tc := range map[string]struct { + s archera.Settings + ok bool + missing []string + }{ + "off": {archera.Settings{}, false, []string{archera.EnvKeySecret, archera.EnvOrgID, archera.EnvPlanID}}, + "complete": {full, true, []string{}}, + } { + t.Run(name, func(t *testing.T) { + h := &Handler{apiKey: "admin-key", insurance: insuranceProvider(t, tc.s)} + req := &events.LambdaFunctionURLRequest{Headers: map[string]string{"x-api-key": "admin-key"}} + st, err := h.getInsuranceStatus(context.Background(), req) + require.NoError(t, err) + assert.Equal(t, tc.ok, st.Configured) + assert.Equal(t, tc.missing, st.Missing) + assert.NotContains(t, fmt.Sprintf("%+v", st), insTestOrg, "status must never carry IDs") + }) + } +} + +func TestInsuranceStatus_UnwiredIsOff(t *testing.T) { + h := &Handler{apiKey: "admin-key"} + req := &events.LambdaFunctionURLRequest{Headers: map[string]string{"x-api-key": "admin-key"}} + st, err := h.getInsuranceStatus(context.Background(), req) + require.NoError(t, err) + assert.False(t, st.Configured) +} + +func TestInsuranceStatus_DeniedUserGets403(t *testing.T) { + m := new(MockAuthService) + m.On("HasPermissionAPI", mock.Anything, "u1", "view", "recommendations").Return(false, nil) + h := &Handler{auth: m, insurance: insuranceProvider(t, archera.Settings{})} + _, err := h.getInsuranceStatus(sessionPrincipalCtx("u1"), &events.LambdaFunctionURLRequest{}) + ce, ok := IsClientError(err) + require.True(t, ok, "got %v", err) + assert.Equal(t, 403, ce.code) +} + +func TestInsuranceStatus_ScopedSessionGets404(t *testing.T) { + m := new(MockAuthService) + m.On("HasPermissionAPI", mock.Anything, "u1", "view", "recommendations").Return(true, nil) + m.On("GetAllowedAccountsAPI", mock.Anything, "u1").Return([]string{"acct-1"}, nil) + h := &Handler{auth: m, insurance: insuranceProvider(t, archera.Settings{})} + _, err := h.getInsuranceStatus(sessionPrincipalCtx("u1"), &events.LambdaFunctionURLRequest{}) + assert.ErrorIs(t, err, errNotFound) +} + +func TestInsuranceStatus_UnrestrictedSessionAllowed(t *testing.T) { + m := new(MockAuthService) + m.On("HasPermissionAPI", mock.Anything, "u1", "view", "recommendations").Return(true, nil) + m.On("GetAllowedAccountsAPI", mock.Anything, "u1").Return([]string(nil), nil) + h := &Handler{auth: m, insurance: insuranceProvider(t, archera.Settings{})} + st, err := h.getInsuranceStatus(sessionPrincipalCtx("u1"), &events.LambdaFunctionURLRequest{}) + require.NoError(t, err) + assert.False(t, st.Configured) +} + +func TestInsuranceStatus_ScopedUserAPIKeyGets404(t *testing.T) { + m := new(MockAuthService) + m.On("HasAPIKeyPermissionAPI", mock.Anything, "user-key", "view", "recommendations").Return("u2", "key-1", true, nil) + m.On("GetAllowedAccountsAPI", mock.Anything, "u2").Return([]string{"acct-1"}, nil) + h := &Handler{auth: m, insurance: insuranceProvider(t, archera.Settings{})} + ctx := contextWithPrincipal(context.Background(), &Principal{Kind: PrincipalUserAPIKey, UserID: "u2", APIKeyID: "key-1"}) + req := &events.LambdaFunctionURLRequest{Headers: map[string]string{"x-api-key": "user-key"}} + _, err := h.getInsuranceStatus(ctx, req) + assert.ErrorIs(t, err, errNotFound) +} + +func TestInsuranceStatus_RouteIsAuthenticated(t *testing.T) { + h := &Handler{auth: new(MockAuthService), insurance: insuranceProvider(t, archera.Settings{})} + _, err := NewRouter(h).Route(context.Background(), "GET", "/api/insurance/status", &events.LambdaFunctionURLRequest{}) + require.Error(t, err, "an unauthenticated request must not reach the handler") +} + +// The Handler holds a lazily built *insurance.Client behind the provider; no +// fmt verb applied to the Handler may print the key. +func TestInsurance_HandlerFormattingNeverPrintsKey(t *testing.T) { + t.Setenv(insTestRef, insTestKey) + p := archera.NewProvider(archera.Settings{OrgID: insTestOrg, PlanID: insTestPlan, KeySecretRef: insTestRef}, secrets.NewEnvResolver(), nil) + h := &Handler{insurance: p} + check := func(label string) { + for _, verb := range []string{"%v", "%+v", "%#v", "%s"} { + assert.NotContains(t, fmt.Sprintf(verb, h), insTestKey, label+" "+verb) + } + } + check("before the client is built") + _, err := p.Client(context.Background()) + require.NoError(t, err) + check("after the client is built") +} diff --git a/internal/api/router.go b/internal/api/router.go index 6909f4573..b64025c12 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -123,6 +123,7 @@ func (r *Router) registerRoutes() { // recommendation feed (handlers still scope rows by account // permission grants). {ExactPath: "/api/recommendations", Method: "GET", Handler: r.getRecommendationsHandler, Auth: AuthUser}, + {ExactPath: "/api/insurance/status", Method: "GET", Handler: r.getInsuranceStatusHandler, Auth: AuthUser}, {ExactPath: "/api/recommendations/freshness", Method: "GET", Handler: r.getRecommendationsFreshnessHandler, Auth: AuthUser}, // AuthUser: any signed-in user can trigger refresh; the handler // then enforces requirePermission(view, recommendations) so @@ -509,6 +510,10 @@ func (r *Router) refreshRecommendationsHandler(ctx context.Context, req *events. return r.h.postRefreshRecommendations(ctx, req) } +func (r *Router) getInsuranceStatusHandler(ctx context.Context, req *events.LambdaFunctionURLRequest, params map[string]string) (any, error) { + return r.h.getInsuranceStatus(ctx, req) +} + func (r *Router) getRecommendationsFreshnessHandler(ctx context.Context, req *events.LambdaFunctionURLRequest, params map[string]string) (any, error) { return r.h.getRecommendationsFreshness(ctx, req) } diff --git a/internal/api/types.go b/internal/api/types.go index d851bbb40..5f84701ed 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -41,6 +41,7 @@ type HandlerConfig struct { AuthService AuthServiceInterface CommitmentOpts CommitmentOptsInterface OIDCSigner oidc.Signer + Insurance InsuranceProvider AnalyticsSnapshots AnalyticsSnapshotStoreInterface CredentialStore credentials.CredentialStore EmailNotifier email.SenderInterface diff --git a/internal/archera/provider.go b/internal/archera/provider.go new file mode 100644 index 000000000..f35e3922a --- /dev/null +++ b/internal/archera/provider.go @@ -0,0 +1,145 @@ +// Package archera wires the explicit Archera comparison (pkg/insurance) into +// the server. The surface is off unless all three settings are present, and +// the API key is resolved lazily on the first explicit request. +package archera + +import ( + "context" + "errors" + "fmt" + "net/http" + "os" + "sync" + "sync/atomic" + + "github.com/LeanerCloud/cloud-commitments-go/pkg/insurance" + "github.com/LeanerCloud/cloud-commitments-go/pkg/logging" + + "github.com/LeanerCloud/cloud-commitments-platform/internal/secrets" +) + +// Setting names, shared with the CLI and MCP consumers. +const ( + EnvOrgID = "ARCHERA_ORG_ID" + EnvPlanID = "ARCHERA_PLAN_ID" + EnvKeySecret = "ARCHERA_API_KEY_SECRET" +) + +// ErrNotConfigured is returned (wrapped, naming the missing settings) when an +// explicit request arrives and one or more settings are absent. +var ErrNotConfigured = errors.New("archera comparison is not configured") + +// ErrKeyUnavailable is returned when the API key secret cannot be resolved or +// yields an unusable client. The message is fixed so no vendor or secret text +// can reach a response. +var ErrKeyUnavailable = errors.New("archera API key could not be resolved from " + EnvKeySecret) + +// Settings are the non-secret Archera settings. KeySecretRef is a reference to +// the secret (name, ARN or URI), not the key itself. +type Settings struct { + OrgID string + PlanID string + KeySecretRef string +} + +// SettingsFromEnv reads the settings from the process environment. +func SettingsFromEnv() Settings { + return Settings{ + OrgID: os.Getenv(EnvOrgID), + PlanID: os.Getenv(EnvPlanID), + KeySecretRef: os.Getenv(EnvKeySecret), + } +} + +// Missing lists the names (never values) of absent settings, in a fixed order. +func (s Settings) Missing() []string { + missing := []string{} + if s.KeySecretRef == "" { + missing = append(missing, EnvKeySecret) + } + if s.OrgID == "" { + missing = append(missing, EnvOrgID) + } + if s.PlanID == "" { + missing = append(missing, EnvPlanID) + } + return missing +} + +// Status reports whether the three settings are present. It resolves nothing +// and never carries a value. +type Status struct { + Missing []string `json:"missing"` + Configured bool `json:"configured"` +} + +// Status returns the presence-only status for the settings. +func (s Settings) Status() Status { + m := s.Missing() + return Status{Configured: len(m) == 0, Missing: m} +} + +// Provider builds the insurance client on first use. It holds the secret +// resolver and a *insurance.Client only, never the raw key or a Config value. +type Provider struct { + resolver secrets.Resolver + hc *http.Client // nil in production (SSRF-hardened default); tests inject + // client is an atomic pointer so fmt prints an address, never the Client's + // fields: fmt dereferences a plain *Client held in an unexported field for + // verbs such as %s, which would print the key. + client atomic.Pointer[insurance.Client] + settings Settings + mu sync.Mutex +} + +// NewProvider returns a Provider. hc must be nil in production. +func NewProvider(settings Settings, resolver secrets.Resolver, hc *http.Client) *Provider { + return &Provider{settings: settings, resolver: resolver, hc: hc} +} + +// Status reports setting presence without any outbound call or secret read. +func (p *Provider) Status() Status { + if p == nil { + return Settings{}.Status() + } + return p.settings.Status() +} + +// Format keeps the provider's contents out of every fmt verb. +func (p *Provider) Format(s fmt.State, _ rune) { + _, _ = fmt.Fprint(s, "archera.Provider{}") +} + +// PlanID is the configured Archera plan ID. +func (p *Provider) PlanID() string { return p.settings.PlanID } + +// Client returns the lazily built client. A failure is not cached: the next +// call retries (a mutex, not sync.Once, guards the build). +func (p *Provider) Client(ctx context.Context) (insurance.QuoteClient, error) { + if p == nil { + return nil, fmt.Errorf("%w: missing %v", ErrNotConfigured, Settings{}.Missing()) + } + if missing := p.settings.Missing(); len(missing) > 0 { + return nil, fmt.Errorf("%w: missing %v", ErrNotConfigured, missing) + } + p.mu.Lock() + defer p.mu.Unlock() + if c := p.client.Load(); c != nil { + return c, nil + } + if p.resolver == nil { + return nil, ErrKeyUnavailable + } + key, err := p.resolver.GetSecret(ctx, p.settings.KeySecretRef) + if err != nil { + logging.Warnf("archera: resolving %s failed: %v", EnvKeySecret, err) + return nil, ErrKeyUnavailable + } + c, err := insurance.NewClient(insurance.Config{APIKey: key, OrgID: p.settings.OrgID}, p.hc) + if err != nil { + logging.Warnf("archera: building client failed: %v", err) + return nil, ErrKeyUnavailable + } + p.client.Store(c) + return c, nil +} diff --git a/internal/archera/provider_test.go b/internal/archera/provider_test.go new file mode 100644 index 000000000..3fa3fa2de --- /dev/null +++ b/internal/archera/provider_test.go @@ -0,0 +1,177 @@ +package archera + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "strings" + "sync/atomic" + "testing" + + "github.com/LeanerCloud/cloud-commitments-go/pkg/insurance" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/LeanerCloud/cloud-commitments-platform/internal/secrets" +) + +// All values are synthetic. +const ( + testKey = "synthetic-archera-key-0123456789" + testOrg = "11111111-1111-4111-8111-111111111111" + testPlan = "44444444-4444-4444-8444-444444444444" + testLineID = "22222222-2222-4222-8222-222222222222" + testSecret = "SYNTHETIC_ARCHERA_KEY_REF" + financials = `{"commitment_cost":{"total":110,"breakdown":{"cloud_provider_cost":{"total":100},"archera_premium":10}},"commitment_savings":{"net":-5.5,"gross":4.5},"covered_ondemand_cost":104.5}` + offerEntry = `{"is_current":true,"offer_id":"11111111-1111-4111-8111-111111111111","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.3,"breakeven_days":null,"commitment_upfront_cost":1200,"commitment_financials_monthly_rate":` + financials + `,"delta_vs_current":{"monthly_net_savings":0,"upfront_cost":0,"discount_rate":0,"breakeven_days":null}}` + comparison = `{"current_totals":{"commitment_financials_monthly_rate":` + financials + `,"commitment_upfront_cost":1200},"hypothetical_totals":[],"data":[{"line_item_id":"` + testLineID + `","current":` + offerEntry + `,"candidates":[]}]}` +) + +// hostAssertingTransport fails the test unless the request targets the fixed +// Archera origin, then serves a synthetic response in place of the network. +type hostAssertingTransport struct { + t *testing.T + calls atomic.Int32 + body string + code int + gotKey, gotPath string +} + +func (rt *hostAssertingTransport) RoundTrip(r *http.Request) (*http.Response, error) { + rt.calls.Add(1) + assert.Equal(rt.t, "api.archera.ai", r.URL.Host) + assert.Equal(rt.t, "https", r.URL.Scheme) + rt.gotKey, rt.gotPath = r.Header.Get("x-api-key"), r.URL.Path + code := rt.code + if code == 0 { + code = 200 + } + return &http.Response{StatusCode: code, Header: http.Header{"Content-Type": {"application/json"}}, + Body: io.NopCloser(strings.NewReader(rt.body)), Request: r}, nil +} + +func fullSettings() Settings { + return Settings{OrgID: testOrg, PlanID: testPlan, KeySecretRef: testSecret} +} + +func envResolver(t *testing.T) secrets.Resolver { + t.Helper() + t.Setenv(testSecret, testKey) + return secrets.NewEnvResolver() +} + +func TestStatus_NamesOnly(t *testing.T) { + cases := []struct { + name string + s Settings + missing []string + ok bool + }{ + {"off", Settings{}, []string{EnvKeySecret, EnvOrgID, EnvPlanID}, false}, + {"no key", Settings{OrgID: testOrg, PlanID: testPlan}, []string{EnvKeySecret}, false}, + {"no org", Settings{PlanID: testPlan, KeySecretRef: testSecret}, []string{EnvOrgID}, false}, + {"no plan", Settings{OrgID: testOrg, KeySecretRef: testSecret}, []string{EnvPlanID}, false}, + {"complete", fullSettings(), []string{}, true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + rt := &hostAssertingTransport{t: t} + st := NewProvider(tc.s, nil, &http.Client{Transport: rt}).Status() + assert.Equal(t, tc.ok, st.Configured) + assert.Equal(t, tc.missing, st.Missing) + assert.Zero(t, rt.calls.Load(), "status must make no outbound call") + assert.NotContains(t, fmt.Sprintf("%+v", st), testOrg) + }) + } +} + +func TestStatus_NilProviderIsOff(t *testing.T) { + var p *Provider + assert.False(t, p.Status().Configured) + _, err := p.Client(context.Background()) + assert.ErrorIs(t, err, ErrNotConfigured) +} + +func TestClient_IncompleteConfigNamesMissingSettingAndSendsNothing(t *testing.T) { + rt := &hostAssertingTransport{t: t} + p := NewProvider(Settings{OrgID: testOrg, KeySecretRef: testSecret}, envResolver(t), &http.Client{Transport: rt}) + _, err := p.Client(context.Background()) + require.ErrorIs(t, err, ErrNotConfigured) + assert.Contains(t, err.Error(), EnvPlanID) + assert.NotContains(t, err.Error(), testKey) + assert.Zero(t, rt.calls.Load()) +} + +type flakyResolver struct { + calls atomic.Int32 + fails int32 +} + +func (f *flakyResolver) GetSecret(context.Context, string) (string, error) { + if f.calls.Add(1) <= f.fails { + // The error text carries the key to prove it is not propagated. + return "", errors.New("vault down while reading " + testKey) + } + return testKey, nil +} +func (*flakyResolver) GetSecretJSON(context.Context, string) (map[string]any, error) { return nil, nil } +func (*flakyResolver) PutSecret(context.Context, string, string) error { return nil } +func (*flakyResolver) ListSecrets(context.Context, string) ([]string, error) { return nil, nil } +func (*flakyResolver) Close() error { return nil } + +func TestClient_ResolveFailureIsNotCachedAndIsSanitized(t *testing.T) { + res := &flakyResolver{fails: 1} + p := NewProvider(fullSettings(), res, &http.Client{Transport: &hostAssertingTransport{t: t, body: comparison}}) + + _, err := p.Client(context.Background()) + require.ErrorIs(t, err, ErrKeyUnavailable) + assert.Contains(t, err.Error(), EnvKeySecret) + assert.NotContains(t, err.Error(), testKey) + + c, err := p.Client(context.Background()) + require.NoError(t, err, "a failed build must be retried on the next call, not cached") + require.NotNil(t, c) + assert.EqualValues(t, 2, res.calls.Load()) + + again, err := p.Client(context.Background()) + require.NoError(t, err) + assert.Same(t, c, again) + assert.EqualValues(t, 2, res.calls.Load(), "a built client is reused without resolving again") +} + +func TestClient_InvalidOrgIDIsSanitized(t *testing.T) { + s := fullSettings() + s.OrgID = "not-a-uuid" + _, err := NewProvider(s, envResolver(t), nil).Client(context.Background()) + require.ErrorIs(t, err, ErrKeyUnavailable) + assert.NotContains(t, err.Error(), testKey) +} + +// Production composition: env -> secret resolver -> lazy NewClient (hc injected +// only to replace the network) -> documented GET -> decode. +func TestComposition_EnvToDecodedComparison_AndRedaction(t *testing.T) { + rt := &hostAssertingTransport{t: t, body: comparison} + p := NewProvider(fullSettings(), envResolver(t), &http.Client{Transport: rt}) + + assertNoKey := func(label string) { + for _, verb := range []string{"%v", "%+v", "%#v", "%s"} { + out := fmt.Sprintf(verb, p) + fmt.Sprintf(verb, struct{ P *Provider }{p}) + fmt.Sprintf(verb, fullSettings()) + assert.NotContains(t, out, testKey, "%s %s", label, verb) + } + } + assertNoKey("before first comparison") + + c, err := p.Client(context.Background()) + require.NoError(t, err) + got, err := c.Comparison(context.Background(), insurance.ComparisonRequest{PlanID: p.PlanID()}) + require.NoError(t, err) + assert.Equal(t, 1, int(rt.calls.Load())) + assert.Equal(t, "/v1/org/"+testOrg+"/commitment-plans/"+testPlan+"/comparison", rt.gotPath) + assert.Equal(t, testKey, rt.gotKey) + assert.Equal(t, testPlan, got.PlanID) + + assertNoKey("after a successful comparison (client now built)") + assert.NotContains(t, fmt.Sprintf("%+v %v %#v", c, c, c), testKey) +} diff --git a/internal/server/app.go b/internal/server/app.go index 130bc3877..cff073acf 100644 --- a/internal/server/app.go +++ b/internal/server/app.go @@ -19,6 +19,7 @@ import ( awsladder "github.com/LeanerCloud/cloud-commitments-go/providers/aws/ladder" "github.com/LeanerCloud/cloud-commitments-platform/internal/analytics" "github.com/LeanerCloud/cloud-commitments-platform/internal/api" + "github.com/LeanerCloud/cloud-commitments-platform/internal/archera" "github.com/LeanerCloud/cloud-commitments-platform/internal/auth" "github.com/LeanerCloud/cloud-commitments-platform/internal/commitmentopts" "github.com/LeanerCloud/cloud-commitments-platform/internal/config" @@ -76,6 +77,7 @@ type Application struct { // Lazy initialization fields for PostgreSQL (Lambda ENI readiness) dbConfig *database.Config secretResolver secrets.Resolver + archera *archera.Provider dbMu sync.Mutex dbConnected bool dbErr error @@ -504,6 +506,9 @@ func NewApplicationFromDeps(ctx context.Context, cfg ApplicationConfig, deps Ext rateLimiter := api.RateLimiterInterface(api.NewInMemoryRateLimiter()) log.Println("Initialized temporary in-memory rate limiter until database connection") + // Archera comparison: default off, key resolved lazily on first explicit request. + archeraProvider := archera.NewProvider(archera.SettingsFromEnv(), deps.SecretResolver, nil) + // Initialize API handler apiHandler := api.NewHandler(api.HandlerConfig{ ConfigStore: deps.ConfigStore, @@ -519,6 +524,7 @@ func NewApplicationFromDeps(ctx context.Context, cfg ApplicationConfig, deps Ext DashboardURL: cfg.DashboardURL, OIDCSigner: signer, OIDCIssuerURL: resolveOIDCIssuerURL(cfg), + Insurance: archeraProvider, }) log.Printf("CUDly Server initialization complete") @@ -536,6 +542,7 @@ func NewApplicationFromDeps(ctx context.Context, cfg ApplicationConfig, deps Ext staticDir: staticDirFromEnv(), dbConfig: deps.DBConfig, secretResolver: deps.SecretResolver, + archera: archeraProvider, appConfig: cfg, signer: signer, scheduledAuth: scheduledAuth, @@ -882,6 +889,7 @@ func (app *Application) reinitializeAfterConnect(ctx context.Context, dbConn *da OIDCIssuerURL: resolveOIDCIssuerURL(app.appConfig), CommitmentOpts: commitmentOpts, EncryptionKeySource: app.encKeySource, + Insurance: app.archera, }) if app.API == nil { return fmt.Errorf("failed to create API handler") From 0c2cb2e1cbfa166011654971060175f2c1308965 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Fri, 9 Oct 2026 19:39:58 +0200 Subject: [PATCH 2/8] fix(insurance): annotate the setting-name constant for gosec G101 Refs #785 #786 Co-Authored-By: Claude Sonnet 5.5 --- internal/archera/provider.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/archera/provider.go b/internal/archera/provider.go index f35e3922a..b2c9d3eee 100644 --- a/internal/archera/provider.go +++ b/internal/archera/provider.go @@ -22,7 +22,7 @@ import ( const ( EnvOrgID = "ARCHERA_ORG_ID" EnvPlanID = "ARCHERA_PLAN_ID" - EnvKeySecret = "ARCHERA_API_KEY_SECRET" + EnvKeySecret = "ARCHERA_API_KEY_SECRET" // #nosec G101 -- setting name, not a credential value ) // ErrNotConfigured is returned (wrapped, naming the missing settings) when an From c3225e867451611dfb1c85c8a40f295aba2aaa0b Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Fri, 9 Oct 2026 19:42:52 +0200 Subject: [PATCH 3/8] feat(insurance): add the Archera comparison DTO with exact decimals Maps pkg/insurance.Comparison to an explicit snake_case DTO: exact decimal strings from big.Rat (null for unknown, error for non-terminating values, no float64), vendor strings stripped of control/format characters and capped at 256 bytes, per-offer AssessProductSupport (source and evidence only when supported), lease_attached, no org ID, currency null with a note, and the required Archera disclosures. The route follows in a separate PR. Refs #785 #786 Co-Authored-By: Claude Sonnet 5.5 --- internal/archera/dto.go | 281 +++++++++++++++++++++++++++++++++++ internal/archera/dto_test.go | 175 ++++++++++++++++++++++ 2 files changed, 456 insertions(+) create mode 100644 internal/archera/dto.go create mode 100644 internal/archera/dto_test.go diff --git a/internal/archera/dto.go b/internal/archera/dto.go new file mode 100644 index 000000000..561203f54 --- /dev/null +++ b/internal/archera/dto.go @@ -0,0 +1,281 @@ +package archera + +import ( + "errors" + "math/big" + "strings" + "time" + "unicode" + "unicode/utf8" + + "github.com/LeanerCloud/cloud-commitments-go/pkg/common" + "github.com/LeanerCloud/cloud-commitments-go/pkg/insurance" +) + +// ErrUnrepresentable is returned when a vendor number has no exact finite +// decimal form (the DTO never rounds or converts to float). +var ErrUnrepresentable = errors.New("archera value has no exact decimal representation") + +const ( + dtoTitle = "Archera commitment plan comparison (read-only)" + currencyNote = "currency not provided by Archera" + unknownVerdict = "unknown" + maxVendorStrLen = 256 + offerNameCaveat = "Archera's name for this offer; a plan comparison is a hypothetical rollup, not a bindable quote, and an insured target is subject to Archera underwriting allowances, not a guarantee." + basisNote = "Monthly figures are 730-hour monthly rates; the Archera premium is already included in commitment cost totals; upfront figures are one-time dollars and are never summed with monthly rates." + deltaBasisNote = "Hypothetical deltas compare against the current plan, not against on-demand." + supportedSummary = "supported" +) + +// Decimal renders r as the shortest exact decimal string. nil (unknown) stays +// nil. The denominator of a reduced rational is finite in decimal exactly when +// it has no prime factor other than 2 and 5, and the digits needed equal +// max(power of 2, power of 5). +func Decimal(r *big.Rat) (*string, error) { + if r == nil { + return nil, nil + } + d := new(big.Int).Set(r.Denom()) + two, five, zero := big.NewInt(2), big.NewInt(5), new(big.Int) + count := func(p *big.Int) int { + n, m := 0, new(big.Int) + for { + q, rem := new(big.Int).QuoRem(d, p, m) + if rem.Cmp(zero) != 0 { + return n + } + d, n = q, n+1 + } + } + c2, c5 := count(two), count(five) + if d.Cmp(big.NewInt(1)) != 0 { + return nil, ErrUnrepresentable + } + s := r.FloatString(max(c2, c5)) + return &s, nil +} + +// CleanString removes control, format and line/paragraph-separator characters +// from vendor text and caps it at 256 bytes on a rune boundary. +func CleanString(s string) string { + var b strings.Builder + for _, r := range s { + if unicode.IsControl(r) || unicode.In(r, unicode.Cf, unicode.Zl, unicode.Zp) { + continue + } + if b.Len()+utf8.RuneLen(r) > maxVendorStrLen { + break + } + b.WriteRune(r) + } + return b.String() +} + +// ProductSupportDTO is the documentation-derived verdict for one offer. +// Source and Evidence appear only for a supported verdict; allowance and +// customer eligibility are always unknown. +type ProductSupportDTO struct { + Status string `json:"status"` + Source string `json:"source,omitempty"` + Evidence string `json:"evidence,omitempty"` + UnderwritingAllowed string `json:"underwriting_allowance"` + CustomerEligibility string `json:"customer_eligibility"` +} + +// FinancialsDTO carries the 730-hour monthly rate block. +type FinancialsDTO struct { + CommitmentCostTotal *string `json:"monthly_730h_commitment_cost_total"` + CloudProviderCost *string `json:"monthly_730h_cloud_provider_cost"` + Premium *string `json:"monthly_730h_archera_premium"` + GrossSavings *string `json:"monthly_730h_gross_savings"` + NetSavings *string `json:"monthly_730h_net_savings"` + CoveredOnDemandCost *string `json:"monthly_730h_covered_on_demand_cost"` +} + +// TotalsDTO is a plan-wide rollup. +type TotalsDTO struct { + FinancialsDTO + UpfrontCost *string `json:"upfront_one_time_cost"` +} + +// DeltaDTO is the vendor's candidate-minus-current difference. +type DeltaDTO struct { + MonthlyNetSavings *string `json:"monthly_730h_net_savings"` + UpfrontCost *string `json:"upfront_one_time_cost"` + DiscountRate *string `json:"discount_rate"` + BreakevenDays *string `json:"breakeven_days"` +} + +// HypotheticalDeltaDTO compares a hypothetical rollup against the current plan. +type HypotheticalDeltaDTO struct { + MonthlyNetSavings *string `json:"monthly_730h_net_savings"` + MonthlyCommitmentCost *string `json:"monthly_730h_commitment_cost"` + UpfrontCost *string `json:"upfront_one_time_cost"` +} + +// LineItemDTO is how one line item lands inside a hypothetical rollup. +type LineItemDTO struct { + LineItemID string `json:"line_item_id"` + Reason string `json:"reason"` + ActualTerm *string `json:"actual_term"` + ActualPaymentOption *string `json:"actual_payment_option"` + ActualCommitmentType *string `json:"actual_commitment_type"` +} + +// HypotheticalDTO is a rollup for one (term, payment option) combination. +type HypotheticalDTO struct { + ContractTerm *string `json:"contract_term"` + PaymentOption string `json:"payment_option"` + Totals TotalsDTO `json:"totals"` + DeltaVsCurrent HypotheticalDeltaDTO `json:"delta_vs_current"` + LineItems []LineItemDTO `json:"line_items"` +} + +// OfferDTO is one offer (current or candidate) for a line item. +type OfferDTO struct { + OfferID string `json:"offer_id"` + IsCurrent bool `json:"is_current"` + Provider string `json:"provider"` + CommitmentType string `json:"commitment_type"` + Region *string `json:"region"` + ContractTerm *string `json:"contract_term"` + PaymentOption *string `json:"payment_option"` + LeaseAttached bool `json:"lease_attached"` + LeaseMenuItemID *string `json:"lease_menu_item_id"` + ArcheraOfferName *string `json:"archera_offer_name,omitempty"` + ArcheraOfferNameNote string `json:"archera_offer_name_note,omitempty"` + ProductSupport ProductSupportDTO `json:"archera_product_support"` + DiscountRate *string `json:"discount_rate"` + BreakevenDays *string `json:"breakeven_days"` + Monthly FinancialsDTO `json:"monthly"` + UpfrontCost *string `json:"upfront_one_time_cost"` + DeltaVsCurrent DeltaDTO `json:"delta_vs_current"` +} + +// RowDTO is a line item's current offer plus its alternatives. +type RowDTO struct { + LineItemID string `json:"line_item_id"` + Current OfferDTO `json:"current"` + Candidates []OfferDTO `json:"candidates"` +} + +// ComparisonDTO is the platform response. No org ID is included. +type ComparisonDTO struct { + Title string `json:"title"` + PlanID string `json:"plan_id"` + FetchedAt string `json:"fetched_at"` + Currency *string `json:"currency"` + CurrencyNote string `json:"currency_note"` + PremiumIncluded bool `json:"premium_included"` + BasisNote string `json:"basis_note"` + DeltaBasisNote string `json:"delta_basis_note"` + Current TotalsDTO `json:"current"` + Hypotheticals []HypotheticalDTO `json:"hypotheticals"` + Rows []RowDTO `json:"rows"` + NonGatingDisclosure string `json:"non_gating_disclosure"` + SponsorshipDisclosure string `json:"sponsorship_disclosure"` +} + +type mapper struct{ err error } + +func (m *mapper) dec(r *big.Rat) *string { + s, err := Decimal(r) + if err != nil && m.err == nil { + m.err = err + } + return s +} + +func cleanPtr(s *string) *string { + if s == nil { + return nil + } + c := CleanString(*s) + return &c +} + +func (m *mapper) financials(f insurance.Financials) FinancialsDTO { + return FinancialsDTO{ + CommitmentCostTotal: m.dec(f.CommitmentCostTotal), CloudProviderCost: m.dec(f.CloudProviderCost), + Premium: m.dec(f.Premium), GrossSavings: m.dec(f.GrossSavings), NetSavings: m.dec(f.NetSavings), + CoveredOnDemandCost: m.dec(f.CoveredOnDemandCost), + } +} + +func (m *mapper) totals(t insurance.Totals) TotalsDTO { + return TotalsDTO{FinancialsDTO: m.financials(t.Monthly), UpfrontCost: m.dec(t.UpfrontCost)} +} + +func (m *mapper) delta(d insurance.OfferDelta) DeltaDTO { + return DeltaDTO{MonthlyNetSavings: m.dec(d.MonthlyNetSavings), UpfrontCost: m.dec(d.UpfrontCost), + DiscountRate: m.dec(d.DiscountRate), BreakevenDays: m.dec(d.BreakevenDays)} +} + +func payment(p *insurance.PaymentOption) *string { + if p == nil { + return nil + } + s := CleanString(string(*p)) + return &s +} + +func (m *mapper) offer(e *insurance.OfferEntry) OfferDTO { + support := insurance.AssessProductSupport(e.Provider, e.CommitmentType) + ps := ProductSupportDTO{Status: unknownVerdict, UnderwritingAllowed: unknownVerdict, CustomerEligibility: unknownVerdict} + if support.Status == insurance.ProductSupportSupported { + ps.Status, ps.Source, ps.Evidence = supportedSummary, support.Source, support.Evidence + } + o := OfferDTO{ + OfferID: CleanString(e.OfferID), IsCurrent: e.IsCurrent, Provider: CleanString(string(e.Provider)), + CommitmentType: CleanString(e.CommitmentType), Region: cleanPtr(e.Region), ContractTerm: cleanPtr(e.ContractTerm), + PaymentOption: payment(e.PaymentOption), LeaseAttached: e.LeaseBacked(), LeaseMenuItemID: cleanPtr(e.LeaseMenuItemID), + ProductSupport: ps, DiscountRate: m.dec(e.DiscountRate), BreakevenDays: m.dec(e.BreakevenDays), + Monthly: m.financials(e.Monthly), UpfrontCost: m.dec(e.UpfrontCost), DeltaVsCurrent: m.delta(e.Delta), + } + if e.GuaranteedDisplayName != nil { + o.ArcheraOfferName, o.ArcheraOfferNameNote = cleanPtr(e.GuaranteedDisplayName), offerNameCaveat + } + return o +} + +// BuildComparison maps the contract type to the platform DTO. Money is exact +// decimal text or null; every vendor string is cleaned and capped. It returns +// ErrUnrepresentable when any number has no exact decimal form. +func BuildComparison(c *insurance.Comparison) (*ComparisonDTO, error) { + m := &mapper{} + out := &ComparisonDTO{ + Title: dtoTitle, PlanID: CleanString(c.PlanID), FetchedAt: c.FetchedAt.UTC().Format(time.RFC3339), + CurrencyNote: currencyNote, PremiumIncluded: true, BasisNote: basisNote, DeltaBasisNote: deltaBasisNote, + Current: m.totals(c.Current), + Hypotheticals: []HypotheticalDTO{}, Rows: []RowDTO{}, + NonGatingDisclosure: common.ArcheraNonGatingDisclosure, SponsorshipDisclosure: common.ArcheraSponsorshipDisclosure, + } + for i := range c.Hypotheticals { + h := &c.Hypotheticals[i] + hd := HypotheticalDTO{ + ContractTerm: cleanPtr(h.ContractTerm), PaymentOption: CleanString(string(h.PaymentOption)), + Totals: m.totals(h.Totals), LineItems: []LineItemDTO{}, + DeltaVsCurrent: HypotheticalDeltaDTO{MonthlyNetSavings: m.dec(h.DeltaMonthlyNetSavings), + MonthlyCommitmentCost: m.dec(h.DeltaMonthlyCommitmentCost), UpfrontCost: m.dec(h.DeltaUpfrontCost)}, + } + for _, li := range h.LineItems { + hd.LineItems = append(hd.LineItems, LineItemDTO{ + LineItemID: CleanString(li.LineItemID), Reason: CleanString(string(li.Reason)), ActualTerm: cleanPtr(li.ActualTerm), + ActualPaymentOption: payment(li.ActualPaymentOption), ActualCommitmentType: cleanPtr(li.ActualCommitmentType), + }) + } + out.Hypotheticals = append(out.Hypotheticals, hd) + } + for i := range c.Rows { + r := &c.Rows[i] + rd := RowDTO{LineItemID: CleanString(r.LineItemID), Current: m.offer(&r.Current), Candidates: []OfferDTO{}} + for j := range r.Candidates { + rd.Candidates = append(rd.Candidates, m.offer(&r.Candidates[j])) + } + out.Rows = append(out.Rows, rd) + } + if m.err != nil { + return nil, m.err + } + return out, nil +} diff --git a/internal/archera/dto_test.go b/internal/archera/dto_test.go new file mode 100644 index 000000000..8ec4284ad --- /dev/null +++ b/internal/archera/dto_test.go @@ -0,0 +1,175 @@ +package archera + +import ( + "encoding/json" + "math/big" + "strings" + "testing" + "time" + + "github.com/LeanerCloud/cloud-commitments-go/pkg/common" + "github.com/LeanerCloud/cloud-commitments-go/pkg/insurance" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func rat(t *testing.T, s string) *big.Rat { + t.Helper() + r, ok := new(big.Rat).SetString(s) + require.True(t, ok) + return r +} + +func TestDecimal(t *testing.T) { + for in, want := range map[string]string{ + "0": "0", "110": "110", "-5.5": "-5.5", "104.5": "104.5", "0.3": "0.3", + "1e100": "1" + strings.Repeat("0", 100), + "1e-100": "0." + strings.Repeat("0", 99) + "1", + "-1.5e-100": "-0." + strings.Repeat("0", 99) + "15", + "1/8": "0.125", "7/25": "0.28", + } { + got, err := Decimal(rat(t, in)) + require.NoError(t, err, in) + require.NotNil(t, got) + assert.Equal(t, want, *got, in) + } +} + +func TestDecimal_NilIsUnknownNotZero(t *testing.T) { + got, err := Decimal(nil) + require.NoError(t, err) + assert.Nil(t, got) +} + +func TestDecimal_NonTerminatingFailsLoud(t *testing.T) { + for _, in := range []string{"1/3", "2/7", "1/6"} { + got, err := Decimal(rat(t, in)) + assert.ErrorIs(t, err, ErrUnrepresentable, in) + assert.Nil(t, got) + } +} + +func TestCleanString(t *testing.T) { + assert.Equal(t, "abc", CleanString("a\x00b\u200bc\u2028\n\t")) + assert.Equal(t, "plain text", CleanString("plain text")) + long := strings.Repeat("a", 300) + assert.Len(t, CleanString(long), 256) + // 3-byte runes: 256/3 = 85 runes = 255 bytes, never a split rune. + got := CleanString(strings.Repeat("€", 100)) + assert.Len(t, got, 255) + assert.True(t, strings.HasPrefix(got, "€")) +} + +func offer(provider common.ProviderType, typ string) insurance.OfferEntry { + name := "Guaranteed\x00 EC2" + return insurance.OfferEntry{ + OfferID: "o\u200b1", CommitmentType: typ, Provider: provider, IsCurrent: true, + GuaranteedDisplayName: &name, LeaseMenuItemID: &name, + DiscountRate: rat(t0(), "0.3"), + Monthly: insurance.Financials{CommitmentCostTotal: rat(t0(), "110"), Premium: rat(t0(), "10")}, + UpfrontCost: rat(t0(), "1200"), + } +} + +func t0() *testing.T { return &testing.T{} } + +func sample() *insurance.Comparison { + return &insurance.Comparison{ + OrgID: "ORG-SECRETISH-ID", PlanID: "plan-1", + Current: insurance.Totals{Monthly: insurance.Financials{CommitmentCostTotal: rat(t0(), "110")}, UpfrontCost: rat(t0(), "1200")}, + Hypotheticals: []insurance.Hypothetical{{ + PaymentOption: insurance.PaymentNoUpfront, DeltaMonthlyNetSavings: rat(t0(), "7.25"), + LineItems: []insurance.HypotheticalLineItem{{LineItemID: "li-1", Reason: insurance.TermReasonNoAlternative}}, + }}, + Rows: []insurance.ComparisonRow{{LineItemID: "li-1", Current: offer(common.ProviderAWS, "aws/AmazonEC2"), Candidates: []insurance.OfferEntry{offer(common.ProviderGCP, "gcp/other")}}}, + FetchedAt: time.Date(2026, 10, 9, 12, 0, 0, 0, time.FixedZone("x", 3600)), + } +} + +func TestBuildComparison_ShapeAndHonesty(t *testing.T) { + dto, err := BuildComparison(sample()) + require.NoError(t, err) + raw, err := json.Marshal(dto) + require.NoError(t, err) + out := string(raw) + + assert.NotContains(t, out, "ORG-SECRETISH-ID", "org ID must not be returned") + assert.Equal(t, "2026-10-09T11:00:00Z", dto.FetchedAt) + assert.True(t, dto.PremiumIncluded) + assert.Nil(t, dto.Currency) + assert.Contains(t, out, `"currency":null`) + assert.Equal(t, currencyNote, dto.CurrencyNote) + assert.Contains(t, out, `"monthly_730h_gross_savings":null`, "unknown money stays null, never 0") + assert.Contains(t, out, `"monthly_730h_commitment_cost_total":"110"`) + assert.Contains(t, out, `"upfront_one_time_cost":"1200"`) + assert.Equal(t, common.ArcheraNonGatingDisclosure, dto.NonGatingDisclosure) + assert.Equal(t, common.ArcheraSponsorshipDisclosure, dto.SponsorshipDisclosure) + assert.Equal(t, "no_alternative", dto.Hypotheticals[0].LineItems[0].Reason) + assert.Equal(t, "7.25", *dto.Hypotheticals[0].DeltaVsCurrent.MonthlyNetSavings) + + cur := dto.Rows[0].Current + assert.Equal(t, "supported", cur.ProductSupport.Status) + assert.NotEmpty(t, cur.ProductSupport.Source) + assert.NotEmpty(t, cur.ProductSupport.Evidence) + assert.Equal(t, "unknown", cur.ProductSupport.UnderwritingAllowed) + assert.Equal(t, "unknown", cur.ProductSupport.CustomerEligibility) + assert.True(t, cur.LeaseAttached) + assert.Equal(t, "o1", cur.OfferID, "control and format characters are stripped") + require.NotNil(t, cur.ArcheraOfferName) + assert.Equal(t, "Guaranteed EC2", *cur.ArcheraOfferName) + assert.Contains(t, cur.ArcheraOfferNameNote, "not a guarantee") + + cand := dto.Rows[0].Candidates[0] + assert.Equal(t, "unknown", cand.ProductSupport.Status, "a product outside the documented rows is unknown") + assert.Empty(t, cand.ProductSupport.Source) + assert.Empty(t, cand.ProductSupport.Evidence) +} + +func TestBuildComparison_NoOfferNameWhenNil(t *testing.T) { + c := sample() + c.Rows[0].Current.GuaranteedDisplayName = nil + c.Rows[0].Current.LeaseMenuItemID = nil + dto, err := BuildComparison(c) + require.NoError(t, err) + raw, _ := json.Marshal(dto.Rows[0].Current) + assert.NotContains(t, string(raw), "archera_offer_name") + assert.False(t, dto.Rows[0].Current.LeaseAttached) +} + +func TestBuildComparison_UnrepresentableMoneyFailsLoud(t *testing.T) { + c := sample() + c.Current.UpfrontCost = rat(t0(), "1/3") + dto, err := BuildComparison(c) + assert.ErrorIs(t, err, ErrUnrepresentable) + assert.Nil(t, dto) +} + +// The pkg contract has no division and the DTO must never convert to float: +// a value that needs more than float64 precision survives exactly. +func TestBuildComparison_ExactBeyondFloat64(t *testing.T) { + c := sample() + c.Current.UpfrontCost = rat(t0(), "12345678901234567890.123456789") + dto, err := BuildComparison(c) + require.NoError(t, err) + assert.Equal(t, "12345678901234567890.123456789", *dto.Current.UpfrontCost) +} + +func TestBuildComparison_CapsEveryVendorString(t *testing.T) { + c := sample() + long := strings.Repeat("x", 400) + e := &c.Rows[0].Current + e.OfferID, e.CommitmentType, e.GuaranteedDisplayName, e.Region, e.LeaseMenuItemID = long, long, &long, &long, &long + c.Rows[0].LineItemID = long + c.Hypotheticals[0].LineItems[0].LineItemID = long + c.Hypotheticals[0].LineItems[0].ActualCommitmentType = &long + dto, err := BuildComparison(c) + require.NoError(t, err) + cur := dto.Rows[0].Current + for name, v := range map[string]string{ + "offer": cur.OfferID, "type": cur.CommitmentType, "name": *cur.ArcheraOfferName, "region": *cur.Region, + "lease": *cur.LeaseMenuItemID, "row": dto.Rows[0].LineItemID, + "li": dto.Hypotheticals[0].LineItems[0].LineItemID, "actual": *dto.Hypotheticals[0].LineItems[0].ActualCommitmentType, + } { + assert.LessOrEqual(t, len(v), 256, name) + } +} From 08a0950b500c0d2301f20391c69c243e19a51dd8 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Fri, 9 Oct 2026 20:05:46 +0200 Subject: [PATCH 4/8] feat(insurance): add the Archera comparison route and error mapping GET /api/insurance/comparison, behind the status route's gate (view:recommendations plus an unrestricted account scope, else 404). Vendor errors map to fixed client text, 429 surfaces Retry-After (header and JSON) only when the vendor gave a positive value, the vendor message and key are never returned or logged, and a response over 5 MiB fails instead of dropping rows. clientError gains optional response headers. Refs #785 #786 Co-Authored-By: Claude Sonnet 5.5 --- internal/api/handler.go | 18 ++ internal/api/handler_insurance.go | 3 + internal/api/handler_insurance_comparison.go | 92 +++++++ .../api/handler_insurance_comparison_test.go | 249 ++++++++++++++++++ internal/api/handler_insurance_test.go | 9 +- internal/api/handler_router.go | 11 + internal/api/router.go | 5 + internal/server/archera_wiring_test.go | 43 +++ 8 files changed, 426 insertions(+), 4 deletions(-) create mode 100644 internal/api/handler_insurance_comparison.go create mode 100644 internal/api/handler_insurance_comparison_test.go create mode 100644 internal/server/archera_wiring_test.go diff --git a/internal/api/handler.go b/internal/api/handler.go index 0800c58ef..15b81e1dc 100644 --- a/internal/api/handler.go +++ b/internal/api/handler.go @@ -804,11 +804,29 @@ func (h *Handler) executeRequest(ctx context.Context, method, path string, req * statusCode := 200 if err != nil { statusCode, response = h.handleRequestError(err) + corsHeaders = errorResponseHeaders(err, corsHeaders) } return h.buildResponse(statusCode, corsHeaders, response, nil), nil } +// errorResponseHeaders returns base plus any headers a client error carries +// (for example Retry-After). base is never mutated. +func errorResponseHeaders(err error, base map[string]string) map[string]string { + ce, ok := IsClientError(err) + if !ok || len(ce.headers) == 0 { + return base + } + merged := make(map[string]string, len(base)+len(ce.headers)) + for k, v := range base { + merged[k] = v + } + for k, v := range ce.headers { + merged[k] = v + } + return merged +} + // handleRequestError converts an error to status code and response. func (h *Handler) handleRequestError(err error) (int, any) { //nolint:gocritic // unnamedResult: stable internal API, adding names would require body renaming in impl if IsNotFoundError(err) { diff --git a/internal/api/handler_insurance.go b/internal/api/handler_insurance.go index eaad790dc..9b008be3d 100644 --- a/internal/api/handler_insurance.go +++ b/internal/api/handler_insurance.go @@ -3,6 +3,7 @@ package api import ( "context" + "github.com/LeanerCloud/cloud-commitments-go/pkg/insurance" "github.com/aws/aws-lambda-go/events" "github.com/LeanerCloud/cloud-commitments-platform/internal/archera" @@ -11,6 +12,8 @@ import ( // InsuranceProvider is the Archera comparison surface. nil means not wired. type InsuranceProvider interface { Status() archera.Status + PlanID() string + Client(ctx context.Context) (insurance.QuoteClient, error) } // getInsuranceStatus reports whether the Archera comparison settings are diff --git a/internal/api/handler_insurance_comparison.go b/internal/api/handler_insurance_comparison.go new file mode 100644 index 000000000..7c5d227df --- /dev/null +++ b/internal/api/handler_insurance_comparison.go @@ -0,0 +1,92 @@ +package api + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strconv" + "strings" + "time" + + "github.com/LeanerCloud/cloud-commitments-go/pkg/insurance" + "github.com/LeanerCloud/cloud-commitments-go/pkg/logging" + "github.com/aws/aws-lambda-go/events" + + "github.com/LeanerCloud/cloud-commitments-platform/internal/archera" +) + +// maxComparisonResponseBytes bounds the JSON returned to the browser. A larger +// comparison fails loudly rather than dropping rows. +const maxComparisonResponseBytes = 5 << 20 + +// getInsuranceComparison runs the explicit, read-only Archera comparison for +// the configured plan. Same gate as the status route. Only mapped, fixed-text +// errors leave this function; vendor text and the key never reach the response +// or the log. +func (h *Handler) getInsuranceComparison(ctx context.Context, req *events.LambdaFunctionURLRequest) (*archera.ComparisonDTO, error) { + if err := h.requireInsuranceAccess(ctx, req); err != nil { + return nil, err + } + if h.insurance == nil { + return nil, notConfiguredError(archera.Settings{}.Missing()) + } + if st := h.insurance.Status(); !st.Configured { + return nil, notConfiguredError(st.Missing) + } + // The raw client lives in this local only. + client, err := h.insurance.Client(ctx) + if err != nil { + if errors.Is(err, archera.ErrNotConfigured) { + return nil, notConfiguredError(h.insurance.Status().Missing) + } + return nil, NewClientError(503, "Archera API key could not be resolved from "+archera.EnvKeySecret) + } + cmp, err := client.Comparison(ctx, insurance.ComparisonRequest{PlanID: h.insurance.PlanID()}) + if err != nil { + return nil, mapInsuranceError(err) + } + dto, err := archera.BuildComparison(cmp) + if err != nil { + logging.Warnf("archera comparison: a vendor value has no exact decimal form") + return nil, NewClientError(502, "Archera returned a value that cannot be shown exactly") + } + raw, err := json.Marshal(dto) + if err != nil || len(raw) > maxComparisonResponseBytes { + return nil, NewClientError(502, "comparison too large to return") + } + return dto, nil +} + +func notConfiguredError(missing []string) error { + return NewClientError(503, "Archera comparison is not configured; missing settings: "+strings.Join(missing, ", ")) +} + +// mapInsuranceError converts a pkg/insurance error to a fixed client error. +// Only the status code and Retry-After are logged; HTTPError.Message is vendor +// text and is never returned or logged. +func mapInsuranceError(err error) error { + var he *insurance.HTTPError + if !errors.As(err, &he) { + logging.Warnf("archera comparison failed: non-HTTP error") + return NewClientError(502, "Archera comparison failed") + } + logging.Warnf("archera comparison failed: http_status=%d retry_after=%s", he.StatusCode, he.RetryAfter) + switch { + case he.StatusCode == 401 || he.StatusCode == 403: + return NewClientError(502, "Archera rejected the configured credentials") + case he.StatusCode == 404: + return NewClientError(502, "Archera organization or plan was not found") + case he.StatusCode == 429: + if he.RetryAfter <= 0 { + return NewClientError(429, "Archera rate limit reached; retry-after not given") + } + secs := int((he.RetryAfter + time.Second - 1) / time.Second) + return NewClientErrorWithHeaders(429, "Archera rate limit reached", + map[string]any{"retry_after_seconds": secs}, + map[string]string{"Retry-After": strconv.Itoa(secs)}) + case he.StatusCode >= 500: + return NewClientError(502, "Archera service error") + } + return NewClientError(502, fmt.Sprintf("Archera request failed with HTTP %d", he.StatusCode)) +} diff --git a/internal/api/handler_insurance_comparison_test.go b/internal/api/handler_insurance_comparison_test.go new file mode 100644 index 000000000..5330dc4a1 --- /dev/null +++ b/internal/api/handler_insurance_comparison_test.go @@ -0,0 +1,249 @@ +package api + +import ( + "bytes" + "context" + "io" + "net/http" + "os" + "strings" + "testing" + + "github.com/LeanerCloud/cloud-commitments-go/pkg/logging" + "github.com/aws/aws-lambda-go/events" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + "github.com/LeanerCloud/cloud-commitments-platform/internal/archera" + "github.com/LeanerCloud/cloud-commitments-platform/internal/secrets" +) + +const ( + cmpLineID = "22222222-2222-4222-8222-222222222222" + cmpFin = `{"commitment_cost":{"total":110,"breakdown":{"cloud_provider_cost":{"total":100},"archera_premium":10}},"commitment_savings":{"net":-5.5,"gross":4.5},"covered_ondemand_cost":104.5}` + cmpOffer = `{"is_current":true,"offer_id":"11111111-1111-4111-8111-111111111111","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.3,"breakeven_days":null,"commitment_upfront_cost":1200,"commitment_financials_monthly_rate":` + cmpFin + `,"delta_vs_current":{"monthly_net_savings":0,"upfront_cost":0,"discount_rate":0,"breakeven_days":null}}` + cmpBody = `{"current_totals":{"commitment_financials_monthly_rate":` + cmpFin + `,"commitment_upfront_cost":1200},"hypothetical_totals":[],"data":[{"line_item_id":"` + cmpLineID + `","current":` + cmpOffer + `,"candidates":[]}]}` +) + +type vendorTransport struct { + t *testing.T + calls int + status int + body string + headers http.Header + lastReq *http.Request +} + +func (v *vendorTransport) RoundTrip(r *http.Request) (*http.Response, error) { + v.calls++ + v.lastReq = r + assert.Equal(v.t, "https", r.URL.Scheme) + assert.Equal(v.t, "api.archera.ai", r.URL.Host) + h := v.headers + if h == nil { + h = http.Header{} + } + code := v.status + if code == 0 { + code = 200 + } + return &http.Response{StatusCode: code, Header: h, Body: io.NopCloser(strings.NewReader(v.body)), Request: r}, nil +} + +func adminCtx() context.Context { + return contextWithPrincipal(context.Background(), &Principal{Kind: PrincipalAdminAPIKey}) +} + +func cmpHandler(t *testing.T, s archera.Settings, vt *vendorTransport) *Handler { + t.Helper() + p := archera.NewProvider(s, secrets.NewEnvResolver(), &http.Client{Transport: vt}) + return NewHandler(HandlerConfig{Insurance: p}) +} + +func completeEnv(t *testing.T) archera.Settings { + t.Helper() + t.Setenv(archera.EnvOrgID, insTestOrg) + t.Setenv(archera.EnvPlanID, insTestPlan) + t.Setenv(archera.EnvKeySecret, insTestRef) + t.Setenv(insTestRef, insTestKey) + return archera.SettingsFromEnv() +} + +func captureLogs(t *testing.T) *bytes.Buffer { + t.Helper() + var buf bytes.Buffer + prev := logging.SetOutput(&buf) + logging.SetLevel("debug") + t.Cleanup(func() { logging.SetOutput(prev) }) + return &buf +} + +// Production composition: env -> SettingsFromEnv -> env secret resolver -> +// lazy insurance.NewClient (only the RoundTripper replaces the network) -> +// router -> handler -> JSON DTO. +func TestInsuranceComparison_Composition(t *testing.T) { + vt := &vendorTransport{t: t, body: cmpBody} + h := cmpHandler(t, completeEnv(t), vt) + + res, err := NewRouter(h).Route(adminCtx(), "GET", "/api/insurance/comparison", &events.LambdaFunctionURLRequest{}) + require.NoError(t, err) + + require.Equal(t, 1, vt.calls) + assert.Equal(t, http.MethodGet, vt.lastReq.Method) + assert.Equal(t, "/v1/org/"+insTestOrg+"/commitment-plans/"+insTestPlan+"/comparison", vt.lastReq.URL.Path) + assert.Equal(t, insTestKey, vt.lastReq.Header.Get("x-api-key")) + assert.Empty(t, vt.lastReq.URL.RawQuery, "no filters are passed") + + dto, ok := res.(*archera.ComparisonDTO) + require.True(t, ok) + assert.Equal(t, insTestPlan, dto.PlanID) + assert.True(t, dto.PremiumIncluded) + assert.Equal(t, "supported", dto.Rows[0].Current.ProductSupport.Status) + assert.Equal(t, "110", *dto.Current.CommitmentCostTotal) +} + +func TestInsuranceComparison_ProductionPassesNilHTTPClient(t *testing.T) { + // app.go must build the provider with hc=nil so the SSRF-hardened default + // client is used; only tests inject a transport. + src, err := os.ReadFile("../server/app.go") + require.NoError(t, err) + assert.Contains(t, string(src), "archera.NewProvider(archera.SettingsFromEnv(), deps.SecretResolver, nil)") +} + +func TestInsuranceComparison_NotConfiguredMakesNoRequest(t *testing.T) { + vt := &vendorTransport{t: t} + for name, s := range map[string]archera.Settings{ + "off": {}, + "incomplete": {OrgID: insTestOrg, KeySecretRef: insTestRef}, + } { + t.Run(name, func(t *testing.T) { + h := cmpHandler(t, s, vt) + _, err := h.getInsuranceComparison(adminCtx(), &events.LambdaFunctionURLRequest{}) + ce, ok := IsClientError(err) + require.True(t, ok, "%v", err) + assert.Equal(t, 503, ce.code) + for _, missing := range s.Missing() { + assert.Contains(t, ce.message, missing) + } + assert.NotContains(t, ce.message, insTestOrg) + assert.Zero(t, vt.calls) + }) + } + h := &Handler{insurance: nil} + _, err := h.getInsuranceComparison(adminCtx(), &events.LambdaFunctionURLRequest{}) + ce, _ := IsClientError(err) + require.NotNil(t, ce) + assert.Equal(t, 503, ce.code) +} + +func TestInsuranceComparison_KeyUnavailableNamesSetting(t *testing.T) { + t.Setenv(archera.EnvOrgID, insTestOrg) + t.Setenv(archera.EnvPlanID, insTestPlan) + t.Setenv(archera.EnvKeySecret, "SYNTHETIC_UNSET_REF") + vt := &vendorTransport{t: t} + h := cmpHandler(t, archera.SettingsFromEnv(), vt) + _, err := h.getInsuranceComparison(adminCtx(), &events.LambdaFunctionURLRequest{}) + ce, ok := IsClientError(err) + require.True(t, ok) + assert.Equal(t, 503, ce.code) + assert.Contains(t, ce.message, archera.EnvKeySecret) + assert.Zero(t, vt.calls) +} + +func TestInsuranceComparison_VendorErrorsAreSanitized(t *testing.T) { + fragment := insTestKey[:12] + echo := `{"message":"bad key ` + insTestKey + ` fragment ` + fragment + `"}` + cases := []struct { + name string + status int + body string + headers http.Header + code int + retry string + }{ + {"401 echoing key", 401, echo, nil, 502, ""}, + {"403", 403, echo, nil, 502, ""}, + {"404", 404, echo, nil, 502, ""}, + {"429 with Retry-After", 429, echo, http.Header{"Retry-After": {"120"}}, 429, "120"}, + {"429 without Retry-After", 429, echo, nil, 429, ""}, + {"429 zero Retry-After", 429, echo, http.Header{"Retry-After": {"0"}}, 429, ""}, + {"500", 500, echo, nil, 502, ""}, + {"oversized body", 200, strings.Repeat(" ", 9<<20), nil, 502, ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + logs := captureLogs(t) + vt := &vendorTransport{t: t, status: tc.status, body: tc.body, headers: tc.headers} + h := cmpHandler(t, completeEnv(t), vt) + _, err := h.getInsuranceComparison(adminCtx(), &events.LambdaFunctionURLRequest{}) + ce, ok := IsClientError(err) + require.True(t, ok, "%v", err) + assert.Equal(t, tc.code, ce.code) + for _, leak := range []string{insTestKey, fragment, "bad key"} { + assert.NotContains(t, ce.message, leak) + assert.NotContains(t, logs.String(), leak) + } + if tc.retry == "" { + assert.Empty(t, ce.headers) + assert.NotContains(t, ce.Details(), "retry_after_seconds") + } else { + assert.Equal(t, tc.retry, ce.headers["Retry-After"]) + assert.Contains(t, ce.Details(), "retry_after_seconds") + } + }) + } +} + +func TestInsuranceComparison_RetryAfterHeaderReachesResponse(t *testing.T) { + h := &Handler{} + err := NewClientErrorWithHeaders(429, "x", map[string]any{"retry_after_seconds": 7}, map[string]string{"Retry-After": "7"}) + base := map[string]string{"Access-Control-Allow-Origin": "o"} + status, body := h.handleRequestError(err) + resp := h.buildResponse(status, errorResponseHeaders(err, base), body, nil) + assert.Equal(t, "7", resp.Headers["Retry-After"]) + assert.Equal(t, "o", resp.Headers["Access-Control-Allow-Origin"]) + assert.Equal(t, 429, resp.StatusCode) + assert.Contains(t, resp.Body, `"retry_after_seconds":7`) + assert.NotContains(t, base, "Retry-After", "the base header map is not mutated") +} + +func TestInsuranceComparison_TooLargeFailsLoud(t *testing.T) { + // 9000 synthetic rows, each ~190 bytes of DTO JSON, exceed 5 MiB + // while the vendor body stays under the 8 MiB client bound. + var rows []string + for i := 0; i < 9000; i++ { + rows = append(rows, `{"line_item_id":"`+cmpLineID+`","current":`+cmpOffer+`,"candidates":[]}`) + } + body := `{"current_totals":{"commitment_financials_monthly_rate":` + cmpFin + `,"commitment_upfront_cost":1200},"hypothetical_totals":[],"data":[` + strings.Join(rows, ",") + `]}` + require.Less(t, len(body), 8<<20, "vendor body must stay under the client bound for this test") + vt := &vendorTransport{t: t, body: body} + h := cmpHandler(t, completeEnv(t), vt) + _, err := h.getInsuranceComparison(adminCtx(), &events.LambdaFunctionURLRequest{}) + ce, ok := IsClientError(err) + require.True(t, ok, "%v", err) + assert.Equal(t, 502, ce.code) + assert.Equal(t, "comparison too large to return", ce.message) +} + +func TestInsuranceComparison_DeniedAndScoped(t *testing.T) { + vt := &vendorTransport{t: t, body: cmpBody} + s := completeEnv(t) + p := archera.NewProvider(s, secrets.NewEnvResolver(), &http.Client{Transport: vt}) + + m := new(MockAuthService) + m.On("HasPermissionAPI", mock.Anything, "u1", "view", "recommendations").Return(false, nil) + _, err := (&Handler{auth: m, insurance: p}).getInsuranceComparison(sessionPrincipalCtx(), &events.LambdaFunctionURLRequest{}) + ce, ok := IsClientError(err) + require.True(t, ok) + assert.Equal(t, 403, ce.code) + + m2 := new(MockAuthService) + m2.On("HasAPIKeyPermissionAPI", mock.Anything, "user-key", "view", "recommendations").Return("u2", "key-1", true, nil) + m2.On("GetAllowedAccountsAPI", mock.Anything, "u2").Return([]string{"acct-1"}, nil) + ctx := contextWithPrincipal(context.Background(), &Principal{Kind: PrincipalUserAPIKey, UserID: "u2", APIKeyID: "key-1"}) + req := &events.LambdaFunctionURLRequest{Headers: map[string]string{"x-api-key": "user-key"}} + _, err = (&Handler{auth: m2, insurance: p}).getInsuranceComparison(ctx, req) + assert.ErrorIs(t, err, errNotFound) + assert.Zero(t, vt.calls, "a denied or scoped caller must not cause an outbound request") +} diff --git a/internal/api/handler_insurance_test.go b/internal/api/handler_insurance_test.go index b5f8338fd..23bcf0bc7 100644 --- a/internal/api/handler_insurance_test.go +++ b/internal/api/handler_insurance_test.go @@ -33,7 +33,8 @@ func insuranceProvider(t *testing.T, s archera.Settings) *archera.Provider { return archera.NewProvider(s, nil, &http.Client{Transport: panicTransport{t}}) } -func sessionPrincipalCtx(userID string) context.Context { +func sessionPrincipalCtx() context.Context { + const userID = "u1" return contextWithPrincipal(context.Background(), &Principal{ Kind: PrincipalSession, Session: &Session{UserID: userID}, UserID: userID, }) @@ -73,7 +74,7 @@ func TestInsuranceStatus_DeniedUserGets403(t *testing.T) { m := new(MockAuthService) m.On("HasPermissionAPI", mock.Anything, "u1", "view", "recommendations").Return(false, nil) h := &Handler{auth: m, insurance: insuranceProvider(t, archera.Settings{})} - _, err := h.getInsuranceStatus(sessionPrincipalCtx("u1"), &events.LambdaFunctionURLRequest{}) + _, err := h.getInsuranceStatus(sessionPrincipalCtx(), &events.LambdaFunctionURLRequest{}) ce, ok := IsClientError(err) require.True(t, ok, "got %v", err) assert.Equal(t, 403, ce.code) @@ -84,7 +85,7 @@ func TestInsuranceStatus_ScopedSessionGets404(t *testing.T) { m.On("HasPermissionAPI", mock.Anything, "u1", "view", "recommendations").Return(true, nil) m.On("GetAllowedAccountsAPI", mock.Anything, "u1").Return([]string{"acct-1"}, nil) h := &Handler{auth: m, insurance: insuranceProvider(t, archera.Settings{})} - _, err := h.getInsuranceStatus(sessionPrincipalCtx("u1"), &events.LambdaFunctionURLRequest{}) + _, err := h.getInsuranceStatus(sessionPrincipalCtx(), &events.LambdaFunctionURLRequest{}) assert.ErrorIs(t, err, errNotFound) } @@ -93,7 +94,7 @@ func TestInsuranceStatus_UnrestrictedSessionAllowed(t *testing.T) { m.On("HasPermissionAPI", mock.Anything, "u1", "view", "recommendations").Return(true, nil) m.On("GetAllowedAccountsAPI", mock.Anything, "u1").Return([]string(nil), nil) h := &Handler{auth: m, insurance: insuranceProvider(t, archera.Settings{})} - st, err := h.getInsuranceStatus(sessionPrincipalCtx("u1"), &events.LambdaFunctionURLRequest{}) + st, err := h.getInsuranceStatus(sessionPrincipalCtx(), &events.LambdaFunctionURLRequest{}) require.NoError(t, err) assert.False(t, st.Configured) } diff --git a/internal/api/handler_router.go b/internal/api/handler_router.go index ec6dc6a6b..3300f17e7 100644 --- a/internal/api/handler_router.go +++ b/internal/api/handler_router.go @@ -42,6 +42,7 @@ func IsNotFoundError(err error) bool { // clientError represents an error that should be returned to the client with a specific HTTP status code. type clientError struct { details map[string]any + headers map[string]string message string code int } @@ -87,6 +88,16 @@ func NewClientErrorWithDetails(code int, message string, details map[string]any) return &clientError{message: message, code: code, details: cloneDetails(details)} } +// NewClientErrorWithHeaders is NewClientErrorWithDetails plus extra response +// headers (for example Retry-After). Both maps are copied at construction. +func NewClientErrorWithHeaders(code int, message string, details map[string]any, headers map[string]string) error { + h := make(map[string]string, len(headers)) + for k, v := range headers { + h[k] = v + } + return &clientError{message: message, code: code, details: cloneDetails(details), headers: h} +} + // IsClientError checks if the error is a client error and returns it. func IsClientError(err error) (*clientError, bool) { var ce *clientError diff --git a/internal/api/router.go b/internal/api/router.go index b64025c12..ca2d88772 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -123,6 +123,7 @@ func (r *Router) registerRoutes() { // recommendation feed (handlers still scope rows by account // permission grants). {ExactPath: "/api/recommendations", Method: "GET", Handler: r.getRecommendationsHandler, Auth: AuthUser}, + {ExactPath: "/api/insurance/comparison", Method: "GET", Handler: r.getInsuranceComparisonHandler, Auth: AuthUser}, {ExactPath: "/api/insurance/status", Method: "GET", Handler: r.getInsuranceStatusHandler, Auth: AuthUser}, {ExactPath: "/api/recommendations/freshness", Method: "GET", Handler: r.getRecommendationsFreshnessHandler, Auth: AuthUser}, // AuthUser: any signed-in user can trigger refresh; the handler @@ -514,6 +515,10 @@ func (r *Router) getInsuranceStatusHandler(ctx context.Context, req *events.Lamb return r.h.getInsuranceStatus(ctx, req) } +func (r *Router) getInsuranceComparisonHandler(ctx context.Context, req *events.LambdaFunctionURLRequest, params map[string]string) (any, error) { + return r.h.getInsuranceComparison(ctx, req) +} + func (r *Router) getRecommendationsFreshnessHandler(ctx context.Context, req *events.LambdaFunctionURLRequest, params map[string]string) (any, error) { return r.h.getRecommendationsFreshness(ctx, req) } diff --git a/internal/server/archera_wiring_test.go b/internal/server/archera_wiring_test.go new file mode 100644 index 000000000..6075904a3 --- /dev/null +++ b/internal/server/archera_wiring_test.go @@ -0,0 +1,43 @@ +package server + +import ( + "go/ast" + "go/parser" + "go/token" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Every api.HandlerConfig literal in app.go (initial construction and +// reinitializeAfterConnect) must pass Insurance; dropping it from either +// silently turns the Archera routes off after the database connects. +func TestAppGoPassesInsuranceToEveryHandlerConfig(t *testing.T) { + fset := token.NewFileSet() + f, err := parser.ParseFile(fset, "app.go", nil, 0) + require.NoError(t, err) + + var found, withInsurance int + ast.Inspect(f, func(n ast.Node) bool { + cl, ok := n.(*ast.CompositeLit) + if !ok { + return true + } + sel, ok := cl.Type.(*ast.SelectorExpr) + if !ok || sel.Sel.Name != "HandlerConfig" { + return true + } + found++ + for _, el := range cl.Elts { + if kv, ok := el.(*ast.KeyValueExpr); ok { + if id, ok := kv.Key.(*ast.Ident); ok && id.Name == "Insurance" { + withInsurance++ + } + } + } + return true + }) + assert.Equal(t, 2, found, "expected the initial and reinitializeAfterConnect HandlerConfig literals") + assert.Equal(t, found, withInsurance, "every HandlerConfig literal must set Insurance") +} From 589c92bf456be8d91a921f6f6ed192e3683c4016 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Sat, 10 Oct 2026 04:27:05 +0200 Subject: [PATCH 5/8] test(insurance): pin every DTO field mapping with a distinct-value golden fixture One fixture gives every numeric field a distinct value (hundreds digit names the block, units digit the field) and compares the full JSON to a golden file; 25 field-mapping mutations (swaps, nils, blanked blocks, dropped term/payment/ is_current/provider) each fail. Also tests PlanID cleaning and passes the real testing.T to the number helpers. Refs #785 #786 Co-Authored-By: Claude Sonnet 5.5 --- internal/archera/dto_golden_test.go | 76 +++++++++++ internal/archera/dto_test.go | 41 +++--- .../archera/testdata/comparison.golden.json | 127 ++++++++++++++++++ 3 files changed, 227 insertions(+), 17 deletions(-) create mode 100644 internal/archera/dto_golden_test.go create mode 100644 internal/archera/testdata/comparison.golden.json diff --git a/internal/archera/dto_golden_test.go b/internal/archera/dto_golden_test.go new file mode 100644 index 000000000..2a33efcb8 --- /dev/null +++ b/internal/archera/dto_golden_test.go @@ -0,0 +1,76 @@ +package archera + +import ( + "encoding/json" + "fmt" + "os" + "testing" + "time" + + "github.com/LeanerCloud/cloud-commitments-go/pkg/common" + "github.com/LeanerCloud/cloud-commitments-go/pkg/insurance" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Every numeric field in goldenFixture carries a distinct value whose hundreds +// digit names its block (1xx current totals, 2xx hypothetical, 3xx current +// offer, 4xx candidate) and whose units digit names the field, so a swapped, +// dropped or blanked mapping changes the golden JSON. +func goldenFixture(t *testing.T) *insurance.Comparison { + t.Helper() + fin := func(b int) insurance.Financials { + return insurance.Financials{ + CommitmentCostTotal: rat(t, fmt.Sprint(b+1)), CloudProviderCost: rat(t, fmt.Sprint(b+2)), + Premium: rat(t, fmt.Sprint(b+3)), GrossSavings: rat(t, fmt.Sprint(b+4)), + NetSavings: rat(t, fmt.Sprint(b+5)), CoveredOnDemandCost: rat(t, fmt.Sprint(b+6)), + } + } + str := func(s string) *string { return &s } + pay := func(p insurance.PaymentOption) *insurance.PaymentOption { return &p } + mkOffer := func(b int, id string, current bool, provider common.ProviderType, typ, region, term string, p insurance.PaymentOption) insurance.OfferEntry { + return insurance.OfferEntry{ + OfferID: id, IsCurrent: current, Provider: provider, CommitmentType: typ, + Region: str(region), ContractTerm: str(term), PaymentOption: pay(p), + DiscountRate: rat(t, fmt.Sprintf("0.%d", b+8)), BreakevenDays: rat(t, fmt.Sprint(b+9)), + Monthly: fin(b), UpfrontCost: rat(t, fmt.Sprint(b+7)), + Delta: insurance.OfferDelta{ + MonthlyNetSavings: rat(t, fmt.Sprint(b+11)), UpfrontCost: rat(t, fmt.Sprint(b+12)), + DiscountRate: rat(t, fmt.Sprintf("0.%d", b+13)), BreakevenDays: rat(t, fmt.Sprint(b+14)), + }, + } + } + cur := mkOffer(300, "offer-cur", true, common.ProviderAWS, "aws/AmazonEC2", "us-east-1", "term-cur", insurance.PaymentNoUpfront) + cur.LeaseMenuItemID, cur.GuaranteedDisplayName = str("lease-cur"), str("name-cur") + cand := mkOffer(400, "offer-cand", false, common.ProviderGCP, "gcp/other", "eu-west-1", "term-cand", insurance.PaymentAllUpfront) + return &insurance.Comparison{ + OrgID: "org-must-not-appear", PlanID: "plan-g", + Current: insurance.Totals{Monthly: fin(100), UpfrontCost: rat(t, "107")}, + Hypotheticals: []insurance.Hypothetical{{ + ContractTerm: str("term-h"), PaymentOption: insurance.PaymentPartialUpfront, + Totals: insurance.Totals{Monthly: fin(200), UpfrontCost: rat(t, "207")}, + DeltaMonthlyNetSavings: rat(t, "211"), + DeltaMonthlyCommitmentCost: rat(t, "212"), + DeltaUpfrontCost: rat(t, "213"), + LineItems: []insurance.HypotheticalLineItem{{ + LineItemID: "li-h", Reason: insurance.TermReasonExactMatch, ActualTerm: str("at-h"), + ActualPaymentOption: pay(insurance.PaymentAllUpfront), ActualCommitmentType: str("act-type-h"), + }}, + }}, + Rows: []insurance.ComparisonRow{{LineItemID: "row-1", Current: cur, Candidates: []insurance.OfferEntry{cand}}}, + FetchedAt: time.Date(2026, 10, 9, 12, 0, 0, 0, time.UTC), + } +} + +func TestBuildComparison_GoldenJSON(t *testing.T) { + dto, err := BuildComparison(goldenFixture(t)) + require.NoError(t, err) + got, err := json.MarshalIndent(dto, "", " ") + require.NoError(t, err) + if os.Getenv("ARCHERA_WRITE_GOLDEN") != "" { + require.NoError(t, os.WriteFile("testdata/comparison.golden.json", append(got, '\n'), 0o600)) + } + want, err := os.ReadFile("testdata/comparison.golden.json") + require.NoError(t, err) + assert.JSONEq(t, string(want), string(got)) +} diff --git a/internal/archera/dto_test.go b/internal/archera/dto_test.go index 8ec4284ad..27494963e 100644 --- a/internal/archera/dto_test.go +++ b/internal/archera/dto_test.go @@ -60,34 +60,32 @@ func TestCleanString(t *testing.T) { assert.True(t, strings.HasPrefix(got, "€")) } -func offer(provider common.ProviderType, typ string) insurance.OfferEntry { +func offer(t *testing.T, provider common.ProviderType, typ string) insurance.OfferEntry { name := "Guaranteed\x00 EC2" return insurance.OfferEntry{ OfferID: "o\u200b1", CommitmentType: typ, Provider: provider, IsCurrent: true, GuaranteedDisplayName: &name, LeaseMenuItemID: &name, - DiscountRate: rat(t0(), "0.3"), - Monthly: insurance.Financials{CommitmentCostTotal: rat(t0(), "110"), Premium: rat(t0(), "10")}, - UpfrontCost: rat(t0(), "1200"), + DiscountRate: rat(t, "0.3"), + Monthly: insurance.Financials{CommitmentCostTotal: rat(t, "110"), Premium: rat(t, "10")}, + UpfrontCost: rat(t, "1200"), } } -func t0() *testing.T { return &testing.T{} } - -func sample() *insurance.Comparison { +func sample(t *testing.T) *insurance.Comparison { return &insurance.Comparison{ OrgID: "ORG-SECRETISH-ID", PlanID: "plan-1", - Current: insurance.Totals{Monthly: insurance.Financials{CommitmentCostTotal: rat(t0(), "110")}, UpfrontCost: rat(t0(), "1200")}, + Current: insurance.Totals{Monthly: insurance.Financials{CommitmentCostTotal: rat(t, "110")}, UpfrontCost: rat(t, "1200")}, Hypotheticals: []insurance.Hypothetical{{ - PaymentOption: insurance.PaymentNoUpfront, DeltaMonthlyNetSavings: rat(t0(), "7.25"), + PaymentOption: insurance.PaymentNoUpfront, DeltaMonthlyNetSavings: rat(t, "7.25"), LineItems: []insurance.HypotheticalLineItem{{LineItemID: "li-1", Reason: insurance.TermReasonNoAlternative}}, }}, - Rows: []insurance.ComparisonRow{{LineItemID: "li-1", Current: offer(common.ProviderAWS, "aws/AmazonEC2"), Candidates: []insurance.OfferEntry{offer(common.ProviderGCP, "gcp/other")}}}, + Rows: []insurance.ComparisonRow{{LineItemID: "li-1", Current: offer(t, common.ProviderAWS, "aws/AmazonEC2"), Candidates: []insurance.OfferEntry{offer(t, common.ProviderGCP, "gcp/other")}}}, FetchedAt: time.Date(2026, 10, 9, 12, 0, 0, 0, time.FixedZone("x", 3600)), } } func TestBuildComparison_ShapeAndHonesty(t *testing.T) { - dto, err := BuildComparison(sample()) + dto, err := BuildComparison(sample(t)) require.NoError(t, err) raw, err := json.Marshal(dto) require.NoError(t, err) @@ -126,7 +124,7 @@ func TestBuildComparison_ShapeAndHonesty(t *testing.T) { } func TestBuildComparison_NoOfferNameWhenNil(t *testing.T) { - c := sample() + c := sample(t) c.Rows[0].Current.GuaranteedDisplayName = nil c.Rows[0].Current.LeaseMenuItemID = nil dto, err := BuildComparison(c) @@ -137,8 +135,8 @@ func TestBuildComparison_NoOfferNameWhenNil(t *testing.T) { } func TestBuildComparison_UnrepresentableMoneyFailsLoud(t *testing.T) { - c := sample() - c.Current.UpfrontCost = rat(t0(), "1/3") + c := sample(t) + c.Current.UpfrontCost = rat(t, "1/3") dto, err := BuildComparison(c) assert.ErrorIs(t, err, ErrUnrepresentable) assert.Nil(t, dto) @@ -147,15 +145,15 @@ func TestBuildComparison_UnrepresentableMoneyFailsLoud(t *testing.T) { // The pkg contract has no division and the DTO must never convert to float: // a value that needs more than float64 precision survives exactly. func TestBuildComparison_ExactBeyondFloat64(t *testing.T) { - c := sample() - c.Current.UpfrontCost = rat(t0(), "12345678901234567890.123456789") + c := sample(t) + c.Current.UpfrontCost = rat(t, "12345678901234567890.123456789") dto, err := BuildComparison(c) require.NoError(t, err) assert.Equal(t, "12345678901234567890.123456789", *dto.Current.UpfrontCost) } func TestBuildComparison_CapsEveryVendorString(t *testing.T) { - c := sample() + c := sample(t) long := strings.Repeat("x", 400) e := &c.Rows[0].Current e.OfferID, e.CommitmentType, e.GuaranteedDisplayName, e.Region, e.LeaseMenuItemID = long, long, &long, &long, &long @@ -173,3 +171,12 @@ func TestBuildComparison_CapsEveryVendorString(t *testing.T) { assert.LessOrEqual(t, len(v), 256, name) } } + +func TestBuildComparison_CleansPlanID(t *testing.T) { + c := sample(t) + c.PlanID = "plan\u200b-\x00" + strings.Repeat("p", 400) + dto, err := BuildComparison(c) + require.NoError(t, err) + assert.True(t, strings.HasPrefix(dto.PlanID, "plan-p")) + assert.Len(t, dto.PlanID, 256) +} diff --git a/internal/archera/testdata/comparison.golden.json b/internal/archera/testdata/comparison.golden.json new file mode 100644 index 000000000..4908a59f6 --- /dev/null +++ b/internal/archera/testdata/comparison.golden.json @@ -0,0 +1,127 @@ +{ + "title": "Archera commitment plan comparison (read-only)", + "plan_id": "plan-g", + "fetched_at": "2026-10-09T12:00:00Z", + "currency": null, + "currency_note": "currency not provided by Archera", + "premium_included": true, + "basis_note": "Monthly figures are 730-hour monthly rates; the Archera premium is already included in commitment cost totals; upfront figures are one-time dollars and are never summed with monthly rates.", + "delta_basis_note": "Hypothetical deltas compare against the current plan, not against on-demand.", + "current": { + "monthly_730h_commitment_cost_total": "101", + "monthly_730h_cloud_provider_cost": "102", + "monthly_730h_archera_premium": "103", + "monthly_730h_gross_savings": "104", + "monthly_730h_net_savings": "105", + "monthly_730h_covered_on_demand_cost": "106", + "upfront_one_time_cost": "107" + }, + "hypotheticals": [ + { + "contract_term": "term-h", + "payment_option": "partial_upfront", + "totals": { + "monthly_730h_commitment_cost_total": "201", + "monthly_730h_cloud_provider_cost": "202", + "monthly_730h_archera_premium": "203", + "monthly_730h_gross_savings": "204", + "monthly_730h_net_savings": "205", + "monthly_730h_covered_on_demand_cost": "206", + "upfront_one_time_cost": "207" + }, + "delta_vs_current": { + "monthly_730h_net_savings": "211", + "monthly_730h_commitment_cost": "212", + "upfront_one_time_cost": "213" + }, + "line_items": [ + { + "line_item_id": "li-h", + "reason": "exact_match", + "actual_term": "at-h", + "actual_payment_option": "all_upfront", + "actual_commitment_type": "act-type-h" + } + ] + } + ], + "rows": [ + { + "line_item_id": "row-1", + "current": { + "offer_id": "offer-cur", + "is_current": true, + "provider": "aws", + "commitment_type": "aws/AmazonEC2", + "region": "us-east-1", + "contract_term": "term-cur", + "payment_option": "no_upfront", + "lease_attached": true, + "lease_menu_item_id": "lease-cur", + "archera_offer_name": "name-cur", + "archera_offer_name_note": "Archera's name for this offer; a plan comparison is a hypothetical rollup, not a bindable quote, and an insured target is subject to Archera underwriting allowances, not a guarantee.", + "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.", + "underwriting_allowance": "unknown", + "customer_eligibility": "unknown" + }, + "discount_rate": "0.308", + "breakeven_days": "309", + "monthly": { + "monthly_730h_commitment_cost_total": "301", + "monthly_730h_cloud_provider_cost": "302", + "monthly_730h_archera_premium": "303", + "monthly_730h_gross_savings": "304", + "monthly_730h_net_savings": "305", + "monthly_730h_covered_on_demand_cost": "306" + }, + "upfront_one_time_cost": "307", + "delta_vs_current": { + "monthly_730h_net_savings": "311", + "upfront_one_time_cost": "312", + "discount_rate": "0.313", + "breakeven_days": "314" + } + }, + "candidates": [ + { + "offer_id": "offer-cand", + "is_current": false, + "provider": "gcp", + "commitment_type": "gcp/other", + "region": "eu-west-1", + "contract_term": "term-cand", + "payment_option": "all_upfront", + "lease_attached": false, + "lease_menu_item_id": null, + "archera_product_support": { + "status": "unknown", + "underwriting_allowance": "unknown", + "customer_eligibility": "unknown" + }, + "discount_rate": "0.408", + "breakeven_days": "409", + "monthly": { + "monthly_730h_commitment_cost_total": "401", + "monthly_730h_cloud_provider_cost": "402", + "monthly_730h_archera_premium": "403", + "monthly_730h_gross_savings": "404", + "monthly_730h_net_savings": "405", + "monthly_730h_covered_on_demand_cost": "406" + }, + "upfront_one_time_cost": "407", + "delta_vs_current": { + "monthly_730h_net_savings": "411", + "upfront_one_time_cost": "412", + "discount_rate": "0.413", + "breakeven_days": "414" + } + } + ] + } + ], + "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." +} From 48ce8536bd33263456847e880ce3c3f4c60e3a05 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Sat, 10 Oct 2026 04:29:06 +0200 Subject: [PATCH 6/8] test(insurance): add a distinct-value composition test for the comparison route Refs #785 #786 Co-Authored-By: Claude Sonnet 5.5 --- .../api/handler_insurance_comparison_test.go | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/internal/api/handler_insurance_comparison_test.go b/internal/api/handler_insurance_comparison_test.go index 5330dc4a1..99d946ea2 100644 --- a/internal/api/handler_insurance_comparison_test.go +++ b/internal/api/handler_insurance_comparison_test.go @@ -103,6 +103,38 @@ func TestInsuranceComparison_Composition(t *testing.T) { assert.Equal(t, "110", *dto.Current.CommitmentCostTotal) } +// distinctBody has a different value in every numeric vendor field, so a +// mapping that swaps or drops one cannot pass the composition test. +func distinctBody() string { + fin := func(total, cloud, prem, net, gross, covered string) string { + return `{"commitment_cost":{"total":` + total + `,"breakdown":{"cloud_provider_cost":{"total":` + cloud + `},"archera_premium":` + prem + `}},"commitment_savings":{"net":` + net + `,"gross":` + gross + `},"covered_ondemand_cost":` + covered + `}` + } + offer := `{"is_current":true,"offer_id":"11111111-1111-4111-8111-111111111111","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":"one_year_gris","payment_option":"no_upfront","discount_rate":0.18,"breakeven_days":19,"commitment_upfront_cost":17,"commitment_financials_monthly_rate":` + fin("31", "32", "33", "35", "34", "36") + `,"delta_vs_current":{"monthly_net_savings":21,"upfront_cost":22,"discount_rate":0.23,"breakeven_days":24}}` + return `{"current_totals":{"commitment_financials_monthly_rate":` + fin("11", "12", "13", "15", "14", "16") + `,"commitment_upfront_cost":10},"hypothetical_totals":[],"data":[{"line_item_id":"` + cmpLineID + `","current":` + offer + `,"candidates":[]}]}` +} + +func TestInsuranceComparison_DistinctValuesReachTheirOwnFields(t *testing.T) { + vt := &vendorTransport{t: t, body: distinctBody()} + h := cmpHandler(t, completeEnv(t), vt) + dto, err := h.getInsuranceComparison(adminCtx(), &events.LambdaFunctionURLRequest{}) + require.NoError(t, err) + + c := dto.Current + assert.Equal(t, []string{"11", "12", "13", "14", "15", "16", "10"}, []string{ + *c.CommitmentCostTotal, *c.CloudProviderCost, *c.Premium, *c.GrossSavings, *c.NetSavings, *c.CoveredOnDemandCost, *c.UpfrontCost}) + o := dto.Rows[0].Current + m := o.Monthly + assert.Equal(t, []string{"31", "32", "33", "34", "35", "36", "17", "0.18", "19"}, []string{ + *m.CommitmentCostTotal, *m.CloudProviderCost, *m.Premium, *m.GrossSavings, *m.NetSavings, *m.CoveredOnDemandCost, + *o.UpfrontCost, *o.DiscountRate, *o.BreakevenDays}) + d := o.DeltaVsCurrent + assert.Equal(t, []string{"21", "22", "0.23", "24"}, []string{*d.MonthlyNetSavings, *d.UpfrontCost, *d.DiscountRate, *d.BreakevenDays}) + assert.Equal(t, "one_year_gris", *o.ContractTerm) + assert.Equal(t, "no_upfront", *o.PaymentOption) + assert.True(t, o.IsCurrent) + assert.Equal(t, "aws", o.Provider) +} + func TestInsuranceComparison_ProductionPassesNilHTTPClient(t *testing.T) { // app.go must build the provider with hc=nil so the SSRF-hardened default // client is used; only tests inject a transport. From 992cbf8756fc76514ce4e586e350d3c0a21435c1 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Sat, 10 Oct 2026 04:35:47 +0200 Subject: [PATCH 7/8] fix(insurance): do not imply a currency in the DTO basis note The basis note said upfront figures are one-time dollars, but Archera provides no currency and the DTO reports currency null. Say one-time amounts, update the golden file, and test that no platform-authored note names a currency. Refs #785 #786 Co-Authored-By: Claude Sonnet 5.5 --- internal/archera/dto.go | 2 +- internal/archera/dto_golden_test.go | 16 ++++++++++++++++ internal/archera/testdata/comparison.golden.json | 2 +- 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/internal/archera/dto.go b/internal/archera/dto.go index 561203f54..3361c530b 100644 --- a/internal/archera/dto.go +++ b/internal/archera/dto.go @@ -22,7 +22,7 @@ const ( unknownVerdict = "unknown" maxVendorStrLen = 256 offerNameCaveat = "Archera's name for this offer; a plan comparison is a hypothetical rollup, not a bindable quote, and an insured target is subject to Archera underwriting allowances, not a guarantee." - basisNote = "Monthly figures are 730-hour monthly rates; the Archera premium is already included in commitment cost totals; upfront figures are one-time dollars and are never summed with monthly rates." + basisNote = "Monthly figures are 730-hour monthly rates; the Archera premium is already included in commitment cost totals; upfront figures are one-time amounts and are never summed with monthly rates." deltaBasisNote = "Hypothetical deltas compare against the current plan, not against on-demand." supportedSummary = "supported" ) diff --git a/internal/archera/dto_golden_test.go b/internal/archera/dto_golden_test.go index 2a33efcb8..28dafc9a9 100644 --- a/internal/archera/dto_golden_test.go +++ b/internal/archera/dto_golden_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "os" + "strings" "testing" "time" @@ -74,3 +75,18 @@ func TestBuildComparison_GoldenJSON(t *testing.T) { require.NoError(t, err) assert.JSONEq(t, string(want), string(got)) } + +// The platform never presents Archera money as USD (currency is null), so no +// platform-authored note may name a currency. +func TestBuildComparison_NotesNameNoCurrency(t *testing.T) { + dto, err := BuildComparison(goldenFixture(t)) + require.NoError(t, err) + notes := []string{dto.Title, dto.CurrencyNote, dto.BasisNote, dto.DeltaBasisNote, dto.NonGatingDisclosure, + dto.SponsorshipDisclosure, dto.Rows[0].Current.ArcheraOfferNameNote} + for _, n := range notes { + require.NotEmpty(t, n) + for _, banned := range []string{"dollar", "USD", "$"} { + assert.NotContains(t, strings.ToLower(n), strings.ToLower(banned), n) + } + } +} diff --git a/internal/archera/testdata/comparison.golden.json b/internal/archera/testdata/comparison.golden.json index 4908a59f6..12ee8e889 100644 --- a/internal/archera/testdata/comparison.golden.json +++ b/internal/archera/testdata/comparison.golden.json @@ -5,7 +5,7 @@ "currency": null, "currency_note": "currency not provided by Archera", "premium_included": true, - "basis_note": "Monthly figures are 730-hour monthly rates; the Archera premium is already included in commitment cost totals; upfront figures are one-time dollars and are never summed with monthly rates.", + "basis_note": "Monthly figures are 730-hour monthly rates; the Archera premium is already included in commitment cost totals; upfront figures are one-time amounts and are never summed with monthly rates.", "delta_basis_note": "Hypothetical deltas compare against the current plan, not against on-demand.", "current": { "monthly_730h_commitment_cost_total": "101", From a2736b55fdd117c8bcfd9f6e63ce6ccdda1e979d Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Sat, 10 Oct 2026 05:08:35 +0200 Subject: [PATCH 8/8] test(insurance): cover Retry-After through the real request path and pin wiring Adds a HandleRequest-level test for the Retry-After header and retry_after_seconds (present when positive, absent at 0 or missing), pins the message for every mapped vendor status, and makes the app.go wiring test require the same provider instance in reinitializeAfterConnect. Refs #785 #786 Co-Authored-By: Claude Sonnet 5.5 --- .../api/handler_insurance_comparison_test.go | 61 +++++++++++++++++++ internal/server/archera_wiring_test.go | 6 ++ 2 files changed, 67 insertions(+) diff --git a/internal/api/handler_insurance_comparison_test.go b/internal/api/handler_insurance_comparison_test.go index 99d946ea2..f13ce0d9f 100644 --- a/internal/api/handler_insurance_comparison_test.go +++ b/internal/api/handler_insurance_comparison_test.go @@ -3,12 +3,14 @@ package api import ( "bytes" "context" + "errors" "io" "net/http" "os" "strings" "testing" + "github.com/LeanerCloud/cloud-commitments-go/pkg/insurance" "github.com/LeanerCloud/cloud-commitments-go/pkg/logging" "github.com/aws/aws-lambda-go/events" "github.com/stretchr/testify/assert" @@ -279,3 +281,62 @@ func TestInsuranceComparison_DeniedAndScoped(t *testing.T) { assert.ErrorIs(t, err, errNotFound) assert.Zero(t, vt.calls, "a denied or scoped caller must not cause an outbound request") } + +// Through the real request path (HandleRequest -> router -> handler -> +// response), so removing the header plumbing in executeRequest fails. +func TestInsuranceComparison_RealRequestPath_RetryAfter(t *testing.T) { + for name, tc := range map[string]struct { + headers http.Header + want string + }{ + "positive Retry-After": {http.Header{"Retry-After": {"120"}}, "120"}, + "no Retry-After": {nil, ""}, + "zero Retry-After": {http.Header{"Retry-After": {"0"}}, ""}, + } { + t.Run(name, func(t *testing.T) { + vt := &vendorTransport{t: t, status: 429, body: `{"message":"slow down"}`, headers: tc.headers} + h := cmpHandler(t, completeEnv(t), vt) + h.apiKey = "admin-key" + req := &events.LambdaFunctionURLRequest{ + Headers: map[string]string{"x-api-key": "admin-key"}, + RequestContext: events.LambdaFunctionURLRequestContext{ + HTTP: events.LambdaFunctionURLRequestContextHTTPDescription{Method: "GET", Path: "/api/insurance/comparison"}, + }, + } + resp, err := h.HandleRequest(context.Background(), req) + require.NoError(t, err) + assert.Equal(t, 429, resp.StatusCode) + require.Equal(t, 1, vt.calls) + if tc.want == "" { + assert.NotContains(t, resp.Headers, "Retry-After") + assert.NotContains(t, resp.Body, "retry_after_seconds") + return + } + assert.Equal(t, tc.want, resp.Headers["Retry-After"]) + assert.Contains(t, resp.Body, `"retry_after_seconds":`+tc.want) + }) + } +} + +func TestMapInsuranceError_PinsEachStatusMessage(t *testing.T) { + for status, want := range map[int]struct { + code int + msg string + }{ + 401: {502, "Archera rejected the configured credentials"}, + 403: {502, "Archera rejected the configured credentials"}, + 404: {502, "Archera organization or plan was not found"}, + 500: {502, "Archera service error"}, + 503: {502, "Archera service error"}, + 418: {502, "Archera request failed with HTTP 418"}, + } { + ce, ok := IsClientError(mapInsuranceError(&insurance.HTTPError{StatusCode: status, Message: "vendor text"})) + require.True(t, ok) + assert.Equal(t, want.code, ce.code, status) + assert.Equal(t, want.msg, ce.message, status) + } + ce, _ := IsClientError(mapInsuranceError(errors.New("boom"))) + assert.Equal(t, "Archera comparison failed", ce.message) + ce, _ = IsClientError(mapInsuranceError(&insurance.HTTPError{StatusCode: 429})) + assert.Equal(t, "Archera rate limit reached; retry-after not given", ce.message) +} diff --git a/internal/server/archera_wiring_test.go b/internal/server/archera_wiring_test.go index 6075904a3..8f6b38d61 100644 --- a/internal/server/archera_wiring_test.go +++ b/internal/server/archera_wiring_test.go @@ -4,6 +4,7 @@ import ( "go/ast" "go/parser" "go/token" + "go/types" "testing" "github.com/stretchr/testify/assert" @@ -19,6 +20,7 @@ func TestAppGoPassesInsuranceToEveryHandlerConfig(t *testing.T) { require.NoError(t, err) var found, withInsurance int + var values []string ast.Inspect(f, func(n ast.Node) bool { cl, ok := n.(*ast.CompositeLit) if !ok { @@ -33,6 +35,7 @@ func TestAppGoPassesInsuranceToEveryHandlerConfig(t *testing.T) { if kv, ok := el.(*ast.KeyValueExpr); ok { if id, ok := kv.Key.(*ast.Ident); ok && id.Name == "Insurance" { withInsurance++ + values = append(values, types.ExprString(kv.Value)) } } } @@ -40,4 +43,7 @@ func TestAppGoPassesInsuranceToEveryHandlerConfig(t *testing.T) { }) assert.Equal(t, 2, found, "expected the initial and reinitializeAfterConnect HandlerConfig literals") assert.Equal(t, found, withInsurance, "every HandlerConfig literal must set Insurance") + // The initial literal passes the provider it built; reinitializeAfterConnect + // must pass that same instance (app.archera), never nil or a fresh one. + assert.ElementsMatch(t, []string{"archeraProvider", "app.archera"}, values) }