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
82 changes: 66 additions & 16 deletions internal/connector/test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,26 +4,21 @@ import (
"context"
"fmt"
"io"
"strings"

"github.com/highperformance-tech/ana-cli/internal/cli"
)

// testCmd implements `ana connector test <id>` — TestConnector.
//
// CATALOG DEVIATION: the task brief specifies
//
// POST TestConnector {"connectorId": <int>}
//
// but the captured API requires a full config body:
//
// POST TestConnector {"config": {connectorType, name, postgres: {...}}}
//
// (see api-catalog/POST_...TestConnector.json). Since the brief says "if
// catalog differs from this brief, prefer catalog," we follow the catalog
// shape and send `{connectorId}` anyway — this matches the brief's CLI UX
// (test-by-id) and will either be accepted by a future server change or
// return the current driver error, which we surface verbatim. Server response
// shape `{error: <string>}` is empty/absent on success.
// CATALOG REALITY: the server's TestConnector endpoint validates a full config
// body (`{config: {connectorType, name, <dialect>: {...}}}`), not a bare id —
// it's a pre-create probe, not a test-existing op. To preserve the CLI's
// id-based UX we GET the connector first, rebuild a config from the returned
// `<dialect>Metadata` block, and POST that to TestConnector. Passwords/secrets
// are redacted in GetConnector responses, so the dial typically fails with an
// auth or connection error — which the server still returns as a 200 with the
// driver message in `error`. Both `OK` and `FAIL: <msg>` are valid CLI outputs.
type testCmd struct{ deps Deps }

