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
14 changes: 13 additions & 1 deletion src/Frontend/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,12 @@ import BackendChecksNotifications from "@/components/BackendChecksNotifications.
import { storeToRefs } from "pinia";
import { useAuthStore } from "@/stores/AuthStore";
import { useAllowedRoutes } from "@/composables/useAllowedRoutes";
import { useConfigurationStore } from "@/stores/ConfigurationStore";

const authStore = useAuthStore();
const configurationStore = useConfigurationStore();
const route = useRoute();
const { isAuthenticated, authEnabled } = storeToRefs(authStore);
const { isAuthenticated, authEnabled, loading } = storeToRefs(authStore);

// Load the allowed-route manifest (my/routes) once authenticated, so the nav and other
// UI can gate on it. Fail-safe: a missing/old endpoint just leaves the manifest unloaded
Expand All @@ -33,6 +35,16 @@ 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);

watch(
[loading, authEnabled, isAuthenticated],
([isLoading, enabled, authenticated]) => {
if (!isLoading && (!enabled || authenticated)) {
configurationStore.ensureLoaded();
}
},
{ immediate: true }
);
</script>

<template>
Expand Down
1 change: 1 addition & 0 deletions src/Frontend/src/components/audit/AuditList.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ async function renderAuditList(messages: Message[] = []): Promise<RenderResult>
createSpy: vi.fn,
initialState: {
AuditStore: { messages, totalCount: messages.length },
ConfigurationStore: { isMassTransitConnected: false },
},
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,8 @@ function togglePanel(panelNum: number) {
panel.value = panelNum;
}
onMounted(() => {
onMounted(async () => {
await messageStore.ensureEditAndRetryConfigurationLoaded();
togglePanel(1);
initializeMessageBodyAndHeaders();
});
Expand Down
27 changes: 19 additions & 8 deletions src/Frontend/src/components/failedmessages/PendingRetries.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { render, screen } from "@testing-library/vue";
import { createTestingPinia } from "@pinia/testing";
import { createRouter, createMemoryHistory } from "vue-router";
import FailedMessagesView from "@/views/FailedMessagesView.vue";
import routeLinks from "@/router/routeLinks";

/**
* DSL for the Pending Retries Tab Visibility feature.
Expand Down Expand Up @@ -68,38 +69,48 @@ async function renderComponent(options: RenderOptions = {}): Promise<RenderResul
routes: [
{
path: "/",
redirect: "/failed-messages/groups",
redirect: routeLinks.failedMessage.failedMessagesGroups.link,
},
{
path: "/failed-messages",
path: routeLinks.failedMessage.root,
name: "failed-messages",
component: FailedMessagesView,
children: [
{
path: "groups",
path: routeLinks.failedMessage.failedMessagesGroups.template,
name: "failed-messages-groups",
component: { template: "<div>Groups</div>" },
},
{
path: "all",
path: routeLinks.failedMessage.failedMessages.template,
name: "failed-messages-all",
component: { template: "<div>All Messages</div>" },
},
{
path: "deleted-groups",
path: routeLinks.failedMessage.deletedMessagesGroup.template,
name: "failed-messages-deleted-groups",
component: { template: "<div>Deleted Groups</div>" },
},
{
path: "deleted",
path: routeLinks.failedMessage.deletedMessages.template,
name: "failed-messages-deleted",
component: { template: "<div>Deleted</div>" },
},
{
path: "pending-retries",
path: routeLinks.failedMessage.pendingRetries.template,
name: "failed-messages-pending-retries",
component: { template: "<div>Pending Retries</div>" },
},
{
path: routeLinks.failedMessage.group.template,
name: "failed-messages-group",
component: { template: "<div>Group</div>" },
},
{
path: routeLinks.failedMessage.deletedGroup.template,
name: "failed-messages-deleted-group",
component: { template: "<div>Deleted Group</div>" },
},
],
},
],
Expand All @@ -110,7 +121,7 @@ async function renderComponent(options: RenderOptions = {}): Promise<RenderResul
stubActions: true,
});

await router.push("/failed-messages/groups");
await router.push(routeLinks.failedMessage.failedMessagesGroups.link);
await router.isReady();

render(FailedMessagesView, {
Expand Down
6 changes: 5 additions & 1 deletion src/Frontend/src/components/messages/EditAndRetryButton.vue
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<script setup lang="ts">
import ActionButton from "@/components/ActionButton.vue";
import { useMessageStore } from "@/stores/MessageStore";
import { computed, ref } from "vue";
import { computed, onMounted, ref } from "vue";
import { useShowToast } from "@/composables/toast";
import { TYPE } from "vue-toastification";
import EditRetryDialog from "@/components/failedmessages/EditRetryDialog.vue";
Expand Down Expand Up @@ -47,6 +47,10 @@ async function openDialog() {
await store.downloadBody();
isConfirmDialogVisible.value = true;
}

onMounted(() => {
store.ensureEditAndRetryConfigurationLoaded();
});
</script>

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

const logger = vi.hoisted(() => ({
warn: vi.fn(),
error: vi.fn(),
}));

// 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("@/logger", () => ({
default: logger,
}));

vi.mock("oidc-client-ts", () => ({
UserManager: class {
getUser = vi.fn().mockResolvedValue(null);
Expand Down Expand Up @@ -45,6 +54,8 @@ async function initAuth() {

beforeEach(() => {
vi.resetModules();
logger.warn.mockReset();
logger.error.mockReset();
signinRedirect.mockClear();
captured.expired = undefined;
captured.renewError = undefined;
Expand All @@ -69,9 +80,11 @@ describe("useAuth recovers a lost session from OIDC events", () => {
store.setAuthenticating(false);
signinRedirect.mockClear();

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

expect(signinRedirect).toHaveBeenCalledTimes(1);
expect(logger.error).toHaveBeenCalledWith("Silent renew error:", error);
});

test("does not re-authenticate while an auth flow is already running", async () => {
Expand Down
14 changes: 14 additions & 0 deletions src/Frontend/src/stores/AllowedRoutesStore.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,17 @@ import { setActivePinia, createPinia } from "pinia";
import { ApiRoutes } from "@/composables/apiRoutes";
import { normalizeRouteKey } from "@/composables/routeMatching";

const logger = vi.hoisted(() => ({
warn: vi.fn(),
error: vi.fn(),
}));

const rootFetch = vi.fn();
const scFetch = vi.fn();
const monFetch = vi.fn();
vi.mock("@/logger", () => ({
default: logger,
}));
vi.mock("@/components/serviceControlClient", () => ({
default: {
fetchTypedFromServiceControl: (s: string) => rootFetch(s),
Expand All @@ -30,6 +38,8 @@ const rootDoc = (myRoutesUrl?: string) => [{}, myRoutesUrl === undefined ? {} :
describe("AllowedRoutesStore", () => {
beforeEach(() => {
setActivePinia(createPinia());
logger.warn.mockReset();
logger.error.mockReset();
rootFetch.mockReset();
scFetch.mockReset();
monFetch.mockReset();
Expand Down Expand Up @@ -80,6 +90,9 @@ describe("AllowedRoutesStore", () => {
await store.refresh();
expect(store.loaded).toBe(false);
expect(store.loadAttempted).toBe(true);
expect(logger.warn).toHaveBeenCalledTimes(2);
expect(logger.warn).toHaveBeenNthCalledWith(1, "Failed to fetch allowed routes", expect.any(Error));
expect(logger.warn).toHaveBeenNthCalledWith(2, "Failed to fetch allowed routes", expect.any(Error));
});

it("Monitoring manifest entry at root (no /api prefix) matches the ApiRoutes registry path", async () => {
Expand All @@ -106,6 +119,7 @@ describe("AllowedRoutesStore", () => {
expect(store.loaded).toBe(true);
expect(store.routes.has("GET /api/errors")).toBe(true);
expect(store.routes.size).toBe(1);
expect(logger.warn).toHaveBeenCalledWith("Skipping malformed allowed-route entry", { method: "POST" });
});

it("skips the primary fetch entirely when the root document omits my_routes_url", async () => {
Expand Down
33 changes: 24 additions & 9 deletions src/Frontend/src/stores/ConfigurationStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,22 +6,37 @@ import logger from "@/logger";

export const useConfigurationStore = defineStore("ConfigurationStore", () => {
const configuration = ref<Configuration | null>(null);
let refreshPromise: Promise<void> | null = null;

const isMassTransitConnected = computed(() => configuration.value?.mass_transit_connector !== undefined);

serviceControlClient
.fetchFromServiceControl("configuration")
.then(async (response) => {
configuration.value = await response.json();
return configuration.value;
})
.catch((error) => {
logger.error("Failed to fetch configuration:", error);
});
async function refresh() {
refreshPromise ??= (async () => {
try {
const response = await serviceControlClient.fetchFromServiceControl("configuration");
configuration.value = await response.json();
} catch (error) {
logger.error("Failed to fetch configuration:", error);
} finally {
refreshPromise = null;
}
})();

await refreshPromise;
}

async function ensureLoaded() {
if (configuration.value !== null) {
return;
}
await refresh();
}

return {
configuration,
isMassTransitConnected,
ensureLoaded,
refresh,
};
});

Expand Down
51 changes: 51 additions & 0 deletions src/Frontend/src/stores/MessageStore.spec.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,26 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { createPinia, setActivePinia } from "pinia";
import { ref } from "vue";
import { HttpError } from "@/utils/HttpError";

const { fetchFromServiceControl, fetchTypedFromServiceControl } = vi.hoisted(() => ({
fetchFromServiceControl: vi.fn(),
fetchTypedFromServiceControl: vi.fn(),
}));
const logger = vi.hoisted(() => ({
warn: vi.fn(),
error: vi.fn(),
}));

vi.mock("@/components/serviceControlClient", () => ({
default: {
fetchFromServiceControl,
fetchTypedFromServiceControl,
},
}));
vi.mock("@/logger", () => ({
default: logger,
}));

vi.mock("@/composables/useEnvironmentAndVersionsAutoRefresh", () => ({
default: () => ({
Expand All @@ -28,6 +36,8 @@ describe("MessageStore tests", () => {
beforeEach(() => {
setActivePinia(createPinia());
vi.clearAllMocks();
logger.warn.mockReset();
logger.error.mockReset();

fetchFromServiceControl.mockResolvedValue({
json: () => Promise.resolve({ data_retention: {} }),
Expand All @@ -41,6 +51,12 @@ describe("MessageStore tests", () => {
});
});

test("does not load edit/config during store creation", () => {
useMessageStore();

expect(fetchTypedFromServiceControl).not.toHaveBeenCalledWith("edit/config");
});

test.each([
["id\\with\\backslash", "id%5Cwith%5Cbackslash"],
["id/with/slash", "id%2Fwith%2Fslash"],
Expand All @@ -51,4 +67,39 @@ describe("MessageStore tests", () => {

expect(fetchTypedFromServiceControl).toHaveBeenCalledWith(`messages/search/${encodedMessageId}`);
});

test("logs a warning when edit/config fails with a non-403 error", async () => {
fetchTypedFromServiceControl.mockImplementation((suffix: string) => {
if (suffix === "edit/config") {
return Promise.reject(new Error("boom"));
}

return Promise.resolve([{} as Response, []]);
});

const store = useMessageStore();
await store.ensureEditAndRetryConfigurationLoaded();
await vi.waitFor(() => {
expect(fetchTypedFromServiceControl).toHaveBeenCalledWith("edit/config");
expect(logger.warn).toHaveBeenCalledWith("Failed to load Edit and Retry configuration");
});
});

test("does not log a warning when edit/config returns 403", async () => {
fetchTypedFromServiceControl.mockImplementation((suffix: string) => {
if (suffix === "edit/config") {
return Promise.reject(new HttpError(403));
}

return Promise.resolve([{} as Response, []]);
});

const store = useMessageStore();
await store.ensureEditAndRetryConfigurationLoaded();
await vi.waitFor(() => {
expect(fetchTypedFromServiceControl).toHaveBeenCalledWith("edit/config");
});

expect(logger.warn).not.toHaveBeenCalled();
});
});
Loading