From fad51d6801b0f124377b1c05d742ea1821707c9b Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Tue, 26 May 2026 01:32:35 +0200 Subject: [PATCH 1/2] fix(config): escape LIKE wildcards in registration search filter Closes #719 --- .../config/store_postgres_pgxmock_test.go | 86 +++++++++++++++++++ .../config/store_postgres_registrations.go | 6 +- 2 files changed, 90 insertions(+), 2 deletions(-) diff --git a/internal/config/store_postgres_pgxmock_test.go b/internal/config/store_postgres_pgxmock_test.go index 263935624..0f2fa68c5 100644 --- a/internal/config/store_postgres_pgxmock_test.go +++ b/internal/config/store_postgres_pgxmock_test.go @@ -1508,6 +1508,92 @@ func TestPGXMock_ListStuckExecutions_QueryError(t *testing.T) { require.Error(t, err) } +// ─── ListAccountRegistrations — LIKE wildcard escaping ─────────────────────── + +// registrationCols returns the column slice that mirrors registrationColumns(). +func registrationCols() []string { + return []string{ + "id", "reference_token", "status", + "provider", "external_id", "account_name", "contact_email", "description", + "source_provider", + "aws_role_arn", "aws_auth_mode", "aws_external_id", + "azure_subscription_id", "azure_tenant_id", "azure_client_id", "azure_auth_mode", + "gcp_project_id", "gcp_client_email", "gcp_auth_mode", "gcp_wif_audience", + "reg_credential_type", "reg_credential_payload", + "rejection_reason", "cloud_account_id", "reviewed_by", "reviewed_at", + "created_at", "updated_at", + } +} + +// minimalRegRow returns a single pgxmock row with only the required non-null +// columns filled in and the rest as nil (matching the sql.NullString scan path). +func minimalRegRow(id, accountName, contactEmail string) []interface{} { + now := time.Now() + return []interface{}{ + id, "tok-" + id, "pending", + "aws", "ext-" + id, accountName, contactEmail, nil, + nil, + nil, nil, nil, + nil, nil, nil, nil, + nil, nil, nil, nil, + nil, nil, + nil, nil, nil, sql.NullTime{}, + now, now, + } +} + +// TestPGXMock_ListAccountRegistrations_SearchEscapesPercent verifies that a +// filter.Search value beginning with "%" is forwarded to the DB as "\%..." +// (escaped) so it matches the literal substring, not every row. +func TestPGXMock_ListAccountRegistrations_SearchEscapesPercent(t *testing.T) { + mock := newMock(t) + store := storeWith(mock) + ctx := context.Background() + + // The raw input contains a leading %; after escaping it must become \%foo. + rawSearch := "%foo" + escapedArg := `\%foo` + + rows := pgxmock.NewRows(registrationCols()). + AddRow(minimalRegRow("reg-1", "%foo Corp", "%foo@example.com")...) + // The query must contain ESCAPE to prove the clause was updated. + mock.ExpectQuery(`ESCAPE`). + WithArgs("%" + escapedArg + "%"). + WillReturnRows(rows) + + filter := AccountRegistrationFilter{Search: rawSearch} + regs, err := store.ListAccountRegistrations(ctx, filter) + require.NoError(t, err) + assert.Len(t, regs, 1, "expected exactly the literal-match row") + assert.Equal(t, "reg-1", regs[0].ID) + assert.NoError(t, mock.ExpectationsWereMet()) +} + +// TestPGXMock_ListAccountRegistrations_SearchEscapesUnderscore verifies that +// "_" in filter.Search is sent as "\_" so it is not treated as the SQL +// single-character wildcard. +func TestPGXMock_ListAccountRegistrations_SearchEscapesUnderscore(t *testing.T) { + mock := newMock(t) + store := storeWith(mock) + ctx := context.Background() + + rawSearch := "foo_bar" + escapedArg := `foo\_bar` + + rows := pgxmock.NewRows(registrationCols()). + AddRow(minimalRegRow("reg-2", "foo_bar Inc", "foo_bar@example.com")...) + mock.ExpectQuery(`ESCAPE`). + WithArgs("%" + escapedArg + "%"). + WillReturnRows(rows) + + filter := AccountRegistrationFilter{Search: rawSearch} + regs, err := store.ListAccountRegistrations(ctx, filter) + require.NoError(t, err) + assert.Len(t, regs, 1, "expected exactly the literal-match row") + assert.Equal(t, "reg-2", regs[0].ID) + assert.NoError(t, mock.ExpectationsWereMet()) +} + // ─── errNoRows helper ──────────────────────────────────────────────────────── func errNoRows() error { diff --git a/internal/config/store_postgres_registrations.go b/internal/config/store_postgres_registrations.go index 120e5b42c..b1ca508ed 100644 --- a/internal/config/store_postgres_registrations.go +++ b/internal/config/store_postgres_registrations.go @@ -99,11 +99,13 @@ func (s *PostgresStore) ListAccountRegistrations(ctx context.Context, filter Acc idx++ } if filter.Search != "" { + // Escape ILIKE wildcards so user-supplied % and _ are treated as literals. + escaped := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(filter.Search) conditions = append(conditions, fmt.Sprintf( - "(account_name ILIKE $%d OR external_id ILIKE $%d OR contact_email ILIKE $%d)", + "(account_name ILIKE $%d ESCAPE '\\' OR external_id ILIKE $%d ESCAPE '\\' OR contact_email ILIKE $%d ESCAPE '\\')", idx, idx, idx, )) - args = append(args, "%"+filter.Search+"%") + args = append(args, "%"+escaped+"%") idx++ } From bb34bf99b637f4fe2a9ba38c4393d7e21c25c61a Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Tue, 26 May 2026 02:03:30 +0200 Subject: [PATCH 2/2] test(config): add backslash-escape regression coverage for registration search --- .../config/store_postgres_pgxmock_test.go | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/internal/config/store_postgres_pgxmock_test.go b/internal/config/store_postgres_pgxmock_test.go index 0f2fa68c5..440be8884 100644 --- a/internal/config/store_postgres_pgxmock_test.go +++ b/internal/config/store_postgres_pgxmock_test.go @@ -1594,6 +1594,33 @@ func TestPGXMock_ListAccountRegistrations_SearchEscapesUnderscore(t *testing.T) assert.NoError(t, mock.ExpectationsWereMet()) } +// TestPGXMock_ListAccountRegistrations_SearchEscapesBackslash verifies that a +// literal "\" in filter.Search is doubled to "\\" before being bound, so the +// backslash is treated as data rather than as the LIKE escape character. +func TestPGXMock_ListAccountRegistrations_SearchEscapesBackslash(t *testing.T) { + mock := newMock(t) + store := storeWith(mock) + ctx := context.Background() + + // Raw input contains a literal backslash; after escaping it must be doubled. + rawSearch := `foo\bar` + escapedArg := `foo\\bar` + + rows := pgxmock.NewRows(registrationCols()). + AddRow(minimalRegRow("reg-3", `foo\bar Ltd`, `foo\bar@example.com`)...) + // The query must contain ESCAPE to prove the clause was updated. + mock.ExpectQuery(`ESCAPE`). + WithArgs("%" + escapedArg + "%"). + WillReturnRows(rows) + + filter := AccountRegistrationFilter{Search: rawSearch} + regs, err := store.ListAccountRegistrations(ctx, filter) + require.NoError(t, err) + assert.Len(t, regs, 1, "expected exactly the literal-match row") + assert.Equal(t, "reg-3", regs[0].ID) + assert.NoError(t, mock.ExpectationsWereMet()) +} + // ─── errNoRows helper ──────────────────────────────────────────────────────── func errNoRows() error {