Stand vor Einführung des Nacht-Agenten
This commit is contained in:
commit
4763548bfb
168 changed files with 12726 additions and 0 deletions
37
apps/api/package.json
Normal file
37
apps/api/package.json
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
{
|
||||
"name": "@kc/api",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/server.ts",
|
||||
"start": "node dist/server.js",
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"migrate": "tsx src/cli/migrate.ts",
|
||||
"cli": "tsx src/cli/index.ts",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/cookie": "^11.1.2",
|
||||
"@fastify/helmet": "^13.1.1",
|
||||
"@fastify/rate-limit": "^11.2.0",
|
||||
"@kc/connector-sdk": "workspace:*",
|
||||
"@kc/connectors": "workspace:*",
|
||||
"@kc/platform": "workspace:*",
|
||||
"@node-rs/argon2": "^2.2.1",
|
||||
"fastify": "^5.12.5",
|
||||
"mysql2": "^3.24.4",
|
||||
"nodemailer": "^10.0.10",
|
||||
"otpauth": "^9.5.2",
|
||||
"zod": "^4.6.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@kc/connector-mock": "workspace:*",
|
||||
"@types/node": "^26.6.3",
|
||||
"@types/nodemailer": "^8.0.2",
|
||||
"tsx": "^4.23.15",
|
||||
"typescript": "^7.0.2",
|
||||
"vitest": "^5.0.2"
|
||||
}
|
||||
}
|
||||
56
apps/api/src/cli/index.ts
Normal file
56
apps/api/src/cli/index.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import { randomUUID } from 'node:crypto';
|
||||
import { hash } from '@node-rs/argon2';
|
||||
import '../core/config.js';
|
||||
import { pool, one, run } from '../core/db.js';
|
||||
import { audit } from '../core/audit.js';
|
||||
|
||||
const [cmd, ...args] = process.argv.slice(2);
|
||||
const opt = (n: string) => args.find((a) => a.startsWith(`--${n}=`))?.slice(n.length + 3);
|
||||
|
||||
if (cmd === 'create-superadmin') {
|
||||
const email = opt('email')?.toLowerCase(), name = opt('name') ?? 'Superadmin', pw = opt('password');
|
||||
if (!email || !pw || pw.length < 12) { console.error('Nutzung: create-superadmin --email=… --name=… --password=<min. 12 Zeichen>'); process.exit(2); }
|
||||
if (await one('SELECT 1 AS x FROM users WHERE email = ?', [email])) { console.error('E-Mail existiert bereits'); process.exit(1); }
|
||||
const id = randomUUID();
|
||||
await run("INSERT INTO users (id, email, name, password_hash, kind, staff_role, status, email_verified_at) VALUES (?,?,?,?,'staff','superadmin','active',UTC_TIMESTAMP(3))", [id, email, name, await hash(pw, { memoryCost: 19456, timeCost: 2, parallelism: 1 })]);
|
||||
await audit({ actorType: 'system', action: 'user.create', resourceType: 'user', resourceId: id, after: { email, kind: 'staff', staffRole: 'superadmin', via: 'cli' } });
|
||||
console.log('Superadmin angelegt:', email, '(2FA muss beim ersten Login eingerichtet werden)');
|
||||
} else if (cmd === 'connector-secrets') {
|
||||
// Zugangsdaten einer Verbindung aus einer geschützten Datei setzen (Werte erscheinen nie in Argumenten/Logs)
|
||||
// Nutzung: connector-secrets --name=Licensing --file=/pfad/datei.txt (Zeilen SCHLÜSSEL=Wert; LICENSING_API_USER→username, LICENSING_API_PASSWORD→password)
|
||||
const { readFileSync } = await import('node:fs');
|
||||
const { encryptSecrets } = await import('@kc/connectors');
|
||||
const { decrypt } = await import('../core/crypto.js');
|
||||
const { enqueue } = await import('../core/jobs.js');
|
||||
const name = opt('name'), file = opt('file');
|
||||
if (!name || !file) { console.error('Nutzung: connector-secrets --name=<Verbindung> --file=<Datei>'); process.exit(2); }
|
||||
const map: Record<string, string> = { LICENSING_API_USER: 'username', LICENSING_API_PASSWORD: 'password' };
|
||||
const vals: Record<string, string> = {};
|
||||
for (const line of readFileSync(file, 'utf8').split('\n')) { const m = /^([A-Z_]+)=(.*)$/.exec(line.trim()); if (m && map[m[1]!]) vals[map[m[1]!]!] = m[2]!; }
|
||||
const inst = await one('SELECT id, connector_key, secrets_enc FROM connector_instances WHERE name = ?', [name]);
|
||||
if (!inst) { console.error('Verbindung nicht gefunden'); process.exit(1); }
|
||||
const old = inst.secrets_enc ? JSON.parse(decrypt(inst.secrets_enc)) : {};
|
||||
await run('UPDATE connector_instances SET secrets_enc = ? WHERE id = ?', [encryptSecrets({ ...old, ...vals }), inst.id]);
|
||||
await enqueue('connector.sync', { instanceId: inst.id }, { idempotencyKey: `sync:${inst.id}:secrets:${Date.now()}` });
|
||||
await audit({ actorType: 'system', action: 'connector.update', resourceType: 'connector', resourceId: inst.id, connector: inst.connector_key, after: { secrets: Object.keys(vals), via: 'cli' } });
|
||||
console.log('Zugangsdaten gesetzt für', name, '- Felder:', Object.keys(vals).join(', '), '- Abgleich eingeplant');
|
||||
} else if (cmd === 'import-domains') {
|
||||
// Domain-Preisliste (Einkauf) aus einer Textdatei einlesen: import-domains --file=/pfad/liste.txt
|
||||
const { readFileSync } = await import('node:fs'); const { parsePriceList } = await import('../modules/domains/logic.js');
|
||||
const file = opt('file'); if (!file) { console.error('Nutzung: import-domains --file=<Datei>'); process.exit(2); }
|
||||
const { rows, skipped } = parsePriceList(readFileSync(file, 'utf8'));
|
||||
for (const r of rows) await run('INSERT INTO domain_tlds (tld, term_months, cost1_cents, cost2_cents, cost3_cents, cost4_cents, setup_cents) VALUES (?,?,?,?,?,?,?) ON DUPLICATE KEY UPDATE term_months=VALUES(term_months), cost1_cents=VALUES(cost1_cents), cost2_cents=VALUES(cost2_cents), cost3_cents=VALUES(cost3_cents), cost4_cents=VALUES(cost4_cents), setup_cents=VALUES(setup_cents)', [r.tld, r.termMonths, ...r.costs, r.setupCents]);
|
||||
console.log(`${rows.length} Endungen importiert, ${skipped.length} Zeilen übersprungen`, skipped);
|
||||
} else if (cmd === 'backup') {
|
||||
// backup run | restore-test [datei] | status
|
||||
const { loadBackupConfig, runBackup, runRestoreTest, readStatus } = await import('../ops/backup.js');
|
||||
const cfg = loadBackupConfig(); const sub = args[0];
|
||||
if (sub === 'run') { const r = await runBackup(cfg); console.log(JSON.stringify({ ok: r.ok, file: r.file, sizeBytes: r.sizeBytes, targets: r.targets, error: r.error }, null, 2)); process.exitCode = r.ok ? 0 : 1; }
|
||||
else if (sub === 'restore-test') { const r = await runRestoreTest(cfg, args[1]); console.log(JSON.stringify(r, null, 2)); process.exitCode = r.ok ? 0 : 1; }
|
||||
else if (sub === 'status') console.log(JSON.stringify(await readStatus(cfg), null, 2));
|
||||
else { console.error('Nutzung: backup run | restore-test [datei] | status'); process.exitCode = 2; }
|
||||
} else {
|
||||
console.error('Befehle: create-superadmin, connector-secrets, import-domains, backup');
|
||||
process.exit(2);
|
||||
}
|
||||
await pool.end();
|
||||
23
apps/api/src/cli/migrate.ts
Normal file
23
apps/api/src/cli/migrate.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { readdirSync, readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { join, dirname } from 'node:path';
|
||||
import '../core/config.js';
|
||||
import { pool } from '../core/db.js';
|
||||
|
||||
const dir = join(dirname(fileURLToPath(import.meta.url)), '../../../../migrations');
|
||||
export async function migrate(): Promise<void> {
|
||||
await pool.query('CREATE TABLE IF NOT EXISTS schema_migrations (name VARCHAR(200) PRIMARY KEY, applied_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3))');
|
||||
const done = new Set((await pool.query('SELECT name FROM schema_migrations') as any)[0].map((r: { name: string }) => r.name));
|
||||
for (const f of readdirSync(dir).filter((n) => n.endsWith('.sql')).sort()) {
|
||||
if (done.has(f)) continue;
|
||||
const c = await pool.getConnection();
|
||||
try {
|
||||
// DDL ist in MariaDB nicht transaktional; jede Migration einzeln, bei Fehler Abbruch.
|
||||
const stmts = readFileSync(join(dir, f), 'utf8').replace(/^\s*--.*$/gm, '').split(/;\s*\n/).map((s) => s.trim()).filter(Boolean);
|
||||
for (const st of stmts) await c.query(st);
|
||||
await c.query('INSERT INTO schema_migrations (name) VALUES (?)', [f]);
|
||||
console.log('migriert:', f);
|
||||
} finally { c.release(); }
|
||||
}
|
||||
}
|
||||
if (import.meta.url === `file://${process.argv[1]}`) { await migrate(); await pool.end(); }
|
||||
27
apps/api/src/core/accounts.ts
Normal file
27
apps/api/src/core/accounts.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { randomUUID } from 'node:crypto';
|
||||
import type { PoolConnection } from 'mysql2/promise';
|
||||
import { run } from './db.js';
|
||||
import { randomToken, sha256 } from './crypto.js';
|
||||
import { config } from './config.js';
|
||||
import { sendMail } from './mail.js';
|
||||
|
||||
export const INVITE_DAYS = 7;
|
||||
|
||||
/** Legt einen eingeladenen Benutzer an und liefert den Einladungslink (Token nur als Hash gespeichert). */
|
||||
export async function createInvitedUser(c: PoolConnection, u: { email: string; name: string; kind: 'customer' | 'staff'; staffRole?: string | null }): Promise<{ userId: string; token: string }> {
|
||||
const userId = randomUUID();
|
||||
await run('INSERT INTO users (id, email, name, kind, staff_role, status) VALUES (?,?,?,?,?,\'invited\')', [userId, u.email.toLowerCase(), u.name, u.kind, u.staffRole ?? null], c);
|
||||
return { userId, token: await createToken(c, userId, 'invite') };
|
||||
}
|
||||
export async function createToken(c: PoolConnection | undefined, userId: string, purpose: 'invite' | 'password_reset' | 'verify_email'): Promise<string> {
|
||||
const token = randomToken(32);
|
||||
const hours = purpose === 'invite' ? INVITE_DAYS * 24 : 1;
|
||||
await run('INSERT INTO user_tokens (id, user_id, purpose, token_hash, expires_at) VALUES (?,?,?,?, DATE_ADD(UTC_TIMESTAMP(3), INTERVAL ? HOUR))', [randomUUID(), userId, purpose, sha256(token), hours], c);
|
||||
return token;
|
||||
}
|
||||
export const inviteLink = (token: string): string => `${config.baseUrl}/einladung?token=${encodeURIComponent(token)}`;
|
||||
export const resetLink = (token: string): string => `${config.baseUrl}/passwort-reset?token=${encodeURIComponent(token)}`;
|
||||
|
||||
export async function mailInvite(email: string, name: string, token: string): Promise<string> {
|
||||
return sendMail(email, 'invite', 'Ihr Zugang zum Kundencenter', `Hallo ${name},\n\nfür Sie wurde ein Zugang eingerichtet. Bitte legen Sie hier Ihr Passwort fest (${INVITE_DAYS} Tage gültig):\n${inviteLink(token)}\n`);
|
||||
}
|
||||
4
apps/api/src/core/actions.ts
Normal file
4
apps/api/src/core/actions.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
/** Aktionen, die einem Kunden je Produkt/Ressource freigegeben werden können (Rolle ∧ Freigabe ∧ Anbieter-Fähigkeit müssen zusammenpassen). */
|
||||
export const CUSTOMER_ACTIONS = ['suspend', 'unsuspend', 'extend', 'panel.login', 'domain.manage', 'email.manage', 'database.manage', 'ftp.manage'] as const;
|
||||
export type CustomerAction = (typeof CUSTOMER_ACTIONS)[number];
|
||||
export const CHILD_MANAGE: Record<string, CustomerAction | undefined> = { domain: 'domain.manage', email: 'email.manage', database: 'database.manage', ftp: 'ftp.manage' };
|
||||
1
apps/api/src/core/audit.ts
Normal file
1
apps/api/src/core/audit.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from '@kc/platform/audit';
|
||||
92
apps/api/src/core/auth.ts
Normal file
92
apps/api/src/core/auth.ts
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { one, query, run } from './db.js';
|
||||
import { config } from './config.js';
|
||||
import { randomToken, sha256, safeEqual } from './crypto.js';
|
||||
import { forbidden, unauthorized } from './errors.js';
|
||||
import { can, type Principal, type OrgRole, type StaffRole } from './policy.js';
|
||||
|
||||
export const COOKIE = 'kc_session';
|
||||
const SESSION_HOURS = 12;
|
||||
const IDLE_MINUTES = 120;
|
||||
|
||||
export interface AuthContext {
|
||||
sessionId: string;
|
||||
csrf: string;
|
||||
pendingMfa: boolean;
|
||||
mfaEnrolled: boolean;
|
||||
user: { id: string; email: string; name: string };
|
||||
principal: Principal;
|
||||
}
|
||||
declare module 'fastify' {
|
||||
interface FastifyRequest { auth?: AuthContext; correlationId: string }
|
||||
}
|
||||
|
||||
export async function createSession(reply: FastifyReply, userId: string, opts: { pendingMfa: boolean; ip: string; ua: string }): Promise<{ id: string; csrf: string }> {
|
||||
const token = randomToken(32);
|
||||
const id = randomUUID();
|
||||
const csrf = randomToken(32);
|
||||
await run(
|
||||
`INSERT INTO sessions (id, user_id, token_hash, csrf_token, mfa_verified, pending_mfa, ip, user_agent, expires_at)
|
||||
VALUES (?,?,?,?,?,?,?,?, DATE_ADD(UTC_TIMESTAMP(3), INTERVAL ? HOUR))`,
|
||||
[id, userId, sha256(token), csrf, opts.pendingMfa ? 0 : 1, opts.pendingMfa ? 1 : 0, opts.ip, opts.ua.slice(0, 255), SESSION_HOURS],
|
||||
);
|
||||
reply.setCookie(COOKIE, token, { httpOnly: true, sameSite: 'lax', secure: config.isProd, path: '/', maxAge: SESSION_HOURS * 3600 });
|
||||
return { id, csrf };
|
||||
}
|
||||
|
||||
async function loadAuth(req: FastifyRequest): Promise<AuthContext | undefined> {
|
||||
const token = req.cookies[COOKIE];
|
||||
if (!token) return undefined;
|
||||
const s = await one(
|
||||
`SELECT s.id, s.csrf_token, s.pending_mfa, u.id AS uid, u.email, u.name, u.kind, u.staff_role, u.status,
|
||||
(SELECT COUNT(*) FROM mfa_totp m WHERE m.user_id = u.id AND m.confirmed_at IS NOT NULL) AS mfa
|
||||
FROM sessions s JOIN users u ON u.id = s.user_id
|
||||
WHERE s.token_hash = ? AND s.revoked_at IS NULL AND s.expires_at > UTC_TIMESTAMP(3)
|
||||
AND s.last_seen_at > DATE_SUB(UTC_TIMESTAMP(3), INTERVAL ? MINUTE)`,
|
||||
[sha256(token), IDLE_MINUTES],
|
||||
);
|
||||
if (!s || s.status !== 'active') return undefined;
|
||||
const ms = await query('SELECT org_id, role FROM memberships WHERE user_id = ?', [s.uid]);
|
||||
await run('UPDATE sessions SET last_seen_at = UTC_TIMESTAMP(3) WHERE id = ?', [s.id]);
|
||||
return {
|
||||
sessionId: s.id, csrf: s.csrf_token, pendingMfa: !!s.pending_mfa, mfaEnrolled: Number(s.mfa) > 0,
|
||||
user: { id: s.uid, email: s.email, name: s.name },
|
||||
principal: { userId: s.uid, kind: s.kind, staffRole: (s.staff_role as StaffRole | null) ?? null, memberships: ms.map((m) => ({ orgId: m.org_id as string, role: m.role as OrgRole })) },
|
||||
};
|
||||
}
|
||||
|
||||
/** Globale Hooks: Korrelations-ID, Sitzung laden, CSRF- und Origin-Prüfung für schreibende Requests. */
|
||||
export function registerAuth(app: FastifyInstance): void {
|
||||
app.decorateRequest('correlationId', '');
|
||||
app.decorateRequest('auth', undefined);
|
||||
app.addHook('onRequest', async (req, reply) => {
|
||||
const inbound = req.headers['x-correlation-id'];
|
||||
req.correlationId = typeof inbound === 'string' && /^[0-9a-f-]{36}$/i.test(inbound) ? inbound : randomUUID();
|
||||
reply.header('x-correlation-id', req.correlationId);
|
||||
req.auth = await loadAuth(req);
|
||||
if (!['GET', 'HEAD', 'OPTIONS'].includes(req.method)) {
|
||||
const origin = req.headers.origin;
|
||||
if (origin && !config.allowedOrigins.has(origin)) throw forbidden('Ungültiger Origin', 'BAD_ORIGIN');
|
||||
if (req.auth) {
|
||||
const sent = req.headers['x-csrf-token'];
|
||||
if (typeof sent !== 'string' || !safeEqual(sent, req.auth.csrf)) throw forbidden('CSRF-Token ungültig', 'BAD_CSRF');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Voraussetzung für geschützte Routen. Staff müssen 2FA eingerichtet haben (außer bei MFA-Einrichtung). */
|
||||
export function requireAuth(req: FastifyRequest, opts: { allowPendingMfa?: boolean; allowUnenrolledStaff?: boolean } = {}): AuthContext {
|
||||
const a = req.auth;
|
||||
if (!a) throw unauthorized();
|
||||
if (a.pendingMfa && !opts.allowPendingMfa) throw unauthorized('Zweiter Faktor erforderlich', 'MFA_REQUIRED');
|
||||
if (a.principal.kind === 'staff' && !a.mfaEnrolled && !opts.allowUnenrolledStaff) throw forbidden('Für Mitarbeiter ist 2FA verpflichtend', 'MFA_ENROLL_REQUIRED');
|
||||
return a;
|
||||
}
|
||||
export function requirePermission(req: FastifyRequest, permission: string): AuthContext {
|
||||
const a = requireAuth(req);
|
||||
if (!can(a.principal, permission)) throw forbidden();
|
||||
return a;
|
||||
}
|
||||
export const clientIp = (req: FastifyRequest): string => req.ip;
|
||||
5
apps/api/src/core/config.ts
Normal file
5
apps/api/src/core/config.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export * from '@kc/platform/config';
|
||||
import { config } from '@kc/platform/config';
|
||||
|
||||
/** Rate-Limits werden im Testmodus praktisch abgeschaltet, damit Tests sich nicht selbst aussperren. */
|
||||
export const rl = (max: number, timeWindow: string) => ({ rateLimit: { max: config.env === 'test' ? 100000 : max, timeWindow } });
|
||||
1
apps/api/src/core/crypto.ts
Normal file
1
apps/api/src/core/crypto.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from '@kc/platform/crypto';
|
||||
1
apps/api/src/core/db.ts
Normal file
1
apps/api/src/core/db.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from '@kc/platform/db';
|
||||
8
apps/api/src/core/errors.ts
Normal file
8
apps/api/src/core/errors.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
export class AppError extends Error {
|
||||
constructor(public status: number, public code: string, message: string) { super(message); }
|
||||
}
|
||||
export const badRequest = (m: string, code = 'BAD_REQUEST') => new AppError(400, code, m);
|
||||
export const unauthorized = (m = 'Nicht angemeldet', code = 'UNAUTHENTICATED') => new AppError(401, code, m);
|
||||
export const forbidden = (m = 'Keine Berechtigung', code = 'FORBIDDEN') => new AppError(403, code, m);
|
||||
export const notFound = (m = 'Nicht gefunden') => new AppError(404, 'NOT_FOUND', m);
|
||||
export const conflict = (m: string, code = 'CONFLICT') => new AppError(409, code, m);
|
||||
1
apps/api/src/core/jobs.ts
Normal file
1
apps/api/src/core/jobs.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from '@kc/platform/jobs';
|
||||
23
apps/api/src/core/mail.ts
Normal file
23
apps/api/src/core/mail.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import nodemailer from 'nodemailer';
|
||||
import { config } from './config.js';
|
||||
import { run } from './db.js';
|
||||
|
||||
const transport = config.smtp
|
||||
? nodemailer.createTransport({ host: config.smtp.host, port: config.smtp.port, secure: config.smtp.port === 465, auth: config.smtp.user ? { user: config.smtp.user, pass: config.smtp.pass } : undefined })
|
||||
: null;
|
||||
|
||||
/** Versand mit Protokoll. Ohne SMTP wird nur "not_configured" protokolliert (Inhalt bewusst nicht gespeichert). */
|
||||
export async function sendMail(to: string, template: string, subject: string, text: string): Promise<'sent' | 'failed' | 'not_configured'> {
|
||||
if (!transport || !config.smtp) {
|
||||
await run('INSERT INTO mail_log (to_email, template, status) VALUES (?,?,?)', [to, template, 'not_configured']);
|
||||
return 'not_configured';
|
||||
}
|
||||
try {
|
||||
await transport.sendMail({ from: config.smtp.from, to, subject, text });
|
||||
await run('INSERT INTO mail_log (to_email, template, status) VALUES (?,?,?)', [to, template, 'sent']);
|
||||
return 'sent';
|
||||
} catch (e) {
|
||||
await run('INSERT INTO mail_log (to_email, template, status, error) VALUES (?,?,?,?)', [to, template, 'failed', String((e as Error).message).slice(0, 300)]);
|
||||
return 'failed';
|
||||
}
|
||||
}
|
||||
10
apps/api/src/core/module.ts
Normal file
10
apps/api/src/core/module.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import type { FastifyInstance } from 'fastify';
|
||||
import type { OrgRole, StaffRole } from './policy.js';
|
||||
|
||||
/** Vertrag für Anwendungsmodule. Module kommunizieren nur über core/* und Domain-Events (Jobs), nie über fremde Module. */
|
||||
export interface KcModule {
|
||||
name: string;
|
||||
/** Zusätzliche Rechte, die das Modul in die Policy-Schicht einträgt. */
|
||||
permissions?: { staff?: Partial<Record<StaffRole, string[]>>; org?: Partial<Record<OrgRole, string[]>> };
|
||||
register(app: FastifyInstance): void | Promise<void>;
|
||||
}
|
||||
42
apps/api/src/core/policy.ts
Normal file
42
apps/api/src/core/policy.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
/** Zentrale Policy-Schicht: RBAC (Staff-Rollen, Org-Rollen) + objektbezogene Prüfung. */
|
||||
export type StaffRole = 'support' | 'accounting' | 'admin' | 'superadmin';
|
||||
export type OrgRole = 'owner' | 'admin' | 'member';
|
||||
|
||||
const STAFF: Record<StaffRole, string[]> = {
|
||||
support: ['customers.read', 'users.read'],
|
||||
accounting: ['customers.read'],
|
||||
admin: ['customers.read', 'customers.write', 'users.read', 'users.write', 'audit.read', 'jobs.read'],
|
||||
superadmin: ['customers.read', 'customers.write', 'users.read', 'users.write', 'users.write_privileged', 'audit.read', 'jobs.read', 'settings.write'],
|
||||
};
|
||||
const ORG: Record<OrgRole, string[]> = {
|
||||
owner: ['org.read', 'org.members.read', 'org.members.invite'],
|
||||
admin: ['org.read', 'org.members.read', 'org.members.invite'],
|
||||
member: ['org.read'],
|
||||
};
|
||||
/** Module dürfen weitere Rechte registrieren (siehe KcModule.permissions). */
|
||||
const extra: { staff: Partial<Record<StaffRole, string[]>>; org: Partial<Record<OrgRole, string[]>> } = { staff: {}, org: {} };
|
||||
export function registerPermissions(p: { staff?: Partial<Record<StaffRole, string[]>>; org?: Partial<Record<OrgRole, string[]>> }): void {
|
||||
for (const [r, l] of Object.entries(p.staff ?? {})) extra.staff[r as StaffRole] = [...(extra.staff[r as StaffRole] ?? []), ...l];
|
||||
for (const [r, l] of Object.entries(p.org ?? {})) extra.org[r as OrgRole] = [...(extra.org[r as OrgRole] ?? []), ...l];
|
||||
}
|
||||
|
||||
export interface Principal {
|
||||
userId: string;
|
||||
kind: 'customer' | 'staff';
|
||||
staffRole: StaffRole | null;
|
||||
memberships: { orgId: string; role: OrgRole }[];
|
||||
}
|
||||
|
||||
export function staffPermissions(role: StaffRole | null): string[] {
|
||||
return role ? [...STAFF[role], ...(extra.staff[role] ?? [])] : [];
|
||||
}
|
||||
/** Globales Recht (nur Staff). */
|
||||
export function can(p: Principal, permission: string): boolean {
|
||||
return p.kind === 'staff' && staffPermissions(p.staffRole).includes(permission);
|
||||
}
|
||||
/** Objektbezogen: Staff mit globalem Recht ODER Mitglied der Organisation mit passender Org-Rolle. */
|
||||
export function canInOrg(p: Principal, orgId: string, orgPermission: string, staffPermission: string): boolean {
|
||||
if (can(p, staffPermission)) return true;
|
||||
const m = p.memberships.find((x) => x.orgId === orgId);
|
||||
return !!m && [...ORG[m.role], ...(extra.org[m.role] ?? [])].includes(orgPermission);
|
||||
}
|
||||
27
apps/api/src/modules/audit/index.ts
Normal file
27
apps/api/src/modules/audit/index.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { query } from '../../core/db.js';
|
||||
import { verifyAuditChain } from '../../core/audit.js';
|
||||
import { requirePermission } from '../../core/auth.js';
|
||||
import type { KcModule } from '../../core/module.js';
|
||||
|
||||
export const auditModule: KcModule = {
|
||||
name: 'audit',
|
||||
register(app: FastifyInstance) {
|
||||
app.get('/admin/audit', async (req) => {
|
||||
requirePermission(req, 'audit.read');
|
||||
const q = z.object({ action: z.string().max(100).optional(), actor: z.string().uuid().optional(), org: z.string().uuid().optional(), limit: z.coerce.number().int().min(1).max(500).default(100) }).parse(req.query);
|
||||
const rows = await query(
|
||||
`SELECT id, ts, actor_type, actor_id, org_id, action, resource_type, resource_id, result, error_class, correlation_id, ip, before_json, after_json
|
||||
FROM audit_events WHERE (? IS NULL OR action LIKE CONCAT(?, '%')) AND (? IS NULL OR actor_id = ?) AND (? IS NULL OR org_id = ?)
|
||||
ORDER BY id DESC LIMIT ?`,
|
||||
[q.action ?? null, q.action ?? null, q.actor ?? null, q.actor ?? null, q.org ?? null, q.org ?? null, q.limit],
|
||||
);
|
||||
return rows.map((r) => ({ id: r.id, ts: r.ts, actorType: r.actor_type, actorId: r.actor_id, orgId: r.org_id, action: r.action, resourceType: r.resource_type, resourceId: r.resource_id, result: r.result, errorClass: r.error_class, correlationId: r.correlation_id, ip: r.ip, before: r.before_json, after: r.after_json }));
|
||||
});
|
||||
app.get('/admin/audit/verify', async (req) => {
|
||||
requirePermission(req, 'audit.read');
|
||||
return verifyAuditChain();
|
||||
});
|
||||
},
|
||||
};
|
||||
176
apps/api/src/modules/backup/index.ts
Normal file
176
apps/api/src/modules/backup/index.ts
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { verify } from '@node-rs/argon2';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { isAbsolute, normalize } from 'node:path';
|
||||
import { audit } from '../../core/audit.js';
|
||||
import { one, query, run } from '../../core/db.js';
|
||||
import { encrypt } from '../../core/crypto.js';
|
||||
import { passwordMeta, passwordProblem, setBackupPassword, MIN_PASSWORD } from '../../ops/settings.js';
|
||||
import { loadTargets, testTarget } from '../../ops/targets.js';
|
||||
import { clientIp, requirePermission } from '../../core/auth.js';
|
||||
import { badRequest, conflict, forbidden, notFound } from '../../core/errors.js';
|
||||
import type { KcModule } from '../../core/module.js';
|
||||
|
||||
const statusFile = () => process.env.BACKUP_STATUS_FILE ?? '/var/lib/kundencenter/backup-status.json';
|
||||
const requestDir = () => process.env.BACKUP_REQUEST_DIR ?? '/var/lib/kundencenter/requests';
|
||||
const FILES = { backup: 'backup-run', 'restore-test': 'backup-restore-test' } as const;
|
||||
const hours = (iso?: string | null) => (iso ? (Date.now() - new Date(iso).getTime()) / 3600000 : null);
|
||||
|
||||
async function readState() {
|
||||
let st: any = null;
|
||||
try { st = JSON.parse(await readFile(statusFile(), 'utf8')); } catch { /* nicht eingerichtet oder noch nie gelaufen */ }
|
||||
const envRemotes = (process.env.BACKUP_REMOTES ?? '').split(',').map((s) => s.trim()).filter(Boolean);
|
||||
const dbTargets = await query('SELECT name, type, enabled, last_test_ok FROM backup_targets ORDER BY name').catch(() => []);
|
||||
const remotes = [...dbTargets.filter((t) => t.enabled).map((t) => t.name as string), ...envRemotes];
|
||||
const run = st?.lastRun ?? null; const test = st?.lastRestoreTest ?? null;
|
||||
const pending = ['backup', 'restore-test'].filter((a) => existsSync(join(requestDir(), FILES[a as keyof typeof FILES])));
|
||||
return {
|
||||
configured: !!process.env.BACKUP_AGE_RECIPIENT || !!st || (await passwordMeta().catch(() => ({ set: false }))).set,
|
||||
running: st?.running ?? null, pendingRequests: pending,
|
||||
lastRun: run, lastRestoreTest: test, history: st?.history ?? [],
|
||||
stale: !run || !run.ok || (hours(run.at) ?? 999) > 26, restoreStale: !test || !test.ok || (hours(test.at) ?? 999) > 24 * 10,
|
||||
schedule: { backup: 'täglich gegen 02:30 Uhr', restoreTest: 'sonntags gegen 04:30 Uhr' },
|
||||
retention: { daily: Number(process.env.BACKUP_KEEP_DAILY ?? 14), weekly: Number(process.env.BACKUP_KEEP_WEEKLY ?? 8), monthly: Number(process.env.BACKUP_KEEP_MONTHLY ?? 12) },
|
||||
localDir: process.env.BACKUP_DIR ?? '/var/backups/kundencenter', remotes, hasExternalTarget: remotes.length > 0, encryption: (await passwordMeta().catch(() => ({ set: false }))).set ? 'password' : process.env.BACKUP_AGE_RECIPIENT ? 'age' : 'none',
|
||||
};
|
||||
}
|
||||
|
||||
export const backupModule: KcModule = {
|
||||
name: 'backup',
|
||||
permissions: { staff: { admin: ['backup.read', 'backup.run'], superadmin: ['backup.read', 'backup.run', 'backup.secrets'] } },
|
||||
register(app: FastifyInstance) {
|
||||
app.get('/admin/backup', async (req) => { requirePermission(req, 'backup.read'); return readState(); });
|
||||
|
||||
// ---- Passwort und Ziele (nur Superadministratoren dürfen ändern) ------------------
|
||||
const targetView = (r: any) => {
|
||||
const cfg = typeof r.config_json === 'string' ? JSON.parse(r.config_json) : r.config_json;
|
||||
return { id: r.id, name: r.name, type: r.type, path: r.path, enabled: !!r.enabled, config: cfg, hasSecret: !!r.secrets_enc, hostKeyPinned: !!r.host_key, hostKeyFingerprint: cfg.hostKeyFingerprint ?? null,
|
||||
lastTestAt: r.last_test_at, lastTestOk: r.last_test_ok === null ? null : !!r.last_test_ok, lastTestError: r.last_test_error };
|
||||
};
|
||||
app.get('/admin/backup/settings', async (req) => {
|
||||
requirePermission(req, 'backup.read');
|
||||
const pw = await passwordMeta();
|
||||
const targets = (await query('SELECT * FROM backup_targets ORDER BY name')).map(targetView);
|
||||
return { encryption: { mode: pw.set ? 'password' : process.env.BACKUP_AGE_RECIPIENT ? 'age' : 'none', passwordSet: pw.set, version: pw.version, updatedAt: pw.updatedAt, minLength: MIN_PASSWORD }, targets };
|
||||
});
|
||||
|
||||
/** Backup-Passwort setzen/ändern: mindestens 11 Zeichen, Wiederholung, Bestätigung mit dem eigenen Anmeldepasswort. Der Wert wird nie angezeigt oder protokolliert. */
|
||||
app.put('/admin/backup/password', async (req) => {
|
||||
const a = requirePermission(req, 'backup.secrets');
|
||||
const b = z.object({ password: z.string().max(200), repeat: z.string().max(200), currentPassword: z.string().max(200) }).parse(req.body);
|
||||
const u = await one('SELECT password_hash FROM users WHERE id = ?', [a.user.id]);
|
||||
if (!u?.password_hash || !(await verify(u.password_hash, b.currentPassword).catch(() => false))) {
|
||||
await audit({ actorType: 'user', actorId: a.user.id, action: 'backup.password.change', resourceType: 'backup', result: 'denied', errorClass: 'reauth_failed', correlationId: req.correlationId, ip: clientIp(req) });
|
||||
throw forbidden('Ihr Anmeldepasswort ist falsch.', 'INVALID_CREDENTIALS');
|
||||
}
|
||||
if (b.password !== b.repeat) throw badRequest('Die Passwörter stimmen nicht überein.', 'PASSWORD_MISMATCH');
|
||||
const problem = passwordProblem(b.password); if (problem) throw badRequest(problem, 'WEAK_PASSWORD');
|
||||
const version = await setBackupPassword(b.password, a.user.id);
|
||||
await audit({ actorType: 'user', actorId: a.user.id, action: 'backup.password.change', resourceType: 'backup', correlationId: req.correlationId, ip: clientIp(req), after: { version } });
|
||||
return { version };
|
||||
});
|
||||
|
||||
const HOST = /^(?=.{1,253}$)([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$|^[0-9a-fA-F:]+$/;
|
||||
const safeHost = (h: string) => HOST.test(h) && !/^(localhost|0\.0\.0\.0|127\.|169\.254\.|::1?$|metadata)/i.test(h);
|
||||
const safeRel = (p: string) => !p.split('/').includes('..') && !/[\0\r\n]/.test(p);
|
||||
const FORBIDDEN = ['/etc', '/proc', '/sys', '/dev', '/boot', '/usr', '/bin', '/sbin', '/lib', '/root', '/var/lib/kundencenter', '/srv/kundencenter'];
|
||||
const str = (n: number) => z.string().trim().min(1).max(n);
|
||||
const common = { name: str(100), path: z.string().trim().max(400).default('') };
|
||||
const targetSchema = z.discriminatedUnion('type', [
|
||||
z.object({ type: z.literal('sftp'), ...common, host: str(253), port: z.number().int().min(1).max(65535).default(22), user: str(100), authType: z.enum(['password', 'key']), password: z.string().max(500).optional(), privateKey: z.string().max(10000).optional(), keyPassphrase: z.string().max(500).optional() }),
|
||||
z.object({ type: z.literal('ftp'), ...common, host: str(253), port: z.number().int().min(1).max(65535).default(21), user: str(100), tls: z.enum(['none', 'explicit', 'implicit']).default('explicit'), password: z.string().min(1).max(500) }),
|
||||
z.object({ type: z.literal('gdrive'), ...common, scope: z.enum(['drive.file', 'drive']).default('drive.file'), token: z.string().min(10).max(8000), clientId: z.string().max(300).optional(), clientSecret: z.string().max(300).optional() }),
|
||||
z.object({ type: z.literal('local'), ...common }),
|
||||
]);
|
||||
type NewTarget = z.infer<typeof targetSchema>;
|
||||
function split(t: NewTarget) {
|
||||
const cfg: Record<string, unknown> = {}; const sec: Record<string, string> = {};
|
||||
if (t.type === 'sftp') {
|
||||
if (!safeHost(t.host)) throw badRequest('Ungültiger oder nicht erlaubter Servername.', 'BAD_HOST');
|
||||
cfg.host = t.host; cfg.port = t.port; cfg.user = t.user; cfg.authType = t.authType;
|
||||
if (t.authType === 'password') { if (!t.password) throw badRequest('Bitte ein Passwort angeben.', 'SECRET_MISSING'); sec.password = t.password; }
|
||||
else { if (!t.privateKey || !/PRIVATE KEY/.test(t.privateKey)) throw badRequest('Bitte den privaten Schlüssel (PEM) einfügen.', 'SECRET_MISSING'); sec.privateKey = t.privateKey; if (t.keyPassphrase) sec.keyPassphrase = t.keyPassphrase; }
|
||||
} else if (t.type === 'ftp') {
|
||||
if (!safeHost(t.host)) throw badRequest('Ungültiger oder nicht erlaubter Servername.', 'BAD_HOST');
|
||||
cfg.host = t.host; cfg.port = t.port; cfg.user = t.user; cfg.tls = t.tls; sec.password = t.password;
|
||||
} else if (t.type === 'gdrive') {
|
||||
try { const j = JSON.parse(t.token); if (!j.access_token && !j.refresh_token) throw new Error(); } catch { throw badRequest('Der Token muss der JSON-Text aus "rclone authorize" sein.', 'BAD_TOKEN'); }
|
||||
cfg.scope = t.scope; sec.token = t.token; if (t.clientId) sec.clientId = t.clientId; if (t.clientSecret) sec.clientSecret = t.clientSecret;
|
||||
}
|
||||
let path = t.path.replace(/\\/g, '/');
|
||||
if (t.type === 'local') {
|
||||
path = normalize(path);
|
||||
if (!isAbsolute(path) || path === '/' || FORBIDDEN.some((f) => path === f || path.startsWith(f + '/'))) throw badRequest('Bitte einen absoluten Ordner angeben (z. B. ein eingehängtes Netzlaufwerk unter /mnt/...). Systemordner sind nicht erlaubt.', 'BAD_PATH');
|
||||
} else if (!safeRel(path)) throw badRequest('Ungültiger Ordnerpfad.', 'BAD_PATH');
|
||||
return { cfg, sec, path };
|
||||
}
|
||||
|
||||
app.post('/admin/backup/targets', async (req) => {
|
||||
const a = requirePermission(req, 'backup.secrets');
|
||||
const b = targetSchema.parse(req.body);
|
||||
if (await one('SELECT 1 AS x FROM backup_targets WHERE name = ?', [b.name])) throw conflict('Der Name ist bereits vergeben.', 'NAME_EXISTS');
|
||||
const { cfg, sec, path } = split(b); const id = randomUUID();
|
||||
await run('INSERT INTO backup_targets (id, name, type, path, config_json, secrets_enc) VALUES (?,?,?,?,?,?)', [id, b.name, b.type, path, JSON.stringify(cfg), Object.keys(sec).length ? encrypt(JSON.stringify(sec)) : null]);
|
||||
await audit({ actorType: 'user', actorId: a.user.id, action: 'backup.target.create', resourceType: 'backup_target', resourceId: id, correlationId: req.correlationId, ip: clientIp(req), after: { name: b.name, type: b.type, path, config: cfg, secrets: Object.keys(sec) } });
|
||||
return { id };
|
||||
});
|
||||
app.patch('/admin/backup/targets/:id', async (req) => {
|
||||
const a = requirePermission(req, 'backup.secrets');
|
||||
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
|
||||
const b = z.object({ enabled: z.boolean().optional(), name: str(100).optional() }).parse(req.body);
|
||||
const r = await one('SELECT * FROM backup_targets WHERE id = ?', [id]); if (!r) throw notFound();
|
||||
if (b.name && b.name !== r.name && (await one('SELECT 1 AS x FROM backup_targets WHERE name = ?', [b.name]))) throw conflict('Der Name ist bereits vergeben.', 'NAME_EXISTS');
|
||||
await run('UPDATE backup_targets SET enabled = COALESCE(?, enabled), name = COALESCE(?, name) WHERE id = ?', [b.enabled === undefined ? null : b.enabled ? 1 : 0, b.name ?? null, id]);
|
||||
await audit({ actorType: 'user', actorId: a.user.id, action: 'backup.target.update', resourceType: 'backup_target', resourceId: id, correlationId: req.correlationId, ip: clientIp(req), before: { enabled: !!r.enabled, name: r.name }, after: b });
|
||||
return { status: 'ok' };
|
||||
});
|
||||
app.delete('/admin/backup/targets/:id', async (req) => {
|
||||
const a = requirePermission(req, 'backup.secrets');
|
||||
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
|
||||
const r = await one('SELECT name, type FROM backup_targets WHERE id = ?', [id]); if (!r) throw notFound();
|
||||
await run('DELETE FROM backup_targets WHERE id = ?', [id]); // bereits abgelegte Sicherungen am Ziel bleiben unberührt
|
||||
await audit({ actorType: 'user', actorId: a.user.id, action: 'backup.target.delete', resourceType: 'backup_target', resourceId: id, correlationId: req.correlationId, ip: clientIp(req), before: { name: r.name, type: r.type } });
|
||||
return { status: 'ok' };
|
||||
});
|
||||
/** Verbindungstest (legt Ordner an, schreibt und löscht eine Testdatei). Bei SFTP wird der Server-Schlüssel beim ersten Erfolg gemerkt. */
|
||||
app.post('/admin/backup/targets/:id/test', { config: { rateLimit: { max: 20, timeWindow: '1 minute' } } }, async (req) => {
|
||||
const a = requirePermission(req, 'backup.secrets');
|
||||
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
|
||||
const t = (await loadTargets(false)).find((x) => x.id === id); if (!t) throw notFound();
|
||||
const res = await testTarget(t);
|
||||
if (res.ok && res.hostKey) { const cfg = { ...t.config, hostKeyFingerprint: res.fingerprint }; await run('UPDATE backup_targets SET host_key = ?, config_json = ? WHERE id = ?', [res.hostKey, JSON.stringify(cfg), id]); }
|
||||
await run('UPDATE backup_targets SET last_test_at = UTC_TIMESTAMP(3), last_test_ok = ?, last_test_error = ? WHERE id = ?', [res.ok ? 1 : 0, res.ok ? null : (res.error ?? '').slice(0, 400), id]);
|
||||
await audit({ actorType: 'user', actorId: a.user.id, action: 'backup.target.test', resourceType: 'backup_target', resourceId: id, result: res.ok ? 'success' : 'failure', errorClass: res.ok ? undefined : 'test_failed', correlationId: req.correlationId, ip: clientIp(req), after: { fingerprint: res.fingerprint } });
|
||||
return { ok: res.ok, error: res.error ?? null, fingerprint: res.fingerprint ?? null, newHostKey: !!res.hostKey };
|
||||
});
|
||||
app.post('/admin/backup/targets/:id/reset-hostkey', async (req) => {
|
||||
const a = requirePermission(req, 'backup.secrets');
|
||||
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
|
||||
const r = await one('SELECT config_json FROM backup_targets WHERE id = ?', [id]); if (!r) throw notFound();
|
||||
const cfg = typeof r.config_json === 'string' ? JSON.parse(r.config_json) : r.config_json; delete cfg.hostKeyFingerprint;
|
||||
await run('UPDATE backup_targets SET host_key = NULL, config_json = ?, last_test_ok = NULL WHERE id = ?', [JSON.stringify(cfg), id]);
|
||||
await audit({ actorType: 'user', actorId: a.user.id, action: 'backup.target.hostkey_reset', resourceType: 'backup_target', resourceId: id, correlationId: req.correlationId, ip: clientIp(req) });
|
||||
return { status: 'ok' };
|
||||
});
|
||||
|
||||
/**
|
||||
* Sicherung oder Wiederherstellungstest anfordern. Die Oberfläche startet nichts selbst: Sie legt nur eine Anfragedatei ab,
|
||||
* die ein systemd-Pfadauslöser als root abarbeitet (feste Aktionen, keine Eingaben).
|
||||
*/
|
||||
app.post('/admin/backup/run', async (req, reply) => {
|
||||
const a = requirePermission(req, 'backup.run');
|
||||
const { action } = z.object({ action: z.enum(['backup', 'restore-test']) }).parse(req.body);
|
||||
const st = await readState();
|
||||
if (st.running) throw conflict('Es läuft bereits ein Vorgang. Bitte warten.', 'BACKUP_RUNNING');
|
||||
if (st.pendingRequests.length) throw conflict('Es liegt bereits eine Anfrage vor. Bitte einen Moment warten.', 'BACKUP_PENDING');
|
||||
await mkdir(requestDir(), { recursive: true });
|
||||
await writeFile(join(requestDir(), FILES[action]), `${new Date().toISOString()}\n`, { mode: 0o644 });
|
||||
await audit({ actorType: 'user', actorId: a.user.id, action: `backup.request.${action}`, resourceType: 'backup', correlationId: req.correlationId, ip: clientIp(req) });
|
||||
return reply.code(202).send({ status: 'requested' });
|
||||
});
|
||||
},
|
||||
};
|
||||
273
apps/api/src/modules/catalog/index.ts
Normal file
273
apps/api/src/modules/catalog/index.ts
Normal file
|
|
@ -0,0 +1,273 @@
|
|||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { readdirSync, readFileSync, existsSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { join } from 'node:path';
|
||||
import { calculatePrice } from '@kc/platform/pricing';
|
||||
import { getConnector, loadInstance } from '@kc/connectors';
|
||||
import { ConnectorError } from '@kc/connector-sdk';
|
||||
import { one, query, run, tx } from '../../core/db.js';
|
||||
import { audit } from '../../core/audit.js';
|
||||
import { clientIp, requireAuth, requirePermission } from '../../core/auth.js';
|
||||
import { AppError, badRequest, conflict, notFound } from '../../core/errors.js';
|
||||
import { canInOrg } from '../../core/policy.js';
|
||||
import type { KcModule } from '../../core/module.js';
|
||||
|
||||
const int = (max: number) => z.number().int().min(0).max(max);
|
||||
const versionSchema = z.object({
|
||||
name: z.string().trim().min(1).max(200), description: z.string().trim().max(2000).optional(), taxRateId: z.string().uuid(), priceBasis: z.enum(['net', 'gross']).default('net'),
|
||||
setupCents: int(100_000_00).default(0), recurringCents: int(100_000_00).default(0), billingInterval: z.enum(['once', 'monthly', 'yearly']),
|
||||
termMonths: int(120).default(0), renewal: z.enum(['auto', 'none']).default('none'), renewalTermMonths: int(120).optional(), noticeDays: int(365).default(30),
|
||||
provisioning: z.record(z.string(), z.unknown()).default({}),
|
||||
});
|
||||
type VersionIn = z.infer<typeof versionSchema>;
|
||||
import { CUSTOMER_ACTIONS } from '../../core/actions.js';
|
||||
const ACTIONS = z.array(z.enum(CUSTOMER_ACTIONS));
|
||||
|
||||
/** Fachregeln für Laufzeiten/Preise je Abrechnungsintervall. */
|
||||
function checkTerms(v: VersionIn): number {
|
||||
if (v.billingInterval === 'once') {
|
||||
if (v.recurringCents > 0) throw badRequest('Einmalprodukte haben keinen wiederkehrenden Preis', 'BAD_TERMS');
|
||||
if (v.termMonths > 0 || v.renewal === 'auto') throw badRequest('Einmalprodukte haben keine Laufzeit und keine Verlängerung', 'BAD_TERMS');
|
||||
return 0;
|
||||
}
|
||||
const per = v.billingInterval === 'monthly' ? 1 : 12;
|
||||
const renewalMonths = v.renewal === 'auto' ? (v.renewalTermMonths ?? per) : 0;
|
||||
if (v.renewal === 'auto' && renewalMonths < 1) throw badRequest('Bei automatischer Verlängerung ist eine Verlängerungslaufzeit nötig', 'BAD_TERMS');
|
||||
if (v.termMonths > 0 && v.termMonths % per !== 0) throw badRequest(`Die Laufzeit muss ein Vielfaches des Abrechnungsintervalls (${per} Monat${per > 1 ? 'e' : ''}) sein`, 'BAD_TERMS');
|
||||
return renewalMonths;
|
||||
}
|
||||
async function checkConnector(instanceId: string | null | undefined, provisioning: Record<string, unknown>, forActivation = false): Promise<void> {
|
||||
if (!instanceId) { if (Object.keys(provisioning).length) throw badRequest('Provisionierungsparameter ohne Verbindung', 'BAD_PROVISIONING'); return; }
|
||||
const inst = await one('SELECT connector_key, capabilities_json FROM connector_instances WHERE id = ?', [instanceId]);
|
||||
if (!inst) throw badRequest('Verbindung nicht gefunden', 'BAD_CONNECTOR');
|
||||
const c = getConnector(inst.connector_key);
|
||||
const msg = c.validateProvisioning?.(provisioning) ?? null;
|
||||
if (msg) throw badRequest(`Provisionierung ungültig: ${msg}`, 'BAD_PROVISIONING');
|
||||
const caps: string[] = inst.capabilities_json ? (typeof inst.capabilities_json === 'string' ? JSON.parse(inst.capabilities_json) : inst.capabilities_json) : [];
|
||||
if (caps.length && !caps.includes('lifecycle.create')) throw badRequest('Diese Verbindung kann aktuell keine Objekte anlegen (Zugangsdaten mit Schreibrechten prüfen)', 'NO_CREATE_CAPABILITY');
|
||||
// Edition (Schlüssel-Präfix) und festes Ablaufdatum: ein aktives Produkt darf sie nur versprechen, wenn der Anbieter sie nachweislich umsetzt.
|
||||
if (forActivation) {
|
||||
if (provisioning.keyPrefix && !caps.includes('license.key_prefix')) throw badRequest('Produkte mit Edition (Schlüssel-Präfix) können erst aktiviert werden, wenn das Lizenzsystem diese Erweiterung unterstützt. Als Entwurf ist es gespeichert.', 'NEEDS_LICENSE_EXTENSION');
|
||||
if (provisioning.validityDays && !caps.includes('license.expiry')) throw badRequest('Produkte mit festem Ablaufdatum können erst aktiviert werden, wenn das Lizenzsystem diese Erweiterung unterstützt.', 'NEEDS_LICENSE_EXTENSION');
|
||||
}
|
||||
}
|
||||
async function insertVersion(c: Parameters<typeof run>[2], productId: string, v: VersionIn, by: string): Promise<string> {
|
||||
const tax = await one('SELECT id, rate_bp, active FROM tax_rates WHERE id = ?', [v.taxRateId], c);
|
||||
if (!tax || !tax.active) throw badRequest('Steuersatz nicht gefunden', 'BAD_TAX');
|
||||
const renewalMonths = checkTerms(v);
|
||||
const last = await one('SELECT COALESCE(MAX(version), 0) AS n FROM product_versions WHERE product_id = ?', [productId], c);
|
||||
const id = randomUUID();
|
||||
await run(
|
||||
`INSERT INTO product_versions (id, product_id, version, name, description, tax_rate_id, tax_bp, price_basis, setup_cents, recurring_cents, billing_interval, term_months, renewal, renewal_term_months, notice_days, provisioning_json, created_by)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
[id, productId, Number(last!.n) + 1, v.name, v.description ?? null, v.taxRateId, tax.rate_bp, v.priceBasis, v.setupCents, v.recurringCents, v.billingInterval, v.termMonths, v.renewal, renewalMonths, v.noticeDays, JSON.stringify(v.provisioning), by], c);
|
||||
await run('UPDATE products SET current_version_id = ? WHERE id = ?', [id, productId], c);
|
||||
return id;
|
||||
}
|
||||
interface NewProduct { sku: string; category: string; connectorInstanceId?: string | null; externalRef?: string; orderableByCustomer: boolean; requiresApproval: boolean; customerActions: string[]; status: 'draft' | 'active'; version: VersionIn }
|
||||
/** Legt ein Produkt mit erster Version an (Regeln, Verbindungsprüfung, Eindeutigkeit der Artikelnummer). */
|
||||
async function createProduct(b: NewProduct, actorId: string): Promise<string> {
|
||||
if (await one('SELECT 1 AS x FROM products WHERE sku = ?', [b.sku])) throw conflict('Artikelnummer bereits vergeben', 'SKU_EXISTS');
|
||||
await checkConnector(b.connectorInstanceId, b.version.provisioning, b.status === 'active');
|
||||
const id = randomUUID();
|
||||
await tx(async (c) => {
|
||||
await run('INSERT INTO products (id, sku, category, status, connector_instance_id, external_ref, orderable_by_customer, requires_approval, customer_actions) VALUES (?,?,?,?,?,?,?,?,?)',
|
||||
[id, b.sku, b.category, b.status, b.connectorInstanceId ?? null, b.externalRef ?? null, b.orderableByCustomer ? 1 : 0, b.requiresApproval ? 1 : 0, JSON.stringify(b.customerActions)], c);
|
||||
await insertVersion(c, id, b.version, actorId);
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
||||
// ---- Produktpakete (Vorlagen, z. B. Editionen einer Software) --------------------
|
||||
const bundleSchema = z.object({
|
||||
key: z.string().regex(/^[a-z0-9-]{2,40}$/), name: z.string().max(200), description: z.string().max(2000).optional(), source: z.string().max(200).optional(),
|
||||
products: z.array(z.object({
|
||||
sku: z.string().regex(/^[A-Za-z0-9._-]{2,50}$/), name: z.string().max(200), description: z.string().max(2000).optional(), category: z.enum(['hosting', 'license', 'addon', 'service']),
|
||||
priceBasis: z.enum(['net', 'gross']), setupCents: int(100_000_00), recurringCents: int(100_000_00), taxBp: int(10000), interval: z.enum(['once', 'monthly', 'yearly']),
|
||||
termMonths: int(120), renewal: z.enum(['auto', 'none']), renewalTermMonths: int(120), noticeDays: int(365), provisioning: z.record(z.string(), z.unknown()),
|
||||
orderableByCustomer: z.boolean().default(false), requiresApproval: z.boolean().default(true), customerActions: ACTIONS.default([]),
|
||||
})).min(1).max(50),
|
||||
});
|
||||
type Bundle = z.infer<typeof bundleSchema>;
|
||||
const bundleDir = fileURLToPath(new URL('../../../../../bundles/', import.meta.url));
|
||||
function loadBundles(): Bundle[] {
|
||||
if (!existsSync(bundleDir)) return [];
|
||||
const out: Bundle[] = [];
|
||||
for (const f of readdirSync(bundleDir).filter((n) => n.endsWith('.json')).sort()) {
|
||||
const r = bundleSchema.safeParse(JSON.parse(readFileSync(join(bundleDir, f), 'utf8')));
|
||||
if (r.success) out.push(r.data);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const versionView = (r: any) => ({
|
||||
id: r.vid ?? r.id, version: r.version, name: r.vname ?? r.name, description: r.description, taxBp: r.tax_bp, priceBasis: r.price_basis, setupCents: r.setup_cents, recurringCents: r.recurring_cents, currency: r.currency,
|
||||
billingInterval: r.billing_interval, termMonths: r.term_months, renewal: r.renewal, renewalTermMonths: r.renewal_term_months, noticeDays: r.notice_days,
|
||||
});
|
||||
const productSelect = `SELECT p.*, v.id AS vid, v.version, v.name AS vname, v.description, v.tax_bp, v.price_basis, v.setup_cents, v.recurring_cents, v.currency, v.billing_interval, v.term_months, v.renewal, v.renewal_term_months, v.notice_days, v.provisioning_json, i.name AS connector_name
|
||||
FROM products p LEFT JOIN product_versions v ON v.id = p.current_version_id LEFT JOIN connector_instances i ON i.id = p.connector_instance_id`;
|
||||
const j = (v: unknown, d: unknown) => (v == null ? d : typeof v === 'string' ? JSON.parse(v) : v);
|
||||
const productView = (r: any) => ({
|
||||
id: r.id, sku: r.sku, category: r.category, status: r.status, connectorInstanceId: r.connector_instance_id, externalRef: r.external_ref ?? null, connectorName: r.connector_name, orderableByCustomer: !!r.orderable_by_customer, requiresApproval: !!r.requires_approval,
|
||||
customerActions: j(r.customer_actions, []), provisioning: j(r.provisioning_json, {}), current: r.vid ? versionView(r) : null,
|
||||
});
|
||||
|
||||
export const catalogModule: KcModule = {
|
||||
name: 'catalog',
|
||||
permissions: { staff: { support: ['products.read'], accounting: ['products.read'], admin: ['products.read', 'products.write'], superadmin: ['products.read', 'products.write'] } },
|
||||
register(app: FastifyInstance) {
|
||||
app.get('/admin/tax-rates', async (req) => {
|
||||
requirePermission(req, 'products.read');
|
||||
return (await query('SELECT id, name, rate_bp FROM tax_rates WHERE active = 1 ORDER BY rate_bp DESC')).map((t) => ({ id: t.id, name: t.name, rateBp: t.rate_bp }));
|
||||
});
|
||||
app.get('/admin/products', async (req) => {
|
||||
requirePermission(req, 'products.read');
|
||||
return (await query(`${productSelect} ORDER BY p.created_at DESC`)).map(productView);
|
||||
});
|
||||
app.get('/admin/products/:id', async (req) => {
|
||||
requirePermission(req, 'products.read');
|
||||
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
|
||||
const r = await one(`${productSelect} WHERE p.id = ?`, [id]);
|
||||
if (!r) throw notFound();
|
||||
const versions = await query('SELECT * FROM product_versions WHERE product_id = ? ORDER BY version DESC', [id]);
|
||||
return { ...productView(r), versions: versions.map(versionView) };
|
||||
});
|
||||
app.post('/admin/products', async (req) => {
|
||||
const a = requirePermission(req, 'products.write');
|
||||
const b = z.object({ sku: z.string().trim().regex(/^[A-Za-z0-9._-]{2,50}$/, 'Nur Buchstaben, Ziffern, Punkt, Unterstrich und Bindestrich'), category: z.enum(['hosting', 'license', 'addon', 'service']),
|
||||
connectorInstanceId: z.string().uuid().nullable().optional(), externalRef: z.string().trim().max(100).optional(), orderableByCustomer: z.boolean().default(false), requiresApproval: z.boolean().default(true), customerActions: ACTIONS.default([]), status: z.enum(['draft', 'active']).default('draft'), version: versionSchema }).parse(req.body);
|
||||
if (b.externalRef && !b.connectorInstanceId) throw badRequest('Herkunft ohne Verbindung', 'BAD_CONNECTOR');
|
||||
const id = await createProduct(b, a.user.id);
|
||||
await audit({ actorType: 'user', actorId: a.user.id, action: 'product.create', resourceType: 'product', resourceId: id, correlationId: req.correlationId, ip: clientIp(req), after: { sku: b.sku, importedFrom: b.externalRef, status: b.status, version: b.version.name } });
|
||||
return { id };
|
||||
});
|
||||
/** Preis-/Vertragsänderung = neue unveränderliche Version. Bestehende Bestellungen und Verträge behalten ihren Snapshot. */
|
||||
app.post('/admin/products/:id/versions', async (req) => {
|
||||
const a = requirePermission(req, 'products.write');
|
||||
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
|
||||
const v = versionSchema.parse(req.body);
|
||||
const p = await one('SELECT id, connector_instance_id, status FROM products WHERE id = ?', [id]);
|
||||
if (!p) throw notFound();
|
||||
await checkConnector(p.connector_instance_id, v.provisioning, p.status === 'active');
|
||||
const vid = await tx((c) => insertVersion(c, id, v, a.user.id));
|
||||
await audit({ actorType: 'user', actorId: a.user.id, action: 'product.version', resourceType: 'product', resourceId: id, correlationId: req.correlationId, ip: clientIp(req), after: { versionId: vid, setupCents: v.setupCents, recurringCents: v.recurringCents } });
|
||||
return { versionId: vid };
|
||||
});
|
||||
app.patch('/admin/products/:id', async (req) => {
|
||||
const a = requirePermission(req, 'products.write');
|
||||
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
|
||||
const b = z.object({ status: z.enum(['draft', 'active', 'retired']).optional(), orderableByCustomer: z.boolean().optional(), requiresApproval: z.boolean().optional(), customerActions: ACTIONS.optional(), connectorInstanceId: z.string().uuid().nullable().optional() }).parse(req.body);
|
||||
const before = await one(`${productSelect} WHERE p.id = ?`, [id]);
|
||||
if (!before) throw notFound();
|
||||
if (b.status === 'active' && !before.vid) throw badRequest('Ohne Version nicht aktivierbar');
|
||||
if (b.status === 'active') await checkConnector(before.connector_instance_id, j(before.provisioning_json, {}) as Record<string, unknown>, true);
|
||||
if (b.connectorInstanceId !== undefined) await checkConnector(b.connectorInstanceId, j(before.provisioning_json, {}) as Record<string, unknown>);
|
||||
await run('UPDATE products SET status = COALESCE(?, status), orderable_by_customer = COALESCE(?, orderable_by_customer), requires_approval = COALESCE(?, requires_approval), customer_actions = COALESCE(?, customer_actions), connector_instance_id = ? WHERE id = ?',
|
||||
[b.status ?? null, b.orderableByCustomer === undefined ? null : b.orderableByCustomer ? 1 : 0, b.requiresApproval === undefined ? null : b.requiresApproval ? 1 : 0, b.customerActions ? JSON.stringify(b.customerActions) : null, b.connectorInstanceId === undefined ? before.connector_instance_id : b.connectorInstanceId, id]);
|
||||
await audit({ actorType: 'user', actorId: a.user.id, action: 'product.update', resourceType: 'product', resourceId: id, correlationId: req.correlationId, ip: clientIp(req), before: { status: before.status, orderableByCustomer: !!before.orderable_by_customer, requiresApproval: !!before.requires_approval }, after: b });
|
||||
return { status: 'ok' };
|
||||
});
|
||||
|
||||
/**
|
||||
* Übernahme: Angebote/Programme eines Anbieters live auslesen (Verbindung, Zugangsdaten bleiben serverseitig).
|
||||
* Zeigt je Eintrag, wie viele Produkte bereits daraus angelegt wurden.
|
||||
*/
|
||||
app.get('/admin/connectors/:id/catalog', async (req) => {
|
||||
requirePermission(req, 'products.write');
|
||||
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
|
||||
if (!(await one('SELECT 1 AS x FROM connector_instances WHERE id = ?', [id]))) throw notFound();
|
||||
try {
|
||||
const { connector, ctx } = await loadInstance(id, req.correlationId);
|
||||
if (!connector.listCatalog || !(await connector.capabilities(ctx)).includes('catalog.list')) throw badRequest('Diese Verbindung unterstützt keine Produktübernahme', 'NO_CATALOG');
|
||||
const items = await connector.listCatalog(ctx);
|
||||
const counts = await query('SELECT external_ref, COUNT(*) AS n FROM products WHERE connector_instance_id = ? AND external_ref IS NOT NULL GROUP BY external_ref', [id]);
|
||||
const byRef = new Map(counts.map((c) => [c.external_ref as string, Number(c.n)]));
|
||||
return items.map((i) => ({ ...i, importedProducts: byRef.get(i.externalRef) ?? 0 }));
|
||||
} catch (e) {
|
||||
if (e instanceof ConnectorError) throw new AppError(502, 'CONNECTOR_ERROR', `Der Anbieter konnte nicht gelesen werden: ${e.userMessage}`);
|
||||
throw e;
|
||||
}
|
||||
});
|
||||
|
||||
/** Vorlagenpakete (Dateien in /bundles): Vorschau mit Preisen, Laufzeiten und Provisionierung. */
|
||||
app.get('/admin/product-bundles', async (req) => {
|
||||
requirePermission(req, 'products.write');
|
||||
const taken = new Set((await query('SELECT sku FROM products')).map((r) => r.sku as string));
|
||||
return loadBundles().map((b) => ({ ...b, products: b.products.map((p) => ({ ...p, exists: taken.has(p.sku) })) }));
|
||||
});
|
||||
/** Importiert ausgewählte Produkte eines Pakets als Entwürfe und verknüpft sie mit Verbindung und Anbieter-Programm. */
|
||||
app.post('/admin/product-bundles/:key/import', async (req) => {
|
||||
const a = requirePermission(req, 'products.write');
|
||||
const { key } = z.object({ key: z.string().max(40) }).parse(req.params);
|
||||
const b = z.object({ connectorInstanceId: z.string().uuid(), programRef: z.string().trim().min(1).max(100), skus: z.array(z.string()).min(1).max(50) }).parse(req.body);
|
||||
const bundle = loadBundles().find((x) => x.key === key);
|
||||
if (!bundle) throw notFound('Paket nicht gefunden');
|
||||
const inst = await one('SELECT connector_key FROM connector_instances WHERE id = ?', [b.connectorInstanceId]);
|
||||
if (!inst) throw badRequest('Verbindung nicht gefunden', 'BAD_CONNECTOR');
|
||||
const tax = await query('SELECT id, rate_bp FROM tax_rates WHERE active = 1');
|
||||
const created: { sku: string; id: string }[] = []; const skipped: { sku: string; reason: string }[] = [];
|
||||
for (const sku of b.skus) {
|
||||
const p = bundle.products.find((x) => x.sku === sku);
|
||||
if (!p) { skipped.push({ sku, reason: 'nicht im Paket' }); continue; }
|
||||
const t = tax.find((x) => Number(x.rate_bp) === p.taxBp);
|
||||
if (!t) { skipped.push({ sku, reason: `Steuersatz ${p.taxBp / 100} % nicht angelegt` }); continue; }
|
||||
try {
|
||||
const provisioning = inst.connector_key === 'licensing' ? { programId: Number(p.provisioning.programId ?? b.programRef), ...p.provisioning } : p.provisioning;
|
||||
if (inst.connector_key === 'licensing') provisioning.programId = Number(b.programRef);
|
||||
const id = await createProduct({ sku: p.sku, category: p.category, connectorInstanceId: b.connectorInstanceId, externalRef: b.programRef, orderableByCustomer: p.orderableByCustomer, requiresApproval: p.requiresApproval, customerActions: p.customerActions, status: 'draft',
|
||||
version: { name: p.name, description: p.description, taxRateId: t.id, priceBasis: p.priceBasis, setupCents: p.setupCents, recurringCents: p.recurringCents, billingInterval: p.interval, termMonths: p.termMonths, renewal: p.renewal, renewalTermMonths: p.renewalTermMonths || undefined, noticeDays: p.noticeDays, provisioning } }, a.user.id);
|
||||
created.push({ sku, id });
|
||||
} catch (e) { skipped.push({ sku, reason: e instanceof AppError ? e.message : 'Fehler beim Anlegen' }); }
|
||||
}
|
||||
await audit({ actorType: 'user', actorId: a.user.id, action: 'product.bundle.import', resourceType: 'product', connector: inst.connector_key, correlationId: req.correlationId, ip: clientIp(req), after: { bundle: key, programRef: b.programRef, created: created.map((c) => c.sku), skipped } });
|
||||
return { created, skipped };
|
||||
});
|
||||
|
||||
/**
|
||||
* Neuen Hosting-Tarif beim Anbieter anlegen UND als Produkt definieren. Wirkt sofort beim Anbieter (Tarif entsteht dort):
|
||||
* Name muss frei sein, Größen in GB, "unbegrenzt" nur wenn die Instanz es kennt. Danach entsteht das Produkt (Entwurf oder aktiv).
|
||||
*/
|
||||
app.post('/admin/connectors/:id/hosting-plans', async (req) => {
|
||||
const a = requirePermission(req, 'products.write');
|
||||
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
|
||||
const lim = z.number().min(0).max(1_000_000).nullable().optional();
|
||||
const b = z.object({
|
||||
plan: z.object({ name: z.string().trim().min(1).max(100), limits: z.object({ diskSpaceGb: lim, trafficGb: lim, domains: lim, subdomains: lim, emailAccounts: lim, emailAddresses: lim, emailForwardings: lim, databases: lim, ftpUsers: lim, scheduledTasks: lim }).default({}), permissions: z.record(z.string(), z.boolean()).optional() }),
|
||||
product: z.object({ sku: z.string().trim().regex(/^[A-Za-z0-9._-]{2,50}$/), status: z.enum(['draft', 'active']).default('draft'), orderableByCustomer: z.boolean().default(false), requiresApproval: z.boolean().default(true), customerActions: ACTIONS.default([]), version: versionSchema.omit({ provisioning: true }) }),
|
||||
}).parse(req.body);
|
||||
const inst = await one('SELECT id, connector_key, capabilities_json FROM connector_instances WHERE id = ?', [id]);
|
||||
if (!inst) throw notFound();
|
||||
const caps: string[] = inst.capabilities_json ? (typeof inst.capabilities_json === 'string' ? JSON.parse(inst.capabilities_json) : inst.capabilities_json) : [];
|
||||
if (!caps.includes('catalog.write')) throw badRequest('Diese Verbindung kann keine Tarife anlegen', 'NO_CATALOG_WRITE');
|
||||
if (await one('SELECT 1 AS x FROM products WHERE sku = ?', [b.product.sku])) throw conflict('Artikelnummer bereits vergeben', 'SKU_EXISTS');
|
||||
let item;
|
||||
try { const { connector, ctx } = await loadInstance(id, req.correlationId); item = await connector.createCatalogItem!(ctx, b.plan); }
|
||||
catch (e) { if (e instanceof ConnectorError) throw new AppError(e.code === 'CONFLICT' ? 409 : e.code === 'INVALID_INPUT' ? 400 : 502, e.code === 'CONFLICT' ? 'PLAN_EXISTS' : 'CONNECTOR_ERROR', e.code === 'INVALID_INPUT' || e.code === 'CONFLICT' ? e.message : `Der Anbieter meldet: ${e.userMessage}`); throw e; }
|
||||
await audit({ actorType: 'user', actorId: a.user.id, action: 'catalog.plan.create', resourceType: 'connector', resourceId: id, connector: inst.connector_key, correlationId: req.correlationId, ip: clientIp(req), after: { name: b.plan.name, ref: item.externalRef, limits: b.plan.limits } });
|
||||
try {
|
||||
const productId = await createProduct({ sku: b.product.sku, category: item.category, connectorInstanceId: id, externalRef: item.externalRef, orderableByCustomer: b.product.orderableByCustomer, requiresApproval: b.product.requiresApproval, customerActions: b.product.customerActions, status: b.product.status,
|
||||
version: { ...b.product.version, provisioning: item.provisioning } }, a.user.id);
|
||||
await audit({ actorType: 'user', actorId: a.user.id, action: 'product.create', resourceType: 'product', resourceId: productId, correlationId: req.correlationId, ip: clientIp(req), after: { sku: b.product.sku, importedFrom: item.externalRef, viaPlanCreate: true } });
|
||||
return { productId, plan: { ref: item.externalRef, name: item.name, features: item.features } };
|
||||
} catch (e) {
|
||||
// Der Tarif existiert beim Anbieter bereits: nicht erneut anlegen, sondern über "Übernehmen" als Produkt anlegen
|
||||
throw new AppError(e instanceof AppError ? e.status : 500, 'PRODUCT_AFTER_PLAN_FAILED', `Der Tarif „${item.name}“ wurde beim Anbieter angelegt, das Produkt konnte aber nicht angelegt werden${e instanceof AppError ? `: ${e.message}` : ''}. Bitte den Tarif über „Aus Verbindung übernehmen“ als Produkt übernehmen.`);
|
||||
}
|
||||
});
|
||||
|
||||
/** Katalog für Kunden: nur aktive, bestellbare Produkte, Preise für die jeweilige Organisation (Netto/Brutto). */
|
||||
app.get('/catalog', async (req) => {
|
||||
const a = requireAuth(req);
|
||||
const q = z.object({ org: z.string().uuid() }).parse(req.query);
|
||||
if (!canInOrg(a.principal, q.org, 'orders.read', 'products.read')) throw notFound();
|
||||
const org = await one('SELECT customer_type FROM organizations WHERE id = ?', [q.org]);
|
||||
const rows = await query(`${productSelect} WHERE p.status = 'active' AND p.orderable_by_customer = 1 ORDER BY v.name`);
|
||||
return rows.map((r) => {
|
||||
const price = calculatePrice({ basis: r.price_basis, setupCents: r.setup_cents, recurringCents: r.recurring_cents, taxBp: r.tax_bp, interval: r.billing_interval, quantity: 1, discountBp: 0 });
|
||||
return { id: r.id, sku: r.sku, category: r.category, name: r.vname, description: r.description, requiresApproval: !!r.requires_approval, customerType: org?.customer_type, price, termMonths: r.term_months, renewal: r.renewal, renewalTermMonths: r.renewal_term_months, noticeDays: r.notice_days };
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
98
apps/api/src/modules/connectors/index.ts
Normal file
98
apps/api/src/modules/connectors/index.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { connect as tlsConnect } from 'node:tls';
|
||||
import { encryptSecrets, getConnector, listConnectors } from '@kc/connectors';
|
||||
import { one, query, run } from '../../core/db.js';
|
||||
import { audit } from '../../core/audit.js';
|
||||
import { enqueue } from '../../core/jobs.js';
|
||||
import { clientIp, requirePermission } from '../../core/auth.js';
|
||||
import { AppError, badRequest, conflict, notFound } from '../../core/errors.js';
|
||||
import type { KcModule } from '../../core/module.js';
|
||||
|
||||
const parseErr = (v: unknown): { tech: string; hint: string } | null => { if (!v) return null; try { const o = JSON.parse(String(v)); return { tech: String(o.tech ?? ''), hint: String(o.hint ?? '') }; } catch { return { tech: String(v), hint: '' }; } };
|
||||
const pub = (r: any) => ({
|
||||
id: r.id, connector: r.connector_key, name: r.name, enabled: !!r.enabled, syncIntervalSec: r.sync_interval_sec, health: r.health, healthMessage: r.health_message,
|
||||
capabilities: r.capabilities_json ?? [], lastOkAt: r.last_ok_at, lastError: parseErr(r.last_error), lastErrorAt: r.last_error_at ?? null, lastSyncAt: r.last_sync_at, config: r.config_json, hasSecrets: !!r.secrets_enc, resources: Number(r.resources ?? 0),
|
||||
});
|
||||
/** Trennt Eingabefelder in Konfiguration und Geheimnisse anhand der Felddefinition des Connectors. */
|
||||
function split(key: string, input: Record<string, string>) {
|
||||
const fields = getConnector(key).configFields; const config: Record<string, string> = {}; const secrets: Record<string, string> = {};
|
||||
for (const f of fields) { const v = input[f.name]?.trim(); if (v) (f.secret ? secrets : config)[f.name] = v; }
|
||||
const missing = fields.filter((f) => f.required && !config[f.name] && !secrets[f.name]).map((f) => f.label);
|
||||
return { config, secrets, missing };
|
||||
}
|
||||
|
||||
export const connectorsModule: KcModule = {
|
||||
name: 'connectors',
|
||||
permissions: { staff: { admin: ['connectors.read'], superadmin: ['connectors.read', 'connectors.write'] } },
|
||||
register(app: FastifyInstance) {
|
||||
app.get('/admin/connector-types', async (req) => {
|
||||
requirePermission(req, 'connectors.read');
|
||||
return listConnectors().map((c) => ({ key: c.key, name: c.displayName, fields: c.configFields.map((f) => ({ name: f.name, label: f.label, secret: !!f.secret, required: !!f.required, placeholder: f.placeholder ?? '', help: f.help ?? '', advanced: !!f.advanced, options: f.options ?? null })) }));
|
||||
});
|
||||
/**
|
||||
* Zertifikat-Fingerabdruck eines Servers holen (für selbstsignierte Zertifikate). Es wird nur ein TLS-Handshake durchgeführt und
|
||||
* das Zertifikat gelesen, keine Anfrage gesendet. Loopback/Link-Local sind gesperrt.
|
||||
*/
|
||||
app.post('/admin/connector-tls-check', { config: { rateLimit: { max: 20, timeWindow: '1 minute' } } }, async (req) => {
|
||||
requirePermission(req, 'connectors.write');
|
||||
const { url } = z.object({ url: z.string().max(300) }).parse(req.body);
|
||||
let u: URL; try { u = new URL(url); } catch { throw badRequest('Ungültige Adresse', 'BAD_URL'); }
|
||||
if (u.protocol !== 'https:') throw badRequest('Bitte eine Adresse mit https:// angeben', 'BAD_URL');
|
||||
const host = u.hostname.replace(/^\[|\]$/g, ''); if (/^(localhost|0\.0\.0\.0|127\.|169\.254\.|::1?$)/i.test(host)) throw badRequest('Diese Adresse ist nicht erlaubt', 'BAD_HOST');
|
||||
const port = Number(u.port || 443);
|
||||
return new Promise((resolve, reject) => {
|
||||
const sock = tlsConnect({ host, port, servername: host, rejectUnauthorized: false, timeout: 8000 }, () => {
|
||||
const c = sock.getPeerCertificate(); const trusted = sock.authorized; sock.end();
|
||||
if (!c || !c.fingerprint256) return reject(new AppError(502, 'NO_CERT', 'Der Server hat kein Zertifikat geliefert.'));
|
||||
resolve({ fingerprint: c.fingerprint256, subject: c.subject?.CN ?? null, issuer: c.issuer?.CN ?? null, validTo: c.valid_to ?? null, trusted, selfSigned: c.issuer?.CN === c.subject?.CN && !trusted });
|
||||
});
|
||||
sock.on('timeout', () => { sock.destroy(); reject(new AppError(502, 'TIMEOUT', 'Der Server hat nicht rechtzeitig geantwortet.')); });
|
||||
sock.on('error', () => reject(new AppError(502, 'UNREACHABLE', 'Der Server ist nicht erreichbar (Adresse und Port prüfen).')));
|
||||
});
|
||||
});
|
||||
app.get('/admin/connectors', async (req) => {
|
||||
requirePermission(req, 'connectors.read');
|
||||
return (await query('SELECT i.*, (SELECT COUNT(*) FROM resources r WHERE r.instance_id = i.id) AS resources FROM connector_instances i ORDER BY i.name')).map(pub);
|
||||
});
|
||||
app.post('/admin/connectors', async (req) => {
|
||||
const a = requirePermission(req, 'connectors.write'); // Secrets: nur Superadmin
|
||||
const b = z.object({ connector: z.string().max(50), name: z.string().trim().min(1).max(100), values: z.record(z.string(), z.string().max(500)), syncIntervalSec: z.number().int().min(60).max(86400).default(300) }).parse(req.body);
|
||||
try { getConnector(b.connector); } catch { throw badRequest('Unbekannter Connector'); }
|
||||
const { config, secrets, missing } = split(b.connector, b.values);
|
||||
if (missing.length) throw badRequest(`Pflichtfelder fehlen: ${missing.join(', ')}`);
|
||||
if (await one('SELECT 1 AS x FROM connector_instances WHERE name = ?', [b.name])) throw conflict('Name bereits vergeben');
|
||||
const id = randomUUID();
|
||||
await run('INSERT INTO connector_instances (id, connector_key, name, config_json, secrets_enc, sync_interval_sec) VALUES (?,?,?,?,?,?)', [id, b.connector, b.name, JSON.stringify(config), encryptSecrets(secrets), b.syncIntervalSec]);
|
||||
await enqueue('connector.sync', { instanceId: id }, { idempotencyKey: `sync:${id}:initial`, correlationId: req.correlationId });
|
||||
await audit({ actorType: 'user', actorId: a.user.id, action: 'connector.create', resourceType: 'connector', resourceId: id, connector: b.connector, correlationId: req.correlationId, ip: clientIp(req), after: { name: b.name, config, secrets: Object.keys(secrets) } });
|
||||
return { id };
|
||||
});
|
||||
app.patch('/admin/connectors/:id', async (req) => {
|
||||
const a = requirePermission(req, 'connectors.write');
|
||||
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
|
||||
const b = z.object({ enabled: z.boolean().optional(), syncIntervalSec: z.number().int().min(60).max(86400).optional(), values: z.record(z.string(), z.string().max(500)).optional() }).parse(req.body);
|
||||
const inst = await one('SELECT * FROM connector_instances WHERE id = ?', [id]);
|
||||
if (!inst) throw notFound();
|
||||
let config = inst.config_json as Record<string, string>; let secretsEnc = inst.secrets_enc as string | null;
|
||||
if (b.values) {
|
||||
// Leere Werte behalten den bisherigen Wert; Geheimnisse werden nur ersetzt, wenn neu angegeben.
|
||||
const { config: c2, secrets } = split(inst.connector_key, b.values);
|
||||
config = { ...config, ...c2 };
|
||||
if (Object.keys(secrets).length) { const { decrypt } = await import('../../core/crypto.js'); const old = inst.secrets_enc ? JSON.parse(decrypt(inst.secrets_enc)) : {}; secretsEnc = encryptSecrets({ ...old, ...secrets }); }
|
||||
}
|
||||
await run('UPDATE connector_instances SET enabled = COALESCE(?, enabled), sync_interval_sec = COALESCE(?, sync_interval_sec), config_json = ?, secrets_enc = ? WHERE id = ?', [b.enabled === undefined ? null : b.enabled ? 1 : 0, b.syncIntervalSec ?? null, JSON.stringify(config), secretsEnc, id]);
|
||||
await audit({ actorType: 'user', actorId: a.user.id, action: 'connector.update', resourceType: 'connector', resourceId: id, connector: inst.connector_key, correlationId: req.correlationId, ip: clientIp(req), before: { enabled: !!inst.enabled, config: inst.config_json }, after: { enabled: b.enabled, syncIntervalSec: b.syncIntervalSec, config, secretsChanged: !!b.values } });
|
||||
return { status: 'ok' };
|
||||
});
|
||||
app.post('/admin/connectors/:id/sync', async (req) => {
|
||||
const a = requirePermission(req, 'connectors.read');
|
||||
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
|
||||
if (!(await one('SELECT 1 AS x FROM connector_instances WHERE id = ?', [id]))) throw notFound();
|
||||
await enqueue('connector.sync', { instanceId: id }, { idempotencyKey: `sync:${id}:manual:${Date.now()}`, correlationId: req.correlationId });
|
||||
await audit({ actorType: 'user', actorId: a.user.id, action: 'connector.sync.request', resourceType: 'connector', resourceId: id, correlationId: req.correlationId, ip: clientIp(req) });
|
||||
return { status: 'queued' };
|
||||
});
|
||||
},
|
||||
};
|
||||
264
apps/api/src/modules/customers/index.ts
Normal file
264
apps/api/src/modules/customers/index.ts
Normal file
|
|
@ -0,0 +1,264 @@
|
|||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { one, query, run, tx } from '../../core/db.js';
|
||||
import { audit } from '../../core/audit.js';
|
||||
import { clientIp, requireAuth, requirePermission } from '../../core/auth.js';
|
||||
import { badRequest, conflict, forbidden, notFound } from '../../core/errors.js';
|
||||
import { canInOrg } from '../../core/policy.js';
|
||||
import { createInvitedUser, inviteLink, mailInvite } from '../../core/accounts.js';
|
||||
import { enqueue } from '../../core/jobs.js';
|
||||
import { calculatePrice } from '@kc/platform/pricing';
|
||||
import { addMonths, consumerTerms } from '@kc/platform/contractterms';
|
||||
import { getConnector, loadInstance, syncInstance } from '@kc/connectors';
|
||||
import { ConnectorError, type ImportableCustomer } from '@kc/connector-sdk';
|
||||
import { AppError } from '../../core/errors.js';
|
||||
import type { KcModule } from '../../core/module.js';
|
||||
|
||||
const email = z.string().email().max(254).transform((s) => s.toLowerCase());
|
||||
const opt = (n: number) => z.string().trim().max(n).optional().transform((v) => (v ? v : null));
|
||||
const billing = z.object({
|
||||
company: opt(200), contactName: opt(150), street: opt(200), zip: opt(20), city: opt(100),
|
||||
country: z.string().length(2).toUpperCase().default('DE'), vatId: opt(30), billingEmail: opt(254), phone: opt(50),
|
||||
});
|
||||
|
||||
type BillingIn = z.infer<typeof billing>;
|
||||
/** Fachregeln je Kundenart: Privatkunde ohne Firma/USt-IdNr., Geschäftskunde mit Firmenname. */
|
||||
function applyTypeRules(type: 'private' | 'business', name: string, b: Partial<BillingIn>, opts: { partial?: boolean } = {}): void {
|
||||
if (type === 'private') {
|
||||
if (b.company || b.vatId) throw badRequest('Privatkunden haben keine Firma und keine USt-IdNr.', 'PRIVATE_NO_COMPANY');
|
||||
} else {
|
||||
if (!opts.partial && !(b.company ?? name)) throw badRequest('Geschäftskunden benötigen einen Firmennamen', 'BUSINESS_NEEDS_COMPANY');
|
||||
if (b.vatId && !/^[A-Z]{2}[A-Z0-9]{2,12}$/.test(b.vatId.replace(/[\s.-]/g, '').toUpperCase())) throw badRequest('USt-IdNr. hat ein ungültiges Format (z. B. DE123456789)', 'INVALID_VAT_ID');
|
||||
}
|
||||
}
|
||||
const normVat = (v: string | null | undefined) => (v ? v.replace(/[\s.-]/g, '').toUpperCase() : v);
|
||||
|
||||
async function nextCustomerNumber(c: Parameters<typeof one>[2]): Promise<string> {
|
||||
await run('UPDATE customer_sequences SET next_value = LAST_INSERT_ID(next_value + 1) WHERE id = 1', [], c as never);
|
||||
const r = await one('SELECT LAST_INSERT_ID() AS n', [], c);
|
||||
return `K-${r!.n}`;
|
||||
}
|
||||
|
||||
export const customersModule: KcModule = {
|
||||
name: 'customers',
|
||||
register(app: FastifyInstance) {
|
||||
// ---- Admin: Kunden -----------------------------------------------------
|
||||
app.get('/admin/customers', async (req) => {
|
||||
requirePermission(req, 'customers.read');
|
||||
const q = z.object({ q: z.string().max(100).optional(), type: z.enum(['private', 'business']).optional() }).parse(req.query);
|
||||
const rows = await query(
|
||||
`SELECT o.id, o.customer_number, o.name, o.customer_type, o.status, o.created_at, b.city, b.billing_email,
|
||||
(SELECT COUNT(*) FROM memberships m WHERE m.org_id = o.id) AS members
|
||||
FROM organizations o LEFT JOIN billing_profiles b ON b.org_id = o.id
|
||||
WHERE (? IS NULL OR o.name LIKE CONCAT('%', ?, '%') OR o.customer_number LIKE CONCAT('%', ?, '%') OR b.billing_email LIKE CONCAT('%', ?, '%')) AND (? IS NULL OR o.customer_type = ?)
|
||||
ORDER BY o.created_at DESC LIMIT 200`,
|
||||
[q.q ?? null, q.q ?? null, q.q ?? null, q.q ?? null, q.type ?? null, q.type ?? null],
|
||||
);
|
||||
return rows.map((r) => ({ id: r.id, customerNumber: r.customer_number, name: r.name, customerType: r.customer_type, status: r.status, createdAt: r.created_at, city: r.city, billingEmail: r.billing_email, members: Number(r.members) }));
|
||||
});
|
||||
|
||||
app.post('/admin/customers', async (req) => {
|
||||
const a = requirePermission(req, 'customers.write');
|
||||
const b = z.object({ type: z.enum(['private', 'business']), name: z.string().trim().min(1).max(200), owner: z.object({ email, name: z.string().trim().min(1).max(150).optional() }), billing: billing.prefault({}) }).parse(req.body);
|
||||
b.billing.vatId = normVat(b.billing.vatId) ?? null;
|
||||
applyTypeRules(b.type, b.name, b.billing);
|
||||
const ownerName = b.owner.name ?? b.name; // Privatkunde: Ansprechpartner = Kunde selbst
|
||||
if (await one('SELECT 1 AS x FROM users WHERE email = ?', [b.owner.email])) throw conflict('Diese E-Mail ist bereits einem Benutzer zugeordnet', 'EMAIL_EXISTS');
|
||||
const orgId = randomUUID();
|
||||
const res = await tx(async (c) => {
|
||||
const number = await nextCustomerNumber(c);
|
||||
await run('INSERT INTO organizations (id, customer_number, name, customer_type) VALUES (?,?,?,?)', [orgId, number, b.name, b.type], c);
|
||||
const bp = b.billing;
|
||||
await run('INSERT INTO billing_profiles (org_id, company, contact_name, street, zip, city, country, vat_id, billing_email, phone) VALUES (?,?,?,?,?,?,?,?,?,?)',
|
||||
[orgId, b.type === 'business' ? (bp.company ?? b.name) : null, b.type === 'private' ? b.name : bp.contactName, bp.street, bp.zip, bp.city, bp.country, bp.vatId, bp.billingEmail ?? b.owner.email, bp.phone], c);
|
||||
const inv = await createInvitedUser(c, { email: b.owner.email, name: ownerName, kind: 'customer' });
|
||||
await run('INSERT INTO memberships (org_id, user_id, role) VALUES (?,?,\'owner\')', [orgId, inv.userId], c);
|
||||
await enqueue('discord.notify', { event: 'customer.created', customerNumber: number }, { idempotencyKey: `customer.created:${orgId}`, correlationId: req.correlationId }, c);
|
||||
return { number, inv };
|
||||
});
|
||||
const mail = await mailInvite(b.owner.email, ownerName, res.inv.token);
|
||||
await audit({ actorType: 'user', actorId: a.user.id, orgId, action: 'customer.create', resourceType: 'organization', resourceId: orgId, correlationId: req.correlationId, ip: clientIp(req), after: { customerNumber: res.number, customerType: b.type, name: b.name, owner: b.owner.email } });
|
||||
return { id: orgId, customerNumber: res.number, mail, inviteLink: mail === 'sent' ? undefined : inviteLink(res.inv.token) };
|
||||
});
|
||||
|
||||
app.get('/admin/customers/:id', async (req) => {
|
||||
requirePermission(req, 'customers.read');
|
||||
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
|
||||
return loadOrg(id);
|
||||
});
|
||||
|
||||
app.patch('/admin/customers/:id', async (req) => {
|
||||
const a = requirePermission(req, 'customers.write');
|
||||
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
|
||||
// Felder: nicht angegeben = unverändert, leer/null = löschen
|
||||
const f = (n: number) => z.string().trim().max(n).nullable().optional();
|
||||
const b = z.object({
|
||||
name: z.string().trim().min(1).max(200).optional(), customerType: z.enum(['private', 'business']).optional(), status: z.enum(['active', 'suspended', 'closed']).optional(),
|
||||
billing: z.object({ company: f(200), contactName: f(150), street: f(200), zip: f(20), city: f(100), country: z.string().length(2).toUpperCase().optional(), vatId: f(30), billingEmail: f(254), phone: f(50) }).optional(),
|
||||
}).parse(req.body);
|
||||
const before = await loadOrg(id);
|
||||
const cols: Record<string, string> = { company: 'company', contactName: 'contact_name', street: 'street', zip: 'zip', city: 'city', country: 'country', vatId: 'vat_id', billingEmail: 'billing_email', phone: 'phone' };
|
||||
const patch: Record<string, string | null> = {};
|
||||
for (const [k, v] of Object.entries(b.billing ?? {})) if (v !== undefined) patch[k] = k === 'vatId' ? (normVat(v as string | null) || null) : ((v as string | null) || null);
|
||||
// Ergebnis nach der Änderung gegen die Regeln der (neuen) Kundenart prüfen
|
||||
const type = b.customerType ?? before.customerType;
|
||||
const name = b.name ?? before.name;
|
||||
const after = { company: 'company' in patch ? patch.company : before.billing.company, vatId: 'vatId' in patch ? patch.vatId : before.billing.vatId };
|
||||
if (type === 'private' && (after.company || after.vatId)) throw badRequest('Privatkunden haben keine Firma und keine USt-IdNr. Bitte beides entfernen.', 'PRIVATE_NO_COMPANY');
|
||||
if (type === 'business') { if (!(after.company || name)) throw badRequest('Geschäftskunden benötigen einen Firmennamen', 'BUSINESS_NEEDS_COMPANY'); applyTypeRules('business', name, { vatId: after.vatId ?? undefined }); }
|
||||
await tx(async (c) => {
|
||||
if (b.name || b.status || b.customerType) await run('UPDATE organizations SET name = COALESCE(?, name), status = COALESCE(?, status), customer_type = COALESCE(?, customer_type) WHERE id = ?', [b.name ?? null, b.status ?? null, b.customerType ?? null, id], c);
|
||||
const keys = Object.keys(patch);
|
||||
if (keys.length) await run(`UPDATE billing_profiles SET ${keys.map((k) => `${cols[k]} = ?`).join(', ')} WHERE org_id = ?`, [...keys.map((k) => patch[k] ?? null), id], c);
|
||||
});
|
||||
await audit({ actorType: 'user', actorId: a.user.id, orgId: id, action: 'customer.update', resourceType: 'organization', resourceId: id, correlationId: req.correlationId, ip: clientIp(req), before, after: b });
|
||||
return loadOrg(id);
|
||||
});
|
||||
|
||||
// ---- Übernahme bestehender Kunden aus einem Anbieter (z. B. KeyHelp) --------------
|
||||
const capsOf = (inst: any): string[] => (inst.capabilities_json ? (typeof inst.capabilities_json === 'string' ? JSON.parse(inst.capabilities_json) : inst.capabilities_json) : []);
|
||||
const legacyRef = (inst: any, ref: string) => `${inst.connector_key}:${inst.id}:${ref}`;
|
||||
async function liveCustomers(instId: string, corr: string): Promise<{ inst: any; list: ImportableCustomer[] }> {
|
||||
const inst = await one('SELECT * FROM connector_instances WHERE id = ?', [instId]); if (!inst) throw notFound();
|
||||
if (!capsOf(inst).includes('customers.list')) throw badRequest('Diese Verbindung unterstützt keine Kundenübernahme', 'NO_CUSTOMERS');
|
||||
try { const { connector, ctx } = await loadInstance(instId, corr); return { inst, list: await connector.listCustomers!(ctx) }; }
|
||||
catch (e) { if (e instanceof ConnectorError) throw new AppError(502, 'CONNECTOR_ERROR', `Der Anbieter konnte nicht gelesen werden: ${e.userMessage}`); throw e; }
|
||||
}
|
||||
/** Kunden des Anbieters mit Übernahmestatus, Produkt-Vorschlägen und möglichen vorhandenen Kunden (gleiche E-Mail/Firma). */
|
||||
app.get('/admin/connectors/:id/customers', async (req) => {
|
||||
requirePermission(req, 'customers.write');
|
||||
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
|
||||
const { inst, list } = await liveCustomers(id, req.correlationId);
|
||||
const orgs = await query('SELECT o.id, o.customer_number, o.name, o.legacy_ref, b.billing_email FROM organizations o LEFT JOIN billing_profiles b ON b.org_id = o.id');
|
||||
const byLegacy = new Map(orgs.filter((o) => o.legacy_ref).map((o) => [o.legacy_ref as string, o]));
|
||||
const res = await query('SELECT external_ref, org_id FROM resources WHERE instance_id = ?', [id]); const resOrg = new Map(res.map((r) => [r.external_ref as string, r.org_id as string | null]));
|
||||
const prods = await query('SELECT p.id, p.sku, p.external_ref, p.status, v.name FROM products p JOIN product_versions v ON v.id = p.current_version_id WHERE p.connector_instance_id = ?', [id]);
|
||||
return list.map((c) => {
|
||||
const done = byLegacy.get(legacyRef(inst, c.externalRef));
|
||||
const matches = orgs.filter((o) => !o.legacy_ref && ((c.email && o.billing_email && String(o.billing_email).toLowerCase() === c.email.toLowerCase()) || (c.company && String(o.name).toLowerCase() === c.company.toLowerCase())));
|
||||
return { ...c, imported: done ? { orgId: done.id, customerNumber: done.customer_number } : null, resourceKnown: resOrg.has(c.externalRef), resourceOrgId: resOrg.get(c.externalRef) ?? null,
|
||||
suggestedProducts: prods.filter((p) => c.planRef && p.external_ref === c.planRef).map((p) => ({ id: p.id, sku: p.sku, name: p.name, status: p.status })),
|
||||
possibleOrgs: matches.map((o) => ({ id: o.id, customerNumber: o.customer_number, name: o.name })) };
|
||||
});
|
||||
});
|
||||
|
||||
const importItem = z.object({
|
||||
externalRef: z.string().min(1).max(100), type: z.enum(['private', 'business']), name: z.string().trim().min(1).max(200).optional(), ownerName: z.string().trim().min(1).max(150).optional(), ownerEmail: email.optional(),
|
||||
linkToOrgId: z.string().uuid().optional(), contract: z.object({ productId: z.string().uuid(), startedAt: z.string().max(40).optional() }).optional(),
|
||||
});
|
||||
/**
|
||||
* Übernimmt ausgewählte Kunden: legt Kunde (Organisation, Rechnungsanschrift, Inhaber) an oder verknüpft mit einem vorhandenen Kunden,
|
||||
* ordnet das Hosting-Konto zu und legt optional einen laufenden Bestandsvertrag an. Es werden KEINE E-Mails versendet (Einladung später gezielt).
|
||||
*/
|
||||
app.post('/admin/connectors/:id/customers/import', async (req) => {
|
||||
const a = requirePermission(req, 'customers.write');
|
||||
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
|
||||
const b = z.object({ items: z.array(importItem).min(1).max(200) }).parse(req.body);
|
||||
const { inst, list } = await liveCustomers(id, req.correlationId);
|
||||
const byRef = new Map(list.map((c) => [c.externalRef, c]));
|
||||
// Fehlende lokale Ressourcen-Spiegel vorab abgleichen (einmalig)
|
||||
const known = async () => new Map((await query('SELECT id, external_ref, org_id, state, valid_from FROM resources WHERE instance_id = ?', [id])).map((r) => [r.external_ref as string, r]));
|
||||
let resMap = await known();
|
||||
if (b.items.some((i) => !resMap.has(i.externalRef))) { await syncInstance(id, req.correlationId).catch(() => undefined); resMap = await known(); }
|
||||
const results: { externalRef: string; status: 'created' | 'linked' | 'skipped'; reason?: string; orgId?: string; customerNumber?: string; contractNumber?: string }[] = [];
|
||||
const seq = async (c: Parameters<typeof one>[2], name: 'order' | 'contract', prefix: string) => { await run('UPDATE number_sequences SET next_value = LAST_INSERT_ID(next_value + 1) WHERE name = ?', [name], c as never); return `${prefix}-${(await one('SELECT LAST_INSERT_ID() AS n', [], c))!.n}`; };
|
||||
for (const it of b.items) {
|
||||
const c = byRef.get(it.externalRef); const res = resMap.get(it.externalRef); const skip = (reason: string) => results.push({ externalRef: it.externalRef, status: 'skipped', reason });
|
||||
if (!c) { skip('Beim Anbieter nicht gefunden'); continue; }
|
||||
if (!res) { skip('Das Konto ist noch nicht abgeglichen (Verbindung prüfen)'); continue; }
|
||||
if (await one('SELECT 1 AS x FROM organizations WHERE legacy_ref = ?', [legacyRef(inst, c.externalRef)])) { skip('Bereits übernommen'); continue; }
|
||||
if (res.org_id && !it.linkToOrgId) { skip('Das Konto ist bereits einem Kunden zugeordnet'); continue; }
|
||||
const name = it.name ?? (it.type === 'business' ? (c.company ?? c.displayName) : ([c.firstName, c.lastName].filter(Boolean).join(' ') || c.displayName));
|
||||
try {
|
||||
let product: any = null;
|
||||
if (it.contract) {
|
||||
product = await one('SELECT p.*, v.id AS vid, v.version, v.name AS vname, v.tax_bp, v.price_basis, v.setup_cents, v.recurring_cents, v.billing_interval, v.term_months, v.renewal, v.renewal_term_months, v.notice_days FROM products p JOIN product_versions v ON v.id = p.current_version_id WHERE p.id = ?', [it.contract.productId]);
|
||||
if (!product || product.connector_instance_id !== id) throw badRequest('Das Produkt gehört nicht zu dieser Verbindung', 'BAD_PRODUCT');
|
||||
if (await one('SELECT 1 AS x FROM contracts WHERE resource_id = ?', [res.id])) throw badRequest('Für dieses Konto gibt es bereits einen Vertrag', 'CONTRACT_EXISTS');
|
||||
}
|
||||
const ownerEmail = (it.ownerEmail ?? c.email ?? '').toLowerCase();
|
||||
let orgId = it.linkToOrgId ?? ''; let customerNumber = ''; let orgType: 'private' | 'business' = it.type; let contractNumber: string | undefined;
|
||||
await tx(async (cn) => {
|
||||
if (it.linkToOrgId) {
|
||||
const o = await one('SELECT id, customer_number, customer_type FROM organizations WHERE id = ?', [it.linkToOrgId], cn); if (!o) throw badRequest('Der gewählte Kunde existiert nicht', 'BAD_ORG');
|
||||
orgId = o.id; customerNumber = o.customer_number; orgType = o.customer_type;
|
||||
} else {
|
||||
if (!ownerEmail || !email.safeParse(ownerEmail).success) throw badRequest('Für den Inhaber fehlt eine gültige E-Mail-Adresse', 'OWNER_EMAIL');
|
||||
if (await one('SELECT 1 AS x FROM users WHERE email = ?', [ownerEmail], cn)) throw conflict('Diese E-Mail ist bereits einem Benutzer zugeordnet (bitte mit vorhandenem Kunden verknüpfen)', 'EMAIL_EXISTS');
|
||||
if (it.type === 'private' && (c.company && !it.name)) { /* Firma vorhanden, aber als Privatkunde gewählt: Person als Name */ }
|
||||
orgId = randomUUID(); customerNumber = await nextCustomerNumber(cn);
|
||||
await run('INSERT INTO organizations (id, customer_number, name, customer_type, legacy_ref) VALUES (?,?,?,?,?)', [orgId, customerNumber, name, it.type, legacyRef(inst, c.externalRef)], cn);
|
||||
const country = c.address.country && /^[A-Za-z]{2}$/.test(c.address.country) ? c.address.country.toUpperCase() : 'DE';
|
||||
await run('INSERT INTO billing_profiles (org_id, company, contact_name, street, zip, city, country, billing_email, phone) VALUES (?,?,?,?,?,?,?,?,?)',
|
||||
[orgId, it.type === 'business' ? (c.company ?? name) : null, it.type === 'private' ? name : ([c.firstName, c.lastName].filter(Boolean).join(' ') || null), c.address.street, c.address.zip, c.address.city, country, ownerEmail, c.phone], cn);
|
||||
const u = await createInvitedUser(cn, { email: ownerEmail, name: it.ownerName ?? ([c.firstName, c.lastName].filter(Boolean).join(' ') || name), kind: 'customer' }); // Einladung wird NICHT versendet
|
||||
await run("INSERT INTO memberships (org_id, user_id, role) VALUES (?,?,'owner')", [orgId, u.userId], cn);
|
||||
}
|
||||
if (it.linkToOrgId) await run('UPDATE organizations SET legacy_ref = COALESCE(legacy_ref, ?) WHERE id = ?', [legacyRef(inst, c.externalRef), orgId], cn);
|
||||
await run('UPDATE resources SET org_id = ?, customer_actions = COALESCE(?, customer_actions) WHERE id = ?', [orgId, product ? product.customer_actions : null, res.id], cn);
|
||||
if (product) {
|
||||
const now = new Date(); const started = it.contract!.startedAt && !Number.isNaN(Date.parse(it.contract!.startedAt)) ? new Date(it.contract!.startedAt) : (c.createdAt ? new Date(c.createdAt) : now);
|
||||
let terms = { termMonths: product.term_months as number, renewal: product.renewal as 'auto' | 'none', renewalTermMonths: product.renewal_term_months as number, noticeDays: product.notice_days as number };
|
||||
if (orgType === 'private' && terms.renewal === 'auto') terms = { ...terms, ...consumerTerms(terms.renewalTermMonths, terms.noticeDays) };
|
||||
const price = calculatePrice({ basis: product.price_basis, setupCents: product.setup_cents, recurringCents: product.recurring_cents, taxBp: product.tax_bp, interval: product.billing_interval, quantity: 1, discountBp: 0 });
|
||||
const snapshot = { productId: product.id, sku: product.sku, productVersionId: product.vid, version: product.version, name: product.vname, category: product.category, customerType: orgType, ...price, terms, imported: true };
|
||||
// Laufzeitende: erste Periode ab Beginn, bei automatischer Verlängerung bis in die Zukunft fortgeschrieben
|
||||
const period = terms.termMonths > 0 ? terms.termMonths : terms.renewal === 'auto' ? terms.renewalTermMonths : 0; let termEnd: Date | null = null;
|
||||
if (period > 0) { termEnd = addMonths(started, period); const step = terms.renewal === 'auto' && terms.renewalTermMonths > 0 ? terms.renewalTermMonths : 0; let g = 0; while (termEnd <= now && step > 0 && g++ < 1200) termEnd = addMonths(termEnd, step); if (termEnd <= now) termEnd = null; }
|
||||
const orderId = randomUUID(); const itemId = randomUUID(); const oNum = await seq(cn, 'order', 'B'); contractNumber = await seq(cn, 'contract', 'V');
|
||||
await run("INSERT INTO orders (id, number, org_id, status, placed_by, placed_via, approval_required, approved_by, approved_at, note) VALUES (?,?,?,'completed',?,'staff',0,?,UTC_TIMESTAMP(3),?)", [orderId, oNum, orgId, a.user.id, a.user.id, `Übernahme aus ${inst.name} (Bestand)`], cn);
|
||||
await run('INSERT INTO order_items (id, order_id, product_version_id, quantity, discount_bp, snapshot_json) VALUES (?,?,?,1,0,?)', [itemId, orderId, product.vid, JSON.stringify(snapshot)], cn);
|
||||
await run('INSERT INTO contracts (id, number, org_id, order_item_id, product_version_id, status, started_at, term_end, renewal, renewal_term_months, notice_days, resource_id, price_snapshot_json) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)',
|
||||
[randomUUID(), contractNumber, orgId, itemId, product.vid, res.state === 'suspended' ? 'suspended' : 'active', started, termEnd, terms.renewal, terms.renewalTermMonths, terms.noticeDays, res.id, JSON.stringify(snapshot)], cn);
|
||||
}
|
||||
});
|
||||
await audit({ actorType: 'user', actorId: a.user.id, orgId, action: 'customer.import', resourceType: 'organization', resourceId: orgId, connector: inst.connector_key, correlationId: req.correlationId, ip: clientIp(req), after: { source: legacyRef(inst, c.externalRef), customerNumber, linked: !!it.linkToOrgId, contractNumber } });
|
||||
results.push({ externalRef: it.externalRef, status: it.linkToOrgId ? 'linked' : 'created', orgId, customerNumber, contractNumber });
|
||||
} catch (e) { skip(e instanceof AppError ? e.message : 'Fehler bei der Übernahme'); }
|
||||
}
|
||||
return { results };
|
||||
});
|
||||
|
||||
// ---- Kundenseite: eigene Organisation ---------------------------------
|
||||
app.get('/orgs/:id', async (req) => {
|
||||
const a = requireAuth(req);
|
||||
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
|
||||
if (!canInOrg(a.principal, id, 'org.read', 'customers.read')) throw notFound(); // keine Existenz verraten
|
||||
return loadOrg(id);
|
||||
});
|
||||
app.get('/orgs/:id/members', async (req) => {
|
||||
const a = requireAuth(req);
|
||||
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
|
||||
if (!canInOrg(a.principal, id, 'org.members.read', 'customers.read')) throw notFound();
|
||||
return (await loadOrg(id)).members;
|
||||
});
|
||||
app.post('/orgs/:id/invitations', async (req) => {
|
||||
const a = requireAuth(req);
|
||||
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
|
||||
if (!canInOrg(a.principal, id, 'org.read', 'customers.read')) throw notFound();
|
||||
if (!canInOrg(a.principal, id, 'org.members.invite', 'customers.write')) throw forbidden();
|
||||
const b = z.object({ email, name: z.string().trim().min(1).max(150), role: z.enum(['admin', 'member']) }).parse(req.body);
|
||||
if (await one('SELECT 1 AS x FROM users WHERE email = ?', [b.email])) throw conflict('Diese E-Mail ist bereits registriert', 'EMAIL_EXISTS');
|
||||
const inv = await tx(async (c) => {
|
||||
const i = await createInvitedUser(c, { email: b.email, name: b.name, kind: 'customer' });
|
||||
await run('INSERT INTO memberships (org_id, user_id, role) VALUES (?,?,?)', [id, i.userId, b.role], c);
|
||||
return i;
|
||||
});
|
||||
const mail = await mailInvite(b.email, b.name, inv.token);
|
||||
await audit({ actorType: 'user', actorId: a.user.id, orgId: id, action: 'org.member.invite', resourceType: 'user', resourceId: inv.userId, correlationId: req.correlationId, ip: clientIp(req), after: { email: b.email, role: b.role } });
|
||||
return { id: inv.userId, mail, inviteLink: mail === 'sent' ? undefined : inviteLink(inv.token) };
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
async function loadOrg(id: string) {
|
||||
const o = await one('SELECT o.*, b.company, b.contact_name, b.street, b.zip, b.city, b.country, b.vat_id, b.billing_email, b.phone FROM organizations o LEFT JOIN billing_profiles b ON b.org_id = o.id WHERE o.id = ?', [id]);
|
||||
if (!o) throw notFound('Kunde nicht gefunden');
|
||||
const members = await query('SELECT u.id, u.email, u.name, u.status, m.role FROM memberships m JOIN users u ON u.id = m.user_id WHERE m.org_id = ? ORDER BY m.created_at', [id]);
|
||||
return {
|
||||
id: o.id as string, customerNumber: o.customer_number as string, name: o.name as string, customerType: o.customer_type as 'private' | 'business', status: o.status as string, createdAt: o.created_at as Date,
|
||||
billing: { company: o.company, contactName: o.contact_name, street: o.street, zip: o.zip, city: o.city, country: o.country, vatId: o.vat_id, billingEmail: o.billing_email, phone: o.phone },
|
||||
members: members.map((m) => ({ id: m.id as string, email: m.email as string, name: m.name as string, status: m.status as string, role: m.role as string })),
|
||||
};
|
||||
}
|
||||
132
apps/api/src/modules/domains/index.ts
Normal file
132
apps/api/src/modules/domains/index.ts
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { one, query, run, tx } from '../../core/db.js';
|
||||
import { audit } from '../../core/audit.js';
|
||||
import { requireAuth, requirePermission, clientIp } from '../../core/auth.js';
|
||||
import { badRequest, notFound } from '../../core/errors.js';
|
||||
import { rl } from '../../core/config.js';
|
||||
import type { KcModule } from '../../core/module.js';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { loadInstance } from '@kc/connectors';
|
||||
import { breakdown, checkAvailability, normalizeDomain, parsePriceList, sellPrice, splitDomain, type CostBasis, type Margin } from './logic.js';
|
||||
|
||||
const taxBp = async () => Number((await one("SELECT rate_bp FROM tax_rates WHERE name LIKE 'Regelsteuersatz%' LIMIT 1"))?.rate_bp ?? 1900);
|
||||
/** Preis-Schnappschuss für eine Domain: Einkauf brutto/netto und errechneter Verkauf brutto/netto. Null-Preise, wenn Endung fehlt oder kein Aufschlag festgelegt ist. */
|
||||
async function snapshot(tld: string) {
|
||||
const s = await settings(); const r = await one('SELECT * FROM domain_tlds WHERE tld = ?', [tld]); const tax = await taxBp();
|
||||
if (!r) return { termMonths: null, costNet: null, costGross: null, setup: 0, net: null, gross: null, tax };
|
||||
const o = offer(r, s.tier, s.margin, s.rounding, s.basis, tax);
|
||||
return { termMonths: o.termMonths, costNet: o.costNetCents, costGross: o.costGrossCents, setup: o.setupGrossCents, net: o.priceNetCents, gross: o.priceGrossCents, tax };
|
||||
}
|
||||
const recView = (r: any) => ({ id: r.id, domain: r.domain, tld: r.tld, orgId: r.org_id, customer: r.org_name ?? null, customerNumber: r.customer_number ?? null, resourceId: r.resource_id, source: r.source, termMonths: r.term_months,
|
||||
costNetCents: r.cost_net_cents, costGrossCents: r.cost_gross_cents, setupCostCents: r.setup_cost_cents, sellNetCents: r.sell_net_cents, taxBp: r.tax_bp, sellGrossCents: r.sell_gross_cents, profitNetCents: r.sell_net_cents === null || r.cost_net_cents === null ? null : r.sell_net_cents - r.cost_net_cents,
|
||||
procurement: r.procurement, orderedAt: r.ordered_at, orderedRef: r.ordered_ref, note: r.note, pricedAt: r.priced_at, createdAt: r.created_at });
|
||||
const REC_SELECT = 'SELECT d.*, o.name AS org_name, o.customer_number FROM domain_records d LEFT JOIN organizations o ON o.id = d.org_id';
|
||||
const SUGGEST = ['de', 'com', 'net', 'org', 'eu', 'info'];
|
||||
const marginIn = z.object({ type: z.enum(['percent', 'fixed']).nullable(), value: z.number().int().min(0).max(100_000_00).nullable() }).refine((m) => (m.type === null) === (m.value === null), 'Art und Wert gehören zusammen');
|
||||
async function settings() { const s = await one('SELECT tier, margin_type, margin_value, rounding, cost_basis FROM domain_settings WHERE id = 1'); return { tier: Number(s?.tier ?? 1), rounding: s ? !!s.rounding : true, basis: (s?.cost_basis ?? 'gross') as CostBasis, margin: { type: s?.margin_type ?? null, value: s?.margin_value ?? null } as Margin }; }
|
||||
const offer = (r: any, tier: number, g: Margin, round: boolean, basis: CostBasis, tax: number) => {
|
||||
const list = Number(r[`cost${tier}_cents`]); const own: Margin = { type: r.margin_type, value: r.margin_value }; const b = breakdown(list, own, g, round, basis, tax);
|
||||
const setupGross = basis === 'gross' ? r.setup_cents : r.setup_cents + Math.round((r.setup_cents * tax) / 10000);
|
||||
return { tld: r.tld, termMonths: r.term_months, ...b, setupGrossCents: setupGross as number, setupNetCents: Math.round((setupGross * 10000) / (10000 + tax)), taxBp: tax, margin: own, active: !!r.active };
|
||||
};
|
||||
|
||||
export const domainsModule: KcModule = {
|
||||
name: 'domains',
|
||||
permissions: { staff: { support: ['domains.read'], accounting: ['domains.read'], admin: ['domains.read', 'domains.write'], superadmin: ['domains.read', 'domains.write'] } },
|
||||
register(app: FastifyInstance) {
|
||||
// Domain prüfen (für alle angemeldeten Benutzer). Ohne Endung werden gängige Endungen geprüft. Einkaufspreise erscheinen hier nie.
|
||||
app.get('/domains/check', { config: rl(30, '1 minute') }, async (req) => {
|
||||
requireAuth(req);
|
||||
const { name } = z.object({ name: z.string().min(1).max(300) }).parse(req.query);
|
||||
const s = await settings(); const tax = await taxBp(); const rows = await query('SELECT * FROM domain_tlds WHERE active = 1'); const by = new Map(rows.map((r) => [r.tld as string, r]));
|
||||
const norm = normalizeDomain(name.includes('.') ? name : `${name}.de`); if (!norm) throw badRequest('Das ist kein gültiger Domainname. Erlaubt sind Buchstaben, Ziffern und Bindestriche.');
|
||||
const names = name.includes('.') ? [norm] : SUGGEST.filter((t) => by.has(t)).map((t) => `${norm.split('.')[0]}.${t}`);
|
||||
const results = await Promise.all(names.map(async (n) => {
|
||||
const sp = splitDomain(n, new Set(by.keys()))!; const o = by.get(sp.tld); const price = o ? offer(o, s.tier, s.margin, s.rounding, s.basis, tax) : null; const a = await checkAvailability(n);
|
||||
return { domain: n, tld: sp.tld, ...a, offer: price && price.priceGrossCents !== null ? { termMonths: price.termMonths, priceGrossCents: price.priceGrossCents, priceNetCents: price.priceNetCents!, taxBp: tax, setupGrossCents: price.setupGrossCents } : null, offered: !!o };
|
||||
}));
|
||||
return { results };
|
||||
});
|
||||
|
||||
app.get('/admin/domain-tlds', async (req) => {
|
||||
requirePermission(req, 'domains.read'); const s = await settings(); const tax = await taxBp();
|
||||
return { settings: { tier: s.tier, margin: s.margin, rounding: s.rounding, basis: s.basis, taxBp: tax }, tlds: (await query('SELECT * FROM domain_tlds ORDER BY tld')).map((r) => offer(r, s.tier, s.margin, s.rounding, s.basis, tax)) };
|
||||
});
|
||||
// ---- Domain-Aufstellung (Einkauf beim Registrar KCS vs. errechneter Verkauf) ----
|
||||
app.get('/admin/domain-records', async (req) => {
|
||||
requirePermission(req, 'domains.read'); const q = z.object({ status: z.enum(['open', 'ordered', 'external']).optional(), q: z.string().max(100).optional(), orgId: z.string().uuid().optional() }).parse(req.query);
|
||||
const rows = await query(`${REC_SELECT} WHERE (? IS NULL OR d.org_id = ?) AND (? IS NULL OR d.procurement = ?) AND (? IS NULL OR d.domain LIKE ?) ORDER BY d.created_at DESC LIMIT 2000`, [q.orgId ?? null, q.orgId ?? null, q.status ?? null, q.status ?? null, q.q ?? null, `%${q.q ?? ''}%`]);
|
||||
return rows.map(recView);
|
||||
});
|
||||
app.post('/admin/domain-records', async (req) => {
|
||||
const a = requirePermission(req, 'domains.write'); const b = z.object({ domain: z.string().max(300), orgId: z.string().uuid().nullable().optional(), procurement: z.enum(['open', 'ordered', 'external']).default('open'), note: z.string().max(300).optional() }).parse(req.body);
|
||||
const name = normalizeDomain(b.domain); if (!name || !name.includes('.')) throw badRequest('Das ist kein gültiger Domainname.');
|
||||
const known = new Set((await query('SELECT tld FROM domain_tlds')).map((r) => r.tld as string)); const sp = splitDomain(name, known)!;
|
||||
if (await one('SELECT 1 AS x FROM domain_records WHERE domain = ?', [name])) throw badRequest('Diese Domain ist bereits erfasst.');
|
||||
const p = await snapshot(sp.tld); const id = randomUUID();
|
||||
await run('INSERT INTO domain_records (id, domain, tld, org_id, source, term_months, cost_net_cents, cost_gross_cents, setup_cost_cents, sell_net_cents, tax_bp, sell_gross_cents, procurement, note, priced_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,UTC_TIMESTAMP(3))', [id, name, sp.tld, b.orgId ?? null, 'manual', p.termMonths, p.costNet, p.costGross, p.setup, p.net, p.tax, p.gross, b.procurement, b.note ?? null]);
|
||||
await audit({ actorType: 'user', actorId: a.user.id, action: 'domains.record.create', resourceType: 'domain_record', resourceId: id, after: { domain: name }, ip: clientIp(req) });
|
||||
return { id };
|
||||
});
|
||||
// Domains eines Hosting-Kontos aus dem Panel übernehmen (Subdomains und System-Domain werden ausgelassen)
|
||||
app.post('/admin/domain-records/from-resource/:id', async (req) => {
|
||||
const a = requirePermission(req, 'domains.write'); const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
|
||||
const r = await one('SELECT r.id, r.org_id, r.external_ref, r.instance_id, i.capabilities_json FROM resources r JOIN connector_instances i ON i.id = r.instance_id WHERE r.id = ?', [id]); if (!r) throw notFound();
|
||||
const { connector, ctx } = await loadInstance(r.instance_id, req.correlationId); if (!connector.children) throw badRequest('Diese Verbindung liefert keine Domains.');
|
||||
const list = await connector.children.list(ctx, r.external_ref, 'domain' as never) as { name: string; details?: { subdomain?: boolean; system?: boolean } }[];
|
||||
const known = new Set((await query('SELECT tld FROM domain_tlds')).map((x) => x.tld as string)); let added = 0, skipped = 0, unpriced = 0;
|
||||
for (const d of list) {
|
||||
const name = normalizeDomain(d.name); if (!name || d.details?.subdomain || d.details?.system) { skipped++; continue; }
|
||||
if (await one('SELECT 1 AS x FROM domain_records WHERE domain = ?', [name])) { skipped++; continue; }
|
||||
const sp = splitDomain(name, known); if (!sp) { skipped++; continue; } const p = await snapshot(sp.tld); if (p.net === null) unpriced++;
|
||||
await run('INSERT INTO domain_records (id, domain, tld, org_id, resource_id, source, term_months, cost_net_cents, cost_gross_cents, setup_cost_cents, sell_net_cents, tax_bp, sell_gross_cents, priced_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,UTC_TIMESTAMP(3))', [randomUUID(), name, sp.tld, r.org_id, id, 'keyhelp', p.termMonths, p.costNet, p.costGross, p.setup, p.net, p.tax, p.gross]); added++;
|
||||
}
|
||||
await audit({ actorType: 'user', actorId: a.user.id, action: 'domains.record.import', resourceType: 'resource', resourceId: id, after: { added, skipped }, ip: clientIp(req) });
|
||||
return { added, skipped, unpriced };
|
||||
});
|
||||
app.patch('/admin/domain-records/:id', async (req) => {
|
||||
const a = requirePermission(req, 'domains.write'); const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
|
||||
const b = z.object({ procurement: z.enum(['open', 'ordered', 'external']).optional(), orderedRef: z.string().max(100).nullable().optional(), note: z.string().max(300).nullable().optional(), orgId: z.string().uuid().nullable().optional(), reprice: z.boolean().optional() }).parse(req.body);
|
||||
const rec = await one('SELECT * FROM domain_records WHERE id = ?', [id]); if (!rec) throw notFound();
|
||||
if (b.procurement) await run('UPDATE domain_records SET procurement = ?, ordered_at = ? WHERE id = ?', [b.procurement, b.procurement === 'ordered' ? (rec.ordered_at ?? new Date()) : null, id]);
|
||||
if (b.orderedRef !== undefined) await run('UPDATE domain_records SET ordered_ref = ? WHERE id = ?', [b.orderedRef, id]);
|
||||
if (b.note !== undefined) await run('UPDATE domain_records SET note = ? WHERE id = ?', [b.note, id]);
|
||||
if (b.orgId !== undefined) await run('UPDATE domain_records SET org_id = ? WHERE id = ?', [b.orgId, id]);
|
||||
if (b.reprice) { const p = await snapshot(rec.tld); await run('UPDATE domain_records SET term_months=?, cost_net_cents=?, cost_gross_cents=?, setup_cost_cents=?, sell_net_cents=?, tax_bp=?, sell_gross_cents=?, priced_at=UTC_TIMESTAMP(3) WHERE id = ?', [p.termMonths, p.costNet, p.costGross, p.setup, p.net, p.tax, p.gross, id]); }
|
||||
await audit({ actorType: 'user', actorId: a.user.id, action: 'domains.record.update', resourceType: 'domain_record', resourceId: id, after: b, ip: clientIp(req) });
|
||||
return { ok: true };
|
||||
});
|
||||
app.delete('/admin/domain-records/:id', async (req) => {
|
||||
const a = requirePermission(req, 'domains.write'); const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
|
||||
await run('DELETE FROM domain_records WHERE id = ?', [id]); await audit({ actorType: 'user', actorId: a.user.id, action: 'domains.record.delete', resourceType: 'domain_record', resourceId: id, ip: clientIp(req) }); return { ok: true };
|
||||
});
|
||||
app.put('/admin/domain-settings', async (req) => {
|
||||
const a = requirePermission(req, 'domains.write');
|
||||
const b = z.object({ tier: z.number().int().min(1).max(4), margin: marginIn, rounding: z.boolean(), basis: z.enum(['gross', 'net']) }).parse(req.body);
|
||||
await run('UPDATE domain_settings SET tier = ?, margin_type = ?, margin_value = ?, rounding = ?, cost_basis = ? WHERE id = 1', [b.tier, b.margin.type, b.margin.value, b.rounding ? 1 : 0, b.basis]);
|
||||
await audit({ actorType: 'user', actorId: a.user.id, action: 'domains.settings', resourceType: 'domain_settings', resourceId: '1', after: b, ip: clientIp(req) });
|
||||
return { ok: true };
|
||||
});
|
||||
app.patch('/admin/domain-tlds/:tld', async (req) => {
|
||||
const a = requirePermission(req, 'domains.write'); const { tld } = z.object({ tld: z.string().max(63) }).parse(req.params);
|
||||
const b = z.object({ margin: marginIn.optional(), active: z.boolean().optional() }).parse(req.body);
|
||||
if (!(await one('SELECT 1 AS x FROM domain_tlds WHERE tld = ?', [tld]))) throw notFound();
|
||||
if (b.margin) await run('UPDATE domain_tlds SET margin_type = ?, margin_value = ? WHERE tld = ?', [b.margin.type, b.margin.value, tld]);
|
||||
if (b.active !== undefined) await run('UPDATE domain_tlds SET active = ? WHERE tld = ?', [b.active ? 1 : 0, tld]);
|
||||
await audit({ actorType: 'user', actorId: a.user.id, action: 'domains.tld.update', resourceType: 'domain_tld', resourceId: tld, after: b, ip: clientIp(req) });
|
||||
return { ok: true };
|
||||
});
|
||||
// Preisliste einlesen (Einkaufspreise); Aufschläge und Aktiv-Status bestehender Endungen bleiben erhalten.
|
||||
app.post('/admin/domain-tlds/import', async (req) => {
|
||||
const a = requirePermission(req, 'domains.write'); const { text, dryRun } = z.object({ text: z.string().min(1).max(500_000), dryRun: z.boolean().default(false) }).parse(req.body);
|
||||
const { rows, skipped } = parsePriceList(text); if (rows.length === 0) throw badRequest('Es wurden keine Preiszeilen erkannt. Erwartet: Endung, Laufzeit, vier Staffelpreise, optional Setup.');
|
||||
const existing = new Set((await query('SELECT tld FROM domain_tlds')).map((r) => r.tld as string)); const created = rows.filter((r) => !existing.has(r.tld)).length;
|
||||
if (!dryRun) {
|
||||
await tx(async (c) => { for (const r of rows) await run('INSERT INTO domain_tlds (tld, term_months, cost1_cents, cost2_cents, cost3_cents, cost4_cents, setup_cents) VALUES (?,?,?,?,?,?,?) ON DUPLICATE KEY UPDATE term_months=VALUES(term_months), cost1_cents=VALUES(cost1_cents), cost2_cents=VALUES(cost2_cents), cost3_cents=VALUES(cost3_cents), cost4_cents=VALUES(cost4_cents), setup_cents=VALUES(setup_cents)', [r.tld, r.termMonths, ...r.costs, r.setupCents], c); });
|
||||
await audit({ actorType: 'user', actorId: a.user.id, action: 'domains.import', resourceType: 'domain_tld', resourceId: 'bulk', after: { rows: rows.length, created }, ip: clientIp(req) });
|
||||
}
|
||||
return { rows: rows.length, created, updated: rows.length - created, skipped, dryRun };
|
||||
});
|
||||
},
|
||||
};
|
||||
93
apps/api/src/modules/domains/logic.ts
Normal file
93
apps/api/src/modules/domains/logic.ts
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
import { domainToASCII } from 'node:url';
|
||||
import { promises as dns } from 'node:dns';
|
||||
|
||||
export type MarginType = 'percent' | 'fixed';
|
||||
export interface Margin { type: MarginType | null; value: number | null }
|
||||
/** Kaufmännische Rundung nach oben: Cent-Anteil 00–50 → ,50; 51–99 → ,99 (z. B. 12,00 → 12,50; 12,51 → 12,99). */
|
||||
export function roundPrice(cents: number): number { const euro = Math.floor(cents / 100), c = cents % 100; return euro * 100 + (c <= 50 ? 50 : 99); }
|
||||
/** Verkaufspreis (netto, Cent) = Einkauf + Gewinnaufschlag; null, wenn kein Aufschlag festgelegt ist. Prozent in Basispunkten. */
|
||||
export function sellPrice(costCents: number, own: Margin, global: Margin, round = false): number | null {
|
||||
const m = own.type && own.value !== null ? own : global; if (!m.type || m.value === null) return null;
|
||||
const p = costCents + (m.type === 'percent' ? Math.round((costCents * m.value) / 10000) : m.value); return round ? roundPrice(p) : p;
|
||||
}
|
||||
|
||||
export type CostBasis = 'gross' | 'net';
|
||||
export interface Breakdown { costGrossCents: number; costNetCents: number; priceGrossCents: number | null; priceNetCents: number | null; profitNetCents: number | null }
|
||||
/** Preise aus dem Listenpreis: Ist die Liste brutto (inkl. USt), wird Netto herausgerechnet, nie erneut USt aufgeschlagen. Rundung und Aufschlag wirken auf die Listenbasis. */
|
||||
export function breakdown(listCost: number, own: Margin, global: Margin, round: boolean, basis: CostBasis, taxBp: number): Breakdown {
|
||||
const toNet = (g: number) => Math.round((g * 10000) / (10000 + taxBp)); const vat = (n: number) => Math.round((n * taxBp) / 10000);
|
||||
const p = sellPrice(listCost, own, global, round);
|
||||
const costGross = basis === 'gross' ? listCost : listCost + vat(listCost); const costNet = basis === 'gross' ? toNet(listCost) : listCost;
|
||||
if (p === null) return { costGrossCents: costGross, costNetCents: costNet, priceGrossCents: null, priceNetCents: null, profitNetCents: null };
|
||||
const g = basis === 'gross' ? p : p + vat(p); const n = basis === 'gross' ? toNet(p) : p;
|
||||
return { costGrossCents: costGross, costNetCents: costNet, priceGrossCents: g, priceNetCents: n, profitNetCents: n - costNet };
|
||||
}
|
||||
export interface ParsedRow { tld: string; termMonths: number; costs: [number, number, number, number]; setupCents: number }
|
||||
const num = (s: string): number | null => {
|
||||
const t = s.replace(/[€\s]/g, ''); if (!t) return null;
|
||||
const n = t.includes(',') ? t.replace(/\./g, '').replace(',', '.') : t; // deutsches Format 1.380,00 oder 26.60
|
||||
return /^\d+(\.\d+)?$/.test(n) ? Math.round(Number(n) * 100) : null;
|
||||
};
|
||||
/** Liest eine Preisliste (Tabulator/Leerzeichen getrennt): Endung, Laufzeit, 4 Staffelpreise, optional Setup. Kopf-/Fußzeilen werden übersprungen. */
|
||||
export function parsePriceList(text: string): { rows: ParsedRow[]; skipped: string[] } {
|
||||
const rows: ParsedRow[] = []; const skipped: string[] = []; const seen = new Set<string>();
|
||||
for (const raw of text.split(/\r?\n/)) {
|
||||
const line = raw.trim(); if (!line || line.startsWith('#')) continue;
|
||||
const c = line.split(/[\t ]+/).filter((x) => x !== '€');
|
||||
const tld = (c[0] ?? '').toLowerCase().replace(/^\./, '');
|
||||
if (!/^[a-z0-9]([a-z0-9.-]{0,60}[a-z0-9])?$/.test(tld) || !/^\d{1,3}$/.test(c[1] ?? '')) { if (!/^Domaintyp/i.test(line)) skipped.push(line.slice(0, 80)); continue; }
|
||||
const p = [2, 3, 4, 5].map((i) => num(c[i] ?? '')); const setup = c[6] !== undefined ? num(c[6]) : 0;
|
||||
if (p.some((x) => x === null) || setup === null) { skipped.push(line.slice(0, 80)); continue; }
|
||||
if (seen.has(tld)) { skipped.push(`doppelt: ${tld}`); continue; } seen.add(tld);
|
||||
rows.push({ tld, termMonths: Number(c[1]), costs: p as [number, number, number, number], setupCents: setup });
|
||||
}
|
||||
return { rows, skipped };
|
||||
}
|
||||
|
||||
/** Normalisiert Eingaben wie „https://www.Beispiel.de/x“ zu „beispiel.de“ (Punycode). */
|
||||
export function normalizeDomain(input: string): string | null {
|
||||
let s = input.trim().toLowerCase().replace(/^[a-z]+:\/\//, '').replace(/[/?#].*$/, '').replace(/^www\./, '').replace(/\.$/, '');
|
||||
if (!s || s.length > 253) return null; s = domainToASCII(s); if (!s) return null;
|
||||
return s.split('.').every((l) => /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/.test(l)) ? s : null;
|
||||
}
|
||||
/** Trennt Name und Endung; bekannte mehrteilige Endungen (co.uk) werden bevorzugt. */
|
||||
export function splitDomain(name: string, known: Set<string>): { label: string; tld: string } | null {
|
||||
const parts = name.split('.'); if (parts.length < 2) return null;
|
||||
for (let i = 1; i < parts.length; i++) { const tld = parts.slice(i).join('.'); if (known.has(tld) && parts.slice(0, i).length === 1) return { label: parts[0]!, tld }; }
|
||||
for (let i = 1; i < parts.length; i++) { const tld = parts.slice(i).join('.'); if (known.has(tld)) return { label: parts.slice(0, i).join('.'), tld }; }
|
||||
return { label: parts.slice(0, -1).join('.'), tld: parts[parts.length - 1]! };
|
||||
}
|
||||
|
||||
export type Availability = { status: 'available' | 'registered' | 'unknown'; method: 'rdap' | 'dns'; note?: string };
|
||||
/** Ergänzung für Endungen, die nicht in der IANA-Liste stehen, aber eine RDAP-Auskunft anbieten. */
|
||||
const EXTRA_RDAP: Record<string, string> = { de: 'https://rdap.denic.de/' };
|
||||
let boot: { at: number; map: Map<string, string> } | null = null;
|
||||
async function rdapBase(tld: string): Promise<string | null> {
|
||||
if (!boot || Date.now() - boot.at > 24 * 3600_000) {
|
||||
const r = await fetch('https://data.iana.org/rdap/dns.json', { signal: AbortSignal.timeout(8000) }); if (!r.ok) throw new Error(`IANA ${r.status}`);
|
||||
const j = (await r.json()) as { services: [string[], string[]][] }; const map = new Map<string, string>();
|
||||
for (const [tlds, urls] of j.services) { const u = urls.find((x) => x.startsWith('https://')); if (u) for (const t of tlds) map.set(t.toLowerCase(), u.endsWith('/') ? u : `${u}/`); }
|
||||
boot = { at: Date.now(), map };
|
||||
}
|
||||
const last = tld.split('.').pop()!; return boot.map.get(last) ?? EXTRA_RDAP[last] ?? null;
|
||||
}
|
||||
async function viaDns(name: string): Promise<Availability> {
|
||||
try { await dns.resolveNs(name); return { status: 'registered', method: 'dns' }; } catch (e) {
|
||||
const c = (e as { code?: string }).code;
|
||||
if (c === 'ENOTFOUND') return { status: 'available', method: 'dns', note: 'Ermittelt über DNS, ohne Gewähr. Vor der Bestellung wird beim Registrar erneut geprüft.' };
|
||||
if (c === 'ENODATA') { try { await dns.resolveSoa(name); return { status: 'registered', method: 'dns' }; } catch { return { status: 'unknown', method: 'dns' }; } }
|
||||
return { status: 'unknown', method: 'dns' };
|
||||
}
|
||||
}
|
||||
/** Prüft, ob eine Domain vergeben ist: bevorzugt per RDAP (Registry-Auskunft), sonst per DNS. */
|
||||
export async function checkAvailability(name: string): Promise<Availability> {
|
||||
try {
|
||||
const base = await rdapBase(name.split('.').slice(1).join('.'));
|
||||
if (base) {
|
||||
const r = await fetch(`${base}domain/${encodeURIComponent(name)}`, { signal: AbortSignal.timeout(7000), headers: { accept: 'application/rdap+json, application/json' }, redirect: 'follow' });
|
||||
if (r.status === 200) return { status: 'registered', method: 'rdap' };
|
||||
if (r.status === 404) return { status: 'available', method: 'rdap' };
|
||||
}
|
||||
} catch { /* Fallback auf DNS */ }
|
||||
return viaDns(name);
|
||||
}
|
||||
277
apps/api/src/modules/identity/index.ts
Normal file
277
apps/api/src/modules/identity/index.ts
Normal file
|
|
@ -0,0 +1,277 @@
|
|||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { hash, verify } from '@node-rs/argon2';
|
||||
import * as OTPAuth from 'otpauth';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { rl } from '../../core/config.js';
|
||||
import { one, query, run, tx } from '../../core/db.js';
|
||||
import { audit } from '../../core/audit.js';
|
||||
import { COOKIE, clientIp, createSession, requireAuth, requirePermission } from '../../core/auth.js';
|
||||
import { badRequest, conflict, forbidden, notFound, unauthorized } from '../../core/errors.js';
|
||||
import { decrypt, encrypt, sha256 } from '../../core/crypto.js';
|
||||
import { can, staffPermissions } from '../../core/policy.js';
|
||||
import { createInvitedUser, createToken, mailInvite, inviteLink, resetLink } from '../../core/accounts.js';
|
||||
import { sendMail } from '../../core/mail.js';
|
||||
import type { KcModule } from '../../core/module.js';
|
||||
|
||||
const ARGON = { memoryCost: 19456, timeCost: 2, parallelism: 1 };
|
||||
const MAX_FAILS = 5;
|
||||
const LOCK_MINUTES = 15;
|
||||
const password = z.string().min(12, 'Mindestens 12 Zeichen').max(200);
|
||||
const email = z.string().email().max(254).transform((s) => s.toLowerCase());
|
||||
// Scheinhash gegen Timing-Unterschiede bei unbekannten Benutzern
|
||||
const DUMMY = await hash('dummy-password-for-timing', ARGON);
|
||||
|
||||
function totp(secret: string, label: string): OTPAuth.TOTP {
|
||||
return new OTPAuth.TOTP({ issuer: 'Kundencenter', label, algorithm: 'SHA1', digits: 6, period: 30, secret: OTPAuth.Secret.fromBase32(secret) });
|
||||
}
|
||||
/** Prüft TOTP mit ±1 Schritt und verhindert Wiederverwendung desselben Schritts. */
|
||||
async function checkTotp(userId: string, code: string): Promise<boolean> {
|
||||
const row = await one('SELECT secret_enc, last_step FROM mfa_totp WHERE user_id = ?', [userId]);
|
||||
if (!row) return false;
|
||||
const t = totp(decrypt(row.secret_enc), 'x');
|
||||
const delta = t.validate({ token: code.replace(/\s/g, ''), window: 1 });
|
||||
if (delta === null) return false;
|
||||
const step = Math.floor(Date.now() / 30000) + delta;
|
||||
if (row.last_step !== null && Number(row.last_step) >= step) return false;
|
||||
await run('UPDATE mfa_totp SET last_step = ? WHERE user_id = ?', [step, userId]);
|
||||
return true;
|
||||
}
|
||||
async function useRecoveryCode(userId: string, code: string): Promise<boolean> {
|
||||
const r = await run('UPDATE recovery_codes SET used_at = UTC_TIMESTAMP(3) WHERE user_id = ? AND code_hash = ? AND used_at IS NULL', [userId, sha256(code.trim().toLowerCase())]);
|
||||
return r.affectedRows === 1;
|
||||
}
|
||||
const publicUser = (u: { id: string; email: string; name: string; kind: string; staff_role: string | null; status: string }) => ({ id: u.id, email: u.email, name: u.name, kind: u.kind, staffRole: u.staff_role, status: u.status });
|
||||
|
||||
export const identityModule: KcModule = {
|
||||
name: 'identity',
|
||||
register(app: FastifyInstance) {
|
||||
// ---- Login -------------------------------------------------------------
|
||||
app.post('/auth/login', { config: rl(10, '1 minute') }, async (req, reply) => {
|
||||
const body = z.object({ email, password: z.string().max(200) }).parse(req.body);
|
||||
const u = await one('SELECT * FROM users WHERE email = ?', [body.email]);
|
||||
const locked = u?.locked_until && new Date(u.locked_until as Date) > new Date();
|
||||
const ok = await verify(u?.password_hash ?? DUMMY, body.password).catch(() => false);
|
||||
if (!u || !u.password_hash || !ok || u.status !== 'active' || locked) {
|
||||
if (u && !locked && u.status === 'active') {
|
||||
const fails = Number(u.failed_logins) + 1;
|
||||
await run('UPDATE users SET failed_logins = ?, locked_until = ? WHERE id = ?', [fails, fails >= MAX_FAILS ? new Date(Date.now() + LOCK_MINUTES * 60000) : null, u.id]);
|
||||
}
|
||||
await audit({ actorType: 'anonymous', action: 'auth.login', result: 'denied', errorClass: locked ? 'locked' : 'invalid_credentials', correlationId: req.correlationId, ip: clientIp(req), after: { email: body.email } });
|
||||
throw unauthorized('E-Mail oder Passwort falsch', 'INVALID_CREDENTIALS');
|
||||
}
|
||||
await run('UPDATE users SET failed_logins = 0, locked_until = NULL WHERE id = ?', [u.id]);
|
||||
if (req.auth) await run('UPDATE sessions SET revoked_at = UTC_TIMESTAMP(3) WHERE id = ?', [req.auth.sessionId]); // keine Session-Fixation
|
||||
const hasMfa = !!(await one('SELECT 1 AS x FROM mfa_totp WHERE user_id = ? AND confirmed_at IS NOT NULL', [u.id]));
|
||||
const s = await createSession(reply, u.id, { pendingMfa: hasMfa, ip: clientIp(req), ua: req.headers['user-agent'] ?? '' });
|
||||
await audit({ actorType: 'user', actorId: u.id, action: hasMfa ? 'auth.login.password_ok' : 'auth.login', correlationId: req.correlationId, ip: clientIp(req) });
|
||||
return { status: hasMfa ? 'mfa_required' : 'ok', csrf: s.csrf };
|
||||
});
|
||||
|
||||
app.post('/auth/mfa/verify', { config: rl(10, '1 minute') }, async (req) => {
|
||||
const a = requireAuth(req, { allowPendingMfa: true });
|
||||
if (!a.pendingMfa) return { status: 'ok' };
|
||||
const { code } = z.object({ code: z.string().min(6).max(20) }).parse(req.body);
|
||||
const good = /^\d{6}$/.test(code.replace(/\s/g, '')) ? await checkTotp(a.user.id, code) : await useRecoveryCode(a.user.id, code);
|
||||
if (!good) {
|
||||
await audit({ actorType: 'user', actorId: a.user.id, action: 'auth.mfa', result: 'denied', correlationId: req.correlationId, ip: clientIp(req) });
|
||||
throw unauthorized('Code ungültig', 'INVALID_MFA');
|
||||
}
|
||||
await run('UPDATE sessions SET pending_mfa = 0, mfa_verified = 1 WHERE id = ?', [a.sessionId]);
|
||||
await audit({ actorType: 'user', actorId: a.user.id, action: 'auth.login', correlationId: req.correlationId, ip: clientIp(req) });
|
||||
return { status: 'ok' };
|
||||
});
|
||||
|
||||
app.post('/auth/logout', async (req, reply) => {
|
||||
if (req.auth) {
|
||||
await run('UPDATE sessions SET revoked_at = UTC_TIMESTAMP(3) WHERE id = ?', [req.auth.sessionId]);
|
||||
await audit({ actorType: 'user', actorId: req.auth.user.id, action: 'auth.logout', correlationId: req.correlationId, ip: clientIp(req) });
|
||||
}
|
||||
reply.clearCookie(COOKIE, { path: '/' });
|
||||
return { status: 'ok' };
|
||||
});
|
||||
|
||||
app.get('/auth/me', async (req) => {
|
||||
const a = requireAuth(req, { allowPendingMfa: true, allowUnenrolledStaff: true });
|
||||
const orgs = a.principal.memberships.length
|
||||
? await query('SELECT o.id, o.name, o.customer_number, m.role FROM memberships m JOIN organizations o ON o.id = m.org_id WHERE m.user_id = ?', [a.user.id])
|
||||
: [];
|
||||
return {
|
||||
user: a.user, kind: a.principal.kind, staffRole: a.principal.staffRole, permissions: staffPermissions(a.principal.staffRole),
|
||||
pendingMfa: a.pendingMfa, mfaEnrolled: a.mfaEnrolled, mfaEnrollRequired: a.principal.kind === 'staff' && !a.mfaEnrolled,
|
||||
organizations: orgs.map((o) => ({ id: o.id, name: o.name, customerNumber: o.customer_number, role: o.role })), csrf: a.csrf,
|
||||
};
|
||||
});
|
||||
|
||||
// ---- MFA-Einrichtung ---------------------------------------------------
|
||||
app.post('/auth/mfa/setup', async (req) => {
|
||||
const a = requireAuth(req, { allowUnenrolledStaff: true });
|
||||
if (a.mfaEnrolled) throw conflict('2FA ist bereits aktiv', 'MFA_ALREADY_ENABLED');
|
||||
const secret = new OTPAuth.Secret({ size: 20 });
|
||||
await run('REPLACE INTO mfa_totp (user_id, secret_enc) VALUES (?,?)', [a.user.id, encrypt(secret.base32)]);
|
||||
return { secret: secret.base32, otpauthUrl: totp(secret.base32, a.user.email).toString() };
|
||||
});
|
||||
app.post('/auth/mfa/confirm', async (req) => {
|
||||
const a = requireAuth(req, { allowUnenrolledStaff: true });
|
||||
const { code } = z.object({ code: z.string().regex(/^\d{6}$/) }).parse(req.body);
|
||||
const row = await one('SELECT confirmed_at FROM mfa_totp WHERE user_id = ?', [a.user.id]);
|
||||
if (!row || row.confirmed_at) throw badRequest('Keine offene 2FA-Einrichtung', 'NO_PENDING_MFA');
|
||||
if (!(await checkTotp(a.user.id, code))) throw badRequest('Code ungültig', 'INVALID_MFA');
|
||||
const codes = Array.from({ length: 10 }, () => randomBytes(5).toString('hex').replace(/(.{5})(.{5})/, '$1-$2'));
|
||||
await tx(async (c) => {
|
||||
await run('UPDATE mfa_totp SET confirmed_at = UTC_TIMESTAMP(3) WHERE user_id = ?', [a.user.id], c);
|
||||
await run('DELETE FROM recovery_codes WHERE user_id = ?', [a.user.id], c);
|
||||
for (const code2 of codes) await run('INSERT INTO recovery_codes (user_id, code_hash) VALUES (?,?)', [a.user.id, sha256(code2)], c);
|
||||
});
|
||||
await audit({ actorType: 'user', actorId: a.user.id, action: 'auth.mfa.enable', resourceType: 'user', resourceId: a.user.id, correlationId: req.correlationId, ip: clientIp(req) });
|
||||
return { recoveryCodes: codes }; // einmalige Anzeige
|
||||
});
|
||||
|
||||
/** 2FA entfernen (z. B. neues Handy): Passwort + aktueller Code oder Wiederherstellungscode nötig. Danach kann neu eingerichtet werden. */
|
||||
app.post('/auth/mfa/disable', { config: rl(5, '10 minutes') }, async (req) => {
|
||||
const a = requireAuth(req, { allowUnenrolledStaff: true });
|
||||
if (!a.mfaEnrolled) throw badRequest('2FA ist nicht aktiv', 'MFA_NOT_ENABLED');
|
||||
const b = z.object({ password: z.string().max(200), code: z.string().min(6).max(20) }).parse(req.body);
|
||||
const u = await one('SELECT password_hash FROM users WHERE id = ?', [a.user.id]);
|
||||
const pwOk = !!u?.password_hash && (await verify(u.password_hash, b.password).catch(() => false));
|
||||
const codeOk = pwOk && (/^\d{6}$/.test(b.code.replace(/\s/g, '')) ? await checkTotp(a.user.id, b.code) : await useRecoveryCode(a.user.id, b.code));
|
||||
if (!pwOk || !codeOk) {
|
||||
await audit({ actorType: 'user', actorId: a.user.id, action: 'auth.mfa.disable', result: 'denied', correlationId: req.correlationId, ip: clientIp(req) });
|
||||
throw forbidden('Passwort oder Code falsch', 'INVALID_CREDENTIALS');
|
||||
}
|
||||
await tx(async (c) => { await run('DELETE FROM recovery_codes WHERE user_id = ?', [a.user.id], c); await run('DELETE FROM mfa_totp WHERE user_id = ?', [a.user.id], c); });
|
||||
await run('UPDATE sessions SET revoked_at = UTC_TIMESTAMP(3) WHERE user_id = ? AND id <> ? AND revoked_at IS NULL', [a.user.id, a.sessionId]);
|
||||
await audit({ actorType: 'user', actorId: a.user.id, action: 'auth.mfa.disable', resourceType: 'user', resourceId: a.user.id, correlationId: req.correlationId, ip: clientIp(req) });
|
||||
return { status: 'ok' };
|
||||
});
|
||||
|
||||
// ---- Passwort ----------------------------------------------------------
|
||||
app.post('/auth/password/change', async (req) => {
|
||||
const a = requireAuth(req, { allowUnenrolledStaff: true });
|
||||
const b = z.object({ current: z.string(), next: password, repeat: z.string() }).parse(req.body);
|
||||
if (b.next !== b.repeat) throw badRequest('Die neuen Passwörter stimmen nicht überein', 'PASSWORD_MISMATCH');
|
||||
const u = await one('SELECT password_hash FROM users WHERE id = ?', [a.user.id]);
|
||||
if (!u?.password_hash || !(await verify(u.password_hash, b.current))) throw forbidden('Aktuelles Passwort falsch', 'INVALID_CREDENTIALS');
|
||||
await run('UPDATE users SET password_hash = ? WHERE id = ?', [await hash(b.next, ARGON), a.user.id]);
|
||||
await run('UPDATE sessions SET revoked_at = UTC_TIMESTAMP(3) WHERE user_id = ? AND id <> ? AND revoked_at IS NULL', [a.user.id, a.sessionId]);
|
||||
await audit({ actorType: 'user', actorId: a.user.id, action: 'auth.password.change', resourceType: 'user', resourceId: a.user.id, correlationId: req.correlationId, ip: clientIp(req) });
|
||||
return { status: 'ok' };
|
||||
});
|
||||
app.post('/auth/password/forgot', { config: rl(5, '10 minutes') }, async (req) => {
|
||||
const { email: e } = z.object({ email }).parse(req.body);
|
||||
const u = await one('SELECT id, name FROM users WHERE email = ? AND status = \'active\'', [e]);
|
||||
if (u) {
|
||||
const t = await createToken(undefined, u.id, 'password_reset');
|
||||
await sendMail(e, 'password_reset', 'Passwort zurücksetzen', `Hallo ${u.name},\n\nüber diesen Link können Sie Ihr Passwort zurücksetzen (1 Stunde gültig):\n${resetLink(t)}\n\nWenn Sie das nicht angefordert haben, ignorieren Sie diese E-Mail.\n`);
|
||||
await audit({ actorType: 'anonymous', action: 'auth.password.forgot', resourceType: 'user', resourceId: u.id, correlationId: req.correlationId, ip: clientIp(req) });
|
||||
}
|
||||
return { status: 'accepted' }; // immer gleiche Antwort
|
||||
});
|
||||
async function consumeToken(token: string, purpose: 'invite' | 'password_reset') {
|
||||
const r = await one('SELECT id, user_id FROM user_tokens WHERE token_hash = ? AND purpose = ? AND used_at IS NULL AND expires_at > UTC_TIMESTAMP(3)', [sha256(token), purpose]);
|
||||
if (!r) throw badRequest('Link ungültig oder abgelaufen', 'INVALID_TOKEN');
|
||||
const upd = await run('UPDATE user_tokens SET used_at = UTC_TIMESTAMP(3) WHERE id = ? AND used_at IS NULL', [r.id]);
|
||||
if (upd.affectedRows !== 1) throw badRequest('Link ungültig oder abgelaufen', 'INVALID_TOKEN');
|
||||
return r.user_id as string;
|
||||
}
|
||||
app.post('/auth/password/reset', { config: rl(10, '10 minutes') }, async (req) => {
|
||||
const b = z.object({ token: z.string().min(20).max(100), password, repeat: z.string() }).parse(req.body);
|
||||
if (b.password !== b.repeat) throw badRequest('Die Passwörter stimmen nicht überein', 'PASSWORD_MISMATCH');
|
||||
const uid = await consumeToken(b.token, 'password_reset');
|
||||
await run('UPDATE users SET password_hash = ?, failed_logins = 0, locked_until = NULL WHERE id = ?', [await hash(b.password, ARGON), uid]);
|
||||
await run('UPDATE sessions SET revoked_at = UTC_TIMESTAMP(3) WHERE user_id = ? AND revoked_at IS NULL', [uid]);
|
||||
await audit({ actorType: 'user', actorId: uid, action: 'auth.password.reset', resourceType: 'user', resourceId: uid, correlationId: req.correlationId, ip: clientIp(req) });
|
||||
return { status: 'ok' };
|
||||
});
|
||||
app.post('/auth/invite/accept', { config: rl(10, '10 minutes') }, async (req) => {
|
||||
const b = z.object({ token: z.string().min(20).max(100), password, repeat: z.string() }).parse(req.body);
|
||||
if (b.password !== b.repeat) throw badRequest('Die Passwörter stimmen nicht überein', 'PASSWORD_MISMATCH');
|
||||
const uid = await consumeToken(b.token, 'invite');
|
||||
await run('UPDATE users SET password_hash = ?, status = \'active\', email_verified_at = UTC_TIMESTAMP(3) WHERE id = ? AND status = \'invited\'', [await hash(b.password, ARGON), uid]);
|
||||
await audit({ actorType: 'user', actorId: uid, action: 'auth.invite.accept', resourceType: 'user', resourceId: uid, correlationId: req.correlationId, ip: clientIp(req) });
|
||||
return { status: 'ok' };
|
||||
});
|
||||
|
||||
// ---- Sitzungen ---------------------------------------------------------
|
||||
app.get('/auth/sessions', async (req) => {
|
||||
const a = requireAuth(req);
|
||||
const rows = await query('SELECT id, ip, user_agent, created_at, last_seen_at FROM sessions WHERE user_id = ? AND revoked_at IS NULL AND expires_at > UTC_TIMESTAMP(3) ORDER BY last_seen_at DESC', [a.user.id]);
|
||||
return rows.map((r) => ({ id: r.id, ip: r.ip, userAgent: r.user_agent, createdAt: r.created_at, lastSeenAt: r.last_seen_at, current: r.id === a.sessionId }));
|
||||
});
|
||||
app.delete('/auth/sessions/:id', async (req) => {
|
||||
const a = requireAuth(req);
|
||||
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
|
||||
const r = await run('UPDATE sessions SET revoked_at = UTC_TIMESTAMP(3) WHERE id = ? AND user_id = ? AND revoked_at IS NULL', [id, a.user.id]);
|
||||
if (!r.affectedRows) throw notFound();
|
||||
await audit({ actorType: 'user', actorId: a.user.id, action: 'auth.session.revoke', resourceType: 'session', resourceId: id, correlationId: req.correlationId, ip: clientIp(req) });
|
||||
return { status: 'ok' };
|
||||
});
|
||||
|
||||
// ---- Benutzerverwaltung (Staff) ---------------------------------------
|
||||
app.get('/admin/users', async (req) => {
|
||||
requirePermission(req, 'users.read');
|
||||
const q = z.object({ q: z.string().max(100).optional(), kind: z.enum(['customer', 'staff']).optional() }).parse(req.query);
|
||||
const rows = await query(
|
||||
`SELECT id, email, name, kind, staff_role, status, created_at,
|
||||
(SELECT COUNT(*) FROM mfa_totp m WHERE m.user_id = users.id AND m.confirmed_at IS NOT NULL) AS mfa
|
||||
FROM users WHERE (? IS NULL OR email LIKE CONCAT('%', ?, '%') OR name LIKE CONCAT('%', ?, '%')) AND (? IS NULL OR kind = ?)
|
||||
ORDER BY created_at DESC LIMIT 200`,
|
||||
[q.q ?? null, q.q ?? null, q.q ?? null, q.kind ?? null, q.kind ?? null],
|
||||
);
|
||||
return rows.map((r) => ({ ...publicUser(r as never), mfa: Number(r.mfa) > 0, createdAt: r.created_at }));
|
||||
});
|
||||
app.post('/admin/users', async (req) => {
|
||||
const a = requirePermission(req, 'users.write');
|
||||
const b = z.object({ email, name: z.string().min(1).max(150), staffRole: z.enum(['support', 'accounting', 'admin', 'superadmin']) }).parse(req.body);
|
||||
if ((b.staffRole === 'admin' || b.staffRole === 'superadmin') && !can(a.principal, 'users.write_privileged')) throw forbidden('Nur Superadministratoren dürfen Administratoren anlegen', 'PRIVILEGED_ONLY');
|
||||
if (await one('SELECT 1 AS x FROM users WHERE email = ?', [b.email])) throw conflict('E-Mail bereits vergeben', 'EMAIL_EXISTS');
|
||||
const inv = await tx((c) => createInvitedUser(c, { email: b.email, name: b.name, kind: 'staff', staffRole: b.staffRole }));
|
||||
const mail = await mailInvite(b.email, b.name, inv.token);
|
||||
await audit({ actorType: 'user', actorId: a.user.id, action: 'user.create', resourceType: 'user', resourceId: inv.userId, correlationId: req.correlationId, ip: clientIp(req), after: { email: b.email, kind: 'staff', staffRole: b.staffRole } });
|
||||
return { id: inv.userId, mail, inviteLink: mail === 'sent' ? undefined : inviteLink(inv.token) };
|
||||
});
|
||||
/** Support-/Admin-Reset der 2FA (Kunde hat Handy und Wiederherstellungscodes verloren). Beendet alle Sitzungen; bei Mitarbeitern muss neu eingerichtet werden. */
|
||||
app.post('/admin/users/:id/mfa-reset', async (req) => {
|
||||
const a = requirePermission(req, 'users.write');
|
||||
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
|
||||
const u = await one('SELECT id, kind, staff_role FROM users WHERE id = ?', [id]);
|
||||
if (!u) throw notFound();
|
||||
if (id === a.user.id) throw forbidden('Eigene 2FA bitte unter „Mein Konto“ zurücksetzen', 'SELF_CHANGE');
|
||||
if (u.kind === 'staff' && !can(a.principal, 'users.write_privileged')) throw forbidden('Nur Superadministratoren dürfen die 2FA von Mitarbeitern zurücksetzen', 'PRIVILEGED_ONLY');
|
||||
await tx(async (c) => { await run('DELETE FROM recovery_codes WHERE user_id = ?', [id], c); await run('DELETE FROM mfa_totp WHERE user_id = ?', [id], c); });
|
||||
await run('UPDATE sessions SET revoked_at = UTC_TIMESTAMP(3) WHERE user_id = ? AND revoked_at IS NULL', [id]);
|
||||
await audit({ actorType: 'user', actorId: a.user.id, action: 'user.mfa.reset', resourceType: 'user', resourceId: id, correlationId: req.correlationId, ip: clientIp(req) });
|
||||
return { status: 'ok' };
|
||||
});
|
||||
/** Einladung (erneut) senden: für eingeladene Benutzer, z. B. nach einer Kundenübernahme (dort wird bewusst nichts versendet). */
|
||||
app.post('/admin/users/:id/reinvite', async (req) => {
|
||||
const a = requirePermission(req, 'users.write');
|
||||
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
|
||||
const u = await one('SELECT id, email, name, kind, status, staff_role FROM users WHERE id = ?', [id]);
|
||||
if (!u) throw notFound();
|
||||
if (u.status !== 'invited') throw badRequest('Nur eingeladene Benutzer haben eine offene Einladung', 'NOT_INVITED');
|
||||
if (u.kind === 'staff' && !can(a.principal, 'users.write_privileged')) throw forbidden('Nur Superadministratoren', 'PRIVILEGED_ONLY');
|
||||
await run("UPDATE user_tokens SET used_at = UTC_TIMESTAMP(3) WHERE user_id = ? AND purpose = 'invite' AND used_at IS NULL", [id]);
|
||||
const token = await createToken(undefined, id, 'invite');
|
||||
const mail = await mailInvite(u.email, u.name, token);
|
||||
await audit({ actorType: 'user', actorId: a.user.id, action: 'user.reinvite', resourceType: 'user', resourceId: id, correlationId: req.correlationId, ip: clientIp(req), after: { mail } });
|
||||
return { mail, inviteLink: mail === 'sent' ? undefined : inviteLink(token) };
|
||||
});
|
||||
app.patch('/admin/users/:id', async (req) => {
|
||||
const a = requirePermission(req, 'users.write');
|
||||
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
|
||||
const b = z.object({ status: z.enum(['active', 'disabled']).optional(), staffRole: z.enum(['support', 'accounting', 'admin', 'superadmin']).optional() }).parse(req.body);
|
||||
const u = await one('SELECT * FROM users WHERE id = ?', [id]);
|
||||
if (!u) throw notFound();
|
||||
if (id === a.user.id) throw forbidden('Eigenes Konto kann hier nicht geändert werden', 'SELF_CHANGE');
|
||||
const privileged = (r: unknown) => r === 'admin' || r === 'superadmin';
|
||||
if ((privileged(u.staff_role) || privileged(b.staffRole)) && !can(a.principal, 'users.write_privileged')) throw forbidden('Nur Superadministratoren', 'PRIVILEGED_ONLY');
|
||||
if (b.staffRole && u.kind !== 'staff') throw badRequest('Nur für Mitarbeiter');
|
||||
if (b.status === 'active' && u.status === 'invited') throw badRequest('Eingeladene Benutzer aktivieren sich selbst');
|
||||
await run('UPDATE users SET status = COALESCE(?, status), staff_role = COALESCE(?, staff_role) WHERE id = ?', [b.status ?? null, b.staffRole ?? null, id]);
|
||||
if (b.status === 'disabled') await run('UPDATE sessions SET revoked_at = UTC_TIMESTAMP(3) WHERE user_id = ? AND revoked_at IS NULL', [id]);
|
||||
await audit({ actorType: 'user', actorId: a.user.id, action: 'user.update', resourceType: 'user', resourceId: id, correlationId: req.correlationId, ip: clientIp(req), before: { status: u.status, staffRole: u.staff_role }, after: b });
|
||||
return { status: 'ok' };
|
||||
});
|
||||
},
|
||||
};
|
||||
14
apps/api/src/modules/index.ts
Normal file
14
apps/api/src/modules/index.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import type { KcModule } from '../core/module.js';
|
||||
import { systemModule } from './system/index.js';
|
||||
import { identityModule } from './identity/index.js';
|
||||
import { customersModule } from './customers/index.js';
|
||||
import { auditModule } from './audit/index.js';
|
||||
import { connectorsModule } from './connectors/index.js';
|
||||
import { resourcesModule } from './resources/index.js';
|
||||
import { domainsModule } from './domains/index.js';
|
||||
import { catalogModule } from './catalog/index.js';
|
||||
import { ordersModule } from './orders/index.js';
|
||||
import { backupModule } from './backup/index.js';
|
||||
|
||||
/** Aktive Module. Neue Module (Produkte, Verträge, Connectoren, Tickets, Rechnungen) werden hier eingetragen. */
|
||||
export const modules: KcModule[] = [systemModule, identityModule, customersModule, auditModule, connectorsModule, resourcesModule, catalogModule, domainsModule, ordersModule, backupModule];
|
||||
213
apps/api/src/modules/orders/index.ts
Normal file
213
apps/api/src/modules/orders/index.ts
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { PoolConnection } from 'mysql2/promise';
|
||||
import { calculatePrice } from '@kc/platform/pricing';
|
||||
import { ORDER_MACHINE, CONTRACT_MACHINE, transition, type OrderEvent } from '@kc/platform/statemachine';
|
||||
import { consumerTerms, effectiveCancelDate } from '@kc/platform/contractterms';
|
||||
import { one, query, run, tx } from '../../core/db.js';
|
||||
import { audit } from '../../core/audit.js';
|
||||
import { enqueue } from '../../core/jobs.js';
|
||||
import { clientIp, requireAuth, requirePermission, type AuthContext } from '../../core/auth.js';
|
||||
import { badRequest, conflict, forbidden, notFound } from '../../core/errors.js';
|
||||
import { can, canInOrg } from '../../core/policy.js';
|
||||
import type { KcModule } from '../../core/module.js';
|
||||
|
||||
const j = (v: unknown, d: unknown = null) => (v == null ? d : typeof v === 'string' ? JSON.parse(v) : v);
|
||||
async function nextNumber(c: PoolConnection, name: 'order' | 'contract', prefix: string): Promise<string> {
|
||||
await run('UPDATE number_sequences SET next_value = LAST_INSERT_ID(next_value + 1) WHERE name = ?', [name], c);
|
||||
return `${prefix}-${(await one('SELECT LAST_INSERT_ID() AS n', [], c))!.n}`;
|
||||
}
|
||||
const orderView = (o: any, items: any[] = []) => ({
|
||||
id: o.id, number: o.number, orgId: o.org_id, orgName: o.org_name, status: o.status, placedVia: o.placed_via, approvalRequired: !!o.approval_required, note: o.note,
|
||||
failureNote: o.failure_note, failureAmbiguous: !!o.failure_ambiguous, createdAt: o.created_at, approvedAt: o.approved_at,
|
||||
items: items.map((i) => ({ id: i.id, quantity: i.quantity, discountBp: i.discount_bp, snapshot: j(i.snapshot_json), contractId: i.contract_id ?? null, contractNumber: i.contract_number ?? null })),
|
||||
});
|
||||
const contractView = (c: any) => ({
|
||||
id: c.id, number: c.number, orgId: c.org_id, orgName: c.org_name, status: c.status, productName: c.product_name, startedAt: c.started_at, termEnd: c.term_end, renewal: c.renewal, renewalTermMonths: c.renewal_term_months,
|
||||
noticeDays: c.notice_days, cancelRequestedAt: c.cancel_requested_at, cancelEffectiveAt: c.cancel_effective_at, cancelledAt: c.cancelled_at, resourceId: c.resource_id, price: j(c.price_snapshot_json), createdAt: c.created_at,
|
||||
});
|
||||
const ORDER_SQL = 'SELECT o.*, g.name AS org_name FROM orders o JOIN organizations g ON g.id = o.org_id';
|
||||
async function loadOrder(id: string) {
|
||||
const o = await one(`${ORDER_SQL} WHERE o.id = ?`, [id]);
|
||||
if (!o) return null;
|
||||
const items = await query('SELECT oi.*, c.id AS contract_id, c.number AS contract_number FROM order_items oi LEFT JOIN contracts c ON c.order_item_id = oi.id WHERE oi.order_id = ?', [id]);
|
||||
return { o, items };
|
||||
}
|
||||
const CONTRACT_SQL = `SELECT c.*, g.name AS org_name, JSON_VALUE(c.price_snapshot_json, '$.name') AS product_name FROM contracts c JOIN organizations g ON g.id = c.org_id`;
|
||||
|
||||
/** Wendet ein Ereignis des Bestell-Statusautomaten an (atomar über den erwarteten Ausgangszustand). */
|
||||
async function orderEvent(c: PoolConnection | undefined, id: string, from: string, ev: OrderEvent, extra: { sql?: string; params?: unknown[] } = {}): Promise<string> {
|
||||
const to = transition(ORDER_MACHINE, from as never, ev);
|
||||
const r = await run(`UPDATE orders SET status = ?${extra.sql ? ', ' + extra.sql : ''} WHERE id = ? AND status = ?`, [to, ...(extra.params ?? []), id, from], c);
|
||||
if (!r.affectedRows) throw conflict('Die Bestellung wurde inzwischen geändert. Bitte neu laden.', 'STALE_STATE');
|
||||
return to;
|
||||
}
|
||||
const startProvisioning = (orderId: string, key: string, correlationId: string) => enqueue('order.provision', { orderId }, { idempotencyKey: key, correlationId });
|
||||
const orderAccess = (a: AuthContext, orgId: string) => canInOrg(a.principal, orgId, 'orders.read', 'orders.read');
|
||||
|
||||
export const ordersModule: KcModule = {
|
||||
name: 'orders',
|
||||
permissions: {
|
||||
staff: {
|
||||
support: ['orders.read', 'contracts.read'], accounting: ['orders.read', 'contracts.read'],
|
||||
admin: ['orders.read', 'orders.write', 'orders.approve', 'contracts.read', 'contracts.write'], superadmin: ['orders.read', 'orders.write', 'orders.approve', 'contracts.read', 'contracts.write'],
|
||||
},
|
||||
org: { owner: ['orders.read', 'orders.create', 'contracts.read', 'contracts.cancel'], admin: ['orders.read', 'orders.create', 'contracts.read', 'contracts.cancel'], member: ['orders.read', 'contracts.read'] },
|
||||
},
|
||||
register(app: FastifyInstance) {
|
||||
// ---- Bestellung anlegen ------------------------------------------------
|
||||
app.post('/orders', async (req) => {
|
||||
const a = requireAuth(req);
|
||||
const b = z.object({ orgId: z.string().uuid(), note: z.string().trim().max(500).optional(), items: z.array(z.object({ productId: z.string().uuid(), quantity: z.number().int().min(1).max(100).default(1), discountBp: z.number().int().min(0).max(10000).default(0) })).min(1).max(20) }).parse(req.body);
|
||||
const staff = can(a.principal, 'orders.write');
|
||||
if (!canInOrg(a.principal, b.orgId, 'orders.create', 'orders.write')) { if (!canInOrg(a.principal, b.orgId, 'orders.read', 'orders.read')) throw notFound(); throw forbidden(); }
|
||||
const org = await one('SELECT id, status, customer_type FROM organizations WHERE id = ?', [b.orgId]);
|
||||
if (!org) throw notFound();
|
||||
if (org.status !== 'active') throw badRequest('Für gesperrte oder beendete Kunden kann nichts bestellt werden', 'ORG_INACTIVE');
|
||||
|
||||
const lines: { p: any; it: { productId: string; quantity: number; discountBp: number }; snapshot: any }[] = [];
|
||||
for (const it of b.items) {
|
||||
const p = await one(`SELECT p.*, v.id AS vid, v.version, v.name AS vname, v.tax_bp, v.price_basis, v.setup_cents, v.recurring_cents, v.currency, v.billing_interval, v.term_months, v.renewal, v.renewal_term_months, v.notice_days
|
||||
FROM products p JOIN product_versions v ON v.id = p.current_version_id WHERE p.id = ?`, [it.productId]);
|
||||
if (!p || p.status !== 'active') throw badRequest('Produkt nicht verfügbar', 'PRODUCT_UNAVAILABLE');
|
||||
if (!staff && !p.orderable_by_customer) throw badRequest('Dieses Produkt kann nicht selbst bestellt werden', 'NOT_ORDERABLE');
|
||||
if (!staff && it.discountBp > 0) throw forbidden('Rabatte können nur vom Personal gewährt werden', 'DISCOUNT_FORBIDDEN');
|
||||
if (p.connector_instance_id && it.quantity !== 1) throw badRequest('Provisionierte Produkte können nur einzeln bestellt werden', 'QUANTITY_NOT_ALLOWED');
|
||||
// Preis IMMER serverseitig aus der aktuellen Produktversion; Eingaben des Clients beeinflussen den Preis nicht
|
||||
const price = calculatePrice({ basis: p.price_basis, setupCents: p.setup_cents, recurringCents: p.recurring_cents, taxBp: p.tax_bp, interval: p.billing_interval, quantity: it.quantity, discountBp: it.discountBp });
|
||||
let terms = { termMonths: p.term_months as number, renewal: p.renewal as 'auto' | 'none', renewalTermMonths: p.renewal_term_months as number, noticeDays: p.notice_days as number };
|
||||
if (org.customer_type === 'private' && terms.renewal === 'auto') terms = { ...terms, ...consumerTerms(terms.renewalTermMonths, terms.noticeDays) }; // Verbraucherregel, rechtlich zu prüfen
|
||||
lines.push({ p, it, snapshot: { productId: p.id, sku: p.sku, productVersionId: p.vid, version: p.version, name: p.vname, category: p.category, customerType: org.customer_type, ...price, terms } });
|
||||
}
|
||||
const approvalRequired = !staff && lines.some((l) => !!l.p.requires_approval); // Personal bestellt = freigegeben
|
||||
const orderId = randomUUID();
|
||||
const number = await tx(async (c) => {
|
||||
const n = await nextNumber(c, 'order', 'B');
|
||||
await run('INSERT INTO orders (id, number, org_id, status, placed_by, placed_via, approval_required, approved_by, approved_at, note) VALUES (?,?,?,?,?,?,?,?,?,?)',
|
||||
[orderId, n, b.orgId, approvalRequired ? 'pending_approval' : 'approved', a.user.id, staff ? 'staff' : 'customer', approvalRequired ? 1 : 0, approvalRequired ? null : a.user.id, approvalRequired ? null : new Date(), b.note ?? null], c);
|
||||
for (const l of lines) {
|
||||
const itemId = randomUUID();
|
||||
await run('INSERT INTO order_items (id, order_id, product_version_id, quantity, discount_bp, snapshot_json) VALUES (?,?,?,?,?,?)', [itemId, orderId, l.snapshot.productVersionId, l.it.quantity, l.it.discountBp, JSON.stringify(l.snapshot)], c);
|
||||
await run("INSERT INTO contracts (id, number, org_id, order_item_id, product_version_id, status, renewal, renewal_term_months, notice_days, price_snapshot_json) VALUES (?,?,?,?,?,'pending',?,?,?,?)",
|
||||
[randomUUID(), await nextNumber(c, 'contract', 'V'), b.orgId, itemId, l.snapshot.productVersionId, l.snapshot.terms.renewal, l.snapshot.terms.renewalTermMonths, l.snapshot.terms.noticeDays, JSON.stringify(l.snapshot)], c);
|
||||
}
|
||||
if (!approvalRequired) { await orderEvent(c, orderId, 'approved', 'start_provisioning'); await startProvisioning(orderId, `provision:${orderId}:1`, req.correlationId); }
|
||||
return n;
|
||||
});
|
||||
await audit({ actorType: 'user', actorId: a.user.id, orgId: b.orgId, action: 'order.create', resourceType: 'order', resourceId: orderId, correlationId: req.correlationId, ip: clientIp(req), after: { number, approvalRequired, items: lines.map((l) => ({ sku: l.snapshot.sku, quantity: l.it.quantity, discountBp: l.it.discountBp })) } });
|
||||
return { id: orderId, number, status: approvalRequired ? 'pending_approval' : 'provisioning' };
|
||||
});
|
||||
|
||||
app.get('/orders', async (req) => {
|
||||
const a = requireAuth(req);
|
||||
const q = z.object({ org: z.string().uuid().optional(), status: z.string().max(30).optional() }).parse(req.query);
|
||||
const staff = can(a.principal, 'orders.read');
|
||||
const orgs = staff ? (q.org ? [q.org] : null) : a.principal.memberships.map((m) => m.orgId);
|
||||
if (orgs && !orgs.length) return [];
|
||||
const where: string[] = []; const params: unknown[] = [];
|
||||
if (orgs) { where.push(`o.org_id IN (${orgs.map(() => '?').join(',')})`); params.push(...orgs); }
|
||||
if (q.status) { where.push('o.status = ?'); params.push(q.status); }
|
||||
const rows = await query(`${ORDER_SQL} ${where.length ? 'WHERE ' + where.join(' AND ') : ''} ORDER BY o.created_at DESC LIMIT 300`, params);
|
||||
return rows.map((o) => orderView(o));
|
||||
});
|
||||
app.get('/orders/:id', async (req) => {
|
||||
const a = requireAuth(req);
|
||||
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
|
||||
const r = await loadOrder(id);
|
||||
if (!r || !orderAccess(a, r.o.org_id)) throw notFound();
|
||||
return orderView(r.o, r.items);
|
||||
});
|
||||
|
||||
// ---- Freigabe, Ablehnung, Storno, Wiederholung -------------------------
|
||||
app.post('/admin/orders/:id/approve', async (req) => {
|
||||
const a = requirePermission(req, 'orders.approve');
|
||||
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
|
||||
const r = await loadOrder(id); if (!r) throw notFound();
|
||||
await tx(async (c) => { await orderEvent(c, id, r.o.status, 'approve', { sql: 'approved_by = ?, approved_at = ?', params: [a.user.id, new Date()] }); await orderEvent(c, id, 'approved', 'start_provisioning'); await startProvisioning(id, `provision:${id}:1`, req.correlationId); });
|
||||
await audit({ actorType: 'user', actorId: a.user.id, orgId: r.o.org_id, action: 'order.approve', resourceType: 'order', resourceId: id, correlationId: req.correlationId, ip: clientIp(req) });
|
||||
return { status: 'provisioning' };
|
||||
});
|
||||
app.post('/admin/orders/:id/reject', async (req) => {
|
||||
const a = requirePermission(req, 'orders.approve');
|
||||
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
|
||||
const b = z.object({ reason: z.string().trim().max(500).optional() }).parse(req.body ?? {});
|
||||
const r = await loadOrder(id); if (!r) throw notFound();
|
||||
await tx(async (c) => { await orderEvent(c, id, r.o.status, 'reject', { sql: 'failure_note = ?', params: [b.reason ?? null] }); await run("UPDATE contracts SET status = 'cancelled', cancelled_at = UTC_TIMESTAMP(3) WHERE order_item_id IN (SELECT id FROM order_items WHERE order_id = ?) AND status = 'pending'", [id], c); });
|
||||
await audit({ actorType: 'user', actorId: a.user.id, orgId: r.o.org_id, action: 'order.reject', resourceType: 'order', resourceId: id, correlationId: req.correlationId, ip: clientIp(req), after: { reason: b.reason } });
|
||||
return { status: 'rejected' };
|
||||
});
|
||||
app.post('/orders/:id/cancel', async (req) => {
|
||||
const a = requireAuth(req);
|
||||
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
|
||||
const r = await loadOrder(id);
|
||||
if (!r || !orderAccess(a, r.o.org_id)) throw notFound();
|
||||
// Kunden dürfen nur ihre noch nicht freigegebene Bestellung zurückziehen
|
||||
const allowed = can(a.principal, 'orders.write') || (r.o.status === 'pending_approval' && canInOrg(a.principal, r.o.org_id, 'orders.create', 'orders.write'));
|
||||
if (!allowed) throw forbidden();
|
||||
await tx(async (c) => { await orderEvent(c, id, r.o.status, 'cancel'); await run("UPDATE contracts SET status = 'cancelled', cancelled_at = UTC_TIMESTAMP(3) WHERE order_item_id IN (SELECT id FROM order_items WHERE order_id = ?) AND status = 'pending'", [id], c); });
|
||||
await audit({ actorType: 'user', actorId: a.user.id, orgId: r.o.org_id, action: 'order.cancel', resourceType: 'order', resourceId: id, correlationId: req.correlationId, ip: clientIp(req) });
|
||||
return { status: 'cancelled' };
|
||||
});
|
||||
/** Fehlgeschlagene Bereitstellung erneut starten. Bei unklarem Ausgang beim Anbieter ist eine ausdrückliche Bestätigung nötig (Doppelanlage!). */
|
||||
app.post('/admin/orders/:id/retry', async (req) => {
|
||||
const a = requirePermission(req, 'orders.approve');
|
||||
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
|
||||
const b = z.object({ confirmChecked: z.boolean().default(false) }).parse(req.body ?? {});
|
||||
const r = await loadOrder(id); if (!r) throw notFound();
|
||||
if (r.o.failure_ambiguous && !b.confirmChecked) throw badRequest('Bitte bestätigen, dass beim Anbieter geprüft wurde, dass nichts angelegt wurde', 'CONFIRM_REQUIRED');
|
||||
await tx(async (c) => { await orderEvent(c, id, r.o.status, 'retry', { sql: 'failure_note = NULL, failure_ambiguous = 0' }); await startProvisioning(id, `provision:${id}:retry:${Date.now()}`, req.correlationId); });
|
||||
await audit({ actorType: 'user', actorId: a.user.id, orgId: r.o.org_id, action: 'order.retry', resourceType: 'order', resourceId: id, correlationId: req.correlationId, ip: clientIp(req), after: { ambiguousConfirmed: b.confirmChecked } });
|
||||
return { status: 'provisioning' };
|
||||
});
|
||||
|
||||
// ---- Verträge ------------------------------------------------------------
|
||||
app.get('/contracts', async (req) => {
|
||||
const a = requireAuth(req);
|
||||
const q = z.object({ org: z.string().uuid().optional(), status: z.string().max(30).optional() }).parse(req.query);
|
||||
const staff = can(a.principal, 'contracts.read');
|
||||
const orgs = staff ? (q.org ? [q.org] : null) : a.principal.memberships.map((m) => m.orgId);
|
||||
if (orgs && !orgs.length) return [];
|
||||
const where: string[] = []; const params: unknown[] = [];
|
||||
if (orgs) { where.push(`c.org_id IN (${orgs.map(() => '?').join(',')})`); params.push(...orgs); }
|
||||
if (q.status) { where.push('c.status = ?'); params.push(q.status); }
|
||||
return (await query(`${CONTRACT_SQL} ${where.length ? 'WHERE ' + where.join(' AND ') : ''} ORDER BY c.created_at DESC LIMIT 300`, params)).map(contractView);
|
||||
});
|
||||
app.get('/contracts/:id', async (req) => {
|
||||
const a = requireAuth(req);
|
||||
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
|
||||
const c = await one(`${CONTRACT_SQL} WHERE c.id = ?`, [id]);
|
||||
if (!c || !canInOrg(a.principal, c.org_id, 'contracts.read', 'contracts.read')) throw notFound();
|
||||
return { ...contractView(c), canCancel: ['active', 'suspended'].includes(c.status) && !c.cancel_requested_at && canInOrg(a.principal, c.org_id, 'contracts.cancel', 'contracts.write') };
|
||||
});
|
||||
/** Kündigung: zum nächstmöglichen Termin unter Beachtung von Laufzeit und Frist; sofort nur durch Personal. */
|
||||
app.post('/contracts/:id/cancel', async (req) => {
|
||||
const a = requireAuth(req);
|
||||
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
|
||||
const b = z.object({ immediate: z.boolean().default(false) }).parse(req.body ?? {});
|
||||
const c = await one(`${CONTRACT_SQL} WHERE c.id = ?`, [id]);
|
||||
if (!c || !canInOrg(a.principal, c.org_id, 'contracts.read', 'contracts.read')) throw notFound();
|
||||
if (!canInOrg(a.principal, c.org_id, 'contracts.cancel', 'contracts.write')) throw forbidden();
|
||||
if (b.immediate && !can(a.principal, 'contracts.write')) throw forbidden('Sofortige Beendigung nur durch das Personal', 'STAFF_ONLY');
|
||||
if (!['active', 'suspended'].includes(c.status)) throw badRequest('Dieser Vertrag kann nicht gekündigt werden', 'NOT_CANCELLABLE');
|
||||
if (c.cancel_requested_at) throw conflict('Die Kündigung wurde bereits eingereicht', 'ALREADY_CANCELLED');
|
||||
transition(CONTRACT_MACHINE, c.status, 'cancel'); // Prüfung, dass die Kündigung im Zustand erlaubt ist
|
||||
const now = new Date();
|
||||
const effective = b.immediate ? now : effectiveCancelDate(now, { termEnd: c.term_end ? new Date(c.term_end) : null, renewal: c.renewal, renewalTermMonths: Number(c.renewal_term_months), noticeDays: Number(c.notice_days) });
|
||||
await run('UPDATE contracts SET cancel_requested_at = ?, cancel_effective_at = ? WHERE id = ? AND cancel_requested_at IS NULL', [now, effective, id]);
|
||||
await audit({ actorType: 'user', actorId: a.user.id, orgId: c.org_id, action: 'contract.cancel.request', resourceType: 'contract', resourceId: id, correlationId: req.correlationId, ip: clientIp(req), after: { effectiveAt: effective.toISOString(), immediate: b.immediate } });
|
||||
return { cancelEffectiveAt: effective.toISOString() };
|
||||
});
|
||||
app.post('/contracts/:id/cancel/revoke', async (req) => {
|
||||
const a = requireAuth(req);
|
||||
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
|
||||
const c = await one(`${CONTRACT_SQL} WHERE c.id = ?`, [id]);
|
||||
if (!c || !canInOrg(a.principal, c.org_id, 'contracts.read', 'contracts.read')) throw notFound();
|
||||
if (!canInOrg(a.principal, c.org_id, 'contracts.cancel', 'contracts.write')) throw forbidden();
|
||||
if (!c.cancel_requested_at || new Date(c.cancel_effective_at) <= new Date()) throw badRequest('Keine widerrufbare Kündigung vorhanden', 'NOT_REVOCABLE');
|
||||
await run("UPDATE contracts SET cancel_requested_at = NULL, cancel_effective_at = NULL WHERE id = ? AND status IN ('active','suspended')", [id]);
|
||||
await audit({ actorType: 'user', actorId: a.user.id, orgId: c.org_id, action: 'contract.cancel.revoke', resourceType: 'contract', resourceId: id, correlationId: req.correlationId, ip: clientIp(req) });
|
||||
return { status: 'ok' };
|
||||
});
|
||||
},
|
||||
};
|
||||
258
apps/api/src/modules/resources/index.ts
Normal file
258
apps/api/src/modules/resources/index.ts
Normal file
|
|
@ -0,0 +1,258 @@
|
|||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { randomUUID, createHash } from 'node:crypto';
|
||||
import { ACTION_CAPABILITY, ACTIONS, loadInstance, type ActionName } from '@kc/connectors';
|
||||
import { ConnectorError, type ChildKind } from '@kc/connector-sdk';
|
||||
import { encrypt } from '../../core/crypto.js';
|
||||
import { CHILD_MANAGE, CUSTOMER_ACTIONS } from '../../core/actions.js';
|
||||
import { rl } from '../../core/config.js';
|
||||
import { DESTRUCTIVE_ACTIONS } from '@kc/connector-sdk';
|
||||
import { one, query, run } from '../../core/db.js';
|
||||
import { audit } from '../../core/audit.js';
|
||||
import { enqueue } from '../../core/jobs.js';
|
||||
import { clientIp, requireAuth, requirePermission, type AuthContext } from '../../core/auth.js';
|
||||
import { AppError, badRequest, forbidden, notFound } from '../../core/errors.js';
|
||||
import { can, canInOrg } from '../../core/policy.js';
|
||||
import type { KcModule } from '../../core/module.js';
|
||||
|
||||
const STALE_FACTOR = 3;
|
||||
const json = <T>(v: unknown, d: T): T => (v == null ? d : typeof v === 'string' ? JSON.parse(v) : (v as T));
|
||||
|
||||
/** Ressource + Instanzzustand; "stale" = Provider gestört oder Daten älter als 3 Abgleichintervalle. */
|
||||
async function loadResource(id: string) {
|
||||
return one('SELECT r.*, i.connector_key, i.name AS instance_name, i.health, i.health_message, i.sync_interval_sec, i.capabilities_json, i.enabled FROM resources r JOIN connector_instances i ON i.id = r.instance_id WHERE r.id = ?', [id]);
|
||||
}
|
||||
function view(r: any, staff: boolean, a?: AuthContext) {
|
||||
const stale = r.health !== 'ok' || Date.now() - new Date(r.synced_at).getTime() > STALE_FACTOR * r.sync_interval_sec * 1000;
|
||||
return {
|
||||
id: r.id, type: r.type, name: r.name, state: r.state, orgId: r.org_id, validFrom: r.valid_from, validUntil: r.valid_until, syncedAt: r.synced_at, missing: !!r.missing_since,
|
||||
canReveal: a ? canReveal(r, a) : undefined,
|
||||
stale, staleReason: r.health !== 'ok' ? (r.health_message ?? 'Der Dienst ist derzeit nicht erreichbar.') : stale ? 'Die Daten sind älter als erwartet.' : null,
|
||||
...(staff ? { instance: r.instance_name, connector: r.connector_key, externalRef: r.external_ref } : {}),
|
||||
};
|
||||
}
|
||||
/** Erlaubte Aktionen = Connector-Fähigkeit ∧ Rolle ∧ Ressourcenregel (Kunden nur freigegebene). */
|
||||
function allowedActions(r: any, a: AuthContext): ActionName[] {
|
||||
const caps = json<string[]>(r.capabilities_json, []);
|
||||
const supported = ACTIONS.filter((x) => caps.includes(ACTION_CAPABILITY[x]) && !DESTRUCTIVE_ACTIONS.has(x));
|
||||
if (r.health !== 'ok' || !r.enabled) return [];
|
||||
if (can(a.principal, 'resources.write')) return supported;
|
||||
const orgOk = r.org_id && canInOrg(a.principal, r.org_id, 'resources.manage', 'resources.write');
|
||||
return orgOk ? supported.filter((x) => json<string[]>(r.customer_actions, []).includes(x)) : [];
|
||||
}
|
||||
const childCapsTop = (r: any): string[] => json<string[]>(r.capabilities_json, []);
|
||||
const canLoginTop = (r: any, a: AuthContext): boolean => childCapsTop(r).includes('sso.login') && r.health === 'ok' && !!r.enabled && (can(a.principal, 'resources.write') || (!!r.org_id && canInOrg(a.principal, r.org_id, 'resources.manage', 'resources.write') && json<string[]>(r.customer_actions, []).includes('panel.login')));
|
||||
/** Zugangsdaten/Schlüssel anzeigen: Personal mit Schreibrecht oder Inhaber/Admin der zugehörigen Organisation; Anbieter muss es unterstützen und erreichbar sein. */
|
||||
function canReveal(r: any, a: AuthContext): boolean {
|
||||
const caps = json<string[]>(r.capabilities_json, []);
|
||||
if (!caps.includes('secret.reveal') || !r.enabled) return false;
|
||||
return can(a.principal, 'resources.write') || (!!r.org_id && canInOrg(a.principal, r.org_id, 'resources.manage', 'resources.write'));
|
||||
}
|
||||
function access(a: AuthContext, r: any): boolean {
|
||||
return can(a.principal, 'resources.read') || (!!r.org_id && canInOrg(a.principal, r.org_id, 'resources.read', 'resources.read'));
|
||||
}
|
||||
|
||||
export const resourcesModule: KcModule = {
|
||||
name: 'resources',
|
||||
permissions: {
|
||||
staff: { support: ['resources.read'], accounting: ['resources.read'], admin: ['resources.read', 'resources.write'], superadmin: ['resources.read', 'resources.write'] },
|
||||
org: { owner: ['resources.read', 'resources.manage'], admin: ['resources.read', 'resources.manage'], member: ['resources.read'] },
|
||||
},
|
||||
register(app: FastifyInstance) {
|
||||
app.get('/resources', async (req) => {
|
||||
const a = requireAuth(req);
|
||||
const q = z.object({ org: z.string().uuid().optional(), unassigned: z.enum(['1']).optional(), type: z.string().max(30).optional() }).parse(req.query);
|
||||
const staff = can(a.principal, 'resources.read');
|
||||
const orgs = staff ? (q.org ? [q.org] : null) : a.principal.memberships.map((m) => m.orgId);
|
||||
if (orgs && orgs.length === 0) return [];
|
||||
const where: string[] = []; const params: unknown[] = [];
|
||||
if (orgs) { where.push(`r.org_id IN (${orgs.map(() => '?').join(',')})`); params.push(...orgs); }
|
||||
if (q.unassigned && staff) where.push('r.org_id IS NULL');
|
||||
if (q.type) { where.push('r.type = ?'); params.push(q.type); }
|
||||
const rows = await query(`SELECT r.*, i.connector_key, i.name AS instance_name, i.health, i.health_message, i.sync_interval_sec, i.capabilities_json, i.enabled FROM resources r JOIN connector_instances i ON i.id = r.instance_id ${where.length ? 'WHERE ' + where.join(' AND ') : ''} ORDER BY r.name LIMIT 500`, params);
|
||||
return rows.map((r) => view(r, staff, a));
|
||||
});
|
||||
|
||||
app.get('/resources/:id', async (req) => {
|
||||
const a = requireAuth(req);
|
||||
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
|
||||
const r = await loadResource(id);
|
||||
if (!r || !access(a, r)) throw notFound();
|
||||
const staff = can(a.principal, 'resources.read');
|
||||
const jobs = await query("SELECT id, status, last_error, attempts, created_at, updated_at, JSON_VALUE(payload, '$.action') AS action FROM jobs WHERE type = 'connector.execute' AND JSON_VALUE(payload, '$.resourceId') = ? ORDER BY created_at DESC LIMIT 10", [id]);
|
||||
const data = json<{ limits?: object; usage?: object; details?: object }>(r.data_json, {});
|
||||
return { ...view(r, staff), limits: data.limits ?? {}, usage: data.usage ?? {}, details: data.details ?? {}, allowedActions: allowedActions(r, a), canReveal: canReveal(r, a), hasChildren: childCapsTop(r).includes('children.read'), canLogin: canLoginTop(r, a), customerActions: staff ? json(r.customer_actions, []) : undefined, jobs: jobs.map((j) => ({ id: j.id, action: j.action, status: j.status, error: j.last_error, attempts: j.attempts, createdAt: j.created_at, updatedAt: j.updated_at })) };
|
||||
});
|
||||
|
||||
app.post('/resources/:id/actions', async (req, reply) => {
|
||||
const a = requireAuth(req);
|
||||
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
|
||||
const b = z.object({ action: z.enum(['suspend', 'unsuspend', 'extend']), days: z.number().int().min(1).max(3650).optional() }).parse(req.body);
|
||||
const r = await loadResource(id);
|
||||
if (!r || !access(a, r)) throw notFound();
|
||||
if (!allowedActions(r, a).includes(b.action)) throw forbidden('Diese Aktion ist für diese Ressource nicht verfügbar', 'ACTION_NOT_ALLOWED');
|
||||
const params: Record<string, unknown> = {};
|
||||
if (b.action === 'extend') {
|
||||
if (!b.days) throw badRequest('Anzahl Tage fehlt');
|
||||
const from = r.valid_until && new Date(r.valid_until) > new Date() ? new Date(r.valid_until) : new Date();
|
||||
params.until = new Date(from.getTime() + b.days * 86400000).toISOString(); // absoluter Zielwert => bei Wiederholung wirkungsgleich
|
||||
params.days = b.days;
|
||||
}
|
||||
// Idempotenz: Header vom Client (pro Bestätigungsdialog), sonst Hash aus Ressource/Aktion/Parameter im Minutenfenster
|
||||
const hdr = req.headers['idempotency-key'];
|
||||
const key = `act:${typeof hdr === 'string' && /^[\w-]{8,100}$/.test(hdr) ? hdr : createHash('sha256').update(`${id}|${b.action}|${JSON.stringify(params)}|${Math.floor(Date.now() / 60000)}`).digest('hex').slice(0, 40)}`;
|
||||
const existing = await one('SELECT id FROM jobs WHERE idempotency_key = ?', [key]);
|
||||
const jobId = existing?.id ?? randomUUID();
|
||||
if (!existing) {
|
||||
try {
|
||||
await run('INSERT INTO jobs (id, type, payload, idempotency_key, correlation_id) VALUES (?,?,?,?,?)', [jobId, 'connector.execute', JSON.stringify({ resourceId: id, action: b.action, params, actorUserId: a.user.id }), key, req.correlationId]);
|
||||
} catch (e) {
|
||||
if ((e as { code?: string }).code !== 'ER_DUP_ENTRY') throw e; // parallele Doppelanfrage: bestehenden Auftrag zurückgeben
|
||||
const dup = await one('SELECT id FROM jobs WHERE idempotency_key = ?', [key]);
|
||||
return reply.code(202).send({ jobId: dup!.id, duplicate: true });
|
||||
}
|
||||
await audit({ actorType: 'user', actorId: a.user.id, orgId: r.org_id, action: `resource.${b.action}.request`, resourceType: 'resource', resourceId: id, connector: r.connector_key, correlationId: req.correlationId, ip: clientIp(req), after: { jobId, params } });
|
||||
}
|
||||
return reply.code(202).send({ jobId, duplicate: !!existing });
|
||||
});
|
||||
|
||||
/** Schlüssel/Zugangsdaten auf Abruf: live beim Anbieter gelesen, nie gespeichert oder protokolliert (nur DASS abgerufen wurde). */
|
||||
app.post('/resources/:id/reveal', { config: rl(10, '1 minute') }, async (req, reply) => {
|
||||
const a = requireAuth(req);
|
||||
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
|
||||
const r = await loadResource(id);
|
||||
if (!r || !access(a, r)) throw notFound();
|
||||
if (!canReveal(r, a)) throw forbidden('Der Zugriff auf Zugangsdaten ist für diese Ressource nicht möglich', 'REVEAL_FORBIDDEN');
|
||||
let items: { label: string; value: string }[];
|
||||
try {
|
||||
const { connector, ctx } = await loadInstance(r.instance_id, req.correlationId);
|
||||
if (!connector.reveal) throw badRequest('Nicht unterstützt', 'NOT_SUPPORTED');
|
||||
items = await connector.reveal(ctx, r.external_ref);
|
||||
} catch (e) {
|
||||
if (e instanceof ConnectorError) throw new AppError(502, 'CONNECTOR_ERROR', `Beim Anbieter konnte nichts gelesen werden: ${e.userMessage}`);
|
||||
throw e;
|
||||
}
|
||||
await audit({ actorType: 'user', actorId: a.user.id, orgId: r.org_id, action: 'resource.reveal', resourceType: 'resource', resourceId: id, connector: r.connector_key, correlationId: req.correlationId, ip: clientIp(req), after: { labels: items.map((i) => i.label) } });
|
||||
reply.header('cache-control', 'no-store');
|
||||
return { items, hideAfterSec: 60 };
|
||||
});
|
||||
|
||||
// ---- Hosting: Unterobjekte (Domains, Postfächer, Datenbanken, FTP, SSL) und Panel-Login ----------
|
||||
const KINDS = ['domain', 'email', 'database', 'ftp', 'certificate'] as const;
|
||||
const kindParam = z.object({ id: z.string().uuid(), kind: z.enum(KINDS) });
|
||||
const childCaps = (r: any): string[] => json<string[]>(r.capabilities_json, []);
|
||||
/** Schreiben erlaubt: Personal mit Schreibrecht ODER Inhaber/Admin der Organisation, wenn das Produkt es für diese Art freigibt. */
|
||||
const canManageKind = (r: any, a: AuthContext, kind: string): boolean => {
|
||||
if (kind === 'certificate') return false;
|
||||
if (r.health !== 'ok' || !r.enabled || !childCaps(r).includes('children.write')) return false;
|
||||
if (can(a.principal, 'resources.write')) return true;
|
||||
const need = CHILD_MANAGE[kind];
|
||||
return !!need && !!r.org_id && canInOrg(a.principal, r.org_id, 'resources.manage', 'resources.write') && json<string[]>(r.customer_actions, []).includes(need);
|
||||
};
|
||||
const providerError = (e: unknown): never => { if (e instanceof ConnectorError) throw new AppError(e.code === 'NOT_FOUND' ? 404 : 502, 'CONNECTOR_ERROR', e.code === 'INVALID_INPUT' ? e.message : `Der Anbieter meldet: ${e.userMessage}`); throw e; };
|
||||
|
||||
app.get('/resources/:id/children', async (req) => {
|
||||
const a = requireAuth(req);
|
||||
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
|
||||
const r = await loadResource(id);
|
||||
if (!r || !access(a, r)) throw notFound();
|
||||
if (!childCaps(r).includes('children.read')) return { kinds: [], panelLogin: false };
|
||||
try {
|
||||
const { connector, ctx } = await loadInstance(r.instance_id, req.correlationId);
|
||||
const kinds = await connector.children!.kinds(ctx, r.external_ref);
|
||||
return { kinds: kinds.map((k) => ({ kind: k.kind, canWrite: k.canWrite && canManageKind(r, a, k.kind) })), panelLogin: canLogin(r, a) };
|
||||
} catch (e) { return providerError(e); }
|
||||
});
|
||||
app.get('/resources/:id/children/:kind', { config: rl(60, '1 minute') }, async (req) => {
|
||||
const a = requireAuth(req);
|
||||
const { id, kind } = kindParam.parse(req.params);
|
||||
const r = await loadResource(id);
|
||||
if (!r || !access(a, r) || !childCaps(r).includes('children.read')) throw notFound();
|
||||
try { const { connector, ctx } = await loadInstance(r.instance_id, req.correlationId); return await connector.children!.list(ctx, r.external_ref, kind as ChildKind); }
|
||||
catch (e) { return providerError(e); }
|
||||
});
|
||||
/** Änderung an einem Unterobjekt: läuft als persistenter Auftrag; Passwörter nur verschlüsselt im Auftrag, nach der Ausführung entfernt. */
|
||||
app.post('/resources/:id/children/:kind', { config: rl(30, '1 minute') }, async (req, reply) => {
|
||||
const a = requireAuth(req);
|
||||
const { id, kind } = kindParam.parse(req.params);
|
||||
const b = z.object({ op: z.enum(['create', 'update', 'delete']), id: z.string().max(40).optional(), data: z.record(z.string(), z.unknown()).default({}), password: z.string().max(128).optional() }).parse(req.body);
|
||||
const r = await loadResource(id);
|
||||
if (!r || !access(a, r)) throw notFound();
|
||||
if (!canManageKind(r, a, kind)) throw forbidden('Diese Änderung ist für diese Ressource nicht möglich', 'ACTION_NOT_ALLOWED');
|
||||
if ((b.op === 'update' || b.op === 'delete') && !b.id) throw badRequest('Objekt fehlt', 'ID_REQUIRED');
|
||||
const needsPw = b.op === 'create' && ['email', 'database', 'ftp'].includes(kind);
|
||||
if (needsPw && !b.password) throw badRequest('Bitte ein Passwort angeben.', 'PASSWORD_REQUIRED');
|
||||
if (b.password && (b.password.length < 12 || /[\0\r\n]/.test(b.password))) throw badRequest('Das Passwort muss mindestens 12 Zeichen lang sein.', 'WEAK_PASSWORD');
|
||||
if (JSON.stringify(b.data).length > 4000) throw badRequest('Eingabe zu groß', 'TOO_LARGE');
|
||||
const hdr = req.headers['idempotency-key'];
|
||||
const key = `child:${typeof hdr === 'string' && /^[\w-]{8,100}$/.test(hdr) ? hdr : createHash('sha256').update(`${id}|${kind}|${JSON.stringify(b.data)}|${b.op}|${b.id ?? ''}|${Math.floor(Date.now() / 60000)}`).digest('hex').slice(0, 40)}`;
|
||||
const existing = await one('SELECT id FROM jobs WHERE idempotency_key = ?', [key]);
|
||||
if (existing) return reply.code(202).send({ jobId: existing.id, duplicate: true });
|
||||
const jobId = randomUUID();
|
||||
const payload = { resourceId: id, kind, op: b.op, id: b.id, data: b.data, actorUserId: a.user.id, destructive: b.op === 'delete', ...(b.password ? { secretEnc: encrypt(JSON.stringify({ password: b.password })) } : {}) };
|
||||
try { await run('INSERT INTO jobs (id, type, payload, idempotency_key, correlation_id) VALUES (?,?,?,?,?)', [jobId, 'connector.child', JSON.stringify(payload), key, req.correlationId]); }
|
||||
catch (e) { if ((e as { code?: string }).code !== 'ER_DUP_ENTRY') throw e; const d = await one('SELECT id FROM jobs WHERE idempotency_key = ?', [key]); return reply.code(202).send({ jobId: d!.id, duplicate: true }); }
|
||||
await audit({ actorType: 'user', actorId: a.user.id, orgId: r.org_id, action: `resource.child.${kind}.${b.op}.request`, resourceType: 'resource', resourceId: id, connector: r.connector_key, correlationId: req.correlationId, ip: clientIp(req), after: { jobId, id: b.id, data: b.data } });
|
||||
return reply.code(202).send({ jobId, duplicate: false });
|
||||
});
|
||||
|
||||
const canLogin = (r: any, a: AuthContext): boolean => {
|
||||
if (!childCaps(r).includes('sso.login') || r.health !== 'ok' || !r.enabled) return false;
|
||||
if (can(a.principal, 'resources.write')) return true;
|
||||
return !!r.org_id && canInOrg(a.principal, r.org_id, 'resources.manage', 'resources.write') && json<string[]>(r.customer_actions, []).includes('panel.login');
|
||||
};
|
||||
/** Panel-Login: kurzlebiger Link, nie gespeichert, Abruf im Audit. */
|
||||
app.post('/resources/:id/login', { config: rl(10, '1 minute') }, async (req, reply) => {
|
||||
const a = requireAuth(req);
|
||||
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
|
||||
const r = await loadResource(id);
|
||||
if (!r || !access(a, r)) throw notFound();
|
||||
if (!canLogin(r, a)) throw forbidden('Der Panel-Login ist für diese Ressource nicht möglich', 'LOGIN_FORBIDDEN');
|
||||
let out: { url: string; validForSec: number };
|
||||
try { const { connector, ctx } = await loadInstance(r.instance_id, req.correlationId); out = await connector.loginUrl!(ctx, r.external_ref); } catch (e) { return providerError(e); }
|
||||
await audit({ actorType: 'user', actorId: a.user.id, orgId: r.org_id, action: 'resource.login', resourceType: 'resource', resourceId: id, connector: r.connector_key, correlationId: req.correlationId, ip: clientIp(req) });
|
||||
reply.header('cache-control', 'no-store'); return out;
|
||||
});
|
||||
|
||||
app.get('/jobs/:id', async (req) => {
|
||||
const a = requireAuth(req);
|
||||
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
|
||||
const j = await one("SELECT id, type, status, attempts, max_attempts, last_error, created_at, updated_at, JSON_VALUE(payload, '$.resourceId') AS resource_id FROM jobs WHERE id = ?", [id]);
|
||||
if (!j || !j.resource_id) throw notFound();
|
||||
const r = await loadResource(j.resource_id);
|
||||
if (!r || !access(a, r)) throw notFound();
|
||||
return { id: j.id, status: j.status, attempts: j.attempts, maxAttempts: j.max_attempts, error: j.last_error, createdAt: j.created_at, updatedAt: j.updated_at };
|
||||
});
|
||||
|
||||
app.get('/admin/jobs', async (req) => {
|
||||
requirePermission(req, 'jobs.read');
|
||||
const q = z.object({ status: z.string().max(30).optional() }).parse(req.query);
|
||||
return (await query('SELECT id, type, status, attempts, max_attempts, last_error, run_at, created_at, updated_at, correlation_id FROM jobs WHERE (? IS NULL OR status = ?) ORDER BY created_at DESC LIMIT 200', [q.status ?? null, q.status ?? null]))
|
||||
.map((j) => ({ id: j.id, type: j.type, status: j.status, attempts: j.attempts, maxAttempts: j.max_attempts, error: j.last_error, runAt: j.run_at, createdAt: j.created_at, correlationId: j.correlation_id }));
|
||||
});
|
||||
/** Kontrollierte manuelle Wiederholung: nur für Jobs in needs_review/failed, nie für destruktive Aktionen. */
|
||||
app.post('/admin/jobs/:id/retry', async (req) => {
|
||||
const a = requirePermission(req, 'resources.write');
|
||||
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
|
||||
const j = await one("SELECT id, status, (JSON_VALUE(payload, '$.destructive') IN ('1','true') OR JSON_VALUE(payload, '$.action') = 'terminate') AS destructive FROM jobs WHERE id = ?", [id]);
|
||||
if (!j) throw notFound();
|
||||
if (!['needs_review', 'failed'].includes(j.status)) throw badRequest('Nur fehlgeschlagene Aufträge können wiederholt werden', 'NOT_RETRYABLE');
|
||||
if (Number(j.destructive) === 1) throw forbidden('Destruktive Aufträge werden nicht automatisch wiederholt; bitte Ergebnis beim Provider prüfen', 'DESTRUCTIVE');
|
||||
await run("UPDATE jobs SET status='retrying', attempts=0, run_at=UTC_TIMESTAMP(3), last_error=NULL WHERE id = ?", [id]);
|
||||
await audit({ actorType: 'user', actorId: a.user.id, action: 'job.retry', resourceType: 'job', resourceId: id, correlationId: req.correlationId, ip: clientIp(req) });
|
||||
return { status: 'ok' };
|
||||
});
|
||||
|
||||
app.patch('/admin/resources/:id', async (req) => {
|
||||
const a = requirePermission(req, 'resources.write');
|
||||
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
|
||||
const b = z.object({ orgId: z.string().uuid().nullable().optional(), customerActions: z.array(z.enum(CUSTOMER_ACTIONS)).optional() }).parse(req.body);
|
||||
const r = await loadResource(id);
|
||||
if (!r) throw notFound();
|
||||
if (b.orgId && !(await one('SELECT 1 AS x FROM organizations WHERE id = ?', [b.orgId]))) throw badRequest('Kunde nicht gefunden');
|
||||
await run('UPDATE resources SET org_id = ?, customer_actions = ? WHERE id = ?', [b.orgId === undefined ? r.org_id : b.orgId, JSON.stringify(b.customerActions ?? json(r.customer_actions, [])), id]);
|
||||
await audit({ actorType: 'user', actorId: a.user.id, orgId: b.orgId ?? r.org_id, action: 'resource.update', resourceType: 'resource', resourceId: id, connector: r.connector_key, correlationId: req.correlationId, ip: clientIp(req), before: { orgId: r.org_id, customerActions: json(r.customer_actions, []) }, after: b });
|
||||
return { status: 'ok' };
|
||||
});
|
||||
},
|
||||
};
|
||||
35
apps/api/src/modules/system/index.ts
Normal file
35
apps/api/src/modules/system/index.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import type { FastifyInstance } from 'fastify';
|
||||
import { one } from '../../core/db.js';
|
||||
import { requirePermission } from '../../core/auth.js';
|
||||
import { query } from '../../core/db.js';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import type { KcModule } from '../../core/module.js';
|
||||
|
||||
/** Backup-Zustand aus der Statusdatei des Backup-Laufs (siehe docs/betrieb-backup-restore.md). Ohne Datei: nicht eingerichtet. */
|
||||
async function backupState() {
|
||||
const file = process.env.BACKUP_STATUS_FILE ?? '/var/lib/kundencenter/backup-status.json';
|
||||
let st: any; try { st = JSON.parse(await readFile(file, 'utf8')); } catch { return { configured: false as const }; }
|
||||
const hours = (iso?: string) => (iso ? (Date.now() - new Date(iso).getTime()) / 3600000 : null);
|
||||
const run = st.lastRun; const test = st.lastRestoreTest;
|
||||
return {
|
||||
configured: true as const, lastRunAt: run?.at ?? null, ok: !!run?.ok, ageHours: hours(run?.at), stale: !run || !run.ok || (hours(run.at) ?? 999) > 26, error: run?.error ?? null,
|
||||
file: run?.file ?? null, sizeBytes: run?.sizeBytes ?? null, targets: (run?.targets ?? []).map((t: any) => ({ name: t.name, ok: t.ok })),
|
||||
lastRestoreTestAt: test?.at ?? null, restoreOk: test ? !!test.ok : null, restoreStale: !test || !test.ok || (hours(test.at) ?? 999) > 24 * 10, restoreError: test?.error ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export const systemModule: KcModule = {
|
||||
name: 'system',
|
||||
register(app: FastifyInstance) {
|
||||
app.get('/health', async () => ({ status: 'ok' })); // Liveness
|
||||
app.get('/ready', async (_req, reply) => { // Readiness: DB erreichbar
|
||||
try { await one('SELECT 1 AS ok'); return { status: 'ready' }; } catch { return reply.code(503).send({ status: 'db_unavailable' }); }
|
||||
});
|
||||
app.get('/admin/system', async (req) => {
|
||||
requirePermission(req, 'jobs.read');
|
||||
const jobs = await query('SELECT status, COUNT(*) AS n FROM jobs GROUP BY status');
|
||||
const oldest = await one('SELECT MIN(run_at) AS t FROM jobs WHERE status IN (\'scheduled\',\'retrying\')');
|
||||
return { jobs: Object.fromEntries(jobs.map((j) => [j.status, Number(j.n)])), oldestPendingJob: oldest?.t ?? null, serverTime: new Date().toISOString(), backup: await backupState() };
|
||||
});
|
||||
},
|
||||
};
|
||||
209
apps/api/src/ops/backup.ts
Normal file
209
apps/api/src/ops/backup.ts
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
import { spawn } from 'node:child_process';
|
||||
import { createReadStream, createWriteStream, existsSync } from 'node:fs';
|
||||
import { chmod, mkdir, mkdtemp, readdir, readFile, rename, rm, stat, writeFile, copyFile } from 'node:fs/promises';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, basename } from 'node:path';
|
||||
import mysql from 'mysql2/promise';
|
||||
import { config } from '../core/config.js';
|
||||
import { query, one } from '../core/db.js';
|
||||
import { audit, verifyAuditChain } from '../core/audit.js';
|
||||
import { enqueue } from '../core/jobs.js';
|
||||
import { fileName, parseName, selectDeletions, type Keep } from './retention.js';
|
||||
import { getBackupPassword } from './settings.js';
|
||||
import { buildRemote, loadTargets, friendly, type Remote } from './targets.js';
|
||||
|
||||
/** Konfiguration aus /etc/kundencenter/backup.env (nur Namen/Pfade, keine Zugangsdaten der Ziele; die liegen in der rclone-Konfiguration). */
|
||||
export interface BackupConfig { dir: string; recipient: string | null; identity: string | null; remotes: string[]; rclone: string; rcloneConfig: string | null; keep: Keep; statusFile: string; envDir: string }
|
||||
export function loadBackupConfig(env = process.env): BackupConfig {
|
||||
return {
|
||||
dir: env.BACKUP_DIR ?? '/var/backups/kundencenter', recipient: env.BACKUP_AGE_RECIPIENT || null, identity: env.BACKUP_AGE_IDENTITY || null,
|
||||
remotes: (env.BACKUP_REMOTES ?? '').split(',').map((s) => s.trim()).filter(Boolean), rclone: env.BACKUP_RCLONE_BIN ?? 'rclone', rcloneConfig: env.RCLONE_CONFIG || null,
|
||||
keep: { daily: Number(env.BACKUP_KEEP_DAILY ?? 14), weekly: Number(env.BACKUP_KEEP_WEEKLY ?? 8), monthly: Number(env.BACKUP_KEEP_MONTHLY ?? 12) },
|
||||
statusFile: env.BACKUP_STATUS_FILE ?? '/var/lib/kundencenter/backup-status.json', envDir: env.KC_BACKUP_ENV_DIR ?? '/etc/kundencenter',
|
||||
};
|
||||
}
|
||||
|
||||
/** Externes Programm ohne Shell starten. Fehlertext enthält nur stderr (ohne Umgebung/Passwörter). */
|
||||
function run(cmd: string, args: string[], o: { env?: Record<string, string>; stdin?: string; stdinText?: string; stdout?: string; input?: string } = {}): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const p = spawn(cmd, args, { env: { PATH: process.env.PATH ?? '', HOME: process.env.HOME ?? '/root', ...(o.env ?? {}) }, stdio: [o.stdin || o.stdinText !== undefined ? 'pipe' : 'ignore', 'pipe', 'pipe'] });
|
||||
let out = ''; let err = '';
|
||||
if (o.stdout) p.stdout!.pipe(createWriteStream(o.stdout, { mode: 0o600 })); else p.stdout!.on('data', (d) => (out += d));
|
||||
p.stderr!.on('data', (d) => { err += d; if (err.length > 4000) err = err.slice(-4000); });
|
||||
if (o.stdin) createReadStream(o.stdin).pipe(p.stdin!); else if (o.stdinText !== undefined) { p.stdin!.on('error', () => undefined); p.stdin!.end(o.stdinText); }
|
||||
p.on('error', (e) => reject(new Error(`${basename(cmd)} konnte nicht gestartet werden: ${e.message}`)));
|
||||
p.on('close', (code) => (code === 0 ? resolve(out) : reject(new Error(`${basename(cmd)} Fehler (Exit ${code}): ${err.trim().split('\n').slice(-3).join(' | ')}`))));
|
||||
});
|
||||
}
|
||||
/** Passwortverschlüsselung mit gpg (AES-256, Integritätsschutz). Das Passwort geht über stdin, nie über Argumente. Eigenes Arbeitsverzeichnis, kein Agent-Rückstand. */
|
||||
async function gpgEncrypt(work: string, input: string, output: string, password: string): Promise<void> {
|
||||
const home = await mkdtemp(join(work, 'gnupg-')); await chmod(home, 0o700);
|
||||
await run('gpg', ['--homedir', home, '--batch', '--yes', '--pinentry-mode', 'loopback', '--passphrase-fd', '0', '--symmetric', '--cipher-algo', 'AES256', '--s2k-mode', '3', '--s2k-count', '65011712', '--s2k-digest-algo', 'SHA512', '-o', output, input], { stdinText: password });
|
||||
}
|
||||
async function gpgDecrypt(work: string, input: string, output: string, password: string): Promise<void> {
|
||||
const home = await mkdtemp(join(work, 'gnupg-')); await chmod(home, 0o700);
|
||||
await run('gpg', ['--homedir', home, '--batch', '--yes', '--pinentry-mode', 'loopback', '--passphrase-fd', '0', '-d', '-o', output, input], { stdinText: password });
|
||||
}
|
||||
const sha256 = async (f: string) => { const h = createHash('sha256'); for await (const c of createReadStream(f)) h.update(c); return h.digest('hex'); };
|
||||
const dbEnv = () => ({ MYSQL_PWD: config.db.password });
|
||||
const dbArgs = () => ['-h', config.db.host, '-P', String(config.db.port), '-u', config.db.user];
|
||||
const rcloneArgs = (c: BackupConfig) => (c.rcloneConfig ? ['--config', c.rcloneConfig] : []);
|
||||
|
||||
export interface RunSummary { at: string; ok: boolean; file?: string; sizeBytes?: number; durationMs: number; label?: string; error?: string }
|
||||
export interface Status {
|
||||
running?: { action: 'backup' | 'restore-test'; since: string } | null;
|
||||
history?: RunSummary[];
|
||||
lastRun?: { at: string; ok: boolean; file?: string; sizeBytes?: number; durationMs: number; label?: string; targets: { name: string; ok: boolean; error?: string }[]; error?: string };
|
||||
lastRestoreTest?: { at: string; ok: boolean; file?: string; durationMs: number; checks: Record<string, string | number | boolean>; error?: string };
|
||||
}
|
||||
export async function readStatus(c: BackupConfig): Promise<Status> { try { return JSON.parse(await readFile(c.statusFile, 'utf8')); } catch { return {}; } }
|
||||
async function writeStatus(c: BackupConfig, patch: Partial<Status>): Promise<void> {
|
||||
const s = { ...(await readStatus(c)), ...patch };
|
||||
if (patch.lastRun) { const r = patch.lastRun; s.history = [{ at: r.at, ok: r.ok, file: r.file, sizeBytes: r.sizeBytes, durationMs: r.durationMs, label: r.label, error: r.error }, ...((await readStatus(c)).history ?? [])].slice(0, 30); } const tmp = `${c.statusFile}.tmp`;
|
||||
await mkdir(join(c.statusFile, '..'), { recursive: true }); await writeFile(tmp, JSON.stringify(s, null, 2), { mode: 0o644 }); await rename(tmp, c.statusFile);
|
||||
}
|
||||
const alertOnce = (event: string, detail: string) => enqueue('discord.notify', { event, detail: detail.slice(0, 200) }, { idempotencyKey: `${event}:${new Date().toISOString().slice(0, 13)}` }).catch(() => undefined);
|
||||
|
||||
const COUNT_TABLES = ['users', 'organizations', 'memberships', 'orders', 'contracts', 'products', 'resources', 'connector_instances', 'audit_events', 'jobs'];
|
||||
async function tableCounts(q: (sql: string) => Promise<any[]>): Promise<Record<string, number>> {
|
||||
const out: Record<string, number> = {};
|
||||
for (const t of COUNT_TABLES) out[t] = Number((await q(`SELECT COUNT(*) AS n FROM \`${t}\``))[0].n);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Erstellt ein verschlüsseltes Backup (DB-Dump + Konfiguration/Schlüssel), lädt es zu allen Zielen hoch und räumt nach Aufbewahrungsregeln auf. */
|
||||
export async function runBackup(c: BackupConfig, now = new Date()): Promise<NonNullable<Status['lastRun']>> {
|
||||
const t0 = Date.now(); const targets: { name: string; ok: boolean; error?: string }[] = []; const dests: { label: string; remote: string; env: Record<string, string> }[] = []; const built: Remote[] = [];
|
||||
const pw = await getBackupPassword().catch(() => null); const name = fileName(now, pw ? 'gpg' : 'age');
|
||||
await mkdir(c.dir, { recursive: true, mode: 0o700 });
|
||||
await writeStatus(c, { running: { action: 'backup', since: now.toISOString() } }).catch(() => undefined);
|
||||
const work = await mkdtemp(join(c.dir, '.work-'));
|
||||
try {
|
||||
await mkdir(join(work, 'config'));
|
||||
await run('mysqldump', [...dbArgs(), '--single-transaction', '--routines', '--triggers', '--events', '--no-tablespaces', '--default-character-set=utf8mb4', config.db.database], { env: dbEnv(), stdout: join(work, 'db.sql') });
|
||||
// Konfiguration inkl. Master-Schlüssel (ohne den privaten Backup-Schlüssel!): ohne KC_SECRET_KEY sind TOTP-/Connector-Geheimnisse unlesbar
|
||||
// Nur die Dateien, die das Kundencenter zum Betrieb braucht (nicht z. B. Plane-Zugang, nie den privaten Backup-Schlüssel)
|
||||
for (const f of ['app.env', 'db.env', 'discord.env']) if (existsSync(join(c.envDir, f))) await copyFile(join(c.envDir, f), join(work, 'config', f));
|
||||
const counts = await tableCounts((sql) => query(sql));
|
||||
const migrations = (await query('SELECT name FROM schema_migrations ORDER BY name')).map((r) => r.name as string);
|
||||
await writeFile(join(work, 'manifest.json'), JSON.stringify({ version: 1, createdAt: now.toISOString(), database: config.db.database, counts, migrations, dumpSha256: await sha256(join(work, 'db.sql')) }, null, 2));
|
||||
await run('tar', ['-czf', join(work, 'archive.tar.gz'), '-C', work, 'db.sql', 'config', 'manifest.json']);
|
||||
const out = join(c.dir, name);
|
||||
if (pw) await gpgEncrypt(work, join(work, 'archive.tar.gz'), out, pw.password);
|
||||
else if (c.recipient) await run('age', ['-r', c.recipient, '-o', out, join(work, 'archive.tar.gz')]);
|
||||
else throw new Error('Keine Verschlüsselung eingerichtet: bitte unter Einstellungen → Backup ein Passwort festlegen.');
|
||||
await chmod(out, 0o600); // nur Besitzer
|
||||
const size = (await stat(out)).size;
|
||||
if (size < 512) throw new Error('Backup-Datei ist unplausibel klein');
|
||||
await writeFile(`${out}.sha256`, `${await sha256(out)} ${name}\n`, { mode: 0o600 });
|
||||
targets.push({ name: `lokal (${c.dir})`, ok: true });
|
||||
// Ziele: in der Oberfläche definierte (Datenbank) plus ggf. Altbestand aus BACKUP_REMOTES
|
||||
try { for (const t of await loadTargets(true)) { try { const r = await buildRemote(t, c.rclone); built.push(r); dests.push({ label: t.name, remote: r.remote, env: r.env }); } catch (e) { targets.push({ name: t.name, ok: false, error: friendly(e) }); } } }
|
||||
catch (e) { targets.push({ name: 'Ziele laden', ok: false, error: friendly(e) }); }
|
||||
for (const r of c.remotes) dests.push({ label: r, remote: r, env: c.rcloneConfig ? { RCLONE_CONFIG: c.rcloneConfig } : {} });
|
||||
const okDests: typeof dests = [];
|
||||
for (const d of dests) {
|
||||
try {
|
||||
const dest = `${d.remote.replace(/\/+$/, '')}/${name}`;
|
||||
await run(c.rclone, ['copyto', out, dest, '--retries', '3', '--low-level-retries', '5', '--timeout', '120s', '--contimeout', '30s'], { env: d.env });
|
||||
const ls = JSON.parse(await run(c.rclone, ['lsjson', dest], { env: d.env })) as { Size: number }[];
|
||||
if (ls[0]?.Size !== size) throw new Error(`Größe am Ziel weicht ab (${ls[0]?.Size ?? 'fehlt'} statt ${size})`);
|
||||
await run(c.rclone, ['copyto', `${out}.sha256`, `${dest}.sha256`], { env: d.env });
|
||||
targets.push({ name: d.label, ok: true }); okDests.push(d);
|
||||
} catch (e) { targets.push({ name: d.label, ok: false, error: friendly(e) }); }
|
||||
}
|
||||
await applyRetention(c, now, okDests, targets);
|
||||
const failed = targets.filter((t) => !t.ok);
|
||||
const res = { at: now.toISOString(), ok: failed.length === 0, file: name, sizeBytes: size, durationMs: Date.now() - t0, label: parseName(name)?.label, targets, ...(failed.length ? { error: `${failed.length} Ziel(e) fehlgeschlagen` } : {}) };
|
||||
await writeStatus(c, { lastRun: res, running: null });
|
||||
await audit({ actorType: 'system', action: 'backup.run', resourceType: 'backup', resourceId: name, result: res.ok ? 'success' : 'failure', errorClass: res.ok ? undefined : 'target_failed', after: { sizeBytes: size, targets: targets.map((t) => ({ name: t.name, ok: t.ok })) } }).catch(() => undefined);
|
||||
if (!res.ok) await alertOnce('backup.failed', res.error ?? 'Ziel fehlgeschlagen');
|
||||
return res;
|
||||
} catch (e) {
|
||||
const res = { at: now.toISOString(), ok: false, durationMs: Date.now() - t0, targets, error: (e as Error).message.slice(0, 400) };
|
||||
await writeStatus(c, { lastRun: res, running: null }).catch(() => undefined);
|
||||
await audit({ actorType: 'system', action: 'backup.run', resourceType: 'backup', result: 'failure', errorClass: 'backup_error' }).catch(() => undefined);
|
||||
await alertOnce('backup.failed', res.error);
|
||||
return res;
|
||||
} finally { await rm(work, { recursive: true, force: true }); for (const r of built) await r.cleanup().catch(() => undefined); }
|
||||
}
|
||||
|
||||
async function applyRetention(c: BackupConfig, now: Date, dests: { label: string; remote: string; env: Record<string, string> }[], targets: { name: string; ok: boolean; error?: string }[]): Promise<void> {
|
||||
const localNames = (await readdir(c.dir)).filter((n) => parseName(n));
|
||||
for (const n of selectDeletions(localNames, now, c.keep)) { await rm(join(c.dir, n), { force: true }); await rm(join(c.dir, `${n}.sha256`), { force: true }); }
|
||||
for (const d of dests) {
|
||||
const t = targets.find((x) => x.name === d.label);
|
||||
try {
|
||||
const base = d.remote.replace(/\/+$/, '');
|
||||
const names = (await run(c.rclone, ['lsf', d.remote, '--files-only'], { env: d.env })).split('\n').map((x) => x.trim()).filter(Boolean);
|
||||
for (const n of selectDeletions(names.filter((x) => parseName(x)), now, c.keep)) { await run(c.rclone, ['deletefile', `${base}/${n}`], { env: d.env }); await run(c.rclone, ['deletefile', `${base}/${n}.sha256`], { env: d.env }).catch(() => undefined); }
|
||||
} catch (e) { if (t) t.error = `Aufräumen fehlgeschlagen: ${friendly(e)}`; }
|
||||
}
|
||||
}
|
||||
|
||||
/** Neueste lokale Sicherung finden. */
|
||||
export async function latestLocal(c: BackupConfig): Promise<string | null> {
|
||||
const names = (await readdir(c.dir)).map(parseName).filter((x): x is NonNullable<ReturnType<typeof parseName>> => !!x).sort((a, b) => b.at.getTime() - a.at.getTime());
|
||||
return names[0]?.name ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wiederherstellungstest: entschlüsselt eine Sicherung, spielt sie in eine Wegwerf-Datenbank ein und prüft Prüfsumme, Zeilenzahlen,
|
||||
* Migrationen, Audit-Hash-Kette und ob die gesicherten Geheimnisse mit dem gesicherten Master-Schlüssel entschlüsselbar sind. Produktivdaten bleiben unberührt.
|
||||
*/
|
||||
export async function runRestoreTest(c: BackupConfig, file?: string): Promise<NonNullable<Status['lastRestoreTest']>> {
|
||||
const t0 = Date.now(); const checks: Record<string, string | number | boolean> = {}; const scratch = `${config.db.database}_restoretest`;
|
||||
const work = await mkdtemp(join(tmpdir(), 'kc-restore-'));
|
||||
await writeStatus(c, { running: { action: 'restore-test', since: new Date().toISOString() } }).catch(() => undefined);
|
||||
let name = file;
|
||||
try {
|
||||
name = name ?? (await latestLocal(c)) ?? undefined; if (!name) throw new Error('Keine Sicherung gefunden');
|
||||
const src = join(c.dir, name); checks.datei = name;
|
||||
const expect = (await readFile(`${src}.sha256`, 'utf8')).split(/\s+/)[0]; checks.pruefsumme = (await sha256(src)) === expect;
|
||||
if (!checks.pruefsumme) throw new Error('Prüfsumme der Sicherung stimmt nicht');
|
||||
if (name.endsWith('.gpg')) {
|
||||
const pw = await getBackupPassword(); if (!pw) throw new Error('Kein Backup-Passwort gespeichert: die Sicherung ist passwortverschlüsselt.');
|
||||
try { await gpgDecrypt(work, src, join(work, 'archive.tar.gz'), pw.password); } catch { throw new Error('Entschlüsselung fehlgeschlagen: Das gespeicherte Passwort passt nicht zu dieser Sicherung (wurde das Passwort inzwischen geändert?).'); }
|
||||
} else {
|
||||
if (!c.identity || !existsSync(c.identity)) throw new Error('Privater Backup-Schlüssel (BACKUP_AGE_IDENTITY) nicht vorhanden');
|
||||
await run('age', ['-d', '-i', c.identity, '-o', join(work, 'archive.tar.gz'), src]);
|
||||
}
|
||||
await run('tar', ['-xzf', join(work, 'archive.tar.gz'), '-C', work]);
|
||||
const manifest = JSON.parse(await readFile(join(work, 'manifest.json'), 'utf8')); checks.dumpPruefsumme = (await sha256(join(work, 'db.sql'))) === manifest.dumpSha256;
|
||||
if (!checks.dumpPruefsumme) throw new Error('Prüfsumme des Datenbank-Dumps stimmt nicht');
|
||||
const admin = await mysql.createConnection({ host: config.db.host, port: config.db.port, user: config.db.user, password: config.db.password });
|
||||
try {
|
||||
await admin.query(`DROP DATABASE IF EXISTS \`${scratch}\``); await admin.query(`CREATE DATABASE \`${scratch}\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci`);
|
||||
} finally { await admin.end(); }
|
||||
await run('mysql', [...dbArgs(), scratch], { env: dbEnv(), stdin: join(work, 'db.sql') });
|
||||
const conn = await mysql.createConnection({ host: config.db.host, port: config.db.port, user: config.db.user, password: config.db.password, database: scratch, timezone: 'Z' }); // UTC wie im Betrieb, sonst stimmt die Hash-Kette der Zeitstempel nicht
|
||||
try {
|
||||
const q = async (sql: string) => (await conn.query(sql))[0] as any[];
|
||||
const counts = await tableCounts(q); const mism = Object.entries(manifest.counts as Record<string, number>).filter(([t, n]) => counts[t] !== n);
|
||||
checks.zeilenzahlen = mism.length === 0; if (mism.length) throw new Error(`Zeilenzahlen weichen ab: ${mism.map(([t]) => t).join(', ')}`);
|
||||
const mig = (await q('SELECT name FROM schema_migrations ORDER BY name')).map((r) => r.name); checks.migrationen = JSON.stringify(mig) === JSON.stringify(manifest.migrations); if (!checks.migrationen) throw new Error('Migrationen weichen ab');
|
||||
const chain = await verifyAuditChain(q); checks.auditKette = chain.brokenAt === null; checks.auditEintraege = chain.checked; if (chain.brokenAt !== null) throw new Error(`Audit-Kette defekt ab Eintrag ${chain.brokenAt}`);
|
||||
// Geheimnisse mit dem GESICHERTEN Schlüssel entschlüsseln (beweist, dass die Schlüsselsicherung brauchbar ist)
|
||||
const key = /^KC_SECRET_KEY=(.+)$/m.exec(await readFile(join(work, 'config', 'app.env'), 'utf8'))?.[1];
|
||||
if (!key) throw new Error('Master-Schlüssel fehlt in der Sicherung');
|
||||
const { createDecipheriv } = await import('node:crypto');
|
||||
const dec = (blob: string) => { const [v, iv, tag, ct] = blob.split(':'); const d = createDecipheriv('aes-256-gcm', Buffer.from(key, 'base64'), Buffer.from(iv!, 'base64url')); d.setAuthTag(Buffer.from(tag!, 'base64url')); return Buffer.concat([d.update(Buffer.from(ct!, 'base64url')), d.final()]).length > 0 && v === 'v1'; };
|
||||
const sample = [...(await q('SELECT secret_enc AS s FROM mfa_totp LIMIT 3')), ...(await q('SELECT secrets_enc AS s FROM connector_instances WHERE secrets_enc IS NOT NULL LIMIT 3'))];
|
||||
checks.geheimnisseGeprueft = sample.length; checks.geheimnisseOk = sample.every((r) => dec(r.s)); if (!checks.geheimnisseOk) throw new Error('Gesicherte Geheimnisse sind mit dem gesicherten Schlüssel nicht entschlüsselbar');
|
||||
} finally { await conn.end(); }
|
||||
const res = { at: new Date().toISOString(), ok: true, file: name, durationMs: Date.now() - t0, checks };
|
||||
await writeStatus(c, { lastRestoreTest: res, running: null });
|
||||
await audit({ actorType: 'system', action: 'backup.restore_test', resourceType: 'backup', resourceId: name, result: 'success' }).catch(() => undefined);
|
||||
return res;
|
||||
} catch (e) {
|
||||
const res = { at: new Date().toISOString(), ok: false, file: name, durationMs: Date.now() - t0, checks, error: (e as Error).message.slice(0, 400) };
|
||||
await writeStatus(c, { lastRestoreTest: res, running: null }).catch(() => undefined);
|
||||
await audit({ actorType: 'system', action: 'backup.restore_test', resourceType: 'backup', resourceId: name, result: 'failure', errorClass: 'restore_test_failed' }).catch(() => undefined);
|
||||
await alertOnce('restoretest.failed', res.error);
|
||||
return res;
|
||||
} finally {
|
||||
await rm(work, { recursive: true, force: true });
|
||||
try { const a = await mysql.createConnection({ host: config.db.host, port: config.db.port, user: config.db.user, password: config.db.password }); await a.query(`DROP DATABASE IF EXISTS \`${scratch}\``); await a.end(); } catch { /* Aufräumen best effort */ }
|
||||
}
|
||||
}
|
||||
26
apps/api/src/ops/retention.ts
Normal file
26
apps/api/src/ops/retention.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
/** Benennung und Aufbewahrung von Backups (rein, ohne Dateizugriff). Zeiten in UTC. */
|
||||
export type Label = 'daily' | 'weekly' | 'monthly';
|
||||
export interface Keep { daily: number; weekly: number; monthly: number }
|
||||
export const FILE_RE = /^kundencenter-(\d{4})(\d{2})(\d{2})-(\d{2})(\d{2})(\d{2})-(daily|weekly|monthly)\.tar\.gz\.(age|gpg)$/;
|
||||
|
||||
/** Monatserster = monthly, Sonntag = weekly, sonst daily. */
|
||||
export const labelFor = (d: Date): Label => (d.getUTCDate() === 1 ? 'monthly' : d.getUTCDay() === 0 ? 'weekly' : 'daily');
|
||||
const pad = (n: number, l = 2) => String(n).padStart(l, '0');
|
||||
export const fileName = (d: Date, ext: 'age' | 'gpg' = 'age'): string => `kundencenter-${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}-${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}${pad(d.getUTCSeconds())}-${labelFor(d)}.tar.gz.${ext}`;
|
||||
export function parseName(n: string): { name: string; at: Date; label: Label; ext: 'age' | 'gpg' } | null {
|
||||
const m = FILE_RE.exec(n); if (!m) return null;
|
||||
return { name: n, at: new Date(Date.UTC(+m[1]!, +m[2]! - 1, +m[3]!, +m[4]!, +m[5]!, +m[6]!)), label: m[7] as Label, ext: m[8] as 'age' | 'gpg' };
|
||||
}
|
||||
/**
|
||||
* Welche Dateien dürfen gelöscht werden? Täglich: keep.daily Tage, wöchentlich: keep.weekly Wochen, monatlich: keep.monthly Monate.
|
||||
* Fremde Dateinamen werden nie angefasst. Es bleiben immer mindestens `minKeep` Sicherungen erhalten (die neuesten).
|
||||
*/
|
||||
export function selectDeletions(names: string[], now: Date, keep: Keep, minKeep = 3): string[] {
|
||||
const all = names.map(parseName).filter((x): x is NonNullable<ReturnType<typeof parseName>> => !!x);
|
||||
const ageDays = (d: Date) => (now.getTime() - d.getTime()) / 86400000;
|
||||
const limit: Record<Label, number> = { daily: keep.daily, weekly: keep.weekly * 7, monthly: keep.monthly * 31 };
|
||||
const del = all.filter((b) => ageDays(b.at) > limit[b.label]).sort((a, b) => b.at.getTime() - a.at.getTime()); // neueste zuerst
|
||||
let survivors = all.length - del.length;
|
||||
while (survivors < minKeep && del.length) { del.shift(); survivors++; } // neueste "Löschkandidaten" behalten
|
||||
return del.map((b) => b.name);
|
||||
}
|
||||
28
apps/api/src/ops/settings.ts
Normal file
28
apps/api/src/ops/settings.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { one, run } from '../core/db.js';
|
||||
import { decrypt, encrypt } from '../core/crypto.js';
|
||||
|
||||
/** Backup-Passwort: Mindestlänge laut Vorgabe "länger als 10 Zeichen". */
|
||||
export const MIN_PASSWORD = 11;
|
||||
export function passwordProblem(pw: string): string | null {
|
||||
if (pw.length < MIN_PASSWORD) return `Das Passwort muss länger als 10 Zeichen sein (mindestens ${MIN_PASSWORD}).`;
|
||||
if (pw.length > 200) return 'Das Passwort darf höchstens 200 Zeichen lang sein.';
|
||||
if (/^(.)\1+$/.test(pw)) return 'Das Passwort darf nicht nur aus einem wiederholten Zeichen bestehen.';
|
||||
if (/[\r\n\0]/.test(pw)) return 'Das Passwort darf keine Zeilenumbrüche enthalten.';
|
||||
return null;
|
||||
}
|
||||
interface PasswordRecord { password: string; version: number; updatedAt: string }
|
||||
/** Liefert das gespeicherte Backup-Passwort (mit dem Master-Schlüssel verschlüsselt in der Datenbank) oder null. */
|
||||
export async function getBackupPassword(): Promise<PasswordRecord | null> {
|
||||
const r = await one('SELECT value_enc FROM backup_settings WHERE `key` = ?', ['password']);
|
||||
return r ? (JSON.parse(decrypt(r.value_enc)) as PasswordRecord) : null;
|
||||
}
|
||||
export async function passwordMeta(): Promise<{ set: boolean; version: number; updatedAt: string | null }> {
|
||||
const r = await getBackupPassword(); return { set: !!r, version: r?.version ?? 0, updatedAt: r?.updatedAt ?? null };
|
||||
}
|
||||
export async function setBackupPassword(pw: string, userId: string | null): Promise<number> {
|
||||
const problem = passwordProblem(pw); if (problem) throw new Error(problem);
|
||||
const cur = await getBackupPassword(); const version = (cur?.version ?? 0) + 1;
|
||||
await run('INSERT INTO backup_settings (`key`, value_enc, updated_by) VALUES (?,?,?) ON DUPLICATE KEY UPDATE value_enc = VALUES(value_enc), updated_by = VALUES(updated_by)',
|
||||
['password', encrypt(JSON.stringify({ password: pw, version, updatedAt: new Date().toISOString() } satisfies PasswordRecord)), userId]);
|
||||
return version;
|
||||
}
|
||||
94
apps/api/src/ops/targets.ts
Normal file
94
apps/api/src/ops/targets.ts
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
import { spawn } from 'node:child_process';
|
||||
import { mkdtemp, rm, stat, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { decrypt } from '../core/crypto.js';
|
||||
import { query } from '../core/db.js';
|
||||
|
||||
export type TargetType = 'sftp' | 'ftp' | 'gdrive' | 'local';
|
||||
export interface TargetRow { id: string; name: string; type: TargetType; path: string; config: Record<string, any>; secrets: Record<string, string>; hostKey: string | null; enabled: boolean }
|
||||
/** Ein einsatzbereites Ziel: rclone-Zielangabe + Umgebung (keine Konfigurationsdatei, keine Passwörter in Argumenten). */
|
||||
export interface Remote { name: string; remote: string; env: Record<string, string>; cleanup: () => Promise<void> }
|
||||
|
||||
export async function loadTargets(onlyEnabled = true): Promise<TargetRow[]> {
|
||||
const rows = await query(`SELECT * FROM backup_targets ${onlyEnabled ? 'WHERE enabled = 1' : ''} ORDER BY name`);
|
||||
return rows.map((r) => ({
|
||||
id: r.id, name: r.name, type: r.type, path: r.path, enabled: !!r.enabled, hostKey: r.host_key ?? null,
|
||||
config: typeof r.config_json === 'string' ? JSON.parse(r.config_json) : r.config_json, secrets: r.secrets_enc ? JSON.parse(decrypt(r.secrets_enc)) : {},
|
||||
}));
|
||||
}
|
||||
|
||||
/** Kleines Hilfsprogramm mit Zeitlimit; Fehlertext nur aus stderr. */
|
||||
export function exec(cmd: string, args: string[], o: { env?: Record<string, string>; input?: string; timeoutMs?: number } = {}): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const p = spawn(cmd, args, { env: { PATH: process.env.PATH ?? '', HOME: process.env.HOME ?? '/tmp', ...(o.env ?? {}) }, stdio: ['pipe', 'pipe', 'pipe'] });
|
||||
let out = ''; let err = ''; const timer = setTimeout(() => { p.kill('SIGKILL'); reject(new Error(`${cmd} hat nicht rechtzeitig geantwortet`)); }, o.timeoutMs ?? 60_000);
|
||||
p.stdout.on('data', (d) => (out += d)); p.stderr.on('data', (d) => { err += d; if (err.length > 4000) err = err.slice(-4000); });
|
||||
p.on('error', (e) => { clearTimeout(timer); reject(new Error(`${cmd} konnte nicht gestartet werden: ${e.message}`)); });
|
||||
p.on('close', (code) => { clearTimeout(timer); code === 0 ? resolve(out) : reject(new Error(`${cmd} Fehler (Exit ${code}): ${err.trim().split('\n').slice(-2).join(' | ')}`)); });
|
||||
p.stdin.on('error', () => undefined); p.stdin.end(o.input ?? '');
|
||||
});
|
||||
}
|
||||
const obscure = async (rclone: string, pw: string) => (await exec(rclone, ['obscure', '-'], { input: pw, timeoutMs: 10_000 })).trim();
|
||||
|
||||
/** Verständliche Fehlermeldung ohne Zugangsdaten. */
|
||||
export function friendly(e: unknown): string {
|
||||
const m = String((e as Error)?.message ?? e);
|
||||
if (/host key|knownhosts|key mismatch|REMOTE HOST IDENTIFICATION/i.test(m)) return 'Der Server-Schlüssel stimmt nicht mit dem gemerkten überein. Wurde der Server neu aufgesetzt? Dann den Server-Schlüssel neu erfassen.';
|
||||
if (/auth|permission denied|login|530|password/i.test(m)) return 'Anmeldung fehlgeschlagen (Benutzer, Passwort oder Schlüssel prüfen).';
|
||||
if (/no such host|lookup|name or service/i.test(m)) return 'Der Servername wurde nicht gefunden.';
|
||||
if (/refused|unreachable|timed out|timeout|nicht rechtzeitig|i\/o timeout/i.test(m)) return 'Der Server ist nicht erreichbar (Adresse, Port und Firewall prüfen).';
|
||||
if (/directory not found|not found|no such file/i.test(m)) return 'Der Zielordner wurde nicht gefunden und konnte nicht angelegt werden.';
|
||||
if (/quota|storage|space/i.test(m)) return 'Kein Speicherplatz mehr am Ziel.';
|
||||
return m.replace(/\s+/g, ' ').slice(0, 300);
|
||||
}
|
||||
|
||||
export async function buildRemote(t: TargetRow, rclone = 'rclone', hostKeyOverride?: string): Promise<Remote> {
|
||||
if (t.type === 'local') return { name: t.name, remote: t.path, env: {}, cleanup: async () => undefined };
|
||||
const rn = `KCT${t.id.replace(/-/g, '').slice(0, 8).toUpperCase()}`; const P = `RCLONE_CONFIG_${rn}_`; const env: Record<string, string> = {};
|
||||
// Eigenes, temporäres Verzeichnis (Server-Schlüssel, leere rclone-Konfiguration): rclone darf nichts in Benutzerordner schreiben und findet keine fremde Konfiguration
|
||||
const tmp: string = await mkdtemp(join(tmpdir(), 'kc-rc-')); await writeFile(join(tmp, 'rclone.conf'), '', { mode: 0o600 }); env.RCLONE_CONFIG = join(tmp, 'rclone.conf');
|
||||
const set = (k: string, v: string | number | boolean | undefined | null) => { if (v !== undefined && v !== null && v !== '') env[`${P}${k}`] = String(v); };
|
||||
if (t.type === 'sftp') {
|
||||
set('TYPE', 'sftp'); set('HOST', t.config.host); set('PORT', t.config.port ?? 22); set('USER', t.config.user);
|
||||
if (t.config.authType === 'key') { set('KEY_PEM', (t.secrets.privateKey ?? '').replace(/\r/g, '').trim().replace(/\n/g, '\\n')); if (t.secrets.keyPassphrase) set('KEY_FILE_PASS', await obscure(rclone, t.secrets.keyPassphrase)); }
|
||||
else if (t.secrets.password) set('PASS', await obscure(rclone, t.secrets.password));
|
||||
const hk = hostKeyOverride ?? t.hostKey;
|
||||
if (hk) { const f = join(tmp, 'known_hosts'); await writeFile(f, `${hk.trim()}\n`, { mode: 0o600 }); set('KNOWN_HOSTS_FILE', f); }
|
||||
set('DISABLE_HASHCHECK', 'true'); // auch für reine SFTP-Zugänge ohne Shell
|
||||
} else if (t.type === 'ftp') {
|
||||
set('TYPE', 'ftp'); set('HOST', t.config.host); set('PORT', t.config.port ?? 21); set('USER', t.config.user);
|
||||
if (t.secrets.password) set('PASS', await obscure(rclone, t.secrets.password));
|
||||
if (t.config.tls === 'explicit') set('EXPLICIT_TLS', 'true'); else if (t.config.tls === 'implicit') set('TLS', 'true');
|
||||
} else if (t.type === 'gdrive') {
|
||||
set('TYPE', 'drive'); set('SCOPE', t.config.scope ?? 'drive.file'); set('TOKEN', t.secrets.token); set('CLIENT_ID', t.secrets.clientId); set('CLIENT_SECRET', t.secrets.clientSecret);
|
||||
}
|
||||
return { name: t.name, remote: `${rn}:${t.path.replace(/^\/+/, '')}`, env, cleanup: async () => { await rm(tmp, { recursive: true, force: true }); } };
|
||||
}
|
||||
|
||||
/** Server-Schlüssel eines SFTP-Ziels holen (Erstkontakt: "Trust on first use"; Fingerabdruck wird angezeigt). */
|
||||
export async function scanHostKey(host: string, port: number): Promise<{ line: string; fingerprint: string }> {
|
||||
const out = await exec('ssh-keyscan', ['-T', '10', '-t', 'ed25519,ecdsa,rsa', '-p', String(port), host], { timeoutMs: 20_000 });
|
||||
const lines = out.split('\n').filter((l) => l && !l.startsWith('#')); if (!lines.length) throw new Error('Der Server hat keinen Schlüssel geliefert (Adresse/Port prüfen).');
|
||||
const pick = lines.find((l) => l.includes('ssh-ed25519')) ?? lines.find((l) => l.includes('ecdsa')) ?? lines[0]!;
|
||||
const fp = (await exec('ssh-keygen', ['-lf', '-'], { input: pick + '\n', timeoutMs: 10_000 })).trim().split(/\s+/)[1] ?? '';
|
||||
return { line: pick, fingerprint: fp };
|
||||
}
|
||||
|
||||
/** Verbindungstest: Ordner anlegen, kleine Testdatei schreiben und wieder löschen (belegt echte Schreibrechte). */
|
||||
export async function testTarget(t: TargetRow, rclone = 'rclone'): Promise<{ ok: boolean; error?: string; hostKey?: string; fingerprint?: string }> {
|
||||
let hostKey: string | undefined; let fingerprint: string | undefined;
|
||||
try {
|
||||
if (t.type === 'local') { const st = await stat(t.path).catch(() => null); if (!st?.isDirectory()) throw new Error('Der Ordner existiert nicht (bei Netzlaufwerken: ist es eingehängt?)'); }
|
||||
if (t.type === 'sftp' && !t.hostKey) { const k = await scanHostKey(String(t.config.host), Number(t.config.port ?? 22)); hostKey = k.line; fingerprint = k.fingerprint; }
|
||||
const r = await buildRemote(t, rclone, hostKey);
|
||||
try {
|
||||
const flags = ['--timeout', '30s', '--contimeout', '15s', '--retries', '1', '--low-level-retries', '1'];
|
||||
await exec(rclone, ['mkdir', r.remote, ...flags], { env: r.env, timeoutMs: 60_000 });
|
||||
const f = `${r.remote.replace(/\/+$/, '')}/.kc-schreibtest`;
|
||||
await exec(rclone, ['rcat', f, ...flags], { env: r.env, input: 'ok', timeoutMs: 60_000 });
|
||||
await exec(rclone, ['deletefile', f, ...flags], { env: r.env, timeoutMs: 60_000 });
|
||||
} finally { await r.cleanup(); }
|
||||
return { ok: true, hostKey, fingerprint };
|
||||
} catch (e) { return { ok: false, error: friendly(e) }; }
|
||||
}
|
||||
43
apps/api/src/server.ts
Normal file
43
apps/api/src/server.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import Fastify from 'fastify';
|
||||
import cookie from '@fastify/cookie';
|
||||
import helmet from '@fastify/helmet';
|
||||
import rateLimit from '@fastify/rate-limit';
|
||||
import { ZodError } from 'zod';
|
||||
import { config } from './core/config.js';
|
||||
import { registerAuth } from './core/auth.js';
|
||||
import { registerPermissions } from './core/policy.js';
|
||||
import { AppError } from './core/errors.js';
|
||||
import { InvalidTransition } from '@kc/platform/statemachine';
|
||||
import { modules } from './modules/index.js';
|
||||
|
||||
export async function buildApp() {
|
||||
const app = Fastify({
|
||||
trustProxy: true,
|
||||
bodyLimit: 1024 * 1024,
|
||||
logger: { level: config.isProd ? 'info' : 'debug', redact: ['req.headers.cookie', 'req.headers.authorization', 'req.body.password', 'req.body.current', 'req.body.next'] },
|
||||
genReqId: () => crypto.randomUUID(),
|
||||
});
|
||||
await app.register(helmet, { contentSecurityPolicy: false }); // CSP setzt das Web-Frontend
|
||||
await app.register(cookie);
|
||||
await app.register(rateLimit, { global: true, max: 300, timeWindow: '1 minute' });
|
||||
registerAuth(app);
|
||||
|
||||
app.setErrorHandler((err, req, reply) => {
|
||||
if (err instanceof AppError) return reply.code(err.status).send({ error: { code: err.code, message: err.message }, correlationId: req.correlationId });
|
||||
if (err instanceof InvalidTransition) return reply.code(409).send({ error: { code: 'INVALID_TRANSITION', message: 'Diese Aktion ist im aktuellen Zustand nicht möglich. Bitte Ansicht neu laden.' }, correlationId: req.correlationId });
|
||||
if (err instanceof ZodError) return reply.code(400).send({ error: { code: 'VALIDATION', message: 'Eingabe ungültig', details: err.issues.map((i) => ({ path: i.path.join('.'), message: i.message })) }, correlationId: req.correlationId });
|
||||
const status = (err as { statusCode?: number }).statusCode;
|
||||
if (status && status < 500) return reply.code(status).send({ error: { code: 'REQUEST_ERROR', message: (err as Error).message }, correlationId: req.correlationId });
|
||||
req.log.error({ err, correlationId: req.correlationId }, 'unhandled');
|
||||
return reply.code(500).send({ error: { code: 'INTERNAL', message: 'Interner Fehler' }, correlationId: req.correlationId });
|
||||
});
|
||||
|
||||
for (const m of modules) registerPermissions(m.permissions ?? {});
|
||||
for (const m of modules) await app.register(async (scope) => { await m.register(scope); }, { prefix: '/v1' });
|
||||
return app;
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
const app = await buildApp();
|
||||
await app.listen({ port: config.port, host: '127.0.0.1' });
|
||||
}
|
||||
52
apps/api/test/backup-api.test.ts
Normal file
52
apps/api/test/backup-api.test.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { mkdtempSync, existsSync, rmSync, writeFileSync, mkdirSync, readFileSync, unlinkSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { buildApp } from '../src/server.js';
|
||||
import { query } from '../src/core/db.js';
|
||||
import { call, code, login, makeUser } from './helpers.js';
|
||||
|
||||
let app: FastifyInstance; const root = mkdtempSync(join(tmpdir(), 'kc-bkapi-')); const statusFile = join(root, 'status.json'); const reqDir = join(root, 'requests');
|
||||
beforeAll(async () => { process.env.BACKUP_STATUS_FILE = statusFile; process.env.BACKUP_REQUEST_DIR = reqDir; process.env.BACKUP_REMOTES = 'nas:kundencenter'; app = await buildApp(); await app.ready(); });
|
||||
afterAll(() => { rmSync(root, { recursive: true, force: true }); delete process.env.BACKUP_STATUS_FILE; delete process.env.BACKUP_REQUEST_DIR; delete process.env.BACKUP_REMOTES; });
|
||||
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;
|
||||
}
|
||||
|
||||
describe('Backup-Seite (API)', () => {
|
||||
it('zeigt Zustand nur Berechtigten, ohne Statusdatei als "noch nie gelaufen"', async () => {
|
||||
const admin = await staff('bk-adm@x.test', 'admin'); const sup = await staff('bk-sup@x.test', 'support');
|
||||
expect((await call(app, sup, 'GET', '/admin/backup')).statusCode).toBe(403);
|
||||
expect((await app.inject({ method: 'GET', url: '/v1/admin/backup' })).statusCode).toBe(401);
|
||||
const s0 = (await call(app, admin, 'GET', '/admin/backup')).json();
|
||||
expect(s0.lastRun).toBeNull(); expect(s0.stale).toBe(true); expect(s0.hasExternalTarget).toBe(true); expect(s0.remotes).toEqual(['nas:kundencenter']); expect(s0.retention).toEqual({ daily: 14, weekly: 8, monthly: 12 });
|
||||
mkdirSync(join(root), { recursive: true });
|
||||
writeFileSync(statusFile, JSON.stringify({ lastRun: { at: new Date().toISOString(), ok: true, file: 'x.age', sizeBytes: 1000, durationMs: 900, targets: [{ name: 'lokal', ok: true }] }, lastRestoreTest: { at: new Date().toISOString(), ok: true, checks: { auditKette: true } }, history: [{ at: new Date().toISOString(), ok: true }] }));
|
||||
const s1 = (await call(app, admin, 'GET', '/admin/backup')).json(); expect(s1.stale).toBe(false); expect(s1.restoreStale).toBe(false); expect(s1.history).toHaveLength(1);
|
||||
// veraltet, wenn der letzte Erfolg zu alt ist
|
||||
writeFileSync(statusFile, JSON.stringify({ lastRun: { at: new Date(Date.now() - 30 * 3600000).toISOString(), ok: true, targets: [] } }));
|
||||
expect((await call(app, admin, 'GET', '/admin/backup')).json().stale).toBe(true);
|
||||
});
|
||||
it('legt Anfragen nur für feste Aktionen ab, nur einmal gleichzeitig, mit Audit', async () => {
|
||||
const admin = await staff('bk-adm2@x.test', 'admin'); const sup = await staff('bk-sup2@x.test', 'support');
|
||||
expect((await call(app, sup, 'POST', '/admin/backup/run', { action: 'backup' })).statusCode).toBe(403);
|
||||
expect((await call(app, admin, 'POST', '/admin/backup/run', { action: 'rm -rf /' })).statusCode).toBe(400); // nur feste Aktionen
|
||||
expect(existsSync(reqDir) ? readdirSyncSafe(reqDir) : []).toEqual([]);
|
||||
const ok = await call(app, admin, 'POST', '/admin/backup/run', { action: 'backup' }); expect(ok.statusCode).toBe(202);
|
||||
expect(readdirSyncSafe(reqDir)).toEqual(['backup-run']);
|
||||
expect((await call(app, admin, 'POST', '/admin/backup/run', { action: 'restore-test' })).json().error.code).toBe('BACKUP_PENDING'); // eine Anfrage nach der anderen
|
||||
expect((await call(app, admin, 'GET', '/admin/backup')).json().pendingRequests).toEqual(['backup']);
|
||||
unlinkSync(join(reqDir, 'backup-run'));
|
||||
writeFileSync(statusFile, JSON.stringify({ running: { action: 'backup', since: new Date().toISOString() } }));
|
||||
expect((await call(app, admin, 'POST', '/admin/backup/run', { action: 'restore-test' })).json().error.code).toBe('BACKUP_RUNNING');
|
||||
writeFileSync(statusFile, JSON.stringify({}));
|
||||
expect((await call(app, admin, 'POST', '/admin/backup/run', { action: 'restore-test' })).statusCode).toBe(202);
|
||||
expect(readdirSyncSafe(reqDir)).toEqual(['backup-restore-test']);
|
||||
expect((await query("SELECT action FROM audit_events WHERE action LIKE 'backup.request.%' ORDER BY id")).map((r) => r.action)).toEqual(['backup.request.backup', 'backup.request.restore-test']);
|
||||
});
|
||||
});
|
||||
import { readdirSync } from 'node:fs';
|
||||
function readdirSyncSafe(d: string): string[] { try { return readdirSync(d); } catch { return []; } }
|
||||
124
apps/api/test/backup-settings.test.ts
Normal file
124
apps/api/test/backup-settings.test.ts
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { buildApp } from '../src/server.js';
|
||||
import { one, query } from '../src/core/db.js';
|
||||
import { parseName, fileName } from '../src/ops/retention.js';
|
||||
import { passwordProblem, setBackupPassword } from '../src/ops/settings.js';
|
||||
import { buildRemote, friendly, type TargetRow } from '../src/ops/targets.js';
|
||||
import { loadBackupConfig, runBackup, runRestoreTest } from '../src/ops/backup.js';
|
||||
import { config } from '../src/core/config.js';
|
||||
import { call, code, login, makeUser } from './helpers.js';
|
||||
|
||||
let app: FastifyInstance; const root = mkdtempSync(join(tmpdir(), 'kc-bkset-')); const envDir = join(root, 'etc'); const dir = join(root, 'backups'); const dest = join(root, 'ziel'); const status = join(root, 'status.json');
|
||||
beforeAll(async () => { delete process.env.BACKUP_AGE_RECIPIENT; delete process.env.BACKUP_REMOTES; app = await buildApp(); await app.ready(); mkdirSync(envDir); mkdirSync(dest); writeFileSync(join(envDir, 'app.env'), `KC_SECRET_KEY=${config.secretKey.toString('base64')}\n`); });
|
||||
afterAll(() => rmSync(root, { recursive: true, force: true }));
|
||||
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 cfg = () => loadBackupConfig({ BACKUP_DIR: dir, BACKUP_STATUS_FILE: status, KC_BACKUP_ENV_DIR: envDir, PATH: process.env.PATH });
|
||||
|
||||
describe('Backup-Passwort', () => {
|
||||
it('verlangt mehr als 10 Zeichen', () => {
|
||||
expect(passwordProblem('1234567890')).toMatch(/länger als 10/); // genau 10: zu kurz
|
||||
expect(passwordProblem('12345678901')).toBeNull(); // 11: ok
|
||||
expect(passwordProblem('aaaaaaaaaaaa')).toMatch(/wiederholt/);
|
||||
expect(passwordProblem('abc\ndefghijkl')).toMatch(/Zeilenumbr/);
|
||||
expect(passwordProblem('x'.repeat(201))).toMatch(/höchstens/);
|
||||
});
|
||||
it('setzt es nur mit Superadmin, Wiederholung und eigenem Anmeldepasswort; speichert es verschlüsselt', async () => {
|
||||
const admin = await staff('bs-adm@x.test', 'admin'); const sa = await staff('bs-sa@x.test', 'superadmin');
|
||||
const body = { password: 'Sehr-langes-Backup-Passwort-1', repeat: 'Sehr-langes-Backup-Passwort-1', currentPassword: 'correct-horse-battery' };
|
||||
expect((await call(app, admin, 'PUT', '/admin/backup/password', body)).statusCode).toBe(403); // Admin darf nicht
|
||||
expect((await call(app, sa, 'PUT', '/admin/backup/password', { ...body, currentPassword: 'falsch-falsch-falsch' })).json().error.code).toBe('INVALID_CREDENTIALS');
|
||||
expect((await call(app, sa, 'PUT', '/admin/backup/password', { ...body, repeat: 'anderes-Passwort-12' })).json().error.code).toBe('PASSWORD_MISMATCH');
|
||||
expect((await call(app, sa, 'PUT', '/admin/backup/password', { ...body, password: '1234567890', repeat: '1234567890' })).json().error.code).toBe('WEAK_PASSWORD');
|
||||
const s0 = (await call(app, sa, 'GET', '/admin/backup/settings')).json(); expect(s0.encryption).toMatchObject({ mode: 'none', passwordSet: false, minLength: 11 });
|
||||
expect((await call(app, sa, 'PUT', '/admin/backup/password', body)).json().version).toBe(1);
|
||||
expect((await call(app, sa, 'PUT', '/admin/backup/password', { ...body, password: 'Noch-ein-anderes-Passwort-2', repeat: 'Noch-ein-anderes-Passwort-2' })).json().version).toBe(2);
|
||||
const s1 = (await call(app, admin, 'GET', '/admin/backup/settings')).json(); expect(s1.encryption).toMatchObject({ mode: 'password', passwordSet: true, version: 2 });
|
||||
expect(JSON.stringify(s1)).not.toContain('Passwort-2');
|
||||
const row = await one("SELECT value_enc FROM backup_settings WHERE `key` = 'password'"); expect(row!.value_enc).toMatch(/^v1:/); expect(row!.value_enc).not.toContain('Passwort');
|
||||
expect(JSON.stringify(await query('SELECT * FROM audit_events'))).not.toMatch(/Backup-Passwort-1|Passwort-2/); // nie im Audit
|
||||
expect((await query("SELECT result FROM audit_events WHERE action = 'backup.password.change' ORDER BY id")).map((r) => r.result)).toEqual(['denied', 'success', 'success']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Externe Ziele', () => {
|
||||
it('legt Ziele an, prüft Eingaben, liefert nie Geheimnisse und speichert sie verschlüsselt', async () => {
|
||||
const sa = await staff('bt-sa@x.test', 'superadmin'); const admin = await staff('bt-adm@x.test', 'admin');
|
||||
const ftp = { type: 'ftp', name: 'NAS-FTP', host: 'nas.example.test', user: 'backup', password: 'geheimes-ftp-passwort', tls: 'explicit', path: 'kundencenter' };
|
||||
expect((await call(app, admin, 'POST', '/admin/backup/targets', ftp)).statusCode).toBe(403);
|
||||
expect((await call(app, sa, 'POST', '/admin/backup/targets', { ...ftp, host: 'localhost' })).json().error.code).toBe('BAD_HOST'); // keine Zugriffe auf den Server selbst
|
||||
expect((await call(app, sa, 'POST', '/admin/backup/targets', { ...ftp, host: '169.254.169.254' })).json().error.code).toBe('BAD_HOST');
|
||||
expect((await call(app, sa, 'POST', '/admin/backup/targets', { ...ftp, path: '../../etc' })).json().error.code).toBe('BAD_PATH');
|
||||
expect((await call(app, sa, 'POST', '/admin/backup/targets', { type: 'local', name: 'Etc', path: '/etc/cron.d' })).json().error.code).toBe('BAD_PATH');
|
||||
expect((await call(app, sa, 'POST', '/admin/backup/targets', { type: 'sftp', name: 'S', host: 'sftp.example.test', user: 'u', authType: 'key', privateKey: 'kein schlüssel' })).json().error.code).toBe('SECRET_MISSING');
|
||||
expect((await call(app, sa, 'POST', '/admin/backup/targets', { type: 'gdrive', name: 'G', token: 'kein-json-token-abc' })).json().error.code).toBe('BAD_TOKEN');
|
||||
const ok = await call(app, sa, 'POST', '/admin/backup/targets', ftp); expect(ok.statusCode).toBe(200);
|
||||
expect((await call(app, sa, 'POST', '/admin/backup/targets', ftp)).json().error.code).toBe('NAME_EXISTS');
|
||||
const list = (await call(app, admin, 'GET', '/admin/backup/settings')).json().targets; // Admin sieht Ziele, aber keine Geheimnisse
|
||||
expect(list).toHaveLength(1); expect(list[0]).toMatchObject({ name: 'NAS-FTP', type: 'ftp', hasSecret: true, enabled: true });
|
||||
expect(JSON.stringify(list)).not.toContain('geheimes-ftp'); expect(JSON.stringify(list)).not.toContain('secrets_enc');
|
||||
const raw = await one('SELECT secrets_enc FROM backup_targets'); expect(raw!.secrets_enc).toMatch(/^v1:/); expect(raw!.secrets_enc).not.toContain('geheimes');
|
||||
expect(JSON.stringify(await query('SELECT * FROM audit_events'))).not.toContain('geheimes-ftp');
|
||||
// aktivieren/deaktivieren, löschen
|
||||
const id = list[0].id; expect((await call(app, sa, 'PATCH', `/admin/backup/targets/${id}`, { enabled: false })).statusCode).toBe(200);
|
||||
expect((await call(app, admin, 'GET', '/admin/backup')).json().hasExternalTarget).toBe(false); // deaktiviert zählt nicht
|
||||
expect((await call(app, sa, 'DELETE', `/admin/backup/targets/${id}`)).statusCode).toBe(200);
|
||||
expect((await call(app, sa, 'DELETE', `/admin/backup/targets/${id}`)).statusCode).toBe(404);
|
||||
});
|
||||
it('testet Ziele mit echtem Schreibtest und meldet Fehler verständlich', async () => {
|
||||
const sa = await staff('bt2-sa@x.test', 'superadmin');
|
||||
const good = (await call(app, sa, 'POST', '/admin/backup/targets', { type: 'local', name: 'Laufwerk', path: dest })).json().id;
|
||||
const t1 = (await call(app, sa, 'POST', `/admin/backup/targets/${good}/test`)).json(); expect(t1.ok).toBe(true); expect(readdirSync(dest)).toEqual([]); // Testdatei wurde wieder gelöscht
|
||||
const bad = (await call(app, sa, 'POST', '/admin/backup/targets', { type: 'local', name: 'Fehlt', path: join(root, 'gibt-es-nicht') })).json().id;
|
||||
const t2 = (await call(app, sa, 'POST', `/admin/backup/targets/${bad}/test`)).json(); expect(t2.ok).toBe(false); expect(t2.error).toMatch(/existiert nicht/);
|
||||
const s = (await call(app, sa, 'GET', '/admin/backup/settings')).json().targets; expect(s.find((x: any) => x.name === 'Laufwerk').lastTestOk).toBe(true); expect(s.find((x: any) => x.name === 'Fehlt').lastTestOk).toBe(false);
|
||||
expect((await query("SELECT COUNT(*) n FROM audit_events WHERE action = 'backup.target.test'"))[0]!.n).toBe(2);
|
||||
await call(app, sa, 'DELETE', `/admin/backup/targets/${bad}`);
|
||||
});
|
||||
it('baut rclone-Umgebung ohne Passwörter im Klartext und mit festem Server-Schlüssel', async () => {
|
||||
const t: TargetRow = { id: '12345678-aaaa-bbbb-cccc-1234567890ab', name: 'x', type: 'sftp', path: 'kc', enabled: true, hostKey: 'sftp.example.test ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIabc', config: { host: 'sftp.example.test', port: 2222, user: 'bk', authType: 'password' }, secrets: { password: 'klartext-passwort-9' } };
|
||||
const r = await buildRemote(t);
|
||||
const P = 'RCLONE_CONFIG_KCT12345678_'; expect(r.remote).toBe('KCT12345678:kc'); expect(r.env[`${P}TYPE`]).toBe('sftp'); expect(r.env[`${P}PORT`]).toBe('2222');
|
||||
expect(r.env[`${P}PASS`]).toBeTruthy(); expect(r.env[`${P}PASS`]).not.toContain('klartext'); // obscured
|
||||
const kh = r.env[`${P}KNOWN_HOSTS_FILE`]!; expect(readFileSync(kh, 'utf8')).toContain('ssh-ed25519'); expect(r.env.RCLONE_CONFIG).toBeTruthy(); await r.cleanup(); expect(existsSync(kh)).toBe(false); expect(existsSync(r.env.RCLONE_CONFIG!)).toBe(false);
|
||||
expect(friendly(new Error('ssh: handshake failed: knownhosts: key mismatch'))).toMatch(/Server-Schlüssel/);
|
||||
expect(friendly(new Error('Permission denied (publickey,password)'))).toMatch(/Anmeldung/);
|
||||
expect(friendly(new Error('dial tcp: lookup nas.x: no such host'))).toMatch(/nicht gefunden/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Backup mit Passwort und Ziel aus der Oberfläche', () => {
|
||||
it('verschlüsselt mit gpg, lädt zum definierten Ziel, Restore-Test besteht; falsches/geändertes Passwort wird erkannt', async () => {
|
||||
const sa = await staff('bp-sa@x.test', 'superadmin');
|
||||
await query('DELETE FROM backup_targets'); // Ziele früherer Tests entfernen
|
||||
await setBackupPassword('Mein-Backup-Passwort-2026', null);
|
||||
await call(app, sa, 'POST', '/admin/backup/targets', { type: 'local', name: 'Ziel-Laufwerk', path: dest });
|
||||
const r = await runBackup(cfg()); expect(r.error).toBeUndefined(); expect(r.ok).toBe(true);
|
||||
expect(r.file).toMatch(/\.tar\.gz\.gpg$/); expect(parseName(r.file!)?.ext).toBe('gpg'); expect(fileName(new Date(), 'gpg')).toMatch(/\.gpg$/);
|
||||
expect(r.targets.map((t) => [t.name.startsWith('lokal') ? 'lokal' : t.name, t.ok])).toEqual([['lokal', true], ['Ziel-Laufwerk', true]]);
|
||||
expect(readdirSync(dest).sort()).toEqual([r.file, `${r.file}.sha256`].sort());
|
||||
// Ohne Passwort unlesbar, mit Standard-gpg lesbar (Notfall-Weg ohne unser Tool)
|
||||
const file = join(dest, r.file!); const home = mkdtempSync(join(tmpdir(), 'gh-'));
|
||||
const dec = (pw: string) => execFileSync('gpg', ['--homedir', home, '--batch', '--pinentry-mode', 'loopback', '--passphrase-fd', '0', '-d', file], { input: pw, stdio: ['pipe', 'pipe', 'pipe'] });
|
||||
expect(() => dec('falsches-Passwort-123')).toThrow(); expect(dec('Mein-Backup-Passwort-2026').length).toBeGreaterThan(500);
|
||||
const raw = readFileSync(file); expect(raw.includes(Buffer.from('CREATE TABLE'))).toBe(false);
|
||||
const t = await runRestoreTest(cfg()); expect(t.error).toBeUndefined(); expect(t.ok).toBe(true); expect(t.checks).toMatchObject({ auditKette: true, geheimnisseOk: true });
|
||||
// Passwort ändern: alte Sicherung ist mit dem neuen Passwort nicht mehr lesbar (klare Meldung), neue Sicherung besteht
|
||||
await setBackupPassword('Ganz-Neues-Passwort-2027', null);
|
||||
const old = await runRestoreTest(cfg(), r.file); expect(old.ok).toBe(false); expect(old.error).toMatch(/Passwort passt nicht/);
|
||||
const r2 = await runBackup(cfg(), new Date(Date.now() + 120000)); expect(r2.ok).toBe(true);
|
||||
expect((await runRestoreTest(cfg(), r2.file)).ok).toBe(true);
|
||||
});
|
||||
it('sichert nie unverschlüsselt: ohne Passwort und Schlüssel bricht der Lauf ab', async () => {
|
||||
await query("DELETE FROM backup_settings");
|
||||
const r = await runBackup(cfg(), new Date(Date.now() + 240000)); expect(r.ok).toBe(false); expect(r.error).toMatch(/Keine Verschlüsselung/);
|
||||
});
|
||||
});
|
||||
87
apps/api/test/backup.test.ts
Normal file
87
apps/api/test/backup.test.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { statSync, mkdtempSync, mkdirSync, readdirSync, readFileSync, writeFileSync, existsSync, rmSync, appendFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { encrypt } from '../src/core/crypto.js';
|
||||
import { audit } from '../src/core/audit.js';
|
||||
import { run as dbRun } from '../src/core/db.js';
|
||||
import { config } from '../src/core/config.js';
|
||||
import { labelFor, parseName, selectDeletions, fileName } from '../src/ops/retention.js';
|
||||
import { loadBackupConfig, runBackup, runRestoreTest, readStatus, latestLocal } from '../src/ops/backup.js';
|
||||
|
||||
const d = (s: string) => new Date(`${s}T03:00:00Z`);
|
||||
describe('Aufbewahrung', () => {
|
||||
it('benennt und erkennt Sicherungen', () => {
|
||||
expect(fileName(new Date('2026-09-26T02:30:05Z'))).toBe('kundencenter-20260926-023005-daily.tar.gz.age');
|
||||
expect(labelFor(d('2026-09-27'))).toBe('weekly'); // Sonntag
|
||||
expect(labelFor(d('2026-10-01'))).toBe('monthly'); // Monatserster hat Vorrang vor Wochentag
|
||||
expect(labelFor(d('2026-09-30'))).toBe('daily');
|
||||
expect(parseName('kundencenter-20260926-023005-daily.tar.gz.age')?.label).toBe('daily');
|
||||
expect(parseName('fremd.txt')).toBeNull();
|
||||
expect(parseName('kundencenter-20260926-023005-daily.tar.gz.age.sha256')).toBeNull();
|
||||
});
|
||||
it('löscht nach Alter je Kategorie, nie Fremdes, nie unter den Mindestbestand', () => {
|
||||
const mk = (s: string) => fileName(d(s)); const now = d('2026-09-30'); const keep = { daily: 14, weekly: 8, monthly: 12 };
|
||||
const names = [mk('2026-09-29'), mk('2026-09-10'), mk('2026-08-02'), mk('2026-08-01'), mk('2025-08-01'), mk('2025-10-01'), 'notizen.txt'];
|
||||
const del = selectDeletions(names, now, keep);
|
||||
expect(del).toContain(mk('2026-09-10')); // daily, 20 Tage
|
||||
expect(del).toContain(mk('2025-08-01')); // monthly, älter als 12 Monate
|
||||
expect(del).not.toContain(mk('2026-09-29')); expect(del).not.toContain(mk('2026-08-01')); expect(del).not.toContain(mk('2025-10-01')); expect(del).not.toContain('notizen.txt');
|
||||
// Mindestbestand: auch wenn alles alt ist, bleiben die 3 neuesten
|
||||
const old = ['2024-01-05', '2024-01-06', '2024-01-08', '2024-01-09'].map(mk);
|
||||
expect(selectDeletions(old, now, keep)).toEqual([old[0]]);
|
||||
expect(selectDeletions([], now, keep)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Backup und Wiederherstellungstest (echte Werkzeuge, Testdatenbank)', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'kc-bk-')); const envDir = join(root, 'etc'); const dir = join(root, 'backups'); const remote = join(root, 'remote'); const idFile = join(root, 'age-key.txt'); const status = join(root, 'state', 'status.json');
|
||||
let recipient = '';
|
||||
beforeAll(async () => {
|
||||
mkdirSync(envDir); mkdirSync(remote);
|
||||
writeFileSync(join(envDir, 'app.env'), `KC_SECRET_KEY=${config.secretKey.toString('base64')}\n`); writeFileSync(join(envDir, 'db.env'), 'DB_NAME=x\n');
|
||||
execFileSync('age-keygen', ['-o', idFile], { stdio: 'pipe' }); recipient = /public key: (age1\w+)/.exec(readFileSync(idFile, 'utf8'))![1]!;
|
||||
// echte Beispieldaten inkl. verschlüsselter Geheimnisse und Audit-Kette
|
||||
await dbRun("INSERT INTO connector_instances (id, connector_key, name, config_json, secrets_enc) VALUES (UUID(), 'mock', 'bk-test', '{}', ?)", [encrypt(JSON.stringify({ token: 'geheim' }))]);
|
||||
await audit({ actorType: 'system', action: 'test.eins' }); await audit({ actorType: 'system', action: 'test.zwei' });
|
||||
});
|
||||
afterAll(() => rmSync(root, { recursive: true, force: true }));
|
||||
const cfg = () => loadBackupConfig({ BACKUP_DIR: dir, BACKUP_AGE_RECIPIENT: recipient, BACKUP_AGE_IDENTITY: idFile, BACKUP_REMOTES: remote, BACKUP_STATUS_FILE: status, KC_BACKUP_ENV_DIR: envDir, PATH: process.env.PATH });
|
||||
|
||||
it('erstellt ein verschlüsseltes Backup, lädt es zum Ziel und der Wiederherstellungstest besteht', async () => {
|
||||
const r = await runBackup(cfg()); expect(r.error).toBeUndefined(); expect(r.ok).toBe(true);
|
||||
const local = readdirSync(dir).filter((n) => parseName(n)); expect(local).toHaveLength(1);
|
||||
expect(statSync(join(dir, local[0]!)).mode & 0o077).toBe(0); // keine Rechte für Gruppe/Andere
|
||||
expect(existsSync(join(dir, `${local[0]}.sha256`))).toBe(true); expect(readdirSync(remote).sort()).toEqual([local[0], `${local[0]}.sha256`].sort());
|
||||
// Klartext darf nirgends liegen: weder SQL noch Schlüssel in der Datei
|
||||
const raw = readFileSync(join(dir, local[0]!)); expect(raw.includes(Buffer.from('CREATE TABLE'))).toBe(false); expect(raw.includes(Buffer.from(config.secretKey.toString('base64')))).toBe(false);
|
||||
expect(raw.subarray(0, 21).toString()).toContain('age-encryption.org');
|
||||
expect(readdirSync(dir).some((n) => n.startsWith('.work-'))).toBe(false); // Arbeitsordner aufgeräumt
|
||||
const t = await runRestoreTest(cfg()); expect(t.error).toBeUndefined(); expect(t.ok).toBe(true);
|
||||
expect(t.checks).toMatchObject({ pruefsumme: true, dumpPruefsumme: true, zeilenzahlen: true, migrationen: true, auditKette: true, geheimnisseOk: true }); expect(Number(t.checks.geheimnisseGeprueft)).toBeGreaterThanOrEqual(1);
|
||||
const s = await readStatus(cfg()); expect(s.lastRun?.ok).toBe(true); expect(s.lastRestoreTest?.ok).toBe(true);
|
||||
expect(await latestLocal(cfg())).toBe(local[0]);
|
||||
});
|
||||
it('meldet einen defekten Ziel-Ablauf als Fehler, behält aber das lokale Backup', async () => {
|
||||
const bad = loadBackupConfig({ BACKUP_DIR: dir, BACKUP_AGE_RECIPIENT: recipient, BACKUP_REMOTES: '/proc/nicht/schreibbar', BACKUP_STATUS_FILE: status, KC_BACKUP_ENV_DIR: envDir, PATH: process.env.PATH });
|
||||
const r = await runBackup(bad, new Date(Date.now() + 60000)); expect(r.ok).toBe(false);
|
||||
expect(r.targets.find((x) => x.name.startsWith('/proc'))?.ok).toBe(false); expect(r.targets[0]!.ok).toBe(true);
|
||||
expect((await readStatus(cfg())).lastRun?.ok).toBe(false);
|
||||
// ohne Passwort und ohne age-Schlüssel wird nichts unverschlüsselt gesichert
|
||||
const none = await runBackup(loadBackupConfig({ BACKUP_DIR: dir, BACKUP_STATUS_FILE: status, KC_BACKUP_ENV_DIR: envDir, PATH: process.env.PATH }), new Date(Date.now() + 120000));
|
||||
expect(none.ok).toBe(false); expect(none.error).toMatch(/Keine Verschlüsselung/);
|
||||
});
|
||||
it('erkennt manipulierte Sicherungen und fehlende Schlüssel', async () => {
|
||||
const name = (await latestLocal(cfg()))!; const file = join(dir, name);
|
||||
const before = readFileSync(file); appendFileSync(file, 'x');
|
||||
const t1 = await runRestoreTest(cfg(), name); expect(t1.ok).toBe(false); expect(t1.error).toMatch(/Prüfsumme/);
|
||||
writeFileSync(file, before);
|
||||
const other = join(root, 'other.txt'); execFileSync('age-keygen', ['-o', other], { stdio: 'pipe' });
|
||||
const wrong = loadBackupConfig({ BACKUP_DIR: dir, BACKUP_AGE_RECIPIENT: recipient, BACKUP_AGE_IDENTITY: other, BACKUP_STATUS_FILE: status, KC_BACKUP_ENV_DIR: envDir, PATH: process.env.PATH });
|
||||
const t2 = await runRestoreTest(wrong, name); expect(t2.ok).toBe(false); expect(t2.error).toMatch(/age/);
|
||||
const none = loadBackupConfig({ BACKUP_DIR: dir, BACKUP_AGE_RECIPIENT: recipient, BACKUP_STATUS_FILE: status, KC_BACKUP_ENV_DIR: envDir, PATH: process.env.PATH });
|
||||
expect((await runRestoreTest(none, name)).error).toMatch(/Schlüssel/);
|
||||
expect((await runRestoreTest(cfg(), name)).ok).toBe(true); // Original wieder heil -> Test besteht
|
||||
});
|
||||
});
|
||||
120
apps/api/test/connectors.test.ts
Normal file
120
apps/api/test/connectors.test.ts
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
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');
|
||||
});
|
||||
});
|
||||
218
apps/api/test/core.test.ts
Normal file
218
apps/api/test/core.test.ts
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
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 { verifyAuditChain } from '../src/core/audit.js';
|
||||
import { call, code, login, makeUser, nextCode } from './helpers.js';
|
||||
|
||||
let app: FastifyInstance;
|
||||
beforeAll(async () => { app = await buildApp(); await app.ready(); });
|
||||
|
||||
async function staffWithMfa(email: string, role: string) {
|
||||
await makeUser({ email, kind: 'staff', staffRole: role });
|
||||
const { client } = await login(app, email);
|
||||
const setup = (await call(app, client, 'POST', '/auth/mfa/setup')).json();
|
||||
const conf = await call(app, client, 'POST', '/auth/mfa/confirm', { code: code(setup.secret) });
|
||||
expect(conf.statusCode).toBe(200);
|
||||
expect(conf.json().recoveryCodes).toHaveLength(10);
|
||||
return { client, secret: setup.secret as string };
|
||||
}
|
||||
|
||||
describe('Login & Sitzungen', () => {
|
||||
it('lehnt falsches Passwort generisch ab und protokolliert', async () => {
|
||||
await makeUser({ email: 'a@example.test' });
|
||||
const r = await app.inject({ method: 'POST', url: '/v1/auth/login', payload: { email: 'a@example.test', password: 'falsch-falsch-falsch' } });
|
||||
expect(r.statusCode).toBe(401);
|
||||
const r2 = await app.inject({ method: 'POST', url: '/v1/auth/login', payload: { email: 'unbekannt@example.test', password: 'falsch-falsch-falsch' } });
|
||||
expect(r2.json().error.code).toBe(r.json().error.code);
|
||||
expect((await one("SELECT COUNT(*) n FROM audit_events WHERE action='auth.login' AND result='denied'"))!.n).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
it('sperrt das Konto nach 5 Fehlversuchen', async () => {
|
||||
await makeUser({ email: 'lock@example.test' });
|
||||
for (let i = 0; i < 5; i++) await app.inject({ method: 'POST', url: '/v1/auth/login', payload: { email: 'lock@example.test', password: 'falsch-falsch-falsch' } });
|
||||
const ok = await app.inject({ method: 'POST', url: '/v1/auth/login', payload: { email: 'lock@example.test', password: 'correct-horse-battery' } });
|
||||
expect(ok.statusCode).toBe(401);
|
||||
});
|
||||
it('verlangt CSRF-Token bei schreibenden Requests', async () => {
|
||||
await makeUser({ email: 'csrf@example.test' });
|
||||
const { client } = await login(app, 'csrf@example.test');
|
||||
const r = await app.inject({ method: 'POST', url: '/v1/auth/logout', headers: { cookie: client.cookie } });
|
||||
expect(r.statusCode).toBe(403);
|
||||
expect(r.json().error.code).toBe('BAD_CSRF');
|
||||
});
|
||||
it('kann Sitzungen widerrufen', async () => {
|
||||
await makeUser({ email: 's@example.test' });
|
||||
const a = (await login(app, 's@example.test')).client;
|
||||
const b = (await login(app, 's@example.test')).client;
|
||||
const list = (await call(app, a, 'GET', '/auth/sessions')).json();
|
||||
expect(list).toHaveLength(2);
|
||||
const other = list.find((s: any) => !s.current);
|
||||
expect((await call(app, a, 'DELETE', `/auth/sessions/${other.id}`)).statusCode).toBe(200);
|
||||
expect((await call(app, b, 'GET', '/auth/me')).statusCode).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('2FA', () => {
|
||||
it('erzwingt 2FA-Einrichtung für Staff und TOTP beim Login', async () => {
|
||||
await makeUser({ email: 'staff1@example.test', kind: 'staff', staffRole: 'admin' });
|
||||
const { client } = await login(app, 'staff1@example.test');
|
||||
expect((await call(app, client, 'GET', '/admin/customers')).json().error.code).toBe('MFA_ENROLL_REQUIRED');
|
||||
const setup = (await call(app, client, 'POST', '/auth/mfa/setup')).json();
|
||||
expect((await call(app, client, 'POST', '/auth/mfa/confirm', { code: '000000' })).statusCode).toBe(400);
|
||||
await call(app, client, 'POST', '/auth/mfa/confirm', { code: code(setup.secret) });
|
||||
expect((await call(app, client, 'GET', '/admin/customers')).statusCode).toBe(200);
|
||||
// neuer Login: Passwort allein reicht nicht
|
||||
const l2 = await login(app, 'staff1@example.test');
|
||||
expect(l2.res.json().status).toBe('mfa_required');
|
||||
expect((await call(app, l2.client, 'GET', '/admin/customers')).json().error.code).toBe('MFA_REQUIRED');
|
||||
expect((await call(app, l2.client, 'POST', '/auth/mfa/verify', { code: '123456' })).statusCode).toBe(401);
|
||||
expect((await call(app, l2.client, 'POST', '/auth/mfa/verify', { code: nextCode(setup.secret) })).statusCode).toBe(200);
|
||||
expect((await call(app, l2.client, 'GET', '/admin/customers')).statusCode).toBe(200);
|
||||
});
|
||||
it('speichert das TOTP-Secret nur verschlüsselt', async () => {
|
||||
const r = await one('SELECT secret_enc FROM mfa_totp LIMIT 1');
|
||||
expect(r!.secret_enc).toMatch(/^v1:/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Kundenanlage & Mandantentrennung', () => {
|
||||
it('legt Kunden an, lädt Owner ein, und trennt Mandanten strikt', async () => {
|
||||
const { client: admin } = await staffWithMfa('admin@example.test', 'admin');
|
||||
const mk = async (name: string, mail: string) => (await call(app, admin, 'POST', '/admin/customers', { type: 'business', name, owner: { email: mail, name }, billing: { city: 'Berlin' } })).json();
|
||||
const A = await mk('Firma A', 'owner-a@example.test');
|
||||
const B = await mk('Firma B', 'owner-b@example.test');
|
||||
expect(A.customerNumber).toMatch(/^K-\d+$/);
|
||||
expect(A.customerNumber).not.toBe(B.customerNumber);
|
||||
expect(A.inviteLink).toContain('/einladung?token=');
|
||||
// Einladung annehmen
|
||||
for (const [inv, pw] of [[A.inviteLink, 'passwort-owner-a1'], [B.inviteLink, 'passwort-owner-b1']] as const) {
|
||||
const token = new URL(inv).searchParams.get('token');
|
||||
expect((await app.inject({ method: 'POST', url: '/v1/auth/invite/accept', payload: { token, password: pw, repeat: pw } })).statusCode).toBe(200);
|
||||
}
|
||||
const ownerA = (await login(app, 'owner-a@example.test', 'passwort-owner-a1')).client;
|
||||
expect((await call(app, ownerA, 'GET', `/orgs/${A.id}`)).statusCode).toBe(200);
|
||||
expect((await call(app, ownerA, 'GET', `/orgs/${B.id}`)).statusCode).toBe(404); // fremde Org
|
||||
expect((await call(app, ownerA, 'GET', `/orgs/${B.id}/members`)).statusCode).toBe(404);
|
||||
expect((await call(app, ownerA, 'POST', `/orgs/${B.id}/invitations`, { email: 'x@example.test', name: 'X', role: 'member' })).statusCode).toBe(404);
|
||||
expect((await call(app, ownerA, 'GET', '/admin/customers')).statusCode).toBe(403); // keine Staff-Rechte
|
||||
expect((await call(app, ownerA, 'GET', '/admin/users')).statusCode).toBe(403);
|
||||
// Einladung nur einmal nutzbar
|
||||
const token = new URL(A.inviteLink).searchParams.get('token');
|
||||
expect((await app.inject({ method: 'POST', url: '/v1/auth/invite/accept', payload: { token, password: 'anderes-passwort-12', repeat: 'anderes-passwort-12' } })).statusCode).toBe(400);
|
||||
// Mitglied einladen; Member darf nicht einladen
|
||||
const inv = (await call(app, ownerA, 'POST', `/orgs/${A.id}/invitations`, { email: 'm@example.test', name: 'M', role: 'member' })).json();
|
||||
await app.inject({ method: 'POST', url: '/v1/auth/invite/accept', payload: { token: new URL(inv.inviteLink).searchParams.get('token'), password: 'member-passwort-1', repeat: 'member-passwort-1' } });
|
||||
const member = (await login(app, 'm@example.test', 'member-passwort-1')).client;
|
||||
expect((await call(app, member, 'GET', `/orgs/${A.id}`)).statusCode).toBe(200);
|
||||
expect((await call(app, member, 'POST', `/orgs/${A.id}/invitations`, { email: 'y@example.test', name: 'Y', role: 'member' })).statusCode).toBe(403);
|
||||
// Discord-Job wurde idempotent eingereiht, ohne personenbezogene Daten
|
||||
const jobs = await query("SELECT payload FROM jobs WHERE type='discord.notify' AND idempotency_key IN (?, ?)", [`customer.created:${A.id}`, `customer.created:${B.id}`]);
|
||||
expect(jobs).toHaveLength(2);
|
||||
expect(JSON.stringify(jobs)).not.toContain('example.test');
|
||||
});
|
||||
it('verhindert doppelte E-Mail bei Kundenanlage', async () => {
|
||||
const { client: admin } = await staffWithMfa('admin2@example.test', 'admin');
|
||||
const r = await call(app, admin, 'POST', '/admin/customers', { type: 'business', name: 'Dup', owner: { email: 'owner-a@example.test', name: 'Dup' } });
|
||||
expect(r.statusCode).toBe(409);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Rechte', () => {
|
||||
it('support darf lesen, aber nicht Kunden anlegen; admin darf keine Admins anlegen', async () => {
|
||||
const { client: sup } = await staffWithMfa('support@example.test', 'support');
|
||||
expect((await call(app, sup, 'GET', '/admin/customers')).statusCode).toBe(200);
|
||||
expect((await call(app, sup, 'POST', '/admin/customers', { type: 'business', name: 'N', owner: { email: 'n@example.test', name: 'N' } })).statusCode).toBe(403);
|
||||
expect((await call(app, sup, 'GET', '/admin/audit')).statusCode).toBe(403);
|
||||
const { client: adm } = await staffWithMfa('admin3@example.test', 'admin');
|
||||
expect((await call(app, adm, 'POST', '/admin/users', { email: 'newadmin@example.test', name: 'N', staffRole: 'admin' })).json().error.code).toBe('PRIVILEGED_ONLY');
|
||||
expect((await call(app, adm, 'POST', '/admin/users', { email: 'newsup@example.test', name: 'N', staffRole: 'support' })).statusCode).toBe(200);
|
||||
const { client: sa } = await staffWithMfa('super@example.test', 'superadmin');
|
||||
expect((await call(app, sa, 'POST', '/admin/users', { email: 'newadmin@example.test', name: 'N', staffRole: 'admin' })).statusCode).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Audit', () => {
|
||||
it('führt eine intakte Hash-Kette, maskiert Geheimnisse und erkennt Manipulation', async () => {
|
||||
expect((await verifyAuditChain()).brokenAt).toBeNull();
|
||||
const dump = JSON.stringify(await query('SELECT before_json, after_json FROM audit_events'));
|
||||
expect(dump).not.toMatch(/passwort-owner|correct-horse/);
|
||||
const first = await one('SELECT id FROM audit_events ORDER BY id LIMIT 1 OFFSET 3');
|
||||
await run("UPDATE audit_events SET action = 'manipuliert' WHERE id = ?", [first!.id]);
|
||||
expect((await verifyAuditChain()).brokenAt).toBe(first!.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Passwort-Wiederholung und 2FA zurücksetzen', () => {
|
||||
it('verlangt die Wiederholung des neuen Passworts', async () => {
|
||||
await makeUser({ email: 'pw@example.test' });
|
||||
const { client } = await login(app, 'pw@example.test');
|
||||
const bad = await call(app, client, 'POST', '/auth/password/change', { current: 'correct-horse-battery', next: 'neues-passwort-123', repeat: 'neues-passwort-124' });
|
||||
expect(bad.statusCode).toBe(400); expect(bad.json().error.code).toBe('PASSWORD_MISMATCH');
|
||||
expect((await call(app, client, 'POST', '/auth/password/change', { current: 'correct-horse-battery', next: 'neues-passwort-123' })).statusCode).toBe(400); // repeat fehlt
|
||||
expect((await call(app, client, 'POST', '/auth/password/change', { current: 'correct-horse-battery', next: 'neues-passwort-123', repeat: 'neues-passwort-123' })).statusCode).toBe(200);
|
||||
expect((await login(app, 'pw@example.test', 'neues-passwort-123')).res.statusCode).toBe(200);
|
||||
});
|
||||
it('erlaubt Kunden, 2FA zu entfernen und mit neuem Gerät neu einzurichten', async () => {
|
||||
await makeUser({ email: 'phone@example.test' });
|
||||
const { client } = await login(app, 'phone@example.test');
|
||||
const s1 = (await call(app, client, 'POST', '/auth/mfa/setup')).json();
|
||||
await call(app, client, 'POST', '/auth/mfa/confirm', { code: code(s1.secret) });
|
||||
expect((await call(app, client, 'POST', '/auth/mfa/setup')).json().error.code).toBe('MFA_ALREADY_ENABLED');
|
||||
// ohne korrektes Passwort / Code nicht möglich
|
||||
expect((await call(app, client, 'POST', '/auth/mfa/disable', { password: 'falsch-falsch-falsch', code: nextCode(s1.secret) })).statusCode).toBe(403);
|
||||
expect((await call(app, client, 'POST', '/auth/mfa/disable', { password: 'correct-horse-battery', code: '000000' })).statusCode).toBe(403);
|
||||
expect((await call(app, client, 'POST', '/auth/mfa/disable', { password: 'correct-horse-battery', code: nextCode(s1.secret) })).statusCode).toBe(200);
|
||||
expect((await one('SELECT COUNT(*) n FROM mfa_totp m JOIN users u ON u.id = m.user_id WHERE u.email = ?', ['phone@example.test']))!.n).toBe(0);
|
||||
// Login ohne 2FA, danach neu einrichten mit neuem Secret
|
||||
const l2 = await login(app, 'phone@example.test'); expect(l2.res.json().status).toBe('ok');
|
||||
const s2 = (await call(app, l2.client, 'POST', '/auth/mfa/setup')).json();
|
||||
expect(s2.secret).not.toBe(s1.secret);
|
||||
expect((await call(app, l2.client, 'POST', '/auth/mfa/confirm', { code: code(s2.secret) })).statusCode).toBe(200);
|
||||
expect((await login(app, 'phone@example.test')).res.json().status).toBe('mfa_required');
|
||||
});
|
||||
it('erlaubt Support-Reset der 2FA für Kunden, für Mitarbeiter nur Superadmin', async () => {
|
||||
const { client: adm } = await staffWithMfa('resetadm@example.test', 'admin');
|
||||
const cust = await makeUser({ email: 'lost@example.test' });
|
||||
const cl = (await login(app, 'lost@example.test')).client;
|
||||
const st = (await call(app, cl, 'POST', '/auth/mfa/setup')).json(); await call(app, cl, 'POST', '/auth/mfa/confirm', { code: code(st.secret) });
|
||||
expect((await call(app, adm, 'POST', `/admin/users/${cust}/mfa-reset`)).statusCode).toBe(200);
|
||||
expect((await call(app, cl, 'GET', '/auth/me')).statusCode).toBe(401); // Sitzungen beendet
|
||||
expect((await login(app, 'lost@example.test')).res.json().status).toBe('ok');
|
||||
const staffId = await makeUser({ email: 'st@example.test', kind: 'staff', staffRole: 'support' });
|
||||
expect((await call(app, adm, 'POST', `/admin/users/${staffId}/mfa-reset`)).json().error.code).toBe('PRIVILEGED_ONLY');
|
||||
const { client: sa } = await staffWithMfa('resetsa@example.test', 'superadmin');
|
||||
expect((await call(app, sa, 'POST', `/admin/users/${staffId}/mfa-reset`)).statusCode).toBe(200);
|
||||
expect((await one("SELECT COUNT(*) n FROM audit_events WHERE action='user.mfa.reset'"))!.n).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Privat- und Geschäftskunden', () => {
|
||||
it('erzwingt die Regeln je Kundenart und erlaubt Filter', async () => {
|
||||
const { client: adm } = await staffWithMfa('type-adm@example.test', 'admin');
|
||||
const mk = (b: object) => call(app, adm, 'POST', '/admin/customers', b);
|
||||
// Typ ist Pflicht
|
||||
expect((await mk({ name: 'X', owner: { email: 'x1@example.test' } })).statusCode).toBe(400);
|
||||
// Privatkunde: keine Firma / USt-IdNr.
|
||||
expect((await mk({ type: 'private', name: 'Erika Muster', owner: { email: 'p0@example.test' }, billing: { company: 'Firma GmbH' } })).json().error.code).toBe('PRIVATE_NO_COMPANY');
|
||||
expect((await mk({ type: 'private', name: 'Erika Muster', owner: { email: 'p0@example.test' }, billing: { vatId: 'DE123456789' } })).json().error.code).toBe('PRIVATE_NO_COMPANY');
|
||||
const priv = (await mk({ type: 'private', name: 'Erika Muster', owner: { email: 'p1@example.test' }, billing: { street: 'Weg 1', zip: '10115', city: 'Berlin' } })).json();
|
||||
expect(priv.customerNumber).toMatch(/^K-/);
|
||||
// Ansprechpartner-Name = Kunde (Owner-Name entfällt)
|
||||
expect((await one("SELECT name FROM users WHERE email = 'p1@example.test'"))!.name).toBe('Erika Muster');
|
||||
// Geschäftskunde: ungültige USt-IdNr. abgelehnt, gültige normalisiert; Firma = Name
|
||||
expect((await mk({ type: 'business', name: 'Muster GmbH', owner: { email: 'b0@example.test', name: 'Max' }, billing: { vatId: '123' } })).json().error.code).toBe('INVALID_VAT_ID');
|
||||
const biz = (await mk({ type: 'business', name: 'Muster GmbH', owner: { email: 'b1@example.test', name: 'Max Muster' }, billing: { vatId: 'de 123.456.789' } })).json();
|
||||
const d = (await call(app, adm, 'GET', `/admin/customers/${biz.id}`)).json();
|
||||
expect(d.customerType).toBe('business'); expect(d.billing.vatId).toBe('DE123456789'); expect(d.billing.company).toBe('Muster GmbH');
|
||||
// Filter
|
||||
const onlyPriv = (await call(app, adm, 'GET', '/admin/customers?type=private')).json();
|
||||
expect(onlyPriv.every((c: any) => c.customerType === 'private')).toBe(true);
|
||||
expect(onlyPriv.some((c: any) => c.id === priv.id)).toBe(true);
|
||||
// Wechsel Geschäft -> Privat nur, wenn Firma/USt-IdNr. entfernt werden
|
||||
expect((await call(app, adm, 'PATCH', `/admin/customers/${biz.id}`, { customerType: 'private' })).json().error.code).toBe('PRIVATE_NO_COMPANY');
|
||||
const sw = await call(app, adm, 'PATCH', `/admin/customers/${biz.id}`, { customerType: 'private', billing: { company: '', vatId: '' } });
|
||||
expect(sw.statusCode).toBe(200); expect(sw.json().customerType).toBe('private'); expect(sw.json().billing.vatId).toBeNull();
|
||||
// Privatkunde kann keine Firma bekommen
|
||||
expect((await call(app, adm, 'PATCH', `/admin/customers/${priv.id}`, { billing: { company: 'Neu GmbH' } })).json().error.code).toBe('PRIVATE_NO_COMPANY');
|
||||
});
|
||||
});
|
||||
98
apps/api/test/domains.test.ts
Normal file
98
apps/api/test/domains.test.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { buildApp } from '../src/server.js';
|
||||
import { breakdown, normalizeDomain, parsePriceList, roundPrice, sellPrice, splitDomain } from '../src/modules/domains/logic.js';
|
||||
import { call, code, login, makeUser } from './helpers.js';
|
||||
|
||||
const PASTE = `Domaintyp\tLaufzeit\t0-49\t50-199\t200-999\t1000+\tSetup
|
||||
com\t12\t12,50\t12,00\t11,00\t10,50\t
|
||||
com.au\t24\t69,00\t69,00\t68,00\t68,00\t
|
||||
cy\t12\t69,00\t69,00\t69,00\t69,00\t75,00 €
|
||||
voting\t12\t1.380,00\t1.360,00\t1.340,00\t1.320,00\t
|
||||
de\t12\t4,50\t4,00\t3,50\t3,00\t
|
||||
Laufzeit in Monaten, alle Preise zzgl. Ust.`;
|
||||
|
||||
describe('Domain-Logik', () => {
|
||||
it('liest das eingefügte Preislistenformat (deutsche Zahlen, Setup, Kopf/Fuß)', () => {
|
||||
const { rows, skipped } = parsePriceList(PASTE);
|
||||
expect(rows.map((r) => r.tld)).toEqual(['com', 'com.au', 'cy', 'voting', 'de']);
|
||||
expect(rows[1]).toMatchObject({ termMonths: 24, costs: [6900, 6900, 6800, 6800] });
|
||||
expect(rows[2]!.setupCents).toBe(7500); expect(rows[3]!.costs[0]).toBe(138000); expect(rows[0]!.setupCents).toBe(0);
|
||||
expect(skipped.length).toBe(1);
|
||||
});
|
||||
it('Verkaufspreis = Einkauf + Aufschlag; ohne Aufschlag kein Preis', () => {
|
||||
const none = { type: null, value: null };
|
||||
expect(sellPrice(1250, none, none)).toBeNull();
|
||||
expect(sellPrice(1250, none, { type: 'percent', value: 2500 })).toBe(1563);
|
||||
expect(sellPrice(1250, none, { type: 'fixed', value: 300 })).toBe(1550);
|
||||
expect(sellPrice(1250, { type: 'fixed', value: 100 }, { type: 'percent', value: 2500 })).toBe(1350); // Endungs-Aufschlag hat Vorrang
|
||||
expect(sellPrice(400, { type: 'percent', value: 0 }, none)).toBe(400);
|
||||
});
|
||||
it('Liste brutto: Netto wird herausgerechnet, keine zweite USt; Liste netto: USt wird aufgeschlagen', () => {
|
||||
const m = { type: 'percent' as const, value: 2000 }, none = { type: null, value: null };
|
||||
expect(breakdown(1190, none, m, false, 'gross', 1900)).toEqual({ costGrossCents: 1190, costNetCents: 1000, priceGrossCents: 1428, priceNetCents: 1200, profitNetCents: 200 });
|
||||
expect(breakdown(1000, none, m, false, 'net', 1900)).toEqual({ costGrossCents: 1190, costNetCents: 1000, priceGrossCents: 1428, priceNetCents: 1200, profitNetCents: 200 });
|
||||
});
|
||||
it('rundet auf ,50 bzw. ,99', () => {
|
||||
expect([1200, 1230, 1250, 1251, 1298, 1299, 450, 475, 1500].map(roundPrice)).toEqual([1250, 1250, 1250, 1299, 1299, 1299, 450, 499, 1550]);
|
||||
expect(sellPrice(1250, { type: null, value: null }, { type: 'percent', value: 2000 }, true)).toBe(1550); // 15,00 → 15,50
|
||||
});
|
||||
it('normalisiert und zerlegt Domainnamen', () => {
|
||||
expect(normalizeDomain(' https://www.Beispiel.DE/pfad?x=1 ')).toBe('beispiel.de');
|
||||
expect(normalizeDomain('müller.de')).toBe('xn--mller-kva.de');
|
||||
expect(normalizeDomain('a b.de')).toBeNull(); expect(normalizeDomain('-x.de')).toBeNull();
|
||||
const known = new Set(['uk', 'co.uk', 'de']);
|
||||
expect(splitDomain('firma.co.uk', known)).toEqual({ label: 'firma', tld: 'co.uk' });
|
||||
expect(splitDomain('firma.de', known)).toEqual({ label: 'firma', tld: 'de' });
|
||||
});
|
||||
});
|
||||
|
||||
let app: FastifyInstance; const realFetch = globalThis.fetch;
|
||||
beforeAll(async () => {
|
||||
vi.stubGlobal('fetch', (async (url: any, init: any) => {
|
||||
const u = String(url);
|
||||
if (u.includes('data.iana.org')) return new Response(JSON.stringify({ services: [[['de', 'com', 'net'], ['https://rdap.test/']]] }), { status: 200 });
|
||||
if (u.startsWith('https://rdap.test/domain/')) return new Response('{}', { status: u.includes('vergeben') ? 200 : 404 });
|
||||
return realFetch(url, init);
|
||||
}) as typeof fetch);
|
||||
app = await buildApp(); await app.ready();
|
||||
});
|
||||
afterAll(() => vi.unstubAllGlobals());
|
||||
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;
|
||||
}
|
||||
describe('Domains: Preisliste, Aufschlag, Prüfung', () => {
|
||||
it('Import, Aufschlag, Verkaufspreis, Verfügbarkeit; Kunden sehen keinen Einkauf', async () => {
|
||||
const admin = await staff('dom-admin@example.com', 'admin'); const sup = await staff('dom-support@example.com', 'support');
|
||||
const imp = await call(app, admin, 'POST', '/admin/domain-tlds/import', { text: PASTE }); expect(imp.statusCode).toBe(200); expect(imp.json()).toMatchObject({ rows: 5, created: 5 });
|
||||
expect((await call(app, sup, 'POST', '/admin/domain-tlds/import', { text: PASTE })).statusCode).toBe(403);
|
||||
expect((await call(app, sup, 'GET', '/admin/domain-tlds')).statusCode).toBe(200);
|
||||
|
||||
const before = (await call(app, admin, 'GET', '/domains/check?name=frei.com')).json().results[0];
|
||||
expect(before).toMatchObject({ domain: 'frei.com', status: 'available', method: 'rdap', offered: true, offer: null }); // noch kein Aufschlag → kein Preis
|
||||
expect((await call(app, admin, 'PUT', '/admin/domain-settings', { tier: 1, basis: 'gross', rounding: false, margin: { type: 'percent', value: 2000 } })).statusCode).toBe(200);
|
||||
expect((await call(app, admin, 'PATCH', '/admin/domain-tlds/de', { margin: { type: 'fixed', value: 500 } })).statusCode).toBe(200);
|
||||
const list = (await call(app, admin, 'GET', '/admin/domain-tlds')).json().tlds; const com = list.find((t: any) => t.tld === 'com'); const de = list.find((t: any) => t.tld === 'de');
|
||||
expect(com).toMatchObject({ costGrossCents: 1250, costNetCents: 1050, priceGrossCents: 1500, priceNetCents: 1261, profitNetCents: 211 }); expect(de).toMatchObject({ costGrossCents: 450, priceGrossCents: 950, priceNetCents: 798 });
|
||||
await call(app, admin, 'PUT', '/admin/domain-settings', { tier: 1, basis: 'gross', rounding: true, margin: { type: 'percent', value: 2000 } });
|
||||
expect((await call(app, admin, 'GET', '/admin/domain-tlds')).json().tlds.find((t: any) => t.tld === 'com')).toMatchObject({ priceGrossCents: 1550, priceNetCents: 1303, profitNetCents: 253 });
|
||||
|
||||
await makeUser({ email: 'dom-kunde@example.com' }); const { client: cust } = await login(app, 'dom-kunde@example.com');
|
||||
const r = (await call(app, cust, 'GET', '/domains/check?name=vergeben.com')).json().results[0];
|
||||
expect(r).toMatchObject({ status: 'registered', offer: { priceGrossCents: 1550, priceNetCents: 1303, termMonths: 12 } }); expect(JSON.stringify(r)).not.toContain('cost');
|
||||
const multi = (await call(app, cust, 'GET', '/domains/check?name=firma')).json().results; expect(multi.map((x: any) => x.domain).sort()).toEqual(['firma.com', 'firma.de']);
|
||||
expect((await call(app, cust, 'GET', '/domains/check?name=a%20b.de')).statusCode).toBe(400);
|
||||
expect((await call(app, cust, 'GET', '/admin/domain-tlds')).statusCode).toBe(403);
|
||||
// Aufstellung: Einkauf netto, Verkauf netto/brutto (19 %), Beschaffungsstatus
|
||||
const rec = await call(app, admin, 'POST', '/admin/domain-records', { domain: 'https://www.Kunde-Test.com/' }); expect(rec.statusCode).toBe(200);
|
||||
expect((await call(app, admin, 'POST', '/admin/domain-records', { domain: 'kunde-test.com' })).statusCode).toBe(400);
|
||||
const rlr = await call(app, admin, 'GET', '/admin/domain-records'); const rl = rlr.json(); expect(rl[0]).toMatchObject({ domain: 'kunde-test.com', costGrossCents: 1250, costNetCents: 1050, sellGrossCents: 1550, sellNetCents: 1303, profitNetCents: 253, procurement: 'open' });
|
||||
expect((await call(app, admin, 'PATCH', `/admin/domain-records/${rec.json().id}`, { procurement: 'ordered', orderedRef: 'KCS-1' })).statusCode).toBe(200);
|
||||
expect((await call(app, admin, 'GET', '/admin/domain-records?status=ordered')).json()[0]).toMatchObject({ procurement: 'ordered', orderedRef: 'KCS-1' });
|
||||
expect((await call(app, cust, 'GET', '/admin/domain-records')).statusCode).toBe(403); expect((await call(app, sup, 'POST', '/admin/domain-records', { domain: 'x.com' })).statusCode).toBe(403);
|
||||
expect((await call(app, admin, 'GET', '/admin/domain-records?orgId=00000000-0000-4000-8000-000000000000')).json()).toEqual([]); // Kundenfilter
|
||||
await call(app, admin, 'PATCH', '/admin/domain-tlds/com', { active: false });
|
||||
expect((await call(app, cust, 'GET', '/domains/check?name=frei.com')).json().results[0]).toMatchObject({ offered: false, offer: null });
|
||||
});
|
||||
});
|
||||
21
apps/api/test/global-setup.ts
Normal file
21
apps/api/test/global-setup.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import mysql from 'mysql2/promise';
|
||||
import { readFileSync, readdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import '../src/core/config.js';
|
||||
|
||||
/** Legt eine frische Testdatenbank an (niemals die produktive) und wendet alle Migrationen an. */
|
||||
export default async function setup() {
|
||||
const base = { host: process.env.DB_HOST ?? '127.0.0.1', user: process.env.DB_USER!, password: process.env.DB_PASSWORD! };
|
||||
const admin = await mysql.createConnection(base);
|
||||
await admin.query('DROP DATABASE IF EXISTS kundencenter_test');
|
||||
await admin.query('CREATE DATABASE kundencenter_test CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci');
|
||||
await admin.end();
|
||||
const c = await mysql.createConnection({ ...base, database: 'kundencenter_test' });
|
||||
await c.query('CREATE TABLE IF NOT EXISTS schema_migrations (name VARCHAR(200) PRIMARY KEY, applied_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3))');
|
||||
const dir = join(process.cwd(), '../../migrations');
|
||||
for (const f of readdirSync(dir).filter((n) => n.endsWith('.sql')).sort()) {
|
||||
await c.query('INSERT INTO schema_migrations (name) VALUES (?)', [f]);
|
||||
for (const st of readFileSync(join(dir, f), 'utf8').replace(/^\s*--.*$/gm, '').split(/;\s*\n/).map((s) => s.trim()).filter(Boolean)) await c.query(st);
|
||||
}
|
||||
await c.end();
|
||||
}
|
||||
23
apps/api/test/helpers.ts
Normal file
23
apps/api/test/helpers.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import type { FastifyInstance } from 'fastify';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { hash } from '@node-rs/argon2';
|
||||
import * as OTPAuth from 'otpauth';
|
||||
import { run } from '../src/core/db.js';
|
||||
|
||||
export interface Client { cookie: string; csrf: string }
|
||||
export async function makeUser(o: { email: string; kind?: 'customer' | 'staff'; staffRole?: string; password?: string }) {
|
||||
const id = randomUUID();
|
||||
await run("INSERT INTO users (id,email,name,password_hash,kind,staff_role,status) VALUES (?,?,?,?,?,?, 'active')", [id, o.email, o.email, await hash(o.password ?? 'correct-horse-battery'), o.kind ?? 'customer', o.staffRole ?? null]);
|
||||
return id;
|
||||
}
|
||||
export async function login(app: FastifyInstance, email: string, password = 'correct-horse-battery'): Promise<{ res: any; client: Client }> {
|
||||
const res = await app.inject({ method: 'POST', url: '/v1/auth/login', payload: { email, password } });
|
||||
const cookie = (res.cookies[0] ? `${res.cookies[0].name}=${res.cookies[0].value}` : '');
|
||||
const body = res.json();
|
||||
return { res, client: { cookie, csrf: body.csrf } };
|
||||
}
|
||||
export const call = (app: FastifyInstance, c: Client, method: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE', url: string, payload?: unknown) =>
|
||||
app.inject({ method, url: `/v1${url}`, payload: payload as never, headers: { cookie: c.cookie, 'x-csrf-token': c.csrf } });
|
||||
export const code = (secret: string) => new OTPAuth.TOTP({ secret: OTPAuth.Secret.fromBase32(secret), digits: 6, period: 30 }).generate();
|
||||
/** Nächster gültiger Code (für zweiten Schritt innerhalb desselben Tests, da Wiederverwendung verboten ist). */
|
||||
export const nextCode = (secret: string) => new OTPAuth.TOTP({ secret: OTPAuth.Secret.fromBase32(secret), digits: 6, period: 30 }).generate({ timestamp: Date.now() + 30000 });
|
||||
122
apps/api/test/keyhelp.test.ts
Normal file
122
apps/api/test/keyhelp.test.ts
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
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;
|
||||
});
|
||||
});
|
||||
268
apps/api/test/orders.test.ts
Normal file
268
apps/api/test/orders.test.ts
Normal file
|
|
@ -0,0 +1,268 @@
|
|||
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 { enqueue } from '../src/core/jobs.js';
|
||||
import { processContractLifecycle, provisionOrder } 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(); });
|
||||
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;
|
||||
}
|
||||
async function customer(admin: any, type: 'private' | 'business', name: string, mail: string) {
|
||||
const c = (await call(app, admin, 'POST', '/admin/customers', { type, 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 version = (over: object = {}) => ({ name: 'Lizenz Pro', taxRateId: '', setupCents: 1000, recurringCents: 4999, billingInterval: 'yearly', termMonths: 12, renewal: 'auto', renewalTermMonths: 12, noticeDays: 30, provisioning: {}, ...over });
|
||||
|
||||
describe('Produkte, Bestellungen, Verträge', () => {
|
||||
it('Katalog, Bestellung, Freigabe, Bereitstellung, Snapshot, Kündigung, Mandantentrennung', async () => {
|
||||
resetMock();
|
||||
const admin = await staff('adm@shop.test', 'admin'); const sa = await staff('sa@shop.test', 'superadmin'); const sup = await staff('sup@shop.test', 'support');
|
||||
const A = await customer(admin, 'business', 'Firma A', 'a@shop.test'); const B = await customer(admin, 'business', 'Firma B', 'b@shop.test'); const P = await customer(admin, 'private', 'Erika Muster', 'p@shop.test');
|
||||
const conn = (await call(app, sa, 'POST', '/admin/connectors', { connector: 'mock', name: 'Mock-Shop', values: { instance: 'shop' } })).json();
|
||||
await drain();
|
||||
const tax = (await call(app, admin, 'GET', '/admin/tax-rates')).json(); const t19 = tax.find((t: any) => t.rateBp === 1900).id;
|
||||
expect(tax.map((t: any) => t.rateBp).sort((x: number, y: number) => x - y)).toEqual([0, 700, 1900]);
|
||||
|
||||
// Rechte + Regeln beim Anlegen
|
||||
const mkBody = (v: object, o: object = {}) => ({ sku: 'LIC-PRO', category: 'license', connectorInstanceId: conn.id, orderableByCustomer: true, requiresApproval: true, customerActions: ['suspend'], status: 'active', version: { ...version({ taxRateId: t19 }), ...v }, ...o });
|
||||
expect((await call(app, sup, 'POST', '/admin/products', mkBody({}))).statusCode).toBe(403);
|
||||
expect((await call(app, admin, 'POST', '/admin/products', mkBody({ billingInterval: 'once', recurringCents: 100, termMonths: 0, renewal: 'none' }))).json().error.code).toBe('BAD_TERMS');
|
||||
expect((await call(app, admin, 'POST', '/admin/products', mkBody({ termMonths: 5 }))).json().error.code).toBe('BAD_TERMS'); // 5 kein Vielfaches von 12
|
||||
const prod = (await call(app, admin, 'POST', '/admin/products', mkBody({}))).json();
|
||||
expect((await call(app, admin, 'POST', '/admin/products', mkBody({}))).json().error.code).toBe('SKU_EXISTS');
|
||||
|
||||
// Kundenkatalog: Preise serverseitig (Netto/Brutto), nur für eigene Organisation
|
||||
const cat = (await call(app, A.client, 'GET', `/catalog?org=${A.org}`)).json();
|
||||
expect(cat).toHaveLength(1); expect(cat[0].price.recurring).toEqual({ net: 4999, tax: 950, gross: 5949 });
|
||||
expect((await call(app, A.client, 'GET', `/catalog?org=${B.org}`)).statusCode).toBe(404);
|
||||
|
||||
// Bestellen: Rabatt verboten, ohne Freigabe nicht ausführbar, fremde Org verboten
|
||||
const order = (over: object = {}, c = A) => call(app, c.client, 'POST', '/orders', { orgId: c.org, items: [{ productId: prod.id }], ...over });
|
||||
expect((await order({ items: [{ productId: prod.id, discountBp: 1000 }] })).statusCode).toBe(403);
|
||||
expect((await call(app, B.client, 'POST', '/orders', { orgId: A.org, items: [{ productId: prod.id }] })).statusCode).toBe(404);
|
||||
const o1 = (await order()).json(); expect(o1.status).toBe('pending_approval'); expect(o1.number).toMatch(/^B-/);
|
||||
expect((await call(app, A.client, 'POST', `/admin/orders/${o1.id}/approve`)).statusCode).toBe(403);
|
||||
expect((await call(app, sup, 'POST', `/admin/orders/${o1.id}/approve`)).statusCode).toBe(403);
|
||||
expect((await one("SELECT COUNT(*) n FROM resources WHERE org_id = ?", [A.org]))!.n).toBe(0);
|
||||
expect((await call(app, B.client, 'GET', `/orders/${o1.id}`)).statusCode).toBe(404);
|
||||
expect((await call(app, B.client, 'GET', '/orders')).json()).toHaveLength(0);
|
||||
|
||||
// Freigabe -> Bereitstellung über Worker -> Vertrag aktiv, Ressource beim Kunden
|
||||
expect((await call(app, admin, 'POST', `/admin/orders/${o1.id}/approve`)).statusCode).toBe(200);
|
||||
expect((await call(app, admin, 'POST', `/admin/orders/${o1.id}/approve`)).statusCode).toBe(409); // nicht doppelt freigeben
|
||||
await drain();
|
||||
const done = (await call(app, A.client, 'GET', `/orders/${o1.id}`)).json();
|
||||
expect(done.status).toBe('completed');
|
||||
const ct = (await call(app, A.client, 'GET', '/contracts')).json()[0];
|
||||
expect(ct.status).toBe('active'); expect(ct.number).toMatch(/^V-/); expect(ct.resourceId).toBeTruthy();
|
||||
const months = (new Date(ct.termEnd).getTime() - new Date(ct.startedAt).getTime()) / 86400000; expect(months).toBeGreaterThan(360); expect(months).toBeLessThan(370);
|
||||
const res = (await call(app, A.client, 'GET', `/resources/${ct.resourceId}`)).json();
|
||||
expect(res.state).toBe('active'); expect(res.allowedActions).toEqual(['suspend']); // Produktregel begrenzt Kundenaktionen
|
||||
expect((await call(app, B.client, 'GET', `/resources/${ct.resourceId}`)).statusCode).toBe(404);
|
||||
expect((await call(app, B.client, 'GET', `/contracts/${ct.id}`)).statusCode).toBe(404);
|
||||
|
||||
// Bereitstellung ist idempotent: erneuter Lauf erzeugt nichts Neues
|
||||
const before = (await one('SELECT COUNT(*) n FROM resources'))!.n;
|
||||
await run("UPDATE orders SET status='provisioning' WHERE id = ?", [o1.id]);
|
||||
await provisionOrder(o1.id, { id: 'x', correlationId: 'c', attempt: 1, maxAttempts: 5 });
|
||||
expect((await one('SELECT COUNT(*) n FROM resources'))!.n).toBe(before);
|
||||
expect((await one('SELECT status FROM orders WHERE id = ?', [o1.id]))!.status).toBe('completed');
|
||||
|
||||
// Preis-Snapshot bleibt bei Preisänderung unverändert; neue Bestellung nutzt neue Version
|
||||
expect((await call(app, admin, 'POST', `/admin/products/${prod.id}/versions`, version({ taxRateId: t19, recurringCents: 9999 }))).statusCode).toBe(200);
|
||||
expect((await call(app, A.client, 'GET', `/contracts/${ct.id}`)).json().price.recurring.net).toBe(4999);
|
||||
const cat2 = (await call(app, A.client, 'GET', `/catalog?org=${A.org}`)).json(); expect(cat2[0].price.recurring.net).toBe(9999);
|
||||
|
||||
// Personal bestellt mit Rabatt: sofort freigegeben, serverseitig berechnet
|
||||
const o2 = (await call(app, admin, 'POST', '/orders', { orgId: B.org, items: [{ productId: prod.id, discountBp: 1000 }] })).json(); expect(o2.status).toBe('provisioning');
|
||||
await drain();
|
||||
const o2d = (await call(app, admin, 'GET', `/orders/${o2.id}`)).json();
|
||||
expect(o2d.status).toBe('completed'); expect(o2d.items[0].snapshot.recurring).toEqual({ net: 8999, tax: 1710, gross: 10709 }); // 99,99 * 0,9 = 89,991
|
||||
expect(o2d.items[0].snapshot.setup.net).toBe(900);
|
||||
|
||||
// Privatkunde: Verbraucherregel (Verlängerung 1 Monat, Frist max. 30 Tage)
|
||||
const o3 = (await order({}, P)).json(); await call(app, admin, 'POST', `/admin/orders/${o3.id}/approve`); await drain();
|
||||
const pc = (await call(app, P.client, 'GET', '/contracts')).json()[0];
|
||||
expect(pc.renewalTermMonths).toBe(1); expect(pc.noticeDays).toBe(30);
|
||||
|
||||
// Kündigung: zum Laufzeitende, widerrufbar, Kunde darf nicht sofort beenden
|
||||
expect((await call(app, A.client, 'POST', `/contracts/${ct.id}/cancel`, { immediate: true })).json().error.code).toBe('STAFF_ONLY');
|
||||
expect((await call(app, B.client, 'POST', `/contracts/${ct.id}/cancel`)).statusCode).toBe(404);
|
||||
const cn = (await call(app, A.client, 'POST', `/contracts/${ct.id}/cancel`)).json();
|
||||
expect(new Date(cn.cancelEffectiveAt).getTime()).toBe(new Date(ct.termEnd).getTime());
|
||||
expect((await call(app, A.client, 'POST', `/contracts/${ct.id}/cancel`)).statusCode).toBe(409);
|
||||
expect((await call(app, A.client, 'POST', `/contracts/${ct.id}/cancel/revoke`)).statusCode).toBe(200);
|
||||
await call(app, A.client, 'POST', `/contracts/${ct.id}/cancel`);
|
||||
// Zeitsprung: Kündigung wird wirksam -> Vertrag beendet, Ressource gesperrt (nicht gelöscht)
|
||||
await run('UPDATE contracts SET cancel_effective_at = DATE_SUB(UTC_TIMESTAMP(3), INTERVAL 1 MINUTE) WHERE id = ?', [ct.id]);
|
||||
const life = await processContractLifecycle(new Date(), (t, p, o) => enqueue(t, p, o)); expect(life.ended).toBe(1);
|
||||
await processContractLifecycle(new Date(), (t, p, o) => enqueue(t, p, o)); // idempotent
|
||||
expect((await query("SELECT id FROM jobs WHERE idempotency_key = ?", [`contract-end:${ct.id}`])).length).toBe(1);
|
||||
await drain();
|
||||
expect((await call(app, A.client, 'GET', `/contracts/${ct.id}`)).json().status).toBe('cancelled');
|
||||
expect((await call(app, admin, 'GET', `/resources/${ct.resourceId}`)).json().state).toBe('suspended');
|
||||
expect((await one("SELECT COUNT(*) n FROM audit_events WHERE action IN ('order.create','order.approve','contract.activate','contract.cancel.request','contract.cancel.effective')"))!.n).toBeGreaterThanOrEqual(8);
|
||||
});
|
||||
|
||||
it('automatische Verlängerung ohne Kündigung', async () => {
|
||||
const c = await one("SELECT id FROM contracts WHERE status = 'active' LIMIT 1");
|
||||
await run('UPDATE contracts SET term_end = DATE_SUB(UTC_TIMESTAMP(3), INTERVAL 2 DAY) WHERE id = ?', [c!.id]);
|
||||
const r = await processContractLifecycle(new Date(), (t, p, o) => enqueue(t, p, o));
|
||||
expect(r.renewed).toBeGreaterThanOrEqual(1);
|
||||
const after = await one('SELECT term_end, status FROM contracts WHERE id = ?', [c!.id]);
|
||||
expect(new Date(after!.term_end).getTime()).toBeGreaterThan(Date.now()); expect(after!.status).toBe('active');
|
||||
});
|
||||
|
||||
it('Fehlerfälle: unklarer Ausgang wird nie automatisch wiederholt; sichere Fehler begrenzt', async () => {
|
||||
const admin = await staff('adm2@shop.test', 'admin'); const sa = await staff('sa2@shop.test', 'superadmin');
|
||||
const C = await customer(admin, 'business', 'Firma C', 'c@shop.test');
|
||||
const inst = await one("SELECT id FROM connector_instances LIMIT 1");
|
||||
const t19 = (await call(app, admin, 'GET', '/admin/tax-rates')).json().find((t: any) => t.rateBp === 1900).id;
|
||||
const mk = async (sku: string, failCreate: string) => (await call(app, admin, 'POST', '/admin/products', { sku, category: 'license', connectorInstanceId: inst!.id, status: 'active', version: version({ taxRateId: t19, provisioning: { failCreate } }) })).json();
|
||||
const resBefore = (await one('SELECT COUNT(*) n FROM resources'))!.n;
|
||||
// Timeout: unklar, ob angelegt -> sofort failed, ambiguous, kein Retry
|
||||
const pT = await mk('FAIL-TIMEOUT', 'timeout');
|
||||
const oT = (await call(app, admin, 'POST', '/orders', { orgId: C.org, items: [{ productId: pT.id }] })).json(); await drain();
|
||||
const dT = (await call(app, admin, 'GET', `/orders/${oT.id}`)).json();
|
||||
expect(dT.status).toBe('failed'); expect(dT.failureAmbiguous).toBe(true); expect(dT.failureNote).toMatch(/unklar/);
|
||||
expect((await one("SELECT attempts, status FROM jobs WHERE type='order.provision' AND JSON_VALUE(payload,'$.orderId') = ?", [oT.id]))).toMatchObject({ attempts: 1, status: 'needs_review' });
|
||||
expect((await call(app, admin, 'POST', `/admin/orders/${oT.id}/retry`, {})).json().error.code).toBe('CONFIRM_REQUIRED');
|
||||
expect((await call(app, admin, 'POST', `/admin/orders/${oT.id}/retry`, { confirmChecked: true })).statusCode).toBe(200);
|
||||
// Verbindung verweigert: sicher nicht gesendet -> begrenzt wiederholt, dann failed (nicht ambiguous)
|
||||
const pU = await mk('FAIL-UNREACH', 'unreachable');
|
||||
const oU = (await call(app, admin, 'POST', '/orders', { orgId: C.org, items: [{ productId: pU.id }] })).json(); await drain();
|
||||
const dU = (await call(app, admin, 'GET', `/orders/${oU.id}`)).json();
|
||||
expect(dU.status).toBe('failed'); expect(dU.failureAmbiguous).toBe(false);
|
||||
expect((await one("SELECT attempts FROM jobs WHERE type='order.provision' AND JSON_VALUE(payload,'$.orderId') = ?", [oU.id]))!.attempts).toBe(5);
|
||||
expect((await one('SELECT COUNT(*) n FROM resources'))!.n).toBe(resBefore);
|
||||
// Verträge der fehlgeschlagenen Bestellung bleiben ausstehend (nicht aktiv)
|
||||
expect((await call(app, admin, 'GET', `/contracts?org=${C.org}`)).json().every((c: any) => c.status === 'pending')).toBe(true);
|
||||
// Zurückziehen einer gescheiterten Bestellung beendet die Verträge
|
||||
expect((await call(app, admin, 'POST', `/orders/${oU.id}/cancel`)).statusCode).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Produktübernahme aus Verbindungen', () => {
|
||||
it('liest Vorlagen des Anbieters, übernimmt sie mit Details und zählt Übernahmen', async () => {
|
||||
resetMock();
|
||||
const admin = await staff('imp-adm@shop.test', 'admin'); const sa = await staff('imp-sa@shop.test', 'superadmin'); const sup = await staff('imp-sup@shop.test', 'support');
|
||||
const conn = (await call(app, sa, 'POST', '/admin/connectors', { connector: 'mock', name: 'Mock-Import', values: { instance: 'imp' } })).json();
|
||||
await drain();
|
||||
const t19 = (await call(app, admin, 'GET', '/admin/tax-rates')).json().find((t: any) => t.rateBp === 1900).id;
|
||||
expect((await call(app, sup, 'GET', `/admin/connectors/${conn.id}/catalog`)).statusCode).toBe(403);
|
||||
const cat = (await call(app, admin, 'GET', `/admin/connectors/${conn.id}/catalog`)).json();
|
||||
expect(cat.map((c: any) => c.externalRef)).toEqual(['mock-prog-1', 'mock-prog-2']);
|
||||
expect(cat.every((c: any) => c.importedProducts === 0)).toBe(true);
|
||||
expect(cat[0].hints[0].label).toBe('5 Benutzer');
|
||||
// Übernahme mit selbst definierten Details (zwei Varianten desselben Angebots)
|
||||
const imp = (sku: string, over: object = {}) => call(app, admin, 'POST', '/admin/products', { sku, category: 'license', connectorInstanceId: conn.id, externalRef: 'mock-prog-1', orderableByCustomer: true, requiresApproval: false, customerActions: ['suspend'],
|
||||
version: { name: 'Alpha Jahreslizenz', taxRateId: t19, setupCents: 0, recurringCents: 12000, billingInterval: 'yearly', termMonths: 12, renewal: 'auto', renewalTermMonths: 12, noticeDays: 30, provisioning: { userLimit: 5 }, ...over } });
|
||||
const a1 = await imp('ALPHA-Y'); expect(a1.statusCode).toBe(200);
|
||||
expect((await imp('ALPHA-M', { name: 'Alpha Monatslizenz', recurringCents: 1200, billingInterval: 'monthly', termMonths: 0, renewalTermMonths: 1 })).statusCode).toBe(200);
|
||||
const cat2 = (await call(app, admin, 'GET', `/admin/connectors/${conn.id}/catalog`)).json();
|
||||
expect(cat2.find((c: any) => c.externalRef === 'mock-prog-1').importedProducts).toBe(2);
|
||||
expect(cat2.find((c: any) => c.externalRef === 'mock-prog-2').importedProducts).toBe(0);
|
||||
const p = (await call(app, admin, 'GET', `/admin/products/${a1.json().id}`)).json(); expect(p.externalRef).toBe('mock-prog-1');
|
||||
// Herkunft nur mit Verbindung
|
||||
expect((await call(app, admin, 'POST', '/admin/products', { sku: 'X-1', category: 'license', externalRef: 'x', version: { name: 'X', taxRateId: t19, billingInterval: 'once', provisioning: {} } })).json().error.code).toBe('BAD_CONNECTOR');
|
||||
// Ausfall des Anbieters: verständlicher Fehler statt Absturz
|
||||
await call(app, sa, 'PATCH', `/admin/connectors/${conn.id}`, { values: { simulateOutage: 'true' } });
|
||||
const down = await call(app, admin, 'GET', `/admin/connectors/${conn.id}/catalog`);
|
||||
expect(down.statusCode).toBe(502); expect(down.json().error.message).toMatch(/nicht erreichbar/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Bruttopreise', () => {
|
||||
it('bestellt zu exakten Endkundenpreisen (Privatkunde 9,99 € brutto) und speichert die Basis im Snapshot', async () => {
|
||||
const admin = await staff('gross-adm@shop.test', 'admin');
|
||||
const P = await customer(admin, 'private', 'Erika Muster', 'gross-p@shop.test');
|
||||
const t19 = (await call(app, admin, 'GET', '/admin/tax-rates')).json().find((t: any) => t.rateBp === 1900).id;
|
||||
const prod = (await call(app, admin, 'POST', '/admin/products', { sku: 'GROSS-1', category: 'service', status: 'active', orderableByCustomer: true, requiresApproval: false,
|
||||
version: { name: 'Beispiel brutto', taxRateId: t19, priceBasis: 'gross', setupCents: 0, recurringCents: 999, billingInterval: 'monthly', termMonths: 0, renewal: 'auto', renewalTermMonths: 1, noticeDays: 30, provisioning: {} } })).json();
|
||||
const cat = (await call(app, P.client, 'GET', `/catalog?org=${P.org}`)).json();
|
||||
expect(cat[0].price.recurring).toEqual({ net: 839, tax: 160, gross: 999 }); expect(cat[0].price.basis).toBe('gross');
|
||||
const o = (await call(app, P.client, 'POST', '/orders', { orgId: P.org, items: [{ productId: prod.id }] })).json();
|
||||
await drain();
|
||||
const d = (await call(app, P.client, 'GET', `/orders/${o.id}`)).json();
|
||||
expect(d.status).toBe('completed'); expect(d.items[0].snapshot.recurring.gross).toBe(999); expect(d.items[0].snapshot.basis).toBe('gross');
|
||||
// Preisänderung gilt nur für neue Bestellungen; alter Vertrag bleibt 9,99 €
|
||||
await call(app, admin, 'POST', `/admin/products/${prod.id}/versions`, { name: 'Beispiel brutto', taxRateId: t19, priceBasis: 'gross', recurringCents: 1299, billingInterval: 'monthly', termMonths: 0, renewal: 'auto', renewalTermMonths: 1, noticeDays: 30, provisioning: {} });
|
||||
expect((await call(app, P.client, 'GET', '/contracts')).json()[0].price.recurring.gross).toBe(999);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Familytool-Editionen (Paket), Edition und Laufzeitpflege', () => {
|
||||
it('importiert das Paket als Entwürfe, sperrt Aktivierung ohne Anbieter-Erweiterung, liefert Editionen und verlängert die Lizenz mit dem Vertrag', async () => {
|
||||
resetMock();
|
||||
const admin = await staff('ft-adm@shop.test', 'admin'); const sa = await staff('ft-sa@shop.test', 'superadmin'); const sup = await staff('ft-sup@shop.test', 'support');
|
||||
const P = await customer(admin, 'private', 'Erika Muster', 'ft-p@shop.test');
|
||||
const conn = (await call(app, sa, 'POST', '/admin/connectors', { connector: 'mock', name: 'Mock-FT', values: { instance: 'ft' } })).json(); await drain();
|
||||
|
||||
expect((await call(app, sup, 'GET', '/admin/product-bundles')).statusCode).toBe(403);
|
||||
const bundles = (await call(app, admin, 'GET', '/admin/product-bundles')).json();
|
||||
const ft = bundles.find((b: any) => b.key === 'familytool-vorlage'); expect(ft.products.map((p: any) => p.sku)).toEqual(['FT-PREMIUM-M', 'FT-PREMIUM-Y', 'FT-UNLIMITED-M', 'FT-UNLIMITED-Y', 'FT-LIFETIME', 'FT-TRIAL']);
|
||||
expect(ft.products.find((p: any) => p.sku === 'FT-UNLIMITED-M').recurringCents).toBe(999);
|
||||
const skus = ft.products.map((p: any) => p.sku);
|
||||
const imp = (await call(app, admin, 'POST', '/admin/product-bundles/familytool-vorlage/import', { connectorInstanceId: conn.id, programRef: 'mock-prog-1', skus })).json();
|
||||
expect(imp.created).toHaveLength(6); expect(imp.skipped).toHaveLength(0);
|
||||
// nochmals importieren: alles übersprungen (Artikelnummern existieren), nichts doppelt
|
||||
const again = (await call(app, admin, 'POST', '/admin/product-bundles/familytool-vorlage/import', { connectorInstanceId: conn.id, programRef: 'mock-prog-1', skus })).json();
|
||||
expect(again.created).toHaveLength(0); expect(again.skipped).toHaveLength(6);
|
||||
const list = (await call(app, admin, 'GET', '/admin/products')).json(); expect(list.filter((p: any) => p.sku.startsWith('FT-')).every((p: any) => p.status === 'draft')).toBe(true);
|
||||
const byS = (s: string) => list.find((p: any) => p.sku === s);
|
||||
expect(byS('FT-PREMIUM-Y').provisioning).toMatchObject({ keyPrefix: 'PREMIUM', durationType: 'YEAR' });
|
||||
|
||||
// Aktivierungssperre: Anbieter meldet Präfix-Unterstützung nicht -> 400, Entwurf bleibt
|
||||
await run("UPDATE connector_instances SET capabilities_json = JSON_REMOVE(capabilities_json, '$[11]', '$[10]') WHERE id = ?", [conn.id]);
|
||||
const caps = (await one('SELECT capabilities_json c FROM connector_instances WHERE id = ?', [conn.id]))!.c; expect(JSON.stringify(caps)).not.toContain('license.key_prefix');
|
||||
const blocked = await call(app, admin, 'PATCH', `/admin/products/${byS('FT-PREMIUM-M').id}`, { status: 'active' });
|
||||
expect(blocked.statusCode).toBe(400); expect(blocked.json().error.code).toBe('NEEDS_LICENSE_EXTENSION');
|
||||
// Unlimited ohne Präfix ist trotzdem aktivierbar
|
||||
expect((await call(app, admin, 'PATCH', `/admin/products/${byS('FT-UNLIMITED-M').id}`, { status: 'active' })).statusCode).toBe(200);
|
||||
// Nach Erweiterung (Abgleich meldet Fähigkeit wieder) sind alle aktivierbar
|
||||
await call(app, sa, 'POST', `/admin/connectors/${conn.id}/sync`); await drain();
|
||||
for (const s of ['FT-PREMIUM-M', 'FT-PREMIUM-Y']) expect((await call(app, admin, 'PATCH', `/admin/products/${byS(s).id}`, { status: 'active' })).statusCode).toBe(200);
|
||||
|
||||
// Privatkunde bestellt Premium monatlich: exakt 2,99 € brutto, Edition PREMIUM, rollierender Monatsvertrag
|
||||
const cat = (await call(app, P.client, 'GET', `/catalog?org=${P.org}`)).json();
|
||||
expect(cat.find((c: any) => c.sku === 'FT-PREMIUM-M').price.recurring).toEqual({ net: 251, tax: 48, gross: 299 });
|
||||
const oM = (await call(app, P.client, 'POST', '/orders', { orgId: P.org, items: [{ productId: byS('FT-PREMIUM-M').id }] })).json(); expect(oM.status).toBe('provisioning'); await drain();
|
||||
const cM = (await call(app, P.client, 'GET', '/contracts')).json()[0];
|
||||
expect(cM.status).toBe('active'); expect(cM.termEnd).toBeTruthy(); // Verlängerungstakt trotz Mindestlaufzeit 0
|
||||
const rM = (await call(app, P.client, 'GET', `/resources/${cM.resourceId}`)).json(); expect(rM.details.edition).toBe('PREMIUM');
|
||||
// Kunde und Herkunft wurden an den Anbieter gemeldet
|
||||
expect(rM.details.context).toMatchObject({ source: 'kundencenter', customerName: 'Erika Muster', customerEmail: 'ft-p@shop.test', contractNumber: cM.number });
|
||||
expect(rM.details.context.customerNumber).toMatch(/^K-\d+$/); expect(rM.details.context.orderNumber).toMatch(/^B-\d+$/);
|
||||
// Vertragsverlängerung zieht die Lizenz mit
|
||||
const until0 = new Date(rM.validUntil).getTime();
|
||||
await run('UPDATE contracts SET term_end = DATE_SUB(UTC_TIMESTAMP(3), INTERVAL 1 DAY) WHERE id = ?', [cM.id]);
|
||||
expect((await processContractLifecycle(new Date(), (t, p, o) => enqueue(t, p, o))).renewed).toBe(1); await drain();
|
||||
const after = (await call(app, admin, 'GET', `/resources/${cM.resourceId}`)).json();
|
||||
const cAfter = (await call(app, admin, 'GET', `/contracts/${cM.id}`)).json();
|
||||
expect(new Date(after.validUntil).toISOString()).toBe(new Date(cAfter.termEnd).toISOString()); expect(new Date(after.validUntil).getTime()).toBeGreaterThan(until0 - 86400000);
|
||||
// Lifetime: Freigabe nötig, Edition LIFETIME, unbefristet, keine Verlängerung
|
||||
await call(app, admin, 'PATCH', `/admin/products/${byS('FT-LIFETIME').id}`, { status: 'active' });
|
||||
const oL = (await call(app, admin, 'POST', '/orders', { orgId: P.org, items: [{ productId: byS('FT-LIFETIME').id }] })).json(); await drain();
|
||||
const cL = (await call(app, admin, 'GET', `/contracts?org=${P.org}`)).json().find((c: any) => c.id !== cM.id);
|
||||
expect(cL.termEnd).toBeNull(); expect((await call(app, admin, 'GET', `/resources/${cL.resourceId}`)).json().details.edition).toBe('LIFETIME');
|
||||
expect(cL.price.setup.gross).toBe(15900);
|
||||
// Trial: 14 Tage Gültigkeit, Edition TRIAL
|
||||
await call(app, admin, 'PATCH', `/admin/products/${byS('FT-TRIAL').id}`, { status: 'active' });
|
||||
await call(app, admin, 'POST', '/orders', { orgId: P.org, items: [{ productId: byS('FT-TRIAL').id }] }); await drain();
|
||||
const trial = (await call(app, admin, 'GET', `/contracts?org=${P.org}`)).json().find((c: any) => c.price.sku === 'FT-TRIAL');
|
||||
const tr = (await call(app, admin, 'GET', `/resources/${trial.resourceId}`)).json(); expect(tr.details.edition).toBe('TRIAL');
|
||||
const days = (new Date(tr.validUntil).getTime() - Date.now()) / 86400000; expect(days).toBeGreaterThan(13); expect(days).toBeLessThan(14.1);
|
||||
});
|
||||
});
|
||||
65
apps/api/test/reveal.test.ts
Normal file
65
apps/api/test/reveal.test.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
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 { 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(); });
|
||||
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;
|
||||
}
|
||||
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 };
|
||||
}
|
||||
|
||||
describe('Schlüssel auf Abruf', () => {
|
||||
it('zeigt den vollständigen Schlüssel nur Berechtigten, nie in Listen/Details/Audit', async () => {
|
||||
resetMock();
|
||||
const admin = await staff('rv-adm@x.test', 'admin'); const sa = await staff('rv-sa@x.test', 'superadmin'); const sup = await staff('rv-sup@x.test', 'support');
|
||||
const A = await customer(admin, 'Firma A', 'rv-a@x.test'); const B = await customer(admin, 'Firma B', 'rv-b@x.test');
|
||||
// Mitglied ohne Verwaltungsrecht in A
|
||||
const inv = (await call(app, A.client, 'POST', `/orgs/${A.org}/invitations`, { email: 'rv-m@x.test', name: 'M', role: 'member' })).json();
|
||||
await app.inject({ method: 'POST', url: '/v1/auth/invite/accept', payload: { token: new URL(inv.inviteLink).searchParams.get('token'), password: 'member-passwort-1', repeat: 'member-passwort-1' } });
|
||||
const member = (await login(app, 'rv-m@x.test', 'member-passwort-1')).client;
|
||||
await call(app, sa, 'POST', '/admin/connectors', { connector: 'mock', name: 'Mock-Reveal', values: { instance: 'rv' } }); await drain();
|
||||
const res = (await call(app, admin, 'GET', '/resources')).json()[0];
|
||||
await call(app, admin, 'PATCH', `/admin/resources/${res.id}`, { orgId: A.org });
|
||||
|
||||
// Liste: Schlüsselfeld nur für Berechtigte
|
||||
expect((await call(app, A.client, 'GET', '/resources')).json()[0].canReveal).toBe(true);
|
||||
expect((await call(app, member, 'GET', '/resources')).json()[0].canReveal).toBe(false);
|
||||
expect((await call(app, sup, 'GET', '/resources')).json()[0].canReveal).toBe(false);
|
||||
const d = (await call(app, A.client, 'GET', `/resources/${res.id}`)).json();
|
||||
expect(d.canReveal).toBe(true); expect((await call(app, member, 'GET', `/resources/${res.id}`)).json().canReveal).toBe(false);
|
||||
expect(JSON.stringify(d)).not.toContain('MOCK-KEY'); // Details enthalten den Schlüssel nie
|
||||
|
||||
const ok = await call(app, A.client, 'POST', `/resources/${res.id}/reveal`);
|
||||
expect(ok.statusCode).toBe(200); expect(ok.json().items).toEqual([{ label: 'Lizenzschlüssel', value: `MOCK-KEY-${res.externalRef ?? 'm-1'}` }]);
|
||||
expect(ok.headers['cache-control']).toBe('no-store'); expect(ok.json().hideAfterSec).toBe(60);
|
||||
expect((await call(app, admin, 'POST', `/resources/${res.id}/reveal`)).statusCode).toBe(200); // Personal mit Schreibrecht
|
||||
expect((await call(app, member, 'POST', `/resources/${res.id}/reveal`)).json().error.code).toBe('REVEAL_FORBIDDEN'); // Mitglied darf nicht
|
||||
expect((await call(app, sup, 'POST', `/resources/${res.id}/reveal`)).json().error.code).toBe('REVEAL_FORBIDDEN'); // Support nur lesend
|
||||
expect((await call(app, B.client, 'POST', `/resources/${res.id}/reveal`)).statusCode).toBe(404); // fremde Organisation
|
||||
expect((await call(app, A.client, 'POST', `/resources/${res.id}/reveal`, undefined)).statusCode).toBe(200);
|
||||
const anon = await app.inject({ method: 'POST', url: `/v1/resources/${res.id}/reveal` }); expect(anon.statusCode).toBe(401);
|
||||
|
||||
// Audit: Abruf ist protokolliert, der Schlüssel selbst nirgends
|
||||
const ev = await query("SELECT actor_id, org_id, after_json FROM audit_events WHERE action = 'resource.reveal'"); expect(ev.length).toBeGreaterThanOrEqual(3);
|
||||
expect(JSON.stringify(await query('SELECT * FROM audit_events'))).not.toContain('MOCK-KEY');
|
||||
expect(JSON.stringify(await query('SELECT * FROM resources'))).not.toContain('MOCK-KEY');
|
||||
// Anbieter-Ausfall: verständlicher Fehler
|
||||
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 down = await call(app, A.client, 'POST', `/resources/${res.id}/reveal`); expect(down.statusCode).toBe(502); expect(down.json().error.message).toMatch(/nicht erreichbar/);
|
||||
});
|
||||
});
|
||||
17
apps/api/test/setup.ts
Normal file
17
apps/api/test/setup.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { beforeAll } from 'vitest';
|
||||
import mysql from 'mysql2/promise';
|
||||
import '../src/core/config.js';
|
||||
|
||||
/** Vor jeder Testdatei: Datenzeilen leeren (Testdatenbank!), Stammdaten (Steuersätze, Einstellungen) und Zähler zurücksetzen. */
|
||||
const DATA = ['backup_targets', 'backup_settings', 'domain_tlds', 'domain_records', 'audit_events', 'jobs', 'mail_log', 'contracts', 'order_items', 'orders', 'product_versions', 'products', 'resources', 'connector_instances', 'sessions', 'mfa_totp', 'recovery_codes', 'user_tokens', 'memberships', 'billing_profiles', 'organizations', 'users'];
|
||||
beforeAll(async () => {
|
||||
if (process.env.DB_NAME !== 'kundencenter_test') throw new Error('Tests dürfen nur gegen kundencenter_test laufen');
|
||||
const c = await mysql.createConnection({ host: process.env.DB_HOST ?? '127.0.0.1', user: process.env.DB_USER!, password: process.env.DB_PASSWORD!, database: 'kundencenter_test' });
|
||||
await c.query('SET FOREIGN_KEY_CHECKS = 0');
|
||||
for (const t of DATA) await c.query(`TRUNCATE TABLE \`${t}\``);
|
||||
await c.query('SET FOREIGN_KEY_CHECKS = 1');
|
||||
await c.query("UPDATE customer_sequences SET next_value = 10000; UPDATE number_sequences SET next_value = CASE name WHEN 'order' THEN 20000 ELSE 30000 END".split(';')[0]);
|
||||
await c.query("UPDATE number_sequences SET next_value = CASE name WHEN 'order' THEN 20000 ELSE 30000 END");
|
||||
await c.query('UPDATE domain_settings SET tier = 1, margin_type = NULL, margin_value = NULL');
|
||||
await c.end();
|
||||
});
|
||||
1
apps/api/tsconfig.json
Normal file
1
apps/api/tsconfig.json
Normal file
|
|
@ -0,0 +1 @@
|
|||
{ "extends": "../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", "outDir": "dist" }, "include": ["src"] }
|
||||
2
apps/api/vitest.config.ts
Normal file
2
apps/api/vitest.config.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
import { defineConfig } from 'vitest/config';
|
||||
export default defineConfig({ test: { globalSetup: ['test/global-setup.ts'], setupFiles: ['test/setup.ts'], env: { DB_NAME: 'kundencenter_test', KC_NODE_ENV: 'test', KC_BASE_URL: 'http://localhost:4101' }, fileParallelism: false, testTimeout: 30000 } });
|
||||
Loading…
Add table
Add a link
Reference in a new issue