Skip to content

Conversation

@PierreLeGuen
Copy link
Contributor

Summary

  • Add sort_by query param: name, created_at, spend_limit, current_usage
  • Add sort_order query param: asc, desc
  • Add search query param (ILIKE on name and org ID)
  • Dynamic SQL with NULLS LAST for nullable columns
  • Return total count in same query for efficiency

Test plan

  • All cargo nextest tests pass (493 passed)
  • Test sorting by each column via API
  • Test search functionality
  • Test pagination with sort/search params

…oint

- Add sort_by (name, created_at, spend_limit, current_usage) query param
- Add sort_order (asc, desc) query param
- Add search query param (ILIKE on name and id)
- Dynamic SQL with NULLS LAST for nullable columns
- Return total count in same query for efficiency
@gemini-code-assist
Copy link
Contributor

Summary of Changes

Hello @PierreLeGuen, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request significantly enhances the administrative interface for managing organizations by integrating robust server-side sorting and searching capabilities. It allows administrators to efficiently filter and order organization data based on various criteria such as name, creation date, spend limits, and current usage, thereby improving data discoverability and management. The changes span across the API, service, and database layers, ensuring a cohesive and performant implementation, complemented by extensive end-to-end tests.

Highlights

  • Server-Side Sorting: Introduced server-side sorting for admin organization listings, supporting name, created_at, spend_limit, and current_usage fields with both ascending and descending orders.
  • Server-Side Search: Added server-side search functionality, allowing filtering organizations by name or ID using case-insensitive ILIKE matching.
  • Database Query Optimization: Optimized database queries by combining the organization listing and total count into a single, dynamic SQL query, improving efficiency.
  • NULLS LAST Handling: Ensured proper handling of NULLS LAST for nullable columns when sorting to maintain consistent ordering.
  • Comprehensive Testing: Added extensive end-to-end tests covering all new sorting and search functionalities, including pagination, case-insensitivity, and error handling.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

@claude
Copy link

claude bot commented Jan 15, 2026

Security Review - Critical Issue Found

⚠️ CRITICAL: SQL Injection Vulnerability via LIKE Pattern

1. LIKE Injection Vulnerability (SECURITY - BLOCKING)

Location: crates/database/src/repositories/admin_composite.rs:391

The search pattern construction allows LIKE injection attacks:

let pattern = format!("%{}%", query);  // ❌ Vulnerable

Attack scenario: A malicious search query like "%" results in pattern "%%%" which matches ALL organizations, potentially exposing unauthorized data.

Fix required:

// Escape LIKE special characters: %, _, let escaped_query = query
    .replace('\\', "\\\\")
    .replace('%', "\\%")
    .replace('_', "\\_");
let pattern = format!("%{}%", escaped_query);

// Update SQL to include ESCAPE clause:
"WHERE o.is_active = true AND (o.name ILIKE $3 ESCAPE '\\' OR o.id::text ILIKE $3 ESCAPE '\\')",

2. Missing Input Validation (SECURITY - HIGH)

Location: crates/api/src/routes/admin.rs:1177

No length limit on search parameter could cause DoS via memory exhaustion or database performance degradation.

Fix required:

if let Some(ref search) = params.search {
    if search.len() > 255 {
        return Err(ErrorResponse::validation_error("Search query too long (max 255 characters)"));
    }
}

3. Minor: Inefficient unwrap_or_default() Calls (PERFORMANCE - LOW)

Location: Lines 369, 376, 382

Call order_by.unwrap_or_default() once and reuse the result.


✅ Positive Notes

  • Good use of parameterized queries for search pattern
  • Comprehensive test coverage (606 lines)
  • Proper enum-based column selection prevents direct SQL injection
  • NULLS LAST handling is correct

🚫 Required Before Merge

  1. Fix LIKE injection vulnerability with proper escaping
  2. Add search query length validation

Status: ⚠️ CHANGES REQUIRED - blocking security issue must be addressed

Copy link
Contributor

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces server-side sorting and searching for the admin organizations endpoint, which is a great feature enhancement. The changes are well-structured across the API, service, and repository layers. The addition of a comprehensive E2E test suite is particularly commendable.

