Checkpoint: Ajout du secours de stockage réservé aux PDF de paie chiffrés : lorsque Forge est indisponible en recette, les octets AES-256-GCM sont stockés dans un volume hôte Docker persistant, jamais dans l'image ni la base. Import, réindexation et consultation passent par cette même abstraction, avec test de round-trip sans Forge. Compose transmet la clé AES et monte le volume dédié. Validation complète : 56 tests, TypeScript, build et format YAML.
All checks were successful
Validation applicative / TypeScript, tests et build (push) Successful in 2m9s
All checks were successful
Validation applicative / TypeScript, tests et build (push) Successful in 2m9s
This commit is contained in:
38
server/payrollStorage.test.ts
Normal file
38
server/payrollStorage.test.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
const originalStorageDir = process.env.PAYROLL_STORAGE_DIR;
|
||||
const originalForgeUrl = process.env.BUILT_IN_FORGE_API_URL;
|
||||
const originalForgeKey = process.env.BUILT_IN_FORGE_API_KEY;
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
if (originalStorageDir === undefined) delete process.env.PAYROLL_STORAGE_DIR;
|
||||
else process.env.PAYROLL_STORAGE_DIR = originalStorageDir;
|
||||
if (originalForgeUrl === undefined) delete process.env.BUILT_IN_FORGE_API_URL;
|
||||
else process.env.BUILT_IN_FORGE_API_URL = originalForgeUrl;
|
||||
if (originalForgeKey === undefined) delete process.env.BUILT_IN_FORGE_API_KEY;
|
||||
else process.env.BUILT_IN_FORGE_API_KEY = originalForgeKey;
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
describe("stockage persistant des bulletins", () => {
|
||||
it("conserve un PDF déjà chiffré dans le répertoire persistant quand Forge est indisponible", async () => {
|
||||
const storageDir = await mkdtemp(join(tmpdir(), "itinova-payroll-storage-"));
|
||||
process.env.PAYROLL_STORAGE_DIR = storageDir;
|
||||
delete process.env.BUILT_IN_FORGE_API_URL;
|
||||
delete process.env.BUILT_IN_FORGE_API_KEY;
|
||||
vi.stubGlobal("fetch", vi.fn(() => { throw new Error("Forge ne doit pas être appelé"); }));
|
||||
|
||||
const { payrollStorageGetBuffer, payrollStoragePut } = await import("./storage");
|
||||
const ciphertext = Buffer.from("octets-chiffres-de-test", "utf8");
|
||||
const stored = await payrollStoragePut("salaires/test.pdf.enc", ciphertext, "application/octet-stream");
|
||||
|
||||
expect(stored.key).toMatch(/^salaires\/test\.pdf_[a-f0-9]{8}\.enc$/);
|
||||
await expect(payrollStorageGetBuffer(stored.key)).resolves.toEqual(ciphertext);
|
||||
|
||||
await rm(storageDir, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
@@ -9,14 +9,14 @@ vi.mock("./db", () => ({
|
||||
getSalaireLiasseById: vi.fn(),
|
||||
replaceSalaireLiasseIndex: vi.fn(),
|
||||
}));
|
||||
vi.mock("./storage", () => ({ storagePut: vi.fn(), storageGetBuffer: vi.fn() }));
|
||||
vi.mock("./storage", () => ({ payrollStoragePut: vi.fn(), payrollStorageGetBuffer: vi.fn() }));
|
||||
vi.mock("pdf-parse/lib/pdf-parse.js", () => ({ default: vi.fn() }));
|
||||
|
||||
import { sdk } from "./_core/sdk";
|
||||
import { createSalaireLiasse, getSalaireLiasseById, getSalaireLiasseByPeriod } from "./db";
|
||||
import { encryptPayrollPdf } from "./payrollCrypto";
|
||||
import { registerSalairesHttpRoutes } from "./salairesHttp";
|
||||
import { storageGetBuffer, storagePut } from "./storage";
|
||||
import { payrollStorageGetBuffer, payrollStoragePut } from "./storage";
|
||||
import pdfParse from "pdf-parse/lib/pdf-parse.js";
|
||||
|
||||
const EXTRACTED_TEXT = `##BULLETIN##05-2026##009999##DUPONT##TEST##
|
||||
@@ -49,13 +49,13 @@ describe("routes HTTP Salaires", () => {
|
||||
.expect(403);
|
||||
|
||||
expect(createSalaireLiasse).not.toHaveBeenCalled();
|
||||
expect(storagePut).not.toHaveBeenCalled();
|
||||
expect(payrollStoragePut).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("chiffre et archive une liasse administrateur sans persister de donnée sensible", async () => {
|
||||
(sdk.authenticateRequest as ReturnType<typeof vi.fn>).mockResolvedValue({ id: 1, role: "admin" });
|
||||
(pdfParse as ReturnType<typeof vi.fn>).mockResolvedValue({ text: EXTRACTED_TEXT });
|
||||
(storagePut as ReturnType<typeof vi.fn>).mockResolvedValue({ key: "salaires/2026/05/liasse_opaque.enc" });
|
||||
(payrollStoragePut as ReturnType<typeof vi.fn>).mockResolvedValue({ key: "salaires/2026/05/liasse_opaque.enc" });
|
||||
(createSalaireLiasse as ReturnType<typeof vi.fn>).mockResolvedValue({ liasseId: 12, bulletins: 1 });
|
||||
|
||||
const response = await request(makeApp())
|
||||
@@ -81,7 +81,7 @@ describe("routes HTTP Salaires", () => {
|
||||
const plaintext = Buffer.from("%PDF-1.7 contenu de bulletin", "utf8");
|
||||
const encrypted = encryptPayrollPdf(plaintext);
|
||||
(getSalaireLiasseById as ReturnType<typeof vi.fn>).mockResolvedValue({ id: 30001, stockageKey: "opaque", ivBase64: encrypted.ivBase64, authTagBase64: encrypted.authTagBase64, annee: 2026, mois: 5 });
|
||||
(storageGetBuffer as ReturnType<typeof vi.fn>).mockResolvedValue(encrypted.ciphertext);
|
||||
(payrollStorageGetBuffer as ReturnType<typeof vi.fn>).mockResolvedValue(encrypted.ciphertext);
|
||||
|
||||
const response = await request(makeApp()).get("/api/salaires/liasses/30001/pdf").expect(200);
|
||||
|
||||
@@ -103,7 +103,7 @@ describe("routes HTTP Salaires", () => {
|
||||
(sdk.authenticateRequest as ReturnType<typeof vi.fn>).mockResolvedValue({ id: 1, role: "admin" });
|
||||
const encrypted = encryptPayrollPdf(Buffer.from("%PDF-1.7 contenu de bulletin", "utf8"));
|
||||
(getSalaireLiasseById as ReturnType<typeof vi.fn>).mockResolvedValue({ id: 30001, stockageKey: "opaque", ivBase64: encrypted.ivBase64, authTagBase64: encrypted.authTagBase64, annee: 2026, mois: 5 });
|
||||
(storageGetBuffer as ReturnType<typeof vi.fn>).mockResolvedValue(encrypted.ciphertext);
|
||||
(payrollStorageGetBuffer as ReturnType<typeof vi.fn>).mockResolvedValue(encrypted.ciphertext);
|
||||
(pdfParse as ReturnType<typeof vi.fn>).mockResolvedValue({ text: EXTRACTED_TEXT });
|
||||
(replaceSalaireLiasseIndex as ReturnType<typeof vi.fn>).mockResolvedValue({ bulletins: 1 });
|
||||
|
||||
@@ -113,6 +113,6 @@ describe("routes HTTP Salaires", () => {
|
||||
statutExtraction: "ready",
|
||||
bulletins: [expect.objectContaining({ matricule: "009999" })],
|
||||
}));
|
||||
expect(storagePut).not.toHaveBeenCalled();
|
||||
expect(payrollStoragePut).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@ import { createSalaireLiasse, getSalaireLiasseById, getSalaireLiasseByPeriod, re
|
||||
import { decryptPayrollPdf, encryptPayrollPdf } from "./payrollCrypto";
|
||||
import { extractPayrollBulletins, type PayrollExtractionResult } from "./payrollParser";
|
||||
import { extractPayrollPdfText } from "./payrollPdfText";
|
||||
import { storageGetBuffer, storagePut } from "./storage";
|
||||
import { payrollStorageGetBuffer, payrollStoragePut } from "./storage";
|
||||
|
||||
const MAX_PDF_BYTES = 25 * 1024 * 1024;
|
||||
const upload = multer({
|
||||
@@ -82,7 +82,7 @@ export function registerSalairesHttpRoutes(app: Express) {
|
||||
|
||||
try {
|
||||
const encrypted = encryptPayrollPdf(file.buffer);
|
||||
const archive = await storagePut(
|
||||
const archive = await payrollStoragePut(
|
||||
`salaires/${annee}/${String(mois).padStart(2, "0")}/liasse.pdf.enc`,
|
||||
encrypted.ciphertext,
|
||||
"application/octet-stream",
|
||||
@@ -128,7 +128,7 @@ export function registerSalairesHttpRoutes(app: Express) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const ciphertext = await storageGetBuffer(liasse.stockageKey);
|
||||
const ciphertext = await payrollStorageGetBuffer(liasse.stockageKey);
|
||||
const pdf = decryptPayrollPdf({ ciphertext, ivBase64: liasse.ivBase64, authTagBase64: liasse.authTagBase64 });
|
||||
if (!isPdf(pdf)) throw new Error("Invalid decrypted PDF signature");
|
||||
const extraction = extractPayrollBulletins(await extractPayrollPdfText(pdf));
|
||||
@@ -160,7 +160,7 @@ export function registerSalairesHttpRoutes(app: Express) {
|
||||
}
|
||||
|
||||
try {
|
||||
const ciphertext = await storageGetBuffer(liasse.stockageKey);
|
||||
const ciphertext = await payrollStorageGetBuffer(liasse.stockageKey);
|
||||
const pdf = decryptPayrollPdf({
|
||||
ciphertext,
|
||||
ivBase64: liasse.ivBase64,
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
// Uploads via Forge Server presigned URL to S3 (PUT direct).
|
||||
// Downloads return /manus-storage/{key} paths served via 307 redirect.
|
||||
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { dirname, relative, resolve } from "node:path";
|
||||
import { ENV } from "./_core/env";
|
||||
|
||||
function getForgeConfig() {
|
||||
@@ -107,3 +109,43 @@ export async function storageGetBuffer(relKey: string): Promise<Buffer> {
|
||||
if (!response.ok) throw new Error(`Storage download failed (${response.status})`);
|
||||
return Buffer.from(await response.arrayBuffer());
|
||||
}
|
||||
|
||||
/**
|
||||
* Répertoire persistant optionnel réservé aux PDF de paie déjà chiffrés.
|
||||
* Il n'est utilisé que sur les environnements sans configuration Forge, où le
|
||||
* volume Docker hôte survit aux reconstructions de l'image applicative.
|
||||
*/
|
||||
function getPayrollStoragePath(relKey: string): string | null {
|
||||
const configuredDirectory = process.env.PAYROLL_STORAGE_DIR?.trim();
|
||||
if (!configuredDirectory) return null;
|
||||
|
||||
const root = resolve(configuredDirectory);
|
||||
const destination = resolve(root, normalizeKey(relKey));
|
||||
const pathFromRoot = relative(root, destination);
|
||||
if (pathFromRoot.startsWith("..") || pathFromRoot === "") {
|
||||
throw new Error("Invalid payroll storage key");
|
||||
}
|
||||
return destination;
|
||||
}
|
||||
|
||||
export async function payrollStoragePut(
|
||||
relKey: string,
|
||||
data: Buffer | Uint8Array | string,
|
||||
contentType = "application/octet-stream",
|
||||
): Promise<{ key: string; url: string }> {
|
||||
const localKey = appendHashSuffix(normalizeKey(relKey));
|
||||
const localDestination = getPayrollStoragePath(localKey);
|
||||
if (!localDestination) return storagePut(relKey, data, contentType);
|
||||
|
||||
await mkdir(dirname(localDestination), { recursive: true, mode: 0o700 });
|
||||
await writeFile(localDestination, data, { mode: 0o600, flag: "wx" });
|
||||
// Les appels paie n'exposent jamais cette URL : la lecture passe exclusivement
|
||||
// par la route administrateur qui déchiffre côté serveur.
|
||||
return { key: localKey, url: `/manus-storage/${localKey}` };
|
||||
}
|
||||
|
||||
export async function payrollStorageGetBuffer(relKey: string): Promise<Buffer> {
|
||||
const localDestination = getPayrollStoragePath(relKey);
|
||||
if (!localDestination) return storageGetBuffer(relKey);
|
||||
return readFile(localDestination);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user