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:
@@ -218,6 +218,9 @@ describe('LdapService.syncUsersForTenant — empty base DN no-op', () => {
|
||||
groupsRenamed: 0,
|
||||
groupsDeleted: 0,
|
||||
defaultMarkerMoved: 0,
|
||||
emailConflicts: [],
|
||||
skippedNoLogin: [],
|
||||
entryFailures: [],
|
||||
errors: [],
|
||||
});
|
||||
expect(mockSearch).not.toHaveBeenCalled();
|
||||
@@ -348,6 +351,9 @@ describe('LdapService — individual user search & import (dedup)', () => {
|
||||
prisma = {
|
||||
user: {
|
||||
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([]),
|
||||
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)', () => {
|
||||
let service: LdapService;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user