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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 21 additions & 4 deletions internal/api/handler_recommendations.go
Original file line number Diff line number Diff line change
Expand Up @@ -371,23 +371,40 @@ func (h *Handler) buildRecommendationDetail(ctx context.Context, rec *config.Rec
}
}

// Confidence bucket cut-offs for the recommendation drawer badge. They must
// stay equal to exchange.ConfidenceHighSavingsUSD, exchange.ConfidenceHighMinCount
// and exchange.ConfidenceMediumSavingsUSD in cloud-commitments-go pkg/exchange,
// which score the reshape ranking the badge accompanies. Those exported
// constants are newer than the pkg version pinned in go.mod; once the pin is
// bumped past them, alias these to the exchange constants (#343).
const (
confidenceHighSavingsUSD = 200.0 // USD per month, high bucket
confidenceHighMinCount = 3 // instances, high bucket
confidenceMediumSavingsUSD = 50.0 // USD per month, medium bucket
)

// confidenceBucketFor mirrors the heuristic that previously lived
// client-side in frontend/src/recommendations.ts. Centralizing it on
// the server lets future provider-specific tuning land in one place
// without a frontend deploy. Thresholds intentionally match the original
// shim 1:1 so the drawer label doesn't visibly shift on rollout.
//
// high = ≥ $200/mo savings AND ≥ 3-instance fleet (both signals)
// medium = ≥ $50/mo savings (single signal)
// high = savings >= confidenceHighSavingsUSD AND count >= confidenceHighMinCount
// medium = savings >= confidenceMediumSavingsUSD
// low = otherwise
//
// A count below 1 (missing) is clamped to 1, so savings alone decide between
// medium and low. This deliberately differs from the exchange ranking, which
// returns a neutral score for a zero count: a badge must always show a
// bucket, while a ranking score should stay neutral on missing data.
func confidenceBucketFor(savings float64, count int) string {
if count < 1 {
count = 1
}
if savings >= 200 && count >= 3 {
if savings >= confidenceHighSavingsUSD && count >= confidenceHighMinCount {
return "high"
}
if savings >= 50 {
if savings >= confidenceMediumSavingsUSD {
return "medium"
}
return "low"
Expand Down
16 changes: 16 additions & 0 deletions internal/api/handler_recommendations_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -807,6 +807,22 @@ func TestConfidenceBucketFor(t *testing.T) {
{"medium on savings alone", 60, 1, "medium"},
{"low when neither threshold is met", 10, 1, "low"},
{"count clamped to >=1 — savings still drives bucket", 60, 0, "medium"},
// Exact boundaries, pinned with literals so a change to the named
// thresholds fails here (#343). Values match pkg/exchange's
// ConfidenceHighSavingsUSD / ConfidenceHighMinCount /
// ConfidenceMediumSavingsUSD.
{"high at both thresholds", 200, 3, "high"},
{"just below high savings", 199.99, 3, "medium"},
{"just below high count", 200, 2, "medium"},
{"medium at threshold", 50, 1, "medium"},
{"just below medium", 49.99, 1, "low"},
{"zero savings", 0, 10, "low"},
// Count-0 handling: the badge clamps a missing count to 1 instead of
// going neutral like the exchange ranking does, so high savings with
// no count is medium (never high) and low savings stays low.
{"count zero with high savings is medium, not high", 10000, 0, "medium"},
{"count zero with low savings is low", 10, 0, "low"},
{"negative count is clamped like zero", 10000, -5, "medium"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
Expand Down
22 changes: 9 additions & 13 deletions internal/server/static.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,19 +34,12 @@ func (h *spaHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, filePath) // #nosec G703 -- filePath validated by resolveStaticFilePath: filepath.Abs + isPathContainedIn prevents directory traversal
}

// setCacheHeaders sets Cache-Control based on file type.
// HTML files get no-cache; hashed assets (JS/CSS/images) get immutable long cache.
// setCacheHeaders sets Cache-Control based on file type. It delegates to
// cacheControlForExt, the single extension-to-policy table shared with the
// Lambda static path (serveStaticForLambda), so container and Lambda
// Function URL deployments cannot drift onto different cache policies.
func setCacheHeaders(w http.ResponseWriter, urlPath string) {
ext := strings.ToLower(path.Ext(urlPath))
switch ext {
case ".html":
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
case ".js", ".css", ".woff", ".woff2", ".ttf", ".eot",
".png", ".jpg", ".jpeg", ".gif", ".svg", ".ico", ".webp":
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
default:
w.Header().Set("Cache-Control", "public, max-age=3600")
}
w.Header().Set("Cache-Control", cacheControlForExt(strings.ToLower(path.Ext(urlPath))))
}

// isPathContainedIn reports whether absFile is at or under absDir, using a
Expand Down Expand Up @@ -173,7 +166,10 @@ func resolveStaticFilePath(dir, urlPath string) (filePath, cleanPath string, ok
return spaIndex(absDir, dir)
}

// cacheControlForExt returns the Cache-Control header value for a file extension.
// cacheControlForExt returns the Cache-Control header value for a lower-cased
// file extension. HTML gets no-cache; hashed assets (JS/CSS/fonts/images) get
// an immutable long cache; everything else gets a one-hour cache. Both static
// paths (setCacheHeaders and serveStaticForLambda) use it.
func cacheControlForExt(ext string) string {
switch ext {
case ".html":
Expand Down
81 changes: 81 additions & 0 deletions internal/server/static_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"net/http"
"net/http/httptest"
"os"
"path"
"path/filepath"
"strconv"
"strings"
Expand Down Expand Up @@ -81,6 +82,86 @@ func TestSetCacheHeaders(t *testing.T) {
}
}

// ----- cache policy pins (issue #343) -----

const (
ccNoCache = "no-cache, no-store, must-revalidate"
ccImmutable = "public, max-age=31536000, immutable"
ccOneHour = "public, max-age=3600"
)

// cachePolicyPins pins the exact Cache-Control value for every extension the
// policy table names, plus case variants and unlisted extensions that fall
// through to the default. The table must pass unchanged before and after
// setCacheHeaders was made to delegate to cacheControlForExt (#343).
var cachePolicyPins = []struct {
file string
want string
}{
{"app.html", ccNoCache}, // not index.html: http.ServeFile 301s that to "/"
{"INDEX.HTML", ccNoCache},
{"bundle.js", ccImmutable},
{"style.css", ccImmutable},
{"font.woff", ccImmutable},
{"font.woff2", ccImmutable},
{"font.ttf", ccImmutable},
{"font.eot", ccImmutable},
{"logo.png", ccImmutable},
{"LOGO.PNG", ccImmutable},
{"photo.jpg", ccImmutable},
{"photo.jpeg", ccImmutable},
{"anim.gif", ccImmutable},
{"icon.svg", ccImmutable},
{"favicon.ico", ccImmutable},
{"hero.webp", ccImmutable},
{"Mixed.Js", ccImmutable},
{"data.json", ccOneHour},
{"hero.avif", ccOneHour},
{"bundle.js.map", ccOneHour},
{"robots.txt", ccOneHour},
{"LICENSE", ccOneHour},
}

// TestCachePolicyPinned asserts exact header values from both entry points:
// setCacheHeaders (container spaHandler) and cacheControlForExt (Lambda).
func TestCachePolicyPinned(t *testing.T) {
for _, tt := range cachePolicyPins {
t.Run(tt.file, func(t *testing.T) {
urlPath := "/assets.v2/" + tt.file
w := httptest.NewRecorder()
setCacheHeaders(w, urlPath)
testutil.AssertEqual(t, tt.want, w.Header().Get("Cache-Control"))
testutil.AssertEqual(t, tt.want, cacheControlForExt(strings.ToLower(path.Ext(urlPath))))
})
}
}

// TestCachePolicyParity_ContainerAndLambda serves the same files through the
// container handler and the Lambda static path and requires an identical
// Cache-Control header from both, so a deployment mode cannot change the
// cache policy for an asset.
func TestCachePolicyParity_ContainerAndLambda(t *testing.T) {
files := map[string]string{}
for _, tt := range cachePolicyPins {
files[tt.file] = "x"
}
dir := makeStaticDir(t, files)
handler := spaFileServer(dir)
for _, tt := range cachePolicyPins {
t.Run(tt.file, func(t *testing.T) {
_, _, lambdaCC, found := serveStaticForLambda(dir, "/"+tt.file)
if !found {
t.Fatalf("serveStaticForLambda did not find %s", tt.file)
}
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/"+tt.file, http.NoBody))
testutil.AssertEqual(t, http.StatusOK, rec.Code)
testutil.AssertEqual(t, tt.want, lambdaCC)
testutil.AssertEqual(t, tt.want, rec.Header().Get("Cache-Control"))
})
}
}

// ----- isStaticPath -----

func TestIsStaticPath(t *testing.T) {
Expand Down
Loading