fix: address audit execution failures and unsafe type conversions
This commit is contained in:
@@ -3,9 +3,11 @@ package pgsql
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -141,15 +143,16 @@ func TestLive_WriteDatabaseEmptyThenIdenticalThenDrifted(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLive_WriteDatabaseFailedStatementContinues(t *testing.T) {
|
||||
func TestLive_WriteDatabaseFailedStatementReturnsError(t *testing.T) {
|
||||
connString := liveWriterConn(t)
|
||||
schema, _ := liveWriterSchema(t, connString)
|
||||
reportPath := filepath.Join(t.TempDir(), "report.json")
|
||||
|
||||
db := liveModel(schema, map[string]string{"bad": "no_such_type_xyz"})
|
||||
rep, err := runLiveWrite(t, connString, db, map[string]interface{}{"full_ddl": true, "report_path": reportPath})
|
||||
if err != nil {
|
||||
t.Fatalf("failed statements must not abort the run: %v", err)
|
||||
var failure *ExecutionFailure
|
||||
if !errors.As(err, &failure) {
|
||||
t.Fatalf("expected structured failure, got: %v", err)
|
||||
}
|
||||
if rep.FailedStatements == 0 || len(rep.Errors) != rep.FailedStatements {
|
||||
t.Fatalf("expected recorded failures: %+v", rep)
|
||||
@@ -230,7 +233,7 @@ func TestLive_ExecuteStatementsSkipsCommentsAndBlank(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r := w.executionReport
|
||||
if r.ExecutedStatements != 2 || r.FailedStatements != 0 || r.TotalStatements != 5 {
|
||||
if r.ExecutedStatements != 2 || r.FailedStatements != 0 || r.TotalStatements != 5 || r.SkippedStatements != 3 {
|
||||
t.Errorf("counts: %+v", r)
|
||||
}
|
||||
if len(r.Schemas) != 1 || r.Schemas[0].Name != schema || len(r.Schemas[0].Tables) != 1 || !r.Schemas[0].Tables[0].Created {
|
||||
@@ -241,3 +244,174 @@ func TestLive_ExecuteStatementsSkipsCommentsAndBlank(t *testing.T) {
|
||||
t.Errorf("table not created: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLive_ExecuteStatementsFailureModes(t *testing.T) {
|
||||
connString := liveWriterConn(t)
|
||||
for _, continueOnError := range []bool{false, true} {
|
||||
t.Run(fmt.Sprintf("continue=%v", continueOnError), func(t *testing.T) {
|
||||
schema, conn := liveWriterSchema(t, connString)
|
||||
reportPath := filepath.Join(t.TempDir(), "report.json")
|
||||
w := NewWriter(&writers.WriterOptions{
|
||||
ContinueOnError: continueOnError,
|
||||
Metadata: map[string]interface{}{"report_path": reportPath},
|
||||
})
|
||||
w.executionReport = &ExecutionReport{}
|
||||
err := w.executeStatements([]string{
|
||||
"CREATE SCHEMA " + schema,
|
||||
"SELECT 1 / 0",
|
||||
"CREATE TABLE " + schema + ".after_failure (id int)",
|
||||
}, connString)
|
||||
var failure *ExecutionFailure
|
||||
if !errors.As(err, &failure) || failure.Report != w.executionReport {
|
||||
t.Fatalf("expected structured execution failure, got %v", err)
|
||||
}
|
||||
var report ExecutionReport
|
||||
data, err := os.ReadFile(reportPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := json.Unmarshal(data, &report); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
executed, skipped := 1, 1
|
||||
if continueOnError {
|
||||
executed, skipped = 2, 0
|
||||
}
|
||||
if report.TotalStatements != 3 || report.ExecutedStatements != executed || report.FailedStatements != 1 || report.SkippedStatements != skipped || report.EndTime == "" {
|
||||
t.Fatalf("incorrect partial application report: %+v", report)
|
||||
}
|
||||
if len(report.Errors) != 1 || report.Errors[0].StatementNumber != 2 {
|
||||
t.Fatalf("incorrect failure details: %+v", report.Errors)
|
||||
}
|
||||
if exists := columnExists(t, conn, schema, "after_failure", "id"); exists != continueOnError {
|
||||
t.Fatalf("later statement executed=%v, continue=%v", exists, continueOnError)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLive_IncompatibleTypeChangePreservesColumnAndData(t *testing.T) {
|
||||
connString := liveWriterConn(t)
|
||||
schema, conn := liveWriterSchema(t, connString)
|
||||
ctx := context.Background()
|
||||
_, err := conn.Exec(ctx, "CREATE SCHEMA "+schema+"; CREATE TABLE "+schema+".accounts (id integer PRIMARY KEY, name text UNIQUE); INSERT INTO "+schema+".accounts VALUES (1, 'not-an-integer')")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var originalIndex uint32
|
||||
if err := conn.QueryRow(ctx, "SELECT to_regclass($1)::oid", schema+".accounts_name_key").Scan(&originalIndex); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Exercise both full-DDL and diff generation against populated data.
|
||||
for _, fullDDL := range []bool{false, true} {
|
||||
t.Run(fmt.Sprintf("full_ddl=%v", fullDDL), func(t *testing.T) {
|
||||
model := liveModel(schema, map[string]string{"name": "integer"})
|
||||
unique := models.InitConstraint("accounts_name_key", models.UniqueConstraint)
|
||||
unique.Columns = []string{"name"}
|
||||
model.Schemas[0].Tables[0].Constraints[unique.Name] = unique
|
||||
report, err := runLiveWrite(t, connString, model, map[string]interface{}{"full_ddl": fullDDL})
|
||||
var failure *ExecutionFailure
|
||||
if !errors.As(err, &failure) || report.FailedStatements != 1 {
|
||||
t.Fatalf("expected failed conversion, got report=%+v err=%v", report, err)
|
||||
}
|
||||
var value, typ string
|
||||
if err := conn.QueryRow(ctx, "SELECT name, pg_typeof(name)::text FROM "+schema+".accounts WHERE id=1").Scan(&value, &typ); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if value != "not-an-integer" || typ != "text" {
|
||||
t.Fatalf("original column changed: value=%q type=%q", value, typ)
|
||||
}
|
||||
var columns int
|
||||
if err := conn.QueryRow(ctx, "SELECT count(*) FROM information_schema.columns WHERE table_schema=$1 AND table_name='accounts'", schema).Scan(&columns); err != nil || columns != 2 {
|
||||
t.Fatalf("unexpected replacement column: columns=%d err=%v", columns, err)
|
||||
}
|
||||
var index uint32
|
||||
if err := conn.QueryRow(ctx, "SELECT to_regclass($1)::oid", schema+".accounts_name_key").Scan(&index); err != nil || index != originalIndex {
|
||||
t.Fatalf("unique index changed: index=%d original=%d err=%v", index, originalIndex, err)
|
||||
}
|
||||
if _, err := conn.Exec(ctx, "INSERT INTO "+schema+".accounts VALUES (2, 'not-an-integer')"); err == nil {
|
||||
t.Fatal("unique constraint no longer enforced")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLive_AtomicExecutionRollback(t *testing.T) {
|
||||
connString := liveWriterConn(t)
|
||||
schema, conn := liveWriterSchema(t, connString)
|
||||
w := NewWriter(&writers.WriterOptions{AtomicExecution: true})
|
||||
w.executionReport = &ExecutionReport{}
|
||||
err := w.executeStatements([]string{
|
||||
"-- Schema: " + schema,
|
||||
"CREATE SCHEMA " + schema,
|
||||
"CREATE TABLE " + schema + ".created (id int)",
|
||||
"SELECT 1 / 0",
|
||||
"SELECT 1",
|
||||
}, connString)
|
||||
var failure *ExecutionFailure
|
||||
if !errors.As(err, &failure) {
|
||||
t.Fatalf("expected execution failure: %v", err)
|
||||
}
|
||||
report := failure.Report
|
||||
if report.Outcome != "rolled_back" || report.RolledBackStatements != 2 || report.ExecutedStatements != 0 || report.FailedStatements != 1 || report.SkippedStatements != 2 {
|
||||
t.Fatalf("rollback report: %+v", report)
|
||||
}
|
||||
if report.Schemas[0].Tables[0].Created {
|
||||
t.Fatal("rolled-back table must not be reported created")
|
||||
}
|
||||
if columnExists(t, conn, schema, "created", "id") {
|
||||
t.Fatal("table survived transaction rollback")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLive_TypePreflightPrecedesDropsEvenWithContinuation(t *testing.T) {
|
||||
connString := liveWriterConn(t)
|
||||
schema, conn := liveWriterSchema(t, connString)
|
||||
_, err := conn.Exec(context.Background(), "CREATE SCHEMA "+schema+"; CREATE TABLE "+schema+".accounts (id integer PRIMARY KEY, name text UNIQUE); INSERT INTO "+schema+".accounts VALUES (1, '123'), (2, 'bad')")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The desired model would drop the unique constraint. Incompatible data must
|
||||
// be caught first, including bad data after the first row.
|
||||
model := liveModel(schema, map[string]string{"name": "integer"})
|
||||
w := NewWriter(&writers.WriterOptions{ContinueOnError: true, Metadata: map[string]interface{}{"connection_string": connString}})
|
||||
err = w.WriteDatabase(model)
|
||||
var failure *ExecutionFailure
|
||||
if !errors.As(err, &failure) || failure.Report.FailedStatements != 1 || failure.Report.ExecutedStatements != 0 {
|
||||
t.Fatalf("preflight must stop mutations: %+v %v", w.executionReport, err)
|
||||
}
|
||||
var exists bool
|
||||
if err := conn.QueryRow(context.Background(), "SELECT to_regclass($1) IS NOT NULL", schema+".accounts_name_key").Scan(&exists); err != nil || !exists {
|
||||
t.Fatalf("constraint was dropped before preflight: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLive_CustomTypeConversionAndPredicate(t *testing.T) {
|
||||
connString := liveWriterConn(t)
|
||||
for _, fullDDL := range []bool{false, true} {
|
||||
t.Run(fmt.Sprintf("full_ddl=%v", fullDDL), func(t *testing.T) {
|
||||
schema, conn := liveWriterSchema(t, connString)
|
||||
_, err := conn.Exec(context.Background(), "CREATE SCHEMA "+schema+"; CREATE TABLE "+schema+".accounts (id integer PRIMARY KEY, name text); INSERT INTO "+schema+".accounts VALUES (1,'42 units'),(2,'7 units')")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
model := liveModel(schema, map[string]string{"name": "integer"})
|
||||
col := model.Schemas[0].Tables[0].Columns["name"]
|
||||
col.Metadata["pgsql_using"] = "split_part(name, ' ', 1)::integer"
|
||||
col.Metadata["pgsql_preflight"] = "name ~ '^[0-9]+ units$' AND name NOT LIKE '7 %'"
|
||||
meta := map[string]interface{}{"full_ddl": fullDDL}
|
||||
if _, err := runLiveWrite(t, connString, model, meta); err == nil || !strings.Contains(err.Error(), "preflight rejected rows") {
|
||||
t.Fatalf("predicate must reject: %v", err)
|
||||
}
|
||||
col.Metadata["pgsql_preflight"] = "name ~ '^[0-9]+ units$'"
|
||||
if _, err := runLiveWrite(t, connString, model, meta); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var value int
|
||||
if err := conn.QueryRow(context.Background(), "SELECT sum(name) FROM "+schema+".accounts").Scan(&value); err != nil || value != 49 {
|
||||
t.Fatalf("custom conversion result %d: %v", value, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user