simple-module-sharing 0.0.38__tar.gz → 0.0.40__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 (38) hide show
  1. {simple_module_sharing-0.0.38 → simple_module_sharing-0.0.40}/PKG-INFO +5 -5
  2. {simple_module_sharing-0.0.38 → simple_module_sharing-0.0.40}/package.json +1 -1
  3. {simple_module_sharing-0.0.38 → simple_module_sharing-0.0.40}/pyproject.toml +5 -5
  4. {simple_module_sharing-0.0.38 → simple_module_sharing-0.0.40}/sharing/locales/de.json +2 -1
  5. {simple_module_sharing-0.0.38 → simple_module_sharing-0.0.40}/sharing/locales/en.json +2 -1
  6. {simple_module_sharing-0.0.38 → simple_module_sharing-0.0.40}/sharing/module.py +14 -0
  7. {simple_module_sharing-0.0.38 → simple_module_sharing-0.0.40}/sharing/pages/Admin.tsx +8 -0
  8. {simple_module_sharing-0.0.38 → simple_module_sharing-0.0.40}/sharing/service.py +14 -7
  9. simple_module_sharing-0.0.40/sharing/tenant_cleanup.py +16 -0
  10. simple_module_sharing-0.0.40/sharing/user_cleanup.py +25 -0
  11. {simple_module_sharing-0.0.38 → simple_module_sharing-0.0.40}/tests/test_sharing_service.py +82 -0
  12. simple_module_sharing-0.0.40/tests/test_sharing_tenant_cleanup.py +43 -0
  13. simple_module_sharing-0.0.40/tests-js/sharing_Admin.test.tsx +110 -0
  14. simple_module_sharing-0.0.40/tests-js/sharing_Admin_errors.test.tsx +61 -0
  15. {simple_module_sharing-0.0.38 → simple_module_sharing-0.0.40}/.gitignore +0 -0
  16. {simple_module_sharing-0.0.38 → simple_module_sharing-0.0.40}/LICENSE +0 -0
  17. {simple_module_sharing-0.0.38 → simple_module_sharing-0.0.40}/README.md +0 -0
  18. {simple_module_sharing-0.0.38 → simple_module_sharing-0.0.40}/sharing/__init__.py +0 -0
  19. {simple_module_sharing-0.0.38 → simple_module_sharing-0.0.40}/sharing/audience.py +0 -0
  20. {simple_module_sharing-0.0.38 → simple_module_sharing-0.0.40}/sharing/constants.py +0 -0
  21. {simple_module_sharing-0.0.38 → simple_module_sharing-0.0.40}/sharing/contracts/__init__.py +0 -0
  22. {simple_module_sharing-0.0.38 → simple_module_sharing-0.0.40}/sharing/contracts/schemas.py +0 -0
  23. {simple_module_sharing-0.0.38 → simple_module_sharing-0.0.40}/sharing/deps.py +0 -0
  24. {simple_module_sharing-0.0.38 → simple_module_sharing-0.0.40}/sharing/endpoints/__init__.py +0 -0
  25. {simple_module_sharing-0.0.38 → simple_module_sharing-0.0.40}/sharing/endpoints/api.py +0 -0
  26. {simple_module_sharing-0.0.38 → simple_module_sharing-0.0.40}/sharing/endpoints/views.py +0 -0
  27. {simple_module_sharing-0.0.38 → simple_module_sharing-0.0.40}/sharing/models.py +0 -0
  28. {simple_module_sharing-0.0.38 → simple_module_sharing-0.0.40}/sharing/pages/components/ShareTable.tsx +0 -0
  29. {simple_module_sharing-0.0.38 → simple_module_sharing-0.0.40}/sharing/pages/types.ts +0 -0
  30. {simple_module_sharing-0.0.38 → simple_module_sharing-0.0.40}/sharing/policy.py +0 -0
  31. {simple_module_sharing-0.0.38 → simple_module_sharing-0.0.40}/sharing/present.py +0 -0
  32. {simple_module_sharing-0.0.38 → simple_module_sharing-0.0.40}/sharing/py.typed +0 -0
  33. {simple_module_sharing-0.0.38 → simple_module_sharing-0.0.40}/sharing/services.py +0 -0
  34. {simple_module_sharing-0.0.38 → simple_module_sharing-0.0.40}/sharing/settings.py +0 -0
  35. {simple_module_sharing-0.0.38 → simple_module_sharing-0.0.40}/tests/_sharing_support.py +0 -0
  36. {simple_module_sharing-0.0.38 → simple_module_sharing-0.0.40}/tests/test_sharing_http.py +0 -0
  37. {simple_module_sharing-0.0.38 → simple_module_sharing-0.0.40}/tests/test_sharing_tenant_audience.py +0 -0
  38. {simple_module_sharing-0.0.38 → simple_module_sharing-0.0.40}/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.40
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.40
15
+ Requires-Dist: simple-module-db==0.0.40
16
+ Requires-Dist: simple-module-hosting==0.0.40
17
+ Requires-Dist: simple-module-inertia==0.0.40
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.40",
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.40"
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.40",
13
+ "simple_module_db==0.0.40",
14
+ "simple_module_hosting==0.0.40",
15
+ "simple_module_inertia==0.0.40",
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,19 @@ 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
+
38
+ from simple_module_core.user_events import UserDeleted
39
+
40
+ from sharing.user_cleanup import purge_user_shares
41
+
42
+ bus.subscribe_durable(UserDeleted, purge_user_shares)
43
+
30
44
  def register_settings(self, app: FastAPI) -> None:
