diff --git a/.agents/skills/xsql/SKILL.md b/.agents/skills/xsql/SKILL.md index d119b30..075a691 100644 --- a/.agents/skills/xsql/SKILL.md +++ b/.agents/skills/xsql/SKILL.md @@ -1,109 +1,185 @@ --- name: xsql -description: Use when inspecting MySQL or PostgreSQL with the xsql CLI, especially for safe read-only querying, schema discovery, profile inspection, machine-readable JSON output, stable error-code handling, SSH-backed connections, or MCP server setup in this repository. +description: > + AI-first database CLI for MySQL and PostgreSQL via xsql. Use this skill whenever the user wants to + analyze, inspect, or query a database — including "分析数据库", "看下表结构", "查下数据量", "数据库概览", + "表大小排行", "数据库健康检查", "碎片化分析", "查下索引", "慢查询", "优化建议", "表关系", + schema discovery, data profiling, slow query investigation, index analysis, fragmentation check, + foreign key analysis, or any task involving SQL databases. Also use when the user mentions xsql, + database profiles, SSH-tunneled DB access, or read-only database operations. Trigger even if the + user just names a database or environment (e.g. "生产环境数据库", "dev库", "帮我看下这个库", + "staging DB") without explicitly saying "xsql". Also trigger for xsql web UI or MCP server setup. --- # xsql -Use `xsql` as an AI-first database CLI. Prefer it when the task requires stable JSON output, explicit error codes, schema discovery, or profile-aware access to MySQL/PostgreSQL. +`xsql` is an AI-first database CLI that provides stable JSON output, explicit error codes, schema discovery, and profile-aware access to MySQL/PostgreSQL over SSH. ## Default Operating Rules -- Prefer `--format json` for agent work, even if TTY would default to table. -- Treat `stdout` as data and `stderr` as logs; never parse logs as result data. -- Check `ok`, `schema_version`, and `error.code` instead of relying on human-readable text. -- Assume read-only mode unless the user explicitly requests write behavior. -- Do not suggest or use `--unsafe-allow-write` unless the task truly requires writes and the user has made that intent explicit. -- Do not leak secrets, full DSNs, passwords, private keys, or passphrases in output, logs, or summaries. -- Treat `--ssh-skip-known-hosts-check` as a risky last resort and call out the security tradeoff if it is required. +- Use `--format json` (`-f json`) for all agent-driven work — it returns structured `ok`/`data`/`error` envelopes. YAML format (`-f yaml`) is also available with the same envelope structure. +- `stdout` is data; `stderr` is logs. Never parse stderr as result data. +- Validate responses by checking `ok`, `schema_version`, and `error.code` — not by string matching. +- Assume read-only mode. Only suggest `--unsafe-allow-write` when the user has explicitly requested writes. +- Never leak secrets, full DSNs, passwords, or private keys in output or summaries. +- `--ssh-skip-known-hosts-check` is a risky last resort — call out the security tradeoff if it's needed. ## Working Sequence -Use this sequence unless the user already knows the exact profile and SQL to run: +Follow this sequence unless the user already knows the exact profile and SQL. -1. Identify the profile with `xsql profile list --format json`. -2. Inspect the chosen profile with `xsql profile show --format json`. -3. Discover schema with `xsql schema dump -p -f json`. -4. Write a minimal read-only query against the confirmed tables and columns. -5. Run `xsql query "" -p -f json`. -6. Validate `ok`, `schema_version`, returned columns/rows, or `error.code`. - -Prefer `schema dump` over guessing table names or dialect details. - -## Command Patterns +### Step 1: Resolve the profile ```bash -# List available profiles xsql profile list --format json +``` -# Inspect a profile before querying +Match the user's intent to a profile by checking each profile's `description` and `database` fields. If the user says "生产环境" or "prod", look for a profile whose description contains those keywords. If ambiguous, ask the user to choose. + +Then confirm the profile details: + +```bash xsql profile show --format json +``` + +Note the `db` field (`mysql` or `pg`) — it determines which SQL dialect to use. Also note the `database` name for use in queries. + +### Step 2: Discover schema (adapt to database size) + +**Small databases (under ~20 tables):** Full dump is fine. + +```bash +xsql schema dump -p -f json +``` + +**Medium/large databases (20+ tables):** Full dump may be too large for context. Use a two-pass approach: + +Pass 1 — Get table list only (lightweight, via information_schema): + +```bash +# MySQL +xsql query "SELECT TABLE_NAME, TABLE_ROWS, DATA_LENGTH, INDEX_LENGTH FROM information_schema.TABLES WHERE TABLE_SCHEMA = '' ORDER BY TABLE_ROWS DESC" -p -f json + +# PostgreSQL +xsql query "SELECT relname AS table_name, reltuples::bigint AS row_estimate, pg_total_relation_size(relid) AS total_bytes FROM pg_stat_user_tables ORDER BY reltuples DESC" -p -f json +``` + +Pass 2 — Get detailed schema only for the tables you actually need: + +```bash +xsql schema dump -p -f json --table "user*" +``` + +Additional schema dump flags: `--include-system` to include system tables, `--schema-timeout ` to override the default 60s timeout. -# Discover schema before writing SQL -xsql schema dump --profile --format json +### Step 3: Write and run targeted queries -# Filter schema discovery when the table family is known -xsql schema dump --profile --table "user*" --format json +Write minimal, narrow queries with explicit columns, predicates, and `LIMIT`. Match SQL syntax to the profile's engine (MySQL vs PostgreSQL). -# Run a read-only query -xsql query "SELECT id, name FROM users LIMIT 10" --profile --format json +```bash +xsql query "" -p -f json +``` -# Export tool metadata for agent integration -xsql spec --format json +For long-running queries, set a timeout (default: 30s): -# Start MCP server -xsql mcp server +```bash +xsql query "" -p -f json --query-timeout 60 ``` +### Step 4: Validate the response + +Check the JSON envelope: +- `ok: true` → use `data.rows` and `data.columns` +- `ok: false` → inspect `error.code` and `error.message` + +## Health Check Workflow + +When the user asks for a database analysis or health check, run multiple analysis patterns together. Run independent queries in parallel for efficiency. + +The SQL patterns for each engine are in the reference files — read only the one matching the profile's `db` field: +- **MySQL**: Read `references/mysql-patterns.md` +- **PostgreSQL**: Read `references/postgresql-patterns.md` + +### Recommended health check scope + +For a comprehensive health check, include these areas in order of impact: + +1. **Database Overview** — table sizes and row counts, identify the biggest tables +2. **Fragmentation Analysis** — detect wasted space and performance degradation +3. **Missing Index Detection** — find tables that need better indexing +4. **Stale Table Detection** — identify potentially abandoned tables + +For specific investigations, also consider: +- **Growth Trend Analysis** — when the user asks about data growth or capacity planning +- **Column Distribution Analysis** — when the user asks about data distribution or skew +- **Server Status** — for deep performance diagnostics + ## Querying Guidance -- Start with schema discovery, then query. -- Keep queries narrow: explicit columns, predicates, and `LIMIT`. -- Match SQL syntax to the target engine after confirming whether the profile is MySQL or PostgreSQL. -- Use aggregation or sampling before asking for large result sets. -- Expect read-only enforcement to block writes through both SQL analysis and read-only transactions. +- Schema discovery first, query second — never guess table or column names. +- Keep queries narrow: explicit columns, WHERE predicates, and LIMIT. +- For large tables, prefer aggregation (`COUNT`, `GROUP BY`) over `SELECT *`. +- Use `--query-timeout` for long-running queries (default: 30s). +- Use `--schema-timeout` for large schema dumps (default: 60s). +- Run `xsql spec --format json` to discover all available commands and flags. ## Output And Error Handling -For machine-readable formats, expect: +JSON responses follow this envelope: ```json -{"ok":true,"schema_version":1,"data":{...}} +{"ok": true, "schema_version": 1, "data": {"columns": [...], "rows": [...]}} ``` ```json -{"ok":false,"schema_version":1,"error":{"code":"...","message":"...","details":{...}}} +{"ok": false, "schema_version": 1, "error": {"code": "...", "message": "...", "details": {...}}} ``` -Handle failures by `error.code` and exit status, not by fragile string matching. Important exit codes in this repo: +Handle failures by `error.code` and exit status: + +| Exit Code | Meaning | +|-----------|---------| +| 0 | success | +| 2 | argument/config error | +| 3 | DB or SSH connection error | +| 4 | read-only policy blocked a write | +| 5 | database execution error | +| 10 | internal error | -- `0`: success -- `2`: argument/config error -- `3`: DB or SSH connection error -- `4`: read-only policy blocked a write -- `5`: database execution error -- `10`: internal error +Table and CSV formats are for humans — they lack the `ok`/`schema_version` envelope. -Table and CSV output are for humans; they do not include `ok` or `schema_version`. +## Additional Commands + +```bash +# MCP server for AI assistant integration +xsql mcp server # stdio transport (default) +xsql mcp server --transport streamable_http # HTTP transport +xsql mcp server --transport streamable_http \ + --http-addr 127.0.0.1:8787 --http-auth-token # HTTP with auth + +# Web UI +xsql web # start web server and open browser +xsql serve # start web server (headless, no browser) + +# Configuration +xsql config init # create template config file +xsql config set profile.dev.host localhost # set a config value by dot-notation key + +# Tool metadata and version +xsql spec --format json # export tool spec for AI/agents +xsql version # print version info +``` ## Config And Profile Rules -- Resolve precedence as `CLI > ENV > Config`. -- Use `XSQL_`-prefixed env vars when environment configuration is needed. -- Default config lookup is `./xsql.yaml`, then `~/.config/xsql/xsql.yaml`, unless `--config` is provided. -- Prefer keyring-backed secrets. Plaintext secrets require explicit allowance. -- If no profile is passed and a `default` profile exists, xsql may use it automatically. +- Precedence: `CLI flags > ENV vars > Config file`. ENV vars use `XSQL_` prefix (e.g. `XSQL_PROFILE`, `XSQL_FORMAT`). +- Config lookup: `./xsql.yaml`, then `~/.config/xsql/xsql.yaml`. +- Prefer keyring-backed secrets. Plaintext secrets require `--allow-plaintext`. +- If no profile is specified and a `default` profile exists, xsql uses it automatically. ## SSH Rules -- Prefer the built-in SSH driver-dial path; it is the default design for MySQL/PostgreSQL. -- Use the local port-forwarding proxy mode only when the workflow explicitly needs `xsql proxy` semantics or a driver fallback. +- The built-in SSH driver-dial path is the default and preferred method. +- Use `xsql proxy` only when the workflow explicitly needs a local port-forward. - Keep host-key verification enabled by default. -- For one-shot commands such as `query` and `schema dump`, expect fresh SSH/DB connections rather than long-lived reconnect behavior. - -## If You Are Modifying xsql - -- Keep CLI-layer logic in `cmd/xsql` thin. -- Keep core behavior in `internal/*`, not coupled to Cobra types. -- Preserve output contracts and stable error codes. -- Add or update tests for JSON output, exit codes, and read-only behavior. +- One-shot commands (`query`, `schema dump`) use fresh connections — don't expect long-lived sessions. diff --git a/cmd/xsql/web_test.go b/cmd/xsql/web_test.go index 76867e6..0a358ef 100644 --- a/cmd/xsql/web_test.go +++ b/cmd/xsql/web_test.go @@ -177,9 +177,9 @@ func TestResolveWebOptions_AddressResolution(t *testing.T) { // Test all loopback address variations func TestResolveWebOptions_LoopbackAddresses(t *testing.T) { tests := []struct { - name string - addr string - shouldReq bool + name string + addr string + shouldReq bool }{ {"IPv4 loopback", "127.0.0.1:8788", false}, {"IPv6 loopback", "[::1]:8788", false}, @@ -218,12 +218,12 @@ func TestResolveWebOptions_LoopbackAddresses(t *testing.T) { // TestResolveWebOptions_TokenResolution tests token resolution priority: CLI > ENV > Config > None func TestResolveWebOptions_TokenResolution(t *testing.T) { tests := []struct { - name string - setEnv string - opts *webCommandOptions - cfg config.File - expectedTok string - expectErr bool + name string + setEnv string + opts *webCommandOptions + cfg config.File + expectedTok string + expectErr bool }{ { name: "CLI token takes priority", @@ -271,10 +271,10 @@ func TestResolveWebOptions_TokenResolution(t *testing.T) { expectedTok: "plaintext-token", }, { - name: "Non-loopback without token fails", - setEnv: "", - opts: &webCommandOptions{addr: "0.0.0.0:8788", addrSet: true}, - expectErr: true, + name: "Non-loopback without token fails", + setEnv: "", + opts: &webCommandOptions{addr: "0.0.0.0:8788", addrSet: true}, + expectErr: true, }, } @@ -491,9 +491,9 @@ func TestWebCommand_AuthTokenSetFlag(t *testing.T) { // TestRunWebCommand_ConfigLoadError tests error handling when config loading fails func TestRunWebCommand_ConfigLoadError(t *testing.T) { opts := &webCommandOptions{ - addr: "127.0.0.1:0", - addrSet: true, - authToken: "", + addr: "127.0.0.1:0", + addrSet: true, + authToken: "", authTokenSet: false, } @@ -508,7 +508,7 @@ func TestRunWebCommand_ConfigLoadError(t *testing.T) { w := output.New(&buf, &bytes.Buffer{}) err := runWebCommand(opts, &w) - + // Should return an error due to missing config file if err == nil { t.Error("expected error from loading invalid config path, got nil") @@ -572,5 +572,3 @@ func TestRunWebCommand_ListenerCreationError(t *testing.T) { t.Error("test timed out - runWebCommand likely blocked waiting for signals") } } - - diff --git a/internal/app/conn_test.go b/internal/app/conn_test.go index e7f522f..f021cc2 100644 --- a/internal/app/conn_test.go +++ b/internal/app/conn_test.go @@ -367,9 +367,9 @@ func TestResolveReconnectableSSH_PassphraseNotAllowed(t *testing.T) { func TestResolveReconnectableSSH_ConnectFails(t *testing.T) { profile := config.Profile{ SSHConfig: &config.SSHProxy{ - Host: "127.0.0.1", - Port: 1, // unlikely to have SSH on port 1 - User: "user", + Host: "127.0.0.1", + Port: 1, // unlikely to have SSH on port 1 + User: "user", }, } diff --git a/internal/app/service_test.go b/internal/app/service_test.go index 624aa2d..6706791 100644 --- a/internal/app/service_test.go +++ b/internal/app/service_test.go @@ -135,11 +135,11 @@ func TestLoadProfileDetail_ProfileNotFound(t *testing.T) { func TestLoadProfileDetail_RedactsSensitiveFields(t *testing.T) { // Test redaction of sensitive fields in result result := map[string]any{ - "config_path": "/path/to/config.yaml", - "name": "prod", - "db": "mysql", - "password": "***", - "dsn": "***", + "config_path": "/path/to/config.yaml", + "name": "prod", + "db": "mysql", + "password": "***", + "dsn": "***", } if pwd, ok := result["password"].(string); ok && pwd != "" && pwd != "***" { @@ -658,14 +658,14 @@ func TestLoadProfileDetail_ResolvedSSHConfig(t *testing.T) { // Test that resolved SSH config is included in detail output // This requires a profile with SSH config attached result := map[string]any{ - "config_path": "/etc/xsql.yaml", - "name": "remote", - "db": "mysql", - "ssh_proxy": "jump", - "ssh_host": "jump.example.com", - "ssh_port": 2222, - "ssh_user": "jumper", - "ssh_identity_file": "/path/to/key", + "config_path": "/etc/xsql.yaml", + "name": "remote", + "db": "mysql", + "ssh_proxy": "jump", + "ssh_host": "jump.example.com", + "ssh_port": 2222, + "ssh_user": "jumper", + "ssh_identity_file": "/path/to/key", } // Verify SSH fields are present when SSH is configured @@ -773,291 +773,291 @@ func TestResolveConnection_CalledWithMissingDriver(t *testing.T) { // TestLoadProfileDetail_ReallyCallsTheFunction verifies LoadProfileDetail is actually covered func TestLoadProfileDetail_ActualCall(t *testing.T) { -cfg := config.File{ -Profiles: map[string]config.Profile{ -"test": { -DB: "mysql", -Host: "localhost", -Port: 3306, -User: "root", -Database: "testdb", -Password: "secret", -}, -}, -} + cfg := config.File{ + Profiles: map[string]config.Profile{ + "test": { + DB: "mysql", + Host: "localhost", + Port: 3306, + User: "root", + Database: "testdb", + Password: "secret", + }, + }, + } -// Create a temporary config file to test with -tmpDir := t.TempDir() -tmpFile := filepath.Join(tmpDir, "config.yaml") + // Create a temporary config file to test with + tmpDir := t.TempDir() + tmpFile := filepath.Join(tmpDir, "config.yaml") -// Write config to file -b, err := yaml.Marshal(cfg) -if err != nil { -t.Fatal(err) -} -if err := os.WriteFile(tmpFile, b, 0o600); err != nil { -t.Fatal(err) -} + // Write config to file + b, err := yaml.Marshal(cfg) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(tmpFile, b, 0o600); err != nil { + t.Fatal(err) + } -result, xe := LoadProfileDetail(config.Options{ConfigPath: tmpFile}, "test") + result, xe := LoadProfileDetail(config.Options{ConfigPath: tmpFile}, "test") -if xe != nil { -t.Fatalf("expected success, got error: %v", xe) -} + if xe != nil { + t.Fatalf("expected success, got error: %v", xe) + } -if result == nil { -t.Fatal("expected non-nil result") -} + if result == nil { + t.Fatal("expected non-nil result") + } -// Verify password is redacted -if pwd, ok := result["password"].(string); ok && pwd != "***" && pwd != "" { -t.Errorf("password should be redacted, got: %s", pwd) -} + // Verify password is redacted + if pwd, ok := result["password"].(string); ok && pwd != "***" && pwd != "" { + t.Errorf("password should be redacted, got: %s", pwd) + } -if result["db"] != "mysql" { -t.Errorf("expected db=mysql, got %v", result["db"]) -} + if result["db"] != "mysql" { + t.Errorf("expected db=mysql, got %v", result["db"]) + } } // TestLoadProfileDetail_ProfileMissing verifies LoadProfileDetail error handling func TestLoadProfileDetail_ProfileMissing(t *testing.T) { -tmpDir := t.TempDir() -tmpFile := filepath.Join(tmpDir, "config.yaml") + tmpDir := t.TempDir() + tmpFile := filepath.Join(tmpDir, "config.yaml") -cfg := config.File{ -Profiles: map[string]config.Profile{}, -} + cfg := config.File{ + Profiles: map[string]config.Profile{}, + } -b, err := yaml.Marshal(cfg) -if err != nil { -t.Fatal(err) -} -if err := os.WriteFile(tmpFile, b, 0o600); err != nil { -t.Fatal(err) -} + b, err := yaml.Marshal(cfg) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(tmpFile, b, 0o600); err != nil { + t.Fatal(err) + } -result, xe := LoadProfileDetail(config.Options{ConfigPath: tmpFile}, "nonexistent") + result, xe := LoadProfileDetail(config.Options{ConfigPath: tmpFile}, "nonexistent") -if xe == nil { -t.Fatal("expected error for missing profile") -} + if xe == nil { + t.Fatal("expected error for missing profile") + } -if result != nil { -t.Errorf("expected nil result, got %v", result) -} + if result != nil { + t.Errorf("expected nil result, got %v", result) + } -if xe.Code != errors.CodeCfgInvalid { -t.Errorf("expected CodeCfgInvalid, got %s", xe.Code) -} + if xe.Code != errors.CodeCfgInvalid { + t.Errorf("expected CodeCfgInvalid, got %s", xe.Code) + } } // TestLoadProfiles_Success_Real tests LoadProfiles with real configuration file func TestLoadProfiles_Success_Real(t *testing.T) { -tmpDir := t.TempDir() -tmpFile := filepath.Join(tmpDir, "config.yaml") - -cfg := config.File{ -Profiles: map[string]config.Profile{ -"prod": { -DB: "mysql", -Host: "prod.example.com", -Port: 3306, -User: "admin", -Password: "secret", -Database: "main_db", -}, -"dev": { -DB: "pg", -Host: "localhost", -Port: 5432, -User: "dev", -Database: "dev_db", -}, -}, -} + tmpDir := t.TempDir() + tmpFile := filepath.Join(tmpDir, "config.yaml") -b, err := yaml.Marshal(cfg) -if err != nil { -t.Fatal(err) -} -if err := os.WriteFile(tmpFile, b, 0o600); err != nil { -t.Fatal(err) -} + cfg := config.File{ + Profiles: map[string]config.Profile{ + "prod": { + DB: "mysql", + Host: "prod.example.com", + Port: 3306, + User: "admin", + Password: "secret", + Database: "main_db", + }, + "dev": { + DB: "pg", + Host: "localhost", + Port: 5432, + User: "dev", + Database: "dev_db", + }, + }, + } -result, xe := LoadProfiles(config.Options{ConfigPath: tmpFile}) + b, err := yaml.Marshal(cfg) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(tmpFile, b, 0o600); err != nil { + t.Fatal(err) + } -if xe != nil { -t.Fatalf("expected success, got error: %v", xe) -} + result, xe := LoadProfiles(config.Options{ConfigPath: tmpFile}) -if result == nil { -t.Fatal("expected non-nil result") -} + if xe != nil { + t.Fatalf("expected success, got error: %v", xe) + } -if result.ConfigPath != tmpFile { -t.Errorf("expected ConfigPath=%s, got %s", tmpFile, result.ConfigPath) -} + if result == nil { + t.Fatal("expected non-nil result") + } -if len(result.Profiles) != 2 { -t.Errorf("expected 2 profiles, got %d", len(result.Profiles)) -} + if result.ConfigPath != tmpFile { + t.Errorf("expected ConfigPath=%s, got %s", tmpFile, result.ConfigPath) + } -// Profiles should be sorted alphabetically -if len(result.Profiles) > 0 && result.Profiles[0].Name != "dev" { -t.Errorf("expected first profile to be 'dev' (sorted), got %s", result.Profiles[0].Name) -} + if len(result.Profiles) != 2 { + t.Errorf("expected 2 profiles, got %d", len(result.Profiles)) + } + + // Profiles should be sorted alphabetically + if len(result.Profiles) > 0 && result.Profiles[0].Name != "dev" { + t.Errorf("expected first profile to be 'dev' (sorted), got %s", result.Profiles[0].Name) + } } // TestLoadProfileDetail_WithSSHProxy tests LoadProfileDetail with SSH proxy setting func TestLoadProfileDetail_WithSSHProxy(t *testing.T) { -tmpDir := t.TempDir() -tmpFile := filepath.Join(tmpDir, "config.yaml") - -cfg := config.File{ -SSHProxies: map[string]config.SSHProxy{ -"remote-proxy": { -Host: "ssh.example.com", -Port: 22, -User: "sshuser", -IdentityFile: "/home/user/.ssh/id_rsa", -}, -}, -Profiles: map[string]config.Profile{ -"remote": { -DB: "mysql", -Host: "10.0.0.1", -Port: 3306, -User: "admin", -Password: "secret", -Database: "mydb", -SSHProxy: "remote-proxy", -}, -}, -} + tmpDir := t.TempDir() + tmpFile := filepath.Join(tmpDir, "config.yaml") -b, err := yaml.Marshal(cfg) -if err != nil { -t.Fatal(err) -} -if err := os.WriteFile(tmpFile, b, 0o600); err != nil { -t.Fatal(err) -} + cfg := config.File{ + SSHProxies: map[string]config.SSHProxy{ + "remote-proxy": { + Host: "ssh.example.com", + Port: 22, + User: "sshuser", + IdentityFile: "/home/user/.ssh/id_rsa", + }, + }, + Profiles: map[string]config.Profile{ + "remote": { + DB: "mysql", + Host: "10.0.0.1", + Port: 3306, + User: "admin", + Password: "secret", + Database: "mydb", + SSHProxy: "remote-proxy", + }, + }, + } -result, xe := LoadProfileDetail(config.Options{ConfigPath: tmpFile}, "remote") + b, err := yaml.Marshal(cfg) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(tmpFile, b, 0o600); err != nil { + t.Fatal(err) + } -if xe != nil { -t.Fatalf("expected success, got error: %v", xe) -} + result, xe := LoadProfileDetail(config.Options{ConfigPath: tmpFile}, "remote") -if result == nil { -t.Fatal("expected non-nil result") -} + if xe != nil { + t.Fatalf("expected success, got error: %v", xe) + } -// Verify SSH proxy is included -if sshProxy, ok := result["ssh_proxy"].(string); !ok || sshProxy != "remote-proxy" { -t.Errorf("expected ssh_proxy=remote-proxy, got %v", result["ssh_proxy"]) -} + if result == nil { + t.Fatal("expected non-nil result") + } -// Verify password is redacted -if pwd, ok := result["password"].(string); !ok || pwd != "***" { -t.Errorf("expected password=***, got %v", result["password"]) -} + // Verify SSH proxy is included + if sshProxy, ok := result["ssh_proxy"].(string); !ok || sshProxy != "remote-proxy" { + t.Errorf("expected ssh_proxy=remote-proxy, got %v", result["ssh_proxy"]) + } + + // Verify password is redacted + if pwd, ok := result["password"].(string); !ok || pwd != "***" { + t.Errorf("expected password=***, got %v", result["password"]) + } } // TestQuery_NoDB tests Query with missing database type func TestQuery_NoDB(t *testing.T) { -ctx := context.Background() -req := QueryRequest{ -Profile: config.Profile{ -DB: "", // Empty DB type -}, -SQL: "SELECT 1", -} + ctx := context.Background() + req := QueryRequest{ + Profile: config.Profile{ + DB: "", // Empty DB type + }, + SQL: "SELECT 1", + } -result, xe := Query(ctx, req) + result, xe := Query(ctx, req) -if xe == nil { -t.Fatal("expected error for missing DB type, got nil") -} + if xe == nil { + t.Fatal("expected error for missing DB type, got nil") + } -if result != nil { -t.Errorf("expected nil result for error case, got %v", result) -} + if result != nil { + t.Errorf("expected nil result for error case, got %v", result) + } -if xe.Code != errors.CodeCfgInvalid { -t.Errorf("expected CodeCfgInvalid, got %q", xe.Code) -} + if xe.Code != errors.CodeCfgInvalid { + t.Errorf("expected CodeCfgInvalid, got %q", xe.Code) + } } // TestDumpSchema_NoDB tests DumpSchema with missing database type func TestDumpSchema_NoDB(t *testing.T) { -ctx := context.Background() -req := SchemaDumpRequest{ -Profile: config.Profile{ -DB: "", // Empty DB type -}, -} + ctx := context.Background() + req := SchemaDumpRequest{ + Profile: config.Profile{ + DB: "", // Empty DB type + }, + } -result, xe := DumpSchema(ctx, req) + result, xe := DumpSchema(ctx, req) -if xe == nil { -t.Fatal("expected error for missing DB type, got nil") -} + if xe == nil { + t.Fatal("expected error for missing DB type, got nil") + } -if result != nil { -t.Errorf("expected nil result for error case, got %v", result) -} + if result != nil { + t.Errorf("expected nil result for error case, got %v", result) + } -if xe.Code != errors.CodeCfgInvalid { -t.Errorf("expected CodeCfgInvalid, got %q", xe.Code) -} + if xe.Code != errors.CodeCfgInvalid { + t.Errorf("expected CodeCfgInvalid, got %q", xe.Code) + } } // TestListTables_NoDB tests ListTables with missing database type func TestListTables_NoDB(t *testing.T) { -ctx := context.Background() -req := TableListRequest{ -Profile: config.Profile{ -DB: "", // Empty DB type -}, -} + ctx := context.Background() + req := TableListRequest{ + Profile: config.Profile{ + DB: "", // Empty DB type + }, + } -result, xe := ListTables(ctx, req) + result, xe := ListTables(ctx, req) -if xe == nil { -t.Fatal("expected error for missing DB type, got nil") -} + if xe == nil { + t.Fatal("expected error for missing DB type, got nil") + } -if result != nil { -t.Errorf("expected nil result for error case, got %v", result) -} + if result != nil { + t.Errorf("expected nil result for error case, got %v", result) + } -if xe.Code != errors.CodeCfgInvalid { -t.Errorf("expected CodeCfgInvalid, got %q", xe.Code) -} + if xe.Code != errors.CodeCfgInvalid { + t.Errorf("expected CodeCfgInvalid, got %q", xe.Code) + } } // TestDescribeTable_NoDB tests DescribeTable with missing database type func TestDescribeTable_NoDB(t *testing.T) { -ctx := context.Background() -req := TableDescribeRequest{ -Profile: config.Profile{ -DB: "", // Empty DB type -}, -Name: "test_table", -} + ctx := context.Background() + req := TableDescribeRequest{ + Profile: config.Profile{ + DB: "", // Empty DB type + }, + Name: "test_table", + } -result, xe := DescribeTable(ctx, req) + result, xe := DescribeTable(ctx, req) -if xe == nil { -t.Fatal("expected error for missing DB type, got nil") -} + if xe == nil { + t.Fatal("expected error for missing DB type, got nil") + } -if result != nil { -t.Errorf("expected nil result for error case, got %v", result) -} + if result != nil { + t.Errorf("expected nil result for error case, got %v", result) + } -if xe.Code != errors.CodeCfgInvalid { -t.Errorf("expected CodeCfgInvalid, got %q", xe.Code) -} + if xe.Code != errors.CodeCfgInvalid { + t.Errorf("expected CodeCfgInvalid, got %q", xe.Code) + } } diff --git a/internal/config/types_test.go b/internal/config/types_test.go index 537e9d5..e136fec 100644 --- a/internal/config/types_test.go +++ b/internal/config/types_test.go @@ -7,8 +7,8 @@ import ( // TestProfileToInfo tests ProfileToInfo conversion function func TestProfileToInfo(t *testing.T) { tests := []struct { - name string - prof Profile + name string + prof Profile wantMode string }{ { @@ -22,18 +22,18 @@ func TestProfileToInfo(t *testing.T) { { name: "read_write_enabled", prof: Profile{ - DB: "mysql", - Description: "Test profile", - UnsafeAllowWrite: true, + DB: "mysql", + Description: "Test profile", + UnsafeAllowWrite: true, }, wantMode: "read-write", }, { name: "read_write_false", prof: Profile{ - DB: "postgresql", - Description: "PG profile", - UnsafeAllowWrite: false, + DB: "postgresql", + Description: "PG profile", + UnsafeAllowWrite: false, }, wantMode: "read-only", }, diff --git a/internal/errors/error_test.go b/internal/errors/error_test.go index db6624c..bc6fa1b 100644 --- a/internal/errors/error_test.go +++ b/internal/errors/error_test.go @@ -8,10 +8,10 @@ import ( // TestAsOrWrap tests the AsOrWrap function func TestAsOrWrap_XError(t *testing.T) { tests := []struct { - name string - err error - wantXErr bool - wantCode Code + name string + err error + wantXErr bool + wantCode Code }{ { name: "wrap_regular_error", diff --git a/internal/ssh/testutil_test.go b/internal/ssh/testutil_test.go index fc77be6..16b6ad5 100644 --- a/internal/ssh/testutil_test.go +++ b/internal/ssh/testutil_test.go @@ -18,11 +18,11 @@ import ( // testSSHServer is a minimal in-process SSH server for testing. // It supports keepalive requests and direct-tcpip channel forwarding. type testSSHServer struct { - listener net.Listener - config *gossh.ServerConfig - hostKey gossh.Signer + listener net.Listener + config *gossh.ServerConfig + hostKey gossh.Signer tempKeyFile string // path to a temporary client private key for auth - wg sync.WaitGroup + wg sync.WaitGroup mu sync.Mutex closed bool diff --git a/internal/web/handler_comprehensive_test.go b/internal/web/handler_comprehensive_test.go index 356e4fc..8cbe386 100644 --- a/internal/web/handler_comprehensive_test.go +++ b/internal/web/handler_comprehensive_test.go @@ -404,7 +404,7 @@ func TestHandler_Frontend_Index(t *testing.T) { // TestHandler_Frontend_SubPath tests serving nested assets func TestHandler_Frontend_SubPath(t *testing.T) { assets := fstest.MapFS{ - "index.html": &fstest.MapFile{Data: []byte("index")}, + "index.html": &fstest.MapFile{Data: []byte("index")}, "css/style.css": &fstest.MapFile{Data: []byte("body { color: red; }")}, } @@ -509,10 +509,10 @@ func TestHandler_ResponseContentType(t *testing.T) { // TestParseSchemaTablePath_Complex tests path parsing with special characters func TestParseSchemaTablePath_Complex(t *testing.T) { cases := []struct { - path string - wantOK bool + path string + wantOK bool wantSchema string - wantTable string + wantTable string }{ {"/api/v1/schema/tables/public/users", true, "public", "users"}, {"/api/v1/schema/tables/my_schema/my_table", true, "my_schema", "my_table"}, @@ -609,7 +609,7 @@ func TestStatusCodeFor_AllCases(t *testing.T) { // TestHandler_ProfileShow_WithAuth tests ProfileShow with authentication func TestHandler_ProfileShow_WithAuth(t *testing.T) { -configPath := createConfigFile(t, ` + configPath := createConfigFile(t, ` profiles: prod: driver: mysql @@ -619,64 +619,64 @@ profiles: password: secret `) -handler := NewHandler(HandlerOptions{ -ConfigPath: configPath, -AuthRequired: true, -AuthToken: "test-token-123", -Assets: fstest.MapFS{"index.html": &fstest.MapFile{Data: []byte("ok")}}, -}) + handler := NewHandler(HandlerOptions{ + ConfigPath: configPath, + AuthRequired: true, + AuthToken: "test-token-123", + Assets: fstest.MapFS{"index.html": &fstest.MapFile{Data: []byte("ok")}}, + }) -req := httptest.NewRequest(http.MethodGet, "/api/v1/profiles/prod", nil) -req.Header.Set("Authorization", "Bearer test-token-123") -rec := httptest.NewRecorder() -handler.ServeHTTP(rec, req) + req := httptest.NewRequest(http.MethodGet, "/api/v1/profiles/prod", nil) + req.Header.Set("Authorization", "Bearer test-token-123") + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) -if rec.Code != http.StatusOK { -t.Errorf("expected 200, got %d", rec.Code) -} + if rec.Code != http.StatusOK { + t.Errorf("expected 200, got %d", rec.Code) + } } // TestHandler_ConfigJS_WithoutAuth tests config endpoint when auth not required func TestHandler_ConfigJS_WithoutAuth(t *testing.T) { -handler := NewHandler(HandlerOptions{ -InitialProfile: "default", -AuthRequired: false, -Assets: fstest.MapFS{"index.html": &fstest.MapFile{Data: []byte("ok")}}, -}) + handler := NewHandler(HandlerOptions{ + InitialProfile: "default", + AuthRequired: false, + Assets: fstest.MapFS{"index.html": &fstest.MapFile{Data: []byte("ok")}}, + }) -req := httptest.NewRequest(http.MethodGet, "/config.js", nil) -rec := httptest.NewRecorder() -handler.ServeHTTP(rec, req) + req := httptest.NewRequest(http.MethodGet, "/config.js", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) -if rec.Code != http.StatusOK { -t.Errorf("expected 200, got %d", rec.Code) -} + if rec.Code != http.StatusOK { + t.Errorf("expected 200, got %d", rec.Code) + } -body := rec.Body.String() -if !strings.Contains(body, "authRequired") { -t.Error("expected authRequired in config") -} + body := rec.Body.String() + if !strings.Contains(body, "authRequired") { + t.Error("expected authRequired in config") + } } // TestHandler_ConfigJS_PostNotAllowed tests POST method not allowed on config.js func TestHandler_ConfigJS_PostNotAllowed(t *testing.T) { -handler := NewHandler(HandlerOptions{ -InitialProfile: "default", -Assets: fstest.MapFS{"index.html": &fstest.MapFile{Data: []byte("ok")}}, -}) + handler := NewHandler(HandlerOptions{ + InitialProfile: "default", + Assets: fstest.MapFS{"index.html": &fstest.MapFile{Data: []byte("ok")}}, + }) -req := httptest.NewRequest(http.MethodPost, "/config.js", nil) -rec := httptest.NewRecorder() -handler.ServeHTTP(rec, req) + req := httptest.NewRequest(http.MethodPost, "/config.js", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) -if rec.Code != http.StatusMethodNotAllowed { -t.Errorf("expected 405, got %d", rec.Code) -} + if rec.Code != http.StatusMethodNotAllowed { + t.Errorf("expected 405, got %d", rec.Code) + } } -// TestHandler_MultipleProfiles tests loading multiple profiles +// TestHandler_MultipleProfiles tests loading multiple profiles func TestHandler_MultipleProfiles_Real(t *testing.T) { -configPath := createConfigFile(t, ` + configPath := createConfigFile(t, ` profiles: db1: driver: mysql @@ -698,187 +698,187 @@ profiles: database: db3 `) -handler := NewHandler(HandlerOptions{ -ConfigPath: configPath, -Assets: fstest.MapFS{"index.html": &fstest.MapFile{Data: []byte("ok")}}, -}) + handler := NewHandler(HandlerOptions{ + ConfigPath: configPath, + Assets: fstest.MapFS{"index.html": &fstest.MapFile{Data: []byte("ok")}}, + }) -req := httptest.NewRequest(http.MethodGet, "/api/v1/profiles", nil) -rec := httptest.NewRecorder() -handler.ServeHTTP(rec, req) + req := httptest.NewRequest(http.MethodGet, "/api/v1/profiles", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) -if rec.Code != http.StatusOK { -t.Errorf("expected 200, got %d", rec.Code) -} + if rec.Code != http.StatusOK { + t.Errorf("expected 200, got %d", rec.Code) + } -var resp envelope -if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { -t.Fatalf("invalid JSON: %v", err) -} + var resp envelope + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("invalid JSON: %v", err) + } -if !resp.OK { -t.Error("expected OK=true") -} + if !resp.OK { + t.Error("expected OK=true") + } } // TestHandler_ProfileShow_InvalidPath tests ProfileShow with invalid path func TestHandler_ProfileShow_InvalidPath(t *testing.T) { -handler := NewHandler(HandlerOptions{ -Assets: fstest.MapFS{"index.html": &fstest.MapFile{Data: []byte("ok")}}, -}) + handler := NewHandler(HandlerOptions{ + Assets: fstest.MapFS{"index.html": &fstest.MapFile{Data: []byte("ok")}}, + }) -tests := []struct { -path string -name string -}{ -{path: "/api/v1/profiles/", name: "empty name"}, -{path: "/api/v1/profiles/dev/extra", name: "path with extra segments"}, -} + tests := []struct { + path string + name string + }{ + {path: "/api/v1/profiles/", name: "empty name"}, + {path: "/api/v1/profiles/dev/extra", name: "path with extra segments"}, + } -for _, tt := range tests { -t.Run(tt.name, func(t *testing.T) { -req := httptest.NewRequest(http.MethodGet, tt.path, nil) -rec := httptest.NewRecorder() -handler.ServeHTTP(rec, req) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, tt.path, nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) -if rec.Code == http.StatusOK { -t.Errorf("expected error status for invalid path, got 200") -} -}) -} + if rec.Code == http.StatusOK { + t.Errorf("expected error status for invalid path, got 200") + } + }) + } } // TestHandler_ProfileShow_PostNotAllowed tests POST to ProfileShow endpoint func TestHandler_ProfileShow_PostNotAllowed(t *testing.T) { -handler := NewHandler(HandlerOptions{ -Assets: fstest.MapFS{"index.html": &fstest.MapFile{Data: []byte("ok")}}, -}) + handler := NewHandler(HandlerOptions{ + Assets: fstest.MapFS{"index.html": &fstest.MapFile{Data: []byte("ok")}}, + }) -req := httptest.NewRequest(http.MethodPost, "/api/v1/profiles/dev", nil) -rec := httptest.NewRecorder() -handler.ServeHTTP(rec, req) + req := httptest.NewRequest(http.MethodPost, "/api/v1/profiles/dev", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) -if rec.Code != http.StatusMethodNotAllowed { -t.Errorf("expected 405, got %d", rec.Code) -} + if rec.Code != http.StatusMethodNotAllowed { + t.Errorf("expected 405, got %d", rec.Code) + } } func TestHandler_HandleProfiles_ConfigError(t *testing.T) { -handler := NewHandler(HandlerOptions{ -ConfigPath: "/nonexistent/path/config.yaml", -Assets: fstest.MapFS{"index.html": &fstest.MapFile{Data: []byte("ok")}}, -}) + handler := NewHandler(HandlerOptions{ + ConfigPath: "/nonexistent/path/config.yaml", + Assets: fstest.MapFS{"index.html": &fstest.MapFile{Data: []byte("ok")}}, + }) -req := httptest.NewRequest(http.MethodGet, "/api/v1/profiles", nil) -rec := httptest.NewRecorder() -handler.ServeHTTP(rec, req) + req := httptest.NewRequest(http.MethodGet, "/api/v1/profiles", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) -// Should fail due to missing config -if rec.Code != http.StatusNotFound && rec.Code != http.StatusInternalServerError { -t.Logf("status code: %d", rec.Code) -} + // Should fail due to missing config + if rec.Code != http.StatusNotFound && rec.Code != http.StatusInternalServerError { + t.Logf("status code: %d", rec.Code) + } } func TestHandler_HandleProfileShow_ConfigError(t *testing.T) { -handler := NewHandler(HandlerOptions{ -ConfigPath: "/nonexistent/path/config.yaml", -Assets: fstest.MapFS{"index.html": &fstest.MapFile{Data: []byte("ok")}}, -}) + handler := NewHandler(HandlerOptions{ + ConfigPath: "/nonexistent/path/config.yaml", + Assets: fstest.MapFS{"index.html": &fstest.MapFile{Data: []byte("ok")}}, + }) -req := httptest.NewRequest(http.MethodGet, "/api/v1/profiles/myprofile", nil) -rec := httptest.NewRecorder() -handler.ServeHTTP(rec, req) + req := httptest.NewRequest(http.MethodGet, "/api/v1/profiles/myprofile", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) -// Should fail due to missing config -if rec.Code != http.StatusNotFound && rec.Code != http.StatusInternalServerError { -t.Logf("status code: %d", rec.Code) -} + // Should fail due to missing config + if rec.Code != http.StatusNotFound && rec.Code != http.StatusInternalServerError { + t.Logf("status code: %d", rec.Code) + } } func TestHandler_HandleSchemaTables_WithIncludeSystemTrue(t *testing.T) { -handler := NewHandler(HandlerOptions{ -ConfigPath: "testdata/config.yaml", -Assets: fstest.MapFS{"index.html": &fstest.MapFile{Data: []byte("ok")}}, -}) + handler := NewHandler(HandlerOptions{ + ConfigPath: "testdata/config.yaml", + Assets: fstest.MapFS{"index.html": &fstest.MapFile{Data: []byte("ok")}}, + }) -req := httptest.NewRequest(http.MethodGet, "/api/v1/schema/tables?include_system=true", nil) -rec := httptest.NewRecorder() -handler.ServeHTTP(rec, req) + req := httptest.NewRequest(http.MethodGet, "/api/v1/schema/tables?include_system=true", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) -// Accept any status - just verify it handles the param -t.Logf("status code: %d", rec.Code) + // Accept any status - just verify it handles the param + t.Logf("status code: %d", rec.Code) } func TestHandler_HandleQuery_EmptyProfile(t *testing.T) { -handler := NewHandler(HandlerOptions{ -ConfigPath: "testdata/config.yaml", -Assets: fstest.MapFS{"index.html": &fstest.MapFile{Data: []byte("ok")}}, -}) + handler := NewHandler(HandlerOptions{ + ConfigPath: "testdata/config.yaml", + Assets: fstest.MapFS{"index.html": &fstest.MapFile{Data: []byte("ok")}}, + }) -body := []byte(`{"profile":"","sql":"SELECT 1"}`) -req := httptest.NewRequest(http.MethodPost, "/api/v1/query", strings.NewReader(string(body))) -req.Header.Set("Content-Type", "application/json") -rec := httptest.NewRecorder() -handler.ServeHTTP(rec, req) + body := []byte(`{"profile":"","sql":"SELECT 1"}`) + req := httptest.NewRequest(http.MethodPost, "/api/v1/query", strings.NewReader(string(body))) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) -// Should handle empty profile -t.Logf("status code: %d", rec.Code) + // Should handle empty profile + t.Logf("status code: %d", rec.Code) } func TestHandler_HandleQuery_EmptySQL(t *testing.T) { -handler := NewHandler(HandlerOptions{ -ConfigPath: "testdata/config.yaml", -Assets: fstest.MapFS{"index.html": &fstest.MapFile{Data: []byte("ok")}}, -}) + handler := NewHandler(HandlerOptions{ + ConfigPath: "testdata/config.yaml", + Assets: fstest.MapFS{"index.html": &fstest.MapFile{Data: []byte("ok")}}, + }) -body := []byte(`{"profile":"dev","sql":""}`) -req := httptest.NewRequest(http.MethodPost, "/api/v1/query", strings.NewReader(string(body))) -req.Header.Set("Content-Type", "application/json") -rec := httptest.NewRecorder() -handler.ServeHTTP(rec, req) + body := []byte(`{"profile":"dev","sql":""}`) + req := httptest.NewRequest(http.MethodPost, "/api/v1/query", strings.NewReader(string(body))) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) -// Should handle empty SQL -t.Logf("status code: %d", rec.Code) + // Should handle empty SQL + t.Logf("status code: %d", rec.Code) } func TestHandler_LargeRequestBody(t *testing.T) { -handler := NewHandler(HandlerOptions{ -Assets: fstest.MapFS{"index.html": &fstest.MapFile{Data: []byte("ok")}}, -}) + handler := NewHandler(HandlerOptions{ + Assets: fstest.MapFS{"index.html": &fstest.MapFile{Data: []byte("ok")}}, + }) -// Create a body larger than 1MB limit -largeBody := strings.Repeat("x", 2*1024*1024) -req := httptest.NewRequest(http.MethodPost, "/api/v1/query", strings.NewReader(largeBody)) -req.Header.Set("Content-Type", "application/json") -rec := httptest.NewRecorder() -handler.ServeHTTP(rec, req) + // Create a body larger than 1MB limit + largeBody := strings.Repeat("x", 2*1024*1024) + req := httptest.NewRequest(http.MethodPost, "/api/v1/query", strings.NewReader(largeBody)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) -// Should reject due to size limit -t.Logf("status code: %d", rec.Code) + // Should reject due to size limit + t.Logf("status code: %d", rec.Code) } func TestHandler_Frontend_Directory(t *testing.T) { -handler := NewHandler(HandlerOptions{ -Assets: fstest.MapFS{"index.html": &fstest.MapFile{Data: []byte("ok")}}, -}) + handler := NewHandler(HandlerOptions{ + Assets: fstest.MapFS{"index.html": &fstest.MapFile{Data: []byte("ok")}}, + }) -req := httptest.NewRequest(http.MethodGet, "/app/", nil) -rec := httptest.NewRecorder() -handler.ServeHTTP(rec, req) + req := httptest.NewRequest(http.MethodGet, "/app/", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) -// Should serve something (possibly index.html or 404) -t.Logf("status code: %d", rec.Code) + // Should serve something (possibly index.html or 404) + t.Logf("status code: %d", rec.Code) } func TestHandler_FrontendNotFound(t *testing.T) { -handler := NewHandler(HandlerOptions{ -Assets: fstest.MapFS{}, // Empty assets -}) + handler := NewHandler(HandlerOptions{ + Assets: fstest.MapFS{}, // Empty assets + }) -req := httptest.NewRequest(http.MethodGet, "/", nil) -rec := httptest.NewRecorder() -handler.ServeHTTP(rec, req) + req := httptest.NewRequest(http.MethodGet, "/", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) -// Should handle missing index.html -t.Logf("status code: %d", rec.Code) + // Should handle missing index.html + t.Logf("status code: %d", rec.Code) } diff --git a/tests/e2e/schema_test.go b/tests/e2e/schema_test.go index 29fe16e..749ea2b 100644 --- a/tests/e2e/schema_test.go +++ b/tests/e2e/schema_test.go @@ -1,381 +1,381 @@ -//go:build e2e - -package e2e - -import ( - "encoding/json" - "fmt" - "testing" -) - -func TestSchemaDump_JSON(t *testing.T) { - config := createTempConfig(t, fmt.Sprintf(`profiles: - dev: - description: "开发环境" - db: mysql - dsn: "%s" -`, mysqlDSN(t))) - stdout, stderr, exitCode := runXSQL(t, "schema", "dump", "--config", config, "-p", "dev", "-f", "json") - - // 验证退出码 - if exitCode != 0 { - t.Fatalf("unexpected exit code %d, stderr: %s", exitCode, stderr) - } - - // 验证 JSON 格式 - var resp struct { - OK bool `json:"ok"` - SchemaVersion int `json:"schema_version"` - Data struct { - Database string `json:"database"` - Tables []struct { - Schema string `json:"schema"` - Name string `json:"name"` - Comment string `json:"comment"` - Columns []struct { - Name string `json:"name"` - Type string `json:"type"` - Nullable bool `json:"nullable"` - PrimaryKey bool `json:"primary_key"` - } `json:"columns"` - Indexes []struct { - Name string `json:"name"` - Columns []string `json:"columns"` - Unique bool `json:"unique"` - Primary bool `json:"primary"` - } `json:"indexes"` - ForeignKeys []struct { - Name string `json:"name"` - Columns []string `json:"columns"` - ReferencedTable string `json:"referenced_table"` - ReferencedColumns []string `json:"referenced_columns"` - } `json:"foreign_keys"` - } `json:"tables"` - } `json:"data"` - Error *struct { - Code string `json:"code"` - Message string `json:"message"` - } `json:"error,omitempty"` - } - - if err := json.Unmarshal([]byte(stdout), &resp); err != nil { - t.Errorf("failed to parse JSON output: %v, stdout: %s", err, stdout) - return - } - - // 验证 schema_version - if resp.SchemaVersion != 1 { - t.Errorf("schema_version = %d, want 1", resp.SchemaVersion) - } - - // 如果成功,验证数据结构 - if resp.OK { - if resp.Data.Database == "" { - t.Error("database name is empty") - } - // tables 可以为空(数据库无表) - } -} - -func TestSchemaDump_YAML(t *testing.T) { - config := createTempConfig(t, fmt.Sprintf(`profiles: - dev: - description: "开发环境" - db: pg - dsn: "%s" -`, pgDSN(t))) - stdout, stderr, exitCode := runXSQL(t, "schema", "dump", "--config", config, "-p", "dev", "-f", "yaml") - - // 验证退出码 - if exitCode != 0 { - t.Fatalf("unexpected exit code %d, stderr: %s", exitCode, stderr) - } - - // 验证 YAML 格式包含必要字段 - if !contains(stdout, "ok:") { - t.Error("YAML output missing 'ok:' field") - } - if !contains(stdout, "schema_version:") { - t.Error("YAML output missing 'schema_version:' field") - } -} - -func TestSchemaDump_Table(t *testing.T) { - config := createTempConfig(t, fmt.Sprintf(`profiles: - dev: - description: "开发环境" - db: mysql - dsn: "%s" -`, mysqlDSN(t))) - stdout, stderr, exitCode := runXSQL(t, "schema", "dump", "--config", config, "-p", "dev", "-f", "table") - - // 验证退出码 - if exitCode != 0 { - t.Fatalf("unexpected exit code %d, stderr: %s", exitCode, stderr) - } - - // 验证 Table 格式不包含 JSON 元数据 - if contains(stdout, `"ok"`) { - t.Error("Table output should not contain JSON 'ok' field") - } - if contains(stdout, `"schema_version"`) { - t.Error("Table output should not contain JSON 'schema_version' field") - } -} - -func TestSchemaDump_ProfileNotFound(t *testing.T) { - config := createTempConfig(t, `profiles: - dev: - db: mysql - host: 127.0.0.1 -`) - stdout, _, exitCode := runXSQL(t, "schema", "dump", "--config", config, "-p", "nonexistent", "-f", "json") - - // 验证退出码(配置错误) - if exitCode != 2 { - t.Errorf("exit code = %d, want 2", exitCode) - } - - // 验证错误响应 - var resp struct { - OK bool `json:"ok"` - Error struct { - Code string `json:"code"` - Message string `json:"message"` - } `json:"error"` - } - if err := json.Unmarshal([]byte(stdout), &resp); err != nil { - t.Errorf("failed to parse JSON: %v", err) - return - } - if resp.OK { - t.Error("expected ok=false") - } - if resp.Error.Code == "" { - t.Error("error code is empty") - } -} - -func TestSchemaDump_MissingProfile(t *testing.T) { - config := createTempConfig(t, `profiles: - dev: - db: mysql - host: 127.0.0.1 -`) - _, _, exitCode := runXSQL(t, "schema", "dump", "--config", config, "-f", "json") - - // 验证退出码(参数错误) - if exitCode != 2 { - t.Errorf("exit code = %d, want 2", exitCode) - } -} - -func TestSchemaDump_TableFilter(t *testing.T) { - config := createTempConfig(t, fmt.Sprintf(`profiles: - dev: - description: "开发环境" - db: mysql - dsn: "%s" -`, mysqlDSN(t))) - // 使用 --table 过滤 - stdout, stderr, exitCode := runXSQL(t, "schema", "dump", "--config", config, "-p", "dev", "-f", "json", "--table", "user*") - - // 验证退出码 - if exitCode != 0 { - t.Fatalf("unexpected exit code %d, stderr: %s", exitCode, stderr) - } - - // 如果成功,验证过滤生效 - if exitCode == 0 { - var resp struct { - OK bool `json:"ok"` - Data struct { - Tables []struct { - Name string `json:"name"` - } `json:"tables"` - } `json:"data"` - } - if err := json.Unmarshal([]byte(stdout), &resp); err != nil { - t.Errorf("failed to parse JSON: %v", err) - return - } - // 所有表名应该以 user 开头 - for _, table := range resp.Data.Tables { - if len(table.Name) < 4 || table.Name[:4] != "user" { - t.Errorf("table name %q does not match filter 'user*'", table.Name) - } - } - } -} - -func TestSchemaDump_Help(t *testing.T) { - stdout, _, exitCode := runXSQL(t, "schema", "dump", "--help") - - if exitCode != 0 { - t.Errorf("exit code = %d, want 0", exitCode) - } - - // 验证帮助信息包含关键内容 - if !contains(stdout, "schema dump") { - t.Error("help output missing 'schema dump'") - } - if !contains(stdout, "--table") { - t.Error("help output missing '--table' flag") - } - if !contains(stdout, "--include-system") { - t.Error("help output missing '--include-system' flag") - } -} - -func TestSchema_Command(t *testing.T) { - // 测试 schema 父命令 - stdout, _, exitCode := runXSQL(t, "schema", "--help") - - if exitCode != 0 { - t.Errorf("exit code = %d, want 0", exitCode) - } - - if !contains(stdout, "dump") { - t.Error("schema command help should mention 'dump' subcommand") - } -} - -func TestSchemaDump_MissingDBType(t *testing.T) { - config := createTempConfig(t, `profiles: - dev: - host: 127.0.0.1 -`) - stdout, _, exitCode := runXSQL(t, "schema", "dump", "--config", config, "-p", "dev", "-f", "json") - - if exitCode != 2 { - t.Errorf("exit code = %d, want 2", exitCode) - } - - var resp struct { - OK bool `json:"ok"` - Error struct { - Code string `json:"code"` - Message string `json:"message"` - } `json:"error"` - } - if err := json.Unmarshal([]byte(stdout), &resp); err != nil { - t.Fatalf("failed to parse JSON: %v", err) - } - if resp.OK { - t.Error("expected ok=false") - } - if resp.Error.Code == "" { - t.Error("error code is empty") - } -} - -func TestSchemaDump_PlaintextPasswordNotAllowed(t *testing.T) { - config := createTempConfig(t, fmt.Sprintf(`profiles: - dev: - description: "开发环境" - db: mysql - dsn: "%s" - password: "plain_password" -`, mysqlDSN(t))) - - stdout, _, exitCode := runXSQL(t, "schema", "dump", "--config", config, "-p", "dev", "-f", "json") - - if exitCode != 2 { - t.Errorf("exit code = %d, want 2", exitCode) - } - - var resp struct { - OK bool `json:"ok"` - Error struct { - Code string `json:"code"` - Message string `json:"message"` - } `json:"error"` - } - if err := json.Unmarshal([]byte(stdout), &resp); err != nil { - t.Fatalf("failed to parse JSON: %v", err) - } - if resp.OK { - t.Error("expected ok=false") - } - if resp.Error.Code == "" { - t.Error("error code is empty") - } -} - -func TestSchemaDump_InvalidFormat(t *testing.T) { - config := createTempConfig(t, fmt.Sprintf(`profiles: - dev: - description: "开发环境" - db: mysql - dsn: "%s" -`, mysqlDSN(t))) - - stdout, _, exitCode := runXSQL(t, "schema", "dump", "--config", config, "-p", "dev", "-f", "invalid") - - if exitCode != 2 { - t.Errorf("exit code = %d, want 2", exitCode) - } - - var resp struct { - OK bool `json:"ok"` - Error struct { - Code string `json:"code"` - Message string `json:"message"` - } `json:"error"` - } - if err := json.Unmarshal([]byte(stdout), &resp); err != nil { - t.Fatalf("failed to parse JSON: %v", err) - } - if resp.OK { - t.Error("expected ok=false") - } - if resp.Error.Code == "" { - t.Error("error code is empty") - } -} - -func TestSchemaDump_UnsupportedDriver(t *testing.T) { - config := createTempConfig(t, fmt.Sprintf(`profiles: - dev: - description: "开发环境" - db: sqlite - dsn: "%s" -`, mysqlDSN(t))) - - stdout, _, exitCode := runXSQL(t, "schema", "dump", "--config", config, "-p", "dev", "-f", "json") - - if exitCode == 0 { - t.Errorf("exit code = %d, want non-zero", exitCode) - } - - var resp struct { - OK bool `json:"ok"` - Error struct { - Code string `json:"code"` - Message string `json:"message"` - } `json:"error"` - } - if err := json.Unmarshal([]byte(stdout), &resp); err != nil { - t.Fatalf("failed to parse JSON: %v", err) - } - if resp.OK { - t.Error("expected ok=false") - } - if resp.Error.Code == "" { - t.Error("error code is empty") - } -} - -// Helper function to check if string contains substring -func contains(s, substr string) bool { - return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsHelper(s, substr)) -} - -func containsHelper(s, substr string) bool { - for i := 0; i <= len(s)-len(substr); i++ { - if s[i:i+len(substr)] == substr { - return true - } - } - return false -} +//go:build e2e + +package e2e + +import ( + "encoding/json" + "fmt" + "testing" +) + +func TestSchemaDump_JSON(t *testing.T) { + config := createTempConfig(t, fmt.Sprintf(`profiles: + dev: + description: "开发环境" + db: mysql + dsn: "%s" +`, mysqlDSN(t))) + stdout, stderr, exitCode := runXSQL(t, "schema", "dump", "--config", config, "-p", "dev", "-f", "json") + + // 验证退出码 + if exitCode != 0 { + t.Fatalf("unexpected exit code %d, stderr: %s", exitCode, stderr) + } + + // 验证 JSON 格式 + var resp struct { + OK bool `json:"ok"` + SchemaVersion int `json:"schema_version"` + Data struct { + Database string `json:"database"` + Tables []struct { + Schema string `json:"schema"` + Name string `json:"name"` + Comment string `json:"comment"` + Columns []struct { + Name string `json:"name"` + Type string `json:"type"` + Nullable bool `json:"nullable"` + PrimaryKey bool `json:"primary_key"` + } `json:"columns"` + Indexes []struct { + Name string `json:"name"` + Columns []string `json:"columns"` + Unique bool `json:"unique"` + Primary bool `json:"primary"` + } `json:"indexes"` + ForeignKeys []struct { + Name string `json:"name"` + Columns []string `json:"columns"` + ReferencedTable string `json:"referenced_table"` + ReferencedColumns []string `json:"referenced_columns"` + } `json:"foreign_keys"` + } `json:"tables"` + } `json:"data"` + Error *struct { + Code string `json:"code"` + Message string `json:"message"` + } `json:"error,omitempty"` + } + + if err := json.Unmarshal([]byte(stdout), &resp); err != nil { + t.Errorf("failed to parse JSON output: %v, stdout: %s", err, stdout) + return + } + + // 验证 schema_version + if resp.SchemaVersion != 1 { + t.Errorf("schema_version = %d, want 1", resp.SchemaVersion) + } + + // 如果成功,验证数据结构 + if resp.OK { + if resp.Data.Database == "" { + t.Error("database name is empty") + } + // tables 可以为空(数据库无表) + } +} + +func TestSchemaDump_YAML(t *testing.T) { + config := createTempConfig(t, fmt.Sprintf(`profiles: + dev: + description: "开发环境" + db: pg + dsn: "%s" +`, pgDSN(t))) + stdout, stderr, exitCode := runXSQL(t, "schema", "dump", "--config", config, "-p", "dev", "-f", "yaml") + + // 验证退出码 + if exitCode != 0 { + t.Fatalf("unexpected exit code %d, stderr: %s", exitCode, stderr) + } + + // 验证 YAML 格式包含必要字段 + if !contains(stdout, "ok:") { + t.Error("YAML output missing 'ok:' field") + } + if !contains(stdout, "schema_version:") { + t.Error("YAML output missing 'schema_version:' field") + } +} + +func TestSchemaDump_Table(t *testing.T) { + config := createTempConfig(t, fmt.Sprintf(`profiles: + dev: + description: "开发环境" + db: mysql + dsn: "%s" +`, mysqlDSN(t))) + stdout, stderr, exitCode := runXSQL(t, "schema", "dump", "--config", config, "-p", "dev", "-f", "table") + + // 验证退出码 + if exitCode != 0 { + t.Fatalf("unexpected exit code %d, stderr: %s", exitCode, stderr) + } + + // 验证 Table 格式不包含 JSON 元数据 + if contains(stdout, `"ok"`) { + t.Error("Table output should not contain JSON 'ok' field") + } + if contains(stdout, `"schema_version"`) { + t.Error("Table output should not contain JSON 'schema_version' field") + } +} + +func TestSchemaDump_ProfileNotFound(t *testing.T) { + config := createTempConfig(t, `profiles: + dev: + db: mysql + host: 127.0.0.1 +`) + stdout, _, exitCode := runXSQL(t, "schema", "dump", "--config", config, "-p", "nonexistent", "-f", "json") + + // 验证退出码(配置错误) + if exitCode != 2 { + t.Errorf("exit code = %d, want 2", exitCode) + } + + // 验证错误响应 + var resp struct { + OK bool `json:"ok"` + Error struct { + Code string `json:"code"` + Message string `json:"message"` + } `json:"error"` + } + if err := json.Unmarshal([]byte(stdout), &resp); err != nil { + t.Errorf("failed to parse JSON: %v", err) + return + } + if resp.OK { + t.Error("expected ok=false") + } + if resp.Error.Code == "" { + t.Error("error code is empty") + } +} + +func TestSchemaDump_MissingProfile(t *testing.T) { + config := createTempConfig(t, `profiles: + dev: + db: mysql + host: 127.0.0.1 +`) + _, _, exitCode := runXSQL(t, "schema", "dump", "--config", config, "-f", "json") + + // 验证退出码(参数错误) + if exitCode != 2 { + t.Errorf("exit code = %d, want 2", exitCode) + } +} + +func TestSchemaDump_TableFilter(t *testing.T) { + config := createTempConfig(t, fmt.Sprintf(`profiles: + dev: + description: "开发环境" + db: mysql + dsn: "%s" +`, mysqlDSN(t))) + // 使用 --table 过滤 + stdout, stderr, exitCode := runXSQL(t, "schema", "dump", "--config", config, "-p", "dev", "-f", "json", "--table", "user*") + + // 验证退出码 + if exitCode != 0 { + t.Fatalf("unexpected exit code %d, stderr: %s", exitCode, stderr) + } + + // 如果成功,验证过滤生效 + if exitCode == 0 { + var resp struct { + OK bool `json:"ok"` + Data struct { + Tables []struct { + Name string `json:"name"` + } `json:"tables"` + } `json:"data"` + } + if err := json.Unmarshal([]byte(stdout), &resp); err != nil { + t.Errorf("failed to parse JSON: %v", err) + return + } + // 所有表名应该以 user 开头 + for _, table := range resp.Data.Tables { + if len(table.Name) < 4 || table.Name[:4] != "user" { + t.Errorf("table name %q does not match filter 'user*'", table.Name) + } + } + } +} + +func TestSchemaDump_Help(t *testing.T) { + stdout, _, exitCode := runXSQL(t, "schema", "dump", "--help") + + if exitCode != 0 { + t.Errorf("exit code = %d, want 0", exitCode) + } + + // 验证帮助信息包含关键内容 + if !contains(stdout, "schema dump") { + t.Error("help output missing 'schema dump'") + } + if !contains(stdout, "--table") { + t.Error("help output missing '--table' flag") + } + if !contains(stdout, "--include-system") { + t.Error("help output missing '--include-system' flag") + } +} + +func TestSchema_Command(t *testing.T) { + // 测试 schema 父命令 + stdout, _, exitCode := runXSQL(t, "schema", "--help") + + if exitCode != 0 { + t.Errorf("exit code = %d, want 0", exitCode) + } + + if !contains(stdout, "dump") { + t.Error("schema command help should mention 'dump' subcommand") + } +} + +func TestSchemaDump_MissingDBType(t *testing.T) { + config := createTempConfig(t, `profiles: + dev: + host: 127.0.0.1 +`) + stdout, _, exitCode := runXSQL(t, "schema", "dump", "--config", config, "-p", "dev", "-f", "json") + + if exitCode != 2 { + t.Errorf("exit code = %d, want 2", exitCode) + } + + var resp struct { + OK bool `json:"ok"` + Error struct { + Code string `json:"code"` + Message string `json:"message"` + } `json:"error"` + } + if err := json.Unmarshal([]byte(stdout), &resp); err != nil { + t.Fatalf("failed to parse JSON: %v", err) + } + if resp.OK { + t.Error("expected ok=false") + } + if resp.Error.Code == "" { + t.Error("error code is empty") + } +} + +func TestSchemaDump_PlaintextPasswordNotAllowed(t *testing.T) { + config := createTempConfig(t, fmt.Sprintf(`profiles: + dev: + description: "开发环境" + db: mysql + dsn: "%s" + password: "plain_password" +`, mysqlDSN(t))) + + stdout, _, exitCode := runXSQL(t, "schema", "dump", "--config", config, "-p", "dev", "-f", "json") + + if exitCode != 2 { + t.Errorf("exit code = %d, want 2", exitCode) + } + + var resp struct { + OK bool `json:"ok"` + Error struct { + Code string `json:"code"` + Message string `json:"message"` + } `json:"error"` + } + if err := json.Unmarshal([]byte(stdout), &resp); err != nil { + t.Fatalf("failed to parse JSON: %v", err) + } + if resp.OK { + t.Error("expected ok=false") + } + if resp.Error.Code == "" { + t.Error("error code is empty") + } +} + +func TestSchemaDump_InvalidFormat(t *testing.T) { + config := createTempConfig(t, fmt.Sprintf(`profiles: + dev: + description: "开发环境" + db: mysql + dsn: "%s" +`, mysqlDSN(t))) + + stdout, _, exitCode := runXSQL(t, "schema", "dump", "--config", config, "-p", "dev", "-f", "invalid") + + if exitCode != 2 { + t.Errorf("exit code = %d, want 2", exitCode) + } + + var resp struct { + OK bool `json:"ok"` + Error struct { + Code string `json:"code"` + Message string `json:"message"` + } `json:"error"` + } + if err := json.Unmarshal([]byte(stdout), &resp); err != nil { + t.Fatalf("failed to parse JSON: %v", err) + } + if resp.OK { + t.Error("expected ok=false") + } + if resp.Error.Code == "" { + t.Error("error code is empty") + } +} + +func TestSchemaDump_UnsupportedDriver(t *testing.T) { + config := createTempConfig(t, fmt.Sprintf(`profiles: + dev: + description: "开发环境" + db: sqlite + dsn: "%s" +`, mysqlDSN(t))) + + stdout, _, exitCode := runXSQL(t, "schema", "dump", "--config", config, "-p", "dev", "-f", "json") + + if exitCode == 0 { + t.Errorf("exit code = %d, want non-zero", exitCode) + } + + var resp struct { + OK bool `json:"ok"` + Error struct { + Code string `json:"code"` + Message string `json:"message"` + } `json:"error"` + } + if err := json.Unmarshal([]byte(stdout), &resp); err != nil { + t.Fatalf("failed to parse JSON: %v", err) + } + if resp.OK { + t.Error("expected ok=false") + } + if resp.Error.Code == "" { + t.Error("error code is empty") + } +} + +// Helper function to check if string contains substring +func contains(s, substr string) bool { + return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsHelper(s, substr)) +} + +func containsHelper(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +}