CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (pull_request) Canceled after 0s
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (push) Canceled after 0s
RBAC-Rollenhierarchie-Pflichtgate / rbac-policy-invariant (pull_request) Canceled after 0s
CI / Core-Modul (Build/Vet/Test) (push) Canceled after 0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (push) Canceled after 0s
CI / Connect Security-Test-Gate (QA-01 AC4, Merge-Gate) (push) Canceled after 0s
CI / Web (dms-browser) (pull_request) Canceled after 0s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (push) Canceled after 0s
CI / Web (dms-browser) (push) Canceled after 0s
CI / Web (pdf-viewer) (push) Canceled after 0s
CI / Web (shl) (push) Canceled after 0s
Supply-Chain-Scan (Go) / govulncheck (push) Canceled after 0s
CI / Core-Modul (Build/Vet/Test) (pull_request) Canceled after 0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (pull_request) Canceled after 0s
CI / Connect Security-Test-Gate (QA-01 AC4, Merge-Gate) (pull_request) Canceled after 0s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (pull_request) Canceled after 0s
CI / Web (pdf-viewer) (pull_request) Canceled after 0s
CI / Web (shl) (pull_request) Canceled after 0s
Core-Schnittstellen-Vertragstests / contract-tests (pull_request) Canceled after 0s
Supply-Chain-Scan (Go) / govulncheck (pull_request) Canceled after 0s
Supply-Chain-Scan (npm) / npm-audit (pull_request) Canceled after 0s
Core-Pflichttest-Gate / pflichttest-gate (pull_request) Canceled after 0s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (push) Canceled after 0s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (pull_request) Canceled after 0s
Tests gegen synthetische Fixtures je Akzeptanzkriterium sowie Pruefprotokoll mit real ausgefuehrten Ergebnissen. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
244 lines
7.8 KiB
Go
244 lines
7.8 KiB
Go
package ldapsync
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"testing"
|
|
"time"
|
|
|
|
"gitea.perlbach24.de/scripte/nexarch/internal/rbac"
|
|
"gitea.perlbach24.de/scripte/nexarch/internal/testdb"
|
|
"gitea.perlbach24.de/scripte/nexarch/internal/user"
|
|
)
|
|
|
|
type fakeClient struct {
|
|
entries []Entry
|
|
err error
|
|
}
|
|
|
|
func (f *fakeClient) Search() ([]Entry, error) {
|
|
if f.err != nil {
|
|
return nil, f.err
|
|
}
|
|
return f.entries, nil
|
|
}
|
|
|
|
func setupTest(t *testing.T) (*user.TenantUserStore, *rbac.Store, *RoleMappingStore, func()) {
|
|
t.Helper()
|
|
adminDSN := os.Getenv("TEST_ADMIN_DSN")
|
|
if adminDSN == "" {
|
|
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
|
|
}
|
|
ctx := context.Background()
|
|
|
|
pool := testdb.Pool(t, adminDSN)
|
|
if _, err := pool.Exec(ctx, `
|
|
CREATE TABLE IF NOT EXISTS users (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), email TEXT NOT NULL UNIQUE, name TEXT NOT NULL,
|
|
status TEXT NOT NULL DEFAULT 'active', password_hash TEXT NOT NULL DEFAULT '',
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
ldap_uid TEXT, ldap_synced_at TIMESTAMPTZ, ldap_source TEXT
|
|
);
|
|
CREATE TABLE IF NOT EXISTS role_assignments (
|
|
user_id UUID PRIMARY KEY REFERENCES users(id), role TEXT NOT NULL CHECK (role IN ('user','tenant_admin')),
|
|
granted_by TEXT NOT NULL, granted_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
);
|
|
CREATE TABLE IF NOT EXISTS role_assignment_history (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL, role TEXT NOT NULL,
|
|
granted_by TEXT NOT NULL, granted_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
);
|
|
CREATE TABLE IF NOT EXISTS ldap_group_role_mapping (
|
|
ldap_group TEXT PRIMARY KEY, role TEXT NOT NULL CHECK (role IN ('user','tenant_admin'))
|
|
);
|
|
`); err != nil {
|
|
t.Fatalf("schema: %v", err)
|
|
}
|
|
|
|
cleanup := func() { pool.Close() }
|
|
return user.NewTenantUserStore(pool), rbac.NewStore(pool), NewRoleMappingStore(pool), cleanup
|
|
}
|
|
|
|
func uniqueEmail() string {
|
|
return fmt.Sprintf("ldap-test-%d@example.com", time.Now().UnixNano())
|
|
}
|
|
|
|
// Akzeptanzkriterium 3 + Pruefung 1: Rollen-Erlaubnis-Matrix mit Positiv- und
|
|
// Negativfaellen — keine Privilege-Escalation ueber Gruppenmitgliedschaft.
|
|
func TestRoleMapping_PositiveAndNegativeCases(t *testing.T) {
|
|
_, _, mappings, cleanup := setupTest(t)
|
|
defer cleanup()
|
|
ctx := context.Background()
|
|
|
|
if err := mappings.SetMapping(ctx, "cn=IT-Admins,ou=groups,dc=example,dc=com", rbac.RoleTenantAdmin); err != nil {
|
|
t.Fatalf("set mapping: %v", err)
|
|
}
|
|
|
|
// Positivfall: gemappte Gruppe liefert die konfigurierte Rolle.
|
|
role, ok, err := mappings.HighestRoleFor(ctx, []string{"cn=IT-Admins,ou=groups,dc=example,dc=com"})
|
|
if err != nil {
|
|
t.Fatalf("highest role: %v", err)
|
|
}
|
|
if !ok || role != rbac.RoleTenantAdmin {
|
|
t.Fatalf("erwartet tenant_admin, habe ok=%v role=%q", ok, role)
|
|
}
|
|
|
|
// Negativfall 1: unbekannte Gruppe vergibt keine Rolle.
|
|
_, ok, err = mappings.HighestRoleFor(ctx, []string{"cn=Praktikanten,ou=groups,dc=example,dc=com"})
|
|
if err != nil {
|
|
t.Fatalf("highest role (unbekannt): %v", err)
|
|
}
|
|
if ok {
|
|
t.Fatal("erwartet KEINE rolle fuer nicht gemappte gruppe")
|
|
}
|
|
|
|
// Negativfall 2 (Privilege-Escalation-Versuch): eine Gruppe, die zufaellig
|
|
// wie eine interne Rolle heisst, aber NIE explizit gemappt wurde, darf
|
|
// keine Rolle vergeben.
|
|
_, ok, err = mappings.HighestRoleFor(ctx, []string{"tenant_admin", "superadmin"})
|
|
if err != nil {
|
|
t.Fatalf("highest role (namens-trick): %v", err)
|
|
}
|
|
if ok {
|
|
t.Fatal("erwartet KEINE privilege-escalation ueber gruppennamen, die wie rollen aussehen")
|
|
}
|
|
}
|
|
|
|
// Akzeptanzkriterium 1: Synchronisationslauf legt Benutzer an.
|
|
func TestSyncer_CreatesUsers(t *testing.T) {
|
|
users, roles, mappings, cleanup := setupTest(t)
|
|
defer cleanup()
|
|
ctx := context.Background()
|
|
|
|
email := uniqueEmail()
|
|
client := &fakeClient{entries: []Entry{{Email: email, Name: "LDAP Nutzer"}}}
|
|
syncer := NewSyncer(client, users, roles, mappings)
|
|
|
|
result, err := syncer.Run(ctx)
|
|
if err != nil {
|
|
t.Fatalf("run: %v", err)
|
|
}
|
|
if result.Created != 1 {
|
|
t.Fatalf("erwartet 1 angelegten benutzer, habe %d", result.Created)
|
|
}
|
|
|
|
if _, err := users.GetByEmailForAuth(ctx, email); err != nil {
|
|
t.Fatalf("benutzer sollte existieren: %v", err)
|
|
}
|
|
}
|
|
|
|
// COR-04 Pruefung 2: ein Konto ohne ldap_uid (z.B. aus einer Migration mit
|
|
// nur einfachem ldap_source-Feld) wird vom naechsten Sync-Lauf ergaenzt,
|
|
// sobald die LDAP-Quelle eine UID zum selben Konto liefert -- ohne Fehler.
|
|
func TestSyncer_EnrichesMissingLDAPUID(t *testing.T) {
|
|
users, roles, mappings, cleanup := setupTest(t)
|
|
defer cleanup()
|
|
ctx := context.Background()
|
|
|
|
email := uniqueEmail()
|
|
existing, err := users.Create(ctx, email, "Unvollstaendiges LDAP-Konto")
|
|
if err != nil {
|
|
t.Fatalf("create existing: %v", err)
|
|
}
|
|
|
|
info, err := users.LDAPInfo(ctx, existing.ID)
|
|
if err != nil {
|
|
t.Fatalf("ldap-info vor sync: %v", err)
|
|
}
|
|
if info.UID != nil {
|
|
t.Fatalf("erwarte kein ldap_uid vor sync, habe %v", *info.UID)
|
|
}
|
|
|
|
ldapUID := "enrich-uid-" + email
|
|
client := &fakeClient{entries: []Entry{{Email: email, Name: "Unvollstaendiges LDAP-Konto", UID: ldapUID}}}
|
|
syncer := NewSyncer(client, users, roles, mappings)
|
|
|
|
if _, err := syncer.Run(ctx); err != nil {
|
|
t.Fatalf("run: %v", err)
|
|
}
|
|
|
|
info, err = users.LDAPInfo(ctx, existing.ID)
|
|
if err != nil {
|
|
t.Fatalf("ldap-info nach sync: %v", err)
|
|
}
|
|
if info.UID == nil || *info.UID != ldapUID {
|
|
t.Fatalf("erwarte nachtraeglich gesetztes ldap_uid %q, habe %v", ldapUID, info.UID)
|
|
}
|
|
if info.SyncedAt == nil {
|
|
t.Fatal("erwarte gesetztes ldap_synced_at nach sync")
|
|
}
|
|
}
|
|
|
|
// Akzeptanzkriterium 2 + Pruefung 2: fehlerhafte/nicht erreichbare LDAP-
|
|
// Quelle bricht kontrolliert ab, ohne bestehende Konten zu beschaedigen.
|
|
func TestSyncer_AbortsCleanlyOnSourceError(t *testing.T) {
|
|
users, roles, mappings, cleanup := setupTest(t)
|
|
defer cleanup()
|
|
ctx := context.Background()
|
|
|
|
// Ein bereits bestehendes Konto, das UNBERUEHRT bleiben muss.
|
|
existingEmail := uniqueEmail()
|
|
existing, err := users.Create(ctx, existingEmail, "Bestehender Nutzer")
|
|
if err != nil {
|
|
t.Fatalf("create existing: %v", err)
|
|
}
|
|
|
|
client := &fakeClient{err: errors.New("ldap-server nicht erreichbar (simuliert)")}
|
|
syncer := NewSyncer(client, users, roles, mappings)
|
|
|
|
if _, err := syncer.Run(ctx); err == nil {
|
|
t.Fatal("erwartet fehler bei nicht erreichbarer ldap-quelle")
|
|
}
|
|
|
|
// Bestehendes Konto unveraendert (noch aktiv, keine geloeschten/neuen Zeilen).
|
|
got, err := users.Get(ctx, existing.ID)
|
|
if err != nil {
|
|
t.Fatalf("bestehendes konto sollte weiterhin existieren: %v", err)
|
|
}
|
|
if got.Status != user.StatusActive {
|
|
t.Fatalf("bestehendes konto sollte unveraendert aktiv sein, ist %q", got.Status)
|
|
}
|
|
}
|
|
|
|
// Akzeptanzkriterium 3 + Pruefung 3: Deaktivierung in LDAP wird bei
|
|
// naechster Synchronisation korrekt uebernommen.
|
|
func TestSyncer_AppliesDeactivationOnNextRun(t *testing.T) {
|
|
users, roles, mappings, cleanup := setupTest(t)
|
|
defer cleanup()
|
|
ctx := context.Background()
|
|
|
|
email := uniqueEmail()
|
|
client := &fakeClient{entries: []Entry{{Email: email, Name: "Wird Deaktiviert"}}}
|
|
syncer := NewSyncer(client, users, roles, mappings)
|
|
|
|
if _, err := syncer.Run(ctx); err != nil {
|
|
t.Fatalf("erster lauf: %v", err)
|
|
}
|
|
u, err := users.GetByEmailForAuth(ctx, email)
|
|
if err != nil {
|
|
t.Fatalf("benutzer nach erstem lauf: %v", err)
|
|
}
|
|
if u.User.Status != user.StatusActive {
|
|
t.Fatalf("erwartet aktiv nach erstem lauf, habe %q", u.User.Status)
|
|
}
|
|
|
|
// Zweiter Lauf: derselbe Benutzer ist jetzt in LDAP deaktiviert.
|
|
client.entries[0].Disabled = true
|
|
result, err := syncer.Run(ctx)
|
|
if err != nil {
|
|
t.Fatalf("zweiter lauf: %v", err)
|
|
}
|
|
if result.Deactivated != 1 {
|
|
t.Fatalf("erwartet 1 deaktivierung, habe %d", result.Deactivated)
|
|
}
|
|
|
|
u, err = users.GetByEmailForAuth(ctx, email)
|
|
if err != nil {
|
|
t.Fatalf("benutzer nach zweitem lauf: %v", err)
|
|
}
|
|
if u.User.Status != user.StatusInactive {
|
|
t.Fatalf("erwartet inaktiv nach deaktivierung in ldap, habe %q", u.User.Status)
|
|
}
|
|
}
|