Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 50 additions & 22 deletions web/pgadmin/browser/server_groups/servers/pg_timetable/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
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")
Expand All @@ -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):
Expand Down Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
});

Comment thread
dpage marked this conversation as resolved.
Resolved
pgBrowser.add_menus([
{
name: 'create_pgt_chain_on_server',
Expand All @@ -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',
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
2 changes: 2 additions & 0 deletions web/pgadmin/browser/static/js/browser.js
Original file line number Diff line number Diff line change
Expand Up @@ -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?.();
},
Expand Down
87 changes: 87 additions & 0 deletions web/regression/javascript/browser/pgt_chain_menus_spec.js
Original file line number Diff line number Diff line change
@@ -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();
});
});
Loading