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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions src/Frontend/src/App.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { render } from "@testing-library/vue";
import { describe, test, expect, vi } from "vitest";
import { createTestingPinia } from "@pinia/testing";
import { setActivePinia } from "pinia";
import { ref } from "vue";

vi.mock("bootstrap", () => ({}));

// Avoid pulling in a real router; App only needs useRoute (for meta) and RouterView.
const routeMeta = ref<Record<string, unknown>>({});
vi.mock("vue-router", () => ({
useRoute: () => ({
get meta() {
return routeMeta.value;
},
}),
RouterView: { name: "RouterView", template: "<div data-testid='router-view' />" },
}));

import App from "@/App.vue";
import { useAuthStore } from "@/stores/AuthStore";

function setup(opts: { authEnabled: boolean; isAuthenticated: boolean; meta?: Record<string, unknown> }) {
routeMeta.value = opts.meta ?? {};
const pinia = createTestingPinia({ stubActions: false });
setActivePinia(pinia);
const store = useAuthStore();
store.authEnabled = opts.authEnabled;
store.isAuthenticated = opts.isAuthenticated;
return render(App, {
global: {
plugins: [pinia],
stubs: {
PageHeader: { template: "<div data-testid='page-header' />" },
PageFooter: true,
LicenseNotifications: true,
BackendChecksNotifications: true,
},
},
});
}

// App.vue is now display-only: recovery from a lost session lives in useAuth (see useAuth.spec.ts).
describe("App layout gating", () => {
test("shows the full layout when authenticated", () => {
const { queryByTestId } = setup({ authEnabled: true, isAuthenticated: true });
expect(queryByTestId("page-header")).not.toBeNull();
expect(queryByTestId("router-view")).not.toBeNull();
});

test("shows the full layout when authentication is disabled", () => {
const { queryByTestId } = setup({ authEnabled: false, isAuthenticated: false });
expect(queryByTestId("page-header")).not.toBeNull();
});

test("renders nothing when auth is enabled and the user is not authenticated", () => {
const { queryByTestId } = setup({ authEnabled: true, isAuthenticated: false });
expect(queryByTestId("page-header")).toBeNull();
expect(queryByTestId("router-view")).toBeNull();
});

test("on an anonymous route, shows the page without the full layout", () => {
const { queryByTestId } = setup({ authEnabled: true, isAuthenticated: false, meta: { allowAnonymous: true } });
expect(queryByTestId("router-view")).not.toBeNull();
expect(queryByTestId("page-header")).toBeNull();
});
});
2 changes: 2 additions & 0 deletions src/Frontend/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ const isAnonymousRoute = computed(() => route.meta?.allowAnonymous === true);
const shouldShowApp = computed(() => !authEnabled.value || isAuthenticated.value || isAnonymousRoute.value);
// Show full app layout (header, footer, notifications) only when authenticated or auth is disabled
const shouldShowFullLayout = computed(() => !authEnabled.value || isAuthenticated.value);
// Recovery from a lost session (expired token / failed silent renewal) is handled in the auth
// domain by useAuth's OIDC event handlers, not here.
</script>

<template>
Expand Down
97 changes: 97 additions & 0 deletions src/Frontend/src/composables/useAuth.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { describe, test, expect, vi, beforeEach } from "vitest";
import { setActivePinia, createPinia } from "pinia";
import routeLinks from "@/router/routeLinks";

// Capture the OIDC event callbacks useAuth registers, plus a spy on signinRedirect, so the tests
// can fire "token expired" / "silent renew error" and assert that recovery re-authenticates.
const signinRedirect = vi.fn().mockResolvedValue(undefined);
const captured: { expired?: () => void; renewError?: (error: unknown) => void } = {};

vi.mock("oidc-client-ts", () => ({
UserManager: class {
getUser = vi.fn().mockResolvedValue(null);
signinRedirect = signinRedirect;
signinCallback = vi.fn().mockResolvedValue(null);
signinSilent = vi.fn().mockResolvedValue(null);
signoutRedirect = vi.fn().mockResolvedValue(undefined);
removeUser = vi.fn().mockResolvedValue(undefined);
events = {
addUserLoaded: vi.fn(),
addUserUnloaded: vi.fn(),
addAccessTokenExpiring: vi.fn(),
addAccessTokenExpired: vi.fn((cb: () => void) => {
captured.expired = cb;
}),
addSilentRenewError: vi.fn((cb: (error: unknown) => void) => {
captured.renewError = cb;
}),
};
},
WebStorageStateStore: class {},
}));

