Files
paliad/internal/services/supabase_admin_test.go
mAi 3d3a4fa36d feat(team-admin): t-paliad-223 Slice B — Add User via Supabase Admin API
#49 — adds a third "Konto direkt anlegen" path on /admin/team alongside
"Onboard existing" and "Invite colleague". Creates both auth.users (via
Supabase Admin API) and paliad.users in one click; new user is visible in
dropdowns immediately and receives a paliad-branded magic-link email.

- internal/services/supabase_admin.go: new SupabaseAdminClient — thin net/http shim. 3 methods (CreateAuthUser, GenerateRecoveryLink, DeleteAuthUser). 10s timeout. ErrSupabaseAdminUnavailable when key unset, ErrSupabaseEmailExists when 422-with-"already" returned. apikey + Bearer headers on every call. Sentinel errors for handler mapping.
- internal/services/supabase_admin_test.go: 5 tests pin wire-shape (disabled mode, happy-path POST + headers + body, email-exists mapping, both action-link response shapes, DELETE-by-id route).
- internal/services/user_service.go: UserService grows optional supabase + mail + baseURL dependencies via SetAddUserDeps. AdminCreateFullInput (email/display_name/office/job_title/profession/lang/send_welcome_mail + inviter fields). AdminCreateUserFull validates input → calls supabase.CreateAuthUser → inserts paliad.users (best-effort DeleteAuthUser rollback on insert fail) → writes paliad.system_audit_log row (event_type='user.added_by_admin') → sends welcome mail with magic-link (best-effort).
- internal/templates/email/add_user_welcome.{de,en}.html: new template with magic-link CTA + base-URL fallback + firm-name placeholder. Editable through the existing /admin/email-templates editor (admin-overridable via DB).
- internal/services/email_template_*.go: register 'add_user_welcome' as a fourth canonical key, defaultSubjects entry, sample data, variable contract (6 vars).
- internal/services/mail_service_test.go: TestRenderTemplateAddUserWelcome pins both langs render with magic-link + firm + matching subject.
- internal/handlers/admin_users.go: handleAdminCreateFullUser POST /api/admin/users/full. Fills inviter fields from auth.uid() server-side (never trusts the request body). Error map: 503 (unavailable), 409 (email exists / already onboarded), 400 (invalid input), 403 (domain not on whitelist), 500 (other).
- internal/handlers/handlers.go: route registered behind adminGate.
- cmd/server/main.go: LoadSupabaseAdminClient + users.SetAddUserDeps + boot-log line so the deployer knows whether the path is active.
- frontend/src/admin-team.tsx: "Konto direkt anlegen" button + admin-add-full-modal with email/name/office/profession/job_title/lang fields + send-welcome checkbox (default on).
- frontend/src/client/admin-team.ts: initAddFullModal — POST to /api/admin/users/full, inline error handling for 503 / 409 / generic, optimistic insert into users[] on success, name auto-fills from email local-part on blur.
- i18n: +20 keys (admin.team.add.full + admin.team.add_full.*) × DE + EN.

Design picks honoured: Supabase Admin API path (Q1), welcome email default on (Q2), two-step with best-effort rollback (Q3), job_title default 'Associate' (Q4), profession default 'associate' (Q5). Trade-off #3 from §6 (privileged credential broadens trust surface) accepted by m via head.

go build && go test -short ./internal/... + bun run build all green.
2026-05-20 15:19:48 +02:00

155 lines
5.5 KiB
Go

