diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aa5ae9bf..5098b2ac 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -59,12 +59,13 @@ jobs: with: apt-packages: 'gdal-bin' python-version: '3.12' - setup-node: true + setup-node: 'yes' - - name: Run tests + - name: Run Django tests (without Playwright) run: | + rm -rf static python src/manage.py collectstatic --noinput --link - coverage run src/manage.py test src + coverage run src/manage.py test src --exclude-tag=playwright env: DJANGO_SETTINGS_MODULE: objects.conf.ci DEBUG: 'true' @@ -76,6 +77,23 @@ jobs: CELERY_BROKER_URL: 'redis://localhost:6379/0' CELERY_ONCE_REDIS_URL: 'redis://localhost:6379/0' + - name: Install Playwright browsers + if: ${{ matrix.postgres == '17' && matrix.postgis == '3.5' && matrix.use_pooling == false }} + run: playwright install --with-deps chromium + + - name: Run Playwright tests + if: ${{ matrix.postgres == '17' && matrix.postgis == '3.5' && matrix.use_pooling == false }} + run: coverage run -a src/manage.py test src --tag=playwright + env: + DJANGO_SETTINGS_MODULE: objects.conf.ci + DEBUG: 'true' + SECRET_KEY: dummy + DB_USER: postgres + DB_PASSWORD: '' + DB_POOL_ENABLED: ${{ matrix.use_pooling }} + CELERY_BROKER_URL: 'redis://localhost:6379/0' + CELERY_ONCE_REDIS_URL: 'redis://localhost:6379/0' + - name: Publish coverage report uses: codecov/codecov-action@v4 with: diff --git a/pyproject.toml b/pyproject.toml index 5f85f6c3..b29fa2cc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -157,4 +157,7 @@ line-ending = "lf" [tool.coverage.run] branch = true source = ["src"] -omit = ["*/test_*.py"] +omit = [ + "*/test_*.py", + "src/objects/tests/playwright.py", +] diff --git a/requirements/ci.txt b/requirements/ci.txt index d908cdfc..8f71859a 100644 --- a/requirements/ci.txt +++ b/requirements/ci.txt @@ -408,6 +408,8 @@ googleapis-common-protos==1.72.0 # -r requirements/base.txt # opentelemetry-exporter-otlp-proto-grpc # opentelemetry-exporter-otlp-proto-http +greenlet==3.3.2 + # via playwright grpcio==1.74.0 # via # -c requirements/base.txt @@ -596,6 +598,8 @@ phonenumberslite==8.13.30 # -c requirements/base.txt # -r requirements/base.txt # django-two-factor-auth +playwright==1.58.0 + # via -r requirements/test-tools.in pluggy==1.5.0 # via pytest prometheus-client==0.20.0 @@ -651,6 +655,8 @@ pydantic-settings==2.6.1 # -c requirements/base.txt # -r requirements/base.txt # django-setup-configuration +pyee==13.0.1 + # via playwright pygments==2.18.0 # via # sphinx @@ -852,6 +858,7 @@ typing-extensions==4.9.0 # psycopg-pool # pydantic # pydantic-core + # pyee # pyopenssl # zgw-consumers tzdata==2025.2 diff --git a/requirements/dev.txt b/requirements/dev.txt index f4f647ec..30e97d49 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -466,6 +466,11 @@ googleapis-common-protos==1.72.0 # opentelemetry-exporter-otlp-proto-http gprof2dot==2024.6.6 # via django-silk +greenlet==3.3.2 + # via + # -c requirements/ci.txt + # -r requirements/ci.txt + # playwright grpcio==1.74.0 # via # -c requirements/ci.txt @@ -696,6 +701,10 @@ pip-tools==7.4.1 # via -r requirements/dev.in platformdirs==4.3.8 # via virtualenv +playwright==1.58.0 + # via + # -c requirements/ci.txt + # -r requirements/ci.txt pluggy==1.5.0 # via # -c requirements/ci.txt @@ -764,6 +773,11 @@ pydantic-settings==2.6.1 # -r requirements/ci.txt # bump-my-version # django-setup-configuration +pyee==13.0.1 + # via + # -c requirements/ci.txt + # -r requirements/ci.txt + # playwright pygments==2.18.0 # via # -c requirements/ci.txt @@ -1040,6 +1054,7 @@ typing-extensions==4.9.0 # psycopg-pool # pydantic # pydantic-core + # pyee # pyopenssl # rich-click # zgw-consumers diff --git a/requirements/test-tools.in b/requirements/test-tools.in index cd401c28..a29f420e 100644 --- a/requirements/test-tools.in +++ b/requirements/test-tools.in @@ -16,3 +16,7 @@ vcrpy # Code formatting ruff + + +# user interface testing +playwright diff --git a/src/objects/api/v2/openapi.yaml b/src/objects/api/v2/openapi.yaml index 7c9d61d6..858f409c 100644 --- a/src/objects/api/v2/openapi.yaml +++ b/src/objects/api/v2/openapi.yaml @@ -961,6 +961,7 @@ paths: tags: - objecttypes security: + - cookieAuth: [] - tokenAuth: [] responses: '200': @@ -1039,6 +1040,7 @@ paths: tags: - objecttypes security: + - cookieAuth: [] - tokenAuth: [] responses: '200': @@ -2266,6 +2268,10 @@ components: * `yearly` - Yearly * `unknown` - Unknown securitySchemes: + cookieAuth: + type: apiKey + in: cookie + name: objects_sessionid tokenAuth: type: apiKey in: header diff --git a/src/objects/api/v2/views.py b/src/objects/api/v2/views.py index 46448083..d038e5f1 100644 --- a/src/objects/api/v2/views.py +++ b/src/objects/api/v2/views.py @@ -17,9 +17,11 @@ ) from notifications_api_common.cloudevents import process_cloudevent from rest_framework import mixins, serializers, status, viewsets +from rest_framework.authentication import SessionAuthentication from rest_framework.decorators import action from rest_framework.exceptions import ValidationError from rest_framework.generics import get_object_or_404 +from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.settings import api_settings from vng_api_common.filters_backend import Backend as FilterBackend @@ -35,6 +37,7 @@ from objects.cloud_events.tasks import send_zaak_events from objects.core.constants import ObjectTypeVersionStatus, ReferenceType from objects.core.models import Object, ObjectRecord, ObjectType, ObjectTypeVersion +from objects.token.authentication import TokenAuthentication from objects.token.models import Permission, TokenAuth from objects.token.permissions import IsTokenAuthenticated, ObjectTypeBasedPermission @@ -179,7 +182,27 @@ class ObjectTypeVersionViewSet(NestedViewSetMixin, viewsets.ModelViewSet): serializer_class = ObjectTypeVersionSerializer lookup_field = "version" pagination_class = DynamicPageSizePagination - permission_classes = [IsTokenAuthenticated] + + def get_authenticators(self): + request = getattr(self, "request", None) + + if request and request.method in ("GET", "HEAD", "OPTIONS"): + return [ + SessionAuthentication(), + TokenAuthentication(), + ] + + return [TokenAuthentication()] + + def get_permissions(self): + request = self.request + + if request and request.method in ("GET", "HEAD", "OPTIONS"): + permission_class = IsAuthenticated | IsTokenAuthenticated + else: + permission_class = IsTokenAuthenticated + + return [permission_class()] def perform_create(self, serializer): super().perform_create(serializer) diff --git a/src/objects/conf/api.py b/src/objects/conf/api.py index 89605213..a0c362c4 100644 --- a/src/objects/conf/api.py +++ b/src/objects/conf/api.py @@ -6,7 +6,7 @@ "DEFAULT_PARSER_CLASSES": ["rest_framework.parsers.JSONParser"], "DEFAULT_FILTER_BACKENDS": ["vng_api_common.filters_backend.Backend"], "DEFAULT_AUTHENTICATION_CLASSES": [ - "objects.token.authentication.TokenAuthentication" + "objects.token.authentication.TokenAuthentication", ], "DEFAULT_SCHEMA_CLASS": "objects.utils.autoschema.AutoSchema", "PAGINATION_CLASS": "vng_api_common.pagination.DynamicPageSizePagination", diff --git a/src/objects/js/components/admin/permissions/permission-form.js b/src/objects/js/components/admin/permissions/permission-form.js index f7e4b5ac..538e3713 100644 --- a/src/objects/js/components/admin/permissions/permission-form.js +++ b/src/objects/js/components/admin/permissions/permission-form.js @@ -17,14 +17,25 @@ const PermissionForm = ({objectFields, tokenChoices, objecttypeChoices, modeChoi const [dataFieldChoices, setDataFieldChoices] = useState({}); const fetchObjecttypeVersions = (objecttype_id) => { - fetch(`/admin/core/objecttype/${objecttype_id}/_versions/`, { + fetch(`/api/v2/objecttypes/${objecttype_id}/versions`, { method: 'GET', + credentials: 'same-origin', + headers: { + 'Accept': 'application/json', + } + }) + .then(response => { + if (!response.ok) { + throw new Error(`Error ${response.status}: ${response.statusText}`); + } + return response.json(); }) - .then(response => response.json()) .then(response_data => { - if (response_data?.length > 0) { + const results = response_data.results || response_data; + + if (Array.isArray(results) && results.length > 0) { const objecttypes = { - [objecttype_id]: response_data.reduce((acc, version) => { + [objecttype_id]: results.reduce((acc, version) => { const properties = Object.keys(version?.jsonSchema?.properties || {}); acc[version.version] = properties.reduce((propsAcc, prop) => { propsAcc[prop] = `record__data__${prop}`; @@ -33,11 +44,12 @@ const PermissionForm = ({objectFields, tokenChoices, objecttypeChoices, modeChoi return acc; }, {}) }; - setDataFieldChoices(objecttypes); - } + + setDataFieldChoices(objecttypes); + } }) .catch(error => { - console.error('An error occurred while fetching the Objecttype versions endpoint:', error); + console.error('An error occurred while fetching the Objecttype versions:', error); }); }; useEffect(() => { diff --git a/src/objects/scss/admin/admin_overrides.scss b/src/objects/scss/admin/admin_overrides.scss index 4dc46a44..1ef664e6 100644 --- a/src/objects/scss/admin/admin_overrides.scss +++ b/src/objects/scss/admin/admin_overrides.scss @@ -1,2 +1,3 @@ @import "./admin_theme"; @import "./app_overrides"; +@import "components/all"; diff --git a/src/objects/scss/admin/components/_permission-fields.scss b/src/objects/scss/admin/components/_permission-fields.scss index 4111fa03..b745050f 100644 --- a/src/objects/scss/admin/components/_permission-fields.scss +++ b/src/objects/scss/admin/components/_permission-fields.scss @@ -1,7 +1,24 @@ .permission-fields { - margin-left: 170px; - &__nested { margin-left: 30px; } + + .checkbox-row { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 6px; + } + + .checkbox-row label { + display: inline; + width: auto; + min-width: 0; + padding: 0; + } + + h3 { + margin-top: 12px; + margin-bottom: 6px; + } } \ No newline at end of file diff --git a/src/objects/tests/js/__init__.py b/src/objects/tests/js/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/objects/tests/js/test_permission_admin.py b/src/objects/tests/js/test_permission_admin.py new file mode 100644 index 00000000..685bde8e --- /dev/null +++ b/src/objects/tests/js/test_permission_admin.py @@ -0,0 +1,122 @@ +from django.test import override_settings, tag + +from maykin_2fa.test import disable_admin_mfa +from playwright.sync_api import expect + +from objects.accounts.tests.factories import UserFactory +from objects.core.tests.factories import ObjectTypeFactory, ObjectTypeVersionFactory +from objects.tests.playwright import PlaywrightSyncLiveServerTestCase +from objects.token.tests.factories import PermissionFactory, TokenAuthFactory + + +@tag("playwright") +@override_settings(AXES_ENABLED=False) +@disable_admin_mfa() +class PermissionAdminTests(PlaywrightSyncLiveServerTestCase): + def setUp(self): + super().setUp() + self.user = UserFactory.create(superuser=True) + self.user.set_password("secret") + self.user.save() + + self.login_state = self.get_user_login_state(self.user) + + def test_field_based_authorization_fetches_versions(self): + object_type = ObjectTypeFactory.create() + ObjectTypeVersionFactory.create( + object_type=object_type, + version=1, + json_schema={ + "type": "object", + "properties": { + "field1": {"type": "string"}, + "field2": {"type": "string"}, + }, + }, + ) + + token = TokenAuthFactory.create() + + context = self.browser.new_context(storage_state=self.login_state) + page = context.new_page() + + page.on("console", lambda msg: print(f"BROWSER CONSOLE: {msg.text}")) + page.on("pageerror", lambda exc: print(f"BROWSER EXCEPTION: {exc}")) + + page.goto(self.live_reverse("admin:token_permission_add")) + + page.wait_for_selector("#id_token_auth", timeout=15000) + page.select_option("#id_token_auth", str(token.pk)) + page.select_option("#id_object_type", str(object_type.uuid)) + page.select_option("#id_mode", "read_only") + + expect(page.locator("#id_use_fields")).to_be_enabled(timeout=5000) + page.check("#id_use_fields") + + expect(page.locator("text=field1")).to_be_visible(timeout=5000) + expect(page.locator("text=field2")).to_be_visible(timeout=5000) + + page.get_by_label("field1").check() + page.get_by_label("field2").check() + + page.locator("input[name='_save']").click() + + page.wait_for_selector(".messagelist", timeout=10000) + + from objects.token.models import Permission + + permission = Permission.objects.get(token_auth=token, object_type=object_type) + + assert permission.use_fields is True + + assert "record__data__field1" in permission.fields["1"] + assert "record__data__field2" in permission.fields["1"] + + context.close() + + def test_edit_existing_permission_shows_fields(self): + object_type = ObjectTypeFactory.create() + ObjectTypeVersionFactory.create( + object_type=object_type, + version=1, + json_schema={ + "type": "object", + "properties": { + "field1": {"type": "string"}, + "field2": {"type": "string"}, + }, + }, + ) + + token = TokenAuthFactory.create() + permission = PermissionFactory.create( + token_auth=token, + object_type=object_type, + mode="read_only", + use_fields=True, + ) + + context = self.browser.new_context(storage_state=self.login_state) + page = context.new_page() + + page.on("console", lambda msg: print(f"BROWSER CONSOLE: {msg.text}")) + page.on("pageerror", lambda exc: print(f"BROWSER EXCEPTION: {exc}")) + + page.goto( + self.live_reverse( + "admin:token_permission_change", + args=[permission.pk], + ) + ) + + page.wait_for_selector("#id_token_auth", timeout=15000) + + expect(page.locator("#id_token_auth")).to_have_value(str(token.pk)) + expect(page.locator("#id_object_type")).to_have_value(str(object_type.uuid)) + + expect(page.locator("#id_use_fields")).to_be_checked() + + expect(page.locator("text=field1")).to_be_visible(timeout=5000) + expect(page.locator("text=field2")).to_be_visible(timeout=5000) + + context.close() diff --git a/src/objects/tests/playwright.py b/src/objects/tests/playwright.py new file mode 100644 index 00000000..22c6477f --- /dev/null +++ b/src/objects/tests/playwright.py @@ -0,0 +1,51 @@ +import os + +from django.contrib.staticfiles.testing import StaticLiveServerTestCase +from django.urls import reverse + +from playwright.sync_api import sync_playwright + + +class PlaywrightSyncLiveServerTestCase(StaticLiveServerTestCase): + playwright = None + browser = None + + @classmethod + def setUpClass(cls): + super().setUpClass() + os.environ["DJANGO_ALLOW_ASYNC_UNSAFE"] = "true" + cls.playwright = sync_playwright().start() + cls.browser = cls.playwright.chromium.launch( + headless=True, + args=["--no-sandbox", "--disable-dev-shm-usage"], + ) + + @classmethod + def tearDownClass(cls): + cls.browser.close() + cls.playwright.stop() + super().tearDownClass() + + @classmethod + def live_reverse(cls, viewname, args=None, kwargs=None): + path = reverse(viewname, args=args, kwargs=kwargs) + return f"{cls.live_server_url}{path}" + + def get_user_login_state(self, user): + context = self.browser.new_context() + page = context.new_page() + + page.goto(self.live_reverse("admin:login")) + page.get_by_label("Username").fill(user.username) + page.get_by_label("Password").fill("secret") + page.get_by_role("button", name="Log in").click() + + try: + page.wait_for_selector("#site-name", timeout=5000) + except Exception: + print(f"Login failed. Current URL: {page.url}") + raise + + state = context.storage_state() + context.close() + return state diff --git a/src/objects/token/admin.py b/src/objects/token/admin.py index 8578ce4f..69efb4e8 100644 --- a/src/objects/token/admin.py +++ b/src/objects/token/admin.py @@ -49,6 +49,8 @@ def get_form_data(self, request, object_id) -> dict: form.is_valid() values = {field.name: field.value() for field in form} + if obj and obj.object_type: + values["object_type"] = str(obj.object_type.uuid) errors = ( { field: [ @@ -67,7 +69,7 @@ def get_extra_context(self, request, object_id): (token.pk, str(token)) for token in TokenAuth.objects.all() ] object_type_choices = [EMPTY_FIELD_CHOICE] + [ - (object_type.pk, str(object_type)) + (str(object_type.uuid), str(object_type)) for object_type in ObjectType.objects.all() ] return { @@ -95,6 +97,17 @@ def add_view(self, request, form_url="", extra_context=None): return super().add_view(request, form_url, extra_context) + def get_form(self, request, obj=None, change=False, **kwargs): + form = super().get_form(request, obj, change, **kwargs) + + # Although Permission.object_type is stored as a ForeignKey to the PK of ObjectType, + # the admin form now submits the UUID as the choice value. + # to_field_name = "uuid" tells Django to resolve the submitted UUID to the correct ObjectType instance. + # Django then automatically stores its PK internally + form.base_fields["object_type"].to_field_name = "uuid" + + return form + class PermissionInline(EditInlineAdminMixin, admin.TabularInline): model = Permission diff --git a/src/objects/token/tests/test_admin.py b/src/objects/token/tests/test_admin.py index 78b6f55a..4a65c25f 100644 --- a/src/objects/token/tests/test_admin.py +++ b/src/objects/token/tests/test_admin.py @@ -34,8 +34,8 @@ def test_with_object_types_api_v2(self): choices = list(form.fields["object_type"].choices) self.assertEqual( - choices[1][0].value, - object_type.id, + str(choices[1][0].value), + str(object_type.uuid), ) self.assertEqual( choices[1][1],