package web_test import ( "context" "errors" "fmt" "net/http" "net/http/httptest" "net/url" "os" "strings" "sync" "testing" "time" "github.com/netcell-it/deklarix/internal/auth" "github.com/netcell-it/deklarix/internal/rules" "github.com/netcell-it/deklarix/internal/store" "github.com/netcell-it/deklarix/internal/web" ) const testSessionCookie = "deklarix_session" // fakeStore ist eine In-Memory-Implementierung von web.Store, damit die // Handler-Tests keine echte Postgres-Instanz brauchen. type fakeStore struct { mu sync.Mutex nextID int accounts map[string]store.Account users map[string]store.User usersByEmail map[string]string // email -> user id sessions map[string]store.Session auditLog []store.AuditEntry abteilungen map[string][]store.Abteilung // accountID -> Abteilungen antraege map[string]store.Antrag werkzeuge map[string]store.Werkzeug bewertungen map[string][]store.Bewertung // antragID -> Bewertungen, älteste zuerst entscheidungen map[string][]store.Entscheidung // antragID -> Entscheidungen, älteste zuerst registereintraege map[string][]store.Registereintrag // accountID -> Registereintraege werkzeugSperren map[string][]store.WerkzeugSperre // accountID -> Sperrungen } func newFakeStore() *fakeStore { return &fakeStore{ accounts: map[string]store.Account{}, users: map[string]store.User{}, usersByEmail: map[string]string{}, sessions: map[string]store.Session{}, abteilungen: map[string][]store.Abteilung{}, antraege: map[string]store.Antrag{}, werkzeuge: map[string]store.Werkzeug{}, bewertungen: map[string][]store.Bewertung{}, entscheidungen: map[string][]store.Entscheidung{}, registereintraege: map[string][]store.Registereintrag{}, werkzeugSperren: map[string][]store.WerkzeugSperre{}, } } func (f *fakeStore) newID() string { f.nextID++ return fmt.Sprintf("id-%d", f.nextID) } func (f *fakeStore) CreateAccount(ctx context.Context, name string) (store.Account, error) { f.mu.Lock() defer f.mu.Unlock() acc := store.Account{ID: f.newID(), Name: name, CreatedAt: time.Now()} f.accounts[acc.ID] = acc return acc, nil } func (f *fakeStore) GetAccount(ctx context.Context, id string) (store.Account, error) { f.mu.Lock() defer f.mu.Unlock() acc, ok := f.accounts[id] if !ok { return store.Account{}, store.ErrNotFound } return acc, nil } func (f *fakeStore) ListAccounts(ctx context.Context) ([]store.Account, error) { f.mu.Lock() defer f.mu.Unlock() var out []store.Account for _, acc := range f.accounts { out = append(out, acc) } return out, nil } func (f *fakeStore) CreateUser(ctx context.Context, accountID, email, passwordHash, role string) (store.User, error) { f.mu.Lock() defer f.mu.Unlock() if _, exists := f.usersByEmail[email]; exists { return store.User{}, fmt.Errorf("fakeStore: email %s bereits vergeben", email) } u := store.User{ ID: f.newID(), AccountID: accountID, Email: email, PasswordHash: passwordHash, Role: role, Active: true, CreatedAt: time.Now(), } f.users[u.ID] = u f.usersByEmail[email] = u.ID return u, nil } func (f *fakeStore) GetUserByEmail(ctx context.Context, email string) (store.User, error) { f.mu.Lock() defer f.mu.Unlock() id, ok := f.usersByEmail[email] if !ok { return store.User{}, store.ErrNotFound } return f.users[id], nil } func (f *fakeStore) GetUser(ctx context.Context, id string) (store.User, error) { f.mu.Lock() defer f.mu.Unlock() u, ok := f.users[id] if !ok { return store.User{}, store.ErrNotFound } return u, nil } func (f *fakeStore) SetUserActive(ctx context.Context, id string, active bool) error { f.mu.Lock() defer f.mu.Unlock() u, ok := f.users[id] if !ok { return store.ErrNotFound } u.Active = active f.users[id] = u return nil } func (f *fakeStore) ListUsersForAccount(ctx context.Context, accountID string) ([]store.User, error) { f.mu.Lock() defer f.mu.Unlock() var out []store.User for _, u := range f.users { if u.AccountID == accountID { out = append(out, u) } } return out, nil } func (f *fakeStore) CreateSession(ctx context.Context, token, userID string, expiresAt time.Time) (store.Session, error) { f.mu.Lock() defer f.mu.Unlock() sess := store.Session{Token: token, UserID: userID, ExpiresAt: expiresAt, CreatedAt: time.Now()} f.sessions[token] = sess return sess, nil } func (f *fakeStore) GetSession(ctx context.Context, token string) (store.Session, error) { f.mu.Lock() defer f.mu.Unlock() sess, ok := f.sessions[token] if !ok { return store.Session{}, store.ErrNotFound } return sess, nil } func (f *fakeStore) DeleteSession(ctx context.Context, token string) error { f.mu.Lock() defer f.mu.Unlock() delete(f.sessions, token) return nil } func (f *fakeStore) CreateAuditEntry(ctx context.Context, actorUserID, action, targetType, targetID, details string) (store.AuditEntry, error) { f.mu.Lock() defer f.mu.Unlock() e := store.AuditEntry{ ID: f.newID(), ActorUserID: actorUserID, Action: action, TargetType: targetType, TargetID: targetID, Details: details, CreatedAt: time.Now(), } f.auditLog = append(f.auditLog, e) return e, nil } func (f *fakeStore) ListAuditLog(ctx context.Context, limit int) ([]store.AuditEntry, error) { f.mu.Lock() defer f.mu.Unlock() out := make([]store.AuditEntry, len(f.auditLog)) for i, e := range f.auditLog { out[len(f.auditLog)-1-i] = e } if len(out) > limit { out = out[:limit] } return out, nil } func (f *fakeStore) ListAbteilungenForAccount(ctx context.Context, accountID string) ([]store.Abteilung, error) { f.mu.Lock() defer f.mu.Unlock() return f.abteilungen[accountID], nil } func (f *fakeStore) GetAbteilung(ctx context.Context, id string) (store.Abteilung, error) { f.mu.Lock() defer f.mu.Unlock() for _, list := range f.abteilungen { for _, a := range list { if a.ID == id { return a, nil } } } return store.Abteilung{}, store.ErrNotFound } func (f *fakeStore) CreateAbteilung(ctx context.Context, accountID, name string) (store.Abteilung, error) { f.mu.Lock() defer f.mu.Unlock() a := store.Abteilung{ID: f.newID(), AccountID: accountID, Name: name, CreatedAt: time.Now()} f.abteilungen[accountID] = append(f.abteilungen[accountID], a) return a, nil } func (f *fakeStore) DeleteAbteilung(ctx context.Context, id string) error { f.mu.Lock() defer f.mu.Unlock() for accID, list := range f.abteilungen { for i, a := range list { if a.ID == id { f.abteilungen[accID] = append(list[:i], list[i+1:]...) return nil } } } return store.ErrNotFound } func (f *fakeStore) CreateAntrag(ctx context.Context, accountID, erstellerUserID string, abteilungID *string, titel string) (store.Antrag, error) { f.mu.Lock() defer f.mu.Unlock() a := store.Antrag{ ID: f.newID(), AccountID: accountID, ErstellerUserID: erstellerUserID, AbteilungID: abteilungID, Titel: titel, Status: "entwurf", Antworten: []byte(`{}`), CreatedAt: time.Now(), UpdatedAt: time.Now(), } f.antraege[a.ID] = a return a, nil } func (f *fakeStore) GetAntrag(ctx context.Context, id string) (store.Antrag, error) { f.mu.Lock() defer f.mu.Unlock() a, ok := f.antraege[id] if !ok { return store.Antrag{}, store.ErrNotFound } return a, nil } func (f *fakeStore) UpdateAntragFelder(ctx context.Context, id, titel, beschreibung, ergebnis, haeufigkeit string, antworten []byte) (store.Antrag, error) { f.mu.Lock() defer f.mu.Unlock() a, ok := f.antraege[id] if !ok { return store.Antrag{}, store.ErrNotFound } a.Titel, a.Beschreibung, a.Ergebnis, a.Haeufigkeit, a.Antworten = titel, beschreibung, ergebnis, haeufigkeit, antworten a.UpdatedAt = time.Now() f.antraege[id] = a return a, nil } func (f *fakeStore) SetAntragStatus(ctx context.Context, id, status string) error { f.mu.Lock() defer f.mu.Unlock() a, ok := f.antraege[id] if !ok { return store.ErrNotFound } a.Status = status f.antraege[id] = a return nil } func (f *fakeStore) ListAntraegeForUser(ctx context.Context, erstellerUserID string) ([]store.Antrag, error) { f.mu.Lock() defer f.mu.Unlock() var out []store.Antrag for _, a := range f.antraege { if a.ErstellerUserID == erstellerUserID { out = append(out, a) } } return out, nil } func (f *fakeStore) ListAntraegeForAccount(ctx context.Context, accountID string) ([]store.Antrag, error) { f.mu.Lock() defer f.mu.Unlock() var out []store.Antrag for _, a := range f.antraege { if a.AccountID == accountID { out = append(out, a) } } return out, nil } func (f *fakeStore) ListWerkzeugeForAccount(ctx context.Context, accountID string) ([]store.Werkzeug, error) { f.mu.Lock() defer f.mu.Unlock() var out []store.Werkzeug for _, w := range f.werkzeuge { if w.AccountID == nil || *w.AccountID == accountID { out = append(out, w) } } return out, nil } func (f *fakeStore) ListZentraleWerkzeuge(ctx context.Context) ([]store.Werkzeug, error) { f.mu.Lock() defer f.mu.Unlock() var out []store.Werkzeug for _, w := range f.werkzeuge { if w.AccountID == nil { out = append(out, w) } } return out, nil } func (f *fakeStore) CreateWerkzeugSperre(ctx context.Context, accountID, werkzeugID, grund string) (store.WerkzeugSperre, error) { f.mu.Lock() defer f.mu.Unlock() sp := store.WerkzeugSperre{ID: f.newID(), AccountID: accountID, WerkzeugID: werkzeugID, Grund: grund, GesperrtAm: time.Now()} f.werkzeugSperren[accountID] = append(f.werkzeugSperren[accountID], sp) return sp, nil } func (f *fakeStore) DeleteWerkzeugSperre(ctx context.Context, accountID, werkzeugID string) error { f.mu.Lock() defer f.mu.Unlock() list := f.werkzeugSperren[accountID] for i, sp := range list { if sp.WerkzeugID == werkzeugID { f.werkzeugSperren[accountID] = append(list[:i], list[i+1:]...) return nil } } return store.ErrNotFound } func (f *fakeStore) ListWerkzeugSperrenForAccount(ctx context.Context, accountID string) ([]store.WerkzeugSperre, error) { f.mu.Lock() defer f.mu.Unlock() return f.werkzeugSperren[accountID], nil } func (f *fakeStore) CreateWerkzeug(ctx context.Context, in store.WerkzeugInput) (store.Werkzeug, error) { f.mu.Lock() defer f.mu.Unlock() w := store.Werkzeug{ ID: f.newID(), AccountID: in.AccountID, Name: in.Name, Anbieter: in.Anbieter, Verarbeitungsort: in.Verarbeitungsort, AVVVerfuegbar: in.AVVVerfuegbar, AVVURL: in.AVVURL, TrainingOptOut: in.TrainingOptOut, TrainingStandard: in.TrainingStandard, AufbewahrungTage: in.AufbewahrungTage, Zertifizierungen: in.Zertifizierungen, GeeigneteZwecke: in.GeeigneteZwecke, Einschraenkungen: in.Einschraenkungen, LetztePruefung: in.LetztePruefung, Quelle: in.Quelle, CreatedAt: time.Now(), UpdatedAt: time.Now(), } f.werkzeuge[w.ID] = w return w, nil } func (f *fakeStore) UpdateWerkzeug(ctx context.Context, id string, in store.WerkzeugInput) (store.Werkzeug, error) { f.mu.Lock() defer f.mu.Unlock() w, ok := f.werkzeuge[id] if !ok { return store.Werkzeug{}, store.ErrNotFound } w.Name, w.Anbieter, w.Verarbeitungsort = in.Name, in.Anbieter, in.Verarbeitungsort w.AVVVerfuegbar, w.AVVURL = in.AVVVerfuegbar, in.AVVURL w.TrainingOptOut, w.TrainingStandard, w.AufbewahrungTage = in.TrainingOptOut, in.TrainingStandard, in.AufbewahrungTage w.Zertifizierungen, w.GeeigneteZwecke, w.Einschraenkungen = in.Zertifizierungen, in.GeeigneteZwecke, in.Einschraenkungen w.LetztePruefung, w.Quelle, w.UpdatedAt = in.LetztePruefung, in.Quelle, time.Now() f.werkzeuge[id] = w return w, nil } func (f *fakeStore) DeleteWerkzeug(ctx context.Context, id string) error { f.mu.Lock() defer f.mu.Unlock() if _, ok := f.werkzeuge[id]; !ok { return store.ErrNotFound } delete(f.werkzeuge, id) return nil } func (f *fakeStore) GetWerkzeug(ctx context.Context, id string) (store.Werkzeug, error) { f.mu.Lock() defer f.mu.Unlock() w, ok := f.werkzeuge[id] if !ok { return store.Werkzeug{}, store.ErrNotFound } return w, nil } func (f *fakeStore) CurrentKatalogVersion(ctx context.Context) (string, error) { f.mu.Lock() defer f.mu.Unlock() return fmt.Sprintf("fake-%d", len(f.werkzeuge)), nil } func (f *fakeStore) CreateBewertung(ctx context.Context, in store.BewertungInput) (store.Bewertung, error) { f.mu.Lock() defer f.mu.Unlock() b := store.Bewertung{ ID: f.newID(), AntragID: in.AntragID, Datenklasse: in.Datenklasse, DatenklasseHerleitung: in.DatenklasseHerleitung, Einstufung: in.Einstufung, EinstufungHerleitung: in.EinstufungHerleitung, Verboten: in.Verboten, Anforderungen: in.Anforderungen, ZulaessigeWerkzeuge: in.ZulaessigeWerkzeuge, AusgeschlosseneWerkzeuge: in.AusgeschlosseneWerkzeuge, RegelwerkVersion: in.RegelwerkVersion, KatalogVersion: in.KatalogVersion, CreatedAt: time.Now(), } f.bewertungen[in.AntragID] = append(f.bewertungen[in.AntragID], b) return b, nil } func (f *fakeStore) GetLatestBewertungForAntrag(ctx context.Context, antragID string) (store.Bewertung, error) { f.mu.Lock() defer f.mu.Unlock() bs := f.bewertungen[antragID] if len(bs) == 0 { return store.Bewertung{}, store.ErrNotFound } return bs[len(bs)-1], nil } func (f *fakeStore) CreateEntscheidung(ctx context.Context, in store.EntscheidungInput) (store.Entscheidung, error) { f.mu.Lock() defer f.mu.Unlock() e := store.Entscheidung{ ID: f.newID(), AntragID: in.AntragID, BewertungID: in.BewertungID, EntscheiderUserID: in.EntscheiderUserID, Entscheidung: in.Entscheidung, WerkzeugID: in.WerkzeugID, WerkzeugSnapshot: in.WerkzeugSnapshot, Begruendung: in.Begruendung, GueltigBis: in.GueltigBis, CreatedAt: time.Now(), } f.entscheidungen[in.AntragID] = append(f.entscheidungen[in.AntragID], e) return e, nil } func (f *fakeStore) GetLatestEntscheidungForAntrag(ctx context.Context, antragID string) (store.Entscheidung, error) { f.mu.Lock() defer f.mu.Unlock() es := f.entscheidungen[antragID] if len(es) == 0 { return store.Entscheidung{}, store.ErrNotFound } return es[len(es)-1], nil } func (f *fakeStore) ListAktiveGenehmigungenForAccount(ctx context.Context, accountID string) ([]store.Entscheidung, error) { f.mu.Lock() defer f.mu.Unlock() var out []store.Entscheidung for antragID, a := range f.antraege { if a.AccountID != accountID { continue } es := f.entscheidungen[antragID] if len(es) == 0 { continue } latest := es[len(es)-1] if latest.Entscheidung == "genehmigt" || latest.Entscheidung == "genehmigt_mit_auflagen" { out = append(out, latest) } } return out, nil } func (f *fakeStore) CreateRegistereintrag(ctx context.Context, in store.RegistereintragInput) (store.Registereintrag, error) { f.mu.Lock() defer f.mu.Unlock() e := store.Registereintrag{ ID: f.newID(), AccountID: in.AccountID, AntragID: in.AntragID, EntscheidungID: in.EntscheidungID, Zweck: in.Zweck, Abteilung: in.Abteilung, Werkzeug: in.Werkzeug, Datenklasse: in.Datenklasse, Einstufung: in.Einstufung, Auflagen: in.Auflagen, Verantwortlicher: in.Verantwortlicher, EntschiedenAm: in.EntschiedenAm, GueltigBis: in.GueltigBis, CreatedAt: time.Now(), } f.registereintraege[in.AccountID] = append(f.registereintraege[in.AccountID], e) return e, nil } func (f *fakeStore) ListRegistereintraegeForAccount(ctx context.Context, accountID string) ([]store.Registereintrag, error) { f.mu.Lock() defer f.mu.Unlock() return f.registereintraege[accountID], nil } // ─── Test-Setup ─────────────────────────────────────────────────────── // loadTestRegelwerk lädt die echten rules/*.yaml-Dateien — dieselben, // die auch main.go beim Start lädt. Handler-Tests laufen so gegen das // tatsächliche Regelwerk statt gegen ein Test-Fixture, das getrennt // von rules/ gepflegt werden müsste. func loadTestRegelwerk(t *testing.T) web.Regelwerk { t.Helper() fsys := os.DirFS("../../rules") dk, err := rules.LoadDatenklasse(fsys, "datenklasse.yaml") if err != nil { t.Fatalf("LoadDatenklasse: %v", err) } ei, err := rules.LoadEinstufung(fsys, "kivo_einstufung.yaml") if err != nil { t.Fatalf("LoadEinstufung: %v", err) } an, err := rules.LoadAnforderungen(fsys, "anforderungen.yaml") if err != nil { t.Fatalf("LoadAnforderungen: %v", err) } return web.Regelwerk{Datenklasse: dk, Einstufung: ei, Anforderungen: an} } func newServer(t *testing.T, fs *fakeStore) *web.Server { t.Helper() s, err := web.NewServer(fs, loadTestRegelwerk(t)) if err != nil { t.Fatalf("NewServer: %v", err) } return s } // seedAccountWithRole legt direkt im fakeStore (ohne HTTP) einen // Account, einen Nutzer und eine gültige Sitzung an und liefert das // Session-Cookie. func seedAccountWithRole(t *testing.T, fs *fakeStore, accountName, email, role string) *http.Cookie { t.Helper() ctx := context.Background() acc, err := fs.CreateAccount(ctx, accountName) if err != nil { t.Fatalf("CreateAccount: %v", err) } hash, err := auth.HashPassword("test-passwort-123") if err != nil { t.Fatalf("HashPassword: %v", err) } user, err := fs.CreateUser(ctx, acc.ID, email, hash, role) if err != nil { t.Fatalf("CreateUser: %v", err) } token, err := auth.NewSessionToken() if err != nil { t.Fatalf("NewSessionToken: %v", err) } if _, err := fs.CreateSession(ctx, token, user.ID, time.Now().Add(time.Hour)); err != nil { t.Fatalf("CreateSession: %v", err) } return &http.Cookie{Name: testSessionCookie, Value: token} } // seedUserInAccount legt einen weiteren Nutzer in einem bereits // existierenden Account an — für Tests, die mehrere Rollen im selben // Mandanten brauchen (z. B. mitarbeiter stellt einen Antrag, // verantwortlicher entscheidet darüber). func seedUserInAccount(t *testing.T, fs *fakeStore, accountID, email, role string) *http.Cookie { t.Helper() ctx := context.Background() hash, err := auth.HashPassword("test-passwort-123") if err != nil { t.Fatalf("HashPassword: %v", err) } user, err := fs.CreateUser(ctx, accountID, email, hash, role) if err != nil { t.Fatalf("CreateUser: %v", err) } token, err := auth.NewSessionToken() if err != nil { t.Fatalf("NewSessionToken: %v", err) } if _, err := fs.CreateSession(ctx, token, user.ID, time.Now().Add(time.Hour)); err != nil { t.Fatalf("CreateSession: %v", err) } return &http.Cookie{Name: testSessionCookie, Value: token} } func seedAccount(t *testing.T, fs *fakeStore, accountName, email string) *http.Cookie { t.Helper() return seedAccountWithRole(t, fs, accountName, email, "mitarbeiter") } func newAuthedTestServer(t *testing.T) (*web.Server, *fakeStore, *http.Cookie) { t.Helper() fs := newFakeStore() s := newServer(t, fs) cookie := seedAccount(t, fs, "Test-Mandant", "test@example.com") return s, fs, cookie } func postForm(t *testing.T, s *web.Server, cookie *http.Cookie, path string, form url.Values) *httptest.ResponseRecorder { t.Helper() req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") if cookie != nil { req.AddCookie(cookie) } w := httptest.NewRecorder() s.ServeHTTP(w, req) return w } func getWithCookie(t *testing.T, s *web.Server, cookie *http.Cookie, path string) *httptest.ResponseRecorder { t.Helper() req := httptest.NewRequest(http.MethodGet, path, nil) if cookie != nil { req.AddCookie(cookie) } w := httptest.NewRecorder() s.ServeHTTP(w, req) return w } // ─── Tests: Health, statische Assets (kein Auth nötig) ──────────────── func TestHandleHealth(t *testing.T) { s, _, _ := newAuthedTestServer(t) req := httptest.NewRequest(http.MethodGet, "/health", nil) w := httptest.NewRecorder() s.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("status = %d, want 200", w.Code) } if w.Body.String() != `{"ok":true}` { t.Fatalf("body = %q, want {\"ok\":true}", w.Body.String()) } } func TestHandleStaticServesHTMX(t *testing.T) { s, _, _ := newAuthedTestServer(t) req := httptest.NewRequest(http.MethodGet, "/static/htmx.min.js", nil) w := httptest.NewRecorder() s.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("status = %d, want 200", w.Code) } if w.Body.Len() < 1000 { t.Fatalf("htmx.min.js suspiciously small: %d bytes", w.Body.Len()) } } // ─── Tests: Auth (Registrierung, Login, Logout, Zugriffsschutz) ─────── func TestRegisterThenLoginThenAccessProtectedPage(t *testing.T) { fs := newFakeStore() s := newServer(t, fs) regResp := postForm(t, s, nil, "/register", url.Values{ "account_name": {"Meine Firma"}, "email": {"neu@example.com"}, "password": {"ein-sicheres-passwort"}, }) if regResp.Code != http.StatusSeeOther { t.Fatalf("register status = %d, want 303, body: %s", regResp.Code, regResp.Body.String()) } cookies := regResp.Result().Cookies() if len(cookies) == 0 { t.Fatal("expected a session cookie to be set after registration") } indexResp := getWithCookie(t, s, cookies[0], "/") if indexResp.Code != http.StatusOK { t.Fatalf("index status with fresh session = %d, want 200", indexResp.Code) } var created store.User for _, u := range fs.users { created = u } if created.Role != "admin" { t.Fatalf("expected the first user of a new company to have role admin, got %q", created.Role) } } func TestRegisterRejectsDuplicateEmail(t *testing.T) { fs := newFakeStore() s := newServer(t, fs) form := url.Values{ "account_name": {"A"}, "email": {"doppelt@example.com"}, "password": {"ein-sicheres-passwort"}, } if resp := postForm(t, s, nil, "/register", form); resp.Code != http.StatusSeeOther { t.Fatalf("first register status = %d, want 303", resp.Code) } resp := postForm(t, s, nil, "/register", form) if resp.Code != http.StatusOK { t.Fatalf("second register status = %d, want 200 (re-rendered form with error)", resp.Code) } if !strings.Contains(resp.Body.String(), "schon vergeben") { t.Errorf("expected a duplicate-email error, got: %s", resp.Body.String()) } } func TestLoginWithCorrectPassword(t *testing.T) { fs := newFakeStore() s := newServer(t, fs) hash, err := auth.HashPassword("richtiges-passwort") if err != nil { t.Fatalf("HashPassword: %v", err) } acc, err := fs.CreateAccount(context.Background(), "Bestehender Mandant") if err != nil { t.Fatalf("CreateAccount: %v", err) } if _, err := fs.CreateUser(context.Background(), acc.ID, "bestehend@example.com", hash, "mitarbeiter"); err != nil { t.Fatalf("CreateUser: %v", err) } resp := postForm(t, s, nil, "/login", url.Values{"email": {"bestehend@example.com"}, "password": {"richtiges-passwort"}}) if resp.Code != http.StatusSeeOther { t.Fatalf("login status = %d, want 303, body: %s", resp.Code, resp.Body.String()) } if len(resp.Result().Cookies()) == 0 { t.Fatal("expected a session cookie after successful login") } } func TestLoginRedirectsBetreiberToPlatformDashboard(t *testing.T) { fs := newFakeStore() s := newServer(t, fs) hash, err := auth.HashPassword("admin-passwort") if err != nil { t.Fatalf("HashPassword: %v", err) } acc, err := fs.CreateAccount(context.Background(), "Deklarix Betreiber") if err != nil { t.Fatalf("CreateAccount: %v", err) } if _, err := fs.CreateUser(context.Background(), acc.ID, "betreiber@example.com", hash, "betreiber"); err != nil { t.Fatalf("CreateUser: %v", err) } resp := postForm(t, s, nil, "/login", url.Values{"email": {"betreiber@example.com"}, "password": {"admin-passwort"}}) if resp.Code != http.StatusSeeOther { t.Fatalf("status = %d, want 303, body: %s", resp.Code, resp.Body.String()) } if loc := resp.Header().Get("Location"); loc != "/betreiber" { t.Fatalf("Location = %q, want /betreiber", loc) } } func TestLoginRejectsWrongPassword(t *testing.T) { fs := newFakeStore() s := newServer(t, fs) hash, err := auth.HashPassword("richtiges-passwort") if err != nil { t.Fatalf("HashPassword: %v", err) } acc, _ := fs.CreateAccount(context.Background(), "X") if _, err := fs.CreateUser(context.Background(), acc.ID, "x@example.com", hash, "mitarbeiter"); err != nil { t.Fatalf("CreateUser: %v", err) } resp := postForm(t, s, nil, "/login", url.Values{"email": {"x@example.com"}, "password": {"falsch"}}) if resp.Code != http.StatusOK { t.Fatalf("status = %d, want 200 (re-rendered login form)", resp.Code) } if len(resp.Result().Cookies()) != 0 { t.Fatal("expected no session cookie for a failed login") } } func TestLoginRejectsUnknownEmailWithSameMessageAsWrongPassword(t *testing.T) { fs := newFakeStore() s := newServer(t, fs) resp := postForm(t, s, nil, "/login", url.Values{"email": {"gibtsnicht@example.com"}, "password": {"irgendwas"}}) if !strings.Contains(resp.Body.String(), "E-Mail oder Passwort falsch") { t.Errorf("expected the generic invalid-credentials message, got: %s", resp.Body.String()) } } func TestIndexRedirectsToLoginWithoutSession(t *testing.T) { s, _, _ := newAuthedTestServer(t) req := httptest.NewRequest(http.MethodGet, "/", nil) w := httptest.NewRecorder() s.ServeHTTP(w, req) if w.Code != http.StatusSeeOther { t.Fatalf("status = %d, want 303 redirect to /login", w.Code) } if loc := w.Header().Get("Location"); loc != "/login" { t.Fatalf("Location = %q, want /login", loc) } } func TestLogoutClearsSession(t *testing.T) { s, fs, cookie := newAuthedTestServer(t) if resp := getWithCookie(t, s, cookie, "/"); resp.Code != http.StatusOK { t.Fatalf("index before logout = %d, want 200", resp.Code) } logoutResp := postForm(t, s, cookie, "/logout", url.Values{}) if logoutResp.Code != http.StatusSeeOther { t.Fatalf("logout status = %d, want 303", logoutResp.Code) } if _, err := fs.GetSession(context.Background(), cookie.Value); !errors.Is(err, store.ErrNotFound) { t.Fatal("expected the session to be deleted from the store after logout") } if resp := getWithCookie(t, s, cookie, "/"); resp.Code != http.StatusSeeOther { t.Fatalf("index after logout = %d, want 303 redirect (session no longer valid)", resp.Code) } } func TestHandleIndexRendersForMitarbeiter(t *testing.T) { s, _, cookie := newAuthedTestServer(t) resp := getWithCookie(t, s, cookie, "/") if resp.Code != http.StatusOK { t.Fatalf("status = %d, want 200", resp.Code) } } // ─── Tests: Plattform-Bereich (nur Betreiber) ───────────────────────── func TestBetreiberRoutesRejectNonBetreiberWith404(t *testing.T) { s, _, cookie := newAuthedTestServer(t) for _, path := range []string{"/betreiber", "/betreiber/accounts", "/betreiber/audit-log", "/betreiber/werkzeuge"} { resp := getWithCookie(t, s, cookie, path) if resp.Code != http.StatusNotFound { t.Errorf("GET %s status = %d, want 404 for a non-betreiber user", path, resp.Code) } } } func TestBetreiberRoutesRedirectToLoginWithoutSession(t *testing.T) { s, _, _ := newAuthedTestServer(t) resp := getWithCookie(t, s, nil, "/betreiber") if resp.Code != http.StatusSeeOther { t.Fatalf("status = %d, want 303 redirect to /login", resp.Code) } } func TestBetreiberDashboardAccessibleForBetreiber(t *testing.T) { fs := newFakeStore() s := newServer(t, fs) betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber") resp := getWithCookie(t, s, betreiberCookie, "/betreiber") if resp.Code != http.StatusOK { t.Fatalf("status = %d, want 200, body: %s", resp.Code, resp.Body.String()) } } func TestBetreiberAccountListShowsAllAccountsAcrossTenants(t *testing.T) { fs := newFakeStore() s := newServer(t, fs) betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber") seedAccount(t, fs, "Mandant A", "a@example.com") seedAccount(t, fs, "Mandant B", "b@example.com") resp := getWithCookie(t, s, betreiberCookie, "/betreiber/accounts") if resp.Code != http.StatusOK { t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String()) } body := resp.Body.String() for _, want := range []string{"Mandant A", "Mandant B", "Deklarix Betreiber"} { if !strings.Contains(body, want) { t.Errorf("expected %q in the betreiber account list, got: %s", want, body) } } } func TestBetreiberAccountDetailShowsUsers(t *testing.T) { fs := newFakeStore() s := newServer(t, fs) betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber") seedAccount(t, fs, "Mandant A", "mitarbeiter-a@example.com") var accID string for id, acc := range fs.accounts { if acc.Name == "Mandant A" { accID = id } } resp := getWithCookie(t, s, betreiberCookie, "/betreiber/accounts/"+accID) if resp.Code != http.StatusOK { t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String()) } if !strings.Contains(resp.Body.String(), "mitarbeiter-a@example.com") { t.Errorf("expected the user's email on the account detail page, got: %s", resp.Body.String()) } } func TestBetreiberAuditLogShowsEntries(t *testing.T) { fs := newFakeStore() s := newServer(t, fs) betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber") betreiber, err := fs.GetUserByEmail(context.Background(), "betreiber@example.com") if err != nil { t.Fatalf("GetUserByEmail: %v", err) } if _, err := fs.CreateAuditEntry(context.Background(), betreiber.ID, "werkzeug.aktualisiert", "werkzeug", "w1", "manuell geprüft"); err != nil { t.Fatalf("CreateAuditEntry: %v", err) } resp := getWithCookie(t, s, betreiberCookie, "/betreiber/audit-log") if resp.Code != http.StatusOK { t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String()) } if !strings.Contains(resp.Body.String(), "werkzeug.aktualisiert") { t.Errorf("expected the audit entry on the audit log page, got: %s", resp.Body.String()) } }