fix(quick-260909-ab3): kollidierende AD-Konten werden angelegt, nur ohne Adresse
- User.email auf optional gestellt (Migration geschrieben, NICHT ausgefuehrt); Eindeutigkeitsindex unangetastet, NULL bleibt in Postgres je verschieden - Neuer Kollisionsentscheider (resolveEmailForWrite) in ldap.service.ts: eine bereits vergebene Adresse wird nie umgehaengt (T-Q3-01) — das zuerst angelegte Konto behaelt sie, jedes weitere Konto entsteht ohne Adresse (gesperrte Nutzerentscheidung 2026-09-09, WINDOWS #15) - Entscheider in upsertMappedUser (Sync) UND importUsersByDn (Handimport) verdrahtet, damit der zweite Anlageweg nicht als Luecke bestehen bleibt - LdapSyncResult um emailConflicts/skippedNoLogin/entryFailures erweitert; rohe ORM-Ausnahmetexte gehen nur noch an logger.error, nie in den Bericht (T-Q3-02) - UserService.create nimmt die Adresse optional entgegen; Tender-Digest und Instant-Alert ueberspringen Empfaenger ohne Adresse (continue) - Fuenf neue Testfaelle vorab gegen den unveraenderten Bestand rot gelaufen (erwartete Ursachen bestaetigt); 651/651 API-Tests gruen, prisma validate und type-check sauber Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FYZcd3SSmo14QTqWx2KKzU
This commit is contained in:
@@ -0,0 +1,18 @@
|
|||||||
|
-- WINDOWS #15: der AD-Sync liess Konten, deren Adresse bereits einem anderen
|
||||||
|
-- Konto gehoert (vier Funktionskonten mit geteilter mail-Adresse gemessen),
|
||||||
|
-- wegen der Pflicht auf "User.email" komplett unimportiert liegen -- ohne
|
||||||
|
-- verstaendliche Rueckmeldung.
|
||||||
|
--
|
||||||
|
-- Gesperrte Nutzerentscheidung vom 2026-09-09 (nicht verhandelbar): ein
|
||||||
|
-- Konto mit bereits belegter Adresse wird trotzdem angelegt, nur eben OHNE
|
||||||
|
-- Adresse. Die Anmeldung laeuft ueber den Benutzernamen, nicht ueber die
|
||||||
|
-- Adresse -- das Konto bleibt voll funktionsfaehig, lediglich
|
||||||
|
-- Benachrichtigungen per E-Mail erreichen es nicht. Wer eine Adresse zuerst
|
||||||
|
-- belegt, behaelt sie unveraendert.
|
||||||
|
--
|
||||||
|
-- Der Eindeutigkeitsindex auf "email" wird NICHT angefasst: PostgreSQL
|
||||||
|
-- behandelt NULL-Werte in einer Eindeutigkeitsregel als jeweils verschieden,
|
||||||
|
-- mehrere Konten ohne Adresse sind also weiterhin erlaubt. Bestandszeilen
|
||||||
|
-- bleiben unangetastet -- jedes bereits angelegte Konto behaelt seine
|
||||||
|
-- heutige Adresse.
|
||||||
|
ALTER TABLE "User" ALTER COLUMN "email" DROP NOT NULL;
|
||||||
@@ -29,7 +29,7 @@ enum Role {
|
|||||||
model User {
|
model User {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
username String @unique
|
username String @unique
|
||||||
email String @unique
|
email String? @unique
|
||||||
passwordHash String?
|
passwordHash String?
|
||||||
displayName String?
|
displayName String?
|
||||||
role Role @default(USER)
|
role Role @default(USER)
|
||||||
|
|||||||
@@ -218,6 +218,9 @@ describe('LdapService.syncUsersForTenant — empty base DN no-op', () => {
|
|||||||
groupsRenamed: 0,
|
groupsRenamed: 0,
|
||||||
groupsDeleted: 0,
|
groupsDeleted: 0,
|
||||||
defaultMarkerMoved: 0,
|
defaultMarkerMoved: 0,
|
||||||
|
emailConflicts: [],
|
||||||
|
skippedNoLogin: [],
|
||||||
|
entryFailures: [],
|
||||||
errors: [],
|
errors: [],
|
||||||
});
|
});
|
||||||
expect(mockSearch).not.toHaveBeenCalled();
|
expect(mockSearch).not.toHaveBeenCalled();
|
||||||
@@ -348,6 +351,9 @@ describe('LdapService — individual user search & import (dedup)', () => {
|
|||||||
prisma = {
|
prisma = {
|
||||||
user: {
|
user: {
|
||||||
findFirst: vi.fn().mockResolvedValue(null),
|
findFirst: vi.fn().mockResolvedValue(null),
|
||||||
|
// Consulted by importUsersByDn's WINDOWS #15 email-collision decider
|
||||||
|
// whenever an entry carries a mapped `mail` attribute.
|
||||||
|
findUnique: vi.fn().mockResolvedValue(null),
|
||||||
findMany: vi.fn().mockResolvedValue([]),
|
findMany: vi.fn().mockResolvedValue([]),
|
||||||
update: vi.fn().mockResolvedValue({}),
|
update: vi.fn().mockResolvedValue({}),
|
||||||
},
|
},
|
||||||
@@ -473,6 +479,172 @@ describe('LdapService — individual user search & import (dedup)', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('LdapService.syncUsersForTenant — E-Mail-Kollision bei der Kontoanlage (WINDOWS #15)', () => {
|
||||||
|
let service: LdapService;
|
||||||
|
let prisma: any;
|
||||||
|
let userService: any;
|
||||||
|
|
||||||
|
const cfg = {
|
||||||
|
id: 'cfg1',
|
||||||
|
tenantId: 't1',
|
||||||
|
serverUrl: 'ldap://example',
|
||||||
|
baseDn: 'dc=example,dc=com',
|
||||||
|
searchFilter: '(objectClass=person)',
|
||||||
|
groupFilterDns: [] as string[],
|
||||||
|
userExcludeList: [] as string[],
|
||||||
|
fieldMappings: [
|
||||||
|
{ ldapField: 'sAMAccountName', tesseraField: 'username' },
|
||||||
|
{ ldapField: 'mail', tesseraField: 'email' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockBind.mockResolvedValue(undefined);
|
||||||
|
mockUnbind.mockResolvedValue(undefined);
|
||||||
|
prisma = {
|
||||||
|
user: {
|
||||||
|
findFirst: vi.fn().mockResolvedValue(null),
|
||||||
|
// The collision decider queries this — NOT mocked in the older
|
||||||
|
// describe blocks above, added here because this is the first
|
||||||
|
// block that exercises it (see PLAN.md Task 2 behavior note).
|
||||||
|
findUnique: vi.fn().mockResolvedValue(null),
|
||||||
|
findMany: vi.fn().mockResolvedValue([]),
|
||||||
|
update: vi.fn().mockResolvedValue({}),
|
||||||
|
},
|
||||||
|
group: { findMany: vi.fn().mockResolvedValue([]) },
|
||||||
|
groupMembership: {
|
||||||
|
createMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||||
|
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||||
|
},
|
||||||
|
ldapConfig: { update: vi.fn().mockResolvedValue({}) },
|
||||||
|
};
|
||||||
|
userService = { create: vi.fn().mockResolvedValue({ id: 'created-user' }) };
|
||||||
|
service = new LdapService(prisma, userService, {} as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('legt ein Konto mit belegter Adresse trotzdem an, nur ohne Adresse', async () => {
|
||||||
|
mockSearch.mockResolvedValue({
|
||||||
|
searchEntries: [
|
||||||
|
{
|
||||||
|
dn: 'cn=konto1,dc=example,dc=com',
|
||||||
|
sAMAccountName: 'konto1',
|
||||||
|
mail: 'geteilt@example.com',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
dn: 'cn=konto2,dc=example,dc=com',
|
||||||
|
sAMAccountName: 'konto2',
|
||||||
|
mail: 'geteilt@example.com',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
// The first entry's address is free (findUnique -> null). By the time
|
||||||
|
// the second entry is processed, the address already belongs to the
|
||||||
|
// first-created account.
|
||||||
|
prisma.user.findUnique
|
||||||
|
.mockResolvedValueOnce(null)
|
||||||
|
.mockResolvedValueOnce({ id: 'konto1-id', email: 'geteilt@example.com' });
|
||||||
|
|
||||||
|
const result = await service.syncUsersForTenant(cfg as any, 't1');
|
||||||
|
|
||||||
|
expect(userService.create).toHaveBeenCalledTimes(2);
|
||||||
|
expect(userService.create).toHaveBeenNthCalledWith(
|
||||||
|
1,
|
||||||
|
expect.objectContaining({ username: 'konto1', email: 'geteilt@example.com' }),
|
||||||
|
);
|
||||||
|
const secondCallArgs = userService.create.mock.calls[1][0];
|
||||||
|
expect(secondCallArgs.email).toBeUndefined();
|
||||||
|
expect(result.created).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('meldet die Kollision strukturiert und nicht als Fehler', async () => {
|
||||||
|
mockSearch.mockResolvedValue({
|
||||||
|
searchEntries: [
|
||||||
|
{
|
||||||
|
dn: 'cn=konto1,dc=example,dc=com',
|
||||||
|
sAMAccountName: 'konto1',
|
||||||
|
mail: 'geteilt@example.com',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
dn: 'cn=konto2,dc=example,dc=com',
|
||||||
|
sAMAccountName: 'konto2',
|
||||||
|
mail: 'geteilt@example.com',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
prisma.user.findUnique
|
||||||
|
.mockResolvedValueOnce(null)
|
||||||
|
.mockResolvedValueOnce({ id: 'konto1-id', email: 'geteilt@example.com' });
|
||||||
|
|
||||||
|
const result = await service.syncUsersForTenant(cfg as any, 't1');
|
||||||
|
|
||||||
|
expect(result.emailConflicts).toEqual([
|
||||||
|
{ account: 'konto2', email: 'geteilt@example.com' },
|
||||||
|
]);
|
||||||
|
expect(result.errors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nimmt einem bestehenden Konto seine Adresse nicht weg', async () => {
|
||||||
|
mockSearch.mockResolvedValue({
|
||||||
|
searchEntries: [
|
||||||
|
{
|
||||||
|
dn: 'cn=konto3,dc=example,dc=com',
|
||||||
|
sAMAccountName: 'konto3',
|
||||||
|
mail: 'fremd@example.com',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
// konto3 already exists locally (matched by ldapDn); the address AD now
|
||||||
|
// reports for it belongs to a THIRD, unrelated user.
|
||||||
|
prisma.user.findFirst.mockResolvedValue({ id: 'konto3-id', email: 'alt@example.com' });
|
||||||
|
prisma.user.findUnique.mockResolvedValue({ id: 'dritter-user-id', email: 'fremd@example.com' });
|
||||||
|
|
||||||
|
const result = await service.syncUsersForTenant(cfg as any, 't1');
|
||||||
|
|
||||||
|
expect(prisma.user.update).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
where: { id: 'konto3-id' },
|
||||||
|
data: expect.not.objectContaining({ email: expect.anything() }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(result.emailConflicts).toEqual([
|
||||||
|
{ account: 'konto3', email: 'fremd@example.com' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('meldet Eintraege ohne Anmeldenamen getrennt und nicht als Fehler', async () => {
|
||||||
|
mockSearch.mockResolvedValue({
|
||||||
|
searchEntries: [{ dn: 'cn=kontakt1,dc=example,dc=com' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.syncUsersForTenant(cfg as any, 't1');
|
||||||
|
|
||||||
|
expect(result.skippedNoLogin).toEqual(['cn=kontakt1,dc=example,dc=com']);
|
||||||
|
expect(result.errors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reicht keinen rohen Datenbanktext an den Bericht durch', async () => {
|
||||||
|
mockSearch.mockResolvedValue({
|
||||||
|
searchEntries: [
|
||||||
|
{
|
||||||
|
dn: 'cn=konto5,dc=example,dc=com',
|
||||||
|
sAMAccountName: 'konto5',
|
||||||
|
mail: 'konto5@example.com',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const ormMessage =
|
||||||
|
'Invalid `prisma.user.create()` invocation: Unique constraint failed on the fields: (`email`)';
|
||||||
|
userService.create.mockRejectedValueOnce(new Error(ormMessage));
|
||||||
|
|
||||||
|
const result = await service.syncUsersForTenant(cfg as any, 't1');
|
||||||
|
|
||||||
|
expect(result.entryFailures).toEqual(['cn=konto5,dc=example,dc=com']);
|
||||||
|
expect(result.errors).not.toContain(ormMessage);
|
||||||
|
expect(JSON.stringify(result)).not.toContain(ormMessage);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('LdapService.testConnection — TLS verification opt-out (ldaps)', () => {
|
describe('LdapService.testConnection — TLS verification opt-out (ldaps)', () => {
|
||||||
let service: LdapService;
|
let service: LdapService;
|
||||||
|
|
||||||
|
|||||||
@@ -30,9 +30,31 @@ export interface LdapSyncResult {
|
|||||||
groupsRenamed: number;
|
groupsRenamed: number;
|
||||||
groupsDeleted: number;
|
groupsDeleted: number;
|
||||||
defaultMarkerMoved: number;
|
defaultMarkerMoved: number;
|
||||||
|
// WINDOWS #15, gesperrte Nutzerentscheidung 2026-09-09: ein AD-Konto,
|
||||||
|
// dessen mail-Adresse bereits einem ANDEREN Konto gehoert, wird trotzdem
|
||||||
|
// angelegt/aktualisiert -- nur eben OHNE diese Adresse. Wer die Adresse
|
||||||
|
// zuerst hatte, behaelt sie unveraendert (T-Q3-01). Diese Liste macht die
|
||||||
|
// Kollision im Bericht sichtbar statt sie still zu verwerfen.
|
||||||
|
emailConflicts: LdapEmailConflict[];
|
||||||
|
// Verzeichniseintraege ohne sAMAccountName (Kontakte, Verteiler,
|
||||||
|
// Ressourcen) -- ein normaler, erwarteter Vorgang, kein Fehler. Traegt die
|
||||||
|
// Kennung (dn) des uebersprungenen Eintrags.
|
||||||
|
skippedNoLogin: string[];
|
||||||
|
// Ein unerwarteter Fehler bei genau diesem Eintrag. Nur die Kennung (dn)
|
||||||
|
// steht hier -- der technische Wortlaut (z. B. eine rohe Prisma/ORM-
|
||||||
|
// Ausnahme) geht AUSSCHLIESSLICH ueber this.logger.error ins
|
||||||
|
// Serverprotokoll und erreicht diesen Bericht nie (T-Q3-02).
|
||||||
|
entryFailures: string[];
|
||||||
errors: string[];
|
errors: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Ein Konto, das wegen einer bereits vergebenen Adresse ohne diese Adresse
|
||||||
|
* angelegt oder aktualisiert wurde (WINDOWS #15). */
|
||||||
|
export interface LdapEmailConflict {
|
||||||
|
account: string;
|
||||||
|
email: string;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* LDAP configuration shape as stored in the database.
|
* LDAP configuration shape as stored in the database.
|
||||||
*/
|
*/
|
||||||
@@ -378,6 +400,32 @@ export class LdapService {
|
|||||||
return { username: mappedData['username']?.toLowerCase(), mappedData };
|
return { username: mappedData['username']?.toLowerCase(), mappedData };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decide whether `desiredEmail` may be written to the account identified by
|
||||||
|
* `ownRecordId` (null when the account does not exist yet, i.e. a create).
|
||||||
|
* Queries the unique `email` column via findUnique (deliberately NOT
|
||||||
|
* findFirst — the column is unique, and this keeps the call unambiguously
|
||||||
|
* distinguishable in tests from the identity findFirst lookups in
|
||||||
|
* upsertMappedUser/importUsersByDn) and returns the address only when
|
||||||
|
* nobody holds it yet, or the holder IS this same account. Otherwise the
|
||||||
|
* address is withheld and reported as a collision instead — an address is
|
||||||
|
* NEVER handed from one account to another (T-Q3-01): a directory entry
|
||||||
|
* could otherwise take over a real person's address and receive their
|
||||||
|
* password-reset mail.
|
||||||
|
*/
|
||||||
|
private async resolveEmailForWrite(
|
||||||
|
desiredEmail: string,
|
||||||
|
ownRecordId: string | null,
|
||||||
|
): Promise<{ email?: string; collides: boolean }> {
|
||||||
|
const holder = await this.prisma.user.findUnique({
|
||||||
|
where: { email: desiredEmail },
|
||||||
|
});
|
||||||
|
if (!holder || holder.id === ownRecordId) {
|
||||||
|
return { email: desiredEmail, collides: false };
|
||||||
|
}
|
||||||
|
return { collides: true };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Find-or-upsert one LDAP user by (ldapDn, then username) within a tenant.
|
* Find-or-upsert one LDAP user by (ldapDn, then username) within a tenant.
|
||||||
*
|
*
|
||||||
@@ -385,13 +433,22 @@ export class LdapService {
|
|||||||
* identity resolution: a user imported one way is NEVER duplicated by the
|
* identity resolution: a user imported one way is NEVER duplicated by the
|
||||||
* other. A manually-imported user (ldapDn set) is matched by ldapDn on a
|
* other. A manually-imported user (ldapDn set) is matched by ldapDn on a
|
||||||
* later department/group sync and updated in place, not re-created.
|
* later department/group sync and updated in place, not re-created.
|
||||||
|
*
|
||||||
|
* WINDOWS #15: an account whose mapped `mail` address is already held by a
|
||||||
|
* DIFFERENT account is still created/updated, just without that address
|
||||||
|
* (locked user decision, 2026-09-09) — see resolveEmailForWrite() above.
|
||||||
|
* The `${username}@ldap.local` fallback for entries with NO mail attribute
|
||||||
|
* at all is untouched: it is not part of this defect.
|
||||||
*/
|
*/
|
||||||
private async upsertMappedUser(
|
private async upsertMappedUser(
|
||||||
dn: string,
|
dn: string,
|
||||||
username: string,
|
username: string,
|
||||||
mappedData: Record<string, string>,
|
mappedData: Record<string, string>,
|
||||||
tenantId: string,
|
tenantId: string,
|
||||||
): Promise<'created' | 'updated'> {
|
): Promise<{
|
||||||
|
status: 'created' | 'updated';
|
||||||
|
emailConflict?: LdapEmailConflict;
|
||||||
|
}> {
|
||||||
const existingByDn = await this.prisma.user.findFirst({
|
const existingByDn = await this.prisma.user.findFirst({
|
||||||
where: { ldapDn: dn, tenantId },
|
where: { ldapDn: dn, tenantId },
|
||||||
});
|
});
|
||||||
@@ -401,30 +458,60 @@ export class LdapService {
|
|||||||
const existing = existingByDn || existingByUsername;
|
const existing = existingByDn || existingByUsername;
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
|
let emailToWrite: string | undefined;
|
||||||
|
let emailConflict: LdapEmailConflict | undefined;
|
||||||
|
if (mappedData['email']) {
|
||||||
|
const decision = await this.resolveEmailForWrite(
|
||||||
|
mappedData['email'],
|
||||||
|
existing.id,
|
||||||
|
);
|
||||||
|
if (decision.collides) {
|
||||||
|
emailConflict = { account: username, email: mappedData['email'] };
|
||||||
|
} else {
|
||||||
|
emailToWrite = decision.email;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await this.prisma.user.update({
|
await this.prisma.user.update({
|
||||||
where: { id: existing.id },
|
where: { id: existing.id },
|
||||||
data: {
|
data: {
|
||||||
...(mappedData['displayName'] && {
|
...(mappedData['displayName'] && {
|
||||||
displayName: mappedData['displayName'],
|
displayName: mappedData['displayName'],
|
||||||
}),
|
}),
|
||||||
...(mappedData['email'] && { email: mappedData['email'] }),
|
...(emailToWrite && { email: emailToWrite }),
|
||||||
...(mappedData['username'] && { username }),
|
...(mappedData['username'] && { username }),
|
||||||
ldapDn: dn,
|
ldapDn: dn,
|
||||||
isActive: true,
|
isActive: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return 'updated';
|
return { status: 'updated', emailConflict };
|
||||||
|
}
|
||||||
|
|
||||||
|
let createEmail: string | undefined =
|
||||||
|
mappedData['email'] || `${username}@ldap.local`;
|
||||||
|
let emailConflict: LdapEmailConflict | undefined;
|
||||||
|
if (mappedData['email']) {
|
||||||
|
const decision = await this.resolveEmailForWrite(
|
||||||
|
mappedData['email'],
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
if (decision.collides) {
|
||||||
|
createEmail = undefined;
|
||||||
|
emailConflict = { account: username, email: mappedData['email'] };
|
||||||
|
} else {
|
||||||
|
createEmail = decision.email;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.userService.create({
|
await this.userService.create({
|
||||||
username,
|
username,
|
||||||
email: mappedData['email'] || `${username}@ldap.local`,
|
...(createEmail && { email: createEmail }),
|
||||||
displayName: mappedData['displayName'],
|
displayName: mappedData['displayName'],
|
||||||
role: 'USER',
|
role: 'USER',
|
||||||
tenantId,
|
tenantId,
|
||||||
ldapDn: dn,
|
ldapDn: dn,
|
||||||
});
|
});
|
||||||
return 'created';
|
return { status: 'created', emailConflict };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -599,9 +686,24 @@ export class LdapService {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WINDOWS #15: same collision decider as upsertMappedUser — an
|
||||||
|
// address already held by a DIFFERENT account is withheld, never
|
||||||
|
// reassigned (T-Q3-01). No new LdapUserImportResult field: the
|
||||||
|
// account is still created and counted, this manual-import path's
|
||||||
|
// display stays as-is.
|
||||||
|
let createEmail: string | undefined =
|
||||||
|
mappedData['email'] || `${username}@ldap.local`;
|
||||||
|
if (mappedData['email']) {
|
||||||
|
const decision = await this.resolveEmailForWrite(
|
||||||
|
mappedData['email'],
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
createEmail = decision.collides ? undefined : decision.email;
|
||||||
|
}
|
||||||
|
|
||||||
await this.userService.create({
|
await this.userService.create({
|
||||||
username,
|
username,
|
||||||
email: mappedData['email'] || `${username}@ldap.local`,
|
...(createEmail && { email: createEmail }),
|
||||||
displayName: mappedData['displayName'],
|
displayName: mappedData['displayName'],
|
||||||
role: 'USER',
|
role: 'USER',
|
||||||
tenantId,
|
tenantId,
|
||||||
@@ -775,6 +877,9 @@ export class LdapService {
|
|||||||
groupsRenamed: 0,
|
groupsRenamed: 0,
|
||||||
groupsDeleted: 0,
|
groupsDeleted: 0,
|
||||||
defaultMarkerMoved: 0,
|
defaultMarkerMoved: 0,
|
||||||
|
emailConflicts: [],
|
||||||
|
skippedNoLogin: [],
|
||||||
|
entryFailures: [],
|
||||||
errors: [],
|
errors: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -858,15 +963,15 @@ export class LdapService {
|
|||||||
syncedDns.push(dn);
|
syncedDns.push(dn);
|
||||||
|
|
||||||
if (!username) {
|
if (!username) {
|
||||||
result.errors.push(
|
// Normal for contacts/resources/distribution entries without a
|
||||||
`Entry ${dn}: no username mapped (check sAMAccountName mapping)`,
|
// sAMAccountName — a neutral hint, not an error (WINDOWS #15).
|
||||||
);
|
result.skippedNoLogin.push(dn);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find-or-upsert by (ldapDn, then username) via the shared helper so
|
// Find-or-upsert by (ldapDn, then username) via the shared helper so
|
||||||
// sync and manual import dedupe identically (never a duplicate row).
|
// sync and manual import dedupe identically (never a duplicate row).
|
||||||
const status = await this.upsertMappedUser(
|
const { status, emailConflict } = await this.upsertMappedUser(
|
||||||
dn,
|
dn,
|
||||||
username,
|
username,
|
||||||
mappedData,
|
mappedData,
|
||||||
@@ -877,12 +982,20 @@ export class LdapService {
|
|||||||
} else {
|
} else {
|
||||||
result.updated++;
|
result.updated++;
|
||||||
}
|
}
|
||||||
|
if (emailConflict) {
|
||||||
|
result.emailConflicts.push(emailConflict);
|
||||||
|
}
|
||||||
} catch (entryError: unknown) {
|
} catch (entryError: unknown) {
|
||||||
const msg =
|
const msg =
|
||||||
entryError instanceof Error
|
entryError instanceof Error
|
||||||
? entryError.message
|
? entryError.message
|
||||||
: 'Unknown error processing entry';
|
: 'Unknown error processing entry';
|
||||||
result.errors.push(`Entry ${entry.dn}: ${msg}`);
|
// T-Q3-02: the raw exception text (ORM table/column/call names)
|
||||||
|
// goes to the server log only — never to the report an
|
||||||
|
// administrator reads. Only the entry's identifier (dn) is
|
||||||
|
// reported.
|
||||||
|
this.logger.error(`LDAP sync entry ${entry.dn} failed: ${msg}`);
|
||||||
|
result.entryFailures.push(entry.dn);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -134,7 +134,10 @@ export class TenderDigestScheduler implements OnModuleInit {
|
|||||||
if (!matches.length) continue;
|
if (!matches.length) continue;
|
||||||
|
|
||||||
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
||||||
if (!user) continue;
|
// Kein Konto, oder ein Konto ohne Adresse (WINDOWS #15, kollidierte
|
||||||
|
// AD-Adresse) -- die Zugehoerigkeit funktioniert, nur der
|
||||||
|
// Mailversand wird uebersprungen (zugesagtes Verhalten).
|
||||||
|
if (!user || !user.email) continue;
|
||||||
|
|
||||||
const sections = groupMatchesByProfile(matches);
|
const sections = groupMatchesByProfile(matches);
|
||||||
const sent = await this.mail.sendDigest({ email: user.email }, user.tenantId, sections);
|
const sent = await this.mail.sendDigest({ email: user.email }, user.tenantId, sections);
|
||||||
|
|||||||
@@ -125,7 +125,10 @@ export class TenderMatchingService {
|
|||||||
if (!fresh.length) continue; // nothing new for this profile this tick
|
if (!fresh.length) continue; // nothing new for this profile this tick
|
||||||
|
|
||||||
const user = await this.prisma.user.findUnique({ where: { id: profile.userId } });
|
const user = await this.prisma.user.findUnique({ where: { id: profile.userId } });
|
||||||
if (!user) continue;
|
// Kein Konto, oder ein Konto ohne Adresse (WINDOWS #15, kollidierte
|
||||||
|
// AD-Adresse) -- die Zugehoerigkeit funktioniert, nur der
|
||||||
|
// Mailversand wird uebersprungen (zugesagtes Verhalten).
|
||||||
|
if (!user || !user.email) continue;
|
||||||
|
|
||||||
const sent = await this.mail.sendInstant(
|
const sent = await this.mail.sendInstant(
|
||||||
{ email: user.email },
|
{ email: user.email },
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ export class UserService {
|
|||||||
*/
|
*/
|
||||||
async create(data: {
|
async create(data: {
|
||||||
username: string;
|
username: string;
|
||||||
email: string;
|
email?: string;
|
||||||
password?: string;
|
password?: string;
|
||||||
displayName?: string;
|
displayName?: string;
|
||||||
role?: 'SUPER_ADMIN' | 'ADMIN' | 'USER';
|
role?: 'SUPER_ADMIN' | 'ADMIN' | 'USER';
|
||||||
|
|||||||
Reference in New Issue
Block a user