@@ -183,6 +183,139 @@ async fn sqlite_concurrency_restart_name_reuse_expiry_and_uncertainty() {
183183 . await ;
184184}
185185
186+ #[ tokio:: test]
187+ async fn workspace_create_request_id_is_scoped_to_each_target ( ) {
188+ let state = test_server_state ( ) . await ;
189+ let service = crate :: grpc:: OpenShellService :: new ( state. clone ( ) ) ;
190+ let first = create ( "scope-a" ) ;
191+ let second = CreateWorkspaceRequest {
192+ name : "scope-b" . into ( ) ,
193+ ..first. clone ( )
194+ } ;
195+ let mut originals = Vec :: new ( ) ;
196+ for req in [ & first, & second] {
197+ let response = service
198+ . create_workspace ( authed_request ( req. clone ( ) ) )
199+ . await
200+ . unwrap ( ) ;
201+ assert ! ( response. metadata( ) . get( "openshell-replayed" ) . is_none( ) ) ;
202+ let original = response. into_inner ( ) ;
203+ assert_eq ! (
204+ original
205+ . workspace
206+ . as_ref( )
207+ . unwrap( )
208+ . metadata
209+ . as_ref( )
210+ . unwrap( )
211+ . name,
212+ req. name
213+ ) ;
214+ assert_eq ! (
215+ state
216+ . store
217+ . get_message_by_name:: <Workspace >( "" , & req. name)
218+ . await
219+ . unwrap( ) ,
220+ original. workspace
221+ ) ;
222+ originals. push ( original) ;
223+ }
224+ assert_ne ! (
225+ originals[ 0 ] . workspace. as_ref( ) . unwrap( ) . object_id( ) ,
226+ originals[ 1 ] . workspace. as_ref( ) . unwrap( ) . object_id( )
227+ ) ;
228+ for ( req, original) in [ & first, & second] . into_iter ( ) . zip ( originals) {
229+ let replay = service
230+ . create_workspace ( authed_request ( req. clone ( ) ) )
231+ . await
232+ . unwrap ( ) ;
233+ assert_eq ! ( replay. metadata( ) . get( "openshell-replayed" ) . unwrap( ) , "true" ) ;
234+ assert_eq ! ( replay. into_inner( ) , original) ;
235+ let mut mismatch = req. clone ( ) ;
236+ mismatch. labels . insert ( "changed" . into ( ) , "payload" . into ( ) ) ;
237+ assert_eq ! (
238+ reason(
239+ & service
240+ . create_workspace( authed_request( mismatch) )
241+ . await
242+ . unwrap_err( )
243+ ) ,
244+ "REQUEST_ID_PAYLOAD_MISMATCH"
245+ ) ;
246+ }
247+ let rows = state
248+ . store
249+ . list_by_type_after ( OBJECT_TYPE , None , 100 )
250+ . await
251+ . unwrap ( ) ;
252+ assert_eq ! ( rows. len( ) , 2 ) ;
253+ for row in rows {
254+ let admission: Admission = serde_json:: from_slice ( & row. payload ) . unwrap ( ) ;
255+ assert ! ( admission. workspace_id. is_none( ) ) ;
256+ }
257+ }
258+
259+ #[ tokio:: test]
260+ async fn workspace_delete_request_id_is_scoped_to_each_target ( ) {
261+ let state = test_server_state ( ) . await ;
262+ let service = crate :: grpc:: OpenShellService :: new ( state. clone ( ) ) ;
263+ let request_id = uuid:: Uuid :: new_v4 ( ) . to_string ( ) ;
264+ let mut requests = Vec :: new ( ) ;
265+ for name in [ "scope-a" , "scope-b" ] {
266+ service
267+ . create_workspace ( authed_request ( CreateWorkspaceRequest {
268+ name : name. into ( ) ,
269+ ..Default :: default ( )
270+ } ) )
271+ . await
272+ . unwrap ( ) ;
273+ requests. push ( DeleteWorkspaceRequest {
274+ name : name. into ( ) ,
275+ request_id : request_id. clone ( ) ,
276+ ..Default :: default ( )
277+ } ) ;
278+ }
279+ for req in & requests {
280+ let response = service
281+ . delete_workspace ( authed_request ( req. clone ( ) ) )
282+ . await
283+ . unwrap ( ) ;
284+ assert ! ( response. metadata( ) . get( "openshell-replayed" ) . is_none( ) ) ;
285+ assert_eq ! (
286+ response. get_ref( ) . outcome,
287+ openshell_core:: proto:: DeletionOutcome :: Completed as i32
288+ ) ;
289+ assert ! (
290+ state
291+ . store
292+ . get_message_by_name:: <Workspace >( "" , & req. name)
293+ . await
294+ . unwrap( )
295+ . is_none( )
296+ ) ;
297+ }
298+ for req in requests {
299+ // The target no longer exists; deletion replay must not require its UUID.
300+ let replay = service. delete_workspace ( authed_request ( req) ) . await . unwrap ( ) ;
301+ assert_eq ! ( replay. metadata( ) . get( "openshell-replayed" ) . unwrap( ) , "true" ) ;
302+ assert_eq ! (
303+ replay. get_ref( ) . outcome,
304+ openshell_core:: proto:: DeletionOutcome :: Completed as i32
305+ ) ;
306+ }
307+ let rows = state
308+ . store
309+ . list_by_type_after ( OBJECT_TYPE , None , 100 )
310+ . await
311+ . unwrap ( ) ;
312+ assert_eq ! ( rows. len( ) , 2 ) ;
313+ for row in rows {
314+ let admission: Admission = serde_json:: from_slice ( & row. payload ) . unwrap ( ) ;
315+ assert ! ( admission. workspace_id. is_none( ) ) ;
316+ }
317+ }
318+
186319#[ tokio:: test]
187320#[ ignore = "requires a disposable PostgreSQL database in OPENSHELL_REPLAY_TEST_DATABASE_URL" ]
188321async fn postgres_concurrency_restart_name_reuse_expiry_and_uncertainty ( ) {
@@ -463,7 +596,13 @@ impl Mutation for ControlledCreate {
463596 & self . request_id
464597 }
465598 async fn authorize ( & self , state : & ServerState , principal : & Principal ) -> Result < Scope , Status > {
466- global_scope ( state, principal)
599+ // Fault-injected execution must use the real adapter's admission key.
600+ CreateWorkspaceRequest {
601+ name : self . name . clone ( ) ,
602+ ..Default :: default ( )
603+ }
604+ . authorize ( state, principal)
605+ . await
467606 }
468607 async fn execute (
469608 state : & Arc < ServerState > ,
0 commit comments