268 lines
7.8 KiB
JavaScript
268 lines
7.8 KiB
JavaScript
// 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 window.location.href setter
|
|
const originalLocation = window.location;
|
|
delete window.location;
|
|
window.location = { href: "" };
|
|
|
|
await store.signIn();
|
|
|
|
expect(window.location.href).toBe("/dashboard");
|
|
window.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 = window.location;
|
|
delete window.location;
|
|
window.location = { href: "" };
|
|
|
|
await store.signIn();
|
|
|
|
// Should NOT redirect to the external URL
|
|
expect(window.location.href).not.toBe("https://evil.example.com/steal");
|
|
// Should fall back to /files
|
|
expect(store.$router.push).toHaveBeenCalledWith("/files");
|
|
window.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");
|
|
});
|
|
});
|
|
|
|
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("redirects to login and re-throws on failure", async () => {
|
|
document.cookie = "luciaRefreshToken=old-refresh-token";
|
|
axios.post.mockRejectedValue(new Error("401"));
|
|
|
|
await expect(store.refreshToken()).rejects.toThrow("401");
|
|
|
|
expect(store.$router.push).toHaveBeenCalledWith("/login");
|
|
});
|
|
});
|
|
});
|