simple-module-sharing 0.0.38__tar.gz → 0.0.39__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. {simple_module_sharing-0.0.38 → simple_module_sharing-0.0.39}/PKG-INFO +5 -5
  2. {simple_module_sharing-0.0.38 → simple_module_sharing-0.0.39}/package.json +1 -1
  3. {simple_module_sharing-0.0.38 → simple_module_sharing-0.0.39}/pyproject.toml +5 -5
  4. {simple_module_sharing-0.0.38 → simple_module_sharing-0.0.39}/sharing/locales/de.json +2 -1
  5. {simple_module_sharing-0.0.38 → simple_module_sharing-0.0.39}/sharing/locales/en.json +2 -1
  6. {simple_module_sharing-0.0.38 → simple_module_sharing-0.0.39}/sharing/module.py +8 -0
  7. {simple_module_sharing-0.0.38 → simple_module_sharing-0.0.39}/sharing/pages/Admin.tsx +8 -0
  8. simple_module_sharing-0.0.39/sharing/tenant_cleanup.py +16 -0
  9. {simple_module_sharing-0.0.38 → simple_module_sharing-0.0.39}/tests/test_sharing_service.py +61 -0
  10. simple_module_sharing-0.0.39/tests/test_sharing_tenant_cleanup.py +43 -0
  11. simple_module_sharing-0.0.39/tests-js/sharing_Admin.test.tsx +110 -0
  12. simple_module_sharing-0.0.39/tests-js/sharing_Admin_errors.test.tsx +61 -0
  13. {simple_module_sharing-0.0.38 → simple_module_sharing-0.0.39}/.gitignore +0 -0
  14. {simple_module_sharing-0.0.38 → simple_module_sharing-0.0.39}/LICENSE +0 -0
  15. {simple_module_sharing-0.0.38 → simple_module_sharing-0.0.39}/README.md +0 -0
  16. {simple_module_sharing-0.0.38 → simple_module_sharing-0.0.39}/sharing/__init__.py +0 -0
  17. {simple_module_sharing-0.0.38 → simple_module_sharing-0.0.39}/sharing/audience.py +0 -0
  18. {simple_module_sharing-0.0.38 → simple_module_sharing-0.0.39}/sharing/constants.py +0 -0
  19. {simple_module_sharing-0.0.38 → simple_module_sharing-0.0.39}/sharing/contracts/__init__.py +0 -0
  20. {simple_module_sharing-0.0.38 → simple_module_sharing-0.0.39}/sharing/contracts/schemas.py +0 -0
  21. {simple_module_sharing-0.0.38 → simple_module_sharing-0.0.39}/sharing/deps.py +0 -0
  22. {simple_module_sharing-0.0.38 → simple_module_sharing-0.0.39}/sharing/endpoints/__init__.py +0 -0
  23. {simple_module_sharing-0.0.38 → simple_module_sharing-0.0.39}/sharing/endpoints/api.py +0 -0
  24. {simple_module_sharing-0.0.38 → simple_module_sharing-0.0.39}/sharing/endpoints/views.py +0 -0
  25. {simple_module_sharing-0.0.38 → simple_module_sharing-0.0.39}/sharing/models.py +0 -0
  26. {simple_module_sharing-0.0.38 → simple_module_sharing-0.0.39}/sharing/pages/components/ShareTable.tsx +0 -0
  27. {simple_module_sharing-0.0.38 → simple_module_sharing-0.0.39}/sharing/pages/types.ts +0 -0
  28. {simple_module_sharing-0.0.38 → simple_module_sharing-0.0.39}/sharing/policy.py +0 -0
  29. {simple_module_sharing-0.0.38 → simple_module_sharing-0.0.39}/sharing/present.py +0 -0
  30. {simple_module_sharing-0.0.38 → simple_module_sharing-0.0.39}/sharing/py.typed +0 -0
  31. {simple_module_sharing-0.0.38 → simple_module_sharing-0.0.39}/sharing/service.py +0 -0
  32. {simple_module_sharing-0.0.38 → simple_module_sharing-0.0.39}/sharing/services.py +0 -0
  33. {simple_module_sharing-0.0.38 → simple_module_sharing-0.0.39}/sharing/settings.py +0 -0
  34. {simple_module_sharing-0.0.38 → simple_module_sharing-0.0.39}/tests/_sharing_support.py +0 -0
  35. {simple_module_sharing-0.0.38 → simple_module_sharing-0.0.39}/tests/test_sharing_http.py +0 -0
  36. {simple_module_sharing-0.0.38 → simple_module_sharing-0.0.39}/tests/test_sharing_tenant_audience.py +0 -0
  37. {simple_module_sharing-0.0.38 → simple_module_sharing-0.0.39}/tsconfig.json +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: simple_module_sharing
