Files
nexarch/internal/totp/handler_test.go
T

172 lines
6.2 KiB
Go

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))
}
}