31
45
  from sharing.services import SharingServices
32
46
  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);
@@ -9,9 +9,10 @@ grantee is someone the user directory knows. Rows are tenant-scoped by
9
9
  from __future__ import annotations
10
10
 
11
11
  import uuid
12
- from datetime import UTC, datetime
13
12
  from typing import Any
14
13
 
14
+ from simple_module_core.datetimes import aware as _aware
15
+ from simple_module_core.datetimes import utcnow
15
16
  from simple_module_core.resources import (
16
17
  ACTION_SHARE,
17
18
  ResourcePrincipal,
@@ -20,6 +21,7 @@ from simple_module_core.resources import (
20
21
  )
21
22
  from simple_module_core.user_directory import UserDirectory
22
23
  from simple_module_db.resource_policy import load_authorized
24
+ from sqlalchemy.exc import IntegrityError
23
25
  from sqlalchemy.ext.asyncio import AsyncSession
24
26
  from sqlmodel import select
25
27
 
@@ -49,10 +51,6 @@ class SharingError(Exception):
49
51
  self.detail = detail
50
52
 
51
53
 
52
- def _aware(value: datetime) -> datetime:
53
- return value if value.tzinfo is not None else value.replace(tzinfo=UTC)
54
-
55
-
56
54
  class SharingService:
57
55
  def __init__(
58
56
  self,
@@ -93,7 +91,7 @@ class SharingService:
93
91
  resource_type = self._type(data.resource_type)
94
92
  if data.role not in resource_type.shareable_actions:
95
93
  raise SharingError(422, ERR_BAD_ROLE)
96
- if data.expires_at is not None and _aware(data.expires_at) <= datetime.now(UTC):
94
+ if data.expires_at is not None and _aware(data.expires_at) <= utcnow():
97
95
  raise SharingError(422, ERR_EXPIRED)
98
96
  row = await self._authorize(principal, resource_type, data.resource_id)
99
97
  grantee = (await self.directory.get_many(self.db, [data.subject_id])).get(data.subject_id)
@@ -112,8 +110,17 @@ class SharingService:
112
110
  subject_id=grantee.id,
113
111
  role=data.role,
114
112
  granted_by=principal.user_id or "",
113
+ expires_at=data.expires_at,
115
114
  )
116
- self.db.add(share)
115
+ try:
116
+ async with self.db.begin_nested():
117
+ self.db.add(share)
118
+ await self.db.flush()
119
+ except IntegrityError:
120
+ # Another grant may have won the unique-key race.
121
+ share = await self._existing(data.resource_type, resource_id, grantee.id)
122
+ if share is None:
123
+ raise
117
124
  share.role = data.role
118
125
  share.expires_at = data.expires_at
119
126
  share.granted_by = principal.user_id or ""
@@ -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
+ )
@@ -0,0 +1,25 @@
1
+ """Revoke shares addressed to a deleted principal across tenants."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from simple_module_core.durable_events import DurableContext
8
+ from simple_module_db import all_tenants
9
+ from sqlmodel import col, delete
10
+
11
+ from sharing.constants import SUBJECT_USER
12
+ from sharing.models import ResourceShare
13
+
14
+ if TYPE_CHECKING:
15
+ from simple_module_core.user_events import UserDeleted
16
+
17
+
18
+ async def purge_user_shares(event: UserDeleted, ctx: DurableContext) -> None:
19
+ with all_tenants():
20
+ await ctx.db.execute(
21
+ delete(ResourceShare).where(
22
+ col(ResourceShare.subject_type) == SUBJECT_USER,
23
+ col(ResourceShare.subject_id) == str(event.user_id),
24
+ )
25
+ )
@@ -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,84 @@ 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
207
+
208
+
209
+ async def test_duplicate_insert_race_reuses_winner_and_preserves_transaction(env, monkeypatch):
210
+ service, note_id = env
211
+ with tenant_context("t1"):
212
+ winner = await service.grant(_who(OWNER), _grant(note_id))
213
+ original = service._existing
214
+ calls = 0
215
+
216
+ async def miss_once(*args):
217
+ nonlocal calls
218
+ calls += 1
219
+ return None if calls == 1 else await original(*args)
220
+
221
+ # Simulate a concurrent winner becoming visible after our initial lookup.
222
+ monkeypatch.setattr(service, "_existing", miss_once)
223
+ result = await service.grant(_who(OWNER), _grant(note_id, role=ACTION_EDIT))
224
+ assert result.id == winner.id
225
+ assert result.role == ACTION_EDIT
226
+ assert len(await service.for_resource(_who(OWNER), NOTE_TYPE, note_id)) == 1
227
+ assert await _visible(service.db, FRIEND, ACTION_EDIT) == ["n"]
@@ -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
+ );