mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge pull request #1320 from Tria-plc/eims-integration
Add container size to Standalone container return
This commit is contained in:
72
apps/edr-freight-api/src/config/eims.config.spec.ts
Normal file
72
apps/edr-freight-api/src/config/eims.config.spec.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import eimsConfigFactory from "./eims.config";
|
||||
|
||||
const REQUIRED = {
|
||||
EIMS_ENABLED: "true",
|
||||
EIMS_CLIENT_ID: "cid",
|
||||
EIMS_CLIENT_SECRET: "secret",
|
||||
EIMS_API_KEY: "apikey",
|
||||
EIMS_TIN: "0000000000",
|
||||
};
|
||||
|
||||
const withEnv = (vars: Record<string, string | undefined>, fn: () => void) => {
|
||||
const prior: Record<string, string | undefined> = {};
|
||||
for (const [key, value] of Object.entries(vars)) {
|
||||
prior[key] = process.env[key];
|
||||
if (value === undefined) delete process.env[key];
|
||||
else process.env[key] = value;
|
||||
}
|
||||
try {
|
||||
fn();
|
||||
} finally {
|
||||
for (const [key, value] of Object.entries(prior)) {
|
||||
if (value === undefined) delete process.env[key];
|
||||
else process.env[key] = value;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
describe("eims.config — private key / certificate resolution", () => {
|
||||
it("unescapes a literal \\n when the PEM was pasted without real newlines", () => {
|
||||
withEnv(
|
||||
{ ...REQUIRED, EIMS_PRIVATE_KEY: "line1\\nline2", EIMS_CERTIFICATE_PATH: "/dev/null" },
|
||||
() => {
|
||||
expect(eimsConfigFactory().privateKeyPem).toBe("line1\nline2");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves a PEM with real newlines untouched", () => {
|
||||
withEnv(
|
||||
{ ...REQUIRED, EIMS_PRIVATE_KEY: "line1\nline2", EIMS_CERTIFICATE_PATH: "/dev/null" },
|
||||
() => {
|
||||
expect(eimsConfigFactory().privateKeyPem).toBe("line1\nline2");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("throws naming all three key/cert options when none are set", () => {
|
||||
withEnv(
|
||||
{
|
||||
...REQUIRED,
|
||||
EIMS_PRIVATE_KEY_PATH: undefined,
|
||||
EIMS_PRIVATE_KEY_BASE64: undefined,
|
||||
EIMS_PRIVATE_KEY: undefined,
|
||||
EIMS_CERTIFICATE_PATH: "/dev/null",
|
||||
},
|
||||
() => {
|
||||
expect(() => eimsConfigFactory()).toThrow(
|
||||
/EIMS_PRIVATE_KEY_PATH or EIMS_PRIVATE_KEY_BASE64 or EIMS_PRIVATE_KEY/,
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("is satisfied by any single one of the three key options", () => {
|
||||
withEnv(
|
||||
{ ...REQUIRED, EIMS_PRIVATE_KEY: "x", EIMS_CERTIFICATE_PATH: "/dev/null" },
|
||||
() => {
|
||||
expect(() => eimsConfigFactory()).not.toThrow();
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -33,11 +33,20 @@ export interface EimsConfig {
|
||||
/**
|
||||
* Inline alternative to `privateKeyPath` — the key file's own bytes, base64-encoded, so a
|
||||
* container that can't be given a host bind mount can still receive it as a plain env var.
|
||||
* Takes precedence over the path when set. Either one must be present when EIMS is enabled.
|
||||
* Either one must be present when EIMS is enabled. Precedence: `privateKeyPem` > `privateKeyBase64`
|
||||
* > `privateKeyPath`.
|
||||
*/
|
||||
privateKeyBase64: string;
|
||||
/** Inline alternative to `certificatePath`, same precedence rule. */
|
||||
/** Inline alternative to `certificatePath`, same precedence rule as the key. */
|
||||
certificateBase64: string;
|
||||
/**
|
||||
* The PEM key pasted directly into the env var, no encoding step at all — the most direct of the
|
||||
* three inline forms, and the hardest for a broken transport step to mangle since there's no
|
||||
* decode stage to get wrong. Wins over `privateKeyBase64`/`privateKeyPath` when set.
|
||||
*/
|
||||
privateKeyPem: string;
|
||||
/** Inline alternative to `certificateBase64`, same precedence rule. */
|
||||
certificatePem: string;
|
||||
httpTimeoutMs: number;
|
||||
/** Re-authenticate this many ms before the access token actually expires. */
|
||||
tokenSkewMs: number;
|
||||
@@ -145,11 +154,11 @@ export interface EimsInvoiceConfig {
|
||||
|
||||
const REQUIRED_VARS = ["EIMS_CLIENT_ID", "EIMS_CLIENT_SECRET", "EIMS_API_KEY", "EIMS_TIN"] as const;
|
||||
|
||||
// Key/cert each have two ways in (file path or inline base64) — checked separately from
|
||||
// REQUIRED_VARS since it's "at least one of", not "this exact var".
|
||||
const REQUIRED_EITHER_OR: Array<[string, string]> = [
|
||||
["EIMS_PRIVATE_KEY_PATH", "EIMS_PRIVATE_KEY_BASE64"],
|
||||
["EIMS_CERTIFICATE_PATH", "EIMS_CERTIFICATE_BASE64"],
|
||||
// Key/cert each have three ways in (file path, inline base64, or raw PEM) — checked separately
|
||||
// from REQUIRED_VARS since it's "at least one of", not "this exact var".
|
||||
const REQUIRED_ANY_OF: string[][] = [
|
||||
["EIMS_PRIVATE_KEY_PATH", "EIMS_PRIVATE_KEY_BASE64", "EIMS_PRIVATE_KEY"],
|
||||
["EIMS_CERTIFICATE_PATH", "EIMS_CERTIFICATE_BASE64", "EIMS_CERTIFICATE"],
|
||||
];
|
||||
|
||||
const positiveInt = (raw: string | undefined, fallback: number, name: string): number => {
|
||||
@@ -171,6 +180,14 @@ const parseCodeMap = (raw: string | undefined): Record<string, string> => {
|
||||
return map;
|
||||
};
|
||||
|
||||
// Some env stores (single-line .env files, certain secret managers) can't hold a literal newline
|
||||
// and expect the caller to write "\n" as two characters instead. If the raw value already has a
|
||||
// real newline, leave it alone; otherwise unescape "\n" so a PEM pasted that way still parses.
|
||||
const normalizePem = (raw: string | undefined): string => {
|
||||
if (!raw) return "";
|
||||
return raw.includes("\n") ? raw : raw.replace(/\\n/g, "\n");
|
||||
};
|
||||
|
||||
/** Unset stays null so the registration-time check can name it; a set-but-bogus value throws. */
|
||||
const optionalNumber = (raw: string | undefined, name: string): number | null => {
|
||||
if (raw === undefined || raw === "") return null;
|
||||
@@ -198,6 +215,8 @@ export default registerAs("eims", (): EimsConfig => {
|
||||
privateKeyPath: process.env.EIMS_PRIVATE_KEY_PATH ?? "",
|
||||
certificatePath: process.env.EIMS_CERTIFICATE_PATH ?? "",
|
||||
privateKeyBase64: process.env.EIMS_PRIVATE_KEY_BASE64 ?? "",
|
||||
privateKeyPem: normalizePem(process.env.EIMS_PRIVATE_KEY),
|
||||
certificatePem: normalizePem(process.env.EIMS_CERTIFICATE),
|
||||
certificateBase64: process.env.EIMS_CERTIFICATE_BASE64 ?? "",
|
||||
httpTimeoutMs,
|
||||
tokenSkewMs,
|
||||
@@ -256,8 +275,8 @@ export default registerAs("eims", (): EimsConfig => {
|
||||
if (!enabled) return base;
|
||||
|
||||
const missing: string[] = REQUIRED_VARS.filter((name) => !process.env[name]);
|
||||
for (const [pathVar, base64Var] of REQUIRED_EITHER_OR) {
|
||||
if (!process.env[pathVar] && !process.env[base64Var]) missing.push(`${pathVar} or ${base64Var}`);
|
||||
for (const vars of REQUIRED_ANY_OF) {
|
||||
if (vars.every((name) => !process.env[name])) missing.push(vars.join(" or "));
|
||||
}
|
||||
if (missing.length > 0) {
|
||||
throw new Error(
|
||||
|
||||
@@ -5,6 +5,17 @@ import { ConfigService } from "@nestjs/config";
|
||||
import { EimsConfig } from "../../config/eims.config";
|
||||
import { EimsConfigException } from "./eims.errors";
|
||||
|
||||
const PEM_HEADER = /-----BEGIN [A-Z ]*(PRIVATE KEY|CERTIFICATE)-----/;
|
||||
|
||||
/**
|
||||
* A safe-to-log fingerprint of decoded key/cert bytes: length + a printable-only preview of the
|
||||
* first line. Never the actual key material — PEM headers aren't secret, the base64 body is.
|
||||
*/
|
||||
const describeBytes = (bytes: Buffer): string => {
|
||||
const preview = bytes.toString("utf8", 0, 40).replace(/[^\x20-\x7e]/g, "?");
|
||||
return `${bytes.length} bytes, starts with "${preview}"`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Loads the INSA-issued EIMS credentials from disk, once, and keeps them in memory.
|
||||
*
|
||||
@@ -25,20 +36,50 @@ export class EimsCredentialsProvider {
|
||||
}
|
||||
|
||||
/**
|
||||
* RSA private key, parsed once. `privateKeyBase64` wins when set (no file I/O at all — for a
|
||||
* container that can't be given a host bind mount); otherwise falls back to `privateKeyPath`.
|
||||
* Throws a config error if neither is usable.
|
||||
* RSA private key, parsed once. Three ways in, checked in this order: `privateKeyPem` (the PEM
|
||||
* text itself, no encoding step to get wrong), `privateKeyBase64` (for stores that can't hold a
|
||||
* literal newline), `privateKeyPath` (the original file-on-disk form). Throws a config error if
|
||||
* none is usable.
|
||||
*/
|
||||
getPrivateKey(): KeyObject {
|
||||
if (this.privateKey) return this.privateKey;
|
||||
|
||||
const { privateKeyBase64, privateKeyPath: path } = this.cfg;
|
||||
const source = privateKeyBase64 ? "EIMS_PRIVATE_KEY_BASE64" : `EIMS_PRIVATE_KEY_PATH (${path})`;
|
||||
if (!privateKeyBase64 && !path) throw new EimsConfigException("EIMS_PRIVATE_KEY_PATH is not set");
|
||||
const { privateKeyPem, privateKeyBase64, privateKeyPath: path } = this.cfg;
|
||||
const source = privateKeyPem
|
||||
? "EIMS_PRIVATE_KEY"
|
||||
: privateKeyBase64
|
||||
? "EIMS_PRIVATE_KEY_BASE64"
|
||||
: `EIMS_PRIVATE_KEY_PATH (${path})`;
|
||||
if (!privateKeyPem && !privateKeyBase64 && !path) {
|
||||
throw new EimsConfigException("EIMS_PRIVATE_KEY_PATH is not set");
|
||||
}
|
||||
|
||||
let bytes: Buffer;
|
||||
try {
|
||||
bytes = privateKeyPem
|
||||
? Buffer.from(privateKeyPem, "utf8")
|
||||
: privateKeyBase64
|
||||
? Buffer.from(privateKeyBase64, "base64")
|
||||
: readFileSync(path);
|
||||
} catch (err) {
|
||||
throw new EimsConfigException(
|
||||
`EIMS private key from ${source} could not be read or parsed: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Fail with a diagnosable message before handing possibly-garbled bytes to OpenSSL, whose own
|
||||
// error ("unsupported") gives no hint whether the problem is truncation, double-encoding, or a
|
||||
// genuinely wrong file — all indistinguishable from outside without seeing the decoded bytes.
|
||||
if (!PEM_HEADER.test(bytes.toString("utf8", 0, 100))) {
|
||||
throw new EimsConfigException(
|
||||
`EIMS private key from ${source} does not look like a PEM key after decoding ` +
|
||||
`(${describeBytes(bytes)}) — check it's base64 of the raw key file with no line-wrapping ` +
|
||||
`or truncation, and not base64 applied twice.`,
|
||||
);
|
||||
}
|
||||
|
||||
let key: KeyObject;
|
||||
try {
|
||||
const bytes = privateKeyBase64 ? Buffer.from(privateKeyBase64, "base64") : readFileSync(path);
|
||||
key = createPrivateKey(bytes);
|
||||
} catch (err) {
|
||||
// The source is operational information, not a secret; the key material never appears.
|
||||
@@ -58,13 +99,19 @@ export class EimsCredentialsProvider {
|
||||
}
|
||||
|
||||
/**
|
||||
* Base64 of the certificate file's exact bytes. No parsing, no re-encoding. `certificateBase64`
|
||||
* config wins when set (already base64, used as-is); otherwise read from `certificatePath`.
|
||||
* Base64 of the certificate file's exact bytes. No parsing, no re-encoding of what MoR issued.
|
||||
* `certificatePem`/`certificateBase64` config win when set (used as-is, or re-encoded from the
|
||||
* pasted text respectively); otherwise read from `certificatePath`.
|
||||
*/
|
||||
getCertificateBase64(): string {
|
||||
if (this.certificateBase64) return this.certificateBase64;
|
||||
|
||||
const { certificateBase64: inline, certificatePath: path } = this.cfg;
|
||||
const { certificatePem: pem, certificateBase64: inline, certificatePath: path } = this.cfg;
|
||||
if (pem) {
|
||||
this.certificateBase64 = Buffer.from(pem, "utf8").toString("base64");
|
||||
this.logger.log(`EIMS certificate bundle loaded from EIMS_CERTIFICATE`);
|
||||
return this.certificateBase64;
|
||||
}
|
||||
if (inline) {
|
||||
this.certificateBase64 = inline;
|
||||
this.logger.log(`EIMS certificate bundle loaded from EIMS_CERTIFICATE_BASE64`);
|
||||
|
||||
@@ -102,6 +102,8 @@ describe("EimsCredentialsProvider", () => {
|
||||
certificatePath?: string;
|
||||
privateKeyBase64?: string;
|
||||
certificateBase64?: string;
|
||||
privateKeyPem?: string;
|
||||
certificatePem?: string;
|
||||
}) => new EimsCredentialsProvider({ get: () => cfg } as unknown as ConfigService);
|
||||
|
||||
it("fails clearly when the key path is unset", () => {
|
||||
@@ -137,4 +139,34 @@ describe("EimsCredentialsProvider", () => {
|
||||
const certBase64 = Buffer.from(CERTIFICATE_FIXTURE, "utf8").toString("base64");
|
||||
expect(providerFor({ certificateBase64: certBase64 }).getCertificateBase64()).toBe(certBase64);
|
||||
});
|
||||
|
||||
it("fails with a decoded-bytes preview when the base64 doesn't decode to a PEM key", () => {
|
||||
// Simulates the real failure this guards against: a truncated/mangled env var still decodes
|
||||
// as *some* bytes, but not a key — OpenSSL's own error here gives no hint why.
|
||||
const notAKey = Buffer.from("not actually a pem file", "utf8").toString("base64");
|
||||
expect(() => providerFor({ privateKeyBase64: notAKey }).getPrivateKey()).toThrow(
|
||||
/does not look like a PEM key.*23 bytes, starts with "not actually a pem file"/s,
|
||||
);
|
||||
});
|
||||
|
||||
it("loads the key from the raw PEM env var directly, no encoding step", () => {
|
||||
const pem = readFileSync(keyPath).toString("utf8");
|
||||
const key = providerFor({ privateKeyPem: pem }).getPrivateKey();
|
||||
expect(key.asymmetricKeyType).toBe("rsa");
|
||||
});
|
||||
|
||||
it("prefers the raw PEM var over base64 and path when all three are set", () => {
|
||||
const pem = readFileSync(keyPath).toString("utf8");
|
||||
const key = providerFor({
|
||||
privateKeyPem: pem,
|
||||
privateKeyBase64: Buffer.from("garbage").toString("base64"),
|
||||
privateKeyPath: join(dir, "nope.key"),
|
||||
}).getPrivateKey();
|
||||
expect(key.asymmetricKeyType).toBe("rsa");
|
||||
});
|
||||
|
||||
it("loads the certificate from the raw PEM env var, re-encoded to base64", () => {
|
||||
const base64 = providerFor({ certificatePem: CERTIFICATE_FIXTURE }).getCertificateBase64();
|
||||
expect(base64).toBe(Buffer.from(CERTIFICATE_FIXTURE, "utf8").toString("base64"));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -61,6 +61,8 @@ export const eimsConfig = (over: Partial<EimsConfig> = {}): EimsConfig => ({
|
||||
certificatePath: "/dev/null",
|
||||
privateKeyBase64: "",
|
||||
certificateBase64: "",
|
||||
privateKeyPem: "",
|
||||
certificatePem: "",
|
||||
httpTimeoutMs: 30_000,
|
||||
tokenSkewMs: 45_000,
|
||||
autoSubmit: false,
|
||||
|
||||
@@ -961,6 +961,7 @@ interface StandaloneReturnModalProps {
|
||||
|
||||
function StandaloneReturnModal({ opened, onClose, onSubmit, loading }: StandaloneReturnModalProps) {
|
||||
const [containerNumber, setContainerNumber] = useState<string>("");
|
||||
const [containerSize, setContainerSize] = useState<EmptyContainerSize | null>(null);
|
||||
const [returnedBy, setReturnedBy] = useState<"EDR" | "CUSTOMER" | null>(null);
|
||||
const [returnDate, setReturnDate] = useState<string>(new Date().toISOString().split("T")[0]);
|
||||
const [warehouse, setWarehouse] = useState<string | null>(null);
|
||||
@@ -1021,6 +1022,7 @@ function StandaloneReturnModal({ opened, onClose, onSubmit, loading }: Standalon
|
||||
containers: [
|
||||
{
|
||||
containerNumber,
|
||||
containerSize: containerSize ?? undefined,
|
||||
returnDate,
|
||||
warehouse: selectedWarehouse?.name || warehouse,
|
||||
yard: selectedYard?.name,
|
||||
@@ -1034,6 +1036,7 @@ function StandaloneReturnModal({ opened, onClose, onSubmit, loading }: Standalon
|
||||
});
|
||||
|
||||
setContainerNumber("");
|
||||
setContainerSize(null);
|
||||
setReturnedBy(null);
|
||||
setReturnDate(new Date().toISOString().split("T")[0]);
|
||||
setWarehouse(null);
|
||||
@@ -1071,6 +1074,17 @@ function StandaloneReturnModal({ opened, onClose, onSubmit, loading }: Standalon
|
||||
required
|
||||
/>
|
||||
|
||||
<Select
|
||||
label="Container Type"
|
||||
placeholder="Select container size"
|
||||
value={containerSize}
|
||||
onChange={(val) => setContainerSize(val as EmptyContainerSize | null)}
|
||||
data={[
|
||||
{ value: "20", label: "20 ft" },
|
||||
{ value: "40", label: "40 ft" },
|
||||
]}
|
||||
/>
|
||||
|
||||
<Select
|
||||
label="Return Warehouse"
|
||||
placeholder="Select warehouse for container return"
|
||||
|
||||
Reference in New Issue
Block a user