// Unit tests for the Supabase admin HTTP client. The client is a thin
// shim over net/http; coverage lives at the wire-shape level: header
// presence, request method, body decode, status-code → error mapping.
// No live Supabase call — every test runs against an httptest.Server.
package services
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/google/uuid"
)
func TestSupabaseAdminClient_Disabled(t *testing.T) {
c := NewSupabaseAdminClient("https://example.invalid", "")
if c.Enabled() {
t.Fatal("Enabled() must be false when service-role key is empty")
}
ctx := context.Background()
if _, err := c.CreateAuthUser(ctx, "x@hlc.com"); !errors.Is(err, ErrSupabaseAdminUnavailable) {
t.Errorf("CreateAuthUser must return ErrSupabaseAdminUnavailable, got %v", err)
}
if _, err := c.GenerateRecoveryLink(ctx, "x@hlc.com"); !errors.Is(err, ErrSupabaseAdminUnavailable) {
t.Errorf("GenerateRecoveryLink must return ErrSupabaseAdminUnavailable, got %v", err)
}
if err := c.DeleteAuthUser(ctx, uuid.New()); !errors.Is(err, ErrSupabaseAdminUnavailable) {
t.Errorf("DeleteAuthUser must return ErrSupabaseAdminUnavailable, got %v", err)
}
}
// TestSupabaseAdminClient_CreateAuthUser_Happy pins the wire-shape:
// POST /auth/v1/admin/users, JSON body with email_confirm=true, both
// apikey + Authorization headers present, parses the response id.
func TestSupabaseAdminClient_CreateAuthUser_Happy(t *testing.T) {
wantID := uuid.New()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
t.Errorf("method = %q, want POST", r.Method)
}
if r.URL.Path != "/auth/v1/admin/users" {
t.Errorf("path = %q, want /auth/v1/admin/users", r.URL.Path)
}
if r.Header.Get("apikey") != "service-key" {
t.Errorf("missing apikey header")
}
if r.Header.Get("Authorization") != "Bearer service-key" {
t.Errorf("missing Bearer header")
}
body, _ := io.ReadAll(r.Body)
var got map[string]any
_ = json.Unmarshal(body, &got)
if got["email"] != "x@hlc.com" {
t.Errorf("email = %v, want x@hlc.com", got["email"])
}
if got["email_confirm"] != true {
t.Errorf("email_confirm = %v, want true", got["email_confirm"])
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(map[string]string{"id": wantID.String()})
}))
defer srv.Close()
c := NewSupabaseAdminClient(srv.URL, "service-key")
gotID, err := c.CreateAuthUser(context.Background(), " X@HLC.COM ")
if err != nil {
t.Fatalf("CreateAuthUser: %v", err)
}
if gotID != wantID {
t.Errorf("id = %s, want %s", gotID, wantID)
}
}
// TestSupabaseAdminClient_CreateAuthUser_EmailExists pins the 422-with-
// "already" body → ErrSupabaseEmailExists translation. Mapped to 409 by
// the handler.
func TestSupabaseAdminClient_CreateAuthUser_EmailExists(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnprocessableEntity)
_, _ = w.Write([]byte(`{"msg":"A user with this email address has already been registered"}`))
}))
defer srv.Close()
c := NewSupabaseAdminClient(srv.URL, "service-key")
_, err := c.CreateAuthUser(context.Background(), "dup@hlc.com")
if !errors.Is(err, ErrSupabaseEmailExists) {
t.Fatalf("got %v, want ErrSupabaseEmailExists", err)
}
}
// TestSupabaseAdminClient_GenerateRecoveryLink_BothShapes — Supabase has
// historically returned the link at top-level and nested under
// properties. Both shapes must be accepted.
func TestSupabaseAdminClient_GenerateRecoveryLink_BothShapes(t *testing.T) {
for _, tc := range []struct {
name string
body string
want string
}{
{"top-level", `{"action_link":"https://supabase.paliad.de/auth/v1/verify?token=A"}`, "https://supabase.paliad.de/auth/v1/verify?token=A"},
{"nested", `{"properties":{"action_link":"https://supabase.paliad.de/auth/v1/verify?token=B"}}`, "https://supabase.paliad.de/auth/v1/verify?token=B"},
} {
t.Run(tc.name, func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/auth/v1/admin/generate_link" {
t.Errorf("path = %q", r.URL.Path)
}
body, _ := io.ReadAll(r.Body)
if !strings.Contains(string(body), `"type":"recovery"`) {
t.Errorf("body missing type=recovery: %s", body)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(tc.body))
}))
defer srv.Close()
c := NewSupabaseAdminClient(srv.URL, "service-key")
got, err := c.GenerateRecoveryLink(context.Background(), "x@hlc.com")
if err != nil {
t.Fatalf("GenerateRecoveryLink: %v", err)
}
if got != tc.want {
t.Errorf("link = %q, want %q", got, tc.want)
}
})
}
}
// TestSupabaseAdminClient_DeleteAuthUser pins the DELETE-by-id route shape
// + 2xx happy path; the cleanup runs after a paliad.users insert failure
// in AdminCreateUserFull, so the round-trip needs to work even with a
// short context window.
func TestSupabaseAdminClient_DeleteAuthUser(t *testing.T) {
id := uuid.New()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "DELETE" {
t.Errorf("method = %q", r.Method)
}
if r.URL.Path != "/auth/v1/admin/users/"+id.String() {
t.Errorf("path = %q", r.URL.Path)
}
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
c := NewSupabaseAdminClient(srv.URL, "service-key")
if err := c.DeleteAuthUser(context.Background(), id); err != nil {
t.Errorf("DeleteAuthUser: %v", err)
}
}