Tenant management: - POST /api/tenants — create tenant (creator becomes owner) - GET /api/tenants — list tenants for authenticated user - GET /api/tenants/:id — tenant details with access check - POST /api/tenants/:id/invite — invite user by email (owner/admin) - DELETE /api/tenants/:id/members/:uid — remove member - GET /api/tenants/:id/members — list members New packages: - internal/services/tenant_service.go — CRUD on tenants + user_tenants - internal/handlers/tenant_handler.go — HTTP handlers with auth checks - internal/auth/tenant_resolver.go — X-Tenant-ID header middleware, defaults to user's first tenant for scoped routes Authorization: owners/admins can invite and remove members. Cannot remove the last owner. Users can remove themselves. TenantResolver applies to resource routes (cases, deadlines, etc.) but not tenant management routes.
125 lines
3.2 KiB
Go
125 lines
3.2 KiB
Go
package auth
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
type mockTenantLookup struct {
|
|
tenantID *uuid.UUID
|
|
err error
|
|
}
|
|
|
|
func (m *mockTenantLookup) FirstTenantForUser(ctx context.Context, userID uuid.UUID) (*uuid.UUID, error) {
|
|
return m.tenantID, m.err
|
|
}
|
|
|
|
func TestTenantResolver_FromHeader(t *testing.T) {
|
|
tenantID := uuid.New()
|
|
tr := NewTenantResolver(&mockTenantLookup{})
|
|
|
|
var gotTenantID uuid.UUID
|
|
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
id, ok := TenantFromContext(r.Context())
|
|
if !ok {
|
|
t.Fatal("tenant ID not in context")
|
|
}
|
|
gotTenantID = id
|
|
w.WriteHeader(http.StatusOK)
|
|
})
|
|
|
|
r := httptest.NewRequest("GET", "/api/cases", nil)
|
|
r.Header.Set("X-Tenant-ID", tenantID.String())
|
|
r = r.WithContext(ContextWithUserID(r.Context(), uuid.New()))
|
|
w := httptest.NewRecorder()
|
|
|
|
tr.Resolve(next).ServeHTTP(w, r)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d", w.Code)
|
|
}
|
|
if gotTenantID != tenantID {
|
|
t.Errorf("expected tenant %s, got %s", tenantID, gotTenantID)
|
|
}
|
|
}
|
|
|
|
func TestTenantResolver_DefaultsToFirst(t *testing.T) {
|
|
tenantID := uuid.New()
|
|
tr := NewTenantResolver(&mockTenantLookup{tenantID: &tenantID})
|
|
|
|
var gotTenantID uuid.UUID
|
|
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
id, _ := TenantFromContext(r.Context())
|
|
gotTenantID = id
|
|
w.WriteHeader(http.StatusOK)
|
|
})
|
|
|
|
r := httptest.NewRequest("GET", "/api/cases", nil)
|
|
r = r.WithContext(ContextWithUserID(r.Context(), uuid.New()))
|
|
w := httptest.NewRecorder()
|
|
|
|
tr.Resolve(next).ServeHTTP(w, r)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d", w.Code)
|
|
}
|
|
if gotTenantID != tenantID {
|
|
t.Errorf("expected tenant %s, got %s", tenantID, gotTenantID)
|
|
}
|
|
}
|
|
|
|
func TestTenantResolver_NoUser(t *testing.T) {
|
|
tr := NewTenantResolver(&mockTenantLookup{})
|
|
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
t.Fatal("next should not be called")
|
|
})
|
|
|
|
r := httptest.NewRequest("GET", "/api/cases", nil)
|
|
w := httptest.NewRecorder()
|
|
|
|
tr.Resolve(next).ServeHTTP(w, r)
|
|
|
|
if w.Code != http.StatusUnauthorized {
|
|
t.Errorf("expected 401, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestTenantResolver_InvalidHeader(t *testing.T) {
|
|
tr := NewTenantResolver(&mockTenantLookup{})
|
|
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
t.Fatal("next should not be called")
|
|
})
|
|
|
|
r := httptest.NewRequest("GET", "/api/cases", nil)
|
|
r.Header.Set("X-Tenant-ID", "not-a-uuid")
|
|
r = r.WithContext(ContextWithUserID(r.Context(), uuid.New()))
|
|
w := httptest.NewRecorder()
|
|
|
|
tr.Resolve(next).ServeHTTP(w, r)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("expected 400, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestTenantResolver_NoTenantForUser(t *testing.T) {
|
|
tr := NewTenantResolver(&mockTenantLookup{tenantID: nil})
|
|
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
t.Fatal("next should not be called")
|
|
})
|
|
|
|
r := httptest.NewRequest("GET", "/api/cases", nil)
|
|
r = r.WithContext(ContextWithUserID(r.Context(), uuid.New()))
|
|
w := httptest.NewRecorder()
|
|
|
|
tr.Resolve(next).ServeHTTP(w, r)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("expected 400, got %d", w.Code)
|
|
}
|
|
}
|