package totp import ( "bytes" "context" "encoding/json" "net/http" "net/http/httptest" "testing" "time" "gitea.perlbach24.de/scripte/nexarch/internal/auth" "gitea.perlbach24.de/scripte/nexarch/internal/user" ) func emailForUser(t *testing.T, users *user.TenantUserStore, userID string) string { t.Helper() u, err := users.Get(context.Background(), userID) if err != nil { t.Fatalf("benutzer laden: %v", err) } return u.Email } // authenticatedRequest baut einen Request mit echtem, gueltigem // Session-Cookie (echter Issuer, echte Verifikation ueber auth.RequireAuth) — // kein simulierter Context, dasselbe Muster wie internal/oidc (IAM-13). func authenticatedRequest(t *testing.T, issuer *auth.TokenIssuer, userID string, req *http.Request) *http.Request { t.Helper() token, err := issuer.Issue(userID, "acme") if err != nil { t.Fatalf("session-token ausstellen: %v", err) } req.AddCookie(&http.Cookie{Name: auth.CookieName, Value: token}) return req } // Akzeptanzkriterium 1 + Pruefung 1: Login ohne 2FA funktioniert normal, // Login mit falschem Passwort liefert die generische Fehlermeldung. func TestHandlerLogin_WithoutTOTP(t *testing.T) { store, users, userID, cleanup := setupTest(t) defer cleanup() login := auth.NewLoginService(users, auth.NewTokenIssuer("test-secret"), "acme") h := NewHandler(users, store, login, "NEXARCH") body, _ := json.Marshal(map[string]string{"email": emailForUser(t, users, userID), "password": "korrektes-passwort"}) req := httptest.NewRequest(http.MethodPost, "/auth/login", bytes.NewReader(body)) rec := httptest.NewRecorder() h.Login(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want 200, body: %s", rec.Code, rec.Body.String()) } if len(rec.Result().Cookies()) == 0 { t.Fatal("erwartet gesetztes session-cookie") } } func TestHandlerLogin_WrongPassword_GivesGenericError(t *testing.T) { store, users, userID, cleanup := setupTest(t) defer cleanup() login := auth.NewLoginService(users, auth.NewTokenIssuer("test-secret"), "acme") h := NewHandler(users, store, login, "NEXARCH") body, _ := json.Marshal(map[string]string{"email": emailForUser(t, users, userID), "password": "falsches-passwort"}) req := httptest.NewRequest(http.MethodPost, "/auth/login", bytes.NewReader(body)) rec := httptest.NewRecorder() h.Login(rec, req) if rec.Code != http.StatusUnauthorized { t.Fatalf("status = %d, want 401", rec.Code) } var resp loginErrorResponse if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { t.Fatalf("response dekodieren: %v", err) } if resp.Error != "Anmeldedaten oder Code ungültig." { t.Fatalf("erwartet generische Fehlermeldung, habe %q (koennte interne Details preisgeben)", resp.Error) } } // Akzeptanzkriterium 1 + 2: mit aktivem 2FA verlangt Login den Code, // Setup-Confirm liefert die Wiederherstellungscodes EINMALIG. func TestHandlerLogin_WithTOTP_RequiresCode(t *testing.T) { store, users, userID, cleanup := setupTest(t) defer cleanup() ctx := context.Background() secret, _, err := store.BeginSetup(ctx, userID, "NEXARCH", "user@example.com") if err != nil { t.Fatalf("begin setup: %v", err) } code, err := GenerateCode(secret, time.Now()) if err != nil { t.Fatalf("code generieren: %v", err) } if _, err := store.ConfirmSetup(ctx, userID, code); err != nil { t.Fatalf("confirm setup: %v", err) } login := auth.NewLoginService(users, auth.NewTokenIssuer("test-secret"), "acme") h := NewHandler(users, store, login, "NEXARCH") email := emailForUser(t, users, userID) // Ohne Code: second_factor_required, kein Cookie. body, _ := json.Marshal(map[string]string{"email": email, "password": "korrektes-passwort"}) req := httptest.NewRequest(http.MethodPost, "/auth/login", bytes.NewReader(body)) rec := httptest.NewRecorder() h.Login(rec, req) if rec.Code != http.StatusUnauthorized { t.Fatalf("ohne code: status = %d, want 401", rec.Code) } var resp loginErrorResponse _ = json.Unmarshal(rec.Body.Bytes(), &resp) if resp.Code != "second_factor_required" { t.Fatalf("erwartet code=second_factor_required, habe %q", resp.Code) } // Mit gueltigem Code: Login gelingt. freshCode, _ := GenerateCode(secret, time.Now()) body2, _ := json.Marshal(map[string]string{"email": email, "password": "korrektes-passwort", "totp_code": freshCode}) req2 := httptest.NewRequest(http.MethodPost, "/auth/login", bytes.NewReader(body2)) rec2 := httptest.NewRecorder() h.Login(rec2, req2) if rec2.Code != http.StatusOK { t.Fatalf("mit code: status = %d, want 200, body: %s", rec2.Code, rec2.Body.String()) } } func TestHandlerSetupConfirm_ReturnsRecoveryCodes(t *testing.T) { store, users, userID, cleanup := setupTest(t) defer cleanup() login := auth.NewLoginService(users, auth.NewTokenIssuer("test-secret"), "acme") sessionIssuer := auth.NewTokenIssuer("test-session-secret") h := NewHandler(users, store, login, "NEXARCH") beginReq := authenticatedRequest(t, sessionIssuer, userID, httptest.NewRequest(http.MethodPost, "/auth/totp/setup/begin", nil)) beginRec := httptest.NewRecorder() auth.RequireAuth(sessionIssuer, h.SetupBegin)(beginRec, beginReq) if beginRec.Code != http.StatusOK { t.Fatalf("setup begin: status = %d, body: %s", beginRec.Code, beginRec.Body.String()) } var beginResp setupBeginResponse if err := json.Unmarshal(beginRec.Body.Bytes(), &beginResp); err != nil { t.Fatalf("begin-response dekodieren: %v", err) } code, err := GenerateCode(beginResp.Secret, time.Now()) if err != nil { t.Fatalf("code generieren: %v", err) } confirmBody, _ := json.Marshal(setupConfirmRequest{Code: code}) confirmReq := authenticatedRequest(t, sessionIssuer, userID, httptest.NewRequest(http.MethodPost, "/auth/totp/setup/confirm", bytes.NewReader(confirmBody))) confirmRec := httptest.NewRecorder() auth.RequireAuth(sessionIssuer, h.SetupConfirm)(confirmRec, confirmReq) if confirmRec.Code != http.StatusOK { t.Fatalf("setup confirm: status = %d, body: %s", confirmRec.Code, confirmRec.Body.String()) } var confirmResp setupConfirmResponse if err := json.Unmarshal(confirmRec.Body.Bytes(), &confirmResp); err != nil { t.Fatalf("confirm-response dekodieren: %v", err) } if len(confirmResp.RecoveryCodes) != RecoveryCodeCount { t.Fatalf("erwartet %d wiederherstellungscodes, habe %d", RecoveryCodeCount, len(confirmResp.RecoveryCodes)) } }