Dos huecos de diagnóstico que hacían pasar por sano lo que no lo estaba. 1. El check `config` usaba `test -f`, que comprueba existencia y no lectura, así que salía verde exactamente en el caso roto: una instalación con sudo deja config/.env en 0600 de root mientras el unit corre como una cuenta común, que no puede leerlo. El agente no arranca y la pantalla decía "Configuración presente: ok". Ahora se comprueba lectura y, además, el dueño frente al User= del unit — porque la sonda entra con la cuenta SSH, que no siempre es la del servicio, y un `test -r` desde la sesión no responde por el agente. El check se renombra a "Configuración legible por el servicio", que es lo que de verdad mide, y falla nombrando a los dos usuarios para que el remedio sea obvio. El cálculo del bit de lectura octal sale a una función pura probada: 0600 no deja leer a nadie más que al dueño, se mira el bit 4 y no el valor (620 y 611 no son lectura), se ignora el dígito de setuid, y un modo ilegible NO se interpreta como permisivo — asumir lectura cuando stat devuelve '-' sería el error caro. La lectura de propiedades del unit se extrae a un helper compartido con el instalador. Ya hubo una divergencia por copiar esta lógica: la resolución de elevación existía duplicada y las dos pantallas acabaron diciendo cosas distintas del mismo servidor. 2. Ningún rechazo de token de servicio dejaba rastro: los seis endpoints solo logueaban el 500 de "token no configurado". Con el token rotado, todos los agentes quedan mudos —dejan de reportar versión, resolver rutas y registrar resultados— y desde el panel se ve igual que un agente apagado; además el trace_id que se le devuelve al agente no existía del lado servidor, así que era imposible correlacionar. Se agrega un helper que loguea el rechazo con trace_id, ruta e instancia, y distingue "sin header Authorization" de "token no coincide", que son un agente sin configurar y un token rotado: dos problemas con remedios distintos. El token NUNCA se registra, ni un fragmento suyo — es el mismo valor en todos los agentes, así que un prefijo en los logs ya acota el espacio de búsqueda. Hay prueba de eso. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
112 lines
4.8 KiB
TypeScript
112 lines
4.8 KiB
TypeScript
/**
|
|
* Autenticación servicio-a-servicio de los endpoints que consume CloudRestoreAS.
|
|
*
|
|
* La prueba que sostiene el resto es la de no-fuga: el rechazo de un token se escribe a los logs
|
|
* del contenedor, así que si alguna vez alguien mete el valor recibido "para depurar", el token de
|
|
* servicio —que es el MISMO en todos los agentes— acaba en disco y en cualquier agregador.
|
|
*
|
|
* El resto cubre por qué se loguea: hasta ahora un 401 no dejaba ni una línea, así que un token
|
|
* rotado dejaba a todos los agentes mudos y desde el panel se veía igual que un agente apagado.
|
|
*/
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { env } from '$env/dynamic/private';
|
|
|
|
const { loggerMock } = vi.hoisted(() => ({
|
|
loggerMock: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }
|
|
}));
|
|
vi.mock('./logger', () => ({ logger: loggerMock }));
|
|
|
|
import { checkServiceToken, logServiceTokenRejected } from './service-auth';
|
|
|
|
const TOKEN = 'tok-de-servicio-no-debe-aparecer-en-logs';
|
|
|
|
function req(authorization?: string): Request {
|
|
return new Request('https://panel.test/api/restore/target-for?database=X&instance=Mediano', {
|
|
headers: authorization ? { authorization } : {}
|
|
});
|
|
}
|
|
|
|
beforeEach(() => {
|
|
env.CLOUDRESTORE_API_TOKEN = TOKEN;
|
|
loggerMock.warn.mockClear();
|
|
});
|
|
|
|
afterEach(() => {
|
|
// Cadena vacía y no `delete`: es el caso real del compose, que deja la variable definida y
|
|
// vacía cuando no se configuró. Y `delete` no tipa, porque la propiedad no es opcional.
|
|
env.CLOUDRESTORE_API_TOKEN = '';
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
describe('checkServiceToken', () => {
|
|
it('acepta el token exacto', () => {
|
|
expect(checkServiceToken(req(`Bearer ${TOKEN}`))).toEqual({ ok: true });
|
|
});
|
|
|
|
it('rechaza con 401 sin header, con esquema equivocado, o con token distinto', () => {
|
|
for (const header of [undefined, 'Basic abc', `Token ${TOKEN}`, 'Bearer otro-token']) {
|
|
expect(checkServiceToken(req(header)), String(header)).toMatchObject({
|
|
ok: false,
|
|
status: 401
|
|
});
|
|
}
|
|
});
|
|
|
|
it('un token de largo distinto se rechaza sin comparar byte a byte', () => {
|
|
// timingSafeEqual lanza si los buffers difieren en largo, así que el guard de largo
|
|
// previo no es cosmético: sin él, un token más corto reventaría con una excepción.
|
|
expect(() => checkServiceToken(req('Bearer x'))).not.toThrow();
|
|
expect(checkServiceToken(req('Bearer x'))).toMatchObject({ ok: false, status: 401 });
|
|
});
|
|
|
|
it('sin token configurado responde 500, no 401: no se puede autenticar de forma segura', () => {
|
|
env.CLOUDRESTORE_API_TOKEN = '';
|
|
expect(checkServiceToken(req(`Bearer ${TOKEN}`))).toMatchObject({ ok: false, status: 500 });
|
|
});
|
|
});
|
|
|
|
describe('logServiceTokenRejected', () => {
|
|
it('NUNCA registra el token, ni el esperado ni el recibido', () => {
|
|
logServiceTokenRejected({
|
|
traceId: 't-1',
|
|
route: 'GET /api/restore/target-for',
|
|
request: req('Bearer un-token-que-no-cuadra'),
|
|
instanceKey: 'Mediano'
|
|
});
|
|
const escrito = JSON.stringify(loggerMock.warn.mock.calls[0][0]);
|
|
expect(escrito).not.toContain(TOKEN);
|
|
expect(escrito).not.toContain('un-token-que-no-cuadra');
|
|
// Ni fragmentos: un prefijo también acota el espacio de búsqueda.
|
|
expect(escrito).not.toContain(TOKEN.slice(0, 8));
|
|
});
|
|
|
|
it('distingue "sin header" de "token no coincide": son remedios distintos', () => {
|
|
// Sin header = agente sin configurar. Token que no cuadra = token rotado.
|
|
logServiceTokenRejected({ traceId: 't-2', route: 'r', request: req() });
|
|
expect(loggerMock.warn.mock.calls[0][0].context.reason).toMatch(/sin header/i);
|
|
|
|
loggerMock.warn.mockClear();
|
|
logServiceTokenRejected({ traceId: 't-3', route: 'r', request: req('Bearer nope') });
|
|
expect(loggerMock.warn.mock.calls[0][0].context.reason).toMatch(/no coincide/i);
|
|
});
|
|
|
|
it('lleva trace_id y ruta, que es lo que permite correlacionar con el agente', () => {
|
|
logServiceTokenRejected({
|
|
traceId: 't-4',
|
|
route: 'POST /api/restore/job-result',
|
|
request: req('Bearer nope')
|
|
});
|
|
const entry = loggerMock.warn.mock.calls[0][0];
|
|
expect(entry.trace_id).toBe('t-4');
|
|
expect(entry.context.route).toBe('POST /api/restore/job-result');
|
|
});
|
|
|
|
it('normaliza la instancia ausente o en blanco a null, no a cadena vacía', () => {
|
|
for (const ik of [undefined, null, '', ' ']) {
|
|
loggerMock.warn.mockClear();
|
|
logServiceTokenRejected({ traceId: 't', route: 'r', request: req(), instanceKey: ik });
|
|
expect(loggerMock.warn.mock.calls[0][0].context.instance_key, String(ik)).toBeNull();
|
|
}
|
|
});
|
|
});
|