From 3f80278dd4ed8414823be863c7c6b7831810b860 Mon Sep 17 00:00:00 2001 From: sagit Date: Sat, 14 Feb 2026 11:02:46 +0000 Subject: [PATCH] fix(backend): harden postgres id default repair and CI coverage Run Postgres id-sequence repair on every startup migration and add contract coverage plus a GitHub Actions Postgres job to catch schema-drift regressions before release. --- .github/workflows/ci-build.yml | 37 ++++++ .../internal/store/sqlite/repository.go | 12 +- .../store/sqlite/repository_migrate_test.go | 78 ++++++++++++ .../postgres_node_id_repair_contract_test.go | 115 ++++++++++++++++++ 4 files changed, 237 insertions(+), 5 deletions(-) create mode 100644 go-backend/internal/store/sqlite/repository_migrate_test.go create mode 100644 go-backend/tests/contract/postgres_node_id_repair_contract_test.go diff --git a/.github/workflows/ci-build.yml b/.github/workflows/ci-build.yml index 89efde9..23474b7 100644 --- a/.github/workflows/ci-build.yml +++ b/.github/workflows/ci-build.yml @@ -48,6 +48,43 @@ jobs: - name: Build run: go build -v ./... + backend-postgres-contract: + name: Go Backend PostgreSQL Contract + runs-on: ubuntu-latest + services: + postgres: + image: postgres:17 + env: + POSTGRES_USER: flux_test + POSTGRES_PASSWORD: flux_test_pass + POSTGRES_DB: flux_test + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U flux_test -d flux_test" + --health-interval 10s + --health-timeout 5s + --health-retries 10 + defaults: + run: + working-directory: go-backend + steps: + - uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: '1.23' + cache-dependency-path: go-backend/go.sum + + - name: Download dependencies + run: go mod download + + - name: Run PostgreSQL contract test + env: + FLVX_POSTGRES_TEST_DSN: 'postgres://flux_test:flux_test_pass@127.0.0.1:5432/flux_test?sslmode=disable' + run: go test ./tests/contract -run TestPostgresNodeCreateRepairsMissingIDDefaultContract -count=1 + agent: name: Build Agent runs-on: ubuntu-latest diff --git a/go-backend/internal/store/sqlite/repository.go b/go-backend/internal/store/sqlite/repository.go index 9d196c3..26e40a1 100644 --- a/go-backend/internal/store/sqlite/repository.go +++ b/go-backend/internal/store/sqlite/repository.go @@ -1307,6 +1307,8 @@ func bootstrapSchema(db *store.DB, schemaSQL, seedSQL string) error { const currentSchemaVersion = 2 +var ensurePostgresIDDefaultsFn = ensurePostgresIDDefaults + func getSchemaVersion(db *store.DB) int { _, _ = db.Exec(`CREATE TABLE IF NOT EXISTS schema_version (version INTEGER NOT NULL DEFAULT 0)`) var v int @@ -1327,6 +1329,11 @@ func migrateSchema(db *store.DB) error { } ver := getSchemaVersion(db) + if db.Dialect() == store.DialectPostgres { + if err := ensurePostgresIDDefaultsFn(db); err != nil { + return err + } + } if ver >= currentSchemaVersion { return nil } @@ -1396,11 +1403,6 @@ func migrateSchema(db *store.DB) error { return err } - if db.Dialect() == store.DialectPostgres { - if err := ensurePostgresIDDefaults(db); err != nil { - return err - } - } setSchemaVersion(db, currentSchemaVersion) return nil } diff --git a/go-backend/internal/store/sqlite/repository_migrate_test.go b/go-backend/internal/store/sqlite/repository_migrate_test.go new file mode 100644 index 0000000..5d31913 --- /dev/null +++ b/go-backend/internal/store/sqlite/repository_migrate_test.go @@ -0,0 +1,78 @@ +package sqlite + +import ( + "database/sql" + "errors" + "testing" + + "go-backend/internal/store" + + _ "modernc.org/sqlite" +) + +func TestMigrateSchemaRunsPostgresIDRepairEvenAtCurrentVersion(t *testing.T) { + raw, err := sql.Open("sqlite", ":memory:") + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + t.Cleanup(func() { + _ = raw.Close() + }) + + db := store.Wrap(raw, store.DialectPostgres) + if _, err := db.Exec(`CREATE TABLE schema_version (version INTEGER NOT NULL DEFAULT 0)`); err != nil { + t.Fatalf("create schema_version: %v", err) + } + if _, err := db.Exec(`INSERT INTO schema_version(version) VALUES(?)`, currentSchemaVersion); err != nil { + t.Fatalf("seed schema_version: %v", err) + } + + called := 0 + original := ensurePostgresIDDefaultsFn + ensurePostgresIDDefaultsFn = func(db *store.DB) error { + called++ + return nil + } + t.Cleanup(func() { + ensurePostgresIDDefaultsFn = original + }) + + if err := migrateSchema(db); err != nil { + t.Fatalf("migrateSchema: %v", err) + } + if called != 1 { + t.Fatalf("expected postgres id repair to run once, got %d", called) + } +} + +func TestMigrateSchemaReturnsPostgresIDRepairError(t *testing.T) { + raw, err := sql.Open("sqlite", ":memory:") + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + t.Cleanup(func() { + _ = raw.Close() + }) + + db := store.Wrap(raw, store.DialectPostgres) + if _, err := db.Exec(`CREATE TABLE schema_version (version INTEGER NOT NULL DEFAULT 0)`); err != nil { + t.Fatalf("create schema_version: %v", err) + } + if _, err := db.Exec(`INSERT INTO schema_version(version) VALUES(?)`, currentSchemaVersion); err != nil { + t.Fatalf("seed schema_version: %v", err) + } + + wantErr := errors.New("repair failed") + original := ensurePostgresIDDefaultsFn + ensurePostgresIDDefaultsFn = func(db *store.DB) error { + return wantErr + } + t.Cleanup(func() { + ensurePostgresIDDefaultsFn = original + }) + + err = migrateSchema(db) + if !errors.Is(err, wantErr) { + t.Fatalf("expected error %v, got %v", wantErr, err) + } +} diff --git a/go-backend/tests/contract/postgres_node_id_repair_contract_test.go b/go-backend/tests/contract/postgres_node_id_repair_contract_test.go new file mode 100644 index 0000000..2659473 --- /dev/null +++ b/go-backend/tests/contract/postgres_node_id_repair_contract_test.go @@ -0,0 +1,115 @@ +package contract_test + +import ( + "database/sql" + "net/http" + "net/http/httptest" + "net/url" + "os" + "strconv" + "strings" + "testing" + "time" + + _ "github.com/jackc/pgx/v5/stdlib" + + "go-backend/internal/auth" + httpserver "go-backend/internal/http" + "go-backend/internal/http/handler" + "go-backend/internal/store/sqlite" +) + +func TestPostgresNodeCreateRepairsMissingIDDefaultContract(t *testing.T) { + baseDSN := strings.TrimSpace(os.Getenv("FLVX_POSTGRES_TEST_DSN")) + if baseDSN == "" { + t.Skip("set FLVX_POSTGRES_TEST_DSN to run postgres contract tests") + } + + schemaName := "contract_node_id_" + strconv.FormatInt(time.Now().UnixNano(), 36) + adminDB, err := sql.Open("pgx", baseDSN) + if err != nil { + t.Fatalf("open postgres admin connection: %v", err) + } + t.Cleanup(func() { + _, _ = adminDB.Exec(`DROP SCHEMA IF EXISTS "` + schemaName + `" CASCADE`) + _ = adminDB.Close() + }) + + if _, err := adminDB.Exec(`CREATE SCHEMA "` + schemaName + `"`); err != nil { + t.Fatalf("create schema %s: %v", schemaName, err) + } + + testDSN, err := withSearchPath(baseDSN, schemaName) + if err != nil { + t.Fatalf("build schema dsn: %v", err) + } + + repo, err := sqlite.OpenPostgres(testDSN) + if err != nil { + t.Fatalf("open postgres repository: %v", err) + } + if _, err := repo.DB().Exec(`ALTER TABLE node ALTER COLUMN id DROP DEFAULT`); err != nil { + _ = repo.Close() + t.Fatalf("drop node.id default to simulate drift: %v", err) + } + if err := repo.Close(); err != nil { + t.Fatalf("close repository before reopen: %v", err) + } + + repo, err = sqlite.OpenPostgres(testDSN) + if err != nil { + t.Fatalf("reopen postgres repository: %v", err) + } + t.Cleanup(func() { + _ = repo.Close() + }) + + var columnDefault sql.NullString + if err := repo.DB().QueryRow(` + SELECT column_default + FROM information_schema.columns + WHERE table_schema = current_schema() + AND table_name = 'node' + AND column_name = 'id' + LIMIT 1 + `).Scan(&columnDefault); err != nil { + t.Fatalf("query node.id default: %v", err) + } + if !columnDefault.Valid || !strings.Contains(strings.ToLower(columnDefault.String), "nextval(") { + t.Fatalf("expected node.id default to be nextval(...), got %q", columnDefault.String) + } + + jwtSecret := "postgres-contract-secret" + router := httpserver.NewRouter(handler.New(repo, jwtSecret), jwtSecret) + token, err := auth.GenerateToken(1, "admin_user", 0, jwtSecret) + if err != nil { + t.Fatalf("generate admin token: %v", err) + } + + body := strings.NewReader(`{"name":"pg-repair-node","serverIp":"10.77.0.10"}`) + req := httptest.NewRequest(http.MethodPost, "/api/v1/node/create", body) + req.Header.Set("Authorization", token) + req.Header.Set("Content-Type", "application/json") + resp := httptest.NewRecorder() + router.ServeHTTP(resp, req) + assertCode(t, resp, 0) + + var nodeID int64 + if err := repo.DB().QueryRow(`SELECT id FROM node WHERE name = ? ORDER BY id DESC LIMIT 1`, "pg-repair-node").Scan(&nodeID); err != nil { + t.Fatalf("query created node: %v", err) + } + if nodeID <= 0 { + t.Fatalf("expected positive node id, got %d", nodeID) + } +} + +func withSearchPath(dsn, schema string) (string, error) { + u, err := url.Parse(dsn) + if err != nil { + return "", err + } + q := u.Query() + q.Set("search_path", schema) + u.RawQuery = q.Encode() + return u.String(), nil +}