|
8 | 8 | "github.com/jackc/pgx/v5" |
9 | 9 | "github.com/stretchr/testify/require" |
10 | 10 |
|
11 | | - connpostgres "github.com/PeerDB-io/peerdb/flow/connectors/postgres" |
12 | 11 | "github.com/PeerDB-io/peerdb/flow/generated/protos" |
13 | 12 | "github.com/PeerDB-io/peerdb/flow/internal" |
14 | 13 | ) |
@@ -283,54 +282,135 @@ func (s PeerFlowE2ETestSuitePG) Test_PG_Schema_Dump_And_CDC() { |
283 | 282 | RequireEnvCanceled(s.t, env) |
284 | 283 | } |
285 | 284 |
|
286 | | -// Test_PG_Schema_Dump_Role_Migration verifies that pg_dumpall --roles-only |
287 | | -// propagates a role from a source PG cluster to a separate destination PG |
288 | | -// cluster. Roles are global objects per cluster, so this requires two clusters |
289 | | -// (peerdb-postgres + peerdb-postgres2). |
290 | | -func (s PeerFlowE2ETestSuitePG) Test_PG_Schema_Dump_Role_Migration() { |
291 | | - srcCfg := internal.GetAncillaryPostgresConfigFromEnv() |
| 285 | +// Test_PG_Schema_Dump_No_Owner_No_Privileges verifies that the schema dump does |
| 286 | +// not emit owner or grant statements that reference roles. We create a role on |
| 287 | +// the source, give it ownership and grants on a table, then dump into a |
| 288 | +// secondary cluster where that role does NOT exist. With --no-owner and |
| 289 | +// --no-privileges the dump must succeed; without them it would fail on |
| 290 | +// ALTER TABLE ... OWNER TO <missing_role> / GRANT ... TO <missing_role>. |
| 291 | +// |
| 292 | +// Also verifies initial load + CDC into the dumped table on the destination |
| 293 | +// cluster, so we know the table is usable end-to-end. |
| 294 | +func (s PeerFlowE2ETestSuitePG) Test_PG_Schema_Dump_No_Owner_No_Privileges() { |
292 | 295 | dstCfg := internal.GetSecondaryPostgresConfigFromEnv() |
293 | 296 |
|
294 | | - roleName := "peerdb_test_role_" + s.suffix |
295 | | - |
296 | | - ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) |
297 | | - defer cancel() |
| 297 | + srcSchema := "e2e_test_" + s.suffix |
| 298 | + roleName := "peerdb_owner_role_" + s.suffix |
| 299 | + tableName := "owned_tbl" |
| 300 | + qualified := fmt.Sprintf("%s.%s", srcSchema, tableName) |
298 | 301 |
|
| 302 | + // destination connection (for assertions + role-absence sanity) |
299 | 303 | dstConnStr := fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s", |
300 | 304 | dstCfg.Host, dstCfg.Port, dstCfg.User, dstCfg.Password, dstCfg.Database) |
301 | | - dstConn, err := pgx.Connect(ctx, dstConnStr) |
| 305 | + dstConn, err := pgx.Connect(s.t.Context(), dstConnStr) |
302 | 306 | require.NoError(s.t, err, "failed to connect to secondary postgres on %s:%d (is the postgres2 tilt resource running?)", dstCfg.Host, dstCfg.Port) |
303 | | - defer dstConn.Close(ctx) |
| 307 | + s.t.Cleanup(func() { dstConn.Close(s.t.Context()) }) |
304 | 308 |
|
305 | | - // sanity: role must not pre-exist on destination |
306 | | - var preExists bool |
307 | | - require.NoError(s.t, dstConn.QueryRow(ctx, |
308 | | - "SELECT EXISTS(SELECT 1 FROM pg_roles WHERE rolname=$1)", roleName).Scan(&preExists)) |
309 | | - require.False(s.t, preExists, "role %s unexpectedly exists on destination before test", roleName) |
| 309 | + // sanity: role must not exist on destination |
| 310 | + var roleExistsOnDst bool |
| 311 | + require.NoError(s.t, dstConn.QueryRow(s.t.Context(), |
| 312 | + "SELECT EXISTS(SELECT 1 FROM pg_roles WHERE rolname=$1)", roleName).Scan(&roleExistsOnDst)) |
| 313 | + require.False(s.t, roleExistsOnDst, "role %s unexpectedly exists on destination", roleName) |
310 | 314 |
|
311 | | - // create role on source |
| 315 | + // create role + owned/granted table on source with seed rows |
312 | 316 | _, err = s.Conn().Exec(s.t.Context(), fmt.Sprintf("CREATE ROLE %s LOGIN PASSWORD 'pw'", roleName)) |
313 | 317 | require.NoError(s.t, err) |
| 318 | + _, err = s.Conn().Exec(s.t.Context(), fmt.Sprintf( |
| 319 | + "CREATE TABLE %s (id SERIAL PRIMARY KEY, val TEXT)", qualified)) |
| 320 | + require.NoError(s.t, err) |
| 321 | + _, err = s.Conn().Exec(s.t.Context(), fmt.Sprintf("ALTER TABLE %s OWNER TO %s", qualified, roleName)) |
| 322 | + require.NoError(s.t, err) |
| 323 | + _, err = s.Conn().Exec(s.t.Context(), fmt.Sprintf("GRANT SELECT, INSERT ON %s TO %s", qualified, roleName)) |
| 324 | + require.NoError(s.t, err) |
| 325 | + |
| 326 | + for i := 1; i <= 5; i++ { |
| 327 | + _, err = s.Conn().Exec(s.t.Context(), |
| 328 | + fmt.Sprintf("INSERT INTO %s (val) VALUES ($1)", qualified), |
| 329 | + fmt.Sprintf("snap_%d", i)) |
| 330 | + require.NoError(s.t, err) |
| 331 | + } |
| 332 | + |
314 | 333 | s.t.Cleanup(func() { |
315 | 334 | cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 30*time.Second) |
316 | 335 | defer cleanupCancel() |
| 336 | + _, _ = s.Conn().Exec(cleanupCtx, fmt.Sprintf("DROP TABLE IF EXISTS %s", qualified)) |
317 | 337 | _, _ = s.Conn().Exec(cleanupCtx, "DROP ROLE IF EXISTS "+roleName) |
318 | 338 |
|
319 | 339 | dropConn, err := pgx.Connect(cleanupCtx, dstConnStr) |
320 | 340 | if err != nil { |
321 | | - s.t.Logf("failed to connect to destination for role cleanup: %v", err) |
| 341 | + s.t.Logf("failed to connect to destination for cleanup: %v", err) |
322 | 342 | return |
323 | 343 | } |
324 | 344 | defer dropConn.Close(cleanupCtx) |
325 | | - if _, err := dropConn.Exec(cleanupCtx, "DROP ROLE IF EXISTS "+roleName); err != nil { |
326 | | - s.t.Logf("failed to drop destination role %s: %v", roleName, err) |
327 | | - } |
| 345 | + _, _ = dropConn.Exec(cleanupCtx, fmt.Sprintf("DROP SCHEMA IF EXISTS %s CASCADE", srcSchema)) |
328 | 346 | }) |
329 | 347 |
|
330 | | - require.NoError(s.t, connpostgres.RunPgDumpSchema(ctx, srcCfg, dstCfg)) |
| 348 | + // register destination peer pointing at postgres2 |
| 349 | + dstPeerName := "pgdump_noowner_dst_" + s.suffix |
| 350 | + CreatePeer(s.t, &protos.Peer{ |
| 351 | + Name: dstPeerName, |
| 352 | + Type: protos.DBType_POSTGRES, |
| 353 | + Config: &protos.Peer_PostgresConfig{PostgresConfig: dstCfg}, |
| 354 | + }) |
331 | 355 |
|
332 | | - var postExists bool |
333 | | - require.NoError(s.t, dstConn.QueryRow(ctx, |
334 | | - "SELECT EXISTS(SELECT 1 FROM pg_roles WHERE rolname=$1)", roleName).Scan(&postExists)) |
335 | | - require.True(s.t, postExists, "role %s should have been migrated to destination cluster", roleName) |
| 356 | + config := &protos.FlowConnectionConfigs{ |
| 357 | + FlowJobName: s.attachSuffix("test_pgdump_noowner"), |
| 358 | + DestinationName: dstPeerName, |
| 359 | + TableMappings: []*protos.TableMapping{{ |
| 360 | + SourceTableIdentifier: qualified, |
| 361 | + DestinationTableIdentifier: qualified, |
| 362 | + }}, |
| 363 | + SourceName: GeneratePostgresPeer(s.t).Name, |
| 364 | + MaxBatchSize: 100, |
| 365 | + DoInitialSnapshot: true, |
| 366 | + System: protos.TypeSystem_PG, |
| 367 | + Env: map[string]string{ |
| 368 | + "PEERDB_PG_AUTOMATED_SCHEMA_DUMP": "true", |
| 369 | + }, |
| 370 | + } |
| 371 | + |
| 372 | + tc := NewTemporalClient(s.t) |
| 373 | + env := ExecutePeerflow(s.t, tc, config) |
| 374 | + SetupCDCFlowStatusQuery(s.t, env, config) |
| 375 | + |
| 376 | + // initial load: pg_dump must succeed despite missing owner/grantee role, |
| 377 | + // then snapshot copies the 5 seed rows. |
| 378 | + EnvWaitFor(s.t, env, 3*time.Minute, "initial load owned_tbl", func() bool { |
| 379 | + var count int64 |
| 380 | + err := dstConn.QueryRow(s.t.Context(), |
| 381 | + fmt.Sprintf("SELECT COUNT(*) FROM %s", qualified)).Scan(&count) |
| 382 | + return err == nil && count == 5 |
| 383 | + }) |
| 384 | + |
| 385 | + // CDC: insert more rows on source and wait for them on dst |
| 386 | + for i := 6; i <= 10; i++ { |
| 387 | + _, err = s.Conn().Exec(s.t.Context(), |
| 388 | + fmt.Sprintf("INSERT INTO %s (val) VALUES ($1)", qualified), |
| 389 | + fmt.Sprintf("cdc_%d", i)) |
| 390 | + EnvNoError(s.t, env, err) |
| 391 | + } |
| 392 | + |
| 393 | + EnvWaitFor(s.t, env, 3*time.Minute, "cdc owned_tbl", func() bool { |
| 394 | + var count int64 |
| 395 | + err := dstConn.QueryRow(s.t.Context(), |
| 396 | + fmt.Sprintf("SELECT COUNT(*) FROM %s", qualified)).Scan(&count) |
| 397 | + return err == nil && count == 10 |
| 398 | + }) |
| 399 | + |
| 400 | + // owner on dst should be the connecting user, not the (missing) source role |
| 401 | + var dstOwner string |
| 402 | + require.NoError(s.t, dstConn.QueryRow(s.t.Context(), |
| 403 | + "SELECT tableowner FROM pg_tables WHERE schemaname=$1 AND tablename=$2", |
| 404 | + srcSchema, tableName).Scan(&dstOwner)) |
| 405 | + require.NotEqual(s.t, roleName, dstOwner, "destination table should not be owned by the source-only role") |
| 406 | + |
| 407 | + // no grants should reference the missing role on dst |
| 408 | + var grantCount int |
| 409 | + require.NoError(s.t, dstConn.QueryRow(s.t.Context(), |
| 410 | + "SELECT COUNT(*) FROM information_schema.table_privileges WHERE table_schema=$1 AND table_name=$2 AND grantee=$3", |
| 411 | + srcSchema, tableName, roleName).Scan(&grantCount)) |
| 412 | + require.Zero(s.t, grantCount, "no privileges should be granted to the source-only role on destination") |
| 413 | + |
| 414 | + env.Cancel(s.t.Context()) |
| 415 | + RequireEnvCanceled(s.t, env) |
336 | 416 | } |
0 commit comments