Some checks failed
Tests / Backend Tests (Python) (3.10) (push) Has been cancelled
Tests / Backend Tests (Python) (3.11) (push) Has been cancelled
Tests / Backend Tests (Python) (3.12) (push) Has been cancelled
Tests / Frontend Tests (JS) (push) Has been cancelled
Tests / Integration Tests (push) Has been cancelled
Tests / All Tests Passed (push) Has been cancelled
641 lines
19 KiB
JavaScript
641 lines
19 KiB
JavaScript
/**
|
|
* Tests for dashboard_core.js - DashboardCore class.
|
|
*
|
|
* This tests the real exported module for coverage.
|
|
*
|
|
* Covers:
|
|
* - Authentication flow (login, logout, checkAuthStatus)
|
|
* - API calls with auth headers
|
|
* - WebSocket connection and message handling
|
|
* - Data management (hosts, tasks, schedules, alerts)
|
|
*/
|
|
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
import { DashboardCore } from '../../app/dashboard_core.js';
|
|
import {
|
|
createMockResponse,
|
|
MockWebSocket,
|
|
localStorageMock
|
|
} from './setup.js';
|
|
|
|
// ============================================================================
|
|
// TESTS: DashboardCore - Authentication
|
|
// ============================================================================
|
|
|
|
describe('DashboardCore - Authentication', () => {
|
|
let core;
|
|
let mockFetch;
|
|
let mockStorage;
|
|
|
|
beforeEach(() => {
|
|
mockStorage = {
|
|
getItem: vi.fn(() => null),
|
|
setItem: vi.fn(),
|
|
removeItem: vi.fn(),
|
|
clear: vi.fn()
|
|
};
|
|
|
|
mockFetch = vi.fn();
|
|
|
|
core = new DashboardCore({
|
|
apiBase: 'http://localhost',
|
|
storage: mockStorage,
|
|
fetch: mockFetch,
|
|
WebSocket: MockWebSocket
|
|
});
|
|
});
|
|
|
|
describe('getAuthHeaders', () => {
|
|
it('returns headers without auth when no token', () => {
|
|
core.accessToken = null;
|
|
const headers = core.getAuthHeaders();
|
|
|
|
expect(headers['Content-Type']).toBe('application/json');
|
|
expect(headers['Authorization']).toBeUndefined();
|
|
});
|
|
|
|
it('returns headers with Bearer token when authenticated', () => {
|
|
core.accessToken = 'test-token-123';
|
|
const headers = core.getAuthHeaders();
|
|
|
|
expect(headers['Authorization']).toBe('Bearer test-token-123');
|
|
});
|
|
});
|
|
|
|
describe('checkAuthStatus', () => {
|
|
it('returns true when authenticated', async () => {
|
|
mockFetch.mockResolvedValue(createMockResponse({
|
|
setup_required: false,
|
|
authenticated: true,
|
|
user: { username: 'testuser', role: 'admin' }
|
|
}));
|
|
|
|
const result = await core.checkAuthStatus();
|
|
|
|
expect(result).toBe(true);
|
|
expect(core.currentUser).toEqual({ username: 'testuser', role: 'admin' });
|
|
});
|
|
|
|
it('returns false when setup required', async () => {
|
|
mockFetch.mockResolvedValue(createMockResponse({
|
|
setup_required: true,
|
|
authenticated: false,
|
|
user: null
|
|
}));
|
|
|
|
const result = await core.checkAuthStatus();
|
|
|
|
expect(result).toBe(false);
|
|
expect(core.setupRequired).toBe(true);
|
|
});
|
|
|
|
it('returns false when not authenticated', async () => {
|
|
mockFetch.mockResolvedValue(createMockResponse({
|
|
setup_required: false,
|
|
authenticated: false,
|
|
user: null
|
|
}));
|
|
|
|
const result = await core.checkAuthStatus();
|
|
|
|
expect(result).toBe(false);
|
|
expect(core.currentUser).toBeNull();
|
|
});
|
|
|
|
it('returns false on network error', async () => {
|
|
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
mockFetch.mockRejectedValue(new Error('Network error'));
|
|
|
|
const result = await core.checkAuthStatus();
|
|
|
|
expect(result).toBe(false);
|
|
consoleSpy.mockRestore();
|
|
});
|
|
|
|
it('calls onAuthStateChanged callback when authenticated', async () => {
|
|
const callback = vi.fn();
|
|
core.onAuthStateChanged = callback;
|
|
|
|
mockFetch.mockResolvedValue(createMockResponse({
|
|
setup_required: false,
|
|
authenticated: true,
|
|
user: { username: 'testuser' }
|
|
}));
|
|
|
|
await core.checkAuthStatus();
|
|
|
|
expect(callback).toHaveBeenCalledWith({
|
|
authenticated: true,
|
|
user: { username: 'testuser' }
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('login', () => {
|
|
it('stores token on successful login', async () => {
|
|
// First call for login, second for checkAuthStatus
|
|
mockFetch
|
|
.mockResolvedValueOnce(createMockResponse({
|
|
access_token: 'new-jwt-token',
|
|
token_type: 'bearer',
|
|
expires_in: 86400
|
|
}))
|
|
.mockResolvedValueOnce(createMockResponse({
|
|
setup_required: false,
|
|
authenticated: true,
|
|
user: { username: 'testuser' }
|
|
}));
|
|
|
|
const result = await core.login('testuser', 'password123');
|
|
|
|
expect(result).toBe(true);
|
|
expect(core.accessToken).toBe('new-jwt-token');
|
|
expect(mockStorage.setItem).toHaveBeenCalledWith('accessToken', 'new-jwt-token');
|
|
});
|
|
|
|
it('returns false on invalid credentials', async () => {
|
|
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
mockFetch.mockResolvedValue(createMockResponse({ detail: 'Invalid credentials' }, 401));
|
|
|
|
const result = await core.login('wrong', 'credentials');
|
|
|
|
expect(result).toBe(false);
|
|
consoleSpy.mockRestore();
|
|
});
|
|
|
|
it('calls onNotification callback on success', async () => {
|
|
const callback = vi.fn();
|
|
core.onNotification = callback;
|
|
|
|
mockFetch
|
|
.mockResolvedValueOnce(createMockResponse({ access_token: 'token' }))
|
|
.mockResolvedValueOnce(createMockResponse({ authenticated: true, user: {} }));
|
|
|
|
await core.login('user', 'pass');
|
|
|
|
expect(callback).toHaveBeenCalledWith('Connexion réussie', 'success');
|
|
});
|
|
|
|
it('calls onNotification callback on error', async () => {
|
|
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
const callback = vi.fn();
|
|
core.onNotification = callback;
|
|
|
|
mockFetch.mockResolvedValue(createMockResponse({ detail: 'Bad password' }, 401));
|
|
|
|
await core.login('user', 'wrong');
|
|
|
|
expect(callback).toHaveBeenCalledWith('Bad password', 'error');
|
|
consoleSpy.mockRestore();
|
|
});
|
|
});
|
|
|
|
describe('logout', () => {
|
|
it('clears token and user data', () => {
|
|
core.accessToken = 'some-token';
|
|
core.currentUser = { username: 'test' };
|
|
|
|
core.logout();
|
|
|
|
expect(core.accessToken).toBeNull();
|
|
expect(core.currentUser).toBeNull();
|
|
expect(mockStorage.removeItem).toHaveBeenCalledWith('accessToken');
|
|
});
|
|
|
|
it('closes WebSocket connection', () => {
|
|
core.ws = new MockWebSocket('ws://localhost/ws');
|
|
const closeSpy = vi.spyOn(core.ws, 'close');
|
|
|
|
core.logout();
|
|
|
|
expect(closeSpy).toHaveBeenCalled();
|
|
expect(core.ws).toBeNull();
|
|
});
|
|
|
|
it('calls onAuthStateChanged callback', () => {
|
|
const callback = vi.fn();
|
|
core.onAuthStateChanged = callback;
|
|
|
|
core.logout();
|
|
|
|
expect(callback).toHaveBeenCalledWith({ authenticated: false, user: null });
|
|
});
|
|
});
|
|
});
|
|
|
|
// ============================================================================
|
|
// TESTS: DashboardCore - API Calls
|
|
// ============================================================================
|
|
|
|
describe('DashboardCore - API Calls', () => {
|
|
let core;
|
|
let mockFetch;
|
|
|
|
beforeEach(() => {
|
|
mockFetch = vi.fn();
|
|
core = new DashboardCore({
|
|
apiBase: 'http://localhost',
|
|
fetch: mockFetch
|
|
});
|
|
core.accessToken = 'test-token';
|
|
});
|
|
|
|
describe('apiCall', () => {
|
|
it('makes authenticated request', async () => {
|
|
mockFetch.mockResolvedValue(createMockResponse({ data: 'test' }));
|
|
|
|
const result = await core.apiCall('/api/test');
|
|
|
|
expect(result).toEqual({ data: 'test' });
|
|
expect(mockFetch).toHaveBeenCalledWith(
|
|
'http://localhost/api/test',
|
|
expect.objectContaining({
|
|
headers: expect.objectContaining({
|
|
'Authorization': 'Bearer test-token'
|
|
})
|
|
})
|
|
);
|
|
});
|
|
|
|
it('throws on HTTP error', async () => {
|
|
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
mockFetch.mockResolvedValue(createMockResponse({ error: 'Not found' }, 404));
|
|
|
|
await expect(core.apiCall('/api/notfound')).rejects.toThrow('HTTP 404');
|
|
consoleSpy.mockRestore();
|
|
});
|
|
|
|
it('passes custom options', async () => {
|
|
mockFetch.mockResolvedValue(createMockResponse({ success: true }));
|
|
|
|
await core.apiCall('/api/test', { method: 'POST', body: '{}' });
|
|
|
|
expect(mockFetch).toHaveBeenCalledWith(
|
|
'http://localhost/api/test',
|
|
expect.objectContaining({
|
|
method: 'POST',
|
|
body: '{}'
|
|
})
|
|
);
|
|
});
|
|
});
|
|
});
|
|
|
|
// ============================================================================
|
|
// TESTS: DashboardCore - WebSocket
|
|
// ============================================================================
|
|
|
|
describe('DashboardCore - WebSocket', () => {
|
|
let core;
|
|
|
|
beforeEach(() => {
|
|
core = new DashboardCore({
|
|
apiBase: 'http://localhost',
|
|
WebSocket: MockWebSocket
|
|
});
|
|
});
|
|
|
|
describe('connectWebSocket', () => {
|
|
it('creates WebSocket connection', () => {
|
|
core.connectWebSocket('ws://localhost/ws');
|
|
|
|
expect(core.ws).toBeInstanceOf(MockWebSocket);
|
|
expect(core.ws.url).toBe('ws://localhost/ws');
|
|
});
|
|
|
|
it('sets up event handlers', () => {
|
|
core.connectWebSocket('ws://localhost/ws');
|
|
|
|
expect(core.ws.onopen).toBeDefined();
|
|
expect(core.ws.onmessage).toBeDefined();
|
|
expect(core.ws.onclose).toBeDefined();
|
|
expect(core.ws.onerror).toBeDefined();
|
|
});
|
|
|
|
it('warns if WebSocket class not available', () => {
|
|
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
const coreNoWs = new DashboardCore({
|
|
apiBase: 'http://localhost',
|
|
WebSocket: null
|
|
});
|
|
// Override the class to null after construction
|
|
coreNoWs.WebSocketClass = null;
|
|
|
|
coreNoWs.connectWebSocket('ws://localhost/ws');
|
|
|
|
expect(consoleSpy).toHaveBeenCalledWith('WebSocket not available');
|
|
consoleSpy.mockRestore();
|
|
});
|
|
|
|
it('uses default URL when none provided', () => {
|
|
core.connectWebSocket();
|
|
|
|
expect(core.ws).toBeInstanceOf(MockWebSocket);
|
|
});
|
|
|
|
it('triggers onopen handler', async () => {
|
|
core.connectWebSocket('ws://localhost/ws');
|
|
|
|
// MockWebSocket auto-connects after a tick
|
|
await new Promise(r => setTimeout(r, 10));
|
|
|
|
// onopen should have been called (check via readyState)
|
|
expect(core.ws.readyState).toBe(MockWebSocket.OPEN);
|
|
});
|
|
|
|
it('handles onmessage with valid JSON', () => {
|
|
core.hosts = [];
|
|
core.connectWebSocket('ws://localhost/ws');
|
|
|
|
// Simulate receiving a message
|
|
core.ws._receiveMessage({ type: 'host_updated', data: { id: 'h1', status: 'online' } });
|
|
|
|
expect(core.hosts.length).toBe(1);
|
|
});
|
|
|
|
it('handles onmessage with invalid JSON gracefully', () => {
|
|
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
core.connectWebSocket('ws://localhost/ws');
|
|
|
|
// Simulate receiving invalid JSON - should not throw
|
|
expect(() => {
|
|
core.ws.onmessage({ data: 'not valid json {' });
|
|
}).not.toThrow();
|
|
consoleSpy.mockRestore();
|
|
});
|
|
|
|
it('handles onclose', () => {
|
|
core.connectWebSocket('ws://localhost/ws');
|
|
|
|
// Should not throw
|
|
expect(() => {
|
|
core.ws.onclose({ code: 1000, reason: 'Normal' });
|
|
}).not.toThrow();
|
|
});
|
|
|
|
it('handles onerror', () => {
|
|
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
core.connectWebSocket('ws://localhost/ws');
|
|
|
|
// Should not throw
|
|
expect(() => {
|
|
core.ws.onerror({ error: new Error('Test error') });
|
|
}).not.toThrow();
|
|
consoleSpy.mockRestore();
|
|
});
|
|
});
|
|
|
|
describe('handleWebSocketMessage', () => {
|
|
beforeEach(() => {
|
|
core.hosts = [{ id: 'host-1', name: 'server1', status: 'unknown' }];
|
|
core.tasks = [];
|
|
core.schedules = [{ id: 'sched-1', name: 'Daily Backup', last_status: 'pending' }];
|
|
core.alerts = [];
|
|
});
|
|
|
|
it('handles host_updated event', () => {
|
|
const callback = vi.fn();
|
|
core.onHostsUpdated = callback;
|
|
|
|
core.handleWebSocketMessage({
|
|
type: 'host_updated',
|
|
data: { id: 'host-1', status: 'online' }
|
|
});
|
|
|
|
expect(core.hosts[0].status).toBe('online');
|
|
expect(callback).toHaveBeenCalled();
|
|
});
|
|
|
|
it('handles task_started event', () => {
|
|
const callback = vi.fn();
|
|
core.onTasksUpdated = callback;
|
|
|
|
core.handleWebSocketMessage({
|
|
type: 'task_started',
|
|
data: { id: 'task-1', playbook: 'test.yml' }
|
|
});
|
|
|
|
expect(core.tasks.length).toBe(1);
|
|
expect(core.tasks[0].id).toBe('task-1');
|
|
expect(callback).toHaveBeenCalled();
|
|
});
|
|
|
|
it('handles task_updated event', () => {
|
|
core.tasks = [{ id: 'task-1', status: 'running' }];
|
|
|
|
core.handleWebSocketMessage({
|
|
type: 'task_updated',
|
|
data: { id: 'task-1', status: 'completed', progress: 100 }
|
|
});
|
|
|
|
expect(core.tasks[0].status).toBe('completed');
|
|
expect(core.tasks[0].progress).toBe(100);
|
|
});
|
|
|
|
it('handles schedule_completed event', () => {
|
|
const callback = vi.fn();
|
|
core.onSchedulesUpdated = callback;
|
|
|
|
core.handleWebSocketMessage({
|
|
type: 'schedule_completed',
|
|
data: { schedule_id: 'sched-1', status: 'success' }
|
|
});
|
|
|
|
expect(core.schedules[0].last_status).toBe('success');
|
|
expect(callback).toHaveBeenCalled();
|
|
});
|
|
|
|
it('handles alert_created event', () => {
|
|
const callback = vi.fn();
|
|
core.onAlertsUpdated = callback;
|
|
|
|
core.handleWebSocketMessage({
|
|
type: 'alert_created',
|
|
data: { id: 1, message: 'Test alert' }
|
|
});
|
|
|
|
expect(core.alerts.length).toBe(1);
|
|
expect(core.alertsUnread).toBe(1);
|
|
expect(callback).toHaveBeenCalled();
|
|
});
|
|
|
|
it('handles hosts_synced event', () => {
|
|
const callback = vi.fn();
|
|
core.onHostsUpdated = callback;
|
|
|
|
core.handleWebSocketMessage({ type: 'hosts_synced', data: {} });
|
|
|
|
expect(callback).toHaveBeenCalled();
|
|
});
|
|
|
|
it('ignores unknown message types', () => {
|
|
// Should not throw
|
|
core.handleWebSocketMessage({ type: 'unknown_type', data: {} });
|
|
});
|
|
});
|
|
});
|
|
|
|
// ============================================================================
|
|
// TESTS: DashboardCore - Data Management
|
|
// ============================================================================
|
|
|
|
describe('DashboardCore - Data Management', () => {
|
|
let core;
|
|
|
|
beforeEach(() => {
|
|
core = new DashboardCore({});
|
|
});
|
|
|
|
describe('updateHostInList', () => {
|
|
it('updates existing host', () => {
|
|
core.hosts = [{ id: 'host-1', name: 'server1', status: 'unknown' }];
|
|
|
|
core.updateHostInList({ id: 'host-1', status: 'online' });
|
|
|
|
expect(core.hosts[0].status).toBe('online');
|
|
expect(core.hosts[0].name).toBe('server1'); // Preserved
|
|
});
|
|
|
|
it('adds new host if not found', () => {
|
|
core.hosts = [];
|
|
|
|
core.updateHostInList({ id: 'host-new', name: 'new-server' });
|
|
|
|
expect(core.hosts.length).toBe(1);
|
|
expect(core.hosts[0].id).toBe('host-new');
|
|
});
|
|
|
|
it('ignores invalid data', () => {
|
|
core.hosts = [];
|
|
|
|
core.updateHostInList(null);
|
|
core.updateHostInList({});
|
|
|
|
expect(core.hosts.length).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('handleTaskUpdate', () => {
|
|
it('updates existing task', () => {
|
|
core.tasks = [{ id: 'task-1', status: 'running' }];
|
|
|
|
core.handleTaskUpdate({ id: 'task-1', status: 'completed' });
|
|
|
|
expect(core.tasks[0].status).toBe('completed');
|
|
});
|
|
|
|
it('adds new task at beginning', () => {
|
|
core.tasks = [{ id: 'task-old' }];
|
|
|
|
core.handleTaskUpdate({ id: 'task-new', status: 'pending' });
|
|
|
|
expect(core.tasks.length).toBe(2);
|
|
expect(core.tasks[0].id).toBe('task-new');
|
|
});
|
|
});
|
|
|
|
describe('handleAlertCreated', () => {
|
|
it('adds alert and increments unread count', () => {
|
|
core.alerts = [];
|
|
core.alertsUnread = 0;
|
|
|
|
core.handleAlertCreated({ id: 1, message: 'Test' });
|
|
|
|
expect(core.alerts.length).toBe(1);
|
|
expect(core.alertsUnread).toBe(1);
|
|
});
|
|
|
|
it('limits alerts to 200', () => {
|
|
core.alerts = Array.from({ length: 200 }, (_, i) => ({ id: i }));
|
|
|
|
core.handleAlertCreated({ id: 999, message: 'New' });
|
|
|
|
expect(core.alerts.length).toBe(200);
|
|
expect(core.alerts[0].id).toBe(999);
|
|
});
|
|
});
|
|
});
|
|
|
|
// ============================================================================
|
|
// TESTS: DashboardCore - Utility
|
|
// ============================================================================
|
|
|
|
describe('DashboardCore - Utility', () => {
|
|
let core;
|
|
|
|
beforeEach(() => {
|
|
core = new DashboardCore({});
|
|
});
|
|
|
|
describe('escapeHtml', () => {
|
|
it('escapes HTML special characters', () => {
|
|
const result = core.escapeHtml('<script>alert("xss")</script>');
|
|
|
|
expect(result).not.toContain('<script>');
|
|
expect(result).toContain('<');
|
|
expect(result).toContain('>');
|
|
});
|
|
|
|
it('returns empty string for null/undefined', () => {
|
|
expect(core.escapeHtml(null)).toBe('');
|
|
expect(core.escapeHtml(undefined)).toBe('');
|
|
});
|
|
|
|
it('handles normal text', () => {
|
|
expect(core.escapeHtml('Hello World')).toBe('Hello World');
|
|
});
|
|
|
|
it('escapes quotes', () => {
|
|
const result = core.escapeHtml('He said "hello" & \'goodbye\'');
|
|
|
|
expect(result).toContain('&');
|
|
});
|
|
});
|
|
|
|
describe('_getDefaultWsUrl', () => {
|
|
it('returns ws:// URL for http', () => {
|
|
// In jsdom, window.location.protocol is typically http:
|
|
const url = core._getDefaultWsUrl();
|
|
|
|
expect(url).toMatch(/^wss?:\/\//);
|
|
});
|
|
});
|
|
});
|
|
|
|
// ============================================================================
|
|
// TESTS: DashboardCore - Constructor
|
|
// ============================================================================
|
|
|
|
describe('DashboardCore - Constructor', () => {
|
|
it('loads token from storage on init', () => {
|
|
const mockStorage = {
|
|
getItem: vi.fn(() => 'stored-token'),
|
|
setItem: vi.fn(),
|
|
removeItem: vi.fn()
|
|
};
|
|
|
|
const core = new DashboardCore({ storage: mockStorage });
|
|
|
|
expect(core.accessToken).toBe('stored-token');
|
|
});
|
|
|
|
it('starts with null token if storage empty', () => {
|
|
const mockStorage = {
|
|
getItem: vi.fn(() => null),
|
|
setItem: vi.fn(),
|
|
removeItem: vi.fn()
|
|
};
|
|
|
|
const core = new DashboardCore({ storage: mockStorage });
|
|
|
|
expect(core.accessToken).toBeNull();
|
|
});
|
|
|
|
it('uses default apiBase in browser environment', () => {
|
|
// In test environment, window.location.origin may not be set
|
|
const core = new DashboardCore({});
|
|
|
|
expect(core.apiBase).toBeDefined();
|
|
});
|
|
});
|