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
23 changes: 23 additions & 0 deletions internal/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
36 changes: 36 additions & 0 deletions internal/api/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
})
}
}
Loading