122 lines
12 KiB
TypeScript
122 lines
12 KiB
TypeScript
|
|
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');
|
||
|
|
});
|
||
|
|
});
|