Introduce workspace tenancy boundaries

This commit is contained in:
rucki
2026-09-09 17:46:10 +02:00
parent 7f203a7117
commit 291c8db471
11 changed files with 565 additions and 118 deletions
+87 -10
View File
@@ -9,7 +9,7 @@ from django.test import TestCase, override_settings
from django.urls import reverse
from django.utils import timezone
from .models import ApiKey, Attachment, Collection, CollectionSection, Item, Tag, Team, TeamMembership, UserPreference
from .models import ApiKey, Attachment, Collection, CollectionSection, Item, Tag, Team, TeamMembership, UserPreference, Workspace, WorkspaceMembership, get_or_create_personal_workspace
from .templatetags.markdown_extras import markdown, markdown_title
from .views import parse_due_command, parse_quick_content
@@ -40,6 +40,83 @@ class ItemMetadataTests(TestCase):
self.assertFalse(Tag.objects.filter(name='new').exists())
class WorkspaceTenancyTests(TestCase):
def test_same_tag_name_is_scoped_per_personal_workspace(self):
first_user = get_user_model().objects.create_user(username='tenant-a', password='secret')
second_user = get_user_model().objects.create_user(username='tenant-b', password='secret')
first = Item.objects.create(owner=first_user, content='One #shared')
first.sync_metadata()
second = Item.objects.create(owner=second_user, content='Two #shared')
second.sync_metadata()
self.assertEqual(Tag.objects.filter(name='shared').count(), 2)
self.assertNotEqual(first.tags.get(name='shared').workspace_id, second.tags.get(name='shared').workspace_id)
def test_same_team_tag_is_allowed_in_different_workspaces(self):
first_user = get_user_model().objects.create_user(username='team-a', password='secret')
second_user = get_user_model().objects.create_user(username='team-b', password='secret')
first = Team.objects.create(owner=first_user, name='Docs A', slug='docs')
second = Team.objects.create(owner=second_user, name='Docs B', slug='docs')
self.assertNotEqual(first.workspace_id, second.workspace_id)
def test_workspace_can_be_created_and_becomes_active(self):
user = get_user_model().objects.create_user(username='workspace-create', password='secret')
self.client.force_login(user)
response = self.client.post(reverse('settings'), {'action': 'workspace', 'name': 'Acme Inc', 'slug': 'acme'})
workspace = Workspace.objects.get(slug='acme')
self.assertRedirects(response, reverse('settings'))
self.assertEqual(self.client.session['workspace_id'], workspace.id)
self.assertTrue(WorkspaceMembership.objects.filter(workspace=workspace, user=user, role=WorkspaceMembership.Role.OWNER).exists())
def test_workspace_switch_changes_overview_scope(self):
user = get_user_model().objects.create_user(username='workspace-switch', password='secret')
personal = get_or_create_personal_workspace(user)
company = Workspace.objects.create(owner=user, name='Company', slug='company')
WorkspaceMembership.objects.create(workspace=company, user=user, role=WorkspaceMembership.Role.OWNER)
Item.objects.create(workspace=personal, owner=user, content='Personal item')
Item.objects.create(workspace=company, owner=user, content='Company item')
self.client.force_login(user)
response = self.client.post(reverse('workspace_switch', args=[company.id]), {'next': reverse('index')})
self.assertRedirects(response, reverse('index'))
response = self.client.get(reverse('index'))
self.assertContains(response, 'Company item')
self.assertNotContains(response, 'Personal item')
def test_workspace_public_item_is_visible_to_workspace_member_only(self):
owner = get_user_model().objects.create_user(username='workspace-owner', password='secret')
member = get_user_model().objects.create_user(username='workspace-member', password='secret')
outsider = get_user_model().objects.create_user(username='workspace-outsider', password='secret')
workspace = Workspace.objects.create(owner=owner, name='Company', slug='company-public')
WorkspaceMembership.objects.create(workspace=workspace, user=owner, role=WorkspaceMembership.Role.OWNER)
WorkspaceMembership.objects.create(workspace=workspace, user=member, role=WorkspaceMembership.Role.MEMBER)
Item.objects.create(workspace=workspace, owner=owner, content='Workspace public', visibility=Item.Visibility.PUBLIC)
self.client.force_login(member)
self.client.post(reverse('workspace_switch', args=[workspace.id]), {'next': reverse('index')})
self.assertContains(self.client.get(reverse('index')), 'Workspace public')
self.client.force_login(outsider)
self.assertNotContains(self.client.get(reverse('index')), 'Workspace public')
def test_authenticated_overview_is_limited_to_current_workspace(self):
user = get_user_model().objects.create_user(username='tenant-visible', password='secret')
other = get_user_model().objects.create_user(username='tenant-other', password='secret')
own = Item.objects.create(owner=user, content='Own item')
public_other = Item.objects.create(owner=other, content='Public other item', visibility=Item.Visibility.PUBLIC)
self.client.force_login(user)
response = self.client.get(reverse('index'))
self.assertContains(response, 'Own item')
self.assertNotContains(response, 'Public other item')
class DefaultItemKindTests(TestCase):
def setUp(self):
self.user = get_user_model().objects.create_user(username='tester', password='secret')
@@ -262,7 +339,7 @@ class NavigationAndLayoutTests(TestCase):
self.assertNotIn('#noteonly', filter_tags)
self.assertIn("'openonly'", html)
self.assertIn("'noteonly'", html)
self.assertIn("'publiconly'", html)
self.assertNotIn('publiconly', html)
self.assertNotIn('foreignonly', html)
response = self.client.get(f'{reverse("tag_list")}?status=archive')
@@ -283,7 +360,7 @@ class NavigationAndLayoutTests(TestCase):
self.assertEqual(response.status_code, 200)
tags = response.json()['tags']
self.assertIn('ownapi', tags)
self.assertIn('publicapi', tags)
self.assertNotIn('publicapi', tags)
self.assertNotIn('foreignapi', tags)
def test_due_filter_also_shows_next_seven_days_separately(self):
@@ -482,7 +559,7 @@ class CollectionTests(TestCase):
def test_collection_form_separates_teams_and_content_tags(self):
team = Team.objects.create(name='Docs team', slug='docs', owner=self.user)
TeamMembership.objects.create(team=team, user=self.user, role=TeamMembership.Role.OWNER)
Tag.objects.create(name='docs')
Tag.objects.create(workspace=get_or_create_personal_workspace(self.user), name='docs')
note = Item.objects.create(owner=self.user, content='#ordinary')
note.sync_metadata()
response = self.client.get(reverse('collection_create'))
@@ -801,15 +878,14 @@ class CollectionTests(TestCase):
self.assertRedirects(response, manage_url)
self.assertTrue(parent.sections.filter(sub_collection=child).exists())
def test_public_collection_is_visible_without_login(self):
def test_public_collection_is_workspace_public_not_anonymous(self):
collection = Collection.objects.create(owner=self.user, title='Public', visibility=Item.Visibility.PUBLIC)
self.client.logout()
response = self.client.get(collection.get_absolute_url())
self.assertEqual(response.status_code, 200)
self.assertNotContains(response, reverse('collection_manage', args=[collection.id]))
self.assertEqual(response.status_code, 404)
def test_collection_tag_is_not_removed_as_unused(self):
tag = Tag.objects.create(name='documentation')
tag = Tag.objects.create(workspace=get_or_create_personal_workspace(self.user), name='documentation')
collection = Collection.objects.create(owner=self.user, title='Tagged', mode=Collection.Mode.TAGS)
collection.filter_tags.add(tag)
note = Item.objects.create(owner=self.user, content='#temporary')
@@ -835,8 +911,9 @@ class CollectionTests(TestCase):
class ApiItemFilterTests(TestCase):
def setUp(self):
self.user = get_user_model().objects.create_user(username='api-filter', password='secret')
self.help_tag = Tag.objects.create(name='help')
self.api_key = ApiKey.objects.create(owner=self.user, name='Help CLI')
self.workspace = get_or_create_personal_workspace(self.user)
self.help_tag = Tag.objects.create(workspace=self.workspace, name='help')
self.api_key = ApiKey.objects.create(workspace=self.workspace, owner=self.user, name='Help CLI')
self.api_key.tags.add(self.help_tag)
def create_item(self, content):