123 lines
16 KiB
TypeScript
123 lines
16 KiB
TypeScript
|
|
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||
|
|
import type { FastifyInstance } from 'fastify';
|
||
|
|
import { buildApp } from '../src/server.js';
|
||
|
|
import { one, query, run } from '../src/core/db.js';
|
||
|
|
import { runOnce } from '../../worker/src/jobs.js';
|
||
|
|
import { createFake, type Fake } from '../../../packages/connector-keyhelp/test/fake.js';
|
||
|
|
import { call, code, login, makeUser } from './helpers.js';
|
||
|
|
|
||
|
|
let app: FastifyInstance; let fake: Fake; const realFetch = globalThis.fetch;
|
||
|
|
beforeAll(async () => {
|
||
|
|
fake = createFake('kh-test-key');
|
||
|
|
vi.stubGlobal('fetch', ((url: any, init: any) => (String(url).startsWith('https://kh.test') ? fake.fetch(url, init) : realFetch(url, init))) as typeof fetch);
|
||
|
|
app = await buildApp(); await app.ready();
|
||
|
|
});
|
||
|
|
afterAll(() => vi.unstubAllGlobals());
|
||
|
|
const drain = async () => { for (let i = 0; i < 30; i++) { await run("UPDATE jobs SET run_at = UTC_TIMESTAMP(3) WHERE status IN ('scheduled','retrying')"); if (!(await runOnce(() => undefined))) break; } };
|
||
|
|
async function staff(email: string, role: string) {
|
||
|
|
await makeUser({ email, kind: 'staff', staffRole: role });
|
||
|
|
const { client } = await login(app, email); const s = (await call(app, client, 'POST', '/auth/mfa/setup')).json();
|
||
|
|
await call(app, client, 'POST', '/auth/mfa/confirm', { code: code(s.secret) }); return client;
|
||
|
|
}
|
||
|
|
const accept = async (link: string, pw: string) => app.inject({ method: 'POST', url: '/v1/auth/invite/accept', payload: { token: new URL(link).searchParams.get('token'), password: pw, repeat: pw } });
|
||
|
|
|
||
|
|
describe('KeyHelp: Verbindung, Kundenübernahme, neue Tarife, Hosting-Funktionen', () => {
|
||
|
|
it('Ende-zu-Ende gegen einen nach der Definition nachgebauten KeyHelp-Server', async () => {
|
||
|
|
const admin = await staff('kh-adm@x.test', 'admin'); const sa = await staff('kh-sa@x.test', 'superadmin'); const sup = await staff('kh-sup@x.test', 'support');
|
||
|
|
// ---- Verbindung: nur Superadmin, Geheimnis verschlüsselt, Abgleich liest Konten mit Nutzung
|
||
|
|
const types = (await call(app, sa, 'GET', '/admin/connector-types')).json(); expect(types.find((t: any) => t.key === 'keyhelp').fields.map((f: any) => f.name)).toEqual(['baseUrl', 'apiKey', 'verifyTls', 'tlsFingerprint']);
|
||
|
|
const conn = await call(app, sa, 'POST', '/admin/connectors', { connector: 'keyhelp', name: 'KeyHelp-Test', values: { baseUrl: 'https://kh.test', apiKey: 'kh-test-key' } }); expect(conn.statusCode).toBe(200);
|
||
|
|
const raw = await one('SELECT secrets_enc FROM connector_instances'); expect(raw!.secrets_enc).toMatch(/^v1:/); expect(raw!.secrets_enc).not.toContain('kh-test-key');
|
||
|
|
await drain();
|
||
|
|
const inst = (await call(app, sa, 'GET', '/admin/connectors')).json()[0]; expect(inst.health).toBe('ok'); expect(inst.resources).toBe(2);
|
||
|
|
expect(inst.capabilities).toEqual(expect.arrayContaining(['customers.list', 'catalog.write', 'children.read', 'children.write', 'sso.login', 'lifecycle.create', 'plan.change']));
|
||
|
|
const res = (await call(app, admin, 'GET', '/resources')).json(); expect(res.map((r: any) => r.state).sort()).toEqual(['active', 'suspended']);
|
||
|
|
const alphaRes = res.find((r: any) => r.name.includes('alpha')); const det = (await call(app, admin, 'GET', `/resources/${alphaRes.id}`)).json();
|
||
|
|
expect(det.usage).toMatchObject({ domains: 2 }); expect(det.limits).toMatchObject({ disk_space: 10737418240 }); expect(det.details.plan).toBe('Starter');
|
||
|
|
// ---- neuen Tarif anlegen + Produkt definieren
|
||
|
|
const t19 = (await call(app, admin, 'GET', '/admin/tax-rates')).json().find((t: any) => t.rateBp === 1900).id;
|
||
|
|
const planBody = { plan: { name: 'Neu Web 20', limits: { diskSpaceGb: 20, trafficGb: 200, domains: 3, emailAccounts: 10, databases: 3, ftpUsers: 2 }, permissions: { ftp: true, ssh: false } },
|
||
|
|
product: { sku: 'HOST-NEU-20', status: 'draft', orderableByCustomer: false, requiresApproval: true, customerActions: ['panel.login', 'email.manage'], version: { name: 'Webhosting Neu 20', taxRateId: t19, priceBasis: 'gross', recurringCents: 499, billingInterval: 'monthly', termMonths: 0, renewal: 'auto', renewalTermMonths: 1, noticeDays: 30 } } };
|
||
|
|
expect((await call(app, sup, 'POST', `/admin/connectors/${conn.json().id}/hosting-plans`, planBody)).statusCode).toBe(403);
|
||
|
|
const created = await call(app, admin, 'POST', `/admin/connectors/${conn.json().id}/hosting-plans`, planBody); expect(created.statusCode).toBe(200);
|
||
|
|
expect(created.json().plan.features).toEqual(expect.arrayContaining(['Speicher: 20 GB', 'Domains: 3', 'FTP']));
|
||
|
|
const newPlan = fake.state.plans.find((p) => p.name === 'Neu Web 20')!; expect(newPlan.resources.disk_space).toBe(20 * 1024 ** 3);
|
||
|
|
const prod = (await call(app, admin, 'GET', `/admin/products/${created.json().productId}`)).json(); expect(prod).toMatchObject({ sku: 'HOST-NEU-20', status: 'draft', externalRef: `plan:${newPlan.id}`, provisioning: { hostingPlanId: newPlan.id } });
|
||
|
|
expect((await call(app, admin, 'POST', `/admin/connectors/${conn.json().id}/hosting-plans`, { ...planBody, product: { ...planBody.product, sku: 'HOST-NEU-21' } })).json().error.code).toBe('PLAN_EXISTS'); // Name schon vergeben
|
||
|
|
// ---- Produkt zum vorhandenen Tarif "Starter" (Übernahme aus Katalog) für die Bestandsverträge
|
||
|
|
const starter = (await call(app, admin, 'POST', '/admin/products', { sku: 'HOST-STARTER', category: 'hosting', connectorInstanceId: conn.json().id, externalRef: 'plan:1', status: 'active', orderableByCustomer: false, requiresApproval: true, customerActions: ['panel.login', 'domain.manage', 'email.manage', 'suspend'],
|
||
|
|
version: { name: 'Starter', taxRateId: t19, priceBasis: 'gross', recurringCents: 999, billingInterval: 'monthly', termMonths: 0, renewal: 'auto', renewalTermMonths: 1, noticeDays: 30, provisioning: { hostingPlanId: 1, language: 'de', createSystemDomain: true, sendLoginCredentials: true } } })).json();
|
||
|
|
// ---- Kunden aus KeyHelp lesen (Kontaktdaten), Rechte
|
||
|
|
fake.state.clients[0]!.contact_data = { company: 'Alpha GmbH', first_name: 'Anna', last_name: 'Alpha', telephone: '0123', address: 'Weg 1', zip: '10115', city: 'Berlin', country: 'DE', client_id: 'K-77' };
|
||
|
|
expect((await call(app, sup, 'GET', `/admin/connectors/${conn.json().id}/customers`)).statusCode).toBe(403);
|
||
|
|
const cust = (await call(app, admin, 'GET', `/admin/connectors/${conn.json().id}/customers`)).json();
|
||
|
|
expect(cust.map((c: any) => c.externalRef)).toEqual(['1', '2']); expect(cust[0]).toMatchObject({ displayName: 'Alpha GmbH', imported: null, resourceKnown: true, planRef: 'plan:1' }); expect(cust[0].suggestedProducts.map((p: any) => p.sku)).toEqual(['HOST-STARTER']);
|
||
|
|
expect(JSON.stringify(cust)).not.toMatch(/GEHEIM|password/i);
|
||
|
|
// ---- Übernahme: Kunde, Anschrift, Inhaber (eingeladen, KEINE Mail), Konto zugeordnet, Bestandsvertrag
|
||
|
|
const imp = (items: any[]) => call(app, admin, 'POST', `/admin/connectors/${conn.json().id}/customers/import`, { items });
|
||
|
|
expect((await call(app, sup, 'POST', `/admin/connectors/${conn.json().id}/customers/import`, { items: [{ externalRef: '1', type: 'business' }] })).statusCode).toBe(403);
|
||
|
|
const r1 = (await imp([{ externalRef: '1', type: 'business', contract: { productId: starter.id, startedAt: '2025-01-15T00:00:00Z' } }, { externalRef: '2', type: 'private', name: 'Bernd Beta' }])).json().results;
|
||
|
|
expect(r1.map((r: any) => r.status)).toEqual(['created', 'created']); expect(r1[0].contractNumber).toMatch(/^V-/); expect(r1[1].contractNumber).toBeUndefined();
|
||
|
|
const orgA = await one("SELECT o.*, b.company, b.street, b.zip, b.city, b.billing_email FROM organizations o JOIN billing_profiles b ON b.org_id = o.id WHERE o.id = ?", [r1[0].orgId]);
|
||
|
|
expect(orgA).toMatchObject({ name: 'Alpha GmbH', customer_type: 'business', company: 'Alpha GmbH', street: 'Weg 1', zip: '10115', city: 'Berlin', billing_email: 'alpha@example.test' }); expect(orgA!.legacy_ref).toContain(`keyhelp:${conn.json().id}:1`);
|
||
|
|
const ownerA = await one("SELECT u.* FROM users u JOIN memberships m ON m.user_id = u.id WHERE m.org_id = ?", [r1[0].orgId]); expect(ownerA).toMatchObject({ email: 'alpha@example.test', status: 'invited', kind: 'customer' });
|
||
|
|
expect((await one('SELECT COUNT(*) n FROM mail_log'))!.n).toBe(0); // es wurde nichts versendet
|
||
|
|
const ct = (await call(app, admin, 'GET', `/contracts?org=${r1[0].orgId}`)).json()[0]; expect(ct).toMatchObject({ status: 'active', productName: 'Starter' }); expect(ct.price.imported).toBe(true); expect(ct.price.recurring.gross).toBe(999); expect(ct.resourceId).toBe(alphaRes.id);
|
||
|
|
expect(new Date(ct.termEnd).getTime()).toBeGreaterThan(Date.now()); // Laufzeit bis in die Zukunft fortgeschrieben
|
||
|
|
const betaRes = (await call(app, admin, 'GET', `/resources?org=${r1[1].orgId}`)).json(); expect(betaRes).toHaveLength(1);
|
||
|
|
// Wiederholung / Konflikte
|
||
|
|
expect((await imp([{ externalRef: '1', type: 'business' }])).json().results[0]).toMatchObject({ status: 'skipped', reason: 'Bereits übernommen' });
|
||
|
|
expect((await imp([{ externalRef: '999', type: 'business' }])).json().results[0].status).toBe('skipped');
|
||
|
|
// Verknüpfen mit vorhandenem Kunden statt neu anlegen
|
||
|
|
fake.state.clients.push({ id: 3, status: 1, username: 'gamma', email: 'alpha@example.test', is_suspended: false, id_hosting_plan: 1, created_at: '2026-03-01 10:00:00', contact_data: { company: 'Gamma' }, permissions: {} }); fake.state.domains.push({ id: 70, id_user: 3, domain: 'gamma.example.test', status: 1, security: {} });
|
||
|
|
await run("UPDATE connector_instances SET last_sync_at = NULL"); await call(app, sa, 'POST', `/admin/connectors/${conn.json().id}/sync`); await drain();
|
||
|
|
const dup = (await imp([{ externalRef: '3', type: 'business' }])).json().results[0]; expect(dup.status).toBe('skipped'); expect(dup.reason).toMatch(/bereits einem Benutzer/); // E-Mail vergeben
|
||
|
|
const lk = (await imp([{ externalRef: '3', type: 'business', linkToOrgId: r1[0].orgId }])).json().results[0]; expect(lk).toMatchObject({ status: 'linked', orgId: r1[0].orgId });
|
||
|
|
// ---- Einladung gezielt nachträglich senden
|
||
|
|
expect((await call(app, sup, 'POST', `/admin/users/${ownerA!.id}/reinvite`)).statusCode).toBe(403);
|
||
|
|
const inv = (await call(app, admin, 'POST', `/admin/users/${ownerA!.id}/reinvite`)).json(); expect(inv.mail).toBe('not_configured'); expect(inv.inviteLink).toContain('/einladung?token=');
|
||
|
|
expect((await accept(inv.inviteLink, 'passwort-alpha-123')).statusCode).toBe(200);
|
||
|
|
expect((await call(app, admin, 'POST', `/admin/users/${ownerA!.id}/reinvite`)).json().error.code).toBe('NOT_INVITED');
|
||
|
|
const owner = (await login(app, 'alpha@example.test', 'passwort-alpha-123')).client;
|
||
|
|
// ---- Kunde nutzt sein Hosting: Panel-Login, Unterobjekte lesen (Mandantentrennung), Änderungen als Auftrag
|
||
|
|
const cres = (await call(app, owner, 'GET', '/resources')).json().find((r: any) => r.name.includes('alpha'));
|
||
|
|
const d2 = (await call(app, owner, 'GET', `/resources/${cres.id}`)).json(); expect(d2).toMatchObject({ hasChildren: true, canLogin: true });
|
||
|
|
const lg = await call(app, owner, 'POST', `/resources/${cres.id}/login`); expect(lg.statusCode).toBe(200); expect(lg.json()).toEqual({ url: 'https://kh.test/login?token=EINMALIG-1', validForSec: 3600 }); expect(lg.headers['cache-control']).toBe('no-store');
|
||
|
|
expect((await call(app, owner, 'POST', `/resources/${betaRes[0].id}/login`)).statusCode).toBe(404); // Konto eines anderen Kunden
|
||
|
|
expect((await query("SELECT COUNT(*) n FROM audit_events WHERE action = 'resource.login'"))[0]!.n).toBe(1); expect(JSON.stringify(await query('SELECT * FROM audit_events'))).not.toContain('EINMALIG');
|
||
|
|
const kinds = (await call(app, owner, 'GET', `/resources/${cres.id}/children`)).json(); expect(kinds.kinds.find((k: any) => k.kind === 'email').canWrite).toBe(true); expect(kinds.kinds.find((k: any) => k.kind === 'database').canWrite).toBe(false); expect(kinds.kinds.find((k: any) => k.kind === 'certificate').canWrite).toBe(false);
|
||
|
|
const doms = (await call(app, owner, 'GET', `/resources/${cres.id}/children/domain`)).json(); expect(doms.map((d: any) => d.name)).toContain('alpha.example.test'); expect(doms.map((d: any) => d.name)).not.toContain('beta.example.test');
|
||
|
|
const impd = await call(app, sa, 'POST', `/admin/domain-records/from-resource/${cres.id}`); expect(impd.statusCode).toBe(200); expect(impd.json().added).toBeGreaterThanOrEqual(1); expect(impd.json().unpriced).toBeGreaterThanOrEqual(1);
|
||
|
|
const impd2 = await call(app, sa, 'POST', `/admin/domain-records/from-resource/${cres.id}`); expect(impd2.json().added).toBe(0); // zweiter Lauf legt nichts doppelt an
|
||
|
|
expect((await call(app, owner, 'GET', `/resources/${cres.id}/children/email`)).json().map((e: any) => e.name)).toEqual(['info@alpha.example.test']);
|
||
|
|
// Schreiben: Passwortregeln, Freigabe je Art, Ausführung im Hintergrund, Passwort nirgends gespeichert
|
||
|
|
const post = (kind: string, body: object, r = cres.id) => call(app, owner, 'POST', `/resources/${r}/children/${kind}`, body);
|
||
|
|
expect((await post('email', { op: 'create', data: { local: 'kontakt', domain: 'alpha.example.test' }, password: 'kurz' })).json().error.code).toBe('WEAK_PASSWORD');
|
||
|
|
expect((await post('email', { op: 'create', data: { local: 'kontakt', domain: 'alpha.example.test' } })).json().error.code).toBe('PASSWORD_REQUIRED');
|
||
|
|
expect((await post('database', { op: 'create', data: {}, password: 'ein-langes-passwort-1' })).json().error.code).toBe('ACTION_NOT_ALLOWED'); // database.manage nicht freigegeben
|
||
|
|
expect((await post('certificate', { op: 'delete', id: '60', data: {} })).statusCode).toBe(403);
|
||
|
|
const ok = await post('email', { op: 'create', data: { local: 'kontakt', domain: 'alpha.example.test' }, password: 'ein-sehr-langes-Passwort-42' }); expect(ok.statusCode).toBe(202);
|
||
|
|
const jobRow = await one('SELECT payload FROM jobs WHERE id = ?', [ok.json().jobId]); expect(JSON.stringify(jobRow!.payload)).not.toContain('sehr-langes'); expect(JSON.stringify(jobRow!.payload)).toContain('secretEnc'); // nur verschlüsselt im Auftrag
|
||
|
|
await drain();
|
||
|
|
expect((await one('SELECT status, last_error, payload FROM jobs WHERE id = ?', [ok.json().jobId]))).toMatchObject({ status: 'succeeded' });
|
||
|
|
expect(JSON.stringify((await one('SELECT payload FROM jobs WHERE id = ?', [ok.json().jobId]))!.payload)).not.toContain('secretEnc'); // nach Ausführung entfernt
|
||
|
|
expect(fake.state.emails.map((e) => e.email)).toContain('kontakt@alpha.example.test');
|
||
|
|
expect(JSON.stringify(await query('SELECT * FROM audit_events'))).not.toContain('sehr-langes'); expect(JSON.stringify(await query('SELECT payload FROM jobs'))).not.toContain('sehr-langes');
|
||
|
|
// Doppelklick: gleicher Schlüssel = ein Auftrag
|
||
|
|
const hdr = { cookie: owner.cookie, 'x-csrf-token': owner.csrf, 'idempotency-key': 'mail-doppelt-0001' }; const body = { op: 'create', data: { local: 'zwei', domain: 'alpha.example.test' }, password: 'ein-sehr-langes-Passwort-42' };
|
||
|
|
const a1 = await app.inject({ method: 'POST', url: `/v1/resources/${cres.id}/children/email`, payload: body, headers: hdr }); const a2 = await app.inject({ method: 'POST', url: `/v1/resources/${cres.id}/children/email`, payload: body, headers: hdr });
|
||
|
|
expect(a2.json().jobId).toBe(a1.json().jobId); expect(a2.json().duplicate).toBe(true); await drain(); expect(fake.state.emails.filter((e) => e.email === 'zwei@alpha.example.test').length).toBe(1);
|
||
|
|
// Fremdes Objekt löschen: der Auftrag scheitert, nichts wird verändert
|
||
|
|
const before = JSON.stringify(fake.state.emails);
|
||
|
|
const del = await post('email', { op: 'delete', id: '31', data: {} }); expect(del.statusCode).toBe(202); await drain();
|
||
|
|
expect((await one('SELECT status, last_error FROM jobs WHERE id = ?', [del.json().jobId]))).toMatchObject({ status: 'failed' }); expect(JSON.stringify(fake.state.emails)).toBe(before);
|
||
|
|
// Eigenes Objekt löschen
|
||
|
|
const delOwn = await post('email', { op: 'delete', id: String(fake.state.emails.find((e) => e.email === 'zwei@alpha.example.test')!.id), data: {} }); await drain();
|
||
|
|
expect(fake.state.emails.some((e) => e.email === 'zwei@alpha.example.test')).toBe(false); expect((await one('SELECT status FROM jobs WHERE id = ?', [delOwn.json().jobId]))!.status).toBe('succeeded');
|
||
|
|
// Sperren durch Kunden (freigegeben) läuft über den bestehenden Auftrag
|
||
|
|
const sus = await call(app, owner, 'POST', `/resources/${cres.id}/actions`, { action: 'suspend' }); expect(sus.statusCode).toBe(202); await drain(); expect(fake.state.clients.find((c) => c.id === 1)!.is_suspended).toBe(true);
|
||
|
|
// Anbieter-Ausfall: verständlicher Fehler, kein Absturz
|
||
|
|
fake.opts.failGet503 = 50; const down = await call(app, owner, 'GET', `/resources/${cres.id}/children/domain`); expect(down.statusCode).toBe(502); fake.opts.failGet503 = 0;
|
||
|
|
});
|
||
|
|
});
|