diff --git a/src/lib/hustlerStufe.js b/src/lib/hustlerStufe.js new file mode 100644 index 0000000..ed1551f --- /dev/null +++ b/src/lib/hustlerStufe.js @@ -0,0 +1,69 @@ +// Stufen-Sperre der Hustler-Seite: EINE Quelle der Wahrheit dafür, was die Oberfläche schon zeigen darf. Der Dienst (workers-dienst) wächst Stufe für Stufe +// (P1, P3, P4, P6); die Oberfläche ist fertig und würde sonst Knöpfe und Bildschirme zeigen, deren Endpunkte noch 404 liefern. +// Stufe < 4: im Pult keine Antwort-Knöpfe (paste „Antwort des Kunden einfügen“, reply „Als Mensch antworten“, approve-preview, start-build). +// Stufe < 6: kein Keller mit den Bot-Leads (kein Bildschirm, kein Link, kein Abruf von /api/workers/botleads); die Keller-Etage zoomt wie jede andere. +// Kein React, kein DOM, kein Appwrite: unter Node testbar (loadModule). Es gibt keine Überschreibung zur Laufzeit (kein localStorage, keine Adresse): die Stufe ist +// zur Bauzeit festgelegt. Nur der Prüfstand (scripts/workers-harness) baut mit VITE_HUSTLER_STUFE=6, damit seine P4- und Keller-Szenarien weiter laufen. + +/** Ab dieser Stufe erscheinen die Antwort-Knöpfe im Pult (Dienst P4: paste, reply, approve-preview, start-build). */ +export const STUFE_ANTWORTEN = 4 +/** Ab dieser Stufe erscheint der Keller mit den Bot-Leads (Dienst P6: GET /botleads). */ +export const STUFE_KELLER = 6 + +const KLEINSTE = 1 +const GROESSTE = 9 + +/** + * Stufe aus einem rohen Wert: nur eine ganze Zahl 1..9 (als Zahl oder als Ziffer im Text, Leerraum ringsum erlaubt) gilt; alles andere + * (leer, Text, 0, 10, 6.5, 06, Infinity, undefined) ergibt `standard`. + */ +export function stufeAus(roh, standard) { + if (typeof roh === 'number') return Number.isInteger(roh) && roh >= KLEINSTE && roh <= GROESSTE ? roh : standard + if (typeof roh !== 'string') return standard + const text = roh.trim() + return /^[1-9]$/.test(text) ? Number(text) : standard +} + +// Der Wert aus dem Bau (Vite ersetzt import.meta.env.VITE_HUSTLER_STUFE beim Bauen, der Prüfstand setzt ihn über define). Unter Node gibt es import.meta.env nicht. +function buildWert() { + try { + return import.meta.env.VITE_HUSTLER_STUFE + } catch { + return undefined + } +} + +// 3 = Dienst P3 live; ab 4 (P4) erscheinen die Antwort-Knöpfe im Pult, ab 6 (P6) der Keller mit den Bot-Leads; beim jeweiligen Livegang hier hochsetzen +export const HUSTLER_STUFE = stufeAus(buildWert(), 3) + +// Eine übergebene Stufe (Parameter der Funktionen und Bausteine, vor allem für Tests); fehlt sie oder ist sie unbrauchbar, gilt HUSTLER_STUFE +const stufeOder = (stufe) => stufeAus(stufe, HUSTLER_STUFE) + +/** Zeigt das Pult die Antwort-Knöpfe (paste, reply, approve-preview, start-build)? Ab Stufe 4. */ +export const antwortenOffen = (stufe = HUSTLER_STUFE) => stufeOder(stufe) >= STUFE_ANTWORTEN + +/** Gibt es den Keller (Bildschirm Bot-Leads, Abruf von /botleads)? Ab Stufe 6. */ +export const kellerOffen = (stufe = HUSTLER_STUFE) => stufeOder(stufe) >= STUFE_KELLER + +/** Darf dieser Bildschirm (Wert von ?tab=) auf der Stufe gezeigt werden? Nur der Keller ('botleads') ist gesperrt, alles andere ist frei. */ +export const tabErlaubt = (tabId, stufe = HUSTLER_STUFE) => tabId !== 'botleads' || kellerOffen(stufe) + +// Adressen, unter denen die Hustler-Seite liegt: /hustler und die alten /workers und /leads (App.jsx leitet sie weiter, die Query bleibt) +const SEITEN_PFADE = ['/hustler', '/workers', '/leads'] + +/** + * Darf ein interner Link (aus der Spur im Dialog) auf der Stufe ein Link sein? Ein Link in den Keller (…/hustler?tab=botleads, auch über /workers und /leads) + * ist unterhalb der Keller-Stufe nur Text; alles andere bleibt ein Link. Externe Adressen berührt das nicht. + */ +export function linkErlaubt(href, stufe = HUSTLER_STUFE) { + if (kellerOffen(stufe)) return true + if (typeof href !== 'string' || !/^\/(?![/\\])/.test(href)) return true // nur interne Pfade (ein einzelner / am Anfang), nie //host oder eine ganze Adresse + let ziel + try { + ziel = new URL(href, 'https://hustler.invalid') + } catch { + return true + } + const pfad = ziel.pathname.replace(/\/+$/, '').toLowerCase() + return !(SEITEN_PFADE.includes(pfad) && ziel.searchParams.get('tab') === 'botleads') +} diff --git a/tests/unit/hustlerStufe.test.mjs b/tests/unit/hustlerStufe.test.mjs new file mode 100644 index 0000000..f62fc11 --- /dev/null +++ b/tests/unit/hustlerStufe.test.mjs @@ -0,0 +1,112 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import fs from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import esbuild from 'esbuild' +import { loadModule } from './load.mjs' + +// Stufen-Sperre der Hustler-Seite (src/lib/hustlerStufe.js): eine Quelle der Wahrheit, was die Oberfläche schon zeigen darf. +// Stufe 3 = Dienst P3 live (Stand der Produktion): kein Keller (Bot-Leads, P6), keine Antwort-Knöpfe im Pult (P4). Ab Stufe 4 kommen die Antwort-Knöpfe, ab 6 der Keller. +// Diese Datei prüft das Modul selbst (Stufe aus dem Bau, Schwellen, Bildschirme, Links). Die Bausteine prüfen ihre Sperre in ihren eigenen Tests, jeweils mit Stufe 3 (gesperrt) +// und Stufe 6 (offen): rowModel in workersPult.test.mjs, der Client in workersApi.test.mjs, Pult in workersPultUi.test.mjs, Haus in hustlerHochhausWelt.test.mjs, Seite in +// hustlerSeite.test.mjs, Spur-Links in hustlerDialog.test.mjs, der Prüfstand in workersHarness.test.mjs. +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..') +const read = (rel) => fs.readFileSync(path.join(ROOT, rel), 'utf8') + +const stufe = await loadModule('src/lib/hustlerStufe.js') +const { HUSTLER_STUFE, STUFE_ANTWORTEN, STUFE_KELLER, stufeAus, antwortenOffen, kellerOffen, tabErlaubt, linkErlaubt } = stufe + +// ---------- Stufe aus dem Bau ---------- + +test('stufeAus: nur eine ganze Zahl 1..9 gilt (Zahl oder einzelne Ziffer im Text, Leerraum außen egal), alles andere ergibt den Standard', () => { + for (let n = 1; n <= 9; n += 1) { + assert.equal(stufeAus(n, 3), n); assert.equal(stufeAus(String(n), 3), n); assert.equal(stufeAus(` ${n}\n`, 3), n) + } + const ungueltig = [undefined, null, '', ' ', '0', 0, '10', 10, '12', '06', '6.0', '6.5', 6.5, '-1', -1, '+6', 'sechs', 'abc', '6a', 'a6', '6 7', NaN, Infinity, -Infinity, true, false, {}, [], [6], () => 6, '1', '٦'] + for (const roh of ungueltig) assert.equal(stufeAus(roh, 3), 3, `Wert ${String(roh)} (${typeof roh})`) + // der Standard ist der übergebene + assert.equal(stufeAus('x', 5), 5); assert.equal(stufeAus(undefined, 7), 7) +}) + +test('Standard: ohne Wert aus dem Bau gilt die Stufe aus dem Quelltext, einmal und an einer Stelle; sie liegt in 1..9', () => { + const quelltext = read('src/lib/hustlerStufe.js') + const treffer = [...quelltext.matchAll(/export const HUSTLER_STUFE = stufeAus\(buildWert\(\), (\d)\)/g)] + assert.equal(treffer.length, 1, 'genau eine Zeile legt die Stufe fest') + assert.equal(HUSTLER_STUFE, Number(treffer[0][1])) + assert.ok(Number.isInteger(HUSTLER_STUFE) && HUSTLER_STUFE >= 1 && HUSTLER_STUFE <= 9) + assert.ok(quelltext.includes('beim jeweiligen Livegang hier hochsetzen')) + assert.equal(STUFE_ANTWORTEN, 4); assert.equal(STUFE_KELLER, 6) +}) + +// Der Wert aus dem Bau (Vite: import.meta.env.VITE_HUSTLER_STUFE, Prüfstand: define von esbuild) wird beim Bauen eingesetzt. Hier derselbe Weg mit esbuild. +async function stufeNachBau(define) { + const out = await esbuild.build({ + stdin: { contents: "export { HUSTLER_STUFE, antwortenOffen, kellerOffen } from './src/lib/hustlerStufe.js'", resolveDir: ROOT, loader: 'js' }, + bundle: true, format: 'esm', platform: 'neutral', write: false, define, + }) + return import(`data:text/javascript;base64,${Buffer.from(out.outputFiles[0].text).toString('base64')}`) +} + +test('Bau: VITE_HUSTLER_STUFE überschreibt die Stufe zur Bauzeit (Prüfstand); ungültige Werte fallen auf den Standard zurück', async () => { + for (const [roh, erwartet] of [['6', 6], ['4', 4], ['9', 9], ['1', 1], [' 6 ', 6], ['3', 3], ['0', HUSTLER_STUFE], ['10', HUSTLER_STUFE], ['abc', HUSTLER_STUFE], ['', HUSTLER_STUFE], ['6.5', HUSTLER_STUFE], ['-2', HUSTLER_STUFE]]) { + const m = await stufeNachBau({ 'import.meta.env.VITE_HUSTLER_STUFE': JSON.stringify(roh) }) + assert.equal(m.HUSTLER_STUFE, erwartet, `VITE_HUSTLER_STUFE=${JSON.stringify(roh)}`) + assert.equal(m.kellerOffen(), erwartet >= 6); assert.equal(m.antwortenOffen(), erwartet >= 4) + } + // nicht gesetzt (Vite: undefined) und ohne import.meta.env (Node): der Standard + assert.equal((await stufeNachBau({ 'import.meta.env.VITE_HUSTLER_STUFE': 'undefined' })).HUSTLER_STUFE, HUSTLER_STUFE) + assert.equal((await stufeNachBau({})).HUSTLER_STUFE, HUSTLER_STUFE) +}) + +test('keine Überschreibung zur Laufzeit: kein localStorage, keine Adresse, keine Umgebung außer dem Bau; VITE_HUSTLER_STUFE steht nur in hustlerStufe.js', () => { + const quelltext = read('src/lib/hustlerStufe.js').split('\n').filter((z) => !z.trimStart().startsWith('//')).join('\n') + for (const verboten of ['localStorage', 'sessionStorage', 'location', 'URLSearchParams', 'document', 'window', 'process.env', 'cookie']) assert.ok(!quelltext.includes(verboten), verboten) + // genau ein Zugriff auf die Umgebung + assert.equal(quelltext.match(/import\.meta\.env/g).length, 1) + const walk = (dir) => fs.readdirSync(dir, { withFileTypes: true }).flatMap((e) => (e.isDirectory() ? walk(path.join(dir, e.name)) : [path.join(dir, e.name)])) + const quellen = walk(path.join(ROOT, 'src')).filter((f) => /\.(jsx?|mjs)$/.test(f) && !f.endsWith(path.join('lib', 'hustlerStufe.js'))) + assert.ok(quellen.length > 50) + for (const f of quellen) assert.ok(!fs.readFileSync(f, 'utf8').includes('VITE_HUSTLER_STUFE'), path.relative(ROOT, f)) +}) + +// ---------- Schwellen ---------- + +test('Schwellen: Antwort-Knöpfe ab Stufe 4, Keller ab Stufe 6; unbrauchbare Stufe gilt als HUSTLER_STUFE', () => { + for (let n = 1; n <= 9; n += 1) { + assert.equal(antwortenOffen(n), n >= 4, `antwortenOffen(${n})`) + assert.equal(kellerOffen(n), n >= 6, `kellerOffen(${n})`) + } + assert.deepEqual([antwortenOffen(3), kellerOffen(3)], [false, false]) + assert.deepEqual([antwortenOffen(6), kellerOffen(6)], [true, true]) + for (const kaputt of [undefined, null, 'x', 0, 12, NaN]) { + assert.equal(antwortenOffen(kaputt), antwortenOffen(HUSTLER_STUFE)); assert.equal(kellerOffen(kaputt), kellerOffen(HUSTLER_STUFE)) + } + assert.equal(antwortenOffen(), antwortenOffen(HUSTLER_STUFE)); assert.equal(kellerOffen(), kellerOffen(HUSTLER_STUFE)) +}) + +// ---------- Bildschirme und Deeplinks ---------- + +test('tabErlaubt: nur der Keller (botleads) ist gesperrt, und nur unterhalb von Stufe 6', () => { + for (const s of [1, 3, 4, 5]) { + assert.equal(tabErlaubt('botleads', s), false, `Stufe ${s}`) + for (const frei of ['hochhaus', 'freigabe', '', 'unbekannt', undefined]) assert.equal(tabErlaubt(frei, s), true, `${frei} auf Stufe ${s}`) + } + for (const s of [6, 7, 9]) assert.equal(tabErlaubt('botleads', s), true, `Stufe ${s}`) +}) + +test('linkErlaubt: Links in den Keller sind unterhalb von Stufe 6 kein Link, alles andere bleibt unberührt', () => { + const keller = [ + '/hustler?tab=botleads', '/hustler?tab=botleads&bot=abc123', '/hustler/?tab=botleads', '/Hustler?tab=botleads', '/hustler?bot=x&tab=botleads', + '/workers?tab=botleads&bot=x', '/leads?tab=botleads', '/hustler?tab=botleads#oben', + ] + for (const href of keller) { + assert.equal(linkErlaubt(href, 3), false, `Stufe 3: ${href}`); assert.equal(linkErlaubt(href, 5), false, `Stufe 5: ${href}`) + assert.equal(linkErlaubt(href, 6), true, `Stufe 6: ${href}`) + } + const frei = [ + '/hustler?tab=freigabe', '/hustler', '/hustler?tab=hochhaus', '/hustler?bot=abc123', '/tickets/100001', '/customers/abc', '/other?tab=botleads', '/hustler/anderes?tab=botleads', + 'https://beispiel.de/hustler?tab=botleads', 'http://x.example/?tab=botleads', '//evil.example/hustler?tab=botleads', '/\\evil.example/hustler?tab=botleads', '', 'kein-pfad', null, undefined, 42, + ] + for (const href of frei) { assert.equal(linkErlaubt(href, 3), true, `Stufe 3: ${String(href)}`); assert.equal(linkErlaubt(href, 6), true, `Stufe 6: ${String(href)}`) } +})