2026-09-27 00:51:32 +02:00
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 ;
} ) ;
} ) ;
2026-09-27 21:35:15 +02:00
describe ( 'KeyHelp: Panel-Passwort neu vergeben' , ( ) = > {
it ( 'erzeugt ein neues Passwort, setzt es beim Anbieter, speichert es verschlüsselt und lässt es auf Abruf ansehen' , async ( ) = > {
const admin = await staff ( 'kh-pw-admin@x.test' , 'admin' ) ; const support = await staff ( 'kh-pw-support@x.test' , 'support' ) ;
await call ( app , admin , 'POST' , '/admin/connectors' , { connector : 'keyhelp' , name : 'KeyHelp-PW-Test' , values : { baseUrl : 'https://kh.test' , apiKey : 'kh-test-key' } } ) ;
await drain ( ) ;
const res = ( await call ( app , admin , 'GET' , '/resources' ) ) . json ( ) ; const alpha = res . find ( ( r : any ) = > r . name . includes ( 'alpha' ) ) ;
// ohne Berechtigung (Support nur lesend)
expect ( ( await call ( app , support , 'POST' , ` /resources/ ${ alpha . id } /panel-credentials/reset ` ) ) . statusCode ) . toBe ( 403 ) ;
const before = ( await call ( app , admin , 'GET' , ` /resources/ ${ alpha . id } /panel-credentials ` ) ) . json ( ) ;
expect ( before ) . toEqual ( { canReset : true , set : false , setAt : null } ) ;
const reset = await call ( app , admin , 'POST' , ` /resources/ ${ alpha . id } /panel-credentials/reset ` ) ; expect ( reset . statusCode ) . toBe ( 202 ) ;
await drain ( ) ;
const fakeClient = fake . state . clients . find ( ( c ) = > c . username === 'alpha' ) ! ;
const after = ( await call ( app , admin , 'GET' , ` /resources/ ${ alpha . id } /panel-credentials ` ) ) . json ( ) ;
expect ( after . set ) . toBe ( true ) ; expect ( after . setAt ) . toBeTruthy ( ) ;
const revealed = ( await call ( app , admin , 'POST' , ` /resources/ ${ alpha . id } /panel-credentials/reveal ` ) ) . json ( ) ;
expect ( revealed . items ) . toEqual ( [ { label : 'Panel-Passwort' , value : ( fakeClient as any ) . password } ] ) ;
expect ( ( revealed . items [ 0 ] . value as string ) . length ) . toBeGreaterThan ( 15 ) ;
expect ( ( await call ( app , support , 'POST' , ` /resources/ ${ alpha . id } /panel-credentials/reveal ` ) ) . statusCode ) . toBe ( 403 ) ;
// in der Datenbank steht das Passwort nur verschlüsselt
const row = await one ( 'SELECT secret_enc FROM panel_credentials WHERE resource_id = ?' , [ alpha . id ] ) ;
expect ( row ! . secret_enc ) . toMatch ( /^v1:/ ) ; expect ( row ! . secret_enc ) . not . toContain ( ( fakeClient as any ) . password ) ;
// erneutes Vergeben ersetzt das alte Passwort (eigener Idempotenzschlüssel, damit es nicht als Doppelklick gilt)
const oldPw = ( fakeClient as any ) . password ;
await app . inject ( { method : 'POST' , url : ` /v1/resources/ ${ alpha . id } /panel-credentials/reset ` , headers : { cookie : admin.cookie , 'x-csrf-token' : admin . csrf , 'idempotency-key' : 'zweiter-reset-test' } } ) ;
await drain ( ) ;
const revealed2 = ( await call ( app , admin , 'POST' , ` /resources/ ${ alpha . id } /panel-credentials/reveal ` ) ) . json ( ) ;
expect ( revealed2 . items [ 0 ] . value ) . not . toBe ( oldPw ) ;
} ) ;
} ) ;