Stand vor Einführung des Nacht-Agenten

This commit is contained in:
Kundencenter 2026-09-27 00:51:32 +02:00
commit 4763548bfb
168 changed files with 12726 additions and 0 deletions

View file

@ -0,0 +1,22 @@
{
"name": "@kc/connector-keyhelp",
"private": true,
"version": "1.0.0",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "tsc -p tsconfig.json",
"typecheck": "tsc -p tsconfig.json --noEmit",
"test": "vitest run"
},
"dependencies": {
"@kc/connector-sdk": "workspace:*",
"undici": "^8.11.2"
},
"devDependencies": {
"@types/node": "^26.6.3",
"typescript": "^7.0.2",
"vitest": "^5.0.2"
}
}

View file

@ -0,0 +1,300 @@
import { createHash } from 'node:crypto';
import { Agent, fetch as undiciFetch } from 'undici';
import {
ConnectorError, CONTRACT_VERSION, httpJson, maskKey,
type ActionName, type Capability, type CatalogItem, type ChildAccess, type ChildKind, type Connector, type ImportableCustomer, type NewCatalogSpec, type ConnectorContext, type HttpOptions, type NormalizedChild, type NormalizedResource, type ProvisionContext,
} from '@kc/connector-sdk';
/**
* KeyHelp-Connector auf Basis der offiziellen OpenAPI-Definition "KeyHelp RESTful API 2.15" (docs/api-specs/).
* Provider-Rohmodelle bleiben in dieser Datei. Einheiten von Speicher/Traffic sind in der Definition nicht angegeben:
* angenommen wird Byte (an der Instanz zu prüfen).
*/
interface RawClient {
id: number; status?: number; username: string; email?: string; language?: string; notes?: string; id_hosting_plan?: number; created_at?: string; document_root?: string;
is_suspended?: boolean; suspend_on?: string | null; delete_on?: string | null; contact_data?: { company?: string; first_name?: string; last_name?: string };
permissions?: Record<string, boolean>; resource_limits?: Record<string, number>;
}
type Stat = { value: number; max: number };
interface RawPlan { id: number; name: string; resources?: Record<string, number>; permissions?: Record<string, boolean> }
type Row = Record<string, any>;
export interface KeyHelpDeps extends HttpOptions { now?: () => Date }
const STATUS_LABEL: Record<number, string> = { 0: 'unbekannt', 1: 'ok', 2: 'Fehler', 3: 'Konfiguration neu', 4: 'Konfiguration wird aktualisiert' };
const PERM_LABEL: Record<string, string> = { ftp: 'FTP', php: 'PHP', ssh: 'SSH', backup: 'Backup', panel_access: 'Panel-Zugang', file_manager: 'Dateimanager', dns_editor: 'DNS-Editor', certificate_management: 'Zertifikate', domain_email: 'E-Mail-Domains', applications: 'Anwendungen' };
const RES_LABEL: Record<string, string> = { disk_space: 'Speicher', traffic: 'Traffic', domains: 'Domains', subdomains: 'Subdomains', email_accounts: 'Postfächer', email_addresses: 'E-Mail-Adressen', databases: 'Datenbanken', ftp_users: 'FTP-Benutzer', scheduled_tasks: 'Cronjobs' };
// ---- Eingabeprüfung für Unterobjekte (Kundeneingaben laufen bis zum Panel: strikt prüfen) ----
const DOMAIN_RE = /^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9-]{2,63}$/i;
const LOCAL_RE = /^[a-z0-9._+-]{1,64}$/i;
const DBNAME_RE = /^[a-z0-9_]{1,64}$/;
const FTPUSER_RE = /^[a-z0-9._-]{1,32}$/i;
const bad = (m: string) => new ConnectorError('INVALID_INPUT', m);
const str = (v: unknown, name: string, max = 200): string => { if (typeof v !== 'string' || !v.trim() || v.length > max || /[\0\r\n]/.test(v)) throw bad(`${name} fehlt oder ist ungültig`); return v.trim(); };
const optStr = (v: unknown, name: string, max = 200) => (v === undefined || v === null || v === '' ? undefined : str(v, name, max));
const password = (s: Record<string, string> | undefined): string => { const p = s?.password; if (!p || p.length > 128 || /[\0\r\n]/.test(p)) throw bad('Passwort fehlt oder ist ungültig'); return p; };
const homeDir = (v: unknown): string | undefined => { const h = optStr(v, 'Verzeichnis', 300); if (h === undefined) return undefined; if (!/^\/(www|files)(\/|$)/.test(h) || h.split('/').includes('..')) throw bad('Das Verzeichnis muss mit /www/ oder /files beginnen'); return h; };
/** Zusammengehörigkeit prüfen: Der Schlüssel des Panels ist ein Administratorzugang, die Mandantentrennung muss deshalb HIER passieren. */
const owned = (row: Row | null | undefined, clientId: string): Row => { if (!row || String(row.id_user) !== clientId) throw new ConnectorError('NOT_FOUND'); return row; };
/** KeyHelp liefert Zeitstempel ohne Zeitzone: einheitlich als UTC gelesen (Annahme, an der Instanz zu prüfen). */
const iso = (s: string | null | undefined): string | null => { if (!s) return null; const t = /[zZ]|[+-]\d\d:?\d\d$/.test(s) ? s : `${s.trim().replace(' ', 'T')}Z`; const d = new Date(t); return Number.isNaN(d.getTime()) ? null : d.toISOString(); };
const digits = (s: string | undefined) => (s ?? '').replace(/\D/g, '');
export const usernameFor = (c: ProvisionContext | undefined, key: string): string =>
c?.contractNumber && digits(c.contractNumber) ? `kc${digits(c.contractNumber)}` : `kc${createHash('sha256').update(key).digest('hex').slice(0, 8)}`;
const noteFor = (c: ProvisionContext) => `Kundencenter ${c.customerNumber} ${c.contractNumber}`;
async function mapLimit<T, R>(items: T[], limit: number, fn: (x: T) => Promise<R>): Promise<R[]> {
const out: R[] = new Array(items.length); let i = 0;
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, async () => { while (i < items.length) { const k = i++; out[k] = await fn(items[k]!); } }));
return out;
}
const fmtRes = (k: string, v: number) => (v < 0 ? 'unbegrenzt' : k === 'disk_space' || k === 'traffic' ? `${Math.round((v / 1024 / 1024 / 1024) * 10) / 10} GB` : String(v));
/** Tarif → Produktvorlage (Limits und Rechte). */
function planItem(p: RawPlan): CatalogItem {
return {
externalRef: `plan:${p.id}`, name: p.name, description: null, category: 'hosting', fixed: true, providerActive: true, program: 'KeyHelp',
features: [...Object.entries(p.resources ?? {}).filter(([k]) => RES_LABEL[k]).map(([k, v]) => `${RES_LABEL[k]}: ${fmtRes(k, v)}`), ...Object.entries(p.permissions ?? {}).filter(([k, v]) => v && PERM_LABEL[k]).map(([k]) => PERM_LABEL[k]!)],
meta: Object.fromEntries(Object.entries(p.resources ?? {}).map(([k, v]) => [k, v])),
provisioning: { hostingPlanId: p.id, language: 'de', createSystemDomain: true, sendLoginCredentials: true },
};
}
export function createKeyHelpConnector(deps: KeyHelpDeps = {}): Connector {
const now = deps.now ?? (() => new Date());
const agents = new Map<string, Agent>();
const base = (ctx: ConnectorContext) => {
const u = String(ctx.config.baseUrl ?? '').replace(/\/+$/, '').replace(/\/api\/v2$/, '');
if (!/^https:\/\//.test(u)) throw new ConnectorError('BAD_CONFIG', 'baseUrl muss mit https:// beginnen');
return u;
};
const apiKey = (ctx: ConnectorContext) => { const k = ctx.secrets.apiKey; if (!k) throw new ConnectorError('BAD_CONFIG', 'API-Schlüssel fehlt');
const bad = [...k].find((c) => c.charCodeAt(0) < 33 || c.charCodeAt(0) > 126);
if (bad !== undefined) throw new ConnectorError('BAD_CONFIG', `API-Schlüssel enthält ein ungültiges Zeichen „${bad}“ (Leerzeichen, Zeilenumbruch oder „…“ vom Kürzen?). Bitte den vollständigen Schlüssel neu kopieren.`);
return k; };
/** TLS: Standard ist volle Prüfung. Für selbstsignierte Zertifikate: Fingerabdruck festlegen (bevorzugt) oder Prüfung abschalten (unsicher). */
function fetchFor(ctx: ConnectorContext): typeof fetch {
if (deps.fetchImpl) return deps.fetchImpl;
const fp = String(ctx.config.tlsFingerprint ?? '').replace(/[:\s]/g, '').toLowerCase();
const verify = String(ctx.config.verifyTls ?? 'true').toLowerCase() !== 'false';
if (verify && !fp) return fetch;
if (fp && !/^[0-9a-f]{64}$/.test(fp)) throw new ConnectorError('BAD_CONFIG', 'tlsFingerprint muss ein SHA-256-Fingerabdruck sein (64 Hex-Zeichen)');
const key = `${base(ctx)}|${fp}|${verify}`; let a = agents.get(key);
if (!a) {
a = new Agent({ connect: { rejectUnauthorized: false, ...(fp ? { checkServerIdentity: (_h: string, cert: { fingerprint256?: string }) => ((cert.fingerprint256 ?? '').replace(/:/g, '').toLowerCase() === fp ? undefined : new Error('TLS-Fingerabdruck stimmt nicht überein')) } : {}) } });
agents.set(key, a);
}
const agent = a; return ((url: string, init: RequestInit) => undiciFetch(url, { ...(init as object), dispatcher: agent } as never)) as unknown as typeof fetch;
}
/** Aufruf mit Wiederholung bei Webserver-Neustart (500/503) für Lesen und Ziel-Zustands-Änderungen; Anlage (POST) wird nie automatisch wiederholt. */
function call<T>(ctx: ConnectorContext, method: 'GET' | 'POST' | 'PUT' | 'DELETE', path: string, body?: unknown): Promise<T> {
return httpJson<T>(method, `${base(ctx)}/api/v2${path}`, { headers: { 'x-api-key': apiKey(ctx) }, body },
{ ...deps, fetchImpl: fetchFor(ctx), retries: deps.retries ?? 3, baseDelayMs: deps.baseDelayMs ?? 1500, retryWrites: method === 'PUT' || method === 'DELETE' });
}
const optional = async <T>(p: Promise<T>): Promise<T | null> => { try { return await p; } catch (e) { if (e instanceof ConnectorError && e.code === 'NOT_FOUND') return null; throw e; } };
// ---- Normalisierung ------------------------------------------------------------------
const clientName = (c: RawClient) => `${c.contact_data?.company?.trim() || c.username} · ${c.username}`;
function mapClient(c: RawClient, stats: Record<string, Stat> | null, plans: Map<number, string>): NormalizedResource {
const usage: Record<string, number | string | null> = {}; const limits: Record<string, number | string | null> = {};
for (const [k, v] of Object.entries(stats ?? {})) { if (v && typeof v.value === 'number') usage[k] = v.value; if (v && typeof v.max === 'number') limits[k] = v.max < 0 ? null : v.max; } // negative Grenze = unbegrenzt (angenommen)
const perms = Object.entries(c.permissions ?? {}).filter(([, v]) => v).map(([k]) => PERM_LABEL[k]).filter(Boolean);
return {
externalRef: String(c.id), type: 'hosting_account', name: clientName(c), state: c.is_suspended ? 'suspended' : c.status === 2 ? 'error' : 'active',
validFrom: iso(c.created_at), validUntil: null, limits, usage,
details: { username: c.username, plan: plans.get(Number(c.id_hosting_plan)) ?? (c.id_hosting_plan ? `Tarif ${c.id_hosting_plan}` : null), planId: c.id_hosting_plan ?? null, providerStatus: STATUS_LABEL[c.status ?? 0] ?? null,
scheduledSuspendOn: c.suspend_on || null, scheduledDeleteOn: c.delete_on || null, features: perms, units: 'Speicher/Traffic in Byte (angenommen)' },
};
}
const state = (r: Row): NormalizedChild['state'] => (r.is_disabled ? 'disabled' : r.status === 2 ? 'error' : r.status === 3 || r.status === 4 ? 'pending' : 'active');
function mapChild(kind: ChildKind, r: Row): NormalizedChild {
if (kind === 'domain') return { id: String(r.id), kind, name: r.domain_utf8 || r.domain, state: state(r), details: { subdomain: !!r.is_subdomain, system: !!r.is_system_domain, phpVersion: r.php_version || 'System', letsEncrypt: !!r.security?.lets_encrypt, forceHttps: !!r.security?.force_https, hsts: !!r.security?.is_hsts, emailDomain: !!r.is_email_domain, parentId: r.id_parent_domain ? String(r.id_parent_domain) : null } };
if (kind === 'email') return { id: String(r.id), kind, name: r.email_utf8 || r.email, state: state(r), details: { sizeBytes: r.size ?? null, maxSizeBytes: r.max_size ?? null, aliases: r.aliases_utf8 ?? r.aliases ?? [], forwardings: r.forwardings_utf8 ?? r.forwardings ?? [], catchAll: !!r.catch_all, description: r.description ?? '' } };
if (kind === 'database') return { id: String(r.id), kind, name: r.database_name, state: 'active', details: { user: r.database_username, sizeBytes: r.size ?? null, description: r.description ?? '', remoteHosts: r.remote_hosts ?? [] } };
if (kind === 'ftp') return { id: String(r.id), kind, name: r.username, state: state(r), details: { home: r.home_directory, description: r.description ?? '' } };
const until = iso(r.valid_till);
return { id: String(r.id), kind, name: r.name, state: until && new Date(until) < now() ? 'expired' : 'active', validUntil: until, details: { issuer: r.issuer ?? null, securedDomains: r.secured_domains ?? [], usedByDomains: r.usage?.domain_count ?? null } };
}
const PATH: Record<ChildKind, string> = { domain: 'domains', email: 'emails', database: 'databases', ftp: 'ftp-users', certificate: 'certificates' };
const LISTKEY: Record<ChildKind, string> = { domain: 'domains', email: 'emails', database: 'databases', ftp: 'ftp_users', certificate: 'certificates' };
const NAMEKEY: Record<ChildKind, string> = { domain: 'domain', email: 'email', database: 'database_name', ftp: 'username', certificate: 'name' };
async function getClient(ctx: ConnectorContext, id: string): Promise<RawClient> { return call<RawClient>(ctx, 'GET', `/clients/${encodeURIComponent(id)}`); }
async function plansMap(ctx: ConnectorContext): Promise<Map<number, string>> { try { return new Map((await call<RawPlan[]>(ctx, 'GET', '/hosting-plans')).map((p) => [p.id, p.name])); } catch { return new Map(); } }
// ---- Unterobjekte ------------------------------------------------------------------
const children: ChildAccess = {
async kinds(ctx, parentRef) {
const c = await getClient(ctx, parentRef); const p = c.permissions ?? {};
return [{ kind: 'domain', canWrite: true }, { kind: 'email', canWrite: true }, { kind: 'database', canWrite: true }, { kind: 'ftp', canWrite: p.ftp !== false }, { kind: 'certificate', canWrite: false }];
},
async list(ctx, parentRef, kind) {
const res = await call<Record<string, Row[]>>(ctx, 'GET', `/clients/${encodeURIComponent(parentRef)}/resources`);
const rows = res?.[LISTKEY[kind]]; if (!Array.isArray(rows)) throw new ConnectorError('INVALID_RESPONSE');
return rows.filter((r) => String(r.id_user) === parentRef || r.id_user === undefined).map((r) => mapChild(kind, r)); // doppelt abgesichert: nur Objekte dieses Kunden
},
async act(ctx, req) {
const { parentRef: cid, kind, op, id } = req; const d = req.data ?? {}; const path = PATH[kind];
if (kind === 'certificate') throw new ConnectorError('UNSUPPORTED', 'Zertifikate sind nur lesbar');
const fetchOwned = async (): Promise<Row> => owned(await optional(call<Row>(ctx, 'GET', `/${path}/${encodeURIComponent(str(id, 'id', 40))}`)), cid);
if (op === 'delete') {
const row = await optional(call<Row>(ctx, 'GET', `/${path}/${encodeURIComponent(str(id, 'id', 40))}`)); if (!row) return {}; // schon weg: Ziel erreicht (idempotent)
owned(row, cid);
if (kind === 'domain' && row.is_system_domain) throw bad('Die System-Domain kann nicht gelöscht werden');
await call(ctx, 'DELETE', `/${path}/${encodeURIComponent(String(row.id))}`); return {};
}
if (op === 'update') {
const row = await fetchOwned(); const body: Row = {};
if (kind === 'domain') { if (d.phpVersion !== undefined) body.php_version = optStr(d.phpVersion, 'PHP-Version', 20) ?? ''; const sec: Row = {}; for (const [k, f] of [['letsEncrypt', 'lets_encrypt'], ['forceHttps', 'force_https'], ['hsts', 'is_hsts']] as const) if (typeof d[k] === 'boolean') sec[f] = d[k]; if (Object.keys(sec).length) body.security = sec; if (typeof d.disabled === 'boolean') body.is_disabled = d.disabled; }
if (kind === 'email') { if (req.secrets?.password) body.password = password(req.secrets); if (d.maxSizeBytes !== undefined) { const n = Number(d.maxSizeBytes); if (!Number.isInteger(n) || n < 0) throw bad('Postfachgröße ungültig'); body.max_size = n; } if (d.description !== undefined) body.description = optStr(d.description, 'Beschreibung', 200) ?? ''; if (typeof d.catchAll === 'boolean') body.catch_all = d.catchAll;
if (Array.isArray(d.forwardings)) body.forwardings = d.forwardings.map((x) => str(x, 'Weiterleitung', 254)); if (Array.isArray(d.aliases)) body.aliases = d.aliases.map((x) => str(x, 'Alias', 254)); }
if (kind === 'database') { if (req.secrets?.password) body.password = password(req.secrets); if (d.description !== undefined) body.description = optStr(d.description, 'Beschreibung', 200) ?? ''; }
if (kind === 'ftp') { if (req.secrets?.password) body.password = password(req.secrets); if (d.home !== undefined) body.home_directory = homeDir(d.home); if (d.description !== undefined) body.description = optStr(d.description, 'Beschreibung', 200) ?? ''; }
if (!Object.keys(body).length) throw bad('Keine Änderung angegeben');
await call(ctx, 'PUT', `/${path}/${encodeURIComponent(String(row.id))}`, body);
return { child: mapChild(kind, await call<Row>(ctx, 'GET', `/${path}/${encodeURIComponent(String(row.id))}`)) };
}
// ---- anlegen: id_user wird IMMER auf den Kunden gesetzt; existiert das Objekt schon (Wiederholung), wird es übernommen ----
const existingBy = async (name: string) => { const r = await optional(call<Row>(ctx, 'GET', `/${path}/name/${encodeURIComponent(name)}`)); if (r && String(r.id_user) !== cid) throw new ConnectorError('CONFLICT', 'Der Name ist bereits vergeben'); return r; };
let body: Row; let natural: string | undefined;
if (kind === 'domain') {
const name = str(d.domain, 'Domain', 253).toLowerCase(); if (!DOMAIN_RE.test(name)) throw bad('Ungültiger Domainname'); natural = name;
let parent = 0; if (d.parentDomainId !== undefined && d.parentDomainId !== null && d.parentDomainId !== 0) { const p = owned(await optional(call<Row>(ctx, 'GET', `/domains/${encodeURIComponent(str(String(d.parentDomainId), 'Hauptdomain', 20))}`)), cid); parent = Number(p.id); if (!name.endsWith(`.${String(p.domain).toLowerCase()}`)) throw bad('Die Subdomain muss zur Hauptdomain passen'); }
body = { id_user: Number(cid), id_parent_domain: parent, domain: name, ...(d.phpVersion ? { php_version: str(d.phpVersion, 'PHP-Version', 20) } : {}), ...(typeof d.emailDomain === 'boolean' ? { is_email_domain: d.emailDomain } : {}), ...(typeof d.letsEncrypt === 'boolean' ? { security: { lets_encrypt: d.letsEncrypt, force_https: !!d.forceHttps } } : {}) };
} else if (kind === 'email') {
const local = str(d.local, 'Postfach', 64); const dom = str(d.domain, 'Domain', 253).toLowerCase(); if (!LOCAL_RE.test(local) || !DOMAIN_RE.test(dom)) throw bad('Ungültige E-Mail-Adresse'); natural = `${local}@${dom}`.toLowerCase();
const dRow = owned(await optional(call<Row>(ctx, 'GET', `/domains/name/${encodeURIComponent(dom)}`)), cid); void dRow; // Domain muss dem Kunden gehören
body = { id_user: Number(cid), email: natural, password: password(req.secrets), ...(d.description ? { description: str(d.description, 'Beschreibung') } : {}), ...(d.maxSizeBytes !== undefined ? { max_size: Number(d.maxSizeBytes) } : {}) };
} else if (kind === 'database') {
const n = optStr(d.name, 'Datenbankname', 64); if (n && !DBNAME_RE.test(n)) throw bad('Datenbankname: nur a-z, 0-9 und Unterstrich'); natural = n;
body = { id_user: Number(cid), ...(n ? { database_name: n, database_username: n } : {}), password: password(req.secrets), ...(d.description ? { description: str(d.description, 'Beschreibung') } : {}) };
} else {
const u = optStr(d.username, 'FTP-Benutzer', 32); if (u && !FTPUSER_RE.test(u)) throw bad('FTP-Benutzername ungültig'); natural = u;
body = { id_user: Number(cid), ...(u ? { username: u } : {}), password: password(req.secrets), home_directory: homeDir(d.home) ?? '/www/', ...(d.description ? { description: str(d.description, 'Beschreibung') } : {}) };
}
if (natural) { const ex = await existingBy(natural); if (ex) return { child: mapChild(kind, ex) }; }
const created = await call<{ id: number }>(ctx, 'POST', `/${path}`, body);
if (!created || typeof created.id !== 'number') throw new ConnectorError('INVALID_RESPONSE', 'keine ID');
return { child: mapChild(kind, await call<Row>(ctx, 'GET', `/${path}/${created.id}`)) };
},
};
void NAMEKEY;
const capabilities: Capability[] = ['customers.list', 'catalog.write', 'catalog.list', 'resources.list', 'resources.get', 'status.read', 'usage.read', 'lifecycle.create', 'lifecycle.suspend', 'lifecycle.unsuspend', 'lifecycle.terminate', 'plan.change', 'sso.login', 'children.read', 'children.write'];
return {
contractVersion: CONTRACT_VERSION, key: 'keyhelp', displayName: 'KeyHelp (Webhosting)',
configFields: [
{ name: 'baseUrl', label: 'KeyHelp-Adresse', required: true, placeholder: 'https://keyhelp.example.de', help: 'Die Adresse, unter der Sie KeyHelp im Browser öffnen (mit https://).' },
{ name: 'apiKey', label: 'API-Schlüssel', secret: true, required: true, help: 'In KeyHelp unter Konfiguration → API anlegen und auf die IP-Adresse dieses Servers beschränken.' },
{ name: 'verifyTls', label: 'Zertifikat von KeyHelp prüfen', advanced: true, options: [{ value: 'true', label: 'Ja, prüfen (empfohlen)' }, { value: 'false', label: 'Nein, nicht prüfen (unsicher)' }], help: 'Nur ändern, wenn KeyHelp ein selbstsigniertes Zertifikat verwendet. Besser ist es, statt „nicht prüfen“ den Fingerabdruck unten einzutragen.' },
{ name: 'tlsFingerprint', label: 'Zertifikat-Fingerabdruck (SHA-256)', advanced: true, placeholder: 'AB:CD:EF:…', help: 'Nur bei selbstsigniertem Zertifikat: Das Kundencenter vertraut dann genau diesem Zertifikat. Mit „Vom Server holen“ eintragen und mit KeyHelp abgleichen.' },
],
capabilities: () => capabilities,
async healthCheck(ctx) {
const t0 = Date.now();
try {
await call(ctx, 'GET', '/ping'); // reines Erreichbarkeits-Signal (kein Wiederholen bei Ausfall)
let version: string | undefined; try { const s = await call<{ meta?: { panel_version?: string; api_version?: string } }>(ctx, 'GET', '/server'); version = s?.meta?.panel_version ? `KeyHelp ${s.meta.panel_version} (API ${s.meta.api_version ?? '?'})` : undefined; } catch { /* Version nur informativ */ }
return { ok: true, latencyMs: Date.now() - t0, version };
} catch (e) { return { ok: false, latencyMs: Date.now() - t0, message: e instanceof ConnectorError ? e.message : 'Unbekannter Fehler' }; }
},
/** Alle Kunden mit Nutzung (Statistik je Kunde, begrenzt parallel). Ausfall einzelner Statistiken bricht den Abgleich nicht ab. */
async listResources(ctx) {
const [clients, plans] = await Promise.all([call<RawClient[]>(ctx, 'GET', '/clients'), plansMap(ctx)]);
if (!Array.isArray(clients)) throw new ConnectorError('INVALID_RESPONSE');
return mapLimit(clients, 4, async (c) => { const st = await call<Record<string, Stat>>(ctx, 'GET', `/clients/${c.id}/stats`).catch(() => null); return mapClient(c, st, plans); });
},
/** Hosting-Tarife als Produktvorlagen (Limits und Rechte des Tarifs). */
async listCatalog(ctx) {
const plans = await call<RawPlan[]>(ctx, 'GET', '/hosting-plans'); if (!Array.isArray(plans)) throw new ConnectorError('INVALID_RESPONSE');
return plans.map(planItem);
},
/** Kunden (Hosting-Konten) mit Kontaktdaten und Tarif für die Übernahme. */
async listCustomers(ctx) {
const [clients, plans] = await Promise.all([call<RawClient[]>(ctx, 'GET', '/clients'), plansMap(ctx)]);
if (!Array.isArray(clients)) throw new ConnectorError('INVALID_RESPONSE');
const t = (v: unknown) => (typeof v === 'string' && v.trim() ? v.trim() : null);
return clients.map((c): ImportableCustomer => {
const cd = (c.contact_data ?? {}) as Row; const person = [t(cd.first_name), t(cd.last_name)].filter(Boolean).join(' ');
return { externalRef: String(c.id), displayName: t(cd.company) ?? (person || c.username), company: t(cd.company), firstName: t(cd.first_name), lastName: t(cd.last_name), email: t(c.email), phone: t(cd.telephone),
address: { street: t(cd.address), zip: t(cd.zip), city: t(cd.city), state: t(cd.state), country: t(cd.country) }, legacyNumber: t(cd.client_id),
planRef: c.id_hosting_plan ? `plan:${c.id_hosting_plan}` : null, planName: plans.get(Number(c.id_hosting_plan)) ?? null, state: c.is_suspended ? 'suspended' : 'active', createdAt: iso(c.created_at) };
});
},
/**
* Neuen Hosting-Tarif anlegen. Größen werden als Byte gesendet (Annahme, an der Instanz prüfen). "Unbegrenzt" gibt es nur, wenn die Instanz
* es bereits kennt (negativer Wert in bestehenden Tarifen/Konten); sonst wird abgelehnt statt zu raten.
*/
async createCatalogItem(ctx, spec) {
const name = str(spec.name, 'Name', 100); const existing = await call<RawPlan[]>(ctx, 'GET', '/hosting-plans');
if (existing.some((p) => p.name.toLowerCase() === name.toLowerCase())) throw new ConnectorError('CONFLICT', 'Es gibt bereits einen Tarif mit diesem Namen');
const clients = await call<RawClient[]>(ctx, 'GET', '/clients').catch(() => [] as RawClient[]);
const unlimitedKnown = existing.some((p) => Object.values(p.resources ?? {}).some((v) => v < 0)) || clients.some((c) => Object.values(c.resource_limits ?? {}).some((v) => v < 0));
const GB = 1024 ** 3; const map: [keyof NewCatalogSpec['limits'], string, boolean][] = [['diskSpaceGb', 'disk_space', true], ['trafficGb', 'traffic', true], ['domains', 'domains', false], ['subdomains', 'subdomains', false], ['emailAccounts', 'email_accounts', false], ['emailAddresses', 'email_addresses', false], ['emailForwardings', 'email_forwardings', false], ['databases', 'databases', false], ['ftpUsers', 'ftp_users', false], ['scheduledTasks', 'scheduled_tasks', false]];
const resources: Record<string, number> = {};
for (const [k, api, isGb] of map) {
const v = spec.limits?.[k]; if (v === undefined) continue;
if (v === null) { if (!unlimitedKnown) throw bad(`"Unbegrenzt" (${RES_LABEL[api]}) wird von dieser Instanz noch nicht erkannt. Bitte eine Zahl angeben.`); resources[api] = -1; continue; }
if (typeof v !== 'number' || !Number.isFinite(v) || v < 0 || v > 1_000_000) throw bad(`${RES_LABEL[api]}: ungültiger Wert`);
resources[api] = isGb ? Math.round(v * GB) : Math.round(v);
}
const permissions: Record<string, boolean> = {}; for (const [k, v] of Object.entries(spec.permissions ?? {})) if (/^[a-z_]{2,40}$/.test(k) && typeof v === 'boolean') permissions[k] = v;
const created = await call<{ id: number }>(ctx, 'POST', '/hosting-plans', { name, ...(Object.keys(resources).length ? { resources } : {}), ...(Object.keys(permissions).length ? { permissions } : {}) });
if (!created || typeof created.id !== 'number') throw new ConnectorError('INVALID_RESPONSE', 'keine Tarif-ID');
return planItem(await call<RawPlan>(ctx, 'GET', `/hosting-plans/${created.id}`));
},
validateProvisioning(p) {
if (p.hostingPlanId !== undefined && p.hostingPlanId !== null && (!Number.isInteger(p.hostingPlanId) || (p.hostingPlanId as number) < 1)) return 'hostingPlanId muss eine ganze Zahl ≥ 1 sein';
if (p.language !== undefined && !/^[a-z]{2}(_[A-Z]{2})?$/.test(String(p.language))) return 'language muss z. B. "de" oder "de_DE" sein';
for (const k of ['createSystemDomain', 'sendLoginCredentials']) if (p[k] !== undefined && typeof p[k] !== 'boolean') return `${k} muss true oder false sein`;
return null;
},
/**
* Legt ein Hosting-Konto an. Benutzername = "kc" + Ziffern der Vertragsnummer (stabil): Existiert er schon und trägt unseren Vermerk,
* wird das Konto übernommen (sichere Wiederholung nach unklarem Ausgang). Das vom Panel erzeugte Passwort wird NIE gelesen oder gespeichert
* (KeyHelp schickt die Zugangsdaten an die Kunden-E-Mail; zusätzlich gibt es den Panel-Login).
*/
async provision(ctx, req) {
const err = this.validateProvisioning!(req.params); if (err) throw new ConnectorError('BAD_CONFIG', err);
const c = req.context; if (!c?.customerEmail) throw new ConnectorError('BAD_CONFIG', 'Für das Hosting-Konto wird die E-Mail-Adresse des Kunden benötigt');
const username = usernameFor(c, req.idempotencyKey); const plans = await plansMap(ctx);
const existing = await optional(call<RawClient>(ctx, 'GET', `/clients/name/${encodeURIComponent(username)}`));
if (existing) {
if (!String(existing.notes ?? '').includes(noteFor(c))) throw new ConnectorError('CONFLICT', `Der Benutzername ${username} ist bereits an ein anderes Konto vergeben`);
return { resource: mapClient(existing, null, plans) }; // bereits angelegt (Wiederholung)
}
const [first, ...rest] = (c.contactName ?? '').trim().split(/\s+/).filter(Boolean);
const body: Row = { username, email: c.customerEmail, language: (req.params.language as string) ?? 'de', ...(req.params.hostingPlanId ? { id_hosting_plan: req.params.hostingPlanId } : {}), create_system_domain: req.params.createSystemDomain ?? true, send_login_credentials: req.params.sendLoginCredentials ?? true,
notes: noteFor(c), contact_data: { company: c.customerName, ...(first ? { first_name: first, last_name: rest.join(' ') || first } : {}) } };
const created = await call<{ id: number }>(ctx, 'POST', '/clients', body);
if (!created || typeof created.id !== 'number') throw new ConnectorError('INVALID_RESPONSE', 'keine Kunden-ID');
const client = await getClient(ctx, String(created.id));
if (client.username !== username) throw new ConnectorError('INVALID_RESPONSE', `Konto ${created.id} mit unerwartetem Benutzernamen angelegt`);
return { resource: mapClient(client, null, plans) };
},
/** Sperren/Entsperren setzen einen Zielzustand; Tarifwechsel setzt den Tarif; Löschen ist endgültig (nur manuell bestätigt, nie automatisch wiederholt). */
async execute(ctx, req) {
const id = encodeURIComponent(req.externalRef); const plans = await plansMap(ctx);
const act: ActionName = req.action;
if (act === 'suspend' || act === 'unsuspend') { await call(ctx, 'PUT', `/clients/${id}`, { is_suspended: act === 'suspend' }); return { resource: mapClient(await getClient(ctx, req.externalRef), null, plans) }; }
if (act === 'change_plan') { const pid = Number(req.params?.planId); if (!Number.isInteger(pid) || pid < 1) throw new ConnectorError('BAD_CONFIG', 'planId fehlt'); await call(ctx, 'PUT', `/clients/${id}`, { id_hosting_plan: pid }); return { resource: mapClient(await getClient(ctx, req.externalRef), null, plans) }; }
if (act === 'terminate') { await optional(call(ctx, 'DELETE', `/clients/${id}`)); return {}; } // 404 = schon gelöscht
throw new ConnectorError('UNSUPPORTED', 'Diese Aktion gibt es bei KeyHelp nicht');
},
/** Offizieller Login-Link (60 Minuten gültig, Brute-Force-Schutz im Panel). */
async loginUrl(ctx, ref) {
const r = await call<{ url?: string }>(ctx, 'GET', `/login/${encodeURIComponent(ref)}`);
if (!r?.url || !/^https:\/\//.test(r.url)) throw new ConnectorError('INVALID_RESPONSE', 'kein Login-Link');
return { url: r.url, validForSec: 3600 };
},
children,
};
}
export const keyhelpConnector = createKeyHelpConnector();
void maskKey;

View file

@ -0,0 +1,91 @@
/**
* Nachbau eines KeyHelp-Servers (Teilmenge) nach der Definition "KeyHelp RESTful API 2.15".
* Zweck: Vertrags- und Integrationstests ohne echte Instanz. Antwortstrukturen folgen den Schemas der Definition;
* Verhalten, das die Definition offenlässt (z. B. Text der Fehlermeldungen), ist bewusst einfach gehalten.
*/
type Row = Record<string, any>;
export interface Fake { fetch: typeof fetch; state: { clients: Row[]; domains: Row[]; emails: Row[]; databases: Row[]; ftp: Row[]; certs: Row[]; plans: Row[] }; calls: { method: string; path: string; body?: any; key?: string }[]; opts: { failGet503: number; leakForeign: boolean; slowDown?: boolean } }
export function createFake(apiKey = 'test-key'): Fake {
let seq = 100;
const state = {
plans: [{ id: 1, name: 'Starter', resources: { disk_space: 10737418240, traffic: 107374182400, domains: 2, subdomains: 10, email_accounts: 5, email_addresses: 10, email_forwardings: 10, databases: 2, ftp_users: 2, scheduled_tasks: 1 }, permissions: { ftp: true, php: true, ssh: false, file_manager: true, backup: true, panel_access: true } },
{ id: 2, name: 'Business', resources: { disk_space: -1, traffic: -1, domains: 50, subdomains: -1, email_accounts: -1, email_addresses: -1, email_forwardings: -1, databases: 20, ftp_users: 20, scheduled_tasks: 10 }, permissions: { ftp: true, php: true, ssh: true, file_manager: true } }],
clients: [
{ id: 1, status: 1, username: 'alpha', email: 'alpha@example.test', language: 'de', notes: 'manuell angelegt', id_hosting_plan: 1, created_at: '2026-01-05 10:00:00', is_suspended: false, contact_data: { company: 'Alpha GmbH', first_name: 'Anna', last_name: 'Alpha' }, permissions: { ftp: true, panel_access: true }, password_hash: 'GEHEIM-HASH' },
{ id: 2, status: 1, username: 'beta', email: 'beta@example.test', language: 'de', notes: '', id_hosting_plan: 2, created_at: '2026-02-01 10:00:00', is_suspended: true, delete_on: '2026-12-01 00:00:00', permissions: { ftp: false }, contact_data: {} },
] as Row[],
domains: [
{ id: 10, id_user: 1, id_parent_domain: 0, domain: 'alpha.example.test', domain_utf8: 'alpha.example.test', status: 1, is_subdomain: false, is_system_domain: false, php_version: '', security: { lets_encrypt: true, force_https: true, is_hsts: false }, is_email_domain: true },
{ id: 11, id_user: 1, id_parent_domain: 0, domain: 'alpha.sys.example.test', domain_utf8: 'alpha.sys.example.test', status: 1, is_subdomain: false, is_system_domain: true, security: {} },
{ id: 20, id_user: 2, id_parent_domain: 0, domain: 'beta.example.test', domain_utf8: 'beta.example.test', status: 1, is_subdomain: false, is_system_domain: false, security: {}, is_email_domain: true },
] as Row[],
emails: [
{ id: 30, id_user: 1, email: 'info@alpha.example.test', email_utf8: 'info@alpha.example.test', status: 1, size: 1000, max_size: 5000, aliases: [], forwardings: [], catch_all: false, password_hash: 'GEHEIM-MAIL-HASH' },
{ id: 31, id_user: 2, email: 'info@beta.example.test', status: 1, size: 5, max_size: 100, aliases: [], forwardings: [] },
] as Row[],
databases: [{ id: 40, id_user: 1, database_name: 'alpha_db', database_username: 'alpha_db', size: 2048, description: '', remote_hosts: [] }, { id: 41, id_user: 2, database_name: 'beta_db', database_username: 'beta_db', size: 1, remote_hosts: [] }] as Row[],
ftp: [{ id: 50, id_user: 1, status: 1, username: 'alpha_ftp', home_directory: '/www/', description: '' }, { id: 51, id_user: 2, status: 1, username: 'beta_ftp', home_directory: '/www/' }] as Row[],
certs: [{ id: 60, id_user: 1, name: 'alpha-cert', secured_domains: ['alpha.example.test'], valid_till: '2027-01-01 00:00:00', issuer: "Let's Encrypt", usage: { domain_count: 1 } }, { id: 61, id_user: 1, name: 'alt', secured_domains: ['x'], valid_till: '2020-01-01 00:00:00', issuer: 'X', usage: { domain_count: 0 } }] as Row[],
};
const calls: Fake['calls'] = []; const opts = { failGet503: 0, leakForeign: false } as Fake['opts'];
const json = (b: unknown, status = 200) => new Response(status === 204 ? null : JSON.stringify(b), { status, headers: { 'content-type': 'application/json' } });
const err = (status: number, message: string) => json({ code: String(status), message }, status);
const byId = (arr: Row[], id: string) => arr.find((x) => String(x.id) === id);
const CRUD: Record<string, { arr: Row[]; nameKey: string; idPrefix: number }> = { domains: { arr: state.domains, nameKey: 'domain', idPrefix: 0 }, emails: { arr: state.emails, nameKey: 'email', idPrefix: 0 }, databases: { arr: state.databases, nameKey: 'database_name', idPrefix: 0 }, 'ftp-users': { arr: state.ftp, nameKey: 'username', idPrefix: 0 }, certificates: { arr: state.certs, nameKey: 'name', idPrefix: 0 } };
const stats = (c: Row) => { const own = (a: Row[]) => a.filter((x) => x.id_user === c.id).length; return { disk_space: { value: 123456789, max: c.id === 2 ? -1 : 10737418240 }, files: { value: 10, max: 100000 }, traffic: { value: 5000, max: 107374182400 }, domains: { value: own(state.domains), max: 2 }, subdomains: { value: 0, max: 10 }, email_accounts: { value: own(state.emails), max: 5 }, email_addresses: { value: 0, max: 10 }, email_forwardings: { value: 0, max: 10 }, databases: { value: own(state.databases), max: 2 }, ftp_users: { value: own(state.ftp), max: 2 }, scheduled_tasks: { value: 0, max: 1 } }; };
const f = (async (url: string, init: RequestInit = {}) => {
const u = new URL(url); const method = (init.method ?? 'GET').toUpperCase(); const path = u.pathname.replace(/^\/api\/v2/, '');
const h = (init.headers ?? {}) as Record<string, string>; const body = init.body ? JSON.parse(init.body as string) : undefined;
calls.push({ method, path, body, key: h['x-api-key'] });
if (h['x-api-key'] !== apiKey) return err(401, 'Unauthorized');
if (method === 'GET' && opts.failGet503 > 0) { opts.failGet503--; return err(503, 'Webserver wird neu geladen'); }
if (path === '/ping') return json({ response: 'pong' });
if (path === '/server') return json({ meta: { hostname: 'kh.test', panel_version: '26.0', api_version: '2.15', keyhelp_pro: false } });
if (path === '/hosting-plans' && method === 'GET') return json(state.plans);
if (path === '/hosting-plans' && method === 'POST') { if (!body?.name) return err(400, 'Invalid property data for: name'); const pl = { id: ++seq, name: body.name, resources: { disk_space: 0, traffic: 0, domains: 0, subdomains: 0, email_accounts: 0, email_addresses: 0, email_forwardings: 0, databases: 0, ftp_users: 0, scheduled_tasks: 0, ...(body.resources ?? {}) }, permissions: body.permissions ?? {} }; state.plans.push(pl); return json({ id: pl.id }, 201); }
const pm = /^\/hosting-plans\/(\d+)$/.exec(path); if (pm && method === 'GET') { const pl = byId(state.plans, pm[1]!); return pl ? json(pl) : err(404, 'Not found'); }
if (path === '/clients' && method === 'GET') return json(state.clients);
if (path === '/clients' && method === 'POST') {
if (!body?.username || !body?.email) return err(400, 'Invalid property data for: username, email');
if (state.clients.some((c) => c.username === body.username)) return err(400, `Invalid property data for: username (already in use)`);
const c = { id: ++seq, status: 3, is_suspended: false, created_at: '2026-09-26 12:00:00', id_hosting_plan: body.id_hosting_plan ?? 1, permissions: { ftp: true }, ...body }; delete c.password; state.clients.push(c);
return json({ id: c.id, password: 'AUTO-GENERATED-SECRET' }, 201);
}
let m = /^\/clients\/(name\/)?([^/]+)(?:\/(stats|resources|traffic))?$/.exec(path);
if (m) {
const c = m[1] ? state.clients.find((x) => x.username === decodeURIComponent(m![2]!)) : byId(state.clients, m[2]!);
if (!c) return err(404, 'Not found');
if (m[3] === 'stats') return json(stats(c));
if (m[3] === 'resources') return json({ domains: state.domains.filter((x) => x.id_user === c.id || opts.leakForeign), emails: state.emails.filter((x) => x.id_user === c.id || opts.leakForeign), databases: state.databases.filter((x) => x.id_user === c.id), ftp_users: state.ftp.filter((x) => x.id_user === c.id), certificates: state.certs.filter((x) => x.id_user === c.id), scheduled_tasks: [], directory_protections: [] });
if (method === 'GET') return json(c);
if (method === 'PUT') { Object.assign(c, body); return json(c); }
if (method === 'DELETE') { state.clients.splice(state.clients.indexOf(c), 1); for (const a of Object.values(CRUD)) for (let i = a.arr.length - 1; i >= 0; i--) if (a.arr[i]!.id_user === c.id) a.arr.splice(i, 1); return json(null, 204); }
}
m = /^\/login\/(name\/)?([^/]+)$/.exec(path);
if (m && method === 'GET') { const c = m[1] ? state.clients.find((x) => x.username === m![2]) : byId(state.clients, m[2]!); return c ? json({ url: `https://kh.test/login?token=EINMALIG-${c.id}` }) : err(404, 'Not found'); }
m = /^\/(domains|emails|databases|ftp-users|certificates)(?:\/(name\/)?([^/]+))?$/.exec(path);
if (m) {
const crud = CRUD[m[1]!]!;
if (!m[3]) {
if (method === 'GET') return json(crud.arr);
if (method === 'POST') {
const r: Row = { id: ++seq, status: 3, ...body }; delete r.password;
if (m[1] === 'databases' && !r.database_name) { r.database_name = `db${seq}`; r.database_username = `dbu${seq}`; }
if (m[1] === 'ftp-users' && !r.username) r.username = `ftp${seq}`;
if (crud.arr.some((x) => x[crud.nameKey] === r[crud.nameKey])) return err(400, 'Invalid property data: name in use');
crud.arr.push(r); return json({ id: r.id, password: 'AUTO-GENERATED-SECRET' }, 201);
}
} else {
const row = m[2] ? crud.arr.find((x) => x[crud.nameKey] === decodeURIComponent(m![3]!)) : byId(crud.arr, m[3]!);
if (!row) return err(404, 'Not found');
if (method === 'GET') return json(row);
if (method === 'PUT') { Object.assign(row, body); delete row.password; return json(row); }
if (method === 'DELETE') { crud.arr.splice(crud.arr.indexOf(row), 1); return json(null, 204); }
}
}
return err(404, 'Unbekannter Endpunkt');
}) as unknown as typeof fetch;
return { fetch: f, state, calls, opts };
}

View file

@ -0,0 +1,187 @@
import { describe, expect, it } from 'vitest';
import { ConnectorError, type ConnectorContext, type ProvisionContext } from '@kc/connector-sdk';
import { runContract } from '@kc/connector-sdk/dist/contract.js';
import { createKeyHelpConnector, usernameFor } from '../src/index.js';
import { createFake, type Fake } from './fake.js';
const ctx = (extra: Record<string, unknown> = {}): ConnectorContext => ({ config: { baseUrl: 'https://kh.test', ...extra }, secrets: { apiKey: 'test-key' }, correlationId: 'c' });
const mk = (fake: Fake) => createKeyHelpConnector({ fetchImpl: fake.fetch, sleep: async () => {}, now: () => new Date('2026-09-26T12:00:00Z') });
const PC: ProvisionContext = { source: 'kundencenter', customerNumber: 'K-10001', customerName: 'Muster GmbH', contactName: 'Max Muster', customerEmail: 'max@example.test', orderNumber: 'B-20001', contractNumber: 'V-30001' };
describe('KeyHelp-Connector: Lesen', () => {
it('erfüllt den Connector-Vertrag', async () => { const f = createFake(); await runContract(expect as never, mk(f), ctx()); });
it('normalisiert Konten mit Nutzung, Limits, Tarif und Zustand – ohne Geheimnisse', async () => {
const list = await mk(createFake()).listResources(ctx());
expect(list.map((r) => [r.externalRef, r.state, r.type])).toEqual([['1', 'active', 'hosting_account'], ['2', 'suspended', 'hosting_account']]);
const a = list[0]!;
expect(a.name).toBe('Alpha GmbH · alpha'); expect(a.details).toMatchObject({ username: 'alpha', plan: 'Starter', planId: 1, providerStatus: 'ok' });
expect(a.usage).toMatchObject({ disk_space: 123456789, domains: 2 }); expect(a.limits).toMatchObject({ disk_space: 10737418240, domains: 2 });
expect(list[1]!.limits!.disk_space).toBeNull(); expect(list[1]!.details.scheduledDeleteOn).toBe('2026-12-01 00:00:00'); // unbegrenzt / geplantes Löschen
expect(JSON.stringify(list)).not.toMatch(/GEHEIM|password|AUTO-GENERATED/i);
});
it('Statistik-Ausfall einzelner Konten bricht den Abgleich nicht ab', async () => {
const f = createFake(); const orig = f.fetch; (f as any).fetch = async (u: string, i: RequestInit) => (String(u).includes('/clients/2/stats') ? new Response('{}', { status: 500 }) : orig(u, i));
const list = await createKeyHelpConnector({ fetchImpl: f.fetch, sleep: async () => {}, retries: 0 }).listResources(ctx());
expect(list).toHaveLength(2); expect(list[1]!.usage).toEqual({});
});
it('liest Hosting-Tarife als Produktvorlagen (fest vorgegeben)', async () => {
const items = await mk(createFake()).listCatalog!(ctx());
expect(items.map((i) => i.name)).toEqual(['Starter', 'Business']);
expect(items[0]).toMatchObject({ externalRef: 'plan:1', category: 'hosting', fixed: true, provisioning: { hostingPlanId: 1, language: 'de', createSystemDomain: true, sendLoginCredentials: true } });
expect(items[0]!.features).toEqual(expect.arrayContaining(['Speicher: 10 GB', 'Domains: 2', 'FTP', 'Dateimanager'])); expect(items[1]!.features).toContain('Speicher: unbegrenzt');
});
it('Health: Version, Ausfall und falscher Schlüssel', async () => {
const f = createFake(); const h = await mk(f).healthCheck(ctx()); expect(h).toMatchObject({ ok: true, version: 'KeyHelp 26.0 (API 2.15)' });
const down = createKeyHelpConnector({ fetchImpl: (async () => { throw Object.assign(new TypeError('fetch failed'), { cause: { code: 'ECONNREFUSED' } }); }) as never, sleep: async () => {}, retries: 0 });
expect(await down.healthCheck(ctx())).toMatchObject({ ok: false, message: expect.stringMatching(/nicht erreichbar/) });
await expect(mk(f).listResources({ ...ctx(), secrets: { apiKey: 'falsch' } })).rejects.toMatchObject({ code: 'AUTH_FAILED' });
});
it('wiederholt Lesezugriffe beim Webserver-Neustart (503), nie das Anlegen', async () => {
const f = createFake(); f.opts.failGet503 = 2; expect((await mk(f).listResources(ctx())).length).toBe(2);
const g = createFake(); const c = mk(g); g.opts.failGet503 = 0;
const orig = g.fetch; let posts = 0; (g as any).fetch = async (u: string, i: RequestInit) => { if (i.method === 'POST') { posts++; return new Response('{}', { status: 503 }); } return orig(u, i); };
const c2 = createKeyHelpConnector({ fetchImpl: g.fetch, sleep: async () => {} }); void c;
await expect(c2.provision!(ctx(), { params: { hostingPlanId: 1 }, label: 'x', idempotencyKey: 'k', context: PC })).rejects.toMatchObject({ code: 'UPSTREAM_ERROR', ambiguous: true });
expect(posts).toBe(1); // Anlage wird nie automatisch wiederholt
});
it('prüft Konfiguration: nur https, Fingerabdruck-Format', async () => {
await expect(createKeyHelpConnector().healthCheck({ config: { baseUrl: 'http://kh.test' }, secrets: { apiKey: 'k' }, correlationId: 'c' }).then((h) => h.ok)).resolves.toBe(false);
await expect(createKeyHelpConnector().listResources({ config: { baseUrl: 'https://kh.test', tlsFingerprint: 'zzz' }, secrets: { apiKey: 'k' }, correlationId: 'c' })).rejects.toMatchObject({ code: 'BAD_CONFIG' });
await expect(createKeyHelpConnector({ fetchImpl: createFake().fetch }).listResources({ config: { baseUrl: 'https://kh.test' }, secrets: {}, correlationId: 'c' })).rejects.toMatchObject({ code: 'BAD_CONFIG' });
});
});
describe('KeyHelp-Connector: Konto anlegen und ändern', () => {
const params = { hostingPlanId: 2, language: 'de', createSystemDomain: true, sendLoginCredentials: true };
it('legt ein Konto an, vermerkt Kundencenter, liest kein Passwort und ist wiederholbar', async () => {
const f = createFake(); const c = mk(f);
expect(usernameFor(PC, 'x')).toBe('kc30001');
const r = await c.provision!(ctx(), { params, label: 'x', idempotencyKey: 'k1', context: PC });
expect(r.resource).toMatchObject({ type: 'hosting_account', state: 'active', details: { username: 'kc30001', planId: 2, plan: 'Business' } });
const post = f.calls.find((x) => x.method === 'POST' && x.path === '/clients')!.body;
expect(post).toMatchObject({ username: 'kc30001', email: 'max@example.test', id_hosting_plan: 2, create_system_domain: true, send_login_credentials: true, notes: 'Kundencenter K-10001 V-30001', contact_data: { company: 'Muster GmbH', first_name: 'Max', last_name: 'Muster' } });
expect(post).not.toHaveProperty('password'); expect(JSON.stringify(r)).not.toContain('AUTO-GENERATED');
// Wiederholung (z. B. nach unklarem Timeout): vorhandenes Konto wird übernommen, keine Doppelanlage
const again = await c.provision!(ctx(), { params, label: 'x', idempotencyKey: 'k2', context: PC });
expect(again.resource.externalRef).toBe(r.resource.externalRef); expect(f.calls.filter((x) => x.method === 'POST' && x.path === '/clients').length).toBe(1);
expect(f.state.clients.filter((x) => x.username === 'kc30001').length).toBe(1);
});
it('lehnt fremd belegten Benutzernamen und fehlende Angaben ab', async () => {
const f = createFake(); f.state.clients.push({ id: 9, username: 'kc30001', email: 'x@y.test', notes: 'Kundencenter K-99999 V-30001x', status: 1 });
await expect(mk(f).provision!(ctx(), { params, label: 'x', idempotencyKey: 'k', context: PC })).rejects.toMatchObject({ code: 'CONFLICT' });
await expect(mk(createFake()).provision!(ctx(), { params, label: 'x', idempotencyKey: 'k', context: { ...PC, customerEmail: null } })).rejects.toMatchObject({ code: 'BAD_CONFIG' });
await expect(mk(createFake()).provision!(ctx(), { params: { hostingPlanId: 0 }, label: 'x', idempotencyKey: 'k', context: PC })).rejects.toMatchObject({ code: 'BAD_CONFIG' });
expect(mk(createFake()).validateProvisioning!({ hostingPlanId: 1, language: 'DE!' })).toMatch(/language/);
});
it('meldet vom Panel abgelehnte Eingaben verständlich und wiederholt sie nicht', async () => {
const f = createFake(); const orig = f.fetch; let n = 0; (f as any).fetch = async (u: string, i: RequestInit) => { if (i.method === 'POST') { n++; return new Response(JSON.stringify({ code: '400', message: 'Invalid property data for: email' }), { status: 400 }); } return orig(u, i); };
const e = await createKeyHelpConnector({ fetchImpl: f.fetch, sleep: async () => {} }).provision!(ctx(), { params, label: 'x', idempotencyKey: 'k', context: PC }).catch((x) => x) as ConnectorError;
expect(e.code).toBe('INVALID_INPUT'); expect(e.notSent).toBe(true); expect(e.message).toContain('Invalid property data'); expect(n).toBe(1);
});
it('sperrt, entsperrt, wechselt den Tarif und löscht (idempotent)', async () => {
const f = createFake(); const c = mk(f);
expect((await c.execute!(ctx(), { action: 'suspend', externalRef: '1', idempotencyKey: 'k' })).resource?.state).toBe('suspended'); expect(f.state.clients[0]!.is_suspended).toBe(true);
expect((await c.execute!(ctx(), { action: 'suspend', externalRef: '1', idempotencyKey: 'k' })).resource?.state).toBe('suspended'); // Wiederholung wirkungsgleich
expect((await c.execute!(ctx(), { action: 'unsuspend', externalRef: '1', idempotencyKey: 'k' })).resource?.state).toBe('active');
const up = await c.execute!(ctx(), { action: 'change_plan', externalRef: '1', params: { planId: 2 }, idempotencyKey: 'k' }); expect(up.resource?.details).toMatchObject({ planId: 2, plan: 'Business' });
await expect(c.execute!(ctx(), { action: 'change_plan', externalRef: '1', params: {}, idempotencyKey: 'k' })).rejects.toMatchObject({ code: 'BAD_CONFIG' });
await expect(c.execute!(ctx(), { action: 'extend', externalRef: '1', params: { until: '2027-01-01' }, idempotencyKey: 'k' })).rejects.toMatchObject({ code: 'UNSUPPORTED' });
await c.execute!(ctx(), { action: 'terminate', externalRef: '2', idempotencyKey: 'k' }); expect(f.state.clients.map((x) => x.id)).toEqual([1]);
await c.execute!(ctx(), { action: 'terminate', externalRef: '2', idempotencyKey: 'k' }); // schon weg: kein Fehler
});
it('liefert den Panel-Login nur als https-Link, gültig 60 Minuten', async () => {
const r = await mk(createFake()).loginUrl!(ctx(), '1'); expect(r).toEqual({ url: 'https://kh.test/login?token=EINMALIG-1', validForSec: 3600 });
const f = createFake(); const orig = f.fetch; (f as any).fetch = async (u: string, i: RequestInit) => (String(u).includes('/login/') ? new Response(JSON.stringify({ url: 'http://unsicher.test' }), { status: 200 }) : orig(u, i));
await expect(createKeyHelpConnector({ fetchImpl: f.fetch }).loginUrl!(ctx(), '1')).rejects.toMatchObject({ code: 'INVALID_RESPONSE' });
await expect(mk(createFake()).loginUrl!(ctx(), '999')).rejects.toMatchObject({ code: 'NOT_FOUND' });
});
});
describe('KeyHelp-Connector: Unterobjekte und Mandantentrennung', () => {
it('listet alle Arten, liefert nie Passwörter und nie Objekte fremder Kunden', async () => {
const f = createFake(); f.opts.leakForeign = true; const ch = mk(f).children!;
expect((await ch.kinds(ctx(), '1')).map((k) => [k.kind, k.canWrite])).toEqual([['domain', true], ['email', true], ['database', true], ['ftp', true], ['certificate', false]]);
expect((await ch.kinds(ctx(), '2')).find((k) => k.kind === 'ftp')!.canWrite).toBe(false); // Recht "ftp" fehlt
const doms = await ch.list(ctx(), '1', 'domain'); expect(doms.map((d) => d.name)).toEqual(['alpha.example.test', 'alpha.sys.example.test']); // beta.example.test wurde trotz "Leck" gefiltert
expect(doms[0]!.details).toMatchObject({ letsEncrypt: true, forceHttps: true, emailDomain: true, system: false });
expect((await ch.list(ctx(), '1', 'email')).map((e) => e.name)).toEqual(['info@alpha.example.test']);
expect((await ch.list(ctx(), '1', 'database'))[0]!.details).toMatchObject({ user: 'alpha_db', sizeBytes: 2048 });
expect((await ch.list(ctx(), '1', 'ftp'))[0]!.details).toMatchObject({ home: '/www/' });
const certs = await ch.list(ctx(), '1', 'certificate'); expect(certs.map((c) => [c.name, c.state])).toEqual([['alpha-cert', 'active'], ['alt', 'expired']]); expect(certs[0]!.validUntil).toBe('2027-01-01T00:00:00.000Z');
expect(JSON.stringify([doms, await ch.list(ctx(), '1', 'email')])).not.toMatch(/GEHEIM|password/i);
});
it('legt Objekte für den Kunden an (Passwort getrennt, Übernahme bei Wiederholung)', async () => {
const f = createFake(); const ch = mk(f).children!;
const dom = await ch.act(ctx(), { parentRef: '1', kind: 'domain', op: 'create', data: { domain: 'Neu.Example.Test', phpVersion: '8.3', letsEncrypt: true }, idempotencyKey: 'k' });
expect(dom.child).toMatchObject({ kind: 'domain', name: 'neu.example.test' }); expect(f.calls.find((c) => c.method === 'POST' && c.path === '/domains')!.body).toMatchObject({ id_user: 1, domain: 'neu.example.test', php_version: '8.3' });
const mail = await ch.act(ctx(), { parentRef: '1', kind: 'email', op: 'create', data: { local: 'Kontakt', domain: 'alpha.example.test' }, secrets: { password: 'ein-sehr-langes-Passwort-1' }, idempotencyKey: 'k' });
expect(mail.child!.name).toBe('kontakt@alpha.example.test'); expect(f.calls.find((c) => c.path === '/emails' && c.method === 'POST')!.body).toMatchObject({ id_user: 1, password: 'ein-sehr-langes-Passwort-1' });
expect(JSON.stringify(mail)).not.toContain('ein-sehr-langes'); expect(JSON.stringify(mail)).not.toContain('AUTO-GENERATED');
const again = await ch.act(ctx(), { parentRef: '1', kind: 'email', op: 'create', data: { local: 'Kontakt', domain: 'alpha.example.test' }, secrets: { password: 'ein-sehr-langes-Passwort-1' }, idempotencyKey: 'k2' });
expect(again.child!.id).toBe(mail.child!.id); expect(f.calls.filter((c) => c.path === '/emails' && c.method === 'POST').length).toBe(1); // keine Doppelanlage
const db = await ch.act(ctx(), { parentRef: '1', kind: 'database', op: 'create', data: {}, secrets: { password: 'db-passwort-lang-123' }, idempotencyKey: 'k' }); expect(db.child!.name).toMatch(/^db\d+$/);
const ftp = await ch.act(ctx(), { parentRef: '1', kind: 'ftp', op: 'create', data: { username: 'neu_ftp', home: '/www/seite' }, secrets: { password: 'ftp-passwort-lang-123' }, idempotencyKey: 'k' }); expect(ftp.child!.details.home).toBe('/www/seite');
const up = await ch.act(ctx(), { parentRef: '1', kind: 'email', op: 'update', id: '30', data: { maxSizeBytes: 9999 }, secrets: { password: 'neues-passwort-lang-1' }, idempotencyKey: 'k' });
expect(f.state.emails.find((e) => e.id === 30)).toMatchObject({ max_size: 9999 }); expect(f.state.emails.find((e) => e.id === 30)).not.toHaveProperty('password'); void up;
const dsu = await ch.act(ctx(), { parentRef: '1', kind: 'domain', op: 'update', id: '10', data: { phpVersion: '8.2', forceHttps: false }, idempotencyKey: 'k' }); expect(dsu.child!.details).toMatchObject({ phpVersion: '8.2', forceHttps: false });
});
it('verhindert Zugriffe auf Objekte fremder Kunden (Administrator-Schlüssel!)', async () => {
const f = createFake(); const ch = mk(f).children!; const before = JSON.stringify(f.state);
for (const [kind, id] of [['domain', '20'], ['email', '31'], ['database', '41'], ['ftp', '51']] as const) {
await expect(ch.act(ctx(), { parentRef: '1', kind, op: 'delete', id, data: {}, idempotencyKey: 'k' })).rejects.toMatchObject({ code: 'NOT_FOUND' });
await expect(ch.act(ctx(), { parentRef: '1', kind, op: 'update', id, data: { description: 'x' }, secrets: { password: 'ein-langes-passwort-1' }, idempotencyKey: 'k' })).rejects.toMatchObject({ code: 'NOT_FOUND' });
}
// E-Mail auf fremder Domain anlegen, Unterdomain unter fremder Hauptdomain, Namenskonflikt mit fremdem Objekt
await expect(ch.act(ctx(), { parentRef: '1', kind: 'email', op: 'create', data: { local: 'x', domain: 'beta.example.test' }, secrets: { password: 'ein-langes-passwort-1' }, idempotencyKey: 'k' })).rejects.toMatchObject({ code: 'NOT_FOUND' });
await expect(ch.act(ctx(), { parentRef: '1', kind: 'domain', op: 'create', data: { domain: 'www.beta.example.test', parentDomainId: 20 }, idempotencyKey: 'k' })).rejects.toMatchObject({ code: 'NOT_FOUND' });
await expect(ch.act(ctx(), { parentRef: '1', kind: 'database', op: 'create', data: { name: 'beta_db' }, secrets: { password: 'ein-langes-passwort-1' }, idempotencyKey: 'k' })).rejects.toMatchObject({ code: 'CONFLICT' });
expect(JSON.stringify(f.state)).toBe(before); // nichts wurde verändert
expect(f.calls.filter((c) => c.method !== 'GET').length).toBe(0);
});
it('prüft Eingaben streng und schützt Systemobjekte', async () => {
const f = createFake(); const ch = mk(f).children!; const p = { parentRef: '1', idempotencyKey: 'k' };
await expect(ch.act(ctx(), { ...p, kind: 'domain', op: 'create', data: { domain: 'kein domain name' } })).rejects.toMatchObject({ code: 'INVALID_INPUT' });
await expect(ch.act(ctx(), { ...p, kind: 'email', op: 'create', data: { local: 'a b', domain: 'alpha.example.test' }, secrets: { password: 'ein-langes-passwort-1' } })).rejects.toMatchObject({ code: 'INVALID_INPUT' });
await expect(ch.act(ctx(), { ...p, kind: 'email', op: 'create', data: { local: 'ok', domain: 'alpha.example.test' } })).rejects.toMatchObject({ code: 'INVALID_INPUT' }); // Passwort fehlt
await expect(ch.act(ctx(), { ...p, kind: 'ftp', op: 'create', data: { home: '/etc' }, secrets: { password: 'ein-langes-passwort-1' } })).rejects.toMatchObject({ code: 'INVALID_INPUT' });
await expect(ch.act(ctx(), { ...p, kind: 'ftp', op: 'create', data: { home: '/www/../etc' }, secrets: { password: 'ein-langes-passwort-1' } })).rejects.toMatchObject({ code: 'INVALID_INPUT' });
await expect(ch.act(ctx(), { ...p, kind: 'database', op: 'create', data: { name: 'Bad Name!' }, secrets: { password: 'ein-langes-passwort-1' } })).rejects.toMatchObject({ code: 'INVALID_INPUT' });
await expect(ch.act(ctx(), { ...p, kind: 'domain', op: 'delete', id: '11', data: {} })).rejects.toMatchObject({ code: 'INVALID_INPUT' }); // System-Domain
await expect(ch.act(ctx(), { ...p, kind: 'certificate', op: 'delete', id: '60', data: {} })).rejects.toMatchObject({ code: 'UNSUPPORTED' });
await expect(ch.act(ctx(), { ...p, kind: 'email', op: 'update', id: '30', data: {} })).rejects.toMatchObject({ code: 'INVALID_INPUT' }); // keine Änderung
expect(f.calls.filter((c) => c.method !== 'GET').length).toBe(0);
});
it('Löschen eigener Objekte ist idempotent', async () => {
const f = createFake(); const ch = mk(f).children!;
await ch.act(ctx(), { parentRef: '1', kind: 'email', op: 'delete', id: '30', data: {}, idempotencyKey: 'k' }); expect(f.state.emails.map((e) => e.id)).toEqual([31]);
await ch.act(ctx(), { parentRef: '1', kind: 'email', op: 'delete', id: '30', data: {}, idempotencyKey: 'k' }); // bereits gelöscht
await ch.act(ctx(), { parentRef: '1', kind: 'domain', op: 'delete', id: '10', data: {}, idempotencyKey: 'k' }); expect(f.state.domains.some((d) => d.id === 10)).toBe(false);
});
});
describe('KeyHelp-Connector: Kunden übernehmen und Tarife anlegen', () => {
it('liest Kunden mit Kontaktdaten und Tarif (ohne Geheimnisse)', async () => {
const f = createFake(); f.state.clients[0]!.contact_data = { company: 'Alpha GmbH', first_name: 'Anna', last_name: 'Alpha', telephone: '0123 4567', address: 'Weg 1', zip: '10115', city: 'Berlin', country: 'DE', client_id: 'K-77' };
const list = await mk(f).listCustomers!(ctx());
expect(list[0]).toEqual({ externalRef: '1', displayName: 'Alpha GmbH', company: 'Alpha GmbH', firstName: 'Anna', lastName: 'Alpha', email: 'alpha@example.test', phone: '0123 4567',
address: { street: 'Weg 1', zip: '10115', city: 'Berlin', state: null, country: 'DE' }, legacyNumber: 'K-77', planRef: 'plan:1', planName: 'Starter', state: 'active', createdAt: '2026-01-05T10:00:00.000Z' });
expect(list[1]).toMatchObject({ displayName: 'beta', company: null, planName: 'Business', state: 'suspended' });
expect(JSON.stringify(list)).not.toMatch(/GEHEIM|password/i);
expect(await mk(f).capabilities(ctx())).toEqual(expect.arrayContaining(['customers.list', 'catalog.write']));
});
it('legt neue Tarife an: Größen in Byte, Eingaben geprüft, "unbegrenzt" nur wenn die Instanz es kennt', async () => {
const f = createFake(); const c = mk(f);
const item = await c.createCatalogItem!(ctx(), { name: 'Neu 50', limits: { diskSpaceGb: 50, trafficGb: 500, domains: 5, emailAccounts: 20, databases: 5, ftpUsers: 3 }, permissions: { ftp: true, ssh: false } });
const post = f.calls.find((x) => x.method === 'POST' && x.path === '/hosting-plans')!.body;
expect(post).toMatchObject({ name: 'Neu 50', resources: { disk_space: 50 * 1024 ** 3, traffic: 500 * 1024 ** 3, domains: 5, email_accounts: 20, databases: 5, ftp_users: 3 }, permissions: { ftp: true, ssh: false } });
expect(item).toMatchObject({ name: 'Neu 50', fixed: true, provisioning: { hostingPlanId: expect.any(Number) } }); expect(item.features).toEqual(expect.arrayContaining(['Speicher: 50 GB', 'Domains: 5', 'FTP']));
await expect(c.createCatalogItem!(ctx(), { name: 'starter', limits: {} })).rejects.toMatchObject({ code: 'CONFLICT' }); // Name existiert (Groß-/Kleinschreibung egal)
await expect(c.createCatalogItem!(ctx(), { name: 'X', limits: { domains: -3 } })).rejects.toMatchObject({ code: 'INVALID_INPUT' });
await expect(c.createCatalogItem!(ctx(), { name: '', limits: {} })).rejects.toMatchObject({ code: 'INVALID_INPUT' });
const u = await c.createCatalogItem!(ctx(), { name: 'Unbegrenzt', limits: { trafficGb: null, domains: 10 } }); // Instanz kennt -1 (Tarif "Business")
expect(f.calls.filter((x) => x.method === 'POST' && x.path === '/hosting-plans').pop()!.body.resources.traffic).toBe(-1); expect(u.features).toContain('Traffic: unbegrenzt');
const fresh = createFake(); fresh.state.plans.forEach((p) => Object.keys(p.resources).forEach((k) => ((p.resources as any)[k] = 5))); fresh.state.clients[1]!.resource_limits = {};
await expect(mk(fresh).createCatalogItem!(ctx(), { name: 'Ohne Unbegrenzt', limits: { trafficGb: null } })).rejects.toMatchObject({ code: 'INVALID_INPUT' }); // nicht raten
expect(fresh.calls.some((x) => x.method === 'POST')).toBe(false);
});
});

View file

@ -0,0 +1 @@
{ "extends": "../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", "outDir": "dist", "declaration": true }, "include": ["src"] }