mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
628 lines
20 KiB
TypeScript
628 lines
20 KiB
TypeScript
import { BadRequestException } from "@nestjs/common";
|
|
|
|
import { CompaniesService } from "./companies.service";
|
|
import { CompanyNationality, CompanyStatus } from "./entities/company.entity";
|
|
import { ProfileType } from "./entities/company-profile.entity";
|
|
import { POA_DELEGATION_FILE_KEY } from "../file-upload-settings/poa-delegation.constants";
|
|
|
|
/**
|
|
* A person's identity is proved through Fayda: name, email, phone and address
|
|
* come from the verified payload, not typed. Fayda's userinfo carries no
|
|
* national ID number, so none is collected or derived here.
|
|
*
|
|
* Only the OWNER's credential varies by nationality:
|
|
* - Ethiopian company: the owner is verified through Fayda.
|
|
* - Foreign company: Fayda is an Ethiopian national ID, so the owner instead
|
|
* supplies a typed passport number — required on its own, whether or not the
|
|
* owner also completes a (purely optional) Fayda verification.
|
|
*
|
|
* The PoA does not vary. A representative acts for the company inside Ethiopia
|
|
* whoever owns it, so a PoA is always an Ethiopian holding a Fayda ID: once one
|
|
* is named, both nationalities must verify them, and their details come from
|
|
* the verified payload rather than the form.
|
|
*
|
|
* The owner is NOT the general manager. The GM is proved the same way, by one
|
|
* of two routes — verifying in their own right, or being declared the owner,
|
|
* which reuses that verification rather than making one human prove themselves
|
|
* twice. It stays out of the trading gate either way: the GM names who to talk
|
|
* to, not what the company may do.
|
|
*/
|
|
|
|
interface Ctx {
|
|
attributes: Record<string, unknown>;
|
|
files: { id: string; code: string; reviewStatus?: string | null }[];
|
|
profileTypes: ProfileType[];
|
|
status: CompanyStatus;
|
|
nationality: CompanyNationality;
|
|
verification: Record<string, unknown>;
|
|
}
|
|
|
|
const OWNER_VERIFIED = {
|
|
ownerFaydaSub: "owner-sub",
|
|
ownerFaydaVerifiedAt: "2026-07-01T00:00:00.000Z",
|
|
ownerName: "Abebe Bikila",
|
|
};
|
|
|
|
const POA_VERIFIED = {
|
|
poaFaydaSub: "poa-sub",
|
|
poaFaydaVerifiedAt: "2026-07-02T00:00:00.000Z",
|
|
poaName: "Tirunesh Dibaba",
|
|
poaEmail: "tirunesh@example.com",
|
|
poaPhone: "+251911000000",
|
|
};
|
|
|
|
const paper = () => ({
|
|
id: "file-1",
|
|
code: POA_DELEGATION_FILE_KEY,
|
|
reviewStatus: null,
|
|
});
|
|
|
|
function makeService(overrides: Partial<Ctx> = {}) {
|
|
const ctx: Ctx = {
|
|
attributes: {},
|
|
files: [],
|
|
profileTypes: [ProfileType.importer],
|
|
status: CompanyStatus.Pending,
|
|
nationality: CompanyNationality.Ethiopian,
|
|
verification: {
|
|
purpose: "VERIFY",
|
|
verified: true,
|
|
sub: "new-sub",
|
|
fullName: "Haile Gebrselassie",
|
|
email: "haile@example.com",
|
|
phoneNumber: "+251922000000",
|
|
address: "Addis Ababa",
|
|
birthdate: "1973-04-18",
|
|
gender: "Male",
|
|
},
|
|
...overrides,
|
|
};
|
|
|
|
const company = () => ({
|
|
id: "company-1",
|
|
status: ctx.status,
|
|
nationality: ctx.nationality,
|
|
attributes: ctx.attributes,
|
|
companyProfiles: ctx.profileTypes.map((type, i) => ({
|
|
id: `profile-${i}`,
|
|
type,
|
|
})),
|
|
type: "customer",
|
|
});
|
|
|
|
const deps = {
|
|
companiesRepo: {
|
|
findById: jest.fn(async () => company()),
|
|
update: jest.fn(async (_id: string, patch: Record<string, unknown>) => {
|
|
if (patch.attributes)
|
|
ctx.attributes = patch.attributes as Record<string, unknown>;
|
|
return company();
|
|
}),
|
|
findByTin: jest.fn(async () => null),
|
|
},
|
|
companyProfilesRepo: {
|
|
findByCompanyId: jest.fn(async () =>
|
|
ctx.profileTypes.map((type, i) => ({ id: `profile-${i}`, type })),
|
|
),
|
|
findByType: jest.fn(async (_id: string, type: ProfileType) =>
|
|
ctx.profileTypes.includes(type) ? { id: "existing", type } : null,
|
|
),
|
|
create: jest.fn(async (row: Record<string, unknown>) => ({
|
|
id: "new",
|
|
...row,
|
|
})),
|
|
},
|
|
changeRequestRepo: {
|
|
findPendingByCompanyId: jest.fn(async () => null),
|
|
findLatestOpenByCompanyId: jest.fn(async () => null),
|
|
findByCompanyId: jest.fn(async () => []),
|
|
create: jest.fn(async (row: Record<string, unknown>) => ({
|
|
id: "cr-1",
|
|
...row,
|
|
})),
|
|
update: jest.fn(async () => ({ id: "cr-1" })),
|
|
},
|
|
revisionRepo: {
|
|
create: jest.fn(async (row: Record<string, unknown>) => ({
|
|
id: "rev-1",
|
|
...row,
|
|
})),
|
|
findByCompanyId: jest.fn(async () => []),
|
|
},
|
|
profilesRepo: {
|
|
findByCompanyId: jest.fn(async () => []),
|
|
findByUserId: jest.fn(async () => ({
|
|
id: "external-1",
|
|
companyId: "company-1",
|
|
company: company(),
|
|
onboardingCompleted: false,
|
|
})),
|
|
},
|
|
filesService: {
|
|
findByResource: jest.fn(async () => ctx.files),
|
|
findById: jest.fn(async () => null),
|
|
remove: jest.fn(async () => undefined),
|
|
},
|
|
companyNotifier: { changeRequestSubmitted: jest.fn() },
|
|
verifayda: {
|
|
completeVerification: jest.fn(async () => ctx.verification),
|
|
},
|
|
};
|
|
|
|
const service = new CompaniesService(
|
|
deps.companiesRepo as never,
|
|
deps.companyProfilesRepo as never,
|
|
deps.changeRequestRepo as never,
|
|
deps.revisionRepo as never,
|
|
deps.profilesRepo as never,
|
|
{} as never,
|
|
deps.filesService as never,
|
|
{} as never,
|
|
{} as never,
|
|
deps.companyNotifier as never,
|
|
{} as never,
|
|
deps.verifayda as never,
|
|
);
|
|
|
|
jest
|
|
.spyOn(service, "getCompanyInfoByUserId")
|
|
.mockImplementation(
|
|
async () =>
|
|
({ profile: { id: "external-1" }, company: company() }) as never,
|
|
);
|
|
|
|
return { service, ctx, deps, company };
|
|
}
|
|
|
|
describe("Fayda identity verification binds a person to the company", () => {
|
|
it("writes the verified identity", async () => {
|
|
const { service, ctx } = makeService();
|
|
|
|
const state = await service.completeIdentityVerification("user-1", {
|
|
subject: "owner",
|
|
code: "c",
|
|
state: "s",
|
|
});
|
|
|
|
expect(ctx.attributes.ownerFaydaSub).toBe("new-sub");
|
|
expect(ctx.attributes.ownerName).toBe("Haile Gebrselassie");
|
|
expect(state.owner.verified).toBe(true);
|
|
});
|
|
|
|
it("fills every PoA detail from the payload, address included", async () => {
|
|
const { service, ctx } = makeService();
|
|
|
|
await service.completeIdentityVerification("user-1", {
|
|
subject: "poa",
|
|
code: "c",
|
|
state: "s",
|
|
});
|
|
|
|
expect(ctx.attributes.poaName).toBe("Haile Gebrselassie");
|
|
expect(ctx.attributes.poaEmail).toBe("haile@example.com");
|
|
expect(ctx.attributes.poaPhone).toBe("+251922000000");
|
|
expect(ctx.attributes.poaAddress).toBe("Addis Ababa");
|
|
});
|
|
|
|
it("verifies successfully even though Fayda returns no national ID number", async () => {
|
|
// Fayda's userinfo carries no FAN/FIN claim at all — this must be the
|
|
// normal, successful path, not an error.
|
|
const { service } = makeService({
|
|
verification: {
|
|
purpose: "VERIFY",
|
|
verified: true,
|
|
sub: "x",
|
|
fullName: "No Fan Here",
|
|
},
|
|
});
|
|
|
|
const state = await service.completeIdentityVerification("user-1", {
|
|
subject: "owner",
|
|
code: "c",
|
|
state: "s",
|
|
});
|
|
|
|
expect(state.owner.verified).toBe(true);
|
|
});
|
|
|
|
it("refuses to make one identity both owner and PoA", async () => {
|
|
const { service } = makeService({
|
|
attributes: { ownerFaydaSub: "same-person" },
|
|
verification: {
|
|
purpose: "VERIFY",
|
|
verified: true,
|
|
sub: "same-person",
|
|
fullName: "Abebe Bikila",
|
|
},
|
|
});
|
|
|
|
await expect(
|
|
service.completeIdentityVerification("user-1", {
|
|
subject: "poa",
|
|
code: "c",
|
|
state: "s",
|
|
}),
|
|
).rejects.toBeInstanceOf(BadRequestException);
|
|
});
|
|
|
|
it("stages an owner re-verification for review on an approved company", async () => {
|
|
// The owner is the live company's identity proof, so re-verifying one is
|
|
// exactly what the backoffice review exists for: it must not rewrite the
|
|
// row directly.
|
|
const { service, ctx, deps } = makeService({
|
|
status: CompanyStatus.Active,
|
|
});
|
|
|
|
await service.completeIdentityVerification("user-1", {
|
|
subject: "owner",
|
|
code: "c",
|
|
state: "s",
|
|
});
|
|
|
|
expect(deps.changeRequestRepo.create).toHaveBeenCalled();
|
|
expect(ctx.attributes.ownerFaydaSub).toBeUndefined();
|
|
});
|
|
|
|
it("applies a PoA verification live on an approved company", async () => {
|
|
// The PoA is personnel the company names for itself — the delegation paper
|
|
// is what a reviewer actually judges — so it does not go to review.
|
|
const { service, ctx, deps } = makeService({
|
|
status: CompanyStatus.Active,
|
|
});
|
|
|
|
await service.completeIdentityVerification("user-1", {
|
|
subject: "poa",
|
|
code: "c",
|
|
state: "s",
|
|
});
|
|
|
|
expect(deps.changeRequestRepo.create).not.toHaveBeenCalled();
|
|
expect(ctx.attributes.poaFaydaSub).toBe("new-sub");
|
|
});
|
|
|
|
it("refuses to rename a verified person by hand", async () => {
|
|
const { service } = makeService({
|
|
attributes: { ...OWNER_VERIFIED, ...POA_VERIFIED },
|
|
files: [paper()],
|
|
});
|
|
|
|
await expect(
|
|
service.updateProfile("user-1", { poaName: "Someone Else" } as never),
|
|
).rejects.toBeInstanceOf(BadRequestException);
|
|
});
|
|
|
|
it("never locks or gates the general manager — it is not the verified subject", async () => {
|
|
// GM is a plain typed role; the portal offers a "same as owner" copy, but
|
|
// the backend must not treat it as identity-owned or require it verified.
|
|
const { service } = makeService({
|
|
attributes: { ...OWNER_VERIFIED },
|
|
});
|
|
|
|
await expect(
|
|
service.updateProfile("user-1", {
|
|
generalManagerName: "Someone Else",
|
|
generalManagerEmail: "someone@example.com",
|
|
generalManagerPhone: "+251911223344",
|
|
} as never),
|
|
).resolves.toBeDefined();
|
|
});
|
|
});
|
|
|
|
describe("Ethiopian companies verify with Fayda; foreign companies verify identity by passport", () => {
|
|
// The company is applying for the forwarder role, so it must not already
|
|
// hold it — createCompanyProfileForUser short-circuits on an existing profile
|
|
// and would never reach the gate.
|
|
const applyingForFf = {
|
|
profileTypes: [ProfileType.importer],
|
|
attributes: { ...POA_VERIFIED },
|
|
files: [paper()],
|
|
};
|
|
|
|
it("blocks the forwarder role while the owner is unverified", async () => {
|
|
const { service } = makeService(applyingForFf);
|
|
|
|
await expect(
|
|
service.createCompanyProfileForUser(
|
|
"user-1",
|
|
ProfileType.freightForwarder,
|
|
),
|
|
).rejects.toBeInstanceOf(BadRequestException);
|
|
});
|
|
|
|
it("blocks the forwarder role while the PoA is unverified", async () => {
|
|
const { service } = makeService({
|
|
profileTypes: [ProfileType.importer],
|
|
attributes: {
|
|
...OWNER_VERIFIED,
|
|
poaName: "Tirunesh Dibaba",
|
|
poaEmail: "t@example.com",
|
|
poaPhone: "+251911000000",
|
|
},
|
|
files: [paper()],
|
|
});
|
|
|
|
await expect(
|
|
service.createCompanyProfileForUser(
|
|
"user-1",
|
|
ProfileType.freightForwarder,
|
|
),
|
|
).rejects.toBeInstanceOf(BadRequestException);
|
|
});
|
|
|
|
it("grants the forwarder role once owner and PoA are both verified", async () => {
|
|
const { service } = makeService({
|
|
profileTypes: [ProfileType.importer],
|
|
attributes: { ...OWNER_VERIFIED, ...POA_VERIFIED },
|
|
files: [paper()],
|
|
});
|
|
|
|
await expect(
|
|
service.createCompanyProfileForUser(
|
|
"user-1",
|
|
ProfileType.freightForwarder,
|
|
),
|
|
).resolves.toBeDefined();
|
|
});
|
|
|
|
it("never asks a foreign company for Fayda, verified or not", async () => {
|
|
const { service } = makeService({
|
|
nationality: CompanyNationality.Foreign,
|
|
});
|
|
|
|
const state = await service.completeIdentityVerification("user-1", {
|
|
subject: "owner",
|
|
code: "c",
|
|
state: "s",
|
|
});
|
|
|
|
// Still lets the owner verify — a foreign owner verifying is allowed, just
|
|
// never required — but the passport is the thing that actually gates it.
|
|
expect(state.owner.verified).toBe(true);
|
|
expect(state.faydaRequired).toBe(false);
|
|
expect(state.passportRequired).toBe(true);
|
|
});
|
|
|
|
it("blocks the forwarder role for a foreign company with no owner passport", async () => {
|
|
const { service } = makeService({
|
|
profileTypes: [ProfileType.importer],
|
|
nationality: CompanyNationality.Foreign,
|
|
attributes: {
|
|
poaName: "Jean Dupont",
|
|
poaEmail: "jean@example.com",
|
|
poaPhone: "+33100000000",
|
|
},
|
|
});
|
|
|
|
await expect(
|
|
service.createCompanyProfileForUser(
|
|
"user-1",
|
|
ProfileType.freightForwarder,
|
|
),
|
|
).rejects.toBeInstanceOf(BadRequestException);
|
|
});
|
|
|
|
it("grants the forwarder role to a foreign company whose owner has a passport and whose PoA is Fayda-verified", async () => {
|
|
const { service } = makeService({
|
|
profileTypes: [ProfileType.importer],
|
|
nationality: CompanyNationality.Foreign,
|
|
attributes: {
|
|
ownerPassportNumber: "P1234567",
|
|
...POA_VERIFIED,
|
|
},
|
|
files: [paper()],
|
|
});
|
|
|
|
await expect(
|
|
service.createCompanyProfileForUser(
|
|
"user-1",
|
|
ProfileType.freightForwarder,
|
|
),
|
|
).resolves.toBeDefined();
|
|
});
|
|
|
|
it("accepts a typed PoA from a foreign company, whose representative may hold no Fayda ID", async () => {
|
|
// Fayda is an Ethiopian national ID, so only an Ethiopian company's
|
|
// representative can be held to it. A foreign company is offered the
|
|
// verification and uses it where its representative holds one, but a typed
|
|
// name stays sufficient — holding it to Fayda would leave a foreign
|
|
// company whose representative has no Fayda ID unable to trade at all.
|
|
const { service } = makeService({
|
|
profileTypes: [ProfileType.importer],
|
|
nationality: CompanyNationality.Foreign,
|
|
attributes: {
|
|
ownerPassportNumber: "P1234567",
|
|
poaName: "Jean Dupont",
|
|
poaEmail: "jean@example.com",
|
|
poaPhone: "+33100000000",
|
|
},
|
|
files: [paper()],
|
|
});
|
|
|
|
await expect(
|
|
service.createCompanyProfileForUser(
|
|
"user-1",
|
|
ProfileType.freightForwarder,
|
|
),
|
|
).resolves.toBeDefined();
|
|
});
|
|
|
|
it("still refuses a foreign company that named no PoA at all", async () => {
|
|
// The typed fallback is a different credential, not a waiver: a freight
|
|
// forwarder acts on other companies' behalf and needs a representative
|
|
// whatever its nationality.
|
|
const { service } = makeService({
|
|
profileTypes: [ProfileType.importer],
|
|
nationality: CompanyNationality.Foreign,
|
|
attributes: { ownerPassportNumber: "P1234567" },
|
|
files: [paper()],
|
|
});
|
|
|
|
await expect(
|
|
service.createCompanyProfileForUser(
|
|
"user-1",
|
|
ProfileType.freightForwarder,
|
|
),
|
|
).rejects.toBeInstanceOf(BadRequestException);
|
|
});
|
|
|
|
it("holds an Ethiopian company to a Fayda-verified PoA, typed details notwithstanding", async () => {
|
|
// The relaxation above is scoped to foreign companies only — an Ethiopian
|
|
// representative holds a Fayda ID, so typing a name must not substitute.
|
|
const { service } = makeService({
|
|
profileTypes: [ProfileType.importer],
|
|
nationality: CompanyNationality.Ethiopian,
|
|
attributes: {
|
|
...OWNER_VERIFIED,
|
|
poaName: "Abebe Bekele",
|
|
poaEmail: "abebe@example.com",
|
|
poaPhone: "+251911000000",
|
|
},
|
|
files: [paper()],
|
|
});
|
|
|
|
await expect(
|
|
service.createCompanyProfileForUser(
|
|
"user-1",
|
|
ProfileType.freightForwarder,
|
|
),
|
|
).rejects.toBeInstanceOf(BadRequestException);
|
|
});
|
|
|
|
it("still requires the passport for a foreign owner who chose to verify with Fayda too", async () => {
|
|
// Verifying is optional for a foreign owner, but it does not waive the
|
|
// passport requirement — the two are independent credentials.
|
|
const { service } = makeService({
|
|
profileTypes: [ProfileType.importer],
|
|
nationality: CompanyNationality.Foreign,
|
|
attributes: {
|
|
...OWNER_VERIFIED,
|
|
poaName: "Jean Dupont",
|
|
poaEmail: "jean@example.com",
|
|
poaPhone: "+33100000000",
|
|
},
|
|
});
|
|
|
|
await expect(
|
|
service.createCompanyProfileForUser(
|
|
"user-1",
|
|
ProfileType.freightForwarder,
|
|
),
|
|
).rejects.toBeInstanceOf(BadRequestException);
|
|
});
|
|
|
|
// -------------------------------------------------------------------------
|
|
// General manager
|
|
// -------------------------------------------------------------------------
|
|
|
|
it("reuses the owner's verified identity when the GM is declared the same person", async () => {
|
|
// The GM is very often the owner. Copying the proven identity is the whole
|
|
// point — asking one human to complete two verifications proves nothing
|
|
// extra, and typing the details instead would forge a verified badge.
|
|
const { service, ctx } = makeService({
|
|
attributes: {
|
|
...OWNER_VERIFIED,
|
|
ownerEmail: "abebe@example.com",
|
|
ownerPhone: "+251911222333",
|
|
},
|
|
});
|
|
|
|
const state = await service.setGmSameAsOwner("user-1");
|
|
|
|
expect(state.gm.verified).toBe(true);
|
|
expect(state.gmSameAsOwner).toBe(true);
|
|
expect(state.gm.name).toBe("Abebe Bikila");
|
|
expect(ctx.attributes.gmFaydaSub).toBe("owner-sub");
|
|
// The notifiers mail the flat column, so a linked GM has to land there too.
|
|
expect(ctx.attributes.generalManagerEmail).toBe("abebe@example.com");
|
|
});
|
|
|
|
it("refuses to declare the GM is the owner while the owner is unverified", async () => {
|
|
// Without a verification there is no proven identity to copy — only typed
|
|
// text, which would arrive wearing a badge it had not earned.
|
|
const { service } = makeService({ attributes: {} });
|
|
|
|
await expect(service.setGmSameAsOwner("user-1")).rejects.toBeInstanceOf(
|
|
BadRequestException,
|
|
);
|
|
});
|
|
|
|
it("lets the GM verify as the same human as the owner", async () => {
|
|
// The owner/PoA collision check exists because self-delegation is not
|
|
// delegation. It must not fire here: the GM being the owner is a supported
|
|
// answer, so verifying with the owner's own Fayda sub has to succeed.
|
|
const { service, ctx } = makeService({
|
|
attributes: { ...OWNER_VERIFIED },
|
|
verification: {
|
|
purpose: "VERIFY",
|
|
verified: true,
|
|
sub: "owner-sub",
|
|
fullName: "Abebe Bikila",
|
|
email: "abebe@example.com",
|
|
phoneNumber: "+251911222333",
|
|
},
|
|
});
|
|
|
|
const state = await service.completeIdentityVerification("user-1", {
|
|
subject: "gm",
|
|
code: "c",
|
|
state: "s",
|
|
});
|
|
|
|
expect(state.gm.verified).toBe(true);
|
|
expect(ctx.attributes.gmFaydaSub).toBe("owner-sub");
|
|
expect(ctx.attributes.generalManagerName).toBe("Abebe Bikila");
|
|
});
|
|
|
|
it("still refuses a PoA who is the owner", async () => {
|
|
// The GM exemption above must not have widened into the PoA.
|
|
const { service } = makeService({
|
|
attributes: { ...OWNER_VERIFIED },
|
|
verification: {
|
|
purpose: "VERIFY",
|
|
verified: true,
|
|
sub: "owner-sub",
|
|
fullName: "Abebe Bikila",
|
|
},
|
|
});
|
|
|
|
await expect(
|
|
service.completeIdentityVerification("user-1", {
|
|
subject: "poa",
|
|
code: "c",
|
|
state: "s",
|
|
}),
|
|
).rejects.toBeInstanceOf(BadRequestException);
|
|
});
|
|
|
|
it("reports a pre-existing typed GM as unverified rather than blank", async () => {
|
|
// Companies onboarded before the GM was verifiable have typed details and
|
|
// no gm* attributes. Those details are still what the notifiers mail, so
|
|
// they must survive — flagged unverified so the portal offers the upgrade.
|
|
const { service, company } = makeService({
|
|
attributes: {
|
|
...OWNER_VERIFIED,
|
|
generalManagerName: "Legacy Manager",
|
|
generalManagerEmail: "legacy@example.com",
|
|
},
|
|
});
|
|
|
|
const state = service.getCompanyIdentityState(company() as never);
|
|
|
|
expect(state.gm.verified).toBe(false);
|
|
expect(state.gm.name).toBe("Legacy Manager");
|
|
expect(state.gm.email).toBe("legacy@example.com");
|
|
});
|
|
|
|
it("does not let an unproven GM block the company from trading", async () => {
|
|
// The GM names who to talk to, not what the company may do. Capturing it
|
|
// through Fayda changed how it is collected, not whether it gates.
|
|
const { service } = makeService({
|
|
attributes: { ...OWNER_VERIFIED },
|
|
});
|
|
|
|
await expect(
|
|
service.createCompanyProfileForUser("user-1", ProfileType.importer),
|
|
).resolves.toBeDefined();
|
|
});
|
|
});
|