@@ -1462,6 +1462,106 @@ mod tests {
14621462 ) ;
14631463 }
14641464
1465+ /// #113 fail-closed: when the repo lookup ERRORS (not a clean Ok(None)), the
1466+ /// visibility gate must not be skipped. The buggy `.ok().flatten()` collapsed an
1467+ /// Err into None, so a transient DB failure during the lookup dropped the gate
1468+ /// and the handler served the private repo's gossip ref-updates via the
1469+ /// ungated None branch (slug taken from the URL owner segment). We force a
1470+ /// deterministic get_repo error by dropping the column its SELECT reads, then
1471+ /// require the handler to fail closed (500, no secret) instead of 200-with-secret.
1472+ #[ sqlx:: test]
1473+ async fn list_repo_events_fails_closed_when_repo_lookup_errors ( pool : PgPool ) {
1474+ use crate :: db:: ReceivedRefUpdate ;
1475+ let owner = "did:key:zEVTERRAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" ;
1476+ // Caller addresses the repo by the full key part (the slug gossip rows use),
1477+ // so the buggy None-branch fallback slug matches the seeded private update.
1478+ let keypart = owner. split ( ':' ) . next_back ( ) . unwrap ( ) ;
1479+ let state = test_state ( pool. clone ( ) ) . await ;
1480+
1481+ let mut priv_repo = seed_repo ( owner, "evt-priv" ) ;
1482+ priv_repo. is_public = false ;
1483+ state
1484+ . db
1485+ . create_repo ( & priv_repo)
1486+ . await
1487+ . expect ( "seed private repo" ) ;
1488+
1489+ state
1490+ . db
1491+ . insert_ref_update ( & ReceivedRefUpdate {
1492+ id : uuid:: Uuid :: new_v4 ( ) . to_string ( ) ,
1493+ node_did : owner. to_string ( ) ,
1494+ pusher_did : owner. to_string ( ) ,
1495+ repo : format ! ( "{keypart}/evt-priv" ) ,
1496+ ref_name : "refs/heads/embargo-gossip" . to_string ( ) ,
1497+ old_sha : "0" . repeat ( 40 ) ,
1498+ new_sha : "gossipSEKRET" . to_string ( ) ,
1499+ timestamp : Utc :: now ( ) . to_rfc3339 ( ) ,
1500+ cert_id : None ,
1501+ received_at : Utc :: now ( ) . to_rfc3339 ( ) ,
1502+ from_peer : "peer" . to_string ( ) ,
1503+ } )
1504+ . await
1505+ . expect ( "seed private gossip update" ) ;
1506+
1507+ // Force get_repo's SELECT (which reads machine_id, db/mod.rs) to error,
1508+ // simulating a transient DB failure during the visibility lookup. The repo
1509+ // row and the gossip update both remain present.
1510+ // Precondition: the lookup must succeed before we break it, otherwise the
1511+ // injection proves nothing.
1512+ state
1513+ . db
1514+ . get_repo ( keypart, "evt-priv" )
1515+ . await
1516+ . expect ( "pre-drop lookup must succeed" )
1517+ . expect ( "private repo row must be present pre-drop" ) ;
1518+ sqlx:: query ( "ALTER TABLE repos DROP COLUMN machine_id" )
1519+ . execute ( & pool)
1520+ . await
1521+ . expect ( "drop column to force a get_repo error" ) ;
1522+ // Guard the injection: if a future refactor drops machine_id from get_repo's
1523+ // SELECT, this assertion fails loudly instead of letting the test pass
1524+ // vacuously (get_repo would return Ok and the gate, not the error path,
1525+ // would drive the response).
1526+ assert ! (
1527+ state. db. get_repo( keypart, "evt-priv" ) . await . is_err( ) ,
1528+ "dropping machine_id must make get_repo error, else this test no longer exercises the Err path"
1529+ ) ;
1530+
1531+ let router = Router :: new ( )
1532+ . route (
1533+ "/api/v1/repos/{owner}/{repo}/events" ,
1534+ axum:: routing:: get ( crate :: api:: events:: list_repo_events) ,
1535+ )
1536+ . with_state ( state. clone ( ) ) ;
1537+ let resp = router
1538+ . oneshot (
1539+ Request :: builder ( )
1540+ . method ( Method :: GET )
1541+ . uri ( format ! ( "/api/v1/repos/{keypart}/evt-priv/events" ) )
1542+ . body ( Body :: empty ( ) )
1543+ . unwrap ( ) ,
1544+ )
1545+ . await
1546+ . unwrap ( ) ;
1547+
1548+ // Fail closed: a lookup error must surface as 500, never a 200 that serves
1549+ // the private repo's ref metadata through the ungated branch.
1550+ assert_eq ! (
1551+ resp. status( ) ,
1552+ StatusCode :: INTERNAL_SERVER_ERROR ,
1553+ "a repo-lookup error must fail closed, not skip the gate"
1554+ ) ;
1555+ let bytes = axum:: body:: to_bytes ( resp. into_body ( ) , usize:: MAX )
1556+ . await
1557+ . unwrap ( ) ;
1558+ let body = String :: from_utf8_lossy ( & bytes) . to_string ( ) ;
1559+ assert ! (
1560+ !body. contains( "gossipSEKRET" ) ,
1561+ "fail-closed response must not leak the gossip secret"
1562+ ) ;
1563+ }
1564+
14651565 /// #94 end-to-end seam: a REAL RFC-9421 signature produced exactly as the gl
14661566 /// client's get_signed does (gitlawb_core::http_sig::sign_request over GET +
14671567 /// empty body) is accepted by the node's actual optional_signature middleware,
0 commit comments