From 8d8bcf374899dacba2a546d747f586ba2d97ab3d Mon Sep 17 00:00:00 2001 From: Dave Page Date: Thu, 15 Oct 2026 18:02:30 +0100 Subject: [PATCH] Fix the Query Tool not re-running the query after reconnecting at a password prompt. #10603 (#10605) --- .../js/components/sections/ResultSet.jsx | 14 +- .../sqleditor/ResultSetReconnect.spec.js | 163 ++++++++++++++++++ 2 files changed, 175 insertions(+), 2 deletions(-) create mode 100644 web/regression/javascript/sqleditor/ResultSetReconnect.spec.js diff --git a/web/pgadmin/tools/sqleditor/static/js/components/sections/ResultSet.jsx b/web/pgadmin/tools/sqleditor/static/js/components/sections/ResultSet.jsx index 0bd3337fc..019be32b6 100644 --- a/web/pgadmin/tools/sqleditor/static/js/components/sections/ResultSet.jsx +++ b/web/pgadmin/tools/sqleditor/static/js/components/sections/ResultSet.jsx @@ -253,10 +253,20 @@ export class ResultSetUtils { } } } catch(e) { + /* After reconnecting, re-initialise the transaction and re-run the + * query. REINIT_QT_CONNECTION is handled by initializeQueryTool(password, + * explainObject, macroSQL, executeCursor, executeServerCursor, reexecute): + * executeServerCursor only resets the status bar's server cursor text + * (queryFinished sets it again if the re-run uses one), so it is false, + * and reexecute must be true or the Query Tool does not re-run. #10603 + * The re-run rebuilds the query from the editor (or from macroSQL), so + * an external query without a macro, such as the toolbar's COMMIT or + * ROLLBACK, is not re-run: the editor's contents would run instead. */ + const reexecute = !(flags.external && _.isEmpty(macroSQL)); if(e?.response?.status == 428){ connectServerModal(this.queryToolCtx.modal, e.response?.data?.result, async (passwordData)=>{ await connectServer(this.api, this.queryToolCtx.modal, this.queryToolCtx.params.sid, this.queryToolCtx.params.user, passwordData, async ()=>{ - await this.eventBus.fireEvent(QUERY_TOOL_EVENTS.REINIT_QT_CONNECTION, '', explainObject, macroSQL, flags.executeCursor, true); + await this.eventBus.fireEvent(QUERY_TOOL_EVENTS.REINIT_QT_CONNECTION, '', explainObject, macroSQL, flags.executeCursor, false, reexecute); }); }, ()=>{ /*This is intentional (SonarQube)*/ @@ -265,7 +275,7 @@ export class ResultSetUtils { let pgBrowser = window.pgAdmin.Browser; pgBrowser.set_master_password('', async (passwordData)=>{ await connectServer(this.api, this.queryToolCtx.modal, this.queryToolCtx.params.sid, this.queryToolCtx.params.user, passwordData, async ()=>{ - await this.eventBus.fireEvent(QUERY_TOOL_EVENTS.REINIT_QT_CONNECTION, '', explainObject, macroSQL, flags.executeCursor, true); + await this.eventBus.fireEvent(QUERY_TOOL_EVENTS.REINIT_QT_CONNECTION, '', explainObject, macroSQL, flags.executeCursor, false, reexecute); }); }, ()=> { /*This is intentional (SonarQube)*/ diff --git a/web/regression/javascript/sqleditor/ResultSetReconnect.spec.js b/web/regression/javascript/sqleditor/ResultSetReconnect.spec.js new file mode 100644 index 000000000..89aa32283 --- /dev/null +++ b/web/regression/javascript/sqleditor/ResultSetReconnect.spec.js @@ -0,0 +1,163 @@ +///////////////////////////////////////////////////////////// +// +// pgAdmin 4 - PostgreSQL Tools +// +// Copyright (C) 2013 - 2026, The pgAdmin Development Team +// This software is released under the PostgreSQL Licence +// +////////////////////////////////////////////////////////////// + +// When a query needs the server password (HTTP 428) or the master password +// crypt key is missing, the user is prompted and the connection is +// re-initialised through REINIT_QT_CONNECTION, which QueryToolComponent hands +// to initializeQueryTool(password, explainObject, macroSQL, executeCursor, +// executeServerCursor, reexecute). reexecute must be true, or the Query Tool +// does not re-run the query, and executeServerCursor must be false. #10603 +// The re-run rebuilds the query from the editor or macroSQL, so an external +// query with no macro (the toolbar's COMMIT or ROLLBACK) is not re-run. + +jest.mock('sources/url_for', () => ({ + __esModule: true, + default: jest.fn((endpoint) => `/mock/${endpoint}`), +})); + +jest.mock('../../../pgadmin/tools/sqleditor/static/js/components/QueryToolComponent.jsx', () => { + const React = require('react'); + return { + QueryToolContext: React.createContext(null), + QueryToolConnectionContext: React.createContext(null), + QueryToolEventsContext: React.createContext(null), + }; +}); + +jest.mock('../../../pgadmin/static/js/components/ReactCodeMirror', () => ({ + __esModule: true, + default: ({ value }) => value, +})); + +jest.mock('../../../pgadmin/static/js/components/JsonEditor', () => ({ + __esModule: true, + default: () => null, +})); + +// ResultSet.jsx pulls in the map and chart viewers, which this test never +// renders and whose ESM-only dependencies Jest cannot load. +jest.mock('../../../pgadmin/tools/sqleditor/static/js/components/sections/GeometryViewer', () => ({ + GeometryViewer: () => null, +})); +jest.mock('../../../pgadmin/tools/sqleditor/static/js/components/sections/GraphVisualiser', () => ({ + GraphVisualiser: () => null, +})); + +// Accept the password prompt and the reconnection straight away. +jest.mock('../../../pgadmin/tools/sqleditor/static/js/components/connectServer', () => ({ + connectServerModal: jest.fn((_modal, _data, connectCallback) => connectCallback({password: 'secret'})), + connectServer: jest.fn(async (_api, _modal, _sid, _user, _formData, connectCallback) => connectCallback()), +})); + +import { ResultSetUtils } from '../../../pgadmin/tools/sqleditor/static/js/components/sections/ResultSet'; +import { QUERY_TOOL_EVENTS } from '../../../pgadmin/tools/sqleditor/static/js/components/QueryToolConstants'; + +describe('ResultSetUtils.startExecution reconnection', () => { + const explainObject = {analyze: false}; + const macroSQL = 'SELECT $SELECTION$'; + let eventBus, api, rsu, originalPgAdmin; + + beforeEach(() => { + originalPgAdmin = window.pgAdmin; + eventBus = { + fireEvent: jest.fn(), + registerListener: jest.fn(), + deregisterListener: jest.fn(), + }; + api = {post: jest.fn(), get: jest.fn()}; + rsu = new ResultSetUtils(api, {modal: {}, params: {sid: 1, user: 'postgres'}}, 1234, true); + rsu.setEventBus(eventBus); + }); + + afterEach(() => { + window.pgAdmin = originalPgAdmin; + }); + + const reinitCalls = () => eventBus.fireEvent.mock.calls.filter( + (c) => c[0] === QUERY_TOOL_EVENTS.REINIT_QT_CONNECTION + ); + + // Name the arguments as initializeQueryTool receives them. + const reinitArgs = (call) => { + const [, password, explain, macro, executeCursor, executeServerCursor, reexecute] = call; + return {password, explain, macro, executeCursor, executeServerCursor, reexecute}; + }; + + // The prompt callbacks are not awaited by startExecution, so let them finish. + const flushPromises = () => new Promise((resolve) => setTimeout(resolve, 0)); + + const expectRerun = async () => { + await flushPromises(); + const calls = reinitCalls(); + expect(calls).toHaveLength(1); + expect(reinitArgs(calls[0])).toEqual({ + password: '', + explain: explainObject, + macro: macroSQL, + executeCursor: true, + executeServerCursor: false, + reexecute: true, + }); + }; + + const flags = {isQueryTool: true, external: false, reconnect: false, executeCursor: true, refreshData: false}; + + it('re-runs the query after the server password prompt', async () => { + api.post.mockRejectedValue({response: {status: 428, data: {result: {}}}}); + + await rsu.startExecution('SELECT 1', explainObject, macroSQL, jest.fn(), flags); + + await expectRerun(); + }); + + it('re-runs the query after the master password prompt', async () => { + window.pgAdmin = {Browser: {set_master_password: jest.fn((_p, cb) => cb({password: 'secret'}))}}; + api.post.mockRejectedValue({response: {status: 500, data: {info: 'CRYPTKEY_MISSING'}}}); + + await rsu.startExecution('SELECT 1', explainObject, macroSQL, jest.fn(), flags); + + await expectRerun(); + }); + + // The toolbar's COMMIT and ROLLBACK run external queries with no macro, and + // a re-run would rebuild the query from the editor rather than run them. + const externalFlags = {...flags, external: true, executeCursor: false}; + + const expectNoRerun = async () => { + await flushPromises(); + const calls = reinitCalls(); + expect(calls).toHaveLength(1); + expect(reinitArgs(calls[0]).reexecute).toBe(false); + }; + + it('does not re-run an external query without a macro after the server password prompt', async () => { + api.post.mockRejectedValue({response: {status: 428, data: {result: {}}}}); + + await rsu.startExecution('COMMIT;', null, undefined, jest.fn(), externalFlags); + + await expectNoRerun(); + }); + + it('does not re-run an external query without a macro after the master password prompt', async () => { + window.pgAdmin = {Browser: {set_master_password: jest.fn((_p, cb) => cb({password: 'secret'}))}}; + api.post.mockRejectedValue({response: {status: 500, data: {info: 'CRYPTKEY_MISSING'}}}); + + await rsu.startExecution('ROLLBACK;', null, undefined, jest.fn(), externalFlags); + + await expectNoRerun(); + }); + + it('re-runs an external macro query, which rebuilds from macroSQL', async () => { + api.post.mockRejectedValue({response: {status: 428, data: {result: {}}}}); + + await rsu.startExecution('SELECT 1', explainObject, macroSQL, jest.fn(), {...flags, external: true}); + + await expectRerun(); + }); +});