From caab63596a71de2a7c8fccddcb985914a26d1d49 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Mon, 26 Oct 2026 03:17:00 +0100 Subject: [PATCH] webhook tokens of tombstoned users rejected --- .../repositories/watch_event/webhook_token.rs | 5 +- .../bootstrap/tests/account_deletion_test.rs | 58 +++++++++++++++++++ .../src/testing/fakes/store_watch_event.rs | 6 +- 3 files changed, 66 insertions(+), 3 deletions(-) diff --git a/crates/adapters/sqlite/src/repositories/watch_event/webhook_token.rs b/crates/adapters/sqlite/src/repositories/watch_event/webhook_token.rs index ccf772ae..ba6353c9 100644 --- a/crates/adapters/sqlite/src/repositories/watch_event/webhook_token.rs +++ b/crates/adapters/sqlite/src/repositories/watch_event/webhook_token.rs @@ -51,8 +51,9 @@ impl WebhookTokenRepository for SqliteWebhookTokenRepository { async fn find_by_token_hash(&self, hash: &str) -> Result, DomainError> { let row = sqlx::query( - "SELECT id, user_id, token_hash, provider, label, created_at, last_used_at \ - FROM webhook_tokens WHERE token_hash = ?", + "SELECT t.id, t.user_id, t.token_hash, t.provider, t.label, t.created_at, t.last_used_at \ + FROM webhook_tokens t JOIN users u ON u.id = t.user_id \ + WHERE t.token_hash = ? AND u.deleted_at IS NULL", ) .bind(hash) .fetch_optional(&self.pool) diff --git a/crates/bootstrap/tests/account_deletion_test.rs b/crates/bootstrap/tests/account_deletion_test.rs index b592df10..8b6fa13d 100644 --- a/crates/bootstrap/tests/account_deletion_test.rs +++ b/crates/bootstrap/tests/account_deletion_test.rs @@ -349,3 +349,61 @@ async fn the_service_actor_is_not_deletable_and_offers_no_delete_ui() { let (_, _, body) = send(&router, req).await; assert!(!body.contains("is_service_account"), "{body}"); } + +#[tokio::test] +async fn a_deleted_users_webhook_token_stops_ingesting_at_once() { + let (router, pool, alice, _bob) = two_users().await; + let (status, _, body) = send( + &router, + json_request( + Method::POST, + "/api/v1/settings/webhook-tokens", + Some(&alice.token), + serde_json::json!({ "provider": "jellyfin", "label": null }), + ), + ) + .await; + assert_eq!(status, 200, "{body}"); + let token = serde_json::from_str::(&body).unwrap()["token"] + .as_str() + .unwrap() + .to_string(); + + let hook = |token: &str| { + Request::builder() + .method(Method::POST) + .uri("/api/v1/webhooks/jellyfin") + .header(header::AUTHORIZATION, format!("Bearer {token}")) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from( + serde_json::json!({ + "NotificationType": "PlaybackStop", "ItemType": "Movie", + "Name": "Heat", "Year": 1995, "PlayedToCompletion": true, + "Provider_tmdb": "949" + }) + .to_string(), + )) + .unwrap() + }; + let events = || async { + sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM watch_events") + .fetch_one(&pool) + .await + .unwrap() + }; + + let (status, _, body) = send(&router, hook(&token)).await; + assert_eq!(status, 200, "live token: {body}"); + assert_eq!(events().await, 1); + + let (status, _, _) = send( + &router, + delete_json("/api/v1/users/me", &alice.token, PASSWORD), + ) + .await; + assert_eq!(status, 204); + + let (status, _, body) = send(&router, hook(&token)).await; + assert_eq!(status, 401, "{body}"); + assert_eq!(events().await, 1, "no event ingested after deletion"); +} diff --git a/crates/domain/src/testing/fakes/store_watch_event.rs b/crates/domain/src/testing/fakes/store_watch_event.rs index 750188fa..3673db76 100644 --- a/crates/domain/src/testing/fakes/store_watch_event.rs +++ b/crates/domain/src/testing/fakes/store_watch_event.rs @@ -99,7 +99,11 @@ impl WebhookTokenRepository for InMemoryStore { async fn find_by_token_hash(&self, hash: &str) -> Result, DomainError> { let store = self.webhook_tokens.read().unwrap(); - Ok(store.iter().find(|t| t.token_hash() == hash).cloned()) + let gone = self.tombstoned.read().unwrap(); + Ok(store + .iter() + .find(|t| t.token_hash() == hash && !gone.contains(&t.user_id().value())) + .cloned()) } async fn list_by_user(&self, user_id: &UserId) -> Result, DomainError> {