#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.
155 lines
5.5 KiB
Go
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)
|
|
}
|
|
}
|