const config = { authority: "https://idp" } as never;

// Fresh module state per test (useAuth keeps a module-singleton UserManager), then run the initial
// authenticate so the handlers are registered. getUser returns null, so this initial call performs
// one signinRedirect and leaves isAuthenticating true (as in the real redirect-away flow).
async function initAuth() {
const { useAuth } = await import("@/composables/useAuth");
const { useAuthStore } = await import("@/stores/AuthStore");
const auth = useAuth();
await auth.authenticate(config);
return useAuthStore();
}

beforeEach(() => {
vi.resetModules();
signinRedirect.mockClear();
captured.expired = undefined;
captured.renewError = undefined;
setActivePinia(createPinia());
window.location.hash = "";
});

describe("useAuth recovers a lost session from OIDC events", () => {
test("re-authenticates and clears the stale token when the access token expires", async () => {
const store = await initAuth();
store.setAuthenticating(false); // initial redirect 'returned'
signinRedirect.mockClear();

captured.expired!();

expect(signinRedirect).toHaveBeenCalledTimes(1);
expect(store.token).toBeNull();
});

test("re-authenticates when silent renewal errors", async () => {
const store = await initAuth();
store.setAuthenticating(false);
signinRedirect.mockClear();

captured.renewError!(new Error("silent renew failed"));

expect(signinRedirect).toHaveBeenCalledTimes(1);
});

test("does not re-authenticate while an auth flow is already running", async () => {
const store = await initAuth();
expect(store.isAuthenticating).toBe(true); // left true by the initial redirect
signinRedirect.mockClear();

captured.expired!();

expect(signinRedirect).not.toHaveBeenCalled();
});

test("does not re-authenticate on the logged-out route", async () => {
const store = await initAuth();
store.setAuthenticating(false);
window.location.hash = `#${routeLinks.loggedOut}`;
signinRedirect.mockClear();

captured.expired!();

expect(signinRedirect).not.toHaveBeenCalled();
});
});
25 changes: 25 additions & 0 deletions src/Frontend/src/composables/useAuth.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useAuthStore } from "@/stores/AuthStore";
import type { AuthConfig } from "@/types/auth";
import { UserManager, type User } from "oidc-client-ts";
import routeLinks from "@/router/routeLinks";
import logger from "@/logger";

let userManager: UserManager | null = null;
Expand All @@ -12,6 +13,24 @@ let userManager: UserManager | null = null;
export function useAuth() {
const authStore = useAuthStore();

// The session was lost mid-run (access token expired or silent renewal failed). Re-authenticate
// instead of leaving the app blank. With a live identity-provider session this is a silent
// redirect round-trip; otherwise the user lands on the provider's login page. Skip it when an
// auth flow is already running, or when a logout left us on the anonymous logged-out route.
function reauthenticate() {
if (authStore.isAuthenticating) {
return;
}
if (window.location.hash === `#${routeLinks.loggedOut}`) {
return;
}
authStore.setAuthenticating(true);
userManager?.signinRedirect().catch((error) => {
authStore.setAuthenticating(false);
logger.error("Re-authentication after session loss failed:", error);
});
}

function initializeUserManager(config: AuthConfig): UserManager {
if (!userManager) {
userManager = new UserManager(config);
Expand All @@ -33,12 +52,18 @@ export function useAuth() {
}
});

// Token fully expired, or silent renewal errored: clear the stale token and re-authenticate
// rather than rendering a blank app. Reacting to the OIDC events directly keeps recovery in
// the auth domain and distinguishes session loss from an intentional logout, which arrives
// as addUserUnloaded and must not re-trigger authentication.
userManager.events.addAccessTokenExpired(() => {
authStore.clearToken();
reauthenticate();
});

userManager.events.addSilentRenewError((error) => {
logger.error("Silent renew error:", error);
reauthenticate();
});
}

Expand Down