mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 23:00:57 +00:00
Merge branch 'dev'
This commit is contained in:
@@ -0,0 +1,152 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
|
||||
import { ContractTransitionService } from './contract-transition.service';
|
||||
|
||||
/**
|
||||
* Where the booking-contract view reads the global stamp live, the contracts
|
||||
* path SNAPSHOTS it onto the signature row at signing time, so replacing the
|
||||
* company stamp can never restamp an already-executed contract. These specs
|
||||
* pin the sourcing split: EDR always seals with the global stamp and staff
|
||||
* never supply one, while the customer must upload their own.
|
||||
*/
|
||||
describe('applySignature stamp sourcing', () => {
|
||||
const contract = { id: 'c-1', reference: 'CTR-1', status: 'SIGNED_CUSTOMER' };
|
||||
const GLOBAL_STAMP = 'data:image/png;base64,RURS';
|
||||
|
||||
const build = (globalStamp: string | null = GLOBAL_STAMP) => {
|
||||
const uploads: Array<{ code: string; image: string }> = [];
|
||||
const saved: unknown[] = [];
|
||||
const service = Object.create(
|
||||
ContractTransitionService.prototype,
|
||||
) as ContractTransitionService;
|
||||
Object.assign(service, {
|
||||
logger: { warn: jest.fn(), log: jest.fn() },
|
||||
stampSettings: {
|
||||
getStampImageUrl: jest.fn().mockResolvedValue(globalStamp),
|
||||
},
|
||||
contractsRepository: {
|
||||
saveSignature: jest.fn((row: unknown) => {
|
||||
saved.push(row);
|
||||
return Promise.resolve(undefined);
|
||||
}),
|
||||
},
|
||||
signaturesService: {
|
||||
getForUser: jest.fn().mockResolvedValue(null),
|
||||
upsertForUser: jest.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
uploadSignatureAsset: jest.fn((_c: unknown, code: string, image: string) => {
|
||||
uploads.push({ code, image });
|
||||
return Promise.resolve({ id: `file-${code}` });
|
||||
}),
|
||||
});
|
||||
return { service, uploads, saved };
|
||||
};
|
||||
|
||||
const apply = (
|
||||
service: ContractTransitionService,
|
||||
dto: Record<string, unknown>,
|
||||
) =>
|
||||
(
|
||||
service as unknown as {
|
||||
applySignature(
|
||||
c: unknown,
|
||||
d: unknown,
|
||||
o: { signerUserId?: string },
|
||||
): Promise<void>;
|
||||
}
|
||||
).applySignature(contract, dto, { signerUserId: 'u-1' });
|
||||
|
||||
const staffDto = {
|
||||
role: 'STAFF' as const,
|
||||
signerDisplayName: 'E. Staff',
|
||||
signatureImageBase64: 'data:image/png;base64,U0lH',
|
||||
};
|
||||
|
||||
it('seals the EDR side with the global stamp', async () => {
|
||||
const { service, uploads, saved } = build();
|
||||
|
||||
await apply(service, staffDto);
|
||||
|
||||
expect(uploads).toContainEqual({ code: 'stamp_staff', image: GLOBAL_STAMP });
|
||||
expect(saved[0]).toEqual(
|
||||
expect.objectContaining({ stampFileId: 'file-stamp_staff' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('ignores a stamp a staff client tries to supply', async () => {
|
||||
const { service, uploads } = build();
|
||||
|
||||
await apply(service, {
|
||||
...staffDto,
|
||||
stampImageBase64: 'data:image/png;base64,SEFDSw==',
|
||||
});
|
||||
|
||||
expect(uploads).toContainEqual({ code: 'stamp_staff', image: GLOBAL_STAMP });
|
||||
expect(uploads.map((u) => u.image)).not.toContain(
|
||||
'data:image/png;base64,SEFDSw==',
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Failing loudly matters here: getStampImageUrl degrades to null when the
|
||||
* stamp cannot be inlined, and silently executing an unsealed contract would
|
||||
* be worse than refusing to counter-sign.
|
||||
*/
|
||||
it('refuses to counter-sign when no global stamp is configured', async () => {
|
||||
const { service, saved } = build(null);
|
||||
|
||||
await expect(apply(service, staffDto)).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
await expect(apply(service, staffDto)).rejects.toThrow(/company stamp is configured/i);
|
||||
expect(saved).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('requires the customer to upload their own stamp', async () => {
|
||||
const { service, saved } = build();
|
||||
|
||||
await expect(
|
||||
apply(service, {
|
||||
role: 'CUSTOMER',
|
||||
signerDisplayName: 'C. Customer',
|
||||
signatureImageBase64: 'data:image/png;base64,U0lH',
|
||||
}),
|
||||
).rejects.toThrow(/company stamp is required/i);
|
||||
expect(saved).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('snapshots the customer stamp and never substitutes the global one', async () => {
|
||||
const { service, uploads } = build();
|
||||
const customerStamp = 'data:image/png;base64,Q1VTVA==';
|
||||
|
||||
await apply(service, {
|
||||
role: 'CUSTOMER',
|
||||
signerDisplayName: 'C. Customer',
|
||||
signatureImageBase64: 'data:image/png;base64,U0lH',
|
||||
stampImageBase64: customerStamp,
|
||||
});
|
||||
|
||||
expect(uploads).toContainEqual({
|
||||
code: 'stamp_customer',
|
||||
image: customerStamp,
|
||||
});
|
||||
expect(uploads.map((u) => u.image)).not.toContain(GLOBAL_STAMP);
|
||||
});
|
||||
|
||||
/**
|
||||
* DIRECTOR/CEO rows are internal approval signatures, not party seals, so
|
||||
* they are deliberately exempt from the stamp requirement.
|
||||
*/
|
||||
it('lets internal approval signatures through without any stamp', async () => {
|
||||
const { service, uploads, saved } = build();
|
||||
|
||||
await apply(service, {
|
||||
role: 'DIRECTOR',
|
||||
signerDisplayName: 'D. Director',
|
||||
signatureImageBase64: 'data:image/png;base64,U0lH',
|
||||
});
|
||||
|
||||
expect(uploads.map((u) => u.code)).toEqual(['signature_director']);
|
||||
expect(saved[0]).toEqual(expect.objectContaining({ stampFileId: null }));
|
||||
});
|
||||
});
|
||||
@@ -35,6 +35,7 @@ import { CargoTypesService } from '../rule-engine/services/cargo-types.service';
|
||||
import { DropdownSettingsService } from '../dropdown-settings/dropdown-settings.service';
|
||||
import { FilesService } from '../files/files.service';
|
||||
import { SignaturesService } from '../signatures/signatures.service';
|
||||
import { StampSettingsService } from '../stamp-settings/stamp-settings.service';
|
||||
import { OtpService } from '../otp/otp.service';
|
||||
import { ContractTemplatesService } from '../contract-templates/contract-templates.service';
|
||||
import { ContractPricingService } from './contract-pricing.service';
|
||||
@@ -175,6 +176,7 @@ export class ContractTransitionService {
|
||||
private readonly otpService: OtpService,
|
||||
private readonly notifier: ContractNotifierService,
|
||||
private readonly contractTemplates: ContractTemplatesService,
|
||||
private readonly stampSettings: StampSettingsService,
|
||||
@InjectDataSource()
|
||||
private readonly dataSource: DataSource,
|
||||
) {}
|
||||
@@ -1103,23 +1105,39 @@ export class ContractTransitionService {
|
||||
// The company stamp is a separate image from the drawn signature. Both
|
||||
// parties to the contract (client + EDR) must seal it; DIRECTOR/CEO rows
|
||||
// are internal approval signatures, not party seals, so they stay exempt.
|
||||
const stampRequired = role === 'CUSTOMER' || role === 'STAFF';
|
||||
if (stampRequired && !dto.stampImageBase64) {
|
||||
//
|
||||
// The two parties source their seal differently: the customer uploads their
|
||||
// own company stamp, while EDR always seals with the ONE global stamp
|
||||
// (StampSettingsService) — staff never upload or pick a stamp.
|
||||
if (role === 'CUSTOMER' && !dto.stampImageBase64) {
|
||||
throw new BadRequestException(
|
||||
'A company stamp is required to sign this contract.',
|
||||
);
|
||||
}
|
||||
|
||||
// Snapshot whichever stamp applies onto the signature row rather than
|
||||
// referencing the global one, so replacing the company stamp later can
|
||||
// never restamp an already-executed contract.
|
||||
let stampImageBase64 = dto.stampImageBase64 ?? null;
|
||||
if (role === 'STAFF') {
|
||||
stampImageBase64 = await this.stampSettings.getStampImageUrl();
|
||||
if (!stampImageBase64) {
|
||||
throw new BadRequestException(
|
||||
'No company stamp is configured. Upload the company stamp under Settings before counter-signing contracts.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const fileRecord = await this.uploadSignatureAsset(
|
||||
contract,
|
||||
`signature_${role.toLowerCase()}`,
|
||||
imageBase64,
|
||||
);
|
||||
const stampRecord = dto.stampImageBase64
|
||||
const stampRecord = stampImageBase64
|
||||
? await this.uploadSignatureAsset(
|
||||
contract,
|
||||
`stamp_${role.toLowerCase()}`,
|
||||
dto.stampImageBase64,
|
||||
stampImageBase64,
|
||||
)
|
||||
: null;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user