feat(oggui): sistema de permisos por rol + mejoras de UX y tests #95

Merged
narenas merged 10 commits from devel into main 2026-06-16 17:59:55 +02:00
29 changed files with 1194 additions and 166 deletions

View File

@ -43,3 +43,5 @@ Thumbs.db
test-results/
.e2e-state.json
playwright-report/

View File

@ -0,0 +1,7 @@
import { setupTestUsers } from './helpers/api';
export default async function globalSetup() {
console.log('\n▶ E2E — creando usuarios de test...');
await setupTestUsers();
console.log(' ✓ Setup completado\n');
}

View File

@ -0,0 +1,7 @@
import { teardownTestUsers } from './helpers/api';
export default async function globalTeardown() {
console.log('\n▶ E2E — eliminando usuarios de test...');
await teardownTestUsers();
console.log(' ✓ Teardown completado\n');
}

View File

@ -0,0 +1,139 @@
/**
* Helpers para crear y borrar usuarios de test vía la API de ogCore.
* Se usan en globalSetup / globalTeardown de Playwright.
*/
import { Agent } from 'undici';
import * as fs from 'fs';
import * as path from 'path';
export const API_URL = process.env['API_URL'] || 'https://ogserver.local/api';
export const APP_URL = process.env['APP_URL'] || 'https://localhost:4200/oggui';
export const ADMIN_USER = process.env['ADMIN_USER'] || 'ogadmin';
export const ADMIN_PASS = process.env['ADMIN_PASS'] || '12345678';
export const TEST_PASS = 'Test1234!';
// Nombres fijos — sin timestamp para que sean consistentes entre procesos
export const TEST_USERS = {
superAdmin: { username: 'e2e-superadmin', role: 'ROLE_SUPER_ADMIN' },
ouAdmin: { username: 'e2e-ouadmin', role: 'ROLE_ORGANIZATIONAL_UNIT_ADMIN' },
operator: { username: 'e2e-operator', role: 'ROLE_ORGANIZATIONAL_UNIT_OPERATOR' },
minimal: { username: 'e2e-minimal', role: 'ROLE_ORGANIZATIONAL_UNIT_MINIMAL' },
};
// Fichero temporal para pasar IRIs del setup al teardown
const STATE_FILE = path.join(__dirname, '../../.e2e-state.json');
// undici Agent que ignora certificados autofirmados (equivalente a curl -k)
const insecureAgent = new Agent({ connect: { rejectUnauthorized: false } });
async function fetchJson(url: string, init: RequestInit): Promise<any> {
const res = await fetch(url, {
...init,
headers: { 'Content-Type': 'application/ld+json', ...(init.headers as any) },
dispatcher: insecureAgent,
} as any);
const text = await res.text();
try { return JSON.parse(text); } catch { return text; }
}
export async function getAdminToken(): Promise<string> {
const data = await fetchJson(`${API_URL}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: ADMIN_USER, password: ADMIN_PASS }),
});
if (!data.token) throw new Error(`Login fallido: ${JSON.stringify(data)}`);
return data.token;
}
async function findUserByUsername(token: string, username: string): Promise<string | null> {
const data = await fetchJson(`${API_URL}/users?username=${encodeURIComponent(username)}`, {
method: 'GET',
headers: { Authorization: `Bearer ${token}` },
});
const members = data['hydra:member'] ?? [];
return members.length > 0 ? members[0]['@id'] : null;
}
async function findUserGroupByName(token: string, name: string): Promise<string | null> {
const data = await fetchJson(`${API_URL}/user-groups?name=${encodeURIComponent(name)}`, {
method: 'GET',
headers: { Authorization: `Bearer ${token}` },
});
const members = data['hydra:member'] ?? [];
return members.length > 0 ? members[0]['@id'] : null;
}
async function deleteResource(token: string, iri: string): Promise<void> {
await fetch(`${API_URL.replace('/api', '')}${iri}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` },
dispatcher: insecureAgent,
} as any);
}
async function createUserGroup(token: string, role: string, name: string): Promise<string> {
const data = await fetchJson(`${API_URL}/user-groups`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
body: JSON.stringify({ name, permissions: [role], enabled: true }),
});
if (!data['@id']) throw new Error(`No se pudo crear UserGroup ${name}: ${JSON.stringify(data)}`);
return data['@id'];
}
async function createUser(token: string, username: string, ugIri: string): Promise<string> {
const data = await fetchJson(`${API_URL}/users`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
body: JSON.stringify({ username, password: TEST_PASS, enabled: true, userGroups: [ugIri] }),
});
if (!data['@id']) throw new Error(`No se pudo crear usuario ${username}: ${JSON.stringify(data)}`);
return data['@id'];
}
export async function setupTestUsers(): Promise<void> {
const token = await getAdminToken();
const userIris: string[] = [];
const ugIris: string[] = [];
for (const { username, role } of Object.values(TEST_USERS)) {
const ugName = `e2e-ug-${username}`;
// Eliminar usuario previo si existe (limpia runs anteriores)
const existingUser = await findUserByUsername(token, username);
if (existingUser) {
await deleteResource(token, existingUser);
console.log(` ↺ Eliminado usuario previo: ${username}`);
}
// Eliminar UserGroup previo si existe
const existingUg = await findUserGroupByName(token, ugName);
if (existingUg) {
await deleteResource(token, existingUg);
}
const ugIri = await createUserGroup(token, role, ugName);
const userIri = await createUser(token, username, ugIri);
ugIris.push(ugIri);
userIris.push(userIri);
console.log(` ✓ ${username} [${role}]`);
}
// Guarda IRIs en fichero para que teardown pueda leerlos (env vars no siempre pasan entre procesos)
fs.writeFileSync(STATE_FILE, JSON.stringify({ userIris, ugIris }, null, 2));
}
export async function teardownTestUsers(): Promise<void> {
if (!fs.existsSync(STATE_FILE)) return;
const { userIris, ugIris } = JSON.parse(fs.readFileSync(STATE_FILE, 'utf-8'));
const token = await getAdminToken();
for (const iri of [...userIris, ...ugIris]) {
await deleteResource(token, iri);
console.log(` ✓ Eliminado: ${iri}`);
}
fs.unlinkSync(STATE_FILE);
}

View File

@ -0,0 +1,39 @@
import { Page, expect } from '@playwright/test';
import { APP_URL, TEST_PASS, TEST_USERS } from './api';
export type Role = keyof typeof TEST_USERS;
/**
* Hace login en la GUI con el usuario de test del rol indicado.
* Espera a que la redirección post-login termine (URL cambia a /groups).
*/
export async function loginAs(page: Page, role: Role): Promise<void> {
const { username } = TEST_USERS[role];
await page.goto(`${APP_URL}/auth/login`);
await page.locator('input[name="username"]').fill(username);
await page.locator('input[name="password"]').fill(TEST_PASS);
await page.locator('button[type="submit"]').click();
await expect(page).toHaveURL(/\/groups/, { timeout: 10_000 });
}
/**
* Navega a una ruta y devuelve la URL final (útil para comprobar redirecciones).
*/
export async function navigateTo(page: Page, path: string): Promise<string> {
await page.goto(`${APP_URL}${path}`);
await page.waitForLoadState('networkidle');
return page.url();
}
/** Comprueba que un enlace del sidebar ES visible */
export async function expectSidebarLink(page: Page, routerLink: string, visible: true): Promise<void>;
/** Comprueba que un enlace del sidebar NO está en el DOM */
export async function expectSidebarLink(page: Page, routerLink: string, visible: false): Promise<void>;
export async function expectSidebarLink(page: Page, routerLink: string, visible: boolean): Promise<void> {
const locator = page.locator(`[routerLink="${routerLink}"], a[href="${routerLink}"]`).first();
if (visible) {
await expect(locator).toBeVisible({ timeout: 5_000 });
} else {
await expect(locator).not.toBeAttached({ timeout: 5_000 });
}
}

View File

@ -0,0 +1,75 @@
import { test, expect } from '@playwright/test';
import { loginAs, navigateTo, expectSidebarLink } from '../helpers/auth';
import { APP_URL } from '../helpers/api';
test.describe('OU Admin — acceso a gestión sin infraestructura', () => {
test.beforeEach(async ({ page }) => {
await loginAs(page, 'ouAdmin');
});
// ── Sidebar ─────────────────────────────────────────────────────────────────
test.describe('Sidebar', () => {
test('ve /groups', async ({ page }) => expectSidebarLink(page, '/groups', true));
test('ve /dhcp-servers', async ({ page }) => expectSidebarLink(page, '/dhcp-servers', true));
test('ve /boot-servers', async ({ page }) => expectSidebarLink(page, '/boot-servers', true));
test('ve /calendars', async ({ page }) => expectSidebarLink(page, '/calendars', true));
test('ve /repositories', async ({ page }) => expectSidebarLink(page, '/repositories', true));
test('ve /menus', async ({ page }) => expectSidebarLink(page, '/menus', true));
});
// ── Rutas permitidas ─────────────────────────────────────────────────────────
test.describe('Rutas — puede acceder', () => {
const allowed = [
'/groups', '/commands', '/dhcp-servers', '/boot-servers',
'/calendars', '/repositories', '/pxe',
];
for (const route of allowed) {
test(`accede a ${route}`, async ({ page }) => {
const finalUrl = await navigateTo(page, route);
expect(finalUrl).toContain(route);
});
}
});
// ── Rutas bloqueadas (solo super-admin) ──────────────────────────────────────
test.describe('Rutas — redirige a /groups cuando accede a rutas de super-admin', () => {
const blocked = ['/users', '/roles', '/env-vars', '/import-legacy'];
for (const route of blocked) {
test(`${route} → redirige a /groups`, async ({ page }) => {
const finalUrl = await navigateTo(page, route);
expect(finalUrl).toContain('/groups');
});
}
});
// ── GUI: botones de infraestructura OCULTOS ──────────────────────────────────
test('NO ve el botón "Añadir OU" en /groups (solo ou-admin no puede crear desde aquí... wait, sí puede)', async ({ page }) => {
await page.goto(`${APP_URL}/groups`);
// ou-admin SÍ ve el botón de añadir OU (no es ou-operator ni ou-minimal)
const addOuBtn = page.locator('button.action-button').first();
await expect(addOuBtn).toBeVisible({ timeout: 5_000 });
});
test('NO ve botón crear en /dhcp-servers (solo super-admin puede crear servidores DHCP)', async ({ page }) => {
await page.goto(`${APP_URL}/dhcp-servers`);
await page.waitForLoadState('networkidle');
// El botón de añadir dhcp tiene *ngIf="auth.isSuperAdmin" → no debe aparecer
const addBtn = page.locator('button[color="primary"]').filter({ hasText: /add|añadir/i });
await expect(addBtn).not.toBeAttached({ timeout: 5_000 });
});
test('NO ve botón crear en /boot-servers (solo super-admin)', async ({ page }) => {
await page.goto(`${APP_URL}/boot-servers`);
await page.waitForLoadState('networkidle');
const addBtn = page.locator('button').filter({ hasText: /add|añadir/i }).first();
await expect(addBtn).not.toBeAttached({ timeout: 5_000 });
});
test('NO ve botón crear en /commands (solo super-admin)', async ({ page }) => {
await page.goto(`${APP_URL}/commands`);
await page.waitForLoadState('networkidle');
const addBtn = page.locator('button.action-button');
await expect(addBtn).not.toBeAttached({ timeout: 5_000 });
});
});

View File

@ -0,0 +1,60 @@
import { test, expect } from '@playwright/test';
import { loginAs, navigateTo, expectSidebarLink } from '../helpers/auth';
import { APP_URL } from '../helpers/api';
test.describe('OU Minimal — solo vista de grupos', () => {
test.beforeEach(async ({ page }) => {
await loginAs(page, 'minimal');
});
// ── Login ────────────────────────────────────────────────────────────────────
test('tras login aterriza en /groups', async ({ page }) => {
await expect(page).toHaveURL(/\/groups/);
});
// ── Sidebar: solo /groups ────────────────────────────────────────────────────
test.describe('Sidebar — solo ve /groups', () => {
test('ve /groups', async ({ page }) => expectSidebarLink(page, '/groups', true));
test('NO ve /dhcp-servers', async ({ page }) => expectSidebarLink(page, '/dhcp-servers', false));
test('NO ve /boot-servers', async ({ page }) => expectSidebarLink(page, '/boot-servers', false));
test('NO ve /calendars', async ({ page }) => expectSidebarLink(page, '/calendars', false));
test('NO ve /repositories', async ({ page }) => expectSidebarLink(page, '/repositories', false));
test('NO ve /menus', async ({ page }) => expectSidebarLink(page, '/menus', false));
});
// ── Rutas bloqueadas ─────────────────────────────────────────────────────────
test.describe('Todas las rutas protegidas redirigen a /groups', () => {
const blocked = [
'/users', '/roles', '/env-vars',
'/commands', '/dhcp-servers', '/boot-servers',
'/calendars', '/repositories', '/pxe',
'/clients/deploy-image', '/clients/partition-assistant', '/clients/run-script',
];
for (const route of blocked) {
test(`${route} → redirige a /groups`, async ({ page }) => {
const finalUrl = await navigateTo(page, route);
expect(finalUrl).toContain('/groups');
});
}
});
// ── GUI en /groups: sin botones de acción ────────────────────────────────────
test.describe('/groups — sin botones de creación ni edición', () => {
test('NO ve botones "Añadir OU" ni "Añadir cliente"', async ({ page }) => {
await page.goto(`${APP_URL}/groups`);
await page.waitForLoadState('networkidle');
const actionBtns = page.locator('button.action-button');
await expect(actionBtns).not.toBeAttached({ timeout: 5_000 });
});
test('NO ve botones de editar ni eliminar en el árbol de grupos', async ({ page }) => {
await page.goto(`${APP_URL}/groups`);
await page.waitForLoadState('networkidle');
// Los botones de editar/borrar nodo tienen *ngIf="auth.userCategory !== 'ou-minimal'"
// Se verifican a través del menú contextual que no debería aparecer con opciones de edición
const editBtns = page.locator('button').filter({ hasText: /editar|edit|modificar/i });
await expect(editBtns).not.toBeAttached({ timeout: 5_000 });
});
});
});

View File

@ -0,0 +1,77 @@
import { test, expect } from '@playwright/test';
import { loginAs, navigateTo, expectSidebarLink } from '../helpers/auth';
import { APP_URL } from '../helpers/api';
test.describe('OU Operator — solo grupos y operaciones de equipos', () => {
test.beforeEach(async ({ page }) => {
await loginAs(page, 'operator');
});
// ── Sidebar: solo /groups visible ───────────────────────────────────────────
test.describe('Sidebar — solo ve /groups', () => {
test('ve /groups', async ({ page }) => expectSidebarLink(page, '/groups', true));
test('NO ve /dhcp-servers', async ({ page }) => expectSidebarLink(page, '/dhcp-servers', false));
test('NO ve /boot-servers', async ({ page }) => expectSidebarLink(page, '/boot-servers', false));
test('NO ve /calendars', async ({ page }) => expectSidebarLink(page, '/calendars', false));
test('NO ve /repositories', async ({ page }) => expectSidebarLink(page, '/repositories', false));
test('NO ve /menus', async ({ page }) => expectSidebarLink(page, '/menus', false));
});
// ── Rutas bloqueadas: redirige a /groups ─────────────────────────────────────
test.describe('Rutas de admin → redirigen a /groups', () => {
const blocked = [
'/users', '/roles', '/env-vars',
'/commands', '/dhcp-servers', '/boot-servers',
'/calendars', '/repositories', '/pxe',
];
for (const route of blocked) {
test(`${route} → redirige a /groups`, async ({ page }) => {
const finalUrl = await navigateTo(page, route);
expect(finalUrl).toContain('/groups');
});
}
});
// ── Rutas permitidas: operaciones de equipos ─────────────────────────────────
test.describe('Rutas de operación — puede acceder', () => {
test('accede a /groups', async ({ page }) => {
const finalUrl = await navigateTo(page, '/groups');
expect(finalUrl).toContain('/groups');
});
test('accede a /clients/deploy-image', async ({ page }) => {
const finalUrl = await navigateTo(page, '/clients/deploy-image');
expect(finalUrl).toContain('/clients/deploy-image');
});
test('accede a /clients/partition-assistant', async ({ page }) => {
const finalUrl = await navigateTo(page, '/clients/partition-assistant');
expect(finalUrl).toContain('/clients/partition-assistant');
});
test('accede a /clients/run-script', async ({ page }) => {
const finalUrl = await navigateTo(page, '/clients/run-script');
expect(finalUrl).toContain('/clients/run-script');
});
});
// ── GUI en /groups: botones de crear OU/cliente ocultos ─────────────────────
test.describe('/groups — botones de creación ocultos', () => {
test('NO ve el botón "Añadir OU"', async ({ page }) => {
await page.goto(`${APP_URL}/groups`);
await page.waitForLoadState('networkidle');
// *ngIf="auth.userCategory !== 'ou-operator' && ..."
const addOuBtn = page.locator('button.action-button').filter({ hasText: /ou|unidad/i });
await expect(addOuBtn).not.toBeAttached({ timeout: 5_000 });
});
test('NO ve el botón de menú "Añadir cliente"', async ({ page }) => {
await page.goto(`${APP_URL}/groups`);
await page.waitForLoadState('networkidle');
// Los botones de acción principales no deben aparecer para operator
const actionBtns = page.locator('button.action-button');
await expect(actionBtns).not.toBeAttached({ timeout: 5_000 });
});
});
});

View File

@ -0,0 +1,66 @@
import { test, expect } from '@playwright/test';
import { loginAs, navigateTo, expectSidebarLink } from '../helpers/auth';
import { APP_URL } from '../helpers/api';
test.describe('Super Admin — acceso total', () => {
test.beforeEach(async ({ page }) => {
await loginAs(page, 'superAdmin');
});
// ── Sidebar: todos los elementos visibles ───────────────────────────────────
test.describe('Sidebar — ve todos los enlaces', () => {
const links = [
'/groups', '/dhcp-servers', '/boot-servers',
'/calendars', '/repositories', '/menus',
];
for (const link of links) {
test(`ve el enlace a ${link}`, async ({ page }) => {
await expectSidebarLink(page, link, true);
});
}
});
// ── Rutas: acceso permitido ─────────────────────────────────────────────────
test.describe('Rutas — puede acceder a todas', () => {
const routes = [
'/users', '/roles', '/env-vars',
'/commands', '/pxe', '/dhcp-servers', '/boot-servers',
'/calendars', '/repositories',
];
for (const route of routes) {
test(`accede a ${route} sin ser redirigido`, async ({ page }) => {
const finalUrl = await navigateTo(page, route);
expect(finalUrl).toContain(route);
});
}
});
// ── GUI: botones de infraestructura visibles ────────────────────────────────
test('ve el botón "Añadir OU" en /groups', async ({ page }) => {
await page.goto(`${APP_URL}/groups`);
await expect(page.locator('button.action-button').first()).toBeVisible();
});
test('ve botones de crear/editar en /dhcp-servers', async ({ page }) => {
await page.goto(`${APP_URL}/dhcp-servers`);
await page.waitForLoadState('networkidle');
// Botón de añadir servidor (solo visible para super-admin)
const addBtn = page.locator('button').filter({ hasText: /add|añadir|nuevo/i }).first();
await expect(addBtn).toBeVisible({ timeout: 8_000 });
});
test('ve botones de crear en /commands', async ({ page }) => {
await page.goto(`${APP_URL}/commands`);
await page.waitForLoadState('networkidle');
const addBtn = page.locator('button.action-button').first();
await expect(addBtn).toBeVisible({ timeout: 8_000 });
});
test('ve botones de crear en /boot-servers', async ({ page }) => {
await page.goto(`${APP_URL}/boot-servers`);
await page.waitForLoadState('networkidle');
const addBtn = page.locator('button').filter({ hasText: /add|añadir|nuevo/i }).first();
await expect(addBtn).toBeVisible({ timeout: 8_000 });
});
});

View File

@ -35,6 +35,7 @@
"@angular/compiler-cli": "^18.0.0",
"@angular/localize": "^18.1.0",
"@ngx-env/builder": "^18.0.1",
"@playwright/test": "^1.60.0",
"@types/jasmine": "~5.1.0",
"@types/papaparse": "^5.3.15",
"jasmine-core": "~5.1.0",
@ -272,6 +273,7 @@
"version": "18.1.0",
"resolved": "https://registry.npmjs.org/@angular/animations/-/animations-18.1.0.tgz",
"integrity": "sha512-K0BhvZ/SIVoGXZVuh1KOJDdgcGlHfFGMGrs58utndndAb+gYXReMfz4GR5cQs2OObH6TKmIOY2EH7Og1CY2tsw==",
"peer": true,
"dependencies": {
"tslib": "^2.3.0"
},
@ -356,6 +358,7 @@
"version": "18.0.6",
"resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-18.0.6.tgz",
"integrity": "sha512-9CmlQ8uZ0cHuj+1jDdabbFw90QpjrJzh1/cgeHN/8vAGE4ranxX3l6cqpVxuoWRLkB2ykTHt2xeoTM8y0d9ERA==",
"peer": true,
"dependencies": {
"tslib": "^2.3.0"
},
@ -405,6 +408,7 @@
"version": "18.1.0",
"resolved": "https://registry.npmjs.org/@angular/common/-/common-18.1.0.tgz",
"integrity": "sha512-noHDLarQSCZZh7hyNd0HR61Fut+q4QCVq9qc/jKPglfbV/6nPujQSmSpT+rNJlNuBOrCLuvH/CNBNbiqii+x3g==",
"peer": true,
"dependencies": {
"tslib": "^2.3.0"
},
@ -420,6 +424,7 @@
"version": "18.1.0",
"resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-18.1.0.tgz",
"integrity": "sha512-JRQzVTeJGSfRLY+dx+gwu/hPQVB8K+5pW12Z42M9x/HBgGW4in0cO2zHkeQPvImqm0nak82Us1Hyf5C+qTlMMQ==",
"peer": true,
"dependencies": {
"tslib": "^2.3.0"
},
@ -440,6 +445,7 @@
"resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-18.1.0.tgz",
"integrity": "sha512-BBsogLPJwxkPh7f8RVHsxyyqNE8XpHbAanjB5fAwnU4W6Sw1kR5rFzkeZM3xaRm2MDiC8DovIl6hlf+s/mKYOw==",
"dev": true,
"peer": true,
"dependencies": {
"@babel/core": "7.24.7",
"@jridgewell/sourcemap-codec": "^1.4.14",
@ -467,6 +473,7 @@
"version": "18.1.0",
"resolved": "https://registry.npmjs.org/@angular/core/-/core-18.1.0.tgz",
"integrity": "sha512-/57/s7CD/0CwlN+3FlhVmx7ypCWXjKi5UKtnlBAUg0D1denIf6ADxwTHFZABYZcYBqOTJgeQUtUw9u/A+0CIlg==",
"peer": true,
"dependencies": {
"tslib": "^2.3.0"
},
@ -482,6 +489,7 @@
"version": "18.1.0",
"resolved": "https://registry.npmjs.org/@angular/forms/-/forms-18.1.0.tgz",
"integrity": "sha512-m+7m9wa+n5dEacd458eSZsZTz0B+HbOtr7/uqM0YTMQaPrhwl1epG5Y103mB6yr00JiJcLNlPLjP888cHFjldQ==",
"peer": true,
"dependencies": {
"tslib": "^2.3.0"
},
@ -500,6 +508,7 @@
"resolved": "https://registry.npmjs.org/@angular/localize/-/localize-18.1.0.tgz",
"integrity": "sha512-84D06p2Th5NxoJZzsSIn4FkTJGImj7rtNnvyTrHvHdomzzUKwiBOXDB2FiCLDstND0DsCtgjD/uBJivg77z9tg==",
"dev": true,
"peer": true,
"dependencies": {
"@babel/core": "7.24.7",
"@types/babel__core": "7.20.5",
@ -588,6 +597,7 @@
"version": "18.1.0",
"resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-18.1.0.tgz",
"integrity": "sha512-jCmxthiI4Zef54crckNht60xwfIsuciGeyZvb7SsXna2maLW9fA4uz1VhZqIWTiBnHwNynVlyfBX3/jBD7S9+g==",
"peer": true,
"dependencies": {
"tslib": "^2.3.0"
},
@ -609,6 +619,7 @@
"version": "18.1.0",
"resolved": "https://registry.npmjs.org/@angular/platform-browser-dynamic/-/platform-browser-dynamic-18.1.0.tgz",
"integrity": "sha512-D/wuOQf+gULld9DVEzn2Lw3WbTyAYf/sp3DC5k83O+DQsG3eAIsVkt0zdE+U3DrDYsiWg8M3X+ioi3ouqK0mNg==",
"peer": true,
"dependencies": {
"tslib": "^2.3.0"
},
@ -666,6 +677,7 @@
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.24.7.tgz",
"integrity": "sha512-nykK+LEK86ahTkX/3TgauT0ikKoNCfKHEaZYTUVupJdTLzGNvrblu4u6fa7DhZONAltdf8e662t/abY8idrd/g==",
"dev": true,
"peer": true,
"dependencies": {
"@ampproject/remapping": "^2.2.0",
"@babel/code-frame": "^7.24.7",
@ -3217,6 +3229,7 @@
"resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-5.0.7.tgz",
"integrity": "sha512-GFcigCxJTKCH3aECzMIu4FhgLJWnFvMXzpI4CCSoELWFtkOOU2P+goYA61+OKpGrB8fPE7q6n8zAXBSlZRrHjQ==",
"dev": true,
"peer": true,
"dependencies": {
"@inquirer/checkbox": "^2.3.7",
"@inquirer/confirm": "^3.1.11",
@ -4518,7 +4531,6 @@
"os": [
"aix"
],
"peer": true,
"engines": {
"node": ">=12"
}
@ -4535,7 +4547,6 @@
"os": [
"android"
],
"peer": true,
"engines": {
"node": ">=12"
}
@ -4552,7 +4563,6 @@
"os": [
"android"
],
"peer": true,
"engines": {
"node": ">=12"
}
@ -4569,7 +4579,6 @@
"os": [
"android"
],
"peer": true,
"engines": {
"node": ">=12"
}
@ -4586,7 +4595,6 @@
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": ">=12"
}
@ -4603,7 +4611,6 @@
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": ">=12"
}
@ -4620,7 +4627,6 @@
"os": [
"freebsd"
],
"peer": true,
"engines": {
"node": ">=12"
}
@ -4637,7 +4643,6 @@
"os": [
"freebsd"
],
"peer": true,
"engines": {
"node": ">=12"
}
@ -4654,7 +4659,6 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=12"
}
@ -4671,7 +4675,6 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=12"
}
@ -4688,7 +4691,6 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=12"
}
@ -4705,7 +4707,6 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=12"
}
@ -4722,7 +4723,6 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=12"
}
@ -4739,7 +4739,6 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=12"
}
@ -4756,7 +4755,6 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=12"
}
@ -4773,7 +4771,6 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=12"
}
@ -4790,7 +4787,6 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=12"
}
@ -4807,7 +4803,6 @@
"os": [
"netbsd"
],
"peer": true,
"engines": {
"node": ">=12"
}
@ -4824,7 +4819,6 @@
"os": [
"openbsd"
],
"peer": true,
"engines": {
"node": ">=12"
}
@ -4841,7 +4835,6 @@
"os": [
"sunos"
],
"peer": true,
"engines": {
"node": ">=12"
}
@ -4858,7 +4851,6 @@
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">=12"
}
@ -4875,7 +4867,6 @@
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">=12"
}
@ -4892,7 +4883,6 @@
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">=12"
}
@ -5306,6 +5296,22 @@
"node": ">=14"
}
},
"node_modules/@playwright/test": {
"version": "1.60.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz",
"integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.60.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.18.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.18.0.tgz",
@ -5891,6 +5897,7 @@
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.10.tgz",
"integrity": "sha512-MdiXf+nDuMvY0gJKxyfZ7/6UFsETO7mGKF54MVD/ekJS6HdFtpZFBgrh6Pseu64XTb2MLyFPlbW6hj8HYRQNOQ==",
"dev": true,
"peer": true,
"dependencies": {
"undici-types": "~5.26.4"
}
@ -6188,6 +6195,7 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.1.tgz",
"integrity": "sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg==",
"dev": true,
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@ -6261,6 +6269,7 @@
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.16.0.tgz",
"integrity": "sha512-F0twR8U1ZU67JIEtekUcLkXkoO5mMMmgGD8sK/xUFzJ805jxHQl92hImFAqqXMyMYjSPOyUPAwHYhB72g5sTXw==",
"dev": true,
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.3",
"json-schema-traverse": "^1.0.0",
@ -6666,6 +6675,7 @@
"url": "https://github.com/sponsors/ai"
}
],
"peer": true,
"dependencies": {
"caniuse-lite": "^1.0.30001640",
"electron-to-chromium": "^1.4.820",
@ -7725,6 +7735,7 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"peer": true,
"engines": {
"node": ">=12"
}
@ -9810,7 +9821,8 @@
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/jasmine-core/-/jasmine-core-5.1.2.tgz",
"integrity": "sha512-2oIUMGn00FdUiqz6epiiJr7xcFyNYj3rDcfmnzfkBnHyBQ3cBQUs4mmyGsOb7TTLb9kxk7dBcmEmqhDKkBoDyA==",
"dev": true
"dev": true,
"peer": true
},
"node_modules/jest-worker": {
"version": "27.5.1",
@ -9959,6 +9971,7 @@
"resolved": "https://registry.npmjs.org/karma/-/karma-6.4.3.tgz",
"integrity": "sha512-LuucC/RE92tJ8mlCwqEoRWXP38UMAqpnq98vktmS9SznSoUPPUJQbc91dHcxcunROvfQjdORVA/YFviH+Xci9Q==",
"dev": true,
"peer": true,
"dependencies": {
"@colors/colors": "1.5.0",
"body-parser": "^1.19.0",
@ -10259,6 +10272,7 @@
"resolved": "https://registry.npmjs.org/less/-/less-4.2.0.tgz",
"integrity": "sha512-P3b3HJDBtSzsXUl0im2L7gTO5Ubg8mEN6G8qoTS77iXxXX4Hvu4Qj540PZDvQ8V6DmX6iXo98k7Md0Cm1PrLaA==",
"dev": true,
"peer": true,
"dependencies": {
"copy-anything": "^2.0.1",
"parse-node-version": "^1.0.1",
@ -12188,6 +12202,53 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/playwright": {
"version": "1.60.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz",
"integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.60.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.60.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz",
"integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/postcss": {
"version": "8.4.38",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.38.tgz",
@ -12207,6 +12268,7 @@
"url": "https://github.com/sponsors/ai"
}
],
"peer": true,
"dependencies": {
"nanoid": "^3.3.7",
"picocolors": "^1.0.0",
@ -12823,6 +12885,7 @@
"version": "7.8.1",
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz",
"integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==",
"peer": true,
"dependencies": {
"tslib": "^2.1.0"
}
@ -13803,6 +13866,7 @@
"resolved": "https://registry.npmjs.org/terser/-/terser-5.29.2.tgz",
"integrity": "sha512-ZiGkhUBIM+7LwkNjXYJq8svgkd+QK3UUr0wJqY4MieaezBSAIPgbSPZyIx0idM6XWK5CMzSWa8MJIzmRcB8Caw==",
"dev": true,
"peer": true,
"dependencies": {
"@jridgewell/source-map": "^0.3.3",
"acorn": "^8.8.2",
@ -13855,6 +13919,7 @@
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"dev": true,
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0",
@ -13987,7 +14052,8 @@
"node_modules/tslib": {
"version": "2.6.3",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz",
"integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ=="
"integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==",
"peer": true
},
"node_modules/tuf-js": {
"version": "2.2.1",
@ -14039,6 +14105,7 @@
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz",
"integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==",
"dev": true,
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@ -14284,6 +14351,7 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-5.3.2.tgz",
"integrity": "sha512-6lA7OBHBlXUxiJxbO5aAY2fsHHzDr1q7DvXYnyZycRs2Dz+dXBWuhpWHvmljTRTpQC2uvGmUFFkSHF2vGo90MA==",
"dev": true,
"peer": true,
"dependencies": {
"esbuild": "^0.21.3",
"postcss": "^8.4.38",
@ -14385,6 +14453,7 @@
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.92.1.tgz",
"integrity": "sha512-JECQ7IwJb+7fgUFBlrJzbyu3GEuNBcdqr1LD7IbSzwkSmIevTm8PF+wej3Oxuz/JFBUZ6O1o43zsPkwm1C4TmA==",
"dev": true,
"peer": true,
"dependencies": {
"@types/eslint-scope": "^3.7.3",
"@types/estree": "^1.0.5",
@ -14461,6 +14530,7 @@
"resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-5.0.4.tgz",
"integrity": "sha512-dljXhUgx3HqKP2d8J/fUMvhxGhzjeNVarDLcbO/EWMSgRizDkxHQDZQaLFL5VJY9tRBj2Gz+rvCEYYvhbqPHNA==",
"dev": true,
"peer": true,
"dependencies": {
"@types/bonjour": "^3.5.13",
"@types/connect-history-api-fallback": "^1.5.4",
@ -14650,6 +14720,7 @@
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"dev": true,
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0",
@ -15031,7 +15102,8 @@
"node_modules/zone.js": {
"version": "0.14.7",
"resolved": "https://registry.npmjs.org/zone.js/-/zone.js-0.14.7.tgz",
"integrity": "sha512-0w6DGkX2BPuiK/NLf+4A8FLE43QwBfuqz2dVgi/40Rj1WmqUskCqj329O/pwrqFJLG5X8wkeG2RhIAro441xtg=="
"integrity": "sha512-0w6DGkX2BPuiK/NLf+4A8FLE43QwBfuqz2dVgi/40Rj1WmqUskCqj329O/pwrqFJLG5X8wkeG2RhIAro441xtg==",
"peer": true
}
}
}

View File

@ -6,7 +6,11 @@
"start": "ng serve",
"build": "ng build",
"watch": "ng build --watch --configuration development",
"test": "ng test"
"test": "ng test",
"e2e": "playwright test",
"e2e:headed": "playwright test --headed --slow-mo=400",
"e2e:ui": "playwright test --ui",
"e2e:report": "playwright show-report"
},
"private": true,
"dependencies": {
@ -37,6 +41,7 @@
"@angular/compiler-cli": "^18.0.0",
"@angular/localize": "^18.1.0",
"@ngx-env/builder": "^18.0.1",
"@playwright/test": "^1.60.0",
"@types/jasmine": "~5.1.0",
"@types/papaparse": "^5.3.15",
"jasmine-core": "~5.1.0",

View File

@ -0,0 +1,33 @@
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
globalSetup: './e2e/global-setup.ts',
globalTeardown: './e2e/global-teardown.ts',
timeout: 30_000,
retries: 0,
workers: 1, // secuencial para no crear conflictos entre usuarios de test
reporter: [['list'], ['html', { outputFolder: 'playwright-report', open: 'never' }]],
use: {
baseURL: 'https://localhost:4200/oggui',
ignoreHTTPSErrors: true,
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},
webServer: {
command: 'npm start',
url: 'https://localhost:4200/oggui',
reuseExistingServer: true,
ignoreHTTPSErrors: true,
timeout: 120_000,
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
});

View File

@ -71,10 +71,10 @@ const routes: Routes = [
{ path: 'commands-task', component: CommandsTaskComponent, canActivate: [roleGuard], data: { allowedRoles: ['super-admin', 'ou-admin'] } },
{ path: 'commands-logs', component: TaskLogsComponent, canActivate: [roleGuard], data: { allowedRoles: ['super-admin', 'ou-admin'] } },
{ path: 'calendars', component: CalendarComponent, canActivate: [roleGuard], data: { allowedRoles: ['super-admin', 'ou-admin'] } },
{ path: 'clients/deploy-image', component: DeployImageComponent, canActivate: [roleGuard], data: { allowedRoles: ['super-admin', 'ou-admin'] } },
{ path: 'clients/partition-assistant', component: PartitionAssistantComponent, canActivate: [roleGuard], data: { allowedRoles: ['super-admin', 'ou-admin'] } },
{ path: 'clients/run-script', component: RunScriptAssistantComponent, canActivate: [roleGuard], data: { allowedRoles: ['super-admin', 'ou-admin'] } },
{ path: 'clients/:id/create-image', component: CreateClientImageComponent, canActivate: [roleGuard], data: { allowedRoles: ['super-admin', 'ou-admin'] } },
{ path: 'clients/deploy-image', component: DeployImageComponent, canActivate: [roleGuard], data: { allowedRoles: ['super-admin', 'ou-admin', 'ou-operator'] } },
{ path: 'clients/partition-assistant', component: PartitionAssistantComponent, canActivate: [roleGuard], data: { allowedRoles: ['super-admin', 'ou-admin', 'ou-operator'] } },
{ path: 'clients/run-script', component: RunScriptAssistantComponent, canActivate: [roleGuard], data: { allowedRoles: ['super-admin', 'ou-admin', 'ou-operator'] } },
{ path: 'clients/:id/create-image', component: CreateClientImageComponent, canActivate: [roleGuard], data: { allowedRoles: ['super-admin', 'ou-admin', 'ou-operator'] } },
{ path: 'repositories', component: RepositoriesComponent, canActivate: [roleGuard], data: { allowedRoles: ['super-admin', 'ou-admin'] } },
{ path: 'repository/:id', component: MainRepositoryViewComponent, canActivate: [roleGuard], data: { allowedRoles: ['super-admin', 'ou-admin'] } },
{ path: 'software', component: SoftwareComponent, canActivate: [roleGuard], data: { allowedRoles: ['super-admin', 'ou-admin'] } },

View File

@ -6,7 +6,7 @@
<h2 joyrideStep="titleStep" text="{{ 'titleStepText' | translate }}">{{ 'CommandsTitle' | translate }}</h2>
</div>
<div class="command-button-row" joyrideStep="addCommandStep" text="{{ 'addCommandStepText' | translate }}">
<button class="action-button" (click)="openCreateCommandModal()">{{ 'addCommand' | translate }}</button>
<button *ngIf="auth.isSuperAdmin" class="action-button" (click)="openCreateCommandModal()">{{ 'addCommand' | translate }}</button>
</div>
</div>
@ -44,8 +44,8 @@
<th mat-header-cell *matHeaderCellDef style="text-align: center;">{{ 'actions' | translate }}</th>
<td mat-cell *matCellDef="let command" style="text-align: center;" joyrideStep="actionsStep" text="{{ 'actionsStepText' | translate }}">
<button mat-icon-button color="info" (click)="viewDetails($event, command)"><mat-icon>visibility</mat-icon></button>
<button mat-icon-button color="primary" [disabled]="command.readOnly" (click)="editCommand($event, command)"><mat-icon>edit</mat-icon></button>
<button mat-icon-button color="warn" [disabled]="command.readOnly" (click)="deleteCommand($event, command)"><mat-icon>delete</mat-icon></button>
<button *ngIf="auth.isSuperAdmin" mat-icon-button color="primary" [disabled]="command.readOnly" (click)="editCommand($event, command)"><mat-icon>edit</mat-icon></button>
<button *ngIf="auth.isSuperAdmin" mat-icon-button color="warn" [disabled]="command.readOnly" (click)="deleteCommand($event, command)"><mat-icon>delete</mat-icon></button>
</td>
</ng-container>

View File

@ -10,6 +10,7 @@ import { DeleteModalComponent } from '../../../shared/delete_modal/delete-modal/
import { MatTableDataSource } from '@angular/material/table';
import { DatePipe } from '@angular/common';
import { ConfigService } from '@services/config.service';
import { AuthService } from '@services/auth.service';
import { JoyrideService } from 'ngx-joyride';
@Component({
@ -19,6 +20,7 @@ import { JoyrideService } from 'ngx-joyride';
})
export class CommandsComponent implements OnInit {
public translate = inject(TranslateService);
public auth = inject(AuthService);
baseUrl: string;
private apiUrl: string;
dataSource = new MatTableDataSource<any>();

View File

@ -1,6 +1,6 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { OgbootStatusComponent } from './ogboot-status.component';
import { GlobalOgbootStatusComponent as OgbootStatusComponent } from './ogboot-status.component';
describe('OgbootStatusComponent', () => {
let component: OgbootStatusComponent;

View File

@ -16,11 +16,11 @@
</div>
<div class="groups-button-row">
<div joyrideStep="addStep" text="{{ 'groupsAddStepText' | translate }}" style="display: flex; gap: 8px;">
<button class="action-button" (click)="addOU($event)" *ngIf="auth.userCategory !== 'ou-minimal'"
<button class="action-button" (click)="addOU($event)" *ngIf="auth.userCategory !== 'ou-minimal' && auth.userCategory !== 'ou-operator'"
matTooltip="{{ 'newOrganizationalUnitTooltip' | translate }}" matTooltipShowDelay="1000">
{{ 'newOrganizationalUnitButton' | translate }}
</button>
<button class="action-button" [matMenuTriggerFor]="menuClients" *ngIf="auth.userCategory !== 'ou-minimal'"
<button class="action-button" [matMenuTriggerFor]="menuClients" *ngIf="auth.userCategory !== 'ou-minimal' && auth.userCategory !== 'ou-operator'"
[disabled]="!hasOrganizationalUnits()"
matTooltip="{{ hasOrganizationalUnits() ? ('newClientButtonTooltip' | translate) : ('noOrganizationalUnitsTooltip' | translate) }}"
matTooltipShowDelay="1000">{{
@ -256,15 +256,15 @@
<mat-icon>map</mat-icon>
<span>{{ 'roomMap' | translate }}</span>
</button>
<button mat-menu-item (click)="addClient($event, selectedNode)" *ngIf="auth.userCategory !== 'ou-minimal'" [disabled]="!hasOrganizationalUnits()">
<button mat-menu-item (click)="addClient($event, selectedNode)" *ngIf="auth.userCategory !== 'ou-minimal' && auth.userCategory !== 'ou-operator'" [disabled]="!hasOrganizationalUnits()">
<mat-icon>add</mat-icon>
<span>{{ 'newSingleClientButton' | translate }}</span>
</button>
<button mat-menu-item (click)="addMultipleClients($event, selectedNode)" *ngIf="auth.userCategory !== 'ou-minimal'" [disabled]="!hasOrganizationalUnits()">
<button mat-menu-item (click)="addMultipleClients($event, selectedNode)" *ngIf="auth.userCategory !== 'ou-minimal' && auth.userCategory !== 'ou-operator'" [disabled]="!hasOrganizationalUnits()">
<mat-icon>playlist_add</mat-icon>
<span>{{ 'newMultipleClientButton' | translate }}</span>
</button>
<button mat-menu-item (click)="addOU($event, selectedNode)" *ngIf="auth.userCategory !== 'ou-minimal'">
<button mat-menu-item (click)="addOU($event, selectedNode)" *ngIf="auth.userCategory !== 'ou-minimal' && auth.userCategory !== 'ou-operator'">
<mat-icon>account_tree</mat-icon>
<span>{{ 'addOrganizationalUnit' | translate }}</span>
</button>

View File

@ -111,6 +111,8 @@ export class GroupsComponent implements OnInit, OnDestroy {
// Tipos de firmware disponibles
firmwareTypes: string[] = [];
private fetchClientsSubscription?: Subscription;
protected status = [
{ value: 'off', name: 'Apagado' },
{ value: 'initializing', name: 'Inicializando' },
@ -418,11 +420,9 @@ export class GroupsComponent implements OnInit, OnDestroy {
event.stopPropagation();
this.selectedNode = node;
const selectedClientsBeforeEdit = this.selection.selected.map(client => client.uuid);
this.fetchClientsForNode(node, selectedClientsBeforeEdit);
if (node.type === NodeType.Classroom || node.type === NodeType.ClientsGroup) {
this.getStatusForAllClientsInNode(node);
}
const checkStatusAfterLoad = node.type === NodeType.Classroom || node.type === NodeType.ClientsGroup;
this.fetchClientsForNode(node, selectedClientsBeforeEdit, checkStatusAfterLoad);
}
onMenuClick(event: Event, node: any): void {
@ -433,7 +433,9 @@ export class GroupsComponent implements OnInit, OnDestroy {
}
public fetchClientsForNode(node: any, selectedClientsBeforeEdit: string[] = []): void {
public fetchClientsForNode(node: any, selectedClientsBeforeEdit: string[] = [], checkStatusAfterLoad: boolean = false): void {
const params = new HttpParams({ fromObject: this.filters });
// Agregar parámetros de ordenamiento al backend
@ -442,8 +444,12 @@ export class GroupsComponent implements OnInit, OnDestroy {
backendParams['order[' + this.sortBy + ']'] = this.sortDirection;
}
if (this.fetchClientsSubscription) {
this.fetchClientsSubscription.unsubscribe();
}
this.isLoadingClients = true;
this.http.get<any>(`${this.baseUrl}/clients?organizationalUnit.id=${node.id}&page=${this.page + 1}&itemsPerPage=${this.itemsPerPage}`, { params: backendParams }).subscribe({
this.fetchClientsSubscription = this.http.get<any>(`${this.baseUrl}/clients?organizationalUnit.id=${node.id}&page=${this.page + 1}&itemsPerPage=${this.itemsPerPage}`, { params: backendParams }).subscribe({
next: (response: any) => {
let members = response['hydra:member'];
let total = response['hydra:totalItems'];
@ -476,6 +482,10 @@ export class GroupsComponent implements OnInit, OnDestroy {
members.forEach((c: Client) => c.uuid && this.syncingClientIds.add(c.uuid));
}
if (checkStatusAfterLoad) {
this.getStatusForAllClientsInNode(node);
}
// Calcular estadísticas después de cargar los clientes
this.calculateLocalStats();
},
@ -855,14 +865,19 @@ export class GroupsComponent implements OnInit, OnDestroy {
}
private checkStatusSubscription?: Subscription;
getStatusForAllClientsInNode(node: any): void {
if (!node.uuid) return;
if (this.checkStatusSubscription) {
this.checkStatusSubscription.unsubscribe();
}
this.isCheckingNodeStatus = true;
this.selectedClients.data.forEach(c => c.uuid && this.syncingClientIds.add(c.uuid));
this.subscriptions.add(
this.http.post<{ results: Array<{ uuid: string; ip: string; status: string; success: boolean }> }>(
this.checkStatusSubscription = this.http.post<{ results: Array<{ uuid: string; ip: string; status: string; success: boolean }> }>(
`${this.baseUrl}/organizational-units/${node.uuid}/agent/status`,
{}
).subscribe({
@ -883,8 +898,7 @@ export class GroupsComponent implements OnInit, OnDestroy {
this.selectedClients.data.forEach(c => c.uuid && this.syncingClientIds.delete(c.uuid));
this.toastr.error(this.translate.instant('errorCheckingStatus'));
},
})
);
});
}

View File

@ -34,5 +34,6 @@
</button>
<app-loading *ngIf="syncingUds" [isLoading]="syncingUds"></app-loading>
</ng-container>
<button class="ordinary-button" mat-dialog-close>{{ 'buttonCancel' | translate }}</button>
<button class="submit-button" (click)="saveDisposition()">{{ 'saveDispositionButton' | translate }}</button>
</mat-dialog-actions>

View File

@ -9,7 +9,7 @@
{{ 'ogbootServers' | translate }}</h2>
</div>
<div class="images-button-row">
<button
<button *ngIf="auth.isSuperAdmin"
class="action-button" (click)="addServer()"
joyrideStep="addStep"
text="Añadir Servidor Boot">{{ 'addBootServer' | translate }}</button>
@ -51,10 +51,10 @@
<button mat-icon-button color="primary" (click)="syncServer(server)" [matTooltip]="'syncServer' | translate">
<mat-icon>sync</mat-icon>
</button>
<button mat-icon-button color="primary" (click)="editServer($event, server)" [matTooltip]="'editServerTitle' | translate">
<button *ngIf="auth.isSuperAdmin" mat-icon-button color="primary" (click)="editServer($event, server)" [matTooltip]="'editServerTitle' | translate">
<mat-icon>edit</mat-icon>
</button>
<button mat-icon-button color="warn" (click)="deleteServer($event, server)" [matTooltip]="'buttonDeleteServer' | translate">
<button *ngIf="auth.isSuperAdmin" mat-icon-button color="warn" (click)="deleteServer($event, server)" [matTooltip]="'buttonDeleteServer' | translate">
<mat-icon>delete</mat-icon>
</button>
</td>

View File

@ -10,6 +10,7 @@ import {DeleteModalComponent} from "../../../shared/delete_modal/delete-modal/de
import { JoyrideService } from 'ngx-joyride';
import { Router } from '@angular/router';
import { ConfigService } from '@services/config.service';
import { AuthService } from '@services/auth.service';
import {ManageBootServerComponent} from "../manage-boot-server/manage-boot-server.component";
import { forkJoin } from 'rxjs';
@ -20,6 +21,7 @@ import { forkJoin } from 'rxjs';
})
export class BootServersComponent implements OnInit {
public translate = inject(TranslateService);
public auth = inject(AuthService);
baseUrl: string;
private apiUrl: string;
dataSource = new MatTableDataSource<any>();

View File

@ -9,7 +9,7 @@
{{ 'dhcpServers' | translate }}</h2>
</div>
<div class="images-button-row">
<button
<button *ngIf="auth.isSuperAdmin"
class="action-button" (click)="addServer()"
joyrideStep="addStep"
text="Añadir Servidor DHCP">{{ 'addDhcpServer' | translate }}</button>
@ -54,9 +54,9 @@
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef style="text-align: center;">{{ 'actions' | translate }}</th>
<td mat-cell *matCellDef="let server" style="text-align: center;" joyrideStep="actionsStep">
<button mat-icon-button color="primary" (click)="editServer($event, server)" [matTooltip]="'editServerTitle' | translate">
<button *ngIf="auth.isSuperAdmin" mat-icon-button color="primary" (click)="editServer($event, server)" [matTooltip]="'editServerTitle' | translate">
<mat-icon>edit</mat-icon></button>
<button mat-icon-button color="warn" (click)="deleteServer($event, server)" [matTooltip]="'buttonDeleteServer' | translate">
<button *ngIf="auth.isSuperAdmin" mat-icon-button color="warn" (click)="deleteServer($event, server)" [matTooltip]="'buttonDeleteServer' | translate">
<mat-icon>delete</mat-icon>
</button>
</td>

View File

@ -10,6 +10,7 @@ import {DeleteModalComponent} from "../../../shared/delete_modal/delete-modal/de
import { JoyrideService } from 'ngx-joyride';
import { Router } from '@angular/router';
import { ConfigService } from '@services/config.service';
import { AuthService } from '@services/auth.service';
import {ManageDhcpServerComponent} from "../manage-dhcp-server/manage-dhcp-server.component";
@Component({
@ -19,6 +20,7 @@ import {ManageDhcpServerComponent} from "../manage-dhcp-server/manage-dhcp-serve
})
export class DhcpServersComponent implements OnInit {
public translate = inject(TranslateService);
public auth = inject(AuthService);
baseUrl: string;
private apiUrl: string;
dataSource = new MatTableDataSource<any>();

View File

@ -10,7 +10,7 @@
</h2>
</div>
<div class="images-button-row">
<button
<button *ngIf="auth.isSuperAdmin"
class="action-button" (click)="addImage()"
joyrideStep="addStep"
text="{{ 'addRepositoryStepText' | translate }}">{{ 'addRepository' | translate }}</button>
@ -68,9 +68,9 @@
<th mat-header-cell *matHeaderCellDef i18n="@@columnActions" style="text-align: center;">{{ 'actions' | translate }}</th>
<td mat-cell *matCellDef="let repository" style="text-align: center;" joyrideStep="actionsStep"
[text]="'actionsDescription' | translate">
<button mat-icon-button color="primary" (click)="editRepository($event, repository)" i18n="@@editImage">
<button *ngIf="auth.isSuperAdmin" mat-icon-button color="primary" (click)="editRepository($event, repository)" i18n="@@editImage">
<mat-icon>edit</mat-icon></button>
<button mat-icon-button color="warn" (click)="deleteRepository($event, repository)">
<button *ngIf="auth.isSuperAdmin" mat-icon-button color="warn" (click)="deleteRepository($event, repository)">
<mat-icon i18n="@@deleteElementTooltip">delete</mat-icon>
</button>
</td>

View File

@ -10,6 +10,7 @@ import {DeleteModalComponent} from "../../shared/delete_modal/delete-modal/delet
import { JoyrideService } from 'ngx-joyride';
import { Router } from '@angular/router';
import { ConfigService } from '@services/config.service';
import { AuthService } from '@services/auth.service';
import {Subnet} from "../ogdhcp/og-dhcp-subnets.component";
import {ShowMonoliticImagesComponent} from "./show-monolitic-images/show-monolitic-images.component";
import {ShowGitCommitsComponent} from "./show-git-images/show-git-images.component";
@ -22,6 +23,7 @@ import {ManageRepositoryComponent} from "./manage-repository/manage-repository.c
})
export class RepositoriesComponent implements OnInit {
public translate = inject(TranslateService);
public auth = inject(AuthService);
baseUrl: string;
private apiUrl: string;
dataSource = new MatTableDataSource<any>();

View File

@ -1,17 +1,204 @@
import { TestBed } from '@angular/core/testing';
import { CanActivateFn } from '@angular/router';
import { ActivatedRouteSnapshot, CanActivateFn, Router, RouterStateSnapshot } from '@angular/router';
import { roleGuard } from './role.guard';
import { AuthService } from '@services/auth.service';
describe('roleGuard', () => {
const executeGuard: CanActivateFn = (...guardParameters) =>
TestBed.runInInjectionContext(() => roleGuard(...guardParameters));
// ─────────────────────────────────────────────────────────────────────────────
// Helper: ejecuta el guard en el contexto de inyección de Angular
// ─────────────────────────────────────────────────────────────────────────────
function runGuard(allowedRoles: string[], currentRole: string): boolean | any {
const route = { data: { allowedRoles } } as unknown as ActivatedRouteSnapshot;
const state = {} as RouterStateSnapshot;
const execute: CanActivateFn = (...args) =>
TestBed.runInInjectionContext(() => roleGuard(...args));
return execute(route, state);
}
describe('roleGuard — control de acceso por rol', () => {
let mockRouter: jasmine.SpyObj<Router>;
let mockAuth: jasmine.SpyObj<AuthService>;
beforeEach(() => {
TestBed.configureTestingModule({});
mockRouter = jasmine.createSpyObj('Router', ['navigate']);
mockAuth = jasmine.createSpyObj('AuthService', ['getCurrentRole']);
TestBed.configureTestingModule({
providers: [
{ provide: Router, useValue: mockRouter },
{ provide: AuthService, useValue: mockAuth },
],
});
});
it('should be created', () => {
expect(executeGuard).toBeTruthy();
// ── Rutas solo para super-admin ────────────────────────────────────────────
describe('rutas solo para super-admin [users, roles, env-vars, import-legacy]', () => {
const onlySuperAdmin = ['super-admin'];
it('super-admin puede acceder', () => {
mockAuth.getCurrentRole.and.returnValue('super-admin');
expect(runGuard(onlySuperAdmin, 'super-admin')).toBeTrue();
});
it('ou-admin NO puede acceder → redirige a /groups', () => {
mockAuth.getCurrentRole.and.returnValue('ou-admin');
expect(runGuard(onlySuperAdmin, 'ou-admin')).toBeFalse();
expect(mockRouter.navigate).toHaveBeenCalledWith(['/groups']);
});
it('ou-operator NO puede acceder', () => {
mockAuth.getCurrentRole.and.returnValue('ou-operator');
expect(runGuard(onlySuperAdmin, 'ou-operator')).toBeFalse();
});
it('ou-minimal NO puede acceder', () => {
mockAuth.getCurrentRole.and.returnValue('ou-minimal');
expect(runGuard(onlySuperAdmin, 'ou-minimal')).toBeFalse();
});
});
// ── Rutas para super-admin + ou-admin ──────────────────────────────────────
describe('rutas para super-admin + ou-admin [commands, pxe, repositorios, etc.]', () => {
const adminRoutes = ['super-admin', 'ou-admin'];
it('super-admin puede acceder', () => {
mockAuth.getCurrentRole.and.returnValue('super-admin');
expect(runGuard(adminRoutes, 'super-admin')).toBeTrue();
});
it('ou-admin puede acceder', () => {
mockAuth.getCurrentRole.and.returnValue('ou-admin');
expect(runGuard(adminRoutes, 'ou-admin')).toBeTrue();
});
it('ou-operator NO puede acceder → redirige a /groups', () => {
mockAuth.getCurrentRole.and.returnValue('ou-operator');
expect(runGuard(adminRoutes, 'ou-operator')).toBeFalse();
expect(mockRouter.navigate).toHaveBeenCalledWith(['/groups']);
});
it('ou-minimal NO puede acceder', () => {
mockAuth.getCurrentRole.and.returnValue('ou-minimal');
expect(runGuard(adminRoutes, 'ou-minimal')).toBeFalse();
});
});
// ── Rutas para super-admin + ou-admin + ou-operator ────────────────────────
describe('rutas para admin + operador [deploy-image, partition-assistant, run-script]', () => {
const operatorRoutes = ['super-admin', 'ou-admin', 'ou-operator'];
it('super-admin puede acceder', () => {
mockAuth.getCurrentRole.and.returnValue('super-admin');
expect(runGuard(operatorRoutes, 'super-admin')).toBeTrue();
});
it('ou-admin puede acceder', () => {
mockAuth.getCurrentRole.and.returnValue('ou-admin');
expect(runGuard(operatorRoutes, 'ou-admin')).toBeTrue();
});
it('ou-operator puede acceder', () => {
mockAuth.getCurrentRole.and.returnValue('ou-operator');
expect(runGuard(operatorRoutes, 'ou-operator')).toBeTrue();
});
it('ou-minimal NO puede acceder → redirige a /groups', () => {
mockAuth.getCurrentRole.and.returnValue('ou-minimal');
expect(runGuard(operatorRoutes, 'ou-minimal')).toBeFalse();
expect(mockRouter.navigate).toHaveBeenCalledWith(['/groups']);
});
});
// ── Verificación de redirección coherente ─────────────────────────────────
describe('redirección al acceder a rutas no permitidas', () => {
it('siempre redirige a /groups cuando el acceso es denegado', () => {
mockAuth.getCurrentRole.and.returnValue('ou-minimal');
runGuard(['super-admin'], 'ou-minimal');
expect(mockRouter.navigate).toHaveBeenCalledWith(['/groups']);
});
it('NO redirige cuando el acceso es permitido', () => {
mockAuth.getCurrentRole.and.returnValue('super-admin');
runGuard(['super-admin'], 'super-admin');
expect(mockRouter.navigate).not.toHaveBeenCalled();
});
});
// ── Matriz completa de rutas de la aplicación ──────────────────────────────
describe('matriz de acceso — todas las rutas protegidas de la aplicación', () => {
interface RouteCase {
path: string;
allowedRoles: string[];
}
const routes: RouteCase[] = [
// solo super-admin
{ path: 'users', allowedRoles: ['super-admin'] },
{ path: 'env-vars', allowedRoles: ['super-admin'] },
{ path: 'roles', allowedRoles: ['super-admin'] },
{ path: 'import-legacy', allowedRoles: ['super-admin'] },
// super-admin + ou-admin
{ path: 'commands', allowedRoles: ['super-admin', 'ou-admin'] },
{ path: 'commands-task', allowedRoles: ['super-admin', 'ou-admin'] },
{ path: 'commands-logs', allowedRoles: ['super-admin', 'ou-admin'] },
{ path: 'pxe-images', allowedRoles: ['super-admin', 'ou-admin'] },
{ path: 'pxe', allowedRoles: ['super-admin', 'ou-admin'] },
{ path: 'pxe-boot-file', allowedRoles: ['super-admin', 'ou-admin'] },
{ path: 'ogboot-status', allowedRoles: ['super-admin', 'ou-admin'] },
{ path: 'dhcp-servers', allowedRoles: ['super-admin', 'ou-admin'] },
{ path: 'boot-servers', allowedRoles: ['super-admin', 'ou-admin'] },
{ path: 'calendars', allowedRoles: ['super-admin', 'ou-admin'] },
{ path: 'repositories', allowedRoles: ['super-admin', 'ou-admin'] },
{ path: 'software', allowedRoles: ['super-admin', 'ou-admin'] },
{ path: 'software-profiles', allowedRoles: ['super-admin', 'ou-admin'] },
{ path: 'operative-systems', allowedRoles: ['super-admin', 'ou-admin'] },
{ path: 'menus', allowedRoles: ['super-admin', 'ou-admin'] },
{ path: 'hardware', allowedRoles: ['super-admin', 'ou-admin'] },
{ path: 'hardware-profiles', allowedRoles: ['super-admin', 'ou-admin'] },
{ path: 'hardware-types', allowedRoles: ['super-admin', 'ou-admin'] },
// super-admin + ou-admin + ou-operator
{ path: 'clients/deploy-image', allowedRoles: ['super-admin', 'ou-admin', 'ou-operator'] },
{ path: 'clients/partition-assistant',allowedRoles: ['super-admin', 'ou-admin', 'ou-operator'] },
{ path: 'clients/run-script', allowedRoles: ['super-admin', 'ou-admin', 'ou-operator'] },
];
const rolesTested: Array<{ role: string; canAccess: (r: RouteCase) => boolean }> = [
{
role: 'super-admin',
canAccess: () => true, // puede acceder a todo
},
{
role: 'ou-admin',
canAccess: r => r.allowedRoles.includes('ou-admin'),
},
{
role: 'ou-operator',
canAccess: r => r.allowedRoles.includes('ou-operator'),
},
{
role: 'ou-minimal',
canAccess: () => false, // no puede acceder a ninguna ruta protegida
},
];
rolesTested.forEach(({ role, canAccess }) => {
routes.forEach(route => {
const shouldAccess = canAccess(route);
const label = shouldAccess
? `${role} PUEDE acceder a /${route.path}`
: `${role} NO PUEDE acceder a /${route.path}`;
it(label, () => {
mockAuth.getCurrentRole.and.returnValue(role);
const result = runGuard(route.allowedRoles, role);
if (shouldAccess) {
expect(result).toBeTrue();
} else {
expect(result).toBeFalse();
expect(mockRouter.navigate).toHaveBeenCalledWith(['/groups']);
}
mockRouter.navigate.calls.reset();
});
});
});
});
});

View File

@ -0,0 +1,172 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { RouterTestingModule } from '@angular/router/testing';
import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import { MatDialogModule } from '@angular/material/dialog';
import { TranslateModule } from '@ngx-translate/core';
import { SidebarComponent } from './sidebar.component';
import { AuthService, UserCategory } from '@services/auth.service';
// ─────────────────────────────────────────────────────────────────────────────
// Rutas con condiciones de visibilidad (según sidebar.component.html)
// ─────────────────────────────────────────────────────────────────────────────
/** Siempre visibles para todos los roles autenticados */
const ALWAYS_VISIBLE = ['/groups'];
/** Solo visibles para super-admin y ou-admin (ocultos para operator y minimal) */
const ADMIN_ONLY_ROUTES = [
'/dhcp-servers',
'/boot-servers',
'/calendars',
'/repositories',
'/menus',
];
// ─────────────────────────────────────────────────────────────────────────────
// Helper: cuenta mat-list-item visibles en el DOM
// ─────────────────────────────────────────────────────────────────────────────
function countVisibleListItems(fixture: ComponentFixture<SidebarComponent>): number {
return fixture.debugElement.queryAll(By.css('mat-list-item')).length;
}
/** Devuelve true si existe algún mat-list-item con [routerLink] apuntando a la ruta dada */
function hasRouteLink(fixture: ComponentFixture<SidebarComponent>, route: string): boolean {
const items = fixture.debugElement.queryAll(By.css(`[routerLink="${route}"]`));
return items.length > 0;
}
// ─────────────────────────────────────────────────────────────────────────────
// Mock de AuthService
// ─────────────────────────────────────────────────────────────────────────────
function mockAuthService(category: UserCategory) {
return {
userCategory: category,
username: 'test-user',
isSuperAdmin: category === 'super-admin',
isOUAdmin: category === 'ou-admin',
isOUOperator: category === 'ou-operator',
isOUMinimal: category === 'ou-minimal',
getCurrentRole: () => category,
};
}
// ─────────────────────────────────────────────────────────────────────────────
// Suite principal
// ─────────────────────────────────────────────────────────────────────────────
describe('SidebarComponent — visibilidad de elementos por rol', () => {
let fixture: ComponentFixture<SidebarComponent>;
let component: SidebarComponent;
async function createComponent(category: UserCategory) {
await TestBed.configureTestingModule({
declarations: [SidebarComponent],
imports: [
RouterTestingModule,
MatDialogModule,
TranslateModule.forRoot(),
],
providers: [
{ provide: AuthService, useValue: mockAuthService(category) },
],
schemas: [CUSTOM_ELEMENTS_SCHEMA], // ignora mat-* desconocidos
}).compileComponents();
fixture = TestBed.createComponent(SidebarComponent);
component = fixture.componentInstance;
fixture.detectChanges();
}
// ── Grupos — siempre visible ───────────────────────────────────────────────
describe('/groups — siempre visible para cualquier rol', () => {
(['super-admin', 'ou-admin', 'ou-operator', 'ou-minimal'] as UserCategory[]).forEach(role => {
it(`${role} ve el enlace a /groups`, async () => {
await createComponent(role);
expect(hasRouteLink(fixture, '/groups')).toBeTrue();
});
});
});
// ── Super-admin ve todo ────────────────────────────────────────────────────
describe('super-admin — ve todos los elementos del sidebar', () => {
beforeEach(async () => createComponent('super-admin'));
ADMIN_ONLY_ROUTES.forEach(route => {
it(`ve el enlace a ${route}`, () => {
expect(hasRouteLink(fixture, route)).toBeTrue();
});
});
it('ve el menú de acciones (commands)', () => {
// El parent de commands es un mat-list-item sin routerLink pero con el ngIf
const items = fixture.debugElement.queryAll(By.css('mat-list-item'));
expect(items.length).toBeGreaterThan(1);
});
});
// ── Ou-admin ve todo ───────────────────────────────────────────────────────
describe('ou-admin — ve todos los elementos del sidebar', () => {
beforeEach(async () => createComponent('ou-admin'));
ADMIN_ONLY_ROUTES.forEach(route => {
it(`ve el enlace a ${route}`, () => {
expect(hasRouteLink(fixture, route)).toBeTrue();
});
});
});
// ── Ou-operator: solo /groups ──────────────────────────────────────────────
describe('ou-operator — solo ve /groups', () => {
beforeEach(async () => createComponent('ou-operator'));
it('ve /groups', () => {
expect(hasRouteLink(fixture, '/groups')).toBeTrue();
});
ADMIN_ONLY_ROUTES.forEach(route => {
it(`NO ve el enlace a ${route}`, () => {
expect(hasRouteLink(fixture, route)).toBeFalse();
});
});
it('NO ve el menú de acciones (commands)', () => {
// Solo debe haber 1 mat-list-item visible: /groups
expect(countVisibleListItems(fixture)).toBe(1);
});
});
// ── Ou-minimal: solo /groups ───────────────────────────────────────────────
describe('ou-minimal — solo ve /groups', () => {
beforeEach(async () => createComponent('ou-minimal'));
it('ve /groups', () => {
expect(hasRouteLink(fixture, '/groups')).toBeTrue();
});
ADMIN_ONLY_ROUTES.forEach(route => {
it(`NO ve el enlace a ${route}`, () => {
expect(hasRouteLink(fixture, route)).toBeFalse();
});
});
it('NO ve el menú de acciones (commands)', () => {
expect(countVisibleListItems(fixture)).toBe(1);
});
});
// ── Comparativa admin vs operator ─────────────────────────────────────────
describe('comparativa: ou-admin ve más items que ou-operator', () => {
it('ou-admin tiene más mat-list-items visibles que ou-operator', async () => {
await createComponent('ou-admin');
const adminCount = countVisibleListItems(fixture);
TestBed.resetTestingModule();
await createComponent('ou-operator');
const operatorCount = countVisibleListItems(fixture);
expect(adminCount).toBeGreaterThan(operatorCount);
});
});
});

View File

@ -0,0 +1,162 @@
import { TestBed } from '@angular/core/testing';
import { Router } from '@angular/router';
import { AuthService } from './auth.service';
import { TranslateModule } from '@ngx-translate/core';
// ─────────────────────────────────────────────────────────────────────────────
// Helpers
// ─────────────────────────────────────────────────────────────────────────────
/** Genera un JWT falso con los roles indicados (sin firma real, solo para tests) */
function makeToken(roles: string[], username = 'test-user'): string {
const encode = (obj: object) =>
btoa(JSON.stringify(obj))
.replace(/=/g, '')
.replace(/\+/g, '-')
.replace(/\//g, '_');
const header = encode({ alg: 'RS256', typ: 'JWT' });
const payload = encode({
roles,
username,
id: 1,
uuid: 'test-uuid',
groupsView: 'card',
clientsPerPage: null,
iat: 1000000,
exp: 9999999999,
});
return `${header}.${payload}.fakesig`;
}
const TOKEN_SUPER_ADMIN = makeToken(['ROLE_SUPER_ADMIN', 'ROLE_USER']);
const TOKEN_OU_ADMIN = makeToken(['ROLE_ORGANIZATIONAL_UNIT_ADMIN', 'ROLE_USER']);
const TOKEN_OPERATOR = makeToken(['ROLE_ORGANIZATIONAL_UNIT_OPERATOR', 'ROLE_USER']);
const TOKEN_MINIMAL = makeToken(['ROLE_ORGANIZATIONAL_UNIT_MINIMAL', 'ROLE_USER']);
const TOKEN_USER = makeToken(['ROLE_USER']);
// ─────────────────────────────────────────────────────────────────────────────
// Helpers de setup
// ─────────────────────────────────────────────────────────────────────────────
function buildService(token: string | null): AuthService {
if (token) {
localStorage.setItem('loginToken', token);
} else {
localStorage.removeItem('loginToken');
}
// Fuerza reload del token en la nueva instancia
const svc = TestBed.inject(AuthService);
svc.refresh();
return svc;
}
// ─────────────────────────────────────────────────────────────────────────────
// Suite principal
// ─────────────────────────────────────────────────────────────────────────────
describe('AuthService — detección de roles', () => {
let service: AuthService;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot()],
providers: [
AuthService,
{ provide: Router, useValue: { navigate: jasmine.createSpy('navigate'), url: '/groups' } },
],
});
});
afterEach(() => {
localStorage.removeItem('loginToken');
});
// ── Sin token ──────────────────────────────────────────────────────────────
describe('sin token almacenado', () => {
beforeEach(() => { service = buildService(null); });
it('isSuperAdmin es false', () => expect(service.isSuperAdmin).toBeFalse());
it('isOUAdmin es false', () => expect(service.isOUAdmin).toBeFalse());
it('isOUOperator es false', () => expect(service.isOUOperator).toBeFalse());
it('isOUMinimal es false', () => expect(service.isOUMinimal).toBeFalse());
it('userCategory es "user"',() => expect(service.userCategory).toBe('user'));
it('getCurrentRole es "user"',()=> expect(service.getCurrentRole()).toBe('user'));
it('username es null', () => expect(service.username).toBeNull());
});
// ── ROLE_SUPER_ADMIN ───────────────────────────────────────────────────────
describe('token con ROLE_SUPER_ADMIN', () => {
beforeEach(() => { service = buildService(TOKEN_SUPER_ADMIN); });
it('isSuperAdmin es true', () => expect(service.isSuperAdmin).toBeTrue());
it('isOUAdmin es false', () => expect(service.isOUAdmin).toBeFalse());
it('isOUOperator es false', () => expect(service.isOUOperator).toBeFalse());
it('isOUMinimal es false', () => expect(service.isOUMinimal).toBeFalse());
it('userCategory es "super-admin"', () => expect(service.userCategory).toBe('super-admin'));
it('getCurrentRole es "super-admin"',() => expect(service.getCurrentRole()).toBe('super-admin'));
});
// ── ROLE_ORGANIZATIONAL_UNIT_ADMIN ─────────────────────────────────────────
describe('token con ROLE_ORGANIZATIONAL_UNIT_ADMIN', () => {
beforeEach(() => { service = buildService(TOKEN_OU_ADMIN); });
it('isSuperAdmin es false', () => expect(service.isSuperAdmin).toBeFalse());
it('isOUAdmin es true', () => expect(service.isOUAdmin).toBeTrue());
it('isOUOperator es false', () => expect(service.isOUOperator).toBeFalse());
it('isOUMinimal es false', () => expect(service.isOUMinimal).toBeFalse());
it('userCategory es "ou-admin"', () => expect(service.userCategory).toBe('ou-admin'));
it('getCurrentRole es "ou-admin"',() => expect(service.getCurrentRole()).toBe('ou-admin'));
});
// ── ROLE_ORGANIZATIONAL_UNIT_OPERATOR ─────────────────────────────────────
describe('token con ROLE_ORGANIZATIONAL_UNIT_OPERATOR', () => {
beforeEach(() => { service = buildService(TOKEN_OPERATOR); });
it('isSuperAdmin es false', () => expect(service.isSuperAdmin).toBeFalse());
it('isOUAdmin es false', () => expect(service.isOUAdmin).toBeFalse());
it('isOUOperator es true', () => expect(service.isOUOperator).toBeTrue());
it('isOUMinimal es false', () => expect(service.isOUMinimal).toBeFalse());
it('userCategory es "ou-operator"', () => expect(service.userCategory).toBe('ou-operator'));
it('getCurrentRole es "ou-operator"',() => expect(service.getCurrentRole()).toBe('ou-operator'));
});
// ── ROLE_ORGANIZATIONAL_UNIT_MINIMAL ──────────────────────────────────────
describe('token con ROLE_ORGANIZATIONAL_UNIT_MINIMAL', () => {
beforeEach(() => { service = buildService(TOKEN_MINIMAL); });
it('isSuperAdmin es false', () => expect(service.isSuperAdmin).toBeFalse());
it('isOUAdmin es false', () => expect(service.isOUAdmin).toBeFalse());
it('isOUOperator es false', () => expect(service.isOUOperator).toBeFalse());
it('isOUMinimal es true', () => expect(service.isOUMinimal).toBeTrue());
it('userCategory es "ou-minimal"', () => expect(service.userCategory).toBe('ou-minimal'));
it('getCurrentRole es "ou-minimal"',() => expect(service.getCurrentRole()).toBe('ou-minimal'));
});
// ── ROLE_USER (sin rol elevado) ────────────────────────────────────────────
describe('token con solo ROLE_USER', () => {
beforeEach(() => { service = buildService(TOKEN_USER); });
it('todos los flags de rol son false', () => {
expect(service.isSuperAdmin).toBeFalse();
expect(service.isOUAdmin).toBeFalse();
expect(service.isOUOperator).toBeFalse();
expect(service.isOUMinimal).toBeFalse();
});
it('userCategory es "user"', () => expect(service.userCategory).toBe('user'));
it('getCurrentRole es "user"', () => expect(service.getCurrentRole()).toBe('user'));
});
// ── Prioridad de roles ─────────────────────────────────────────────────────
describe('prioridad cuando hay múltiples roles en el token', () => {
it('super-admin tiene prioridad sobre ou-admin', () => {
service = buildService(makeToken(['ROLE_SUPER_ADMIN', 'ROLE_ORGANIZATIONAL_UNIT_ADMIN']));
expect(service.userCategory).toBe('super-admin');
});
it('ou-admin tiene prioridad sobre ou-operator', () => {
service = buildService(makeToken(['ROLE_ORGANIZATIONAL_UNIT_ADMIN', 'ROLE_ORGANIZATIONAL_UNIT_OPERATOR']));
expect(service.userCategory).toBe('ou-admin');
});
});
});

View File

@ -1,98 +0,0 @@
<<<<<<< Updated upstream
<?xml version="1.0"?>
<testsuite name="Chrome Headless 131.0.0.0 (Linux x86_64)" package="" timestamp="2024-12-09T10:14:38" id="0" hostname="alvaro-Latitude-3420" tests="80" errors="0" failures="0" time="2.257">
<properties>
<property name="browser.fullName" value="Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/131.0.0.0 Safari/537.36"/>
</properties>
<testcase name="CreateSoftwareComponent should create" time="0.12" classname="CreateSoftwareComponent"/>
<testcase name="CommandsComponent should create" time="0.104" classname="CommandsComponent"/>
<testcase name="OgbootStatusComponent should create the component" time="0.054" classname="OgbootStatusComponent"/>
<testcase name="PxeComponent should have a defined currentPage" time="0.069" classname="PxeComponent"/>
<testcase name="PxeComponent should have a defined itemsPerPage" time="0.041" classname="PxeComponent"/>
<testcase name="PxeComponent should have a defined component" time="0.035" classname="PxeComponent"/>
<testcase name="PxeComponent should have a defined loading" time="0.037" classname="PxeComponent"/>
<testcase name="PxeComponent should have a defined page" time="0.036" classname="PxeComponent"/>
<testcase name="PxeComponent should have a defined selectedElements" time="0.032" classname="PxeComponent"/>
<testcase name="PxeComponent should have a defined pageSizeOptions" time="0.034" classname="PxeComponent"/>
<testcase name="PxeComponent should have a defined selectedItem" time="0.031" classname="PxeComponent"/>
<testcase name="PxeComponent should have a defined length" time="0.029" classname="PxeComponent"/>
<testcase name="PxeComponent should have a defined datePipe" time="0.028" classname="PxeComponent"/>
<testcase name="PxeComponent should have a defined dataSource" time="0.029" classname="PxeComponent"/>
<testcase name="PxeComponent should have a defined baseUrl" time="0.029" classname="PxeComponent"/>
<testcase name="PxeComponent should have a defined alertMessage" time="0.025" classname="PxeComponent"/>
<testcase name="PxeComponent should have a defined pxeTemplates" time="0.027" classname="PxeComponent"/>
<testcase name="PxeComponent should have a defined filters" time="0.029" classname="PxeComponent"/>
<testcase name="PxeComponent should create the component" time="0.025" classname="PxeComponent"/>
<testcase name="AdminComponent debería renderizar dos botones" time="0.012" classname="AdminComponent"/>
<testcase name="AdminComponent debería tener un botón con routerLink a &quot;/users&quot;" time="0.005" classname="AdminComponent"/>
<testcase name="AdminComponent debería aplicar la clase &quot;fab-button&quot; a ambos botones" time="0.005" classname="AdminComponent"/>
<testcase name="AdminComponent debería crear el componente" time="0.006" classname="AdminComponent"/>
<testcase name="CreateOperativeSystemComponent should create" time="0.017" classname="CreateOperativeSystemComponent"/>
<testcase name="PxeBootFilesComponent should create" time="0.048" classname="PxeBootFilesComponent"/>
<testcase name="CalendarComponent should create" time="0.05" classname="CalendarComponent"/>
<testcase name="CreateRepositoryComponent should create" time="0.018" classname="CreateRepositoryComponent"/>
<testcase name="PXEimagesComponent should create" time="0.077" classname="PXEimagesComponent"/>
<testcase name="SoftwareComponent should create" time="0.055" classname="SoftwareComponent"/>
<testcase name="CreateCommandComponent should create" time="0.033" classname="CreateCommandComponent"/>
<testcase name="AppComponent should default to Spanish if no language is saved in localStorage" time="0.01" classname="AppComponent"/>
<testcase name="AppComponent should create the app" time="0.003" classname="AppComponent"/>
<testcase name="AppComponent should set language to Spanish in sessionStorage on ngOnInit" time="0.002" classname="AppComponent"/>
<testcase name="AppComponent should have as title 'ogWebconsole'" time="0.003" classname="AppComponent"/>
<testcase name="AppComponent should set the language from localStorage on creation" time="0.003" classname="AppComponent"/>
<testcase name="DeployImageComponent should create" time="0.08" classname="DeployImageComponent"/>
<testcase name="CreateSoftwareProfileComponent should create" time="0.085" classname="CreateSoftwareProfileComponent"/>
<testcase name="OgDhcpSubnetsComponent should create" time="0.078" classname="OgDhcpSubnetsComponent"/>
<testcase name="OgDhcpSubnetsComponent should call syncSubnets and handle success" time="0.044" classname="OgDhcpSubnetsComponent"/>
<testcase name="SoftwareProfileComponent should create" time="0.051" classname="SoftwareProfileComponent"/>
<testcase name="RolesComponent should create" time="0.021" classname="RolesComponent"/>
<testcase name="RolesComponent should initialize the dataSource" time="0.005" classname="RolesComponent"/>
<testcase name="RolesComponent should have a defined columns array" time="0.005" classname="RolesComponent"/>
<testcase name="RolesComponent should have a default itemsPerPage value" time="0.007" classname="RolesComponent"/>
<testcase name="EnvVarsComponent should create" time="0.019" classname="EnvVarsComponent"/>
<testcase name="MainRepositoryViewComponent should create" time="0.071" classname="MainRepositoryViewComponent"/>
<testcase name="StatusComponent should create" time="0.022" classname="StatusComponent"/>
<testcase name="CommandsTaskComponent should create" time="0.039" classname="CommandsTaskComponent"/>
<testcase name="LoginComponent should add rotating class to the logo when loading" time="0.025" classname="LoginComponent"/>
<testcase name="LoginComponent should create" time="0.01" classname="LoginComponent"/>
<testcase name="LoginComponent should add &quot;invalid&quot; class to username input if it is invalid and touched" time="0.011" classname="LoginComponent"/>
<testcase name="LoginComponent should show a success toast message" time="0.01" classname="LoginComponent"/>
<testcase name="LoginComponent should not add rotating class to the logo when not loading" time="0.01" classname="LoginComponent"/>
<testcase name="LoginComponent should show error message on login failure" time="0.01" classname="LoginComponent"/>
<testcase name="LoginComponent should toggle password visibility when clicking the button" time="0.01" classname="LoginComponent"/>
<testcase name="LoginComponent should disable the login button if username or password is missing" time="0.01" classname="LoginComponent"/>
<testcase name="LoginComponent should change language to English and save to localStorage" time="0.009" classname="LoginComponent"/>
<testcase name="LoginComponent should call onLogin and navigate on successful login" time="0.012" classname="LoginComponent"/>
<testcase name="LoginComponent should enable the login button if username and password are present" time="0.01" classname="LoginComponent"/>
<testcase name="LoginComponent should change language to Spanish and save to localStorage" time="0.01" classname="LoginComponent"/>
<testcase name="LoginComponent should show an error toast message" time="0.01" classname="LoginComponent"/>
<testcase name="ExecuteCommandComponent should create" time="0.025" classname="ExecuteCommandComponent"/>
<testcase name="CreateImageComponent should create" time="0.025" classname="CreateImageComponent"/>
<testcase name="OgdhcpComponent should create" time="0.005" classname="OgdhcpComponent"/>
<testcase name="DashboardComponent should create the component" time="0.006" classname="DashboardComponent"/>
<testcase name="GroupsComponent should call getFilters on ngOnInit" time="0.141" classname="GroupsComponent"/>
<testcase name="GroupsComponent should call search on ngOnInit" time="0.011" classname="GroupsComponent"/>
<testcase name="GroupsComponent should call getFilters method" time="0.014" classname="GroupsComponent"/>
<testcase name="GroupsComponent should call onSelectUnidad method" time="0.009" classname="GroupsComponent"/>
<testcase name="GroupsComponent should call onTabChange method" time="0.011" classname="GroupsComponent"/>
<testcase name="GroupsComponent should create" time="0.013" classname="GroupsComponent"/>
<testcase name="GroupsComponent should call search method" time="0.011" classname="GroupsComponent"/>
<testcase name="ServerInfoDialogComponent should create" time="0.013" classname="ServerInfoDialogComponent"/>
<testcase name="UsersComponent should call search on init" time="0.032" classname="UsersComponent"/>
<testcase name="UsersComponent should have default values for pagination" time="0.006" classname="UsersComponent"/>
<testcase name="UsersComponent should create" time="0.008" classname="UsersComponent"/>
<testcase name="UsersComponent should define displayedColumns" time="0.007" classname="UsersComponent"/>
<testcase name="UsersComponent should initialize the dataSource" time="0.007" classname="UsersComponent"/>
<testcase name="OperativeSystemComponent should create" time="0.041" classname="OperativeSystemComponent"/>
<testcase name="RepositoriesComponent should create" time="0.037" classname="RepositoriesComponent"/>
<system-out>
<![CDATA[Chrome Headless 131.0.0.0 (Linux x86_64) ERROR: 'Error fetching organizational units:', HttpErrorResponse{headers: HttpHeaders{normalizedNames: Map{}, lazyUpdate: null, headers: Map{}}, status: 0, statusText: 'Unknown Error', url: 'https://127.0.0.1:8443/organizational-units?type=classroom&page=1&itemsPerPage=50', ok: false, name: 'HttpErrorResponse', message: 'Http failure response for https://127.0.0.1:8443/organizational-units?type=classroom&page=1&itemsPerPage=50: 0 Unknown Error', error: ProgressEvent{isTrusted: true}}
,Chrome Headless 131.0.0.0 (Linux x86_64) ERROR: 'Error al sincronizar', HttpErrorResponse{headers: HttpHeaders{normalizedNames: Map{}, lazyUpdate: null, headers: Map{}}, status: 0, statusText: 'Unknown Error', url: 'https://127.0.0.1:8443/og-lives/sync', ok: false, name: 'HttpErrorResponse', message: 'Http failure response for https://127.0.0.1:8443/og-lives/sync: 0 Unknown Error', error: ProgressEvent{isTrusted: true}}
,Chrome Headless 131.0.0.0 (Linux x86_64) ERROR: 'Error fetching images', HttpErrorResponse{headers: HttpHeaders{normalizedNames: Map{}, lazyUpdate: null, headers: Map{}}, status: 0, statusText: 'Unknown Error', url: 'https://127.0.0.1:8443/og-lives?page=1&itemsPerPage=1000', ok: false, name: 'HttpErrorResponse', message: 'Http failure response for https://127.0.0.1:8443/og-lives?page=1&itemsPerPage=1000: 0 Unknown Error', error: ProgressEvent{isTrusted: true}}
,Chrome Headless 131.0.0.0 (Linux x86_64) ERROR: 'Error fetching og lives', HttpErrorResponse{headers: HttpHeaders{normalizedNames: Map{}, lazyUpdate: null, headers: Map{}}, status: 0, statusText: 'Unknown Error', url: 'https://127.0.0.1:8443/og-lives?page=1&itemsPerPage=1000', ok: false, name: 'HttpErrorResponse', message: 'Http failure response for https://127.0.0.1:8443/og-lives?page=1&itemsPerPage=1000: 0 Unknown Error', error: ProgressEvent{isTrusted: true}}
]]>
</system-out>
<system-err/>
</testsuite>
=======
>>>>>>> Stashed changes