/** * 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(); } }); });