webhook tokens of tombstoned users rejected

This commit is contained in:
2026-10-26 03:17:00 +01:00
parent c9e74d9cc3
commit caab63596a
3 changed files with 66 additions and 3 deletions

View File

@@ -51,8 +51,9 @@ impl WebhookTokenRepository for SqliteWebhookTokenRepository {
async fn find_by_token_hash(&self, hash: &str) -> Result<Option<WebhookToken>, DomainError> { async fn find_by_token_hash(&self, hash: &str) -> Result<Option<WebhookToken>, DomainError> {
let row = sqlx::query( let row = sqlx::query(
"SELECT id, user_id, token_hash, provider, label, created_at, last_used_at \ "SELECT t.id, t.user_id, t.token_hash, t.provider, t.label, t.created_at, t.last_used_at \
FROM webhook_tokens WHERE token_hash = ?", 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) .bind(hash)
.fetch_optional(&self.pool) .fetch_optional(&self.pool)

View File

@@ -349,3 +349,61 @@ async fn the_service_actor_is_not_deletable_and_offers_no_delete_ui() {
let (_, _, body) = send(&router, req).await; let (_, _, body) = send(&router, req).await;
assert!(!body.contains("is_service_account"), "{body}"); 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::<serde_json::Value>(&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");
}

View File

@@ -99,7 +99,11 @@ impl WebhookTokenRepository for InMemoryStore {
async fn find_by_token_hash(&self, hash: &str) -> Result<Option<WebhookToken>, DomainError> { async fn find_by_token_hash(&self, hash: &str) -> Result<Option<WebhookToken>, DomainError> {
let store = self.webhook_tokens.read().unwrap(); 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<Vec<WebhookToken>, DomainError> { async fn list_by_user(&self, user_id: &UserId) -> Result<Vec<WebhookToken>, DomainError> {