diff --git a/cmd/root.go b/cmd/root.go index 05dce7bb..fcdd5c84 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -223,7 +223,7 @@ func newRootCommand() *cobra.Command { spinner.Update("Loading user context...") } if err := runtimeContext.AttachTenantContext(cmd.Context()); err != nil { - runtimeContext.Logger.Warn().Err(err).Msg("failed to load user context — context.yaml not available") + runtimeContext.Logger.Warn().Err(err).Msg("failed to load user context") } // Check if organization is ungated for commands that require it @@ -470,6 +470,7 @@ func isLoadSettings(cmd *cobra.Command) bool { "cre workflow limits": {}, "cre workflow limits export": {}, "cre workflow build": {}, + "cre workflow list": {}, "cre account": {}, "cre secrets": {}, "cre templates": {}, diff --git a/cmd/workflow/list/list.go b/cmd/workflow/list/list.go new file mode 100644 index 00000000..12e732f1 --- /dev/null +++ b/cmd/workflow/list/list.go @@ -0,0 +1,107 @@ +package list + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + + "github.com/smartcontractkit/cre-cli/internal/client/graphqlclient" + "github.com/smartcontractkit/cre-cli/internal/client/workflowdataclient" + "github.com/smartcontractkit/cre-cli/internal/credentials" + "github.com/smartcontractkit/cre-cli/internal/runtime" + "github.com/smartcontractkit/cre-cli/internal/tenantctx" + "github.com/smartcontractkit/cre-cli/internal/ui" +) + +// Workflow is a type alias so that print.go and registry.go in this package +// can use the name without importing workflowdataclient directly. +type Workflow = workflowdataclient.Workflow + +// Handler loads workflows via the WorkflowDataClient and prints them. +type Handler struct { + credentials *credentials.Credentials + tenantCtx *tenantctx.EnvironmentContext + wdc *workflowdataclient.Client +} + +// NewHandler builds a Handler with a real WorkflowDataClient. +func NewHandler(ctx *runtime.Context) *Handler { + gql := graphqlclient.New(ctx.Credentials, ctx.EnvironmentSet, ctx.Logger) + wdc := workflowdataclient.New(gql, ctx.Logger) + return &Handler{ + credentials: ctx.Credentials, + tenantCtx: ctx.TenantContext, + wdc: wdc, + } +} + +// NewHandlerWithClient builds a Handler with a pre-built WorkflowDataClient (for testing). +func NewHandlerWithClient(ctx *runtime.Context, wdc *workflowdataclient.Client) *Handler { + return &Handler{ + credentials: ctx.Credentials, + tenantCtx: ctx.TenantContext, + wdc: wdc, + } +} + +// Execute lists workflows, optionally filtering by registry ID from user context. +// Deleted workflows are omitted unless includeDeleted is true. +func (h *Handler) Execute(ctx context.Context, registryFilter string, includeDeleted bool) error { + if h.tenantCtx == nil { + return fmt.Errorf("user context not available — run `cre login` and retry") + } + + if h.credentials == nil { + return fmt.Errorf("credentials not available — run `cre login` and retry") + } + + if registryFilter != "" { + if findRegistry(h.tenantCtx.Registries, registryFilter) == nil { + return fmt.Errorf("registry %q not found in user context; available: [%s]", + registryFilter, availableRegistryIDs(h.tenantCtx.Registries)) + } + } + + spinner := ui.NewSpinner() + spinner.Start("Listing workflows...") + rows, err := h.wdc.ListAll(ctx, workflowdataclient.DefaultPageSize) + spinner.Stop() + if err != nil { + return err + } + + if registryFilter != "" { + reg := findRegistry(h.tenantCtx.Registries, registryFilter) + rows = filterRowsByRegistry(rows, reg, h.tenantCtx.Registries) + } + + afterRegistryFilter := len(rows) + if !includeDeleted { + rows = omitDeleted(rows) + } + + printWorkflowTable(rows, h.tenantCtx.Registries, afterRegistryFilter, includeDeleted) + return nil +} + +// New returns the cobra command. +func New(runtimeContext *runtime.Context) *cobra.Command { + var registryID string + var includeDeleted bool + + cmd := &cobra.Command{ + Use: "list", + Short: "Lists workflows deployed for your organization", + Long: `Lists workflows across registries in your organization. Requires authentication and user context. Deleted workflows are hidden by default.`, + Example: "cre workflow list\n cre workflow list --registry private\n cre workflow list --include-deleted", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + return NewHandler(runtimeContext).Execute(cmd.Context(), registryID, includeDeleted) + }, + } + + cmd.Flags().StringVar(®istryID, "registry", "", "Filter by registry ID from user context") + cmd.Flags().BoolVar(&includeDeleted, "include-deleted", false, "Include workflows in DELETED status") + return cmd +} diff --git a/cmd/workflow/list/list_test.go b/cmd/workflow/list/list_test.go new file mode 100644 index 00000000..016093c7 --- /dev/null +++ b/cmd/workflow/list/list_test.go @@ -0,0 +1,637 @@ +package list_test + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "strings" + "sync/atomic" + "testing" + + "github.com/rs/zerolog" + + cmdlist "github.com/smartcontractkit/cre-cli/cmd/workflow/list" + "github.com/smartcontractkit/cre-cli/internal/client/graphqlclient" + "github.com/smartcontractkit/cre-cli/internal/client/workflowdataclient" + "github.com/smartcontractkit/cre-cli/internal/credentials" + "github.com/smartcontractkit/cre-cli/internal/environments" + "github.com/smartcontractkit/cre-cli/internal/runtime" + "github.com/smartcontractkit/cre-cli/internal/tenantctx" +) + +func strPtr(s string) *string { return &s } + +// newWorkflowServer starts an httptest.Server that responds to ListWorkflows +// with the provided pages of workflow data (each call advances through pages). +func newWorkflowServer(t *testing.T, pages [][]map[string]string, totalCount int) *httptest.Server { + t.Helper() + var call atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + idx := int(call.Add(1)) - 1 + w.Header().Set("Content-Type", "application/json") + var data []map[string]string + if idx < len(pages) { + data = pages[idx] + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "data": map[string]any{ + "workflows": map[string]any{ + "count": totalCount, + "data": data, + }, + }, + }) + })) + return srv +} + +// newHandlerWithServer builds a Handler wired to an httptest.Server. +func newHandlerWithServer(t *testing.T, rtCtx *runtime.Context, srv *httptest.Server) *cmdlist.Handler { + t.Helper() + logger := zerolog.Nop() + creds := &credentials.Credentials{AuthType: credentials.AuthTypeApiKey, APIKey: "test-key"} + envSet := &environments.EnvironmentSet{GraphQLURL: srv.URL} + gql := graphqlclient.New(creds, envSet, &logger) + wdc := workflowdataclient.New(gql, &logger) + return cmdlist.NewHandlerWithClient(rtCtx, wdc) +} + +// threeWorkflowPage returns the two-active-one-deleted page used across several tests. +func threeWorkflowPage() []map[string]string { + return []map[string]string{ + { + "name": "alpha", + "workflowId": "1010101010101010101010101010101010101010101010101010101010101010", + "ownerAddress": "2020202020202020202020202020202020202020", + "status": "ACTIVE", + "workflowSource": "private", + }, + { + "name": "beta", + "workflowId": "3030303030303030303030303030303030303030303030303030303030303030", + "ownerAddress": "4040404040404040404040404040404040404040", + "status": "PAUSED", + "workflowSource": "contract:999888777666555444333:0xabababababababababababababababababababab", + }, + { + "name": "gone-deleted", + "workflowId": "5050505050505050505050505050505050505050505050505050505050505050", + "ownerAddress": "6060606060606060606060606060606060606060", + "status": "DELETED", + "workflowSource": "private", + }, + } +} + +func captureStdout(t *testing.T, fn func()) string { + t.Helper() + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + old := os.Stdout + os.Stdout = w + fn() + w.Close() + os.Stdout = old + var buf strings.Builder + _, _ = io.Copy(&buf, r) + return buf.String() +} + +func captureStderr(t *testing.T, fn func()) string { + t.Helper() + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + old := os.Stderr + os.Stderr = w + fn() + w.Close() + os.Stderr = old + var buf strings.Builder + _, _ = io.Copy(&buf, r) + return buf.String() +} + +func TestNew_NoTenantContext(t *testing.T) { + logger := zerolog.New(io.Discard) + rtCtx := &runtime.Context{ + Logger: &logger, + Credentials: &credentials.Credentials{}, + EnvironmentSet: &environments.EnvironmentSet{EnvName: "STAGING"}, + TenantContext: nil, + } + + cmd := cmdlist.New(rtCtx) + cmd.SetArgs([]string{}) + err := cmd.Execute() + if err == nil { + t.Fatal("expected error when TenantContext is nil") + } + if !strings.Contains(err.Error(), "user context not available") { + t.Errorf("unexpected error: %v", err) + } +} + +func TestNew_NoCredentials(t *testing.T) { + logger := zerolog.New(io.Discard) + rtCtx := &runtime.Context{ + Logger: &logger, + Credentials: nil, + EnvironmentSet: &environments.EnvironmentSet{EnvName: "STAGING"}, + TenantContext: &tenantctx.EnvironmentContext{Registries: []*tenantctx.Registry{{ID: "private"}}}, + } + + cmd := cmdlist.New(rtCtx) + cmd.SetArgs([]string{}) + err := cmd.Execute() + if err == nil { + t.Fatal("expected error when Credentials is nil") + } + if !strings.Contains(err.Error(), "credentials not available") { + t.Errorf("unexpected error: %v", err) + } +} + +func TestNew_UnknownRegistry(t *testing.T) { + logger := zerolog.New(io.Discard) + rtCtx := &runtime.Context{ + Logger: &logger, + Credentials: &credentials.Credentials{}, + EnvironmentSet: &environments.EnvironmentSet{EnvName: "STAGING"}, + TenantContext: &tenantctx.EnvironmentContext{ + Registries: []*tenantctx.Registry{{ID: "private", Label: "Private"}}, + }, + } + + cmd := cmdlist.New(rtCtx) + cmd.SetArgs([]string{"--registry", "nope"}) + err := cmd.Execute() + if err == nil { + t.Fatal("expected error for unknown registry") + } + if !strings.Contains(err.Error(), "not found in user context") { + t.Errorf("unexpected error: %v", err) + } +} + +func TestNew_RejectsArgs(t *testing.T) { + logger := zerolog.New(io.Discard) + rtCtx := &runtime.Context{ + Logger: &logger, + Credentials: &credentials.Credentials{}, + EnvironmentSet: &environments.EnvironmentSet{}, + TenantContext: &tenantctx.EnvironmentContext{}, + } + + cmd := cmdlist.New(rtCtx) + cmd.SetArgs([]string{"extra"}) + cmd.SilenceUsage = true + cmd.SilenceErrors = true + + if err := cmd.Execute(); err == nil { + t.Fatal("expected error when extra args provided") + } +} + +func TestExecute_WithMock_PrintsWorkflowBlocks(t *testing.T) { + logger := zerolog.New(io.Discard) + rtCtx := &runtime.Context{ + Logger: &logger, + Credentials: &credentials.Credentials{}, + EnvironmentSet: &environments.EnvironmentSet{EnvName: "STAGING"}, + TenantContext: &tenantctx.EnvironmentContext{ + Registries: []*tenantctx.Registry{ + {ID: "private", Label: "Private hosted"}, + }, + }, + } + + srv := newWorkflowServer(t, [][]map[string]string{threeWorkflowPage()}, 3) + defer srv.Close() + h := newHandlerWithServer(t, rtCtx, srv) + + out := captureStdout(t, func() { + if err := h.Execute(context.Background(), "", false); err != nil { + t.Fatal(err) + } + }) + + if strings.Contains(out, "gone-deleted") { + t.Errorf("deleted workflow should be omitted by default; output:\n%s", out) + } + for _, want := range []string{ + "Workflows", + "1. alpha", + "Workflow ID:", + "1010101010101010101010101010101010101010101010101010101010101010", + "Owner:", + "2020202020202020202020202020202020202020", + "Status:", + "ACTIVE", + "Registry:", + "private", + "2. beta", + "Workflow ID:", + "3030303030303030303030303030303030303030303030303030303030303030", + "Owner:", + "4040404040404040404040404040404040404040", + "PAUSED", + "contract:999888777666555444333:0xabababababababababababababababababababab", + } { + if !strings.Contains(out, want) { + t.Errorf("output missing %q:\n%s", want, out) + } + } +} + +func TestExecute_WithMock_IncludeDeleted(t *testing.T) { + logger := zerolog.New(io.Discard) + rtCtx := &runtime.Context{ + Logger: &logger, + Credentials: &credentials.Credentials{}, + EnvironmentSet: &environments.EnvironmentSet{EnvName: "STAGING"}, + TenantContext: &tenantctx.EnvironmentContext{ + Registries: []*tenantctx.Registry{{ID: "private", Label: "Private hosted"}}, + }, + } + + srv := newWorkflowServer(t, [][]map[string]string{threeWorkflowPage()}, 3) + defer srv.Close() + h := newHandlerWithServer(t, rtCtx, srv) + + out := captureStdout(t, func() { + if err := h.Execute(context.Background(), "", true); err != nil { + t.Fatal(err) + } + }) + + if !strings.Contains(out, "gone-deleted") || !strings.Contains(out, "DELETED") { + t.Errorf("expected deleted workflow with --include-deleted; output:\n%s", out) + } +} + +func TestExecute_AllDeletedShowsHint(t *testing.T) { + logger := zerolog.New(io.Discard) + rtCtx := &runtime.Context{ + Logger: &logger, + Credentials: &credentials.Credentials{}, + EnvironmentSet: &environments.EnvironmentSet{EnvName: "STAGING"}, + TenantContext: &tenantctx.EnvironmentContext{Registries: []*tenantctx.Registry{}}, + } + + deletedPage := []map[string]string{ + { + "name": "gone-deleted-only", + "workflowId": "7070707070707070707070707070707070707070707070707070707070707070", + "ownerAddress": "8080808080808080808080808080808080808080", + "status": "DELETED", + "workflowSource": "private", + }, + } + srv := newWorkflowServer(t, [][]map[string]string{deletedPage}, 1) + defer srv.Close() + h := newHandlerWithServer(t, rtCtx, srv) + + var errOut string + captureStdout(t, func() { + errOut = captureStderr(t, func() { + if err := h.Execute(context.Background(), "", false); err != nil { + t.Fatal(err) + } + }) + }) + + if !strings.Contains(errOut, "excluding deleted") || !strings.Contains(errOut, "--include-deleted") { + t.Errorf("expected hint on stderr when all workflows are deleted; stderr:\n%s", errOut) + } +} + +func TestExecute_WithMock_RegistryFilter(t *testing.T) { + logger := zerolog.New(io.Discard) + rtCtx := &runtime.Context{ + Logger: &logger, + Credentials: &credentials.Credentials{}, + EnvironmentSet: &environments.EnvironmentSet{EnvName: "STAGING"}, + TenantContext: &tenantctx.EnvironmentContext{ + Registries: []*tenantctx.Registry{{ID: "private", Label: "Private hosted"}}, + }, + } + + srv := newWorkflowServer(t, [][]map[string]string{threeWorkflowPage()}, 3) + defer srv.Close() + h := newHandlerWithServer(t, rtCtx, srv) + + out := captureStdout(t, func() { + if err := h.Execute(context.Background(), "private", false); err != nil { + t.Fatal(err) + } + }) + + if !strings.Contains(out, "alpha") || strings.Contains(out, "beta") { + t.Errorf("expected only private registry row; output:\n%s", out) + } +} + +func mixedRegistriesPage() []map[string]string { + return []map[string]string{ + { + "name": "onchain-wf", + "workflowId": "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1", + "ownerAddress": "b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2", + "status": "ACTIVE", + "workflowSource": "contract:12345678901234567890:0xcafebabe00000000000000000000000000feed", + }, + { + "name": "grpc-wf", + "workflowId": "c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3", + "ownerAddress": "d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4", + "status": "ACTIVE", + "workflowSource": "grpc:mock-private-registry:v1", + }, + } +} + +func mixedRegistriesContext() *tenantctx.EnvironmentContext { + chainSel := "12345678901234567890" + addr := "0xcafebabe00000000000000000000000000feed" + return &tenantctx.EnvironmentContext{ + Registries: []*tenantctx.Registry{ + { + ID: "onchain:mock-testnet", + Label: "mock-testnet (short)", + ChainSelector: strPtr(chainSel), + Address: strPtr(addr), + }, + {ID: "private", Label: "Private", Type: "off-chain"}, + }, + } +} + +func TestExecute_RegistryFilter_MatchesContractSource(t *testing.T) { + logger := zerolog.New(io.Discard) + rtCtx := &runtime.Context{ + Logger: &logger, + Credentials: &credentials.Credentials{}, + EnvironmentSet: &environments.EnvironmentSet{EnvName: "STAGING"}, + TenantContext: mixedRegistriesContext(), + } + + srv := newWorkflowServer(t, [][]map[string]string{mixedRegistriesPage()}, 2) + defer srv.Close() + h := newHandlerWithServer(t, rtCtx, srv) + + out := captureStdout(t, func() { + if err := h.Execute(context.Background(), "onchain:mock-testnet", false); err != nil { + t.Fatal(err) + } + }) + + if !strings.Contains(out, "onchain-wf") || strings.Contains(out, "grpc-wf") { + t.Errorf("expected only contract-registry workflow; output:\n%s", out) + } +} + +func TestExecute_RegistryFilter_MatchesGrpcSource(t *testing.T) { + logger := zerolog.New(io.Discard) + rtCtx := &runtime.Context{ + Logger: &logger, + Credentials: &credentials.Credentials{}, + EnvironmentSet: &environments.EnvironmentSet{EnvName: "STAGING"}, + TenantContext: mixedRegistriesContext(), + } + + srv := newWorkflowServer(t, [][]map[string]string{mixedRegistriesPage()}, 2) + defer srv.Close() + h := newHandlerWithServer(t, rtCtx, srv) + + out := captureStdout(t, func() { + if err := h.Execute(context.Background(), "private", false); err != nil { + t.Fatal(err) + } + }) + + if !strings.Contains(out, "grpc-wf") || strings.Contains(out, "onchain-wf") { + t.Errorf("expected only grpc/private-registry workflow; output:\n%s", out) + } +} + +func TestExecute_List_ShowsRegistryIDForGrpcSource(t *testing.T) { + logger := zerolog.New(io.Discard) + rtCtx := &runtime.Context{ + Logger: &logger, + Credentials: &credentials.Credentials{}, + EnvironmentSet: &environments.EnvironmentSet{EnvName: "STAGING"}, + TenantContext: mixedRegistriesContext(), + } + + srv := newWorkflowServer(t, [][]map[string]string{mixedRegistriesPage()}, 2) + defer srv.Close() + h := newHandlerWithServer(t, rtCtx, srv) + + out := captureStdout(t, func() { + if err := h.Execute(context.Background(), "", false); err != nil { + t.Fatal(err) + } + }) + + if strings.Contains(out, "grpc:mock-private-registry:v1") { + t.Errorf("expected resolved grpc to show context registry id, not raw API source; output:\n%s", out) + } + idx := strings.Index(out, "grpc-wf") + if idx < 0 { + t.Fatal("expected grpc-wf in output") + } + end := idx + 400 + if end > len(out) { + end = len(out) + } + if !strings.Contains(out[idx:end], "Registry: private") { + t.Errorf("expected registry private near grpc-wf block; output:\n%s", out) + } + if strings.Contains(out[idx:end], "Address:") { + t.Errorf("did not expect Address line for off-chain/grpc workflow; output:\n%s", out) + } + + idxOn := strings.Index(out, "onchain-wf") + if idxOn < 0 { + t.Fatal("expected onchain-wf in output") + } + endOn := idxOn + 500 + if endOn > len(out) { + endOn = len(out) + } + onChunk := out[idxOn:endOn] + if !strings.Contains(onChunk, "onchain:mock-testnet") || !strings.Contains(onChunk, "Registry:") { + t.Errorf("expected on-chain registry near onchain-wf block; output:\n%s", out) + } + if !strings.Contains(onChunk, "Address:") || !strings.Contains(onChunk, "0xcafebabe00000000000000000000000000feed") { + t.Errorf("expected Address line for on-chain workflow; output:\n%s", onChunk) + } +} + +func orphanAndGrpcPage() []map[string]string { + chainSel := "12345678901234567890" + orphanAddr := "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" + return []map[string]string{ + { + "name": "orphan-onchain", + "workflowId": "f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1", + "ownerAddress": "e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2", + "status": "ACTIVE", + "workflowSource": "contract:" + chainSel + ":" + orphanAddr, + }, + { + "name": "grpc-wf", + "workflowId": "c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3", + "ownerAddress": "d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4", + "status": "ACTIVE", + "workflowSource": "grpc:mock-private-registry:v1", + }, + } +} + +func TestExecute_List_UnmatchedContractShowsAPISource(t *testing.T) { + chainSel := "12345678901234567890" + addr := "0xcafebabe00000000000000000000000000feed" + logger := zerolog.New(io.Discard) + rtCtx := &runtime.Context{ + Logger: &logger, + Credentials: &credentials.Credentials{}, + EnvironmentSet: &environments.EnvironmentSet{EnvName: "STAGING"}, + TenantContext: &tenantctx.EnvironmentContext{ + Registries: []*tenantctx.Registry{ + { + ID: "onchain:mock-testnet", + Label: "mock", + ChainSelector: strPtr(chainSel), + Address: strPtr(addr), + }, + {ID: "private", Label: "Private hosted"}, + }, + }, + } + + srv := newWorkflowServer(t, [][]map[string]string{orphanAndGrpcPage()}, 2) + defer srv.Close() + h := newHandlerWithServer(t, rtCtx, srv) + + out := captureStdout(t, func() { + if err := h.Execute(context.Background(), "", false); err != nil { + t.Fatal(err) + } + }) + + wantSource := "contract:" + chainSel + ":0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" + idx := strings.Index(out, "orphan-onchain") + if idx < 0 { + t.Fatal("expected orphan-onchain in output") + } + end := idx + 500 + if end > len(out) { + end = len(out) + } + chunk := out[idx:end] + if !strings.Contains(chunk, "Registry: "+wantSource) { + t.Errorf("expected unmatched contract to show API workflowSource in Registry line; chunk:\n%s", chunk) + } + if !strings.Contains(chunk, "Address:") || !strings.Contains(chunk, "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee") { + t.Errorf("expected Address from workflow source for orphan contract; chunk:\n%s", chunk) + } +} + +func TestExecute_RegistryFilter_PrivateExcludesUnmatchedContract(t *testing.T) { + chainSel := "12345678901234567890" + addr := "0xcafebabe00000000000000000000000000feed" + logger := zerolog.New(io.Discard) + rtCtx := &runtime.Context{ + Logger: &logger, + Credentials: &credentials.Credentials{}, + EnvironmentSet: &environments.EnvironmentSet{EnvName: "STAGING"}, + TenantContext: &tenantctx.EnvironmentContext{ + Registries: []*tenantctx.Registry{ + { + ID: "onchain:mock-testnet", + Label: "mock", + ChainSelector: strPtr(chainSel), + Address: strPtr(addr), + }, + {ID: "private", Label: "Private hosted"}, + }, + }, + } + + srv := newWorkflowServer(t, [][]map[string]string{orphanAndGrpcPage()}, 2) + defer srv.Close() + h := newHandlerWithServer(t, rtCtx, srv) + + out := captureStdout(t, func() { + if err := h.Execute(context.Background(), "private", false); err != nil { + t.Fatal(err) + } + }) + + if !strings.Contains(out, "grpc-wf") || strings.Contains(out, "orphan-onchain") { + t.Errorf("expected private filter to include only grpc workflows resolved to private, not unmatched contract rows; output:\n%s", out) + } +} + +func TestExecute_Pagination(t *testing.T) { + logger := zerolog.New(io.Discard) + rtCtx := &runtime.Context{ + Logger: &logger, + Credentials: &credentials.Credentials{}, + EnvironmentSet: &environments.EnvironmentSet{EnvName: "STAGING"}, + TenantContext: &tenantctx.EnvironmentContext{ + Registries: []*tenantctx.Registry{{ID: "private"}}, + }, + } + + page1 := make([]map[string]string, workflowdataclient.DefaultPageSize) + for i := range page1 { + page1[i] = map[string]string{ + "name": "wf-page-batch", + "workflowId": "9191919191919191919191919191919191919191919191919191919191919191", + "ownerAddress": "9292929292929292929292929292929292929292", + "status": "ACTIVE", + "workflowSource": "private", + } + } + page2 := []map[string]string{ + { + "name": "wf-page-tail-1", + "workflowId": "9393939393939393939393939393939393939393939393939393939393939393", + "ownerAddress": "9292929292929292929292929292929292929292", + "status": "ACTIVE", + "workflowSource": "private", + }, + { + "name": "wf-page-tail-2", + "workflowId": "9494949494949494949494949494949494949494949494949494949494949494", + "ownerAddress": "9292929292929292929292929292929292929292", + "status": "ACTIVE", + "workflowSource": "private", + }, + } + + total := workflowdataclient.DefaultPageSize + 2 + srv := newWorkflowServer(t, [][]map[string]string{page1, page2}, total) + defer srv.Close() + h := newHandlerWithServer(t, rtCtx, srv) + + out := captureStdout(t, func() { + if err := h.Execute(context.Background(), "", false); err != nil { + t.Fatal(err) + } + }) + + wantRows := workflowdataclient.DefaultPageSize + 2 + if got := strings.Count(out, "9292929292929292929292929292929292929292"); got < wantRows { + t.Errorf("expected at least %d owner cells, got %d in:\n%s", wantRows, got, out) + } +} diff --git a/cmd/workflow/list/print.go b/cmd/workflow/list/print.go new file mode 100644 index 00000000..42a53cd1 --- /dev/null +++ b/cmd/workflow/list/print.go @@ -0,0 +1,52 @@ +package list + +import ( + "fmt" + "strings" + + "github.com/smartcontractkit/cre-cli/internal/tenantctx" + "github.com/smartcontractkit/cre-cli/internal/ui" +) + +func omitDeleted(rows []Workflow) []Workflow { + out := make([]Workflow, 0, len(rows)) + for _, r := range rows { + if strings.EqualFold(strings.TrimSpace(r.Status), "DELETED") { + continue + } + out = append(out, r) + } + return out +} + +func printWorkflowTable(rows []Workflow, registries []*tenantctx.Registry, afterRegistryFilter int, includeDeleted bool) { + ui.Line() + if len(rows) == 0 { + if afterRegistryFilter > 0 && !includeDeleted { + ui.Warning("No workflows found (excluding deleted). Use --include-deleted to list them.") + } else { + ui.Warning("No workflows found") + } + ui.Line() + return + } + + ui.Bold("Workflows") + ui.Line() + + for i, r := range rows { + matchedReg := resolveWorkflowRegistry(r.WorkflowSource, registries) + regIDCol := formatRegistryIDFromResolved(r.WorkflowSource, matchedReg) + ui.Bold(fmt.Sprintf("%d. %s", i+1, r.Name)) + ui.Dim(fmt.Sprintf(" Workflow ID: %s", r.WorkflowID)) + ui.Dim(fmt.Sprintf(" Owner: %s", r.OwnerAddress)) + ui.Dim(fmt.Sprintf(" Status: %s", r.Status)) + ui.Dim(fmt.Sprintf(" Registry: %s", regIDCol)) + if matchedReg != nil && registryEligibleForContractRows(matchedReg) && matchedReg.Address != nil { + ui.Dim(fmt.Sprintf(" Address: %s", strings.TrimSpace(*matchedReg.Address))) + } else if _, addr, ok := parseContractWorkflowSource(r.WorkflowSource); ok && strings.TrimSpace(addr) != "" { + ui.Dim(fmt.Sprintf(" Address: %s", strings.TrimSpace(addr))) + } + ui.Line() + } +} diff --git a/cmd/workflow/list/registry.go b/cmd/workflow/list/registry.go new file mode 100644 index 00000000..9de98901 --- /dev/null +++ b/cmd/workflow/list/registry.go @@ -0,0 +1,214 @@ +package list + +import ( + "strings" + + "github.com/smartcontractkit/cre-cli/internal/tenantctx" +) + +// Registry matching: user context stores registry id +// plus chain_selector and address, while the list API returns workflowSource as +// contract::<0x…> or grpc:… — not the manifest id string. Direct equality with +// reg.ID therefore only applies when the API echoes the same id (e.g. "private"). + +func filterRowsByRegistry(rows []Workflow, reg *tenantctx.Registry, all []*tenantctx.Registry) []Workflow { + if reg == nil { + return rows + } + out := make([]Workflow, 0, len(rows)) + for _, r := range rows { + if rowMatchesRegistry(r.WorkflowSource, reg, all) { + out = append(out, r) + } + } + return out +} + +func rowMatchesRegistry(workflowSource string, reg *tenantctx.Registry, all []*tenantctx.Registry) bool { + if workflowSource == reg.ID { + return true + } + + const contractPrefix = "contract:" + if strings.HasPrefix(workflowSource, contractPrefix) { + return contractSourceMatchesRegistry(workflowSource, reg, all) + } + + if strings.HasPrefix(workflowSource, "grpc:") { + if !registryEligibleForGrpcRows(reg) { + return false + } + resolved := resolveGrpcSourceRegistry(workflowSource, all) + return resolved != nil && resolved.ID == reg.ID + } + + return false +} + +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") +} + +func hasContractAddress(reg *tenantctx.Registry) bool { + return reg != nil && reg.Address != nil && strings.TrimSpace(*reg.Address) != "" +} + +func registryEligibleForContractRows(reg *tenantctx.Registry) bool { + if reg == nil || !hasContractAddress(reg) { + return false + } + if registryTypeOffChain(reg) { + return false + } + return true +} + +func registryEligibleForGrpcRows(reg *tenantctx.Registry) bool { + if reg == nil { + return false + } + if registryTypeOffChain(reg) { + return true + } + if hasContractAddress(reg) { + return false + } + return true +} + +func contractSourceMatchesRegistry(workflowSource string, reg *tenantctx.Registry, all []*tenantctx.Registry) bool { + found := findContractRegistry(workflowSource, all) + return found != nil && found.ID == reg.ID +} + +func findContractRegistry(workflowSource string, registries []*tenantctx.Registry) *tenantctx.Registry { + const contractPrefix = "contract:" + if !strings.HasPrefix(workflowSource, contractPrefix) { + return nil + } + rest := strings.TrimPrefix(workflowSource, contractPrefix) + selector, addr, ok := strings.Cut(rest, ":") + if !ok || addr == "" { + return nil + } + for _, r := range registries { + if !registryEligibleForContractRows(r) { + continue + } + if !addressesEqual(addr, *r.Address) { + continue + } + if r.ChainSelector != nil && strings.TrimSpace(*r.ChainSelector) != "" && + strings.TrimSpace(*r.ChainSelector) != strings.TrimSpace(selector) { + continue + } + return r + } + return nil +} + +func parseContractWorkflowSource(workflowSource string) (selector, addr string, ok bool) { + const contractPrefix = "contract:" + if !strings.HasPrefix(workflowSource, contractPrefix) { + return "", "", false + } + rest := strings.TrimPrefix(workflowSource, contractPrefix) + return strings.Cut(rest, ":") +} + +func resolveGrpcSourceRegistry(workflowSource string, all []*tenantctx.Registry) *tenantctx.Registry { + if !strings.HasPrefix(workflowSource, "grpc:") { + return nil + } + eligible := make([]*tenantctx.Registry, 0, len(all)) + for _, r := range all { + if r != nil && registryEligibleForGrpcRows(r) { + eligible = append(eligible, r) + } + } + if len(eligible) == 1 { + return eligible[0] + } + var match *tenantctx.Registry + for _, r := range eligible { + id := strings.ToLower(strings.TrimSpace(r.ID)) + if len(id) < 3 { + continue + } + if strings.Contains(strings.ToLower(workflowSource), id) { + if match != nil { + return nil + } + match = r + } + } + return match +} + +func resolveWorkflowRegistry(workflowSource string, registries []*tenantctx.Registry) *tenantctx.Registry { + byID := registryByWorkflowSource(registries) + if reg, ok := byID[workflowSource]; ok { + return reg + } + + if cr := findContractRegistry(workflowSource, registries); cr != nil { + return cr + } + const contractPrefix = "contract:" + if strings.HasPrefix(workflowSource, contractPrefix) { + return nil + } + + if strings.HasPrefix(workflowSource, "grpc:") { + return resolveGrpcSourceRegistry(workflowSource, registries) + } + + return nil +} + +func formatRegistryIDFromResolved(workflowSource string, matched *tenantctx.Registry) string { + if matched != nil { + return matched.ID + } + return workflowSource +} + +func addressesEqual(a, b string) bool { + return strings.EqualFold( + strings.TrimPrefix(strings.TrimSpace(a), "0x"), + strings.TrimPrefix(strings.TrimSpace(b), "0x"), + ) +} + +func registryByWorkflowSource(registries []*tenantctx.Registry) map[string]*tenantctx.Registry { + m := make(map[string]*tenantctx.Registry) + for _, r := range registries { + if r != nil { + m[r.ID] = r + } + } + return m +} + +func findRegistry(registries []*tenantctx.Registry, id string) *tenantctx.Registry { + for _, r := range registries { + if r != nil && r.ID == id { + return r + } + } + return nil +} + +func availableRegistryIDs(registries []*tenantctx.Registry) string { + ids := make([]string, 0, len(registries)) + for _, r := range registries { + if r != nil { + ids = append(ids, r.ID) + } + } + return strings.Join(ids, ", ") +} diff --git a/cmd/workflow/list/registry_test.go b/cmd/workflow/list/registry_test.go new file mode 100644 index 00000000..2272e4ca --- /dev/null +++ b/cmd/workflow/list/registry_test.go @@ -0,0 +1,126 @@ +package list + +import ( + "testing" + + "github.com/smartcontractkit/cre-cli/internal/tenantctx" +) + +func sp(s string) *string { return &s } + +func TestRowMatchesRegistry_DirectIDMatch(t *testing.T) { + reg := &tenantctx.Registry{ID: "private", Type: "off-chain"} + all := []*tenantctx.Registry{reg} + + if !rowMatchesRegistry("private", reg, all) { + t.Error("expected direct ID match to return true") + } + if rowMatchesRegistry("other", reg, all) { + t.Error("expected non-matching ID to return false") + } +} + +func TestRowMatchesRegistry_ContractSource_MatchesByAddress(t *testing.T) { + chainSel := "12345678901234567890" + addr := "0xdeadbeef00000000000000000000000000000000" + reg := &tenantctx.Registry{ + ID: "onchain:testnet", + ChainSelector: sp(chainSel), + Address: sp(addr), + } + all := []*tenantctx.Registry{reg, {ID: "private", Type: "off-chain"}} + + source := "contract:" + chainSel + ":" + addr + if !rowMatchesRegistry(source, reg, all) { + t.Errorf("expected contract source %q to match registry with address %q", source, addr) + } +} + +func TestRowMatchesRegistry_ContractSource_AddressCaseInsensitive(t *testing.T) { + chainSel := "99999999999999999999" + addr := "0xABCDEF0000000000000000000000000000000000" + reg := &tenantctx.Registry{ + ID: "onchain:case-test", + ChainSelector: sp(chainSel), + Address: sp(addr), + } + all := []*tenantctx.Registry{reg} + + lowerSource := "contract:" + chainSel + ":0xabcdef0000000000000000000000000000000000" + if !rowMatchesRegistry(lowerSource, reg, all) { + t.Errorf("expected case-insensitive address match for source %q", lowerSource) + } +} + +func TestRowMatchesRegistry_ContractSource_WrongChainSelector(t *testing.T) { + addr := "0xdeadbeef00000000000000000000000000000000" + reg := &tenantctx.Registry{ + ID: "onchain:testnet", + ChainSelector: sp("11111111111111111111"), + Address: sp(addr), + } + all := []*tenantctx.Registry{reg} + + source := "contract:99999999999999999999:" + addr + if rowMatchesRegistry(source, reg, all) { + t.Errorf("expected wrong chain selector to NOT match") + } +} + +func TestRowMatchesRegistry_ContractSource_NoMatchForOffChainRegistry(t *testing.T) { + offChain := &tenantctx.Registry{ID: "private", Type: "off-chain"} + all := []*tenantctx.Registry{offChain} + + source := "contract:12345678901234567890:0xdeadbeef00000000000000000000000000000000" + if rowMatchesRegistry(source, offChain, all) { + t.Error("expected contract source to NOT match an off-chain registry") + } +} + +func TestRowMatchesRegistry_GrpcSource_SingleEligibleRegistry(t *testing.T) { + private := &tenantctx.Registry{ID: "private", Type: "off-chain"} + all := []*tenantctx.Registry{ + {ID: "onchain:testnet", ChainSelector: sp("12345678901234567890"), Address: sp("0xaaaa")}, + private, + } + + if !rowMatchesRegistry("grpc:some-endpoint:v1", private, all) { + t.Error("expected grpc source to match the single eligible off-chain registry") + } +} + +func TestRowMatchesRegistry_GrpcSource_NoMatchForOnChainRegistry(t *testing.T) { + onchain := &tenantctx.Registry{ + ID: "onchain:testnet", + ChainSelector: sp("12345678901234567890"), + Address: sp("0xdeadbeef00000000000000000000000000000000"), + } + all := []*tenantctx.Registry{onchain} + + if rowMatchesRegistry("grpc:some-endpoint:v1", onchain, all) { + t.Error("expected grpc source to NOT match an on-chain registry") + } +} + +func TestRowMatchesRegistry_GrpcSource_MatchByIDSubstring(t *testing.T) { + reg := &tenantctx.Registry{ID: "private", Type: "off-chain"} + other := &tenantctx.Registry{ID: "staging", Type: "off-chain"} + all := []*tenantctx.Registry{reg, other} + + // Contains "private" in the source — should resolve to reg. + if !rowMatchesRegistry("grpc:private-endpoint:v1", reg, all) { + t.Error("expected grpc source containing registry ID substring to match that registry") + } + if rowMatchesRegistry("grpc:private-endpoint:v1", other, all) { + t.Error("expected grpc source to NOT match the non-matching registry") + } +} + +func TestRowMatchesRegistry_UnknownSourceFormat(t *testing.T) { + reg := &tenantctx.Registry{ID: "private", Type: "off-chain"} + all := []*tenantctx.Registry{reg} + + if rowMatchesRegistry("unknown:format:xyz", reg, all) { + t.Error("expected unknown source format to NOT match any registry") + } +} diff --git a/cmd/workflow/workflow.go b/cmd/workflow/workflow.go index f03a7bdf..cc39b70c 100644 --- a/cmd/workflow/workflow.go +++ b/cmd/workflow/workflow.go @@ -10,6 +10,7 @@ import ( "github.com/smartcontractkit/cre-cli/cmd/workflow/deploy" "github.com/smartcontractkit/cre-cli/cmd/workflow/hash" "github.com/smartcontractkit/cre-cli/cmd/workflow/limits" + workflowlist "github.com/smartcontractkit/cre-cli/cmd/workflow/list" "github.com/smartcontractkit/cre-cli/cmd/workflow/pause" "github.com/smartcontractkit/cre-cli/cmd/workflow/simulate" "github.com/smartcontractkit/cre-cli/cmd/workflow/test" @@ -33,6 +34,7 @@ func New(runtimeContext *runtime.Context) *cobra.Command { workflowCmd.AddCommand(hash.New(runtimeContext)) workflowCmd.AddCommand(simulate.New(runtimeContext)) workflowCmd.AddCommand(limits.New()) + workflowCmd.AddCommand(workflowlist.New(runtimeContext)) return workflowCmd } diff --git a/docs/cre_workflow.md b/docs/cre_workflow.md index 0bdfb9a6..00e56839 100644 --- a/docs/cre_workflow.md +++ b/docs/cre_workflow.md @@ -36,6 +36,7 @@ cre workflow [optional flags] * [cre workflow deploy](cre_workflow_deploy.md) - Deploys a workflow to the Workflow Registry contract * [cre workflow hash](cre_workflow_hash.md) - Computes and displays workflow hashes * [cre workflow limits](cre_workflow_limits.md) - Manage simulation limits +* [cre workflow list](cre_workflow_list.md) - Lists workflows deployed for your organization * [cre workflow pause](cre_workflow_pause.md) - Pauses workflow on the Workflow Registry contract * [cre workflow simulate](cre_workflow_simulate.md) - Simulates a workflow diff --git a/docs/cre_workflow_list.md b/docs/cre_workflow_list.md new file mode 100644 index 00000000..ba95df4f --- /dev/null +++ b/docs/cre_workflow_list.md @@ -0,0 +1,42 @@ +## cre workflow list + +Lists workflows deployed for your organization + +### Synopsis + +Lists workflows across registries in your organization. Requires authentication and user context. Deleted workflows are hidden by default. + +``` +cre workflow list [optional flags] +``` + +### Examples + +``` +cre workflow list + cre workflow list --registry private + cre workflow list --include-deleted +``` + +### Options + +``` + -h, --help help for list + --include-deleted Include workflows in DELETED status + --registry string Filter by registry ID from user context +``` + +### Options inherited from parent commands + +``` + -e, --env string Path to .env file which contains sensitive info + -R, --project-root string Path to the project root + -E, --public-env string Path to .env.public file which contains shared, non-sensitive build config + -T, --target string Use target settings from YAML config + -v, --verbose Run command in VERBOSE mode +``` + +### SEE ALSO + +* [cre workflow](cre_workflow.md) - Manages workflows + diff --git a/internal/client/workflowdataclient/workflowdataclient.go b/internal/client/workflowdataclient/workflowdataclient.go new file mode 100644 index 00000000..1af3f272 --- /dev/null +++ b/internal/client/workflowdataclient/workflowdataclient.go @@ -0,0 +1,104 @@ +package workflowdataclient + +import ( + "context" + "fmt" + + "github.com/machinebox/graphql" + "github.com/rs/zerolog" + + "github.com/smartcontractkit/cre-cli/internal/client/graphqlclient" +) + +const DefaultPageSize = 100 + +// Workflow is a workflow row returned by the platform list API. +type Workflow struct { + Name string + WorkflowID string + OwnerAddress string + Status string + WorkflowSource string +} + +// Client fetches workflow data from the CRE platform GraphQL API. +type Client struct { + graphql *graphqlclient.Client + log *zerolog.Logger +} + +// New creates a WorkflowDataClient backed by the provided GraphQL client. +func New(gql *graphqlclient.Client, log *zerolog.Logger) *Client { + return &Client{graphql: gql, log: log} +} + +const listWorkflowsQuery = ` +query ListWorkflows($input: WorkflowsInput!) { + workflows(input: $input) { + data { + name + workflowId + ownerAddress + status + workflowSource + } + count + } +} +` + +type gqlWorkflow struct { + Name string `json:"name"` + WorkflowID string `json:"workflowId"` + OwnerAddress string `json:"ownerAddress"` + Status string `json:"status"` + WorkflowSource string `json:"workflowSource"` +} + +type listWorkflowsEnvelope struct { + Workflows struct { + Data []gqlWorkflow `json:"data"` + Count int `json:"count"` + } `json:"workflows"` +} + +// ListAll pages through the ListWorkflows query and returns all workflows. +func (c *Client) ListAll(ctx context.Context, pageSize int) ([]Workflow, error) { + if pageSize <= 0 { + pageSize = DefaultPageSize + } + + var total int + all := make([]Workflow, 0) + + for pageNum := 0; ; pageNum++ { + req := graphql.NewRequest(listWorkflowsQuery) + req.Var("input", map[string]any{ + "page": map[string]any{ + "number": pageNum, + "size": pageSize, + }, + }) + + var env listWorkflowsEnvelope + if err := c.graphql.Execute(ctx, req, &env); err != nil { + return nil, fmt.Errorf("list workflows: %w", err) + } + + if pageNum == 0 { + total = env.Workflows.Count + } + + batch := env.Workflows.Data + for _, g := range batch { + all = append(all, Workflow(g)) + } + + if len(all) >= total || len(batch) == 0 { + break + } + } + + c.log.Debug().Int("count", len(all)).Msg("Listed workflows from platform") + return all, nil +} diff --git a/internal/client/workflowdataclient/workflowdataclient_test.go b/internal/client/workflowdataclient/workflowdataclient_test.go new file mode 100644 index 00000000..503c244f --- /dev/null +++ b/internal/client/workflowdataclient/workflowdataclient_test.go @@ -0,0 +1,149 @@ +package workflowdataclient + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "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/testutil" +) + +func newTestClient(t *testing.T, serverURL string) *Client { + t.Helper() + logger := testutil.NewTestLogger() + creds := &credentials.Credentials{ + AuthType: credentials.AuthTypeApiKey, + APIKey: "test-api-key", + } + envSet := &environments.EnvironmentSet{GraphQLURL: serverURL} + gql := graphqlclient.New(creds, envSet, logger) + return New(gql, logger) +} + +func TestListAll_SinglePage(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "data": map[string]any{ + "workflows": map[string]any{ + "count": 2, + "data": []map[string]string{ + { + "name": "alpha", + "workflowId": "1010101010101010101010101010101010101010101010101010101010101010", + "ownerAddress": "2020202020202020202020202020202020202020", + "status": "ACTIVE", + "workflowSource": "private", + }, + { + "name": "beta", + "workflowId": "3030303030303030303030303030303030303030303030303030303030303030", + "ownerAddress": "4040404040404040404040404040404040404040", + "status": "PAUSED", + "workflowSource": "contract:999888777666555444333:0xabababababababababababababababababababab", + }, + }, + }, + }, + }) + })) + defer srv.Close() + + client := newTestClient(t, srv.URL) + got, err := client.ListAll(context.Background(), DefaultPageSize) + require.NoError(t, err) + require.Len(t, got, 2) + assert.Equal(t, "alpha", got[0].Name) + assert.Equal(t, "ACTIVE", got[0].Status) + assert.Equal(t, "private", got[0].WorkflowSource) + assert.Equal(t, "beta", got[1].Name) + assert.Equal(t, "PAUSED", got[1].Status) +} + +func TestListAll_Pagination(t *testing.T) { + var callCount atomic.Int32 + + page1Data := make([]map[string]string, DefaultPageSize) + for i := range page1Data { + page1Data[i] = map[string]string{ + "name": "wf-page-1", + "workflowId": "a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0", + "ownerAddress": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", + "status": "ACTIVE", + "workflowSource": "private", + } + } + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + call := int(callCount.Add(1)) + w.Header().Set("Content-Type", "application/json") + + switch call { + case 1: + _ = json.NewEncoder(w).Encode(map[string]any{ + "data": map[string]any{ + "workflows": map[string]any{ + "count": DefaultPageSize + 1, + "data": page1Data, + }, + }, + }) + case 2: + _ = json.NewEncoder(w).Encode(map[string]any{ + "data": map[string]any{ + "workflows": map[string]any{ + "count": DefaultPageSize + 1, + "data": []map[string]string{ + { + "name": "wf-last", + "workflowId": "c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0", + "ownerAddress": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", + "status": "ACTIVE", + "workflowSource": "private", + }, + }, + }, + }, + }) + default: + _ = json.NewEncoder(w).Encode(map[string]any{ + "data": map[string]any{ + "workflows": map[string]any{"count": DefaultPageSize + 1, "data": []any{}}, + }, + }) + } + })) + defer srv.Close() + + client := newTestClient(t, srv.URL) + got, err := client.ListAll(context.Background(), DefaultPageSize) + require.NoError(t, err) + assert.Len(t, got, DefaultPageSize+1) + assert.Equal(t, "wf-last", got[len(got)-1].Name) + assert.Equal(t, int32(2), callCount.Load(), "expected exactly 2 HTTP calls for 2 pages") +} + +func TestListAll_GQLError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "errors": []map[string]string{{"message": "unauthorized"}}, + }) + })) + defer srv.Close() + + client := newTestClient(t, srv.URL) + _, err := client.ListAll(context.Background(), DefaultPageSize) + require.Error(t, err) + assert.Contains(t, err.Error(), "list workflows") + assert.Contains(t, err.Error(), "unauthorized") +} diff --git a/internal/settings/registry_resolution.go b/internal/settings/registry_resolution.go index 93249881..21f60627 100644 --- a/internal/settings/registry_resolution.go +++ b/internal/settings/registry_resolution.go @@ -88,7 +88,7 @@ func ResolveRegistry( reg := findRegistry(tenantCtx.Registries, deploymentRegistry) if reg == nil { - return nil, fmt.Errorf("registry %q not found in context.yaml; available: [%s]", + return nil, fmt.Errorf("registry %q not found in user context; available: [%s]", deploymentRegistry, availableIDs(tenantCtx.Registries)) } @@ -100,11 +100,11 @@ func ResolveRegistry( } if reg.Address == nil || *reg.Address == "" { - return nil, fmt.Errorf("on-chain registry %q has no address in context.yaml", reg.ID) + 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 context.yaml", reg.ID) + 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 { @@ -120,7 +120,7 @@ func ResolveRegistry( ), nil } -// ParseRegistryType converts a raw type string from context.yaml to a +// 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") { diff --git a/internal/settings/registry_resolution_test.go b/internal/settings/registry_resolution_test.go index 55511952..853f571c 100644 --- a/internal/settings/registry_resolution_test.go +++ b/internal/settings/registry_resolution_test.go @@ -122,7 +122,7 @@ func TestResolveRegistry_UnknownID(t *testing.T) { if err == nil { t.Fatal("expected error for unknown registry ID") } - if !strings.Contains(err.Error(), "not found in context.yaml") { + if !strings.Contains(err.Error(), "not found in user context") { t.Errorf("unexpected error: %v", err) } if !strings.Contains(err.Error(), "onchain:ethereum-testnet-sepolia") {