mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-08-10 00:33:28 +08:00
- 在表概览及新旧侧栏右键菜单接入复制整表入口与确认、进度和刷新反馈 - 按 source_copyN 原子创建目标表并复制列、索引、默认值及全部数据 - 处理 PostgreSQL 生成列、identity/serial 序列校准与失败清理 - 限定安全数据源与连接保护策略,阻止分区表、RLS 及引用型存储引擎 - 加固 MySQL/PostgreSQL 元数据标识符处理并补齐六语种文案 - 增加后端、能力矩阵、菜单接线和国际化回归测试
792 lines
29 KiB
Go
792 lines
29 KiB
Go
package app
|
|
|
|
import (
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
"unicode/utf8"
|
|
|
|
"GoNavi-Wails/internal/connection"
|
|
"GoNavi-Wails/internal/db"
|
|
"GoNavi-Wails/internal/secretstore"
|
|
"GoNavi-Wails/internal/sqlaudit"
|
|
)
|
|
|
|
type fakeCopyTableDB struct {
|
|
columns []connection.ColumnDefinition
|
|
columnsErr error
|
|
queryRows []map[string]interface{}
|
|
queryErr error
|
|
queryFunc func(string) ([]map[string]interface{}, error)
|
|
queryQueries []string
|
|
sourceEngine *string
|
|
pgSafetyRows []map[string]interface{}
|
|
sequenceRows []map[string]interface{}
|
|
execQueries []string
|
|
execFailures map[int]error
|
|
}
|
|
|
|
func (f *fakeCopyTableDB) Connect(connection.ConnectionConfig) error { return nil }
|
|
func (f *fakeCopyTableDB) Close() error { return nil }
|
|
func (f *fakeCopyTableDB) Ping() error { return nil }
|
|
|
|
func (f *fakeCopyTableDB) Query(query string) ([]map[string]interface{}, []string, error) {
|
|
f.queryQueries = append(f.queryQueries, query)
|
|
if strings.Contains(query, "information_schema.tables") && strings.Contains(query, "ENGINE AS engine") {
|
|
engine := "InnoDB"
|
|
if f.sourceEngine != nil {
|
|
engine = *f.sourceEngine
|
|
}
|
|
return []map[string]interface{}{{"engine": engine}}, nil, nil
|
|
}
|
|
if strings.Contains(query, "c.relkind AS relation_kind") {
|
|
if f.pgSafetyRows != nil {
|
|
return f.pgSafetyRows, nil, nil
|
|
}
|
|
return []map[string]interface{}{{
|
|
"relation_kind": "r",
|
|
"persistence": "p",
|
|
"row_security": false,
|
|
}}, nil, nil
|
|
}
|
|
if strings.Contains(query, "FROM pg_catalog.pg_sequence") {
|
|
if f.sequenceRows != nil {
|
|
return f.sequenceRows, nil, nil
|
|
}
|
|
return []map[string]interface{}{{
|
|
"data_type": "bigint",
|
|
"seqstart": int64(1),
|
|
"seqincrement": int64(1),
|
|
"seqmin": int64(1),
|
|
"seqmax": int64(9223372036854775807),
|
|
"seqcache": int64(1),
|
|
"seqcycle": false,
|
|
}}, nil, nil
|
|
}
|
|
if f.queryFunc != nil {
|
|
rows, err := f.queryFunc(query)
|
|
return rows, nil, err
|
|
}
|
|
return f.queryRows, nil, f.queryErr
|
|
}
|
|
func (f *fakeCopyTableDB) Exec(query string) (int64, error) {
|
|
f.execQueries = append(f.execQueries, query)
|
|
if err := f.execFailures[len(f.execQueries)]; err != nil {
|
|
return 0, err
|
|
}
|
|
return 0, nil
|
|
}
|
|
func (f *fakeCopyTableDB) GetDatabases() ([]string, error) { return nil, nil }
|
|
func (f *fakeCopyTableDB) GetTables(string) ([]string, error) {
|
|
return nil, nil
|
|
}
|
|
func (f *fakeCopyTableDB) GetCreateStatement(string, string) (string, error) {
|
|
return "", nil
|
|
}
|
|
func (f *fakeCopyTableDB) GetColumns(string, string) ([]connection.ColumnDefinition, error) {
|
|
return f.columns, f.columnsErr
|
|
}
|
|
func (f *fakeCopyTableDB) GetAllColumns(string) ([]connection.ColumnDefinitionWithTable, error) {
|
|
return nil, nil
|
|
}
|
|
func (f *fakeCopyTableDB) GetIndexes(string, string) ([]connection.IndexDefinition, error) {
|
|
return nil, nil
|
|
}
|
|
func (f *fakeCopyTableDB) GetForeignKeys(string, string) ([]connection.ForeignKeyDefinition, error) {
|
|
return nil, nil
|
|
}
|
|
func (f *fakeCopyTableDB) GetTriggers(string, string) ([]connection.TriggerDefinition, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
var _ db.Database = (*fakeCopyTableDB)(nil)
|
|
|
|
func installCopyTableTestDatabase(t *testing.T, database db.Database) *App {
|
|
t.Helper()
|
|
originalNewDatabaseFunc := newDatabaseFunc
|
|
originalResolveDialConfigWithProxyFunc := resolveDialConfigWithProxyFunc
|
|
t.Cleanup(func() {
|
|
newDatabaseFunc = originalNewDatabaseFunc
|
|
resolveDialConfigWithProxyFunc = originalResolveDialConfigWithProxyFunc
|
|
})
|
|
newDatabaseFunc = func(string) (db.Database, error) { return database, nil }
|
|
resolveDialConfigWithProxyFunc = func(config connection.ConnectionConfig) (connection.ConnectionConfig, error) {
|
|
return config, nil
|
|
}
|
|
return NewAppWithSecretStore(secretstore.NewUnavailableStore("test"))
|
|
}
|
|
|
|
func TestCopyTableMySQLChoosesNextSuffixAndCopiesWritableColumns(t *testing.T) {
|
|
database := &fakeCopyTableDB{
|
|
columns: []connection.ColumnDefinition{
|
|
{Name: "id", Extra: "auto_increment"},
|
|
{Name: "name"},
|
|
{Name: "search_text", Extra: "STORED GENERATED"},
|
|
},
|
|
execFailures: map[int]error{1: errors.New("table already exists (Error 1050)")},
|
|
}
|
|
app := installCopyTableTestDatabase(t, database)
|
|
|
|
result := app.CopyTable(connection.ConnectionConfig{Type: "mysql", Database: "app"}, "app", "app", "users")
|
|
|
|
if !result.Success {
|
|
t.Fatalf("CopyTable returned failure: %s", result.Message)
|
|
}
|
|
if result.Data != "users_copy2" {
|
|
t.Fatalf("CopyTable target = %#v, want users_copy2", result.Data)
|
|
}
|
|
want := []string{
|
|
"CREATE TABLE `app`.`users_copy1` LIKE `app`.`users`",
|
|
"CREATE TABLE `app`.`users_copy2` LIKE `app`.`users`",
|
|
"INSERT INTO `app`.`users_copy2` (`id`, `name`) SELECT `id`, `name` FROM `app`.`users`",
|
|
}
|
|
if len(database.execQueries) != len(want) {
|
|
t.Fatalf("Exec count = %d, want %d: %#v", len(database.execQueries), len(want), database.execQueries)
|
|
}
|
|
for index := range want {
|
|
if database.execQueries[index] != want[index] {
|
|
t.Fatalf("Exec[%d] = %q, want %q", index, database.execQueries[index], want[index])
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCopyTableMySQLKeepsDotsInsideTableName(t *testing.T) {
|
|
database := &fakeCopyTableDB{
|
|
columns: []connection.ColumnDefinition{{Name: "id"}},
|
|
execFailures: map[int]error{1: errors.New("table already exists (Error 1050)")},
|
|
}
|
|
app := installCopyTableTestDatabase(t, database)
|
|
|
|
result := app.CopyTable(connection.ConnectionConfig{Type: "mysql", Database: "app"}, "app", "app", "audit.logs")
|
|
|
|
if !result.Success {
|
|
t.Fatalf("CopyTable returned failure: %s", result.Message)
|
|
}
|
|
if result.Data != "audit.logs_copy2" {
|
|
t.Fatalf("CopyTable target = %#v, want audit.logs_copy2", result.Data)
|
|
}
|
|
want := []string{
|
|
"CREATE TABLE `app`.`audit.logs_copy1` LIKE `app`.`audit.logs`",
|
|
"CREATE TABLE `app`.`audit.logs_copy2` LIKE `app`.`audit.logs`",
|
|
"INSERT INTO `app`.`audit.logs_copy2` (`id`) SELECT `id` FROM `app`.`audit.logs`",
|
|
}
|
|
if len(database.execQueries) != len(want) {
|
|
t.Fatalf("Exec count = %d, want %d: %#v", len(database.execQueries), len(want), database.execQueries)
|
|
}
|
|
for index := range want {
|
|
if database.execQueries[index] != want[index] {
|
|
t.Fatalf("Exec[%d] = %q, want %q", index, database.execQueries[index], want[index])
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCopyTableStopsWhenColumnMetadataIsUnavailable(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
columns []connection.ColumnDefinition
|
|
columnsErr error
|
|
wantDetail string
|
|
}{
|
|
{
|
|
name: "query failed",
|
|
columnsErr: errors.New("column metadata unavailable"),
|
|
wantDetail: "column metadata unavailable",
|
|
},
|
|
{
|
|
name: "empty metadata",
|
|
columns: []connection.ColumnDefinition{},
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
database := &fakeCopyTableDB{
|
|
columns: test.columns,
|
|
columnsErr: test.columnsErr,
|
|
}
|
|
app := installCopyTableTestDatabase(t, database)
|
|
wantDetail := test.wantDetail
|
|
if test.columnsErr == nil {
|
|
wantDetail = app.appText("db.backend.error.table_columns_missing_for_ddl", nil)
|
|
}
|
|
|
|
result := app.CopyTable(connection.ConnectionConfig{Type: "mysql"}, "app", "app", "users")
|
|
|
|
if result.Success {
|
|
t.Fatal("CopyTable unexpectedly succeeded")
|
|
}
|
|
if wantDetail != "" && !strings.Contains(result.Message, wantDetail) {
|
|
t.Fatalf("failure message = %q, want detail %q", result.Message, wantDetail)
|
|
}
|
|
if len(database.execQueries) != 0 {
|
|
t.Fatalf("CopyTable executed SQL without column metadata: %#v", database.execQueries)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCopyTableRejectsTableWithoutWritableColumns(t *testing.T) {
|
|
database := &fakeCopyTableDB{
|
|
columns: []connection.ColumnDefinition{
|
|
{Name: "computed_value", Extra: "VIRTUAL GENERATED"},
|
|
},
|
|
}
|
|
app := installCopyTableTestDatabase(t, database)
|
|
|
|
result := app.CopyTable(connection.ConnectionConfig{Type: "mysql"}, "app", "app", "computed_values")
|
|
|
|
if result.Success {
|
|
t.Fatal("CopyTable unexpectedly succeeded")
|
|
}
|
|
if len(database.execQueries) != 0 {
|
|
t.Fatalf("CopyTable executed SQL without writable columns: %#v", database.execQueries)
|
|
}
|
|
}
|
|
|
|
func TestCopyTableWritesOneObjectEditorAuditEvent(t *testing.T) {
|
|
originalNewDatabaseFunc := newDatabaseFunc
|
|
originalResolveDialConfigWithProxyFunc := resolveDialConfigWithProxyFunc
|
|
t.Cleanup(func() {
|
|
newDatabaseFunc = originalNewDatabaseFunc
|
|
resolveDialConfigWithProxyFunc = originalResolveDialConfigWithProxyFunc
|
|
})
|
|
database := &fakeCopyTableDB{columns: []connection.ColumnDefinition{{Name: "id"}, {Name: "name"}}}
|
|
newDatabaseFunc = func(string) (db.Database, error) { return database, nil }
|
|
resolveDialConfigWithProxyFunc = func(config connection.ConnectionConfig) (connection.ConnectionConfig, error) {
|
|
return config, nil
|
|
}
|
|
app := newSQLAuditTestApp(t)
|
|
|
|
result := app.CopyTable(connection.ConnectionConfig{Type: "mysql", Database: "app"}, "app", "app", "users")
|
|
|
|
if !result.Success {
|
|
t.Fatalf("CopyTable result = %#v, want audited success", result)
|
|
}
|
|
events := loadSQLAuditEvents(t, app, sqlaudit.Filter{})
|
|
if len(events) != 1 {
|
|
t.Fatalf("audit event count = %d, want 1: %#v", len(events), events)
|
|
}
|
|
event := events[0]
|
|
if event.Source != "object_editor" || event.Status != "success" || event.StatementCount != 2 {
|
|
t.Fatalf("unexpected CopyTable audit event: %#v", event)
|
|
}
|
|
if !strings.Contains(event.SQLText, "CREATE TABLE") || !strings.Contains(event.SQLText, "INSERT INTO") {
|
|
t.Fatalf("CopyTable audit SQL missing executed statements: %#v", event)
|
|
}
|
|
}
|
|
|
|
func TestCopyTableRejectsReferenceStorageEngines(t *testing.T) {
|
|
engine := "FEDERATED"
|
|
database := &fakeCopyTableDB{sourceEngine: &engine}
|
|
app := installCopyTableTestDatabase(t, database)
|
|
|
|
result := app.CopyTable(connection.ConnectionConfig{Type: "mysql"}, "app", "app", "remote_users")
|
|
|
|
if result.Success {
|
|
t.Fatal("CopyTable unexpectedly succeeded for FEDERATED")
|
|
}
|
|
if !strings.Contains(result.Message, "ENGINE=FEDERATED") {
|
|
t.Fatalf("failure message = %q, want engine detail", result.Message)
|
|
}
|
|
if len(database.execQueries) != 0 {
|
|
t.Fatalf("unsafe engine executed SQL: %#v", database.execQueries)
|
|
}
|
|
}
|
|
|
|
func TestCopyTableRejectsPostgresPartitionedAndRLSSources(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
row map[string]interface{}
|
|
}{
|
|
{name: "partitioned", row: map[string]interface{}{"relation_kind": "p", "persistence": "p", "row_security": false}},
|
|
{name: "row security", row: map[string]interface{}{"relation_kind": "r", "persistence": "p", "row_security": true}},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
database := &fakeCopyTableDB{pgSafetyRows: []map[string]interface{}{test.row}}
|
|
app := installCopyTableTestDatabase(t, database)
|
|
|
|
result := app.CopyTable(connection.ConnectionConfig{Type: "postgres"}, "app", "public", "orders")
|
|
|
|
if result.Success {
|
|
t.Fatal("CopyTable unexpectedly succeeded")
|
|
}
|
|
if len(database.execQueries) != 0 {
|
|
t.Fatalf("unsafe PostgreSQL source executed SQL: %#v", database.execQueries)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestBuildCopyTablePlanUsesNativeDialectSyntax(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
dbType string
|
|
schema string
|
|
source string
|
|
target string
|
|
columns []string
|
|
wantCreate string
|
|
wantInsert string
|
|
}{
|
|
{
|
|
name: "postgres",
|
|
dbType: "postgres",
|
|
schema: "sales",
|
|
source: "orders",
|
|
target: "orders_copy1",
|
|
columns: []string{"id", "total"},
|
|
wantCreate: `CREATE TABLE "sales"."orders_copy1" (LIKE "sales"."orders" INCLUDING ALL)`,
|
|
wantInsert: `INSERT INTO "sales"."orders_copy1" ("id", "total") OVERRIDING SYSTEM VALUE SELECT "id", "total" FROM "sales"."orders"`,
|
|
},
|
|
{
|
|
name: "mysql",
|
|
dbType: "mysql",
|
|
schema: "warehouse",
|
|
source: "facts",
|
|
target: "facts_copy1",
|
|
columns: []string{"id", "value"},
|
|
wantCreate: "CREATE TABLE `warehouse`.`facts_copy1` LIKE `warehouse`.`facts`",
|
|
wantInsert: "INSERT INTO `warehouse`.`facts_copy1` (`id`, `value`) SELECT `id`, `value` FROM `warehouse`.`facts`",
|
|
},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
plan := buildCopyTablePlan(test.dbType, test.schema, test.source, test.target, copyTableColumnMetadata{
|
|
writableColumns: test.columns,
|
|
})
|
|
if plan.createSQL != test.wantCreate {
|
|
t.Fatalf("create SQL = %q, want %q", plan.createSQL, test.wantCreate)
|
|
}
|
|
if plan.insertSQL != test.wantInsert {
|
|
t.Fatalf("insert SQL = %q, want %q", plan.insertSQL, test.wantInsert)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCopyTablePostgresOmitsGeneratedColumns(t *testing.T) {
|
|
database := &fakeCopyTableDB{
|
|
columns: []connection.ColumnDefinition{
|
|
{Name: "id"},
|
|
{Name: "subtotal"},
|
|
{Name: "tax"},
|
|
{Name: "Amount"},
|
|
{Name: "amount"},
|
|
},
|
|
queryFunc: func(query string) ([]map[string]interface{}, error) {
|
|
if strings.Contains(query, "generated_kind") {
|
|
return []map[string]interface{}{{"column_name": "Amount", "generated_kind": "s"}}, nil
|
|
}
|
|
return nil, nil
|
|
},
|
|
}
|
|
app := installCopyTableTestDatabase(t, database)
|
|
|
|
result := app.CopyTable(connection.ConnectionConfig{Type: "postgres"}, "shop", "sales", "orders")
|
|
|
|
if !result.Success {
|
|
t.Fatalf("CopyTable returned failure: %s", result.Message)
|
|
}
|
|
if len(database.execQueries) != 2 {
|
|
t.Fatalf("Exec count = %d, want 2: %#v", len(database.execQueries), database.execQueries)
|
|
}
|
|
wantInsert := `INSERT INTO "sales"."orders_copy1" ("id", "subtotal", "tax", "amount") OVERRIDING SYSTEM VALUE SELECT "id", "subtotal", "tax", "amount" FROM "sales"."orders"`
|
|
if database.execQueries[1] != wantInsert {
|
|
t.Fatalf("insert SQL = %q, want %q", database.execQueries[1], wantInsert)
|
|
}
|
|
}
|
|
|
|
func TestCopyTablePostgresRebuildsSerialAndAdvancesIdentitySequences(t *testing.T) {
|
|
serialDefault := "nextval('sales.orders_id_seq'::regclass)"
|
|
database := &fakeCopyTableDB{
|
|
columns: []connection.ColumnDefinition{
|
|
{Name: "id", Default: &serialDefault},
|
|
{Name: "external_id"},
|
|
{Name: "name"},
|
|
},
|
|
queryFunc: func(query string) ([]map[string]interface{}, error) {
|
|
if strings.Contains(query, "identity_kind") {
|
|
return []map[string]interface{}{{"column_name": "external_id", "identity_kind": "a"}}, nil
|
|
}
|
|
return nil, nil
|
|
},
|
|
sequenceRows: []map[string]interface{}{{
|
|
"data_type": "integer",
|
|
"seqstart": int64(100),
|
|
"seqincrement": int64(-1),
|
|
"seqmin": int64(-2147483648),
|
|
"seqmax": int64(100),
|
|
"seqcache": int64(5),
|
|
"seqcycle": true,
|
|
}},
|
|
}
|
|
app := installCopyTableTestDatabase(t, database)
|
|
|
|
result := app.CopyTable(connection.ConnectionConfig{Type: "postgres"}, "shop", "sales", "orders")
|
|
|
|
if !result.Success {
|
|
t.Fatalf("CopyTable returned failure: %s", result.Message)
|
|
}
|
|
if len(database.execQueries) != 7 {
|
|
t.Fatalf("Exec count = %d, want 7: %#v", len(database.execQueries), database.execQueries)
|
|
}
|
|
sequenceName := buildPostgresCopyTableSequenceName("orders_copy1", "id")
|
|
wantCreateSequence := `CREATE SEQUENCE "sales"."` + sequenceName + `" AS integer INCREMENT BY -1 MINVALUE -2147483648 MAXVALUE 100 START WITH 100 CACHE 5 CYCLE`
|
|
if database.execQueries[2] != wantCreateSequence {
|
|
t.Fatalf("serial sequence create SQL = %q", database.execQueries[2])
|
|
}
|
|
if !strings.Contains(database.execQueries[4], `SET DEFAULT pg_catalog.nextval($gonavi_copy_0$"sales"."`+sequenceName+`"$gonavi_copy_0$::regclass)`) {
|
|
t.Fatalf("serial default was not rewired: %q", database.execQueries[4])
|
|
}
|
|
if !strings.Contains(database.execQueries[6], `pg_catalog.pg_get_serial_sequence($gonavi_copy_0$"sales"."orders_copy1"$gonavi_copy_0$, $gonavi_copy_0$external_id$gonavi_copy_0$)`) {
|
|
t.Fatalf("identity sequence was not advanced: %q", database.execQueries[6])
|
|
}
|
|
if !strings.Contains(database.execQueries[5], `pg_catalog.min("id")`) || !strings.Contains(database.execQueries[6], `pg_catalog.min("external_id")`) {
|
|
t.Fatalf("descending sequences were not calibrated with MIN: %#v", database.execQueries[5:])
|
|
}
|
|
}
|
|
|
|
func TestCopyTableMetadataQueriesEncodeAdversarialIdentifiers(t *testing.T) {
|
|
t.Run("mysql hex predicates", func(t *testing.T) {
|
|
database := &fakeCopyTableDB{}
|
|
schemaName := `app'; DROP TABLE audit_log; --`
|
|
tableName := "users` WHERE 1=1; --"
|
|
|
|
if err := ensureCopyTableSourceIsIndependent(database, "mysql", schemaName, tableName); err != nil {
|
|
t.Fatalf("metadata query failed: %v", err)
|
|
}
|
|
if len(database.queryQueries) != 1 {
|
|
t.Fatalf("query count = %d, want 1", len(database.queryQueries))
|
|
}
|
|
query := database.queryQueries[0]
|
|
if strings.Contains(query, schemaName) || strings.Contains(query, tableName) {
|
|
t.Fatalf("MySQL metadata query contains a raw identifier: %s", query)
|
|
}
|
|
for _, identifier := range []string{schemaName, tableName} {
|
|
if encoded := mysqlCopyTableMetadataHex(identifier); !strings.Contains(query, "'"+encoded+"'") {
|
|
t.Fatalf("MySQL metadata query does not contain HEX(%q): %s", identifier, query)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("postgres dollar quoted predicates", func(t *testing.T) {
|
|
database := &fakeCopyTableDB{}
|
|
schemaName := `sales$gonavi_copy_0$'; DROP SCHEMA public CASCADE; --`
|
|
tableName := `orders'; DROP TABLE audit_log; --`
|
|
|
|
if err := ensureCopyTableSourceIsIndependent(database, "postgres", schemaName, tableName); err != nil {
|
|
t.Fatalf("metadata query failed: %v", err)
|
|
}
|
|
if len(database.queryQueries) != 1 {
|
|
t.Fatalf("query count = %d, want 1", len(database.queryQueries))
|
|
}
|
|
query := database.queryQueries[0]
|
|
for _, identifier := range []string{schemaName, tableName} {
|
|
literal := postgresCopyTableSQLLiteral(identifier)
|
|
if !strings.Contains(query, literal) {
|
|
t.Fatalf("PostgreSQL metadata query does not contain protected literal %q: %s", literal, query)
|
|
}
|
|
tagEnd := strings.Index(literal[1:], "$") + 1
|
|
if tagEnd <= 0 {
|
|
t.Fatalf("invalid dollar-quoted literal: %q", literal)
|
|
}
|
|
tag := literal[:tagEnd+1]
|
|
if strings.Count(literal, tag) != 2 {
|
|
t.Fatalf("dollar quote tag %q can be closed by identifier %q", tag, identifier)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestCopyTablePostgresStopsWhenColumnTraitsCannotBeVerified(t *testing.T) {
|
|
database := &fakeCopyTableDB{
|
|
columns: []connection.ColumnDefinition{{Name: "id"}},
|
|
queryFunc: func(query string) ([]map[string]interface{}, error) {
|
|
if strings.Contains(query, "identity_kind") {
|
|
return nil, errors.New("catalog unavailable")
|
|
}
|
|
return nil, nil
|
|
},
|
|
}
|
|
app := installCopyTableTestDatabase(t, database)
|
|
|
|
result := app.CopyTable(connection.ConnectionConfig{Type: "postgres"}, "shop", "sales", "orders")
|
|
|
|
if result.Success {
|
|
t.Fatal("CopyTable unexpectedly succeeded")
|
|
}
|
|
if !strings.Contains(result.Message, "catalog unavailable") {
|
|
t.Fatalf("failure message = %q, want catalog error", result.Message)
|
|
}
|
|
if len(database.execQueries) != 0 {
|
|
t.Fatalf("CopyTable executed SQL without verified traits: %#v", database.execQueries)
|
|
}
|
|
}
|
|
|
|
func TestCopyTablePostgresCleansCreatedSequenceWhenFinalizationFails(t *testing.T) {
|
|
serialDefault := "nextval('sales.orders_id_seq'::regclass)"
|
|
database := &fakeCopyTableDB{
|
|
columns: []connection.ColumnDefinition{{Name: "id", Default: &serialDefault}},
|
|
execFailures: map[int]error{
|
|
4: errors.New("sequence ownership failed"),
|
|
},
|
|
}
|
|
app := installCopyTableTestDatabase(t, database)
|
|
|
|
result := app.CopyTable(connection.ConnectionConfig{Type: "postgres"}, "shop", "sales", "orders")
|
|
|
|
if result.Success {
|
|
t.Fatal("CopyTable unexpectedly succeeded")
|
|
}
|
|
if len(database.execQueries) != 6 {
|
|
t.Fatalf("Exec count = %d, want 6: %#v", len(database.execQueries), database.execQueries)
|
|
}
|
|
if database.execQueries[4] != `DROP TABLE "sales"."orders_copy1"` {
|
|
t.Fatalf("target table cleanup SQL = %q", database.execQueries[4])
|
|
}
|
|
if !strings.HasPrefix(database.execQueries[5], `DROP SEQUENCE IF EXISTS "sales".`) {
|
|
t.Fatalf("orphan sequence cleanup SQL = %q", database.execQueries[5])
|
|
}
|
|
}
|
|
|
|
func TestBuildCopyTableTargetNameReservesSuffixWithinIdentifierLimit(t *testing.T) {
|
|
postgresSource := strings.Repeat("表", 21)
|
|
postgresTarget := buildCopyTableTargetName("postgres", postgresSource, 1)
|
|
if len(postgresTarget) > 63 || !utf8.ValidString(postgresTarget) || !strings.HasSuffix(postgresTarget, "_copy1") {
|
|
t.Fatalf("invalid PostgreSQL copy name %q (%d bytes)", postgresTarget, len(postgresTarget))
|
|
}
|
|
|
|
mysqlSource := strings.Repeat("表", 64)
|
|
mysqlTarget := buildCopyTableTargetName("mysql", mysqlSource, 1)
|
|
if utf8.RuneCountInString(mysqlTarget) > 64 || !strings.HasSuffix(mysqlTarget, "_copy1") {
|
|
t.Fatalf("invalid MySQL copy name %q (%d chars)", mysqlTarget, utf8.RuneCountInString(mysqlTarget))
|
|
}
|
|
|
|
longPostgresSource := strings.Repeat("x", 63)
|
|
tenthTarget := buildCopyTableTargetName("postgres", longPostgresSource, 10)
|
|
if len(tenthTarget) > 63 || !strings.HasSuffix(tenthTarget, "_copy10") {
|
|
t.Fatalf("invalid two-digit PostgreSQL copy name %q", tenthTarget)
|
|
}
|
|
}
|
|
|
|
func TestCopyTableAlreadyExistsErrorMatchesSupportedDrivers(t *testing.T) {
|
|
tests := []struct {
|
|
message string
|
|
want bool
|
|
}{
|
|
{message: `ERROR: relation "orders_copy1" already exists (SQLSTATE 42P07)`, want: true},
|
|
{message: "Error 1050 (42S01): Table 'orders_copy1' already exists", want: true},
|
|
{message: "Code: 57, table already exists", want: true},
|
|
{message: "Code: 57, unrelated ClickHouse error", want: false},
|
|
{message: "permission denied", want: false},
|
|
}
|
|
for _, test := range tests {
|
|
if got := isCopyTableAlreadyExistsError(errors.New(test.message)); got != test.want {
|
|
t.Fatalf("isCopyTableAlreadyExistsError(%q) = %v, want %v", test.message, got, test.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCopyTableRetriesCreateTimeNameConflict(t *testing.T) {
|
|
database := &fakeCopyTableDB{
|
|
columns: []connection.ColumnDefinition{{Name: "id"}},
|
|
execFailures: map[int]error{1: errors.New("relation already exists (SQLSTATE 42P07)")},
|
|
}
|
|
app := installCopyTableTestDatabase(t, database)
|
|
|
|
result := app.CopyTable(connection.ConnectionConfig{Type: "postgres"}, "app", "public", "orders")
|
|
|
|
if !result.Success {
|
|
t.Fatalf("CopyTable returned failure: %s", result.Message)
|
|
}
|
|
if result.Data != "orders_copy2" {
|
|
t.Fatalf("CopyTable target = %#v, want orders_copy2", result.Data)
|
|
}
|
|
if len(database.execQueries) != 3 {
|
|
t.Fatalf("Exec count = %d, want 3: %#v", len(database.execQueries), database.execQueries)
|
|
}
|
|
if !strings.Contains(database.execQueries[1], `"orders_copy2"`) || !strings.Contains(database.execQueries[2], `"orders_copy2"`) {
|
|
t.Fatalf("retry did not use orders_copy2: %#v", database.execQueries)
|
|
}
|
|
}
|
|
|
|
func TestCopyTableRetriesConflictWhenPostgresSchemaContainsDot(t *testing.T) {
|
|
database := &fakeCopyTableDB{
|
|
columns: []connection.ColumnDefinition{{Name: "id"}},
|
|
execFailures: map[int]error{1: errors.New("relation already exists (SQLSTATE 42P07)")},
|
|
}
|
|
app := installCopyTableTestDatabase(t, database)
|
|
|
|
result := app.CopyTable(connection.ConnectionConfig{Type: "postgres"}, "app", "sales.region", "orders")
|
|
|
|
if !result.Success {
|
|
t.Fatalf("CopyTable returned failure: %s", result.Message)
|
|
}
|
|
if result.Data != "orders_copy2" {
|
|
t.Fatalf("CopyTable target = %#v, want orders_copy2", result.Data)
|
|
}
|
|
if len(database.execQueries) != 3 {
|
|
t.Fatalf("Exec count = %d, want 3: %#v", len(database.execQueries), database.execQueries)
|
|
}
|
|
if !strings.Contains(database.execQueries[1], `"sales.region"."orders_copy2"`) {
|
|
t.Fatalf("retry did not advance inside dotted schema: %#v", database.execQueries)
|
|
}
|
|
}
|
|
|
|
func TestCopyTableRetriesConflictWhenPostgresTableContainsDot(t *testing.T) {
|
|
database := &fakeCopyTableDB{
|
|
columns: []connection.ColumnDefinition{{Name: "id"}},
|
|
execFailures: map[int]error{1: errors.New("relation already exists (SQLSTATE 42P07)")},
|
|
}
|
|
app := installCopyTableTestDatabase(t, database)
|
|
|
|
result := app.CopyTable(connection.ConnectionConfig{Type: "postgres"}, "app", "public", "orders.archive")
|
|
|
|
if !result.Success {
|
|
t.Fatalf("CopyTable returned failure: %s", result.Message)
|
|
}
|
|
if result.Data != "orders.archive_copy2" {
|
|
t.Fatalf("CopyTable target = %#v, want orders.archive_copy2", result.Data)
|
|
}
|
|
if len(database.execQueries) != 3 {
|
|
t.Fatalf("Exec count = %d, want 3: %#v", len(database.execQueries), database.execQueries)
|
|
}
|
|
if !strings.Contains(database.execQueries[1], `"public"."orders.archive_copy2"`) {
|
|
t.Fatalf("retry did not advance dotted table as one identifier: %#v", database.execQueries)
|
|
}
|
|
}
|
|
|
|
func TestNormalizeCopyTableSourceUsesExplicitPostgresSchema(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
schema string
|
|
source string
|
|
wantSource string
|
|
}{
|
|
{name: "dotted schema", schema: "sales.region", source: "sales.region.orders", wantSource: "orders"},
|
|
{name: "qualified dotted table", schema: "public", source: "public.orders.archive", wantSource: "orders.archive"},
|
|
{name: "unqualified dotted table", schema: "public", source: "orders.archive", wantSource: "orders.archive"},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
gotSchema, gotSource := normalizeCopyTableSource("postgres", "app", test.schema, test.source)
|
|
if gotSchema != test.schema || gotSource != test.wantSource {
|
|
t.Fatalf("normalizeCopyTableSource = (%q, %q), want (%q, %q)", gotSchema, gotSource, test.schema, test.wantSource)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCopyTableDropsPartialTargetWhenInsertFails(t *testing.T) {
|
|
database := &fakeCopyTableDB{
|
|
columns: []connection.ColumnDefinition{{Name: "id"}},
|
|
execFailures: map[int]error{2: errors.New("copy rows failed")},
|
|
}
|
|
app := installCopyTableTestDatabase(t, database)
|
|
|
|
result := app.CopyTable(connection.ConnectionConfig{Type: "mysql"}, "app", "app", "users")
|
|
|
|
if result.Success {
|
|
t.Fatal("CopyTable unexpectedly succeeded")
|
|
}
|
|
if !strings.Contains(result.Message, "copy rows failed") {
|
|
t.Fatalf("failure message does not retain insert error: %q", result.Message)
|
|
}
|
|
if len(database.execQueries) != 3 || database.execQueries[2] != "DROP TABLE `app`.`users_copy1`" {
|
|
t.Fatalf("partial target cleanup = %#v, want DROP TABLE users_copy1", database.execQueries)
|
|
}
|
|
}
|
|
|
|
func TestCopyTableReportsInsertAndCleanupFailures(t *testing.T) {
|
|
database := &fakeCopyTableDB{
|
|
columns: []connection.ColumnDefinition{{Name: "id"}},
|
|
execFailures: map[int]error{
|
|
2: errors.New("copy rows failed"),
|
|
3: errors.New("cleanup failed"),
|
|
},
|
|
}
|
|
app := installCopyTableTestDatabase(t, database)
|
|
|
|
result := app.CopyTable(connection.ConnectionConfig{Type: "mysql"}, "app", "app", "users")
|
|
|
|
if result.Success {
|
|
t.Fatal("CopyTable unexpectedly succeeded")
|
|
}
|
|
if !strings.Contains(result.Message, "copy rows failed") || !strings.Contains(result.Message, "cleanup failed") {
|
|
t.Fatalf("failure message = %q, want both errors", result.Message)
|
|
}
|
|
}
|
|
|
|
func TestCopyTableProtectionBlocksBeforeOpeningDatabase(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
protection connection.ConnectionProtectionConfig
|
|
}{
|
|
{name: "structure", protection: connection.ConnectionProtectionConfig{RestrictStructureEdit: true}},
|
|
{name: "import", protection: connection.ConnectionProtectionConfig{RestrictDataImport: true}},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
opened := false
|
|
originalNewDatabaseFunc := newDatabaseFunc
|
|
t.Cleanup(func() { newDatabaseFunc = originalNewDatabaseFunc })
|
|
newDatabaseFunc = func(string) (db.Database, error) {
|
|
opened = true
|
|
return &fakeCopyTableDB{}, nil
|
|
}
|
|
app := NewAppWithSecretStore(secretstore.NewUnavailableStore("test"))
|
|
result := app.CopyTable(connection.ConnectionConfig{Type: "mysql", Protection: test.protection}, "app", "app", "users")
|
|
if result.Success {
|
|
t.Fatal("CopyTable unexpectedly succeeded")
|
|
}
|
|
if opened {
|
|
t.Fatal("CopyTable opened a database despite connection protection")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCopyTableRejectsUnsupportedDatabaseWithoutOpeningConnection(t *testing.T) {
|
|
for _, dbType := range []string{"oracle", "clickhouse", "diros", "starrocks", "kingbase", "highgo", "vastbase", "opengauss", "gaussdb"} {
|
|
t.Run(dbType, func(t *testing.T) {
|
|
opened := false
|
|
originalNewDatabaseFunc := newDatabaseFunc
|
|
t.Cleanup(func() { newDatabaseFunc = originalNewDatabaseFunc })
|
|
newDatabaseFunc = func(string) (db.Database, error) {
|
|
opened = true
|
|
return &fakeCopyTableDB{}, nil
|
|
}
|
|
app := NewAppWithSecretStore(secretstore.NewUnavailableStore("test"))
|
|
|
|
result := app.CopyTable(connection.ConnectionConfig{Type: dbType}, "SYSTEM", "", "USERS")
|
|
|
|
if result.Success {
|
|
t.Fatal("CopyTable unexpectedly succeeded")
|
|
}
|
|
if opened {
|
|
t.Fatal("CopyTable opened a database for an unsupported dialect")
|
|
}
|
|
})
|
|
}
|
|
|
|
t.Run("custom OceanBase Oracle", func(t *testing.T) {
|
|
opened := false
|
|
originalNewDatabaseFunc := newDatabaseFunc
|
|
t.Cleanup(func() { newDatabaseFunc = originalNewDatabaseFunc })
|
|
newDatabaseFunc = func(string) (db.Database, error) {
|
|
opened = true
|
|
return &fakeCopyTableDB{}, nil
|
|
}
|
|
app := NewAppWithSecretStore(secretstore.NewUnavailableStore("test"))
|
|
result := app.CopyTable(connection.ConnectionConfig{
|
|
Type: "custom",
|
|
Driver: "oceanbase",
|
|
OceanBaseProtocol: "oracle",
|
|
}, "SYSTEM", "", "USERS")
|
|
if result.Success || opened {
|
|
t.Fatalf("custom OceanBase Oracle result=%#v opened=%v, want unsupported without connection", result, opened)
|
|
}
|
|
})
|
|
}
|