kundencenter/packages/connector-licensing/test/modern.test.ts

114 lines
11 KiB
TypeScript
Raw Normal View History

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