From 9382d375f61733ffcd2f2fea4f9e9d26c7f0b6c8 Mon Sep 17 00:00:00 2001 From: sagitchu Date: Wed, 11 Nov 2026 17:57:14 +0800 Subject: [PATCH] fix(postgres): encode relay tunnel chain roles as text --- .github/workflows/ci-build.yml | 4 +- .../store/repo/repository_relay_auth.go | 5 +- .../store/repo/repository_relay_auth_test.go | 98 +++++++++++++++++++ .../federation_dual_panel_contract_test.go | 8 +- .../contract/relay_auth_contract_test.go | 13 +++ 5 files changed, 122 insertions(+), 6 deletions(-) create mode 100644 go-backend/internal/store/repo/repository_relay_auth_test.go diff --git a/.github/workflows/ci-build.yml b/.github/workflows/ci-build.yml index f0b7141..b88968b 100644 --- a/.github/workflows/ci-build.yml +++ b/.github/workflows/ci-build.yml @@ -103,10 +103,10 @@ jobs: - name: Download dependencies run: go mod download - - name: Run PostgreSQL contract test + - name: Run PostgreSQL contract tests 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 + run: go test ./tests/contract -run 'TestPostgres(NodeCreateRepairsMissingIDDefaultContract|NewRelayTunnelEmitsMatchingPrivateEndpointAuthentication)$' -count=1 agent: name: Build Agent diff --git a/go-backend/internal/store/repo/repository_relay_auth.go b/go-backend/internal/store/repo/repository_relay_auth.go index bc4bf7c..73a26d0 100644 --- a/go-backend/internal/store/repo/repository_relay_auth.go +++ b/go-backend/internal/store/repo/repository_relay_auth.go @@ -2,6 +2,8 @@ package repo import ( "errors" + "strconv" + "go-backend/internal/relaysecurity" "go-backend/internal/store/model" "gorm.io/gorm" @@ -22,7 +24,8 @@ func (r *Repository) RelayEndpointCredentials(tx *gorm.DB, tunnelID, nodeID int6 err := find() if errors.Is(err, gorm.ErrRecordNotFound) && create { var existing int64 - if e := db.Model(&model.ChainTunnel{}).Where("tunnel_id = ? AND node_id = ? AND chain_type = ?", tunnelID, nodeID, role).Count(&existing).Error; e != nil { + // chain_tunnel stores roles as text; relay_endpoint_auth uses integers. + if e := db.Model(&model.ChainTunnel{}).Where("tunnel_id = ? AND node_id = ? AND chain_type = ?", tunnelID, nodeID, strconv.Itoa(role)).Count(&existing).Error; e != nil { return nil, e } if existing > 0 { diff --git a/go-backend/internal/store/repo/repository_relay_auth_test.go b/go-backend/internal/store/repo/repository_relay_auth_test.go new file mode 100644 index 0000000..149a284 --- /dev/null +++ b/go-backend/internal/store/repo/repository_relay_auth_test.go @@ -0,0 +1,98 @@ +package repo + +import ( + "fmt" + "path/filepath" + "strconv" + "strings" + "testing" + + "github.com/jackc/pgx/v5/pgtype" + "gorm.io/gorm" + + "go-backend/internal/relaysecurity" + "go-backend/internal/store/model" +) + +func TestRelayEndpointCredentialsBindsLegacyChainTypeAsText(t *testing.T) { + for _, role := range []int{2, 3} { + t.Run(strconv.Itoa(role), func(t *testing.T) { + r, err := Open(filepath.Join(t.TempDir(), "relay.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = r.Close() }) + + // SQLite coerces integer parameters for this legacy text column. + // Check the actual repository query with pgx's text encoder as well, + // so the PostgreSQL regression is caught without a running server. + checked := 0 + if err := r.DB().Callback().Query().After("gorm:query").Register("test:relay_chain_text_parameter", func(db *gorm.DB) { + if db.Statement.Table != "chain_tunnel" { + return + } + checked++ + if len(db.Statement.Vars) != 3 { + db.AddError(fmt.Errorf("expected three chain lookup arguments, got %d", len(db.Statement.Vars))) + return + } + encoded, err := pgtype.NewMap().Encode(pgtype.TextOID, pgtype.TextFormatCode, db.Statement.Vars[2], nil) + if err != nil { + db.AddError(fmt.Errorf("encode chain_type for PostgreSQL: %w", err)) + } else if string(encoded) != strconv.Itoa(role) { + db.AddError(fmt.Errorf("unexpected chain_type argument: %q", encoded)) + } + }); err != nil { + t.Fatal(err) + } + + tunnel := model.Tunnel{Name: "relay", Type: 2, Flow: 1, RelayAuthMode: relaysecurity.Required} + if err := r.DB().Create(&tunnel).Error; err != nil { + t.Fatal(err) + } + const nodeID = int64(7) + if err := r.DB().Transaction(func(tx *gorm.DB) error { + created, err := r.RelayEndpointCredentials(tx, tunnel.ID, nodeID, role, true) + if err != nil { + return err + } + for _, create := range []bool{false, true} { + loaded, err := r.RelayEndpointCredentials(tx, tunnel.ID, nodeID, role, create) + if err != nil { + return err + } + if *created != *loaded { + return fmt.Errorf("reloading endpoint changed credentials") + } + } + return nil + }); err != nil { + t.Fatalf("create and reload endpoint: %v", err) + } + if checked != 1 { + t.Fatalf("expected one chain lookup for a new endpoint, got %d", checked) + } + + chain := model.ChainTunnel{TunnelID: tunnel.ID, NodeID: nodeID, ChainType: strconv.Itoa(role)} + if err := r.DB().Create(&chain).Error; err != nil { + t.Fatal(err) + } + if err := r.DB().Where("tunnel_id = ?", tunnel.ID).Delete(&model.RelayEndpointAuth{}).Error; err != nil { + t.Fatal(err) + } + if _, err := r.RelayEndpointCredentials(nil, tunnel.ID, nodeID, role, true); err == nil || !strings.Contains(err.Error(), "refusing regeneration") { + t.Fatalf("missing protected credentials must fail closed, got %v", err) + } + if checked != 2 { + t.Fatalf("expected chain lookup before refusing regeneration, got %d", checked) + } + var count int64 + if err := r.DB().Model(&model.RelayEndpointAuth{}).Count(&count).Error; err != nil { + t.Fatal(err) + } + if count != 0 { + t.Fatal("regenerated credentials for an existing protected endpoint") + } + }) + } +} diff --git a/go-backend/tests/contract/federation_dual_panel_contract_test.go b/go-backend/tests/contract/federation_dual_panel_contract_test.go index 1e6530f..3a1ed57 100644 --- a/go-backend/tests/contract/federation_dual_panel_contract_test.go +++ b/go-backend/tests/contract/federation_dual_panel_contract_test.go @@ -440,13 +440,15 @@ func TestFederationDualPanelRemoteEntryRuntimeContract(t *testing.T) { func insertContractNode(t *testing.T, r *repo.Repository, name, ip, portRange, secret string, status int) int64 { t.Helper() now := time.Now().UnixMilli() - if err := r.DB().Exec(` + var id int64 + if err := r.DB().Raw(` INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, name, secret, ip, ip, "", portRange, "", "v1", 1, 1, 1, now, now, status, "[::]", "[::]", 0).Error; err != nil { + RETURNING id + `, name, secret, ip, ip, "", portRange, "", "v1", 1, 1, 1, now, now, status, "[::]", "[::]", 0).Row().Scan(&id); err != nil { t.Fatalf("insert node %s: %v", name, err) } - return mustLastInsertID(t, r, name) + return id } func insertPeerShare(t *testing.T, r *repo.Repository, share *repo.PeerShare) int64 { diff --git a/go-backend/tests/contract/relay_auth_contract_test.go b/go-backend/tests/contract/relay_auth_contract_test.go index be9c611..34d67e0 100644 --- a/go-backend/tests/contract/relay_auth_contract_test.go +++ b/go-backend/tests/contract/relay_auth_contract_test.go @@ -11,11 +11,24 @@ import ( "time" "go-backend/internal/auth" + httpserver "go-backend/internal/http" + "go-backend/internal/http/handler" "go-backend/internal/store/repo" ) func TestNewRelayTunnelEmitsMatchingPrivateEndpointAuthentication(t *testing.T) { router, r := setupContractRouter(t, "relay-auth-contract") + assertNewRelayTunnelEndpointAuthentication(t, router, r) +} + +func TestPostgresNewRelayTunnelEmitsMatchingPrivateEndpointAuthentication(t *testing.T) { + r := ownerNamesRepository(t, "postgres")() + router := httpserver.NewRouter(handler.New(r, "relay-auth-contract"), "relay-auth-contract") + assertNewRelayTunnelEndpointAuthentication(t, router, r) +} + +func assertNewRelayTunnelEndpointAuthentication(t *testing.T, router http.Handler, r *repo.Repository) { + t.Helper() server := httptest.NewServer(router) defer server.Close() token, err := auth.GenerateToken(1, "admin", 0, "relay-auth-contract")