From bb530ced8d85235565f60cd122a6a39f6e0a29c6 Mon Sep 17 00:00:00 2001 From: Dave Page Date: Thu, 5 Nov 2026 11:42:25 +0000 Subject: [PATCH 1/5] Disable the server-level pgTimeTable Chain create menu when pg_timetable is not available. #10694 The create menu on the server was offered even when the maintenance database has no timetable schema, and saving then failed with a raw "relation timetable.chain does not exist" error and HTTP 500. The menu is now enabled only when the server has a pgTimeTable Chains collection node, which the backend adds only when pg_timetable is usable, and the chain endpoints now return a clear 400 error in that case instead of a 500. --- .../servers/pg_timetable/__init__.py | 74 +++++++++++----- .../pg_timetable/static/js/pgt_chain.js | 21 +++++ .../tests/test_pgt_chain_not_available.py | 78 +++++++++++++++++ .../browser/pgt_chain_menus_spec.js | 87 +++++++++++++++++++ 4 files changed, 238 insertions(+), 22 deletions(-) create mode 100644 web/pgadmin/browser/server_groups/servers/pg_timetable/tests/test_pgt_chain_not_available.py create mode 100644 web/regression/javascript/browser/pgt_chain_menus_spec.js 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..1240ef02f91 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,45 @@ 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 +PGT_CHAIN_ACCESS_SQL = """ +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' + ) +) +""" + + +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 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][0]) + + class ChainModule(CollectionNodeModule): _NODE_TYPE = 'pgt_chain' _COLLECTION_LABEL = _("pgTimeTable Chains") @@ -49,27 +83,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 +181,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 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..fd55ba937b6 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,21 @@ define('pgadmin.node.pgt_chain', [ }, }); } + + 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 +107,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..5b78dc99fe2 --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/tests/test_pgt_chain_not_available.py @@ -0,0 +1,78 @@ +########################################################################## +# +# 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 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): + 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): + 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/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(); + }); +}); From 03bf45bdc7cf3caf81e269826f8d04d68be10861 Mon Sep 17 00:00:00 2001 From: Dave Page Date: Thu, 5 Nov 2026 11:53:11 +0000 Subject: [PATCH 2/5] Read the pg_timetable access check result by column name, as the driver returns dict rows. --- .../browser/server_groups/servers/pg_timetable/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 1240ef02f91..2f5c001a55b 100644 --- a/web/pgadmin/browser/server_groups/servers/pg_timetable/__init__.py +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/__init__.py @@ -57,7 +57,7 @@ def has_pgt_chain_access(conn): if not status: return False, res rows = res['rows'] - return True, bool(rows and rows[0][0]) + return True, bool(rows and rows[0]['has_priviledge']) class ChainModule(CollectionNodeModule): From 2452e6621be5bec9b2f90b056eaabc103ab1b379 Mon Sep 17 00:00:00 2001 From: Dave Page Date: Thu, 5 Nov 2026 12:04:54 +0000 Subject: [PATCH 3/5] Require schema USAGE in the pg_timetable access check, and refresh the menus after a tree node refresh. --- .../servers/pg_timetable/__init__.py | 28 +++++++++---------- web/pgadmin/browser/static/js/browser.js | 2 ++ 2 files changed, 15 insertions(+), 15 deletions(-) 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 2f5c001a55b..e576849bc30 100644 --- a/web/pgadmin/browser/server_groups/servers/pg_timetable/__init__.py +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/__init__.py @@ -26,21 +26,18 @@ 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 - ( - 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' - ) -) +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 """ @@ -48,7 +45,8 @@ 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 can INSERT, SELECT and UPDATE timetable.chain. + 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. 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?.(); }, From 95731041845f7fea75244d3b67db84c62358a174 Mon Sep 17 00:00:00 2001 From: Dave Page Date: Thu, 5 Nov 2026 12:05:11 +0000 Subject: [PATCH 4/5] Add docstrings to the new pg_timetable test and menu helper. --- .../server_groups/servers/pg_timetable/static/js/pgt_chain.js | 1 + .../servers/pg_timetable/tests/test_pgt_chain_not_available.py | 2 ++ 2 files changed, 3 insertions(+) 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 fd55ba937b6..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 @@ -82,6 +82,7 @@ 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' 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 index 5b78dc99fe2..ce96ebb58f5 100644 --- 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 @@ -48,6 +48,7 @@ class PgtChainNotAvailableTestCase(BaseTestGenerator): ] 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} @@ -62,6 +63,7 @@ def setUp(self): 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, '') From a53bfeefd6d0ad8e3b3ab729dbd2cf3dd7b3cb44 Mon Sep 17 00:00:00 2001 From: Dave Page Date: Thu, 5 Nov 2026 12:23:47 +0000 Subject: [PATCH 5/5] Mention schema USAGE in the pg_timetable not-available error. --- .../browser/server_groups/servers/pg_timetable/__init__.py | 4 ++-- .../pg_timetable/tests/test_pgt_chain_not_available.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) 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 e576849bc30..e8b08fbe061 100644 --- a/web/pgadmin/browser/server_groups/servers/pg_timetable/__init__.py +++ b/web/pgadmin/browser/server_groups/servers/pg_timetable/__init__.py @@ -190,8 +190,8 @@ def wrap(self, *args, **kwargs): return bad_request(errormsg=_( "The pg_timetable schema was not found in the " "maintenance database, or the current role does not " - "have INSERT, SELECT and UPDATE privileges on " - "timetable.chain." + "have USAGE on it and INSERT, SELECT and UPDATE " + "privileges on timetable.chain." )) return f(self, *args, **kwargs) 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 index ce96ebb58f5..cf447161ab5 100644 --- 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 @@ -15,8 +15,8 @@ NOT_AVAILABLE_MSG = ( "The pg_timetable schema was not found in the maintenance database, " - "or the current role does not have INSERT, SELECT and UPDATE " - "privileges on timetable.chain." + "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'