327 lines
10 KiB
JavaScript
327 lines
10 KiB
JavaScript
"use strict";
|
|
|
|
const assert = require("node:assert/strict");
|
|
const fs = require("node:fs");
|
|
const os = require("node:os");
|
|
const path = require("node:path");
|
|
const { after, describe, it } = require("node:test");
|
|
|
|
const testDataDir = fs.mkdtempSync(path.join(os.tmpdir(), "toolctrl-leads-test-"));
|
|
process.env.DATA_DIR = testDataDir;
|
|
process.env.CLAMAV_ENABLED = "false";
|
|
|
|
const {
|
|
bindSubmissionId,
|
|
createLeadFingerprint
|
|
} = require("./public/submission-id");
|
|
|
|
const {
|
|
PlatformLeadConfigurationError,
|
|
PlatformLeadSubmissionError,
|
|
createServer,
|
|
forwardLeadToPlatform
|
|
} = require("./server");
|
|
|
|
const ENV = {
|
|
PLATFORM_LEADS_API_URL: "https://api.hivegrid.de",
|
|
PLATFORM_LEADS_API_KEY: "t".repeat(32)
|
|
};
|
|
|
|
const LEAD = {
|
|
submissionId: "334955b4-269d-4f18-b51b-7b1d93ed2103",
|
|
firstName: "Erika",
|
|
lastName: "Musterfrau",
|
|
email: "erika@example.com",
|
|
phone: "+49 123 456",
|
|
company: "Beispiel GmbH",
|
|
message: "Bitte melden."
|
|
};
|
|
|
|
function successResponse() {
|
|
return new Response(JSON.stringify({
|
|
id: "lead_789",
|
|
status: "new",
|
|
createdAt: "2026-07-16T12:00:00.000Z"
|
|
}), {
|
|
status: 201,
|
|
headers: { "Content-Type": "application/json" }
|
|
});
|
|
}
|
|
|
|
async function withServer(submitLead, callback) {
|
|
const server = createServer({ submitLead: submitLead });
|
|
await new Promise((resolve, reject) => {
|
|
server.once("error", reject);
|
|
server.listen(0, "127.0.0.1", resolve);
|
|
});
|
|
const address = server.address();
|
|
try {
|
|
return await callback("http://127.0.0.1:" + address.port);
|
|
} finally {
|
|
await new Promise((resolve) => server.close(resolve));
|
|
}
|
|
}
|
|
|
|
after(() => {
|
|
fs.rmSync(testDataDir, { recursive: true, force: true });
|
|
});
|
|
|
|
describe("forwardLeadToPlatform", () => {
|
|
it("sends the strict native contract without a source field", async () => {
|
|
const calls = [];
|
|
const fetchImpl = async (input, init) => {
|
|
calls.push({ input: String(input), init: init });
|
|
return successResponse();
|
|
};
|
|
|
|
const result = await forwardLeadToPlatform(LEAD, {
|
|
env: ENV,
|
|
fetchImpl: fetchImpl
|
|
});
|
|
|
|
assert.equal(result.id, "lead_789");
|
|
assert.equal(calls.length, 1);
|
|
assert.equal(calls[0].input, "https://api.hivegrid.de/v1/platform/leads");
|
|
assert.deepEqual(calls[0].init.headers, {
|
|
"Content-Type": "application/json",
|
|
Authorization: "Bearer " + "t".repeat(32),
|
|
"Idempotency-Key": LEAD.submissionId
|
|
});
|
|
const body = JSON.parse(calls[0].init.body);
|
|
assert.deepEqual(body, {
|
|
externalId: LEAD.submissionId,
|
|
sourceDetail: "Messe — Landing Page",
|
|
firstName: "Erika",
|
|
lastName: "Musterfrau",
|
|
email: "erika@example.com",
|
|
phone: "+49 123 456",
|
|
company: "Beispiel GmbH",
|
|
message: "Bitte melden.",
|
|
utmCampaign: "messe-landing-page",
|
|
metadata: {
|
|
consent: true,
|
|
formId: "contact-form",
|
|
formName: "ToolCTRL Kontaktformular",
|
|
locale: "de-DE",
|
|
submissionId: LEAD.submissionId
|
|
}
|
|
});
|
|
assert.equal(Object.hasOwn(body, "source"), false);
|
|
});
|
|
|
|
it("reuses the exact key and body across a retry", async () => {
|
|
const calls = [];
|
|
const fetchImpl = async (_input, init) => {
|
|
calls.push(init);
|
|
return calls.length === 1
|
|
? new Response(null, { status: 503 })
|
|
: successResponse();
|
|
};
|
|
|
|
await forwardLeadToPlatform(LEAD, { env: ENV, fetchImpl: fetchImpl });
|
|
|
|
assert.equal(calls.length, 2);
|
|
assert.equal(calls[0].body, calls[1].body);
|
|
assert.deepEqual(calls[0].headers, calls[1].headers);
|
|
});
|
|
|
|
it("rejects an API error instead of reporting success", async () => {
|
|
await assert.rejects(
|
|
forwardLeadToPlatform(LEAD, {
|
|
env: ENV,
|
|
fetchImpl: async () => new Response(null, { status: 401 })
|
|
}),
|
|
(error) => error instanceof PlatformLeadSubmissionError &&
|
|
error.code === "upstream" && error.upstreamStatus === 401
|
|
);
|
|
});
|
|
|
|
it("fails closed without runtime credentials", async () => {
|
|
await assert.rejects(
|
|
forwardLeadToPlatform(LEAD, { env: {} }),
|
|
PlatformLeadConfigurationError
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("browser submission id binding", () => {
|
|
it("keeps the id for an equivalent retry and renews it after a semantic edit", () => {
|
|
let sequence = 0;
|
|
const createId = () => "submission-" + (++sequence);
|
|
const original = {
|
|
name: " Erika Musterfrau ",
|
|
firma: " Beispiel GmbH ",
|
|
email: " ERIKA@example.com ",
|
|
telefon: " +49 123 456 ",
|
|
nachricht: " Bitte melden. "
|
|
};
|
|
const first = bindSubmissionId(null, createLeadFingerprint(original), createId);
|
|
const retry = bindSubmissionId(first, createLeadFingerprint({
|
|
...original,
|
|
name: "Erika Musterfrau",
|
|
email: "erika@EXAMPLE.com"
|
|
}), createId);
|
|
const edited = bindSubmissionId(retry, createLeadFingerprint({
|
|
...original,
|
|
nachricht: "Bitte morgen melden."
|
|
}), createId);
|
|
|
|
assert.equal(retry, first);
|
|
assert.equal(retry.id, "submission-1");
|
|
assert.equal(edited.id, "submission-2");
|
|
});
|
|
|
|
it("loads the binding helper before the form client", () => {
|
|
const index = fs.readFileSync(path.join(__dirname, "public/index.html"), "utf8");
|
|
const helperPosition = index.indexOf('<script src="/submission-id.js?v=');
|
|
const clientPosition = index.indexOf('<script src="/script.js?v=');
|
|
|
|
assert.notEqual(helperPosition, -1);
|
|
assert.notEqual(clientPosition, -1);
|
|
assert.equal(helperPosition < clientPosition, true);
|
|
});
|
|
});
|
|
|
|
describe("runtime readiness", () => {
|
|
it("fails without platform credentials and succeeds with valid runtime configuration", async () => {
|
|
const previousUrl = process.env.PLATFORM_LEADS_API_URL;
|
|
const previousKey = process.env.PLATFORM_LEADS_API_KEY;
|
|
try {
|
|
delete process.env.PLATFORM_LEADS_API_URL;
|
|
delete process.env.PLATFORM_LEADS_API_KEY;
|
|
await withServer(async () => ({ id: "unused" }), async (baseUrl) => {
|
|
const response = await fetch(baseUrl + "/readyz");
|
|
assert.equal(response.status, 503);
|
|
assert.equal((await response.json()).checks.platformLeadsConfigured, false);
|
|
});
|
|
|
|
process.env.PLATFORM_LEADS_API_URL = ENV.PLATFORM_LEADS_API_URL;
|
|
process.env.PLATFORM_LEADS_API_KEY = ENV.PLATFORM_LEADS_API_KEY;
|
|
await withServer(async () => ({ id: "unused" }), async (baseUrl) => {
|
|
const response = await fetch(baseUrl + "/readyz");
|
|
assert.equal(response.status, 200);
|
|
assert.equal((await response.json()).checks.platformLeadsConfigured, true);
|
|
});
|
|
} finally {
|
|
if (previousUrl == null) delete process.env.PLATFORM_LEADS_API_URL;
|
|
else process.env.PLATFORM_LEADS_API_URL = previousUrl;
|
|
if (previousKey == null) delete process.env.PLATFORM_LEADS_API_KEY;
|
|
else process.env.PLATFORM_LEADS_API_KEY = previousKey;
|
|
}
|
|
});
|
|
|
|
it("uses readiness rather than liveness for the container healthcheck", () => {
|
|
const dockerfile = fs.readFileSync(path.join(__dirname, "Dockerfile"), "utf8");
|
|
assert.match(dockerfile, /HEALTHCHECK[^]*\/readyz/);
|
|
});
|
|
});
|
|
|
|
describe("deployment contract", () => {
|
|
it("uses Coolify pull-deploy and verifies the ready revision", () => {
|
|
const workflow = fs.readFileSync(
|
|
path.join(__dirname, ".gitea/workflows/deploy.yml"),
|
|
"utf8"
|
|
);
|
|
|
|
assert.equal(
|
|
workflow.includes(
|
|
"/api/v1/deploy?uuid=${COOLIFY_RESOURCE_UUID}&force=true"
|
|
),
|
|
true
|
|
);
|
|
assert.doesNotMatch(
|
|
workflow,
|
|
/\/api\/v1\/applications\/[^\s"']+\/(?:start|restart)/
|
|
);
|
|
assert.equal(workflow.includes('-t "$IMAGE_LC:$SHA"'), true);
|
|
assert.equal(workflow.includes("EXPECTED_REVISION: ${{ github.sha }}"), true);
|
|
assert.equal(workflow.includes("READY_URL: https://tool-ctrl.de/readyz"), true);
|
|
assert.equal(
|
|
workflow.includes(
|
|
'if [ "$DEPLOYED_REVISION" = "$EXPECTED_REVISION" ]; then'
|
|
),
|
|
true
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("POST /api/lead", () => {
|
|
const formBody = {
|
|
name: " Erika Musterfrau ",
|
|
firma: " Beispiel GmbH ",
|
|
email: " ERIKA@example.com ",
|
|
telefon: " +49 123 456 ",
|
|
nachricht: " Bitte melden. ",
|
|
website: "",
|
|
submissionId: LEAD.submissionId
|
|
};
|
|
|
|
it("returns success only after the platform accepted the normalized lead", async () => {
|
|
const captured = [];
|
|
await withServer(async (lead) => {
|
|
captured.push(lead);
|
|
return { id: "lead_route", status: "new", createdAt: "now" };
|
|
}, async (baseUrl) => {
|
|
const response = await fetch(baseUrl + "/api/lead", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(formBody)
|
|
});
|
|
assert.equal(response.status, 200);
|
|
assert.deepEqual(await response.json(), { ok: true, id: "lead_route" });
|
|
});
|
|
|
|
assert.deepEqual(captured, [LEAD]);
|
|
assert.equal(fs.existsSync(path.join(testDataDir, "leads")), false);
|
|
});
|
|
|
|
it("returns an error and does not write NDJSON/CSV when the platform fails", async () => {
|
|
await withServer(async () => {
|
|
throw new PlatformLeadSubmissionError("network");
|
|
}, async (baseUrl) => {
|
|
const response = await fetch(baseUrl + "/api/lead", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(formBody)
|
|
});
|
|
assert.equal(response.status, 502);
|
|
assert.equal((await response.json()).error.includes("erneut"), true);
|
|
});
|
|
|
|
assert.equal(fs.existsSync(path.join(testDataDir, "leads")), false);
|
|
});
|
|
|
|
it("requires a stable submission id before calling the platform", async () => {
|
|
let calls = 0;
|
|
await withServer(async () => {
|
|
calls += 1;
|
|
return { id: "unexpected" };
|
|
}, async (baseUrl) => {
|
|
const response = await fetch(baseUrl + "/api/lead", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ ...formBody, submissionId: undefined })
|
|
});
|
|
assert.equal(response.status, 400);
|
|
});
|
|
assert.equal(calls, 0);
|
|
});
|
|
|
|
it("keeps the honeypot response silent without creating a lead", async () => {
|
|
let calls = 0;
|
|
await withServer(async () => {
|
|
calls += 1;
|
|
return { id: "unexpected" };
|
|
}, async (baseUrl) => {
|
|
const response = await fetch(baseUrl + "/api/lead", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ website: "bot.example" })
|
|
});
|
|
assert.equal(response.status, 200);
|
|
assert.deepEqual(await response.json(), { ok: true });
|
|
});
|
|
assert.equal(calls, 0);
|
|
});
|
|
});
|