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/databases/schemas/tables/columns/static/js/column.ui.js b/web/pgadmin/browser/server_groups/servers/databases/schemas/tables/columns/static/js/column.ui.js index 062464c504f..5121dd9d50f 100644 --- a/web/pgadmin/browser/server_groups/servers/databases/schemas/tables/columns/static/js/column.ui.js +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/tables/columns/static/js/column.ui.js @@ -88,15 +88,22 @@ export default class ColumnSchema extends BaseUISchema { } if(this.nodeInfo && ('schema' in this.nodeInfo)) { - if(this.isNew(state)) { - return false; + // inheritedfrom/inheritedfromtable check is useful when we use this + // schema in table node. A column inherited from a parent table should + // always be read-only, whether it was already present when the table + // was opened (inheritedfromtable, set on the properties fetch) or was + // just added interactively via 'Inherited from table(s)' + // (inheritedfrom, set on the freshly fetched column). This must be + // checked before the isNew() check below, as interactively added + // inherited columns don't carry an attnum yet and would otherwise be + // (wrongly) treated as new, editable rows. + if (!isEmptyString(state.inheritedfrom) || + !isEmptyString(state.inheritedfromtable)){ + return true; } - // We will disable control if it's system columns - // inheritedfrom check is useful when we use this schema in table node - // inheritedfrom has value then we should disable it - if (!isEmptyString(state.inheritedfrom)){ - return true; + if(this.isNew(state)) { + return false; } // ie: it's position is less than 1 @@ -164,6 +171,22 @@ export default class ColumnSchema extends BaseUISchema { return !isEmptyString(state.inheritedfromtype); } + // Shared by the inline grid-cell 'Data type' editor and the expanded + // Definition tab's 'Data type' dropdown, so both apply the exact same + // edit_types restriction for the same column. isRowNew must be computed + // by the caller against the *row's* own state (not the enclosing table's + // or the field's own scalar state), since new columns can be set to any + // type whilst existing ones may only be altered to one of edit_types. + editTypesFilter(edit_types, isRowNew) { + return (options)=>{ + if (isRowNew || this.inErd) { + return options; + } + let allowed = edit_types || []; + return _.filter(options, (o)=>allowed.indexOf(o.value) > -1); + }; + } + get baseFields() { let obj = this; @@ -241,20 +264,21 @@ export default class ColumnSchema extends BaseUISchema { group: gettext('Definition'), noEmpty: true, editable: this.editableCheckForTable, options: this.cltypeOptions, optionsLoaded: (options)=>{obj.datatypes = options;}, - type: (state)=>{ + // 'edit_types'/'attnum' are declared as deps purely so that the + // schema view resolves them against this row (not the whole table), + // and passes them through as the 2nd (depVals) argument below. This + // is what lets the expanded Definition tab's dropdown apply the same + // edit_types restriction as the inline grid-cell editor, whose + // 'cell' callback already receives the full row. + deps: ['edit_types', 'attnum'], + type: (state, depVals)=>{ + let [edit_types, attnum] = depVals || []; return { type: 'select', options: this.cltypeOptions, controlProps: { allowClear: false, - filter: (options)=>{ - let result = options; - let edit_types = state?.edit_types || []; - if(!obj.isNew(state) && !this.inErd) { - result = _.filter(options, (o)=>edit_types.indexOf(o.value) > -1); - } - return result; - }, + filter: obj.editTypesFilter(edit_types, obj.isNew({attnum})), } }; }, @@ -264,14 +288,7 @@ export default class ColumnSchema extends BaseUISchema { options: this.cltypeOptions, controlProps: { allowClear: false, - filter: (options)=>{ - let result = options; - let edit_types = row?.edit_types || []; - if(!obj.isNew(row) && !this.inErd) { - result = _.filter(options, (o)=>edit_types.indexOf(o.value) > -1); - } - return result; - }, + filter: obj.editTypesFilter(row?.edit_types, obj.isNew(row)), } }; } diff --git a/web/pgadmin/browser/server_groups/servers/databases/schemas/tables/static/js/table.ui.js b/web/pgadmin/browser/server_groups/servers/databases/schemas/tables/static/js/table.ui.js index 4cc2bc46b47..6efb0064ca4 100644 --- a/web/pgadmin/browser/server_groups/servers/databases/schemas/tables/static/js/table.ui.js +++ b/web/pgadmin/browser/server_groups/servers/databases/schemas/tables/static/js/table.ui.js @@ -510,7 +510,12 @@ export default class TableSchema extends BaseUISchema { // Check for column grid when to edit/delete (for each row) canEditDeleteRowColumns(colstate) { - return isEmptyString(colstate.inheritedfrom); + // 'inheritedfrom' is set on columns fetched interactively via + // 'Inherited from table(s)'; 'inheritedfromtable' is set on columns + // already inherited when the table's properties were fetched. Both + // must disable the row's edit/delete buttons. + return isEmptyString(colstate.inheritedfrom) && + isEmptyString(colstate.inheritedfromtable); } isPartitioned(state) { 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/MappedControl.jsx b/web/pgadmin/static/js/SchemaView/MappedControl.jsx index 78353a07097..0adfb0d4285 100644 --- a/web/pgadmin/static/js/SchemaView/MappedControl.jsx +++ b/web/pgadmin/static/js/SchemaView/MappedControl.jsx @@ -401,7 +401,15 @@ export const MappedFormControl = ({ } if (typeof (field.type) === 'function') { - const typeProps = evalFunc(null, field.type, state); + // 'state' here is the whole top-level schema data, not this field's + // row, since a field nested inside a collection row shares the same + // accessPath resolution as any other field. 'depVals' (already resolved + // against this field's own row via 'deps', see listenDepChanges above) + // is passed as a 2nd argument so a field.type() callback can access + // sibling fields from its own row, mirroring what field.cell() already + // gets via its row argument. Existing field.type() callbacks that only + // take a single argument are unaffected. + const typeProps = evalFunc(null, field.type, state, depVals); newProps = { ...newProps, ...typeProps, 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/schema_ui_files/column.ui.spec.js b/web/regression/javascript/schema_ui_files/column.ui.spec.js index cc99104e044..66dfd642d77 100644 --- a/web/regression/javascript/schema_ui_files/column.ui.spec.js +++ b/web/regression/javascript/schema_ui_files/column.ui.spec.js @@ -133,6 +133,76 @@ describe('ColumnSchema', ()=>{ expect(schemaObj.inSchemaWithColumnCheck(state)).toBe(false); }); + it('inSchemaWithColumnCheck - column already inherited from a parent table', ()=>{ + // Set on the properties fetch for a column the table already + // inherits when opened (issue #10179, case 1). + schemaObj.nodeInfo = {schema: {}}; + let state = {attnum: 1, inheritedfromtable: 'public.parent'}; + expect(schemaObj.inSchemaWithColumnCheck(state)).toBe(true); + expect(schemaObj.editableCheckForTable(state)).toBe(false); + }); + + it('inSchemaWithColumnCheck - column added interactively via Inherited from table(s)', ()=>{ + // Columns freshly fetched via 'Inherited from table(s)' don't carry an + // attnum yet, so isNew() would otherwise (wrongly) treat them as new, + // editable rows (issue #10179, case 2). + schemaObj.nodeInfo = {schema: {}}; + let state = {name: 'id', inheritedfrom: 'public.parent'}; + expect(schemaObj.inSchemaWithColumnCheck(state)).toBe(true); + expect(schemaObj.editableCheckForTable(state)).toBe(false); + }); + + it('editTypesFilter', ()=>{ + let options = [ + {label: 'integer', value: 'integer'}, + {label: 'text', value: 'text'}, + {label: 'boolean', value: 'boolean'}, + ]; + + // Existing column: restricted to edit_types. + let filtered = schemaObj.editTypesFilter(['integer', 'text'], false)(options); + expect(filtered).toEqual([ + {label: 'integer', value: 'integer'}, + {label: 'text', value: 'text'}, + ]); + + // New column: unrestricted, full list. + expect(schemaObj.editTypesFilter(['integer'], true)(options)).toEqual(options); + + // No edit_types available: restricts down to nothing. + expect(schemaObj.editTypesFilter(undefined, false)(options)).toEqual([]); + + // ERD is always unrestricted, regardless of edit_types/isNew. + schemaObj.inErd = true; + expect(schemaObj.editTypesFilter(['integer'], false)(options)).toEqual(options); + schemaObj.inErd = false; + }); + + it('cltype - expanded Definition tab options match the inline grid-cell options', ()=>{ + let cltypeField = _.find(schemaObj.fields, (f)=>f.id === 'cltype'); + let options = [ + {label: 'integer', value: 'integer'}, + {label: 'text', value: 'text'}, + {label: 'boolean', value: 'boolean'}, + ]; + let row = {attnum: 1, edit_types: ['integer', 'boolean']}; + + // Inline grid-cell editor. + let cellResult = cltypeField.cell(row); + let cellFiltered = cellResult.controlProps.filter(options); + + // Expanded Definition tab, as it is invoked once 'edit_types'/'attnum' + // have been resolved against this row via deps (see MappedControl.jsx). + let typeResult = cltypeField.type(row.cltype, [row.edit_types, row.attnum]); + let typeFiltered = typeResult.controlProps.filter(options); + + expect(typeFiltered).toEqual(cellFiltered); + expect(typeFiltered).toEqual([ + {label: 'integer', value: 'integer'}, + {label: 'boolean', value: 'boolean'}, + ]); + }); + it('editableCheckForTable', ()=>{ let state = {}; schemaObj.nodeInfo = {}; 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); + }); + }); }); diff --git a/web/regression/javascript/schema_ui_files/table.ui.spec.js b/web/regression/javascript/schema_ui_files/table.ui.spec.js index 46fa0b1d67c..7482debdaad 100644 --- a/web/regression/javascript/schema_ui_files/table.ui.spec.js +++ b/web/regression/javascript/schema_ui_files/table.ui.spec.js @@ -73,6 +73,12 @@ describe('TableSchema', () => { it('canEditDeleteRowColumns', () => { expect(schemaObj.canEditDeleteRowColumns({inheritedfrom: 1234})).toBe(false); expect(schemaObj.canEditDeleteRowColumns({inheritedfrom: null})).toBe(true); + + // Column already inherited from a parent table when the table was + // opened (issue #10179, case 1) - the row's edit/delete buttons must + // be disabled too. + expect(schemaObj.canEditDeleteRowColumns({inheritedfromtable: 'public.parent'})).toBe(false); + expect(schemaObj.canEditDeleteRowColumns({inheritedfromtable: null})).toBe(true); }); it('LikeSchema typname change', () => {