diff --git a/internal/api/api.go b/internal/api/api.go index 16b6206..9994c68 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -7700,11 +7700,34 @@ func filterPrices(prices []billing.Price, r *http.Request) []billing.Price { if priceType := query.Get("type"); priceType != "" && priceSearchType(price) != priceType { continue } + // Stripe 의 `lookup_keys[]` 는 OR 매칭 필터다. 이걸 무시하면 호출자가 특정 lookup key 로 + // 조회한 뒤 `.firstOrNull()` 을 하는 순간 **전혀 다른 테넌트의 상품**을 집는다. + // 실측: 한 테넌트의 플랜 목록이 다른 테넌트 상품으로 채워져 + // Upgrade plan 화면이 통째로 비었다. + if keys := lookupKeysFilter(query); len(keys) > 0 && !keys[price.LookupKey] { + continue + } out = append(out, price) } return out } +// lookupKeysFilter 는 `lookup_keys[]=a&lookup_keys[]=b` 와 `lookup_keys=a` 양쪽 표기를 받는다. +func lookupKeysFilter(query url.Values) map[string]bool { + keys := map[string]bool{} + for name, values := range query { + if name != "lookup_keys" && !strings.HasPrefix(name, "lookup_keys[") { + continue + } + for _, v := range values { + if v != "" { + keys[v] = true + } + } + } + return keys +} + func filterAccounts(accounts []billing.Account, r *http.Request) []billing.Account { query := r.URL.Query() metadataFilters := queryMetadataFilters(query) diff --git a/internal/api/api_test.go b/internal/api/api_test.go index b1cd544..01dcda2 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -6924,3 +6924,39 @@ func decodeResponse[T any](t *testing.T, rec *httptest.ResponseRecorder) T { func stringsReader(value string) *bytes.Reader { return bytes.NewReader([]byte(value)) } + +// lookup_keys 는 Stripe 의 OR 매칭 필터다. 무시하면 호출자가 특정 key 로 조회한 뒤 +// firstOrNull 을 하는 순간 다른 테넌트 상품을 집는다 — 실측으로 한 테넌트의 플랜 목록이 +// 다른 테넌트 상품으로 채워져 업그레이드 화면이 비었다. +func TestFilterPricesLookupKeys(t *testing.T) { + prices := []billing.Price{ + {ID: "price_a", LookupKey: "tenant_a_premium_yearly", Active: true}, + {ID: "price_b", LookupKey: "tenant_b_standard_yearly", Active: true}, + {ID: "price_c", LookupKey: "tenant_a_standard_monthly", Active: true}, + } + + for _, tc := range []struct { + name string + query string + want []string + }{ + {"필터 없으면 전부", "", []string{"price_a", "price_b", "price_c"}}, + {"배열 표기", "?lookup_keys[]=tenant_a_premium_yearly", []string{"price_a"}}, + {"평문 표기", "?lookup_keys=tenant_a_premium_yearly", []string{"price_a"}}, + {"복수는 OR", "?lookup_keys[]=tenant_a_premium_yearly&lookup_keys[]=tenant_a_standard_monthly", []string{"price_a", "price_c"}}, + {"없는 key 는 빈 목록", "?lookup_keys[]=nope", nil}, + } { + t.Run(tc.name, func(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, "/v1/prices"+tc.query, nil) + got := filterPrices(prices, r) + if len(got) != len(tc.want) { + t.Fatalf("건수 %d, 기대 %d (%v)", len(got), len(tc.want), got) + } + for i, id := range tc.want { + if got[i].ID != id { + t.Fatalf("[%d] %s, 기대 %s", i, got[i].ID, id) + } + } + }) + } +}