// 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('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'); }); }); describe('expired', () => { it('is approximately 6 months in the future', () => { const now = new Date(); const sixMonthsLater = new Date(now); sixMonthsLater.setMonth(sixMonthsLater.getMonth() + 6); const expiredDate = new Date(store.expired); // Allow 1 day tolerance const diffMs = Math.abs(expiredDate.getTime() - sixMonthsLater.getTime()); expect(diffMs).toBeLessThan(24 * 60 * 60 * 1000); }); }); });