Skip to content

Commit 9f5a6df

Browse files
authored
fix: static entitlement deserialization (#3701)
1 parent 02656df commit 9f5a6df

File tree

5 files changed

+316
-17
lines changed

5 files changed

+316
-17
lines changed

api/v3/handlers/customers/entitlementaccess/mapping.go

Lines changed: 2 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,13 @@
11
package customersentitlement
22

33
import (
4-
"encoding/json"
54
"errors"
6-
"fmt"
75

86
api "github.com/openmeterio/openmeter/api/v3"
97
"github.com/openmeterio/openmeter/openmeter/entitlement"
108
booleanentitlement "github.com/openmeterio/openmeter/openmeter/entitlement/boolean"
119
meteredentitlement "github.com/openmeterio/openmeter/openmeter/entitlement/metered"
1210
staticentitlement "github.com/openmeterio/openmeter/openmeter/entitlement/static"
13-
"github.com/openmeterio/openmeter/pkg/models"
1411
)
1512

1613
// mapEntitlementValueToAPI maps an entitlement value to an API entitlement access result.
@@ -29,17 +26,9 @@ func mapEntitlementValueToAPI(featureKey string, entitlementValue entitlement.En
2926
HasAccess: ent.HasAccess(),
3027
}
3128

32-
// If config is not nil, unmarshal it
29+
// Config is now properly encoded (unwrapped at DB layer)
3330
if ent.Config != nil {
34-
var jsonValue string
35-
36-
// FIXME (pmarton): static config is double json encoded, we need to unmarshal before returning it
37-
if err := json.Unmarshal(ent.Config, &jsonValue); err != nil {
38-
return true, api.BillingEntitlementAccessResult{}, models.NewGenericValidationError(
39-
fmt.Errorf("failed to unmarshal static entitlement config: %w", err),
40-
)
41-
}
42-
31+
jsonValue := string(ent.Config)
4332
accessResult.Config = &jsonValue
4433
}
4534

e2e/entitlement_test.go

Lines changed: 282 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@ package e2e
22

33
import (
44
"context"
5+
"encoding/json"
56
"fmt"
67
"math"
78
"net/http"
9+
"os"
810
"testing"
911
"time"
1012

@@ -640,3 +642,283 @@ func TestEntitlementWithLatestAggregation(t *testing.T) {
640642
}, 2*time.Minute, time.Second)
641643
})
642644
}
645+
646+
func TestEntitlementStaticConfigEncoding(t *testing.T) {
647+
client := initClient(t)
648+
649+
ctx, cancel := context.WithCancel(context.Background())
650+
defer cancel()
651+
652+
subject := "ent_static_config_customer"
653+
customer := "ent_static_config_customer"
654+
var customerID string
655+
var featureId string
656+
var entitlementId string
657+
658+
c := CreateCustomerWithSubject(t, client, customer, subject)
659+
customerID = c.Id
660+
661+
t.Run("Create Feature", func(t *testing.T) {
662+
randKey := fmt.Sprintf("entitlement_static_config_feature_%d", time.Now().Unix())
663+
resp, err := client.CreateFeatureWithResponse(ctx, api.CreateFeatureJSONRequestBody{
664+
Name: "Entitlement Static Config Feature",
665+
Key: randKey,
666+
})
667+
668+
require.NoError(t, err)
669+
require.Equal(t, http.StatusCreated, resp.StatusCode(), "Invalid status code [response_body=%s]", string(resp.Body))
670+
671+
featureId = resp.JSON201.Id
672+
})
673+
674+
expectedConfigJSON := `{"integrations":["github","gitlab"],"maxUsers":10}`
675+
// Config needs to be a JSON-encoded string (not raw JSON object)
676+
// because the API expects: {"config": "{...}"} not {"config": {...}}
677+
configAsJSONString, err := json.Marshal(expectedConfigJSON)
678+
require.NoError(t, err)
679+
680+
t.Run("Create Static Entitlement with Config (V1)", func(t *testing.T) {
681+
staticEntitlement := api.EntitlementStaticCreateInputs{
682+
Type: "static",
683+
FeatureId: &featureId,
684+
Config: configAsJSONString,
685+
}
686+
body := &api.CreateEntitlementJSONRequestBody{}
687+
err := body.FromEntitlementStaticCreateInputs(staticEntitlement)
688+
require.NoError(t, err)
689+
resp, err := client.CreateEntitlementWithResponse(ctx, subject, *body)
690+
691+
require.NoError(t, err)
692+
require.Equal(t, http.StatusCreated, resp.StatusCode(), "Invalid status code [response_body=%s]", string(resp.Body))
693+
694+
static, err := resp.JSON201.AsEntitlementStatic()
695+
require.NoError(t, err)
696+
entitlementId = static.Id
697+
698+
// The config comes as json.RawMessage which is a JSON-encoded string
699+
// Unmarshal it to get the actual JSON content
700+
var configStr string
701+
require.NoError(t, json.Unmarshal(static.Config, &configStr))
702+
require.JSONEq(t, expectedConfigJSON, configStr, "Config should be valid JSON, not double-encoded")
703+
})
704+
705+
t.Run("Get Entitlement by ID and Check Config (V1)", func(t *testing.T) {
706+
resp, err := client.GetEntitlementByIdWithResponse(ctx, entitlementId)
707+
require.NoError(t, err)
708+
require.Equal(t, http.StatusOK, resp.StatusCode())
709+
710+
static, err := resp.JSON200.AsEntitlementStatic()
711+
require.NoError(t, err)
712+
713+
// Verify config is not double-encoded when retrieved
714+
var configStr string
715+
require.NoError(t, json.Unmarshal(static.Config, &configStr))
716+
require.JSONEq(t, expectedConfigJSON, configStr, "Retrieved config should match and not be double-encoded")
717+
})
718+
719+
t.Run("Get Entitlement Value and Check Config (V1)", func(t *testing.T) {
720+
resp, err := client.GetEntitlementValueWithResponse(ctx, subject, entitlementId, nil)
721+
require.NoError(t, err)
722+
require.Equal(t, http.StatusOK, resp.StatusCode())
723+
724+
if resp.JSON200.Config != nil {
725+
// The config string should be valid JSON
726+
require.JSONEq(t, expectedConfigJSON, *resp.JSON200.Config, "EntitlementValue config should not be double-encoded")
727+
} else {
728+
t.Fatal("Config should not be nil in entitlement value")
729+
}
730+
})
731+
732+
// Test V2 API
733+
t.Run("Create Static Entitlement with Config (V2)", func(t *testing.T) {
734+
// Create a new feature for V2 test
735+
var v2FeatureId string
736+
{
737+
randKey := fmt.Sprintf("entitlement_static_config_v2_feature_%d", time.Now().Unix())
738+
resp, err := client.CreateFeatureWithResponse(ctx, api.CreateFeatureJSONRequestBody{
739+
Name: "Entitlement Static Config V2 Feature",
740+
Key: randKey,
741+
})
742+
require.NoError(t, err)
743+
require.Equal(t, http.StatusCreated, resp.StatusCode())
744+
v2FeatureId = resp.JSON201.Id
745+
}
746+
747+
// Create static entitlement via V2 API using V1 static create inputs
748+
staticEntitlement := api.EntitlementStaticCreateInputs{
749+
Type: "static",
750+
FeatureId: &v2FeatureId,
751+
Config: configAsJSONString,
752+
}
753+
var body api.CreateCustomerEntitlementV2JSONRequestBody
754+
require.NoError(t, body.FromEntitlementStaticCreateInputs(staticEntitlement))
755+
756+
resp, err := client.CreateCustomerEntitlementV2WithResponse(ctx, customer, body)
757+
require.NoError(t, err)
758+
require.Equal(t, http.StatusCreated, resp.StatusCode(), "Invalid status code [response_body=%s]", string(resp.Body))
759+
760+
v2Static, err := resp.JSON201.AsEntitlementStaticV2()
761+
require.NoError(t, err)
762+
763+
// The config should be a valid JSON string, not double-encoded
764+
var configStr string
765+
require.NoError(t, json.Unmarshal(v2Static.Config, &configStr))
766+
require.JSONEq(t, expectedConfigJSON, configStr, "V2 Config should be valid JSON, not double-encoded")
767+
})
768+
769+
// Test V3 API (Entitlement Access)
770+
var v3FeatureKey string
771+
t.Run("Create Feature for V3", func(t *testing.T) {
772+
v3FeatureKey = fmt.Sprintf("entitlement_static_config_v3_feature_%d", time.Now().Unix())
773+
resp, err := client.CreateFeatureWithResponse(ctx, api.CreateFeatureJSONRequestBody{
774+
Name: "Entitlement Static Config V3 Feature",
775+
Key: v3FeatureKey,
776+
})
777+
require.NoError(t, err)
778+
require.Equal(t, http.StatusCreated, resp.StatusCode())
779+
})
780+
781+
t.Run("Create Static Entitlement for V3", func(t *testing.T) {
782+
staticEntitlement := api.EntitlementStaticCreateInputs{
783+
Type: "static",
784+
FeatureKey: &v3FeatureKey,
785+
Config: configAsJSONString,
786+
}
787+
body := &api.CreateEntitlementJSONRequestBody{}
788+
require.NoError(t, body.FromEntitlementStaticCreateInputs(staticEntitlement))
789+
resp, err := client.CreateEntitlementWithResponse(ctx, subject, *body)
790+
require.NoError(t, err)
791+
require.Equal(t, http.StatusCreated, resp.StatusCode())
792+
})
793+
794+
// Raw HTTP tests to verify actual format (independent of Go client)
795+
baseURL := getBaseURL(t)
796+
httpClient := &http.Client{}
797+
798+
t.Run("Raw HTTP: Get Entitlement by ID (V1)", func(t *testing.T) {
799+
req, err := http.NewRequestWithContext(ctx, "GET", fmt.Sprintf("%s/api/v1/entitlements/%s", baseURL, entitlementId), nil)
800+
require.NoError(t, err)
801+
802+
resp, err := httpClient.Do(req)
803+
require.NoError(t, err)
804+
defer resp.Body.Close()
805+
806+
require.Equal(t, http.StatusOK, resp.StatusCode)
807+
808+
var result map[string]interface{}
809+
require.NoError(t, json.NewDecoder(resp.Body).Decode(&result))
810+
811+
// Verify config is a string in the JSON response
812+
configValue, ok := result["config"]
813+
require.True(t, ok, "config field should exist")
814+
configStr, ok := configValue.(string)
815+
require.True(t, ok, "config should be a JSON string, not an object")
816+
require.JSONEq(t, expectedConfigJSON, configStr, "Raw HTTP: config should not be double-encoded")
817+
})
818+
819+
t.Run("Raw HTTP: Get Entitlement Value (V1)", func(t *testing.T) {
820+
req, err := http.NewRequestWithContext(ctx, "GET", fmt.Sprintf("%s/api/v1/subjects/%s/entitlements/%s/value", baseURL, subject, entitlementId), nil)
821+
require.NoError(t, err)
822+
823+
resp, err := httpClient.Do(req)
824+
require.NoError(t, err)
825+
defer resp.Body.Close()
826+
827+
require.Equal(t, http.StatusOK, resp.StatusCode)
828+
829+
var result map[string]interface{}
830+
require.NoError(t, json.NewDecoder(resp.Body).Decode(&result))
831+
832+
// Verify config is a string in the JSON response
833+
configValue, ok := result["config"]
834+
require.True(t, ok, "config field should exist")
835+
configStr, ok := configValue.(string)
836+
require.True(t, ok, "config should be a JSON string, not an object")
837+
require.JSONEq(t, expectedConfigJSON, configStr, "Raw HTTP: EntitlementValue config should not be double-encoded")
838+
})
839+
840+
t.Run("Raw HTTP: List Customer Entitlements (V2)", func(t *testing.T) {
841+
req, err := http.NewRequestWithContext(ctx, "GET", fmt.Sprintf("%s/api/v2/customers/%s/entitlements", baseURL, customer), nil)
842+
require.NoError(t, err)
843+
844+
resp, err := httpClient.Do(req)
845+
require.NoError(t, err)
846+
defer resp.Body.Close()
847+
848+
require.Equal(t, http.StatusOK, resp.StatusCode)
849+
850+
var result map[string]interface{}
851+
require.NoError(t, json.NewDecoder(resp.Body).Decode(&result))
852+
853+
items, ok := result["items"].([]interface{})
854+
require.True(t, ok, "items should be an array")
855+
require.NotEmpty(t, items, "should have at least one entitlement")
856+
857+
// Find a static entitlement with config
858+
var foundConfig bool
859+
for _, item := range items {
860+
entitlement, ok := item.(map[string]interface{})
861+
if !ok {
862+
continue
863+
}
864+
if entitlement["type"] == "static" {
865+
if configValue, ok := entitlement["config"]; ok {
866+
configStr, ok := configValue.(string)
867+
require.True(t, ok, "V2 config should be a JSON string, not an object")
868+
require.JSONEq(t, expectedConfigJSON, configStr, "Raw HTTP V2: config should not be double-encoded")
869+
foundConfig = true
870+
break
871+
}
872+
}
873+
}
874+
require.True(t, foundConfig, "should find a static entitlement with config")
875+
})
876+
877+
t.Run("Raw HTTP: Check Entitlement Access (V3)", func(t *testing.T) {
878+
req, err := http.NewRequestWithContext(ctx, "GET", fmt.Sprintf("%s/api/v3/openmeter/customers/%s/entitlement-access", baseURL, customerID), nil)
879+
require.NoError(t, err)
880+
881+
resp, err := httpClient.Do(req)
882+
require.NoError(t, err)
883+
defer resp.Body.Close()
884+
885+
require.Equal(t, http.StatusOK, resp.StatusCode, "V3 endpoint should be available")
886+
887+
var result map[string]interface{}
888+
require.NoError(t, json.NewDecoder(resp.Body).Decode(&result))
889+
890+
data, ok := result["data"].([]interface{})
891+
require.True(t, ok, "data should be an array")
892+
require.NotEmpty(t, data, "should have at least one access result")
893+
894+
// Find the static entitlement we created
895+
var foundConfig bool
896+
for _, item := range data {
897+
access, ok := item.(map[string]interface{})
898+
if !ok {
899+
continue
900+
}
901+
// Check if this is our v3 feature
902+
if fk, ok := access["feature_key"].(string); ok && fk == v3FeatureKey {
903+
configValue, ok := access["config"]
904+
require.True(t, ok, "config field should exist for static entitlement")
905+
configStr, ok := configValue.(string)
906+
require.True(t, ok, "V3 config should be a JSON string, not an object")
907+
require.JSONEq(t, expectedConfigJSON, configStr, "Raw HTTP V3: config should not be double-encoded")
908+
foundConfig = true
909+
break
910+
}
911+
}
912+
require.True(t, foundConfig, "should find the v3 static entitlement with config")
913+
})
914+
}
915+
916+
// getBaseURL returns the base URL for raw HTTP requests
917+
func getBaseURL(t *testing.T) string {
918+
t.Helper()
919+
address := os.Getenv("OPENMETER_ADDRESS")
920+
if address == "" {
921+
t.Skip("OPENMETER_ADDRESS not set")
922+
}
923+
return address
924+
}

openmeter/entitlement/adapter/entitlement.go

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package adapter
22

33
import (
44
"context"
5+
"encoding/json"
56
"fmt"
67
"strings"
78
"time"
@@ -508,7 +509,16 @@ func (a *entitlementDBAdapter) mapEntitlementEntity(e *db.Entitlement) (*entitle
508509
}
509510

510511
if e.Config != nil {
511-
ent.Config = e.Config
512+
// Config in DB is double-encoded (stored as JSON string with quotes)
513+
// Unwrap one layer when reading from DB so the domain model has correct encoding
514+
var configStr string
515+
if err := json.Unmarshal(e.Config, &configStr); err == nil {
516+
// Successfully unmarshaled as string - store the unwrapped JSON
517+
ent.Config = []byte(configStr)
518+
} else {
519+
// Couldn't unmarshal as string - use as-is
520+
ent.Config = e.Config
521+
}
512522
}
513523

514524
if e.UsagePeriodAnchor != nil && e.UsagePeriodInterval != nil {

0 commit comments

Comments
 (0)