diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/__init__.py b/web/pgadmin/browser/server_groups/servers/pg_timetable/__init__.py index bdbf4994e13..e8b08fbe061 100644 --- a/web/pgadmin/browser/server_groups/servers/pg_timetable/__init__.py +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/__init__.py @@ -21,11 +21,43 @@ from pgadmin.browser.utils import PGChildNodeView from pgadmin.browser.server_groups import servers from pgadmin.utils.ajax import make_json_response, internal_server_error, \ - make_response as ajax_response, gone + make_response as ajax_response, gone, bad_request from pgadmin.utils.driver import get_driver from pgadmin.utils.preferences import Preferences +# Pass OIDs to the privilege functions so that a missing schema or table +# makes the check false rather than raising an error. +PGT_CHAIN_ACCESS_SQL = """ +SELECT EXISTS( + SELECT 1 FROM pg_catalog.pg_class cl + JOIN pg_catalog.pg_namespace ns ON ns.oid = cl.relnamespace + WHERE cl.relname = 'chain' AND ns.nspname = 'timetable' AND + has_schema_privilege(ns.oid, 'USAGE') AND + has_table_privilege(cl.oid, 'INSERT') AND + has_table_privilege(cl.oid, 'SELECT') AND + has_table_privilege(cl.oid, 'UPDATE') +) has_priviledge +""" + + +def has_pgt_chain_access(conn): + """ + Check whether the pg_timetable schema exists in the database conn is + connected to (the server's maintenance database), and whether the + current role has USAGE on it and can INSERT, SELECT and UPDATE + timetable.chain. + + Returns a (status, result) tuple: on success, result is True or False; + on failure, status is False and result holds the error message. + """ + status, res = conn.execute_2darray(PGT_CHAIN_ACCESS_SQL) + if not status: + return False, res + rows = res['rows'] + return True, bool(rows and rows[0]['has_priviledge']) + + class ChainModule(CollectionNodeModule): _NODE_TYPE = 'pgt_chain' _COLLECTION_LABEL = _("pgTimeTable Chains") @@ -49,27 +81,8 @@ def backend_supported(self, manager, **kwargs): if hasattr(self, 'show_node') and not self.show_node: return False - conn = manager.connection() - - status, res = conn.execute_scalar(""" -SELECT - ( - has_table_privilege('timetable.chain', 'INSERT') AND - has_table_privilege('timetable.chain', 'SELECT') AND - has_table_privilege('timetable.chain', 'UPDATE') - ) has_priviledge -WHERE EXISTS( - SELECT has_schema_privilege('timetable', 'USAGE') - WHERE EXISTS( - SELECT cl.oid FROM pg_catalog.pg_class cl - LEFT JOIN pg_catalog.pg_namespace ns ON ns.oid=relnamespace - WHERE relname='chain' AND nspname='timetable' - ) -) -""") - if status and res: - return True - return False + status, res = has_pgt_chain_access(manager.connection()) + return status and res @property def csssnippets(self): @@ -166,6 +179,21 @@ def wrap(self, *args, **kwargs): # Set the template path for the sql scripts. self.template_path = 'pgt_chain/sql/default' + + # Without this check, a server whose maintenance database + # lacks the pg_timetable schema fails with a raw "relation + # timetable.chain does not exist" error and HTTP 500. + status, res = has_pgt_chain_access(self.conn) + if not status: + return internal_server_error(errormsg=res) + if not res: + return bad_request(errormsg=_( + "The pg_timetable schema was not found in the " + "maintenance database, or the current role does not " + "have USAGE on it and INSERT, SELECT and UPDATE " + "privileges on timetable.chain." + )) + return f(self, *args, **kwargs) return wrap diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/static/js/pgt_chain.js b/web/pgadmin/browser/server_groups/servers/pg_timetable/static/js/pgt_chain.js index 14da0c62fe6..96f691e4087 100644 --- a/web/pgadmin/browser/server_groups/servers/pg_timetable/static/js/pgt_chain.js +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/static/js/pgt_chain.js @@ -81,6 +81,22 @@ define('pgadmin.node.pgt_chain', [ }, }); } + + /* Whether the server tree item has a loaded pgTimeTable Chains child. */ + function hasPgtChainCollection(serverItem) { + return (pgBrowser.tree.children(serverItem) || []).some( + (child) => pgBrowser.tree.itemData(child)?._type === 'coll-pgt_chain' + ); + } + + /* The server is selected (and its menus evaluated) before its children + * are loaded, so re-evaluate the menus once the collection appears. */ + pgBrowser.Events.on('pgadmin-browser:tree:added', function (_item, data) { + if (data?._type === 'coll-pgt_chain') { + pgBrowser.enable_disable_menus(); + } + }); + pgBrowser.add_menus([ { name: 'create_pgt_chain_on_server', @@ -92,6 +108,12 @@ define('pgadmin.node.pgt_chain', [ priority: 4, label: gettext('pgTimeTable Chain...'), data: { action: 'create' }, + /* The backend only adds the pgTimeTable Chains collection to a + * server when pg_timetable is usable in its maintenance database, + * so offer this menu only when that collection node is present. */ + enable: function (node, item) { + return Boolean(node?.connected) && hasPgtChainCollection(item); + }, }, { name: 'create_pgt_chain_on_coll', diff --git a/web/pgadmin/browser/server_groups/servers/pg_timetable/tests/test_pgt_chain_not_available.py b/web/pgadmin/browser/server_groups/servers/pg_timetable/tests/test_pgt_chain_not_available.py new file mode 100644 index 00000000000..cf447161ab5 --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/tests/test_pgt_chain_not_available.py @@ -0,0 +1,80 @@ +########################################################################## +# +# pgAdmin 4 - PostgreSQL Tools +# +# Copyright (C) 2013 - 2026, The pgAdmin Development Team +# This software is released under the PostgreSQL Licence +# +########################################################################## + +from unittest.mock import patch + +from pgadmin.utils.route import BaseTestGenerator +from regression.python_test_utils import test_utils as utils +from . import utils as pgt_utils + +NOT_AVAILABLE_MSG = ( + "The pg_timetable schema was not found in the maintenance database, " + "or the current role does not have USAGE on it and INSERT, SELECT " + "and UPDATE privileges on timetable.chain." +) +ACCESS_CHECK = \ + 'pgadmin.browser.server_groups.servers.pg_timetable.has_pgt_chain_access' + + +class PgtChainNotAvailableTestCase(BaseTestGenerator): + """ + Test that the pgTimetable chain API returns a clear error, rather than a + raw SQL error and HTTP 500, when pg_timetable is not usable in the + server's maintenance database. + """ + scenarios = [ + ('Create chain: pg_timetable not available', dict( + api='create', mock_return=(True, False), + expected_data={'status_code': 400, + 'error_msg': NOT_AVAILABLE_MSG})), + ('List chains: pg_timetable not available', dict( + api='list', mock_return=(True, False), + expected_data={'status_code': 400, + 'error_msg': NOT_AVAILABLE_MSG})), + ('List chains: access check fails', dict( + api='list', mock_return=(False, 'Mocked Internal Server Error'), + expected_data={'status_code': 500, + 'error_msg': 'Mocked Internal Server Error'})), + ('List chains: pg_timetable not installed (unmocked)', dict( + api='list', mock_return=None, + expected_data={'status_code': 400, + 'error_msg': NOT_AVAILABLE_MSG})), + ] + + def setUp(self): + """Connect to the server and set up the request data""" + super().setUp() + self.url = '/browser/pgt_chain/obj/' + self.data = {'chain_name': 'test_chain_not_available', 'live': False} + + flag, msg = pgt_utils.is_valid_server_to_run_pgtimetable(self) + if not flag: + self.skipTest(msg) + + if self.mock_return is None: + installed, _ = pgt_utils.is_pgtimetable_installed_on_server(self) + if installed: + self.skipTest('pg_timetable is installed on the server.') + + def _call_api(self): + """Call the chain API selected by the scenario""" + if self.api == 'create': + return pgt_utils.api_create(self) + return pgt_utils.api_get(self, '') + + def runTest(self): + """Check the error returned when pg_timetable is not available""" + if self.mock_return is None: + response = self._call_api() + else: + with patch(ACCESS_CHECK, return_value=self.mock_return): + response = self._call_api() + + utils.assert_status_code(self, response) + utils.assert_error_message(self, response) diff --git a/web/pgadmin/browser/static/js/browser.js b/web/pgadmin/browser/static/js/browser.js index 91483b0556d..16712b58fc2 100644 --- a/web/pgadmin/browser/static/js/browser.js +++ b/web/pgadmin/browser/static/js/browser.js @@ -1259,6 +1259,8 @@ define('pgadmin.browser', [ } await this.tree.refresh(nodeItem); + // The refresh may have added or removed children that menus depend on. + this.enable_disable_menus(); this.tree.toggleItemLoader(nodeItem, false); opts?.success?.(); }, diff --git a/web/regression/javascript/browser/pgt_chain_menus_spec.js b/web/regression/javascript/browser/pgt_chain_menus_spec.js new file mode 100644 index 00000000000..8408cb9821e --- /dev/null +++ b/web/regression/javascript/browser/pgt_chain_menus_spec.js @@ -0,0 +1,87 @@ +///////////////////////////////////////////////////////////// +// +// pgAdmin 4 - PostgreSQL Tools +// +// Copyright (C) 2013 - 2026, The pgAdmin Development Team +// This software is released under the PostgreSQL Licence +// +////////////////////////////////////////////////////////////// + +jest.mock('../../../pgadmin/browser/server_groups/servers/pg_timetable/static/js/pgt_chain.ui', () => ({ + __esModule: true, default: jest.fn(), +})); +jest.mock('../../../pgadmin/browser/server_groups/servers/pg_timetable/tasks/static/js/pgt_chaintask.ui', () => ({ + getNodePgtChainTaskSchema: jest.fn(), +})); +jest.mock('../../../pgadmin/static/js/api_instance', () => ({ + __esModule: true, default: jest.fn(), +})); + +const pgBrowser = { + Nodes: {}, + Node: {extend: (props) => props}, + Collection: {extend: (props) => props}, + Events: {on: jest.fn()}, + add_menus: jest.fn(), + enable_disable_menus: jest.fn(), +}; + +// The node module registers itself through an AMD style define(); load it +// with a stub that hands the factory our fake browser. +global.define = (_name, _deps, factory) => { + factory((s) => s, () => '', pgBrowser); +}; +require('../../../pgadmin/browser/server_groups/servers/pg_timetable/static/js/pgt_chain'); +delete global.define; + +describe('pgTimeTable Chain create menu on the server', () => { + const serverMenu = pgBrowser.add_menus.mock.calls[0][0].find( + (m) => m.name === 'create_pgt_chain_on_server' + ); + const addedHandler = pgBrowser.Events.on.mock.calls.find( + (c) => c[0] === 'pgadmin-browser:tree:added' + )[1]; + + const setChildren = (types) => { + const children = types.map((t) => ({_type: t})); + pgBrowser.tree = { + children: jest.fn(() => children), + itemData: (child) => child, + }; + }; + + beforeEach(() => { + pgBrowser.enable_disable_menus.mockClear(); + }); + + it('is enabled when the server has a pgTimeTable Chains collection', () => { + setChildren(['coll-database', 'coll-pgt_chain']); + expect(serverMenu.enable({connected: true}, {})).toBe(true); + }); + + it('is disabled when the collection is not present', () => { + setChildren(['coll-database', 'coll-role']); + expect(serverMenu.enable({connected: true}, {})).toBe(false); + }); + + it('is disabled when the server children are not loaded', () => { + pgBrowser.tree = {children: jest.fn(() => undefined), itemData: jest.fn()}; + expect(serverMenu.enable({connected: true}, {})).toBe(false); + }); + + it('is disabled when the server is not connected', () => { + setChildren(['coll-pgt_chain']); + expect(serverMenu.enable({connected: false}, {})).toBe(false); + }); + + it('refreshes the menus when the collection is added to the tree', () => { + addedHandler({}, {_type: 'coll-pgt_chain'}); + expect(pgBrowser.enable_disable_menus).toHaveBeenCalledTimes(1); + }); + + it('does not refresh the menus for other nodes', () => { + addedHandler({}, {_type: 'coll-database'}); + addedHandler({}, undefined); + expect(pgBrowser.enable_disable_menus).not.toHaveBeenCalled(); + }); +});