Some checks failed
Aduanasoft/PANEL_BASES_ANEXO24/pipeline/head There was a failure building this commit
Reviewed-on: #19 Co-authored-by: hreyes <hreyes@aduanasoft.com.mx> Co-committed-by: hreyes <hreyes@aduanasoft.com.mx>
274 lines
11 KiB
TypeScript
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([]);
|
|
});
|
|
});
|