feat(verzeichnis): LDAP-Abgleich, Zeitplan, CSV-Import und Log
Prüfung / pruefung (push) Canceled after 0s
Prüfung / pruefung (pull_request) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s
Prüfung / e2e (pull_request) Canceled after 0s

Quelle anlegen, Verbindung testen, nach Zeitplan oder auf Knopfdruck
abgleichen. Gelesen wird seitenweise mit searchPaginated, objectGUID
kommt als Puffer und wird gemischt-endian zur GUID umsortiert. Wer im
Verzeichnis verschwindet, wird hier inaktiv und nie geloescht.

Drei Regeln verhindern, dass eine kaputte Eingabe wie "alle sind
gegangen" aussieht: laesst sich kein einziger Eintrag zuordnen, wird
niemand deaktiviert; uebersprungene Eintraege werden am dn erkannt und
ausgenommen; eine von Hand angelegte Person wird uebernommen statt
verdoppelt.

Der Zeitplan laeuft ueber node-cron im selben Prozess, gestartet aus
src/instrumentation.ts, und nimmt keine eigene Sperre - die liegt in der
Datenbank, damit zwei Instanzen sich einig sind.

Closes #16

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-09-26 02:21:08 +02:00
co-authored by Claude Opus 5
parent ab5777a57e
commit 16059e7846
57 changed files with 6834 additions and 61 deletions
+430
View File
@@ -0,0 +1,430 @@
import { beforeEach, describe, expect, it } from "vitest";
import { Betriebsmodus, Herkunft } from "@/generated/prisma/enums";
import {
MITARBEITER_IMPORT_HOECHSTZEILEN,
mitarbeiterImportieren,
type MitarbeiterImportErgebnis,
} from "@/server/services/mitarbeiter";
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
/**
* CSV-Import der Personen (#016).
*
* Festgehalten wird vor allem, was in #011 am Geräteimport schiefging: der
* Import meldete „3 von 3 angelegt“, obwohl Zeilen verschluckt worden waren.
* Deshalb prüft hier fast jeder Test die **Summe** mit — je Zeile genau ein Fach,
* und die Datenbank danach genauso, wie die Zahlen es behaupten.
*
* Dazu die zweite Regel des Hauses: was aus dem Verzeichnis kommt, wird nicht von
* Hand überschrieben.
*/
const KOPF = "givenName,sn,displayName,mail,upn,sam,department,title";
async function grundbestand(modus: Betriebsmodus = Betriebsmodus.INTERN) {
const eigene = await testPrisma.kunde.create({
data: { name: "Eigene Organisation", kuerzel: "EIGEN", istEigeneOrganisation: true },
});
await testPrisma.einstellung.create({ data: { id: "global", modus } });
const benutzer = await testPrisma.benutzer.create({
data: { email: "admin@firma.example", name: "Administrator", rolle: "ADMIN" },
});
return { eigene, admin: { id: benutzer.id, rolle: benutzer.rolle } };
}
/** Jede Zeile landet in genau einem Fach – sonst lügt die Erfolgsmeldung. */
function summePruefen(ergebnis: MitarbeiterImportErgebnis): void {
expect(ergebnis.angelegt + ergebnis.aktualisiert + ergebnis.unveraendert + ergebnis.abgewiesen.length).toBe(
ergebnis.gesamt,
);
}
describe.runIf(datenbankVerfuegbar)("Personen aus einer CSV-Datei", () => {
beforeEach(async () => {
await datenbankLeeren();
delete process.env["OIDC_CUSTOMER_GROUP_PREFIX"];
});
describe("Zeile für Zeile", () => {
it("legt die guten Zeilen an und meldet die kaputte mit ihrer Zeilennummer", async () => {
const { admin } = await grundbestand();
const text = [
KOPF,
"Anna,Muster,Anna Muster,anna@firma.example,anna@firma.example,amuster,Vertrieb,Innendienst",
'Bert,"Berg,Bert Berg,bert@firma.example,,bberg,Vertrieb,', // Anführungszeichen nicht geschlossen
"Clara,Cloud,Clara Cloud,clara@firma.example,,ccloud,Technik,",
",,,dora@firma.example,,dfehlt,,", // kein Name in der Zeile
"Emil,Eck,Emil Eck,,,eeck,,",
].join("\n");
const ergebnis = await mitarbeiterImportieren(text, admin);
expect(ergebnis.gesamt).toBe(5);
expect(ergebnis.angelegt).toBe(3);
summePruefen(ergebnis);
// Zeilennummern wie in der Datei: die Kopfzeile ist 1.
expect(ergebnis.abgewiesen.map((abweisung) => abweisung.zeile)).toEqual([3, 5]);
expect(ergebnis.abgewiesen[0]?.grund).toMatch(/Anführungszeichen/);
expect(ergebnis.abgewiesen[1]?.grund).toMatch(/fehlt ein Name/);
const namen = (await testPrisma.mitarbeiter.findMany({ select: { anzeigename: true } }))
.map((zeile) => zeile.anzeigename)
.sort();
expect(namen).toEqual(["Anna Muster", "Clara Cloud", "Emil Eck"]);
});
it("legt als MANUELL an und protokolliert die Zeilennummer", async () => {
// MANUELL, weil niemand aus dieser Datei im Verzeichnis steht — und weil
// eine so angelegte Person änderbar bleiben muss.
const { admin, eigene } = await grundbestand();
await mitarbeiterImportieren([KOPF, "Anna,Muster,,,,amuster,Vertrieb,"].join("\n"), admin);
const person = await testPrisma.mitarbeiter.findFirstOrThrow();
expect(person.herkunft).toBe(Herkunft.MANUELL);
expect(person.kundeId).toBe(eigene.id);
expect(person.anzeigename).toBe("Anna Muster");
expect(person.abteilung).toBe("Vertrieb");
const eintrag = await testPrisma.protokoll.findFirstOrThrow({
where: { aktion: "Mitarbeiter aus CSV angelegt" },
});
expect(eintrag.nachher).toMatchObject({ anzeigename: "Anna Muster", zeile: 2 });
});
it("versteht deutsches Excel: Semikolon, BOM und deutsche Spaltennamen", async () => {
const { admin } = await grundbestand();
const text =
"Vorname;Nachname;Anzeigename;Mail;UPN;sAMAccountName;Abteilung;Titel\r\n" +
'Jörg;Öz;"Öz, Jörg";joerg@firma.example;joerg@firma.example;joez;Technik;"Leiter ""IT"""\r\n';
const ergebnis = await mitarbeiterImportieren(text, admin);
expect(ergebnis.angelegt).toBe(1);
summePruefen(ergebnis);
await expect(testPrisma.mitarbeiter.findFirstOrThrow()).resolves.toMatchObject({
vorname: "Jörg",
nachname: "Öz",
anzeigename: "Öz, Jörg",
titel: 'Leiter "IT"',
});
});
it("teilt displayName am letzten Leerzeichen, lässt den Anzeigenamen aber stehen", async () => {
const { admin } = await grundbestand();
const ergebnis = await mitarbeiterImportieren(
["displayName,sam", "Anna van der Berg,aberg"].join("\n"),
admin,
);
expect(ergebnis.angelegt).toBe(1);
summePruefen(ergebnis);
// Die Teilung ist eine Annahme; der Anzeigename ist es nicht — und nur er
// steht später in der Oberfläche und im Laufzettel.
await expect(testPrisma.mitarbeiter.findFirstOrThrow()).resolves.toMatchObject({
vorname: "Anna van der",
nachname: "Berg",
anzeigename: "Anna van der Berg",
});
});
it("gibt die Regel des Dienstes auch zeilenweise weiter", async () => {
// Die 20-Zeichen-Grenze des sAMAccountName steht im Dienst und nirgends
// sonst. Der Import geht durch denselben Weg, also gilt sie hier auch.
const { admin } = await grundbestand();
const ergebnis = await mitarbeiterImportieren(
[KOPF, "Anna,Muster,,,,viel-zu-langer-anmeldename,,", "Bert,Berg,,,,bberg,,"].join("\n"),
admin,
);
expect(ergebnis.angelegt).toBe(1);
summePruefen(ergebnis);
expect(ergebnis.abgewiesen[0]).toMatchObject({ zeile: 2 });
expect(ergebnis.abgewiesen[0]?.grund).toMatch(/höchstens 20/);
});
it("weist eine Datei ohne Namensspalte als Ganzes ab", async () => {
const { admin } = await grundbestand();
await expect(mitarbeiterImportieren("mail,sam\nanna@firma.example,amuster\n", admin)).rejects.toThrow(
/displayName/,
);
expect(await testPrisma.mitarbeiter.count()).toBe(0);
});
it("bricht bei zu vielen Zeilen ab, bevor eine einzige geschrieben ist", async () => {
const { admin } = await grundbestand();
const zeilen = Array.from(
{ length: MITARBEITER_IMPORT_HOECHSTZEILEN + 1 },
(_, index) => `Anna,Muster${index},,,,a${index},,`,
);
await expect(mitarbeiterImportieren([KOPF, ...zeilen].join("\n"), admin)).rejects.toThrow(
new RegExp(`mehr als ${MITARBEITER_IMPORT_HOECHSTZEILEN} Zeilen`),
);
expect(await testPrisma.mitarbeiter.count()).toBe(0);
});
});
describe("Wiedererkennen statt verdoppeln", () => {
it("erkennt ein Duplikat innerhalb derselben Datei und nennt die erste Zeile", async () => {
const { admin } = await grundbestand();
const ergebnis = await mitarbeiterImportieren(
[
KOPF,
"Anna,Muster,,anna@firma.example,,amuster,Vertrieb,",
"Bert,Berg,,bert@firma.example,,bberg,Vertrieb,",
"Anna,Muster-Neu,,anna@firma.example,,AMUSTER,Vertrieb,", // dieselbe Kennung
].join("\n"),
admin,
);
expect(ergebnis.angelegt).toBe(2);
summePruefen(ergebnis);
expect(ergebnis.abgewiesen[0]?.zeile).toBe(4);
expect(ergebnis.abgewiesen[0]?.grund).toMatch(/schon in Zeile 2/);
expect(await testPrisma.mitarbeiter.count()).toBe(2);
// Und die erste Zeile gilt, nicht die dritte.
await expect(
testPrisma.mitarbeiter.findFirstOrThrow({ where: { sam: "amuster" } }),
).resolves.toMatchObject({ nachname: "Muster" });
});
it("legt dieselbe Datei beim zweiten Einlesen nicht erneut an", async () => {
// Der eigentliche Zweck der Wiedererkennung: eine Onboarding-Liste wandert
// mehrfach durch dieses Feld, und niemand zählt vorher nach.
const { admin } = await grundbestand();
const text = [
KOPF,
"Anna,Muster,Anna Muster,anna@firma.example,anna@firma.example,amuster,Vertrieb,Innendienst",
"Bert,Berg,Bert Berg,bert@firma.example,bert@firma.example,bberg,Technik,",
].join("\n");
const erster = await mitarbeiterImportieren(text, admin);
expect(erster.angelegt).toBe(2);
const zweiter = await mitarbeiterImportieren(text, admin);
expect(zweiter).toMatchObject({ gesamt: 2, angelegt: 0, aktualisiert: 0, unveraendert: 2 });
summePruefen(zweiter);
expect(await testPrisma.mitarbeiter.count()).toBe(2);
});
it("erkennt über den UPN, wenn kein sam in der Datei steht", async () => {
const { admin, eigene } = await grundbestand();
await testPrisma.mitarbeiter.create({
data: {
kundeId: eigene.id,
vorname: "Anna",
nachname: "Muster",
anzeigename: "Anna Muster",
upn: "anna.muster@firma.example",
sam: "amuster",
},
});
const ergebnis = await mitarbeiterImportieren(
["givenName,sn,upn,department", "Anna,Muster,ANNA.MUSTER@firma.example,Vertrieb"].join("\n"),
admin,
);
expect(ergebnis).toMatchObject({ angelegt: 0, aktualisiert: 1 });
summePruefen(ergebnis);
const person = await testPrisma.mitarbeiter.findFirstOrThrow();
expect(person.abteilung).toBe("Vertrieb");
// Gross- und Kleinschreibung unterscheidet keine Personen, deshalb bleibt
// der vorhandene Wert stehen, statt ihn zu überschreiben.
expect(person.upn).toBe("anna.muster@firma.example");
});
it("erkennt ohne sam und ohne UPN über den Anzeigenamen", async () => {
// Die Onboarding-Liste mit Name und Abteilung und sonst nichts. Ohne diesen
// Rückfall stünde jede Person nach dem zweiten Einlesen doppelt da.
const { admin } = await grundbestand();
const text = ["displayName,department", "Anna Muster,Vertrieb"].join("\n");
expect(await mitarbeiterImportieren(text, admin)).toMatchObject({ angelegt: 1 });
expect(await mitarbeiterImportieren(text, admin)).toMatchObject({ angelegt: 0, unveraendert: 1 });
expect(await testPrisma.mitarbeiter.count()).toBe(1);
});
it("weist eine Zeile ab, die auf zwei Personen passt", async () => {
// Hier zu raten hieße, die Stammdaten der falschen Person zu überschreiben.
const { admin, eigene } = await grundbestand();
for (const [vorname, sam, upn] of [
["Anna", "amuster", "a.muster@firma.example"],
["Anneliese", "amuster2", "anna@firma.example"],
] as const) {
await testPrisma.mitarbeiter.create({
data: { kundeId: eigene.id, vorname, nachname: "Muster", anzeigename: vorname, sam, upn },
});
}
const ergebnis = await mitarbeiterImportieren(
[KOPF, "Anna,Muster,,,anna@firma.example,amuster,Vertrieb,"].join("\n"),
admin,
);
expect(ergebnis).toMatchObject({ angelegt: 0, aktualisiert: 0, unveraendert: 0 });
summePruefen(ergebnis);
expect(ergebnis.abgewiesen[0]?.grund).toMatch(/passt auf/);
expect(await testPrisma.mitarbeiter.count()).toBe(2);
});
it("aktiviert eine deaktivierte Person nicht wieder", async () => {
// Das Wiedereinstellen ist eine Entscheidung und keine Zeile in einer
// Datei. Die Oberfläche sagt es genauso — also muss es hier auch gelten.
const { admin, eigene } = await grundbestand();
await testPrisma.mitarbeiter.create({
data: {
kundeId: eigene.id,
vorname: "Anna",
nachname: "Muster",
anzeigename: "Anna Muster",
sam: "amuster",
aktiv: false,
},
});
const ergebnis = await mitarbeiterImportieren(
[KOPF, "Anna,Muster,,,,amuster,Vertrieb,"].join("\n"),
admin,
);
expect(ergebnis).toMatchObject({ angelegt: 0, aktualisiert: 1 });
summePruefen(ergebnis);
await expect(testPrisma.mitarbeiter.findFirstOrThrow()).resolves.toMatchObject({
aktiv: false,
abteilung: "Vertrieb",
});
});
it("lässt eine leere Zelle nichts löschen", async () => {
const { admin, eigene } = await grundbestand();
await testPrisma.mitarbeiter.create({
data: {
kundeId: eigene.id,
vorname: "Anna",
nachname: "Muster",
anzeigename: "Muster, Anna",
sam: "amuster",
titel: "Innendienst",
mail: "anna@firma.example",
},
});
const ergebnis = await mitarbeiterImportieren(
[KOPF, "Anna,Muster,,,,amuster,Vertrieb,"].join("\n"),
admin,
);
expect(ergebnis).toMatchObject({ aktualisiert: 1 });
summePruefen(ergebnis);
await expect(testPrisma.mitarbeiter.findFirstOrThrow()).resolves.toMatchObject({
abteilung: "Vertrieb",
titel: "Innendienst",
mail: "anna@firma.example",
// Kein displayName in der Zeile heißt: der vorhandene bleibt, auch wenn
// er anders lautet als „Vorname Nachname“.
anzeigename: "Muster, Anna",
});
});
});
describe("Herkunft ABGLEICH", () => {
it("überschreibt eine Person aus dem Verzeichnis nicht, sondern meldet die Zeile", async () => {
// Der nächste Abgleich drehte die Änderung ohnehin zurück. Stillschweigend
// zu überspringen wäre das Schlimmere: der Import meldete dann einen
// Erfolg, den es nicht gab.
const { admin, eigene } = await grundbestand();
await testPrisma.mitarbeiter.create({
data: {
kundeId: eigene.id,
vorname: "Maria",
nachname: "Huber",
anzeigename: "Maria Huber",
sam: "mhuber",
abteilung: "Buchhaltung",
titel: "Leitung",
herkunft: Herkunft.ABGLEICH,
},
});
const protokollVorher = await testPrisma.protokoll.count();
const ergebnis = await mitarbeiterImportieren(
[KOPF, "Maria,Huber-Neu,,neu@firma.example,,mhuber,Vertrieb,Sachbearbeitung"].join("\n"),
admin,
);
expect(ergebnis).toMatchObject({ gesamt: 1, angelegt: 0, aktualisiert: 0, unveraendert: 0 });
summePruefen(ergebnis);
expect(ergebnis.abgewiesen[0]?.zeile).toBe(2);
expect(ergebnis.abgewiesen[0]?.grund).toMatch(/kommt aus dem Verzeichnis/);
await expect(testPrisma.mitarbeiter.findFirstOrThrow()).resolves.toMatchObject({
nachname: "Huber",
abteilung: "Buchhaltung",
titel: "Leitung",
mail: null,
});
expect(await testPrisma.protokoll.count()).toBe(protokollVorher);
});
});
describe("Mandantengrenze", () => {
it("verwirft im Modus INTERN eine mitgeschickte Kundenkennung", async () => {
const { admin, eigene } = await grundbestand();
const fremder = await testPrisma.kunde.create({ data: { name: "Fremder", kuerzel: "FREMD" } });
await mitarbeiterImportieren([KOPF, "Anna,Muster,,,,amuster,,"].join("\n"), admin, fremder.id);
await expect(testPrisma.mitarbeiter.findFirstOrThrow()).resolves.toMatchObject({
kundeId: eigene.id,
});
});
it("erkennt nur innerhalb desselben Kunden – und schreibt nicht für einen fremden", async () => {
await grundbestand(Betriebsmodus.DIENSTLEISTUNG);
const meiner = await testPrisma.kunde.create({ data: { name: "Meiner", kuerzel: "MEIN" } });
const fremder = await testPrisma.kunde.create({ data: { name: "Fremder", kuerzel: "FREMD" } });
const techniker = await testPrisma.benutzer.create({
data: { email: "t@firma.example", name: "Techniker", rolle: "TECHNIKER" },
});
await testPrisma.benutzerKunde.create({ data: { benutzerId: techniker.id, kundeId: meiner.id } });
process.env["OIDC_CUSTOMER_GROUP_PREFIX"] = "lz-kunde-";
const handelnder = { id: techniker.id, rolle: techniker.rolle };
// Dieselbe Kennung gibt es beim fremden Kunden schon. Sie darf hier nichts
// treffen: sonst schriebe dieser Import in fremde Stammdaten.
await testPrisma.mitarbeiter.create({
data: {
kundeId: fremder.id,
vorname: "Anna",
nachname: "Fremd",
anzeigename: "Anna Fremd",
sam: "amuster",
},
});
const ergebnis = await mitarbeiterImportieren(
[KOPF, "Anna,Muster,,,,amuster,Vertrieb,"].join("\n"),
handelnder,
meiner.id,
);
expect(ergebnis).toMatchObject({ angelegt: 1 });
summePruefen(ergebnis);
await expect(
testPrisma.mitarbeiter.findFirstOrThrow({ where: { kundeId: fremder.id } }),
).resolves.toMatchObject({ nachname: "Fremd", abteilung: null });
// Und für einen Kunden, der diesem Techniker nicht gehört, geht gar nichts.
await expect(
mitarbeiterImportieren([KOPF, "Bert,Berg,,,,bberg,,"].join("\n"), handelnder, fremder.id),
).rejects.toThrow(/Berechtigung/);
expect(await testPrisma.mitarbeiter.count({ where: { kundeId: fremder.id } })).toBe(1);
});
it("verlangt im Modus DIENSTLEISTUNG eine Kundenwahl", async () => {
const { admin } = await grundbestand(Betriebsmodus.DIENSTLEISTUNG);
await expect(
mitarbeiterImportieren([KOPF, "Anna,Muster,,,,amuster,,"].join("\n"), admin),
).rejects.toThrow(/Kunde/);
expect(await testPrisma.mitarbeiter.count()).toBe(0);
});
});
});