Files
PANEL_BASES_ANEXO24/src/lib/server/cras-artifacts.test.ts
hreyes 14b611c581
Some checks failed
Aduanasoft/PANEL_BASES_ANEXO24/pipeline/head There was a failure building this commit
feature/interfaz-binarios (#19)
Reviewed-on: #19
Co-authored-by: hreyes <hreyes@aduanasoft.com.mx>
Co-committed-by: hreyes <hreyes@aduanasoft.com.mx>
2026-07-30 13:52:45 +00:00

274 lines
11 KiB
TypeScript

/**
* Caché local de artefactos de CloudRestoreAS.
*
* Lo crítico aquí es que un artefacto corrupto NUNCA llegue a tener nombre definitivo: el
* rename solo ocurre después de que el sha256 cuadra. Si eso falla, el panel empujaría un
* binario roto a un servidor de producción. También se prueba el anti-traversal, porque
* version y fileName se usan para construir rutas.
*/
import { createHash } from 'node:crypto';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { env } from '$env/dynamic/private';
import {
ArtifactError,
artifactPath,
cacheDir,
cacheUsage,
ensureCached,
isCached,
isSafeFileName,
isSafeVersion,
pruneCache,
removeCached,
selectPrunableVersions
} from './cras-artifacts';
const originalFetch = globalThis.fetch;
let tmpRoot: string;
function sha256Of(text: string): string {
return createHash('sha256').update(text).digest('hex');
}
/** Stub de la descarga de Gitea: responde el contenido indicado como stream. */
function stubDownload(content: string, status = 200) {
globalThis.fetch = vi.fn(async () => {
if (status !== 200) return new Response('', { status });
return new Response(content, {
status: 200,
headers: { 'content-length': String(Buffer.byteLength(content)) }
});
}) as unknown as typeof fetch;
}
beforeEach(async () => {
tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'cras-cache-'));
env.CRAS_RELEASES_DIR = tmpRoot;
env.GITEA_TOKEN = 'token-de-prueba';
env.GITEA_BASE_URL = 'https://git.ejemplo.test';
});
afterEach(async () => {
globalThis.fetch = originalFetch;
await fs.rm(tmpRoot, { recursive: true, force: true });
for (const key of ['CRAS_RELEASES_DIR', 'GITEA_TOKEN', 'GITEA_BASE_URL', 'CRAS_CACHE_KEEP_VERSIONS']) {
delete env[key];
}
vi.restoreAllMocks();
});
describe('validación de nombres', () => {
it('acepta versiones y nombres de artefacto reales', () => {
expect(isSafeVersion('1.1.0')).toBe(true);
expect(isSafeVersion('26.7.1.4')).toBe(true);
expect(isSafeFileName('CloudRestoreAS-1.1.0-linux-x86_64.tar.gz')).toBe(true);
expect(isSafeFileName('SHA256SUMS')).toBe(true);
});
it('rechaza intentos de salir de la caché', () => {
for (const version of ['..', '../etc', '1.1.0/../..', '', 'latest']) {
expect(isSafeVersion(version), version).toBe(false);
}
for (const file of ['../evil', '/etc/passwd', 'a/b.zip', '..', '.oculto', 'con espacio.zip']) {
expect(isSafeFileName(file), file).toBe(false);
}
});
it('artifactPath lanza ArtifactError ante entradas inseguras', () => {
expect(() => artifactPath('../etc', 'x.zip')).toThrow(ArtifactError);
expect(() => artifactPath('1.1.0', '../../evil')).toThrow(ArtifactError);
});
it('artifactPath resuelve dentro de la caché', () => {
const p = artifactPath('1.1.0', 'a.tar.gz');
expect(p).toBe(path.join(cacheDir(), '1.1.0', 'a.tar.gz'));
expect(p.startsWith(path.resolve(cacheDir()) + path.sep)).toBe(true);
});
});
describe('ensureCached', () => {
const FILE = 'CloudRestoreAS-1.1.0-linux-x86_64.tar.gz';
const CONTENT = 'contenido-del-artefacto';
it('descarga, verifica el sha256 y deja el archivo definitivo', async () => {
stubDownload(CONTENT);
const result = await ensureCached('1.1.0', FILE, sha256Of(CONTENT), Buffer.byteLength(CONTENT));
expect(result.downloaded).toBe(true);
expect(result.size).toBe(Buffer.byteLength(CONTENT));
expect(await fs.readFile(result.path, 'utf8')).toBe(CONTENT);
expect(await isCached('1.1.0', FILE)).toBe(true);
});
it('no re-descarga si ya está en caché', async () => {
stubDownload(CONTENT);
await ensureCached('1.1.0', FILE, sha256Of(CONTENT));
const spy = vi.fn();
globalThis.fetch = spy as unknown as typeof fetch;
const second = await ensureCached('1.1.0', FILE, sha256Of(CONTENT));
expect(second.downloaded).toBe(false);
expect(spy).not.toHaveBeenCalled();
});
it('con sha256 distinto NO deja el archivo definitivo y borra el temporal', async () => {
// Este es el caso que evita empujar un binario corrupto a producción.
stubDownload(CONTENT);
await expect(ensureCached('1.1.0', FILE, sha256Of('otro-contenido'))).rejects.toThrow(
/sha256/i
);
expect(await isCached('1.1.0', FILE)).toBe(false);
const leftovers = await fs.readdir(path.join(tmpRoot, '1.1.0')).catch(() => []);
expect(leftovers.filter((f) => f.includes('.part'))).toEqual([]);
});
it('con tamaño distinto al declarado también aborta', async () => {
stubDownload(CONTENT);
await expect(
ensureCached('1.1.0', FILE, sha256Of(CONTENT), Buffer.byteLength(CONTENT) + 100)
).rejects.toThrow(/tama/i);
expect(await isCached('1.1.0', FILE)).toBe(false);
});
it('sin sha256 registrado se niega a descargar 270 MB a ciegas', async () => {
const spy = vi.fn();
globalThis.fetch = spy as unknown as typeof fetch;
await expect(ensureCached('1.1.0', FILE, null)).rejects.toMatchObject({ status: 409 });
expect(spy).not.toHaveBeenCalled();
});
it('con un sha256 mal formado también se niega', async () => {
const spy = vi.fn();
globalThis.fetch = spy as unknown as typeof fetch;
await expect(ensureCached('1.1.0', FILE, 'abc123')).rejects.toMatchObject({ status: 409 });
expect(spy).not.toHaveBeenCalled();
});
it('un fallo de descarga limpia el temporal', async () => {
stubDownload('', 500);
await expect(ensureCached('1.1.0', FILE, sha256Of(CONTENT))).rejects.toThrow();
const leftovers = await fs.readdir(path.join(tmpRoot, '1.1.0')).catch(() => []);
expect(leftovers.filter((f) => f.includes('.part'))).toEqual([]);
});
});
describe('cacheUsage y removeCached', () => {
it('mide el uso por versión e ignora los .part', async () => {
await fs.mkdir(path.join(tmpRoot, '1.1.0'), { recursive: true });
await fs.writeFile(path.join(tmpRoot, '1.1.0', 'a.tar.gz'), 'x'.repeat(100));
await fs.writeFile(path.join(tmpRoot, '1.1.0', 'b.zip'), 'y'.repeat(50));
// Un temporal de una descarga en curso no debe contar como espacio consumido útil.
await fs.writeFile(path.join(tmpRoot, '1.1.0', 'c.zip.123.part'), 'z'.repeat(999));
const usage = await cacheUsage();
expect(usage.total_bytes).toBe(150);
expect(usage.versions).toEqual([{ version: '1.1.0', bytes: 150, files: 2 }]);
});
it('ignora carpetas que no son versiones válidas', async () => {
await fs.mkdir(path.join(tmpRoot, 'basura'), { recursive: true });
await fs.writeFile(path.join(tmpRoot, 'basura', 'x'), 'x'.repeat(10));
expect((await cacheUsage()).total_bytes).toBe(0);
});
it('devuelve vacío si la caché no existe todavía', async () => {
env.CRAS_RELEASES_DIR = path.join(tmpRoot, 'no-existe');
expect(await cacheUsage()).toEqual({ total_bytes: 0, versions: [] });
});
it('removeCached borra el archivo', async () => {
await fs.mkdir(path.join(tmpRoot, '1.1.0'), { recursive: true });
await fs.writeFile(path.join(tmpRoot, '1.1.0', 'a.tar.gz'), 'x');
await removeCached('1.1.0', 'a.tar.gz');
expect(await isCached('1.1.0', 'a.tar.gz')).toBe(false);
});
});
describe('pruneCache', () => {
async function seed(versions: string[]) {
for (const v of versions) {
await fs.mkdir(path.join(tmpRoot, v), { recursive: true });
await fs.writeFile(path.join(tmpRoot, v, 'a.tar.gz'), 'x'.repeat(10));
}
}
it('conserva las N más recientes por orden numérico de versión', async () => {
env.CRAS_CACHE_KEEP_VERSIONS = '2';
// 1.10.0 es más nueva que 1.9.0: un orden lexicográfico borraría la equivocada.
await seed(['1.8.0', '1.9.0', '1.10.0']);
const removed = await pruneCache([]);
expect(removed).toEqual(['1.8.0']);
expect((await cacheUsage()).versions.map((v) => v.version).sort()).toEqual([
'1.10.0',
'1.9.0'
]);
});
it('nunca borra una versión fijada, aunque sea vieja', async () => {
// Dejar sin artefacto local a la versión activa obligaría a re-descargar 270 MB en
// plena instalación.
env.CRAS_CACHE_KEEP_VERSIONS = '1';
await seed(['1.8.0', '1.9.0', '1.10.0']);
const removed = await pruneCache(['1.8.0']);
expect(removed).not.toContain('1.8.0');
expect(await isCached('1.8.0', 'a.tar.gz')).toBe(true);
});
it('no borra nada si cabe dentro del límite', async () => {
env.CRAS_CACHE_KEEP_VERSIONS = '5';
await seed(['1.1.0', '1.2.0']);
expect(await pruneCache([])).toEqual([]);
});
});
describe('selectPrunableVersions', () => {
// Es la previsualización que ve el operador antes de confirmar la poda: tiene que decidir con
// el mismo criterio que pruneCache, o el modal prometería borrar algo distinto de lo que borra.
const usage = [
{ version: '1.8.0', bytes: 100 },
{ version: '1.9.0', bytes: 200 },
{ version: '1.10.0', bytes: 400 }
];
it('conserva las N más nuevas en orden numérico, no lexicográfico', () => {
// Con orden de texto "1.10.0" < "1.9.0" y se borraría la más nueva.
expect(selectPrunableVersions(usage, [], 2)).toEqual({ versions: ['1.8.0'], bytes: 100 });
expect(selectPrunableVersions(usage, [], 1)).toEqual({
versions: ['1.9.0', '1.8.0'],
bytes: 300
});
});
it('nunca propone una versión fijada y no la cuenta contra el límite', () => {
expect(selectPrunableVersions(usage, ['1.8.0'], 1)).toEqual({
versions: ['1.9.0'],
bytes: 200
});
});
it('suma exactamente los bytes de las versiones elegidas', () => {
const plan = selectPrunableVersions(usage, [], 0);
expect(plan.versions).toEqual(['1.10.0', '1.9.0', '1.8.0']);
expect(plan.bytes).toBe(700);
});
it('con menos versiones que el límite el plan es vacío: el clic no haría nada', () => {
// El control debe decir "Nada que liberar" en vez de ofrecer un borrado que es un no-op.
expect(selectPrunableVersions(usage, [], 5)).toEqual({ versions: [], bytes: 0 });
expect(selectPrunableVersions([], [], 3)).toEqual({ versions: [], bytes: 0 });
});
it('ignora carpetas que no son versiones válidas', () => {
const dirty = [...usage, { version: '../etc', bytes: 999 }, { version: 'tmp', bytes: 5 }];
expect(selectPrunableVersions(dirty, [], 3).versions).toEqual([]);
});
});