1 package sources_test
2
3 import (
4 "context"
5 "fmt"
6 "net/http"
7 "net/http/httptest"
8 "net/url"
9 "strings"
10 "sync"
11 "testing"
12 "testing/synctest"
13 "time"
14
15 "github.com/jackc/pgx/v5"
16 "github.com/jackc/pgx/v5/pgconn"
17 "github.com/jackc/pgx/v5/pgxpool"
18 "github.com/pashagolub/pgxmock/v5"
19 "github.com/sirupsen/logrus"
20 "github.com/stretchr/testify/assert"
21 "github.com/stretchr/testify/require"
22
23 "github.com/cybertec-postgresql/pgwatch/v6/internal/db"
24 "github.com/cybertec-postgresql/pgwatch/v6/internal/metrics"
25 "github.com/cybertec-postgresql/pgwatch/v6/internal/sources"
26 "github.com/cybertec-postgresql/pgwatch/v6/internal/testutil"
27 )
28
29 func TestSourceConn_Connect(t *testing.T) {
30
31 t.Run("failed config parsing", func(t *testing.T) {
32 md := &sources.DbConn{}
33 md.ConnStr = "invalid connection string"
34 err := md.Connect(ctx, sources.CmdOpts{})
35 assert.Error(t, err)
36 })
37
38 t.Run("failed connection", func(t *testing.T) {
39 md := &sources.DbConn{}
40 sources.NewConnWithConfig = func(_ context.Context, _ *pgxpool.Config, _ ...db.ConnConfigCallback) (db.PgxPoolIface, error) {
41 return nil, assert.AnError
42 }
43 err := md.Connect(ctx, sources.CmdOpts{})
44 assert.ErrorIs(t, err, assert.AnError)
45 })
46
47 t.Run("successful connection to pgbouncer", func(t *testing.T) {
48 mock, err := pgxmock.NewPool()
49 require.NoError(t, err)
50 sources.NewConnWithConfig = func(_ context.Context, _ *pgxpool.Config, _ ...db.ConnConfigCallback) (db.PgxPoolIface, error) {
51 return mock, nil
52 }
53
54 md := &sources.DbConn{}
55 md.Kind = sources.SourcePgBouncer
56
57 opts := sources.CmdOpts{}
58 opts.MaxParallelConnectionsPerDb = 3
59
60 mock.ExpectExec("SHOW VERSION").WillReturnResult(pgconn.NewCommandTag("SELECT 1"))
61
62 err = md.Connect(ctx, opts)
63 assert.NoError(t, err)
64
65 assert.NoError(t, mock.ExpectationsWereMet())
66 })
67 }
68
69 func TestSourceConn_ParseConfig(t *testing.T) {
70 md := &sources.DbConn{}
71 assert.NoError(t, md.ParseConfig())
72
73 assert.NoError(t, md.ParseConfig())
74 }
75
76 func TestSourceConn_GetDatabaseName(t *testing.T) {
77 md := &sources.DbConn{}
78 md.ConnStr = "postgres://user:password@localhost:5432/mydatabase"
79 expected := "mydatabase"
80
81 got := md.GetDatabaseName()
82 assert.Equal(t, expected, got, "GetDatabaseName() = %v, want %v", got, expected)
83
84 got = md.Source.GetDatabaseName()
85 assert.Equal(t, expected, got, "GetDatabaseName() = %v, want %v", got, expected)
86
87 md = &sources.DbConn{}
88 md.ConnStr = "foo boo"
89 expected = ""
90 got = md.GetDatabaseName()
91 assert.Equal(t, expected, got, "GetDatabaseName() = %v, want %v", got, expected)
92 }
93
94 func TestSourceConn_SetDatabaseName(t *testing.T) {
95 md := &sources.DbConn{}
96 md.ConnStr = "postgres://user:password@localhost:5432/mydatabase"
97 expected := "mydatabase"
98
99 md.SetDatabaseName(expected)
100 got := md.GetDatabaseName()
101 assert.Equal(t, expected, got, "GetDatabaseName() = %v, want %v", got, expected)
102
103 expected = "newdatabase"
104 md.SetDatabaseName(expected)
105 got = md.GetDatabaseName()
106 assert.Equal(t, expected, got, "GetDatabaseName() = %v, want %v", got, expected)
107
108 md = &sources.DbConn{}
109 md.ConnStr = "foo boo"
110 expected = ""
111 md.SetDatabaseName("ingored due to invalid ConnStr")
112 got = md.GetDatabaseName()
113 assert.Equal(t, expected, got, "GetDatabaseName() = %v, want %v", got, expected)
114 }
115
116 func TestSourceConn_DiscoverPlatform(t *testing.T) {
117 ctx := context.Background()
118 mock, err := pgxmock.NewPool()
119 require.NoError(t, err)
120 md := &sources.DbConn{Conn: mock}
121
122 mock.ExpectQuery("select").WillReturnRows(pgxmock.NewRows([]string{"exec_env"}).AddRow("AZURE_SINGLE"))
123 assert.NoError(t, md.DiscoverPlatform(ctx))
124 assert.Equal(t, "AZURE_SINGLE", md.ExecEnv)
125 assert.NoError(t, md.DiscoverPlatform(ctx))
126 assert.NoError(t, mock.ExpectationsWereMet())
127 }
128
129 func TestSourceConn_GetApproxSize(t *testing.T) {
130 mock, err := pgxmock.NewPool()
131 require.NoError(t, err)
132 md := &sources.DbConn{Conn: mock}
133
134 mock.ExpectQuery("select").WillReturnRows(pgxmock.NewRows([]string{"size"}).AddRow(42))
135
136 assert.NoError(t, md.FetchApproxSize(ctx))
137 assert.EqualValues(t, 42, md.ApproxDbSize)
138 assert.NoError(t, mock.ExpectationsWereMet())
139 }
140
141 func TestSourceConn_FetchControlInfo(t *testing.T) {
142 ctx := context.Background()
143
144 t.Run("success", func(t *testing.T) {
145 mock, err := pgxmock.NewPool()
146 require.NoError(t, err)
147 md := &sources.DbConn{Conn: mock}
148
149 mock.ExpectQuery("select").WillReturnRows(pgxmock.NewRows(
150 []string{"ver", "version", "pg_is_in_recovery", "current_database", "system_identifier", "is_superuser"},
151 ).AddRow(16, "PostgreSQL 16.0", false, "testdb", "12345", true))
152
153 assert.NoError(t, md.FetchControlInfo(ctx))
154 assert.Equal(t, 16, md.Version)
155 assert.Equal(t, "PostgreSQL 16.0", md.VersionStr)
156 assert.False(t, md.IsInRecovery)
157 assert.Equal(t, "testdb", md.RealDbname)
158 assert.Equal(t, "12345", md.SystemIdentifier)
159 assert.True(t, md.IsSuperuser)
160 assert.NoError(t, mock.ExpectationsWereMet())
161 })
162
163 t.Run("query error", func(t *testing.T) {
164 mock, err := pgxmock.NewPool()
165 require.NoError(t, err)
166 md := &sources.DbConn{Conn: mock}
167
168 mock.ExpectQuery("select").WillReturnError(assert.AnError)
169 assert.Error(t, md.FetchControlInfo(ctx))
170 assert.NoError(t, mock.ExpectationsWereMet())
171 })
172 }
173
174 func TestSourceConn_FetchExtensions(t *testing.T) {
175 ctx := context.Background()
176
177 t.Run("success", func(t *testing.T) {
178 mock, err := pgxmock.NewPool()
179 require.NoError(t, err)
180 md := &sources.DbConn{Conn: mock, RuntimeInfo: sources.RuntimeInfo{Extensions: make(map[string]int)}}
181
182 mock.ExpectQuery("select").WillReturnRows(pgxmock.NewRows([]string{"extname", "extversion"}).
183 AddRow("pg_stat_statements", "1.10").
184 AddRow("plpgsql", "1.0"))
185
186 assert.NoError(t, md.FetchExtensions(ctx))
187 assert.Equal(t, 1_10_00, md.Extensions["pg_stat_statements"])
188 assert.Equal(t, 1_00_00, md.Extensions["plpgsql"])
189 assert.NoError(t, mock.ExpectationsWereMet())
190 })
191
192 t.Run("invalid extension version", func(t *testing.T) {
193 mock, err := pgxmock.NewPool()
194 require.NoError(t, err)
195 md := &sources.DbConn{Conn: mock, RuntimeInfo: sources.RuntimeInfo{Extensions: make(map[string]int)}}
196
197 mock.ExpectQuery("select").WillReturnRows(pgxmock.NewRows([]string{"extname", "extversion"}).
198 AddRow("badext", "notaversion"))
199
200 assert.Error(t, md.FetchExtensions(ctx))
201 assert.NoError(t, mock.ExpectationsWereMet())
202 })
203
204 t.Run("query error", func(t *testing.T) {
205 mock, err := pgxmock.NewPool()
206 require.NoError(t, err)
207 md := &sources.DbConn{Conn: mock, RuntimeInfo: sources.RuntimeInfo{Extensions: make(map[string]int)}}
208
209 mock.ExpectQuery("select").WillReturnError(assert.AnError)
210 assert.Error(t, md.FetchExtensions(ctx))
211 assert.NoError(t, mock.ExpectationsWereMet())
212 })
213 }
214
215 func TestSourceConn_FunctionExists(t *testing.T) {
216 mock, err := pgxmock.NewPool()
217 require.NoError(t, err)
218 md := &sources.DbConn{Conn: mock}
219
220 mock.ExpectQuery("select").WithArgs("get_foo").WillReturnRows(pgxmock.NewRows([]string{"exists"}))
221
222 assert.False(t, md.FunctionExists(ctx, "get_foo"))
223 assert.NoError(t, mock.ExpectationsWereMet())
224 }
225
226 func TestSourceConn_IsPostgresSource(t *testing.T) {
227 md := &sources.DbConn{}
228 md.Kind = sources.SourcePostgres
229 assert.True(t, md.IsPostgresSource(), "IsPostgresSource() = false, want true")
230
231 md.Kind = sources.SourcePgBouncer
232 assert.False(t, md.IsPostgresSource(), "IsPostgresSource() = true, want false")
233
234 md.Kind = sources.SourcePgPool
235 assert.False(t, md.IsPostgresSource(), "IsPostgresSource() = true, want false")
236
237 md.Kind = sources.SourcePatroniDiscovery
238 assert.True(t, md.IsPostgresSource(), "IsPostgresSource() = false, want true")
239
240 md.Kind = sources.SourcePrometheus
241 assert.False(t, md.IsPostgresSource(), "IsPostgresSource() = true, want false")
242 }
243
244 func TestSourceConn_Ping(t *testing.T) {
245 db, err := pgxmock.NewPool()
246 require.NoError(t, err)
247 md := &sources.DbConn{Conn: db}
248
249 db.ExpectPing()
250 md.Kind = sources.SourcePostgres
251 assert.NoError(t, md.Ping(ctx), "Ping() = error, want nil")
252
253 db.ExpectExec("SHOW VERSION").WillReturnResult(pgconn.NewCommandTag("SELECT 1"))
254 md.Conn = db
255 md.Kind = sources.SourcePgBouncer
256 assert.NoError(t, md.Ping(ctx), "Ping() = error, want nil")
257 }
258
259 func TestSourceConn_GetMetricInterval(t *testing.T) {
260 md := &sources.DbConn{
261 Source: sources.Source{
262 Metrics: metrics.MetricIntervals{"foo": 15, "bar": 25},
263 MetricsStandby: metrics.MetricIntervals{"foo": 35},
264 },
265 }
266
267 t.Run("primary uses Metrics", func(t *testing.T) {
268 md.IsInRecovery = false
269 assert.Equal(t, 15*time.Second, md.GetMetricInterval("foo"))
270 assert.Equal(t, 25*time.Second, md.GetMetricInterval("bar"))
271 })
272
273 t.Run("standby uses MetricsStandby if present", func(t *testing.T) {
274 md.IsInRecovery = true
275 assert.Equal(t, 35*time.Second, md.GetMetricInterval("foo"))
276 assert.Equal(t, time.Duration(0), md.GetMetricInterval("bar"))
277 })
278
279 t.Run("standby with empty MetricsStandby falls back to Metrics", func(t *testing.T) {
280 md.IsInRecovery = true
281 md.MetricsStandby = metrics.MetricIntervals{}
282 assert.Equal(t, 15*time.Second, md.GetMetricInterval("foo"))
283 })
284 }
285
286 func TestVersionToInt(t *testing.T) {
287 tests := []struct {
288 arg string
289 want int
290 }{
291 {"", 0},
292 {"foo", 0},
293 {"13", 13_00_00},
294 {"3.0", 3_00_00},
295 {"9.6.3", 9_06_03},
296 {"v9.6-beta2", 9_06_00},
297 }
298 for _, tt := range tests {
299 if got := sources.VersionToInt(tt.arg); got != tt.want {
300 t.Errorf("VersionToInt() = %v, want %v", got, tt.want)
301 }
302 }
303 }
304
305 func TestSourceConn_FetchRuntimeInfo(t *testing.T) {
306 ctx := context.Background()
307
308 t.Run("cancelled context", func(t *testing.T) {
309 ctxNew, cancel := context.WithCancel(ctx)
310 cancel()
311 err := (&sources.DbConn{}).FetchRuntimeInfo(ctxNew, true)
312 assert.Error(t, err)
313 })
314
315 t.Run("cached version", func(t *testing.T) {
316 md := sources.NewDbConn(sources.Source{})
317 md.SetLastCheckedForTesting(time.Now().Add(-time.Minute))
318 md.Version = 42
319 err := md.FetchRuntimeInfo(ctx, false)
320 assert.NoError(t, err)
321 assert.Equal(t, 42, md.Version)
322 })
323
324 t.Run("pgbouncer version fetch", func(t *testing.T) {
325 mock, err := pgxmock.NewPool()
326 require.NoError(t, err)
327 md := sources.NewDbConn(sources.Source{Kind: sources.SourcePgBouncer})
328 md.Conn = mock
329 mock.ExpectQuery("SHOW VERSION").
330 WithArgs(pgx.QueryExecModeSimpleProtocol).
331 WillReturnRows(pgxmock.NewRows([]string{"version"}).AddRow("PgBouncer 1.12.0"))
332 err = md.FetchRuntimeInfo(ctx, true)
333 assert.NoError(t, err)
334 assert.Contains(t, md.VersionStr, "PgBouncer")
335 assert.True(t, md.Version > 0)
336 assert.NoError(t, mock.ExpectationsWereMet())
337 })
338
339 t.Run("pgpool version fetch", func(t *testing.T) {
340 mock, err := pgxmock.NewPool()
341 require.NoError(t, err)
342 md := sources.NewDbConn(sources.Source{Kind: sources.SourcePgPool})
343 md.Conn = mock
344 mock.ExpectQuery("SHOW POOL_VERSION").
345 WithArgs(pgx.QueryExecModeSimpleProtocol).
346 WillReturnRows(pgxmock.NewRows([]string{"version"}).AddRow("4.1.2"))
347 err = md.FetchRuntimeInfo(ctx, true)
348 assert.NoError(t, err)
349 assert.Contains(t, md.VersionStr, "4.1.2")
350 assert.True(t, md.Version > 0)
351 assert.NoError(t, mock.ExpectationsWereMet())
352 })
353
354 t.Run("postgres version and extensions", func(t *testing.T) {
355 mock, err := pgxmock.NewPool()
356 require.NoError(t, err)
357 md := sources.NewDbConn(sources.Source{Kind: sources.SourcePostgres})
358 md.Conn = mock
359 mock.ExpectQuery("select").WillReturnRows(
360 pgxmock.NewRows([]string{"ver", "version", "pg_is_in_recovery", "current_database", "system_identifier", "is_superuser"}).
361 AddRow(13, "PostgreSQL 13.3", false, "testdb", "42424242", true),
362 )
363 mock.ExpectQuery("select").WillReturnRows(
364 pgxmock.NewRows([]string{"exec_env"}).AddRow("UNKNOWN"),
365 )
366 mock.ExpectQuery("select").WillReturnRows(
367 pgxmock.NewRows([]string{"approx_size"}).AddRow(42),
368 )
369
370 mock.ExpectQuery("select").WillReturnRows(
371 pgxmock.NewRows([]string{"extname", "extversion"}).AddRow("pg_stat_statements", "1.8"),
372 )
373 err = md.FetchRuntimeInfo(ctx, true)
374 assert.NoError(t, err)
375 assert.Equal(t, 13, md.Version)
376 assert.Equal(t, "testdb", md.RealDbname)
377 assert.Contains(t, md.Extensions, "pg_stat_statements")
378 assert.NoError(t, mock.ExpectationsWereMet())
379 })
380
381 t.Run("query error", func(t *testing.T) {
382 mock, err := pgxmock.NewPool()
383 require.NoError(t, err)
384 md := sources.NewDbConn(sources.Source{Kind: sources.SourcePgBouncer})
385 md.Conn = mock
386 mock.ExpectQuery("SHOW VERSION").
387 WithArgs(pgx.QueryExecModeSimpleProtocol).
388 WillReturnError(fmt.Errorf("db error"))
389 err = md.FetchRuntimeInfo(ctx, true)
390 assert.Error(t, err)
391 assert.NoError(t, mock.ExpectationsWereMet())
392 })
393 }
394
395 func TestSourceConn_FetchVersion(t *testing.T) {
396 ctx := context.Background()
397
398 t.Run("valid version string", func(t *testing.T) {
399 mock, err := pgxmock.NewPool()
400 require.NoError(t, err)
401 md := &sources.DbConn{Conn: mock, Source: sources.Source{Kind: sources.SourcePgBouncer}}
402 mock.ExpectQuery("SHOW VERSION").
403 WithArgs(pgx.QueryExecModeSimpleProtocol).
404 WillReturnRows(pgxmock.NewRows([]string{"version"}).AddRow("FooBar 1.12.0"))
405 assert.NoError(t, md.FetchVersion(ctx, sources.SourcePgBouncer))
406 assert.Equal(t, "FooBar 1.12.0", md.VersionStr)
407 assert.Equal(t, 1_12_00, md.Version)
408 assert.NoError(t, mock.ExpectationsWereMet())
409 })
410
411 t.Run("invalid version string", func(t *testing.T) {
412 mock, err := pgxmock.NewPool()
413 require.NoError(t, err)
414 md := &sources.DbConn{Conn: mock, Source: sources.Source{Kind: sources.SourcePgBouncer}}
415 mock.ExpectQuery("SHOW VERSION").
416 WithArgs(pgx.QueryExecModeSimpleProtocol).
417 WillReturnRows(pgxmock.NewRows([]string{"version"}).AddRow("invalid version"))
418 assert.NoError(t, md.FetchVersion(ctx, sources.SourcePgBouncer))
419 assert.Equal(t, 0, md.Version)
420 assert.NoError(t, mock.ExpectationsWereMet())
421 })
422
423 t.Run("query error", func(t *testing.T) {
424 mock, err := pgxmock.NewPool()
425 require.NoError(t, err)
426 md := &sources.DbConn{Conn: mock, Source: sources.Source{Kind: sources.SourcePgBouncer}}
427 mock.ExpectQuery("SHOW VERSION").
428 WithArgs(pgx.QueryExecModeSimpleProtocol).
429 WillReturnError(assert.AnError)
430 assert.Error(t, md.FetchVersion(ctx, sources.SourcePgBouncer))
431 assert.NoError(t, mock.ExpectationsWereMet())
432 })
433 }
434
435 func TestSourceConn_GetClusterIdentifier(t *testing.T) {
436 md := &sources.DbConn{
437 Source: sources.Source{
438 Name: "test",
439 Kind: sources.SourcePostgres,
440 ConnStr: "postgres://user:password@localhost:5432/mydatabase",
441 },
442 RuntimeInfo: sources.RuntimeInfo{
443 SystemIdentifier: "42424242",
444 },
445 }
446 assert.Equal(t, "42424242:localhost:5432", md.GetClusterIdentifier())
447
448 md = &sources.DbConn{
449 Source: sources.Source{
450 Name: "test",
451 Kind: sources.SourcePostgres,
452 ConnStr: "foo boo",
453 },
454 }
455 assert.Equal(t, "", md.GetClusterIdentifier())
456 }
457
458 func TestTryCreateMetricsFetchingHelpers(t *testing.T) {
459 ctx := context.Background()
460 mock, err := pgxmock.NewPool()
461 assert.NoError(t, err)
462 defer mock.Close()
463
464 fn := func(metric string) string {
465 if metric == "metric1" {
466 return "CREATE FUNCTION metric1"
467 }
468 return ""
469 }
470
471 md := &sources.DbConn{
472 Conn: mock,
473 Source: sources.Source{
474 Name: "testdb",
475 Metrics: metrics.MetricIntervals{"metric1": 42, "nonexistent": 0},
476 MetricsStandby: metrics.MetricIntervals{"metric1": 42},
477 },
478 }
479
480 t.Run("success", func(t *testing.T) {
481 mock.ExpectExec("CREATE FUNCTION metric1").WillReturnResult(pgxmock.NewResult("CREATE", 1))
482
483 err = md.TryCreateMetricsHelpers(ctx, fn)
484 assert.NoError(t, err)
485 assert.NoError(t, mock.ExpectationsWereMet())
486 })
487
488 t.Run("error on exec", func(t *testing.T) {
489 mock.ExpectExec("CREATE FUNCTION metric1").WillReturnError(assert.AnError)
490
491 err = md.TryCreateMetricsHelpers(ctx, fn)
492 assert.Error(t, err)
493 assert.NoError(t, mock.ExpectationsWereMet())
494 })
495
496 }
497
498 func TestTryCreateMissingExtensions(t *testing.T) {
499 ctx := context.Background()
500
501 availableExtsRows := func() *pgxmock.Rows {
502 return pgxmock.NewRows([]string{"name"}).
503 AddRow("pg_stat_statements").
504 AddRow("pg_trgm")
505 }
506
507 t.Run("extension already loaded", func(t *testing.T) {
508 mock, err := pgxmock.NewPool()
509 require.NoError(t, err)
510 defer mock.Close()
511
512 md := &sources.DbConn{
513 Conn: mock,
514 RuntimeInfo: sources.RuntimeInfo{
515 Extensions: map[string]int{"pg_stat_statements": 10800},
516 },
517 }
518
519 mock.ExpectQuery("select name").WillReturnRows(availableExtsRows())
520
521 created, err := md.TryCreateMissingExtensions(ctx, []string{"pg_stat_statements"})
522 assert.NoError(t, err)
523 assert.Empty(t, created)
524 assert.NoError(t, mock.ExpectationsWereMet())
525 })
526
527 t.Run("extension not available on instance", func(t *testing.T) {
528 mock, err := pgxmock.NewPool()
529 require.NoError(t, err)
530 defer mock.Close()
531
532 md := &sources.DbConn{
533 Conn: mock,
534 RuntimeInfo: sources.RuntimeInfo{Extensions: map[string]int{}},
535 }
536
537 mock.ExpectQuery("select name").WillReturnRows(availableExtsRows())
538
539 created, err := md.TryCreateMissingExtensions(ctx, []string{"missing_ext"})
540 assert.Error(t, err)
541 assert.Empty(t, created)
542 assert.NoError(t, mock.ExpectationsWereMet())
543 })
544
545 t.Run("extension created successfully", func(t *testing.T) {
546 mock, err := pgxmock.NewPool()
547 require.NoError(t, err)
548 defer mock.Close()
549
550 md := &sources.DbConn{
551 Conn: mock,
552 RuntimeInfo: sources.RuntimeInfo{Extensions: map[string]int{}},
553 }
554
555 mock.ExpectQuery("select name").WillReturnRows(availableExtsRows())
556 mock.ExpectExec(`create extension if not exists`).WillReturnResult(pgxmock.NewResult("CREATE EXTENSION", 1))
557
558 created, err := md.TryCreateMissingExtensions(ctx, []string{"pg_stat_statements"})
559 assert.NoError(t, err)
560 assert.Equal(t, "pg_stat_statements", created)
561 assert.NoError(t, mock.ExpectationsWereMet())
562 })
563
564 t.Run("extension creation fails", func(t *testing.T) {
565 mock, err := pgxmock.NewPool()
566 require.NoError(t, err)
567 defer mock.Close()
568
569 md := &sources.DbConn{
570 Conn: mock,
571 RuntimeInfo: sources.RuntimeInfo{Extensions: map[string]int{}},
572 }
573
574 mock.ExpectQuery("select name").WillReturnRows(availableExtsRows())
575 mock.ExpectExec(`create extension if not exists`).WillReturnError(assert.AnError)
576
577 created, err := md.TryCreateMissingExtensions(ctx, []string{"pg_stat_statements"})
578 assert.Error(t, err)
579 assert.Empty(t, created)
580 assert.NoError(t, mock.ExpectationsWereMet())
581 })
582
583 t.Run("query available extensions fails", func(t *testing.T) {
584 mock, err := pgxmock.NewPool()
585 require.NoError(t, err)
586 defer mock.Close()
587
588 md := &sources.DbConn{
589 Conn: mock,
590 RuntimeInfo: sources.RuntimeInfo{Extensions: map[string]int{}},
591 }
592
593 mock.ExpectQuery("select name").WillReturnError(assert.AnError)
594
595 created, err := md.TryCreateMissingExtensions(ctx, []string{"pg_stat_statements"})
596 assert.Error(t, err)
597 assert.Empty(t, created)
598 assert.NoError(t, mock.ExpectationsWereMet())
599 })
600
601 t.Run("mixed: one created, one already loaded, one unavailable", func(t *testing.T) {
602 mock, err := pgxmock.NewPool()
603 require.NoError(t, err)
604 defer mock.Close()
605
606 md := &sources.DbConn{
607 Conn: mock,
608 RuntimeInfo: sources.RuntimeInfo{
609 Extensions: map[string]int{"pg_trgm": 10000},
610 },
611 }
612
613 mock.ExpectQuery("select name").WillReturnRows(availableExtsRows())
614 mock.ExpectExec(`create extension if not exists`).WillReturnResult(pgxmock.NewResult("CREATE EXTENSION", 1))
615
616 created, err := md.TryCreateMissingExtensions(ctx, []string{"pg_stat_statements", "pg_trgm", "missing_ext"})
617 assert.Error(t, err)
618 assert.Equal(t, "pg_stat_statements", created)
619 assert.NoError(t, mock.ExpectationsWereMet())
620 })
621 }
622
623
624 func TestDbConn_IsPostgresSource(t *testing.T) {
625 tests := []struct {
626 kind sources.Kind
627 want bool
628 }{
629 {sources.SourcePostgres, true},
630 {sources.SourcePatroniDiscovery, true},
631 {sources.SourcePostgresDiscovery, true},
632 {sources.SourcePgBouncer, false},
633 {sources.SourcePgPool, false},
634 }
635 for _, tt := range tests {
636 md := &sources.DbConn{Source: sources.Source{Kind: tt.kind}}
637 assert.Equal(t, tt.want, md.IsPostgresSource(), "kind=%v", tt.kind)
638 }
639 }
640
641
642 func TestPromConn_IsPostgresSource(t *testing.T) {
643 pc := &sources.PromConn{}
644 assert.False(t, pc.IsPostgresSource())
645 }
646
647 func TestPromConn_Connect_TLSSkipVerify(t *testing.T) {
648 srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
649 assert.Empty(t, r.URL.Query().Get("tlsskipverify"), "tlsskipverify param should be stripped")
650 assert.Empty(t, r.URL.Query().Get("tlsrootcert"), "tlsrootcert param should be stripped")
651 w.WriteHeader(http.StatusOK)
652 }))
653 t.Cleanup(srv.Close)
654
655 ctx := testutil.TestContext
656
657 connStr := srv.URL + "/metrics?tlsskipverify=true"
658 pc := sources.NewPromConn(sources.Source{ConnStr: connStr})
659
660 err := pc.Connect(ctx, sources.CmdOpts{})
661 assert.NoError(t, err, "Connect should succeed with tlsskipverify=true")
662 assert.NotNil(t, pc.HTTPClient, "HTTPClient should be set after Connect")
663 }
664
665 func TestPromConn_Connect_BasicAuth(t *testing.T) {
666 var capturedAuth string
667 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
668 capturedAuth = r.Header.Get("Authorization")
669 w.WriteHeader(http.StatusOK)
670 }))
671 t.Cleanup(srv.Close)
672
673 u, err := url.Parse(srv.URL)
674 require.NoError(t, err)
675 u.Path = "/metrics"
676 u.User = url.UserPassword("pgwatch", "supersecret")
677
678 ctx, logOutput := testutil.NewTestLogger(t, logrus.DebugLevel)
679
680 pc := sources.NewPromConn(sources.Source{ConnStr: u.String()})
681 err = pc.Connect(ctx, sources.CmdOpts{})
682 assert.NoError(t, err)
683 assert.NotEmpty(t, capturedAuth, "Authorization header should be sent")
684 assert.True(t, strings.HasPrefix(capturedAuth, "Basic "), "should be Basic auth")
685 assert.NotContains(t, logOutput.String(), "supersecret", "password must not be logged (SEC-001)")
686 }
687
688 func TestPromConn_Connect_Unreachable(t *testing.T) {
689 ctx := t.Context()
690 pc := sources.NewPromConn(sources.Source{
691 ConnStr: "http://127.0.0.1:1/metrics",
692 })
693 assert.Error(t, pc.Connect(ctx, sources.CmdOpts{}))
694 }
695
696 func TestPromConn_Ping(t *testing.T) {
697 tests := []struct {
698 name string
699 statusCode int
700 wantErr bool
701 }{
702 {"200 OK", http.StatusOK, false},
703 {"204 No Content", http.StatusNoContent, false},
704 {"400 Bad Request", http.StatusBadRequest, true},
705 {"500 Internal Server Error", http.StatusInternalServerError, true},
706 {"301 Redirect", http.StatusMovedPermanently, true},
707 }
708
709 for _, tt := range tests {
710 t.Run(tt.name, func(t *testing.T) {
711 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
712 w.WriteHeader(tt.statusCode)
713 }))
714 t.Cleanup(srv.Close)
715
716 pc := sources.NewPromConn(sources.Source{ConnStr: srv.URL + "/metrics"})
717 pc.HTTPClient = srv.Client()
718 require.NoError(t, pc.ParseConfig())
719 err := pc.Ping(t.Context())
720 if tt.wantErr {
721 assert.Error(t, err)
722 } else {
723 assert.NoError(t, err)
724 }
725 })
726 }
727 }
728
729 func TestPromConn_FetchRuntimeInfo(t *testing.T) {
730 pc := sources.NewPromConn(sources.Source{})
731 err := pc.FetchRuntimeInfo(t.Context(), false)
732 assert.NoError(t, err)
733 }
734
735 func TestRedactURL(t *testing.T) {
736 tests := []struct {
737 name string
738 input string
739 want string
740 }{
741 {
742 name: "password is redacted",
743 input: "http://user:secret@localhost:9187/metrics",
744 want: "http://user:xxxxx@localhost:9187/metrics",
745 },
746 {
747 name: "no userinfo unchanged",
748 input: "http://localhost:9187/metrics",
749 want: "http://localhost:9187/metrics",
750 },
751 {
752 name: "username only (no password) unchanged",
753 input: "http://user@localhost:9187/metrics",
754 want: "http://user@localhost:9187/metrics",
755 },
756 {
757 name: "query params preserved",
758 input: "http://user:pass@localhost:9187/metrics?tlsskipverify=true",
759 want: "http://user:xxxxx@localhost:9187/metrics?tlsskipverify=true",
760 },
761 }
762
763 for _, tt := range tests {
764 t.Run(tt.name, func(t *testing.T) {
765 got := sources.RedactURL(tt.input)
766 assert.Equal(t, tt.want, got)
767 })
768 }
769 }
770
771
772
773 func TestRace_GetClusterIdentifier(t *testing.T) {
774 mock, err := pgxmock.NewPool()
775 require.NoError(t, err)
776 defer mock.Close()
777
778 md := sources.NewDbConn(sources.Source{
779 Kind: sources.SourcePgBouncer,
780 ConnStr: "postgres://user:pass@localhost:5432/db",
781 })
782 md.Conn = mock
783
784 const iterations = 50
785
786 for range iterations {
787 mock.ExpectQuery("SHOW VERSION").
788 WithArgs(pgx.QueryExecModeSimpleProtocol).
789 WillReturnRows(pgxmock.NewRows([]string{"version"}).AddRow("PgBouncer 1.12.0"))
790 }
791
792 var wg sync.WaitGroup
793 wg.Add(2)
794
795 go func() {
796 defer wg.Done()
797 for range iterations {
798 _ = md.FetchRuntimeInfo(context.Background(), true)
799 }
800 }()
801
802 go func() {
803 defer wg.Done()
804 for range iterations {
805 _ = md.GetClusterIdentifier()
806 }
807 }()
808
809 wg.Wait()
810 }
811
812
813
814 func TestRace_TryCreateMissingExtensions(t *testing.T) {
815 mock, err := pgxmock.NewPool()
816 require.NoError(t, err)
817 defer mock.Close()
818
819 md := sources.NewDbConn(sources.Source{Kind: sources.SourcePostgres})
820 md.Conn = mock
821
822 const iterations = 50
823
824
825 for range iterations {
826 mock.ExpectQuery("select name").
827 WillReturnRows(pgxmock.NewRows([]string{"name"}).AddRow("pg_stat_statements"))
828 }
829
830 var wg sync.WaitGroup
831 wg.Add(2)
832
833
834 go func() {
835 defer wg.Done()
836 for range iterations {
837 md.Lock()
838 md.Extensions = map[string]int{"pg_stat_statements": 10800}
839 md.Unlock()
840 }
841 }()
842
843
844 go func() {
845 defer wg.Done()
846 for range iterations {
847 _, _ = md.TryCreateMissingExtensions(context.Background(), []string{"pg_stat_statements"})
848 }
849 }()
850
851 wg.Wait()
852 }
853
854
855
856 func TestRace_TryCreateMetricsHelpers(t *testing.T) {
857 mock, err := pgxmock.NewPool()
858 require.NoError(t, err)
859 defer mock.Close()
860
861 md := sources.NewDbConn(sources.Source{
862 Kind: sources.SourcePostgres,
863 Metrics: metrics.MetricIntervals{"metric1": 30},
864 })
865 md.Conn = mock
866
867 const iterations = 50
868 for range iterations {
869 mock.ExpectExec("CREATE FUNCTION metric1").
870 WillReturnResult(pgxmock.NewResult("CREATE", 1))
871 }
872
873 getSQLFn := func(metric string) string {
874 if metric == "metric1" {
875 return "CREATE FUNCTION metric1"
876 }
877 return ""
878 }
879
880 var wg sync.WaitGroup
881 wg.Add(2)
882
883
884 go func() {
885 defer wg.Done()
886 for range iterations {
887 md.SetMetricIntervals(metrics.MetricIntervals{"metric1": 30}, nil)
888 }
889 }()
890
891
892 go func() {
893 defer wg.Done()
894 for range iterations {
895 _ = md.TryCreateMetricsHelpers(context.Background(), getSQLFn)
896 }
897 }()
898
899 wg.Wait()
900 }
901
902
903
904
905 func TestRace_FetchRuntimeInfoAtomicCache(t *testing.T) {
906 mock, err := pgxmock.NewPool()
907 require.NoError(t, err)
908 defer mock.Close()
909
910 md := sources.NewDbConn(sources.Source{Kind: sources.SourcePgBouncer})
911 md.Conn = mock
912
913 const goroutines = 4
914 const iterations = 50
915
916
917 for range goroutines * iterations {
918 mock.ExpectQuery("SHOW VERSION").
919 WithArgs(pgx.QueryExecModeSimpleProtocol).
920 WillReturnRows(pgxmock.NewRows([]string{"version"}).AddRow("PgBouncer 1.12.0"))
921 }
922
923 var wg sync.WaitGroup
924 wg.Add(goroutines)
925
926 for range goroutines {
927 go func() {
928 defer wg.Done()
929 for range iterations {
930
931 _ = md.FetchRuntimeInfo(context.Background(), false)
932 }
933 }()
934 }
935
936 wg.Wait()
937 }
938
939
940
941
942
943
944
945
946 func TestSourceConn_Ping_BoundedAgainstHalfOpenTCP(t *testing.T) {
947 addr, _ := testutil.BlackholeListener(t)
948
949
950
951
952 origMargin := db.PingTimeoutMargin
953 t.Cleanup(func() { db.PingTimeoutMargin = origMargin })
954 db.PingTimeoutMargin = 100 * time.Millisecond
955
956 connStr := "postgres://x@" + addr + "/postgres"
957 cfg, err := pgxpool.ParseConfig(connStr)
958 require.NoError(t, err)
959 require.Zero(t, cfg.ConnConfig.ConnectTimeout, "precondition: no connect_timeout in conn string")
960
961
962
963 pool, err := pgxpool.NewWithConfig(context.Background(), cfg)
964 require.NoError(t, err)
965 t.Cleanup(pool.Close)
966
967 md := &sources.DbConn{
968 Source: sources.Source{
969 Name: "blackhole_ping_src",
970 Kind: sources.SourcePostgres,
971 ConnStr: connStr,
972 },
973 Conn: pool,
974 ConnConfig: cfg,
975 }
976
977 ctx, cancel := context.WithCancel(ctx)
978 defer cancel()
979
980
981 time.AfterFunc(5*time.Second, cancel)
982
983 start := time.Now()
984 err = md.Ping(ctx)
985 elapsed := time.Since(start)
986
987 require.Error(t, err)
988
989
990
991 if elapsed > 1500*time.Millisecond {
992 t.Fatalf("Ping took %v, want ~PingTimeoutMargin (100ms)", elapsed)
993 }
994 }
995
996
997
998
999
1000
1001
1002
1003
1004
1005 func TestSourceConn_Ping_BoundedBehindWedgedPool(t *testing.T) {
1006 synctest.Test(t, func(t *testing.T) {
1007
1008
1009
1010 db.PingTimeoutMargin = 100 * time.Millisecond
1011 t.Cleanup(func() { db.PingTimeoutMargin = 5 * time.Second })
1012
1013 md := &sources.DbConn{
1014 Source: sources.Source{
1015 Name: "wedged_pool_ping_src",
1016 Kind: sources.SourcePostgres,
1017 },
1018 Conn: testutil.BlockingPool{},
1019 }
1020
1021
1022
1023 md.ConnConfig = &pgxpool.Config{}
1024
1025
1026
1027
1028
1029
1030
1031 callCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
1032 defer cancel()
1033
1034 done := make(chan error, 1)
1035 start := time.Now()
1036 go func() { done <- md.Ping(callCtx) }()
1037
1038
1039
1040
1041 time.Sleep(500 * time.Millisecond)
1042 synctest.Wait()
1043
1044 err := <-done
1045 elapsed := time.Since(start)
1046
1047 require.Error(t, err)
1048 if elapsed > 1500*time.Millisecond {
1049 t.Fatalf("Ping took %v, want ~PingTimeoutMargin (100ms)", elapsed)
1050 }
1051 })
1052 }
1053 func TestSourceConn_FetchRuntimeInfo_BoundedByRuntimeInfoTimeout(t *testing.T) {
1054 synctest.Test(t, func(t *testing.T) {
1055 db.RuntimeInfoTimeout = 100 * time.Millisecond
1056 t.Cleanup(func() { db.RuntimeInfoTimeout = 30 * time.Second })
1057
1058 md := sources.NewDbConn(sources.Source{
1059 Name: "bounded_runtime_info_src",
1060 Kind: sources.SourcePostgres,
1061 })
1062 md.Conn = testutil.BlockingPool{}
1063
1064
1065
1066
1067
1068
1069
1070 callCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
1071 defer cancel()
1072
1073 done := make(chan error, 1)
1074 start := time.Now()
1075 go func() { done <- md.FetchRuntimeInfo(callCtx, true) }()
1076
1077 time.Sleep(500 * time.Millisecond)
1078 synctest.Wait()
1079
1080 err := <-done
1081 elapsed := time.Since(start)
1082
1083 require.Error(t, err)
1084 if elapsed > 1500*time.Millisecond {
1085 t.Fatalf("FetchRuntimeInfo took %v, want ~RuntimeInfoTimeout (100ms)", elapsed)
1086 }
1087 })
1088 }
1089