func (c *testCmd) Help() string {
Expand All @@ -32,7 +27,7 @@ func (c *testCmd) Help() string {
}

type testReq struct {
ConnectorID int `json:"connectorId"`
Config map[string]any `json:"config"`
}

type testResp struct {
Expand All @@ -51,8 +46,16 @@ func (c *testCmd) Run(ctx context.Context, args []string, stdio cli.IO) error {
if err != nil {
return err
}
var getRaw map[string]any
if err := c.deps.Unary(ctx, servicePath+"/GetConnector", getReq{ConnectorID: id}, &getRaw); err != nil {
return fmt.Errorf("connector test: %w", err)
}
cfg, err := configFromGetConnector(getRaw)
if err != nil {
return fmt.Errorf("connector test: %w", err)
}
var raw map[string]any
if err := c.deps.Unary(ctx, servicePath+"/TestConnector", testReq{ConnectorID: id}, &raw); err != nil {
if err := c.deps.Unary(ctx, servicePath+"/TestConnector", testReq{Config: cfg}, &raw); err != nil {
return fmt.Errorf("connector test: %w", err)
}
var typed testResp
Expand All @@ -68,3 +71,50 @@ func (c *testCmd) Run(ctx context.Context, args []string, stdio cli.IO) error {
}
return nil
}

// configFromGetConnector rebuilds a TestConnector `config` body from a
// GetConnector response. It copies the top-level `name` + `connectorType`, and
// moves the `<dialect>Metadata` block into `<dialect>`. Secrets are absent
// from the metadata block; the server accepts the probe and returns a driver
// error for the missing credential.
func configFromGetConnector(raw map[string]any) (map[string]any, error) {
conn, _ := raw["connector"].(map[string]any)
if conn == nil {
return nil, fmt.Errorf("GetConnector: missing connector object")
}
connectorType, _ := conn["connectorType"].(string)
name, _ := conn["name"].(string)
if connectorType == "" {
return nil, fmt.Errorf("GetConnector: missing connectorType")
}
cfg := map[string]any{"connectorType": connectorType, "name": name}
for k, v := range conn {
if !strings.HasSuffix(k, "Metadata") {
continue
}
if block, ok := v.(map[string]any); ok {
dialectKey := strings.TrimSuffix(k, "Metadata")
// Shallow-copy so we never mutate the caller's map.
out := make(map[string]any, len(block)+1)
for bk, bv := range block {
out[bk] = bv
}
// GetConnector redacts secrets, so fill a placeholder for any
// required secret field. The server returns the driver's auth
// failure as a 200 `{error: ...}` which the CLI surfaces as FAIL:.
// NOTE: only "password" is placeholdered today. Postgres uses it;
// Snowflake's non-password auth modes (keypair, oauth-sso,
// oauth-individual) use privateKey / oauthClientSecret and will
// still surface a driver-side auth error via the same FAIL: path
// — extend this slice when adding a dialect whose TestConnector
// body requires a different secret field name.
for _, secret := range []string{"password"} {
if _, present := out[secret]; !present {
out[secret] = "redacted"
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
cfg[dialectKey] = out
}
}
return cfg, nil
}
91 changes: 87 additions & 4 deletions internal/connector/test_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,28 @@ import (
"github.com/highperformance-tech/ana-cli/internal/testcli"
)

// stubGetConnector returns a minimal GetConnector response so `connector test`
// can build a config body for the follow-up TestConnector call.
func stubGetConnector(resp any) {
out := resp.(*map[string]any)
*out = map[string]any{
"connector": map[string]any{
"id": 1.0,
"name": "probe",
"connectorType": "POSTGRES",
"postgresMetadata": map[string]any{"host": "h", "port": 5432.0, "user": "u", "database": "d", "dialect": "postgres", "sslMode": true},
},
}
}

func TestTestOK(t *testing.T) {
t.Parallel()
f := &fakeDeps{
unaryFn: func(_ context.Context, _ string, _, resp any) error {
unaryFn: func(_ context.Context, path string, _, resp any) error {
if strings.HasSuffix(path, "/GetConnector") {
stubGetConnector(resp)
return nil
}
out := resp.(*map[string]any)
*out = map[string]any{"error": ""}
return nil
Expand All @@ -35,7 +53,11 @@ func TestTestOK(t *testing.T) {
func TestTestFail(t *testing.T) {
t.Parallel()
f := &fakeDeps{
unaryFn: func(_ context.Context, _ string, _, resp any) error {
unaryFn: func(_ context.Context, path string, _, resp any) error {
if strings.HasSuffix(path, "/GetConnector") {
stubGetConnector(resp)
return nil
}
out := resp.(*map[string]any)
*out = map[string]any{"error": "connection refused"}
return nil
Expand All @@ -54,7 +76,11 @@ func TestTestFail(t *testing.T) {
func TestTestJSON(t *testing.T) {
t.Parallel()
f := &fakeDeps{
unaryFn: func(_ context.Context, _ string, _, resp any) error {
unaryFn: func(_ context.Context, path string, _, resp any) error {
if strings.HasSuffix(path, "/GetConnector") {
stubGetConnector(resp)
return nil
}
out := resp.(*map[string]any)
*out = map[string]any{"error": ""}
return nil
Expand Down Expand Up @@ -115,7 +141,11 @@ func TestTestBadFlag(t *testing.T) {
func TestTestRemarshalErr(t *testing.T) {
t.Parallel()
f := &fakeDeps{
unaryFn: func(_ context.Context, _ string, _, resp any) error {
unaryFn: func(_ context.Context, path string, _, resp any) error {
if strings.HasSuffix(path, "/GetConnector") {
stubGetConnector(resp)
return nil
}
out := resp.(*map[string]any)
*out = map[string]any{"error": 123.0}
return nil
Expand All @@ -128,3 +158,56 @@ func TestTestRemarshalErr(t *testing.T) {
t.Errorf("err=%v", err)
}
}

func TestTestTestConnectorUnaryErr(t *testing.T) {
t.Parallel()
f := &fakeDeps{
unaryFn: func(_ context.Context, path string, _, resp any) error {
if strings.HasSuffix(path, "/GetConnector") {
stubGetConnector(resp)
return nil
}
return errors.New("boom")
},
}
cmd := &testCmd{deps: f.deps()}
stdio, _, _ := testcli.NewIO(strings.NewReader(""))
err := cmd.Run(context.Background(), []string{"1"}, stdio)
if err == nil || !strings.Contains(err.Error(), "boom") {
t.Errorf("err=%v", err)
}
}

func TestTestMissingConnectorType(t *testing.T) {
t.Parallel()
f := &fakeDeps{
unaryFn: func(_ context.Context, _ string, _, resp any) error {
out := resp.(*map[string]any)
*out = map[string]any{"connector": map[string]any{"id": 1.0}}
return nil
},
}
cmd := &testCmd{deps: f.deps()}
stdio, _, _ := testcli.NewIO(strings.NewReader(""))
err := cmd.Run(context.Background(), []string{"1"}, stdio)
if err == nil || !strings.Contains(err.Error(), "missing connectorType") {
t.Errorf("err=%v", err)
}
}

func TestTestMissingConnector(t *testing.T) {
t.Parallel()
f := &fakeDeps{
unaryFn: func(_ context.Context, _ string, _, resp any) error {
out := resp.(*map[string]any)
*out = map[string]any{}
return nil
},
}
cmd := &testCmd{deps: f.deps()}
stdio, _, _ := testcli.NewIO(strings.NewReader(""))
err := cmd.Run(context.Background(), []string{"1"}, stdio)
if err == nil || !strings.Contains(err.Error(), "missing connector object") {
t.Errorf("err=%v", err)
}
}
Loading