3
- Version: 0.0.38
3
+ Version: 0.0.39
4
4
  Summary: Generic resource shares (owner / shared-with-user) for simple_module resource-scoped access
5
5
  Project-URL: Homepage, https://github.com/antosubash/simple_module_python
6
6
  Project-URL: Repository, https://github.com/antosubash/simple_module_python
@@ -11,10 +11,10 @@ License-Expression: MIT
11
11
  License-File: LICENSE
12
12
  Keywords: access-control,permissions,sharing,simple-module
13
13
  Requires-Python: >=3.12
14
- Requires-Dist: simple-module-core==0.0.38
15
- Requires-Dist: simple-module-db==0.0.38
16
- Requires-Dist: simple-module-hosting==0.0.38
17
- Requires-Dist: simple-module-inertia==0.0.38
14
+ Requires-Dist: simple-module-core==0.0.39
15
+ Requires-Dist: simple-module-db==0.0.39
16
+ Requires-Dist: simple-module-hosting==0.0.39
17
+ Requires-Dist: simple-module-inertia==0.0.39
18
18
  Description-Content-Type: text/markdown
19
19
 
20
20
  # simple_module_sharing
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@simple-module-py/sharing",
3
- "version": "0.0.38",
3
+ "version": "0.0.39",
4
4
  "private": true,
5
5
  "description": "Frontend assets for the Sharing module",
6
6
  "peerDependencies": {
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "simple_module_sharing"
3
- version = "0.0.38"
3
+ version = "0.0.39"
4
4
  description = "Generic resource shares (owner / shared-with-user) for simple_module resource-scoped access"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -9,10 +9,10 @@ requires-python = ">=3.12"
9
9
  authors = [{ name = "Anto Subash", email = "antosubash@live.com" }]
10
10
  keywords = ["simple-module", "sharing", "permissions", "access-control"]
11
11
  dependencies = [
12
- "simple_module_core==0.0.38",
13
- "simple_module_db==0.0.38",
14
- "simple_module_hosting==0.0.38",
15
- "simple_module_inertia==0.0.38",
12
+ "simple_module_core==0.0.39",
13
+ "simple_module_db==0.0.39",
14
+ "simple_module_hosting==0.0.39",
15
+ "simple_module_inertia==0.0.39",
16
16
  ]
17
17
 
18
18
  [project.entry-points.simple_module]
@@ -28,6 +28,7 @@
28
28
  "title": "Diese Freigabe widerrufen?",
29
29
  "description": "Die Person verliert sofort den Zugriff auf die Ressource.",
30
30
  "confirm": "Widerrufen",
31
- "cancel": "Abbrechen"
31
+ "cancel": "Abbrechen",
32
+ "failed": "Die Freigabe konnte nicht widerrufen werden"
32
33
  }
33
34
  }
@@ -28,6 +28,7 @@
28
28
  "title": "Revoke this share?",
29
29
  "description": "The user loses access to the resource immediately.",
30
30
  "confirm": "Revoke",
31
- "cancel": "Cancel"
31
+ "cancel": "Cancel",
32
+ "failed": "Failed to revoke the share"
32
33
  }
33
34
  }
@@ -17,6 +17,7 @@ from sharing import constants as c
17
17
  if TYPE_CHECKING:
18
18
  from fastapi import APIRouter, FastAPI
19
19
  from simple_module_core.audit_links import AuditLinkRegistry
20
+ from simple_module_core.events import EventBus
20
21
  from simple_module_core.permissions import PermissionRegistry
21
22
 
22
23
 
@@ -27,6 +28,13 @@ class SharingModule(ModuleBase):
27
28
  view_prefix=c.VIEW_PREFIX,
