121 lines
8.7 KiB
TypeScript
121 lines
8.7 KiB
TypeScript
|
|
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');
|
|||
|
|
});
|
|||
|
|
});
|