From 755e70bc8c3ec6458304ee5fb3d31045746adf30 Mon Sep 17 00:00:00 2001 From: Suren Date: Thu, 6 Nov 2025 16:30:30 +0530 Subject: [PATCH 01/10] Implement dynamic request configurations --- geonode_mapstore_client/client/MapStore2 | 2 +- .../client/js/actions/gnsecurity.js | 15 + .../client/js/api/geonode/security/index.js | 6 + .../client/js/apps/gn-catalogue.js | 4 +- .../client/js/apps/gn-components.js | 4 +- .../client/js/apps/gn-dashboard.js | 4 +- .../client/js/apps/gn-document.js | 4 +- .../client/js/apps/gn-geostory.js | 4 +- .../client/js/apps/gn-map.js | 4 +- .../js/epics/__tests__/security-test.js | 485 ++++++++++++++++++ .../client/js/epics/security.js | 99 ++++ .../client/js/utils/AppUtils.js | 2 +- 12 files changed, 625 insertions(+), 8 deletions(-) create mode 100644 geonode_mapstore_client/client/js/actions/gnsecurity.js create mode 100644 geonode_mapstore_client/client/js/epics/__tests__/security-test.js create mode 100644 geonode_mapstore_client/client/js/epics/security.js diff --git a/geonode_mapstore_client/client/MapStore2 b/geonode_mapstore_client/client/MapStore2 index b82943a5ab..763e0cf1e6 160000 --- a/geonode_mapstore_client/client/MapStore2 +++ b/geonode_mapstore_client/client/MapStore2 @@ -1 +1 @@ -Subproject commit b82943a5abf30348b7e6509f0ad3cb0da3ffe5d8 +Subproject commit 763e0cf1e64f2207463197eb6cabebfc2de0beb3 diff --git a/geonode_mapstore_client/client/js/actions/gnsecurity.js b/geonode_mapstore_client/client/js/actions/gnsecurity.js new file mode 100644 index 0000000000..4e1c265032 --- /dev/null +++ b/geonode_mapstore_client/client/js/actions/gnsecurity.js @@ -0,0 +1,15 @@ +/* + * Copyright 2025, GeoSolutions Sas. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +export const RULE_EXPIRED = 'GEONODE_SECURITY:RULE_EXPIRED'; + +export function ruleExpired() { + return { + type: RULE_EXPIRED + }; +} diff --git a/geonode_mapstore_client/client/js/api/geonode/security/index.js b/geonode_mapstore_client/client/js/api/geonode/security/index.js index c867107d7f..57be20c2ca 100644 --- a/geonode_mapstore_client/client/js/api/geonode/security/index.js +++ b/geonode_mapstore_client/client/js/api/geonode/security/index.js @@ -10,6 +10,7 @@ import axios from '@mapstore/framework/libs/ajax'; import WKT from 'ol/format/WKT'; import GeoJSON from 'ol/format/GeoJSON'; import uuid from 'uuid'; +import { getEndpointUrl, USERS } from '../v2/constants'; const wktFormat = new WKT(); const geoJSONFormat = new GeoJSON(); @@ -78,3 +79,8 @@ export const deleteGeoLimits = (resourceId, id, type = 'user') => { return axios.delete(`/security/geolimits/${resourceId}?${type}_id=${id}`) .then(({ data }) => data); }; + +export const getRequestConfigurationRulesByUserPK = (pk) => { + return axios.get(getEndpointUrl(USERS, `/${pk}/rules`)) + .then(({ data }) => data); +}; diff --git a/geonode_mapstore_client/client/js/apps/gn-catalogue.js b/geonode_mapstore_client/client/js/apps/gn-catalogue.js index ecdd03ba7a..34903a0998 100644 --- a/geonode_mapstore_client/client/js/apps/gn-catalogue.js +++ b/geonode_mapstore_client/client/js/apps/gn-catalogue.js @@ -72,6 +72,7 @@ import { import timelineEpics from '@mapstore/framework/epics/timeline'; import gnresourceEpics from '@js/epics/gnresource'; +import securityEpics from '@js/epics/security'; import resourceServiceEpics from '@js/epics/resourceservice'; import maplayout from '@mapstore/framework/reducers/maplayout'; @@ -149,7 +150,8 @@ getEndpoints() ...resourceServiceEpics, updateMapLayoutEpic, // needed to initialize the correct time range - ...timelineEpics + ...timelineEpics, + ...securityEpics }); storeEpicsNamesToExclude(appEpics); diff --git a/geonode_mapstore_client/client/js/apps/gn-components.js b/geonode_mapstore_client/client/js/apps/gn-components.js index 983b964a2c..dd3e19fcd6 100644 --- a/geonode_mapstore_client/client/js/apps/gn-components.js +++ b/geonode_mapstore_client/client/js/apps/gn-components.js @@ -30,6 +30,7 @@ import { updateGeoNodeSettings } from '@js/actions/gnsettings'; import { COMPONENTS_ROUTES, appRouteComponentTypes } from '@js/utils/AppRoutesUtils'; import gnresourceEpics from '@js/epics/gnresource'; import resourceServiceEpics from '@js/epics/resourceservice'; +import securityEpics from '@js/epics/security'; import gnresource from '@js/reducers/gnresource'; import resourceservice from '@js/reducers/resourceservice'; @@ -73,7 +74,8 @@ document.addEventListener('DOMContentLoaded', function() { const appEpics = cleanEpics({ ...configEpics, ...gnresourceEpics, - ...resourceServiceEpics + ...resourceServiceEpics, + ...securityEpics }); storeEpicsNamesToExclude(appEpics); diff --git a/geonode_mapstore_client/client/js/apps/gn-dashboard.js b/geonode_mapstore_client/client/js/apps/gn-dashboard.js index 2e27abc7dd..22f1eef1f1 100644 --- a/geonode_mapstore_client/client/js/apps/gn-dashboard.js +++ b/geonode_mapstore_client/client/js/apps/gn-dashboard.js @@ -37,6 +37,7 @@ import ReactSwipe from 'react-swipeable-views'; import SwipeHeader from '@mapstore/framework/components/data/identify/SwipeHeader'; import { requestResourceConfig } from '@js/actions/gnresource'; import gnresourceEpics from '@js/epics/gnresource'; +import securityEpics from '@js/epics/security'; const requires = { ReactSwipe, SwipeHeader @@ -81,7 +82,8 @@ document.addEventListener('DOMContentLoaded', function() { const appEpics = cleanEpics({ ...configEpics, - ...gnresourceEpics + ...gnresourceEpics, + ...securityEpics }); storeEpicsNamesToExclude(appEpics); diff --git a/geonode_mapstore_client/client/js/apps/gn-document.js b/geonode_mapstore_client/client/js/apps/gn-document.js index 4e79c59c26..522d19a436 100644 --- a/geonode_mapstore_client/client/js/apps/gn-document.js +++ b/geonode_mapstore_client/client/js/apps/gn-document.js @@ -33,6 +33,7 @@ import ReactSwipe from 'react-swipeable-views'; import SwipeHeader from '@mapstore/framework/components/data/identify/SwipeHeader'; import { requestResourceConfig } from '@js/actions/gnresource'; import gnresourceEpics from '@js/epics/gnresource'; +import securityEpics from '@js/epics/security'; const requires = { ReactSwipe, SwipeHeader @@ -76,7 +77,8 @@ document.addEventListener('DOMContentLoaded', function() { const appEpics = cleanEpics({ ...configEpics, - ...gnresourceEpics + ...gnresourceEpics, + ...securityEpics }); storeEpicsNamesToExclude(appEpics); diff --git a/geonode_mapstore_client/client/js/apps/gn-geostory.js b/geonode_mapstore_client/client/js/apps/gn-geostory.js index 4497636b7c..8e58e33704 100644 --- a/geonode_mapstore_client/client/js/apps/gn-geostory.js +++ b/geonode_mapstore_client/client/js/apps/gn-geostory.js @@ -26,6 +26,7 @@ import { import { updateGeoNodeSettings } from '@js/actions/gnsettings'; import { requestResourceConfig } from '@js/actions/gnresource'; import gnresourceEpics from '@js/epics/gnresource'; +import securityEpics from '@js/epics/security'; import { setupConfiguration, initializeApp, @@ -84,7 +85,8 @@ document.addEventListener('DOMContentLoaded', function() { const appEpics = cleanEpics({ ...configEpics, - ...gnresourceEpics + ...gnresourceEpics, + ...securityEpics }); storeEpicsNamesToExclude(appEpics); diff --git a/geonode_mapstore_client/client/js/apps/gn-map.js b/geonode_mapstore_client/client/js/apps/gn-map.js index c031e81f23..49970e4678 100644 --- a/geonode_mapstore_client/client/js/apps/gn-map.js +++ b/geonode_mapstore_client/client/js/apps/gn-map.js @@ -64,6 +64,7 @@ import { import timelineEpics from '@mapstore/framework/epics/timeline'; import gnresourceEpics from '@js/epics/gnresource'; +import securityEpics from '@js/epics/security'; import maplayout from '@mapstore/framework/reducers/maplayout'; import 'react-widgets/dist/css/react-widgets.css'; import 'react-select/dist/react-select.css'; @@ -130,7 +131,8 @@ document.addEventListener('DOMContentLoaded', function() { ...gnresourceEpics, ...pluginsDefinition.epics, // needed to initialize the correct time range - ...timelineEpics + ...timelineEpics, + ...securityEpics }); storeEpicsNamesToExclude(appEpics); diff --git a/geonode_mapstore_client/client/js/epics/__tests__/security-test.js b/geonode_mapstore_client/client/js/epics/__tests__/security-test.js new file mode 100644 index 0000000000..48a61069fd --- /dev/null +++ b/geonode_mapstore_client/client/js/epics/__tests__/security-test.js @@ -0,0 +1,485 @@ +/* + * Copyright 2025, GeoSolutions Sas. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +import expect from 'expect'; +import MockAdapter from 'axios-mock-adapter'; +import axios from '@mapstore/framework/libs/ajax'; +import { testEpic } from '@mapstore/framework/epics/__tests__/epicTestUtils'; +import { + LOAD_REQUESTS_RULES, + UPDATE_REQUESTS_RULES, + updateRequestsRules, + loadRequestsRulesError +} from '@mapstore/framework/actions/security'; +import { RULE_EXPIRED, ruleExpired } from '@js/actions/gnsecurity'; +import { + gnUpdateRequestConfigurationRulesEpic, + gnRuleExpiredEpic +} from '../security'; + +let mockAxios; + +describe('security epics', () => { + beforeEach(done => { + global.__DEVTOOLS__ = true; + mockAxios = new MockAdapter(axios); + setTimeout(done); + }); + + afterEach(done => { + delete global.__DEVTOOLS__; + mockAxios.restore(); + setTimeout(done); + }); + + describe('gnUpdateRequestConfigurationRulesEpic', () => { + it('should fetch and update rules when LOAD_REQUESTS_RULES is dispatched with valid user', (done) => { + const NUM_ACTIONS = 1; + const userPk = 1; + const futureDate = new Date(Date.now() + 60 * 60 * 1000).toISOString(); // 1 hour from now + const mockRules = { + rules: [ + { + urlPattern: 'http://localhost/geoserver//.*', + params: { + access_token: 'token123' + } + }, + { + urlPattern: 'localhost/gs.*', + params: { + access_token: 'token456' + }, + expires: futureDate + } + ] + }; + + const testState = { + security: { + user: { + pk: userPk + } + } + }; + + mockAxios.onGet(new RegExp(`/api/v2/users/${userPk}/rules`)).reply(200, mockRules); + + testEpic( + gnUpdateRequestConfigurationRulesEpic, + NUM_ACTIONS, + { type: LOAD_REQUESTS_RULES }, + (actions) => { + try { + expect(actions.length).toBe(1); + expect(actions[0].type).toBe(UPDATE_REQUESTS_RULES); + expect(actions[0].rules).toEqual(mockRules.rules); + done(); + } catch (e) { + done(e); + } + }, + testState + ); + }); + + it('should fetch and update rules when RULE_EXPIRED is dispatched', (done) => { + const NUM_ACTIONS = 1; + const userPk = 1; + const mockRules = { + rules: [ + { + urlPattern: 'http://localhost/geoserver//.*', + params: { + access_token: 'token123' + } + } + ] + }; + + const testState = { + security: { + user: { + pk: userPk + } + } + }; + + mockAxios.onGet(new RegExp(`/api/v2/users/${userPk}/rules`)).reply(200, mockRules); + + testEpic( + gnUpdateRequestConfigurationRulesEpic, + NUM_ACTIONS, + ruleExpired(), + (actions) => { + try { + expect(actions.length).toBe(1); + expect(actions[0].type).toBe(UPDATE_REQUESTS_RULES); + expect(actions[0].rules).toEqual(mockRules.rules); + done(); + } catch (e) { + done(e); + } + }, + testState + ); + }); + + it('should return empty observable when userPk is not available', (done) => { + const NUM_ACTIONS = 0; + const testState = { + security: { + user: null + } + }; + + testEpic( + gnUpdateRequestConfigurationRulesEpic, + NUM_ACTIONS, + { type: LOAD_REQUESTS_RULES }, + (actions) => { + try { + expect(actions.length).toBe(0); + done(); + } catch (e) { + done(e); + } + }, + testState + ); + }); + + it('should handle API error and dispatch loadRequestsRulesError', (done) => { + const NUM_ACTIONS = 1; + const userPk = 1; + const testState = { + security: { + user: { + pk: userPk + } + } + }; + + mockAxios.onGet(new RegExp(`/api/v2/users/${userPk}/rules`)).reply(500, { error: 'Server error' }); + + testEpic( + gnUpdateRequestConfigurationRulesEpic, + NUM_ACTIONS, + { type: LOAD_REQUESTS_RULES }, + (actions) => { + try { + expect(actions.length).toBe(1); + expect(actions[0].type).toBe(loadRequestsRulesError().type); + expect(actions[0].error).toBeTruthy(); + done(); + } catch (e) { + done(e); + } + }, + testState + ); + }); + + it('should deduplicate rules by urlPattern', (done) => { + const NUM_ACTIONS = 1; + const userPk = 1; + const mockRules = { + rules: [ + { + urlPattern: 'http://localhost/geoserver//.*', + params: { + access_token: 'token123' + } + }, + { + urlPattern: 'http://localhost/geoserver//.*', + params: { + access_token: 'token456' + } + }, + { + urlPattern: 'localhost/gs.*', + params: { + access_token: 'token789' + } + } + ] + }; + + const testState = { + security: { + user: { + pk: userPk + } + } + }; + + mockAxios.onGet(new RegExp(`/api/v2/users/${userPk}/rules`)).reply(200, mockRules); + + testEpic( + gnUpdateRequestConfigurationRulesEpic, + NUM_ACTIONS, + { type: LOAD_REQUESTS_RULES }, + (actions) => { + try { + expect(actions.length).toBe(1); + expect(actions[0].type).toBe(UPDATE_REQUESTS_RULES); + // Should have only 2 unique rules (duplicate removed) + expect(actions[0].rules.length).toBe(2); + expect(actions[0].rules[0].urlPattern).toBe('http://localhost/geoserver//.*'); + expect(actions[0].rules[1].urlPattern).toBe('localhost/gs.*'); + done(); + } catch (e) { + done(e); + } + }, + testState + ); + }); + + it('should handle empty rules array', (done) => { + const NUM_ACTIONS = 1; + const userPk = 1; + const mockRules = { + rules: [] + }; + + const testState = { + security: { + user: { + pk: userPk + } + } + }; + + mockAxios.onGet(new RegExp(`/api/v2/users/${userPk}/rules`)).reply(200, mockRules); + + testEpic( + gnUpdateRequestConfigurationRulesEpic, + NUM_ACTIONS, + { type: LOAD_REQUESTS_RULES }, + (actions) => { + try { + expect(actions.length).toBe(1); + expect(actions[0].type).toBe(UPDATE_REQUESTS_RULES); + expect(actions[0].rules).toEqual([]); + done(); + } catch (e) { + done(e); + } + }, + testState + ); + }); + }); + + describe('gnRuleExpiredEpic', () => { + it('should dispatch ruleExpired when rules contain expired rule', (done) => { + const NUM_ACTIONS = 1; + const expiredDate = new Date(Date.now() - 1000).toISOString(); // 1 second ago + const rulesArray = [ + { + urlPattern: 'http://localhost/geoserver//.*', + params: { + access_token: 'token123' + }, + expires: expiredDate + } + ]; + + const testState = { + security: { + rules: rulesArray + } + }; + + testEpic( + gnRuleExpiredEpic, + NUM_ACTIONS, + updateRequestsRules(rulesArray), + (actions) => { + try { + expect(actions.length).toBe(1); + expect(actions[0].type).toBe(RULE_EXPIRED); + done(); + } catch (e) { + done(e); + } + }, + testState + ); + }); + + it('should dispatch ruleExpired when rules contain expiring rule (within 5 minutes)', (done) => { + const NUM_ACTIONS = 1; + const expiringDate = new Date(Date.now() + 4 * 60 * 1000).toISOString(); // 4 minutes from now + const rulesArray = [ + { + urlPattern: 'http://localhost/geoserver//.*', + params: { + access_token: 'token123' + }, + expires: expiringDate + } + ]; + + const testState = { + security: { + rules: rulesArray + } + }; + + testEpic( + gnRuleExpiredEpic, + NUM_ACTIONS, + updateRequestsRules(rulesArray), + (actions) => { + try { + expect(actions.length).toBe(1); + expect(actions[0].type).toBe(RULE_EXPIRED); + done(); + } catch (e) { + done(e); + } + }, + testState + ); + }); + + it('should not dispatch ruleExpired when rules do not contain expired or expiring rules', (done) => { + const NUM_ACTIONS = 0; + const futureDate = new Date(Date.now() + 10 * 60 * 1000).toISOString(); // 10 minutes from now + const rulesArray = [ + { + urlPattern: 'http://localhost/geoserver//.*', + params: { + access_token: 'token123' + }, + expires: futureDate + } + ]; + + const testState = { + security: { + rules: rulesArray + } + }; + + testEpic( + gnRuleExpiredEpic, + NUM_ACTIONS, + updateRequestsRules(rulesArray), + (actions) => { + try { + expect(actions.length).toBe(0); + done(); + } catch (e) { + done(e); + } + }, + testState + ); + }); + + it('should not dispatch ruleExpired when rules have no expires field', (done) => { + const NUM_ACTIONS = 0; + const rulesArray = [ + { + urlPattern: 'http://localhost/geoserver//.*', + params: { + access_token: 'token123' + } + } + ]; + + const testState = { + security: { + rules: rulesArray + } + }; + + testEpic( + gnRuleExpiredEpic, + NUM_ACTIONS, + updateRequestsRules(rulesArray), + (actions) => { + try { + expect(actions.length).toBe(0); + done(); + } catch (e) { + done(e); + } + }, + testState + ); + }); + + it('should handle rules as direct array format', (done) => { + const NUM_ACTIONS = 1; + const expiredDate = new Date(Date.now() - 1000).toISOString(); + const rulesArray = [ + { + urlPattern: 'http://localhost/geoserver//.*', + params: { + access_token: 'token123' + }, + expires: expiredDate + } + ]; + + const testState = { + security: { + rules: rulesArray + } + }; + + testEpic( + gnRuleExpiredEpic, + NUM_ACTIONS, + updateRequestsRules(rulesArray), + (actions) => { + try { + expect(actions.length).toBe(1); + expect(actions[0].type).toBe(RULE_EXPIRED); + done(); + } catch (e) { + done(e); + } + }, + testState + ); + }); + + it('should handle empty rules array', (done) => { + const NUM_ACTIONS = 0; + const rulesArray = []; + + const testState = { + security: { + rules: [] + } + }; + + testEpic( + gnRuleExpiredEpic, + NUM_ACTIONS, + updateRequestsRules(rulesArray), + (actions) => { + try { + expect(actions.length).toBe(0); + done(); + } catch (e) { + done(e); + } + }, + testState + ); + }); + }); +}); + diff --git a/geonode_mapstore_client/client/js/epics/security.js b/geonode_mapstore_client/client/js/epics/security.js new file mode 100644 index 0000000000..3348ba2aef --- /dev/null +++ b/geonode_mapstore_client/client/js/epics/security.js @@ -0,0 +1,99 @@ +/* + * Copyright 2025, GeoSolutions Sas. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { Observable } from 'rxjs'; +import uniqBy from 'lodash/uniqBy'; +import { + LOAD_REQUESTS_RULES, + UPDATE_REQUESTS_RULES, + updateRequestsRules, + loadRequestsRulesError +} from '@mapstore/framework/actions/security'; +import { getRequestConfigurationRulesByUserPK } from '@js/api/geonode/security'; +import { RULE_EXPIRED, ruleExpired } from '@js/actions/gnsecurity'; +import { userSelector, requestsRulesSelector } from '@mapstore/framework/selectors/security'; + +/** +* @module epics/security +*/ + +/** + * Epic to fetch request configuration rules and update the store + */ +export const gnUpdateRequestConfigurationRulesEpic = (action$, store) => + action$.ofType(LOAD_REQUESTS_RULES, RULE_EXPIRED) + .switchMap(() => { + const userPk = userSelector(store.getState())?.pk; + if (!userPk) { + return Observable.empty(); + } + return Observable.defer(() => getRequestConfigurationRulesByUserPK(userPk)) + .switchMap((data) => { + const uniqRules = uniqBy(data.rules ?? [], 'urlPattern'); + return Observable.of(updateRequestsRules(uniqRules)); + }) + .catch((error) => Observable.of(loadRequestsRulesError(error))); + }); + +/** + * Helper function to check if a rule has expired or is about to expire + * @param {string} expires - ISO date string + * @param {number} warningThreshold - Milliseconds before expiration to trigger warning (default: 5 minutes) + * @returns {boolean} + */ +const isRuleExpiredOrExpiring = (expires, warningThreshold = 300000) => { + if (!expires) { + return false; + } + const expirationDate = new Date(expires); + const now = new Date(); + const timeUntilExpiration = expirationDate.getTime() - now.getTime(); + return timeUntilExpiration <= warningThreshold; +}; + +/** + * Epic to check if request configuration rules have expired or are about to expire + * Monitors rules when they're updated and sets up periodic checks + */ +export const gnRuleExpiredEpic = (action$, store) => { + // Check rules immediately when they're updated + const checkOnUpdate$ = action$ + .ofType(UPDATE_REQUESTS_RULES) + .map(({ rules: rulesData }) => { + const rules = rulesData?.rules || (Array.isArray(rulesData) ? rulesData : []); + const hasExpiredRule = rules.some((rule) => isRuleExpiredOrExpiring(rule.expires)); + return hasExpiredRule; + }) + .filter((hasExpired) => hasExpired) + .map(() => ruleExpired()); + + // Set up periodic check every minute to catch expiring rules + const periodicCheck$ = action$ + .ofType(UPDATE_REQUESTS_RULES) + .switchMap(() => { + return Observable.interval(60 * 1000) // Check every minute + .startWith(0) // Check immediately + .map(() => { + const state = store.getState(); + const rulesData = requestsRulesSelector(state); + const rules = rulesData?.rules || (Array.isArray(rulesData) ? rulesData : []); + return rules.some((rule) => isRuleExpiredOrExpiring(rule.expires)); + }) + .distinctUntilChanged() // Only emit when value changes + .filter((hasExpired) => hasExpired) + .map(() => ruleExpired()) + .takeUntil(action$.ofType(UPDATE_REQUESTS_RULES)); // Stop when rules are updated + }); + + return Observable.merge(checkOnUpdate$, periodicCheck$); +}; + +export default { + gnUpdateRequestConfigurationRulesEpic, + gnRuleExpiredEpic +}; diff --git a/geonode_mapstore_client/client/js/utils/AppUtils.js b/geonode_mapstore_client/client/js/utils/AppUtils.js index f23caaf72e..5d5ac3551c 100644 --- a/geonode_mapstore_client/client/js/utils/AppUtils.js +++ b/geonode_mapstore_client/client/js/utils/AppUtils.js @@ -98,7 +98,7 @@ export function initializeApp() { } ); // Set proxy and authentication from geonode config - ['proxyUrl', 'useAuthenticationRules', 'authenticationRules'].forEach(key=> { + ['proxyUrl', 'useAuthenticationRules', 'authenticationRules', 'requestsConfigurationRules'].forEach(key=> { setConfigProp(key, getGeoNodeLocalConfig(key)); }); } From ed0845009cadbf63656f6373f81dc09ff540ea2b Mon Sep 17 00:00:00 2001 From: Suren Date: Thu, 20 Nov 2025 04:13:47 +0530 Subject: [PATCH 02/10] expiry token refresh logic updated --- .../js/epics/__tests__/security-test.js | 99 ++++++++++++++++--- .../client/js/epics/security.js | 49 ++++----- 2 files changed, 106 insertions(+), 42 deletions(-) diff --git a/geonode_mapstore_client/client/js/epics/__tests__/security-test.js b/geonode_mapstore_client/client/js/epics/__tests__/security-test.js index 48a61069fd..987d53f3a7 100644 --- a/geonode_mapstore_client/client/js/epics/__tests__/security-test.js +++ b/geonode_mapstore_client/client/js/epics/__tests__/security-test.js @@ -16,7 +16,7 @@ import { updateRequestsRules, loadRequestsRulesError } from '@mapstore/framework/actions/security'; -import { RULE_EXPIRED, ruleExpired } from '@js/actions/gnsecurity'; +import { ruleExpired } from '@js/actions/gnsecurity'; import { gnUpdateRequestConfigurationRulesEpic, gnRuleExpiredEpic @@ -279,8 +279,13 @@ describe('security epics', () => { }); describe('gnRuleExpiredEpic', () => { - it('should dispatch ruleExpired when rules contain expired rule', (done) => { - const NUM_ACTIONS = 1; + // This test verifies that the epic can dispatch RULE_EXPIRED. + // Note: Due to rate limiting (lastRefreshTime is updated before check), + // the immediate check is blocked. The epic will dispatch after the 60s interval. + // For unit testing, we verify the epic structure and that it processes expired rules. + + it('should dispatch ruleExpired when expired rules are detected and rate limit allows', (done) => { + const NUM_ACTIONS = 0; // Rate limiting blocks immediate dispatch const expiredDate = new Date(Date.now() - 1000).toISOString(); // 1 second ago const rulesArray = [ { @@ -304,8 +309,7 @@ describe('security epics', () => { updateRequestsRules(rulesArray), (actions) => { try { - expect(actions.length).toBe(1); - expect(actions[0].type).toBe(RULE_EXPIRED); + expect(actions.length).toBe(0); done(); } catch (e) { done(e); @@ -315,8 +319,43 @@ describe('security epics', () => { ); }); - it('should dispatch ruleExpired when rules contain expiring rule (within 5 minutes)', (done) => { - const NUM_ACTIONS = 1; + it('should not dispatch ruleExpired immediately when rules are updated (rate limited)', (done) => { + const NUM_ACTIONS = 0; + const expiredDate = new Date(Date.now() - 1000).toISOString(); // 1 second ago + const rulesArray = [ + { + urlPattern: 'http://localhost/geoserver//.*', + params: { + access_token: 'token123' + }, + expires: expiredDate + } + ]; + + const testState = { + security: { + rules: rulesArray + } + }; + + testEpic( + gnRuleExpiredEpic, + NUM_ACTIONS, + updateRequestsRules(rulesArray), + (actions) => { + try { + expect(actions.length).toBe(0); + done(); + } catch (e) { + done(e); + } + }, + testState + ); + }); + + it('should not dispatch ruleExpired immediately when expiring rules are updated (rate limited)', (done) => { + const NUM_ACTIONS = 0; const expiringDate = new Date(Date.now() + 4 * 60 * 1000).toISOString(); // 4 minutes from now const rulesArray = [ { @@ -340,8 +379,7 @@ describe('security epics', () => { updateRequestsRules(rulesArray), (actions) => { try { - expect(actions.length).toBe(1); - expect(actions[0].type).toBe(RULE_EXPIRED); + expect(actions.length).toBe(0); done(); } catch (e) { done(e); @@ -419,8 +457,8 @@ describe('security epics', () => { ); }); - it('should handle rules as direct array format', (done) => { - const NUM_ACTIONS = 1; + it('should handle rules as direct array format (rate limited on update)', (done) => { + const NUM_ACTIONS = 0; const expiredDate = new Date(Date.now() - 1000).toISOString(); const rulesArray = [ { @@ -444,8 +482,7 @@ describe('security epics', () => { updateRequestsRules(rulesArray), (actions) => { try { - expect(actions.length).toBe(1); - expect(actions[0].type).toBe(RULE_EXPIRED); + expect(actions.length).toBe(0); done(); } catch (e) { done(e); @@ -480,6 +517,42 @@ describe('security epics', () => { testState ); }); + + it('should handle Unix timestamp expires value (rate limited on update)', (done) => { + const NUM_ACTIONS = 0; + // Unix timestamp in seconds (expired - 1 hour ago) + const expiredTimestamp = Math.floor((Date.now() - 60 * 60 * 1000) / 1000); + const rulesArray = [ + { + urlPattern: 'http://localhost/geoserver//.*', + params: { + access_token: 'token123' + }, + expires: expiredTimestamp + } + ]; + + const testState = { + security: { + rules: rulesArray + } + }; + + testEpic( + gnRuleExpiredEpic, + NUM_ACTIONS, + updateRequestsRules(rulesArray), + (actions) => { + try { + expect(actions.length).toBe(0); + done(); + } catch (e) { + done(e); + } + }, + testState + ); + }); }); }); diff --git a/geonode_mapstore_client/client/js/epics/security.js b/geonode_mapstore_client/client/js/epics/security.js index 3348ba2aef..e46986684b 100644 --- a/geonode_mapstore_client/client/js/epics/security.js +++ b/geonode_mapstore_client/client/js/epics/security.js @@ -22,6 +22,8 @@ import { userSelector, requestsRulesSelector } from '@mapstore/framework/selecto * @module epics/security */ +const RULE_EXPIRATION_CHECK_INTERVAL = 60 * 1000; + /** * Epic to fetch request configuration rules and update the store */ @@ -42,7 +44,7 @@ export const gnUpdateRequestConfigurationRulesEpic = (action$, store) => /** * Helper function to check if a rule has expired or is about to expire - * @param {string} expires - ISO date string + * @param {string|number} expires - ISO date string or Unix timestamp (in seconds) * @param {number} warningThreshold - Milliseconds before expiration to trigger warning (default: 5 minutes) * @returns {boolean} */ @@ -50,7 +52,10 @@ const isRuleExpiredOrExpiring = (expires, warningThreshold = 300000) => { if (!expires) { return false; } - const expirationDate = new Date(expires); + // Handle Unix timestamp (in seconds) - convert to milliseconds + const expirationDate = typeof expires === 'number' + ? new Date(expires * 1000) + : new Date(expires); const now = new Date(); const timeUntilExpiration = expirationDate.getTime() - now.getTime(); return timeUntilExpiration <= warningThreshold; @@ -58,39 +63,25 @@ const isRuleExpiredOrExpiring = (expires, warningThreshold = 300000) => { /** * Epic to check if request configuration rules have expired or are about to expire - * Monitors rules when they're updated and sets up periodic checks + * Periodically checks every minute when rules are expired and triggers API refresh */ export const gnRuleExpiredEpic = (action$, store) => { - // Check rules immediately when they're updated - const checkOnUpdate$ = action$ - .ofType(UPDATE_REQUESTS_RULES) - .map(({ rules: rulesData }) => { - const rules = rulesData?.rules || (Array.isArray(rulesData) ? rulesData : []); - const hasExpiredRule = rules.some((rule) => isRuleExpiredOrExpiring(rule.expires)); - return hasExpiredRule; - }) - .filter((hasExpired) => hasExpired) - .map(() => ruleExpired()); - - // Set up periodic check every minute to catch expiring rules - const periodicCheck$ = action$ - .ofType(UPDATE_REQUESTS_RULES) + let lastRefreshTime = 0; + return action$.ofType(UPDATE_REQUESTS_RULES) .switchMap(() => { - return Observable.interval(60 * 1000) // Check every minute - .startWith(0) // Check immediately - .map(() => { - const state = store.getState(); - const rulesData = requestsRulesSelector(state); - const rules = rulesData?.rules || (Array.isArray(rulesData) ? rulesData : []); - return rules.some((rule) => isRuleExpiredOrExpiring(rule.expires)); + lastRefreshTime = Date.now(); + return Observable.interval(60 * 1000) + .startWith(0) + .filter(() => { + const rules = requestsRulesSelector(store.getState()); + const expired = rules.some((rule) => isRuleExpiredOrExpiring(rule.expires)); + const now = Date.now(); + const ready = expired && (now - lastRefreshTime >= RULE_EXPIRATION_CHECK_INTERVAL); + return ready; }) - .distinctUntilChanged() // Only emit when value changes - .filter((hasExpired) => hasExpired) .map(() => ruleExpired()) - .takeUntil(action$.ofType(UPDATE_REQUESTS_RULES)); // Stop when rules are updated + .takeUntil(action$.ofType(UPDATE_REQUESTS_RULES)); }); - - return Observable.merge(checkOnUpdate$, periodicCheck$); }; export default { From 387db3e581fb99b5feaddb0ff857a67dcded38ef Mon Sep 17 00:00:00 2001 From: sijandh35 Date: Tue, 25 Nov 2025 12:07:59 +0000 Subject: [PATCH 03/10] [Fixes #2233 #2234 #2235] Implementation of dynamic request --- geonode_mapstore_client/apps.py | 8 + geonode_mapstore_client/handlers.py | 33 +++ geonode_mapstore_client/registry.py | 67 ++++++ geonode_mapstore_client/tests.py | 315 ++++++++++++++++++++++++++++ geonode_mapstore_client/views.py | 12 ++ 5 files changed, 435 insertions(+) create mode 100644 geonode_mapstore_client/handlers.py create mode 100644 geonode_mapstore_client/registry.py diff --git a/geonode_mapstore_client/apps.py b/geonode_mapstore_client/apps.py index 025e2bb1e4..540e02345f 100644 --- a/geonode_mapstore_client/apps.py +++ b/geonode_mapstore_client/apps.py @@ -98,6 +98,7 @@ def run_setup_hooks(*args, **kwargs): re_path(r"^maps$", TemplateView.as_view(template_name="geonode-mapstore-client/pages/maps.html")), re_path(r"^documents$", TemplateView.as_view(template_name="geonode-mapstore-client/pages/documents.html")), re_path(r"^geostories$", TemplateView.as_view(template_name="geonode-mapstore-client/pages/geostories.html")), + re_path("reqparams/", views.RequestConfigurationView.as_view(), name="request-params"), ] # adding default format for metadata schema validation @@ -304,6 +305,9 @@ def run_setup_hooks(*args, **kwargs): setattr(settings, "MAPSTORE_DASHBOARD_CATALOGUE_SELECTED_SERVICE", MAPSTORE_DASHBOARD_CATALOGUE_SELECTED_SERVICE) setattr(settings, "MAPSTORE_DASHBOARD_CATALOGUE_SERVICES", MAPSTORE_DASHBOARD_CATALOGUE_SERVICES) + setattr(settings, "REQUEST_CONFIGURATION_RULES_HANDLERS", [ + "geonode_mapstore_client.handlers.BaseConfigurationRuleHandler", + ]) def connect_geoserver_style_visual_mode_signal(): @@ -324,4 +328,8 @@ def ready(self): if not apps.ready: run_setup_hooks() connect_geoserver_style_visual_mode_signal() + + from geonode_mapstore_client.registry import request_configuration_rules_registry + request_configuration_rules_registry.init_registry() + super(AppConfig, self).ready() diff --git a/geonode_mapstore_client/handlers.py b/geonode_mapstore_client/handlers.py new file mode 100644 index 0000000000..cbbf56a827 --- /dev/null +++ b/geonode_mapstore_client/handlers.py @@ -0,0 +1,33 @@ +from django.conf import settings +from geonode.base.auth import get_or_create_token +from .registry import BaseRequestConfigurationRuleHandler + + + +class BaseConfigurationRuleHandler(BaseRequestConfigurationRuleHandler): + """ + Base handler for configuration rules. + """ + + def get_rules(self, request): + user = request.user + if user.is_anonymous: + return [] + rules = [] + token_obj = get_or_create_token(user) + access_token = token_obj.token + + rules.extend( + [ + { + "urlPattern": f"{settings.GEOSERVER_WEB_UI_LOCATION.rstrip('/')}/.*", + "params": {"access_token": access_token}, + }, + {"urlPattern": f"{settings.SITEURL.rstrip('/')}/gs.*", "params": {"access_token": access_token}}, + { + "urlPattern": f"{settings.SITEURL.rstrip('/')}/api/v2.*", + "headers": {"Authorization": f"Bearer {access_token}"}, + }, + ] + ) + return rules \ No newline at end of file diff --git a/geonode_mapstore_client/registry.py b/geonode_mapstore_client/registry.py new file mode 100644 index 0000000000..5c55c58219 --- /dev/null +++ b/geonode_mapstore_client/registry.py @@ -0,0 +1,67 @@ +from django.conf import settings +from django.utils.module_loading import import_string + + +class BaseRequestConfigurationRuleHandler: + """ + Base class for request configuration rule handlers. + """ + + def get_rules(self, request): + return [] + + +class RequestConfigurationRulesRegistry: + """ + A registry for request configuration rule handlers. + """ + + REGISTRY = [] + + def init_registry(self): + self._register() + self.sanity_checks() + + def add(self, module_path): + item = import_string(module_path) + self.__check_item(item) + if item not in self.REGISTRY: + self.REGISTRY.append(item) + + def remove(self, module_path): + item = import_string(module_path) + self.__check_item(item) + if item in self.REGISTRY: + self.REGISTRY.remove(item) + + def reset(self): + self.REGISTRY = [] + + @classmethod + def get_registry(cls): + return cls.REGISTRY + + def sanity_checks(self): + for item in self.REGISTRY: + self.__check_item(item) + + def get_rules(self, request): + rules = [] + for HandlerClass in self.REGISTRY: + handler = HandlerClass() + rules.extend(handler.get_rules(request)) + return {"rules": rules} + + def __check_item(self, item): + """ + Ensure that the handler is a subclass of BaseRequestConfigurationRuleHandler + """ + if not (isinstance(item, type) and issubclass(item, BaseRequestConfigurationRuleHandler)): + raise TypeError(f"Item must be a subclass of BaseRequestConfigurationRuleHandler, " f"got {item}") + + def _register(self): + for module_path in getattr(settings, "REQUEST_CONFIGURATION_RULES_HANDLERS", []): + self.add(module_path) + + +request_configuration_rules_registry = RequestConfigurationRulesRegistry() \ No newline at end of file diff --git a/geonode_mapstore_client/tests.py b/geonode_mapstore_client/tests.py index 077c2c2bbe..526f8a580a 100644 --- a/geonode_mapstore_client/tests.py +++ b/geonode_mapstore_client/tests.py @@ -11,11 +11,14 @@ from django.urls import reverse from rest_framework.test import APIClient +from geonode.tests.base import GeoNodeBaseTestSupport + from .utils import validate_zip_file from .admin import ExtensionAdminForm from .models import Extension from unittest import mock + # Define temporary directories for testing to avoid affecting the real media/static roots TEST_MEDIA_ROOT = os.path.join(settings.PROJECT_ROOT, "test_media") TEST_STATIC_ROOT = os.path.join(settings.PROJECT_ROOT, "test_static") @@ -177,3 +180,315 @@ def test_plugins_config_view_structure(self): self.assertIsNotNone(map_plugin_data) self.assertIn("bundle", map_plugin_data) self.assertTrue(map_plugin_data["bundle"].endswith("MapPlugin/index.js")) + + +class RequestConfigurationViewTestCase(GeoNodeBaseTestSupport): + """ + Test cases for RequestConfigurationView. + """ + + def setUp(self): + """Set up test environment.""" + from django.contrib.auth import get_user_model + from geonode_mapstore_client.registry import RequestConfigurationRulesRegistry + + User = get_user_model() + self.user = User.objects.create_user(username="testuser", password="testpass") + self.client = APIClient() + + # Reset registry to clean state + RequestConfigurationRulesRegistry.REGISTRY = [] + + def tearDown(self): + """Clean up after tests.""" + from geonode_mapstore_client.registry import RequestConfigurationRulesRegistry + RequestConfigurationRulesRegistry.REGISTRY = [] + + def test_view_returns_rules_from_all_handlers(self): + """Test that the view collects and returns rules from all registered handlers.""" + from geonode_mapstore_client.registry import RequestConfigurationRulesRegistry, BaseRequestConfigurationRuleHandler + + # Create mock handlers + class MockHandler1(BaseRequestConfigurationRuleHandler): + def get_rules(self, request): + return [{"urlPattern": "http://example1.com/.*", "params": {"key1": "value1"}}] + + class MockHandler2(BaseRequestConfigurationRuleHandler): + def get_rules(self, request): + return [{"urlPattern": "http://example2.com/.*", "params": {"key2": "value2"}}] + + # Register handlers + RequestConfigurationRulesRegistry.REGISTRY = [] + RequestConfigurationRulesRegistry.REGISTRY.append(MockHandler1) + RequestConfigurationRulesRegistry.REGISTRY.append(MockHandler2) + + # Make request + url = reverse("request-params") + response = self.client.get(url) + + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertIn("rules", data) + + rules = data["rules"] + self.assertEqual(len(rules), 2) + + # Verify both handlers' rules are present + patterns = [rule["urlPattern"] for rule in rules] + self.assertIn("http://example1.com/.*", patterns) + self.assertIn("http://example2.com/.*", patterns) + + def test_view_returns_user_specific_token(self): + """Test that authenticated users receive rules with their own token.""" + from geonode_mapstore_client.handlers import BaseConfigurationRuleHandler + from geonode_mapstore_client.registry import RequestConfigurationRulesRegistry + from geonode.base.auth import get_or_create_token + + # Register the default handler + RequestConfigurationRulesRegistry.REGISTRY = [] + RequestConfigurationRulesRegistry.REGISTRY.append(BaseConfigurationRuleHandler) + + self.client.force_authenticate(user=self.user) + + # Get expected token + expected_token = get_or_create_token(self.user).token + + # Make request + url = reverse("request-params") + response = self.client.get(url) + + self.assertEqual(response.status_code, 200) + data = response.json() + rules = data["rules"] + + # Verify token is present in rules + self.assertTrue(len(rules) > 0) + + # Check that the token appears in the rules + token_found = False + for rule in rules: + if "params" in rule and "access_token" in rule["params"]: + self.assertEqual(rule["params"]["access_token"], expected_token) + token_found = True + elif "headers" in rule and "Authorization" in rule["headers"]: + self.assertIn(expected_token, rule["headers"]["Authorization"]) + token_found = True + + self.assertTrue(token_found, "User token should be present in rules") + + def test_only_get_allowed(self): + """Test that only GET requests are allowed.""" + url = reverse("request-params") + + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + + # Test POST + response = self.client.post(url, {}) + self.assertEqual(response.status_code, 405) + + # Test PUT + response = self.client.put(url, {}) + self.assertEqual(response.status_code, 405) + + # Test DELETE + response = self.client.delete(url) + self.assertEqual(response.status_code, 405) + + +class RequestConfigurationRulesRegistryTestCase(GeoNodeBaseTestSupport): + """ + Test cases for RequestConfigurationRulesRegistry. + """ + + def setUp(self): + """Set up test environment.""" + from geonode_mapstore_client.registry import RequestConfigurationRulesRegistry + self.registry = RequestConfigurationRulesRegistry() + self.registry.reset() + + def tearDown(self): + """Clean up after tests.""" + self.registry.reset() + + def test_add_handler_to_registry(self): + """Test adding a handler to the registry.""" + from geonode_mapstore_client.registry import BaseRequestConfigurationRuleHandler + + class TestHandler(BaseRequestConfigurationRuleHandler): + def get_rules(self, request): + return [{"test": "rule"}] + + initial_count = len(self.registry.REGISTRY) + self.registry.REGISTRY.append(TestHandler) + + self.assertEqual(len(self.registry.REGISTRY), initial_count + 1) + self.assertIn(TestHandler, self.registry.REGISTRY) + + def test_remove_handler_from_registry(self): + """Test removing a handler from the registry.""" + from geonode_mapstore_client.registry import BaseRequestConfigurationRuleHandler + + class TestHandler(BaseRequestConfigurationRuleHandler): + def get_rules(self, request): + return [{"test": "rule"}] + + self.registry.REGISTRY.append(TestHandler) + self.assertIn(TestHandler, self.registry.REGISTRY) + + self.registry.REGISTRY.remove(TestHandler) + self.assertNotIn(TestHandler, self.registry.REGISTRY) + + def test_get_rules_collects_from_all_handlers(self): + """Test that get_rules aggregates rules from all registered handlers.""" + from geonode_mapstore_client.registry import BaseRequestConfigurationRuleHandler + from django.test import RequestFactory + + class Handler1(BaseRequestConfigurationRuleHandler): + def get_rules(self, request): + return [{"urlPattern": "pattern1"}] + + class Handler2(BaseRequestConfigurationRuleHandler): + def get_rules(self, request): + return [{"urlPattern": "pattern2"}, {"urlPattern": "pattern3"}] + + self.registry.REGISTRY.append(Handler1) + self.registry.REGISTRY.append(Handler2) + + factory = RequestFactory() + request = factory.get("/") + + result = self.registry.get_rules(request) + + self.assertIn("rules", result) + rules = result["rules"] + self.assertEqual(len(rules), 3) + + patterns = [rule["urlPattern"] for rule in rules] + self.assertIn("pattern1", patterns) + self.assertIn("pattern2", patterns) + self.assertIn("pattern3", patterns) + + def test_sanity_check_rejects_invalid_handler(self): + """Test that sanity checks reject non-subclass handlers.""" + class InvalidHandler: + """Not a subclass of BaseRequestConfigurationRuleHandler""" + pass + + with self.assertRaises(TypeError) as context: + self.registry._RequestConfigurationRulesRegistry__check_item(InvalidHandler) + + self.assertIn("must be a subclass of BaseRequestConfigurationRuleHandler", str(context.exception)) + + @override_settings(REQUEST_CONFIGURATION_RULES_HANDLERS=[ + "geonode_mapstore_client.handlers.BaseConfigurationRuleHandler" + ]) + def test_init_registry_loads_from_settings(self): + """Test that init_registry loads handlers from settings.""" + from geonode_mapstore_client.handlers import BaseConfigurationRuleHandler + + self.registry.reset() + self.registry.init_registry() + + self.assertIn(BaseConfigurationRuleHandler, self.registry.REGISTRY) + + +class BaseConfigurationRuleHandlerTestCase(GeoNodeBaseTestSupport): + """ + Test cases for BaseConfigurationRuleHandler. + """ + + def setUp(self): + """Set up test environment.""" + from django.contrib.auth import get_user_model + + User = get_user_model() + self.user1 = User.objects.create_user(username="user1", password="pass1") + self.user2 = User.objects.create_user(username="user2", password="pass2") + + def test_authenticated_user_gets_rules(self): + """Test that authenticated users receive configuration rules.""" + from geonode_mapstore_client.handlers import BaseConfigurationRuleHandler + from django.test import RequestFactory + + handler = BaseConfigurationRuleHandler() + factory = RequestFactory() + request = factory.get("/") + request.user = self.user1 + + rules = handler.get_rules(request) + + self.assertIsInstance(rules, list) + self.assertTrue(len(rules) > 0) + + def test_anonymous_user_gets_empty_rules(self): + """Test that anonymous users receive empty rules list.""" + from geonode_mapstore_client.handlers import BaseConfigurationRuleHandler + from django.test import RequestFactory + from django.contrib.auth.models import AnonymousUser + + handler = BaseConfigurationRuleHandler() + factory = RequestFactory() + request = factory.get("/") + request.user = AnonymousUser() + + rules = handler.get_rules(request) + + self.assertIsInstance(rules, list) + self.assertEqual(len(rules), 0) + + def test_rules_contain_correct_structure(self): + """Test that rules have the correct structure with urlPattern and params/headers.""" + from geonode_mapstore_client.handlers import BaseConfigurationRuleHandler + from django.test import RequestFactory + + handler = BaseConfigurationRuleHandler() + factory = RequestFactory() + request = factory.get("/") + request.user = self.user1 + + rules = handler.get_rules(request) + + # Verify each rule has required fields + for rule in rules: + self.assertIn("urlPattern", rule) + self.assertTrue("params" in rule or "headers" in rule) + + def test_different_users_get_different_tokens(self): + """Test that different users receive different tokens in their rules.""" + from geonode_mapstore_client.handlers import BaseConfigurationRuleHandler + from django.test import RequestFactory + from geonode.base.auth import get_or_create_token + + handler = BaseConfigurationRuleHandler() + factory = RequestFactory() + + # Get rules for user1 + request1 = factory.get("/") + request1.user = self.user1 + rules1 = handler.get_rules(request1) + token1 = get_or_create_token(self.user1).token + + # Get rules for user2 + request2 = factory.get("/") + request2.user = self.user2 + rules2 = handler.get_rules(request2) + token2 = get_or_create_token(self.user2).token + + # Verify tokens are different + self.assertNotEqual(token1, token2) + + # Verify each user's rules contain their own token + token1_found = any( + rule.get("params", {}).get("access_token") == token1 or + token1 in rule.get("headers", {}).get("Authorization", "") + for rule in rules1 + ) + token2_found = any( + rule.get("params", {}).get("access_token") == token2 or + token2 in rule.get("headers", {}).get("Authorization", "") + for rule in rules2 + ) + + self.assertTrue(token1_found, "User1's token should be in their rules") + self.assertTrue(token2_found, "User2's token should be in their rules") diff --git a/geonode_mapstore_client/views.py b/geonode_mapstore_client/views.py index 47db2009fb..c632b4babb 100644 --- a/geonode_mapstore_client/views.py +++ b/geonode_mapstore_client/views.py @@ -173,3 +173,15 @@ def get(self, request, *args, **kwargs): ) return Response({"plugins": plugins}) + + + +class RequestConfigurationView(APIView): + permission_classes = [] + + def get(self, request, *args, **kwargs): + from geonode_mapstore_client.registry import RequestConfigurationRulesRegistry + + registry = RequestConfigurationRulesRegistry() + rules = registry.get_rules(request) + return Response(rules) \ No newline at end of file From 2be394e34a48b62b58d890d89043087f7f41689c Mon Sep 17 00:00:00 2001 From: sijandh35 Date: Wed, 26 Nov 2025 07:13:57 +0000 Subject: [PATCH 04/10] [Fixes #2233 #2234 #2235] change request params endpoint to reqrules --- geonode_mapstore_client/apps.py | 2 +- geonode_mapstore_client/tests.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/geonode_mapstore_client/apps.py b/geonode_mapstore_client/apps.py index 540e02345f..0267d8ddd8 100644 --- a/geonode_mapstore_client/apps.py +++ b/geonode_mapstore_client/apps.py @@ -98,7 +98,7 @@ def run_setup_hooks(*args, **kwargs): re_path(r"^maps$", TemplateView.as_view(template_name="geonode-mapstore-client/pages/maps.html")), re_path(r"^documents$", TemplateView.as_view(template_name="geonode-mapstore-client/pages/documents.html")), re_path(r"^geostories$", TemplateView.as_view(template_name="geonode-mapstore-client/pages/geostories.html")), - re_path("reqparams/", views.RequestConfigurationView.as_view(), name="request-params"), + re_path("/reqrules", views.RequestConfigurationView.as_view(), name="request-rules"), ] # adding default format for metadata schema validation diff --git a/geonode_mapstore_client/tests.py b/geonode_mapstore_client/tests.py index 526f8a580a..f1f8d531e9 100644 --- a/geonode_mapstore_client/tests.py +++ b/geonode_mapstore_client/tests.py @@ -223,7 +223,7 @@ def get_rules(self, request): RequestConfigurationRulesRegistry.REGISTRY.append(MockHandler2) # Make request - url = reverse("request-params") + url = reverse("request-rules") response = self.client.get(url) self.assertEqual(response.status_code, 200) @@ -254,7 +254,7 @@ def test_view_returns_user_specific_token(self): expected_token = get_or_create_token(self.user).token # Make request - url = reverse("request-params") + url = reverse("request-rules") response = self.client.get(url) self.assertEqual(response.status_code, 200) @@ -278,7 +278,7 @@ def test_view_returns_user_specific_token(self): def test_only_get_allowed(self): """Test that only GET requests are allowed.""" - url = reverse("request-params") + url = reverse("request-rules") response = self.client.get(url) self.assertEqual(response.status_code, 200) From 1925a009537b08a1954af202c024b8d3318b778c Mon Sep 17 00:00:00 2001 From: sijandh35 Date: Wed, 26 Nov 2025 07:50:12 +0000 Subject: [PATCH 05/10] [Fixes #2233 #2234 #2235] use more standard way of checking non logged in user --- geonode_mapstore_client/handlers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/geonode_mapstore_client/handlers.py b/geonode_mapstore_client/handlers.py index cbbf56a827..56ec4f6cc7 100644 --- a/geonode_mapstore_client/handlers.py +++ b/geonode_mapstore_client/handlers.py @@ -11,7 +11,7 @@ class BaseConfigurationRuleHandler(BaseRequestConfigurationRuleHandler): def get_rules(self, request): user = request.user - if user.is_anonymous: + if not user.is_authenticated: return [] rules = [] token_obj = get_or_create_token(user) From e53b467c1578b638debfdaede513cc313105e163 Mon Sep 17 00:00:00 2001 From: sijandh35 Date: Wed, 26 Nov 2025 10:05:06 +0000 Subject: [PATCH 06/10] [Fixes #2233 #2234 #2235] collect the handlers in settings --- geonode_mapstore_client/apps.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/geonode_mapstore_client/apps.py b/geonode_mapstore_client/apps.py index 0267d8ddd8..353d51a4fd 100644 --- a/geonode_mapstore_client/apps.py +++ b/geonode_mapstore_client/apps.py @@ -305,9 +305,11 @@ def run_setup_hooks(*args, **kwargs): setattr(settings, "MAPSTORE_DASHBOARD_CATALOGUE_SELECTED_SERVICE", MAPSTORE_DASHBOARD_CATALOGUE_SELECTED_SERVICE) setattr(settings, "MAPSTORE_DASHBOARD_CATALOGUE_SERVICES", MAPSTORE_DASHBOARD_CATALOGUE_SERVICES) - setattr(settings, "REQUEST_CONFIGURATION_RULES_HANDLERS", [ + handlers = getattr(settings, "REQUEST_CONFIGURATION_RULES_HANDLERS", []) + handlers.extend([ "geonode_mapstore_client.handlers.BaseConfigurationRuleHandler", ]) + setattr(settings, "REQUEST_CONFIGURATION_RULES_HANDLERS", handlers) def connect_geoserver_style_visual_mode_signal(): From c43b66d42f385bdd3cf6fc6fe3a5c73ec0014251 Mon Sep 17 00:00:00 2001 From: Suren Date: Wed, 26 Nov 2025 16:51:01 +0530 Subject: [PATCH 07/10] update rules endpoint --- .../client/js/api/geonode/security/index.js | 6 +- .../client/js/api/geonode/v2/constants.js | 4 +- .../js/epics/__tests__/security-test.js | 78 +++++++------------ .../client/js/epics/security.js | 12 +-- 4 files changed, 39 insertions(+), 61 deletions(-) diff --git a/geonode_mapstore_client/client/js/api/geonode/security/index.js b/geonode_mapstore_client/client/js/api/geonode/security/index.js index 57be20c2ca..8760884121 100644 --- a/geonode_mapstore_client/client/js/api/geonode/security/index.js +++ b/geonode_mapstore_client/client/js/api/geonode/security/index.js @@ -10,7 +10,7 @@ import axios from '@mapstore/framework/libs/ajax'; import WKT from 'ol/format/WKT'; import GeoJSON from 'ol/format/GeoJSON'; import uuid from 'uuid'; -import { getEndpointUrl, USERS } from '../v2/constants'; +import { getEndpointUrl, RULES } from '../v2/constants'; const wktFormat = new WKT(); const geoJSONFormat = new GeoJSON(); @@ -80,7 +80,7 @@ export const deleteGeoLimits = (resourceId, id, type = 'user') => { .then(({ data }) => data); }; -export const getRequestConfigurationRulesByUserPK = (pk) => { - return axios.get(getEndpointUrl(USERS, `/${pk}/rules`)) +export const getRequestRules = () => { + return axios.get(getEndpointUrl(RULES)) .then(({ data }) => data); }; diff --git a/geonode_mapstore_client/client/js/api/geonode/v2/constants.js b/geonode_mapstore_client/client/js/api/geonode/v2/constants.js index bf2720d70c..bb2090c723 100644 --- a/geonode_mapstore_client/client/js/api/geonode/v2/constants.js +++ b/geonode_mapstore_client/client/js/api/geonode/v2/constants.js @@ -35,7 +35,8 @@ let endpoints = { 'facets': '/api/v2/facets', 'uploads': '/api/v2/uploads', 'metadata': '/api/v2/metadata', - 'assets': '/api/v2/assets' + 'assets': '/api/v2/assets', + 'rules': '/api/v2/reqrules' }; export const RESOURCES = 'resources'; @@ -51,6 +52,7 @@ export const FACETS = 'facets'; export const UPLOADS = 'uploads'; export const METADATA = 'metadata'; export const ASSETS = 'assets'; +export const RULES = 'rules'; export const setEndpoints = (data) => { endpoints = { ...endpoints, ...data }; diff --git a/geonode_mapstore_client/client/js/epics/__tests__/security-test.js b/geonode_mapstore_client/client/js/epics/__tests__/security-test.js index 987d53f3a7..392c0292d3 100644 --- a/geonode_mapstore_client/client/js/epics/__tests__/security-test.js +++ b/geonode_mapstore_client/client/js/epics/__tests__/security-test.js @@ -38,9 +38,8 @@ describe('security epics', () => { }); describe('gnUpdateRequestConfigurationRulesEpic', () => { - it('should fetch and update rules when LOAD_REQUESTS_RULES is dispatched with valid user', (done) => { + it('should fetch and update rules when LOAD_REQUESTS_RULES is dispatched', (done) => { const NUM_ACTIONS = 1; - const userPk = 1; const futureDate = new Date(Date.now() + 60 * 60 * 1000).toISOString(); // 1 hour from now const mockRules = { rules: [ @@ -60,15 +59,9 @@ describe('security epics', () => { ] }; - const testState = { - security: { - user: { - pk: userPk - } - } - }; + const testState = {}; - mockAxios.onGet(new RegExp(`/api/v2/users/${userPk}/rules`)).reply(200, mockRules); + mockAxios.onGet(new RegExp('/api/v2/reqrules')).reply(200, mockRules); testEpic( gnUpdateRequestConfigurationRulesEpic, @@ -90,7 +83,6 @@ describe('security epics', () => { it('should fetch and update rules when RULE_EXPIRED is dispatched', (done) => { const NUM_ACTIONS = 1; - const userPk = 1; const mockRules = { rules: [ { @@ -102,15 +94,9 @@ describe('security epics', () => { ] }; - const testState = { - security: { - user: { - pk: userPk - } - } - }; + const testState = {}; - mockAxios.onGet(new RegExp(`/api/v2/users/${userPk}/rules`)).reply(200, mockRules); + mockAxios.onGet(new RegExp('/api/v2/reqrules')).reply(200, mockRules); testEpic( gnUpdateRequestConfigurationRulesEpic, @@ -130,21 +116,36 @@ describe('security epics', () => { ); }); - it('should return empty observable when userPk is not available', (done) => { - const NUM_ACTIONS = 0; + it('should fetch and update rules even without user in state', (done) => { + const NUM_ACTIONS = 1; + const mockRules = { + rules: [ + { + urlPattern: 'http://localhost/geoserver//.*', + params: { + access_token: 'token123' + } + } + ] + }; + const testState = { security: { user: null } }; + mockAxios.onGet(new RegExp('/api/v2/reqrules')).reply(200, mockRules); + testEpic( gnUpdateRequestConfigurationRulesEpic, NUM_ACTIONS, { type: LOAD_REQUESTS_RULES }, (actions) => { try { - expect(actions.length).toBe(0); + expect(actions.length).toBe(1); + expect(actions[0].type).toBe(UPDATE_REQUESTS_RULES); + expect(actions[0].rules).toEqual(mockRules.rules); done(); } catch (e) { done(e); @@ -156,16 +157,9 @@ describe('security epics', () => { it('should handle API error and dispatch loadRequestsRulesError', (done) => { const NUM_ACTIONS = 1; - const userPk = 1; - const testState = { - security: { - user: { - pk: userPk - } - } - }; + const testState = {}; - mockAxios.onGet(new RegExp(`/api/v2/users/${userPk}/rules`)).reply(500, { error: 'Server error' }); + mockAxios.onGet(new RegExp('/api/v2/reqrules')).reply(500, { error: 'Server error' }); testEpic( gnUpdateRequestConfigurationRulesEpic, @@ -187,7 +181,6 @@ describe('security epics', () => { it('should deduplicate rules by urlPattern', (done) => { const NUM_ACTIONS = 1; - const userPk = 1; const mockRules = { rules: [ { @@ -211,15 +204,9 @@ describe('security epics', () => { ] }; - const testState = { - security: { - user: { - pk: userPk - } - } - }; + const testState = {}; - mockAxios.onGet(new RegExp(`/api/v2/users/${userPk}/rules`)).reply(200, mockRules); + mockAxios.onGet(new RegExp('/api/v2/reqrules')).reply(200, mockRules); testEpic( gnUpdateRequestConfigurationRulesEpic, @@ -244,20 +231,13 @@ describe('security epics', () => { it('should handle empty rules array', (done) => { const NUM_ACTIONS = 1; - const userPk = 1; const mockRules = { rules: [] }; - const testState = { - security: { - user: { - pk: userPk - } - } - }; + const testState = {}; - mockAxios.onGet(new RegExp(`/api/v2/users/${userPk}/rules`)).reply(200, mockRules); + mockAxios.onGet(new RegExp('/api/v2/reqrules')).reply(200, mockRules); testEpic( gnUpdateRequestConfigurationRulesEpic, diff --git a/geonode_mapstore_client/client/js/epics/security.js b/geonode_mapstore_client/client/js/epics/security.js index e46986684b..b3147f8b3d 100644 --- a/geonode_mapstore_client/client/js/epics/security.js +++ b/geonode_mapstore_client/client/js/epics/security.js @@ -14,9 +14,9 @@ import { updateRequestsRules, loadRequestsRulesError } from '@mapstore/framework/actions/security'; -import { getRequestConfigurationRulesByUserPK } from '@js/api/geonode/security'; +import { getRequestRules } from '@js/api/geonode/security'; import { RULE_EXPIRED, ruleExpired } from '@js/actions/gnsecurity'; -import { userSelector, requestsRulesSelector } from '@mapstore/framework/selectors/security'; +import { requestsRulesSelector } from '@mapstore/framework/selectors/security'; /** * @module epics/security @@ -27,14 +27,10 @@ const RULE_EXPIRATION_CHECK_INTERVAL = 60 * 1000; /** * Epic to fetch request configuration rules and update the store */ -export const gnUpdateRequestConfigurationRulesEpic = (action$, store) => +export const gnUpdateRequestConfigurationRulesEpic = (action$) => action$.ofType(LOAD_REQUESTS_RULES, RULE_EXPIRED) .switchMap(() => { - const userPk = userSelector(store.getState())?.pk; - if (!userPk) { - return Observable.empty(); - } - return Observable.defer(() => getRequestConfigurationRulesByUserPK(userPk)) + return Observable.defer(() => getRequestRules()) .switchMap((data) => { const uniqRules = uniqBy(data.rules ?? [], 'urlPattern'); return Observable.of(updateRequestsRules(uniqRules)); From 186289f28d0762575449453786f8abbe23f61262 Mon Sep 17 00:00:00 2001 From: sijandh35 Date: Wed, 26 Nov 2025 14:26:24 +0000 Subject: [PATCH 08/10] [Fixes #2233 #2234 #2235] corrected url pattern to make it exact --- geonode_mapstore_client/apps.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/geonode_mapstore_client/apps.py b/geonode_mapstore_client/apps.py index 353d51a4fd..8a4a9f3c02 100644 --- a/geonode_mapstore_client/apps.py +++ b/geonode_mapstore_client/apps.py @@ -98,7 +98,7 @@ def run_setup_hooks(*args, **kwargs): re_path(r"^maps$", TemplateView.as_view(template_name="geonode-mapstore-client/pages/maps.html")), re_path(r"^documents$", TemplateView.as_view(template_name="geonode-mapstore-client/pages/documents.html")), re_path(r"^geostories$", TemplateView.as_view(template_name="geonode-mapstore-client/pages/geostories.html")), - re_path("/reqrules", views.RequestConfigurationView.as_view(), name="request-rules"), + re_path(r"^reqrules$", views.RequestConfigurationView.as_view(), name="request-rules"), ] # adding default format for metadata schema validation From baaf695d76dcc7bcb44dc3f1d4ecf740d1316f3c Mon Sep 17 00:00:00 2001 From: sijandh35 Date: Thu, 27 Nov 2025 17:26:28 +0000 Subject: [PATCH 09/10] [Fixes #2233 #2234 #2235] Fixes endpoint to allign with client --- geonode_mapstore_client/apps.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/geonode_mapstore_client/apps.py b/geonode_mapstore_client/apps.py index 8a4a9f3c02..718001aaa2 100644 --- a/geonode_mapstore_client/apps.py +++ b/geonode_mapstore_client/apps.py @@ -88,6 +88,7 @@ def run_setup_hooks(*args, **kwargs): ), re_path(r"^metadata/(?P[^/]*)$", views.metadata, name='metadata'), re_path(r"^metadata/(?P[^/]*)/embed$", views.metadata_embed, name='metadata'), + re_path(r"^api/v2/reqrules$", views.RequestConfigurationView.as_view(), name="request-rules"), # required, otherwise will raise no-lookup errors to be analysed re_path(r"^api/v2/", include(router.urls)), @@ -98,7 +99,6 @@ def run_setup_hooks(*args, **kwargs): re_path(r"^maps$", TemplateView.as_view(template_name="geonode-mapstore-client/pages/maps.html")), re_path(r"^documents$", TemplateView.as_view(template_name="geonode-mapstore-client/pages/documents.html")), re_path(r"^geostories$", TemplateView.as_view(template_name="geonode-mapstore-client/pages/geostories.html")), - re_path(r"^reqrules$", views.RequestConfigurationView.as_view(), name="request-rules"), ] # adding default format for metadata schema validation From c2b946bd923f535ec96a594c82fa302a8ff78d24 Mon Sep 17 00:00:00 2001 From: allyoucanmap Date: Tue, 2 Dec 2025 10:29:58 +0100 Subject: [PATCH 10/10] update mapstore submodule --- geonode_mapstore_client/client/MapStore2 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/geonode_mapstore_client/client/MapStore2 b/geonode_mapstore_client/client/MapStore2 index 763e0cf1e6..c41c790006 160000 --- a/geonode_mapstore_client/client/MapStore2 +++ b/geonode_mapstore_client/client/MapStore2 @@ -1 +1 @@ -Subproject commit 763e0cf1e64f2207463197eb6cabebfc2de0beb3 +Subproject commit c41c790006d435e4928e5590922488e85bcc57db