From a9b62fe515da2c0d6a1bb457b91a6c4a8b622fce Mon Sep 17 00:00:00 2001 From: Dennis van der Stelt Date: Thu, 25 Jun 2026 01:13:46 +0200 Subject: [PATCH 1/2] =?UTF-8?q?=F0=9F=90=9B=20Re-authenticate=20instead=20?= =?UTF-8?q?of=20going=20blank=20when=20the=20session=20is=20lost?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit App.vue renders the whole app behind shouldShowApp (authEnabled, isAuthenticated, isAnonymousRoute). When the access token expired and silent renewal failed, isAuthenticated flipped to false and the app rendered nothing, requiring a manual browser refresh to recover. Watch for the session being lost while running and re-trigger authentication: with a live identity-provider session this is a silent redirect round-trip; otherwise the user lands on the provider's login page. Skipped on anonymous routes and while a sign-in is already in progress. --- src/Frontend/src/App.spec.ts | 72 ++++++++++++++++++++++++++++++++++++ src/Frontend/src/App.vue | 16 +++++++- 2 files changed, 86 insertions(+), 2 deletions(-) create mode 100644 src/Frontend/src/App.spec.ts diff --git a/src/Frontend/src/App.spec.ts b/src/Frontend/src/App.spec.ts new file mode 100644 index 0000000000..d4478460f1 --- /dev/null +++ b/src/Frontend/src/App.spec.ts @@ -0,0 +1,72 @@ +import { render } from "@testing-library/vue"; +import { describe, test, expect, vi, beforeEach } from "vitest"; +import { createTestingPinia } from "@pinia/testing"; +import { setActivePinia } from "pinia"; +import { nextTick, ref } from "vue"; + +const authenticate = vi.fn().mockResolvedValue(false); +vi.mock("@/composables/useAuth", () => ({ useAuth: () => ({ authenticate }) })); +vi.mock("bootstrap", () => ({})); + +// Avoid pulling in a real router; App only needs useRoute (for meta) and RouterView. +const routeMeta = ref>({}); +vi.mock("vue-router", () => ({ + useRoute: () => ({ + get meta() { + return routeMeta.value; + }, + }), + RouterView: { name: "RouterView", template: "
" }, +})); + +import App from "@/App.vue"; +import { useAuthStore } from "@/stores/AuthStore"; + +function setup(meta: Record = {}) { + routeMeta.value = meta; + const pinia = createTestingPinia({ stubActions: false }); + setActivePinia(pinia); + const store = useAuthStore(); + store.authEnabled = true; + store.isAuthenticated = true; + store.isAuthenticating = false; + store.authConfig = { authority: "https://idp" } as never; + render(App, { + global: { + plugins: [pinia], + stubs: { PageHeader: true, PageFooter: true, LicenseNotifications: true, BackendChecksNotifications: true }, + }, + }); + return store; +} + +describe("App re-authenticates when the session is lost", () => { + beforeEach(() => authenticate.mockClear()); + + test("re-triggers authentication when the token is lost mid-session", async () => { + const store = setup(); + expect(authenticate).not.toHaveBeenCalled(); + + store.isAuthenticated = false; // token expired / cleared while running + await nextTick(); + + expect(authenticate).toHaveBeenCalledTimes(1); + }); + + test("does not re-authenticate while already authenticating", async () => { + const store = setup(); + store.isAuthenticating = true; + store.isAuthenticated = false; + await nextTick(); + + expect(authenticate).not.toHaveBeenCalled(); + }); + + test("does not re-authenticate on an anonymous route (e.g. logged-out)", async () => { + const store = setup({ allowAnonymous: true }); + store.isAuthenticated = false; + await nextTick(); + + expect(authenticate).not.toHaveBeenCalled(); + }); +}); diff --git a/src/Frontend/src/App.vue b/src/Frontend/src/App.vue index de55293cb5..6ee08275bb 100644 --- a/src/Frontend/src/App.vue +++ b/src/Frontend/src/App.vue @@ -1,5 +1,5 @@