diff --git a/ccsapi/handlers_test.go b/ccsapi/handlers_test.go index 3ea1c65..aaa8b1c 100644 --- a/ccsapi/handlers_test.go +++ b/ccsapi/handlers_test.go @@ -363,6 +363,41 @@ func TestGetService_Success(t *testing.T) { assert.Equal(t, "default", resp.DefaultOidcScope) } +func TestGetService_TrustedIssuersListsSerializeAsPlainURLs(t *testing.T) { + repo := &mockServiceRepository{ + getServiceFn: func(_ context.Context, id string) (config.ConfiguredService, error) { + return config.ConfiguredService{ + Id: id, + DefaultOidcScope: "default", + ServiceScopes: map[string]config.ScopeEntry{ + "default": { + Credentials: []config.Credential{{ + Type: "VerifiableCredential", + TrustedIssuersLists: config.TrustedIssuersLists{ + {Type: "ebsi-v5", Url: "https://til-v5.example.com"}, + {Type: "ebsi", Url: "https://til-v3.example.com"}, + }, + }}, + }, + }, + }, nil + }, + } + + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/service/my-service", nil) + setupRouter(repo).ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var raw map[string]interface{} + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &raw)) + scopes := raw["oidcScopes"].(map[string]interface{}) + credentials := scopes["default"].(map[string]interface{})["credentials"].([]interface{}) + trustedIssuersLists := credentials[0].(map[string]interface{})["trustedIssuersLists"] + assert.Equal(t, []interface{}{"https://til-v5.example.com", "https://til-v3.example.com"}, trustedIssuersLists) +} + func TestGetService_NotFound(t *testing.T) { repo := &mockServiceRepository{ getServiceFn: func(_ context.Context, _ string) (config.ConfiguredService, error) { diff --git a/config/configClient.go b/config/configClient.go index 9f0a0c3..f6cc768 100644 --- a/config/configClient.go +++ b/config/configClient.go @@ -327,6 +327,22 @@ func (t *TrustedIssuersLists) UnmarshalJSON(data []byte) error { return nil } +// MarshalJSON always serializes as a plain array of URL strings, dropping the +// per-entry Type. This is the API-facing representation: config.Credential +// (and thus TrustedIssuersLists) is embedded directly in ccsapi request/response +// bodies, and callers only need the endpoint URLs. Type is an internal-only +// concern (dispatches between ebsi/ebsi-v5/gaia-x lookups, see +// trustedparticipant.go) that is never surfaced over the API and is untouched +// on the database persistence path, which round-trips through the separate +// CredentialDB model instead of this type's JSON methods. +func (t TrustedIssuersLists) MarshalJSON() ([]byte, error) { + urls := make([]string, len(t)) + for i, entry := range t { + urls[i] = entry.Url + } + return json.Marshal(urls) +} + // trustedIssuersListsType is the reflect.Type for TrustedIssuersLists, cached // to avoid repeated reflect calls in the decode hook. var trustedIssuersListsType = reflect.TypeOf(TrustedIssuersLists{}) diff --git a/config/configClient_test.go b/config/configClient_test.go index 687213a..936c901 100644 --- a/config/configClient_test.go +++ b/config/configClient_test.go @@ -368,6 +368,56 @@ func TestTrustedIssuersLists_UnmarshalJSON(t *testing.T) { } } +func TestTrustedIssuersLists_MarshalJSON(t *testing.T) { + type testCase struct { + name string + input TrustedIssuersLists + expected string + } + + tests := []testCase{ + { + name: "mixed types collapse to plain URLs", + input: TrustedIssuersLists{ + {Type: "ebsi-v5", Url: "https://v5.example.com"}, + {Type: "ebsi", Url: "https://v3.example.com"}, + }, + expected: `["https://v5.example.com","https://v3.example.com"]`, + }, + { + name: "empty list", + input: TrustedIssuersLists{}, + expected: `[]`, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, err := json.Marshal(tc.input) + assert.NoError(t, err) + assert.JSONEq(t, tc.expected, string(got)) + }) + } +} + +// TestTrustedIssuersLists_RoundTripAsymmetry documents that the API-facing +// serialization is intentionally asymmetric: reading (UnmarshalJSON) still +// accepts the structured {type,url} format so non-default types can be +// configured, but writing (MarshalJSON) always collapses to plain URLs. +// Keeping Unmarshal permissive avoids a breaking change for existing callers +// that send the structured format. +func TestTrustedIssuersLists_RoundTripAsymmetry(t *testing.T) { + input := `[{"type":"ebsi-v5","url":"https://v5.example.com"}]` + + var parsed TrustedIssuersLists + assert.NoError(t, json.Unmarshal([]byte(input), &parsed)) + assert.Equal(t, "ebsi-v5", parsed[0].Type) + + out, err := json.Marshal(parsed) + assert.NoError(t, err) + assert.JSONEq(t, `["https://v5.example.com"]`, string(out)) +} + func TestTrustedIssuersListsDecodeHook(t *testing.T) { hook := TrustedIssuersListsDecodeHook()