mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
3
.gitignore
vendored
3
.gitignore
vendored
@@ -49,3 +49,6 @@ test-results/
|
||||
playwright-report/
|
||||
blob-report/
|
||||
RUNNING_LOCALLY.md
|
||||
|
||||
# Generated per-shard compose file for the integration suite (it.mjs).
|
||||
integration/.it-shards.yaml
|
||||
|
||||
@@ -98,10 +98,10 @@ FAYDA_PRIVATE_KEY_BASE64=
|
||||
# OAuth redirect_uri for MOBILE clients (must be registered with eSignet)
|
||||
FAYDA_REDIRECT_URI=http://localhost:3001/api/fayda/verification/complete
|
||||
# OAuth redirect_uri for WEB clients. Defaults to FAYDA_REDIRECT_URI when unset.
|
||||
FAYDA_WEB_REDIRECT_URI=http://localhost:3000/callback
|
||||
FAYDA_WEB_REDIRECT_URI=http://localhost:3000/fayda/callback
|
||||
# OAuth redirect_uri for the customer portal (its own origin — must also be
|
||||
# registered with eSignet). Defaults to FAYDA_WEB_REDIRECT_URI when unset.
|
||||
FAYDA_PORTAL_REDIRECT_URI=http://localhost:5173/callback
|
||||
FAYDA_PORTAL_REDIRECT_URI=http://localhost:5173/fayda/callback
|
||||
CLIENT_ASSERTION_TYPE=urn:ietf:params:oauth:client-assertion-type:jwt-bearer
|
||||
FAYDA_SCOPE=openid profile email phone address
|
||||
FAYDA_ACR_VALUES=mosip:idp:acr:generated-code
|
||||
|
||||
@@ -21,7 +21,7 @@ flowchart TD
|
||||
S0(["Customer visits portal"]):::start
|
||||
S0 --> S1["Signup via IAM<br/>GET /auth/check-availability @Public<br/>POST /otp/send + /otp/verify (P)"]:::port
|
||||
S1 --> S2{"Identity proofing<br/>(VeriFayda)?"}:::dec
|
||||
S2 -->|"Yes"| S3["POST /fayda/verification/start →<br/>/callback → /complete<br/>upsert iam.users (verified_by=fayda) (P)"]:::port
|
||||
S2 -->|"Yes"| S3["POST /fayda/verification/start →<br/>/fayda/callback → /complete<br/>upsert iam.users (verified_by=fayda) (P)"]:::port
|
||||
S2 -->|"No"| S4
|
||||
S3 --> S4["POST /companies/onboarding/start<br/>draft company (placeholder TIN, PENDING) (P)"]:::port
|
||||
S4 --> S4b["Wizard: PATCH /profile, /onboarding-step,<br/>upload license + docs<br/>GET /onboarding/requirements (P)"]:::port
|
||||
|
||||
@@ -131,7 +131,7 @@ sequenceDiagram
|
||||
`HasActiveDelegationGuard` as **global `APP_GUARD`s** — *every* route is JWT-protected unless it
|
||||
carries `@Public()`. Fine-grained `FreightPermissionGuard([perm])` decorators add permission checks
|
||||
on staff routes. Explicitly **public** endpoints: `GET /api/files/:fileId`, `POST /api/otp/{send,verify}`,
|
||||
`GET /api/auth/check-availability`, the `fayda/verification/*` + `/callback` endpoints,
|
||||
`GET /api/auth/check-availability`, the `fayda/verification/*` + `/fayda/callback` endpoints,
|
||||
`GET /api/payments/{checkout,receipt/:orderId}`, and the service-to-service `POST /api/internal/payments/mark-paid`.
|
||||
Real login / JWT issuance lives in the **external IAM package**, not this repo. (Note: `@edr/api-common`'s
|
||||
`@Public` and `@tria-plc/api-common`'s `@IsPublic` both set the same `"isPublic"` metadata key the guard reads.)
|
||||
@@ -288,7 +288,7 @@ flowchart TD
|
||||
chk --> otp["POST /otp/send + /otp/verify (P) @Public"]
|
||||
otp --> fayda{"Identity proofing?"}
|
||||
fayda -->|"VeriFayda 2.0"| fstart["POST /fayda/verification/start<br/>→ eSignet authorize URL"]
|
||||
fstart --> fcb["Fayda redirect → GET /callback (ack)<br/>→ GET /fayda/verification/complete<br/>(PKCE code exchange → upsert iam.users)"]
|
||||
fstart --> fcb["Fayda redirect → GET /fayda/callback (ack)<br/>→ GET /fayda/verification/complete<br/>(PKCE code exchange → upsert iam.users)"]
|
||||
fcb --> onb
|
||||
fayda -->|"skip"| onb
|
||||
|
||||
@@ -313,7 +313,7 @@ drives the required document set. Booking guards elsewhere `403` if the acting p
|
||||
| POST | `/api/fayda/verification/start` | start eSignet session (PKCE) | `@Public` + OptionalJwt | (B) verifayda.service |
|
||||
| GET | `/api/fayda/verification/complete` | code→identity, upsert `iam.users` | `@Public` | (B) verifayda.service |
|
||||
| GET | `/api/fayda/verification/status` | current user's Fayda link | JwtGuard | — |
|
||||
| GET | `/callback` | passive Fayda redirect ack (no `/api`) | `@Public` | popup postMessage |
|
||||
| GET | `/fayda/callback` | passive Fayda redirect ack (no `/api`) | `@Public` | popup postMessage |
|
||||
| GET·PUT | `/api/me/signature` | reusable signature (MinIO, base64) | JwtGuard | (P)(B) signatures.service |
|
||||
| GET | `/api/test_user1` · `/api/test_user2` | permission-guard demo | `PermissionGuard` | (B) demo pages |
|
||||
| GET | `/api/companies/getInfo` · `/profile` · `/dashboard` | company info / KPIs | JwtGuard | (P) companies.service |
|
||||
|
||||
@@ -19,7 +19,7 @@ import { AppModule } from "./app.module";
|
||||
* ~13.4MB on the wire. Express defaults to 100kb, which rejected any real stamp
|
||||
* image with a 413 "request entity too large".
|
||||
*/
|
||||
const JSON_BODY_LIMIT = '20mb';
|
||||
const JSON_BODY_LIMIT = "20mb";
|
||||
|
||||
/**
|
||||
* Static /etc/hosts-style overrides from `DNS_HOST_OVERRIDES`, formatted as
|
||||
@@ -44,7 +44,9 @@ function applyDnsHostOverrides(): void {
|
||||
}
|
||||
if (overrides.size === 0) return;
|
||||
|
||||
const dns = createRequire(__filename)("node:dns") as typeof import("node:dns");
|
||||
const dns = createRequire(__filename)(
|
||||
"node:dns",
|
||||
) as typeof import("node:dns");
|
||||
const originalLookup = dns.lookup.bind(dns);
|
||||
// `dns.lookup` is overloaded (options optional, all/family variants); the
|
||||
// cast keeps that surface intact while we intercept only mapped hostnames.
|
||||
@@ -63,7 +65,9 @@ function applyDnsHostOverrides(): void {
|
||||
) => void;
|
||||
const family = ip.includes(":") ? 6 : 4;
|
||||
const wantsAll =
|
||||
typeof options === "object" && options !== null && (options as { all?: boolean }).all;
|
||||
typeof options === "object" &&
|
||||
options !== null &&
|
||||
(options as { all?: boolean }).all;
|
||||
|
||||
process.nextTick(() =>
|
||||
wantsAll ? done(null, [{ address: ip, family }]) : done(null, ip, family),
|
||||
@@ -77,7 +81,15 @@ function applyDnsHostOverrides(): void {
|
||||
|
||||
applyDnsHostOverrides();
|
||||
|
||||
async function bootstrap() {
|
||||
/**
|
||||
* Build the app with every global the production process applies, but do NOT
|
||||
* listen. Exported so a test harness can boot the REAL app in its own process
|
||||
* (integration/src/app.ts) and get the same prefix, pipe, filter, interceptor
|
||||
* and body-parser configuration — replaying this list by hand is how an e2e
|
||||
* harness silently drifts from production (routes 404 without the "api"
|
||||
* prefix, responses lose the transform envelope).
|
||||
*/
|
||||
export async function createFreightApp(): Promise<NestExpressApplication> {
|
||||
const app = await NestFactory.create<NestExpressApplication>(AppModule);
|
||||
|
||||
// Nest's own body-parser API, NOT `app.use(json(...))` from express: express
|
||||
@@ -86,14 +98,14 @@ async function bootstrap() {
|
||||
// pnpm's hoisted dev store and died as MODULE_NOT_FOUND in the production
|
||||
// image, where `pnpm deploy --prod` installs declared dependencies only.
|
||||
// This also RECONFIGURES the default parsers rather than racing them.
|
||||
app.useBodyParser('json', { limit: JSON_BODY_LIMIT });
|
||||
app.useBodyParser('urlencoded', { limit: JSON_BODY_LIMIT, extended: true });
|
||||
app.useBodyParser("json", { limit: JSON_BODY_LIMIT });
|
||||
app.useBodyParser("urlencoded", { limit: JSON_BODY_LIMIT, extended: true });
|
||||
|
||||
// Dev CORS: reflect any localhost origin and allow credentials so the
|
||||
// freight portal (5173), passenger portal (5174), backoffices (5183/5184)
|
||||
// and any other dev port can call the API with cookies + Authorization.
|
||||
// For production, restrict `origin` to known FQDNs.
|
||||
|
||||
|
||||
app.enableCors({
|
||||
origin: true, // reflect request origin
|
||||
credentials: true,
|
||||
@@ -130,8 +142,11 @@ async function bootstrap() {
|
||||
maxAge: 86400, // cache preflight for 24h to cut chatter in dev
|
||||
});
|
||||
|
||||
// /callback stays un-prefixed: it's the Fayda OAuth redirect_uri ack endpoint.
|
||||
app.setGlobalPrefix("api", { exclude: ["callback"] });
|
||||
// /fayda/callback stays un-prefixed: it's the Fayda OAuth redirect_uri ack
|
||||
// endpoint. Exact path, not "fayda" — exclusion is an exact route match, so
|
||||
// "fayda" would leave /fayda/callback prefixed (404 at the registered
|
||||
// redirect_uri) while still reading as if it covered the whole subtree.
|
||||
app.setGlobalPrefix("api", { exclude: ["fayda/callback"] });
|
||||
// enableImplicitConversion is OFF: class-transformer's implicit boolean
|
||||
// coercion turns any non-empty multipart/form-data string (including the
|
||||
// literal "false") into `true`, silently corrupting flags like isHazardous
|
||||
@@ -154,13 +169,21 @@ async function bootstrap() {
|
||||
const document = SwaggerModule.createDocument(app, config);
|
||||
SwaggerModule.setup("api/docs", app, document);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await createFreightApp();
|
||||
const port = parseInt(process.env.PORT ?? "3001", 10);
|
||||
// await app.listen(port, "0.0.0.0");
|
||||
await app.listen(
|
||||
|
||||
port)
|
||||
await app.listen(port);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[freight-api] listening on port ${port}`);
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
// Only self-start when this file IS the entrypoint. The Dockerfile's
|
||||
// `CMD ["node", "dist/main.js"]` still boots; importers get `createFreightApp`
|
||||
// without the process binding a port behind their back.
|
||||
if (require.main === module) {
|
||||
bootstrap();
|
||||
}
|
||||
|
||||
@@ -408,6 +408,30 @@ export class CompaniesController {
|
||||
return this.companiesService.completeIdentityVerification(user.id, dto);
|
||||
}
|
||||
|
||||
@Post("identity/gm/same-as-owner")
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Declare the General Manager is the company's owner, copying the owner's verified identity across. " +
|
||||
"Refused until the owner is Fayda-verified — there would be nothing proven to copy.",
|
||||
})
|
||||
async setGmSameAsOwner(
|
||||
@CurrentUser() user: CurrentIamUser,
|
||||
): Promise<CompanyIdentityStateDto> {
|
||||
return this.companiesService.setGmSameAsOwner(user.id);
|
||||
}
|
||||
|
||||
@Delete("identity/gm")
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Clear the General Manager's identity — the \"same as owner\" declaration or a verification, and the details either wrote. " +
|
||||
"Leaves the GM open to be verified in their own right, or typed where Fayda is optional.",
|
||||
})
|
||||
async clearGmIdentity(
|
||||
@CurrentUser() user: CurrentIamUser,
|
||||
): Promise<CompanyIdentityStateDto> {
|
||||
return this.companiesService.clearGmIdentity(user.id);
|
||||
}
|
||||
|
||||
@Delete("identity/fayda/poa")
|
||||
@ApiOperation({
|
||||
summary:
|
||||
|
||||
@@ -21,9 +21,11 @@ import { POA_DELEGATION_FILE_KEY } from "../file-upload-settings/poa-delegation.
|
||||
* 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 — GM is a separate, plain typed role
|
||||
* the portal offers a "same as owner" copy for, but it is never itself
|
||||
* Fayda-verified or gated on.
|
||||
* 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 {
|
||||
@@ -418,10 +420,12 @@ describe("Ethiopian companies verify with Fayda; foreign companies verify identi
|
||||
).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it("still requires a Fayda-verified PoA from a foreign company", async () => {
|
||||
// The owner's credential is nationality-specific; the representative's is
|
||||
// not. A PoA acts for the company inside Ethiopia whoever owns it, so a
|
||||
// typed foreign name is not a representative the platform can accept.
|
||||
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,
|
||||
@@ -434,6 +438,48 @@ describe("Ethiopian companies verify with Fayda; foreign companies verify identi
|
||||
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",
|
||||
@@ -463,4 +509,119 @@ describe("Ethiopian companies verify with Fayda; foreign companies verify identi
|
||||
),
|
||||
).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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
buildCompanyIdentityState,
|
||||
CompanyIdentityStateDto,
|
||||
CompleteIdentityVerificationDto,
|
||||
IDENTITY_SUBJECTS,
|
||||
IdentitySubject,
|
||||
} from "./dto/complete-identity-verification.dto";
|
||||
import { ETradeService } from "./services/etrade.service";
|
||||
@@ -122,31 +123,47 @@ const REQUIRED_POA_FIELDS: { key: string; label: string }[] = [
|
||||
|
||||
/**
|
||||
* `attributes` key prefix per verifiable person. The owner is NOT the general
|
||||
* manager — GM is a plain typed role (the portal offers a "same as owner" copy
|
||||
* once the owner is verified), while the owner is who this verification
|
||||
* actually proves. They're very often the same human; that's what the copy is
|
||||
* for.
|
||||
* manager: the owner is who the verification proves the company through, the
|
||||
* GM is personnel it names. They're very often the same human, which is what
|
||||
* the portal's "same as owner" copy is for.
|
||||
*/
|
||||
const IDENTITY_PREFIX: Record<IdentitySubject, "owner" | "poa"> = {
|
||||
const IDENTITY_PREFIX: Record<IdentitySubject, string> = {
|
||||
owner: "owner",
|
||||
poa: "poa",
|
||||
gm: "gm",
|
||||
};
|
||||
|
||||
const IDENTITY_LABEL: Record<IdentitySubject, string> = {
|
||||
owner: "owner",
|
||||
poa: "Power of Attorney",
|
||||
gm: "General Manager",
|
||||
};
|
||||
|
||||
/**
|
||||
* Typed GM columns a GM verification also writes. Three notifier services mail
|
||||
* `company.generalManagerEmail` directly, so leaving these behind would mean a
|
||||
* verified GM whose address the system never actually uses.
|
||||
*/
|
||||
const GM_TYPED_FIELDS = [
|
||||
"generalManagerName",
|
||||
"generalManagerEmail",
|
||||
"generalManagerPhone",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Identity fields a Fayda verification owns outright, per person. Once verified
|
||||
* these can no longer be typed — the government IdP is the source, so an edit
|
||||
* that disagrees with it is either a mistake or an attempt to launder the
|
||||
* guarantee away. The GM fields are deliberately absent: GM is never itself
|
||||
* Fayda-verified, so it stays freely editable regardless of the owner's state.
|
||||
* guarantee away.
|
||||
*
|
||||
* The GM's entries are its typed columns: a verified GM is locked the same way
|
||||
* the others are, while an unverified one (a foreign company's, or a record
|
||||
* that predates this) stays freely editable.
|
||||
*/
|
||||
const IDENTITY_OWNED_FIELDS: Record<IdentitySubject, string[]> = {
|
||||
owner: ["ownerName", "ownerEmail", "ownerPhone", "ownerAddress"],
|
||||
poa: ["poaName", "poaEmail", "poaPhone", "poaAddress"],
|
||||
gm: [...GM_TYPED_FIELDS],
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -834,7 +851,7 @@ export class CompaniesService {
|
||||
|
||||
// Renaming a Fayda-verified person by hand would launder the guarantee
|
||||
// away, so the fields the verification owns are refused once it exists.
|
||||
for (const subject of ["owner", "poa"] as IdentitySubject[]) {
|
||||
for (const subject of IDENTITY_SUBJECTS) {
|
||||
if (!attrUpdates[`${IDENTITY_PREFIX[subject]}FaydaSub`]) continue;
|
||||
for (const field of IDENTITY_OWNED_FIELDS[subject]) {
|
||||
const incoming = (dto as Record<string, unknown>)[field];
|
||||
@@ -2693,12 +2710,17 @@ export class CompaniesService {
|
||||
|
||||
// The owner delegating power of attorney to themselves is not a
|
||||
// delegation — it would let one identity satisfy both halves of the check.
|
||||
const other: IdentitySubject = dto.subject === "poa" ? "owner" : "poa";
|
||||
const otherSub = company.attributes?.[`${IDENTITY_PREFIX[other]}FaydaSub`];
|
||||
if (otherSub && otherSub === result.sub) {
|
||||
throw new BadRequestException(
|
||||
`This identity is already registered as the company's ${IDENTITY_LABEL[other]}. The Power of Attorney must be a different person from the owner.`,
|
||||
);
|
||||
// Only owner/PoA collide this way: the GM is very often the owner, and
|
||||
// saying so is a supported answer rather than a conflict, so it is left out
|
||||
// of this check entirely.
|
||||
if (dto.subject === "owner" || dto.subject === "poa") {
|
||||
const other: IdentitySubject = dto.subject === "poa" ? "owner" : "poa";
|
||||
const otherSub = company.attributes?.[`${IDENTITY_PREFIX[other]}FaydaSub`];
|
||||
if (otherSub && otherSub === result.sub) {
|
||||
throw new BadRequestException(
|
||||
`This identity is already registered as the company's ${IDENTITY_LABEL[other]}. The Power of Attorney must be a different person from the owner.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
@@ -2714,13 +2736,26 @@ export class CompaniesService {
|
||||
...(result.address ? { [`${prefix}Address`]: result.address } : {}),
|
||||
};
|
||||
|
||||
// A GM verification also lands on the typed columns the rest of the system
|
||||
// already reads (the booking, train-scheduling and contract notifiers all
|
||||
// mail `generalManagerEmail`), and clears any earlier "same as owner"
|
||||
// declaration — verifying in their own right is the GM answering for
|
||||
// themselves.
|
||||
if (dto.subject === "gm") {
|
||||
identity.gmSameAsOwner = false;
|
||||
if (result.fullName) identity.generalManagerName = result.fullName;
|
||||
if (result.email) identity.generalManagerEmail = result.email;
|
||||
if (result.phoneNumber)
|
||||
identity.generalManagerPhone = normalizeE164(result.phoneNumber);
|
||||
}
|
||||
|
||||
// An approved company's *owner* is its identity proof, so re-verifying one
|
||||
// is staged for backoffice review rather than quietly rewriting a live
|
||||
// record. The PoA is personnel — the company names its own representative,
|
||||
// and the delegation letter backing them is what the reviewer sees — so a
|
||||
// PoA verification lands live, matching the typed PoA fields in
|
||||
// `SELF_SERVICE_ATTRIBUTES`.
|
||||
if (company.status === CompanyStatus.Active && dto.subject !== "poa") {
|
||||
// record. The PoA and GM are personnel — the company names its own
|
||||
// representative and manager, and the delegation letter backing the PoA is
|
||||
// what the reviewer sees — so those land live, matching their typed
|
||||
// counterparts in `SELF_SERVICE_ATTRIBUTES`.
|
||||
if (company.status === CompanyStatus.Active && dto.subject === "owner") {
|
||||
await this.stageIdentityChange(company, userId, identity);
|
||||
return this.getCompanyIdentityState(company);
|
||||
}
|
||||
@@ -2734,6 +2769,85 @@ export class CompaniesService {
|
||||
return this.getCompanyIdentityState(updated);
|
||||
}
|
||||
|
||||
/**
|
||||
* Declare that the General Manager is the company's owner.
|
||||
*
|
||||
* The GM is very often the owner, and making that human verify twice buys
|
||||
* nothing — the owner's verification already proves them. So this copies the
|
||||
* owner's verified identity across rather than starting a second flow, and
|
||||
* records `gmSameAsOwner` so the portal can show it as a declaration rather
|
||||
* than as a verification the GM passed in their own right.
|
||||
*
|
||||
* Refused until the owner is actually verified: without that there is no
|
||||
* proven identity to copy, only typed text that would arrive wearing a
|
||||
* verified badge.
|
||||
*/
|
||||
async setGmSameAsOwner(userId: string): Promise<CompanyIdentityStateDto> {
|
||||
const { company } = await this.getCompanyInfoByUserId(userId);
|
||||
const attrs = company.attributes ?? {};
|
||||
const ownerSub = attrs.ownerFaydaSub as string | undefined;
|
||||
if (!ownerSub) {
|
||||
throw new BadRequestException(
|
||||
"Verify the company owner with Fayda first — there is no proven identity to reuse yet.",
|
||||
);
|
||||
}
|
||||
|
||||
const copied: Record<string, unknown> = {
|
||||
gmSameAsOwner: true,
|
||||
gmFaydaSub: ownerSub,
|
||||
gmFaydaVerifiedAt: attrs.ownerFaydaVerifiedAt ?? new Date().toISOString(),
|
||||
gmName: attrs.ownerName ?? null,
|
||||
gmEmail: attrs.ownerEmail ?? null,
|
||||
gmPhone: attrs.ownerPhone ?? null,
|
||||
gmAddress: attrs.ownerAddress ?? null,
|
||||
gmBirthdate: attrs.ownerBirthdate ?? null,
|
||||
gmGender: attrs.ownerGender ?? null,
|
||||
// Kept in step for the notifiers, same as a GM verification does.
|
||||
generalManagerName: attrs.ownerName ?? null,
|
||||
generalManagerEmail: attrs.ownerEmail ?? null,
|
||||
generalManagerPhone: attrs.ownerPhone ?? null,
|
||||
};
|
||||
|
||||
const updated = await this.companiesRepo.update(company.id, {
|
||||
attributes: { ...attrs, ...copied },
|
||||
});
|
||||
if (!updated)
|
||||
throw new NotFoundException(`Company ${company.id} not found`);
|
||||
updated.companyProfiles = company.companyProfiles;
|
||||
return this.getCompanyIdentityState(updated);
|
||||
}
|
||||
|
||||
/**
|
||||
* Undo the "same as owner" declaration, clearing the copied identity so the
|
||||
* GM can be verified in their own right (or typed, where Fayda is optional).
|
||||
*/
|
||||
async clearGmIdentity(userId: string): Promise<CompanyIdentityStateDto> {
|
||||
const { company } = await this.getCompanyInfoByUserId(userId);
|
||||
const attrs = { ...(company.attributes ?? {}) };
|
||||
for (const key of [
|
||||
"gmSameAsOwner",
|
||||
"gmFaydaSub",
|
||||
"gmFaydaVerifiedAt",
|
||||
"gmName",
|
||||
"gmEmail",
|
||||
"gmPhone",
|
||||
"gmAddress",
|
||||
"gmBirthdate",
|
||||
"gmGender",
|
||||
...GM_TYPED_FIELDS,
|
||||
]) {
|
||||
attrs[key] = null;
|
||||
}
|
||||
|
||||
const updated = await this.companiesRepo.update(company.id, {
|
||||
attributes: attrs,
|
||||
});
|
||||
if (!updated)
|
||||
throw new NotFoundException(`Company ${company.id} not found`);
|
||||
updated.companyProfiles = company.companyProfiles;
|
||||
return this.getCompanyIdentityState(updated);
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the Power of Attorney entirely — the verified identity, the details it
|
||||
* wrote and the delegation paper together.
|
||||
@@ -2864,14 +2978,28 @@ export class CompaniesService {
|
||||
);
|
||||
}
|
||||
|
||||
// The representative is not. A PoA acts for the company inside Ethiopia
|
||||
// whoever owns it, so they are always an Ethiopian holding a Fayda ID —
|
||||
// a foreign company nominates one rather than typing a name.
|
||||
const poaNamed = POA_ATTRIBUTES.some((k) =>
|
||||
(company.attributes?.[k] as string | undefined)?.trim(),
|
||||
);
|
||||
if (!opts.requirePoa && !poaNamed) return;
|
||||
|
||||
// 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 nominates a Fayda-holding representative where it can,
|
||||
// but a typed name has to remain sufficient — otherwise a foreign company
|
||||
// whose representative holds no Fayda ID could never trade at all. Mirrors
|
||||
// `poaProven` in buildCompanyIdentityState; the two must agree.
|
||||
if (state.passportRequired) {
|
||||
if (!state.poa.verified && !state.poa.name?.trim()) {
|
||||
throw new BadRequestException(
|
||||
opts.requirePoa
|
||||
? "Name your Power of Attorney — a freight forwarder cannot operate without one."
|
||||
: "Complete the Power of Attorney you named, or remove the representative.",
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!state.poa.verified) {
|
||||
throw new BadRequestException(
|
||||
opts.requirePoa
|
||||
|
||||
@@ -5,13 +5,15 @@ import { Company, CompanyNationality } from "../entities/company.entity";
|
||||
import { ProfileType } from "../entities/company-profile.entity";
|
||||
|
||||
/**
|
||||
* The two people a company is verified through — its owner and its Power of
|
||||
* Attorney. "Owner" is not the same as the General Manager: a company's GM is
|
||||
* a plain typed role (with a "same as owner" copy the portal offers), while
|
||||
* the owner is the person this verification proves. They're very often the
|
||||
* same human, which is exactly what the copy is for.
|
||||
* The three people a company is verified through — its owner, its Power of
|
||||
* Attorney and its General Manager. The owner is the person the company's
|
||||
* existence is proven by; the other two are personnel it names.
|
||||
*
|
||||
* The GM is very often the owner, which is what the portal's "same as owner"
|
||||
* copy is for: that path reuses the owner's verified identity outright rather
|
||||
* than asking the same human to verify twice.
|
||||
*/
|
||||
export const IDENTITY_SUBJECTS = ["owner", "poa"] as const;
|
||||
export const IDENTITY_SUBJECTS = ["owner", "poa", "gm"] as const;
|
||||
export type IdentitySubject = (typeof IDENTITY_SUBJECTS)[number];
|
||||
|
||||
export class CompleteIdentityVerificationDto {
|
||||
@@ -73,6 +75,19 @@ export class CompanyIdentityStateDto {
|
||||
@ApiProperty({ type: IdentityVerificationStateDto })
|
||||
poa!: IdentityVerificationStateDto;
|
||||
|
||||
@ApiProperty({
|
||||
type: IdentityVerificationStateDto,
|
||||
description:
|
||||
"General manager. `verified` is true both when the GM verified with Fayda in their own right and when the company declared the GM is the owner — in the latter case the owner's Fayda sub backs it.",
|
||||
})
|
||||
gm!: IdentityVerificationStateDto;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
"True when the GM's identity is the owner's, declared through the portal's \"same as owner\" copy rather than a separate verification.",
|
||||
})
|
||||
gmSameAsOwner!: boolean;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
"False while a mandatory requirement (Fayda for Ethiopian, passport for foreign) is still outstanding.",
|
||||
@@ -81,11 +96,28 @@ export class CompanyIdentityStateDto {
|
||||
}
|
||||
|
||||
/** `attributes` key prefix per person. */
|
||||
const PREFIX: Record<IdentitySubject, "owner" | "poa"> = {
|
||||
const PREFIX: Record<IdentitySubject, string> = {
|
||||
owner: "owner",
|
||||
poa: "poa",
|
||||
gm: "gm",
|
||||
};
|
||||
|
||||
/**
|
||||
* Typed GM fields, kept in step with the Fayda-written ones.
|
||||
*
|
||||
* The GM predates this verification: its details are plain company columns
|
||||
* that three notifier services mail (booking-lifecycle, train-scheduling and
|
||||
* contract notifiers all read `company.generalManagerEmail`). A verification
|
||||
* therefore writes BOTH — the `gm*` attributes carry the proof, these carry
|
||||
* the value everything else already reads — and an unverified company keeps
|
||||
* showing whatever was typed before this existed.
|
||||
*/
|
||||
const GM_TYPED_KEYS = {
|
||||
name: "generalManagerName",
|
||||
email: "generalManagerEmail",
|
||||
phone: "generalManagerPhone",
|
||||
} as const;
|
||||
|
||||
/** company.attributes keys that together mean "a PoA was entered". */
|
||||
const POA_KEYS = [
|
||||
"poaName",
|
||||
@@ -101,7 +133,7 @@ function stateFor(
|
||||
): IdentityVerificationStateDto {
|
||||
const p = PREFIX[subject];
|
||||
const read = (key: string) => (attrs[key] as string | undefined) ?? null;
|
||||
return {
|
||||
const state: IdentityVerificationStateDto = {
|
||||
verified: Boolean(read(`${p}FaydaSub`)),
|
||||
name: read(`${p}Name`),
|
||||
phone: read(`${p}Phone`),
|
||||
@@ -111,6 +143,18 @@ function stateFor(
|
||||
birthdate: read(`${p}Birthdate`),
|
||||
gender: read(`${p}Gender`),
|
||||
};
|
||||
if (subject !== "gm") return state;
|
||||
|
||||
// Companies onboarded before the GM was verifiable have typed details and no
|
||||
// `gm*` attributes at all. Report those rather than a blank card — they are
|
||||
// still what the notifiers mail — leaving `verified` false so the portal
|
||||
// offers the upgrade instead of pretending the identity is proven.
|
||||
return {
|
||||
...state,
|
||||
name: state.name ?? read(GM_TYPED_KEYS.name),
|
||||
email: state.email ?? read(GM_TYPED_KEYS.email),
|
||||
phone: state.phone ?? read(GM_TYPED_KEYS.phone),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -144,14 +188,34 @@ export function buildCompanyIdentityState(
|
||||
(p) => p.type === ProfileType.freightForwarder,
|
||||
) || POA_KEYS.some((k) => (attrs[k] as string | undefined)?.trim());
|
||||
|
||||
// Only the *owner's* credential is nationality-specific. A Power of Attorney
|
||||
// acts for the company inside Ethiopia whoever owns it, so the PoA is always
|
||||
// proven with Fayda — a foreign company nominates a representative who holds
|
||||
// one rather than typing a name nothing backs.
|
||||
const gm = stateFor(attrs, "gm");
|
||||
const gmSameAsOwner = Boolean(attrs.gmSameAsOwner);
|
||||
|
||||
const ownerProven = faydaRequired
|
||||
? owner.verified
|
||||
: !passportRequired || Boolean(owner.passportNumber);
|
||||
const complete = ownerProven && (!poaDue || poa.verified);
|
||||
|
||||
return { faydaRequired, passportRequired, owner, poa, complete };
|
||||
// Fayda is an Ethiopian national ID, so only an Ethiopian company's
|
||||
// personnel can be held to it. A foreign company may nominate a
|
||||
// representative who holds one — and is offered the verification — but a
|
||||
// typed name has to remain sufficient, or a foreign company whose PoA has no
|
||||
// Fayda ID could never finish onboarding.
|
||||
const poaProven = faydaRequired
|
||||
? poa.verified
|
||||
: poa.verified || Boolean(poa.name?.trim());
|
||||
|
||||
// The GM is deliberately absent from this verdict: it names who to talk to,
|
||||
// not what the company may do, and it has never gated trading. Capturing it
|
||||
// through Fayda changes how it is collected, not whether it is required.
|
||||
const complete = ownerProven && (!poaDue || poaProven);
|
||||
|
||||
return {
|
||||
faydaRequired,
|
||||
passportRequired,
|
||||
owner,
|
||||
poa,
|
||||
gm,
|
||||
gmSameAsOwner,
|
||||
complete,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,62 +1,22 @@
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import axios, { isAxiosError } from "axios";
|
||||
|
||||
import { NotificationStrategy } from "./notification.strategy";
|
||||
import { SmsClientService } from "../sms-client.service";
|
||||
|
||||
@Injectable()
|
||||
export class SmsNotificationStrategy implements NotificationStrategy {
|
||||
private readonly logger = new Logger(SmsNotificationStrategy.name);
|
||||
|
||||
constructor(private readonly configService: ConfigService) {}
|
||||
constructor(private readonly smsClient: SmsClientService) {}
|
||||
|
||||
async send(recipient: string, message: string): Promise<boolean> {
|
||||
const url =
|
||||
this.configService.get<string>("OZIKING_SMS_URL") ??
|
||||
"https://notification-dev.license.aafda.gov.et/api/sms-services/ozeking/sms";
|
||||
|
||||
const appKey = this.configService.get<string>("OZIKING_APP_KEY") ?? "";
|
||||
if (!appKey) {
|
||||
this.logger.warn("OZIKING_APP_KEY is not set — SMS may be rejected by the API");
|
||||
}
|
||||
|
||||
this.logger.debug(`Sending SMS to ${recipient} via ${url}`);
|
||||
|
||||
// axios defaults to no timeout — a hanging gateway would block the caller
|
||||
// (and any transaction it sits in) indefinitely. Always bound the wait.
|
||||
const timeout = Number(this.configService.get<string>("SMS_TIMEOUT_MS") ?? 8000);
|
||||
|
||||
try {
|
||||
const response = await axios.post(
|
||||
url,
|
||||
{
|
||||
to: recipient,
|
||||
sourceId: this.configService.get<string>("OZIKING_SOURCE_ID") ?? "EDR",
|
||||
sourceName: this.configService.get<string>("OZIKING_SOURCE_NAME") ?? "EDR Freight",
|
||||
appKey,
|
||||
text: message,
|
||||
callbackUrl: "",
|
||||
},
|
||||
{
|
||||
timeout,
|
||||
headers: {
|
||||
accept: "*/*",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
this.logger.debug(`SMS API response: ${response.status} ${JSON.stringify(response.data)}`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
if (isAxiosError(err)) {
|
||||
this.logger.error(
|
||||
`SMS API error: ${err.message} | status=${err.response?.status} | body=${JSON.stringify(err.response?.data)}`,
|
||||
);
|
||||
} else {
|
||||
this.logger.error(`SMS send failed: ${String(err)}`);
|
||||
}
|
||||
throw err;
|
||||
const { queued } = await this.smsClient.sendSms({
|
||||
to: recipient,
|
||||
message,
|
||||
});
|
||||
if (!queued) {
|
||||
this.logger.error(`SMS to ${recipient} was not queued to RabbitMQ`);
|
||||
}
|
||||
return queued;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -265,10 +265,10 @@ export class OtpService {
|
||||
|
||||
/**
|
||||
* SMS half of {@link dispatchEmail}; same swallow-and-report contract. Sent
|
||||
* via NotificationsService's direct-HTTP Ozeking strategy — the same
|
||||
* transport the notification system uses — rather than the RabbitMQ
|
||||
* `SMS_SERVICE` queue, so `queued: true` here means the gateway accepted the
|
||||
* request, not just that a broker took ownership of the message.
|
||||
* via NotificationsService's `directSend`, which now routes through the
|
||||
* same RabbitMQ `SMS_SERVICE` queue as every other SMS in freight-api, so
|
||||
* `queued: true` here means the broker confirmed ownership of the message,
|
||||
* not that the carrier delivered it.
|
||||
*/
|
||||
private async dispatchSms(
|
||||
phone: string,
|
||||
|
||||
@@ -91,7 +91,16 @@ export class BookingWindowService implements OnModuleInit {
|
||||
// 10-second cadence: every transition is derived from persisted timestamps
|
||||
// and applied idempotently, so a finer tick only shrinks the lag between a
|
||||
// deadline passing and the phase actually moving (was a full minute).
|
||||
@Cron('*/10 * * * * *', { name: 'booking-window-tick', timeZone: BATCH_TIMEZONE })
|
||||
//
|
||||
// Overridable because that lag is the integration suite's pacing floor: every
|
||||
// window phase, wagon allocation and expiry it waits on lands on this tick, so
|
||||
// a 10s cadence costs ~5s of pure latency per wait across a few hundred waits.
|
||||
// The suite runs it at `*/1 * * * * *`. Read at class-definition time, so the
|
||||
// env var must be set before the module is imported.
|
||||
@Cron(process.env.BOOKING_WINDOW_TICK_CRON ?? '*/10 * * * * *', {
|
||||
name: 'booking-window-tick',
|
||||
timeZone: BATCH_TIMEZONE,
|
||||
})
|
||||
async tick(): Promise<void> {
|
||||
if (this.ticking) return;
|
||||
this.ticking = true;
|
||||
|
||||
@@ -6,12 +6,14 @@ import { VerifaydaCallbackDto } from './verifayda.dto';
|
||||
/**
|
||||
* Plain acknowledgement endpoint for the Fayda redirect_uri when it points at
|
||||
* the API instead of the web app (e.g. MOBILE clients or connectivity checks).
|
||||
* Registered at /callback (excluded from the global /api prefix in main.ts).
|
||||
* Registered at /fayda/callback (excluded by exact path from the global /api
|
||||
* prefix in main.ts — the exclusion must NOT be widened to "fayda", or
|
||||
* /api/fayda/verification/* loses its prefix too).
|
||||
* It does NOT consume the verification session — the client must still call
|
||||
* GET /api/fayda/verification/complete with the echoed code+state.
|
||||
*/
|
||||
@ApiTags('Fayda Verification')
|
||||
@Controller('callback')
|
||||
@Controller('fayda/callback')
|
||||
export class FaydaCallbackController {
|
||||
@Get()
|
||||
@IsPublic()
|
||||
|
||||
@@ -292,7 +292,10 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
label: "Locomotives",
|
||||
href: "/dashboard/locomotives",
|
||||
icon: <Train />,
|
||||
permission: [FREIGHT_PERMS.locomotives.view, FREIGHT_PERMS.fleet.view],
|
||||
permission: [
|
||||
FREIGHT_PERMS.locomotives.view,
|
||||
FREIGHT_PERMS.fleet.view,
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Train Builder",
|
||||
@@ -818,7 +821,7 @@ const App = () => {
|
||||
{UserManagementRoutes()}
|
||||
{/* <Route path="/um/*" element={<UserManagementHostPage />} /> */}
|
||||
<Route path="/health" element={<HealthCheck />} />
|
||||
<Route path="/callback" element={<FaydaCallbackPage />} />
|
||||
<Route path="/fayda/callback" element={<FaydaCallbackPage />} />
|
||||
<Route path="/" element={<Navigate to="/dashboard/overview" replace />} />
|
||||
<Route
|
||||
path="/dashboard"
|
||||
@@ -1085,7 +1088,10 @@ const App = () => {
|
||||
<Route path="intercity" element={<IntercityPage />} />
|
||||
<Route path="trucks-on-site" element={<TrucksOnSitePage />} />
|
||||
<Route path="import-trucks" element={<ImportTrucksPage />} />
|
||||
<Route path="edr-last-mile-returns" element={<EDRLastMileReturnsPage />} />
|
||||
<Route
|
||||
path="edr-last-mile-returns"
|
||||
element={<EDRLastMileReturnsPage />}
|
||||
/>
|
||||
<Route path="container-returns" element={<ContainerReturnsPage />} />
|
||||
<Route path="loaded-inventory" element={<LoadedInventoryPage />} />
|
||||
<Route path="dispatch-queue" element={<DispatchQueuePage />} />
|
||||
@@ -1173,7 +1179,9 @@ const App = () => {
|
||||
<Route
|
||||
path="routes"
|
||||
element={
|
||||
<RequirePermission permission={[FREIGHT_PERMS.routes.view, FREIGHT_PERMS.fleet.view]}>
|
||||
<RequirePermission
|
||||
permission={[FREIGHT_PERMS.routes.view, FREIGHT_PERMS.fleet.view]}
|
||||
>
|
||||
<RoutesPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
@@ -1181,7 +1189,12 @@ const App = () => {
|
||||
<Route
|
||||
path="locomotives"
|
||||
element={
|
||||
<RequirePermission permission={[FREIGHT_PERMS.locomotives.view, FREIGHT_PERMS.fleet.view]}>
|
||||
<RequirePermission
|
||||
permission={[
|
||||
FREIGHT_PERMS.locomotives.view,
|
||||
FREIGHT_PERMS.fleet.view,
|
||||
]}
|
||||
>
|
||||
<FleetResourcePage />
|
||||
</RequirePermission>
|
||||
}
|
||||
@@ -1189,7 +1202,9 @@ const App = () => {
|
||||
<Route
|
||||
path="trains"
|
||||
element={
|
||||
<RequirePermission permission={[FREIGHT_PERMS.trains.view, FREIGHT_PERMS.fleet.view]}>
|
||||
<RequirePermission
|
||||
permission={[FREIGHT_PERMS.trains.view, FREIGHT_PERMS.fleet.view]}
|
||||
>
|
||||
<FleetResourcePage />
|
||||
</RequirePermission>
|
||||
}
|
||||
@@ -1197,7 +1212,9 @@ const App = () => {
|
||||
<Route
|
||||
path="trains/:id"
|
||||
element={
|
||||
<RequirePermission permission={[FREIGHT_PERMS.trains.view, FREIGHT_PERMS.fleet.view]}>
|
||||
<RequirePermission
|
||||
permission={[FREIGHT_PERMS.trains.view, FREIGHT_PERMS.fleet.view]}
|
||||
>
|
||||
<TrainDetailPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
@@ -1205,7 +1222,9 @@ const App = () => {
|
||||
<Route
|
||||
path="train-builder"
|
||||
element={
|
||||
<RequirePermission permission={[FREIGHT_PERMS.trains.view, FREIGHT_PERMS.fleet.view]}>
|
||||
<RequirePermission
|
||||
permission={[FREIGHT_PERMS.trains.view, FREIGHT_PERMS.fleet.view]}
|
||||
>
|
||||
<TrainBuilderListPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
@@ -1213,7 +1232,9 @@ const App = () => {
|
||||
<Route
|
||||
path="train-builder/:id"
|
||||
element={
|
||||
<RequirePermission permission={[FREIGHT_PERMS.trains.view, FREIGHT_PERMS.fleet.view]}>
|
||||
<RequirePermission
|
||||
permission={[FREIGHT_PERMS.trains.view, FREIGHT_PERMS.fleet.view]}
|
||||
>
|
||||
<TrainBuilderDetailPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
@@ -1221,7 +1242,9 @@ const App = () => {
|
||||
<Route
|
||||
path="wagons"
|
||||
element={
|
||||
<RequirePermission permission={[FREIGHT_PERMS.wagons.view, FREIGHT_PERMS.fleet.view]}>
|
||||
<RequirePermission
|
||||
permission={[FREIGHT_PERMS.wagons.view, FREIGHT_PERMS.fleet.view]}
|
||||
>
|
||||
<FleetResourcePage />
|
||||
</RequirePermission>
|
||||
}
|
||||
@@ -1242,7 +1265,12 @@ const App = () => {
|
||||
<Route
|
||||
path="containers"
|
||||
element={
|
||||
<RequirePermission permission={[FREIGHT_PERMS.containers.view, FREIGHT_PERMS.fleet.view]}>
|
||||
<RequirePermission
|
||||
permission={[
|
||||
FREIGHT_PERMS.containers.view,
|
||||
FREIGHT_PERMS.fleet.view,
|
||||
]}
|
||||
>
|
||||
<FleetResourcePage />
|
||||
</RequirePermission>
|
||||
}
|
||||
@@ -1250,7 +1278,12 @@ const App = () => {
|
||||
<Route
|
||||
path="cargoes"
|
||||
element={
|
||||
<RequirePermission permission={[FREIGHT_PERMS.cargoes.view, FREIGHT_PERMS.fleet.view]}>
|
||||
<RequirePermission
|
||||
permission={[
|
||||
FREIGHT_PERMS.cargoes.view,
|
||||
FREIGHT_PERMS.fleet.view,
|
||||
]}
|
||||
>
|
||||
<FleetResourcePage />
|
||||
</RequirePermission>
|
||||
}
|
||||
@@ -1336,7 +1369,9 @@ const App = () => {
|
||||
<Route
|
||||
path="routes"
|
||||
element={
|
||||
<RequirePermission permission={[FREIGHT_PERMS.routes.view, FREIGHT_PERMS.fleet.view]}>
|
||||
<RequirePermission
|
||||
permission={[FREIGHT_PERMS.routes.view, FREIGHT_PERMS.fleet.view]}
|
||||
>
|
||||
<RoutesPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
@@ -1424,7 +1459,12 @@ const App = () => {
|
||||
<Route
|
||||
path="locomotives"
|
||||
element={
|
||||
<RequirePermission permission={[FREIGHT_PERMS.locomotives.view, FREIGHT_PERMS.fleet.view]}>
|
||||
<RequirePermission
|
||||
permission={[
|
||||
FREIGHT_PERMS.locomotives.view,
|
||||
FREIGHT_PERMS.fleet.view,
|
||||
]}
|
||||
>
|
||||
<FleetResourcePage />
|
||||
</RequirePermission>
|
||||
}
|
||||
@@ -1432,7 +1472,9 @@ const App = () => {
|
||||
<Route
|
||||
path="trains"
|
||||
element={
|
||||
<RequirePermission permission={[FREIGHT_PERMS.trains.view, FREIGHT_PERMS.fleet.view]}>
|
||||
<RequirePermission
|
||||
permission={[FREIGHT_PERMS.trains.view, FREIGHT_PERMS.fleet.view]}
|
||||
>
|
||||
<FleetResourcePage />
|
||||
</RequirePermission>
|
||||
}
|
||||
@@ -1440,7 +1482,9 @@ const App = () => {
|
||||
<Route
|
||||
path="trains/:id"
|
||||
element={
|
||||
<RequirePermission permission={[FREIGHT_PERMS.trains.view, FREIGHT_PERMS.fleet.view]}>
|
||||
<RequirePermission
|
||||
permission={[FREIGHT_PERMS.trains.view, FREIGHT_PERMS.fleet.view]}
|
||||
>
|
||||
<TrainDetailPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
@@ -1448,7 +1492,9 @@ const App = () => {
|
||||
<Route
|
||||
path="train-builder"
|
||||
element={
|
||||
<RequirePermission permission={[FREIGHT_PERMS.trains.view, FREIGHT_PERMS.fleet.view]}>
|
||||
<RequirePermission
|
||||
permission={[FREIGHT_PERMS.trains.view, FREIGHT_PERMS.fleet.view]}
|
||||
>
|
||||
<TrainBuilderListPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
@@ -1456,7 +1502,9 @@ const App = () => {
|
||||
<Route
|
||||
path="train-builder/:id"
|
||||
element={
|
||||
<RequirePermission permission={[FREIGHT_PERMS.trains.view, FREIGHT_PERMS.fleet.view]}>
|
||||
<RequirePermission
|
||||
permission={[FREIGHT_PERMS.trains.view, FREIGHT_PERMS.fleet.view]}
|
||||
>
|
||||
<TrainBuilderDetailPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
@@ -1464,7 +1512,9 @@ const App = () => {
|
||||
<Route
|
||||
path="wagons"
|
||||
element={
|
||||
<RequirePermission permission={[FREIGHT_PERMS.wagons.view, FREIGHT_PERMS.fleet.view]}>
|
||||
<RequirePermission
|
||||
permission={[FREIGHT_PERMS.wagons.view, FREIGHT_PERMS.fleet.view]}
|
||||
>
|
||||
<FleetResourcePage />
|
||||
</RequirePermission>
|
||||
}
|
||||
@@ -1485,7 +1535,12 @@ const App = () => {
|
||||
<Route
|
||||
path="containers"
|
||||
element={
|
||||
<RequirePermission permission={[FREIGHT_PERMS.containers.view, FREIGHT_PERMS.fleet.view]}>
|
||||
<RequirePermission
|
||||
permission={[
|
||||
FREIGHT_PERMS.containers.view,
|
||||
FREIGHT_PERMS.fleet.view,
|
||||
]}
|
||||
>
|
||||
<FleetResourcePage />
|
||||
</RequirePermission>
|
||||
}
|
||||
@@ -1493,7 +1548,12 @@ const App = () => {
|
||||
<Route
|
||||
path="cargoes"
|
||||
element={
|
||||
<RequirePermission permission={[FREIGHT_PERMS.cargoes.view, FREIGHT_PERMS.fleet.view]}>
|
||||
<RequirePermission
|
||||
permission={[
|
||||
FREIGHT_PERMS.cargoes.view,
|
||||
FREIGHT_PERMS.fleet.view,
|
||||
]}
|
||||
>
|
||||
<FleetResourcePage />
|
||||
</RequirePermission>
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ export function isComplaintAuthContext(pathname = ""): boolean {
|
||||
pathname.startsWith("/complaints") ||
|
||||
pathname === "/complaint-form" ||
|
||||
pathname === "/follow-complaint" ||
|
||||
pathname === "/callback"
|
||||
pathname === "/fayda/callback"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ let listener: Listener | null = null;
|
||||
/** Current-page path patterns where the global modal must stay silent. */
|
||||
const EXCLUDED_PATH_PATTERNS = [
|
||||
/^\/auth/,
|
||||
/^\/callback/,
|
||||
/^\/fayda/,
|
||||
/warehouse/i,
|
||||
/first-mile/i,
|
||||
/last-mile/i,
|
||||
|
||||
@@ -148,7 +148,7 @@ const FleetFormDialog = ({
|
||||
});
|
||||
}, [open, fields]);
|
||||
|
||||
// Receive the ?code&state relayed by the /callback popup, exchange it for
|
||||
// Receive the ?code&state relayed by the /fayda/callback popup, exchange it for
|
||||
// the verified identity, and prefill the matching form fields.
|
||||
useEffect(() => {
|
||||
if (!open || !verifyWithFayda) return;
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { FaydaCallbackMessage } from "@/services/verifayda.service";
|
||||
|
||||
/**
|
||||
* Landing page for the eSignet redirect_uri (FAYDA_WEB_REDIRECT_URI →
|
||||
* http://localhost:5183/callback). Runs inside the verification popup:
|
||||
* http://localhost:5183/fayda/callback). Runs inside the verification popup:
|
||||
* relays ?code&state (or ?error) to the window that opened it via
|
||||
* postMessage, then closes itself. The opener performs the /complete call
|
||||
* so the single-use session is only consumed once, in one place.
|
||||
|
||||
@@ -17,7 +17,7 @@ export interface FaydaCompleteResult {
|
||||
userDataSaved?: boolean;
|
||||
}
|
||||
|
||||
/** Message posted from the /callback popup back to the opener window. */
|
||||
/** Message posted from the /fayda/callback popup back to the opener window. */
|
||||
export interface FaydaCallbackMessage {
|
||||
type: 'fayda-callback';
|
||||
code?: string;
|
||||
|
||||
@@ -5,7 +5,7 @@ import ExternalPortalCallback from "@/external-portal/components/Registration/Ex
|
||||
|
||||
/**
|
||||
* Single FAYDA OIDC callback entry point.
|
||||
* Fayda only allows whitelisted redirect URIs (e.g. /callback) — route
|
||||
* Fayda only allows whitelisted redirect URIs (e.g. /fayda/callback) — route
|
||||
* internally based on the `state` param sent during authorization.
|
||||
*/
|
||||
export default function FaydaCallbackDispatcher() {
|
||||
|
||||
@@ -11,7 +11,7 @@ const PUBLIC_PATHS = [
|
||||
"/set-password",
|
||||
"/verify-otp",
|
||||
"/verification_page",
|
||||
"/callback",
|
||||
"/fayda/callback",
|
||||
"/complaints",
|
||||
"/complaint-form",
|
||||
"/follow-complaint",
|
||||
|
||||
@@ -12,7 +12,7 @@ const DEFAULT_CODE_CHALLENGE = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM";
|
||||
const DEFAULT_NONCE = "g4DEuje5Fx57Vb64dO4oqLHXGT8L8G7g";
|
||||
const DEFAULT_STATE = "ptOO76SD";
|
||||
|
||||
/** OIDC state value that routes the shared /callback to the complaint flow (legacy sign-in). */
|
||||
/** OIDC state value that routes the shared /fayda/callback to the complaint flow (legacy sign-in). */
|
||||
export const COMPLAINT_FLOW_STATE = "complaint_flow";
|
||||
|
||||
/** Complaint flow OIDC states — distinguish sign-in vs sign-up endpoints. */
|
||||
@@ -66,9 +66,7 @@ export function startExternalPortalFaydaAuth(): void {
|
||||
export function generateFaydaAuthorizationUrl(
|
||||
options: FaydaOidcOptions = {},
|
||||
): string {
|
||||
const redirectUri =
|
||||
options.redirectUri ||
|
||||
getDefaultFaydaRedirectUri();
|
||||
const redirectUri = options.redirectUri || getDefaultFaydaRedirectUri();
|
||||
|
||||
const params = new URLSearchParams({
|
||||
client_id: import.meta.env.VITE_CLIENT_ID || "",
|
||||
@@ -98,7 +96,7 @@ export function generateFaydaAuthorizationUrl(
|
||||
export function getDefaultFaydaRedirectUri(): string {
|
||||
return (
|
||||
import.meta.env.VITE_REDIRECT_URI ||
|
||||
`${window.location.origin}/callback`
|
||||
`${window.location.origin}/fayda/callback`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -106,13 +104,12 @@ export function getDefaultFaydaRedirectUri(): string {
|
||||
* Returns the redirect URI registered with FAYDA for the complaint flow.
|
||||
*
|
||||
* Must exactly match a URI whitelisted in the FAYDA OIDC client — we reuse
|
||||
* the same /callback path as external-portal registration and distinguish
|
||||
* the same /fayda/callback path as external-portal registration and distinguish
|
||||
* flows via the `state` parameter (see COMPLAINT_FLOW_STATE).
|
||||
*/
|
||||
export function getComplaintFaydaRedirectUri(): string {
|
||||
return (
|
||||
import.meta.env.VITE_COMPLAINT_REDIRECT_URI ||
|
||||
getDefaultFaydaRedirectUri()
|
||||
import.meta.env.VITE_COMPLAINT_REDIRECT_URI || getDefaultFaydaRedirectUri()
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --port 5173 --clearScreen false",
|
||||
"dev": "vite --port 3000 --clearScreen false",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview --port 5173",
|
||||
"lint": "eslint src",
|
||||
|
||||
@@ -253,113 +253,116 @@ const App = () => {
|
||||
{/* Global API error modal — shows the server's actual error message for
|
||||
every failed request (suppressed on onboarding/auth pages). */}
|
||||
<ApiErrorModal />
|
||||
<Routes>
|
||||
{/* Public routes */}
|
||||
<Route index element={<LandingRoute />} />
|
||||
<Route path="/logout" element={<LogoutHandler />} />
|
||||
<Route
|
||||
path="/booking/check-status/:orderId"
|
||||
element={<CheckPaymentPage />}
|
||||
/>
|
||||
{/* Payment provider browser redirects (PAYMENT_RETURN_URL / PAYMENT_FAILURE_URL) */}
|
||||
{/* Fayda (eSignet) redirect_uri — runs in the verification popup and
|
||||
relays the code/state back to the form that opened it. */}
|
||||
<Route path="/callback" element={<FaydaCallbackPage />} />
|
||||
<Route path="/payment/success" element={<PaymentSuccessPage />} />
|
||||
<Route path="/payment/failure" element={<PaymentFailurePage />} />
|
||||
<Routes>
|
||||
{/* Public routes */}
|
||||
<Route index element={<LandingRoute />} />
|
||||
<Route path="/logout" element={<LogoutHandler />} />
|
||||
<Route
|
||||
path="/booking/check-status/:orderId"
|
||||
element={<CheckPaymentPage />}
|
||||
/>
|
||||
{/* Payment provider browser redirects (PAYMENT_RETURN_URL / PAYMENT_FAILURE_URL) */}
|
||||
{/* Fayda (eSignet) redirect_uri — the whole tab lands here after
|
||||
verification, completes the code/state exchange and navigates back
|
||||
to the page that started it. Public on purpose: behind RequireAuth
|
||||
the onboarding gate would redirect away before the exchange ran. */}
|
||||
<Route path="/fayda/callback" element={<FaydaCallbackPage />} />
|
||||
<Route path="/callback" element={<FaydaCallbackPage />} />
|
||||
<Route path="/payment/success" element={<PaymentSuccessPage />} />
|
||||
<Route path="/payment/failure" element={<PaymentFailurePage />} />
|
||||
|
||||
{/* Auth pages — inaccessible once logged in */}
|
||||
<Route element={<RedirectIfAuthed />}>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/signup" element={<SignupPage />} />
|
||||
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
|
||||
</Route>
|
||||
{/* Auth pages — inaccessible once logged in */}
|
||||
<Route element={<RedirectIfAuthed />}>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/signup" element={<SignupPage />} />
|
||||
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
|
||||
</Route>
|
||||
|
||||
{/* Staff-issued reset links land here. Deliberately outside
|
||||
{/* Staff-issued reset links land here. Deliberately outside
|
||||
RedirectIfAuthed: a customer with a stale session still needs the link
|
||||
to work, and the token — not the session — is what authorises it. */}
|
||||
<Route path="/reset-password" element={<ResetPasswordLinkPage />} />
|
||||
<Route path="/reset-password" element={<ResetPasswordLinkPage />} />
|
||||
|
||||
{/* Signup-flow pages; reached while a session already exists */}
|
||||
<Route path="/otp" element={<VerificationOtpPage />} />
|
||||
<Route path="/set-password" element={<SetPasswordPage />} />
|
||||
{/* Signup-flow pages; reached while a session already exists */}
|
||||
<Route path="/otp" element={<VerificationOtpPage />} />
|
||||
<Route path="/set-password" element={<SetPasswordPage />} />
|
||||
|
||||
<Route element={<RequireAuth />}>
|
||||
<Route element={<RequireCompany />}>
|
||||
<Route
|
||||
element={
|
||||
<AppLayout
|
||||
title="EDR Freight"
|
||||
sidebarItems={sidebarItems}
|
||||
activeHref={location.pathname}
|
||||
onNavigate={navigate}
|
||||
enableThemeToggle
|
||||
userName={displayName}
|
||||
userEmail={userEmail}
|
||||
companyProfiles={companyProfiles}
|
||||
companyType={companyType}
|
||||
onCreateProfile={createProfile}
|
||||
onReapplyProfile={reapplyProfile}
|
||||
>
|
||||
<OnboardingGate />
|
||||
</AppLayout>
|
||||
}
|
||||
>
|
||||
<Route path="/portal" element={<MyPortalPage />} />
|
||||
{/* Bookings are created against a contract, but the full list is
|
||||
<Route element={<RequireAuth />}>
|
||||
<Route element={<RequireCompany />}>
|
||||
<Route
|
||||
element={
|
||||
<AppLayout
|
||||
title="EDR Freight"
|
||||
sidebarItems={sidebarItems}
|
||||
activeHref={location.pathname}
|
||||
onNavigate={navigate}
|
||||
enableThemeToggle
|
||||
userName={displayName}
|
||||
userEmail={userEmail}
|
||||
companyProfiles={companyProfiles}
|
||||
companyType={companyType}
|
||||
onCreateProfile={createProfile}
|
||||
onReapplyProfile={reapplyProfile}
|
||||
>
|
||||
<OnboardingGate />
|
||||
</AppLayout>
|
||||
}
|
||||
>
|
||||
<Route path="/portal" element={<MyPortalPage />} />
|
||||
{/* Bookings are created against a contract, but the full list is
|
||||
browsable here. New-booking entry still routes via a contract. */}
|
||||
<Route path="/bookings" element={<BookingsListPage />} />
|
||||
<Route
|
||||
path="/bookings/new"
|
||||
element={<Navigate to="/contracts/new" replace />}
|
||||
/>
|
||||
<Route path="/bookings/:id/edit" element={<EditBookingPage />} />
|
||||
<Route path="/bookings/:id" element={<BookingDetailPage />} />
|
||||
<Route
|
||||
path="/bookings/:id/contract"
|
||||
element={<BookingContractPage />}
|
||||
/>
|
||||
<Route path="/contracts" element={<ContractsList />} />
|
||||
<Route path="/contracts/new" element={<NewContractPage />} />
|
||||
<Route
|
||||
path="/contracts/:id/edit"
|
||||
element={<NewContractPage mode="edit" />}
|
||||
/>
|
||||
<Route
|
||||
path="/contracts/:id/shipment-requests/new"
|
||||
element={<NewShipmentRequestPage />}
|
||||
/>
|
||||
<Route
|
||||
path="/contracts/:id/bookings/new"
|
||||
element={<NewShipmentPage />}
|
||||
/>
|
||||
{/* Completion of an initiated (bare) booking after per-booking
|
||||
<Route path="/bookings" element={<BookingsListPage />} />
|
||||
<Route
|
||||
path="/bookings/new"
|
||||
element={<Navigate to="/contracts/new" replace />}
|
||||
/>
|
||||
<Route path="/bookings/:id/edit" element={<EditBookingPage />} />
|
||||
<Route path="/bookings/:id" element={<BookingDetailPage />} />
|
||||
<Route
|
||||
path="/bookings/:id/contract"
|
||||
element={<BookingContractPage />}
|
||||
/>
|
||||
<Route path="/contracts" element={<ContractsList />} />
|
||||
<Route path="/contracts/new" element={<NewContractPage />} />
|
||||
<Route
|
||||
path="/contracts/:id/edit"
|
||||
element={<NewContractPage mode="edit" />}
|
||||
/>
|
||||
<Route
|
||||
path="/contracts/:id/shipment-requests/new"
|
||||
element={<NewShipmentRequestPage />}
|
||||
/>
|
||||
<Route
|
||||
path="/contracts/:id/bookings/new"
|
||||
element={<NewShipmentPage />}
|
||||
/>
|
||||
{/* Completion of an initiated (bare) booking after per-booking
|
||||
clearance — same form, submits to the complete endpoint. */}
|
||||
<Route
|
||||
path="/contracts/:id/bookings/:bookingId/complete"
|
||||
element={<NewShipmentPage />}
|
||||
/>
|
||||
<Route
|
||||
path="/contracts/:id/view"
|
||||
element={<ContractViewPage />}
|
||||
/>
|
||||
<Route path="/contracts/:id" element={<ContractDetailPage />} />
|
||||
<Route path="/tracking" element={<TrackingPage />} />
|
||||
<Route path="/billing" element={<InvoicesList />} />
|
||||
<Route path="/billing/:id" element={<InvoiceDetailPage />} />
|
||||
{/* Profile was merged into Settings — keep old links working. */}
|
||||
<Route
|
||||
path="/profile"
|
||||
element={<Navigate to="/settings" replace />}
|
||||
/>
|
||||
<Route path="/signature" element={<MySignaturePage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
<Route
|
||||
path="/contracts/:id/bookings/:bookingId/complete"
|
||||
element={<NewShipmentPage />}
|
||||
/>
|
||||
<Route
|
||||
path="/contracts/:id/view"
|
||||
element={<ContractViewPage />}
|
||||
/>
|
||||
<Route path="/contracts/:id" element={<ContractDetailPage />} />
|
||||
<Route path="/tracking" element={<TrackingPage />} />
|
||||
<Route path="/billing" element={<InvoicesList />} />
|
||||
<Route path="/billing/:id" element={<InvoiceDetailPage />} />
|
||||
{/* Profile was merged into Settings — keep old links working. */}
|
||||
<Route
|
||||
path="/profile"
|
||||
element={<Navigate to="/settings" replace />}
|
||||
/>
|
||||
<Route path="/signature" element={<MySignaturePage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
</Route>
|
||||
</Route>
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { useState, type ReactNode } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Avatar,
|
||||
@@ -20,9 +20,8 @@ import {
|
||||
} from "lucide-react";
|
||||
|
||||
import {
|
||||
stashPendingVerification,
|
||||
verifaydaService,
|
||||
type CompanyIdentityState,
|
||||
type FaydaCallbackMessage,
|
||||
type IdentitySubject,
|
||||
type IdentityVerificationState,
|
||||
} from "@/services/verifayda.service";
|
||||
@@ -38,8 +37,6 @@ interface FaydaVerifyPanelProps {
|
||||
* on it, so the panel says so rather than nagging.
|
||||
*/
|
||||
required: boolean;
|
||||
/** Called with the fresh company-wide state once a verification lands. */
|
||||
onVerified: (next: CompanyIdentityState) => void;
|
||||
disabled?: boolean;
|
||||
/**
|
||||
* True when a fresh verification for this person is already staged in a
|
||||
@@ -61,115 +58,45 @@ function getInitials(name: string | null): string {
|
||||
/**
|
||||
* Verify one of the company's people through Fayda and show what came back.
|
||||
*
|
||||
* The identity is proved in an eSignet popup; that popup lands on /callback,
|
||||
* which relays the code+state here by postMessage. This window then completes
|
||||
* the exchange — once, in one place — and the API writes the person's name,
|
||||
* phone, email and address from the verified payload. Nothing on this panel
|
||||
* is typed.
|
||||
* The identity is proved on eSignet, which the whole tab navigates to — no
|
||||
* popup, because a popup opened after the /start round-trip has lost its user
|
||||
* activation and iOS Safari blocks it outright. eSignet redirects back to
|
||||
* /fayda/callback, which completes the exchange and returns the user here; the API
|
||||
* writes the person's name, phone, email and address from the verified
|
||||
* payload. Nothing on this panel is typed.
|
||||
*/
|
||||
export default function FaydaVerifyPanel({
|
||||
subject,
|
||||
title,
|
||||
state,
|
||||
required,
|
||||
onVerified,
|
||||
disabled,
|
||||
pendingReview,
|
||||
}: FaydaVerifyPanelProps) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// The listener closes over `subject`; keep it in a ref so remounting the
|
||||
// panel between steps can't complete a verification against the wrong person.
|
||||
const subjectRef = useRef(subject);
|
||||
subjectRef.current = subject;
|
||||
// FaydaCallbackPage posts its message from a StrictMode-double-invoked
|
||||
// effect in dev, so the same one-time-use code+state can arrive twice.
|
||||
// Track the last state we've started completing so the resend is a no-op.
|
||||
const handledStateRef = useRef<string | null>(null);
|
||||
// Polls the popup so a manually-closed window (no postMessage ever sent)
|
||||
// still clears `loading` instead of leaving the button spinning forever.
|
||||
const pollRef = useRef<number | null>(null);
|
||||
|
||||
const stopPolling = () => {
|
||||
if (pollRef.current !== null) {
|
||||
window.clearInterval(pollRef.current);
|
||||
pollRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const onMessage = async (event: MessageEvent<FaydaCallbackMessage>) => {
|
||||
if (event.origin !== window.location.origin) return;
|
||||
if (event.data?.type !== "fayda-callback") return;
|
||||
|
||||
if (event.data.error) {
|
||||
stopPolling();
|
||||
setLoading(false);
|
||||
setError(event.data.errorDescription ?? event.data.error);
|
||||
return;
|
||||
}
|
||||
if (!event.data.code || !event.data.state) return;
|
||||
if (handledStateRef.current === event.data.state) return;
|
||||
handledStateRef.current = event.data.state;
|
||||
stopPolling();
|
||||
|
||||
try {
|
||||
const next = await verifaydaService.completeIdentity(
|
||||
subjectRef.current,
|
||||
event.data.code,
|
||||
event.data.state,
|
||||
);
|
||||
setError(null);
|
||||
onVerified(next);
|
||||
} catch (err) {
|
||||
setError(
|
||||
(err as { response?: { data?: { message?: string } } })?.response?.data
|
||||
?.message ??
|
||||
(err instanceof Error ? err.message : "Verification failed"),
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
window.addEventListener("message", onMessage);
|
||||
return () => {
|
||||
window.removeEventListener("message", onMessage);
|
||||
stopPolling();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const startVerification = async () => {
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
handledStateRef.current = null;
|
||||
try {
|
||||
const authorizationUrl = await verifaydaService.start();
|
||||
const popup = window.open(
|
||||
authorizationUrl,
|
||||
"fayda-verify",
|
||||
"width=480,height=760,noopener=no",
|
||||
);
|
||||
if (!popup) {
|
||||
setLoading(false);
|
||||
setError("Pop-up blocked — allow pop-ups for this site and try again.");
|
||||
return;
|
||||
}
|
||||
// Loading stays on until the popup posts back — unless the user closes
|
||||
// it by hand, which never sends a message; poll for that and clear
|
||||
// loading ourselves so the button doesn't spin forever.
|
||||
stopPolling();
|
||||
pollRef.current = window.setInterval(() => {
|
||||
if (!popup.closed) return;
|
||||
stopPolling();
|
||||
if (handledStateRef.current === null) setLoading(false);
|
||||
}, 500);
|
||||
// Record who is being verified and where to come back to before the tab
|
||||
// leaves — /fayda/callback has no other way to know either.
|
||||
stashPendingVerification({
|
||||
subject,
|
||||
returnTo:
|
||||
window.location.pathname +
|
||||
window.location.search +
|
||||
window.location.hash,
|
||||
});
|
||||
window.location.assign(authorizationUrl);
|
||||
} catch (err) {
|
||||
setLoading(false);
|
||||
setError(
|
||||
(err as { response?: { data?: { message?: string } } })?.response?.data
|
||||
?.message ??
|
||||
(err instanceof Error ? err.message : "Could not start verification"),
|
||||
(err instanceof Error ? err.message : "Could not start verification"),
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -262,7 +189,13 @@ function DataRow({ icon, value }: { icon: ReactNode; value: string | null }) {
|
||||
if (!value) return null;
|
||||
return (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<span style={{ color: "var(--mantine-color-edr-muted-6)", display: "flex", flexShrink: 0 }}>
|
||||
<span
|
||||
style={{
|
||||
color: "var(--mantine-color-edr-muted-6)",
|
||||
display: "flex",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
</span>
|
||||
<Text size="xs" c="edr-muted" truncate>
|
||||
|
||||
@@ -209,7 +209,20 @@ export default function OnboardingWizardDialog({
|
||||
nationality?: CompanyNationality;
|
||||
}) => api.companies.startOnboarding.call(vars),
|
||||
onSuccess: async () => {
|
||||
await refreshInfo();
|
||||
// Nationality drives the server-resolved identity requirements (Fayda vs
|
||||
// passport), the document set and the GM/PoA copy — all read from
|
||||
// onboardingRequirements/profile. Re-entering role selection can change
|
||||
// it, so both must be refetched alongside getInfo or the form step would
|
||||
// keep rendering the previous nationality's requirements.
|
||||
await Promise.all([
|
||||
refreshInfo(),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: api.companies.onboardingRequirements.queryKey(),
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: api.companies.getProfile.queryKey(),
|
||||
}),
|
||||
]);
|
||||
setPhase("form");
|
||||
},
|
||||
onError: (err) => setStartError(extractApiError(err).message),
|
||||
|
||||
@@ -1,51 +1,89 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Center, Loader, Stack, Text } from "@mantine/core";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Button, Center, Loader, Stack, Text } from "@mantine/core";
|
||||
|
||||
import type { FaydaCallbackMessage } from "@/services/verifayda.service";
|
||||
import {
|
||||
takePendingVerification,
|
||||
verifaydaService,
|
||||
} from "@/services/verifayda.service";
|
||||
|
||||
/**
|
||||
* Landing page for the portal's eSignet redirect_uri
|
||||
* (FAYDA_PORTAL_REDIRECT_URI → http://localhost:5173/callback). Runs inside the
|
||||
* verification popup: relays ?code&state (or ?error) to the window that opened
|
||||
* it via postMessage, then closes itself. The opener performs the completion
|
||||
* call so the single-use session is only consumed once, in one place.
|
||||
* (FAYDA_PORTAL_REDIRECT_URI → http://localhost:5173/fayda/callback).
|
||||
*
|
||||
* The verification is a full-page redirect, so the page that started it no
|
||||
* longer exists: this page completes the code+state exchange itself against
|
||||
* the subject FaydaVerifyPanel stashed, then sends the user back where they
|
||||
* were. Everything mounts fresh on the way back, so the verified identity is
|
||||
* fetched rather than pushed.
|
||||
*/
|
||||
export default function FaydaCallbackPage() {
|
||||
const [standalone, setStandalone] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [returnTo, setReturnTo] = useState("/");
|
||||
// The code+state are single-use, so StrictMode's double-invoked effect must
|
||||
// not exchange them twice — the second attempt would fail on a spent session.
|
||||
const startedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const message: FaydaCallbackMessage = {
|
||||
type: "fayda-callback",
|
||||
code: params.get("code") ?? undefined,
|
||||
state: params.get("state") ?? undefined,
|
||||
error: params.get("error") ?? undefined,
|
||||
errorDescription: params.get("error_description") ?? undefined,
|
||||
};
|
||||
if (startedRef.current) return;
|
||||
startedRef.current = true;
|
||||
|
||||
if (window.opener && window.opener !== window) {
|
||||
(window.opener as Window).postMessage(message, window.location.origin);
|
||||
window.close();
|
||||
} else {
|
||||
// Opened as a full-page redirect instead of a popup — nothing to relay to.
|
||||
setStandalone(true);
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const pending = takePendingVerification();
|
||||
if (pending) setReturnTo(pending.returnTo);
|
||||
|
||||
const authError = params.get("error");
|
||||
if (authError) {
|
||||
setError(params.get("error_description") ?? authError);
|
||||
return;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const code = params.get("code");
|
||||
const state = params.get("state");
|
||||
if (!code || !state) {
|
||||
setError("This verification link is missing its code — start again.");
|
||||
return;
|
||||
}
|
||||
if (!pending) {
|
||||
// Landed here without the tab that started it — a bookmarked/copied
|
||||
// callback URL, or sessionStorage cleared mid-flow.
|
||||
setError("This verification was started somewhere else — start again.");
|
||||
return;
|
||||
}
|
||||
|
||||
verifaydaService
|
||||
.completeIdentity(pending.subject, code, state)
|
||||
.then(() => navigate(pending.returnTo, { replace: true }))
|
||||
.catch((err) =>
|
||||
setError(
|
||||
(err as { response?: { data?: { message?: string } } })?.response
|
||||
?.data?.message ??
|
||||
(err instanceof Error ? err.message : "Verification failed"),
|
||||
),
|
||||
);
|
||||
}, [navigate]);
|
||||
|
||||
return (
|
||||
<Center h="100vh">
|
||||
<Stack align="center" gap="sm">
|
||||
{standalone ? (
|
||||
{error ? (
|
||||
<>
|
||||
<Text fw={600}>Verification window lost its parent page</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
Close this tab and start the verification again from the form.
|
||||
<Text fw={600}>Verification could not be completed</Text>
|
||||
<Text size="sm" c="edr-muted" ta="center" maw={360}>
|
||||
{error}
|
||||
</Text>
|
||||
<Button
|
||||
variant="light"
|
||||
onClick={() => navigate(returnTo, { replace: true })}
|
||||
>
|
||||
Go back
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Loader size="sm" color="edr-green" />
|
||||
<Text size="sm" c="dimmed">
|
||||
<Text size="sm" c="edr-muted">
|
||||
Completing Fayda verification…
|
||||
</Text>
|
||||
</>
|
||||
|
||||
@@ -82,6 +82,12 @@ function tabIncomplete(tabId: SettingsTab, profile: ProfileResponse): boolean {
|
||||
case "contact":
|
||||
return !profile.contactPersonName || !profile.contactPersonPhone;
|
||||
case "gm":
|
||||
// The GM is established through Fayda — verified in their own right or
|
||||
// declared the same person as the owner — so the identity answers this,
|
||||
// not the typed columns. A company that may still type them (foreign,
|
||||
// whose manager may hold no Fayda ID) is judged on those instead.
|
||||
if (profile.identity?.gm.verified) return false;
|
||||
if (profile.identity?.faydaRequired) return true;
|
||||
return (
|
||||
!profile.generalManagerName ||
|
||||
!profile.generalManagerEmail ||
|
||||
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
toFormValues,
|
||||
} from "./companyProfileForm/helpers";
|
||||
import FaydaVerifyPanel from "@/components/FaydaVerifyPanel";
|
||||
import { verifaydaService } from "@/services/verifayda.service";
|
||||
import type { CompanyIdentityState } from "@/services/verifayda.service";
|
||||
import { LinkCheckboxCard } from "./companyProfileForm/LinkCheckboxCard";
|
||||
import ETradeCompanyCard from "./companyProfileForm/ETradeCompanyCard";
|
||||
@@ -107,7 +108,11 @@ export default function CompanyProfileForm({
|
||||
>;
|
||||
/** Fayda verification state for the owner and the PoA (undefined until loaded). */
|
||||
identity?: CompanyIdentityState;
|
||||
/** Refetch the profile + requirements once a verification lands. */
|
||||
/**
|
||||
* Refetch the profile + requirements. Only the in-page identity actions need
|
||||
* this — a Fayda verification navigates the whole tab away and comes back to
|
||||
* a freshly booted app, so it has nothing to notify.
|
||||
*/
|
||||
onIdentityChange?: () => void;
|
||||
}) {
|
||||
const [step, setStep] = useState<CompanyStep>(initialStep ?? "company");
|
||||
@@ -342,7 +347,11 @@ export default function CompanyProfileForm({
|
||||
// "Same as …" links. A checked card prefills the target step's fields from the
|
||||
// source step and disables them (kept mirrored while linked); unchecking clears
|
||||
// them and re-enables editing.
|
||||
const [gmSameAsOwner, setGmSameAsOwner] = useState(false);
|
||||
// Seeded from the server so a resumed draft reopens with the declaration the
|
||||
// company already made, rather than an unticked box over a linked GM.
|
||||
const [gmSameAsOwner, setGmSameAsOwner] = useState(
|
||||
identity?.gmSameAsOwner ?? false,
|
||||
);
|
||||
const [contactSameAsGm, setContactSameAsGm] = useState(false);
|
||||
|
||||
// General Manager source. The company step's email/phone are seeded from
|
||||
@@ -369,6 +378,10 @@ export default function CompanyProfileForm({
|
||||
|
||||
useEffect(() => {
|
||||
if (!gmSameAsOwner) return;
|
||||
// A verified owner's identity is copied server-side and read back from
|
||||
// `identity.gm`; mirroring it into form fields here would send typed
|
||||
// values for something the API already owns.
|
||||
if (identity?.owner.verified) return;
|
||||
setValue("generalManagerName", gmSourceName, { shouldValidate: true });
|
||||
setValue("generalManagerEmail", gmSourceEmail, { shouldValidate: true });
|
||||
setValue("generalManagerPhone", gmSourcePhone, {
|
||||
@@ -377,18 +390,78 @@ export default function CompanyProfileForm({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [gmSameAsOwner, gmSourceName, gmSourceEmail, gmSourcePhone]);
|
||||
|
||||
const toggleGmSameAsOwner = (checked: boolean) => {
|
||||
/**
|
||||
* "Same as owner" has two meanings depending on what backs the owner.
|
||||
*
|
||||
* A Fayda-verified owner is a proven identity, so the declaration is made
|
||||
* server-side: the API copies that identity onto the GM and records what it
|
||||
* did. Anything typed here would arrive wearing a verified badge it hadn't
|
||||
* earned, which is exactly what the verification exists to prevent.
|
||||
*
|
||||
* A foreign company's owner is backed by a typed passport instead, so there
|
||||
* is nothing proven to copy — that stays the local field-mirroring it has
|
||||
* always been.
|
||||
*/
|
||||
const [gmLinkPending, setGmLinkPending] = useState(false);
|
||||
const toggleGmSameAsOwner = async (checked: boolean) => {
|
||||
setGmSameAsOwner(checked);
|
||||
if (!checked) {
|
||||
setValue("generalManagerName", "");
|
||||
setValue("generalManagerEmail", "");
|
||||
setValue("generalManagerPhone", "");
|
||||
if (!identity?.owner.verified) {
|
||||
if (!checked) {
|
||||
setValue("generalManagerName", "");
|
||||
setValue("generalManagerEmail", "");
|
||||
setValue("generalManagerPhone", "");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setGmLinkPending(true);
|
||||
try {
|
||||
if (checked) await verifaydaService.setGmSameAsOwner();
|
||||
else await verifaydaService.clearGmIdentity();
|
||||
onIdentityChange?.();
|
||||
} catch (err) {
|
||||
setGmSameAsOwner(!checked);
|
||||
setSaveError(
|
||||
(err as { response?: { data?: { message?: string } } })?.response?.data
|
||||
?.message ??
|
||||
(err instanceof Error ? err.message : "Could not update the general manager"),
|
||||
);
|
||||
} finally {
|
||||
setGmLinkPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const gmName = watch("generalManagerName");
|
||||
const gmEmail = watch("generalManagerEmail");
|
||||
const gmPhone = watch("generalManagerPhone");
|
||||
// Where the GM's details come from depends on how they were established: a
|
||||
// Fayda verification (or a "same as owner" declaration) owns them outright,
|
||||
// and only a company that may still type them falls back to form state.
|
||||
const gmVerified = identity?.gm.verified ?? false;
|
||||
const gmName = gmVerified
|
||||
? (identity?.gm.name ?? "")
|
||||
: watch("generalManagerName");
|
||||
const gmEmail = gmVerified
|
||||
? (identity?.gm.email ?? "")
|
||||
: watch("generalManagerEmail");
|
||||
const gmPhone = gmVerified
|
||||
? (identity?.gm.phone ?? "")
|
||||
: watch("generalManagerPhone");
|
||||
|
||||
/**
|
||||
* Whether the GM has been established at all — by verification, by the
|
||||
* "same as owner" declaration, or (only where Fayda is optional) by typing.
|
||||
* Fayda is an Ethiopian national ID, so a foreign company's GM may hold none.
|
||||
*/
|
||||
const gmTyped = Boolean(
|
||||
watch("generalManagerName")?.trim() &&
|
||||
watch("generalManagerEmail")?.trim() &&
|
||||
watch("generalManagerPhone")?.trim(),
|
||||
);
|
||||
const gmEstablished =
|
||||
gmVerified || (identity ? !identity.faydaRequired && gmTyped : gmTyped);
|
||||
|
||||
/** Same rule for the representative: verified, or typed where Fayda is optional. */
|
||||
const poaEstablished =
|
||||
(identity?.poa.verified ?? false) ||
|
||||
(identity ? !identity.faydaRequired && Boolean(watch("poaName")?.trim()) : false);
|
||||
|
||||
// While linked, mirror the source values into the (disabled) target fields so
|
||||
// the copy stays current even if the user goes back and edits the source.
|
||||
@@ -535,14 +608,16 @@ export default function CompanyProfileForm({
|
||||
const currentIdx = stepOrder.indexOf(step);
|
||||
|
||||
// The DARS delegation paper is what proves the representative was actually
|
||||
// delegated, so it's required the moment a PoA exists — and unconditionally
|
||||
// for a freight forwarder, whose PoA itself is mandatory. The API enforces
|
||||
// the same rule on save, so skipping it here only costs the customer a
|
||||
// delegated, so it's required the moment a PoA exists. The API enforces the
|
||||
// same rule on save, so skipping it here only costs the customer a
|
||||
// round-trip.
|
||||
// A PoA exists exactly when one has been verified — the details are the
|
||||
// verification's output, so there is nothing else that could stand for one.
|
||||
// Until then the upload is hidden: there is no representative for the paper
|
||||
// to authorise, and a freight forwarder is held on the verification gate
|
||||
// below rather than on a file field it cannot yet fill.
|
||||
const poaProvided = identity?.poa.verified ?? false;
|
||||
const delegationRequired = requirePoa || poaProvided;
|
||||
const delegationRequired = poaProvided;
|
||||
const delegationPresent =
|
||||
(uploadedDocumentKeys ?? []).includes(POA_DELEGATION_FILE_KEY) ||
|
||||
(() => {
|
||||
@@ -628,9 +703,23 @@ export default function CompanyProfileForm({
|
||||
setSaveError("Verify the company owner's identity with Fayda before continuing.");
|
||||
return;
|
||||
}
|
||||
if (step === "poa" && requirePoa && !identity?.poa.verified) {
|
||||
// The GM is established through Fayda now, so the step gates on the
|
||||
// identity rather than on typed text — same strength as the old required
|
||||
// fields, different evidence. A foreign company's GM may hold no Fayda ID,
|
||||
// so typed details still satisfy it there.
|
||||
if (step === "personnel" && !gmEstablished) {
|
||||
setSaveError(
|
||||
"Freight forwarders act on other companies' behalf, so the Power of Attorney's identity must be verified with Fayda.",
|
||||
identity?.faydaRequired
|
||||
? "Verify the general manager with Fayda, or tick “same as owner” if they are the company's owner."
|
||||
: "Add the general manager's details, or verify them with Fayda.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (step === "poa" && requirePoa && !poaEstablished) {
|
||||
setSaveError(
|
||||
identity?.faydaRequired
|
||||
? "Freight forwarders act on other companies' behalf, so the Power of Attorney's identity must be verified with Fayda."
|
||||
: "Freight forwarders act on other companies' behalf, so a Power of Attorney is required.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -714,7 +803,6 @@ export default function CompanyProfileForm({
|
||||
title="Owner"
|
||||
state={identity.owner}
|
||||
required={identity.faydaRequired}
|
||||
onVerified={() => onIdentityChange?.()}
|
||||
/>
|
||||
{identity.passportRequired && (
|
||||
<TextInput
|
||||
@@ -766,10 +854,11 @@ export default function CompanyProfileForm({
|
||||
<Text fw={600} size="sm" c="edr-text">
|
||||
General Manager
|
||||
</Text>
|
||||
{/* GM is a plain typed role, not the person the Fayda
|
||||
verification proves — the owner is (see the Company step).
|
||||
They're very often the same human, which "same as owner" is
|
||||
for once the owner has verified. */}
|
||||
{/* The GM is very often the owner. Where the owner is
|
||||
Fayda-verified this reuses that proven identity outright
|
||||
rather than making the same human verify twice; where the
|
||||
owner is backed by a typed passport there is nothing proven
|
||||
to copy, so it stays a local prefill. */}
|
||||
<LinkCheckboxCard
|
||||
checked={gmSameAsOwner}
|
||||
onToggle={toggleGmSameAsOwner}
|
||||
@@ -780,33 +869,53 @@ export default function CompanyProfileForm({
|
||||
}
|
||||
description={
|
||||
identity?.owner.verified
|
||||
? "Reuse the Fayda-verified owner's name, email and phone. Uncheck to enter different details."
|
||||
? "Reuse the Fayda-verified owner's identity for the general manager. Uncheck to verify a different person."
|
||||
: etradeOwner
|
||||
? "Reuse the eTrade-registered owner's name, plus the company email and phone as you entered them. Uncheck to enter different details."
|
||||
: "Reuse your account's name and the company email and phone as you entered them. Uncheck to enter different details."
|
||||
}
|
||||
/>
|
||||
<TextInput
|
||||
label="Name"
|
||||
placeholder="Abebe Bikila"
|
||||
error={errors.generalManagerName?.message}
|
||||
{...register("generalManagerName")}
|
||||
/>
|
||||
<SimpleGrid cols={2} spacing="md">
|
||||
<TextInput
|
||||
label="Email"
|
||||
type="email"
|
||||
placeholder="gm@company.com"
|
||||
error={errors.generalManagerEmail?.message}
|
||||
{...register("generalManagerEmail")}
|
||||
|
||||
{/* Verifying a second person is only meaningful when the GM is
|
||||
someone other than the owner. */}
|
||||
{!gmSameAsOwner && identity && (
|
||||
<FaydaVerifyPanel
|
||||
subject="gm"
|
||||
title="General Manager"
|
||||
state={identity.gm}
|
||||
required={identity.faydaRequired}
|
||||
disabled={gmLinkPending}
|
||||
/>
|
||||
<ControlledPhoneField
|
||||
control={control}
|
||||
name="generalManagerPhone"
|
||||
label="Phone"
|
||||
required
|
||||
/>
|
||||
</SimpleGrid>
|
||||
)}
|
||||
|
||||
{/* Typed details survive only where Fayda cannot be required —
|
||||
a foreign company's manager may hold no Fayda ID. Once
|
||||
verified the API owns these fields, so they go away. */}
|
||||
{!gmSameAsOwner && !gmVerified && !identity?.faydaRequired && (
|
||||
<>
|
||||
<TextInput
|
||||
label="Name"
|
||||
placeholder="Abebe Bikila"
|
||||
error={errors.generalManagerName?.message}
|
||||
{...register("generalManagerName")}
|
||||
/>
|
||||
<SimpleGrid cols={2} spacing="md">
|
||||
<TextInput
|
||||
label="Email"
|
||||
type="email"
|
||||
placeholder="gm@company.com"
|
||||
error={errors.generalManagerEmail?.message}
|
||||
{...register("generalManagerEmail")}
|
||||
/>
|
||||
<ControlledPhoneField
|
||||
control={control}
|
||||
name="generalManagerPhone"
|
||||
label="Phone"
|
||||
required
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -872,19 +981,23 @@ export default function CompanyProfileForm({
|
||||
title="Power of Attorney"
|
||||
state={identity.poa}
|
||||
required={requirePoa}
|
||||
onVerified={() => onIdentityChange?.()}
|
||||
/>
|
||||
)}
|
||||
{/* The city is the one field the Fayda address claim does not
|
||||
reliably decompose into, so it stays typed. */}
|
||||
<TextInput
|
||||
label="PoA Location"
|
||||
placeholder="City, Country"
|
||||
error={errors.poaLocation?.message}
|
||||
{...register("poaLocation")}
|
||||
/>
|
||||
{/* The address comes from the Fayda claim along with the name,
|
||||
so it is shown on the panel rather than typed. Only a company
|
||||
whose representative may hold no Fayda ID still types it. */}
|
||||
{!identity?.poa.verified && !identity?.faydaRequired && (
|
||||
<TextInput
|
||||
label="PoA Location"
|
||||
placeholder="City, Country"
|
||||
error={errors.poaLocation?.message}
|
||||
{...register("poaLocation")}
|
||||
/>
|
||||
)}
|
||||
|
||||
{poaDocumentSetting && (
|
||||
{/* The paper authorises the representative the verification
|
||||
named, so it only has meaning once one exists. */}
|
||||
{poaProvided && poaDocumentSetting && (
|
||||
<>
|
||||
<Divider my="sm" />
|
||||
<SmartFileInput
|
||||
|
||||
@@ -69,12 +69,24 @@ export const onboardingSchema = z.object({
|
||||
.string()
|
||||
.min(1, "Contact person phone is required")
|
||||
.refine(isValidPhone, "Enter a valid phone number"),
|
||||
generalManagerName: z.string().min(1, "Manager name is required"),
|
||||
generalManagerEmail: z.string().email("Invalid Manager email"),
|
||||
// Optional here, not unrequired: the GM is now established by Fayda — either
|
||||
// verified in their own right or declared the same person as the owner — so
|
||||
// for an Ethiopian company these fields are never typed and would fail a
|
||||
// blanket `min(1)`. Presence is gated per nationality in the step's own
|
||||
// check, where the identity state is available; zod only polices format for
|
||||
// the foreign companies that still type them.
|
||||
generalManagerName: z.string().optional(),
|
||||
generalManagerEmail: z
|
||||
.string()
|
||||
.optional()
|
||||
.refine(
|
||||
(v) => !v || z.string().email().safeParse(v).success,
|
||||
"Invalid Manager email",
|
||||
),
|
||||
generalManagerPhone: z
|
||||
.string()
|
||||
.min(1, "Manager phone is required")
|
||||
.refine(isValidPhone, "Enter a valid phone number"),
|
||||
.optional()
|
||||
.refine((v) => !v || isValidPhone(v), "Enter a valid phone number"),
|
||||
poaName: z.string().optional(),
|
||||
poaPhone: z
|
||||
.string()
|
||||
|
||||
@@ -379,11 +379,6 @@ export default function TabCompanyProfile({
|
||||
required={identity.faydaRequired}
|
||||
disabled={mutation.isPending}
|
||||
pendingReview={pendingOwnerReview}
|
||||
onVerified={() =>
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: api.companies.getProfile.queryKey(),
|
||||
})
|
||||
}
|
||||
/>
|
||||
{identity.passportRequired && (
|
||||
<TextInput
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import { Briefcase, CheckCircle2, Save, XCircle } from "lucide-react";
|
||||
import {
|
||||
Alert,
|
||||
Card,
|
||||
Group,
|
||||
Stack,
|
||||
@@ -15,17 +16,29 @@ import {
|
||||
Grid,
|
||||
} from "@mantine/core";
|
||||
import { api } from "@/services/api";
|
||||
import { verifaydaService } from "@/services/verifayda.service";
|
||||
import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField";
|
||||
import { LinkCheckboxCard } from "@/pages/accounts/companyProfileForm/LinkCheckboxCard";
|
||||
import FaydaVerifyPanel from "@/components/FaydaVerifyPanel";
|
||||
import type { ProfileResponse } from "@/types/profile";
|
||||
|
||||
// Optional, not unrequired: an Ethiopian company's GM is established through
|
||||
// Fayda and never types these, so a blanket `min(1)` would fail a form that is
|
||||
// correct. Presence is gated below, where the identity state says which route
|
||||
// applies; zod only polices format for the companies that still type them.
|
||||
const schema = z.object({
|
||||
generalManagerName: z.string().min(1, "GM name is required"),
|
||||
generalManagerEmail: z.string().email("Invalid GM email"),
|
||||
generalManagerName: z.string().optional(),
|
||||
generalManagerEmail: z
|
||||
.string()
|
||||
.optional()
|
||||
.refine(
|
||||
(v) => !v || z.string().email().safeParse(v).success,
|
||||
"Invalid GM email",
|
||||
),
|
||||
generalManagerPhone: z
|
||||
.string()
|
||||
.min(1, "GM phone is required")
|
||||
.refine(isValidPhone, "Enter a valid phone number"),
|
||||
.optional()
|
||||
.refine((v) => !v || isValidPhone(v), "Enter a valid phone number"),
|
||||
});
|
||||
|
||||
type FormData = z.infer<typeof schema>;
|
||||
@@ -37,16 +50,20 @@ interface TabGeneralManagerProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* The general manager is a plain typed role, not the person the Fayda
|
||||
* verification proves — the owner is. They're very often the same human,
|
||||
* which is what "Same as owner" is for: once the owner has verified, this
|
||||
* copies their name/email/phone in rather than making the customer re-type
|
||||
* data the company already proved.
|
||||
* The general manager's identity comes from Fayda: either verified in their
|
||||
* own right, or declared to be the owner — very often the same human, which is
|
||||
* what "Same as owner" is for. Typed details survive only for a foreign
|
||||
* company, whose manager may hold no Fayda ID at all.
|
||||
*/
|
||||
export default function TabGeneralManager({ profile, mode = "edit", onContinue }: TabGeneralManagerProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const owner = profile.identity?.owner;
|
||||
const [gmSameAsOwner, setGmSameAsOwner] = useState(false);
|
||||
const identity = profile.identity;
|
||||
const gm = identity?.gm;
|
||||
const faydaRequired = identity?.faydaRequired ?? false;
|
||||
const [gmSameAsOwner, setGmSameAsOwner] = useState(
|
||||
identity?.gmSameAsOwner ?? false,
|
||||
);
|
||||
|
||||
const defaultValues = useMemo((): FormData => {
|
||||
return {
|
||||
@@ -68,25 +85,45 @@ export default function TabGeneralManager({ profile, mode = "edit", onContinue }
|
||||
values: defaultValues,
|
||||
});
|
||||
|
||||
const toggleGmSameAsOwner = (checked: boolean) => {
|
||||
/**
|
||||
* With a Fayda-verified owner the declaration is made server-side — the API
|
||||
* copies the proven identity onto the GM — so nothing is typed here. Without
|
||||
* one (a foreign company, whose owner is backed by a passport) there is
|
||||
* nothing proven to copy and this stays a local prefill.
|
||||
*/
|
||||
const [linkPending, setLinkPending] = useState(false);
|
||||
const [linkError, setLinkError] = useState<string | null>(null);
|
||||
const toggleGmSameAsOwner = async (checked: boolean) => {
|
||||
setGmSameAsOwner(checked);
|
||||
if (checked && owner) {
|
||||
setValue("generalManagerName", owner.name ?? "", { shouldValidate: true });
|
||||
setValue("generalManagerEmail", owner.email ?? "", { shouldValidate: true });
|
||||
setValue("generalManagerPhone", owner.phone ?? "", { shouldValidate: true });
|
||||
setLinkError(null);
|
||||
if (!owner?.verified) {
|
||||
if (checked && owner) {
|
||||
setValue("generalManagerName", owner.name ?? "", { shouldValidate: true });
|
||||
setValue("generalManagerEmail", owner.email ?? "", { shouldValidate: true });
|
||||
setValue("generalManagerPhone", owner.phone ?? "", { shouldValidate: true });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setLinkPending(true);
|
||||
try {
|
||||
if (checked) await verifaydaService.setGmSameAsOwner();
|
||||
else await verifaydaService.clearGmIdentity();
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: api.companies.getProfile.queryKey(),
|
||||
});
|
||||
} catch (err) {
|
||||
setGmSameAsOwner(!checked);
|
||||
setLinkError(
|
||||
(err as { response?: { data?: { message?: string } } })?.response?.data
|
||||
?.message ??
|
||||
(err instanceof Error ? err.message : "Could not update the general manager"),
|
||||
);
|
||||
} finally {
|
||||
setLinkPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Keep the copy live while the checkbox is on — e.g. the owner re-verifies
|
||||
// with updated details.
|
||||
useEffect(() => {
|
||||
if (!gmSameAsOwner || !owner) return;
|
||||
setValue("generalManagerName", owner.name ?? "");
|
||||
setValue("generalManagerEmail", owner.email ?? "");
|
||||
setValue("generalManagerPhone", owner.phone ?? "");
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [gmSameAsOwner, owner?.name, owner?.email, owner?.phone]);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (data: FormData) =>
|
||||
api.companies.updateProfile.call({
|
||||
@@ -102,6 +139,11 @@ export default function TabGeneralManager({ profile, mode = "edit", onContinue }
|
||||
|
||||
const onSubmit = (data: FormData) => mutation.mutate(data);
|
||||
|
||||
// Nothing to save when Fayda owns the details: the verification and the
|
||||
// "same as owner" declaration both write server-side, so the form would be
|
||||
// posting empty strings over a proven identity.
|
||||
const typedFieldsInUse = !gmSameAsOwner && !gm?.verified && !faydaRequired;
|
||||
|
||||
return (
|
||||
<Card padding="lg">
|
||||
<Group gap="sm" mb="xs">
|
||||
@@ -114,40 +156,70 @@ export default function TabGeneralManager({ profile, mode = "edit", onContinue }
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<Stack gap="md">
|
||||
{owner?.verified && (
|
||||
<LinkCheckboxCard
|
||||
checked={gmSameAsOwner}
|
||||
onToggle={toggleGmSameAsOwner}
|
||||
title="Same as verified owner"
|
||||
description="Reuse the Fayda-verified owner's name, email and phone. Uncheck to enter different details."
|
||||
/>
|
||||
)}
|
||||
<TextInput
|
||||
label="Full Name"
|
||||
placeholder="Abebe Bikila"
|
||||
error={errors.generalManagerName?.message}
|
||||
{...register("generalManagerName")}
|
||||
<LinkCheckboxCard
|
||||
checked={gmSameAsOwner}
|
||||
onToggle={toggleGmSameAsOwner}
|
||||
title={
|
||||
owner?.verified ? "Same as verified owner" : "Same as business owner"
|
||||
}
|
||||
description={
|
||||
owner?.verified
|
||||
? "Reuse the Fayda-verified owner's identity for the general manager. Uncheck to verify a different person."
|
||||
: "Reuse the owner's name, email and phone. Uncheck to enter different details."
|
||||
}
|
||||
/>
|
||||
|
||||
<Grid>
|
||||
<Grid.Col span={6}>
|
||||
{linkError && (
|
||||
<Alert color="red" variant="light" icon={<XCircle size={18} />}>
|
||||
{linkError}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Verifying a second person only means something when the manager
|
||||
is someone other than the owner. */}
|
||||
{!gmSameAsOwner && gm && (
|
||||
<FaydaVerifyPanel
|
||||
subject="gm"
|
||||
title="General Manager"
|
||||
state={gm}
|
||||
required={faydaRequired}
|
||||
disabled={linkPending || mutation.isPending}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Typed details survive only where Fayda cannot be required — a
|
||||
foreign company's manager may hold no Fayda ID. Once verified the
|
||||
API owns these fields and refuses edits, so they go away. */}
|
||||
{!gmSameAsOwner && !gm?.verified && !faydaRequired && (
|
||||
<>
|
||||
<TextInput
|
||||
label="Email Address"
|
||||
type="email"
|
||||
placeholder="gm@company.com"
|
||||
error={errors.generalManagerEmail?.message}
|
||||
{...register("generalManagerEmail")}
|
||||
label="Full Name"
|
||||
placeholder="Abebe Bikila"
|
||||
error={errors.generalManagerName?.message}
|
||||
{...register("generalManagerName")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={6}>
|
||||
<ControlledPhoneField
|
||||
control={control}
|
||||
name="generalManagerPhone"
|
||||
label="Phone Number"
|
||||
required
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
|
||||
<Grid>
|
||||
<Grid.Col span={6}>
|
||||
<TextInput
|
||||
label="Email Address"
|
||||
type="email"
|
||||
placeholder="gm@company.com"
|
||||
error={errors.generalManagerEmail?.message}
|
||||
{...register("generalManagerEmail")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={6}>
|
||||
<ControlledPhoneField
|
||||
control={control}
|
||||
name="generalManagerPhone"
|
||||
label="Phone Number"
|
||||
required
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Group
|
||||
@@ -171,7 +243,7 @@ export default function TabGeneralManager({ profile, mode = "edit", onContinue }
|
||||
)}
|
||||
</Group>
|
||||
<Group gap="md">
|
||||
{mode === "edit" && (
|
||||
{mode === "edit" && typedFieldsInUse && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
@@ -181,13 +253,26 @@ export default function TabGeneralManager({ profile, mode = "edit", onContinue }
|
||||
Reset
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
leftSection={<Save size={16} />}
|
||||
loading={mutation.isPending}
|
||||
>
|
||||
{mode === "onboarding" ? "Continue" : "Save Changes"}
|
||||
</Button>
|
||||
{/* Saving only means something while the details are typed: under
|
||||
Fayda both routes write server-side, so a submit would post
|
||||
empty strings at an identity the API owns and refuses to
|
||||
overwrite. Onboarding still needs a way forward, so the button
|
||||
becomes a plain Continue rather than disappearing. */}
|
||||
{typedFieldsInUse ? (
|
||||
<Button
|
||||
type="submit"
|
||||
leftSection={<Save size={16} />}
|
||||
loading={mutation.isPending}
|
||||
>
|
||||
{mode === "onboarding" ? "Continue" : "Save Changes"}
|
||||
</Button>
|
||||
) : (
|
||||
mode === "onboarding" && (
|
||||
<Button type="button" onClick={() => onContinue?.()}>
|
||||
Continue
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
</form>
|
||||
|
||||
@@ -136,7 +136,12 @@ export default function TabPowerOfAttorney({
|
||||
// has been verified.
|
||||
const identity = profile.identity;
|
||||
const poaProvided = identity?.poa.verified ?? false;
|
||||
const letterRequired = requirePoa || poaProvided;
|
||||
// The paper authorises the representative named above, so there is nothing
|
||||
// for it to authorise until one has been verified — the upload is hidden
|
||||
// until then, and requiring it while hidden would block the save on a
|
||||
// control the customer cannot see. A freight forwarder is still held to
|
||||
// having a PoA at all, by the verification gate on the panel and by the API.
|
||||
const letterRequired = poaProvided;
|
||||
const letterMissing = letterRequired && !hasLetterAfterSave;
|
||||
|
||||
const fileDirty = Boolean(pickedFile) || removeIds.length > 0;
|
||||
@@ -253,35 +258,33 @@ export default function TabPowerOfAttorney({
|
||||
state={identity.poa}
|
||||
required={requirePoa}
|
||||
disabled={mutation.isPending}
|
||||
onVerified={() => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: api.companies.getProfile.queryKey(),
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: api.companies.poaDelegation.queryKey(),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<Stack gap="md">
|
||||
{/* Name, email, phone and address are all written by the Fayda
|
||||
verification, so only the city — which the address claim does
|
||||
not reliably decompose into — is typed. */}
|
||||
<Grid>
|
||||
<Grid.Col span={6}>
|
||||
<TextInput
|
||||
label="PoA Location"
|
||||
placeholder="City, Country"
|
||||
error={errors.poaLocation?.message}
|
||||
{...register("poaLocation")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
{/* Name, email, phone and address all come from the Fayda
|
||||
verification and are shown on the panel above. Only a company
|
||||
whose representative may hold no Fayda ID still types a
|
||||
location. */}
|
||||
{!poaProvided && !(identity?.faydaRequired ?? false) && (
|
||||
<Grid>
|
||||
<Grid.Col span={6}>
|
||||
<TextInput
|
||||
label="PoA Location"
|
||||
placeholder="City, Country"
|
||||
error={errors.poaLocation?.message}
|
||||
{...register("poaLocation")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{/* ------------------------ Delegation letter ------------------------ */}
|
||||
{/* The paper authorises the representative the verification named,
|
||||
so it only has meaning once one exists. */}
|
||||
{poaProvided && (
|
||||
<Stack gap="sm" mt="xl">
|
||||
<Group justify="space-between" align="center">
|
||||
<Group gap="sm">
|
||||
@@ -429,6 +432,7 @@ export default function TabPowerOfAttorney({
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<Group
|
||||
justify="space-between"
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
stashPendingVerification,
|
||||
takePendingVerification,
|
||||
} from "./verifayda.service";
|
||||
|
||||
/**
|
||||
* The stash is the only thing that survives the full-page handoff to eSignet,
|
||||
* so /fayda/callback completing against the wrong subject — or throwing on junk left
|
||||
* behind by an older build — would either misfile a verified identity or dead-
|
||||
* end the flow.
|
||||
*/
|
||||
describe("pending verification stash", () => {
|
||||
// The suite runs in node, not jsdom — a Map is all these two calls need.
|
||||
beforeEach(() => {
|
||||
const store = new Map<string, string>();
|
||||
vi.stubGlobal("sessionStorage", {
|
||||
getItem: (k: string) => store.get(k) ?? null,
|
||||
setItem: (k: string, v: string) => void store.set(k, v),
|
||||
removeItem: (k: string) => void store.delete(k),
|
||||
});
|
||||
});
|
||||
|
||||
it("round-trips and clears, so a spent code can't be replayed", () => {
|
||||
stashPendingVerification({ subject: "poa", returnTo: "/settings?tab=poa" });
|
||||
|
||||
expect(takePendingVerification()).toEqual({
|
||||
subject: "poa",
|
||||
returnTo: "/settings?tab=poa",
|
||||
});
|
||||
expect(takePendingVerification()).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null rather than throwing on missing or malformed entries", () => {
|
||||
expect(takePendingVerification()).toBeNull();
|
||||
|
||||
sessionStorage.setItem("fayda-pending-verification", "not json");
|
||||
expect(takePendingVerification()).toBeNull();
|
||||
|
||||
sessionStorage.setItem("fayda-pending-verification", '{"returnTo":"/"}');
|
||||
expect(takePendingVerification()).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -3,11 +3,12 @@ import { unwrap } from "@/utils/endpoint";
|
||||
import type { ApiResponse } from "@/types/apiResponse";
|
||||
|
||||
/**
|
||||
* Which of the company's people a verification is for. The owner is NOT the
|
||||
* general manager — GM is a plain typed role the portal offers a "same as
|
||||
* owner" copy for, but only the owner and the PoA are ever Fayda-verified.
|
||||
* Which of the company's people a verification is for. The owner is who the
|
||||
* company is proven through; the PoA and GM are personnel it names. The GM is
|
||||
* very often the owner — "same as owner" reuses that verification rather than
|
||||
* making the same human prove themselves twice.
|
||||
*/
|
||||
export type IdentitySubject = "owner" | "poa";
|
||||
export type IdentitySubject = "owner" | "poa" | "gm";
|
||||
|
||||
/** One person's Fayda verification state, as the API reports it. */
|
||||
export interface IdentityVerificationState {
|
||||
@@ -29,29 +30,67 @@ export interface OwnerIdentityState extends IdentityVerificationState {
|
||||
}
|
||||
|
||||
export interface CompanyIdentityState {
|
||||
/** True when Fayda verification of the owner (and PoA) is mandatory — Ethiopian companies only. */
|
||||
/**
|
||||
* True when Fayda verification is mandatory — Ethiopian companies only.
|
||||
* Doubles as "may this person be typed instead": Fayda is an Ethiopian
|
||||
* national ID, so a foreign company's GM and PoA are offered the
|
||||
* verification but fall back to typed details when they hold none.
|
||||
*/
|
||||
faydaRequired: boolean;
|
||||
/** True when the owner's passport number is mandatory — foreign companies only. */
|
||||
passportRequired: boolean;
|
||||
owner: OwnerIdentityState;
|
||||
poa: IdentityVerificationState;
|
||||
/**
|
||||
* General manager. `verified` covers both routes: the GM verifying in their
|
||||
* own right, and the company declaring the GM is the owner (in which case
|
||||
* `gmSameAsOwner` is set and the owner's Fayda sub backs it).
|
||||
*/
|
||||
gm: IdentityVerificationState;
|
||||
gmSameAsOwner: boolean;
|
||||
complete: boolean;
|
||||
}
|
||||
|
||||
/** Message posted from the /callback popup back to the opener window. */
|
||||
export interface FaydaCallbackMessage {
|
||||
type: "fayda-callback";
|
||||
code?: string;
|
||||
state?: string;
|
||||
error?: string;
|
||||
errorDescription?: string;
|
||||
/**
|
||||
* What the panel was doing when it handed the tab over to eSignet. The
|
||||
* verification is a full-page redirect, so the page that started it is gone by
|
||||
* the time /fayda/callback runs — this is how that page knows whose identity
|
||||
* the code+state belongs to and where to put the user back.
|
||||
*
|
||||
* sessionStorage, not localStorage: it is scoped to this tab, so two tabs
|
||||
* verifying different people can't overwrite each other, and it dies with the
|
||||
* tab rather than outliving an abandoned verification.
|
||||
*/
|
||||
const PENDING_KEY = "fayda-pending-verification";
|
||||
|
||||
export interface PendingVerification {
|
||||
subject: IdentitySubject;
|
||||
/** Path to return to once the verification completes. */
|
||||
returnTo: string;
|
||||
}
|
||||
|
||||
export function stashPendingVerification(pending: PendingVerification): void {
|
||||
sessionStorage.setItem(PENDING_KEY, JSON.stringify(pending));
|
||||
}
|
||||
|
||||
/** Read and clear — the code+state are single-use, so a retry needs a fresh start. */
|
||||
export function takePendingVerification(): PendingVerification | null {
|
||||
const raw = sessionStorage.getItem(PENDING_KEY);
|
||||
sessionStorage.removeItem(PENDING_KEY);
|
||||
if (!raw) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as PendingVerification;
|
||||
return parsed.subject ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export const verifaydaService = {
|
||||
/**
|
||||
* Returns the eSignet authorize URL to open in a popup. `PORTAL` selects the
|
||||
* portal's own registered redirect_uri — the backoffice and mobile clients
|
||||
* have their own.
|
||||
* Returns the eSignet authorize URL to navigate the tab to. `PORTAL` selects
|
||||
* the portal's own registered redirect_uri — the backoffice and mobile
|
||||
* clients have their own.
|
||||
*/
|
||||
start: async (): Promise<string> => {
|
||||
const response = await client.post<
|
||||
@@ -80,6 +119,30 @@ export const verifaydaService = {
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
/**
|
||||
* Declare the General Manager is the company's owner, reusing the owner's
|
||||
* verified identity rather than making the same human verify twice. The copy
|
||||
* happens server-side from the stored owner identity — the portal never
|
||||
* supplies the values — and is refused until the owner is verified.
|
||||
*/
|
||||
setGmSameAsOwner: async (): Promise<CompanyIdentityState> => {
|
||||
const response = await client.post<ApiResponse<CompanyIdentityState>>(
|
||||
"/api/companies/identity/gm/same-as-owner",
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear the GM's identity — the "same as owner" declaration or a verification
|
||||
* of their own — leaving them open to be re-established either way.
|
||||
*/
|
||||
clearGmIdentity: async (): Promise<CompanyIdentityState> => {
|
||||
const response = await client.delete<ApiResponse<CompanyIdentityState>>(
|
||||
"/api/companies/identity/gm",
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
/**
|
||||
* Drop the Power of Attorney — verified identity, details and delegation
|
||||
* paper together. A verified person's fields are locked, so blanking the form
|
||||
|
||||
@@ -36,11 +36,15 @@ export interface ProfileResponse {
|
||||
generalManagerEmail: string | null;
|
||||
generalManagerPhone: string | null;
|
||||
/**
|
||||
* Fayda verification state for the owner and the PoA — not the general
|
||||
* manager, which stays a plain typed role. `identity.faydaRequired` /
|
||||
* `identity.passportRequired` is the Ethiopian/foreign switch: an Ethiopian
|
||||
* company verifies the owner (and PoA) with Fayda; a foreign one requires a
|
||||
* typed passport number for the owner instead.
|
||||
* Fayda verification state for the owner, the PoA and the general manager.
|
||||
* `identity.faydaRequired` / `identity.passportRequired` is the
|
||||
* Ethiopian/foreign switch: an Ethiopian company verifies all three with
|
||||
* Fayda, while a foreign one proves its owner with a typed passport number
|
||||
* and may type its GM and PoA, whose holders may have no Fayda ID.
|
||||
*
|
||||
* The `generalManager*` fields above are the same person's details written
|
||||
* flat — a verification keeps them in step, since the booking, contract and
|
||||
* train-scheduling notifiers mail `generalManagerEmail` directly.
|
||||
*/
|
||||
identity: CompanyIdentityState;
|
||||
poaName: string | null;
|
||||
|
||||
@@ -235,8 +235,8 @@ services:
|
||||
PAYMENT_API_URL: http://payment-mock-e2e:4500
|
||||
FAYDA_TOKEN_ENDPOINT: http://fayda-mock-e2e:4400/token
|
||||
FAYDA_USERINFO_ENDPOINT: http://fayda-mock-e2e:4400/userinfo
|
||||
FAYDA_REDIRECT_URI: http://localhost:${E2E_PORTAL_PORT:-5373}/callback
|
||||
FAYDA_PORTAL_REDIRECT_URI: http://localhost:${E2E_PORTAL_PORT:-5373}/callback
|
||||
FAYDA_REDIRECT_URI: http://localhost:${E2E_PORTAL_PORT:-5373}/fayda/callback
|
||||
FAYDA_PORTAL_REDIRECT_URI: http://localhost:${E2E_PORTAL_PORT:-5373}/fayda/callback
|
||||
# Throwaway e2e-only RSA JWK (client_assertion signing) — the mock
|
||||
# never verifies the signature, this just has to be well-formed.
|
||||
# Generated fresh per launch by e2e.mjs (fakeFaydaPrivateKeyBase64),
|
||||
|
||||
@@ -1,12 +1,24 @@
|
||||
# EDR Freight — API integration stack (headless).
|
||||
# EDR Freight — API integration stack (headless, sharded).
|
||||
#
|
||||
# Overlay on docker-compose.e2e.yaml. Same base services (postgres, minio,
|
||||
# mocks, freight-api), except the payment microservice is REAL here instead of
|
||||
# `payment-mock-e2e`, and only the bank gateways are stubbed:
|
||||
# Overlay on docker-compose.e2e.yaml, plus a GENERATED third file holding the
|
||||
# per-shard services (integration/.it-shards.yaml, written by gen-shards.mjs).
|
||||
#
|
||||
# freight-api-it ──HTTP──> payment-api-it ──HTTP──> gateway-mock-it
|
||||
# ^ │
|
||||
# └────── RabbitMQ ───────┘ (outbox → payment.events → consumer)
|
||||
# The freight API is NOT a container here — it boots inside each vitest worker,
|
||||
# on the host, so a code change needs no image rebuild and a breakpoint works.
|
||||
# Each worker is a full shard of the topology; nothing mutable is shared:
|
||||
#
|
||||
# shard i:
|
||||
# freight app (in vitest worker, host :3111+i)
|
||||
# │ ▲
|
||||
# │ └──── HTTP ─── payment-api-it-{i} :3131+i (inbound CBE bill query,
|
||||
# │ │ via host.docker.internal)
|
||||
# └── HTTP ──────────────► │ ──HTTP──> gateway-mock-it-{i} :4600+i
|
||||
# ▲ │
|
||||
# └──── RabbitMQ vhost payment_s{i} ──┘ (outbox → payment.events → consumer)
|
||||
#
|
||||
# Shared by every shard: postgres (one container, one database per shard cloned
|
||||
# from a seeded template), rabbitmq (one container, one vhost per shard), minio,
|
||||
# and the one-shot migration.
|
||||
#
|
||||
# Its own compose project (`name:` below overrides the base) and its own host
|
||||
# ports, so it can run side by side with the Cypress e2e stack.
|
||||
@@ -15,13 +27,16 @@
|
||||
#
|
||||
# Never start it with plain `docker compose -f docker-compose.it.yaml` — it is
|
||||
# an OVERLAY and needs the base file first:
|
||||
# docker compose -f docker-compose.e2e.yaml -f docker-compose.it.yaml ...
|
||||
# docker compose -f docker-compose.e2e.yaml -f docker-compose.it.yaml \
|
||||
# -f integration/.it-shards.yaml ...
|
||||
name: edr-freight-it
|
||||
|
||||
services:
|
||||
# Outbox transport. The payment API publishes payment.succeeded/failed here
|
||||
# and freight consumes it — the production path. Copied from the passenger
|
||||
# harness (e2e/docker-compose.yml).
|
||||
# harness (e2e/docker-compose.yml). One broker, one vhost per shard
|
||||
# (payment_s0, payment_s1, …) created by it.mjs — a shared vhost would let one
|
||||
# shard's freight consumer eat another shard's settlement event.
|
||||
rabbitmq-it:
|
||||
image: rabbitmq:3-management
|
||||
environment:
|
||||
@@ -32,139 +47,14 @@ services:
|
||||
- "${IT_RABBIT_PORT:-5772}:5672"
|
||||
- "${IT_RABBIT_UI_PORT:-15772}:15672"
|
||||
healthcheck:
|
||||
test: ["CMD", "rabbitmq-diagnostics", "-q", "ping"]
|
||||
# check_running, NOT ping: ping only proves the Erlang node answers, and
|
||||
# it.mjs runs `rabbitmqctl add_vhost` the moment this goes healthy — which
|
||||
# on a cold boot failed with "this command requires the 'rabbit' app to be
|
||||
# running on the target node". check_running waits for the application.
|
||||
test: ["CMD", "rabbitmq-diagnostics", "-q", "check_running"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
timeout: 10s
|
||||
retries: 20
|
||||
|
||||
# Stand-in for every bank/wallet gateway the payment API talks to, plus a
|
||||
# control plane the tests drive (force a provider to fail/hang, fire a
|
||||
# correctly-signed webhook, read back what was called). See
|
||||
# integration/gateway-mock/server.js.
|
||||
gateway-mock-it:
|
||||
image: node:20-alpine
|
||||
volumes:
|
||||
- ./integration/gateway-mock:/app:ro
|
||||
working_dir: /app
|
||||
environment:
|
||||
PORT: "4600"
|
||||
# Same secrets the payment API gets — so webhooks the mock signs pass the
|
||||
# API's REAL signature verification instead of bypassing it.
|
||||
CBE_SECRET_KEY: it-cbe-secret
|
||||
CBE_MERCHANT_ID: it-cbe-merchant
|
||||
PAYMENT_API_URL: http://payment-api-it:3003
|
||||
command: ["node", "server.js"]
|
||||
ports:
|
||||
- "${IT_GATEWAY_PORT:-4600}:4600"
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD",
|
||||
"node",
|
||||
"-e",
|
||||
"fetch('http://localhost:4600/__control/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))",
|
||||
]
|
||||
interval: 3s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
|
||||
payment-api-it:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: apps/edr-payment-api/Dockerfile
|
||||
secrets:
|
||||
- npmrc
|
||||
depends_on:
|
||||
postgres-freight-e2e:
|
||||
condition: service_healthy
|
||||
rabbitmq-it:
|
||||
condition: service_healthy
|
||||
gateway-mock-it:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
PORT: "3003"
|
||||
NODE_ENV: test
|
||||
# Payment tables live in their own schema of the same throwaway DB;
|
||||
# main.ts ensurePaymentSchema() creates it, migrationsRun does the rest.
|
||||
DB_HOST: postgres-freight-e2e
|
||||
DB_PORT: "5432"
|
||||
DB_USER: edr_e2e
|
||||
DB_PASSWORD: edr_e2e
|
||||
DB_NAME: edr_freight_e2e
|
||||
DB_SCHEMA: edr_payment
|
||||
# Same token freight already uses in the base stack.
|
||||
SERVICE_AUTH_TOKEN: e2e-service-token
|
||||
PUBLISHER_TRANSPORT: rabbitmq
|
||||
PAYMENT_RABBITMQ_URL: amqp://edr:edr_secret@rabbitmq-it:5672/payment
|
||||
# HTTP fallback targets (only used with PUBLISHER_TRANSPORT=http).
|
||||
PAYMENT_NOTIFY_FREIGHT_URL: http://freight-api-e2e:3001/api/internal/payments/mark-paid
|
||||
# Fast relay + sweep so retry/reconciliation are observable inside a test
|
||||
# rather than a minute later.
|
||||
OUTBOX_RELAY_INTERVAL_MS: "1000"
|
||||
RECONCILE_STALE_AFTER_MS: "5000"
|
||||
# Every gateway points at the one mock. Paths are per-provider prefixes.
|
||||
CBE_BASE_URL: http://gateway-mock-it:4600/cbe-birr
|
||||
CBE_MERCHANT_ID: it-cbe-merchant
|
||||
CBE_SECRET_KEY: it-cbe-secret
|
||||
CBE_NOTIFY_URL: http://payment-api-it:3003/webhooks/cbe-birr
|
||||
CBE_RETURN_URL: http://localhost/return
|
||||
TELEBIRR_BASE_URL: http://gateway-mock-it:4600/telebirr
|
||||
TELEBIRR_WEB_BASE_URL: http://gateway-mock-it:4600/telebirr/web
|
||||
TELEBIRR_FABRIC_APP_ID: it-fabric
|
||||
TELEBIRR_APP_SECRET: it-secret
|
||||
TELEBIRR_MERCHANT_APP_ID: it-merchant-app
|
||||
TELEBIRR_MERCHANT_CODE: "999999"
|
||||
TELEBIRR_NOTIFY_URL: http://payment-api-it:3003/webhooks/telebirr
|
||||
# Telebirr PSS-signs every request object — a throwaway key generated per
|
||||
# launch by it.mjs (nothing key-shaped lives in git).
|
||||
TELEBIRR_PRIVATE_KEY: ${IT_TELEBIRR_PRIVATE_KEY}
|
||||
EBIRR_BASE_URL: http://gateway-mock-it:4600/ebirr
|
||||
DMONEY_BASE_URL: http://gateway-mock-it:4600/dmoney
|
||||
CARD_BASE_URL: http://gateway-mock-it:4600/card
|
||||
WAAFI_BASE_URL: http://gateway-mock-it:4600/waafi
|
||||
CAC_BASE_URL: http://gateway-mock-it:4600/cac
|
||||
CAC_USERNAME: it-cac
|
||||
CAC_PASSWORD: it-cac
|
||||
CAC_APP_KEY: it-cac-key
|
||||
CAC_API_KEY: it-cac-api
|
||||
CAC_COMPANY_SERVICES_ID: "1"
|
||||
# Inbound CBE Unified Bill — we are the biller; bill-query hops back into
|
||||
# the freight API, so this direction runs real code on both sides.
|
||||
CBE_BILL_ENABLED: "true"
|
||||
CBE_BILL_CLIENT_ID: it-cbe-bill
|
||||
CBE_BILL_CLIENT_SECRET: it-cbe-bill-secret
|
||||
CBE_BILL_JWT_SECRET: it-cbe-bill-jwt
|
||||
FREIGHT_API_BASE_URL: http://freight-api-e2e:3001/api
|
||||
ports:
|
||||
- "${IT_PAYMENT_PORT:-3113}:3003"
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD",
|
||||
"node",
|
||||
"-e",
|
||||
"fetch('http://localhost:3003/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))",
|
||||
]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
start_period: 40s
|
||||
|
||||
# Base-stack service, re-pointed at the real payment API.
|
||||
freight-api-e2e:
|
||||
depends_on:
|
||||
payment-api-it:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
PAYMENT_API_URL: http://payment-api-it:3003
|
||||
# Freight's payment module skips RabbitMQModule entirely when this is
|
||||
# unset (payment.module.ts) — without it, outbox events never arrive.
|
||||
PAYMENT_RABBITMQ_URL: amqp://edr:edr_secret@rabbitmq-it:5672/payment
|
||||
# RABBITMQ_ENABLED stays "false" (base stack) — it only gates the SMS/email
|
||||
# clients, which must remain off. The payment consumer is wired by
|
||||
# PAYMENT_RABBITMQ_URL alone.
|
||||
# Drain tail on every pay window. Production defaults to 5 minutes; a
|
||||
# reservation here lives ~60s, so 5 would push every natural expiry past
|
||||
# the suite's 180s timeouts. One minute keeps the tail real and observable
|
||||
# (src/expired-invoice-late-settle.it.ts asserts both sides of it).
|
||||
FREIGHT_PAYMENT_DRAIN_MINUTES: "1"
|
||||
# The per-shard `gateway-mock-it-{i}` and `payment-api-it-{i}` services live in
|
||||
# integration/.it-shards.yaml. The base file's `freight-api-e2e` is never
|
||||
# started — the app runs in-process (integration/src/app.ts).
|
||||
|
||||
@@ -42,21 +42,44 @@ function latestOnboardJourney() {
|
||||
});
|
||||
}
|
||||
|
||||
/** Fill a labelled Mantine input (label[for] → input id). */
|
||||
/**
|
||||
* Fill a labelled Mantine input (label[for] → input id).
|
||||
*
|
||||
* The input is resolved fresh for every action rather than captured once.
|
||||
* Each wizard step persists and re-seeds asynchronously, and when a field
|
||||
* remounts Mantine mints a NEW generated id — so both a subject and an id
|
||||
* captured a command earlier can be stale by the time the next command runs.
|
||||
* Going label → for → element each time always addresses what's on the page
|
||||
* now.
|
||||
*/
|
||||
function fill(label: string | RegExp, value: string) {
|
||||
cy.contains("label", label)
|
||||
.invoke("attr", "for")
|
||||
.then((id) => {
|
||||
cy.get(`[id="${id}"]`).clear({ force: true }).type(value, { force: true });
|
||||
});
|
||||
const input = () =>
|
||||
cy
|
||||
.contains("label", label)
|
||||
.invoke("attr", "for")
|
||||
.then((id) => cy.get(`[id="${id}"]`));
|
||||
|
||||
input().clear({ force: true });
|
||||
input().type(value, { force: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill an input that has no <label> — the wizard's company step renders its
|
||||
* fields inside StepSection cards (the heading is the card's title, not a
|
||||
* label), so they're reachable only by aria-label. Both TIN and VAT share the
|
||||
* "0012345678" placeholder, which is why this matches on aria-label instead.
|
||||
*/
|
||||
function fillAria(ariaSelector: string, value: string) {
|
||||
const selector = `.mantine-Modal-content ${ariaSelector}`;
|
||||
cy.get(selector).clear({ force: true });
|
||||
cy.get(selector).type(value, { force: true });
|
||||
}
|
||||
|
||||
/** The wizard's phone inputs (react-phone-number-input, type=tel). */
|
||||
function fillPhone(index: number, national: string) {
|
||||
cy.get('.mantine-Modal-content input[type="tel"]')
|
||||
.eq(index)
|
||||
.clear({ force: true })
|
||||
.type(national, { force: true });
|
||||
const selector = '.mantine-Modal-content input[type="tel"]';
|
||||
cy.get(selector).eq(index).clear({ force: true });
|
||||
cy.get(selector).eq(index).type(national, { force: true });
|
||||
}
|
||||
|
||||
describe("customer onboarding journey", { retries: 0 }, () => {
|
||||
@@ -86,20 +109,48 @@ describe("customer onboarding journey", { retries: 0 }, () => {
|
||||
cy.get(".mantine-Modal-content").contains("button", "Continue").click();
|
||||
|
||||
// Ethiopian companies gate the "Owner identity" step on Fayda
|
||||
// verification — a real popup + SMS OTP flow that can't run in e2e.
|
||||
// Complete it via the API against fayda-mock-e2e (the profile this
|
||||
// verification — a real eSignet redirect + SMS OTP flow that can't run in
|
||||
// e2e. Complete it via the API against fayda-mock-e2e (the profile this
|
||||
// attaches to was just created by the nationality/role step above).
|
||||
// The wizard already fetched `identity` once when this step mounted —
|
||||
// completing verification out-of-band (no popup, so no onVerified
|
||||
// callback fires) leaves that fetch stale, so reload to force a fresh
|
||||
// one. Wizard progress resumes server-side, so this doesn't lose the
|
||||
// nationality/role step just completed.
|
||||
// The wizard already fetched `identity` once when this step mounted, and
|
||||
// completing verification out-of-band skips the redirect that would
|
||||
// normally remount everything — so reload to force a fresh fetch. Wizard
|
||||
// progress resumes server-side, so this doesn't lose the nationality/role
|
||||
// step just completed.
|
||||
completeFaydaVerification("owner");
|
||||
cy.reload();
|
||||
cy.contains("Confirm your VAT number", { timeout: 20000 }).should(
|
||||
"be.visible",
|
||||
);
|
||||
|
||||
// The owner's identity is the verification's output, never typed: the
|
||||
// panel must show it verified and render the name/phone/email that came
|
||||
// back from fayda-mock-e2e. Asserting the mock's own values is the only
|
||||
// way to prove the payload travelled Fayda → API → UI rather than the
|
||||
// panel simply flipping a "verified" flag.
|
||||
cy.get(".mantine-Modal-content").within(() => {
|
||||
cy.contains("Fayda verified").should("be.visible");
|
||||
cy.contains("Abebe Bekele").should("be.visible");
|
||||
cy.contains("+251911223344").should("be.visible");
|
||||
cy.contains("abebe.bekele@example.com").should("be.visible");
|
||||
});
|
||||
|
||||
// The verified sub is what locks the owner's fields server-side, so check
|
||||
// it actually landed on the company rather than trusting the panel alone.
|
||||
cy.task<{ rows: Array<{ owner_fayda_sub: string | null }> }>("db:query", {
|
||||
sql: `SELECT c.attributes->>'ownerFaydaSub' AS owner_fayda_sub
|
||||
FROM freight.companies c
|
||||
JOIN freight.external_profiles ep ON ep.company_id = c.id
|
||||
JOIN iam.users u ON u.id = ep.user_id
|
||||
WHERE u.email = $1`,
|
||||
params: [email],
|
||||
}).then(({ rows }) => {
|
||||
expect(rows, "company row").to.have.length(1);
|
||||
expect(rows[0].owner_fayda_sub, "owner Fayda sub").to.eq(
|
||||
"e2e-fayda-sub-0001",
|
||||
);
|
||||
});
|
||||
|
||||
// Company step. TIN auto-triggers the eTrade lookup once it's a full 10
|
||||
// digits (mocked in e2e — see docker-compose.e2e.yaml's etrade-mock-e2e).
|
||||
// A successful lookup locks Company Name/Region/Zone/Woreda/Kebele/House
|
||||
@@ -108,7 +159,7 @@ describe("customer onboarding journey", { retries: 0 }, () => {
|
||||
// Fayda-verified owner supplies contact details now). By label, not
|
||||
// placeholder: the VAT Number field on this same step shares the TIN
|
||||
// field's "0012345678" placeholder, so a placeholder selector matches 2.
|
||||
fill(/^TIN Number/, tin);
|
||||
fillAria('[aria-label^="TIN Number"]', tin);
|
||||
cy.contains("Verified with eTrade", { timeout: 15000 }).should(
|
||||
"be.visible",
|
||||
);
|
||||
@@ -117,7 +168,7 @@ describe("customer onboarding journey", { retries: 0 }, () => {
|
||||
// after the badge appears. Typing into VAT immediately raced one of
|
||||
// those and detached mid-type; let it finish before touching the form.
|
||||
cy.wait(500);
|
||||
fill(/^VAT Number/, vat);
|
||||
fillAria('[aria-label="VAT Number"]', vat);
|
||||
cy.get(".mantine-Modal-content").contains("button", "Continue").click();
|
||||
|
||||
// Personnel (general manager).
|
||||
@@ -131,7 +182,16 @@ describe("customer onboarding journey", { retries: 0 }, () => {
|
||||
fillPhone(0, "911234569");
|
||||
cy.get(".mantine-Modal-content").contains("button", "Continue").click();
|
||||
|
||||
// PoA — optional for an importer.
|
||||
// PoA — optional for an importer, and left unverified here. The DARS
|
||||
// delegation paper authorises the representative the verification names,
|
||||
// so with no verified PoA there is nothing for it to authorise: the
|
||||
// upload must not be offered, and the step must not block on it. Asserted
|
||||
// as "no file input on this step" rather than by label, so a reworded
|
||||
// document setting doesn't turn a real regression into a passing test.
|
||||
cy.get(".mantine-Modal-content")
|
||||
.contains("Power of Attorney")
|
||||
.should("be.visible");
|
||||
cy.get('.mantine-Modal-content input[type="file"]').should("not.exist");
|
||||
cy.get(".mantine-Modal-content").contains("button", "Continue").click();
|
||||
|
||||
// Documents: no company docs are configured in e2e, but every role needs
|
||||
|
||||
@@ -3,29 +3,77 @@
|
||||
Headless, API-level tests for the freight API running against the **real**
|
||||
`edr-payment-api`. Only the bank/wallet gateways are stubbed.
|
||||
|
||||
The freight API is **not containerized** here — it boots inside each vitest
|
||||
worker from `apps/edr-freight-api/dist`, which `it:test` rebuilds every run. A
|
||||
code change needs no image rebuild, and a breakpoint in the API is hit by the
|
||||
test that provoked it.
|
||||
|
||||
```
|
||||
pnpm it:up # build + start the stack (first run ~5 min)
|
||||
pnpm it:test # vitest run (auto-ups the stack if needed)
|
||||
pnpm it:test -- --reporter=basic src/payment-happy.it.ts
|
||||
pnpm it:logs payment-api-it
|
||||
pnpm it:down # -v, wipes the throwaway DB
|
||||
pnpm it:test --no-reset -- src/payment-happy.it.ts # skip the DB re-clone
|
||||
pnpm it:logs payment-api-it-0
|
||||
pnpm it:down # -v, wipes the throwaway DBs and the generated shard file
|
||||
|
||||
IT_SHARDS=1 pnpm it:up # 1 shard when RAM is tight or output must be readable
|
||||
```
|
||||
|
||||
## Stack
|
||||
|
||||
`docker-compose.it.yaml` is an **overlay** on `docker-compose.e2e.yaml` — same
|
||||
freight API, Postgres (tmpfs), MinIO, Fayda/eTrade mocks; plus RabbitMQ, the
|
||||
real payment API, and one gateway mock. It is a separate compose project
|
||||
(`edr-freight-it`) on offset ports, so the Cypress e2e stack can run alongside.
|
||||
`docker-compose.it.yaml` is an **overlay** on `docker-compose.e2e.yaml`, plus a
|
||||
third **generated** file (`integration/.it-shards.yaml`, written by
|
||||
`scripts/gen-shards.mjs`, gitignored) holding the per-shard services. It is a
|
||||
separate compose project (`edr-freight-it`) on offset ports, so the Cypress e2e
|
||||
stack can run alongside.
|
||||
|
||||
Each vitest worker is a whole shard of the topology. Nothing mutable is shared
|
||||
between shards, so spec files run in parallel:
|
||||
|
||||
```
|
||||
freight-api-e2e :3111 ──HTTP──> payment-api-it :3113 ──HTTP──> gateway-mock-it :4600
|
||||
^ │
|
||||
└────────── RabbitMQ :5772 ─────┘ (outbox → payment.events → consumer)
|
||||
shard i (vitest worker i, VITEST_POOL_ID)
|
||||
|
||||
freight app in-process, host :3111+i
|
||||
│ ▲
|
||||
│ └──── HTTP ──── payment-api-it-{i} :3131+i inbound CBE bill query,
|
||||
│ │ via host.docker.internal
|
||||
└── HTTP ──────────────► │ ──HTTP──> gateway-mock-it-{i} :4600+i
|
||||
▲ │
|
||||
└── RabbitMQ vhost payment_s{i} ──┘ (outbox → payment.events → consumer)
|
||||
|
||||
database edr_it_s{i} ← CREATE DATABASE … TEMPLATE edr_freight_e2e
|
||||
freight tables AND the payment API's edr_payment
|
||||
schema live in it, so `db()` and `paymentDb()` are
|
||||
one connection
|
||||
```
|
||||
|
||||
Shared by every shard: Postgres (one container, one database each), RabbitMQ
|
||||
(one container, one vhost each), MinIO, and the one-shot migration.
|
||||
|
||||
`IT_SHARDS` defaults to **4**. RAM is the ceiling, not cores — a shard is an
|
||||
in-process Nest app plus two containers.
|
||||
|
||||
Never `docker compose -f docker-compose.it.yaml` on its own — it needs the base
|
||||
file first. Use `it.mjs`.
|
||||
file first, and the generated shard file last. Use `it.mjs`.
|
||||
|
||||
## Setup order
|
||||
|
||||
The sequence is load-bearing, which is why `scripts/prepare-shards.mjs` exists
|
||||
rather than vitest's `globalSetup`:
|
||||
|
||||
1. `freight-migration-e2e` migrates the **template** database `edr_freight_e2e`.
|
||||
2. `prepare-shards.mjs` boots the freight app once against the template and
|
||||
calls `app.init()` — that fires `onApplicationBootstrap`, the always-on
|
||||
seeders (org, units, positions, permissions).
|
||||
3. …then applies the SQL fixtures, which declare exactly those as prerequisites
|
||||
(`seed-users.sql`: *"created by the API's always-on boot seeders"*).
|
||||
4. `CREATE DATABASE edr_it_s{i} TEMPLATE edr_freight_e2e` per shard — a file
|
||||
copy on the tmpfs Postgres, not a re-run of migrations and five seeders.
|
||||
5. The per-shard payment APIs start; each creates its own `edr_payment` schema
|
||||
inside its shard database.
|
||||
|
||||
`it:test` re-runs step 4 on **every run** (stop payment APIs → drop → clone →
|
||||
start), so each run is hermetic. `--no-reset` skips it for a quick rerun.
|
||||
|
||||
## Gateway mock
|
||||
|
||||
@@ -46,7 +94,15 @@ negative test. The suite drives **CBE Birr** end to end (plain HMAC, no key
|
||||
material); other providers answer a generic stub until a scenario needs them.
|
||||
|
||||
Editing `server.js` needs a container restart (`docker compose … restart
|
||||
gateway-mock-it`) — the code is a read-only mount, not baked into an image.
|
||||
gateway-mock-it-0`) — the code is a read-only mount, not baked into an image.
|
||||
|
||||
**One mock per shard.** `modes`, `calls` and `orders` are process-global in
|
||||
`server.js`, and 20 of 25 specs call `POST /__control/reset` in `beforeAll`. On a
|
||||
shared mock every starting spec would wipe every running spec's live orders —
|
||||
and that fails as a wrong *assertion*, not an error: an unknown order still gets
|
||||
a correctly-signed webhook, just carrying the mock's default amount. Per-shard
|
||||
mocks are why the suite can run `payment-failure` (which forces providers into
|
||||
`fail`/`timeout`) beside a spec that settles normally.
|
||||
|
||||
## Files
|
||||
|
||||
@@ -151,21 +207,34 @@ what it actually does and says so in a comment, so a fix fails loudly:
|
||||
ignores `deleted_at`, so a soft-unlinked booking can never be re-batched).
|
||||
- **Arrange is slow.** Contract → booking → clearance → ops accept → batch is
|
||||
30–60s of real API work per booking, so files share one schedule day.
|
||||
- Files run sequentially (one DB); concurrency is exercised inside a test with
|
||||
`Promise.all`.
|
||||
- **Files run in parallel across shards, sequentially within one.** A shard owns
|
||||
its whole topology, so two files never contend; two files on the *same* shard
|
||||
still run one after the other, which is what the day-offset partitioning and
|
||||
the per-file `releaseUnpaidHolds()` / `resetCorridorDay()` hygiene still
|
||||
assume. Concurrency inside a single test is exercised with `Promise.all`.
|
||||
- **Day offsets are the de-facto partition key** (`departureAt(n)` per file).
|
||||
`payment-happy` and `expired-invoice-late-settle` both claim day 4 — harmless
|
||||
now that a shard has its own database, but do not read the offsets as unique.
|
||||
- **A retired fixture keeps its shipment day at its peril.**
|
||||
`rescueStrandedPaidForDay` sweeps every unlinked booking whose
|
||||
`payment_status` is PAID and whose `scheduled_date` falls on the day being
|
||||
filled, and re-places it on the fresh train. A previous run's paid bookings
|
||||
therefore climb back aboard — 18 stowaway wagons on a 28-wagon day, until
|
||||
`releaseUnpaidHolds` / `resetCorridorDay` started nulling `scheduled_date`.
|
||||
filled, and re-places it on the fresh train. Within a run this still bites
|
||||
across files on one shard; *between* runs it no longer can — `it:test`
|
||||
re-clones every shard database from the template first.
|
||||
- **Only a FULL train rests at DONE.** An under-filled day CONCLUDES and
|
||||
REOPENS (`window_phase` back to OPEN, `booking_cycle_no` 2), so waiting for
|
||||
DONE there waits forever — use `pollCycleConcluded`.
|
||||
- **Wagon stock is finite and shared.** Paid bookings keep their wagons, so
|
||||
`releaseUnpaidHolds()` also frees every earlier `CTR-IT-%` allocation —
|
||||
without it the fifth or sixth file on a warm stack silently gets a short
|
||||
consist.
|
||||
- **Wagon stock is finite and shared** *within a shard*. Paid bookings keep
|
||||
their wagons, so `releaseUnpaidHolds()` also frees every earlier `CTR-IT-%`
|
||||
allocation — without it the fifth or sixth file on one shard silently gets a
|
||||
short consist.
|
||||
- **`poll()` samples every 250ms, but keeps the caller's deadline.** Callers pass
|
||||
`attempts × intervalMs`; that product is the timeout, and only the sampling
|
||||
rate changed. Override with `IT_POLL_INTERVAL_MS`.
|
||||
- **The tick cadence is env-driven.** `BOOKING_WINDOW_TICK_CRON` is `*/1` here
|
||||
and `*/10` in production. Every window phase, allocation and expiry the suite
|
||||
waits on lands on that tick. A spec that proves sensitive to it should pin its
|
||||
own value rather than the whole suite reverting.
|
||||
- **One tenant per booking.** `seedTenantContracts` mints a company per booking
|
||||
because a company may hold only one unpaid reservation at a time; staff book
|
||||
and pay on their behalf, which is also the real Path B flow.
|
||||
|
||||
182
integration/scripts/gen-shards.mjs
Normal file
182
integration/scripts/gen-shards.mjs
Normal file
@@ -0,0 +1,182 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Generate `integration/.it-shards.yaml` — one payment API + one gateway mock
|
||||
* per shard. Compose has no loops, so the per-shard services are written out.
|
||||
*
|
||||
* Every shard is the real topology in miniature; nothing crosses between them:
|
||||
*
|
||||
* gateway-mock-it-{i} its own `modes`/`calls`/`orders` — the mock keeps those
|
||||
* process-global, and 20 of 25 specs call
|
||||
* POST /__control/reset in beforeAll, so a shared mock
|
||||
* would have each starting spec wipe every running spec's
|
||||
* live orders (which fails as a wrong *assertion*: an
|
||||
* unknown order still gets a correctly-signed webhook,
|
||||
* just with the mock's default amount).
|
||||
* payment-api-it-{i} its own DB_NAME (the shard's database, `edr_payment`
|
||||
* schema inside it), its own broker vhost, and its own
|
||||
* FREIGHT_API_BASE_URL — the inbound CBE-bill query has
|
||||
* to land on THIS shard's in-process app, and a single
|
||||
* container could only ever point at one of them.
|
||||
*
|
||||
* Written by it.mjs, gitignored. Do not edit by hand.
|
||||
*/
|
||||
import { writeFileSync } from "node:fs";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const itDir = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
|
||||
/**
|
||||
* All shards share one build and one image tag, so the payment API image is
|
||||
* built once and reused rather than N times.
|
||||
*/
|
||||
const PAYMENT_IMAGE = "edr-payment-api-it:local";
|
||||
|
||||
export function renderShards({
|
||||
shards,
|
||||
apiPortBase,
|
||||
paymentPortBase,
|
||||
gatewayPortBase,
|
||||
dbPrefix,
|
||||
telebirrKey,
|
||||
}) {
|
||||
const services = [];
|
||||
|
||||
for (let i = 0; i < shards; i++) {
|
||||
const gw = `gateway-mock-it-${i}`;
|
||||
const pay = `payment-api-it-${i}`;
|
||||
const db = `${dbPrefix}${i}`;
|
||||
const vhost = `payment_s${i}`;
|
||||
// The freight app for this shard runs on the HOST, inside the vitest worker.
|
||||
const freightBase = `http://host.docker.internal:${apiPortBase + i}/api`;
|
||||
|
||||
services.push(`
|
||||
${gw}:
|
||||
image: node:20-alpine
|
||||
volumes:
|
||||
- ./integration/gateway-mock:/app:ro
|
||||
working_dir: /app
|
||||
environment:
|
||||
PORT: "4600"
|
||||
# Same secrets the payment API gets — so webhooks the mock signs pass the
|
||||
# API's REAL signature verification instead of bypassing it.
|
||||
CBE_SECRET_KEY: it-cbe-secret
|
||||
CBE_MERCHANT_ID: it-cbe-merchant
|
||||
PAYMENT_API_URL: http://${pay}:3003
|
||||
command: ["node", "server.js"]
|
||||
ports:
|
||||
- "${gatewayPortBase + i}:4600"
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD",
|
||||
"node",
|
||||
"-e",
|
||||
"fetch('http://localhost:4600/__control/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))",
|
||||
]
|
||||
interval: 3s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
|
||||
${pay}:
|
||||
image: ${PAYMENT_IMAGE}
|
||||
build:
|
||||
context: .
|
||||
dockerfile: apps/edr-payment-api/Dockerfile
|
||||
secrets:
|
||||
- npmrc
|
||||
depends_on:
|
||||
postgres-freight-e2e:
|
||||
condition: service_healthy
|
||||
rabbitmq-it:
|
||||
condition: service_healthy
|
||||
${gw}:
|
||||
condition: service_healthy
|
||||
# The freight app is on the host now, not in this network.
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
environment:
|
||||
PORT: "3003"
|
||||
NODE_ENV: test
|
||||
# Payment tables live in their own schema of THIS SHARD's database;
|
||||
# main.ts ensurePaymentSchema() creates it, migrationsRun does the rest.
|
||||
# Same database as freight, so every raw edr_payment.* query in the specs
|
||||
# reads through the one connection.
|
||||
DB_HOST: postgres-freight-e2e
|
||||
DB_PORT: "5432"
|
||||
DB_USER: edr_e2e
|
||||
DB_PASSWORD: edr_e2e
|
||||
DB_NAME: ${db}
|
||||
DB_SCHEMA: edr_payment
|
||||
SERVICE_AUTH_TOKEN: e2e-service-token
|
||||
PUBLISHER_TRANSPORT: rabbitmq
|
||||
PAYMENT_RABBITMQ_URL: amqp://edr:edr_secret@rabbitmq-it:5672/${vhost}
|
||||
PAYMENT_NOTIFY_FREIGHT_URL: ${freightBase}/internal/payments/mark-paid
|
||||
# Fast relay + sweep so retry/reconciliation are observable inside a test
|
||||
# rather than a minute later.
|
||||
OUTBOX_RELAY_INTERVAL_MS: "1000"
|
||||
RECONCILE_STALE_AFTER_MS: "5000"
|
||||
# Every gateway points at this shard's mock. Paths are per-provider prefixes.
|
||||
CBE_BASE_URL: http://${gw}:4600/cbe-birr
|
||||
CBE_MERCHANT_ID: it-cbe-merchant
|
||||
CBE_SECRET_KEY: it-cbe-secret
|
||||
CBE_NOTIFY_URL: http://${pay}:3003/webhooks/cbe-birr
|
||||
CBE_RETURN_URL: http://localhost/return
|
||||
TELEBIRR_BASE_URL: http://${gw}:4600/telebirr
|
||||
TELEBIRR_WEB_BASE_URL: http://${gw}:4600/telebirr/web
|
||||
TELEBIRR_FABRIC_APP_ID: it-fabric
|
||||
TELEBIRR_APP_SECRET: it-secret
|
||||
TELEBIRR_MERCHANT_APP_ID: it-merchant-app
|
||||
TELEBIRR_MERCHANT_CODE: "999999"
|
||||
TELEBIRR_NOTIFY_URL: http://${pay}:3003/webhooks/telebirr
|
||||
# Telebirr PSS-signs every request object — a throwaway key generated per
|
||||
# launch by it.mjs (nothing key-shaped lives in git).
|
||||
TELEBIRR_PRIVATE_KEY: ${JSON.stringify(telebirrKey)}
|
||||
EBIRR_BASE_URL: http://${gw}:4600/ebirr
|
||||
DMONEY_BASE_URL: http://${gw}:4600/dmoney
|
||||
CARD_BASE_URL: http://${gw}:4600/card
|
||||
WAAFI_BASE_URL: http://${gw}:4600/waafi
|
||||
CAC_BASE_URL: http://${gw}:4600/cac
|
||||
CAC_USERNAME: it-cac
|
||||
CAC_PASSWORD: it-cac
|
||||
CAC_APP_KEY: it-cac-key
|
||||
CAC_API_KEY: it-cac-api
|
||||
CAC_COMPANY_SERVICES_ID: "1"
|
||||
# Inbound CBE Unified Bill — we are the biller; bill-query hops back into
|
||||
# the freight API, so this direction runs real code on both sides.
|
||||
CBE_BILL_ENABLED: "true"
|
||||
CBE_BILL_CLIENT_ID: it-cbe-bill
|
||||
CBE_BILL_CLIENT_SECRET: it-cbe-bill-secret
|
||||
CBE_BILL_JWT_SECRET: it-cbe-bill-jwt
|
||||
FREIGHT_API_BASE_URL: ${freightBase}
|
||||
ports:
|
||||
- "${paymentPortBase + i}:3003"
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD",
|
||||
"node",
|
||||
"-e",
|
||||
"fetch('http://localhost:3003/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))",
|
||||
]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
start_period: 40s`);
|
||||
}
|
||||
|
||||
return `# GENERATED by integration/scripts/gen-shards.mjs — do not edit.
|
||||
# ${shards} shard(s). Overlaid on docker-compose.e2e.yaml + docker-compose.it.yaml.
|
||||
services:${services.join("\n")}
|
||||
`;
|
||||
}
|
||||
|
||||
export const SHARD_FILE = join(itDir, ".it-shards.yaml");
|
||||
|
||||
export function writeShards(opts) {
|
||||
writeFileSync(SHARD_FILE, renderShards(opts));
|
||||
return SHARD_FILE;
|
||||
}
|
||||
|
||||
export const shardServices = (shards) =>
|
||||
Array.from({ length: shards }, (_, i) => [`gateway-mock-it-${i}`, `payment-api-it-${i}`]).flat();
|
||||
@@ -3,10 +3,13 @@
|
||||
* Freight integration-suite launcher.
|
||||
*
|
||||
* node integration/scripts/it.mjs <up|test|down|logs> [vitest args...]
|
||||
* IT_SHARDS=4 node integration/scripts/it.mjs test
|
||||
* node integration/scripts/it.mjs test --no-reset -- src/payment-happy.it.ts
|
||||
*
|
||||
* Overlays docker-compose.it.yaml on docker-compose.e2e.yaml: same freight
|
||||
* stack, but the payment microservice is real and only the bank gateways are
|
||||
* stubbed. Web/Cypress containers are never started — this suite is HTTP only.
|
||||
* The freight API is NOT containerized here: it boots inside each vitest worker
|
||||
* from `apps/edr-freight-api/dist`, which `test` rebuilds every run. Each worker
|
||||
* is a whole shard — own database, own payment API, own gateway mock, own broker
|
||||
* vhost — so spec files run in parallel without sharing anything mutable.
|
||||
*
|
||||
* Ports are fixed (and distinct from the Cypress e2e defaults) so both stacks
|
||||
* can be up at once; they are separate compose projects.
|
||||
@@ -16,21 +19,24 @@
|
||||
|
||||
import { execFileSync, spawnSync } from "node:child_process";
|
||||
import { generateKeyPairSync } from "node:crypto";
|
||||
import { existsSync } from "node:fs";
|
||||
import { existsSync, rmSync } from "node:fs";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { SHARD_FILE, shardServices, writeShards } from "./gen-shards.mjs";
|
||||
|
||||
const itDir = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const repoRoot = resolve(itDir, "..");
|
||||
const composeBase = [
|
||||
"compose",
|
||||
"-f",
|
||||
join(repoRoot, "docker-compose.e2e.yaml"),
|
||||
"-f",
|
||||
join(repoRoot, "docker-compose.it.yaml"),
|
||||
];
|
||||
|
||||
/** Deliberately offset from the Cypress stack's defaults (3101/5533/9310…). */
|
||||
/**
|
||||
* Deliberately offset from the Cypress stack's defaults (3101/5533/9310…).
|
||||
*
|
||||
* Each per-shard service takes a CONTIGUOUS BLOCK of `IT_SHARDS` ports starting
|
||||
* at its base (freight :3111+i, payment :3131+i, gateway :4600+i), so the bases
|
||||
* must be at least MAX_SHARDS apart. They were originally 3111 and 3113 — two
|
||||
* apart — which silently worked at 1–2 shards and then had shard 2's freight app
|
||||
* try to bind :3113, the port payment-api-it-0 was already published on.
|
||||
*/
|
||||
const PORTS = {
|
||||
E2E_API_PORT: 3111,
|
||||
E2E_DB_PORT: 5543,
|
||||
@@ -39,32 +45,39 @@ const PORTS = {
|
||||
// Unused here (no web containers) but referenced by the base file's build args.
|
||||
E2E_PORTAL_PORT: 5393,
|
||||
E2E_BACKOFFICE_PORT: 5394,
|
||||
IT_PAYMENT_PORT: 3113,
|
||||
IT_PAYMENT_PORT: 3131,
|
||||
IT_GATEWAY_PORT: 4600,
|
||||
IT_RABBIT_PORT: 5772,
|
||||
IT_RABBIT_UI_PORT: 15772,
|
||||
};
|
||||
|
||||
/** Everything the suite needs up — web + cypress are deliberately absent. */
|
||||
const SERVICES = [
|
||||
"postgres-freight-e2e",
|
||||
"minio-e2e",
|
||||
"minio-init-e2e",
|
||||
"freight-migration-e2e",
|
||||
"fayda-mock-e2e",
|
||||
"etrade-mock-e2e",
|
||||
// Still a base-stack dependency of freight-api-e2e (reconcile-before-expire
|
||||
// has its own client); cheap to run alongside the real payment API.
|
||||
"payment-mock-e2e",
|
||||
"gateway-mock-it",
|
||||
"rabbitmq-it",
|
||||
"payment-api-it",
|
||||
"freight-api-e2e",
|
||||
];
|
||||
/** Beyond this the freight block (3111+) would run into the payment block (3131+). */
|
||||
const MAX_SHARDS = 16;
|
||||
|
||||
const RUNNING = SERVICES.filter(
|
||||
(s) => !["minio-init-e2e", "freight-migration-e2e"].includes(s),
|
||||
);
|
||||
/**
|
||||
* How many shards. RAM is the ceiling, not cores: each shard is an in-process
|
||||
* Nest app plus two containers.
|
||||
*/
|
||||
const SHARDS = Math.max(1, Number(process.env.IT_SHARDS ?? 4));
|
||||
if (SHARDS > MAX_SHARDS) {
|
||||
console.error(
|
||||
`\nit: IT_SHARDS=${SHARDS} exceeds ${MAX_SHARDS} — the per-service port blocks would overlap.` +
|
||||
`\n Raise IT_PAYMENT_PORT/IT_GATEWAY_PORT in it.mjs first.`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
const TEMPLATE_DB = "edr_freight_e2e";
|
||||
const DB_PREFIX = "edr_it_s";
|
||||
|
||||
/** Long-running services shared by every shard. Web + Cypress are absent. */
|
||||
const INFRA = ["postgres-freight-e2e", "minio-e2e", "rabbitmq-it"];
|
||||
|
||||
/**
|
||||
* One-shots: they run, then exit 0. They cannot go in the `up --wait` set —
|
||||
* compose reports an exited container as a failed wait, so a healthy stack
|
||||
* looks broken. Run them with `compose run`, which returns their exit code.
|
||||
*/
|
||||
const ONE_SHOT = ["minio-init-e2e", "freight-migration-e2e"];
|
||||
|
||||
function fail(msg) {
|
||||
console.error(`\nit: ${msg}`);
|
||||
@@ -97,64 +110,271 @@ function fakeFaydaPrivateKeyBase64() {
|
||||
return Buffer.from(JSON.stringify(jwk)).toString("base64");
|
||||
}
|
||||
|
||||
const telebirrKey = process.env.IT_TELEBIRR_PRIVATE_KEY ?? fakeTelebirrPrivateKey();
|
||||
|
||||
/**
|
||||
* The freight app's own environment. This is docker-compose.e2e.yaml's
|
||||
* `freight-api-e2e` block with container hostnames swapped for published host
|
||||
* ports — the app runs on the host now. Per-shard values (DB_NAME, PORT,
|
||||
* PAYMENT_API_URL, PAYMENT_RABBITMQ_URL) are derived per worker in src/app.ts.
|
||||
*/
|
||||
const freightEnv = {
|
||||
NODE_ENV: "test",
|
||||
DB_HOST: "localhost",
|
||||
DB_PORT: String(PORTS.E2E_DB_PORT),
|
||||
DB_USER: "edr_e2e",
|
||||
DB_PASSWORD: "edr_e2e",
|
||||
DB_NAME: TEMPLATE_DB,
|
||||
|
||||
// e2e-only secrets — never reuse outside this stack
|
||||
JWT_SECRET: "e2e-jwt-secret",
|
||||
JWT_ACCESS_TOKEN_SECRET: "e2e-access-secret",
|
||||
JWT_REFRESH_TOKEN_SECRET: "e2e-refresh-secret",
|
||||
JWT_EXPIRES_IN: "1d",
|
||||
JWT_ACCESS_TOKEN_EXPIRES: "1d",
|
||||
JWT_REFRESH_TOKEN_EXPIRES: "7d",
|
||||
// Mandatory: ServiceAuthGuard returns TRUE when this is unset, and
|
||||
// authz.it.ts asserts that the internal surface rejects an unsigned caller.
|
||||
SERVICE_AUTH_TOKEN: "e2e-service-token",
|
||||
|
||||
SEED_EDR_ORG: "true",
|
||||
SUPER_ADMIN_EMAIL: "superadmin@tria.com",
|
||||
SUPER_ADMIN_PHONE: "+251900000000",
|
||||
|
||||
MINIO_ENDPOINT: "localhost",
|
||||
MINIO_PORT: String(PORTS.E2E_MINIO_PORT),
|
||||
MINIO_USE_SSL: "false",
|
||||
MINIO_ACCESS_KEY: "e2e-minio",
|
||||
MINIO_SECRET_KEY: "e2e-minio-secret",
|
||||
MINIO_REGION: "us-east-1",
|
||||
|
||||
// Only gates the SMS/email clients. The payment consumer is wired by
|
||||
// PAYMENT_RABBITMQ_URL alone (payment.module.ts).
|
||||
RABBITMQ_ENABLED: "false",
|
||||
// fayda.config.ts THROWS at load if this is "true" without the full var set,
|
||||
// and the fayda/etrade mocks publish no host ports. No IT spec touches them.
|
||||
FAYDA_ENABLED: "false",
|
||||
// SMS strategy has no kill switch and defaults to a real dev endpoint.
|
||||
OZIKING_SMS_URL: "http://127.0.0.1:9/sms",
|
||||
// app.config.ts otherwise live-scrapes https://ethio.forex on first booking.
|
||||
CBE_EXCHANGE_SCRAPE_URL: "http://127.0.0.1:9/fx",
|
||||
CBE_EXCHANGE_API_URL: "http://127.0.0.1:9/fx",
|
||||
CBE_EXCHANGE_FALLBACK_RATE: "130",
|
||||
FREIGHT_PORTAL_URL: `http://localhost:${PORTS.E2E_PORTAL_PORT}`,
|
||||
|
||||
// Drain tail on every pay window. Production defaults to 5 minutes; a
|
||||
// reservation here lives ~60s, so 5 would push every natural expiry past the
|
||||
// suite's timeouts. One minute keeps the tail real and observable
|
||||
// (src/expired-invoice-late-settle.it.ts asserts both sides of it, and
|
||||
// hardcodes DRAIN_MS = 60_000 to match).
|
||||
FREIGHT_PAYMENT_DRAIN_MINUTES: "1",
|
||||
// The suite's pacing floor: every window phase, allocation and expiry it waits
|
||||
// on lands on this tick. Production stays at */10. Overridable from the
|
||||
// environment so a cadence-sensitive spec (or a bisect) can pin it back.
|
||||
BOOKING_WINDOW_TICK_CRON: process.env.BOOKING_WINDOW_TICK_CRON ?? "*/1 * * * * *",
|
||||
};
|
||||
|
||||
const env = {
|
||||
...process.env,
|
||||
...Object.fromEntries(Object.entries(PORTS).map(([k, v]) => [k, String(v)])),
|
||||
IT_API_URL: `http://localhost:${PORTS.E2E_API_PORT}`,
|
||||
IT_PAYMENT_URL: `http://localhost:${PORTS.IT_PAYMENT_PORT}`,
|
||||
IT_GATEWAY_URL: `http://localhost:${PORTS.IT_GATEWAY_PORT}`,
|
||||
IT_DB_URL: `postgres://edr_e2e:edr_e2e@localhost:${PORTS.E2E_DB_PORT}/edr_freight_e2e`,
|
||||
...freightEnv,
|
||||
IT_SHARDS: String(SHARDS),
|
||||
IT_TEMPLATE_DB: TEMPLATE_DB,
|
||||
IT_DB_PREFIX: DB_PREFIX,
|
||||
IT_API_PORT_BASE: String(PORTS.E2E_API_PORT),
|
||||
IT_PAYMENT_PORT_BASE: String(PORTS.IT_PAYMENT_PORT),
|
||||
IT_GATEWAY_PORT_BASE: String(PORTS.IT_GATEWAY_PORT),
|
||||
FAYDA_PRIVATE_KEY_BASE64:
|
||||
process.env.FAYDA_PRIVATE_KEY_BASE64 ?? fakeFaydaPrivateKeyBase64(),
|
||||
IT_TELEBIRR_PRIVATE_KEY:
|
||||
process.env.IT_TELEBIRR_PRIVATE_KEY ?? fakeTelebirrPrivateKey(),
|
||||
IT_TELEBIRR_PRIVATE_KEY: telebirrKey,
|
||||
};
|
||||
|
||||
function generateShardFile() {
|
||||
writeShards({
|
||||
shards: SHARDS,
|
||||
apiPortBase: PORTS.E2E_API_PORT,
|
||||
paymentPortBase: PORTS.IT_PAYMENT_PORT,
|
||||
gatewayPortBase: PORTS.IT_GATEWAY_PORT,
|
||||
dbPrefix: DB_PREFIX,
|
||||
telebirrKey,
|
||||
});
|
||||
}
|
||||
|
||||
/** The shard file must exist before compose is invoked — it is one of the -f's. */
|
||||
function composeBase() {
|
||||
if (!existsSync(SHARD_FILE)) generateShardFile();
|
||||
return [
|
||||
"compose",
|
||||
"-f",
|
||||
join(repoRoot, "docker-compose.e2e.yaml"),
|
||||
"-f",
|
||||
join(repoRoot, "docker-compose.it.yaml"),
|
||||
"-f",
|
||||
SHARD_FILE,
|
||||
];
|
||||
}
|
||||
|
||||
function compose(args) {
|
||||
const { status } = spawnSync("docker", [...composeBase, ...args], { stdio: "inherit", env });
|
||||
const { status } = spawnSync("docker", [...composeBase(), ...args], {
|
||||
stdio: "inherit",
|
||||
env,
|
||||
});
|
||||
return status ?? 1;
|
||||
}
|
||||
|
||||
function composeQuiet(args) {
|
||||
return spawnSync("docker", [...composeBase(), ...args], {
|
||||
encoding: "utf8",
|
||||
env,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
}
|
||||
|
||||
function stackRunning() {
|
||||
try {
|
||||
const out = execFileSync("docker", [...composeBase, "ps", "--services", "--status", "running"], {
|
||||
encoding: "utf8",
|
||||
env,
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
});
|
||||
const out = execFileSync(
|
||||
"docker",
|
||||
[...composeBase(), "ps", "--services", "--status", "running"],
|
||||
{ encoding: "utf8", env, stdio: ["ignore", "pipe", "ignore"] },
|
||||
);
|
||||
const running = new Set(out.split("\n").filter(Boolean));
|
||||
return RUNNING.every((s) => running.has(s));
|
||||
const want = [...INFRA, ...shardServices(SHARDS)];
|
||||
return want.every((s) => running.has(s));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function node(script, args = []) {
|
||||
const { status } = spawnSync("node", [join(itDir, "scripts", script), ...args], {
|
||||
cwd: repoRoot,
|
||||
stdio: "inherit",
|
||||
env,
|
||||
});
|
||||
return status ?? 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* One broker, one vhost per shard — a shared vhost would let one shard's freight
|
||||
* consumer eat another shard's settlement event.
|
||||
*
|
||||
* Retried: the healthcheck now waits for the rabbit *application*, but a cold
|
||||
* boot can still land between "healthy" and "accepting rabbitmqctl", and a
|
||||
* half-provisioned broker fails later as an unexplained missing settlement.
|
||||
*/
|
||||
function createVhosts() {
|
||||
for (let i = 0; i < SHARDS; i++) {
|
||||
const vhost = `payment_s${i}`;
|
||||
let last = "";
|
||||
let ok = false;
|
||||
for (let attempt = 1; attempt <= 10 && !ok; attempt++) {
|
||||
// Idempotent: add_vhost on an existing vhost exits non-zero, which is fine.
|
||||
composeQuiet(["exec", "-T", "rabbitmq-it", "rabbitmqctl", "add_vhost", vhost]);
|
||||
const perm = composeQuiet([
|
||||
"exec", "-T", "rabbitmq-it",
|
||||
"rabbitmqctl", "set_permissions", "-p", vhost, "edr", ".*", ".*", ".*",
|
||||
]);
|
||||
ok = perm.status === 0;
|
||||
last = perm.stderr ?? "";
|
||||
if (!ok) execFileSync("sleep", ["3"]);
|
||||
}
|
||||
if (!ok) fail(`could not grant on rabbit vhost ${vhost} after 10 tries:\n${last}`);
|
||||
}
|
||||
console.log(`it: rabbit vhosts payment_s0…payment_s${SHARDS - 1} ready`);
|
||||
}
|
||||
|
||||
function buildFreight() {
|
||||
console.log("it: building @edr/freight-api (the suite runs dist/, never stale)");
|
||||
const { status } = spawnSync("pnpm", ["--filter", "@edr/freight-api", "run", "build"], {
|
||||
cwd: repoRoot,
|
||||
stdio: "inherit",
|
||||
env,
|
||||
});
|
||||
if (status !== 0) fail("freight API build failed — fix it before running the suite.");
|
||||
}
|
||||
|
||||
function up() {
|
||||
preflight();
|
||||
generateShardFile();
|
||||
console.log(
|
||||
`it: starting stack — freight :${PORTS.E2E_API_PORT} payment :${PORTS.IT_PAYMENT_PORT} ` +
|
||||
`gateway :${PORTS.IT_GATEWAY_PORT} db :${PORTS.E2E_DB_PORT}`,
|
||||
`it: starting ${SHARDS} shard(s) — freight :${PORTS.E2E_API_PORT}..${
|
||||
PORTS.E2E_API_PORT + SHARDS - 1
|
||||
} (in-process) payment :${PORTS.IT_PAYMENT_PORT}.. gateway :${
|
||||
PORTS.IT_GATEWAY_PORT
|
||||
}.. db :${PORTS.E2E_DB_PORT}`,
|
||||
);
|
||||
if (compose(["up", "-d", "--build", "--wait", ...SERVICES]) !== 0) {
|
||||
|
||||
// 1. Shared infrastructure.
|
||||
if (compose(["up", "-d", "--build", "--wait", "--remove-orphans", ...INFRA]) !== 0) {
|
||||
fail(
|
||||
"stack failed to become healthy. Inspect with:\n" +
|
||||
" node integration/scripts/it.mjs logs payment-api-it",
|
||||
"shared services failed to become healthy. Inspect with:\n" +
|
||||
" node integration/scripts/it.mjs logs postgres-freight-e2e",
|
||||
);
|
||||
}
|
||||
createVhosts();
|
||||
|
||||
// 1b. One-shots: the MinIO bucket, then freight's migrations into the
|
||||
// TEMPLATE database every shard is cloned from.
|
||||
for (const svc of ONE_SHOT) {
|
||||
if (compose(["run", "--rm", "--no-deps", "--build", svc]) !== 0) {
|
||||
fail(`${svc} failed — inspect with:\n node integration/scripts/it.mjs logs ${svc}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. The app must exist as dist/ before we can boot it to seed the template.
|
||||
buildFreight();
|
||||
|
||||
// 3. Seed the template (boot seeders + SQL fixtures) and clone it per shard.
|
||||
// Must precede the payment APIs: they connect to the shard databases.
|
||||
if (node("prepare-shards.mjs") !== 0) fail("template seed / shard clone failed.");
|
||||
|
||||
// 4. Per-shard payment API + gateway mock.
|
||||
if (compose(["up", "-d", "--build", "--wait", ...shardServices(SHARDS)]) !== 0) {
|
||||
fail(
|
||||
"shard services failed to become healthy. Inspect with:\n" +
|
||||
" node integration/scripts/it.mjs logs payment-api-it-0",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const [cmd, ...rawExtra] = process.argv.slice(2);
|
||||
/**
|
||||
* Re-clone every shard database from the template so each run is hermetic. The
|
||||
* payment APIs hold connections to those databases, so they come down first —
|
||||
* their `edr_payment` schema is recreated by their own boot migrations.
|
||||
*
|
||||
* This is what retires the warm-stack failure modes the suite used to document:
|
||||
* stowaway paid bookings climbing back aboard, a short consist on the fifth
|
||||
* file, invoice numbers continuing from a previous run.
|
||||
*/
|
||||
function resetShardDbs() {
|
||||
const svc = shardServices(SHARDS);
|
||||
console.log("it: re-cloning shard databases from the template");
|
||||
compose(["stop", ...svc]);
|
||||
if (node("prepare-shards.mjs", ["--clone"]) !== 0) fail("shard database reset failed.");
|
||||
if (compose(["up", "-d", "--wait", ...svc]) !== 0) {
|
||||
fail("shard services failed to become healthy after the reset.");
|
||||
}
|
||||
}
|
||||
|
||||
const [cmd, ...rawArgs] = process.argv.slice(2);
|
||||
const noReset = rawArgs.includes("--no-reset");
|
||||
// `pnpm it:test -- src/foo.it.ts` hands us a literal "--" first. Forwarding it
|
||||
// makes vitest treat everything after it as CLI options and ignore the file
|
||||
// filter — the "one file" run silently becomes the whole suite.
|
||||
const extra = rawExtra[0] === "--" ? rawExtra.slice(1) : rawExtra;
|
||||
const passthrough = rawArgs.filter((a) => a !== "--no-reset");
|
||||
const extra = passthrough[0] === "--" ? passthrough.slice(1) : passthrough;
|
||||
|
||||
switch (cmd) {
|
||||
case "up":
|
||||
up();
|
||||
break;
|
||||
case "test": {
|
||||
if (!stackRunning()) up();
|
||||
if (!stackRunning()) {
|
||||
up();
|
||||
} else {
|
||||
buildFreight();
|
||||
if (!noReset) resetShardDbs();
|
||||
}
|
||||
const { status } = spawnSync(
|
||||
"pnpm",
|
||||
["--filter", "@edr/freight-integration", "run", "test", ...extra],
|
||||
@@ -162,12 +382,14 @@ switch (cmd) {
|
||||
);
|
||||
process.exit(status ?? 1);
|
||||
}
|
||||
case "logs":
|
||||
case "logs": {
|
||||
process.exit(compose(["logs", "--tail", "200", ...extra]));
|
||||
break;
|
||||
case "down":
|
||||
process.exit(compose(["down", "-v", "--remove-orphans"]));
|
||||
break;
|
||||
}
|
||||
case "down": {
|
||||
const status = compose(["down", "-v", "--remove-orphans"]);
|
||||
rmSync(SHARD_FILE, { force: true });
|
||||
process.exit(status);
|
||||
}
|
||||
default:
|
||||
fail(`unknown command "${cmd ?? ""}" — use up | test | logs | down`);
|
||||
}
|
||||
|
||||
134
integration/scripts/prepare-shards.mjs
Normal file
134
integration/scripts/prepare-shards.mjs
Normal file
@@ -0,0 +1,134 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Build the seeded template database, then clone it once per shard.
|
||||
*
|
||||
* node integration/scripts/prepare-shards.mjs # seed template + clone
|
||||
* node integration/scripts/prepare-shards.mjs --clone # clone only (per-run reset)
|
||||
*
|
||||
* Seeding has to happen here, not in vitest's globalSetup, because the order is
|
||||
* load-bearing and half of it is the app itself:
|
||||
*
|
||||
* 1. freight migrations (freight-migration-e2e container, before us)
|
||||
* 2. the app's always-on boot seeders — org, units, positions, permissions
|
||||
* (app.module.ts onApplicationBootstrap)
|
||||
* 3. the SQL fixtures, which declare those as prerequisites
|
||||
* (seed-users.sql: "created by the API's always-on boot seeders")
|
||||
*
|
||||
* Step 2 needs a real Nest boot, and the app now lives inside the vitest workers
|
||||
* — which globalSetup runs in a different process from. So we boot it once here
|
||||
* against the template and every shard is a `CREATE DATABASE … TEMPLATE` copy of
|
||||
* the result: a file copy on the tmpfs postgres, versus re-running migrations
|
||||
* and five seeders per shard.
|
||||
*
|
||||
* Run by it.mjs, which owns the env. No dependencies beyond `pg`.
|
||||
*/
|
||||
import { createRequire } from "node:module";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { Client } from "pg";
|
||||
|
||||
const itDir = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const repoRoot = resolve(itDir, "..");
|
||||
const freightDir = join(repoRoot, "apps", "edr-freight-api");
|
||||
|
||||
const TEMPLATE = process.env.IT_TEMPLATE_DB ?? "edr_freight_e2e";
|
||||
const PREFIX = process.env.IT_DB_PREFIX ?? "edr_it_s";
|
||||
const SHARDS = Number(process.env.IT_SHARDS ?? 1);
|
||||
|
||||
const admin = () =>
|
||||
new Client({
|
||||
host: process.env.DB_HOST ?? "localhost",
|
||||
port: Number(process.env.DB_PORT ?? 5543),
|
||||
user: process.env.DB_USER ?? "edr_e2e",
|
||||
password: process.env.DB_PASSWORD ?? "edr_e2e",
|
||||
// Never the template itself: CREATE DATABASE … TEMPLATE refuses while any
|
||||
// session is connected to the source.
|
||||
database: "postgres",
|
||||
});
|
||||
|
||||
const CYPRESS_FIXTURES = join(repoRoot, "e2e", "freight", "cypress", "fixtures");
|
||||
const OWN_FIXTURES = join(itDir, "sql");
|
||||
|
||||
/** Order matters — company needs the users, everything needs the corridor. */
|
||||
const SEEDS = [
|
||||
[CYPRESS_FIXTURES, "seed-users.sql"],
|
||||
[CYPRESS_FIXTURES, "seed-company.sql"],
|
||||
[CYPRESS_FIXTURES, "seed-import-corridor.sql"],
|
||||
[CYPRESS_FIXTURES, "seed-bulk-items.sql"],
|
||||
[CYPRESS_FIXTURES, "seed-g1-train.sql"],
|
||||
[CYPRESS_FIXTURES, "seed-g2-weight.sql"],
|
||||
[CYPRESS_FIXTURES, "seed-government.sql"],
|
||||
[OWN_FIXTURES, "seed-company-b.sql"],
|
||||
[OWN_FIXTURES, "seed-customs-service-type.sql"],
|
||||
];
|
||||
|
||||
async function seedTemplate() {
|
||||
console.log(`it: seeding template ${TEMPLATE}`);
|
||||
|
||||
process.env.DB_NAME = TEMPLATE;
|
||||
// No broker for the template boot: payment.module.ts skips RabbitMQModule
|
||||
// entirely when this is unset, and nothing here publishes.
|
||||
delete process.env.PAYMENT_RABBITMQ_URL;
|
||||
|
||||
const { createFreightApp } = createRequire(join(freightDir, "package.json"))("./dist/main.js");
|
||||
|
||||
// createFreightApp() only builds the graph. `init()` is what fires
|
||||
// onApplicationBootstrap — the seeders we are here for. We never listen.
|
||||
const app = await createFreightApp();
|
||||
try {
|
||||
await app.init();
|
||||
console.log("it: boot seeders done (org, units, positions, permissions)");
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
|
||||
const client = new Client({
|
||||
host: process.env.DB_HOST ?? "localhost",
|
||||
port: Number(process.env.DB_PORT ?? 5543),
|
||||
user: process.env.DB_USER ?? "edr_e2e",
|
||||
password: process.env.DB_PASSWORD ?? "edr_e2e",
|
||||
database: TEMPLATE,
|
||||
});
|
||||
await client.connect();
|
||||
try {
|
||||
for (const [dir, file] of SEEDS) {
|
||||
await client.query(readFileSync(join(dir, file), "utf8"));
|
||||
console.log(`it: seeded ${file}`);
|
||||
}
|
||||
} finally {
|
||||
await client.end();
|
||||
}
|
||||
}
|
||||
|
||||
async function cloneShards() {
|
||||
const pg = admin();
|
||||
await pg.connect();
|
||||
try {
|
||||
// The app's pool and any stray psql hold the template open; without this the
|
||||
// CREATE below fails with "source database is being accessed by other users".
|
||||
await pg.query(
|
||||
`SELECT pg_terminate_backend(pid) FROM pg_stat_activity
|
||||
WHERE datname = $1 AND pid <> pg_backend_pid()`,
|
||||
[TEMPLATE],
|
||||
);
|
||||
|
||||
for (let i = 0; i < SHARDS; i++) {
|
||||
const db = `${PREFIX}${i}`;
|
||||
await pg.query(
|
||||
`SELECT pg_terminate_backend(pid) FROM pg_stat_activity
|
||||
WHERE datname = $1 AND pid <> pg_backend_pid()`,
|
||||
[db],
|
||||
);
|
||||
await pg.query(`DROP DATABASE IF EXISTS ${db}`);
|
||||
await pg.query(`CREATE DATABASE ${db} TEMPLATE ${TEMPLATE}`);
|
||||
console.log(`it: shard ${i} → ${db}`);
|
||||
}
|
||||
} finally {
|
||||
await pg.end();
|
||||
}
|
||||
}
|
||||
|
||||
const cloneOnly = process.argv.includes("--clone");
|
||||
if (!cloneOnly) await seedTemplate();
|
||||
await cloneShards();
|
||||
133
integration/src/app.ts
Normal file
133
integration/src/app.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* The freight API, booted IN THIS PROCESS — one instance per vitest worker.
|
||||
*
|
||||
* Each worker is a self-contained shard of the whole topology, so spec files can
|
||||
* run in parallel without sharing anything mutable:
|
||||
*
|
||||
* shard i = this in-process freight app on :3111+i
|
||||
* + database edr_it_s{i} (cloned from the seeded template)
|
||||
* + payment-api-it-{i} container on :3113+i, DB_NAME=edr_it_s{i}
|
||||
* + gateway-mock-it-{i} container on :4600+i
|
||||
* + RabbitMQ vhost payment_s{i}
|
||||
*
|
||||
* The payment schema lives INSIDE the shard's own database under the usual
|
||||
* `edr_payment` name, so every raw `edr_payment.*` query in the specs works
|
||||
* unchanged and `paymentDb()` stays an alias for `db()`.
|
||||
*
|
||||
* Why the app is loaded from `dist/` and not from TypeScript: the freight source
|
||||
* is CJS-flavoured — `src/config/database.config.ts` uses `__dirname`,
|
||||
* `require.resolve` and an entity glob, none of which survive vitest's ESM
|
||||
* transform. `createRequire` anchored at the freight app's own package.json
|
||||
* keeps all Nest / typeorm / @tria-plc resolution inside its node_modules, and
|
||||
* `dist` is rebuilt by `it.mjs test` on every run so it can never go stale.
|
||||
*/
|
||||
import { createRequire } from "node:module";
|
||||
import { existsSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { Server } from "node:http";
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const freightDir = join(here, "..", "..", "apps", "edr-freight-api");
|
||||
const distMain = join(freightDir, "dist", "main.js");
|
||||
|
||||
/**
|
||||
* `VITEST_POOL_ID` is 1-based and stable per worker for the life of the run.
|
||||
* Absent outside a worker (global-setup, scripts) — that context gets shard 0.
|
||||
*/
|
||||
export const SHARD = Math.max(0, Number(process.env.VITEST_POOL_ID ?? "1") - 1);
|
||||
|
||||
/**
|
||||
* Each service takes a contiguous block of `IT_SHARDS` ports from its base, so
|
||||
* the bases must stay far apart — see the PORTS comment in scripts/it.mjs.
|
||||
*/
|
||||
const port = (base: string, fallback: number) =>
|
||||
Number(process.env[base] ?? fallback) + SHARD;
|
||||
|
||||
export const API_PORT = port("IT_API_PORT_BASE", 3111);
|
||||
export const PAYMENT_API = `http://localhost:${port("IT_PAYMENT_PORT_BASE", 3131)}`;
|
||||
export const GATEWAY = `http://localhost:${port("IT_GATEWAY_PORT_BASE", 4600)}`;
|
||||
|
||||
export const SHARD_DB = `${process.env.IT_DB_PREFIX ?? "edr_it_s"}${SHARD}`;
|
||||
|
||||
export const DB_URL =
|
||||
`postgres://${process.env.DB_USER ?? "edr_e2e"}:${process.env.DB_PASSWORD ?? "edr_e2e"}` +
|
||||
`@${process.env.DB_HOST ?? "localhost"}:${process.env.DB_PORT ?? "5543"}/${SHARD_DB}`;
|
||||
|
||||
const RABBIT_URL =
|
||||
`amqp://edr:edr_secret@localhost:${process.env.IT_RABBIT_PORT ?? "5772"}` +
|
||||
`/payment_s${SHARD}`;
|
||||
|
||||
/** Prefix every diagnostic — parallel worker output interleaves. */
|
||||
export const tag = (msg: string) => `[shard ${SHARD}] ${msg}`;
|
||||
|
||||
interface FreightMain {
|
||||
createFreightApp: () => Promise<{
|
||||
listen: (port: number) => Promise<unknown>;
|
||||
getHttpServer: () => Server;
|
||||
close: () => Promise<void>;
|
||||
}>;
|
||||
}
|
||||
|
||||
let booted: Promise<Server> | undefined;
|
||||
|
||||
/**
|
||||
* The shard's HTTP server, booted once per worker. supertest takes this
|
||||
* directly in place of a base URL, so every call site in client.ts is a
|
||||
* one-token change.
|
||||
*/
|
||||
export function freightServer(): Promise<Server> {
|
||||
return (booted ??= boot());
|
||||
}
|
||||
|
||||
async function boot(): Promise<Server> {
|
||||
if (!existsSync(distMain)) {
|
||||
throw new Error(
|
||||
tag(
|
||||
`${distMain} is missing — build the API first ` +
|
||||
`(\`pnpm --filter @edr/freight-api run build\`, which \`it.mjs test\` does for you).`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Must be set BEFORE the require: payment.module.ts reads
|
||||
// PAYMENT_RABBITMQ_URL at module-definition time to decide whether
|
||||
// RabbitMQModule is in the graph at all, and database.config.ts reads DB_NAME
|
||||
// when ConfigModule loads it.
|
||||
process.env.DB_NAME = SHARD_DB;
|
||||
process.env.PORT = String(API_PORT);
|
||||
process.env.PAYMENT_API_URL = PAYMENT_API;
|
||||
process.env.PAYMENT_RABBITMQ_URL = RABBIT_URL;
|
||||
|
||||
const { createFreightApp } = createRequire(join(freightDir, "package.json"))(
|
||||
"./dist/main.js",
|
||||
) as FreightMain;
|
||||
|
||||
// Postgres and the broker are up before vitest starts, but a shard's payment
|
||||
// API may still be finishing its own migrations when the first worker boots.
|
||||
// Retry the whole app rather than a connection: a half-initialised Nest app
|
||||
// cannot be resumed, only closed and rebuilt.
|
||||
let lastErr: unknown;
|
||||
for (let attempt = 1; attempt <= 3; attempt++) {
|
||||
const app = await createFreightApp().catch((err) => {
|
||||
lastErr = err;
|
||||
return undefined;
|
||||
});
|
||||
if (app) {
|
||||
try {
|
||||
// A real port, not an ephemeral one: payment-api-it-{SHARD} calls back
|
||||
// in here for the inbound CBE Unified Bill query (cbe-bill.it.ts).
|
||||
await app.listen(API_PORT);
|
||||
console.log(tag(`freight-api in-process on :${API_PORT} → ${SHARD_DB}`));
|
||||
return app.getHttpServer();
|
||||
} catch (err) {
|
||||
lastErr = err;
|
||||
await app.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
if (attempt < 3) await new Promise((r) => setTimeout(r, 2000 * attempt));
|
||||
}
|
||||
|
||||
console.error(tag(`freight-api failed to boot on :${API_PORT} (${SHARD_DB})`), lastErr);
|
||||
throw lastErr;
|
||||
}
|
||||
@@ -3,44 +3,75 @@
|
||||
* failures here are the expensive kind: a tenant reading another tenant's
|
||||
* invoice, or an unauthenticated caller marking one paid.
|
||||
*/
|
||||
import { afterAll, describe, expect, it } from "vitest";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import request from "supertest";
|
||||
import {
|
||||
API,
|
||||
PAYMENT_API,
|
||||
api,
|
||||
closeDb,
|
||||
customerA,
|
||||
customerB,
|
||||
db,
|
||||
freightServer,
|
||||
login,
|
||||
payment,
|
||||
} from "./client";
|
||||
|
||||
/** Company A, from e2e/freight/cypress/fixtures/seed-company.sql. */
|
||||
const TENANT_A_TIN = "0102030405";
|
||||
|
||||
/**
|
||||
* This file used to read "the newest invoice of company A" and `return` early
|
||||
* when there wasn't one — which passed silently on a database no payment spec
|
||||
* had run against yet. Now that every shard starts from a pristine clone, that
|
||||
* would be *every* run. It bills itself instead.
|
||||
*
|
||||
* Straight SQL, not the booking chain: the point of this file is that it is
|
||||
* cheap, and a cross-tenant read is refused on ownership alone — nothing here
|
||||
* cares how the invoice came to exist.
|
||||
*/
|
||||
async function seedTenantAInvoice(): Promise<string> {
|
||||
const rows = await db<{ id: string }>(
|
||||
`INSERT INTO freight.invoices (
|
||||
id, invoice_number, company_id, company_profile_id,
|
||||
subtotal_amount, tax_amount, total_amount, paid_amount, balance_amount,
|
||||
currency, status, source, source_id, type, issued_at, due_at, payments
|
||||
)
|
||||
SELECT gen_random_uuid(), $1, c.id, p.id,
|
||||
1000, 0, 1000, 0, 1000,
|
||||
'ETB', 'ISSUED', 'booking', gen_random_uuid()::text, 'PREPAID',
|
||||
now(), now() + interval '7 days', '[]'::jsonb
|
||||
FROM freight.companies c
|
||||
JOIN freight.company_profiles p ON p.company_id = c.id AND p.deleted_at IS NULL
|
||||
WHERE c.tin = $2 AND c.deleted_at IS NULL
|
||||
LIMIT 1
|
||||
RETURNING id`,
|
||||
[`INV-AUTHZ-${Date.now()}`, TENANT_A_TIN],
|
||||
);
|
||||
const id = rows[0]?.id;
|
||||
if (!id) {
|
||||
throw new Error(
|
||||
`authz: could not bill company ${TENANT_A_TIN} — seed-company.sql missing from this shard?`,
|
||||
);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
describe("payment authorization boundaries", () => {
|
||||
let invoiceId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
invoiceId = await seedTenantAInvoice();
|
||||
});
|
||||
afterAll(closeDb);
|
||||
|
||||
it("hides one tenant's invoice from the other", async () => {
|
||||
const rows = await db<{ id: string; company_id: string }>(
|
||||
`SELECT i.id, i.company_id FROM freight.invoices i
|
||||
JOIN freight.companies c ON c.id = i.company_id
|
||||
WHERE c.tin = '0102030405' AND i.deleted_at IS NULL
|
||||
ORDER BY i.created_at DESC LIMIT 1`,
|
||||
);
|
||||
if (!rows[0]) return; // nothing billed yet in this run — payment files cover it
|
||||
const res = await api(customerB, "get", `/api/billing/my-invoices/${rows[0].id}`);
|
||||
const res = await api(customerB, "get", `/api/billing/my-invoices/${invoiceId}`);
|
||||
expect([403, 404]).toContain(res.status);
|
||||
});
|
||||
|
||||
it("refuses to let one tenant pay the other's invoice", async () => {
|
||||
const rows = await db<{ id: string }>(
|
||||
`SELECT i.id FROM freight.invoices i
|
||||
JOIN freight.companies c ON c.id = i.company_id
|
||||
WHERE c.tin = '0102030405' AND i.status <> 'PAID' AND i.deleted_at IS NULL
|
||||
ORDER BY i.created_at DESC LIMIT 1`,
|
||||
);
|
||||
if (!rows[0]) return;
|
||||
const res = await api(customerB, "post", `/api/billing/my-invoices/${rows[0].id}/pay`, {
|
||||
const res = await api(customerB, "post", `/api/billing/my-invoices/${invoiceId}/pay`, {
|
||||
method: "CBE_BIRR",
|
||||
platform: "web",
|
||||
});
|
||||
@@ -71,7 +102,9 @@ describe("payment authorization boundaries", () => {
|
||||
amountMinor: 1,
|
||||
currency: "ETB",
|
||||
};
|
||||
const res = await request(API).post("/api/internal/payments/mark-paid").send(body);
|
||||
const res = await request(await freightServer())
|
||||
.post("/api/internal/payments/mark-paid")
|
||||
.send(body);
|
||||
expect([401, 403]).toContain(res.status);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
/**
|
||||
* Plumbing for the integration suite: HTTP against the containerized freight
|
||||
* and payment APIs, SQL against their shared throwaway Postgres, and the
|
||||
* gateway mock's control plane.
|
||||
* Plumbing for the integration suite: HTTP against this worker's IN-PROCESS
|
||||
* freight API and its containerized payment API, SQL against the worker's own
|
||||
* throwaway database, and its own gateway mock's control plane.
|
||||
*
|
||||
* Everything here is shard-scoped — see app.ts for the topology. supertest takes
|
||||
* an `http.Server` exactly where it takes a base URL, so pointing the suite at
|
||||
* the in-process app is a one-token change at each call site.
|
||||
*
|
||||
* This is the Cypress-free port of e2e/freight/cypress/e2e/flows/import-utils.ts —
|
||||
* same request sequences, same SQL, `pg.Pool` instead of `cy.task`.
|
||||
@@ -9,13 +13,11 @@
|
||||
import request from "supertest";
|
||||
import { Pool, type QueryResultRow } from "pg";
|
||||
|
||||
export const API = process.env.IT_API_URL ?? "http://localhost:3111";
|
||||
export const PAYMENT_API = process.env.IT_PAYMENT_URL ?? "http://localhost:3113";
|
||||
export const GATEWAY = process.env.IT_GATEWAY_URL ?? "http://localhost:4600";
|
||||
export const SERVICE_TOKEN = process.env.IT_SERVICE_TOKEN ?? "e2e-service-token";
|
||||
import { DB_URL, GATEWAY, PAYMENT_API, SHARD, freightServer, tag } from "./app";
|
||||
|
||||
const DB_URL =
|
||||
process.env.IT_DB_URL ?? "postgres://edr_e2e:edr_e2e@localhost:5543/edr_freight_e2e";
|
||||
export { GATEWAY, PAYMENT_API, SHARD, freightServer };
|
||||
|
||||
export const SERVICE_TOKEN = process.env.IT_SERVICE_TOKEN ?? "e2e-service-token";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// users (e2e/freight/cypress/fixtures/seed-users.sql + users.json)
|
||||
@@ -45,11 +47,23 @@ export async function db<T extends QueryResultRow = Record<string, unknown>>(
|
||||
return res.rows;
|
||||
}
|
||||
|
||||
export async function closeDb(): Promise<void> {
|
||||
await pool.end();
|
||||
}
|
||||
/**
|
||||
* No-op. The pool is per-WORKER, not per-file: with `isolate: false` the module
|
||||
* registry survives across every spec a worker runs, so the first `afterAll` to
|
||||
* call this would break every later file on that shard. The pool dies with the
|
||||
* worker process.
|
||||
*/
|
||||
export async function closeDb(): Promise<void> {}
|
||||
|
||||
/**
|
||||
* Poll a query until `check` passes. Async settlement here is broker-driven and
|
||||
* tick-driven, so the interval is pure overshoot: at the old 2000ms every wait
|
||||
* in the suite finished up to two seconds after the state it wanted was already
|
||||
* in the database, several hundred times over. The ceiling
|
||||
* (`attempts × intervalMs`) is unchanged.
|
||||
*/
|
||||
const POLL_INTERVAL_MS = Number(process.env.IT_POLL_INTERVAL_MS ?? 250);
|
||||
|
||||
/** Poll a query until `check` passes. Async settlement here is broker-driven. */
|
||||
export async function poll<T extends QueryResultRow = Record<string, unknown>>(
|
||||
label: string,
|
||||
sql: string,
|
||||
@@ -57,14 +71,24 @@ export async function poll<T extends QueryResultRow = Record<string, unknown>>(
|
||||
check: (row: T | undefined) => boolean,
|
||||
{ attempts = 40, intervalMs = 2000 } = {},
|
||||
): Promise<T> {
|
||||
// Callers express patience as attempts × their own interval; keep that
|
||||
// deadline and just sample it finely.
|
||||
const deadlineMs = attempts * intervalMs;
|
||||
const tries = Math.ceil(deadlineMs / POLL_INTERVAL_MS);
|
||||
|
||||
let last: T | undefined;
|
||||
for (let i = 0; i < attempts; i++) {
|
||||
for (let i = 0; i < tries; i++) {
|
||||
last = (await db<T>(sql, params))[0];
|
||||
if (check(last)) return last as T;
|
||||
await sleep(intervalMs);
|
||||
await sleep(POLL_INTERVAL_MS);
|
||||
}
|
||||
throw new Error(
|
||||
`timed out waiting for ${label} after ${attempts} attempts — last row: ${JSON.stringify(last)}`,
|
||||
tag(
|
||||
`timed out waiting for ${label} after ${Math.round(deadlineMs / 1000)}s\n` +
|
||||
` sql: ${sql.replace(/\s+/g, " ").trim()}\n` +
|
||||
` params: ${JSON.stringify(params)}\n` +
|
||||
` last row: ${JSON.stringify(last)}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -86,7 +110,7 @@ export async function tokenFor(email: string): Promise<string> {
|
||||
if (cached) return cached;
|
||||
|
||||
const portal = email.endsWith("@gmail.com");
|
||||
const res = await request(API)
|
||||
const res = await request(await freightServer())
|
||||
.post("/api/auth/login")
|
||||
.set("x-client-app", portal ? "portal" : "backoffice")
|
||||
.send({ email, password: portal ? CUSTOMER_PASSWORD : STAFF_PASSWORD });
|
||||
@@ -101,8 +125,11 @@ export async function tokenFor(email: string): Promise<string> {
|
||||
}
|
||||
|
||||
/** Login without caching, so audience-rejection can be asserted. */
|
||||
export function login(email: string, password: string, app: "portal" | "backoffice") {
|
||||
return request(API).post("/api/auth/login").set("x-client-app", app).send({ email, password });
|
||||
export async function login(email: string, password: string, app: "portal" | "backoffice") {
|
||||
return request(await freightServer())
|
||||
.post("/api/auth/login")
|
||||
.set("x-client-app", app)
|
||||
.send({ email, password });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -119,7 +146,9 @@ export async function api(
|
||||
body?: unknown,
|
||||
): Promise<request.Response> {
|
||||
const token = await tokenFor(email);
|
||||
const req = request(API)[method](path).set("Authorization", `Bearer ${token}`);
|
||||
const req = request(await freightServer())
|
||||
[method](path)
|
||||
.set("Authorization", `Bearer ${token}`);
|
||||
return method === "get" || method === "delete" ? req.send() : req.send(body ?? {});
|
||||
}
|
||||
|
||||
@@ -148,7 +177,9 @@ export async function upload(
|
||||
fields: Record<string, string> = {},
|
||||
): Promise<request.Response> {
|
||||
const token = await tokenFor(email);
|
||||
const req = request(API).post(path).set("Authorization", `Bearer ${token}`);
|
||||
const req = request(await freightServer())
|
||||
.post(path)
|
||||
.set("Authorization", `Bearer ${token}`);
|
||||
for (const [k, v] of Object.entries(fields)) req.field(k, v);
|
||||
return req.attach(field, filePath);
|
||||
}
|
||||
|
||||
@@ -1,41 +1,17 @@
|
||||
/**
|
||||
* Runs once before any spec: wait for both APIs, then seed.
|
||||
* Runs once, in its own process, before any spec: confirm every shard's
|
||||
* containerized half is answering, then clear its gateway mock.
|
||||
*
|
||||
* Seeds are the Cypress suite's fixtures, reused verbatim (they are idempotent
|
||||
* `insert … where not exists`), plus one of our own for the second tenant:
|
||||
* seed-users.sql → seed-company.sql (order matters; company needs the users)
|
||||
* seed-import-corridor.sql (yards, locos, wagons, rates, distances)
|
||||
* seed-bulk-items.sql (PER_ITEM break-bulk cargo types)
|
||||
* seed-g1-train.sql (the 53-wagon BUILT container train)
|
||||
* seed-g2-weight.sql (the two 3 500 T weight-bound trains)
|
||||
* seed-government.sql (the kind='government' company)
|
||||
* seed-company-b.sql (user2@gmail.com's company — this suite)
|
||||
* seed-customs-service-type.sql (a service type that bundles customs)
|
||||
* Seeding is NOT here any more. The order is load-bearing —
|
||||
* `seed-users.sql` names its prerequisites as "created by the API's always-on
|
||||
* boot seeders" (iam.organizations / units / positions) — and the freight app
|
||||
* now boots inside the vitest *workers*, which this process cannot reach. So the
|
||||
* template database is seeded once by `scripts/prepare-shards.mjs` (boot the app
|
||||
* → boot seeders → SQL fixtures) and each shard is a clone of it.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { Client } from "pg";
|
||||
|
||||
const API = process.env.IT_API_URL ?? "http://localhost:3111";
|
||||
const PAYMENT_API = process.env.IT_PAYMENT_URL ?? "http://localhost:3113";
|
||||
const GATEWAY = process.env.IT_GATEWAY_URL ?? "http://localhost:4600";
|
||||
const DB_URL =
|
||||
process.env.IT_DB_URL ?? "postgres://edr_e2e:edr_e2e@localhost:5543/edr_freight_e2e";
|
||||
|
||||
const CYPRESS_FIXTURES = join(process.cwd(), "..", "e2e", "freight", "cypress", "fixtures");
|
||||
const OWN_FIXTURES = join(process.cwd(), "sql");
|
||||
|
||||
const SEEDS: Array<[dir: string, file: string]> = [
|
||||
[CYPRESS_FIXTURES, "seed-users.sql"],
|
||||
[CYPRESS_FIXTURES, "seed-company.sql"],
|
||||
[CYPRESS_FIXTURES, "seed-import-corridor.sql"],
|
||||
[CYPRESS_FIXTURES, "seed-bulk-items.sql"],
|
||||
[CYPRESS_FIXTURES, "seed-g1-train.sql"],
|
||||
[CYPRESS_FIXTURES, "seed-g2-weight.sql"],
|
||||
[CYPRESS_FIXTURES, "seed-government.sql"],
|
||||
[OWN_FIXTURES, "seed-company-b.sql"],
|
||||
[OWN_FIXTURES, "seed-customs-service-type.sql"],
|
||||
];
|
||||
const SHARDS = Number(process.env.IT_SHARDS ?? 1);
|
||||
const PAYMENT_BASE = Number(process.env.IT_PAYMENT_PORT_BASE ?? 3131);
|
||||
const GATEWAY_BASE = Number(process.env.IT_GATEWAY_PORT_BASE ?? 4600);
|
||||
|
||||
async function waitFor(label: string, url: string, attempts = 60): Promise<void> {
|
||||
for (let i = 0; i < attempts; i++) {
|
||||
@@ -51,22 +27,20 @@ async function waitFor(label: string, url: string, attempts = 60): Promise<void>
|
||||
}
|
||||
|
||||
export async function setup(): Promise<void> {
|
||||
await Promise.all([
|
||||
waitFor("freight-api", `${API}/api/health`),
|
||||
waitFor("payment-api", `${PAYMENT_API}/health`),
|
||||
waitFor("gateway-mock", `${GATEWAY}/__control/health`),
|
||||
]);
|
||||
const shards = Array.from({ length: SHARDS }, (_, i) => i);
|
||||
|
||||
const client = new Client({ connectionString: DB_URL });
|
||||
await client.connect();
|
||||
try {
|
||||
for (const [dir, file] of SEEDS) {
|
||||
await client.query(readFileSync(join(dir, file), "utf8"));
|
||||
console.log(`it: seeded ${file}`);
|
||||
}
|
||||
} finally {
|
||||
await client.end();
|
||||
}
|
||||
await Promise.all(
|
||||
shards.flatMap((i) => [
|
||||
waitFor(`payment-api (shard ${i})`, `http://localhost:${PAYMENT_BASE + i}/health`),
|
||||
waitFor(`gateway-mock (shard ${i})`, `http://localhost:${GATEWAY_BASE + i}/__control/health`),
|
||||
]),
|
||||
);
|
||||
|
||||
await fetch(`${GATEWAY}/__control/reset`, { method: "POST" });
|
||||
await Promise.all(
|
||||
shards.map((i) =>
|
||||
fetch(`http://localhost:${GATEWAY_BASE + i}/__control/reset`, { method: "POST" }),
|
||||
),
|
||||
);
|
||||
|
||||
console.log(`it: ${SHARDS} shard(s) ready`);
|
||||
}
|
||||
|
||||
@@ -1,18 +1,34 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
/**
|
||||
* One worker per shard. A shard owns its whole topology — in-process freight
|
||||
* app, database, payment API, gateway mock, broker vhost — so files can run in
|
||||
* parallel without sharing anything mutable. See src/app.ts.
|
||||
*/
|
||||
const SHARDS = Number(process.env.IT_SHARDS ?? 1);
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ["src/**/*.it.ts"],
|
||||
globalSetup: ["src/global-setup.ts"],
|
||||
// One shared containerized stack and one shared database: files run in
|
||||
// sequence. Concurrency is exercised INSIDE tests (Promise.all), which is
|
||||
// what the guards under test actually see in production.
|
||||
fileParallelism: false,
|
||||
pool: "forks",
|
||||
// Pinned min = max: VITEST_POOL_ID is the shard key, and a worker that vitest
|
||||
// declines to spawn is a shard whose containers are idling.
|
||||
poolOptions: { forks: { minForks: SHARDS, maxForks: SHARDS } },
|
||||
fileParallelism: SHARDS > 1,
|
||||
// The freight app boots ONCE per worker and is memoised in module scope.
|
||||
// With isolation on, every file would pay a fresh Nest boot (60 modules,
|
||||
// TypeORM, 5 seeders) — 25 boots instead of `SHARDS`.
|
||||
isolate: false,
|
||||
testTimeout: 180_000,
|
||||
hookTimeout: 180_000,
|
||||
// Booking/scheduling steps are not idempotent — a retry would assert
|
||||
// against a half-advanced booking.
|
||||
// against a half-advanced booking. (A whole-RUN retry is now safe, because
|
||||
// `it.mjs test` re-clones every shard database first.)
|
||||
retry: 0,
|
||||
// Workers hold a pg pool, three Socket.IO gateways, a RabbitMQ channel and
|
||||
// live cron timers; don't wait on those handles to unwind.
|
||||
teardownTimeout: 20_000,
|
||||
reporters: ["verbose"],
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user