feat(leads): send contact requests to native platform intake #1
No files matched your search
@@ -3,6 +3,7 @@
|
||||
.DS_Store
|
||||
node_modules
|
||||
*.log
|
||||
.env*
|
||||
leads
|
||||
gamejam
|
||||
public/gamejam-games
|
||||
@@ -0,0 +1,3 @@
|
||||
# Server runtime only. Never expose the key in public/ or client-side JavaScript.
|
||||
PLATFORM_LEADS_API_URL=https://api.hivegrid.de
|
||||
PLATFORM_LEADS_API_KEY=replace-with-source-key-at-least-32-characters-long
|
||||
@@ -0,0 +1,26 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
types: [opened, synchronize, reopened]
|
||||
|
||||
jobs:
|
||||
quality:
|
||||
name: Syntax and tests
|
||||
runs-on: toolctrl-ci
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
|
||||
- name: Verify
|
||||
run: |
|
||||
npm run check
|
||||
npm test
|
||||
git diff --check
|
||||
@@ -33,7 +33,8 @@ jobs:
|
||||
- name: Static checks
|
||||
run: |
|
||||
cd /workspace/repo
|
||||
node --check server.js
|
||||
npm run check
|
||||
npm test
|
||||
git diff --check
|
||||
|
||||
- name: Login to Gitea Registry
|
||||
@@ -45,18 +46,51 @@ jobs:
|
||||
SHA: ${{ github.sha }}
|
||||
run: |
|
||||
cd /workspace/repo
|
||||
SHORT_SHA=${SHA:0:7}
|
||||
IMAGE_LC=$(echo "$IMAGE" | tr '[:upper:]' '[:lower:]')
|
||||
docker build \
|
||||
--build-arg APP_REVISION="$SHA" \
|
||||
-t "$IMAGE_LC:$SHORT_SHA" \
|
||||
-t "$IMAGE_LC:$SHA" \
|
||||
-t "$IMAGE_LC:main" \
|
||||
.
|
||||
docker push "$IMAGE_LC:$SHORT_SHA"
|
||||
docker push "$IMAGE_LC:$SHA"
|
||||
docker push "$IMAGE_LC:main"
|
||||
|
||||
- name: Trigger Coolify deploy
|
||||
env:
|
||||
COOLIFY_RESOURCE_UUID: ocwtt7rd2zijtlszgua005k9
|
||||
run: |
|
||||
curl -fsS -X POST \
|
||||
-H "Authorization: Bearer ${{ secrets.COOLIFY_TOKEN }}" \
|
||||
"https://saas-ctrl.com/api/v1/applications/ocwtt7rd2zijtlszgua005k9/start"
|
||||
"https://saas-ctrl.com/api/v1/deploy?uuid=${COOLIFY_RESOURCE_UUID}&force=true"
|
||||
|
||||
- name: Verify deployed revision and readiness
|
||||
env:
|
||||
EXPECTED_REVISION: ${{ github.sha }}
|
||||
READY_URL: https://tool-ctrl.de/readyz
|
||||
run: |
|
||||
for attempt in $(seq 1 36); do
|
||||
READY_RESPONSE=$(curl -fsS --max-time 10 "$READY_URL" 2>/dev/null || true)
|
||||
DEPLOYED_REVISION=$(printf '%s' "$READY_RESPONSE" | node -e '
|
||||
let body = "";
|
||||
process.stdin.on("data", (chunk) => { body += chunk; });
|
||||
process.stdin.on("end", () => {
|
||||
try {
|
||||
const parsed = JSON.parse(body);
|
||||
if (parsed.ok === true && typeof parsed.revision === "string") {
|
||||
process.stdout.write(parsed.revision);
|
||||
}
|
||||
} catch (_error) {}
|
||||
});
|
||||
' 2>/dev/null || true)
|
||||
|
||||
if [ "$DEPLOYED_REVISION" = "$EXPECTED_REVISION" ]; then
|
||||
echo "toolctrl-website is ready on revision $EXPECTED_REVISION"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Waiting for revision $EXPECTED_REVISION (attempt $attempt/36)"
|
||||
sleep 10
|
||||
done
|
||||
|
||||
echo "Timed out waiting for ready revision $EXPECTED_REVISION" >&2
|
||||
exit 1
|
||||
@@ -11,3 +11,7 @@ public/gamejam-games/
|
||||
# Node
|
||||
node_modules/
|
||||
*.log
|
||||
|
||||
# Runtime-Secrets (nur das Muster wird versioniert)
|
||||
.env*
|
||||
!.env.example
|
||||
+1
-1
@@ -30,6 +30,6 @@ USER nodejs
|
||||
EXPOSE 3000
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
|
||||
CMD curl -fsS http://127.0.0.1:3000/healthz >/dev/null || exit 1
|
||||
CMD curl -fsS http://127.0.0.1:3000/readyz >/dev/null || exit 1
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
@@ -16,6 +16,13 @@ und Netzwerk-URL aus. Stoppen mit `Strg + C`.
|
||||
|
||||
Voraussetzung: Node.js (getestet mit v26).
|
||||
|
||||
Qualitätschecks:
|
||||
|
||||
```bash
|
||||
npm run check
|
||||
npm test
|
||||
```
|
||||
|
||||
## Aufbau
|
||||
|
||||
```
|
||||
@@ -29,18 +36,46 @@ public/ statische Website
|
||||
script.js Formular-Logik & Mobile-Navigation
|
||||
assets/ Logos, Emblem, Favicon, Icons, Schriften, Mockups
|
||||
server.js abhängigkeitsfreier Node-HTTP-Server + API
|
||||
leads/ gesammelte Kontaktanfragen (wird automatisch angelegt)
|
||||
leads/ historische NDJSON/CSV-Leads (nur noch Backfill-Quelle)
|
||||
gamejam/ GameJam-Einreichungen, Metadaten (wird automatisch angelegt)
|
||||
```
|
||||
|
||||
`leads/`, `gamejam/` und hochgeladene Spiele sind per `.gitignore` vom Repo
|
||||
ausgeschlossen.
|
||||
|
||||
## Leads
|
||||
## Plattform-Leads
|
||||
|
||||
`POST /api/lead` (JSON, Pflichtfelder `name`, `email`, `nachricht`). Jede
|
||||
Anfrage landet sofort lokal in `leads/leads.csv` **und** `leads.ndjson` — so
|
||||
geht auf der Messe nichts verloren. Ein Honeypot-Feld fängt einfache Bots ab.
|
||||
`POST /api/lead` validiert das Kontaktformular serverseitig und sendet die
|
||||
Anfrage an HiveGrids nativen Endpoint `POST /v1/platform/leads`. Pflichtfelder
|
||||
sind `name`, `email`, `nachricht` und die vom Browser erzeugte `submissionId`.
|
||||
Ein Honeypot-Feld fängt einfache Bots ab.
|
||||
|
||||
Der Browser behält die Submission-ID nach einem fehlgeschlagenen Versuch mit
|
||||
semantisch gleichem Payload bei. Wird das Formular inhaltlich geändert, bindet er
|
||||
vor dem nächsten Senden eine neue ID. Der Server verwendet dieselbe ID als
|
||||
`externalId`, `metadata.submissionId` und `Idempotency-Key`; auch der einmalige
|
||||
Netzwerk-/5xx-Retry verwendet exakt denselben Key und Payload. Dem Formular wird
|
||||
nur dann Erfolg gemeldet, wenn die Plattform den Lead mit `201` angenommen hat.
|
||||
API-, Netzwerk- und Konfigurationsfehler bleiben Fehler.
|
||||
|
||||
Die bisherige Zuordnung `Messe — Landing Page` bleibt im nativen Vertrag als
|
||||
`sourceDetail` und `utmCampaign=messe-landing-page` erhalten.
|
||||
|
||||
Erforderliche Runtime-Variablen:
|
||||
|
||||
```bash
|
||||
PLATFORM_LEADS_API_URL=https://api.hivegrid.de
|
||||
PLATFORM_LEADS_API_KEY=<source-spezifischer Key für toolctrl-website, 32–512 Zeichen>
|
||||
```
|
||||
|
||||
Der Bearer-Key gehört ausschließlich in die Server-Runtime (Coolify), nie ins
|
||||
Repository oder nach `public/`. Ohne gültige Konfiguration liefert die Route
|
||||
`503` und `/readyz` bleibt nicht bereit.
|
||||
|
||||
Die bestehenden Dateien `leads/leads.ndjson` und `leads/leads.csv` sind
|
||||
Legacy-Bestände für den kontrollierten R-LEADS-Backfill. Der laufende Producer
|
||||
erstellt oder verändert sie nicht mehr und meldet bei Plattformfehlern keinen
|
||||
lokalen Ersatz-Erfolg.
|
||||
|
||||
## GameJam-Upload & Virenscan (ClamAV)
|
||||
|
||||
@@ -93,11 +128,15 @@ CLAMAV_HOST=127.0.0.1
|
||||
CLAMAV_PORT=3310
|
||||
CLAMAV_ENABLED=true
|
||||
APP_REVISION=<git-sha>
|
||||
PLATFORM_LEADS_API_URL=https://api.hivegrid.de
|
||||
PLATFORM_LEADS_API_KEY=<Runtime-Secret>
|
||||
```
|
||||
|
||||
`/healthz` ist die leichte Liveness-Probe für Container und Coolify.
|
||||
`/readyz` prüft zusätzlich Schreibzugriff auf die Datenverzeichnisse und,
|
||||
falls aktiv, die Erreichbarkeit von ClamAV.
|
||||
`/readyz` prüft zusätzlich die Plattform-Lead-Konfiguration, Schreibzugriff auf
|
||||
die GameJam-Datenverzeichnisse und, falls aktiv, die Erreichbarkeit von ClamAV.
|
||||
Der Container-Healthcheck verwendet `/readyz`, damit eine fehlende
|
||||
Plattform-Lead-Konfiguration einen Deploy nicht gesund erscheinen lässt.
|
||||
|
||||
### Funktion testen
|
||||
|
||||
@@ -117,9 +156,11 @@ Deployment folgt dem Gitea-Infra-Standard aus `toolctrl-labs/infra`:
|
||||
1. Push auf `main` triggert `.gitea/workflows/deploy.yml`.
|
||||
2. Gitea Actions baut das Docker-Image und pusht es nach
|
||||
`git.tool-ctrl.de/toolctrl-labs/toolctrl-website`.
|
||||
3. Tags: kurzer Commit-SHA und `main`.
|
||||
3. Tags: vollständiger, unveränderlicher Commit-SHA und `main`.
|
||||
4. Coolify-App `toolctrl-website` (`ocwtt7rd2zijtlszgua005k9`) zieht
|
||||
`:main` auf der Marketing-VM und startet neu.
|
||||
`:main` per erzwungenem Pull-Deploy auf der Marketing-VM.
|
||||
5. Der Workflow wartet anschließend, bis `/readyz` erfolgreich ist und exakt
|
||||
den ausgelieferten Commit-SHA als `revision` meldet.
|
||||
|
||||
Die Coolify-App läuft im Projekt `Marketing`, Environment `production`, Server
|
||||
`marketing-vm`, Port `3000`, mit persistentem Storage auf `/app/data`.
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "toolctrl-website",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"check": "node --check server.js && node --check public/submission-id.js && node --check public/script.js",
|
||||
"test": "node --test server.test.js"
|
||||
}
|
||||
}
|
||||
+7
-6
@@ -425,11 +425,11 @@
|
||||
<div class="form-grid">
|
||||
<div class="form-row">
|
||||
<label for="f-vorname">Vorname <span>*</span></label>
|
||||
<input id="f-vorname" name="vorname" type="text" autocomplete="given-name" required />
|
||||
<input id="f-vorname" name="vorname" type="text" autocomplete="given-name" maxlength="120" required />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="f-nachname">Nachname <span>*</span></label>
|
||||
<input id="f-nachname" name="nachname" type="text" autocomplete="family-name" required />
|
||||
<input id="f-nachname" name="nachname" type="text" autocomplete="family-name" maxlength="120" required />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-grid">
|
||||
@@ -445,16 +445,16 @@
|
||||
<div class="form-grid">
|
||||
<div class="form-row">
|
||||
<label for="f-email">E-Mail <span>*</span></label>
|
||||
<input id="f-email" name="email" type="email" autocomplete="email" required />
|
||||
<input id="f-email" name="email" type="email" autocomplete="email" maxlength="320" required />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="f-telefon">Telefonnummer</label>
|
||||
<input id="f-telefon" name="telefon" type="tel" autocomplete="tel" />
|
||||
<input id="f-telefon" name="telefon" type="tel" autocomplete="tel" maxlength="80" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="f-nachricht">Worum geht es Ihnen? <span>*</span></label>
|
||||
<textarea id="f-nachricht" name="nachricht" rows="4" required
|
||||
<textarea id="f-nachricht" name="nachricht" rows="4" maxlength="4000" required
|
||||
placeholder="Kurz Ihr Anliegen, wir kümmern uns um die Details."></textarea>
|
||||
</div>
|
||||
<!-- Honeypot gegen Spam -->
|
||||
@@ -507,6 +507,7 @@
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script src="/script.js?v=20260610"></script>
|
||||
<script src="/submission-id.js?v=20260716"></script>
|
||||
<script src="/script.js?v=20260716"></script>
|
||||
</body>
|
||||
</html>
|
||||
+27
-7
@@ -31,6 +31,8 @@
|
||||
/* ---- Lead-Formular ---- */
|
||||
var form = document.getElementById("lead-form");
|
||||
var status = document.getElementById("form-status");
|
||||
var pendingSubmission = null;
|
||||
var submissionIds = window.ToolCtrlSubmission;
|
||||
if (!form) return;
|
||||
|
||||
function setStatus(msg, kind) {
|
||||
@@ -69,22 +71,39 @@
|
||||
}
|
||||
|
||||
var btn = form.querySelector("button[type=submit]");
|
||||
if (!submissionIds) {
|
||||
setStatus("Formular konnte nicht initialisiert werden. Bitte laden Sie die Seite neu.", "err");
|
||||
return;
|
||||
}
|
||||
var vorname = form.vorname.value.trim();
|
||||
var nachname = form.nachname.value.trim();
|
||||
var firmenname = form.firmenname.value.trim();
|
||||
var branche = form.branche.value.trim();
|
||||
var payload = {
|
||||
var leadInput = {
|
||||
name: (vorname + " " + nachname).trim(),
|
||||
vorname: vorname,
|
||||
nachname: nachname,
|
||||
firma: firmenname + (branche ? " (" + branche + ")" : ""),
|
||||
firmenname: firmenname,
|
||||
branche: branche,
|
||||
email: form.email.value.trim(),
|
||||
telefon: form.telefon.value.trim(),
|
||||
nachricht: form.nachricht.value.trim(),
|
||||
nachricht: form.nachricht.value.trim()
|
||||
};
|
||||
pendingSubmission = submissionIds.bindSubmissionId(
|
||||
pendingSubmission,
|
||||
submissionIds.createLeadFingerprint(leadInput),
|
||||
function () { return submissionIds.createSubmissionId(window.crypto); }
|
||||
);
|
||||
var payload = {
|
||||
name: leadInput.name,
|
||||
vorname: vorname,
|
||||
nachname: nachname,
|
||||
firma: leadInput.firma,
|
||||
firmenname: firmenname,
|
||||
branche: branche,
|
||||
email: leadInput.email,
|
||||
telefon: leadInput.telefon,
|
||||
nachricht: leadInput.nachricht,
|
||||
website: form.website.value.trim(), // Honeypot
|
||||
quelle: "Landing Page"
|
||||
quelle: "Landing Page",
|
||||
submissionId: pendingSubmission.id
|
||||
};
|
||||
|
||||
if (btn) { btn.disabled = true; btn.textContent = "Wird gesendet …"; }
|
||||
@@ -102,6 +121,7 @@
|
||||
})
|
||||
.then(function (r) {
|
||||
if (r.ok) {
|
||||
pendingSubmission = null;
|
||||
form.reset();
|
||||
setStatus(
|
||||
"Vielen Dank — Ihre Anfrage ist bei uns eingegangen. Wir melden uns zeitnah.",
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
(function (root, factory) {
|
||||
"use strict";
|
||||
var api = factory();
|
||||
if (typeof module === "object" && module.exports) module.exports = api;
|
||||
if (root) root.ToolCtrlSubmission = api;
|
||||
})(typeof globalThis === "object" ? globalThis : this, function () {
|
||||
"use strict";
|
||||
|
||||
function createSubmissionId(cryptoApi) {
|
||||
if (!cryptoApi || typeof cryptoApi.getRandomValues !== "function") {
|
||||
throw new Error("Secure browser randomness is unavailable");
|
||||
}
|
||||
if (typeof cryptoApi.randomUUID === "function") {
|
||||
return cryptoApi.randomUUID();
|
||||
}
|
||||
var bytes = new Uint8Array(16);
|
||||
cryptoApi.getRandomValues(bytes);
|
||||
return "web-" + Array.from(bytes, function (value) {
|
||||
return value.toString(16).padStart(2, "0");
|
||||
}).join("");
|
||||
}
|
||||
|
||||
function normalizedName(value) {
|
||||
return String(value || "").trim().replace(/\s+/g, " ");
|
||||
}
|
||||
|
||||
function createLeadFingerprint(input) {
|
||||
return JSON.stringify({
|
||||
name: normalizedName(input.name),
|
||||
company: String(input.firma || "").trim(),
|
||||
email: String(input.email || "").trim().toLowerCase(),
|
||||
phone: String(input.telefon || "").trim(),
|
||||
message: String(input.nachricht || "").trim(),
|
||||
formId: "contact-form",
|
||||
consent: true
|
||||
});
|
||||
}
|
||||
|
||||
function bindSubmissionId(pending, fingerprint, createId) {
|
||||
if (pending && pending.fingerprint === fingerprint) return pending;
|
||||
return { id: createId(), fingerprint: fingerprint };
|
||||
}
|
||||
|
||||
return {
|
||||
bindSubmissionId: bindSubmissionId,
|
||||
createLeadFingerprint: createLeadFingerprint,
|
||||
createSubmissionId: createSubmissionId
|
||||
};
|
||||
});
|
||||
@@ -22,9 +22,6 @@ const PUBLIC_DIR = path.join(ROOT, "public");
|
||||
const DATA_DIR = process.env.DATA_DIR
|
||||
? path.resolve(process.env.DATA_DIR)
|
||||
: ROOT;
|
||||
const LEADS_DIR = path.join(DATA_DIR, "leads");
|
||||
const LEADS_JSON = path.join(LEADS_DIR, "leads.ndjson");
|
||||
const LEADS_CSV = path.join(LEADS_DIR, "leads.csv");
|
||||
|
||||
/* GameJam-Speicher */
|
||||
const GAMEJAM_DIR = path.join(DATA_DIR, "gamejam");
|
||||
@@ -38,31 +35,127 @@ const MAX_UPLOAD = 5 * 1024 * 1024; /* 5 MB pro eingereichtem Spiel */
|
||||
const UPLOAD_DEADLINE = new Date("2026-05-27T22:00:00+02:00");
|
||||
const APP_REVISION = process.env.APP_REVISION || "local";
|
||||
|
||||
/* Central lead-inbox (infra ADR 012). Sites forward leads here; local files
|
||||
stay as a fallback. Unset env => old local-only behavior. */
|
||||
const LEAD_INBOX_URL = process.env.LEAD_INBOX_URL || "";
|
||||
const LEAD_INBOX_KEY = process.env.LEAD_INBOX_KEY || "";
|
||||
const PLATFORM_LEADS_PATH = "/v1/platform/leads";
|
||||
const PLATFORM_LEAD_RETRYABLE_STATUSES = new Set([502, 503, 504]);
|
||||
const PLATFORM_LEAD_CREDENTIAL_PATTERN = /^[A-Za-z0-9._~-]{32,512}$/;
|
||||
const SUBMISSION_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{7,199}$/;
|
||||
const TOOLCTRL_SOURCE_DETAIL = "Messe — Landing Page";
|
||||
const TOOLCTRL_UTM_CAMPAIGN = "messe-landing-page";
|
||||
|
||||
async function forwardLeadToInbox(lead) {
|
||||
if (!LEAD_INBOX_URL || !LEAD_INBOX_KEY) return false;
|
||||
try {
|
||||
const res = await fetch(LEAD_INBOX_URL.replace(/\/+$/, "") + "/v1/leads", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: "Bearer " + LEAD_INBOX_KEY
|
||||
},
|
||||
/* inbox accepts DE aliases: firma, telefon, nachricht, quelle */
|
||||
body: JSON.stringify(lead),
|
||||
signal: AbortSignal.timeout(5000)
|
||||
});
|
||||
return res.ok;
|
||||
} catch (e) {
|
||||
console.error("[lead] inbox forward failed:", e.message);
|
||||
return false;
|
||||
class PlatformLeadConfigurationError extends Error {
|
||||
constructor() {
|
||||
super("Platform lead producer is not configured");
|
||||
this.name = "PlatformLeadConfigurationError";
|
||||
}
|
||||
}
|
||||
|
||||
class PlatformLeadSubmissionError extends Error {
|
||||
constructor(code, upstreamStatus) {
|
||||
super("Platform lead submission failed: " + code);
|
||||
this.name = "PlatformLeadSubmissionError";
|
||||
this.code = code;
|
||||
this.upstreamStatus = upstreamStatus;
|
||||
}
|
||||
}
|
||||
|
||||
function platformLeadConfig(env) {
|
||||
const rawUrl = String(env.PLATFORM_LEADS_API_URL || "").trim();
|
||||
const key = String(env.PLATFORM_LEADS_API_KEY || "").trim();
|
||||
if (!rawUrl || !PLATFORM_LEAD_CREDENTIAL_PATTERN.test(key)) {
|
||||
throw new PlatformLeadConfigurationError();
|
||||
}
|
||||
|
||||
let baseUrl;
|
||||
try {
|
||||
baseUrl = new URL(rawUrl);
|
||||
} catch (_error) {
|
||||
throw new PlatformLeadConfigurationError();
|
||||
}
|
||||
|
||||
const loopback = ["localhost", "127.0.0.1", "[::1]"].includes(baseUrl.hostname);
|
||||
const validProtocol = baseUrl.protocol === "https:" ||
|
||||
(baseUrl.protocol === "http:" && loopback);
|
||||
if (!validProtocol || (baseUrl.pathname !== "/" && baseUrl.pathname !== "") ||
|
||||
baseUrl.username || baseUrl.password || baseUrl.search || baseUrl.hash) {
|
||||
throw new PlatformLeadConfigurationError();
|
||||
}
|
||||
|
||||
return {
|
||||
endpoint: new URL(PLATFORM_LEADS_PATH, baseUrl).toString(),
|
||||
key: key
|
||||
};
|
||||
}
|
||||
|
||||
function compactObject(value) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).filter((entry) => entry[1] !== undefined)
|
||||
);
|
||||
}
|
||||
|
||||
async function forwardLeadToPlatform(lead, options) {
|
||||
const opts = options || {};
|
||||
const config = platformLeadConfig(opts.env || process.env);
|
||||
const fetchImpl = opts.fetchImpl || fetch;
|
||||
const maxAttempts = opts.maxAttempts || 2;
|
||||
const timeoutMs = opts.timeoutMs || 5000;
|
||||
const payload = compactObject({
|
||||
externalId: lead.submissionId,
|
||||
sourceDetail: TOOLCTRL_SOURCE_DETAIL,
|
||||
firstName: lead.firstName,
|
||||
lastName: lead.lastName,
|
||||
email: lead.email,
|
||||
phone: lead.phone,
|
||||
company: lead.company,
|
||||
message: lead.message,
|
||||
utmCampaign: TOOLCTRL_UTM_CAMPAIGN,
|
||||
metadata: {
|
||||
consent: true,
|
||||
formId: "contact-form",
|
||||
formName: "ToolCTRL Kontaktformular",
|
||||
locale: "de-DE",
|
||||
submissionId: lead.submissionId
|
||||
}
|
||||
});
|
||||
const serializedPayload = JSON.stringify(payload);
|
||||
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
||||
let response;
|
||||
try {
|
||||
response = await fetchImpl(config.endpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: "Bearer " + config.key,
|
||||
"Idempotency-Key": lead.submissionId
|
||||
},
|
||||
body: serializedPayload,
|
||||
signal: AbortSignal.timeout(timeoutMs)
|
||||
});
|
||||
} catch (_error) {
|
||||
if (attempt < maxAttempts) continue;
|
||||
throw new PlatformLeadSubmissionError("network");
|
||||
}
|
||||
|
||||
if (response.status === 201) {
|
||||
const result = await response.json().catch(() => null);
|
||||
if (result && typeof result.id === "string" &&
|
||||
typeof result.status === "string" &&
|
||||
typeof result.createdAt === "string") {
|
||||
return result;
|
||||
}
|
||||
throw new PlatformLeadSubmissionError("invalid_response", response.status);
|
||||
}
|
||||
|
||||
if (PLATFORM_LEAD_RETRYABLE_STATUSES.has(response.status) &&
|
||||
attempt < maxAttempts) {
|
||||
continue;
|
||||
}
|
||||
throw new PlatformLeadSubmissionError("upstream", response.status);
|
||||
}
|
||||
|
||||
throw new PlatformLeadSubmissionError("network");
|
||||
}
|
||||
|
||||
function envFlag(name, defaultValue) {
|
||||
const raw = process.env[name];
|
||||
if (raw == null || raw === "") return defaultValue;
|
||||
@@ -120,18 +213,6 @@ const MIME = {
|
||||
".xml": "application/xml; charset=utf-8"
|
||||
};
|
||||
|
||||
/* ---------- Lead-Speicher vorbereiten ---------- */
|
||||
function ensureLeadStore() {
|
||||
if (!fs.existsSync(LEADS_DIR)) fs.mkdirSync(LEADS_DIR, { recursive: true });
|
||||
if (!fs.existsSync(LEADS_CSV)) {
|
||||
fs.writeFileSync(
|
||||
LEADS_CSV,
|
||||
"zeitpunkt,name,firma,email,telefon,nachricht,quelle\n",
|
||||
"utf8"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- GameJam-Speicher vorbereiten ---------- */
|
||||
function ensureGamejamStore() {
|
||||
if (!fs.existsSync(GAMEJAM_DIR)) fs.mkdirSync(GAMEJAM_DIR, { recursive: true });
|
||||
@@ -150,28 +231,6 @@ function writeProbe(dir) {
|
||||
fs.unlinkSync(file);
|
||||
}
|
||||
|
||||
function csvCell(value) {
|
||||
const s = String(value == null ? "" : value);
|
||||
return '"' + s.replace(/"/g, '""').replace(/\r?\n/g, " ") + '"';
|
||||
}
|
||||
|
||||
function saveLead(lead) {
|
||||
ensureLeadStore();
|
||||
fs.appendFileSync(LEADS_JSON, JSON.stringify(lead) + "\n", "utf8");
|
||||
const row = [
|
||||
lead.zeitpunkt,
|
||||
lead.name,
|
||||
lead.firma,
|
||||
lead.email,
|
||||
lead.telefon,
|
||||
lead.nachricht,
|
||||
lead.quelle
|
||||
]
|
||||
.map(csvCell)
|
||||
.join(",");
|
||||
fs.appendFileSync(LEADS_CSV, row + "\n", "utf8");
|
||||
}
|
||||
|
||||
/* ---------- Statische Dateien ---------- */
|
||||
function serveStatic(req, res) {
|
||||
let urlPath = decodeURIComponent(req.url.split("?")[0]);
|
||||
@@ -228,19 +287,26 @@ function serveStatic(req, res) {
|
||||
}
|
||||
|
||||
/* ---------- Lead-Endpunkt ---------- */
|
||||
function handleLead(req, res) {
|
||||
function handleLead(req, res, submitLead) {
|
||||
const contentLength = Number.parseInt(req.headers["content-length"] || "0", 10);
|
||||
if (Number.isFinite(contentLength) && contentLength > 64 * 1024) {
|
||||
req.resume();
|
||||
return sendJSON(res, 413, { error: "Anfrage zu groß." });
|
||||
}
|
||||
|
||||
let body = "";
|
||||
let tooLarge = false;
|
||||
|
||||
req.on("data", (chunk) => {
|
||||
if (tooLarge) return;
|
||||
body += chunk;
|
||||
if (body.length > 64 * 1024) {
|
||||
if (Buffer.byteLength(body, "utf8") > 64 * 1024) {
|
||||
tooLarge = true;
|
||||
req.destroy();
|
||||
body = "";
|
||||
}
|
||||
});
|
||||
|
||||
req.on("end", () => {
|
||||
req.on("end", async () => {
|
||||
if (tooLarge) return sendJSON(res, 413, { error: "Anfrage zu groß." });
|
||||
|
||||
let data;
|
||||
@@ -250,16 +316,29 @@ function handleLead(req, res) {
|
||||
return sendJSON(res, 400, { error: "Ungültiges Format." });
|
||||
}
|
||||
|
||||
if (!data || typeof data !== "object" || Array.isArray(data)) {
|
||||
return sendJSON(res, 400, { error: "Ungültiges Format." });
|
||||
}
|
||||
|
||||
/* Honeypot — befülltes Feld = Bot. Still mit OK quittieren. */
|
||||
if (data.website && String(data.website).trim() !== "") {
|
||||
return sendJSON(res, 200, { ok: true });
|
||||
}
|
||||
|
||||
const name = String(data.name || "").trim();
|
||||
const email = String(data.email || "").trim();
|
||||
const nachricht = String(data.nachricht || "").trim();
|
||||
const stringFields = ["name", "email", "nachricht", "firma", "telefon", "submissionId"];
|
||||
if (stringFields.some((field) =>
|
||||
data[field] != null && typeof data[field] !== "string")) {
|
||||
return sendJSON(res, 400, { error: "Ungültiges Format." });
|
||||
}
|
||||
|
||||
if (!name || !email || !nachricht) {
|
||||
const name = String(data.name || "").trim();
|
||||
const email = String(data.email || "").trim().toLowerCase();
|
||||
const message = String(data.nachricht || "").trim();
|
||||
const company = String(data.firma || "").trim();
|
||||
const phone = String(data.telefon || "").trim();
|
||||
const submissionId = String(data.submissionId || "").trim();
|
||||
|
||||
if (!name || !email || !message) {
|
||||
return sendJSON(res, 400, {
|
||||
error: "Bitte Name, E-Mail und Nachricht angeben."
|
||||
});
|
||||
@@ -267,38 +346,55 @@ function handleLead(req, res) {
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||
return sendJSON(res, 400, { error: "Bitte eine gültige E-Mail angeben." });
|
||||
}
|
||||
if (!SUBMISSION_ID_PATTERN.test(submissionId)) {
|
||||
return sendJSON(res, 400, { error: "Gültige Submission-ID erforderlich." });
|
||||
}
|
||||
if (name.length > 241 || email.length > 320 || company.length > 200 ||
|
||||
phone.length > 80 || message.length > 4000) {
|
||||
return sendJSON(res, 400, { error: "Eingabe ist zu lang." });
|
||||
}
|
||||
|
||||
const nameParts = name.split(/\s+/);
|
||||
const firstName = nameParts.shift();
|
||||
const lastName = nameParts.join(" ") || undefined;
|
||||
if (firstName.length > 120 || (lastName && lastName.length > 120)) {
|
||||
return sendJSON(res, 400, { error: "Name ist zu lang." });
|
||||
}
|
||||
const lead = {
|
||||
zeitpunkt: new Date().toISOString(),
|
||||
name: name.slice(0, 200),
|
||||
firma: String(data.firma || "").trim().slice(0, 200),
|
||||
email: email.slice(0, 200),
|
||||
telefon: String(data.telefon || "").trim().slice(0, 80),
|
||||
nachricht: nachricht.slice(0, 4000),
|
||||
quelle: String(data.quelle || "Landing Page").slice(0, 120)
|
||||
submissionId: submissionId,
|
||||
firstName: firstName,
|
||||
lastName: lastName,
|
||||
email: email,
|
||||
phone: phone || undefined,
|
||||
company: company || undefined,
|
||||
message: message
|
||||
};
|
||||
|
||||
/* Central inbox is source of truth; local files are the fallback so a
|
||||
lead is never lost if the inbox is unreachable (infra ADR 012). */
|
||||
forwardLeadToInbox(lead).then((forwarded) => {
|
||||
if (!forwarded) {
|
||||
try {
|
||||
saveLead(lead);
|
||||
} catch (e) {
|
||||
console.error("[lead] Speichern fehlgeschlagen:", e.message);
|
||||
return sendJSON(res, 500, {
|
||||
error: "Speichern fehlgeschlagen. Bitte später erneut versuchen."
|
||||
});
|
||||
}
|
||||
try {
|
||||
const result = await submitLead(lead);
|
||||
console.log("[lead] →platform submission=" + submissionId + " lead=" + result.id);
|
||||
return sendJSON(res, 200, { ok: true, id: result.id });
|
||||
} catch (error) {
|
||||
if (error instanceof PlatformLeadConfigurationError) {
|
||||
console.error("[lead] platform producer is not configured");
|
||||
return sendJSON(res, 503, {
|
||||
error: "Anfragen können derzeit nicht übermittelt werden. Bitte später erneut versuchen."
|
||||
});
|
||||
}
|
||||
|
||||
console.log(
|
||||
"[lead] " + (forwarded ? "→inbox " : "→local ") +
|
||||
lead.zeitpunkt + " — " + lead.name +
|
||||
(lead.firma ? " (" + lead.firma + ")" : "") + " <" + lead.email + ">"
|
||||
);
|
||||
sendJSON(res, 200, { ok: true });
|
||||
});
|
||||
if (error instanceof PlatformLeadSubmissionError) {
|
||||
console.error("[lead] platform submission failed", {
|
||||
code: error.code,
|
||||
upstreamStatus: error.upstreamStatus
|
||||
});
|
||||
return sendJSON(res, 502, {
|
||||
error: "Anfrage konnte nicht übermittelt werden. Bitte erneut versuchen."
|
||||
});
|
||||
}
|
||||
console.error("[lead] unexpected platform producer failure");
|
||||
return sendJSON(res, 500, {
|
||||
error: "Interner Fehler. Bitte später erneut versuchen."
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -560,15 +656,20 @@ async function handleReadyz(req, res) {
|
||||
}
|
||||
|
||||
const checks = {
|
||||
leadsWritable: false,
|
||||
platformLeadsConfigured: false,
|
||||
gamejamWritable: false,
|
||||
gamesWritable: false,
|
||||
clamav: !CLAMAV.enabled
|
||||
};
|
||||
|
||||
try {
|
||||
writeProbe(LEADS_DIR);
|
||||
checks.leadsWritable = true;
|
||||
platformLeadConfig(process.env);
|
||||
checks.platformLeadsConfigured = true;
|
||||
} catch (_error) {
|
||||
checks.platformLeadsConfigured = false;
|
||||
}
|
||||
|
||||
try {
|
||||
writeProbe(GAMEJAM_DIR);
|
||||
checks.gamejamWritable = true;
|
||||
writeProbe(GAMES_DIR);
|
||||
@@ -588,42 +689,45 @@ async function handleReadyz(req, res) {
|
||||
}
|
||||
|
||||
/* ---------- Router ---------- */
|
||||
const server = http.createServer((req, res) => {
|
||||
const route = req.url.split("?")[0];
|
||||
function createServer(options) {
|
||||
const submitLead = (options && options.submitLead) || forwardLeadToPlatform;
|
||||
return http.createServer((req, res) => {
|
||||
const route = req.url.split("?")[0];
|
||||
|
||||
if (route === "/healthz") return handleHealthz(req, res);
|
||||
if (route === "/readyz") return handleReadyz(req, res);
|
||||
if (route === "/healthz") return handleHealthz(req, res);
|
||||
if (route === "/readyz") return handleReadyz(req, res);
|
||||
|
||||
if (route === "/api/lead") {
|
||||
if (req.method !== "POST") {
|
||||
return sendJSON(res, 405, { error: "Methode nicht erlaubt." });
|
||||
if (route === "/api/lead") {
|
||||
if (req.method !== "POST") {
|
||||
return sendJSON(res, 405, { error: "Methode nicht erlaubt." });
|
||||
}
|
||||
return handleLead(req, res, submitLead);
|
||||
}
|
||||
return handleLead(req, res);
|
||||
}
|
||||
|
||||
if (route === "/api/gamejam/upload") {
|
||||
if (req.method !== "POST") {
|
||||
return sendJSON(res, 405, { error: "Methode nicht erlaubt." });
|
||||
if (route === "/api/gamejam/upload") {
|
||||
if (req.method !== "POST") {
|
||||
return sendJSON(res, 405, { error: "Methode nicht erlaubt." });
|
||||
}
|
||||
return handleGamejamUpload(req, res);
|
||||
}
|
||||
return handleGamejamUpload(req, res);
|
||||
}
|
||||
|
||||
if (route === "/api/gamejam/games") {
|
||||
if (req.method !== "GET") {
|
||||
return sendJSON(res, 405, { error: "Methode nicht erlaubt." });
|
||||
if (route === "/api/gamejam/games") {
|
||||
if (req.method !== "GET") {
|
||||
return sendJSON(res, 405, { error: "Methode nicht erlaubt." });
|
||||
}
|
||||
return handleGamejamGames(req, res);
|
||||
}
|
||||
return handleGamejamGames(req, res);
|
||||
}
|
||||
|
||||
if (req.method !== "GET" && req.method !== "HEAD") {
|
||||
res.writeHead(405).end("Method Not Allowed");
|
||||
return;
|
||||
}
|
||||
serveStatic(req, res);
|
||||
});
|
||||
if (req.method !== "GET" && req.method !== "HEAD") {
|
||||
res.writeHead(405).end("Method Not Allowed");
|
||||
return;
|
||||
}
|
||||
serveStatic(req, res);
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------- Freien Port suchen und starten ---------- */
|
||||
function listen(candidates) {
|
||||
function listen(server, candidates) {
|
||||
if (candidates.length === 0) {
|
||||
console.error("Kein freier Port gefunden.");
|
||||
process.exit(1);
|
||||
@@ -632,7 +736,7 @@ function listen(candidates) {
|
||||
server.once("error", (err) => {
|
||||
if (err.code === "EADDRINUSE") {
|
||||
console.log("Port " + port + " belegt — versuche nächsten …");
|
||||
listen(candidates.slice(1));
|
||||
listen(server, candidates.slice(1));
|
||||
} else {
|
||||
console.error("Serverfehler:", err.message);
|
||||
process.exit(1);
|
||||
@@ -640,7 +744,6 @@ function listen(candidates) {
|
||||
});
|
||||
server.listen(port, HOST, () => {
|
||||
const actual = server.address().port;
|
||||
ensureLeadStore();
|
||||
ensureGamejamStore();
|
||||
const lan = getLanIP();
|
||||
console.log("");
|
||||
@@ -651,7 +754,12 @@ function listen(candidates) {
|
||||
console.log(" Netzwerk: http://" + lan + ":" + actual +
|
||||
" (andere Geräte im selben WLAN / QR-Code)");
|
||||
}
|
||||
console.log(" Leads: " + LEADS_CSV);
|
||||
try {
|
||||
platformLeadConfig(process.env);
|
||||
console.log(" Plattform-Leads: konfiguriert");
|
||||
} catch (_error) {
|
||||
console.log(" Plattform-Leads: NICHT konfiguriert — Formulare liefern 503");
|
||||
}
|
||||
console.log(" GameJam-Einreichungen: " + SUBMISSIONS_JSON);
|
||||
console.log(" Daten: " + DATA_DIR);
|
||||
console.log(" Revision: " + APP_REVISION);
|
||||
@@ -669,4 +777,14 @@ function listen(candidates) {
|
||||
});
|
||||
}
|
||||
|
||||
listen(PORT_CANDIDATES);
|
||||
if (require.main === module) {
|
||||
listen(createServer(), PORT_CANDIDATES);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
PlatformLeadConfigurationError,
|
||||
PlatformLeadSubmissionError,
|
||||
createServer,
|
||||
forwardLeadToPlatform,
|
||||
platformLeadConfig
|
||||
};
|
||||
+326
@@ -0,0 +1,326 @@
|
||||
"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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user