77
88from sentry .conf .server import SENTRY_SCOPE_HIERARCHY_MAPPING , SENTRY_SCOPES
99from sentry .hybridcloud .models import ApiTokenReplica
10+ from sentry .hybridcloud .models .outbox import ControlOutbox
11+ from sentry .hybridcloud .outbox .category import OutboxCategory , OutboxScope
1012from sentry .models .apitoken import ApiToken , NotSupported , PlaintextSecretAlreadyRead
1113from sentry .sentry_apps .models .sentry_app_installation import SentryAppInstallation
1214from sentry .sentry_apps .models .sentry_app_installation_token import SentryAppInstallationToken
1315from sentry .silo .base import SiloMode
1416from sentry .testutils .cases import TestCase
17+ from sentry .testutils .helpers .options import override_options
1518from sentry .testutils .outbox import outbox_runner
1619from sentry .testutils .silo import assume_test_silo_mode , control_silo_test
1720from sentry .types .token import AuthTokenType
@@ -51,9 +54,10 @@ def test_enforces_scope_hierarchy(self) -> None:
5154 assert set (token .get_scopes ()) == SENTRY_SCOPE_HIERARCHY_MAPPING [scope ]
5255
5356 def test_organization_id_for_non_internal (self ) -> None :
54- install = self .create_sentry_app_installation ()
55- token = install .api_token
56- org_id = token .organization_id
57+ with outbox_runner ():
58+ install = self .create_sentry_app_installation ()
59+ token = install .api_token
60+ org_id = token .organization_id
5761
5862 with assume_test_silo_mode (SiloMode .REGION ):
5963 assert ApiTokenReplica .objects .get (apitoken_id = token .id ).organization_id == org_id
@@ -63,6 +67,7 @@ def test_organization_id_for_non_internal(self) -> None:
6367
6468 with assume_test_silo_mode (SiloMode .REGION ):
6569 assert ApiTokenReplica .objects .get (apitoken_id = token .id ).organization_id is None
70+
6671 assert token .organization_id is None
6772
6873 def test_last_chars_are_set (self ) -> None :
@@ -143,13 +148,15 @@ def test_default_string_serialization(self) -> None:
143148
144149 def test_replica_string_serialization (self ) -> None :
145150 user = self .create_user ()
146- token = ApiToken .objects .create (user_id = user .id )
147- with assume_test_silo_mode (SiloMode .REGION ):
148- replica = ApiTokenReplica .objects .get (apitoken_id = token .id )
149- assert (
150- f"{ replica } is swug"
151- == f"replica_token_id={ replica .id } , token_id={ token .id } is swug"
152- )
151+ with outbox_runner ():
152+ token = ApiToken .objects .create (user_id = user .id )
153+
154+ with assume_test_silo_mode (SiloMode .REGION ):
155+ replica = ApiTokenReplica .objects .get (apitoken_id = token .id )
156+ assert (
157+ f"{ replica } is swug"
158+ == f"replica_token_id={ replica .id } , token_id={ token .id } is swug"
159+ )
153160
154161 def test_delete_token_removes_replica (self ) -> None :
155162 user = self .create_user ()
@@ -186,6 +193,90 @@ def test_handle_async_deletion_called(self, mock_delete_replica: mock.MagicMock)
186193 region_name = mock .ANY ,
187194 )
188195
196+ @override_options ({"api-token-async-flush" : True })
197+ def test_outboxes_created_with_default_flush_false (self ) -> None :
198+ user = self .create_user ()
199+
200+ token = ApiToken .objects .create (user_id = user .id )
201+
202+ outboxes = ControlOutbox .objects .filter (
203+ shard_scope = OutboxScope .USER_SCOPE ,
204+ shard_identifier = user .id ,
205+ category = OutboxCategory .API_TOKEN_UPDATE ,
206+ object_identifier = token .id ,
207+ )
208+ assert outboxes .exists ()
209+ assert outboxes .count () > 0
210+
211+ with assume_test_silo_mode (SiloMode .REGION ):
212+ assert not ApiTokenReplica .objects .filter (apitoken_id = token .id ).exists ()
213+
214+ @override_options ({"api-token-async-flush" : True })
215+ def test_outboxes_created_on_update_with_async_flush (self ) -> None :
216+ user = self .create_user ()
217+
218+ with outbox_runner ():
219+ token = ApiToken .objects .create (user_id = user .id )
220+
221+ updated_expires_at = timezone .now () + timedelta (days = 30 )
222+ token .update (expires_at = updated_expires_at )
223+
224+ outboxes = ControlOutbox .objects .filter (
225+ shard_scope = OutboxScope .USER_SCOPE ,
226+ shard_identifier = user .id ,
227+ category = OutboxCategory .API_TOKEN_UPDATE ,
228+ object_identifier = token .id ,
229+ )
230+ assert outboxes .exists ()
231+ assert outboxes .count () > 0
232+
233+ with assume_test_silo_mode (SiloMode .REGION ):
234+ replica = ApiTokenReplica .objects .get (apitoken_id = token .id )
235+ assert replica .expires_at != updated_expires_at
236+
237+ @override_options ({"api-token-async-flush" : True })
238+ def test_async_replication_creates_replica_after_processing (self ) -> None :
239+ user = self .create_user ()
240+
241+ with self .tasks ():
242+ token = ApiToken .objects .create (user_id = user .id )
243+
244+ # Verify outboxes were processed (should be deleted after processing)
245+ remaining_outboxes = ControlOutbox .objects .filter (
246+ shard_scope = OutboxScope .USER_SCOPE ,
247+ shard_identifier = user .id ,
248+ category = OutboxCategory .API_TOKEN_UPDATE ,
249+ object_identifier = token .id ,
250+ )
251+ assert not remaining_outboxes .exists ()
252+
253+ with assume_test_silo_mode (SiloMode .REGION ):
254+ replica = ApiTokenReplica .objects .get (apitoken_id = token .id )
255+ assert replica .hashed_token == token .hashed_token
256+ assert replica .user_id == user .id
257+
258+ @override_options ({"api-token-async-flush" : True })
259+ def test_async_replication_updates_existing_replica (self ) -> None :
260+ user = self .create_user ()
261+ initial_expires_at = timezone .now () + timedelta (days = 1 )
262+ updated_expires_at = timezone .now () + timedelta (days = 30 )
263+
264+ with self .tasks ():
265+ token = ApiToken .objects .create (user_id = user .id , expires_at = initial_expires_at )
266+
267+ with assume_test_silo_mode (SiloMode .REGION ):
268+ replica = ApiTokenReplica .objects .get (apitoken_id = token .id )
269+ assert replica .expires_at is not None
270+ assert abs ((replica .expires_at - initial_expires_at ).total_seconds ()) < 1
271+
272+ with self .tasks ():
273+ token .update (expires_at = updated_expires_at )
274+
275+ with assume_test_silo_mode (SiloMode .REGION ):
276+ replica = ApiTokenReplica .objects .get (apitoken_id = token .id )
277+ assert replica .expires_at is not None
278+ assert abs ((replica .expires_at - updated_expires_at ).total_seconds ()) < 1
279+
189280
190281@control_silo_test
191282class ApiTokenInternalIntegrationTest (TestCase ):
@@ -223,3 +314,27 @@ def test_multiple_tokens_have_correct_organization_id(self) -> None:
223314 with assume_test_silo_mode (SiloMode .REGION ):
224315 assert ApiTokenReplica .objects .get (apitoken_id = token_1 .id ).organization_id is None
225316 assert ApiTokenReplica .objects .get (apitoken_id = token_2 .id ).organization_id is None
317+
318+ @override_options ({"api-token-async-flush" : True })
319+ @mock .patch ("sentry.hybridcloud.tasks.deliver_from_outbox.drain_outbox_shards_control.delay" )
320+ def test_async_replication_schedules_drain_task (self , mock_drain_task ) -> None :
321+ user = self .create_user ()
322+
323+ token = ApiToken .objects .create (user_id = user .id )
324+
325+ assert mock_drain_task .called
326+ call_args = mock_drain_task .call_args
327+ assert call_args .kwargs ["outbox_name" ] == "sentry.ControlOutbox"
328+
329+ outboxes = ControlOutbox .objects .filter (
330+ shard_scope = OutboxScope .USER_SCOPE ,
331+ shard_identifier = user .id ,
332+ category = OutboxCategory .API_TOKEN_UPDATE ,
333+ object_identifier = token .id ,
334+ )
335+ assert outboxes .exists ()
336+
337+ # Verify the task was called with the correct ID range
338+ outbox_ids = list (outboxes .values_list ("id" , flat = True ))
339+ assert call_args .kwargs ["outbox_identifier_low" ] == min (outbox_ids )
340+ assert call_args .kwargs ["outbox_identifier_hi" ] == max (outbox_ids ) + 1
0 commit comments