// 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"; // Mock apiError to prevent side effects (imports router, pinia, toast) vi.mock("@/module/apiError.js", () => ({ default: vi.fn(), })); import axios from "axios"; const { mockClientGet } = vi.hoisted(() => ({ mockClientGet: vi.fn() })); vi.mock("@/api/client.js", () => ({ default: { get: mockClientGet }, })); import { useLoginStore } from "@/stores/login"; // Mock axios methods (used for signIn/refreshToken which call plain axios) vi.spyOn(axios, "post").mockImplementation(vi.fn()); describe("loginStore", () => { let store; beforeEach(() => { setActivePinia(createPinia()); store = useLoginStore(); store.$router = { push: vi.fn() }; vi.clearAllMocks(); // 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 () => { axios.post.mockResolvedValue({ data: { access_token: "test-access-token", refresh_token: "test-refresh-token", }, }); await store.signIn(); expect(axios.post).toHaveBeenCalledWith( "/api/oauth/token", store.auth, expect.objectContaining({ headers: { "Content-Type": "application/x-www-form-urlencoded" }, }), ); expect(store.isLoggedIn).toBe(true); // Verify token cookie was set with Secure flag // (jsdom drops Secure cookies, so spy on setter) const cookieSetter = vi.spyOn(document, "cookie", "set"); vi.clearAllMocks(); axios.post.mockResolvedValue({ data: { access_token: "test-access-token", refresh_token: "test-refresh-token", }, }); 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 () => { axios.post.mockResolvedValue({ data: { access_token: "token", refresh_token: "refresh", }, }); // btoa('/dashboard') = 'L2Rhc2hib2FyZA==' store.rememberedReturnToUrl = btoa("/dashboard"); // Mock globalThis.location.href setter const originalLocation = globalThis.location; delete globalThis.location; globalThis.location = { href: "" }; await store.signIn(); expect(globalThis.location.href).toBe("/dashboard"); globalThis.location = originalLocation; }); it("does not redirect to external URL (open redirect prevention)", async () => { axios.post.mockResolvedValue({ data: { 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"); const originalLocation = globalThis.location; delete globalThis.location; globalThis.location = { href: "" }; await store.signIn(); // Should NOT redirect to the external URL expect(globalThis.location.href).not.toBe("https://evil.example.com/steal"); // Should fall back to /files expect(store.$router.push).toHaveBeenCalledWith("/files"); globalThis.location = originalLocation; }); it("falls back to /files when return-to is not valid base64", async () => { axios.post.mockResolvedValue({ data: { 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 () => { axios.post.mockRejectedValue(new Error("401")); await store.signIn(); expect(store.isInvalid).toBe(true); }); }); 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 () => { mockClientGet.mockResolvedValue({ data: { username: "testuser", name: "Test User" }, }); await store.getUserData(); expect(mockClientGet).toHaveBeenCalledWith("/api/my-account"); expect(store.userData).toEqual({ username: "testuser", name: "Test User", }); }); }); describe("checkLogin", () => { it("does not redirect on success", async () => { mockClientGet.mockResolvedValue({ data: {} }); await store.checkLogin(); expect(store.$router.push).not.toHaveBeenCalled(); }); it("redirects to login on error", async () => { mockClientGet.mockRejectedValue(new Error("401")); await store.checkLogin(); expect(store.$router.push).toHaveBeenCalledWith("/login"); }); }); 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"; axios.post.mockResolvedValue({ status: 200, data: { access_token: "new-access-token", refresh_token: "new-refresh-token", }, }); await store.refreshToken(); // Should call with content-type header (config must be defined) expect(axios.post).toHaveBeenCalledWith( "/api/oauth/token", expect.objectContaining({ grant_type: "refresh_token", refresh_token: "old-refresh-token", }), expect.objectContaining({ headers: { "Content-Type": "application/x-www-form-urlencoded" }, }), ); // Verify cookies were set with Secure flag const cookieSetter = vi.spyOn(document, "cookie", "set"); vi.clearAllMocks(); document.cookie = "luciaRefreshToken=old-refresh-token"; axios.post.mockResolvedValue({ status: 200, data: { access_token: "new-access-token", refresh_token: "new-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"; axios.post.mockRejectedValue(new Error("401")); await expect(store.refreshToken()).rejects.toThrow("401"); expect(store.$router.push).not.toHaveBeenCalled(); }); }); });