Stand vor Einführung des Nacht-Agenten
This commit is contained in:
commit
4763548bfb
168 changed files with 12726 additions and 0 deletions
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);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue