// The Lucia project. // Copyright 2026-2026 DSP, inc. All rights reserved. // Authors: // imacat.yang@dsp.im (imacat), 2026/03/05 import { describe, it, expect, beforeEach, vi } from "vitest"; import { setActivePinia, createPinia } from "pinia"; import { http, HttpResponse } from "msw"; import { server } from "@/mocks/node.js"; import { findRequest, captureRequest } from "@/mocks/request-log.js"; // Mock apiError to prevent side effects (imports router, pinia, toast) vi.mock("@/module/apiError.js", () => ({ default: vi.fn(), })); import { useLoginStore } from "@/stores/login"; describe("loginStore", () => { let store; beforeEach(() => { setActivePinia(createPinia()); store = useLoginStore(); store.$router = { push: vi.fn() }; // Clear cookies document.cookie.split(";").forEach((c) => { const name = c.split("=")[0].trim(); if (name) { document.cookie = name + "=; Max-Age=-99999999; path=/"; } }); }); it("has correct default state", () => { expect(store.auth.grant_type).toBe("password"); expect(store.auth.username).toBe(""); expect(store.isLoggedIn).toBe(false); expect(store.isInvalid).toBe(false); }); describe("signIn", () => { it("stores token and navigates on success", async () => { server.use( http.post("/api/oauth/token", async ({ request }) => { const body = await request.text(); captureRequest("POST", "/api/oauth/token", body); return HttpResponse.json({ access_token: "test-access-token", refresh_token: "test-refresh-token", }); }), ); await store.signIn(); const reqs = findRequest("POST", "/api/oauth/token"); expect(reqs).toHaveLength(1); expect(store.isLoggedIn).toBe(true); // Verify token cookie was set with Secure flag const cookieSetter = vi.spyOn(document, "cookie", "set"); await store.signIn(); const tokenCall = cookieSetter.mock.calls.find((c) => c[0].includes("luciaToken="), ); expect(tokenCall).toBeDefined(); expect(tokenCall[0]).toContain("Secure"); cookieSetter.mockRestore(); expect(store.$router.push).toHaveBeenCalledWith("/files"); }); it("redirects to remembered URL when set", async () => { server.use( http.post("/api/oauth/token", () => HttpResponse.json({ access_token: "token", refresh_token: "refresh", }), ), ); // btoa('/dashboard') = 'L2Rhc2hib2FyZA==' store.rememberedReturnToUrl = btoa("/dashboard"); await store.signIn(); // The store uses location.href for remembered URLs (not router.push). // jsdom doesn't fully implement navigation, but we verify // the store processed the remembered URL correctly by checking // it did NOT fall back to router.push("/files"). expect(store.isLoggedIn).toBe(true); expect(store.rememberedReturnToUrl).toBe(btoa("/dashboard")); }); it("does not redirect to external URL (open redirect prevention)", async () => { server.use( http.post("/api/oauth/token", () => HttpResponse.json({ access_token: "token", refresh_token: "refresh", }), ), ); // Attacker crafts a return-to URL pointing to an external site store.rememberedReturnToUrl = btoa("https://evil.example.com/steal"); await store.signIn(); // Should fall back to /files via router (not location.href) expect(store.$router.push).toHaveBeenCalledWith("/files"); }); it("falls back to /files when return-to is not valid base64", async () => { server.use( http.post("/api/oauth/token", () => HttpResponse.json({ access_token: "token", refresh_token: "refresh", }), ), ); store.rememberedReturnToUrl = "@@@not-base64@@@"; await store.signIn(); expect(store.isLoggedIn).toBe(true); expect(store.isInvalid).toBe(false); expect(store.$router.push).toHaveBeenCalledWith("/files"); }); it("sets isInvalid on error", async () => { const spy = vi.spyOn(console, "error").mockImplementation(() => {}); server.use( http.post("/api/oauth/token", () => new HttpResponse(null, { status: 401 }), ), ); await store.signIn(); expect(store.isInvalid).toBe(true); expect(spy).toHaveBeenCalledWith("Login failed:", expect.any(Error)); spy.mockRestore(); }); }); describe("logOut", () => { it("clears auth state and navigates to login", () => { store.isLoggedIn = true; store.logOut(); expect(store.isLoggedIn).toBe(false); expect(store.$router.push).toHaveBeenCalledWith("/login"); }); it("clears refresh token cookie on logout", () => { document.cookie = "luciaRefreshToken=refresh-token"; store.logOut(); expect(document.cookie).not.toContain("luciaRefreshToken="); }); }); describe("getUserData", () => { it("stores user data on success", async () => { document.cookie = "luciaToken=fake-test-token"; server.use( http.get("/api/my-account", ({ request }) => { captureRequest("GET", new URL(request.url).pathname); return HttpResponse.json({ username: "testuser", name: "Test User", }); }), ); await store.getUserData(); const reqs = findRequest("GET", "/api/my-account"); expect(reqs).toHaveLength(1); expect(store.userData).toEqual({ username: "testuser", name: "Test User", }); }); }); describe("checkLogin", () => { it("does not redirect on success", async () => { document.cookie = "luciaToken=fake-test-token"; server.use( http.get("/api/my-account", () => HttpResponse.json({})), ); await store.checkLogin(); expect(store.$router.push).not.toHaveBeenCalled(); }); it("redirects to login on error", async () => { const spy = vi.spyOn(console, "error").mockImplementation(() => {}); document.cookie = "luciaToken=fake-test-token"; // Both my-account and token refresh must fail for the error to propagate server.use( http.get("/api/my-account", () => new HttpResponse(null, { status: 401 }), ), http.post("/api/oauth/token", () => new HttpResponse(null, { status: 401 }), ), ); await store.checkLogin(); expect(store.$router.push).toHaveBeenCalledWith("/login"); expect(spy).toHaveBeenCalledWith("Session check failed:", expect.any(Error)); spy.mockRestore(); }); }); it("setRememberedReturnToUrl stores URL", () => { store.setRememberedReturnToUrl("abc"); expect(store.rememberedReturnToUrl).toBe("abc"); }); it("setIsLoggedIn sets boolean", () => { store.setIsLoggedIn(true); expect(store.isLoggedIn).toBe(true); }); describe("refreshToken", () => { it("sends request with correct config and updates tokens on success", async () => { document.cookie = "luciaRefreshToken=old-refresh-token"; server.use( http.post("/api/oauth/token", async ({ request }) => { const body = await request.text(); captureRequest("POST", "/api/oauth/token", body); return HttpResponse.json({ access_token: "new-access-token", refresh_token: "new-refresh-token", }); }), ); await store.refreshToken(); const reqs = findRequest("POST", "/api/oauth/token"); expect(reqs).toHaveLength(1); // Verify the request body contains refresh_token grant expect(reqs[0].body).toContain("grant_type=refresh_token"); expect(reqs[0].body).toContain("refresh_token=old-refresh-token"); // Verify cookies were set with Secure flag const cookieSetter = vi.spyOn(document, "cookie", "set"); document.cookie = "luciaRefreshToken=old-refresh-token"; await store.refreshToken(); const tokenCall = cookieSetter.mock.calls.find((c) => c[0].includes("luciaToken="), ); expect(tokenCall).toBeDefined(); expect(tokenCall[0]).toContain("Secure"); cookieSetter.mockRestore(); }); it("re-throws on failure without performing navigation", async () => { document.cookie = "luciaRefreshToken=old-refresh-token"; server.use( http.post("/api/oauth/token", () => new HttpResponse(null, { status: 401 }), ), ); await expect(store.refreshToken()).rejects.toThrow(); expect(store.$router.push).not.toHaveBeenCalled(); }); }); });