mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-08-15 11:14:31 +08:00
- 新增无头运行时及连接、查询、导出、批处理、审计和 MCP 命令 - 复用活动数据根、密文存储与跨进程锁,落实写入安全和取消语义 - 增加六平台 CLI 归档、独立校验和、Docker、npm 与 WinGet 分发 - 隔离 GUI/CLI 更新资产并强化 macOS 签名与公证门禁 - 补充并发、审计、事务及发布契约回归测试 Refs #902
773 lines
31 KiB
Go
773 lines
31 KiB
Go
package cli
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
appcore "GoNavi-Wails/internal/app"
|
|
"GoNavi-Wails/internal/connection"
|
|
"GoNavi-Wails/internal/mcpserver"
|
|
"GoNavi-Wails/internal/sqlaudit"
|
|
)
|
|
|
|
type fakeBackend struct {
|
|
closed bool
|
|
saveCalls int
|
|
resolveCalls int
|
|
|
|
connections []connection.SavedConnectionView
|
|
resolveErr error
|
|
queryResult connection.QueryResult
|
|
batchResult connection.QueryResult
|
|
|
|
queryConfig connection.ConnectionConfig
|
|
querySQL string
|
|
queryOptions appcore.HeadlessQueryOptions
|
|
savedConnectionParams string
|
|
batchConfig connection.ConnectionConfig
|
|
batchFile string
|
|
batchOptions appcore.HeadlessSQLFileOptions
|
|
auditFilter sqlaudit.Filter
|
|
auditFormat string
|
|
auditPath string
|
|
auditOverwrite bool
|
|
}
|
|
|
|
func (backend *fakeBackend) Close() { backend.closed = true }
|
|
|
|
func (backend *fakeBackend) GetSavedConnections() ([]connection.SavedConnectionView, error) {
|
|
return backend.connections, nil
|
|
}
|
|
|
|
func (backend *fakeBackend) SaveConnection(input connection.SavedConnectionInput) (connection.SavedConnectionView, error) {
|
|
backend.saveCalls++
|
|
backend.savedConnectionParams = input.Config.ConnectionParams
|
|
return connection.SavedConnectionView{ID: input.ID, Name: input.Name, Config: input.Config}, nil
|
|
}
|
|
|
|
func (backend *fakeBackend) ImportLegacyConnections(items []connection.LegacySavedConnection) ([]connection.SavedConnectionView, error) {
|
|
result := make([]connection.SavedConnectionView, 0, len(items))
|
|
for _, item := range items {
|
|
result = append(result, connection.SavedConnectionView{ID: item.ID, Name: item.Name, Config: item.Config})
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (backend *fakeBackend) ResolveSavedConnection(selector string) (connection.SavedConnectionView, error) {
|
|
backend.resolveCalls++
|
|
if backend.resolveErr != nil {
|
|
return connection.SavedConnectionView{}, backend.resolveErr
|
|
}
|
|
for _, item := range backend.connections {
|
|
if item.ID == selector || item.Name == selector {
|
|
return item, nil
|
|
}
|
|
}
|
|
return connection.SavedConnectionView{}, errors.New("saved connection not found")
|
|
}
|
|
|
|
func (backend *fakeBackend) Query(_ context.Context, config connection.ConnectionConfig, _ string, sql string, options appcore.HeadlessQueryOptions) connection.QueryResult {
|
|
backend.queryConfig = config
|
|
backend.querySQL = sql
|
|
backend.queryOptions = options
|
|
return backend.queryResult
|
|
}
|
|
|
|
func (backend *fakeBackend) ExportQueryToPath(context.Context, connection.ConnectionConfig, string, string, string, appcore.ExportFileOptions, bool) connection.QueryResult {
|
|
return connection.QueryResult{Success: true}
|
|
}
|
|
|
|
func (backend *fakeBackend) ExecuteSQLFile(_ context.Context, config connection.ConnectionConfig, _ string, filePath string, options appcore.HeadlessSQLFileOptions) connection.QueryResult {
|
|
backend.batchConfig = config
|
|
backend.batchFile = filePath
|
|
backend.batchOptions = options
|
|
return backend.batchResult
|
|
}
|
|
|
|
func (backend *fakeBackend) ExportSQLAuditToPath(filter sqlaudit.Filter, format string, path string, overwrite bool) connection.QueryResult {
|
|
backend.auditFilter = filter
|
|
backend.auditFormat = format
|
|
backend.auditPath = path
|
|
backend.auditOverwrite = overwrite
|
|
return connection.QueryResult{Success: true}
|
|
}
|
|
|
|
func runWithBackend(t *testing.T, fake *fakeBackend, args ...string) (int, string, string) {
|
|
t.Helper()
|
|
previous := newBackend
|
|
newBackend = func(context.Context, appcore.HeadlessRuntimeOptions) (backend, error) {
|
|
return fake, nil
|
|
}
|
|
t.Cleanup(func() { newBackend = previous })
|
|
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
code := Run(context.Background(), args, &stdout, &stderr)
|
|
return code, stdout.String(), stderr.String()
|
|
}
|
|
|
|
func TestRunVersionAcceptsFlagForm(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
if code := Run(context.Background(), []string{"--version"}, &stdout, &stderr); code != ExitSuccess {
|
|
t.Fatalf("Run(--version) = %d, stderr=%s", code, stderr.String())
|
|
}
|
|
if !strings.Contains(stdout.String(), `"version":"`) {
|
|
t.Fatalf("version output missing JSON payload: %s", stdout.String())
|
|
}
|
|
}
|
|
|
|
func TestRunVersionRejectsExtraArguments(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
if code := Run(context.Background(), []string{"version", "extra"}, &stdout, &stderr); code != ExitUsage {
|
|
t.Fatalf("Run(version extra) = %d, stderr=%s, want usage exit=%d", code, stderr.String(), ExitUsage)
|
|
}
|
|
if stdout.Len() != 0 || !strings.Contains(stderr.String(), `"code":"usage"`) {
|
|
t.Fatalf("version extra output mismatch: stdout=%q stderr=%q", stdout.String(), stderr.String())
|
|
}
|
|
}
|
|
|
|
func TestRunAuditWithoutSubcommandRejectsBeforeBackendInitialization(t *testing.T) {
|
|
previous := newBackend
|
|
started := false
|
|
newBackend = func(context.Context, appcore.HeadlessRuntimeOptions) (backend, error) {
|
|
started = true
|
|
return nil, errors.New("runtime should not start for invalid audit command")
|
|
}
|
|
t.Cleanup(func() { newBackend = previous })
|
|
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
code := Run(context.Background(), []string{"audit"}, &stdout, &stderr)
|
|
if code != ExitUsage || started || stdout.Len() != 0 || !strings.Contains(stderr.String(), `"code":"usage"`) {
|
|
t.Fatalf("audit exit=%d started=%t stdout=%q stderr=%q", code, started, stdout.String(), stderr.String())
|
|
}
|
|
}
|
|
|
|
func TestRunCommandHelpSkipsRuntimeInitialization(t *testing.T) {
|
|
previous := newBackend
|
|
started := false
|
|
newBackend = func(context.Context, appcore.HeadlessRuntimeOptions) (backend, error) {
|
|
started = true
|
|
return nil, errors.New("runtime should not start for help")
|
|
}
|
|
t.Cleanup(func() { newBackend = previous })
|
|
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
code := Run(context.Background(), []string{"query", "--help"}, &stdout, &stderr)
|
|
if code != ExitSuccess || started || !strings.Contains(stdout.String(), "Usage: gonavi query") {
|
|
t.Fatalf("help exit=%d started=%t stdout=%s stderr=%s", code, started, stdout.String(), stderr.String())
|
|
}
|
|
}
|
|
|
|
func TestRunDataRootOverrideUsesActiveRootResolution(t *testing.T) {
|
|
root := t.TempDir()
|
|
t.Setenv("GONAVI_DATA_ROOT", "existing-root")
|
|
|
|
previous := newBackend
|
|
var receivedOptions appcore.HeadlessRuntimeOptions
|
|
var receivedRoot string
|
|
newBackend = func(_ context.Context, options appcore.HeadlessRuntimeOptions) (backend, error) {
|
|
receivedOptions = options
|
|
receivedRoot = os.Getenv("GONAVI_DATA_ROOT")
|
|
return &fakeBackend{}, nil
|
|
}
|
|
t.Cleanup(func() { newBackend = previous })
|
|
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
if code := Run(context.Background(), []string{"--data-root", root, "list-connections"}, &stdout, &stderr); code != ExitSuccess {
|
|
t.Fatalf("Run returned %d, stderr=%s", code, stderr.String())
|
|
}
|
|
if receivedOptions.DataRoot != "" {
|
|
t.Fatalf("CLI bypassed ResolveActiveRoot with DataRoot=%q", receivedOptions.DataRoot)
|
|
}
|
|
if receivedRoot != root {
|
|
t.Fatalf("GONAVI_DATA_ROOT during backend initialization = %q, want %q", receivedRoot, root)
|
|
}
|
|
if restored := os.Getenv("GONAVI_DATA_ROOT"); restored != "existing-root" {
|
|
t.Fatalf("GONAVI_DATA_ROOT after invocation = %q, want existing-root", restored)
|
|
}
|
|
}
|
|
|
|
func TestRunQueryForwardsMutatingAcknowledgementAndTimeout(t *testing.T) {
|
|
fake := &fakeBackend{
|
|
connections: []connection.SavedConnectionView{{ID: "conn-1", Name: "production", Config: connection.ConnectionConfig{ID: "conn-1", Type: "mysql"}}},
|
|
queryResult: connection.QueryResult{Success: true, Data: []connection.ResultSetData{{Columns: []string{"id"}, Rows: []map[string]any{{"id": 1}}}}},
|
|
}
|
|
code, stdout, stderr := runWithBackend(t, fake,
|
|
"query", "--conn", "production", "--allow-mutating", "--query-timeout", "17", "UPDATE account SET active = 1",
|
|
)
|
|
if code != ExitSuccess {
|
|
t.Fatalf("query exit = %d, stderr=%s", code, stderr)
|
|
}
|
|
if !fake.queryOptions.AllowMutating || fake.queryConfig.QueryTimeout != 17 || !strings.Contains(fake.querySQL, "UPDATE") {
|
|
t.Fatalf("query options not forwarded: %#v, %#v, %q", fake.queryOptions, fake.queryConfig, fake.querySQL)
|
|
}
|
|
if !strings.Contains(stdout, `"success":true`) {
|
|
t.Fatalf("query stdout missing result: %s", stdout)
|
|
}
|
|
}
|
|
|
|
func TestRunQueryDefaultsToJSONLResultSetsRowsAndSummary(t *testing.T) {
|
|
fake := &fakeBackend{
|
|
connections: []connection.SavedConnectionView{{ID: "conn-1", Name: "production", Config: connection.ConnectionConfig{ID: "conn-1", Type: "mysql"}}},
|
|
queryResult: connection.QueryResult{
|
|
Success: true,
|
|
QueryID: "query-1",
|
|
Data: []connection.ResultSetData{
|
|
{Columns: []string{"id"}, Rows: []map[string]any{{"id": 1}}},
|
|
{Columns: []string{"name"}, Rows: []map[string]any{{"name": "GoNavi"}}},
|
|
},
|
|
},
|
|
}
|
|
code, stdout, stderr := runWithBackend(t, fake, "query", "--conn", "production", "SELECT 1; SELECT 'GoNavi'")
|
|
if code != ExitSuccess {
|
|
t.Fatalf("query exit = %d, stderr=%s", code, stderr)
|
|
}
|
|
lines := strings.Split(strings.TrimSpace(stdout), "\n")
|
|
if len(lines) != 5 {
|
|
t.Fatalf("JSONL lines = %d, want 5: %s", len(lines), stdout)
|
|
}
|
|
types := make([]string, 0, len(lines))
|
|
for _, line := range lines {
|
|
var event map[string]any
|
|
if err := json.Unmarshal([]byte(line), &event); err != nil {
|
|
t.Fatalf("invalid JSONL event %q: %v", line, err)
|
|
}
|
|
types = append(types, event["type"].(string))
|
|
}
|
|
if got, want := strings.Join(types, ","), "result_set,row,result_set,row,summary"; got != want {
|
|
t.Fatalf("event order = %s, want %s", got, want)
|
|
}
|
|
if !strings.Contains(lines[4], `"queryId":"query-1"`) || !strings.Contains(lines[4], `"resultSets":2`) || !strings.Contains(lines[4], `"rows":2`) {
|
|
t.Fatalf("summary is incomplete: %s", lines[4])
|
|
}
|
|
}
|
|
|
|
func TestRunQueryJSONLSummarizesSuccessfulNonTabularWrite(t *testing.T) {
|
|
fake := &fakeBackend{
|
|
connections: []connection.SavedConnectionView{{ID: "conn-1", Name: "production", Config: connection.ConnectionConfig{ID: "conn-1", Type: "mysql"}}},
|
|
queryResult: connection.QueryResult{
|
|
Success: true,
|
|
QueryID: "write-1",
|
|
Data: map[string]int64{"affectedRows": 3},
|
|
},
|
|
}
|
|
code, stdout, stderr := runWithBackend(t, fake, "query", "--conn", "production", "--allow-write", "UPDATE account SET active = 1")
|
|
if code != ExitSuccess {
|
|
t.Fatalf("query exit = %d, stderr=%s", code, stderr)
|
|
}
|
|
var summary map[string]any
|
|
if err := json.Unmarshal([]byte(strings.TrimSpace(stdout)), &summary); err != nil {
|
|
t.Fatalf("non-tabular JSONL summary is invalid: %v; stdout=%s", err, stdout)
|
|
}
|
|
if summary["type"] != "summary" || summary["success"] != true || summary["queryId"] != "write-1" {
|
|
t.Fatalf("unexpected non-tabular summary: %#v", summary)
|
|
}
|
|
if summary["resultSets"] != float64(0) || summary["rows"] != float64(0) {
|
|
t.Fatalf("unexpected non-tabular counts: %#v", summary)
|
|
}
|
|
data, ok := summary["data"].(map[string]any)
|
|
if !ok || data["affectedRows"] != float64(3) {
|
|
t.Fatalf("affectedRows metadata missing from summary: %#v", summary["data"])
|
|
}
|
|
}
|
|
|
|
func TestRunQueryFormatJSONKeepsEnvelopeCompatibility(t *testing.T) {
|
|
fake := &fakeBackend{
|
|
connections: []connection.SavedConnectionView{{ID: "conn-1", Name: "production"}},
|
|
queryResult: connection.QueryResult{Success: true, Data: []connection.ResultSetData{{Columns: []string{"id"}, Rows: []map[string]any{{"id": 1}}}}},
|
|
}
|
|
code, stdout, stderr := runWithBackend(t, fake, "query", "--conn", "production", "--format", "json", "SELECT 1")
|
|
if code != ExitSuccess {
|
|
t.Fatalf("query exit = %d, stderr=%s", code, stderr)
|
|
}
|
|
if !strings.Contains(stdout, `"success":true`) || !strings.Contains(stdout, `"data"`) || strings.Contains(stdout, `"type":"summary"`) {
|
|
t.Fatalf("json envelope changed unexpectedly: %s", stdout)
|
|
}
|
|
}
|
|
|
|
func TestRunQueryAcceptsAllowWriteAndLegacyAlias(t *testing.T) {
|
|
for _, flagName := range []string{"--allow-write", "--allow-mutating"} {
|
|
t.Run(flagName, func(t *testing.T) {
|
|
fake := &fakeBackend{
|
|
connections: []connection.SavedConnectionView{{ID: "conn-1", Name: "production"}},
|
|
queryResult: connection.QueryResult{Success: true, Data: []connection.ResultSetData{}},
|
|
}
|
|
code, _, stderr := runWithBackend(t, fake, "query", "--conn", "production", flagName, "UPDATE account SET active = 1")
|
|
if code != ExitSuccess || !fake.queryOptions.AllowMutating {
|
|
t.Fatalf("query exit=%d allow=%t stderr=%s", code, fake.queryOptions.AllowMutating, stderr)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRunQueryUsesTemporaryConnectionFileWithoutSavedConnectionLookup(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "temporary-connection.json")
|
|
contents := []byte(`{"type":"postgres","host":"db.example.test","port":5432,"user":"cli","password":"temporary-secret","database":"app"}`)
|
|
if err := os.WriteFile(path, contents, 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.Chmod(path, 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
fake := &fakeBackend{
|
|
queryResult: connection.QueryResult{Success: true, Data: []connection.ResultSetData{}},
|
|
}
|
|
code, _, stderr := runWithBackend(t, fake, "query", "--connection-file", path, "SELECT 1")
|
|
if code != ExitSuccess {
|
|
t.Fatalf("query exit=%d stderr=%s", code, stderr)
|
|
}
|
|
if fake.queryConfig.ID != "" || fake.queryConfig.SavePassword || fake.queryConfig.Password != "temporary-secret" || fake.queryConfig.Type != "postgres" {
|
|
t.Fatalf("temporary config was not isolated: %#v", fake.queryConfig)
|
|
}
|
|
if fake.resolveCalls != 0 || fake.saveCalls != 0 {
|
|
t.Fatalf("temporary config touched saved connections: resolve=%d save=%d", fake.resolveCalls, fake.saveCalls)
|
|
}
|
|
}
|
|
|
|
func TestRunConnectionAddKeepsSensitiveConnectionParamsOutOfArgv(t *testing.T) {
|
|
t.Run("rejects sensitive argv parameters without leaking them", func(t *testing.T) {
|
|
fake := &fakeBackend{}
|
|
code, _, stderr := runWithBackend(t, fake,
|
|
"connection", "add", "--name", "production", "--type", "postgres",
|
|
"--connection-params", "application_name=gonavi&password=argv-secret",
|
|
)
|
|
if code != ExitUsage || fake.saveCalls != 0 {
|
|
t.Fatalf("connection add exit=%d saves=%d stderr=%s", code, fake.saveCalls, stderr)
|
|
}
|
|
if !strings.Contains(stderr, "--connection-params-env") || strings.Contains(stderr, "argv-secret") {
|
|
t.Fatalf("sensitive argv rejection leaked or omitted remediation: %s", stderr)
|
|
}
|
|
})
|
|
|
|
t.Run("accepts public argv parameters", func(t *testing.T) {
|
|
fake := &fakeBackend{}
|
|
code, _, stderr := runWithBackend(t, fake,
|
|
"connection", "add", "--name", "production", "--type", "postgres",
|
|
"--connection-params", "application_name=gonavi&connect_timeout=10",
|
|
)
|
|
if code != ExitSuccess || fake.saveCalls != 1 {
|
|
t.Fatalf("connection add exit=%d saves=%d stderr=%s", code, fake.saveCalls, stderr)
|
|
}
|
|
})
|
|
|
|
t.Run("accepts complete sensitive parameters from environment", func(t *testing.T) {
|
|
t.Setenv("GONAVI_CLI_CONNECTION_PARAMS", "application_name=gonavi&password=environment-secret")
|
|
fake := &fakeBackend{}
|
|
code, _, stderr := runWithBackend(t, fake,
|
|
"connection", "add", "--name", "production", "--type", "postgres",
|
|
"--connection-params-env", "GONAVI_CLI_CONNECTION_PARAMS",
|
|
)
|
|
if code != ExitSuccess || fake.saveCalls != 1 {
|
|
t.Fatalf("connection add exit=%d saves=%d stderr=%s", code, fake.saveCalls, stderr)
|
|
}
|
|
if got := fake.savedConnectionParams; got != "application_name=gonavi&password=environment-secret" {
|
|
t.Fatalf("connection parameters from environment = %q", got)
|
|
}
|
|
})
|
|
|
|
t.Run("rejects conflicting direct and environment sources", func(t *testing.T) {
|
|
t.Setenv("GONAVI_CLI_CONNECTION_PARAMS", "password=environment-secret")
|
|
fake := &fakeBackend{}
|
|
code, _, stderr := runWithBackend(t, fake,
|
|
"connection", "add", "--name", "production", "--type", "postgres",
|
|
"--connection-params", "application_name=gonavi",
|
|
"--connection-params-env", "GONAVI_CLI_CONNECTION_PARAMS",
|
|
)
|
|
if code != ExitUsage || fake.saveCalls != 0 || !strings.Contains(stderr, "either --connection-params or --connection-params-env") {
|
|
t.Fatalf("conflicting parameter sources exit=%d saves=%d stderr=%s", code, fake.saveCalls, stderr)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestLoadTemporaryConnectionConfigRejectsInsecurePermissions(t *testing.T) {
|
|
if cliGOOS() == "windows" {
|
|
t.Skip("Windows ACLs are not represented by os.FileMode")
|
|
}
|
|
path := filepath.Join(t.TempDir(), "temporary-connection.json")
|
|
if err := os.WriteFile(path, []byte(`{"type":"mysql"}`), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.Chmod(path, 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_, err := loadTemporaryConnectionConfig(path)
|
|
if err == nil || !strings.Contains(err.Error(), "permissions") {
|
|
t.Fatalf("loadTemporaryConnectionConfig error = %v, want permission rejection", err)
|
|
}
|
|
|
|
fake := &fakeBackend{}
|
|
code, _, stderr := runWithBackend(t, fake, "query", "--connection-file", path, "SELECT 1")
|
|
if code != ExitConnection || fake.querySQL != "" || !strings.Contains(stderr, `"code":"connection_file_invalid"`) {
|
|
t.Fatalf("insecure connection file exit=%d sql=%q stderr=%s", code, fake.querySQL, stderr)
|
|
}
|
|
}
|
|
|
|
func TestLoadTemporaryConnectionConfigAccepts0600AndRejectsSymlink(t *testing.T) {
|
|
if cliGOOS() == "windows" {
|
|
t.Skip("Windows ACLs and symlinks differ from POSIX mode checks")
|
|
}
|
|
directory := t.TempDir()
|
|
path := filepath.Join(directory, "temporary-connection.json")
|
|
if err := os.WriteFile(path, []byte(`{"type":"mysql","password":"temporary-secret"}`), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.Chmod(path, 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
config, err := loadTemporaryConnectionConfig(path)
|
|
if err != nil || config.Password != "temporary-secret" {
|
|
t.Fatalf("0600 connection file = %#v, %v", config, err)
|
|
}
|
|
|
|
linkPath := filepath.Join(directory, "connection-link.json")
|
|
if err := os.Symlink(path, linkPath); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := loadTemporaryConnectionConfig(linkPath); err == nil || !strings.Contains(err.Error(), "symbolic link") {
|
|
t.Fatalf("symlink error = %v, want rejection", err)
|
|
}
|
|
}
|
|
|
|
func TestRunQueryRejectsConnectionSelectorAndFileTogether(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "temporary-connection.json")
|
|
if err := os.WriteFile(path, []byte(`{"type":"mysql"}`), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.Chmod(path, 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
fake := &fakeBackend{}
|
|
code, _, stderr := runWithBackend(t, fake, "query", "--conn", "saved", "--connection-file", path, "SELECT 1")
|
|
if code != ExitUsage || fake.querySQL != "" || fake.resolveCalls != 0 {
|
|
t.Fatalf("query exit=%d sql=%q resolve=%d stderr=%s", code, fake.querySQL, fake.resolveCalls, stderr)
|
|
}
|
|
if !strings.Contains(stderr, `"code":"usage"`) {
|
|
t.Fatalf("connection source conflict was not a usage error: %s", stderr)
|
|
}
|
|
}
|
|
|
|
func TestRunHelpUsesAllowWriteAsPrimaryFlag(t *testing.T) {
|
|
for _, args := range [][]string{{"query", "--help"}, {"batch", "--help"}} {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
if code := Run(context.Background(), args, &stdout, &stderr); code != ExitSuccess {
|
|
t.Fatalf("Run(%v) = %d, stderr=%s", args, code, stderr.String())
|
|
}
|
|
if !strings.Contains(stdout.String(), "--allow-write") || strings.Contains(stdout.String(), "--allow-mutating") {
|
|
t.Fatalf("help did not make --allow-write primary: %s", stdout.String())
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRunQueryRejectsInvalidFormatBeforeExecution(t *testing.T) {
|
|
fake := &fakeBackend{connections: []connection.SavedConnectionView{{ID: "conn-1", Name: "production"}}}
|
|
code, _, stderr := runWithBackend(t, fake, "query", "--conn", "production", "--format", "xlsx", "SELECT 1")
|
|
if code != ExitUsage || fake.querySQL != "" {
|
|
t.Fatalf("query exit=%d sql=%q stderr=%s", code, fake.querySQL, stderr)
|
|
}
|
|
}
|
|
|
|
func TestRunQueryReportsAmbiguousConnectionWithoutExecutingSQL(t *testing.T) {
|
|
fake := &fakeBackend{
|
|
resolveErr: &appcore.AmbiguousConnectionNameError{Name: "production", IDs: []string{"conn-1", "conn-2"}},
|
|
}
|
|
code, _, stderr := runWithBackend(t, fake, "query", "--conn", "production", "SELECT 1")
|
|
if code != ExitConnection || fake.querySQL != "" {
|
|
t.Fatalf("query exit=%d sql=%q stderr=%s", code, fake.querySQL, stderr)
|
|
}
|
|
if !strings.Contains(stderr, `"code":"connection_ambiguous"`) || !strings.Contains(stderr, "conn-1") || !strings.Contains(stderr, "conn-2") {
|
|
t.Fatalf("ambiguous connection report lost structured candidates: %s", stderr)
|
|
}
|
|
}
|
|
|
|
func TestRunQuerySanitizesFailure(t *testing.T) {
|
|
fake := &fakeBackend{
|
|
connections: []connection.SavedConnectionView{{ID: "conn-1", Name: "production"}},
|
|
queryResult: connection.QueryResult{Success: false, Message: "connect postgres://alice:driver-secret@example.test/db password=top-secret"},
|
|
}
|
|
code, _, stderr := runWithBackend(t, fake, "query", "--conn", "production", "SELECT 1")
|
|
if code != ExitExecution {
|
|
t.Fatalf("query exit = %d, stderr=%s", code, stderr)
|
|
}
|
|
if strings.Contains(stderr, "driver-secret") || strings.Contains(stderr, "top-secret") {
|
|
t.Fatalf("secret leaked in stderr: %s", stderr)
|
|
}
|
|
}
|
|
|
|
func TestRunQueryMapsStructuredPolicyFailureToExitFour(t *testing.T) {
|
|
fake := &fakeBackend{
|
|
connections: []connection.SavedConnectionView{{ID: "conn-1", Name: "production"}},
|
|
queryResult: connection.QueryResult{
|
|
Success: false,
|
|
Message: "SQL is blocked by AI safety level readonly",
|
|
Data: map[string]any{"errorKind": "policy"},
|
|
},
|
|
}
|
|
code, _, stderr := runWithBackend(t, fake, "query", "--conn", "production", "--allow-write", "UPDATE account SET active = 1")
|
|
if code != ExitPolicyDenied || !strings.Contains(stderr, `"code":"policy_denied"`) {
|
|
t.Fatalf("query policy exit=%d stderr=%s", code, stderr)
|
|
}
|
|
}
|
|
|
|
func TestRunBatchRequiresAcknowledgementBeforeFileOrConnectionAccess(t *testing.T) {
|
|
fake := &fakeBackend{}
|
|
code, _, stderr := runWithBackend(t, fake, "batch", "--conn", "production", "--file", "/missing.sql")
|
|
if code != ExitPolicyDenied {
|
|
t.Fatalf("batch exit = %d, stderr=%s", code, stderr)
|
|
}
|
|
if fake.batchFile != "" {
|
|
t.Fatalf("batch unexpectedly executed %q", fake.batchFile)
|
|
}
|
|
}
|
|
|
|
func TestRunBatchUnknownOutcomeHasDedicatedExitCode(t *testing.T) {
|
|
directory := t.TempDir()
|
|
filePath := filepath.Join(directory, "migration.sql")
|
|
if err := os.WriteFile(filePath, []byte("UPDATE account SET active = 1;"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
fake := &fakeBackend{
|
|
connections: []connection.SavedConnectionView{{ID: "conn-1", Name: "production", Config: connection.ConnectionConfig{ID: "conn-1"}}},
|
|
batchResult: connection.QueryResult{Success: false, Message: "connection dropped after dispatch", Data: map[string]any{"outcomeUnknown": true}},
|
|
}
|
|
code, _, stderr := runWithBackend(t, fake, "batch", "--conn", "production", "--file", filePath, "--allow-mutating", "--stop-on-error")
|
|
if code != ExitUnknownOutcome || !strings.Contains(stderr, `"code":"outcome_unknown"`) {
|
|
t.Fatalf("batch exit=%d stderr=%s", code, stderr)
|
|
}
|
|
if !fake.batchOptions.AllowMutating || fake.batchOptions.TransactionMode != appcore.HeadlessSQLTransactionModeSingle || fake.batchFile != filePath {
|
|
t.Fatalf("batch options not forwarded: %#v file=%q", fake.batchOptions, fake.batchFile)
|
|
}
|
|
}
|
|
|
|
func TestFailResultUsesStructuredCancellationAndPreservesUnknownOutcomePriority(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
data map[string]any
|
|
wantExit int
|
|
wantCode string
|
|
}{
|
|
{
|
|
name: "cancelled",
|
|
data: map[string]any{"cancelled": true},
|
|
wantExit: ExitCancelled,
|
|
wantCode: `"code":"cancelled"`,
|
|
},
|
|
{
|
|
name: "unknown outcome after cancellation",
|
|
data: map[string]any{"cancelled": true, "outcomeUnknown": true},
|
|
wantExit: ExitUnknownOutcome,
|
|
wantCode: `"code":"outcome_unknown"`,
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
var stderr bytes.Buffer
|
|
code := failResult(context.Background(), &stderr, connection.QueryResult{
|
|
Success: false,
|
|
Message: "执行已取消",
|
|
Data: test.data,
|
|
})
|
|
if code != test.wantExit || !strings.Contains(stderr.String(), test.wantCode) {
|
|
t.Fatalf("failResult exit=%d stderr=%s, want exit=%d code=%s", code, stderr.String(), test.wantExit, test.wantCode)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestFailResultPrefersStructuredPolicyOverCancellationText(t *testing.T) {
|
|
var stderr bytes.Buffer
|
|
result := connection.QueryResult{
|
|
Success: false,
|
|
Message: "policy denied: cancellation command is not permitted",
|
|
Data: map[string]any{"errorKind": "policy"},
|
|
}
|
|
if code := failResult(context.Background(), &stderr, result); code != ExitPolicyDenied {
|
|
t.Fatalf("failResult exit=%d stderr=%s, want policy exit=%d", code, stderr.String(), ExitPolicyDenied)
|
|
}
|
|
if !strings.Contains(stderr.String(), `"code":"policy_denied"`) {
|
|
t.Fatalf("structured policy code missing: %s", stderr.String())
|
|
}
|
|
}
|
|
|
|
func TestFailResultMapsStructuredConnectionFailure(t *testing.T) {
|
|
var stderr bytes.Buffer
|
|
result := connection.QueryResult{
|
|
Success: false,
|
|
Message: "authentication failed",
|
|
Data: map[string]any{"errorKind": "connection"},
|
|
}
|
|
if code := failResult(context.Background(), &stderr, result); code != ExitConnection {
|
|
t.Fatalf("failResult exit=%d stderr=%s, want connection exit=%d", code, stderr.String(), ExitConnection)
|
|
}
|
|
if !strings.Contains(stderr.String(), `"code":"connection_failed"`) {
|
|
t.Fatalf("structured connection code missing: %s", stderr.String())
|
|
}
|
|
}
|
|
|
|
func TestFailResultDoesNotTreatOrdinaryCancellationTextAsCancellation(t *testing.T) {
|
|
var stderr bytes.Buffer
|
|
result := connection.QueryResult{
|
|
Success: false,
|
|
Message: `column "cancellation_reason" does not exist`,
|
|
}
|
|
if code := failResult(context.Background(), &stderr, result); code != ExitExecution {
|
|
t.Fatalf("failResult exit=%d stderr=%s, want execution exit=%d", code, stderr.String(), ExitExecution)
|
|
}
|
|
if !strings.Contains(stderr.String(), `"code":"execution_failed"`) {
|
|
t.Fatalf("ordinary database error was not classified as execution failure: %s", stderr.String())
|
|
}
|
|
}
|
|
|
|
func TestRunBatchOnlyAllowsContinueOnErrorWithTransactionOff(t *testing.T) {
|
|
fake := &fakeBackend{}
|
|
code, _, stderr := runWithBackend(t, fake,
|
|
"batch", "--conn", "production", "--file", "/missing.sql", "--allow-write", "--continue-on-error",
|
|
)
|
|
if code != ExitUsage || fake.batchFile != "" || !strings.Contains(stderr, "--transaction=off") {
|
|
t.Fatalf("batch exit=%d file=%q stderr=%s", code, fake.batchFile, stderr)
|
|
}
|
|
}
|
|
|
|
func TestRunBatchForwardsTransactionOff(t *testing.T) {
|
|
directory := t.TempDir()
|
|
filePath := filepath.Join(directory, "migration.sql")
|
|
if err := os.WriteFile(filePath, []byte("UPDATE account SET active = 1;"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
fake := &fakeBackend{
|
|
connections: []connection.SavedConnectionView{{ID: "conn-1", Name: "production"}},
|
|
batchResult: connection.QueryResult{Success: true},
|
|
}
|
|
code, _, stderr := runWithBackend(t, fake,
|
|
"batch", "--conn", "production", "--file", filePath, "--allow-write", "--transaction=off", "--continue-on-error",
|
|
)
|
|
if code != ExitSuccess || !fake.batchOptions.ContinueOnError || fake.batchOptions.TransactionMode != appcore.HeadlessSQLTransactionModeOff {
|
|
t.Fatalf("batch exit=%d options=%#v stderr=%s", code, fake.batchOptions, stderr)
|
|
}
|
|
}
|
|
|
|
func TestRunAuditExportParsesTimestampAndFilters(t *testing.T) {
|
|
fake := &fakeBackend{}
|
|
code, _, stderr := runWithBackend(t, fake,
|
|
"audit", "export", "--output", "audit.json", "--source", "cli", "--from", "2026-08-10T00:00:00Z", "--to", "123",
|
|
)
|
|
if code != ExitSuccess {
|
|
t.Fatalf("audit exit=%d stderr=%s", code, stderr)
|
|
}
|
|
if fake.auditFormat != "json" || fake.auditPath != "audit.json" || fake.auditFilter.Source != "cli" || fake.auditFilter.FromTimestamp == 0 || fake.auditFilter.ToTimestamp != 123 {
|
|
t.Fatalf("audit args not forwarded: format=%q path=%q filter=%#v", fake.auditFormat, fake.auditPath, fake.auditFilter)
|
|
}
|
|
}
|
|
|
|
func TestRunAuditExportRejectsUnsupportedFormatBeforeBackendCall(t *testing.T) {
|
|
fake := &fakeBackend{}
|
|
code, _, stderr := runWithBackend(t, fake, "audit", "export", "--output", "audit.out", "--format", "yaml")
|
|
if code != ExitUsage {
|
|
t.Fatalf("audit exit=%d stderr=%s, want usage exit=%d", code, stderr, ExitUsage)
|
|
}
|
|
if fake.auditFormat != "" {
|
|
t.Fatalf("unsupported audit format reached backend: %q", fake.auditFormat)
|
|
}
|
|
if !strings.Contains(stderr, `"code":"usage"`) {
|
|
t.Fatalf("unsupported audit format was not reported as usage error: %s", stderr)
|
|
}
|
|
}
|
|
|
|
func TestRunMCPMapsInvocationTerminationToCancelledExit(t *testing.T) {
|
|
t.Run("stdio cancellation error", func(t *testing.T) {
|
|
previousStdio := runMCPStdioServer
|
|
t.Cleanup(func() { runMCPStdioServer = previousStdio })
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
runMCPStdioServer = func(received context.Context) error {
|
|
if received != ctx {
|
|
t.Fatal("stdio runner received a different invocation context")
|
|
}
|
|
return received.Err()
|
|
}
|
|
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
if code := runMCP(ctx, []string{"stdio"}, &stdout, &stderr); code != ExitCancelled || !strings.Contains(stderr.String(), `"code":"cancelled"`) {
|
|
t.Fatalf("stdio cancellation exit=%d stdout=%s stderr=%s", code, stdout.String(), stderr.String())
|
|
}
|
|
})
|
|
|
|
t.Run("http graceful deadline shutdown", func(t *testing.T) {
|
|
previousHTTP := runMCPHTTPServer
|
|
t.Cleanup(func() { runMCPHTTPServer = previousHTTP })
|
|
ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(-time.Second))
|
|
defer cancel()
|
|
runMCPHTTPServer = func(received context.Context, _ mcpserver.HTTPServerOptions) error {
|
|
if received != ctx {
|
|
t.Fatal("http runner received a different invocation context")
|
|
}
|
|
// The real HTTP server treats a context-triggered graceful shutdown as
|
|
// a nil server error, which must still map to ExitCancelled.
|
|
return nil
|
|
}
|
|
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
if code := runMCP(ctx, []string{"http", "--token", "test-token"}, &stdout, &stderr); code != ExitCancelled || !strings.Contains(stderr.String(), `"code":"cancelled"`) {
|
|
t.Fatalf("http deadline exit=%d stdout=%s stderr=%s", code, stdout.String(), stderr.String())
|
|
}
|
|
})
|
|
|
|
t.Run("ordinary server failure remains execution failure", func(t *testing.T) {
|
|
previousStdio := runMCPStdioServer
|
|
t.Cleanup(func() { runMCPStdioServer = previousStdio })
|
|
runMCPStdioServer = func(context.Context) error { return errors.New("MCP transport failed") }
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
if code := runMCP(context.Background(), nil, &stdout, &stderr); code != ExitExecution || !strings.Contains(stderr.String(), `"code":"mcp_failed"`) {
|
|
t.Fatalf("MCP failure exit=%d stdout=%s stderr=%s", code, stdout.String(), stderr.String())
|
|
}
|
|
})
|
|
}
|
|
|
|
type failingWriter struct {
|
|
err error
|
|
}
|
|
|
|
func (writer failingWriter) Write([]byte) (int, error) {
|
|
return 0, writer.err
|
|
}
|
|
|
|
func TestRunReportsStdoutWriteFailureOnStderr(t *testing.T) {
|
|
fake := &fakeBackend{
|
|
connections: []connection.SavedConnectionView{{ID: "conn-1", Name: "production"}},
|
|
queryResult: connection.QueryResult{
|
|
Success: true,
|
|
Data: []connection.ResultSetData{{Columns: []string{"id"}, Rows: []map[string]any{{"id": 1}}}},
|
|
},
|
|
}
|
|
previous := newBackend
|
|
newBackend = func(context.Context, appcore.HeadlessRuntimeOptions) (backend, error) {
|
|
return fake, nil
|
|
}
|
|
t.Cleanup(func() { newBackend = previous })
|
|
|
|
var stderr bytes.Buffer
|
|
code := Run(context.Background(), []string{"query", "--conn", "production", "SELECT 1"}, failingWriter{err: errors.New("stdout sink unavailable")}, &stderr)
|
|
if code != ExitExecution {
|
|
t.Fatalf("query exit=%d stderr=%s, want execution exit=%d", code, stderr.String(), ExitExecution)
|
|
}
|
|
if !strings.Contains(stderr.String(), `"code":"output_failed"`) || !strings.Contains(stderr.String(), "stdout sink unavailable") {
|
|
t.Fatalf("stdout failure did not produce a structured stderr diagnostic: %s", stderr.String())
|
|
}
|
|
}
|