diff --git a/README.md b/README.md index 680f0bc..9be3898 100644 --- a/README.md +++ b/README.md @@ -39,9 +39,15 @@ make build ### Windows SmartScreen -The Windows binary is unsigned. On first run SmartScreen may block it with -"Windows protected your PC". Click **More info → Run anyway**. To avoid the -prompt altogether, unblock the executable before running: +Official release binaries are Authenticode-signed via Azure Trusted Signing +(see the `sign-windows` job in `.github/workflows/release.yml`), so +SmartScreen should not prompt when running an `ana.exe` downloaded from +GitHub Releases. + +Binaries built from source (`go install` / `make build`) are **not** signed +and SmartScreen may still block them on first run with "Windows protected +your PC". Click **More info → Run anyway**, or unblock the file before +running: ```powershell Unblock-File -Path .\ana.exe @@ -74,6 +80,11 @@ ana chat send "show me last month's revenue" Run `ana --help` or `ana --help` for command-specific flags. +Connector-create commands are structured as `dialect auth-mode` subcommands +(e.g. `ana connector create postgres password --name prod --host … --user … +--database … --password-stdin`) so new dialects and auth modes land as +additions rather than growing a conditional flag matrix. + ## Configuration `ana` stores tokens and per-profile endpoints at diff --git a/docs/cli-readiness.md b/docs/cli-readiness.md index 036e4ff..fd7b3b7 100644 --- a/docs/cli-readiness.md +++ b/docs/cli-readiness.md @@ -81,7 +81,7 @@ ana auth keys rotate ana auth keys revoke ana connector list -ana connector create --type postgres --name X --host ... --db ... # runs Test, then Create +ana connector create postgres password --name X --host ... --database ... # dialect + auth-mode subtree; runs Test, then Create ana connector test ana connector update --name X ana connector delete diff --git a/internal/connector/CLAUDE.md b/internal/connector/CLAUDE.md index 252b032..eb31ec7 100644 --- a/internal/connector/CLAUDE.md +++ b/internal/connector/CLAUDE.md @@ -7,8 +7,10 @@ The `ana connector` verb tree: `list`, `get`, `create`, `update`, `delete`, `tes - `connector.go` — `New`, `Deps`, service path prefix. - `types.go` — shared wire shapes consumed by create + update: `createReq`, `updateReq`, `configEnvelope`, `postgresSpec`, `createResp`, `getConnectorResp`. Per-dialect spec types will land alongside (`types_snowflake.go`, etc.) as new dialects are probed. - `list.go` / `get.go` — `GetConnectors` / `GetConnector` (readonly). -- `create.go` — `CreateConnector`. Postgres dialect verified; other dialects assumed from captured samples. -- `update.go` — `UpdateConnector`. Pre-fetches the baseline so interleaved flags merge correctly (see commit `1433e01`). +- `create.go` — `newCreateGroup` (dialect-selector Group) + the shared `resolvePassword` helper reused by per-dialect password leaves and `update.go`. +- `create_postgres.go` — `newPostgresCreateGroup` (Postgres dialect Group whose inheritable `Flags` closure owns `--name`/`--ssl`) and `postgresPasswordCmd` (leaf for the `password` auth mode). Implements `cli.Flagger` so `--help` enumerates own + ancestor flags. Additional Postgres auth modes (key-based, cert-based) land as sibling leaf files — no reshuffling. +- `update.go` — `UpdateConnector`. Pre-fetches the baseline so interleaved flags merge correctly (see commit `1433e01`). Still a flat leaf today; dialect-aware validation + auth-mode-swap subtree are Phase 3g work. - `delete.go`, `test.go` (TestConnector), `tables.go` (ListConnectorTables), `examples.go` (GetExampleQueries) — remaining CRUD + diagnostic verbs. - `connector_test.go` — shared `fakeDeps`, `errReader`, `TestNew*`/`TestHelp*`. -- `list_test.go` / `get_test.go` / `create_test.go` / `update_test.go` / `delete_test.go` / `test_test.go` / `tables_test.go` / `examples_test.go` — one per source file. `create_test.go` carries the `requiredFlags()` builder used by the create cases; `update_test.go` covers the baseline-merge path via `cli.FlagWasSet`. +- `create_postgres_password_test.go` — covers the Postgres password leaf end-to-end by dispatching through `newCreateGroup` so ancestor-flag plumbing (`--name`, `--ssl` from the Postgres Group) is exercised the same way real CLI dispatch does. `requiredArgs()` builder starts with `"postgres", "password"` because every test routes through the Group. +- `list_test.go` / `get_test.go` / `update_test.go` / `delete_test.go` / `test_test.go` / `tables_test.go` / `examples_test.go` — one per non-create source file. diff --git a/internal/connector/connector.go b/internal/connector/connector.go index c2a4521..f0b1f54 100644 --- a/internal/connector/connector.go +++ b/internal/connector/connector.go @@ -29,7 +29,7 @@ func New(deps Deps) *cli.Group { Children: map[string]cli.Command{ "list": &listCmd{deps: deps}, "get": &getCmd{deps: deps}, - "create": &createCmd{deps: deps}, + "create": newCreateGroup(deps), "update": &updateCmd{deps: deps}, "delete": &deleteCmd{deps: deps}, "test": &testCmd{deps: deps}, diff --git a/internal/connector/connector_test.go b/internal/connector/connector_test.go index b497968..3bda3b5 100644 --- a/internal/connector/connector_test.go +++ b/internal/connector/connector_test.go @@ -55,7 +55,6 @@ func TestHelpStringsNonEmpty(t *testing.T) { cases := map[string]cli.Command{ "list": &listCmd{}, "get": &getCmd{}, - "create": &createCmd{}, "update": &updateCmd{}, "delete": &deleteCmd{}, "test": &testCmd{}, diff --git a/internal/connector/create.go b/internal/connector/create.go index c6cc54d..abd719a 100644 --- a/internal/connector/create.go +++ b/internal/connector/create.go @@ -1,84 +1,27 @@ package connector import ( - "context" "fmt" "io" "github.com/highperformance-tech/ana-cli/internal/cli" ) -// createCmd implements `ana connector create` — POST CreateConnector. -// v1 only supports the `postgres` dialect; other values are a usage error. -type createCmd struct{ deps Deps } - -func (c *createCmd) Help() string { - return "create Create a new connector (postgres only in v1).\n" + - "Usage: ana connector create --type postgres --name --host --port

--user (--password-stdin|--password

) --database [--ssl]" -} - -func (c *createCmd) Run(ctx context.Context, args []string, stdio cli.IO) error { - fs := cli.NewFlagSet("connector create") - var typ string - fs.Var(cli.EnumFlag(&typ, []string{"postgres"}), "type", "connector type (postgres — required)") - name := fs.String("name", "", "connector name (required)") - host := fs.String("host", "", "database host (required)") - port := fs.Int("port", 0, "database port (required)") - user := fs.String("user", "", "database user (required)") - pass := fs.String("password", "", "database password (discouraged; prefer --password-stdin)") - passStdin := fs.Bool("password-stdin", false, "read password from the first stdin line") - database := fs.String("database", "", "database name (required)") - ssl := fs.Bool("ssl", false, "enable SSL/TLS") - if err := cli.ParseFlags(fs, args); err != nil { - return err - } - if err := cli.RequireFlags(fs, "connector create", "type", "name", "host", "port", "user", "database"); err != nil { - return err - } - // RequireFlags only checks that the flag was set, not its value. Guard - // against explicit empties (`--name ""`) and out-of-range ports so we - // fail fast with a local usage error rather than surfacing a server-side - // error. Deterministic order so tests can assert which flag triggered. - for _, p := range []struct { - name, val string - }{{"name", *name}, {"host", *host}, {"user", *user}, {"database", *database}} { - if p.val == "" { - return cli.UsageErrf("connector create: --%s must not be empty", p.name) - } - } - if *port <= 0 || *port > 65535 { - return cli.UsageErrf("connector create: --port must be in 1..65535 (got %d)", *port) - } - resolvedPass, err := resolvePassword(*pass, *passStdin, stdio.Stdin) - if err != nil { - return fmt.Errorf("connector create: %w", err) - } - - req := createReq{Config: configEnvelope{ - ConnectorType: "POSTGRES", - Name: *name, - Postgres: &postgresSpec{ - Host: *host, - Port: *port, - User: *user, - Password: resolvedPass, - Database: *database, - SSLMode: *ssl, +// newCreateGroup wires the `ana connector create` subtree. Each child is a +// Group for a specific dialect; each dialect's children are leaves for each +// supported auth mode. The two-level shape scales: adding a dialect is a new +// file, adding an auth mode is a sibling leaf under the dialect Group — no +// N×M conditional matrix on a flat `--type`/`--auth` flag pair. +// +// Breaking change from v0.x: `ana connector create --type postgres …` became +// `ana connector create postgres password …`. +func newCreateGroup(deps Deps) *cli.Group { + return &cli.Group{ + Summary: "Create a new connector. Pick a dialect, then an auth mode.", + Children: map[string]cli.Command{ + "postgres": newPostgresCreateGroup(deps), }, - }} - var raw map[string]any - if err := c.deps.Unary(ctx, servicePath+"/CreateConnector", req, &raw); err != nil { - return fmt.Errorf("connector create: %w", err) - } - var typed createResp - if err := cli.RenderOutput(stdio.Stdout, raw, cli.GlobalFrom(ctx).JSON, &typed, func(w io.Writer, t *createResp) error { - _, err := fmt.Fprintf(w, "connectorId: %d\nname: %s\nconnectorType: %s\n", - t.ConnectorID, t.Name, t.ConnectorType) - return err - }); err != nil { - return fmt.Errorf("connector create: %w", err) } - return nil } // resolvePassword resolves the password from either --password-stdin (reads @@ -88,6 +31,9 @@ func (c *createCmd) Run(ctx context.Context, args []string, stdio cli.IO) error // surrounding whitespace is intentional: a password may legitimately start or // end with spaces/tabs, and silently trimming would cause hard-to-diagnose // auth failures. +// +// Lives in create.go rather than a per-dialect file because update.go also +// reuses it when --password{,-stdin} is supplied on an edit. func resolvePassword(passFlag string, stdinFlag bool, r io.Reader) (string, error) { if stdinFlag { pass, err := cli.ReadPassword(r) @@ -95,7 +41,6 @@ func resolvePassword(passFlag string, stdinFlag bool, r io.Reader) (string, erro return "", fmt.Errorf("read password: %w", err) } if pass == "" { - // Empty stream is a usage error; the flag explicitly promised a line. return "", cli.UsageErrf("--password-stdin set but stdin was empty") } return pass, nil diff --git a/internal/connector/create_postgres.go b/internal/connector/create_postgres.go new file mode 100644 index 0000000..357612d --- /dev/null +++ b/internal/connector/create_postgres.go @@ -0,0 +1,127 @@ +package connector + +import ( + "context" + "flag" + "fmt" + "io" + + "github.com/highperformance-tech/ana-cli/internal/cli" +) + +// newPostgresCreateGroup returns the Postgres create-dialect Group. Flags +// common to every Postgres auth-mode leaf (`--name`, `--ssl`) are declared on +// the Group's inheritable Flags closure; each auth-mode leaf (today only +// `password`) declares its own dialect-specific flags and reads the Group's +// via cli.ApplyAncestorFlags. +// +// The shared `name`/`ssl` vars live in this builder's closure: the CLI is +// single-shot so one set of mutable targets per-Group is fine. If we ever +// need concurrent invocations we'd allocate them per-Run instead. +func newPostgresCreateGroup(deps Deps) *cli.Group { + var name string + var ssl bool + return &cli.Group{ + Summary: "Create a Postgres connector. Pick an auth mode.", + Flags: func(fs *flag.FlagSet) { + cli.DeclareString(fs, &name, "name", "", "connector name (required)") + cli.DeclareBool(fs, &ssl, "ssl", false, "enable SSL/TLS") + }, + Children: map[string]cli.Command{ + "password": &postgresPasswordCmd{deps: deps, name: &name, ssl: &ssl}, + }, + } +} + +// postgresPasswordCmd is the leaf for `ana connector create postgres password`. +// name/ssl are pointers into the parent Group's Flags closure state — the +// Group's inheritable flag registrar binds --name/--ssl on the leaf's fs to +// those addresses, so reading them here after ParseFlags is equivalent to +// reading any other flag target. +type postgresPasswordCmd struct { + deps Deps + name *string + ssl *bool + + // Leaf-specific flag targets. Declared in Flags(fs) so both --help and + // Run see the same binding. + host string + port int + user string + database string + password string + passStdin bool +} + +func (c *postgresPasswordCmd) Help() string { + return "password Password-based Postgres auth.\n" + + "Usage: ana connector create postgres password --name --host --port

