diff --git a/crates/gitlawb-node/src/api/certs.rs b/crates/gitlawb-node/src/api/certs.rs index c818bce..023e49c 100644 --- a/crates/gitlawb-node/src/api/certs.rs +++ b/crates/gitlawb-node/src/api/certs.rs @@ -1,23 +1,33 @@ //! API handlers for ref certificates. -use axum::extract::{Extension, Path, State}; +use std::collections::HashMap; + +use axum::extract::{Extension, Path, Query, State}; use axum::Json; use crate::auth::AuthenticatedDid; use crate::error::{AppError, Result}; use crate::state::AppState; -/// GET /api/v1/repos/{owner}/{repo}/certs +/// GET /api/v1/repos/{owner}/{repo}/certs?limit=50 pub async fn list_certs( State(state): State, Path((owner, name)): Path<(String, String)>, + Query(params): Query>, auth: Option>, ) -> Result> { + let limit = params + .get("limit") + .and_then(|v| v.parse::().ok()) + .map(|v| v.max(1)) + .unwrap_or(50) + .min(200); + let caller = auth.as_ref().map(|e| e.0 .0.as_str()); let (record, _rules) = crate::api::authorize_repo_read(&state, &owner, &name, caller, "/").await?; - let certs = state.db.list_ref_certificates(&record.id).await?; + let certs = state.db.list_ref_certificates(&record.id, limit).await?; let certs_json: Vec = certs .iter() .map(|c| { @@ -35,7 +45,10 @@ pub async fn list_certs( }) .collect(); - Ok(Json(serde_json::json!({ "certificates": certs_json }))) + let count = certs_json.len(); + Ok(Json( + serde_json::json!({ "certificates": certs_json, "count": count }), + )) } /// GET /api/v1/repos/{owner}/{repo}/certs/{id} diff --git a/crates/gitlawb-node/src/api/events.rs b/crates/gitlawb-node/src/api/events.rs index 0c35fa6..99cc2ee 100644 --- a/crates/gitlawb-node/src/api/events.rs +++ b/crates/gitlawb-node/src/api/events.rs @@ -129,6 +129,7 @@ pub async fn list_ref_updates( let limit = params .get("limit") .and_then(|v| v.parse::().ok()) + .map(|v| v.max(0)) .unwrap_or(50) .clamp(0, MAX_VISIBLE_REF_UPDATES); @@ -177,6 +178,7 @@ pub async fn list_repo_events( let limit = params .get("limit") .and_then(|v| v.parse::().ok()) + .map(|v| v.max(0)) .unwrap_or(50) .clamp(0, MAX_VISIBLE_REF_UPDATES); @@ -209,7 +211,7 @@ pub async fn list_repo_events( // into an empty 200, matching the gossip half below. let cert_events: Vec = state .db - .list_ref_certificates(&record.id) + .list_ref_certificates(&record.id, limit) .await? .iter() .map(|c| { diff --git a/crates/gitlawb-node/src/cert.rs b/crates/gitlawb-node/src/cert.rs index bc82902..0ed5041 100644 --- a/crates/gitlawb-node/src/cert.rs +++ b/crates/gitlawb-node/src/cert.rs @@ -52,6 +52,7 @@ pub async fn issue_ref_certificate( issued_at, }; - state.db.insert_ref_certificate(&cert).await?; - Ok(cert) + // Persist and return the row as it exists in the database (on a + // conflict the existing row survives when it is newer). + state.db.insert_ref_certificate(&cert).await } diff --git a/crates/gitlawb-node/src/db/mod.rs b/crates/gitlawb-node/src/db/mod.rs index 25a8534..5a2e025 100644 --- a/crates/gitlawb-node/src/db/mod.rs +++ b/crates/gitlawb-node/src/db/mod.rs @@ -823,6 +823,25 @@ const MIGRATIONS: &[Migration] = &[ "ALTER TABLE repos ADD COLUMN IF NOT EXISTS quarantined BOOLEAN NOT NULL DEFAULT FALSE", ], }, + Migration { + version: 10, + name: "ref_cert_unique_per_ref", + stmts: &[ + // Dedup before the unique index: keep only the most recent row per + // (repo_id, ref_name) so the CREATE UNIQUE INDEX below does not fail + // on existing databases that accumulated duplicates. + r#"DELETE FROM ref_certificates + WHERE id IN ( + SELECT id FROM ( + SELECT id, ROW_NUMBER() OVER ( + PARTITION BY repo_id, ref_name ORDER BY issued_at DESC, id DESC + ) AS rn + FROM ref_certificates + ) dups WHERE dups.rn > 1 + )"#, + "CREATE UNIQUE INDEX IF NOT EXISTS idx_ref_certs_repo_ref ON ref_certificates(repo_id, ref_name)", + ], + }, ]; // ── Repos ───────────────────────────────────────────────────────────────────── @@ -1921,11 +1940,31 @@ impl Db { // ── Ref Certificates ────────────────────────────────────────────────────────── impl Db { - pub async fn insert_ref_certificate(&self, cert: &RefCertificate) -> Result<()> { - sqlx::query( + /// Insert a ref certificate, or update it if a row for `(repo_id, ref_name)` + /// already exists. The update only applies when the incoming row is newer + /// (compared by `issued_at`, which assumes a monotonic wall clock), so a + /// late-landing older cert cannot regress a ref's persisted state. Returns + /// the full row as it now exists in the database (the original row on a + /// rejected upsert; the passed row on insert). + pub async fn insert_ref_certificate(&self, cert: &RefCertificate) -> Result { + let row = sqlx::query( "INSERT INTO ref_certificates (id, repo_id, ref_name, old_sha, new_sha, pusher_did, node_did, signature, issued_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)", + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + ON CONFLICT (repo_id, ref_name) DO UPDATE SET + old_sha = CASE WHEN EXCLUDED.issued_at > ref_certificates.issued_at + THEN EXCLUDED.old_sha ELSE ref_certificates.old_sha END, + new_sha = CASE WHEN EXCLUDED.issued_at > ref_certificates.issued_at + THEN EXCLUDED.new_sha ELSE ref_certificates.new_sha END, + pusher_did = CASE WHEN EXCLUDED.issued_at > ref_certificates.issued_at + THEN EXCLUDED.pusher_did ELSE ref_certificates.pusher_did END, + node_did = CASE WHEN EXCLUDED.issued_at > ref_certificates.issued_at + THEN EXCLUDED.node_did ELSE ref_certificates.node_did END, + signature = CASE WHEN EXCLUDED.issued_at > ref_certificates.issued_at + THEN EXCLUDED.signature ELSE ref_certificates.signature END, + issued_at = CASE WHEN EXCLUDED.issued_at > ref_certificates.issued_at + THEN EXCLUDED.issued_at ELSE ref_certificates.issued_at END + RETURNING id, repo_id, ref_name, old_sha, new_sha, pusher_did, node_did, signature, issued_at", ) .bind(&cert.id) .bind(&cert.repo_id) @@ -1936,17 +1975,25 @@ impl Db { .bind(&cert.node_did) .bind(&cert.signature) .bind(&cert.issued_at) - .execute(&self.pool) + .fetch_one(&self.pool) .await?; - Ok(()) + Ok(row_to_cert(row)) } - pub async fn list_ref_certificates(&self, repo_id: &str) -> Result> { + pub async fn list_ref_certificates( + &self, + repo_id: &str, + limit: i64, + ) -> Result> { + // Clamp at the DB boundary so every caller (present and future) stays + // bounded even if a raw/negative value slips through the handler layer. + let limit = limit.max(1); let rows = sqlx::query( "SELECT id, repo_id, ref_name, old_sha, new_sha, pusher_did, node_did, signature, issued_at - FROM ref_certificates WHERE repo_id = $1 ORDER BY issued_at DESC", + FROM ref_certificates WHERE repo_id = $1 ORDER BY issued_at DESC LIMIT $2", ) .bind(repo_id) + .bind(limit) .fetch_all(&self.pool) .await?; Ok(rows.into_iter().map(row_to_cert).collect()) @@ -4610,3 +4657,555 @@ mod ref_update_keyset_same_timestamp_tests { ); } } + +#[cfg(test)] +mod ref_certificate_tests { + use super::{Db, RefCertificate, RepoRecord}; + use chrono::Utc; + use sqlx::PgPool; + + async fn db(pool: PgPool) -> Db { + let db = Db::for_testing(pool); + db.run_migrations().await.unwrap(); + db + } + + fn make_cert( + id: &str, + repo_id: &str, + ref_name: &str, + old_sha: &str, + new_sha: &str, + issued_at: &str, + ) -> RefCertificate { + RefCertificate { + id: id.to_string(), + repo_id: repo_id.to_string(), + ref_name: ref_name.to_string(), + old_sha: old_sha.to_string(), + new_sha: new_sha.to_string(), + pusher_did: "did:key:zPUSHER".to_string(), + node_did: "did:key:zNODE".to_string(), + signature: "sig".to_string(), + issued_at: issued_at.to_string(), + } + } + + #[sqlx::test] + async fn list_ref_certificates_respects_limit(pool: PgPool) { + let db = db(pool).await; + let repo_id = uuid::Uuid::new_v4().to_string(); + + // Create a repo to satisfy FK + db.create_repo(&RepoRecord { + id: repo_id.clone(), + name: "limit-test".into(), + owner_did: "did:key:zOWNER".into(), + description: None, + is_public: true, + default_branch: "main".into(), + created_at: Utc::now(), + updated_at: Utc::now(), + disk_path: "/tmp/limit-test".into(), + forked_from: None, + machine_id: None, + }) + .await + .unwrap(); + + // Insert 5 certs with descending issued_at + for i in 0..5 { + db.insert_ref_certificate(&make_cert( + &format!("cert-{i}"), + &repo_id, + &format!("refs/heads/feature-{i}"), + "0000", + "1111", + &format!("2026-07-03T20:0{i}:00Z"), + )) + .await + .unwrap(); + } + + // limit=2 returns only 2 + let certs = db.list_ref_certificates(&repo_id, 2).await.unwrap(); + assert_eq!(certs.len(), 2, "LIMIT 2 must return exactly 2 certs"); + assert_eq!(certs[0].id, "cert-4", "most recent first"); + assert_eq!(certs[1].id, "cert-3", "second most recent"); + + // limit=10 returns all 5 (no padding) + let all = db.list_ref_certificates(&repo_id, 10).await.unwrap(); + assert_eq!(all.len(), 5, "LIMIT >= row count returns all rows"); + } + + #[sqlx::test] + async fn insert_ref_certificate_upserts_on_repo_ref(pool: PgPool) { + let db = db(pool).await; + let repo_id = uuid::Uuid::new_v4().to_string(); + + db.create_repo(&RepoRecord { + id: repo_id.clone(), + name: "upsert-test".into(), + owner_did: "did:key:zOWNER".into(), + description: None, + is_public: true, + default_branch: "main".into(), + created_at: Utc::now(), + updated_at: Utc::now(), + disk_path: "/tmp/upsert-test".into(), + forked_from: None, + machine_id: None, + }) + .await + .unwrap(); + + // First insert + db.insert_ref_certificate(&make_cert( + "cert-original", + &repo_id, + "refs/heads/main", + "0000", + "1111", + "2026-07-03T20:00:00Z", + )) + .await + .unwrap(); + + // Upsert same ref with new values + db.insert_ref_certificate(&make_cert( + "cert-upserted", + &repo_id, + "refs/heads/main", + "aaaa", + "bbbb", + "2026-07-03T21:00:00Z", + )) + .await + .unwrap(); + + // Only one row exists for this ref + let certs = db.list_ref_certificates(&repo_id, 10).await.unwrap(); + assert_eq!(certs.len(), 1, "upsert must not create a duplicate row"); + assert_eq!( + certs[0].id, "cert-original", + "upsert must preserve the original ID across re-pushes" + ); + assert_eq!(certs[0].old_sha, "aaaa", "old_sha updated"); + assert_eq!(certs[0].new_sha, "bbbb", "new_sha updated"); + assert_eq!( + certs[0].issued_at, "2026-07-03T21:00:00Z", + "newer issued_at overwrites older" + ); + + // Now try to overwrite with an OLDER cert — the guard must reject it. + db.insert_ref_certificate(&make_cert( + "stale-id", + &repo_id, + "refs/heads/main", + "stale", + "stale", + "2026-07-03T19:00:00Z", + )) + .await + .unwrap(); + let certs = db.list_ref_certificates(&repo_id, 10).await.unwrap(); + assert_eq!(certs.len(), 1, "no extra row from stale cert"); + assert_eq!( + certs[0].id, "cert-original", + "stale cert does not change the original id" + ); + assert_eq!( + certs[0].old_sha, "aaaa", + "stale cert does not regress old_sha" + ); + assert_eq!( + certs[0].new_sha, "bbbb", + "stale cert does not regress new_sha" + ); + assert_eq!( + certs[0].issued_at, "2026-07-03T21:00:00Z", + "stale cert does not regress issued_at" + ); + } + + #[sqlx::test] + async fn list_ref_certificates_clamps_negative_limit(pool: PgPool) { + let db = db(pool).await; + let repo_id = uuid::Uuid::new_v4().to_string(); + + db.create_repo(&RepoRecord { + id: repo_id.clone(), + name: "clamp-test".into(), + owner_did: "did:key:zOWNER".into(), + description: None, + is_public: true, + default_branch: "main".into(), + created_at: Utc::now(), + updated_at: Utc::now(), + disk_path: "/tmp/clamp-test".into(), + forked_from: None, + machine_id: None, + }) + .await + .unwrap(); + + db.insert_ref_certificate(&make_cert( + "clamp-1", + &repo_id, + "refs/heads/main", + "0000", + "1111", + "2026-07-03T20:00:00Z", + )) + .await + .unwrap(); + + // Negative limit is clamped to 1 at the DB boundary + let certs = db.list_ref_certificates(&repo_id, -5).await.unwrap(); + assert_eq!(certs.len(), 1, "negative limit clamped to min 1"); + assert_eq!(certs[0].id, "clamp-1"); + + // Zero limit also clamped to 1 + let certs = db.list_ref_certificates(&repo_id, 0).await.unwrap(); + assert_eq!(certs.len(), 1, "zero limit clamped to min 1"); + assert_eq!(certs[0].id, "clamp-1"); + } + + #[sqlx::test] + async fn list_ref_certificates_empty_repo_returns_empty(pool: PgPool) { + let db = db(pool).await; + let certs = db + .list_ref_certificates("nonexistent-repo-id", 10) + .await + .unwrap(); + assert!(certs.is_empty()); + } + + /// NOTE: this test hand-copies the migration SQL as string literals and will + /// silently drift if the v10 migration block changes. The load-bearing + /// upgrade-path test is `v10_upgrade_dedup_via_migration`, which fires the + /// real MIGRATIONS[v10] entry via run_migrations(). + #[sqlx::test] + async fn v10_dedup_removes_old_duplicates(pool: PgPool) { + let db = db(pool.clone()).await; + + // Drop the unique index so we can simulate pre-v10 duplicate rows. + sqlx::query("DROP INDEX IF EXISTS idx_ref_certs_repo_ref") + .execute(&pool) + .await + .unwrap(); + + let repo_id = uuid::Uuid::new_v4().to_string(); + db.create_repo(&RepoRecord { + id: repo_id.clone(), + name: "dedup-test".into(), + owner_did: "did:key:zOWNER".into(), + description: None, + is_public: true, + default_branch: "main".into(), + created_at: Utc::now(), + updated_at: Utc::now(), + disk_path: "/tmp/dedup-test".into(), + forked_from: None, + machine_id: None, + }) + .await + .unwrap(); + + // Insert two rows for the same (repo_id, ref_name) with raw INSERT + // (no ON CONFLICT — the unique index was dropped above to simulate a + // pre-v10 database). The second row has the newer timestamp and should + // survive the dedup. + sqlx::query( + "INSERT INTO ref_certificates + (id, repo_id, ref_name, old_sha, new_sha, pusher_did, node_did, signature, issued_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)", + ) + .bind("keep-id") + .bind(&repo_id) + .bind("refs/heads/main") + .bind("0000") + .bind("1111") + .bind("did:key:zPUSHER") + .bind("did:key:zNODE") + .bind("sig-first") + .bind("2026-07-03T20:00:00Z") + .execute(&pool) + .await + .unwrap(); + + sqlx::query( + "INSERT INTO ref_certificates + (id, repo_id, ref_name, old_sha, new_sha, pusher_did, node_did, signature, issued_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)", + ) + .bind("remove-id") + .bind(&repo_id) + .bind("refs/heads/main") + .bind("aaaa") + .bind("bbbb") + .bind("did:key:zPUSHER") + .bind("did:key:zNODE") + .bind("sig-dup") + .bind("2026-07-03T19:00:00Z") // older timestamp → should be removed + .execute(&pool) + .await + .unwrap(); + + // Apply the v10 dedup logic: keep the most recent per (repo_id, ref_name). + sqlx::query( + "DELETE FROM ref_certificates + WHERE id IN ( + SELECT id FROM ( + SELECT id, ROW_NUMBER() OVER ( + PARTITION BY repo_id, ref_name ORDER BY issued_at DESC, id DESC + ) AS rn + FROM ref_certificates + ) dups WHERE dups.rn > 1 + )", + ) + .execute(&pool) + .await + .unwrap(); + + // Re-create the unique index — must succeed after dedup. + sqlx::query("CREATE UNIQUE INDEX IF NOT EXISTS idx_ref_certs_repo_ref ON ref_certificates(repo_id, ref_name)") + .execute(&pool) + .await + .unwrap(); + + // Only the most recent row survives. + let certs = db.list_ref_certificates(&repo_id, 10).await.unwrap(); + assert_eq!(certs.len(), 1, "dedup leaves one row per ref"); + assert_eq!( + certs[0].id, "keep-id", + "dedup keeps the most recent (later issued_at)" + ); + } + + /// INV-7: upgrade-path test — seed a database at v9 with duplicate + /// ref_certificates, then let the real v10 migration fire via + /// run_migrations(). This exercises the migration code path rather than + /// hand-copying its SQL, so the test stays in sync with MIGRATIONS[v10]. + #[sqlx::test] + async fn v10_upgrade_dedup_via_migration(pool: PgPool) { + // 1. Bootstrap schema via the full migration chain. + let db = Db::for_testing(pool.clone()); + db.run_migrations().await.unwrap(); + + // 2. Roll back to v9: remove the v10-unique index and the + // schema_migrations record so that run_migrations() re-applies v10. + sqlx::query("DROP INDEX IF EXISTS idx_ref_certs_repo_ref") + .execute(&pool) + .await + .unwrap(); + sqlx::query("DELETE FROM schema_migrations WHERE version = 10") + .execute(&pool) + .await + .unwrap(); + + // 3. Seed repos and duplicate certs (raw INSERT — no ON CONFLICT + // since the index is gone). + let r1 = uuid::Uuid::new_v4().to_string(); + let r2 = uuid::Uuid::new_v4().to_string(); + for (id, name) in [(&r1, "upgrade-repo-a"), (&r2, "upgrade-repo-b")] { + db.create_repo(&RepoRecord { + id: id.clone(), + name: name.into(), + owner_did: "did:key:zOWNER".into(), + description: None, + is_public: true, + default_branch: "main".into(), + created_at: Utc::now(), + updated_at: Utc::now(), + disk_path: format!("/tmp/{name}"), + forked_from: None, + machine_id: None, + }) + .await + .unwrap(); + } + + // Helper macro for raw INSERT. + macro_rules! insert_cert { + ($id:expr, $repo_id:expr, $ref_name:expr, $old_sha:expr, $new_sha:expr, $issued_at:expr) => { + sqlx::query( + "INSERT INTO ref_certificates + (id, repo_id, ref_name, old_sha, new_sha, pusher_did, node_did, signature, issued_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)", + ) + .bind($id) + .bind($repo_id) + .bind($ref_name) + .bind($old_sha) + .bind($new_sha) + .bind("did:key:zPUSHER") + .bind("did:key:zNODE") + .bind("sig") + .bind($issued_at) + .execute(&pool) + .await + .unwrap(); + }; + } + + // Repo A, ref "main": two rows with distinct timestamps. + insert_cert!( + "dup-a-old", + &r1, + "refs/heads/main", + "0000", + "1111", + "2026-07-01T10:00:00Z" + ); + insert_cert!( + "dup-a-new", + &r1, + "refs/heads/main", + "aaaa", + "bbbb", + "2026-07-02T10:00:00Z" + ); + + // Repo A, ref "feature": two rows with IDENTICAL timestamps — the + // id-DESC tiebreaker must choose the higher id (alphabetical: "z" > "a"). + insert_cert!( + "dup-feat-a", + &r1, + "refs/heads/feature", + "0000", + "1111", + "2026-07-01T10:00:00Z" + ); + insert_cert!( + "dup-feat-z", + &r1, + "refs/heads/feature", + "cccc", + "dddd", + "2026-07-01T10:00:00Z" + ); + + // Repo B, ref "main": two rows with distinct timestamps. + insert_cert!( + "dup-b-old", + &r2, + "refs/heads/main", + "0000", + "1111", + "2026-07-01T10:00:00Z" + ); + insert_cert!( + "dup-b-new", + &r2, + "refs/heads/main", + "eeee", + "ffff", + "2026-07-02T10:00:00Z" + ); + + // A non-duplicate singleton row (single row per ref) — must survive + // untouched. + insert_cert!( + "singleton", + &r2, + "refs/heads/singleton", + "0000", + "1111", + "2026-07-01T10:00:00Z" + ); + + // 4. Run migrations — the v10 dedup fires inside run_pending_migrations. + db.run_migrations().await.unwrap(); + + // 5. Assert each ref has exactly one survivor. + let all_r1 = db.list_ref_certificates(&r1, 10).await.unwrap(); + assert_eq!(all_r1.len(), 2, "repo A: 2 refs, 1 survivor each"); + + let r1_main: Vec<_> = all_r1 + .iter() + .filter(|c| c.ref_name == "refs/heads/main") + .collect(); + assert_eq!(r1_main.len(), 1, "repo A main deduped to one row"); + assert_eq!(r1_main[0].id, "dup-a-new", "newer timestamp survives"); + assert_eq!(r1_main[0].old_sha, "aaaa"); + assert_eq!(r1_main[0].new_sha, "bbbb"); + + let r1_feat: Vec<_> = all_r1 + .iter() + .filter(|c| c.ref_name == "refs/heads/feature") + .collect(); + assert_eq!(r1_feat.len(), 1, "repo A feature deduped to one row"); + assert_eq!( + r1_feat[0].id, "dup-feat-z", + "same-timestamp tiebreaker: higher id wins (id DESC)" + ); + + let all_r2 = db.list_ref_certificates(&r2, 10).await.unwrap(); + assert_eq!(all_r2.len(), 2, "repo B: 2 refs, 1 survivor each"); + + let r2_main: Vec<_> = all_r2 + .iter() + .filter(|c| c.ref_name == "refs/heads/main") + .collect(); + assert_eq!(r2_main.len(), 1, "repo B main deduped to one row"); + assert_eq!(r2_main[0].id, "dup-b-new", "newer timestamp survives"); + + let all_r2 = db.list_ref_certificates(&r2, 10).await.unwrap(); + assert_eq!( + all_r2.iter().filter(|c| c.id == "singleton").count(), + 1, + "non-duplicate singleton untouched" + ); + + // 6. Verify the unique index exists: the upsert helper must succeed + // (exercises ON CONFLICT) and a direct duplicate INSERT must fail. + db.insert_ref_certificate(&make_cert( + "post-migration-upsert", + &r1, + "refs/heads/main", + "1111", + "2222", + "2026-07-03T10:00:00Z", + )) + .await + .unwrap(); + let after_upsert = db.list_ref_certificates(&r1, 10).await.unwrap(); + let r1_main_after: Vec<_> = after_upsert + .iter() + .filter(|c| c.ref_name == "refs/heads/main") + .collect(); + assert_eq!( + r1_main_after.len(), + 1, + "upsert keeps exactly one row for main" + ); + assert_eq!( + r1_main_after[0].id, "dup-a-new", + "upsert preserves original id" + ); + assert_eq!(r1_main_after[0].old_sha, "1111", "upsert updated old_sha"); + + // A raw INSERT for the same (repo_id, ref_name) must now fail. + let err = sqlx::query( + "INSERT INTO ref_certificates + (id, repo_id, ref_name, old_sha, new_sha, pusher_did, node_did, signature, issued_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)", + ) + .bind("should-fail") + .bind(&r1) + .bind("refs/heads/main") + .bind("xxxx") + .bind("yyyy") + .bind("did:key:zPUSHER") + .bind("did:key:zNODE") + .bind("sig") + .bind("2026-07-04T10:00:00Z") + .execute(&pool) + .await; + assert!( + err.is_err(), + "raw duplicate INSERT must be rejected by the unique index" + ); + } +} diff --git a/crates/gitlawb-node/src/test_support.rs b/crates/gitlawb-node/src/test_support.rs index 14a6120..e6d6a25 100644 --- a/crates/gitlawb-node/src/test_support.rs +++ b/crates/gitlawb-node/src/test_support.rs @@ -3547,4 +3547,173 @@ mod tests { .unwrap(); assert!(resp.status().is_success()); } + + // ── #147: list_certs respects ?limit ────────────────────────────────────── + + fn seed_cert( + id: &str, + repo_id: &str, + ref_name: &str, + issued_at: &str, + ) -> crate::db::RefCertificate { + crate::db::RefCertificate { + id: id.to_string(), + repo_id: repo_id.to_string(), + ref_name: ref_name.to_string(), + old_sha: "0000".into(), + new_sha: "1111".into(), + pusher_did: "did:key:zPUSHER".into(), + node_did: "did:key:zNODE".into(), + signature: "sig".into(), + issued_at: issued_at.to_string(), + } + } + + #[sqlx::test] + async fn list_certs_respects_limit_param(pool: PgPool) { + let owner = "did:key:zCERTOWNERAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; + let state = test_state(pool).await; + state + .db + .create_repo(&seed_repo(owner, "cert-repo")) + .await + .expect("seed repo"); + let repo = state + .db + .get_repo(owner, "cert-repo") + .await + .unwrap() + .expect("repo must exist"); + + for i in 0..10u64 { + state + .db + .insert_ref_certificate(&seed_cert( + &format!("cert-{i}"), + &repo.id, + &format!("refs/heads/feature-{i}"), + &format!("2026-07-03T20:{i:02}:00Z"), + )) + .await + .unwrap(); + } + + let router = || { + Router::new() + .route( + "/api/v1/repos/{owner}/{repo}/certs", + axum::routing::get(crate::api::certs::list_certs), + ) + .with_state(state.clone()) + }; + + // No limit param → default 50, returns all 10 + let resp = router() + .oneshot(anon_get(&format!("/api/v1/repos/{owner}/cert-repo/certs"))) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body = json_body(resp).await; + assert_eq!(body["count"], 10, "default limit returns all rows"); + assert_eq!( + body["certificates"].as_array().unwrap().len(), + 10, + "all certs in response" + ); + + // limit=3 returns exactly 3 + let resp = router() + .oneshot(anon_get(&format!( + "/api/v1/repos/{owner}/cert-repo/certs?limit=3" + ))) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body = json_body(resp).await; + assert_eq!(body["count"], 3, "limit=3 returns 3 certs"); + let certs = body["certificates"].as_array().unwrap(); + assert_eq!(certs.len(), 3); + assert_eq!(certs[0]["id"], "cert-9", "most recent cert first"); + assert_eq!(certs[2]["id"], "cert-7", "third most recent cert"); + + // limit=0 is clamped to min 1, returns 1 cert + let resp = router() + .oneshot(anon_get(&format!( + "/api/v1/repos/{owner}/cert-repo/certs?limit=0" + ))) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body = json_body(resp).await; + assert_eq!(body["count"], 1, "limit=0 clamped to min 1"); + assert_eq!( + body["certificates"].as_array().unwrap().len(), + 1, + "one cert when limit=0" + ); + assert_eq!(body["certificates"][0]["id"], "cert-9", "most recent"); + + // limit=200+ is capped at 200 + let resp = router() + .oneshot(anon_get(&format!( + "/api/v1/repos/{owner}/cert-repo/certs?limit=300" + ))) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body = json_body(resp).await; + assert_eq!( + body["count"], 10, + "limit=300 capped to 200, still returns all 10" + ); + } + + #[sqlx::test] + async fn list_certs_returns_count_field(pool: PgPool) { + let owner = "did:key:zCERTCOUNTAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; + let state = test_state(pool).await; + state + .db + .create_repo(&seed_repo(owner, "count-repo")) + .await + .expect("seed repo"); + let repo = state + .db + .get_repo(owner, "count-repo") + .await + .unwrap() + .unwrap(); + + state + .db + .insert_ref_certificate(&seed_cert( + "cnt-1", + &repo.id, + "refs/heads/main", + "2026-07-03T20:00:00Z", + )) + .await + .unwrap(); + + let router = Router::new() + .route( + "/api/v1/repos/{owner}/{repo}/certs", + axum::routing::get(crate::api::certs::list_certs), + ) + .with_state(state); + + let resp = router + .oneshot(anon_get(&format!("/api/v1/repos/{owner}/count-repo/certs"))) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body = json_body(resp).await; + assert!(body.get("count").is_some(), "response must include `count`"); + assert_eq!(body["count"], 1); + assert_eq!( + body["certificates"].as_array().unwrap().len(), + 1, + "certificates array length matches count" + ); + } }