/** * 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; 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 = { 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; 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 }; }