@@ -207,15 +207,23 @@ async fn delete_device(
207207 return Err ( StatusCode :: BAD_REQUEST ) ;
208208 }
209209
210- // Remove from DB.
211- let _ = crate :: db:: DeviceRow :: delete ( db, & target_device_id)
210+ // Revoke the target's auth tokens before removing its device row. The DB
211+ // helper also scopes the deletion to this account and performs both writes
212+ // atomically so a guessed device id cannot affect another account.
213+ let deleted = crate :: db:: DeviceRow :: delete_for_user ( db, & user_id, & target_device_id)
212214 . await
213- . map_err ( |_| StatusCode :: INTERNAL_SERVER_ERROR ) ?;
214-
215- // Revoke any auth tokens belonging to the removed device.
216- let _ = crate :: db:: AuthToken :: revoke_by_device ( db, & target_device_id)
217- . await
218- . map_err ( |_| StatusCode :: INTERNAL_SERVER_ERROR ) ?;
215+ . map_err ( |error| {
216+ tracing:: error!(
217+ user_id = %user_id,
218+ target_device_id = %target_device_id,
219+ %error,
220+ "Failed to delete account device"
221+ ) ;
222+ StatusCode :: INTERNAL_SERVER_ERROR
223+ } ) ?;
224+ if !deleted {
225+ return Err ( StatusCode :: NOT_FOUND ) ;
226+ }
219227
220228 // Disconnect active WS session if any.
221229 state
@@ -225,3 +233,123 @@ async fn delete_device(
225233 tracing:: info!( "Device {target_device_id} removed from account {user_id}" ) ;
226234 Ok ( StatusCode :: NO_CONTENT )
227235}
236+
237+ #[ cfg( test) ]
238+ mod tests {
239+ use super :: * ;
240+ use crate :: db:: { connect, AuthToken , DbPool , DeviceRow , UserRow } ;
241+ use crate :: relay:: RoomManager ;
242+ use crate :: MemoryAssetStore ;
243+ use axum:: body:: Body ;
244+ use axum:: http:: Request ;
245+ use std:: sync:: Arc ;
246+ use tower:: ServiceExt ;
247+
248+ struct TestContext {
249+ app : axum:: Router ,
250+ db : Arc < DbPool > ,
251+ owner_token : String ,
252+ target_token : String ,
253+ other_token : String ,
254+ }
255+
256+ async fn setup_app ( ) -> TestContext {
257+ let db = Arc :: new ( connect ( ":memory:" ) . await . unwrap ( ) ) ;
258+ UserRow :: create ( & db, "owner" , "alice" , "s" , "ks" , "{}" , "hash" , "wmk" )
259+ . await
260+ . unwrap ( ) ;
261+ UserRow :: create ( & db, "other" , "bob" , "s" , "ks" , "{}" , "hash" , "wmk" )
262+ . await
263+ . unwrap ( ) ;
264+ DeviceRow :: upsert ( & db, "owner-device" , "owner" , "Owner" , None )
265+ . await
266+ . unwrap ( ) ;
267+ DeviceRow :: upsert ( & db, "target-device" , "owner" , "Target" , None )
268+ . await
269+ . unwrap ( ) ;
270+ DeviceRow :: upsert ( & db, "other-device" , "other" , "Other" , None )
271+ . await
272+ . unwrap ( ) ;
273+
274+ let owner_token = AuthToken :: create ( & db, "owner" , "owner-device" )
275+ . await
276+ . unwrap ( )
277+ . token ;
278+ let target_token = AuthToken :: create ( & db, "owner" , "target-device" )
279+ . await
280+ . unwrap ( )
281+ . token ;
282+ let other_token = AuthToken :: create ( & db, "other" , "other-device" )
283+ . await
284+ . unwrap ( )
285+ . token ;
286+ let app = crate :: build_relay_router (
287+ RoomManager :: new ( ) ,
288+ Arc :: new ( MemoryAssetStore :: new ( ) ) ,
289+ std:: time:: Instant :: now ( ) ,
290+ Some ( db. clone ( ) ) ,
291+ "test" ,
292+ ) ;
293+
294+ TestContext {
295+ app,
296+ db,
297+ owner_token,
298+ target_token,
299+ other_token,
300+ }
301+ }
302+
303+ async fn delete ( app : & axum:: Router , token : & str , device_id : & str ) -> StatusCode {
304+ app. clone ( )
305+ . oneshot (
306+ Request :: builder ( )
307+ . method ( "DELETE" )
308+ . uri ( format ! ( "/api/devices/{device_id}" ) )
309+ . header ( header:: AUTHORIZATION , format ! ( "Bearer {token}" ) )
310+ . body ( Body :: empty ( ) )
311+ . unwrap ( ) ,
312+ )
313+ . await
314+ . unwrap ( )
315+ . status ( )
316+ }
317+
318+ #[ tokio:: test]
319+ async fn deleting_owned_device_revokes_token_before_device_row ( ) {
320+ let ctx = setup_app ( ) . await ;
321+
322+ let status = delete ( & ctx. app , & ctx. owner_token , "target-device" ) . await ;
323+
324+ assert_eq ! ( status, StatusCode :: NO_CONTENT ) ;
325+ assert ! ( AuthToken :: find( & ctx. db, & ctx. target_token)
326+ . await
327+ . unwrap( )
328+ . is_none( ) ) ;
329+ let devices = DeviceRow :: list_by_user ( & ctx. db , "owner" ) . await . unwrap ( ) ;
330+ assert_eq ! ( devices. len( ) , 1 ) ;
331+ assert_eq ! ( devices[ 0 ] . device_id, "owner-device" ) ;
332+ }
333+
334+ #[ tokio:: test]
335+ async fn deleting_own_or_another_accounts_device_is_rejected ( ) {
336+ let ctx = setup_app ( ) . await ;
337+
338+ assert_eq ! (
339+ delete( & ctx. app, & ctx. owner_token, "owner-device" ) . await ,
340+ StatusCode :: BAD_REQUEST
341+ ) ;
342+ assert_eq ! (
343+ delete( & ctx. app, & ctx. owner_token, "other-device" ) . await ,
344+ StatusCode :: NOT_FOUND
345+ ) ;
346+ assert ! ( AuthToken :: find( & ctx. db, & ctx. owner_token)
347+ . await
348+ . unwrap( )
349+ . is_some( ) ) ;
350+ assert ! ( AuthToken :: find( & ctx. db, & ctx. other_token)
351+ . await
352+ . unwrap( )
353+ . is_some( ) ) ;
354+ }
355+ }
0 commit comments