From b1aff4666b9bd210d77a09d82544f947eda22818 Mon Sep 17 00:00:00 2001 From: Michael Kinney <14325925+aliasfoxkde@users.noreply.github.com> Date: Mon, 2 Nov 2026 02:27:00 -0600 Subject: [PATCH] fix(api): treat blank pipeline timestamps as null --- Cargo.lock | 1 + crates/gitforge-api/Cargo.toml | 1 + crates/gitforge-api/tests/ci_routes.rs | 43 ++++++++++++++++++++++++++ crates/gitforge-db/src/queries.rs | 1 + 4 files changed, 46 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index 6f331483..e65cd3b1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1386,6 +1386,7 @@ dependencies = [ "serde", "serde_json", "sha2 0.11.1", + "sqlx", "ssh-key", "tempfile", "thiserror 2.0.21", diff --git a/crates/gitforge-api/Cargo.toml b/crates/gitforge-api/Cargo.toml index 1c86bd38..4959ad0e 100644 --- a/crates/gitforge-api/Cargo.toml +++ b/crates/gitforge-api/Cargo.toml @@ -43,6 +43,7 @@ prometheus = "0.14" hyper = "1.5" tempfile = "3" gitforge-review = { path = "../gitforge-review" } +sqlx = { workspace = true } [lints] workspace = true diff --git a/crates/gitforge-api/tests/ci_routes.rs b/crates/gitforge-api/tests/ci_routes.rs index 1ed3ac4c..376d953d 100644 --- a/crates/gitforge-api/tests/ci_routes.rs +++ b/crates/gitforge-api/tests/ci_routes.rs @@ -292,6 +292,49 @@ async fn pipeline_run_endpoints_scoped_and_expose_jobs_with_receipts() { assert_eq!(missing["error"], "not_found"); } +#[tokio::test] +async fn pipeline_run_list_treats_blank_optional_finish_time_as_null() { + let f = seed().await; + sqlx::query("UPDATE pipeline_runs SET finished_at = '' WHERE id = ?") + .bind(f.run_id.to_string()) + .execute(f.pool.pool()) + .await + .unwrap(); + + let (status, runs) = request_json( + f.app, + "GET", + "/api/pipeline-runs", + Some(&f.owner_token), + None, + ) + .await; + assert_eq!(status, StatusCode::OK, "response: {runs}"); + assert_eq!(runs.as_array().unwrap().len(), 1); + assert_eq!(runs[0]["finished_at"], Value::Null); +} + +#[tokio::test] +async fn pipeline_run_list_still_rejects_malformed_nonblank_finish_time() { + let f = seed().await; + sqlx::query("UPDATE pipeline_runs SET finished_at = 'not-a-timestamp' WHERE id = ?") + .bind(f.run_id.to_string()) + .execute(f.pool.pool()) + .await + .unwrap(); + + let (status, body) = request_json( + f.app, + "GET", + "/api/pipeline-runs", + Some(&f.owner_token), + None, + ) + .await; + assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR); + assert_eq!(body["error"], "database_error"); +} + #[tokio::test] async fn submit_job_rejects_invalid_payloads() { let f = seed().await; diff --git a/crates/gitforge-db/src/queries.rs b/crates/gitforge-db/src/queries.rs index 6404bd52..1e6cda89 100644 --- a/crates/gitforge-db/src/queries.rs +++ b/crates/gitforge-db/src/queries.rs @@ -118,6 +118,7 @@ fn parse_optional_timestamp_column( .try_get(column) .map_err(|error| Error::database(format!("invalid {column} column: {error}")))?; value + .and_then(|value| (!value.trim().is_empty()).then_some(value)) .map(|value| { DateTime::parse_from_rfc3339(&value) .map(|date| date.with_timezone(&Utc))