Bei Firmenanlage (Registrierung + Betreiber-Firmenanlage) müssen jetzt Adresse (Straße, PLZ, Ort, Land) und Abrechnungsdaten (Rechnungsemail, optional USt-IdNr.) erfasst werden, nicht nur der Firmenname (Migration 0023). USt-IdNr. bewusst optional - Kleinunternehmer nach §19 UStG haben keine. Neue Seite /verwaltung/firma (admin-only) zum Einsehen/ Nachtragen für bestehende Firmen. store.CreateAccount nimmt jetzt ein AccountInput statt nur einen Namen entgegen (Signaturänderung betrifft ~20 Testaufrufe, mechanisch umgestellt). register.html/ betreiber_account_neu.html auf form-card/form-grid umgestellt. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1422 lines
45 KiB
Go
1422 lines
45 KiB
Go
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/mail"
|
|
"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
|
|
|
|
genehmigerRollen map[string]store.GenehmigerRolle
|
|
nutzerGenehmigerRollen map[string]map[string]bool // userID -> Set von genehmigerRolleID
|
|
freigabeRegeln map[string]store.FreigabeRegel
|
|
freigabeschritte map[string]store.Freigabeschritt
|
|
passwordResetTokens map[string]store.PasswordResetToken
|
|
loeschfristen map[string]map[string]int // accountID -> datenklasseID -> maxTage
|
|
emailVorlagen map[string]store.EmailVorlage // key: accountKeyFor(accountID)+"|"+typ
|
|
}
|
|
|
|
// accountKeyFor macht nil und "" für die Plattform-Vorlage im
|
|
// fakeStore-Key ununterscheidbar von einer echten, aber leeren
|
|
// account_id — genügt für Tests, da echte account_ids nie leer sind.
|
|
func accountKeyFor(accountID *string) string {
|
|
if accountID == nil {
|
|
return ""
|
|
}
|
|
return *accountID
|
|
}
|
|
|
|
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{},
|
|
genehmigerRollen: map[string]store.GenehmigerRolle{},
|
|
nutzerGenehmigerRollen: map[string]map[string]bool{},
|
|
freigabeRegeln: map[string]store.FreigabeRegel{},
|
|
freigabeschritte: map[string]store.Freigabeschritt{},
|
|
passwordResetTokens: map[string]store.PasswordResetToken{},
|
|
loeschfristen: map[string]map[string]int{},
|
|
emailVorlagen: map[string]store.EmailVorlage{
|
|
// Platzhalter für den plattformweiten Standard, wie ihn
|
|
// Migration 0022 für echtes Postgres seedet — sonst würde
|
|
// ResolveEmailVorlage im fakeStore ErrNotFound liefern,
|
|
// wo die echte DB immer einen Treffer hat.
|
|
"|passwort_zuruecksetzen": {
|
|
Typ: "passwort_zuruecksetzen", Betreff: "Deklarix — Passwort zurücksetzen",
|
|
Text: "Hallo,\n\nüber diesen Link kannst du dein Deklarix-Passwort zurücksetzen (gültig 1 Stunde):\n{{link}}\n\nFalls du das nicht angefordert hast, ignoriere diese E-Mail.",
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
// WithTenantScope/SetTenantScope sind im fakeStore reine Passthroughs —
|
|
// fakeStore hat kein RLS-Äquivalent, Tests prüfen Isolation weiterhin
|
|
// wie bisher auf Anwendungsebene (AccountID-Vergleich in den Handlern).
|
|
func (f *fakeStore) WithTenantScope(ctx context.Context, accountID string, isBetreiber bool, fn func(ctx context.Context) error) error {
|
|
return fn(ctx)
|
|
}
|
|
|
|
func (f *fakeStore) SetTenantScope(ctx context.Context, accountID string, isBetreiber bool) error {
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeStore) UpsertEmailVorlage(ctx context.Context, accountID *string, typ, betreff, text string) (store.EmailVorlage, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
key := accountKeyFor(accountID) + "|" + typ
|
|
v := store.EmailVorlage{ID: f.newID(), AccountID: accountID, Typ: typ, Betreff: betreff, Text: text, UpdatedAt: time.Now()}
|
|
f.emailVorlagen[key] = v
|
|
return v, nil
|
|
}
|
|
|
|
func (f *fakeStore) GetEmailVorlage(ctx context.Context, accountID *string, typ string) (store.EmailVorlage, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
v, ok := f.emailVorlagen[accountKeyFor(accountID)+"|"+typ]
|
|
if !ok {
|
|
return store.EmailVorlage{}, store.ErrNotFound
|
|
}
|
|
return v, nil
|
|
}
|
|
|
|
func (f *fakeStore) ResolveEmailVorlage(ctx context.Context, accountID, typ string) (store.EmailVorlage, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
if v, ok := f.emailVorlagen[accountID+"|"+typ]; ok {
|
|
return v, nil
|
|
}
|
|
if v, ok := f.emailVorlagen["|"+typ]; ok {
|
|
return v, nil
|
|
}
|
|
return store.EmailVorlage{}, store.ErrNotFound
|
|
}
|
|
|
|
func (f *fakeStore) DeleteEmailVorlage(ctx context.Context, accountID *string, typ string) error {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
key := accountKeyFor(accountID) + "|" + typ
|
|
if _, ok := f.emailVorlagen[key]; !ok {
|
|
return store.ErrNotFound
|
|
}
|
|
delete(f.emailVorlagen, key)
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeStore) newID() string {
|
|
f.nextID++
|
|
return fmt.Sprintf("id-%d", f.nextID)
|
|
}
|
|
|
|
func (f *fakeStore) CreateAccount(ctx context.Context, in store.AccountInput) (store.Account, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
id := f.newID()
|
|
acc := store.Account{
|
|
ID: id, Name: in.Name, EinladungToken: "einladung-token-" + id,
|
|
Strasse: in.Strasse, PLZ: in.PLZ, Ort: in.Ort, Land: in.Land,
|
|
UStID: in.UStID, Rechnungsemail: in.Rechnungsemail, CreatedAt: time.Now(),
|
|
}
|
|
f.accounts[acc.ID] = acc
|
|
return acc, nil
|
|
}
|
|
|
|
func (f *fakeStore) UpdateAccount(ctx context.Context, id, name string) (store.Account, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
acc, ok := f.accounts[id]
|
|
if !ok {
|
|
return store.Account{}, store.ErrNotFound
|
|
}
|
|
acc.Name = name
|
|
f.accounts[id] = acc
|
|
return acc, nil
|
|
}
|
|
|
|
func (f *fakeStore) UpdateAccountDetails(ctx context.Context, id string, in store.AccountInput) (store.Account, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
acc, ok := f.accounts[id]
|
|
if !ok {
|
|
return store.Account{}, store.ErrNotFound
|
|
}
|
|
acc.Name, acc.Strasse, acc.PLZ, acc.Ort, acc.Land, acc.UStID, acc.Rechnungsemail =
|
|
in.Name, in.Strasse, in.PLZ, in.Ort, in.Land, in.UStID, in.Rechnungsemail
|
|
f.accounts[id] = acc
|
|
return acc, nil
|
|
}
|
|
|
|
func (f *fakeStore) GetAccountByEinladungToken(ctx context.Context, token string) (store.Account, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
for _, acc := range f.accounts {
|
|
if acc.EinladungToken == token {
|
|
return acc, nil
|
|
}
|
|
}
|
|
return store.Account{}, store.ErrNotFound
|
|
}
|
|
|
|
func (f *fakeStore) RegenerateEinladungToken(ctx context.Context, accountID, newToken string) error {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
acc, ok := f.accounts[accountID]
|
|
if !ok {
|
|
return store.ErrNotFound
|
|
}
|
|
acc.EinladungToken = newToken
|
|
f.accounts[accountID] = acc
|
|
return 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) SetUserPassword(ctx context.Context, id, passwordHash string) error {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
u, ok := f.users[id]
|
|
if !ok {
|
|
return store.ErrNotFound
|
|
}
|
|
u.PasswordHash = passwordHash
|
|
f.users[id] = u
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeStore) CreatePasswordResetToken(ctx context.Context, userID, token string) (store.PasswordResetToken, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
t := store.PasswordResetToken{
|
|
ID: f.newID(), UserID: userID, Token: token,
|
|
ExpiresAt: time.Now().Add(store.PasswordResetTokenDuration), CreatedAt: time.Now(),
|
|
}
|
|
f.passwordResetTokens[t.ID] = t
|
|
return t, nil
|
|
}
|
|
|
|
func (f *fakeStore) GetValidPasswordResetToken(ctx context.Context, token string) (store.PasswordResetToken, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
for _, t := range f.passwordResetTokens {
|
|
if t.Token == token && t.UsedAt == nil && t.ExpiresAt.After(time.Now()) {
|
|
return t, nil
|
|
}
|
|
}
|
|
return store.PasswordResetToken{}, store.ErrNotFound
|
|
}
|
|
|
|
func (f *fakeStore) MarkPasswordResetTokenUsed(ctx context.Context, id string) error {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
t, ok := f.passwordResetTokens[id]
|
|
if !ok {
|
|
return store.ErrNotFound
|
|
}
|
|
now := time.Now()
|
|
t.UsedAt = &now
|
|
f.passwordResetTokens[id] = t
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeStore) UpsertLoeschfristEinstellung(ctx context.Context, accountID, datenklasseID string, maxTage int) (store.LoeschfristEinstellung, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
if f.loeschfristen[accountID] == nil {
|
|
f.loeschfristen[accountID] = map[string]int{}
|
|
}
|
|
f.loeschfristen[accountID][datenklasseID] = maxTage
|
|
return store.LoeschfristEinstellung{AccountID: accountID, DatenklasseID: datenklasseID, MaxTage: maxTage, UpdatedAt: time.Now()}, nil
|
|
}
|
|
|
|
func (f *fakeStore) ListLoeschfristEinstellungenForAccount(ctx context.Context, accountID string) ([]store.LoeschfristEinstellung, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
var out []store.LoeschfristEinstellung
|
|
for datenklasseID, maxTage := range f.loeschfristen[accountID] {
|
|
out = append(out, store.LoeschfristEinstellung{AccountID: accountID, DatenklasseID: datenklasseID, MaxTage: maxTage})
|
|
}
|
|
return out, 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) CreateImpersonatedSession(ctx context.Context, token, userID, impersonatedByUserID string, expiresAt time.Time) (store.Session, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
sess := store.Session{Token: token, UserID: userID, ImpersonatedByUserID: &impersonatedByUserID, 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,
|
|
Verarbeitungslaender: in.Verarbeitungslaender, AVVVerfuegbar: in.AVVVerfuegbar, AVVURL: in.AVVURL,
|
|
TrainingOptOut: in.TrainingOptOut, TrainingStandard: in.TrainingStandard, DPFZertifiziert: in.DPFZertifiziert, AufbewahrungTage: in.AufbewahrungTage,
|
|
Zertifizierungen: in.Zertifizierungen, Subprozessoren: in.Subprozessoren, 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.Verarbeitungslaender = in.Name, in.Anbieter, in.Verarbeitungslaender
|
|
w.AVVVerfuegbar, w.AVVURL = in.AVVVerfuegbar, in.AVVURL
|
|
w.TrainingOptOut, w.TrainingStandard, w.DPFZertifiziert, w.AufbewahrungTage = in.TrainingOptOut, in.TrainingStandard, in.DPFZertifiziert, in.AufbewahrungTage
|
|
w.Zertifizierungen, w.Subprozessoren, w.GeeigneteZwecke, w.Einschraenkungen = in.Zertifizierungen, in.Subprozessoren, 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
|
|
}
|
|
|
|
func (f *fakeStore) CreateGenehmigerRolle(ctx context.Context, accountID, name, beschreibung string) (store.GenehmigerRolle, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
g := store.GenehmigerRolle{ID: f.newID(), AccountID: accountID, Name: name, Beschreibung: beschreibung, CreatedAt: time.Now()}
|
|
f.genehmigerRollen[g.ID] = g
|
|
return g, nil
|
|
}
|
|
|
|
func (f *fakeStore) GetGenehmigerRolle(ctx context.Context, id string) (store.GenehmigerRolle, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
g, ok := f.genehmigerRollen[id]
|
|
if !ok {
|
|
return store.GenehmigerRolle{}, store.ErrNotFound
|
|
}
|
|
return g, nil
|
|
}
|
|
|
|
func (f *fakeStore) ListGenehmigerRollenForAccount(ctx context.Context, accountID string) ([]store.GenehmigerRolle, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
var out []store.GenehmigerRolle
|
|
for _, g := range f.genehmigerRollen {
|
|
if g.AccountID == accountID {
|
|
out = append(out, g)
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (f *fakeStore) DeleteGenehmigerRolle(ctx context.Context, id string) error {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
if _, ok := f.genehmigerRollen[id]; !ok {
|
|
return store.ErrNotFound
|
|
}
|
|
delete(f.genehmigerRollen, id)
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeStore) AddNutzerGenehmigerRolle(ctx context.Context, userID, genehmigerRolleID string) error {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
if f.nutzerGenehmigerRollen[userID] == nil {
|
|
f.nutzerGenehmigerRollen[userID] = map[string]bool{}
|
|
}
|
|
f.nutzerGenehmigerRollen[userID][genehmigerRolleID] = true
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeStore) RemoveNutzerGenehmigerRolle(ctx context.Context, userID, genehmigerRolleID string) error {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
delete(f.nutzerGenehmigerRollen[userID], genehmigerRolleID)
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeStore) ListGenehmigerRollenForUser(ctx context.Context, userID string) ([]store.GenehmigerRolle, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
var out []store.GenehmigerRolle
|
|
for grID := range f.nutzerGenehmigerRollen[userID] {
|
|
if g, ok := f.genehmigerRollen[grID]; ok {
|
|
out = append(out, g)
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (f *fakeStore) ListNutzerForGenehmigerRolle(ctx context.Context, genehmigerRolleID string) ([]store.GenehmigerMitglied, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
var out []store.GenehmigerMitglied
|
|
for userID, set := range f.nutzerGenehmigerRollen {
|
|
if !set[genehmigerRolleID] {
|
|
continue
|
|
}
|
|
if u, ok := f.users[userID]; ok {
|
|
out = append(out, store.GenehmigerMitglied{UserID: u.ID, Email: u.Email})
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (f *fakeStore) CreateFreigabeRegel(ctx context.Context, accountID, bedingungTyp, bedingungWert, genehmigerRolleID string) (store.FreigabeRegel, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
fr := store.FreigabeRegel{
|
|
ID: f.newID(), AccountID: accountID, BedingungTyp: bedingungTyp,
|
|
BedingungWert: bedingungWert, GenehmigerRolleID: genehmigerRolleID, CreatedAt: time.Now(),
|
|
}
|
|
f.freigabeRegeln[fr.ID] = fr
|
|
return fr, nil
|
|
}
|
|
|
|
func (f *fakeStore) ListFreigabeRegelnForAccount(ctx context.Context, accountID string) ([]store.FreigabeRegel, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
var out []store.FreigabeRegel
|
|
for _, fr := range f.freigabeRegeln {
|
|
if fr.AccountID == accountID {
|
|
out = append(out, fr)
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (f *fakeStore) GetFreigabeRegel(ctx context.Context, id string) (store.FreigabeRegel, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
fr, ok := f.freigabeRegeln[id]
|
|
if !ok {
|
|
return store.FreigabeRegel{}, store.ErrNotFound
|
|
}
|
|
return fr, nil
|
|
}
|
|
|
|
func (f *fakeStore) DeleteFreigabeRegel(ctx context.Context, id string) error {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
if _, ok := f.freigabeRegeln[id]; !ok {
|
|
return store.ErrNotFound
|
|
}
|
|
delete(f.freigabeRegeln, id)
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeStore) CreateFreigabeschritt(ctx context.Context, antragID, genehmigerRolleID string) (store.Freigabeschritt, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
fs := store.Freigabeschritt{
|
|
ID: f.newID(), AntragID: antragID, GenehmigerRolleID: genehmigerRolleID,
|
|
Status: "ausstehend", CreatedAt: time.Now(),
|
|
}
|
|
f.freigabeschritte[fs.ID] = fs
|
|
return fs, nil
|
|
}
|
|
|
|
func (f *fakeStore) GetFreigabeschritt(ctx context.Context, id string) (store.Freigabeschritt, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
fs, ok := f.freigabeschritte[id]
|
|
if !ok {
|
|
return store.Freigabeschritt{}, store.ErrNotFound
|
|
}
|
|
return fs, nil
|
|
}
|
|
|
|
func (f *fakeStore) ListFreigabeschritteForAntrag(ctx context.Context, antragID string) ([]store.Freigabeschritt, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
var out []store.Freigabeschritt
|
|
for _, fs := range f.freigabeschritte {
|
|
if fs.AntragID == antragID {
|
|
out = append(out, fs)
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (f *fakeStore) ListAusstehendeFreigabeschritteForUser(ctx context.Context, userID string) ([]store.Freigabeschritt, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
var out []store.Freigabeschritt
|
|
for grID := range f.nutzerGenehmigerRollen[userID] {
|
|
for _, fs := range f.freigabeschritte {
|
|
if fs.GenehmigerRolleID == grID && fs.Status == "ausstehend" {
|
|
out = append(out, fs)
|
|
}
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (f *fakeStore) EntscheideFreigabeschritt(ctx context.Context, id, status, entschiedenVon, kommentar string) (store.Freigabeschritt, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
fs, ok := f.freigabeschritte[id]
|
|
if !ok || fs.Status != "ausstehend" {
|
|
return store.Freigabeschritt{}, store.ErrNotFound
|
|
}
|
|
now := time.Now()
|
|
fs.Status, fs.EntschiedenVon, fs.EntschiedenAm, fs.Kommentar = status, &entschiedenVon, &now, kommentar
|
|
f.freigabeschritte[id] = fs
|
|
return fs, nil
|
|
}
|
|
|
|
func (f *fakeStore) KaskadiereAblehnung(ctx context.Context, antragID, ausloesenderSchrittID string) error {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
for id, fs := range f.freigabeschritte {
|
|
if fs.AntragID == antragID && fs.Status == "ausstehend" && id != ausloesenderSchrittID {
|
|
fs.Status = "abgelehnt"
|
|
now := time.Now()
|
|
fs.EntschiedenAm = &now
|
|
fs.Kommentar = "Automatisch abgelehnt, da eine andere erforderliche Freigabe für diesen Antrag abgelehnt wurde."
|
|
f.freigabeschritte[id] = fs
|
|
}
|
|
}
|
|
return 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, _ := newServerWithMailer(t, fs)
|
|
return s
|
|
}
|
|
|
|
func newServerWithMailer(t *testing.T, fs *fakeStore) (*web.Server, *mail.FakeMailer) {
|
|
t.Helper()
|
|
fm := &mail.FakeMailer{}
|
|
s, err := web.NewServer(fs, loadTestRegelwerk(t), fm)
|
|
if err != nil {
|
|
t.Fatalf("NewServer: %v", err)
|
|
}
|
|
return s, fm
|
|
}
|
|
|
|
// 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, store.AccountInput{Name: 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
|
|
}
|
|
|
|
// firmenPflichtfelder liefert die seit Migration 0023 bei Firmenanlage
|
|
// (Registrierung und Betreiber-Firmenanlage) verlangten Adress-/
|
|
// Abrechnungsfelder — gemeinsame Basis für Tests, die nicht speziell
|
|
// deren Validierung prüfen, sonst müsste jeder Testfall sie einzeln
|
|
// nachpflegen.
|
|
func firmenPflichtfelder() url.Values {
|
|
return url.Values{
|
|
"strasse": {"Teststraße 1"}, "plz": {"12345"}, "ort": {"Teststadt"},
|
|
"land": {"Deutschland"}, "rechnungsemail": {"rechnung@example.com"},
|
|
}
|
|
}
|
|
|
|
// mergeValues kopiert alle Werte aus extra in base (base wird mutiert
|
|
// und zurückgegeben) — für Tests, die ein Basisformular um wenige
|
|
// eigene Felder ergänzen wollen.
|
|
func mergeValues(base url.Values, extra url.Values) url.Values {
|
|
for k, v := range extra {
|
|
base[k] = v
|
|
}
|
|
return base
|
|
}
|
|
|
|
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", mergeValues(url.Values{
|
|
"account_name": {"Meine Firma"}, "email": {"neu@example.com"},
|
|
"password": {"ein-sicheres-passwort"},
|
|
}, firmenPflichtfelder()))
|
|
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 TestRegisterSeedsStandardGenehmigerRollen(t *testing.T) {
|
|
fs := newFakeStore()
|
|
s := newServer(t, fs)
|
|
|
|
regResp := postForm(t, s, nil, "/register", mergeValues(url.Values{
|
|
"account_name": {"Meine Firma"}, "email": {"neu2@example.com"},
|
|
"password": {"ein-sicheres-passwort"},
|
|
}, firmenPflichtfelder()))
|
|
if regResp.Code != http.StatusSeeOther {
|
|
t.Fatalf("register status = %d, want 303, body: %s", regResp.Code, regResp.Body.String())
|
|
}
|
|
|
|
var accountID string
|
|
for _, u := range fs.users {
|
|
if u.Email == "neu2@example.com" {
|
|
accountID = u.AccountID
|
|
}
|
|
}
|
|
if accountID == "" {
|
|
t.Fatal("neuer Nutzer nicht gefunden")
|
|
}
|
|
|
|
rollen, err := fs.ListGenehmigerRollenForAccount(context.Background(), accountID)
|
|
if err != nil {
|
|
t.Fatalf("ListGenehmigerRollenForAccount: %v", err)
|
|
}
|
|
wantNamen := []string{"Datenschutzbeauftragter", "Geschäftsführer", "KI-Manager", "CISO"}
|
|
if len(rollen) != len(wantNamen) {
|
|
t.Fatalf("got %d Genehmiger-Rollen, want %d", len(rollen), len(wantNamen))
|
|
}
|
|
var namen []string
|
|
for _, r := range rollen {
|
|
namen = append(namen, r.Name)
|
|
}
|
|
for _, want := range wantNamen {
|
|
found := false
|
|
for _, n := range namen {
|
|
if n == want {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatalf("Standard-Rolle %q wurde nicht angelegt, vorhanden: %v", want, namen)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRegisterRejectsDuplicateEmail(t *testing.T) {
|
|
fs := newFakeStore()
|
|
s := newServer(t, fs)
|
|
form := mergeValues(url.Values{
|
|
"account_name": {"A"}, "email": {"doppelt@example.com"},
|
|
"password": {"ein-sicheres-passwort"},
|
|
}, firmenPflichtfelder())
|
|
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(), store.AccountInput{Name: "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(), store.AccountInput{Name: "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(), store.AccountInput{Name: "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())
|
|
}
|
|
}
|