Files
Debian 32ab2c7f47 chore(lint): Backlog auf 0 + golangci-lint als HARTER Gate — v1.3.3
Go-Quality-Baseline-Rollout ABGESCHLOSSEN.

Code-Quality-Backlog (55 → 0):
- errcheck: unbehandelte Close/Rollback/Remove explizit `_ =`; fmt.Sscanf
  `_, _ =` (Zero-Value degradiert sauber).
- unused: toter Code entfernt (nodeIDOrHostname, stripTrailingNewline,
  acme.Service.user, strFold + ungenutzter Import).
- noctx (net/http): http.NewRequestWithContext mit vorhandenem ctx.
- staticcheck: QF1001/S1009/ST1005/SA9003.
- contextcheck: detached-by-design-Stellen mit begründetem //nolint.

Zwei echte Bugs beim Aufräumen gefunden+gefixt:
- backup/remote SFTP-Upload: dst.Close()-Flush-Fehler wurde verschluckt →
  unvollständiges Remote-File galt als Erfolg. Jetzt geprüft+gemeldet.
- haproxy_test: leere if-Assertion (SA9003) testete faktisch nichts →
  echte t.Errorf-Prüfung (kein HSTS für HSTS-disabled Domain).

Bewusste Config-Entscheidungen (.golangci.yml):
- noctx-on-os/exec ausgeschlossen: System-Command-Reloads (systemctl/nft/
  wg/pg) dürfen NICHT an den Request-Context gebunden werden — ein Client-
  Disconnect darf keinen laufenden Reload mitten in der Ausführung killen.
  net/http-noctx bleibt voll aktiv. KEINE exec-Zeile im Code angefasst.
- rowserrcheck/sqlclosecheck raus (database/sql-Linter, bei pgx nur FPs).

Gate scharf gestellt: Makefile release-check ruft golangci-lint jetzt als
HARTEN Gate (install-if-missing, pinned v2.12.2). `make release-check`
grün: vet, golangci-lint, govulncheck, build, test -race.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 00:49:07 +02:00

203 lines
6.8 KiB
Go

