Stand vor Einführung des Nacht-Agenten

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

View file

@ -0,0 +1,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"
}
}

View 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();

View 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');
});
});

View 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
});
});

View file

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