CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (push) Canceled after 0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (push) Canceled after 0s
CI / Core-Modul (Build/Vet/Test) (push) Canceled after 0s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (push) 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
Core-Schnittstellen-Vertragstests / contract-tests (push) Canceled after 0s
Supply-Chain-Scan (Go) / govulncheck (push) Canceled after 0s
RBAC-Rollenhierarchie-Pflichtgate / rbac-policy-invariant (push) Canceled after 0s
184 lines
5.8 KiB
Go
184 lines
5.8 KiB
Go
package authtoken
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"gitea.perlbach24.de/scripte/nexarch/internal/auth"
|
|
"gitea.perlbach24.de/scripte/nexarch/internal/testdb"
|
|
"gitea.perlbach24.de/scripte/nexarch/internal/user"
|
|
)
|
|
|
|
func setupTest(t *testing.T) (*Store, *user.TenantUserStore, string, 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()
|
|
);
|
|
CREATE TABLE IF NOT EXISTS password_tokens (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES users(id),
|
|
token_hash BYTEA NOT NULL UNIQUE, purpose TEXT NOT NULL CHECK (purpose IN ('reset','invite')),
|
|
expires_at TIMESTAMPTZ NOT NULL, used_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
);
|
|
`); err != nil {
|
|
t.Fatalf("schema: %v", err)
|
|
}
|
|
|
|
userStore := user.NewTenantUserStore(pool)
|
|
email := fmt.Sprintf("reset-test-%d@example.com", time.Now().UnixNano())
|
|
u, err := userStore.Create(ctx, email, "Reset Test")
|
|
if err != nil {
|
|
t.Fatalf("testuser anlegen: %v", err)
|
|
}
|
|
|
|
cleanup := func() { pool.Close() }
|
|
return NewStore(pool), userStore, u.ID, cleanup
|
|
}
|
|
|
|
// Akzeptanzkriterium 1 + Pruefung 1: Wiederverwendung eines bereits
|
|
// eingeloesten Tokens wird abgewiesen.
|
|
func TestConsume_RejectsReuse(t *testing.T) {
|
|
store, _, userID, cleanup := setupTest(t)
|
|
defer cleanup()
|
|
ctx := context.Background()
|
|
|
|
token, err := store.Create(ctx, userID, PurposeReset, time.Hour)
|
|
if err != nil {
|
|
t.Fatalf("create: %v", err)
|
|
}
|
|
|
|
gotUserID, err := store.Consume(ctx, token, PurposeReset)
|
|
if err != nil {
|
|
t.Fatalf("erste einloesung: %v", err)
|
|
}
|
|
if gotUserID != userID {
|
|
t.Fatalf("user id = %q, want %q", gotUserID, userID)
|
|
}
|
|
|
|
if _, err := store.Consume(ctx, token, PurposeReset); !errors.Is(err, ErrInvalidToken) {
|
|
t.Fatalf("erwartet ErrInvalidToken bei zweiter einloesung, habe %v", err)
|
|
}
|
|
}
|
|
|
|
// Akzeptanzkriterium 1 + 3 + Pruefung 2: Ablaufzeit wird serverseitig
|
|
// durchgesetzt.
|
|
func TestConsume_RejectsExpiredToken(t *testing.T) {
|
|
store, _, userID, cleanup := setupTest(t)
|
|
defer cleanup()
|
|
ctx := context.Background()
|
|
|
|
token, err := store.Create(ctx, userID, PurposeReset, -1*time.Second) // sofort abgelaufen
|
|
if err != nil {
|
|
t.Fatalf("create: %v", err)
|
|
}
|
|
|
|
if _, err := store.Consume(ctx, token, PurposeReset); !errors.Is(err, ErrInvalidToken) {
|
|
t.Fatalf("erwartet ErrInvalidToken bei abgelaufenem token, habe %v", err)
|
|
}
|
|
}
|
|
|
|
func TestConsume_RejectsUnknownToken(t *testing.T) {
|
|
store, _, _, cleanup := setupTest(t)
|
|
defer cleanup()
|
|
ctx := context.Background()
|
|
|
|
if _, err := store.Consume(ctx, "niemals-ausgestelltes-token", PurposeReset); !errors.Is(err, ErrInvalidToken) {
|
|
t.Fatalf("erwartet ErrInvalidToken, habe %v", err)
|
|
}
|
|
}
|
|
|
|
func TestConsume_RejectsWrongPurpose(t *testing.T) {
|
|
store, _, userID, cleanup := setupTest(t)
|
|
defer cleanup()
|
|
ctx := context.Background()
|
|
|
|
token, err := store.Create(ctx, userID, PurposeInvite, time.Hour)
|
|
if err != nil {
|
|
t.Fatalf("create: %v", err)
|
|
}
|
|
if _, err := store.Consume(ctx, token, PurposeReset); !errors.Is(err, ErrInvalidToken) {
|
|
t.Fatalf("erwartet ErrInvalidToken bei falschem purpose, habe %v", err)
|
|
}
|
|
}
|
|
|
|
// Akzeptanzkriterium 2: Einladungs-Link setzt das Erstpasswort ohne
|
|
// bestehende Session — der Ablauf braucht keinerlei Auth-Middleware/Cookie.
|
|
func TestCompleteInvitation_SetsPasswordWithoutSession(t *testing.T) {
|
|
store, userStore, userID, cleanup := setupTest(t)
|
|
defer cleanup()
|
|
ctx := context.Background()
|
|
|
|
token, err := store.Create(ctx, userID, PurposeInvite, time.Hour)
|
|
if err != nil {
|
|
t.Fatalf("create: %v", err)
|
|
}
|
|
|
|
if err := CompleteInvitation(ctx, store, userStore, token, "neues-erstpasswort-123"); err != nil {
|
|
t.Fatalf("complete invitation: %v", err)
|
|
}
|
|
|
|
u, err := userStore.Get(ctx, userID)
|
|
if err != nil {
|
|
t.Fatalf("user lesen: %v", err)
|
|
}
|
|
creds, err := userStore.GetByEmailForAuth(ctx, u.Email)
|
|
if err != nil {
|
|
t.Fatalf("credentials lesen: %v", err)
|
|
}
|
|
if !auth.VerifyPassword(creds.PasswordHash, "neues-erstpasswort-123") {
|
|
t.Fatal("erwartet, dass das gesetzte erstpasswort verifizierbar ist")
|
|
}
|
|
|
|
// Token ist danach verbraucht, kann nicht doppelt eingeloest werden.
|
|
if err := CompleteInvitation(ctx, store, userStore, token, "irgendwas"); !errors.Is(err, ErrInvalidToken) {
|
|
t.Fatalf("erwartet ErrInvalidToken bei erneuter einloesung, habe %v", err)
|
|
}
|
|
}
|
|
|
|
// Akzeptanzkriterium 3 + Pruefung 3: Token werden nicht im Klartext geloggt.
|
|
func TestCreateAndConsume_NeverLogTokenPlaintext(t *testing.T) {
|
|
store, _, userID, cleanup := setupTest(t)
|
|
defer cleanup()
|
|
ctx := context.Background()
|
|
|
|
var buf bytes.Buffer
|
|
prevLogger := slog.Default()
|
|
slog.SetDefault(slog.New(slog.NewTextHandler(&buf, nil)))
|
|
defer slog.SetDefault(prevLogger)
|
|
|
|
token, err := store.Create(ctx, userID, PurposeReset, time.Hour)
|
|
if err != nil {
|
|
t.Fatalf("create: %v", err)
|
|
}
|
|
if _, err := store.Consume(ctx, "falsches-token-zum-testen-des-log-pfads", PurposeReset); !errors.Is(err, ErrInvalidToken) {
|
|
t.Fatalf("consume (ungueltig): %v", err)
|
|
}
|
|
if _, err := store.Consume(ctx, token, PurposeReset); err != nil {
|
|
t.Fatalf("consume (gueltig): %v", err)
|
|
}
|
|
|
|
logOutput := buf.String()
|
|
if strings.Contains(logOutput, token) {
|
|
t.Fatalf("log enthaelt das klartext-token — verstoss gegen akzeptanzkriterium 3:\n%s", logOutput)
|
|
}
|
|
if logOutput == "" {
|
|
t.Fatal("erwartet ueberhaupt log-ausgabe fuer diesen stichprobentest")
|
|
}
|
|
}
|