package handlers
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/jackc/pgx/v5/pgxpool"
"git.netcell-it.de/projekte/edgeguard-native/internal/database"
oidcsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/oidc"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/session"
usersvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/users"
)
// mockAuth erfüllt oidcsvc.Authenticator und liefert vorgegebene Claims —
// kein echter IdP nötig.
type mockAuth struct {
claims *oidcsvc.Claims
err error
}
func (m *mockAuth) AuthCodeURL(_ context.Context, _, state, _, _ string) (string, error) {
return "https://idp.example/authorize?state=" + state, nil
}
func (m *mockAuth) Exchange(_ context.Context, _, _, _ string) (*oidcsvc.Claims, error) {
return m.claims, m.err
}
func oidcTestSetup(t *testing.T) (*usersvc.Repo, *pgxpool.Pool, *session.Signer) {
t.Helper()
dsn := os.Getenv("EG_FWTEST_DSN")
if dsn == "" {
t.Skip("set EG_FWTEST_DSN to run the oidc handler test")
}
ctx := context.Background()
// Retry: goose-Erst-Apply ist nicht concurrency-safe, wenn mehrere
// guarded Test-Pakete dieselbe frische DB parallel migrieren.
var mErr error
for i := 0; i < 3; i++ {
if mErr = database.Migrate(ctx, dsn); mErr == nil {
break
}
time.Sleep(700 * time.Millisecond)
}
if mErr != nil {
t.Fatalf("migrate: %v", mErr)
}
pool, err := database.Open(ctx, dsn)
if err != nil {
t.Fatalf("open: %v", err)
}
t.Cleanup(pool.Close)
return usersvc.New(pool), pool, session.NewSigner([]byte("0123456789abcdef0123456789abcdef"), nil, 0)
}
func seedUser(t *testing.T, repo *usersvc.Repo, pool *pgxpool.Pool, email, role string, active bool) {
t.Helper()
ctx := context.Background()
_, _ = pool.Exec(ctx, `DELETE FROM users WHERE email=$1`, email)
if _, err := repo.Create(ctx, email, "Sup3rSecret-pw-123", role, active); err != nil {
t.Fatalf("seed user: %v", err)
}
}
func runCallback(t *testing.T, h *OIDCHandler, flow oidcFlow, queryState, code string) *httptest.ResponseRecorder {
t.Helper()
gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
blob, _ := json.Marshal(flow)
signed, err := h.Signer.SignBlob(blob, oidcFlowTTL)
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequestWithContext(context.Background(), http.MethodGet,
"/api/v1/auth/oidc/callback?state="+queryState+"&code="+code, nil)
req.AddCookie(&http.Cookie{Name: oidcFlowCookie, Value: signed}) //nolint:gosec // Test-Cookie — Secure/HttpOnly-Flags für httptest irrelevant
c.Request = req
h.Callback(c)
return rec
}
func sessionCookie(rec *httptest.ResponseRecorder) string {
for _, ck := range rec.Result().Cookies() {
if ck.Name == cookieName && ck.Value != "" && ck.MaxAge >= 0 {
return ck.Value
}
}
return ""
}
func TestCallback_KnownActiveUser_RoleFromDB(t *testing.T) {
users, pool, signer := oidcTestSetup(t)
seedUser(t, users, pool, "sso-viewer@test.local", "viewer", true)
h := NewOIDCHandler(nil, &mockAuth{claims: &oidcsvc.Claims{
Email: "sso-viewer@test.local", EmailVerified: true, Subject: "sub-1", Nonce: "N",
}}, users, signer, nil)
rec := runCallback(t, h, oidcFlow{State: "S", Verifier: "v", Nonce: "N"}, "S", "code")
if loc := rec.Header().Get("Location"); loc != "/dashboard" {
t.Fatalf("expected redirect to /dashboard, got %q (body proves failure path)", loc)
}
raw := sessionCookie(rec)
if raw == "" {
t.Fatal("expected a session cookie to be set")
}
tok, err := signer.Verify(raw)
if err != nil {
t.Fatalf("session token invalid: %v", err)
}
// Kernbeweis: Rolle kommt aus der DB-Row (viewer), nicht aus Claims.
if tok.Role != "viewer" {
t.Errorf("token role = %q, want viewer (role must come from DB)", tok.Role)
}
if tok.Actor != "sso-viewer@test.local" {
t.Errorf("token actor = %q", tok.Actor)
}
}
func TestCallback_UnknownEmail_Rejected(t *testing.T) {
users, pool, signer := oidcTestSetup(t)
_, _ = pool.Exec(context.Background(), `DELETE FROM users WHERE email=$1`, "ghost@test.local")
h := NewOIDCHandler(nil, &mockAuth{claims: &oidcsvc.Claims{
Email: "ghost@test.local", EmailVerified: true, Subject: "x", Nonce: "N",
}}, users, signer, nil)
rec := runCallback(t, h, oidcFlow{State: "S", Nonce: "N"}, "S", "code")
if !strings.Contains(rec.Header().Get("Location"), "sso_error=no_account") {
t.Fatalf("expected sso_error=no_account, got %q", rec.Header().Get("Location"))
}
if sessionCookie(rec) != "" {
t.Fatal("no session cookie expected for unknown user")
}
}
func TestCallback_InactiveUser_Rejected(t *testing.T) {
users, pool, signer := oidcTestSetup(t)
seedUser(t, users, pool, "sso-disabled@test.local", "admin", false)
h := NewOIDCHandler(nil, &mockAuth{claims: &oidcsvc.Claims{
Email: "sso-disabled@test.local", EmailVerified: true, Subject: "x", Nonce: "N",
}}, users, signer, nil)
rec := runCallback(t, h, oidcFlow{State: "S", Nonce: "N"}, "S", "code")
if !strings.Contains(rec.Header().Get("Location"), "sso_error=disabled") {
t.Fatalf("expected sso_error=disabled, got %q", rec.Header().Get("Location"))
}
if sessionCookie(rec) != "" {
t.Fatal("no session cookie expected for inactive user")
}
}
func TestCallback_EmailUnverified_Rejected(t *testing.T) {
users, pool, signer := oidcTestSetup(t)
seedUser(t, users, pool, "sso-unverified@test.local", "admin", true)
h := NewOIDCHandler(nil, &mockAuth{claims: &oidcsvc.Claims{
Email: "sso-unverified@test.local", EmailVerified: false, Subject: "x", Nonce: "N",
}}, users, signer, nil)
rec := runCallback(t, h, oidcFlow{State: "S", Nonce: "N"}, "S", "code")
if !strings.Contains(rec.Header().Get("Location"), "sso_error=unverified") {
t.Fatalf("expected sso_error=unverified, got %q", rec.Header().Get("Location"))
}
if sessionCookie(rec) != "" {
t.Fatal("no session cookie expected for unverified email")
}
}
func TestCallback_NonceMismatch_Rejected(t *testing.T) {
users, pool, signer := oidcTestSetup(t)
seedUser(t, users, pool, "sso-nonce@test.local", "admin", true)
h := NewOIDCHandler(nil, &mockAuth{claims: &oidcsvc.Claims{
Email: "sso-nonce@test.local", EmailVerified: true, Subject: "x", Nonce: "WRONG",
}}, users, signer, nil)
rec := runCallback(t, h, oidcFlow{State: "S", Nonce: "N"}, "S", "code")
if !strings.Contains(rec.Header().Get("Location"), "sso_error=nonce") {
t.Fatalf("expected sso_error=nonce, got %q", rec.Header().Get("Location"))
}
}
func TestCallback_StateMismatch_Rejected(t *testing.T) {
users, _, signer := oidcTestSetup(t)
h := NewOIDCHandler(nil, &mockAuth{claims: &oidcsvc.Claims{}}, users, signer, nil)
rec := runCallback(t, h, oidcFlow{State: "S", Nonce: "N"}, "WRONG", "code")
if !strings.Contains(rec.Header().Get("Location"), "sso_error=state") {
t.Fatalf("expected sso_error=state, got %q", rec.Header().Get("Location"))
}
}