@@ -449,3 +449,198 @@ mod lock_pool_shed_tests {
449449 ) ;
450450 }
451451}
452+
453+ /// #426: end-to-end at the handler seam. A corrupt object store behind a
454+ /// resolving issue ref used to look identical to an absent issue: get returned
455+ /// a fake 404, close returned a fake 404 after releasing the write guard, and
456+ /// list silently under-reported. All three must now surface a git error.
457+ #[ cfg( test) ]
458+ mod corrupt_store_tests {
459+ use super :: * ;
460+ use axum:: response:: IntoResponse ;
461+ use sqlx:: PgPool ;
462+ use tempfile:: TempDir ;
463+
464+ fn seed_repo ( owner_did : & str , name : & str ) -> crate :: db:: RepoRecord {
465+ let now = Utc :: now ( ) ;
466+ crate :: db:: RepoRecord {
467+ id : Uuid :: new_v4 ( ) . to_string ( ) ,
468+ name : name. to_string ( ) ,
469+ owner_did : owner_did. to_string ( ) ,
470+ description : None ,
471+ is_public : true ,
472+ default_branch : "main" . to_string ( ) ,
473+ created_at : now,
474+ updated_at : now,
475+ disk_path : format ! ( "/tmp/{name}" ) ,
476+ forked_from : None ,
477+ machine_id : None ,
478+ }
479+ }
480+
481+ /// A state whose repo store roots in `repos_dir`, so the test can lay down
482+ /// a real repo and then corrupt it without touching /tmp.
483+ async fn state_with_repos_dir ( pool : & PgPool , repos_dir : & std:: path:: Path ) -> AppState {
484+ let mut state = crate :: test_support:: test_state ( pool. clone ( ) ) . await ;
485+ state. repo_store =
486+ crate :: git:: repo_store:: RepoStore :: for_testing ( repos_dir. to_path_buf ( ) , pool. clone ( ) ) ;
487+ state
488+ }
489+
490+ /// Bare repo at the store's expected path, holding one issue ref.
491+ fn bare_repo_with_issue (
492+ repos_dir : & std:: path:: Path ,
493+ owner : & str ,
494+ name : & str ,
495+ ) -> ( std:: path:: PathBuf , String ) {
496+ let repo_path = repos_dir
497+ . join ( owner. replace ( [ ':' , '/' ] , "_" ) )
498+ . join ( format ! ( "{name}.git" ) ) ;
499+ std:: fs:: create_dir_all ( & repo_path) . unwrap ( ) ;
500+ let out = std:: process:: Command :: new ( "git" )
501+ . args ( [ "init" , "--bare" ] )
502+ . arg ( & repo_path)
503+ . output ( )
504+ . unwrap ( ) ;
505+ assert ! ( out. status. success( ) ) ;
506+ let issue_id = "aaaabbbb-0000-0000-0000-000000000000" . to_string ( ) ;
507+ git_issues:: create_issue (
508+ & repo_path,
509+ & issue_id,
510+ & serde_json:: json!( {
511+ "id" : issue_id,
512+ "title" : "t" ,
513+ "status" : "open" ,
514+ } )
515+ . to_string ( ) ,
516+ )
517+ . unwrap ( ) ;
518+ ( repo_path, issue_id)
519+ }
520+
521+ /// Delete the blob object behind the issue ref and put garbage in its
522+ /// place: `cat-file` fails while `rev-parse` still resolves the ref.
523+ fn corrupt_issue_object ( repo_path : & std:: path:: Path , issue_id : & str ) {
524+ let out = std:: process:: Command :: new ( "git" )
525+ . args ( [ "rev-parse" , & format ! ( "refs/gitlawb/issues/{issue_id}" ) ] )
526+ . current_dir ( repo_path)
527+ . output ( )
528+ . unwrap ( ) ;
529+ let oid = String :: from_utf8 ( out. stdout ) . unwrap ( ) . trim ( ) . to_string ( ) ;
530+ let obj = repo_path. join ( "objects" ) . join ( & oid[ ..2 ] ) . join ( & oid[ 2 ..] ) ;
531+ std:: fs:: remove_file ( & obj) . unwrap ( ) ;
532+ std:: fs:: write ( & obj, b"garbage" ) . unwrap ( ) ;
533+ }
534+
535+ /// The regression: closing over a corrupt store used to return a fake 404
536+ /// (or panic deeper in). It must reach the caller as a git 500, and the
537+ /// handler must return rather than unwind.
538+ #[ sqlx:: test]
539+ async fn close_issue_on_corrupt_store_returns_git_error_not_panic ( pool : PgPool ) {
540+ let owner = "did:key:zISSUECORRUPTCCCCCCCCCCCCCCCCCCCCCCCCC" ;
541+ let dir = TempDir :: new ( ) . unwrap ( ) ;
542+ let state = state_with_repos_dir ( & pool, dir. path ( ) ) . await ;
543+ state
544+ . db
545+ . create_repo ( & seed_repo ( owner, "corrupt-close" ) )
546+ . await
547+ . expect ( "seed repo" ) ;
548+ let ( repo_path, issue_id) = bare_repo_with_issue ( dir. path ( ) , owner, "corrupt-close" ) ;
549+ corrupt_issue_object ( & repo_path, & issue_id) ;
550+
551+ let err = close_issue (
552+ State ( state) ,
553+ Extension ( AuthenticatedDid ( owner. to_string ( ) ) ) ,
554+ Path ( ( owner. to_string ( ) , "corrupt-close" . to_string ( ) , issue_id) ) ,
555+ )
556+ . await
557+ . expect_err ( "a corrupt store must error, not panic or 404" ) ;
558+ assert_eq ! (
559+ err. into_response( ) . status( ) ,
560+ StatusCode :: INTERNAL_SERVER_ERROR ,
561+ "a read failure is a git 500, not a fake not-found"
562+ ) ;
563+ }
564+
565+ /// get on a corrupt store: 500, not the 404 the fold used to produce.
566+ #[ sqlx:: test]
567+ async fn get_issue_on_corrupt_store_returns_500_not_404 ( pool : PgPool ) {
568+ let owner = "did:key:zISSUECORRUPTGETDDDDDDDDDDDDDDDDDDDDDDD" ;
569+ let dir = TempDir :: new ( ) . unwrap ( ) ;
570+ let state = state_with_repos_dir ( & pool, dir. path ( ) ) . await ;
571+ state
572+ . db
573+ . create_repo ( & seed_repo ( owner, "corrupt-get" ) )
574+ . await
575+ . expect ( "seed repo" ) ;
576+ let ( repo_path, issue_id) = bare_repo_with_issue ( dir. path ( ) , owner, "corrupt-get" ) ;
577+ corrupt_issue_object ( & repo_path, & issue_id) ;
578+
579+ let err = get_issue (
580+ State ( state) ,
581+ Path ( ( owner. to_string ( ) , "corrupt-get" . to_string ( ) , issue_id) ) ,
582+ None ,
583+ )
584+ . await
585+ . expect_err ( "a corrupt store must error, not 404" ) ;
586+ assert_eq ! (
587+ err. into_response( ) . status( ) ,
588+ StatusCode :: INTERNAL_SERVER_ERROR ,
589+ ) ;
590+ }
591+
592+ /// list on a corrupt store must fail outright; a 200 that silently drops
593+ /// the unreadable issue is the other half of the fold.
594+ #[ sqlx:: test]
595+ async fn list_issues_on_corrupt_store_returns_git_error ( pool : PgPool ) {
596+ let owner = "did:key:zISSUECORRUPTLISTEEEEEEEEEEEEEEEEEEEEEE" ;
597+ let dir = TempDir :: new ( ) . unwrap ( ) ;
598+ let state = state_with_repos_dir ( & pool, dir. path ( ) ) . await ;
599+ state
600+ . db
601+ . create_repo ( & seed_repo ( owner, "corrupt-list" ) )
602+ . await
603+ . expect ( "seed repo" ) ;
604+ let ( repo_path, issue_id) = bare_repo_with_issue ( dir. path ( ) , owner, "corrupt-list" ) ;
605+ corrupt_issue_object ( & repo_path, & issue_id) ;
606+
607+ let err = list_issues (
608+ State ( state) ,
609+ Path ( ( owner. to_string ( ) , "corrupt-list" . to_string ( ) ) ) ,
610+ None ,
611+ )
612+ . await
613+ . expect_err ( "a corrupt store must error, not under-report" ) ;
614+ assert_eq ! (
615+ err. into_response( ) . status( ) ,
616+ StatusCode :: INTERNAL_SERVER_ERROR ,
617+ ) ;
618+ }
619+
620+ /// Must-not direction: a genuinely absent issue still gets the 404.
621+ #[ sqlx:: test]
622+ async fn get_issue_on_absent_ref_still_returns_404 ( pool : PgPool ) {
623+ let owner = "did:key:zISSUEABSENTFFFFFFFFFFFFFFFFFFFFFFFFFFF" ;
624+ let dir = TempDir :: new ( ) . unwrap ( ) ;
625+ let state = state_with_repos_dir ( & pool, dir. path ( ) ) . await ;
626+ state
627+ . db
628+ . create_repo ( & seed_repo ( owner, "absent-get" ) )
629+ . await
630+ . expect ( "seed repo" ) ;
631+ bare_repo_with_issue ( dir. path ( ) , owner, "absent-get" ) ;
632+
633+ let err = get_issue (
634+ State ( state) ,
635+ Path ( (
636+ owner. to_string ( ) ,
637+ "absent-get" . to_string ( ) ,
638+ "ffffffff-0000-0000-0000-000000000000" . to_string ( ) ,
639+ ) ) ,
640+ None ,
641+ )
642+ . await
643+ . expect_err ( "a missing issue is still not-found" ) ;
644+ assert_eq ! ( err. into_response( ) . status( ) , StatusCode :: NOT_FOUND ) ;
645+ }
646+ }
0 commit comments