From 8d8e35461843761cbd2a2b23efaaa8ea3b23e207 Mon Sep 17 00:00:00 2001 From: timothyF95 Date: Tue, 2 Jun 2026 19:56:23 +0100 Subject: [PATCH 1/4] Add centralise registry types and reject unknown registries --- internal/registrytype/registrytype.go | 50 +++++++++++++ internal/registrytype/registrytype_test.go | 57 +++++++++++++++ internal/settings/registry_resolution.go | 73 +++++++++++-------- internal/settings/registry_resolution_test.go | 57 ++++++++++++++- internal/tenantctx/tenantctx.go | 19 +---- internal/tenantctx/tenantctx_test.go | 62 +++++++++++++--- 6 files changed, 258 insertions(+), 60 deletions(-) create mode 100644 internal/registrytype/registrytype.go create mode 100644 internal/registrytype/registrytype_test.go diff --git a/internal/registrytype/registrytype.go b/internal/registrytype/registrytype.go new file mode 100644 index 00000000..5e793516 --- /dev/null +++ b/internal/registrytype/registrytype.go @@ -0,0 +1,50 @@ +package registrytype + +import ( + "fmt" + "strings" + + "github.com/rs/zerolog" +) + +// Type is the normalised registry type stored in context.yaml. +type Type string + +const ( + OnChain Type = "on-chain" + OffChain Type = "off-chain" + Unknown Type = "unknown" +) + +const ( + GQLOnChain = "ON_CHAIN" + GQLOffChain = "OFF_CHAIN" +) + +// FromGQL maps a GraphQL registry type to the normalised context.yaml value. +func FromGQL(gqlType string, log *zerolog.Logger) Type { + switch gqlType { + case GQLOnChain: + return OnChain + case GQLOffChain: + return OffChain + default: + log.Warn().Str("type", gqlType).Msg("unrecognised registry type from server") + return Unknown + } +} + +// Parse converts a raw type string from context.yaml to a Type. +// Unrecognised values return an error; there is no default to on-chain. +func Parse(raw string) (Type, error) { + switch { + case strings.EqualFold(raw, string(OffChain)), strings.EqualFold(raw, "off_chain"): + return OffChain, nil + case strings.EqualFold(raw, string(OnChain)), strings.EqualFold(raw, "on_chain"): + return OnChain, nil + case strings.EqualFold(raw, string(Unknown)): + return Unknown, nil + default: + return "", fmt.Errorf("unrecognised registry type %q", raw) + } +} diff --git a/internal/registrytype/registrytype_test.go b/internal/registrytype/registrytype_test.go new file mode 100644 index 00000000..2eac9ac0 --- /dev/null +++ b/internal/registrytype/registrytype_test.go @@ -0,0 +1,57 @@ +package registrytype + +import ( + "testing" + + "github.com/smartcontractkit/cre-cli/internal/testutil" + "github.com/stretchr/testify/assert" +) + +func TestFromGQL(t *testing.T) { + log := testutil.NewTestLogger() + tests := []struct { + input string + want Type + }{ + {GQLOnChain, OnChain}, + {GQLOffChain, OffChain}, + {"FUTURE_TYPE", Unknown}, + } + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + assert.Equal(t, tt.want, FromGQL(tt.input, log)) + }) + } +} + +func TestParse(t *testing.T) { + valid := []struct { + input string + want Type + }{ + {"on-chain", OnChain}, + {"off-chain", OffChain}, + {"ON-CHAIN", OnChain}, + {"OFF-CHAIN", OffChain}, + {"on_chain", OnChain}, + {"off_chain", OffChain}, + {"unknown", Unknown}, + {"UNKNOWN", Unknown}, + } + for _, tt := range valid { + t.Run(tt.input, func(t *testing.T) { + got, err := Parse(tt.input) + assert.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } + + invalid := []string{"on-chian", ""} + for _, input := range invalid { + t.Run(input, func(t *testing.T) { + _, err := Parse(input) + assert.Error(t, err) + assert.Contains(t, err.Error(), "unrecognised registry type") + }) + } +} diff --git a/internal/settings/registry_resolution.go b/internal/settings/registry_resolution.go index d5939385..267ef87e 100644 --- a/internal/settings/registry_resolution.go +++ b/internal/settings/registry_resolution.go @@ -5,15 +5,17 @@ import ( "strings" "github.com/smartcontractkit/cre-cli/internal/environments" + "github.com/smartcontractkit/cre-cli/internal/registrytype" "github.com/smartcontractkit/cre-cli/internal/tenantctx" ) // RegistryType distinguishes between on-chain and off-chain workflow registries. -type RegistryType string +type RegistryType = registrytype.Type const ( - RegistryTypeOnChain RegistryType = "on-chain" - RegistryTypeOffChain RegistryType = "off-chain" + RegistryTypeOnChain = registrytype.OnChain + RegistryTypeOffChain = registrytype.OffChain + RegistryTypeUnknown = registrytype.Unknown ) // ResolvedRegistry is the interface implemented by both OnChainRegistry and @@ -104,38 +106,47 @@ func ResolveRegistry( deploymentRegistry, availableIDs(tenantCtx.Registries)) } - if ParseRegistryType(reg.Type) == RegistryTypeOffChain { - return NewOffChainRegistry(reg.ID, EffectiveDonFamily(envSet, tenantCtx)), nil - } - - if reg.Address == nil || *reg.Address == "" { - return nil, fmt.Errorf("on-chain registry %q has no address in user context", reg.ID) - } - - if reg.ChainSelector == nil { - return nil, fmt.Errorf("on-chain registry %q has no chain_selector in user context", reg.ID) - } - chainName, err := ChainNameFromSelectorString(*reg.ChainSelector) + regType, err := ParseRegistryType(reg.Type) if err != nil { return nil, fmt.Errorf("registry %q: %w", reg.ID, err) } - return NewOnChainRegistry( - reg.ID, - *reg.Address, - chainName, - EffectiveDonFamily(envSet, tenantCtx), - envSet.WorkflowRegistryChainExplorerURL, - ), nil -} + switch regType { + case RegistryTypeOffChain: + return NewOffChainRegistry(reg.ID, EffectiveDonFamily(envSet, tenantCtx)), nil + case RegistryTypeOnChain: + if reg.Address == nil || *reg.Address == "" { + return nil, fmt.Errorf("on-chain registry %q has no address in user context", reg.ID) + } -// ParseRegistryType converts a raw type string from user context to a -// RegistryType. Unknown values default to on-chain. -func ParseRegistryType(raw string) RegistryType { - if strings.EqualFold(raw, string(RegistryTypeOffChain)) || strings.EqualFold(raw, "off_chain") { - return RegistryTypeOffChain + if reg.ChainSelector == nil { + return nil, fmt.Errorf("on-chain registry %q has no chain_selector in user context", reg.ID) + } + chainName, err := ChainNameFromSelectorString(*reg.ChainSelector) + if err != nil { + return nil, fmt.Errorf("registry %q: %w", reg.ID, err) + } + + return NewOnChainRegistry( + reg.ID, + *reg.Address, + chainName, + EffectiveDonFamily(envSet, tenantCtx), + envSet.WorkflowRegistryChainExplorerURL, + ), nil + case RegistryTypeUnknown: + return nil, fmt.Errorf( + "registry %q is not supported by this CLI version (unrecognised type from server); run `cre login` after upgrading or choose a different deployment-registry", + reg.ID, + ) + default: + return nil, fmt.Errorf("registry %q: %w", reg.ID, fmt.Errorf("unrecognised registry type %q", regType)) } - return RegistryTypeOnChain +} + +// ParseRegistryType converts a raw type string from user context to a RegistryType. +func ParseRegistryType(raw string) (RegistryType, error) { + return registrytype.Parse(raw) } func defaultFromEnvironmentSet(envSet *environments.EnvironmentSet, tenantCtx *tenantctx.EnvironmentContext) *OnChainRegistry { @@ -160,6 +171,10 @@ func findRegistry(registries []*tenantctx.Registry, id string) *tenantctx.Regist func availableIDs(registries []*tenantctx.Registry) string { ids := make([]string, 0, len(registries)) for _, r := range registries { + regType, err := ParseRegistryType(r.Type) + if err != nil || regType == RegistryTypeUnknown { + continue + } ids = append(ids, r.ID) } return strings.Join(ids, ", ") diff --git a/internal/settings/registry_resolution_test.go b/internal/settings/registry_resolution_test.go index 15010598..49e642fc 100644 --- a/internal/settings/registry_resolution_test.go +++ b/internal/settings/registry_resolution_test.go @@ -164,8 +164,45 @@ func TestResolveRegistry_OnChainMissingChainSelector(t *testing.T) { assert.Contains(t, err.Error(), "has no chain_selector") } +func TestResolveRegistry_UnknownType(t *testing.T) { + ctx := &tenantctx.EnvironmentContext{ + DefaultDonFamily: "zone-a", + Registries: []*tenantctx.Registry{ + { + ID: "future-registry", + Type: "unknown", + }, + }, + } + _, err := ResolveRegistry("future-registry", ctx, stagingEnvSet()) + assert.Error(t, err) + assert.Contains(t, err.Error(), "future-registry") + assert.Contains(t, err.Error(), "not supported by this CLI version") +} + +func TestResolveRegistry_UnknownExcludedFromAvailable(t *testing.T) { + ctx := &tenantctx.EnvironmentContext{ + DefaultDonFamily: "zone-a", + Registries: []*tenantctx.Registry{ + { + ID: "private", + Type: "off-chain", + }, + { + ID: "future-registry", + Type: "unknown", + }, + }, + } + _, err := ResolveRegistry("does-not-exist", ctx, stagingEnvSet()) + assert.Error(t, err) + assert.Contains(t, err.Error(), "not found in user context") + assert.Contains(t, err.Error(), "private") + assert.NotContains(t, err.Error(), "future-registry") +} + func TestParseRegistryType(t *testing.T) { - tests := []struct { + valid := []struct { input string want RegistryType }{ @@ -173,12 +210,24 @@ func TestParseRegistryType(t *testing.T) { {"off-chain", RegistryTypeOffChain}, {"ON-CHAIN", RegistryTypeOnChain}, {"OFF-CHAIN", RegistryTypeOffChain}, + {"on_chain", RegistryTypeOnChain}, {"off_chain", RegistryTypeOffChain}, - {"unknown", RegistryTypeOnChain}, + {"unknown", RegistryTypeUnknown}, } - for _, tt := range tests { + for _, tt := range valid { t.Run(tt.input, func(t *testing.T) { - assert.Equal(t, tt.want, ParseRegistryType(tt.input)) + got, err := ParseRegistryType(tt.input) + assert.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } + + invalid := []string{"on-chian", ""} + for _, input := range invalid { + t.Run(input, func(t *testing.T) { + _, err := ParseRegistryType(input) + assert.Error(t, err) + assert.Contains(t, err.Error(), "unrecognised registry type") }) } } diff --git a/internal/tenantctx/tenantctx.go b/internal/tenantctx/tenantctx.go index ec3f2064..06c50d79 100644 --- a/internal/tenantctx/tenantctx.go +++ b/internal/tenantctx/tenantctx.go @@ -17,6 +17,7 @@ import ( "github.com/smartcontractkit/cre-cli/internal/client/graphqlclient" "github.com/smartcontractkit/cre-cli/internal/credentials" "github.com/smartcontractkit/cre-cli/internal/environments" + "github.com/smartcontractkit/cre-cli/internal/registrytype" ) // ContextFile is the filename for the local registry manifest. @@ -106,11 +107,11 @@ func FetchAndWriteContext(ctx context.Context, gqlClient *graphqlclient.Client, registries := make([]*Registry, 0, len(tc.Registries)) for _, r := range tc.Registries { - regType := mapRegistryType(r.Type, log) + regType := registrytype.FromGQL(r.Type, log) id := r.ID label := r.Label - if regType == "on-chain" { + if regType == registrytype.OnChain { id = "onchain:" + r.ID if r.Address != nil { label = fmt.Sprintf("%s (%s)", r.ID, abbreviateAddress(*r.Address)) @@ -120,7 +121,7 @@ func FetchAndWriteContext(ctx context.Context, gqlClient *graphqlclient.Client, registries = append(registries, &Registry{ ID: id, Label: label, - Type: regType, + Type: string(regType), ChainSelector: r.ChainSelector, Address: r.Address, SecretsAuthFlows: mapSecretsAuthFlows(r.SecretsAuthFlows, log), @@ -157,18 +158,6 @@ func FetchAndWriteContext(ctx context.Context, gqlClient *graphqlclient.Client, return writeContextFile(contextMap, log) } -func mapRegistryType(gqlType string, log *zerolog.Logger) string { - switch gqlType { - case "ON_CHAIN": - return "on-chain" - case "OFF_CHAIN": - return "off-chain" - default: - log.Warn().Str("type", gqlType).Msg("unknown registry type, skipping") - return "unknown" - } -} - func mapSecretsAuthFlows(gqlFlows []string, log *zerolog.Logger) []string { flows := make([]string, 0, len(gqlFlows)) for _, f := range gqlFlows { diff --git a/internal/tenantctx/tenantctx_test.go b/internal/tenantctx/tenantctx_test.go index 683c2f20..dacddf24 100644 --- a/internal/tenantctx/tenantctx_test.go +++ b/internal/tenantctx/tenantctx_test.go @@ -423,19 +423,57 @@ func TestEnsureContext_DefaultsToProduction(t *testing.T) { // --- Helper functions --- -func TestMapRegistryType(t *testing.T) { - tests := []struct { - input string - want string - }{ - {"ON_CHAIN", "on-chain"}, - {"OFF_CHAIN", "off-chain"}, - {"UNKNOWN", "unknown"}, +func TestFetchAndWriteContext_PersistsUnknownRegistryType(t *testing.T) { + response := map[string]any{ + "data": map[string]any{ + "getTenantConfig": map[string]any{ + "tenantId": "42", + "defaultDonFamily": "zone-a", + "vaultGatewayUrl": "https://gateway.example.com/", + "registries": []any{ + map[string]any{ + "id": "private", + "label": "Private (Chainlink-hosted)", + "type": "OFF_CHAIN", + "secretsAuthFlows": []any{"BROWSER"}, + }, + map[string]any{ + "id": "future-registry", + "label": "Future Registry", + "type": "FUTURE_TYPE", + "secretsAuthFlows": []any{}, + }, + }, + "forwarders": []any{}, + }, + }, } - for _, tt := range tests { - if got := mapRegistryType(tt.input, testutil.NewTestLogger()); got != tt.want { - t.Errorf("mapRegistryType(%q) = %q, want %q", tt.input, got, tt.want) - } + srv := newMockGQLServer(t, response) + defer srv.Close() + + t.Setenv("HOME", t.TempDir()) + log := testutil.NewTestLogger() + client := newGQLClient(t, srv.URL) + + if err := FetchAndWriteContext(context.Background(), client, "STAGING", log); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + envCtx, err := LoadContext("STAGING") + if err != nil { + t.Fatalf("failed to load written context: %v", err) + } + if len(envCtx.Registries) != 2 { + t.Fatalf("expected 2 registries (including unknown), got %d", len(envCtx.Registries)) + } + if envCtx.Registries[0].ID != "private" { + t.Errorf("first registry ID = %q, want %q", envCtx.Registries[0].ID, "private") + } + if envCtx.Registries[1].ID != "future-registry" { + t.Errorf("second registry ID = %q, want %q", envCtx.Registries[1].ID, "future-registry") + } + if envCtx.Registries[1].Type != "unknown" { + t.Errorf("unknown registry type = %q, want %q", envCtx.Registries[1].Type, "unknown") } } From 1327f8854a1bb753002f7b1494441e9a7a43c0b6 Mon Sep 17 00:00:00 2001 From: timothyF95 Date: Tue, 2 Jun 2026 20:08:20 +0100 Subject: [PATCH 2/4] Lint --- internal/registrytype/registrytype_test.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/internal/registrytype/registrytype_test.go b/internal/registrytype/registrytype_test.go index 2eac9ac0..d4b84e09 100644 --- a/internal/registrytype/registrytype_test.go +++ b/internal/registrytype/registrytype_test.go @@ -3,8 +3,9 @@ package registrytype import ( "testing" - "github.com/smartcontractkit/cre-cli/internal/testutil" "github.com/stretchr/testify/assert" + + "github.com/smartcontractkit/cre-cli/internal/testutil" ) func TestFromGQL(t *testing.T) { From 44c33bc5c971c628444ef75c890bd18e49eb1f06 Mon Sep 17 00:00:00 2001 From: timothyF95 Date: Wed, 3 Jun 2026 22:38:00 +0100 Subject: [PATCH 3/4] Tighten reg type parse --- internal/registrytype/registrytype.go | 13 ++++--------- internal/registrytype/registrytype_test.go | 7 +------ internal/settings/registry_resolution_test.go | 6 +----- 3 files changed, 6 insertions(+), 20 deletions(-) diff --git a/internal/registrytype/registrytype.go b/internal/registrytype/registrytype.go index 5e793516..e606af60 100644 --- a/internal/registrytype/registrytype.go +++ b/internal/registrytype/registrytype.go @@ -2,7 +2,6 @@ package registrytype import ( "fmt" - "strings" "github.com/rs/zerolog" ) @@ -35,15 +34,11 @@ func FromGQL(gqlType string, log *zerolog.Logger) Type { } // Parse converts a raw type string from context.yaml to a Type. -// Unrecognised values return an error; there is no default to on-chain. +// Only the canonical values written by FromGQL are accepted; anything else errors. func Parse(raw string) (Type, error) { - switch { - case strings.EqualFold(raw, string(OffChain)), strings.EqualFold(raw, "off_chain"): - return OffChain, nil - case strings.EqualFold(raw, string(OnChain)), strings.EqualFold(raw, "on_chain"): - return OnChain, nil - case strings.EqualFold(raw, string(Unknown)): - return Unknown, nil + switch Type(raw) { + case OnChain, OffChain, Unknown: + return Type(raw), nil default: return "", fmt.Errorf("unrecognised registry type %q", raw) } diff --git a/internal/registrytype/registrytype_test.go b/internal/registrytype/registrytype_test.go index d4b84e09..ea12adcb 100644 --- a/internal/registrytype/registrytype_test.go +++ b/internal/registrytype/registrytype_test.go @@ -32,12 +32,7 @@ func TestParse(t *testing.T) { }{ {"on-chain", OnChain}, {"off-chain", OffChain}, - {"ON-CHAIN", OnChain}, - {"OFF-CHAIN", OffChain}, - {"on_chain", OnChain}, - {"off_chain", OffChain}, {"unknown", Unknown}, - {"UNKNOWN", Unknown}, } for _, tt := range valid { t.Run(tt.input, func(t *testing.T) { @@ -47,7 +42,7 @@ func TestParse(t *testing.T) { }) } - invalid := []string{"on-chian", ""} + invalid := []string{"ON-CHAIN", "OFF-CHAIN", "on_chain", "off_chain", "ON_CHAIN", "OFF_CHAIN", "on-chian", ""} for _, input := range invalid { t.Run(input, func(t *testing.T) { _, err := Parse(input) diff --git a/internal/settings/registry_resolution_test.go b/internal/settings/registry_resolution_test.go index 49e642fc..fb3e846b 100644 --- a/internal/settings/registry_resolution_test.go +++ b/internal/settings/registry_resolution_test.go @@ -208,10 +208,6 @@ func TestParseRegistryType(t *testing.T) { }{ {"on-chain", RegistryTypeOnChain}, {"off-chain", RegistryTypeOffChain}, - {"ON-CHAIN", RegistryTypeOnChain}, - {"OFF-CHAIN", RegistryTypeOffChain}, - {"on_chain", RegistryTypeOnChain}, - {"off_chain", RegistryTypeOffChain}, {"unknown", RegistryTypeUnknown}, } for _, tt := range valid { @@ -222,7 +218,7 @@ func TestParseRegistryType(t *testing.T) { }) } - invalid := []string{"on-chian", ""} + invalid := []string{"ON-CHAIN", "on_chain", "on-chian", ""} for _, input := range invalid { t.Run(input, func(t *testing.T) { _, err := ParseRegistryType(input) From bc365c922e45f9754587f680344e16006ab8aa53 Mon Sep 17 00:00:00 2001 From: timothyF95 Date: Thu, 4 Jun 2026 00:06:48 +0100 Subject: [PATCH 4/4] Missed test --- internal/workflowrender/registry.go | 5 +++-- internal/workflowrender/registry_test.go | 9 +++++++++ test/multi_command_flows/workflow_private_registry.go | 2 +- 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/internal/workflowrender/registry.go b/internal/workflowrender/registry.go index 6a9f9e19..dd60719c 100644 --- a/internal/workflowrender/registry.go +++ b/internal/workflowrender/registry.go @@ -12,6 +12,7 @@ import ( "strings" "github.com/smartcontractkit/cre-cli/internal/client/workflowdataclient" + "github.com/smartcontractkit/cre-cli/internal/registrytype" "github.com/smartcontractkit/cre-cli/internal/tenantctx" ) @@ -137,8 +138,8 @@ func registryTypeOffChain(reg *tenantctx.Registry) bool { if reg == nil { return false } - t := strings.TrimSpace(strings.ReplaceAll(strings.ToLower(reg.Type), "_", "-")) - return t == "off-chain" || strings.EqualFold(strings.TrimSpace(reg.Type), "OFF_CHAIN") + regType, err := registrytype.Parse(reg.Type) + return err == nil && regType == registrytype.OffChain } func hasContractAddress(reg *tenantctx.Registry) bool { diff --git a/internal/workflowrender/registry_test.go b/internal/workflowrender/registry_test.go index 4bad568c..e2ab88fc 100644 --- a/internal/workflowrender/registry_test.go +++ b/internal/workflowrender/registry_test.go @@ -10,6 +10,15 @@ import ( func sp(s string) *string { return &s } +func TestRegistryTypeOffChain(t *testing.T) { + assert.True(t, registryTypeOffChain(&tenantctx.Registry{Type: "off-chain"})) + assert.False(t, registryTypeOffChain(&tenantctx.Registry{Type: "on-chain"})) + assert.False(t, registryTypeOffChain(&tenantctx.Registry{Type: "unknown"})) + assert.False(t, registryTypeOffChain(&tenantctx.Registry{Type: "OFF_CHAIN"})) + assert.False(t, registryTypeOffChain(&tenantctx.Registry{Type: ""})) + assert.False(t, registryTypeOffChain(nil)) +} + func TestWorkflowSourceMatchesRegistry_DirectIDMatch(t *testing.T) { reg := &tenantctx.Registry{ID: "private", Type: "off-chain"} all := []*tenantctx.Registry{reg} diff --git a/test/multi_command_flows/workflow_private_registry.go b/test/multi_command_flows/workflow_private_registry.go index b2fb7a47..74f15c57 100644 --- a/test/multi_command_flows/workflow_private_registry.go +++ b/test/multi_command_flows/workflow_private_registry.go @@ -871,7 +871,7 @@ func RunPrivateRegistryAuthAndSettingsFinalize(t *testing.T, envPath, blankWorkf tenantCtx := &tenantctx.EnvironmentContext{ DefaultDonFamily: "test-don", Registries: []*tenantctx.Registry{ - {ID: "reg-test", Type: "OFF_CHAIN"}, + {ID: "reg-test", Type: string(settings.RegistryTypeOffChain)}, }, } resolved, err := settings.ResolveRegistry("reg-test", tenantCtx, envSet)