From fdf6d75109e884c65decb8cf7091900770f020d7 Mon Sep 17 00:00:00 2001 From: Ramon Smits Date: Fri, 19 Jun 2026 12:17:08 +0200 Subject: [PATCH 1/9] =?UTF-8?q?=F0=9F=90=9B=20Gate=20Throughput=20menu=20o?= =?UTF-8?q?n=20admin=5Fread?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Throughput is an admin resource server-side (ServiceControl bundles error:throughput: under the admin permission group, alongside licensing, notifications and redirects), so the top-nav Throughput item must gate on admin_read rather than failed_messages_read. This matches the existing 'Usage Setup' configuration tab, which already gates on admin_read. --- src/Frontend/src/components/PageHeader.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Frontend/src/components/PageHeader.vue b/src/Frontend/src/components/PageHeader.vue index 26aa84878..eed60b277 100644 --- a/src/Frontend/src/components/PageHeader.vue +++ b/src/Frontend/src/components/PageHeader.vue @@ -42,7 +42,7 @@ const menuItems = computed( ...(has("failed_messages_read") ? [FailedMessagesMenuItem] : []), ...(has("failed_messages_read") ? [CustomChecksMenuItem] : []), ...(has("failed_messages_read") ? [EventsMenuItem] : []), - ...(has("failed_messages_read") ? [ThroughputMenuItem] : []), + ...(has("admin_read") ? [ThroughputMenuItem] : []), ConfigurationMenuItem, FeedbackButton, ]); From 0fab204fb974f12c5fdf09e1600720599ab26f9b Mon Sep 17 00:00:00 2001 From: Ramon Smits Date: Fri, 19 Jun 2026 12:17:44 +0200 Subject: [PATCH 2/9] =?UTF-8?q?=F0=9F=90=9B=20Gate=20user-permissions=20fe?= =?UTF-8?q?ature=20on=20supportsUserPermissions=20capability?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The supportsUserPermissions flag was computed in EnvironmentAndVersionsStore but never consumed. Older ServiceControl versions do not expose the my/permissions endpoints, so without this guard the app would attempt to fetch permissions (and show the User Permissions tab) against an instance that returns 404. - App.vue only triggers permissionsStore.refresh() when the capability is present; the watch now also reacts to the flag becoming available. - ConfigurationView only renders the User Permissions tab when ServiceControl advertises the endpoints, in addition to auth being enabled. --- src/Frontend/src/App.vue | 8 +++++--- src/Frontend/src/views/ConfigurationView.vue | 4 +++- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/Frontend/src/App.vue b/src/Frontend/src/App.vue index 20ce9d576..567f2973e 100644 --- a/src/Frontend/src/App.vue +++ b/src/Frontend/src/App.vue @@ -9,16 +9,18 @@ import BackendChecksNotifications from "@/components/BackendChecksNotifications. import { storeToRefs } from "pinia"; import { useAuthStore } from "@/stores/AuthStore"; import { useUserPermissionsStore } from "@/stores/UserPermissionsStore"; +import { useEnvironmentAndVersionsStore } from "@/stores/EnvironmentAndVersionsStore"; const authStore = useAuthStore(); const route = useRoute(); const { isAuthenticated, authEnabled } = storeToRefs(authStore); const permissionsStore = useUserPermissionsStore(); +const environmentStore = useEnvironmentAndVersionsStore(); watch( - [authEnabled, isAuthenticated], - ([enabled, authenticated]) => { - if (enabled && authenticated) { + [authEnabled, isAuthenticated, () => environmentStore.environment.supportsUserPermissions], + ([enabled, authenticated, supported]) => { + if (enabled && authenticated && supported) { permissionsStore.refresh(); } }, diff --git a/src/Frontend/src/views/ConfigurationView.vue b/src/Frontend/src/views/ConfigurationView.vue index 78dba05aa..aa06643a8 100644 --- a/src/Frontend/src/views/ConfigurationView.vue +++ b/src/Frontend/src/views/ConfigurationView.vue @@ -13,6 +13,7 @@ import { useRedirectsStore } from "@/stores/RedirectsStore"; import { useLicenseStore } from "@/stores/LicenseStore"; import { useAuthStore } from "@/stores/AuthStore"; import { useUserPermissionsStore } from "@/stores/UserPermissionsStore"; +import { useEnvironmentAndVersionsStore } from "@/stores/EnvironmentAndVersionsStore"; const { store: throughputStore } = useThroughputStoreAutoRefresh(); const { hasErrors } = storeToRefs(throughputStore); @@ -22,6 +23,7 @@ const redirectsStore = useRedirectsStore(); const licenseStore = useLicenseStore(); const { licenseStatus } = licenseStore; const authStore = useAuthStore(); +const environmentStore = useEnvironmentAndVersionsStore(); const permissionsStore = useUserPermissionsStore(); const { summary: permSummary } = storeToRefs(permissionsStore); @@ -151,7 +153,7 @@ function preventIfDisabled(e: Event) { - From 955951a16180cd2de1538c53471055b3b418e730 Mon Sep 17 00:00:00 2001 From: Ramon Smits Date: Fri, 19 Jun 2026 12:17:59 +0200 Subject: [PATCH 3/9] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Dedupe=20concurrent=20?= =?UTF-8?q?permission=20refreshes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit App.vue's auth watch and the User Permissions view's onMounted both call refresh() on load, firing two identical pairs of requests. Share a single in-flight promise so concurrent callers reuse the same request; the slot is released once it settles, so later refreshes still re-fetch. --- src/Frontend/src/stores/UserPermissionsStore.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/Frontend/src/stores/UserPermissionsStore.ts b/src/Frontend/src/stores/UserPermissionsStore.ts index fd3c1f41e..05feb5542 100644 --- a/src/Frontend/src/stores/UserPermissionsStore.ts +++ b/src/Frontend/src/stores/UserPermissionsStore.ts @@ -23,7 +23,16 @@ export const useUserPermissionsStore = defineStore("UserPermissionsStore", () => const loading = ref(false); const error = ref(null); - async function refresh() { + // Multiple callers can request a refresh in the same tick (the App-level watch + // and the User Permissions view's onMounted). Share a single in-flight request + // so they don't trigger duplicate fetches. + let inFlight: Promise | null = null; + + function refresh() { + return (inFlight ??= load().finally(() => (inFlight = null))); + } + + async function load() { loading.value = true; error.value = null; try { From c07bdccc25a431dc4dea5ee00ea22786e9f6675e Mon Sep 17 00:00:00 2001 From: Ramon Smits Date: Fri, 19 Jun 2026 12:20:38 +0200 Subject: [PATCH 4/9] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Extract=20shared=20use?= =?UTF-8?q?PermissionGate=20composable?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PageHeader and ConfigurationView each defined their own permission gate. The two predicates had drifted: PageHeader required authEnabled && isAuthenticated && summary, while ConfigurationView omitted isAuthenticated. Extract a single usePermissionGate composable exposing has(flag) so both views share identical, fail-open gating logic. --- src/Frontend/src/components/PageHeader.vue | 11 ++-------- .../src/composables/usePermissionGate.ts | 22 +++++++++++++++++++ src/Frontend/src/views/ConfigurationView.vue | 17 +++++++++----- 3 files changed, 35 insertions(+), 15 deletions(-) create mode 100644 src/Frontend/src/composables/usePermissionGate.ts diff --git a/src/Frontend/src/components/PageHeader.vue b/src/Frontend/src/components/PageHeader.vue index eed60b277..b5c810211 100644 --- a/src/Frontend/src/components/PageHeader.vue +++ b/src/Frontend/src/components/PageHeader.vue @@ -15,7 +15,7 @@ import AuditMenuItem from "./audit/AuditMenuItem.vue"; import monitoringClient from "@/components/monitoring/monitoringClient"; import UserProfileMenuItem from "@/components/UserProfileMenuItem.vue"; import { useAuthStore } from "@/stores/AuthStore"; -import { useUserPermissionsStore, type PermissionsSummary } from "@/stores/UserPermissionsStore"; +import usePermissionGate from "@/composables/usePermissionGate"; import { storeToRefs } from "pinia"; const isMonitoringEnabled = monitoringClient.isMonitoringEnabled; @@ -23,14 +23,7 @@ const isMonitoringEnabled = monitoringClient.isMonitoringEnabled; const authStore = useAuthStore(); const { authEnabled, isAuthenticated } = storeToRefs(authStore); -const permissionsStore = useUserPermissionsStore(); -const { summary } = storeToRefs(permissionsStore); - -const shouldGate = computed(() => authEnabled.value && isAuthenticated.value && summary.value !== null); - -function has(flag: keyof PermissionsSummary): boolean { - return !shouldGate.value || summary.value?.[flag] === true; -} +const { has } = usePermissionGate(); // prettier-ignore const menuItems = computed( diff --git a/src/Frontend/src/composables/usePermissionGate.ts b/src/Frontend/src/composables/usePermissionGate.ts new file mode 100644 index 000000000..516a3abee --- /dev/null +++ b/src/Frontend/src/composables/usePermissionGate.ts @@ -0,0 +1,22 @@ +import { computed } from "vue"; +import { storeToRefs } from "pinia"; +import { useAuthStore } from "@/stores/AuthStore"; +import { useUserPermissionsStore, type PermissionsSummary } from "@/stores/UserPermissionsStore"; + +// Centralises the "should this UI element be shown for the current user" decision. +// Gating only kicks in once authorization is enabled, the user is authenticated and +// the permission summary has loaded; otherwise everything is shown (fail-open) so the +// UI is unchanged for unauthenticated/older-ServiceControl setups. Server-side checks +// remain the real enforcement — this only hides UI the user cannot use anyway. +export default function usePermissionGate() { + const { authEnabled, isAuthenticated } = storeToRefs(useAuthStore()); + const { summary } = storeToRefs(useUserPermissionsStore()); + + const shouldGate = computed(() => authEnabled.value && isAuthenticated.value && summary.value !== null); + + function has(flag: keyof PermissionsSummary): boolean { + return !shouldGate.value || summary.value?.[flag] === true; + } + + return { has, shouldGate }; +} diff --git a/src/Frontend/src/views/ConfigurationView.vue b/src/Frontend/src/views/ConfigurationView.vue index aa06643a8..0dd4f7ea6 100644 --- a/src/Frontend/src/views/ConfigurationView.vue +++ b/src/Frontend/src/views/ConfigurationView.vue @@ -12,7 +12,7 @@ import useConnectionsAndStatsAutoRefresh from "@/composables/useConnectionsAndSt import { useRedirectsStore } from "@/stores/RedirectsStore"; import { useLicenseStore } from "@/stores/LicenseStore"; import { useAuthStore } from "@/stores/AuthStore"; -import { useUserPermissionsStore } from "@/stores/UserPermissionsStore"; +import usePermissionGate from "@/composables/usePermissionGate"; import { useEnvironmentAndVersionsStore } from "@/stores/EnvironmentAndVersionsStore"; const { store: throughputStore } = useThroughputStoreAutoRefresh(); @@ -25,10 +25,8 @@ const { licenseStatus } = licenseStore; const authStore = useAuthStore(); const environmentStore = useEnvironmentAndVersionsStore(); -const permissionsStore = useUserPermissionsStore(); -const { summary: permSummary } = storeToRefs(permissionsStore); -const shouldGateConfig = computed(() => authStore.authEnabled && permSummary.value !== null); -const hasAdminRead = computed(() => !shouldGateConfig.value || permSummary.value?.admin_read === true); +const { has } = usePermissionGate(); +const hasAdminRead = computed(() => has("admin_read")); onMounted(async () => { if (notConnected.value) { @@ -153,7 +151,14 @@ function preventIfDisabled(e: Event) { - From 421103fd3046bb401832d3a781d60582f0ead07d Mon Sep 17 00:00:00 2001 From: Ramon Smits Date: Fri, 19 Jun 2026 12:46:33 +0200 Subject: [PATCH 5/9] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Fetch=20permissions=20?= =?UTF-8?q?via=20discovered=20root=20URLs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The permission endpoint paths were hardcoded in UserPermissionsStore even though ServiceControl advertises them (mypermissions_summary/mypermissions_all) in its root document. Expose those URLs from EnvironmentAndVersionsStore and fetch them through a new serviceControlClient.fetchTypedFromUrl() helper, so the frontend follows the server's routing instead of duplicating path knowledge. --- src/Frontend/src/components/serviceControlClient.ts | 9 +++++++-- src/Frontend/src/stores/EnvironmentAndVersionsStore.ts | 4 ++++ src/Frontend/src/stores/UserPermissionsStore.ts | 6 ++++-- 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/src/Frontend/src/components/serviceControlClient.ts b/src/Frontend/src/components/serviceControlClient.ts index 40ad24c53..9db92f3f7 100644 --- a/src/Frontend/src/components/serviceControlClient.ts +++ b/src/Frontend/src/components/serviceControlClient.ts @@ -29,8 +29,13 @@ class ServiceControlClient { } } - public async fetchTypedFromServiceControl(suffix: string, signal?: AbortSignal): Promise<[Response, T]> { - const response = await authFetch(`${this.url}${suffix}`, { signal }); + public fetchTypedFromServiceControl(suffix: string, signal?: AbortSignal): Promise<[Response, T]> { + return this.fetchTypedFromUrl(`${this.url}${suffix}`, signal); + } + + // Fetch from an absolute URL, e.g. one discovered from the ServiceControl root document. + public async fetchTypedFromUrl(url: string, signal?: AbortSignal): Promise<[Response, T]> { + const response = await authFetch(url, { signal }); if (!response.ok) throw new HttpError(response.status, response.statusText ?? "No response"); const data = await response.json(); diff --git a/src/Frontend/src/stores/EnvironmentAndVersionsStore.ts b/src/Frontend/src/stores/EnvironmentAndVersionsStore.ts index 3e797116c..1ce6b4f09 100644 --- a/src/Frontend/src/stores/EnvironmentAndVersionsStore.ts +++ b/src/Frontend/src/stores/EnvironmentAndVersionsStore.ts @@ -18,6 +18,8 @@ export const useEnvironmentAndVersionsStore = defineStore("EnvironmentAndVersion sp_version: window.defaultConfig && window.defaultConfig.version ? window.defaultConfig.version : "1.2.0", supportsArchiveGroups: false, supportsUserPermissions: false, + mypermissions_all_url: "", + mypermissions_summary_url: "", endpoints_error_url: "", known_endpoints_url: "", endpoints_message_search_url: "", @@ -58,6 +60,8 @@ export const useEnvironmentAndVersionsStore = defineStore("EnvironmentAndVersion if (scVer) { environment.supportsArchiveGroups = !!scVer.archived_groups_url; environment.supportsUserPermissions = !!scVer.mypermissions_all && !!scVer.mypermissions_summary; + environment.mypermissions_all_url = scVer.mypermissions_all ?? ""; + environment.mypermissions_summary_url = scVer.mypermissions_summary ?? ""; environment.is_compatible_with_sc = isSupported(environment.sc_version, environment.minimum_supported_sc_version); environment.endpoints_error_url = scVer && scVer.endpoints_error_url; environment.known_endpoints_url = scVer && scVer.known_endpoints_url; diff --git a/src/Frontend/src/stores/UserPermissionsStore.ts b/src/Frontend/src/stores/UserPermissionsStore.ts index 05feb5542..1c2df3d1f 100644 --- a/src/Frontend/src/stores/UserPermissionsStore.ts +++ b/src/Frontend/src/stores/UserPermissionsStore.ts @@ -1,6 +1,7 @@ import { acceptHMRUpdate, defineStore } from "pinia"; import { ref } from "vue"; import serviceControlClient from "@/components/serviceControlClient"; +import { useEnvironmentAndVersionsStore } from "@/stores/EnvironmentAndVersionsStore"; interface PermissionsSummary { failed_messages_read: boolean; @@ -35,10 +36,11 @@ export const useUserPermissionsStore = defineStore("UserPermissionsStore", () => async function load() { loading.value = true; error.value = null; + const { environment } = useEnvironmentAndVersionsStore(); try { const [summaryResult, descriptorResult] = await Promise.all([ - serviceControlClient.fetchTypedFromServiceControl("my/permissions"), - serviceControlClient.fetchTypedFromServiceControl("my/permissions/all"), + serviceControlClient.fetchTypedFromUrl(environment.mypermissions_summary_url), + serviceControlClient.fetchTypedFromUrl(environment.mypermissions_all_url), ]); summary.value = summaryResult[1]; descriptor.value = descriptorResult[1]; From 66cc9ef672ddd8c8c01dd1e42509bd45a42899d4 Mon Sep 17 00:00:00 2001 From: Ramon Smits Date: Fri, 19 Jun 2026 12:46:49 +0200 Subject: [PATCH 6/9] =?UTF-8?q?=F0=9F=90=9B=20Log=20permission=20load=20fa?= =?UTF-8?q?ilures?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The catch block discarded the underlying error, leaving no diagnostic trail when permissions fail to load. Log it via the shared logger (as the other stores do) while still surfacing the friendly message to the user. --- src/Frontend/src/stores/UserPermissionsStore.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Frontend/src/stores/UserPermissionsStore.ts b/src/Frontend/src/stores/UserPermissionsStore.ts index 1c2df3d1f..434adaa32 100644 --- a/src/Frontend/src/stores/UserPermissionsStore.ts +++ b/src/Frontend/src/stores/UserPermissionsStore.ts @@ -2,6 +2,7 @@ import { acceptHMRUpdate, defineStore } from "pinia"; import { ref } from "vue"; import serviceControlClient from "@/components/serviceControlClient"; import { useEnvironmentAndVersionsStore } from "@/stores/EnvironmentAndVersionsStore"; +import logger from "@/logger"; interface PermissionsSummary { failed_messages_read: boolean; @@ -44,7 +45,8 @@ export const useUserPermissionsStore = defineStore("UserPermissionsStore", () => ]); summary.value = summaryResult[1]; descriptor.value = descriptorResult[1]; - } catch { + } catch (err) { + logger.error("Failed to load user permissions", err); error.value = "Failed to load user permissions"; } finally { loading.value = false; From 5fba84ee2f3b1f3f56da8ca4e38878a01f2ad5d4 Mon Sep 17 00:00:00 2001 From: Ramon Smits Date: Fri, 19 Jun 2026 12:47:41 +0200 Subject: [PATCH 7/9] =?UTF-8?q?=F0=9F=92=84=20Use=20Bootstrap=20text-succe?= =?UTF-8?q?ss/text-danger=20for=20permission=20icons?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the bespoke .icon-granted/.icon-denied rules (hardcoded hex colours) with the standard Bootstrap utility classes already used elsewhere for granted/denied FAIcons (StatusIcon, EndpointInstances, ConnectionResultView). --- .../configuration/UserPermissions.vue | 22 ++++++------------- 1 file changed, 7 insertions(+), 15 deletions(-) diff --git a/src/Frontend/src/components/configuration/UserPermissions.vue b/src/Frontend/src/components/configuration/UserPermissions.vue index 11b542a71..973b7b1cc 100644 --- a/src/Frontend/src/components/configuration/UserPermissions.vue +++ b/src/Frontend/src/components/configuration/UserPermissions.vue @@ -33,35 +33,35 @@ onMounted(async () => { Failed Messages - + - + Auditing - + — Monitoring - + - + Admin - + - + @@ -92,14 +92,6 @@ onMounted(async () => { padding: 10px 16px; } -.icon-granted { - color: #28a745; -} - -.icon-denied { - color: #dc3545; -} - .user-label { color: #666; margin-bottom: 12px; From 04524d19a212cadea1af36a8e79f69d6414b8f88 Mon Sep 17 00:00:00 2001 From: Ramon Smits Date: Fri, 19 Jun 2026 15:43:48 +0200 Subject: [PATCH 8/9] =?UTF-8?q?=E2=9C=85=20Test=20usePermissionGate=20fail?= =?UTF-8?q?-open=20and=20per-flag=20gating?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Covers the gate's core contract: it fails open (shows everything) when authorization is disabled, when the user is unauthenticated, or while the summary is still loading; and gates per flag once enabled, authenticated and loaded. Also asserts it reacts to summary updates. --- .../src/composables/usePermissionGate.spec.ts | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 src/Frontend/src/composables/usePermissionGate.spec.ts diff --git a/src/Frontend/src/composables/usePermissionGate.spec.ts b/src/Frontend/src/composables/usePermissionGate.spec.ts new file mode 100644 index 000000000..a76ed96c6 --- /dev/null +++ b/src/Frontend/src/composables/usePermissionGate.spec.ts @@ -0,0 +1,75 @@ +import { describe, test, expect, beforeEach } from "vitest"; +import { createTestingPinia } from "@pinia/testing"; +import { setActivePinia } from "pinia"; +import usePermissionGate from "@/composables/usePermissionGate"; +import { useAuthStore } from "@/stores/AuthStore"; +import { useUserPermissionsStore, type PermissionsSummary } from "@/stores/UserPermissionsStore"; + +const summaryWith = (overrides: Partial = {}): PermissionsSummary => ({ + failed_messages_read: false, + failed_messages_write: false, + auditing_read: false, + monitoring_read: false, + monitoring_write: false, + admin_read: false, + admin_write: false, + ...overrides, +}); + +describe("usePermissionGate", () => { + beforeEach(() => { + setActivePinia(createTestingPinia({ stubActions: false })); + }); + + function withState(opts: { authEnabled: boolean; isAuthenticated: boolean; summary: PermissionsSummary | null }) { + const auth = useAuthStore(); + auth.authEnabled = opts.authEnabled; + auth.isAuthenticated = opts.isAuthenticated; + useUserPermissionsStore().summary = opts.summary; + return usePermissionGate(); + } + + test("fails open (shows everything) when authorization is disabled", () => { + const { has, shouldGate } = withState({ authEnabled: false, isAuthenticated: true, summary: summaryWith() }); + + expect(shouldGate.value).toBe(false); + expect(has("admin_read")).toBe(true); + expect(has("failed_messages_read")).toBe(true); + }); + + test("fails open when the user is not authenticated", () => { + const { has, shouldGate } = withState({ authEnabled: true, isAuthenticated: false, summary: summaryWith() }); + + expect(shouldGate.value).toBe(false); + expect(has("admin_read")).toBe(true); + }); + + test("fails open while the permission summary has not loaded yet", () => { + const { has, shouldGate } = withState({ authEnabled: true, isAuthenticated: true, summary: null }); + + expect(shouldGate.value).toBe(false); + expect(has("admin_read")).toBe(true); + }); + + test("gates per flag once enabled, authenticated and loaded", () => { + const { has, shouldGate } = withState({ authEnabled: true, isAuthenticated: true, summary: summaryWith({ admin_read: true }) }); + + expect(shouldGate.value).toBe(true); + expect(has("admin_read")).toBe(true); + expect(has("failed_messages_read")).toBe(false); + }); + + test("reacts to the summary being updated", () => { + const auth = useAuthStore(); + auth.authEnabled = true; + auth.isAuthenticated = true; + const permissions = useUserPermissionsStore(); + permissions.summary = summaryWith(); + + const { has } = usePermissionGate(); + expect(has("monitoring_read")).toBe(false); + + permissions.summary = summaryWith({ monitoring_read: true }); + expect(has("monitoring_read")).toBe(true); + }); +}); From d14a57343445085017387c38a69e1407a31b67b2 Mon Sep 17 00:00:00 2001 From: Ramon Smits Date: Fri, 19 Jun 2026 15:44:36 +0200 Subject: [PATCH 9/9] =?UTF-8?q?=E2=9C=85=20Test=20UserPermissionsStore=20l?= =?UTF-8?q?oad,=20error=20and=20dedupe=20behaviour?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Covers: a successful refresh fetches the discovered root URLs and populates summary+descriptor; a failed request sets the friendly error and logs the cause; and concurrent refresh() calls share one in-flight request (two endpoint calls, not four) while still re-fetching once settled. --- .../src/stores/UserPermissionsStore.spec.ts | 89 +++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 src/Frontend/src/stores/UserPermissionsStore.spec.ts diff --git a/src/Frontend/src/stores/UserPermissionsStore.spec.ts b/src/Frontend/src/stores/UserPermissionsStore.spec.ts new file mode 100644 index 000000000..e73270c92 --- /dev/null +++ b/src/Frontend/src/stores/UserPermissionsStore.spec.ts @@ -0,0 +1,89 @@ +import { describe, test, expect, beforeEach, vi } from "vitest"; +import { createTestingPinia } from "@pinia/testing"; +import { setActivePinia } from "pinia"; + +vi.mock("@/components/serviceControlClient", () => ({ + default: { fetchTypedFromUrl: vi.fn() }, +})); + +import serviceControlClient from "@/components/serviceControlClient"; +import logger from "@/logger"; +import { useUserPermissionsStore } from "@/stores/UserPermissionsStore"; +import { useEnvironmentAndVersionsStore } from "@/stores/EnvironmentAndVersionsStore"; + +const summaryUrl = "http://localhost/my/permissions"; +const allUrl = "http://localhost/my/permissions/all"; + +const fetchTypedFromUrl = vi.mocked(serviceControlClient.fetchTypedFromUrl); + +const summaryData = { failed_messages_read: true, failed_messages_write: false, auditing_read: true, monitoring_read: false, monitoring_write: false, admin_read: false, admin_write: false }; +const descriptorData = { user: "alice", permissions: ["error:messages:view", "audit:view"] }; + +function ok(data: T): [Response, T] { + return [{} as Response, data]; +} + +function setup() { + setActivePinia(createTestingPinia({ stubActions: false })); + const environment = useEnvironmentAndVersionsStore().environment; + environment.mypermissions_summary_url = summaryUrl; + environment.mypermissions_all_url = allUrl; + return useUserPermissionsStore(); +} + +describe("UserPermissionsStore", () => { + beforeEach(() => { + fetchTypedFromUrl.mockReset(); + }); + + test("refresh fetches the discovered URLs and populates summary and descriptor", async () => { + const store = setup(); + fetchTypedFromUrl.mockImplementation((url: string) => Promise.resolve(url === summaryUrl ? ok(summaryData) : ok(descriptorData))); + + await store.refresh(); + + expect(fetchTypedFromUrl).toHaveBeenCalledWith(summaryUrl); + expect(fetchTypedFromUrl).toHaveBeenCalledWith(allUrl); + expect(store.summary).toEqual(summaryData); + expect(store.descriptor).toEqual(descriptorData); + expect(store.error).toBeNull(); + expect(store.loading).toBe(false); + }); + + test("refresh records an error and logs when a request fails", async () => { + const store = setup(); + const loggerError = vi.spyOn(logger, "error").mockImplementation(() => {}); + const failure = new Error("boom"); + fetchTypedFromUrl.mockRejectedValue(failure); + + await store.refresh(); + + expect(store.summary).toBeNull(); + expect(store.error).toBe("Failed to load user permissions"); + expect(store.loading).toBe(false); + expect(loggerError).toHaveBeenCalledWith("Failed to load user permissions", failure); + loggerError.mockRestore(); + }); + + test("concurrent refreshes share a single in-flight request", async () => { + const store = setup(); + let resolveSummary: (value: [Response, typeof summaryData]) => void = () => {}; + const pendingSummary = new Promise<[Response, typeof summaryData]>((resolve) => (resolveSummary = resolve)); + fetchTypedFromUrl.mockImplementation((url: string) => (url === summaryUrl ? pendingSummary : Promise.resolve(ok(descriptorData)))); + + const first = store.refresh(); + const second = store.refresh(); + + // One load in flight => two endpoint calls (summary + descriptor), not four. + expect(fetchTypedFromUrl).toHaveBeenCalledTimes(2); + + resolveSummary(ok(summaryData)); + await Promise.all([first, second]); + + expect(store.summary).toEqual(summaryData); + + // Once settled the in-flight slot is released, so a later refresh fetches again. + await store.refresh(); + expect(fetchTypedFromUrl).toHaveBeenCalledTimes(4); + }); +});