]> begriffs open source - pg_scribe/blob - tests/test_validation_ordering.sh
Apply diffs faster with synchronous_commit = off
[pg_scribe] / tests / test_validation_ordering.sh
1 #!/usr/bin/env bash
2 #
3 # Test suite for pg_scribe validation ordering bugs
4 #
5 # This test suite verifies that pg_scribe validates all prerequisites
6 # BEFORE making any state changes, to prevent corrupted/orphaned chains
7 #
8 # Tests cover:
9 # 1. cmd_new_chain with non-existent replication slot
10 # 2. cmd_new_chain --start with non-existent replication slot
11 # 3. cmd_start with metadata/slot name mismatch
12 # 4. cmd_new_chain with missing compression tool
13 #
14
15 set -euo pipefail
16
17 # Colors for test output
18 RED='\033[0;31m'
19 GREEN='\033[0;32m'
20 YELLOW='\033[0;33m'
21 BLUE='\033[0;34m'
22 NC='\033[0m' # No Color
23
24 # Test configuration
25 SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
26 PG_SCRIBE="$SCRIPT_DIR/scripts/pg_scribe"
27 TEST_DIR="/tmp/pg_scribe_validation_test_$$"
28 TEST_DB_PREFIX="pg_scribe_val_$$"
29 PGUSER="${PGUSER:-postgres}"
30
31 # Test counters
32 TESTS_RUN=0
33 TESTS_PASSED=0
34 TESTS_FAILED=0
35
36 # Cleanup tracking
37 DATABASES_TO_CLEANUP=()
38 SLOTS_TO_CLEANUP=()
39
40 #
41 # Logging functions
42 #
43
44 log_test() {
45     echo -e "${BLUE}TEST:${NC} $*"
46 }
47
48 log_pass() {
49     echo -e "${GREEN}PASS:${NC} $*"
50     ((TESTS_PASSED++))
51 }
52
53 log_fail() {
54     echo -e "${RED}FAIL:${NC} $*"
55     ((TESTS_FAILED++))
56 }
57
58 log_info() {
59     echo -e "${YELLOW}INFO:${NC} $*"
60 }
61
62 #
63 # Helper functions
64 #
65
66 run_psql() {
67     local dbname="$1"
68     shift
69     psql -U "$PGUSER" -d "$dbname" -tAq "$@"
70 }
71
72 query_db() {
73     local dbname="$1"
74     local query="$2"
75     run_psql "$dbname" -c "$query" 2>/dev/null || true
76 }
77
78 create_test_db() {
79     local dbname="$1"
80     log_info "Creating test database: $dbname"
81
82     # Drop if exists
83     psql -U "$PGUSER" -d postgres -c "DROP DATABASE IF EXISTS $dbname;" &>/dev/null || true
84
85     # Create database
86     psql -U "$PGUSER" -d postgres -c "CREATE DATABASE $dbname;" &>/dev/null
87
88     DATABASES_TO_CLEANUP+=("$dbname")
89 }
90
91 # shellcheck disable=SC2317  # Function called from cleanup trap handler
92 drop_test_db() {
93     local dbname="$1"
94     log_info "Dropping test database: $dbname"
95
96     # Terminate connections
97     psql -U "$PGUSER" -d postgres -c "
98         SELECT pg_terminate_backend(pid)
99         FROM pg_stat_activity
100         WHERE datname = '$dbname' AND pid <> pg_backend_pid();
101     " &>/dev/null || true
102
103     # Drop database
104     psql -U "$PGUSER" -d postgres -c "DROP DATABASE IF EXISTS $dbname;" &>/dev/null || true
105 }
106
107 create_table_with_pk() {
108     local dbname="$1"
109     local table="$2"
110     query_db "$dbname" "
111         CREATE TABLE $table (
112             id SERIAL PRIMARY KEY,
113             name TEXT,
114             created_at TIMESTAMP DEFAULT now()
115         );
116     "
117 }
118
119 # Initialize a backup directory (creates replication slot and initial backups)
120 init_backup_system() {
121     local dbname="$1"
122     local backup_dir="$2"
123     local slot="$3"
124
125     mkdir -p "$backup_dir"
126     "$PG_SCRIBE" --init -d "$dbname" -f "$backup_dir" -S "$slot" -U "$PGUSER" &>/dev/null
127
128     SLOTS_TO_CLEANUP+=("$dbname:$slot")
129 }
130
131 # Check if a chain directory was created
132 chain_dir_exists() {
133     local backup_dir="$1"
134     local chain_count
135     chain_count=$(find "$backup_dir" -maxdepth 1 -type d -name 'chain-*' 2>/dev/null | wc -l)
136     [[ $chain_count -gt 0 ]]
137 }
138
139 #
140 # Test cases
141 #
142
143 test_new_chain_validates_slot_exists() {
144     ((TESTS_RUN++))
145     log_test "cmd_new_chain should validate slot exists BEFORE creating chain"
146
147     local dbname="${TEST_DB_PREFIX}_slot1"
148     local backup_dir="$TEST_DIR/slot1"
149     local real_slot="test_slot_real"
150     local fake_slot="nonexistent_slot"
151
152     # Setup: Initialize with real slot
153     create_test_db "$dbname"
154     create_table_with_pk "$dbname" "users"
155     query_db "$dbname" "INSERT INTO users (name) VALUES ('Alice');"
156     init_backup_system "$dbname" "$backup_dir" "$real_slot"
157
158     # Count chains before (should be 1 from init)
159     local chains_before
160     chains_before=$(find "$backup_dir" -maxdepth 1 -type d -name 'chain-*' 2>/dev/null | wc -l)
161
162     # Try to create new chain with non-existent slot
163     local exit_code=0
164     local output
165     output=$("$PG_SCRIBE" --new-chain -d "$dbname" -f "$backup_dir" -S "$fake_slot" -U "$PGUSER" 2>&1) || exit_code=$?
166
167     # Should fail with slot error (exit code 3)
168     if [[ $exit_code -ne 3 ]]; then
169         log_fail "Expected exit code 3 (slot error), got $exit_code"
170         echo "Output: $output"
171         return 1
172     fi
173
174     # CRITICAL: Should NOT have created a new chain directory
175     local chains_after
176     chains_after=$(find "$backup_dir" -maxdepth 1 -type d -name 'chain-*' 2>/dev/null | wc -l)
177
178     if [[ $chains_after -ne $chains_before ]]; then
179         log_fail "CRITICAL: Created orphaned chain before validating slot!"
180         log_fail "Chains before: $chains_before, after: $chains_after"
181         return 1
182     fi
183
184     log_pass "Validates slot exists before creating chain"
185     return 0
186 }
187
188 test_new_chain_start_validates_slot_exists() {
189     ((TESTS_RUN++))
190     log_test "cmd_new_chain --start should validate slot BEFORE creating chain"
191
192     local dbname="${TEST_DB_PREFIX}_slot2"
193     local backup_dir="$TEST_DIR/slot2"
194     local real_slot="test_slot_real2"
195     local fake_slot="nonexistent_slot2"
196
197     # Setup: Initialize with real slot
198     create_test_db "$dbname"
199     create_table_with_pk "$dbname" "products"
200     init_backup_system "$dbname" "$backup_dir" "$real_slot"
201
202     # Count chains before
203     local chains_before
204     chains_before=$(find "$backup_dir" -maxdepth 1 -type d -name 'chain-*' 2>/dev/null | wc -l)
205
206     # Try to create new chain with --start and wrong slot
207     # Use timeout to prevent hanging if it tries to exec pg_recvlogical
208     local exit_code=0
209     local output
210     output=$(timeout 10s "$PG_SCRIBE" --new-chain --start -d "$dbname" -f "$backup_dir" -S "$fake_slot" -U "$PGUSER" 2>&1) || exit_code=$?
211
212     # Check what happened
213     local chains_after
214     chains_after=$(find "$backup_dir" -maxdepth 1 -type d -name 'chain-*' 2>/dev/null | wc -l)
215
216     # If command timed out (exit 124), it means it started pg_recvlogical
217     # which means it created the chain first - THIS IS THE BUG
218     if [[ $exit_code -eq 124 ]]; then
219         log_fail "CRITICAL: Command hung (tried to start pg_recvlogical)"
220         log_fail "This means it created the chain BEFORE validating the slot!"
221         log_fail "Chains before: $chains_before, after: $chains_after"
222
223         # Kill any lingering pg_recvlogical
224         pkill -f "pg_recvlogical.*$backup_dir" 2>/dev/null || true
225         rm -f "$backup_dir/.pg_scribe.pid" 2>/dev/null || true
226
227         return 1
228     fi
229
230     # Should fail with slot error (exit code 3)
231     if [[ $exit_code -ne 3 ]]; then
232         log_fail "Expected exit code 3 (slot error), got $exit_code"
233         echo "Output: $output"
234
235         # Check if orphaned chain was created
236         if [[ $chains_after -ne $chains_before ]]; then
237             log_fail "Also created orphaned chain!"
238         fi
239
240         return 1
241     fi
242
243     # CRITICAL: Should NOT have created a new chain directory
244     if [[ $chains_after -ne $chains_before ]]; then
245         log_fail "CRITICAL: Created orphaned chain before validating slot!"
246         log_fail "This is the bug that hit the user!"
247         log_fail "Chains before: $chains_before, after: $chains_after"
248         return 1
249     fi
250
251     log_pass "Validates slot exists before creating chain (with --start)"
252     return 0
253 }
254
255 test_start_reads_slot_from_metadata() {
256     ((TESTS_RUN++))
257     log_test "cmd_start should read slot from metadata (not accept -S parameter)"
258
259     local dbname="${TEST_DB_PREFIX}_meta"
260     local backup_dir="$TEST_DIR/meta"
261     local real_slot="test_slot_meta"
262
263     # Setup: Initialize with real slot
264     create_test_db "$dbname"
265     create_table_with_pk "$dbname" "orders"
266     init_backup_system "$dbname" "$backup_dir" "$real_slot"
267
268     # Start should work without -S flag (reads from metadata)
269     # Use timeout in case something goes wrong
270     local exit_code=0
271     local output
272     output=$(timeout 5s "$PG_SCRIBE" --start -d "$dbname" -f "$backup_dir" -U "$PGUSER" 2>&1) || exit_code=$?
273
274     # Should have started successfully (exit 124 = timeout = streaming started)
275     if [[ $exit_code -ne 124 ]]; then
276         log_fail "Expected streaming to start (timeout), got exit code $exit_code"
277         echo "Output: $output"
278         return 1
279     fi
280
281     # Should have created pidfile
282     if [[ ! -f "$backup_dir/.pg_scribe.pid" ]]; then
283         log_fail "Pidfile not created"
284         return 1
285     fi
286
287     # Clean up streaming process
288     local pid
289     pid=$(cat "$backup_dir/.pg_scribe.pid")
290     kill -TERM "$pid" 2>/dev/null || true
291     rm -f "$backup_dir/.pg_scribe.pid"
292
293     # Verify it logged the correct slot from metadata
294     if ! echo "$output" | grep -q "$real_slot"; then
295         log_fail "Output should show slot from metadata: $real_slot"
296         echo "Output: $output"
297         return 1
298     fi
299
300     log_pass "Reads slot from metadata correctly"
301     return 0
302 }
303
304 test_new_chain_validates_compression_tool() {
305     ((TESTS_RUN++))
306     log_test "cmd_new_chain should validate compression tool exists BEFORE backup"
307
308     local dbname="${TEST_DB_PREFIX}_compress"
309     local backup_dir="$TEST_DIR/compress"
310     local slot="test_slot_compress"
311
312     # Setup
313     create_test_db "$dbname"
314     create_table_with_pk "$dbname" "data_table"
315     init_backup_system "$dbname" "$backup_dir" "$slot"
316
317     # Count chains before
318     local chains_before
319     chains_before=$(find "$backup_dir" -maxdepth 1 -type d -name 'chain-*' 2>/dev/null | wc -l)
320
321     # Try to use a fake compression method
322     local exit_code=0
323     local output
324     output=$("$PG_SCRIBE" --new-chain -d "$dbname" -f "$backup_dir" -Z totally_fake_compression -U "$PGUSER" 2>&1) || exit_code=$?
325
326     # Should fail with validation error (exit code 5) or backup error (exit code 4)
327     # The important thing is it should NOT create a chain directory first
328     if [[ $exit_code -eq 0 ]]; then
329         log_fail "Should have failed with invalid compression method"
330         return 1
331     fi
332
333     # CRITICAL: Should NOT have created a new chain directory
334     local chains_after
335     chains_after=$(find "$backup_dir" -maxdepth 1 -type d -name 'chain-*' 2>/dev/null | wc -l)
336
337     if [[ $chains_after -ne $chains_before ]]; then
338         log_fail "CRITICAL: Created orphaned chain before validating compression!"
339         log_fail "Chains before: $chains_before, after: $chains_after"
340         return 1
341     fi
342
343     log_pass "Validates compression method before creating chain"
344     return 0
345 }
346
347 test_new_chain_metadata_slot_consistency() {
348     ((TESTS_RUN++))
349     log_test "cmd_new_chain should preserve slot name in metadata"
350
351     local dbname="${TEST_DB_PREFIX}_consistency"
352     local backup_dir="$TEST_DIR/consistency"
353     local slot="test_slot_consistency"
354
355     # Setup
356     create_test_db "$dbname"
357     create_table_with_pk "$dbname" "items"
358     init_backup_system "$dbname" "$backup_dir" "$slot"
359
360     # Create a new chain (should succeed)
361     sleep 1  # Ensure different timestamp
362     if ! "$PG_SCRIBE" --new-chain -d "$dbname" -f "$backup_dir" -S "$slot" -U "$PGUSER" &>/dev/null; then
363         log_fail "New chain creation failed"
364         return 1
365     fi
366
367     # Get latest chain
368     local latest_chain
369     latest_chain=$(find "$backup_dir" -maxdepth 1 -type d -name 'chain-*' 2>/dev/null | sort | tail -1)
370
371     # Verify metadata has correct slot name
372     local metadata_slot
373     metadata_slot=$(grep '"replication_slot"' "$latest_chain/metadata.json" | cut -d'"' -f4)
374
375     if [[ "$metadata_slot" != "$slot" ]]; then
376         log_fail "Metadata slot mismatch: expected '$slot', got '$metadata_slot'"
377         return 1
378     fi
379
380     log_pass "Metadata preserves slot name correctly"
381     return 0
382 }
383
384 #
385 # Cleanup
386 #
387
388 # shellcheck disable=SC2317  # Function called via trap handler
389 cleanup() {
390     log_info "Cleaning up test resources..."
391
392     # Drop replication slots
393     for entry in "${SLOTS_TO_CLEANUP[@]}"; do
394         local dbname="${entry%%:*}"
395         local slot="${entry#*:}"
396         psql -U "$PGUSER" -d "$dbname" -c "
397             SELECT pg_drop_replication_slot('$slot')
398             FROM pg_replication_slots
399             WHERE slot_name = '$slot';
400         " &>/dev/null || true
401     done
402
403     # Stop any lingering streaming processes
404     if [[ -d "$TEST_DIR" ]]; then
405         find "$TEST_DIR" -name '.pg_scribe.pid' 2>/dev/null | while read -r pidfile; do
406             if [[ -f "$pidfile" ]]; then
407                 local pid
408                 pid=$(cat "$pidfile")
409                 kill -TERM "$pid" 2>/dev/null || true
410             fi
411         done
412     fi
413
414     # Drop databases
415     for dbname in "${DATABASES_TO_CLEANUP[@]}"; do
416         drop_test_db "$dbname"
417     done
418
419     # Remove test directory
420     if [[ -d "$TEST_DIR" ]]; then
421         rm -rf "$TEST_DIR"
422     fi
423
424     log_info "Cleanup complete"
425 }
426
427 #
428 # Main test runner
429 #
430
431 main() {
432     echo "========================================"
433     echo "pg_scribe Validation Ordering Tests"
434     echo "========================================"
435     echo ""
436     echo "These tests verify that pg_scribe validates"
437     echo "all prerequisites BEFORE making state changes"
438     echo ""
439
440     # Verify pg_scribe exists
441     if [[ ! -x "$PG_SCRIBE" ]]; then
442         echo "ERROR: pg_scribe not found or not executable: $PG_SCRIBE"
443         exit 1
444     fi
445
446     # Verify PostgreSQL is running
447     if ! psql -U "$PGUSER" -d postgres -c "SELECT 1;" &>/dev/null; then
448         echo "ERROR: Cannot connect to PostgreSQL"
449         exit 1
450     fi
451
452     # Verify wal_level is logical
453     local wal_level
454     wal_level=$(psql -U "$PGUSER" -d postgres -tAq -c "SHOW wal_level;")
455     if [[ "$wal_level" != "logical" ]]; then
456         echo "ERROR: wal_level must be 'logical', currently: $wal_level"
457         echo "Update ~/.pgenv/pgsql/data/postgresql.conf and restart PostgreSQL"
458         exit 1
459     fi
460
461     # Create test directory
462     mkdir -p "$TEST_DIR"
463
464     # Set up cleanup trap
465     trap cleanup EXIT INT TERM
466
467     echo "Running tests..."
468     echo ""
469
470     # Run all tests (use || true to prevent set -e from exiting)
471     test_new_chain_validates_slot_exists || true
472     test_new_chain_start_validates_slot_exists || true
473     test_start_reads_slot_from_metadata || true
474     test_new_chain_validates_compression_tool || true
475     test_new_chain_metadata_slot_consistency || true
476
477     # Summary
478     echo ""
479     echo "========================================"
480     echo "Test Results"
481     echo "========================================"
482     echo "Tests run:    $TESTS_RUN"
483     echo -e "Tests passed: ${GREEN}$TESTS_PASSED${NC}"
484     echo -e "Tests failed: ${RED}$TESTS_FAILED${NC}"
485     echo ""
486
487     if [[ $TESTS_FAILED -eq 0 ]]; then
488         echo -e "${GREEN}All tests passed!${NC}"
489         exit 0
490     else
491         echo -e "${RED}Some tests failed!${NC}"
492         echo ""
493         echo "This is EXPECTED before fixing the bugs."
494         echo "These failures demonstrate the validation ordering problems."
495         exit 1
496     fi
497 }
498
499 main "$@"