Stand vor Einführung des Nacht-Agenten
This commit is contained in:
commit
4763548bfb
168 changed files with 12726 additions and 0 deletions
22
packages/connector-keyhelp/package.json
Normal file
22
packages/connector-keyhelp/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
300
packages/connector-keyhelp/src/index.ts
Normal file
300
packages/connector-keyhelp/src/index.ts
Normal 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;
|
||||
91
packages/connector-keyhelp/test/fake.ts
Normal file
91
packages/connector-keyhelp/test/fake.ts
Normal 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 };
|
||||
}
|
||||
187
packages/connector-keyhelp/test/keyhelp.test.ts
Normal file
187
packages/connector-keyhelp/test/keyhelp.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
1
packages/connector-keyhelp/tsconfig.json
Normal file
1
packages/connector-keyhelp/tsconfig.json
Normal file
|
|
@ -0,0 +1 @@
|
|||
{ "extends": "../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", "outDir": "dist", "declaration": true }, "include": ["src"] }
|
||||
21
packages/connector-licensing/package.json
Normal file
21
packages/connector-licensing/package.json
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"name": "@kc/connector-licensing",
|
||||
"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:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^26.6.3",
|
||||
"typescript": "^7.0.2",
|
||||
"vitest": "^5.0.2"
|
||||
}
|
||||
}
|
||||
237
packages/connector-licensing/src/index.ts
Normal file
237
packages/connector-licensing/src/index.ts
Normal file
|
|
@ -0,0 +1,237 @@
|
|||
import { ConnectorError, CONTRACT_VERSION, httpJson, maskKey, type ActionName, type Capability, type CatalogItem, type Connector, type ConnectorContext, type HttpOptions, type NormalizedResource, type ResourceState } from '@kc/connector-sdk';
|
||||
|
||||
/** Rohdaten des eigenen Lizenzsystems (FastAPI, siehe /var/www/html/licensing). Bleiben im Connector. */
|
||||
interface RawActivation { hardware_id: string; ip_address?: string | null; last_seen_ip?: string | null; activated_at: string; last_seen_at: string }
|
||||
interface RawLicense {
|
||||
id: number; program_id: number; product_id?: number | null; license_key: string; user_limit: number | null; duration_type: string; starts_at: string | null; expires_at: string | null; is_active: boolean;
|
||||
status?: string | null; blocked?: boolean | null; suspended_at?: string | null; revoked_at?: string | null; license_type?: string | null; activation_limit?: number | null;
|
||||
activation?: RawActivation | null; activations?: RawActivation[] | null; product?: { name?: string | null } | null;
|
||||
}
|
||||
interface RawProduct { id: number; name: string; description?: string | null; price?: string | number | null; currency?: string | null; duration_type?: string | null; user_limit?: number | null; is_active?: boolean; features?: string | null; modules?: string | null; badge?: string | null }
|
||||
interface RawGroup { id: number; name: string; program_id: number; is_active?: boolean; products?: RawProduct[] }
|
||||
interface RawProgram { id: number; name: string; description?: string | null }
|
||||
|
||||
/** Das Lizenzsystem speichert naive Ortszeit (datetime.now()); wir wandeln sie in UTC um. */
|
||||
export function localToUtcIso(naive: string | null | undefined, tz: string): string | null {
|
||||
if (!naive) return null;
|
||||
if (/[zZ]|[+-]\d\d:?\d\d$/.test(naive)) return new Date(naive).toISOString();
|
||||
const [d, t = '00:00:00'] = naive.split('T');
|
||||
const asUtc = new Date(`${d}T${t.split('.')[0]}Z`);
|
||||
const parts = new Intl.DateTimeFormat('en-CA', { timeZone: tz, hourCycle: 'h23', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' }).formatToParts(asUtc);
|
||||
const g = (n: string) => parts.find((p) => p.type === n)!.value;
|
||||
const shown = Date.UTC(+g('year'), +g('month') - 1, +g('day'), +g('hour'), +g('minute'), +g('second'));
|
||||
return new Date(asUtc.getTime() - (shown - asUtc.getTime())).toISOString();
|
||||
}
|
||||
const toLocalNaive = (iso: string, tz: string): string => {
|
||||
const p = new Intl.DateTimeFormat('en-CA', { timeZone: tz, hourCycle: 'h23', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' }).formatToParts(new Date(iso));
|
||||
const g = (n: string) => p.find((x) => x.type === n)!.value;
|
||||
return `${g('year')}-${g('month')}-${g('day')}T${g('hour')}:${g('minute')}:${g('second')}`;
|
||||
};
|
||||
|
||||
/** Edition wie im Familytool: Präfix des Schlüssels (PREMIUM-, TRIAL-, LIFETIME…), sonst UNLIMITED. */
|
||||
export const editionOf = (key: string): string => { const k = key.toUpperCase(); return k.startsWith('LIFETIME') ? 'LIFETIME' : k.startsWith('PREMIUM-') ? 'PREMIUM' : k.startsWith('TRIAL-') ? 'TRIAL' : 'UNLIMITED'; };
|
||||
|
||||
export interface LicensingDeps extends HttpOptions { now?: () => Date }
|
||||
// Token-Cache hält das Promise, damit parallele Anfragen nur einen Login auslösen
|
||||
const PREFIXES = ['PREMIUM', 'TRIAL', 'LIFETIME'];
|
||||
const tokenCache = new Map<string, { token: Promise<string>; at: number }>();
|
||||
|
||||
export function createLicensingConnector(deps: LicensingDeps = {}): Connector {
|
||||
const now = deps.now ?? (() => new Date());
|
||||
const featureCache = new Map<string, { features: string[]; at: number }>();
|
||||
const base = (ctx: ConnectorContext) => {
|
||||
const u = String(ctx.config.baseUrl ?? '').replace(/\/+$/, '');
|
||||
if (!/^https?:\/\//.test(u)) throw new ConnectorError('BAD_CONFIG', 'baseUrl fehlt');
|
||||
return u;
|
||||
};
|
||||
const tz = (ctx: ConnectorContext) => String(ctx.config.timezone ?? 'Europe/Berlin');
|
||||
/** Neuere Lizenzsysteme liefern UTC ("utc_timestamps"); sonst gilt die konfigurierte Ortszeit. */
|
||||
const zoneOf = async (ctx: ConnectorContext) => ((await features(ctx)).includes('utc_timestamps') ? 'UTC' : tz(ctx));
|
||||
/** Erweiterungen des Lizenzsystems (Feld "features" in GET /): key_prefix, explicit_expiry. Ältere Versionen liefern keine. */
|
||||
async function features(ctx: ConnectorContext): Promise<string[]> {
|
||||
const key = base(ctx); const c = featureCache.get(key);
|
||||
if (c && Date.now() - c.at < 60_000) return c.features;
|
||||
try {
|
||||
const r = await httpJson<{ features?: unknown }>('GET', `${key}/`, {}, { ...deps, retries: 0, timeoutMs: 5000 });
|
||||
const f = Array.isArray(r?.features) ? r.features.filter((x): x is string => typeof x === 'string') : [];
|
||||
featureCache.set(key, { features: f, at: Date.now() }); return f;
|
||||
} catch { return []; }
|
||||
}
|
||||
const hasToken = (ctx: ConnectorContext) => !!ctx.secrets.token;
|
||||
const hasAuth = (ctx: ConnectorContext) => hasToken(ctx) || !!(ctx.secrets.username && ctx.secrets.password);
|
||||
|
||||
async function authHeaders(ctx: ConnectorContext, force = false): Promise<Record<string, string>> {
|
||||
if (hasToken(ctx)) return { authorization: `Bearer ${ctx.secrets.token}` }; // Service-Token: kein Login nötig
|
||||
if (!hasAuth(ctx)) return {};
|
||||
// Cache nur im Speicher; ein Passwortwechsel entwertet ihn automatisch.
|
||||
const key = `${base(ctx)}|${ctx.secrets.username}|${ctx.secrets.password}`; const c = tokenCache.get(key);
|
||||
if (!force && c && Date.now() - c.at < 20 * 60_000) return { authorization: `Bearer ${await c.token}` };
|
||||
const token = httpJson<{ access_token?: string }>('POST', `${base(ctx)}/token`, { form: { username: ctx.secrets.username!, password: ctx.secrets.password! } }, deps)
|
||||
.then((r) => { if (!r?.access_token) throw new ConnectorError('INVALID_RESPONSE', 'kein Token'); return r.access_token; });
|
||||
tokenCache.set(key, { token, at: Date.now() });
|
||||
try { return { authorization: `Bearer ${await token}` }; } catch (e) { tokenCache.delete(key); throw e; }
|
||||
}
|
||||
/** Anfrage mit einmaligem Re-Login bei abgelaufenem Token. */
|
||||
async function call<T>(ctx: ConnectorContext, method: 'GET' | 'PUT' | 'POST', path: string, body?: unknown): Promise<T> {
|
||||
const go = async (force: boolean) => httpJson<T>(method, `${base(ctx)}${path}`, { headers: await authHeaders(ctx, force), body }, deps);
|
||||
try { return await go(false); }
|
||||
catch (e) { if (e instanceof ConnectorError && e.code === 'AUTH_FAILED' && hasAuth(ctx) && !hasToken(ctx)) return go(true); throw e; }
|
||||
}
|
||||
|
||||
/** Zustand: gesperrt/widerrufen/blockiert erkennt auch die neuen Felder des Lizenzsystems (status, blocked, suspended_at, revoked_at). */
|
||||
const stateOf = (l: RawLicense, until: string | null): ResourceState => {
|
||||
const st = String(l.status ?? '').toLowerCase();
|
||||
if (l.is_active === false || l.blocked || l.suspended_at || l.revoked_at || ['suspended', 'revoked', 'blocked', 'inactive', 'canceled', 'cancelled'].includes(st)) return 'suspended';
|
||||
if (st === 'expired' || (until && new Date(until) < now())) return 'expired';
|
||||
return 'active';
|
||||
};
|
||||
const map = (l: RawLicense, programs: Map<number, string>, zone: string): NormalizedResource => {
|
||||
const until = localToUtcIso(l.expires_at, zone);
|
||||
const program = programs.get(l.program_id) ?? `Programm ${l.program_id}`;
|
||||
const act = l.activation ?? l.activations?.[0] ?? null;
|
||||
return {
|
||||
externalRef: String(l.id), type: 'license', name: `${program}${l.product?.name ? ` ${l.product.name}` : ''} · ${maskKey(l.license_key)}`, state: stateOf(l, until),
|
||||
validFrom: localToUtcIso(l.starts_at, zone), validUntil: until,
|
||||
limits: { users: l.user_limit },
|
||||
details: {
|
||||
program, product: l.product?.name ?? null, licenseKeyMasked: maskKey(l.license_key), durationType: l.duration_type, edition: l.product?.name ?? editionOf(l.license_key),
|
||||
providerStatus: l.status ?? null, activationsUsed: l.activations?.length ?? (l.activation ? 1 : 0), activationLimit: l.activation_limit ?? null,
|
||||
activation: act ? { hardwareIdMasked: maskKey(act.hardware_id), activatedAt: localToUtcIso(act.activated_at, zone), lastCheckAt: localToUtcIso(act.last_seen_at, zone), lastSeenIp: act.last_seen_ip ?? null } : null,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
contractVersion: CONTRACT_VERSION, key: 'licensing', displayName: 'Lizenzsystem',
|
||||
configFields: [
|
||||
{ name: 'baseUrl', label: 'Basis-URL', required: true, placeholder: 'http://127.0.0.1:8001' },
|
||||
{ name: 'timezone', label: 'Zeitzone des Lizenzsystems', placeholder: 'Europe/Berlin', advanced: true, help: 'Nur bei älteren Lizenzsystemen relevant (neuere liefern UTC).' },
|
||||
{ name: 'token', label: 'Service-Token (empfohlen, nur die nötigen Rechte)', secret: true },
|
||||
{ name: 'username', label: 'Alternativ: API-Benutzer', secret: true, advanced: true, help: 'Nur nötig, wenn Sie keinen Service-Token verwenden.' },
|
||||
{ name: 'password', label: 'Alternativ: API-Passwort', secret: true, advanced: true },
|
||||
],
|
||||
async capabilities(ctx) {
|
||||
const c: Capability[] = ['catalog.list', 'resources.list', 'resources.get', 'status.read'];
|
||||
if (hasAuth(ctx)) c.push('lifecycle.suspend', 'lifecycle.unsuspend', 'lifecycle.extend', 'lifecycle.create', 'secret.reveal'); // Änderungen brauchen einen authentifizierten Benutzer
|
||||
const f = await features(ctx);
|
||||
if (f.includes('key_prefix')) c.push('license.key_prefix');
|
||||
if (f.includes('explicit_expiry')) c.push('license.expiry');
|
||||
if (f.includes('customer_info')) c.push('license.customer_info');
|
||||
return c;
|
||||
},
|
||||
async healthCheck(ctx) {
|
||||
const t0 = Date.now();
|
||||
try { await httpJson('GET', `${base(ctx)}/`, {}, { ...deps, retries: 0, timeoutMs: 5000 }); return { ok: true, latencyMs: Date.now() - t0 }; }
|
||||
catch (e) { return { ok: false, latencyMs: Date.now() - t0, message: e instanceof ConnectorError ? e.message : 'Unbekannter Fehler' }; }
|
||||
},
|
||||
/**
|
||||
* Produktvorlagen aus dem Lizenzsystem: bevorzugt dessen Produktkatalog (Programm → Gruppe → Produkt mit Preis, Dauer, Features),
|
||||
* sonst die Programme mit Vorschlägen aus bestehenden Lizenzen. Programm-API-Keys und Produktschlüssel werden nie übernommen.
|
||||
*/
|
||||
async listCatalog(ctx) {
|
||||
const DUR: Record<string, string> = { WEEK: 'Woche', MONTH: 'Monat', YEAR: 'Jahr', UNLIMITED: 'Unbegrenzt' };
|
||||
const programs = await call<RawProgram[]>(ctx, 'GET', '/programs/?limit=1000');
|
||||
if (!Array.isArray(programs)) throw new ConnectorError('INVALID_RESPONSE');
|
||||
const names = new Map(programs.map((p) => [p.id, p.name]));
|
||||
let groups: RawGroup[] | null = null;
|
||||
try { const g = await call<RawGroup[]>(ctx, 'GET', '/products/groups'); groups = Array.isArray(g) ? g : null; }
|
||||
catch (e) { if (!(e instanceof ConnectorError && e.code === 'NOT_FOUND')) throw e; }
|
||||
const items: CatalogItem[] = [];
|
||||
for (const g of groups ?? []) for (const p of g.products ?? []) {
|
||||
const dur = String(p.duration_type ?? 'MONTH');
|
||||
const cents = Math.round(Number(p.price ?? 0) * 100);
|
||||
items.push({
|
||||
externalRef: `product:${p.id}`, name: g.name === p.name ? p.name : `${g.name} – ${p.name}`, description: p.description || null, category: 'license',
|
||||
group: g.name, program: names.get(g.program_id) ?? `Programm ${g.program_id}`, badge: p.badge ?? null, providerActive: p.is_active !== false && g.is_active !== false,
|
||||
features: (p.features ?? '').split('\n').map((x) => x.trim()).filter(Boolean),
|
||||
price: Number.isFinite(cents) ? { cents, currency: p.currency ?? 'EUR' } : undefined, interval: dur === 'MONTH' ? 'monthly' : dur === 'YEAR' ? 'yearly' : 'once',
|
||||
meta: { modules: p.modules ?? null, durationType: dur, userLimit: p.user_limit ?? null },
|
||||
provisioning: { programId: g.program_id, productId: p.id, durationType: dur, userLimit: p.user_limit ?? null },
|
||||
});
|
||||
}
|
||||
if (items.length) return items;
|
||||
// Ältere Lizenzsysteme ohne Produktkatalog: Programme + Vorschläge aus bestehenden Lizenzen
|
||||
const licenses = await call<RawLicense[]>(ctx, 'GET', '/licenses/?limit=1000');
|
||||
if (!Array.isArray(licenses)) throw new ConnectorError('INVALID_RESPONSE');
|
||||
return programs.map((p): CatalogItem => {
|
||||
const counts = new Map<string, { durationType: string; userLimit: number | null; count: number }>();
|
||||
for (const l of licenses.filter((x) => x.program_id === p.id)) {
|
||||
const k = `${l.duration_type}|${l.user_limit ?? ''}`; const e = counts.get(k) ?? { durationType: l.duration_type, userLimit: l.user_limit, count: 0 }; e.count++; counts.set(k, e);
|
||||
}
|
||||
const hints = [...counts.values()].sort((a, b) => b.count - a.count).slice(0, 5).map((e) => ({ label: `${DUR[e.durationType] ?? e.durationType}, ${e.userLimit === null ? 'unbegrenzt viele' : e.userLimit} Benutzer`, provisioning: { programId: p.id, durationType: e.durationType, userLimit: e.userLimit }, count: e.count }));
|
||||
return { externalRef: String(p.id), name: p.name, description: p.description ?? null, category: 'license', provisioning: { programId: p.id, durationType: 'YEAR', userLimit: null }, hints };
|
||||
});
|
||||
},
|
||||
async listResources(ctx) {
|
||||
const [programs, licenses] = await Promise.all([call<RawProgram[]>(ctx, 'GET', '/programs/?limit=1000'), call<RawLicense[]>(ctx, 'GET', '/licenses/?limit=1000')]);
|
||||
if (!Array.isArray(programs) || !Array.isArray(licenses)) throw new ConnectorError('INVALID_RESPONSE');
|
||||
const names = new Map(programs.map((p) => [p.id, p.name])); const zone = await zoneOf(ctx);
|
||||
return licenses.map((l) => map(l, names, zone));
|
||||
},
|
||||
/** Vollständiger Lizenzschlüssel für die berechtigte Anzeige auf Abruf (nie gespeichert, nie protokolliert). */
|
||||
async reveal(ctx, ref) {
|
||||
if (!hasAuth(ctx)) throw new ConnectorError('UNSUPPORTED', 'weder Service-Token noch API-Benutzer konfiguriert');
|
||||
const l = await call<RawLicense>(ctx, 'GET', `/licenses/${encodeURIComponent(ref)}`);
|
||||
if (!l || typeof l.license_key !== 'string' || !l.license_key) throw new ConnectorError('INVALID_RESPONSE');
|
||||
return [{ label: 'Lizenzschlüssel', value: l.license_key }];
|
||||
},
|
||||
validateProvisioning(p) {
|
||||
if (!Number.isInteger(p.programId) || (p.programId as number) < 1) return 'programId (ganze Zahl) fehlt';
|
||||
if (p.productId !== undefined && p.productId !== null && (!Number.isInteger(p.productId) || (p.productId as number) < 1)) return 'productId muss eine ganze Zahl ≥ 1 oder leer sein';
|
||||
if (!['WEEK', 'MONTH', 'YEAR', 'UNLIMITED'].includes(String(p.durationType))) return 'durationType muss WEEK, MONTH, YEAR oder UNLIMITED sein';
|
||||
if (p.userLimit !== null && p.userLimit !== undefined && (!Number.isInteger(p.userLimit) || (p.userLimit as number) < 1)) return 'userLimit muss eine ganze Zahl ≥ 1 oder leer sein';
|
||||
if (p.keyPrefix !== undefined && p.keyPrefix !== null && !PREFIXES.includes(String(p.keyPrefix))) return `keyPrefix muss ${PREFIXES.join(', ')} oder leer sein`;
|
||||
if (p.validityDays !== undefined && p.validityDays !== null && (!Number.isInteger(p.validityDays) || (p.validityDays as number) < 1 || (p.validityDays as number) > 3650)) return 'validityDays muss eine ganze Zahl zwischen 1 und 3650 sein';
|
||||
return null;
|
||||
},
|
||||
/** Legt eine Lizenz an (POST /licenses/). Nicht idempotent: Aufrufer wiederholt nur bei sicher nicht gesendeten Anfragen. */
|
||||
async provision(ctx, req) {
|
||||
if (!hasAuth(ctx)) throw new ConnectorError('UNSUPPORTED', 'weder Service-Token noch API-Benutzer konfiguriert');
|
||||
const err = this.validateProvisioning!(req.params); if (err) throw new ConnectorError('BAD_CONFIG', err);
|
||||
const programs = await call<RawProgram[]>(ctx, 'GET', '/programs/?limit=1000');
|
||||
if (!Array.isArray(programs)) throw new ConnectorError('INVALID_RESPONSE');
|
||||
if (!programs.some((p) => p.id === req.params.programId)) throw new ConnectorError('BAD_CONFIG', `Programm ${String(req.params.programId)} existiert nicht`);
|
||||
const prefix = req.params.keyPrefix ? String(req.params.keyPrefix) : null; const days = req.params.validityDays ? Number(req.params.validityDays) : null;
|
||||
// Edition/Ablauf dürfen NIE stillschweigend verloren gehen: sonst bekäme der Kunde eine andere Stufe als bestellt.
|
||||
if (prefix || days) {
|
||||
const f = await features(ctx);
|
||||
if (prefix && !f.includes('key_prefix')) throw new ConnectorError('BAD_CONFIG', 'Das Lizenzsystem unterstützt noch keine Schlüssel-Präfixe (Edition). Bitte zuerst das Lizenzsystem erweitern.');
|
||||
if (days && !f.includes('explicit_expiry')) throw new ConnectorError('BAD_CONFIG', 'Das Lizenzsystem unterstützt noch kein festes Ablaufdatum.');
|
||||
}
|
||||
const zone = await zoneOf(ctx); const productId = req.params.productId ? Number(req.params.productId) : null;
|
||||
const body: Record<string, unknown> = { program_id: req.params.programId, user_limit: req.params.userLimit ?? null, duration_type: req.params.durationType, is_active: true };
|
||||
if (productId) body.product_id = productId; // Produkt (Edition/Plan) des Lizenzsystems
|
||||
// Kunde und Herkunft nur senden, wenn das Lizenzsystem sie kennt (Feature "customer_info"); ältere Systeme würden sie ignorieren
|
||||
if (req.context && (await features(ctx)).includes('customer_info')) {
|
||||
const c = req.context;
|
||||
Object.assign(body, { source: c.source, customer_name: c.customerName, customer_email: c.customerEmail, customer_contact: c.contactName, customer_reference: c.customerNumber, order_ref: c.orderNumber, external_ref: c.contractNumber });
|
||||
}
|
||||
if (prefix) body.key_prefix = prefix;
|
||||
if (days) { const end = new Date(now().getTime() + days * 86400000).toISOString(); body.expires_at = zone === 'UTC' ? end : toLocalNaive(end, zone); }
|
||||
const created = await call<RawLicense>(ctx, 'POST', '/licenses/', body);
|
||||
if (!created || typeof created.id !== 'number') throw new ConnectorError('INVALID_RESPONSE');
|
||||
if (prefix && !String(created.license_key ?? '').toUpperCase().startsWith(`${prefix}-`)) throw new ConnectorError('INVALID_RESPONSE', `Lizenz ${created.id} wurde ohne das Präfix ${prefix} angelegt`);
|
||||
// Das Produkt darf nie stillschweigend verloren gehen (sonst bekäme der Kunde einen anderen Plan als bestellt)
|
||||
if (productId && created.product_id !== productId) throw new ConnectorError('INVALID_RESPONSE', `Lizenz ${created.id} wurde ohne das bestellte Produkt ${productId} angelegt`);
|
||||
return { resource: map(created, new Map(programs.map((p) => [p.id, p.name])), zone) };
|
||||
},
|
||||
async execute(ctx, req) {
|
||||
if (!hasAuth(ctx)) throw new ConnectorError('UNSUPPORTED', 'weder Service-Token noch API-Benutzer konfiguriert');
|
||||
const id = encodeURIComponent(req.externalRef); const f = await features(ctx); const zone = f.includes('utc_timestamps') ? 'UTC' : tz(ctx);
|
||||
const until = req.action === 'extend' ? String(req.params?.until ?? '') : '';
|
||||
if (req.action === 'extend' && Number.isNaN(Date.parse(until))) throw new ConnectorError('BAD_CONFIG', 'until fehlt');
|
||||
if (!['suspend', 'unsuspend', 'extend'].includes(req.action)) throw new ConnectorError('UNSUPPORTED');
|
||||
// Neuere Lizenzsysteme: eigene Lebenszyklus-Endpunkte (Status, Zeitstempel und Protokoll bleiben konsistent). Alle setzen einen Zielzustand.
|
||||
if (f.includes('lifecycle')) {
|
||||
const reason = 'Kundencenter';
|
||||
const r = await call<{ license?: RawLicense }>(ctx, 'POST', `/licenses/${id}/${req.action}`, req.action === 'extend' ? { until: new Date(until).toISOString(), reason } : { reason });
|
||||
if (!r?.license || typeof r.license.id !== 'number') throw new ConnectorError('INVALID_RESPONSE');
|
||||
return { resource: map(r.license, new Map(), zone) };
|
||||
}
|
||||
const body = req.action === 'suspend' ? { is_active: false } : req.action === 'unsuspend' ? { is_active: true } : { expires_at: zone === 'UTC' ? new Date(until).toISOString() : toLocalNaive(until, zone) };
|
||||
const l = await call<RawLicense>(ctx, 'PUT', `/licenses/${id}`, body);
|
||||
return { resource: map(l, new Map(), zone) };
|
||||
},
|
||||
};
|
||||
}
|
||||
export const licensingConnector = createLicensingConnector();
|
||||
121
packages/connector-licensing/test/contract.test.ts
Normal file
121
packages/connector-licensing/test/contract.test.ts
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { ConnectorError } from '@kc/connector-sdk';
|
||||
import { runContract } from '@kc/connector-sdk/dist/contract.js';
|
||||
import { createLicensingConnector, localToUtcIso } from '../src/index.js';
|
||||
|
||||
// Anonymisierte Beispielantworten des Lizenzsystems (Struktur wie /licenses/ und /programs/).
|
||||
const PROGRAMS = [{ id: 1, name: 'Beispiel-Tool', description: null, api_key: 'aaaaaaaa-0000-4000-8000-000000000001' }];
|
||||
const LICENSES = [
|
||||
{ id: 10, program_id: 1, license_key: '11111111-2222-4333-8444-555555555555', user_limit: 5, duration_type: 'YEAR', starts_at: '2026-01-01T10:00:00', expires_at: '2027-01-01T10:00:00', is_active: true, activation: { id: 1, license_id: 10, hardware_id: 'HW-ABCDEF123456', ip_address: '203.0.113.5', last_seen_ip: '203.0.113.5', activated_at: '2026-01-02T08:00:00', last_seen_at: '2026-09-25T07:00:00' } },
|
||||
{ id: 11, program_id: 1, license_key: '66666666-7777-4888-8999-000000000000', user_limit: null, duration_type: 'MONTH', starts_at: '2025-01-01T10:00:00', expires_at: '2025-01-31T10:00:00', is_active: true, activation: null },
|
||||
{ id: 12, program_id: 1, license_key: 'abcdefab-cdef-4abc-8def-abcdefabcdef', user_limit: 1, duration_type: 'UNLIMITED', starts_at: null, expires_at: null, is_active: false, activation: null },
|
||||
];
|
||||
const json = (b: unknown, status = 200) => new Response(JSON.stringify(b), { status, headers: { 'content-type': 'application/json' } });
|
||||
const CREATED = { id: 99, program_id: 1, license_key: 'deadbeef-dead-4bee-8fde-adbeefdeadbe', user_limit: 3, duration_type: 'YEAR', starts_at: '2026-09-26T12:00:00', expires_at: '2027-09-26T12:00:00', is_active: true, activation: null };
|
||||
let FEATURES: string[] | null = null;
|
||||
const fakeFetch = (log: { method: string; url: string; body?: string; auth?: string }[] = [], failFirst = 0) => {
|
||||
let fails = failFirst;
|
||||
return (async (url: string, init: RequestInit) => {
|
||||
const u = new URL(url); const h = init.headers as Record<string, string>;
|
||||
log.push({ method: init.method!, url: u.pathname, body: init.body as string, auth: h.authorization });
|
||||
if (fails-- > 0) return json({}, 503);
|
||||
if (u.pathname === '/') return json({ message: 'ok', ...(FEATURES ? { features: FEATURES } : {}) });
|
||||
if (u.pathname === '/token') return (init.body as string).includes('secret') ? json({ access_token: 'tok' }) : json({}, 401);
|
||||
if (u.pathname === '/programs/') return json(PROGRAMS);
|
||||
if (u.pathname === '/licenses/' && init.method === 'POST') { const b = JSON.parse(init.body as string); return json(b.key_prefix && !(FEATURES ?? []).includes('key_prefix') ? CREATED : { ...CREATED, license_key: b.key_prefix ? `${b.key_prefix}-deadbeef-dead-4bee-8fde-adbeefdeadbe` : CREATED.license_key, expires_at: b.expires_at ?? CREATED.expires_at }, 201); }
|
||||
if (u.pathname === '/licenses/') return json(LICENSES);
|
||||
if (u.pathname.startsWith('/licenses/') && init.method === 'PUT') return json({ ...LICENSES[0], ...JSON.parse(init.body as string) });
|
||||
return json({}, 404);
|
||||
}) as unknown as typeof fetch;
|
||||
};
|
||||
const ctx = (extra: Record<string, string> = {}) => ({ config: { baseUrl: 'http://lic.test' }, secrets: extra, correlationId: 'c' });
|
||||
const opts = (f: typeof fetch) => ({ fetchImpl: f, sleep: async () => {}, now: () => new Date('2026-09-26T12:00:00Z') });
|
||||
|
||||
describe('Lizenz-Connector', () => {
|
||||
it('erfüllt den Connector-Vertrag', async () => { await runContract(expect as never, createLicensingConnector(opts(fakeFetch())), ctx()); });
|
||||
it('normalisiert Status, Zeiten (Ortszeit → UTC) und maskiert Schlüssel', async () => {
|
||||
const list = await createLicensingConnector(opts(fakeFetch())).listResources(ctx());
|
||||
expect(list.map((r) => r.state)).toEqual(['active', 'expired', 'suspended']);
|
||||
expect(list[0]!.validUntil).toBe('2027-01-01T09:00:00.000Z'); // Berlin Winterzeit UTC+1
|
||||
expect(list[0]!.name).toBe('Beispiel-Tool · 1111…5555');
|
||||
expect(JSON.stringify(list)).not.toContain('11111111-2222');
|
||||
expect(JSON.stringify(list)).not.toContain('aaaaaaaa-0000'); // Program-API-Key gelangt nie in die Ausgabe
|
||||
});
|
||||
it('meldet Schreib-Fähigkeiten nur mit API-Benutzer', async () => {
|
||||
const c = createLicensingConnector(opts(fakeFetch()));
|
||||
expect(await c.capabilities(ctx())).not.toContain('lifecycle.suspend');
|
||||
expect(await c.capabilities(ctx({ username: 'u', password: 'secret' }))).toContain('lifecycle.suspend');
|
||||
});
|
||||
it('wiederholt GET bei 503 mit Backoff und gibt dann auf', async () => {
|
||||
const log: any[] = [];
|
||||
expect((await createLicensingConnector(opts(fakeFetch(log, 2))).listResources(ctx())).length).toBe(3);
|
||||
await expect(createLicensingConnector(opts(fakeFetch([], 99))).listResources(ctx())).rejects.toMatchObject({ code: 'UPSTREAM_ERROR', retryable: true });
|
||||
});
|
||||
it('führt Aktionen als absoluten Zielzustand aus (idempotent) und wiederholt Schreibzugriffe nicht', async () => {
|
||||
const log: any[] = []; const c = createLicensingConnector(opts(fakeFetch(log)));
|
||||
const r = await c.execute!(ctx({ username: 'u', password: 'secret' }), { action: 'suspend', externalRef: '10', idempotencyKey: 'k' });
|
||||
expect(r.resource?.state).toBe('suspended');
|
||||
expect(log.find((l) => l.method === 'PUT')!.body).toBe('{"is_active":false}');
|
||||
await expect(c.execute!(ctx(), { action: 'suspend', externalRef: '10', idempotencyKey: 'k' })).rejects.toMatchObject({ code: 'UNSUPPORTED' });
|
||||
const w: any[] = []; const failing = createLicensingConnector(opts(fakeFetch(w, 5)));
|
||||
await expect(failing.execute!(ctx({ username: 'u', password: 'secret' }), { action: 'unsuspend', externalRef: '10', idempotencyKey: 'k2' })).rejects.toBeInstanceOf(ConnectorError);
|
||||
expect(w.filter((l) => l.method === 'PUT').length).toBe(1);
|
||||
});
|
||||
it('legt Lizenzen an, validiert Parameter und wiederholt die Anlage nie', async () => {
|
||||
const log: any[] = []; const c = createLicensingConnector(opts(fakeFetch(log)));
|
||||
const auth = ctx({ username: 'u', password: 'secret' });
|
||||
expect(c.validateProvisioning!({ programId: 1, durationType: 'YEAR', userLimit: 3 })).toBeNull();
|
||||
expect(c.validateProvisioning!({ programId: 0, durationType: 'YEAR' })).toMatch(/programId/);
|
||||
expect(c.validateProvisioning!({ programId: 1, durationType: 'DAY' })).toMatch(/durationType/);
|
||||
expect(await c.capabilities(ctx())).not.toContain('lifecycle.create');
|
||||
const r = await c.provision!(auth, { params: { programId: 1, durationType: 'YEAR', userLimit: 3 }, label: 'X', idempotencyKey: 'k' });
|
||||
expect(r.resource.externalRef).toBe('99'); expect(r.resource.name).toBe('Beispiel-Tool · dead…dbe'.replace('…dbe', '…' + 'deadbeef-dead-4bee-8fde-adbeefdeadbe'.slice(-4)));
|
||||
expect(JSON.stringify(r)).not.toContain('deadbeef-dead');
|
||||
expect(log.find((l) => l.method === 'POST' && l.url === '/licenses/')!.body).toBe('{"program_id":1,"user_limit":3,"duration_type":"YEAR","is_active":true}');
|
||||
await expect(c.provision!(auth, { params: { programId: 5, durationType: 'YEAR' }, label: 'X', idempotencyKey: 'k' })).rejects.toMatchObject({ code: 'BAD_CONFIG' });
|
||||
// Anlage bei 503: genau ein POST, kein automatischer Retry
|
||||
const w: any[] = []; const f503 = (async (url: string, init: RequestInit) => { w.push(init.method + ' ' + new URL(url).pathname); const u = new URL(url); if (u.pathname === '/token') return json({ access_token: 't' }); if (u.pathname === '/programs/') return json(PROGRAMS); return json({}, 503); }) as unknown as typeof fetch;
|
||||
await expect(createLicensingConnector(opts(f503)).provision!(auth, { params: { programId: 1, durationType: 'YEAR' }, label: 'X', idempotencyKey: 'k' })).rejects.toMatchObject({ code: 'UPSTREAM_ERROR', ambiguous: true });
|
||||
expect(w.filter((x) => x === 'POST /licenses/').length).toBe(1);
|
||||
});
|
||||
it('liefert Programme als Produktvorlagen mit Vorschlägen, ohne Programm-API-Key', async () => {
|
||||
const items = await createLicensingConnector(opts(fakeFetch())).listCatalog!(ctx());
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0]).toMatchObject({ externalRef: '1', name: 'Beispiel-Tool', category: 'license', provisioning: { programId: 1, durationType: 'YEAR', userLimit: null } });
|
||||
expect(items[0]!.hints!.map((h) => h.label)).toEqual(expect.arrayContaining(['Jahr, 5 Benutzer', 'Monat, unbegrenzt viele Benutzer', 'Unbegrenzt, 1 Benutzer']));
|
||||
expect(JSON.stringify(items)).not.toContain('aaaaaaaa-0000'); // Program-API-Key
|
||||
expect(JSON.stringify(items)).not.toContain('11111111-2222'); // Lizenzschlüssel
|
||||
});
|
||||
it('Edition/Ablauf: ohne Erweiterung im Lizenzsystem wird NICHT angelegt; mit Erweiterung mit Präfix und Ablaufdatum', async () => {
|
||||
const auth = ctx({ username: 'u', password: 'secret' });
|
||||
const params = { programId: 1, durationType: 'WEEK', userLimit: null, keyPrefix: 'TRIAL', validityDays: 14 };
|
||||
// 1) altes Lizenzsystem (keine features): Anlage wird vor dem POST abgelehnt
|
||||
FEATURES = null; const log1: any[] = [];
|
||||
await expect(createLicensingConnector(opts(fakeFetch(log1))).provision!(auth, { params, label: 'X', idempotencyKey: 'k' })).rejects.toMatchObject({ code: 'BAD_CONFIG', notSent: true });
|
||||
expect(log1.some((l) => l.method === 'POST' && l.url === '/licenses/')).toBe(false);
|
||||
expect(await createLicensingConnector(opts(fakeFetch())).capabilities(auth)).not.toContain('license.key_prefix');
|
||||
// 2) erweitertes Lizenzsystem
|
||||
FEATURES = ['key_prefix', 'explicit_expiry']; const log2: any[] = [];
|
||||
const c = createLicensingConnector({ ...opts(fakeFetch(log2)), now: () => new Date('2026-09-26T12:00:00Z') });
|
||||
expect(await c.capabilities(auth)).toEqual(expect.arrayContaining(['license.key_prefix', 'license.expiry']));
|
||||
const r = await c.provision!(auth, { params, label: 'X', idempotencyKey: 'k2' });
|
||||
const post = JSON.parse(log2.find((l) => l.method === 'POST' && l.url === '/licenses/')!.body);
|
||||
expect(post).toMatchObject({ key_prefix: 'TRIAL', duration_type: 'WEEK' }); expect(post.expires_at).toBe('2026-10-10T14:00:00'); // 14 Tage, Berlin-Ortszeit
|
||||
expect(r.resource.details).toMatchObject({ edition: 'TRIAL' });
|
||||
// 3) Lizenzsystem ignoriert das Präfix (meldet features, liefert aber Schlüssel ohne Präfix): als unklar/fehlerhaft melden, nie still übergehen
|
||||
const ignoring = (async (url: string, init: RequestInit) => { const u = new URL(url); if (u.pathname === '/') return json({ features: ['key_prefix'] }); if (u.pathname === '/token') return json({ access_token: 't' }); if (u.pathname === '/programs/') return json(PROGRAMS); return json(CREATED, 201); }) as unknown as typeof fetch;
|
||||
await expect(createLicensingConnector(opts(ignoring)).provision!(auth, { params: { programId: 1, durationType: 'YEAR', keyPrefix: 'PREMIUM' }, label: 'X', idempotencyKey: 'k3' })).rejects.toMatchObject({ code: 'INVALID_RESPONSE', ambiguous: true });
|
||||
expect(c.validateProvisioning!({ programId: 1, durationType: 'YEAR', keyPrefix: 'GOLD' })).toMatch(/keyPrefix/);
|
||||
expect(c.validateProvisioning!({ programId: 1, durationType: 'YEAR', validityDays: 0 })).toMatch(/validityDays/);
|
||||
FEATURES = null;
|
||||
});
|
||||
it('erkennt falsche Zugangsdaten und Nichterreichbarkeit', async () => {
|
||||
await expect(createLicensingConnector(opts(fakeFetch())).listResources(ctx({ username: 'u', password: 'falsch' }))).rejects.toMatchObject({ code: 'AUTH_FAILED' });
|
||||
const down = createLicensingConnector({ ...opts(fakeFetch()), fetchImpl: (async () => { throw Object.assign(new TypeError('fetch failed'), { cause: { code: 'ECONNREFUSED' } }); }) as never });
|
||||
const h = await down.healthCheck(ctx()); expect(h.ok).toBe(false); expect(h.message).toContain('nicht erreichbar');
|
||||
});
|
||||
it('rechnet Ortszeit korrekt um (Sommer-/Winterzeit)', () => {
|
||||
expect(localToUtcIso('2026-07-01T12:00:00', 'Europe/Berlin')).toBe('2026-07-01T10:00:00.000Z');
|
||||
expect(localToUtcIso('2026-01-01T12:00:00', 'Europe/Berlin')).toBe('2026-01-01T11:00:00.000Z');
|
||||
});
|
||||
});
|
||||
113
packages/connector-licensing/test/modern.test.ts
Normal file
113
packages/connector-licensing/test/modern.test.ts
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { runContract } from '@kc/connector-sdk/dist/contract.js';
|
||||
import { createLicensingConnector } from '../src/index.js';
|
||||
|
||||
// Anonymisierte Antworten eines neueren Lizenzsystems (Produktkatalog, Lifecycle-Endpunkte, Service-Tokens, UTC).
|
||||
let FEATURES_OVERRIDE: string[] | null = null;
|
||||
const FEATURES = ['key_prefix', 'explicit_expiry', 'service_tokens', 'lifecycle', 'multi_activation', 'audit', 'utc_timestamps'];
|
||||
const PROGRAMS = [{ id: 1, name: 'Familytool', description: null }, { id: 2, name: 'RP-Framework', description: null }];
|
||||
const GROUPS = [
|
||||
{ id: 2, name: 'Premium', program_id: 1, is_active: true, products: [
|
||||
{ id: 3, name: 'Monatlich', description: 'Premium-Funktionen', price: '2.99', currency: 'EUR', duration_type: 'MONTH', user_limit: null, is_active: true, features: 'Stundenplan\nAufräumplan', modules: 'a,b', badge: null, product_key: 'SECRET-PRODUCT-KEY', public_key: 'SECRET-PUBLIC' },
|
||||
{ id: 4, name: 'Jährlich', price: '29.90', currency: 'EUR', duration_type: 'YEAR', user_limit: null, is_active: true, features: '', modules: '' }] },
|
||||
{ id: 4, name: 'Testen', program_id: 1, is_active: true, products: [{ id: 2, name: 'Testen', price: '0.00', currency: 'EUR', duration_type: 'UNLIMITED', user_limit: 2, is_active: true, features: 'Kalender' }] },
|
||||
{ id: 6, name: 'Server-Lizenzen', program_id: 2, is_active: false, products: [{ id: 7, name: 'Starter', price: '0.00', currency: 'EUR', duration_type: 'MONTH', is_active: false }] },
|
||||
];
|
||||
const lic = (o: object = {}) => ({ id: 18, program_id: 1, product_id: 6, license_key: 'aaaaaaaa-1111-4222-8333-bbbbbbbbbbbb', user_limit: null, duration_type: 'MONTH', starts_at: '2026-09-01T10:00:00Z', expires_at: '2026-10-01T10:00:00Z', is_active: true, status: 'active', blocked: false, suspended_at: null, revoked_at: null, product: { name: 'Premium' }, activation: null, activations: [], ...o });
|
||||
const json = (b: unknown, status = 200) => new Response(JSON.stringify(b), { status, headers: { 'content-type': 'application/json' } });
|
||||
interface Call { method: string; path: string; auth?: string; body?: any }
|
||||
const api = (opts: { licenses?: object[]; features?: string[] | null; log?: Call[] } = {}) => {
|
||||
const log = opts.log ?? [];
|
||||
return (async (url: string, init: RequestInit) => {
|
||||
const u = new URL(url); const h = (init.headers ?? {}) as Record<string, string>; const body = init.body && !String(init.body).includes('=') ? JSON.parse(init.body as string) : init.body;
|
||||
log.push({ method: init.method!, path: u.pathname, auth: h.authorization, body });
|
||||
if (u.pathname === '/') return json({ message: 'ok', features: opts.features === undefined ? (FEATURES_OVERRIDE ?? FEATURES) : opts.features ?? undefined });
|
||||
if (h.authorization !== 'Bearer svc-token-1' && u.pathname !== '/token') return json({}, 401);
|
||||
if (u.pathname === '/programs/') return json(PROGRAMS);
|
||||
if (u.pathname === '/products/groups') return json(GROUPS);
|
||||
if (u.pathname === '/licenses/' && init.method === 'GET') return json(opts.licenses ?? [lic()]);
|
||||
if (/^\/licenses\/\d+$/.test(u.pathname) && init.method === 'GET') return json(lic({ id: Number(u.pathname.split('/')[2]) }));
|
||||
if (u.pathname === '/licenses/' && init.method === 'POST') return json(lic({ id: 99, product_id: body.product_id === 6 ? 6 : null, expires_at: body.expires_at ?? null }), 201);
|
||||
const m = /^\/licenses\/(\d+)\/(suspend|unsuspend|extend)$/.exec(u.pathname);
|
||||
if (m && init.method === 'POST') return json({ changed: true, action: m[2], license: lic({ id: Number(m[1]), is_active: m[2] !== 'suspend', suspended_at: m[2] === 'suspend' ? '2026-09-26T12:00:00Z' : null, status: m[2] === 'suspend' ? 'suspended' : 'active', expires_at: m[2] === 'extend' ? body.until : '2026-10-01T10:00:00Z' }) });
|
||||
return json({}, 404);
|
||||
}) as unknown as typeof fetch;
|
||||
};
|
||||
const ctx = { config: { baseUrl: 'http://lic.test', timezone: 'Europe/Berlin' }, secrets: { token: 'svc-token-1' }, correlationId: 'c' };
|
||||
const mk = (o: Parameters<typeof api>[0] = {}) => createLicensingConnector({ fetchImpl: api(o), sleep: async () => {}, now: () => new Date('2026-09-26T12:00:00Z') });
|
||||
|
||||
describe('Lizenz-Connector (neues Lizenzsystem)', () => {
|
||||
it('erfüllt den Vertrag mit Service-Token (kein Login)', async () => {
|
||||
const log: Call[] = []; await runContract(expect as never, mk({ log }), ctx);
|
||||
expect(log.some((l) => l.path === '/token')).toBe(false);
|
||||
expect(log.filter((l) => l.path !== '/').every((l) => l.auth === 'Bearer svc-token-1')).toBe(true);
|
||||
});
|
||||
it('liest den Produktkatalog des Lizenzsystems (Preis, Dauer, Features) ohne Schlüssel', async () => {
|
||||
const items = await mk().listCatalog!(ctx);
|
||||
expect(items.map((i) => i.name)).toEqual(['Premium – Monatlich', 'Premium – Jährlich', 'Testen', 'Server-Lizenzen – Starter']);
|
||||
const m = items[0]!;
|
||||
expect(m).toMatchObject({ externalRef: 'product:3', group: 'Premium', program: 'Familytool', interval: 'monthly', price: { cents: 299, currency: 'EUR' }, providerActive: true, features: ['Stundenplan', 'Aufräumplan'],
|
||||
provisioning: { programId: 1, productId: 3, durationType: 'MONTH', userLimit: null } });
|
||||
expect(items[1]).toMatchObject({ interval: 'yearly', price: { cents: 2990 } });
|
||||
expect(items[2]).toMatchObject({ interval: 'once', price: { cents: 0 }, provisioning: { userLimit: 2 } });
|
||||
expect(items[3]!.providerActive).toBe(false);
|
||||
expect(JSON.stringify(items)).not.toMatch(/SECRET|product_key|public_key/);
|
||||
});
|
||||
it('erkennt gesperrte, widerrufene und blockierte Lizenzen sowie Ablauf', async () => {
|
||||
const list = await mk({ licenses: [lic({ id: 1 }), lic({ id: 2, blocked: true }), lic({ id: 3, suspended_at: '2026-09-20T00:00:00Z', status: 'suspended', is_active: true }), lic({ id: 4, revoked_at: '2026-09-20T00:00:00Z' }), lic({ id: 5, expires_at: '2026-09-01T00:00:00Z' }), lic({ id: 6, status: 'revoked' })] }).listResources(ctx);
|
||||
expect(list.map((r) => r.state)).toEqual(['active', 'suspended', 'suspended', 'suspended', 'expired', 'suspended']);
|
||||
expect(list[0]!.validUntil).toBe('2026-10-01T10:00:00.000Z'); // UTC bleibt UTC (keine Ortszeit-Verschiebung)
|
||||
expect(list[0]!.details).toMatchObject({ product: 'Premium', edition: 'Premium' });
|
||||
expect(JSON.stringify(list)).not.toContain('aaaaaaaa-1111');
|
||||
});
|
||||
it('nutzt die Lebenszyklus-Endpunkte für Sperren, Entsperren und Verlängern', async () => {
|
||||
const log: Call[] = []; const c = mk({ log });
|
||||
expect((await c.execute!(ctx, { action: 'suspend', externalRef: '18', idempotencyKey: 'k' })).resource?.state).toBe('suspended');
|
||||
expect((await c.execute!(ctx, { action: 'unsuspend', externalRef: '18', idempotencyKey: 'k' })).resource?.state).toBe('active');
|
||||
const r = await c.execute!(ctx, { action: 'extend', externalRef: '18', params: { until: '2027-01-01T00:00:00.000Z' }, idempotencyKey: 'k' });
|
||||
expect(r.resource?.validUntil).toBe('2027-01-01T00:00:00.000Z');
|
||||
const posts = log.filter((l) => l.method === 'POST').map((l) => l.path); expect(posts).toEqual(['/licenses/18/suspend', '/licenses/18/unsuspend', '/licenses/18/extend']);
|
||||
expect(log.find((l) => l.path.endsWith('/extend'))!.body).toMatchObject({ until: '2027-01-01T00:00:00.000Z' });
|
||||
expect(log.some((l) => l.method === 'PUT')).toBe(false);
|
||||
});
|
||||
it('gibt den vollständigen Lizenzschlüssel nur über reveal heraus, sonst nie', async () => {
|
||||
const c = mk();
|
||||
expect(await c.capabilities(ctx)).toContain('secret.reveal');
|
||||
expect(await c.capabilities({ ...ctx, secrets: {} })).not.toContain('secret.reveal');
|
||||
expect(await c.reveal!(ctx, '18')).toEqual([{ label: 'Lizenzschlüssel', value: 'aaaaaaaa-1111-4222-8333-bbbbbbbbbbbb' }]);
|
||||
expect(JSON.stringify(await c.listResources(ctx))).not.toContain('aaaaaaaa-1111'); // Listen enthalten den Schlüssel weiterhin nie
|
||||
await expect(c.reveal!({ ...ctx, secrets: {} }, '18')).rejects.toMatchObject({ code: 'UNSUPPORTED' });
|
||||
});
|
||||
it('meldet Kunde und Herkunft (Kundencenter) nur, wenn das Lizenzsystem es unterstützt', async () => {
|
||||
const context = { source: 'kundencenter' as const, customerNumber: 'K-10001', customerName: 'Muster GmbH', contactName: 'Max Muster', customerEmail: 'max@example.test', orderNumber: 'B-20001', contractNumber: 'V-30001' };
|
||||
const params = { programId: 1, productId: 6, durationType: 'UNLIMITED', userLimit: null };
|
||||
// 1) ohne Unterstützung: Felder werden NICHT gesendet
|
||||
const log1: Call[] = []; await mk({ log: log1 }).provision!(ctx, { params, label: 'X', idempotencyKey: 'k', context });
|
||||
const b1 = log1.find((l) => l.method === 'POST' && l.path === '/licenses/')!.body; expect(b1).not.toHaveProperty('source'); expect(b1).not.toHaveProperty('customer_email');
|
||||
expect(await mk().capabilities(ctx)).not.toContain('license.customer_info');
|
||||
// 2) mit Unterstützung: alle Angaben werden gesendet
|
||||
FEATURES_OVERRIDE = [...FEATURES, 'customer_info'];
|
||||
try {
|
||||
const log2: Call[] = []; const c = mk({ log: log2 }); expect(await c.capabilities(ctx)).toContain('license.customer_info');
|
||||
await c.provision!(ctx, { params, label: 'X', idempotencyKey: 'k2', context });
|
||||
expect(log2.find((l) => l.method === 'POST' && l.path === '/licenses/')!.body).toMatchObject({ source: 'kundencenter', customer_name: 'Muster GmbH', customer_email: 'max@example.test', customer_contact: 'Max Muster', customer_reference: 'K-10001', order_ref: 'B-20001', external_ref: 'V-30001' });
|
||||
} finally { FEATURES_OVERRIDE = null; }
|
||||
});
|
||||
it('legt Lizenzen mit Produkt an und meldet, wenn das Produkt verloren ginge', async () => {
|
||||
const log: Call[] = []; const c = mk({ log });
|
||||
const params = { programId: 1, productId: 6, durationType: 'UNLIMITED', userLimit: null };
|
||||
expect((await c.provision!(ctx, { params, label: 'X', idempotencyKey: 'k' })).resource.externalRef).toBe('99');
|
||||
expect(log.find((l) => l.method === 'POST' && l.path === '/licenses/')!.body).toMatchObject({ program_id: 1, product_id: 6, duration_type: 'UNLIMITED' });
|
||||
// Lizenzsystem ignoriert das Produkt (liefert product_id null): nie still akzeptieren
|
||||
await expect(c.provision!(ctx, { params: { ...params, productId: 7 }, label: 'X', idempotencyKey: 'k2' })).rejects.toMatchObject({ code: 'INVALID_RESPONSE', ambiguous: true });
|
||||
expect(c.validateProvisioning!({ ...params, productId: 0 })).toMatch(/productId/);
|
||||
});
|
||||
it('Testphase mit festem Ablauf sendet UTC und meldet falschen Token als Anmeldefehler', async () => {
|
||||
const log: Call[] = []; const c = mk({ log });
|
||||
await c.provision!(ctx, { params: { programId: 1, durationType: 'WEEK', validityDays: 14, keyPrefix: 'TRIAL' }, label: 'X', idempotencyKey: 'k' }).catch(() => undefined);
|
||||
expect(log.find((l) => l.method === 'POST' && l.path === '/licenses/')!.body.expires_at).toBe('2026-10-10T12:00:00.000Z');
|
||||
await expect(mk().listResources({ ...ctx, secrets: { token: 'falsch' } })).rejects.toMatchObject({ code: 'AUTH_FAILED' });
|
||||
expect(await mk().capabilities(ctx)).toEqual(expect.arrayContaining(['catalog.list', 'lifecycle.suspend', 'lifecycle.create', 'license.key_prefix']));
|
||||
expect(await mk().capabilities({ ...ctx, secrets: {} })).not.toContain('lifecycle.suspend'); // ohne Zugang nur lesend
|
||||
});
|
||||
});
|
||||
1
packages/connector-licensing/tsconfig.json
Normal file
1
packages/connector-licensing/tsconfig.json
Normal file
|
|
@ -0,0 +1 @@
|
|||
{ "extends": "../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", "outDir": "dist", "declaration": true }, "include": ["src"] }
|
||||
21
packages/connector-mock/package.json
Normal file
21
packages/connector-mock/package.json
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"name": "@kc/connector-mock",
|
||||
"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:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^26.6.3",
|
||||
"typescript": "^7.0.2",
|
||||
"vitest": "^5.0.2"
|
||||
}
|
||||
}
|
||||
60
packages/connector-mock/src/index.ts
Normal file
60
packages/connector-mock/src/index.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import { ConnectorError, CONTRACT_VERSION, type Connector, type ConnectorContext, type NormalizedResource } from '@kc/connector-sdk';
|
||||
|
||||
/**
|
||||
* Mock-Connector für Entwicklung und Tests. Alle Daten sind erfundene Testdaten und klar mit "[Mock]" gekennzeichnet.
|
||||
* Zustand lebt im Speicher des jeweiligen Prozesses (Worker) und geht bei Neustart verloren.
|
||||
* config.simulateOutage = "true" simuliert einen Providerausfall.
|
||||
*/
|
||||
const state = new Map<string, Map<string, NormalizedResource>>();
|
||||
const seed = (): Map<string, NormalizedResource> => new Map<string, NormalizedResource>([
|
||||
['m-1', { externalRef: 'm-1', type: 'license', name: '[Mock] Beispiel-Lizenz Pro', state: 'active', validFrom: '2026-01-01T00:00:00.000Z', validUntil: '2027-01-01T00:00:00.000Z', limits: { users: 5 }, details: { mock: true } }],
|
||||
['m-2', { externalRef: 'm-2', type: 'license', name: '[Mock] Beispiel-Lizenz Basic', state: 'active', validFrom: '2026-03-01T00:00:00.000Z', validUntil: '2026-12-31T00:00:00.000Z', limits: { users: 1 }, details: { mock: true } }],
|
||||
['m-3', { externalRef: 'm-3', type: 'hosting_account', name: '[Mock] Hosting-Konto', state: 'active', limits: { storageMb: 10240 }, usage: { storageMb: 1234 }, details: { mock: true } }],
|
||||
]);
|
||||
const store = (ctx: ConnectorContext) => { const k = String(ctx.config.instance ?? 'default'); if (!state.has(k)) state.set(k, seed()); return state.get(k)!; };
|
||||
const outage = (ctx: ConnectorContext) => { if (String(ctx.config.simulateOutage) === 'true') throw new ConnectorError('UNREACHABLE', 'simulierter Ausfall'); };
|
||||
export const resetMock = () => state.clear();
|
||||
|
||||
export const mockConnector: Connector = {
|
||||
contractVersion: CONTRACT_VERSION, key: 'mock', displayName: 'Mock (Testdaten)',
|
||||
configFields: [{ name: 'instance', label: 'Instanzname', placeholder: 'default' }, { name: 'simulateOutage', label: 'Ausfall simulieren (true/false)', placeholder: 'false' }],
|
||||
capabilities: () => ['catalog.list', 'resources.list', 'resources.get', 'status.read', 'usage.read', 'lifecycle.suspend', 'lifecycle.unsuspend', 'lifecycle.extend', 'lifecycle.terminate', 'lifecycle.create', 'license.key_prefix', 'license.expiry', 'secret.reveal'],
|
||||
async healthCheck(ctx) {
|
||||
try { outage(ctx); return { ok: true, latencyMs: 1, message: 'Mock', version: '1' }; } catch (e) { return { ok: false, latencyMs: 1, message: (e as ConnectorError).userMessage }; }
|
||||
},
|
||||
validateProvisioning(params) {
|
||||
if ('failCreate' in params && typeof params.failCreate !== 'string') return 'failCreate muss Text sein';
|
||||
return null;
|
||||
},
|
||||
/** Legt eine Mock-Ressource an. params.failCreate = "timeout" | "unreachable" simuliert Fehler (nur für Tests). */
|
||||
async provision(ctx, req) {
|
||||
outage(ctx);
|
||||
if (req.params.failCreate === 'timeout') throw new ConnectorError('TIMEOUT');
|
||||
if (req.params.failCreate === 'unreachable') throw new ConnectorError('UNREACHABLE', 'ECONNREFUSED simuliert');
|
||||
const s = store(ctx);
|
||||
const ref = `m-${req.idempotencyKey.slice(0, 8)}`; // gleicher Schlüssel => gleiche Ressource (Mock ist idempotent)
|
||||
if (!s.has(ref)) s.set(ref, { externalRef: ref, type: 'license', name: `[Mock] ${req.label}`, state: 'active', validFrom: new Date().toISOString(), validUntil: req.params.validityDays ? new Date(Date.now() + Number(req.params.validityDays) * 86400000).toISOString() : req.params.durationType === 'YEAR' ? new Date(Date.now() + 365 * 86400000).toISOString() : req.params.durationType === 'MONTH' ? new Date(Date.now() + 30 * 86400000).toISOString() : null, limits: { users: Number(req.params.userLimit ?? 1) }, details: { mock: true, edition: String(req.params.keyPrefix ?? 'UNLIMITED'), context: req.context ?? null } });
|
||||
return { resource: { ...s.get(ref)! } };
|
||||
},
|
||||
async reveal(ctx, ref) { outage(ctx); if (!store(ctx).has(ref)) throw new ConnectorError('NOT_FOUND'); return [{ label: 'Lizenzschlüssel', value: `MOCK-KEY-${ref}` }]; },
|
||||
async listCatalog(ctx) {
|
||||
outage(ctx);
|
||||
return [
|
||||
{ externalRef: 'mock-prog-1', name: '[Mock] Programm Alpha', description: 'Testprogramm', category: 'license', provisioning: { userLimit: 5 }, hints: [{ label: '5 Benutzer', provisioning: { userLimit: 5 }, count: 2 }] },
|
||||
{ externalRef: 'mock-prog-2', name: '[Mock] Programm Beta', description: null, category: 'license', provisioning: { userLimit: 1 } },
|
||||
];
|
||||
},
|
||||
async listResources(ctx) { outage(ctx); return [...store(ctx).values()].map((r) => ({ ...r })); },
|
||||
async execute(ctx, req) {
|
||||
outage(ctx);
|
||||
const r = store(ctx).get(req.externalRef);
|
||||
if (!r) throw new ConnectorError('NOT_FOUND');
|
||||
// Zielzustand setzen: bei Wiederholung mit gleichem Schlüssel wirkungsgleich
|
||||
if (req.action === 'suspend') r.state = 'suspended';
|
||||
else if (req.action === 'unsuspend') r.state = 'active';
|
||||
else if (req.action === 'extend') { const u = String(req.params?.until ?? ''); if (Number.isNaN(Date.parse(u))) throw new ConnectorError('BAD_CONFIG', 'until fehlt'); r.validUntil = new Date(u).toISOString(); }
|
||||
else if (req.action === 'terminate') { store(ctx).delete(req.externalRef); return {}; }
|
||||
else throw new ConnectorError('UNSUPPORTED');
|
||||
return { resource: { ...r } };
|
||||
},
|
||||
};
|
||||
16
packages/connector-mock/test/contract.test.ts
Normal file
16
packages/connector-mock/test/contract.test.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { runContract } from '@kc/connector-sdk/dist/contract.js';
|
||||
import { mockConnector, resetMock } from '../src/index.js';
|
||||
const ctx = (c: Record<string, unknown> = {}) => ({ config: { instance: 't', ...c }, secrets: {}, correlationId: 'c' });
|
||||
describe('Mock-Connector', () => {
|
||||
it('erfüllt den Vertrag', async () => { resetMock(); expect((await runContract(expect as never, mockConnector, ctx())).length).toBe(3); });
|
||||
it('sperrt und entsperrt', async () => {
|
||||
resetMock();
|
||||
expect((await mockConnector.execute!(ctx(), { action: 'suspend', externalRef: 'm-1', idempotencyKey: 'a' })).resource?.state).toBe('suspended');
|
||||
expect((await mockConnector.execute!(ctx(), { action: 'unsuspend', externalRef: 'm-1', idempotencyKey: 'b' })).resource?.state).toBe('active');
|
||||
});
|
||||
it('simuliert Ausfall', async () => {
|
||||
expect((await mockConnector.healthCheck(ctx({ simulateOutage: 'true' }))).ok).toBe(false);
|
||||
await expect(mockConnector.listResources(ctx({ simulateOutage: 'true' }))).rejects.toMatchObject({ code: 'UNREACHABLE', retryable: true });
|
||||
});
|
||||
});
|
||||
1
packages/connector-mock/tsconfig.json
Normal file
1
packages/connector-mock/tsconfig.json
Normal file
|
|
@ -0,0 +1 @@
|
|||
{ "extends": "../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", "outDir": "dist", "declaration": true }, "include": ["src"] }
|
||||
18
packages/connector-sdk/package.json
Normal file
18
packages/connector-sdk/package.json
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
{
|
||||
"name": "@kc/connector-sdk",
|
||||
"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 --passWithNoTests"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^26.6.3",
|
||||
"typescript": "^7.0.2",
|
||||
"vitest": "^5.0.2"
|
||||
}
|
||||
}
|
||||
25
packages/connector-sdk/src/contract.ts
Normal file
25
packages/connector-sdk/src/contract.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import type { Connector, ConnectorContext, NormalizedResource } from './index.js';
|
||||
import { ConnectorError } from './index.js';
|
||||
|
||||
/**
|
||||
* Vertragstest, den JEDER Connector bestehen muss (mit Mock-/Fixture-Backend).
|
||||
* Wird aus den Connector-Paketen mit vitest aufgerufen: `runContract(expect, connector, ctx)`.
|
||||
*/
|
||||
export async function runContract(expect: (v: unknown) => any, c: Connector, ctx: ConnectorContext): Promise<NormalizedResource[]> {
|
||||
expect(c.contractVersion).toBe(1);
|
||||
expect(c.key).toMatch(/^[a-z][a-z0-9-]*$/);
|
||||
const caps = await c.capabilities(ctx);
|
||||
expect(Array.isArray(caps)).toBe(true);
|
||||
const h = await c.healthCheck(ctx);
|
||||
expect(typeof h.ok).toBe('boolean'); expect(typeof h.latencyMs).toBe('number');
|
||||
const list = await c.listResources(ctx);
|
||||
for (const r of list) {
|
||||
expect(typeof r.externalRef).toBe('string'); expect(r.externalRef.length > 0).toBe(true);
|
||||
expect(['active', 'suspended', 'expired', 'error', 'unknown']).toContain(r.state);
|
||||
// keine Geheimnisse in Detaildaten
|
||||
expect(JSON.stringify(r.details ?? {})).not.toMatch(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i);
|
||||
}
|
||||
if (caps.includes('lifecycle.suspend')) expect(typeof c.execute).toBe('function');
|
||||
expect(ConnectorError).toBeDefined();
|
||||
return list;
|
||||
}
|
||||
216
packages/connector-sdk/src/index.ts
Normal file
216
packages/connector-sdk/src/index.ts
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
/**
|
||||
* Connector-Vertrag v1. Jeder Provider (KeyHelp, Plesk, Lizenzsystem, Mock, …) implementiert `Connector`.
|
||||
* Provider-spezifische Datenmodelle dürfen den Connector nicht verlassen: nach außen gehen nur normalisierte Typen.
|
||||
*/
|
||||
export const CONTRACT_VERSION = 1 as const;
|
||||
|
||||
/** Fähigkeiten. Backend und UI bieten nur an, was der Connector meldet. */
|
||||
export type Capability =
|
||||
| 'customers.list' | 'catalog.write' | 'children.read' | 'children.write' | 'secret.reveal' | 'license.customer_info' | 'catalog.list' | 'license.key_prefix' | 'license.expiry' | 'resources.list' | 'resources.get' | 'status.read' | 'usage.read'
|
||||
| 'lifecycle.create' | 'lifecycle.suspend' | 'lifecycle.unsuspend' | 'lifecycle.terminate' | 'lifecycle.extend'
|
||||
| 'settings.update' | 'plan.change' | 'sso.login' | 'webhooks';
|
||||
|
||||
/** Aktionen, die über `execute` laufen (immer als persistenter Auftrag). */
|
||||
export type ActionName = 'suspend' | 'unsuspend' | 'extend' | 'terminate' | 'change_plan';
|
||||
export const ACTION_CAPABILITY: Record<ActionName, Capability> = {
|
||||
suspend: 'lifecycle.suspend', unsuspend: 'lifecycle.unsuspend', extend: 'lifecycle.extend', terminate: 'lifecycle.terminate', change_plan: 'plan.change',
|
||||
};
|
||||
/** Aktionen, die nie automatisch wiederholt werden dürfen (destruktiv). */
|
||||
export const DESTRUCTIVE_ACTIONS: ReadonlySet<ActionName> = new Set(['terminate']);
|
||||
|
||||
export type ResourceType = 'license' | 'hosting_account' | 'domain' | 'server';
|
||||
export type ResourceState = 'active' | 'suspended' | 'expired' | 'error' | 'unknown';
|
||||
|
||||
export interface NormalizedResource {
|
||||
externalRef: string; // stabile ID beim Provider
|
||||
type: ResourceType;
|
||||
name: string;
|
||||
state: ResourceState;
|
||||
validFrom?: string | null; // ISO 8601 UTC
|
||||
validUntil?: string | null;
|
||||
limits?: Record<string, number | string | null>;
|
||||
usage?: Record<string, number | string | null>;
|
||||
/** Anzeigedaten. Geheimnisse (z. B. vollständige Lizenzschlüssel) sind bereits maskiert. */
|
||||
details?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Angebot/Produktvorlage beim Anbieter (z. B. ein Programm im Lizenzsystem), Grundlage für die Produktübernahme. Enthält keine Geheimnisse. */
|
||||
export interface CatalogItem {
|
||||
externalRef: string; name: string; description?: string | null; category: 'hosting' | 'license' | 'addon' | 'service';
|
||||
/** Optionale Angaben des Anbieters, die die Übernahme vorbelegen. */
|
||||
/** true: Der Anbieter legt die Provisionierung fest (nicht änderbar bei der Übernahme). */
|
||||
fixed?: boolean;
|
||||
group?: string; program?: string; features?: string[]; badge?: string | null; providerActive?: boolean;
|
||||
price?: { cents: number; currency: string }; interval?: 'once' | 'monthly' | 'yearly'; meta?: Record<string, string | number | null>;
|
||||
/** Standard-Provisionierungsparameter (Grundlage für Produkte). */
|
||||
provisioning: Record<string, unknown>;
|
||||
/** Vorschläge aus dem Bestand des Anbieters (z. B. übliche Laufzeit/Limit bestehender Lizenzen). */
|
||||
hints?: { label: string; provisioning: Record<string, unknown>; count: number }[];
|
||||
}
|
||||
|
||||
/** Angaben zum Kunden und Vorgang, die bei der Anlage an den Anbieter gemeldet werden können (nur wenn dieser sie unterstützt). */
|
||||
export interface ProvisionContext {
|
||||
source: 'kundencenter'; customerNumber: string; customerName: string; contactName: string | null; customerEmail: string | null; orderNumber: string; contractNumber: string;
|
||||
}
|
||||
|
||||
// ---- Kunden des Anbieters (Übernahme) und neue Angebote ----
|
||||
/** Ein Kunde/Konto beim Anbieter, das ins Kundencenter übernommen werden kann. Enthält keine Geheimnisse. */
|
||||
export interface ImportableCustomer {
|
||||
externalRef: string; displayName: string; company: string | null; firstName: string | null; lastName: string | null; email: string | null; phone: string | null;
|
||||
address: { street: string | null; zip: string | null; city: string | null; state: string | null; country: string | null };
|
||||
/** Kundennummer beim Anbieter, falls vorhanden. */ legacyNumber: string | null;
|
||||
planRef: string | null; planName: string | null; state: 'active' | 'suspended'; createdAt: string | null;
|
||||
}
|
||||
/** Neues Angebot (z. B. Hosting-Tarif) beim Anbieter anlegen. null = unbegrenzt. Größen in GB. */
|
||||
export interface NewCatalogSpec {
|
||||
name: string;
|
||||
limits: { diskSpaceGb?: number | null; trafficGb?: number | null; domains?: number | null; subdomains?: number | null; emailAccounts?: number | null; emailAddresses?: number | null; emailForwardings?: number | null; databases?: number | null; ftpUsers?: number | null; scheduledTasks?: number | null };
|
||||
permissions?: Record<string, boolean>;
|
||||
}
|
||||
|
||||
// ---- Unterobjekte (z. B. Domains, Postfächer, Datenbanken eines Hosting-Kontos) ----
|
||||
export type ChildKind = 'domain' | 'email' | 'database' | 'ftp' | 'certificate';
|
||||
export type ChildOp = 'create' | 'update' | 'delete';
|
||||
export interface NormalizedChild {
|
||||
id: string; kind: ChildKind; name: string; state: 'active' | 'disabled' | 'pending' | 'error' | 'expired';
|
||||
validUntil?: string | null;
|
||||
/** Anzeigedaten ohne Geheimnisse (Passwörter werden nie geliefert). */
|
||||
details: Record<string, unknown>;
|
||||
}
|
||||
export interface ChildAccess {
|
||||
/** Welche Arten der Anbieter für dieses Objekt kennt und ob Schreiben möglich ist. */
|
||||
kinds(ctx: ConnectorContext, parentRef: string): Promise<{ kind: ChildKind; canWrite: boolean }[]>;
|
||||
/** Liest die Unterobjekte NUR des angegebenen Elternobjekts (Mandantentrennung liegt im Connector). */
|
||||
list(ctx: ConnectorContext, parentRef: string, kind: ChildKind): Promise<NormalizedChild[]>;
|
||||
/**
|
||||
* Ändert ein Unterobjekt. Betrifft nur Objekte des Elternobjekts (Zugehörigkeit wird vor jeder Änderung geprüft).
|
||||
* `secrets` (z. B. Passwörter) laufen getrennt von `data`, damit sie nie protokolliert werden.
|
||||
*/
|
||||
act(ctx: ConnectorContext, req: { parentRef: string; kind: ChildKind; op: ChildOp; id?: string; data: Record<string, unknown>; secrets?: Record<string, string>; idempotencyKey: string }): Promise<{ child?: NormalizedChild }>;
|
||||
}
|
||||
|
||||
export interface Health { ok: boolean; latencyMs: number; message?: string; version?: string }
|
||||
|
||||
export interface ConnectorContext {
|
||||
config: Record<string, unknown>;
|
||||
secrets: Record<string, string>;
|
||||
correlationId: string;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export interface Connector {
|
||||
readonly contractVersion: typeof CONTRACT_VERSION;
|
||||
readonly key: string;
|
||||
readonly displayName: string;
|
||||
/** Beschreibt Konfigurationsfelder (für die Admin-UI); secret=true wird verschlüsselt gespeichert. */
|
||||
readonly configFields: { name: string; label: string; secret?: boolean; required?: boolean; placeholder?: string; /** Erklärungstext unter dem Feld */ help?: string; /** Erweiterte Einstellung (in der Oberfläche eingeklappt) */ advanced?: boolean; /** Auswahlfeld statt Freitext */ options?: { value: string; label: string }[] }[];
|
||||
capabilities(ctx: ConnectorContext): Promise<Capability[]> | Capability[];
|
||||
healthCheck(ctx: ConnectorContext): Promise<Health>;
|
||||
listResources(ctx: ConnectorContext): Promise<NormalizedResource[]>;
|
||||
/**
|
||||
* Liefert Zugangsdaten/Schlüssel einer Ressource im Klartext (z. B. den vollständigen Lizenzschlüssel) für die berechtigte Anzeige auf Abruf.
|
||||
* Die Werte werden nie gespeichert oder protokolliert (Capability `secret.reveal`).
|
||||
*/
|
||||
/** Kurzlebiger Login-Link ins Kundenpanel (Capability `sso.login`). Wird nie gespeichert. */
|
||||
loginUrl?(ctx: ConnectorContext, externalRef: string): Promise<{ url: string; validForSec: number }>;
|
||||
/** Unterobjekte eines Objekts (Capability `children.read`/`children.write`). */
|
||||
children?: ChildAccess;
|
||||
reveal?(ctx: ConnectorContext, externalRef: string): Promise<{ label: string; value: string }[]>;
|
||||
/** Liest die Kunden des Anbieters für die Übernahme (Capability `customers.list`). */
|
||||
listCustomers?(ctx: ConnectorContext): Promise<ImportableCustomer[]>;
|
||||
/** Legt ein neues Angebot beim Anbieter an und liefert es als Vorlage zurück (Capability `catalog.write`). */
|
||||
createCatalogItem?(ctx: ConnectorContext, spec: NewCatalogSpec): Promise<CatalogItem>;
|
||||
/** Liest Angebote/Produktvorlagen des Anbieters aus (Capability `catalog.list`). */
|
||||
listCatalog?(ctx: ConnectorContext): Promise<CatalogItem[]>;
|
||||
/** Prüft Provisionierungsparameter eines Produkts (beim Speichern). Liefert eine Fehlermeldung oder null. */
|
||||
validateProvisioning?(params: Record<string, unknown>): string | null;
|
||||
/**
|
||||
* Legt beim Provider ein neues Objekt an (Capability `lifecycle.create`). NICHT idempotent beim Provider:
|
||||
* Der Aufrufer wiederholt nur bei Fehlern, bei denen die Anfrage sicher nicht verarbeitet wurde (UNREACHABLE, RATE_LIMITED).
|
||||
*/
|
||||
provision?(ctx: ConnectorContext, req: { params: Record<string, unknown>; label: string; idempotencyKey: string; context?: ProvisionContext }): Promise<{ resource: NormalizedResource }>;
|
||||
/** Idempotent: derselbe idempotencyKey darf beim Provider nie zu einer Doppelausführung führen. */
|
||||
execute?(ctx: ConnectorContext, req: { action: ActionName; externalRef: string; params?: Record<string, unknown>; idempotencyKey: string }): Promise<{ resource?: NormalizedResource }>;
|
||||
}
|
||||
|
||||
// ---- Normalisierte Fehler --------------------------------------------------
|
||||
export type ErrorCode = 'UNREACHABLE' | 'TIMEOUT' | 'AUTH_FAILED' | 'RATE_LIMITED' | 'NOT_FOUND' | 'INVALID_RESPONSE' | 'UNSUPPORTED' | 'CONFLICT' | 'UPSTREAM_ERROR' | 'BAD_CONFIG' | 'INVALID_INPUT';
|
||||
/** Fehler, bei denen die Anfrage sicher NICHT beim Provider angekommen ist (Anlage darf wiederholt werden). */
|
||||
export const NOT_SENT: ReadonlySet<ErrorCode> = new Set<ErrorCode>(['RATE_LIMITED']);
|
||||
const RETRYABLE: ReadonlySet<ErrorCode> = new Set(['UNREACHABLE', 'TIMEOUT', 'RATE_LIMITED', 'UPSTREAM_ERROR']);
|
||||
const MESSAGES: Record<ErrorCode, string> = {
|
||||
UNREACHABLE: 'Der Dienst ist nicht erreichbar.', TIMEOUT: 'Der Dienst hat nicht rechtzeitig geantwortet.', AUTH_FAILED: 'Anmeldung beim Dienst fehlgeschlagen.',
|
||||
RATE_LIMITED: 'Zu viele Anfragen, bitte später erneut versuchen.', NOT_FOUND: 'Objekt beim Dienst nicht gefunden.', INVALID_RESPONSE: 'Unerwartete Antwort des Dienstes.',
|
||||
UNSUPPORTED: 'Diese Aktion wird nicht unterstützt.', CONFLICT: 'Konflikt mit dem Zustand beim Dienst.', UPSTREAM_ERROR: 'Der Dienst meldet einen Fehler.', BAD_CONFIG: 'Die Verbindung ist unvollständig konfiguriert.', INVALID_INPUT: 'Die Eingaben wurden vom Dienst abgelehnt.',
|
||||
};
|
||||
export class ConnectorError extends Error {
|
||||
readonly retryable: boolean;
|
||||
constructor(public code: ErrorCode, public detail?: string, public retryAfterMs?: number) {
|
||||
super(detail ? `${MESSAGES[code]} (${detail})` : MESSAGES[code]);
|
||||
this.retryable = RETRYABLE.has(code);
|
||||
}
|
||||
/** Verständliche Meldung für die Oberfläche (ohne technische Details). */
|
||||
/** Die Anfrage ist sicher NICHT beim Provider verarbeitet worden (Verbindung verweigert, abgelehnt, Konfiguration falsch). */
|
||||
get notSent(): boolean {
|
||||
if (['RATE_LIMITED', 'AUTH_FAILED', 'BAD_CONFIG', 'UNSUPPORTED', 'NOT_FOUND', 'CONFLICT', 'INVALID_INPUT'].includes(this.code)) return true;
|
||||
return this.code === 'UNREACHABLE' && /ECONNREFUSED|ENOTFOUND|EAI_AGAIN|EHOSTUNREACH|ENETUNREACH/.test(this.detail ?? '');
|
||||
}
|
||||
/** Nach diesem Fehler ist unklar, ob der Provider die Anfrage ausgeführt hat (Timeout, 5xx, ungültige Antwort). */
|
||||
get ambiguous(): boolean { return !this.notSent; }
|
||||
get userMessage(): string { return MESSAGES[this.code]; }
|
||||
/** Wahrscheinliche Ursache und Abhilfe in einfachen Worten (aus Fehlercode und technischem Detail). */
|
||||
get hint(): string {
|
||||
const d = this.detail ?? '';
|
||||
if (/ENOTFOUND|EAI_AGAIN/.test(d)) return 'Der Servername wird nicht aufgelöst. Adresse auf Tippfehler prüfen und ob der Name vom Kundencenter-Server aus auflösbar ist.';
|
||||
if (/ECONNREFUSED/.test(d)) return 'Der Server ist erreichbar, aber an dieser Adresse/diesem Port läuft nichts. Port und https/http prüfen.';
|
||||
if (/EHOSTUNREACH|ENETUNREACH|ETIMEDOUT|UND_ERR_CONNECT_TIMEOUT/.test(d)) return 'Keine Netzwerkverbindung zum Server. Firewall/VLAN-Regeln prüfen (kann das Kundencenter den Server erreichen?).';
|
||||
if (/CERT|SELF.SIGNED|UNABLE_TO_VERIFY|ERR_TLS|SSL/i.test(d)) return 'Das HTTPS-Zertifikat wird nicht als vertrauenswürdig akzeptiert. Bei selbstsigniertem Zertifikat unter „Erweiterte Einstellungen“ den Fingerabdruck eintragen.';
|
||||
if (this.code === 'BAD_CONFIG') return 'Die gespeicherten Einstellungen sind unvollständig oder fehlerhaft (siehe technische Details). Unter „Zugangsdaten / Einstellungen ändern“ korrigieren.';
|
||||
if (this.code === 'AUTH_FAILED' || /Anmeldung beim Dienst|HTTP 40[13]/.test(d)) return 'Der API-Schlüssel wurde abgelehnt. Schlüssel prüfen und ob er auf die IP-Adresse des Kundencenter-Servers erlaubt ist.';
|
||||
if (this.code === 'NOT_FOUND' || /nicht gefunden/.test(d)) return 'Die Adresse antwortet, aber die API wurde nicht gefunden. Ist die Adresse die des Panels (ohne /api/v2)?';
|
||||
if (this.code === 'TIMEOUT' || /nicht rechtzeitig/.test(d)) return 'Keine Antwort innerhalb der Wartezeit. Server oder Netzwerk überlastet, oder Firewall verwirft Pakete.';
|
||||
if (this.code === 'INVALID_RESPONSE' || /kein JSON|ungültige Antwort/.test(d)) return 'Die Adresse antwortet, aber nicht wie eine API (evtl. falsche Adresse oder Weiterleitung auf eine Webseite).';
|
||||
if (this.code === 'UPSTREAM_ERROR' || /HTTP 5\d\d/.test(d)) return 'Der Dienst meldet einen eigenen Fehler. Dort im Protokoll nachsehen.';
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
// ---- HTTP-Helfer: Timeout, Backoff, Rate-Limit -----------------------------
|
||||
export interface HttpOptions { timeoutMs?: number; retries?: number; baseDelayMs?: number; fetchImpl?: typeof fetch; sleep?: (ms: number) => Promise<void> }
|
||||
const defaultSleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
|
||||
|
||||
/** JSON-Anfrage mit Timeout. Wiederholungen mit exponentiellem Backoff nur für GET (idempotent) bzw. wenn `retryWrites`. */
|
||||
export async function httpJson<T>(method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE', url: string, init: { headers?: Record<string, string>; body?: unknown; form?: Record<string, string> } = {}, o: HttpOptions & { retryWrites?: boolean } = {}): Promise<T> {
|
||||
const f = o.fetchImpl ?? fetch; const sleep = o.sleep ?? defaultSleep;
|
||||
const attempts = (method === 'GET' || o.retryWrites ? (o.retries ?? 2) : 0) + 1;
|
||||
let last: ConnectorError | undefined;
|
||||
for (let i = 0; i < attempts; i++) {
|
||||
const ctl = new AbortController(); const timer = setTimeout(() => ctl.abort(), o.timeoutMs ?? 10_000);
|
||||
try {
|
||||
const headers: Record<string, string> = { accept: 'application/json', ...(init.headers ?? {}) };
|
||||
let body: string | undefined;
|
||||
if (init.form) { headers['content-type'] = 'application/x-www-form-urlencoded'; body = new URLSearchParams(init.form).toString(); }
|
||||
else if (init.body !== undefined) { headers['content-type'] = 'application/json'; body = JSON.stringify(init.body); }
|
||||
const res = await f(url, { method, headers, body, signal: ctl.signal });
|
||||
if (res.status === 401 || res.status === 403) throw new ConnectorError('AUTH_FAILED', `HTTP ${res.status}`);
|
||||
if (res.status === 404) throw new ConnectorError('NOT_FOUND');
|
||||
if (res.status === 400) { const b = await res.json().catch(() => null) as { message?: string } | null; throw new ConnectorError('INVALID_INPUT', typeof b?.message === 'string' ? b.message.slice(0, 200) : undefined); }
|
||||
if (res.status === 409) throw new ConnectorError('CONFLICT');
|
||||
if (res.status === 429) { const ra = Number(res.headers.get('retry-after')); throw new ConnectorError('RATE_LIMITED', undefined, Number.isFinite(ra) && ra > 0 ? ra * 1000 : undefined); }
|
||||
if (res.status >= 500) throw new ConnectorError('UPSTREAM_ERROR', `HTTP ${res.status}`);
|
||||
if (!res.ok) throw new ConnectorError('UPSTREAM_ERROR', `HTTP ${res.status}`);
|
||||
if (res.status === 204) return undefined as T;
|
||||
try { return (await res.json()) as T; } catch { throw new ConnectorError('INVALID_RESPONSE', 'kein JSON'); }
|
||||
} catch (e) {
|
||||
last = e instanceof ConnectorError ? e
|
||||
: (e as { name?: string }).name === 'AbortError' ? new ConnectorError('TIMEOUT')
|
||||
: new ConnectorError('UNREACHABLE', (e as { cause?: { code?: string } }).cause?.code ?? (e as Error).message);
|
||||
if (!last.retryable || i === attempts - 1) throw last;
|
||||
await sleep(last.retryAfterMs ?? Math.min(10_000, (o.baseDelayMs ?? 300) * 2 ** i) + Math.floor(Math.random() * 100));
|
||||
} finally { clearTimeout(timer); }
|
||||
}
|
||||
throw last!;
|
||||
}
|
||||
|
||||
/** Maskiert Geheimnisse für Anzeige/Logs: "4e59…7174". */
|
||||
export const maskKey = (k: string): string => (k.length <= 8 ? '****' : `${k.slice(0, 4)}…${k.slice(-4)}`);
|
||||
1
packages/connector-sdk/tsconfig.json
Normal file
1
packages/connector-sdk/tsconfig.json
Normal file
|
|
@ -0,0 +1 @@
|
|||
{ "extends": "../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", "outDir": "dist", "declaration": true }, "include": ["src"] }
|
||||
24
packages/connectors/package.json
Normal file
24
packages/connectors/package.json
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"name": "@kc/connectors",
|
||||
"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": "echo ok"
|
||||
},
|
||||
"dependencies": {
|
||||
"@kc/connector-keyhelp": "workspace:*",
|
||||
"@kc/connector-licensing": "workspace:*",
|
||||
"@kc/connector-mock": "workspace:*",
|
||||
"@kc/connector-sdk": "workspace:*",
|
||||
"@kc/platform": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^26.6.3",
|
||||
"typescript": "^7.0.2"
|
||||
}
|
||||
}
|
||||
253
packages/connectors/src/index.ts
Normal file
253
packages/connectors/src/index.ts
Normal file
|
|
@ -0,0 +1,253 @@
|
|||
import { randomUUID } from 'node:crypto';
|
||||
import { ACTION_CAPABILITY, ConnectorError, DESTRUCTIVE_ACTIONS, type ActionName, type Capability, type ChildKind, type ChildOp, type Connector, type ConnectorContext, type NormalizedResource } from '@kc/connector-sdk';
|
||||
import { licensingConnector } from '@kc/connector-licensing';
|
||||
import { mockConnector } from '@kc/connector-mock';
|
||||
import { keyhelpConnector } from '@kc/connector-keyhelp';
|
||||
import { audit } from '@kc/platform/audit';
|
||||
import { decrypt, encrypt } from '@kc/platform/crypto';
|
||||
import { one, query, run } from '@kc/platform/db';
|
||||
import { JobFailure, enqueue } from '@kc/platform/jobs';
|
||||
import { addMonths } from '@kc/platform/contractterms';
|
||||
import { CONTRACT_MACHINE, ORDER_MACHINE, transition } from '@kc/platform/statemachine';
|
||||
|
||||
/** Registry: neue Connectoren (KeyHelp, Plesk, …) werden hier eingetragen, sonst nirgends. */
|
||||
const registry = new Map<string, Connector>([licensingConnector, keyhelpConnector, mockConnector].map((c) => [c.key, c]));
|
||||
export const listConnectors = (): Connector[] => [...registry.values()];
|
||||
export const getConnector = (key: string): Connector => {
|
||||
const c = registry.get(key);
|
||||
if (!c) throw new ConnectorError('BAD_CONFIG', `Unbekannter Connector ${key}`);
|
||||
return c;
|
||||
};
|
||||
|
||||
export const encryptSecrets = (s: Record<string, string>): string | null => (Object.keys(s).length ? encrypt(JSON.stringify(s)) : null);
|
||||
export const ACTIONS: readonly ActionName[] = ['suspend', 'unsuspend', 'extend', 'terminate'];
|
||||
|
||||
interface Loaded { inst: any; connector: Connector; ctx: ConnectorContext }
|
||||
export async function loadInstance(id: string, correlationId: string): Promise<Loaded> {
|
||||
const inst = await one('SELECT * FROM connector_instances WHERE id = ?', [id]);
|
||||
if (!inst) throw new JobFailure('Connector-Instanz nicht gefunden', false, 'failed');
|
||||
const config = typeof inst.config_json === 'string' ? JSON.parse(inst.config_json) : inst.config_json;
|
||||
const secrets = inst.secrets_enc ? JSON.parse(decrypt(inst.secrets_enc)) : {};
|
||||
return { inst, connector: getConnector(inst.connector_key), ctx: { config, secrets, correlationId } };
|
||||
}
|
||||
|
||||
const iso = (d?: string | null) => (d ? new Date(d) : null);
|
||||
|
||||
/** Gleicht eine Instanz mit dem Provider ab. Bei Ausfall bleiben die zuletzt bekannten Daten erhalten (mit altem Zeitstempel). */
|
||||
export async function syncInstance(instanceId: string, correlationId: string): Promise<{ ok: boolean; count?: number; error?: string }> {
|
||||
const { inst, connector, ctx } = await loadInstance(instanceId, correlationId);
|
||||
const wasDown = inst.health === 'down';
|
||||
await run('UPDATE connector_instances SET last_sync_at = UTC_TIMESTAMP(3) WHERE id = ?', [instanceId]);
|
||||
try {
|
||||
const health = await connector.healthCheck(ctx);
|
||||
if (!health.ok) throw new ConnectorError(/unvollständig|API-Schlüssel|Fingerabdruck/.test(health.message ?? '') ? 'BAD_CONFIG' : 'UNREACHABLE', health.message);
|
||||
const [caps, list] = [await connector.capabilities(ctx), await connector.listResources(ctx)];
|
||||
const seen = new Set<string>();
|
||||
for (const r of list) { seen.add(r.externalRef); await upsert(instanceId, r); }
|
||||
await run('UPDATE resources SET missing_since = COALESCE(missing_since, UTC_TIMESTAMP(3)) WHERE instance_id = ?' + (seen.size ? ` AND external_ref NOT IN (${[...seen].map(() => '?').join(',')})` : ''), [instanceId, ...seen]);
|
||||
await run("UPDATE connector_instances SET health='ok', health_message=NULL, capabilities_json=?, last_ok_at=UTC_TIMESTAMP(3), last_error=NULL WHERE id = ?", [JSON.stringify(caps), instanceId]);
|
||||
if (wasDown) await audit({ actorType: 'system', action: 'connector.recovered', resourceType: 'connector', resourceId: instanceId, connector: inst.connector_key, correlationId });
|
||||
return { ok: true, count: list.length };
|
||||
} catch (e) {
|
||||
const msg = e instanceof ConnectorError ? e.userMessage : 'Unerwarteter Fehler beim Abgleich';
|
||||
let tech = String((e as Error).message);
|
||||
for (let n = 0; n < 3; n++) { const m = /^[^()]{5,80}\. \((.*)\)$/s.exec(tech); if (!m) break; tech = m[1]!; }
|
||||
tech = tech.slice(0, 250);
|
||||
const hint = e instanceof ConnectorError ? e.hint : '';
|
||||
await run("UPDATE connector_instances SET health='down', health_message=?, last_error=?, last_error_at=UTC_TIMESTAMP(3) WHERE id = ?", [msg, JSON.stringify({ tech, hint }).slice(0, 490), instanceId]);
|
||||
if (!wasDown) await audit({ actorType: 'system', action: 'connector.down', resourceType: 'connector', resourceId: instanceId, connector: inst.connector_key, result: 'failure', errorClass: e instanceof ConnectorError ? e.code : 'unexpected', correlationId });
|
||||
return { ok: false, error: msg };
|
||||
}
|
||||
}
|
||||
|
||||
async function upsert(instanceId: string, r: NormalizedResource): Promise<string> {
|
||||
await run(
|
||||
`INSERT INTO resources (id, instance_id, external_ref, type, name, state, valid_from, valid_until, data_json, synced_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?, UTC_TIMESTAMP(3))
|
||||
ON DUPLICATE KEY UPDATE type=VALUES(type), name=VALUES(name), state=VALUES(state), valid_from=VALUES(valid_from), valid_until=VALUES(valid_until), data_json=VALUES(data_json), synced_at=UTC_TIMESTAMP(3), missing_since=NULL`,
|
||||
[randomUUID(), instanceId, r.externalRef, r.type, r.name.slice(0, 300), r.state, iso(r.validFrom), iso(r.validUntil), JSON.stringify({ limits: r.limits ?? {}, usage: r.usage ?? {}, details: r.details ?? {} })],
|
||||
);
|
||||
return (await one('SELECT id FROM resources WHERE instance_id = ? AND external_ref = ?', [instanceId, r.externalRef]))!.id as string;
|
||||
}
|
||||
|
||||
export interface ExecutePayload { resourceId: string; action: ActionName; params?: Record<string, unknown>; actorUserId: string | null; destructive?: boolean }
|
||||
/** Führt eine Aktion aus. Aufgerufen vom Worker im Rahmen eines persistenten Auftrags; jobId dient als Idempotenzschlüssel. */
|
||||
export async function executeAction(p: ExecutePayload, jobId: string, correlationId: string): Promise<string> {
|
||||
const r = await one('SELECT * FROM resources WHERE id = ?', [p.resourceId]);
|
||||
if (!r) throw new JobFailure('Ressource nicht gefunden', false, 'failed');
|
||||
const { inst, connector, ctx } = await loadInstance(r.instance_id, correlationId);
|
||||
const caps = await connector.capabilities(ctx);
|
||||
if (!caps.includes(ACTION_CAPABILITY[p.action] as Capability) || !connector.execute) throw new JobFailure('Aktion wird vom Connector nicht unterstützt', false, 'failed');
|
||||
const before = { state: r.state, validUntil: r.valid_until };
|
||||
try {
|
||||
const out = await connector.execute(ctx, { action: p.action, externalRef: r.external_ref, params: p.params, idempotencyKey: jobId });
|
||||
if (out.resource) await upsert(r.instance_id, out.resource);
|
||||
const after = await one('SELECT state, valid_until FROM resources WHERE id = ?', [p.resourceId]);
|
||||
await audit({ actorType: p.actorUserId ? 'user' : 'system', actorId: p.actorUserId, orgId: r.org_id, action: `resource.${p.action}`, resourceType: 'resource', resourceId: p.resourceId, connector: inst.connector_key, correlationId, before, after: { state: after?.state, validUntil: after?.valid_until, params: p.params } });
|
||||
return `${p.action}: ${after?.state ?? 'ok'}`;
|
||||
} catch (e) {
|
||||
if (e instanceof JobFailure) throw e;
|
||||
if (e instanceof ConnectorError) {
|
||||
await audit({ actorType: p.actorUserId ? 'user' : 'system', actorId: p.actorUserId, orgId: r.org_id, action: `resource.${p.action}`, resourceType: 'resource', resourceId: p.resourceId, connector: inst.connector_key, result: 'failure', errorClass: e.code, correlationId });
|
||||
// Destruktive Aktionen und nicht wiederholbare Fehler nie automatisch wiederholen.
|
||||
if (DESTRUCTIVE_ACTIONS.has(p.action) || !e.retryable) throw new JobFailure(e.userMessage, false, DESTRUCTIVE_ACTIONS.has(p.action) ? 'needs_review' : 'failed');
|
||||
throw new JobFailure(e.userMessage, true, 'needs_review', e.retryAfterMs ? Math.ceil(e.retryAfterMs / 1000) : undefined);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/** Plant Abgleiche für fällige Instanzen (idempotent pro Zeitfenster). Wird regelmäßig vom Worker aufgerufen. */
|
||||
export async function scheduleDueSyncs(enqueue: (type: string, payload: unknown, o: { idempotencyKey: string }) => Promise<void>): Promise<number> {
|
||||
const due = await query("SELECT id, sync_interval_sec FROM connector_instances WHERE enabled = 1 AND (last_sync_at IS NULL OR last_sync_at < DATE_SUB(UTC_TIMESTAMP(3), INTERVAL sync_interval_sec SECOND))");
|
||||
for (const d of due) await enqueue('connector.sync', { instanceId: d.id }, { idempotencyKey: `sync:${d.id}:${Math.floor(Date.now() / (Number(d.sync_interval_sec) * 1000))}` });
|
||||
return due.length;
|
||||
}
|
||||
export { ACTION_CAPABILITY, DESTRUCTIVE_ACTIONS };
|
||||
export type { ActionName, Capability };
|
||||
|
||||
|
||||
export interface JobMeta { id: string; correlationId: string; attempt: number; maxAttempts: number }
|
||||
|
||||
/**
|
||||
* Provisioniert eine freigegebene Bestellung (Auftrag `order.provision`). Je Position: Ressource beim Provider anlegen
|
||||
* (falls das Produkt an eine Verbindung gebunden ist), Vertrag aktivieren. Bereits aktive Verträge werden übersprungen,
|
||||
* sodass ein Neustart des Auftrags keine Doppelanlage erzeugt. Anlage wird nur bei sicher nicht gesendeter Anfrage wiederholt.
|
||||
*/
|
||||
export async function provisionOrder(orderId: string, meta: JobMeta): Promise<string> {
|
||||
const order = await one('SELECT * FROM orders WHERE id = ?', [orderId]);
|
||||
if (!order) throw new JobFailure('Bestellung nicht gefunden', false, 'failed');
|
||||
if (order.status === 'completed') return 'bereits abgeschlossen';
|
||||
if (order.status !== 'provisioning') throw new JobFailure(`Bestellung ist im Status ${order.status}`, false, 'failed');
|
||||
const items = await query(
|
||||
`SELECT oi.id AS item_id, oi.snapshot_json, c.id AS contract_id, c.number AS contract_number, c.status AS cstatus, c.renewal AS c_renewal, c.renewal_term_months AS c_renewal_months, pv.name AS product_name, pv.term_months, pv.provisioning_json,
|
||||
p.connector_instance_id, p.customer_actions, o.name AS org_name, o.customer_number, ord.number AS order_number
|
||||
FROM order_items oi JOIN contracts c ON c.order_item_id = oi.id JOIN product_versions pv ON pv.id = oi.product_version_id
|
||||
JOIN products p ON p.id = pv.product_id JOIN orders ord ON ord.id = oi.order_id JOIN organizations o ON o.id = ord.org_id
|
||||
WHERE oi.order_id = ? ORDER BY oi.id`, [orderId]);
|
||||
// Kunde/Vorgang für den Anbieter (Inhaber der Organisation als Ansprechpartner)
|
||||
const owner = await one("SELECT u.name, u.email FROM memberships m JOIN users u ON u.id = m.user_id WHERE m.org_id = ? AND m.role = 'owner' ORDER BY m.created_at LIMIT 1", [order.org_id]);
|
||||
const fail = async (note: string, ambiguous: boolean, err: unknown) => {
|
||||
await run("UPDATE orders SET status = ?, failure_note = ?, failure_ambiguous = ? WHERE id = ?", [transition(ORDER_MACHINE, 'provisioning', 'fail'), note.slice(0, 500), ambiguous ? 1 : 0, orderId]);
|
||||
await audit({ actorType: 'system', orgId: order.org_id, action: 'order.provision', resourceType: 'order', resourceId: orderId, result: 'failure', errorClass: err instanceof ConnectorError ? err.code : 'unexpected', correlationId: meta.correlationId, after: { note, ambiguous } });
|
||||
};
|
||||
for (const it of items) {
|
||||
if (it.cstatus === 'active') continue;
|
||||
let resourceId: string | null = null; let ref = ''; let providerValidUntil: string | null = null;
|
||||
try {
|
||||
if (it.connector_instance_id) {
|
||||
const { inst, connector, ctx } = await loadInstance(it.connector_instance_id, meta.correlationId);
|
||||
if (!inst.enabled) throw new ConnectorError('BAD_CONFIG', 'Verbindung ist deaktiviert');
|
||||
const caps = await connector.capabilities(ctx);
|
||||
if (!caps.includes('lifecycle.create') || !connector.provision) throw new ConnectorError('UNSUPPORTED', 'Anlegen wird nicht unterstützt');
|
||||
const params = typeof it.provisioning_json === 'string' ? JSON.parse(it.provisioning_json) : it.provisioning_json;
|
||||
const out = await connector.provision(ctx, { params, label: `${it.org_name} · ${it.product_name}`, idempotencyKey: `${meta.id}:${it.item_id}`,
|
||||
context: { source: 'kundencenter', customerNumber: it.customer_number, customerName: it.org_name, contactName: owner?.name ?? null, customerEmail: owner?.email ?? null, orderNumber: it.order_number, contractNumber: it.contract_number } });
|
||||
ref = out.resource.externalRef; providerValidUntil = out.resource.validUntil ?? null;
|
||||
try {
|
||||
resourceId = await upsert(it.connector_instance_id, out.resource);
|
||||
await run('UPDATE resources SET org_id = ?, customer_actions = ? WHERE id = ?', [order.org_id, JSON.stringify(typeof it.customer_actions === 'string' ? JSON.parse(it.customer_actions) : it.customer_actions), resourceId]);
|
||||
} catch (dbErr) {
|
||||
// Provider hat angelegt, lokale Zuordnung schlug fehl: nie automatisch wiederholen (sonst Doppelanlage)
|
||||
await fail(`Beim Anbieter wurde ${ref} angelegt, die lokale Zuordnung schlug fehl. Bitte prüfen, nicht blind wiederholen.`, true, dbErr);
|
||||
throw new JobFailure('Zuordnung nach Anlage fehlgeschlagen', false, 'needs_review');
|
||||
}
|
||||
}
|
||||
// Erste Laufzeitperiode: Mindestlaufzeit, sonst (bei automatischer Verlängerung) die Verlängerungsperiode, damit auch rollierende Monatsverträge einen Verlängerungstakt haben
|
||||
const now = new Date(); const months = Number(it.term_months) > 0 ? Number(it.term_months) : it.c_renewal === 'auto' ? Number(it.c_renewal_months) : 0;
|
||||
const c = transition(CONTRACT_MACHINE, 'pending', 'activate');
|
||||
await run('UPDATE contracts SET status = ?, started_at = ?, term_end = ?, resource_id = ? WHERE id = ? AND status = \'pending\'', [c, now, months > 0 ? addMonths(now, months) : null, resourceId, it.contract_id]);
|
||||
// Laufzeit des Providerobjekts an das Vertragsende angleichen (z. B. Lizenz 365 Tage vs. 12 Kalendermonate)
|
||||
if (resourceId && months > 0 && providerValidUntil) {
|
||||
const target = addMonths(now, months);
|
||||
if (Math.abs(new Date(providerValidUntil).getTime() - target.getTime()) > 60_000) await enqueue('connector.execute', { resourceId, action: 'extend', params: { until: target.toISOString() }, actorUserId: null }, { idempotencyKey: `align:${it.contract_id}`, correlationId: meta.correlationId });
|
||||
}
|
||||
await audit({ actorType: 'system', orgId: order.org_id, action: 'contract.activate', resourceType: 'contract', resourceId: it.contract_id, connector: it.connector_instance_id ? 'provisioned' : undefined, correlationId: meta.correlationId, after: { resourceId, externalRef: ref || undefined } });
|
||||
} catch (e) {
|
||||
if (e instanceof JobFailure) throw e;
|
||||
if (e instanceof ConnectorError) {
|
||||
const last = meta.attempt >= meta.maxAttempts;
|
||||
if (e.retryable && e.notSent && !last) throw new JobFailure(e.userMessage, true, 'needs_review');
|
||||
await fail(`${e.userMessage}${e.ambiguous ? ' Es ist unklar, ob beim Anbieter bereits etwas angelegt wurde. Bitte dort prüfen, bevor die Bestellung erneut gestartet wird.' : ''}`, e.ambiguous, e);
|
||||
throw new JobFailure(e.userMessage, false, 'needs_review');
|
||||
}
|
||||
await fail('Unerwarteter Fehler bei der Bereitstellung', true, e);
|
||||
throw new JobFailure('Unerwarteter Fehler', false, 'needs_review');
|
||||
}
|
||||
}
|
||||
await run('UPDATE orders SET status = ?, failure_note = NULL, failure_ambiguous = 0 WHERE id = ?', [transition(ORDER_MACHINE, 'provisioning', 'complete'), orderId]);
|
||||
await audit({ actorType: 'system', orgId: order.org_id, action: 'order.complete', resourceType: 'order', resourceId: orderId, correlationId: meta.correlationId });
|
||||
return `${items.length} Position(en) bereitgestellt`;
|
||||
}
|
||||
|
||||
/** Beendet Verträge zum Kündigungs-/Laufzeitende und verlängert automatisch. Idempotent; regelmäßig vom Worker aufgerufen. */
|
||||
export async function processContractLifecycle(now: Date, enqueueJob: (type: string, payload: unknown, o: { idempotencyKey: string }) => Promise<void>): Promise<{ ended: number; renewed: number }> {
|
||||
let ended = 0; let renewed = 0;
|
||||
// 1) Gekündigte Verträge, deren Kündigung wirksam wird
|
||||
const due = await query("SELECT id, org_id, resource_id, status FROM contracts WHERE status IN ('active','suspended') AND cancel_effective_at IS NOT NULL AND cancel_effective_at <= ?", [now]);
|
||||
for (const c of due) {
|
||||
const to = transition(CONTRACT_MACHINE, c.status, 'cancel');
|
||||
const r = await run("UPDATE contracts SET status = ?, cancelled_at = ? WHERE id = ? AND status IN ('active','suspended')", [to, now, c.id]);
|
||||
if (!r.affectedRows) continue;
|
||||
ended++;
|
||||
await audit({ actorType: 'system', orgId: c.org_id, action: 'contract.cancel.effective', resourceType: 'contract', resourceId: c.id });
|
||||
// Ressource sperren (nicht löschen): Löschung ist destruktiv und bleibt eine bewusste manuelle Entscheidung
|
||||
if (c.resource_id) await enqueueJob('connector.execute', { resourceId: c.resource_id, action: 'suspend', actorUserId: null }, { idempotencyKey: `contract-end:${c.id}` });
|
||||
}
|
||||
// 2) Laufzeitende ohne Kündigung: automatisch verlängern oder auslaufen lassen
|
||||
const ended2 = await query("SELECT c.id, c.org_id, c.term_end, c.renewal, c.renewal_term_months, c.status, c.resource_id, r.valid_until AS res_until FROM contracts c LEFT JOIN resources r ON r.id = c.resource_id WHERE c.status IN ('active','suspended') AND c.term_end IS NOT NULL AND c.term_end <= ? AND c.cancel_effective_at IS NULL", [now]);
|
||||
for (const c of ended2) {
|
||||
if (c.renewal === 'auto' && Number(c.renewal_term_months) > 0) {
|
||||
let end = new Date(c.term_end as Date); let guard = 0;
|
||||
while (end <= now && guard++ < 1200) end = addMonths(end, Number(c.renewal_term_months));
|
||||
await run('UPDATE contracts SET term_end = ? WHERE id = ? AND term_end = ?', [end, c.id, c.term_end]);
|
||||
renewed++;
|
||||
// Befristetes Providerobjekt (z. B. Monats-/Jahreslizenz) mitverlängern, sonst läuft es trotz laufendem Vertrag ab.
|
||||
// HINWEIS: Bis zur Rechnungs-/Zahlungsanbindung erfolgt das ohne Zahlungsprüfung (siehe Plane).
|
||||
if (c.resource_id && c.res_until) await enqueueJob('connector.execute', { resourceId: c.resource_id, action: 'extend', params: { until: end.toISOString() }, actorUserId: null }, { idempotencyKey: `renew:${c.id}:${end.toISOString()}` });
|
||||
await audit({ actorType: 'system', orgId: c.org_id, action: 'contract.renew', resourceType: 'contract', resourceId: c.id, after: { termEnd: end.toISOString() } });
|
||||
} else {
|
||||
const to = transition(CONTRACT_MACHINE, c.status, 'expire');
|
||||
const r = await run("UPDATE contracts SET status = ? WHERE id = ? AND status IN ('active','suspended')", [to, c.id]);
|
||||
if (!r.affectedRows) continue;
|
||||
ended++;
|
||||
await audit({ actorType: 'system', orgId: c.org_id, action: 'contract.expire', resourceType: 'contract', resourceId: c.id });
|
||||
if (c.resource_id) await enqueueJob('connector.execute', { resourceId: c.resource_id, action: 'suspend', actorUserId: null }, { idempotencyKey: `contract-end:${c.id}` });
|
||||
}
|
||||
}
|
||||
return { ended, renewed };
|
||||
}
|
||||
|
||||
|
||||
export interface ChildPayload { resourceId: string; kind: ChildKind; op: ChildOp; id?: string; data?: Record<string, unknown>; secretEnc?: string; actorUserId: string | null; destructive?: boolean }
|
||||
/**
|
||||
* Führt eine Änderung an einem Unterobjekt aus (Auftrag `connector.child`). Geheimnisse (z. B. Passwörter) liegen nur verschlüsselt im Auftrag und
|
||||
* werden nach dem Ende (Erfolg oder endgültiger Fehler) aus der Datenbank entfernt. Wiederholt wird nur, wenn die Anfrage sicher nicht ankam.
|
||||
*/
|
||||
export async function executeChild(p: ChildPayload, jobId: string, correlationId: string, isFinal: () => boolean): Promise<string> {
|
||||
const wipe = () => run("UPDATE jobs SET payload = JSON_REMOVE(payload, '$.secretEnc') WHERE id = ?", [jobId]).catch(() => undefined);
|
||||
const r = await one('SELECT * FROM resources WHERE id = ?', [p.resourceId]);
|
||||
if (!r) { await wipe(); throw new JobFailure('Ressource nicht gefunden', false, 'failed'); }
|
||||
const { inst, connector, ctx } = await loadInstance(r.instance_id, correlationId);
|
||||
const caps = await connector.capabilities(ctx);
|
||||
if (!connector.children || !caps.includes('children.write')) { await wipe(); throw new JobFailure('Der Anbieter unterstützt diese Änderung nicht', false, 'failed'); }
|
||||
const secrets = p.secretEnc ? (JSON.parse(decrypt(p.secretEnc)) as Record<string, string>) : undefined;
|
||||
const name = typeof p.data?.domain === 'string' ? p.data.domain : typeof p.data?.local === 'string' ? `${p.data.local}@${String(p.data.domain ?? '')}` : typeof p.data?.name === 'string' ? p.data.name : typeof p.data?.username === 'string' ? p.data.username : (p.id ?? '');
|
||||
const base = { actorType: (p.actorUserId ? 'user' : 'system') as 'user' | 'system', actorId: p.actorUserId, orgId: r.org_id, resourceType: 'resource', resourceId: p.resourceId, connector: inst.connector_key, correlationId };
|
||||
try {
|
||||
const out = await connector.children.act(ctx, { parentRef: r.external_ref, kind: p.kind, op: p.op, id: p.id, data: p.data ?? {}, secrets, idempotencyKey: jobId });
|
||||
await wipe();
|
||||
await audit({ ...base, action: `resource.child.${p.kind}.${p.op}`, after: { name: out.child?.name ?? name, id: out.child?.id ?? p.id } });
|
||||
return `${p.kind}: ${p.op === 'create' ? 'angelegt' : p.op === 'update' ? 'geändert' : 'gelöscht'}${out.child?.name ? ` (${out.child.name})` : ''}`;
|
||||
} catch (e) {
|
||||
if (e instanceof ConnectorError) {
|
||||
await audit({ ...base, action: `resource.child.${p.kind}.${p.op}`, result: 'failure', errorClass: e.code, after: { name } }).catch(() => undefined);
|
||||
const retry = e.retryable && e.notSent && !isFinal();
|
||||
if (!retry) await wipe();
|
||||
// Bei abgelehnten Eingaben ist die Meldung des Anbieters für Nutzer hilfreich (z. B. "Name schon vergeben").
|
||||
const msg = e.code === 'INVALID_INPUT' || e.code === 'CONFLICT' || e.code === 'NOT_FOUND' ? e.message : e.userMessage;
|
||||
throw new JobFailure(msg, retry, e.ambiguous ? 'needs_review' : 'failed');
|
||||
}
|
||||
await wipe(); throw e;
|
||||
}
|
||||
}
|
||||
1
packages/connectors/tsconfig.json
Normal file
1
packages/connectors/tsconfig.json
Normal file
|
|
@ -0,0 +1 @@
|
|||
{ "extends": "../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", "outDir": "dist", "declaration": true }, "include": ["src"] }
|
||||
25
packages/platform/package.json
Normal file
25
packages/platform/package.json
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"name": "@kc/platform",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
"./*": {
|
||||
"types": "./dist/*.d.ts",
|
||||
"default": "./dist/*.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"mysql2": "^3.24.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^26.6.3",
|
||||
"typescript": "^7.0.2",
|
||||
"vitest": "^5.0.2"
|
||||
}
|
||||
}
|
||||
81
packages/platform/src/audit.ts
Normal file
81
packages/platform/src/audit.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import { createHash } from 'node:crypto';
|
||||
import type { PoolConnection } from 'mysql2/promise';
|
||||
import { pool, one, query } from './db.js';
|
||||
|
||||
const SENSITIVE = /pass|secret|token|hash|code|key|totp/i;
|
||||
/** Maskiert Geheimnisse rekursiv, bevor Zustände in das Audit-Protokoll gelangen. */
|
||||
export function mask(v: unknown): unknown {
|
||||
if (Array.isArray(v)) return v.map(mask);
|
||||
if (v && typeof v === 'object') {
|
||||
return Object.fromEntries(Object.entries(v as Record<string, unknown>).map(([k, val]) => [k, SENSITIVE.test(k) ? '***' : mask(val)]));
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
export interface AuditInput {
|
||||
actorType: 'user' | 'system' | 'anonymous';
|
||||
actorId?: string | null;
|
||||
orgId?: string | null;
|
||||
action: string;
|
||||
resourceType?: string;
|
||||
resourceId?: string;
|
||||
result?: 'success' | 'denied' | 'failure';
|
||||
errorClass?: string;
|
||||
connector?: string;
|
||||
correlationId?: string;
|
||||
ip?: string;
|
||||
before?: unknown;
|
||||
after?: unknown;
|
||||
}
|
||||
|
||||
const canon = (o: unknown) => JSON.stringify(o);
|
||||
|
||||
/** Hängt ein Ereignis an die Hash-Kette an. Serialisiert über Zeilensperre auf dem letzten Eintrag. */
|
||||
export async function audit(e: AuditInput, conn?: PoolConnection): Promise<void> {
|
||||
const own = !conn;
|
||||
const c = conn ?? (await pool.getConnection());
|
||||
try {
|
||||
if (own) await c.beginTransaction();
|
||||
await c.query('SELECT GET_LOCK(?, 10)', ['kc_audit_chain']);
|
||||
const last = await one<{ hash: string } & import('mysql2').RowDataPacket>('SELECT hash FROM audit_events ORDER BY id DESC LIMIT 1', [], c);
|
||||
const prev = last?.hash ?? '0'.repeat(64);
|
||||
const ts = new Date();
|
||||
const body = {
|
||||
ts: ts.toISOString(), actor_type: e.actorType, actor_id: e.actorId ?? null, org_id: e.orgId ?? null, action: e.action,
|
||||
resource_type: e.resourceType ?? null, resource_id: e.resourceId ?? null, result: e.result ?? 'success',
|
||||
error_class: e.errorClass ?? null, correlation_id: e.correlationId ?? null,
|
||||
before: e.before === undefined ? null : mask(e.before), after: e.after === undefined ? null : mask(e.after),
|
||||
};
|
||||
const hash = createHash('sha256').update(prev + canon(body)).digest('hex');
|
||||
await c.execute(
|
||||
`INSERT INTO audit_events (ts, actor_type, actor_id, org_id, action, resource_type, resource_id, result, error_class, connector, correlation_id, ip, before_json, after_json, prev_hash, hash)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
[ts, e.actorType, body.actor_id, body.org_id, e.action, body.resource_type, body.resource_id, body.result, body.error_class, e.connector ?? null,
|
||||
body.correlation_id, e.ip ?? null, body.before === null ? null : JSON.stringify(body.before), body.after === null ? null : JSON.stringify(body.after), prev, hash],
|
||||
);
|
||||
if (own) await c.commit();
|
||||
} catch (err) {
|
||||
if (own) await c.rollback();
|
||||
throw err;
|
||||
} finally {
|
||||
await c.query('SELECT RELEASE_LOCK(?)', ['kc_audit_chain']).catch(() => undefined);
|
||||
if (own) c.release();
|
||||
}
|
||||
}
|
||||
|
||||
/** Prüft die Hash-Kette. Liefert die erste fehlerhafte ID oder null. */
|
||||
export async function verifyAuditChain(q: (sql: string) => Promise<any[]> = (sql) => query(sql)): Promise<{ checked: number; brokenAt: number | null }> {
|
||||
const rows = await q('SELECT id, ts, actor_type, actor_id, org_id, action, resource_type, resource_id, result, error_class, correlation_id, before_json, after_json, prev_hash, hash FROM audit_events ORDER BY id');
|
||||
let prev = '0'.repeat(64);
|
||||
for (const r of rows) {
|
||||
const body = {
|
||||
ts: (r.ts as Date).toISOString(), actor_type: r.actor_type, actor_id: r.actor_id, org_id: r.org_id, action: r.action,
|
||||
resource_type: r.resource_type, resource_id: r.resource_id, result: r.result, error_class: r.error_class, correlation_id: r.correlation_id,
|
||||
before: r.before_json ?? null, after: r.after_json ?? null,
|
||||
};
|
||||
const expect = createHash('sha256').update(prev + canon(body)).digest('hex');
|
||||
if (r.prev_hash !== prev || r.hash !== expect) return { checked: rows.length, brokenAt: r.id as number };
|
||||
prev = r.hash as string;
|
||||
}
|
||||
return { checked: rows.length, brokenAt: null };
|
||||
}
|
||||
41
packages/platform/src/config.ts
Normal file
41
packages/platform/src/config.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import { readdirSync, readFileSync, existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
/** Lädt *.env aus KC_ENV_DIR (Standard /etc/kundencenter), ohne bereits gesetzte Variablen zu überschreiben. */
|
||||
function loadEnvDir(dir: string): void {
|
||||
if (!existsSync(dir)) return;
|
||||
for (const f of readdirSync(dir).filter((n) => n.endsWith('.env')).sort()) {
|
||||
for (const line of readFileSync(join(dir, f), 'utf8').split('\n')) {
|
||||
const m = /^\s*([A-Z0-9_]+)\s*=\s*(.*?)\s*$/.exec(line);
|
||||
if (m && m[1] && process.env[m[1]] === undefined) process.env[m[1]] = m[2] ?? '';
|
||||
}
|
||||
}
|
||||
}
|
||||
loadEnvDir(process.env.KC_ENV_DIR ?? '/etc/kundencenter');
|
||||
|
||||
function req(name: string): string {
|
||||
const v = process.env[name];
|
||||
if (!v) throw new Error(`Konfiguration fehlt: ${name}`);
|
||||
return v;
|
||||
}
|
||||
|
||||
export const config = {
|
||||
env: process.env.KC_NODE_ENV ?? 'development',
|
||||
isProd: (process.env.KC_NODE_ENV ?? 'development') === 'production',
|
||||
port: Number(process.env.KC_API_PORT ?? 4100),
|
||||
baseUrl: process.env.KC_BASE_URL ?? 'http://localhost:4101',
|
||||
/** Erlaubte Browser-Origins (CSRF/Origin-Prüfung): baseUrl plus optional KC_ALLOWED_ORIGINS (kommagetrennt). */
|
||||
allowedOrigins: new Set([process.env.KC_BASE_URL ?? 'http://localhost:4101', ...(process.env.KC_ALLOWED_ORIGINS ?? '').split(',').map((o) => o.trim()).filter(Boolean)]),
|
||||
secretKey: Buffer.from(req('KC_SECRET_KEY'), 'base64'),
|
||||
db: {
|
||||
host: process.env.DB_HOST ?? '127.0.0.1',
|
||||
port: Number(process.env.DB_PORT ?? 3306),
|
||||
database: req('DB_NAME'),
|
||||
user: req('DB_USER'),
|
||||
password: req('DB_PASSWORD'),
|
||||
},
|
||||
smtp: process.env.SMTP_HOST
|
||||
? { host: process.env.SMTP_HOST, port: Number(process.env.SMTP_PORT ?? 587), user: process.env.SMTP_USER, pass: process.env.SMTP_PASSWORD, from: process.env.SMTP_FROM ?? 'kundencenter@localhost' }
|
||||
: null,
|
||||
};
|
||||
if (config.secretKey.length !== 32) throw new Error('KC_SECRET_KEY muss 32 Byte (base64) sein');
|
||||
42
packages/platform/src/contractterms.ts
Normal file
42
packages/platform/src/contractterms.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
/** Laufzeit-, Verlängerungs- und Kündigungslogik (rein, ohne Datenbank). Zeitpunkte in UTC. */
|
||||
export interface Terms { termEnd: Date | null; renewal: 'auto' | 'none'; renewalTermMonths: number; noticeDays: number }
|
||||
|
||||
export function addMonths(d: Date, months: number): Date {
|
||||
const r = new Date(d.getTime());
|
||||
const day = r.getUTCDate();
|
||||
r.setUTCDate(1); r.setUTCMonth(r.getUTCMonth() + months);
|
||||
const last = new Date(Date.UTC(r.getUTCFullYear(), r.getUTCMonth() + 1, 0)).getUTCDate();
|
||||
r.setUTCDate(Math.min(day, last)); // 31.01. + 1 Monat = 28./29.02.
|
||||
return r;
|
||||
}
|
||||
const addDays = (d: Date, n: number) => new Date(d.getTime() + n * 86400000);
|
||||
|
||||
/**
|
||||
* Wann endet der Vertrag, wenn jetzt gekündigt wird?
|
||||
* - ohne Mindestlaufzeit (termEnd null): mit Frist ab jetzt.
|
||||
* - ohne Verlängerung: zum Laufzeitende.
|
||||
* - mit Verlängerung: zum nächsten Laufzeitende, das mindestens noticeDays in der Zukunft liegt.
|
||||
*/
|
||||
export function effectiveCancelDate(now: Date, t: Terms): Date {
|
||||
if (!t.termEnd) return addDays(now, t.noticeDays);
|
||||
if (t.renewal === 'none') return t.termEnd > now ? t.termEnd : now;
|
||||
let end = t.termEnd; let guard = 0;
|
||||
while (addDays(end, -t.noticeDays) < now) {
|
||||
if (t.renewalTermMonths < 1 || ++guard > 1200) throw new RangeError('Ungültige Verlängerungslaufzeit');
|
||||
end = addMonths(end, t.renewalTermMonths);
|
||||
}
|
||||
return end;
|
||||
}
|
||||
/** Verlängert das Laufzeitende, solange es in der Vergangenheit liegt (nur bei auto-Verlängerung und ohne Kündigung). */
|
||||
export function renewedTermEnd(now: Date, termEnd: Date, renewalTermMonths: number): Date {
|
||||
let end = termEnd; let guard = 0;
|
||||
while (end <= now) { if (renewalTermMonths < 1 || ++guard > 1200) throw new RangeError('Ungültige Verlängerungslaufzeit'); end = addMonths(end, renewalTermMonths); }
|
||||
return end;
|
||||
}
|
||||
/**
|
||||
* Verbraucherverträge (Privatkunden): Nach der Erstlaufzeit läuft der Vertrag nur auf unbestimmte Zeit weiter
|
||||
* und ist mit höchstens einem Monat Frist kündbar. HINWEIS: rechtlich vor Produktivbetrieb prüfen lassen.
|
||||
*/
|
||||
export function consumerTerms(renewalTermMonths: number, noticeDays: number): { renewalTermMonths: number; noticeDays: number } {
|
||||
return { renewalTermMonths: renewalTermMonths > 0 ? 1 : 0, noticeDays: Math.min(noticeDays, 30) };
|
||||
}
|
||||
24
packages/platform/src/crypto.ts
Normal file
24
packages/platform/src/crypto.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { createCipheriv, createDecipheriv, createHash, randomBytes, timingSafeEqual } from 'node:crypto';
|
||||
import { config } from './config.js';
|
||||
|
||||
export const sha256 = (s: string): string => createHash('sha256').update(s).digest('hex');
|
||||
export const randomToken = (bytes = 32): string => randomBytes(bytes).toString('base64url');
|
||||
|
||||
/** AES-256-GCM, Format v1:<iv>:<tag>:<ciphertext> (base64url). Key-ID im Präfix ermöglicht spätere Rotation. */
|
||||
export function encrypt(plain: string): string {
|
||||
const iv = randomBytes(12);
|
||||
const c = createCipheriv('aes-256-gcm', config.secretKey, iv);
|
||||
const ct = Buffer.concat([c.update(plain, 'utf8'), c.final()]);
|
||||
return ['v1', iv.toString('base64url'), c.getAuthTag().toString('base64url'), ct.toString('base64url')].join(':');
|
||||
}
|
||||
export function decrypt(blob: string): string {
|
||||
const [v, iv, tag, ct] = blob.split(':');
|
||||
if (v !== 'v1' || !iv || !tag || !ct) throw new Error('Unbekanntes Verschlüsselungsformat');
|
||||
const d = createDecipheriv('aes-256-gcm', config.secretKey, Buffer.from(iv, 'base64url'));
|
||||
d.setAuthTag(Buffer.from(tag, 'base64url'));
|
||||
return Buffer.concat([d.update(Buffer.from(ct, 'base64url')), d.final()]).toString('utf8');
|
||||
}
|
||||
export function safeEqual(a: string, b: string): boolean {
|
||||
const x = Buffer.from(a), y = Buffer.from(b);
|
||||
return x.length === y.length && timingSafeEqual(x, y);
|
||||
}
|
||||
44
packages/platform/src/db.ts
Normal file
44
packages/platform/src/db.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import mysql from 'mysql2/promise';
|
||||
import type { Pool, PoolConnection, RowDataPacket, ResultSetHeader } from 'mysql2/promise';
|
||||
import { config } from './config.js';
|
||||
|
||||
export const pool: Pool = mysql.createPool({
|
||||
...config.db,
|
||||
connectionLimit: 10,
|
||||
charset: 'utf8mb4',
|
||||
timezone: 'Z',
|
||||
dateStrings: false,
|
||||
namedPlaceholders: false,
|
||||
});
|
||||
|
||||
// Alle Zeiten in UTC, auch die DB-Defaults (CURRENT_TIMESTAMP) – sonst Versatz zur Serverzeit.
|
||||
(pool as unknown as { on(e: string, f: (c: { query(s: string): void }) => void): void }).on('connection', (c) => c.query("SET time_zone = '+00:00'"));
|
||||
|
||||
export type Row = RowDataPacket;
|
||||
type Exec = Pick<Pool | PoolConnection, 'execute'>;
|
||||
|
||||
export async function query<T extends Row = Row>(sql: string, params: unknown[] = [], c: Exec = pool): Promise<T[]> {
|
||||
const [rows] = await c.execute<T[]>(sql, params as never[]);
|
||||
return rows;
|
||||
}
|
||||
export async function one<T extends Row = Row>(sql: string, params: unknown[] = [], c: Exec = pool): Promise<T | undefined> {
|
||||
return (await query<T>(sql, params, c))[0];
|
||||
}
|
||||
export async function run(sql: string, params: unknown[] = [], c: Exec = pool): Promise<ResultSetHeader> {
|
||||
const [res] = await c.execute<ResultSetHeader>(sql, params as never[]);
|
||||
return res;
|
||||
}
|
||||
export async function tx<T>(fn: (c: PoolConnection) => Promise<T>): Promise<T> {
|
||||
const c = await pool.getConnection();
|
||||
try {
|
||||
await c.beginTransaction();
|
||||
const out = await fn(c);
|
||||
await c.commit();
|
||||
return out;
|
||||
} catch (e) {
|
||||
await c.rollback();
|
||||
throw e;
|
||||
} finally {
|
||||
c.release();
|
||||
}
|
||||
}
|
||||
18
packages/platform/src/jobs.ts
Normal file
18
packages/platform/src/jobs.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { randomUUID } from 'node:crypto';
|
||||
import type { PoolConnection } from 'mysql2/promise';
|
||||
import { run } from './db.js';
|
||||
|
||||
/** Persistenten Auftrag einreihen. Mit idempotencyKey wird Doppelanlage verhindert. */
|
||||
export async function enqueue(type: string, payload: unknown, opts: { idempotencyKey?: string; correlationId?: string; runAt?: Date } = {}, c?: PoolConnection): Promise<void> {
|
||||
await run(
|
||||
'INSERT IGNORE INTO jobs (id, type, payload, idempotency_key, correlation_id, run_at) VALUES (?,?,?,?,?,?)',
|
||||
[randomUUID(), type, JSON.stringify(payload), opts.idempotencyKey ?? null, opts.correlationId ?? null, opts.runAt ?? new Date()],
|
||||
c,
|
||||
);
|
||||
}
|
||||
|
||||
/** Wird von Job-Handlern geworfen, um die Wiederholungslogik zu steuern. */
|
||||
export class JobFailure extends Error {
|
||||
/** retry=false: nie automatisch wiederholen (z. B. destruktive Aktionen); finalStatus bestimmt den Endzustand. */
|
||||
constructor(message: string, public retry: boolean, public finalStatus: 'failed' | 'needs_review' = 'needs_review', public delaySec?: number) { super(message); }
|
||||
}
|
||||
36
packages/platform/src/pricing.ts
Normal file
36
packages/platform/src/pricing.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
/**
|
||||
* Serverseitige Preisberechnung in Cent (ganzzahlig, kaufmännisch gerundet je Position).
|
||||
* Steuer in Basispunkten (1900 = 19 %); Rabatt in Basispunkten (1000 = 10 %).
|
||||
* Preisbasis `net`: Beträge sind Nettopreise, Steuer wird aufgeschlagen.
|
||||
* Preisbasis `gross`: Beträge sind Bruttopreise (z. B. Endkundenpreis 9,99 €) und bleiben exakt; Netto und Steuer werden herausgerechnet.
|
||||
*/
|
||||
export type Interval = 'once' | 'monthly' | 'yearly';
|
||||
export type Basis = 'net' | 'gross';
|
||||
export interface PriceInput {
|
||||
basis?: Basis; setupCents: number; recurringCents: number; taxBp: number; interval: Interval; quantity: number; discountBp: number; currency?: string;
|
||||
}
|
||||
export interface PriceSnapshot {
|
||||
basis: Basis; currency: string; interval: Interval; quantity: number; taxBp: number; discountBp: number;
|
||||
unitSetupCents: number; unitRecurringCents: number;
|
||||
setup: { net: number; tax: number; gross: number }; recurring: { net: number; tax: number; gross: number };
|
||||
}
|
||||
/** Kaufmännisches Runden (halb auf) für nicht-negative Ganzzahl-Division. */
|
||||
const divRound = (n: number, d: number): number => Math.floor((n * 2 + d) / (d * 2));
|
||||
|
||||
export function calculatePrice(i: PriceInput): PriceSnapshot {
|
||||
const ints: [string, number][] = [['setupCents', i.setupCents], ['recurringCents', i.recurringCents], ['taxBp', i.taxBp], ['quantity', i.quantity], ['discountBp', i.discountBp]];
|
||||
for (const [n, v] of ints) if (!Number.isInteger(v) || v < 0) throw new RangeError(`${n} muss eine nicht-negative Ganzzahl sein`);
|
||||
if (i.quantity < 1 || i.quantity > 10000) throw new RangeError('quantity außerhalb des Bereichs');
|
||||
if (i.discountBp > 10000) throw new RangeError('discountBp darf höchstens 10000 sein');
|
||||
if (i.interval === 'once' && i.recurringCents > 0) throw new RangeError('Einmalprodukte haben keinen wiederkehrenden Preis');
|
||||
const basis: Basis = i.basis ?? 'net';
|
||||
const line = (unit: number) => {
|
||||
const amount = divRound(unit * i.quantity * (10000 - i.discountBp), 10000);
|
||||
if (basis === 'gross') { const net = divRound(amount * 10000, 10000 + i.taxBp); return { net, tax: amount - net, gross: amount }; }
|
||||
const tax = divRound(amount * i.taxBp, 10000);
|
||||
return { net: amount, tax, gross: amount + tax };
|
||||
};
|
||||
return { basis, currency: i.currency ?? 'EUR', interval: i.interval, quantity: i.quantity, taxBp: i.taxBp, discountBp: i.discountBp,
|
||||
unitSetupCents: i.setupCents, unitRecurringCents: i.recurringCents, setup: line(i.setupCents), recurring: line(i.recurringCents) };
|
||||
}
|
||||
export const formatEuro = (cents: number): string => (cents / 100).toLocaleString('de-DE', { style: 'currency', currency: 'EUR' });
|
||||
27
packages/platform/src/statemachine.ts
Normal file
27
packages/platform/src/statemachine.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
/** Statusautomaten für Bestellung und Vertrag. Zustandsänderungen laufen ausschließlich über `transition`. */
|
||||
export type OrderStatus = 'pending_approval' | 'approved' | 'provisioning' | 'completed' | 'failed' | 'rejected' | 'cancelled';
|
||||
export type OrderEvent = 'approve' | 'reject' | 'cancel' | 'start_provisioning' | 'complete' | 'fail' | 'retry';
|
||||
export type ContractStatus = 'pending' | 'active' | 'suspended' | 'cancelled' | 'expired' | 'failed';
|
||||
export type ContractEvent = 'activate' | 'suspend' | 'unsuspend' | 'cancel' | 'expire' | 'fail';
|
||||
|
||||
type Machine<S extends string, E extends string> = Partial<Record<S, Partial<Record<E, S>>>>;
|
||||
export const ORDER_MACHINE: Machine<OrderStatus, OrderEvent> = {
|
||||
pending_approval: { approve: 'approved', reject: 'rejected', cancel: 'cancelled' },
|
||||
approved: { start_provisioning: 'provisioning', cancel: 'cancelled' },
|
||||
provisioning: { complete: 'completed', fail: 'failed' },
|
||||
failed: { retry: 'provisioning', cancel: 'cancelled' },
|
||||
};
|
||||
export const CONTRACT_MACHINE: Machine<ContractStatus, ContractEvent> = {
|
||||
pending: { activate: 'active', fail: 'failed', cancel: 'cancelled' },
|
||||
active: { suspend: 'suspended', cancel: 'cancelled', expire: 'expired' },
|
||||
suspended: { unsuspend: 'active', cancel: 'cancelled', expire: 'expired' },
|
||||
};
|
||||
export class InvalidTransition extends Error {
|
||||
constructor(public from: string, public event: string) { super(`Ungültiger Statuswechsel: ${event} ist im Zustand ${from} nicht erlaubt`); }
|
||||
}
|
||||
export function transition<S extends string, E extends string>(m: Machine<S, E>, from: S, event: E): S {
|
||||
const to = m[from]?.[event];
|
||||
if (!to) throw new InvalidTransition(from, event);
|
||||
return to;
|
||||
}
|
||||
export const terminalOrder = (s: OrderStatus) => ['completed', 'rejected', 'cancelled'].includes(s);
|
||||
93
packages/platform/test/logic.test.ts
Normal file
93
packages/platform/test/logic.test.ts
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { calculatePrice } from '../src/pricing.js';
|
||||
import { CONTRACT_MACHINE, ORDER_MACHINE, InvalidTransition, transition, type ContractEvent, type ContractStatus, type OrderEvent, type OrderStatus } from '../src/statemachine.js';
|
||||
import { addMonths, consumerTerms, effectiveCancelDate, renewedTermEnd } from '../src/contractterms.js';
|
||||
|
||||
const P = { setupCents: 0, recurringCents: 0, taxBp: 1900, interval: 'yearly' as const, quantity: 1, discountBp: 0 };
|
||||
describe('Preisberechnung', () => {
|
||||
it('rechnet Netto, Steuer und Brutto je Position in Cent', () => {
|
||||
const r = calculatePrice({ ...P, setupCents: 1000, recurringCents: 4999 });
|
||||
expect(r.setup).toEqual({ net: 1000, tax: 190, gross: 1190 });
|
||||
expect(r.recurring).toEqual({ net: 4999, tax: 950, gross: 5949 }); // 949,81 -> 950
|
||||
});
|
||||
it('wendet Menge und Rabatt vor der Steuer an und rundet kaufmännisch', () => {
|
||||
const r = calculatePrice({ ...P, recurringCents: 999, quantity: 3, discountBp: 1000 }); // 3*9,99*0,9 = 26,973
|
||||
expect(r.recurring.net).toBe(2697); expect(r.recurring.tax).toBe(512); expect(r.recurring.gross).toBe(3209);
|
||||
expect(calculatePrice({ ...P, recurringCents: 5, discountBp: 5000 }).recurring.net).toBe(3); // 2,5 -> 3
|
||||
});
|
||||
it('unterstützt 7 %, 0 % und 100 % Rabatt', () => {
|
||||
expect(calculatePrice({ ...P, taxBp: 700, recurringCents: 10000 }).recurring.tax).toBe(700);
|
||||
expect(calculatePrice({ ...P, taxBp: 0, recurringCents: 10000 }).recurring.gross).toBe(10000);
|
||||
expect(calculatePrice({ ...P, recurringCents: 10000, discountBp: 10000 }).recurring).toEqual({ net: 0, tax: 0, gross: 0 });
|
||||
});
|
||||
it('Bruttopreise bleiben exakt; Netto und Steuer werden herausgerechnet', () => {
|
||||
const g = (cents: number, extra: object = {}) => calculatePrice({ ...P, basis: 'gross', recurringCents: cents, ...extra }).recurring;
|
||||
expect(g(299)).toEqual({ net: 251, tax: 48, gross: 299 }); // 2,99 € brutto
|
||||
expect(g(999)).toEqual({ net: 839, tax: 160, gross: 999 }); // 9,99 € bleibt 9,99 € (netto allein nicht darstellbar)
|
||||
expect(g(2990)).toEqual({ net: 2513, tax: 477, gross: 2990 });
|
||||
expect(g(15900)).toEqual({ net: 13361, tax: 2539, gross: 15900 });
|
||||
expect(g(999, { taxBp: 0 })).toEqual({ net: 999, tax: 0, gross: 999 });
|
||||
expect(g(999, { discountBp: 1000 })).toEqual({ net: 755, tax: 144, gross: 899 }); // 9,99 * 0,9 = 8,991 -> 8,99 brutto
|
||||
for (let c = 0; c <= 20000; c += 7) { const r = g(c); expect(r.net + r.tax).toBe(r.gross); expect(r.gross).toBe(c); } // Summe stimmt immer
|
||||
expect(calculatePrice({ ...P, basis: 'gross', recurringCents: 999 }).basis).toBe('gross');
|
||||
});
|
||||
it('lehnt ungültige Eingaben ab', () => {
|
||||
for (const bad of [{ quantity: 0 }, { quantity: 1.5 }, { recurringCents: -1 }, { discountBp: 10001 }, { taxBp: NaN }, { interval: 'once' as const, recurringCents: 100 }])
|
||||
expect(() => calculatePrice({ ...P, ...bad })).toThrow(RangeError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Statusautomaten', () => {
|
||||
const orderStates: OrderStatus[] = ['pending_approval', 'approved', 'provisioning', 'completed', 'failed', 'rejected', 'cancelled'];
|
||||
const orderEvents: OrderEvent[] = ['approve', 'reject', 'cancel', 'start_provisioning', 'complete', 'fail', 'retry'];
|
||||
const contractStates: ContractStatus[] = ['pending', 'active', 'suspended', 'cancelled', 'expired', 'failed'];
|
||||
const contractEvents: ContractEvent[] = ['activate', 'suspend', 'unsuspend', 'cancel', 'expire', 'fail'];
|
||||
it('Bestellung: erlaubte Übergänge', () => {
|
||||
expect(transition(ORDER_MACHINE, 'pending_approval', 'approve')).toBe('approved');
|
||||
expect(transition(ORDER_MACHINE, 'approved', 'start_provisioning')).toBe('provisioning');
|
||||
expect(transition(ORDER_MACHINE, 'provisioning', 'complete')).toBe('completed');
|
||||
expect(transition(ORDER_MACHINE, 'failed', 'retry')).toBe('provisioning');
|
||||
});
|
||||
it('Bestellung: alle anderen Übergänge sind verboten, Endzustände haben keine Ausgänge', () => {
|
||||
const allowed = new Set(['pending_approval:approve', 'pending_approval:reject', 'pending_approval:cancel', 'approved:start_provisioning', 'approved:cancel', 'provisioning:complete', 'provisioning:fail', 'failed:retry', 'failed:cancel']);
|
||||
for (const s of orderStates) for (const e of orderEvents) {
|
||||
if (allowed.has(`${s}:${e}`)) expect(() => transition(ORDER_MACHINE, s, e)).not.toThrow();
|
||||
else expect(() => transition(ORDER_MACHINE, s, e)).toThrow(InvalidTransition);
|
||||
}
|
||||
});
|
||||
it('Vertrag: alle Übergänge vollständig geprüft', () => {
|
||||
const allowed = new Set(['pending:activate', 'pending:fail', 'pending:cancel', 'active:suspend', 'active:cancel', 'active:expire', 'suspended:unsuspend', 'suspended:cancel', 'suspended:expire']);
|
||||
for (const s of contractStates) for (const e of contractEvents) {
|
||||
if (allowed.has(`${s}:${e}`)) expect(() => transition(CONTRACT_MACHINE, s, e)).not.toThrow();
|
||||
else expect(() => transition(CONTRACT_MACHINE, s, e)).toThrow(InvalidTransition);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const d = (s: string) => new Date(s + 'T00:00:00Z');
|
||||
describe('Laufzeit und Kündigung', () => {
|
||||
it('addMonths behandelt Monatsenden', () => {
|
||||
expect(addMonths(d('2026-01-31'), 1).toISOString().slice(0, 10)).toBe('2026-02-28');
|
||||
expect(addMonths(d('2028-01-31'), 1).toISOString().slice(0, 10)).toBe('2028-02-29');
|
||||
expect(addMonths(d('2026-11-15'), 3).toISOString().slice(0, 10)).toBe('2027-02-15');
|
||||
});
|
||||
it('Kündigung zum nächsten Laufzeitende unter Beachtung der Frist', () => {
|
||||
const t = { termEnd: d('2027-01-01'), renewal: 'auto' as const, renewalTermMonths: 12, noticeDays: 30 };
|
||||
expect(effectiveCancelDate(d('2026-10-01'), t).toISOString().slice(0, 10)).toBe('2027-01-01'); // rechtzeitig
|
||||
expect(effectiveCancelDate(d('2026-12-15'), t).toISOString().slice(0, 10)).toBe('2028-01-01'); // Frist verpasst -> nächste Periode
|
||||
expect(effectiveCancelDate(d('2026-12-02'), t).toISOString().slice(0, 10)).toBe('2027-01-01'); // genau 30 Tage vorher
|
||||
expect(effectiveCancelDate(d('2026-12-03'), t).toISOString().slice(0, 10)).toBe('2028-01-01');
|
||||
});
|
||||
it('ohne Verlängerung: zum Laufzeitende; ohne Laufzeit: mit Frist ab jetzt', () => {
|
||||
expect(effectiveCancelDate(d('2026-10-01'), { termEnd: d('2027-01-01'), renewal: 'none', renewalTermMonths: 0, noticeDays: 30 }).toISOString().slice(0, 10)).toBe('2027-01-01');
|
||||
expect(effectiveCancelDate(d('2026-10-01'), { termEnd: null, renewal: 'auto', renewalTermMonths: 1, noticeDays: 14 }).toISOString().slice(0, 10)).toBe('2026-10-15');
|
||||
});
|
||||
it('Verlängerung und Verbraucherregel', () => {
|
||||
expect(renewedTermEnd(d('2027-03-10'), d('2027-01-01'), 12).toISOString().slice(0, 10)).toBe('2028-01-01');
|
||||
expect(renewedTermEnd(d('2026-10-01'), d('2027-01-01'), 12).toISOString().slice(0, 10)).toBe('2027-01-01');
|
||||
expect(consumerTerms(12, 90)).toEqual({ renewalTermMonths: 1, noticeDays: 30 }); // Jahresvertrag: nach der Erstlaufzeit monatlich
|
||||
expect(consumerTerms(1, 30)).toEqual({ renewalTermMonths: 1, noticeDays: 30 }); // rollierender Monatsvertrag bleibt monatlich
|
||||
expect(consumerTerms(0, 30)).toEqual({ renewalTermMonths: 0, noticeDays: 30 }); // keine Verlängerung
|
||||
expect(() => effectiveCancelDate(d('2026-12-30'), { termEnd: d('2027-01-01'), renewal: 'auto', renewalTermMonths: 0, noticeDays: 30 })).toThrow(RangeError);
|
||||
});
|
||||
});
|
||||
1
packages/platform/tsconfig.json
Normal file
1
packages/platform/tsconfig.json
Normal file
|
|
@ -0,0 +1 @@
|
|||
{ "extends": "../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", "outDir": "dist", "declaration": true, "types": ["node"] }, "include": ["src"] }
|
||||
Loading…
Add table
Add a link
Reference in a new issue