Files
edr-platform/apps/edr-freight-api/src/modules/companies/companies.fayda-identity.spec.ts

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();
});
});