From 6e3aaca3b17216ad1cf1edebe8aecc67de05fc5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BE=9D=E7=91=AA=E8=B2=93?= Date: Sun, 8 Mar 2026 19:39:10 +0800 Subject: [PATCH] Extract reusable auth guard decision logic and test router auth behavior against it Co-Authored-By: Codex --- src/router/authGuard.ts | 74 ++++++++++++++++++++++++++++++++ src/router/index.ts | 50 ++++----------------- tests/router/routerGuard.test.js | 53 ++++++++++------------- 3 files changed, 106 insertions(+), 71 deletions(-) create mode 100644 src/router/authGuard.ts diff --git a/src/router/authGuard.ts b/src/router/authGuard.ts new file mode 100644 index 0000000..2a1f439 --- /dev/null +++ b/src/router/authGuard.ts @@ -0,0 +1,74 @@ +// The Lucia project. +// Copyright 2026-2026 DSP, inc. All rights reserved. +// Authors: +// imacat.yang@dsp.im (imacat), 2026/03/08 +/** + * @module router/authGuard Shared auth-guard decision logic for router.beforeEach + * and unit tests. + */ + +/** Minimal route shape used by auth guard decision logic. */ +export interface GuardRouteTarget { + name?: unknown; + path?: string; + fullPath?: string; + matched?: Array<{ meta?: { requiresAuth?: boolean } }>; +} + +/** Dependency contract for evaluating authentication navigation decisions. */ +export interface AuthGuardDependencies { + /** Reads a cookie value by name. */ + getCookie: (name: string) => string | null; + /** Attempts to refresh session credentials. */ + refreshSession: () => Promise; + /** Persists the logged-in marker cookie after successful refresh. */ + setLoginMarker: () => void; + /** Encodes return-to path into query-safe payload. */ + encodeReturnTo: (returnToPath: string) => string; +} + +/** + * Evaluates auth-related navigation decisions. + * @param {GuardRouteTarget} to - Target route object from router guard. + * @param {AuthGuardDependencies} deps - Side-effect dependencies for auth checks. + * @returns {Promise<{name: string}|{path: string, query: {"return-to": string}}|undefined>} + * Redirect target when needed, otherwise undefined. + */ +export async function evaluateAuthNavigation( + to: GuardRouteTarget, + deps: AuthGuardDependencies, +) { + const hasLoginMarker = Boolean(deps.getCookie("isLuciaLoggedIn")); + const hasAccessToken = Boolean(deps.getCookie("luciaToken")); + const hasRefreshToken = Boolean(deps.getCookie("luciaRefreshToken")); + const isAuthenticated = hasLoginMarker && hasAccessToken; + + if (to.name === "Login" && isAuthenticated) { + return { name: "Files" }; + } + + const requiresAuth = (to.matched || []).some( + (routeRecord) => routeRecord.meta?.requiresAuth, + ); + if (!requiresAuth || isAuthenticated) { + return undefined; + } + + if (hasRefreshToken) { + try { + await deps.refreshSession(); + deps.setLoginMarker(); + return undefined; + } catch { + // Fall through to login redirect below. + } + } + + const returnToPath = to.fullPath || to.path || "/"; + return { + path: "/login", + query: { + "return-to": deps.encodeReturnTo(returnToPath), + }, + }; +} diff --git a/src/router/index.ts b/src/router/index.ts index 9a57d05..b941ccb 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -13,6 +13,7 @@ import { createRouter, createWebHistory } from "vue-router"; import { useLoginStore } from "@/stores/login"; import { getCookie, setCookie } from "@/utils/cookieUtil"; import { encodeReturnTo } from "@/utils/returnToEncoding"; +import { evaluateAuthNavigation } from "@/router/authGuard"; import AuthContainer from "@/views/AuthContainer.vue"; import MainContainer from "@/views/MainContainer.vue"; import Login from "@/views/Login/LoginPage.vue"; @@ -174,51 +175,18 @@ const router = createRouter({ routes, }); -/** - * Builds a login redirect target with a return-to query. - * @param {string} fullPath - The destination path to return after login. - * @returns {{path: string, query: {"return-to": string}}} The redirect target. - */ -function buildLoginRedirect(fullPath: string) { - return { - path: "/login", - query: { - "return-to": encodeReturnTo(fullPath), - }, - }; -} - // Global navigation guard router.beforeEach(async (to) => { - // to: Route: the target route object being navigated to - const hasLoginMarker = Boolean(getCookie("isLuciaLoggedIn")); - const hasAccessToken = Boolean(getCookie("luciaToken")); - const hasRefreshToken = Boolean(getCookie("luciaRefreshToken")); - const isAuthenticated = hasLoginMarker && hasAccessToken; - - // When navigating to the login page, redirect to Files if already logged in - if (to.name === "Login" && isAuthenticated) { - return { name: "Files" }; - } - - const requiresAuth = to.matched.some((routeRecord) => routeRecord.meta.requiresAuth); - if (!requiresAuth || isAuthenticated) { - return undefined; - } - - if (hasRefreshToken) { - const loginStore = useLoginStore(); - try { + return evaluateAuthNavigation(to, { + getCookie, + refreshSession: async () => { + const loginStore = useLoginStore(); await loginStore.refreshToken(); loginStore.setIsLoggedIn(true); - setCookie("isLuciaLoggedIn", "true"); - return undefined; - } catch { - return buildLoginRedirect(to.fullPath); - } - } - - return buildLoginRedirect(to.fullPath); + }, + setLoginMarker: () => setCookie("isLuciaLoggedIn", "true"), + encodeReturnTo, + }); }); export default router; diff --git a/tests/router/routerGuard.test.js b/tests/router/routerGuard.test.js index 600c654..77e1a94 100644 --- a/tests/router/routerGuard.test.js +++ b/tests/router/routerGuard.test.js @@ -5,6 +5,7 @@ import { describe, it, expect, beforeEach } from "vitest"; import { decodeReturnTo } from "@/utils/returnToEncoding"; +import { evaluateAuthNavigation } from "@/router/authGuard"; describe("router beforeEach guard logic", () => { beforeEach(() => { @@ -17,38 +18,30 @@ describe("router beforeEach guard logic", () => { }); }); - // Simulate the guard logic from router/index.ts + // Run the real auth guard decision logic from src/router/authGuard.ts async function runGuard(to, options = {}) { const { refreshSucceeds = true } = options; - const hasLoginMarker = document.cookie - .split(";") - .some((c) => c.trim().startsWith("isLuciaLoggedIn=")); - const hasAccessToken = document.cookie - .split(";") - .some((c) => c.trim().startsWith("luciaToken=")); - const hasRefreshToken = document.cookie - .split(";") - .some((c) => c.trim().startsWith("luciaRefreshToken=")); - const isAuthenticated = hasLoginMarker && hasAccessToken; - - if (to.name === "Login") { - if (isAuthenticated) return { name: "Files" }; - } - - const requiresAuth = (to.matched || []).some((r) => r.meta?.requiresAuth); - if (requiresAuth && !isAuthenticated) { - if (hasRefreshToken) { - if (refreshSucceeds) return undefined; - } - return { - path: "/login", - query: { - "return-to": btoa(to.fullPath || to.path || "/"), - }, - }; - } - - return undefined; + return evaluateAuthNavigation(to, { + getCookie: (name) => { + const cookieArr = document.cookie.split(";"); + for (const cookie of cookieArr) { + const c = cookie.trim(); + if (c.startsWith(`${name}=`)) { + return c.slice(name.length + 1); + } + } + return null; + }, + refreshSession: async () => { + if (!refreshSucceeds) { + throw new Error("refresh failed"); + } + }, + setLoginMarker: () => { + document.cookie = "isLuciaLoggedIn=true"; + }, + encodeReturnTo: (path) => btoa(path), + }); } it("redirects logged-in user from Login to Files", () => {