--user --database (--password

|--password-stdin) [--ssl]" +} + +// Flags declares this leaf's own flags on fs. cli.dispatchChild runs this +// plus ApplyAncestorFlags when rendering --help, so the leaf's Flags: block +// lists both its own and the Postgres Group's --name/--ssl. +func (c *postgresPasswordCmd) Flags(fs *flag.FlagSet) { + fs.StringVar(&c.host, "host", "", "database host (required)") + fs.IntVar(&c.port, "port", 0, "database port (required)") + fs.StringVar(&c.user, "user", "", "database user (required)") + fs.StringVar(&c.database, "database", "", "database name (required)") + fs.StringVar(&c.password, "password", "", "database password (discouraged; prefer --password-stdin)") + fs.BoolVar(&c.passStdin, "password-stdin", false, "read password from the first stdin line") +} + +func (c *postgresPasswordCmd) Run(ctx context.Context, args []string, stdio cli.IO) error { + fs := cli.NewFlagSet("connector create postgres password") + c.Flags(fs) + cli.ApplyAncestorFlags(ctx, fs) + if err := cli.ParseFlags(fs, args); err != nil { + return err + } + if err := cli.RequireFlags(fs, "connector create postgres password", + "name", "host", "port", "user", "database"); err != nil { + return err + } + // RequireFlags only checks presence; reject explicit empties for fields + // where "" is meaningless, and clamp port to the TCP range so a local + // usage error beats a server-side rejection. + for _, p := range []struct { + name, val string + }{{"name", *c.name}, {"host", c.host}, {"user", c.user}, {"database", c.database}} { + if p.val == "" { + return cli.UsageErrf("connector create postgres password: --%s must not be empty", p.name) + } + } + if c.port <= 0 || c.port > 65535 { + return cli.UsageErrf("connector create postgres password: --port must be in 1..65535 (got %d)", c.port) + } + resolvedPass, err := resolvePassword(c.password, c.passStdin, stdio.Stdin) + if err != nil { + return fmt.Errorf("connector create postgres password: %w", err) + } + + req := createReq{Config: configEnvelope{ + ConnectorType: "POSTGRES", + Name: *c.name, + Postgres: &postgresSpec{ + Host: c.host, + Port: c.port, + User: c.user, + Password: resolvedPass, + Database: c.database, + SSLMode: *c.ssl, + }, + }} + var raw map[string]any + if err := c.deps.Unary(ctx, servicePath+"/CreateConnector", req, &raw); err != nil { + return fmt.Errorf("connector create postgres password: %w", err) + } + var typed createResp + if err := cli.RenderOutput(stdio.Stdout, raw, cli.GlobalFrom(ctx).JSON, &typed, func(w io.Writer, t *createResp) error { + _, err := fmt.Fprintf(w, "connectorId: %d\nname: %s\nconnectorType: %s\n", + t.ConnectorID, t.Name, t.ConnectorType) + return err + }); err != nil { + return fmt.Errorf("connector create postgres password: %w", err) + } + return nil +} diff --git a/internal/connector/create_postgres_password_test.go b/internal/connector/create_postgres_password_test.go new file mode 100644 index 0000000..07c4749 --- /dev/null +++ b/internal/connector/create_postgres_password_test.go @@ -0,0 +1,331 @@ +package connector + +import ( + "bytes" + "context" + "errors" + "strings" + "testing" + + "github.com/highperformance-tech/ana-cli/internal/cli" + "github.com/highperformance-tech/ana-cli/internal/testcli" +) + +// requiredArgs returns the full dispatch args for `connector create postgres +// password ...` with every required flag set. Tests route through +// newCreateGroup so ancestor flags (--name/--ssl declared on the Postgres +// Group) get bound on the leaf's FlagSet the same way real dispatch does. +func requiredArgs() []string { + return []string{ + "postgres", "password", + "--name", "pg1", + "--host", "h", + "--port", "5432", + "--user", "u", + "--database", "d", + "--password", "p", + } +} + +// runCreate dispatches the Group end-to-end. Returns the out buffer so +// happy-path tests can assert on stdout. +func runCreate(t *testing.T, deps Deps, args []string, stdin string) (*bytes.Buffer, error) { + t.Helper() + g := newCreateGroup(deps) + stdio, out, _ := testcli.NewIO(strings.NewReader(stdin)) + return out, g.Run(context.Background(), args, stdio) +} + +func TestCreatePostgresPasswordHappy(t *testing.T) { + t.Parallel() + f := &fakeDeps{ + unaryFn: func(_ context.Context, _ string, _, resp any) error { + out := resp.(*map[string]any) + *out = map[string]any{"connectorId": 99.0, "name": "pg1", "connectorType": "POSTGRES"} + return nil + }, + } + out, err := runCreate(t, f.deps(), requiredArgs(), "") + if err != nil { + t.Fatalf("err=%v", err) + } + s := out.String() + if !strings.Contains(s, "connectorId: 99") || !strings.Contains(s, "name: pg1") { + t.Errorf("stdout=%q", s) + } + req := string(f.lastRawReq) + for _, want := range []string{ + `"connectorType":"POSTGRES"`, `"name":"pg1"`, + `"postgres":`, `"host":"h"`, `"port":5432`, `"user":"u"`, + `"password":"p"`, `"database":"d"`, + } { + if !strings.Contains(req, want) { + t.Errorf("req missing %s in %s", want, req) + } + } + if f.lastPath != servicePath+"/CreateConnector" { + t.Errorf("path=%s", f.lastPath) + } +} + +func TestCreatePostgresPasswordSSL(t *testing.T) { + t.Parallel() + f := &fakeDeps{ + unaryFn: func(_ context.Context, _ string, _, resp any) error { + out := resp.(*map[string]any) + *out = map[string]any{"connectorId": 1.0, "name": "pg1", "connectorType": "POSTGRES"} + return nil + }, + } + args := append(requiredArgs(), "--ssl") + _, err := runCreate(t, f.deps(), args, "") + if err != nil { + t.Fatalf("err=%v", err) + } + if !strings.Contains(string(f.lastRawReq), `"sslMode":true`) { + t.Errorf("--ssl should wire sslMode:true; req=%s", string(f.lastRawReq)) + } +} + +func TestCreatePostgresPasswordJSONBypass(t *testing.T) { + t.Parallel() + f := &fakeDeps{ + unaryFn: func(_ context.Context, _ string, _, resp any) error { + out := resp.(*map[string]any) + *out = map[string]any{"connectorId": 1.0, "name": "n", "connectorType": "POSTGRES"} + return nil + }, + } + // Wrap dispatch with ctx carrying JSON=true. Run the Group with that ctx. + ctx := cli.WithGlobal(context.Background(), cli.Global{JSON: true}) + g := newCreateGroup(f.deps()) + stdio, out, _ := testcli.NewIO(strings.NewReader("")) + if err := g.Run(ctx, requiredArgs(), stdio); err != nil { + t.Fatalf("err=%v", err) + } + if !strings.Contains(out.String(), "\"connectorId\"") { + t.Errorf("stdout=%q", out.String()) + } +} + +func TestCreatePostgresPasswordStdin(t *testing.T) { + t.Parallel() + f := &fakeDeps{} + args := []string{ + "postgres", "password", + "--name", "n", "--host", "h", + "--port", "5432", "--user", "u", "--database", "d", + "--password-stdin", + } + _, err := runCreate(t, f.deps(), args, "secret-line\n") + if err != nil { + t.Fatalf("err=%v", err) + } + if !strings.Contains(string(f.lastRawReq), `"password":"secret-line"`) { + t.Errorf("req=%s", string(f.lastRawReq)) + } +} + +func TestCreatePostgresPasswordStdinEmpty(t *testing.T) { + t.Parallel() + args := []string{ + "postgres", "password", + "--name", "n", "--host", "h", + "--port", "5432", "--user", "u", "--database", "d", + "--password-stdin", + } + _, err := runCreate(t, (&fakeDeps{}).deps(), args, "") + if !errors.Is(err, cli.ErrUsage) { + t.Errorf("err=%v", err) + } +} + +func TestCreatePostgresPasswordStdinReadErr(t *testing.T) { + t.Parallel() + args := []string{ + "postgres", "password", + "--name", "n", "--host", "h", + "--port", "5432", "--user", "u", "--database", "d", + "--password-stdin", + } + g := newCreateGroup((&fakeDeps{}).deps()) + stdio, _, _ := testcli.NewIO(errReader{err: errors.New("read fail")}) + err := g.Run(context.Background(), args, stdio) + if err == nil || !strings.Contains(err.Error(), "read fail") { + t.Errorf("err=%v", err) + } +} + +func TestResolvePasswordNilReader(t *testing.T) { + t.Parallel() + _, err := resolvePassword("", true, nil) + if err == nil { + t.Errorf("want error on nil reader") + } +} + +// TestResolvePasswordPreservesSurroundingWhitespace locks in the contract +// that stdin passwords are not silently trimmed: a credential can +// legitimately start or end with spaces or tabs, and mutating user-supplied +// bytes would cause hard-to-diagnose auth failures. cli.ReadPassword strips +// only the trailing line terminator (\n or \r\n) and nothing else. +func TestResolvePasswordPreservesSurroundingWhitespace(t *testing.T) { + t.Parallel() + got, err := resolvePassword("", true, strings.NewReader(" secret\twith\tabs \n")) + if err != nil { + t.Fatalf("resolvePassword: %v", err) + } + if want := " secret\twith\tabs "; got != want { + t.Errorf("resolvePassword=%q want %q", got, want) + } +} + +// TestResolvePasswordStripsCRLF verifies a Windows line terminator is +// stripped cleanly without swallowing any user bytes that precede it. +func TestResolvePasswordStripsCRLF(t *testing.T) { + t.Parallel() + got, err := resolvePassword("", true, strings.NewReader(" hunter2 \r\n")) + if err != nil { + t.Fatalf("resolvePassword: %v", err) + } + if want := " hunter2 "; got != want { + t.Errorf("resolvePassword=%q want %q", got, want) + } +} + +func TestCreatePostgresPasswordMissingPassword(t *testing.T) { + t.Parallel() + args := []string{ + "postgres", "password", + "--name", "n", "--host", "h", + "--port", "5432", "--user", "u", "--database", "d", + } + _, err := runCreate(t, (&fakeDeps{}).deps(), args, "") + if !errors.Is(err, cli.ErrUsage) { + t.Errorf("err=%v", err) + } +} + +func TestCreatePostgresPasswordMissingFlags(t *testing.T) { + t.Parallel() + args := []string{"postgres", "password"} + _, err := runCreate(t, (&fakeDeps{}).deps(), args, "") + if !errors.Is(err, cli.ErrUsage) { + t.Errorf("err=%v", err) + } +} + +func TestCreatePostgresPasswordEmptyString(t *testing.T) { + t.Parallel() + for _, flag := range []string{"name", "host", "user", "database"} { + t.Run(flag, func(t *testing.T) { + t.Parallel() + args := append(requiredArgs(), "--"+flag, "") + _, err := runCreate(t, (&fakeDeps{}).deps(), args, "") + if !errors.Is(err, cli.ErrUsage) || !strings.Contains(err.Error(), "--"+flag) { + t.Errorf("err=%v", err) + } + }) + } +} + +func TestCreatePostgresPasswordBadPort(t *testing.T) { + t.Parallel() + for _, port := range []string{"0", "-1", "70000"} { + t.Run(port, func(t *testing.T) { + t.Parallel() + args := []string{ + "postgres", "password", + "--name", "n", "--host", "h", + "--port", port, "--user", "u", "--database", "d", "--password", "p", + } + _, err := runCreate(t, (&fakeDeps{}).deps(), args, "") + if !errors.Is(err, cli.ErrUsage) { + t.Errorf("err=%v", err) + } + }) + } +} + +func TestCreatePostgresPasswordRenderWriteErr(t *testing.T) { + t.Parallel() + f := &fakeDeps{ + unaryFn: func(_ context.Context, _ string, _, resp any) error { + out := resp.(*map[string]any) + *out = map[string]any{"connectorId": 99.0, "name": "pg1", "connectorType": "POSTGRES"} + return nil + }, + } + g := newCreateGroup(f.deps()) + err := g.Run(context.Background(), requiredArgs(), testcli.FailingIO()) + if err == nil || !strings.Contains(err.Error(), "boom") { + t.Errorf("err=%v want boom", err) + } +} + +func TestCreatePostgresPasswordBadFlag(t *testing.T) { + t.Parallel() + args := []string{"postgres", "password", "--nope"} + _, err := runCreate(t, (&fakeDeps{}).deps(), args, "") + if !errors.Is(err, cli.ErrUsage) { + t.Errorf("err=%v", err) + } +} + +func TestCreatePostgresPasswordUnaryErr(t *testing.T) { + t.Parallel() + f := &fakeDeps{unaryFn: func(_ context.Context, _ string, _, _ any) error { return errors.New("boom") }} + _, err := runCreate(t, f.deps(), requiredArgs(), "") + if err == nil || !strings.Contains(err.Error(), "boom") { + t.Errorf("err=%v", err) + } +} + +func TestCreatePostgresPasswordRemarshalErr(t *testing.T) { + t.Parallel() + f := &fakeDeps{ + unaryFn: func(_ context.Context, _ string, _, resp any) error { + out := resp.(*map[string]any) + *out = map[string]any{"connectorId": "not-an-int"} + return nil + }, + } + _, err := runCreate(t, f.deps(), requiredArgs(), "") + if err == nil || !strings.Contains(err.Error(), "decode response") { + t.Errorf("err=%v", err) + } +} + +// TestCreateGroupUnknownDialect exercises the top-level create Group: an +// unknown dialect (and implicitly, an unknown auth-mode under a dialect) +// returns ErrUsage via cli.Group dispatch. +func TestCreateGroupUnknownDialect(t *testing.T) { + t.Parallel() + _, err := runCreate(t, (&fakeDeps{}).deps(), []string{"mysql"}, "") + if !errors.Is(err, cli.ErrUsage) { + t.Errorf("err=%v", err) + } +} + +// TestCreateGroupUnknownAuthMode mirrors the above but under a known dialect. +func TestCreateGroupUnknownAuthMode(t *testing.T) { + t.Parallel() + _, err := runCreate(t, (&fakeDeps{}).deps(), []string{"postgres", "certificate"}, "") + if !errors.Is(err, cli.ErrUsage) { + t.Errorf("err=%v", err) + } +} + +// TestCreateGroupHelpMentionsDialects — the `create` Group's Help() should +// list every registered dialect child so users discover them via +// `ana connector create --help`. +func TestCreateGroupHelpMentionsDialects(t *testing.T) { + t.Parallel() + g := newCreateGroup(Deps{}) + h := g.Help() + for _, d := range []string{"postgres"} { + if !strings.Contains(h, d) { + t.Errorf("create Help missing dialect %q: %q", d, h) + } + } +} diff --git a/internal/connector/create_test.go b/internal/connector/create_test.go deleted file mode 100644 index 6e1dc0f..0000000 --- a/internal/connector/create_test.go +++ /dev/null @@ -1,295 +0,0 @@ -package connector - -import ( - "context" - "errors" - "strings" - "testing" - - "github.com/highperformance-tech/ana-cli/internal/cli" - "github.com/highperformance-tech/ana-cli/internal/testcli" -) - -// requiredFlags is the minimal happy-path flag set for create. -func requiredFlags() []string { - return []string{ - "--type", "postgres", - "--name", "pg1", - "--host", "h", - "--port", "5432", - "--user", "u", - "--database", "d", - "--password", "p", - } -} - -func TestCreateHappy(t *testing.T) { - t.Parallel() - f := &fakeDeps{ - unaryFn: func(_ context.Context, _ string, _, resp any) error { - out := resp.(*map[string]any) - *out = map[string]any{"connectorId": 99.0, "name": "pg1", "connectorType": "POSTGRES"} - return nil - }, - } - cmd := &createCmd{deps: f.deps()} - stdio, out, _ := testcli.NewIO(strings.NewReader("")) - if err := cmd.Run(context.Background(), requiredFlags(), stdio); err != nil { - t.Fatalf("err=%v", err) - } - s := out.String() - if !strings.Contains(s, "connectorId: 99") || !strings.Contains(s, "name: pg1") { - t.Errorf("stdout=%q", s) - } - // Verify camelCase wire fields. - req := string(f.lastRawReq) - for _, want := range []string{ - `"connectorType":"POSTGRES"`, `"name":"pg1"`, - `"postgres":`, `"host":"h"`, `"port":5432`, `"user":"u"`, - `"password":"p"`, `"database":"d"`, - } { - if !strings.Contains(req, want) { - t.Errorf("req missing %s in %s", want, req) - } - } - if f.lastPath != servicePath+"/CreateConnector" { - t.Errorf("path=%s", f.lastPath) - } -} - -func TestCreateJSONBypass(t *testing.T) { - t.Parallel() - f := &fakeDeps{ - unaryFn: func(_ context.Context, _ string, _, resp any) error { - out := resp.(*map[string]any) - *out = map[string]any{"connectorId": 1.0, "name": "n", "connectorType": "POSTGRES"} - return nil - }, - } - cmd := &createCmd{deps: f.deps()} - ctx := cli.WithGlobal(context.Background(), cli.Global{JSON: true}) - stdio, out, _ := testcli.NewIO(strings.NewReader("")) - if err := cmd.Run(ctx, requiredFlags(), stdio); err != nil { - t.Fatalf("err=%v", err) - } - // JSON dump path — ensure table-style "connectorId:" formatting isn't used; - // raw JSON would have `"connectorId":` (with quotes). - if !strings.Contains(out.String(), "\"connectorId\"") { - t.Errorf("stdout=%q", out.String()) - } -} - -func TestCreatePasswordStdin(t *testing.T) { - t.Parallel() - f := &fakeDeps{} - cmd := &createCmd{deps: f.deps()} - args := []string{ - "--type", "postgres", "--name", "n", "--host", "h", - "--port", "5432", "--user", "u", "--database", "d", - "--password-stdin", - } - stdio, _, _ := testcli.NewIO(strings.NewReader("secret-line\n")) - if err := cmd.Run(context.Background(), args, stdio); err != nil { - t.Fatalf("err=%v", err) - } - if !strings.Contains(string(f.lastRawReq), `"password":"secret-line"`) { - t.Errorf("req=%s", string(f.lastRawReq)) - } -} - -func TestCreatePasswordStdinEmpty(t *testing.T) { - t.Parallel() - cmd := &createCmd{deps: (&fakeDeps{}).deps()} - args := []string{ - "--type", "postgres", "--name", "n", "--host", "h", - "--port", "5432", "--user", "u", "--database", "d", - "--password-stdin", - } - stdio, _, _ := testcli.NewIO(strings.NewReader("")) - err := cmd.Run(context.Background(), args, stdio) - if !errors.Is(err, cli.ErrUsage) { - t.Errorf("err=%v", err) - } -} - -func TestCreatePasswordStdinReadErr(t *testing.T) { - t.Parallel() - cmd := &createCmd{deps: (&fakeDeps{}).deps()} - args := []string{ - "--type", "postgres", "--name", "n", "--host", "h", - "--port", "5432", "--user", "u", "--database", "d", - "--password-stdin", - } - stdio, _, _ := testcli.NewIO(errReader{err: errors.New("read fail")}) - err := cmd.Run(context.Background(), args, stdio) - if err == nil || !strings.Contains(err.Error(), "read fail") { - t.Errorf("err=%v", err) - } -} - -func TestCreatePasswordStdinNilReader(t *testing.T) { - t.Parallel() - // resolvePassword directly, to exercise the nil-reader branch. - _, err := resolvePassword("", true, nil) - if err == nil { - t.Errorf("want error on nil reader") - } -} - -// TestResolvePassword_PreservesSurroundingWhitespace locks in the contract -// that stdin passwords are not silently trimmed: a real credential can -// legitimately start or end with spaces or tabs, and mutating the user-supplied -// bytes would cause hard-to-diagnose auth failures. resolvePassword calls -// cli.ReadPassword, which strips only the trailing line terminator (\n or -// \r\n) and nothing else. This supersedes the prior "TrimsSurroundingWhitespace" -// pin (commit 21a1af9) which had locked in unsafe behavior. -func TestResolvePassword_PreservesSurroundingWhitespace(t *testing.T) { - t.Parallel() - got, err := resolvePassword("", true, strings.NewReader(" secret\twith\tabs \n")) - if err != nil { - t.Fatalf("resolvePassword: %v", err) - } - if want := " secret\twith\tabs "; got != want { - t.Errorf("resolvePassword=%q want %q", got, want) - } -} - -// TestResolvePassword_StripsCRLF verifies a Windows line terminator is -// stripped cleanly without swallowing any user bytes that precede it. -func TestResolvePassword_StripsCRLF(t *testing.T) { - t.Parallel() - got, err := resolvePassword("", true, strings.NewReader(" hunter2 \r\n")) - if err != nil { - t.Fatalf("resolvePassword: %v", err) - } - if want := " hunter2 "; got != want { - t.Errorf("resolvePassword=%q want %q", got, want) - } -} - -func TestCreateMissingPassword(t *testing.T) { - t.Parallel() - cmd := &createCmd{deps: (&fakeDeps{}).deps()} - // Missing both --password and --password-stdin. - args := []string{ - "--type", "postgres", "--name", "n", "--host", "h", - "--port", "5432", "--user", "u", "--database", "d", - } - stdio, _, _ := testcli.NewIO(strings.NewReader("")) - err := cmd.Run(context.Background(), args, stdio) - if !errors.Is(err, cli.ErrUsage) { - t.Errorf("err=%v", err) - } -} - -func TestCreateWrongType(t *testing.T) { - t.Parallel() - cmd := &createCmd{deps: (&fakeDeps{}).deps()} - args := []string{"--type", "mysql", "--name", "n"} - stdio, _, _ := testcli.NewIO(strings.NewReader("")) - err := cmd.Run(context.Background(), args, stdio) - if !errors.Is(err, cli.ErrUsage) { - t.Errorf("err=%v", err) - } -} - -func TestCreateMissingFlags(t *testing.T) { - t.Parallel() - cmd := &createCmd{deps: (&fakeDeps{}).deps()} - // type is ok but everything else missing. - args := []string{"--type", "postgres"} - stdio, _, _ := testcli.NewIO(strings.NewReader("")) - err := cmd.Run(context.Background(), args, stdio) - if !errors.Is(err, cli.ErrUsage) { - t.Errorf("err=%v", err) - } -} - -func TestCreateEmptyString(t *testing.T) { - t.Parallel() - for _, flag := range []string{"name", "host", "user", "database"} { - t.Run(flag, func(t *testing.T) { - t.Parallel() - cmd := &createCmd{deps: (&fakeDeps{}).deps()} - args := append(requiredFlags(), "--"+flag, "") - stdio, _, _ := testcli.NewIO(strings.NewReader("")) - err := cmd.Run(context.Background(), args, stdio) - if !errors.Is(err, cli.ErrUsage) || !strings.Contains(err.Error(), "--"+flag) { - t.Errorf("err=%v", err) - } - }) - } -} - -func TestCreateBadPort(t *testing.T) { - t.Parallel() - for _, port := range []string{"0", "-1", "70000"} { - t.Run(port, func(t *testing.T) { - t.Parallel() - cmd := &createCmd{deps: (&fakeDeps{}).deps()} - args := []string{ - "--type", "postgres", "--name", "n", "--host", "h", - "--port", port, "--user", "u", "--database", "d", "--password", "p", - } - stdio, _, _ := testcli.NewIO(strings.NewReader("")) - err := cmd.Run(context.Background(), args, stdio) - if !errors.Is(err, cli.ErrUsage) { - t.Errorf("err=%v", err) - } - }) - } -} - -func TestCreateRenderWriteErr(t *testing.T) { - t.Parallel() - f := &fakeDeps{ - unaryFn: func(_ context.Context, _ string, _, resp any) error { - out := resp.(*map[string]any) - *out = map[string]any{"connectorId": 99.0, "name": "pg1", "connectorType": "POSTGRES"} - return nil - }, - } - cmd := &createCmd{deps: f.deps()} - err := cmd.Run(context.Background(), requiredFlags(), testcli.FailingIO()) - if err == nil || !strings.Contains(err.Error(), "boom") { - t.Errorf("err=%v want boom", err) - } -} - -func TestCreateBadFlag(t *testing.T) { - t.Parallel() - cmd := &createCmd{deps: (&fakeDeps{}).deps()} - stdio, _, _ := testcli.NewIO(strings.NewReader("")) - err := cmd.Run(context.Background(), []string{"--nope"}, stdio) - if !errors.Is(err, cli.ErrUsage) { - t.Errorf("err=%v", err) - } -} - -func TestCreateUnaryErr(t *testing.T) { - t.Parallel() - f := &fakeDeps{unaryFn: func(_ context.Context, _ string, _, _ any) error { return errors.New("boom") }} - cmd := &createCmd{deps: f.deps()} - stdio, _, _ := testcli.NewIO(strings.NewReader("")) - err := cmd.Run(context.Background(), requiredFlags(), stdio) - if err == nil || !strings.Contains(err.Error(), "boom") { - t.Errorf("err=%v", err) - } -} - -func TestCreateRemarshalErr(t *testing.T) { - t.Parallel() - f := &fakeDeps{ - unaryFn: func(_ context.Context, _ string, _, resp any) error { - out := resp.(*map[string]any) - *out = map[string]any{"connectorId": "not-an-int"} - return nil - }, - } - cmd := &createCmd{deps: f.deps()} - stdio, _, _ := testcli.NewIO(strings.NewReader("")) - err := cmd.Run(context.Background(), requiredFlags(), stdio) - if err == nil || !strings.Contains(err.Error(), "decode response") { - t.Errorf("err=%v", err) - } -}