mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-10-07 18:26:37 +08:00
Merge pull request #120 from Sagit-chu/opencode/pg-id-repair-ci-2-1-3-rc2
fix(backend): harden postgres id default repair and CI coverage
This commit is contained in:
@@ -48,6 +48,43 @@ jobs:
|
|||||||
- name: Build
|
- name: Build
|
||||||
run: go build -v ./...
|
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:
|
agent:
|
||||||
name: Build Agent
|
name: Build Agent
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
@@ -1307,6 +1307,8 @@ func bootstrapSchema(db *store.DB, schemaSQL, seedSQL string) error {
|
|||||||
|
|
||||||
const currentSchemaVersion = 2
|
const currentSchemaVersion = 2
|
||||||
|
|
||||||
|
var ensurePostgresIDDefaultsFn = ensurePostgresIDDefaults
|
||||||
|
|
||||||
func getSchemaVersion(db *store.DB) int {
|
func getSchemaVersion(db *store.DB) int {
|
||||||
_, _ = db.Exec(`CREATE TABLE IF NOT EXISTS schema_version (version INTEGER NOT NULL DEFAULT 0)`)
|
_, _ = db.Exec(`CREATE TABLE IF NOT EXISTS schema_version (version INTEGER NOT NULL DEFAULT 0)`)
|
||||||
var v int
|
var v int
|
||||||
@@ -1327,6 +1329,11 @@ func migrateSchema(db *store.DB) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ver := getSchemaVersion(db)
|
ver := getSchemaVersion(db)
|
||||||
|
if db.Dialect() == store.DialectPostgres {
|
||||||
|
if err := ensurePostgresIDDefaultsFn(db); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
if ver >= currentSchemaVersion {
|
if ver >= currentSchemaVersion {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -1396,11 +1403,6 @@ func migrateSchema(db *store.DB) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if db.Dialect() == store.DialectPostgres {
|
|
||||||
if err := ensurePostgresIDDefaults(db); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setSchemaVersion(db, currentSchemaVersion)
|
setSchemaVersion(db, currentSchemaVersion)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user