/** * SsoButton Component Test Coverage */ import { useQuery } from '@vue/apollo-composable'; import { flushPromises, mount } from '@vue/test-utils'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { Mock, MockInstance } from 'vitest'; import SsoButtons from '~/components/sso/SsoButtons.vue'; import { createTestI18n } from '../utils/i18n'; // Mock the child components const SsoProviderButtonStub = { template: '', props: ['provider', 'disabled', 'onClick'], methods: { handleClick(this: { onClick: (id: string) => void; provider: { id: string } }) { this.onClick(this.provider.id); }, }, }; // Mock the GraphQL composable vi.mock('@vue/apollo-composable', () => ({ useQuery: vi.fn(), })); // Mock the GraphQL query vi.mock('~/components/queries/public-oidc-providers.query.js', () => ({ PUBLIC_OIDC_PROVIDERS: 'PUBLIC_OIDC_PROVIDERS_QUERY', })); // Mock window APIs vi.stubGlobal('fetch', vi.fn()); vi.stubGlobal('sessionStorage', { getItem: vi.fn(), setItem: vi.fn(), removeItem: vi.fn(), clear: vi.fn(), }); const mockCrypto = { getRandomValues: vi.fn((array: Uint8Array) => { for (let i = 0; i < array.length; i++) { array[i] = Math.floor(Math.random() * 256); } return array; }), }; vi.stubGlobal('crypto', mockCrypto); let mockLocationHref = 'http://mock-origin.com/login'; const mockLocation = { search: '', hash: '', origin: 'http://mock-origin.com', pathname: '/login', protocol: 'http:', host: 'mock-origin.com', get href() { return mockLocationHref; }, set href(value: string) { mockLocationHref = value; }, }; vi.stubGlobal('location', mockLocation); vi.stubGlobal('URLSearchParams', URLSearchParams); vi.stubGlobal('URL', URL); const mockHistory = { replaceState: vi.fn(), }; vi.stubGlobal('history', mockHistory); // Mock DOM interactions const mockForm = { requestSubmit: vi.fn(), style: { display: 'block' }, }; const mockPasswordField = { value: '' }; const mockUsernameField = { value: '' }; describe('SsoButtons', () => { let querySelectorSpy: MockInstance; let mockUseQuery: Mock; beforeEach(async () => { vi.restoreAllMocks(); vi.clearAllTimers(); vi.useFakeTimers(); mockUseQuery = useQuery as Mock; (sessionStorage.getItem as Mock).mockReturnValue(null); (sessionStorage.setItem as Mock).mockClear(); (sessionStorage.removeItem as Mock).mockClear(); mockForm.requestSubmit.mockClear(); mockPasswordField.value = ''; mockUsernameField.value = ''; mockForm.style.display = 'block'; mockLocation.search = ''; mockLocation.hash = ''; mockLocationHref = 'http://mock-origin.com/login'; mockLocation.pathname = '/login'; (fetch as Mock).mockClear(); mockUseQuery.mockClear(); // Spy on document.querySelector and provide mock implementation querySelectorSpy = vi.spyOn(document, 'querySelector'); querySelectorSpy.mockImplementation((selector: string) => { if (selector === 'form[action="/login"]') return mockForm as unknown as HTMLFormElement; if (selector === 'input[name=password]') return mockPasswordField as unknown as HTMLInputElement; if (selector === 'input[name=username]') return mockUsernameField as unknown as HTMLInputElement; return null; }); Object.defineProperty(document, 'title', { value: 'Mock Title', writable: true, }); }); afterEach(() => { vi.restoreAllMocks(); vi.useRealTimers(); }); it('renders provider buttons when OIDC providers are available', async () => { const mockProviders = [ { id: 'unraid-net', name: 'Unraid.net', buttonText: 'Log In With Unraid.net', buttonIcon: null, buttonVariant: 'secondary', buttonStyle: null, }, ]; mockUseQuery.mockReturnValue({ result: { value: { publicOidcProviders: mockProviders } }, refetch: vi.fn().mockResolvedValue({ data: { publicOidcProviders: mockProviders } }), }); const wrapper = mount(SsoButtons, { global: { plugins: [createTestI18n()], stubs: { SsoProviderButton: SsoProviderButtonStub, Button: { template: '' }, }, }, }); // Wait for the API check to complete await flushPromises(); vi.runAllTimers(); await flushPromises(); expect(wrapper.text()).toContain('or'); expect(wrapper.text()).toContain('Log In With Unraid.net'); }); it('does not render buttons when no OIDC providers are configured', async () => { mockUseQuery.mockReturnValue({ result: { value: { publicOidcProviders: [] } }, refetch: vi.fn().mockResolvedValue({ data: { publicOidcProviders: [] } }), }); const wrapper = mount(SsoButtons, { global: { plugins: [createTestI18n()], stubs: { SsoProviderButton: SsoProviderButtonStub, Button: { template: '' }, }, }, }); await flushPromises(); vi.runAllTimers(); await flushPromises(); expect(wrapper.text()).not.toContain('or'); expect(wrapper.findAll('button')).toHaveLength(0); }); it('shows checking message while API is being polled', async () => { const refetchMock = vi .fn() .mockRejectedValueOnce(new Error('API not available')) .mockResolvedValueOnce({ data: { publicOidcProviders: [] } }); mockUseQuery.mockReturnValue({ result: { value: null }, refetch: refetchMock, }); const wrapper = mount(SsoButtons, { global: { plugins: [createTestI18n()], stubs: { SsoProviderButton: SsoProviderButtonStub, Button: { template: '' }, }, }, }); expect(wrapper.text()).toContain('Checking authentication options...'); // Advance timers to trigger the polling await flushPromises(); vi.advanceTimersByTime(2000); await flushPromises(); // After successful API response, checking message should disappear expect(wrapper.text()).not.toContain('Checking authentication options...'); }); it('navigates to the OIDC provider URL on button click', async () => { const mockProviders = [ { id: 'unraid-net', name: 'Unraid.net', buttonText: 'Log In With Unraid.net', buttonIcon: null, buttonVariant: 'secondary', buttonStyle: null, }, ]; mockUseQuery.mockReturnValue({ result: { value: { publicOidcProviders: mockProviders } }, refetch: vi.fn().mockResolvedValue({ data: { publicOidcProviders: mockProviders } }), }); const wrapper = mount(SsoButtons, { global: { plugins: [createTestI18n()], stubs: { SsoProviderButton: SsoProviderButtonStub, Button: { template: '' }, }, }, }); await flushPromises(); vi.runAllTimers(); await flushPromises(); const button = wrapper.find('button'); await button.trigger('click'); // Should set state and provider in sessionStorage expect(sessionStorage.setItem).toHaveBeenCalledWith('sso_state', expect.any(String)); expect(sessionStorage.setItem).toHaveBeenCalledWith('sso_provider', 'unraid-net'); const generatedState = (sessionStorage.setItem as Mock).mock.calls[0][1]; const redirectUri = `${mockLocation.origin}/graphql/api/auth/oidc/callback`; const expectedUrl = `/graphql/api/auth/oidc/authorize/unraid-net?state=${encodeURIComponent(generatedState)}&redirect_uri=${encodeURIComponent(redirectUri)}`; expect(mockLocation.href).toBe(expectedUrl); }); it('handles OIDC callback with token successfully', async () => { const mockProviders = [ { id: 'unraid-net', name: 'Unraid.net', buttonText: 'Log In With Unraid.net', }, ]; mockUseQuery.mockReturnValue({ result: { value: { publicOidcProviders: mockProviders } }, refetch: vi.fn().mockResolvedValue({ data: { publicOidcProviders: mockProviders } }), }); const mockToken = 'mock_access_token_123'; mockLocation.search = ''; // No query params - using hash instead mockLocation.pathname = '/login'; mockLocationHref = `http://mock-origin.com/login#token=${mockToken}`; mockLocation.hash = `#token=${mockToken}`; // Mount the component so that onMounted hook is called mount(SsoButtons, { global: { plugins: [createTestI18n()], stubs: { SsoProviderButton: SsoProviderButtonStub, Button: { template: '' }, }, }, }); await flushPromises(); expect(mockForm.style.display).toBe('none'); expect(mockUsernameField.value).toBe('root'); expect(mockPasswordField.value).toBe(mockToken); expect(mockForm.requestSubmit).toHaveBeenCalledTimes(1); // Should clear the URL hash after processing expect(mockHistory.replaceState).toHaveBeenCalledWith({}, 'Mock Title', '/login'); }); it('handles OIDC callback error from backend', async () => { const mockProviders = [ { id: 'unraid-net', name: 'Unraid.net', buttonText: 'Log In With Unraid.net', }, ]; mockUseQuery.mockReturnValue({ result: { value: { publicOidcProviders: mockProviders } }, refetch: vi.fn().mockResolvedValue({ data: { publicOidcProviders: mockProviders } }), }); const errorMessage = 'Authentication failed'; mockLocation.search = ''; // No query params - using hash instead mockLocation.pathname = '/login'; mockLocationHref = `http://mock-origin.com/login#error=${encodeURIComponent(errorMessage)}`; mockLocation.hash = `#error=${encodeURIComponent(errorMessage)}`; const wrapper = mount(SsoButtons, { global: { plugins: [createTestI18n()], stubs: { SsoProviderButton: SsoProviderButtonStub, Button: { template: '' }, }, }, }); await flushPromises(); const errorElement = wrapper.find('p.text-red-500'); expect(errorElement.exists()).toBe(true); expect(errorElement.text()).toBe(errorMessage); expect(mockForm.style.display).toBe('block'); expect(mockForm.requestSubmit).not.toHaveBeenCalled(); // The URL cleanup happens with both hash and query params being removed const expectedUrl = mockLocation.pathname; expect(mockHistory.replaceState).toHaveBeenCalledWith({}, 'Mock Title', expectedUrl); }); it('redirects to OIDC callback endpoint when code and state are present', async () => { const mockProviders = [ { id: 'unraid-net', name: 'Unraid.net', buttonText: 'Log In With Unraid.net', }, ]; mockUseQuery.mockReturnValue({ result: { value: { publicOidcProviders: mockProviders } }, refetch: vi.fn().mockResolvedValue({ data: { publicOidcProviders: mockProviders } }), }); const mockCode = 'mock_auth_code'; const mockState = 'mock_session_state_value'; mockLocation.search = `?code=${mockCode}&state=${mockState}`; mockLocation.pathname = '/login'; mount(SsoButtons, { global: { plugins: [createTestI18n()], stubs: { SsoProviderButton: SsoProviderButtonStub, Button: { template: '' }, }, }, }); await flushPromises(); // Should redirect to the OIDC callback endpoint const expectedUrl = `/graphql/api/auth/oidc/callback?code=${encodeURIComponent(mockCode)}&state=${encodeURIComponent(mockState)}`; expect(mockLocation.href).toBe(expectedUrl); }); it('handles HTTPS with non-standard port correctly', async () => { const mockProviders = [ { id: 'tsidp', name: 'Tailscale IDP', buttonText: 'Sign in with Tailscale', buttonIcon: null, buttonVariant: 'secondary', buttonStyle: null, }, ]; // Set up location with HTTPS and non-standard port mockLocation.protocol = 'https:'; mockLocation.host = 'unraid.mytailnet.ts.net:1443'; mockLocation.origin = 'https://unraid.mytailnet.ts.net:1443'; mockUseQuery.mockReturnValue({ result: { value: { publicOidcProviders: mockProviders } }, refetch: vi.fn().mockResolvedValue({ data: { publicOidcProviders: mockProviders } }), }); const wrapper = mount(SsoButtons, { global: { plugins: [createTestI18n()], stubs: { SsoProviderButton: SsoProviderButtonStub, Button: { template: '' }, }, }, }); await flushPromises(); vi.runAllTimers(); await flushPromises(); const button = wrapper.find('button'); await button.trigger('click'); // Should include the correct redirect URI with HTTPS and port 1443 const generatedState = (sessionStorage.setItem as Mock).mock.calls[0][1]; const redirectUri = 'https://unraid.mytailnet.ts.net:1443/graphql/api/auth/oidc/callback'; const expectedUrl = `/graphql/api/auth/oidc/authorize/tsidp?state=${encodeURIComponent(generatedState)}&redirect_uri=${encodeURIComponent(redirectUri)}`; expect(mockLocation.href).toBe(expectedUrl); // Reset location mock for other tests mockLocation.protocol = 'http:'; mockLocation.host = 'mock-origin.com'; mockLocation.origin = 'http://mock-origin.com'; }); it('handles multiple OIDC providers', async () => { const mockProviders = [ { id: 'unraid-net', name: 'Unraid.net', buttonText: 'Log In With Unraid.net', buttonIcon: null, buttonVariant: 'secondary', buttonStyle: null, }, { id: 'google', name: 'Google', buttonText: 'Sign in with Google', buttonIcon: 'https://google.com/icon.png', buttonVariant: 'outline', buttonStyle: 'background: white;', }, ]; mockUseQuery.mockReturnValue({ result: { value: { publicOidcProviders: mockProviders } }, refetch: vi.fn().mockResolvedValue({ data: { publicOidcProviders: mockProviders } }), }); const wrapper = mount(SsoButtons, { global: { plugins: [createTestI18n()], stubs: { SsoProviderButton: SsoProviderButtonStub, Button: { template: '' }, }, }, }); await flushPromises(); vi.runAllTimers(); await flushPromises(); const buttons = wrapper.findAll('button'); expect(buttons).toHaveLength(2); expect(wrapper.text()).toContain('Log In With Unraid.net'); expect(wrapper.text()).toContain('Sign in with Google'); }); });