mirror of
https://github.com/unraid/api.git
synced 2026-01-03 15:09:48 -06:00
<!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **New Features** - App-wide internationalization: dynamic locale detection/loading, many new locale bundles, and CLI helpers to extract/sort translation keys. - **Accessibility** - Brand button supports keyboard activation (Enter/Space). - **Documentation** - Internationalization guidance added to API and Web READMEs. - **Refactor** - UI updated to use centralized i18n keys and a unified locale loading approach. - **Tests** - Test utilities updated to support i18n and localized assertions. - **Chores** - Crowdin config and i18n scripts added; runtime locale exposed for selection. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
486 lines
15 KiB
TypeScript
486 lines
15 KiB
TypeScript
/**
|
|
* 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:
|
|
'<button @click="handleClick" :disabled="disabled">{{ provider.buttonText || `Sign in with ${provider.name}` }}</button>',
|
|
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: '<button><slot /></button>' },
|
|
},
|
|
},
|
|
});
|
|
|
|
// 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: '<button><slot /></button>' },
|
|
},
|
|
},
|
|
});
|
|
|
|
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: '<button><slot /></button>' },
|
|
},
|
|
},
|
|
});
|
|
|
|
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: '<button><slot /></button>' },
|
|
},
|
|
},
|
|
});
|
|
|
|
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: '<button><slot /></button>' },
|
|
},
|
|
},
|
|
});
|
|
|
|
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: '<button><slot /></button>' },
|
|
},
|
|
},
|
|
});
|
|
|
|
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: '<button><slot /></button>' },
|
|
},
|
|
},
|
|
});
|
|
|
|
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: '<button><slot /></button>' },
|
|
},
|
|
},
|
|
});
|
|
|
|
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: '<button><slot /></button>' },
|
|
},
|
|
},
|
|
});
|
|
|
|
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');
|
|
});
|
|
});
|