Files
paliad/internal/auth/require_admin_test.go
m c697fe3418 feat(admin): /admin/team page + admin-only user CRUD (t-paliad-050)
- New auth.RequireAdmin middleware (gates by paliad.users.role='admin')
  with API/browser-aware reject paths and a fail-closed lookup-error 500.
- Service: AdminCreateUser (onboard from existing auth.users), AdminUpdate
  (full profile fields incl. additional_offices), AdminDeleteUser (also
  removes project_teams + department_members memberships and clears any
  led-Dezernat seat — auth.users is left intact), ListUnonboardedAuthUsers,
  IsAdmin (implements auth.AdminLookup).
- Handlers: GET/POST /api/admin/users, GET /api/admin/users/unonboarded,
  PATCH/DELETE /api/admin/users/{id}, plus GET /admin/team for the page.
  All registered through RequireAdminFunc so non-admins get 403/302.
- Refuses to delete the last remaining admin and rejects role='admin'
  assignment via the admin UI (still SQL-only) — same rules as PATCH /api/me.
- /admin/team page: full users table with inline edit (display_name, office,
  role, dezernat, additional_offices, lang), trash with confirm, search +
  office filters, "Onboard existing account" modal driven by
  /api/admin/users/unonboarded, and an Invite button that re-opens the
  shared sidebar invite modal.
- Sidebar gains a hidden Admin section that sidebar.ts reveals after a
  successful /api/me lookup confirms role='admin' (fails closed on error).
- DE+EN i18n strings for the page, modal and table.
- Tests: require_admin_test.go covers admin-allowed, non-admin 403/302,
  unauthenticated 401 and lookup-error fail-closed paths.
2026-04-27 13:40:00 +02:00

115 lines
3.4 KiB
Go

package auth
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"testing"
"github.com/google/uuid"
)
// fakeAdminLookup implements AdminLookup for unit tests.
type fakeAdminLookup struct {
admin bool
err error
}
func (f fakeAdminLookup) IsAdmin(ctx context.Context, id uuid.UUID) (bool, error) {
return f.admin, f.err
}
// withUID returns a request that already has the user-id context value set,
// matching what Client.WithUserID would have populated.
func withUID(req *http.Request, id uuid.UUID) *http.Request {
ctx := context.WithValue(req.Context(), userIDContextKey, id)
return req.WithContext(ctx)
}
func TestRequireAdmin_AllowsAdmin(t *testing.T) {
called := false
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
called = true
w.WriteHeader(http.StatusOK)
})
h := RequireAdmin(fakeAdminLookup{admin: true})(next)
req := withUID(httptest.NewRequest("GET", "/api/admin/users", nil), uuid.New())
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if !called {
t.Fatal("admin user should reach the wrapped handler")
}
if rec.Code != http.StatusOK {
t.Errorf("status: got %d, want 200", rec.Code)
}
}
func TestRequireAdmin_RejectsNonAdminAPI(t *testing.T) {
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("non-admin must not reach the wrapped handler")
})
h := RequireAdmin(fakeAdminLookup{admin: false})(next)
req := withUID(httptest.NewRequest("GET", "/api/admin/users", nil), uuid.New())
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Errorf("API path should 403 for non-admin, got %d", rec.Code)
}
}
func TestRequireAdmin_RedirectsNonAdminBrowser(t *testing.T) {
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("non-admin must not reach the wrapped handler")
})
h := RequireAdmin(fakeAdminLookup{admin: false})(next)
req := withUID(httptest.NewRequest("GET", "/admin/team", nil), uuid.New())
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusFound {
t.Errorf("browser path should 302 for non-admin, got %d", rec.Code)
}
if got := rec.Header().Get("Location"); got != "/dashboard?forbidden=admin" {
t.Errorf("redirect target: got %q", got)
}
}
func TestRequireAdmin_RejectsUnauthenticated(t *testing.T) {
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("unauthenticated must not reach the wrapped handler")
})
h := RequireAdmin(fakeAdminLookup{admin: true})(next)
// No userIDContextKey on the request — simulates a path that didn't
// authenticate first. The middleware must fail closed even when the
// lookup would have approved.
req := httptest.NewRequest("GET", "/api/admin/users", nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Errorf("missing user id should 401, got %d", rec.Code)
}
}
func TestRequireAdmin_FailsClosedOnLookupError(t *testing.T) {
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("lookup-error path must not reach the wrapped handler")
})
h := RequireAdmin(fakeAdminLookup{err: errors.New("db gone")})(next)
req := withUID(httptest.NewRequest("GET", "/api/admin/users", nil), uuid.New())
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusInternalServerError {
t.Errorf("lookup error should 500 (fail closed), got %d", rec.Code)
}
}