- 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.
115 lines
3.4 KiB
Go
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)
|
|
}
|
|
}
|