Files
pleno-vue/tests/unit/session-bootstrap.spec.js
Jeppe B 664b50d4ef Clarify customer login and invoice emails (#250)
Separate Pleno login email from e-conomic invoice email, make the login-email update flow authoritative and cache-safe, clear stale economic profile state, and add focused frontend coverage.
2026-08-03 10:43:53 +02:00

439 lines
16 KiB
JavaScript

// @vitest-environment jsdom
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
authenticatedRequest: vi.fn(),
parseError: vi.fn(),
pingApiServer: vi.fn(),
fetchReleaseRuntime: vi.fn(() => Promise.resolve({})),
swalFire: vi.fn(() => Promise.resolve({ isConfirmed: true })),
loginRedirect: vi.fn(),
subuserLoginRedirect: vi.fn(),
}));
vi.mock("@/components/session/authenticatedRequest.vue", () => ({
authenticatedRequest: mocks.authenticatedRequest,
}));
vi.mock("@/components/request/HandleGlobalError.vue", () => ({
parseError: mocks.parseError,
}));
vi.mock("@/services/apiHealth.js", () => ({
pingApiServer: mocks.pingApiServer,
}));
vi.mock("@/services/releaseBootstrap.js", () => ({
fetchReleaseRuntime: mocks.fetchReleaseRuntime,
}));
vi.mock("sweetalert2", () => ({
default: {
fire: mocks.swalFire,
},
}));
import { getSessionData, getSubuserSessionData, SessionUser } from "@/components/session/token/SessionUser.vue";
import { EDGE_GATEWAY_WORKSPACE_CACHE_KEY } from "@/services/edgeGateways.js";
import {
buildPeriodCacheKey,
getCachedPeriodPage,
setCachedPeriodPage,
} from "@/views/dashboards/superUserDashboard/InvoicingBillingPeriod/imports/InvoicingBillingPeriodImportPaging.js";
import {
getCachedXlvaskUsageAmount,
setCachedXlvaskUsageAmount,
} from "@/components/displays/department/pos/sync/xlvaskUsageAmountCache.js";
const seedStoredSession = (token = "stored-token") => {
localStorage.setItem("token", token);
SessionUser.token.value = token;
SessionUser.authenticated.value = true;
SessionUser.isSubuser.value = false;
SessionUser.initiated.value = false;
};
const invalidSessionError = () => ({
response: {
status: 401,
data: {
message: "Unauthenticated",
},
},
});
const userNotFoundSessionError = () => ({
response: {
status: 400,
data: {
data: {
message: "User not found",
},
},
},
});
const subuserSessionPayload = () => ({
data: {
data: {
id: 77,
username: "driver-77",
name: "Driver 77",
email: "driver@example.test",
email_verified: true,
email_verified_at: "2026-07-13 10:00:00",
phone_country_code: 45,
phone: 12345678,
phone_verified: true,
phone_verified_at: "2026-07-13 10:00:00",
verification_state: "verified",
verification: { state: "verified" },
grants: [
{
grant_id: 5,
billing_customer_number: 12345678,
permissions: ["BOOKINGS_LIST", "VEHICLES_LIST"],
},
],
created_at: "2026-07-13 09:00:00",
updated_at: "2026-07-13 09:30:00",
suspended_at: null,
},
},
});
describe("session bootstrap failure handling", () => {
beforeEach(() => {
SessionUser.auth.forceClearSession();
SessionUser.functions.redirectTo.pages.login = mocks.loginRedirect;
SessionUser.functions.redirectTo.pages.subuserLogin = mocks.subuserLoginRedirect;
mocks.authenticatedRequest.mockReset();
mocks.parseError.mockReset();
mocks.pingApiServer.mockReset();
mocks.fetchReleaseRuntime.mockReset();
mocks.fetchReleaseRuntime.mockResolvedValue({});
mocks.swalFire.mockClear();
mocks.loginRedirect.mockReset();
mocks.subuserLoginRedirect.mockReset();
window.history.replaceState({}, "", "/user");
});
it("clears privileged session caches when forcing the session closed", () => {
seedStoredSession("cached-session-token");
const periodCacheKey = buildPeriodCacheKey({
dateFrom: "2026-05-01",
dateTo: "2026-05-31",
periodView: "all",
page: 1,
limit: 100,
search: "",
flagTab: "all",
includeRequiresAction: true,
includeBooked: true,
});
setCachedPeriodPage(
periodCacheKey,
{ types: { all: [{ customer_name: "Sensitive Customer" }] } },
{ page: 1, per_page: 100, total: 1 }
);
setCachedXlvaskUsageAmount(8101, {
order_items: [{ license_plate: "PII-123" }],
potential_duplicates: [],
});
window.sessionStorage.setItem(EDGE_GATEWAY_WORKSPACE_CACHE_KEY, JSON.stringify({ details: { 1: "gateway" } }));
window.localStorage.setItem(EDGE_GATEWAY_WORKSPACE_CACHE_KEY, JSON.stringify({ details: { 1: "legacy" } }));
expect(getCachedPeriodPage(periodCacheKey)?.data?.types?.all?.[0]?.customer_name).toBe("Sensitive Customer");
expect(getCachedXlvaskUsageAmount(8101)?.order_items?.[0]?.license_plate).toBe("PII-123");
expect(window.sessionStorage.getItem(EDGE_GATEWAY_WORKSPACE_CACHE_KEY)).toContain("gateway");
expect(window.localStorage.getItem(EDGE_GATEWAY_WORKSPACE_CACHE_KEY)).toContain("legacy");
window.localStorage.setItem("selected_customer_number", "12345678");
SessionUser.auth.forceClearSession();
expect(getCachedPeriodPage(periodCacheKey)).toBeNull();
expect(getCachedXlvaskUsageAmount(8101)).toBeNull();
expect(Object.keys(window.sessionStorage).filter((key) => key.startsWith("invoicing-period-page:")).length).toBe(0);
expect(Object.keys(window.sessionStorage).filter((key) => key.startsWith("xlvask-usage-amount:")).length).toBe(0);
expect(window.sessionStorage.getItem(EDGE_GATEWAY_WORKSPACE_CACHE_KEY)).toBeNull();
expect(window.localStorage.getItem(EDGE_GATEWAY_WORKSPACE_CACHE_KEY)).toBeNull();
expect(window.localStorage.getItem("selected_customer_number")).toBeNull();
});
it("clears the local session when the logout request stalls", async () => {
seedStoredSession("logout-token");
mocks.authenticatedRequest.mockReturnValueOnce(new Promise(() => {}));
await SessionUser.auth.logout({ timeoutMs: 1 });
expect(mocks.authenticatedRequest).toHaveBeenCalledWith("/auth/logout", "GET");
expect(localStorage.getItem("token")).toBeNull();
expect(SessionUser.authenticated.value).toBe(false);
});
it("redirects after a confirmed logout even when the API logout request fails", async () => {
seedStoredSession("logout-token");
mocks.authenticatedRequest.mockRejectedValueOnce(new Error("Network unavailable"));
await SessionUser.functions.showConfirmLogoutDialog();
expect(localStorage.getItem("token")).toBeNull();
expect(mocks.loginRedirect).toHaveBeenCalledTimes(1);
});
it("clears stale economic data when a refreshed session has no economic customer", async () => {
seedStoredSession("customer-session-token");
SessionUser.economicData.customerNumber.value = 12345679;
SessionUser.economicData.name.value = "Stale Customer";
SessionUser.economicData.address.value = "Old Street 1";
SessionUser.economicData.zip.value = "1000";
SessionUser.economicData.city.value = "Copenhagen";
SessionUser.economicData.mobilePhone.value = "12345678";
SessionUser.economicData.email.value = "stale.invoice@example.test";
SessionUser.economicData.cvr.value = "12345678";
SessionUser.economicData.currency.value = "DKK";
SessionUser.economicData.country.value = "DK";
SessionUser.economicData.cached_at.value = new Date();
mocks.authenticatedRequest.mockResolvedValueOnce({
data: {
data: {
id: 1,
customer_number: 12345679,
group_id: 1,
email: "login@example.test",
phone: { number: "12345678", country_code: 45 },
notifications: {},
display_name: "Customer",
permissions: ["user"],
economic_customer: null,
runtime_config: { economic: {}, release: {} },
},
},
});
await getSessionData();
expect(SessionUser.economicData.customerNumber.value).toBeNull();
expect(SessionUser.economicData.name.value).toBeNull();
expect(SessionUser.economicData.address.value).toBeNull();
expect(SessionUser.economicData.zip.value).toBeNull();
expect(SessionUser.economicData.city.value).toBeNull();
expect(SessionUser.economicData.mobilePhone.value).toBeNull();
expect(SessionUser.economicData.email.value).toBeNull();
expect(SessionUser.economicData.cvr.value).toBeNull();
expect(SessionUser.economicData.currency.value).toBeNull();
expect(SessionUser.economicData.country.value).toBeNull();
expect(SessionUser.economicData.cached_at.value).toBeNull();
});
it("clears an invalid stored session after ping confirms the API is reachable", async () => {
seedStoredSession("invalid-token");
mocks.authenticatedRequest.mockRejectedValueOnce(invalidSessionError());
mocks.pingApiServer.mockResolvedValueOnce({
ok: true,
status: 200,
url: "/api/ping",
error: null,
});
const result = await getSessionData();
expect(mocks.pingApiServer).toHaveBeenCalledTimes(1);
expect(localStorage.getItem("token")).toBeNull();
expect(SessionUser.authenticated.value).toBe(false);
expect(mocks.loginRedirect).toHaveBeenCalledTimes(1);
expect(result).toMatchObject({
cleared: true,
apiReachable: true,
redirectedTo: "/login",
});
});
it("does not expose subuser grant permissions before a valid grant is selected", () => {
SessionUser.auth.forceClearSession();
localStorage.setItem("token", "subuser-token");
localStorage.setItem("is_subuser", "true");
SessionUser.token.value = "subuser-token";
SessionUser.authenticated.value = true;
SessionUser.isSubuser.value = true;
SessionUser.permissions.value = ["SUBUSERS_LIST"];
SessionUser.subuser.grants.value = [
{
billing_customer_number: 12345678,
permissions: ["SUBUSERS_LIST"],
},
];
SessionUser.subuser.selectGrant(null);
expect(SessionUser.hasPermission("SUBUSERS_LIST")).toBe(false);
SessionUser.subuser.selectGrant(12345678);
expect(SessionUser.hasPermission("SUBUSERS_LIST")).toBe(true);
});
it("maps regular customer feature actions through legacy customer permissions", () => {
SessionUser.auth.forceClearSession();
SessionUser.isSubuser.value = false;
SessionUser.permissions.value = ["user"];
expect(SessionUser.canAccessCustomerFeature("vehicles", "list")).toBe(true);
expect(SessionUser.canAccessCustomerFeature("vehicles", "add")).toBe(true);
expect(SessionUser.canAccessCustomerFeature("bookings", "add")).toBe(true);
expect(SessionUser.canAccessCustomerFeature("orders", "list")).toBe(true);
expect(SessionUser.canAccessCustomerFeature("vehicles", "delete")).toBe(false);
});
it("maps subuser feature actions through the selected grant only", () => {
SessionUser.auth.forceClearSession();
SessionUser.isSubuser.value = true;
SessionUser.permissions.value = ["BOOKINGS_ADD", "VEHICLES_DELETE"];
SessionUser.subuser.grants.value = [
{
billing_customer_number: 12345678,
permissions: ["BOOKINGS_ADD", "VEHICLES_LIST"],
},
{
billing_customer_number: 87654321,
permissions: ["VEHICLES_DELETE"],
},
];
SessionUser.subuser.selectGrant(12345678);
expect(SessionUser.canAccessCustomerFeature("bookings", "add")).toBe(true);
expect(SessionUser.canAccessCustomerFeature("vehicles", "list")).toBe(true);
expect(SessionUser.canAccessCustomerFeature("vehicles", "delete")).toBe(false);
SessionUser.subuser.selectGrant(87654321);
expect(SessionUser.canAccessCustomerFeature("bookings", "add")).toBe(false);
expect(SessionUser.canAccessCustomerFeature("vehicles", "delete")).toBe(true);
});
it("redirects invalid stored subuser sessions to driver login", async () => {
seedStoredSession("invalid-subuser-token");
localStorage.setItem("is_subuser", "true");
SessionUser.isSubuser.value = true;
mocks.authenticatedRequest.mockRejectedValueOnce(invalidSessionError());
mocks.pingApiServer.mockResolvedValueOnce({
ok: true,
status: 200,
url: "/api/ping",
error: null,
});
const result = await getSubuserSessionData();
expect(localStorage.getItem("token")).toBeNull();
expect(localStorage.getItem("is_subuser")).toBeNull();
expect(mocks.loginRedirect).not.toHaveBeenCalled();
expect(mocks.subuserLoginRedirect).toHaveBeenCalledTimes(1);
expect(result).toMatchObject({
cleared: true,
apiReachable: true,
redirectedTo: "/login/driver",
});
});
it("recovers a stored subuser token that was missing the subuser localStorage marker", async () => {
seedStoredSession("subuser-token-without-marker");
mocks.authenticatedRequest.mockRejectedValueOnce(userNotFoundSessionError());
mocks.authenticatedRequest.mockResolvedValueOnce(subuserSessionPayload());
const result = await getSessionData();
expect(mocks.authenticatedRequest).toHaveBeenCalledTimes(2);
expect(mocks.authenticatedRequest.mock.calls[0][0]).toBe("/auth/session");
expect(mocks.authenticatedRequest.mock.calls[1][0]).toBe("/subusers/me");
expect(result).toBeUndefined();
expect(localStorage.getItem("token")).toBe("subuser-token-without-marker");
expect(localStorage.getItem("is_subuser")).toBe("true");
expect(localStorage.getItem("selected_customer_number")).toBe("12345678");
expect(SessionUser.isSubuser.value).toBe(true);
expect(SessionUser.initiated.value).toBe(true);
expect(SessionUser.subuser.id.value).toBe(77);
expect(SessionUser.permissions.value).toEqual(["BOOKINGS_LIST", "VEHICLES_LIST"]);
expect(mocks.loginRedirect).not.toHaveBeenCalled();
expect(mocks.subuserLoginRedirect).not.toHaveBeenCalled();
});
it("redirects to driver login when subuser-token recovery also proves invalid", async () => {
seedStoredSession("stale-subuser-token-without-marker");
mocks.authenticatedRequest.mockRejectedValueOnce(userNotFoundSessionError());
mocks.authenticatedRequest.mockRejectedValueOnce(invalidSessionError());
mocks.pingApiServer.mockResolvedValueOnce({
ok: true,
status: 200,
url: "/api/ping",
error: null,
});
const result = await getSessionData();
expect(mocks.authenticatedRequest).toHaveBeenCalledTimes(2);
expect(mocks.authenticatedRequest.mock.calls[1][0]).toBe("/subusers/me");
expect(localStorage.getItem("token")).toBeNull();
expect(localStorage.getItem("is_subuser")).toBeNull();
expect(mocks.loginRedirect).not.toHaveBeenCalled();
expect(mocks.subuserLoginRedirect).toHaveBeenCalledTimes(1);
expect(result).toMatchObject({
cleared: true,
apiReachable: true,
redirectedTo: "/login/driver",
});
});
it("preserves an invalid stored session and routes to connectivity when ping fails", async () => {
seedStoredSession("invalid-token");
mocks.authenticatedRequest.mockRejectedValueOnce(invalidSessionError());
mocks.pingApiServer.mockResolvedValueOnce({
ok: false,
status: null,
url: "/api/ping",
error: new Error("Network unavailable"),
});
const result = await getSessionData();
expect(mocks.pingApiServer).toHaveBeenCalledTimes(1);
expect(localStorage.getItem("token")).toBe("invalid-token");
expect(SessionUser.authenticated.value).toBe(true);
expect(mocks.loginRedirect).not.toHaveBeenCalled();
expect(result).toMatchObject({
cleared: false,
apiReachable: false,
redirectedTo: "/connectivity-issue",
});
});
it("does not clear the stored session for generic server bootstrap errors", async () => {
seedStoredSession("still-valid-token");
const serverError = {
response: {
status: 500,
data: {
message: "Internal server error",
},
},
};
mocks.authenticatedRequest.mockRejectedValueOnce(serverError);
const result = await getSessionData();
expect(mocks.pingApiServer).not.toHaveBeenCalled();
expect(localStorage.getItem("token")).toBe("still-valid-token");
expect(SessionUser.authenticated.value).toBe(true);
expect(mocks.loginRedirect).not.toHaveBeenCalled();
expect(mocks.parseError).toHaveBeenCalledWith(serverError, "auth");
expect(mocks.swalFire).toHaveBeenCalledTimes(1);
expect(result).toMatchObject({
cleared: false,
apiReachable: null,
redirectedTo: null,
});
});
});