kundencenter/apps/api/test/connectors.test.ts
2026-09-27 00:51:32 +02:00

120 lines
8.7 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { beforeAll, describe, expect, it } from 'vitest';
import type { FastifyInstance } from 'fastify';
import { buildApp } from '../src/server.js';
import { one, query, run } from '../src/core/db.js';
import { syncInstance } from '@kc/connectors';
import { resetMock } from '@kc/connector-mock';
import { runOnce } from '../../worker/src/jobs.js';
import { call, code, login, makeUser } from './helpers.js';
let app: FastifyInstance;
beforeAll(async () => { app = await buildApp(); await app.ready(); });
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;
}
async function customer(admin: any, name: string, mail: string) {
const c = (await call(app, admin, 'POST', '/admin/customers', { type: 'business', name, owner: { email: mail, name } })).json();
await app.inject({ method: 'POST', url: '/v1/auth/invite/accept', payload: { token: new URL(c.inviteLink).searchParams.get('token'), password: 'passwort-kunde-123', repeat: 'passwort-kunde-123' } });
return { org: c.id as string, client: (await login(app, mail, 'passwort-kunde-123')).client };
}
const drain = async () => { for (let i = 0; i < 20 && (await runOnce(() => undefined)); i++); };
describe('Connectoren, Ressourcen und Aufträge', () => {
it('Ende-zu-Ende: Verbindung, Abgleich, Zuweisung, Rechte, idempotenter Auftrag, Ausfall mit letztem Stand', async () => {
resetMock();
const admin = await staff('adm@conn.test', 'admin'); const sup = await staff('sa@conn.test', 'superadmin');
const A = await customer(admin, 'Firma A', 'a@conn.test'); const B = await customer(admin, 'Firma B', 'b@conn.test');
// Nur Superadmin darf Verbindungen (mit Geheimnissen) anlegen
const body = { connector: 'mock', name: 'Mock-Test', values: { instance: 'e2e' } };
expect((await call(app, admin, 'POST', '/admin/connectors', body)).statusCode).toBe(403);
const created = (await call(app, sup, 'POST', '/admin/connectors', body)).json();
const list = (await call(app, sup, 'GET', '/admin/connectors')).json();
expect(list[0].hasSecrets).toBe(false); expect(JSON.stringify(list)).not.toMatch(/secrets_enc|password/);
// Abgleich über Worker-Job (angelegt beim Erstellen)
await drain();
const inst = (await call(app, sup, 'GET', '/admin/connectors')).json()[0];
expect(inst.health).toBe('ok'); expect(inst.resources).toBe(3);
const all = (await call(app, admin, 'GET', '/resources')).json();
expect(all).toHaveLength(3);
// Nicht zugewiesene Ressourcen sind für Kunden unsichtbar
expect((await call(app, A.client, 'GET', '/resources')).json()).toHaveLength(0);
const res = all.find((r: any) => r.name.includes('Pro'));
expect((await call(app, A.client, 'GET', `/resources/${res.id}`)).statusCode).toBe(404);
// Zuweisung an A: A sieht sie, B nicht
expect((await call(app, admin, 'PATCH', `/admin/resources/${res.id}`, { orgId: A.org })).statusCode).toBe(200);
expect((await call(app, A.client, 'GET', '/resources')).json()).toHaveLength(1);
expect((await call(app, B.client, 'GET', `/resources/${res.id}`)).statusCode).toBe(404);
expect((await call(app, B.client, 'POST', `/resources/${res.id}/actions`, { action: 'suspend' })).statusCode).toBe(404);
// Aktionen: standardmäßig keine für Kunden (Ressourcenregel), Staff sieht alle unterstützten
expect((await call(app, A.client, 'GET', `/resources/${res.id}`)).json().allowedActions).toEqual([]);
expect((await call(app, A.client, 'POST', `/resources/${res.id}/actions`, { action: 'suspend' })).json().error.code).toBe('ACTION_NOT_ALLOWED');
expect((await call(app, admin, 'GET', `/resources/${res.id}`)).json().allowedActions).toEqual(['suspend', 'unsuspend', 'extend']);
await call(app, admin, 'PATCH', `/admin/resources/${res.id}`, { customerActions: ['suspend', 'unsuspend'] });
expect((await call(app, A.client, 'GET', `/resources/${res.id}`)).json().allowedActions).toEqual(['suspend', 'unsuspend']);
expect((await call(app, A.client, 'POST', `/resources/${res.id}/actions`, { action: 'extend', days: 30 })).statusCode).toBe(403);
// Idempotenz: gleicher Schlüssel => ein Auftrag
const hdr = { cookie: A.client.cookie, 'x-csrf-token': A.client.csrf, 'idempotency-key': 'klick-0001-abcdef' };
const r1 = await app.inject({ method: 'POST', url: `/v1/resources/${res.id}/actions`, payload: { action: 'suspend' }, headers: hdr });
const r2 = await app.inject({ method: 'POST', url: `/v1/resources/${res.id}/actions`, payload: { action: 'suspend' }, headers: hdr });
expect(r1.statusCode).toBe(202); expect(r2.json().jobId).toBe(r1.json().jobId); expect(r2.json().duplicate).toBe(true);
expect((await query("SELECT id FROM jobs WHERE type='connector.execute'")).length).toBe(1);
expect((await call(app, A.client, 'GET', `/jobs/${r1.json().jobId}`)).json().status).toBe('scheduled');
expect((await call(app, B.client, 'GET', `/jobs/${r1.json().jobId}`)).statusCode).toBe(404);
// Worker führt aus; Zustand + Audit
await drain();
expect((await call(app, A.client, 'GET', `/jobs/${r1.json().jobId}`)).json().status).toBe('succeeded');
expect((await call(app, A.client, 'GET', `/resources/${res.id}`)).json().state).toBe('suspended');
const au = await one("SELECT actor_id, connector, result FROM audit_events WHERE action='resource.suspend' ORDER BY id DESC LIMIT 1");
expect(au!.connector).toBe('mock'); expect(au!.result).toBe('success');
// Providerausfall: letzter Stand bleibt sichtbar, mit Hinweis
await call(app, sup, 'PATCH', `/admin/connectors/${created.id}`, { values: { simulateOutage: 'true' } });
await call(app, sup, 'POST', `/admin/connectors/${created.id}/sync`); await drain();
const down = (await call(app, sup, 'GET', '/admin/connectors')).json()[0];
expect(down.health).toBe('down'); expect(down.lastOkAt).toBeTruthy();
const stale = (await call(app, A.client, 'GET', `/resources/${res.id}`)).json();
expect(stale.stale).toBe(true); expect(stale.state).toBe('suspended'); expect(stale.staleReason).toMatch(/nicht erreichbar/);
expect(stale.allowedActions).toEqual([]); // keine Aktionen während des Ausfalls
expect((await one("SELECT COUNT(*) n FROM audit_events WHERE action='connector.down'"))!.n).toBe(1);
// Aktion bei Ausfall: wird wiederholt (retrying), nicht verloren – nach Erholung erfolgreich
await call(app, sup, 'PATCH', `/admin/connectors/${created.id}`, { values: { simulateOutage: 'false' } });
await call(app, sup, 'POST', `/admin/connectors/${created.id}/sync`); await drain();
expect((await call(app, sup, 'GET', '/admin/connectors')).json()[0].health).toBe('ok');
expect((await one("SELECT COUNT(*) n FROM audit_events WHERE action='connector.recovered'"))!.n).toBe(1);
});
it('wiederholt fehlgeschlagene Aufträge begrenzt, destruktive nie', async () => {
const inst = await one("SELECT id FROM connector_instances LIMIT 1");
await run("UPDATE connector_instances SET config_json = JSON_SET(config_json, '$.simulateOutage', 'true') WHERE id = ?", [inst!.id]);
const res = await one('SELECT id FROM resources LIMIT 1');
const mk = async (payload: object, max = 2) => { const id = crypto.randomUUID(); await run('INSERT INTO jobs (id,type,payload,max_attempts) VALUES (?,?,?,?)', [id, 'connector.execute', JSON.stringify({ resourceId: res!.id, actorUserId: null, ...payload }), max]); return id; };
const retry = await mk({ action: 'suspend' });
await run("UPDATE jobs SET status='succeeded' WHERE type='connector.execute' AND id <> ?", [retry]);
await runOnce(() => undefined);
expect((await one('SELECT status, attempts, last_error FROM jobs WHERE id = ?', [retry]))).toMatchObject({ status: 'retrying', attempts: 1 });
await run("UPDATE jobs SET run_at = UTC_TIMESTAMP(3) WHERE id = ?", [retry]);
await runOnce(() => undefined);
expect((await one('SELECT status FROM jobs WHERE id = ?', [retry]))!.status).toBe('needs_review'); // Limit erreicht -> manuelle Prüfung
const term = await mk({ action: 'terminate', destructive: true }, 5);
await runOnce(() => undefined);
const t = await one('SELECT status, attempts FROM jobs WHERE id = ?', [term]);
expect(t).toMatchObject({ status: 'needs_review', attempts: 1 }); // nicht automatisch wiederholt
// Manuelle Wiederholung: normaler Auftrag ja, destruktiver nein
const adm = await staff('adm2@conn.test', 'admin');
expect((await call(app, adm, 'POST', `/admin/jobs/${retry}/retry`)).statusCode).toBe(200);
expect((await call(app, adm, 'POST', `/admin/jobs/${term}/retry`)).json().error.code).toBe('DESTRUCTIVE');
});
});