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>
203 lines
6.8 KiB
Go
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"))
|
|
}
|
|
}
|