mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 19:58:11 +00:00
feat(freight-api): seal EDR side of contracts with the one global stamp
This commit is contained in:
@@ -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 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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}`,
|
||||
|
||||
Reference in New Issue
Block a user