@@ -14,6 +14,68 @@ use subscription::SubscriptionRoot;
1414
1515pub type GitlawbSchema = Schema < QueryRoot , MutationRoot , SubscriptionRoot > ;
1616
17+ /// Client-facing message for GraphQL resolver failures that wrap a real
18+ /// `sqlx::Error`. The real error is logged server-side; never put sqlx/Postgres
19+ /// detail in the GraphQL `errors` array (#250).
20+ ///
21+ /// Kept as its own constant on this PR's base (main still renders
22+ /// `AppError::Db` with `e.to_string()`). If/when #247's `DB_ERROR_MESSAGE`
23+ /// lands, fold this into that shared constant.
24+ pub const GRAPHQL_DB_ERROR_MESSAGE : & str = "a database error occurred" ;
25+
26+ fn anyhow_has_sqlx ( e : & anyhow:: Error ) -> bool {
27+ e. chain ( ) . any ( |c| c. downcast_ref :: < sqlx:: Error > ( ) . is_some ( ) )
28+ }
29+
30+ /// Map an `anyhow` failure from the db layer to a GraphQL error.
31+ ///
32+ /// - Real DB faults (`sqlx::Error` anywhere in the chain) → opaque client
33+ /// message + `error!` log with the full `{e:#}` cause chain.
34+ /// - Application/business errors (e.g. claim race, not-in-claimed-state) →
35+ /// keep the actionable message; log at `warn!` so they are not mistaken for
36+ /// infrastructure failures (#250 review).
37+ pub ( crate ) fn graphql_db_err ( e : anyhow:: Error ) -> async_graphql:: Error {
38+ if anyhow_has_sqlx ( & e) {
39+ tracing:: error!( error = %format!( "{e:#}" ) , "graphql database error" ) ;
40+ async_graphql:: Error :: new ( GRAPHQL_DB_ERROR_MESSAGE )
41+ } else {
42+ tracing:: warn!( error = %format!( "{e:#}" ) , "graphql application error" ) ;
43+ async_graphql:: Error :: new ( e. to_string ( ) )
44+ }
45+ }
46+
47+ /// Map an `AppError` from a shared collector (e.g. ref-update feed) to a
48+ /// GraphQL error.
49+ ///
50+ /// Fail closed: only explicitly curated variants surface their `Display`
51+ /// text. Unnamed variants (including `Git`, which may embed on-disk paths)
52+ /// render opaque so a future addition cannot leak by default (#255 review).
53+ pub ( crate ) fn graphql_app_err ( e : crate :: error:: AppError ) -> async_graphql:: Error {
54+ match e {
55+ crate :: error:: AppError :: Db ( sql) => graphql_db_err ( sql. into ( ) ) ,
56+ crate :: error:: AppError :: Internal ( err) => {
57+ tracing:: error!( error = %format!( "{err:#}" ) , "graphql internal error" ) ;
58+ async_graphql:: Error :: new ( GRAPHQL_DB_ERROR_MESSAGE )
59+ }
60+ // Curated client-safe variants — `Display` is intentional API text.
61+ safe @ ( crate :: error:: AppError :: RepoNotFound ( _)
62+ | crate :: error:: AppError :: RepoExists ( _)
63+ | crate :: error:: AppError :: NotFound ( _)
64+ | crate :: error:: AppError :: Unauthorized ( _)
65+ | crate :: error:: AppError :: Forbidden ( _)
66+ | crate :: error:: AppError :: BadRequest ( _)
67+ | crate :: error:: AppError :: TooManyRequests ( _)
68+ | crate :: error:: AppError :: Incomplete ( _) ) => {
69+ tracing:: warn!( error = %safe, "graphql application error" ) ;
70+ async_graphql:: Error :: new ( safe. to_string ( ) )
71+ }
72+ other => {
73+ tracing:: error!( error = %other, "graphql unclassified AppError (opaque)" ) ;
74+ async_graphql:: Error :: new ( GRAPHQL_DB_ERROR_MESSAGE )
75+ }
76+ }
77+ }
78+
1779pub fn build_schema (
1880 db : Arc < Db > ,
1981 ref_update_tx : tokio:: sync:: broadcast:: Sender < RefUpdateBroadcast > ,
@@ -25,3 +87,105 @@ pub fn build_schema(
2587 . data ( task_event_tx)
2688 . finish ( )
2789}
90+
91+ #[ cfg( test) ]
92+ mod tests {
93+ use super :: * ;
94+
95+ #[ test]
96+ fn graphql_db_err_opaques_sqlx_chain ( ) {
97+ let leak = "error returned from database: column \" is_public\" does not exist" ;
98+ // Context layer must not hide sqlx from the chain walk (db helpers
99+ // wrap with `.context(...)` in several places).
100+ let err = graphql_db_err (
101+ anyhow:: Error :: from ( sqlx:: Error :: Protocol ( leak. into ( ) ) ) . context ( "loading repos" ) ,
102+ ) ;
103+ assert_eq ! ( err. message, GRAPHQL_DB_ERROR_MESSAGE ) ;
104+ assert ! ( !err. message. contains( "is_public" ) ) ;
105+ assert ! ( !err. message. contains( leak) ) ;
106+ assert ! ( !err. message. contains( "loading repos" ) ) ;
107+ }
108+
109+ #[ test]
110+ fn graphql_db_err_keeps_business_message ( ) {
111+ let msg = "task not claimable: not found or already claimed" ;
112+ let err = graphql_db_err ( anyhow:: anyhow!( "{msg}" ) ) ;
113+ assert_eq ! ( err. message, msg) ;
114+ }
115+
116+ #[ test]
117+ fn graphql_app_err_opaques_db_and_internal ( ) {
118+ let leak = "column \" is_public\" does not exist" ;
119+ let db_err = graphql_app_err ( crate :: error:: AppError :: Db ( sqlx:: Error :: Protocol (
120+ leak. into ( ) ,
121+ ) ) ) ;
122+ assert_eq ! ( db_err. message, GRAPHQL_DB_ERROR_MESSAGE ) ;
123+ assert ! ( !db_err. message. contains( "is_public" ) ) ;
124+
125+ let internal = graphql_app_err ( crate :: error:: AppError :: Internal ( anyhow:: anyhow!(
126+ "loading repo: {leak}"
127+ ) ) ) ;
128+ assert_eq ! ( internal. message, GRAPHQL_DB_ERROR_MESSAGE ) ;
129+ assert ! ( !internal. message. contains( "is_public" ) ) ;
130+ }
131+
132+ #[ test]
133+ fn graphql_app_err_keeps_safe_variant_messages ( ) {
134+ let err = graphql_app_err ( crate :: error:: AppError :: NotFound ( "widget" . into ( ) ) ) ;
135+ assert ! (
136+ err. message. contains( "widget" ) ,
137+ "safe NotFound message must reach the client: {}" ,
138+ err. message
139+ ) ;
140+ assert_ne ! ( err. message, GRAPHQL_DB_ERROR_MESSAGE ) ;
141+
142+ let err = graphql_app_err ( crate :: error:: AppError :: BadRequest ( "bad cid" . into ( ) ) ) ;
143+ assert ! (
144+ err. message. contains( "bad cid" ) ,
145+ "safe BadRequest message must reach the client: {}" ,
146+ err. message
147+ ) ;
148+ assert_ne ! ( err. message, GRAPHQL_DB_ERROR_MESSAGE ) ;
149+ }
150+
151+ #[ test]
152+ fn graphql_app_err_opaques_unclassified_variants ( ) {
153+ // `Git` may embed on-disk paths from libgit2; fail closed.
154+ let path = "/var/lib/gitlawb/repos/owner/secret.git" ;
155+ let err = graphql_app_err ( crate :: error:: AppError :: Git ( format ! (
156+ "failed to open '{path}'"
157+ ) ) ) ;
158+ assert_eq ! ( err. message, GRAPHQL_DB_ERROR_MESSAGE ) ;
159+ assert ! ( !err. message. contains( path) ) ;
160+ assert ! ( !err. message. contains( "failed to open" ) ) ;
161+ }
162+
163+ /// Every `.map_err(` in the GraphQL query/mutation resolvers must route
164+ /// through the opaque helpers, or discard the error (`|_|`). Same source-
165+ /// scrape pattern as `api::authz_guard` (#255 review).
166+ #[ test]
167+ fn every_graphql_map_err_uses_opaque_helpers ( ) {
168+ for ( file, src) in [
169+ ( "query.rs" , include_str ! ( "query.rs" ) ) ,
170+ ( "mutation.rs" , include_str ! ( "mutation.rs" ) ) ,
171+ ] {
172+ for ( lineno, line) in src. lines ( ) . enumerate ( ) {
173+ let code = line. split ( "//" ) . next ( ) . unwrap_or ( line) ;
174+ let Some ( idx) = code. find ( ".map_err(" ) else {
175+ continue ;
176+ } ;
177+ let after = code[ idx + ".map_err(" . len ( ) ..] . trim_start ( ) ;
178+ let ok = after. starts_with ( "crate::graphql::graphql_db_err" )
179+ || after. starts_with ( "crate::graphql::graphql_app_err" )
180+ || after. starts_with ( "|_|" )
181+ || after. starts_with ( "|_ " ) ;
182+ assert ! (
183+ ok,
184+ "{file}:{}: `.map_err(` must use graphql_db_err / graphql_app_err \
185+ or discard (`|_|`): {line}",
186+ lineno + 1
187+ ) ;
188+ }
189+ }
190+ }
191+ }
0 commit comments