diff --git a/pkg/helm/templates/deployment.yaml b/pkg/helm/templates/deployment.yaml index 5c12294868b..854e809180c 100644 --- a/pkg/helm/templates/deployment.yaml +++ b/pkg/helm/templates/deployment.yaml @@ -32,12 +32,12 @@ spec: {{- with omit .Values.commonLabels "app" }} {{- . | toYaml | nindent 8 }} {{- end }} - {{- if or (not (empty .Values.commonAnnotations)) (not .Values.existingSecret) .Values.preferences.enabled .Values.serverDefinitions.enabled }} + {{- if or (not (empty .Values.commonAnnotations)) (empty .Values.auth.existingSecret) .Values.preferences.enabled .Values.serverDefinitions.enabled }} annotations: {{- with .Values.commonAnnotations }} {{- . | toYaml | nindent 8 }} {{- end }} - {{- if not .Values.existingSecret }} + {{- if empty .Values.auth.existingSecret }} checksum/secret: {{ include (print $.Template.BasePath "/secret.yaml") . | sha256sum }} {{- end }} {{- if and .Values.config_local.enabled (empty .Values.config_local.existingSecret) }} diff --git a/web/pgadmin/browser/server_groups/servers/roles/__init__.py b/web/pgadmin/browser/server_groups/servers/roles/__init__.py index a2f407cda68..63a21d7887e 100644 --- a/web/pgadmin/browser/server_groups/servers/roles/__init__.py +++ b/web/pgadmin/browser/server_groups/servers/roles/__init__.py @@ -619,6 +619,7 @@ def _check_action(action, kwargs): return fetch_name, check_permission, forbidden_msg def _check_permission(self, check_permission, action, kwargs): + self.membership_only_update = False if check_permission: user = self.manager.user_info @@ -627,6 +628,15 @@ def _check_permission(self, check_permission, action, kwargs): (action != 'update' or 'rid' in kwargs) and \ kwargs['rid'] != -1 and \ user['id'] != kwargs['rid']: + # A role that only has ADMIN OPTION on this specific role + # (rather than being a superuser or having CREATEROLE) may + # still manage that role's membership, so don't forbid the + # request outright; the update handler restricts what such + # a request is allowed to change to membership only. + if action == 'update' and getattr( + self, 'has_admin_option', False): + self.membership_only_update = True + return False return True return False @@ -658,6 +668,7 @@ def _check_and_fetch_name(self, fetch_name, kwargs): self.role = row['rolname'] self.rolCanLogin = row['rolcanlogin'] self.rolSuper = row['rolsuper'] + self.has_admin_option = row.get('has_admin_option', False) return False, '' @@ -713,16 +724,20 @@ def wrapped(self, **kwargs): fetch_name, check_permission, \ forbidden_msg = RoleView._check_action(action, kwargs) - is_permission_error = self._check_permission(check_permission, - action, kwargs) - if is_permission_error: - return forbidden(forbidden_msg) - + # Fetched first: the permission check needs to know + # whether the current user holds ADMIN OPTION on this + # role before it can decide whether to forbid the + # request. is_error, errmsg = self._check_and_fetch_name(fetch_name, kwargs) if is_error: return errmsg + is_permission_error = self._check_permission(check_permission, + action, kwargs) + if is_permission_error: + return forbidden(forbidden_msg) + return f(self, **kwargs) return wrapped @@ -1023,6 +1038,13 @@ def create(self, gid, sid): @check_precondition(action='update') @validate_request def update(self, gid, sid, rid): + if getattr(self, 'membership_only_update', False) and \ + not set(self.request) <= {'rolmembers'}: + return forbidden( + _("The current user does not have permission to update " + "the role. Users with ADMIN OPTION on this role may " + "only manage its membership.") + ) sql = render_template( self.sql_path + self._UPDATE_SQL, diff --git a/web/pgadmin/browser/server_groups/servers/roles/static/js/role.ui.js b/web/pgadmin/browser/server_groups/servers/roles/static/js/role.ui.js index 68ff085dacd..b23f06e0640 100644 --- a/web/pgadmin/browser/server_groups/servers/roles/static/js/role.ui.js +++ b/web/pgadmin/browser/server_groups/servers/roles/static/js/role.ui.js @@ -55,6 +55,18 @@ export default class RoleSchema extends BaseUISchema { return (!(user.is_superuser || user.can_create_role) && user.id != state.oid); } + // A role that isn't a superuser or CREATEROLE holder can still manage + // this role's membership if they hold ADMIN OPTION on it themselves. + isMemberAdmin(state) { + return (state.rolmembers ?? []).some( + (member) => member.role === this.user.name && member.admin + ); + } + + membersReadOnly(state) { + return this.readOnly(state) && !this.isMemberAdmin(state); + } + memberDataFormatter(rawData) { let members = ''; if(_.isObject(rawData)) { @@ -194,8 +206,8 @@ export default class RoleSchema extends BaseUISchema { mode: ['edit', 'create'], cell: 'text', type: 'collection', schema: obj.membershipSchema, - disabled: obj.readOnly, - canDelete: (state) => !obj.readOnly(state), + disabled: (state) => obj.membersReadOnly(state), + canDelete: (state) => !obj.membersReadOnly(state), canDeleteRow: true, helpMessage: obj.isReadOnly ? gettext('Select the checkbox for roles to include WITH ADMIN OPTION.') : gettext('Roles shown with a check mark have the WITH ADMIN OPTION set.'), }, diff --git a/web/pgadmin/browser/server_groups/servers/roles/templates/roles/sql/default/permission.sql b/web/pgadmin/browser/server_groups/servers/roles/templates/roles/sql/default/permission.sql index 66b931cd970..7f3febc6645 100644 --- a/web/pgadmin/browser/server_groups/servers/roles/templates/roles/sql/default/permission.sql +++ b/web/pgadmin/browser/server_groups/servers/roles/templates/roles/sql/default/permission.sql @@ -1,5 +1,14 @@ SELECT - rolname, rolcanlogin, rolsuper + rolname, rolcanlogin, rolsuper, + EXISTS ( + SELECT 1 FROM pg_catalog.pg_auth_members am + WHERE am.roleid = {{ rid }}::OID + AND am.member = ( + SELECT oid FROM pg_catalog.pg_roles + WHERE rolname = current_user + ) + AND am.admin_option + ) AS has_admin_option FROM pg_catalog.pg_roles WHERE oid = {{ rid }}::OID diff --git a/web/pgadmin/browser/server_groups/servers/roles/tests/test_role_check_permission_unit_test.py b/web/pgadmin/browser/server_groups/servers/roles/tests/test_role_check_permission_unit_test.py new file mode 100644 index 00000000000..4319ec9ff4d --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/roles/tests/test_role_check_permission_unit_test.py @@ -0,0 +1,62 @@ +########################################################################## +# +# pgAdmin 4 - PostgreSQL Tools +# +# Copyright (C) 2013 - 2026, The pgAdmin Development Team +# This software is released under the PostgreSQL Licence +# +########################################################################## + +from unittest.mock import MagicMock + +from pgadmin.utils.route import BaseTestGenerator +from pgadmin.browser.server_groups.servers.roles import RoleView + + +class RoleCheckPermissionTest(BaseTestGenerator): + """Unit tests for RoleView._check_permission's ADMIN OPTION carve-out. + + A role holder who is neither a superuser nor a CREATEROLE holder, but + who has been granted ADMIN OPTION on the specific role being updated, + should be allowed through the permission gate so they can manage that + role's membership - but only for 'update', never for 'drop', and the + view should record that the request must be restricted to membership + changes only. + """ + scenarios = [ + ('Check Role Node', dict(url='/browser/role/obj/')) + ] + + def setUp(self): + pass + + def runTest(self): + view = RoleView(cmd=None) + view.manager = MagicMock() + + # Plain user, no admin option: update is forbidden. + view.manager.user_info = { + 'is_superuser': False, 'can_create_role': False, 'id': 5 + } + view.has_admin_option = False + self.assertTrue(view._check_permission(True, 'update', {'rid': 10})) + self.assertFalse(view.membership_only_update) + + # Same user, but with ADMIN OPTION on the target role: allowed + # through, flagged as membership-only. + view.has_admin_option = True + self.assertFalse(view._check_permission(True, 'update', {'rid': 10})) + self.assertTrue(view.membership_only_update) + + # ADMIN OPTION does not extend to dropping the role. + self.assertTrue(view._check_permission(True, 'drop', {'rid': 10})) + + # Superusers are unaffected by the ADMIN OPTION check. + view.manager.user_info = { + 'is_superuser': True, 'can_create_role': False, 'id': 5 + } + view.has_admin_option = False + self.assertFalse(view._check_permission(True, 'update', {'rid': 10})) + + def tearDown(self): + pass diff --git a/web/pgadmin/static/js/SchemaView/DataGridView/grid.jsx b/web/pgadmin/static/js/SchemaView/DataGridView/grid.jsx index 8366cd44ccd..d497c90811e 100644 --- a/web/pgadmin/static/js/SchemaView/DataGridView/grid.jsx +++ b/web/pgadmin/static/js/SchemaView/DataGridView/grid.jsx @@ -122,12 +122,25 @@ export default function DataGridView({ ) ).includes(true); + // Virtualising a small grid buys nothing (there's no offscreen window to + // skip rendering) but still pays for measureElement's per-row + // getBoundingClientRect on every mount/remeasure. That remeasure is + // exactly what fires when a dialog tab holding the grid is hidden via + // `display: none` and then shown again, since the scroll viewport + // momentarily measures 0 and the virtualizer's ResizeObserver treats + // that as a real resize. Below the threshold we skip virtualisation + // entirely and render every row in normal document flow, so showing a + // hidden tab is a pure CSS toggle again. + const virtualiseThreshold = viewHelperProps.virtualiseThreshold ?? 100; + const shouldVirtualise = rows.length > virtualiseThreshold; + const virtualizer = useVirtualizer({ count: rows.length, getScrollElement: () => tableEleRef.current, estimateSize: () => 50, measureElement: - typeof window !== 'undefined' && + shouldVirtualise && + typeof window !== 'undefined' && navigator.userAgent.indexOf('Firefox') === -1 ? element => element?.getBoundingClientRect().height : undefined, @@ -152,22 +165,29 @@ export default function DataGridView({ ref={tableEleRef} table={table} data-test="data-grid-view" tableClassName='DataGridView-table'> - + { - virtualizer.getVirtualItems().map((virtualRow) => { + ( + shouldVirtualise + ? virtualizer.getVirtualItems() + : rows.map((_row, index) => ({index, start: 0})) + ).map((virtualRow) => { const row = rows[virtualRow.index]; return ( virtualizer.measureElement(node)} - style={{ - // This should always be a `style` as it changes on - // scroll. - transform: `translateY(${virtualRow.start}px)`, - }} + ref={shouldVirtualise ? node => virtualizer.measureElement(node) : undefined} + className={shouldVirtualise ? undefined : 'pgrt-row--static'} + style={ + shouldVirtualise ? { + // This should always be a `style` as it changes + // on scroll. + transform: `translateY(${virtualRow.start}px)`, + } : undefined + } > ({ position: 'absolute', width: '100%', + // Opted out of the virtualizer's absolute positioning for grids + // small enough that virtualisation isn't used. Keeps the row in + // normal document flow so a hidden/shown dialog tab is a pure CSS + // toggle instead of triggering a virtualizer remeasure. + '&.pgrt-row--static': { + position: 'static', + }, + '& .pgrt-row-content': { display: 'flex', minHeight: 0, diff --git a/web/pgadmin/tools/maintenance/templates/maintenance/sql/command.sql b/web/pgadmin/tools/maintenance/templates/maintenance/sql/command.sql index 32abb115f71..35312924227 100644 --- a/web/pgadmin/tools/maintenance/templates/maintenance/sql/command.sql +++ b/web/pgadmin/tools/maintenance/templates/maintenance/sql/command.sql @@ -14,7 +14,6 @@ {% if data.vacuum_parallel %}{{ maintenance_options.append('PARALLEL ' + data.vacuum_parallel) or "" }}{% endif %} {% if data.buffer_usage_limit %}{{ maintenance_options.append('BUFFER_USAGE_LIMIT "' + data.buffer_usage_limit + '"') or "" }}{% endif %} {% if data.reindex_tablespace %}{{ maintenance_options.append('TABLESPACE ' + conn|qtIdent(data.reindex_tablespace)) or "" }}{% endif %} -{% if data.reindex_concurrently %}{{ maintenance_options.append('CONCURRENTLY') or "" }}{% endif %} {% if data.op == "VACUUM" %} VACUUM{% for option in maintenance_options %}{% if loop.first %} ({% endif %}{{ option }}{% if not loop.last %}, {% endif %}{% if loop.last %}){% endif %}{% endfor %}{% if data.schema %} {{ conn|qtIdent(data.schema) }}.{{ conn|qtIdent(data.table) }}{% endif %}; {% endif %} @@ -23,9 +22,9 @@ ANALYZE{% for option in maintenance_options %}{% if loop.first %} ({% endif %}{{ {% endif %} {% if data.op == "REINDEX" %} {% if index_name %} -REINDEX{% for option in maintenance_options %}{% if loop.first %} ({% endif %}{{ option }}{% if not loop.last %}, {% endif %}{% if loop.last %}){% endif %}{% endfor %} INDEX {{ conn|qtIdent(data.schema, index_name) }}; +REINDEX{% for option in maintenance_options %}{% if loop.first %} ({% endif %}{{ option }}{% if not loop.last %}, {% endif %}{% if loop.last %}){% endif %}{% endfor %} INDEX{% if data.reindex_concurrently %} CONCURRENTLY{% endif %} {{ conn|qtIdent(data.schema, index_name) }}; {% else %} -REINDEX{% for option in maintenance_options %}{% if loop.first %} ({% endif %}{{ option }}{% if not loop.last %}, {% endif %}{% if loop.last %}){% endif %}{% endfor %}{% if not data.schema and not data.reindex_system %} DATABASE {{ conn|qtIdent(data.database) }}{% elif not data.schema and data.reindex_system%} SYSTEM {{ conn|qtIdent(data.database) }}{% elif data.schema and not data.table and not data.primary_key and not data.unique_constraint and not data.index and not data.mview %} SCHEMA {{ conn|qtIdent(data.schema) }}{% else %} TABLE {{ conn|qtIdent(data.schema, data.table) }}{% endif %}; +REINDEX{% for option in maintenance_options %}{% if loop.first %} ({% endif %}{{ option }}{% if not loop.last %}, {% endif %}{% if loop.last %}){% endif %}{% endfor %}{% if not data.schema and not data.reindex_system %} DATABASE{% if data.reindex_concurrently %} CONCURRENTLY{% endif %} {{ conn|qtIdent(data.database) }}{% elif not data.schema and data.reindex_system%} SYSTEM {{ conn|qtIdent(data.database) }}{% elif data.schema and not data.table and not data.primary_key and not data.unique_constraint and not data.index and not data.mview %} SCHEMA{% if data.reindex_concurrently %} CONCURRENTLY{% endif %} {{ conn|qtIdent(data.schema) }}{% else %} TABLE{% if data.reindex_concurrently %} CONCURRENTLY{% endif %} {{ conn|qtIdent(data.schema, data.table) }}{% endif %}; {% endif %} {% endif %} {% if data.op == "CLUSTER" %} diff --git a/web/pgadmin/tools/maintenance/tests/test_maintenance_create_job_unit_test.py b/web/pgadmin/tools/maintenance/tests/test_maintenance_create_job_unit_test.py index 0b6f265b9ee..54fdfebc670 100644 --- a/web/pgadmin/tools/maintenance/tests/test_maintenance_create_job_unit_test.py +++ b/web/pgadmin/tools/maintenance/tests/test_maintenance_create_job_unit_test.py @@ -537,7 +537,7 @@ class MaintenanceCreateJobTest(BaseTestGenerator): verbose=True ), url=MAINTENANCE_URL, - expected_cmd_opts=['REINDEX (VERBOSE, CONCURRENTLY) DATABASE ' + expected_cmd_opts=['REINDEX (VERBOSE) DATABASE CONCURRENTLY ' 'postgres;\n'], server_min_version=120000, message='REINDEX CONCURRENTLY is not supported by EPAS/PG server ' @@ -643,7 +643,7 @@ class MaintenanceCreateJobTest(BaseTestGenerator): verbose=True ), url=MAINTENANCE_URL, - expected_cmd_opts=['REINDEX (VERBOSE, CONCURRENTLY) TABLE ' + expected_cmd_opts=['REINDEX (VERBOSE) TABLE CONCURRENTLY ' 'my_schema.my_table;\n'], server_min_version=120000, message='REINDEX CONCURRENTLY TABLE is not supported by ' @@ -710,7 +710,7 @@ class MaintenanceCreateJobTest(BaseTestGenerator): verbose=True ), url=MAINTENANCE_URL, - expected_cmd_opts=['REINDEX (VERBOSE, CONCURRENTLY) INDEX ' + expected_cmd_opts=['REINDEX (VERBOSE) INDEX CONCURRENTLY ' 'my_schema.my_index;\n'], server_min_version=120000, message='REINDEX CONCURRENTLY is not supported by EPAS/PG server ' diff --git a/web/pgadmin/utils/__init__.py b/web/pgadmin/utils/__init__.py index d459e72b99d..0a57d7e6c0f 100644 --- a/web/pgadmin/utils/__init__.py +++ b/web/pgadmin/utils/__init__.py @@ -649,9 +649,11 @@ def check_is_integer(value): "found for server '%s'" % server ) else: - errmsg = check_attrib("Username") - if errmsg: - return errmsg + if not obj.get("Username"): + return gettext( + "'Username' attribute not found for server '%s'" % + server + ) errmsg = check_attrib("MaintenanceDB") if errmsg: diff --git a/web/pgadmin/utils/driver/psycopg3/connection.py b/web/pgadmin/utils/driver/psycopg3/connection.py index d07a16cefcd..d8a6cd53172 100644 --- a/web/pgadmin/utils/driver/psycopg3/connection.py +++ b/web/pgadmin/utils/driver/psycopg3/connection.py @@ -1173,6 +1173,19 @@ def execute_void(self, query, params=None, formatted_exception_msg=False): if not status: return False, str(cur) + + if isinstance(cur, AsyncDictServerCursor): + # A named/server-side cursor's execute() always runs the query + # as `DECLARE ... CURSOR FOR `, which cannot express a + # transaction-control statement such as BEGIN/COMMIT/ROLLBACK. + # Run this one statement through a throwaway plain cursor + # instead, leaving the cached server-side cursor untouched, and + # treat it as leaving no result set for whatever poll() call + # comes next. + cur = self.conn.cursor() + self.column_info = None + self.row_count = 0 + query_id = str(secrets.choice(range(1, 9999999))) current_app.logger.log( diff --git a/web/pgadmin/utils/driver/psycopg3/tests/test_execute_void_server_cursor.py b/web/pgadmin/utils/driver/psycopg3/tests/test_execute_void_server_cursor.py new file mode 100644 index 00000000000..c885f66df8e --- /dev/null +++ b/web/pgadmin/utils/driver/psycopg3/tests/test_execute_void_server_cursor.py @@ -0,0 +1,85 @@ +########################################################################## +# +# pgAdmin 4 - PostgreSQL Tools +# +# Copyright (C) 2013 - 2026, The pgAdmin Development Team +# This software is released under the PostgreSQL Licence +# +########################################################################## + +"""Regression test: ``execute_void()`` must not run a transaction-control +statement (BEGIN/COMMIT/ROLLBACK) through a cached named/server-side +cursor. + +A named cursor's ``execute()`` always wraps the statement as +``DECLARE ... CURSOR FOR ``, which cannot express BEGIN/COMMIT/ +ROLLBACK. Before the fix, the Commit/Rollback buttons under "server +cursor" mode silently did nothing: the DECLARE-wrapped call failed +(actually failing one step earlier, on a ``prepare`` keyword the +server-side cursor's ``execute()`` doesn't accept at all), the exception +was swallowed by the background query thread, and the next poll() then +reported the *previous* query's leftover column info, making the result +grid appear instead of the Messages tab (pgAdmin issue #8991).""" + +from unittest.mock import MagicMock, patch + +from pgadmin.utils.driver.psycopg3.connection import Connection +from pgadmin.utils.driver.psycopg3.cursor import AsyncDictServerCursor +from pgadmin.utils.route import BaseTestGenerator + + +class ExecuteVoidServerCursorTest(BaseTestGenerator): + + scenarios = [ + ('COMMIT with a cached server-side cursor runs on a throwaway ' + 'plain cursor and clears stale column info', dict(sql='COMMIT;')), + ('ROLLBACK with a cached server-side cursor runs on a throwaway ' + 'plain cursor and clears stale column info', + dict(sql='ROLLBACK;')), + ] + + def runTest(self): + manager = MagicMock(sid=1) + conn = Connection(manager, 'test-conn-id', 'testdb') + conn.python_encoding = 'utf-8' + + # Leftover state from a previous SELECT executed through the + # server-side cursor. + conn.column_info = [{'name': 'x'}] + conn.row_count = 1 + + server_cursor = MagicMock(spec=AsyncDictServerCursor) + server_cursor.closed = False + + plain_cursor = MagicMock() + plain_cursor.closed = False + + conn.conn = MagicMock() + conn.conn.cursor.return_value = plain_cursor + conn.conn.info.user = 'postgres' + conn.conn.info.host = 'localhost' + conn.conn.info.dbname = 'testdb' + + # current_user needs a real request context to resolve at all; + # patch it only once inside that context, to a stand-in with the + # attribute execute_void()'s log line reads. + with self.app.test_request_context(): + with patch( + 'pgadmin.utils.driver.psycopg3.connection.current_user', + MagicMock(email='test@example.com') + ), patch.object(Connection, '_Connection__cursor', + return_value=(True, server_cursor)): + status, result = conn.execute_void(self.sql) + + self.assertTrue(status) + self.assertIsNone(result) + + # The statement ran on the throwaway plain cursor, not the + # cached server-side one. + plain_cursor.execute.assert_called_once() + server_cursor.execute.assert_not_called() + + # Stale result-set state from the prior SELECT must not leak + # into whatever poll() call comes next. + self.assertIsNone(conn.column_info) + self.assertEqual(conn.row_count, 0) diff --git a/web/pgadmin/utils/tests/test_validate_json_data.py b/web/pgadmin/utils/tests/test_validate_json_data.py index b5525d8eed3..4360f9dccb0 100644 --- a/web/pgadmin/utils/tests/test_validate_json_data.py +++ b/web/pgadmin/utils/tests/test_validate_json_data.py @@ -47,6 +47,20 @@ class TestValidateJsonData(BaseTestGenerator): expected_error="'Username' attribute not found", expected_servers=["1"] )), + ('A non-shared server with an empty username is rejected', + dict( + servers={"1": server(Username="")}, + is_admin=True, + expected_error="'Username' attribute not found", + expected_servers=["1"] + )), + ('A non-shared server with a null username is rejected', + dict( + servers={"1": server(Username=None)}, + is_admin=True, + expected_error="'Username' attribute not found", + expected_servers=["1"] + )), ('A shared server with only a shared username is valid', dict( servers={"1": server(Shared=True, SharedUsername="postgres")}, diff --git a/web/regression/javascript/SchemaView/SchemaDialogView.spec.js b/web/regression/javascript/SchemaView/SchemaDialogView.spec.js index 9c4c944138e..fbfad366241 100644 --- a/web/regression/javascript/SchemaView/SchemaDialogView.spec.js +++ b/web/regression/javascript/SchemaView/SchemaDialogView.spec.js @@ -172,6 +172,41 @@ describe('SchemaView', ()=>{ await user.type(ctrl.container.querySelectorAll('[name="field5"]')[1], 'rval51'); expect(ctrl.container.querySelector('[data-test="notifier-message"]')).toHaveTextContent('Field5 in FieldColl must be unique.'); }); + + it('does not virtualise a small grid, rendering rows in static flow', async ()=>{ + await simulateValidData(); + + const dataRows = ctrl.container.querySelectorAll('[data-test="data-table-row"]'); + expect(dataRows.length).toBe(2); + + // Every row should be fully mounted and opted out of the + // virtualizer's absolute positioning, so a hidden dialog tab is a + // pure CSS toggle rather than something the virtualizer has to + // remeasure when the tab is shown again. + const pgrtRows = ctrl.container.querySelectorAll('.pgrt-row'); + expect(pgrtRows.length).toBe(2); + pgrtRows.forEach((rowEl)=>{ + expect(rowEl.classList.contains('pgrt-row--static')).toBe(true); + expect(rowEl.style.transform).toBe(''); + }); + }); + + it('virtualises a large grid, mounting only a window of rows', async ()=>{ + const manyRows = Array.from({length: 150}, (_, i)=>( + {field3: i, field4: 'field4val', field5: `field5val${i}`} + )); + + await ctrlMount({ + getInitData: ()=>Promise.resolve({fieldcoll: manyRows}), + }); + + const pgrtRows = ctrl.container.querySelectorAll('.pgrt-row'); + expect(pgrtRows.length).toBeGreaterThan(0); + expect(pgrtRows.length).toBeLessThan(manyRows.length); + pgrtRows.forEach((rowEl)=>{ + expect(rowEl.classList.contains('pgrt-row--static')).toBe(false); + }); + }); }); describe('SQL tab', ()=>{ diff --git a/web/regression/javascript/schema_ui_files/role.ui.spec.js b/web/regression/javascript/schema_ui_files/role.ui.spec.js index 63bc47fda6d..7760ac71baf 100644 --- a/web/regression/javascript/schema_ui_files/role.ui.spec.js +++ b/web/regression/javascript/schema_ui_files/role.ui.spec.js @@ -45,5 +45,32 @@ describe('RoleSchema', ()=>{ it('properties', async ()=>{ await getPropertiesView(createSchemaObject(), getInitData); }); + + describe('membersReadOnly', ()=>{ + it('is read only for a plain user who is not an admin member', ()=>{ + const schemaObj = createSchemaObject(); + const state = {oid: 123, rolmembers: [{role: 'postgres', admin: false}]}; + expect(schemaObj.membersReadOnly(state)).toBe(true); + }); + + it('is editable for a user with ADMIN OPTION on the role', ()=>{ + const schemaObj = createSchemaObject(); + const state = {oid: 123, rolmembers: [{role: 'postgres', admin: true}]}; + expect(schemaObj.membersReadOnly(state)).toBe(false); + }); + + it('is editable regardless when the user is a superuser/can create roles', ()=>{ + const schemaObj = new RoleSchema( + ()=>new MockSchema(), + ()=>new MockSchema(), + { + role: ()=>[], + nodeInfo: {server: {user: {name: 'postgres', id: 0, is_superuser: true}}} + }, + ); + const state = {oid: 123, rolmembers: []}; + expect(schemaObj.membersReadOnly(state)).toBe(false); + }); + }); });