feat: add profile sync

This commit is contained in:
Philipp
2026-06-10 16:32:46 +02:00
parent 9947286b1e
commit 000c08dae7
11 changed files with 251 additions and 8 deletions
+1 -1
View File
@@ -28,7 +28,7 @@ func (m Middleware) RequireAuth(next http.Handler) http.Handler {
return
}
next.ServeHTTP(w, r.WithContext(withPrincipal(r.Context(), principal)))
next.ServeHTTP(w, r.WithContext(ContextWithPrincipal(r.Context(), principal)))
})
}
+1 -1
View File
@@ -18,7 +18,7 @@ func PrincipalFromContext(ctx context.Context) (Principal, bool) {
return principal, ok
}
func withPrincipal(ctx context.Context, principal Principal) context.Context {
func ContextWithPrincipal(ctx context.Context, principal Principal) context.Context {
return context.WithValue(ctx, principalContextKey{}, principal)
}
+50
View File
@@ -0,0 +1,50 @@
package profile
import (
"context"
"encoding/json"
"log/slog"
"net/http"
"proxui/backend/internal/auth"
)
type Ensurer interface {
Ensure(ctx context.Context, id string, email string) error
}
type Middleware struct {
ensurer Ensurer
logger *slog.Logger
}
func NewMiddleware(ensurer Ensurer, logger *slog.Logger) Middleware {
return Middleware{
ensurer: ensurer,
logger: logger,
}
}
func (m Middleware) EnsureProfile(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
principal, ok := auth.PrincipalFromRequest(r)
if !ok {
writeError(w, http.StatusUnauthorized, "unauthorized")
return
}
if err := m.ensurer.Ensure(r.Context(), principal.Subject, principal.Email); err != nil {
m.logger.Error("profile sync failed", "error", err)
writeError(w, http.StatusInternalServerError, "profile_sync_failed")
return
}
next.ServeHTTP(w, r)
})
}
func writeError(w http.ResponseWriter, status int, message string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(map[string]string{"error": message})
}
@@ -0,0 +1,99 @@
package profile
import (
"context"
"errors"
"log/slog"
"net/http"
"net/http/httptest"
"testing"
"proxui/backend/internal/auth"
)
func TestEnsureProfileCreatesMissingProfile(t *testing.T) {
ensurer := &stubEnsurer{}
middleware := NewMiddleware(ensurer, slog.Default())
handler := middleware.EnsureProfile(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}))
req := httptest.NewRequest(http.MethodGet, "/me", nil)
req = req.WithContext(auth.ContextWithPrincipal(req.Context(), auth.Principal{
Subject: "00000000-0000-0000-0000-000000000001",
Email: "user@example.test",
Role: "authenticated",
}))
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusNoContent)
}
if ensurer.calls != 1 {
t.Fatalf("Ensure calls = %d, want 1", ensurer.calls)
}
if ensurer.id != "00000000-0000-0000-0000-000000000001" {
t.Fatalf("id = %q", ensurer.id)
}
if ensurer.email != "user@example.test" {
t.Fatalf("email = %q", ensurer.email)
}
}
func TestEnsureProfileRejectsMissingPrincipal(t *testing.T) {
ensurer := &stubEnsurer{}
middleware := NewMiddleware(ensurer, slog.Default())
handler := middleware.EnsureProfile(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}))
req := httptest.NewRequest(http.MethodGet, "/me", nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusUnauthorized)
}
if ensurer.calls != 0 {
t.Fatalf("Ensure calls = %d, want 0", ensurer.calls)
}
}
func TestEnsureProfileReturnsServerErrorOnSyncFailure(t *testing.T) {
ensurer := &stubEnsurer{err: errors.New("db failed")}
middleware := NewMiddleware(ensurer, slog.Default())
handler := middleware.EnsureProfile(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}))
req := httptest.NewRequest(http.MethodGet, "/me", nil)
req = req.WithContext(auth.ContextWithPrincipal(req.Context(), auth.Principal{
Subject: "00000000-0000-0000-0000-000000000001",
Email: "user@example.test",
Role: "authenticated",
}))
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusInternalServerError {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusInternalServerError)
}
}
type stubEnsurer struct {
calls int
id string
email string
err error
}
func (s *stubEnsurer) Ensure(_ context.Context, id string, email string) error {
s.calls++
s.id = id
s.email = email
return s.err
}
+25
View File
@@ -0,0 +1,25 @@
package profile
import (
"context"
"database/sql"
)
type Repository struct {
db *sql.DB
}
func NewRepository(db *sql.DB) Repository {
return Repository{db: db}
}
func (r Repository) Ensure(ctx context.Context, id string, email string) error {
_, err := r.db.ExecContext(ctx, `
insert into public.profiles (id, email)
values ($1, $2)
on conflict (id) do update
set email = excluded.email
where public.profiles.email is distinct from excluded.email
`, id, email)
return err
}