28
29
  )
29
30
 
31
+ def register_event_handlers(self, bus: EventBus, app: FastAPI | None = None) -> None:
32
+ from simple_module_core.tenant_events import TenantDeleted
33
+
34
+ from sharing.tenant_cleanup import purge_tenant_data
35
+
36
+ bus.subscribe_durable(TenantDeleted, purge_tenant_data)
37
+
30
38
  def register_settings(self, app: FastAPI) -> None:
31
39
  from sharing.services import SharingServices
32
40
  from sharing.settings import SharingSettings
@@ -7,6 +7,7 @@ import { PaginationBar } from '@simple-module-py/ui/components/PaginationBar';
7
7
  import { AdminLayout } from '@simple-module-py/ui/layouts/AdminLayout';
8
8
  import { Building2, Trash2 } from 'lucide-react';
9
9
  import { useState } from 'react';
10
+ import { toast } from 'sonner';
10
11
  import { ShareTable } from './components/ShareTable';
11
12
  import type { Share } from './types';
12
13
 
@@ -37,11 +38,18 @@ function Admin(props: Props) {
37
38
  function confirmRevoke() {
38
39
  if (!pending) return;
39
40
  setBusy(true);
41
+ const onFailure = () => {
42
+ toast.error(t(keys.sharing.revoke.failed));
43
+ return false;
44
+ };
40
45
  router.post(
41
46
  `${BASE_URL}/${pending.id}/revoke`,
42
47
  {},
43
48
  {
44
49
  preserveScroll: true,
50
+ onError: onFailure,
51
+ onHttpException: onFailure,
52
+ onNetworkError: onFailure,
45
53
  onFinish: () => {
46
54
  setBusy(false);
47
55
  setPending(null);
@@ -0,0 +1,16 @@
1
+ """Durable teardown of tenant-owned sharing rows."""
2
+
3
+ from simple_module_core.durable_events import DurableContext
4
+ from simple_module_core.tenant_events import TenantDeleted
5
+ from simple_module_db.tenancy import tenant_context
6
+ from sqlmodel import delete
7
+
8
+ from sharing.models import ResourceShare
9
+
10
+
11
+ async def purge_tenant_data(event: TenantDeleted, ctx: DurableContext) -> None:
12
+ """Hard-delete tenant rows atomically with outbox acknowledgement."""
13
+ with tenant_context(event.tenant_id):
14
+ await ctx.db.execute(
15
+ delete(ResourceShare).where(ResourceShare.tenant_id == event.tenant_id)
16
+ )
@@ -14,6 +14,7 @@ from _sharing_support import (
14
14
  create_note_table,
15
15
  register_note_type,
16
16
  )
17
+ from sharing.constants import PERM_MANAGE
17
18
  from sharing.contracts.schemas import ShareGrant
18
19
  from sharing.models import ResourceShare
19
20
  from sharing.service import SharingError, SharingService
@@ -143,3 +144,63 @@ async def test_grantee_may_leave_and_owner_lists(env):
143
144
  await _refused(service.for_resource(_who(FRIEND), NOTE_TYPE, note_id), 403)
144
145
  await service.revoke(_who(FRIEND), share.id)
145
146
  assert await _visible(service.db, FRIEND) == []
147
+
148
+
149
+ @pytest.mark.parametrize("role", [ACTION_VIEW, ACTION_EDIT])
150
+ async def test_grantee_cannot_manage_shares_even_with_edit_role(env, role):
151
+ service, note_id = env
152
+ with tenant_context("t1"):
153
+ await service.grant(_who(OWNER), _grant(note_id, role=role))
154
+ reshare = _grant(note_id).model_copy(update={"subject_id": STRANGER})
155
+ await _refused(service.grant(_who(FRIEND), reshare), 403)
156
+ await _refused(service.for_resource(_who(FRIEND), NOTE_TYPE, note_id), 403)
157
+ assert len(await service.for_resource(_who(OWNER), NOTE_TYPE, note_id)) == 1
158
+
159
+
160
+ @pytest.mark.parametrize("actor", [OWNER, FRIEND, "admin"])
161
+ async def test_real_share_id_cannot_be_revoked_across_tenants(env, actor):
162
+ service, note_id = env
163
+ who = (
164
+ ResourcePrincipal(user_id=actor, permissions=frozenset({PERM_MANAGE}))
165
+ if actor == "admin"
166
+ else _who(actor)
167
+ )
168
+ with tenant_context("t1"):
169
+ share = await service.grant(_who(OWNER), _grant(note_id))
170
+ with tenant_context("t2"):
171
+ await _refused(service.revoke(who, share.id), 404)
172
+ with tenant_context("t1"):
173
+ assert [s.id for s in await service.for_resource(_who(OWNER), NOTE_TYPE, note_id)] == [
174
+ share.id
175
+ ]
176
+ assert await _visible(service.db, FRIEND) == ["n"]
177
+
178
+
179
+ async def test_admin_may_revoke_without_resource_ownership(env):
180
+ service, note_id = env
181
+ admin = ResourcePrincipal(user_id=STRANGER, permissions=frozenset({PERM_MANAGE}))
182
+ with tenant_context("t1"):
183
+ share = await service.grant(_who(OWNER), _grant(note_id))
184
+ await service.revoke(admin, share.id)
185
+ assert await service.for_resource(_who(OWNER), NOTE_TYPE, note_id) == []
186
+ assert await _visible(service.db, FRIEND) == []
187
+ await _refused(service.revoke(admin, share.id), 404)
188
+
189
+
190
+ async def test_expired_share_can_be_renewed_without_duplicate_grants(env):
191
+ service, note_id = env
192
+ with tenant_context("t1"):
193
+ share = await service.grant(_who(OWNER), _grant(note_id))
194
+ share.expires_at = datetime.now(UTC) - timedelta(days=1)
195
+ await service.db.flush()
196
+ assert await _visible(service.db, FRIEND) == []
197
+ assert [s.id for s in await service.for_resource(_who(OWNER), NOTE_TYPE, note_id)] == [
198
+ share.id
199
+ ]
200
+ future = datetime.now(UTC) + timedelta(days=1)
201
+ renewed = await service.grant(
202
+ _who(OWNER), _grant(note_id, expires_at=future, role=ACTION_EDIT)
203
+ )
204
+ assert renewed.id == share.id
205
+ assert await _visible(service.db, FRIEND, ACTION_EDIT) == ["n"]
206
+ assert len(await service.for_resource(_who(OWNER), NOTE_TYPE, note_id)) == 1
@@ -0,0 +1,43 @@
1
+ """Tenant deletion purges only the deleted tenant through a durable handler."""
2
+
3
+ from sharing.models import ResourceShare
4
+ from sharing.module import SharingModule
5
+ from simple_module_core.durable_events import DurableContext, event_key
6
+ from simple_module_core.events import EventBus
7
+ from simple_module_core.tenant_events import TenantDeleted
8
+ from simple_module_db.tenancy import all_tenants, tenant_context
9
+ from sqlmodel import select
10
+
11
+
12
+ async def test_durable_tenant_cleanup_is_scoped_idempotent_and_transactional(db_session):
13
+ bus = EventBus()
14
+ SharingModule().register_event_handlers(bus)
15
+ names = bus.durable.handler_names(TenantDeleted)
16
+ assert len(names) == 1
17
+ subscription = bus.durable.lookup(event_key(TenantDeleted), names[0])
18
+ assert subscription is not None
19
+ for tenant_id in ("deleted-tenant", "other-tenant"):
20
+ with tenant_context(tenant_id):
21
+ db_session.add(
22
+ ResourceShare(
23
+ resource_type="test.note",
24
+ resource_id="note",
25
+ subject_id="user",
26
+ role="view",
27
+ granted_by="owner",
28
+ )
29
+ )
30
+ await db_session.flush()
31
+ await db_session.commit()
32
+ event = TenantDeleted(tenant_id="deleted-tenant", slug="deleted", name="Deleted")
33
+ ctx = DurableContext(db=db_session, attempt=1)
34
+ # A worker rollback must restore rows: the handler must never commit its session.
35
+ await subscription.handler(event, ctx)
36
+ await db_session.rollback()
37
+ with all_tenants():
38
+ assert len((await db_session.execute(select(ResourceShare))).scalars().all()) == 2
39
+ await subscription.handler(event, ctx)
40
+ await subscription.handler(event, DurableContext(db=db_session, attempt=2))
41
+ with all_tenants():
42
+ remaining = (await db_session.execute(select(ResourceShare))).scalars().all()
43
+ assert [row.tenant_id for row in remaining] == ["other-tenant"]
@@ -0,0 +1,110 @@
1
+ import { configureI18n, keys } from '@simple-module-py/i18n';
2
+ import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
3
+ import type { ComponentProps } from 'react';
4
+ import { afterEach, describe, expect, it, vi } from 'vitest';
5
+
6
+ configureI18n({ locale: 'en', messages: {} });
7
+ const { post, get } = vi.hoisted(() => ({ post: vi.fn(), get: vi.fn() }));
8
+ vi.mock('@inertiajs/react', () => ({
9
+ Head: () => null,
10
+ router: { post, get },
11
+ usePage: () => ({ props: {} }),
12
+ }));
13
+
14
+ import Admin from '../sharing/pages/Admin';
15
+
16
+ const props: ComponentProps<typeof Admin> = {
17
+ shares: [
18
+ {
19
+ id: 'share-1',
20
+ resource_type: 'notes.note',
21
+ resource_id: 'note-1',
22
+ subject_type: 'user',
23
+ subject_id: 'friend',
24
+ subject_label: 'Friend',
25
+ role: 'view',
26
+ granted_by: 'owner',
27
+ granted_by_label: 'Owner',
28
+ expires_at: null,
29
+ expired: false,
30
+ created_at: null,
31
+ },
32
+ ],
33
+ total: 21,
34
+ page: 2,
35
+ per_page: 10,
36
+ can_revoke: true,
37
+ tenant_required: false,
38
+ };
39
+
40
+ afterEach(() => vi.clearAllMocks());
41
+
42
+ describe('sharing Admin', () => {
43
+ it('renders labels and hides revoke from read-only viewers', () => {
44
+ render(<Admin {...props} can_revoke={false} />);
45
+ expect(screen.getByText('Friend')).toBeInTheDocument();
46
+ expect(screen.getByText('Owner')).toBeInTheDocument();
47
+ expect(screen.queryByRole('button', { name: keys.sharing.list.revoke })).toBeNull();
48
+ });
49
+
50
+ it('shows the tenant warning instead of shares and pagination when unbound', () => {
51
+ render(<Admin {...props} tenant_required />);
52
+ expect(screen.getByText(keys.sharing.admin.tenant_required)).toBeInTheDocument();
53
+ expect(screen.queryByText('Friend')).toBeNull();
54
+ expect(screen.queryByRole('button', { name: keys.ui.pagination.next })).toBeNull();
55
+ });
56
+
57
+ it('shows an empty state without pagination for an empty list', () => {
58
+ render(<Admin {...props} shares={[]} total={0} page={1} />);
59
+ expect(screen.getByText(keys.sharing.list.empty_title)).toBeInTheDocument();
60
+ expect(screen.queryByRole('button', { name: keys.ui.pagination.next })).toBeNull();
61
+ });
62
+
63
+ it.each([
64
+ [keys.ui.pagination.previous, 1],
65
+ [keys.ui.pagination.next, 3],
66
+ ])('navigates using %s', (name, page) => {
67
+ render(<Admin {...props} />);
68
+ fireEvent.click(screen.getByRole('button', { name }));
69
+ expect(get).toHaveBeenCalledWith('/admin/sharing/', { page }, { preserveScroll: true });
70
+ });
71
+
72
+ it.each([1, 3])('disables navigation beyond page %s', (page) => {
73
+ render(<Admin {...props} page={page} />);
74
+ const name = page === 1 ? keys.ui.pagination.previous : keys.ui.pagination.next;
75
+ expect(screen.getByRole('button', { name })).toBeDisabled();
76
+ });
77
+
78
+ it('cancels without writing and revokes only after confirmation', async () => {
79
+ render(<Admin {...props} />);
80
+ fireEvent.click(screen.getByRole('button', { name: keys.sharing.list.revoke }));
81
+ expect(post).not.toHaveBeenCalled();
82
+ fireEvent.click(await screen.findByRole('button', { name: keys.sharing.revoke.cancel }));
83
+ await waitFor(() => expect(screen.queryByText(keys.sharing.revoke.description)).toBeNull());
84
+ expect(post).not.toHaveBeenCalled();
85
+ fireEvent.click(screen.getByRole('button', { name: keys.sharing.list.revoke }));
86
+ fireEvent.click(await screen.findByRole('button', { name: keys.sharing.revoke.confirm }));
87
+ expect(post).toHaveBeenCalledWith(
88
+ '/admin/sharing/share-1/revoke',
89
+ {},
90
+ expect.objectContaining({
91
+ preserveScroll: true,
92
+ onFinish: expect.any(Function),
93
+ }),
94
+ );
95
+ expect(screen.getByRole('button', { name: keys.sharing.revoke.confirm })).toBeDisabled();
96
+ act(() => post.mock.calls[0][2].onFinish());
97
+ await waitFor(() => expect(screen.queryByText(keys.sharing.revoke.description)).toBeNull());
98
+ });
99
+
100
+ it('clears pending state on visit completion so an unchanged share can be retried', async () => {
101
+ render(<Admin {...props} />);
102
+ fireEvent.click(screen.getByRole('button', { name: keys.sharing.list.revoke }));
103
+ fireEvent.click(await screen.findByRole('button', { name: keys.sharing.revoke.confirm }));
104
+ act(() => post.mock.calls[0][2].onFinish());
105
+ await waitFor(() => expect(screen.queryByText(keys.sharing.revoke.description)).toBeNull());
106
+ expect(screen.getByText('Friend')).toBeInTheDocument();
107
+ fireEvent.click(screen.getByRole('button', { name: keys.sharing.list.revoke }));
108
+ expect(await screen.findByRole('button', { name: keys.sharing.revoke.confirm })).toBeEnabled();
109
+ });
110
+ });
@@ -0,0 +1,61 @@
1
+ import { configureI18n } from '@simple-module-py/i18n';
2
+ import { act, fireEvent, render, screen, within } from '@testing-library/react';
3
+ import { toast } from 'sonner';
4
+ import { beforeEach, expect, test, vi } from 'vitest';
5
+ import Admin from '../sharing/pages/Admin';
6
+
7
+ configureI18n({ locale: 'en', messages: {} });
8
+ const { post } = vi.hoisted(() => ({ post: vi.fn() }));
9
+ vi.mock('@inertiajs/react', () => ({
10
+ Head: () => null,
11
+ router: { post },
12
+ usePage: () => ({ props: {} }),
13
+ }));
14
+ vi.mock('sonner', () => ({ toast: { error: vi.fn() } }));
15
+
16
+ beforeEach(() => vi.clearAllMocks());
17
+
18
+ test.each(['onError', 'onHttpException', 'onNetworkError'])(
19
+ 'revoke failures show a toast and release the busy dialog (%s)',
20
+ (callback) => {
21
+ render(
22
+ <Admin
23
+ shares={[
24
+ {
25
+ id: 's1',
26
+ resource_type: 'order',
27
+ resource_id: 'o1',
28
+ subject_type: 'user',
29
+ subject_id: 'u1',
30
+ subject_label: null,
31
+ role: 'viewer',
32
+ granted_by: 'u2',
33
+ granted_by_label: null,
34
+ expires_at: null,
35
+ expired: false,
36
+ created_at: null,
37
+ },
38
+ ]}
39
+ total={1}
40
+ page={1}
41
+ per_page={20}
42
+ can_revoke
43
+ tenant_required={false}
44
+ />,
45
+ );
46
+ fireEvent.click(screen.getAllByRole('button', { name: 'sharing.list.revoke' })[0]);
47
+ fireEvent.click(
48
+ within(screen.getByRole('alertdialog')).getByRole('button', {
49
+ name: 'sharing.revoke.confirm',
50
+ }),
51
+ );
52
+ const [url, , options] = post.mock.calls[0];
53
+ expect(url).toBe('/admin/sharing/s1/revoke');
54
+ act(() => {
55
+ options[callback]({});
56
+ options.onFinish();
57
+ });
58
+ expect(toast.error).toHaveBeenCalledWith('sharing.revoke.failed');
59
+ expect(screen.queryByRole('alertdialog')).toBeNull();
60
+ },
61
+ );