feat(freight-api): seal EDR side of contracts with the one global stamp

This commit is contained in:
ghost2023
2026-08-12 06:26:46 +03:00
parent e52d666647
commit a603807e8e
21 changed files with 679 additions and 183 deletions

View File

@@ -0,0 +1,110 @@
import { Readable } from "stream";
import { StampSettingsService } from "./stamp-settings.service";
/**
* The one global company stamp feeds three document paths (invoices, warehouse
* papers, contract signature blocks). All three treat the returned value as a
* `data:` URL — so the data-URL-or-null contract of getStampImageUrl is what
* these specs pin down, especially its behaviour when MinIO cannot be reached.
*/
describe("StampSettingsService.getStampImageUrl", () => {
const PNG = Buffer.from("fake-png-bytes");
const OBJECT_URL = "https://minio.local:9000/edr-freight/stamp/company.png";
const build = (
overrides: {
stampUrl?: string | null;
getFileStream?: jest.Mock;
findSingleton?: jest.Mock;
} = {},
) => {
const service = Object.create(
StampSettingsService.prototype,
) as StampSettingsService;
const warn = jest.fn();
Object.assign(service, {
logger: { warn, log: jest.fn() },
repository: {
findSingleton:
overrides.findSingleton ??
jest.fn().mockResolvedValue({
id: "s-1",
stampFileId: overrides.stampUrl ? "f-1" : null,
stampFile: overrides.stampUrl ? { url: overrides.stampUrl } : null,
updatedById: null,
updatedAt: null,
}),
create: jest.fn(),
update: jest.fn(),
},
minioService: {
getObjectNameFromUrl: jest.fn().mockReturnValue("stamp/company.png"),
getFileStream:
overrides.getFileStream ??
jest.fn().mockResolvedValue(Readable.from(PNG)),
},
filesService: { upload: jest.fn() },
});
return { service, warn };
};
it("inlines the stored stamp as a data URL", async () => {
const { service } = build({ stampUrl: OBJECT_URL });
await expect(service.getStampImageUrl()).resolves.toBe(
`data:image/png;base64,${PNG.toString("base64")}`,
);
});
it("returns null when no stamp is configured", async () => {
const { service } = build({ stampUrl: null });
await expect(service.getStampImageUrl()).resolves.toBeNull();
});
it("passes an already-inlined data URL straight through", async () => {
const dataUrl = "data:image/png;base64,QUJD";
const { service } = build({ stampUrl: dataUrl });
await expect(service.getStampImageUrl()).resolves.toBe(dataUrl);
});
/**
* Regression: inlineImageUrl falls back to the raw object URL when MinIO is
* unreachable, which is right for getView (a browser can fetch it) but wrong
* here. ContractTransitionService base64-decodes this value to snapshot the
* seal — and a URL decodes to garbage bytes WITHOUT throwing, so a transient
* MinIO failure used to seal an executed contract with a corrupt image file.
* Degrade to null instead so callers draw their text/vector seal.
*/
it("returns null rather than a raw object URL when MinIO inlining fails", async () => {
const { service, warn } = build({
stampUrl: OBJECT_URL,
getFileStream: jest.fn().mockRejectedValue(new Error("connect ECONNREFUSED")),
});
await expect(service.getStampImageUrl()).resolves.toBeNull();
expect(warn).toHaveBeenCalledWith(expect.stringMatching(/plain seal/i));
});
it("never throws when the settings lookup itself fails", async () => {
const { service, warn } = build({
findSingleton: jest.fn().mockRejectedValue(new Error("db is down")),
});
await expect(service.getStampImageUrl()).resolves.toBeNull();
expect(warn).toHaveBeenCalledWith(expect.stringContaining("db is down"));
});
it("still exposes the raw URL through getView, which a browser can load", async () => {
const { service } = build({
stampUrl: OBJECT_URL,
getFileStream: jest.fn().mockRejectedValue(new Error("connect ECONNREFUSED")),
});
await expect(service.getView()).resolves.toEqual(
expect.objectContaining({ stampImageUrl: OBJECT_URL }),
);
});
});

View File

@@ -49,14 +49,30 @@ export class StampSettingsService {
}
/**
* The stamp image for embedding into invoice PDFs. Never throws — invoice
* generation must succeed even if the stamp lookup fails; callers fall back
* to the programmatic seal when this returns null.
* The stamp image for embedding into generated documents, ALWAYS as a
* `data:` URL or null. Never throws — document generation must succeed even
* if the stamp lookup fails; callers fall back to their own seal on null.
*
* The data-URL-or-null guarantee is load-bearing, not cosmetic. Callers do
* two things with this value that a bare MinIO URL silently corrupts:
* ContractTransitionService base64-decodes it to snapshot the seal onto a
* signature row (a URL decodes to garbage bytes, not an error, permanently
* sealing an executed contract with a broken image), and the HTML render
* path inlines it into an <img> that headless Chromium cannot fetch. So
* where getView() may hand a raw URL to a browser that can load it, this
* degrades to null and lets the caller draw its text/vector seal instead.
*/
async getStampImageUrl(): Promise<string | null> {
try {
const setting = await this.get();
return await this.inlineImageUrl(setting.stampFile?.url);
const inlined = await this.inlineImageUrl(setting.stampFile?.url);
if (inlined && !inlined.startsWith("data:")) {
this.logger.warn(
`Company stamp could not be inlined for document rendering (falling back to the plain seal): ${inlined}`,
);
return null;
}
return inlined;
} catch (err) {
this.logger.warn(
`Could not load company stamp for PDF rendering: ${(err as Error).message}`,