Files
pleno-vue/tests/unit/origin-migration.spec.js
T
Jeppe B 5ffd471a45 Exchange one-time login grants in QR flow (#235)
## Summary

Updates the QR login view to consume the short-lived, one-time employee
login grants created by approved Pleno Control Plane
Conversations/Suggestions actions.

- reads generated grants from the URL fragment
- scrubs the bearer from the address bar before exchange
- exchanges the grant for a normal session token, then uses the existing
secure session-storage path
- preserves legacy token QR links
- validates exact URL origin and removes raw credential/QR logging
- prevents repeated scanner exchange attempts while one is in progress

## Visual change previews

No layout or styling changes. The visible flow changes only after
opening or scanning a grant:

- Before: one-time grant links were rejected as unknown QR content.
- After: the existing loader appears during exchange; invalid/expired
grants use the existing localized error dialog; successful grants
redirect through the existing login path.

## Verification

- focused Vitest: 2 passed
- focused ESLint: passed
- production Vite build: passed (existing chunk-size warning only)
- `git diff --check`: passed

## Dependency

Pair with copenhagentruckwash/api (one-time limited-backoffice login
grants) and merge after that backend PR. Required by
copenhagentruckwash/pleno-control-plane#1.
2026-07-29 00:01:22 +02:00

148 lines
4.4 KiB
JavaScript

import { afterEach, describe, expect, it, vi } from "vitest";
import {
OUTDATED_GATEWAY_ROUTE_NAME,
OUTDATED_INSTALLATION_ROUTE_NAME,
applyReleaseFrontendBase,
buildMigrationUrl,
isOriginAllowed,
resolveGrantMigrationUrl,
resolveOriginMigrationRoute,
sanitizeRedirectPath,
startRedirectCountdown,
} from "@/middleware/originMigration";
import { ALLOWED_ORIGINS } from "@/config";
describe("origin migration route guard", () => {
const allowedOrigins = ["https://truckwash.io", "https://www.truckwash.io"];
const route = { name: "landing", fullPath: "/user/bookings?tab=open#latest" };
it("allows navigation on allowed origins", () => {
const target = resolveOriginMigrationRoute({
to: route,
currentOrigin: "https://truckwash.io",
allowedOrigins,
standalone: false,
});
expect(target).toBeNull();
});
it("allows loopback origins on arbitrary ports", () => {
const target = resolveOriginMigrationRoute({
to: route,
currentOrigin: "http://localhost:5191",
allowedOrigins,
standalone: false,
});
expect(target).toBeNull();
});
it("allows the public release gateway origin", () => {
expect(isOriginAllowed("https://api-v2.truckwash.io", ALLOWED_ORIGINS)).toBe(true);
});
it("routes to outdated installation for disallowed standalone context", () => {
const target = resolveOriginMigrationRoute({
to: route,
currentOrigin: "https://legacy.truckwash.io",
allowedOrigins,
standalone: true,
});
expect(target).toEqual({ name: OUTDATED_INSTALLATION_ROUTE_NAME });
});
it("routes to outdated gateway for disallowed browser context", () => {
const target = resolveOriginMigrationRoute({
to: route,
currentOrigin: "https://legacy.truckwash.io",
allowedOrigins,
standalone: false,
});
expect(target).toEqual({
name: OUTDATED_GATEWAY_ROUTE_NAME,
query: { redirect: "/user/bookings?tab=open#latest" },
});
});
it("moves a login grant directly in the fragment without an intermediate redirect query", () => {
const target = resolveGrantMigrationUrl({
to: {
name: "loginqr",
path: "/login/qr",
fullPath: "/login/qr#grant=lbg_secret",
hash: "#grant=lbg_secret",
query: {},
},
currentOrigin: "https://legacy.truckwash.io",
allowedOrigins,
migrationOrigin: "https://truckwash.io",
currentPath: "/canary/frontend/login/qr",
});
expect(target).toBe("https://truckwash.io/canary/frontend/login/qr#grant=lbg_secret");
expect(target).not.toContain("redirect=");
expect(target).not.toContain("?grant=");
});
it("bypasses the gate on dedicated outdated routes", () => {
const target = resolveOriginMigrationRoute({
to: { name: OUTDATED_GATEWAY_ROUTE_NAME, fullPath: "/outdated-gateway" },
currentOrigin: "https://legacy.truckwash.io",
allowedOrigins,
standalone: false,
});
expect(target).toBeNull();
});
});
describe("origin migration redirect url", () => {
it("preserves path, query, and hash on migration origin", () => {
const redirectUrl = buildMigrationUrl("https://truckwash.io", "/user/bookings?tab=open#latest");
expect(redirectUrl).toBe("https://truckwash.io/user/bookings?tab=open#latest");
});
it("preserves release frontend base when redirecting from a release-prefixed route", () => {
expect(applyReleaseFrontendBase("/", "/master/frontend/outdated-gateway")).toBe("/master/frontend/");
expect(applyReleaseFrontendBase("/user/wash/start", "/master/frontend/outdated-gateway")).toBe(
"/master/frontend/user/wash/start"
);
expect(buildMigrationUrl("https://truckwash.io", "/", "/master/frontend/outdated-gateway")).toBe(
"https://truckwash.io/master/frontend/"
);
});
it("sanitizes invalid redirect paths to root", () => {
expect(sanitizeRedirectPath("https://malicious.example")).toBe("/");
});
});
describe("gateway redirect countdown", () => {
afterEach(() => {
vi.useRealTimers();
});
it("ticks every second and redirects after five seconds", () => {
vi.useFakeTimers();
const onRedirect = vi.fn();
const tickValues = [];
const stop = startRedirectCountdown({
seconds: 5,
onTick: (seconds) => tickValues.push(seconds),
onRedirect,
});
vi.advanceTimersByTime(5000);
expect(tickValues).toEqual([4, 3, 2, 1, 0]);
expect(onRedirect).toHaveBeenCalledTimes(1);
stop();
});
});