My review focuses on improving the robustness of the new tests and addressing a minor performance regression in the database query execution. Specifically, I've suggested:

  • Making the sorting tests more reliable by isolating test data to prevent flakiness.
  • Enhancing a test for sorting by usage to ensure it properly validates the functionality.
  • Reintroducing concurrent query execution for fetching data and total count to optimize performance.
  • A minor refactoring for better code clarity in the repository layer.

All original comments were retained as they are valid and do not contradict or fall under the specific modification criteria of the provided rules. Overall, this is a solid contribution. Addressing these points will further improve the quality and reliability of the new functionality.

Comment on lines +230 to +247
async fn test_admin_list_organizations_sort_by_current_usage() {
let (server, _guard) = setup_test_server().await;

let _org1 = setup_org_with_credits(&server, 1000000).await;
let _org2 = setup_org_with_credits(&server, 2000000).await;

let response = server
.get("/v1/admin/organizations?sort_by=current_usage&sort_order=asc")
.add_header("Authorization", format!("Bearer {}", get_session_id()))
.add_header("User-Agent", MOCK_USER_AGENT)
.await;

assert_eq!(response.status_code(), 200);
let list_response: ListOrganizationsAdminResponse =
serde_json::from_str(&response.text()).expect("Failed to parse response");

assert!(list_response.organizations.len() >= 2);
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

This test is not currently verifying the sorting logic for current_usage. It only asserts that at least two organizations are returned, which doesn't confirm that they are sorted correctly. To make this test effective, it should:

  1. Create organizations and then generate some API usage for them to ensure current_usage is non-zero and varies between them.
  2. Fetch the list sorted by current_usage.
  3. Assert that the returned organizations are correctly sorted based on their usage.

As it stands, this test provides a false sense of security about the sorting functionality.

Comment on lines +69 to +109
async fn test_admin_list_organizations_sort_by_name_asc() {
let (server, _guard) = setup_test_server().await;

let org_names = vec!["zebra-org", "alpha-org", "delta-org"];
for name in &org_names {
let request = api::models::CreateOrganizationRequest {
name: format!("test-{}-{}", name, uuid::Uuid::new_v4()),
description: Some(format!("Test organization {}", name)),
};
let response = server
.post("/v1/organizations")
.add_header("Authorization", format!("Bearer {}", get_session_id()))
.add_header("User-Agent", MOCK_USER_AGENT)
.json(&serde_json::json!(request))
.await;
assert_eq!(response.status_code(), 200);
}

let response = server
.get("/v1/admin/organizations?sort_by=name&sort_order=asc")
.add_header("Authorization", format!("Bearer {}", get_session_id()))
.add_header("User-Agent", MOCK_USER_AGENT)
.await;

assert_eq!(response.status_code(), 200);
let list_response: ListOrganizationsAdminResponse =
serde_json::from_str(&response.text()).expect("Failed to parse response");

let names: Vec<String> = list_response
.organizations
.iter()
.map(|o| o.name.clone())
.collect();

let mut sorted_names = names.clone();
sorted_names.sort();
assert_eq!(
names, sorted_names,
"Organizations should be sorted by name ascending"
);
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This test may be flaky because it asserts the sorting order on the entire list of organizations returned by the API. If other organizations exist in the test database (e.g., from other tests running in parallel or from setup), this assertion can fail. To make the test more robust, you should isolate the test data. A good approach is to create organizations with a unique name prefix and then use the search query parameter to fetch only those specific organizations before asserting the sort order. The test_admin_list_organizations_search_and_sort test already uses this reliable pattern.

Comment on lines +112 to +151
async fn test_admin_list_organizations_sort_by_name_desc() {
let (server, _guard) = setup_test_server().await;

for i in 0..3 {
let request = api::models::CreateOrganizationRequest {
name: format!("sort-test-{}-{}", i, uuid::Uuid::new_v4()),
description: Some(format!("Test organization {}", i)),
};
let response = server
.post("/v1/organizations")
.add_header("Authorization", format!("Bearer {}", get_session_id()))
.add_header("User-Agent", MOCK_USER_AGENT)
.json(&serde_json::json!(request))
.await;
assert_eq!(response.status_code(), 200);
}

let response = server
.get("/v1/admin/organizations?sort_by=name&sort_order=desc")
.add_header("Authorization", format!("Bearer {}", get_session_id()))
.add_header("User-Agent", MOCK_USER_AGENT)
.await;

assert_eq!(response.status_code(), 200);
let list_response: ListOrganizationsAdminResponse =
serde_json::from_str(&response.text()).expect("Failed to parse response");

let names: Vec<String> = list_response
.organizations
.iter()
.map(|o| o.name.clone())
.collect();

let mut sorted_names = names.clone();
sorted_names.sort_by(|a, b| b.cmp(a));
assert_eq!(
names, sorted_names,
"Organizations should be sorted by name descending"
);
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Similar to the ascending sort test, this test can be flaky. It assumes the returned list contains only the organizations created within this test, which may not be true in a shared test environment. To ensure reliability, please isolate the test data by using a unique prefix for organization names and then filtering the results using the search parameter before checking the descending sort order.

Comment on lines +154 to +186
async fn test_admin_list_organizations_sort_by_created_at() {
let (server, _guard) = setup_test_server().await;

let mut org_ids = Vec::new();
for _i in 0..3 {
let org = create_org(&server).await;
org_ids.push(org.id.clone());
tokio::time::sleep(tokio::time::Duration::from_millis(10)).await;
}

let response = server
.get("/v1/admin/organizations?sort_by=created_at&sort_order=desc")
.add_header("Authorization", format!("Bearer {}", get_session_id()))
.add_header("User-Agent", MOCK_USER_AGENT)
.await;

assert_eq!(response.status_code(), 200);
let list_response: ListOrganizationsAdminResponse =
serde_json::from_str(&response.text()).expect("Failed to parse response");

let timestamps: Vec<chrono::DateTime<chrono::Utc>> = list_response
.organizations
.iter()
.map(|o| o.created_at)
.collect();

for i in 0..timestamps.len().saturating_sub(1) {
assert!(
timestamps[i] >= timestamps[i + 1],
"Timestamps should be in descending order"
);
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This test is susceptible to flakiness because it fetches and checks the sort order of all organizations, which might include data from other tests. Although you are collecting org_ids, you are not using them to filter the results. To make this test reliable, you should filter the returned organizations to include only the ones created in this test before asserting the created_at sort order. A better approach would be to use a unique prefix and the search parameter to isolate test data from the API side.

Comment on lines +577 to +606
async fn test_admin_list_organizations_default_sort() {
let (server, _guard) = setup_test_server().await;

create_org(&server).await;
tokio::time::sleep(tokio::time::Duration::from_millis(10)).await;
create_org(&server).await;

let response = server
.get("/v1/admin/organizations")
.add_header("Authorization", format!("Bearer {}", get_session_id()))
.add_header("User-Agent", MOCK_USER_AGENT)
.await;

assert_eq!(response.status_code(), 200);
let list_response: ListOrganizationsAdminResponse =
serde_json::from_str(&response.text()).expect("Failed to parse response");

let timestamps: Vec<chrono::DateTime<chrono::Utc>> = list_response
.organizations
.iter()
.map(|o| o.created_at)
.collect();

for i in 0..timestamps.len().saturating_sub(1) {
assert!(
timestamps[i] >= timestamps[i + 1],
"Default sort should be by created_at descending"
);
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This test for default sorting can be flaky as it relies on the entire list of organizations returned by the API. If other organizations are present in the database, the assertion for created_at descending order might fail. To make this test robust, please isolate the data by creating organizations with a unique prefix and using the search parameter to fetch only those organizations for the assertion.

Comment on lines +369 to +387
let order_column = match order_by.unwrap_or_default() {
AdminOrganizationOrderBy::CreatedAt => "o.created_at",
AdminOrganizationOrderBy::Name => "o.name",
AdminOrganizationOrderBy::SpendLimit => "olh.spend_limit",
AdminOrganizationOrderBy::CurrentUsage => "ob.total_spent",
};

let order_dir = match order_direction.unwrap_or_default() {
AdminOrganizationOrderDirection::Asc => "ASC",
AdminOrganizationOrderDirection::Desc => "DESC",
};

// NULLS LAST for columns that can be null
let nulls_clause = match order_by.unwrap_or_default() {
AdminOrganizationOrderBy::SpendLimit | AdminOrganizationOrderBy::CurrentUsage => {
" NULLS LAST"
}
_ => "",
};
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

To improve readability and avoid redundant calls, you can call order_by.unwrap_or_default() once and store its result in a variable. This makes the code slightly cleaner and more efficient.

Suggested change
let order_column = match order_by.unwrap_or_default() {
AdminOrganizationOrderBy::CreatedAt => "o.created_at",
AdminOrganizationOrderBy::Name => "o.name",
AdminOrganizationOrderBy::SpendLimit => "olh.spend_limit",
AdminOrganizationOrderBy::CurrentUsage => "ob.total_spent",
};
let order_dir = match order_direction.unwrap_or_default() {
AdminOrganizationOrderDirection::Asc => "ASC",
AdminOrganizationOrderDirection::Desc => "DESC",
};
// NULLS LAST for columns that can be null
let nulls_clause = match order_by.unwrap_or_default() {
AdminOrganizationOrderBy::SpendLimit | AdminOrganizationOrderBy::CurrentUsage => {
" NULLS LAST"
}
_ => "",
};
let final_order_by = order_by.unwrap_or_default();
let order_column = match final_order_by {
AdminOrganizationOrderBy::CreatedAt => "o.created_at",
AdminOrganizationOrderBy::Name => "o.name",
AdminOrganizationOrderBy::SpendLimit => "olh.spend_limit",
AdminOrganizationOrderBy::CurrentUsage => "ob.total_spent",
};
let order_dir = match order_direction.unwrap_or_default() {
AdminOrganizationOrderDirection::Asc => "ASC",
AdminOrganizationOrderDirection::Desc => "DESC",
};
// NULLS LAST for columns that can be null
let nulls_clause = match final_order_by {
AdminOrganizationOrderBy::SpendLimit | AdminOrganizationOrderBy::CurrentUsage => {
" NULLS LAST"
}
_ => "",
};

Comment on lines +441 to +449
let (rows, count_row) = if let Some(ref pattern) = search_pattern {
let rows = client.query(&query, &[&limit, &offset, pattern]).await?;
let count_row = client.query_one(&count_query, &[pattern]).await?;
(rows, count_row)
} else {
let rows = client.query(&query, &[&limit, &offset]).await?;
let count_row = client.query_one(&count_query, &[]).await?;
(rows, count_row)
};
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The main query and the count query are executed sequentially. This is a performance regression, as the previous implementation executed them concurrently. You can use tokio::join! to run these independent database queries in parallel, which will improve the endpoint's response time.

Suggested change
let (rows, count_row) = if let Some(ref pattern) = search_pattern {
let rows = client.query(&query, &[&limit, &offset, pattern]).await?;
let count_row = client.query_one(&count_query, &[pattern]).await?;
(rows, count_row)
} else {
let rows = client.query(&query, &[&limit, &offset]).await?;
let count_row = client.query_one(&count_query, &[]).await?;
(rows, count_row)
};
let (rows, count_row) = if let Some(ref pattern) = search_pattern {
let rows_fut = client.query(&query, &[&limit, &offset, pattern]);
let count_fut = client.query_one(&count_query, &[pattern]);
let (rows_result, count_row_result) = tokio::join!(rows_fut, count_fut);
(rows_result?, count_row_result?)
} else {
let rows_fut = client.query(&query, &[&limit, &offset]);
let count_fut = client.query_one(&count_query, &[]);
let (rows_result, count_row_result) = tokio::join!(rows_fut, count_fut);
(rows_result?, count_row_result?)
};

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants